Skip to content

feat(cli): REPL 打磨、doctor --probe、lisa upgrade - #372

Merged
oratis merged 7 commits into
mainfrom
claude/opt-cli
Sep 7, 2026
Merged

oratis merged 7 commits into
mainfrom
claude/opt-cli

Conversation

@oratis

@oratis oratis commented Sep 7, 2026

Copy link
Copy Markdown
Owner

第 4 / 8 条,实现 UX-9 与 T-3、T-11 的 CLI 部分。基于 #371。

做了什么

日志噪音与路径(UX-9)。 [proxy] outbound HTTP routed through … 之前每条命令都打印,现在只在 --verbose 或 LISA_DEBUG=1 下出现(serve 启动时仍保留一行)。所有面向用户的路径统一用 ~ 缩写——缺密钥的报错之前会打出 200 多字符的绝对路径。

REPL。 持久化历史(~/.lisa/history,0600,上限 1000 行,跳过空行与连续重复);ANSI 颜色助手,遵守 NO_COLOR、--no-color 和非 TTY;工具调用压缩成单行——运行中 ⚙ bash npm test,结束时 ✓ bash (1.2s) 或 ✗ bash (0.3s): <首行错误>,完整结果只在 --verbose 下展开;发送提示词到首个响应之间显示 spinner(仅 TTY,走 stderr);thinking 事件显示为暗色标记但从不打印思考内容。stdout 仍然只承载 Lisa 的正文,管道用法不受影响。

lisa doctor --probe [url]。 探测运行中后端的 /health,打印版本、运行时长、事件循环延迟、堆内存、租户数;p99 超过 1000ms 标警告;不可达时退出码非零。同时兼容只返回 {ok:true} 的旧服务端。

lisa upgrade。 识别安装方式(Homebrew / npm 全局 / 源码检出),执行对应升级,打印升级前后版本;若 LaunchAgent 已加载则顺带 launchctl kickstart 让守护进程用上新代码——这正是此前需要手工执行 6 条命令的场景。--check 只比对版本,--dry-run 只打印将要执行的命令。

验证

新增逻辑均有测试(渲染器用假的非 TTY writer,探测用端口 0 的本地服务,升级只测纯检测与命令拼装,不联网不真装)。无新增运行时依赖。

🤖 Generated with Claude Code

oratis and others added 7 commits September 7, 2026 23:06
…(UX-9)

Every command — `doctor`, `status`, a one-line prompt — opened with
"[proxy] outbound HTTP routed through …". That line is debug detail on a
one-shot command but the one useful line in serve.log, so:

- proxy-bootstrap.ts: the success banner is now opt-in (`verbose`);
  failures still always log because they are actionable. New
  proxyStatusLine() returns the same text so serve startup / doctor can
  print it deliberately, and there is one wording for both.
- cli.ts: both bootstrap call sites pass verbose = --verbose || LISA_DEBUG
  || subcommand is serve. The first one runs at module load, before
  parseArgs, so cli-args.ts gains a raw-argv isVerboseArgv() helper and
  ParsedArgs.verbose (--verbose) for later use by the renderer.
- New src/cli/display-path.ts: displayPath() collapses $HOME to ~ for
  display only. Applied to the missing-key error (which was one
  200-character line and is now four readable ones), the help Data/
  Plugins/MCP/Heartbeat/Config lines, `lisa channels`, and every path
  doctor prints.

Tests: display-path (child/self/sibling-prefix/outside/trailing-sep),
proxy-bootstrap (quiet default, status line, idempotence, verbose
announces once — via a fresh module instance), cli-args (--verbose,
LISA_DEBUG spellings). npm test 1658 pass / 0 fail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit ef74576c685e66bd18c24e9f8ddb0e5c448f996c)
…r (UX-9)

The review scored the CLI 6/10: `doctor` is the best screen in the product,
but the conversation itself was "plain text, no history, tool calls dumped to
stderr". Five changes, all inside src/cli/:

- `history.ts` + readline wiring: prompts persist in ~/.lisa/history, newest
  1000, mode 0600 (prompts routinely carry pasted secrets), blanks and
  consecutive duplicates dropped. Written atomically on close, and only for a
  real terminal so `lisa < script.txt` never lands in the user's history.
