Compare commits

..
Author SHA1 Message Date
Peter Steinberger c01bfcbc12 fix: load CLI plugin registry for channel-aware commands (#1338) (thanks @MaudeBot) 2026-01-21 00:48:36 +00:00
Maude Bot 47110e88c7 fix(cli): load plugin registry for message/channels commands
Fixes #1327 - 'clawdbot message --channel telegram' fails with
'Unknown channel: telegram' because plugins weren't loaded.

The Commander code path (non-route-first) calls ensureConfigReady() in
preAction but doesn't load the plugin registry. Channel plugins like
telegram are registered during plugin loading, so getChannelPlugin()
returns undefined without it.

This adds ensurePluginRegistryLoaded() call for commands that need
channel plugin access: message, channels, directory.
2026-01-20 23:59:43 +00:00
191 changed files with 17313 additions and 23690 deletions

No files matched your search

+1 -18
View File
@@ -6,11 +6,9 @@ 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`.
- Browser: allow config defaults for efficient snapshots in the tool/CLI. (#1336) — thanks @sebslight.
- Channels: add the Nostr plugin channel with profile management + onboarding install defaults. (#1323) — thanks @joelklabo. - Channels: add the Nostr plugin channel with profile management + onboarding install defaults. (#1323) — thanks @joelklabo.
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow. - Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel. - Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
@@ -20,35 +18,20 @@ Docs: https://docs.clawd.bot
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) — thanks @steipete. - Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) — thanks @steipete.
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) — thanks @suminhthanh. - Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) — thanks @suminhthanh.
- Security: warn when <=300B models run without sandboxing and with web tools enabled. - Security: warn when <=300B models run without sandboxing and with web tools enabled.
- 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.
- 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.
- 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.
- Diagnostics: emit message-flow diagnostics across channels via shared dispatch; gate heartbeat/webhook logging. (#1244) — thanks @oscargavin.
- CLI: preserve cron delivery settings when editing message payloads. (#1322) — thanks @KrauseFx.
- CLI: keep `clawdbot logs` output resilient to broken pipes while preserving progress output.
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) — thanks @dougvk. - Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) — thanks @dougvk.
- Doctor: clarify plugin auto-enable hint text in the startup banner. - Doctor: clarify plugin auto-enable hint text in the startup banner.
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance. - Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
- Gateway: clarify connect/validation errors for gateway params. (#1347) — thanks @vignesh07.
- Gateway: preserve restart wake routing + thread replies across restarts. (#1337) — thanks @John-Rood.
- Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.
- Config: log invalid config issues once per run and keep invalid-config errors stackless.
- Exec: default gateway/node exec security to allowlist when unset (sandbox stays deny).
- UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315) — thanks @MaudeBot. - UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315) — thanks @MaudeBot.
- UI: preserve ordered list numbering in chat markdown. (#1341) — thanks @bradleypriest.
- UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342) — thanks @ameno-.
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter). - Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
- Web fetch: harden SSRF protection with shared hostname checks and redirect limits. (#1346) — thanks @fogboots.
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs. - TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
- TUI: align custom editor initialization with the latest pi-tui API. (#1298) — thanks @sibbl. - TUI: align custom editor initialization with the latest pi-tui API. (#1298) — thanks @sibbl.
- CLI: avoid duplicating --profile/--dev flags when formatting commands. - CLI: avoid duplicating --profile/--dev flags when formatting commands.
- CLI: load channel plugins for commands that need registry-backed lookups. (#1338) — thanks @MaudeBot.
- Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301) - Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301)
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander. - Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander.
- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304) - Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)
+23 -23
View File
@@ -479,27 +479,27 @@ Core contributors:
Thanks to all clawtributors: Thanks to all clawtributors:
<p align="left"> <p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a>
<a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a>
<a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a>
<a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a>
<a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a>
<a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a>
<a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a>
<a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a>
<a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a>
<a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a>
<a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a>
<a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a>
<a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a>
<a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a>
<a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a>
<a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a>
<a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a>
<a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a>
<a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a>
<a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a>
<a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a>
<a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a>
<a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p> </p>
@@ -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)
+24 -321
View File
@@ -6,19 +6,15 @@ struct ConfigSettings: View {
private let isNixMode = ProcessInfo.processInfo.isNixMode private let isNixMode = ProcessInfo.processInfo.isNixMode
@Bindable var store: ChannelsStore @Bindable var store: ChannelsStore
@State private var hasLoaded = false @State private var hasLoaded = false
@State private var activeSectionKey: String?
@State private var activeSubsection: SubsectionSelection?
init(store: ChannelsStore = .shared) { init(store: ChannelsStore = .shared) {
self.store = store self.store = store
} }
var body: some View { var body: some View {
HStack(spacing: 16) { ScrollView {
self.sidebar self.content
self.detail
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.task { .task {
guard !self.hasLoaded else { return } guard !self.hasLoaded else { return }
guard !self.isPreview else { return } guard !self.isPreview else { return }
@@ -26,125 +22,42 @@ struct ConfigSettings: View {
await self.store.loadConfigSchema() await self.store.loadConfigSchema()
await self.store.loadConfig() await self.store.loadConfig()
} }
.onAppear { self.ensureSelection() }
.onChange(of: self.store.configSchemaLoading) { _, loading in
if !loading { self.ensureSelection() }
}
} }
} }
extension ConfigSettings { extension ConfigSettings {
private enum SubsectionSelection: Hashable { private var content: some View {
case all VStack(alignment: .leading, spacing: 16) {
case key(String) self.header
} if let status = self.store.configStatus {
Text(status)
private struct ConfigSection: Identifiable { .font(.callout)
let key: String .foregroundStyle(.secondary)
let label: String }
let help: String? self.actionRow
let node: ConfigSchemaNode Group {
if self.store.configSchemaLoading {
var id: String { self.key } ProgressView().controlSize(.small)
} } else if let schema = self.store.configSchema {
ConfigSchemaForm(store: self.store, schema: schema, path: [])
private struct ConfigSubsection: Identifiable { .disabled(self.isNixMode)
let key: String } else {
let label: String Text("Schema unavailable.")
let help: String?
let node: ConfigSchemaNode
let path: ConfigPath
var id: String { self.key }
}
private var sections: [ConfigSection] {
guard let schema = self.store.configSchema else { return [] }
return self.resolveSections(schema)
}
private var activeSection: ConfigSection? {
self.sections.first { $0.key == self.activeSectionKey }
}
private var sidebar: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
if self.sections.isEmpty {
Text("No config sections available.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 4)
} else {
ForEach(self.sections) { section in
self.sidebarRow(section)
}
} }
} }
.padding(.vertical, 10) if self.store.configDirty, !self.isNixMode {
.padding(.horizontal, 10) Text("Unsaved changes")
}
.frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(nsColor: .windowBackgroundColor)))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
private var detail: some View {
VStack(alignment: .leading, spacing: 16) {
if self.store.configSchemaLoading {
ProgressView().controlSize(.small)
} else if let section = self.activeSection {
self.sectionDetail(section)
} else if self.store.configSchema != nil {
self.emptyDetail
} else {
Text("Schema unavailable.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Spacer(minLength: 0)
} }
.frame(minWidth: 460, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .leading)
}
private var emptyDetail: some View {
VStack(alignment: .leading, spacing: 8) {
self.header
Text("Select a config section to view settings.")
.font(.callout)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 24) .padding(.horizontal, 24)
.padding(.vertical, 18) .padding(.vertical, 18)
} .groupBoxStyle(PlainSettingsGroupBoxStyle())
private func sectionDetail(_ section: ConfigSection) -> some View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 16) {
self.header
if let status = self.store.configStatus {
Text(status)
.font(.callout)
.foregroundStyle(.secondary)
}
self.actionRow
self.sectionHeader(section)
self.subsectionNav(section)
self.sectionForm(section)
if self.store.configDirty, !self.isNixMode {
Text("Unsaved changes")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.vertical, 18)
.groupBoxStyle(PlainSettingsGroupBoxStyle())
}
} }
@ViewBuilder @ViewBuilder
@@ -158,18 +71,6 @@ extension ConfigSettings {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
private func sectionHeader(_ section: ConfigSection) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(section.label)
.font(.title3.weight(.semibold))
if let help = section.help {
Text(help)
.font(.callout)
.foregroundStyle(.secondary)
}
}
}
private var actionRow: some View { private var actionRow: some View {
HStack(spacing: 10) { HStack(spacing: 10) {
Button("Reload") { Button("Reload") {
@@ -184,204 +85,6 @@ extension ConfigSettings {
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
} }
private func sidebarRow(_ section: ConfigSection) -> some View {
let isSelected = self.activeSectionKey == section.key
return Button {
self.selectSection(section)
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(section.label)
if let help = section.help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
.padding(.vertical, 6)
.padding(.horizontal, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(isSelected ? Color.accentColor.opacity(0.18) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.background(Color.clear)
.contentShape(Rectangle())
}
.frame(maxWidth: .infinity, alignment: .leading)
.buttonStyle(.plain)
.contentShape(Rectangle())
}
@ViewBuilder
private func subsectionNav(_ section: ConfigSection) -> some View {
let subsections = self.resolveSubsections(for: section)
if subsections.isEmpty {
EmptyView()
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
self.subsectionButton(
title: "All",
isSelected: self.activeSubsection == .all)
{
self.activeSubsection = .all
}
ForEach(subsections) { subsection in
self.subsectionButton(
title: subsection.label,
isSelected: self.activeSubsection == .key(subsection.key))
{
self.activeSubsection = .key(subsection.key)
}
}
}
.padding(.vertical, 2)
}
}
}
private func subsectionButton(
title: String,
isSelected: Bool,
action: @escaping () -> Void) -> some View
{
Button(action: action) {
Text(title)
.font(.callout.weight(.semibold))
.foregroundStyle(isSelected ? Color.accentColor : .primary)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(isSelected ? Color.accentColor.opacity(0.18) : Color(nsColor: .controlBackgroundColor))
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
private func sectionForm(_ section: ConfigSection) -> some View {
let subsection = self.activeSubsection
let defaultPath: ConfigPath = [.key(section.key)]
let subsections = self.resolveSubsections(for: section)
let resolved: (ConfigSchemaNode, ConfigPath) = {
if case let .key(key) = subsection,
let match = subsections.first(where: { $0.key == key })
{
return (match.node, match.path)
}
return (self.resolvedSchemaNode(section.node), defaultPath)
}()
return ConfigSchemaForm(store: self.store, schema: resolved.0, path: resolved.1)
.disabled(self.isNixMode)
}
private func ensureSelection() {
guard let schema = self.store.configSchema else { return }
let sections = self.resolveSections(schema)
guard !sections.isEmpty else { return }
let active = sections.first { $0.key == self.activeSectionKey } ?? sections[0]
if self.activeSectionKey != active.key {
self.activeSectionKey = active.key
}
self.ensureSubsection(for: active)
}
private func ensureSubsection(for section: ConfigSection) {
let subsections = self.resolveSubsections(for: section)
guard !subsections.isEmpty else {
self.activeSubsection = nil
return
}
switch self.activeSubsection {
case .all:
return
case let .key(key):
if subsections.contains(where: { $0.key == key }) { return }
case .none:
break
}
if let first = subsections.first {
self.activeSubsection = .key(first.key)
}
}
private func selectSection(_ section: ConfigSection) {
guard self.activeSectionKey != section.key else { return }
self.activeSectionKey = section.key
let subsections = self.resolveSubsections(for: section)
if let first = subsections.first {
self.activeSubsection = .key(first.key)
} else {
self.activeSubsection = nil
}
}
private func resolveSections(_ root: ConfigSchemaNode) -> [ConfigSection] {
let node = self.resolvedSchemaNode(root)
let hints = self.store.configUiHints
let keys = node.properties.keys.sorted { lhs, rhs in
let orderA = hintForPath([.key(lhs)], hints: hints)?.order ?? 0
let orderB = hintForPath([.key(rhs)], hints: hints)?.order ?? 0
if orderA != orderB { return orderA < orderB }
return lhs < rhs
}
return keys.compactMap { key in
guard let child = node.properties[key] else { return nil }
let path: ConfigPath = [.key(key)]
let hint = hintForPath(path, hints: hints)
let label = hint?.label
?? child.title
?? self.humanize(key)
let help = hint?.help ?? child.description
return ConfigSection(key: key, label: label, help: help, node: child)
}
}
private func resolveSubsections(for section: ConfigSection) -> [ConfigSubsection] {
let node = self.resolvedSchemaNode(section.node)
guard node.schemaType == "object" else { return [] }
let hints = self.store.configUiHints
let keys = node.properties.keys.sorted { lhs, rhs in
let orderA = hintForPath([.key(section.key), .key(lhs)], hints: hints)?.order ?? 0
let orderB = hintForPath([.key(section.key), .key(rhs)], hints: hints)?.order ?? 0
if orderA != orderB { return orderA < orderB }
return lhs < rhs
}
return keys.compactMap { key in
guard let child = node.properties[key] else { return nil }
let path: ConfigPath = [.key(section.key), .key(key)]
let hint = hintForPath(path, hints: hints)
let label = hint?.label
?? child.title
?? self.humanize(key)
let help = hint?.help ?? child.description
return ConfigSubsection(
key: key,
label: label,
help: help,
node: child,
path: path)
}
}
private func resolvedSchemaNode(_ node: ConfigSchemaNode) -> ConfigSchemaNode {
let variants = node.anyOf.isEmpty ? node.oneOf : node.anyOf
if !variants.isEmpty {
let nonNull = variants.filter { !$0.isNullSchema }
if nonNull.count == 1, let only = nonNull.first { return only }
}
return node
}
private func humanize(_ key: String) -> String {
key.replacingOccurrences(of: "_", with: " ")
.replacingOccurrences(of: "-", with: " ")
.capitalized
}
} }
struct ConfigSettings_Previews: PreviewProvider { struct ConfigSettings_Previews: PreviewProvider {
@@ -319,7 +319,7 @@ private enum ExecHostExecutor {
security: context.security, security: context.security,
allowlistMatch: context.allowlistMatch, allowlistMatch: context.allowlistMatch,
skillAllow: context.skillAllow), skillAllow: context.skillAllow),
approvalDecision == nil approvalDecision == nil
{ {
let decision = ExecApprovalsPromptPresenter.prompt( let decision = ExecApprovalsPromptPresenter.prompt(
ExecApprovalPromptRequest( ExecApprovalPromptRequest(
@@ -172,10 +172,6 @@ actor GatewayEndpointStore {
return configToken return configToken
} }
if isRemote {
return nil
}
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines), if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty !token.isEmpty
{ {
@@ -558,16 +554,13 @@ actor GatewayEndpointStore {
{ {
switch bindMode { switch bindMode {
case "tailnet": case "tailnet":
return tailscaleIP ?? "127.0.0.1" tailscaleIP ?? "127.0.0.1"
case "auto": case "auto":
if let tailscaleIP, !tailscaleIP.isEmpty { "127.0.0.1"
return tailscaleIP
}
return "127.0.0.1"
case "custom": case "custom":
return customBindHost ?? "127.0.0.1" customBindHost ?? "127.0.0.1"
default: default:
return "127.0.0.1" "127.0.0.1"
} }
} }
} }
@@ -638,12 +631,11 @@ extension GatewayEndpointStore {
static func _testResolveLocalGatewayHost( static func _testResolveLocalGatewayHost(
bindMode: String?, bindMode: String?,
tailscaleIP: String?, tailscaleIP: String?) -> String
customBindHost: String? = nil) -> String
{ {
self.resolveLocalGatewayHost( self.resolveLocalGatewayHost(
bindMode: bindMode, bindMode: bindMode,
customBindHost: customBindHost, customBindHost: nil,
tailscaleIP: tailscaleIP) tailscaleIP: tailscaleIP)
} }
} }
@@ -2,9 +2,6 @@ import AppKit
import Foundation import Foundation
import Observation import Observation
import os import os
#if canImport(Darwin)
import Darwin
#endif
/// Manages Tailscale integration and status checking. /// Manages Tailscale integration and status checking.
@Observable @Observable
@@ -104,12 +101,15 @@ final class TailscaleService {
func checkTailscaleStatus() async { func checkTailscaleStatus() async {
self.isInstalled = self.checkAppInstallation() self.isInstalled = self.checkAppInstallation()
if !self.isInstalled { guard self.isInstalled else {
self.isRunning = false self.isRunning = false
self.tailscaleHostname = nil self.tailscaleHostname = nil
self.tailscaleIP = nil self.tailscaleIP = nil
self.statusError = "Tailscale is not installed" self.statusError = "Tailscale is not installed"
} else if let apiResponse = await fetchTailscaleStatus() { return
}
if let apiResponse = await fetchTailscaleStatus() {
self.isRunning = apiResponse.status.lowercased() == "running" self.isRunning = apiResponse.status.lowercased() == "running"
if self.isRunning { if self.isRunning {
@@ -138,15 +138,6 @@ final class TailscaleService {
self.statusError = "Please start the Tailscale app" self.statusError = "Please start the Tailscale app"
self.logger.info("Tailscale API not responding; app likely not running") self.logger.info("Tailscale API not responding; app likely not running")
} }
if self.tailscaleIP == nil, let fallback = Self.detectTailnetIPv4() {
self.tailscaleIP = fallback
if !self.isRunning {
self.isRunning = true
}
self.statusError = nil
self.logger.info("Tailscale interface IP detected (fallback) ip=\(fallback, privacy: .public)")
}
} }
func openTailscaleApp() { func openTailscaleApp() {
@@ -172,46 +163,4 @@ final class TailscaleService {
NSWorkspace.shared.open(url) NSWorkspace.shared.open(url)
} }
} }
private static func isTailnetIPv4(_ address: String) -> Bool {
let parts = address.split(separator: ".")
guard parts.count == 4 else { return false }
let octets = parts.compactMap { Int($0) }
guard octets.count == 4 else { return false }
let a = octets[0]
let b = octets[1]
return a == 100 && b >= 64 && b <= 127
}
private static func detectTailnetIPv4() -> String? {
var addrList: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
defer { freeifaddrs(addrList) }
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
let flags = Int32(ptr.pointee.ifa_flags)
let isUp = (flags & IFF_UP) != 0
let isLoopback = (flags & IFF_LOOPBACK) != 0
let family = ptr.pointee.ifa_addr.pointee.sa_family
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
var addr = ptr.pointee.ifa_addr.pointee
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
let result = getnameinfo(
&addr,
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
&buffer,
socklen_t(buffer.count),
nil,
0,
NI_NUMERICHOST)
guard result == 0 else { continue }
let len = buffer.prefix { $0 != 0 }
let bytes = len.map { UInt8(bitPattern: $0) }
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
if Self.isTailnetIPv4(ip) { return ip }
}
return nil
}
} }
@@ -1,16 +1,13 @@
import ClawdbotKit import ClawdbotKit
import ClawdbotProtocol import ClawdbotProtocol
import Foundation import Foundation
#if canImport(Darwin)
import Darwin
#endif
struct ConnectOptions { struct ConnectOptions {
var url: String? var url: String?
var token: String? var token: String?
var password: String? var password: String?
var mode: String? var mode: String?
var timeoutMs: Int = 15000 var timeoutMs: Int = 15_000
var json: Bool = false var json: Bool = false
var probe: Bool = false var probe: Bool = false
var clientId: String = "clawdbot-macos" var clientId: String = "clawdbot-macos"
@@ -22,43 +19,53 @@ struct ConnectOptions {
static func parse(_ args: [String]) -> ConnectOptions { static func parse(_ args: [String]) -> ConnectOptions {
var opts = ConnectOptions() var opts = ConnectOptions()
let flagHandlers: [String: (inout ConnectOptions) -> Void] = [
"-h": { $0.help = true },
"--help": { $0.help = true },
"--json": { $0.json = true },
"--probe": { $0.probe = true },
]
let valueHandlers: [String: (inout ConnectOptions, String) -> Void] = [
"--url": { $0.url = $1 },
"--token": { $0.token = $1 },
"--password": { $0.password = $1 },
"--mode": { $0.mode = $1 },
"--timeout": { opts, raw in
if let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)) {
opts.timeoutMs = max(250, parsed)
}
},
"--client-id": { $0.clientId = $1 },
"--client-mode": { $0.clientMode = $1 },
"--display-name": { $0.displayName = $1 },
"--role": { $0.role = $1 },
"--scopes": { opts, raw in
opts.scopes = raw.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
},
]
var i = 0 var i = 0
while i < args.count { while i < args.count {
let arg = args[i] let arg = args[i]
if let handler = flagHandlers[arg] { switch arg {
handler(&opts) case "-h", "--help":
i += 1 opts.help = true
continue case "--json":
} opts.json = true
if let handler = valueHandlers[arg], let value = self.nextValue(args, index: &i) { case "--probe":
handler(&opts, value) opts.probe = true
i += 1 case "--url":
continue opts.url = self.nextValue(args, index: &i)
case "--token":
opts.token = self.nextValue(args, index: &i)
case "--password":
opts.password = self.nextValue(args, index: &i)
case "--mode":
if let value = self.nextValue(args, index: &i) {
opts.mode = value
}
case "--timeout":
if let raw = self.nextValue(args, index: &i),
let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
{
opts.timeoutMs = max(250, parsed)
}
case "--client-id":
if let value = self.nextValue(args, index: &i) {
opts.clientId = value
}
case "--client-mode":
if let value = self.nextValue(args, index: &i) {
opts.clientMode = value
}
case "--display-name":
opts.displayName = self.nextValue(args, index: &i)
case "--role":
if let value = self.nextValue(args, index: &i) {
opts.role = value
}
case "--scopes":
if let value = self.nextValue(args, index: &i) {
opts.scopes = value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
default:
break
} }
i += 1 i += 1
} }
@@ -247,12 +254,8 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig)
if resolvedMode == "remote" { if resolvedMode == "remote" {
guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines), guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty !raw.isEmpty else {
else { throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"])
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"])
} }
guard let url = URL(string: raw) else { guard let url = URL(string: raw) else {
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"]) throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"])
@@ -265,12 +268,9 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig)
} }
let port = config.port ?? 18789 let port = config.port ?? 18789
let host = resolveLocalHost(bind: config.bind) let host = "127.0.0.1"
guard let url = URL(string: "ws://\(host):\(port)") else { guard let url = URL(string: "ws://\(host):\(port)") else {
throw NSError( throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"])
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"])
} }
return GatewayEndpoint( return GatewayEndpoint(
url: url, url: url,
@@ -280,7 +280,7 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig)
} }
private func bestEffortEndpoint(opts: ConnectOptions, config: GatewayConfig) -> GatewayEndpoint? { private func bestEffortEndpoint(opts: ConnectOptions, config: GatewayConfig) -> GatewayEndpoint? {
try? resolveGatewayEndpoint(opts: opts, config: config) return try? resolveGatewayEndpoint(opts: opts, config: config)
} }
private func resolvedToken(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? { private func resolvedToken(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? {
@@ -304,56 +304,3 @@ private func resolvedPassword(opts: ConnectOptions, mode: String, config: Gatewa
} }
return config.password return config.password
} }
private func resolveLocalHost(bind: String?) -> String {
let normalized = (bind ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let tailnetIP = detectTailnetIPv4()
switch normalized {
case "tailnet", "auto":
return tailnetIP ?? "127.0.0.1"
default:
return "127.0.0.1"
}
}
private func detectTailnetIPv4() -> String? {
var addrList: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
defer { freeifaddrs(addrList) }
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
let flags = Int32(ptr.pointee.ifa_flags)
let isUp = (flags & IFF_UP) != 0
let isLoopback = (flags & IFF_LOOPBACK) != 0
let family = ptr.pointee.ifa_addr.pointee.sa_family
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
var addr = ptr.pointee.ifa_addr.pointee
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
let result = getnameinfo(
&addr,
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
&buffer,
socklen_t(buffer.count),
nil,
0,
NI_NUMERICHOST)
guard result == 0 else { continue }
let len = buffer.prefix { $0 != 0 }
let bytes = len.map { UInt8(bitPattern: $0) }
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
if isTailnetIPv4(ip) { return ip }
}
return nil
}
private func isTailnetIPv4(_ address: String) -> Bool {
let parts = address.split(separator: ".")
guard parts.count == 4 else { return false }
let octets = parts.compactMap { Int($0) }
guard octets.count == 4 else { return false }
let a = octets[0]
let b = octets[1]
return a == 100 && b >= 64 && b <= 127
}
@@ -473,7 +473,6 @@ public struct AgentParams: Codable, Sendable {
public let replychannel: String? public let replychannel: String?
public let accountid: String? public let accountid: String?
public let replyaccountid: String? public let replyaccountid: String?
public let threadid: String?
public let timeout: Int? public let timeout: Int?
public let lane: String? public let lane: String?
public let extrasystemprompt: String? public let extrasystemprompt: String?
@@ -495,7 +494,6 @@ public struct AgentParams: Codable, Sendable {
replychannel: String?, replychannel: String?,
accountid: String?, accountid: String?,
replyaccountid: String?, replyaccountid: String?,
threadid: String?,
timeout: Int?, timeout: Int?,
lane: String?, lane: String?,
extrasystemprompt: String?, extrasystemprompt: String?,
@@ -516,7 +514,6 @@ public struct AgentParams: Codable, Sendable {
self.replychannel = replychannel self.replychannel = replychannel
self.accountid = accountid self.accountid = accountid
self.replyaccountid = replyaccountid self.replyaccountid = replyaccountid
self.threadid = threadid
self.timeout = timeout self.timeout = timeout
self.lane = lane self.lane = lane
self.extrasystemprompt = extrasystemprompt self.extrasystemprompt = extrasystemprompt
@@ -538,7 +535,6 @@ public struct AgentParams: Codable, Sendable {
case replychannel = "replyChannel" case replychannel = "replyChannel"
case accountid = "accountId" case accountid = "accountId"
case replyaccountid = "replyAccountId" case replyaccountid = "replyAccountId"
case threadid = "threadId"
case timeout case timeout
case lane case lane
case extrasystemprompt = "extraSystemPrompt" case extrasystemprompt = "extraSystemPrompt"
@@ -839,47 +835,35 @@ public struct SessionsListParams: Codable, Sendable {
public let activeminutes: Int? public let activeminutes: Int?
public let includeglobal: Bool? public let includeglobal: Bool?
public let includeunknown: Bool? public let includeunknown: Bool?
public let includederivedtitles: Bool?
public let includelastmessage: Bool?
public let label: String? public let label: String?
public let spawnedby: String? public let spawnedby: String?
public let agentid: String? public let agentid: String?
public let search: String?
public init( public init(
limit: Int?, limit: Int?,
activeminutes: Int?, activeminutes: Int?,
includeglobal: Bool?, includeglobal: Bool?,
includeunknown: Bool?, includeunknown: Bool?,
includederivedtitles: Bool?,
includelastmessage: Bool?,
label: String?, label: String?,
spawnedby: String?, spawnedby: String?,
agentid: String?, agentid: String?
search: String?
) { ) {
self.limit = limit self.limit = limit
self.activeminutes = activeminutes self.activeminutes = activeminutes
self.includeglobal = includeglobal self.includeglobal = includeglobal
self.includeunknown = includeunknown self.includeunknown = includeunknown
self.includederivedtitles = includederivedtitles
self.includelastmessage = includelastmessage
self.label = label self.label = label
self.spawnedby = spawnedby self.spawnedby = spawnedby
self.agentid = agentid self.agentid = agentid
self.search = search
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case limit case limit
case activeminutes = "activeMinutes" case activeminutes = "activeMinutes"
case includeglobal = "includeGlobal" case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown" case includeunknown = "includeUnknown"
case includederivedtitles = "includeDerivedTitles"
case includelastmessage = "includeLastMessage"
case label case label
case spawnedby = "spawnedBy" case spawnedby = "spawnedBy"
case agentid = "agentId" case agentid = "agentId"
case search
} }
} }
@@ -3,8 +3,6 @@ import SwiftUI
import Testing import Testing
@testable import Clawdbot @testable import Clawdbot
private typealias SnapshotAnyCodable = Clawdbot.AnyCodable
@Suite(.serialized) @Suite(.serialized)
@MainActor @MainActor
struct ChannelsSettingsSmokeTests { struct ChannelsSettingsSmokeTests {
@@ -19,11 +17,8 @@ struct ChannelsSettingsSmokeTests {
"signal": "Signal", "signal": "Signal",
"imessage": "iMessage", "imessage": "iMessage",
], ],
channelDetailLabels: nil,
channelSystemImages: nil,
channelMeta: nil,
channels: [ channels: [
"whatsapp": SnapshotAnyCodable([ "whatsapp": AnyCodable([
"configured": true, "configured": true,
"linked": true, "linked": true,
"authAgeMs": 86_400_000, "authAgeMs": 86_400_000,
@@ -42,7 +37,7 @@ struct ChannelsSettingsSmokeTests {
"lastEventAt": 1_700_000_060_000, "lastEventAt": 1_700_000_060_000,
"lastError": "needs login", "lastError": "needs login",
]), ]),
"telegram": SnapshotAnyCodable([ "telegram": AnyCodable([
"configured": true, "configured": true,
"tokenSource": "env", "tokenSource": "env",
"running": true, "running": true,
@@ -57,7 +52,7 @@ struct ChannelsSettingsSmokeTests {
], ],
"lastProbeAt": 1_700_000_050_000, "lastProbeAt": 1_700_000_050_000,
]), ]),
"signal": SnapshotAnyCodable([ "signal": AnyCodable([
"configured": true, "configured": true,
"baseUrl": "http://127.0.0.1:8080", "baseUrl": "http://127.0.0.1:8080",
"running": true, "running": true,
@@ -70,7 +65,7 @@ struct ChannelsSettingsSmokeTests {
], ],
"lastProbeAt": 1_700_000_050_000, "lastProbeAt": 1_700_000_050_000,
]), ]),
"imessage": SnapshotAnyCodable([ "imessage": AnyCodable([
"configured": false, "configured": false,
"running": false, "running": false,
"lastError": "not configured", "lastError": "not configured",
@@ -105,18 +100,15 @@ struct ChannelsSettingsSmokeTests {
"signal": "Signal", "signal": "Signal",
"imessage": "iMessage", "imessage": "iMessage",
], ],
channelDetailLabels: nil,
channelSystemImages: nil,
channelMeta: nil,
channels: [ channels: [
"whatsapp": SnapshotAnyCodable([ "whatsapp": AnyCodable([
"configured": false, "configured": false,
"linked": false, "linked": false,
"running": false, "running": false,
"connected": false, "connected": false,
"reconnectAttempts": 0, "reconnectAttempts": 0,
]), ]),
"telegram": SnapshotAnyCodable([ "telegram": AnyCodable([
"configured": false, "configured": false,
"running": false, "running": false,
"lastError": "bot missing", "lastError": "bot missing",
@@ -128,7 +120,7 @@ struct ChannelsSettingsSmokeTests {
], ],
"lastProbeAt": 1_700_000_100_000, "lastProbeAt": 1_700_000_100_000,
]), ]),
"signal": SnapshotAnyCodable([ "signal": AnyCodable([
"configured": false, "configured": false,
"baseUrl": "http://127.0.0.1:8080", "baseUrl": "http://127.0.0.1:8080",
"running": false, "running": false,
@@ -141,7 +133,7 @@ struct ChannelsSettingsSmokeTests {
], ],
"lastProbeAt": 1_700_000_200_000, "lastProbeAt": 1_700_000_200_000,
]), ]),
"imessage": SnapshotAnyCodable([ "imessage": AnyCodable([
"configured": false, "configured": false,
"running": false, "running": false,
"lastError": "not configured", "lastError": "not configured",
@@ -11,19 +11,16 @@ struct CronJobEditorSmokeTests {
} }
@Test func cronJobEditorBuildsBodyForNewJob() { @Test func cronJobEditorBuildsBodyForNewJob() {
let channelsStore = ChannelsStore(isPreview: true)
let view = CronJobEditor( let view = CronJobEditor(
job: nil, job: nil,
isSaving: .constant(false), isSaving: .constant(false),
error: .constant(nil), error: .constant(nil),
channelsStore: channelsStore,
onCancel: {}, onCancel: {},
onSave: { _ in }) onSave: { _ in })
_ = view.body _ = view.body
} }
@Test func cronJobEditorBuildsBodyForExistingJob() { @Test func cronJobEditorBuildsBodyForExistingJob() {
let channelsStore = ChannelsStore(isPreview: true)
let job = CronJob( let job = CronJob(
id: "job-1", id: "job-1",
agentId: "ops", agentId: "ops",
@@ -57,36 +54,31 @@ struct CronJobEditorSmokeTests {
job: job, job: job,
isSaving: .constant(false), isSaving: .constant(false),
error: .constant(nil), error: .constant(nil),
channelsStore: channelsStore,
onCancel: {}, onCancel: {},
onSave: { _ in }) onSave: { _ in })
_ = view.body _ = view.body
} }
@Test func cronJobEditorExercisesBuilders() { @Test func cronJobEditorExercisesBuilders() {
let channelsStore = ChannelsStore(isPreview: true)
var view = CronJobEditor( var view = CronJobEditor(
job: nil, job: nil,
isSaving: .constant(false), isSaving: .constant(false),
error: .constant(nil), error: .constant(nil),
channelsStore: channelsStore,
onCancel: {}, onCancel: {},
onSave: { _ in }) onSave: { _ in })
view.exerciseForTesting() view.exerciseForTesting()
} }
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws { @Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
let channelsStore = ChannelsStore(isPreview: true)
let view = CronJobEditor( let view = CronJobEditor(
job: nil, job: nil,
isSaving: .constant(false), isSaving: .constant(false),
error: .constant(nil), error: .constant(nil),
channelsStore: channelsStore,
onCancel: {}, onCancel: {},
onSave: { _ in }) onSave: { _ in })
var root: [String: Any] = [:] var root: [String: Any] = [:]
view.applyDeleteAfterRun(to: &root, scheduleKind: CronJobEditor.ScheduleKind.at, deleteAfterRun: true) view.applyDeleteAfterRun(to: &root, scheduleKind: .at, deleteAfterRun: true)
let raw = root["deleteAfterRun"] as? Bool let raw = root["deleteAfterRun"] as? Bool
#expect(raw == true) #expect(raw == true)
} }
@@ -1,4 +1,3 @@
import ClawdbotKit
import Foundation import Foundation
import os import os
import Testing import Testing
@@ -1,4 +1,3 @@
import ClawdbotKit
import Foundation import Foundation
import os import os
import Testing import Testing
@@ -1,4 +1,3 @@
import ClawdbotKit
import Foundation import Foundation
import os import os
import Testing import Testing
@@ -1,4 +1,3 @@
import ClawdbotKit
import Foundation import Foundation
import os import os
import Testing import Testing
@@ -1,4 +1,3 @@
import ClawdbotKit
import Foundation import Foundation
import Testing import Testing
@testable import Clawdbot @testable import Clawdbot
@@ -139,40 +139,4 @@ import Testing
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults) let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
#expect(resolved.mode == .remote) #expect(resolved.mode == .remote)
} }
@Test func resolveLocalGatewayHostPrefersTailnetForAuto() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "auto",
tailscaleIP: "100.64.1.2")
#expect(host == "100.64.1.2")
}
@Test func resolveLocalGatewayHostFallsBackToLoopbackForAuto() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "auto",
tailscaleIP: nil)
#expect(host == "127.0.0.1")
}
@Test func resolveLocalGatewayHostPrefersTailnetForTailnetMode() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "tailnet",
tailscaleIP: "100.64.1.5")
#expect(host == "100.64.1.5")
}
@Test func resolveLocalGatewayHostFallsBackToLoopbackForTailnetMode() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "tailnet",
tailscaleIP: nil)
#expect(host == "127.0.0.1")
}
@Test func resolveLocalGatewayHostUsesCustomBindHost() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "custom",
tailscaleIP: "100.64.1.9",
customBindHost: "192.168.1.10")
#expect(host == "192.168.1.10")
}
} }
@@ -7,17 +7,15 @@ import Testing
@Suite(.serialized) @Suite(.serialized)
struct LowCoverageHelperTests { struct LowCoverageHelperTests {
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
@Test func anyCodableHelperAccessors() throws { @Test func anyCodableHelperAccessors() throws {
let payload: [String: ProtoAnyCodable] = [ let payload: [String: AnyCodable] = [
"title": ProtoAnyCodable("Hello"), "title": AnyCodable("Hello"),
"flag": ProtoAnyCodable(true), "flag": AnyCodable(true),
"count": ProtoAnyCodable(3), "count": AnyCodable(3),
"ratio": ProtoAnyCodable(1.25), "ratio": AnyCodable(1.25),
"list": ProtoAnyCodable([ProtoAnyCodable("a"), ProtoAnyCodable(2)]), "list": AnyCodable([AnyCodable("a"), AnyCodable(2)]),
] ]
let any = ProtoAnyCodable(payload) let any = AnyCodable(payload)
let dict = try #require(any.dictionaryValue) let dict = try #require(any.dictionaryValue)
#expect(dict["title"]?.stringValue == "Hello") #expect(dict["title"]?.stringValue == "Hello")
#expect(dict["flag"]?.boolValue == true) #expect(dict["flag"]?.boolValue == true)
@@ -78,27 +76,31 @@ struct LowCoverageHelperTests {
#expect(result.stderr.contains("stderr-1999")) #expect(result.stderr.contains("stderr-1999"))
} }
@Test func nodeInfoCodableRoundTrip() throws { @Test func pairedNodesStorePersists() async throws {
let info = NodeInfo( let dir = FileManager().temporaryDirectory
.appendingPathComponent("paired-\(UUID().uuidString)", isDirectory: true)
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
let url = dir.appendingPathComponent("nodes.json")
let store = PairedNodesStore(fileURL: url)
await store.load()
#expect(await store.all().isEmpty)
let node = PairedNode(
nodeId: "node-1", nodeId: "node-1",
displayName: "Node One", displayName: "Node One",
platform: "macOS", platform: "macOS",
version: "1.0", version: "1.0",
coreVersion: "1.0-core",
uiVersion: "1.0-ui",
deviceFamily: "Mac", deviceFamily: "Mac",
modelIdentifier: "MacBookPro", modelIdentifier: "MacBookPro",
remoteIp: "192.168.1.2", token: "token",
caps: ["chat"], createdAtMs: 1,
commands: ["send"], lastSeenAtMs: nil)
permissions: ["send": true], try await store.upsert(node)
paired: true, #expect(await store.find(nodeId: "node-1")?.displayName == "Node One")
connected: false)
let data = try JSONEncoder().encode(info) try await store.touchSeen(nodeId: "node-1")
let decoded = try JSONDecoder().decode(NodeInfo.self, from: data) let updated = await store.find(nodeId: "node-1")
#expect(decoded.nodeId == "node-1") #expect(updated?.lastSeenAtMs != nil)
#expect(decoded.isPaired == true)
#expect(decoded.isConnected == false)
} }
@Test @MainActor func presenceReporterHelpers() { @Test @MainActor func presenceReporterHelpers() {
@@ -21,7 +21,6 @@ import Testing
features: [:], features: [:],
snapshot: snapshot, snapshot: snapshot,
canvashosturl: nil, canvashosturl: nil,
auth: nil,
policy: [:]) policy: [:])
let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.snapshot(hello)) let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.snapshot(hello))
@@ -3,15 +3,13 @@ import SwiftUI
import Testing import Testing
@testable import Clawdbot @testable import Clawdbot
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
@Suite(.serialized) @Suite(.serialized)
@MainActor @MainActor
struct OnboardingWizardStepViewTests { struct OnboardingWizardStepViewTests {
@Test func noteStepBuilds() { @Test func noteStepBuilds() {
let step = WizardStep( let step = WizardStep(
id: "step-1", id: "step-1",
type: ProtoAnyCodable("note"), type: AnyCodable("note"),
title: "Welcome", title: "Welcome",
message: "Hello", message: "Hello",
options: nil, options: nil,
@@ -24,17 +22,17 @@ struct OnboardingWizardStepViewTests {
} }
@Test func selectStepBuilds() { @Test func selectStepBuilds() {
let options: [[String: ProtoAnyCodable]] = [ let options: [[String: AnyCodable]] = [
["value": ProtoAnyCodable("local"), "label": ProtoAnyCodable("Local"), "hint": ProtoAnyCodable("This Mac")], ["value": AnyCodable("local"), "label": AnyCodable("Local"), "hint": AnyCodable("This Mac")],
["value": ProtoAnyCodable("remote"), "label": ProtoAnyCodable("Remote")], ["value": AnyCodable("remote"), "label": AnyCodable("Remote")],
] ]
let step = WizardStep( let step = WizardStep(
id: "step-2", id: "step-2",
type: ProtoAnyCodable("select"), type: AnyCodable("select"),
title: "Mode", title: "Mode",
message: "Choose a mode", message: "Choose a mode",
options: options, options: options,
initialvalue: ProtoAnyCodable("local"), initialvalue: AnyCodable("local"),
placeholder: nil, placeholder: nil,
sensitive: nil, sensitive: nil,
executor: nil) executor: nil)
@@ -15,9 +15,6 @@ extension URLSessionWebSocketTask: WebSocketTasking {}
public struct WebSocketTaskBox: @unchecked Sendable { public struct WebSocketTaskBox: @unchecked Sendable {
public let task: any WebSocketTasking public let task: any WebSocketTasking
public init(task: any WebSocketTasking) {
self.task = task
}
public var state: URLSessionTask.State { self.task.state } public var state: URLSessionTask.State { self.task.state }
@@ -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),
@@ -473,7 +473,6 @@ public struct AgentParams: Codable, Sendable {
public let replychannel: String? public let replychannel: String?
public let accountid: String? public let accountid: String?
public let replyaccountid: String? public let replyaccountid: String?
public let threadid: String?
public let timeout: Int? public let timeout: Int?
public let lane: String? public let lane: String?
public let extrasystemprompt: String? public let extrasystemprompt: String?
@@ -495,7 +494,6 @@ public struct AgentParams: Codable, Sendable {
replychannel: String?, replychannel: String?,
accountid: String?, accountid: String?,
replyaccountid: String?, replyaccountid: String?,
threadid: String?,
timeout: Int?, timeout: Int?,
lane: String?, lane: String?,
extrasystemprompt: String?, extrasystemprompt: String?,
@@ -516,7 +514,6 @@ public struct AgentParams: Codable, Sendable {
self.replychannel = replychannel self.replychannel = replychannel
self.accountid = accountid self.accountid = accountid
self.replyaccountid = replyaccountid self.replyaccountid = replyaccountid
self.threadid = threadid
self.timeout = timeout self.timeout = timeout
self.lane = lane self.lane = lane
self.extrasystemprompt = extrasystemprompt self.extrasystemprompt = extrasystemprompt
@@ -538,7 +535,6 @@ public struct AgentParams: Codable, Sendable {
case replychannel = "replyChannel" case replychannel = "replyChannel"
case accountid = "accountId" case accountid = "accountId"
case replyaccountid = "replyAccountId" case replyaccountid = "replyAccountId"
case threadid = "threadId"
case timeout case timeout
case lane case lane
case extrasystemprompt = "extraSystemPrompt" case extrasystemprompt = "extraSystemPrompt"
@@ -839,47 +835,35 @@ public struct SessionsListParams: Codable, Sendable {
public let activeminutes: Int? public let activeminutes: Int?
public let includeglobal: Bool? public let includeglobal: Bool?
public let includeunknown: Bool? public let includeunknown: Bool?
public let includederivedtitles: Bool?
public let includelastmessage: Bool?
public let label: String? public let label: String?
public let spawnedby: String? public let spawnedby: String?
public let agentid: String? public let agentid: String?
public let search: String?
public init( public init(
limit: Int?, limit: Int?,
activeminutes: Int?, activeminutes: Int?,
includeglobal: Bool?, includeglobal: Bool?,
includeunknown: Bool?, includeunknown: Bool?,
includederivedtitles: Bool?,
includelastmessage: Bool?,
label: String?, label: String?,
spawnedby: String?, spawnedby: String?,
agentid: String?, agentid: String?
search: String?
) { ) {
self.limit = limit self.limit = limit
self.activeminutes = activeminutes self.activeminutes = activeminutes
self.includeglobal = includeglobal self.includeglobal = includeglobal
self.includeunknown = includeunknown self.includeunknown = includeunknown
self.includederivedtitles = includederivedtitles
self.includelastmessage = includelastmessage
self.label = label self.label = label
self.spawnedby = spawnedby self.spawnedby = spawnedby
self.agentid = agentid self.agentid = agentid
self.search = search
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case limit case limit
case activeminutes = "activeMinutes" case activeminutes = "activeMinutes"
case includeglobal = "includeGlobal" case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown" case includeunknown = "includeUnknown"
case includederivedtitles = "includeDerivedTitles"
case includelastmessage = "includeLastMessage"
case label case label
case spawnedby = "spawnedBy" case spawnedby = "spawnedBy"
case agentid = "agentId" case agentid = "agentId"
case search
} }
} }
@@ -1340,9 +1324,6 @@ public struct ChannelsStatusResult: Codable, Sendable {
public let ts: Int public let ts: Int
public let channelorder: [String] public let channelorder: [String]
public let channellabels: [String: AnyCodable] public let channellabels: [String: AnyCodable]
public let channeldetaillabels: [String: AnyCodable]?
public let channelsystemimages: [String: AnyCodable]?
public let channelmeta: [[String: AnyCodable]]?
public let channels: [String: AnyCodable] public let channels: [String: AnyCodable]
public let channelaccounts: [String: AnyCodable] public let channelaccounts: [String: AnyCodable]
public let channeldefaultaccountid: [String: AnyCodable] public let channeldefaultaccountid: [String: AnyCodable]
@@ -1351,9 +1332,6 @@ public struct ChannelsStatusResult: Codable, Sendable {
ts: Int, ts: Int,
channelorder: [String], channelorder: [String],
channellabels: [String: AnyCodable], channellabels: [String: AnyCodable],
channeldetaillabels: [String: AnyCodable]?,
channelsystemimages: [String: AnyCodable]?,
channelmeta: [[String: AnyCodable]]?,
channels: [String: AnyCodable], channels: [String: AnyCodable],
channelaccounts: [String: AnyCodable], channelaccounts: [String: AnyCodable],
channeldefaultaccountid: [String: AnyCodable] channeldefaultaccountid: [String: AnyCodable]
@@ -1361,9 +1339,6 @@ public struct ChannelsStatusResult: Codable, Sendable {
self.ts = ts self.ts = ts
self.channelorder = channelorder self.channelorder = channelorder
self.channellabels = channellabels self.channellabels = channellabels
self.channeldetaillabels = channeldetaillabels
self.channelsystemimages = channelsystemimages
self.channelmeta = channelmeta
self.channels = channels self.channels = channels
self.channelaccounts = channelaccounts self.channelaccounts = channelaccounts
self.channeldefaultaccountid = channeldefaultaccountid self.channeldefaultaccountid = channeldefaultaccountid
@@ -1372,9 +1347,6 @@ public struct ChannelsStatusResult: Codable, Sendable {
case ts case ts
case channelorder = "channelOrder" case channelorder = "channelOrder"
case channellabels = "channelLabels" case channellabels = "channelLabels"
case channeldetaillabels = "channelDetailLabels"
case channelsystemimages = "channelSystemImages"
case channelmeta = "channelMeta"
case channels case channels
case channelaccounts = "channelAccounts" case channelaccounts = "channelAccounts"
case channeldefaultaccountid = "channelDefaultAccountId" case channeldefaultaccountid = "channelDefaultAccountId"
-5
View File
@@ -286,11 +286,6 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
- CLI: `clawdbot message send --media <mp4> --gif-playback` - CLI: `clawdbot message send --media <mp4> --gif-playback`
- Gateway: `send` params include `gifPlayback: true` - Gateway: `send` params include `gifPlayback: true`
## Voice notes (PTT audio)
WhatsApp sends audio as **voice notes** (PTT bubble).
- Best results: OGG/Opus. Clawdbot rewrites `audio/ogg` to `audio/ogg; codecs=opus`.
- `[[audio_as_voice]]` is ignored for WhatsApp (audio already ships as voice note).
## Media limits + optimization ## Media limits + optimization
- Default outbound cap: 5 MB (per media item). - Default outbound cap: 5 MB (per media item).
- Override: `agents.defaults.mediaMaxMb`. - Override: `agents.defaults.mediaMaxMb`.
-13
View File
@@ -14,16 +14,3 @@ Related:
Tip: run `clawdbot cron --help` for the full command surface. Tip: run `clawdbot cron --help` for the full command surface.
## Common edits
Update delivery settings without changing the message:
```bash
clawdbot cron edit <job-id> --deliver --channel telegram --to "123456789"
```
Disable delivery for an isolated job:
```bash
clawdbot cron edit <job-id> --no-deliver
```
+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>`
+1 -8
View File
@@ -18,22 +18,15 @@ 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).
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
```bash ```bash
clawdbot nodes invoke --node <id|name|ip> --command <command> --params <json> clawdbot nodes invoke --node <id|name|ip> --command <command> --params <json>
clawdbot nodes run --node <id|name|ip> <command...> clawdbot nodes run --node <id|name|ip> <command...>
``` ```
+1 -1
View File
@@ -21,4 +21,4 @@ clawdbot security audit --fix
``` ```
The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` for shared inboxes. The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` for shared inboxes.
It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled. It also warns when small models (<=300B) are used without sandboxing and with web/browser tools enabled.
-15
View File
@@ -77,21 +77,6 @@ Client Gateway
safely retry; the server keeps a short‑lived dedupe cache. safely retry; the server keeps a short‑lived dedupe cache.
- Nodes must include `role: "node"` plus caps/commands/permissions in `connect`. - Nodes must include `role: "node"` plus caps/commands/permissions in `connect`.
## Pairing + local trust
- All WS clients (operators + nodes) include a **device identity** on `connect`.
- New device IDs require pairing approval; the Gateway issues a **device token**
for subsequent connects.
- **Local** connects (loopback or the gateway host’s own tailnet address) can be
auto‑approved to keep same‑host UX smooth.
- **Non‑local** connects must sign the `connect.challenge` nonce and require
explicit approval.
- Gateway auth (`gateway.auth.*`) still applies to **all** connections, local or
remote.
Details: [Gateway protocol](/gateway/protocol), [Pairing](/start/pairing),
[Security](/gateway/security).
## Protocol typing and codegen ## Protocol typing and codegen
- TypeBox schemas define the protocol. - TypeBox schemas define the protocol.
-4
View File
@@ -1774,7 +1774,6 @@ Note: `applyPatch` is only under `tools.exec`.
- `tools.web.fetch.maxChars` (default 50000) - `tools.web.fetch.maxChars` (default 50000)
- `tools.web.fetch.timeoutSeconds` (default 30) - `tools.web.fetch.timeoutSeconds` (default 30)
- `tools.web.fetch.cacheTtlMinutes` (default 15) - `tools.web.fetch.cacheTtlMinutes` (default 15)
- `tools.web.fetch.maxRedirects` (default 3)
- `tools.web.fetch.userAgent` (optional override) - `tools.web.fetch.userAgent` (optional override)
- `tools.web.fetch.readability` (default true; disable to use basic HTML cleanup only) - `tools.web.fetch.readability` (default true; disable to use basic HTML cleanup only)
- `tools.web.fetch.firecrawl.enabled` (default true when an API key is set) - `tools.web.fetch.firecrawl.enabled` (default true when an API key is set)
@@ -2615,13 +2614,10 @@ Defaults:
// noSandbox: false, // noSandbox: false,
// executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", // executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
// attachOnly: false, // set true when tunneling a remote CDP to localhost // attachOnly: false, // set true when tunneling a remote CDP to localhost
// snapshotDefaults: { mode: "efficient" }, // tool/CLI default snapshot preset
} }
} }
``` ```
Note: `browser.snapshotDefaults` only affects Clawdbot's browser tool + CLI. Direct HTTP clients must pass `mode` explicitly.
### `ui` (Appearance) ### `ui` (Appearance)
Optional accent color used by the native apps for UI chrome (e.g. Talk Mode bubble tint). Optional accent color used by the native apps for UI chrome (e.g. Talk Mode bubble tint).
-2
View File
@@ -195,8 +195,6 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
- Gateways issue tokens per device + role. - Gateways issue tokens per device + role.
- Pairing approvals are required for new device IDs unless local auto-approval - Pairing approvals are required for new device IDs unless local auto-approval
is enabled. is enabled.
- **Local** connects include loopback and the gateway host’s own tailnet address
(so same‑host tailnet binds can still auto‑approve).
- All WS clients must include `device` identity during `connect` (operator + node). - All WS clients must include `device` identity during `connect` (operator + node).
- Non-local connections must sign the server-provided `connect.challenge` nonce. - Non-local connections must sign the server-provided `connect.challenge` nonce.
-6
View File
@@ -270,12 +270,6 @@ Note: `gateway.remote.token` is **only** for remote CLI calls; it does not
protect local WS access. protect local WS access.
Optional: pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`. Optional: pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`.
Local device pairing:
- Device pairing is auto‑approved for **local** connects (loopback or the
gateway host’s own tailnet address) to keep same‑host clients smooth.
- Other tailnet peers are **not** treated as local; they still need pairing
approval.
Auth modes: Auth modes:
- `gateway.auth.mode: "token"`: shared bearer token (recommended for most setups). - `gateway.auth.mode: "token"`: shared bearer token (recommended for most setups).
- `gateway.auth.mode: "password"`: password auth (prefer setting via env: `CLAWDBOT_GATEWAY_PASSWORD`). - `gateway.auth.mode: "password"`: password auth (prefer setting via env: `CLAWDBOT_GATEWAY_PASSWORD`).
-51
View File
@@ -1,51 +0,0 @@
---
summary: "Network hub: gateway surfaces, pairing, discovery, and security"
read_when:
- You need the network architecture + security overview
- You are debugging local vs tailnet access or pairing
- You want the canonical list of networking docs
---
# Network hub
This hub links the core docs for how Clawdbot connects, pairs, and secures
devices across localhost, LAN, and tailnet.
## Core model
- [Gateway architecture](/concepts/architecture)
- [Gateway protocol](/gateway/protocol)
- [Gateway runbook](/gateway)
- [Web surfaces + bind modes](/web)
## Pairing + identity
- [Pairing overview (DM + nodes)](/start/pairing)
- [Gateway-owned node pairing](/gateway/pairing)
- [Devices CLI (pairing + token rotation)](/cli/devices)
- [Pairing CLI (DM approvals)](/cli/pairing)
Local trust:
- Local connections (loopback or the gateway host’s own tailnet address) can be
auto‑approved for pairing to keep same‑host UX smooth.
- Non‑local tailnet/LAN clients still require explicit pairing approval.
## Discovery + transports
- [Discovery & transports](/gateway/discovery)
- [Bonjour / mDNS](/gateway/bonjour)
- [Remote access (SSH)](/gateway/remote)
- [Tailscale](/gateway/tailscale)
## Nodes + bridge
- [Nodes overview](/nodes)
- [Bridge protocol (legacy nodes)](/gateway/bridge-protocol)
- [Node runbook: iOS](/platforms/ios)
- [Node runbook: Android](/platforms/android)
## Security
- [Security overview](/gateway/security)
- [Gateway config reference](/gateway/configuration)
- [Troubleshooting](/gateway/troubleshooting)
- [Doctor](/gateway/doctor)
-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
-44
View File
@@ -49,50 +49,6 @@ Repair/migrate:
clawdbot doctor clawdbot doctor
``` ```
## Advanced: expose WSL services over LAN (portproxy)
WSL has its own virtual network. If another machine needs to reach a service
running **inside WSL** (SSH, a local TTS server, or the Gateway), you must
forward a Windows port to the current WSL IP. The WSL IP changes after restarts,
so you may need to refresh the forwarding rule.
Example (PowerShell **as Administrator**):
```powershell
$Distro = "Ubuntu-24.04"
$ListenPort = 2222
$TargetPort = 22
$WslIp = (wsl -d $Distro -- hostname -I).Trim().Split(" ")[0]
if (-not $WslIp) { throw "WSL IP not found." }
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=$ListenPort `
connectaddress=$WslIp connectport=$TargetPort
```
Allow the port through Windows Firewall (one-time):
```powershell
New-NetFirewallRule -DisplayName "WSL SSH $ListenPort" -Direction Inbound `
-Protocol TCP -LocalPort $ListenPort -Action Allow
```
Refresh the portproxy after WSL restarts:
```powershell
netsh interface portproxy delete v4tov4 listenport=$ListenPort listenaddress=0.0.0.0 | Out-Null
netsh interface portproxy add v4tov4 listenport=$ListenPort listenaddress=0.0.0.0 `
connectaddress=$WslIp connectport=$TargetPort | Out-Null
```
Notes:
- SSH from another machine targets the **Windows host IP** (example: `ssh user@windows-host -p 2222`).
- Remote nodes must point at a **reachable** Gateway URL (not `127.0.0.1`); use
`clawdbot status --all` to confirm.
- Use `listenaddress=0.0.0.0` for LAN access; `127.0.0.1` keeps it local only.
- If you want this automatic, register a Scheduled Task to run the refresh
step at login.
## Step-by-step WSL2 install ## Step-by-step WSL2 install
### 1) Install WSL2 + Ubuntu ### 1) Install WSL2 + Ubuntu
-1
View File
@@ -32,7 +32,6 @@ Use these hubs to discover every page, including deep dives and reference docs t
## Core concepts ## Core concepts
- [Architecture](/concepts/architecture) - [Architecture](/concepts/architecture)
- [Network hub](/network)
- [Agent runtime](/concepts/agent) - [Agent runtime](/concepts/agent)
- [Agent workspace](/concepts/agent-workspace) - [Agent workspace](/concepts/agent-workspace)
- [Memory](/concepts/memory) - [Memory](/concepts/memory)
-1
View File
@@ -500,7 +500,6 @@ Notes:
- `--format ai` (default when Playwright is installed): returns an AI snapshot with numeric refs (`aria-ref="<n>"`). - `--format ai` (default when Playwright is installed): returns an AI snapshot with numeric refs (`aria-ref="<n>"`).
- `--format aria`: returns the accessibility tree (no refs; inspection only). - `--format aria`: returns the accessibility tree (no refs; inspection only).
- `--efficient` (or `--mode efficient`): compact role snapshot preset (interactive + compact + depth + lower maxChars). - `--efficient` (or `--mode efficient`): compact role snapshot preset (interactive + compact + depth + lower maxChars).
- Config default (tool/CLI only): set `browser.snapshotDefaults.mode: "efficient"` to use efficient snapshots when the caller does not pass a mode (see [Gateway configuration](/gateway/configuration#browser-clawd-managed-browser)).
- Role snapshot options (`--interactive`, `--compact`, `--depth`, `--selector`) force a role-based snapshot with refs like `ref=e12`. - Role snapshot options (`--interactive`, `--compact`, `--depth`, `--selector`) force a role-based snapshot with refs like `ref=e12`.
- `--frame "<iframe selector>"` scopes role snapshots to an iframe (pairs with role refs like `e12`). - `--frame "<iframe selector>"` scopes role snapshots to an iframe (pairs with role refs like `e12`).
- `--interactive` outputs a flat, easy-to-pick list of interactive elements (best for driving actions). - `--interactive` outputs a flat, easy-to-pick list of interactive elements (best for driving actions).
+1 -1
View File
@@ -39,7 +39,7 @@ Notes:
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit. - `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
- `tools.exec.host` (default: `sandbox`) - `tools.exec.host` (default: `sandbox`)
- `tools.exec.security` (default: `deny` for sandbox, `allowlist` for gateway + node when unset) - `tools.exec.security` (default: `deny`)
- `tools.exec.ask` (default: `on-miss`) - `tools.exec.ask` (default: `on-miss`)
- `tools.exec.node` (default: unset) - `tools.exec.node` (default: unset)
- `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs. - `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs.
+1 -4
View File
@@ -111,7 +111,7 @@ Fields under `metadata.clawdbot`:
- `requires.env` — list; env var must exist **or** be provided in config. - `requires.env` — list; env var must exist **or** be provided in config.
- `requires.config` — list of `clawdbot.json` paths that must be truthy. - `requires.config` — list of `clawdbot.json` paths that must be truthy.
- `primaryEnv` — env var name associated with `skills.entries.<name>.apiKey`. - `primaryEnv` — env var name associated with `skills.entries.<name>.apiKey`.
- `install` — optional array of installer specs used by the macOS Skills UI (brew/node/go/uv/download). - `install` — optional array of installer specs used by the macOS Skills UI (brew/node/go/uv).
Note on sandboxing: Note on sandboxing:
- `requires.bins` is checked on the **host** at skill load time. - `requires.bins` is checked on the **host** at skill load time.
@@ -134,13 +134,10 @@ metadata: {"clawdbot":{"emoji":"♊️","requires":{"bins":["gemini"]},"install"
Notes: Notes:
- If multiple installers are listed, the gateway picks a **single** preferred option (brew when available, otherwise node). - If multiple installers are listed, the gateway picks a **single** preferred option (brew when available, otherwise node).
- If all installers are `download`, Clawdbot lists each entry so you can see the available artifacts.
- Installer specs can include `os: ["darwin"|"linux"|"win32"]` to filter options by platform.
- Node installs honor `skills.install.nodeManager` in `clawdbot.json` (default: npm; options: npm/pnpm/yarn/bun). - Node installs honor `skills.install.nodeManager` in `clawdbot.json` (default: npm; options: npm/pnpm/yarn/bun).
This only affects **skill installs**; the Gateway runtime should still be Node This only affects **skill installs**; the Gateway runtime should still be Node
(Bun is not recommended for WhatsApp/Telegram). (Bun is not recommended for WhatsApp/Telegram).
- Go installs: if `go` is missing and `brew` is available, the gateway installs Go via Homebrew first and sets `GOBIN` to Homebrew’s `bin` when possible. - Go installs: if `go` is missing and `brew` is available, the gateway installs Go via Homebrew first and sets `GOBIN` to Homebrew’s `bin` when possible.
- Download installs: `url` (required), `archive` (`tar.gz` | `tar.bz2` | `zip`), `extract` (default: auto when archive detected), `stripComponents`, `targetDir` (default: `~/.clawdbot/tools/<skillKey>`).
If no `metadata.clawdbot` is present, the skill is always eligible (unless If no `metadata.clawdbot` is present, the skill is always eligible (unless
disabled in config or blocked by `skills.allowBundled` for bundled skills). disabled in config or blocked by `skills.allowBundled` for bundled skills).
-2
View File
@@ -215,7 +215,6 @@ Fetch a URL and extract readable content.
maxChars: 50000, maxChars: 50000,
timeoutSeconds: 30, timeoutSeconds: 30,
cacheTtlMinutes: 15, cacheTtlMinutes: 15,
maxRedirects: 3,
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
readability: true, readability: true,
firecrawl: { firecrawl: {
@@ -242,7 +241,6 @@ Notes:
- `web_fetch` uses Readability (main-content extraction) first, then Firecrawl (if configured). If both fail, the tool returns an error. - `web_fetch` uses Readability (main-content extraction) first, then Firecrawl (if configured). If both fail, the tool returns an error.
- Firecrawl requests use bot-circumvention mode and cache results by default. - Firecrawl requests use bot-circumvention mode and cache results by default.
- `web_fetch` sends a Chrome-like User-Agent and `Accept-Language` by default; override `userAgent` if needed. - `web_fetch` sends a Chrome-like User-Agent and `Accept-Language` by default; override `userAgent` if needed.
- `web_fetch` blocks private/internal hostnames and re-checks redirects (limit with `maxRedirects`).
- `web_fetch` is best-effort extraction; some sites will need the browser tool. - `web_fetch` is best-effort extraction; some sites will need the browser tool.
- See [Firecrawl](/tools/firecrawl) for key setup and service details. - See [Firecrawl](/tools/firecrawl) for key setup and service details.
- Responses are cached (default 15 minutes) to reduce repeated fetches. - Responses are cached (default 15 minutes) to reduce repeated fetches.
+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
+2 -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");
@@ -112,10 +110,11 @@ const writeBuildStamp = () => {
}; };
if (!shouldBuild()) { if (!shouldBuild()) {
logRunner("Skipping build; dist is fresh.");
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);
}); });
-49
View File
@@ -1,49 +0,0 @@
---
name: sherpa-onnx-tts
description: Local text-to-speech via sherpa-onnx (offline, no cloud)
metadata: {"clawdbot":{"emoji":"🗣️","os":["darwin","linux","win32"],"requires":{"env":["SHERPA_ONNX_RUNTIME_DIR","SHERPA_ONNX_MODEL_DIR"]},"install":[{"id":"download-runtime-macos","kind":"download","os":["darwin"],"url":"https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.12.23/sherpa-onnx-v1.12.23-osx-universal2-shared.tar.bz2","archive":"tar.bz2","extract":true,"stripComponents":1,"targetDir":"~/.clawdbot/tools/sherpa-onnx-tts/runtime","label":"Download sherpa-onnx runtime (macOS)"},{"id":"download-runtime-linux-x64","kind":"download","os":["linux"],"url":"https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.12.23/sherpa-onnx-v1.12.23-linux-x64-shared.tar.bz2","archive":"tar.bz2","extract":true,"stripComponents":1,"targetDir":"~/.clawdbot/tools/sherpa-onnx-tts/runtime","label":"Download sherpa-onnx runtime (Linux x64)"},{"id":"download-runtime-win-x64","kind":"download","os":["win32"],"url":"https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.12.23/sherpa-onnx-v1.12.23-win-x64-shared.tar.bz2","archive":"tar.bz2","extract":true,"stripComponents":1,"targetDir":"~/.clawdbot/tools/sherpa-onnx-tts/runtime","label":"Download sherpa-onnx runtime (Windows x64)"},{"id":"download-model-lessac","kind":"download","url":"https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-en_US-lessac-high.tar.bz2","archive":"tar.bz2","extract":true,"targetDir":"~/.clawdbot/tools/sherpa-onnx-tts/models","label":"Download Piper en_US lessac (high)"}]}}
---
# sherpa-onnx-tts
Local TTS using the sherpa-onnx offline CLI.
## Install
1) Download the runtime for your OS (extracts into `~/.clawdbot/tools/sherpa-onnx-tts/runtime`)
2) Download a voice model (extracts into `~/.clawdbot/tools/sherpa-onnx-tts/models`)
Update `~/.clawdbot/clawdbot.json`:
```json5
{
skills: {
entries: {
"sherpa-onnx-tts": {
env: {
SHERPA_ONNX_RUNTIME_DIR: "~/.clawdbot/tools/sherpa-onnx-tts/runtime",
SHERPA_ONNX_MODEL_DIR: "~/.clawdbot/tools/sherpa-onnx-tts/models/vits-piper-en_US-lessac-high"
}
}
}
}
}
```
The wrapper lives in this skill folder. Run it directly, or add the wrapper to PATH:
```bash
export PATH="{baseDir}/bin:$PATH"
```
## Usage
```bash
{baseDir}/bin/sherpa-onnx-tts -o ./tts.wav "Hello from local TTS."
```
Notes:
- Pick a different model from the sherpa-onnx `tts-models` release if you want another voice.
- If the model dir has multiple `.onnx` files, set `SHERPA_ONNX_MODEL_FILE` or pass `--model-file`.
- You can also pass `--tokens-file` or `--data-dir` to override the defaults.
- Windows: run `node {baseDir}\\bin\\sherpa-onnx-tts -o tts.wav "Hello from local TTS."`
-178
View File
@@ -1,178 +0,0 @@
#!/usr/bin/env node
const fs = require("node:fs");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
function usage(message) {
if (message) {
console.error(message);
}
console.error(
"\nUsage: sherpa-onnx-tts [--runtime-dir <dir>] [--model-dir <dir>] [--model-file <file>] [--tokens-file <file>] [--data-dir <dir>] [--output <file>] \"text\"",
);
console.error("\nRequired env (or flags):\n SHERPA_ONNX_RUNTIME_DIR\n SHERPA_ONNX_MODEL_DIR");
process.exit(1);
}
function resolveRuntimeDir(explicit) {
const value = explicit || process.env.SHERPA_ONNX_RUNTIME_DIR || "";
return value.trim();
}
function resolveModelDir(explicit) {
const value = explicit || process.env.SHERPA_ONNX_MODEL_DIR || "";
return value.trim();
}
function resolveModelFile(modelDir, explicitFlag) {
const explicit = (explicitFlag || process.env.SHERPA_ONNX_MODEL_FILE || "").trim();
if (explicit) return explicit;
try {
const candidates = fs
.readdirSync(modelDir)
.filter((entry) => entry.endsWith(".onnx"))
.map((entry) => path.join(modelDir, entry));
if (candidates.length === 1) return candidates[0];
} catch {
return "";
}
return "";
}
function resolveTokensFile(modelDir, explicitFlag) {
const explicit = (explicitFlag || process.env.SHERPA_ONNX_TOKENS_FILE || "").trim();
if (explicit) return explicit;
const candidate = path.join(modelDir, "tokens.txt");
return fs.existsSync(candidate) ? candidate : "";
}
function resolveDataDir(modelDir, explicitFlag) {
const explicit = (explicitFlag || process.env.SHERPA_ONNX_DATA_DIR || "").trim();
if (explicit) return explicit;
const candidate = path.join(modelDir, "espeak-ng-data");
return fs.existsSync(candidate) ? candidate : "";
}
function resolveBinary(runtimeDir) {
const binName = process.platform === "win32" ? "sherpa-onnx-offline-tts.exe" : "sherpa-onnx-offline-tts";
return path.join(runtimeDir, "bin", binName);
}
function prependEnvPath(current, next) {
if (!next) return current;
if (!current) return next;
return `${next}${path.delimiter}${current}`;
}
const args = process.argv.slice(2);
let runtimeDir = "";
let modelDir = "";
let modelFile = "";
let tokensFile = "";
let dataDir = "";
let output = "tts.wav";
const textParts = [];
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "--runtime-dir") {
runtimeDir = args[i + 1] || "";
i += 1;
continue;
}
if (arg === "--model-dir") {
modelDir = args[i + 1] || "";
i += 1;
continue;
}
if (arg === "--model-file") {
modelFile = args[i + 1] || "";
i += 1;
continue;
}
if (arg === "--tokens-file") {
tokensFile = args[i + 1] || "";
i += 1;
continue;
}
if (arg === "--data-dir") {
dataDir = args[i + 1] || "";
i += 1;
continue;
}
if (arg === "-o" || arg === "--output") {
output = args[i + 1] || output;
i += 1;
continue;
}
if (arg === "--text") {
textParts.push(args[i + 1] || "");
i += 1;
continue;
}
textParts.push(arg);
}
runtimeDir = resolveRuntimeDir(runtimeDir);
modelDir = resolveModelDir(modelDir);
if (!runtimeDir || !modelDir) {
usage("Missing runtime/model directory.");
}
modelFile = resolveModelFile(modelDir, modelFile);
tokensFile = resolveTokensFile(modelDir, tokensFile);
dataDir = resolveDataDir(modelDir, dataDir);
if (!modelFile || !tokensFile || !dataDir) {
usage(
"Model directory is missing required files. Set SHERPA_ONNX_MODEL_FILE, SHERPA_ONNX_TOKENS_FILE, SHERPA_ONNX_DATA_DIR or pass --model-file/--tokens-file/--data-dir.",
);
}
const text = textParts.join(" ").trim();
if (!text) {
usage("Missing text.");
}
const bin = resolveBinary(runtimeDir);
if (!fs.existsSync(bin)) {
usage(`TTS binary not found: ${bin}`);
}
const env = { ...process.env };
const libDir = path.join(runtimeDir, "lib");
if (process.platform === "darwin") {
env.DYLD_LIBRARY_PATH = prependEnvPath(env.DYLD_LIBRARY_PATH || "", libDir);
} else if (process.platform === "win32") {
env.PATH = prependEnvPath(env.PATH || "", [path.join(runtimeDir, "bin"), libDir].join(path.delimiter));
} else {
env.LD_LIBRARY_PATH = prependEnvPath(env.LD_LIBRARY_PATH || "", libDir);
}
const outputPath = path.isAbsolute(output) ? output : path.join(process.cwd(), output);
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
const child = spawnSync(
bin,
[
`--vits-model=${modelFile}`,
`--vits-tokens=${tokensFile}`,
`--vits-data-dir=${dataDir}`,
`--output-filename=${outputPath}`,
text,
],
{
stdio: "inherit",
env,
},
);
if (typeof child.status === "number") {
process.exit(child.status);
}
if (child.error) {
console.error(child.error.message || String(child.error));
}
process.exit(1);
+39 -9
View File
@@ -400,7 +400,7 @@ export function createExecTool(
host = "gateway"; host = "gateway";
} }
const configuredSecurity = defaults?.security ?? (host === "sandbox" ? "deny" : "allowlist"); const configuredSecurity = defaults?.security ?? "deny";
const requestedSecurity = normalizeExecSecurity(params.security); const requestedSecurity = normalizeExecSecurity(params.security);
let security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity); let security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity);
if (elevatedRequested) { if (elevatedRequested) {
@@ -447,10 +447,7 @@ export function createExecTool(
applyPathPrepend(env, defaultPathPrepend); applyPathPrepend(env, defaultPathPrepend);
if (host === "node") { if (host === "node") {
const approvals = resolveExecApprovals( const approvals = resolveExecApprovals(defaults?.agentId);
defaults?.agentId,
host === "node" ? { security: "allowlist" } : undefined,
);
const hostSecurity = minSecurity(security, approvals.agent.security); const hostSecurity = minSecurity(security, approvals.agent.security);
const hostAsk = maxAsk(ask, approvals.agent.ask); const hostAsk = maxAsk(ask, approvals.agent.ask);
const askFallback = approvals.agent.askFallback; const askFallback = approvals.agent.askFallback;
@@ -494,7 +491,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 +511,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 +529,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 +545,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",
@@ -586,7 +616,7 @@ export function createExecTool(
} }
if (host === "gateway") { if (host === "gateway") {
const approvals = resolveExecApprovals(defaults?.agentId, { security: "allowlist" }); const approvals = resolveExecApprovals(defaults?.agentId);
const hostSecurity = minSecurity(security, approvals.agent.security); const hostSecurity = minSecurity(security, approvals.agent.security);
const hostAsk = maxAsk(ask, approvals.agent.ask); const hostAsk = maxAsk(ask, approvals.agent.ask);
const askFallback = approvals.agent.askFallback; const askFallback = approvals.agent.askFallback;
+1 -163
View File
@@ -1,13 +1,10 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { Readable } from "node:stream";
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
import { pipeline } from "node:stream/promises";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { resolveBrewExecutable } from "../infra/brew.js"; import { resolveBrewExecutable } from "../infra/brew.js";
import { runCommandWithTimeout } from "../process/exec.js"; import { runCommandWithTimeout } from "../process/exec.js";
import { CONFIG_DIR, ensureDir, resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
import { import {
hasBinary, hasBinary,
loadWorkspaceSkillEntries, loadWorkspaceSkillEntries,
@@ -16,7 +13,6 @@ import {
type SkillInstallSpec, type SkillInstallSpec,
type SkillsInstallPreferences, type SkillsInstallPreferences,
} from "./skills.js"; } from "./skills.js";
import { resolveSkillKey } from "./skills/frontmatter.js";
export type SkillInstallRequest = { export type SkillInstallRequest = {
workspaceDir: string; workspaceDir: string;
@@ -34,10 +30,6 @@ export type SkillInstallResult = {
code: number | null; code: number | null;
}; };
function isNodeReadableStream(value: unknown): value is NodeJS.ReadableStream {
return Boolean(value && typeof (value as NodeJS.ReadableStream).pipe === "function");
}
function summarizeInstallOutput(text: string): string | undefined { function summarizeInstallOutput(text: string): string | undefined {
const raw = text.trim(); const raw = text.trim();
if (!raw) return undefined; if (!raw) return undefined;
@@ -120,162 +112,11 @@ function buildInstallCommand(
if (!spec.package) return { argv: null, error: "missing uv package" }; if (!spec.package) return { argv: null, error: "missing uv package" };
return { argv: ["uv", "tool", "install", spec.package] }; return { argv: ["uv", "tool", "install", spec.package] };
} }
case "download": {
return { argv: null, error: "download install handled separately" };
}
default: default:
return { argv: null, error: "unsupported installer" }; return { argv: null, error: "unsupported installer" };
} }
} }
function resolveDownloadTargetDir(entry: SkillEntry, spec: SkillInstallSpec): string {
if (spec.targetDir?.trim()) return resolveUserPath(spec.targetDir);
const key = resolveSkillKey(entry.skill, entry);
return path.join(CONFIG_DIR, "tools", key);
}
function resolveArchiveType(spec: SkillInstallSpec, filename: string): string | undefined {
const explicit = spec.archive?.trim().toLowerCase();
if (explicit) return explicit;
const lower = filename.toLowerCase();
if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz")) return "tar.gz";
if (lower.endsWith(".tar.bz2") || lower.endsWith(".tbz2")) return "tar.bz2";
if (lower.endsWith(".zip")) return "zip";
return undefined;
}
async function downloadFile(
url: string,
destPath: string,
timeoutMs: number,
): Promise<{ bytes: number }> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), Math.max(1_000, timeoutMs));
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok || !response.body) {
throw new Error(`Download failed (${response.status} ${response.statusText})`);
}
await ensureDir(path.dirname(destPath));
const file = fs.createWriteStream(destPath);
const body = response.body as unknown;
const readable = isNodeReadableStream(body)
? body
: Readable.fromWeb(body as NodeReadableStream);
await pipeline(readable, file);
const stat = await fs.promises.stat(destPath);
return { bytes: stat.size };
} finally {
clearTimeout(timeout);
}
}
async function extractArchive(params: {
archivePath: string;
archiveType: string;
targetDir: string;
stripComponents?: number;
timeoutMs: number;
}): Promise<{ stdout: string; stderr: string; code: number | null }> {
const { archivePath, archiveType, targetDir, stripComponents, timeoutMs } = params;
if (archiveType === "zip") {
if (!hasBinary("unzip")) {
return { stdout: "", stderr: "unzip not found on PATH", code: null };
}
const argv = ["unzip", "-q", archivePath, "-d", targetDir];
return await runCommandWithTimeout(argv, { timeoutMs });
}
if (!hasBinary("tar")) {
return { stdout: "", stderr: "tar not found on PATH", code: null };
}
const argv = ["tar", "xf", archivePath, "-C", targetDir];
if (typeof stripComponents === "number" && Number.isFinite(stripComponents)) {
argv.push("--strip-components", String(Math.max(0, Math.floor(stripComponents))));
}
return await runCommandWithTimeout(argv, { timeoutMs });
}
async function installDownloadSpec(params: {
entry: SkillEntry;
spec: SkillInstallSpec;
timeoutMs: number;
}): Promise<SkillInstallResult> {
const { entry, spec, timeoutMs } = params;
const url = spec.url?.trim();
if (!url) {
return {
ok: false,
message: "missing download url",
stdout: "",
stderr: "",
code: null,
};
}
let filename = "";
try {
const parsed = new URL(url);
filename = path.basename(parsed.pathname);
} catch {
filename = path.basename(url);
}
if (!filename) filename = "download";
const targetDir = resolveDownloadTargetDir(entry, spec);
await ensureDir(targetDir);
const archivePath = path.join(targetDir, filename);
let downloaded = 0;
try {
const result = await downloadFile(url, archivePath, timeoutMs);
downloaded = result.bytes;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { ok: false, message, stdout: "", stderr: message, code: null };
}
const archiveType = resolveArchiveType(spec, filename);
const shouldExtract = spec.extract ?? Boolean(archiveType);
if (!shouldExtract) {
return {
ok: true,
message: `Downloaded to ${archivePath}`,
stdout: `downloaded=${downloaded}`,
stderr: "",
code: 0,
};
}
if (!archiveType) {
return {
ok: false,
message: "extract requested but archive type could not be detected",
stdout: "",
stderr: "",
code: null,
};
}
const extractResult = await extractArchive({
archivePath,
archiveType,
targetDir,
stripComponents: spec.stripComponents,
timeoutMs,
});
const success = extractResult.code === 0;
return {
ok: success,
message: success
? `Downloaded and extracted to ${targetDir}`
: formatInstallFailureMessage(extractResult),
stdout: extractResult.stdout.trim(),
stderr: extractResult.stderr.trim(),
code: extractResult.code,
};
}
async function resolveBrewBinDir(timeoutMs: number, brewExe?: string): Promise<string | undefined> { async function resolveBrewBinDir(timeoutMs: number, brewExe?: string): Promise<string | undefined> {
const exe = brewExe ?? (hasBinary("brew") ? "brew" : resolveBrewExecutable()); const exe = brewExe ?? (hasBinary("brew") ? "brew" : resolveBrewExecutable());
if (!exe) return undefined; if (!exe) return undefined;
@@ -326,9 +167,6 @@ export async function installSkill(params: SkillInstallRequest): Promise<SkillIn
code: null, code: null,
}; };
} }
if (spec.kind === "download") {
return await installDownloadSpec({ entry, spec, timeoutMs });
}
const prefs = resolveSkillsInstallPreferences(params.config); const prefs = resolveSkillsInstallPreferences(params.config);
const command = buildInstallCommand(spec, prefs); const command = buildInstallCommand(spec, prefs);
+29 -42
View File
@@ -100,49 +100,36 @@ function normalizeInstallOptions(
): SkillInstallOption[] { ): SkillInstallOption[] {
const install = entry.clawdbot?.install ?? []; const install = entry.clawdbot?.install ?? [];
if (install.length === 0) return []; if (install.length === 0) return [];
const preferred = selectPreferredInstallSpec(install, prefs);
const platform = process.platform;
const filtered = install.filter((spec) => {
const osList = spec.os ?? [];
return osList.length === 0 || osList.includes(platform);
});
if (filtered.length === 0) return [];
const toOption = (spec: SkillInstallSpec, index: number): SkillInstallOption => {
const id = (spec.id ?? `${spec.kind}-${index}`).trim();
const bins = spec.bins ?? [];
let label = (spec.label ?? "").trim();
if (spec.kind === "node" && spec.package) {
label = `Install ${spec.package} (${prefs.nodeManager})`;
}
if (!label) {
if (spec.kind === "brew" && spec.formula) {
label = `Install ${spec.formula} (brew)`;
} else if (spec.kind === "node" && spec.package) {
label = `Install ${spec.package} (${prefs.nodeManager})`;
} else if (spec.kind === "go" && spec.module) {
label = `Install ${spec.module} (go)`;
} else if (spec.kind === "uv" && spec.package) {
label = `Install ${spec.package} (uv)`;
} else if (spec.kind === "download" && spec.url) {
const url = spec.url.trim();
const last = url.split("/").pop();
label = `Download ${last && last.length > 0 ? last : url}`;
} else {
label = "Run installer";
}
}
return { id, kind: spec.kind, label, bins };
};
const allDownloads = filtered.every((spec) => spec.kind === "download");
if (allDownloads) {
return filtered.map((spec, index) => toOption(spec, index));
}
const preferred = selectPreferredInstallSpec(filtered, prefs);
if (!preferred) return []; if (!preferred) return [];
return [toOption(preferred.spec, preferred.index)]; const { spec, index } = preferred;
const id = (spec.id ?? `${spec.kind}-${index}`).trim();
const bins = spec.bins ?? [];
let label = (spec.label ?? "").trim();
if (spec.kind === "node" && spec.package) {
label = `Install ${spec.package} (${prefs.nodeManager})`;
}
if (!label) {
if (spec.kind === "brew" && spec.formula) {
label = `Install ${spec.formula} (brew)`;
} else if (spec.kind === "node" && spec.package) {
label = `Install ${spec.package} (${prefs.nodeManager})`;
} else if (spec.kind === "go" && spec.module) {
label = `Install ${spec.module} (go)`;
} else if (spec.kind === "uv" && spec.package) {
label = `Install ${spec.package} (uv)`;
} else {
label = "Run installer";
}
}
return [
{
id,
kind: spec.kind,
label,
bins,
},
];
} }
function buildSkillStatus( function buildSkillStatus(
@@ -109,33 +109,4 @@ describe("buildWorkspaceSkillStatus", () => {
} }
} }
}); });
it("filters install options by OS", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-"));
const skillDir = path.join(workspaceDir, "skills", "install-skill");
await writeSkill({
dir: skillDir,
name: "install-skill",
description: "OS-specific installs",
metadata:
'{"clawdbot":{"requires":{"bins":["missing-bin"]},"install":[{"id":"mac","kind":"download","os":["darwin"],"url":"https://example.com/mac.tar.bz2"},{"id":"linux","kind":"download","os":["linux"],"url":"https://example.com/linux.tar.bz2"},{"id":"win","kind":"download","os":["win32"],"url":"https://example.com/win.tar.bz2"}]}}',
});
const report = buildWorkspaceSkillStatus(workspaceDir, {
managedSkillsDir: path.join(workspaceDir, ".managed"),
});
const skill = report.skills.find((entry) => entry.name === "install-skill");
expect(skill).toBeDefined();
if (process.platform === "darwin") {
expect(skill?.install.map((opt) => opt.id)).toEqual(["mac"]);
} else if (process.platform === "linux") {
expect(skill?.install.map((opt) => opt.id)).toEqual(["linux"]);
} else if (process.platform === "win32") {
expect(skill?.install.map((opt) => opt.id)).toEqual(["win"]);
} else {
expect(skill?.install).toEqual([]);
}
});
}); });
+1 -8
View File
@@ -35,7 +35,7 @@ function parseInstallSpec(input: unknown): SkillInstallSpec | undefined {
const kindRaw = const kindRaw =
typeof raw.kind === "string" ? raw.kind : typeof raw.type === "string" ? raw.type : ""; typeof raw.kind === "string" ? raw.kind : typeof raw.type === "string" ? raw.type : "";
const kind = kindRaw.trim().toLowerCase(); const kind = kindRaw.trim().toLowerCase();
if (kind !== "brew" && kind !== "node" && kind !== "go" && kind !== "uv" && kind !== "download") { if (kind !== "brew" && kind !== "node" && kind !== "go" && kind !== "uv") {
return undefined; return undefined;
} }
@@ -47,16 +47,9 @@ function parseInstallSpec(input: unknown): SkillInstallSpec | undefined {
if (typeof raw.label === "string") spec.label = raw.label; if (typeof raw.label === "string") spec.label = raw.label;
const bins = normalizeStringList(raw.bins); const bins = normalizeStringList(raw.bins);
if (bins.length > 0) spec.bins = bins; if (bins.length > 0) spec.bins = bins;
const osList = normalizeStringList(raw.os);
if (osList.length > 0) spec.os = osList;
if (typeof raw.formula === "string") spec.formula = raw.formula; if (typeof raw.formula === "string") spec.formula = raw.formula;
if (typeof raw.package === "string") spec.package = raw.package; if (typeof raw.package === "string") spec.package = raw.package;
if (typeof raw.module === "string") spec.module = raw.module; if (typeof raw.module === "string") spec.module = raw.module;
if (typeof raw.url === "string") spec.url = raw.url;
if (typeof raw.archive === "string") spec.archive = raw.archive;
if (typeof raw.extract === "boolean") spec.extract = raw.extract;
if (typeof raw.stripComponents === "number") spec.stripComponents = raw.stripComponents;
if (typeof raw.targetDir === "string") spec.targetDir = raw.targetDir;
return spec; return spec;
} }
+1 -7
View File
@@ -2,18 +2,12 @@ import type { Skill } from "@mariozechner/pi-coding-agent";
export type SkillInstallSpec = { export type SkillInstallSpec = {
id?: string; id?: string;
kind: "brew" | "node" | "go" | "uv" | "download"; kind: "brew" | "node" | "go" | "uv";
label?: string; label?: string;
bins?: string[]; bins?: string[];
os?: string[];
formula?: string; formula?: string;
package?: string; package?: string;
module?: string; module?: string;
url?: string;
archive?: string;
extract?: boolean;
stripComponents?: number;
targetDir?: string;
}; };
export type ClawdbotSkillMetadata = { export type ClawdbotSkillMetadata = {
+7 -37
View File
@@ -49,10 +49,9 @@ const browserConfigMocks = vi.hoisted(() => ({
})); }));
vi.mock("../../browser/config.js", () => browserConfigMocks); vi.mock("../../browser/config.js", () => browserConfigMocks);
const configMocks = vi.hoisted(() => ({ vi.mock("../../config/config.js", () => ({
loadConfig: vi.fn(() => ({ browser: {} })), loadConfig: vi.fn(() => ({ browser: {} })),
})); }));
vi.mock("../../config/config.js", () => configMocks);
const toolCommonMocks = vi.hoisted(() => ({ const toolCommonMocks = vi.hoisted(() => ({
imageResultFromFile: vi.fn(), imageResultFromFile: vi.fn(),
@@ -71,12 +70,11 @@ import { createBrowserTool } from "./browser-tool.js";
describe("browser tool snapshot maxChars", () => { describe("browser tool snapshot maxChars", () => {
afterEach(() => { afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
configMocks.loadConfig.mockReturnValue({ browser: {} });
}); });
it("applies the default ai snapshot limit", async () => { it("applies the default ai snapshot limit", async () => {
const tool = createBrowserTool(); const tool = createBrowserTool();
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai" }); await tool.execute?.(null, { action: "snapshot", format: "ai" });
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith( expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
"http://127.0.0.1:18791", "http://127.0.0.1:18791",
@@ -92,7 +90,7 @@ describe("browser tool snapshot maxChars", () => {
const override = 2_000; const override = 2_000;
await tool.execute?.(null, { await tool.execute?.(null, {
action: "snapshot", action: "snapshot",
snapshotFormat: "ai", format: "ai",
maxChars: override, maxChars: override,
}); });
@@ -108,7 +106,7 @@ describe("browser tool snapshot maxChars", () => {
const tool = createBrowserTool(); const tool = createBrowserTool();
await tool.execute?.(null, { await tool.execute?.(null, {
action: "snapshot", action: "snapshot",
snapshotFormat: "ai", format: "ai",
maxChars: 0, maxChars: 0,
}); });
@@ -126,7 +124,7 @@ describe("browser tool snapshot maxChars", () => {
it("passes refs mode through to browser snapshot", async () => { it("passes refs mode through to browser snapshot", async () => {
const tool = createBrowserTool(); const tool = createBrowserTool();
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai", refs: "aria" }); await tool.execute?.(null, { action: "snapshot", format: "ai", refs: "aria" });
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith( expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
"http://127.0.0.1:18791", "http://127.0.0.1:18791",
@@ -137,36 +135,9 @@ describe("browser tool snapshot maxChars", () => {
); );
}); });
it("uses config snapshot defaults when mode is not provided", async () => {
configMocks.loadConfig.mockReturnValue({
browser: { snapshotDefaults: { mode: "efficient" } },
});
const tool = createBrowserTool();
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai" });
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
"http://127.0.0.1:18791",
expect.objectContaining({
mode: "efficient",
}),
);
});
it("does not apply config snapshot defaults to aria snapshots", async () => {
configMocks.loadConfig.mockReturnValue({
browser: { snapshotDefaults: { mode: "efficient" } },
});
const tool = createBrowserTool();
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "aria" });
expect(browserClientMocks.browserSnapshot).toHaveBeenCalled();
const [, opts] = browserClientMocks.browserSnapshot.mock.calls.at(-1) ?? [];
expect(opts?.mode).toBeUndefined();
});
it("defaults to host when using profile=chrome (even in sandboxed sessions)", async () => { it("defaults to host when using profile=chrome (even in sandboxed sessions)", async () => {
const tool = createBrowserTool({ defaultControlUrl: "http://127.0.0.1:9999" }); const tool = createBrowserTool({ defaultControlUrl: "http://127.0.0.1:9999" });
await tool.execute?.(null, { action: "snapshot", profile: "chrome", snapshotFormat: "ai" }); await tool.execute?.(null, { action: "snapshot", profile: "chrome", format: "ai" });
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith( expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
"http://127.0.0.1:18791", "http://127.0.0.1:18791",
@@ -180,7 +151,6 @@ describe("browser tool snapshot maxChars", () => {
describe("browser tool snapshot labels", () => { describe("browser tool snapshot labels", () => {
afterEach(() => { afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
configMocks.loadConfig.mockReturnValue({ browser: {} });
}); });
it("returns image + text when labels are requested", async () => { it("returns image + text when labels are requested", async () => {
@@ -205,7 +175,7 @@ describe("browser tool snapshot labels", () => {
const result = await tool.execute?.(null, { const result = await tool.execute?.(null, {
action: "snapshot", action: "snapshot",
snapshotFormat: "ai", format: "ai",
labels: true, labels: true,
}); });
+1 -7
View File
@@ -190,17 +190,11 @@ export function createBrowserTool(opts?: {
return jsonResult({ ok: true }); return jsonResult({ ok: true });
} }
case "snapshot": { case "snapshot": {
const snapshotDefaults = loadConfig().browser?.snapshotDefaults;
const format = const format =
params.snapshotFormat === "ai" || params.snapshotFormat === "aria" params.snapshotFormat === "ai" || params.snapshotFormat === "aria"
? (params.snapshotFormat as "ai" | "aria") ? (params.snapshotFormat as "ai" | "aria")
: "ai"; : "ai";
const mode = const mode = params.mode === "efficient" ? "efficient" : undefined;
params.mode === "efficient"
? "efficient"
: format === "ai" && snapshotDefaults?.mode === "efficient"
? "efficient"
: undefined;
const labels = typeof params.labels === "boolean" ? params.labels : undefined; const labels = typeof params.labels === "boolean" ? params.labels : undefined;
const refs = params.refs === "aria" || params.refs === "role" ? params.refs : undefined; const refs = params.refs === "aria" || params.refs === "role" ? params.refs : undefined;
const hasMaxChars = Object.hasOwn(params, "maxChars"); const hasMaxChars = Object.hasOwn(params, "maxChars");
+2 -2
View File
@@ -65,7 +65,7 @@ describe("cron tool", () => {
data: { data: {
name: "wake-up", name: "wake-up",
schedule: { atMs: 123 }, schedule: { atMs: 123 },
payload: { kind: "systemEvent", text: "hello" }, payload: { text: "hello" },
}, },
}, },
}); });
@@ -105,7 +105,7 @@ describe("cron tool", () => {
job: { job: {
name: "reminder", name: "reminder",
schedule: { atMs: 123 }, schedule: { atMs: 123 },
payload: { kind: "systemEvent", text: "Reminder: the thing." }, payload: { text: "Reminder: the thing." },
}, },
}); });
-33
View File
@@ -3,8 +3,6 @@ import crypto from "node:crypto";
import { Type } from "@sinclair/typebox"; import { Type } from "@sinclair/typebox";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { loadConfig } from "../../config/io.js";
import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js"; import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
import { import {
formatDoctorNonInteractiveHint, formatDoctorNonInteractiveHint,
@@ -79,42 +77,11 @@ export function createGatewayTool(opts?: {
: undefined; : undefined;
const note = const note =
typeof params.note === "string" && params.note.trim() ? params.note.trim() : undefined; typeof params.note === "string" && params.note.trim() ? params.note.trim() : undefined;
// Extract channel + threadId for routing after restart
let deliveryContext: { channel?: string; to?: string; accountId?: string } | undefined;
let threadId: string | undefined;
if (sessionKey) {
const threadMarker = ":thread:";
const threadIndex = sessionKey.lastIndexOf(threadMarker);
const baseSessionKey = threadIndex === -1 ? sessionKey : sessionKey.slice(0, threadIndex);
const threadIdRaw =
threadIndex === -1 ? undefined : sessionKey.slice(threadIndex + threadMarker.length);
threadId = threadIdRaw?.trim() || undefined;
try {
const cfg = loadConfig();
const storePath = resolveStorePath(cfg.session?.store);
const store = loadSessionStore(storePath);
let entry = store[sessionKey];
if (!entry?.deliveryContext && threadIndex !== -1 && baseSessionKey) {
entry = store[baseSessionKey];
}
if (entry?.deliveryContext) {
deliveryContext = {
channel: entry.deliveryContext.channel,
to: entry.deliveryContext.to,
accountId: entry.deliveryContext.accountId,
};
}
} catch {
// ignore: best-effort
}
}
const payload: RestartSentinelPayload = { const payload: RestartSentinelPayload = {
kind: "restart", kind: "restart",
status: "ok", status: "ok",
ts: Date.now(), ts: Date.now(),
sessionKey, sessionKey,
deliveryContext,
threadId,
message: note ?? reason ?? null, message: note ?? reason ?? null,
doctorHint: formatDoctorNonInteractiveHint(), doctorHint: formatDoctorNonInteractiveHint(),
stats: { stats: {
-160
View File
@@ -1,160 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const lookupMock = vi.fn();
vi.mock("node:dns/promises", () => ({
lookup: lookupMock,
}));
function makeHeaders(map: Record<string, string>): { get: (key: string) => string | null } {
return {
get: (key) => map[key.toLowerCase()] ?? null,
};
}
function redirectResponse(location: string): Response {
return {
ok: false,
status: 302,
headers: makeHeaders({ location }),
body: { cancel: vi.fn() },
} as Response;
}
function textResponse(body: string): Response {
return {
ok: true,
status: 200,
headers: makeHeaders({ "content-type": "text/plain" }),
text: async () => body,
} as Response;
}
describe("web_fetch SSRF protection", () => {
const priorFetch = global.fetch;
afterEach(() => {
// @ts-expect-error restore
global.fetch = priorFetch;
lookupMock.mockReset();
vi.restoreAllMocks();
});
it("blocks localhost hostnames before fetch/firecrawl", async () => {
const fetchSpy = vi.fn();
// @ts-expect-error mock fetch
global.fetch = fetchSpy;
const { createWebFetchTool } = await import("./web-tools.js");
const tool = createWebFetchTool({
config: {
tools: {
web: {
fetch: {
cacheTtlMinutes: 0,
firecrawl: { apiKey: "firecrawl-test" },
},
},
},
},
});
await expect(tool?.execute?.("call", { url: "http://localhost/test" })).rejects.toThrow(
/Blocked hostname/i,
);
expect(fetchSpy).not.toHaveBeenCalled();
expect(lookupMock).not.toHaveBeenCalled();
});
it("blocks private IP literals without DNS", async () => {
const fetchSpy = vi.fn();
// @ts-expect-error mock fetch
global.fetch = fetchSpy;
const { createWebFetchTool } = await import("./web-tools.js");
const tool = createWebFetchTool({
config: {
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
},
});
await expect(tool?.execute?.("call", { url: "http://127.0.0.1/test" })).rejects.toThrow(
/private|internal|blocked/i,
);
await expect(tool?.execute?.("call", { url: "http://[::ffff:127.0.0.1]/" })).rejects.toThrow(
/private|internal|blocked/i,
);
expect(fetchSpy).not.toHaveBeenCalled();
expect(lookupMock).not.toHaveBeenCalled();
});
it("blocks when DNS resolves to private addresses", async () => {
lookupMock.mockImplementation(async (hostname: string) => {
if (hostname === "public.test") {
return [{ address: "93.184.216.34", family: 4 }];
}
return [{ address: "10.0.0.5", family: 4 }];
});
const fetchSpy = vi.fn();
// @ts-expect-error mock fetch
global.fetch = fetchSpy;
const { createWebFetchTool } = await import("./web-tools.js");
const tool = createWebFetchTool({
config: {
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
},
});
await expect(tool?.execute?.("call", { url: "https://private.test/resource" })).rejects.toThrow(
/private|internal|blocked/i,
);
expect(fetchSpy).not.toHaveBeenCalled();
});
it("blocks redirects to private hosts", async () => {
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
const fetchSpy = vi.fn().mockResolvedValueOnce(redirectResponse("http://127.0.0.1/secret"));
// @ts-expect-error mock fetch
global.fetch = fetchSpy;
const { createWebFetchTool } = await import("./web-tools.js");
const tool = createWebFetchTool({
config: {
tools: {
web: {
fetch: { cacheTtlMinutes: 0, firecrawl: { apiKey: "firecrawl-test" } },
},
},
},
});
await expect(tool?.execute?.("call", { url: "https://example.com" })).rejects.toThrow(
/private|internal|blocked/i,
);
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it("allows public hosts", async () => {
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
const fetchSpy = vi.fn().mockResolvedValue(textResponse("ok"));
// @ts-expect-error mock fetch
global.fetch = fetchSpy;
const { createWebFetchTool } = await import("./web-tools.js");
const tool = createWebFetchTool({
config: {
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
},
});
const result = await tool?.execute?.("call", { url: "https://example.com" });
expect(result?.details).toMatchObject({
status: 200,
extractor: "raw",
});
});
});
+14 -88
View File
@@ -1,7 +1,6 @@
import { Type } from "@sinclair/typebox"; import { Type } from "@sinclair/typebox";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { assertPublicHostname, SsrFBlockedError } from "../../infra/net/ssrf.js";
import { stringEnum } from "../schema/typebox.js"; import { stringEnum } from "../schema/typebox.js";
import type { AnyAgentTool } from "./common.js"; import type { AnyAgentTool } from "./common.js";
import { jsonResult, readNumberParam, readStringParam } from "./common.js"; import { jsonResult, readNumberParam, readStringParam } from "./common.js";
@@ -30,7 +29,6 @@ export { extractReadableContent } from "./web-fetch-utils.js";
const EXTRACT_MODES = ["markdown", "text"] as const; const EXTRACT_MODES = ["markdown", "text"] as const;
const DEFAULT_FETCH_MAX_CHARS = 50_000; const DEFAULT_FETCH_MAX_CHARS = 50_000;
const DEFAULT_FETCH_MAX_REDIRECTS = 3;
const DEFAULT_ERROR_MAX_CHARS = 4_000; const DEFAULT_ERROR_MAX_CHARS = 4_000;
const DEFAULT_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev"; const DEFAULT_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev";
const DEFAULT_FIRECRAWL_MAX_AGE_MS = 172_800_000; const DEFAULT_FIRECRAWL_MAX_AGE_MS = 172_800_000;
@@ -146,11 +144,6 @@ function resolveMaxChars(value: unknown, fallback: number): number {
return Math.max(100, Math.floor(parsed)); return Math.max(100, Math.floor(parsed));
} }
function resolveMaxRedirects(value: unknown, fallback: number): number {
const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback;
return Math.max(0, Math.floor(parsed));
}
function looksLikeHtml(value: string): boolean { function looksLikeHtml(value: string): boolean {
const trimmed = value.trimStart(); const trimmed = value.trimStart();
if (!trimmed) return false; if (!trimmed) return false;
@@ -158,68 +151,6 @@ function looksLikeHtml(value: string): boolean {
return head.startsWith("<!doctype html") || head.startsWith("<html"); return head.startsWith("<!doctype html") || head.startsWith("<html");
} }
function isRedirectStatus(status: number): boolean {
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
}
async function fetchWithRedirects(params: {
url: string;
maxRedirects: number;
timeoutSeconds: number;
userAgent: string;
}): Promise<{ response: Response; finalUrl: string }> {
const signal = withTimeout(undefined, params.timeoutSeconds * 1000);
const visited = new Set<string>();
let currentUrl = params.url;
let redirectCount = 0;
while (true) {
let parsedUrl: URL;
try {
parsedUrl = new URL(currentUrl);
} catch {
throw new Error("Invalid URL: must be http or https");
}
if (!["http:", "https:"].includes(parsedUrl.protocol)) {
throw new Error("Invalid URL: must be http or https");
}
await assertPublicHostname(parsedUrl.hostname);
const res = await fetch(parsedUrl.toString(), {
method: "GET",
headers: {
Accept: "*/*",
"User-Agent": params.userAgent,
"Accept-Language": "en-US,en;q=0.9",
},
signal,
redirect: "manual",
});
if (isRedirectStatus(res.status)) {
const location = res.headers.get("location");
if (!location) {
throw new Error(`Redirect missing location header (${res.status})`);
}
redirectCount += 1;
if (redirectCount > params.maxRedirects) {
throw new Error(`Too many redirects (limit: ${params.maxRedirects})`);
}
const nextUrl = new URL(location, parsedUrl).toString();
if (visited.has(nextUrl)) {
throw new Error("Redirect loop detected");
}
visited.add(nextUrl);
void res.body?.cancel();
currentUrl = nextUrl;
continue;
}
return { response: res, finalUrl: currentUrl };
}
}
function formatWebFetchErrorDetail(params: { function formatWebFetchErrorDetail(params: {
detail: string; detail: string;
contentType?: string | null; contentType?: string | null;
@@ -316,7 +247,6 @@ async function runWebFetch(params: {
url: string; url: string;
extractMode: ExtractMode; extractMode: ExtractMode;
maxChars: number; maxChars: number;
maxRedirects: number;
timeoutSeconds: number; timeoutSeconds: number;
cacheTtlMs: number; cacheTtlMs: number;
userAgent: string; userAgent: string;
@@ -348,23 +278,20 @@ async function runWebFetch(params: {
const start = Date.now(); const start = Date.now();
let res: Response; let res: Response;
let finalUrl = params.url;
try { try {
const result = await fetchWithRedirects({ res = await fetch(parsedUrl.toString(), {
url: params.url, method: "GET",
maxRedirects: params.maxRedirects, headers: {
timeoutSeconds: params.timeoutSeconds, Accept: "*/*",
userAgent: params.userAgent, "User-Agent": params.userAgent,
"Accept-Language": "en-US,en;q=0.9",
},
signal: withTimeout(undefined, params.timeoutSeconds * 1000),
}); });
res = result.response;
finalUrl = result.finalUrl;
} catch (error) { } catch (error) {
if (error instanceof SsrFBlockedError) {
throw error;
}
if (params.firecrawlEnabled && params.firecrawlApiKey) { if (params.firecrawlEnabled && params.firecrawlApiKey) {
const firecrawl = await fetchFirecrawlContent({ const firecrawl = await fetchFirecrawlContent({
url: finalUrl, url: params.url,
extractMode: params.extractMode, extractMode: params.extractMode,
apiKey: params.firecrawlApiKey, apiKey: params.firecrawlApiKey,
baseUrl: params.firecrawlBaseUrl, baseUrl: params.firecrawlBaseUrl,
@@ -377,7 +304,7 @@ async function runWebFetch(params: {
const truncated = truncateText(firecrawl.text, params.maxChars); const truncated = truncateText(firecrawl.text, params.maxChars);
const payload = { const payload = {
url: params.url, url: params.url,
finalUrl: firecrawl.finalUrl || finalUrl, finalUrl: firecrawl.finalUrl || params.url,
status: firecrawl.status ?? 200, status: firecrawl.status ?? 200,
contentType: "text/markdown", contentType: "text/markdown",
title: firecrawl.title, title: firecrawl.title,
@@ -412,7 +339,7 @@ async function runWebFetch(params: {
const truncated = truncateText(firecrawl.text, params.maxChars); const truncated = truncateText(firecrawl.text, params.maxChars);
const payload = { const payload = {
url: params.url, url: params.url,
finalUrl: firecrawl.finalUrl || finalUrl, finalUrl: firecrawl.finalUrl || params.url,
status: firecrawl.status ?? res.status, status: firecrawl.status ?? res.status,
contentType: "text/markdown", contentType: "text/markdown",
title: firecrawl.title, title: firecrawl.title,
@@ -447,7 +374,7 @@ async function runWebFetch(params: {
if (params.readabilityEnabled) { if (params.readabilityEnabled) {
const readable = await extractReadableContent({ const readable = await extractReadableContent({
html: body, html: body,
url: finalUrl, url: res.url || params.url,
extractMode: params.extractMode, extractMode: params.extractMode,
}); });
if (readable?.text) { if (readable?.text) {
@@ -455,7 +382,7 @@ async function runWebFetch(params: {
title = readable.title; title = readable.title;
extractor = "readability"; extractor = "readability";
} else { } else {
const firecrawl = await tryFirecrawlFallback({ ...params, url: finalUrl }); const firecrawl = await tryFirecrawlFallback(params);
if (firecrawl) { if (firecrawl) {
text = firecrawl.text; text = firecrawl.text;
title = firecrawl.title; title = firecrawl.title;
@@ -484,7 +411,7 @@ async function runWebFetch(params: {
const truncated = truncateText(text, params.maxChars); const truncated = truncateText(text, params.maxChars);
const payload = { const payload = {
url: params.url, url: params.url,
finalUrl, finalUrl: res.url || params.url,
status: res.status, status: res.status,
contentType, contentType,
title, title,
@@ -581,7 +508,6 @@ export function createWebFetchTool(options?: {
url, url,
extractMode, extractMode,
maxChars: resolveMaxChars(maxChars ?? fetch?.maxChars, DEFAULT_FETCH_MAX_CHARS), maxChars: resolveMaxChars(maxChars ?? fetch?.maxChars, DEFAULT_FETCH_MAX_CHARS),
maxRedirects: resolveMaxRedirects(fetch?.maxRedirects, DEFAULT_FETCH_MAX_REDIRECTS),
timeoutSeconds: resolveTimeoutSeconds(fetch?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS), timeoutSeconds: resolveTimeoutSeconds(fetch?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS),
cacheTtlMs: resolveCacheTtlMs(fetch?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES), cacheTtlMs: resolveCacheTtlMs(fetch?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES),
userAgent, userAgent,
@@ -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();
});
});
+3 -1
View File
@@ -184,7 +184,9 @@ export async function buildStatusReply(params: {
const requesterKey = resolveInternalSessionKey({ key: sessionKey, alias, mainKey }); const requesterKey = resolveInternalSessionKey({ key: sessionKey, alias, mainKey });
const runs = listSubagentRunsForRequester(requesterKey); const runs = listSubagentRunsForRequester(requesterKey);
const verboseEnabled = resolvedVerboseLevel && resolvedVerboseLevel !== "off"; const verboseEnabled = resolvedVerboseLevel && resolvedVerboseLevel !== "off";
if (runs.length > 0) { if (runs.length === 0) {
if (verboseEnabled) subagentsLine = "🤖 Subagents: none";
} else {
const active = runs.filter((entry) => !entry.endedAt); const active = runs.filter((entry) => !entry.endedAt);
const done = runs.length - active.length; const done = runs.length - active.length;
if (verboseEnabled) { if (verboseEnabled) {
-14
View File
@@ -215,20 +215,6 @@ describe("handleCommands subagents", () => {
expect(result.reply?.text).toContain("Subagents: none"); expect(result.reply?.text).toContain("Subagents: none");
}); });
it("omits subagent status line when none exist", async () => {
resetSubagentRegistryForTests();
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { mainKey: "main", scope: "per-sender" },
} as ClawdbotConfig;
const params = buildParams("/status", cfg);
params.resolvedVerboseLevel = "on";
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).not.toContain("Subagents:");
});
it("returns help for unknown subagents action", async () => { it("returns help for unknown subagents action", async () => {
resetSubagentRegistryForTests(); resetSubagentRegistryForTests();
const cfg = { const cfg = {
@@ -13,11 +13,6 @@ const mocks = vi.hoisted(() => ({
aborted: false, aborted: false,
})), })),
})); }));
const diagnosticMocks = vi.hoisted(() => ({
logMessageQueued: vi.fn(),
logMessageProcessed: vi.fn(),
logSessionStateChange: vi.fn(),
}));
vi.mock("./route-reply.js", () => ({ vi.mock("./route-reply.js", () => ({
isRoutableChannel: (channel: string | undefined) => isRoutableChannel: (channel: string | undefined) =>
@@ -39,12 +34,6 @@ vi.mock("./abort.js", () => ({
}, },
})); }));
vi.mock("../../logging/diagnostic.js", () => ({
logMessageQueued: diagnosticMocks.logMessageQueued,
logMessageProcessed: diagnosticMocks.logMessageProcessed,
logSessionStateChange: diagnosticMocks.logSessionStateChange,
}));
const { dispatchReplyFromConfig } = await import("./dispatch-from-config.js"); const { dispatchReplyFromConfig } = await import("./dispatch-from-config.js");
const { resetInboundDedupe } = await import("./inbound-dedupe.js"); const { resetInboundDedupe } = await import("./inbound-dedupe.js");
@@ -61,9 +50,6 @@ function createDispatcher(): ReplyDispatcher {
describe("dispatchReplyFromConfig", () => { describe("dispatchReplyFromConfig", () => {
beforeEach(() => { beforeEach(() => {
resetInboundDedupe(); resetInboundDedupe();
diagnosticMocks.logMessageQueued.mockReset();
diagnosticMocks.logMessageProcessed.mockReset();
diagnosticMocks.logSessionStateChange.mockReset();
}); });
it("does not route when Provider matches OriginatingChannel (even if Surface is missing)", async () => { it("does not route when Provider matches OriginatingChannel (even if Surface is missing)", async () => {
mocks.tryFastAbortFromMessage.mockResolvedValue({ mocks.tryFastAbortFromMessage.mockResolvedValue({
@@ -200,74 +186,4 @@ describe("dispatchReplyFromConfig", () => {
expect(replyResolver).toHaveBeenCalledTimes(1); expect(replyResolver).toHaveBeenCalledTimes(1);
}); });
it("emits diagnostics when enabled", async () => {
mocks.tryFastAbortFromMessage.mockResolvedValue({
handled: false,
aborted: false,
});
const cfg = { diagnostics: { enabled: true } } as ClawdbotConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "slack",
Surface: "slack",
SessionKey: "agent:main:main",
MessageSid: "msg-1",
To: "slack:C123",
});
const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload;
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(diagnosticMocks.logMessageQueued).toHaveBeenCalledTimes(1);
expect(diagnosticMocks.logSessionStateChange).toHaveBeenCalledWith({
sessionKey: "agent:main:main",
state: "processing",
reason: "message_start",
});
expect(diagnosticMocks.logMessageProcessed).toHaveBeenCalledWith(
expect.objectContaining({
channel: "slack",
outcome: "completed",
sessionKey: "agent:main:main",
}),
);
});
it("marks diagnostics skipped for duplicate inbound messages", async () => {
mocks.tryFastAbortFromMessage.mockResolvedValue({
handled: false,
aborted: false,
});
const cfg = { diagnostics: { enabled: true } } as ClawdbotConfig;
const ctx = buildTestCtx({
Provider: "whatsapp",
OriginatingChannel: "whatsapp",
OriginatingTo: "whatsapp:+15555550123",
MessageSid: "msg-dup",
});
const replyResolver = vi.fn(async () => ({ text: "hi" }) as ReplyPayload);
await dispatchReplyFromConfig({
ctx,
cfg,
dispatcher: createDispatcher(),
replyResolver,
});
await dispatchReplyFromConfig({
ctx,
cfg,
dispatcher: createDispatcher(),
replyResolver,
});
expect(replyResolver).toHaveBeenCalledTimes(1);
expect(diagnosticMocks.logMessageProcessed).toHaveBeenCalledWith(
expect.objectContaining({
channel: "whatsapp",
outcome: "skipped",
reason: "duplicate",
}),
);
});
}); });
+82 -147
View File
@@ -1,11 +1,5 @@
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { logVerbose } from "../../globals.js"; import { logVerbose } from "../../globals.js";
import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
import {
logMessageProcessed,
logMessageQueued,
logSessionStateChange,
} from "../../logging/diagnostic.js";
import { getReplyFromConfig } from "../reply.js"; import { getReplyFromConfig } from "../reply.js";
import type { FinalizedMsgContext } from "../templating.js"; import type { FinalizedMsgContext } from "../templating.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js";
@@ -27,55 +21,8 @@ export async function dispatchReplyFromConfig(params: {
replyResolver?: typeof getReplyFromConfig; replyResolver?: typeof getReplyFromConfig;
}): Promise<DispatchFromConfigResult> { }): Promise<DispatchFromConfigResult> {
const { ctx, cfg, dispatcher } = params; const { ctx, cfg, dispatcher } = params;
const diagnosticsEnabled = isDiagnosticsEnabled(cfg);
const channel = String(ctx.Surface ?? ctx.Provider ?? "unknown").toLowerCase();
const chatId = ctx.To ?? ctx.From;
const messageId = ctx.MessageSid ?? ctx.MessageSidFirst ?? ctx.MessageSidLast;
const sessionKey = ctx.SessionKey;
const startTime = diagnosticsEnabled ? Date.now() : 0;
const canTrackSession = diagnosticsEnabled && Boolean(sessionKey);
const recordProcessed = (
outcome: "completed" | "skipped" | "error",
opts?: {
reason?: string;
error?: string;
},
) => {
if (!diagnosticsEnabled) return;
logMessageProcessed({
channel,
chatId,
messageId,
sessionKey,
durationMs: Date.now() - startTime,
outcome,
reason: opts?.reason,
error: opts?.error,
});
};
const markProcessing = () => {
if (!canTrackSession || !sessionKey) return;
logMessageQueued({ sessionKey, channel, source: "dispatch" });
logSessionStateChange({
sessionKey,
state: "processing",
reason: "message_start",
});
};
const markIdle = (reason: string) => {
if (!canTrackSession || !sessionKey) return;
logSessionStateChange({
sessionKey,
state: "idle",
reason,
});
};
if (shouldSkipDuplicateInbound(ctx)) { if (shouldSkipDuplicateInbound(ctx)) {
recordProcessed("skipped", { reason: "duplicate" });
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() }; return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
} }
@@ -121,107 +68,95 @@ export async function dispatchReplyFromConfig(params: {
} }
}; };
markProcessing(); const fastAbort = await tryFastAbortFromMessage({ ctx, cfg });
if (fastAbort.handled) {
try { const payload = {
const fastAbort = await tryFastAbortFromMessage({ ctx, cfg }); text: formatAbortReplyText(fastAbort.stoppedSubagents),
if (fastAbort.handled) { } satisfies ReplyPayload;
const payload = {
text: formatAbortReplyText(fastAbort.stoppedSubagents),
} satisfies ReplyPayload;
let queuedFinal = false;
let routedFinalCount = 0;
if (shouldRouteToOriginating && originatingChannel && originatingTo) {
const result = await routeReply({
payload,
channel: originatingChannel,
to: originatingTo,
sessionKey: ctx.SessionKey,
accountId: ctx.AccountId,
threadId: ctx.MessageThreadId,
cfg,
});
queuedFinal = result.ok;
if (result.ok) routedFinalCount += 1;
if (!result.ok) {
logVerbose(
`dispatch-from-config: route-reply (abort) failed: ${result.error ?? "unknown error"}`,
);
}
} else {
queuedFinal = dispatcher.sendFinalReply(payload);
}
await dispatcher.waitForIdle();
const counts = dispatcher.getQueuedCounts();
counts.final += routedFinalCount;
recordProcessed("completed", { reason: "fast_abort" });
markIdle("message_completed");
return { queuedFinal, counts };
}
const replyResult = await (params.replyResolver ?? getReplyFromConfig)(
ctx,
{
...params.replyOptions,
onToolResult: (payload: ReplyPayload) => {
if (shouldRouteToOriginating) {
// Fire-and-forget for streaming tool results when routing.
void sendPayloadAsync(payload);
} else {
// Synchronous dispatch to preserve callback timing.
dispatcher.sendToolResult(payload);
}
},
onBlockReply: (payload: ReplyPayload, context) => {
if (shouldRouteToOriginating) {
// Await routed sends so upstream can enforce ordering/timeouts.
return sendPayloadAsync(payload, context?.abortSignal);
} else {
// Synchronous dispatch to preserve callback timing.
dispatcher.sendBlockReply(payload);
}
},
},
cfg,
);
const replies = replyResult ? (Array.isArray(replyResult) ? replyResult : [replyResult]) : [];
let queuedFinal = false; let queuedFinal = false;
let routedFinalCount = 0; let routedFinalCount = 0;
for (const reply of replies) { if (shouldRouteToOriginating && originatingChannel && originatingTo) {
if (shouldRouteToOriginating && originatingChannel && originatingTo) { const result = await routeReply({
// Route final reply to originating channel. payload,
const result = await routeReply({ channel: originatingChannel,
payload: reply, to: originatingTo,
channel: originatingChannel, sessionKey: ctx.SessionKey,
to: originatingTo, accountId: ctx.AccountId,
sessionKey: ctx.SessionKey, threadId: ctx.MessageThreadId,
accountId: ctx.AccountId, cfg,
threadId: ctx.MessageThreadId, });
cfg, queuedFinal = result.ok;
}); if (result.ok) routedFinalCount += 1;
if (!result.ok) { if (!result.ok) {
logVerbose( logVerbose(
`dispatch-from-config: route-reply (final) failed: ${result.error ?? "unknown error"}`, `dispatch-from-config: route-reply (abort) failed: ${result.error ?? "unknown error"}`,
); );
}
queuedFinal = result.ok || queuedFinal;
if (result.ok) routedFinalCount += 1;
} else {
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
} }
} else {
queuedFinal = dispatcher.sendFinalReply(payload);
} }
await dispatcher.waitForIdle(); await dispatcher.waitForIdle();
const counts = dispatcher.getQueuedCounts(); const counts = dispatcher.getQueuedCounts();
counts.final += routedFinalCount; counts.final += routedFinalCount;
recordProcessed("completed");
markIdle("message_completed");
return { queuedFinal, counts }; return { queuedFinal, counts };
} catch (err) {
recordProcessed("error", { error: String(err) });
markIdle("message_error");
throw err;
} }
const replyResult = await (params.replyResolver ?? getReplyFromConfig)(
ctx,
{
...params.replyOptions,
onToolResult: (payload: ReplyPayload) => {
if (shouldRouteToOriginating) {
// Fire-and-forget for streaming tool results when routing.
void sendPayloadAsync(payload);
} else {
// Synchronous dispatch to preserve callback timing.
dispatcher.sendToolResult(payload);
}
},
onBlockReply: (payload: ReplyPayload, context) => {
if (shouldRouteToOriginating) {
// Await routed sends so upstream can enforce ordering/timeouts.
return sendPayloadAsync(payload, context?.abortSignal);
} else {
// Synchronous dispatch to preserve callback timing.
dispatcher.sendBlockReply(payload);
}
},
},
cfg,
);
const replies = replyResult ? (Array.isArray(replyResult) ? replyResult : [replyResult]) : [];
let queuedFinal = false;
let routedFinalCount = 0;
for (const reply of replies) {
if (shouldRouteToOriginating && originatingChannel && originatingTo) {
// Route final reply to originating channel.
const result = await routeReply({
payload: reply,
channel: originatingChannel,
to: originatingTo,
sessionKey: ctx.SessionKey,
accountId: ctx.AccountId,
threadId: ctx.MessageThreadId,
cfg,
});
if (!result.ok) {
logVerbose(
`dispatch-from-config: route-reply (final) failed: ${result.error ?? "unknown error"}`,
);
}
queuedFinal = result.ok || queuedFinal;
if (result.ok) routedFinalCount += 1;
} else {
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
}
}
await dispatcher.waitForIdle();
const counts = dispatcher.getQueuedCounts();
counts.final += routedFinalCount;
return { queuedFinal, counts };
} }
-16
View File
@@ -143,22 +143,6 @@ describe("buildStatusMessage", () => {
expect(normalized).toContain("Media: image ok (openai/gpt-5.2) · audio skipped (maxBytes)"); expect(normalized).toContain("Media: image ok (openai/gpt-5.2) · audio skipped (maxBytes)");
}); });
it("omits media line when all decisions are none", () => {
const text = buildStatusMessage({
agent: { model: "anthropic/claude-opus-4-5" },
sessionEntry: { sessionId: "media-none", updatedAt: 0 },
sessionKey: "agent:main:main",
queue: { mode: "none" },
mediaDecisions: [
{ capability: "image", outcome: "no-attachment", attachments: [] },
{ capability: "audio", outcome: "no-attachment", attachments: [] },
{ capability: "video", outcome: "no-attachment", attachments: [] },
],
});
expect(normalizeTestText(text)).not.toContain("Media:");
});
it("does not show elevated label when session explicitly disables it", () => { it("does not show elevated label when session explicitly disables it", () => {
const text = buildStatusMessage({ const text = buildStatusMessage({
agent: { model: "anthropic/claude-opus-4-5", elevatedDefault: "on" }, agent: { model: "anthropic/claude-opus-4-5", elevatedDefault: "on" },
+1 -2
View File
@@ -201,9 +201,8 @@ const formatMediaUnderstandingLine = (decisions?: MediaUnderstandingDecision[])
} }
return null; return null;
}) })
.filter((part): part is string => part != null); .filter(Boolean);
if (parts.length === 0) return null; if (parts.length === 0) return null;
if (parts.every((part) => part.endsWith(" none"))) return null;
return `📎 Media: ${parts.join(" · ")}`; return `📎 Media: ${parts.join(" · ")}`;
}; };
+1 -1
View File
@@ -1 +1 @@
d9a36b111dfd93cbbb629fddf075800690ce0ae32a3a2ef201b365f4f6d6f5d5 c1cdb1b463e70d87976d88abf13e373e774c057e6796c947227c56b459af9d77
File diff suppressed because it is too large. Load diff
-78
View File
@@ -1,78 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { Command } from "commander";
const clientMocks = vi.hoisted(() => ({
browserSnapshot: vi.fn(async () => ({
ok: true,
format: "ai",
targetId: "t1",
url: "https://example.com",
snapshot: "ok",
})),
resolveBrowserControlUrl: vi.fn(() => "http://127.0.0.1:18791"),
}));
vi.mock("../browser/client.js", () => clientMocks);
const configMocks = vi.hoisted(() => ({
loadConfig: vi.fn(() => ({ browser: {} })),
}));
vi.mock("../config/config.js", () => configMocks);
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
vi.mock("../runtime.js", () => ({
defaultRuntime: runtime,
}));
describe("browser cli snapshot defaults", () => {
afterEach(() => {
vi.clearAllMocks();
configMocks.loadConfig.mockReturnValue({ browser: {} });
});
it("uses config snapshot defaults when mode is not provided", async () => {
configMocks.loadConfig.mockReturnValue({
browser: { snapshotDefaults: { mode: "efficient" } },
});
const { registerBrowserInspectCommands } = await import("./browser-cli-inspect.js");
const program = new Command();
const browser = program.command("browser").option("--json", false);
registerBrowserInspectCommands(browser, () => ({}));
await program.parseAsync(["browser", "snapshot"], { from: "user" });
expect(clientMocks.browserSnapshot).toHaveBeenCalledWith(
"http://127.0.0.1:18791",
expect.objectContaining({
format: "ai",
mode: "efficient",
}),
);
});
it("does not apply config snapshot defaults to aria snapshots", async () => {
configMocks.loadConfig.mockReturnValue({
browser: { snapshotDefaults: { mode: "efficient" } },
});
clientMocks.browserSnapshot.mockResolvedValueOnce({
ok: true,
format: "aria",
targetId: "t1",
url: "https://example.com",
nodes: [],
});
const { registerBrowserInspectCommands } = await import("./browser-cli-inspect.js");
const program = new Command();
const browser = program.command("browser").option("--json", false);
registerBrowserInspectCommands(browser, () => ({}));
await program.parseAsync(["browser", "snapshot", "--format", "aria"], { from: "user" });
expect(clientMocks.browserSnapshot).toHaveBeenCalled();
const [, opts] = clientMocks.browserSnapshot.mock.calls.at(-1) ?? [];
expect(opts?.mode).toBeUndefined();
});
});
+1 -6
View File
@@ -2,7 +2,6 @@ import type { Command } from "commander";
import { browserSnapshot, resolveBrowserControlUrl } from "../browser/client.js"; import { browserSnapshot, resolveBrowserControlUrl } from "../browser/client.js";
import { browserScreenshotAction } from "../browser/client-actions.js"; import { browserScreenshotAction } from "../browser/client-actions.js";
import { loadConfig } from "../config/config.js";
import { danger } from "../globals.js"; import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import type { BrowserParentOpts } from "./browser-cli-shared.js"; import type { BrowserParentOpts } from "./browser-cli-shared.js";
@@ -63,11 +62,7 @@ export function registerBrowserInspectCommands(
const baseUrl = resolveBrowserControlUrl(parent?.url); const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile; const profile = parent?.browserProfile;
const format = opts.format === "aria" ? "aria" : "ai"; const format = opts.format === "aria" ? "aria" : "ai";
const configMode = const mode = opts.efficient === true || opts.mode === "efficient" ? "efficient" : undefined;
format === "ai" && loadConfig().browser?.snapshotDefaults?.mode === "efficient"
? "efficient"
: undefined;
const mode = opts.efficient === true || opts.mode === "efficient" ? "efficient" : configMode;
try { try {
const result = await browserSnapshot(baseUrl, { const result = await browserSnapshot(baseUrl, {
format, format,
+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`,
+4 -4
View File
@@ -1,5 +1,4 @@
import type { Command } from "commander"; import type { Command } from "commander";
import { listChannelPlugins } from "../channels/plugins/index.js";
import { import {
channelsAddCommand, channelsAddCommand,
channelsCapabilitiesCommand, channelsCapabilitiesCommand,
@@ -13,9 +12,11 @@ import { danger } from "../globals.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 { resolveCliChannelOptions } from "./channel-options.js";
import { runChannelLogin, runChannelLogout } from "./channel-auth.js"; import { runChannelLogin, runChannelLogout } from "./channel-auth.js";
import { runCommandWithRuntime } from "./cli-utils.js"; import { runCommandWithRuntime } from "./cli-utils.js";
import { hasExplicitOptions } from "./command-options.js"; import { hasExplicitOptions } from "./command-options.js";
import { markCommandRequiresPluginRegistry } from "./program/command-metadata.js";
const optionNamesAdd = [ const optionNamesAdd = [
"channel", "channel",
@@ -58,9 +59,7 @@ function runChannelsCommandWithDanger(action: () => Promise<void>, label: string
} }
export function registerChannelsCli(program: Command) { export function registerChannelsCli(program: Command) {
const channelNames = listChannelPlugins() const channelNames = resolveCliChannelOptions().join("|");
.map((plugin) => plugin.id)
.join("|");
const channels = program const channels = program
.command("channels") .command("channels")
.description("Manage chat channel accounts") .description("Manage chat channel accounts")
@@ -72,6 +71,7 @@ export function registerChannelsCli(program: Command) {
"docs.clawd.bot/cli/channels", "docs.clawd.bot/cli/channels",
)}\n`, )}\n`,
); );
markCommandRequiresPluginRegistry(channels);
channels channels
.command("list") .command("list")
+3 -183
View File
@@ -173,7 +173,7 @@ describe("cron cli", () => {
expect(clearPatch?.patch?.agentId).toBeNull(); expect(clearPatch?.patch?.agentId).toBeNull();
}); });
it("allows model/thinking updates without --message", async () => { it("does not include model/thinking when no payload change is requested", async () => {
callGatewayFromCli.mockClear(); callGatewayFromCli.mockClear();
const { registerCronCli } = await import("./cron-cli.js"); const { registerCronCli } = await import("./cron-cli.js");
@@ -186,188 +186,8 @@ describe("cron cli", () => {
}); });
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update"); const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as { const patch = updateCall?.[2] as { patch?: { payload?: unknown } };
patch?: { payload?: { kind?: string; model?: string; thinking?: string } };
};
expect(patch?.patch?.payload?.kind).toBe("agentTurn"); expect(patch?.patch?.payload).toBeUndefined();
expect(patch?.patch?.payload?.model).toBe("opus");
expect(patch?.patch?.payload?.thinking).toBe("low");
});
it("updates delivery settings without requiring --message", async () => {
callGatewayFromCli.mockClear();
const { registerCronCli } = await import("./cron-cli.js");
const program = new Command();
program.exitOverride();
registerCronCli(program);
await program.parseAsync(
["cron", "edit", "job-1", "--deliver", "--channel", "telegram", "--to", "19098680"],
{ from: "user" },
);
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as {
patch?: {
payload?: {
kind?: string;
message?: string;
deliver?: boolean;
channel?: string;
to?: string;
};
};
};
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
expect(patch?.patch?.payload?.deliver).toBe(true);
expect(patch?.patch?.payload?.channel).toBe("telegram");
expect(patch?.patch?.payload?.to).toBe("19098680");
expect(patch?.patch?.payload?.message).toBeUndefined();
});
it("supports --no-deliver on cron edit", async () => {
callGatewayFromCli.mockClear();
const { registerCronCli } = await import("./cron-cli.js");
const program = new Command();
program.exitOverride();
registerCronCli(program);
await program.parseAsync(["cron", "edit", "job-1", "--no-deliver"], { from: "user" });
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as {
patch?: { payload?: { kind?: string; deliver?: boolean } };
};
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
expect(patch?.patch?.payload?.deliver).toBe(false);
});
it("does not include undefined delivery fields when updating message", async () => {
callGatewayFromCli.mockClear();
const { registerCronCli } = await import("./cron-cli.js");
const program = new Command();
program.exitOverride();
registerCronCli(program);
// Update message without delivery flags - should NOT include undefined delivery fields
await program.parseAsync(["cron", "edit", "job-1", "--message", "Updated message"], {
from: "user",
});
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as {
patch?: {
payload?: {
message?: string;
deliver?: boolean;
channel?: string;
to?: string;
bestEffortDeliver?: boolean;
};
};
};
// Should include the new message
expect(patch?.patch?.payload?.message).toBe("Updated message");
// Should NOT include delivery fields at all (to preserve existing values)
expect(patch?.patch?.payload).not.toHaveProperty("deliver");
expect(patch?.patch?.payload).not.toHaveProperty("channel");
expect(patch?.patch?.payload).not.toHaveProperty("to");
expect(patch?.patch?.payload).not.toHaveProperty("bestEffortDeliver");
});
it("includes delivery fields when explicitly provided with message", async () => {
callGatewayFromCli.mockClear();
const { registerCronCli } = await import("./cron-cli.js");
const program = new Command();
program.exitOverride();
registerCronCli(program);
// Update message AND delivery - should include both
await program.parseAsync(
[
"cron",
"edit",
"job-1",
"--message",
"Updated message",
"--deliver",
"--channel",
"telegram",
"--to",
"19098680",
],
{ from: "user" },
);
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as {
patch?: {
payload?: {
message?: string;
deliver?: boolean;
channel?: string;
to?: string;
};
};
};
// Should include everything
expect(patch?.patch?.payload?.message).toBe("Updated message");
expect(patch?.patch?.payload?.deliver).toBe(true);
expect(patch?.patch?.payload?.channel).toBe("telegram");
expect(patch?.patch?.payload?.to).toBe("19098680");
});
it("includes best-effort delivery when provided with message", async () => {
callGatewayFromCli.mockClear();
const { registerCronCli } = await import("./cron-cli.js");
const program = new Command();
program.exitOverride();
registerCronCli(program);
await program.parseAsync(
["cron", "edit", "job-1", "--message", "Updated message", "--best-effort-deliver"],
{ from: "user" },
);
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as {
patch?: { payload?: { message?: string; bestEffortDeliver?: boolean } };
};
expect(patch?.patch?.payload?.message).toBe("Updated message");
expect(patch?.patch?.payload?.bestEffortDeliver).toBe(true);
});
it("includes no-best-effort delivery when provided with message", async () => {
callGatewayFromCli.mockClear();
const { registerCronCli } = await import("./cron-cli.js");
const program = new Command();
program.exitOverride();
registerCronCli(program);
await program.parseAsync(
["cron", "edit", "job-1", "--message", "Updated message", "--no-best-effort-deliver"],
{ from: "user" },
);
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as {
patch?: { payload?: { message?: string; bestEffortDeliver?: boolean } };
};
expect(patch?.patch?.payload?.message).toBe("Updated message");
expect(patch?.patch?.payload?.bestEffortDeliver).toBe(false);
}); });
}); });
+27 -52
View File
@@ -10,15 +10,6 @@ import {
warnIfCronSchedulerDisabled, warnIfCronSchedulerDisabled,
} from "./shared.js"; } from "./shared.js";
const assignIf = (
target: Record<string, unknown>,
key: string,
value: unknown,
shouldAssign: boolean,
) => {
if (shouldAssign) target[key] = value;
};
export function registerCronEditCommand(cron: Command) { export function registerCronEditCommand(cron: Command) {
addGatewayClientOptions( addGatewayClientOptions(
cron cron
@@ -47,15 +38,14 @@ export function registerCronEditCommand(cron: Command) {
.option( .option(
"--deliver", "--deliver",
"Deliver agent output (required when using last-route delivery without --to)", "Deliver agent output (required when using last-route delivery without --to)",
false,
) )
.option("--no-deliver", "Disable delivery")
.option("--channel <channel>", `Delivery channel (${getCronChannelOptions()})`) .option("--channel <channel>", `Delivery channel (${getCronChannelOptions()})`)
.option( .option(
"--to <dest>", "--to <dest>",
"Delivery destination (E.164, Telegram chatId, or Discord channel/user)", "Delivery destination (E.164, Telegram chatId, or Discord channel/user)",
) )
.option("--best-effort-deliver", "Do not fail job if delivery fails") .option("--best-effort-deliver", "Do not fail job if delivery fails", false)
.option("--no-best-effort-deliver", "Fail job when delivery fails")
.option("--post-prefix <prefix>", "Prefix for summary system event") .option("--post-prefix <prefix>", "Prefix for summary system event")
.action(async (id, opts) => { .action(async (id, opts) => {
try { try {
@@ -115,50 +105,35 @@ export function registerCronEditCommand(cron: Command) {
}; };
} }
const hasSystemEventPatch = typeof opts.systemEvent === "string"; const payloadChosen = [opts.systemEvent, opts.message].filter(Boolean).length;
const model = if (payloadChosen > 1) throw new Error("Choose at most one payload change");
typeof opts.model === "string" && opts.model.trim() ? opts.model.trim() : undefined; if (opts.systemEvent) {
const thinking =
typeof opts.thinking === "string" && opts.thinking.trim()
? opts.thinking.trim()
: undefined;
const timeoutSeconds = opts.timeoutSeconds
? Number.parseInt(String(opts.timeoutSeconds), 10)
: undefined;
const hasTimeoutSeconds = Boolean(timeoutSeconds && Number.isFinite(timeoutSeconds));
const hasAgentTurnPatch =
typeof opts.message === "string" ||
Boolean(model) ||
Boolean(thinking) ||
hasTimeoutSeconds ||
typeof opts.deliver === "boolean" ||
typeof opts.channel === "string" ||
typeof opts.to === "string" ||
typeof opts.bestEffortDeliver === "boolean";
if (hasSystemEventPatch && hasAgentTurnPatch) {
throw new Error("Choose at most one payload change");
}
if (hasSystemEventPatch) {
patch.payload = { patch.payload = {
kind: "systemEvent", kind: "systemEvent",
text: String(opts.systemEvent), text: String(opts.systemEvent),
}; };
} else if (hasAgentTurnPatch) { } else if (opts.message) {
const payload: Record<string, unknown> = { kind: "agentTurn" }; const model =
assignIf(payload, "message", String(opts.message), typeof opts.message === "string"); typeof opts.model === "string" && opts.model.trim() ? opts.model.trim() : undefined;
assignIf(payload, "model", model, Boolean(model)); const thinking =
assignIf(payload, "thinking", thinking, Boolean(thinking)); typeof opts.thinking === "string" && opts.thinking.trim()
assignIf(payload, "timeoutSeconds", timeoutSeconds, hasTimeoutSeconds); ? opts.thinking.trim()
assignIf(payload, "deliver", opts.deliver, typeof opts.deliver === "boolean"); : undefined;
assignIf(payload, "channel", opts.channel, typeof opts.channel === "string"); const timeoutSeconds = opts.timeoutSeconds
assignIf(payload, "to", opts.to, typeof opts.to === "string"); ? Number.parseInt(String(opts.timeoutSeconds), 10)
assignIf( : undefined;
payload, patch.payload = {
"bestEffortDeliver", kind: "agentTurn",
opts.bestEffortDeliver, message: String(opts.message),
typeof opts.bestEffortDeliver === "boolean", model,
); thinking,
patch.payload = payload; timeoutSeconds:
timeoutSeconds && Number.isFinite(timeoutSeconds) ? timeoutSeconds : undefined,
deliver: opts.deliver ? true : undefined,
channel: typeof opts.channel === "string" ? opts.channel : undefined,
to: typeof opts.to === "string" ? opts.to : undefined,
bestEffortDeliver: opts.bestEffortDeliver ? true : undefined,
};
} }
if (typeof opts.postPrefix === "string") { if (typeof opts.postPrefix === "string") {
+2 -3
View File
@@ -1,13 +1,12 @@
import { listChannelPlugins } from "../../channels/plugins/index.js";
import { parseAbsoluteTimeMs } from "../../cron/parse.js"; import { parseAbsoluteTimeMs } from "../../cron/parse.js";
import type { CronJob, CronSchedule } from "../../cron/types.js"; import type { CronJob, CronSchedule } from "../../cron/types.js";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import { colorize, isRich, theme } from "../../terminal/theme.js"; import { colorize, isRich, theme } from "../../terminal/theme.js";
import { resolveCliChannelOptions } from "../channel-options.js";
import type { GatewayRpcOpts } from "../gateway-rpc.js"; import type { GatewayRpcOpts } from "../gateway-rpc.js";
import { callGatewayFromCli } from "../gateway-rpc.js"; import { callGatewayFromCli } from "../gateway-rpc.js";
export const getCronChannelOptions = () => export const getCronChannelOptions = () => ["last", ...resolveCliChannelOptions()].join("|");
["last", ...listChannelPlugins().map((plugin) => plugin.id)].join("|");
export async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) { export async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) {
try { try {
+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"}`);
}), }),
); );
+11 -56
View File
@@ -8,7 +8,7 @@ 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"; import { markCommandRequiresPluginRegistry } from "./program/command-metadata.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 +23,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) {
@@ -52,6 +43,7 @@ export function registerDirectoryCli(program: Command) {
.action(() => { .action(() => {
directory.help({ error: true }); directory.help({ error: true });
}); });
markCommandRequiresPluginRegistry(directory);
const withChannel = (cmd: Command) => const withChannel = (cmd: Command) =>
cmd cmd
@@ -87,21 +79,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 +113,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 +145,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.",
),
); );
} }
}); });
+4 -114
View File
@@ -5,8 +5,7 @@ import type { Command } from "commander";
import type { ExecApprovalsAgent, ExecApprovalsFile } from "../infra/exec-approvals.js"; import type { ExecApprovalsAgent, ExecApprovalsFile } from "../infra/exec-approvals.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js"; import { formatDocsLink } from "../terminal/links.js";
import { isRich, theme } from "../terminal/theme.js"; import { theme } from "../terminal/theme.js";
import { renderTable } from "../terminal/table.js";
import { callGatewayFromCli } from "./gateway-rpc.js"; import { callGatewayFromCli } from "./gateway-rpc.js";
import { nodesCallOpts, resolveNodeId } from "./nodes-cli/rpc.js"; import { nodesCallOpts, resolveNodeId } from "./nodes-cli/rpc.js";
import type { NodesRpcOpts } from "./nodes-cli/types.js"; import type { NodesRpcOpts } from "./nodes-cli/types.js";
@@ -25,17 +24,6 @@ type ExecApprovalsCliOpts = NodesRpcOpts & {
agent?: string; agent?: string;
}; };
function formatAge(msAgo: number) {
const s = Math.max(0, Math.floor(msAgo / 1000));
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h`;
const d = Math.floor(h / 24);
return `${d}d`;
}
async function readStdin(): Promise<string> { async function readStdin(): Promise<string> {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
for await (const chunk of process.stdin) { for await (const chunk of process.stdin) {
@@ -101,9 +89,6 @@ function isEmptyAgent(agent: ExecApprovalsAgent): boolean {
} }
export function registerExecApprovalsCli(program: Command) { export function registerExecApprovalsCli(program: Command) {
const formatExample = (cmd: string, desc: string) =>
` ${theme.command(cmd)}\n ${theme.muted(desc)}`;
const approvals = program const approvals = program
.command("approvals") .command("approvals")
.alias("exec-approvals") .alias("exec-approvals")
@@ -121,87 +106,8 @@ export function registerExecApprovalsCli(program: Command) {
.action(async (opts: ExecApprovalsCliOpts) => { .action(async (opts: ExecApprovalsCliOpts) => {
const nodeId = await resolveTargetNodeId(opts); const nodeId = await resolveTargetNodeId(opts);
const snapshot = await loadSnapshot(opts, nodeId); const snapshot = await loadSnapshot(opts, nodeId);
if (opts.json) { const payload = opts.json ? JSON.stringify(snapshot) : JSON.stringify(snapshot, null, 2);
defaultRuntime.log(JSON.stringify(snapshot)); defaultRuntime.log(payload);
return;
}
const rich = isRich();
const heading = (text: string) => (rich ? theme.heading(text) : text);
const muted = (text: string) => (rich ? theme.muted(text) : text);
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
const file = snapshot.file ?? { version: 1 };
const defaults = file.defaults ?? {};
const defaultsParts = [
defaults.security ? `security=${defaults.security}` : null,
defaults.ask ? `ask=${defaults.ask}` : null,
defaults.askFallback ? `askFallback=${defaults.askFallback}` : null,
typeof defaults.autoAllowSkills === "boolean"
? `autoAllowSkills=${defaults.autoAllowSkills ? "on" : "off"}`
: null,
].filter(Boolean) as string[];
const agents = file.agents ?? {};
const allowlistRows: Array<{ Agent: string; Pattern: string; LastUsed: string }> = [];
const now = Date.now();
for (const [agentId, agent] of Object.entries(agents)) {
const allowlist = Array.isArray(agent.allowlist) ? agent.allowlist : [];
for (const entry of allowlist) {
const pattern = entry?.pattern?.trim() ?? "";
if (!pattern) continue;
const lastUsedAt = typeof entry.lastUsedAt === "number" ? entry.lastUsedAt : null;
allowlistRows.push({
Agent: agentId,
Pattern: pattern,
LastUsed: lastUsedAt
? `${formatAge(Math.max(0, now - lastUsedAt))} ago`
: muted("unknown"),
});
}
}
const summaryRows = [
{ Field: "Path", Value: snapshot.path },
{ Field: "Exists", Value: snapshot.exists ? "yes" : "no" },
{ Field: "Hash", Value: snapshot.hash },
{ Field: "Version", Value: String(file.version ?? 1) },
{ Field: "Socket", Value: file.socket?.path ?? "default" },
{ Field: "Defaults", Value: defaultsParts.length > 0 ? defaultsParts.join(", ") : "none" },
{ Field: "Agents", Value: String(Object.keys(agents).length) },
{ Field: "Allowlist", Value: String(allowlistRows.length) },
];
defaultRuntime.log(heading("Approvals"));
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Field", header: "Field", minWidth: 8 },
{ key: "Value", header: "Value", minWidth: 24, flex: true },
],
rows: summaryRows,
}).trimEnd(),
);
if (allowlistRows.length === 0) {
defaultRuntime.log("");
defaultRuntime.log(muted("No allowlist entries."));
return;
}
defaultRuntime.log("");
defaultRuntime.log(heading("Allowlist"));
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Agent", header: "Agent", minWidth: 8 },
{ key: "Pattern", header: "Pattern", minWidth: 20, flex: true },
{ key: "LastUsed", header: "Last Used", minWidth: 10 },
],
rows: allowlistRows,
}).trimEnd(),
);
}); });
nodesCallOpts(getCmd); nodesCallOpts(getCmd);
@@ -245,23 +151,7 @@ export function registerExecApprovalsCli(program: Command) {
}); });
nodesCallOpts(setCmd); nodesCallOpts(setCmd);
const allowlist = approvals const allowlist = approvals.command("allowlist").description("Edit the per-agent allowlist");
.command("allowlist")
.description("Edit the per-agent allowlist")
.addHelpText(
"after",
() =>
`\n${theme.heading("Examples:")}\n${formatExample(
'clawdbot approvals allowlist add "~/Projects/**/bin/rg"',
"Allowlist a local binary pattern for the default agent.",
)}\n${formatExample(
'clawdbot approvals allowlist add --agent main --node <id|name|ip> "/usr/bin/uptime"',
"Allowlist on a specific node/agent.",
)}\n${formatExample(
'clawdbot approvals allowlist remove "~/Projects/**/bin/rg"',
"Remove an allowlist pattern.",
)}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/approvals", "docs.clawd.bot/cli/approvals")}\n`,
);
const allowlistAdd = allowlist const allowlistAdd = allowlist
.command("add <pattern>") .command("add <pattern>")
-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);
} }
}); });
-85
View File
@@ -1,85 +0,0 @@
import { Command } from "commander";
import { afterEach, describe, expect, it, vi } from "vitest";
const callGatewayFromCli = vi.fn();
vi.mock("./gateway-rpc.js", async () => {
const actual = await vi.importActual<typeof import("./gateway-rpc.js")>("./gateway-rpc.js");
return {
...actual,
callGatewayFromCli: (...args: unknown[]) => callGatewayFromCli(...args),
};
});
describe("logs cli", () => {
afterEach(() => {
callGatewayFromCli.mockReset();
});
it("writes output directly to stdout/stderr", async () => {
callGatewayFromCli.mockResolvedValueOnce({
file: "/tmp/clawdbot.log",
cursor: 1,
size: 123,
lines: ["raw line"],
truncated: true,
reset: true,
});
const stdoutWrites: string[] = [];
const stderrWrites: string[] = [];
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
stdoutWrites.push(String(chunk));
return true;
});
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
stderrWrites.push(String(chunk));
return true;
});
const { registerLogsCli } = await import("./logs-cli.js");
const program = new Command();
program.exitOverride();
registerLogsCli(program);
await program.parseAsync(["logs"], { from: "user" });
stdoutSpy.mockRestore();
stderrSpy.mockRestore();
expect(stdoutWrites.join("")).toContain("Log file:");
expect(stdoutWrites.join("")).toContain("raw line");
expect(stderrWrites.join("")).toContain("Log tail truncated");
expect(stderrWrites.join("")).toContain("Log cursor reset");
});
it("warns when the output pipe closes", async () => {
callGatewayFromCli.mockResolvedValueOnce({
file: "/tmp/clawdbot.log",
lines: ["line one"],
});
const stderrWrites: string[] = [];
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => {
const err = new Error("EPIPE") as NodeJS.ErrnoException;
err.code = "EPIPE";
throw err;
});
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
stderrWrites.push(String(chunk));
return true;
});
const { registerLogsCli } = await import("./logs-cli.js");
const program = new Command();
program.exitOverride();
registerLogsCli(program);
await program.parseAsync(["logs"], { from: "user" });
stdoutSpy.mockRestore();
stderrSpy.mockRestore();
expect(stderrWrites.join("")).toContain("output stdout closed");
});
});
+45 -97
View File
@@ -2,9 +2,8 @@ import { setTimeout as delay } from "node:timers/promises";
import type { Command } from "commander"; import type { Command } from "commander";
import { buildGatewayConnectionDetails } from "../gateway/call.js"; import { buildGatewayConnectionDetails } from "../gateway/call.js";
import { parseLogLine } from "../logging/parse-log-line.js"; import { parseLogLine } from "../logging/parse-log-line.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js"; import { formatDocsLink } from "../terminal/links.js";
import { clearActiveProgressLine } from "../terminal/progress-line.js";
import { createSafeStreamWriter } from "../terminal/stream-writer.js";
import { colorize, isRich, theme } from "../terminal/theme.js"; import { colorize, isRich, theme } from "../terminal/theme.js";
import { formatCliCommand } from "./command-format.js"; import { formatCliCommand } from "./command-format.js";
import { addGatewayClientOptions, callGatewayFromCli } from "./gateway-rpc.js"; import { addGatewayClientOptions, callGatewayFromCli } from "./gateway-rpc.js";
@@ -105,28 +104,10 @@ function formatLogLine(
return [head, messageValue].filter(Boolean).join(" ").trim(); return [head, messageValue].filter(Boolean).join(" ").trim();
} }
function createLogWriters() { function emitJsonLine(payload: Record<string, unknown>, toStdErr = false) {
const writer = createSafeStreamWriter({ const text = `${JSON.stringify(payload)}\n`;
beforeWrite: () => clearActiveProgressLine(), if (toStdErr) process.stderr.write(text);
onBrokenPipe: (err, stream) => { else process.stdout.write(text);
const code = err.code ?? "EPIPE";
const target = stream === process.stdout ? "stdout" : "stderr";
const message = `clawdbot logs: output ${target} closed (${code}). Stopping tail.`;
try {
clearActiveProgressLine();
process.stderr.write(`${message}\n`);
} catch {
// ignore secondary failures while reporting the broken pipe
}
},
});
return {
logLine: (text: string) => writer.writeLine(process.stdout, text),
errorLine: (text: string) => writer.writeLine(process.stderr, text),
emitJsonLine: (payload: Record<string, unknown>, toStdErr = false) =>
writer.write(toStdErr ? process.stderr : process.stdout, `${JSON.stringify(payload)}\n`),
};
} }
function emitGatewayError( function emitGatewayError(
@@ -134,8 +115,6 @@ function emitGatewayError(
opts: LogsCliOptions, opts: LogsCliOptions,
mode: "json" | "text", mode: "json" | "text",
rich: boolean, rich: boolean,
emitJsonLine: (payload: Record<string, unknown>, toStdErr?: boolean) => boolean,
errorLine: (text: string) => boolean,
) { ) {
const details = buildGatewayConnectionDetails({ url: opts.url }); const details = buildGatewayConnectionDetails({ url: opts.url });
const message = "Gateway not reachable. Is it running and accessible?"; const message = "Gateway not reachable. Is it running and accessible?";
@@ -143,26 +122,22 @@ function emitGatewayError(
const errorText = err instanceof Error ? err.message : String(err); const errorText = err instanceof Error ? err.message : String(err);
if (mode === "json") { if (mode === "json") {
if ( emitJsonLine(
!emitJsonLine( {
{ type: "error",
type: "error", message,
message, error: errorText,
error: errorText, details,
details, hint,
hint, },
}, true,
true, );
)
) {
return;
}
return; return;
} }
if (!errorLine(colorize(rich, theme.error, message))) return; defaultRuntime.error(colorize(rich, theme.error, message));
if (!errorLine(details.message)) return; defaultRuntime.error(details.message);
errorLine(colorize(rich, theme.muted, hint)); defaultRuntime.error(colorize(rich, theme.muted, hint));
} }
export function registerLogsCli(program: Command) { export function registerLogsCli(program: Command) {
@@ -184,7 +159,6 @@ export function registerLogsCli(program: Command) {
addGatewayClientOptions(logs); addGatewayClientOptions(logs);
logs.action(async (opts: LogsCliOptions) => { logs.action(async (opts: LogsCliOptions) => {
const { logLine, errorLine, emitJsonLine } = createLogWriters();
const interval = parsePositiveInt(opts.interval, 1000); const interval = parsePositiveInt(opts.interval, 1000);
let cursor: number | undefined; let cursor: number | undefined;
let first = true; let first = true;
@@ -197,84 +171,58 @@ export function registerLogsCli(program: Command) {
try { try {
payload = await fetchLogs(opts, cursor); payload = await fetchLogs(opts, cursor);
} catch (err) { } catch (err) {
emitGatewayError(err, opts, jsonMode ? "json" : "text", rich, emitJsonLine, errorLine); emitGatewayError(err, opts, jsonMode ? "json" : "text", rich);
process.exit(1); defaultRuntime.exit(1);
return; return;
} }
const lines = Array.isArray(payload.lines) ? payload.lines : []; const lines = Array.isArray(payload.lines) ? payload.lines : [];
if (jsonMode) { if (jsonMode) {
if (first) { if (first) {
if ( emitJsonLine({
!emitJsonLine({ type: "meta",
type: "meta", file: payload.file,
file: payload.file, cursor: payload.cursor,
cursor: payload.cursor, size: payload.size,
size: payload.size, });
})
) {
return;
}
} }
for (const line of lines) { for (const line of lines) {
const parsed = parseLogLine(line); const parsed = parseLogLine(line);
if (parsed) { if (parsed) {
if (!emitJsonLine({ type: "log", ...parsed })) { emitJsonLine({ type: "log", ...parsed });
return;
}
} else { } else {
if (!emitJsonLine({ type: "raw", raw: line })) { emitJsonLine({ type: "raw", raw: line });
return;
}
} }
} }
if (payload.truncated) { if (payload.truncated) {
if ( emitJsonLine({
!emitJsonLine({ type: "notice",
type: "notice", message: "Log tail truncated (increase --max-bytes).",
message: "Log tail truncated (increase --max-bytes).", });
})
) {
return;
}
} }
if (payload.reset) { if (payload.reset) {
if ( emitJsonLine({
!emitJsonLine({ type: "notice",
type: "notice", message: "Log cursor reset (file rotated).",
message: "Log cursor reset (file rotated).", });
})
) {
return;
}
} }
} else { } else {
if (first && payload.file) { if (first && payload.file) {
const prefix = pretty ? colorize(rich, theme.muted, "Log file:") : "Log file:"; const prefix = pretty ? colorize(rich, theme.muted, "Log file:") : "Log file:";
if (!logLine(`${prefix} ${payload.file}`)) { defaultRuntime.log(`${prefix} ${payload.file}`);
return;
}
} }
for (const line of lines) { for (const line of lines) {
if ( defaultRuntime.log(
!logLine( formatLogLine(line, {
formatLogLine(line, { pretty,
pretty, rich,
rich, }),
}), );
)
) {
return;
}
} }
if (payload.truncated) { if (payload.truncated) {
if (!errorLine("Log tail truncated (increase --max-bytes).")) { defaultRuntime.error("Log tail truncated (increase --max-bytes).");
return;
}
} }
if (payload.reset) { if (payload.reset) {
if (!errorLine("Log cursor reset (file rotated).")) { defaultRuntime.error("Log cursor reset (file rotated).");
return;
}
} }
} }
cursor = cursor =
+2 -17
View File
@@ -1,28 +1,13 @@
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import { isRich, theme } from "../../terminal/theme.js";
import { runCommandWithRuntime } from "../cli-utils.js"; import { runCommandWithRuntime } from "../cli-utils.js";
import { unauthorizedHintForMessage } from "./rpc.js"; import { unauthorizedHintForMessage } from "./rpc.js";
export function getNodesTheme() {
const rich = isRich();
const color = (fn: (value: string) => string) => (value: string) => (rich ? fn(value) : value);
return {
rich,
heading: color(theme.heading),
ok: color(theme.success),
warn: color(theme.warn),
muted: color(theme.muted),
error: color(theme.error),
};
}
export function runNodesCommand(label: string, action: () => Promise<void>) { export function runNodesCommand(label: string, action: () => Promise<void>) {
return runCommandWithRuntime(defaultRuntime, action, (err) => { return runCommandWithRuntime(defaultRuntime, action, (err) => {
const message = String(err); const message = String(err);
const { error, warn } = getNodesTheme(); defaultRuntime.error(`nodes ${label} failed: ${message}`);
defaultRuntime.error(error(`nodes ${label} failed: ${message}`));
const hint = unauthorizedHintForMessage(message); const hint = unauthorizedHintForMessage(message);
if (hint) defaultRuntime.error(warn(hint)); if (hint) defaultRuntime.error(hint);
defaultRuntime.exit(1); defaultRuntime.exit(1);
}); });
} }
+8 -23
View File
@@ -9,10 +9,9 @@ import {
writeBase64ToFile, writeBase64ToFile,
} from "../nodes-camera.js"; } from "../nodes-camera.js";
import { parseDurationMs } from "../parse-duration.js"; import { parseDurationMs } from "../parse-duration.js";
import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; import { 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";
const parseFacing = (value: string): CameraFacing => { const parseFacing = (value: string): CameraFacing => {
const v = String(value ?? "") const v = String(value ?? "")
@@ -53,30 +52,16 @@ export function registerNodesCameraCommands(nodes: Command) {
} }
if (devices.length === 0) { if (devices.length === 0) {
const { muted } = getNodesTheme(); defaultRuntime.log("No cameras reported.");
defaultRuntime.log(muted("No cameras reported."));
return; return;
} }
const { heading, muted } = getNodesTheme(); for (const device of devices) {
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); const id = typeof device.id === "string" ? device.id : "";
const rows = devices.map((device) => ({ const name = typeof device.name === "string" ? device.name : "Unknown Camera";
Name: typeof device.name === "string" ? device.name : "Unknown Camera", const position = typeof device.position === "string" ? device.position : "unspecified";
Position: typeof device.position === "string" ? device.position : muted("unspecified"), defaultRuntime.log(`${name} (${position})${id ? ` — ${id}` : ""}`);
ID: typeof device.id === "string" ? device.id : "", }
}));
defaultRuntime.log(heading("Cameras"));
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Name", header: "Name", minWidth: 14, flex: true },
{ key: "Position", header: "Position", minWidth: 10 },
{ key: "ID", header: "ID", minWidth: 10, flex: true },
],
rows,
}).trimEnd(),
);
}); });
}), }),
{ timeoutMs: 60_000 }, { timeoutMs: 60_000 },
+7 -25
View File
@@ -6,7 +6,7 @@ import { writeBase64ToFile } from "../nodes-camera.js";
import { canvasSnapshotTempPath, parseCanvasSnapshotPayload } from "../nodes-canvas.js"; import { canvasSnapshotTempPath, parseCanvasSnapshotPayload } from "../nodes-canvas.js";
import { parseTimeoutMs } from "../nodes-run.js"; import { parseTimeoutMs } from "../nodes-run.js";
import { buildA2UITextJsonl, validateA2UIJsonl } from "./a2ui-jsonl.js"; import { buildA2UITextJsonl, validateA2UIJsonl } from "./a2ui-jsonl.js";
import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; import { 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";
@@ -121,10 +121,7 @@ export function registerNodesCanvasCommands(nodes: Command) {
params.placement = placement; params.placement = placement;
} }
await invokeCanvas(opts, "canvas.present", params); await invokeCanvas(opts, "canvas.present", params);
if (!opts.json) { if (!opts.json) defaultRuntime.log("canvas present ok");
const { ok } = getNodesTheme();
defaultRuntime.log(ok("canvas present ok"));
}
}); });
}), }),
); );
@@ -138,10 +135,7 @@ export function registerNodesCanvasCommands(nodes: Command) {
.action(async (opts: NodesRpcOpts) => { .action(async (opts: NodesRpcOpts) => {
await runNodesCommand("canvas hide", async () => { await runNodesCommand("canvas hide", async () => {
await invokeCanvas(opts, "canvas.hide", undefined); await invokeCanvas(opts, "canvas.hide", undefined);
if (!opts.json) { if (!opts.json) defaultRuntime.log("canvas hide ok");
const { ok } = getNodesTheme();
defaultRuntime.log(ok("canvas hide ok"));
}
}); });
}), }),
); );
@@ -156,10 +150,7 @@ export function registerNodesCanvasCommands(nodes: Command) {
.action(async (url: string, opts: NodesRpcOpts) => { .action(async (url: string, opts: NodesRpcOpts) => {
await runNodesCommand("canvas navigate", async () => { await runNodesCommand("canvas navigate", async () => {
await invokeCanvas(opts, "canvas.navigate", { url }); await invokeCanvas(opts, "canvas.navigate", { url });
if (!opts.json) { if (!opts.json) defaultRuntime.log("canvas navigate ok");
const { ok } = getNodesTheme();
defaultRuntime.log(ok("canvas navigate ok"));
}
}); });
}), }),
); );
@@ -188,10 +179,7 @@ export function registerNodesCanvasCommands(nodes: Command) {
? (raw as { payload?: { result?: string } }).payload ? (raw as { payload?: { result?: string } }).payload
: undefined; : undefined;
if (payload?.result) defaultRuntime.log(payload.result); if (payload?.result) defaultRuntime.log(payload.result);
else { else defaultRuntime.log("canvas eval ok");
const { ok } = getNodesTheme();
defaultRuntime.log(ok("canvas eval ok"));
}
}); });
}), }),
); );
@@ -225,11 +213,8 @@ export function registerNodesCanvasCommands(nodes: Command) {
} }
await invokeCanvas(opts, "canvas.a2ui.pushJSONL", { jsonl }); await invokeCanvas(opts, "canvas.a2ui.pushJSONL", { jsonl });
if (!opts.json) { if (!opts.json) {
const { ok } = getNodesTheme();
defaultRuntime.log( defaultRuntime.log(
ok( `canvas a2ui push ok (v0.8, ${messageCount} message${messageCount === 1 ? "" : "s"})`,
`canvas a2ui push ok (v0.8, ${messageCount} message${messageCount === 1 ? "" : "s"})`,
),
); );
} }
}); });
@@ -245,10 +230,7 @@ export function registerNodesCanvasCommands(nodes: Command) {
.action(async (opts: NodesRpcOpts) => { .action(async (opts: NodesRpcOpts) => {
await runNodesCommand("canvas a2ui reset", async () => { await runNodesCommand("canvas a2ui reset", async () => {
await invokeCanvas(opts, "canvas.a2ui.reset", undefined); await invokeCanvas(opts, "canvas.a2ui.reset", undefined);
if (!opts.json) { if (!opts.json) defaultRuntime.log("canvas a2ui reset ok");
const { ok } = getNodesTheme();
defaultRuntime.log(ok("canvas a2ui reset ok"));
}
}); });
}), }),
); );
+5 -11
View File
@@ -2,7 +2,7 @@ import type { Command } from "commander";
import { randomIdempotencyKey } from "../../gateway/call.js"; import { randomIdempotencyKey } from "../../gateway/call.js";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import { parseEnvPairs, parseTimeoutMs } from "../nodes-run.js"; import { parseEnvPairs, parseTimeoutMs } from "../nodes-run.js";
import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; import { runNodesCommand } from "./cli-utils.js";
import { callGatewayCli, nodesCallOpts, resolveNodeId, unauthorizedHintForMessage } from "./rpc.js"; import { callGatewayCli, nodesCallOpts, resolveNodeId, unauthorizedHintForMessage } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js"; import type { NodesRpcOpts } from "./types.js";
@@ -21,8 +21,7 @@ export function registerNodesInvokeCommands(nodes: Command) {
const nodeId = await resolveNodeId(opts, String(opts.node ?? "")); const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
const command = String(opts.command ?? "").trim(); const command = String(opts.command ?? "").trim();
if (!nodeId || !command) { if (!nodeId || !command) {
const { error } = getNodesTheme(); defaultRuntime.error("--node and --command required");
defaultRuntime.error(error("--node and --command required"));
defaultRuntime.exit(1); defaultRuntime.exit(1);
return; return;
} }
@@ -109,21 +108,16 @@ export function registerNodesInvokeCommands(nodes: Command) {
if (stdout) process.stdout.write(stdout); if (stdout) process.stdout.write(stdout);
if (stderr) process.stderr.write(stderr); if (stderr) process.stderr.write(stderr);
if (timedOut) { if (timedOut) {
const { error } = getNodesTheme(); defaultRuntime.error("run timed out");
defaultRuntime.error(error("run timed out"));
defaultRuntime.exit(1); defaultRuntime.exit(1);
return; return;
} }
if (exitCode !== null && exitCode !== 0) { if (exitCode !== null && exitCode !== 0) {
const hint = unauthorizedHintForMessage(`${stderr}\n${stdout}`); const hint = unauthorizedHintForMessage(`${stderr}\n${stdout}`);
if (hint) { if (hint) defaultRuntime.error(hint);
const { warn } = getNodesTheme();
defaultRuntime.error(warn(hint));
}
} }
if (exitCode !== null && exitCode !== 0 && !success) { if (exitCode !== null && exitCode !== 0 && !success) {
const { error } = getNodesTheme(); defaultRuntime.error(`run exit ${exitCode}`);
defaultRuntime.error(error(`run exit ${exitCode}`));
defaultRuntime.exit(1); defaultRuntime.exit(1);
return; return;
} }
+2 -3
View File
@@ -1,7 +1,7 @@
import type { Command } from "commander"; import type { Command } from "commander";
import { randomIdempotencyKey } from "../../gateway/call.js"; import { randomIdempotencyKey } from "../../gateway/call.js";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; import { 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";
@@ -49,8 +49,7 @@ export function registerNodesNotifyCommand(nodes: Command) {
defaultRuntime.log(JSON.stringify(result, null, 2)); defaultRuntime.log(JSON.stringify(result, null, 2));
return; return;
} }
const { ok } = getNodesTheme(); defaultRuntime.log("notify ok");
defaultRuntime.log(ok("notify ok"));
}); });
}), }),
); );
+10 -33
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 { formatAge, parsePairingList } from "./format.js"; import { formatAge, parsePairingList } from "./format.js";
import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; import { 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";
export function registerNodesPairingCommands(nodes: Command) { export function registerNodesPairingCommands(nodes: Command) {
nodesCallOpts( nodesCallOpts(
@@ -20,37 +19,16 @@ export function registerNodesPairingCommands(nodes: Command) {
return; return;
} }
if (pending.length === 0) { if (pending.length === 0) {
const { muted } = getNodesTheme(); defaultRuntime.log("No pending pairing requests.");
defaultRuntime.log(muted("No pending pairing requests."));
return; return;
} }
const { heading, warn, muted } = getNodesTheme(); for (const r of pending) {
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); const name = r.displayName || r.nodeId;
const now = Date.now(); const repair = r.isRepair ? " (repair)" : "";
const rows = pending.map((r) => ({ const ip = r.remoteIp ? ` · ${r.remoteIp}` : "";
Request: r.requestId, const age = typeof r.ts === "number" ? ` · ${formatAge(Date.now() - r.ts)} ago` : "";
Node: r.displayName?.trim() ? r.displayName.trim() : r.nodeId, defaultRuntime.log(`- ${r.requestId}: ${name}${repair}${ip}${age}`);
IP: r.remoteIp ?? "", }
Requested:
typeof r.ts === "number"
? `${formatAge(Math.max(0, now - r.ts))} ago`
: muted("unknown"),
Repair: r.isRepair ? warn("yes") : "",
}));
defaultRuntime.log(heading("Pending"));
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Request", header: "Request", minWidth: 8 },
{ key: "Node", header: "Node", minWidth: 14, flex: true },
{ key: "IP", header: "IP", minWidth: 10 },
{ key: "Requested", header: "Requested", minWidth: 12 },
{ key: "Repair", header: "Repair", minWidth: 6 },
],
rows,
}).trimEnd(),
);
}); });
}), }),
); );
@@ -108,8 +86,7 @@ export function registerNodesPairingCommands(nodes: Command) {
defaultRuntime.log(JSON.stringify(result, null, 2)); defaultRuntime.log(JSON.stringify(result, null, 2));
return; return;
} }
const { ok } = getNodesTheme(); defaultRuntime.log(`node rename ok: ${nodeId} -> ${name}`);
defaultRuntime.log(ok(`node rename ok: ${nodeId} -> ${name}`));
}); });
}), }),
); );
+54 -251
View File
@@ -1,11 +1,9 @@
import type { Command } from "commander"; import type { Command } from "commander";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import { formatAge, formatPermissions, parseNodeList, parsePairingList } from "./format.js"; import { formatAge, formatPermissions, parseNodeList, parsePairingList } from "./format.js";
import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; import { 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 { parseDurationMs } from "../parse-duration.js";
function formatVersionLabel(raw: string) { function formatVersionLabel(raw: string) {
const trimmed = raw.trim(); const trimmed = raw.trim();
@@ -44,131 +42,44 @@ 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 =
typeof result === "object" && result !== null
? (result as Record<string, unknown>)
: {};
const { ok, warn, muted } = getNodesTheme();
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
const now = Date.now();
const nodes = parseNodeList(result);
const lastConnectedById =
sinceMs !== undefined
? 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) { if (opts.json) {
const ts = typeof obj.ts === "number" ? obj.ts : Date.now(); defaultRuntime.log(JSON.stringify(result, null, 2));
defaultRuntime.log(JSON.stringify({ ...obj, ts, nodes: filtered }, null, 2));
return; return;
} }
const nodes = parseNodeList(result);
const pairedCount = filtered.filter((n) => Boolean(n.paired)).length; const pairedCount = nodes.filter((n) => Boolean(n.paired)).length;
const connectedCount = filtered.filter((n) => Boolean(n.connected)).length; const connectedCount = nodes.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; for (const n of nodes) {
const name = n.displayName || n.nodeId;
const rows = filtered.map((n) => { const ip = n.remoteIp ? ` · ${n.remoteIp}` : "";
const name = n.displayName?.trim() ? n.displayName.trim() : n.nodeId; const device = n.deviceFamily ? ` · device: ${n.deviceFamily}` : "";
const hw = n.modelIdentifier ? ` · hw: ${n.modelIdentifier}` : "";
const perms = formatPermissions(n.permissions); const perms = formatPermissions(n.permissions);
const permsText = perms ? ` · perms: ${perms}` : "";
const versions = formatNodeVersions(n); const versions = formatNodeVersions(n);
const detailParts = [ const versionText = versions ? ` · ${versions}` : "";
n.deviceFamily ? `device: ${n.deviceFamily}` : null, const caps =
n.modelIdentifier ? `hw: ${n.modelIdentifier}` : null, Array.isArray(n.caps) && n.caps.length > 0
perms ? `perms: ${perms}` : null, ? `[${n.caps.map(String).filter(Boolean).sort().join(",")}]`
versions, : Array.isArray(n.caps)
].filter(Boolean) as string[]; ? "[]"
const caps = Array.isArray(n.caps) : "?";
? n.caps.map(String).filter(Boolean).sort().join(", ") const pairing = n.paired ? "paired" : "unpaired";
: "?"; defaultRuntime.log(
const paired = n.paired ? ok("paired") : warn("unpaired"); `- ${name} · ${n.nodeId}${ip}${device}${hw}${permsText}${versionText} · ${pairing} · ${n.connected ? "connected" : "disconnected"} · caps: ${caps}`,
const connected = n.connected ? ok("connected") : muted("disconnected"); );
const since = }
typeof n.connectedAtMs === "number"
? ` (${formatAge(Math.max(0, now - n.connectedAtMs))} ago)`
: "";
return {
Node: name,
ID: n.nodeId,
IP: n.remoteIp ?? "",
Detail: detailParts.join(" · "),
Status: `${paired} · ${connected}${since}`,
Caps: caps,
};
});
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Node", header: "Node", minWidth: 14, flex: true },
{ key: "ID", header: "ID", minWidth: 10 },
{ key: "IP", header: "IP", minWidth: 10 },
{ key: "Detail", header: "Detail", minWidth: 18, flex: true },
{ key: "Status", header: "Status", minWidth: 18 },
{ key: "Caps", header: "Caps", minWidth: 12, flex: true },
],
rows,
}).trimEnd(),
);
}); });
}), }),
); );
@@ -195,7 +106,6 @@ export function registerNodesStatusCommands(nodes: Command) {
: {}; : {};
const displayName = typeof obj.displayName === "string" ? obj.displayName : nodeId; const displayName = typeof obj.displayName === "string" ? obj.displayName : nodeId;
const connected = Boolean(obj.connected); const connected = Boolean(obj.connected);
const paired = Boolean(obj.paired);
const caps = Array.isArray(obj.caps) ? obj.caps.map(String).filter(Boolean).sort() : null; const caps = Array.isArray(obj.caps) ? obj.caps.map(String).filter(Boolean).sort() : null;
const commands = Array.isArray(obj.commands) const commands = Array.isArray(obj.commands)
? obj.commands.map(String).filter(Boolean).sort() ? obj.commands.map(String).filter(Boolean).sort()
@@ -213,38 +123,18 @@ export function registerNodesStatusCommands(nodes: Command) {
}, },
); );
const { heading, ok, warn, muted } = getNodesTheme(); const parts: string[] = ["Node:", displayName, nodeId];
const status = `${paired ? ok("paired") : warn("unpaired")} · ${ if (ip) parts.push(ip);
connected ? ok("connected") : muted("disconnected") if (family) parts.push(`device: ${family}`);
}`; if (model) parts.push(`hw: ${model}`);
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); if (perms) parts.push(`perms: ${perms}`);
const rows = [ if (versions) parts.push(versions);
{ Field: "ID", Value: nodeId }, parts.push(connected ? "connected" : "disconnected");
displayName ? { Field: "Name", Value: displayName } : null, parts.push(`caps: ${caps ? `[${caps.join(",")}]` : "?"}`);
ip ? { Field: "IP", Value: ip } : null, defaultRuntime.log(parts.join(" · "));
family ? { Field: "Device", Value: family } : null, defaultRuntime.log("Commands:");
model ? { Field: "Model", Value: model } : null,
perms ? { Field: "Perms", Value: perms } : null,
versions ? { Field: "Version", Value: versions } : null,
{ Field: "Status", Value: status },
{ Field: "Caps", Value: caps ? caps.join(", ") : "?" },
].filter(Boolean) as Array<{ Field: string; Value: string }>;
defaultRuntime.log(heading("Node"));
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Field", header: "Field", minWidth: 8 },
{ key: "Value", header: "Value", minWidth: 24, flex: true },
],
rows,
}).trimEnd(),
);
defaultRuntime.log("");
defaultRuntime.log(heading("Commands"));
if (commands.length === 0) { if (commands.length === 0) {
defaultRuntime.log(muted("- (none reported)")); defaultRuntime.log("- (none reported)");
return; return;
} }
for (const c of commands) defaultRuntime.log(`- ${c}`); for (const c of commands) defaultRuntime.log(`- ${c}`);
@@ -256,119 +146,32 @@ 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;
const { pending, paired } = parsePairingList(result);
const { heading, muted, warn } = getNodesTheme();
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
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 (opts.json) {
defaultRuntime.log( defaultRuntime.log(JSON.stringify(result, null, 2));
JSON.stringify({ pending: pendingRows, paired: filteredPaired }, null, 2),
);
return; return;
} }
const { pending, paired } = parsePairingList(result);
if (pendingRows.length > 0) { defaultRuntime.log(`Pending: ${pending.length} · Paired: ${paired.length}`);
const pendingRowsRendered = pendingRows.map((r) => ({ if (pending.length > 0) {
Request: r.requestId, defaultRuntime.log("\nPending:");
Node: r.displayName?.trim() ? r.displayName.trim() : r.nodeId, for (const r of pending) {
IP: r.remoteIp ?? "", const name = r.displayName || r.nodeId;
Requested: const repair = r.isRepair ? " (repair)" : "";
typeof r.ts === "number" const ip = r.remoteIp ? ` · ${r.remoteIp}` : "";
? `${formatAge(Math.max(0, now - r.ts))} ago` const age = typeof r.ts === "number" ? ` · ${formatAge(Date.now() - r.ts)} ago` : "";
: muted("unknown"), defaultRuntime.log(`- ${r.requestId}: ${name}${repair}${ip}${age}`);
Repair: r.isRepair ? warn("yes") : "", }
}));
defaultRuntime.log("");
defaultRuntime.log(heading("Pending"));
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Request", header: "Request", minWidth: 8 },
{ key: "Node", header: "Node", minWidth: 14, flex: true },
{ key: "IP", header: "IP", minWidth: 10 },
{ key: "Requested", header: "Requested", minWidth: 12 },
{ key: "Repair", header: "Repair", minWidth: 6 },
],
rows: pendingRowsRendered,
}).trimEnd(),
);
} }
if (paired.length > 0) {
if (filteredPaired.length > 0) { defaultRuntime.log("\nPaired:");
const pairedRows = filteredPaired.map((n) => { for (const n of paired) {
const live = connectedById?.get(n.nodeId); const name = n.displayName || n.nodeId;
const lastConnectedAtMs = const ip = n.remoteIp ? ` · ${n.remoteIp}` : "";
typeof n.lastConnectedAtMs === "number" defaultRuntime.log(`- ${n.nodeId}: ${name}${ip}`);
? n.lastConnectedAtMs }
: typeof live?.connectedAtMs === "number"
? 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(heading("Paired"));
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Node", header: "Node", minWidth: 14, flex: true },
{ key: "Id", header: "ID", minWidth: 10 },
{ key: "IP", header: "IP", minWidth: 10 },
{ key: "LastConnect", header: "Last Connect", minWidth: 14 },
],
rows: pairedRows,
}).trimEnd(),
);
} }
}); });
}), }),
-4
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;
@@ -58,7 +56,6 @@ export type NodeListNode = {
permissions?: Record<string, boolean>; permissions?: Record<string, boolean>;
paired?: boolean; paired?: boolean;
connected?: boolean; connected?: boolean;
connectedAtMs?: number;
}; };
export type PendingRequest = { export type PendingRequest = {
@@ -86,7 +83,6 @@ export type PairedNode = {
permissions?: Record<string, boolean>; permissions?: Record<string, boolean>;
createdAtMs?: number; createdAtMs?: number;
approvedAtMs?: number; approvedAtMs?: number;
lastConnectedAtMs?: number;
}; };
export type PairingList = { export type PairingList = {
+17 -15
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();
@@ -36,12 +34,16 @@ vi.mock("../channels/plugins/index.js", () => ({
normalizeChannelId, normalizeChannelId,
})); }));
vi.mock("../config/config.js", () => ({ vi.mock("../config/config.js", async (importOriginal) => {
loadConfig: vi.fn().mockReturnValue({}), const actual = await importOriginal<typeof import("../config/config.js")>();
})); return {
...actual,
loadConfig: vi.fn().mockReturnValue({}),
};
});
describe("pairing cli", () => { describe("pairing cli", () => {
it("evaluates pairing channels when registering the CLI (not at import)", async () => { it("defers pairing channel lookup until command execution", async () => {
listPairingChannels.mockClear(); listPairingChannels.mockClear();
const { registerPairingCli } = await import("./pairing-cli.js"); const { registerPairingCli } = await import("./pairing-cli.js");
@@ -51,6 +53,10 @@ describe("pairing cli", () => {
program.name("test"); program.name("test");
registerPairingCli(program); registerPairingCli(program);
expect(listPairingChannels).not.toHaveBeenCalled();
listChannelPairingRequests.mockResolvedValueOnce([]);
await program.parseAsync(["pairing", "list", "telegram"], { from: "user" });
expect(listPairingChannels).toHaveBeenCalledTimes(1); expect(listPairingChannels).toHaveBeenCalledTimes(1);
}); });
@@ -66,16 +72,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 +132,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 +154,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);
+20 -34
View File
@@ -8,11 +8,11 @@ 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 { resolveCliChannelOptions } from "./channel-options.js";
import { formatCliCommand } from "./command-format.js"; import { formatCliCommand } from "./command-format.js";
import { markCommandRequiresPluginRegistry } from "./program/command-metadata.js";
/** Parse channel, allowing extension channels not in core registry. */ /** Parse channel, allowing extension channels not in core registry. */
function parseChannel(raw: unknown, channels: PairingChannel[]): PairingChannel { function parseChannel(raw: unknown, channels: PairingChannel[]): PairingChannel {
@@ -46,7 +46,9 @@ async function notifyApproved(channel: PairingChannel, id: string) {
} }
export function registerPairingCli(program: Command) { export function registerPairingCli(program: Command) {
const channels = listPairingChannels(); const channelOptions = resolveCliChannelOptions();
const channelHint =
channelOptions.length > 0 ? `Channel (${channelOptions.join(", ")})` : "Channel";
const pairing = program const pairing = program
.command("pairing") .command("pairing")
.description("Secure DM pairing (approve inbound requests)") .description("Secure DM pairing (approve inbound requests)")
@@ -55,14 +57,16 @@ export function registerPairingCli(program: Command) {
() => () =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/pairing", "docs.clawd.bot/cli/pairing")}\n`, `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/pairing", "docs.clawd.bot/cli/pairing")}\n`,
); );
markCommandRequiresPluginRegistry(pairing);
pairing pairing
.command("list") .command("list")
.description("List pending pairing requests") .description("List pending pairing requests")
.option("--channel <channel>", `Channel (${channels.join(", ")})`) .option("--channel <channel>", channelHint)
.argument("[channel]", `Channel (${channels.join(", ")})`) .argument("[channel]", channelHint)
.option("--json", "Print JSON", false) .option("--json", "Print JSON", false)
.action(async (channelArg, opts) => { .action(async (channelArg, opts) => {
const channels = listPairingChannels();
const channelRaw = opts.channel ?? channelArg; const channelRaw = opts.channel ?? channelArg;
if (!channelRaw) { if (!channelRaw) {
throw new Error( throw new Error(
@@ -72,45 +76,29 @@ 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
.command("approve") .command("approve")
.description("Approve a pairing code and allow that sender") .description("Approve a pairing code and allow that sender")
.option("--channel <channel>", `Channel (${channels.join(", ")})`) .option("--channel <channel>", channelHint)
.argument("<codeOrChannel>", "Pairing code (or channel when using 2 args)") .argument("<codeOrChannel>", "Pairing code (or channel when using 2 args)")
.argument("[code]", "Pairing code (when channel is passed as the 1st arg)") .argument("[code]", "Pairing code (when channel is passed as the 1st arg)")
.option("--notify", "Notify the requester on the same channel", false) .option("--notify", "Notify the requester on the same channel", false)
.action(async (codeOrChannel, code, opts) => { .action(async (codeOrChannel, code, opts) => {
const channels = listPairingChannels();
const channelRaw = opts.channel ?? codeOrChannel; const channelRaw = opts.channel ?? codeOrChannel;
const resolvedCode = opts.channel ? codeOrChannel : code; const resolvedCode = opts.channel ? codeOrChannel : code;
if (!opts.channel && !code) { if (!opts.channel && !code) {
@@ -132,13 +120,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}`);
+5 -92
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(),
@@ -172,14 +95,10 @@ describe("cli program (nodes basics)", () => {
const output = runtime.log.mock.calls.map((c) => String(c[0] ?? "")).join("\n"); const output = runtime.log.mock.calls.map((c) => String(c[0] ?? "")).join("\n");
expect(output).toContain("Known: 1 · Paired: 1 · Connected: 1"); expect(output).toContain("Known: 1 · Paired: 1 · Connected: 1");
expect(output).toContain("iOS Node"); expect(output).toContain("iOS Node");
expect(output).toContain("Detail");
expect(output).toContain("device: iPad"); expect(output).toContain("device: iPad");
expect(output).toContain("hw: iPad16,6"); expect(output).toContain("hw: iPad16,6");
expect(output).toContain("Status");
expect(output).toContain("paired"); expect(output).toContain("paired");
expect(output).toContain("Caps"); expect(output).toContain("caps: [camera,canvas]");
expect(output).toContain("camera");
expect(output).toContain("canvas");
}); });
it("runs nodes status and shows unpaired nodes", async () => { it("runs nodes status and shows unpaired nodes", async () => {
@@ -204,18 +123,12 @@ describe("cli program (nodes basics)", () => {
const output = runtime.log.mock.calls.map((c) => String(c[0] ?? "")).join("\n"); const output = runtime.log.mock.calls.map((c) => String(c[0] ?? "")).join("\n");
expect(output).toContain("Known: 1 · Paired: 0 · Connected: 1"); expect(output).toContain("Known: 1 · Paired: 0 · Connected: 1");
expect(output).toContain("Peter's Tab"); expect(output).toContain("Peter's Tab S10 Ultra");
expect(output).toContain("S10 Ultra");
expect(output).toContain("Detail");
expect(output).toContain("device: Android"); expect(output).toContain("device: Android");
expect(output).toContain("hw: samsung"); expect(output).toContain("hw: samsung SM-X926B");
expect(output).toContain("SM-X926B");
expect(output).toContain("Status");
expect(output).toContain("unpaired"); expect(output).toContain("unpaired");
expect(output).toContain("connected"); expect(output).toContain("connected");
expect(output).toContain("Caps"); expect(output).toContain("caps: [camera,canvas]");
expect(output).toContain("camera");
expect(output).toContain("canvas");
}); });
it("runs nodes describe and calls node.describe", async () => { it("runs nodes describe and calls node.describe", async () => {
@@ -263,7 +176,7 @@ describe("cli program (nodes basics)", () => {
); );
const out = runtime.log.mock.calls.map((c) => String(c[0] ?? "")).join("\n"); const out = runtime.log.mock.calls.map((c) => String(c[0] ?? "")).join("\n");
expect(out).toContain("Commands"); expect(out).toContain("Commands:");
expect(out).toContain("canvas.eval"); expect(out).toContain("canvas.eval");
}); });
+24
View File
@@ -0,0 +1,24 @@
import { Command } from "commander";
import { describe, expect, it } from "vitest";
import {
commandRequiresPluginRegistry,
markCommandRequiresPluginRegistry,
} from "./command-metadata.js";
describe("commandRequiresPluginRegistry", () => {
it("detects direct requirement", () => {
const program = new Command();
const cmd = program.command("message");
markCommandRequiresPluginRegistry(cmd);
expect(commandRequiresPluginRegistry(cmd)).toBe(true);
});
it("walks parent chain", () => {
const program = new Command();
const parent = program.command("channels");
const child = parent.command("list");
markCommandRequiresPluginRegistry(parent);
expect(commandRequiresPluginRegistry(child)).toBe(true);
});
});
+21
View File
@@ -0,0 +1,21 @@
import type { Command } from "commander";
const REQUIRES_PLUGIN_REGISTRY = Symbol.for("clawdbot.requiresPluginRegistry");
type CommandWithPluginRequirement = Command & {
[REQUIRES_PLUGIN_REGISTRY]?: boolean;
};
export function markCommandRequiresPluginRegistry(command: Command): Command {
(command as CommandWithPluginRequirement)[REQUIRES_PLUGIN_REGISTRY] = true;
return command;
}
export function commandRequiresPluginRegistry(command?: Command | null): boolean {
let current: Command | null | undefined = command;
while (current) {
if ((current as CommandWithPluginRequirement)[REQUIRES_PLUGIN_REGISTRY]) return true;
current = current.parent ?? undefined;
}
return false;
}
+6
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;
}, },
@@ -81,6 +86,7 @@ const routeSessions: RouteSpec = {
const routeAgentsList: RouteSpec = { const routeAgentsList: RouteSpec = {
match: (path) => path[0] === "agents" && path[1] === "list", match: (path) => path[0] === "agents" && path[1] === "list",
loadPlugins: true,
run: async (argv) => { run: async (argv) => {
const json = hasFlag(argv, "--json"); const json = hasFlag(argv, "--json");
const bindings = hasFlag(argv, "--bindings"); const bindings = hasFlag(argv, "--bindings");
+1 -2
View File
@@ -80,8 +80,7 @@ export function configureProgramHelp(program: Command, ctx: ProgramContext) {
([cmd, desc]) => ` ${theme.command(cmd)}\n ${theme.muted(desc)}`, ([cmd, desc]) => ` ${theme.command(cmd)}\n ${theme.muted(desc)}`,
).join("\n"); ).join("\n");
program.addHelpText("afterAll", ({ command }) => { program.addHelpText("afterAll", () => {
if (command !== program) return "";
const docs = formatDocsLink("/cli", "docs.clawd.bot/cli"); const docs = formatDocsLink("/cli", "docs.clawd.bot/cli");
return `\n${theme.heading("Examples:")}\n${fmtExamples}\n\n${theme.muted("Docs:")} ${docs}\n`; return `\n${theme.heading("Examples:")}\n${fmtExamples}\n\n${theme.muted("Docs:")} ${docs}\n`;
}); });
+56
View File
@@ -0,0 +1,56 @@
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { markCommandRequiresPluginRegistry } from "./command-metadata.js";
vi.mock("../plugin-registry.js", () => ({
ensurePluginRegistryLoaded: vi.fn(),
}));
vi.mock("./config-guard.js", () => ({
ensureConfigReady: vi.fn(async () => {}),
}));
vi.mock("../banner.js", () => ({
emitCliBanner: vi.fn(),
}));
vi.mock("../argv.js", () => ({
getCommandPath: vi.fn(() => ["message"]),
hasHelpOrVersion: vi.fn(() => false),
}));
const loadRegisterPreActionHooks = async () => {
const mod = await import("./preaction.js");
return mod.registerPreActionHooks;
};
const loadEnsurePluginRegistryLoaded = async () => {
const mod = await import("../plugin-registry.js");
return mod.ensurePluginRegistryLoaded;
};
describe("registerPreActionHooks", () => {
beforeEach(async () => {
const ensurePluginRegistryLoaded = await loadEnsurePluginRegistryLoaded();
vi.mocked(ensurePluginRegistryLoaded).mockClear();
});
it("loads plugins for marked commands", async () => {
const registerPreActionHooks = await loadRegisterPreActionHooks();
const ensurePluginRegistryLoaded = await loadEnsurePluginRegistryLoaded();
const program = new Command();
registerPreActionHooks(program, "test");
const message = program.command("message").action(() => {});
markCommandRequiresPluginRegistry(message);
const originalArgv = process.argv;
const argv = ["node", "clawdbot", "message"];
process.argv = argv;
try {
await program.parseAsync(argv);
} finally {
process.argv = originalArgv;
}
expect(vi.mocked(ensurePluginRegistryLoaded)).toHaveBeenCalledTimes(1);
});
});
Loaded 100 of 191 files, more files were not shown because too many files have changed in this diff. Show more