From e4fe8546447f02e2208e1377a911392bab65233b Mon Sep 17 00:00:00 2001 From: HeftyKoo Date: Mon, 19 Oct 2026 11:42:39 +0800 Subject: [PATCH 1/2] feat(cli): add checkbox setup and matrix-style output --- README.md | 2 +- README.zh-CN.md | 2 +- docs/one-command-setup.md | 2 +- docs/store-site/support.md | 2 +- packages/terminal-host/README.md | 4 +- packages/terminal-host/src/cli-help.ts | 114 ++++++++++ packages/terminal-host/src/cli-ui.test.ts | 29 +++ packages/terminal-host/src/cli-ui.ts | 67 ++++++ packages/terminal-host/src/cli.ts | 200 ++++++++++-------- .../src/host.integration.test.ts | 24 +++ packages/terminal-host/src/service.test.ts | 2 +- packages/terminal-host/src/service.ts | 22 +- packages/terminal-host/src/setup-agents.ts | 12 +- .../terminal-host/src/setup-selection.test.ts | 133 +++++++++--- packages/terminal-host/src/setup-selection.ts | 119 ++++++++--- packages/terminal-host/src/setup.ts | 15 +- packages/terminal-host/src/upgrade.ts | 10 +- 17 files changed, 569 insertions(+), 190 deletions(-) create mode 100644 packages/terminal-host/src/cli-help.ts create mode 100644 packages/terminal-host/src/cli-ui.test.ts create mode 100644 packages/terminal-host/src/cli-ui.ts diff --git a/README.md b/README.md index 9f873fd..6fe16b5 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Run this once in your Mac's Terminal: curl -fsSL https://raw.githubusercontent.com/HeftyKoo/AgentOnWeb/main/scripts/install.sh | bash ``` -The installer sets up the local terminal, automatic startup and Chrome connection. It detects Codex, Claude Code and Pi on `PATH`, then shows a terminal menu to choose all or some detected CLI integrations, or skip them. Existing CLI settings are preserved. You can also choose explicitly with `aow setup --agents codex,claude`, `--agents all` or `--agents none`. +The installer sets up the local terminal, automatic startup and Chrome connection. It detects Codex, Claude Code and Pi on `PATH`, then shows a checkbox menu: Up/Down to move, Space to toggle, A to select/clear all, I to invert, and Enter to confirm. Clear all to skip integrations. Existing CLI settings are preserved. You can also choose explicitly with `aow setup --agents codex,claude`, `--agents all` or `--agents none`. The install command works from zsh, bash and fish. When Node's command directory is writable and already on PATH, the installer adds an `aow` entry there, so the same terminal can run `aow service` immediately without sourcing a profile. New zsh/bash windows and detected fish installations also get `aow` on their PATH. If that directory is protected or has an unrelated `aow` command, use `"$HOME/.local/bin/aow" service` directly. diff --git a/README.zh-CN.md b/README.zh-CN.md index 7079c40..3713003 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -50,7 +50,7 @@ AgentOnWeb 将你的编程工作区带到你当前打开的页面上。一边看 curl -fsSL https://raw.githubusercontent.com/HeftyKoo/AgentOnWeb/main/scripts/install.sh | bash ``` -安装器会配置本机终端、登录自动启动和 Chrome 连接,先检测 `PATH` 中的 Codex / Claude Code / Pi,再显示终端交互菜单,让你选择安装全部、部分已检测到的 CLI 集成,或跳过。已有的 CLI 设置会保留。也可以用 `aow setup --agents codex,claude`、`--agents all` 或 `--agents none` 显式选择。 +安装器会配置本机终端、登录自动启动和 Chrome 连接,先检测 `PATH` 中的 Codex / Claude Code / Pi,再显示多选框菜单,默认勾选全部已检测到的 CLI。方向键移动,空格切换勾选,A 全选/全不选,I 反选,回车确认;清空勾选可只配置终端服务,Ctrl+C 或 Esc 取消。已有的 CLI 设置会保留。也可以用 `aow setup --agents codex,claude`、`--agents all` 或 `--agents none` 显式选择。 安装命令兼容 zsh、bash 和 fish。如果 Node 命令目录已在 PATH 中且可写,安装器会在其中创建 `aow` 入口,同一个终端安装后即可运行 `aow service`,无需 source。新开的 zsh/bash 窗口及检测到的 fish 也会配置 `aow` 路径。如果该目录受保护或已有其他程序的 `aow` 命令,可直接运行 `"$HOME/.local/bin/aow" service`。 diff --git a/docs/one-command-setup.md b/docs/one-command-setup.md index c657314..81fffb0 100644 --- a/docs/one-command-setup.md +++ b/docs/one-command-setup.md @@ -20,7 +20,7 @@ For an already-open terminal, the installer also creates a link to the installed ## Choose CLI integrations -By default, both the installer and `aow setup` show a numbered terminal menu for the detected CLIs. Enter a number, several comma-separated numbers (for example `1,2`), or CLI names (for example `codex,claude`). Press Enter or type `all` to choose every detected CLI; enter `0` or `none` to configure only the terminal service. The installer reads the choice from your terminal even when run through `curl | bash`. Cancel with Control+C before integration/service configuration starts. +By default, both the installer and `aow setup` show a checkbox menu for the detected CLIs. All detected CLIs start selected. Use **Up/Down** to move, **Space** to toggle a checkbox, **A** to select/clear all, **I** to invert, and **Enter** to confirm. Clear all checkboxes to configure only the terminal service. The installer reads the choice from your terminal even when run through `curl | bash`. Cancel with Control+C or Esc before integration/service configuration starts. To choose explicitly without an interactive menu: diff --git a/docs/store-site/support.md b/docs/store-site/support.md index 9300646..d013b6b 100644 --- a/docs/store-site/support.md +++ b/docs/store-site/support.md @@ -46,4 +46,4 @@ Terminal host 0.4.1 checks the stable npm version daily without installing code ## Setup, notifications and removal -On macOS + Chrome, `aow setup` configures the service and native pairing, and shows a terminal menu for the Codex/Claude Code/Pi integrations detected on `PATH`. Select all, comma-separated numbers/names, or `0` to skip integrations. For unattended setup use `--agents codex,claude`, `--agents all` or `--agents none`; missing CLIs are always skipped. Review AgentOnWeb hooks in your CLI's `/hooks`, then launch a new Codex or Claude Code session in a new host terminal for session notifications. If an older host is running, finish terminal work, run `aow service uninstall`, then `aow setup`. Before removing the npm package run `aow uninstall`. See [setup, upgrade and uninstall](https://github.com/HeftyKoo/AgentOnWeb/blob/main/docs/one-command-setup.md). +On macOS + Chrome, `aow setup` configures the service and native pairing, and shows a checkbox menu for the Codex/Claude Code/Pi integrations detected on `PATH`. Use Up/Down to move, Space to toggle, A to select/clear all, I to invert, and Enter to confirm. Clear all to skip integrations. For unattended setup use `--agents codex,claude`, `--agents all` or `--agents none`; missing CLIs are always skipped. Review AgentOnWeb hooks in your CLI's `/hooks`, then launch a new Codex or Claude Code session in a new host terminal for session notifications. If an older host is running, finish terminal work, run `aow service uninstall`, then `aow setup`. Before removing the npm package run `aow uninstall`. See [setup, upgrade and uninstall](https://github.com/HeftyKoo/AgentOnWeb/blob/main/docs/one-command-setup.md). diff --git a/packages/terminal-host/README.md b/packages/terminal-host/README.md index 01ddd65..71e76fe 100644 --- a/packages/terminal-host/README.md +++ b/packages/terminal-host/README.md @@ -10,7 +10,7 @@ Install [the Chrome extension](https://chromewebstore.google.com/detail/agentonw curl -fsSL https://raw.githubusercontent.com/HeftyKoo/AgentOnWeb/main/scripts/install.sh | bash ``` -The installer configures the terminal service and Chrome connection. It detects `codex`, `claude` and `pi` executables on `PATH` and asks which notification integrations to install in a numbered terminal menu, including when run through `curl | bash`. Choose all detected CLIs, comma-separated numbers/names, or `0` to skip integrations. Missing CLIs are always skipped. Open a website, click AgentOnWeb and run: +The installer configures the terminal service and Chrome connection. It detects `codex`, `claude` and `pi` executables on `PATH` and asks which notification integrations to install in a checkbox menu, including when run through `curl | bash`. Use Up/Down to move, Space to toggle, A to select/clear all, I to invert, and Enter to confirm. All detected CLIs start selected; clear all to configure the service only. Missing CLIs are always skipped. Open a website, click AgentOnWeb and run: ```sh cd ~/your-project @@ -87,3 +87,5 @@ npm uninstall --global --prefix "$HOME/.local" @agentonweb/terminal-host If you installed through npm directly, uninstall from your original npm prefix instead. User settings, CLI history and backups remain. [Setup and troubleshooting](https://github.com/HeftyKoo/AgentOnWeb/blob/main/docs/one-command-setup.md) · [Agent notifications](https://github.com/HeftyKoo/AgentOnWeb/blob/main/docs/agent-notifications.md) · [Source development](https://github.com/HeftyKoo/AgentOnWeb/blob/main/docs/development.md) + +The human-facing CLI uses a compact Matrix-inspired green theme, grouped help and status labels. Run `aow --help` (or `aow help`) and `aow --help` for details. Help adapts to terminal width; redirected output is plain text. Set `NO_COLOR=1` to disable color. diff --git a/packages/terminal-host/src/cli-help.ts b/packages/terminal-host/src/cli-help.ts new file mode 100644 index 0000000..c6ff8ce --- /dev/null +++ b/packages/terminal-host/src/cli-help.ts @@ -0,0 +1,114 @@ +import { terminalUi } from './cli-ui.js'; + +type HelpSection = { title: string; rows: readonly (readonly [string, string])[] }; +type Help = { usage: string; summary?: string; sections: HelpSection[] }; +export const SETUP_USAGE = 'Usage: aow setup [--agents all|none|codex,claude,pi] [--extension-id CHROME_EXTENSION_ID]'; +const topics: Record = { + main: { + usage: 'Usage: aow [options]', + summary: 'Your local terminal, connected to the web.', + sections: [ + { title: 'CONNECT', rows: [ + ['setup', 'Configure the service and choose agent integrations.'], + ['terminal', 'Run a foreground terminal host (also the default command).'], + ['uninstall', 'Remove AgentOnWeb integrations and the local service.'], + ] }, + { title: 'SERVICE', rows: [ + ['service [open]', 'Open the private browser connection page.'], + ['service install', 'Start the service automatically at login.'], + ['service status', 'Show the service, host version and active terminals.'], + ['service restart [--force]', 'Restart the host; --force ends active tasks.'], + ['service uninstall', 'Remove automatic startup and stop service terminals.'], + ] }, + { title: 'AGENT INTEGRATIONS', rows: [ + ['codex-hooks install|uninstall', 'Manage Codex session hooks.'], + ['claude-hooks install|uninstall', 'Manage Claude Code session hooks.'], + ['pi-extension install|uninstall', 'Manage the Pi extension (Pi 1.1+).'], + ] }, + { title: 'MAINTENANCE', rows: [ + ['check-update', 'Check for a newer stable npm version.'], + ['check-update --enable|--disable', 'Enable or disable automatic update checks.'], + ['update [--force]', 'Upgrade the CLI and restart the macOS service.'], + ['--version', 'Print the installed version.'], + ['-h, --help, help', 'Show this help.'], + ] }, + { title: 'QUICK START', rows: [ + ['aow setup', 'Choose integrations with a checkbox menu.'], + ['aow setup --agents codex,pi', 'Select integrations without prompting.'], + ['aow service status', 'Check the running service.'], + ] }, + ], + }, + setup: { + usage: SETUP_USAGE, + summary: 'Detect CLIs on PATH, then ask which integrations to install with a checkbox menu.', + sections: [ + { title: 'KEYS', rows: [ + ['Up / Down', 'Move between detected agents.'], ['Space', 'Toggle the focused checkbox.'], + ['A / I', 'Select or clear all / invert selection.'], ['Enter', 'Confirm; clear all for the terminal service only.'], + ['Ctrl+C / Esc', 'Cancel before installing integrations or configuring the service.'], + ] }, + { title: 'OPTIONS', rows: [ + ['--agents codex,claude,pi', 'Select a comma-separated subset without prompting.'], + ['--agents all', 'Select every detected CLI without prompting.'], + ['--agents none', 'Configure the terminal service only.'], + ['--extension-id ID', 'Also allow this Chrome extension ID; may be repeated.'], + ] }, + { title: 'NOTES', rows: [ + ['Detection', 'Missing CLIs are always skipped. Selection does not remove existing integrations.'], + ['Terminal', 'Works with curl | bash through /dev/tty. Use --agents when no interactive terminal is available.'], + ] }, + ], + }, + service: { + usage: 'Usage: aow service [options]', + summary: 'Manage the macOS login service (default: open).', + sections: [{ title: 'ACTIONS', rows: [ + ['open', 'Open the private browser connection page.'], + ['install [--no-open]', 'Install automatic startup; optionally skip opening the browser.'], + ['status', 'Show the loaded service, installed/running versions and terminals.'], + ['restart [--force]', 'Restart after closing terminals; --force ends their tasks.'], + ['uninstall', 'Remove automatic startup and stop service terminals.'], + ] }], + }, + 'check-update': { + usage: 'Usage: aow check-update [--enable|--disable]', + summary: 'Check the stable npm version. Explicit checks still work when automatic checks are disabled.', + sections: [{ title: 'OPTIONS', rows: [ + ['--enable', 'Enable automatic update checks.'], ['--disable', 'Disable automatic update checks.'], + ] }], + }, + update: { + usage: 'Usage: aow update [--force]', + summary: 'Install the newer stable npm version into the existing prefix and restart the macOS service. Close terminals first.', + sections: [{ title: 'OPTIONS', rows: [['--force', 'End active terminal tasks before restarting.']] }], + }, + terminal: { + usage: 'Usage: aow terminal', summary: 'Run the terminal host in the foreground. Keep this process running while using your browser terminal.', + sections: [{ title: 'SERVICE', rows: [['aow setup', 'Configure automatic startup on macOS + Chrome.']] }], + }, + uninstall: { + usage: 'Usage: aow uninstall', summary: 'Remove hooks, notification forwarding, the native bridge and the local service.', + sections: [{ title: 'RETAINED DATA', rows: [['Local data', 'Backups, browser grants, pairing receipts and CLI history are retained.']] }], + }, +}; +for (const [command, label] of [['codex-hooks', 'Codex hooks'], ['claude-hooks', 'Claude Code hooks'], ['pi-extension', 'Pi extension']] as const) { + topics[command] = { + usage: `Usage: aow ${command} install|uninstall`, + sections: [{ title: label.toUpperCase(), rows: [ + ['install', `Install AgentOnWeb ${label}; preserve existing settings.`], + ['uninstall', `Remove AgentOnWeb ${label}; preserve other settings.`], + ] }], + }; +} + +export function hasHelp(topic: string) { return Object.hasOwn(topics, topic); } + +export function formatHelp(topic = 'main', ui = terminalUi()) { + const help = topics[topic]!; + return [ui.banner(topic === 'main' ? 'COMMAND INTERFACE' : topic.toUpperCase()), '', + ui.wrap(help.usage), ...(help.summary ? ['', ui.wrap(help.summary)] : []), + ...help.sections.flatMap(section => ['', ui.section(section.title), ui.rows(section.rows)]), + ...(topic === 'main' ? ['', ui.wrap('Run aow --help for options and keyboard controls.')] : []), + ].join('\n'); +} diff --git a/packages/terminal-host/src/cli-ui.test.ts b/packages/terminal-host/src/cli-ui.test.ts new file mode 100644 index 0000000..10bc730 --- /dev/null +++ b/packages/terminal-host/src/cli-ui.test.ts @@ -0,0 +1,29 @@ +import { stripVTControlCharacters } from 'node:util'; +import { expect, it } from 'vitest'; +import { terminalUi } from './cli-ui.js'; +import { formatHelp } from './cli-help.js'; + +const output = (columns: number, isTTY = true) => ({ write: () => true, columns, isTTY }); + +it.each([24, 40, 60, 80, 120])('keeps every help line within a %s-column terminal', columns => { + const ui = terminalUi(output(columns), { TERM: 'xterm-256color' }); + for (const topic of ['main', 'setup', 'service', 'update', 'check-update', 'codex-hooks', 'claude-hooks', 'pi-extension']) { + const help = stripVTControlCharacters(formatHelp(topic, ui)); + for (const line of help.split('\n')) expect(line.length, `${topic}: ${line}`).toBeLessThan(columns); + } +}); + +it('groups commands with descriptions and examples', () => { + const help = formatHelp('main', terminalUi(output(80), {})); + for (const title of ['CONNECT', 'SERVICE', 'AGENT INTEGRATIONS', 'MAINTENANCE', 'QUICK START']) expect(help).toContain(`[ ${title} ]`); + expect(help).toContain('aow setup --agents codex,pi'); + expect(help).toContain('aow --help'); +}); + +it('uses ANSI green on terminals and plain text for pipes, NO_COLOR and TERM=dumb', () => { + expect(terminalUi(output(80), {}).banner()).toContain('\x1b[1;92m'); + for (const [isTTY, env] of [[false, {}], [true, { NO_COLOR: '' }], [true, { NO_COLOR: '1', FORCE_COLOR: '1' }], [true, { TERM: 'dumb' }], [true, { FORCE_COLOR: '0' }]] as const) { + const help = formatHelp('main', terminalUi(output(80, isTTY), env)); + expect(help).not.toContain('\x1b'); + } +}); diff --git a/packages/terminal-host/src/cli-ui.ts b/packages/terminal-host/src/cli-ui.ts new file mode 100644 index 0000000..2e06991 --- /dev/null +++ b/packages/terminal-host/src/cli-ui.ts @@ -0,0 +1,67 @@ +import type { Writable } from 'node:stream'; +import { VERSION } from './version.js'; + +type TerminalOutput = Pick & { isTTY?: boolean; columns?: number }; +type Tone = 'green' | 'muted' | 'warning' | 'error'; +type Status = 'ok' | 'info' | 'skip' | 'warn'; +const colors = { green: '1;92', muted: '32', warning: '33', error: '31' }; + +/** Styling belongs at the human output boundary; protocol/version output stays raw. */ +export function terminalUi(output: TerminalOutput = process.stdout, env = process.env) { + const color = env.NO_COLOR === undefined && env.TERM !== 'dumb' + && (env.FORCE_COLOR !== undefined ? env.FORCE_COLOR !== '0' : Boolean(output.isTTY)); + const width = Math.max(1, Math.min(output.columns || 80, 100) - 1); + const paint = (text: string, tone: Tone = 'green') => color ? `\x1b[${colors[tone]}m${text}\x1b[0m` : text; + const wrap = (text: string, indent = ''): string => { + const prefix = indent.slice(0, Math.max(0, width - 1)); + const size = Math.max(1, width - prefix.length); + return text.split('\n').flatMap(line => { + const lines: string[] = []; + let rest = line; + while (rest.length > size) { + const space = rest.lastIndexOf(' ', size); + const end = space > 0 ? space : size; + lines.push(prefix + rest.slice(0, end)); + rest = rest.slice(end).trimStart(); + } + lines.push(prefix + rest); + return lines; + }).join('\n'); + }; + return { + width, paint, wrap, + banner: (context?: string) => [ + paint(wrap(`AOW // AGENTONWEB v${VERSION}`)), + ...(context ? [paint(wrap(context), 'muted')] : []), + paint('-'.repeat(Math.min(width, 62)), 'muted'), + ].join('\n'), + section: (title: string) => paint(wrap(`[ ${title} ]`)), + rows: (rows: readonly (readonly [string, string])[]) => { + const column = Math.max(...rows.map(([command]) => command.length)) + 2; + return rows.map(([command, description]) => { + if (width < 78 || column > width / 2) { + return paint(wrap(command, ' ')) + '\n' + wrap(description, ' '); + } + const descriptionLines = wrap(description, ' '.repeat(column + 2)).split('\n'); + descriptionLines[0] = ' ' + paint(command.padEnd(column)) + descriptionLines[0]!.slice(column + 2); + return descriptionLines.join('\n'); + }).join('\n'); + }, + status: (kind: Status, text: string) => { + const tag = `[${kind}]`; + const lines = wrap(text, ' '.repeat(tag.length + 1)).split('\n'); + lines[0] = paint(tag, kind === 'warn' ? 'warning' : kind === 'skip' ? 'muted' : 'green') + + ' ' + lines[0]!.slice(tag.length + 1); + return lines.join('\n'); + }, + }; +} + +export function printStatus(kind: Status, text: string) { + console.log(terminalUi().status(kind, text)); +} + +export function printError(error: unknown) { + const ui = terminalUi(process.stderr); + console.error(ui.paint('aow:', 'error') + ' ' + (error instanceof Error ? error.message : String(error))); +} diff --git a/packages/terminal-host/src/cli.ts b/packages/terminal-host/src/cli.ts index bb074a8..88a943a 100644 --- a/packages/terminal-host/src/cli.ts +++ b/packages/terminal-host/src/cli.ts @@ -25,104 +25,115 @@ import { VERSION, installedVersion } from './version.js'; import { Updates } from './updates.js'; import { Maintenance } from './management.js'; import { updateCommand } from './upgrade.js'; +import { formatHelp, hasHelp } from './cli-help.js'; +import { printError, printStatus, terminalUi } from './cli-ui.js'; const inputArgs = process.argv.slice(2); -if (inputArgs.length === 1 && inputArgs[0] === '--version') { - console.log(VERSION); process.exit(0); -} -if (inputArgs[0] === 'update' || inputArgs[0] === 'check-update') { - try { - if (inputArgs[0] === 'update') await updateCommand(inputArgs.slice(1)); - else { - const option = inputArgs[1]; - if (inputArgs.length > 2 || option && !['--enable', '--disable', '--help', '-h'].includes(option)) throw new Error('Usage: aow check-update [--enable|--disable]'); - const updates = new Updates(serviceDirectory); - if (option === '--help' || option === '-h') console.log('Usage: aow check-update [--enable|--disable]\nCheck the stable npm version, or enable/disable automatic checks. Explicit checks still work when disabled.'); - else if (option) { - await updates.configure(option === '--enable'); - console.log(`Automatic update checks ${option === '--enable' ? 'enabled' : 'disabled'}.`); - } else { - const status = await updates.check(true); - if (status.error) throw new Error(status.error); - console.log(`Installed CLI: ${VERSION}\nLatest stable: ${status.latest}\n${status.updateAvailable ? 'Update available. Run aow update from a separate macOS Terminal.' : 'No newer stable version available.'}`); +try { + if (inputArgs.length === 1 && ['--help', '-h', 'help'].includes(inputArgs[0]!)) { + console.log(formatHelp()); process.exit(0); + } + if (inputArgs.length === 2 && (inputArgs[0] === 'help' && hasHelp(inputArgs[1]!) || + ['--help', '-h'].includes(inputArgs[1]!) && hasHelp(inputArgs[0]!))) { + console.log(formatHelp(inputArgs[0] === 'help' ? inputArgs[1] : inputArgs[0])); process.exit(0); + } + if (inputArgs.length === 1 && inputArgs[0] === '--version') { + console.log(VERSION); process.exit(0); + } + if (inputArgs[0] === 'update' || inputArgs[0] === 'check-update') { + try { + if (inputArgs[0] === 'update') await updateCommand(inputArgs.slice(1)); + else { + const option = inputArgs[1]; + if (inputArgs.length > 2 || option && !['--enable', '--disable', '--help', '-h'].includes(option)) throw new Error('Usage: aow check-update [--enable|--disable]'); + const updates = new Updates(serviceDirectory); + if (option === '--help' || option === '-h') console.log(formatHelp('check-update')); + else if (option) { + await updates.configure(option === '--enable'); + printStatus('ok', `Automatic update checks ${option === '--enable' ? 'enabled' : 'disabled'}.`); + } else { + const status = await updates.check(true); + if (status.error) throw new Error(status.error); + printStatus('info', `Installed CLI: ${VERSION}`); + printStatus('info', `Latest stable: ${status.latest}`); + printStatus(status.updateAvailable ? 'warn' : 'ok', status.updateAvailable ? 'Update available. Run aow update from a separate macOS Terminal.' : 'No newer stable version available.'); + } } + } catch (error) { + printError(error); process.exit(1); } - } catch (error) { - console.error(`aow: ${error instanceof Error ? error.message : String(error)}`); process.exit(1); + process.exit(0); } - process.exit(0); -} -if (inputArgs[0] === "native-host") { - await runNativeHost(inputArgs[1] ?? "", serviceDirectory); - process.exit(0); -} -if (inputArgs[0] === "setup") { - await setup(inputArgs.slice(1)); - process.exit(0); -} -if (inputArgs[0] === "uninstall" && inputArgs.length === 1) { - await uninstall(); - process.exit(0); -} -if (inputArgs[0] === "agent-notify") { - const payload = inputArgs.at(-1) ?? ""; - try { - const event = completionEvent(JSON.parse(payload)); - if (event) await reportCodexInput(event); - } catch { /* Notifications must never interfere with Codex. */ } - await forwardNotification(inputArgs.slice(1, -1), payload).catch(() => {}); - process.exit(0); -} -if (inputArgs[0] === "agent-event") { - if (inputArgs.length === 1 || inputArgs.length === 2 && (inputArgs[1] === "claude-code" || inputArgs[1] === "pi")) - await reportAgentEvent(inputArgs[1] === "pi" ? "pi" : inputArgs[1] === "claude-code" ? "claude-code" : "codex").catch(() => {}); - process.exit(0); -} -if (inputArgs[0] === "claude-hooks" && inputArgs[1] === "install" && inputArgs.length === 2) { - console.log(`Installed observational hooks: ${await installClaudeHooks()}\nReview AgentOnWeb session status in Claude Code /hooks, then start a new Claude Code session inside an AgentOnWeb terminal.`); - process.exit(0); -} -if (inputArgs[0] === "claude-hooks" && inputArgs[1] === "uninstall" && inputArgs.length === 2) { - await uninstallClaudeHooks(); - console.log("AgentOnWeb Claude Code hooks removed; other settings preserved."); - process.exit(0); -} -if (inputArgs[0] === "pi-extension" && inputArgs[1] === "install" && inputArgs.length === 2) { - console.log(`Installed observational extension: ${await installPiExtension()}\nStart Pi 1.1+ inside an AgentOnWeb terminal, or use /reload in Pi.`); - process.exit(0); -} -if (inputArgs[0] === "pi-extension" && inputArgs[1] === "uninstall" && inputArgs.length === 2) { - await uninstallPiExtension(); - console.log("AgentOnWeb Pi extension removed; other settings preserved. Use /reload in Pi to unload it."); - process.exit(0); -} -if (inputArgs[0] === "codex-hooks" && inputArgs[1] === "install") { - console.log(`Installed observational hooks: ${await installCodexHooks()}\nIn Codex, open /hooks and review/trust AgentOnWeb session status hooks, then start a new session inside an AgentOnWeb terminal.`); - process.exit(0); -} -if (inputArgs[0] === "codex-hooks" && inputArgs[1] === "uninstall") { - await uninstallCodexHooks(); - console.log('AgentOnWeb hooks removed and the previous notify command restored.'); - process.exit(0); -} -if (inputArgs[0] === "service") { - try { - await serviceCommand(inputArgs.slice(1)); - } catch (error) { - console.error(`aow: ${error instanceof Error ? error.message : String(error)}`); - process.exit(1); + if (inputArgs[0] === "native-host") { + await runNativeHost(inputArgs[1] ?? "", serviceDirectory); + process.exit(0); } - process.exit(0); -} -if (inputArgs.length === 1 && inputArgs[0] === "--help") { - console.log( - "Usage: aow setup | aow uninstall | aow terminal | aow --version | aow check-update [--enable|--disable] | aow update [--force] | aow codex-hooks install|uninstall | aow claude-hooks install|uninstall | aow pi-extension install|uninstall | aow service [open|install|status|restart [--force]|uninstall] (default: open)", - "\nRun aow setup --help to select CLI integrations with --agents.", - ); - process.exit(0); -} -if (inputArgs.length && (inputArgs[0] !== "terminal" || inputArgs.slice(1).some((arg) => arg !== "--service"))) { - throw new Error("Usage: aow setup | aow terminal | aow service install | aow --help"); + if (inputArgs[0] === "setup") { + await setup(inputArgs.slice(1)); + process.exit(0); + } + if (inputArgs[0] === "uninstall" && inputArgs.length === 1) { + await uninstall(); + process.exit(0); + } + if (inputArgs[0] === "agent-notify") { + const payload = inputArgs.at(-1) ?? ""; + try { + const event = completionEvent(JSON.parse(payload)); + if (event) await reportCodexInput(event); + } catch { /* Notifications must never interfere with Codex. */ } + await forwardNotification(inputArgs.slice(1, -1), payload).catch(() => {}); + process.exit(0); + } + if (inputArgs[0] === "agent-event") { + if (inputArgs.length === 1 || inputArgs.length === 2 && (inputArgs[1] === "claude-code" || inputArgs[1] === "pi")) + await reportAgentEvent(inputArgs[1] === "pi" ? "pi" : inputArgs[1] === "claude-code" ? "claude-code" : "codex").catch(() => {}); + process.exit(0); + } + if (inputArgs[0] === "claude-hooks" && inputArgs[1] === "install" && inputArgs.length === 2) { + printStatus('ok', `Installed observational hooks: ${await installClaudeHooks()}`); + printStatus('info', 'Review AgentOnWeb session status in Claude Code /hooks, then start a new Claude Code session inside an AgentOnWeb terminal.'); + process.exit(0); + } + if (inputArgs[0] === "claude-hooks" && inputArgs[1] === "uninstall" && inputArgs.length === 2) { + await uninstallClaudeHooks(); + printStatus('ok', 'AgentOnWeb Claude Code hooks removed; other settings preserved.'); + process.exit(0); + } + if (inputArgs[0] === "pi-extension" && inputArgs[1] === "install" && inputArgs.length === 2) { + printStatus('ok', `Installed observational extension: ${await installPiExtension()}`); + printStatus('info', 'Start Pi 1.1+ inside an AgentOnWeb terminal, or use /reload in Pi.'); + process.exit(0); + } + if (inputArgs[0] === "pi-extension" && inputArgs[1] === "uninstall" && inputArgs.length === 2) { + await uninstallPiExtension(); + printStatus('ok', 'AgentOnWeb Pi extension removed; other settings preserved. Use /reload in Pi to unload it.'); + process.exit(0); + } + if (inputArgs[0] === "codex-hooks" && inputArgs[1] === "install" && inputArgs.length === 2) { + printStatus('ok', `Installed observational hooks: ${await installCodexHooks()}`); + printStatus('info', 'In Codex, open /hooks and review/trust AgentOnWeb session status hooks, then start a new session inside an AgentOnWeb terminal.'); + process.exit(0); + } + if (inputArgs[0] === "codex-hooks" && inputArgs[1] === "uninstall" && inputArgs.length === 2) { + await uninstallCodexHooks(); + printStatus('ok', 'AgentOnWeb hooks removed and the previous notify command restored.'); + process.exit(0); + } + if (inputArgs[0] === "service") { + try { + await serviceCommand(inputArgs.slice(1)); + } catch (error) { + printError(error); + process.exit(1); + } + process.exit(0); + } + if (inputArgs.length && (inputArgs[0] !== "terminal" || inputArgs.slice(1).some((arg) => arg !== "--service"))) { + throw new Error("Usage: aow setup | aow terminal | aow service install | aow --help"); + } +} catch (error) { + printError(error); process.exit(1); } const { command, args } = shellLaunch(); const executable = await executablePath(command); @@ -562,11 +573,12 @@ authority.onRevoke(() => { connector.refreshSurfaces(); }); console.log( - `AgentOnWeb · Local shell\nExecutable: ${executable}\nInitial directory: ${cwd}\nConnector: ${connector.port}\nOpen once: ${origin}/launch?token=${launchToken}\nApprove your browser through the private setup page.`, + `${terminalUi().banner('LOCAL SHELL')}\nExecutable: ${executable}\nInitial directory: ${cwd}\nConnector: ${connector.port}\nOpen once: ${origin}/launch?token=${launchToken}`, ); +printStatus('info', 'Approve your browser through the private setup page.'); await saveEndpoint(); const stopUpdateChecks = updates.start(status => { - console.log(`AgentOnWeb ${status.latest} is available (running ${VERSION}). Run aow update after closing terminal sessions.`); + printStatus('warn', `AgentOnWeb ${status.latest} is available (running ${VERSION}). Run aow update after closing terminal sessions.`); }); let stopping = false; async function stop() { diff --git a/packages/terminal-host/src/host.integration.test.ts b/packages/terminal-host/src/host.integration.test.ts index 6a60145..e3ec39d 100644 --- a/packages/terminal-host/src/host.integration.test.ts +++ b/packages/terminal-host/src/host.integration.test.ts @@ -126,6 +126,30 @@ it('prints service help and usage errors without a Node stack trace', () => { expect(invalid.stdout).toBe(''); expect(invalid.stderr).toMatch(/^aow: Usage: aow service [^\n]+\n$/); }); +it.each([ + ['--help'], ['-h'], ['help'], ['help', 'setup'], ['setup', '--help'], + ['service', '-h'], ['terminal', '--help'], ['uninstall', '--help'], + ['update', '--help'], ['check-update', '-h'], ['codex-hooks', '--help'], + ['claude-hooks', '--help'], ['pi-extension', '--help'], +])('prints structured help without starting a host or modifying integrations: %j', (...args) => { + const result = spawnSync(process.execPath, [resolve('packages/terminal-host/lib/cli.js'), ...args], { + encoding: 'utf8', env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: undefined }, + }); + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout).toContain('AOW // AGENTONWEB'); + expect(result.stdout).toContain('Usage: aow'); + expect(result.stdout).not.toContain('\x1b'); + expect(result.stdout).not.toContain('Open once:'); +}); +it('keeps version output machine-readable even with forced color', () => { + const result = spawnSync(process.execPath, [resolve('packages/terminal-host/lib/cli.js'), '--version'], { + encoding: 'utf8', env: { ...process.env, NO_COLOR: undefined, FORCE_COLOR: '1' }, + }); + expect(result.status).toBe(0); + expect(result.stdout).toBe(VERSION + '\n'); + expect(result.stderr).toBe(''); +}); it("keeps shell sessions alive across viewers and isolates setup authorization from delegated terminals", async () => { directory = await mkdtemp(join(tmpdir(), "aow-shell-test-")); host = spawn(process.execPath, [resolve("packages/terminal-host/lib/cli.js"), "terminal"], { diff --git a/packages/terminal-host/src/service.test.ts b/packages/terminal-host/src/service.test.ts index bfbd441..a900ac1 100644 --- a/packages/terminal-host/src/service.test.ts +++ b/packages/terminal-host/src/service.test.ts @@ -84,7 +84,7 @@ it('restarts after maintenance and confirms the new PID and running version', as })); await serviceCommand(['restart']); expect(bootedOut).toBe(true); expect(bootstrapped).toBe(true); - expect(log).toHaveBeenCalledWith(`AgentOnWeb service is running version ${VERSION}.`); + expect(log).toHaveBeenCalledWith(expect.stringContaining(`AgentOnWeb service is running version ${VERSION}.`)); }); it('reports readiness failure instead of claiming a successful restart', async () => { diff --git a/packages/terminal-host/src/service.ts b/packages/terminal-host/src/service.ts index 1c5b9ac..052070a 100644 --- a/packages/terminal-host/src/service.ts +++ b/packages/terminal-host/src/service.ts @@ -5,6 +5,8 @@ import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { hostRequest, liveEndpoint, prepareHost } from './management.js'; import { VERSION } from './version.js'; +import { formatHelp } from './cli-help.js'; +import { printStatus } from './cli-ui.js'; const label = 'com.agentonweb.terminal'; export const serviceDirectory = join(homedir(), '.agentonweb', 'terminal'); @@ -24,7 +26,7 @@ export function launchAgent(node: string, entry: string, home: string, path: str export async function serviceCommand(args: string[]): Promise { const usage = 'Usage: aow service [open | install [--no-open] | status | restart [--force] | uninstall] (default: open)'; if (args.length === 1 && (args[0] === '--help' || args[0] === '-h')) { - console.log(usage); + console.log(formatHelp('service')); return; } const action = args[0] ?? 'open'; @@ -44,20 +46,20 @@ export async function serviceCommand(args: string[]): Promise { if (!running) { execFileSync('/bin/launchctl', ['bootstrap', domain, plist], { stdio: 'pipe' }); } - console.log(running ? 'Service configuration updated for next login. Existing terminals remain running.' : 'AgentOnWeb terminal service starts automatically at login.'); + printStatus('ok', running ? 'Service configuration updated for next login. Existing terminals remain running.' : 'AgentOnWeb terminal service starts automatically at login.'); if (!args.includes('--no-open')) await openService(); } else if (action === 'open') await openService(); else if (action === 'status') { - try { execFileSync('/bin/launchctl', ['print', domain + '/' + label], { stdio: 'ignore' }); console.log('AgentOnWeb terminal service is loaded.'); } - catch { console.log('AgentOnWeb terminal service is not loaded.'); } - console.log(`Installed CLI: ${VERSION}`); + try { execFileSync('/bin/launchctl', ['print', domain + '/' + label], { stdio: 'ignore' }); printStatus('ok', 'AgentOnWeb terminal service is loaded.'); } + catch { printStatus('warn', 'AgentOnWeb terminal service is not loaded.'); } + printStatus('info', `Installed CLI: ${VERSION}`); const endpoint = await liveEndpoint(serviceDirectory); if (endpoint) { try { const status = await hostRequest(endpoint); - console.log(`Running host: ${status.version} · ${status.activeTerminals} active terminal(s).`); - if (status.version !== VERSION) console.log('Installed version differs from the running host. Run aow service restart after closing terminals.'); - } catch { console.log('Running host version unavailable. Finish terminal work and restart the older host manually.'); } + printStatus('info', `Running host: ${status.version} · ${status.activeTerminals} active terminal(s).`); + if (status.version !== VERSION) printStatus('warn', 'Installed version differs from the running host. Run aow service restart after closing terminals.'); + } catch { printStatus('warn', 'Running host version unavailable. Finish terminal work and restart the older host manually.'); } } } else if (action === 'restart') { const prepared = await prepareHost(serviceDirectory, args.includes('--force')); @@ -66,7 +68,7 @@ export async function serviceCommand(args: string[]): Promise { } else if (action === 'uninstall') { try { execFileSync('/bin/launchctl', ['bootout', domain + '/' + label], { stdio: 'pipe' }); } catch {} await unlink(plist).catch(error => { if (error.code !== 'ENOENT') throw error; }); - console.log('Automatic startup removed. Service terminals stopped; saved CLI history is unchanged.'); + printStatus('ok', 'Automatic startup removed. Service terminals stopped; saved CLI history is unchanged.'); } } export function serviceLoaded(): boolean { @@ -94,7 +96,7 @@ export async function reloadService(expectedVersion: string) { if (endpoint && endpoint.pid !== old?.pid) { const status = await hostRequest(endpoint); if (status.version === expectedVersion && status.managedService) { - console.log(`AgentOnWeb service is running version ${expectedVersion}.`); return; + printStatus('ok', `AgentOnWeb service is running version ${expectedVersion}.`); return; } } } catch { /* Wait for the new host to publish its endpoint. */ } diff --git a/packages/terminal-host/src/setup-agents.ts b/packages/terminal-host/src/setup-agents.ts index 7647e29..6a8fe46 100644 --- a/packages/terminal-host/src/setup-agents.ts +++ b/packages/terminal-host/src/setup-agents.ts @@ -3,6 +3,7 @@ import { installCodexHooks } from './codex-hooks.js'; import { installClaudeHooks } from './claude-hooks.js'; import { installPiExtension } from './pi-hooks.js'; import { promptAgentSelection } from './setup-selection.js'; +import { printStatus } from './cli-ui.js'; export const SETUP_AGENTS = ['codex', 'claude', 'pi'] as const; export type SetupAgent = typeof SETUP_AGENTS[number]; @@ -23,18 +24,18 @@ export async function planAgentSetup(requested?: readonly SetupAgent[]): Promise catch { return { integration, path: undefined }; } })); const names = detected.filter(value => value.path).map(value => value.integration.name); - console.log(`Detected CLIs on PATH: ${names.length ? names.join(', ') : 'none'}.`); + printStatus('info', `Detected CLIs on PATH: ${names.length ? names.join(', ') : 'none'}.`); const available = detected.filter(value => value.path).map(value => value.integration.id); const selected = requested ?? (available.length ? await promptAgentSelection(available) : []); for (const { integration, path } of detected) { const label = `${integration.name} ${integration.kind}`; if (!path) { - console.log(`Skipped ${label}: CLI "${integration.id}" not found on PATH.`); + printStatus('skip', `${label}: CLI "${integration.id}" not found on PATH.`); } else if (!selected.includes(integration.id)) { - console.log(`Skipped ${label}: not selected.`); + printStatus('skip', `${label}: not selected.`); } } - if (!names.length) console.log('Install your preferred CLI and add it to PATH, then rerun aow setup to enable its notifications.'); + if (!names.length) printStatus('info', 'Install your preferred CLI and add it to PATH, then rerun aow setup to enable its notifications.'); return available.filter(agent => selected.includes(agent)); } @@ -42,6 +43,7 @@ export async function installSelectedAgents(selected: readonly SetupAgent[]): Pr for (const integration of integrations) { if (!selected.includes(integration.id)) continue; await integration.install(); - console.log(`${integration.name} ${integration.kind} installed; existing settings preserved. ${integration.guidance}`); + printStatus('ok', `${integration.name} ${integration.kind} installed; existing settings preserved.`); + printStatus('info', integration.guidance); } } diff --git a/packages/terminal-host/src/setup-selection.test.ts b/packages/terminal-host/src/setup-selection.test.ts index 505cce6..04a85d0 100644 --- a/packages/terminal-host/src/setup-selection.test.ts +++ b/packages/terminal-host/src/setup-selection.test.ts @@ -1,56 +1,121 @@ import { PassThrough } from 'node:stream'; +import type { ReadStream, WriteStream } from 'node:tty'; import * as fs from 'node:fs'; -import { afterEach, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { readAgentSelection, promptAgentSelection } from './setup-selection.js'; +import type { SetupAgent } from './setup-agents.js'; vi.mock('node:fs', async importOriginal => { const original = await importOriginal(); return { ...original, openSync: vi.fn(original.openSync) }; }); -afterEach(() => vi.restoreAllMocks()); +beforeEach(() => vi.stubEnv('TERM', 'xterm-256color')); +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); }); -async function choose(answers: string[]) { - const input = new PassThrough(); - const output = new PassThrough(); +function terminal(columns = 80, raw = false) { + const input = Object.assign(new PassThrough(), { isTTY: true, isRaw: raw, setRawMode: vi.fn() }); + const output = Object.assign(new PassThrough(), { isTTY: true, columns }); let text = ''; - output.on('data', chunk => { - text += String(chunk); - if (String(chunk).includes('Selection [all]:')) queueMicrotask(() => { - const answer = answers.shift(); - if (answer === undefined) input.end(); - else input.write(answer + '\n'); - }); - }); - try { return { selected: await readAgentSelection(['codex', 'claude', 'pi'], input, output), text }; } - finally { input.destroy(); output.destroy(); } + output.on('data', chunk => { text += String(chunk); }); + return { input, output, text: () => text, read: (agents: readonly SetupAgent[] = ['codex', 'claude', 'pi']) => + readAgentSelection(agents, input as unknown as ReadStream, output as unknown as WriteStream) }; } -it.each([ - ['', ['codex', 'claude', 'pi']], - ['all', ['codex', 'claude', 'pi']], - ['1,3', ['codex', 'pi']], - ['claude, codex,claude', ['claude', 'codex']], - ['0', []], - ['none', []], -])('accepts terminal selection %s', async (answer, selected) => { - const result = await choose([answer as string]); - expect(result.selected).toEqual(selected); - expect(result.text).toContain('1) Codex hooks'); - expect(result.text).toContain('2) Claude Code hooks'); - expect(result.text).toContain('3) Pi extension'); +async function choose(keys: string, agents?: readonly SetupAgent[]) { + const term = terminal(); + const result = term.read(agents); + term.input.write(keys); + try { return { selected: await result, text: term.text(), term }; } + finally { term.input.destroy(); term.output.destroy(); } +} + +it('shows checkboxes with every detected CLI selected by default', async () => { + const result = await choose('\r'); + expect(result.selected).toEqual(['codex', 'claude', 'pi']); + expect(result.text).toContain('> [x] Codex hooks'); + expect(result.text).toContain(' [x] Claude Code hooks'); + expect(result.text).toContain(' [x] Pi extension'); + expect(result.text).toContain('Space toggle'); + expect(result.text).not.toContain('Selection [all]'); + expect(result.text).toContain('Selected: Codex hooks, Claude Code hooks, Pi extension.'); +}); + +it.each(Array.from({ length: 8 }, (_, mask) => [mask, ['codex', 'claude', 'pi'].filter((_, i) => mask & (1 << i))]))( + 'supports any subset with Space and arrow keys (mask %s)', async (mask, selected) => { + let keys = 'a'; + for (let i = 0; i < 3; i++) keys += (Number(mask) & (1 << i) ? ' ' : '') + '\x1b[B'; + expect((await choose(keys + '\r')).selected).toEqual(selected); + }, +); + +it('supports select/clear all and invert without a required selection', async () => { + expect((await choose('a\r')).selected).toEqual([]); + expect((await choose('aa\r')).selected).toEqual(['codex', 'claude', 'pi']); + expect((await choose(' i\r')).selected).toEqual(['codex']); + expect((await choose('a\r')).text).toContain('terminal service only'); +}); + +it('wraps navigation and only offers detected CLIs', async () => { + expect((await choose('\x1b[A \r', ['codex', 'pi'])).selected).toEqual(['codex']); }); -it('reprompts invalid or unavailable choices without accepting a partial selection', async () => { - const result = await choose(['codex,unknown', '4', '2']); - expect(result.selected).toEqual(['claude']); - expect(result.text.match(/Selection \[all\]:/g)).toHaveLength(3); +it('ignores typed numbers and does not treat them as selection', async () => { + expect((await choose('1,3\r')).selected).toEqual(['codex', 'claude', 'pi']); }); -it('cancels on EOF instead of silently accepting the default', async () => { - await expect(choose([])).rejects.toThrow('Setup cancelled'); +it.each(['\x03', '\x04', '\x1b'])('cancels with keyboard input %j and restores terminal state', async keys => { + const term = terminal(); + const sigint = process.listenerCount('SIGINT'); + const sigterm = process.listenerCount('SIGTERM'); + const result = term.read(); + term.input.write(keys); + await expect(result).rejects.toThrow('Setup cancelled before installing'); + expect(term.input.setRawMode.mock.calls).toEqual([[true], [false]]); + expect(term.input.listenerCount('keypress')).toBe(0); + expect(term.output.listenerCount('resize')).toBe(0); + expect(process.listenerCount('SIGINT')).toBe(sigint); + expect(process.listenerCount('SIGTERM')).toBe(sigterm); + expect(term.input.isPaused()).toBe(true); + expect(term.text()).toContain('\x1b[?25h'); + term.input.destroy(); term.output.destroy(); +}); + +it('cancels on EOF rather than accepting the default', async () => { + const term = terminal(); + const result = term.read(); + term.input.end(); + await expect(result).rejects.toThrow('Setup cancelled'); + expect(term.input.setRawMode).toHaveBeenLastCalledWith(false); + term.output.destroy(); +}); + +it('restores pre-existing raw mode and handles resize without losing selection', async () => { + const term = terminal(80, true); + const result = term.read(); + term.input.write(' '); + term.output.columns = 40; + term.output.emit('resize'); + term.input.write('\r'); + expect(await result).toEqual(['claude', 'pi']); + expect(term.input.setRawMode).toHaveBeenLastCalledWith(true); + expect(term.text()).toContain('2/3 selected.'); + term.input.destroy(); term.output.destroy(); +}); + +it('does not let buffered keys change a confirmed selection', async () => { + expect((await choose('\r a')).selected).toEqual(['codex', 'claude', 'pi']); }); it('requires explicit --agents when there is no controlling terminal', async () => { vi.mocked(fs.openSync).mockImplementationOnce(() => { throw new Error('No controlling terminal'); }); await expect(promptAgentSelection(['codex'])).rejects.toThrow('pass --agents'); }); + +it('does not show a keyboard prompt on an unsupported terminal', async () => { + vi.stubEnv('TERM', 'dumb'); + const term = terminal(); + await expect(term.read()).rejects.toThrow('pass --agents'); + expect(term.input.setRawMode).not.toHaveBeenCalled(); + expect(term.text()).toBe(''); + term.input.destroy(); term.output.destroy(); +}); diff --git a/packages/terminal-host/src/setup-selection.ts b/packages/terminal-host/src/setup-selection.ts index 04869f3..67105ba 100644 --- a/packages/terminal-host/src/setup-selection.ts +++ b/packages/terminal-host/src/setup-selection.ts @@ -1,39 +1,98 @@ import { closeSync, openSync } from 'node:fs'; -import { createInterface } from 'node:readline/promises'; +import { emitKeypressEvents, type Key } from 'node:readline'; import { ReadStream, WriteStream } from 'node:tty'; -import type { Readable, Writable } from 'node:stream'; +import { stripVTControlCharacters } from 'node:util'; import type { SetupAgent } from './setup-agents.js'; +import { terminalUi } from './cli-ui.js'; const names = { codex: 'Codex hooks', claude: 'Claude Code hooks', pi: 'Pi extension' }; +const terminalRequired = 'Interactive setup needs a terminal with keyboard controls. Run aow setup in a terminal, or pass --agents all, --agents codex,claude,pi, or --agents none for unattended setup.'; +const cancellation = 'Setup cancelled before installing integrations or configuring the service.'; -/** A numbered selection also works without raw-mode key handling or extra dependencies. */ -export async function readAgentSelection(available: readonly SetupAgent[], input: Readable, output: Writable): Promise { - const readline = createInterface({ input, output, terminal: Boolean((output as WriteStream).isTTY) }); - const cancelled = new AbortController(); - const cancel = () => cancelled.abort(); - readline.once('close', cancel); - readline.once('SIGINT', cancel); - input.once('end', cancel); - try { - output.write('\nChoose AgentOnWeb integrations to install:\n'); - available.forEach((agent, index) => output.write(` ${index + 1}) ${names[agent]}\n`)); - output.write(' all) All detected CLIs (default)\n 0) Skip CLI integrations; configure the terminal service only\n'); - output.write('Enter numbers or CLI names separated by commas.\n'); - while (true) { - const answer = (await readline.question('Selection [all]: ', { signal: cancelled.signal })).trim().toLowerCase(); - if (answer === '' || answer === 'all') return [...available]; - if (answer === '0' || answer === 'none') return []; - const choices = answer.split(',').map(value => value.trim()); - const selected = choices.map(value => available.find((agent, index) => value === agent || value === String(index + 1))); - if (selected.every((agent): agent is SetupAgent => agent !== undefined)) return [...new Set(selected)]; - output.write('Choose listed numbers or CLI names separated by commas (for example 1,2 or codex,claude), all, or 0.\n'); +/** Use the controlling TTY, including when the installer's stdin is a curl pipe. */ +export async function readAgentSelection(available: readonly SetupAgent[], input: ReadStream, output: WriteStream): Promise { + if (!available.length) return []; + if (!input.isTTY || !output.isTTY || !input.setRawMode || process.env.TERM === 'dumb') throw new Error(terminalRequired); + const wasRaw = input.isRaw; + const wasPaused = input.readableFlowing !== true; + const selected = new Set(available); + let focused = 0; + let rendered = ''; + let settled = false; + const render = (done = false) => { + const ui = terminalUi(output); + const text = done ? ui.status('ok', `Selected: ${available.filter(agent => selected.has(agent)).map(agent => names[agent]).join(', ') || 'terminal service only'}.`) : [ + ui.section('AGENT INTEGRATIONS'), + ui.wrap('Choose integrations to install. All detected agents start selected.'), '', + ...available.map((agent, index) => ui.paint(ui.wrap(`${focused === index ? '>' : ' '} [${selected.has(agent) ? 'x' : ' '}] ${names[agent]}`), focused === index || selected.has(agent) ? 'green' : 'muted')), + '', ui.paint(ui.wrap(`${selected.size}/${available.length} selected. Clear all for service only.`), 'muted'), + ui.wrap('Up/Down move Space toggle A all/none I invert'), + ui.wrap('Enter confirm Ctrl+C / Esc cancel'), + ].join('\n'); + if (rendered) { + const columns = output.columns || 80; + const lines = stripVTControlCharacters(rendered).split('\n').reduce((total, line) => total + Math.max(1, Math.ceil(line.length / columns)), 0); + output.write(`\x1b[${lines}A\r\x1b[J`); } - } catch (error) { - if (cancelled.signal.aborted) throw new Error('Setup cancelled before installing integrations or configuring the service.'); - throw error; + output.write(text + '\n'); + rendered = text; + }; + const resize = () => render(); + let onKey: ((text: string, key: Key) => void) | undefined; + let onCancel: (() => void) | undefined; + let onError: ((error: Error) => void) | undefined; + try { + input.setRawMode(true); + emitKeypressEvents(input); + return await new Promise((resolve, reject) => { + onCancel = () => { settled = true; reject(new Error(cancellation)); }; + onError = error => { settled = true; reject(error); }; + onKey = (_text, key) => { + if (settled) return; + if (key.name === 'escape' || key.ctrl && ['c', 'd'].includes(key.name ?? '')) { onCancel!(); return; } + if (key.name === 'return' || key.name === 'enter') { + settled = true; render(true); resolve(available.filter(agent => selected.has(agent))); return; + } + if (key.name === 'up' || key.name === 'k') focused = (focused + available.length - 1) % available.length; + else if (key.name === 'down' || key.name === 'j') focused = (focused + 1) % available.length; + else if (key.name === 'space') { + const agent = available[focused]!; + if (selected.has(agent)) selected.delete(agent); else selected.add(agent); + } else if (key.name === 'a') { + if (selected.size === available.length) selected.clear(); else available.forEach(agent => selected.add(agent)); + } else if (key.name === 'i') { + available.forEach(agent => { if (selected.has(agent)) selected.delete(agent); else selected.add(agent); }); + } else return; + render(); + }; + input.on('keypress', onKey); + input.once('end', onCancel); + input.once('close', onCancel); + input.once('error', onError); + output.once('error', onError); + process.once('SIGINT', onCancel); + process.once('SIGTERM', onCancel); + output.on('resize', resize); + input.resume(); + output.write('\x1b[?25l'); + render(); + }); } finally { - input.removeListener('end', cancel); - readline.close(); + if (onKey) input.removeListener('keypress', onKey); + if (onCancel) { + input.removeListener('end', onCancel); + input.removeListener('close', onCancel); + process.removeListener('SIGINT', onCancel); + process.removeListener('SIGTERM', onCancel); + } + if (onError) { + input.removeListener('error', onError); + output.removeListener('error', onError); + } + output.removeListener('resize', resize); + input.setRawMode(Boolean(wasRaw)); + if (wasPaused) input.pause(); + output.write('\x1b[?25h'); } } @@ -41,13 +100,11 @@ export async function promptAgentSelection(available: readonly SetupAgent[]): Pr let inputFd: number | undefined; let outputFd: number | undefined; try { - // curl | bash consumes stdin, and the installer redirects child stdin to /dev/null. - // The controlling terminal remains available for the user's selection. inputFd = openSync('/dev/tty', 'r'); outputFd = openSync('/dev/tty', 'w'); } catch { if (inputFd !== undefined) closeSync(inputFd); - throw new Error('Interactive setup needs a terminal. Run aow setup in a terminal, or pass --agents all, --agents codex,claude,pi, or --agents none for unattended setup.'); + throw new Error(terminalRequired); } const input = new ReadStream(inputFd); const output = new WriteStream(outputFd); diff --git a/packages/terminal-host/src/setup.ts b/packages/terminal-host/src/setup.ts index 9ffcd3d..8fbb4e9 100644 --- a/packages/terminal-host/src/setup.ts +++ b/packages/terminal-host/src/setup.ts @@ -10,12 +10,14 @@ import type { SetupAgent } from './setup-agents.js'; import { installNativeBridge, uninstallNativeBridge, extensionOrigin, STORE_EXTENSION_ID } from './native-bridge.js'; import { serviceCommand, serviceDirectory } from './service.js'; import { cleanup } from './cleanup.js'; +import { formatHelp, SETUP_USAGE } from './cli-help.js'; +import { printStatus, terminalUi } from './cli-ui.js'; -export const SETUP_USAGE = 'Usage: aow setup [--agents all|none|codex,claude,pi] [--extension-id CHROME_EXTENSION_ID]'; +export { SETUP_USAGE } from './cli-help.js'; export async function setup(args: string[]) { if (args.length === 1 && (args[0] === '--help' || args[0] === '-h')) { - console.log(`${SETUP_USAGE}\nDefault: detect CLIs on PATH, then ask which integrations to install in your terminal.\n--agents selects a comma-separated subset without prompting; all selects all detected CLIs, none skips integrations.\nMissing CLIs are always skipped. Selection does not remove existing integrations.`); + console.log(formatHelp('setup')); return; } if (process.platform !== 'darwin') throw new Error('One-command setup currently supports macOS + Chrome. Other platforms can use aow terminal.'); @@ -48,6 +50,7 @@ export async function setup(args: string[]) { // This also works when npm blocks lifecycle scripts. const require = createRequire(import.meta.url); const helper = join(dirname(require.resolve('node-pty/package.json')), 'prebuilds', `darwin-${process.arch}`, 'spawn-helper'); + console.log(terminalUi().banner('SETUP')); const selected = await planAgentSetup(agents); await chmod(helper, 0o755); await installSelectedAgents(selected); @@ -68,9 +71,9 @@ export async function setup(args: string[]) { await new Promise(resolve => setTimeout(resolve, 100)); } if (!ready) throw new Error('Setup saved, but the running host is not ready for automatic pairing. Finish active terminal work, then run aow service uninstall && aow setup. See ~/.agentonweb/terminal/service.log.'); - console.log('Local service is ready.'); - console.log('Setup records absolute Node/package paths. Before removing that Node installation or moving the package, reinstall under the intended Node version and rerun aow setup.'); - console.log('First installation: Chrome connects automatically on its next setup check (up to 5 minutes after failed attempts). Previously revoked or reset connections require manual pairing.'); + printStatus('ok', 'Local service is ready.'); + printStatus('info', 'Setup records absolute Node/package paths. Before removing that Node installation or moving the package, reinstall under the intended Node version and rerun aow setup.'); + printStatus('info', 'First installation: Chrome connects automatically on its next setup check (up to 5 minutes after failed attempts). Previously revoked or reset connections require manual pairing.'); } export async function uninstall(): Promise { @@ -83,5 +86,5 @@ export async function uninstall(): Promise { () => uninstallNativeBridge(), async () => { if (process.platform === 'darwin') await serviceCommand(['uninstall']); }, ], 'AgentOnWeb uninstall is incomplete. All integrations were attempted; fix the reported errors and rerun aow uninstall before removing the npm package.'); - console.log('AgentOnWeb hooks, notify forwarding, native bridge and service removed. Backups, browser grants, pairing receipts and CLI history are retained. You can now run npm uninstall -g @agentonweb/terminal-host.'); + printStatus('ok', 'AgentOnWeb hooks, notify forwarding, native bridge and service removed. Backups, browser grants, pairing receipts and CLI history are retained. You can now run npm uninstall -g @agentonweb/terminal-host.'); } diff --git a/packages/terminal-host/src/upgrade.ts b/packages/terminal-host/src/upgrade.ts index cebe78c..ef7f8ca 100644 --- a/packages/terminal-host/src/upgrade.ts +++ b/packages/terminal-host/src/upgrade.ts @@ -7,6 +7,8 @@ import { packageDirectory, installedVersion } from './version.js'; import { outsideTerminal, prepareHost, liveEndpoint } from './management.js'; import { serviceDirectory, serviceLoaded, reloadService } from './service.js'; import { Updates } from './updates.js'; +import { formatHelp } from './cli-help.js'; +import { printStatus } from './cli-ui.js'; export function npmPrefix(root: string) { const prefix = dirname(dirname(dirname(dirname(root)))); @@ -30,7 +32,7 @@ async function install(prefix: string, version: string) { } export async function updateCommand(args: string[]) { if (args.length === 1 && ['--help', '-h'].includes(args[0]!)) { - console.log('Usage: aow update [--force]\nInstall the newer stable npm version into the existing prefix and restart the macOS service. Close terminals first. --force ends their tasks.'); return; + console.log(formatHelp('update')); return; } if (args.length > 1 || args.length === 1 && args[0] !== '--force') throw new Error('Usage: aow update [--force]'); outsideTerminal(); @@ -41,7 +43,7 @@ export async function updateCommand(args: string[]) { const updates = new Updates(serviceDirectory); const status = await updates.check(true); if (status.error) throw new Error(status.error); - if (!status.updateAvailable) { console.log(`AgentOnWeb ${status.currentVersion} is up to date; no package was changed.`); return; } + if (!status.updateAvailable) { printStatus('ok', `AgentOnWeb ${status.currentVersion} is up to date; no package was changed.`); return; } if (status.node && !semver.satisfies(process.versions.node, status.node)) throw new Error(`Version ${status.latest} requires Node.js ${status.node}. Update Node and rerun the installer.`); await mkdir(serviceDirectory, { recursive: true, mode: 0o700 }); const guard = join(serviceDirectory, 'update.guard'); @@ -54,7 +56,7 @@ export async function updateCommand(args: string[]) { const prepared = await prepareHost(serviceDirectory, args.includes('--force')); try { if (!prepared.status && serviceLoaded()) throw new Error('The service is loaded but cannot be checked. No package was changed; repair the service first.'); - console.log(`Updating AgentOnWeb ${status.currentVersion} → ${status.latest} in ${prefix}.`); + printStatus('info', `Updating AgentOnWeb ${status.currentVersion} → ${status.latest} in ${prefix}.`); await install(prefix, status.latest!); if (installedVersion() !== status.latest) throw new Error('npm did not install the expected version. The service was not restarted.'); // Hooks/native registration keep the same absolute paths. Skipping setup @@ -62,7 +64,7 @@ export async function updateCommand(args: string[]) { if (prepared.status) await reloadService(status.latest!); else { if (await liveEndpoint(serviceDirectory)) throw new Error('A host started during installation. Close its terminals, then run aow service restart to activate the installed version.'); - console.log(`Installed ${status.latest}. Run aow setup if you want to configure the service.`); + printStatus('ok', `Installed ${status.latest}. Run aow setup if you want to configure the service.`); } } finally { await prepared.release(); } } finally { await rmdir(guard); } From c6986f236a2a9fbfe96770a7037c2b848615e554 Mon Sep 17 00:00:00 2001 From: HeftyKoo Date: Mon, 19 Oct 2026 11:45:47 +0800 Subject: [PATCH 2/2] fix(cli): include foreground terminal help example --- packages/terminal-host/src/cli-help.ts | 1 + packages/terminal-host/src/cli-ui.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/terminal-host/src/cli-help.ts b/packages/terminal-host/src/cli-help.ts index c6ff8ce..0fbca1d 100644 --- a/packages/terminal-host/src/cli-help.ts +++ b/packages/terminal-host/src/cli-help.ts @@ -34,6 +34,7 @@ const topics: Record = { ] }, { title: 'QUICK START', rows: [ ['aow setup', 'Choose integrations with a checkbox menu.'], + ['aow terminal', 'Run the terminal host in the foreground.'], ['aow setup --agents codex,pi', 'Select integrations without prompting.'], ['aow service status', 'Check the running service.'], ] }, diff --git a/packages/terminal-host/src/cli-ui.test.ts b/packages/terminal-host/src/cli-ui.test.ts index 10bc730..93bbe54 100644 --- a/packages/terminal-host/src/cli-ui.test.ts +++ b/packages/terminal-host/src/cli-ui.test.ts @@ -17,6 +17,7 @@ it('groups commands with descriptions and examples', () => { const help = formatHelp('main', terminalUi(output(80), {})); for (const title of ['CONNECT', 'SERVICE', 'AGENT INTEGRATIONS', 'MAINTENANCE', 'QUICK START']) expect(help).toContain(`[ ${title} ]`); expect(help).toContain('aow setup --agents codex,pi'); + expect(help).toContain('aow terminal'); expect(help).toContain('aow --help'); });