Compare commits

..
Author SHA1 Message Date
Peter Steinberger 206071be69 fix: answer telegram callback queries immediately (#1349) (thanks @siddhantjain) 2026-01-21 03:51:22 +00:00
Siddhant Jain 98f399b7fd fix(telegram): answer callback queries immediately to prevent retries
Telegram retries callback queries if they aren't acknowledged quickly.
Previously, answerCallbackQuery was called in a finally block AFTER
processing, which could take several seconds for agent responses.

This change moves answerCallbackQuery to immediately after basic
validation, before any processing begins. This prevents Telegram
from sending duplicate callbacks while the agent is thinking.

Fixes duplicate callback handling when agent processing is slow.
2026-01-21 03:46:38 +00:00
48 changed files with 617 additions and 1718 deletions

No files matched your search

+1 -3
View File
@@ -6,7 +6,6 @@ Docs: https://docs.clawd.bot
### Changes ### Changes
- Deps: update workspace + memory-lancedb dependencies. - Deps: update workspace + memory-lancedb dependencies.
- Dev: use tsgo for dev/watch builds by default; set `CLAWDBOT_TS_COMPILER=tsc` to opt out.
- Repo: remove the Peekaboo git submodule now that the SPM release is used. - Repo: remove the Peekaboo git submodule now that the SPM release is used.
- Update: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`. - Update: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
- Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`. - Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`.
@@ -23,10 +22,9 @@ Docs: https://docs.clawd.bot
- Skills: add download installs with OS-filtered install options; add local sherpa-onnx-tts skill. - Skills: add download installs with OS-filtered install options; add local sherpa-onnx-tts skill.
- Docs: clarify WhatsApp voice notes and Windows WSL portproxy LAN access notes. - Docs: clarify WhatsApp voice notes and Windows WSL portproxy LAN access notes.
- UI: add copy-as-markdown with error feedback and drop legacy list view. (#1345) — thanks @bradleypriest. - UI: add copy-as-markdown with error feedback and drop legacy list view. (#1345) — thanks @bradleypriest.
- TUI: add input history (up/down) for submitted messages. (#1348) — thanks @vignesh07.
### Fixes ### Fixes
- iOS: explain Talk mode is unavailable on the simulator to avoid Speech live-audio crashes. (#1358) — thanks @vignesh07.
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs. - Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
- Telegram: answer callback queries immediately to prevent retries. (#1349) — thanks @siddhantjain.
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco. - Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco.
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs. - Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs.
- Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry. - Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.
@@ -132,13 +132,6 @@ final class TalkModeManager: NSObject {
} }
private func startRecognition() throws { private func startRecognition() throws {
#if targetEnvironment(simulator)
// Apple Speech live-audio recognition is not supported on Simulator.
throw NSError(domain: "TalkMode", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Talk mode is not supported on the iOS simulator (Speech live audio requires a device).",
])
#endif
self.stopRecognition() self.stopRecognition()
self.speechRecognizer = SFSpeechRecognizer() self.speechRecognizer = SFSpeechRecognizer()
guard let recognizer = self.speechRecognizer else { guard let recognizer = self.speechRecognizer else {
@@ -153,11 +146,6 @@ final class TalkModeManager: NSObject {
let input = self.audioEngine.inputNode let input = self.audioEngine.inputNode
let format = input.outputFormat(forBus: 0) let format = input.outputFormat(forBus: 0)
guard format.sampleRate > 0, format.channelCount > 0 else {
throw NSError(domain: "TalkMode", code: 3, userInfo: [
NSLocalizedDescriptionKey: "Invalid audio input format",
])
}
input.removeTap(onBus: 0) input.removeTap(onBus: 0)
let tapBlock = Self.makeAudioTapAppendCallback(request: request) let tapBlock = Self.makeAudioTapAppendCallback(request: request)
input.installTap(onBus: 0, bufferSize: 2048, format: format, block: tapBlock) input.installTap(onBus: 0, bufferSize: 2048, format: format, block: tapBlock)
@@ -180,10 +180,8 @@ public actor GatewayNodeSession {
"id": AnyCodable(request.id), "id": AnyCodable(request.id),
"nodeId": AnyCodable(request.nodeId), "nodeId": AnyCodable(request.nodeId),
"ok": AnyCodable(response.ok), "ok": AnyCodable(response.ok),
"payloadJSON": AnyCodable(response.payloadJSON ?? NSNull()),
] ]
if let payloadJSON = response.payloadJSON {
params["payloadJSON"] = AnyCodable(payloadJSON)
}
if let error = response.error { if let error = response.error {
params["error"] = AnyCodable([ params["error"] = AnyCodable([
"code": AnyCodable(error.code.rawValue), "code": AnyCodable(error.code.rawValue),
+2 -2
View File
@@ -825,9 +825,9 @@ Common options:
- `--url`, `--token`, `--timeout`, `--json` - `--url`, `--token`, `--timeout`, `--json`
Subcommands: Subcommands:
- `nodes status [--connected] [--last-connected <duration>]` - `nodes status`
- `nodes describe --node <id|name|ip>` - `nodes describe --node <id|name|ip>`
- `nodes list [--connected] [--last-connected <duration>]` - `nodes list`
- `nodes pending` - `nodes pending`
- `nodes approve <requestId>` - `nodes approve <requestId>`
- `nodes reject <requestId>` - `nodes reject <requestId>`
-6
View File
@@ -18,18 +18,12 @@ Related:
```bash ```bash
clawdbot nodes list clawdbot nodes list
clawdbot nodes list --connected
clawdbot nodes list --last-connected 24h
clawdbot nodes pending clawdbot nodes pending
clawdbot nodes approve <requestId> clawdbot nodes approve <requestId>
clawdbot nodes status clawdbot nodes status
clawdbot nodes status --connected
clawdbot nodes status --last-connected 24h
``` ```
`nodes list` prints pending/paired tables. Paired rows include the most recent connect age (Last Connect). `nodes list` prints pending/paired tables. Paired rows include the most recent connect age (Last Connect).
Use `--connected` to only show currently-connected nodes. Use `--last-connected <duration>` to
filter to nodes that connected within a duration (e.g. `24h`, `7d`).
## Invoke / run ## Invoke / run
-78
View File
@@ -34,81 +34,6 @@ clawdbot nodes rename --node <idOrNameOrIp> --name "Kitchen iPad"
Notes: Notes:
- `nodes rename` stores a display name override in the gateway pairing store. - `nodes rename` stores a display name override in the gateway pairing store.
## Remote node host (system.run)
Use a **node host** when your Gateway runs on one machine and you want commands
to execute on another. The model still talks to the **gateway**; the gateway
forwards `exec` calls to the **node host** when `host=node` is selected.
### What runs where
- **Gateway host**: receives messages, runs the model, routes tool calls.
- **Node host**: executes `system.run`/`system.which` on the node machine.
- **Approvals**: enforced on the node host via `~/.clawdbot/exec-approvals.json`.
### Start a node host (foreground)
On the node machine:
```bash
clawdbot node start --host <gateway-host> --port 18789 --display-name "Build Node"
```
### Start a node host (service)
```bash
clawdbot node service install --host <gateway-host> --port 18789 --display-name "Build Node"
clawdbot node service start
```
### Pair + name
On the gateway host:
```bash
clawdbot nodes pending
clawdbot nodes approve <requestId>
clawdbot nodes list
```
Naming options:
- `--display-name` on `clawdbot node start/service install` (persists in `~/.clawdbot/node.json` on the node).
- `clawdbot nodes rename --node <id|name|ip> --name "Build Node"` (gateway override).
### Allowlist the commands
Exec approvals are **per node host**. Add allowlist entries from the gateway:
```bash
clawdbot approvals allowlist add --node <id|name|ip> "/usr/bin/uname"
clawdbot approvals allowlist add --node <id|name|ip> "/usr/bin/sw_vers"
```
Approvals live on the node host at `~/.clawdbot/exec-approvals.json`.
### Point exec at the node
Configure defaults (gateway config):
```bash
clawdbot config set tools.exec.host node
clawdbot config set tools.exec.security allowlist
clawdbot config set tools.exec.node "<id-or-name>"
```
Or per session:
```
/exec host=node security=allowlist node=<id-or-name>
```
Once set, any `exec` call with `host=node` runs on the node host (subject to the
node allowlist/approvals).
Related:
- [Node host CLI](/cli/node)
- [Exec tool](/tools/exec)
- [Exec approvals](/tools/exec-approvals)
## Invoking commands ## Invoking commands
Low-level (raw RPC): Low-level (raw RPC):
@@ -289,9 +214,6 @@ Notes:
- The node host stores its node id + pairing token in `~/.clawdbot/node.json`. - The node host stores its node id + pairing token in `~/.clawdbot/node.json`.
- Exec approvals are enforced locally via `~/.clawdbot/exec-approvals.json` - Exec approvals are enforced locally via `~/.clawdbot/exec-approvals.json`
(see [Exec approvals](/tools/exec-approvals)). (see [Exec approvals](/tools/exec-approvals)).
- On macOS, the headless node host prefers the companion app exec host when reachable and falls
back to local execution if the app is unavailable. Set `CLAWDBOT_NODE_EXEC_HOST=app` to require
the app, or `CLAWDBOT_NODE_EXEC_FALLBACK=0` to disable fallback.
- Add `--tls` / `--tls-fingerprint` when the bridge requires TLS. - Add `--tls` / `--tls-fingerprint` when the bridge requires TLS.
## Mac node mode ## Mac node mode
+1 -2
View File
@@ -210,7 +210,6 @@
"@types/proper-lockfile": "^4.1.4", "@types/proper-lockfile": "^4.1.4",
"@types/qrcode-terminal": "^0.12.2", "@types/qrcode-terminal": "^0.12.2",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@typescript/native-preview": "7.0.0-dev.20260120.1",
"@vitest/coverage-v8": "^4.0.17", "@vitest/coverage-v8": "^4.0.17",
"docx-preview": "^0.3.7", "docx-preview": "^0.3.7",
"lit": "^3.3.2", "lit": "^3.3.2",
@@ -232,7 +231,7 @@
"overrides": { "overrides": {
"@sinclair/typebox": "0.34.47", "@sinclair/typebox": "0.34.47",
"hono": "4.11.4", "hono": "4.11.4",
"tar": "7.5.4" "tar": "7.5.3"
}, },
"patchedDependencies": { "patchedDependencies": {
"@mariozechner/pi-ai@0.49.2": "patches/@mariozechner__pi-ai@0.49.2.patch" "@mariozechner/pi-ai@0.49.2": "patches/@mariozechner__pi-ai@0.49.2.patch"
+8 -80
View File
@@ -7,7 +7,7 @@ settings:
overrides: overrides:
'@sinclair/typebox': 0.34.47 '@sinclair/typebox': 0.34.47
hono: 4.11.4 hono: 4.11.4
tar: 7.5.4 tar: 7.5.3
patchedDependencies: patchedDependencies:
'@mariozechner/pi-ai@0.49.2': '@mariozechner/pi-ai@0.49.2':
@@ -151,8 +151,8 @@ importers:
specifier: 0.1.7-alpha.2 specifier: 0.1.7-alpha.2
version: 0.1.7-alpha.2 version: 0.1.7-alpha.2
tar: tar:
specifier: 7.5.4 specifier: 7.5.3
version: 7.5.4 version: 7.5.3
tslog: tslog:
specifier: ^4.10.2 specifier: ^4.10.2
version: 4.10.2 version: 4.10.2
@@ -202,9 +202,6 @@ importers:
'@types/ws': '@types/ws':
specifier: ^8.18.1 specifier: ^8.18.1
version: 8.18.1 version: 8.18.1
'@typescript/native-preview':
specifier: 7.0.0-dev.20260120.1
version: 7.0.0-dev.20260120.1
'@vitest/coverage-v8': '@vitest/coverage-v8':
specifier: ^4.0.17 specifier: ^4.0.17
version: 4.0.17(@vitest/browser@4.0.17(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.17))(vitest@4.0.17) version: 4.0.17(@vitest/browser@4.0.17(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.17))(vitest@4.0.17)
@@ -2533,45 +2530,6 @@ packages:
'@types/ws@8.18.1': '@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260120.1':
resolution: {integrity: sha512-r3pWFuR2H7mn6ScwpH5jJljKQqKto0npVuJSk6pRwFwexpTyxOGmJTZJ1V0AWiisaNxU2+CNAqWFJSJYIE/QTg==}
cpu: [arm64]
os: [darwin]
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260120.1':
resolution: {integrity: sha512-cuC1+wLbUP+Ip2UT94G134fqRdp5w3b3dhcCO6/FQ4yXxvRNyv/WK+upHBUFDaeSOeHgDTyO9/QFYUWwC4If1A==}
cpu: [x64]
os: [darwin]
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260120.1':
resolution: {integrity: sha512-zZGvEGY7wcHYefMZ87KNmvjN3NLIhsCMHEpHZiGCS3khKf+8z6ZsanrzCjOTodvL01VPyBzHxV1EtkSxAcLiQg==}
cpu: [arm64]
os: [linux]
'@typescript/native-preview-linux-arm@7.0.0-dev.20260120.1':
resolution: {integrity: sha512-vN6OYVySol/kQZjJGmAzd6L30SyVlCgmCXS8WjUYtE5clN0YrzQHop16RK29fYZHMxpkOniVBtRPxUYQANZBlQ==}
cpu: [arm]
os: [linux]
'@typescript/native-preview-linux-x64@7.0.0-dev.20260120.1':
resolution: {integrity: sha512-JBfNhWd/asd5MDeS3VgRvE24pGKBkmvLub6tsux6ypr+Yhy+o0WaAEzVpmlRYZUqss2ai5tvOu4dzPBXzZAtFw==}
cpu: [x64]
os: [linux]
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260120.1':
resolution: {integrity: sha512-tTndRtYCq2xwgE0VkTi9ACNiJaV43+PqvBqCxk8ceYi3X36Ve+CCnwlZfZJ4k9NxZthtrAwF/kUmpC9iIYbq1w==}
cpu: [arm64]
os: [win32]
'@typescript/native-preview-win32-x64@7.0.0-dev.20260120.1':
resolution: {integrity: sha512-oZia7hFL6k9pVepfonuPI86Jmyz6WlJKR57tWCDwRNmpA7odxuTq1PbvcYgy1z4+wHF1nnKKJY0PMAiq6ac18w==}
cpu: [x64]
os: [win32]
'@typescript/native-preview@7.0.0-dev.20260120.1':
resolution: {integrity: sha512-nnEf37C9ue7OBRnF2zmV/OCBmV5Y7T/K4mCHa+nxgiXcF/1w8sA0cgdFl+gHQ0mysqUJ+Bu5btAMeWgpLyjrgg==}
hasBin: true
'@typespec/ts-http-runtime@0.3.2': '@typespec/ts-http-runtime@0.3.2':
resolution: {integrity: sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==} resolution: {integrity: sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -4905,9 +4863,10 @@ packages:
tailwindcss@4.1.17: tailwindcss@4.1.17:
resolution: {integrity: sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==} resolution: {integrity: sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==}
tar@7.5.4: tar@7.5.3:
resolution: {integrity: sha512-AN04xbWGrSTDmVwlI4/GTlIIwMFk/XEv7uL8aa57zuvRy6s4hdBed+lVq2fAZ89XDa7Us3ANXcE3Tvqvja1kTA==} resolution: {integrity: sha512-ENg5JUHUm2rDD7IvKNFGzyElLXNjachNLp6RaGf4+JOgxXHkqA+gq81ZAMCUmtMtqBsoU62lcp6S27g1LCYGGQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me
thenify-all@1.6.0: thenify-all@1.6.0:
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
@@ -7740,37 +7699,6 @@ snapshots:
dependencies: dependencies:
'@types/node': 25.0.9 '@types/node': 25.0.9
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260120.1':
optional: true
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260120.1':
optional: true
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260120.1':
optional: true
'@typescript/native-preview-linux-arm@7.0.0-dev.20260120.1':
optional: true
'@typescript/native-preview-linux-x64@7.0.0-dev.20260120.1':
optional: true
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260120.1':
optional: true
'@typescript/native-preview-win32-x64@7.0.0-dev.20260120.1':
optional: true
'@typescript/native-preview@7.0.0-dev.20260120.1':
optionalDependencies:
'@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260120.1
'@typescript/native-preview-darwin-x64': 7.0.0-dev.20260120.1
'@typescript/native-preview-linux-arm': 7.0.0-dev.20260120.1
'@typescript/native-preview-linux-arm64': 7.0.0-dev.20260120.1
'@typescript/native-preview-linux-x64': 7.0.0-dev.20260120.1
'@typescript/native-preview-win32-arm64': 7.0.0-dev.20260120.1
'@typescript/native-preview-win32-x64': 7.0.0-dev.20260120.1
'@typespec/ts-http-runtime@0.3.2': '@typespec/ts-http-runtime@0.3.2':
dependencies: dependencies:
http-proxy-agent: 7.0.2 http-proxy-agent: 7.0.2
@@ -8268,7 +8196,7 @@ snapshots:
npmlog: 6.0.2 npmlog: 6.0.2
rc: 1.2.8 rc: 1.2.8
semver: 7.7.3 semver: 7.7.3
tar: 7.5.4 tar: 7.5.3
url-join: 4.0.1 url-join: 4.0.1
which: 2.0.2 which: 2.0.2
yargs: 17.7.2 yargs: 17.7.2
@@ -10510,7 +10438,7 @@ snapshots:
tailwindcss@4.1.17: {} tailwindcss@4.1.17: {}
tar@7.5.4: tar@7.5.3:
dependencies: dependencies:
'@isaacs/fs-minipass': 4.0.1 '@isaacs/fs-minipass': 4.0.1
chownr: 3.0.0 chownr: 3.0.0
+1 -3
View File
@@ -7,8 +7,6 @@ import process from "node:process";
const args = process.argv.slice(2); const args = process.argv.slice(2);
const env = { ...process.env }; const env = { ...process.env };
const cwd = process.cwd(); const cwd = process.cwd();
const compiler = env.CLAWDBOT_TS_COMPILER === "tsc" ? "tsc" : "tsgo";
const projectArgs = ["--project", "tsconfig.json"];
const distRoot = path.join(cwd, "dist"); const distRoot = path.join(cwd, "dist");
const distEntry = path.join(distRoot, "entry.js"); const distEntry = path.join(distRoot, "entry.js");
@@ -115,7 +113,7 @@ if (!shouldBuild()) {
runNode(); runNode();
} else { } else {
logRunner("Building TypeScript (dist is stale)."); logRunner("Building TypeScript (dist is stale).");
const build = spawn("pnpm", ["exec", compiler, ...projectArgs], { const build = spawn("pnpm", ["exec", "tsc", "-p", "tsconfig.json"], {
cwd, cwd,
env, env,
stdio: "inherit", stdio: "inherit",
+4 -11
View File
@@ -5,10 +5,8 @@ import process from "node:process";
const args = process.argv.slice(2); const args = process.argv.slice(2);
const env = { ...process.env }; const env = { ...process.env };
const cwd = process.cwd(); const cwd = process.cwd();
const compiler = env.CLAWDBOT_TS_COMPILER === "tsc" ? "tsc" : "tsgo";
const projectArgs = ["--project", "tsconfig.json"];
const initialBuild = spawnSync("pnpm", ["exec", compiler, ...projectArgs], { const initialBuild = spawnSync("pnpm", ["exec", "tsc", "-p", "tsconfig.json"], {
cwd, cwd,
env, env,
stdio: "inherit", stdio: "inherit",
@@ -18,12 +16,7 @@ if (initialBuild.status !== 0) {
process.exit(initialBuild.status ?? 1); process.exit(initialBuild.status ?? 1);
} }
const watchArgs = const tsc = spawn("pnpm", ["exec", "tsc", "--watch", "--preserveWatchOutput"], {
compiler === "tsc"
? [...projectArgs, "--watch", "--preserveWatchOutput"]
: [...projectArgs, "--watch"];
const compilerProcess = spawn("pnpm", ["exec", compiler, ...watchArgs], {
cwd, cwd,
env, env,
stdio: "inherit", stdio: "inherit",
@@ -41,14 +34,14 @@ function cleanup(code = 0) {
if (exiting) return; if (exiting) return;
exiting = true; exiting = true;
nodeProcess.kill("SIGTERM"); nodeProcess.kill("SIGTERM");
compilerProcess.kill("SIGTERM"); tsc.kill("SIGTERM");
process.exit(code); process.exit(code);
} }
process.on("SIGINT", () => cleanup(130)); process.on("SIGINT", () => cleanup(130));
process.on("SIGTERM", () => cleanup(143)); process.on("SIGTERM", () => cleanup(143));
compilerProcess.on("exit", (code) => { tsc.on("exit", (code) => {
if (exiting) return; if (exiting) return;
cleanup(code ?? 1); cleanup(code ?? 1);
}); });
+36 -3
View File
@@ -494,7 +494,12 @@ export function createExecTool(
if (nodeEnv) { if (nodeEnv) {
applyPathPrepend(nodeEnv, defaultPathPrepend, { requireExisting: true }); applyPathPrepend(nodeEnv, defaultPathPrepend, { requireExisting: true });
} }
const requiresAsk = hostAsk === "always" || hostAsk === "on-miss"; const resolution = resolveCommandResolution(params.command, workdir, env);
const allowlistMatch =
hostSecurity === "allowlist" ? matchAllowlist(approvals.allowlist, resolution) : null;
const requiresAsk =
hostAsk === "always" ||
(hostAsk === "on-miss" && hostSecurity === "allowlist" && !allowlistMatch);
let approvedByAsk = false; let approvedByAsk = false;
let approvalDecision: "allow-once" | "allow-always" | null = null; let approvalDecision: "allow-once" | "allow-always" | null = null;
@@ -509,7 +514,7 @@ export function createExecTool(
security: hostSecurity, security: hostSecurity,
ask: hostAsk, ask: hostAsk,
agentId: defaults?.agentId, agentId: defaults?.agentId,
resolvedPath: null, resolvedPath: resolution?.resolvedPath ?? null,
sessionKey: defaults?.sessionKey ?? null, sessionKey: defaults?.sessionKey ?? null,
timeoutMs: 120_000, timeoutMs: 120_000,
}, },
@@ -527,7 +532,11 @@ export function createExecTool(
approvedByAsk = true; approvedByAsk = true;
approvalDecision = "allow-once"; approvalDecision = "allow-once";
} else if (askFallback === "allowlist") { } else if (askFallback === "allowlist") {
// Defer allowlist enforcement to the node host. if (!allowlistMatch) {
throw new Error("exec denied: approval required (approval UI not available)");
}
approvedByAsk = true;
approvalDecision = "allow-once";
} else { } else {
throw new Error("exec denied: approval required (approval UI not available)"); throw new Error("exec denied: approval required (approval UI not available)");
} }
@@ -539,8 +548,32 @@ export function createExecTool(
if (decision === "allow-always") { if (decision === "allow-always") {
approvedByAsk = true; approvedByAsk = true;
approvalDecision = "allow-always"; approvalDecision = "allow-always";
if (hostSecurity === "allowlist") {
const pattern =
resolution?.resolvedPath ??
resolution?.rawExecutable ??
params.command.split(/\s+/).shift() ??
"";
if (pattern) {
addAllowlistEntry(approvals.file, defaults?.agentId, pattern);
}
}
} }
} }
if (hostSecurity === "allowlist" && !allowlistMatch && !approvedByAsk) {
throw new Error("exec denied: allowlist miss");
}
if (allowlistMatch) {
recordAllowlistUse(
approvals.file,
defaults?.agentId,
allowlistMatch,
params.command,
resolution?.resolvedPath,
);
}
const invokeParams: Record<string, unknown> = { const invokeParams: Record<string, unknown> = {
nodeId, nodeId,
command: "system.run", command: "system.run",
@@ -1,149 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { TemplateContext } from "../templating.js";
import type { FollowupRun, QueueSettings } from "./queue.js";
import { createMockTypingController } from "./test-helpers.js";
const runEmbeddedPiAgentMock = vi.fn();
vi.mock("../../agents/model-fallback.js", () => ({
runWithModelFallback: async ({
run,
}: {
run: (provider: string, model: string) => Promise<unknown>;
}) => ({
// Force a cross-provider fallback candidate
result: await run("openai-codex", "gpt-5.2"),
provider: "openai-codex",
model: "gpt-5.2",
}),
}));
vi.mock("../../agents/pi-embedded.js", () => ({
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params),
}));
vi.mock("./queue.js", async () => {
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
return {
...actual,
enqueueFollowupRun: vi.fn(),
scheduleFollowupDrain: vi.fn(),
};
});
import { runReplyAgent } from "./agent-runner.js";
function createBaseRun(params: { runOverrides?: Partial<FollowupRun["run"]> }) {
const typing = createMockTypingController();
const sessionCtx = {
Provider: "telegram",
OriginatingTo: "chat",
AccountId: "primary",
MessageSid: "msg",
Surface: "telegram",
} as unknown as TemplateContext;
const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings;
const followupRun = {
prompt: "hello",
summaryLine: "hello",
enqueuedAt: Date.now(),
run: {
agentId: "main",
agentDir: "/tmp/agent",
sessionId: "session",
sessionKey: "main",
messageProvider: "telegram",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: {},
skillsSnapshot: {},
provider: "anthropic",
model: "claude-opus",
authProfileId: "anthropic:clawd",
authProfileIdSource: "manual",
thinkLevel: "low",
verboseLevel: "off",
elevatedLevel: "off",
bashElevated: {
enabled: false,
allowed: false,
defaultLevel: "off",
},
timeoutMs: 5_000,
blockReplyBreak: "message_end",
},
} as unknown as FollowupRun;
return {
typing,
sessionCtx,
resolvedQueue,
followupRun: {
...followupRun,
run: { ...followupRun.run, ...params.runOverrides },
},
};
}
describe("authProfileId fallback scoping", () => {
it("drops authProfileId when provider changes during fallback", async () => {
runEmbeddedPiAgentMock.mockReset();
runEmbeddedPiAgentMock.mockResolvedValue({ payloads: [{ text: "ok" }], meta: {} });
const sessionKey = "main";
const sessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
totalTokens: 1,
compactionCount: 0,
};
const { typing, sessionCtx, resolvedQueue, followupRun } = createBaseRun({
runOverrides: {
provider: "anthropic",
model: "claude-opus",
authProfileId: "anthropic:clawd",
authProfileIdSource: "manual",
},
});
await runReplyAgent({
commandBody: "hello",
followupRun,
queueKey: sessionKey,
resolvedQueue,
shouldSteer: false,
shouldFollowup: false,
isActive: false,
isStreaming: false,
typing,
sessionCtx,
sessionEntry,
sessionStore: { [sessionKey]: sessionEntry },
sessionKey,
storePath: undefined,
defaultModel: "anthropic/claude-opus-4-5",
agentCfgContextTokens: 100_000,
resolvedVerboseLevel: "off",
isNewSession: false,
blockStreamingEnabled: false,
resolvedBlockStreamingBreak: "message_end",
shouldInjectGroupIntro: false,
typingMode: "instant",
});
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0] as {
authProfileId?: unknown;
authProfileIdSource?: unknown;
provider?: unknown;
};
expect(call.provider).toBe("openai-codex");
expect(call.authProfileId).toBeUndefined();
expect(call.authProfileIdSource).toBeUndefined();
});
});
+1 -5
View File
@@ -5,7 +5,6 @@ import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js"; import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js"; import { theme } from "../terminal/theme.js";
import { formatCliCommand } from "./command-format.js"; import { formatCliCommand } from "./command-format.js";
import { formatHelpExamples } from "./help-format.js";
import { registerBrowserActionInputCommands } from "./browser-cli-actions-input.js"; import { registerBrowserActionInputCommands } from "./browser-cli-actions-input.js";
import { registerBrowserActionObserveCommands } from "./browser-cli-actions-observe.js"; import { registerBrowserActionObserveCommands } from "./browser-cli-actions-observe.js";
import { registerBrowserDebugCommands } from "./browser-cli-debug.js"; import { registerBrowserDebugCommands } from "./browser-cli-debug.js";
@@ -27,10 +26,7 @@ export function registerBrowserCli(program: Command) {
.addHelpText( .addHelpText(
"after", "after",
() => () =>
`\n${theme.heading("Examples:")}\n${formatHelpExamples( `\nExamples:\n ${[...browserCoreExamples, ...browserActionExamples].join("\n ")}\n\n${theme.muted("Docs:")} ${formatDocsLink(
[...browserCoreExamples, ...browserActionExamples].map((cmd) => [cmd, ""]),
true,
)}\n\n${theme.muted("Docs:")} ${formatDocsLink(
"/cli/browser", "/cli/browser",
"docs.clawd.bot/cli/browser", "docs.clawd.bot/cli/browser",
)}\n`, )}\n`,
+26 -55
View File
@@ -3,8 +3,6 @@ import type { Command } from "commander";
import { callGateway } from "../gateway/call.js"; import { callGateway } from "../gateway/call.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js";
import { withProgress } from "./progress.js"; import { withProgress } from "./progress.js";
type DevicesRpcOpts = { type DevicesRpcOpts = {
@@ -98,11 +96,11 @@ function parseDevicePairingList(value: unknown): DevicePairingList {
} }
function formatTokenSummary(tokens: DeviceTokenSummary[] | undefined) { function formatTokenSummary(tokens: DeviceTokenSummary[] | undefined) {
if (!tokens || tokens.length === 0) return "none"; if (!tokens || tokens.length === 0) return "tokens: none";
const parts = tokens const parts = tokens
.map((t) => `${t.role}${t.revokedAtMs ? " (revoked)" : ""}`) .map((t) => `${t.role}${t.revokedAtMs ? " (revoked)" : ""}`)
.sort((a, b) => a.localeCompare(b)); .sort((a, b) => a.localeCompare(b));
return parts.join(", "); return `tokens: ${parts.join(", ")}`;
} }
export function registerDevicesCli(program: Command) { export function registerDevicesCli(program: Command) {
@@ -120,59 +118,32 @@ export function registerDevicesCli(program: Command) {
return; return;
} }
if (list.pending?.length) { if (list.pending?.length) {
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); defaultRuntime.log("Pending:");
defaultRuntime.log( for (const req of list.pending) {
`${theme.heading("Pending")} ${theme.muted(`(${list.pending.length})`)}`, const name = req.displayName || req.deviceId;
); const repair = req.isRepair ? " (repair)" : "";
defaultRuntime.log( const ip = req.remoteIp ? ` · ${req.remoteIp}` : "";
renderTable({ const age =
width: tableWidth, typeof req.ts === "number" ? ` · ${formatAge(Date.now() - req.ts)} ago` : "";
columns: [ const role = req.role ? ` · role: ${req.role}` : "";
{ key: "Request", header: "Request", minWidth: 10 }, defaultRuntime.log(`- ${req.requestId}: ${name}${repair}${role}${ip}${age}`);
{ key: "Device", header: "Device", minWidth: 16, flex: true }, }
{ key: "Role", header: "Role", minWidth: 8 },
{ key: "IP", header: "IP", minWidth: 12 },
{ key: "Age", header: "Age", minWidth: 8 },
{ key: "Flags", header: "Flags", minWidth: 8 },
],
rows: list.pending.map((req) => ({
Request: req.requestId,
Device: req.displayName || req.deviceId,
Role: req.role ?? "",
IP: req.remoteIp ?? "",
Age: typeof req.ts === "number" ? `${formatAge(Date.now() - req.ts)} ago` : "",
Flags: req.isRepair ? "repair" : "",
})),
}).trimEnd(),
);
} }
if (list.paired?.length) { if (list.paired?.length) {
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); defaultRuntime.log("Paired:");
defaultRuntime.log( for (const device of list.paired) {
`${theme.heading("Paired")} ${theme.muted(`(${list.paired.length})`)}`, const name = device.displayName || device.deviceId;
); const roles = device.roles?.length ? `roles: ${device.roles.join(", ")}` : "roles: -";
defaultRuntime.log( const scopes = device.scopes?.length
renderTable({ ? `scopes: ${device.scopes.join(", ")}`
width: tableWidth, : "scopes: -";
columns: [ const ip = device.remoteIp ? ` · ${device.remoteIp}` : "";
{ key: "Device", header: "Device", minWidth: 16, flex: true }, const tokens = formatTokenSummary(device.tokens);
{ key: "Roles", header: "Roles", minWidth: 12, flex: true }, defaultRuntime.log(`- ${name} · ${roles} · ${scopes} · ${tokens}${ip}`);
{ key: "Scopes", header: "Scopes", minWidth: 12, flex: true }, }
{ key: "Tokens", header: "Tokens", minWidth: 12, flex: true },
{ key: "IP", header: "IP", minWidth: 12 },
],
rows: list.paired.map((device) => ({
Device: device.displayName || device.deviceId,
Roles: device.roles?.length ? device.roles.join(", ") : "",
Scopes: device.scopes?.length ? device.scopes.join(", ") : "",
Tokens: formatTokenSummary(device.tokens),
IP: device.remoteIp ?? "",
})),
}).trimEnd(),
);
} }
if (!list.pending?.length && !list.paired?.length) { if (!list.pending?.length && !list.paired?.length) {
defaultRuntime.log(theme.muted("No device pairing entries.")); defaultRuntime.log("No device pairing entries.");
} }
}), }),
); );
@@ -189,7 +160,7 @@ export function registerDevicesCli(program: Command) {
return; return;
} }
const deviceId = (result as { device?: { deviceId?: string } })?.device?.deviceId; const deviceId = (result as { device?: { deviceId?: string } })?.device?.deviceId;
defaultRuntime.log(`${theme.success("Approved")} ${theme.command(deviceId ?? "ok")}`); defaultRuntime.log(`device approved: ${deviceId ?? "ok"}`);
}), }),
); );
@@ -205,7 +176,7 @@ export function registerDevicesCli(program: Command) {
return; return;
} }
const deviceId = (result as { deviceId?: string })?.deviceId; const deviceId = (result as { deviceId?: string })?.deviceId;
defaultRuntime.log(`${theme.warn("Rejected")} ${theme.command(deviceId ?? "ok")}`); defaultRuntime.log(`device rejected: ${deviceId ?? "ok"}`);
}), }),
); );
+9 -56
View File
@@ -8,7 +8,6 @@ import { resolveMessageChannelSelection } from "../infra/outbound/channel-select
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js"; import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js"; import { theme } from "../terminal/theme.js";
import { renderTable } from "../terminal/table.js";
function parseLimit(value: unknown): number | null { function parseLimit(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) { if (typeof value === "number" && Number.isFinite(value)) {
@@ -23,18 +22,9 @@ function parseLimit(value: unknown): number | null {
return parsed; return parsed;
} }
function buildRows(entries: Array<{ id: string; name?: string | undefined }>) { function formatEntry(entry: { kind: string; id: string; name?: string | undefined }): string {
return entries.map((entry) => ({ const name = entry.name?.trim();
ID: entry.id, return name ? `${entry.id}\t${name}` : entry.id;
Name: entry.name?.trim() ?? "",
}));
}
function formatEntry(entry: { id: string; name?: string; handle?: string }) {
const name = entry.name?.trim() ?? "";
const handle = entry.handle?.trim() ?? "";
const label = name || handle;
return label ? `${entry.id} - ${label}` : entry.id;
} }
export function registerDirectoryCli(program: Command) { export function registerDirectoryCli(program: Command) {
@@ -87,21 +77,10 @@ export function registerDirectoryCli(program: Command) {
return; return;
} }
if (!result) { if (!result) {
defaultRuntime.log(theme.muted("Not available.")); defaultRuntime.log("not available");
return; return;
} }
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); defaultRuntime.log(formatEntry(result));
defaultRuntime.log(`${theme.heading("Self")}`);
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "ID", header: "ID", minWidth: 16, flex: true },
{ key: "Name", header: "Name", minWidth: 18, flex: true },
],
rows: buildRows([result]),
}).trimEnd(),
);
} catch (err) { } catch (err) {
defaultRuntime.error(danger(String(err))); defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1); defaultRuntime.exit(1);
@@ -132,22 +111,9 @@ export function registerDirectoryCli(program: Command) {
defaultRuntime.log(JSON.stringify(result, null, 2)); defaultRuntime.log(JSON.stringify(result, null, 2));
return; return;
} }
if (result.length === 0) { for (const entry of result) {
defaultRuntime.log(theme.muted("No peers found.")); defaultRuntime.log(formatEntry(entry));
return;
} }
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
defaultRuntime.log(`${theme.heading("Peers")} ${theme.muted(`(${result.length})`)}`);
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "ID", header: "ID", minWidth: 16, flex: true },
{ key: "Name", header: "Name", minWidth: 18, flex: true },
],
rows: buildRows(result),
}).trimEnd(),
);
} catch (err) { } catch (err) {
defaultRuntime.error(danger(String(err))); defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1); defaultRuntime.exit(1);
@@ -177,22 +143,9 @@ export function registerDirectoryCli(program: Command) {
defaultRuntime.log(JSON.stringify(result, null, 2)); defaultRuntime.log(JSON.stringify(result, null, 2));
return; return;
} }
if (result.length === 0) { for (const entry of result) {
defaultRuntime.log(theme.muted("No groups found.")); defaultRuntime.log(formatEntry(entry));
return;
} }
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
defaultRuntime.log(`${theme.heading("Groups")} ${theme.muted(`(${result.length})`)}`);
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "ID", header: "ID", minWidth: 16, flex: true },
{ key: "Name", header: "Name", minWidth: 18, flex: true },
],
rows: buildRows(result),
}).trimEnd(),
);
} catch (err) { } catch (err) {
defaultRuntime.error(danger(String(err))); defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1); defaultRuntime.exit(1);
+2 -5
View File
@@ -1,15 +1,12 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { defaultRuntime } from "../runtime.js";
const { buildProgram } = await import("./program.js"); const { buildProgram } = await import("./program.js");
describe("dns cli", () => { describe("dns cli", () => {
it("prints setup info (no apply)", async () => { it("prints setup info (no apply)", async () => {
const log = vi.spyOn(defaultRuntime, "log").mockImplementation(() => {}); const log = vi.spyOn(console, "log").mockImplementation(() => {});
const program = buildProgram(); const program = buildProgram();
await program.parseAsync(["dns", "setup"], { from: "user" }); await program.parseAsync(["dns", "setup"], { from: "user" });
const output = log.mock.calls.map((args) => args.join(" ")).join("\n"); expect(log).toHaveBeenCalledWith(expect.stringContaining("Domain:"));
expect(output).toContain("clawdbot.internal");
}); });
}); });
+18 -38
View File
@@ -7,9 +7,7 @@ import type { Command } from "commander";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js"; import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js";
import { getWideAreaZonePath, WIDE_AREA_DISCOVERY_DOMAIN } from "../infra/widearea-dns.js"; import { getWideAreaZonePath, WIDE_AREA_DISCOVERY_DOMAIN } from "../infra/widearea-dns.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js"; import { formatDocsLink } from "../terminal/links.js";
import { renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js"; import { theme } from "../terminal/theme.js";
type RunOpts = { allowFailure?: boolean; inherit?: boolean }; type RunOpts = { allowFailure?: boolean; inherit?: boolean };
@@ -114,28 +112,14 @@ export function registerDnsCli(program: Command) {
const tailnetIPv6 = pickPrimaryTailnetIPv6(); const tailnetIPv6 = pickPrimaryTailnetIPv6();
const zonePath = getWideAreaZonePath(); const zonePath = getWideAreaZonePath();
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); console.log(`Domain: ${WIDE_AREA_DISCOVERY_DOMAIN}`);
defaultRuntime.log(theme.heading("DNS setup")); console.log(`Zone file (gateway-owned): ${zonePath}`);
defaultRuntime.log( console.log(
renderTable({ `Detected tailnet IP: ${tailnetIPv4 ?? "—"}${tailnetIPv6 ? ` (v6 ${tailnetIPv6})` : ""}`,
width: tableWidth,
columns: [
{ key: "Key", header: "Key", minWidth: 18 },
{ key: "Value", header: "Value", minWidth: 24, flex: true },
],
rows: [
{ Key: "Domain", Value: WIDE_AREA_DISCOVERY_DOMAIN },
{ Key: "Zone file", Value: zonePath },
{
Key: "Tailnet IP",
Value: `${tailnetIPv4 ?? "—"}${tailnetIPv6 ? ` (v6 ${tailnetIPv6})` : ""}`,
},
],
}).trimEnd(),
); );
defaultRuntime.log(""); console.log("");
defaultRuntime.log(theme.heading("Recommended ~/.clawdbot/clawdbot.json:")); console.log("Recommended ~/.clawdbot/clawdbot.json:");
defaultRuntime.log( console.log(
JSON.stringify( JSON.stringify(
{ {
gateway: { bind: "auto" }, gateway: { bind: "auto" },
@@ -145,16 +129,14 @@ export function registerDnsCli(program: Command) {
2, 2,
), ),
); );
defaultRuntime.log(""); console.log("");
defaultRuntime.log(theme.heading("Tailscale admin (DNS → Nameservers):")); console.log("Tailscale admin (DNS → Nameservers):");
defaultRuntime.log( console.log(`- Add nameserver: ${tailnetIPv4 ?? "<this machine's tailnet IPv4>"}`);
theme.muted(`- Add nameserver: ${tailnetIPv4 ?? "<this machine's tailnet IPv4>"}`), console.log(`- Restrict to domain (Split DNS): clawdbot.internal`);
);
defaultRuntime.log(theme.muted("- Restrict to domain (Split DNS): clawdbot.internal"));
if (!opts.apply) { if (!opts.apply) {
defaultRuntime.log(""); console.log("");
defaultRuntime.log(theme.muted("Run with --apply to install CoreDNS and configure it.")); console.log("Run with --apply to install CoreDNS and configure it.");
return; return;
} }
@@ -223,18 +205,16 @@ export function registerDnsCli(program: Command) {
fs.writeFileSync(zonePath, zoneLines.join("\n"), "utf-8"); fs.writeFileSync(zonePath, zoneLines.join("\n"), "utf-8");
} }
defaultRuntime.log(""); console.log("");
defaultRuntime.log(theme.heading("Starting CoreDNS (sudo)…")); console.log("Starting CoreDNS (sudo)…");
run("sudo", ["brew", "services", "restart", "coredns"], { run("sudo", ["brew", "services", "restart", "coredns"], {
inherit: true, inherit: true,
}); });
if (cfg.discovery?.wideArea?.enabled !== true) { if (cfg.discovery?.wideArea?.enabled !== true) {
defaultRuntime.log(""); console.log("");
defaultRuntime.log( console.log(
theme.muted( "Note: enable discovery.wideArea.enabled in ~/.clawdbot/clawdbot.json on the gateway and restart the gateway so it writes the DNS-SD zone.",
"Note: enable discovery.wideArea.enabled in ~/.clawdbot/clawdbot.json on the gateway and restart the gateway so it writes the DNS-SD zone.",
),
); );
} }
}); });
-21
View File
@@ -1,21 +0,0 @@
import { theme } from "../terminal/theme.js";
export type HelpExample = [command: string, description: string];
export function formatHelpExample(command: string, description: string): string {
return ` ${theme.command(command)}\n ${theme.muted(description)}`;
}
export function formatHelpExampleLine(command: string, description: string): string {
if (!description) return ` ${theme.command(command)}`;
return ` ${theme.command(command)} ${theme.muted(`# ${description}`)}`;
}
export function formatHelpExamples(examples: HelpExample[], inline = false): string {
const formatter = inline ? formatHelpExampleLine : formatHelpExample;
return examples.map(([command, description]) => formatter(command, description)).join("\n");
}
export function formatHelpExampleGroup(label: string, examples: HelpExample[], inline = false) {
return `${theme.muted(label)}\n${formatHelpExamples(examples, inline)}`;
}
+112 -130
View File
@@ -1,6 +1,7 @@
import fs from "node:fs"; import fs from "node:fs";
import fsp from "node:fs/promises"; import fsp from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import chalk from "chalk";
import type { Command } from "commander"; import type { Command } from "commander";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
@@ -22,7 +23,6 @@ import { recordHookInstall } from "../hooks/installs.js";
import { buildPluginStatusReport } from "../plugins/status.js"; import { buildPluginStatusReport } from "../plugins/status.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js"; import { formatDocsLink } from "../terminal/links.js";
import { renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js"; import { theme } from "../terminal/theme.js";
import { formatCliCommand } from "./command-format.js"; import { formatCliCommand } from "./command-format.js";
import { resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
@@ -66,40 +66,50 @@ function buildHooksReport(config: ClawdbotConfig): HookStatusReport {
return buildWorkspaceHookStatus(workspaceDir, { config, entries }); return buildWorkspaceHookStatus(workspaceDir, { config, entries });
} }
function formatHookStatus(hook: HookStatusEntry): string { /**
if (hook.eligible) return theme.success("✓ ready"); * Format a single hook for display in the list
if (hook.disabled) return theme.warn("⏸ disabled"); */
return theme.error("✗ missing"); function formatHookLine(hook: HookStatusEntry, verbose = false): string {
}
function formatHookName(hook: HookStatusEntry): string {
const emoji = hook.emoji ?? "🔗"; const emoji = hook.emoji ?? "🔗";
return `${emoji} ${theme.command(hook.name)}`; const status = hook.eligible
} ? chalk.green("✓")
: hook.disabled
? chalk.yellow("disabled")
: chalk.red("missing reqs");
function formatHookSource(hook: HookStatusEntry): string { const name = hook.eligible ? chalk.white(hook.name) : chalk.gray(hook.name);
if (!hook.managedByPlugin) return hook.source;
return `plugin:${hook.pluginId ?? "unknown"}`;
}
function formatHookMissingSummary(hook: HookStatusEntry): string { const desc = chalk.gray(
const missing: string[] = []; hook.description.length > 50 ? `${hook.description.slice(0, 47)}...` : hook.description,
if (hook.missing.bins.length > 0) { );
missing.push(`bins: ${hook.missing.bins.join(", ")}`); const sourceLabel = hook.managedByPlugin
? chalk.magenta(`plugin:${hook.pluginId ?? "unknown"}`)
: "";
if (verbose) {
const missing: string[] = [];
if (hook.missing.bins.length > 0) {
missing.push(`bins: ${hook.missing.bins.join(", ")}`);
}
if (hook.missing.anyBins.length > 0) {
missing.push(`anyBins: ${hook.missing.anyBins.join(", ")}`);
}
if (hook.missing.env.length > 0) {
missing.push(`env: ${hook.missing.env.join(", ")}`);
}
if (hook.missing.config.length > 0) {
missing.push(`config: ${hook.missing.config.join(", ")}`);
}
if (hook.missing.os.length > 0) {
missing.push(`os: ${hook.missing.os.join(", ")}`);
}
const missingStr = missing.length > 0 ? chalk.red(` [${missing.join("; ")}]`) : "";
const sourceSuffix = sourceLabel ? ` ${sourceLabel}` : "";
return `${emoji} ${name} ${status}${missingStr}\n ${desc}${sourceSuffix}`;
} }
if (hook.missing.anyBins.length > 0) {
missing.push(`anyBins: ${hook.missing.anyBins.join(", ")}`); const sourceSuffix = sourceLabel ? ` ${sourceLabel}` : "";
} return `${emoji} ${name} ${status} - ${desc}${sourceSuffix}`;
if (hook.missing.env.length > 0) {
missing.push(`env: ${hook.missing.env.join(", ")}`);
}
if (hook.missing.config.length > 0) {
missing.push(`config: ${hook.missing.config.join(", ")}`);
}
if (hook.missing.os.length > 0) {
missing.push(`os: ${hook.missing.os.join(", ")}`);
}
return missing.join("; ");
} }
async function readInstalledPackageVersion(dir: string): Promise<string | undefined> { async function readInstalledPackageVersion(dir: string): Promise<string | undefined> {
@@ -147,39 +157,27 @@ export function formatHooksList(report: HookStatusReport, opts: HooksListOptions
} }
const eligible = hooks.filter((h) => h.eligible); const eligible = hooks.filter((h) => h.eligible);
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); const notEligible = hooks.filter((h) => !h.eligible);
const rows = hooks.map((hook) => {
const missing = formatHookMissingSummary(hook);
return {
Status: formatHookStatus(hook),
Hook: formatHookName(hook),
Description: theme.muted(hook.description),
Source: formatHookSource(hook),
Missing: missing ? theme.warn(missing) : "",
};
});
const columns = [
{ key: "Status", header: "Status", minWidth: 10 },
{ key: "Hook", header: "Hook", minWidth: 18, flex: true },
{ key: "Description", header: "Description", minWidth: 24, flex: true },
{ key: "Source", header: "Source", minWidth: 12, flex: true },
];
if (opts.verbose) {
columns.push({ key: "Missing", header: "Missing", minWidth: 18, flex: true });
}
const lines: string[] = []; const lines: string[] = [];
lines.push( lines.push(chalk.bold.cyan("Hooks") + chalk.gray(` (${eligible.length}/${hooks.length} ready)`));
`${theme.heading("Hooks")} ${theme.muted(`(${eligible.length}/${hooks.length} ready)`)}`, lines.push("");
);
lines.push( if (eligible.length > 0) {
renderTable({ lines.push(chalk.bold.green("Ready:"));
width: tableWidth, for (const hook of eligible) {
columns, lines.push(` ${formatHookLine(hook, opts.verbose)}`);
rows, }
}).trimEnd(), }
);
if (notEligible.length > 0 && !opts.eligible) {
if (eligible.length > 0) lines.push("");
lines.push(chalk.bold.yellow("Not ready:"));
for (const hook of notEligible) {
lines.push(` ${formatHookLine(hook, opts.verbose)}`);
}
}
return lines.join("\n"); return lines.join("\n");
} }
@@ -207,33 +205,33 @@ export function formatHookInfo(
const lines: string[] = []; const lines: string[] = [];
const emoji = hook.emoji ?? "🔗"; const emoji = hook.emoji ?? "🔗";
const status = hook.eligible const status = hook.eligible
? theme.success("✓ Ready") ? chalk.green("✓ Ready")
: hook.disabled : hook.disabled
? theme.warn("⏸ Disabled") ? chalk.yellow("⏸ Disabled")
: theme.error("✗ Missing requirements"); : chalk.red("✗ Missing requirements");
lines.push(`${emoji} ${theme.heading(hook.name)} ${status}`); lines.push(`${emoji} ${chalk.bold.cyan(hook.name)} ${status}`);
lines.push(""); lines.push("");
lines.push(hook.description); lines.push(chalk.white(hook.description));
lines.push(""); lines.push("");
// Details // Details
lines.push(theme.heading("Details:")); lines.push(chalk.bold("Details:"));
if (hook.managedByPlugin) { if (hook.managedByPlugin) {
lines.push(`${theme.muted(" Source:")} ${hook.source} (${hook.pluginId ?? "unknown"})`); lines.push(` Source: ${hook.source} (${hook.pluginId ?? "unknown"})`);
} else { } else {
lines.push(`${theme.muted(" Source:")} ${hook.source}`); lines.push(` Source: ${hook.source}`);
} }
lines.push(`${theme.muted(" Path:")} ${hook.filePath}`); lines.push(` Path: ${chalk.gray(hook.filePath)}`);
lines.push(`${theme.muted(" Handler:")} ${hook.handlerPath}`); lines.push(` Handler: ${chalk.gray(hook.handlerPath)}`);
if (hook.homepage) { if (hook.homepage) {
lines.push(`${theme.muted(" Homepage:")} ${hook.homepage}`); lines.push(` Homepage: ${chalk.blue(hook.homepage)}`);
} }
if (hook.events.length > 0) { if (hook.events.length > 0) {
lines.push(`${theme.muted(" Events:")} ${hook.events.join(", ")}`); lines.push(` Events: ${hook.events.join(", ")}`);
} }
if (hook.managedByPlugin) { if (hook.managedByPlugin) {
lines.push(theme.muted(" Managed by plugin; enable/disable via hooks CLI not available.")); lines.push(` Managed by plugin; enable/disable via hooks CLI not available.`);
} }
// Requirements // Requirements
@@ -246,40 +244,40 @@ export function formatHookInfo(
if (hasRequirements) { if (hasRequirements) {
lines.push(""); lines.push("");
lines.push(theme.heading("Requirements:")); lines.push(chalk.bold("Requirements:"));
if (hook.requirements.bins.length > 0) { if (hook.requirements.bins.length > 0) {
const binsStatus = hook.requirements.bins.map((bin) => { const binsStatus = hook.requirements.bins.map((bin) => {
const missing = hook.missing.bins.includes(bin); const missing = hook.missing.bins.includes(bin);
return missing ? theme.error(`✗ ${bin}`) : theme.success(`✓ ${bin}`); return missing ? chalk.red(`✗ ${bin}`) : chalk.green(`✓ ${bin}`);
}); });
lines.push(`${theme.muted(" Binaries:")} ${binsStatus.join(", ")}`); lines.push(` Binaries: ${binsStatus.join(", ")}`);
} }
if (hook.requirements.anyBins.length > 0) { if (hook.requirements.anyBins.length > 0) {
const anyBinsStatus = const anyBinsStatus =
hook.missing.anyBins.length > 0 hook.missing.anyBins.length > 0
? theme.error(`✗ (any of: ${hook.requirements.anyBins.join(", ")})`) ? chalk.red(`✗ (any of: ${hook.requirements.anyBins.join(", ")})`)
: theme.success(`✓ (any of: ${hook.requirements.anyBins.join(", ")})`); : chalk.green(`✓ (any of: ${hook.requirements.anyBins.join(", ")})`);
lines.push(`${theme.muted(" Any binary:")} ${anyBinsStatus}`); lines.push(` Any binary: ${anyBinsStatus}`);
} }
if (hook.requirements.env.length > 0) { if (hook.requirements.env.length > 0) {
const envStatus = hook.requirements.env.map((env) => { const envStatus = hook.requirements.env.map((env) => {
const missing = hook.missing.env.includes(env); const missing = hook.missing.env.includes(env);
return missing ? theme.error(`✗ ${env}`) : theme.success(`✓ ${env}`); return missing ? chalk.red(`✗ ${env}`) : chalk.green(`✓ ${env}`);
}); });
lines.push(`${theme.muted(" Environment:")} ${envStatus.join(", ")}`); lines.push(` Environment: ${envStatus.join(", ")}`);
} }
if (hook.requirements.config.length > 0) { if (hook.requirements.config.length > 0) {
const configStatus = hook.configChecks.map((check) => { const configStatus = hook.configChecks.map((check) => {
return check.satisfied ? theme.success(`✓ ${check.path}`) : theme.error(`✗ ${check.path}`); return check.satisfied ? chalk.green(`✓ ${check.path}`) : chalk.red(`✗ ${check.path}`);
}); });
lines.push(`${theme.muted(" Config:")} ${configStatus.join(", ")}`); lines.push(` Config: ${configStatus.join(", ")}`);
} }
if (hook.requirements.os.length > 0) { if (hook.requirements.os.length > 0) {
const osStatus = const osStatus =
hook.missing.os.length > 0 hook.missing.os.length > 0
? theme.error(`✗ (${hook.requirements.os.join(", ")})`) ? chalk.red(`✗ (${hook.requirements.os.join(", ")})`)
: theme.success(`✓ (${hook.requirements.os.join(", ")})`); : chalk.green(`✓ (${hook.requirements.os.join(", ")})`);
lines.push(`${theme.muted(" OS:")} ${osStatus}`); lines.push(` OS: ${osStatus}`);
} }
} }
@@ -315,15 +313,15 @@ export function formatHooksCheck(report: HookStatusReport, opts: HooksCheckOptio
const notEligible = report.hooks.filter((h) => !h.eligible); const notEligible = report.hooks.filter((h) => !h.eligible);
const lines: string[] = []; const lines: string[] = [];
lines.push(theme.heading("Hooks Status")); lines.push(chalk.bold.cyan("Hooks Status"));
lines.push(""); lines.push("");
lines.push(`${theme.muted("Total hooks:")} ${report.hooks.length}`); lines.push(`Total hooks: ${report.hooks.length}`);
lines.push(`${theme.success("Ready:")} ${eligible.length}`); lines.push(chalk.green(`Ready: ${eligible.length}`));
lines.push(`${theme.warn("Not ready:")} ${notEligible.length}`); lines.push(chalk.yellow(`Not ready: ${notEligible.length}`));
if (notEligible.length > 0) { if (notEligible.length > 0) {
lines.push(""); lines.push("");
lines.push(theme.heading("Hooks not ready:")); lines.push(chalk.bold.yellow("Hooks not ready:"));
for (const hook of notEligible) { for (const hook of notEligible) {
const reasons = []; const reasons = [];
if (hook.disabled) reasons.push("disabled"); if (hook.disabled) reasons.push("disabled");
@@ -376,9 +374,7 @@ export async function enableHook(hookName: string): Promise<void> {
}; };
await writeConfigFile(nextConfig); await writeConfigFile(nextConfig);
defaultRuntime.log( console.log(`${chalk.green("✓")} Enabled hook: ${hook.emoji ?? "🔗"} ${hookName}`);
`${theme.success("✓")} Enabled hook: ${hook.emoji ?? "🔗"} ${theme.command(hookName)}`,
);
} }
export async function disableHook(hookName: string): Promise<void> { export async function disableHook(hookName: string): Promise<void> {
@@ -412,9 +408,7 @@ export async function disableHook(hookName: string): Promise<void> {
}; };
await writeConfigFile(nextConfig); await writeConfigFile(nextConfig);
defaultRuntime.log( console.log(`${chalk.yellow("⏸")} Disabled hook: ${hook.emoji ?? "🔗"} ${hookName}`);
`${theme.warn("⏸")} Disabled hook: ${hook.emoji ?? "🔗"} ${theme.command(hookName)}`,
);
} }
export function registerHooksCli(program: Command): void { export function registerHooksCli(program: Command): void {
@@ -437,11 +431,9 @@ export function registerHooksCli(program: Command): void {
try { try {
const config = loadConfig(); const config = loadConfig();
const report = buildHooksReport(config); const report = buildHooksReport(config);
defaultRuntime.log(formatHooksList(report, opts)); console.log(formatHooksList(report, opts));
} catch (err) { } catch (err) {
defaultRuntime.error( console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
`${theme.error("Error:")} ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1); process.exit(1);
} }
}); });
@@ -454,11 +446,9 @@ export function registerHooksCli(program: Command): void {
try { try {
const config = loadConfig(); const config = loadConfig();
const report = buildHooksReport(config); const report = buildHooksReport(config);
defaultRuntime.log(formatHookInfo(report, name, opts)); console.log(formatHookInfo(report, name, opts));
} catch (err) { } catch (err) {
defaultRuntime.error( console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
`${theme.error("Error:")} ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1); process.exit(1);
} }
}); });
@@ -471,11 +461,9 @@ export function registerHooksCli(program: Command): void {
try { try {
const config = loadConfig(); const config = loadConfig();
const report = buildHooksReport(config); const report = buildHooksReport(config);
defaultRuntime.log(formatHooksCheck(report, opts)); console.log(formatHooksCheck(report, opts));
} catch (err) { } catch (err) {
defaultRuntime.error( console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
`${theme.error("Error:")} ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1); process.exit(1);
} }
}); });
@@ -487,9 +475,7 @@ export function registerHooksCli(program: Command): void {
try { try {
await enableHook(name); await enableHook(name);
} catch (err) { } catch (err) {
defaultRuntime.error( console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
`${theme.error("Error:")} ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1); process.exit(1);
} }
}); });
@@ -501,9 +487,7 @@ export function registerHooksCli(program: Command): void {
try { try {
await disableHook(name); await disableHook(name);
} catch (err) { } catch (err) {
defaultRuntime.error( console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
`${theme.error("Error:")} ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1); process.exit(1);
} }
}); });
@@ -586,7 +570,7 @@ export function registerHooksCli(program: Command): void {
path: resolved, path: resolved,
logger: { logger: {
info: (msg) => defaultRuntime.log(msg), info: (msg) => defaultRuntime.log(msg),
warn: (msg) => defaultRuntime.log(theme.warn(msg)), warn: (msg) => defaultRuntime.log(chalk.yellow(msg)),
}, },
}); });
if (!result.ok) { if (!result.ok) {
@@ -666,7 +650,7 @@ export function registerHooksCli(program: Command): void {
spec: raw, spec: raw,
logger: { logger: {
info: (msg) => defaultRuntime.log(msg), info: (msg) => defaultRuntime.log(msg),
warn: (msg) => defaultRuntime.log(theme.warn(msg)), warn: (msg) => defaultRuntime.log(chalk.yellow(msg)),
}, },
}); });
if (!result.ok) { if (!result.ok) {
@@ -742,15 +726,15 @@ export function registerHooksCli(program: Command): void {
for (const hookId of targets) { for (const hookId of targets) {
const record = installs[hookId]; const record = installs[hookId];
if (!record) { if (!record) {
defaultRuntime.log(theme.warn(`No install record for "${hookId}".`)); defaultRuntime.log(chalk.yellow(`No install record for "${hookId}".`));
continue; continue;
} }
if (record.source !== "npm") { if (record.source !== "npm") {
defaultRuntime.log(theme.warn(`Skipping "${hookId}" (source: ${record.source}).`)); defaultRuntime.log(chalk.yellow(`Skipping "${hookId}" (source: ${record.source}).`));
continue; continue;
} }
if (!record.spec) { if (!record.spec) {
defaultRuntime.log(theme.warn(`Skipping "${hookId}" (missing npm spec).`)); defaultRuntime.log(chalk.yellow(`Skipping "${hookId}" (missing npm spec).`));
continue; continue;
} }
@@ -765,11 +749,11 @@ export function registerHooksCli(program: Command): void {
expectedHookPackId: hookId, expectedHookPackId: hookId,
logger: { logger: {
info: (msg) => defaultRuntime.log(msg), info: (msg) => defaultRuntime.log(msg),
warn: (msg) => defaultRuntime.log(theme.warn(msg)), warn: (msg) => defaultRuntime.log(chalk.yellow(msg)),
}, },
}); });
if (!probe.ok) { if (!probe.ok) {
defaultRuntime.log(theme.error(`Failed to check ${hookId}: ${probe.error}`)); defaultRuntime.log(chalk.red(`Failed to check ${hookId}: ${probe.error}`));
continue; continue;
} }
@@ -789,11 +773,11 @@ export function registerHooksCli(program: Command): void {
expectedHookPackId: hookId, expectedHookPackId: hookId,
logger: { logger: {
info: (msg) => defaultRuntime.log(msg), info: (msg) => defaultRuntime.log(msg),
warn: (msg) => defaultRuntime.log(theme.warn(msg)), warn: (msg) => defaultRuntime.log(chalk.yellow(msg)),
}, },
}); });
if (!result.ok) { if (!result.ok) {
defaultRuntime.log(theme.error(`Failed to update ${hookId}: ${result.error}`)); defaultRuntime.log(chalk.red(`Failed to update ${hookId}: ${result.error}`));
continue; continue;
} }
@@ -827,11 +811,9 @@ export function registerHooksCli(program: Command): void {
try { try {
const config = loadConfig(); const config = loadConfig();
const report = buildHooksReport(config); const report = buildHooksReport(config);
defaultRuntime.log(formatHooksList(report, {})); console.log(formatHooksList(report, {}));
} catch (err) { } catch (err) {
defaultRuntime.error( console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
`${theme.error("Error:")} ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1); process.exit(1);
} }
}); });
+26 -133
View File
@@ -5,7 +5,6 @@ import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js"; import type { NodesRpcOpts } from "./types.js";
import { renderTable } from "../../terminal/table.js"; import { renderTable } from "../../terminal/table.js";
import { parseDurationMs } from "../parse-duration.js";
function formatVersionLabel(raw: string) { function formatVersionLabel(raw: string) {
const trimmed = raw.trim(); const trimmed = raw.trim();
@@ -44,88 +43,30 @@ function formatNodeVersions(node: {
return parts.length > 0 ? parts.join(" · ") : null; return parts.length > 0 ? parts.join(" · ") : null;
} }
function parseSinceMs(raw: unknown, label: string): number | undefined {
if (raw === undefined || raw === null) return undefined;
let value = "";
if (typeof raw === "string") {
value = raw.trim();
} else if (typeof raw === "number") {
value = `${raw}`;
} else {
defaultRuntime.error(`${label}: expected a duration string`);
defaultRuntime.exit(1);
return undefined;
}
if (!value) return undefined;
try {
return parseDurationMs(value);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
defaultRuntime.error(`${label}: ${message}`);
defaultRuntime.exit(1);
return undefined;
}
}
export function registerNodesStatusCommands(nodes: Command) { export function registerNodesStatusCommands(nodes: Command) {
nodesCallOpts( nodesCallOpts(
nodes nodes
.command("status") .command("status")
.description("List known nodes with connection status and capabilities") .description("List known nodes with connection status and capabilities")
.option("--connected", "Only show connected nodes")
.option("--last-connected <duration>", "Only show nodes connected within duration (e.g. 24h)")
.action(async (opts: NodesRpcOpts) => { .action(async (opts: NodesRpcOpts) => {
await runNodesCommand("status", async () => { await runNodesCommand("status", async () => {
const connectedOnly = Boolean(opts.connected);
const sinceMs = parseSinceMs(opts.lastConnected, "Invalid --last-connected");
const result = (await callGatewayCli("node.list", opts, {})) as unknown; const result = (await callGatewayCli("node.list", opts, {})) as unknown;
const obj = if (opts.json) {
typeof result === "object" && result !== null defaultRuntime.log(JSON.stringify(result, null, 2));
? (result as Record<string, unknown>) return;
: {}; }
const { ok, warn, muted } = getNodesTheme(); const { ok, warn, muted } = getNodesTheme();
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
const now = Date.now(); const now = Date.now();
const nodes = parseNodeList(result); const nodes = parseNodeList(result);
const lastConnectedById = const pairedCount = nodes.filter((n) => Boolean(n.paired)).length;
sinceMs !== undefined const connectedCount = nodes.filter((n) => Boolean(n.connected)).length;
? new Map(
parsePairingList(await callGatewayCli("node.pair.list", opts, {})).paired.map(
(entry) => [entry.nodeId, entry],
),
)
: null;
const filtered = nodes.filter((n) => {
if (connectedOnly && !n.connected) return false;
if (sinceMs !== undefined) {
const paired = lastConnectedById?.get(n.nodeId);
const lastConnectedAtMs =
typeof paired?.lastConnectedAtMs === "number"
? paired.lastConnectedAtMs
: typeof n.connectedAtMs === "number"
? n.connectedAtMs
: undefined;
if (typeof lastConnectedAtMs !== "number") return false;
if (now - lastConnectedAtMs > sinceMs) return false;
}
return true;
});
if (opts.json) {
const ts = typeof obj.ts === "number" ? obj.ts : Date.now();
defaultRuntime.log(JSON.stringify({ ...obj, ts, nodes: filtered }, null, 2));
return;
}
const pairedCount = filtered.filter((n) => Boolean(n.paired)).length;
const connectedCount = filtered.filter((n) => Boolean(n.connected)).length;
const filteredLabel = filtered.length !== nodes.length ? ` (of ${nodes.length})` : "";
defaultRuntime.log( defaultRuntime.log(
`Known: ${filtered.length}${filteredLabel} · Paired: ${pairedCount} · Connected: ${connectedCount}`, `Known: ${nodes.length} · Paired: ${pairedCount} · Connected: ${connectedCount}`,
); );
if (filtered.length === 0) return; if (nodes.length === 0) return;
const rows = filtered.map((n) => { const rows = nodes.map((n) => {
const name = n.displayName?.trim() ? n.displayName.trim() : n.nodeId; const name = n.displayName?.trim() ? n.displayName.trim() : n.nodeId;
const perms = formatPermissions(n.permissions); const perms = formatPermissions(n.permissions);
const versions = formatNodeVersions(n); const versions = formatNodeVersions(n);
@@ -256,60 +197,21 @@ export function registerNodesStatusCommands(nodes: Command) {
nodes nodes
.command("list") .command("list")
.description("List pending and paired nodes") .description("List pending and paired nodes")
.option("--connected", "Only show connected nodes")
.option("--last-connected <duration>", "Only show nodes connected within duration (e.g. 24h)")
.action(async (opts: NodesRpcOpts) => { .action(async (opts: NodesRpcOpts) => {
await runNodesCommand("list", async () => { await runNodesCommand("list", async () => {
const connectedOnly = Boolean(opts.connected);
const sinceMs = parseSinceMs(opts.lastConnected, "Invalid --last-connected");
const result = (await callGatewayCli("node.pair.list", opts, {})) as unknown; const result = (await callGatewayCli("node.pair.list", opts, {})) as unknown;
if (opts.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
const { pending, paired } = parsePairingList(result); const { pending, paired } = parsePairingList(result);
defaultRuntime.log(`Pending: ${pending.length} · Paired: ${paired.length}`);
const { heading, muted, warn } = getNodesTheme(); const { heading, muted, warn } = getNodesTheme();
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
const now = Date.now(); const now = Date.now();
const hasFilters = connectedOnly || sinceMs !== undefined;
const pendingRows = hasFilters ? [] : pending;
const connectedById = hasFilters
? new Map(
parseNodeList(await callGatewayCli("node.list", opts, {})).map((node) => [
node.nodeId,
node,
]),
)
: null;
const filteredPaired = paired.filter((node) => {
if (connectedOnly) {
const live = connectedById?.get(node.nodeId);
if (!live?.connected) return false;
}
if (sinceMs !== undefined) {
const live = connectedById?.get(node.nodeId);
const lastConnectedAtMs =
typeof node.lastConnectedAtMs === "number"
? node.lastConnectedAtMs
: typeof live?.connectedAtMs === "number"
? live.connectedAtMs
: undefined;
if (typeof lastConnectedAtMs !== "number") return false;
if (now - lastConnectedAtMs > sinceMs) return false;
}
return true;
});
const filteredLabel =
hasFilters && filteredPaired.length !== paired.length ? ` (of ${paired.length})` : "";
defaultRuntime.log(
`Pending: ${pendingRows.length} · Paired: ${filteredPaired.length}${filteredLabel}`,
);
if (opts.json) { if (pending.length > 0) {
defaultRuntime.log( const pendingRows = pending.map((r) => ({
JSON.stringify({ pending: pendingRows, paired: filteredPaired }, null, 2),
);
return;
}
if (pendingRows.length > 0) {
const pendingRowsRendered = pendingRows.map((r) => ({
Request: r.requestId, Request: r.requestId,
Node: r.displayName?.trim() ? r.displayName.trim() : r.nodeId, Node: r.displayName?.trim() ? r.displayName.trim() : r.nodeId,
IP: r.remoteIp ?? "", IP: r.remoteIp ?? "",
@@ -331,30 +233,21 @@ export function registerNodesStatusCommands(nodes: Command) {
{ key: "Requested", header: "Requested", minWidth: 12 }, { key: "Requested", header: "Requested", minWidth: 12 },
{ key: "Repair", header: "Repair", minWidth: 6 }, { key: "Repair", header: "Repair", minWidth: 6 },
], ],
rows: pendingRowsRendered, rows: pendingRows,
}).trimEnd(), }).trimEnd(),
); );
} }
if (filteredPaired.length > 0) { if (paired.length > 0) {
const pairedRows = filteredPaired.map((n) => { const pairedRows = paired.map((n) => ({
const live = connectedById?.get(n.nodeId); Node: n.displayName?.trim() ? n.displayName.trim() : n.nodeId,
const lastConnectedAtMs = Id: n.nodeId,
IP: n.remoteIp ?? "",
LastConnect:
typeof n.lastConnectedAtMs === "number" typeof n.lastConnectedAtMs === "number"
? n.lastConnectedAtMs ? `${formatAge(Math.max(0, now - n.lastConnectedAtMs))} ago`
: typeof live?.connectedAtMs === "number" : muted("unknown"),
? live.connectedAtMs }));
: undefined;
return {
Node: n.displayName?.trim() ? n.displayName.trim() : n.nodeId,
Id: n.nodeId,
IP: n.remoteIp ?? "",
LastConnect:
typeof lastConnectedAtMs === "number"
? `${formatAge(Math.max(0, now - lastConnectedAtMs))} ago`
: muted("unknown"),
};
});
defaultRuntime.log(""); defaultRuntime.log("");
defaultRuntime.log(heading("Paired")); defaultRuntime.log(heading("Paired"));
defaultRuntime.log( defaultRuntime.log(
-2
View File
@@ -8,8 +8,6 @@ export type NodesRpcOpts = {
params?: string; params?: string;
invokeTimeout?: string; invokeTimeout?: string;
idempotencyKey?: string; idempotencyKey?: string;
connected?: boolean;
lastConnected?: string;
target?: string; target?: string;
x?: string; x?: string;
y?: string; y?: string;
+5 -11
View File
@@ -1,8 +1,6 @@
import { Command } from "commander"; import { Command } from "commander";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { defaultRuntime } from "../runtime.js";
const listChannelPairingRequests = vi.fn(); const listChannelPairingRequests = vi.fn();
const approveChannelPairingCode = vi.fn(); const approveChannelPairingCode = vi.fn();
const notifyPairingApproved = vi.fn(); const notifyPairingApproved = vi.fn();
@@ -66,16 +64,14 @@ describe("pairing cli", () => {
}, },
]); ]);
const log = vi.spyOn(defaultRuntime, "log").mockImplementation(() => {}); const log = vi.spyOn(console, "log").mockImplementation(() => {});
const program = new Command(); const program = new Command();
program.name("test"); program.name("test");
registerPairingCli(program); registerPairingCli(program);
await program.parseAsync(["pairing", "list", "--channel", "telegram"], { await program.parseAsync(["pairing", "list", "--channel", "telegram"], {
from: "user", from: "user",
}); });
const output = log.mock.calls.map((args) => args.join(" ")).join("\n"); expect(log).toHaveBeenCalledWith(expect.stringContaining("telegramUserId=123"));
expect(output).toContain("telegramUserId");
expect(output).toContain("123");
}); });
it("accepts channel as positional for list", async () => { it("accepts channel as positional for list", async () => {
@@ -128,16 +124,14 @@ describe("pairing cli", () => {
}, },
]); ]);
const log = vi.spyOn(defaultRuntime, "log").mockImplementation(() => {}); const log = vi.spyOn(console, "log").mockImplementation(() => {});
const program = new Command(); const program = new Command();
program.name("test"); program.name("test");
registerPairingCli(program); registerPairingCli(program);
await program.parseAsync(["pairing", "list", "--channel", "discord"], { await program.parseAsync(["pairing", "list", "--channel", "discord"], {
from: "user", from: "user",
}); });
const output = log.mock.calls.map((args) => args.join(" ")).join("\n"); expect(log).toHaveBeenCalledWith(expect.stringContaining("discordUserId=999"));
expect(output).toContain("discordUserId");
expect(output).toContain("999");
}); });
it("accepts channel as positional for approve (npm-run compatible)", async () => { it("accepts channel as positional for approve (npm-run compatible)", async () => {
@@ -152,7 +146,7 @@ describe("pairing cli", () => {
}, },
}); });
const log = vi.spyOn(defaultRuntime, "log").mockImplementation(() => {}); const log = vi.spyOn(console, "log").mockImplementation(() => {});
const program = new Command(); const program = new Command();
program.name("test"); program.name("test");
registerPairingCli(program); registerPairingCli(program);
+9 -30
View File
@@ -8,9 +8,7 @@ import {
listChannelPairingRequests, listChannelPairingRequests,
type PairingChannel, type PairingChannel,
} from "../pairing/pairing-store.js"; } from "../pairing/pairing-store.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js"; import { formatDocsLink } from "../terminal/links.js";
import { renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js"; import { theme } from "../terminal/theme.js";
import { formatCliCommand } from "./command-format.js"; import { formatCliCommand } from "./command-format.js";
@@ -72,35 +70,18 @@ export function registerPairingCli(program: Command) {
const channel = parseChannel(channelRaw, channels); const channel = parseChannel(channelRaw, channels);
const requests = await listChannelPairingRequests(channel); const requests = await listChannelPairingRequests(channel);
if (opts.json) { if (opts.json) {
defaultRuntime.log(JSON.stringify({ channel, requests }, null, 2)); console.log(JSON.stringify({ channel, requests }, null, 2));
return; return;
} }
if (requests.length === 0) { if (requests.length === 0) {
defaultRuntime.log(theme.muted(`No pending ${channel} pairing requests.`)); console.log(`No pending ${channel} pairing requests.`);
return; return;
} }
const idLabel = resolvePairingIdLabel(channel); for (const r of requests) {
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); const meta = r.meta ? JSON.stringify(r.meta) : "";
defaultRuntime.log( const idLabel = resolvePairingIdLabel(channel);
`${theme.heading("Pairing requests")} ${theme.muted(`(${requests.length})`)}`, console.log(`${r.code} ${idLabel}=${r.id}${meta ? ` meta=${meta}` : ""} ${r.createdAt}`);
); }
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Code", header: "Code", minWidth: 10 },
{ key: "ID", header: idLabel, minWidth: 12, flex: true },
{ key: "Meta", header: "Meta", minWidth: 8, flex: true },
{ key: "Requested", header: "Requested", minWidth: 12 },
],
rows: requests.map((r) => ({
Code: r.code,
ID: r.id,
Meta: r.meta ? JSON.stringify(r.meta) : "",
Requested: r.createdAt,
})),
}).trimEnd(),
);
}); });
pairing pairing
@@ -132,13 +113,11 @@ export function registerPairingCli(program: Command) {
throw new Error(`No pending pairing request found for code: ${String(resolvedCode)}`); throw new Error(`No pending pairing request found for code: ${String(resolvedCode)}`);
} }
defaultRuntime.log( console.log(`Approved ${channel} sender ${approved.id}.`);
`${theme.success("Approved")} ${theme.muted(channel)} sender ${theme.command(approved.id)}.`,
);
if (!opts.notify) return; if (!opts.notify) return;
await notifyApproved(channel, approved.id).catch((err) => { await notifyApproved(channel, approved.id).catch((err) => {
defaultRuntime.log(theme.warn(`Failed to notify requester: ${String(err)}`)); console.log(`Failed to notify requester: ${String(err)}`);
}); });
}); });
} }
+45 -79
View File
@@ -1,5 +1,6 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import chalk from "chalk";
import type { Command } from "commander"; import type { Command } from "commander";
import { loadConfig, writeConfigFile } from "../config/config.js"; import { loadConfig, writeConfigFile } from "../config/config.js";
@@ -13,7 +14,6 @@ import { buildPluginStatusReport } from "../plugins/status.js";
import { updateNpmInstalledPlugins } from "../plugins/update.js"; import { updateNpmInstalledPlugins } from "../plugins/update.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js"; import { formatDocsLink } from "../terminal/links.js";
import { renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js"; import { theme } from "../terminal/theme.js";
import { resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
@@ -35,19 +35,19 @@ export type PluginUpdateOptions = {
function formatPluginLine(plugin: PluginRecord, verbose = false): string { function formatPluginLine(plugin: PluginRecord, verbose = false): string {
const status = const status =
plugin.status === "loaded" plugin.status === "loaded"
? theme.success("loaded") ? chalk.green("✓")
: plugin.status === "disabled" : plugin.status === "disabled"
? theme.warn("disabled") ? chalk.yellow("disabled")
: theme.error("error"); : chalk.red("error");
const name = theme.command(plugin.name || plugin.id); const name = plugin.name ? chalk.white(plugin.name) : chalk.white(plugin.id);
const idSuffix = plugin.name && plugin.name !== plugin.id ? theme.muted(` (${plugin.id})`) : ""; const idSuffix = plugin.name !== plugin.id ? chalk.gray(` (${plugin.id})`) : "";
const desc = plugin.description const desc = plugin.description
? theme.muted( ? chalk.gray(
plugin.description.length > 60 plugin.description.length > 60
? `${plugin.description.slice(0, 57)}...` ? `${plugin.description.slice(0, 57)}...`
: plugin.description, : plugin.description,
) )
: theme.muted("(no description)"); : chalk.gray("(no description)");
if (!verbose) { if (!verbose) {
return `${name}${idSuffix} ${status} - ${desc}`; return `${name}${idSuffix} ${status} - ${desc}`;
@@ -55,14 +55,14 @@ function formatPluginLine(plugin: PluginRecord, verbose = false): string {
const parts = [ const parts = [
`${name}${idSuffix} ${status}`, `${name}${idSuffix} ${status}`,
` source: ${theme.muted(plugin.source)}`, ` source: ${chalk.gray(plugin.source)}`,
` origin: ${plugin.origin}`, ` origin: ${plugin.origin}`,
]; ];
if (plugin.version) parts.push(` version: ${plugin.version}`); if (plugin.version) parts.push(` version: ${plugin.version}`);
if (plugin.providerIds.length > 0) { if (plugin.providerIds.length > 0) {
parts.push(` providers: ${plugin.providerIds.join(", ")}`); parts.push(` providers: ${plugin.providerIds.join(", ")}`);
} }
if (plugin.error) parts.push(theme.error(` error: ${plugin.error}`)); if (plugin.error) parts.push(chalk.red(` error: ${plugin.error}`));
return parts.join("\n"); return parts.join("\n");
} }
@@ -87,7 +87,7 @@ function applySlotSelectionForPlugin(
function logSlotWarnings(warnings: string[]) { function logSlotWarnings(warnings: string[]) {
if (warnings.length === 0) return; if (warnings.length === 0) return;
for (const warning of warnings) { for (const warning of warnings) {
defaultRuntime.log(theme.warn(warning)); defaultRuntime.log(chalk.yellow(warning));
} }
} }
@@ -124,51 +124,19 @@ export function registerPluginsCli(program: Command) {
} }
if (list.length === 0) { if (list.length === 0) {
defaultRuntime.log(theme.muted("No plugins found.")); defaultRuntime.log("No plugins found.");
return;
}
const loaded = list.filter((p) => p.status === "loaded").length;
defaultRuntime.log(
`${theme.heading("Plugins")} ${theme.muted(`(${loaded}/${list.length} loaded)`)}`,
);
if (!opts.verbose) {
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
const rows = list.map((plugin) => ({
Name: plugin.name || plugin.id,
ID: plugin.name && plugin.name !== plugin.id ? plugin.id : "",
Status:
plugin.status === "loaded"
? theme.success("loaded")
: plugin.status === "disabled"
? theme.warn("disabled")
: theme.error("error"),
Source: plugin.source,
Version: plugin.version ?? "",
Description: plugin.description ?? "",
}));
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Name", header: "Name", minWidth: 14, flex: true },
{ key: "ID", header: "ID", minWidth: 10, flex: true },
{ key: "Status", header: "Status", minWidth: 10 },
{ key: "Source", header: "Source", minWidth: 10 },
{ key: "Version", header: "Version", minWidth: 8 },
{ key: "Description", header: "Description", minWidth: 18, flex: true },
],
rows,
}).trimEnd(),
);
return; return;
} }
const lines: string[] = []; const lines: string[] = [];
const loaded = list.filter((p) => p.status === "loaded").length;
lines.push(
`${chalk.bold.cyan("Plugins")} ${chalk.gray(`(${loaded}/${list.length} loaded)`)}`,
);
lines.push("");
for (const plugin of list) { for (const plugin of list) {
lines.push(formatPluginLine(plugin, true)); lines.push(formatPluginLine(plugin, opts.verbose));
lines.push(""); if (opts.verbose) lines.push("");
} }
defaultRuntime.log(lines.join("\n").trim()); defaultRuntime.log(lines.join("\n").trim());
}); });
@@ -194,45 +162,43 @@ export function registerPluginsCli(program: Command) {
} }
const lines: string[] = []; const lines: string[] = [];
lines.push(theme.heading(plugin.name || plugin.id)); lines.push(chalk.bold.cyan(plugin.name || plugin.id));
if (plugin.name && plugin.name !== plugin.id) { if (plugin.name && plugin.name !== plugin.id) {
lines.push(theme.muted(`id: ${plugin.id}`)); lines.push(chalk.gray(`id: ${plugin.id}`));
} }
if (plugin.description) lines.push(plugin.description); if (plugin.description) lines.push(plugin.description);
lines.push(""); lines.push("");
lines.push(`${theme.muted("Status:")} ${plugin.status}`); lines.push(`Status: ${plugin.status}`);
lines.push(`${theme.muted("Source:")} ${plugin.source}`); lines.push(`Source: ${plugin.source}`);
lines.push(`${theme.muted("Origin:")} ${plugin.origin}`); lines.push(`Origin: ${plugin.origin}`);
if (plugin.version) lines.push(`${theme.muted("Version:")} ${plugin.version}`); if (plugin.version) lines.push(`Version: ${plugin.version}`);
if (plugin.toolNames.length > 0) { if (plugin.toolNames.length > 0) {
lines.push(`${theme.muted("Tools:")} ${plugin.toolNames.join(", ")}`); lines.push(`Tools: ${plugin.toolNames.join(", ")}`);
} }
if (plugin.hookNames.length > 0) { if (plugin.hookNames.length > 0) {
lines.push(`${theme.muted("Hooks:")} ${plugin.hookNames.join(", ")}`); lines.push(`Hooks: ${plugin.hookNames.join(", ")}`);
} }
if (plugin.gatewayMethods.length > 0) { if (plugin.gatewayMethods.length > 0) {
lines.push(`${theme.muted("Gateway methods:")} ${plugin.gatewayMethods.join(", ")}`); lines.push(`Gateway methods: ${plugin.gatewayMethods.join(", ")}`);
} }
if (plugin.providerIds.length > 0) { if (plugin.providerIds.length > 0) {
lines.push(`${theme.muted("Providers:")} ${plugin.providerIds.join(", ")}`); lines.push(`Providers: ${plugin.providerIds.join(", ")}`);
} }
if (plugin.cliCommands.length > 0) { if (plugin.cliCommands.length > 0) {
lines.push(`${theme.muted("CLI commands:")} ${plugin.cliCommands.join(", ")}`); lines.push(`CLI commands: ${plugin.cliCommands.join(", ")}`);
} }
if (plugin.services.length > 0) { if (plugin.services.length > 0) {
lines.push(`${theme.muted("Services:")} ${plugin.services.join(", ")}`); lines.push(`Services: ${plugin.services.join(", ")}`);
} }
if (plugin.error) lines.push(`${theme.error("Error:")} ${plugin.error}`); if (plugin.error) lines.push(chalk.red(`Error: ${plugin.error}`));
if (install) { if (install) {
lines.push(""); lines.push("");
lines.push(`${theme.muted("Install:")} ${install.source}`); lines.push(`Install: ${install.source}`);
if (install.spec) lines.push(`${theme.muted("Spec:")} ${install.spec}`); if (install.spec) lines.push(`Spec: ${install.spec}`);
if (install.sourcePath) lines.push(`${theme.muted("Source path:")} ${install.sourcePath}`); if (install.sourcePath) lines.push(`Source path: ${install.sourcePath}`);
if (install.installPath) if (install.installPath) lines.push(`Install path: ${install.installPath}`);
lines.push(`${theme.muted("Install path:")} ${install.installPath}`); if (install.version) lines.push(`Recorded version: ${install.version}`);
if (install.version) lines.push(`${theme.muted("Recorded version:")} ${install.version}`); if (install.installedAt) lines.push(`Installed at: ${install.installedAt}`);
if (install.installedAt)
lines.push(`${theme.muted("Installed at:")} ${install.installedAt}`);
} }
defaultRuntime.log(lines.join("\n")); defaultRuntime.log(lines.join("\n"));
}); });
@@ -342,7 +308,7 @@ export function registerPluginsCli(program: Command) {
path: resolved, path: resolved,
logger: { logger: {
info: (msg) => defaultRuntime.log(msg), info: (msg) => defaultRuntime.log(msg),
warn: (msg) => defaultRuntime.log(theme.warn(msg)), warn: (msg) => defaultRuntime.log(chalk.yellow(msg)),
}, },
}); });
if (!result.ok) { if (!result.ok) {
@@ -406,7 +372,7 @@ export function registerPluginsCli(program: Command) {
spec: raw, spec: raw,
logger: { logger: {
info: (msg) => defaultRuntime.log(msg), info: (msg) => defaultRuntime.log(msg),
warn: (msg) => defaultRuntime.log(theme.warn(msg)), warn: (msg) => defaultRuntime.log(chalk.yellow(msg)),
}, },
}); });
if (!result.ok) { if (!result.ok) {
@@ -464,17 +430,17 @@ export function registerPluginsCli(program: Command) {
dryRun: opts.dryRun, dryRun: opts.dryRun,
logger: { logger: {
info: (msg) => defaultRuntime.log(msg), info: (msg) => defaultRuntime.log(msg),
warn: (msg) => defaultRuntime.log(theme.warn(msg)), warn: (msg) => defaultRuntime.log(chalk.yellow(msg)),
}, },
}); });
for (const outcome of result.outcomes) { for (const outcome of result.outcomes) {
if (outcome.status === "error") { if (outcome.status === "error") {
defaultRuntime.log(theme.error(outcome.message)); defaultRuntime.log(chalk.red(outcome.message));
continue; continue;
} }
if (outcome.status === "skipped") { if (outcome.status === "skipped") {
defaultRuntime.log(theme.warn(outcome.message)); defaultRuntime.log(chalk.yellow(outcome.message));
continue; continue;
} }
defaultRuntime.log(outcome.message); defaultRuntime.log(outcome.message);
@@ -501,14 +467,14 @@ export function registerPluginsCli(program: Command) {
const lines: string[] = []; const lines: string[] = [];
if (errors.length > 0) { if (errors.length > 0) {
lines.push(theme.error("Plugin errors:")); lines.push(chalk.bold.red("Plugin errors:"));
for (const entry of errors) { for (const entry of errors) {
lines.push(`- ${entry.id}: ${entry.error ?? "failed to load"} (${entry.source})`); lines.push(`- ${entry.id}: ${entry.error ?? "failed to load"} (${entry.source})`);
} }
} }
if (diags.length > 0) { if (diags.length > 0) {
if (lines.length > 0) lines.push(""); if (lines.length > 0) lines.push("");
lines.push(theme.warn("Diagnostics:")); lines.push(chalk.bold.yellow("Diagnostics:"));
for (const diag of diags) { for (const diag of diags) {
const target = diag.pluginId ? `${diag.pluginId}: ` : ""; const target = diag.pluginId ? `${diag.pluginId}: ` : "";
lines.push(`- ${target}${diag.message}`); lines.push(`- ${target}${diag.message}`);
-77
View File
@@ -68,83 +68,6 @@ describe("cli program (nodes basics)", () => {
expect(runtime.log).toHaveBeenCalledWith("Pending: 0 · Paired: 0"); expect(runtime.log).toHaveBeenCalledWith("Pending: 0 · Paired: 0");
}); });
it("runs nodes list --connected and filters to connected nodes", async () => {
const now = Date.now();
callGateway.mockImplementation(async (opts: { method?: string }) => {
if (opts.method === "node.pair.list") {
return {
pending: [],
paired: [
{
nodeId: "n1",
displayName: "One",
remoteIp: "10.0.0.1",
lastConnectedAtMs: now - 1_000,
},
{
nodeId: "n2",
displayName: "Two",
remoteIp: "10.0.0.2",
lastConnectedAtMs: now - 1_000,
},
],
};
}
if (opts.method === "node.list") {
return {
nodes: [
{ nodeId: "n1", connected: true },
{ nodeId: "n2", connected: false },
],
};
}
return { ok: true };
});
const program = buildProgram();
runtime.log.mockClear();
await program.parseAsync(["nodes", "list", "--connected"], { from: "user" });
expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "node.list" }));
const output = runtime.log.mock.calls.map((c) => String(c[0] ?? "")).join("\n");
expect(output).toContain("One");
expect(output).not.toContain("Two");
});
it("runs nodes status --last-connected and filters by age", async () => {
const now = Date.now();
callGateway.mockImplementation(async (opts: { method?: string }) => {
if (opts.method === "node.list") {
return {
ts: now,
nodes: [
{ nodeId: "n1", displayName: "One", connected: false },
{ nodeId: "n2", displayName: "Two", connected: false },
],
};
}
if (opts.method === "node.pair.list") {
return {
pending: [],
paired: [
{ nodeId: "n1", lastConnectedAtMs: now - 1_000 },
{ nodeId: "n2", lastConnectedAtMs: now - 2 * 24 * 60 * 60 * 1000 },
],
};
}
return { ok: true };
});
const program = buildProgram();
runtime.log.mockClear();
await program.parseAsync(["nodes", "status", "--last-connected", "24h"], {
from: "user",
});
expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "node.pair.list" }));
const output = runtime.log.mock.calls.map((c) => String(c[0] ?? "")).join("\n");
expect(output).toContain("One");
expect(output).not.toContain("Two");
});
it("runs nodes status and calls node.list", async () => { it("runs nodes status and calls node.list", async () => {
callGateway.mockResolvedValue({ callGateway.mockResolvedValue({
ts: Date.now(), ts: Date.now(),
+5
View File
@@ -4,6 +4,7 @@ import { agentsListCommand } from "../../commands/agents.js";
import { healthCommand } from "../../commands/health.js"; import { healthCommand } from "../../commands/health.js";
import { sessionsCommand } from "../../commands/sessions.js"; import { sessionsCommand } from "../../commands/sessions.js";
import { statusCommand } from "../../commands/status.js"; import { statusCommand } from "../../commands/status.js";
import { setVerbose } from "../../globals.js";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import { getFlagValue, getPositiveIntFlagValue, getVerboseFlag, hasFlag } from "../argv.js"; import { getFlagValue, getPositiveIntFlagValue, getVerboseFlag, hasFlag } from "../argv.js";
import { registerBrowserCli } from "../browser-cli.js"; import { registerBrowserCli } from "../browser-cli.js";
@@ -45,6 +46,7 @@ const routeHealth: RouteSpec = {
const verbose = getVerboseFlag(argv, { includeDebug: true }); const verbose = getVerboseFlag(argv, { includeDebug: true });
const timeoutMs = getPositiveIntFlagValue(argv, "--timeout"); const timeoutMs = getPositiveIntFlagValue(argv, "--timeout");
if (timeoutMs === null) return false; if (timeoutMs === null) return false;
setVerbose(verbose);
await healthCommand({ json, timeoutMs, verbose }, defaultRuntime); await healthCommand({ json, timeoutMs, verbose }, defaultRuntime);
return true; return true;
}, },
@@ -61,6 +63,7 @@ const routeStatus: RouteSpec = {
const verbose = getVerboseFlag(argv, { includeDebug: true }); const verbose = getVerboseFlag(argv, { includeDebug: true });
const timeoutMs = getPositiveIntFlagValue(argv, "--timeout"); const timeoutMs = getPositiveIntFlagValue(argv, "--timeout");
if (timeoutMs === null) return false; if (timeoutMs === null) return false;
setVerbose(verbose);
await statusCommand({ json, deep, all, usage, timeoutMs, verbose }, defaultRuntime); await statusCommand({ json, deep, all, usage, timeoutMs, verbose }, defaultRuntime);
return true; return true;
}, },
@@ -70,10 +73,12 @@ const routeSessions: RouteSpec = {
match: (path) => path[0] === "sessions", match: (path) => path[0] === "sessions",
run: async (argv) => { run: async (argv) => {
const json = hasFlag(argv, "--json"); const json = hasFlag(argv, "--json");
const verbose = getVerboseFlag(argv);
const store = getFlagValue(argv, "--store"); const store = getFlagValue(argv, "--store");
if (store === null) return false; if (store === null) return false;
const active = getFlagValue(argv, "--active"); const active = getFlagValue(argv, "--active");
if (active === null) return false; if (active === null) return false;
setVerbose(verbose);
await sessionsCommand({ json, store, active }, defaultRuntime); await sessionsCommand({ json, store, active }, defaultRuntime);
return true; return true;
}, },
+1 -7
View File
@@ -1,10 +1,9 @@
import type { Command } from "commander"; import type { Command } from "commander";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import { emitCliBanner } from "../banner.js"; import { emitCliBanner } from "../banner.js";
import { getCommandPath, getVerboseFlag, hasHelpOrVersion } from "../argv.js"; import { getCommandPath, hasHelpOrVersion } from "../argv.js";
import { ensureConfigReady } from "./config-guard.js"; import { ensureConfigReady } from "./config-guard.js";
import { ensurePluginRegistryLoaded } from "../plugin-registry.js"; import { ensurePluginRegistryLoaded } from "../plugin-registry.js";
import { setVerbose } from "../../globals.js";
function setProcessTitleForCommand(actionCommand: Command) { function setProcessTitleForCommand(actionCommand: Command) {
let current: Command = actionCommand; let current: Command = actionCommand;
@@ -25,11 +24,6 @@ export function registerPreActionHooks(program: Command, programVersion: string)
emitCliBanner(programVersion); emitCliBanner(programVersion);
const argv = process.argv; const argv = process.argv;
if (hasHelpOrVersion(argv)) return; if (hasHelpOrVersion(argv)) return;
const verbose = getVerboseFlag(argv, { includeDebug: true });
setVerbose(verbose);
if (!verbose) {
process.env.NODE_NO_WARNINGS ??= "1";
}
const commandPath = getCommandPath(argv, 2); const commandPath = getCommandPath(argv, 2);
if (commandPath[0] === "doctor") return; if (commandPath[0] === "doctor") return;
await ensureConfigReady({ runtime: defaultRuntime, commandPath }); await ensureConfigReady({ runtime: defaultRuntime, commandPath });
+11 -28
View File
@@ -12,7 +12,6 @@ import { defaultRuntime } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js"; import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js"; import { theme } from "../../terminal/theme.js";
import { hasExplicitOptions } from "../command-options.js"; import { hasExplicitOptions } from "../command-options.js";
import { formatHelpExamples } from "../help-format.js";
import { createDefaultDeps } from "../deps.js"; import { createDefaultDeps } from "../deps.js";
import { runCommandWithRuntime } from "../cli-utils.js"; import { runCommandWithRuntime } from "../cli-utils.js";
import { collectOption } from "./helpers.js"; import { collectOption } from "./helpers.js";
@@ -49,24 +48,13 @@ export function registerAgentCommands(program: Command, args: { agentChannelOpti
"after", "after",
() => () =>
` `
${theme.heading("Examples:")} Examples:
${formatHelpExamples([ clawdbot agent --to +15555550123 --message "status update"
['clawdbot agent --to +15555550123 --message "status update"', "Start a new session."], clawdbot agent --agent ops --message "Summarize logs"
['clawdbot agent --agent ops --message "Summarize logs"', "Use a specific agent."], clawdbot agent --session-id 1234 --message "Summarize inbox" --thinking medium
[ clawdbot agent --to +15555550123 --message "Trace logs" --verbose on --json
'clawdbot agent --session-id 1234 --message "Summarize inbox" --thinking medium', clawdbot agent --to +15555550123 --message "Summon reply" --deliver
"Target a session with explicit thinking level.", clawdbot agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports"
],
[
'clawdbot agent --to +15555550123 --message "Trace logs" --verbose on --json',
"Enable verbose logging and JSON output.",
],
['clawdbot agent --to +15555550123 --message "Summon reply" --deliver', "Deliver reply."],
[
'clawdbot agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports"',
"Send reply to a different channel/target.",
],
])}
${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent")}`, ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent")}`,
) )
@@ -152,15 +140,10 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent
"after", "after",
() => () =>
` `
${theme.heading("Examples:")} Examples:
${formatHelpExamples([ clawdbot agents set-identity --agent main --name "Clawd" --emoji "🦞"
['clawdbot agents set-identity --agent main --name "Clawd" --emoji "🦞"', "Set name + emoji."], clawdbot agents set-identity --workspace ~/clawd --from-identity
["clawdbot agents set-identity --workspace ~/clawd --from-identity", "Load from IDENTITY.md."], clawdbot agents set-identity --identity-file ~/clawd/IDENTITY.md --agent main
[
"clawdbot agents set-identity --identity-file ~/clawd/IDENTITY.md --agent main",
"Use a specific IDENTITY.md.",
],
])}
`, `,
) )
.action(async (opts) => { .action(async (opts) => {
+5 -17
View File
@@ -1,7 +1,6 @@
import type { Command } from "commander"; import type { Command } from "commander";
import { formatDocsLink } from "../../terminal/links.js"; import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js"; import { theme } from "../../terminal/theme.js";
import { formatHelpExamples } from "../help-format.js";
import type { ProgramContext } from "./context.js"; import type { ProgramContext } from "./context.js";
import { createMessageCliHelpers } from "./message/helpers.js"; import { createMessageCliHelpers } from "./message/helpers.js";
import { registerMessageDiscordAdminCommands } from "./message/register.discord-admin.js"; import { registerMessageDiscordAdminCommands } from "./message/register.discord-admin.js";
@@ -29,22 +28,11 @@ export function registerMessageCommands(program: Command, ctx: ProgramContext) {
"after", "after",
() => () =>
` `
${theme.heading("Examples:")} Examples:
${formatHelpExamples([ clawdbot message send --target +15555550123 --message "Hi"
['clawdbot message send --target +15555550123 --message "Hi"', "Send a text message."], clawdbot message send --target +15555550123 --message "Hi" --media photo.jpg
[ clawdbot message poll --channel discord --target channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi
'clawdbot message send --target +15555550123 --message "Hi" --media photo.jpg', clawdbot message react --channel discord --target 123 --message-id 456 --emoji "✅"
"Send a message with media.",
],
[
'clawdbot message poll --channel discord --target channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi',
"Create a Discord poll.",
],
[
'clawdbot message react --channel discord --target 123 --message-id 456 --emoji "✅"',
"React to a message.",
],
])}
${theme.muted("Docs:")} ${formatDocsLink("/cli/message", "docs.clawd.bot/cli/message")}`, ${theme.muted("Docs:")} ${formatDocsLink("/cli/message", "docs.clawd.bot/cli/message")}`,
) )
@@ -7,7 +7,6 @@ import { defaultRuntime } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js"; import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js"; import { theme } from "../../terminal/theme.js";
import { runCommandWithRuntime } from "../cli-utils.js"; import { runCommandWithRuntime } from "../cli-utils.js";
import { formatHelpExamples } from "../help-format.js";
import { parsePositiveIntOrUndefined } from "./helpers.js"; import { parsePositiveIntOrUndefined } from "./helpers.js";
function resolveVerbose(opts: { verbose?: boolean; debug?: boolean }): boolean { function resolveVerbose(opts: { verbose?: boolean; debug?: boolean }): boolean {
@@ -37,18 +36,15 @@ export function registerStatusHealthSessionsCommands(program: Command) {
.option("--debug", "Alias for --verbose", false) .option("--debug", "Alias for --verbose", false)
.addHelpText( .addHelpText(
"after", "after",
() => `
`\n${theme.heading("Examples:")}\n${formatHelpExamples([ Examples:
["clawdbot status", "Show channel health + session summary."], clawdbot status # show linked account + session store summary
["clawdbot status --all", "Full diagnosis (read-only)."], clawdbot status --all # full diagnosis (read-only)
["clawdbot status --json", "Machine-readable output."], clawdbot status --json # machine-readable output
["clawdbot status --usage", "Show model provider usage/quota snapshots."], clawdbot status --usage # show model provider usage/quota snapshots
[ clawdbot status --deep # run channel probes (WA + Telegram + Discord + Slack + Signal)
"clawdbot status --deep", clawdbot status --deep --timeout 5000 # tighten probe timeout
"Run channel probes (WA + Telegram + Discord + Slack + Signal).", clawdbot channels status # gateway channel runtime + probes`,
],
["clawdbot status --deep --timeout 5000", "Tighten probe timeout."],
])}`,
) )
.addHelpText( .addHelpText(
"after", "after",
@@ -117,15 +113,14 @@ export function registerStatusHealthSessionsCommands(program: Command) {
.option("--active <minutes>", "Only show sessions updated within the past N minutes") .option("--active <minutes>", "Only show sessions updated within the past N minutes")
.addHelpText( .addHelpText(
"after", "after",
() => `
`\n${theme.heading("Examples:")}\n${formatHelpExamples([ Examples:
["clawdbot sessions", "List all sessions."], clawdbot sessions # list all sessions
["clawdbot sessions --active 120", "Only last 2 hours."], clawdbot sessions --active 120 # only last 2 hours
["clawdbot sessions --json", "Machine-readable output."], clawdbot sessions --json # machine-readable output
["clawdbot sessions --store ./tmp/sessions.json", "Use a specific session store."], clawdbot sessions --store ./tmp/sessions.json
])}\n\n${theme.muted(
"Shows token usage per session when the agent reports it; set agents.defaults.contextTokens to see % of your model window.", Shows token usage per session when the agent reports it; set agents.defaults.contextTokens to see % of your model window.`,
)}`,
) )
.addHelpText( .addHelpText(
"after", "after",
+1 -17
View File
@@ -21,7 +21,7 @@ const { nodesAction, registerNodesCli } = vi.hoisted(() => {
vi.mock("../acp-cli.js", () => ({ registerAcpCli })); vi.mock("../acp-cli.js", () => ({ registerAcpCli }));
vi.mock("../nodes-cli.js", () => ({ registerNodesCli })); vi.mock("../nodes-cli.js", () => ({ registerNodesCli }));
const { registerSubCliByName, registerSubCliCommands } = await import("./register.subclis.js"); const { registerSubCliCommands } = await import("./register.subclis.js");
describe("registerSubCliCommands", () => { describe("registerSubCliCommands", () => {
const originalArgv = process.argv; const originalArgv = process.argv;
@@ -78,20 +78,4 @@ describe("registerSubCliCommands", () => {
expect(registerNodesCli).toHaveBeenCalledTimes(1); expect(registerNodesCli).toHaveBeenCalledTimes(1);
expect(nodesAction).toHaveBeenCalledTimes(1); expect(nodesAction).toHaveBeenCalledTimes(1);
}); });
it("replaces placeholder when registering a subcommand by name", async () => {
process.argv = ["node", "clawdbot", "acp", "--help"];
const program = new Command();
program.name("clawdbot");
registerSubCliCommands(program, process.argv);
await registerSubCliByName(program, "acp");
const names = program.commands.map((cmd) => cmd.name());
expect(names.filter((name) => name === "acp")).toHaveLength(1);
await program.parseAsync(["node", "clawdbot", "acp"], { from: "user" });
expect(registerAcpCli).toHaveBeenCalledTimes(1);
expect(acpAction).toHaveBeenCalledTimes(1);
});
}); });
+4 -11
View File
@@ -18,8 +18,10 @@ const shouldRegisterPrimaryOnly = (argv: string[]) => {
return true; return true;
}; };
const shouldEagerRegisterSubcommands = (_argv: string[]) => { const shouldEagerRegisterSubcommands = (argv: string[]) => {
return isTruthyEnvValue(process.env.CLAWDBOT_DISABLE_LAZY_SUBCOMMANDS); if (isTruthyEnvValue(process.env.CLAWDBOT_DISABLE_LAZY_SUBCOMMANDS)) return true;
if (hasHelpOrVersion(argv)) return true;
return false;
}; };
const loadConfig = async (): Promise<ClawdbotConfig> => { const loadConfig = async (): Promise<ClawdbotConfig> => {
@@ -232,15 +234,6 @@ function removeCommand(program: Command, command: Command) {
} }
} }
export async function registerSubCliByName(program: Command, name: string): Promise<boolean> {
const entry = entries.find((candidate) => candidate.name === name);
if (!entry) return false;
const existing = program.commands.find((cmd) => cmd.name() === entry.name);
if (existing) removeCommand(program, existing);
await entry.register(program);
return true;
}
function registerLazyCommand(program: Command, entry: SubCliEntry) { function registerLazyCommand(program: Command, entry: SubCliEntry) {
const placeholder = program.command(entry.name).description(entry.description); const placeholder = program.command(entry.name).description(entry.description);
placeholder.allowUnknownOption(true); placeholder.allowUnknownOption(true);
+1 -10
View File
@@ -10,7 +10,6 @@ import { assertSupportedRuntime } from "../infra/runtime-guard.js";
import { formatUncaughtError } from "../infra/errors.js"; import { formatUncaughtError } from "../infra/errors.js";
import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js"; import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
import { enableConsoleCapture } from "../logging.js"; import { enableConsoleCapture } from "../logging.js";
import { getPrimaryCommand, hasHelpOrVersion } from "./argv.js";
import { tryRouteCli } from "./route.js"; import { tryRouteCli } from "./route.js";
export function rewriteUpdateFlagArgv(argv: string[]): string[] { export function rewriteUpdateFlagArgv(argv: string[]): string[] {
@@ -48,15 +47,7 @@ export async function runCli(argv: string[] = process.argv) {
process.exit(1); process.exit(1);
}); });
const parseArgv = rewriteUpdateFlagArgv(normalizedArgv); await program.parseAsync(rewriteUpdateFlagArgv(normalizedArgv));
if (hasHelpOrVersion(parseArgv)) {
const primary = getPrimaryCommand(parseArgv);
if (primary) {
const { registerSubCliByName } = await import("./program/register.subclis.js");
await registerSubCliByName(program, primary);
}
}
await program.parseAsync(parseArgv);
} }
function stripWindowsNodeExec(argv: string[]): string[] { function stripWindowsNodeExec(argv: string[]): string[] {
+55 -66
View File
@@ -5,7 +5,6 @@ import { sandboxExplainCommand } from "../commands/sandbox-explain.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js"; import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js"; import { theme } from "../terminal/theme.js";
import { formatHelpExamples } from "./help-format.js";
// --- Types --- // --- Types ---
@@ -13,33 +12,57 @@ type CommandOptions = Record<string, unknown>;
// --- Helpers --- // --- Helpers ---
const SANDBOX_EXAMPLES: Record<string, [string, string][]> = { const EXAMPLES = {
main: [ main: `
["clawdbot sandbox list", "List all sandbox containers."], Examples:
["clawdbot sandbox list --browser", "List only browser containers."], clawdbot sandbox list # List all sandbox containers
["clawdbot sandbox recreate --all", "Recreate all containers."], clawdbot sandbox list --browser # List only browser containers
["clawdbot sandbox recreate --session main", "Recreate a specific session."], clawdbot sandbox recreate --all # Recreate all containers
["clawdbot sandbox recreate --agent mybot", "Recreate agent containers."], clawdbot sandbox recreate --session main # Recreate specific session
["clawdbot sandbox explain", "Explain effective sandbox config."], clawdbot sandbox recreate --agent mybot # Recreate agent containers
], clawdbot sandbox explain # Explain effective sandbox config`,
list: [
["clawdbot sandbox list", "List all sandbox containers."], list: `
["clawdbot sandbox list --browser", "List only browser containers."], Examples:
["clawdbot sandbox list --json", "JSON output."], clawdbot sandbox list # List all sandbox containers
], clawdbot sandbox list --browser # List only browser containers
recreate: [ clawdbot sandbox list --json # JSON output
["clawdbot sandbox recreate --all", "Recreate all containers."],
["clawdbot sandbox recreate --session main", "Recreate a specific session."], Output includes:
["clawdbot sandbox recreate --agent mybot", "Recreate a specific agent (includes sub-agents)."], • Container name and status (running/stopped)
["clawdbot sandbox recreate --browser --all", "Recreate only browser containers."], • Docker image and whether it matches current config
["clawdbot sandbox recreate --all --force", "Skip confirmation."], • Age (time since creation)
], • Idle time (time since last use)
explain: [ • Associated session/agent ID`,
["clawdbot sandbox explain", "Show effective sandbox config."],
["clawdbot sandbox explain --session agent:main:main", "Explain a specific session."], recreate: `
["clawdbot sandbox explain --agent work", "Explain an agent sandbox."], Examples:
["clawdbot sandbox explain --json", "JSON output."], clawdbot sandbox recreate --all # Recreate all containers
], clawdbot sandbox recreate --session main # Specific session
clawdbot sandbox recreate --agent mybot # Specific agent (includes sub-agents)
clawdbot sandbox recreate --browser --all # All browser containers only
clawdbot sandbox recreate --all --force # Skip confirmation
Why use this?
After updating Docker images or sandbox configuration, existing containers
continue running with old settings. This command removes them so they'll be
recreated automatically with current config when next needed.
Filter options:
--all Remove all sandbox containers
--session Remove container for specific session key
--agent Remove containers for agent (includes agent:id:* variants)
Modifiers:
--browser Only affect browser containers (not regular sandbox)
--force Skip confirmation prompt`,
explain: `
Examples:
clawdbot sandbox explain
clawdbot sandbox explain --session agent:main:main
clawdbot sandbox explain --agent work
clawdbot sandbox explain --json`,
}; };
function createRunner( function createRunner(
@@ -61,10 +84,7 @@ export function registerSandboxCli(program: Command) {
const sandbox = program const sandbox = program
.command("sandbox") .command("sandbox")
.description("Manage sandbox containers (Docker-based agent isolation)") .description("Manage sandbox containers (Docker-based agent isolation)")
.addHelpText( .addHelpText("after", EXAMPLES.main)
"after",
() => `\n${theme.heading("Examples:")}\n${formatHelpExamples(SANDBOX_EXAMPLES.main)}\n`,
)
.addHelpText( .addHelpText(
"after", "after",
() => () =>
@@ -81,17 +101,7 @@ export function registerSandboxCli(program: Command) {
.description("List sandbox containers and their status") .description("List sandbox containers and their status")
.option("--json", "Output result as JSON", false) .option("--json", "Output result as JSON", false)
.option("--browser", "List browser containers only", false) .option("--browser", "List browser containers only", false)
.addHelpText( .addHelpText("after", EXAMPLES.list)
"after",
() =>
`\n${theme.heading("Examples:")}\n${formatHelpExamples(SANDBOX_EXAMPLES.list)}\n\n${theme.heading(
"Output includes:",
)}\n${theme.muted("- Container name and status (running/stopped)")}\n${theme.muted(
"- Docker image and whether it matches current config",
)}\n${theme.muted("- Age (time since creation)")}\n${theme.muted(
"- Idle time (time since last use)",
)}\n${theme.muted("- Associated session/agent ID")}`,
)
.action( .action(
createRunner((opts) => createRunner((opts) =>
sandboxListCommand( sandboxListCommand(
@@ -114,25 +124,7 @@ export function registerSandboxCli(program: Command) {
.option("--agent <id>", "Recreate containers for specific agent") .option("--agent <id>", "Recreate containers for specific agent")
.option("--browser", "Only recreate browser containers", false) .option("--browser", "Only recreate browser containers", false)
.option("--force", "Skip confirmation prompt", false) .option("--force", "Skip confirmation prompt", false)
.addHelpText( .addHelpText("after", EXAMPLES.recreate)
"after",
() =>
`\n${theme.heading("Examples:")}\n${formatHelpExamples(SANDBOX_EXAMPLES.recreate)}\n\n${theme.heading(
"Why use this?",
)}\n${theme.muted(
"After updating Docker images or sandbox configuration, existing containers continue running with old settings.",
)}\n${theme.muted(
"This command removes them so they'll be recreated automatically with current config when next needed.",
)}\n\n${theme.heading("Filter options:")}\n${theme.muted(
" --all Remove all sandbox containers",
)}\n${theme.muted(
" --session Remove container for specific session key",
)}\n${theme.muted(
" --agent Remove containers for agent (includes agent:id:* variants)",
)}\n\n${theme.heading("Modifiers:")}\n${theme.muted(
" --browser Only affect browser containers (not regular sandbox)",
)}\n${theme.muted(" --force Skip confirmation prompt")}`,
)
.action( .action(
createRunner((opts) => createRunner((opts) =>
sandboxRecreateCommand( sandboxRecreateCommand(
@@ -156,10 +148,7 @@ export function registerSandboxCli(program: Command) {
.option("--session <key>", "Session key to inspect (defaults to agent main)") .option("--session <key>", "Session key to inspect (defaults to agent main)")
.option("--agent <id>", "Agent id to inspect (defaults to derived agent)") .option("--agent <id>", "Agent id to inspect (defaults to derived agent)")
.option("--json", "Output result as JSON", false) .option("--json", "Output result as JSON", false)
.addHelpText( .addHelpText("after", EXAMPLES.explain)
"after",
() => `\n${theme.heading("Examples:")}\n${formatHelpExamples(SANDBOX_EXAMPLES.explain)}\n`,
)
.action( .action(
createRunner((opts) => createRunner((opts) =>
sandboxExplainCommand( sandboxExplainCommand(
+2 -1
View File
@@ -1,3 +1,4 @@
import chalk from "chalk";
import type { Command } from "commander"; import type { Command } from "commander";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
@@ -120,7 +121,7 @@ export function registerSecurityCli(program: Command) {
lines.push(""); lines.push("");
lines.push(heading(label)); lines.push(heading(label));
for (const f of list) { for (const f of list) {
lines.push(`${theme.muted(f.checkId)} ${f.title}`); lines.push(`${chalk.gray(f.checkId)} ${f.title}`);
lines.push(` ${f.detail}`); lines.push(` ${f.detail}`);
if (f.remediation?.trim()) lines.push(` ${muted(`Fix: ${f.remediation.trim()}`)}`); if (f.remediation?.trim()) lines.push(` ${muted(`Fix: ${f.remediation.trim()}`)}`);
} }
+10 -7
View File
@@ -1,7 +1,6 @@
import type { Command } from "commander"; import type { Command } from "commander";
import { formatDocsLink } from "../terminal/links.js"; import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js"; import { theme } from "../terminal/theme.js";
import { formatHelpExampleGroup } from "./help-format.js";
import { createDefaultDeps } from "./deps.js"; import { createDefaultDeps } from "./deps.js";
import { import {
runDaemonInstall, runDaemonInstall,
@@ -21,6 +20,12 @@ import {
} from "./node-cli/daemon.js"; } from "./node-cli/daemon.js";
export function registerServiceCli(program: Command) { export function registerServiceCli(program: Command) {
const formatExample = (cmd: string, desc: string) =>
` ${theme.command(cmd)}\n ${theme.muted(desc)}`;
const formatGroup = (label: string, examples: Array<[string, string]>) =>
`${theme.muted(label)}\n${examples.map(([cmd, desc]) => formatExample(cmd, desc)).join("\n")}`;
const gatewayExamples: Array<[string, string]> = [ const gatewayExamples: Array<[string, string]> = [
["clawdbot service gateway status", "Show gateway service status + probe."], ["clawdbot service gateway status", "Show gateway service status + probe."],
[ [
@@ -45,12 +50,10 @@ export function registerServiceCli(program: Command) {
.addHelpText( .addHelpText(
"after", "after",
() => () =>
`\n${theme.heading("Examples:")}\n${formatHelpExampleGroup( `\n${theme.heading("Examples:")}\n${formatGroup("Gateway:", gatewayExamples)}\n\n${formatGroup(
"Gateway:", "Node:",
gatewayExamples, nodeExamples,
)}\n\n${formatHelpExampleGroup("Node:", nodeExamples)}\n\n${theme.muted( )}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/service", "docs.clawd.bot/cli/service")}\n`,
"Docs:",
)} ${formatDocsLink("/cli/service", "docs.clawd.bot/cli/service")}\n`,
); );
const gateway = service.command("gateway").description("Manage the Gateway service"); const gateway = service.command("gateway").description("Manage the Gateway service");
+94 -94
View File
@@ -1,3 +1,4 @@
import chalk from "chalk";
import type { Command } from "commander"; import type { Command } from "commander";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { import {
@@ -8,7 +9,6 @@ import {
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js"; import { formatDocsLink } from "../terminal/links.js";
import { renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js"; import { theme } from "../terminal/theme.js";
import { formatCliCommand } from "./command-format.js"; import { formatCliCommand } from "./command-format.js";
@@ -31,36 +31,47 @@ function appendClawdHubHint(output: string, json?: boolean): string {
return `${output}\n\nTip: use \`npx clawdhub\` to search, install, and sync skills.`; return `${output}\n\nTip: use \`npx clawdhub\` to search, install, and sync skills.`;
} }
function formatSkillStatus(skill: SkillStatusEntry): string { /**
if (skill.eligible) return theme.success("✓ ready"); * Format a single skill for display in the list
if (skill.disabled) return theme.warn("⏸ disabled"); */
if (skill.blockedByAllowlist) return theme.warn("🚫 blocked"); function formatSkillLine(skill: SkillStatusEntry, verbose = false): string {
return theme.error("✗ missing");
}
function formatSkillName(skill: SkillStatusEntry): string {
const emoji = skill.emoji ?? "📦"; const emoji = skill.emoji ?? "📦";
return `${emoji} ${theme.command(skill.name)}`; const status = skill.eligible
} ? chalk.green("✓")
: skill.disabled
? chalk.yellow("disabled")
: skill.blockedByAllowlist
? chalk.yellow("blocked")
: chalk.red("missing reqs");
function formatSkillMissingSummary(skill: SkillStatusEntry): string { const name = skill.eligible ? chalk.white(skill.name) : chalk.gray(skill.name);
const missing: string[] = [];
if (skill.missing.bins.length > 0) { const desc = chalk.gray(
missing.push(`bins: ${skill.missing.bins.join(", ")}`); skill.description.length > 50 ? `${skill.description.slice(0, 47)}...` : skill.description,
);
if (verbose) {
const missing: string[] = [];
if (skill.missing.bins.length > 0) {
missing.push(`bins: ${skill.missing.bins.join(", ")}`);
}
if (skill.missing.anyBins.length > 0) {
missing.push(`anyBins: ${skill.missing.anyBins.join(", ")}`);
}
if (skill.missing.env.length > 0) {
missing.push(`env: ${skill.missing.env.join(", ")}`);
}
if (skill.missing.config.length > 0) {
missing.push(`config: ${skill.missing.config.join(", ")}`);
}
if (skill.missing.os.length > 0) {
missing.push(`os: ${skill.missing.os.join(", ")}`);
}
const missingStr = missing.length > 0 ? chalk.red(` [${missing.join("; ")}]`) : "";
return `${emoji} ${name} ${status}${missingStr}\n ${desc}`;
} }
if (skill.missing.anyBins.length > 0) {
missing.push(`anyBins: ${skill.missing.anyBins.join(", ")}`); return `${emoji} ${name} ${status} - ${desc}`;
}
if (skill.missing.env.length > 0) {
missing.push(`env: ${skill.missing.env.join(", ")}`);
}
if (skill.missing.config.length > 0) {
missing.push(`config: ${skill.missing.config.join(", ")}`);
}
if (skill.missing.os.length > 0) {
missing.push(`os: ${skill.missing.os.join(", ")}`);
}
return missing.join("; ");
} }
/** /**
@@ -97,39 +108,28 @@ export function formatSkillsList(report: SkillStatusReport, opts: SkillsListOpti
} }
const eligible = skills.filter((s) => s.eligible); const eligible = skills.filter((s) => s.eligible);
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); const notEligible = skills.filter((s) => !s.eligible);
const rows = skills.map((skill) => {
const missing = formatSkillMissingSummary(skill);
return {
Status: formatSkillStatus(skill),
Skill: formatSkillName(skill),
Description: theme.muted(skill.description),
Source: skill.source ?? "",
Missing: missing ? theme.warn(missing) : "",
};
});
const columns = [
{ key: "Status", header: "Status", minWidth: 10 },
{ key: "Skill", header: "Skill", minWidth: 18, flex: true },
{ key: "Description", header: "Description", minWidth: 24, flex: true },
{ key: "Source", header: "Source", minWidth: 10 },
];
if (opts.verbose) {
columns.push({ key: "Missing", header: "Missing", minWidth: 18, flex: true });
}
const lines: string[] = []; const lines: string[] = [];
lines.push( lines.push(
`${theme.heading("Skills")} ${theme.muted(`(${eligible.length}/${skills.length} ready)`)}`, chalk.bold.cyan("Skills") + chalk.gray(` (${eligible.length}/${skills.length} ready)`),
);
lines.push(
renderTable({
width: tableWidth,
columns,
rows,
}).trimEnd(),
); );
lines.push("");
if (eligible.length > 0) {
lines.push(chalk.bold.green("Ready:"));
for (const skill of eligible) {
lines.push(` ${formatSkillLine(skill, opts.verbose)}`);
}
}
if (notEligible.length > 0 && !opts.eligible) {
if (eligible.length > 0) lines.push("");
lines.push(chalk.bold.yellow("Not ready:"));
for (const skill of notEligible) {
lines.push(` ${formatSkillLine(skill, opts.verbose)}`);
}
}
return appendClawdHubHint(lines.join("\n"), opts.json); return appendClawdHubHint(lines.join("\n"), opts.json);
} }
@@ -161,27 +161,27 @@ export function formatSkillInfo(
const lines: string[] = []; const lines: string[] = [];
const emoji = skill.emoji ?? "📦"; const emoji = skill.emoji ?? "📦";
const status = skill.eligible const status = skill.eligible
? theme.success("✓ Ready") ? chalk.green("✓ Ready")
: skill.disabled : skill.disabled
? theme.warn("⏸ Disabled") ? chalk.yellow("⏸ Disabled")
: skill.blockedByAllowlist : skill.blockedByAllowlist
? theme.warn("🚫 Blocked by allowlist") ? chalk.yellow("🚫 Blocked by allowlist")
: theme.error("✗ Missing requirements"); : chalk.red("✗ Missing requirements");
lines.push(`${emoji} ${theme.heading(skill.name)} ${status}`); lines.push(`${emoji} ${chalk.bold.cyan(skill.name)} ${status}`);
lines.push(""); lines.push("");
lines.push(skill.description); lines.push(chalk.white(skill.description));
lines.push(""); lines.push("");
// Details // Details
lines.push(theme.heading("Details:")); lines.push(chalk.bold("Details:"));
lines.push(`${theme.muted(" Source:")} ${skill.source}`); lines.push(` Source: ${skill.source}`);
lines.push(`${theme.muted(" Path:")} ${skill.filePath}`); lines.push(` Path: ${chalk.gray(skill.filePath)}`);
if (skill.homepage) { if (skill.homepage) {
lines.push(`${theme.muted(" Homepage:")} ${skill.homepage}`); lines.push(` Homepage: ${chalk.blue(skill.homepage)}`);
} }
if (skill.primaryEnv) { if (skill.primaryEnv) {
lines.push(`${theme.muted(" Primary env:")} ${skill.primaryEnv}`); lines.push(` Primary env: ${skill.primaryEnv}`);
} }
// Requirements // Requirements
@@ -194,51 +194,51 @@ export function formatSkillInfo(
if (hasRequirements) { if (hasRequirements) {
lines.push(""); lines.push("");
lines.push(theme.heading("Requirements:")); lines.push(chalk.bold("Requirements:"));
if (skill.requirements.bins.length > 0) { if (skill.requirements.bins.length > 0) {
const binsStatus = skill.requirements.bins.map((bin) => { const binsStatus = skill.requirements.bins.map((bin) => {
const missing = skill.missing.bins.includes(bin); const missing = skill.missing.bins.includes(bin);
return missing ? theme.error(`✗ ${bin}`) : theme.success(`✓ ${bin}`); return missing ? chalk.red(`✗ ${bin}`) : chalk.green(`✓ ${bin}`);
}); });
lines.push(`${theme.muted(" Binaries:")} ${binsStatus.join(", ")}`); lines.push(` Binaries: ${binsStatus.join(", ")}`);
} }
if (skill.requirements.anyBins.length > 0) { if (skill.requirements.anyBins.length > 0) {
const anyBinsMissing = skill.missing.anyBins.length > 0; const anyBinsMissing = skill.missing.anyBins.length > 0;
const anyBinsStatus = skill.requirements.anyBins.map((bin) => { const anyBinsStatus = skill.requirements.anyBins.map((bin) => {
const missing = anyBinsMissing; const missing = anyBinsMissing;
return missing ? theme.error(`✗ ${bin}`) : theme.success(`✓ ${bin}`); return missing ? chalk.red(`✗ ${bin}`) : chalk.green(`✓ ${bin}`);
}); });
lines.push(`${theme.muted(" Any binaries:")} ${anyBinsStatus.join(", ")}`); lines.push(` Any binaries: ${anyBinsStatus.join(", ")}`);
} }
if (skill.requirements.env.length > 0) { if (skill.requirements.env.length > 0) {
const envStatus = skill.requirements.env.map((env) => { const envStatus = skill.requirements.env.map((env) => {
const missing = skill.missing.env.includes(env); const missing = skill.missing.env.includes(env);
return missing ? theme.error(`✗ ${env}`) : theme.success(`✓ ${env}`); return missing ? chalk.red(`✗ ${env}`) : chalk.green(`✓ ${env}`);
}); });
lines.push(`${theme.muted(" Environment:")} ${envStatus.join(", ")}`); lines.push(` Environment: ${envStatus.join(", ")}`);
} }
if (skill.requirements.config.length > 0) { if (skill.requirements.config.length > 0) {
const configStatus = skill.requirements.config.map((cfg) => { const configStatus = skill.requirements.config.map((cfg) => {
const missing = skill.missing.config.includes(cfg); const missing = skill.missing.config.includes(cfg);
return missing ? theme.error(`✗ ${cfg}`) : theme.success(`✓ ${cfg}`); return missing ? chalk.red(`✗ ${cfg}`) : chalk.green(`✓ ${cfg}`);
}); });
lines.push(`${theme.muted(" Config:")} ${configStatus.join(", ")}`); lines.push(` Config: ${configStatus.join(", ")}`);
} }
if (skill.requirements.os.length > 0) { if (skill.requirements.os.length > 0) {
const osStatus = skill.requirements.os.map((osName) => { const osStatus = skill.requirements.os.map((osName) => {
const missing = skill.missing.os.includes(osName); const missing = skill.missing.os.includes(osName);
return missing ? theme.error(`✗ ${osName}`) : theme.success(`✓ ${osName}`); return missing ? chalk.red(`✗ ${osName}`) : chalk.green(`✓ ${osName}`);
}); });
lines.push(`${theme.muted(" OS:")} ${osStatus.join(", ")}`); lines.push(` OS: ${osStatus.join(", ")}`);
} }
} }
// Install options // Install options
if (skill.install.length > 0 && !skill.eligible) { if (skill.install.length > 0 && !skill.eligible) {
lines.push(""); lines.push("");
lines.push(theme.heading("Install options:")); lines.push(chalk.bold("Install options:"));
for (const inst of skill.install) { for (const inst of skill.install) {
lines.push(` ${theme.warn("→")} ${inst.label}`); lines.push(` ${chalk.yellow("→")} ${inst.label}`);
} }
} }
@@ -281,17 +281,17 @@ export function formatSkillsCheck(report: SkillStatusReport, opts: SkillsCheckOp
} }
const lines: string[] = []; const lines: string[] = [];
lines.push(theme.heading("Skills Status Check")); lines.push(chalk.bold.cyan("Skills Status Check"));
lines.push(""); lines.push("");
lines.push(`${theme.muted("Total:")} ${report.skills.length}`); lines.push(`Total: ${report.skills.length}`);
lines.push(`${theme.success("✓")} ${theme.muted("Eligible:")} ${eligible.length}`); lines.push(`${chalk.green("✓")} Eligible: ${eligible.length}`);
lines.push(`${theme.warn("⏸")} ${theme.muted("Disabled:")} ${disabled.length}`); lines.push(`${chalk.yellow("⏸")} Disabled: ${disabled.length}`);
lines.push(`${theme.warn("🚫")} ${theme.muted("Blocked by allowlist:")} ${blocked.length}`); lines.push(`${chalk.yellow("🚫")} Blocked by allowlist: ${blocked.length}`);
lines.push(`${theme.error("✗")} ${theme.muted("Missing requirements:")} ${missingReqs.length}`); lines.push(`${chalk.red("✗")} Missing requirements: ${missingReqs.length}`);
if (eligible.length > 0) { if (eligible.length > 0) {
lines.push(""); lines.push("");
lines.push(theme.heading("Ready to use:")); lines.push(chalk.bold.green("Ready to use:"));
for (const skill of eligible) { for (const skill of eligible) {
const emoji = skill.emoji ?? "📦"; const emoji = skill.emoji ?? "📦";
lines.push(` ${emoji} ${skill.name}`); lines.push(` ${emoji} ${skill.name}`);
@@ -300,7 +300,7 @@ export function formatSkillsCheck(report: SkillStatusReport, opts: SkillsCheckOp
if (missingReqs.length > 0) { if (missingReqs.length > 0) {
lines.push(""); lines.push("");
lines.push(theme.heading("Missing requirements:")); lines.push(chalk.bold.red("Missing requirements:"));
for (const skill of missingReqs) { for (const skill of missingReqs) {
const emoji = skill.emoji ?? "📦"; const emoji = skill.emoji ?? "📦";
const missing: string[] = []; const missing: string[] = [];
@@ -319,7 +319,7 @@ export function formatSkillsCheck(report: SkillStatusReport, opts: SkillsCheckOp
if (skill.missing.os.length > 0) { if (skill.missing.os.length > 0) {
missing.push(`os: ${skill.missing.os.join(", ")}`); missing.push(`os: ${skill.missing.os.join(", ")}`);
} }
lines.push(` ${emoji} ${skill.name} ${theme.muted(`(${missing.join("; ")})`)}`); lines.push(` ${emoji} ${skill.name} ${chalk.gray(`(${missing.join("; ")})`)}`);
} }
} }
@@ -350,7 +350,7 @@ export function registerSkillsCli(program: Command) {
const config = loadConfig(); const config = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
const report = buildWorkspaceSkillStatus(workspaceDir, { config }); const report = buildWorkspaceSkillStatus(workspaceDir, { config });
defaultRuntime.log(formatSkillsList(report, opts)); console.log(formatSkillsList(report, opts));
} catch (err) { } catch (err) {
defaultRuntime.error(String(err)); defaultRuntime.error(String(err));
defaultRuntime.exit(1); defaultRuntime.exit(1);
@@ -367,7 +367,7 @@ export function registerSkillsCli(program: Command) {
const config = loadConfig(); const config = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
const report = buildWorkspaceSkillStatus(workspaceDir, { config }); const report = buildWorkspaceSkillStatus(workspaceDir, { config });
defaultRuntime.log(formatSkillInfo(report, name, opts)); console.log(formatSkillInfo(report, name, opts));
} catch (err) { } catch (err) {
defaultRuntime.error(String(err)); defaultRuntime.error(String(err));
defaultRuntime.exit(1); defaultRuntime.exit(1);
@@ -383,7 +383,7 @@ export function registerSkillsCli(program: Command) {
const config = loadConfig(); const config = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
const report = buildWorkspaceSkillStatus(workspaceDir, { config }); const report = buildWorkspaceSkillStatus(workspaceDir, { config });
defaultRuntime.log(formatSkillsCheck(report, opts)); console.log(formatSkillsCheck(report, opts));
} catch (err) { } catch (err) {
defaultRuntime.error(String(err)); defaultRuntime.error(String(err));
defaultRuntime.exit(1); defaultRuntime.exit(1);
@@ -396,7 +396,7 @@ export function registerSkillsCli(program: Command) {
const config = loadConfig(); const config = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
const report = buildWorkspaceSkillStatus(workspaceDir, { config }); const report = buildWorkspaceSkillStatus(workspaceDir, { config });
defaultRuntime.log(formatSkillsList(report, {})); console.log(formatSkillsList(report, {}));
} catch (err) { } catch (err) {
defaultRuntime.error(String(err)); defaultRuntime.error(String(err));
defaultRuntime.exit(1); defaultRuntime.exit(1);
-42
View File
@@ -447,7 +447,6 @@ describe("update-cli", () => {
it("requires confirmation on downgrade when non-interactive", async () => { it("requires confirmation on downgrade when non-interactive", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-")); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-"));
try { try {
setTty(false);
await fs.writeFile( await fs.writeFile(
path.join(tempDir, "package.json"), path.join(tempDir, "package.json"),
JSON.stringify({ name: "clawdbot", version: "2.0.0" }), JSON.stringify({ name: "clawdbot", version: "2.0.0" }),
@@ -484,45 +483,4 @@ describe("update-cli", () => {
await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempDir, { recursive: true, force: true });
} }
}); });
it("allows downgrade with --yes in non-interactive mode", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-"));
try {
setTty(false);
await fs.writeFile(
path.join(tempDir, "package.json"),
JSON.stringify({ name: "clawdbot", version: "2.0.0" }),
"utf-8",
);
const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js");
const { resolveNpmChannelTag } = await import("../infra/update-check.js");
const { runGatewayUpdate } = await import("../infra/update-runner.js");
const { defaultRuntime } = await import("../runtime.js");
const { updateCommand } = await import("./update-cli.js");
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir);
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
tag: "latest",
version: "0.0.1",
});
vi.mocked(runGatewayUpdate).mockResolvedValue({
status: "ok",
mode: "npm",
steps: [],
durationMs: 100,
});
vi.mocked(defaultRuntime.error).mockClear();
vi.mocked(defaultRuntime.exit).mockClear();
await updateCommand({ yes: true });
expect(defaultRuntime.error).not.toHaveBeenCalledWith(
expect.stringContaining("Downgrade confirmation required."),
);
expect(runGatewayUpdate).toHaveBeenCalled();
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
}); });
+28 -50
View File
@@ -32,7 +32,6 @@ import { formatCliCommand } from "./command-format.js";
import { stylePromptMessage } from "../terminal/prompt-style.js"; import { stylePromptMessage } from "../terminal/prompt-style.js";
import { theme } from "../terminal/theme.js"; import { theme } from "../terminal/theme.js";
import { renderTable } from "../terminal/table.js"; import { renderTable } from "../terminal/table.js";
import { formatHelpExamples } from "./help-format.js";
import { import {
formatUpdateAvailableHint, formatUpdateAvailableHint,
formatUpdateOneLiner, formatUpdateOneLiner,
@@ -46,7 +45,6 @@ export type UpdateCommandOptions = {
channel?: string; channel?: string;
tag?: string; tag?: string;
timeout?: string; timeout?: string;
yes?: boolean;
}; };
export type UpdateStatusOptions = { export type UpdateStatusOptions = {
json?: boolean; json?: boolean;
@@ -377,8 +375,6 @@ function printResult(result: UpdateRunResult, opts: PrintResultOptions) {
} }
export async function updateCommand(opts: UpdateCommandOptions): Promise<void> { export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
process.noDeprecation = true;
process.env.NODE_NO_WARNINGS = "1";
const timeoutMs = opts.timeout ? Number.parseInt(opts.timeout, 10) * 1000 : undefined; const timeoutMs = opts.timeout ? Number.parseInt(opts.timeout, 10) * 1000 : undefined;
if (timeoutMs !== undefined && (Number.isNaN(timeoutMs) || timeoutMs <= 0)) { if (timeoutMs !== undefined && (Number.isNaN(timeoutMs) || timeoutMs <= 0)) {
@@ -431,7 +427,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
const needsConfirm = const needsConfirm =
currentVersion != null && (targetVersion == null || (cmp != null && cmp > 0)); currentVersion != null && (targetVersion == null || (cmp != null && cmp > 0));
if (needsConfirm && !opts.yes) { if (needsConfirm) {
if (!process.stdin.isTTY || opts.json) { if (!process.stdin.isTTY || opts.json) {
defaultRuntime.error( defaultRuntime.error(
[ [
@@ -671,46 +667,27 @@ export function registerUpdateCli(program: Command) {
.option("--channel <stable|beta|dev>", "Persist update channel (git + npm)") .option("--channel <stable|beta|dev>", "Persist update channel (git + npm)")
.option("--tag <dist-tag|version>", "Override npm dist-tag or version for this update") .option("--tag <dist-tag|version>", "Override npm dist-tag or version for this update")
.option("--timeout <seconds>", "Timeout for each update step in seconds (default: 1200)") .option("--timeout <seconds>", "Timeout for each update step in seconds (default: 1200)")
.option("--yes", "Skip confirmation prompts (non-interactive)", false) .addHelpText(
.addHelpText("after", () => { "after",
const examples = [ () =>
["clawdbot update", "Update a source checkout (git)"], `
["clawdbot update --channel beta", "Switch to beta channel (git + npm)"], Examples:
["clawdbot update --channel dev", "Switch to dev channel (git + npm)"], clawdbot update # Update a source checkout (git)
["clawdbot update --tag beta", "One-off update to a dist-tag or version"], clawdbot update --channel beta # Switch to beta channel (git + npm)
["clawdbot update --restart", "Update and restart the daemon"], clawdbot update --channel dev # Switch to dev channel (git + npm)
["clawdbot update --json", "Output result as JSON"], clawdbot update --tag beta # One-off update to a dist-tag or version
["clawdbot update --yes", "Non-interactive (accept downgrade prompts)"], clawdbot update --restart # Update and restart the daemon
["clawdbot --update", "Shorthand for clawdbot update"], clawdbot update --json # Output result as JSON
] as const; clawdbot --update # Shorthand for clawdbot update
const fmtExamples = examples
.map(([cmd, desc]) => ` ${theme.command(cmd)} ${theme.muted(`# ${desc}`)}`)
.join("\n");
return `
${theme.heading("What this does:")}
- Git checkouts: fetches, rebases, installs deps, builds, and runs doctor
- npm installs: updates via detected package manager
${theme.heading("Switch channels:")} Notes:
- Use --channel stable|beta|dev to persist the update channel in config - For git installs: fetches, rebases, installs deps, builds, and runs doctor
- Run clawdbot update status to see the active channel and source
- Use --tag <dist-tag|version> for a one-off npm update without persisting
${theme.heading("Non-interactive:")}
- Use --yes to accept downgrade prompts
- Combine with --channel/--tag/--restart/--json/--timeout as needed
${theme.heading("Examples:")}
${fmtExamples}
${theme.heading("Notes:")}
- Switch channels with --channel stable|beta|dev
- For global installs: auto-updates via detected package manager when possible (see docs/install/updating.md) - For global installs: auto-updates via detected package manager when possible (see docs/install/updating.md)
- Downgrades require confirmation (can break configuration) - Downgrades require confirmation (can break configuration)
- Skips update if the working directory has uncommitted changes - Skips update if the working directory has uncommitted changes
${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/update")}`; ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/update")}`,
}) )
.action(async (opts) => { .action(async (opts) => {
try { try {
await updateCommand({ await updateCommand({
@@ -719,7 +696,6 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/upda
channel: opts.channel as string | undefined, channel: opts.channel as string | undefined,
tag: opts.tag as string | undefined, tag: opts.tag as string | undefined,
timeout: opts.timeout as string | undefined, timeout: opts.timeout as string | undefined,
yes: Boolean(opts.yes),
}); });
} catch (err) { } catch (err) {
defaultRuntime.error(String(err)); defaultRuntime.error(String(err));
@@ -735,15 +711,17 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/upda
.addHelpText( .addHelpText(
"after", "after",
() => () =>
`\n${theme.heading("Examples:")}\n${formatHelpExamples([ `
["clawdbot update status", "Show channel + version status."], Examples:
["clawdbot update status --json", "JSON output."], clawdbot update status
["clawdbot update status --timeout 10", "Custom timeout."], clawdbot update status --json
])}\n\n${theme.heading("Notes:")}\n${theme.muted( clawdbot update status --timeout 10
"- Shows current update channel (stable/beta/dev) and source",
)}\n${theme.muted("- Includes git tag/branch/SHA for source checkouts")}\n\n${theme.muted( Notes:
"Docs:", - Shows current update channel (stable/beta/dev) and source
)} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/update")}`, - Includes git tag/branch/SHA for source checkouts
${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/update")}`,
) )
.action(async (opts) => { .action(async (opts) => {
try { try {
+2 -21
View File
@@ -38,24 +38,6 @@ function isNodeEntry(entry: { role?: string; roles?: string[] }) {
return false; return false;
} }
function normalizeNodeInvokeResultParams(params: unknown): unknown {
if (!params || typeof params !== "object") return params;
const raw = params as Record<string, unknown>;
const normalized: Record<string, unknown> = { ...raw };
if (normalized.payloadJSON === null) {
delete normalized.payloadJSON;
} else if (normalized.payloadJSON !== undefined && typeof normalized.payloadJSON !== "string") {
if (normalized.payload === undefined) {
normalized.payload = normalized.payloadJSON;
}
delete normalized.payloadJSON;
}
if (normalized.error === null) {
delete normalized.error;
}
return normalized;
}
export const nodeHandlers: GatewayRequestHandlers = { export const nodeHandlers: GatewayRequestHandlers = {
"node.pair.request": async ({ params, respond, context }) => { "node.pair.request": async ({ params, respond, context }) => {
if (!validateNodePairRequestParams(params)) { if (!validateNodePairRequestParams(params)) {
@@ -435,8 +417,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
}); });
}, },
"node.invoke.result": async ({ params, respond, context, client }) => { "node.invoke.result": async ({ params, respond, context, client }) => {
const normalizedParams = normalizeNodeInvokeResultParams(params); if (!validateNodeInvokeResultParams(params)) {
if (!validateNodeInvokeResultParams(normalizedParams)) {
respondInvalidParams({ respondInvalidParams({
respond, respond,
method: "node.invoke.result", method: "node.invoke.result",
@@ -444,7 +425,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
}); });
return; return;
} }
const p = normalizedParams as { const p = params as {
id: string; id: string;
nodeId: string; nodeId: string;
ok: boolean; ok: boolean;
@@ -172,55 +172,4 @@ describe("gateway node command allowlist", () => {
ws.close(); ws.close();
await server.close(); await server.close();
}); });
test("accepts node invoke result with null payloadJSON", async () => {
const { server, ws, port } = await startServerWithClient();
await connectOk(ws);
let resolveInvoke: ((payload: { id?: string; nodeId?: string }) => void) | null = null;
const invokeReqP = new Promise<{ id?: string; nodeId?: string }>((resolve) => {
resolveInvoke = resolve;
});
const nodeClient = await connectNodeClient({
port,
commands: ["canvas.snapshot"],
instanceId: "node-null-payloadjson",
displayName: "node-null-payloadjson",
onEvent: (evt) => {
if (evt.event === "node.invoke.request") {
const payload = evt.payload as { id?: string; nodeId?: string };
resolveInvoke?.(payload);
}
},
});
const listRes = await rpcReq<{ nodes?: Array<{ nodeId: string }> }>(ws, "node.list", {});
const nodeId = listRes.payload?.nodes?.[0]?.nodeId ?? "";
expect(nodeId).toBeTruthy();
const invokeResP = rpcReq(ws, "node.invoke", {
nodeId,
command: "canvas.snapshot",
params: { format: "png" },
idempotencyKey: "allowlist-null-payloadjson",
});
const payload = await invokeReqP;
const requestId = payload?.id ?? "";
const nodeIdFromReq = payload?.nodeId ?? "node-null-payloadjson";
await nodeClient.request("node.invoke.result", {
id: requestId,
nodeId: nodeIdFromReq,
ok: true,
payloadJSON: null,
});
const invokeRes = await invokeResP;
expect(invokeRes.ok).toBe(true);
nodeClient.stop();
ws.close();
await server.close();
});
}); });
@@ -719,17 +719,13 @@ export function attachGatewayWsMessageHandler(params: {
if (role === "node") { if (role === "node") {
const context = buildRequestContext(); const context = buildRequestContext();
const nodeSession = context.nodeRegistry.register(nextClient, { remoteIp: remoteAddr }); const nodeSession = context.nodeRegistry.register(nextClient, { remoteIp: remoteAddr });
const instanceIdRaw = connectParams.client.instanceId; void updatePairedNodeMetadata(nodeSession.nodeId, {
const instanceId = typeof instanceIdRaw === "string" ? instanceIdRaw.trim() : ""; lastConnectedAtMs: nodeSession.connectedAtMs,
const nodeIdsForPairing = new Set<string>([nodeSession.nodeId]); }).catch((err) =>
if (instanceId) nodeIdsForPairing.add(instanceId); logGateway.warn(
for (const nodeId of nodeIdsForPairing) { `failed to record last connect for ${nodeSession.nodeId}: ${formatForLog(err)}`,
void updatePairedNodeMetadata(nodeId, { ),
lastConnectedAtMs: nodeSession.connectedAtMs, );
}).catch((err) =>
logGateway.warn(`failed to record last connect for ${nodeId}: ${formatForLog(err)}`),
);
}
recordRemoteNodeInfo({ recordRemoteNodeInfo({
nodeId: nodeSession.nodeId, nodeId: nodeSession.nodeId,
displayName: nodeSession.displayName, displayName: nodeSession.displayName,
+1 -1
View File
@@ -187,7 +187,7 @@ export function onceMessage<T = unknown>(
// Full-suite runs can saturate the event loop (581+ files). Keep this high // Full-suite runs can saturate the event loop (581+ files). Keep this high
// enough to avoid flaky RPC timeouts, but still fail fast when a response // enough to avoid flaky RPC timeouts, but still fail fast when a response
// never arrives. // never arrives.
timeoutMs = 15_000, timeoutMs = 10_000,
): Promise<T> { ): Promise<T> {
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("timeout")), timeoutMs); const timer = setTimeout(() => reject(new Error("timeout")), timeoutMs);
-34
View File
@@ -1,34 +0,0 @@
import { describe, expect, test } from "vitest";
import { buildNodeInvokeResultParams } from "./runner.js";
describe("buildNodeInvokeResultParams", () => {
test("omits optional fields when null/undefined", () => {
const params = buildNodeInvokeResultParams(
{ id: "invoke-1", nodeId: "node-1", command: "system.run" },
{ ok: true, payloadJSON: null, error: null },
);
expect(params).toEqual({ id: "invoke-1", nodeId: "node-1", ok: true });
expect("payloadJSON" in params).toBe(false);
expect("error" in params).toBe(false);
});
test("includes payloadJSON when provided", () => {
const params = buildNodeInvokeResultParams(
{ id: "invoke-2", nodeId: "node-2", command: "system.run" },
{ ok: true, payloadJSON: '{"ok":true}' },
);
expect(params.payloadJSON).toBe('{"ok":true}');
});
test("includes payload when provided", () => {
const params = buildNodeInvokeResultParams(
{ id: "invoke-3", nodeId: "node-3", command: "system.run" },
{ ok: false, payload: { reason: "bad" } },
);
expect(params.payload).toEqual({ reason: "bad" });
});
});
+55 -88
View File
@@ -104,8 +104,7 @@ const OUTPUT_CAP = 200_000;
const OUTPUT_EVENT_TAIL = 20_000; const OUTPUT_EVENT_TAIL = 20_000;
const execHostEnforced = process.env.CLAWDBOT_NODE_EXEC_HOST?.trim().toLowerCase() === "app"; const execHostEnforced = process.env.CLAWDBOT_NODE_EXEC_HOST?.trim().toLowerCase() === "app";
const execHostFallbackAllowed = const execHostFallbackAllowed = process.env.CLAWDBOT_NODE_EXEC_FALLBACK?.trim() === "1";
process.env.CLAWDBOT_NODE_EXEC_FALLBACK?.trim().toLowerCase() !== "0";
const blockedEnvKeys = new Set([ const blockedEnvKeys = new Set([
"PATH", "PATH",
@@ -560,7 +559,8 @@ async function handleInvoke(
const skillAllow = const skillAllow =
autoAllowSkills && resolution?.executableName ? bins.has(resolution.executableName) : false; autoAllowSkills && resolution?.executableName ? bins.has(resolution.executableName) : false;
const useMacAppExec = process.platform === "darwin"; const useMacAppExec =
process.platform === "darwin" && (execHostEnforced || !execHostFallbackAllowed);
if (useMacAppExec) { if (useMacAppExec) {
const approvalDecision = const approvalDecision =
params.approvalDecision === "allow-once" || params.approvalDecision === "allow-always" params.approvalDecision === "allow-once" || params.approvalDecision === "allow-always"
@@ -579,28 +579,28 @@ async function handleInvoke(
}; };
const response = await runViaMacAppExecHost({ approvals, request: execRequest }); const response = await runViaMacAppExecHost({ approvals, request: execRequest });
if (!response) { if (!response) {
if (execHostEnforced || !execHostFallbackAllowed) { await sendNodeEvent(
await sendNodeEvent( client,
client, "exec.denied",
"exec.denied", buildExecEventPayload({
buildExecEventPayload({ sessionKey,
sessionKey, runId,
runId, host: "node",
host: "node", command: cmdText,
command: cmdText, reason: "companion-unavailable",
reason: "companion-unavailable", }),
}), );
); await sendInvokeResult(client, frame, {
await sendInvokeResult(client, frame, { ok: false,
ok: false, error: {
error: { code: "UNAVAILABLE",
code: "UNAVAILABLE", message: "COMPANION_APP_UNAVAILABLE: macOS app exec host unreachable",
message: "COMPANION_APP_UNAVAILABLE: macOS app exec host unreachable", },
}, });
}); return;
return; }
}
} else if (!response.ok) { if (!response.ok) {
const reason = response.error.reason ?? "approval-required"; const reason = response.error.reason ?? "approval-required";
await sendNodeEvent( await sendNodeEvent(
client, client,
@@ -618,29 +618,29 @@ async function handleInvoke(
error: { code: "UNAVAILABLE", message: response.error.message }, error: { code: "UNAVAILABLE", message: response.error.message },
}); });
return; return;
} else {
const result: ExecHostRunResult = response.payload;
const combined = [result.stdout, result.stderr, result.error].filter(Boolean).join("\n");
await sendNodeEvent(
client,
"exec.finished",
buildExecEventPayload({
sessionKey,
runId,
host: "node",
command: cmdText,
exitCode: result.exitCode,
timedOut: result.timedOut,
success: result.success,
output: combined,
}),
);
await sendInvokeResult(client, frame, {
ok: true,
payloadJSON: JSON.stringify(result),
});
return;
} }
const result: ExecHostRunResult = response.payload;
const combined = [result.stdout, result.stderr, result.error].filter(Boolean).join("\n");
await sendNodeEvent(
client,
"exec.finished",
buildExecEventPayload({
sessionKey,
runId,
host: "node",
command: cmdText,
exitCode: result.exitCode,
timedOut: result.timedOut,
success: result.success,
output: combined,
}),
);
await sendInvokeResult(client, frame, {
ok: true,
payloadJSON: JSON.stringify(result),
});
return;
} }
if (security === "deny") { if (security === "deny") {
@@ -833,52 +833,19 @@ async function sendInvokeResult(
}, },
) { ) {
try { try {
await client.request("node.invoke.result", buildNodeInvokeResultParams(frame, result)); await client.request("node.invoke.result", {
id: frame.id,
nodeId: frame.nodeId,
ok: result.ok,
payload: result.payload,
payloadJSON: result.payloadJSON ?? null,
error: result.error ?? null,
});
} catch { } catch {
// ignore: node invoke responses are best-effort // ignore: node invoke responses are best-effort
} }
} }
export function buildNodeInvokeResultParams(
frame: NodeInvokeRequestPayload,
result: {
ok: boolean;
payload?: unknown;
payloadJSON?: string | null;
error?: { code?: string; message?: string } | null;
},
): {
id: string;
nodeId: string;
ok: boolean;
payload?: unknown;
payloadJSON?: string;
error?: { code?: string; message?: string };
} {
const params: {
id: string;
nodeId: string;
ok: boolean;
payload?: unknown;
payloadJSON?: string;
error?: { code?: string; message?: string };
} = {
id: frame.id,
nodeId: frame.nodeId,
ok: result.ok,
};
if (result.payload !== undefined) {
params.payload = result.payload;
}
if (typeof result.payloadJSON === "string") {
params.payloadJSON = result.payloadJSON;
}
if (result.error) {
params.error = result.error;
}
return params;
}
async function sendNodeEvent(client: GatewayClient, event: string, payload: unknown) { async function sendNodeEvent(client: GatewayClient, event: string, payload: unknown) {
try { try {
await client.request("node.event", { await client.request("node.event", {
+1 -18
View File
@@ -1,5 +1,4 @@
import { execFile, spawn } from "node:child_process"; import { execFile, spawn } from "node:child_process";
import path from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { danger, shouldLogVerbose } from "../globals.js"; import { danger, shouldLogVerbose } from "../globals.js";
@@ -62,28 +61,12 @@ export async function runCommandWithTimeout(
const { windowsVerbatimArguments } = options; const { windowsVerbatimArguments } = options;
const hasInput = input !== undefined; const hasInput = input !== undefined;
const shouldSuppressNpmFund = (() => {
const cmd = path.basename(argv[0] ?? "");
if (cmd === "npm" || cmd === "npm.cmd" || cmd === "npm.exe") return true;
if (cmd === "node" || cmd === "node.exe") {
const script = argv[1] ?? "";
return script.includes("npm-cli.js");
}
return false;
})();
const resolvedEnv = env ? { ...process.env, ...env } : { ...process.env };
if (shouldSuppressNpmFund) {
if (resolvedEnv.NPM_CONFIG_FUND == null) resolvedEnv.NPM_CONFIG_FUND = "false";
if (resolvedEnv.npm_config_fund == null) resolvedEnv.npm_config_fund = "false";
}
// Spawn with inherited stdin (TTY) so tools like `pi` stay interactive when needed. // Spawn with inherited stdin (TTY) so tools like `pi` stay interactive when needed.
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
const child = spawn(argv[0], argv.slice(1), { const child = spawn(argv[0], argv.slice(1), {
stdio: [hasInput ? "pipe" : "inherit", "pipe", "pipe"], stdio: [hasInput ? "pipe" : "inherit", "pipe", "pipe"],
cwd, cwd,
env: resolvedEnv, env: env ? { ...process.env, ...env } : process.env,
windowsVerbatimArguments, windowsVerbatimArguments,
}); });
let stdout = ""; let stdout = "";
-99
View File
@@ -1,99 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { createEditorSubmitHandler } from "./tui.js";
describe("createEditorSubmitHandler", () => {
it("adds submitted messages to editor history", () => {
const editor = {
setText: vi.fn(),
addToHistory: vi.fn(),
};
const handler = createEditorSubmitHandler({
editor,
handleCommand: vi.fn(),
sendMessage: vi.fn(),
});
handler("hello world");
expect(editor.setText).toHaveBeenCalledWith("");
expect(editor.addToHistory).toHaveBeenCalledWith("hello world");
});
it("trims input before adding to history", () => {
const editor = {
setText: vi.fn(),
addToHistory: vi.fn(),
};
const handler = createEditorSubmitHandler({
editor,
handleCommand: vi.fn(),
sendMessage: vi.fn(),
});
handler(" hi ");
expect(editor.addToHistory).toHaveBeenCalledWith("hi");
});
it("does not add empty submissions to history", () => {
const editor = {
setText: vi.fn(),
addToHistory: vi.fn(),
};
const handler = createEditorSubmitHandler({
editor,
handleCommand: vi.fn(),
sendMessage: vi.fn(),
});
handler(" ");
expect(editor.addToHistory).not.toHaveBeenCalled();
});
it("routes slash commands to handleCommand", () => {
const editor = {
setText: vi.fn(),
addToHistory: vi.fn(),
};
const handleCommand = vi.fn();
const sendMessage = vi.fn();
const handler = createEditorSubmitHandler({
editor,
handleCommand,
sendMessage,
});
handler("/models");
expect(editor.addToHistory).toHaveBeenCalledWith("/models");
expect(handleCommand).toHaveBeenCalledWith("/models");
expect(sendMessage).not.toHaveBeenCalled();
});
it("routes normal messages to sendMessage", () => {
const editor = {
setText: vi.fn(),
addToHistory: vi.fn(),
};
const handleCommand = vi.fn();
const sendMessage = vi.fn();
const handler = createEditorSubmitHandler({
editor,
handleCommand,
sendMessage,
});
handler("hello");
expect(editor.addToHistory).toHaveBeenCalledWith("hello");
expect(sendMessage).toHaveBeenCalledWith("hello");
expect(handleCommand).not.toHaveBeenCalled();
});
});
+10 -29
View File
@@ -36,30 +36,6 @@ import type {
export { resolveFinalAssistantText } from "./tui-formatters.js"; export { resolveFinalAssistantText } from "./tui-formatters.js";
export type { TuiOptions } from "./tui-types.js"; export type { TuiOptions } from "./tui-types.js";
export function createEditorSubmitHandler(params: {
editor: {
setText: (value: string) => void;
addToHistory: (value: string) => void;
};
handleCommand: (value: string) => Promise<void> | void;
sendMessage: (value: string) => Promise<void> | void;
}) {
return (text: string) => {
const value = text.trim();
params.editor.setText("");
if (!value) return;
// Enable built-in editor prompt history navigation (up/down).
params.editor.addToHistory(value);
if (value.startsWith("/")) {
void params.handleCommand(value);
return;
}
void params.sendMessage(value);
};
}
export async function runTui(opts: TuiOptions) { export async function runTui(opts: TuiOptions) {
const config = loadConfig(); const config = loadConfig();
const initialSessionInput = (opts.session ?? "").trim(); const initialSessionInput = (opts.session ?? "").trim();
@@ -497,11 +473,16 @@ export async function runTui(opts: TuiOptions) {
}); });
updateAutocompleteProvider(); updateAutocompleteProvider();
editor.onSubmit = createEditorSubmitHandler({ editor.onSubmit = (text) => {
editor, const value = text.trim();
handleCommand, editor.setText("");
sendMessage, if (!value) return;
}); if (value.startsWith("/")) {
void handleCommand(value);
return;
}
void sendMessage(value);
};
editor.onEscape = () => { editor.onEscape = () => {
void abortActive(); void abortActive();