- `ansi.ts`: one decision function for "may this stream carry colour?" —
  --no-color beats everything, then NO_COLOR (no-color.org semantics), then
  TERM=dumb, then "is a TTY". colors.ts now delegates to it instead of
  carrying a second copy of the rule, so doctor/status/monitor pick up
  --no-color for free.
- `render.ts`: one compact stderr line per tool call — "⚙ bash  npm test"
  while running, rewritten in place to "✓ bash (1.2s)" or "✗ bash (0.3s):
  <first line>". Full results stay hidden unless --verbose. A TTY-only
  spinner covers the wait between sending a prompt and the first token, a
  dim "thinking…" marker says reasoning is happening without ever printing
  it, and system_prompt_rebuilt reads as "[soul updated]".
- stdout still carries Lisa's words and nothing else — the `Lisa> ` label
  moved to stderr — so `lisa "…" > answer.md` and `| pbcopy` keep working.
  The renderer tracks the terminal cursor and the stdout stream separately
  for exactly that reason.
- repl.ts takes its streams as options (testable without a TTY) and queues
  line handlers, which also fixes an ERR_USE_AFTER_CLOSE crash when stdin
  ended while a turn was still running.

`--no-color` is parsed in cli-args.ts and recorded once, process-wide.
No new dependencies: the escapes, the spinner and the history file are ~200
lines of hand-rolled code.

Tests: render.test.ts drives the renderer through a fake non-TTY writer with
an injected clock and injected timers (no real interval can leak on CI),
plus ansi/history/repl unit tests. 1700 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit e8f28b99091e9e39251e832f827673d570fc6163)
The tech review's reliability finding was that the daily-driver instance
stalled for 5+ seconds at a time and nothing could see it: launchd's KeepAlive
only restarts a process that exited, and /health answered {ok:true}, which a
wedged event loop keeps saying happily. The server stream is extending /health
with version, uptime, event-loop-lag percentiles, heap and tenant counts; this
is the client that reads them.

  lisa doctor --probe                 # http://127.0.0.1:5757
  lisa doctor --probe 8080            # bare port
  lisa doctor --probe https://host    # any instance

Compatibility is the whole point of a probe, so every telemetry field is
optional and printed only when present — an older server that says {ok:true}
reports as healthy with a "no telemetry" note rather than as broken. /health is
tried first and /healthz second, since which one a deployment exposes has
changed over time.

Event loop lag p99 above 1000ms gets a ⚠ (up but degraded, exit 0); an
unreachable or non-2xx instance exits 1, so `lisa doctor --probe || launchctl
kickstart …` is a usable watchdog line.

`doctor` and `upgrade` join heartbeat/autostart in RAW_SUBCOMMANDS so the
global parser collects their own flags instead of rejecting --probe as unknown.

Tests: probe.test.ts stands up a real http server on port 0 for the rich,
legacy-{ok:true}, /healthz-only, 503, non-JSON and connection-refused cases,
and closes every socket so no handle outlives the test on Node 22 CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit e45b36cb82f51381f57e7a7cb4dffd560cf5ff1b)
…art the daemon

Tech review item 17: "add `lisa upgrade` (npm global upgrade + launchctl
kickstart) so the author's own machine always runs the newest release
candidate". Upgrading today means remembering how Lisa got installed and then
remembering that the login LaunchAgent keeps executing the old code until
something kicks it — both mechanical, both now handled.

  lisa upgrade            detect → upgrade → restart the daemon
  lisa upgrade --check    compare the installed and published versions only
  lisa upgrade --dry-run  print the exact commands, run nothing

Detection reads process.argv[1], its realpath, `brew --prefix` and
`npm prefix -g`. Order matters and is the interesting part: on Apple silicon a
Homebrew shim and an npm global bin are both `/opt/homebrew/bin/lisa`, and the
brew formula wraps the npm package, so the resolved path contains BOTH
`/Cellar/` and `/node_modules/@oratis/lisa/`. `/Cellar/` is conclusive, so it
is checked first; the package path second; the npm global prefix third; a git
checkout last.

Decisions worth recording:
- A source checkout is never upgraded automatically. `git pull` on a working
  tree that may hold uncommitted work is a merge, not an upgrade — so it
  prints the three commands and exits 0.
- `--check` exits 0 whether or not an update exists. It is a report; a cron
  line that mails its output shouldn't look like a failure.
