Merge branch 'main' into commands-list-clean

This commit is contained in:
Luke
2026-01-09 03:12:08 -05:00
committed by GitHub
128 changed files with 5743 additions and 1503 deletions

View File

@@ -83,6 +83,8 @@
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested.
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
- **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those.
- Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed.
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
@@ -92,17 +94,17 @@
- Voice wake forwarding tips:
- Command template should stay `clawdbot-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Dont add extra quotes.
- launchd PATH is minimal; ensure the apps launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
- For manual `clawdbot send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tools escaping.
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tools escaping.
## Exclamation Mark Escaping Workaround
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot send` with messages containing exclamation marks, use heredoc syntax:
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:
```bash
# WRONG - will send "Hello\\!" with backslash
clawdbot send --to "+1234" --message 'Hello!'
clawdbot message send --to "+1234" --message 'Hello!'
# CORRECT - use heredoc to avoid escaping
clawdbot send --to "+1234" --message "$(cat <<'EOF'
clawdbot message send --to "+1234" --message "$(cat <<'EOF'
Hello!
EOF
)"

View File

@@ -2,8 +2,10 @@
## Unreleased
- Commands: accept /models as an alias for /model.
- Debugging: add raw model stream logging flags and document gateway watch mode.
- Agent: add claude-cli/opus-4.5 runner via Claude CLI with resume support (tools disabled).
- CLI: move `clawdbot message` to subcommands (`message send|poll|…`), fold Discord/Slack/Telegram/WhatsApp tools into `message`, and require `--provider` unless only one provider is configured.
- CLI: improve `logs` output (pretty/plain/JSONL), add gateway unreachable hint, and document logging.
- WhatsApp: route queued replies to the original sender instead of the bot's own number. (#534) — thanks @mcinteerj
- Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223
@@ -37,6 +39,7 @@
- Control UI: add Docs link, remove chat composer divider, and add New session button.
- Control UI: link sessions list to chat view. (#471) — thanks @HazAT
- Control UI: queue outgoing chat messages, add Enter-to-send, and show queued items. (#527) — thanks @YuriNachos
- Control UI: drop explicit `ui:install` step; `ui:build` now auto-installs UI deps (docs + update flow).
- Telegram: retry long-polling conflicts with backoff to avoid fatal exits.
- Telegram: fix grammY fetch type mismatch when injecting `fetch`. (#512) — thanks @YuriNachos
- WhatsApp: resolve @lid JIDs via Baileys mapping to unblock inbound messages. (#415)
@@ -72,6 +75,10 @@
- Commands: return /status in directive-only multi-line messages.
- Models: fall back to configured models when the provider catalog is unavailable.
- Agent system prompt: add messaging guidance for reply routing and cross-session sends. (#526) — thanks @neist
- Agent: bypass Anthropic OAuth tool-name blocks by capitalizing built-ins and keeping pruning tool matching case-insensitive. (#553) — thanks @andrewting19
- Commands/Tools: disable /restart and gateway restart tool by default (enable with commands.restart=true).
- Gateway/CLI: add `clawdbot gateway discover` (Bonjour scan on `local.` + `clawdbot.internal.`) with `--timeout` and `--json`. — thanks @steipete
- CLI: centralize lobster palette + apply it to onboarding/config prompts. — thanks @steipete
## 2026.1.8

View File

@@ -62,7 +62,7 @@ clawdbot onboard --install-daemon
clawdbot gateway --port 18789 --verbose
# Send a message
clawdbot send --to +1234567890 --message "Hello from Clawdbot"
clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord)
clawdbot agent --message "Ship checklist" --thinking high
@@ -79,8 +79,7 @@ git clone https://github.com/clawdbot/clawdbot.git
cd clawdbot
pnpm install
pnpm ui:install
pnpm ui:build
pnpm ui:build # auto-installs UI deps on first run
pnpm build
pnpm clawdbot onboard --install-daemon
@@ -453,18 +452,21 @@ by Peter Steinberger and the community.
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
AI/vibe-coded PRs welcome! 🤖
Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
Thanks to all clawtributors:
<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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></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/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/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/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/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/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=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></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/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/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/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/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/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/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=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></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=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></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/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/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/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/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/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/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/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/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=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></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/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/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/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></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/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/search?q=Tobias%20Bischoff"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></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/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/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/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/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/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=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></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=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></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/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/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/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/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/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/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/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=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></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/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/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/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></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/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/search?q=Tobias%20Bischoff"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></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/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a>
</p>

View File

@@ -15,18 +15,22 @@ read_when:
```bash
# WhatsApp
clawdbot poll --to +15555550123 -q "Lunch today?" -o "Yes" -o "No" -o "Maybe"
clawdbot poll --to 123456789@g.us -q "Meeting time?" -o "10am" -o "2pm" -o "4pm" -s 2
clawdbot message poll --to +15555550123 \
--poll-question "Lunch today?" --poll-option "Yes" --poll-option "No" --poll-option "Maybe"
clawdbot message poll --to 123456789@g.us \
--poll-question "Meeting time?" --poll-option "10am" --poll-option "2pm" --poll-option "4pm" --poll-multi
# Discord
clawdbot poll --to channel:123456789 -q "Snack?" -o "Pizza" -o "Sushi" --provider discord
clawdbot poll --to channel:123456789 -q "Plan?" -o "A" -o "B" --provider discord --duration-hours 48
clawdbot message poll --provider discord --to channel:123456789 \
--poll-question "Snack?" --poll-option "Pizza" --poll-option "Sushi"
clawdbot message poll --provider discord --to channel:123456789 \
--poll-question "Plan?" --poll-option "A" --poll-option "B" --poll-duration-hours 48
```
Options:
- `--provider`: `whatsapp` (default) or `discord`
- `--max-selections`: how many choices a voter can select (default: 1)
- `--duration-hours`: Discord-only (defaults to 24 when omitted)
- `--poll-multi`: allow selecting multiple options
- `--poll-duration-hours`: Discord-only (defaults to 24 when omitted)
## Gateway RPC
@@ -45,7 +49,7 @@ Params:
- WhatsApp: 2-12 options, `maxSelections` must be within option count, ignores `durationHours`.
- Discord: 2-10 options, `durationHours` clamped to 1-768 hours (default 24). `maxSelections > 1` enables multi-select; Discord does not support a strict selection count.
## Agent tool (Discord)
The Discord tool action `poll` still uses `question`, `answers`, optional `allowMultiselect`, `durationHours`, and `content`. The gateway/CLI poll model maps `allowMultiselect` to `maxSelections > 1`.
## Agent tool (Message)
Use the `message` tool with `poll` action (`to`, `pollQuestion`, `pollOption`, optional `pollMulti`, `pollDurationHours`, `provider`).
Note: Discord has no “pick exactly N” mode; `maxSelections` is treated as a boolean (`> 1` = multiselect).
Note: Discord has no “pick exactly N” mode; `pollMulti` maps to multi-select.

107
docs/cli/gateway.md Normal file
View File

@@ -0,0 +1,107 @@
---
summary: "Clawdbot Gateway CLI (`clawdbot gateway`) — run, query, and discover gateways"
read_when:
- Running the Gateway from the CLI (dev or servers)
- Debugging Gateway auth, bind modes, and connectivity
- Discovering gateways via Bonjour (LAN + tailnet)
---
# Gateway CLI
The Gateway is Clawdbots WebSocket server (providers, nodes, sessions, hooks).
Subcommands in this page live under `clawdbot gateway …`.
Related docs:
- [/gateway/bonjour](/gateway/bonjour)
- [/gateway/discovery](/gateway/discovery)
- [/gateway/configuration](/gateway/configuration)
## Run the Gateway
Run a local Gateway process:
```bash
clawdbot gateway
```
Notes:
- By default, the Gateway refuses to start unless `gateway.mode=local` is set in `~/.clawdbot/clawdbot.json`. Use `--allow-unconfigured` for ad-hoc/dev runs.
- Binding beyond loopback without auth is blocked (safety guardrail).
- `SIGUSR1` triggers an in-process restart (useful without a supervisor).
### Options
- `--port <port>`: WebSocket port (default comes from config/env; usually `18789`).
- `--bind <loopback|lan|tailnet|auto>`: listener bind mode.
- `--auth <token|password>`: auth mode override.
- `--token <token>`: token override (also sets `CLAWDBOT_GATEWAY_TOKEN` for the process).
- `--password <password>`: password override (also sets `CLAWDBOT_GATEWAY_PASSWORD` for the process).
- `--tailscale <off|serve|funnel>`: expose the Gateway via Tailscale.
- `--tailscale-reset-on-exit`: reset Tailscale serve/funnel config on shutdown.
- `--force`: kill any existing listener on the selected port before starting.
- `--verbose`: verbose logs.
- `--claude-cli-logs`: only show claude-cli logs in the console (and enable its stdout/stderr).
- `--ws-log <auto|full|compact>`: websocket log style (default `auto`).
- `--compact`: alias for `--ws-log compact`.
- `--raw-stream`: log raw model stream events to jsonl.
- `--raw-stream-path <path>`: raw stream jsonl path.
## Query a running Gateway
All query commands use WebSocket RPC.
Shared options:
- `--url <url>`: Gateway WebSocket URL (defaults to `gateway.remote.url` when configured).
- `--token <token>`: Gateway token (if required).
- `--password <password>`: Gateway password (password auth).
- `--timeout <ms>`: timeout (default `10000`).
- `--expect-final`: wait for a “final” response (agent calls).
### `gateway health`
```bash
clawdbot gateway health --url ws://127.0.0.1:18789
```
### `gateway status`
```bash
clawdbot gateway status --url ws://127.0.0.1:18789
```
### `gateway call <method>`
Low-level RPC helper.
```bash
clawdbot gateway call status
clawdbot gateway call logs.tail --params '{"sinceMs": 60000}'
```
## Discover gateways (Bonjour)
`gateway discover` scans for Gateway bridge beacons (`_clawdbot-bridge._tcp`).
- Multicast DNS-SD: `local.`
- Unicast DNS-SD (Wide-Area Bonjour): `clawdbot.internal.` (requires split DNS + DNS server; see [/gateway/bonjour](/gateway/bonjour))
Only gateways with the **bridge enabled** will advertise the discovery beacon.
### `gateway discover`
```bash
clawdbot gateway discover
```
Options:
- `--timeout <ms>`: per-command timeout (browse/resolve); default `2000`.
- `--json`: machine-readable output (also disables styling/spinner).
Examples:
```bash
clawdbot gateway discover --timeout 4000
clawdbot gateway discover --json | jq '.beacons[].wsUrl'
```

View File

@@ -36,6 +36,8 @@ Clawdbot uses a lobster palette for CLI output.
- `error` (#E23D2D): errors, failures.
- `muted` (#8B7F77): de-emphasis, metadata.
Palette source of truth: `src/terminal/palette.ts` (aka “lobster seam”).
## Command tree
```
@@ -55,8 +57,7 @@ clawdbot [--dev] [--profile <name>] <command>
list
info
check
send
poll
message
agent
agents
list
@@ -69,6 +70,7 @@ clawdbot [--dev] [--profile <name>] <command>
call
health
status
discover
models
list
status
@@ -283,37 +285,21 @@ Options:
## Messaging + agent
### `send`
Send a message through a provider.
### `message`
Unified outbound messaging + provider actions.
Required:
- `--to <dest>`
- `--message <text>`
See: [/cli/message](/cli/message)
Options:
- `--media <path-or-url>`
- `--gif-playback`
- `--provider <whatsapp|telegram|discord|slack|signal|imessage>`
- `--account <id>` (WhatsApp)
- `--dry-run`
- `--json`
- `--verbose`
### `poll`
Create a poll (WhatsApp or Discord).
Required:
- `--to <id>`
- `--question <text>`
- `--option <choice>` (repeat 2-12 times)
Options:
- `--max-selections <n>`
- `--duration-hours <n>` (Discord)
- `--provider <whatsapp|discord>`
- `--dry-run`
- `--json`
- `--verbose`
Subcommands:
- `message send|poll|react|reactions|read|edit|delete|pin|unpin|pins|permissions|search|timeout|kick|ban`
- `message thread <create|list|reply>`
- `message emoji <list|upload>`
- `message sticker <send|upload>`
- `message role <info|add|remove>`
- `message channel <info|list>`
- `message member info`
- `message voice status`
- `message event <list|create>`
### `agent`
Run one agent turn via the Gateway (or `--local` embedded).

161
docs/cli/message.md Normal file
View File

@@ -0,0 +1,161 @@
---
summary: "CLI reference for `clawdbot message` (send + provider actions)"
read_when:
- Adding or modifying message CLI actions
- Changing outbound provider behavior
---
# `clawdbot message`
Single outbound command for sending messages and provider actions
(Discord/Slack/Telegram/WhatsApp/Signal/iMessage).
## Usage
```
clawdbot message <subcommand> [flags]
```
Provider selection:
- `--provider` required if more than one provider is configured.
- If exactly one provider is configured, it becomes the default.
- Values: `whatsapp|telegram|discord|slack|signal|imessage`
Target formats (`--to`):
- WhatsApp: E.164 or group JID
- Telegram: chat id or `@username`
- Discord/Slack: `channel:<id>` or `user:<id>` (raw id ok)
- Signal: E.164, `group:<id>`, or `signal:+E.164`
- iMessage: handle or `chat_id:<id>`
## Common flags
- `--provider <name>`
- `--account <id>`
- `--json`
- `--dry-run`
- `--verbose`
## Actions
### Core
- `send`
- Required: `--to`, `--message`
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
- `poll`
- Required: `--to`, `--poll-question`, `--poll-option` (repeat)
- Optional: `--poll-multi`, `--poll-duration-hours`, `--message`
- `react`
- Required: `--to`, `--message-id`
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me`, `--channel-id`
- `reactions`
- Required: `--to`, `--message-id`
- Optional: `--limit`, `--channel-id`
- `read`
- Required: `--to`
- Optional: `--limit`, `--before`, `--after`, `--around`, `--channel-id`
- `edit`
- Required: `--to`, `--message-id`, `--message`
- Optional: `--channel-id`
- `delete`
- Required: `--to`, `--message-id`
- Optional: `--channel-id`
- `pin` / `unpin`
- Required: `--to`, `--message-id`
- Optional: `--channel-id`
- `pins` (list)
- Required: `--to`
- Optional: `--channel-id`
- `permissions`
- Required: `--to`
- Optional: `--channel-id`
- `search`
- Required: `--guild-id`, `--query`
- Optional: `--channel-id`, `--channel-ids` (repeat), `--author-id`, `--author-ids` (repeat), `--limit`
### Threads
- `thread create`
- Required: `--thread-name`, `--to` (channel id) or `--channel-id`
- Optional: `--message-id`, `--auto-archive-min`
- `thread list`
- Required: `--guild-id`
- Optional: `--channel-id`, `--include-archived`, `--before`, `--limit`
- `thread reply`
- Required: `--to` (thread id), `--message`
- Optional: `--media`, `--reply-to`
### Emojis
- `emoji list`
- Discord: `--guild-id`
- `emoji upload`
- Required: `--guild-id`, `--emoji-name`, `--media`
- Optional: `--role-ids` (repeat)
### Stickers
- `sticker send`
- Required: `--to`, `--sticker-id` (repeat)
- Optional: `--message`
- `sticker upload`
- Required: `--guild-id`, `--sticker-name`, `--sticker-desc`, `--sticker-tags`, `--media`
### Roles / Channels / Members / Voice
- `role info` (Discord): `--guild-id`
- `role add` / `role remove` (Discord): `--guild-id`, `--user-id`, `--role-id`
- `channel info` (Discord): `--channel-id`
- `channel list` (Discord): `--guild-id`
- `member info` (Discord/Slack): `--user-id` (+ `--guild-id` for Discord)
- `voice status` (Discord): `--guild-id`, `--user-id`
### Events
- `event list` (Discord): `--guild-id`
- `event create` (Discord): `--guild-id`, `--event-name`, `--start-time`
- Optional: `--end-time`, `--desc`, `--channel-id`, `--location`, `--event-type`
### Moderation (Discord)
- `timeout`: `--guild-id`, `--user-id` (+ `--duration-min` or `--until`)
- `kick`: `--guild-id`, `--user-id`
- `ban`: `--guild-id`, `--user-id` (+ `--delete-days`)
## Examples
Send a Discord reply:
```
clawdbot message send --provider discord \
--to channel:123 --message "hi" --reply-to 456
```
Create a Discord poll:
```
clawdbot message poll --provider discord \
--to channel:123 \
--poll-question "Snack?" \
--poll-option Pizza --poll-option Sushi \
--poll-multi --poll-duration-hours 48
```
React in Slack:
```
clawdbot message react --provider slack \
--to C123 --message-id 456 --emoji "✅"
```

View File

@@ -44,6 +44,7 @@ Pruning uses an estimated context window (chars ≈ tokens × 4). The window siz
## Tool selection
- `tools.allow` / `tools.deny` support `*` wildcards.
- Deny wins.
- Matching is case-insensitive.
- Empty allow list => all tools allowed.
## Interaction with other limits

View File

@@ -549,6 +549,13 @@
"install/bun"
]
},
{
"group": "CLI",
"pages": [
"cli/index",
"cli/gateway"
]
},
{
"group": "Core Concepts",
"pages": [

View File

@@ -591,6 +591,7 @@ Controls how chat commands are enabled across connectors.
commands: {
native: false, // register native commands when supported
text: true, // parse slash commands in chat messages
restart: false, // allow /restart + gateway restart tool
useAccessGroups: true // enforce access-group allowlists/policies for commands
}
}
@@ -601,6 +602,7 @@ Notes:
- `commands.text: false` disables parsing chat messages for commands.
- `commands.native: true` registers native commands on supported connectors (Discord/Slack/Telegram). Platforms without native commands still rely on text commands.
- `commands.native: false` skips native registration; Discord/Telegram clear previously registered commands on startup. Slack commands are managed in the Slack app.
- `commands.restart: true` enables `/restart` and the gateway tool restart action.
- `commands.useAccessGroups: false` allows commands to bypass access-group allowlists/policies.
### `web` (WhatsApp web provider)

View File

@@ -254,7 +254,7 @@ Windows installs should use **WSL2** and follow the Linux systemd section above.
## CLI helpers
- `clawdbot gateway health|status` — request health/status over the Gateway WS.
- `clawdbot send --to <num> --message "hi" [--media ...]` — send via Gateway (idempotent for WhatsApp).
- `clawdbot message send --to <num> --message "hi" [--media ...]` — send via Gateway (idempotent for WhatsApp).
- `clawdbot agent --message "hi" --to <num>` — run an agent turn (waits for final by default).
- `clawdbot gateway call <method> --params '{"k":"v"}'` — raw method invoker for debugging.
- `clawdbot daemon stop|restart` — stop/restart the supervised gateway service (launchd/systemd).

View File

@@ -118,8 +118,7 @@ From source (development):
git clone https://github.com/clawdbot/clawdbot.git
cd clawdbot
pnpm install
pnpm ui:install
pnpm ui:build
pnpm ui:build # auto-installs UI deps on first run
pnpm build
pnpm clawdbot onboard --install-daemon
```
@@ -135,7 +134,7 @@ clawdbot gateway --port 19001
Send a test message (requires a running Gateway):
```bash
clawdbot send --to +15555550123 --message "Hello from CLAWDBOT"
clawdbot message send --to +15555550123 --message "Hello from CLAWDBOT"
```
## Configuration (optional)

View File

@@ -59,8 +59,7 @@ From the repo checkout:
git pull
pnpm install
pnpm build
pnpm ui:install
pnpm ui:build
pnpm ui:build # auto-installs UI deps on first run
pnpm clawdbot doctor
pnpm clawdbot health
```

View File

@@ -8,12 +8,12 @@ read_when:
CLAWDBOT is now **web-only** (Baileys). This document captures the current media handling rules for send, gateway, and agent replies.
## Goals
- Send media with optional captions via `clawdbot send --media`.
- Send media with optional captions via `clawdbot message send --media`.
- Allow auto-replies from the web inbox to include media alongside text.
- Keep per-type limits sane and predictable.
## CLI Surface
- `clawdbot send --media <path-or-url> [--message <caption>]`
- `clawdbot message send --media <path-or-url> [--message <caption>]`
- `--media` optional; caption can be empty for media-only sends.
- `--dry-run` prints the resolved payload; `--json` emits `{ provider, to, messageId, mediaUrl, caption }`.
@@ -30,7 +30,7 @@ CLAWDBOT is now **web-only** (Baileys). This document captures the current media
## Auto-Reply Pipeline
- `getReplyFromConfig` returns `{ text?, mediaUrl?, mediaUrls? }`.
- When media is present, the web sender resolves local paths or URLs using the same pipeline as `clawdbot send`.
- When media is present, the web sender resolves local paths or URLs using the same pipeline as `clawdbot message send`.
- Multiple media entries are sent sequentially if provided.
## Inbound Media to Commands (Pi)

View File

@@ -95,8 +95,7 @@ Follow the Linux Getting Started flow inside WSL:
git clone https://github.com/clawdbot/clawdbot.git
cd clawdbot
pnpm install
pnpm ui:install
pnpm ui:build
pnpm ui:build # auto-installs UI deps on first run
pnpm build
pnpm clawdbot onboard
```

View File

@@ -223,7 +223,7 @@ Outbound Telegram API calls retry on transient network/429 errors with exponenti
## Delivery targets (CLI/cron)
- Use a chat id (`123456789`) or a username (`@name`) as the target.
- Example: `clawdbot send --provider telegram --to 123456789 "hi"`.
- Example: `clawdbot message send --provider telegram --to 123456789 --message "hi"`.
## Troubleshooting

View File

@@ -158,7 +158,7 @@ Behavior:
- Caption only on first media item.
- Media fetch supports HTTP(S) and local paths.
- Animated GIFs: WhatsApp expects MP4 with `gifPlayback: true` for inline looping.
- CLI: `clawdbot send --media <mp4> --gif-playback`
- CLI: `clawdbot message send --media <mp4> --gif-playback`
- Gateway: `send` params include `gifPlayback: true`
## Media limits + optimization

View File

@@ -37,8 +37,7 @@ From source (development):
git clone https://github.com/clawdbot/clawdbot.git
cd clawdbot
pnpm install
pnpm ui:install
pnpm ui:build
pnpm ui:build # auto-installs UI deps on first run
pnpm build
pnpm link --global
```

View File

@@ -64,8 +64,7 @@ pnpm install
pnpm build
# If the Control UI assets are missing or you want the dashboard:
pnpm ui:install
pnpm ui:build
pnpm ui:build # auto-installs UI deps on first run
pnpm clawdbot onboard
```
@@ -561,7 +560,7 @@ Outbound attachments from the agent must include a `MEDIA:<path-or-url>` line (o
CLI sending:
```bash
clawdbot send --to +15555550123 --message "Here you go" --media /path/to/file.png
clawdbot message send --to +15555550123 --message "Here you go" --media /path/to/file.png
```
Note: images are resized/recompressed (max side 2048px) to hit size limits. See [Images](/nodes/images).

View File

@@ -135,8 +135,7 @@ If youre hacking on Clawdbot itself, run from source:
git clone https://github.com/clawdbot/clawdbot.git
cd clawdbot
pnpm install
pnpm ui:install
pnpm ui:build
pnpm ui:build # auto-installs UI deps on first run
pnpm build
pnpm clawdbot onboard --install-daemon
```
@@ -153,7 +152,7 @@ In a new terminal:
```bash
clawdbot health
clawdbot send --to +15555550123 --message "Hello from Clawdbot"
clawdbot message send --to +15555550123 --message "Hello from Clawdbot"
```
If `health` shows “no auth configured”, go back to the wizard and set OAuth/key auth — the agent wont be able to respond without it.

View File

@@ -71,7 +71,7 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
2) **Model/Auth**
- **Anthropic OAuth (Claude CLI)**: on macOS the wizard checks Keychain item "Claude Code-credentials" (choose "Always Allow" so launchd starts don't block); on Linux/Windows it reuses `~/.claude/.credentials.json` if present.
- **Anthropic OAuth (recommended)**: browser flow; paste the `code#state`.
- **Anthropic token (paste setup-token)**: run `claude setup-token` in your terminal, then paste the token (you can name it; blank = default).
- **OpenAI Codex OAuth (Codex CLI)**: if `~/.codex/auth.json` exists, the wizard can reuse it.
- **OpenAI Codex OAuth**: browser flow; paste the `code#state`.
- Sets `agent.model` to `openai-codex/gpt-5.2` when model is unset or `openai/*`.
@@ -120,7 +120,7 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
9) **Finish**
- Summary + next steps, including iOS/Android/macOS apps for extra features.
- If no GUI is detected, the wizard prints SSH port-forward instructions for the Control UI instead of opening a browser.
- If the Control UI assets are missing, the wizard attempts to build them; fallback is `pnpm ui:install && pnpm ui:build`.
- If the Control UI assets are missing, the wizard attempts to build them; fallback is `pnpm ui:build` (auto-installs UI deps).
## Remote mode

View File

@@ -1,5 +1,5 @@
---
summary: "Agent tool surface for Clawdbot (browser, canvas, nodes, cron) replacing legacy `clawdbot-*` skills"
summary: "Agent tool surface for Clawdbot (browser, canvas, nodes, message, cron) replacing legacy `clawdbot-*` skills"
read_when:
- Adding or modifying agent tools
- Retiring or changing `clawdbot-*` skills
@@ -148,6 +148,30 @@ Notes:
- Only available when `agent.imageModel` is configured (primary or fallbacks).
- Uses the image model directly (independent of the main chat model).
### `message`
Send messages and provider actions across Discord/Slack/Telegram/WhatsApp/Signal/iMessage.
Core actions:
- `send` (text + optional media)
- `poll` (WhatsApp/Discord polls)
- `react` / `reactions` / `read` / `edit` / `delete`
- `pin` / `unpin` / `list-pins`
- `permissions`
- `thread-create` / `thread-list` / `thread-reply`
- `search`
- `sticker`
- `member-info` / `role-info`
- `emoji-list` / `emoji-upload` / `sticker-upload`
- `role-add` / `role-remove`
- `channel-info` / `channel-list`
- `voice-status`
- `event-list` / `event-create`
- `timeout` / `kick` / `ban`
Notes:
- `send` routes WhatsApp via the Gateway; other providers go direct.
- `poll` uses the Gateway for WhatsApp and direct Discord API for Discord.
### `cron`
Manage Gateway cron jobs and wakeups.
@@ -171,6 +195,7 @@ Core actions:
Notes:
- Use `delayMs` (defaults to 2000) to avoid interrupting an in-flight reply.
- `restart` is disabled by default; enable with `commands.restart: true`.
### `sessions_list` / `sessions_history` / `sessions_send` / `sessions_spawn`
List sessions, inspect transcript history, or send to another session.
@@ -197,70 +222,6 @@ Notes:
- Result is restricted to per-agent allowlists (`routing.agents.<agentId>.subagents.allowAgents`).
- When `["*"]` is configured, the tool includes all configured agents and marks `allowAny: true`.
### `discord`
Send Discord reactions, stickers, or polls.
Core actions:
- `react` (`channelId`, `messageId`, `emoji`)
- `reactions` (`channelId`, `messageId`, optional `limit`)
- `sticker` (`to`, `stickerIds`, optional `content`)
- `poll` (`to`, `question`, `answers`, optional `allowMultiselect`, `durationHours`, `content`)
- `permissions` (`channelId`)
- `readMessages` (`channelId`, optional `limit`/`before`/`after`/`around`)
- `sendMessage` (`to`, `content`, optional `mediaUrl`, `replyTo`)
- `editMessage` (`channelId`, `messageId`, `content`)
- `deleteMessage` (`channelId`, `messageId`)
- `threadCreate` (`channelId`, `name`, optional `messageId`, `autoArchiveMinutes`)
- `threadList` (`guildId`, optional `channelId`, `includeArchived`, `before`, `limit`)
- `threadReply` (`channelId`, `content`, optional `mediaUrl`, `replyTo`)
- `pinMessage`/`unpinMessage` (`channelId`, `messageId`)
- `listPins` (`channelId`)
- `searchMessages` (`guildId`, `content`, optional `channelId`/`channelIds`, `authorId`/`authorIds`, `limit`)
- `memberInfo` (`guildId`, `userId`)
- `roleInfo` (`guildId`)
- `emojiList` (`guildId`)
- `roleAdd`/`roleRemove` (`guildId`, `userId`, `roleId`)
- `channelInfo` (`channelId`)
- `channelList` (`guildId`)
- `voiceStatus` (`guildId`, `userId`)
- `eventList` (`guildId`)
- `eventCreate` (`guildId`, `name`, `startTime`, optional `endTime`, `description`, `channelId`, `entityType`, `location`)
- `timeout` (`guildId`, `userId`, optional `durationMinutes`, `until`, `reason`)
- `kick` (`guildId`, `userId`, optional `reason`)
- `ban` (`guildId`, `userId`, optional `reason`, `deleteMessageDays`)
Notes:
- `to` accepts `channel:<id>` or `user:<id>`.
- Polls require 210 answers and default to 24 hours.
- `reactions` returns per-emoji user lists (limited to 100 per reaction).
- Reaction removal semantics: see [/tools/reactions](/tools/reactions).
- `discord.actions.*` gates Discord tool actions; `roles` + `moderation` default to `false`.
- `searchMessages` follows the Discord preview feature constraints (limit max 25, channel/author filters accept arrays).
- The tool is only exposed when the current provider is Discord.
### `whatsapp`
Send WhatsApp reactions.
Core actions:
- `react` (`chatJid`, `messageId`, `emoji`, optional `remove`, `participant`, `fromMe`, `accountId`)
Notes:
- Reaction removal semantics: see [/tools/reactions](/tools/reactions).
- `whatsapp.actions.*` gates WhatsApp tool actions.
- The tool is only exposed when the current provider is WhatsApp.
### `telegram`
Send Telegram messages or reactions.
Core actions:
- `sendMessage` (`to`, `content`, optional `mediaUrl`, `replyToMessageId`, `messageThreadId`)
- `react` (`chatId`, `messageId`, `emoji`, optional `remove`)
Notes:
- Reaction removal semantics: see [/tools/reactions](/tools/reactions).
- `telegram.actions.*` gates Telegram tool actions.
- The tool is only exposed when the current provider is Telegram.
## Parameters (common)
Gateway-backed tools (`canvas`, `nodes`, `cron`):

View File

@@ -18,6 +18,7 @@ Directives (`/think`, `/verbose`, `/reasoning`, `/elevated`) are parsed even whe
commands: {
native: false,
text: true,
restart: false,
useAccessGroups: true
}
}
@@ -55,6 +56,7 @@ Text-only:
Notes:
- Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`).
- `/cost` appends per-response token usage; it only shows dollar cost when the model uses an API key (OAuth hides cost).
- `/restart` is disabled by default; set `commands.restart: true` to enable it.
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
- `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats.

View File

@@ -74,8 +74,7 @@ Paste the token into the UI settings (sent as `connect.params.auth.token`).
The Gateway serves static files from `dist/control-ui`. Build them with:
```bash
pnpm ui:install
pnpm ui:build
pnpm ui:build # auto-installs UI deps on first run
```
Optional absolute base (when you want fixed asset URLs):
@@ -87,8 +86,7 @@ CLAWDBOT_CONTROL_UI_BASE_PATH=/clawdbot/ pnpm ui:build
For local development (separate dev server):
```bash
pnpm ui:install
pnpm ui:dev
pnpm ui:dev # auto-installs UI deps on first run
```
Then point the UI at your Gateway WS URL (e.g. `ws://127.0.0.1:18789`).

View File

@@ -101,6 +101,5 @@ Open:
The Gateway serves static files from `dist/control-ui`. Build them with:
```bash
pnpm ui:install
pnpm ui:build
pnpm ui:build # auto-installs UI deps on first run
```

View File

@@ -97,10 +97,10 @@
"@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.4",
"@mariozechner/pi-agent-core": "^0.40.0",
"@mariozechner/pi-ai": "^0.40.0",
"@mariozechner/pi-coding-agent": "^0.40.0",
"@mariozechner/pi-tui": "^0.40.0",
"@mariozechner/pi-agent-core": "^0.41.0",
"@mariozechner/pi-ai": "^0.41.0",
"@mariozechner/pi-coding-agent": "^0.41.0",
"@mariozechner/pi-tui": "^0.41.0",
"@sinclair/typebox": "0.34.47",
"@slack/bolt": "^4.6.0",
"@slack/web-api": "^7.13.0",

50
pnpm-lock.yaml generated
View File

@@ -32,17 +32,17 @@ importers:
specifier: ^1.3.4
version: 1.3.4
'@mariozechner/pi-agent-core':
specifier: ^0.40.0
version: 0.40.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
specifier: ^0.41.0
version: 0.41.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-ai':
specifier: ^0.40.0
version: 0.40.0(ws@8.19.0)(zod@4.3.5)
specifier: ^0.41.0
version: 0.41.0(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-coding-agent':
specifier: ^0.40.0
version: 0.40.0(ws@8.19.0)(zod@4.3.5)
specifier: ^0.41.0
version: 0.41.0(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui':
specifier: ^0.40.0
version: 0.40.0
specifier: ^0.41.0
version: 0.41.0
'@sinclair/typebox':
specifier: 0.34.47
version: 0.34.47
@@ -812,22 +812,22 @@ packages:
peerDependencies:
lit: ^3.3.1
'@mariozechner/pi-agent-core@0.40.0':
resolution: {integrity: sha512-l43rJlKJVTaKPIIMTKe6AHYLSN/6FU/zZ//uUK6BCp4CNJlcAN2iX4wdXC9t+QoAnpshJFheBP6kXS2ynFhxuw==}
'@mariozechner/pi-agent-core@0.41.0':
resolution: {integrity: sha512-eXmnMWCeRnSjvF5nbC8LbiOhdcSuUG/p+ZzfZqhfzkc5JMKGccGPnEHzXwfrVkJpyqL0rIWi9cG0yelVAat30A==}
engines: {node: '>=20.0.0'}
'@mariozechner/pi-ai@0.40.0':
resolution: {integrity: sha512-OiE6ir7bVEFVnXY/Jd4uIDMTOTdXpDlMpmJ8qXhlp5SlVzjiZkuPEJS3Hki8j4DnwdkPGMWyOX4kZi8FCrtBUA==}
'@mariozechner/pi-ai@0.41.0':
resolution: {integrity: sha512-ZcI+lFMbf35kQvppHa4hy5tu34GiH5WYwWxPD7BHm7AiYxPcytdP+0NiaJdLIRGSLZqKklXDDejbb6/QvOwI3w==}
engines: {node: '>=20.0.0'}
hasBin: true
'@mariozechner/pi-coding-agent@0.40.0':
resolution: {integrity: sha512-IUTZxZkNjnzoZmpjPODmAkM9K2Eoq8LBDqYB1LZwr/f3JQXWxQNCIKfEnhMnkBmjijQ/0kba1mS2G45tlMDMPA==}
'@mariozechner/pi-coding-agent@0.41.0':
resolution: {integrity: sha512-+x5tPGxjsT5d9u48xvTwayHW/v+w7L/zK1Oyyfhpu8qqSqkM5G5jeqK3tqQREG2YE+PwPSozmDtVzHYPrcNamA==}
engines: {node: '>=20.0.0'}
hasBin: true
'@mariozechner/pi-tui@0.40.0':
resolution: {integrity: sha512-fWp8hxpQq7PB2GxQN3dOCfy40e2kk3y0oPw9gSVsDxCjCeIZ1y9TYGHU8k2yrdz5I5B2TVpkvsjE6Z6Q5FdU1w==}
'@mariozechner/pi-tui@0.41.0':
resolution: {integrity: sha512-FxhNyQfsQvZJBbUIPbtvBzF8yJo2JjEXVksn5cUU8Qphw8z1Uf+bRXeleH7Q7VVvGnaH9zJR3r2cfkaWxC1Jig==}
engines: {node: '>=20.0.0'}
'@mistralai/mistralai@1.10.0':
@@ -3611,10 +3611,10 @@ snapshots:
transitivePeerDependencies:
- tailwindcss
'@mariozechner/pi-agent-core@0.40.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)':
'@mariozechner/pi-agent-core@0.41.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)':
dependencies:
'@mariozechner/pi-ai': 0.40.0(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui': 0.40.0
'@mariozechner/pi-ai': 0.41.0(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui': 0.41.0
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- bufferutil
@@ -3623,7 +3623,7 @@ snapshots:
- ws
- zod
'@mariozechner/pi-ai@0.40.0(ws@8.19.0)(zod@4.3.5)':
'@mariozechner/pi-ai@0.41.0(ws@8.19.0)(zod@4.3.5)':
dependencies:
'@anthropic-ai/sdk': 0.71.2(zod@4.3.5)
'@google/genai': 1.34.0
@@ -3643,12 +3643,12 @@ snapshots:
- ws
- zod
'@mariozechner/pi-coding-agent@0.40.0(ws@8.19.0)(zod@4.3.5)':
'@mariozechner/pi-coding-agent@0.41.0(ws@8.19.0)(zod@4.3.5)':
dependencies:
'@mariozechner/clipboard': 0.3.0
'@mariozechner/pi-agent-core': 0.40.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-ai': 0.40.0(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui': 0.40.0
'@mariozechner/pi-agent-core': 0.41.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-ai': 0.41.0(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui': 0.41.0
chalk: 5.6.2
cli-highlight: 2.1.11
diff: 8.0.2
@@ -3667,7 +3667,7 @@ snapshots:
- ws
- zod
'@mariozechner/pi-tui@0.40.0':
'@mariozechner/pi-tui@0.41.0':
dependencies:
'@types/mime-types': 2.1.4
chalk: 5.6.2

View File

@@ -19,7 +19,7 @@ export type AuthProfileHealthStatus =
export type AuthProfileHealth = {
profileId: string;
provider: string;
type: "oauth" | "api_key";
type: "oauth" | "token" | "api_key";
status: AuthProfileHealthStatus;
expiresAt?: number;
remainingMs?: number;
@@ -109,6 +109,39 @@ function buildProfileHealth(params: {
};
}
if (credential.type === "token") {
const expiresAt =
typeof credential.expires === "number" &&
Number.isFinite(credential.expires)
? credential.expires
: undefined;
if (!expiresAt || expiresAt <= 0) {
return {
profileId,
provider: credential.provider,
type: "token",
status: "static",
source,
label,
};
}
const { status, remainingMs } = resolveOAuthStatus(
expiresAt,
now,
warnAfterMs,
);
return {
profileId,
provider: credential.provider,
type: "token",
status,
expiresAt,
remainingMs,
source,
label,
};
}
const { status, remainingMs } = resolveOAuthStatus(
credential.expires,
now,
@@ -192,16 +225,18 @@ export function buildAuthHealthSummary(params: {
}
const oauthProfiles = provider.profiles.filter((p) => p.type === "oauth");
const tokenProfiles = provider.profiles.filter((p) => p.type === "token");
const apiKeyProfiles = provider.profiles.filter(
(p) => p.type === "api_key",
);
if (oauthProfiles.length === 0) {
const expirable = [...oauthProfiles, ...tokenProfiles];
if (expirable.length === 0) {
provider.status = apiKeyProfiles.length > 0 ? "static" : "missing";
continue;
}
const expiryCandidates = oauthProfiles
const expiryCandidates = expirable
.map((p) => p.expiresAt)
.filter((v): v is number => typeof v === "number" && Number.isFinite(v));
if (expiryCandidates.length > 0) {
@@ -209,7 +244,7 @@ export function buildAuthHealthSummary(params: {
provider.remainingMs = provider.expiresAt - now;
}
const statuses = oauthProfiles.map((p) => p.status);
const statuses = expirable.map((p) => p.status);
if (statuses.includes("expired") || statuses.includes("missing")) {
provider.status = "expired";
} else if (statuses.includes("expiring")) {

View File

@@ -428,7 +428,7 @@ describe("external CLI credential sync", () => {
);
expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined();
expect(
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { access: string }).access,
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { token: string }).token,
).toBe("fresh-access-token");
expect(
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { expires: number }).expires,
@@ -537,7 +537,7 @@ describe("external CLI credential sync", () => {
}
});
it("does not overwrite fresher store OAuth with older Claude CLI credentials", () => {
it("does not overwrite fresher store token with older Claude CLI credentials", () => {
const agentDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-cli-no-downgrade-"),
);
@@ -567,10 +567,9 @@ describe("external CLI credential sync", () => {
version: 1,
profiles: {
[CLAUDE_CLI_PROFILE_ID]: {
type: "oauth",
type: "token",
provider: "anthropic",
access: "store-access",
refresh: "store-refresh",
token: "store-access",
expires: Date.now() + 60 * 60 * 1000,
},
},
@@ -579,7 +578,7 @@ describe("external CLI credential sync", () => {
const store = ensureAuthProfileStore(agentDir);
expect(
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { access: string }).access,
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { token: string }).token,
).toBe("store-access");
} finally {
restoreHomeEnv(originalHome);

View File

@@ -48,13 +48,29 @@ export type ApiKeyCredential = {
email?: string;
};
export type TokenCredential = {
/**
* Static bearer-style token (often OAuth access token / PAT).
* Not refreshable by clawdbot (unlike `type: "oauth"`).
*/
type: "token";
provider: string;
token: string;
/** Optional expiry timestamp (ms since epoch). */
expires?: number;
email?: string;
};
export type OAuthCredential = OAuthCredentials & {
type: "oauth";
provider: OAuthProvider;
email?: string;
};
export type AuthProfileCredential = ApiKeyCredential | OAuthCredential;
export type AuthProfileCredential =
| ApiKeyCredential
| TokenCredential
| OAuthCredential;
/** Per-profile usage statistics for round-robin and cooldown tracking */
export type ProfileUsageStats = {
@@ -220,7 +236,13 @@ function coerceLegacyStore(raw: unknown): LegacyAuthStore | null {
for (const [key, value] of Object.entries(record)) {
if (!value || typeof value !== "object") continue;
const typed = value as Partial<AuthProfileCredential>;
if (typed.type !== "api_key" && typed.type !== "oauth") continue;
if (
typed.type !== "api_key" &&
typed.type !== "oauth" &&
typed.type !== "token"
) {
continue;
}
entries[key] = {
...typed,
provider: typed.provider ?? (key as OAuthProvider),
@@ -238,7 +260,13 @@ function coerceAuthStore(raw: unknown): AuthProfileStore | null {
for (const [key, value] of Object.entries(profiles)) {
if (!value || typeof value !== "object") continue;
const typed = value as Partial<AuthProfileCredential>;
if (typed.type !== "api_key" && typed.type !== "oauth") continue;
if (
typed.type !== "api_key" &&
typed.type !== "oauth" &&
typed.type !== "token"
) {
continue;
}
if (!typed.provider) continue;
normalized[key] = typed as AuthProfileCredential;
}
@@ -285,7 +313,7 @@ function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
*/
function readClaudeCliCredentials(options?: {
allowKeychainPrompt?: boolean;
}): OAuthCredential | null {
}): TokenCredential | null {
if (process.platform === "darwin" && options?.allowKeychainPrompt !== false) {
const keychainCreds = readClaudeCliKeychainCredentials();
if (keychainCreds) {
@@ -306,18 +334,15 @@ function readClaudeCliCredentials(options?: {
if (!claudeOauth || typeof claudeOauth !== "object") return null;
const accessToken = claudeOauth.accessToken;
const refreshToken = claudeOauth.refreshToken;
const expiresAt = claudeOauth.expiresAt;
if (typeof accessToken !== "string" || !accessToken) return null;
if (typeof refreshToken !== "string" || !refreshToken) return null;
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
return {
type: "oauth",
type: "token",
provider: "anthropic",
access: accessToken,
refresh: refreshToken,
token: accessToken,
expires: expiresAt,
};
}
@@ -326,7 +351,7 @@ function readClaudeCliCredentials(options?: {
* Read Claude Code credentials from macOS keychain.
* Uses the `security` CLI to access keychain without native dependencies.
*/
function readClaudeCliKeychainCredentials(): OAuthCredential | null {
function readClaudeCliKeychainCredentials(): TokenCredential | null {
try {
const result = execSync(
'security find-generic-password -s "Claude Code-credentials" -w',
@@ -338,18 +363,15 @@ function readClaudeCliKeychainCredentials(): OAuthCredential | null {
if (!claudeOauth || typeof claudeOauth !== "object") return null;
const accessToken = claudeOauth.accessToken;
const refreshToken = claudeOauth.refreshToken;
const expiresAt = claudeOauth.expiresAt;
if (typeof accessToken !== "string" || !accessToken) return null;
if (typeof refreshToken !== "string" || !refreshToken) return null;
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
return {
type: "oauth",
type: "token",
provider: "anthropic",
access: accessToken,
refresh: refreshToken,
token: accessToken,
expires: expiresAt,
};
} catch {
@@ -416,6 +438,20 @@ function shallowEqualOAuthCredentials(
);
}
function shallowEqualTokenCredentials(
a: TokenCredential | undefined,
b: TokenCredential,
): boolean {
if (!a) return false;
if (a.type !== "token") return false;
return (
a.provider === b.provider &&
a.token === b.token &&
a.expires === b.expires &&
a.email === b.email
);
}
/**
* Sync OAuth credentials from external CLI tools (Claude CLI, Codex CLI) into the store.
* This allows clawdbot to use the same credentials as these tools without requiring
@@ -434,25 +470,28 @@ function syncExternalCliCredentials(
const claudeCreds = readClaudeCliCredentials(options);
if (claudeCreds) {
const existing = store.profiles[CLAUDE_CLI_PROFILE_ID];
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
const existingToken = existing?.type === "token" ? existing : undefined;
// Update if: no existing profile, existing is not oauth, or CLI has newer/valid token
const shouldUpdate =
!existingOAuth ||
existingOAuth.provider !== "anthropic" ||
existingOAuth.expires <= now ||
(claudeCreds.expires > now &&
claudeCreds.expires > existingOAuth.expires);
!existingToken ||
existingToken.provider !== "anthropic" ||
(existingToken.expires ?? 0) <= now ||
((claudeCreds.expires ?? 0) > now &&
(claudeCreds.expires ?? 0) > (existingToken.expires ?? 0));
if (
shouldUpdate &&
!shallowEqualOAuthCredentials(existingOAuth, claudeCreds)
!shallowEqualTokenCredentials(existingToken, claudeCreds)
) {
store.profiles[CLAUDE_CLI_PROFILE_ID] = claudeCreds;
mutated = true;
log.info("synced anthropic credentials from claude cli", {
profileId: CLAUDE_CLI_PROFILE_ID,
expires: new Date(claudeCreds.expires).toISOString(),
expires:
typeof claudeCreds.expires === "number"
? new Date(claudeCreds.expires).toISOString()
: "unknown",
});
}
}
@@ -515,6 +554,16 @@ export function loadAuthProfileStore(): AuthProfileStore {
key: cred.key,
...(cred.email ? { email: cred.email } : {}),
};
} else if (cred.type === "token") {
store.profiles[profileId] = {
type: "token",
provider: cred.provider ?? (provider as OAuthProvider),
token: cred.token,
...(typeof cred.expires === "number"
? { expires: cred.expires }
: {}),
...(cred.email ? { email: cred.email } : {}),
};
} else {
store.profiles[profileId] = {
type: "oauth",
@@ -570,6 +619,16 @@ export function ensureAuthProfileStore(
key: cred.key,
...(cred.email ? { email: cred.email } : {}),
};
} else if (cred.type === "token") {
store.profiles[profileId] = {
type: "token",
provider: cred.provider ?? (provider as OAuthProvider),
token: cred.token,
...(typeof cred.expires === "number"
? { expires: cred.expires }
: {}),
...(cred.email ? { email: cred.email } : {}),
};
} else {
store.profiles[profileId] = {
type: "oauth",
@@ -882,16 +941,17 @@ function orderProfilesByMode(
// Then by lastUsed (oldest first = round-robin within type)
const scored = available.map((profileId) => {
const type = store.profiles[profileId]?.type;
const typeScore = type === "oauth" ? 0 : type === "api_key" ? 1 : 2;
const typeScore =
type === "oauth" ? 0 : type === "token" ? 1 : type === "api_key" ? 2 : 3;
const lastUsed = store.usageStats?.[profileId]?.lastUsed ?? 0;
return { profileId, typeScore, lastUsed };
});
// Primary sort: type preference (oauth > api_key).
// Primary sort: type preference (oauth > token > api_key).
// Secondary sort: lastUsed (oldest first for round-robin within type).
const sorted = scored
.sort((a, b) => {
// First by type (oauth > api_key)
// First by type (oauth > token > api_key)
if (a.typeScore !== b.typeScore) return a.typeScore - b.typeScore;
// Then by lastUsed (oldest first)
return a.lastUsed - b.lastUsed;
@@ -921,11 +981,27 @@ export async function resolveApiKeyForProfile(params: {
if (!cred) return null;
const profileConfig = cfg?.auth?.profiles?.[profileId];
if (profileConfig && profileConfig.provider !== cred.provider) return null;
if (profileConfig && profileConfig.mode !== cred.type) return null;
if (profileConfig && profileConfig.mode !== cred.type) {
// Compatibility: treat "oauth" config as compatible with stored token profiles.
if (!(profileConfig.mode === "oauth" && cred.type === "token")) return null;
}
if (cred.type === "api_key") {
return { apiKey: cred.key, provider: cred.provider, email: cred.email };
}
if (cred.type === "token") {
const token = cred.token?.trim();
if (!token) return null;
if (
typeof cred.expires === "number" &&
Number.isFinite(cred.expires) &&
cred.expires > 0 &&
Date.now() >= cred.expires
) {
return null;
}
return { apiKey: token, provider: cred.provider, email: cred.email };
}
if (Date.now() < cred.expires) {
return {
apiKey: buildOAuthApiKey(cred.provider, cred),

View File

@@ -4,8 +4,34 @@ import { runClaudeCliAgent } from "./claude-cli-runner.js";
const runCommandWithTimeoutMock = vi.fn();
function createDeferred<T>() {
let resolve: (value: T) => void;
let reject: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return {
promise,
resolve: resolve as (value: T) => void,
reject: reject as (error: unknown) => void,
};
}
async function waitForCalls(
mockFn: { mock: { calls: unknown[][] } },
count: number,
) {
for (let i = 0; i < 50; i += 1) {
if (mockFn.mock.calls.length >= count) return;
await new Promise((resolve) => setTimeout(resolve, 0));
}
throw new Error(`Expected ${count} calls, got ${mockFn.mock.calls.length}`);
}
vi.mock("../process/exec.js", () => ({
runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args),
runCommandWithTimeout: (...args: unknown[]) =>
runCommandWithTimeoutMock(...args),
}));
describe("runClaudeCliAgent", () => {
@@ -13,7 +39,7 @@ describe("runClaudeCliAgent", () => {
runCommandWithTimeoutMock.mockReset();
});
it("starts a new session without --session-id when no resume id", async () => {
it("starts a new session with --session-id when none is provided", async () => {
runCommandWithTimeoutMock.mockResolvedValueOnce({
stdout: JSON.stringify({ message: "ok", session_id: "sid-1" }),
stderr: "",
@@ -35,11 +61,11 @@ describe("runClaudeCliAgent", () => {
expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1);
const argv = runCommandWithTimeoutMock.mock.calls[0]?.[0] as string[];
expect(argv).toContain("claude");
expect(argv).not.toContain("--session-id");
expect(argv).not.toContain("--resume");
expect(argv).toContain("--session-id");
expect(argv).toContain("hi");
});
it("uses --resume when a resume session id is provided", async () => {
it("uses provided --session-id when a claude session id is provided", async () => {
runCommandWithTimeoutMock.mockResolvedValueOnce({
stdout: JSON.stringify({ message: "ok", session_id: "sid-2" }),
stderr: "",
@@ -56,13 +82,76 @@ describe("runClaudeCliAgent", () => {
model: "opus",
timeoutMs: 1_000,
runId: "run-2",
resumeSessionId: "sid-1",
claudeSessionId: "c9d7b831-1c31-4d22-80b9-1e50ca207d4b",
});
expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1);
const argv = runCommandWithTimeoutMock.mock.calls[0]?.[0] as string[];
expect(argv).toContain("--resume");
expect(argv).toContain("sid-1");
expect(argv).not.toContain("--session-id");
expect(argv).toContain("--session-id");
expect(argv).toContain("c9d7b831-1c31-4d22-80b9-1e50ca207d4b");
expect(argv).toContain("hi");
});
it("serializes concurrent claude-cli runs", async () => {
const firstDeferred = createDeferred<{
stdout: string;
stderr: string;
code: number | null;
signal: NodeJS.Signals | null;
killed: boolean;
}>();
const secondDeferred = createDeferred<{
stdout: string;
stderr: string;
code: number | null;
signal: NodeJS.Signals | null;
killed: boolean;
}>();
runCommandWithTimeoutMock
.mockImplementationOnce(() => firstDeferred.promise)
.mockImplementationOnce(() => secondDeferred.promise);
const firstRun = runClaudeCliAgent({
sessionId: "s1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
prompt: "first",
model: "opus",
timeoutMs: 1_000,
runId: "run-1",
});
const secondRun = runClaudeCliAgent({
sessionId: "s2",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
prompt: "second",
model: "opus",
timeoutMs: 1_000,
runId: "run-2",
});
await waitForCalls(runCommandWithTimeoutMock, 1);
firstDeferred.resolve({
stdout: JSON.stringify({ message: "ok", session_id: "sid-1" }),
stderr: "",
code: 0,
signal: null,
killed: false,
});
await waitForCalls(runCommandWithTimeoutMock, 2);
secondDeferred.resolve({
stdout: JSON.stringify({ message: "ok", session_id: "sid-2" }),
stderr: "",
code: 0,
signal: null,
killed: false,
});
await Promise.all([firstRun, secondRun]);
});
});

View File

@@ -1,9 +1,11 @@
import crypto from "node:crypto";
import os from "node:os";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { resolveHeartbeatPrompt } from "../auto-reply/heartbeat.js";
import type { ThinkLevel } from "../auto-reply/thinking.js";
import type { ClawdbotConfig } from "../config/config.js";
import { shouldLogVerbose } from "../globals.js";
import { createSubsystemLogger } from "../logging.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { resolveUserPath } from "../utils.js";
@@ -16,6 +18,23 @@ import { buildAgentSystemPrompt } from "./system-prompt.js";
import { loadWorkspaceBootstrapFiles } from "./workspace.js";
const log = createSubsystemLogger("agent/claude-cli");
const CLAUDE_CLI_QUEUE_KEY = "global";
const CLAUDE_CLI_RUN_QUEUE = new Map<string, Promise<unknown>>();
function enqueueClaudeCliRun<T>(
key: string,
task: () => Promise<T>,
): Promise<T> {
const prior = CLAUDE_CLI_RUN_QUEUE.get(key) ?? Promise.resolve();
const chained = prior.catch(() => undefined).then(task);
const tracked = chained.finally(() => {
if (CLAUDE_CLI_RUN_QUEUE.get(key) === tracked) {
CLAUDE_CLI_RUN_QUEUE.delete(key);
}
});
CLAUDE_CLI_RUN_QUEUE.set(key, tracked);
return chained;
}
type ClaudeCliUsage = {
input?: number;
@@ -31,6 +50,15 @@ type ClaudeCliOutput = {
usage?: ClaudeCliUsage;
};
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function normalizeClaudeSessionId(raw?: string): string {
const trimmed = raw?.trim();
if (trimmed && UUID_RE.test(trimmed)) return trimmed;
return crypto.randomUUID();
}
function resolveUserTimezone(configured?: string): string {
const trimmed = configured?.trim();
if (trimmed) {
@@ -207,7 +235,7 @@ async function runClaudeCliOnce(params: {
modelId: string;
systemPrompt: string;
timeoutMs: number;
resumeSessionId?: string;
sessionId: string;
}): Promise<ClaudeCliOutput> {
const args = [
"-p",
@@ -218,28 +246,79 @@ async function runClaudeCliOnce(params: {
"--append-system-prompt",
params.systemPrompt,
"--dangerously-skip-permissions",
"--permission-mode",
"dontAsk",
"--tools",
"",
"--session-id",
params.sessionId,
];
if (params.resumeSessionId) {
args.push("--resume", params.resumeSessionId);
}
args.push(params.prompt);
log.info(
`claude-cli exec: model=${normalizeClaudeCliModel(params.modelId)} promptChars=${params.prompt.length} systemPromptChars=${params.systemPrompt.length}`,
);
if (process.env.CLAWDBOT_CLAUDE_CLI_LOG_OUTPUT === "1") {
const logArgs: string[] = [];
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "--append-system-prompt") {
logArgs.push(arg, `<systemPrompt:${params.systemPrompt.length} chars>`);
i += 1;
continue;
}
if (arg === "--session-id") {
logArgs.push(arg, args[i + 1] ?? "");
i += 1;
continue;
}
logArgs.push(arg);
}
const promptIndex = logArgs.indexOf(params.prompt);
if (promptIndex >= 0) {
logArgs[promptIndex] = `<prompt:${params.prompt.length} chars>`;
}
log.info(`claude-cli argv: claude ${logArgs.join(" ")}`);
}
const result = await runCommandWithTimeout(["claude", ...args], {
timeoutMs: params.timeoutMs,
cwd: params.workspaceDir,
env: (() => {
const next = { ...process.env };
delete next.ANTHROPIC_API_KEY;
return next;
})(),
});
if (process.env.CLAWDBOT_CLAUDE_CLI_LOG_OUTPUT === "1") {
const stdoutDump = result.stdout.trim();
const stderrDump = result.stderr.trim();
if (stdoutDump) {
log.info(`claude-cli stdout:\n${stdoutDump}`);
}
if (stderrDump) {
log.info(`claude-cli stderr:\n${stderrDump}`);
}
}
const stdout = result.stdout.trim();
const logOutputText = process.env.CLAWDBOT_CLAUDE_CLI_LOG_OUTPUT === "1";
if (shouldLogVerbose()) {
if (stdout) {
log.debug(`claude-cli stdout:\n${stdout}`);
}
if (result.stderr.trim()) {
log.debug(`claude-cli stderr:\n${result.stderr.trim()}`);
}
}
if (result.code !== 0) {
const err = result.stderr.trim() || stdout || "Claude CLI failed.";
throw new Error(err);
}
const parsed = parseClaudeCliJson(stdout);
if (parsed) return parsed;
return { text: stdout };
const output = parsed ?? { text: stdout };
if (logOutputText) {
const text = output.text?.trim();
if (text) {
log.info(`claude-cli output:\n${text}`);
}
}
return output;
}
export async function runClaudeCliAgent(params: {
@@ -256,7 +335,7 @@ export async function runClaudeCliAgent(params: {
runId: string;
extraSystemPrompt?: string;
ownerNumbers?: string[];
resumeSessionId?: string;
claudeSessionId?: string;
}): Promise<EmbeddedPiRunResult> {
const started = Date.now();
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
@@ -285,29 +364,17 @@ export async function runClaudeCliAgent(params: {
modelDisplay,
});
let output: ClaudeCliOutput;
try {
output = await runClaudeCliOnce({
const claudeSessionId = normalizeClaudeSessionId(params.claudeSessionId);
const output = await enqueueClaudeCliRun(CLAUDE_CLI_QUEUE_KEY, () =>
runClaudeCliOnce({
prompt: params.prompt,
workspaceDir,
modelId,
systemPrompt,
timeoutMs: params.timeoutMs,
resumeSessionId: params.resumeSessionId,
});
} catch (err) {
if (!params.resumeSessionId) throw err;
log.warn(
`claude-cli resume failed for ${params.resumeSessionId}; retrying without resume`,
);
output = await runClaudeCliOnce({
prompt: params.prompt,
workspaceDir,
modelId,
systemPrompt,
timeoutMs: params.timeoutMs,
});
}
sessionId: claudeSessionId,
}),
);
const text = output.text?.trim();
const payloads = text ? [{ text }] : undefined;
@@ -317,7 +384,7 @@ export async function runClaudeCliAgent(params: {
meta: {
durationMs: Date.now() - started,
agentMeta: {
sessionId: output.sessionId ?? params.sessionId,
sessionId: output.sessionId ?? claudeSessionId,
provider: params.provider ?? "claude-cli",
model: modelId,
usage: output.usage,

View File

@@ -12,9 +12,9 @@ describe("gateway tool", () => {
const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
try {
const tool = createClawdbotTools().find(
(candidate) => candidate.name === "gateway",
);
const tool = createClawdbotTools({
config: { commands: { restart: true } },
}).find((candidate) => candidate.name === "gateway");
expect(tool).toBeDefined();
if (!tool) throw new Error("missing gateway tool");

View File

@@ -4,17 +4,14 @@ import { createBrowserTool } from "./tools/browser-tool.js";
import { createCanvasTool } from "./tools/canvas-tool.js";
import type { AnyAgentTool } from "./tools/common.js";
import { createCronTool } from "./tools/cron-tool.js";
import { createDiscordTool } from "./tools/discord-tool.js";
import { createGatewayTool } from "./tools/gateway-tool.js";
import { createImageTool } from "./tools/image-tool.js";
import { createMessageTool } from "./tools/message-tool.js";
import { createNodesTool } from "./tools/nodes-tool.js";
import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js";
import { createSessionsListTool } from "./tools/sessions-list-tool.js";
import { createSessionsSendTool } from "./tools/sessions-send-tool.js";
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
import { createSlackTool } from "./tools/slack-tool.js";
import { createTelegramTool } from "./tools/telegram-tool.js";
import { createWhatsAppTool } from "./tools/whatsapp-tool.js";
export function createClawdbotTools(options?: {
browserControlUrl?: string;
@@ -34,14 +31,14 @@ export function createClawdbotTools(options?: {
createCanvasTool(),
createNodesTool(),
createCronTool(),
createDiscordTool(),
createSlackTool({
createMessageTool({
agentAccountId: options?.agentAccountId,
config: options?.config,
}),
createTelegramTool(),
createWhatsAppTool(),
createGatewayTool({ agentSessionKey: options?.agentSessionKey }),
createGatewayTool({
agentSessionKey: options?.agentSessionKey,
config: options?.config,
}),
createAgentsListTool({ agentSessionKey: options?.agentSessionKey }),
createSessionsListTool({
agentSessionKey: options?.agentSessionKey,

View File

@@ -100,7 +100,7 @@ export async function resolveApiKeyForProvider(params: {
}
export type EnvApiKeyResult = { apiKey: string; source: string };
export type ModelAuthMode = "api-key" | "oauth" | "mixed" | "unknown";
export type ModelAuthMode = "api-key" | "oauth" | "token" | "mixed" | "unknown";
export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
const applied = new Set(getShellEnvAppliedKeys());
@@ -158,10 +158,14 @@ export function resolveModelAuthMode(
const modes = new Set(
profiles
.map((id) => authStore.profiles[id]?.type)
.filter((mode): mode is "api_key" | "oauth" => Boolean(mode)),
.filter((mode): mode is "api_key" | "oauth" | "token" => Boolean(mode)),
);
if (modes.has("oauth") && modes.has("api_key")) return "mixed";
const distinct = ["oauth", "token", "api_key"].filter((k) =>
modes.has(k as "oauth" | "token" | "api_key"),
);
if (distinct.length >= 2) return "mixed";
if (modes.has("oauth")) return "oauth";
if (modes.has("token")) return "token";
if (modes.has("api_key")) return "api-key";
}

View File

@@ -1,7 +1,11 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { buildAllowedModelSet, modelKey } from "./model-selection.js";
import {
buildAllowedModelSet,
modelKey,
parseModelRef,
} from "./model-selection.js";
const catalog = [
{
@@ -30,9 +34,9 @@ describe("buildAllowedModelSet", () => {
expect(allowed.allowAny).toBe(false);
expect(allowed.allowedKeys.has(modelKey("openai", "gpt-4"))).toBe(true);
expect(
allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5")),
).toBe(true);
expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe(
true,
);
});
it("includes the default model when no allowlist is set", () => {
@@ -49,8 +53,18 @@ describe("buildAllowedModelSet", () => {
expect(allowed.allowAny).toBe(true);
expect(allowed.allowedKeys.has(modelKey("openai", "gpt-4"))).toBe(true);
expect(
allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5")),
).toBe(true);
expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe(
true,
);
});
});
describe("parseModelRef", () => {
it("normalizes anthropic/opus-4.5 to claude-opus-4-5", () => {
const ref = parseModelRef("anthropic/opus-4.5", "anthropic");
expect(ref).toEqual({
provider: "anthropic",
model: "claude-opus-4-5",
});
});
});

View File

@@ -27,6 +27,15 @@ export function normalizeProviderId(provider: string): string {
return normalized;
}
function normalizeAnthropicModelId(model: string): string {
const trimmed = model.trim();
if (!trimmed) return trimmed;
const lower = trimmed.toLowerCase();
if (lower === "opus-4.5") return "claude-opus-4-5";
if (lower === "sonnet-4.5") return "claude-sonnet-4-5";
return trimmed;
}
export function parseModelRef(
raw: string,
defaultProvider: string,
@@ -35,13 +44,18 @@ export function parseModelRef(
if (!trimmed) return null;
const slash = trimmed.indexOf("/");
if (slash === -1) {
return { provider: normalizeProviderId(defaultProvider), model: trimmed };
const provider = normalizeProviderId(defaultProvider);
const model =
provider === "anthropic" ? normalizeAnthropicModelId(trimmed) : trimmed;
return { provider, model };
}
const providerRaw = trimmed.slice(0, slash).trim();
const provider = normalizeProviderId(providerRaw);
const model = trimmed.slice(slash + 1).trim();
if (!provider || !model) return null;
return { provider, model };
const normalizedModel =
provider === "anthropic" ? normalizeAnthropicModelId(model) : model;
return { provider, model: normalizedModel };
}
export function buildModelAliasIndex(params: {

View File

@@ -68,41 +68,45 @@ function createStubTool(name: string): AgentTool {
}
describe("splitSdkTools", () => {
// Tool names are now capitalized (Bash, Read, etc.) to bypass Anthropic OAuth blocking
const tools = [
createStubTool("read"),
createStubTool("bash"),
createStubTool("edit"),
createStubTool("write"),
createStubTool("Read"),
createStubTool("Bash"),
createStubTool("Edit"),
createStubTool("Write"),
createStubTool("browser"),
];
it("routes built-ins to custom tools when sandboxed", () => {
it("routes all tools to customTools when sandboxed", () => {
const { builtInTools, customTools } = splitSdkTools({
tools,
sandboxEnabled: true,
});
expect(builtInTools).toEqual([]);
expect(customTools.map((tool) => tool.name)).toEqual([
"read",
"bash",
"edit",
"write",
"Read",
"Bash",
"Edit",
"Write",
"browser",
]);
});
it("keeps built-ins as SDK tools when not sandboxed", () => {
it("routes all tools to customTools even when not sandboxed (for OAuth compatibility)", () => {
// All tools are now passed as customTools to bypass pi-coding-agent's
// built-in tool filtering, which expects lowercase names.
const { builtInTools, customTools } = splitSdkTools({
tools,
sandboxEnabled: false,
});
expect(builtInTools.map((tool) => tool.name)).toEqual([
"read",
"bash",
"edit",
"write",
expect(builtInTools).toEqual([]);
expect(customTools.map((tool) => tool.name)).toEqual([
"Read",
"Bash",
"Edit",
"Write",
"browser",
]);
expect(customTools.map((tool) => tool.name)).toEqual(["browser"]);
});
});

View File

@@ -612,7 +612,10 @@ export function createSystemPromptOverride(
return () => trimmed;
}
const BUILT_IN_TOOL_NAMES = new Set(["read", "bash", "edit", "write"]);
// Tool names are now capitalized (Bash, Read, Write, Edit) to bypass Anthropic's
// OAuth token blocking of lowercase names. However, pi-coding-agent's SDK has
// hardcoded lowercase names in its built-in tool registry, so we must pass ALL
// tools as customTools to bypass the SDK's filtering.
type AnyAgentTool = AgentTool;
@@ -623,19 +626,13 @@ export function splitSdkTools(options: {
builtInTools: AnyAgentTool[];
customTools: ReturnType<typeof toToolDefinitions>;
} {
// SDK rebuilds built-ins from cwd; route sandboxed versions as custom tools.
const { tools, sandboxEnabled } = options;
if (sandboxEnabled) {
return {
builtInTools: [],
customTools: toToolDefinitions(tools),
};
}
// Always pass all tools as customTools to bypass pi-coding-agent's built-in
// tool filtering, which expects lowercase names (bash, read, write, edit).
// Our tools are now capitalized (Bash, Read, Write, Edit) for OAuth compatibility.
const { tools } = options;
return {
builtInTools: tools.filter((tool) => BUILT_IN_TOOL_NAMES.has(tool.name)),
customTools: toToolDefinitions(
tools.filter((tool) => !BUILT_IN_TOOL_NAMES.has(tool.name)),
),
builtInTools: [],
customTools: toToolDefinitions(tools),
};
}

View File

@@ -313,12 +313,12 @@ describe("context-pruning", () => {
makeUser("u1"),
makeToolResult({
toolCallId: "t1",
toolName: "bash",
toolName: "Bash",
text: "x".repeat(20_000),
}),
makeToolResult({
toolCallId: "t2",
toolName: "browser",
toolName: "Browser",
text: "y".repeat(20_000),
}),
];

View File

@@ -2,7 +2,13 @@ import type { ContextPruningToolMatch } from "./settings.js";
function normalizePatterns(patterns?: string[]): string[] {
if (!Array.isArray(patterns)) return [];
return patterns.map((p) => String(p ?? "").trim()).filter(Boolean);
return patterns
.map((p) =>
String(p ?? "")
.trim()
.toLowerCase(),
)
.filter(Boolean);
}
type CompiledPattern =
@@ -39,8 +45,9 @@ export function makeToolPrunablePredicate(
const allow = compilePatterns(match.allow);
return (toolName: string) => {
if (matchesAny(toolName, deny)) return false;
const normalized = toolName.trim().toLowerCase();
if (matchesAny(normalized, deny)) return false;
if (allow.length === 0) return true;
return matchesAny(toolName, allow);
return matchesAny(normalized, allow);
};
}

View File

@@ -29,9 +29,9 @@ describe("Agent-specific tool filtering", () => {
});
const toolNames = tools.map((t) => t.name);
expect(toolNames).toContain("read");
expect(toolNames).toContain("write");
expect(toolNames).not.toContain("bash");
expect(toolNames).toContain("Read");
expect(toolNames).toContain("Write");
expect(toolNames).not.toContain("Bash");
});
it("should apply agent-specific tool policy", () => {
@@ -63,10 +63,10 @@ describe("Agent-specific tool filtering", () => {
});
const toolNames = tools.map((t) => t.name);
expect(toolNames).toContain("read");
expect(toolNames).not.toContain("bash");
expect(toolNames).not.toContain("write");
expect(toolNames).not.toContain("edit");
expect(toolNames).toContain("Read");
expect(toolNames).not.toContain("Bash");
expect(toolNames).not.toContain("Write");
expect(toolNames).not.toContain("Edit");
});
it("should allow different tool policies for different agents", () => {
@@ -96,9 +96,9 @@ describe("Agent-specific tool filtering", () => {
agentDir: "/tmp/agent-main",
});
const mainToolNames = mainTools.map((t) => t.name);
expect(mainToolNames).toContain("bash");
expect(mainToolNames).toContain("write");
expect(mainToolNames).toContain("edit");
expect(mainToolNames).toContain("Bash");
expect(mainToolNames).toContain("Write");
expect(mainToolNames).toContain("Edit");
// family agent: restricted
const familyTools = createClawdbotCodingTools({
@@ -108,10 +108,10 @@ describe("Agent-specific tool filtering", () => {
agentDir: "/tmp/agent-family",
});
const familyToolNames = familyTools.map((t) => t.name);
expect(familyToolNames).toContain("read");
expect(familyToolNames).not.toContain("bash");
expect(familyToolNames).not.toContain("write");
expect(familyToolNames).not.toContain("edit");
expect(familyToolNames).toContain("Read");
expect(familyToolNames).not.toContain("Bash");
expect(familyToolNames).not.toContain("Write");
expect(familyToolNames).not.toContain("Edit");
});
it("should prefer agent-specific tool policy over global", () => {
@@ -143,7 +143,7 @@ describe("Agent-specific tool filtering", () => {
const toolNames = tools.map((t) => t.name);
// Agent policy overrides global: browser is allowed again
expect(toolNames).toContain("browser");
expect(toolNames).not.toContain("bash");
expect(toolNames).not.toContain("Bash");
expect(toolNames).not.toContain("process");
});
@@ -209,9 +209,9 @@ describe("Agent-specific tool filtering", () => {
// Agent policy should be applied first, then sandbox
// Agent allows only "read", sandbox allows ["read", "write", "bash"]
// Result: only "read" (most restrictive wins)
expect(toolNames).toContain("read");
expect(toolNames).not.toContain("bash");
expect(toolNames).not.toContain("write");
expect(toolNames).toContain("Read");
expect(toolNames).not.toContain("Bash");
expect(toolNames).not.toContain("Write");
});
it("should run bash synchronously when process is denied", async () => {
@@ -229,7 +229,7 @@ describe("Agent-specific tool filtering", () => {
workspaceDir: "/tmp/test-main",
agentDir: "/tmp/agent-main",
});
const bash = tools.find((tool) => tool.name === "bash");
const bash = tools.find((tool) => tool.name === "Bash");
expect(bash).toBeDefined();
const result = await bash?.execute("call1", {

View File

@@ -66,7 +66,14 @@ describe("createClawdbotCodingTools", () => {
it("preserves action enums in normalized schemas", () => {
const tools = createClawdbotCodingTools();
const toolNames = ["browser", "canvas", "nodes", "cron", "gateway"];
const toolNames = [
"browser",
"canvas",
"nodes",
"cron",
"gateway",
"message",
];
const collectActionValues = (
schema: unknown,
@@ -110,7 +117,8 @@ describe("createClawdbotCodingTools", () => {
it("includes bash and process tools", () => {
const tools = createClawdbotCodingTools();
expect(tools.some((tool) => tool.name === "bash")).toBe(true);
// NOTE: bash/read/write/edit are capitalized to bypass Anthropic OAuth blocking
expect(tools.some((tool) => tool.name === "Bash")).toBe(true);
expect(tools.some((tool) => tool.name === "process")).toBe(true);
});
@@ -133,36 +141,13 @@ describe("createClawdbotCodingTools", () => {
expect(offenders).toEqual([]);
});
it("scopes discord tool to discord provider", () => {
const other = createClawdbotCodingTools({ messageProvider: "whatsapp" });
expect(other.some((tool) => tool.name === "discord")).toBe(false);
const discord = createClawdbotCodingTools({ messageProvider: "discord" });
expect(discord.some((tool) => tool.name === "discord")).toBe(true);
});
it("scopes slack tool to slack provider", () => {
const other = createClawdbotCodingTools({ messageProvider: "whatsapp" });
expect(other.some((tool) => tool.name === "slack")).toBe(false);
const slack = createClawdbotCodingTools({ messageProvider: "slack" });
expect(slack.some((tool) => tool.name === "slack")).toBe(true);
});
it("scopes telegram tool to telegram provider", () => {
const other = createClawdbotCodingTools({ messageProvider: "whatsapp" });
expect(other.some((tool) => tool.name === "telegram")).toBe(false);
const telegram = createClawdbotCodingTools({ messageProvider: "telegram" });
expect(telegram.some((tool) => tool.name === "telegram")).toBe(true);
});
it("scopes whatsapp tool to whatsapp provider", () => {
const other = createClawdbotCodingTools({ messageProvider: "slack" });
expect(other.some((tool) => tool.name === "whatsapp")).toBe(false);
const whatsapp = createClawdbotCodingTools({ messageProvider: "whatsapp" });
expect(whatsapp.some((tool) => tool.name === "whatsapp")).toBe(true);
it("does not expose provider-specific message tools", () => {
const tools = createClawdbotCodingTools({ messageProvider: "discord" });
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("discord")).toBe(false);
expect(names.has("slack")).toBe(false);
expect(names.has("telegram")).toBe(false);
expect(names.has("whatsapp")).toBe(false);
});
it("filters session tools for sub-agent sessions by default", () => {
@@ -175,8 +160,9 @@ describe("createClawdbotCodingTools", () => {
expect(names.has("sessions_send")).toBe(false);
expect(names.has("sessions_spawn")).toBe(false);
expect(names.has("read")).toBe(true);
expect(names.has("bash")).toBe(true);
// NOTE: bash/read/write/edit are capitalized to bypass Anthropic OAuth blocking
expect(names.has("Read")).toBe(true);
expect(names.has("Bash")).toBe(true);
expect(names.has("process")).toBe(true);
});
@@ -188,18 +174,21 @@ describe("createClawdbotCodingTools", () => {
agent: {
subagents: {
tools: {
// Policy matching is case-insensitive
allow: ["read"],
},
},
},
},
});
expect(tools.map((tool) => tool.name)).toEqual(["read"]);
// Tool names are capitalized for OAuth compatibility
expect(tools.map((tool) => tool.name)).toEqual(["Read"]);
});
it("keeps read tool image metadata intact", async () => {
const tools = createClawdbotCodingTools();
const readTool = tools.find((tool) => tool.name === "read");
// NOTE: read is capitalized to bypass Anthropic OAuth blocking
const readTool = tools.find((tool) => tool.name === "Read");
expect(readTool).toBeDefined();
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-read-"));
@@ -239,7 +228,8 @@ describe("createClawdbotCodingTools", () => {
it("returns text content without image blocks for text files", async () => {
const tools = createClawdbotCodingTools();
const readTool = tools.find((tool) => tool.name === "read");
// NOTE: read is capitalized to bypass Anthropic OAuth blocking
const readTool = tools.find((tool) => tool.name === "Read");
expect(readTool).toBeDefined();
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-read-"));
@@ -294,8 +284,10 @@ describe("createClawdbotCodingTools", () => {
},
};
const tools = createClawdbotCodingTools({ sandbox });
expect(tools.some((tool) => tool.name === "bash")).toBe(true);
expect(tools.some((tool) => tool.name === "read")).toBe(false);
// NOTE: bash/read are capitalized to bypass Anthropic OAuth blocking
// Policy matching is case-insensitive, so allow: ["bash"] matches tool named "Bash"
expect(tools.some((tool) => tool.name === "Bash")).toBe(true);
expect(tools.some((tool) => tool.name === "Read")).toBe(false);
expect(tools.some((tool) => tool.name === "browser")).toBe(false);
});
@@ -325,16 +317,18 @@ describe("createClawdbotCodingTools", () => {
},
};
const tools = createClawdbotCodingTools({ sandbox });
expect(tools.some((tool) => tool.name === "read")).toBe(true);
expect(tools.some((tool) => tool.name === "write")).toBe(false);
expect(tools.some((tool) => tool.name === "edit")).toBe(false);
// NOTE: read/write/edit are capitalized to bypass Anthropic OAuth blocking
expect(tools.some((tool) => tool.name === "Read")).toBe(true);
expect(tools.some((tool) => tool.name === "Write")).toBe(false);
expect(tools.some((tool) => tool.name === "Edit")).toBe(false);
});
it("filters tools by agent tool policy even without sandbox", () => {
const tools = createClawdbotCodingTools({
config: { agent: { tools: { deny: ["browser"] } } },
});
expect(tools.some((tool) => tool.name === "bash")).toBe(true);
// NOTE: bash is capitalized to bypass Anthropic OAuth blocking
expect(tools.some((tool) => tool.name === "Bash")).toBe(true);
expect(tools.some((tool) => tool.name === "browser")).toBe(false);
});
});

View File

@@ -399,6 +399,28 @@ function normalizeToolNames(list?: string[]) {
return list.map((entry) => entry.trim().toLowerCase()).filter(Boolean);
}
/**
* Anthropic blocks specific lowercase tool names (bash, read, write, edit) with OAuth tokens.
* Renaming to capitalized versions bypasses the block while maintaining compatibility
* with regular API keys.
*/
const OAUTH_BLOCKED_TOOL_NAMES: Record<string, string> = {
bash: "Bash",
read: "Read",
write: "Write",
edit: "Edit",
};
function renameBlockedToolsForOAuth(tools: AnyAgentTool[]): AnyAgentTool[] {
return tools.map((tool) => {
const newName = OAUTH_BLOCKED_TOOL_NAMES[tool.name];
if (newName) {
return { ...tool, name: newName };
}
return tool;
});
}
const DEFAULT_SUBAGENT_TOOL_DENY = [
"sessions_list",
"sessions_history",
@@ -591,37 +613,6 @@ function createClawdbotReadTool(base: AnyAgentTool): AnyAgentTool {
};
}
function normalizeMessageProvider(
messageProvider?: string,
): string | undefined {
const trimmed = messageProvider?.trim().toLowerCase();
return trimmed ? trimmed : undefined;
}
function shouldIncludeDiscordTool(messageProvider?: string): boolean {
const normalized = normalizeMessageProvider(messageProvider);
if (!normalized) return false;
return normalized === "discord" || normalized.startsWith("discord:");
}
function shouldIncludeSlackTool(messageProvider?: string): boolean {
const normalized = normalizeMessageProvider(messageProvider);
if (!normalized) return false;
return normalized === "slack" || normalized.startsWith("slack:");
}
function shouldIncludeTelegramTool(messageProvider?: string): boolean {
const normalized = normalizeMessageProvider(messageProvider);
if (!normalized) return false;
return normalized === "telegram" || normalized.startsWith("telegram:");
}
function shouldIncludeWhatsAppTool(messageProvider?: string): boolean {
const normalized = normalizeMessageProvider(messageProvider);
if (!normalized) return false;
return normalized === "whatsapp" || normalized.startsWith("whatsapp:");
}
export function createClawdbotCodingTools(options?: {
bash?: BashToolDefaults & ProcessToolDefaults;
messageProvider?: string;
@@ -702,20 +693,9 @@ export function createClawdbotCodingTools(options?: {
config: options?.config,
}),
];
const allowDiscord = shouldIncludeDiscordTool(options?.messageProvider);
const allowSlack = shouldIncludeSlackTool(options?.messageProvider);
const allowTelegram = shouldIncludeTelegramTool(options?.messageProvider);
const allowWhatsApp = shouldIncludeWhatsAppTool(options?.messageProvider);
const filtered = tools.filter((tool) => {
if (tool.name === "discord") return allowDiscord;
if (tool.name === "slack") return allowSlack;
if (tool.name === "telegram") return allowTelegram;
if (tool.name === "whatsapp") return allowWhatsApp;
return true;
});
const toolsFiltered = effectiveToolsPolicy
? filterToolsByPolicy(filtered, effectiveToolsPolicy)
: filtered;
? filterToolsByPolicy(tools, effectiveToolsPolicy)
: tools;
const sandboxed = sandbox
? filterToolsByPolicy(toolsFiltered, sandbox.tools)
: toolsFiltered;
@@ -724,5 +704,9 @@ export function createClawdbotCodingTools(options?: {
: sandboxed;
// Always normalize tool JSON Schemas before handing them to pi-agent/pi-ai.
// Without this, some providers (notably OpenAI) will reject root-level union schemas.
return subagentFiltered.map(normalizeToolParameters);
const normalized = subagentFiltered.map(normalizeToolParameters);
// Anthropic blocks specific lowercase tool names (bash, read, write, edit) with OAuth tokens.
// Always use capitalized versions for compatibility with both OAuth and regular API keys.
return renameBlockedToolsForOAuth(normalized);
}

View File

@@ -150,6 +150,43 @@
"restart": { "label": "restart", "detailKeys": ["reason", "delayMs"] }
}
},
"message": {
"emoji": "✉️",
"title": "Message",
"actions": {
"send": { "label": "send", "detailKeys": ["provider", "to", "media", "replyTo", "threadId"] },
"poll": { "label": "poll", "detailKeys": ["provider", "to", "pollQuestion"] },
"react": { "label": "react", "detailKeys": ["provider", "to", "messageId", "emoji", "remove"] },
"reactions": { "label": "reactions", "detailKeys": ["provider", "to", "messageId", "limit"] },
"read": { "label": "read", "detailKeys": ["provider", "to", "limit"] },
"edit": { "label": "edit", "detailKeys": ["provider", "to", "messageId"] },
"delete": { "label": "delete", "detailKeys": ["provider", "to", "messageId"] },
"pin": { "label": "pin", "detailKeys": ["provider", "to", "messageId"] },
"unpin": { "label": "unpin", "detailKeys": ["provider", "to", "messageId"] },
"list-pins": { "label": "list pins", "detailKeys": ["provider", "to"] },
"permissions": { "label": "permissions", "detailKeys": ["provider", "channelId", "to"] },
"thread-create": { "label": "thread create", "detailKeys": ["provider", "channelId", "threadName"] },
"thread-list": { "label": "thread list", "detailKeys": ["provider", "guildId", "channelId"] },
"thread-reply": { "label": "thread reply", "detailKeys": ["provider", "channelId", "messageId"] },
"search": { "label": "search", "detailKeys": ["provider", "guildId", "query"] },
"sticker": { "label": "sticker", "detailKeys": ["provider", "to", "stickerId"] },
"member-info": { "label": "member", "detailKeys": ["provider", "guildId", "userId"] },
"role-info": { "label": "roles", "detailKeys": ["provider", "guildId"] },
"emoji-list": { "label": "emoji list", "detailKeys": ["provider", "guildId"] },
"emoji-upload": { "label": "emoji upload", "detailKeys": ["provider", "guildId", "emojiName"] },
"sticker-upload": { "label": "sticker upload", "detailKeys": ["provider", "guildId", "stickerName"] },
"role-add": { "label": "role add", "detailKeys": ["provider", "guildId", "userId", "roleId"] },
"role-remove": { "label": "role remove", "detailKeys": ["provider", "guildId", "userId", "roleId"] },
"channel-info": { "label": "channel", "detailKeys": ["provider", "channelId"] },
"channel-list": { "label": "channels", "detailKeys": ["provider", "guildId"] },
"voice-status": { "label": "voice", "detailKeys": ["provider", "guildId", "userId"] },
"event-list": { "label": "events", "detailKeys": ["provider", "guildId"] },
"event-create": { "label": "event create", "detailKeys": ["provider", "guildId", "eventName"] },
"timeout": { "label": "timeout", "detailKeys": ["provider", "guildId", "userId"] },
"kick": { "label": "kick", "detailKeys": ["provider", "guildId", "userId"] },
"ban": { "label": "ban", "detailKeys": ["provider", "guildId", "userId"] }
}
},
"agents_list": {
"emoji": "🧭",
"title": "Agents",
@@ -182,77 +219,6 @@
"start": { "label": "start" },
"wait": { "label": "wait" }
}
},
"discord": {
"emoji": "💬",
"title": "Discord",
"actions": {
"react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji", "remove"] },
"reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] },
"sticker": { "label": "sticker", "detailKeys": ["to", "stickerIds"] },
"poll": { "label": "poll", "detailKeys": ["question", "to"] },
"permissions": { "label": "permissions", "detailKeys": ["channelId"] },
"readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] },
"sendMessage": { "label": "send", "detailKeys": ["to", "content"] },
"editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] },
"deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] },
"threadCreate": { "label": "thread create", "detailKeys": ["channelId", "name"] },
"threadList": { "label": "thread list", "detailKeys": ["guildId", "channelId"] },
"threadReply": { "label": "thread reply", "detailKeys": ["channelId", "content"] },
"pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] },
"unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] },
"listPins": { "label": "list pins", "detailKeys": ["channelId"] },
"searchMessages": { "label": "search", "detailKeys": ["guildId", "content"] },
"memberInfo": { "label": "member", "detailKeys": ["guildId", "userId"] },
"roleInfo": { "label": "roles", "detailKeys": ["guildId"] },
"emojiList": { "label": "emoji list", "detailKeys": ["guildId"] },
"emojiUpload": { "label": "emoji upload", "detailKeys": ["guildId", "name"] },
"stickerUpload": { "label": "sticker upload", "detailKeys": ["guildId", "name"] },
"roleAdd": { "label": "role add", "detailKeys": ["guildId", "userId", "roleId"] },
"roleRemove": { "label": "role remove", "detailKeys": ["guildId", "userId", "roleId"] },
"channelInfo": { "label": "channel", "detailKeys": ["channelId"] },
"channelList": { "label": "channels", "detailKeys": ["guildId"] },
"voiceStatus": { "label": "voice", "detailKeys": ["guildId", "userId"] },
"eventList": { "label": "events", "detailKeys": ["guildId"] },
"eventCreate": { "label": "event create", "detailKeys": ["guildId", "name"] },
"timeout": { "label": "timeout", "detailKeys": ["guildId", "userId"] },
"kick": { "label": "kick", "detailKeys": ["guildId", "userId"] },
"ban": { "label": "ban", "detailKeys": ["guildId", "userId"] }
}
},
"slack": {
"emoji": "💬",
"title": "Slack",
"actions": {
"react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji", "remove"] },
"reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] },
"sendMessage": { "label": "send", "detailKeys": ["to", "content"] },
"editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] },
"deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] },
"readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] },
"pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] },
"unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] },
"listPins": { "label": "list pins", "detailKeys": ["channelId"] },
"memberInfo": { "label": "member", "detailKeys": ["userId"] },
"emojiList": { "label": "emoji list" }
}
},
"telegram": {
"emoji": "✈️",
"title": "Telegram",
"actions": {
"react": { "label": "react", "detailKeys": ["chatId", "messageId", "emoji", "remove"] }
}
},
"whatsapp": {
"emoji": "💬",
"title": "WhatsApp",
"actions": {
"react": {
"label": "react",
"detailKeys": ["chatJid", "messageId", "emoji", "remove", "participant", "accountId", "fromMe"]
}
}
}
}
}

View File

@@ -1,5 +1,6 @@
import { Type } from "@sinclair/typebox";
import type { ClawdbotConfig } from "../../config/config.js";
import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
import { callGatewayTool } from "./gateway.js";
@@ -45,6 +46,7 @@ const GatewayToolSchema = Type.Union([
export function createGatewayTool(opts?: {
agentSessionKey?: string;
config?: ClawdbotConfig;
}): AnyAgentTool {
return {
label: "Gateway",
@@ -56,6 +58,11 @@ export function createGatewayTool(opts?: {
const params = args as Record<string, unknown>;
const action = readStringParam(params, "action", { required: true });
if (action === "restart") {
if (opts?.config?.commands?.restart !== true) {
throw new Error(
"Gateway restart is disabled. Set commands.restart=true to enable.",
);
}
const delayMs =
typeof params.delayMs === "number" && Number.isFinite(params.delayMs)
? Math.floor(params.delayMs)
@@ -64,6 +71,9 @@ export function createGatewayTool(opts?: {
typeof params.reason === "string" && params.reason.trim()
? params.reason.trim().slice(0, 200)
: undefined;
console.info(
`gateway tool: restart requested (delayMs=${delayMs ?? "default"}, reason=${reason ?? "none"})`,
);
const scheduled = scheduleGatewaySigusr1Restart({
delayMs,
reason,

View File

@@ -0,0 +1,916 @@
import { Type } from "@sinclair/typebox";
import type { ClawdbotConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
import {
type MessagePollResult,
type MessageSendResult,
sendMessage,
sendPoll,
} from "../../infra/outbound/message.js";
import { resolveMessageProviderSelection } from "../../infra/outbound/provider-selection.js";
import { normalizeAccountId } from "../../routing/session-key.js";
import type { AnyAgentTool } from "./common.js";
import {
jsonResult,
readNumberParam,
readStringArrayParam,
readStringParam,
} from "./common.js";
import { handleDiscordAction } from "./discord-actions.js";
import { handleSlackAction } from "./slack-actions.js";
import { handleTelegramAction } from "./telegram-actions.js";
import { handleWhatsAppAction } from "./whatsapp-actions.js";
const MessageActionSchema = Type.Union([
Type.Literal("send"),
Type.Literal("poll"),
Type.Literal("react"),
Type.Literal("reactions"),
Type.Literal("read"),
Type.Literal("edit"),
Type.Literal("delete"),
Type.Literal("pin"),
Type.Literal("unpin"),
Type.Literal("list-pins"),
Type.Literal("permissions"),
Type.Literal("thread-create"),
Type.Literal("thread-list"),
Type.Literal("thread-reply"),
Type.Literal("search"),
Type.Literal("sticker"),
Type.Literal("member-info"),
Type.Literal("role-info"),
Type.Literal("emoji-list"),
Type.Literal("emoji-upload"),
Type.Literal("sticker-upload"),
Type.Literal("role-add"),
Type.Literal("role-remove"),
Type.Literal("channel-info"),
Type.Literal("channel-list"),
Type.Literal("voice-status"),
Type.Literal("event-list"),
Type.Literal("event-create"),
Type.Literal("timeout"),
Type.Literal("kick"),
Type.Literal("ban"),
]);
const MessageToolSchema = Type.Object({
action: MessageActionSchema,
provider: Type.Optional(Type.String()),
to: Type.Optional(Type.String()),
message: Type.Optional(Type.String()),
media: Type.Optional(Type.String()),
messageId: Type.Optional(Type.String()),
replyTo: Type.Optional(Type.String()),
threadId: Type.Optional(Type.String()),
accountId: Type.Optional(Type.String()),
dryRun: Type.Optional(Type.Boolean()),
bestEffort: Type.Optional(Type.Boolean()),
gifPlayback: Type.Optional(Type.Boolean()),
emoji: Type.Optional(Type.String()),
remove: Type.Optional(Type.Boolean()),
limit: Type.Optional(Type.Number()),
before: Type.Optional(Type.String()),
after: Type.Optional(Type.String()),
around: Type.Optional(Type.String()),
pollQuestion: Type.Optional(Type.String()),
pollOption: Type.Optional(Type.Array(Type.String())),
pollDurationHours: Type.Optional(Type.Number()),
pollMulti: Type.Optional(Type.Boolean()),
channelId: Type.Optional(Type.String()),
channelIds: Type.Optional(Type.Array(Type.String())),
guildId: Type.Optional(Type.String()),
userId: Type.Optional(Type.String()),
authorId: Type.Optional(Type.String()),
authorIds: Type.Optional(Type.Array(Type.String())),
roleId: Type.Optional(Type.String()),
roleIds: Type.Optional(Type.Array(Type.String())),
emojiName: Type.Optional(Type.String()),
stickerId: Type.Optional(Type.Array(Type.String())),
stickerName: Type.Optional(Type.String()),
stickerDesc: Type.Optional(Type.String()),
stickerTags: Type.Optional(Type.String()),
threadName: Type.Optional(Type.String()),
autoArchiveMin: Type.Optional(Type.Number()),
query: Type.Optional(Type.String()),
eventName: Type.Optional(Type.String()),
eventType: Type.Optional(Type.String()),
startTime: Type.Optional(Type.String()),
endTime: Type.Optional(Type.String()),
desc: Type.Optional(Type.String()),
location: Type.Optional(Type.String()),
durationMin: Type.Optional(Type.Number()),
until: Type.Optional(Type.String()),
reason: Type.Optional(Type.String()),
deleteDays: Type.Optional(Type.Number()),
includeArchived: Type.Optional(Type.Boolean()),
participant: Type.Optional(Type.String()),
fromMe: Type.Optional(Type.Boolean()),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
});
type MessageToolOptions = {
agentAccountId?: string;
config?: ClawdbotConfig;
};
function resolveAgentAccountId(value?: string): string | undefined {
const trimmed = value?.trim();
if (!trimmed) return undefined;
return normalizeAccountId(trimmed);
}
export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
const agentAccountId = resolveAgentAccountId(options?.agentAccountId);
return {
label: "Message",
name: "message",
description:
"Send messages and provider-specific actions (Discord/Slack/Telegram/WhatsApp/Signal/iMessage).",
parameters: MessageToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const cfg = options?.config ?? loadConfig();
const action = readStringParam(params, "action", { required: true });
const providerSelection = await resolveMessageProviderSelection({
cfg,
provider: readStringParam(params, "provider"),
});
const provider = providerSelection.provider;
const accountId = readStringParam(params, "accountId") ?? agentAccountId;
const gateway = {
url: readStringParam(params, "gatewayUrl", { trim: false }),
token: readStringParam(params, "gatewayToken", { trim: false }),
timeoutMs: readNumberParam(params, "timeoutMs"),
clientName: "agent" as const,
mode: "agent" as const,
};
const dryRun = Boolean(params.dryRun);
if (action === "send") {
const to = readStringParam(params, "to", { required: true });
const message = readStringParam(params, "message", {
required: true,
allowEmpty: true,
});
const mediaUrl = readStringParam(params, "media", { trim: false });
const replyTo = readStringParam(params, "replyTo");
const threadId = readStringParam(params, "threadId");
const gifPlayback =
typeof params.gifPlayback === "boolean" ? params.gifPlayback : false;
const bestEffort =
typeof params.bestEffort === "boolean"
? params.bestEffort
: undefined;
if (dryRun) {
const result: MessageSendResult = await sendMessage({
to,
content: message,
mediaUrl: mediaUrl || undefined,
provider: provider || undefined,
accountId: accountId ?? undefined,
gifPlayback,
dryRun,
bestEffort,
gateway,
});
return jsonResult(result);
}
if (provider === "discord") {
return await handleDiscordAction(
{
action: "sendMessage",
to,
content: message,
mediaUrl: mediaUrl ?? undefined,
replyTo: replyTo ?? undefined,
},
cfg,
);
}
if (provider === "slack") {
return await handleSlackAction(
{
action: "sendMessage",
to,
content: message,
mediaUrl: mediaUrl ?? undefined,
accountId: accountId ?? undefined,
threadTs: threadId ?? replyTo ?? undefined,
},
cfg,
);
}
if (provider === "telegram") {
return await handleTelegramAction(
{
action: "sendMessage",
to,
content: message,
mediaUrl: mediaUrl ?? undefined,
replyToMessageId: replyTo ?? undefined,
messageThreadId: threadId ?? undefined,
},
cfg,
);
}
const result: MessageSendResult = await sendMessage({
to,
content: message,
mediaUrl: mediaUrl || undefined,
provider: provider || undefined,
accountId: accountId ?? undefined,
gifPlayback,
dryRun,
bestEffort,
gateway,
});
return jsonResult(result);
}
if (action === "poll") {
const to = readStringParam(params, "to", { required: true });
const question = readStringParam(params, "pollQuestion", {
required: true,
});
const options =
readStringArrayParam(params, "pollOption", { required: true }) ?? [];
const allowMultiselect =
typeof params.pollMulti === "boolean" ? params.pollMulti : undefined;
const durationHours = readNumberParam(params, "pollDurationHours", {
integer: true,
});
if (dryRun) {
const maxSelections = allowMultiselect
? Math.max(2, options.length)
: 1;
const result: MessagePollResult = await sendPoll({
to,
question,
options,
maxSelections,
durationHours: durationHours ?? undefined,
provider,
dryRun,
gateway,
});
return jsonResult(result);
}
if (provider === "discord") {
return await handleDiscordAction(
{
action: "poll",
to,
question,
answers: options,
allowMultiselect,
durationHours: durationHours ?? undefined,
content: readStringParam(params, "message"),
},
cfg,
);
}
const maxSelections = allowMultiselect
? Math.max(2, options.length)
: 1;
const result: MessagePollResult = await sendPoll({
to,
question,
options,
maxSelections,
durationHours: durationHours ?? undefined,
provider,
dryRun,
gateway,
});
return jsonResult(result);
}
const resolveChannelId = (label: string) =>
readStringParam(params, label) ??
readStringParam(params, "to", { required: true });
const resolveChatId = (label: string) =>
readStringParam(params, label) ??
readStringParam(params, "to", { required: true });
if (action === "react") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
const remove =
typeof params.remove === "boolean" ? params.remove : undefined;
if (provider === "discord") {
return await handleDiscordAction(
{
action: "react",
channelId: resolveChannelId("channelId"),
messageId,
emoji,
remove,
},
cfg,
);
}
if (provider === "slack") {
return await handleSlackAction(
{
action: "react",
channelId: resolveChannelId("channelId"),
messageId,
emoji,
remove,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (provider === "telegram") {
return await handleTelegramAction(
{
action: "react",
chatId: resolveChatId("chatId"),
messageId,
emoji,
remove,
},
cfg,
);
}
if (provider === "whatsapp") {
return await handleWhatsAppAction(
{
action: "react",
chatJid: resolveChatId("chatJid"),
messageId,
emoji,
remove,
participant: readStringParam(params, "participant"),
accountId: accountId ?? undefined,
fromMe:
typeof params.fromMe === "boolean" ? params.fromMe : undefined,
},
cfg,
);
}
throw new Error(`React is not supported for provider ${provider}.`);
}
if (action === "reactions") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
const limit = readNumberParam(params, "limit", { integer: true });
if (provider === "discord") {
return await handleDiscordAction(
{
action: "reactions",
channelId: resolveChannelId("channelId"),
messageId,
limit,
},
cfg,
);
}
if (provider === "slack") {
return await handleSlackAction(
{
action: "reactions",
channelId: resolveChannelId("channelId"),
messageId,
limit,
accountId: accountId ?? undefined,
},
cfg,
);
}
throw new Error(
`Reactions are not supported for provider ${provider}.`,
);
}
if (action === "read") {
const limit = readNumberParam(params, "limit", { integer: true });
const before = readStringParam(params, "before");
const after = readStringParam(params, "after");
const around = readStringParam(params, "around");
if (provider === "discord") {
return await handleDiscordAction(
{
action: "readMessages",
channelId: resolveChannelId("channelId"),
limit,
before,
after,
around,
},
cfg,
);
}
if (provider === "slack") {
return await handleSlackAction(
{
action: "readMessages",
channelId: resolveChannelId("channelId"),
limit,
before,
after,
accountId: accountId ?? undefined,
},
cfg,
);
}
throw new Error(`Read is not supported for provider ${provider}.`);
}
if (action === "edit") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
const message = readStringParam(params, "message", { required: true });
if (provider === "discord") {
return await handleDiscordAction(
{
action: "editMessage",
channelId: resolveChannelId("channelId"),
messageId,
content: message,
},
cfg,
);
}
if (provider === "slack") {
return await handleSlackAction(
{
action: "editMessage",
channelId: resolveChannelId("channelId"),
messageId,
content: message,
accountId: accountId ?? undefined,
},
cfg,
);
}
throw new Error(`Edit is not supported for provider ${provider}.`);
}
if (action === "delete") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
if (provider === "discord") {
return await handleDiscordAction(
{
action: "deleteMessage",
channelId: resolveChannelId("channelId"),
messageId,
},
cfg,
);
}
if (provider === "slack") {
return await handleSlackAction(
{
action: "deleteMessage",
channelId: resolveChannelId("channelId"),
messageId,
accountId: accountId ?? undefined,
},
cfg,
);
}
throw new Error(`Delete is not supported for provider ${provider}.`);
}
if (action === "pin" || action === "unpin" || action === "list-pins") {
const messageId =
action === "list-pins"
? undefined
: readStringParam(params, "messageId", { required: true });
const channelId = resolveChannelId("channelId");
if (provider === "discord") {
const discordAction =
action === "pin"
? "pinMessage"
: action === "unpin"
? "unpinMessage"
: "listPins";
return await handleDiscordAction(
{
action: discordAction,
channelId,
messageId,
},
cfg,
);
}
if (provider === "slack") {
const slackAction =
action === "pin"
? "pinMessage"
: action === "unpin"
? "unpinMessage"
: "listPins";
return await handleSlackAction(
{
action: slackAction,
channelId,
messageId,
accountId: accountId ?? undefined,
},
cfg,
);
}
throw new Error(`Pins are not supported for provider ${provider}.`);
}
if (action === "permissions") {
if (provider !== "discord") {
throw new Error(
`Permissions are only supported for Discord (provider=${provider}).`,
);
}
return await handleDiscordAction(
{
action: "permissions",
channelId: resolveChannelId("channelId"),
},
cfg,
);
}
if (action === "thread-create") {
if (provider !== "discord") {
throw new Error(
`Thread create is only supported for Discord (provider=${provider}).`,
);
}
const name = readStringParam(params, "threadName", { required: true });
const messageId = readStringParam(params, "messageId");
const autoArchiveMinutes = readNumberParam(params, "autoArchiveMin", {
integer: true,
});
return await handleDiscordAction(
{
action: "threadCreate",
channelId: resolveChannelId("channelId"),
name,
messageId,
autoArchiveMinutes,
},
cfg,
);
}
if (action === "thread-list") {
if (provider !== "discord") {
throw new Error(
`Thread list is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
const channelId = readStringParam(params, "channelId");
const includeArchived =
typeof params.includeArchived === "boolean"
? params.includeArchived
: undefined;
const before = readStringParam(params, "before");
const limit = readNumberParam(params, "limit", { integer: true });
return await handleDiscordAction(
{
action: "threadList",
guildId,
channelId,
includeArchived,
before,
limit,
},
cfg,
);
}
if (action === "thread-reply") {
if (provider !== "discord") {
throw new Error(
`Thread reply is only supported for Discord (provider=${provider}).`,
);
}
const content = readStringParam(params, "message", { required: true });
const mediaUrl = readStringParam(params, "media", { trim: false });
const replyTo = readStringParam(params, "replyTo");
return await handleDiscordAction(
{
action: "threadReply",
channelId: resolveChannelId("channelId"),
content,
mediaUrl: mediaUrl ?? undefined,
replyTo: replyTo ?? undefined,
},
cfg,
);
}
if (action === "search") {
if (provider !== "discord") {
throw new Error(
`Search is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
const query = readStringParam(params, "query", { required: true });
const channelId = readStringParam(params, "channelId");
const channelIds = readStringArrayParam(params, "channelIds");
const authorId = readStringParam(params, "authorId");
const authorIds = readStringArrayParam(params, "authorIds");
const limit = readNumberParam(params, "limit", { integer: true });
return await handleDiscordAction(
{
action: "searchMessages",
guildId,
content: query,
channelId,
channelIds,
authorId,
authorIds,
limit,
},
cfg,
);
}
if (action === "sticker") {
if (provider !== "discord") {
throw new Error(
`Sticker send is only supported for Discord (provider=${provider}).`,
);
}
const stickerIds =
readStringArrayParam(params, "stickerId", {
required: true,
label: "sticker-id",
}) ?? [];
const content = readStringParam(params, "message");
return await handleDiscordAction(
{
action: "sticker",
to: readStringParam(params, "to", { required: true }),
stickerIds,
content,
},
cfg,
);
}
if (action === "member-info") {
const userId = readStringParam(params, "userId", { required: true });
if (provider === "discord") {
const guildId = readStringParam(params, "guildId", {
required: true,
});
return await handleDiscordAction(
{ action: "memberInfo", guildId, userId },
cfg,
);
}
if (provider === "slack") {
return await handleSlackAction(
{ action: "memberInfo", userId, accountId: accountId ?? undefined },
cfg,
);
}
throw new Error(
`Member info is not supported for provider ${provider}.`,
);
}
if (action === "role-info") {
if (provider !== "discord") {
throw new Error(
`Role info is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
return await handleDiscordAction({ action: "roleInfo", guildId }, cfg);
}
if (action === "emoji-list") {
if (provider === "discord") {
const guildId = readStringParam(params, "guildId", {
required: true,
});
return await handleDiscordAction(
{ action: "emojiList", guildId },
cfg,
);
}
if (provider === "slack") {
return await handleSlackAction(
{ action: "emojiList", accountId: accountId ?? undefined },
cfg,
);
}
throw new Error(
`Emoji list is not supported for provider ${provider}.`,
);
}
if (action === "emoji-upload") {
if (provider !== "discord") {
throw new Error(
`Emoji upload is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
const name = readStringParam(params, "emojiName", { required: true });
const mediaUrl = readStringParam(params, "media", {
required: true,
trim: false,
});
const roleIds = readStringArrayParam(params, "roleIds");
return await handleDiscordAction(
{
action: "emojiUpload",
guildId,
name,
mediaUrl,
roleIds,
},
cfg,
);
}
if (action === "sticker-upload") {
if (provider !== "discord") {
throw new Error(
`Sticker upload is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
const name = readStringParam(params, "stickerName", { required: true });
const description = readStringParam(params, "stickerDesc", {
required: true,
});
const tags = readStringParam(params, "stickerTags", { required: true });
const mediaUrl = readStringParam(params, "media", {
required: true,
trim: false,
});
return await handleDiscordAction(
{
action: "stickerUpload",
guildId,
name,
description,
tags,
mediaUrl,
},
cfg,
);
}
if (action === "role-add" || action === "role-remove") {
if (provider !== "discord") {
throw new Error(
`Role changes are only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
const userId = readStringParam(params, "userId", { required: true });
const roleId = readStringParam(params, "roleId", { required: true });
const discordAction = action === "role-add" ? "roleAdd" : "roleRemove";
return await handleDiscordAction(
{ action: discordAction, guildId, userId, roleId },
cfg,
);
}
if (action === "channel-info") {
if (provider !== "discord") {
throw new Error(
`Channel info is only supported for Discord (provider=${provider}).`,
);
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
return await handleDiscordAction(
{ action: "channelInfo", channelId },
cfg,
);
}
if (action === "channel-list") {
if (provider !== "discord") {
throw new Error(
`Channel list is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
return await handleDiscordAction(
{ action: "channelList", guildId },
cfg,
);
}
if (action === "voice-status") {
if (provider !== "discord") {
throw new Error(
`Voice status is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
const userId = readStringParam(params, "userId", { required: true });
return await handleDiscordAction(
{ action: "voiceStatus", guildId, userId },
cfg,
);
}
if (action === "event-list") {
if (provider !== "discord") {
throw new Error(
`Event list is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
return await handleDiscordAction({ action: "eventList", guildId }, cfg);
}
if (action === "event-create") {
if (provider !== "discord") {
throw new Error(
`Event create is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
const name = readStringParam(params, "eventName", { required: true });
const startTime = readStringParam(params, "startTime", {
required: true,
});
const endTime = readStringParam(params, "endTime");
const description = readStringParam(params, "desc");
const channelId = readStringParam(params, "channelId");
const location = readStringParam(params, "location");
const entityType = readStringParam(params, "eventType");
return await handleDiscordAction(
{
action: "eventCreate",
guildId,
name,
startTime,
endTime,
description,
channelId,
location,
entityType,
},
cfg,
);
}
if (action === "timeout" || action === "kick" || action === "ban") {
if (provider !== "discord") {
throw new Error(
`Moderation actions are only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
const userId = readStringParam(params, "userId", { required: true });
const durationMinutes = readNumberParam(params, "durationMin", {
integer: true,
});
const until = readStringParam(params, "until");
const reason = readStringParam(params, "reason");
const deleteMessageDays = readNumberParam(params, "deleteDays", {
integer: true,
});
const discordAction = action as "timeout" | "kick" | "ban";
return await handleDiscordAction(
{
action: discordAction,
guildId,
userId,
durationMinutes,
until,
reason,
deleteMessageDays,
},
cfg,
);
}
throw new Error(`Unknown action: ${action}`);
},
};
}

View File

@@ -91,9 +91,11 @@ export async function handleSlackAction(
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "content", { required: true });
const mediaUrl = readStringParam(params, "mediaUrl");
const threadTs = readStringParam(params, "threadTs");
const result = await sendSlackMessage(to, content, {
accountId: accountId ?? undefined,
mediaUrl: mediaUrl ?? undefined,
threadTs: threadTs ?? undefined,
});
return jsonResult({ ok: true, result });
}

View File

@@ -24,6 +24,7 @@ export const SlackToolSchema = Type.Union([
to: Type.String(),
content: Type.String(),
mediaUrl: Type.Optional(Type.String()),
threadTs: Type.Optional(Type.String()),
accountId: Type.Optional(Type.String()),
}),
Type.Object({

View File

@@ -27,6 +27,8 @@ describe("commands registry", () => {
expect(detection.regex.test("/status:")).toBe(true);
expect(detection.regex.test("/stop")).toBe(true);
expect(detection.regex.test("/send:")).toBe(true);
expect(detection.regex.test("/models")).toBe(true);
expect(detection.regex.test("/models list")).toBe(true);
expect(detection.regex.test("try /status")).toBe(false);
});

View File

@@ -111,7 +111,7 @@ const CHAT_COMMANDS: ChatCommandDefinition[] = [
key: "model",
nativeName: "model",
description: "Show or set the model.",
textAliases: ["/model"],
textAliases: ["/model", "/models"],
acceptsArgs: true,
},
{

View File

@@ -10,6 +10,13 @@ describe("extractModelDirective", () => {
expect(result.cleaned).toBe("");
});
it("extracts /models with argument", () => {
const result = extractModelDirective("/models gpt-5");
expect(result.hasDirective).toBe(true);
expect(result.rawModel).toBe("gpt-5");
expect(result.cleaned).toBe("");
});
it("extracts /model with provider/model format", () => {
const result = extractModelDirective("/model anthropic/claude-opus-4-5");
expect(result.hasDirective).toBe(true);

View File

@@ -14,7 +14,7 @@ export function extractModelDirective(
if (!body) return { cleaned: "", hasDirective: false };
const modelMatch = body.match(
/(?:^|\s)\/model(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)?)?/i,
/(?:^|\s)\/models?(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)?)?/i,
);
const aliases = (options?.aliases ?? [])

View File

@@ -181,7 +181,7 @@ describe("trigger handling", () => {
});
});
it("restarts even with prefix/whitespace", async () => {
it("rejects /restart by default", async () => {
await withTempHome(async (home) => {
const res = await getReplyFromConfig(
{
@@ -193,6 +193,24 @@ describe("trigger handling", () => {
makeCfg(home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("/restart is disabled");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("restarts when enabled", async () => {
await withTempHome(async (home) => {
const cfg = { ...makeCfg(home), commands: { restart: true } };
const res = await getReplyFromConfig(
{
Body: "/restart",
From: "+1001",
To: "+2000",
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(
text?.startsWith("⚙️ Restarting") ||
text?.startsWith("⚠️ Restart failed"),

View File

@@ -1,8 +1,7 @@
import crypto from "node:crypto";
import { describe, expect, it, vi } from "vitest";
import type { TemplateContext } from "../templating.js";
import { onAgentEvent } from "../../infra/agent-events.js";
import type { TemplateContext } from "../templating.js";
import type { FollowupRun, QueueSettings } from "./queue.js";
import { createMockTypingController } from "./test-helpers.js";
@@ -105,9 +104,7 @@ function createRun() {
describe("runReplyAgent claude-cli routing", () => {
it("uses claude-cli runner for claude-cli provider", async () => {
const randomSpy = vi
.spyOn(crypto, "randomUUID")
.mockReturnValue("run-1");
const randomSpy = vi.spyOn(crypto, "randomUUID").mockReturnValue("run-1");
const lifecyclePhases: string[] = [];
const unsubscribe = onAgentEvent((evt) => {
if (evt.runId !== "run-1") return;

View File

@@ -18,7 +18,10 @@ import {
} from "../../config/sessions.js";
import type { TypingMode } from "../../config/types.js";
import { logVerbose } from "../../globals.js";
import { emitAgentEvent, registerAgentRunContext } from "../../infra/agent-events.js";
import {
emitAgentEvent,
registerAgentRunContext,
} from "../../infra/agent-events.js";
import { defaultRuntime } from "../../runtime.js";
import {
estimateUsageCost,
@@ -352,7 +355,7 @@ export async function runReplyAgent(params: {
runId,
extraSystemPrompt: followupRun.run.extraSystemPrompt,
ownerNumbers: followupRun.run.ownerNumbers,
resumeSessionId:
claudeSessionId:
sessionEntry?.claudeCliSessionId?.trim() || undefined,
})
.then((result) => {

View File

@@ -220,16 +220,13 @@ function resolveModelAuthLabel(
const providerKey = normalizeProviderId(resolved);
const store = ensureAuthProfileStore();
const profileOverride = sessionEntry?.authProfileOverride?.trim();
const lastGood = store.lastGood?.[providerKey] ?? store.lastGood?.[resolved];
const order = resolveAuthProfileOrder({
cfg,
store,
provider: providerKey,
preferredProfile: profileOverride,
});
const candidates = [profileOverride, lastGood, ...order].filter(
Boolean,
) as string[];
const candidates = [profileOverride, ...order].filter(Boolean) as string[];
for (const profileId of candidates) {
const profile = store.profiles[profileId];
@@ -240,6 +237,10 @@ function resolveModelAuthLabel(
if (profile.type === "oauth") {
return `oauth${label ? ` (${label})` : ""}`;
}
if (profile.type === "token") {
const snippet = formatApiKeySnippet(profile.token);
return `token ${snippet}${label ? ` (${label})` : ""}`;
}
const snippet = formatApiKeySnippet(profile.key);
return `api-key ${snippet}${label ? ` (${label})` : ""}`;
}
@@ -508,6 +509,14 @@ export async function handleCommands(params: {
);
return { shouldContinue: false };
}
if (cfg.commands?.restart !== true) {
return {
shouldContinue: false,
reply: {
text: "⚠️ /restart is disabled. Set commands.restart=true to enable.",
},
};
}
const hasSigusr1Listener = process.listenerCount("SIGUSR1") > 0;
if (hasSigusr1Listener) {
scheduleGatewaySigusr1Restart({ reason: "/restart" });

View File

@@ -88,13 +88,18 @@ const resolveAuthLabel = async (
!profile ||
(configProfile?.provider &&
configProfile.provider !== profile.provider) ||
(configProfile?.mode && configProfile.mode !== profile.type)
(configProfile?.mode &&
configProfile.mode !== profile.type &&
!(configProfile.mode === "oauth" && profile.type === "token"))
) {
return `${profileId}=missing`;
}
if (profile.type === "api_key") {
return `${profileId}=${maskApiKey(profile.key)}`;
}
if (profile.type === "token") {
return `${profileId}=token:${maskApiKey(profile.token)}`;
}
const display = resolveAuthProfileDisplayLabel({
cfg,
store,

View File

@@ -330,7 +330,9 @@ export function buildStatusMessage(args: StatusArgs): string {
const usagePair = formatUsagePair(inputTokens, outputTokens);
const costLine = costLabel ? `💵 Cost: ${costLabel}` : null;
const usageCostLine =
usagePair && costLine ? `${usagePair} · ${costLine}` : usagePair ?? costLine;
usagePair && costLine
? `${usagePair} · ${costLine}`
: (usagePair ?? costLine);
return [
versionLine,
@@ -349,7 +351,7 @@ export function buildStatusMessage(args: StatusArgs): string {
export function buildHelpMessage(): string {
return [
" Help",
"Shortcuts: /new reset | /compact [instructions] | /restart relink",
"Shortcuts: /new reset | /compact [instructions] | /restart relink (if enabled)",
"Options: /think <level> | /verbose on|off | /reasoning on|off | /elevated on|off | /model <id> | /cost on|off",
"More: /commands for all slash commands",
].join("\n");

View File

@@ -10,6 +10,20 @@ type BannerOptions = TaglineOptions & {
let bannerEmitted = false;
const graphemeSegmenter =
typeof Intl !== "undefined" && "Segmenter" in Intl
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
: null;
function splitGraphemes(value: string): string[] {
if (!graphemeSegmenter) return Array.from(value);
try {
return Array.from(graphemeSegmenter.segment(value), (seg) => seg.segment);
} catch {
return Array.from(value);
}
}
const hasJsonFlag = (argv: string[]) =>
argv.some((arg) => arg === "--json" || arg.startsWith("--json="));
@@ -33,6 +47,41 @@ export function formatCliBannerLine(
return `${title} ${version} (${commitLabel}) — ${tagline}`;
}
const LOBSTER_ASCII = [
"░████░█░░░░░█████░█░░░█░███░░████░░████░░▀█▀",
"█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░░█░█░░░█░░█░",
"█░░░░░█░░░░░█████░█░█░█░█░░█░████░░█░░░█░░█░",
"█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░█░░█░░░█░░█░",
"░████░█████░█░░░█░░█░█░░███░░████░░░███░░░█░",
" 🦞 FRESH DAILY 🦞",
];
export function formatCliBannerArt(options: BannerOptions = {}): string {
const rich = options.richTty ?? isRich();
if (!rich) return LOBSTER_ASCII.join("\n");
const colorChar = (ch: string) => {
if (ch === "█") return theme.accentBright(ch);
if (ch === "░") return theme.accentDim(ch);
if (ch === "▀") return theme.accent(ch);
return theme.muted(ch);
};
const colored = LOBSTER_ASCII.map((line) => {
if (line.includes("FRESH DAILY")) {
return (
theme.muted(" ") +
theme.accent("🦞") +
theme.info(" FRESH DAILY ") +
theme.accent("🦞")
);
}
return splitGraphemes(line).map(colorChar).join("");
});
return colored.join("\n");
}
export function emitCliBanner(version: string, options: BannerOptions = {}) {
if (bannerEmitted) return;
const argv = options.argv ?? process.argv;

View File

@@ -672,7 +672,9 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
service.runtime?.status === "running"
) {
defaultRuntime.log(
warnText("Warm-up: launch agents can take a few seconds. Try again shortly."),
warnText(
"Warm-up: launch agents can take a few seconds. Try again shortly.",
),
);
}
if (rpc) {
@@ -680,8 +682,7 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
defaultRuntime.log(`${label("RPC probe:")} ${okText("ok")}`);
} else {
defaultRuntime.error(`${label("RPC probe:")} ${errorText("failed")}`);
if (rpc.url)
defaultRuntime.error(`${label("RPC target:")} ${rpc.url}`);
if (rpc.url) defaultRuntime.error(`${label("RPC target:")} ${rpc.url}`);
const lines = String(rpc.error ?? "unknown")
.split(/\r?\n/)
.filter(Boolean);
@@ -698,7 +699,9 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
}
} else if (service.loaded && service.runtime?.status === "stopped") {
defaultRuntime.error(
errorText("Service is loaded but not running (likely exited immediately)."),
errorText(
"Service is loaded but not running (likely exited immediately).",
),
);
for (const hint of renderRuntimeHints(
service.runtime,
@@ -736,7 +739,9 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
),
);
if (addrs.length > 0) {
defaultRuntime.log(`${label("Listening:")} ${infoText(addrs.join(", "))}`);
defaultRuntime.log(
`${label("Listening:")} ${infoText(addrs.join(", "))}`,
);
}
}
if (status.portCli && status.portCli.port !== status.port?.port) {

View File

@@ -12,6 +12,7 @@ const forceFreePortAndWait = vi.fn(async () => ({
escalatedToSigkill: false,
}));
const serviceIsLoaded = vi.fn().mockResolvedValue(true);
const discoverGatewayBeacons = vi.fn(async () => []);
const runtimeLogs: string[] = [];
const runtimeErrors: string[] = [];
@@ -90,6 +91,10 @@ vi.mock("../daemon/program-args.js", () => ({
}),
}));
vi.mock("../infra/bonjour-discovery.js", () => ({
discoverGatewayBeacons: (opts: unknown) => discoverGatewayBeacons(opts),
}));
describe("gateway-cli coverage", () => {
it("registers call/health/status commands and routes to callGateway", async () => {
runtimeLogs.length = 0;
@@ -110,6 +115,59 @@ describe("gateway-cli coverage", () => {
expect(runtimeLogs.join("\n")).toContain('"ok": true');
});
it("registers gateway discover and prints JSON", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
discoverGatewayBeacons.mockReset();
discoverGatewayBeacons.mockResolvedValueOnce([
{
instanceName: "Studio (Clawdbot)",
displayName: "Studio",
domain: "local.",
host: "studio.local",
lanHost: "studio.local",
tailnetDns: "studio.tailnet.ts.net",
gatewayPort: 18789,
bridgePort: 18790,
sshPort: 22,
},
]);
const { registerGatewayCli } = await import("./gateway-cli.js");
const program = new Command();
program.exitOverride();
registerGatewayCli(program);
await program.parseAsync(["gateway", "discover", "--json"], {
from: "user",
});
expect(discoverGatewayBeacons).toHaveBeenCalledTimes(1);
expect(runtimeLogs.join("\n")).toContain('"beacons"');
expect(runtimeLogs.join("\n")).toContain('"wsUrl"');
expect(runtimeLogs.join("\n")).toContain("ws://");
});
it("validates gateway discover timeout", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
discoverGatewayBeacons.mockReset();
const { registerGatewayCli } = await import("./gateway-cli.js");
const program = new Command();
program.exitOverride();
registerGatewayCli(program);
await expect(
program.parseAsync(["gateway", "discover", "--timeout", "0"], {
from: "user",
}),
).rejects.toThrow("__exit__:1");
expect(runtimeErrors.join("\n")).toContain("gateway discover failed:");
expect(discoverGatewayBeacons).not.toHaveBeenCalled();
});
it("fails gateway call on invalid params JSON", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;

View File

@@ -22,10 +22,17 @@ import {
setGatewayWsLogStyle,
} from "../gateway/ws-logging.js";
import { setVerbose } from "../globals.js";
import type { GatewayBonjourBeacon } from "../infra/bonjour-discovery.js";
import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js";
import { GatewayLockError } from "../infra/gateway-lock.js";
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
import { createSubsystemLogger } from "../logging.js";
import { WIDE_AREA_DISCOVERY_DOMAIN } from "../infra/widearea-dns.js";
import {
createSubsystemLogger,
setConsoleSubsystemFilter,
} from "../logging.js";
import { defaultRuntime } from "../runtime.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
import { forceFreePortAndWait } from "./ports.js";
import { withProgress } from "./progress.js";
@@ -48,6 +55,7 @@ type GatewayRunOpts = {
allowUnconfigured?: boolean;
force?: boolean;
verbose?: boolean;
claudeCliLogs?: boolean;
wsLog?: unknown;
compact?: boolean;
rawStream?: boolean;
@@ -83,6 +91,111 @@ const toOptionString = (value: unknown): string | undefined => {
return undefined;
};
type GatewayDiscoverOpts = {
timeout?: string;
json?: boolean;
};
function parseDiscoverTimeoutMs(raw: unknown, fallbackMs: number): number {
if (raw === undefined || raw === null) return fallbackMs;
const value =
typeof raw === "string"
? raw.trim()
: typeof raw === "number" || typeof raw === "bigint"
? String(raw)
: null;
if (value === null) {
throw new Error("invalid --timeout");
}
if (!value) return fallbackMs;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`invalid --timeout: ${value}`);
}
return parsed;
}
function pickBeaconHost(beacon: GatewayBonjourBeacon): string | null {
const host = beacon.tailnetDns || beacon.lanHost || beacon.host;
return host?.trim() ? host.trim() : null;
}
function pickGatewayPort(beacon: GatewayBonjourBeacon): number {
const port = beacon.gatewayPort ?? 18789;
return port > 0 ? port : 18789;
}
function dedupeBeacons(
beacons: GatewayBonjourBeacon[],
): GatewayBonjourBeacon[] {
const out: GatewayBonjourBeacon[] = [];
const seen = new Set<string>();
for (const b of beacons) {
const host = pickBeaconHost(b) ?? "";
const key = [
b.domain ?? "",
b.instanceName ?? "",
b.displayName ?? "",
host,
String(b.port ?? ""),
String(b.bridgePort ?? ""),
String(b.gatewayPort ?? ""),
].join("|");
if (seen.has(key)) continue;
seen.add(key);
out.push(b);
}
return out;
}
function renderBeaconLines(
beacon: GatewayBonjourBeacon,
rich: boolean,
): string[] {
const nameRaw = (
beacon.displayName ||
beacon.instanceName ||
"Gateway"
).trim();
const domainRaw = (beacon.domain || "local.").trim();
const title = colorize(rich, theme.accentBright, nameRaw);
const domain = colorize(rich, theme.muted, domainRaw);
const parts: string[] = [];
if (beacon.tailnetDns)
parts.push(
`${colorize(rich, theme.info, "tailnet")}: ${beacon.tailnetDns}`,
);
if (beacon.lanHost)
parts.push(`${colorize(rich, theme.info, "lan")}: ${beacon.lanHost}`);
if (beacon.host)
parts.push(`${colorize(rich, theme.info, "host")}: ${beacon.host}`);
const host = pickBeaconHost(beacon);
const gatewayPort = pickGatewayPort(beacon);
const wsUrl = host ? `ws://${host}:${gatewayPort}` : null;
const firstLine =
parts.length > 0
? `${title} ${domain} · ${parts.join(" · ")}`
: `${title} ${domain}`;
const lines = [`- ${firstLine}`];
if (wsUrl) {
lines.push(
` ${colorize(rich, theme.muted, "ws")}: ${colorize(rich, theme.command, wsUrl)}`,
);
}
if (typeof beacon.sshPort === "number" && beacon.sshPort > 0 && host) {
const ssh = `ssh -N -L 18789:127.0.0.1:18789 <user>@${host} -p ${beacon.sshPort}`;
lines.push(
` ${colorize(rich, theme.muted, "ssh")}: ${colorize(rich, theme.command, ssh)}`,
);
}
return lines;
}
function describeUnknownError(err: unknown): string {
if (err instanceof Error) return err.message;
if (typeof err === "string") return err;
@@ -215,9 +328,18 @@ async function runGatewayLoop(params: {
})();
};
const onSigterm = () => request("stop", "SIGTERM");
const onSigint = () => request("stop", "SIGINT");
const onSigusr1 = () => request("restart", "SIGUSR1");
const onSigterm = () => {
gatewayLog.info("signal SIGTERM received");
request("stop", "SIGTERM");
};
const onSigint = () => {
gatewayLog.info("signal SIGINT received");
request("stop", "SIGINT");
};
const onSigusr1 = () => {
gatewayLog.info("signal SIGUSR1 received");
request("restart", "SIGUSR1");
};
process.on("SIGTERM", onSigterm);
process.on("SIGINT", onSigint);
@@ -286,6 +408,10 @@ async function runGatewayCommand(
}
setVerbose(Boolean(opts.verbose));
if (opts.claudeCliLogs) {
setConsoleSubsystemFilter(["agent/claude-cli"]);
process.env.CLAWDBOT_CLAUDE_CLI_LOG_OUTPUT = "1";
}
const wsLogRaw = (opts.compact ? "compact" : opts.wsLog) as
| string
| undefined;
@@ -569,6 +695,11 @@ function addGatewayRunCommand(
false,
)
.option("--verbose", "Verbose logging to stdout/stderr", false)
.option(
"--claude-cli-logs",
"Only show claude-cli logs in the console (includes stdout/stderr)",
false,
)
.option(
"--ws-log <style>",
'WebSocket log style ("auto"|"full"|"compact")',
@@ -645,4 +776,75 @@ export function registerGatewayCli(program: Command) {
}
}),
);
gateway
.command("discover")
.description(
`Discover gateways via Bonjour (multicast local. + unicast ${WIDE_AREA_DISCOVERY_DOMAIN})`,
)
.option("--timeout <ms>", "Per-command timeout in ms", "2000")
.option("--json", "Output JSON", false)
.action(async (opts: GatewayDiscoverOpts) => {
try {
const timeoutMs = parseDiscoverTimeoutMs(opts.timeout, 2000);
const beacons = await withProgress(
{
label: "Scanning for gateways…",
indeterminate: true,
enabled: opts.json !== true,
},
async () => await discoverGatewayBeacons({ timeoutMs }),
);
const deduped = dedupeBeacons(beacons).sort((a, b) =>
String(a.displayName || a.instanceName).localeCompare(
String(b.displayName || b.instanceName),
),
);
if (opts.json) {
const enriched = deduped.map((b) => {
const host = pickBeaconHost(b);
const port = pickGatewayPort(b);
return {
...b,
wsUrl: host ? `ws://${host}:${port}` : null,
};
});
defaultRuntime.log(
JSON.stringify(
{
timeoutMs,
domains: ["local.", WIDE_AREA_DISCOVERY_DOMAIN],
count: enriched.length,
beacons: enriched,
},
null,
2,
),
);
return;
}
const rich = isRich();
defaultRuntime.log(colorize(rich, theme.heading, "Gateway Discovery"));
defaultRuntime.log(
colorize(
rich,
theme.muted,
`Found ${deduped.length} gateway(s) · domains: local., ${WIDE_AREA_DISCOVERY_DOMAIN}`,
),
);
if (deduped.length === 0) return;
for (const beacon of deduped) {
for (const line of renderBeaconLines(beacon, rich)) {
defaultRuntime.log(line);
}
}
} catch (err) {
defaultRuntime.error(`gateway discover failed: ${String(err)}`);
defaultRuntime.exit(1);
}
});
}

View File

@@ -4,6 +4,9 @@ import {
modelsAliasesAddCommand,
modelsAliasesListCommand,
modelsAliasesRemoveCommand,
modelsAuthAddCommand,
modelsAuthPasteTokenCommand,
modelsAuthSetupTokenCommand,
modelsFallbacksAddCommand,
modelsFallbacksClearCommand,
modelsFallbacksListCommand,
@@ -294,4 +297,63 @@ export function registerModelsCli(program: Command) {
defaultRuntime.exit(1);
}
});
const auth = models.command("auth").description("Manage model auth profiles");
auth
.command("add")
.description("Interactive auth helper (setup-token or paste token)")
.action(async () => {
try {
await modelsAuthAddCommand({}, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
auth
.command("setup-token")
.description("Run a provider CLI to create/sync a token (TTY required)")
.option("--provider <name>", "Provider id (default: anthropic)")
.option("--yes", "Skip confirmation", false)
.action(async (opts) => {
try {
await modelsAuthSetupTokenCommand(
{
provider: opts.provider as string | undefined,
yes: Boolean(opts.yes),
},
defaultRuntime,
);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
auth
.command("paste-token")
.description("Paste a token into auth-profiles.json and update config")
.requiredOption("--provider <name>", "Provider id (e.g. anthropic)")
.option("--profile-id <id>", "Auth profile id (default: <provider>:manual)")
.option(
"--expires-in <duration>",
"Optional expiry duration (e.g. 365d, 12h). Stored as absolute expiresAt.",
)
.action(async (opts) => {
try {
await modelsAuthPasteTokenCommand(
{
provider: opts.provider as string | undefined,
profileId: opts.profileId as string | undefined,
expiresIn: opts.expiresIn as string | undefined,
},
defaultRuntime,
);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
}

View File

@@ -19,6 +19,10 @@ describe("parseDurationMs", () => {
expect(parseDurationMs("2h")).toBe(7_200_000);
});
it("parses days suffix", () => {
expect(parseDurationMs("2d")).toBe(172_800_000);
});
it("supports decimals", () => {
expect(parseDurationMs("0.5s")).toBe(500);
});

View File

@@ -1,5 +1,5 @@
export type DurationMsParseOptions = {
defaultUnit?: "ms" | "s" | "m" | "h";
defaultUnit?: "ms" | "s" | "m" | "h" | "d";
};
export function parseDurationMs(
@@ -11,7 +11,7 @@ export function parseDurationMs(
.toLowerCase();
if (!trimmed) throw new Error("invalid duration (empty)");
const m = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(trimmed);
const m = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)?$/.exec(trimmed);
if (!m) throw new Error(`invalid duration: ${raw}`);
const value = Number(m[1]);
@@ -19,9 +19,22 @@ export function parseDurationMs(
throw new Error(`invalid duration: ${raw}`);
}
const unit = (m[2] ?? opts?.defaultUnit ?? "ms") as "ms" | "s" | "m" | "h";
const unit = (m[2] ?? opts?.defaultUnit ?? "ms") as
| "ms"
| "s"
| "m"
| "h"
| "d";
const multiplier =
unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60_000 : 3_600_000;
unit === "ms"
? 1
: unit === "s"
? 1000
: unit === "m"
? 60_000
: unit === "h"
? 3_600_000
: 86_400_000;
const ms = Math.round(value * multiplier);
if (!Number.isFinite(ms)) throw new Error(`invalid duration: ${raw}`);
return ms;

View File

@@ -1,7 +1,7 @@
import * as fs from "node:fs/promises";
import { beforeEach, describe, expect, it, vi } from "vitest";
const sendCommand = vi.fn();
const messageCommand = vi.fn();
const statusCommand = vi.fn();
const configureCommand = vi.fn();
const setupCommand = vi.fn();
@@ -18,7 +18,9 @@ const runtime = {
}),
};
vi.mock("../commands/send.js", () => ({ sendCommand }));
vi.mock("../commands/message.js", () => ({
messageCommand,
}));
vi.mock("../commands/status.js", () => ({ statusCommand }));
vi.mock("../commands/configure.js", () => ({ configureCommand }));
vi.mock("../commands/setup.js", () => ({ setupCommand }));
@@ -43,12 +45,15 @@ describe("cli program", () => {
vi.clearAllMocks();
});
it("runs send with required options", async () => {
it("runs message with required options", async () => {
const program = buildProgram();
await program.parseAsync(["send", "--to", "+1", "--message", "hi"], {
from: "user",
});
expect(sendCommand).toHaveBeenCalled();
await program.parseAsync(
["message", "send", "--to", "+1", "--message", "hi"],
{
from: "user",
},
);
expect(messageCommand).toHaveBeenCalled();
});
it("runs status command", async () => {

View File

@@ -8,9 +8,8 @@ import {
import { configureCommand } from "../commands/configure.js";
import { doctorCommand } from "../commands/doctor.js";
import { healthCommand } from "../commands/health.js";
import { messageCommand } from "../commands/message.js";
import { onboardCommand } from "../commands/onboard.js";
import { pollCommand } from "../commands/poll.js";
import { sendCommand } from "../commands/send.js";
import { sessionsCommand } from "../commands/sessions.js";
import { setupCommand } from "../commands/setup.js";
import { statusCommand } from "../commands/status.js";
@@ -26,7 +25,11 @@ import { autoMigrateLegacyState } from "../infra/state-migrations.js";
import { defaultRuntime } from "../runtime.js";
import { isRich, theme } from "../terminal/theme.js";
import { VERSION } from "../version.js";
import { emitCliBanner, formatCliBannerLine } from "./banner.js";
import {
emitCliBanner,
formatCliBannerArt,
formatCliBannerLine,
} from "./banner.js";
import { registerBrowserCli } from "./browser-cli.js";
import { hasExplicitOptions } from "./command-options.js";
import { registerCronCli } from "./cron-cli.js";
@@ -96,8 +99,10 @@ export function buildProgram() {
}
program.addHelpText("beforeAll", () => {
const line = formatCliBannerLine(PROGRAM_VERSION, { richTty: isRich() });
return `\n${line}\n`;
const rich = isRich();
const art = formatCliBannerArt({ richTty: rich });
const line = formatCliBannerLine(PROGRAM_VERSION, { richTty: rich });
return `\n${art}\n${line}\n`;
});
program.hook("preAction", async (_thisCommand, actionCommand) => {
@@ -146,7 +151,7 @@ export function buildProgram() {
"Link personal WhatsApp Web and show QR + connection logs.",
],
[
'clawdbot send --to +15555550123 --message "Hi" --json',
'clawdbot message send --to +15555550123 --message "Hi" --json',
"Send via your web session and print JSON result.",
],
["clawdbot gateway --port 18789", "Run the WebSocket Gateway locally."],
@@ -164,7 +169,7 @@ export function buildProgram() {
"Talk directly to the agent using the Gateway; optionally send the WhatsApp reply.",
],
[
'clawdbot send --provider telegram --to @mychat --message "Hi"',
'clawdbot message send --provider telegram --to @mychat --message "Hi"',
"Send via your Telegram bot.",
],
] as const;
@@ -232,7 +237,7 @@ export function buildProgram() {
.option("--mode <mode>", "Wizard mode: local|remote")
.option(
"--auth-choice <choice>",
"Auth: oauth|claude-cli|openai-codex|codex-cli|antigravity|gemini-api-key|apiKey|minimax|skip",
"Auth: oauth|claude-cli|token|openai-codex|codex-cli|antigravity|gemini-api-key|apiKey|minimax|skip",
)
.option("--anthropic-api-key <key>", "Anthropic API key")
.option("--gemini-api-key <key>", "Gemini API key")
@@ -261,6 +266,7 @@ export function buildProgram() {
authChoice: opts.authChoice as
| "oauth"
| "claude-cli"
| "token"
| "openai-codex"
| "codex-cli"
| "antigravity"
@@ -402,107 +408,472 @@ export function buildProgram() {
}
});
program
.command("send")
.description(
"Send a message (WhatsApp Web, Telegram bot, Discord, Slack, Signal, iMessage)",
const message = program
.command("message")
.description("Send messages and provider actions")
.addHelpText(
"after",
`
Examples:
clawdbot message send --to +15555550123 --message "Hi"
clawdbot message send --to +15555550123 --message "Hi" --media photo.jpg
clawdbot message poll --provider discord --to channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi
clawdbot message react --provider discord --to 123 --message-id 456 --emoji "✅"`,
)
.requiredOption(
"-t, --to <number>",
"Recipient: E.164 for WhatsApp/Signal, Telegram chat id/@username, Discord channel/user, or iMessage handle/chat_id",
.action(() => {
message.help({ error: true });
});
const withMessageBase = (command: Command) =>
command
.option(
"--provider <provider>",
"Provider: whatsapp|telegram|discord|slack|signal|imessage",
)
.option("--account <id>", "Provider account id")
.option("--json", "Output result as JSON", false)
.option("--dry-run", "Print payload and skip sending", false)
.option("--verbose", "Verbose logging", false);
const withMessageTarget = (command: Command) =>
command.option(
"-t, --to <dest>",
"Recipient/channel: E.164 for WhatsApp/Signal, Telegram chat id/@username, Discord/Slack channel/user, or iMessage handle/chat_id",
);
const withRequiredMessageTarget = (command: Command) =>
command.requiredOption(
"-t, --to <dest>",
"Recipient/channel: E.164 for WhatsApp/Signal, Telegram chat id/@username, Discord/Slack channel/user, or iMessage handle/chat_id",
);
const runMessageAction = async (
action: string,
opts: Record<string, unknown>,
) => {
setVerbose(Boolean(opts.verbose));
const deps = createDefaultDeps();
try {
await messageCommand(
{
...opts,
action,
account: opts.account as string | undefined,
},
deps,
defaultRuntime,
);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
};
withMessageBase(
withRequiredMessageTarget(
message
.command("send")
.description("Send a message")
.requiredOption("-m, --message <text>", "Message body"),
)
.requiredOption("-m, --message <text>", "Message body")
.option(
"--media <path-or-url>",
"Attach media (image/audio/video/document). Accepts local paths or URLs.",
)
.option("--reply-to <id>", "Reply-to message id")
.option("--thread-id <id>", "Thread id (Telegram forum thread)")
.option(
"--gif-playback",
"Treat video media as GIF playback (WhatsApp only).",
false,
),
).action(async (opts) => {
await runMessageAction("send", opts);
});
withMessageBase(
withRequiredMessageTarget(
message.command("poll").description("Send a poll"),
),
)
.requiredOption("--poll-question <text>", "Poll question")
.option(
"--poll-option <choice>",
"Poll option (repeat 2-12 times)",
collectOption,
[] as string[],
)
.option("--poll-multi", "Allow multiple selections", false)
.option("--poll-duration-hours <n>", "Poll duration (Discord)")
.option("-m, --message <text>", "Optional message body")
.action(async (opts) => {
await runMessageAction("poll", opts);
});
withMessageBase(
withMessageTarget(
message.command("react").description("Add or remove a reaction"),
),
)
.requiredOption("--message-id <id>", "Message id")
.option("--emoji <emoji>", "Emoji for reactions")
.option("--remove", "Remove reaction", false)
.option("--participant <id>", "WhatsApp reaction participant")
.option("--from-me", "WhatsApp reaction fromMe", false)
.option("--channel-id <id>", "Channel id (defaults to --to)")
.action(async (opts) => {
await runMessageAction("react", opts);
});
withMessageBase(
withMessageTarget(
message.command("reactions").description("List reactions on a message"),
),
)
.requiredOption("--message-id <id>", "Message id")
.option("--limit <n>", "Result limit")
.option("--channel-id <id>", "Channel id (defaults to --to)")
.action(async (opts) => {
await runMessageAction("reactions", opts);
});
withMessageBase(
withMessageTarget(
message.command("read").description("Read recent messages"),
),
)
.option("--limit <n>", "Result limit")
.option("--before <id>", "Read/search before id")
.option("--after <id>", "Read/search after id")
.option("--around <id>", "Read around id (Discord)")
.option("--channel-id <id>", "Channel id (defaults to --to)")
.action(async (opts) => {
await runMessageAction("read", opts);
});
withMessageBase(
withMessageTarget(
message
.command("edit")
.description("Edit a message")
.requiredOption("-m, --message <text>", "Message body"),
),
)
.requiredOption("--message-id <id>", "Message id")
.option("--channel-id <id>", "Channel id (defaults to --to)")
.action(async (opts) => {
await runMessageAction("edit", opts);
});
withMessageBase(
withMessageTarget(
message.command("delete").description("Delete a message"),
),
)
.requiredOption("--message-id <id>", "Message id")
.option("--channel-id <id>", "Channel id (defaults to --to)")
.action(async (opts) => {
await runMessageAction("delete", opts);
});
withMessageBase(
withMessageTarget(message.command("pin").description("Pin a message")),
)
.requiredOption("--message-id <id>", "Message id")
.option("--channel-id <id>", "Channel id (defaults to --to)")
.action(async (opts) => {
await runMessageAction("pin", opts);
});
withMessageBase(
withMessageTarget(message.command("unpin").description("Unpin a message")),
)
.option("--message-id <id>", "Message id")
.option("--channel-id <id>", "Channel id (defaults to --to)")
.action(async (opts) => {
await runMessageAction("unpin", opts);
});
withMessageBase(
withMessageTarget(
message.command("pins").description("List pinned messages"),
),
)
.option("--channel-id <id>", "Channel id (defaults to --to)")
.action(async (opts) => {
await runMessageAction("list-pins", opts);
});
withMessageBase(
withMessageTarget(
message.command("permissions").description("Fetch channel permissions"),
),
)
.option("--channel-id <id>", "Channel id (defaults to --to)")
.action(async (opts) => {
await runMessageAction("permissions", opts);
});
withMessageBase(
message.command("search").description("Search Discord messages"),
)
.requiredOption("--guild-id <id>", "Guild id")
.requiredOption("--query <text>", "Search query")
.option("--channel-id <id>", "Channel id")
.option(
"--channel-ids <id>",
"Channel id (repeat)",
collectOption,
[] as string[],
)
.option("--author-id <id>", "Author id")
.option(
"--author-ids <id>",
"Author id (repeat)",
collectOption,
[] as string[],
)
.option("--limit <n>", "Result limit")
.action(async (opts) => {
await runMessageAction("search", opts);
});
const thread = message.command("thread").description("Thread actions");
withMessageBase(
withMessageTarget(
thread
.command("create")
.description("Create a thread")
.requiredOption("--thread-name <name>", "Thread name"),
),
)
.option("--channel-id <id>", "Channel id (defaults to --to)")
.option("--message-id <id>", "Message id (optional)")
.option("--auto-archive-min <n>", "Thread auto-archive minutes")
.action(async (opts) => {
await runMessageAction("thread-create", opts);
});
withMessageBase(
thread
.command("list")
.description("List threads")
.requiredOption("--guild-id <id>", "Guild id"),
)
.option("--channel-id <id>", "Channel id")
.option("--include-archived", "Include archived threads", false)
.option("--before <id>", "Read/search before id")
.option("--limit <n>", "Result limit")
.action(async (opts) => {
await runMessageAction("thread-list", opts);
});
withMessageBase(
withRequiredMessageTarget(
thread
.command("reply")
.description("Reply in a thread")
.requiredOption("-m, --message <text>", "Message body"),
),
)
.option(
"--media <path-or-url>",
"Attach media (image/audio/video/document). Accepts local paths or URLs.",
)
.option(
"--gif-playback",
"Treat video media as GIF playback (WhatsApp only).",
false,
)
.option(
"--provider <provider>",
"Delivery provider: whatsapp|telegram|discord|slack|signal|imessage (default: whatsapp)",
)
.option("--account <id>", "WhatsApp account id (accountId)")
.option("--dry-run", "Print payload and skip sending", false)
.option("--json", "Output result as JSON", false)
.option("--verbose", "Verbose logging", false)
.addHelpText(
"after",
`
Examples:
clawdbot send --to +15555550123 --message "Hi"
clawdbot send --to +15555550123 --message "Hi" --media photo.jpg
clawdbot send --to +15555550123 --message "Hi" --dry-run # print payload only
clawdbot send --to +15555550123 --message "Hi" --json # machine-readable result`,
)
.option("--reply-to <id>", "Reply-to message id")
.action(async (opts) => {
setVerbose(Boolean(opts.verbose));
const deps = createDefaultDeps();
try {
await sendCommand(
{
...opts,
account: opts.account as string | undefined,
},
deps,
defaultRuntime,
);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
await runMessageAction("thread-reply", opts);
});
program
.command("poll")
.description("Create a poll via WhatsApp or Discord")
.requiredOption(
"-t, --to <id>",
"Recipient: WhatsApp JID/number or Discord channel/user",
)
.requiredOption("-q, --question <text>", "Poll question")
.requiredOption(
"-o, --option <choice>",
"Poll option (use multiple times, 2-12 required)",
(value: string, previous: string[]) => previous.concat([value]),
const emoji = message.command("emoji").description("Emoji actions");
withMessageBase(emoji.command("list").description("List emojis"))
.option("--guild-id <id>", "Guild id (Discord)")
.action(async (opts) => {
await runMessageAction("emoji-list", opts);
});
withMessageBase(
emoji
.command("upload")
.description("Upload an emoji")
.requiredOption("--guild-id <id>", "Guild id"),
)
.requiredOption("--emoji-name <name>", "Emoji name")
.requiredOption("--media <path-or-url>", "Emoji media (path or URL)")
.option(
"--role-ids <id>",
"Role id (repeat)",
collectOption,
[] as string[],
)
.option(
"-s, --max-selections <n>",
"How many options can be selected (default: 1)",
)
.option(
"--duration-hours <n>",
"Poll duration in hours (Discord only, default: 24)",
)
.option(
"--provider <provider>",
"Delivery provider: whatsapp|discord (default: whatsapp)",
)
.option("--dry-run", "Print payload and skip sending", false)
.option("--json", "Output result as JSON", false)
.option("--verbose", "Verbose logging", false)
.addHelpText(
"after",
`
Examples:
clawdbot poll --to +15555550123 -q "Lunch today?" -o "Yes" -o "No" -o "Maybe"
clawdbot poll --to 123456789@g.us -q "Meeting time?" -o "10am" -o "2pm" -o "4pm" -s 2
clawdbot poll --to channel:123456789 -q "Snack?" -o "Pizza" -o "Sushi" --provider discord
clawdbot poll --to channel:123456789 -q "Plan?" -o "A" -o "B" --provider discord --duration-hours 48`,
)
.action(async (opts) => {
setVerbose(Boolean(opts.verbose));
const deps = createDefaultDeps();
try {
await pollCommand(opts, deps, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
await runMessageAction("emoji-upload", opts);
});
const sticker = message.command("sticker").description("Sticker actions");
withMessageBase(
withRequiredMessageTarget(
sticker.command("send").description("Send stickers"),
),
)
.requiredOption("--sticker-id <id>", "Sticker id (repeat)", collectOption)
.option("-m, --message <text>", "Optional message body")
.action(async (opts) => {
await runMessageAction("sticker", opts);
});
withMessageBase(
sticker
.command("upload")
.description("Upload a sticker")
.requiredOption("--guild-id <id>", "Guild id"),
)
.requiredOption("--sticker-name <name>", "Sticker name")
.requiredOption("--sticker-desc <text>", "Sticker description")
.requiredOption("--sticker-tags <tags>", "Sticker tags")
.requiredOption("--media <path-or-url>", "Sticker media (path or URL)")
.action(async (opts) => {
await runMessageAction("sticker-upload", opts);
});
const role = message.command("role").description("Role actions");
withMessageBase(
role
.command("info")
.description("List roles")
.requiredOption("--guild-id <id>", "Guild id"),
).action(async (opts) => {
await runMessageAction("role-info", opts);
});
withMessageBase(
role
.command("add")
.description("Add role to a member")
.requiredOption("--guild-id <id>", "Guild id")
.requiredOption("--user-id <id>", "User id")
.requiredOption("--role-id <id>", "Role id"),
).action(async (opts) => {
await runMessageAction("role-add", opts);
});
withMessageBase(
role
.command("remove")
.description("Remove role from a member")
.requiredOption("--guild-id <id>", "Guild id")
.requiredOption("--user-id <id>", "User id")
.requiredOption("--role-id <id>", "Role id"),
).action(async (opts) => {
await runMessageAction("role-remove", opts);
});
const channel = message.command("channel").description("Channel actions");
withMessageBase(
channel
.command("info")
.description("Fetch channel info")
.requiredOption("--channel-id <id>", "Channel id"),
).action(async (opts) => {
await runMessageAction("channel-info", opts);
});
withMessageBase(
channel
.command("list")
.description("List channels")
.requiredOption("--guild-id <id>", "Guild id"),
).action(async (opts) => {
await runMessageAction("channel-list", opts);
});
const member = message.command("member").description("Member actions");
withMessageBase(
member
.command("info")
.description("Fetch member info")
.requiredOption("--user-id <id>", "User id"),
)
.option("--guild-id <id>", "Guild id (Discord)")
.action(async (opts) => {
await runMessageAction("member-info", opts);
});
const voice = message.command("voice").description("Voice actions");
withMessageBase(
voice
.command("status")
.description("Fetch voice status")
.requiredOption("--guild-id <id>", "Guild id")
.requiredOption("--user-id <id>", "User id"),
).action(async (opts) => {
await runMessageAction("voice-status", opts);
});
const event = message.command("event").description("Event actions");
withMessageBase(
event
.command("list")
.description("List scheduled events")
.requiredOption("--guild-id <id>", "Guild id"),
).action(async (opts) => {
await runMessageAction("event-list", opts);
});
withMessageBase(
event
.command("create")
.description("Create a scheduled event")
.requiredOption("--guild-id <id>", "Guild id")
.requiredOption("--event-name <name>", "Event name")
.requiredOption("--start-time <iso>", "Event start time"),
)
.option("--end-time <iso>", "Event end time")
.option("--desc <text>", "Event description")
.option("--channel-id <id>", "Channel id")
.option("--location <text>", "Event location")
.option("--event-type <stage|external|voice>", "Event type")
.action(async (opts) => {
await runMessageAction("event-create", opts);
});
withMessageBase(
message
.command("timeout")
.description("Timeout a member")
.requiredOption("--guild-id <id>", "Guild id")
.requiredOption("--user-id <id>", "User id"),
)
.option("--duration-min <n>", "Timeout duration minutes")
.option("--until <iso>", "Timeout until")
.option("--reason <text>", "Moderation reason")
.action(async (opts) => {
await runMessageAction("timeout", opts);
});
withMessageBase(
message
.command("kick")
.description("Kick a member")
.requiredOption("--guild-id <id>", "Guild id")
.requiredOption("--user-id <id>", "User id"),
)
.option("--reason <text>", "Moderation reason")
.action(async (opts) => {
await runMessageAction("kick", opts);
});
withMessageBase(
message
.command("ban")
.description("Ban a member")
.requiredOption("--guild-id <id>", "Guild id")
.requiredOption("--user-id <id>", "User id"),
)
.option("--reason <text>", "Moderation reason")
.option("--delete-days <n>", "Ban delete message days")
.action(async (opts) => {
await runMessageAction("ban", opts);
});
program

View File

@@ -411,7 +411,7 @@ export async function agentCommand(
let result: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
let fallbackProvider = provider;
let fallbackModel = model;
const claudeResumeId = sessionEntry?.claudeCliSessionId?.trim();
const claudeSessionId = sessionEntry?.claudeCliSessionId?.trim();
try {
const messageProvider = resolveMessageProvider(
opts.messageProvider,
@@ -436,7 +436,7 @@ export async function agentCommand(
timeoutMs,
runId,
extraSystemPrompt: opts.extraSystemPrompt,
resumeSessionId: claudeResumeId,
claudeSessionId,
});
}
return runEmbeddedPiAgent({

View File

@@ -38,10 +38,9 @@ describe("buildAuthChoiceOptions", () => {
version: 1,
profiles: {
[CLAUDE_CLI_PROFILE_ID]: {
type: "oauth",
type: "token",
provider: "anthropic",
access: "token",
refresh: "refresh",
token: "token",
expires: Date.now() + 60 * 60 * 1000,
},
},

View File

@@ -61,7 +61,7 @@ export function buildAuthChoiceOptions(params: {
}
const claudeCli = params.store.profiles[CLAUDE_CLI_PROFILE_ID];
if (claudeCli?.type === "oauth") {
if (claudeCli?.type === "oauth" || claudeCli?.type === "token") {
options.push({
value: "claude-cli",
label: "Anthropic OAuth (Claude CLI)",
@@ -75,7 +75,11 @@ export function buildAuthChoiceOptions(params: {
});
}
options.push({ value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" });
options.push({
value: "token",
label: "Anthropic token (paste setup-token)",
hint: "Run `claude setup-token`, then paste the token",
});
options.push({
value: "openai-codex",
@@ -87,6 +91,7 @@ export function buildAuthChoiceOptions(params: {
});
options.push({ value: "gemini-api-key", label: "Google Gemini API key" });
options.push({ value: "apiKey", label: "Anthropic API key" });
// Token flow is currently Anthropic-only; use CLI for advanced providers.
options.push({ value: "minimax", label: "Minimax M2.1 (LM Studio)" });
if (params.includeSkip) {
options.push({ value: "skip", label: "Skip for now" });

View File

@@ -1,5 +1,4 @@
import {
loginAnthropic,
loginOpenAICodex,
type OAuthCredentials,
type OAuthProvider,
@@ -10,6 +9,7 @@ import {
CODEX_CLI_PROFILE_ID,
ensureAuthProfileStore,
listProfilesForProvider,
upsertAuthProfile,
} from "../agents/auth-profiles.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import {
@@ -18,6 +18,7 @@ import {
} from "../agents/model-auth.js";
import { loadModelCatalog } from "../agents/model-catalog.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import { parseDurationMs } from "../cli/parse-duration.js";
import type { ClawdbotConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
@@ -25,6 +26,10 @@ import {
isRemoteEnvironment,
loginAntigravityVpsAware,
} from "./antigravity-oauth.js";
import {
buildTokenProfileId,
validateAnthropicSetupToken,
} from "./auth-token.js";
import {
applyGoogleGeminiModelDefault,
GOOGLE_GEMINI_DEFAULT_MODEL,
@@ -132,47 +137,7 @@ export async function applyAuthChoice(params: {
);
};
if (params.authChoice === "oauth") {
await params.prompter.note(
"Browser will open. Paste the code shown after login (code#state).",
"Anthropic OAuth",
);
const spin = params.prompter.progress("Waiting for authorization…");
let oauthCreds: OAuthCredentials | null = null;
try {
oauthCreds = await loginAnthropic(
async (url) => {
await openUrl(url);
params.runtime.log(`Open: ${url}`);
},
async () => {
const code = await params.prompter.text({
message: "Paste authorization code (code#state)",
validate: (value) => (value?.trim() ? undefined : "Required"),
});
return String(code);
},
);
spin.stop("OAuth complete");
if (oauthCreds) {
await writeOAuthCredentials("anthropic", oauthCreds, params.agentDir);
const profileId = `anthropic:${oauthCreds.email ?? "default"}`;
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId,
provider: "anthropic",
mode: "oauth",
email: oauthCreds.email ?? undefined,
});
}
} catch (err) {
spin.stop("OAuth failed");
params.runtime.error(String(err));
await params.prompter.note(
"Trouble with OAuth? See https://docs.clawd.bot/start/faq",
"OAuth help",
);
}
} else if (params.authChoice === "claude-cli") {
if (params.authChoice === "claude-cli") {
const store = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
});
@@ -202,18 +167,134 @@ export async function applyAuthChoice(params: {
});
if (!storeWithKeychain.profiles[CLAUDE_CLI_PROFILE_ID]) {
await params.prompter.note(
process.platform === "darwin"
? 'No Claude CLI credentials found in Keychain ("Claude Code-credentials") or ~/.claude/.credentials.json.'
: "No Claude CLI credentials found at ~/.claude/.credentials.json.",
"Claude CLI OAuth",
);
return { config: nextConfig, agentModelOverride };
if (process.stdin.isTTY) {
const runNow = await params.prompter.confirm({
message: "Run `claude setup-token` now?",
initialValue: true,
});
if (runNow) {
const res = await (async () => {
const { spawnSync } = await import("node:child_process");
return spawnSync("claude", ["setup-token"], { stdio: "inherit" });
})();
if (res.error) {
await params.prompter.note(
`Failed to run claude: ${String(res.error)}`,
"Claude setup-token",
);
}
}
} else {
await params.prompter.note(
"`claude setup-token` requires an interactive TTY.",
"Claude setup-token",
);
}
const refreshed = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: true,
});
if (!refreshed.profiles[CLAUDE_CLI_PROFILE_ID]) {
await params.prompter.note(
process.platform === "darwin"
? 'No Claude CLI credentials found in Keychain ("Claude Code-credentials") or ~/.claude/.credentials.json.'
: "No Claude CLI credentials found at ~/.claude/.credentials.json.",
"Claude CLI OAuth",
);
return { config: nextConfig, agentModelOverride };
}
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
mode: "oauth",
mode: "token",
});
} else if (params.authChoice === "token" || params.authChoice === "oauth") {
const profileNameRaw = await params.prompter.text({
message: "Token name (blank = default)",
placeholder: "default",
});
const provider = (await params.prompter.select({
message: "Token provider",
options: [{ value: "anthropic", label: "Anthropic (only supported)" }],
})) as "anthropic";
const profileId = buildTokenProfileId({
provider,
name: String(profileNameRaw ?? ""),
});
const store = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
});
const existing = store.profiles[profileId];
if (existing?.type === "token") {
const useExisting = await params.prompter.confirm({
message: `Use existing token "${profileId}"?`,
initialValue: true,
});
if (useExisting) {
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId,
provider,
mode: "token",
});
return { config: nextConfig, agentModelOverride };
}
}
await params.prompter.note(
[
"Run `claude setup-token` in your terminal.",
"Then paste the generated token below.",
].join("\n"),
"Anthropic token",
);
const tokenRaw = await params.prompter.text({
message: "Paste Anthropic setup-token",
validate: (value) => validateAnthropicSetupToken(String(value ?? "")),
});
const token = String(tokenRaw).trim();
const wantsExpiry = await params.prompter.confirm({
message: "Does this token expire?",
initialValue: false,
});
const expiresInRaw = wantsExpiry
? await params.prompter.text({
message: "Expires in (duration)",
initialValue: "365d",
validate: (value) => {
try {
parseDurationMs(String(value ?? ""), { defaultUnit: "d" });
return undefined;
} catch {
return "Invalid duration (e.g. 365d, 12h, 30m)";
}
},
})
: "";
const expiresIn = String(expiresInRaw).trim();
const expires = expiresIn
? Date.now() + parseDurationMs(expiresIn, { defaultUnit: "d" })
: undefined;
upsertAuthProfile({
profileId,
agentDir: params.agentDir,
credential: {
type: "token",
provider,
token,
...(expires ? { expires } : {}),
},
});
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId,
provider,
mode: "token",
});
} else if (params.authChoice === "openai-codex") {
const isRemote = isRemoteEnvironment();

View File

@@ -0,0 +1,37 @@
import { normalizeProviderId } from "../agents/model-selection.js";
export const ANTHROPIC_SETUP_TOKEN_PREFIX = "sk-ant-oat01-";
export const ANTHROPIC_SETUP_TOKEN_MIN_LENGTH = 80;
export const DEFAULT_TOKEN_PROFILE_NAME = "default";
export function normalizeTokenProfileName(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return DEFAULT_TOKEN_PROFILE_NAME;
const slug = trimmed
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "");
return slug || DEFAULT_TOKEN_PROFILE_NAME;
}
export function buildTokenProfileId(params: {
provider: string;
name: string;
}): string {
const provider = normalizeProviderId(params.provider);
const name = normalizeTokenProfileName(params.name);
return `${provider}:${name}`;
}
export function validateAnthropicSetupToken(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) return "Required";
if (!trimmed.startsWith(ANTHROPIC_SETUP_TOKEN_PREFIX)) {
return `Expected token starting with ${ANTHROPIC_SETUP_TOKEN_PREFIX}`;
}
if (trimmed.length < ANTHROPIC_SETUP_TOKEN_MIN_LENGTH) {
return "Token looks too short; paste the full setup-token";
}
return undefined;
}

View File

@@ -1,17 +1,16 @@
import path from "node:path";
import {
confirm,
intro,
multiselect,
note,
outro,
select,
confirm as clackConfirm,
intro as clackIntro,
multiselect as clackMultiselect,
note as clackNote,
outro as clackOutro,
select as clackSelect,
spinner,
text,
text as clackText,
} from "@clack/prompts";
import {
loginAnthropic,
loginOpenAICodex,
type OAuthCredentials,
type OAuthProvider,
@@ -20,7 +19,9 @@ import {
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
ensureAuthProfileStore,
upsertAuthProfile,
} from "../agents/auth-profiles.js";
import { parseDurationMs } from "../cli/parse-duration.js";
import { createCliProgress } from "../cli/progress.js";
import type { ClawdbotConfig } from "../config/config.js";
import {
@@ -38,6 +39,11 @@ import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { theme } from "../terminal/theme.js";
import {
stylePromptHint,
stylePromptMessage,
stylePromptTitle,
} from "../terminal/prompt-style.js";
import { resolveUserPath, sleep } from "../utils.js";
import { createClackPrompter } from "../wizard/clack-prompter.js";
import {
@@ -45,6 +51,10 @@ import {
loginAntigravityVpsAware,
} from "./antigravity-oauth.js";
import { buildAuthChoiceOptions } from "./auth-choice-options.js";
import {
buildTokenProfileId,
validateAnthropicSetupToken,
} from "./auth-token.js";
import {
DEFAULT_GATEWAY_DAEMON_RUNTIME,
GATEWAY_DAEMON_RUNTIME_OPTIONS,
@@ -99,6 +109,43 @@ type ConfigureWizardParams = {
sections?: WizardSection[];
};
const intro = (message: string) =>
clackIntro(stylePromptTitle(message) ?? message);
const outro = (message: string) =>
clackOutro(stylePromptTitle(message) ?? message);
const note = (message: string, title?: string) =>
clackNote(message, stylePromptTitle(title));
const text = (params: Parameters<typeof clackText>[0]) =>
clackText({
...params,
message: stylePromptMessage(params.message),
});
const confirm = (params: Parameters<typeof clackConfirm>[0]) =>
clackConfirm({
...params,
message: stylePromptMessage(params.message),
});
const select = <T>(params: Parameters<typeof clackSelect<T>>[0]) =>
clackSelect({
...params,
message: stylePromptMessage(params.message),
options: params.options.map((opt) =>
opt.hint === undefined
? opt
: { ...opt, hint: stylePromptHint(opt.hint) },
),
});
const multiselect = <T>(params: Parameters<typeof clackMultiselect<T>>[0]) =>
clackMultiselect({
...params,
message: stylePromptMessage(params.message),
options: params.options.map((opt) =>
opt.hint === undefined
? opt
: { ...opt, hint: stylePromptHint(opt.hint) },
),
});
const startOscSpinner = (label: string) => {
const spin = spinner();
spin.start(theme.accent(label));
@@ -302,6 +349,7 @@ async function promptAuthConfig(
) as
| "oauth"
| "claude-cli"
| "token"
| "openai-codex"
| "codex-cli"
| "antigravity"
@@ -312,52 +360,148 @@ async function promptAuthConfig(
let next = cfg;
if (authChoice === "oauth") {
note(
"Browser will open. Paste the code shown after login (code#state).",
"Anthropic OAuth",
);
const spin = startOscSpinner("Waiting for authorization…");
let oauthCreds: OAuthCredentials | null = null;
try {
oauthCreds = await loginAnthropic(
async (url) => {
await openUrl(url);
runtime.log(`Open: ${url}`);
},
async () => {
const code = guardCancel(
await text({
message: "Paste authorization code (code#state)",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
runtime,
);
return String(code);
},
if (authChoice === "claude-cli") {
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: false,
});
if (!store.profiles[CLAUDE_CLI_PROFILE_ID] && process.stdin.isTTY) {
note(
[
"No Claude CLI credentials found yet.",
"If you have a Claude Pro/Max subscription, run `claude setup-token`.",
].join("\n"),
"Claude CLI",
);
spin.stop("OAuth complete");
if (oauthCreds) {
await writeOAuthCredentials("anthropic", oauthCreds);
const profileId = `anthropic:${oauthCreds.email ?? "default"}`;
next = applyAuthProfileConfig(next, {
profileId,
provider: "anthropic",
mode: "oauth",
email: oauthCreds.email ?? undefined,
});
const runNow = guardCancel(
await confirm({
message: "Run `claude setup-token` now?",
initialValue: true,
}),
runtime,
);
if (runNow) {
const res = await (async () => {
const { spawnSync } = await import("node:child_process");
return spawnSync("claude", ["setup-token"], { stdio: "inherit" });
})();
if (res.error) {
note(
`Failed to run claude: ${String(res.error)}`,
"Claude setup-token",
);
}
}
} catch (err) {
spin.stop("OAuth failed");
runtime.error(String(err));
note("Trouble with OAuth? See https://docs.clawd.bot/start/faq", "OAuth");
}
} else if (authChoice === "claude-cli") {
next = applyAuthProfileConfig(next, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
mode: "oauth",
mode: "token",
});
} else if (authChoice === "token" || authChoice === "oauth") {
const profileNameRaw = guardCancel(
await text({
message: "Token name (blank = default)",
placeholder: "default",
}),
runtime,
);
const provider = guardCancel(
await select({
message: "Token provider",
options: [
{
value: "anthropic",
label: "Anthropic (only supported)",
},
],
}),
runtime,
) as "anthropic";
const profileId = buildTokenProfileId({
provider,
name: String(profileNameRaw ?? ""),
});
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: false,
});
const existing = store.profiles[profileId];
if (existing?.type === "token") {
const useExisting = guardCancel(
await confirm({
message: `Use existing token "${profileId}"?`,
initialValue: true,
}),
runtime,
);
if (useExisting) {
next = applyAuthProfileConfig(next, {
profileId,
provider,
mode: "token",
});
return next;
}
}
note(
[
"Run `claude setup-token` in your terminal.",
"Then paste the generated token below.",
].join("\n"),
"Anthropic token",
);
const tokenRaw = guardCancel(
await text({
message: "Paste Anthropic setup-token",
validate: (value) => validateAnthropicSetupToken(String(value ?? "")),
}),
runtime,
);
const token = String(tokenRaw).trim();
const wantsExpiry = guardCancel(
await confirm({
message: "Does this token expire?",
initialValue: false,
}),
runtime,
);
const expiresInRaw = wantsExpiry
? guardCancel(
await text({
message: "Expires in (duration)",
initialValue: "365d",
validate: (value) => {
try {
parseDurationMs(String(value ?? ""), { defaultUnit: "d" });
return undefined;
} catch {
return "Invalid duration (e.g. 365d, 12h, 30m)";
}
},
}),
runtime,
)
: "";
const expiresIn = String(expiresInRaw).trim();
const expires = expiresIn
? Date.now() + parseDurationMs(expiresIn, { defaultUnit: "d" })
: undefined;
upsertAuthProfile({
profileId,
credential: {
type: "token",
provider,
token,
...(expires ? { expires } : {}),
},
});
next = applyAuthProfileConfig(next, { profileId, provider, mode: "token" });
} else if (authChoice === "openai-codex") {
const isRemote = isRemoteEnvironment();
note(
@@ -787,13 +931,41 @@ export async function runConfigureWizard(
await multiselect({
message: "Select sections to configure",
options: [
{ value: "workspace", label: "Workspace" },
{ value: "model", label: "Model/auth" },
{ value: "gateway", label: "Gateway config" },
{ value: "daemon", label: "Gateway daemon" },
{ value: "providers", label: "Providers" },
{ value: "skills", label: "Skills" },
{ value: "health", label: "Health check" },
{
value: "workspace",
label: "Workspace",
hint: "Set agent workspace + ensure sessions",
},
{
value: "model",
label: "Model/auth",
hint: "Pick model + auth profile sources",
},
{
value: "gateway",
label: "Gateway config",
hint: "Port/bind/auth/control UI settings",
},
{
value: "daemon",
label: "Gateway daemon",
hint: "Install/manage the background service",
},
{
value: "providers",
label: "Providers",
hint: "Link WhatsApp/Telegram/etc and defaults",
},
{
value: "skills",
label: "Skills",
hint: "Install/enable workspace skills",
},
{
value: "health",
label: "Health check",
hint: "Run gateway + provider checks",
},
],
}),
runtime,

View File

@@ -1,4 +1,4 @@
import { note } from "@clack/prompts";
import { note as clackNote } from "@clack/prompts";
import {
buildAuthHealthSummary,
@@ -13,8 +13,12 @@ import {
resolveApiKeyForProfile,
} from "../agents/auth-profiles.js";
import type { ClawdbotConfig } from "../config/config.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
const note = (message: string, title?: string) =>
clackNote(message, stylePromptTitle(title));
export async function maybeRepairAnthropicOAuthProfileId(
cfg: ClawdbotConfig,
prompter: DoctorPrompter,
@@ -86,7 +90,7 @@ export async function noteAuthProfileHealth(params: {
const findIssues = () =>
summary.profiles.filter(
(profile) =>
profile.type === "oauth" &&
(profile.type === "oauth" || profile.type === "token") &&
(profile.status === "expired" ||
profile.status === "expiring" ||
profile.status === "missing"),
@@ -96,13 +100,15 @@ export async function noteAuthProfileHealth(params: {
if (issues.length === 0) return;
const shouldRefresh = await params.prompter.confirmRepair({
message: "Refresh expiring OAuth tokens now?",
message: "Refresh expiring OAuth tokens now? (static tokens need re-auth)",
initialValue: true,
});
if (shouldRefresh) {
const refreshTargets = issues.filter((issue) =>
["expired", "expiring", "missing"].includes(issue.status),
const refreshTargets = issues.filter(
(issue) =>
issue.type === "oauth" &&
["expired", "expiring", "missing"].includes(issue.status),
);
const errors: string[] = [];
for (const profile of refreshTargets) {

View File

@@ -1,6 +1,6 @@
import path from "node:path";
import { note } from "@clack/prompts";
import { note as clackNote } from "@clack/prompts";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveGatewayPort, resolveIsNixMode } from "../config/paths.js";
@@ -31,6 +31,10 @@ import {
type GatewayDaemonRuntime,
} from "./daemon-runtime.js";
import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
const note = (message: string, title?: string) =>
clackNote(message, stylePromptTitle(title));
function detectGatewayRuntime(
programArguments: string[] | undefined,

View File

@@ -1,7 +1,7 @@
import os from "node:os";
import path from "node:path";
import { note } from "@clack/prompts";
import { note as clackNote } from "@clack/prompts";
import type { ClawdbotConfig } from "../config/config.js";
import {
@@ -12,8 +12,12 @@ import {
writeConfigFile,
} from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
import { resolveUserPath } from "../utils.js";
const note = (message: string, title?: string) =>
clackNote(message, stylePromptTitle(title));
function resolveLegacyConfigPath(env: NodeJS.ProcessEnv): string {
const override = env.CLAWDIS_CONFIG_PATH?.trim();
if (override) return override;

View File

@@ -1,6 +1,7 @@
import { confirm, select } from "@clack/prompts";
import type { RuntimeEnv } from "../runtime.js";
import { stylePromptHint, stylePromptMessage } from "../terminal/prompt-style.js";
import { guardCancel } from "./onboard-helpers.js";
export type DoctorOptions = {
@@ -42,7 +43,15 @@ export function createDoctorPrompter(params: {
if (nonInteractive) return false;
if (shouldRepair) return true;
if (!canPrompt) return Boolean(p.initialValue ?? false);
return guardCancel(await confirm(p), params.runtime) === true;
return (
guardCancel(
await confirm({
...p,
message: stylePromptMessage(p.message),
}),
params.runtime,
) === true
);
};
return {
@@ -56,7 +65,15 @@ export function createDoctorPrompter(params: {
if (shouldRepair && shouldForce) return true;
if (shouldRepair && !shouldForce) return false;
if (!canPrompt) return Boolean(p.initialValue ?? false);
return guardCancel(await confirm(p), params.runtime) === true;
return (
guardCancel(
await confirm({
...p,
message: stylePromptMessage(p.message),
}),
params.runtime,
) === true
);
},
confirmSkipInNonInteractive: async (p) => {
if (nonInteractive) return false;
@@ -65,7 +82,18 @@ export function createDoctorPrompter(params: {
},
select: async <T>(p: Parameters<typeof select>[0], fallback: T) => {
if (!canPrompt || shouldRepair) return fallback;
return guardCancel(await select(p), params.runtime) as T;
return guardCancel(
await select({
...p,
message: stylePromptMessage(p.message),
options: p.options.map((opt) =>
opt.hint === undefined
? opt
: { ...opt, hint: stylePromptHint(opt.hint) },
),
}),
params.runtime,
) as T;
},
shouldRepair,
shouldForce,

View File

@@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { note } from "@clack/prompts";
import { note as clackNote } from "@clack/prompts";
import {
DEFAULT_SANDBOX_BROWSER_IMAGE,
@@ -12,9 +12,13 @@ import {
import type { ClawdbotConfig } from "../config/config.js";
import { runCommandWithTimeout, runExec } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
import { replaceModernName } from "./doctor-legacy-config.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
const note = (message: string, title?: string) =>
clackNote(message, stylePromptTitle(title));
type SandboxScriptInfo = {
scriptPath: string;
cwd: string;

View File

@@ -1,11 +1,15 @@
import { note } from "@clack/prompts";
import { note as clackNote } from "@clack/prompts";
import type { ClawdbotConfig } from "../config/config.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
import { readProviderAllowFromStore } from "../pairing/pairing-store.js";
import { readTelegramAllowFromStore } from "../telegram/pairing-store.js";
import { resolveTelegramToken } from "../telegram/token.js";
import { normalizeE164 } from "../utils.js";
const note = (message: string, title?: string) =>
clackNote(message, stylePromptTitle(title));
export async function noteSecurityWarnings(cfg: ClawdbotConfig) {
const warnings: string[] = [];

View File

@@ -2,7 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { note } from "@clack/prompts";
import { note as clackNote } from "@clack/prompts";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
@@ -13,8 +13,12 @@ import {
resolveSessionTranscriptsDirForAgent,
resolveStorePath,
} from "../config/sessions.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
const note = (message: string, title?: string) =>
clackNote(message, stylePromptTitle(title));
type DoctorPrompterLike = {
confirmSkipInNonInteractive: (params: {
message: string;

View File

@@ -1,5 +1,5 @@
import path from "node:path";
import { intro, note, outro } from "@clack/prompts";
import { intro as clackIntro, note as clackNote, outro as clackOutro } from "@clack/prompts";
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import type { ClawdbotConfig } from "../config/config.js";
import {
@@ -20,6 +20,7 @@ import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
import { collectProvidersStatusIssues } from "../infra/providers-status-issues.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
import { resolveUserPath, sleep } from "../utils.js";
import {
DEFAULT_GATEWAY_DAEMON_RUNTIME,
@@ -71,6 +72,13 @@ import {
} from "./onboard-helpers.js";
import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js";
const intro = (message: string) =>
clackIntro(stylePromptTitle(message) ?? message);
const outro = (message: string) =>
clackOutro(stylePromptTitle(message) ?? message);
const note = (message: string, title?: string) =>
clackNote(message, stylePromptTitle(title));
function resolveMode(cfg: ClawdbotConfig): "local" | "remote" {
return cfg.gateway?.mode === "remote" ? "remote" : "local";
}

View File

@@ -0,0 +1,153 @@
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { CliDeps } from "../cli/deps.js";
import type { RuntimeEnv } from "../runtime.js";
import { messageCommand } from "./message.js";
let testConfig: Record<string, unknown> = {};
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => testConfig,
};
});
const callGatewayMock = vi.fn();
vi.mock("../gateway/call.js", () => ({
callGateway: (...args: unknown[]) => callGatewayMock(...args),
randomIdempotencyKey: () => "idem-1",
}));
const webAuthExists = vi.fn(async () => false);
vi.mock("../web/session.js", () => ({
webAuthExists: (...args: unknown[]) => webAuthExists(...args),
}));
const handleDiscordAction = vi.fn(async () => ({ details: { ok: true } }));
vi.mock("../agents/tools/discord-actions.js", () => ({
handleDiscordAction: (...args: unknown[]) => handleDiscordAction(...args),
}));
const handleSlackAction = vi.fn(async () => ({ details: { ok: true } }));
vi.mock("../agents/tools/slack-actions.js", () => ({
handleSlackAction: (...args: unknown[]) => handleSlackAction(...args),
}));
const handleTelegramAction = vi.fn(async () => ({ details: { ok: true } }));
vi.mock("../agents/tools/telegram-actions.js", () => ({
handleTelegramAction: (...args: unknown[]) => handleTelegramAction(...args),
}));
const handleWhatsAppAction = vi.fn(async () => ({ details: { ok: true } }));
vi.mock("../agents/tools/whatsapp-actions.js", () => ({
handleWhatsAppAction: (...args: unknown[]) => handleWhatsAppAction(...args),
}));
const originalTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
const originalDiscordToken = process.env.DISCORD_BOT_TOKEN;
beforeEach(() => {
process.env.TELEGRAM_BOT_TOKEN = "";
process.env.DISCORD_BOT_TOKEN = "";
testConfig = {};
callGatewayMock.mockReset();
webAuthExists.mockReset().mockResolvedValue(false);
handleDiscordAction.mockReset();
handleSlackAction.mockReset();
handleTelegramAction.mockReset();
handleWhatsAppAction.mockReset();
});
afterAll(() => {
process.env.TELEGRAM_BOT_TOKEN = originalTelegramToken;
process.env.DISCORD_BOT_TOKEN = originalDiscordToken;
});
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(() => {
throw new Error("exit");
}),
};
const makeDeps = (overrides: Partial<CliDeps> = {}): CliDeps => ({
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn(),
sendMessageSlack: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
...overrides,
});
describe("messageCommand", () => {
it("defaults provider when only one configured", async () => {
process.env.TELEGRAM_BOT_TOKEN = "token-abc";
const deps = makeDeps();
await messageCommand(
{
to: "123",
message: "hi",
},
deps,
runtime,
);
expect(handleTelegramAction).toHaveBeenCalled();
});
it("requires provider when multiple configured", async () => {
process.env.TELEGRAM_BOT_TOKEN = "token-abc";
process.env.DISCORD_BOT_TOKEN = "token-discord";
const deps = makeDeps();
await expect(
messageCommand(
{
to: "123",
message: "hi",
},
deps,
runtime,
),
).rejects.toThrow(/Provider is required/);
});
it("sends via gateway for WhatsApp", async () => {
callGatewayMock.mockResolvedValueOnce({ messageId: "g1" });
const deps = makeDeps();
await messageCommand(
{
action: "send",
provider: "whatsapp",
to: "+1",
message: "hi",
},
deps,
runtime,
);
expect(callGatewayMock).toHaveBeenCalled();
});
it("routes discord polls through message action", async () => {
const deps = makeDeps();
await messageCommand(
{
action: "poll",
provider: "discord",
to: "channel:123",
pollQuestion: "Snack?",
pollOption: ["Pizza", "Sushi"],
},
deps,
runtime,
);
expect(handleDiscordAction).toHaveBeenCalledWith(
expect.objectContaining({
action: "poll",
to: "channel:123",
}),
expect.any(Object),
);
});
});

1113
src/commands/message.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,11 @@ export {
modelsAliasesListCommand,
modelsAliasesRemoveCommand,
} from "./models/aliases.js";
export {
modelsAuthAddCommand,
modelsAuthPasteTokenCommand,
modelsAuthSetupTokenCommand,
} from "./models/auth.js";
export {
modelsFallbacksAddCommand,
modelsFallbacksClearCommand,

236
src/commands/models/auth.ts Normal file
View File

@@ -0,0 +1,236 @@
import { spawnSync } from "node:child_process";
import {
confirm as clackConfirm,
select as clackSelect,
text as clackText,
} from "@clack/prompts";
import {
CLAUDE_CLI_PROFILE_ID,
ensureAuthProfileStore,
upsertAuthProfile,
} from "../../agents/auth-profiles.js";
import { normalizeProviderId } from "../../agents/model-selection.js";
import { parseDurationMs } from "../../cli/parse-duration.js";
import { CONFIG_PATH_CLAWDBOT } from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js";
import {
stylePromptHint,
stylePromptMessage,
} from "../../terminal/prompt-style.js";
import { applyAuthProfileConfig } from "../onboard-auth.js";
import { updateConfig } from "./shared.js";
const confirm = (params: Parameters<typeof clackConfirm>[0]) =>
clackConfirm({
...params,
message: stylePromptMessage(params.message),
});
const text = (params: Parameters<typeof clackText>[0]) =>
clackText({
...params,
message: stylePromptMessage(params.message),
});
const select = <T>(params: Parameters<typeof clackSelect<T>>[0]) =>
clackSelect({
...params,
message: stylePromptMessage(params.message),
options: params.options.map((opt) =>
opt.hint === undefined
? opt
: { ...opt, hint: stylePromptHint(opt.hint) },
),
});
type TokenProvider = "anthropic";
function resolveTokenProvider(raw?: string): TokenProvider | "custom" | null {
const trimmed = raw?.trim();
if (!trimmed) return null;
const normalized = normalizeProviderId(trimmed);
if (normalized === "anthropic") return "anthropic";
return "custom";
}
function resolveDefaultTokenProfileId(provider: string): string {
return `${normalizeProviderId(provider)}:manual`;
}
export async function modelsAuthSetupTokenCommand(
opts: { provider?: string; yes?: boolean },
runtime: RuntimeEnv,
) {
const provider = resolveTokenProvider(opts.provider ?? "anthropic");
if (provider !== "anthropic") {
throw new Error(
"Only --provider anthropic is supported for setup-token (uses `claude setup-token`).",
);
}
if (!process.stdin.isTTY) {
throw new Error("setup-token requires an interactive TTY.");
}
if (!opts.yes) {
const proceed = await confirm({
message: "Run `claude setup-token` now?",
initialValue: true,
});
if (!proceed) return;
}
const res = spawnSync("claude", ["setup-token"], { stdio: "inherit" });
if (res.error) throw res.error;
if (typeof res.status === "number" && res.status !== 0) {
throw new Error(`claude setup-token failed (exit ${res.status})`);
}
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: true,
});
const synced = store.profiles[CLAUDE_CLI_PROFILE_ID];
if (!synced) {
throw new Error(
`No Claude CLI credentials found after setup-token. Expected auth profile ${CLAUDE_CLI_PROFILE_ID}.`,
);
}
await updateConfig((cfg) =>
applyAuthProfileConfig(cfg, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
mode: "token",
}),
);
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Auth profile: ${CLAUDE_CLI_PROFILE_ID} (anthropic/token)`);
}
export async function modelsAuthPasteTokenCommand(
opts: {
provider?: string;
profileId?: string;
expiresIn?: string;
},
runtime: RuntimeEnv,
) {
const rawProvider = opts.provider?.trim();
if (!rawProvider) {
throw new Error("Missing --provider.");
}
const provider = normalizeProviderId(rawProvider);
const profileId =
opts.profileId?.trim() || resolveDefaultTokenProfileId(provider);
const tokenInput = await text({
message: `Paste token for ${provider}`,
validate: (value) => (value?.trim() ? undefined : "Required"),
});
const token = String(tokenInput).trim();
const expires =
opts.expiresIn?.trim() && opts.expiresIn.trim().length > 0
? Date.now() +
parseDurationMs(String(opts.expiresIn).trim(), { defaultUnit: "d" })
: undefined;
upsertAuthProfile({
profileId,
credential: {
type: "token",
provider,
token,
...(expires ? { expires } : {}),
},
});
await updateConfig((cfg) =>
applyAuthProfileConfig(cfg, { profileId, provider, mode: "token" }),
);
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Auth profile: ${profileId} (${provider}/token)`);
}
export async function modelsAuthAddCommand(
_opts: Record<string, never>,
runtime: RuntimeEnv,
) {
const provider = (await select({
message: "Token provider",
options: [
{ value: "anthropic", label: "anthropic" },
{ value: "custom", label: "custom (type provider id)" },
],
})) as TokenProvider | "custom";
const providerId =
provider === "custom"
? normalizeProviderId(
String(
await text({
message: "Provider id",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
),
)
: provider;
const method = (await select({
message: "Token method",
options: [
...(providerId === "anthropic"
? [
{
value: "setup-token",
label: "setup-token (claude)",
hint: "Runs `claude setup-token` (recommended)",
},
]
: []),
{ value: "paste", label: "paste token" },
],
})) as "setup-token" | "paste";
if (method === "setup-token") {
await modelsAuthSetupTokenCommand({ provider: providerId }, runtime);
return;
}
const profileIdDefault = resolveDefaultTokenProfileId(providerId);
const profileId = String(
await text({
message: "Profile id",
initialValue: profileIdDefault,
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
const wantsExpiry = await confirm({
message: "Does this token expire?",
initialValue: false,
});
const expiresIn = wantsExpiry
? String(
await text({
message: "Expires in (duration)",
initialValue: "365d",
validate: (value) => {
try {
parseDurationMs(String(value ?? ""), { defaultUnit: "d" });
return undefined;
} catch {
return "Invalid duration (e.g. 365d, 12h, 30m)";
}
},
}),
).trim()
: undefined;
await modelsAuthPasteTokenCommand(
{ provider: providerId, profileId, expiresIn },
runtime,
);
}

View File

@@ -159,6 +159,7 @@ type ProviderAuthOverview = {
profiles: {
count: number;
oauth: number;
token: number;
apiKey: number;
labels: string[];
};
@@ -180,6 +181,9 @@ function resolveProviderAuthOverview(params: {
if (profile.type === "api_key") {
return `${profileId}=${maskApiKey(profile.key)}`;
}
if (profile.type === "token") {
return `${profileId}=token:${maskApiKey(profile.token)}`;
}
const display = resolveAuthProfileDisplayLabel({ cfg, store, profileId });
const suffix =
display === profileId
@@ -192,6 +196,9 @@ function resolveProviderAuthOverview(params: {
const oauthCount = profiles.filter(
(id) => store.profiles[id]?.type === "oauth",
).length;
const tokenCount = profiles.filter(
(id) => store.profiles[id]?.type === "token",
).length;
const apiKeyCount = profiles.filter(
(id) => store.profiles[id]?.type === "api_key",
).length;
@@ -227,6 +234,7 @@ function resolveProviderAuthOverview(params: {
profiles: {
count: profiles.length,
oauth: oauthCount,
token: tokenCount,
apiKey: apiKeyCount,
labels,
},
@@ -739,11 +747,16 @@ export async function modelsStatusCommand(
const providersWithOauth = providerAuth
.filter(
(entry) => entry.profiles.oauth > 0 || entry.env?.value === "OAuth (env)",
(entry) =>
entry.profiles.oauth > 0 ||
entry.profiles.token > 0 ||
entry.env?.value === "OAuth (env)",
)
.map((entry) => {
const count =
entry.profiles.oauth || (entry.env?.value === "OAuth (env)" ? 1 : 0);
entry.profiles.oauth +
entry.profiles.token +
(entry.env?.value === "OAuth (env)" ? 1 : 0);
return `${entry.provider} (${count})`;
});
@@ -754,7 +767,7 @@ export async function modelsStatusCommand(
providers,
});
const oauthProfiles = authHealth.profiles.filter(
(profile) => profile.type === "oauth",
(profile) => profile.type === "oauth" || profile.type === "token",
);
const checkStatus = (() => {
@@ -926,7 +939,7 @@ export async function modelsStatusCommand(
);
runtime.log(
`${label(
`Providers w/ OAuth (${providersWithOauth.length || 0})`,
`Providers w/ OAuth/tokens (${providersWithOauth.length || 0})`,
)}${colorize(rich, theme.muted, ":")} ${colorize(
rich,
providersWithOauth.length ? theme.info : theme.muted,
@@ -953,7 +966,7 @@ export async function modelsStatusCommand(
bits.push(
formatKeyValue(
"profiles",
`${entry.profiles.count} (oauth=${entry.profiles.oauth}, api_key=${entry.profiles.apiKey})`,
`${entry.profiles.count} (oauth=${entry.profiles.oauth}, token=${entry.profiles.token}, api_key=${entry.profiles.apiKey})`,
rich,
),
);
@@ -1003,7 +1016,7 @@ export async function modelsStatusCommand(
}
runtime.log("");
runtime.log(colorize(rich, theme.heading, "OAuth status"));
runtime.log(colorize(rich, theme.heading, "OAuth/token status"));
if (oauthProfiles.length === 0) {
runtime.log(colorize(rich, theme.muted, "- none"));
return;
@@ -1011,6 +1024,7 @@ export async function modelsStatusCommand(
const formatStatus = (status: string) => {
if (status === "ok") return colorize(rich, theme.success, "ok");
if (status === "static") return colorize(rich, theme.muted, "static");
if (status === "expiring") return colorize(rich, theme.warn, "expiring");
if (status === "missing") return colorize(rich, theme.warn, "unknown");
return colorize(rich, theme.error, "expired");
@@ -1020,9 +1034,12 @@ export async function modelsStatusCommand(
const labelText = profile.label || profile.profileId;
const label = colorize(rich, theme.accent, labelText);
const status = formatStatus(profile.status);
const expiry = profile.expiresAt
? ` expires in ${formatRemainingShort(profile.remainingMs)}`
: " expires unknown";
const expiry =
profile.status === "static"
? ""
: profile.expiresAt
? ` expires in ${formatRemainingShort(profile.remainingMs)}`
: " expires unknown";
const source =
profile.source !== "store"
? colorize(rich, theme.muted, ` (${profile.source})`)

View File

@@ -1,4 +1,8 @@
import { cancel, isCancel, multiselect } from "@clack/prompts";
import {
cancel,
isCancel,
multiselect as clackMultiselect,
} from "@clack/prompts";
import { resolveApiKeyForProvider } from "../../agents/model-auth.js";
import {
type ModelScanResult,
@@ -7,11 +11,27 @@ import {
import { withProgressTotals } from "../../cli/progress.js";
import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js";
import {
stylePromptHint,
stylePromptMessage,
stylePromptTitle,
} from "../../terminal/prompt-style.js";
import { formatMs, formatTokenK, updateConfig } from "./shared.js";
const MODEL_PAD = 42;
const CTX_PAD = 8;
const multiselect = <T>(params: Parameters<typeof clackMultiselect<T>>[0]) =>
clackMultiselect({
...params,
message: stylePromptMessage(params.message),
options: params.options.map((opt) =>
opt.hint === undefined
? opt
: { ...opt, hint: stylePromptHint(opt.hint) },
),
});
const pad = (value: string, size: number) => value.padEnd(size);
const truncate = (value: string, max: number) => {
@@ -268,7 +288,7 @@ export async function modelsScanCommand(
});
if (isCancel(selection)) {
cancel("Model scan cancelled.");
cancel(stylePromptTitle("Model scan cancelled.") ?? "Model scan cancelled.");
runtime.exit(0);
}
@@ -285,7 +305,9 @@ export async function modelsScanCommand(
});
if (isCancel(imageSelection)) {
cancel("Model scan cancelled.");
cancel(
stylePromptTitle("Model scan cancelled.") ?? "Model scan cancelled.",
);
runtime.exit(0);
}

View File

@@ -5,7 +5,10 @@ import path from "node:path";
import type { OAuthCredentials } from "@mariozechner/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import { writeOAuthCredentials } from "./onboard-auth.js";
import {
applyAuthProfileConfig,
writeOAuthCredentials,
} from "./onboard-auth.js";
describe("writeOAuthCredentials", () => {
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
@@ -49,7 +52,7 @@ describe("writeOAuthCredentials", () => {
expires: Date.now() + 60_000,
} satisfies OAuthCredentials;
await writeOAuthCredentials("anthropic", creds);
await writeOAuthCredentials("openai-codex", creds);
// Now writes to the multi-agent path: agents/main/agent
const authProfilePath = path.join(
@@ -63,7 +66,7 @@ describe("writeOAuthCredentials", () => {
const parsed = JSON.parse(raw) as {
profiles?: Record<string, OAuthCredentials & { type?: string }>;
};
expect(parsed.profiles?.["anthropic:default"]).toMatchObject({
expect(parsed.profiles?.["openai-codex:default"]).toMatchObject({
refresh: "refresh-token",
access: "access-token",
type: "oauth",
@@ -77,3 +80,28 @@ describe("writeOAuthCredentials", () => {
).rejects.toThrow();
});
});
describe("applyAuthProfileConfig", () => {
it("promotes the newly selected profile to the front of auth.order", () => {
const next = applyAuthProfileConfig(
{
auth: {
profiles: {
"anthropic:default": { provider: "anthropic", mode: "api_key" },
},
order: { anthropic: ["anthropic:default"] },
},
},
{
profileId: "anthropic:claude-cli",
provider: "anthropic",
mode: "oauth",
},
);
expect(next.auth?.order?.anthropic).toEqual([
"anthropic:claude-cli",
"anthropic:default",
]);
});
});

View File

@@ -51,8 +51,9 @@ export function applyAuthProfileConfig(
params: {
profileId: string;
provider: string;
mode: "api_key" | "oauth";
mode: "api_key" | "oauth" | "token";
email?: string;
preferProfileFirst?: boolean;
},
): ClawdbotConfig {
const profiles = {
@@ -67,13 +68,23 @@ export function applyAuthProfileConfig(
// Only maintain `auth.order` when the user explicitly configured it.
// Default behavior: no explicit order -> resolveAuthProfileOrder can round-robin by lastUsed.
const existingProviderOrder = cfg.auth?.order?.[params.provider];
const preferProfileFirst = params.preferProfileFirst ?? true;
const reorderedProviderOrder =
existingProviderOrder && preferProfileFirst
? [
params.profileId,
...existingProviderOrder.filter(
(profileId) => profileId !== params.profileId,
),
]
: existingProviderOrder;
const order =
existingProviderOrder !== undefined
? {
...cfg.auth?.order,
[params.provider]: existingProviderOrder.includes(params.profileId)
? existingProviderOrder
: [...existingProviderOrder, params.profileId],
[params.provider]: reorderedProviderOrder?.includes(params.profileId)
? reorderedProviderOrder
: [...(reorderedProviderOrder ?? []), params.profileId],
}
: cfg.auth?.order;
return {

View File

@@ -17,6 +17,7 @@ import { normalizeControlUiBasePath } from "../gateway/control-ui.js";
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
import { runCommandWithTimeout } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
import { VERSION } from "../version.js";
import type {
@@ -27,7 +28,7 @@ import type {
export function guardCancel<T>(value: T, runtime: RuntimeEnv): T {
if (isCancel(value)) {
cancel("Setup cancelled.");
cancel(stylePromptTitle("Setup cancelled.") ?? "Setup cancelled.");
runtime.exit(0);
}
return value;

View File

@@ -151,7 +151,7 @@ export async function runNonInteractiveOnboarding(
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
mode: "oauth",
mode: "token",
});
} else if (authChoice === "codex-cli") {
const store = ensureAuthProfileStore();
@@ -169,17 +169,18 @@ export async function runNonInteractiveOnboarding(
} else if (authChoice === "minimax") {
nextConfig = applyMinimaxConfig(nextConfig);
} else if (
authChoice === "token" ||
authChoice === "oauth" ||
authChoice === "openai-codex" ||
authChoice === "antigravity"
) {
runtime.error(
`${
authChoice === "oauth" || authChoice === "openai-codex"
? "OAuth"
: "Antigravity"
} requires interactive mode.`,
);
const label =
authChoice === "antigravity"
? "Antigravity"
: authChoice === "token"
? "Token"
: "OAuth";
runtime.error(`${label} requires interactive mode.`);
runtime.exit(1);
return;
}

View File

@@ -5,6 +5,7 @@ export type OnboardMode = "local" | "remote";
export type AuthChoice =
| "oauth"
| "claude-cli"
| "token"
| "openai-codex"
| "codex-cli"
| "antigravity"

View File

@@ -1,110 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CliDeps } from "../cli/deps.js";
import { pollCommand } from "./poll.js";
let testConfig: Record<string, unknown> = {};
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => testConfig,
};
});
const callGatewayMock = vi.fn();
vi.mock("../gateway/call.js", () => ({
callGateway: (...args: unknown[]) => callGatewayMock(...args),
randomIdempotencyKey: () => "idem-1",
}));
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const deps: CliDeps = {
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn(),
sendMessageSlack: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
};
describe("pollCommand", () => {
beforeEach(() => {
callGatewayMock.mockReset();
runtime.log.mockReset();
runtime.error.mockReset();
runtime.exit.mockReset();
testConfig = {};
});
it("routes through gateway", async () => {
callGatewayMock.mockResolvedValueOnce({ messageId: "p1" });
await pollCommand(
{
to: "+1",
question: "hi?",
option: ["y", "n"],
},
deps,
runtime,
);
expect(callGatewayMock).toHaveBeenCalledWith(
expect.objectContaining({ method: "poll" }),
);
});
it("does not override remote gateway URL", async () => {
callGatewayMock.mockResolvedValueOnce({ messageId: "p1" });
testConfig = {
gateway: { mode: "remote", remote: { url: "wss://remote.example" } },
};
await pollCommand(
{
to: "+1",
question: "hi?",
option: ["y", "n"],
},
deps,
runtime,
);
const args = callGatewayMock.mock.calls.at(-1)?.[0] as
| Record<string, unknown>
| undefined;
expect(args?.url).toBeUndefined();
});
it("emits json output with gateway metadata", async () => {
callGatewayMock.mockResolvedValueOnce({ messageId: "p1", channelId: "C1" });
await pollCommand(
{
to: "channel:C1",
question: "hi?",
option: ["y", "n"],
provider: "discord",
json: true,
},
deps,
runtime,
);
const lastLog = runtime.log.mock.calls.at(-1)?.[0] as string | undefined;
expect(lastLog).toBeDefined();
const payload = JSON.parse(lastLog ?? "{}") as Record<string, unknown>;
expect(payload).toMatchObject({
provider: "discord",
via: "gateway",
to: "channel:C1",
messageId: "p1",
channelId: "C1",
mediaUrl: null,
question: "hi?",
options: ["y", "n"],
maxSelections: 1,
durationHours: null,
});
});
});

View File

@@ -1,121 +0,0 @@
import type { CliDeps } from "../cli/deps.js";
import { withProgress } from "../cli/progress.js";
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
import { success } from "../globals.js";
import { buildOutboundResultEnvelope } from "../infra/outbound/envelope.js";
import {
buildOutboundDeliveryJson,
formatGatewaySummary,
} from "../infra/outbound/format.js";
import { normalizePollInput, type PollInput } from "../polls.js";
import type { RuntimeEnv } from "../runtime.js";
function parseIntOption(value: unknown, label: string): number | undefined {
if (value === undefined || value === null) return undefined;
if (typeof value !== "string" || value.trim().length === 0) return undefined;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) {
throw new Error(`${label} must be a number`);
}
return parsed;
}
export async function pollCommand(
opts: {
to: string;
question: string;
option: string[];
maxSelections?: string;
durationHours?: string;
provider?: string;
json?: boolean;
dryRun?: boolean;
},
_deps: CliDeps,
runtime: RuntimeEnv,
) {
const provider = (opts.provider ?? "whatsapp").toLowerCase();
if (provider !== "whatsapp" && provider !== "discord") {
throw new Error(`Unsupported poll provider: ${provider}`);
}
const maxSelections = parseIntOption(opts.maxSelections, "max-selections");
const durationHours = parseIntOption(opts.durationHours, "duration-hours");
const pollInput: PollInput = {
question: opts.question,
options: opts.option,
maxSelections,
durationHours,
};
const maxOptions = provider === "discord" ? 10 : 12;
const normalized = normalizePollInput(pollInput, { maxOptions });
if (opts.dryRun) {
runtime.log(
`[dry-run] would send poll via ${provider} -> ${opts.to}:\n Question: ${normalized.question}\n Options: ${normalized.options.join(", ")}\n Max selections: ${normalized.maxSelections}`,
);
return;
}
const result = await withProgress(
{
label: `Sending poll via ${provider}`,
indeterminate: true,
enabled: opts.json !== true,
},
async () =>
await callGateway<{
messageId: string;
toJid?: string;
channelId?: string;
}>({
method: "poll",
params: {
to: opts.to,
question: normalized.question,
options: normalized.options,
maxSelections: normalized.maxSelections,
durationHours: normalized.durationHours,
provider,
idempotencyKey: randomIdempotencyKey(),
},
timeoutMs: 10_000,
clientName: "cli",
mode: "cli",
}),
);
runtime.log(
success(
formatGatewaySummary({
action: "Poll sent",
provider,
messageId: result.messageId ?? null,
}),
),
);
if (opts.json) {
runtime.log(
JSON.stringify(
{
...buildOutboundResultEnvelope({
delivery: buildOutboundDeliveryJson({
provider,
via: "gateway",
to: opts.to,
result,
mediaUrl: null,
}),
}),
question: normalized.question,
options: normalized.options,
maxSelections: normalized.maxSelections,
durationHours: normalized.durationHours ?? null,
},
null,
2,
),
);
}
}

View File

@@ -331,12 +331,12 @@ describe("providers command", () => {
accountId: "default",
enabled: true,
configured: true,
application: { intents: { messageContent: "limited" } },
application: { intents: { messageContent: "disabled" } },
},
],
});
expect(lines.join("\n")).toMatch(/Warnings:/);
expect(lines.join("\n")).toMatch(/Message Content Intent is limited/i);
expect(lines.join("\n")).toMatch(/Message Content Intent is disabled/i);
expect(lines.join("\n")).toMatch(/Run: clawdbot doctor/);
});

View File

@@ -1,253 +0,0 @@
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { CliDeps } from "../cli/deps.js";
import type { RuntimeEnv } from "../runtime.js";
import { sendCommand } from "./send.js";
let testConfig: Record<string, unknown> = {};
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => testConfig,
};
});
const callGatewayMock = vi.fn();
vi.mock("../gateway/call.js", () => ({
callGateway: (...args: unknown[]) => callGatewayMock(...args),
randomIdempotencyKey: () => "idem-1",
}));
const originalTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
const originalDiscordToken = process.env.DISCORD_BOT_TOKEN;
beforeEach(() => {
process.env.TELEGRAM_BOT_TOKEN = "token-abc";
process.env.DISCORD_BOT_TOKEN = "token-discord";
testConfig = {};
});
afterAll(() => {
process.env.TELEGRAM_BOT_TOKEN = originalTelegramToken;
process.env.DISCORD_BOT_TOKEN = originalDiscordToken;
});
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(() => {
throw new Error("exit");
}),
};
const makeDeps = (overrides: Partial<CliDeps> = {}): CliDeps => ({
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn(),
sendMessageSlack: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
...overrides,
});
describe("sendCommand", () => {
it("skips send on dry-run", async () => {
const deps = makeDeps();
await sendCommand(
{
to: "+1",
message: "hi",
dryRun: true,
},
deps,
runtime,
);
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
});
it("sends via gateway", async () => {
callGatewayMock.mockResolvedValueOnce({ messageId: "g1" });
const deps = makeDeps();
await sendCommand(
{
to: "+1",
message: "hi",
},
deps,
runtime,
);
expect(callGatewayMock).toHaveBeenCalled();
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("g1"));
});
it("does not override remote gateway URL", async () => {
callGatewayMock.mockResolvedValueOnce({ messageId: "g2" });
testConfig = {
gateway: { mode: "remote", remote: { url: "wss://remote.example" } },
};
const deps = makeDeps();
await sendCommand(
{
to: "+1",
message: "hi",
},
deps,
runtime,
);
const args = callGatewayMock.mock.calls.at(-1)?.[0] as
| Record<string, unknown>
| undefined;
expect(args?.url).toBeUndefined();
});
it("passes gifPlayback to gateway send", async () => {
callGatewayMock.mockClear();
callGatewayMock.mockResolvedValueOnce({ messageId: "g1" });
const deps = makeDeps();
await sendCommand(
{
to: "+1",
message: "hi",
gifPlayback: true,
},
deps,
runtime,
);
expect(callGatewayMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "send",
params: expect.objectContaining({ gifPlayback: true }),
}),
);
});
it("routes to telegram provider", async () => {
const deps = makeDeps({
sendMessageTelegram: vi
.fn()
.mockResolvedValue({ messageId: "t1", chatId: "123" }),
});
testConfig = { telegram: { botToken: "token-abc" } };
await sendCommand(
{ to: "123", message: "hi", provider: "telegram" },
deps,
runtime,
);
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
"123",
"hi",
expect.objectContaining({ accountId: undefined, verbose: false }),
);
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
});
it("uses config token for telegram when env is missing", async () => {
process.env.TELEGRAM_BOT_TOKEN = "";
testConfig = { telegram: { botToken: "cfg-token" } };
const deps = makeDeps({
sendMessageTelegram: vi
.fn()
.mockResolvedValue({ messageId: "t1", chatId: "123" }),
});
await sendCommand(
{ to: "123", message: "hi", provider: "telegram" },
deps,
runtime,
);
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
"123",
"hi",
expect.objectContaining({ accountId: undefined, verbose: false }),
);
});
it("routes to discord provider", async () => {
const deps = makeDeps({
sendMessageDiscord: vi
.fn()
.mockResolvedValue({ messageId: "d1", channelId: "chan" }),
});
await sendCommand(
{ to: "channel:chan", message: "hi", provider: "discord" },
deps,
runtime,
);
expect(deps.sendMessageDiscord).toHaveBeenCalledWith(
"channel:chan",
"hi",
expect.objectContaining({ verbose: false }),
);
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
});
it("routes to signal provider", async () => {
const deps = makeDeps({
sendMessageSignal: vi.fn().mockResolvedValue({ messageId: "s1" }),
});
await sendCommand(
{ to: "+15551234567", message: "hi", provider: "signal" },
deps,
runtime,
);
expect(deps.sendMessageSignal).toHaveBeenCalledWith(
"+15551234567",
"hi",
expect.objectContaining({ maxBytes: undefined }),
);
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
});
it("routes to slack provider", async () => {
const deps = makeDeps({
sendMessageSlack: vi
.fn()
.mockResolvedValue({ messageId: "s1", channelId: "C123" }),
});
await sendCommand(
{ to: "channel:C123", message: "hi", provider: "slack" },
deps,
runtime,
);
expect(deps.sendMessageSlack).toHaveBeenCalledWith(
"channel:C123",
"hi",
expect.objectContaining({ accountId: undefined }),
);
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
});
it("routes to imessage provider", async () => {
const deps = makeDeps({
sendMessageIMessage: vi.fn().mockResolvedValue({ messageId: "i1" }),
});
await sendCommand(
{ to: "chat_id:42", message: "hi", provider: "imessage" },
deps,
runtime,
);
expect(deps.sendMessageIMessage).toHaveBeenCalledWith(
"chat_id:42",
"hi",
expect.objectContaining({ maxBytes: undefined }),
);
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
});
it("emits json output", async () => {
callGatewayMock.mockResolvedValueOnce({ messageId: "direct2" });
const deps = makeDeps();
await sendCommand(
{
to: "+1",
message: "hi",
json: true,
},
deps,
runtime,
);
expect(runtime.log).toHaveBeenCalledWith(
expect.stringContaining('"provider": "whatsapp"'),
);
});
});

View File

@@ -1,148 +0,0 @@
import type { CliDeps } from "../cli/deps.js";
import { withProgress } from "../cli/progress.js";
import { loadConfig } from "../config/config.js";
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
import { success } from "../globals.js";
import { deliverOutboundPayloads } from "../infra/outbound/deliver.js";
import { buildOutboundResultEnvelope } from "../infra/outbound/envelope.js";
import {
buildOutboundDeliveryJson,
formatGatewaySummary,
formatOutboundDeliverySummary,
} from "../infra/outbound/format.js";
import { resolveOutboundTarget } from "../infra/outbound/targets.js";
import type { RuntimeEnv } from "../runtime.js";
import { normalizeMessageProvider } from "../utils/message-provider.js";
export async function sendCommand(
opts: {
to: string;
message: string;
provider?: string;
json?: boolean;
dryRun?: boolean;
media?: string;
gifPlayback?: boolean;
account?: string;
},
deps: CliDeps,
runtime: RuntimeEnv,
) {
const provider = normalizeMessageProvider(opts.provider) ?? "whatsapp";
if (opts.dryRun) {
runtime.log(
`[dry-run] would send via ${provider} -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
);
return;
}
if (
provider === "telegram" ||
provider === "discord" ||
provider === "slack" ||
provider === "signal" ||
provider === "imessage"
) {
const resolvedTarget = resolveOutboundTarget({
provider,
to: opts.to,
});
if (!resolvedTarget.ok) {
throw resolvedTarget.error;
}
const results = await withProgress(
{
label: `Sending via ${provider}`,
indeterminate: true,
enabled: opts.json !== true,
},
async () =>
await deliverOutboundPayloads({
cfg: loadConfig(),
provider,
to: resolvedTarget.to,
payloads: [{ text: opts.message, mediaUrl: opts.media }],
deps: {
sendWhatsApp: deps.sendMessageWhatsApp,
sendTelegram: deps.sendMessageTelegram,
sendDiscord: deps.sendMessageDiscord,
sendSlack: deps.sendMessageSlack,
sendSignal: deps.sendMessageSignal,
sendIMessage: deps.sendMessageIMessage,
},
}),
);
const last = results.at(-1);
const summary = formatOutboundDeliverySummary(provider, last);
runtime.log(success(summary));
if (opts.json) {
runtime.log(
JSON.stringify(
buildOutboundDeliveryJson({
provider,
via: "direct",
to: opts.to,
result: last,
mediaUrl: opts.media,
}),
null,
2,
),
);
}
return;
}
// Always send via gateway over WS to avoid multi-session corruption.
const sendViaGateway = async () =>
callGateway<{
messageId: string;
}>({
method: "send",
params: {
to: opts.to,
message: opts.message,
mediaUrl: opts.media,
gifPlayback: opts.gifPlayback,
accountId: opts.account,
provider,
idempotencyKey: randomIdempotencyKey(),
},
timeoutMs: 10_000,
clientName: "cli",
mode: "cli",
});
const result = await withProgress(
{
label: `Sending via ${provider}`,
indeterminate: true,
enabled: opts.json !== true,
},
async () => await sendViaGateway(),
);
runtime.log(
success(
formatGatewaySummary({ provider, messageId: result.messageId ?? null }),
),
);
if (opts.json) {
runtime.log(
JSON.stringify(
buildOutboundResultEnvelope({
delivery: buildOutboundDeliveryJson({
provider,
via: "gateway",
to: opts.to,
result,
mediaUrl: opts.media ?? null,
}),
}),
null,
2,
),
);
}
}

View File

@@ -1,10 +1,14 @@
import { note } from "@clack/prompts";
import { note as clackNote } from "@clack/prompts";
import {
enableSystemdUserLinger,
readSystemdUserLingerStatus,
} from "../daemon/systemd.js";
import type { RuntimeEnv } from "../runtime.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
const note = (message: string, title?: string) =>
clackNote(message, stylePromptTitle(title));
export type LingerPrompter = {
confirm?: (params: {

View File

@@ -98,6 +98,7 @@ const FIELD_LABELS: Record<string, string> = {
"agent.imageModel.fallbacks": "Image Model Fallbacks",
"commands.native": "Native Commands",
"commands.text": "Text Commands",
"commands.restart": "Allow Restart",
"commands.useAccessGroups": "Use Access Groups",
"ui.seamColor": "Accent Color",
"browser.controlUrl": "Browser Control URL",
@@ -159,6 +160,8 @@ const FIELD_HELP: Record<string, string> = {
"commands.native":
"Register native commands with connectors that support it (Discord/Slack/Telegram).",
"commands.text": "Allow text command parsing (slash commands only).",
"commands.restart":
"Allow /restart and gateway restart tool actions (default: false).",
"commands.useAccessGroups":
"Enforce access-group allowlists/policies for commands.",
"session.agentToAgent.maxPingPongTurns":

Some files were not shown because too many files have changed in this diff Show More