mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
Merge branch 'main' into commands-list-clean
This commit is contained in:
10
AGENTS.md
10
AGENTS.md
@@ -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}`. Don’t add extra quotes.
|
||||
- launchd PATH is minimal; ensure the app’s 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 tool’s escaping.
|
||||
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s 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
|
||||
)"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
24
README.md
24
README.md
@@ -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>
|
||||
|
||||
@@ -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
107
docs/cli/gateway.md
Normal 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 Clawdbot’s 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'
|
||||
```
|
||||
|
||||
@@ -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
161
docs/cli/message.md
Normal 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 "✅"
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -549,6 +549,13 @@
|
||||
"install/bun"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "CLI",
|
||||
"pages": [
|
||||
"cli/index",
|
||||
"cli/gateway"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Core Concepts",
|
||||
"pages": [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -135,8 +135,7 @@ If you’re 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 won’t be able to respond without it.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 2–10 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`):
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
50
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
916
src/agents/tools/message-tool.ts
Normal file
916
src/agents/tools/message-tool.ts
Normal 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}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ?? [])
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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();
|
||||
|
||||
37
src/commands/auth-token.ts
Normal file
37
src/commands/auth-token.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
153
src/commands/message.test.ts
Normal file
153
src/commands/message.test.ts
Normal 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
1113
src/commands/message.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
236
src/commands/models/auth.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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})`)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export type OnboardMode = "local" | "remote";
|
||||
export type AuthChoice =
|
||||
| "oauth"
|
||||
| "claude-cli"
|
||||
| "token"
|
||||
| "openai-codex"
|
||||
| "codex-cli"
|
||||
| "antigravity"
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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"'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user