- The kickstart only runs on macOS, only when `autostart status` says the
  agent is actually loaded, and a kickstart failure is a warning — the
  upgrade itself already succeeded.
- AUTOSTART_LABEL is copied from src/autostart/install.ts (that file owns it
  and isn't mine to edit); a test reads the installer and fails on drift.

Every decision is a pure function (detectInstall / upgradeCommands /
compareVersions / kickstartCommand) and every effect is injected, so the 26
tests cover the whole surface with no network and no real install.

`upgrade` joins the subcommand union and RAW_SUBCOMMANDS so --check/--dry-run
reach the handler instead of being rejected as unknown global flags.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit d982c98142a14cd793cc6be636f8cf983094b089)
The two new commands existed but were invisible: `lisa --help` didn't mention
them and tab-completion didn't offer them. Both lists keep their existing
structure — `doctor --probe` sits under its `doctor` entry in INSPECTION,
`upgrade` opens LIFECYCLE, next to the other "keep this machine running"
commands.

While in completions/*, the three files had also fallen behind the CLI: pair,
mail, kb, login, logout and billing shipped without ever being added, and
--verbose / --no-color / --host were missing from the flag lists. Added, since
a completion list that is missing a third of the subcommands teaches people the
wrong command set.

Verified with `bash -n` and `zsh -n`; fish isn't installed on this machine, so
lisa.fish was edited to match the file's existing `complete -c lisa` patterns
and read back rather than parsed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit e89acbb13c52634c66d2a30ce3962292efe43d9b)
AbortSignal.timeout() leaves its timer armed for the full duration even after
the fetch resolves. Harmless in production, but Node 22's test runner — which
CI uses and this machine does not — counts still-pending async activity from a
finished test against it. An own AbortController with an unref'd timer cleared
in a `finally` costs three lines and removes the whole class of problem.

Also adds the case that timer exists for: a server that accepts the connection
and then never answers, which is exactly the wedged-event-loop symptom the
probe was written to diagnose. It now reports "timed out after 150ms" instead
of hanging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 581c2c692be810b61770dedbb5c01779278836ce)
`lisa upgrade` printed npm's first line and stopped. EACCES on a global
install is the single most common way this command fails — it is the whole
reason `sudo npm install -g` is folklore — and #371 had already taught the
Mac wizard the no-sudo prefix fix. The same user got a dead end from the CLI
and a working fix from the app.

classifyUpgradeFailure() mirrors classifyInstallFailure() in
packaging/mac-client/Sources/LisaSetup/BackendSetup.swift, on the same
needles, so the two surfaces cannot drift into contradicting each other about
one shared `npm install -g`. The permissions branch prints that wizard's
commands verbatim.

Classification reads the whole error message, not its first line: runCmd
rejects with `npm exited 1: <stderr>` and the needle is almost never on line
one — the reason the old first-line-only output was uninformative.

Tests cover each class, the multi-line real-world shape, that no branch
hands out a bare `sudo npm install` (that is what creates the root-owned
folder in the first place), and that MIN_NODE_MAJOR tracks
package.json `engines.node`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@oratis
oratis changed the base branch from claude/opt-native to main September 7, 2026 15:13
@oratis

oratis commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

评审 — 承诺逐条实跑;补上失败升级的出路

已 rebase 到 main,base 改为 main,无冲突。

承诺实测(不是读代码,是真跑)

承诺 实测
proxy 日志默认静默 HTTPS_PROXY=… lisa --help → 0 条;加 LISA_DEBUG=1 → 1 条 ✓
路径用 ~ 缩写 缺密钥报错输出 ~/.lisa/config.env(HOME 外的路径仍显示绝对路径,正确) ✓
历史 0600 / 去重 / 限长 实写后 mode: 600,["hello","hello","","world"] → "hello\nworld\n",无残留 .tmp ✓
doctor --probe 不可达时退出非零 exit=1,并提示 try lisa serve --web ✓
upgrade --dry-run 不动源码检出 正确识别为 source checkout 并拒绝触碰工作树 ✓
NO_COLOR / --no-color / 非 TTY 三种情况转义序列数均为 0 ✓

结构性检查

命令执行面干净。 upgrade.ts 把命令构造成 {cmd, args} 常量数组,执行走 launchd.ts 的 runCmd,即 spawn(cmd, args) 不带 shell: true —— 没有注入面。源码检出被刻意排除在自动升级之外("git pull 到别人的工作树上不是升级,是合并"),这个判断是对的。

probe.ts 不带任何令牌,只发 accept: application/json,所以不会把凭据泄给用户随手输入的主机。(附带含义:/health 必须是免鉴权的 —— 我会在评审 #373 时重点检查它新增的租户数/堆内存字段在云端的暴露面。)

"thinking 内容从不打印"是结构性保证,不是约定。 onThinking() 不接受任何参数,而分发处 case "thinking_delta": onThinking(); 直接把事件文本丢弃 —— 即使有人想打印也无从取值。这比一句注释强得多。

spinner 定时器 h.unref(),不会在工作结束后吊住进程。

已修:升级失败后没有出路

失败路径原本是打印 npm 报错的第一行然后停止:

✗ npm exited 1: npm ERR! code EACCES
`npm install -g @oratis/lisa@latest` failed — Lisa is still on 0.24.0

问题有两层:

  1. EACCES 是这条命令最常见的失败——sudo npm install -g 会成为民间偏方就是这么来的。
  2. native: Swift 警告清零并转为错误;Mac 后端安装向导;iOS 可访问性与推送通道 #371 已经给 Mac 向导写好了免 sudo 的 prefix 修复(BackendSetup.permissionsFixScript)。同一个用户,同一条 npm install -g,从 App 得到修复、从 CLI 得到死胡同。

新增 classifyUpgradeFailure(),needle 与 Mac 侧 classifyInstallFailure() 一一对应,两个表面不会在同一条命令上给出互相矛盾的说法。permissions 分支逐字输出向导那几条命令:

npm can't write to its global folder — it's owned by root.
The standard fix moves global packages into your home folder (no sudo), then retries:
  mkdir -p "$HOME/.npm-global"
  npm config set prefix "$HOME/.npm-global"
  grep -qs 'npm-global/bin' "$HOME/.zprofile" || echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> "$HOME/.zprofile"
  export PATH="$HOME/.npm-global/bin:$PATH"
  npm install -g @oratis/lisa@latest

一个实现细节值得说:分类读整条消息而不是第一行。runCmd 以 npm exited 1: <stderr> 形式 reject,而 EACCES / EBADENGINE 这类 needle 几乎从不在第一行——这正是原来"只打印第一行"没有信息量的原因。

测试覆盖四种分类、真实多行报错形状、任何分支都不会给出裸 sudo npm install(那恰恰是 root 属主目录的成因),以及 MIN_NODE_MAJOR 与 package.json 的 engines.node 保持同步。

验证

npm run typecheck 通过;npm test 1817 个,1816 通过,0 失败,1 跳过(PTY,Node 26 已知);npm run build 通过。本 PR 自带约 99 个新测试,质量高——render.test.ts 用假的非 TTY writer 驱动、probe.test.ts 用端口 0,都没有留下真实副作用。

🤖 Generated with Claude Code

@oratis
oratis merged commit bb7598e into main Sep 7, 2026
2 checks passed
oratis added a commit that referenced this pull request Sep 7, 2026
This PR raised engines.node to >=22.19.0 for a real reason (undici calls
worker_threads APIs added in 22.10) and its final commit aligned three
places that still promised Node 20 — both READMEs, both install pages and
`lisa doctor`. Two more were left saying 20:

- **The Mac install wizard.** BackendSetup.minimumNodeMajor was 20 and the
  check compared the major only, so it reported Node 20 — or Node 22.5 —
  as a perfectly good environment and then ran an `npm install -g` that npm
  refuses with EBADENGINE. That is precisely the failure this commit's own
  message set out to remove ("the tool told them their environment was fine
  minutes before it broke"), on a surface it did not check. The floor is now
  major.minor via nodeVersionParts + meetsNodeFloor, and both user-facing
  strings say 22.19.

- **`lisa upgrade`'s node-too-old advice**, whose MIN_NODE_MAJOR was 20.

The drift guard added in #372 is what caught this: `MIN_NODE_MAJOR tracks
package.json engines.node` failed with `20 !== 22` on the integrated tree,
which is the only place the two halves meet.

New Swift tests pin the pair that a major-only check waves through — 22.0
and 22.18 are below the floor, 22.19 is exactly at it — and two existing
tests that hardcoded Node 20 as "at the floor" move with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant