mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
Compare commits
414 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8e3f9f959 | |||
| 8d57bdfa3d | |||
| ead25ee6e2 | |||
| 9e1dbdf48c | |||
| 0774667826 | |||
| 0ba4ecadb1 | |||
| ad5d4d2664 | |||
| 9267d52bdb | |||
| 4c57e4d69b | |||
| eff13717f9 | |||
| dc13300fb1 | |||
| d324419d7b | |||
| 0287d71595 | |||
| 05906e4b6a | |||
| 6539c0286a | |||
| 5ba9d23ea8 | |||
| f7a8df0e8e | |||
| 9312d467bd | |||
| bd50ef7e38 | |||
| 25a5b81c59 | |||
| f33f2d89ce | |||
| 661da3969e | |||
| aeeb54a495 | |||
| f9d30e8b9c | |||
| 1c7623ca41 | |||
| faeeba198e | |||
| 04e41fb0ef | |||
| aebcffe180 | |||
| be3a8a97c6 | |||
| a84327573e | |||
| 39e9583820 | |||
| 08698da0d2 | |||
| 9ce82588d9 | |||
| 37b54968ce | |||
| 1efe28ccdc | |||
| 78cf2433a3 | |||
| 4c83a8193b | |||
| 7eebcf74c2 | |||
| 31ccc61b27 | |||
| ef43cbb3d9 | |||
| 0060c6b84a | |||
| e6d470d831 | |||
| 0e11eaf186 | |||
| 4990994204 | |||
| 2d03c94252 | |||
| 01eac7b225 | |||
| 6caad0757a | |||
| ed571d1f66 | |||
| 93ec82ce38 | |||
| 046b99c745 | |||
| 0855369b79 | |||
| 33cbf59dd8 | |||
| 9a649fadc5 | |||
| 405dd34148 | |||
| 81cb13ec46 | |||
| 9629d1cc4a | |||
| 85850874ab | |||
| 6e9f35a646 | |||
| 635a49369f | |||
| 028cb017ed | |||
| 2f552ce1bb | |||
| f3e19872ac | |||
| 9b670563b8 | |||
| 6ea49898dd | |||
| 9090c33e7e | |||
| 3b64d6cf76 | |||
| 35233dabe8 | |||
| 107945f10c | |||
| 3b07c0ceaf | |||
| 1a358dc7e3 | |||
| 7da08b3327 | |||
| 684a98d078 | |||
| e1251da394 | |||
| eb6502a1b2 | |||
| 3af6e70c05 | |||
| 8a7311a7bb | |||
| 61ab60041c | |||
| ca17683e32 | |||
| 6a56b10ffc | |||
| 2754f7559a | |||
| 1594a0c682 | |||
| a6995cc17e | |||
| 93732a4978 | |||
| 350d9631fa | |||
| a90104b8e3 | |||
| 708f98e156 | |||
| b40d43725f | |||
| d92c676327 | |||
| 6f0b8f1961 | |||
| 1afbba6a91 | |||
| cd69bf5c45 | |||
| c3d1b3e976 | |||
| 1240a0cf4f | |||
| 42e8ae004e | |||
| 9ccafcda82 | |||
| 860d1310ca | |||
| 9ca3367229 | |||
| e3645f799c | |||
| 9db39b291d | |||
| ba70ddf73a | |||
| f7c610ac4a | |||
| 12719be6e1 | |||
| 57dad14a01 | |||
| 8d5d088108 | |||
| 6d8d085f96 | |||
| 348e200c11 | |||
| 4305c6a87d | |||
| 877d2a370a | |||
| 8eff3e558c | |||
| 7e0c256fa0 | |||
| 1eb55e85a0 | |||
| d8d6f6bd65 | |||
| 88ff54cf83 | |||
| 4d5fa0868b | |||
| aff3f58bc8 | |||
| 18635e7c7d | |||
| 0d7458c6f3 | |||
| 5213c98506 | |||
| a36acd3413 | |||
| f2d2ce9aed | |||
| 22715c163a | |||
| eacb93c4e5 | |||
| 2db5173f07 | |||
| 9b4860dd48 | |||
| ec23bd7a7e | |||
| 61caac0a04 | |||
| 3dc29bb674 | |||
| 8771e259a8 | |||
| a597b42648 | |||
| 4d5af78d35 | |||
| 6865811147 | |||
| 5e2bf1cb54 | |||
| bc19b716bf | |||
| 863c224d9e | |||
| 87f75eed79 | |||
| fc09b900ef | |||
| 1a2d004bad | |||
| e25eae7e57 | |||
| 4a10a455f9 | |||
| eefbf4f61d | |||
| a9c8c841f6 | |||
| 3d42ba6e3d | |||
| 5277e12a48 | |||
| a8e0a7f011 | |||
| 291a1fc8a4 | |||
| 92a2347dc5 | |||
| 73d45f8097 | |||
| 395139ce63 | |||
| 644ad2f017 | |||
| 824f311e31 | |||
| 13f6fc2093 | |||
| c93e611228 | |||
| 4fc3273889 | |||
| fa6f2da83e | |||
| 34982eaf31 | |||
| 9df6a91b32 | |||
| 81b2364336 | |||
| 144c65e32d | |||
| 6d6584d120 | |||
| 9ac1e6fd7b | |||
| 24d719fb88 | |||
| a870e7ebf2 | |||
| 7fdd7eaa1c | |||
| decf18049f | |||
| c44c7a6669 | |||
| 6a54b69912 | |||
| e1ecfb9c48 | |||
| c795ecff6e | |||
| 48d2fab779 | |||
| 948a0dcada | |||
| 3c5ae96cdd | |||
| c8163d16f3 | |||
| 3eec441b84 | |||
| e753d09e64 | |||
| a57bb8fec0 | |||
| 9633788a1b | |||
| 32dba601fe | |||
| 30a898508a | |||
| 306fa6f014 | |||
| 1404f7feb6 | |||
| 657110cb0b | |||
| 7ed149057d | |||
| 5f5f4fe62c | |||
| 8d489ee19e | |||
| dcf8d2096f | |||
| 9e8f256dd2 | |||
| 057f0d174c | |||
| 1c16b09c84 | |||
| cf71f961d3 | |||
| 251b31cd78 | |||
| 6262211af1 | |||
| e0e4f0189b | |||
| 9e4feb0800 | |||
| b33f6654fd | |||
| 768980e874 | |||
| 34c3e90156 | |||
| f68f6da406 | |||
| ebb11a1127 | |||
| 9b067b2d8e | |||
| 517e719146 | |||
| 5eda6c160e | |||
| 2902d86ac8 | |||
| b2ed5a5fc0 | |||
| 37d6335ebc | |||
| 5deccc44ea | |||
| 6daa1a3ffe | |||
| 58e4df44e2 | |||
| d88b0807e6 | |||
| 6a05e41afe | |||
| 8bdc5c4217 | |||
| 5dc54194ab | |||
| cf783385e7 | |||
| faff9ac0e3 | |||
| 64ad618089 | |||
| e432467066 | |||
| 7fc68a1008 | |||
| c0a7538dbe | |||
| fc1066a303 | |||
| e34380656c | |||
| 60526c971b | |||
| 6936e97fe2 | |||
| dd055bbb8e | |||
| 0e9dadfaee | |||
| 63f88356eb | |||
| b01b13323e | |||
| e4181f5451 | |||
| 58fc5728db | |||
| 953264e0d3 | |||
| 52051d4aa5 | |||
| 64769feae7 | |||
| eba5b78006 | |||
| 6b76c1a56c | |||
| cb1d8dd791 | |||
| 82216b536d | |||
| 02650fa616 | |||
| 640303e4a9 | |||
| 3b5e5a24f4 | |||
| 7dbedad9bd | |||
| 8181054bdb | |||
| 7922a19af7 | |||
| 8c1b209aeb | |||
| 2bc1279a12 | |||
| 2617313f19 | |||
| 8439a180be | |||
| dca02f5453 | |||
| 2a915e8af0 | |||
| 416c283dcb | |||
| aec7ddd099 | |||
| ceb0b9cf5f | |||
| 8d022fd9da | |||
| 1ebb2dc8d2 | |||
| ce9f175238 | |||
| cf3fcc18d4 | |||
| 00a30e3eff | |||
| f35be24aef | |||
| 67eb85d818 | |||
| 1d73b2986a | |||
| 1b08b58fcd | |||
| 897b770296 | |||
| a71d2a4e2c | |||
| 39c579ba2a | |||
| dab4fb497b | |||
| 663d9a4091 | |||
| a590fbd830 | |||
| 20a17cbc44 | |||
| 0d836220d9 | |||
| 36e731c02d | |||
| 8c962d3f73 | |||
| 28c38ae28b | |||
| 7ac8dd0f6d | |||
| 7814e45570 | |||
| fc3c11b6b9 | |||
| 852009dcb1 | |||
| 212281ba8e | |||
| 6db6bf9c40 | |||
| 8977f0d0be | |||
| d13f338af9 | |||
| 5ab1a2733c | |||
| 7d29888e59 | |||
| 58d875b3c3 | |||
| 3e7fea0fde | |||
| d418f830db | |||
| 32daf607c1 | |||
| 524ac221e1 | |||
| 69b4225916 | |||
| 3d6a9b74f3 | |||
| dcc625f2b8 | |||
| 99a8559b14 | |||
| 3dc772cca0 | |||
| 5ebad280ce | |||
| d73b9e14ad | |||
| 681a5b51c8 | |||
| 8e45f4e964 | |||
| eb9a5d706d | |||
| 46cd91c306 | |||
| 0218159ef0 | |||
| 3ee07effea | |||
| 462b9581b2 | |||
| a359f2555f | |||
| 6525926ca9 | |||
| 35d35fefc3 | |||
| eab9110232 | |||
| 2c0d0e9d44 | |||
| ccfdf2dd75 | |||
| 17823dffae | |||
| 941a75f65d | |||
| c2ee2b7c91 | |||
| ef62f57326 | |||
| e51f6e0c41 | |||
| cb15e606c3 | |||
| 6863e0f63b | |||
| 4d42bb95fb | |||
| a66cd545d5 | |||
| cfb737d681 | |||
| 1db98ee614 | |||
| bb1b41800c | |||
| 5be15be139 | |||
| e56132d04a | |||
| 5cf5840426 | |||
| 7ce9922cde | |||
| 35f8e9d2f5 | |||
| d5388a168b | |||
| 23a3fea868 | |||
| 72837c1643 | |||
| d07cd7afa0 | |||
| 3d29965413 | |||
| 0a18c1d21a | |||
| dec1be6adc | |||
| 030ee8a46f | |||
| c4f654083d | |||
| 7755082a4c | |||
| 8a205808e0 | |||
| d7c76ac12b | |||
| f828e2971c | |||
| 43f49b988e | |||
| 012292d063 | |||
| d2151ae848 | |||
| 15f286b73d | |||
| 6e5e568da1 | |||
| 2a3be9ec7f | |||
| 2b1b138a44 | |||
| 3c7b971f1b | |||
| 8dd004ca75 | |||
| 34f3612877 | |||
| 1452ed262b | |||
| 597e282f88 | |||
| 33a03f25a9 | |||
| e31a6c7e34 | |||
| ee165d09c2 | |||
| 70cb35f58b | |||
| d1a2505d20 | |||
| 9889848932 | |||
| 594d1b4055 | |||
| beb5e049ed | |||
| 3a3d2ee644 | |||
| b9d302524e | |||
| ef8e3aa1b8 | |||
| 3c620bc8d0 | |||
| d5b48e4742 | |||
| 8542c484f6 | |||
| 1dd8fabde9 | |||
| 8c5d67cc78 | |||
| d86051805b | |||
| 5f34e26240 | |||
| 5dd15c0014 | |||
| db19837740 | |||
| 9e45845000 | |||
| 9a919f4148 | |||
| 4608836953 | |||
| 1bf903a64d | |||
| 0044bba0e5 | |||
| 26594d2c54 | |||
| 01131521ff | |||
| 3742165708 | |||
| 4c791a41b2 | |||
| 6ef147bc89 | |||
| 7d153df710 | |||
| ab2d509671 | |||
| 57a959028d | |||
| 9f564650c6 | |||
| 2acd71731a | |||
| b7f099db96 | |||
| c8e960314a | |||
| ec3aa0f139 | |||
| d4868a5e01 | |||
| a014a67556 | |||
| e0f813603e | |||
| aa390b3fd0 | |||
| 9c8f680ca8 | |||
| 93be2d15f0 | |||
| 89738917ae | |||
| ede6c01da8 | |||
| 4d6f9b70f4 | |||
| 336e01d2a1 | |||
| 2bf296b04a | |||
| ae9bcb7c33 | |||
| 99869105ba | |||
| c5d0243417 | |||
| c36f0c6b36 | |||
| 45d3016bce | |||
| a80f095174 | |||
| 5812422321 | |||
| a65ee2e55c | |||
| 04e0e18e8e | |||
| 9faa8a9a2c | |||
| e1c8876a72 | |||
| 3ee7d2147e | |||
| 6ef479ddf7 | |||
| 0c420cffca | |||
| 5ed74c3a3f | |||
| ad507fa426 | |||
| 94689fcb36 | |||
| 4743513018 | |||
| 0320e3fe26 |
@@ -0,0 +1,62 @@
|
||||
# Remove DeltaChat
|
||||
|
||||
## 1. Disable the adapter
|
||||
|
||||
Comment out the import in `src/channels/index.ts`:
|
||||
|
||||
```typescript
|
||||
// import './deltachat.js';
|
||||
```
|
||||
|
||||
## 2. Remove credentials
|
||||
|
||||
Remove the `DC_*` lines from `.env`:
|
||||
|
||||
```bash
|
||||
DC_EMAIL
|
||||
DC_PASSWORD
|
||||
DC_IMAP_HOST
|
||||
DC_IMAP_PORT
|
||||
DC_SMTP_HOST
|
||||
DC_SMTP_PORT
|
||||
```
|
||||
|
||||
## 3. Rebuild and restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
|
||||
# Linux
|
||||
systemctl --user restart nanoclaw
|
||||
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
```
|
||||
|
||||
## 4. Remove account data (optional)
|
||||
|
||||
To fully remove all account data including DeltaChat encryption keys:
|
||||
|
||||
```bash
|
||||
rm -rf dc-account/
|
||||
```
|
||||
|
||||
> **Warning:** This deletes the Autocrypt keys. Contacts who have verified your bot's key will need to re-verify if the same email address is re-used with a new account.
|
||||
|
||||
To keep the account for later reinstall, leave `dc-account/` intact.
|
||||
|
||||
## 5. Remove the package (optional)
|
||||
|
||||
```bash
|
||||
pnpm remove @deltachat/stdio-rpc-server
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
After removal, confirm the adapter is no longer starting:
|
||||
|
||||
```bash
|
||||
grep "deltachat" logs/nanoclaw.log | tail -5
|
||||
```
|
||||
|
||||
Expected: no `Channel adapter started` entry after the last restart.
|
||||
@@ -0,0 +1,254 @@
|
||||
---
|
||||
name: add-deltachat
|
||||
description: Add DeltaChat channel integration via @deltachat/stdio-rpc-server. Native adapter — no Chat SDK bridge. Email-based messaging with end-to-end encryption.
|
||||
---
|
||||
|
||||
# Add DeltaChat Channel
|
||||
|
||||
The adapter drives the `@deltachat/stdio-rpc-server` JSON-RPC subprocess directly — pure Node.js against the DeltaChat core library. Messages are delivered over email with Autocrypt/OpenPGP encryption.
|
||||
|
||||
## Install
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
- `src/channels/deltachat.ts` exists
|
||||
- `src/channels/index.ts` contains `import './deltachat.js';`
|
||||
- `@deltachat/stdio-rpc-server` is listed in `package.json` dependencies
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
### 1. Fetch the channels branch
|
||||
|
||||
```bash
|
||||
git fetch origin channels
|
||||
```
|
||||
|
||||
### 2. Copy the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/deltachat.ts > src/channels/deltachat.ts
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if already present):
|
||||
|
||||
```typescript
|
||||
import './deltachat.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @deltachat/stdio-rpc-server@2.49.0
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
## Account Setup
|
||||
|
||||
A dedicated email account is strongly recommended — it will accumulate DeltaChat-formatted messages and store encryption keys. Not all providers work well with DeltaChat; check https://providers.delta.chat/ before picking one.
|
||||
|
||||
**Default security modes:** IMAP uses SSL/TLS (port 993), SMTP uses STARTTLS (port 587). Both are configurable via `.env` — see Credentials below.
|
||||
|
||||
To find the correct hostnames for a domain:
|
||||
|
||||
```bash
|
||||
node -e "require('dns').resolveMx('example.com', (e,r) => console.log(r))"
|
||||
```
|
||||
|
||||
Most providers publish their IMAP/SMTP hostnames in their help docs under "manual setup" or "IMAP access."
|
||||
|
||||
## Credentials
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
DC_EMAIL=bot@example.com
|
||||
DC_PASSWORD=your-app-password
|
||||
DC_IMAP_HOST=imap.example.com
|
||||
DC_IMAP_PORT=993
|
||||
DC_IMAP_SECURITY=1 # 1=SSL/TLS (default), 2=STARTTLS, 3=plain
|
||||
DC_SMTP_HOST=smtp.example.com
|
||||
DC_SMTP_PORT=587
|
||||
DC_SMTP_SECURITY=2 # 2=STARTTLS (default), 1=SSL/TLS, 3=plain
|
||||
```
|
||||
|
||||
Security settings are applied on every startup, so changing them in `.env` and restarting takes effect without wiping the account.
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
### Optional settings
|
||||
|
||||
The following are read from the process environment (not `.env`). To override them, add `Environment=` lines to the systemd service unit or your launchd plist:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DC_ACCOUNT_DIR` | `dc-account` | Directory for DeltaChat account data (IMAP state, keys, blobs) |
|
||||
| `DC_DISPLAY_NAME` | `NanoClaw` | Bot display name shown in DeltaChat |
|
||||
| `DC_AVATAR_PATH` | _(none)_ | Absolute path to avatar image; set at startup only |
|
||||
|
||||
The `/set-avatar` command (send an image with that caption) is the easiest way to set the avatar at runtime without modifying the service file. Only users with `owner` or global `admin` role can use it.
|
||||
|
||||
### Restart
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
systemctl --user restart nanoclaw
|
||||
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
```
|
||||
|
||||
On first start the adapter configures the email account (IMAP/SMTP credentials, calls `configure()`). Subsequent starts skip straight to `startIo()`. Account data is stored in `dc-account/` in the project root (or your `DC_ACCOUNT_DIR`).
|
||||
|
||||
## Wiring
|
||||
|
||||
### DMs
|
||||
|
||||
**DeltaChat contacts cannot be added by email alone** — to start a chat, the user must open the bot's invite link in their DeltaChat app or scan its QR code. This triggers the SecureJoin handshake.
|
||||
|
||||
#### Step 1 — Get the invite link
|
||||
|
||||
After the service starts, the adapter logs the invite URL and writes a QR SVG:
|
||||
|
||||
```bash
|
||||
grep "invite link" logs/nanoclaw.log | tail -1
|
||||
# url field contains the https://i.delta.chat/... invite link
|
||||
# also written to dc-account/invite-qr.svg (or $DC_ACCOUNT_DIR/invite-qr.svg)
|
||||
```
|
||||
|
||||
The invite URL is stable (tied to the bot's email and encryption keys) so it stays valid across restarts.
|
||||
|
||||
#### Step 2 — Add the bot in DeltaChat
|
||||
|
||||
Two options for the user to connect:
|
||||
|
||||
- **Link**: Copy the `https://i.delta.chat/...` URL and open it on the device running DeltaChat. The app recognises it and shows a "Start chat" prompt.
|
||||
- **QR code**: Open `dc-account/invite-qr.svg` in a browser or image viewer, display it on screen, and scan it from the DeltaChat app using the QR-scan button on the new-chat screen.
|
||||
|
||||
After accepting, DeltaChat exchanges keys and creates the chat automatically.
|
||||
|
||||
#### Step 3 — Wire the chat to an agent
|
||||
|
||||
Once the first message arrives the router auto-creates a `messaging_groups` row. Look up the chat ID:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"SELECT platform_id, name FROM messaging_groups WHERE channel_type='deltachat' AND is_group=0 ORDER BY created_at DESC LIMIT 5"
|
||||
```
|
||||
|
||||
Then run `/init-first-agent` — it creates the agent group, grants the user owner access, and wires the messaging group in one step:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/init-first-agent.ts \
|
||||
--channel deltachat \
|
||||
--user-id deltachat:user@example.com \
|
||||
--platform-id <platform_id from above> \
|
||||
--display-name "Your Name"
|
||||
```
|
||||
|
||||
### Groups
|
||||
|
||||
Add the bot email to a DeltaChat group. When any member sends a message, the router creates a `messaging_groups` row with `is_group = 1`. Run `/manage-channels` to wire it to an agent group.
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
|
||||
Otherwise, run `/init-first-agent` to create an agent and wire it to your DeltaChat DM (see Wiring above), or `/manage-channels` to wire this channel to an existing agent group.
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `deltachat`
|
||||
- **terminology**: DeltaChat calls them "chats" (1:1 DMs) and "groups"
|
||||
- **supports-threads**: no — DeltaChat has no thread model
|
||||
- **platform-id-format**: numeric chat ID as a string (e.g. `"12"`) — the DeltaChat core's internal chat identifier
|
||||
- **user-id-format**: `deltachat:{email}` — the contact's email address
|
||||
- **how-to-find-id**: Send a message from DeltaChat to the bot email, then query `messaging_groups` as shown above
|
||||
- **typical-use**: Personal assistant over DeltaChat DMs; small groups where participants use DeltaChat
|
||||
- **default-isolation**: One agent per bot identity. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode
|
||||
|
||||
### Features
|
||||
|
||||
- File attachments — inbound and outbound; inbound waits up to 30 seconds for large-message download to complete
|
||||
- Invite link logged on every startup — URL + QR SVG written to `dc-account/invite-qr.svg`; see Wiring for the bootstrap flow
|
||||
- `/set-avatar` — send an image with this caption to change the bot's DeltaChat avatar (admin/owner only)
|
||||
- Connectivity watchdog — restarts IO if IMAP goes quiet for 20 minutes or connectivity drops below threshold for two consecutive 5-minute checks
|
||||
- Network nudge — `maybeNetwork()` called every 10 minutes to recover from prolonged idle
|
||||
|
||||
Not supported: DeltaChat reactions, message editing/deletion, read receipts.
|
||||
|
||||
### Connectivity model
|
||||
|
||||
`isConnected()` returns `true` when the internal connectivity value is ≥ 3000:
|
||||
|
||||
| Range | Meaning |
|
||||
|-------|---------|
|
||||
| 1000–1999 | Not connected |
|
||||
| 2000–2999 | Connecting |
|
||||
| 3000–3999 | Working (IMAP fetching) |
|
||||
| ≥ 4000 | Fully connected (IMAP IDLE) |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Adapter not starting — credentials missing
|
||||
|
||||
```bash
|
||||
grep "Channel credentials missing" logs/nanoclaw.log | grep deltachat
|
||||
```
|
||||
|
||||
All six required vars (`DC_EMAIL`, `DC_PASSWORD`, `DC_IMAP_HOST`, `DC_IMAP_PORT`, `DC_SMTP_HOST`, `DC_SMTP_PORT`) must be present in `.env`.
|
||||
|
||||
### Account configure fails
|
||||
|
||||
```bash
|
||||
grep "DeltaChat" logs/nanoclaw.log | tail -20
|
||||
```
|
||||
|
||||
Common causes:
|
||||
- Wrong IMAP/SMTP hostnames — double-check provider docs
|
||||
- App password not generated — Gmail and some others require this when 2FA is enabled
|
||||
- Port/security mismatch — defaults are port 993 + SSL/TLS for IMAP and port 587 + STARTTLS for SMTP; override with `DC_IMAP_PORT`/`DC_IMAP_SECURITY` or `DC_SMTP_PORT`/`DC_SMTP_SECURITY` in `.env`
|
||||
|
||||
### Provider uses SMTP port 465 (SSL/TLS) instead of 587
|
||||
|
||||
Set `DC_SMTP_SECURITY=1` and `DC_SMTP_PORT=465` in `.env`, then restart.
|
||||
|
||||
### Messages not arriving
|
||||
|
||||
1. Check the service is running and the adapter started: `grep "Channel adapter started.*deltachat" logs/nanoclaw.log`
|
||||
2. Check connectivity: `grep "DeltaChat: IO started" logs/nanoclaw.log`
|
||||
3. Check the sender has been granted access — run `/init-first-agent` to create their user record and wire the chat
|
||||
4. Verify the messaging group is wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mga.agent_group_id FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='deltachat'"`
|
||||
|
||||
### Stale lock file after crash
|
||||
|
||||
```bash
|
||||
rm -f dc-account/accounts.lock
|
||||
systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
### Bot not responding after restart
|
||||
|
||||
The account is already configured — IO restarts automatically on service start. If the RPC subprocess is stuck, restart the service. Check for errors:
|
||||
|
||||
```bash
|
||||
grep "DeltaChat" logs/nanoclaw.error.log | tail -20
|
||||
```
|
||||
|
||||
### Messages received but agent not responding
|
||||
|
||||
The messaging group exists but may not be wired to an agent group. Run:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat'"
|
||||
```
|
||||
|
||||
If the group has no entry in `messaging_group_agents`, wire it with `/manage-channels`.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Verify DeltaChat
|
||||
|
||||
## 1. Check the adapter started
|
||||
|
||||
```bash
|
||||
grep "Channel adapter started.*deltachat" logs/nanoclaw.log | tail -1
|
||||
```
|
||||
|
||||
Expected: `Channel adapter started { channel: 'deltachat', type: 'deltachat' }`
|
||||
|
||||
## 2. Check IMAP/SMTP connectivity
|
||||
|
||||
Replace with your provider's hostnames from `.env`:
|
||||
|
||||
```bash
|
||||
DC_IMAP=$(grep '^DC_IMAP_HOST=' .env | cut -d= -f2)
|
||||
DC_SMTP=$(grep '^DC_SMTP_HOST=' .env | cut -d= -f2)
|
||||
|
||||
bash -c "echo >/dev/tcp/$DC_IMAP/993" && echo "IMAP open" || echo "IMAP blocked"
|
||||
bash -c "echo >/dev/tcp/$DC_SMTP/587" && echo "SMTP open" || echo "SMTP blocked"
|
||||
```
|
||||
|
||||
## 3. End-to-end message test
|
||||
|
||||
1. Open DeltaChat on your device
|
||||
2. Add the bot email address as a contact
|
||||
3. Send a message
|
||||
4. The bot should respond within a few seconds
|
||||
|
||||
If nothing arrives, check:
|
||||
|
||||
```bash
|
||||
grep "DeltaChat" logs/nanoclaw.log | tail -20
|
||||
grep "DeltaChat" logs/nanoclaw.error.log | tail -10
|
||||
```
|
||||
|
||||
## 4. Check messaging group was created
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat' ORDER BY created_at DESC LIMIT 5"
|
||||
```
|
||||
|
||||
If a row appears, the inbound routing is working. If not, the adapter isn't receiving the message — check logs for `DeltaChat: error handling incoming message`.
|
||||
|
||||
## 5. Verify user access
|
||||
|
||||
If the message arrived but the agent didn't respond, the sender may not have access:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "SELECT id, display_name FROM users WHERE id LIKE 'deltachat:%'"
|
||||
```
|
||||
|
||||
Grant access as shown in the SKILL.md "Grant user access" section.
|
||||
@@ -44,7 +44,7 @@ import './discord.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/discord@4.26.0
|
||||
pnpm install @chat-adapter/discord@4.27.0
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
|
||||
@@ -241,7 +241,7 @@ grep -q "import './emacs.js'" src/channels/index.ts && echo "imported" || echo "
|
||||
### No response from agent
|
||||
|
||||
1. NanoClaw running: `launchctl list | grep nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux)
|
||||
2. Messaging group wired: `sqlite3 data/v2.db "SELECT mg.platform_id, ag.folder FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id JOIN agent_groups ag ON ag.id = mga.agent_group_id WHERE mg.channel_type = 'emacs'"`
|
||||
2. Messaging group wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, ag.folder FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id JOIN agent_groups ag ON ag.id = mga.agent_group_id WHERE mg.channel_type = 'emacs'"`
|
||||
3. Logs show inbound: `grep 'channel_type=emacs\|Emacs' logs/nanoclaw.log | tail -20`
|
||||
|
||||
If no messaging group row exists, run the `register` command above.
|
||||
@@ -292,5 +292,5 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
|
||||
# Remove the NanoClaw block from your Emacs config
|
||||
# Optionally clean up the messaging group:
|
||||
sqlite3 data/v2.db "DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='emacs'); DELETE FROM messaging_groups WHERE channel_type='emacs';"
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='emacs'); DELETE FROM messaging_groups WHERE channel_type='emacs';"
|
||||
```
|
||||
|
||||
@@ -44,7 +44,7 @@ import './gchat.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/gchat@4.26.0
|
||||
pnpm install @chat-adapter/gchat@4.27.0
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
|
||||
@@ -48,7 +48,7 @@ import './github.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/github@4.26.0
|
||||
pnpm install @chat-adapter/github@4.27.0
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
|
||||
@@ -82,11 +82,14 @@ For each target agent group, confirm OneCLI will inject Gmail secrets into its c
|
||||
onecli agents list
|
||||
```
|
||||
|
||||
If that agent's `secretMode` is `all`, you're done — Gmail secrets (identified by OneCLI's Gmail hostPattern) will auto-inject. If it's `selective`, explicitly assign the Gmail secrets:
|
||||
If that agent's `secretMode` is `all`, you're done — Gmail secrets (identified by OneCLI's Gmail hostPattern) will auto-inject. If it's `selective`, explicitly assign the Gmail secrets using the safe merge pattern (`set-secrets` replaces the entire list — always read first):
|
||||
|
||||
```bash
|
||||
onecli secrets list # find Gmail secret IDs (OneCLI creates one per connected app)
|
||||
onecli agents set-secrets --id <agent-id> --secret-ids <gmail-secret-id>
|
||||
GMAIL_IDS=$(onecli secrets list | jq -r '[.data[] | select(.name | test("(?i)gmail")) | .id] | join(",")')
|
||||
CURRENT=$(onecli agents secrets --id <agent-id> | jq -r '[.data[]] | join(",")')
|
||||
MERGED=$(printf '%s' "$CURRENT,$GMAIL_IDS" | tr ',' '\n' | sort -u | paste -sd ',' -)
|
||||
onecli agents set-secrets --id <agent-id> --secret-ids "$MERGED"
|
||||
onecli agents secrets --id <agent-id>
|
||||
```
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
@@ -71,38 +71,11 @@ AskUserQuestion: "Want periodic wiki health checks?"
|
||||
2. **Monthly**
|
||||
3. **Skip** — lint manually
|
||||
|
||||
If yes, create a NanoClaw scheduled task that runs in the wiki group. This is NOT a Claude Code cron job — it's a NanoClaw group task that runs in the agent container. Insert it into the SQLite database:
|
||||
If yes, ask the agent to schedule the lint task using the `schedule_task` MCP tool in conversation.
|
||||
|
||||
## Step 6: Restart
|
||||
|
||||
```bash
|
||||
pnpm exec tsx -e "
|
||||
const Database = require('better-sqlite3');
|
||||
const { CronExpressionParser } = require('cron-parser');
|
||||
const db = new Database('store/messages.db');
|
||||
const interval = CronExpressionParser.parse('<cron-expr>', { tz: process.env.TZ || 'UTC' });
|
||||
const nextRun = interval.next().toISOString();
|
||||
db.prepare('INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run(
|
||||
'wiki-lint',
|
||||
'<group_folder>',
|
||||
'<chat_jid>',
|
||||
'Run a wiki lint pass per the wiki container skill. Check for contradictions, orphan pages, stale content, missing cross-references, and gaps. Report findings and offer to fix issues.',
|
||||
'cron',
|
||||
'<cron-expr>',
|
||||
'group',
|
||||
nextRun,
|
||||
'active',
|
||||
new Date().toISOString()
|
||||
);
|
||||
db.close();
|
||||
"
|
||||
```
|
||||
|
||||
Use the group's `folder` and `chat_jid` from the registered groups table. Cron expressions: `0 10 * * 0` (weekly Sunday 10am) or `0 10 1 * *` (monthly 1st at 10am).
|
||||
|
||||
## Step 6: Build and restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
./container/build.sh
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
@@ -87,7 +87,7 @@ Linear OAuth apps can't be @-mentioned, so the bridge's `onNewMention` handler n
|
||||
### 5. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/linear@4.26.0
|
||||
pnpm install @chat-adapter/linear@4.27.0
|
||||
```
|
||||
|
||||
### 6. Build
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
---
|
||||
name: add-mnemon
|
||||
description: Add persistent graph-based memory via mnemon. Agents recall past context before responding and remember insights after each turn.
|
||||
---
|
||||
|
||||
# Add Mnemon — Persistent Memory
|
||||
|
||||
Installs [mnemon](https://github.com/mnemon-dev/mnemon) in the agent container image. On each container start, `mnemon setup` registers Claude Code hooks that surface relevant memory before the agent responds and store new insights after each turn. Memory is written to the per-agent-group `.claude/` mount and survives container restarts.
|
||||
|
||||
## Provider Compatibility
|
||||
|
||||
**mnemon hooks only work with `--target claude-code`.** If the agent group uses `AGENT_PROVIDER=opencode`, hooks registered by `mnemon setup` will never fire — OpenCode spawns its own process and doesn't invoke the `claude` CLI at all.
|
||||
|
||||
Check your provider:
|
||||
|
||||
```bash
|
||||
grep AGENT_PROVIDER .env groups/*/container.json 2>/dev/null
|
||||
```
|
||||
|
||||
- `AGENT_PROVIDER=claude` (default) — fully compatible, proceed with both Phase 2 steps.
|
||||
- `AGENT_PROVIDER=opencode` — use **Phase 2 (OpenCode path)** instead of the standard entrypoint step.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Check if already applied
|
||||
|
||||
```bash
|
||||
grep -q 'MNEMON_VERSION' container/Dockerfile && echo "Already applied" || echo "Not applied"
|
||||
```
|
||||
|
||||
If already applied, skip to Phase 3 (Verify).
|
||||
|
||||
### Check latest mnemon version
|
||||
|
||||
```bash
|
||||
curl -fsSL https://api.github.com/repos/mnemon-dev/mnemon/releases/latest | grep '"tag_name"'
|
||||
```
|
||||
|
||||
Note the version (e.g. `v0.1.1`) — use it as `MNEMON_VERSION` in the next step.
|
||||
|
||||
## Phase 2: Apply Changes (Claude Code path)
|
||||
|
||||
### 1. Dockerfile — install mnemon binary
|
||||
|
||||
Add after the AWS CLI block, before the Bun runtime section:
|
||||
|
||||
```dockerfile
|
||||
# ---- mnemon — persistent agent memory ----------------------------------------
|
||||
ARG MNEMON_VERSION=0.1.1
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \
|
||||
| tar -xz -C /usr/local/bin mnemon && \
|
||||
chmod +x /usr/local/bin/mnemon
|
||||
|
||||
ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon
|
||||
```
|
||||
|
||||
`MNEMON_DATA_DIR` points into the per-agent-group `.claude/` mount so memory persists across container restarts. No extra volume mounts needed.
|
||||
|
||||
### 2. Entrypoint — run mnemon setup on each container start
|
||||
|
||||
`mnemon setup` is idempotent. Edit `container/entrypoint.sh` to run it right after `set -e`, before the `cat` that captures stdin:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# NanoClaw agent container entrypoint.
|
||||
#
|
||||
# ...existing header comment...
|
||||
|
||||
set -e
|
||||
|
||||
mnemon setup --target claude-code --yes --global >/dev/stderr 2>&1
|
||||
|
||||
cat > /tmp/input.json
|
||||
|
||||
exec bun run /app/src/index.ts < /tmp/input.json
|
||||
```
|
||||
|
||||
`>/dev/stderr 2>&1` routes all mnemon output to stderr (docker logs) so it doesn't interfere with the JSON stdin handshake between host and agent-runner.
|
||||
|
||||
### 3. Rebuild and smoke-test the image
|
||||
|
||||
```bash
|
||||
./container/build.sh
|
||||
docker run --rm --entrypoint mnemon nanoclaw-agent:latest --version
|
||||
```
|
||||
|
||||
## Phase 3: Restart and Verify
|
||||
|
||||
### Restart the service
|
||||
|
||||
```bash
|
||||
systemctl --user restart nanoclaw # Linux
|
||||
# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
```
|
||||
|
||||
### Confirm mnemon hooks are registered
|
||||
|
||||
After the next container starts, check that setup ran:
|
||||
|
||||
```bash
|
||||
docker logs $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -1) 2>&1 | grep -i mnemon
|
||||
```
|
||||
|
||||
Then inspect the hooks inside the running container:
|
||||
|
||||
```bash
|
||||
docker exec $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -1) \
|
||||
cat /home/node/.claude/settings.json | grep -A5 mnemon
|
||||
```
|
||||
|
||||
### Test memory recall
|
||||
|
||||
Have a conversation with the agent, then start a new session and reference something from the earlier one. Mnemon should surface the relevant context automatically without you restating it.
|
||||
|
||||
## Phase 2 (OpenCode path) — context injection
|
||||
|
||||
mnemon hooks don't fire under OpenCode. Instead, the agent-runner injects mnemon context directly into every prompt via `wrapPromptWithContext()` in `container/agent-runner/src/providers/opencode.ts`. This is already implemented in NanoClaw — no code changes needed if you're on current `ester`/`main`.
|
||||
|
||||
**How it works:** On each prompt, `readMnemonContext()` checks for `MNEMON_DATA_DIR` (set by the Dockerfile `ENV`). If the env var is present, it reads `$MNEMON_DATA_DIR/prompt/guide.md` (mnemon's custom prompt guide, written by `mnemon setup`) or falls back to an inline guide. The content is prepended as a `<system>` block, instructing the agent to run `mnemon recall` at the start of relevant tasks and `mnemon remember` after key decisions.
|
||||
|
||||
**What this means for the agent:** The agent (running inside OpenCode) can call `mnemon recall`, `mnemon remember`, `mnemon link`, and `mnemon status` via its bash tool. mnemon writes its graph to `$MNEMON_DATA_DIR`, which is in the per-agent-group `.claude/` mount — so memory persists across container restarts.
|
||||
|
||||
**Applying:** Only the Dockerfile step from Phase 2 is needed for OpenCode agents. Skip `container/entrypoint.sh` entirely.
|
||||
|
||||
```dockerfile
|
||||
ARG MNEMON_VERSION=0.1.1
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \
|
||||
| tar -xz -C /usr/local/bin mnemon && \
|
||||
chmod +x /usr/local/bin/mnemon
|
||||
ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon
|
||||
```
|
||||
|
||||
Then rebuild: `./container/build.sh`
|
||||
|
||||
### Verify (OpenCode)
|
||||
|
||||
Start a session and ask the agent to run `mnemon status`. It should report empty graphs (no error) on first run.
|
||||
|
||||
```bash
|
||||
# Also confirm the binary is present in the image:
|
||||
docker run --rm --entrypoint mnemon nanoclaw-agent:latest --version
|
||||
```
|
||||
|
||||
## Memory Storage
|
||||
|
||||
Mnemon writes to `/home/node/.claude/mnemon/` inside the container, which maps to the per-agent-group `.claude/` directory on the host. To find the exact host path:
|
||||
|
||||
```bash
|
||||
docker inspect $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -1) \
|
||||
--format '{{range .Mounts}}{{if eq .Destination "/home/node/.claude"}}{{.Source}}{{end}}{{end}}'
|
||||
```
|
||||
|
||||
To reset all memory for an agent, stop the container and delete the `mnemon/` subdirectory from that host path.
|
||||
|
||||
## Migration Guide Update
|
||||
|
||||
If you are using `/migrate-nanoclaw`, add these entries to `.nanoclaw-migrations/05-dockerfile.md`:
|
||||
|
||||
**Dockerfile — after AWS CLI, before Bun runtime:**
|
||||
```dockerfile
|
||||
ARG MNEMON_VERSION=0.1.1
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \
|
||||
| tar -xz -C /usr/local/bin mnemon && \
|
||||
chmod +x /usr/local/bin/mnemon
|
||||
ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon
|
||||
```
|
||||
|
||||
**`container/entrypoint.sh` — add after `set -e`:**
|
||||
```bash
|
||||
mnemon setup --target claude-code --yes --global >/dev/stderr 2>&1
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `mnemon: command not found` in container
|
||||
|
||||
The image wasn't rebuilt after adding the Dockerfile layer. Run `./container/build.sh` and restart.
|
||||
|
||||
### Memory not persisting across restarts
|
||||
|
||||
Verify `MNEMON_DATA_DIR` resolves to a mounted path (not an in-container ephemeral directory):
|
||||
|
||||
```bash
|
||||
docker exec <container> sh -c 'ls -la $MNEMON_DATA_DIR'
|
||||
```
|
||||
|
||||
If the directory is empty after conversations, the mount is missing or the path is wrong. Check the host mount with the `docker inspect` command above.
|
||||
|
||||
### Agent not using past memory
|
||||
|
||||
`mnemon setup` writes hooks into `/home/node/.claude/settings.json`. Verify:
|
||||
|
||||
```bash
|
||||
docker exec <container> cat /home/node/.claude/settings.json
|
||||
```
|
||||
|
||||
If the hooks are absent, `mnemon setup` may have failed silently. Check container startup logs for errors from mnemon.
|
||||
|
||||
### Setup fails at container start
|
||||
|
||||
Run setup manually inside a running container to see the full error:
|
||||
|
||||
```bash
|
||||
docker exec -it <container> mnemon setup --target claude-code --yes --global
|
||||
```
|
||||
@@ -76,7 +76,7 @@ Then rebuild the container image: `./container/build.sh`
|
||||
|
||||
Ask the user (plain text, not AskUserQuestion):
|
||||
|
||||
1. **Which agent group?** List available groups: `sqlite3 data/v2.db "SELECT folder, name FROM agent_groups;"`
|
||||
1. **Which agent group?** List available groups: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT folder, name FROM agent_groups;"`
|
||||
2. **Which Ollama model?** List available: `curl -s http://localhost:11434/api/tags | grep '"name"'`
|
||||
3. **Block Anthropic API?** Recommended yes — prevents accidental spend if config drifts.
|
||||
|
||||
@@ -111,7 +111,7 @@ Read the agent group's shared Claude settings:
|
||||
|
||||
```bash
|
||||
# Find the agent group ID
|
||||
AG_ID=$(sqlite3 data/v2.db "SELECT id FROM agent_groups WHERE folder='<FOLDER>';")
|
||||
AG_ID=$(pnpm exec tsx scripts/q.ts data/v2.db "SELECT id FROM agent_groups WHERE folder='<FOLDER>';")
|
||||
SETTINGS=data/v2-sessions/$AG_ID/.claude-shared/settings.json
|
||||
```
|
||||
|
||||
|
||||
@@ -132,12 +132,15 @@ Credentials: register provider API keys in OneCLI with the matching `--host-patt
|
||||
|
||||
After adding a secret, **grant the agent access** — agents in `selective` mode only receive secrets they've been explicitly assigned:
|
||||
|
||||
```bash
|
||||
# Find the agent id and secret id, then:
|
||||
onecli agents set-secrets --id <agent-id> --secret-ids <existing-ids>,<new-secret-id>
|
||||
```
|
||||
Use the safe merge pattern — `set-secrets` replaces the entire list, so always read first:
|
||||
|
||||
Always include existing secret IDs in the list — `set-secrets` replaces, not appends.
|
||||
```bash
|
||||
AGENT_ID=$(onecli agents list | jq -r '.data[] | select(.identifier=="<agentGroupId>") | .id')
|
||||
CURRENT=$(onecli agents secrets --id "$AGENT_ID" | jq -r '[.data[]] | join(",")')
|
||||
MERGED=$(printf '%s' "$CURRENT,<new-secret-id>" | tr ',' '\n' | sort -u | paste -sd ',' -)
|
||||
onecli agents set-secrets --id "$AGENT_ID" --secret-ids "$MERGED"
|
||||
onecli agents secrets --id "$AGENT_ID"
|
||||
```
|
||||
|
||||
#### Example: DeepSeek
|
||||
|
||||
|
||||
@@ -275,7 +275,7 @@ Look for: `Parallel AI MCP servers configured`
|
||||
- Check agent-runner logs for "Parallel AI MCP servers configured" message
|
||||
|
||||
**Task polling not working:**
|
||||
- Verify scheduled task was created: `sqlite3 store/messages.db "SELECT * FROM scheduled_tasks"`
|
||||
- Verify scheduled task was created: `pnpm exec tsx scripts/q.ts store/messages.db "SELECT * FROM scheduled_tasks"`
|
||||
- Check task runs: `tail -f logs/nanoclaw.log | grep "scheduled task"`
|
||||
- Ensure task prompt includes proper Parallel MCP tool names
|
||||
|
||||
|
||||
@@ -200,7 +200,7 @@ systemctl --user restart nanoclaw
|
||||
After the service starts, send any message to the Signal number from your personal Signal app. The router auto-creates a `messaging_groups` row. Then:
|
||||
|
||||
```bash
|
||||
sqlite3 data/v2.db \
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"SELECT id, platform_id FROM messaging_groups WHERE channel_type='signal' ORDER BY created_at DESC LIMIT 5"
|
||||
```
|
||||
|
||||
@@ -212,7 +212,7 @@ Add the Signal number to a group from your phone, send any message, then wire th
|
||||
|
||||
```bash
|
||||
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
|
||||
sqlite3 data/v2.db "
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "
|
||||
INSERT OR IGNORE INTO messaging_group_agents
|
||||
(id, messaging_group_id, agent_group_id, session_mode, priority, created_at)
|
||||
VALUES
|
||||
@@ -226,7 +226,7 @@ New Signal users (including the owner's Signal identity) are silently dropped wi
|
||||
|
||||
```bash
|
||||
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
|
||||
sqlite3 data/v2.db "
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "
|
||||
INSERT OR REPLACE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at)
|
||||
VALUES ('signal:UUID', 'owner', NULL, 'system', '$NOW');
|
||||
INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at)
|
||||
@@ -282,8 +282,13 @@ If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DA
|
||||
### Bot not responding
|
||||
|
||||
1. Channel initialized: `grep "Signal channel connected" logs/nanoclaw.log | tail -1`
|
||||
2. Channel wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='signal'"`
|
||||
2. Channel wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='signal'"`
|
||||
3. Service running: `launchctl print gui/$(id -u)/com.nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux)
|
||||
4. **Check for duplicate service instances** — if `logs/nanoclaw.error.log` shows `No adapter for channel type channelType="signal"` despite the adapter starting, two NanoClaw processes are racing. See the `/debug` skill section "No adapter for channel type / Messages silently lost" for the full fix.
|
||||
|
||||
### Messages delivered but never arrive (null platformMsgId)
|
||||
|
||||
Signal responses show `platformMsgId=undefined` in the main log. This means the delivery poll ran but found no adapter — likely a duplicate service instance issue (see above). Affected messages cannot be retried; the user must resend.
|
||||
|
||||
### Lost connection mid-session
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ import './slack.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/slack@4.26.0
|
||||
pnpm install @chat-adapter/slack@4.27.0
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
@@ -60,7 +60,7 @@ pnpm run build
|
||||
1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** > **From scratch**
|
||||
2. Name it (e.g., "NanoClaw") and select your workspace
|
||||
3. Go to **OAuth & Permissions** and add Bot Token Scopes:
|
||||
- `chat:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`
|
||||
- `chat:write`, `im:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`
|
||||
4. Click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`)
|
||||
5. Go to **Basic Information** and copy the **Signing Secret**
|
||||
|
||||
@@ -76,7 +76,13 @@ pnpm run build
|
||||
10. Under **Subscribe to bot events**, add:
|
||||
- `message.channels`, `message.groups`, `message.im`, `app_mention`
|
||||
11. Click **Save Changes**
|
||||
12. Slack will show a banner asking you to **reinstall the app** — click it to apply the new event subscriptions
|
||||
|
||||
### Interactivity
|
||||
|
||||
12. Go to **Interactivity & Shortcuts** and toggle **Interactivity** on
|
||||
13. Set the **Request URL** to the same `https://your-domain/webhook/slack`
|
||||
14. Click **Save Changes**
|
||||
15. Slack will show a banner asking you to **reinstall the app** — click it to apply the new settings
|
||||
|
||||
### Configure environment
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ import './teams.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/teams@4.26.0
|
||||
pnpm install @chat-adapter/teams@4.27.0
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
|
||||
@@ -58,7 +58,7 @@ In `setup/index.ts`, add this entry to the `STEPS` map (right after the `registe
|
||||
### 5. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/telegram@4.26.0
|
||||
pnpm install @chat-adapter/telegram@4.27.0
|
||||
```
|
||||
|
||||
### 6. Build
|
||||
|
||||
@@ -90,12 +90,12 @@ onecli secrets list | grep -i vercel
|
||||
OneCLI uses selective secret mode — secrets must be explicitly assigned to each agent. Get the Vercel secret ID from the output above, then assign it to every agent:
|
||||
|
||||
```bash
|
||||
# For each agent, add the Vercel secret to its assigned secrets list.
|
||||
# First get current assignments, then set them with the new secret appended.
|
||||
VERCEL_SECRET_ID=$(onecli secrets list 2>/dev/null | grep -B2 "Vercel" | grep '"id"' | head -1 | sed 's/.*"id": "//;s/".*//')
|
||||
for agent in $(onecli agents list 2>/dev/null | grep '"id"' | sed 's/.*"id": "//;s/".*//'); do
|
||||
CURRENT=$(onecli agents secrets --id "$agent" 2>/dev/null | grep '"' | grep -v hint | grep -v data | sed 's/.*"//;s/".*//' | tr '\n' ',' | sed 's/,$//')
|
||||
onecli agents set-secrets --id "$agent" --secret-ids "${CURRENT:+$CURRENT,}$VERCEL_SECRET_ID"
|
||||
# set-secrets replaces the entire list — read and merge for each agent.
|
||||
VERCEL_SECRET_ID=$(onecli secrets list | jq -r '.data[] | select(.name | test("(?i)vercel")) | .id' | head -1)
|
||||
for agent in $(onecli agents list | jq -r '.data[].id'); do
|
||||
CURRENT=$(onecli agents secrets --id "$agent" | jq -r '[.data[]] | join(",")')
|
||||
MERGED=$(printf '%s' "$CURRENT,$VERCEL_SECRET_ID" | tr ',' '\n' | sort -u | paste -sd ',' -)
|
||||
onecli agents set-secrets --id "$agent" --secret-ids "$MERGED"
|
||||
done
|
||||
```
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ import './whatsapp-cloud.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/whatsapp@4.26.0
|
||||
pnpm install @chat-adapter/whatsapp@4.27.0
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
|
||||
@@ -57,7 +57,7 @@ groups: () => import('./groups.js'),
|
||||
### 5. Install the adapter packages (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||
pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||
```
|
||||
|
||||
### 6. Build
|
||||
@@ -200,7 +200,7 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
||||
|
||||
- **type**: `whatsapp`
|
||||
- **terminology**: WhatsApp calls them "groups" and "chats." A "chat" is a 1:1 DM; a "group" has multiple members.
|
||||
- **how-to-find-id**: DMs use `<phone>@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `<id>@g.us`. To find your number: `node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')"`. Groups are auto-discovered — check `sqlite3 data/v2.db "SELECT platform_id, name FROM messaging_groups WHERE channel_type='whatsapp' AND is_group=1"`.
|
||||
- **how-to-find-id**: DMs use `<phone>@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `<id>@g.us`. To find your number: `node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')"`. Groups are auto-discovered — check `pnpm exec tsx scripts/q.ts data/v2.db "SELECT platform_id, name FROM messaging_groups WHERE channel_type='whatsapp' AND is_group=1"`.
|
||||
- **supports-threads**: no
|
||||
- **typical-use**: Interactive chat — direct messages or small groups
|
||||
- **default-isolation**: Same agent group if you're the only participant across multiple chats. Separate agent group if different people are in different groups.
|
||||
@@ -256,7 +256,7 @@ systemctl --user start nanoclaw
|
||||
|
||||
1. Auth exists: `test -f store/auth/creds.json`
|
||||
2. Connected: `grep "Connected to WhatsApp" logs/nanoclaw.log | tail -1`
|
||||
3. Channel wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id=mga.messaging_group_id WHERE mg.channel_type='whatsapp'"`
|
||||
3. Channel wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id=mga.messaging_group_id WHERE mg.channel_type='whatsapp'"`
|
||||
4. Service running: `systemctl --user status nanoclaw`
|
||||
|
||||
### "conflict" disconnection
|
||||
|
||||
@@ -57,7 +57,50 @@ Debug level shows:
|
||||
|
||||
## Common Issues
|
||||
|
||||
### 1. "Claude Code process exited with code 1"
|
||||
### 1. "No adapter for channel type" / Messages silently lost (null platformMsgId)
|
||||
|
||||
**Symptom:** The bot stops replying. `logs/nanoclaw.error.log` shows repeated:
|
||||
```
|
||||
WARN No adapter for channel type channelType="telegram"
|
||||
WARN No adapter for channel type channelType="signal"
|
||||
```
|
||||
The main log shows "Message delivered" entries with `platformMsgId=undefined` — meaning the delivery poll ran, found no adapter, and permanently marked the message as delivered without sending it.
|
||||
|
||||
**Root cause: two NanoClaw service instances running simultaneously.**
|
||||
|
||||
When a second service instance (often `nanoclaw-v2-<id>.service` running alongside `nanoclaw.service`) is active with a stale binary, it has no channel adapters registered. Its delivery poll races against the working instance and wins — permanently marking outbound messages as delivered without ever sending them.
|
||||
|
||||
**Diagnosis:**
|
||||
```bash
|
||||
# Check for duplicate running instances
|
||||
ps aux | grep 'nanoclaw/dist/index.js' | grep -v grep
|
||||
|
||||
# Check which services are active
|
||||
systemctl --user list-units 'nanoclaw*' --all
|
||||
|
||||
# Confirm channel adapters registered by the current process
|
||||
grep "Channel adapter started" logs/nanoclaw.log | tail -10
|
||||
```
|
||||
|
||||
**Fix:**
|
||||
1. Identify which service has the correct binary and EnvironmentFile (the one showing `signal`, `telegram`, `cli` all started in the log).
|
||||
2. Stop and disable the stale duplicate service:
|
||||
```bash
|
||||
systemctl --user stop nanoclaw.service # or whichever is the old one
|
||||
systemctl --user disable nanoclaw.service
|
||||
```
|
||||
3. If the remaining service unit is missing `EnvironmentFile`, add it:
|
||||
```bash
|
||||
# Edit the service unit — add this line under [Service]:
|
||||
# EnvironmentFile=/home/[user]/nanoclaw/.env
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user restart nanoclaw-v2-<id>.service
|
||||
```
|
||||
4. Verify only one instance runs: `ps aux | grep nanoclaw/dist/index.js | grep -v grep`
|
||||
|
||||
**Note:** Messages that were marked delivered with a null `platform_message_id` cannot be automatically retried — they are permanently lost. The user must resend their message.
|
||||
|
||||
### 2. "Claude Code process exited with code 1"
|
||||
|
||||
**Check the container log file** in `groups/{folder}/logs/container-*.log`
|
||||
|
||||
@@ -279,7 +322,7 @@ rm -rf data/sessions/
|
||||
rm -rf data/sessions/{groupFolder}/.claude/
|
||||
|
||||
# Also clear the session ID from NanoClaw's tracking (stored in SQLite)
|
||||
sqlite3 store/messages.db "DELETE FROM sessions WHERE group_folder = '{groupFolder}'"
|
||||
pnpm exec tsx scripts/q.ts store/messages.db "DELETE FROM sessions WHERE group_folder = '{groupFolder}'"
|
||||
```
|
||||
|
||||
To verify session resumption is working, check the logs for the same session ID across messages:
|
||||
|
||||
@@ -54,7 +54,7 @@ Tell the user:
|
||||
Wait for the user's confirmation. Then look up the most recent DM messaging groups:
|
||||
|
||||
```bash
|
||||
sqlite3 data/v2.db "SELECT id, platform_id, name, created_at FROM messaging_groups WHERE channel_type='${CHANNEL}' AND is_group=0 ORDER BY created_at DESC LIMIT 5"
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "SELECT id, platform_id, name, created_at FROM messaging_groups WHERE channel_type='${CHANNEL}' AND is_group=0 ORDER BY created_at DESC LIMIT 5"
|
||||
```
|
||||
|
||||
Show the top rows to the user and confirm which `platform_id` is theirs (usually the most recent). Record as `PLATFORM_ID`. If none appeared, check `logs/nanoclaw.log` for `unknown_sender` drops — the adapter might be rejecting inbound due to connection or permission issues.
|
||||
@@ -103,7 +103,7 @@ Wait for the user's reply. If they confirm receipt, the skill is done.
|
||||
|
||||
If they say it didn't arrive, then diagnose using the DB directly (no waiting loops required — the message either delivered or it didn't):
|
||||
|
||||
- `sqlite3 data/v2-sessions/<agent-group-id>/sessions/<session-id>/outbound.db "SELECT id, status, created_at FROM messages_out ORDER BY created_at DESC LIMIT 5"` — check for stuck `pending` rows. Replace `<agent-group-id>` and `<session-id>` with the values from the script's output.
|
||||
- `pnpm exec tsx scripts/q.ts data/v2-sessions/<agent-group-id>/sessions/<session-id>/outbound.db "SELECT id, status, created_at FROM messages_out ORDER BY created_at DESC LIMIT 5"` — check for stuck `pending` rows. Replace `<agent-group-id>` and `<session-id>` with the values from the script's output.
|
||||
- `grep -E 'Unauthorized channel destination|container.*exited|error' logs/nanoclaw.log | tail -20` — look for ACL rejections or container crashes.
|
||||
- `ls data/v2-sessions/<agent-group-id>/sessions/*/outbound.db` — confirm the session exists.
|
||||
|
||||
|
||||
@@ -259,6 +259,41 @@ Tell the user:
|
||||
- To manage secrets: `onecli secrets list`, or open ${ONECLI_URL}
|
||||
- To add rate limits or policies: `onecli rules create --help`
|
||||
|
||||
## Granting secrets to agents (safe merge)
|
||||
|
||||
`set-secrets` **replaces** the agent's entire secret list — it never appends. Always read the current list first and merge before calling it. This pattern is canonical across all skills that assign secrets:
|
||||
|
||||
```bash
|
||||
AGENT_ID=$(onecli agents list | jq -r '.data[] | select(.identifier=="<agentGroupId>") | .id')
|
||||
CURRENT=$(onecli agents secrets --id "$AGENT_ID" | jq -r '[.data[]] | join(",")')
|
||||
MERGED=$(printf '%s' "$CURRENT,<new-secret-id>" | tr ',' '\n' | sort -u | paste -sd ',' -)
|
||||
onecli agents set-secrets --id "$AGENT_ID" --secret-ids "$MERGED"
|
||||
onecli agents secrets --id "$AGENT_ID"
|
||||
```
|
||||
|
||||
- `<agentGroupId>` — the `agentGroupId` field in `groups/<folder>/container.json`
|
||||
- `<new-secret-id>` — the `id` from `onecli secrets list`
|
||||
- Multiple new secrets: append them comma-separated before the `printf` step
|
||||
|
||||
### git over HTTPS
|
||||
|
||||
OneCLI's proxy injects credentials proactively — `injections_applied=1` appears in `docker logs onecli` even when git sends no auth header. However, OneCLI sets `SSL_CERT_FILE` for Node/Python/Deno but not `GIT_SSL_CAINFO`. Without it, git rejects the OneCLI MITM certificate.
|
||||
|
||||
**Auth format matters**: GitHub's git smart HTTP protocol (`github.com`) requires `Basic` auth, not `Bearer`. GitHub's REST API (`api.github.com`) accepts `Bearer`. These must be configured as separate secrets with different formats — see `/add-github` for the full setup.
|
||||
|
||||
If an agent uses `git` or `gh`, add to `data/v2-sessions/<agent-group-id>/.claude-shared/settings.json`:
|
||||
|
||||
```json
|
||||
"GIT_SSL_CAINFO": "/tmp/onecli-combined-ca.pem",
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
"GIT_CONFIG_COUNT": "1",
|
||||
"GIT_CONFIG_KEY_0": "credential.helper",
|
||||
"GIT_CONFIG_VALUE_0": "",
|
||||
"GH_TOKEN": "ghp_onecli_proxy_replaces_this"
|
||||
```
|
||||
|
||||
**Debugging injection**: `docker logs onecli 2>&1 | grep "github.com"` shows every request with `injections_applied=N` and the HTTP status. If `injections_applied=1` but status is still 401, the injected credential value is wrong or uses the wrong auth format for that endpoint.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"OneCLI gateway not reachable" in logs:** The gateway isn't running. Check with `curl -sf ${ONECLI_URL}/health`. Start it with `onecli start` if needed.
|
||||
|
||||
@@ -11,7 +11,22 @@ Privilege is a **user-level** concept, not a channel-level one (see `src/db/user
|
||||
|
||||
## Assess Current State
|
||||
|
||||
Read the central DB (`data/v2.db`) — query `agent_groups`, `messaging_groups`, `messaging_group_agents`, `users`, and `user_roles` tables. Also check `.env` for channel tokens and `src/channels/index.ts` for uncommented imports.
|
||||
Read the central DB (`data/v2.db`) using these canonical queries (column names match the schema, not the CLI flags — the `register` command's `--assistant-name` is stored in `agent_groups.name`).
|
||||
|
||||
Run each via the in-tree wrapper — the host setup deliberately ships no `sqlite3` CLI:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "<query>"
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT id, name AS assistant_name, folder, agent_provider FROM agent_groups;
|
||||
SELECT id, channel_type, platform_id, name, unknown_sender_policy FROM messaging_groups;
|
||||
SELECT messaging_group_id, agent_group_id, session_mode, priority FROM messaging_group_agents;
|
||||
SELECT user_id, role, agent_group_id FROM user_roles ORDER BY role='owner' DESC;
|
||||
```
|
||||
|
||||
Also check `.env` for channel tokens and `src/channels/index.ts` for uncommented imports.
|
||||
|
||||
Categorize channels as: **wired** (has DB entities + messaging_group_agents row), **configured but unwired** (has credentials + barrel import, no DB entities), or **not configured**.
|
||||
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
---
|
||||
name: migrate-from-v1
|
||||
description: Finish migrating a NanoClaw v1 install into v2. Run after `bash migrate-v2.sh` completes. Seeds the owner, cleans up CLAUDE.local.md files, reconciles container configs, and helps port custom v1 code. Triggers on "migrate from v1", "finish migration", "v1 migration".
|
||||
---
|
||||
|
||||
# Finish v1 → v2 migration
|
||||
|
||||
`bash migrate-v2.sh` already ran the deterministic migration. It handled:
|
||||
|
||||
- .env keys merged
|
||||
- v2 DB seeded (agent_groups, messaging_groups, wiring)
|
||||
- Group folders copied (v1 CLAUDE.md → v2 CLAUDE.local.md)
|
||||
- Session data copied with conversation continuity (incl. Claude Code memory + JSONL transcripts)
|
||||
- Scheduled tasks ported
|
||||
- Channel code installed and auth state copied (incl. WhatsApp Baileys keystore)
|
||||
- WhatsApp LIDs resolved from `store/auth` and aliased into `messaging_groups`
|
||||
- Container skills copied
|
||||
- Container image built
|
||||
|
||||
Your job is the parts that need human judgment: triage any failed steps, seed the owner, clean up CLAUDE.local.md files, reconcile configs, and port any fork customizations.
|
||||
|
||||
Read `logs/setup-migration/handoff.json` first — it has `overall_status`, per-step results in `steps`, and a `followups` list.
|
||||
|
||||
## Preflight: was the script run?
|
||||
|
||||
Before anything else, check that `logs/setup-migration/handoff.json` exists. If it doesn't, the user is invoking this skill before `migrate-v2.sh` ran. Stop and tell them, verbatim:
|
||||
|
||||
> This skill finishes a migration that `migrate-v2.sh` started. Run that first, in your terminal — not from inside Claude:
|
||||
>
|
||||
> ```bash
|
||||
> bash migrate-v2.sh
|
||||
> ```
|
||||
>
|
||||
> It needs interactive prompts (channel selection, service switchover) and runs Node/pnpm bootstrap, Docker, OneCLI setup, and a container build that don't fit inside a Claude session. When it finishes, it'll hand control back to Claude automatically — at which point this skill picks up.
|
||||
|
||||
Do not attempt to run the script yourself, simulate its effects, or pick up the migration mid-stream. The deterministic side has dependencies on a real interactive shell.
|
||||
|
||||
Once `handoff.json` exists, proceed to Phase 0.
|
||||
|
||||
## Phase 0: Get v2 routing real messages
|
||||
|
||||
Before any deeper migration work, prove v2 actually answers messages on the user's real channels. v1 is paused, not touched — flipping back is a service restart.
|
||||
|
||||
### 0a — Fix blockers only
|
||||
|
||||
Walk `handoff.steps`. Fix only the failures that would stop the bot from routing one message; defer the rest to its later phase.
|
||||
|
||||
### 0b — Smoke test, then continue
|
||||
|
||||
Tell the user the switch is non-destructive (v1 is paused, not modified; reverting is one command). Help them stop v1's service unit and start v2's, tail the host log for a clean boot, and have them send a real test message. Use `AskUserQuestion` to confirm the bot responded.
|
||||
|
||||
If yes, continue to Phase 1. If no, diagnose from `logs/nanoclaw.log` and re-test — don't proceed to deeper work on a broken router.
|
||||
|
||||
### Deferred failures
|
||||
|
||||
Re-visit anything you skipped in 0a before declaring the migration done. Most surface naturally in later phases (`1c-groups` ↔ Phase 2, `1e-tasks` ↔ task verification).
|
||||
|
||||
## Phase 1: Owner and access
|
||||
|
||||
v2 auto-creates a `users` row for every sender it sees (via `extractAndUpsertUser` in `src/modules/permissions/index.ts`). By the time this skill runs, the owner's row likely already exists — it just needs the `owner` role granted.
|
||||
|
||||
**User ID format**: always `<channel_type>:<platform_handle>`. Each channel populates this differently:
|
||||
- **Telegram**: `telegram:<numeric_user_id>` (e.g. `telegram:6037840640`)
|
||||
- **Discord**: `discord:<snowflake_user_id>` (e.g. `discord:123456789012345678`)
|
||||
- **WhatsApp**: `whatsapp:<phone>@s.whatsapp.net` (e.g. `whatsapp:14155551234@s.whatsapp.net`)
|
||||
- **Slack**: `slack:<user_id>` (e.g. `slack:U04ABCDEF`)
|
||||
- **Others**: `<channel_type>:<platform_id>`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Query `users` table: `SELECT id, kind, display_name FROM users`.
|
||||
2. If exactly one user exists, confirm: `AskUserQuestion`: "Is `<display_name>` (`<id>`) you?" — Yes / No, let me type it.
|
||||
3. If multiple users exist, present them as options in `AskUserQuestion`.
|
||||
4. If no users exist yet (service hasn't received a message), ask the user to send a test message first, then re-query.
|
||||
5. Once confirmed, check `user_roles` — if the owner role already exists, skip. Otherwise insert:
|
||||
```sql
|
||||
INSERT INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at)
|
||||
VALUES ('<user_id>', 'owner', NULL, NULL, datetime('now'))
|
||||
```
|
||||
|
||||
Use the DB helpers in `src/db/user-roles.ts` — they keep indexes correct. Init the DB first:
|
||||
|
||||
```ts
|
||||
import { initDb } from '../src/db/connection.js';
|
||||
import { runMigrations } from '../src/db/migrations/index.js';
|
||||
import { DATA_DIR } from '../src/config.js';
|
||||
import path from 'path';
|
||||
const db = initDb(path.join(DATA_DIR, 'v2.db'));
|
||||
runMigrations(db);
|
||||
```
|
||||
|
||||
### Access policy
|
||||
|
||||
After seeding the owner, discuss the access policy. v2's `messaging_groups.unknown_sender_policy` controls who can interact with the bot. `migrate-v2.sh` set it to `public` so the bot would respond during the switchover test, but the user may want to tighten it.
|
||||
|
||||
Present the options via `AskUserQuestion`:
|
||||
|
||||
1. **Public** (current) — anyone can message the bot. Good for personal DM bots.
|
||||
2. **Known users only** — only users in `agent_group_members` can trigger the bot. Others are silently dropped.
|
||||
3. **Approval required** — unknown senders trigger an approval request to the owner. Good for group chats where you want to vet new members.
|
||||
|
||||
If the user picks option 2 or 3, seed the known users from v1's message history. The v1 database is at `<handoff.v1_path>/store/messages.db`. It has a `messages` table with `sender` and `sender_name` columns. For each group:
|
||||
|
||||
```sql
|
||||
-- v1: unique senders per chat (excluding bot messages)
|
||||
SELECT DISTINCT sender, sender_name
|
||||
FROM messages
|
||||
WHERE chat_jid = '<v1_jid>' AND is_from_me = 0 AND sender IS NOT NULL
|
||||
```
|
||||
|
||||
The `sender` value is a platform handle (e.g. `6037840640` for Telegram). Build the v2 user ID by inferring the channel type from the chat JID prefix (use `parseJid` from `setup/migrate-v2/shared.ts`) and combining: `<channel_type>:<sender>`.
|
||||
|
||||
For each sender:
|
||||
1. Upsert into `users(id, kind, display_name)` if not already present.
|
||||
2. Insert into `agent_group_members(user_id, agent_group_id)` for each agent group wired to that messaging group.
|
||||
|
||||
Show the user the list of senders being imported and let them deselect any they don't want.
|
||||
|
||||
Then update the messaging groups:
|
||||
```sql
|
||||
UPDATE messaging_groups SET unknown_sender_policy = '<chosen_policy>'
|
||||
WHERE id IN (SELECT id FROM messaging_groups WHERE channel_type IN (<migrated_channels>))
|
||||
```
|
||||
|
||||
## Phase 2: Clean up CLAUDE.local.md
|
||||
|
||||
The migration copied v1's entire CLAUDE.md into CLAUDE.local.md for each group. This file now contains v1 boilerplate that v2 handles through its own composed fragments (`container/CLAUDE.md` + `.claude-fragments/module-*.md`). The user's customizations are buried inside.
|
||||
|
||||
For each group that has a `CLAUDE.local.md`:
|
||||
|
||||
1. Read the file.
|
||||
2. Read the v1 template it was based on. Determine which template by checking the v1 install:
|
||||
- If the group had `is_main=1` in v1's `registered_groups`, the template was `groups/main/CLAUDE.md`
|
||||
- Otherwise, the template was `groups/global/CLAUDE.md`
|
||||
- The v1 path is in `handoff.json` → `v1_path`
|
||||
3. Diff the file against the template. Identify sections that are:
|
||||
- **Stock boilerplate** (identical to template) — remove. v2's fragments cover this.
|
||||
- **User customizations** (added sections, modified sections) — keep.
|
||||
4. The following v1 sections are now handled by v2 fragments and should be removed even if slightly modified:
|
||||
- "What You Can Do" → v2 runtime system prompt
|
||||
- "Communication" / "Internal thoughts" / "Sub-agents" → `container/CLAUDE.md` + `module-core.md`
|
||||
- "Your Workspace" / workspace path references → `container/CLAUDE.md`
|
||||
- "Memory" (the stock version) → `container/CLAUDE.md`
|
||||
- "Message Formatting" → `container/CLAUDE.md`
|
||||
- "Admin Context" → v2 uses `user_roles`, not is_main
|
||||
- "Authentication" → v2 uses OneCLI
|
||||
- "Container Mounts" → v2 mounts are different
|
||||
- "Managing Groups" / "Finding Available Groups" / "Registered Groups Config" → v2 entity model, no IPC
|
||||
- "Global Memory" → v2 has `.claude-shared.md` symlink
|
||||
- "Scheduling for Other Groups" → `module-scheduling.md`
|
||||
- "Task Scripts" → `module-scheduling.md`
|
||||
- "Sender Allowlist" → v2 uses `unknown_sender_policy` + `user_roles`
|
||||
5. Fix path references in kept sections:
|
||||
- `/workspace/group/` → `/workspace/agent/`
|
||||
- `/workspace/project/` → these paths don't exist in v2; discuss with the user
|
||||
- `/workspace/ipc/` → gone; remove references
|
||||
- `/workspace/extra/` → v2 uses `container.json` `additionalMounts`; keep but note the path may change
|
||||
6. Keep the `# Name` heading and first paragraph (identity) — this is the user's agent personality.
|
||||
7. Show the user the proposed new CLAUDE.local.md before writing it. Use `AskUserQuestion`: "Here's what I'd keep — look right?" with options to approve, edit, or keep the original.
|
||||
|
||||
If a CLAUDE.local.md has no user customizations (pure template copy), write a minimal file with just the identity heading.
|
||||
|
||||
## Phase 3: Container config
|
||||
|
||||
`migrate-v2.sh` writes `container.json` directly from v1's `container_config` (the `additionalMounts` shape is identical). If the v1 config was unparseable, it falls back to a `.v1-container-config.json` sidecar.
|
||||
|
||||
For each group, check:
|
||||
|
||||
1. If `container.json` exists, read it and verify the `additionalMounts` host paths are still valid on this machine. Flag any that don't exist.
|
||||
2. If `.v1-container-config.json` exists (parse failure fallback), read it, discuss with the user, and write a proper `container.json`. Then delete the sidecar.
|
||||
3. Check for `env` or `packages` fields — `env` may overlap with OneCLI vault, `packages` (apt/npm) are portable.
|
||||
|
||||
## Phase 4: Fork customizations
|
||||
|
||||
Check whether the user's v1 install was a customized fork.
|
||||
|
||||
```bash
|
||||
cd <v1_path>
|
||||
git remote -v
|
||||
git log --oneline <upstream>/main..HEAD 2>/dev/null
|
||||
```
|
||||
|
||||
If no commits ahead of upstream: stock v1, skip this phase.
|
||||
|
||||
If there are commits:
|
||||
|
||||
1. Show the commit list to the user.
|
||||
2. `AskUserQuestion`: "How do you want to handle your v1 customizations?"
|
||||
- **Copy portable items** (recommended) — copy `container/skills/*`, `.claude/skills/*`, `docs/*`. Scan each with `scanForV1Patterns` from `setup/migrate-v2/shared.ts`.
|
||||
- **Full walkthrough** — go commit by commit, decide together.
|
||||
- **Reference only** — stash to `docs/v1-fork-reference/` for later.
|
||||
3. Source code (`src/*`, `container/agent-runner/src/*`) is NOT portable — v2's architecture is fundamentally different. Stash to `docs/v1-fork-reference/` with a README explaining what each file did. Don't translate.
|
||||
|
||||
## Principles
|
||||
|
||||
- **v1 checkout is read-only.** Never modify files under `handoff.v1_path`.
|
||||
- **Show before writing.** Show diffs/proposed content before modifying CLAUDE.local.md or container.json.
|
||||
- **Mask credentials** when displaying (first 4 + `...` + last 4 characters).
|
||||
- **`handoff.json` is the recovery point.** If context gets compacted, re-read it and `git status` to recover state.
|
||||
|
||||
## Setup steps you can run
|
||||
|
||||
The setup flow at `setup/index.ts` has individual steps you can invoke if something is missing or failed:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx setup/index.ts --step <name>
|
||||
```
|
||||
|
||||
| Step | When to use |
|
||||
|------|-------------|
|
||||
| `onecli` | OneCLI not installed or not healthy |
|
||||
| `auth` | No Anthropic credential in vault |
|
||||
| `container` | Container image needs rebuild |
|
||||
| `service` | Service not installed or not running |
|
||||
| `mounts` | Mount allowlist missing |
|
||||
| `verify` | End-to-end health check (run after everything else) |
|
||||
| `environment` | System check (Node, dirs) |
|
||||
|
||||
## When done
|
||||
|
||||
1. Run the verify step to confirm everything works:
|
||||
```bash
|
||||
pnpm exec tsx setup/index.ts --step verify
|
||||
```
|
||||
2. Delete `logs/setup-migration/handoff.json` — offer to save as `docs/migration-<date>.md` first.
|
||||
3. Restart the service if running so changes take effect:
|
||||
```bash
|
||||
# Linux
|
||||
systemctl --user restart nanoclaw-v2-*
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw-v2-*
|
||||
```
|
||||
@@ -17,8 +17,9 @@ Run `/update-nanoclaw` in Claude Code.
|
||||
|
||||
**Preview**: runs `git log` and `git diff` against the merge base to show upstream changes since your last sync. Groups changed files into categories:
|
||||
- **Skills** (`.claude/skills/`): unlikely to conflict unless you edited an upstream skill
|
||||
- **Source** (`src/`): may conflict if you modified the same files
|
||||
- **Build/config** (`package.json`, `tsconfig*.json`, `container/`): review needed
|
||||
- **Host source** (`src/`): may conflict if you modified the same files
|
||||
- **Container** (`container/`): triggers container rebuild
|
||||
- **Build/config** (`package.json`, `pnpm-lock.yaml`, `tsconfig*.json`): lockfile changes trigger dep install
|
||||
|
||||
**Update paths** (you pick one):
|
||||
- `merge` (default): `git merge upstream/<branch>`. Resolves all conflicts in one pass.
|
||||
@@ -30,7 +31,7 @@ Run `/update-nanoclaw` in Claude Code.
|
||||
|
||||
**Conflict resolution**: opens only conflicted files, resolves the conflict markers, keeps your local customizations intact.
|
||||
|
||||
**Validation**: runs `pnpm run build` and `pnpm test`.
|
||||
**Validation**: runs `pnpm run build` and `pnpm test`. If container files changed, also runs the container typecheck and `./container/build.sh`.
|
||||
|
||||
**Breaking changes check**: after validation, reads CHANGELOG.md for any `[BREAKING]` entries introduced by the update. If found, shows each breaking change and offers to run the recommended skill to migrate.
|
||||
|
||||
@@ -108,9 +109,10 @@ Show file-level impact from upstream:
|
||||
|
||||
Bucket the upstream changed files:
|
||||
- **Skills** (`.claude/skills/`): unlikely to conflict unless the user edited an upstream skill
|
||||
- **Source** (`src/`): may conflict if user modified the same files
|
||||
- **Build/config** (`package.json`, `pnpm-lock.yaml`, `tsconfig*.json`, `container/`, `launchd/`): review needed
|
||||
- **Other**: docs, tests, misc
|
||||
- **Host source** (`src/`): may conflict if user modified the same files
|
||||
- **Container** (`container/`): triggers container rebuild (+ typecheck if `agent-runner/src/` changed)
|
||||
- **Build/config** (`package.json`, `pnpm-lock.yaml`, `tsconfig*.json`): lockfile changes trigger dep install
|
||||
- **Other**: docs, tests, setup scripts, misc
|
||||
|
||||
**Large drift check:** If the upstream commit count and age suggest the user has a lot of catching up to do, mention that `/migrate-nanoclaw` might be a better fit — it extracts customizations and reapplies them on clean upstream instead of merging. Offer it as an option but don't push.
|
||||
|
||||
@@ -173,11 +175,31 @@ If it gets messy (more than 3 rounds of conflicts):
|
||||
- `git rebase --abort`
|
||||
- Recommend merge instead.
|
||||
|
||||
# Step 4.5: Install dependencies (if lockfiles changed)
|
||||
Check if the merge changed any lockfiles or package manifests:
|
||||
- `git diff <backup-tag-from-step-1>..HEAD --name-only | grep -E '^(pnpm-lock\.yaml|package\.json)$'`
|
||||
- If matched: `pnpm install`
|
||||
- `git diff <backup-tag-from-step-1>..HEAD --name-only | grep -E '^container/agent-runner/(bun\.lock|package\.json)$'`
|
||||
- If matched AND `command -v bun` succeeds: `cd container/agent-runner && bun install`
|
||||
- If bun is not installed on the host, skip — container deps will be installed during `./container/build.sh`
|
||||
|
||||
Skip this step if neither lockfile changed.
|
||||
|
||||
# Step 5: Validation
|
||||
Run:
|
||||
Check which areas changed to determine what to validate:
|
||||
- `CHANGED_FILES=$(git diff --name-only <backup-tag-from-step-1>..HEAD)`
|
||||
|
||||
**Host build** (always):
|
||||
- `pnpm run build`
|
||||
- `pnpm test` (do not fail the flow if tests are not configured)
|
||||
|
||||
**Container typecheck** (only if `container/agent-runner/src/` files are in CHANGED_FILES AND bun types are available):
|
||||
- Check: `pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit`
|
||||
- If this fails because bun types are missing (`Cannot find type definition file for 'bun'`), skip with a note — type errors will surface at container runtime instead
|
||||
|
||||
**Container image rebuild** (only if any `container/` files are in CHANGED_FILES):
|
||||
- `./container/build.sh`
|
||||
|
||||
If build fails:
|
||||
- Show the error.
|
||||
- Only fix issues clearly caused by the merge (missing imports, type mismatches from merged code).
|
||||
@@ -209,8 +231,10 @@ If one or more `[BREAKING]` lines are found:
|
||||
- For each skill the user selects, invoke it using the Skill tool.
|
||||
- After all selected skills complete (or if user chose Skip), proceed to Step 7 (skill updates check).
|
||||
|
||||
# Step 7: Check for skill updates
|
||||
After the summary, check if skills are distributed as branches in this repo:
|
||||
# Step 7: Check for skill and channel/provider updates
|
||||
|
||||
## 7a: Skill branches
|
||||
Check if skills are distributed as branches in this repo:
|
||||
- `git branch -r --list 'upstream/skill/*'`
|
||||
|
||||
If any `upstream/skill/*` branches exist:
|
||||
@@ -218,7 +242,21 @@ If any `upstream/skill/*` branches exist:
|
||||
- Option 1: "Yes, check for updates" (description: "Runs /update-skills to check for and apply skill branch updates")
|
||||
- Option 2: "No, skip" (description: "You can run /update-skills later any time")
|
||||
- If user selects yes, invoke `/update-skills` using the Skill tool.
|
||||
- After the skill completes (or if user selected no), proceed to Step 8.
|
||||
|
||||
## 7b: Channel and provider updates
|
||||
Detect installed channels by reading `src/channels/index.ts` and collecting all `import './<name>.js';` lines (excluding `cli`). For providers, check `src/providers/index.ts` the same way.
|
||||
|
||||
If any channels/providers are installed AND `upstream/channels` or `upstream/providers` branches exist:
|
||||
- List the installed channels/providers.
|
||||
- Use AskUserQuestion to ask: "Would you like to update your installed channels/providers? Re-running `/add-<name>` is safe — it only updates code files, credentials and wiring are untouched."
|
||||
- One option per installed channel/provider (e.g., "Update Slack (/add-slack)")
|
||||
- "Skip — I'll update them later"
|
||||
- Set `multiSelect: true`
|
||||
- For each selected option, invoke the corresponding `/add-<channel>` or `/add-<provider>` skill.
|
||||
|
||||
If no channels/providers are installed, skip silently.
|
||||
|
||||
Proceed to Step 8.
|
||||
|
||||
# Step 8: Summary + rollback instructions
|
||||
Show:
|
||||
@@ -232,9 +270,10 @@ Show:
|
||||
Tell the user:
|
||||
- To rollback: `git reset --hard <backup-tag-from-step-1>`
|
||||
- Backup branch also exists: `backup/pre-update-<HASH>-<TIMESTAMP>`
|
||||
- Restart the service to apply changes:
|
||||
- If using launchd: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist && launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist`
|
||||
- If running manually: restart `pnpm run dev`
|
||||
- Restart the service to apply changes. Detect platform with `uname -s`:
|
||||
- **macOS (Darwin)**: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
|
||||
- **Linux**: detect the service name with `systemctl --user list-units --type=service | grep nanoclaw | awk '{print $1}'`, then `systemctl --user restart <detected-name>`
|
||||
- **Manual** (no service found): restart `pnpm run dev`
|
||||
|
||||
|
||||
## Diagnostics
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
staged=$(git diff --cached --name-only --diff-filter=ACM -- 'src/**/*.ts')
|
||||
pnpm run format:fix
|
||||
if [ -n "$staged" ]; then
|
||||
echo "$staged" | xargs git add
|
||||
fi
|
||||
|
||||
@@ -4,6 +4,23 @@ All notable changes to NanoClaw will be documented in this file.
|
||||
|
||||
For detailed release notes, see the [full changelog on the documentation site](https://docs.nanoclaw.dev/changelog).
|
||||
|
||||
## [2.0.54] - 2026-05-10
|
||||
|
||||
- **Per-group model and effort overrides.** Agent groups can now run a specific Claude model and effort level, set via `ncl groups config update --model <model> --effort <level>`. Defaults to the host-configured model when unset.
|
||||
- **Claude Code 2.1.128.** Container claude-code bumped from 2.1.116 to 2.1.128.
|
||||
- CLI help text improvements for `ncl groups config` and `ncl groups restart`.
|
||||
|
||||
## [2.0.48] - 2026-05-09
|
||||
|
||||
- **Container config moved to DB.** Per-agent-group container runtime config (provider, model, packages, MCP servers, mounts, skills) now lives in the `container_configs` table instead of `groups/<folder>/container.json`. Existing filesystem configs are backfilled automatically on startup. Managed via `ncl groups config get/update` and `config add-mcp-server/remove-mcp-server/add-package/remove-package`.
|
||||
- **Explicit restart with on-wake messages.** Config CLI operations no longer auto-kill containers. New `ncl groups restart` command with `--rebuild` and `--message` flags. On-wake messages (`on_wake` column on `messages_in`) are only picked up by a fresh container's first poll, preventing dying containers from stealing them during the SIGTERM grace period. Self-mod approval handlers (`install_packages`, `add_mcp_server`) use the same race-free mechanism.
|
||||
- **Per-group CLI scope.** New `cli_scope` setting on container config (`disabled` / `group` / `global`, default `group`). Controls what the agent can access via `ncl` from inside the container. `disabled` excludes CLI instructions from CLAUDE.md and blocks all requests. `group` (default) restricts to own-group resources with auto-filled args. `global` gives unrestricted access (set automatically for owner agent groups). Includes post-handler result filtering to prevent cross-group data leaks and blocks `cli_scope` escalation from group-scoped agents.
|
||||
|
||||
## [2.0.45] - 2026-05-08
|
||||
|
||||
- **Admin CLI (`ncl`).** New `ncl` command for querying and modifying the central DB — agent groups, messaging groups, wirings, users, roles, members, destinations, sessions, approvals, and dropped messages. Host-side transport via Unix socket; container-side transport via session DB. Write operations from inside containers go through the approval flow. `list` supports column filtering and `--limit`. Run `ncl help` for usage.
|
||||
- **v1 → v2 migration.** Run `bash migrate-v2.sh` from the v2 checkout. Finds your v1 install (sibling directory or `NANOCLAW_V1_PATH`), merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders (`CLAUDE.md` → `CLAUDE.local.md`), copies session data with conversation continuity, ports scheduled tasks, interactively selects and installs channels (clack multiselect), copies container skills, builds the agent container, and offers a service switchover to test. Hands off to Claude (`/migrate-from-v1`) for owner seeding, access policy, CLAUDE.md cleanup, and fork customization porting. See [docs/migration-dev.md](docs/migration-dev.md) and [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md).
|
||||
|
||||
## [2.0.0] - 2026-04-22
|
||||
|
||||
Major version. NanoClaw v2 is a substantial architectural rewrite. Existing forks should run `/migrate-nanoclaw` (clean-base replay of customizations) or `/update-nanoclaw` (selective cherry-pick) before resuming work.
|
||||
|
||||
@@ -6,8 +6,8 @@ This is NanoClaw v2, a ground-up rewrite with breaking changes throughout. It ca
|
||||
|
||||
**Do this instead:**
|
||||
1. Abort the merge: `git merge --abort` (or `git reset --hard ORIG_HEAD` if the merge already completed).
|
||||
2. Tell the user, verbatim: *"This is the v2 rewrite — it can't be merged into your existing install. Run `bash migrate-v2.sh` to migrate instead."*
|
||||
3. Wait for the user to confirm before doing anything else. Do not attempt the migration yourself.
|
||||
2. Tell the user, verbatim: *"This is the v2 rewrite — it can't be merged into your existing install. Exit Claude Code (or open a separate terminal) and run `bash migrate-v2.sh` from the shell."*
|
||||
3. Wait for the user to confirm before doing anything else. Do not run the migration script yourself — it requires an interactive terminal and cannot be run from within Claude Code.
|
||||
|
||||
If you are a fresh install (you ran `git clone`, not `git pull`) and there are no conflicts, ignore this banner and continue below.
|
||||
|
||||
@@ -53,6 +53,8 @@ Exactly one writer per file — no cross-mount lock contention. Heartbeat is a f
|
||||
|
||||
`data/v2.db` holds everything that isn't per-session: users, user_roles, agent_groups, messaging_groups, wiring, pending_approvals, user_dms, chat_sdk_* (for the Chat SDK bridge), schema_version. Migrations live at `src/db/migrations/`.
|
||||
|
||||
For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than the `sqlite3` CLI: `pnpm exec tsx scripts/q.ts <db> "<sql>"`. The host setup intentionally avoids depending on the `sqlite3` binary (`setup/verify.ts:5`); the wrapper goes through the `better-sqlite3` dep that setup already installs and verifies. Default-output format matches `sqlite3 -list` (pipe-separated, no header) so existing skill text reads identically.
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
@@ -70,13 +72,43 @@ Exactly one writer per file — no cross-mount lock contention. Heartbeat is a f
|
||||
| `src/onecli-approvals.ts` | OneCLI credentialed-action approval bridge |
|
||||
| `src/user-dm.ts` | Cold-DM resolution + `user_dms` cache |
|
||||
| `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills, agent-runner-src overlay) |
|
||||
| `src/db/` | DB layer — agent_groups, messaging_groups, sessions, user_roles, user_dms, pending_*, migrations |
|
||||
| `src/db/container-configs.ts` | CRUD for `container_configs` table (per-group container runtime config) |
|
||||
| `src/backfill-container-configs.ts` | Migrates legacy `container.json` files into the DB on startup |
|
||||
| `src/container-restart.ts` | Kill + on-wake respawn for agent group containers |
|
||||
| `src/db/` | DB layer — agent_groups, messaging_groups, sessions, container_configs, user_roles, user_dms, pending_*, migrations |
|
||||
| `src/channels/` | Channel adapter infra (registry, Chat SDK bridge); specific channel adapters are skill-installed from the `channels` branch |
|
||||
| `src/providers/` | Host-side provider container-config (`claude` baked in; `opencode` etc. installed from the `providers` branch) |
|
||||
| `container/agent-runner/src/` | Agent-runner: poll loop, formatter, provider abstraction, MCP tools, destinations |
|
||||
| `container/skills/` | Container skills mounted into every agent session |
|
||||
| `container/skills/` | Container skills mounted into every agent session (`onecli-gateway`, `welcome`, `self-customize`, `agent-browser`, `slack-formatting`) |
|
||||
| `groups/<folder>/` | Per-agent-group filesystem (CLAUDE.md, skills, per-group `agent-runner-src/` overlay) |
|
||||
| `scripts/init-first-agent.ts` | Bootstrap the first DM-wired agent (used by `/init-first-agent` skill) |
|
||||
| `migrate-v2.sh` + `setup/migrate-v2/` | v1→v2 migration. Standalone script: `bash migrate-v2.sh`. Seeds DB, copies groups/sessions, installs channels, builds container, offers service switchover, then hands off to `/migrate-from-v1` skill for owner setup and CLAUDE.md cleanup. See [docs/migration-dev.md](docs/migration-dev.md). |
|
||||
|
||||
## Admin CLI (`ncl`)
|
||||
|
||||
`ncl` queries and modifies the central DB — agent groups, messaging groups, wirings, users, roles, and more. On the host it connects via Unix socket (`src/cli/socket-server.ts`); inside containers it uses the session DB transport (`container/agent-runner/src/cli/ncl.ts`).
|
||||
|
||||
```
|
||||
ncl <resource> <verb> [<id>] [--flags]
|
||||
ncl <resource> help
|
||||
ncl help
|
||||
```
|
||||
|
||||
| Resource | Verbs | What it is |
|
||||
|----------|-------|------------|
|
||||
| groups | list, get, create, update, delete, restart, config get/update, config add-mcp-server/remove-mcp-server, config add-package/remove-package | Agent groups (workspace, personality, container config) |
|
||||
| messaging-groups | list, get, create, update, delete | A single chat/channel on one platform |
|
||||
| wirings | list, get, create, update, delete | Links a messaging group to an agent group (session mode, triggers) |
|
||||
| users | list, get, create, update | Platform identities (`<channel>:<handle>`) |
|
||||
| roles | list, grant, revoke | Owner / admin privileges (global or scoped to an agent group) |
|
||||
| members | list, add, remove | Unprivileged access gate for an agent group |
|
||||
| destinations | list, add, remove | Where an agent group can send messages |
|
||||
| sessions | list, get | Active sessions (read-only) |
|
||||
| user-dms | list | Cold-DM cache (read-only) |
|
||||
| dropped-messages | list | Messages from unregistered senders (read-only) |
|
||||
| approvals | list, get | Pending approval requests (read-only) |
|
||||
|
||||
Key files: `src/cli/dispatch.ts` (dispatcher + approval handler), `src/cli/crud.ts` (generic CRUD registration), `src/cli/resources/` (per-resource definitions).
|
||||
|
||||
## Channels and Providers (skill-installed)
|
||||
|
||||
@@ -91,13 +123,35 @@ Each `/add-<name>` skill is idempotent: `git fetch origin <branch>` → copy mod
|
||||
|
||||
One tier of agent self-modification today:
|
||||
|
||||
1. **`install_packages` / `add_mcp_server`** — changes to the per-agent-group container config only (apt/npm deps, wire an existing MCP server). Single admin approval per request; on approve, the handler in `src/modules/self-mod/apply.ts` rebuilds the image when needed (`install_packages` only) and restarts the container. `container/agent-runner/src/mcp-tools/self-mod.ts`.
|
||||
1. **`install_packages` / `add_mcp_server`** — changes to the per-agent-group container config in the DB (apt/npm deps, wire an existing MCP server). Single admin approval per request; on approve, the handler in `src/modules/self-mod/apply.ts` rebuilds the image when needed (`install_packages` only), writes an `on_wake` message, kills the container, and respawns via `onExit` callback. The on-wake message is only picked up by the fresh container's first poll — dying containers can never steal it. `container/agent-runner/src/mcp-tools/self-mod.ts`.
|
||||
|
||||
A second tier (direct source-level self-edits via a draft/activate flow) is planned but not yet implemented.
|
||||
|
||||
## Container Config
|
||||
|
||||
Per-agent-group container runtime config (provider, model, packages, MCP servers, mounts, etc.) lives in the `container_configs` table in the central DB. Materialized to `groups/<folder>/container.json` at spawn time so the container runner can read it. Managed via `ncl groups config get/update` and the self-mod MCP tools.
|
||||
|
||||
**`cli_scope`** — controls what the agent can do with `ncl` from inside the container:
|
||||
|
||||
| Value | Behavior |
|
||||
|-------|----------|
|
||||
| `disabled` | Agent never learns about ncl (instructions excluded from CLAUDE.md). Host dispatch rejects any `cli_request`. |
|
||||
| `group` (default) | Agent can access `groups`, `sessions`, `destinations`, `members` only, scoped to its own agent group. `--id` and group args are auto-filled. Cross-group access rejected. `cli_scope` changes blocked. |
|
||||
| `global` | Unrestricted. Set automatically for owner agent groups via `init-first-agent`. |
|
||||
|
||||
Key files: `src/db/container-configs.ts`, `src/container-config.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts` (instructions exclusion).
|
||||
|
||||
## Container Restart
|
||||
|
||||
`ncl groups restart --id <group-id> [--rebuild] [--message <text>]`. Kills running containers; if `--message` is provided, writes an `on_wake` message and respawns via `onExit` callback. Without `--message`, containers come back on the next user message. From inside a container, `--id` is auto-filled and only the calling session is restarted.
|
||||
|
||||
The `on_wake` column on `messages_in` ensures wake messages are only picked up by a fresh container's first poll iteration. This prevents the race where a dying container (still in its SIGTERM grace period) could steal the message. `killContainer` accepts an optional `onExit` callback that fires after the process exits, guaranteeing the old container is gone before the new one spawns.
|
||||
|
||||
Key files: `src/container-restart.ts`, `src/container-runner.ts` (`killContainer`), `container/agent-runner/src/db/messages-in.ts` (`getPendingMessages`).
|
||||
|
||||
## Secrets / Credentials / OneCLI
|
||||
|
||||
API keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway. Secrets are injected into per-agent containers at request time — none are passed in env vars or through chat context. `src/onecli-approvals.ts`, `ensureAgent()` in `container-runner.ts`. Run `onecli --help`.
|
||||
API keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway. Secrets are injected into per-agent containers at request time — none are passed in env vars or through chat context. The container agent sees this via the `onecli-gateway` container skill (`container/skills/onecli-gateway/SKILL.md`), which teaches it how the proxy works, how to handle auth errors, and to never ask for raw credentials. Host-side wiring: `src/onecli-approvals.ts`, `ensureAgent()` in `container-runner.ts`. Run `onecli --help`.
|
||||
|
||||
### Gotcha: auto-created agents start in `selective` secret mode
|
||||
|
||||
@@ -141,7 +195,7 @@ Four types of skills. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxono
|
||||
- **Channel/provider install skills** — copy the relevant module(s) in from the `channels` or `providers` branch, wire imports, install pinned deps (e.g. `/add-discord`, `/add-slack`, `/add-whatsapp`, `/add-opencode`).
|
||||
- **Utility skills** — ship code files alongside `SKILL.md` (e.g. `/claw`).
|
||||
- **Operational skills** — instruction-only workflows (`/setup`, `/debug`, `/customize`, `/init-first-agent`, `/manage-channels`, `/init-onecli`, `/update-nanoclaw`).
|
||||
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `welcome`, `self-customize`, `agent-browser`, `slack-formatting`).
|
||||
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `onecli-gateway`, `welcome`, `self-customize`, `agent-browser`, `slack-formatting`).
|
||||
|
||||
| Skill | When to Use |
|
||||
|-------|-------------|
|
||||
@@ -157,6 +211,17 @@ Four types of skills. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxono
|
||||
|
||||
Before creating a PR, adding a skill, or preparing any contribution, you MUST read [CONTRIBUTING.md](CONTRIBUTING.md). It covers accepted change types, the four skill types and their guidelines, `SKILL.md` format rules, and the pre-submission checklist.
|
||||
|
||||
## PR Hygiene
|
||||
|
||||
Before creating a PR, run these checks:
|
||||
|
||||
```bash
|
||||
git diff upstream/main --stat HEAD
|
||||
git log upstream/main..HEAD --oneline
|
||||
```
|
||||
|
||||
Show the output and wait for approval. Installation-specific files (group files, .claude/settings.json, local configs) should not be included.
|
||||
|
||||
## Development
|
||||
|
||||
Run commands directly — don't tell the user to run them.
|
||||
@@ -186,7 +251,17 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # restart
|
||||
systemctl --user start|stop|restart nanoclaw
|
||||
```
|
||||
|
||||
Host logs: `logs/nanoclaw.log` (normal) and `logs/nanoclaw.error.log` (errors only — some delivery/approval failures only show up here).
|
||||
## Troubleshooting
|
||||
|
||||
Check these first when something goes wrong:
|
||||
|
||||
| What | Where |
|
||||
|------|-------|
|
||||
| Host logs | `logs/nanoclaw.error.log` first (delivery failures, crash-loop backoff, warnings), then `logs/nanoclaw.log` for the full routing chain |
|
||||
| Setup logs | `logs/setup.log` (overall), `logs/setup-steps/*.log` (per-step: bootstrap, environment, container, onecli, mounts, service, etc.) |
|
||||
| Session DBs | `data/v2-sessions/<agent-group>/<session>/` — `inbound.db` (`messages_in`: did the message reach the container?), `outbound.db` (`messages_out`: did the agent produce a response?) |
|
||||
|
||||
Note: container logs are lost after the container exits (`--rm` flag). If the agent silently failed inside the container, there's no persistent log to inspect.
|
||||
|
||||
## Supply Chain Security (pnpm)
|
||||
|
||||
@@ -211,6 +286,8 @@ This project uses pnpm with `minimumReleaseAge: 4320` (3 days) in `pnpm-workspac
|
||||
| [docs/setup-wiring.md](docs/setup-wiring.md) | What's wired, what's open in the setup flow |
|
||||
| [docs/architecture-diagram.md](docs/architecture-diagram.md) | Diagram version of the architecture |
|
||||
| [docs/build-and-runtime.md](docs/build-and-runtime.md) | Runtime split (Node host + Bun container), lockfiles, image build surface, CI, key invariants |
|
||||
| [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) | v1→v2 architecture diff — vocabulary for where v1 things moved |
|
||||
| [docs/migration-dev.md](docs/migration-dev.md) | Migration development guide — testing, debugging, dev loop |
|
||||
|
||||
## Container Build Cache
|
||||
|
||||
|
||||
+2
-1
@@ -123,7 +123,8 @@ Test your contribution on a fresh clone before submitting. For skills, run the s
|
||||
|
||||
1. **Link related issues.** If your PR resolves an open issue, include `Closes #123` in the description so it's auto-closed on merge.
|
||||
2. **Test thoroughly.** Run the feature yourself. For skills, test on a fresh clone.
|
||||
3. **Check the right box** in the PR template. Labels are auto-applied based on your selection:
|
||||
3. **Check for installation-specific files.** Before creating a PR, verify no installation-specific files are in your diff (see PR Hygiene in CLAUDE.md).
|
||||
4. **Check the right box** in the PR template. Labels are auto-applied based on your selection:
|
||||
|
||||
| Checkbox | Label |
|
||||
|----------|-------|
|
||||
|
||||
@@ -16,6 +16,7 @@ Thanks to everyone who has contributed to NanoClaw!
|
||||
- [flobo3](https://github.com/flobo3) — Flo
|
||||
- [edwinwzhe](https://github.com/edwinwzhe) — Edwin He
|
||||
- [scottgl9](https://github.com/scottgl9) — Scott Glover
|
||||
- [ingyukoh](https://github.com/ingyukoh) — Ingyu Koh
|
||||
- [cschmidt](https://github.com/cschmidt) — Carl Schmidt
|
||||
- [leonalfredbot-ship-it](https://github.com/leonalfredbot-ship-it) — Alfred-the-buttler
|
||||
- [moktamd](https://github.com/moktamd)
|
||||
|
||||
@@ -33,6 +33,29 @@ bash nanoclaw.sh
|
||||
|
||||
`nanoclaw.sh` walks you from a fresh machine to a named agent you can message. It installs Node, pnpm, and Docker if missing, registers your Anthropic credential with OneCLI, builds the agent container, and pairs your first channel (Telegram, Discord, WhatsApp, or a local CLI). If a step fails, Claude Code is invoked automatically to diagnose and resume from where it broke.
|
||||
|
||||
<details>
|
||||
<summary><strong>Migrating from NanoClaw v1?</strong></summary>
|
||||
|
||||
Run from a fresh v2 checkout next to your v1 install:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2
|
||||
cd nanoclaw-v2
|
||||
bash migrate-v2.sh
|
||||
```
|
||||
|
||||
`migrate-v2.sh` finds your v1 install (sibling directory, or `NANOCLAW_V1_PATH=/path/to/nanoclaw`), migrates state into the v2 checkout, then `exec`s into Claude Code to finish the parts that need judgment (owner seeding, CLAUDE.local.md cleanup, fork-customisation replay).
|
||||
|
||||
Run the script directly, not from inside a Claude session — the deterministic side needs interactive prompts and real shell I/O for Node/pnpm bootstrap, Docker, OneCLI, and the container build.
|
||||
|
||||
**What it does:** merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders + session data + scheduled tasks, installs the channel adapters you select, copies channel auth state (including Baileys keystore + LID mappings for WhatsApp), builds the agent container.
|
||||
|
||||
**What it doesn't:** flip the system service. Pick *"switch to v2"* at the prompt, or do it manually after testing — your v1 install is left untouched.
|
||||
|
||||
See [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) for what's different and [docs/migration-dev.md](docs/migration-dev.md) for development notes.
|
||||
|
||||
</details>
|
||||
|
||||
## Philosophy
|
||||
|
||||
**Small enough to understand.** One process, a few source files and no microservices. If you want to understand the full NanoClaw codebase, just ask Claude Code to walk you through it.
|
||||
@@ -192,3 +215,5 @@ See [CHANGELOG.md](CHANGELOG.md) for breaking changes, or the [full release hist
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=47894bd5-353b-42fe-bb97-74144e6df0bf" />
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
|
||||
[38;2;43;183;206m⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠰⣄⠘⣦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206m⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢹⡆⢸⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[39m [2m[38;2;43;183;206m°[39m[22m
|
||||
[38;2;43;183;206m⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206m⠀⠀⠀⠀⠀⢀⣠⣴⠾⠟⠛⠛⠿⢶⣦⣾⠇⣾⠁⠀⠀⠀⢀⣤⣤⠀⢀⣄⠀[39m
|
||||
[38;2;43;183;206m⠀⠀⠀⠀⣴⡿⡋⠀⠀⠀⠀⠀⢤⣾⣿⢛⢿⣏⠀⠀⠀⢰⣟⣽⡏⠀⣸⡿⣧[39m
|
||||
[2m[38;2;43;183;206mo[39m[22m [38;2;43;183;206m⠀⠀⢀⣾⠋⠀⠀⠀⠀⠀⠀⠀⠀⠘⠈⣧⣀⣿⣧⠀⠀⣿⣼⣿⣇⣾⠋⢠⣿[39m
|
||||
[38;2;43;183;206m⠀⠀⣾⢃⠀⢲⣷⡋⣰⡀⢀⣀⣀⡀⠠⣿⣿⣠⣿⣇⠀⣿⢻⣉⠉⠙⠠⣼⠇[39m
|
||||
[38;2;43;183;206m⠀⣼⡏⠃⠀⢸⣿⣿⡿⠃⣾⣷⣻⣿⡏⢹⠿⠿⣿⣿⢀⣿⣐⠙⣷⣦⡾⠋⠀[39m [2m[38;2;43;183;206mo[39m[22m
|
||||
[38;2;43;183;206m⢠⣿⡃⠀⠀⠀⠀⠀⠈⠀⠀⠉⠙⠁⠀⠀⠀⠐⣿⣿⣟⠁⣿⣿⠟⠋⠀⠀⠀[39m
|
||||
[2m[38;2;43;183;206m°[39m[22m [38;2;43;183;206m⢸⣿⣧⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣨⣿⣿⣿⣿⣿⠟⠁⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206m⢸⣿⣿⣷⣤⣤⠀⣀⢀⠀⢀⣀⣠⣴⣶⣿⣿⣿⣿⡿⠛⠁⠀⠀⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206m⣿⢋⠿⣿⣿⣿⣿⡿⣿⣿⣿⣿⣿⣿⠿⠿⠿⣿⣅⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀[39m [38;2;43;183;206mO[39m
|
||||
[38;2;43;183;206m⣿⣿⠙⢾⣽⣟⣿⣿⣼⣿⣿⣿⣩⣶⣶⣦⠀⠀⠩⢻⣆⠀⠀⠀⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206m⠘⣿⣶⣤⣿⣿⣿⣿⣵⢖⡀⠉⠹⡛⢷⣝⡿⠁⠀⠀⣿⡆⠀⠀⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206m⠀⢹⣯⣽⣟⣛⣻⣿⣿⣾⣽⢶⣽⣿⣿⣿⣏⠀⠠⣤⣿⡇⠀⠀⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206m⠀⠀⠻⣿⣶⣾⣿⢿⣻⣿⣿⣿⣿⣿⣿⣏⣛⣧⣦⣿⣿⣧⣄⠀⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206mo[39m [38;2;43;183;206m⠀⠀⠀⠈⠻⣿⣶⣥⣼⣿⣿⣽⣿⣿⣿⣷⣶⣾⣿⣿⣯⣘⣿⣧⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206m⠀⠀⠀⠀⠤⣤⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠿⠿⠋⠀⠀⠀⠀⠀[39m
|
||||
|
||||
[1m _ _ [22m[38;2;43;183;206m[1m ___ _ [22m[39m
|
||||
[1m| \| |__ _ _ _ ___ [22m[38;2;43;183;206m[1m / __| |__ ___ __ __[22m[39m
|
||||
[1m| .` / _` | ' \/ _ \[22m[38;2;43;183;206m[1m| (__| / _` \ V V /[22m[39m
|
||||
[1m|_|\_\__,_|_||_\___/[22m[38;2;43;183;206m[1m \___|_\__,_|\_/\_/ [22m[39m
|
||||
|
||||
[2mSmall.[22m
|
||||
[2mRuns on your machine.[22m
|
||||
[2mYours to modify.[22m
|
||||
|
||||
[38;2;5;62;165m════════════════════════════════════════[39m
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# ncl — NanoClaw CLI launcher.
|
||||
#
|
||||
# Resolves the project root from this script's location, cd's there so the
|
||||
# host-resolved DATA_DIR matches the running host, and execs the TS entry
|
||||
# via tsx. Symlink this file into a directory on your PATH (or alias `ncl`
|
||||
# to its full path) to invoke from anywhere:
|
||||
#
|
||||
# ln -s "$(pwd)/bin/ncl" /usr/local/bin/ncl
|
||||
# # or
|
||||
# alias ncl="$(pwd)/bin/ncl"
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT="${BASH_SOURCE[0]}"
|
||||
# Resolve symlinks so PROJECT_ROOT points at the real checkout.
|
||||
while [ -h "$SCRIPT" ]; do
|
||||
DIR="$(cd -P "$(dirname "$SCRIPT")" && pwd)"
|
||||
SCRIPT="$(readlink "$SCRIPT")"
|
||||
[[ "$SCRIPT" != /* ]] && SCRIPT="$DIR/$SCRIPT"
|
||||
done
|
||||
SCRIPT_DIR="$(cd -P "$(dirname "$SCRIPT")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
exec pnpm exec tsx src/cli/client.ts "$@"
|
||||
+14
-3
@@ -19,9 +19,9 @@ ARG INSTALL_CJK_FONTS=false
|
||||
# Pin CLI versions for reproducibility. Bump deliberately — unpinned installs
|
||||
# mean every rebuild silently picks up the latest and can break in lockstep
|
||||
# across all users.
|
||||
ARG CLAUDE_CODE_VERSION=2.1.116
|
||||
ARG CLAUDE_CODE_VERSION=2.1.128
|
||||
ARG AGENT_BROWSER_VERSION=latest
|
||||
ARG VERCEL_VERSION=latest
|
||||
ARG VERCEL_VERSION=52.2.1
|
||||
ARG BUN_VERSION=1.3.12
|
||||
|
||||
# ---- System dependencies -----------------------------------------------------
|
||||
@@ -91,7 +91,13 @@ RUN --mount=type=cache,target=/root/.bun/install/cache \
|
||||
# the SDK fails at spawn time with "native binary not found".
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
# Pin pnpm to match the host (package.json packageManager). pnpm 11 stopped
|
||||
# honoring `only-built-dependencies[]=` in .npmrc for global installs, which
|
||||
# silently skips claude-code's native-binary postinstall and agent-browser's
|
||||
# bin chmod — the agent then crashes at runtime with "native binary not
|
||||
# installed". Keep this in lockstep with package.json's `packageManager`.
|
||||
ARG PNPM_VERSION=10.33.0
|
||||
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
echo "only-built-dependencies[]=agent-browser" > /root/.npmrc && \
|
||||
@@ -104,6 +110,11 @@ RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"
|
||||
|
||||
# ---- ncl CLI wrapper ----------------------------------------------------------
|
||||
# Actual script lives in the mounted source at /app/src/cli/ncl.ts.
|
||||
RUN printf '#!/bin/sh\nexec bun /app/src/cli/ncl.ts "$@"\n' > /usr/local/bin/ncl && \
|
||||
chmod +x /usr/local/bin/ncl
|
||||
|
||||
# ---- Entrypoint --------------------------------------------------------------
|
||||
COPY entrypoint.sh /app/entrypoint.sh
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"": {
|
||||
"name": "nanoclaw-agent-runner",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.128",
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"cron-parser": "^5.0.0",
|
||||
"zod": "^4.0.0",
|
||||
@@ -18,23 +18,23 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.116", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.116", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.116" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-5NKpgaOZkzNCGCvLxJZUVGimf5IcYmpQ2x2XrR9ilK+2UkWrnnwcUfIWo8bBz9e7lSYcUf9XleGigq2eOOF7aw=="],
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.138", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.138", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.138", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.138", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.138", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.138", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.138", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.138", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.138" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-rH6dFI3DBBsPBPcHTBdTZCHA14OCt2t4+6XYi2MJB/GlFrnZvlWmMIk2z9uxAiZ05Txg8YbftgSuE5A1qpAXwg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.116", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mG19ovtXCpETmd5KmTU1JO2iIHZBG09IP8DmgZjLA3wLmTzpgn9Au9veRaeJeXb1EqiHiFZU+z+mNB79+w5v9g=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.138", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aObxJ/GeJ5UxT9N8XypUHPYQKpwYsRT5THiJl5E2pKEUk/Xt42gT55N5GV0TOjtgxVAnDMWjxTAgGCGoDzjgpg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.116", "", { "os": "darwin", "cpu": "x64" }, "sha512-qC25N0HRM8IXbM4Qi4svH9f51Y6DciDvjLV+oNYnxkdPgDG8p/+b7vQirN7qPxytIQb2TPdoFgUeCsSe7lrQyw=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.138", "", { "os": "darwin", "cpu": "x64" }, "sha512-ou3i1/gAf2PEgVl2WYJb7ZdE+KGwoB1I46JRhWHSC3uD6lb9HMZam233T/rlKCVX9e5dzfkujUOnmCkmXjgVGQ=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.116", "", { "os": "linux", "cpu": "arm64" }, "sha512-MQIcJhhPM+RPJ7kMQdOQarkJ2FlJqOiu953c08YyJOoWdHykd3DIiHws3mf1Mwl/dfFeIyshOVpNND3hyIy5Dg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.138", "", { "os": "linux", "cpu": "arm64" }, "sha512-jp8lmAVe9uI9X5o+IYWFajLbN+Z80XogVX7NeyaenLHdpHkxg29Yf8pb6Os4OvHMjJOAdwDhPpXajf6RtBeEDA=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.116", "", { "os": "linux", "cpu": "arm64" }, "sha512-Dg/T3NkSp35ODiwdhj0KquvC6Xu+DMbyWFNkfepA3bz4oF2SVSgyOPYwVmfoJerzEUnYDldP4YhOxRrhbt0vXA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.138", "", { "os": "linux", "cpu": "arm64" }, "sha512-uZaEFND1pl7KD9tdYqj2hd6ktjlYizVmkHRgU2Aj/P1CC6WMDsKG+rqPP7dsVXO77gMXhL4xjjwwqMjxx83HkA=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.116", "", { "os": "linux", "cpu": "x64" }, "sha512-Bww1fzQB+vcF0tRhmCAlwSsN4wR2HgX7pBT9AWuwzJj6DKsVC23N54Ea80lsnM7dTUtUTrGYMTwVUHTWqfYnfQ=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.138", "", { "os": "linux", "cpu": "x64" }, "sha512-SLuUmu/nH1Wh0wnoXj/Bwh0nbDfEn9PgXqMsZHEUk3x1zxeR+6aRqFLjKZ8TawBey7xod7nfYUIjPnQx6IWDzg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.116", "", { "os": "linux", "cpu": "x64" }, "sha512-LMYxUMa1nK4N9BPRJdcGBAvl9rjTI4ZHo+kfAKrJ3MlfB6VFF1tRIubwsWOaOtkuNazMdAYovsZJg4bdzOBBTQ=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.138", "", { "os": "linux", "cpu": "x64" }, "sha512-T16F8Vkikb98E781ZM6Cx84yEBk+loSCqAObjaZ1hzQ1eKcpnxzSTF4rH2bz6N91dhFuCfIjFaBfNYg+oQA+yQ=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.116", "", { "os": "win32", "cpu": "arm64" }, "sha512-h0YO1vkTIeUtffQhONrYbNC1pXmk1yjb1xxMEw7bAwucqtFoFpLDWe+q4+RhxaQr8ZOj6LtRE/U3dzPWHOlshA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.138", "", { "os": "win32", "cpu": "arm64" }, "sha512-H/sD25fmMyEeJWamYmBKRS3E7jaIrg2S8KWxyR37P+xTZgkLe19sDTp7gYYywMXf1X9CJZJ8jJZ93qxINZoCeA=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.116", "", { "os": "win32", "cpu": "x64" }, "sha512-3lllmtDFHgpW0ZM3iNvxsEjblrgRzF9Qm1lxTOtunP3hIn+pA/IkWMtKlN1ixxWiaBguLVQkJ90V6JHsvJJIvw=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.138", "", { "os": "win32", "cpu": "x64" }, "sha512-cSOdTH1OfIamVdJit9laWZiXne81ewgdP8MGh5HzLLLci0NGHkME7YxCWd0lYkCNkfiOEcToKU9axaZ+84jGiw=="],
|
||||
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="],
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.128",
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"cron-parser": "^5.0.0",
|
||||
"zod": "^4.0.0"
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* ncl — NanoClaw CLI client (container edition).
|
||||
*
|
||||
* Same interface as the host-side `bin/ncl`. Detects that it's inside a
|
||||
* container (the session DBs exist at /workspace/) and uses a DB transport
|
||||
* instead of the Unix socket transport.
|
||||
*
|
||||
* Writes a cli_request system message to outbound.db, polls inbound.db
|
||||
* for the response. Self-contained — no imports from agent-runner.
|
||||
*/
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frame types (mirrors src/cli/frame.ts on the host)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type RequestFrame = {
|
||||
id: string;
|
||||
command: string;
|
||||
args: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type ResponseFrame =
|
||||
| { id: string; ok: true; data: unknown }
|
||||
| { id: string; ok: false; error: { code: string; message: string } };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const INBOUND_DB = '/workspace/inbound.db';
|
||||
const OUTBOUND_DB = '/workspace/outbound.db';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB transport
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function generateId(): string {
|
||||
return `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a cli_request to outbound.db.
|
||||
*
|
||||
* Uses BEGIN IMMEDIATE to acquire a write lock before reading max(seq),
|
||||
* preventing seq collisions with concurrent agent-runner writes.
|
||||
*/
|
||||
function writeRequest(req: RequestFrame): void {
|
||||
const db = new Database(OUTBOUND_DB);
|
||||
db.exec('PRAGMA journal_mode = DELETE');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
|
||||
const inDb = new Database(INBOUND_DB, { readonly: true });
|
||||
inDb.exec('PRAGMA busy_timeout = 5000');
|
||||
|
||||
try {
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
const maxOut = (db.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_out').get() as { m: number }).m;
|
||||
const maxIn = (inDb.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number }).m;
|
||||
const max = Math.max(maxOut, maxIn);
|
||||
const nextSeq = max % 2 === 0 ? max + 1 : max + 2;
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO messages_out (id, seq, timestamp, kind, content)
|
||||
VALUES ($id, $seq, datetime('now'), 'system', $content)`,
|
||||
).run({
|
||||
$id: req.id,
|
||||
$seq: nextSeq,
|
||||
$content: JSON.stringify({
|
||||
action: 'cli_request',
|
||||
requestId: req.id,
|
||||
command: req.command,
|
||||
args: req.args,
|
||||
}),
|
||||
});
|
||||
db.exec('COMMIT');
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
throw e;
|
||||
} finally {
|
||||
inDb.close();
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll inbound.db for a cli_response matching our requestId.
|
||||
* Opens a fresh connection each poll (mmap_size=0) for cross-mount visibility.
|
||||
*/
|
||||
function pollResponse(requestId: string, timeoutMs: number): ResponseFrame | null {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const inDb = new Database(INBOUND_DB, { readonly: true });
|
||||
inDb.exec('PRAGMA busy_timeout = 5000');
|
||||
inDb.exec('PRAGMA mmap_size = 0');
|
||||
|
||||
try {
|
||||
const row = inDb
|
||||
.prepare("SELECT id, content FROM messages_in WHERE status = 'pending' AND content LIKE ?")
|
||||
.get(`%"requestId":"${requestId}"%`) as { id: string; content: string } | null;
|
||||
|
||||
if (row) {
|
||||
// Mark as completed via processing_ack so agent-runner skips it
|
||||
const outDb = new Database(OUTBOUND_DB);
|
||||
outDb.exec('PRAGMA journal_mode = DELETE');
|
||||
outDb.exec('PRAGMA busy_timeout = 5000');
|
||||
outDb
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'completed', datetime('now'))",
|
||||
)
|
||||
.run(row.id);
|
||||
outDb.close();
|
||||
|
||||
const parsed = JSON.parse(row.content);
|
||||
return parsed.frame as ResponseFrame;
|
||||
}
|
||||
} finally {
|
||||
inDb.close();
|
||||
}
|
||||
|
||||
Bun.sleepSync(500);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Arg parsing (mirrors host-side client.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseArgv(argv: string[]): {
|
||||
command: string;
|
||||
args: Record<string, unknown>;
|
||||
json: boolean;
|
||||
} {
|
||||
const positional: string[] = [];
|
||||
const args: Record<string, unknown> = {};
|
||||
let json = false;
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--json') {
|
||||
json = true;
|
||||
continue;
|
||||
}
|
||||
if (a.startsWith('--')) {
|
||||
const key = a.slice(2);
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined || next.startsWith('--')) {
|
||||
args[key] = true;
|
||||
} else {
|
||||
args[key] = next;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
positional.push(a);
|
||||
}
|
||||
|
||||
if (positional.length === 0) {
|
||||
process.stderr.write('ncl: missing command\n');
|
||||
printUsage();
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Join all positionals with dashes. The dispatcher trims the last
|
||||
// segment as a target ID if the full name isn't a registered command.
|
||||
const command = positional.join('-');
|
||||
|
||||
return { command, args, json };
|
||||
}
|
||||
|
||||
function printUsage(): void {
|
||||
process.stdout.write(
|
||||
['Usage: ncl <command> [--key value ...] [--json]', '', 'Run `ncl help` to list available commands.', ''].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatting (mirrors src/cli/format.ts on the host)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatHuman(resp: ResponseFrame): string {
|
||||
if (!resp.ok) {
|
||||
return `error (${resp.error.code}): ${resp.error.message}\n`;
|
||||
}
|
||||
|
||||
const data = resp.data;
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
return JSON.stringify(data, null, 2) + '\n';
|
||||
}
|
||||
|
||||
const isFlat = data.every(
|
||||
(r) =>
|
||||
typeof r === 'object' &&
|
||||
r !== null &&
|
||||
!Array.isArray(r) &&
|
||||
Object.values(r as Record<string, unknown>).every((v) => typeof v !== 'object' || v === null),
|
||||
);
|
||||
|
||||
if (!isFlat) return JSON.stringify(data, null, 2) + '\n';
|
||||
|
||||
const keys = Object.keys(data[0] as Record<string, unknown>);
|
||||
const widths = keys.map((k) =>
|
||||
Math.max(k.length, ...data.map((r) => String((r as Record<string, unknown>)[k] ?? '').length)),
|
||||
);
|
||||
|
||||
const header = keys.map((k, i) => k.padEnd(widths[i])).join(' ');
|
||||
const sep = widths.map((w) => '-'.repeat(w)).join(' ');
|
||||
const rows = data.map((r) =>
|
||||
keys
|
||||
.map((k, i) => String((r as Record<string, unknown>)[k] ?? '').padEnd(widths[i]))
|
||||
.join(' '),
|
||||
);
|
||||
|
||||
return [header, sep, ...rows, ''].join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
|
||||
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const { command, args, json } = parseArgv(argv);
|
||||
const requestId = generateId();
|
||||
const req: RequestFrame = { id: requestId, command, args };
|
||||
|
||||
writeRequest(req);
|
||||
|
||||
const resp = pollResponse(requestId, 30_000);
|
||||
|
||||
if (!resp) {
|
||||
process.stderr.write('ncl: command timed out after 30s\n');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (json) {
|
||||
process.stdout.write(JSON.stringify(resp, null, 2) + '\n');
|
||||
} else {
|
||||
const output = formatHuman(resp);
|
||||
if (!resp.ok) {
|
||||
process.stderr.write(output);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(output);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* PreCompact hook script — outputs custom compaction instructions to stdout.
|
||||
*
|
||||
* Claude Code captures the stdout of PreCompact shell hooks and passes it
|
||||
* as `customInstructions` to the compaction prompt. This ensures the
|
||||
* compaction summary preserves message routing context that the agent needs
|
||||
* to correctly address responses.
|
||||
*
|
||||
* Invoked by the PreCompact hook in .claude-shared/settings.json:
|
||||
* "command": "bun /app/src/compact-instructions.ts"
|
||||
*/
|
||||
import { getAllDestinations } from './destinations.js';
|
||||
|
||||
const destinations = getAllDestinations();
|
||||
const names = destinations.map((d) => d.name);
|
||||
|
||||
const instructions = [
|
||||
'Preserve the following in the compaction summary:',
|
||||
'',
|
||||
'1. For recent messages, keep the full XML structure including all attributes:',
|
||||
' - <message from="..." sender="..." time="..."> for chat messages',
|
||||
' - <task from="..." time="..."> for scheduled tasks',
|
||||
' - <webhook from="..." source="..." event="..."> for webhooks',
|
||||
' The message content can be summarized if long, but the XML tags and attributes must remain.',
|
||||
'',
|
||||
'2. Preserve the chronological message/reply sequence of recent exchanges.',
|
||||
' The agent needs to see: who said what, in what order, and from which destination.',
|
||||
'',
|
||||
'3. The `from` attribute identifies which destination sent the message.',
|
||||
' The agent MUST wrap all responses in <message to="name">...</message> blocks.',
|
||||
` Available destinations: ${names.length > 0 ? names.map((n) => `\`${n}\``).join(', ') : '(none)'}`,
|
||||
];
|
||||
|
||||
console.log(instructions.join('\n'));
|
||||
@@ -16,6 +16,8 @@ export interface RunnerConfig {
|
||||
agentGroupId: string;
|
||||
maxMessagesPerPrompt: number;
|
||||
mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }>;
|
||||
model?: string;
|
||||
effort?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_MESSAGES = 10;
|
||||
@@ -43,6 +45,8 @@ export function loadConfig(): RunnerConfig {
|
||||
agentGroupId: (raw.agentGroupId as string) || '',
|
||||
maxMessagesPerPrompt: (raw.maxMessagesPerPrompt as number) || DEFAULT_MAX_MESSAGES,
|
||||
mcpServers: (raw.mcpServers as RunnerConfig['mcpServers']) || {},
|
||||
model: (raw.model as string) || undefined,
|
||||
effort: (raw.effort as string) || undefined,
|
||||
};
|
||||
|
||||
return _config;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Per-batch context the poll loop publishes for downstream consumers
|
||||
* (MCP tools, etc.) that don't sit on the poll-loop's call stack.
|
||||
*
|
||||
* Today the only field is `inReplyTo` — the id of the first inbound
|
||||
* message in the batch the agent is currently processing. MCP tools like
|
||||
* `send_message` and `send_file` read this and stamp it onto the outbound
|
||||
* row so the host's a2a return-path routing can correlate replies back to
|
||||
* the originating session.
|
||||
*
|
||||
* This is module-level state on purpose: the agent-runner is single-process
|
||||
* and processes one batch at a time. Poll-loop calls `setCurrentInReplyTo`
|
||||
* before invoking the provider and `clearCurrentInReplyTo` after the batch
|
||||
* completes (or errors out).
|
||||
*/
|
||||
let currentInReplyTo: string | null = null;
|
||||
|
||||
export function setCurrentInReplyTo(id: string | null): void {
|
||||
currentInReplyTo = id;
|
||||
}
|
||||
|
||||
export function clearCurrentInReplyTo(): void {
|
||||
currentInReplyTo = null;
|
||||
}
|
||||
|
||||
export function getCurrentInReplyTo(): string | null {
|
||||
return currentInReplyTo;
|
||||
}
|
||||
|
||||
@@ -27,12 +27,46 @@ const DEFAULT_HEARTBEAT_PATH = '/workspace/.heartbeat';
|
||||
let _inbound: Database | null = null;
|
||||
let _outbound: Database | null = null;
|
||||
let _heartbeatPath: string = DEFAULT_HEARTBEAT_PATH;
|
||||
let _testMode = false;
|
||||
|
||||
/** Inbound DB — container opens read-only (host is the sole writer). */
|
||||
/**
|
||||
* Avoid all cached db reads; open inbound.db read-only with mmap and page cache disabled.
|
||||
*
|
||||
* Use this (not getInboundDb) for readers that need to see host-written rows
|
||||
* promptly — e.g. messages_in polling. Caller must .close() the returned
|
||||
* connection (try/finally).
|
||||
*
|
||||
* Needed for mounts where host writes don't reliably invalidate
|
||||
* SQLite's caches: virtiofs (Colima, Lima, Podman Machine, Apple
|
||||
* Container), NFS.
|
||||
*
|
||||
* Cost is microseconds per query, so safe for universal use.
|
||||
*/
|
||||
export function openInboundDb(): Database {
|
||||
// In test mode return a thin wrapper over the in-memory singleton.
|
||||
// Callers do try/finally { db.close() } — the wrapper no-ops close()
|
||||
// so the singleton survives for the rest of the test.
|
||||
if (_testMode && _inbound) {
|
||||
const db = _inbound;
|
||||
return { prepare: (sql: string) => db.prepare(sql), exec: (sql: string) => db.exec(sql), close: () => {} } as unknown as Database;
|
||||
}
|
||||
const db = new Database(DEFAULT_INBOUND_PATH, { readonly: true });
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
db.exec('PRAGMA mmap_size = 0');
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inbound DB — long-lived singleton, OK for tables the host writes once
|
||||
* at spawn and never again (destinations, session_routing). For
|
||||
* messages_in polling — where the host writes continuously and a stale
|
||||
* view causes the pollHandle hang — use `openInboundDb()` instead.
|
||||
*/
|
||||
export function getInboundDb(): Database {
|
||||
if (!_inbound) {
|
||||
_inbound = new Database(DEFAULT_INBOUND_PATH, { readonly: true });
|
||||
_inbound.exec('PRAGMA busy_timeout = 5000');
|
||||
_inbound.exec('PRAGMA mmap_size = 0');
|
||||
}
|
||||
return _inbound;
|
||||
}
|
||||
@@ -144,6 +178,7 @@ export function clearStaleProcessingAcks(): void {
|
||||
|
||||
/** For tests — creates in-memory DBs with the session schemas. */
|
||||
export function initTestSessionDb(): { inbound: Database; outbound: Database } {
|
||||
_testMode = true;
|
||||
_inbound = new Database(':memory:');
|
||||
_inbound.exec('PRAGMA foreign_keys = ON');
|
||||
_inbound.exec(`
|
||||
@@ -161,7 +196,8 @@ export function initTestSessionDb(): { inbound: Database; outbound: Database } {
|
||||
platform_id TEXT,
|
||||
channel_type TEXT,
|
||||
thread_id TEXT,
|
||||
content TEXT NOT NULL
|
||||
content TEXT NOT NULL,
|
||||
on_wake INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE delivered (
|
||||
message_out_id TEXT PRIMARY KEY,
|
||||
@@ -220,6 +256,7 @@ export function initTestSessionDb(): { inbound: Database; outbound: Database } {
|
||||
export function closeSessionDb(): void {
|
||||
_inbound?.close();
|
||||
_inbound = null;
|
||||
_testMode = false;
|
||||
_outbound?.close();
|
||||
_outbound = null;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* processing_ack. The host reads processing_ack to sync message lifecycle.
|
||||
*/
|
||||
import { getConfig } from '../config.js';
|
||||
import { getInboundDb, getOutboundDb } from './connection.js';
|
||||
import { openInboundDb, getOutboundDb } from './connection.js';
|
||||
|
||||
export interface MessageInRow {
|
||||
id: string;
|
||||
@@ -49,32 +49,37 @@ function getMaxMessagesPerPrompt(): number {
|
||||
* sees the prior context it missed. Host's countDueMessages gates waking on
|
||||
* trigger=1 separately (see src/db/session-db.ts).
|
||||
*/
|
||||
export function getPendingMessages(): MessageInRow[] {
|
||||
const inbound = getInboundDb();
|
||||
export function getPendingMessages(isFirstPoll = false): MessageInRow[] {
|
||||
const inbound = openInboundDb();
|
||||
const outbound = getOutboundDb();
|
||||
|
||||
const pending = inbound
|
||||
.prepare(
|
||||
`SELECT * FROM messages_in
|
||||
WHERE status = 'pending'
|
||||
AND (process_after IS NULL OR datetime(process_after) <= datetime('now'))
|
||||
ORDER BY seq DESC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(getMaxMessagesPerPrompt()) as MessageInRow[];
|
||||
try {
|
||||
const pending = inbound
|
||||
.prepare(
|
||||
`SELECT * FROM messages_in
|
||||
WHERE status = 'pending'
|
||||
AND (process_after IS NULL OR datetime(process_after) <= datetime('now'))
|
||||
AND (on_wake = 0 OR ?1 = 1)
|
||||
ORDER BY seq DESC
|
||||
LIMIT ?2`,
|
||||
)
|
||||
.all(isFirstPoll ? 1 : 0, getMaxMessagesPerPrompt()) as MessageInRow[];
|
||||
|
||||
if (pending.length === 0) return [];
|
||||
if (pending.length === 0) return [];
|
||||
|
||||
// Filter out messages already acknowledged in outbound.db
|
||||
const ackedIds = new Set(
|
||||
(outbound.prepare('SELECT message_id FROM processing_ack').all() as Array<{ message_id: string }>).map(
|
||||
(r) => r.message_id,
|
||||
),
|
||||
);
|
||||
// Filter out messages already acknowledged in outbound.db
|
||||
const ackedIds = new Set(
|
||||
(outbound.prepare('SELECT message_id FROM processing_ack').all() as Array<{ message_id: string }>).map(
|
||||
(r) => r.message_id,
|
||||
),
|
||||
);
|
||||
|
||||
// Reverse: we fetched DESC to take the most recent N, but the agent
|
||||
// should see them in chronological order (oldest first).
|
||||
return pending.filter((m) => !ackedIds.has(m.id)).reverse();
|
||||
// Reverse: we fetched DESC to take the most recent N, but the agent
|
||||
// should see them in chronological order (oldest first).
|
||||
return pending.filter((m) => !ackedIds.has(m.id)).reverse();
|
||||
} finally {
|
||||
inbound.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Mark messages as processing — writes to processing_ack in outbound.db. */
|
||||
@@ -112,7 +117,12 @@ export function markFailed(id: string): void {
|
||||
|
||||
/** Get a message by ID (read from inbound.db). */
|
||||
export function getMessageIn(id: string): MessageInRow | undefined {
|
||||
return getInboundDb().prepare('SELECT * FROM messages_in WHERE id = ?').get(id) as MessageInRow | undefined;
|
||||
const inbound = openInboundDb();
|
||||
try {
|
||||
return inbound.prepare('SELECT * FROM messages_in WHERE id = ?').get(id) as MessageInRow | undefined;
|
||||
} finally {
|
||||
inbound.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,19 +130,23 @@ export function getMessageIn(id: string): MessageInRow | undefined {
|
||||
* Reads from inbound.db, checks processing_ack to skip already-handled responses.
|
||||
*/
|
||||
export function findQuestionResponse(questionId: string): MessageInRow | undefined {
|
||||
const inbound = getInboundDb();
|
||||
const inbound = openInboundDb();
|
||||
const outbound = getOutboundDb();
|
||||
|
||||
const response = inbound
|
||||
.prepare("SELECT * FROM messages_in WHERE status = 'pending' AND content LIKE ?")
|
||||
.get(`%"questionId":"${questionId}"%`) as MessageInRow | undefined;
|
||||
try {
|
||||
const response = inbound
|
||||
.prepare("SELECT * FROM messages_in WHERE status = 'pending' AND content LIKE ?")
|
||||
.get(`%"questionId":"${questionId}"%`) as MessageInRow | undefined;
|
||||
|
||||
if (!response) return undefined;
|
||||
if (!response) return undefined;
|
||||
|
||||
// Check it hasn't been acked already
|
||||
const acked = outbound.prepare('SELECT 1 FROM processing_ack WHERE message_id = ?').get(response.id);
|
||||
if (acked) return undefined;
|
||||
// Check it hasn't been acked already
|
||||
const acked = outbound.prepare('SELECT 1 FROM processing_ack WHERE message_id = ?').get(response.id);
|
||||
if (acked) return undefined;
|
||||
|
||||
return response;
|
||||
return response;
|
||||
} finally {
|
||||
inbound.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
|
||||
import { closeSessionDb, getInboundDb, initTestSessionDb } from './db/connection.js';
|
||||
import { buildSystemPromptAddendum } from './destinations.js';
|
||||
|
||||
beforeEach(() => {
|
||||
initTestSessionDb();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeSessionDb();
|
||||
});
|
||||
|
||||
function seedDestination(name: string, displayName: string, channelType: string, platformId: string): void {
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
|
||||
VALUES (?, ?, 'channel', ?, ?, NULL)`,
|
||||
)
|
||||
.run(name, displayName, channelType, platformId);
|
||||
}
|
||||
|
||||
describe('buildSystemPromptAddendum — multi-destination routing guidance', () => {
|
||||
it('includes default-routing nudge when there are >1 destinations', () => {
|
||||
seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us');
|
||||
seedDestination('whatsapp-mg-17780', 'whatsapp-mg-17780', 'whatsapp', 'phone-2@s.whatsapp.net');
|
||||
|
||||
const prompt = buildSystemPromptAddendum('Casa');
|
||||
|
||||
expect(prompt).toContain('Default routing');
|
||||
expect(prompt).toContain('from="name"');
|
||||
expect(prompt).toContain('`casa`');
|
||||
expect(prompt).toContain('`whatsapp-mg-17780`');
|
||||
});
|
||||
|
||||
it('requires explicit wrapping even for a single destination', () => {
|
||||
seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us');
|
||||
|
||||
const prompt = buildSystemPromptAddendum('Casa');
|
||||
|
||||
expect(prompt).toContain('Every response must be wrapped');
|
||||
expect(prompt).toContain('<message to="name">');
|
||||
expect(prompt).toContain('`casa`');
|
||||
});
|
||||
|
||||
it('handles the no-destination case without crashing', () => {
|
||||
const prompt = buildSystemPromptAddendum('Casa');
|
||||
|
||||
expect(prompt).toContain('no configured destinations');
|
||||
expect(prompt).not.toContain('Default routing');
|
||||
});
|
||||
|
||||
it('includes default-routing and wrapping instructions for single destination', () => {
|
||||
seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us');
|
||||
|
||||
const prompt = buildSystemPromptAddendum('Casa');
|
||||
|
||||
expect(prompt).toContain('Every response must be wrapped');
|
||||
expect(prompt).toContain('<message to="name">');
|
||||
expect(prompt).toContain('Default routing');
|
||||
expect(prompt).toContain('`casa`');
|
||||
});
|
||||
});
|
||||
@@ -102,32 +102,28 @@ function buildDestinationsSection(): string {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// Single-destination shortcut: the agent just writes its response normally.
|
||||
const lines = ['## Sending messages', ''];
|
||||
if (all.length === 1) {
|
||||
const d = all[0];
|
||||
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
|
||||
return [
|
||||
'## Sending messages',
|
||||
'',
|
||||
`Your messages are delivered to \`${d.name}\`${label}. Just write your response directly — no special wrapping needed.`,
|
||||
'',
|
||||
'To mark something as scratchpad (logged but not sent), wrap it in `<internal>...</internal>`.',
|
||||
'',
|
||||
'To send a message mid-response (e.g., an acknowledgment before a long task), call the `send_message` MCP tool.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const lines = ['## Sending messages', '', 'You can send messages to the following destinations:', ''];
|
||||
for (const d of all) {
|
||||
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
|
||||
lines.push(`- \`${d.name}\`${label}`);
|
||||
lines.push(`Your destination is \`${d.name}\`${label}.`);
|
||||
} else {
|
||||
lines.push('You can send messages to the following destinations:', '');
|
||||
for (const d of all) {
|
||||
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
|
||||
lines.push(`- \`${d.name}\`${label}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('To send a message, wrap it in a `<message to="name">...</message>` block.');
|
||||
lines.push('**Every response must be wrapped** in a `<message to="name">...</message>` block.');
|
||||
lines.push('You can include multiple `<message>` blocks in one response to send to multiple destinations.');
|
||||
lines.push('Text outside of `<message>` blocks is scratchpad — logged but not sent anywhere.');
|
||||
lines.push('Use `<internal>...</internal>` to make scratchpad intent explicit.');
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'**Default routing**: when replying to an incoming message, address the same destination the message came `from` — every inbound `<message>` tag carries a `from="name"` attribute that names the origin destination. Only address a different destination when the request itself asks you to (e.g., "tell Laura that…").',
|
||||
);
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'To send a message mid-response (e.g., an acknowledgment before a long task), call the `send_message` MCP tool with the `to` parameter set to a destination name.',
|
||||
);
|
||||
|
||||
@@ -66,6 +66,18 @@ export function isClearCommand(msg: MessageInRow): boolean {
|
||||
return text.toLowerCase().startsWith('/clear');
|
||||
}
|
||||
|
||||
/**
|
||||
* True for any chat that needs the outer loop's command path: /clear plus
|
||||
* admin/passthrough slash commands the SDK can only dispatch when they are
|
||||
* a query's first input. Used by the follow-up poller to bail out and let
|
||||
* the outer loop reopen the query.
|
||||
*/
|
||||
export function isRunnerCommand(msg: MessageInRow): boolean {
|
||||
if (msg.kind !== 'chat' && msg.kind !== 'chat-sdk') return false;
|
||||
const cat = categorizeMessage(msg).category;
|
||||
return cat === 'admin' || cat === 'passthrough';
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function extractSenderId(msg: MessageInRow, content: any): string | null {
|
||||
const raw: string | null = content?.senderId || content?.author?.userId || null;
|
||||
@@ -165,40 +177,49 @@ function formatSingleChat(msg: MessageInRow): string {
|
||||
const replyPrefix = formatReplyContext(content.replyTo);
|
||||
const attachmentsSuffix = formatAttachments(content.attachments);
|
||||
|
||||
// Look up the destination name for the origin (reverse map lookup).
|
||||
// If not found, fall back to a raw channel:platform_id marker so nothing
|
||||
// gets silently dropped — this should only happen if the destination was
|
||||
// removed between when the message was received and when it's being processed.
|
||||
const fromDest = findByRouting(msg.channel_type, msg.platform_id);
|
||||
const fromAttr = fromDest
|
||||
? ` from="${escapeXml(fromDest.name)}"`
|
||||
: msg.channel_type || msg.platform_id
|
||||
? ` from="unknown:${escapeXml(msg.channel_type || '')}:${escapeXml(msg.platform_id || '')}"`
|
||||
: '';
|
||||
const fromAttr = originAttr(msg);
|
||||
|
||||
return `<message${idAttr}${fromAttr} sender="${escapeXml(sender)}" time="${escapeXml(time)}"${replyAttr}>${replyPrefix}${escapeXml(text)}${attachmentsSuffix}</message>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a ` from="destination_name"` attribute string from a message's routing
|
||||
* fields. Shared by all formatters so the agent always knows where a message
|
||||
* originated — critical for explicit addressing.
|
||||
*/
|
||||
function originAttr(msg: MessageInRow): string {
|
||||
const fromDest = findByRouting(msg.channel_type, msg.platform_id);
|
||||
if (fromDest) return ` from="${escapeXml(fromDest.name)}"`;
|
||||
if (msg.channel_type || msg.platform_id) {
|
||||
return ` from="unknown:${escapeXml(msg.channel_type || '')}:${escapeXml(msg.platform_id || '')}"`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function formatTaskMessage(msg: MessageInRow): string {
|
||||
const content = parseContent(msg.content);
|
||||
const parts = ['[SCHEDULED TASK]'];
|
||||
const from = originAttr(msg);
|
||||
const time = formatLocalTime(msg.timestamp, TIMEZONE);
|
||||
const parts: string[] = [];
|
||||
if (content.scriptOutput) {
|
||||
parts.push('', 'Script output:', JSON.stringify(content.scriptOutput, null, 2));
|
||||
parts.push('Script output:', JSON.stringify(content.scriptOutput, null, 2), '');
|
||||
}
|
||||
parts.push('', 'Instructions:', content.prompt || '');
|
||||
return parts.join('\n');
|
||||
parts.push('Instructions:', content.prompt || '');
|
||||
return `<task${from} time="${escapeXml(time)}">${parts.join('\n')}</task>`;
|
||||
}
|
||||
|
||||
function formatWebhookMessage(msg: MessageInRow): string {
|
||||
const content = parseContent(msg.content);
|
||||
const source = content.source || 'unknown';
|
||||
const event = content.event || 'unknown';
|
||||
return `[WEBHOOK: ${source}/${event}]\n\n${JSON.stringify(content.payload || content, null, 2)}`;
|
||||
const from = originAttr(msg);
|
||||
return `<webhook${from} source="${escapeXml(source)}" event="${escapeXml(event)}">${JSON.stringify(content.payload || content, null, 2)}</webhook>`;
|
||||
}
|
||||
|
||||
function formatSystemMessage(msg: MessageInRow): string {
|
||||
const content = parseContent(msg.content);
|
||||
return `[SYSTEM RESPONSE]\n\nAction: ${content.action || 'unknown'}\nStatus: ${content.status || 'unknown'}\nResult: ${JSON.stringify(content.result || null)}`;
|
||||
const from = originAttr(msg);
|
||||
return `<system_response${from} action="${escapeXml(content.action || 'unknown')}" status="${escapeXml(content.status || 'unknown')}">${JSON.stringify(content.result || null)}</system_response>`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -91,6 +91,8 @@ async function main(): Promise<void> {
|
||||
mcpServers,
|
||||
env: { ...process.env },
|
||||
additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined,
|
||||
model: config.model,
|
||||
effort: config.effort,
|
||||
});
|
||||
|
||||
await runPollLoop({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from './db/connection.js';
|
||||
import { getUndeliveredMessages } from './db/messages-out.js';
|
||||
import { getPendingMessages } from './db/messages-in.js';
|
||||
import { getContinuation, setContinuation } from './db/session-state.js';
|
||||
import { MockProvider } from './providers/mock.js';
|
||||
import { runPollLoop } from './poll-loop.js';
|
||||
|
||||
@@ -74,6 +75,163 @@ describe('poll loop integration', () => {
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('should resolve thread_id per-destination, not from global routing', async () => {
|
||||
// Seed a second destination
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
|
||||
VALUES ('slack-test', 'Slack Test', 'channel', 'slack', 'chan-2', NULL)`,
|
||||
)
|
||||
.run();
|
||||
|
||||
// Insert messages from each destination with distinct thread IDs
|
||||
insertMessage('m-discord', { sender: 'Alice', text: 'from discord' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'discord-thread-1' });
|
||||
insertMessage('m-slack', { sender: 'Bob', text: 'from slack' }, { platformId: 'chan-2', channelType: 'slack', threadId: 'slack-thread-99' });
|
||||
|
||||
// Agent replies to both destinations
|
||||
const provider = new MockProvider({}, () =>
|
||||
'<message to="discord-test">reply-d</message><message to="slack-test">reply-s</message>',
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length >= 2, 2000);
|
||||
controller.abort();
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
const discordOut = out.find((m) => m.platform_id === 'chan-1');
|
||||
const slackOut = out.find((m) => m.platform_id === 'chan-2');
|
||||
|
||||
expect(discordOut).toBeDefined();
|
||||
expect(discordOut!.thread_id).toBe('discord-thread-1');
|
||||
expect(discordOut!.in_reply_to).toBe('m-discord');
|
||||
|
||||
expect(slackOut).toBeDefined();
|
||||
expect(slackOut!.thread_id).toBe('slack-thread-99');
|
||||
expect(slackOut!.in_reply_to).toBe('m-slack');
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('bare text produces no outbound messages (scratchpad only)', async () => {
|
||||
insertMessage('m1', { sender: 'Alice', text: 'hello' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
// Agent responds with bare text — no <message to="..."> wrapping
|
||||
const provider = new MockProvider({}, () => 'I am thinking about this...');
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||
|
||||
// Wait long enough for the poll loop to process
|
||||
await sleep(1000);
|
||||
controller.abort();
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(0);
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('unknown destination is dropped, valid destination is sent', async () => {
|
||||
insertMessage('m1', { sender: 'Alice', text: 'hi' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
const provider = new MockProvider(
|
||||
{},
|
||||
() => '<message to="nonexistent">dropped</message><message to="discord-test">delivered</message>',
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||
controller.abort();
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
// Only the valid destination should produce output
|
||||
expect(out).toHaveLength(1);
|
||||
expect(JSON.parse(out[0].content).text).toBe('delivered');
|
||||
expect(out[0].platform_id).toBe('chan-1');
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('multiple <message> blocks each produce an outbound message', async () => {
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
|
||||
VALUES ('slack-test', 'Slack Test', 'channel', 'slack', 'chan-2', NULL)`,
|
||||
)
|
||||
.run();
|
||||
|
||||
insertMessage('m1', { sender: 'Alice', text: 'broadcast' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
const provider = new MockProvider(
|
||||
{},
|
||||
() => '<message to="discord-test">for discord</message><message to="slack-test">for slack</message>',
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length >= 2, 2000);
|
||||
controller.abort();
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(2);
|
||||
const discord = out.find((m) => m.platform_id === 'chan-1');
|
||||
const slack = out.find((m) => m.platform_id === 'chan-2');
|
||||
expect(discord).toBeDefined();
|
||||
expect(JSON.parse(discord!.content).text).toBe('for discord');
|
||||
expect(slack).toBeDefined();
|
||||
expect(JSON.parse(slack!.content).text).toBe('for slack');
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('sends null thread_id when no prior inbound from destination', async () => {
|
||||
// Seed a second destination that has NO inbound messages
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
|
||||
VALUES ('slack-new', 'Slack New', 'channel', 'slack', 'chan-new', NULL)`,
|
||||
)
|
||||
.run();
|
||||
|
||||
// Only insert a message from discord — slack-new has never sent anything
|
||||
insertMessage('m1', { sender: 'Alice', text: 'tell slack' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'discord-thread' });
|
||||
|
||||
const provider = new MockProvider({}, () => '<message to="slack-new">hello slack</message>');
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||
controller.abort();
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].platform_id).toBe('chan-new');
|
||||
expect(out[0].thread_id).toBeNull();
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('resolves most recent thread_id when destination has multiple inbound messages', async () => {
|
||||
// Two messages from same destination, different threads
|
||||
insertMessage('m-old', { sender: 'Alice', text: 'old' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'thread-old' });
|
||||
insertMessage('m-new', { sender: 'Alice', text: 'new' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'thread-new' });
|
||||
|
||||
const provider = new MockProvider({}, () => '<message to="discord-test">reply</message>');
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||
controller.abort();
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].thread_id).toBe('thread-new');
|
||||
expect(out[0].in_reply_to).toBe('m-new');
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('should process messages arriving after loop starts', async () => {
|
||||
const provider = new MockProvider({}, () => '<message to="discord-test">Processed</message>');
|
||||
const controller = new AbortController();
|
||||
@@ -91,8 +249,161 @@ describe('poll loop integration', () => {
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('internal tags between message blocks are stripped from scratchpad', async () => {
|
||||
insertMessage('m1', { sender: 'Alice', text: 'hi' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
const provider = new MockProvider(
|
||||
{},
|
||||
() => '<internal>thinking about this...</internal><message to="discord-test">answer</message><internal>done thinking</internal>',
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||
controller.abort();
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(JSON.parse(out[0].content).text).toBe('answer');
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('handles mixed task + chat batch with correct origin metadata', async () => {
|
||||
// Seed destination for routing lookup
|
||||
insertMessage('m-chat', { sender: 'Alice', text: 'check this' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
// Task with same routing — simulates a scheduled task in a channel session
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content)
|
||||
VALUES ('t-task', 'task', datetime('now'), 'pending', 'chan-1', 'discord', ?)`,
|
||||
)
|
||||
.run(JSON.stringify({ prompt: 'daily check' }));
|
||||
|
||||
const provider = new MockProvider({}, () => '<message to="discord-test">done</message>');
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||
controller.abort();
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].platform_id).toBe('chan-1');
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('should inject destination reminder after a compacted event', async () => {
|
||||
// Two destinations — required for the reminder to fire (single-destination
|
||||
// groups have a fallback path that works without <message to="…"> wrapping).
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
|
||||
VALUES ('discord-second', 'Discord Second', 'channel', 'discord', 'chan-2', NULL)`,
|
||||
)
|
||||
.run();
|
||||
|
||||
insertMessage('m1', { sender: 'Alice', text: 'First message' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
const provider = new CompactingProvider();
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2500);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 2500);
|
||||
controller.abort();
|
||||
|
||||
expect(provider.pushes.length).toBeGreaterThanOrEqual(1);
|
||||
const reminder = provider.pushes.find((p) => p.includes('Context was just compacted'));
|
||||
expect(reminder).toBeDefined();
|
||||
expect(reminder).toContain('2 destinations');
|
||||
expect(reminder).toContain('discord-test');
|
||||
expect(reminder).toContain('discord-second');
|
||||
expect(reminder).toContain('<message to="name">');
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('should NOT inject destination reminder with a single destination', async () => {
|
||||
insertMessage('m1', { sender: 'Alice', text: 'First message' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
const provider = new CompactingProvider();
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2500);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 2500);
|
||||
controller.abort();
|
||||
|
||||
// Only the original prompt push (if any) — no reminder, since beforeEach
|
||||
// seeds exactly one destination.
|
||||
const reminders = provider.pushes.filter((p) => p.includes('Context was just compacted'));
|
||||
expect(reminders).toHaveLength(0);
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Provider that emits a single compacted event mid-stream, then returns a
|
||||
* result. Captures every push() call so tests can assert on the injected
|
||||
* reminder content.
|
||||
*/
|
||||
class CompactingProvider {
|
||||
readonly supportsNativeSlashCommands = false;
|
||||
readonly pushes: string[] = [];
|
||||
|
||||
isSessionInvalid(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
query(_input: { prompt: string; cwd: string }) {
|
||||
const pushes = this.pushes;
|
||||
let ended = false;
|
||||
let aborted = false;
|
||||
let resolveWaiter: (() => void) | null = null;
|
||||
|
||||
async function* events() {
|
||||
yield { type: 'activity' as const };
|
||||
yield { type: 'init' as const, continuation: 'compaction-test-session' };
|
||||
yield { type: 'activity' as const };
|
||||
yield { type: 'compacted' as const, text: 'Context compacted (50,000 tokens compacted).' };
|
||||
|
||||
// Wait for poll-loop to push the reminder (or end / abort)
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveWaiter = resolve;
|
||||
// Belt-and-braces: don't hang forever if the reminder never arrives
|
||||
setTimeout(resolve, 200);
|
||||
});
|
||||
|
||||
yield { type: 'activity' as const };
|
||||
yield { type: 'result' as const, text: '<message to="discord-test">ack</message>' };
|
||||
while (!ended && !aborted) {
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveWaiter = resolve;
|
||||
setTimeout(resolve, 50);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
push(message: string) {
|
||||
pushes.push(message);
|
||||
resolveWaiter?.();
|
||||
},
|
||||
end() {
|
||||
ended = true;
|
||||
resolveWaiter?.();
|
||||
},
|
||||
abort() {
|
||||
aborted = true;
|
||||
resolveWaiter?.();
|
||||
},
|
||||
events: events(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: run poll loop until aborted or timeout
|
||||
async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSignal, timeoutMs: number): Promise<void> {
|
||||
return Promise.race([
|
||||
@@ -119,3 +430,142 @@ async function waitFor(condition: () => boolean, timeoutMs: number): Promise<voi
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
describe('poll loop — provider error recovery', () => {
|
||||
it('writes error to outbound and continues loop on provider throw', async () => {
|
||||
insertMessage('m1', { sender: 'Alice', text: 'trigger error' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
const provider = new ThrowingProvider('API rate limit exceeded');
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2000);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||
controller.abort();
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(JSON.parse(out[0].content).text).toContain('Error:');
|
||||
expect(JSON.parse(out[0].content).text).toContain('API rate limit exceeded');
|
||||
|
||||
// Input message should be marked completed despite the error
|
||||
const pending = getPendingMessages();
|
||||
expect(pending).toHaveLength(0);
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('poll loop — stale session recovery', () => {
|
||||
it('clears continuation when provider reports session invalid', async () => {
|
||||
// Pre-seed a continuation so the local variable in runPollLoop is set.
|
||||
// Without this, the `if (continuation && isSessionInvalid)` check skips.
|
||||
setContinuation('mock', 'pre-existing-session');
|
||||
|
||||
insertMessage('m1', { sender: 'Alice', text: 'stale session' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
const provider = new InvalidSessionProvider();
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2000);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||
controller.abort();
|
||||
|
||||
// Error was written to outbound
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(JSON.parse(out[0].content).text).toContain('Error:');
|
||||
|
||||
// Continuation was cleared (isSessionInvalid returned true)
|
||||
expect(getContinuation('mock')).toBeUndefined();
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('poll loop — /clear command', () => {
|
||||
it('clears session, writes confirmation, skips query', async () => {
|
||||
// Seed a continuation so we can verify it gets cleared
|
||||
setContinuation('mock', 'existing-session-id');
|
||||
expect(getContinuation('mock')).toBe('existing-session-id');
|
||||
|
||||
// Insert a /clear command
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content)
|
||||
VALUES ('m-clear', 'chat', datetime('now'), 'pending', 'chan-1', 'discord', ?)`,
|
||||
)
|
||||
.run(JSON.stringify({ text: '/clear' }));
|
||||
|
||||
const provider = new MockProvider({}, () => '<message to="discord-test">should not run</message>');
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||
controller.abort();
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(JSON.parse(out[0].content).text).toBe('Session cleared.');
|
||||
|
||||
// Continuation was cleared
|
||||
expect(getContinuation('mock')).toBeUndefined();
|
||||
|
||||
// Command message was completed
|
||||
const pending = getPendingMessages();
|
||||
expect(pending).toHaveLength(0);
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Provider that throws on every query, simulating API failures.
|
||||
*/
|
||||
class ThrowingProvider {
|
||||
readonly supportsNativeSlashCommands = false;
|
||||
private errorMessage: string;
|
||||
|
||||
constructor(errorMessage: string) {
|
||||
this.errorMessage = errorMessage;
|
||||
}
|
||||
|
||||
isSessionInvalid(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
query(_input: { prompt: string; cwd: string }) {
|
||||
const errorMessage = this.errorMessage;
|
||||
return {
|
||||
push() {},
|
||||
end() {},
|
||||
abort() {},
|
||||
events: (async function* () {
|
||||
throw new Error(errorMessage);
|
||||
})(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider that throws with an error that triggers isSessionInvalid.
|
||||
* First emits an init event (setting continuation), then throws.
|
||||
*/
|
||||
class InvalidSessionProvider {
|
||||
readonly supportsNativeSlashCommands = false;
|
||||
|
||||
isSessionInvalid(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
query(_input: { prompt: string; cwd: string }) {
|
||||
return {
|
||||
push() {},
|
||||
end() {},
|
||||
abort() {},
|
||||
events: (async function* () {
|
||||
yield { type: 'init' as const, continuation: 'doomed-session' };
|
||||
throw new Error('session not found');
|
||||
})(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
## Admin CLI (`ncl`)
|
||||
|
||||
The `ncl` command is available at `/usr/local/bin/ncl`. It lets you query and modify NanoClaw's central configuration.
|
||||
|
||||
### Usage
|
||||
|
||||
```
|
||||
ncl <resource> <verb> [--flags]
|
||||
ncl <resource> help
|
||||
ncl help
|
||||
```
|
||||
|
||||
### Scope
|
||||
|
||||
Your CLI access may be scoped. Run `ncl help` to see which resources are available and whether args are auto-filled. Under `group` scope (the default), `--id` and group-related args are auto-filled to your agent group — you don't need to pass them.
|
||||
|
||||
### Resources
|
||||
|
||||
Run `ncl help` for the full list. Common resources:
|
||||
|
||||
| Resource | Verbs | What it is |
|
||||
|----------|-------|------------|
|
||||
| groups | list, get, create, update, delete, restart, config get/update, config add-mcp-server/remove-mcp-server, config add-package/remove-package | Agent groups (workspace, personality, container config) |
|
||||
| sessions | list, get | Active sessions (read-only) |
|
||||
| destinations | list, add, remove | Where an agent group can send messages |
|
||||
| members | list, add, remove | Unprivileged access gate for an agent group |
|
||||
|
||||
Additional resources (available under `global` scope only): messaging-groups, wirings, users, roles, user-dms, dropped-messages, approvals.
|
||||
|
||||
### When to use
|
||||
|
||||
- **Looking up your own config** — `ncl groups get` or `ncl groups config get` to see your container config.
|
||||
- **Restarting your container** — `ncl groups restart` (with optional `--rebuild` and `--message`).
|
||||
- **Checking who's in your group** — `ncl members list`.
|
||||
- **Seeing your destinations** — `ncl destinations list`.
|
||||
- **Answering questions about the system** — query `ncl` rather than guessing.
|
||||
|
||||
### Access rules
|
||||
|
||||
Read commands (list, get) are open. Write commands (create, update, delete, restart, config update, add, remove) require admin approval — the request is held until an admin approves it.
|
||||
|
||||
### Approval flow
|
||||
|
||||
Write commands require admin approval. Here's what happens:
|
||||
|
||||
1. You run the command (e.g. `ncl groups config update --model claude-sonnet-4-5-20250514`).
|
||||
2. The command returns immediately with an `approval-pending` response — it has **not** been executed yet.
|
||||
3. An admin or owner gets a notification showing exactly what you requested, with approve/reject options.
|
||||
4. Once the admin responds:
|
||||
- **Approved:** the command executes and the result is delivered back to you as a system message in this conversation.
|
||||
- **Rejected:** you get a system message saying the request was rejected.
|
||||
|
||||
You don't need to poll or retry — the result arrives automatically.
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# Read commands (no approval needed)
|
||||
ncl groups get
|
||||
ncl groups config get
|
||||
ncl sessions list
|
||||
ncl destinations list
|
||||
ncl members list
|
||||
|
||||
# Write commands (approval required)
|
||||
ncl groups restart
|
||||
ncl groups restart --rebuild --message "Config updated."
|
||||
ncl groups config update --model claude-sonnet-4-5-20250514
|
||||
ncl groups config add-mcp-server --name rss --command npx --args '["some-rss-mcp"]'
|
||||
ncl groups config add-package --npm some-package
|
||||
ncl members add --user telegram:jane
|
||||
```
|
||||
|
||||
### Important
|
||||
|
||||
Config changes via `ncl groups config update` do not take effect until `ncl groups restart`. Run `ncl groups config help` for details.
|
||||
|
||||
### Tips
|
||||
|
||||
- Use `ncl <resource> help` to see all available fields, types, enums, and which fields are auto-filled.
|
||||
- Flags use `--hyphen-case` (e.g. `--agent-group-id`), mapped to `underscore_case` DB columns automatically.
|
||||
- `list` supports filtering by any non-auto column. Default limit is 200 rows; override with `--limit N`.
|
||||
- Write commands return `approval-pending` immediately — don't treat this as an error. Wait for the system message with the result.
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Tests for the core MCP tools' interaction with the per-batch routing
|
||||
* context. The agent-runner sets a current `inReplyTo` at the top of each
|
||||
* batch in poll-loop, and outbound writes from MCP tools (send_message,
|
||||
* send_file) must pick it up so a2a return-path routing on the host can
|
||||
* correlate replies back to the originating session.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
|
||||
import { initTestSessionDb, closeSessionDb, getInboundDb } from '../db/connection.js';
|
||||
import { getUndeliveredMessages } from '../db/messages-out.js';
|
||||
import { setCurrentInReplyTo, clearCurrentInReplyTo } from '../current-batch.js';
|
||||
import { sendMessage } from './core.js';
|
||||
|
||||
beforeEach(() => {
|
||||
initTestSessionDb();
|
||||
// Seed a peer agent destination
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
|
||||
VALUES ('peer', 'Peer', 'agent', NULL, NULL, 'ag-peer')`,
|
||||
)
|
||||
.run();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearCurrentInReplyTo();
|
||||
closeSessionDb();
|
||||
});
|
||||
|
||||
describe('send_message MCP tool — in_reply_to plumbing', () => {
|
||||
it('stamps current batch in_reply_to on outbound rows', async () => {
|
||||
setCurrentInReplyTo('inbound-msg-1');
|
||||
|
||||
await sendMessage.handler({ to: 'peer', text: 'hello' });
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].in_reply_to).toBe('inbound-msg-1');
|
||||
});
|
||||
|
||||
it('writes null when no batch is active', async () => {
|
||||
// No setCurrentInReplyTo before this call — simulates ad-hoc / out-of-batch invocation.
|
||||
await sendMessage.handler({ to: 'peer', text: 'hello' });
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].in_reply_to).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { getCurrentInReplyTo } from '../current-batch.js';
|
||||
import { findByName, getAllDestinations } from '../destinations.js';
|
||||
import { getMessageIdBySeq, getRoutingBySeq, writeMessageOut } from '../db/messages-out.js';
|
||||
import { getSessionRouting } from '../db/session-routing.js';
|
||||
@@ -50,9 +51,7 @@ function destinationList(): string {
|
||||
*/
|
||||
function resolveRouting(
|
||||
to: string | undefined,
|
||||
):
|
||||
| { channel_type: string; platform_id: string; thread_id: string | null; resolvedName: string }
|
||||
| { error: string } {
|
||||
): { channel_type: string; platform_id: string; thread_id: string | null; resolvedName: string } | { error: string } {
|
||||
if (!to) {
|
||||
// Default: reply to whatever thread/channel this session is bound to.
|
||||
const session = getSessionRouting();
|
||||
@@ -82,9 +81,7 @@ function resolveRouting(
|
||||
// preserve the thread_id so replies land in the correct thread.
|
||||
const session = getSessionRouting();
|
||||
const threadId =
|
||||
session.channel_type === dest.channelType && session.platform_id === dest.platformId
|
||||
? session.thread_id
|
||||
: null;
|
||||
session.channel_type === dest.channelType && session.platform_id === dest.platformId ? session.thread_id : null;
|
||||
return {
|
||||
channel_type: dest.channelType!,
|
||||
platform_id: dest.platformId!,
|
||||
@@ -98,12 +95,14 @@ function resolveRouting(
|
||||
export const sendMessage: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'send_message',
|
||||
description:
|
||||
'Send a message to a named destination. If you have only one destination, you can omit `to`.',
|
||||
description: 'Send a message to a named destination. If you have only one destination, you can omit `to`.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
to: { type: 'string', description: 'Destination name (e.g., "family", "worker-1"). Optional if you have only one destination.' },
|
||||
to: {
|
||||
type: 'string',
|
||||
description: 'Destination name (e.g., "family", "worker-1"). Optional if you have only one destination.',
|
||||
},
|
||||
text: { type: 'string', description: 'Message content' },
|
||||
},
|
||||
required: ['text'],
|
||||
@@ -119,6 +118,7 @@ export const sendMessage: McpToolDefinition = {
|
||||
const id = generateId();
|
||||
const seq = writeMessageOut({
|
||||
id,
|
||||
in_reply_to: getCurrentInReplyTo(),
|
||||
kind: 'chat',
|
||||
platform_id: routing.platform_id,
|
||||
channel_type: routing.channel_type,
|
||||
@@ -165,6 +165,7 @@ export const sendFile: McpToolDefinition = {
|
||||
|
||||
writeMessageOut({
|
||||
id,
|
||||
in_reply_to: getCurrentInReplyTo(),
|
||||
kind: 'chat',
|
||||
platform_id: routing.platform_id,
|
||||
channel_type: routing.channel_type,
|
||||
|
||||
@@ -89,6 +89,9 @@ export const scheduleTask: McpToolDefinition = {
|
||||
script,
|
||||
processAfter,
|
||||
recurrence,
|
||||
platformId: r.platform_id,
|
||||
channelType: r.channel_type,
|
||||
threadId: r.thread_id,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -14,13 +14,18 @@ afterEach(() => {
|
||||
closeSessionDb();
|
||||
});
|
||||
|
||||
function insertMessage(id: string, kind: string, content: object, opts?: { processAfter?: string; trigger?: 0 | 1 }) {
|
||||
function insertMessage(
|
||||
id: string,
|
||||
kind: string,
|
||||
content: object,
|
||||
opts?: { processAfter?: string; trigger?: 0 | 1; onWake?: 0 | 1 },
|
||||
) {
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, process_after, trigger, content)
|
||||
VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?)`,
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, process_after, trigger, on_wake, content)
|
||||
VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(id, kind, opts?.processAfter ?? null, opts?.trigger ?? 1, JSON.stringify(content));
|
||||
.run(id, kind, opts?.processAfter ?? null, opts?.trigger ?? 1, opts?.onWake ?? 0, JSON.stringify(content));
|
||||
}
|
||||
|
||||
describe('formatter', () => {
|
||||
@@ -47,7 +52,7 @@ describe('formatter', () => {
|
||||
insertMessage('m1', 'task', { prompt: 'Review open PRs' });
|
||||
const messages = getPendingMessages();
|
||||
const prompt = formatMessages(messages);
|
||||
expect(prompt).toContain('[SCHEDULED TASK]');
|
||||
expect(prompt).toContain('<task');
|
||||
expect(prompt).toContain('Review open PRs');
|
||||
});
|
||||
|
||||
@@ -55,15 +60,17 @@ describe('formatter', () => {
|
||||
insertMessage('m1', 'webhook', { source: 'github', event: 'push', payload: { ref: 'main' } });
|
||||
const messages = getPendingMessages();
|
||||
const prompt = formatMessages(messages);
|
||||
expect(prompt).toContain('[WEBHOOK: github/push]');
|
||||
expect(prompt).toContain('<webhook');
|
||||
expect(prompt).toContain('source="github"');
|
||||
expect(prompt).toContain('event="push"');
|
||||
});
|
||||
|
||||
it('should format system messages', () => {
|
||||
insertMessage('m1', 'system', { action: 'register_group', status: 'success', result: { id: 'ag-1' } });
|
||||
const messages = getPendingMessages();
|
||||
const prompt = formatMessages(messages);
|
||||
expect(prompt).toContain('[SYSTEM RESPONSE]');
|
||||
expect(prompt).toContain('register_group');
|
||||
expect(prompt).toContain('<system_response');
|
||||
expect(prompt).toContain('action="register_group"');
|
||||
});
|
||||
|
||||
it('should handle mixed kinds', () => {
|
||||
@@ -72,7 +79,7 @@ describe('formatter', () => {
|
||||
const messages = getPendingMessages();
|
||||
const prompt = formatMessages(messages);
|
||||
expect(prompt).toContain('sender="John"');
|
||||
expect(prompt).toContain('[SYSTEM RESPONSE]');
|
||||
expect(prompt).toContain('<system_response');
|
||||
});
|
||||
|
||||
it('should escape XML in content', () => {
|
||||
@@ -129,6 +136,58 @@ describe('accumulate gate (trigger column)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('on_wake filtering', () => {
|
||||
it('first poll returns on_wake=1 messages', () => {
|
||||
insertMessage('m1', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 });
|
||||
const messages = getPendingMessages(true);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].id).toBe('m1');
|
||||
});
|
||||
|
||||
it('subsequent polls skip on_wake=1 messages', () => {
|
||||
insertMessage('m1', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 });
|
||||
const messages = getPendingMessages(false);
|
||||
expect(messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('normal messages returned regardless of isFirstPoll', () => {
|
||||
insertMessage('m1', 'chat', { sender: 'A', text: 'hello' });
|
||||
expect(getPendingMessages(true)).toHaveLength(1);
|
||||
|
||||
// Reset: mark completed so we can re-test with a fresh message
|
||||
markCompleted(['m1']);
|
||||
insertMessage('m2', 'chat', { sender: 'A', text: 'hello again' });
|
||||
expect(getPendingMessages(false)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('mixed batch: first poll returns both normal and on_wake messages', () => {
|
||||
insertMessage('m1', 'chat', { sender: 'A', text: 'user msg' });
|
||||
insertMessage('m2', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 });
|
||||
const messages = getPendingMessages(true);
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages.map((m) => m.id).sort()).toEqual(['m1', 'm2']);
|
||||
});
|
||||
|
||||
it('mixed batch: subsequent poll returns only normal messages', () => {
|
||||
insertMessage('m1', 'chat', { sender: 'A', text: 'user msg' });
|
||||
insertMessage('m2', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 });
|
||||
const messages = getPendingMessages(false);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].id).toBe('m1');
|
||||
});
|
||||
|
||||
it('on_wake defaults to 0 for inserts without explicit value', () => {
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, content)
|
||||
VALUES ('m1', 'chat', datetime('now'), 'pending', '{"text":"hi"}')`,
|
||||
)
|
||||
.run();
|
||||
// Should be returned even on non-first poll (on_wake=0)
|
||||
expect(getPendingMessages(false)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('routing', () => {
|
||||
it('should extract routing from messages', () => {
|
||||
getInboundDb()
|
||||
@@ -147,6 +206,76 @@ describe('routing', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('origin metadata (from= attribute)', () => {
|
||||
function seedDestination(name: string, channelType: string, platformId: string): void {
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
|
||||
VALUES (?, ?, 'channel', ?, ?, NULL)`,
|
||||
)
|
||||
.run(name, name, channelType, platformId);
|
||||
}
|
||||
|
||||
function insertWithRouting(id: string, kind: string, content: object, channelType: string | null, platformId: string | null): void {
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content)
|
||||
VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?)`,
|
||||
)
|
||||
.run(id, kind, platformId, channelType, JSON.stringify(content));
|
||||
}
|
||||
|
||||
it('chat message includes from= when destination matches', () => {
|
||||
seedDestination('discord-main', 'discord', 'chan-1');
|
||||
insertWithRouting('m1', 'chat', { sender: 'Alice', text: 'hi' }, 'discord', 'chan-1');
|
||||
const prompt = formatMessages(getPendingMessages());
|
||||
expect(prompt).toContain('from="discord-main"');
|
||||
});
|
||||
|
||||
it('chat message falls back to raw routing when no destination matches', () => {
|
||||
insertWithRouting('m1', 'chat', { sender: 'Alice', text: 'hi' }, 'telegram', 'chat-999');
|
||||
const prompt = formatMessages(getPendingMessages());
|
||||
expect(prompt).toContain('from="unknown:telegram:chat-999"');
|
||||
});
|
||||
|
||||
it('chat message omits from= when routing is null', () => {
|
||||
insertMessage('m1', 'chat', { sender: 'Alice', text: 'hi' });
|
||||
const prompt = formatMessages(getPendingMessages());
|
||||
expect(prompt).not.toContain('from=');
|
||||
});
|
||||
|
||||
it('task message includes from= when destination matches', () => {
|
||||
seedDestination('slack-ops', 'slack', 'C-OPS');
|
||||
insertWithRouting('t1', 'task', { prompt: 'check status' }, 'slack', 'C-OPS');
|
||||
const prompt = formatMessages(getPendingMessages());
|
||||
expect(prompt).toContain('<task');
|
||||
expect(prompt).toContain('from="slack-ops"');
|
||||
});
|
||||
|
||||
it('task message omits from= when routing is null', () => {
|
||||
insertMessage('t1', 'task', { prompt: 'check status' });
|
||||
const prompt = formatMessages(getPendingMessages());
|
||||
expect(prompt).toContain('<task');
|
||||
expect(prompt).not.toContain('from=');
|
||||
});
|
||||
|
||||
it('webhook message includes from= when destination matches', () => {
|
||||
seedDestination('github-ch', 'github', 'repo-1');
|
||||
insertWithRouting('w1', 'webhook', { source: 'github', event: 'push', payload: {} }, 'github', 'repo-1');
|
||||
const prompt = formatMessages(getPendingMessages());
|
||||
expect(prompt).toContain('<webhook');
|
||||
expect(prompt).toContain('from="github-ch"');
|
||||
});
|
||||
|
||||
it('system message includes from= when destination matches', () => {
|
||||
seedDestination('discord-main', 'discord', 'chan-1');
|
||||
insertWithRouting('s1', 'system', { action: 'test', status: 'ok', result: null }, 'discord', 'chan-1');
|
||||
const prompt = formatMessages(getPendingMessages());
|
||||
expect(prompt).toContain('<system_response');
|
||||
expect(prompt).toContain('from="discord-main"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mock provider', () => {
|
||||
it('should produce init + result events', async () => {
|
||||
const provider = new MockProvider({}, (prompt) => `Echo: ${prompt}`);
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { findByName, getAllDestinations, type DestinationEntry } from './destinations.js';
|
||||
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
|
||||
import { writeMessageOut } from './db/messages-out.js';
|
||||
import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
||||
import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
||||
import { clearContinuation, migrateLegacyContinuation, setContinuation } from './db/session-state.js';
|
||||
import { clearCurrentInReplyTo, setCurrentInReplyTo } from './current-batch.js';
|
||||
import {
|
||||
clearContinuation,
|
||||
migrateLegacyContinuation,
|
||||
setContinuation,
|
||||
} from './db/session-state.js';
|
||||
import { formatMessages, extractRouting, categorizeMessage, isClearCommand, stripInternalTags, type RoutingContext } from './formatter.js';
|
||||
formatMessages,
|
||||
extractRouting,
|
||||
categorizeMessage,
|
||||
isClearCommand,
|
||||
isRunnerCommand,
|
||||
stripInternalTags,
|
||||
type RoutingContext,
|
||||
} from './formatter.js';
|
||||
import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js';
|
||||
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
@@ -62,9 +67,11 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
clearStaleProcessingAcks();
|
||||
|
||||
let pollCount = 0;
|
||||
let isFirstPoll = true;
|
||||
while (true) {
|
||||
// Skip system messages — they're responses for MCP tools (e.g., ask_user_question)
|
||||
const messages = getPendingMessages().filter((m) => m.kind !== 'system');
|
||||
const messages = getPendingMessages(isFirstPoll).filter((m) => m.kind !== 'system');
|
||||
isFirstPoll = false;
|
||||
pollCount++;
|
||||
|
||||
// Periodic heartbeat so we know the loop is alive
|
||||
@@ -170,6 +177,9 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
// Process the query while concurrently polling for new messages
|
||||
const skippedSet = new Set(skipped);
|
||||
const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id));
|
||||
// Publish the batch's in_reply_to so MCP tools (send_message, send_file)
|
||||
// can stamp it on outbound rows — needed for a2a return-path routing.
|
||||
setCurrentInReplyTo(routing.inReplyTo);
|
||||
try {
|
||||
const result = await processQuery(query, routing, processingIds, config.providerName);
|
||||
if (result.continuation && result.continuation !== continuation) {
|
||||
@@ -198,6 +208,8 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
thread_id: routing.threadId,
|
||||
content: JSON.stringify({ text: `Error: ${errMsg}` }),
|
||||
});
|
||||
} finally {
|
||||
clearCurrentInReplyTo();
|
||||
}
|
||||
|
||||
// Ensure completed even if processQuery ended without a result event
|
||||
@@ -255,36 +267,90 @@ async function processQuery(
|
||||
let done = false;
|
||||
|
||||
// Concurrent polling: push follow-ups into the active query as they arrive.
|
||||
// We do NOT force-end the stream on silence — keeping the query open is
|
||||
// strictly cheaper than close+reopen (no cold prompt cache, no reconnect).
|
||||
// We do NOT force-end the stream on silence — keeping the query open avoids
|
||||
// re-spawning the SDK subprocess (~few seconds) and re-loading the .jsonl
|
||||
// transcript on every turn. The Anthropic prompt cache is server-side with
|
||||
// a 5-min TTL keyed on prefix hash, so stream lifecycle does NOT affect
|
||||
// cache lifetime — close+reopen within 5 min still gets cache hits.
|
||||
// Stream liveness is decided host-side via the heartbeat file + processing
|
||||
// claim age (see src/host-sweep.ts); if something is truly stuck, the host
|
||||
// will kill the container and messages get reset to pending.
|
||||
let pollInFlight = false;
|
||||
let endedForCommand = false;
|
||||
const pollHandle = setInterval(() => {
|
||||
if (done) return;
|
||||
if (done || pollInFlight || endedForCommand) return;
|
||||
pollInFlight = true;
|
||||
|
||||
// Skip system messages (MCP tool responses) and /clear (needs fresh query).
|
||||
// Thread routing is the router's concern — if a message landed in this
|
||||
// session, the agent should see it. Per-thread sessions already isolate
|
||||
// threads into separate containers; shared sessions intentionally merge
|
||||
// everything. Filtering on thread_id here caused deadlocks when the
|
||||
// initial batch and follow-ups had mismatched thread_ids (e.g. a
|
||||
// host-generated welcome trigger with null thread vs a Discord DM reply).
|
||||
const newMessages = getPendingMessages().filter((m) => {
|
||||
if (m.kind === 'system') return false;
|
||||
if ((m.kind === 'chat' || m.kind === 'chat-sdk') && isClearCommand(m)) return false;
|
||||
return true;
|
||||
});
|
||||
if (newMessages.length > 0) {
|
||||
const newIds = newMessages.map((m) => m.id);
|
||||
markProcessing(newIds);
|
||||
void (async () => {
|
||||
try {
|
||||
const pending = getPendingMessages();
|
||||
|
||||
const prompt = formatMessages(newMessages);
|
||||
log(`Pushing ${newMessages.length} follow-up message(s) into active query`);
|
||||
query.push(prompt);
|
||||
// Slash commands need a fresh query: /clear resets the SDK's
|
||||
// resume id (fixed at sdkQuery() time); admin/passthrough commands
|
||||
// (/compact, /cost, …) only dispatch when they're the first input
|
||||
// of a query — pushed mid-stream they arrive as plain text and
|
||||
// the SDK never runs them. End the stream and leave the rows
|
||||
// pending; the outer loop handles them on next iteration via the
|
||||
// canonical command path + formatMessagesWithCommands.
|
||||
if (pending.some((m) => isRunnerCommand(m))) {
|
||||
log('Pending slash command — ending stream so outer loop can process');
|
||||
endedForCommand = true;
|
||||
query.end();
|
||||
return;
|
||||
}
|
||||
|
||||
markCompleted(newIds);
|
||||
}
|
||||
// Skip system messages (MCP tool responses).
|
||||
// Thread routing is the router's concern — if a message landed in this
|
||||
// session, the agent should see it. Per-thread sessions already isolate
|
||||
// threads into separate containers; shared sessions intentionally merge
|
||||
// everything. Filtering on thread_id here caused deadlocks when the
|
||||
// initial batch and follow-ups had mismatched thread_ids (e.g. a
|
||||
// host-generated welcome trigger with null thread vs a Discord DM reply).
|
||||
const newMessages = pending.filter((m) => m.kind !== 'system');
|
||||
if (newMessages.length === 0) return;
|
||||
|
||||
const newIds = newMessages.map((m) => m.id);
|
||||
markProcessing(newIds);
|
||||
|
||||
// Run pre-task scripts on follow-ups too — without this, a task that
|
||||
// arrives during an active query (e.g. a */10 monitoring cron) bypasses
|
||||
// its script gate and always wakes the agent, defeating the gate.
|
||||
// Mirrors the initial-batch hook above.
|
||||
let keep = newMessages;
|
||||
let skipped: string[] = [];
|
||||
// MODULE-HOOK:scheduling-pre-task-followup:start
|
||||
const { applyPreTaskScripts } = await import('./scheduling/task-script.js');
|
||||
const preTask = await applyPreTaskScripts(newMessages);
|
||||
keep = preTask.keep;
|
||||
skipped = preTask.skipped;
|
||||
if (skipped.length > 0) {
|
||||
markCompleted(skipped);
|
||||
log(`Pre-task script skipped ${skipped.length} follow-up task(s): ${skipped.join(', ')}`);
|
||||
}
|
||||
// MODULE-HOOK:scheduling-pre-task-followup:end
|
||||
|
||||
if (keep.length === 0) return;
|
||||
// Re-check done — the outer query may have finished while the script
|
||||
// was awaited. Pushing into a closed stream is wasted work; the
|
||||
// claimed messages get released by the host's processing-claim sweep.
|
||||
if (done) return;
|
||||
|
||||
const keptIds = keep.map((m) => m.id);
|
||||
const prompt = formatMessages(keep);
|
||||
log(`Pushing ${keep.length} follow-up message(s) into active query`);
|
||||
query.push(prompt);
|
||||
markCompleted(keptIds);
|
||||
} catch (err) {
|
||||
// Without this catch the rejection escapes the void IIFE and Node
|
||||
// terminates the container on unhandled-rejection. The initial-batch
|
||||
// path is wrapped by processQuery's outer try/catch; the follow-up
|
||||
// path is not, so it needs its own.
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
log(`Follow-up poll error: ${errMsg}`);
|
||||
} finally {
|
||||
pollInFlight = false;
|
||||
}
|
||||
})();
|
||||
}, ACTIVE_POLL_INTERVAL_MS);
|
||||
|
||||
try {
|
||||
@@ -312,6 +378,23 @@ async function processQuery(
|
||||
if (event.text) {
|
||||
dispatchResultText(event.text, routing);
|
||||
}
|
||||
} else if (event.type === 'compacted') {
|
||||
// The SDK auto-compacted the conversation. After compaction the
|
||||
// model often drops the learned `<message to="…">` wrapping
|
||||
// discipline (the destinations are still in the system prompt,
|
||||
// but the behavioral pattern is summarized away). Inject a
|
||||
// reminder back into the live query so the next turn re-anchors
|
||||
// on the destination model. Only do this when there's >1
|
||||
// destination — single-destination groups have a fallback that
|
||||
// works without wrapping. See qwibitai/nanoclaw#2325.
|
||||
const destinations = getAllDestinations();
|
||||
if (destinations.length > 1) {
|
||||
const names = destinations.map((d) => d.name).join(', ');
|
||||
query.push(
|
||||
`[system] Context was just compacted. Reminder: you have ${destinations.length} destinations (${names}). ` +
|
||||
`Use <message to="name"> blocks to address them. Bare text goes to the scratchpad fallback only.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -331,25 +414,26 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
|
||||
log(`Result: ${event.text ? event.text.slice(0, 200) : '(empty)'}`);
|
||||
break;
|
||||
case 'error':
|
||||
log(`Error: ${event.message} (retryable: ${event.retryable}${event.classification ? `, ${event.classification}` : ''})`);
|
||||
log(
|
||||
`Error: ${event.message} (retryable: ${event.retryable}${event.classification ? `, ${event.classification}` : ''})`,
|
||||
);
|
||||
break;
|
||||
case 'progress':
|
||||
log(`Progress: ${event.message}`);
|
||||
break;
|
||||
case 'compacted':
|
||||
log(`Compacted: ${event.text}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the agent's final text for <message to="name">...</message> blocks
|
||||
* and dispatch each one to its resolved destination. Text outside of blocks
|
||||
* (including <internal>...</internal>) is normally scratchpad — logged but
|
||||
* not sent.
|
||||
* (including <internal>...</internal>) is scratchpad — logged but not sent.
|
||||
*
|
||||
* Single-destination shortcut: if the agent has exactly one configured
|
||||
* destination AND the output contains zero <message> blocks, the entire
|
||||
* cleaned text (with <internal> tags stripped) is sent to that destination.
|
||||
* This preserves the simple case of one user on one channel — the agent
|
||||
* doesn't need to know about wrapping syntax at all.
|
||||
* The agent must always wrap output in <message to="name">...</message>
|
||||
* blocks, even with a single destination. Bare text is scratchpad only.
|
||||
*/
|
||||
function dispatchResultText(text: string, routing: RoutingContext): void {
|
||||
const MESSAGE_RE = /<message\s+to="([^"]+)"\s*>([\s\S]*?)<\/message>/g;
|
||||
@@ -382,30 +466,6 @@ function dispatchResultText(text: string, routing: RoutingContext): void {
|
||||
|
||||
const scratchpad = stripInternalTags(scratchpadParts.join(''));
|
||||
|
||||
// Single-destination shortcut: the agent wrote plain text — send to
|
||||
// the session's originating channel (from session_routing) if available,
|
||||
// otherwise fall back to the single destination.
|
||||
if (sent === 0 && scratchpad) {
|
||||
if (routing.channelType && routing.platformId) {
|
||||
// Reply to the channel/thread the message came from
|
||||
writeMessageOut({
|
||||
id: generateId(),
|
||||
in_reply_to: routing.inReplyTo,
|
||||
kind: 'chat',
|
||||
platform_id: routing.platformId,
|
||||
channel_type: routing.channelType,
|
||||
thread_id: routing.threadId,
|
||||
content: JSON.stringify({ text: scratchpad }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const all = getAllDestinations();
|
||||
if (all.length === 1) {
|
||||
sendToDestination(all[0], scratchpad, routing);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (scratchpad) {
|
||||
log(`[scratchpad] ${scratchpad.slice(0, 500)}${scratchpad.length > 500 ? '…' : ''}`);
|
||||
}
|
||||
@@ -418,20 +478,46 @@ function dispatchResultText(text: string, routing: RoutingContext): void {
|
||||
function sendToDestination(dest: DestinationEntry, body: string, routing: RoutingContext): void {
|
||||
const platformId = dest.type === 'channel' ? dest.platformId! : dest.agentGroupId!;
|
||||
const channelType = dest.type === 'channel' ? dest.channelType! : 'agent';
|
||||
// Inherit thread_id from the inbound routing context so replies land in the
|
||||
// same thread the conversation is in. For non-threaded adapters the router
|
||||
// strips thread_id at ingest, so this will already be null.
|
||||
// Resolve thread_id per-destination from the most recent inbound message
|
||||
// that came from this same channel+platform. In agent-shared sessions,
|
||||
// different destinations have different thread contexts — using a single
|
||||
// routing.threadId would stamp one channel's thread onto another.
|
||||
const destRouting = resolveDestinationThread(channelType, platformId);
|
||||
writeMessageOut({
|
||||
id: generateId(),
|
||||
in_reply_to: routing.inReplyTo,
|
||||
in_reply_to: destRouting?.inReplyTo ?? routing.inReplyTo,
|
||||
kind: 'chat',
|
||||
platform_id: platformId,
|
||||
channel_type: channelType,
|
||||
thread_id: routing.threadId,
|
||||
thread_id: destRouting?.threadId ?? null,
|
||||
content: JSON.stringify({ text: body }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the thread_id and message id from the most recent inbound message
|
||||
* matching the given channel+platform. Returns null if no match found.
|
||||
*/
|
||||
function resolveDestinationThread(
|
||||
channelType: string,
|
||||
platformId: string,
|
||||
): { threadId: string | null; inReplyTo: string | null } | null {
|
||||
try {
|
||||
const db = getInboundDb();
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT thread_id, id FROM messages_in
|
||||
WHERE channel_type = ? AND platform_id = ?
|
||||
ORDER BY seq DESC LIMIT 1`,
|
||||
)
|
||||
.get(channelType, platformId) as { thread_id: string | null; id: string } | undefined;
|
||||
if (row) return { threadId: row.thread_id, inReplyTo: row.id };
|
||||
} catch (err) {
|
||||
log(`resolveDestinationThread error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
@@ -34,7 +34,11 @@ const SDK_DISALLOWED_TOOLS = [
|
||||
'ExitWorktree',
|
||||
];
|
||||
|
||||
// Tool allowlist for NanoClaw agent containers
|
||||
// Tool allowlist for NanoClaw agent containers. MCP-tool entries are derived
|
||||
// at the call site from the registered `mcpServers` map so that any server
|
||||
// added via `add_mcp_server` (or wired in container.json directly) is
|
||||
// reachable to the agent — without this, the SDK's allowedTools filter
|
||||
// silently drops every MCP namespace not listed here.
|
||||
const TOOL_ALLOWLIST = [
|
||||
'Bash',
|
||||
'Read',
|
||||
@@ -54,9 +58,15 @@ const TOOL_ALLOWLIST = [
|
||||
'ToolSearch',
|
||||
'Skill',
|
||||
'NotebookEdit',
|
||||
'mcp__nanoclaw__*',
|
||||
];
|
||||
|
||||
// MCP server names are sanitized by the SDK when forming tool prefixes:
|
||||
// any character outside [A-Za-z0-9_-] becomes '_'. Mirror that here so our
|
||||
// allowlist patterns match what the SDK actually exposes.
|
||||
function mcpAllowPattern(serverName: string): string {
|
||||
return `mcp__${serverName.replace(/[^a-zA-Z0-9_-]/g, '_')}__*`;
|
||||
}
|
||||
|
||||
interface SDKUserMessage {
|
||||
type: 'user';
|
||||
message: { role: 'user'; content: string };
|
||||
@@ -226,8 +236,12 @@ function createPreCompactHook(assistantName?: string): HookCallback {
|
||||
/**
|
||||
* Claude Code auto-compacts context at this window (tokens). Kept here so
|
||||
* the generic bootstrap doesn't need to know about Claude-specific env vars.
|
||||
*
|
||||
* Operator override: set CLAUDE_CODE_AUTO_COMPACT_WINDOW in the host env to
|
||||
* raise or lower the threshold without editing source — useful when running
|
||||
* with a 1M-context model variant or when emergency-tuning a deployment.
|
||||
*/
|
||||
const CLAUDE_CODE_AUTO_COMPACT_WINDOW = '165000';
|
||||
const CLAUDE_CODE_AUTO_COMPACT_WINDOW = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW || '165000';
|
||||
|
||||
/**
|
||||
* Stale-session detection. Matches Claude Code's error text when a
|
||||
@@ -243,11 +257,15 @@ export class ClaudeProvider implements AgentProvider {
|
||||
private mcpServers: Record<string, McpServerConfig>;
|
||||
private env: Record<string, string | undefined>;
|
||||
private additionalDirectories?: string[];
|
||||
private model?: string;
|
||||
private effort?: string;
|
||||
|
||||
constructor(options: ProviderOptions = {}) {
|
||||
this.assistantName = options.assistantName;
|
||||
this.mcpServers = options.mcpServers ?? {};
|
||||
this.additionalDirectories = options.additionalDirectories;
|
||||
this.model = options.model;
|
||||
this.effort = options.effort;
|
||||
this.env = {
|
||||
...(options.env ?? {}),
|
||||
CLAUDE_CODE_AUTO_COMPACT_WINDOW,
|
||||
@@ -273,9 +291,15 @@ export class ClaudeProvider implements AgentProvider {
|
||||
resume: input.continuation,
|
||||
pathToClaudeCodeExecutable: '/pnpm/claude',
|
||||
systemPrompt: instructions ? { type: 'preset' as const, preset: 'claude_code' as const, append: instructions } : undefined,
|
||||
allowedTools: TOOL_ALLOWLIST,
|
||||
allowedTools: [
|
||||
...TOOL_ALLOWLIST,
|
||||
...Object.keys(this.mcpServers).map(mcpAllowPattern),
|
||||
],
|
||||
disallowedTools: SDK_DISALLOWED_TOOLS,
|
||||
env: this.env,
|
||||
model: this.model,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
effort: this.effort as any,
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
settingSources: ['project', 'user'],
|
||||
@@ -312,7 +336,7 @@ export class ClaudeProvider implements AgentProvider {
|
||||
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') {
|
||||
const meta = (message as { compact_metadata?: { pre_tokens?: number } }).compact_metadata;
|
||||
const detail = meta?.pre_tokens ? ` (${meta.pre_tokens.toLocaleString()} tokens compacted)` : '';
|
||||
yield { type: 'result', text: `Context compacted${detail}.` };
|
||||
yield { type: 'compacted', text: `Context compacted${detail}.` };
|
||||
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') {
|
||||
const tn = message as { summary?: string };
|
||||
yield { type: 'progress', message: tn.summary || 'Task notification' };
|
||||
|
||||
@@ -25,6 +25,16 @@ export interface ProviderOptions {
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
env?: Record<string, string | undefined>;
|
||||
additionalDirectories?: string[];
|
||||
/**
|
||||
* Model alias (`sonnet`, `opus`, `haiku`) or full model ID. Passed through
|
||||
* to the underlying SDK. If omitted, the SDK default is used.
|
||||
*/
|
||||
model?: string;
|
||||
/**
|
||||
* Reasoning effort (`'low' | 'medium' | 'high' | 'xhigh' | 'max'`). Passed
|
||||
* through to the underlying SDK. If omitted, the SDK default is used.
|
||||
*/
|
||||
effort?: string;
|
||||
}
|
||||
|
||||
export interface QueryInput {
|
||||
@@ -79,4 +89,12 @@ export type ProviderEvent =
|
||||
* event (tool call, thinking, partial message, anything) so the
|
||||
* poll-loop's idle timer stays honest during long tool runs.
|
||||
*/
|
||||
| { type: 'activity' };
|
||||
| { type: 'activity' }
|
||||
/**
|
||||
* The provider's underlying SDK auto-compacted the conversation context.
|
||||
* The poll-loop reacts by injecting a destination reminder back into
|
||||
* the live query so the agent doesn't drop `<message to="…">` wrapping
|
||||
* after compaction. Distinct from `result` so it doesn't mark the turn
|
||||
* completed or get dispatched as a chat message. See qwibitai/nanoclaw#2325.
|
||||
*/
|
||||
| { type: 'compacted'; text: string };
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
name: onecli-gateway
|
||||
description: >-
|
||||
OneCLI Gateway: transparent HTTPS proxy that injects stored credentials
|
||||
into outbound calls. You MUST use this skill when the user asks you to
|
||||
read emails, check calendar, access GitHub repos, create issues, check
|
||||
Stripe payments, or interact with ANY external service or API. Do NOT
|
||||
use browser extensions or OAuth CLI tools. Make HTTP requests directly;
|
||||
the gateway injects credentials automatically.
|
||||
compatibility: Requires HTTPS_PROXY set in environment (automatic when launched via `onecli run`)
|
||||
metadata:
|
||||
author: onecli
|
||||
version: "0.5.0"
|
||||
---
|
||||
|
||||
# OneCLI Gateway
|
||||
|
||||
Your outbound HTTPS traffic is transparently proxied through the OneCLI
|
||||
gateway, which injects stored credentials at the proxy boundary. You never
|
||||
see or handle credential values directly.
|
||||
|
||||
## How to Access External Services
|
||||
|
||||
You have direct HTTP access to external APIs. OAuth apps (Gmail, GitHub,
|
||||
Google Calendar, Google Drive, etc.) and API key services are all available
|
||||
through the gateway. Just make the request directly; the gateway injects
|
||||
credentials if the app is connected. If not, it returns an error with a
|
||||
connect URL you can present to the user.
|
||||
|
||||
## Making Requests
|
||||
|
||||
Call the real API URL. The gateway intercepts the request and injects
|
||||
credentials automatically.
|
||||
|
||||
```bash
|
||||
curl -s "https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults=5"
|
||||
curl -s "https://api.github.com/user/repos?per_page=10"
|
||||
curl -s "https://api.stripe.com/v1/charges?limit=5"
|
||||
```
|
||||
|
||||
Standard HTTP clients (curl, fetch, requests, axios, Go net/http, git) all
|
||||
honor the `HTTPS_PROXY` environment variable automatically. You do not need
|
||||
to set any auth headers.
|
||||
|
||||
## Credential Stubs for MCP Servers
|
||||
|
||||
Some MCP servers need local credential files to start. Stubs for connected
|
||||
apps are pre-written automatically. Files containing `"onecli-managed"`
|
||||
values are managed by OneCLI — do NOT modify or delete them.
|
||||
|
||||
If an MCP server won't start due to missing credentials, create stubs
|
||||
**before** starting it. Use `"onecli-managed"` as the placeholder for all
|
||||
secret values, with file permissions `0600`. See the guide at:
|
||||
https://www.onecli.sh/docs/guides/credential-stubs/general-app
|
||||
|
||||
## When a Request Fails
|
||||
|
||||
If you get a 401, 403, or a gateway error (e.g., `app_not_connected`):
|
||||
|
||||
**Step 1 — Show the user a connect link.** Use the `connect_url` from the
|
||||
error response:
|
||||
|
||||
> To connect [service], open this link:
|
||||
> [connect_url from the error response]
|
||||
|
||||
If there is no `connect_url` in the error, tell the user to open the
|
||||
OneCLI dashboard and connect the service there.
|
||||
|
||||
**Step 2 — Retry after the user connects.** Let the user know you will
|
||||
retry once they have connected. When they confirm, retry the original
|
||||
request. If the retry still fails, ask if they need help with the setup.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Never** say "I don't have access to X" without first making the HTTP
|
||||
request through the proxy.
|
||||
- **Never** use browser extensions, gcloud, or manual auth flows. The
|
||||
gateway handles credentials for you.
|
||||
- **Never** ask the user for API keys or tokens directly. Direct them to
|
||||
connect the service in the OneCLI dashboard.
|
||||
- **Never** suggest the user open Gmail/Calendar/GitHub in their browser
|
||||
when they ask you to read or interact with those services. You have API
|
||||
access. Use it.
|
||||
- If the gateway returns a policy error (403 with a JSON body), respect
|
||||
the block. Do not retry or circumvent it.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Credentials & External Services
|
||||
|
||||
Your HTTP requests go through the OneCLI proxy, which injects real credentials automatically. Just call any API directly (Gmail, GitHub, Slack, etc.) — the proxy adds auth before it reaches the service.
|
||||
|
||||
Use any method: curl, Python, a CLI tool, whatever fits. If a tool checks for credentials locally, pass any placeholder value — the proxy replaces it with real credentials at request time.
|
||||
|
||||
If you get a `401`/`403`/`app_not_connected`, run `/onecli-gateway` for the full error-handling flow. Never ask the user for API keys or tokens — if credentials are missing, the fix is connecting the service in OneCLI.
|
||||
+29
-1
@@ -10,7 +10,7 @@ Access layer: `src/db/`. Authoritative schema reference: `src/db/schema.ts` (com
|
||||
|
||||
### 1.1 `agent_groups`
|
||||
|
||||
Agent workspaces. Each maps 1:1 to a `groups/<folder>/` directory containing `CLAUDE.md`, skills, and `container.json`. Container config lives on disk, not in the DB.
|
||||
Agent workspaces. Each maps 1:1 to a `groups/<folder>/` directory containing `CLAUDE.md` and skills. Container config lives in `container_configs` (see §1.x below); a `container.json` file is materialized at spawn time for the container runner to read.
|
||||
|
||||
```sql
|
||||
CREATE TABLE agent_groups (
|
||||
@@ -294,6 +294,32 @@ CREATE TABLE schema_version (
|
||||
);
|
||||
```
|
||||
|
||||
### 1.15 `container_configs`
|
||||
|
||||
Per-agent-group container runtime config. Source of truth for provider, model, packages, MCP servers, mounts, CLI scope, etc. Materialized to `groups/<folder>/container.json` at spawn time.
|
||||
|
||||
```sql
|
||||
CREATE TABLE container_configs (
|
||||
agent_group_id TEXT PRIMARY KEY REFERENCES agent_groups(id) ON DELETE CASCADE,
|
||||
provider TEXT,
|
||||
model TEXT,
|
||||
effort TEXT,
|
||||
image_tag TEXT,
|
||||
assistant_name TEXT,
|
||||
max_messages_per_prompt INTEGER,
|
||||
skills TEXT NOT NULL DEFAULT '"all"',
|
||||
mcp_servers TEXT NOT NULL DEFAULT '{}',
|
||||
packages_apt TEXT NOT NULL DEFAULT '[]',
|
||||
packages_npm TEXT NOT NULL DEFAULT '[]',
|
||||
additional_mounts TEXT NOT NULL DEFAULT '[]',
|
||||
cli_scope TEXT NOT NULL DEFAULT 'group', -- disabled | group | global
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
- **Readers:** `src/container-config.ts`, `src/container-runner.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts`
|
||||
- **Writers:** `src/db/container-configs.ts`, `src/modules/self-mod/apply.ts`, `src/backfill-container-configs.ts`
|
||||
|
||||
---
|
||||
|
||||
## 2. Migration system
|
||||
@@ -313,6 +339,8 @@ Migrations live in `src/db/migrations/`, one file per migration. Runner: `runMig
|
||||
| 007 | `007-pending-approvals-title-options.ts` | `ALTER TABLE pending_approvals` add `title`, `options_json` (retrofits DBs created between 003 and 007) |
|
||||
| 008 | `008-dropped-messages.ts` | `unregistered_senders` |
|
||||
| 009 | `009-drop-pending-credentials.ts` | Drop the defunct `pending_credentials` table |
|
||||
| 014 | `014-container-configs.ts` | `container_configs` — per-agent-group container runtime config |
|
||||
| 015 | `015-cli-scope.ts` | `ALTER TABLE container_configs ADD COLUMN cli_scope` |
|
||||
|
||||
Numbers 005 and 006 are intentionally absent — migrations were renumbered during early development.
|
||||
|
||||
|
||||
+16
-13
@@ -33,19 +33,22 @@ Every message landing in the session: user chat, scheduled task, recurring task,
|
||||
|
||||
```sql
|
||||
CREATE TABLE messages_in (
|
||||
id TEXT PRIMARY KEY,
|
||||
seq INTEGER UNIQUE, -- EVEN only (host assigns) — see §3
|
||||
kind TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending', -- pending|completed|failed|paused
|
||||
process_after TEXT,
|
||||
recurrence TEXT, -- cron expr for recurring
|
||||
series_id TEXT, -- groups occurrences of a recurring task
|
||||
tries INTEGER DEFAULT 0,
|
||||
platform_id TEXT,
|
||||
channel_type TEXT,
|
||||
thread_id TEXT,
|
||||
content TEXT NOT NULL -- JSON; shape depends on kind
|
||||
id TEXT PRIMARY KEY,
|
||||
seq INTEGER UNIQUE, -- EVEN only (host assigns) — see §3
|
||||
kind TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending', -- pending|completed|failed|paused
|
||||
process_after TEXT,
|
||||
recurrence TEXT, -- cron expr for recurring
|
||||
series_id TEXT, -- groups occurrences of a recurring task
|
||||
tries INTEGER DEFAULT 0,
|
||||
trigger INTEGER NOT NULL DEFAULT 1, -- 0 = context only (don't wake), 1 = wake agent
|
||||
platform_id TEXT,
|
||||
channel_type TEXT,
|
||||
thread_id TEXT,
|
||||
content TEXT NOT NULL, -- JSON; shape depends on kind
|
||||
source_session_id TEXT, -- agent-to-agent return path
|
||||
on_wake INTEGER NOT NULL DEFAULT 0 -- 1 = only deliver on container's first poll
|
||||
);
|
||||
CREATE INDEX idx_messages_in_series ON messages_in(series_id);
|
||||
```
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# v1 → v2 Migration — Development Guide
|
||||
|
||||
How to test, develop, and debug the migration flow.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Full cycle: reset → migrate → Claude finishes
|
||||
bash migrate-v2-reset.sh && bash migrate-v2.sh
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Two-part migration:
|
||||
|
||||
1. **`migrate-v2.sh`** — deterministic bash script. Handles prerequisites, DB seeding, file copies, channel install, container build, service switchover. Writes `logs/setup-migration/handoff.json` then `exec`s into Claude.
|
||||
|
||||
2. **`/migrate-from-v1` skill** — Claude-driven. Reads the handoff, seeds owner/roles, cleans up CLAUDE.local.md, validates container configs, ports fork customizations.
|
||||
|
||||
## File layout
|
||||
|
||||
```
|
||||
migrate-v2.sh # Entry point
|
||||
migrate-v2-reset.sh # Wipe v2 state for re-testing
|
||||
setup/migrate-v2/
|
||||
env.ts # Phase 1a: merge .env
|
||||
db.ts # Phase 1b: seed v2 DB
|
||||
groups.ts # Phase 1c: copy group folders + container.json
|
||||
sessions.ts # Phase 1d: copy sessions + set continuation
|
||||
tasks.ts # Phase 1e: port scheduled tasks
|
||||
channel-auth.ts # Phase 2b: copy channel auth state
|
||||
select-channels.ts # Phase 2a: clack multiselect
|
||||
switchover-prompt.ts # Service switch prompts
|
||||
setup/migrate-v2/shared.ts # Shared helpers (JID parsing, trigger mapping, etc.)
|
||||
.claude/skills/migrate-from-v1/ # The Claude skill
|
||||
logs/setup-migration/handoff.json # Written by migrate-v2.sh, read by skill
|
||||
logs/migrate-steps/*.log # Per-step raw output
|
||||
```
|
||||
|
||||
## Development loop
|
||||
|
||||
```bash
|
||||
# Reset v2 to clean state (keeps node_modules)
|
||||
bash migrate-v2-reset.sh
|
||||
|
||||
# Run migration with non-interactive channel selection
|
||||
NANOCLAW_CHANNELS="telegram" bash migrate-v2.sh
|
||||
|
||||
# Or run interactively (clack multiselect)
|
||||
bash migrate-v2.sh
|
||||
```
|
||||
|
||||
`migrate-v2-reset.sh` wipes: `data/`, `logs/`, `.env`, `groups/` (restores git-tracked), `container/skills/` (restores git-tracked), `src/channels/` (restores git-tracked).
|
||||
|
||||
It does NOT wipe `node_modules/` (expensive to reinstall).
|
||||
|
||||
## Testing individual steps
|
||||
|
||||
Each step is a standalone TypeScript file:
|
||||
|
||||
```bash
|
||||
# Run a single step (after pnpm install)
|
||||
pnpm exec tsx setup/migrate-v2/env.ts /path/to/v1
|
||||
pnpm exec tsx setup/migrate-v2/db.ts /path/to/v1
|
||||
pnpm exec tsx setup/migrate-v2/groups.ts /path/to/v1
|
||||
pnpm exec tsx setup/migrate-v2/sessions.ts /path/to/v1
|
||||
pnpm exec tsx setup/migrate-v2/tasks.ts /path/to/v1
|
||||
pnpm exec tsx setup/migrate-v2/channel-auth.ts /path/to/v1 telegram discord
|
||||
```
|
||||
|
||||
Each prints `OK:<details>`, `SKIPPED:<reason>`, or errors to stdout. Exit 0 on success/skip, non-zero on failure.
|
||||
|
||||
## Debugging
|
||||
|
||||
### Check what was migrated
|
||||
|
||||
```bash
|
||||
# Agent groups
|
||||
sqlite3 data/v2.db "SELECT * FROM agent_groups"
|
||||
|
||||
# Messaging groups + wiring
|
||||
sqlite3 data/v2.db "SELECT mg.id, mg.channel_type, mg.platform_id, mg.unknown_sender_policy, mga.engage_mode, mga.engage_pattern FROM messaging_groups mg JOIN messaging_group_agents mga ON mga.messaging_group_id = mg.id"
|
||||
|
||||
# Sessions
|
||||
sqlite3 data/v2.db "SELECT * FROM sessions"
|
||||
|
||||
# Users and roles
|
||||
sqlite3 data/v2.db "SELECT * FROM users"
|
||||
sqlite3 data/v2.db "SELECT * FROM user_roles"
|
||||
|
||||
# Session continuation (which Claude Code session will be resumed)
|
||||
AG_ID=$(sqlite3 data/v2.db "SELECT id FROM agent_groups LIMIT 1")
|
||||
SESS_ID=$(sqlite3 data/v2.db "SELECT id FROM sessions LIMIT 1")
|
||||
sqlite3 data/v2-sessions/$AG_ID/$SESS_ID/outbound.db "SELECT * FROM session_state"
|
||||
|
||||
# Scheduled tasks
|
||||
sqlite3 data/v2-sessions/$AG_ID/$SESS_ID/inbound.db "SELECT id, kind, recurrence, status FROM messages_in WHERE kind='task'"
|
||||
```
|
||||
|
||||
### Check handoff
|
||||
|
||||
```bash
|
||||
python3 -m json.tool logs/setup-migration/handoff.json
|
||||
```
|
||||
|
||||
### Common issues
|
||||
|
||||
**Bot doesn't respond after switchover:**
|
||||
1. Check both services aren't running: `systemctl --user list-units 'nanoclaw*'`
|
||||
2. Check error log: `tail logs/nanoclaw.error.log`
|
||||
3. Check sender policy: `sqlite3 data/v2.db "SELECT unknown_sender_policy FROM messaging_groups"` — must be `public` before owner is seeded
|
||||
4. Check engage pattern: `sqlite3 data/v2.db "SELECT engage_mode, engage_pattern FROM messaging_group_agents"` — should be `pattern` / `.` for respond-to-everything
|
||||
|
||||
**Session not continuing from v1:**
|
||||
1. Check continuation is set: see "Session continuation" query above
|
||||
2. Check JSONL exists at the right path: `ls data/v2-sessions/<ag_id>/.claude-shared/projects/-workspace-agent/`
|
||||
3. The v1 session JSONL should be copied from `-workspace-group/` to `-workspace-agent/` (v2 container CWD is `/workspace/agent`)
|
||||
|
||||
**Service switchover revert didn't work:**
|
||||
1. The v2 service name is `nanoclaw-v2-<hash>` — find it: `systemctl --user list-units 'nanoclaw*'`
|
||||
2. Manually stop: `systemctl --user stop <unit> && systemctl --user disable <unit>`
|
||||
3. Restart v1: `systemctl --user start nanoclaw`
|
||||
|
||||
### Step logs
|
||||
|
||||
Each step writes raw output to `logs/migrate-steps/<step>.log`. Read these when a step fails:
|
||||
|
||||
```bash
|
||||
cat logs/migrate-steps/1b-db.log
|
||||
cat logs/migrate-steps/1d-sessions.log
|
||||
```
|
||||
|
||||
## Key decisions
|
||||
|
||||
- `unknown_sender_policy` is set to `public` during migration so the bot responds immediately. The `/migrate-from-v1` skill tightens it after seeding the owner.
|
||||
- `requires_trigger=0` in v1 takes priority over a non-empty `trigger_pattern` — it means "respond to everything."
|
||||
- v1 `container_config.additionalMounts` is written directly to v2 `container.json` (same shape).
|
||||
- v1 Claude Code sessions are copied from `-workspace-group/` to `-workspace-agent/` and the session ID is written to `outbound.db` as `continuation:claude` so the agent-runner resumes the same conversation.
|
||||
- `exec claude "/migrate-from-v1"` at the end replaces the bash process — `write_handoff` is called explicitly before `exec` since EXIT traps don't fire on `exec`.
|
||||
@@ -0,0 +1,172 @@
|
||||
# NanoClaw v1 → v2 — what changed
|
||||
|
||||
Big-picture differences between NanoClaw v1 (the `~/nanoclaw` checkout you've been running) and v2 (this rewrite). Not a migration guide — that's what `bash migrate-v2.sh` and the `/migrate-from-v1` skill are for. This doc is the **vocabulary**: when something has moved or been renamed, find it here.
|
||||
|
||||
Read this before touching the migration code or porting customizations forward.
|
||||
|
||||
---
|
||||
|
||||
## One-line summary
|
||||
|
||||
v1 was one Node process with one SQLite file and native channel adapters. v2 is a host that spawns per-session Docker containers, splits state across a central DB + per-session DB pair, routes through an explicit entity model, and installs channels as skills from a sibling branch.
|
||||
|
||||
---
|
||||
|
||||
## Entity model — the biggest shift
|
||||
|
||||
**v1:** one flat table `registered_groups(jid, name, folder, trigger_pattern, requires_trigger, is_main, channel_name)`. A group folder is the unit of agent identity. A chat (JID) is wired to exactly one folder, and `trigger_pattern` is an opaque regex the router applies to every incoming message.
|
||||
|
||||
**v2:** three tables, with a deliberate many-to-many in the middle:
|
||||
|
||||
```
|
||||
agent_groups ─┐
|
||||
├─ messaging_group_agents ─┬─ messaging_groups
|
||||
│ (engage_mode, │ (channel_type,
|
||||
│ engage_pattern, │ platform_id,
|
||||
│ sender_scope, │ unknown_sender_policy)
|
||||
│ ignored_message_policy,
|
||||
│ session_mode, priority)
|
||||
```
|
||||
|
||||
Consequences:
|
||||
|
||||
- **One agent can answer on many chats, and one chat can fan out to many agents.** v1 couldn't do either.
|
||||
- **No `is_main` flag.** Privilege is now explicit via `user_roles` (owner/admin, global or scoped). See below.
|
||||
- **No `trigger_pattern` regex.** Replaced with four orthogonal columns. Mapping rule used by the automated migration and by the `/migrate-from-v1` skill:
|
||||
- v1 `trigger_pattern` non-empty → v2 `engage_mode='pattern'`, `engage_pattern = <the regex>`
|
||||
- v1 `requires_trigger=0` or pattern was `.`/`.*` → v2 `engage_mode='pattern'`, `engage_pattern='.'` (the "always" flavor)
|
||||
- no pattern and requires a trigger → v2 `engage_mode='mention'`
|
||||
- `sender_scope` and `ignored_message_policy` are new; defaults `all` / `drop`
|
||||
- **JID decomposition.** v1's `jid` column stored `dc:12345` / `tg:67890`. v2 splits this into `channel_type` + `platform_id`. Concretely: `dc:12345` becomes `channel_type='discord'`, `platform_id='discord:12345'`. Prefix aliases (`dc` → `discord`, `tg` → `telegram`, `wa` → `whatsapp`) are in `setup/migrate-v2/shared.ts`.
|
||||
- **`channel_name` was unreliable in v1.** Many rows had it empty; the actual channel had to be guessed from the JID prefix. v2's `channel_type` is always explicit.
|
||||
|
||||
---
|
||||
|
||||
## Central DB vs session DBs
|
||||
|
||||
**v1:** one SQLite file at `store/messages.db`. Every chat, message, registered group, scheduled task, and session lived there. Host and any agent processes all opened the same file.
|
||||
|
||||
**v2:** three DB shapes.
|
||||
|
||||
1. `data/v2.db` — **central**. Everything that isn't per-session: users, roles, agent groups, messaging groups, wirings, pending approvals, user DMs, schema migrations.
|
||||
2. `data/v2-sessions/<session_id>/inbound.db` — **host writes, container reads**. `messages_in`, routing, destinations, pending questions, processing_ack. This is where scheduled tasks live (see "Scheduling" below).
|
||||
3. `data/v2-sessions/<session_id>/outbound.db` — **container writes, host reads**. `messages_out`, session_state.
|
||||
|
||||
Exactly one writer per file. No cross-mount lock contention. Heartbeat is a file touch at `/workspace/.heartbeat`, not a DB update. Host uses even `seq` numbers, container uses odd.
|
||||
|
||||
Message history (v1 `messages` table, v1 `chats` table) is **not migrated**. The migration copies operationally important state forward (agents, channels, wirings, scheduled tasks, group folders) and leaves chat logs behind.
|
||||
|
||||
---
|
||||
|
||||
## Scheduling
|
||||
|
||||
**v1:** dedicated `scheduled_tasks` table in `store/messages.db` with its own columns (`schedule_type`, `schedule_value`, `next_run`, `last_run`, `context_mode`, `script`, `status`). A separate cron-ish scheduler process read from it.
|
||||
|
||||
**v2:** scheduled tasks are **`messages_in` rows with `kind='task'`** in a session's `inbound.db`. Relevant columns:
|
||||
- `process_after` (ISO8601) — host sweep wakes the container when `datetime(process_after) <= datetime('now')`
|
||||
- `recurrence` — cron string; `NULL` = one-shot
|
||||
- `series_id` — groups recurring occurrences; set to the task id on first insert
|
||||
- `status` — `pending` | `processing` | `completed` | `failed` | `paused`
|
||||
|
||||
The public API is `insertTask()` in `src/modules/scheduling/db.ts`. Recurrence is computed in the user's TZ via `cron-parser` (see `src/modules/scheduling/recurrence.ts`). The migration maps v1's `schedule_type`+`schedule_value` pair into a single cron string before calling `insertTask()`.
|
||||
|
||||
Tasks can exist before a session is awake — the host sweep creates/wakes the container on the first due tick.
|
||||
|
||||
---
|
||||
|
||||
## Credentials
|
||||
|
||||
**v1:** `.env` — plain environment variables. `DISCORD_BOT_TOKEN`, `ANTHROPIC_API_KEY`, etc. The host read them directly and passed them in to any code that needed them.
|
||||
|
||||
**v2:** OneCLI Agent Vault. A separate local service at `http://127.0.0.1:10254` holds secrets. Agents are *scoped* to specific secrets and the vault injects them into approved API requests as they leave the container. The container never sees the raw secret value.
|
||||
|
||||
Gotcha: auto-created agents default to `selective` secret mode — no secrets attached, even if matching secrets exist in the vault. See the "auto-created agents start in selective secret mode" section of the root CLAUDE.md for the fix (`onecli agents set-secret-mode --mode all`).
|
||||
|
||||
**What the automated migration does:** copies every v1 `.env` key verbatim into v2 `.env`, never overwriting existing v2 keys. The OneCLI vault migration is a separate step owned by the `/init-onecli` skill, which knows how to pull from `.env`.
|
||||
|
||||
---
|
||||
|
||||
## Channel adapters
|
||||
|
||||
**v1:** native adapters (e.g. `discord.js` used directly) imported in `src/channels/`. Installing a channel meant editing code, adding a dependency, and setting env vars.
|
||||
|
||||
**v2:** channel adapters live on a sibling `channels` branch. Each `/add-<channel>` skill:
|
||||
1. `git fetch origin channels`
|
||||
2. `git show channels:src/channels/<name>.ts > src/channels/<name>.ts`
|
||||
3. Appends `import './<name>.js';` to `src/channels/index.ts`
|
||||
4. `pnpm install @chat-adapter/<name>@<pinned>`
|
||||
5. `pnpm run build`
|
||||
|
||||
Idempotent — re-running is a no-op. Pinned versions keep the supply chain honest. The automated migration detects which channels were wired in v1 (via distinct `channel_name` / JID prefix) and runs the matching `setup/install-<channel>.sh` for each. Channels in v1 that don't have a v2 skill (rare now, more common as v2 catches up) are recorded in the handoff file for the `/migrate-from-v1` skill to raise with the user.
|
||||
|
||||
**Channel auth beyond `.env`.** Some channels store session state on disk (Baileys WhatsApp keystore, Matrix sync state, iMessage tokens). The `channel-auth` step has a per-channel registry (`setup/migrate-v2/shared.ts: CHANNEL_AUTH_REGISTRY`) that knows which file globs to copy alongside env keys.
|
||||
|
||||
---
|
||||
|
||||
## Privilege — from implicit to explicit
|
||||
|
||||
**v1:** `registered_groups.is_main = 1` flagged one group as the privileged one. No `users` table. Permissions were conventions, not enforced.
|
||||
|
||||
**v2:** explicit tables.
|
||||
- `users(id = "<channel_type>:<handle>", kind, display_name)` — one row per messaging-platform identifier
|
||||
- `user_roles(user_id, role ∈ {owner, admin}, agent_group_id nullable, granted_by, granted_at)` — owner is always global; admin can be global or scoped
|
||||
- `agent_group_members(user_id, agent_group_id, ...)` — "known" membership for the `sender_scope='known'` gate
|
||||
|
||||
Owner gets seeded during the `/migrate-from-v1` skill's interview phase ("Which handle is you?"). The automated migration doesn't guess — v1 has no source of truth for it.
|
||||
|
||||
**Default access — "anyone can talk to the bot" vs "only known users".** v1 stored this implicitly (via trigger regex + `is_main`). v2 exposes it as `messaging_groups.unknown_sender_policy ∈ {'strict', 'request_approval', 'public'}`. The skill asks the user which mode v1 ran in and flips the migrated messaging groups accordingly.
|
||||
|
||||
---
|
||||
|
||||
## Group folders on disk
|
||||
|
||||
**v1:** `groups/<folder>/CLAUDE.md` and optional `logs/`. `CLAUDE.md` was a plain instruction file, group-specific.
|
||||
|
||||
**v2:** each group still lives at `groups/<folder>/`, but the shape is richer:
|
||||
- `CLAUDE.md` — **composed at container spawn** from `.claude-shared.md` (symlink to global) + `.claude-fragments/*.md` (module fragments) + `CLAUDE.local.md`. **Don't edit `CLAUDE.md` directly.**
|
||||
- `CLAUDE.local.md` — per-group content. The migration writes v1's old `CLAUDE.md` here.
|
||||
- `container.json` — optional per-group container config (apt deps, env, mounts). v1's `registered_groups.container_config` JSON is close but not identical — the migration stores the v1 payload at `groups/<folder>/.v1-container-config.json` for the skill to reconcile, rather than silently mapping it.
|
||||
- `.claude-fragments/` and `.claude-shared.md` are installed by `initGroupFilesystem()` the first time the host touches the group, so the migration only has to write `CLAUDE.local.md` and leave the scaffolding to the host.
|
||||
|
||||
---
|
||||
|
||||
## Host process vs containers
|
||||
|
||||
**v1:** single Node process. The "agent" was the same process as the router.
|
||||
|
||||
**v2:** Node host at top, Bun-runtime Docker container per session. They communicate only via the two session DBs. No shared modules, no IPC, no stdin piping. If you wrote custom code that reached from the agent into host internals (or vice versa), that surface no longer exists — porting it is a `/migrate-from-v1` skill topic, not a mechanical copy.
|
||||
|
||||
Lockfiles: host uses `pnpm-lock.yaml`, agent-runner uses `bun.lock`. `minimumReleaseAge: 4320` on the host side (3-day supply-chain wait); agent-runner has no release-age gate.
|
||||
|
||||
---
|
||||
|
||||
## Self-modification and MCP tools
|
||||
|
||||
**v1:** if you added MCP servers or self-modification plumbing, it was usually direct edits to the long-running process.
|
||||
|
||||
**v2:**
|
||||
- MCP servers register through `container/agent-runner/src/mcp-tools/*.ts` and load per-session. There's also `install_packages` and `add_mcp_server` self-mod tools that go through an admin-approval flow (`src/modules/self-mod/apply.ts`) before rebuilding the container image.
|
||||
- Custom MCP tools you wrote in v1 map cleanly to the v2 tool registry, but the import paths, runtime (Bun vs Node), and SQL helper differences (`bun:sqlite` uses `$name`-prefixed params) may need adjustment. The skill walks through this.
|
||||
|
||||
---
|
||||
|
||||
## Things that are gone or don't map
|
||||
|
||||
- **`scheduled_tasks` as a separate table** — moved into session `inbound.db` under `kind='task'`. Migration ports active rows; inactive/completed are exported to `logs/setup-migration/inactive-tasks.json` for reference.
|
||||
- **`messages` / `chats` tables (chat history)** — not migrated. Stay in the v1 checkout if you need them.
|
||||
- **`router_state` (key/value)** — not migrated. v2 state lives in the explicit tables above.
|
||||
- **`sessions` (v1 group→session_id)** — v1 sessions don't map; v2 sessions are keyed by `(agent_group_id, messaging_group_id, thread_id)` and are created on demand.
|
||||
- **Raw access to the old `store/messages.db`** — the v1 DB is left in place and untouched. If migration goes wrong you can re-run it (the migration sub-steps are idempotent for agents/channels/wirings; folders use rsync semantics).
|
||||
|
||||
---
|
||||
|
||||
## Migration surface — where the code lives
|
||||
|
||||
- `migrate-v2.sh` — entry point: `bash migrate-v2.sh` from the v2 checkout.
|
||||
- `setup/migrate-v2/*.ts` — individual migration steps (env, db, groups, sessions, tasks, channel-auth, select-channels, switchover-prompt).
|
||||
- `setup/migrate-v2/shared.ts` — JID parsing, trigger mapping, channel auth registry.
|
||||
- `logs/setup-migration/handoff.json` — written by `migrate-v2.sh`, read by the `/migrate-from-v1` skill.
|
||||
- `logs/migrate-steps/*.log` — raw per-step stdout.
|
||||
- `.claude/skills/migrate-from-v1/SKILL.md` — Claude skill for owner seeding, CLAUDE.md cleanup, container config validation, fork porting.
|
||||
- `migrate-v2-reset.sh` — development helper to wipe v2 state for re-testing.
|
||||
- See [docs/migration-dev.md](migration-dev.md) for the full development guide.
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# migrate-v2-reset.sh — Wipe v2 migration state back to clean.
|
||||
#
|
||||
# For development iteration:
|
||||
# bash migrate-v2-reset.sh && bash migrate-v2.sh
|
||||
#
|
||||
# What it removes:
|
||||
# - data/ (v2 DBs, session state)
|
||||
# - logs/ (migration + setup logs)
|
||||
# - .env (merged env keys)
|
||||
# - groups/*/ (non-git group folders copied from v1)
|
||||
# - container/skills/*/ (untracked skill dirs copied from v1)
|
||||
# - src/channels/*.ts (untracked adapters copied from channels branch)
|
||||
# - setup/groups.ts (untracked, copied by channel install scripts)
|
||||
#
|
||||
# What it restores from git:
|
||||
# - groups/ (CLAUDE.md files etc.)
|
||||
# - container/skills/ (tracked container skills)
|
||||
# - src/channels/ (tracked bridge / registry code)
|
||||
# - setup/whatsapp-auth.ts (channel installs may overwrite)
|
||||
# - setup/pair-telegram.ts (channel installs may overwrite)
|
||||
# - setup/index.ts (channel installs append entries)
|
||||
# - package.json + pnpm-lock.yaml (channel installs add deps)
|
||||
#
|
||||
# What it does NOT touch:
|
||||
# - node_modules/ (expensive to reinstall, kept on purpose)
|
||||
# - setup/migrate-v2/* (the migration scripts themselves, plus user WIP)
|
||||
# - The v1 install (read-only, never modified)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
use_ansi() { [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; }
|
||||
dim() { use_ansi && printf '\033[2m%s\033[0m' "$1" || printf '%s' "$1"; }
|
||||
green() { use_ansi && printf '\033[32m%s\033[0m' "$1" || printf '%s' "$1"; }
|
||||
|
||||
clean() {
|
||||
local target=$1 label=$2
|
||||
if [ -e "$target" ]; then
|
||||
rm -rf "$target"
|
||||
printf '%s Removed %s\n' "$(green '✓')" "$label"
|
||||
fi
|
||||
}
|
||||
|
||||
echo
|
||||
printf '%s\n\n' "$(dim 'Resetting v2 migration state…')"
|
||||
|
||||
clean "data" "data/"
|
||||
clean "logs" "logs/"
|
||||
clean ".env" ".env"
|
||||
|
||||
# Remove all group folders, then restore the two git-tracked ones
|
||||
if [ -d "groups" ]; then
|
||||
rm -rf groups
|
||||
printf '%s Removed %s\n' "$(green '✓')" "groups/"
|
||||
fi
|
||||
git checkout -- groups/ 2>/dev/null || true
|
||||
printf '%s Restored %s\n' "$(green '✓')" "groups/ from git"
|
||||
|
||||
# Restore container/skills/ to git state (remove v1-copied skills)
|
||||
git checkout -- container/skills/ 2>/dev/null || true
|
||||
# Remove any untracked skill dirs that were copied from v1
|
||||
for d in container/skills/*/; do
|
||||
[ -d "$d" ] || continue
|
||||
if ! git ls-files --error-unmatch "$d" >/dev/null 2>&1; then
|
||||
rm -rf "$d"
|
||||
fi
|
||||
done
|
||||
printf '%s Restored %s\n' "$(green '✓')" "container/skills/ from git"
|
||||
|
||||
# Restore channel code (src/channels/) to git state
|
||||
git checkout -- src/channels/ 2>/dev/null || true
|
||||
# Remove any untracked channel adapters copied in by install-*.sh
|
||||
for f in src/channels/*.ts; do
|
||||
[ -f "$f" ] || continue
|
||||
if ! git ls-files --error-unmatch "$f" >/dev/null 2>&1; then
|
||||
rm -f "$f"
|
||||
fi
|
||||
done
|
||||
printf '%s Restored %s\n' "$(green '✓')" "src/channels/ from git"
|
||||
|
||||
# Restore tracked setup helpers that channel installs overwrite, and
|
||||
# remove the untracked ones they create. Don't blanket-clean setup/
|
||||
# because user WIP (setup/migrate-v2/*) lives there too.
|
||||
git checkout -- setup/whatsapp-auth.ts setup/pair-telegram.ts setup/index.ts 2>/dev/null || true
|
||||
rm -f setup/groups.ts
|
||||
printf '%s Restored %s\n' "$(green '✓')" "setup/ install helpers"
|
||||
|
||||
# Restore package.json + lockfile (channel installs add deps like
|
||||
# @whiskeysockets/baileys). node_modules/ is intentionally kept.
|
||||
git checkout -- package.json pnpm-lock.yaml 2>/dev/null || true
|
||||
printf '%s Restored %s\n' "$(green '✓')" "package.json + pnpm-lock.yaml"
|
||||
|
||||
echo
|
||||
printf '%s\n\n' "$(dim 'Clean. Run: bash migrate-v2.sh')"
|
||||
+742
@@ -0,0 +1,742 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# migrate-v2.sh — Migrate a NanoClaw v1 install into this v2 checkout.
|
||||
#
|
||||
# Run from the v2 directory:
|
||||
# bash migrate-v2.sh
|
||||
#
|
||||
# If you're in Claude Code, exit first or open a separate terminal.
|
||||
#
|
||||
# Finds v1 automatically (sibling directory, or $NANOCLAW_V1_PATH).
|
||||
# Installs prerequisites (Node, pnpm, deps) via the existing setup.sh
|
||||
# bootstrap, then runs the migration steps.
|
||||
#
|
||||
# Idempotent — safe to re-run. Use migrate-v2-reset.sh to wipe v2 state
|
||||
# back to clean for development iteration.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# This script has interactive prompts (channel selection, service switchover)
|
||||
# and streams progress output — it must run in a real terminal, not inside
|
||||
# a tool subprocess (e.g. Claude Code's Bash tool, which collapses output).
|
||||
if ! [ -t 0 ] || ! [ -t 1 ]; then
|
||||
echo "This script requires an interactive terminal."
|
||||
echo ""
|
||||
echo "If you're in Claude Code, exit first or open a separate terminal,"
|
||||
echo "then run:"
|
||||
echo " bash migrate-v2.sh"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
LOGS_DIR="$PROJECT_ROOT/logs"
|
||||
STEPS_DIR="$LOGS_DIR/migrate-steps"
|
||||
MIGRATE_LOG="$LOGS_DIR/migrate-v2.log"
|
||||
|
||||
# Defaults for variables that may not be set if we exit early
|
||||
V1_PATH=""
|
||||
V1_VERSION="unknown"
|
||||
ONECLI_OK=false
|
||||
SERVICE_SWITCHED=false
|
||||
SELECTED_CHANNELS=()
|
||||
ABORTED_AT=""
|
||||
|
||||
# Per-step status tracking. Parallel indexed arrays so this works on
|
||||
# bash 3.2 (macOS default) which has no associative arrays.
|
||||
STEP_NAMES=()
|
||||
STEP_STATUSES=()
|
||||
|
||||
record_step() {
|
||||
STEP_NAMES+=("$1")
|
||||
STEP_STATUSES+=("$2")
|
||||
}
|
||||
|
||||
# Write handoff.json on any exit so the skill can always read it
|
||||
write_handoff() {
|
||||
local handoff_dir="$LOGS_DIR/setup-migration"
|
||||
mkdir -p "$handoff_dir"
|
||||
|
||||
local has_failures=false
|
||||
local i
|
||||
for ((i=0; i<${#STEP_NAMES[@]}; i++)); do
|
||||
[ "${STEP_STATUSES[$i]}" = "failed" ] && has_failures=true
|
||||
done
|
||||
|
||||
local overall="success"
|
||||
$has_failures && overall="partial"
|
||||
[ -n "$ABORTED_AT" ] && overall="failed"
|
||||
|
||||
local steps_json="{"
|
||||
for ((i=0; i<${#STEP_NAMES[@]}; i++)); do
|
||||
local n="${STEP_NAMES[$i]}"
|
||||
local s="${STEP_STATUSES[$i]}"
|
||||
steps_json="${steps_json}\"${n}\": {\"status\": \"${s}\", \"log\": \"logs/migrate-steps/${n}.log\"},"
|
||||
done
|
||||
steps_json="${steps_json%,}}"
|
||||
|
||||
cat > "$handoff_dir/handoff.json" <<HANDOFF_EOF
|
||||
{
|
||||
"version": 1,
|
||||
"started_at": "$(ts_utc)",
|
||||
"v1_path": "$V1_PATH",
|
||||
"v1_version": "$V1_VERSION",
|
||||
"overall_status": "$overall",
|
||||
"aborted_at": "$ABORTED_AT",
|
||||
"source": "migrate-v2.sh",
|
||||
"channels_installed": [$(printf '"%s",' "${SELECTED_CHANNELS[@]}" 2>/dev/null | sed 's/,$//')],
|
||||
"onecli_healthy": $ONECLI_OK,
|
||||
"service_switched": $SERVICE_SWITCHED,
|
||||
"steps": $steps_json,
|
||||
"step_logs_dir": "logs/migrate-steps",
|
||||
"followups": [
|
||||
"Seed owner user and access policy",
|
||||
"Review CLAUDE.local.md files for v1-specific patterns",
|
||||
"Verify container.json mount paths are valid"
|
||||
]
|
||||
}
|
||||
HANDOFF_EOF
|
||||
}
|
||||
|
||||
trap write_handoff EXIT
|
||||
|
||||
abort() {
|
||||
ABORTED_AT="$1"
|
||||
log "ABORTED at $1"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ─── output helpers ──────────────────────────────────────────────────────
|
||||
|
||||
use_ansi() { [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; }
|
||||
dim() { use_ansi && printf '\033[2m%s\033[0m' "$1" || printf '%s' "$1"; }
|
||||
green() { use_ansi && printf '\033[32m%s\033[0m' "$1" || printf '%s' "$1"; }
|
||||
red() { use_ansi && printf '\033[31m%s\033[0m' "$1" || printf '%s' "$1"; }
|
||||
bold() { use_ansi && printf '\033[1m%s\033[0m' "$1" || printf '%s' "$1"; }
|
||||
clear_line() { use_ansi && printf '\r\033[2K' || printf '\n'; }
|
||||
|
||||
step_ok() { printf '%s %s\n' "$(green '✓')" "$1"; }
|
||||
step_fail() { printf '%s %s\n' "$(red '✗')" "$1"; }
|
||||
step_skip() { printf '%s %s\n' "$(dim '–')" "$1"; }
|
||||
step_info() { printf '%s %s\n' "$(dim '·')" "$1"; }
|
||||
|
||||
ts_utc() { date -u +%Y-%m-%dT%H:%M:%SZ; }
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$MIGRATE_LOG"
|
||||
}
|
||||
|
||||
# ─── init logs ───────────────────────────────────────────────────────────
|
||||
|
||||
mkdir -p "$STEPS_DIR"
|
||||
{
|
||||
echo "## $(ts_utc) · migrate-v2.sh started"
|
||||
echo " cwd: $PROJECT_ROOT"
|
||||
echo ""
|
||||
} > "$MIGRATE_LOG"
|
||||
|
||||
echo
|
||||
bold "NanoClaw v1 → v2 migration"
|
||||
echo
|
||||
echo
|
||||
|
||||
# ─── phase 0a: bootstrap prerequisites ──────────────────────────────────
|
||||
|
||||
step_info "Installing prerequisites (Node, pnpm, dependencies)…"
|
||||
|
||||
BOOTSTRAP_RAW="$STEPS_DIR/01-bootstrap.log"
|
||||
export NANOCLAW_BOOTSTRAP_LOG="$BOOTSTRAP_RAW"
|
||||
|
||||
if bash "$PROJECT_ROOT/setup.sh" > "$BOOTSTRAP_RAW" 2>&1; then
|
||||
# Parse the status block from setup.sh output
|
||||
STATUS=$(grep '^STATUS:' "$BOOTSTRAP_RAW" | head -1 | sed 's/^STATUS: *//')
|
||||
NODE_VERSION=$(grep '^NODE_VERSION:' "$BOOTSTRAP_RAW" | head -1 | sed 's/^NODE_VERSION: *//')
|
||||
|
||||
if [ "$STATUS" = "success" ]; then
|
||||
step_ok "Prerequisites ready $(dim "(node $NODE_VERSION)")"
|
||||
log "Bootstrap succeeded: node=$NODE_VERSION"
|
||||
else
|
||||
step_fail "Bootstrap reported: $STATUS"
|
||||
echo
|
||||
dim " See: $BOOTSTRAP_RAW"
|
||||
echo
|
||||
abort "bootstrap"
|
||||
fi
|
||||
else
|
||||
step_fail "Bootstrap failed"
|
||||
echo
|
||||
echo "$(dim '── last 20 lines ──')"
|
||||
tail -20 "$BOOTSTRAP_RAW" 2>/dev/null || true
|
||||
echo
|
||||
dim " Full log: $BOOTSTRAP_RAW"
|
||||
echo
|
||||
abort "bootstrap"
|
||||
fi
|
||||
|
||||
# setup.sh may have installed pnpm to a prefix not on our PATH — replay
|
||||
# the same lookup nanoclaw.sh does.
|
||||
if ! command -v pnpm >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then
|
||||
NPM_PREFIX="$(npm config get prefix 2>/dev/null)"
|
||||
if [ -n "$NPM_PREFIX" ] && [ -x "$NPM_PREFIX/bin/pnpm" ]; then
|
||||
export PATH="$NPM_PREFIX/bin:$PATH"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! command -v pnpm >/dev/null 2>&1; then
|
||||
step_fail "pnpm not found after bootstrap"
|
||||
abort "pnpm-missing"
|
||||
fi
|
||||
|
||||
# ─── phase 0b: find v1 install ──────────────────────────────────────────
|
||||
|
||||
find_v1() {
|
||||
# Explicit override
|
||||
if [ -n "${NANOCLAW_V1_PATH:-}" ]; then
|
||||
if [ -f "$NANOCLAW_V1_PATH/store/messages.db" ]; then
|
||||
echo "$NANOCLAW_V1_PATH"
|
||||
return 0
|
||||
fi
|
||||
step_fail "NANOCLAW_V1_PATH=$NANOCLAW_V1_PATH does not contain store/messages.db"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Scan sibling directories for anything claw-ish with a v1 DB
|
||||
local parent
|
||||
parent="$(dirname "$PROJECT_ROOT")"
|
||||
for entry in "$parent"/*/; do
|
||||
[ -d "$entry" ] || continue
|
||||
# Skip ourselves
|
||||
[ "$(cd "$entry" && pwd)" = "$PROJECT_ROOT" ] && continue
|
||||
# Must have the v1 DB
|
||||
[ -f "$entry/store/messages.db" ] || continue
|
||||
# Must not be v2 (check package.json version)
|
||||
if [ -f "$entry/package.json" ]; then
|
||||
local ver
|
||||
ver=$(grep '"version"' "$entry/package.json" 2>/dev/null | head -1 | sed -E 's/.*"([0-9]+)\..*/\1/')
|
||||
[ "$ver" = "2" ] && continue
|
||||
fi
|
||||
echo "$(cd "$entry" && pwd)"
|
||||
return 0
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
V1_PATH=""
|
||||
if V1_PATH=$(find_v1); then
|
||||
V1_VERSION=$(grep '"version"' "$V1_PATH/package.json" 2>/dev/null | head -1 | sed -E 's/.*"([^"]+)".*/\1/' || echo "unknown")
|
||||
step_ok "Found v1 at $(dim "$V1_PATH") $(dim "(v$V1_VERSION)")"
|
||||
log "v1 found: $V1_PATH (v$V1_VERSION)"
|
||||
else
|
||||
step_fail "No v1 install found"
|
||||
echo
|
||||
echo " $(dim 'Set NANOCLAW_V1_PATH to point at your v1 checkout:')"
|
||||
echo " $(dim 'NANOCLAW_V1_PATH=~/nanoclaw bash migrate-v2.sh')"
|
||||
echo
|
||||
abort "v1-not-found"
|
||||
fi
|
||||
|
||||
# ─── phase 0c: validate v1 DB ───────────────────────────────────────────
|
||||
|
||||
V1_DB="$V1_PATH/store/messages.db"
|
||||
|
||||
# Quick schema check — make sure the tables we need exist.
|
||||
# Uses the in-tree wrapper instead of the sqlite3 CLI: setup.sh (run via
|
||||
# phase 0a above) installs Node + better-sqlite3 but NOT the sqlite3 CLI,
|
||||
# and #2191 documented how a missing CLI here used to surface as a
|
||||
# misleading "registered_groups missing" abort.
|
||||
TABLES=$(pnpm exec tsx scripts/q.ts "$V1_DB" "SELECT name FROM sqlite_master WHERE type='table'" 2>/dev/null || true)
|
||||
|
||||
if echo "$TABLES" | grep -q "registered_groups"; then
|
||||
step_ok "v1 database has registered_groups"
|
||||
else
|
||||
step_fail "v1 database missing registered_groups table"
|
||||
abort "v1-db-invalid"
|
||||
fi
|
||||
|
||||
# Show what we found
|
||||
GROUP_COUNT=$(pnpm exec tsx scripts/q.ts "$V1_DB" "SELECT COUNT(*) FROM registered_groups" 2>/dev/null || echo 0)
|
||||
TASK_COUNT=$(pnpm exec tsx scripts/q.ts "$V1_DB" "SELECT COUNT(*) FROM scheduled_tasks WHERE status='active'" 2>/dev/null || echo 0)
|
||||
ENV_KEYS=0
|
||||
if [ -f "$V1_PATH/.env" ]; then
|
||||
ENV_KEYS=$(grep -c '=' "$V1_PATH/.env" 2>/dev/null || echo 0)
|
||||
fi
|
||||
|
||||
step_info "v1 state: $(bold "$GROUP_COUNT") groups, $(bold "$TASK_COUNT") active tasks, $(bold "$ENV_KEYS") env keys"
|
||||
|
||||
echo
|
||||
step_ok "Phase 0 complete — ready to migrate"
|
||||
echo
|
||||
log "Phase 0 complete: groups=$GROUP_COUNT tasks=$TASK_COUNT env_keys=$ENV_KEYS"
|
||||
|
||||
export NANOCLAW_V1_PATH="$V1_PATH"
|
||||
export NANOCLAW_V2_PATH="$PROJECT_ROOT"
|
||||
|
||||
# ─── run_step helper ─────────────────────────────────────────────────────
|
||||
# Runs a TypeScript migration step, captures output, reports success/failure.
|
||||
|
||||
# Step outcomes are tracked via record_step() into STEP_NAMES/STEP_STATUSES
|
||||
# (defined above, near write_handoff).
|
||||
|
||||
run_step() {
|
||||
local name=$1 label=$2 script=$3
|
||||
shift 3
|
||||
local raw="$STEPS_DIR/${name}.log"
|
||||
|
||||
if pnpm exec tsx "$script" "$@" > "$raw" 2>&1; then
|
||||
local result
|
||||
result=$(grep '^OK:' "$raw" | head -1 || true)
|
||||
step_ok "$label $(dim "$result")"
|
||||
log "$name: $result"
|
||||
record_step "$name" "success"
|
||||
# Surface partial errors (rows skipped due to parse/lookup failures)
|
||||
# even when the step exited successfully — they're easy to miss in the
|
||||
# raw log and have caused silent migrations before.
|
||||
if grep -q '^ERROR:' "$raw" 2>/dev/null; then
|
||||
local err_count
|
||||
err_count=$(grep -c '^ERROR:' "$raw")
|
||||
echo " $(dim "${err_count} error(s) reported — see $raw")"
|
||||
grep '^ERROR:' "$raw" | head -3 | while IFS= read -r line; do
|
||||
echo " $(dim "$line")"
|
||||
done
|
||||
log "$name: ${err_count} non-fatal errors"
|
||||
fi
|
||||
elif grep -q '^SKIPPED:' "$raw" 2>/dev/null; then
|
||||
local reason
|
||||
reason=$(grep '^SKIPPED:' "$raw" | head -1 | sed 's/^SKIPPED://')
|
||||
step_skip "$label $(dim "($reason)")"
|
||||
log "$name: skipped ($reason)"
|
||||
record_step "$name" "skipped"
|
||||
else
|
||||
step_fail "$label"
|
||||
echo
|
||||
tail -10 "$raw" 2>/dev/null | while IFS= read -r line; do
|
||||
echo " $(dim "$line")"
|
||||
done
|
||||
echo
|
||||
log "$name: FAILED (see $raw)"
|
||||
record_step "$name" "failed"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── phase 1: core state ────────────────────────────────────────────────
|
||||
|
||||
echo "$(bold 'Phase 1: Core state')"
|
||||
echo
|
||||
|
||||
run_step "1a-env" \
|
||||
"Merge .env" \
|
||||
"setup/migrate-v2/env.ts" "$V1_PATH"
|
||||
|
||||
run_step "1b-db" \
|
||||
"Seed v2 database" \
|
||||
"setup/migrate-v2/db.ts" "$V1_PATH"
|
||||
|
||||
run_step "1c-groups" \
|
||||
"Copy group folders" \
|
||||
"setup/migrate-v2/groups.ts" "$V1_PATH"
|
||||
|
||||
run_step "1d-sessions" \
|
||||
"Copy session data" \
|
||||
"setup/migrate-v2/sessions.ts" "$V1_PATH"
|
||||
|
||||
run_step "1e-tasks" \
|
||||
"Port scheduled tasks" \
|
||||
"setup/migrate-v2/tasks.ts" "$V1_PATH"
|
||||
|
||||
echo
|
||||
step_ok "Phase 1 complete"
|
||||
echo
|
||||
|
||||
# ─── phase 2: channels (interactive) ────────────────────────────────────
|
||||
|
||||
echo "$(bold 'Phase 2: Channels')"
|
||||
echo
|
||||
|
||||
# Channel selection — clack multiselect (interactive) or NANOCLAW_CHANNELS env var.
|
||||
# NANOCLAW_CHANNELS accepts comma-separated channel names: "telegram,discord"
|
||||
SELECTED_CHANNELS=()
|
||||
CHANNEL_SELECT_OUT="$STEPS_DIR/2a-channels-selected.txt"
|
||||
|
||||
pnpm exec tsx setup/migrate-v2/select-channels.ts "$CHANNEL_SELECT_OUT" || true
|
||||
|
||||
if [ -f "$CHANNEL_SELECT_OUT" ]; then
|
||||
while IFS= read -r ch; do
|
||||
[ -n "$ch" ] && SELECTED_CHANNELS+=("$ch")
|
||||
done < "$CHANNEL_SELECT_OUT"
|
||||
fi
|
||||
|
||||
if [ ${#SELECTED_CHANNELS[@]} -eq 0 ]; then
|
||||
echo
|
||||
step_skip "No channels selected"
|
||||
else
|
||||
echo
|
||||
step_info "Selected: ${SELECTED_CHANNELS[*]}"
|
||||
echo
|
||||
|
||||
# 2b. Copy channel auth state
|
||||
run_step "2b-channel-auth" \
|
||||
"Copy channel credentials" \
|
||||
"setup/migrate-v2/channel-auth.ts" "$V1_PATH" "${SELECTED_CHANNELS[@]}"
|
||||
|
||||
# 2c. Install channel code
|
||||
for ch in "${SELECTED_CHANNELS[@]}"; do
|
||||
INSTALL_SCRIPT="setup/install-${ch}.sh"
|
||||
STEP_NAME="2c-install-${ch}"
|
||||
if [ -f "$INSTALL_SCRIPT" ]; then
|
||||
STEP_LOG="$STEPS_DIR/${STEP_NAME}.log"
|
||||
if bash "$INSTALL_SCRIPT" > "$STEP_LOG" 2>&1; then
|
||||
STATUS_LINE=$(grep '^STATUS:' "$STEP_LOG" | head -1 | sed 's/^STATUS: *//')
|
||||
if [ "$STATUS_LINE" = "already-installed" ]; then
|
||||
step_skip "Install $ch $(dim "(already installed)")"
|
||||
record_step "$STEP_NAME" "skipped"
|
||||
else
|
||||
step_ok "Install $ch"
|
||||
record_step "$STEP_NAME" "success"
|
||||
fi
|
||||
log "install-$ch: $STATUS_LINE"
|
||||
else
|
||||
step_fail "Install $ch"
|
||||
tail -5 "$STEP_LOG" 2>/dev/null | while IFS= read -r line; do
|
||||
echo " $(dim "$line")"
|
||||
done
|
||||
log "install-$ch: FAILED (see $STEP_LOG)"
|
||||
record_step "$STEP_NAME" "failed"
|
||||
fi
|
||||
else
|
||||
step_skip "Install $ch $(dim "(no install script)")"
|
||||
log "install-$ch: no install script"
|
||||
record_step "$STEP_NAME" "failed"
|
||||
fi
|
||||
done
|
||||
|
||||
# 2d. (Removed) WhatsApp LID resolution was previously needed because the
|
||||
# v6 adapter couldn't reliably translate LID→phone JIDs, so the migration
|
||||
# pre-created dual messaging_groups rows. With Baileys v7, the adapter
|
||||
# resolves LIDs via extractAddressingContext + signalRepository.lidMapping
|
||||
# on every inbound message, so dual rows are unnecessary and were causing
|
||||
# split sessions.
|
||||
fi
|
||||
|
||||
echo
|
||||
step_ok "Phase 2 complete"
|
||||
echo
|
||||
|
||||
# ─── phase 3: infrastructure ────────────────────────────────────────────
|
||||
|
||||
echo "$(bold 'Phase 3: Infrastructure')"
|
||||
echo
|
||||
|
||||
# 3a. Docker — install if missing (OneCLI needs it)
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
DOCKER_V=$(docker --version 2>/dev/null | head -1)
|
||||
step_ok "Docker available $(dim "($DOCKER_V)")"
|
||||
log "Docker: $DOCKER_V"
|
||||
else
|
||||
step_info "Installing Docker…"
|
||||
DOCKER_LOG="$STEPS_DIR/3a-docker.log"
|
||||
if bash setup/install-docker.sh > "$DOCKER_LOG" 2>&1; then
|
||||
hash -r 2>/dev/null || true
|
||||
step_ok "Docker installed"
|
||||
record_step "3a-docker" "success"
|
||||
log "Docker: installed"
|
||||
else
|
||||
step_fail "Docker install failed $(dim "(see $DOCKER_LOG)")"
|
||||
record_step "3a-docker" "failed"
|
||||
log "Docker: FAILED"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 3b. OneCLI — detect or install via setup step (requires Docker)
|
||||
ONECLI_OK=false
|
||||
ONECLI_URL_FROM_ENV=$(grep '^ONECLI_URL=' .env 2>/dev/null | head -1 | sed 's/^ONECLI_URL=//')
|
||||
ONECLI_URL_CHECK="${ONECLI_URL_FROM_ENV:-http://127.0.0.1:10254}"
|
||||
|
||||
if curl -sf "${ONECLI_URL_CHECK}/api/health" >/dev/null 2>&1; then
|
||||
step_ok "OneCLI running at $(dim "$ONECLI_URL_CHECK")"
|
||||
ONECLI_OK=true
|
||||
log "OneCLI: running at $ONECLI_URL_CHECK"
|
||||
elif command -v docker >/dev/null 2>&1; then
|
||||
step_info "Setting up OneCLI…"
|
||||
ONECLI_LOG="$STEPS_DIR/3b-onecli.log"
|
||||
ONECLI_ERR="$STEPS_DIR/3b-onecli.err"
|
||||
if pnpm exec tsx setup/index.ts --step onecli > "$ONECLI_LOG" 2>"$ONECLI_ERR"; then
|
||||
step_ok "OneCLI ready"
|
||||
ONECLI_OK=true
|
||||
record_step "3b-onecli" "success"
|
||||
log "OneCLI: installed/configured"
|
||||
else
|
||||
step_fail "OneCLI setup failed $(dim "(see $ONECLI_LOG)")"
|
||||
record_step "3b-onecli" "failed"
|
||||
log "OneCLI: FAILED"
|
||||
fi
|
||||
else
|
||||
step_fail "OneCLI needs Docker $(dim "(install Docker first)")"
|
||||
record_step "3b-onecli" "failed"
|
||||
log "OneCLI: skipped (no Docker)"
|
||||
fi
|
||||
|
||||
# 3c. Anthropic credential — run the auth setup step if no credential found
|
||||
if grep -qE '^(ANTHROPIC_API_KEY|CLAUDE_CODE_OAUTH_TOKEN)=' .env 2>/dev/null; then
|
||||
step_ok "Anthropic credential found in .env"
|
||||
log "Anthropic credential: found in .env"
|
||||
elif [ "$ONECLI_OK" = "true" ]; then
|
||||
step_info "Registering Anthropic credential…"
|
||||
AUTH_LOG="$STEPS_DIR/3c-auth.log"
|
||||
AUTH_ERR="$STEPS_DIR/3c-auth.err"
|
||||
if pnpm exec tsx setup/index.ts --step auth > "$AUTH_LOG" 2>"$AUTH_ERR"; then
|
||||
step_ok "Anthropic credential registered"
|
||||
record_step "3c-auth" "success"
|
||||
log "Anthropic credential: registered via auth step"
|
||||
else
|
||||
step_fail "Auth setup failed $(dim "(see $AUTH_LOG)")"
|
||||
record_step "3c-auth" "failed"
|
||||
log "Anthropic credential: FAILED"
|
||||
fi
|
||||
else
|
||||
step_info "No Anthropic credential $(dim "(OneCLI not available — add manually to .env)")"
|
||||
log "Anthropic credential: skipped (no OneCLI)"
|
||||
fi
|
||||
|
||||
# 3d. Copy container skills from v1 that v2 doesn't have
|
||||
V1_SKILLS_DIR="$V1_PATH/container/skills"
|
||||
V2_SKILLS_DIR="$PROJECT_ROOT/container/skills"
|
||||
|
||||
if [ -d "$V1_SKILLS_DIR" ]; then
|
||||
SKILLS_COPIED=0
|
||||
SKILLS_SKIPPED=0
|
||||
for skill_dir in "$V1_SKILLS_DIR"/*/; do
|
||||
[ -d "$skill_dir" ] || continue
|
||||
skill_name=$(basename "$skill_dir")
|
||||
if [ -d "$V2_SKILLS_DIR/$skill_name" ]; then
|
||||
SKILLS_SKIPPED=$((SKILLS_SKIPPED + 1))
|
||||
else
|
||||
cp -r "$skill_dir" "$V2_SKILLS_DIR/$skill_name"
|
||||
SKILLS_COPIED=$((SKILLS_COPIED + 1))
|
||||
fi
|
||||
done
|
||||
if [ $SKILLS_COPIED -gt 0 ]; then
|
||||
step_ok "Copied $SKILLS_COPIED container skills $(dim "(skipped $SKILLS_SKIPPED already in v2)")"
|
||||
else
|
||||
step_skip "All v1 container skills already in v2 $(dim "($SKILLS_SKIPPED)")"
|
||||
fi
|
||||
log "Container skills: copied=$SKILLS_COPIED skipped=$SKILLS_SKIPPED"
|
||||
else
|
||||
step_skip "No v1 container skills"
|
||||
fi
|
||||
|
||||
# 3e. Build agent container image
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
step_info "Building agent container image…"
|
||||
BUILD_LOG="$STEPS_DIR/3e-container-build.log"
|
||||
if bash container/build.sh > "$BUILD_LOG" 2>&1; then
|
||||
step_ok "Container image built"
|
||||
record_step "3e-build" "success"
|
||||
log "Container build: success"
|
||||
else
|
||||
step_fail "Container build failed"
|
||||
record_step "3e-build" "failed"
|
||||
tail -10 "$BUILD_LOG" 2>/dev/null | while IFS= read -r line; do
|
||||
echo " $(dim "$line")"
|
||||
done
|
||||
log "Container build: FAILED (see $BUILD_LOG)"
|
||||
fi
|
||||
else
|
||||
step_fail "Docker not available — cannot build container"
|
||||
record_step "3e-build" "failed"
|
||||
log "Container build: skipped (no Docker)"
|
||||
fi
|
||||
|
||||
echo
|
||||
step_ok "Phase 3 complete"
|
||||
echo
|
||||
|
||||
# ─── service switchover ─────────────────────────────────────────────────
|
||||
|
||||
echo "$(bold 'Service switchover')"
|
||||
echo
|
||||
|
||||
# Disable the v1 service so it doesn't auto-start, but leave the unit file
|
||||
# on disk so the user can rollback with: systemctl --user start nanoclaw
|
||||
# Idempotent — safe to call multiple times.
|
||||
disable_v1_service() {
|
||||
if [ "$PLATFORM_SERVICE" = "systemd" ]; then
|
||||
local v1_file="$HOME/.config/systemd/user/${V1_SERVICE}.service"
|
||||
if [ -f "$v1_file" ] || [ -L "$v1_file" ]; then
|
||||
systemctl --user stop "$V1_SERVICE" 2>/dev/null || true
|
||||
systemctl --user disable "$V1_SERVICE" 2>/dev/null || true
|
||||
step_ok "Disabled $V1_SERVICE (unit file kept for rollback)"
|
||||
fi
|
||||
elif [ "$PLATFORM_SERVICE" = "launchd" ]; then
|
||||
local v1_plist="$HOME/Library/LaunchAgents/${V1_SERVICE}.plist"
|
||||
if [ -f "$v1_plist" ] || [ -L "$v1_plist" ]; then
|
||||
launchctl unload "$v1_plist" 2>/dev/null || true
|
||||
step_ok "Unloaded $V1_SERVICE (plist kept for rollback)"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Detect platform and service names
|
||||
V1_SERVICE=""
|
||||
V2_SERVICE=""
|
||||
PLATFORM_SERVICE=""
|
||||
|
||||
if [ "$(uname -s)" = "Darwin" ]; then
|
||||
PLATFORM_SERVICE="launchd"
|
||||
V1_SERVICE="com.nanoclaw"
|
||||
# v2 uses install-slug for unique service names
|
||||
V2_SERVICE=$(pnpm exec tsx -e "import{getLaunchdLabel}from'./src/install-slug.js';console.log(getLaunchdLabel())" 2>/dev/null || echo "")
|
||||
elif [ "$(uname -s)" = "Linux" ]; then
|
||||
PLATFORM_SERVICE="systemd"
|
||||
V1_SERVICE="nanoclaw"
|
||||
V2_SERVICE=$(pnpm exec tsx -e "import{getSystemdUnit}from'./src/install-slug.js';console.log(getSystemdUnit())" 2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
# Check if v1 service is running
|
||||
V1_RUNNING=false
|
||||
if [ "$PLATFORM_SERVICE" = "systemd" ]; then
|
||||
systemctl --user is-active "$V1_SERVICE" >/dev/null 2>&1 && V1_RUNNING=true
|
||||
elif [ "$PLATFORM_SERVICE" = "launchd" ]; then
|
||||
launchctl list "$V1_SERVICE" >/dev/null 2>&1 && V1_RUNNING=true
|
||||
fi
|
||||
|
||||
SERVICE_SWITCHED=false
|
||||
if [ "$V1_RUNNING" = "true" ]; then
|
||||
step_info "v1 service is running $(dim "($V1_SERVICE)")"
|
||||
|
||||
# Ask user if they want to switch
|
||||
SWITCH_ANSWER_FILE=$(mktemp)
|
||||
pnpm exec tsx setup/migrate-v2/switchover-prompt.ts --offer-switch "$SWITCH_ANSWER_FILE" || true
|
||||
SWITCH_ANSWER=$(cat "$SWITCH_ANSWER_FILE" 2>/dev/null || echo "skip")
|
||||
rm -f "$SWITCH_ANSWER_FILE"
|
||||
|
||||
if [ "$SWITCH_ANSWER" = "switch" ]; then
|
||||
# Stop v1
|
||||
if [ "$PLATFORM_SERVICE" = "systemd" ]; then
|
||||
systemctl --user stop "$V1_SERVICE" 2>/dev/null && step_ok "Stopped v1 service" || step_fail "Could not stop v1"
|
||||
elif [ "$PLATFORM_SERVICE" = "launchd" ]; then
|
||||
launchctl unload ~/Library/LaunchAgents/${V1_SERVICE}.plist 2>/dev/null && step_ok "Stopped v1 service" || step_fail "Could not stop v1"
|
||||
fi
|
||||
|
||||
# Install and start v2 service
|
||||
V2_SERVICE_LOG="$STEPS_DIR/service-install.log"
|
||||
V2_SERVICE_ERR="$STEPS_DIR/service-install.err"
|
||||
if pnpm exec tsx setup/index.ts --step service > "$V2_SERVICE_LOG" 2>"$V2_SERVICE_ERR"; then
|
||||
# Parse the actual unit name from the service step stdout (clean, no ANSI)
|
||||
if [ "$PLATFORM_SERVICE" = "systemd" ]; then
|
||||
V2_SERVICE=$(grep '^SERVICE_UNIT:' "$V2_SERVICE_LOG" | head -1 | sed 's/^SERVICE_UNIT: *//')
|
||||
elif [ "$PLATFORM_SERVICE" = "launchd" ]; then
|
||||
V2_SERVICE=$(grep '^SERVICE_LABEL:' "$V2_SERVICE_LOG" | head -1 | sed 's/^SERVICE_LABEL: *//')
|
||||
fi
|
||||
step_ok "v2 service installed and started $(dim "($V2_SERVICE)")"
|
||||
else
|
||||
step_fail "Could not start v2 service $(dim "(see $V2_SERVICE_LOG)")"
|
||||
fi
|
||||
|
||||
SERVICE_SWITCHED=true
|
||||
echo
|
||||
step_info "v2 is running — send a test message to your bot"
|
||||
echo
|
||||
|
||||
# Ask: keep or revert?
|
||||
KEEP_ANSWER_FILE=$(mktemp)
|
||||
pnpm exec tsx setup/migrate-v2/switchover-prompt.ts --keep-or-revert "$KEEP_ANSWER_FILE" || true
|
||||
KEEP_ANSWER=$(cat "$KEEP_ANSWER_FILE" 2>/dev/null || echo "keep")
|
||||
rm -f "$KEEP_ANSWER_FILE"
|
||||
|
||||
if [ "$KEEP_ANSWER" = "revert" ]; then
|
||||
# Stop v2
|
||||
if [ "$PLATFORM_SERVICE" = "systemd" ] && [ -n "$V2_SERVICE" ]; then
|
||||
systemctl --user stop "$V2_SERVICE" 2>/dev/null || true
|
||||
systemctl --user disable "$V2_SERVICE" 2>/dev/null || true
|
||||
elif [ "$PLATFORM_SERVICE" = "launchd" ] && [ -n "$V2_SERVICE" ]; then
|
||||
launchctl unload ~/Library/LaunchAgents/${V2_SERVICE}.plist 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Restart v1
|
||||
if [ "$PLATFORM_SERVICE" = "systemd" ]; then
|
||||
systemctl --user start "$V1_SERVICE" 2>/dev/null || true
|
||||
elif [ "$PLATFORM_SERVICE" = "launchd" ]; then
|
||||
launchctl load ~/Library/LaunchAgents/${V1_SERVICE}.plist 2>/dev/null || true
|
||||
fi
|
||||
|
||||
step_ok "Reverted to v1 service"
|
||||
SERVICE_SWITCHED=false
|
||||
else
|
||||
step_ok "Keeping v2 service"
|
||||
disable_v1_service
|
||||
fi
|
||||
else
|
||||
step_skip "Service switchover skipped"
|
||||
fi
|
||||
else
|
||||
step_skip "v1 service not running — nothing to switch"
|
||||
disable_v1_service
|
||||
fi
|
||||
|
||||
echo
|
||||
|
||||
# ─── phase 4: handoff ───────────────────────────────────────────────────
|
||||
# handoff.json is written by the EXIT trap (write_handoff) — always, even on
|
||||
# abort. Here we just print the summary.
|
||||
|
||||
echo "$(bold 'Phase 4: Handoff')"
|
||||
echo
|
||||
|
||||
step_ok "Wrote handoff summary"
|
||||
|
||||
# Summary
|
||||
echo
|
||||
echo "$(bold '── Migration complete ──')"
|
||||
echo
|
||||
echo " $(dim 'v1:') $V1_PATH"
|
||||
echo " $(dim 'v2:') $PROJECT_ROOT"
|
||||
echo
|
||||
echo " $(bold 'What was done:')"
|
||||
echo " $(green '✓') .env keys merged"
|
||||
echo " $(green '✓') Database seeded (agent groups, messaging groups, wiring)"
|
||||
echo " $(green '✓') Group folders copied (CLAUDE.md → CLAUDE.local.md)"
|
||||
echo " $(green '✓') Session data copied"
|
||||
echo " $(green '✓') Scheduled tasks ported"
|
||||
if [ ${#SELECTED_CHANNELS[@]} -gt 0 ]; then
|
||||
echo " $(green '✓') Channels installed: ${SELECTED_CHANNELS[*]}"
|
||||
fi
|
||||
echo " $(green '✓') Container skills copied"
|
||||
echo " $(green '✓') Container image built"
|
||||
if [ "$SERVICE_SWITCHED" = "true" ] && [ -n "$V2_SERVICE" ]; then
|
||||
echo " $(green '✓') Service switched to v2 $(dim "($V2_SERVICE)")"
|
||||
echo
|
||||
echo " $(bold 'Rollback to v1:')"
|
||||
if [ "$PLATFORM_SERVICE" = "systemd" ]; then
|
||||
echo " $(dim '$') systemctl --user stop $V2_SERVICE && systemctl --user start $V1_SERVICE"
|
||||
elif [ "$PLATFORM_SERVICE" = "launchd" ]; then
|
||||
echo " $(dim '$') launchctl unload ~/Library/LaunchAgents/${V2_SERVICE}.plist && launchctl load ~/Library/LaunchAgents/${V1_SERVICE}.plist"
|
||||
fi
|
||||
fi
|
||||
echo
|
||||
echo " $(bold 'What still needs a human:')"
|
||||
if [ "$ONECLI_OK" = "false" ]; then
|
||||
echo " $(dim '·') Set up OneCLI: pnpm exec tsx setup/index.ts --step onecli"
|
||||
fi
|
||||
if ! grep -qE '^(ANTHROPIC_API_KEY|CLAUDE_CODE_OAUTH_TOKEN)=' .env 2>/dev/null; then
|
||||
echo " $(dim '·') Add Anthropic credential to .env or OneCLI vault"
|
||||
fi
|
||||
echo " $(dim '·') Run $(bold '/migrate-from-v1') in Claude to finish:"
|
||||
echo " $(dim '- Seed your owner account')"
|
||||
echo " $(dim '- Set access policies')"
|
||||
echo " $(dim '- Port any custom v1 code')"
|
||||
echo
|
||||
echo " $(dim "Handoff: $LOGS_DIR/setup-migration/handoff.json")"
|
||||
echo " $(dim "Full log: $MIGRATE_LOG")"
|
||||
echo " $(dim "Step logs: $STEPS_DIR/")"
|
||||
echo
|
||||
|
||||
# ─── hand off to Claude ─────────────────────────────────────────────────
|
||||
|
||||
if command -v claude >/dev/null 2>&1; then
|
||||
write_handoff
|
||||
trap - EXIT
|
||||
exec claude "/migrate-from-v1"
|
||||
fi
|
||||
+117
-7
@@ -129,10 +129,123 @@ rm -f "$PROGRESS_LOG"
|
||||
mkdir -p "$STEPS_DIR" "$LOGS_DIR"
|
||||
write_header
|
||||
|
||||
# NanoClaw wordmark — clack's intro carries the "let's get you set up" framing,
|
||||
# so we don't print a subtitle here. setup:auto sees NANOCLAW_BOOTSTRAPPED=1 and
|
||||
# skips re-printing the wordmark, keeping the flow visually continuous.
|
||||
printf '\n %s%s\n\n' "$(bold 'Nano')" "$(brand_bold 'Claw')"
|
||||
# NanoClaw splash — under-the-sea lobster mascot in truecolor braille,
|
||||
# with the figlet wordmark and taglines below. Pre-rendered into
|
||||
# assets/setup-splash.txt (built from assets/nanoclaw-icon.png via chafa +
|
||||
# figlet); the bash script just streams the literal frame. clack's intro
|
||||
# then carries the "let's get you set up" framing — setup:auto sees
|
||||
# NANOCLAW_BOOTSTRAPPED=1 and skips re-printing the wordmark.
|
||||
cat "$PROJECT_ROOT/assets/setup-splash.txt"
|
||||
|
||||
# ─── pre-flight: minimum hardware specs ────────────────────────────────
|
||||
# NanoClaw runs an agent container per session. Below this threshold the
|
||||
# host + container + agent will struggle (OOM under load). Soft warn — the
|
||||
# user can override.
|
||||
|
||||
# RAM floor is set below 4 GB because "4 GB" VMs typically report 3700–3900 MB
|
||||
# after kernel reserves (e.g. Hetzner CX21 ≈ 3814, AWS t3.medium ≈ 3800).
|
||||
MIN_MEM_MB=3700
|
||||
|
||||
detect_mem_mb() {
|
||||
case "$(uname -s)" in
|
||||
Linux)
|
||||
awk '/^MemTotal:/ {printf "%d", $2 / 1024}' /proc/meminfo 2>/dev/null
|
||||
;;
|
||||
Darwin)
|
||||
local bytes
|
||||
bytes=$(sysctl -n hw.memsize 2>/dev/null || echo 0)
|
||||
echo $(( bytes / 1024 / 1024 ))
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
MEM_MB=$(detect_mem_mb)
|
||||
: "${MEM_MB:=0}"
|
||||
|
||||
LOW_MEM=false
|
||||
[ "$MEM_MB" -gt 0 ] && [ "$MEM_MB" -lt "$MIN_MEM_MB" ] && LOW_MEM=true
|
||||
|
||||
if [ "$LOW_MEM" = true ]; then
|
||||
printf ' %s\n' "$(red 'Warning: this machine likely cannot run NanoClaw.')"
|
||||
printf ' %s\n' "$(dim 'NanoClaw recommends a 4 GB+ RAM machine. Below this, the host + agent')"
|
||||
printf ' %s\n' "$(dim 'container will run out of memory under most workloads. A stronger')"
|
||||
printf ' %s\n' "$(dim 'machine is strongly recommended.')"
|
||||
printf ' %s\n' "$(dim " · Detected RAM: ${MEM_MB} MB")"
|
||||
printf '\n'
|
||||
read -r -p " $(bold 'Try anyway?') [y/N] " SPECS_ANS </dev/tty
|
||||
|
||||
case "${SPECS_ANS:-N}" in
|
||||
[Yy]*)
|
||||
ph_event setup_low_specs_continued mem_mb="$MEM_MB" low_mem="$LOW_MEM"
|
||||
printf '\n'
|
||||
;;
|
||||
*)
|
||||
ph_event setup_low_specs_aborted mem_mb="$MEM_MB" low_mem="$LOW_MEM"
|
||||
printf '\n %s\n\n' "$(dim 'Aborted. Re-run after upgrading the host.')"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ─── pre-flight: Google Cloud VM warning (Linux) ──────────────────────
|
||||
# NanoClaw is known to not run reliably on Google Compute Engine instances.
|
||||
# Warn early — before the root check or bootstrap spinner — so users can
|
||||
# switch providers before sinking time into setup. Detection uses DMI
|
||||
# (no network round-trip), which on GCE reports "Google" / "Google
|
||||
# Compute Engine".
|
||||
if [ "$(uname -s)" = "Linux" ] \
|
||||
&& { grep -qi 'Google' /sys/class/dmi/id/product_name 2>/dev/null \
|
||||
|| grep -qi 'Google' /sys/class/dmi/id/sys_vendor 2>/dev/null; }; then
|
||||
printf ' %s\n' "$(red 'Warning: Google Cloud VM detected.')"
|
||||
printf ' %s\n' "$(dim 'Google blocks sudo commands, so NanoClaw is unlikely to run successfully on this VM.')"
|
||||
printf ' %s\n\n' "$(dim 'If you want to run NanoClaw successfully, switch to a different provider (Hetzner, Hostinger, exe.dev and others..).')"
|
||||
read -r -p " $(bold 'Try anyway?') [y/N] " GCE_ANS </dev/tty
|
||||
|
||||
case "${GCE_ANS:-N}" in
|
||||
[Yy]*)
|
||||
ph_event setup_gce_continued
|
||||
printf '\n'
|
||||
;;
|
||||
*)
|
||||
ph_event setup_gce_aborted
|
||||
printf '\n %s\n\n' "$(dim 'Aborted. Re-run on a non-GCE host to continue.')"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ─── pre-flight: root user warning (Linux) ────────────────────────────
|
||||
if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then
|
||||
printf ' %s\n' \
|
||||
"$(red 'Warning: you are running as root.')"
|
||||
printf ' %s\n' \
|
||||
"$(dim "Running NanoClaw as root is not recommended. It can cause permission")"
|
||||
printf ' %s\n\n' \
|
||||
"$(dim "issues with containers, services, and file ownership.")"
|
||||
printf ' %s\n' "$(bold '1)') $(dim 'Show me instructions for creating a new Linux user')"
|
||||
printf ' %s\n\n' "$(bold '2)') $(dim 'Continue setting up NanoClaw as root user (not recommended)')"
|
||||
read -r -p " $(bold 'Choose [1/2]: ')" ROOT_ANS </dev/tty
|
||||
|
||||
case "${ROOT_ANS:-1}" in
|
||||
2)
|
||||
ph_event setup_root_continued
|
||||
printf '\n'
|
||||
;;
|
||||
*)
|
||||
ph_event setup_root_aborted
|
||||
printf '\n %s\n' "$(bold 'To set up a regular user (via SSH):')"
|
||||
printf ' %s\n\n' "$(dim 'Not using SSH? Refer to your hosting provider docs or ask your coding agent to help you set up SSH access.')"
|
||||
printf ' %s\n' "$(dim '1. Create a new user: adduser nanoclaw')"
|
||||
printf ' %s\n' "$(dim '2. Add to sudo group: usermod -aG sudo nanoclaw')"
|
||||
printf ' %s\n' "$(dim '3. Enable passwordless sudo: echo "nanoclaw ALL=(ALL) NOPASSWD:ALL" | tee /etc/sudoers.d/nanoclaw')"
|
||||
printf ' %s\n' "$(dim '4. Log out: exit')"
|
||||
printf ' %s\n' "$(dim '5. Log back in as the new user: ssh nanoclaw@your-server')"
|
||||
printf ' %s\n' "$(dim '6. Clone the repo: git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw')"
|
||||
printf ' %s\n\n' "$(dim '7. Re-run setup: bash nanoclaw.sh')"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ─── pre-flight: Homebrew on macOS ─────────────────────────────────────
|
||||
# setup/install-node.sh and setup/install-docker.sh both require `brew` on
|
||||
@@ -188,9 +301,6 @@ BOOTSTRAP_RAW="${STEPS_DIR}/01-bootstrap.log"
|
||||
BOOTSTRAP_LABEL="Installing the basics"
|
||||
BOOTSTRAP_START=$(date +%s)
|
||||
|
||||
# One-line "why" that teaches a differentiator while the user waits.
|
||||
printf '%s %s\n' "$(gray '│')" \
|
||||
"$(dim "Small. Runs on your machine. Yours to modify.")"
|
||||
spinner_start "$BOOTSTRAP_LABEL"
|
||||
|
||||
# Run in the background so we can tick elapsed time. Capture exit code via
|
||||
|
||||
+6
-2
@@ -1,10 +1,13 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.54",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"ncl": "bin/ncl"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
@@ -16,6 +19,7 @@
|
||||
"prepare": "husky",
|
||||
"setup": "tsx setup/index.ts",
|
||||
"setup:auto": "tsx setup/auto.ts",
|
||||
"ncl": "tsx src/cli/client.ts",
|
||||
"chat": "tsx scripts/chat.ts",
|
||||
"auth": "tsx src/whatsapp-auth.ts",
|
||||
"lint": "eslint src/",
|
||||
@@ -26,7 +30,7 @@
|
||||
"dependencies": {
|
||||
"@clack/core": "^1.2.0",
|
||||
"@clack/prompts": "^1.2.0",
|
||||
"@onecli-sh/sdk": "^0.3.1",
|
||||
"@onecli-sh/sdk": "^0.5.0",
|
||||
"better-sqlite3": "11.10.0",
|
||||
"chat": "^4.24.0",
|
||||
"cron-parser": "5.5.0",
|
||||
|
||||
Generated
+5
-5
@@ -15,8 +15,8 @@ importers:
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0
|
||||
'@onecli-sh/sdk':
|
||||
specifier: ^0.3.1
|
||||
version: 0.3.1
|
||||
specifier: ^0.5.0
|
||||
version: 0.5.0
|
||||
better-sqlite3:
|
||||
specifier: 11.10.0
|
||||
version: 11.10.0
|
||||
@@ -303,8 +303,8 @@ packages:
|
||||
'@emnapi/core': ^1.7.1
|
||||
'@emnapi/runtime': ^1.7.1
|
||||
|
||||
'@onecli-sh/sdk@0.3.1':
|
||||
resolution: {integrity: sha512-oMSa4DUCVS52vec41nFOg3XdCBTbMVEZdCFCsaUd9sRXVorCPWd3VyZq4giXsmk4g09DA/zLjsnrY7l6G94Ulg==}
|
||||
'@onecli-sh/sdk@0.5.0':
|
||||
resolution: {integrity: sha512-oe5Yx9o98v6N1PgzcCR7nULHHqcqKWNJIDOHGOSNX+l20mLlZpFUqfKPeFmsojBNRQMoqbvZQKUlFMp6gVuYBA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@oxc-project/types@0.124.0':
|
||||
@@ -1665,7 +1665,7 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.10.1
|
||||
optional: true
|
||||
|
||||
'@onecli-sh/sdk@0.3.1': {}
|
||||
'@onecli-sh/sdk@0.5.0': {}
|
||||
|
||||
'@oxc-project/types@0.124.0': {}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="133k tokens, 66% of context window">
|
||||
<title>133k tokens, 66% of context window</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="174k tokens, 87% of context window">
|
||||
<title>174k tokens, 87% of context window</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -10,13 +10,13 @@
|
||||
<a xlink:href="https://github.com/qwibitai/nanoclaw/tree/main/repo-tokens">
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="52" height="20" fill="#555"/>
|
||||
<rect x="52" width="38" height="20" fill="#dfb317"/>
|
||||
<rect x="52" width="38" height="20" fill="#e05d44"/>
|
||||
<rect width="90" height="20" fill="url(#s)"/>
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||
<text x="26" y="14">tokens</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">133k</text>
|
||||
<text x="71" y="14">133k</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">174k</text>
|
||||
<text x="71" y="14">174k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
+2
-2
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* nc — chat with your NanoClaw agent from the terminal.
|
||||
* ncl — chat with your NanoClaw agent from the terminal.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm run chat <message...>
|
||||
@@ -36,7 +36,7 @@ function main(): void {
|
||||
const e = err as NodeJS.ErrnoException;
|
||||
if (e.code === 'ENOENT' || e.code === 'ECONNREFUSED') {
|
||||
console.error(`NanoClaw daemon not reachable at ${socketPath()}.`);
|
||||
console.error('Start the service (launchctl/systemd) before running nc.');
|
||||
console.error('Start the service (launchctl/systemd) before running ncl.');
|
||||
} else {
|
||||
console.error('CLI socket error:', err);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Delete the scratch CLI agent created during setup's ping-pong test.
|
||||
*
|
||||
* Dynamically finds and removes all rows referencing the agent group
|
||||
* (any table with an agent_group_id column), deletes the agent group
|
||||
* itself, and removes the groups/<folder>/ directory. Leaves the CLI
|
||||
* messaging group intact so it can be reused for a new agent.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm exec tsx scripts/delete-cli-agent.ts --folder <folder-name>
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR } from '../src/config.js';
|
||||
import { getAgentGroupByFolder, deleteAgentGroup } from '../src/db/agent-groups.js';
|
||||
import { initDb } from '../src/db/connection.js';
|
||||
import { runMigrations } from '../src/db/migrations/index.js';
|
||||
|
||||
interface Args {
|
||||
folder: string;
|
||||
}
|
||||
|
||||
function parseArgs(): Args {
|
||||
const argv = process.argv.slice(2);
|
||||
let folder = '';
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i] === '--folder' && argv[i + 1]) folder = argv[++i];
|
||||
}
|
||||
if (!folder) {
|
||||
console.error('usage: pnpm exec tsx scripts/delete-cli-agent.ts --folder <folder-name>');
|
||||
process.exit(1);
|
||||
}
|
||||
return { folder };
|
||||
}
|
||||
|
||||
const args = parseArgs();
|
||||
|
||||
const db = initDb(path.join(DATA_DIR, 'v2.db'));
|
||||
runMigrations(db);
|
||||
|
||||
const ag = getAgentGroupByFolder(args.folder);
|
||||
if (!ag) {
|
||||
console.log(`No agent group with folder "${args.folder}" — nothing to delete.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const cleanup = db.transaction(() => {
|
||||
const tables = db
|
||||
.prepare(
|
||||
`SELECT DISTINCT m.name FROM sqlite_master m
|
||||
JOIN pragma_table_info(m.name) p ON p.name = 'agent_group_id'
|
||||
WHERE m.type = 'table' AND m.name != 'agent_groups'`,
|
||||
)
|
||||
.all() as { name: string }[];
|
||||
for (const { name } of tables) {
|
||||
db.prepare(`DELETE FROM ${name} WHERE agent_group_id = ?`).run(ag.id);
|
||||
}
|
||||
deleteAgentGroup(ag.id);
|
||||
});
|
||||
cleanup();
|
||||
|
||||
// Remove the groups/<folder>/ directory.
|
||||
const groupDir = path.join(process.cwd(), 'groups', args.folder);
|
||||
if (fs.existsSync(groupDir)) {
|
||||
fs.rmSync(groupDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Remove session data on disk.
|
||||
const sessionsDir = path.join(DATA_DIR, 'v2-sessions', ag.id);
|
||||
if (fs.existsSync(sessionsDir)) {
|
||||
fs.rmSync(sessionsDir, { recursive: true });
|
||||
}
|
||||
|
||||
console.log(`Deleted agent group ${ag.id} (${args.folder}).`);
|
||||
@@ -41,11 +41,13 @@ const CLI_SYNTHETIC_USER_ID = `${CLI_CHANNEL}:${CLI_PLATFORM_ID}`;
|
||||
interface Args {
|
||||
displayName: string;
|
||||
agentName: string;
|
||||
folder?: string;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Args {
|
||||
let displayName: string | undefined;
|
||||
let agentName: string | undefined;
|
||||
let folder: string | undefined;
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const key = argv[i];
|
||||
const val = argv[i + 1];
|
||||
@@ -55,6 +57,9 @@ function parseArgs(argv: string[]): Args {
|
||||
} else if (key === '--agent-name') {
|
||||
agentName = val;
|
||||
i++;
|
||||
} else if (key === '--folder') {
|
||||
folder = val;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +72,7 @@ function parseArgs(argv: string[]): Args {
|
||||
return {
|
||||
displayName,
|
||||
agentName: agentName?.trim() || displayName,
|
||||
folder,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,7 +101,7 @@ async function main(): Promise<void> {
|
||||
const promotedToOwner = false;
|
||||
|
||||
// 2. Agent group + filesystem.
|
||||
const folder = `cli-with-${normalizeName(args.displayName)}`;
|
||||
const folder = args.folder || `cli-with-${normalizeName(args.displayName)}`;
|
||||
let ag: AgentGroup | undefined = getAgentGroupByFolder(folder);
|
||||
if (!ag) {
|
||||
const agId = generateId('ag');
|
||||
|
||||
@@ -47,6 +47,7 @@ import { normalizeName } from '../src/modules/agent-to-agent/db/agent-destinatio
|
||||
import { addMember } from '../src/modules/permissions/db/agent-group-members.js';
|
||||
import { getUserRoles, grantRole } from '../src/modules/permissions/db/user-roles.js';
|
||||
import { upsertUser } from '../src/modules/permissions/db/users.js';
|
||||
import { updateContainerConfigScalars } from '../src/db/container-configs.js';
|
||||
import { initGroupFilesystem } from '../src/group-init.js';
|
||||
import { namespacedPlatformId } from '../src/platform-id.js';
|
||||
import type { AgentGroup, MessagingGroup } from '../src/types.js';
|
||||
@@ -231,6 +232,8 @@ async function main(): Promise<void> {
|
||||
granted_at: now,
|
||||
});
|
||||
}
|
||||
// Owner's agent group gets global CLI access
|
||||
updateContainerConfigScalars(ag.id, { cli_scope: 'global' });
|
||||
} else if (args.role === 'admin') {
|
||||
const alreadyAdmin = existingRoles.some(
|
||||
(r) => r.role === 'admin' && r.agent_group_id === ag.id,
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
/**
|
||||
* Smoke tests for the q.ts sqlite-CLI replacement wrapper.
|
||||
*
|
||||
* Verifies the two modes (SELECT prints rows in sqlite3 default "list"
|
||||
* format; mutation runs via db.exec) and a few edge cases that real
|
||||
* skill invocations rely on.
|
||||
*/
|
||||
|
||||
const Q = path.resolve(__dirname, 'q.ts');
|
||||
|
||||
describe('scripts/q.ts', () => {
|
||||
let tempDir: string;
|
||||
let dbPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'q-test-'));
|
||||
dbPath = path.join(tempDir, 'test.db');
|
||||
const db = new Database(dbPath);
|
||||
db.exec(`
|
||||
CREATE TABLE t (id INTEGER, name TEXT, note TEXT);
|
||||
INSERT INTO t (id, name, note) VALUES (1, 'alice', 'hi'), (2, 'bob', NULL);
|
||||
`);
|
||||
db.close();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function run(sql: string): { stdout: string; stderr: string; status: number } {
|
||||
const r = spawnSync('pnpm', ['exec', 'tsx', Q, dbPath, sql], {
|
||||
encoding: 'utf-8',
|
||||
cwd: path.resolve(__dirname, '..'),
|
||||
});
|
||||
return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1 };
|
||||
}
|
||||
|
||||
it('SELECT prints pipe-separated rows in default order', () => {
|
||||
const r = run('SELECT id, name FROM t ORDER BY id');
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout.trim()).toBe('1|alice\n2|bob');
|
||||
});
|
||||
|
||||
it('SELECT renders NULL as empty string (matches sqlite3 default mode)', () => {
|
||||
const r = run('SELECT id, note FROM t ORDER BY id');
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout.trim()).toBe('1|hi\n2|');
|
||||
});
|
||||
|
||||
it('SELECT with no rows prints nothing', () => {
|
||||
const r = run("SELECT id FROM t WHERE name = 'nobody'");
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe('');
|
||||
});
|
||||
|
||||
it('INSERT runs via db.exec and persists', () => {
|
||||
const r = run("INSERT INTO t (id, name) VALUES (3, 'carol')");
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe('');
|
||||
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
const row = db.prepare('SELECT name FROM t WHERE id = 3').get() as { name: string };
|
||||
db.close();
|
||||
expect(row.name).toBe('carol');
|
||||
});
|
||||
|
||||
it('compound mutation statements execute together', () => {
|
||||
const r = run("DELETE FROM t WHERE id = 1; INSERT INTO t (id, name) VALUES (9, 'zed');");
|
||||
expect(r.status).toBe(0);
|
||||
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
const ids = (db.prepare('SELECT id FROM t ORDER BY id').all() as { id: number }[]).map(
|
||||
(r) => r.id,
|
||||
);
|
||||
db.close();
|
||||
expect(ids).toEqual([2, 9]);
|
||||
});
|
||||
|
||||
it('WITH...DELETE is treated as a mutation, not a query', () => {
|
||||
const r = run("WITH stale AS (SELECT id FROM t WHERE name = 'alice') DELETE FROM t WHERE id IN (SELECT id FROM stale)");
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe('');
|
||||
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
const rows = db.prepare('SELECT name FROM t').all() as { name: string }[];
|
||||
db.close();
|
||||
expect(rows).toEqual([{ name: 'bob' }]);
|
||||
});
|
||||
|
||||
it('exits 2 with usage when args are missing', () => {
|
||||
const r = spawnSync('pnpm', ['exec', 'tsx', Q], {
|
||||
encoding: 'utf-8',
|
||||
cwd: path.resolve(__dirname, '..'),
|
||||
});
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toMatch(/Usage/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* scripts/q.ts — sqlite3 CLI replacement for skill SQL invocations.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm exec tsx scripts/q.ts <db-path> "<sql>"
|
||||
*
|
||||
* Uses better-sqlite3's stmt.reader property to distinguish queries
|
||||
* (SELECT / WITH...SELECT) from mutations. Queries print rows in
|
||||
* sqlite3 CLI default ("list") format — pipe-separated, no header —
|
||||
* so existing skill text reads identically. Mutations run via
|
||||
* stmt.run() (single statement) or db.exec() (compound).
|
||||
*
|
||||
* Why this exists: setup/verify.ts:5 codifies that NanoClaw avoids
|
||||
* depending on the sqlite3 CLI binary; setup never installs or probes
|
||||
* for it. Skills that shell out to `sqlite3` therefore fail on hosts
|
||||
* where it isn't preinstalled (common on fresh Ubuntu — see #2191).
|
||||
* This wrapper preserves the skill-text shape (path then SQL string)
|
||||
* while routing through the better-sqlite3 dep that setup already
|
||||
* installs and verifies.
|
||||
*/
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
const [, , dbPath, sql] = process.argv;
|
||||
|
||||
if (!dbPath || sql === undefined) {
|
||||
console.error('Usage: pnpm exec tsx scripts/q.ts <db-path> "<sql>"');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const db = new Database(dbPath);
|
||||
try {
|
||||
try {
|
||||
const stmt = db.prepare(sql);
|
||||
if (stmt.reader) {
|
||||
const rows = stmt.all() as Record<string, unknown>[];
|
||||
for (const row of rows) {
|
||||
console.log(
|
||||
Object.values(row)
|
||||
.map((v) => (v === null ? '' : String(v)))
|
||||
.join('|'),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
stmt.run();
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
// better-sqlite3 throws on compound statements ("contains more than
|
||||
// one statement"). Compound SQL in skills is always mutations
|
||||
// (e.g. "DELETE ...; INSERT ...;"), so fall back to db.exec().
|
||||
if (e instanceof Error && /more than one statement/i.test(e.message)) {
|
||||
db.exec(sql);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
Executable → Regular
+1
-1
@@ -16,7 +16,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-whatsapp/SKILL.md.
|
||||
BAILEYS_VERSION="@whiskeysockets/baileys@6.17.16"
|
||||
BAILEYS_VERSION="@whiskeysockets/baileys@7.0.0-rc.9"
|
||||
QRCODE_VERSION="qrcode@1.5.4"
|
||||
QRCODE_TYPES_VERSION="@types/qrcode@1.5.6"
|
||||
PINO_VERSION="pino@9.6.0"
|
||||
|
||||
+257
-97
@@ -14,8 +14,8 @@
|
||||
* "Terminal Agent".
|
||||
* NANOCLAW_SKIP comma-separated step names to skip
|
||||
* (environment|container|onecli|auth|mounts|
|
||||
* service|cli-agent|timezone|channel|verify|
|
||||
* first-chat)
|
||||
* service|cli-agent|timezone|channel|
|
||||
* verify|first-chat)
|
||||
*
|
||||
* Timezone is auto-detected after the CLI agent step. UTC resolves are
|
||||
* confirmed with the user, and free-text replies fall through to a
|
||||
@@ -23,11 +23,13 @@
|
||||
*/
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import { BACK_TO_CHANNEL_SELECTION } from './lib/back-nav.js';
|
||||
import { runDiscordChannel } from './channels/discord.js';
|
||||
import { runIMessageChannel } from './channels/imessage.js';
|
||||
import { runSignalChannel } from './channels/signal.js';
|
||||
@@ -37,7 +39,7 @@ import { runTelegramChannel } from './channels/telegram.js';
|
||||
import { runWhatsAppChannel } from './channels/whatsapp.js';
|
||||
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
|
||||
import { brightSelect } from './lib/bright-select.js';
|
||||
import { offerClaudeAssist } from './lib/claude-assist.js';
|
||||
import { offerClaudeOnFailure } from './lib/claude-handoff.js';
|
||||
import {
|
||||
applyToEnv,
|
||||
parseFlags,
|
||||
@@ -46,21 +48,29 @@ import {
|
||||
} from './lib/setup-config-parse.js';
|
||||
import { runAdvancedScreen } from './lib/setup-config-screen.js';
|
||||
import { runWindowedStep } from './lib/windowed-runner.js';
|
||||
import { detectRegisteredGroups, detectExistingDisplayName } from './environment.js';
|
||||
import { pollHealth } from './onecli.js';
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
||||
import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-claude.js';
|
||||
import * as setupLog from './logs.js';
|
||||
import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js';
|
||||
import { ensureAnswer, fail, runQuietChild, runQuietStep, spawnQuiet } from './lib/runner.js';
|
||||
import { emit as phEmit } from './lib/diagnostics.js';
|
||||
import { brandBold, brandChip, dimWrap, fitToWidth, wrapForGutter } from './lib/theme.js';
|
||||
import { accentGreen, brandBody, brandBold, brandChip, dimWrap, fitToWidth, fmtDuration, note, wrapForGutter } from './lib/theme.js';
|
||||
import { isValidTimezone } from '../src/timezone.js';
|
||||
|
||||
const CLI_AGENT_NAME = 'Terminal Agent';
|
||||
const RUN_START = Date.now();
|
||||
|
||||
type ChannelChoice = 'telegram' | 'discord' | 'whatsapp' | 'signal' | 'teams' | 'slack' | 'imessage' | 'skip';
|
||||
type ChannelChoice = 'telegram' | 'discord' | 'whatsapp' | 'signal' | 'teams' | 'slack' | 'imessage' | 'other' | 'skip';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// Make sure ~/.local/bin is on PATH for every child process we spawn.
|
||||
// Installers we run mid-setup (OneCLI, claude) drop binaries there and
|
||||
// append a PATH line to the user's shell rc, but rc updates don't reach
|
||||
// an already-running Node process — so without this patch a freshly
|
||||
// installed `onecli` is invisible to a subsequent `runInheritScript`.
|
||||
ensureLocalBinOnPath();
|
||||
|
||||
// Parse CLI flags first — `--help` short-circuits before we render anything,
|
||||
// and flag values get folded into process.env so existing step code reading
|
||||
// NANOCLAW_* sees them unchanged.
|
||||
@@ -84,17 +94,21 @@ async function main(): Promise<void> {
|
||||
|
||||
// Welcome menu — default path or open advanced overrides before any setup
|
||||
// work begins. Default lands on standard so Enter is the happy path.
|
||||
const startChoice = ensureAnswer(
|
||||
await brightSelect<'default' | 'advanced'>({
|
||||
message: 'How would you like to begin?',
|
||||
options: [
|
||||
{ value: 'default', label: 'Standard setup' },
|
||||
{ value: 'advanced', label: 'Advanced', hint: 'override defaults' },
|
||||
],
|
||||
initialValue: 'default',
|
||||
}),
|
||||
) as 'default' | 'advanced';
|
||||
setupLog.userInput('start_choice', startChoice);
|
||||
// On sg re-exec, the user already chose — skip straight to standard.
|
||||
let startChoice: 'default' | 'advanced' = 'default';
|
||||
if (process.env.NANOCLAW_REEXEC_SG !== '1') {
|
||||
startChoice = ensureAnswer(
|
||||
await brightSelect<'default' | 'advanced'>({
|
||||
message: 'How would you like to begin?',
|
||||
options: [
|
||||
{ value: 'default', label: 'Standard setup' },
|
||||
{ value: 'advanced', label: 'Advanced', hint: 'override defaults' },
|
||||
],
|
||||
initialValue: 'default',
|
||||
}),
|
||||
) as 'default' | 'advanced';
|
||||
setupLog.userInput('start_choice', startChoice);
|
||||
}
|
||||
if (startChoice === 'advanced') {
|
||||
configValues = await runAdvancedScreen(configValues);
|
||||
applyToEnv(configValues);
|
||||
@@ -122,11 +136,13 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
if (!skip.has('container')) {
|
||||
p.log.message(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4));
|
||||
p.log.message(brandBody(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4)));
|
||||
p.log.message(
|
||||
dimWrap(
|
||||
'The first build pulls a base image and installs a few tools. On a fresh machine this usually takes 3–10 minutes.',
|
||||
4,
|
||||
brandBody(
|
||||
dimWrap(
|
||||
'The first build pulls a base image and installs a few tools. On a fresh machine this usually takes 3–10 minutes.',
|
||||
4,
|
||||
),
|
||||
),
|
||||
);
|
||||
const res = await runWindowedStep('container', {
|
||||
@@ -161,9 +177,11 @@ async function main(): Promise<void> {
|
||||
|
||||
if (!skip.has('onecli')) {
|
||||
p.log.message(
|
||||
dimWrap(
|
||||
'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.',
|
||||
4,
|
||||
brandBody(
|
||||
dimWrap(
|
||||
'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.',
|
||||
4,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -287,29 +305,39 @@ async function main(): Promise<void> {
|
||||
await fail('service', "Couldn't start NanoClaw.", 'See logs/nanoclaw.error.log for details.');
|
||||
}
|
||||
if (res.terminal?.fields.DOCKER_GROUP_STALE === 'true') {
|
||||
p.log.warn("NanoClaw's permissions need a tweak before it can reach Docker.");
|
||||
p.log.warn(brandBody("NanoClaw's permissions need a tweak before it can reach Docker."));
|
||||
p.log.message(
|
||||
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + ` systemctl --user restart ${getSystemdUnit()}`,
|
||||
brandBody(
|
||||
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + ` systemctl --user restart ${getSystemdUnit()}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let displayName: string | undefined;
|
||||
const needsDisplayName = !skip.has('cli-agent') || !skip.has('channel');
|
||||
if (needsDisplayName) {
|
||||
const fallback = process.env.USER?.trim() || 'Operator';
|
||||
async function resolveDisplayName(): Promise<string> {
|
||||
if (displayName) return displayName;
|
||||
const preset = process.env.NANOCLAW_DISPLAY_NAME?.trim();
|
||||
displayName = preset || (await askDisplayName(fallback));
|
||||
const existing = detectExistingDisplayName(process.cwd());
|
||||
const fallback = process.env.USER?.trim() || 'Operator';
|
||||
displayName = preset || existing || (await askDisplayName(fallback));
|
||||
return displayName;
|
||||
}
|
||||
|
||||
if (!skip.has('cli-agent') && detectRegisteredGroups(process.cwd())) {
|
||||
skip.add('cli-agent');
|
||||
skip.add('first-chat');
|
||||
}
|
||||
|
||||
if (!skip.has('cli-agent')) {
|
||||
await resolveDisplayName();
|
||||
const res = await runQuietStep(
|
||||
'cli-agent',
|
||||
{
|
||||
running: 'Bringing your assistant online…',
|
||||
done: 'Assistant wired up.',
|
||||
},
|
||||
['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME],
|
||||
['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME, '--folder', '_ping-test'],
|
||||
);
|
||||
if (!res.ok) {
|
||||
await fail(
|
||||
@@ -320,16 +348,39 @@ async function main(): Promise<void> {
|
||||
}
|
||||
if (!skip.has('first-chat')) {
|
||||
p.log.message(
|
||||
dimWrap(
|
||||
"Your assistant runs in an isolated sandbox. I'm going to send it a quick test message (ping) and wait for a reply (pong) to confirm it's responding. First startup typically takes 30–60 seconds while the sandbox warms up.",
|
||||
4,
|
||||
brandBody(
|
||||
dimWrap(
|
||||
"Your assistant runs in an isolated sandbox. I'm going to send it a quick test message (ping) and wait for a reply (pong) to confirm it's responding. First startup typically takes 30–60 seconds while the sandbox warms up.",
|
||||
4,
|
||||
),
|
||||
),
|
||||
);
|
||||
const ping = await confirmAssistantResponds();
|
||||
if (ping === 'ok') {
|
||||
phEmit('first_chat_ready');
|
||||
const cleanupRawLog = setupLog.stepRawLog('cleanup-cli-agent');
|
||||
const cleanupStart = Date.now();
|
||||
const cleanup = await spawnQuiet(
|
||||
'pnpm',
|
||||
['exec', 'tsx', 'scripts/delete-cli-agent.ts', '--folder', '_ping-test'],
|
||||
cleanupRawLog,
|
||||
);
|
||||
setupLog.step(
|
||||
'cleanup-cli-agent',
|
||||
cleanup.ok ? 'success' : 'failed',
|
||||
Date.now() - cleanupStart,
|
||||
{ exit_code: cleanup.exitCode },
|
||||
cleanupRawLog,
|
||||
);
|
||||
if (!cleanup.ok) {
|
||||
p.log.warn(
|
||||
brandBody(
|
||||
`Couldn't clean up the test agent — it may still appear in your agent list. See ${cleanupRawLog} for details.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
const next = ensureAnswer(
|
||||
await p.select({
|
||||
await brightSelect<'continue' | 'chat'>({
|
||||
message: 'What next?',
|
||||
options: [
|
||||
{
|
||||
@@ -345,11 +396,27 @@ async function main(): Promise<void> {
|
||||
}),
|
||||
) as 'continue' | 'chat';
|
||||
setupLog.userInput('first_chat_choice', next);
|
||||
if (next === 'chat') await runFirstChat();
|
||||
if (next === 'chat') {
|
||||
const terminalAgentName = `${displayName!}'s Terminal`;
|
||||
const createRes = await runQuietChild(
|
||||
'create-terminal-agent',
|
||||
'pnpm',
|
||||
['exec', 'tsx', 'scripts/init-cli-agent.ts', '--display-name', displayName!, '--agent-name', terminalAgentName],
|
||||
{ running: `Creating ${terminalAgentName}…`, done: `${terminalAgentName} is ready.` },
|
||||
);
|
||||
if (!createRes.ok) {
|
||||
await fail(
|
||||
'create-terminal-agent',
|
||||
`Couldn't create ${terminalAgentName}.`,
|
||||
'You can retry later with `pnpm exec tsx scripts/init-cli-agent.ts`.',
|
||||
);
|
||||
}
|
||||
await runFirstChat();
|
||||
}
|
||||
} else {
|
||||
phEmit('first_chat_failed', { reason: ping });
|
||||
renderPingFailureNote(ping);
|
||||
await offerClaudeAssist({
|
||||
await offerClaudeOnFailure({
|
||||
stepName: 'cli-agent',
|
||||
msg:
|
||||
ping === 'socket_error'
|
||||
@@ -368,30 +435,51 @@ async function main(): Promise<void> {
|
||||
await runTimezoneStep();
|
||||
}
|
||||
|
||||
// v1 → v2 migration is handled by `bash migrate-v2.sh`, not the setup flow.
|
||||
// Users migrating from v1 run that script before (or instead of) setup.
|
||||
|
||||
let channelChoice: ChannelChoice = 'skip';
|
||||
|
||||
if (!skip.has('channel')) {
|
||||
channelChoice = await askChannelChoice();
|
||||
if (channelChoice === 'telegram') {
|
||||
await runTelegramChannel(displayName!);
|
||||
} else if (channelChoice === 'discord') {
|
||||
await runDiscordChannel(displayName!);
|
||||
} else if (channelChoice === 'whatsapp') {
|
||||
await runWhatsAppChannel(displayName!);
|
||||
} else if (channelChoice === 'signal') {
|
||||
await runSignalChannel(displayName!);
|
||||
} else if (channelChoice === 'teams') {
|
||||
await runTeamsChannel(displayName!);
|
||||
} else if (channelChoice === 'slack') {
|
||||
await runSlackChannel(displayName!);
|
||||
} else if (channelChoice === 'imessage') {
|
||||
await runIMessageChannel(displayName!);
|
||||
} else {
|
||||
p.log.info(
|
||||
wrapForGutter(
|
||||
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).',
|
||||
4,
|
||||
),
|
||||
);
|
||||
// Loop so a channel sub-flow can return BACK_TO_CHANNEL_SELECTION on
|
||||
// its first prompt and bounce the user back to the chooser without
|
||||
// restarting setup. Channels not yet wired with the back option just
|
||||
// return void and the loop exits after one pass.
|
||||
let backed = true;
|
||||
while (backed) {
|
||||
backed = false;
|
||||
channelChoice = await askChannelChoice();
|
||||
if (channelChoice !== 'skip' && channelChoice !== 'other') {
|
||||
await resolveDisplayName();
|
||||
}
|
||||
let result: void | typeof BACK_TO_CHANNEL_SELECTION;
|
||||
if (channelChoice === 'telegram') {
|
||||
result = await runTelegramChannel(displayName!);
|
||||
} else if (channelChoice === 'discord') {
|
||||
result = await runDiscordChannel(displayName!);
|
||||
} else if (channelChoice === 'whatsapp') {
|
||||
result = await runWhatsAppChannel(displayName!);
|
||||
} else if (channelChoice === 'signal') {
|
||||
result = await runSignalChannel(displayName!);
|
||||
} else if (channelChoice === 'teams') {
|
||||
result = await runTeamsChannel(displayName!);
|
||||
} else if (channelChoice === 'slack') {
|
||||
result = await runSlackChannel(displayName!);
|
||||
} else if (channelChoice === 'imessage') {
|
||||
result = await runIMessageChannel(displayName!);
|
||||
} else if (channelChoice === 'other') {
|
||||
result = await askOtherChannelName();
|
||||
} else {
|
||||
p.log.info(
|
||||
brandBody(
|
||||
wrapForGutter(
|
||||
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).',
|
||||
4,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (result === BACK_TO_CHANNEL_SELECTION) backed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,14 +508,6 @@ async function main(): Promise<void> {
|
||||
6,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const agentPing = res.terminal?.fields.AGENT_PING;
|
||||
if (agentPing && agentPing !== 'ok' && agentPing !== 'skipped') {
|
||||
notes.push(
|
||||
"• Your assistant didn't reply to a test message. " +
|
||||
'Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!res.terminal?.fields.CONFIGURED_CHANNELS) {
|
||||
notes.push(
|
||||
@@ -435,7 +515,7 @@ async function main(): Promise<void> {
|
||||
);
|
||||
}
|
||||
if (notes.length > 0) {
|
||||
p.note(notes.join('\n'), "What's left");
|
||||
note(notes.join('\n'), "What's left");
|
||||
}
|
||||
// "What's left" is a soft failure — we don't abort like fail(), but the
|
||||
// user is still stuck and a fix is exactly what claude-assist is for.
|
||||
@@ -447,9 +527,8 @@ async function main(): Promise<void> {
|
||||
unresolved_count: notes.length,
|
||||
service_running: res.terminal?.fields.SERVICE === 'running',
|
||||
has_credentials: res.terminal?.fields.CREDENTIALS === 'configured',
|
||||
agent_responds: res.terminal?.fields.AGENT_PING === 'ok',
|
||||
});
|
||||
await offerClaudeAssist({
|
||||
await offerClaudeOnFailure({
|
||||
stepName: 'verify',
|
||||
msg: summary || 'Verification completed with unresolved issues.',
|
||||
hint: `Terminal block: ${JSON.stringify(res.terminal?.fields ?? {})}`,
|
||||
@@ -467,11 +546,11 @@ async function main(): Promise<void> {
|
||||
];
|
||||
const labelWidth = Math.max(...rows.map(([l]) => l.length));
|
||||
const nextSteps = rows.map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`).join('\n');
|
||||
p.note(nextSteps, 'Try these');
|
||||
note(nextSteps, 'Try these');
|
||||
|
||||
// Always-on warning goes before the "check your DMs" directive so the
|
||||
// caveat doesn't land after the user's already looked away at their phone.
|
||||
p.note(
|
||||
note(
|
||||
wrapForGutter(
|
||||
"NanoClaw runs on this machine. It's only reachable while this computer is on and connected to the internet. For always-on availability, run it on a cloud VM — or keep this machine awake.",
|
||||
6,
|
||||
@@ -488,7 +567,7 @@ async function main(): Promise<void> {
|
||||
// that the welcome-message signal was too easy to miss. Use p.note so it
|
||||
// renders with a visible box, cyan-bold the directive line, and put it
|
||||
// as the last thing before outro.
|
||||
p.note(`${brandBold('→')} ${k.bold(`Check your ${dmTarget} — your assistant is saying hi.`)}`, 'Go say hi');
|
||||
note(`${brandBold('→')} ${k.bold(`Check your ${dmTarget} — your assistant is saying hi.`)}`, 'Go say hi');
|
||||
p.outro(k.green("You're set."));
|
||||
} else {
|
||||
p.outro(k.green("You're ready! Chat with `pnpm run chat hi`."));
|
||||
@@ -510,10 +589,7 @@ function channelDmLabel(choice: ChannelChoice): string | null {
|
||||
case 'imessage':
|
||||
return 'iMessage';
|
||||
case 'slack':
|
||||
// Slack install doesn't wire an agent or send a welcome DM — the
|
||||
// driver prints its own "finish in your Slack app" note. Falling
|
||||
// through to null avoids a misleading "check your Slack DMs" banner.
|
||||
return null;
|
||||
return 'Slack DMs';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -532,18 +608,16 @@ async function confirmAssistantResponds(): Promise<PingResult> {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
const label = 'Waking your assistant…';
|
||||
s.start(fitToWidth(label, ' (999s)'));
|
||||
s.start(fitToWidth(label, ' (99m 59s)'));
|
||||
const tick = setInterval(() => {
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
const suffix = ` (${elapsed}s)`;
|
||||
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||
s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`);
|
||||
}, 1000);
|
||||
|
||||
const result = await pingCliAgent();
|
||||
|
||||
clearInterval(tick);
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
const suffix = ` (${elapsed}s)`;
|
||||
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||
if (result === 'ok') {
|
||||
s.stop(`${k.bold(fitToWidth('Your assistant is ready.', suffix))}${k.dim(suffix)}`);
|
||||
} else {
|
||||
@@ -570,7 +644,7 @@ function renderPingFailureNote(result: PingResult): void {
|
||||
'No reply from your assistant within 30 seconds. Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.',
|
||||
6,
|
||||
);
|
||||
p.note(body, 'Skipping the first chat');
|
||||
note(body, 'Skipping the first chat');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -585,7 +659,7 @@ function renderPingFailureNote(result: PingResult): void {
|
||||
* clearly optional.
|
||||
*/
|
||||
async function runFirstChat(): Promise<void> {
|
||||
p.note(
|
||||
note(
|
||||
wrapForGutter(
|
||||
[
|
||||
'Your assistant runs in a sandbox on this machine.',
|
||||
@@ -632,7 +706,7 @@ function sendChatMessage(message: string): Promise<void> {
|
||||
|
||||
async function runAuthStep(): Promise<void> {
|
||||
if (anthropicSecretExists()) {
|
||||
p.log.success('Your Claude account is already connected.');
|
||||
p.log.success(brandBody('Your Claude account is already connected.'));
|
||||
setupLog.step('auth', 'skipped', 0, { REASON: 'secret-already-present' });
|
||||
return;
|
||||
}
|
||||
@@ -666,12 +740,38 @@ async function runAuthStep(): Promise<void> {
|
||||
label: 'Paste an Anthropic API key',
|
||||
hint: 'pay-per-use via console.anthropic.com',
|
||||
},
|
||||
{
|
||||
value: 'skip',
|
||||
label: "Skip — I'll connect later",
|
||||
hint: 'not recommended — Claude helps debug setup issues',
|
||||
},
|
||||
],
|
||||
}),
|
||||
) as 'subscription' | 'oauth' | 'api';
|
||||
) as 'subscription' | 'oauth' | 'api' | 'skip';
|
||||
setupLog.userInput('auth_method', method);
|
||||
phEmit('auth_method_chosen', { method });
|
||||
|
||||
if (method === 'skip') {
|
||||
const confirmed = ensureAnswer(
|
||||
await p.confirm({
|
||||
message:
|
||||
"Skip Claude sign-in? The agent won't be able to run until you connect, and we won't be able to help debug setup errors.",
|
||||
initialValue: false,
|
||||
}),
|
||||
);
|
||||
if (!confirmed) {
|
||||
// Loop back to the auth picker so they can choose a real method.
|
||||
return runAuthStep();
|
||||
}
|
||||
setupLog.step('auth', 'skipped', 0, { REASON: 'user-skipped' });
|
||||
p.log.warn(
|
||||
brandBody(
|
||||
'Claude sign-in skipped. Re-run setup or run `bash nanoclaw.sh` to finish later.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'subscription') {
|
||||
await runSubscriptionAuth();
|
||||
} else {
|
||||
@@ -680,7 +780,7 @@ async function runAuthStep(): Promise<void> {
|
||||
}
|
||||
|
||||
async function runSubscriptionAuth(): Promise<void> {
|
||||
p.log.step('Opening the Claude sign-in flow…');
|
||||
p.log.step(brandBody('Opening the Claude sign-in flow…'));
|
||||
console.log(k.dim(' (a browser will open for sign-in; this part is interactive)'));
|
||||
console.log();
|
||||
const start = Date.now();
|
||||
@@ -699,7 +799,7 @@ async function runSubscriptionAuth(): Promise<void> {
|
||||
);
|
||||
}
|
||||
setupLog.step('auth', 'interactive', durationMs, { METHOD: 'subscription' });
|
||||
p.log.success('Claude account connected.');
|
||||
p.log.success(brandBody('Claude account connected.'));
|
||||
}
|
||||
|
||||
async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
|
||||
@@ -709,16 +809,27 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: `Paste your ${label}`,
|
||||
clearOnError: true,
|
||||
validate: (v) => {
|
||||
if (!v || !v.trim()) return 'Required';
|
||||
if (!v.trim().startsWith(prefix)) {
|
||||
// Strip any internal whitespace so a line-wrapped paste that did
|
||||
// survive into clack can still validate. The mid-token-newline
|
||||
// case where clack only sees the first line is caught by the
|
||||
// shape check below.
|
||||
const cleaned = (v ?? '').replace(/\s+/g, '');
|
||||
if (!cleaned) return 'Required';
|
||||
if (!cleaned.startsWith(prefix)) {
|
||||
return `Should start with ${prefix}…`;
|
||||
}
|
||||
if (method === 'oauth' && !/^sk-ant-oat[A-Za-z0-9_-]{80,500}AA$/.test(cleaned)) {
|
||||
return cleaned.length < 90
|
||||
? 'Token looks truncated — line breaks in the paste can cut it off. Widen your terminal so the token fits on one line, then paste again.'
|
||||
: "Token shape doesn't look right (expected sk-ant-oat…AA).";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const token = (answer as string).trim();
|
||||
const token = (answer as string).replace(/\s+/g, '');
|
||||
|
||||
const res = await runQuietChild(
|
||||
'auth',
|
||||
@@ -922,9 +1033,11 @@ async function runTimezoneStep(): Promise<void> {
|
||||
tz = await resolveTimezoneViaClaude(raw);
|
||||
} else {
|
||||
p.log.warn(
|
||||
wrapForGutter(
|
||||
"That's not a standard IANA zone and I can't call Claude to interpret it here — try again with a zone like `America/New_York` or `Europe/London`.",
|
||||
4,
|
||||
brandBody(
|
||||
wrapForGutter(
|
||||
"That's not a standard IANA zone and I can't call Claude to interpret it here — try again with a zone like `America/New_York` or `Europe/London`.",
|
||||
4,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -967,7 +1080,7 @@ async function runTimezoneStep(): Promise<void> {
|
||||
async function askDisplayName(fallback: string): Promise<string> {
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'What should your assistant call you?',
|
||||
message: `What should your assistant call ${accentGreen('you')}?`,
|
||||
placeholder: fallback,
|
||||
defaultValue: fallback,
|
||||
}),
|
||||
@@ -1002,6 +1115,7 @@ async function askChannelChoice(): Promise<ChannelChoice> {
|
||||
hint: 'needs public URL',
|
||||
},
|
||||
{ value: 'teams', label: 'Yes, connect Microsoft Teams', hint: 'complex setup' },
|
||||
{ value: 'other', label: 'Other…', hint: 'install via /add-<name> after setup' },
|
||||
{ value: 'skip', label: 'Skip for now', hint: "I'll just use the terminal" },
|
||||
],
|
||||
}),
|
||||
@@ -1011,8 +1125,52 @@ async function askChannelChoice(): Promise<ChannelChoice> {
|
||||
return choice;
|
||||
}
|
||||
|
||||
async function askOtherChannelName(): Promise<void | typeof BACK_TO_CHANNEL_SELECTION> {
|
||||
const action = ensureAnswer(
|
||||
await brightSelect<'type' | 'back'>({
|
||||
message: 'Which channel would you like to install?',
|
||||
options: [
|
||||
{
|
||||
value: 'type',
|
||||
label: 'Type the channel name',
|
||||
hint: 'e.g. matrix, github, linear, webex',
|
||||
},
|
||||
{ value: 'back', label: '← Back to channel selection' },
|
||||
],
|
||||
initialValue: 'type',
|
||||
}),
|
||||
);
|
||||
if (action === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'Channel name',
|
||||
placeholder: 'e.g. matrix, github, linear, webex',
|
||||
}),
|
||||
);
|
||||
const name = (answer as string).trim().toLowerCase().replace(/^\/?(add-)?/, '');
|
||||
setupLog.userInput('other_channel', name);
|
||||
phEmit('channel_other_named', { channel: name });
|
||||
p.log.info(
|
||||
brandBody(
|
||||
wrapForGutter(
|
||||
`No bash installer for ${k.bold(name)} — open Claude Code after setup and run ${k.bold(`/add-${name}`)} to install it.`,
|
||||
4,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── interactive / env helpers ─────────────────────────────────────────
|
||||
|
||||
function ensureLocalBinOnPath(): void {
|
||||
const localBin = path.join(os.homedir(), '.local', 'bin');
|
||||
const current = process.env.PATH ?? '';
|
||||
const segments = current.split(path.delimiter).filter(Boolean);
|
||||
if (segments.includes(localBin)) return;
|
||||
process.env.PATH = current ? `${localBin}${path.delimiter}${current}` : localBin;
|
||||
}
|
||||
|
||||
function anthropicSecretExists(): boolean {
|
||||
try {
|
||||
const res = spawnSync('onecli', ['secrets', 'list'], {
|
||||
@@ -1089,10 +1247,12 @@ function maybeReexecUnderSg(): void {
|
||||
if (!/permission denied/i.test(err)) return;
|
||||
if (spawnSync('which', ['sg'], { stdio: 'ignore' }).status !== 0) return;
|
||||
|
||||
p.log.warn('Docker socket not accessible in current group. Re-executing under `sg docker`.');
|
||||
p.log.warn(brandBody('Docker socket not accessible in current group. Re-executing under `sg docker`.'));
|
||||
const existingSkip = (process.env.NANOCLAW_SKIP ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const skipList = [...new Set([...existingSkip, ...setupLog.completedStepNames()])].join(',');
|
||||
const res = spawnSync('sg', ['docker', '-c', 'pnpm run setup:auto'], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, NANOCLAW_REEXEC_SG: '1' },
|
||||
env: { ...process.env, NANOCLAW_REEXEC_SG: '1', ...(skipList ? { NANOCLAW_SKIP: skipList } : {}) },
|
||||
});
|
||||
process.exit(res.status ?? 1);
|
||||
}
|
||||
|
||||
+43
-33
@@ -27,10 +27,13 @@ import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
|
||||
import { brightSelect } from '../lib/bright-select.js';
|
||||
import { confirmThenOpen } from '../lib/browser.js';
|
||||
import { confirmThenOpen, formatNoteLink } from '../lib/browser.js';
|
||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||
import { readEnvKey } from '../environment.js';
|
||||
import { accentGreen, brandBody, fmtDuration, note } from '../lib/theme.js';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
const DISCORD_API = 'https://discord.com/api/v10';
|
||||
@@ -46,8 +49,10 @@ interface AppInfo {
|
||||
owner: { id: string; username: string } | null;
|
||||
}
|
||||
|
||||
export async function runDiscordChannel(displayName: string): Promise<void> {
|
||||
const hasBot = await askHasBotToken();
|
||||
export async function runDiscordChannel(displayName: string): Promise<ChannelFlowResult> {
|
||||
const choice = await askHasBotToken();
|
||||
if (choice === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
const hasBot = choice === 'yes';
|
||||
if (!hasBot) {
|
||||
await walkThroughBotCreation();
|
||||
}
|
||||
@@ -140,22 +145,23 @@ export async function runDiscordChannel(displayName: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function askHasBotToken(): Promise<boolean> {
|
||||
async function askHasBotToken(): Promise<'yes' | 'no' | 'back'> {
|
||||
const answer = ensureAnswer(
|
||||
await brightSelect({
|
||||
message: 'Do you already have a Discord bot?',
|
||||
options: [
|
||||
{ value: 'yes', label: 'Yes, I have a bot token ready' },
|
||||
{ value: 'no', label: "No, walk me through creating one" },
|
||||
{ value: 'back', label: '← Back to channel selection' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
return answer === 'yes';
|
||||
return answer as 'yes' | 'no' | 'back';
|
||||
}
|
||||
|
||||
async function walkThroughBotCreation(): Promise<void> {
|
||||
const url = 'https://discord.com/developers/applications';
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
"You'll create a Discord bot in the Developer Portal. It's free and takes about a minute.",
|
||||
'',
|
||||
@@ -163,9 +169,8 @@ async function walkThroughBotCreation(): Promise<void> {
|
||||
' 2. In the "Bot" tab, click "Reset Token" and copy the token',
|
||||
' 3. On the same tab, enable "Message Content Intent"',
|
||||
' (under Privileged Gateway Intents)',
|
||||
'',
|
||||
k.dim(url),
|
||||
].join('\n'),
|
||||
formatNoteLink(url),
|
||||
].filter((line): line is string => line !== null).join('\n'),
|
||||
'Create a Discord bot',
|
||||
);
|
||||
await confirmThenOpen(url, 'Press Enter to open the Developer Portal');
|
||||
@@ -184,7 +189,7 @@ function showTokenLocationReminder(hasExistingBot: boolean): void {
|
||||
// to find it — tokens in the Dev Portal aren't visible after first reveal,
|
||||
// and "Reset Token" issues a new one.
|
||||
if (hasExistingBot) {
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
"Where to find your bot token:",
|
||||
'',
|
||||
@@ -216,16 +221,15 @@ async function walkThroughServerCreation(): Promise<void> {
|
||||
// the web client and rely on the + button being visible. The steps below
|
||||
// are the same whether they're in the desktop app or the browser.
|
||||
const url = 'https://discord.com/channels/@me';
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
"A Discord server is just a private space for you and the bot. Free and takes 30 seconds.",
|
||||
'',
|
||||
' 1. In Discord, click the "+" at the bottom of the server list',
|
||||
' 2. Choose "Create My Own" → "For me and my friends"',
|
||||
' 3. Give it any name (e.g. "NanoClaw")',
|
||||
'',
|
||||
k.dim(url),
|
||||
].join('\n'),
|
||||
formatNoteLink(url),
|
||||
].filter((line): line is string => line !== null).join('\n'),
|
||||
'Create a Discord server',
|
||||
);
|
||||
await confirmThenOpen(url, 'Press Enter to open Discord');
|
||||
@@ -239,9 +243,22 @@ async function walkThroughServerCreation(): Promise<void> {
|
||||
}
|
||||
|
||||
async function collectDiscordToken(): Promise<string> {
|
||||
const existing = readEnvKey('DISCORD_BOT_TOKEN');
|
||||
if (existing && /^[A-Za-z0-9._-]{50,}$/.test(existing)) {
|
||||
const reuse = ensureAnswer(await p.confirm({
|
||||
message: `Found an existing Discord bot token (${existing.slice(0, 10)}…). Use it?`,
|
||||
initialValue: true,
|
||||
}));
|
||||
if (reuse) {
|
||||
setupLog.userInput('discord_token', 'reused-existing');
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Paste your bot token',
|
||||
clearOnError: true,
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Token is required';
|
||||
@@ -275,9 +292,8 @@ async function validateDiscordToken(token: string): Promise<string> {
|
||||
username?: string;
|
||||
message?: string;
|
||||
};
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
if (res.ok && data.username) {
|
||||
s.stop(`Found your bot: @${data.username}. ${k.dim(`(${elapsedS}s)`)}`);
|
||||
s.stop(`Found your bot: @${data.username}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||
setupLog.step('discord-validate', 'success', Date.now() - start, {
|
||||
BOT_USERNAME: data.username,
|
||||
BOT_ID: data.id ?? '',
|
||||
@@ -295,8 +311,7 @@ async function validateDiscordToken(token: string): Promise<string> {
|
||||
'Copy the token again from the Developer Portal and retry setup.',
|
||||
);
|
||||
} catch (err) {
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
||||
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setupLog.step('discord-validate', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
@@ -324,7 +339,6 @@ async function fetchApplicationInfo(token: string): Promise<AppInfo> {
|
||||
team?: unknown;
|
||||
message?: string;
|
||||
};
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
if (!res.ok || !data.id || !data.verify_key) {
|
||||
const reason = data.message ?? `HTTP ${res.status}`;
|
||||
s.stop(`Couldn't read application info: ${reason}`, 1);
|
||||
@@ -337,7 +351,7 @@ async function fetchApplicationInfo(token: string): Promise<AppInfo> {
|
||||
'Re-run setup. If it keeps failing, check the bot token has the right scopes.',
|
||||
);
|
||||
}
|
||||
s.stop(`Got your application details. ${k.dim(`(${elapsedS}s)`)}`);
|
||||
s.stop(`Got your application details. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||
// owner is populated for solo applications; team-owned apps return a
|
||||
// team object instead and we'll fall back to a manual user-id prompt.
|
||||
const owner =
|
||||
@@ -355,8 +369,7 @@ async function fetchApplicationInfo(token: string): Promise<AppInfo> {
|
||||
owner,
|
||||
};
|
||||
} catch (err) {
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
||||
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setupLog.step('discord-app-info', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
@@ -385,14 +398,14 @@ async function resolveOwnerUserId(
|
||||
}
|
||||
} else {
|
||||
p.log.info(
|
||||
"Your bot is owned by a Developer Team, so we need your Discord user ID directly.",
|
||||
brandBody("Your bot is owned by a Developer Team, so we need your Discord user ID directly."),
|
||||
);
|
||||
}
|
||||
return await promptForUserIdWithDevMode();
|
||||
}
|
||||
|
||||
async function promptForUserIdWithDevMode(): Promise<string> {
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
"To get your Discord user ID:",
|
||||
'',
|
||||
@@ -430,15 +443,14 @@ async function promptInviteBot(
|
||||
`&scope=bot` +
|
||||
`&permissions=${INVITE_PERMISSIONS}`;
|
||||
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
`@${botUsername} needs to share a server with you before it can DM you.`,
|
||||
'',
|
||||
' 1. Pick any server you\'re in (a personal one is fine)',
|
||||
' 2. Click "Authorize"',
|
||||
'',
|
||||
k.dim(url),
|
||||
].join('\n'),
|
||||
formatNoteLink(url),
|
||||
].filter((line): line is string => line !== null).join('\n'),
|
||||
'Add bot to a server',
|
||||
);
|
||||
await confirmThenOpen(url, 'Press Enter to open the invite page');
|
||||
@@ -465,7 +477,6 @@ async function openDmChannel(token: string, userId: string): Promise<string> {
|
||||
body: JSON.stringify({ recipient_id: userId }),
|
||||
});
|
||||
const data = (await res.json()) as { id?: string; message?: string };
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
if (!res.ok || !data.id) {
|
||||
const reason = data.message ?? `HTTP ${res.status}`;
|
||||
s.stop(`Couldn't open a DM channel: ${reason}`, 1);
|
||||
@@ -478,14 +489,13 @@ async function openDmChannel(token: string, userId: string): Promise<string> {
|
||||
'Make sure the bot is in a server you\'re also in, then retry setup.',
|
||||
);
|
||||
}
|
||||
s.stop(`DM channel ready. ${k.dim(`(${elapsedS}s)`)}`);
|
||||
s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||
setupLog.step('discord-open-dm', 'success', Date.now() - start, {
|
||||
DM_CHANNEL_ID: data.id,
|
||||
});
|
||||
return data.id;
|
||||
} catch (err) {
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
||||
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setupLog.step('discord-open-dm', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
@@ -506,7 +516,7 @@ async function resolveAgentName(): Promise<string> {
|
||||
}
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'What should your assistant be called?',
|
||||
message: `What should your ${accentGreen('assistant')} be called?`,
|
||||
placeholder: DEFAULT_AGENT_NAME,
|
||||
defaultValue: DEFAULT_AGENT_NAME,
|
||||
}),
|
||||
|
||||
+52
-30
@@ -33,10 +33,12 @@ import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
|
||||
import { brightSelect } from '../lib/bright-select.js';
|
||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||
import { wrapForGutter } from '../lib/theme.js';
|
||||
import { accentGreen, note, wrapForGutter } from '../lib/theme.js';
|
||||
import { readEnvKey } from '../environment.js';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
|
||||
@@ -47,10 +49,11 @@ interface RemoteCreds {
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export async function runIMessageChannel(displayName: string): Promise<void> {
|
||||
export async function runIMessageChannel(displayName: string): Promise<ChannelFlowResult> {
|
||||
const isMac = os.platform() === 'darwin';
|
||||
|
||||
const mode = await askMode(isMac);
|
||||
if (mode === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
let remoteCreds: RemoteCreds | null = null;
|
||||
|
||||
if (mode === 'local') {
|
||||
@@ -138,34 +141,38 @@ export async function runIMessageChannel(displayName: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function askMode(isMac: boolean): Promise<Mode> {
|
||||
async function askMode(isMac: boolean): Promise<Mode | 'back'> {
|
||||
const baseOptions = isMac
|
||||
? [
|
||||
{
|
||||
value: 'local' as const,
|
||||
label: 'Local (this Mac)',
|
||||
hint: "uses this machine's iMessage account",
|
||||
},
|
||||
{
|
||||
value: 'remote' as const,
|
||||
label: 'Remote (Photon API)',
|
||||
hint: 'the bot lives on another server',
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
value: 'remote' as const,
|
||||
label: 'Remote (Photon API)',
|
||||
hint: 'only option off macOS',
|
||||
},
|
||||
];
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect<Mode>({
|
||||
await brightSelect<Mode | 'back'>({
|
||||
message: 'How should iMessage run?',
|
||||
initialValue: isMac ? 'local' : 'remote',
|
||||
options: isMac
|
||||
? [
|
||||
{
|
||||
value: 'local',
|
||||
label: 'Local (this Mac)',
|
||||
hint: "uses this machine's iMessage account",
|
||||
},
|
||||
{
|
||||
value: 'remote',
|
||||
label: 'Remote (Photon API)',
|
||||
hint: 'the bot lives on another server',
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
value: 'remote',
|
||||
label: 'Remote (Photon API)',
|
||||
hint: 'only option off macOS',
|
||||
},
|
||||
],
|
||||
options: [
|
||||
...baseOptions,
|
||||
{ value: 'back', label: '← Back to channel selection' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
setupLog.userInput('imessage_mode', String(choice));
|
||||
if (choice !== 'back') setupLog.userInput('imessage_mode', String(choice));
|
||||
return choice;
|
||||
}
|
||||
|
||||
@@ -189,7 +196,7 @@ async function walkThroughFullDiskAccess(): Promise<void> {
|
||||
}
|
||||
const nodeDir = path.dirname(nodePath);
|
||||
|
||||
p.note(
|
||||
note(
|
||||
wrapForGutter(
|
||||
[
|
||||
`iMessage needs Full Disk Access granted to the Node binary:`,
|
||||
@@ -222,7 +229,20 @@ async function walkThroughFullDiskAccess(): Promise<void> {
|
||||
}
|
||||
|
||||
async function collectRemoteCreds(): Promise<RemoteCreds> {
|
||||
p.note(
|
||||
const existingUrl = readEnvKey('IMESSAGE_SERVER_URL');
|
||||
const existingKey = readEnvKey('IMESSAGE_API_KEY');
|
||||
if (existingUrl && existingKey && /^https?:\/\//i.test(existingUrl)) {
|
||||
const reuse = ensureAnswer(await p.confirm({
|
||||
message: `Found existing Photon credentials (${existingUrl}). Use them?`,
|
||||
initialValue: true,
|
||||
}));
|
||||
if (reuse) {
|
||||
setupLog.userInput('imessage_remote_creds', 'reused-existing');
|
||||
return { serverUrl: existingUrl, apiKey: existingKey };
|
||||
}
|
||||
}
|
||||
|
||||
note(
|
||||
[
|
||||
"Photon is a separate service that owns an iMessage account and",
|
||||
"exposes it over HTTP. NanoClaw will talk to it via its API.",
|
||||
@@ -250,6 +270,7 @@ async function collectRemoteCreds(): Promise<RemoteCreds> {
|
||||
const keyAnswer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Photon API key',
|
||||
clearOnError: true,
|
||||
validate: (v) => ((v ?? '').trim() ? undefined : 'API key is required'),
|
||||
}),
|
||||
);
|
||||
@@ -264,12 +285,13 @@ async function collectRemoteCreds(): Promise<RemoteCreds> {
|
||||
}
|
||||
|
||||
async function askOperatorHandle(): Promise<string> {
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
"What phone number or email do you iMessage with?",
|
||||
"That's where your assistant will send its welcome message.",
|
||||
'',
|
||||
k.dim(' • Phone: full E.164, e.g. +15551234567'),
|
||||
k.dim(' • Phone: start with + and your country code, no spaces or dashes'),
|
||||
k.dim(' Example: +14155551234 (country code 1, then 4155551234)'),
|
||||
k.dim(' • Email: whatever iMessage recognises (Apple ID, iCloud alias, …)'),
|
||||
].join('\n'),
|
||||
'Your iMessage handle',
|
||||
@@ -303,7 +325,7 @@ async function resolveAgentName(): Promise<string> {
|
||||
}
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'What should your assistant be called?',
|
||||
message: `What should your ${accentGreen('assistant')} be called?`,
|
||||
placeholder: DEFAULT_AGENT_NAME,
|
||||
defaultValue: DEFAULT_AGENT_NAME,
|
||||
}),
|
||||
|
||||
+81
-21
@@ -33,6 +33,8 @@ import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js';
|
||||
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
|
||||
import { brightSelect } from '../lib/bright-select.js';
|
||||
import {
|
||||
type Block,
|
||||
type StepResult,
|
||||
@@ -44,10 +46,37 @@ import {
|
||||
writeStepEntry,
|
||||
} from '../lib/runner.js';
|
||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||
import { accentGreen, fmtDuration, note } from '../lib/theme.js';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
|
||||
export async function runSignalChannel(displayName: string): Promise<void> {
|
||||
export async function runSignalChannel(displayName: string): Promise<ChannelFlowResult> {
|
||||
note(
|
||||
[
|
||||
"NanoClaw links to Signal as a *secondary* device on your existing",
|
||||
"phone — no new number needed. Your assistant will send and receive",
|
||||
"messages as the number on that phone.",
|
||||
'',
|
||||
"Here's what's about to happen — no input needed for any of it:",
|
||||
'',
|
||||
' 1. Set up signal-cli (auto-installs if missing)',
|
||||
' 2. Install the Signal adapter',
|
||||
' 3. Show a QR code — scan it from Signal → Settings → Linked Devices',
|
||||
' 4. Wire your assistant and send a welcome message',
|
||||
].join('\n'),
|
||||
'Set up Signal',
|
||||
);
|
||||
|
||||
const proceed = ensureAnswer(await brightSelect<'continue' | 'back'>({
|
||||
message: 'Ready to set up Signal?',
|
||||
options: [
|
||||
{ value: 'continue', label: 'Continue' },
|
||||
{ value: 'back', label: '← Back to channel selection' },
|
||||
],
|
||||
initialValue: 'continue',
|
||||
}));
|
||||
if (proceed === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
|
||||
await ensureSignalCli();
|
||||
|
||||
const install = await runQuietChild(
|
||||
@@ -133,42 +162,74 @@ export async function runSignalChannel(displayName: string): Promise<void> {
|
||||
|
||||
async function ensureSignalCli(): Promise<void> {
|
||||
const cli = process.env.SIGNAL_CLI_PATH || 'signal-cli';
|
||||
const probe = spawnSync(cli, ['--version'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
if (!probe.error && probe.status === 0) return;
|
||||
const probeFor = (): boolean => {
|
||||
const r = spawnSync(cli, ['--version'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
return !r.error && r.status === 0;
|
||||
};
|
||||
if (probeFor()) return;
|
||||
|
||||
note(
|
||||
[
|
||||
"NanoClaw talks to Signal through signal-cli, which isn't installed yet.",
|
||||
"We'll install it for you now — about 30 seconds, one-time only.",
|
||||
'',
|
||||
process.platform === 'darwin'
|
||||
? "On this Mac we'll use Homebrew (no admin password needed)."
|
||||
: "On Linux we'll grab the native release binary (no Java needed) and install it to ~/.local/bin.",
|
||||
].join('\n'),
|
||||
'Setting up signal-cli',
|
||||
);
|
||||
|
||||
const install = await runQuietChild(
|
||||
'install-signal-cli',
|
||||
'bash',
|
||||
['setup/install-signal-cli.sh'],
|
||||
{
|
||||
running: 'Installing signal-cli…',
|
||||
done: 'signal-cli installed.',
|
||||
},
|
||||
);
|
||||
|
||||
if (install.ok && probeFor()) return;
|
||||
|
||||
const reason = install.terminal?.fields.ERROR;
|
||||
if (process.platform === 'darwin') {
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
"NanoClaw talks to Signal through signal-cli, which isn't installed yet.",
|
||||
"We couldn't install signal-cli automatically.",
|
||||
reason === 'homebrew_not_installed'
|
||||
? ' Reason: Homebrew is not installed.'
|
||||
: ` Reason: ${reason ?? 'unknown'}.`,
|
||||
'',
|
||||
'The quickest way on macOS is Homebrew:',
|
||||
'You can install it manually:',
|
||||
'',
|
||||
k.cyan(' brew install signal-cli'),
|
||||
'',
|
||||
"Install it in another terminal, then re-run setup.",
|
||||
'Then re-run setup.',
|
||||
].join('\n'),
|
||||
'signal-cli not found',
|
||||
"Couldn't install signal-cli",
|
||||
);
|
||||
} else {
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
"NanoClaw talks to Signal through signal-cli, which isn't installed yet.",
|
||||
"We couldn't install signal-cli automatically.",
|
||||
` Reason: ${reason ?? 'unknown'}.`,
|
||||
'',
|
||||
'Grab the latest release from GitHub:',
|
||||
'You can install it manually from GitHub:',
|
||||
'',
|
||||
k.cyan(' https://github.com/AsamK/signal-cli/releases'),
|
||||
'',
|
||||
"Install it, make sure `signal-cli --version` works, then re-run setup.",
|
||||
'Then re-run setup.',
|
||||
].join('\n'),
|
||||
'signal-cli not found',
|
||||
"Couldn't install signal-cli",
|
||||
);
|
||||
}
|
||||
await fail(
|
||||
'signal-install',
|
||||
'signal-cli is required but not installed.',
|
||||
'Install it and re-run setup.',
|
||||
'install-signal-cli',
|
||||
'signal-cli is required but the auto-install failed.',
|
||||
'Install it manually and re-run setup.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -323,8 +384,7 @@ async function restartService(): Promise<void> {
|
||||
// Give the adapter a moment to connect to signal-cli before
|
||||
// init-first-agent's welcome DM hits the delivery path.
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`);
|
||||
s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||
setupLog.step('signal-restart', 'success', Date.now() - start, {
|
||||
PLATFORM: platform,
|
||||
});
|
||||
@@ -346,7 +406,7 @@ async function resolveAgentName(): Promise<string> {
|
||||
}
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'What should your assistant be called?',
|
||||
message: `What should your ${accentGreen('assistant')} be called?`,
|
||||
placeholder: DEFAULT_AGENT_NAME,
|
||||
defaultValue: DEFAULT_AGENT_NAME,
|
||||
}),
|
||||
|
||||
+236
-38
@@ -1,24 +1,23 @@
|
||||
/**
|
||||
* Slack channel flow for setup:auto.
|
||||
*
|
||||
* `runSlackChannel(displayName)` walks the operator from a bare Slack
|
||||
* workspace through a running bot, then stops before wiring an agent:
|
||||
* `runSlackChannel(displayName)` owns the full branch from creating a
|
||||
* Slack app through the welcome DM:
|
||||
*
|
||||
* 1. Walk through creating a Slack app (api.slack.com/apps) — scopes,
|
||||
* event subscriptions, and signing secret
|
||||
* 2. Paste the bot token + signing secret (clack password prompts)
|
||||
* 3. Validate via auth.test → resolves workspace + bot identity
|
||||
* 4. Install the adapter (setup/add-slack.sh, non-interactive)
|
||||
* 5. Print the post-install checklist: set the public webhook URL in
|
||||
* Slack's Event Subscriptions, DM the bot to bootstrap the channel,
|
||||
* then `/manage-channels` to wire an agent.
|
||||
* 5. Ask for the operator's Slack user ID
|
||||
* 6. conversations.open to get the DM channel ID
|
||||
* 7. Ask for the messaging-agent name (defaulting to "Nano")
|
||||
* 8. Wire the agent via scripts/init-first-agent.ts
|
||||
*
|
||||
* Why no welcome DM here: unlike Discord/Telegram (gateway / long-poll),
|
||||
* Slack needs a public Event Subscriptions URL for inbound events, and
|
||||
* opening an unsolicited DM would need `im:write` scope we don't force
|
||||
* the SKILL.md to require. Shipping a honest "here's what's left" note
|
||||
* is better than a welcome DM the user won't receive until they
|
||||
* configure the webhook anyway.
|
||||
* The welcome DM is sent via outbound delivery (chat.postMessage), which
|
||||
* works without Event Subscriptions being configured. The user sees the
|
||||
* greeting in Slack immediately; inbound replies require webhooks, so the
|
||||
* post-install note covers that.
|
||||
*
|
||||
* All output obeys the three-level contract. See docs/setup-flow.md.
|
||||
*/
|
||||
@@ -26,12 +25,18 @@ import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { confirmThenOpen } from '../lib/browser.js';
|
||||
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
|
||||
import { brightSelect } from '../lib/bright-select.js';
|
||||
import { openUrl } from '../lib/browser.js';
|
||||
import { isHeadless } from '../platform.js';
|
||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||
import { wrapForGutter } from '../lib/theme.js';
|
||||
import { readEnvKey } from '../environment.js';
|
||||
import { accentGreen, fmtDuration, note, wrapForGutter } from '../lib/theme.js';
|
||||
|
||||
const SLACK_API = 'https://slack.com/api';
|
||||
const SLACK_APPS_URL = 'https://api.slack.com/apps';
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
|
||||
interface WorkspaceInfo {
|
||||
teamName: string;
|
||||
@@ -40,11 +45,9 @@ interface WorkspaceInfo {
|
||||
botUserId: string;
|
||||
}
|
||||
|
||||
// displayName is reserved for when we start wiring the first agent here.
|
||||
// Kept to match the `run<X>Channel(displayName)` signature every other
|
||||
// channel driver uses, so auto.ts can dispatch without a branch.
|
||||
export async function runSlackChannel(_displayName: string): Promise<void> {
|
||||
await walkThroughAppCreation();
|
||||
export async function runSlackChannel(displayName: string): Promise<ChannelFlowResult> {
|
||||
const intro = await walkThroughAppCreation();
|
||||
if (intro === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
|
||||
const token = await collectBotToken();
|
||||
const signingSecret = await collectSigningSecret();
|
||||
@@ -78,29 +81,92 @@ export async function runSlackChannel(_displayName: string): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
const ownerUserId = await collectSlackUserId();
|
||||
const dmChannelId = await openDmChannel(token, ownerUserId);
|
||||
const platformId = `slack:${dmChannelId}`;
|
||||
|
||||
const role = await askOperatorRole('Slack');
|
||||
setupLog.userInput('slack_role', role);
|
||||
|
||||
const agentName = await resolveAgentName();
|
||||
|
||||
const init = await runQuietChild(
|
||||
'init-first-agent',
|
||||
'pnpm',
|
||||
[
|
||||
'exec', 'tsx', 'scripts/init-first-agent.ts',
|
||||
'--channel', 'slack',
|
||||
'--user-id', `slack:${ownerUserId}`,
|
||||
'--platform-id', platformId,
|
||||
'--display-name', displayName,
|
||||
'--agent-name', agentName,
|
||||
'--role', role,
|
||||
],
|
||||
{
|
||||
running: `Wiring ${agentName} to your Slack DMs…`,
|
||||
done: 'Agent wired.',
|
||||
},
|
||||
{
|
||||
extraFields: {
|
||||
CHANNEL: 'slack',
|
||||
AGENT_NAME: agentName,
|
||||
PLATFORM_ID: platformId,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!init.ok) {
|
||||
await fail(
|
||||
'init-first-agent',
|
||||
`Couldn't finish connecting ${agentName}.`,
|
||||
'You can retry later with `/init-first-agent` in Claude Code.',
|
||||
);
|
||||
}
|
||||
|
||||
showPostInstallChecklist(info);
|
||||
}
|
||||
|
||||
async function walkThroughAppCreation(): Promise<void> {
|
||||
p.note(
|
||||
async function walkThroughAppCreation(): Promise<'continue' | 'back'> {
|
||||
// Bright-white ANSI overrides the surrounding brand-cyan from `note()`'s
|
||||
// per-line formatter so the URL stands out against the rest of the body.
|
||||
const linkBlock = isHeadless()
|
||||
? [`\x1b[97mGet started: ${SLACK_APPS_URL}\x1b[39m`, '']
|
||||
: [];
|
||||
|
||||
note(
|
||||
[
|
||||
"You'll create a Slack app that the assistant talks through.",
|
||||
"Free and stays inside the workspaces you pick.",
|
||||
'',
|
||||
...linkBlock,
|
||||
' 1. Create a new app "From scratch", name it, pick a workspace',
|
||||
' 2. OAuth & Permissions → add Bot Token Scopes:',
|
||||
' chat:write, channels:history, groups:history, im:history,',
|
||||
' channels:read, groups:read, users:read, reactions:write',
|
||||
' • im:write, im:history',
|
||||
' • channels:read, channels:history',
|
||||
' • groups:read, groups:history',
|
||||
' • chat:write',
|
||||
' • users:read',
|
||||
' • reactions:write',
|
||||
' 3. App Home → enable "Messages Tab" and "Allow users to send',
|
||||
' slash commands and messages from the messages tab"',
|
||||
' 4. Basic Information → copy the "Signing Secret"',
|
||||
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
|
||||
'',
|
||||
k.dim(SLACK_APPS_URL),
|
||||
].join('\n'),
|
||||
'Create a Slack app',
|
||||
);
|
||||
await confirmThenOpen(SLACK_APPS_URL, 'Press Enter to open Slack app settings');
|
||||
|
||||
// Back-aware gate replacing the old `confirmThenOpen` "Press Enter to open
|
||||
// Slack app settings" so users can bail out of Slack before we open the
|
||||
// browser or ask for tokens.
|
||||
const choice = ensureAnswer(await brightSelect<'open' | 'back'>({
|
||||
message: 'Open Slack app settings in your browser?',
|
||||
options: [
|
||||
{ value: 'open', label: 'Open Slack app settings' },
|
||||
{ value: 'back', label: '← Back to channel selection' },
|
||||
],
|
||||
initialValue: 'open',
|
||||
}));
|
||||
if (choice === 'back') return 'back';
|
||||
if (!isHeadless()) openUrl(SLACK_APPS_URL);
|
||||
|
||||
ensureAnswer(
|
||||
await p.confirm({
|
||||
@@ -108,12 +174,26 @@ async function walkThroughAppCreation(): Promise<void> {
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
return 'continue';
|
||||
}
|
||||
|
||||
async function collectBotToken(): Promise<string> {
|
||||
const existing = readEnvKey('SLACK_BOT_TOKEN');
|
||||
if (existing && existing.startsWith('xoxb-') && existing.length >= 24) {
|
||||
const reuse = ensureAnswer(await p.confirm({
|
||||
message: `Found an existing Slack bot token (${existing.slice(0, 10)}…). Use it?`,
|
||||
initialValue: true,
|
||||
}));
|
||||
if (reuse) {
|
||||
setupLog.userInput('slack_bot_token', 'reused-existing');
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Paste your Slack bot token',
|
||||
clearOnError: true,
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Token is required';
|
||||
@@ -132,9 +212,22 @@ async function collectBotToken(): Promise<string> {
|
||||
}
|
||||
|
||||
async function collectSigningSecret(): Promise<string> {
|
||||
const existing = readEnvKey('SLACK_SIGNING_SECRET');
|
||||
if (existing && /^[a-f0-9]{16,}$/i.test(existing)) {
|
||||
const reuse = ensureAnswer(await p.confirm({
|
||||
message: 'Found an existing Slack signing secret. Use it?',
|
||||
initialValue: true,
|
||||
}));
|
||||
if (reuse) {
|
||||
setupLog.userInput('slack_signing_secret', 'reused-existing');
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Paste your Slack signing secret',
|
||||
clearOnError: true,
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Signing secret is required';
|
||||
@@ -175,10 +268,9 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
|
||||
user_id?: string;
|
||||
error?: string;
|
||||
};
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
if (data.ok && data.team && data.user) {
|
||||
s.stop(
|
||||
`Connected to ${data.team} as @${data.user}. ${k.dim(`(${elapsedS}s)`)}`,
|
||||
`Connected to ${data.team} as @${data.user}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`,
|
||||
);
|
||||
const info: WorkspaceInfo = {
|
||||
teamName: data.team,
|
||||
@@ -207,8 +299,7 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
|
||||
: `Slack said "${reason}". Check the token scopes and workspace install, then retry.`,
|
||||
);
|
||||
} catch (err) {
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`Couldn't reach Slack. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
||||
s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setupLog.step('slack-validate', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
@@ -221,26 +312,133 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
|
||||
}
|
||||
}
|
||||
|
||||
async function collectSlackUserId(): Promise<string> {
|
||||
note(
|
||||
[
|
||||
"To get your Slack member ID:",
|
||||
'',
|
||||
' 1. In Slack, click your profile picture (bottom left)',
|
||||
' 2. Click "Profile"',
|
||||
' 3. Click the three dots (⋮) → "Copy member ID"',
|
||||
].join('\n'),
|
||||
'Find your Slack user ID',
|
||||
);
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'Paste your Slack member ID',
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Member ID is required';
|
||||
if (!/^U[A-Z0-9]{8,}$/.test(t)) {
|
||||
return "That doesn't look like a Slack member ID (starts with U)";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const id = (answer as string).trim();
|
||||
setupLog.userInput('slack_user_id', id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function openDmChannel(token: string, userId: string): Promise<string> {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
s.start('Opening a DM channel…');
|
||||
try {
|
||||
const res = await fetch(`${SLACK_API}/conversations.open`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ users: userId }),
|
||||
});
|
||||
const data = (await res.json()) as {
|
||||
ok?: boolean;
|
||||
channel?: { id?: string };
|
||||
error?: string;
|
||||
};
|
||||
if (data.ok && data.channel?.id) {
|
||||
s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||
setupLog.step('slack-open-dm', 'success', Date.now() - start, {
|
||||
DM_CHANNEL_ID: data.channel.id,
|
||||
});
|
||||
return data.channel.id;
|
||||
}
|
||||
const reason = data.error ?? `HTTP ${res.status}`;
|
||||
s.stop(`Couldn't open a DM channel: ${reason}`, 1);
|
||||
setupLog.step('slack-open-dm', 'failed', Date.now() - start, {
|
||||
ERROR: reason,
|
||||
});
|
||||
if (reason === 'missing_scope') {
|
||||
await fail(
|
||||
'slack-open-dm',
|
||||
"Your Slack app is missing the im:write scope.",
|
||||
'Go to OAuth & Permissions in your Slack app settings, add the im:write scope, reinstall the app, then retry setup.',
|
||||
);
|
||||
}
|
||||
await fail(
|
||||
'slack-open-dm',
|
||||
"Couldn't open a DM channel with you.",
|
||||
`Slack said "${reason}". Check the member ID and app permissions, then retry.`,
|
||||
);
|
||||
} catch (err) {
|
||||
s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setupLog.step('slack-open-dm', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
});
|
||||
await fail(
|
||||
'slack-open-dm',
|
||||
"Couldn't reach Slack.",
|
||||
'Check your internet connection and retry setup.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAgentName(): Promise<string> {
|
||||
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
|
||||
if (preset) {
|
||||
setupLog.userInput('agent_name', preset);
|
||||
return preset;
|
||||
}
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: `What should your ${accentGreen('assistant')} be called?`,
|
||||
placeholder: DEFAULT_AGENT_NAME,
|
||||
defaultValue: DEFAULT_AGENT_NAME,
|
||||
}),
|
||||
);
|
||||
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
|
||||
setupLog.userInput('agent_name', value);
|
||||
return value;
|
||||
}
|
||||
|
||||
function showPostInstallChecklist(info: WorkspaceInfo): void {
|
||||
p.note(
|
||||
note(
|
||||
wrapForGutter(
|
||||
[
|
||||
`The Slack adapter is installed and your creds are saved. ${info.teamName} still needs two things before it can talk to you:`,
|
||||
`Your agent is wired to Slack and a welcome DM is on its way.`,
|
||||
`To receive replies, Slack needs a public URL for delivering events:`,
|
||||
'',
|
||||
' 1. A public URL so Slack can deliver events.',
|
||||
' NanoClaw serves a webhook on port 3000 by default — expose it',
|
||||
' via ngrok, Cloudflare Tunnel, or a reverse proxy on a VPS.',
|
||||
' 1. Expose NanoClaw\'s webhook server (port 3000) via ngrok,',
|
||||
' Cloudflare Tunnel, or a reverse proxy on a VPS.',
|
||||
'',
|
||||
' 2. In your Slack app → Event Subscriptions:',
|
||||
' • Toggle "Enable Events" on',
|
||||
` • Request URL: https://<your-public-host>/webhook/slack`,
|
||||
' • Subscribe to bot events: message.channels, message.groups,',
|
||||
' message.im, app_mention',
|
||||
' • Save, then reinstall the app when Slack prompts',
|
||||
' • Save Changes',
|
||||
'',
|
||||
` 3. DM @${info.botName} from Slack once — that bootstraps the`,
|
||||
' messaging group. Then run `/manage-channels` in `claude` to',
|
||||
' wire an agent to it.',
|
||||
' 3. In your Slack app → Interactivity & Shortcuts:',
|
||||
' • Toggle "Interactivity" on',
|
||||
` • Request URL: https://<your-public-host>/webhook/slack`,
|
||||
' • Save Changes',
|
||||
'',
|
||||
' 4. Slack will prompt you to reinstall the app — do it to apply',
|
||||
' the new settings',
|
||||
].join('\n'),
|
||||
6,
|
||||
),
|
||||
|
||||
+120
-40
@@ -30,6 +30,7 @@ import path from 'path';
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
|
||||
import { brightSelect } from '../lib/bright-select.js';
|
||||
import { confirmThenOpen } from '../lib/browser.js';
|
||||
import {
|
||||
@@ -40,7 +41,9 @@ import {
|
||||
} from '../lib/claude-handoff.js';
|
||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||
import { buildTeamsAppPackage } from '../lib/teams-manifest.js';
|
||||
import { note } from '../lib/theme.js';
|
||||
import * as setupLog from '../logs.js';
|
||||
import { readEnvKey } from '../environment.js';
|
||||
|
||||
const CHANNEL = 'teams';
|
||||
const MANIFEST_DIR = path.join(process.cwd(), 'data', 'teams');
|
||||
@@ -55,20 +58,62 @@ interface Collected {
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
export async function runTeamsChannel(_displayName: string): Promise<void> {
|
||||
export async function runTeamsChannel(_displayName: string): Promise<ChannelFlowResult> {
|
||||
const collected: Collected = {};
|
||||
const completed: string[] = [];
|
||||
|
||||
const existingAppId = readEnvKey('TEAMS_APP_ID');
|
||||
const existingPassword = readEnvKey('TEAMS_APP_PASSWORD');
|
||||
if (existingAppId && existingPassword) {
|
||||
const choice = ensureAnswer(await brightSelect<'yes' | 'no' | 'back'>({
|
||||
message: `Found existing Teams credentials (App ID: ${existingAppId.slice(0, 8)}…). Use them?`,
|
||||
options: [
|
||||
{ value: 'yes', label: 'Yes, use the existing credentials' },
|
||||
{ value: 'no', label: "No, set up new ones" },
|
||||
{ value: 'back', label: '← Back to channel selection' },
|
||||
],
|
||||
initialValue: 'yes',
|
||||
}));
|
||||
if (choice === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
if (choice === 'yes') {
|
||||
collected.appId = existingAppId;
|
||||
collected.appPassword = existingPassword;
|
||||
collected.appType = (readEnvKey('TEAMS_APP_TYPE') as 'SingleTenant' | 'MultiTenant') || 'MultiTenant';
|
||||
if (collected.appType === 'SingleTenant') {
|
||||
collected.tenantId = readEnvKey('TEAMS_APP_TENANT_ID') ?? undefined;
|
||||
}
|
||||
setupLog.userInput('teams_credentials', 'reused-existing');
|
||||
await installAdapter(collected);
|
||||
completed.push('Adapter installed and service restarted (reused existing credentials).');
|
||||
await finishWithHandoff(collected, completed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
printIntro();
|
||||
|
||||
await confirmPrereqs({ collected, completed });
|
||||
const prereqsResult = await confirmPrereqs({ collected, completed });
|
||||
if (prereqsResult === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
await stepPublicUrl({ collected, completed });
|
||||
await stepAppRegistration({ collected, completed });
|
||||
await stepClientSecret({ collected, completed });
|
||||
await stepAzureBot({ collected, completed });
|
||||
await stepEnableTeamsChannel({ collected, completed });
|
||||
if (await stepAppRegistration({ collected, completed }) === 'back') {
|
||||
return BACK_TO_CHANNEL_SELECTION;
|
||||
}
|
||||
if (await stepClientSecret({ collected, completed }) === 'back') {
|
||||
return BACK_TO_CHANNEL_SELECTION;
|
||||
}
|
||||
if (await stepAzureBot({ collected, completed }) === 'back') {
|
||||
return BACK_TO_CHANNEL_SELECTION;
|
||||
}
|
||||
if (await stepEnableTeamsChannel({ collected, completed }) === 'back') {
|
||||
return BACK_TO_CHANNEL_SELECTION;
|
||||
}
|
||||
const manifestResult = await stepGenerateManifest({ collected, completed });
|
||||
await stepSideload({ collected, completed, zipPath: manifestResult.zipPath });
|
||||
if (
|
||||
await stepSideload({ collected, completed, zipPath: manifestResult.zipPath })
|
||||
=== 'back'
|
||||
) {
|
||||
return BACK_TO_CHANNEL_SELECTION;
|
||||
}
|
||||
|
||||
await installAdapter(collected);
|
||||
completed.push('Adapter installed and service restarted.');
|
||||
@@ -79,7 +124,7 @@ export async function runTeamsChannel(_displayName: string): Promise<void> {
|
||||
// ─── step: intro / prereqs ──────────────────────────────────────────────
|
||||
|
||||
function printIntro(): void {
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
'Setting up Teams is more involved than the other channels — about',
|
||||
'7 steps across the Azure portal and Teams admin.',
|
||||
@@ -92,8 +137,8 @@ function printIntro(): void {
|
||||
);
|
||||
}
|
||||
|
||||
async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise<void> {
|
||||
p.note(
|
||||
async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise<'continue' | 'back'> {
|
||||
note(
|
||||
[
|
||||
'Before we start, confirm you have:',
|
||||
'',
|
||||
@@ -107,19 +152,42 @@ async function confirmPrereqs(args: { collected: Collected; completed: string[]
|
||||
'Prereqs',
|
||||
);
|
||||
|
||||
await stepGate({
|
||||
stepName: 'teams-prereqs',
|
||||
stepDescription: 'confirming they have the right Microsoft 365 tenant and tunnel',
|
||||
reshow: () => confirmPrereqs(args),
|
||||
args,
|
||||
});
|
||||
// Back-aware variant of stepGate — Back is only offered on the very first
|
||||
// step of the Teams flow so users can bail out before any state is taken.
|
||||
while (true) {
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect<'done' | 'help' | 'reshow' | 'back'>({
|
||||
message: 'How did that go?',
|
||||
options: [
|
||||
{ value: 'done', label: "Done — let's continue" },
|
||||
{ value: 'help', label: 'Stuck — hand me off to Claude' },
|
||||
{ value: 'reshow', label: 'Show me the steps again' },
|
||||
{ value: 'back', label: '← Back to channel selection' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
if (choice === 'back') return 'back';
|
||||
if (choice === 'done') break;
|
||||
if (choice === 'help') {
|
||||
await offerHandoff({
|
||||
step: 'teams-prereqs',
|
||||
stepDescription: 'confirming they have the right Microsoft 365 tenant and tunnel',
|
||||
args,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (choice === 'reshow') {
|
||||
return confirmPrereqs(args);
|
||||
}
|
||||
}
|
||||
args.completed.push('Prereqs confirmed.');
|
||||
return 'continue';
|
||||
}
|
||||
|
||||
// ─── step: public URL ──────────────────────────────────────────────────
|
||||
|
||||
async function stepPublicUrl(args: { collected: Collected; completed: string[] }): Promise<void> {
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
"Azure Bot Service delivers messages to an HTTPS endpoint you",
|
||||
"control. The endpoint needs to reach this machine's webhook",
|
||||
@@ -174,8 +242,8 @@ async function stepPublicUrl(args: { collected: Collected; completed: string[] }
|
||||
async function stepAppRegistration(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
}): Promise<void> {
|
||||
p.note(
|
||||
}): Promise<'continue' | 'back'> {
|
||||
note(
|
||||
[
|
||||
`1. In ${AZURE_PORTAL_URL}, search "App registrations" → "New registration"`,
|
||||
'2. Name it (e.g. "NanoClaw")',
|
||||
@@ -207,15 +275,17 @@ async function stepAppRegistration(args: {
|
||||
);
|
||||
}
|
||||
|
||||
await stepGate({
|
||||
const gate = await stepGate({
|
||||
stepName: 'teams-app-registration',
|
||||
stepDescription: 'registering an app in Azure and collecting App ID + tenant type',
|
||||
reshow: () => stepAppRegistration(args),
|
||||
args,
|
||||
});
|
||||
if (gate === 'back') return 'back';
|
||||
args.completed.push(
|
||||
`App registered: ${args.collected.appId} (${args.collected.appType})`,
|
||||
);
|
||||
return 'continue';
|
||||
}
|
||||
|
||||
async function askAppType(args: {
|
||||
@@ -258,8 +328,8 @@ async function askAppType(args: {
|
||||
async function stepClientSecret(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
}): Promise<void> {
|
||||
p.note(
|
||||
}): Promise<'continue' | 'back'> {
|
||||
note(
|
||||
[
|
||||
`1. In your app registration, open "Certificates & secrets"`,
|
||||
'2. Click "New client secret"',
|
||||
@@ -276,6 +346,7 @@ async function stepClientSecret(args: {
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Paste the client secret Value',
|
||||
clearOnError: true,
|
||||
validate: validateWithHelpEscape((v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Required';
|
||||
@@ -300,13 +371,15 @@ async function stepClientSecret(args: {
|
||||
break;
|
||||
}
|
||||
|
||||
await stepGate({
|
||||
const gate = await stepGate({
|
||||
stepName: 'teams-client-secret',
|
||||
stepDescription: 'creating and copying the client secret',
|
||||
reshow: () => stepClientSecret(args),
|
||||
args,
|
||||
});
|
||||
if (gate === 'back') return 'back';
|
||||
args.completed.push('Client secret captured.');
|
||||
return 'continue';
|
||||
}
|
||||
|
||||
// ─── step: Azure Bot resource ──────────────────────────────────────────
|
||||
@@ -314,7 +387,7 @@ async function stepClientSecret(args: {
|
||||
async function stepAzureBot(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
}): Promise<void> {
|
||||
}): Promise<'continue' | 'back'> {
|
||||
const endpoint = `${args.collected.publicUrl}/api/webhooks/teams`;
|
||||
const tenantFlag =
|
||||
args.collected.appType === 'SingleTenant'
|
||||
@@ -328,7 +401,7 @@ async function stepAzureBot(args: {
|
||||
` --appid ${args.collected.appId} \\\n` +
|
||||
` ${tenantFlag}--endpoint "${endpoint}"`;
|
||||
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
`In ${AZURE_PORTAL_URL}, search "Azure Bot" → Create.`,
|
||||
'',
|
||||
@@ -349,14 +422,16 @@ async function stepAzureBot(args: {
|
||||
'Step 3 of 6 — Create Azure Bot resource',
|
||||
);
|
||||
|
||||
await stepGate({
|
||||
const gate = await stepGate({
|
||||
stepName: 'teams-azure-bot',
|
||||
stepDescription:
|
||||
'creating an Azure Bot resource linked to the app registration and setting the messaging endpoint',
|
||||
reshow: () => stepAzureBot(args),
|
||||
args,
|
||||
});
|
||||
if (gate === 'back') return 'back';
|
||||
args.completed.push('Azure Bot created; messaging endpoint configured.');
|
||||
return 'continue';
|
||||
}
|
||||
|
||||
// ─── step: enable Teams channel ────────────────────────────────────────
|
||||
@@ -364,8 +439,8 @@ async function stepAzureBot(args: {
|
||||
async function stepEnableTeamsChannel(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
}): Promise<void> {
|
||||
p.note(
|
||||
}): Promise<'continue' | 'back'> {
|
||||
note(
|
||||
[
|
||||
'1. Open your Azure Bot resource → Channels',
|
||||
'2. Click Microsoft Teams → Accept terms → Apply',
|
||||
@@ -375,13 +450,15 @@ async function stepEnableTeamsChannel(args: {
|
||||
].join('\n'),
|
||||
'Step 4 of 6 — Enable Teams channel on the bot',
|
||||
);
|
||||
await stepGate({
|
||||
const gate = await stepGate({
|
||||
stepName: 'teams-enable-channel',
|
||||
stepDescription: 'enabling the Microsoft Teams channel on the Azure Bot resource',
|
||||
reshow: () => stepEnableTeamsChannel(args),
|
||||
args,
|
||||
});
|
||||
if (gate === 'back') return 'back';
|
||||
args.completed.push('Teams channel enabled on the bot.');
|
||||
return 'continue';
|
||||
}
|
||||
|
||||
// ─── step: manifest zip ────────────────────────────────────────────────
|
||||
@@ -434,8 +511,8 @@ async function stepSideload(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
zipPath: string;
|
||||
}): Promise<void> {
|
||||
p.note(
|
||||
}): Promise<'continue' | 'back'> {
|
||||
note(
|
||||
[
|
||||
'1. Open Microsoft Teams',
|
||||
'2. Go to Apps → Manage your apps → Upload an app',
|
||||
@@ -449,13 +526,15 @@ async function stepSideload(args: {
|
||||
].join('\n'),
|
||||
'Step 5 of 6 — Sideload the app into Teams',
|
||||
);
|
||||
await stepGate({
|
||||
const gate = await stepGate({
|
||||
stepName: 'teams-sideload',
|
||||
stepDescription: 'uploading the generated zip into Teams as a custom app',
|
||||
reshow: () => stepSideload(args),
|
||||
reshow: () => stepSideload({ ...args, zipPath: args.zipPath }),
|
||||
args,
|
||||
});
|
||||
if (gate === 'back') return 'back';
|
||||
args.completed.push('App sideloaded into Teams.');
|
||||
return 'continue';
|
||||
}
|
||||
|
||||
// ─── step: install adapter ─────────────────────────────────────────────
|
||||
@@ -501,7 +580,7 @@ async function finishWithHandoff(
|
||||
collected: Collected,
|
||||
completed: string[],
|
||||
): Promise<void> {
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
'The Teams adapter is live and the service is running.',
|
||||
'',
|
||||
@@ -530,7 +609,7 @@ async function finishWithHandoff(
|
||||
);
|
||||
|
||||
if (choice === 'self') {
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
' 1. Find your bot in Teams (search by name, or via the sideloaded',
|
||||
' app) and send it a message ("hi" is fine)',
|
||||
@@ -567,9 +646,9 @@ async function finishWithHandoff(
|
||||
async function stepGate(args: {
|
||||
stepName: string;
|
||||
stepDescription: string;
|
||||
reshow: () => Promise<void> | Promise<unknown>;
|
||||
reshow: () => Promise<'continue' | 'back'>;
|
||||
args: { collected: Collected; completed: string[] };
|
||||
}): Promise<void> {
|
||||
}): Promise<'continue' | 'back'> {
|
||||
while (true) {
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect({
|
||||
@@ -578,10 +657,12 @@ async function stepGate(args: {
|
||||
{ value: 'done', label: "Done — let's continue" },
|
||||
{ value: 'help', label: 'Stuck — hand me off to Claude' },
|
||||
{ value: 'reshow', label: 'Show me the steps again' },
|
||||
{ value: 'back', label: '← Back to channel selection' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
if (choice === 'done') return;
|
||||
if (choice === 'done') return 'continue';
|
||||
if (choice === 'back') return 'back';
|
||||
if (choice === 'help') {
|
||||
await offerHandoff({
|
||||
step: args.stepName,
|
||||
@@ -591,8 +672,7 @@ async function stepGate(args: {
|
||||
continue;
|
||||
}
|
||||
if (choice === 'reshow') {
|
||||
await args.reshow();
|
||||
return;
|
||||
return args.reshow();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+82
-23
@@ -21,7 +21,10 @@ import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { confirmThenOpen } from '../lib/browser.js';
|
||||
import { isHeadless } from '../platform.js';
|
||||
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
|
||||
import { confirmThenOpen, formatNoteLink, openUrl } from '../lib/browser.js';
|
||||
import { brightSelect } from '../lib/bright-select.js';
|
||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||
import {
|
||||
type Block,
|
||||
@@ -33,12 +36,15 @@ import {
|
||||
spawnStep,
|
||||
writeStepEntry,
|
||||
} from '../lib/runner.js';
|
||||
import { brandBold } from '../lib/theme.js';
|
||||
import { readEnvKey } from '../environment.js';
|
||||
import { accentGreen, brandBold, fitToWidth, fmtDuration, note } from '../lib/theme.js';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
|
||||
export async function runTelegramChannel(displayName: string): Promise<void> {
|
||||
const token = await collectTelegramToken();
|
||||
export async function runTelegramChannel(displayName: string): Promise<ChannelFlowResult> {
|
||||
const tokenOrBack = await collectTelegramToken();
|
||||
if (tokenOrBack === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
const token = tokenOrBack;
|
||||
const botUsername = await validateTelegramToken(token);
|
||||
|
||||
// Deep-link the user into the bot's chat so they're on the right screen
|
||||
@@ -47,15 +53,37 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
|
||||
// installed, or the bot's web profile if not. tg://resolve?domain= is
|
||||
// more direct but silently fails when the scheme isn't registered.
|
||||
const botUrl = `https://t.me/${botUsername}`;
|
||||
p.note(
|
||||
[
|
||||
// Two card variants — auto-open fires only on GUI, so headless users
|
||||
// need full self-serve instructions inside the card itself, while GUI
|
||||
// users get a leaner status line plus the auto-open + a single
|
||||
// combined dim fallback line (URL + mobile alternative) on the
|
||||
// confirm prompt below.
|
||||
if (isHeadless()) {
|
||||
note(
|
||||
[
|
||||
`Open @${botUsername} in Telegram now — the pairing code is coming next, and that's where you'll send it.`,
|
||||
'',
|
||||
`Get started: ${botUrl}`,
|
||||
'',
|
||||
`Don't have Telegram installed here? Open it on any device and search for @${botUsername}`,
|
||||
].join('\n'),
|
||||
'Open Telegram',
|
||||
);
|
||||
} else {
|
||||
note(
|
||||
`Opening @${botUsername} in Telegram so it's ready when the pairing code shows up.`,
|
||||
'',
|
||||
k.dim(botUrl),
|
||||
].join('\n'),
|
||||
'Open Telegram',
|
||||
);
|
||||
await confirmThenOpen(botUrl, 'Press Enter to open Telegram');
|
||||
'Open Telegram',
|
||||
);
|
||||
ensureAnswer(
|
||||
await p.confirm({
|
||||
message: `Press Enter to open Telegram (must be installed here)\n${k.dim(
|
||||
`If browser does not appear, please visit: ${botUrl} — or search for @${botUsername} in Telegram`,
|
||||
)}`,
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
openUrl(botUrl);
|
||||
}
|
||||
|
||||
const install = await runQuietChild(
|
||||
'telegram-install',
|
||||
@@ -131,13 +159,32 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function collectTelegramToken(): Promise<string> {
|
||||
p.note(
|
||||
async function collectTelegramToken(): Promise<string | 'back'> {
|
||||
const existing = readEnvKey('TELEGRAM_BOT_TOKEN');
|
||||
if (existing && /^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(existing)) {
|
||||
const choice = ensureAnswer(await brightSelect<'yes' | 'no' | 'back'>({
|
||||
message: `Found an existing Telegram bot token (${existing.slice(0, 8)}…). Use it?`,
|
||||
options: [
|
||||
{ value: 'yes', label: 'Yes, use the existing token' },
|
||||
{ value: 'no', label: 'No, paste a new one' },
|
||||
{ value: 'back', label: '← Back to channel selection' },
|
||||
],
|
||||
initialValue: 'yes',
|
||||
}));
|
||||
if (choice === 'back') return 'back';
|
||||
if (choice === 'yes') {
|
||||
setupLog.userInput('telegram_token', 'reused-existing');
|
||||
return existing;
|
||||
}
|
||||
// 'no' falls through to the paste flow below
|
||||
}
|
||||
|
||||
note(
|
||||
[
|
||||
"Your assistant talks to you through a Telegram bot you create.",
|
||||
"Here's how:",
|
||||
'',
|
||||
' 1. Open Telegram and message @BotFather',
|
||||
" 1. Open Telegram and message @BotFather — Telegram's official bot for creating and managing bots",
|
||||
' 2. Send /newbot and follow the prompts',
|
||||
' 3. Copy the token it gives you (it looks like <digits>:<chars>)',
|
||||
'',
|
||||
@@ -147,9 +194,23 @@ async function collectTelegramToken(): Promise<string> {
|
||||
'Set up your Telegram bot',
|
||||
);
|
||||
|
||||
// Back-aware gate before the password prompt — `p.password` doesn't
|
||||
// accept extra options, so we offer Back as a separate brightSelect
|
||||
// immediately after the BotFather instructions and before the paste.
|
||||
const proceed = ensureAnswer(await brightSelect<'continue' | 'back'>({
|
||||
message: 'Ready to paste your bot token?',
|
||||
options: [
|
||||
{ value: 'continue', label: 'Yes, paste it on the next prompt' },
|
||||
{ value: 'back', label: '← Back to channel selection' },
|
||||
],
|
||||
initialValue: 'continue',
|
||||
}));
|
||||
if (proceed === 'back') return 'back';
|
||||
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Paste your bot token',
|
||||
clearOnError: true,
|
||||
validate: (v) => {
|
||||
if (!v || !v.trim()) return "Token is required";
|
||||
if (!/^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(v.trim())) {
|
||||
@@ -178,10 +239,9 @@ async function validateTelegramToken(token: string): Promise<string> {
|
||||
result?: { username?: string; id?: number };
|
||||
description?: string;
|
||||
};
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
if (data.ok && data.result?.username) {
|
||||
const username = data.result.username;
|
||||
s.stop(`Found your bot: @${username}. ${k.dim(`(${elapsedS}s)`)}`);
|
||||
s.stop(`Found your bot: @${username}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||
setupLog.step('telegram-validate', 'success', Date.now() - start, {
|
||||
BOT_USERNAME: username,
|
||||
BOT_ID: data.result.id ?? '',
|
||||
@@ -199,8 +259,7 @@ async function validateTelegramToken(token: string): Promise<string> {
|
||||
'Copy the token again from @BotFather and try setup once more.',
|
||||
);
|
||||
} catch (err) {
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`Couldn't reach Telegram. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
||||
s.stop(`Couldn't reach Telegram. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setupLog.step('telegram-validate', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
@@ -240,12 +299,12 @@ async function runPairTelegram(): Promise<
|
||||
} else {
|
||||
stopSpinner("Old code expired. Here's a fresh one.");
|
||||
}
|
||||
p.note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code');
|
||||
s.start('Waiting for you to send the code from Telegram…');
|
||||
note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code');
|
||||
s.start(fitToWidth('Waiting for you to send the code from Telegram…', ''));
|
||||
spinnerActive = true;
|
||||
} else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') {
|
||||
stopSpinner(`Got "${block.fields.CANDIDATE ?? '?'}", not a match.`);
|
||||
s.start('Waiting for the correct code…');
|
||||
s.start(fitToWidth('Waiting for the correct code…', ''));
|
||||
spinnerActive = true;
|
||||
} else if (block.type === 'PAIR_TELEGRAM') {
|
||||
if (block.fields.STATUS === 'success') {
|
||||
@@ -291,7 +350,7 @@ async function resolveAgentName(): Promise<string> {
|
||||
}
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'What should your assistant be called?',
|
||||
message: `What should your ${accentGreen('assistant')} be called?`,
|
||||
placeholder: DEFAULT_AGENT_NAME,
|
||||
defaultValue: DEFAULT_AGENT_NAME,
|
||||
}),
|
||||
|
||||
+19
-14
@@ -33,6 +33,7 @@ import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
|
||||
import { brightSelect } from '../lib/bright-select.js';
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js';
|
||||
import {
|
||||
@@ -46,15 +47,16 @@ import {
|
||||
writeStepEntry,
|
||||
} from '../lib/runner.js';
|
||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||
import { brandBold } from '../lib/theme.js';
|
||||
import { accentGreen, brandBody, brandBold, fmtDuration, note } from '../lib/theme.js';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json');
|
||||
|
||||
type AuthMethod = 'qr' | 'pairing-code';
|
||||
|
||||
export async function runWhatsAppChannel(displayName: string): Promise<void> {
|
||||
export async function runWhatsAppChannel(displayName: string): Promise<ChannelFlowResult> {
|
||||
const method = await askAuthMethod();
|
||||
if (method === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
const phone = method === 'pairing-code' ? await askPhoneNumber() : undefined;
|
||||
|
||||
const install = await runQuietChild(
|
||||
@@ -148,7 +150,7 @@ export async function runWhatsAppChannel(displayName: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function askAuthMethod(): Promise<AuthMethod> {
|
||||
async function askAuthMethod(): Promise<AuthMethod | 'back'> {
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect({
|
||||
message: 'How would you like to authenticate with WhatsApp?',
|
||||
@@ -163,15 +165,19 @@ async function askAuthMethod(): Promise<AuthMethod> {
|
||||
label: 'Enter a pairing code on your phone',
|
||||
hint: 'no camera needed',
|
||||
},
|
||||
{
|
||||
value: 'back',
|
||||
label: '← Back to channel selection',
|
||||
},
|
||||
],
|
||||
}),
|
||||
) as AuthMethod;
|
||||
setupLog.userInput('whatsapp_auth_method', choice);
|
||||
) as AuthMethod | 'back';
|
||||
if (choice !== 'back') setupLog.userInput('whatsapp_auth_method', choice);
|
||||
return choice;
|
||||
}
|
||||
|
||||
async function askPhoneNumber(): Promise<string> {
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
"Enter your phone number the way WhatsApp expects it:",
|
||||
'',
|
||||
@@ -249,7 +255,7 @@ async function runWhatsAppAuth(
|
||||
} else if (block.type === 'WHATSAPP_AUTH_PAIRING_CODE') {
|
||||
const code = block.fields.CODE ?? '????';
|
||||
stopSpinner('Your pairing code is ready.');
|
||||
p.note(formatPairingCard(code), 'Pairing code');
|
||||
note(formatPairingCard(code), 'Pairing code');
|
||||
s.start('Waiting for you to enter the code…');
|
||||
spinnerActive = true;
|
||||
} else if (block.type === 'WHATSAPP_AUTH') {
|
||||
@@ -267,7 +273,7 @@ async function runWhatsAppAuth(
|
||||
if (spinnerActive) {
|
||||
stopSpinner('WhatsApp linked.');
|
||||
} else {
|
||||
p.log.success('WhatsApp linked.');
|
||||
p.log.success(brandBody('WhatsApp linked.'));
|
||||
}
|
||||
} else if (status === 'failed') {
|
||||
if (qrLinesPrinted > 0) {
|
||||
@@ -312,7 +318,7 @@ async function renderQr(qr: string): Promise<string[]> {
|
||||
const QRCode = await import('qrcode');
|
||||
const qrText = await QRCode.toString(qr, { type: 'terminal', small: true });
|
||||
const caption = k.dim(
|
||||
' Open WhatsApp → Settings → Linked Devices → Link a Device → scan.',
|
||||
' Open WhatsApp → You / Settings → Linked Devices → Link a Device → scan.',
|
||||
);
|
||||
return [...qrText.trimEnd().split('\n'), '', caption];
|
||||
} catch {
|
||||
@@ -328,7 +334,7 @@ function formatPairingCard(code: string): string {
|
||||
'',
|
||||
` ${brandBold(spaced)}`,
|
||||
'',
|
||||
k.dim(' Open WhatsApp → Settings → Linked Devices → Link a Device'),
|
||||
k.dim(' Open WhatsApp → You / Settings → Linked Devices → Link a Device'),
|
||||
k.dim(' → "Link with phone number instead" → enter this code.'),
|
||||
k.dim(' It expires in ~60 seconds.'),
|
||||
].join('\n');
|
||||
@@ -379,8 +385,7 @@ async function restartService(): Promise<void> {
|
||||
// Give the adapter a moment to reconnect before init-first-agent's
|
||||
// welcome DM hits the delivery path.
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`);
|
||||
s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||
setupLog.step('whatsapp-restart', 'success', Date.now() - start, {
|
||||
PLATFORM: platform,
|
||||
});
|
||||
@@ -395,7 +400,7 @@ async function restartService(): Promise<void> {
|
||||
}
|
||||
|
||||
async function askChatPhone(authedPhone: string): Promise<string> {
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
`Authenticated with ${k.cyan('+' + authedPhone)}.`,
|
||||
'',
|
||||
@@ -462,7 +467,7 @@ async function resolveAgentName(): Promise<string> {
|
||||
}
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'What should your assistant be called?',
|
||||
message: `What should your ${accentGreen('assistant')} be called?`,
|
||||
placeholder: DEFAULT_AGENT_NAME,
|
||||
defaultValue: DEFAULT_AGENT_NAME,
|
||||
}),
|
||||
|
||||
+10
-2
@@ -8,6 +8,7 @@
|
||||
* Args:
|
||||
* --display-name <name> (required) operator's display name
|
||||
* --agent-name <name> (optional) agent persona name, defaults to display-name
|
||||
* --folder <name> (optional) explicit folder name, defaults to cli-with-<normalized-display-name>
|
||||
*/
|
||||
import { execFileSync } from 'child_process';
|
||||
import path from 'path';
|
||||
@@ -18,9 +19,11 @@ import { emitStatus } from './status.js';
|
||||
function parseArgs(args: string[]): {
|
||||
displayName: string;
|
||||
agentName?: string;
|
||||
folder?: string;
|
||||
} {
|
||||
let displayName: string | undefined;
|
||||
let agentName: string | undefined;
|
||||
let folder: string | undefined;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const key = args[i];
|
||||
@@ -34,6 +37,10 @@ function parseArgs(args: string[]): {
|
||||
agentName = val;
|
||||
i++;
|
||||
break;
|
||||
case '--folder':
|
||||
folder = val;
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,17 +53,18 @@ function parseArgs(args: string[]): {
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
return { displayName, agentName };
|
||||
return { displayName, agentName, folder };
|
||||
}
|
||||
|
||||
export async function run(args: string[]): Promise<void> {
|
||||
const { displayName, agentName } = parseArgs(args);
|
||||
const { displayName, agentName, folder } = parseArgs(args);
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const script = path.join(projectRoot, 'scripts', 'init-cli-agent.ts');
|
||||
|
||||
const scriptArgs = ['exec', 'tsx', script, '--display-name', displayName];
|
||||
if (agentName) scriptArgs.push('--agent-name', agentName);
|
||||
if (folder) scriptArgs.push('--folder', folder);
|
||||
|
||||
log.info('Invoking init-cli-agent', { displayName, agentName });
|
||||
|
||||
|
||||
+15
-4
@@ -127,11 +127,22 @@ export async function run(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
// Socket is unreachable due to group perms — current shell's supplementary
|
||||
// groups are fixed at login, so `usermod -aG docker` (via install-docker.sh
|
||||
// or a prior install) doesn't affect us until next login. Re-exec this
|
||||
// step under `sg docker` so the child picks up docker as its primary
|
||||
// group and can talk to /var/run/docker.sock without a logout.
|
||||
// groups are fixed at login, so `usermod -aG docker` doesn't affect us
|
||||
// until next login. Ensure the user is in the docker group (install-docker.sh
|
||||
// does this on fresh installs, but skips when Docker is already present),
|
||||
// then re-exec under `sg docker` so the child picks up docker as its
|
||||
// primary group and can talk to /var/run/docker.sock without a logout.
|
||||
if (status === 'no-permission' && getPlatform() === 'linux' && commandExists('sg')) {
|
||||
// Ensure the current user is in the docker group — without this,
|
||||
// sg will ask for the (typically unset) group password and fail.
|
||||
const inGroup = spawnSync('id', ['-nG'], { encoding: 'utf-8' });
|
||||
if (!(inGroup.stdout ?? '').split(/\s+/).includes('docker')) {
|
||||
log.info('Adding current user to docker group');
|
||||
spawnSync('sudo', ['usermod', '-aG', 'docker', process.env.USER ?? ''], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
|
||||
log.info('Re-executing container step under `sg docker`');
|
||||
const res = spawnSync(
|
||||
'sg',
|
||||
|
||||
@@ -84,21 +84,28 @@ describe('credentials detection', () => {
|
||||
const content =
|
||||
'SOME_KEY=value\nANTHROPIC_API_KEY=sk-ant-test123\nOTHER=foo';
|
||||
const hasCredentials =
|
||||
/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content);
|
||||
/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|ONECLI_URL)=/m.test(content);
|
||||
expect(hasCredentials).toBe(true);
|
||||
});
|
||||
|
||||
it('detects CLAUDE_CODE_OAUTH_TOKEN in env content', () => {
|
||||
const content = 'CLAUDE_CODE_OAUTH_TOKEN=token123';
|
||||
const hasCredentials =
|
||||
/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content);
|
||||
/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|ONECLI_URL)=/m.test(content);
|
||||
expect(hasCredentials).toBe(true);
|
||||
});
|
||||
|
||||
it('detects ANTHROPIC_AUTH_TOKEN in env content', () => {
|
||||
const content = 'ANTHROPIC_AUTH_TOKEN=token123\nANTHROPIC_BASE_URL=http://localhost:8080';
|
||||
const hasCredentials =
|
||||
/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|ONECLI_URL)=/m.test(content);
|
||||
expect(hasCredentials).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when no credentials', () => {
|
||||
const content = 'ASSISTANT_NAME="Andy"\nOTHER=foo';
|
||||
const hasCredentials =
|
||||
/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content);
|
||||
/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|ONECLI_URL)=/m.test(content);
|
||||
expect(hasCredentials).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,48 @@ import { log } from '../src/log.js';
|
||||
import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
/**
|
||||
* Read a single key from `.env` on disk (not process.env).
|
||||
* Returns the trimmed value or null if the key isn't set / file doesn't exist.
|
||||
*/
|
||||
export function readEnvKey(key: string, projectRoot?: string): string | null {
|
||||
const envPath = path.join(projectRoot ?? process.cwd(), '.env');
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(envPath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eq = trimmed.indexOf('=');
|
||||
if (eq < 1) continue;
|
||||
if (trimmed.slice(0, eq) === key) {
|
||||
return trimmed.slice(eq + 1).trim() || null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function detectExistingDisplayName(projectRoot: string): string | null {
|
||||
const dbPath = path.join(projectRoot, 'data', 'v2.db');
|
||||
if (!fs.existsSync(dbPath)) return null;
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
try {
|
||||
db = new Database(dbPath, { readonly: true });
|
||||
const row = db
|
||||
.prepare(`SELECT display_name FROM users WHERE id = 'cli:local'`)
|
||||
.get() as { display_name: string } | undefined;
|
||||
return row?.display_name?.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function detectRegisteredGroups(projectRoot: string): boolean {
|
||||
if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) {
|
||||
return true;
|
||||
|
||||
@@ -14,6 +14,7 @@ const STEPS: Record<
|
||||
environment: () => import('./environment.js'),
|
||||
container: () => import('./container.js'),
|
||||
register: () => import('./register.js'),
|
||||
'pair-telegram': () => import('./pair-telegram.js'),
|
||||
groups: () => import('./groups.js'),
|
||||
'whatsapp-auth': () => import('./whatsapp-auth.js'),
|
||||
'signal-auth': () => import('./signal-auth.js'),
|
||||
|
||||
+31
-21
@@ -17,30 +17,40 @@ if command -v node >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
echo "STEP: brew-install-node"
|
||||
if ! command -v brew >/dev/null 2>&1; then
|
||||
if command -v uvx >/dev/null 2>&1; then
|
||||
echo "STEP: uvx-nodeenv"
|
||||
uvx nodeenv -n lts ~/node
|
||||
mkdir -p ~/.local/bin
|
||||
ln -sf ~/node/bin/node ~/.local/bin/node
|
||||
ln -sf ~/node/bin/npm ~/.local/bin/npm
|
||||
ln -sf ~/node/bin/npx ~/.local/bin/npx
|
||||
ln -sf ~/node/bin/pnpm ~/.local/bin/pnpm
|
||||
else
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
echo "STEP: brew-install-node"
|
||||
if ! command -v brew >/dev/null 2>&1; then
|
||||
echo "STATUS: failed"
|
||||
echo "ERROR: Homebrew not installed. Install brew first (https://brew.sh) then re-run."
|
||||
echo "=== END ==="
|
||||
exit 1
|
||||
fi
|
||||
brew install node@22
|
||||
;;
|
||||
Linux)
|
||||
echo "STEP: nodesource-setup"
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
echo "STEP: apt-install-nodejs"
|
||||
sudo apt-get install -y nodejs
|
||||
;;
|
||||
*)
|
||||
echo "STATUS: failed"
|
||||
echo "ERROR: Homebrew not installed. Install brew first (https://brew.sh) then re-run."
|
||||
echo "ERROR: Unsupported platform: $(uname -s)"
|
||||
echo "=== END ==="
|
||||
exit 1
|
||||
fi
|
||||
brew install node@22
|
||||
;;
|
||||
Linux)
|
||||
echo "STEP: nodesource-setup"
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
echo "STEP: apt-install-nodejs"
|
||||
sudo apt-get install -y nodejs
|
||||
;;
|
||||
*)
|
||||
echo "STATUS: failed"
|
||||
echo "ERROR: Unsupported platform: $(uname -s)"
|
||||
echo "=== END ==="
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "STATUS: failed"
|
||||
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
# install-signal-cli.sh — auto-install signal-cli on the host.
|
||||
#
|
||||
# NanoClaw needs `signal-cli` on PATH to talk to Signal. Picks the right
|
||||
# install method per platform:
|
||||
# macOS → `brew install signal-cli` (bottled, no Java needed)
|
||||
# Linux → download latest native binary from GitHub releases to
|
||||
# ~/.local/bin/signal-cli (no Java, no sudo)
|
||||
#
|
||||
# Emits the standard NanoClaw STATUS block on success or failure so the
|
||||
# `runQuietChild` driver can parse the outcome.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="0.14.3"
|
||||
INSTALL_DIR="${HOME}/.local/bin"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
echo "=== NANOCLAW SETUP: INSTALL_SIGNAL_CLI ==="
|
||||
echo "STATUS: ${status}"
|
||||
[ -n "$error" ] && echo "ERROR: ${error}"
|
||||
echo "=== END ==="
|
||||
}
|
||||
|
||||
log() { echo "[install-signal-cli] $*" >&2; }
|
||||
|
||||
uname_s=$(uname)
|
||||
|
||||
if [[ "${uname_s}" == "Darwin" ]]; then
|
||||
if ! command -v brew >/dev/null 2>&1; then
|
||||
emit_status failed "homebrew_not_installed"
|
||||
exit 1
|
||||
fi
|
||||
log "Installing signal-cli via Homebrew…"
|
||||
brew install signal-cli >&2 || {
|
||||
emit_status failed "brew_install_failed"
|
||||
exit 1
|
||||
}
|
||||
emit_status success
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${uname_s}" != "Linux" ]]; then
|
||||
emit_status failed "unsupported_platform_${uname_s}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Linux native build (no Java required) → ~/.local/bin/signal-cli.
|
||||
URL="https://github.com/AsamK/signal-cli/releases/download/v${VERSION}/signal-cli-${VERSION}-Linux-native.tar.gz"
|
||||
TARBALL=$(mktemp -t signal-cli.XXXXXX.tar.gz)
|
||||
|
||||
log "Downloading signal-cli v${VERSION} (~96MB)…"
|
||||
if ! curl -fLsS -o "${TARBALL}" "${URL}"; then
|
||||
rm -f "${TARBALL}"
|
||||
emit_status failed "download_failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Extracting…"
|
||||
EXTRACT_DIR=$(mktemp -d)
|
||||
if ! tar -xzf "${TARBALL}" -C "${EXTRACT_DIR}"; then
|
||||
rm -rf "${TARBALL}" "${EXTRACT_DIR}"
|
||||
emit_status failed "extract_failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "${INSTALL_DIR}"
|
||||
log "Installing to ${INSTALL_DIR}/signal-cli…"
|
||||
if ! mv "${EXTRACT_DIR}/signal-cli" "${INSTALL_DIR}/signal-cli"; then
|
||||
rm -rf "${TARBALL}" "${EXTRACT_DIR}"
|
||||
emit_status failed "install_failed"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x "${INSTALL_DIR}/signal-cli"
|
||||
rm -rf "${TARBALL}" "${EXTRACT_DIR}"
|
||||
|
||||
emit_status success
|
||||
Executable → Regular
+1
-1
@@ -66,7 +66,7 @@ if ! grep -q "'whatsapp-auth':" setup/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||
pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -20,6 +20,15 @@ describe('classifyPingResult', () => {
|
||||
expect(classifyPingResult(1, '', 'Authentication error')).toBe('auth_error');
|
||||
});
|
||||
|
||||
it('detects Claude Code login banners printed as a chat reply', () => {
|
||||
expect(
|
||||
classifyPingResult(0, 'Invalid API key · Please run /login'),
|
||||
).toBe('auth_error');
|
||||
expect(
|
||||
classifyPingResult(0, 'Not logged in · Please run /login'),
|
||||
).toBe('auth_error');
|
||||
});
|
||||
|
||||
it('preserves socket errors', () => {
|
||||
expect(classifyPingResult(2, '')).toBe('socket_error');
|
||||
});
|
||||
|
||||
@@ -20,7 +20,10 @@ export function classifyPingResult(exitCode: number | null, stdout: string, stde
|
||||
if (
|
||||
/Invalid bearer token/i.test(output) ||
|
||||
/authentication[_ ]error/i.test(output) ||
|
||||
/Failed to authenticate/i.test(output)
|
||||
/Failed to authenticate/i.test(output) ||
|
||||
/Please run \/login/i.test(output) ||
|
||||
/Not logged in/i.test(output) ||
|
||||
/Invalid API key/i.test(output)
|
||||
) {
|
||||
return 'auth_error';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Channel-flow back-navigation sentinel.
|
||||
*
|
||||
* Each `runXxxChannel(displayName)` in `setup/channels/` may return either
|
||||
* `void` (sub-flow completed normally) or `BACK_TO_CHANNEL_SELECTION` to
|
||||
* signal "the user picked '← Back to channel selection' on my first
|
||||
* prompt; please re-run the channel chooser." `setup/auto.ts` catches
|
||||
* that signal and loops back to `askChannelChoice()`.
|
||||
*
|
||||
* Back is only offered on the *first* interactive prompt of each channel
|
||||
* sub-flow — once the user has answered something, they're committed
|
||||
* (subsequent steps may have side effects like opening browsers, hitting
|
||||
* APIs, or installing adapter packages, none of which are easily undone).
|
||||
*/
|
||||
export const BACK_TO_CHANNEL_SELECTION = Symbol('BACK_TO_CHANNEL_SELECTION');
|
||||
|
||||
export type ChannelFlowResult = void | typeof BACK_TO_CHANNEL_SELECTION;
|
||||
@@ -18,6 +18,8 @@ import { SelectPrompt } from '@clack/core';
|
||||
import { isCancel } from '@clack/prompts';
|
||||
import { styleText } from 'node:util';
|
||||
|
||||
import { brandBody } from './theme.js';
|
||||
|
||||
const BULLET_ACTIVE = '●';
|
||||
const BULLET_INACTIVE = '○';
|
||||
const BAR = '│';
|
||||
@@ -95,7 +97,7 @@ export function brightSelect<T>(
|
||||
const shown =
|
||||
st === 'cancel'
|
||||
? styleText(['strikethrough', 'dim'], selected)
|
||||
: styleText('dim', selected);
|
||||
: styleText('dim', brandBody(selected));
|
||||
lines.push(`${grayBar} ${shown}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -104,11 +106,12 @@ export function brightSelect<T>(
|
||||
options.forEach((opt, idx) => {
|
||||
const label = opt.label ?? String(opt.value);
|
||||
const hint = opt.hint ? ` ${styleText('dim', `(${opt.hint})`)}` : '';
|
||||
const marker =
|
||||
idx === cursor
|
||||
? styleText('green', BULLET_ACTIVE)
|
||||
: styleText('dim', BULLET_INACTIVE);
|
||||
lines.push(`${bar} ${marker} ${label}${hint}`);
|
||||
const isActive = idx === cursor;
|
||||
const marker = isActive
|
||||
? styleText('green', BULLET_ACTIVE)
|
||||
: styleText('dim', BULLET_INACTIVE);
|
||||
const shownLabel = isActive ? brandBody(label) : label;
|
||||
lines.push(`${bar} ${marker} ${shownLabel}${hint}`);
|
||||
});
|
||||
lines.push(styleText(color, CAP_BOT));
|
||||
return lines.join('\n');
|
||||
|
||||
+36
-4
@@ -9,12 +9,19 @@
|
||||
* `confirmThenOpen` pauses for the operator before triggering the open —
|
||||
* the browser tends to steal focus when it pops, and a split-second
|
||||
* "wait what just happened" moment is worse than letting the user hit
|
||||
* Enter when they're ready.
|
||||
* Enter when they're ready. On headless devices (no graphical session
|
||||
* available) it skips both the prompt and the open: there's no browser
|
||||
* to launch, the surrounding `note(...)` already shows the URL for
|
||||
* copy-paste on another device, and the next prompt in the channel
|
||||
* flow ("Got your bot token?" etc.) provides the natural completion
|
||||
* confirmation.
|
||||
*/
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import { isHeadless } from '../platform.js';
|
||||
import { ensureAnswer } from './runner.js';
|
||||
|
||||
/** Best-effort open of a URL in the user's default browser. Silent on failure. */
|
||||
@@ -32,18 +39,43 @@ export function openUrl(url: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a URL for inclusion in a setup `note(...)` card. On
|
||||
* headless devices we surface the URL inside the card with a
|
||||
* "Get started:" label at full strength — copy-pasting onto
|
||||
* another device is the actual action, not an incidental
|
||||
* reference. The leading `\n` acts as a visual separator from
|
||||
* the body steps above; callers `.filter(line => line !== null)`
|
||||
* before joining, so on GUI we drop the line entirely (and the
|
||||
* URL ends up below the next-step confirm prompt as a "if
|
||||
* browser does not appear, please visit" fallback — see
|
||||
* `confirmThenOpen`).
|
||||
*/
|
||||
export function formatNoteLink(url: string): string | null {
|
||||
if (isHeadless()) return `\nGet started: ${url}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate a browser-open on a confirm so the user is ready for their browser
|
||||
* to take focus. Proceeds on cancel as well — the user can always copy the
|
||||
* URL from the note that precedes the prompt.
|
||||
* to take focus. Proceeds on cancel as well. On headless devices both the
|
||||
* prompt and the open are skipped — the URL is already surfaced inside
|
||||
* the surrounding note (via `formatNoteLink`).
|
||||
*
|
||||
* On GUI devices the confirm message includes the fallback URL on the
|
||||
* lines below the action ("If browser does not appear, please visit:
|
||||
* <url>" in dim) so the user has a copy-paste path right next to the
|
||||
* action button without needing to scroll back up to the card.
|
||||
*/
|
||||
export async function confirmThenOpen(
|
||||
url: string,
|
||||
message = 'Press Enter to open your browser',
|
||||
): Promise<void> {
|
||||
if (isHeadless()) return;
|
||||
const fallback = `\n${k.dim(`If browser does not appear, please visit: ${url}`)}`;
|
||||
ensureAnswer(
|
||||
await p.confirm({
|
||||
message,
|
||||
message: `${message}${fallback}`,
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
|
||||
+106
-18
@@ -2,8 +2,11 @@
|
||||
* Offer Claude-assisted debugging when a setup step fails.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Check `claude` is on PATH and has a working credential. If not,
|
||||
* silently skip — pre-auth failures can't use this path.
|
||||
* 1. Check `claude` is on PATH — if not, offer to install it via
|
||||
* setup/install-claude.sh. Then check auth via `claude auth status`
|
||||
* — if not signed in, offer to run `claude setup-token` (browser
|
||||
* OAuth with code-paste fallback for headless/remote systems).
|
||||
* If either is declined or fails, silently skip.
|
||||
* 2. Ask the user for consent ("Want me to ask Claude for a fix?").
|
||||
* 3. Build a minimal prompt: the one-paragraph situation, the failing
|
||||
* step's name/message/hint, and a short list of *file references*
|
||||
@@ -16,15 +19,16 @@
|
||||
*
|
||||
* Skippable with NANOCLAW_SKIP_CLAUDE_ASSIST=1 for CI/scripted runs.
|
||||
*/
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import { execSync, spawn, spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import { ensureAnswer } from './runner.js';
|
||||
import { fitToWidth } from './theme.js';
|
||||
import { brandBody, fitToWidth, fmtDuration, note } from './theme.js';
|
||||
|
||||
export interface AssistContext {
|
||||
stepName: string;
|
||||
@@ -39,7 +43,7 @@ export interface AssistContext {
|
||||
* rather than us stuffing contents into the prompt. Keys are step names as
|
||||
* they appear in fail() calls; values are repo-relative paths.
|
||||
*/
|
||||
const STEP_FILES: Record<string, string[]> = {
|
||||
export const STEP_FILES: Record<string, string[]> = {
|
||||
bootstrap: ['setup.sh', 'setup/install-node.sh', 'nanoclaw.sh'],
|
||||
environment: ['setup/environment.ts'],
|
||||
container: [
|
||||
@@ -77,7 +81,7 @@ const STEP_FILES: Record<string, string[]> = {
|
||||
],
|
||||
};
|
||||
|
||||
const BIG_PICTURE_FILES = ['README.md', 'setup/auto.ts'];
|
||||
export const BIG_PICTURE_FILES = ['README.md', 'setup/auto.ts'];
|
||||
|
||||
/**
|
||||
* Returns `true` if the user ran a Claude-suggested fix command; callers
|
||||
@@ -90,7 +94,7 @@ export async function offerClaudeAssist(
|
||||
projectRoot: string = process.cwd(),
|
||||
): Promise<boolean> {
|
||||
if (process.env.NANOCLAW_SKIP_CLAUDE_ASSIST === '1') return false;
|
||||
if (!isClaudeUsable()) return false;
|
||||
if (!(await ensureClaudeReady(projectRoot))) return false;
|
||||
|
||||
const want = ensureAnswer(
|
||||
await p.confirm({
|
||||
@@ -106,12 +110,12 @@ export async function offerClaudeAssist(
|
||||
|
||||
const parsed = parseResponse(response);
|
||||
if (!parsed) {
|
||||
p.log.warn("Claude responded but I couldn't parse a command out of it.");
|
||||
p.log.warn(brandBody("Claude responded but I couldn't parse a command out of it."));
|
||||
p.log.message(k.dim(response.trim().slice(0, 500)));
|
||||
return false;
|
||||
}
|
||||
|
||||
p.note(
|
||||
note(
|
||||
`${parsed.reason}\n\n${k.cyan('$')} ${parsed.command}`,
|
||||
"Claude's suggestion",
|
||||
);
|
||||
@@ -128,15 +132,101 @@ export async function offerClaudeAssist(
|
||||
return true;
|
||||
}
|
||||
|
||||
function isClaudeUsable(): boolean {
|
||||
function isClaudeInstalled(): boolean {
|
||||
try {
|
||||
execSync('command -v claude', { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// Availability without auth is half the story; a real query will still
|
||||
// fail if the token isn't registered. We try first and surface the error
|
||||
// rather than pre-checking auth with a separate round trip.
|
||||
}
|
||||
|
||||
function isClaudeAuthenticated(): boolean {
|
||||
try {
|
||||
execSync('claude auth status', { stdio: 'ignore', timeout: 5_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureClaudeReady(projectRoot: string): Promise<boolean> {
|
||||
if (!isClaudeInstalled()) {
|
||||
const install = ensureAnswer(
|
||||
await p.confirm({
|
||||
message:
|
||||
'Claude CLI is needed to diagnose this. Install it now?',
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
if (!install) return false;
|
||||
|
||||
const code = spawnSync('bash', ['setup/install-claude.sh'], {
|
||||
cwd: projectRoot,
|
||||
stdio: 'inherit',
|
||||
}).status;
|
||||
if (code !== 0 || !isClaudeInstalled()) {
|
||||
p.log.error("Couldn't install the Claude CLI.");
|
||||
return false;
|
||||
}
|
||||
p.log.success('Claude CLI installed.');
|
||||
}
|
||||
|
||||
if (!isClaudeAuthenticated()) {
|
||||
const auth = ensureAnswer(
|
||||
await p.confirm({
|
||||
message:
|
||||
"Claude CLI isn't signed in. Sign in now? (a browser will open)",
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
if (!auth) return false;
|
||||
|
||||
// setup-token has an interactive TUI; reset terminal to cooked mode
|
||||
// so its prompts render correctly after clack's raw-mode prompts.
|
||||
spawnSync('stty', ['sane'], { stdio: 'inherit' });
|
||||
|
||||
// Run under script(1) to capture the OAuth token from PTY output
|
||||
// while preserving interactive TTY for the browser OAuth flow.
|
||||
// Same approach as register-claude-token.sh, but we set the env var
|
||||
// instead of writing to OneCLI.
|
||||
const tmpfile = path.join(os.tmpdir(), `claude-setup-token-${process.pid}`);
|
||||
try {
|
||||
const isUtilLinux = (() => {
|
||||
try {
|
||||
return execSync('script --version 2>&1', { encoding: 'utf-8' }).includes('util-linux');
|
||||
} catch { return false; }
|
||||
})();
|
||||
const scriptArgs = isUtilLinux
|
||||
? ['-q', '-c', 'claude setup-token', tmpfile]
|
||||
: ['-q', tmpfile, 'claude', 'setup-token'];
|
||||
|
||||
spawnSync('script', scriptArgs, {
|
||||
cwd: projectRoot,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
if (!isClaudeAuthenticated() && fs.existsSync(tmpfile)) {
|
||||
const raw = fs.readFileSync(tmpfile, 'utf-8');
|
||||
const stripped = raw
|
||||
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
|
||||
.replace(/[\n\r]/g, '');
|
||||
const matches = stripped.match(/(sk-ant-oat[A-Za-z0-9_-]{80,500}AA)/g);
|
||||
if (matches) {
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN = matches[matches.length - 1];
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try { fs.unlinkSync(tmpfile); } catch {}
|
||||
}
|
||||
|
||||
if (!isClaudeAuthenticated()) {
|
||||
p.log.error("Couldn't complete Claude sign-in.");
|
||||
return false;
|
||||
}
|
||||
p.log.success('Claude CLI signed in.');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -205,9 +295,8 @@ async function queryClaudeUnderSpinner(
|
||||
// Move cursor back to the start of the block (WINDOW_SIZE + 1 = header + window).
|
||||
out.write(`\x1b[${WINDOW_SIZE + 1}A`);
|
||||
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
const icon = SPINNER_FRAMES[frameIdx % SPINNER_FRAMES.length];
|
||||
const suffix = ` (${elapsed}s)`;
|
||||
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||
const header = fitToWidth('Asking Claude to diagnose…', suffix);
|
||||
out.write(`\x1b[2K${k.cyan(icon)} ${header}${k.dim(suffix)}\n`);
|
||||
|
||||
@@ -265,10 +354,9 @@ async function queryClaudeUnderSpinner(
|
||||
clearBlock();
|
||||
out.write(SHOW_CURSOR);
|
||||
process.off('exit', restoreCursorOnExit);
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
const suffix = ` (${elapsed}s)`;
|
||||
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||
if (kind === 'ok') {
|
||||
p.log.success(`${fitToWidth('Claude replied.', suffix)}${k.dim(suffix)}`);
|
||||
p.log.success(`${brandBody(fitToWidth('Claude replied.', suffix))}${k.dim(suffix)}`);
|
||||
resolve(payload);
|
||||
} else {
|
||||
p.log.error(
|
||||
|
||||
+121
-3
@@ -23,10 +23,21 @@
|
||||
* attempting to parse it as a real answer.
|
||||
*/
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import {
|
||||
type AssistContext,
|
||||
BIG_PICTURE_FILES,
|
||||
ensureClaudeReady,
|
||||
offerClaudeAssist,
|
||||
STEP_FILES,
|
||||
} from './claude-assist.js';
|
||||
import { ensureAnswer } from './runner.js';
|
||||
import { brandBody, note } from './theme.js';
|
||||
|
||||
export interface HandoffContext {
|
||||
/** Channel this handoff is happening in (e.g., 'teams'). */
|
||||
channel: string;
|
||||
@@ -62,14 +73,14 @@ export interface HandoffContext {
|
||||
export async function offerClaudeHandoff(ctx: HandoffContext): Promise<boolean> {
|
||||
if (!isClaudeUsable()) {
|
||||
p.log.warn(
|
||||
"Claude isn't installed yet — can't hand you off here. Finish setup first, then retry.",
|
||||
brandBody("Claude isn't installed yet — can't hand you off here. Finish setup first, then retry."),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const systemPrompt = buildSystemPrompt(ctx);
|
||||
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
"I'm handing you off to Claude in interactive mode.",
|
||||
"It has the context of where you are in setup.",
|
||||
@@ -91,7 +102,7 @@ export async function offerClaudeHandoff(ctx: HandoffContext): Promise<boolean>
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
child.on('close', () => {
|
||||
p.log.success("Back from Claude. Let's continue.");
|
||||
p.log.success(brandBody("Back from Claude. Let's continue."));
|
||||
resolve(true);
|
||||
});
|
||||
child.on('error', () => {
|
||||
@@ -192,3 +203,110 @@ function buildSystemPrompt(ctx: HandoffContext): string {
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatcher: checks NANOCLAW_SETUP_ASSIST_MODE and delegates to either
|
||||
* the interactive failure handoff (default) or the non-interactive assist.
|
||||
*
|
||||
* Drop-in replacement for `offerClaudeAssist` at failure call sites.
|
||||
*/
|
||||
export async function offerClaudeOnFailure(
|
||||
ctx: AssistContext,
|
||||
projectRoot: string = process.cwd(),
|
||||
): Promise<boolean> {
|
||||
if (process.env.NANOCLAW_SETUP_ASSIST_MODE === 'true' || process.env.NANOCLAW_SETUP_ASSIST_MODE === '1') {
|
||||
return offerClaudeAssist(ctx, projectRoot);
|
||||
}
|
||||
return offerFailureHandoff(ctx, projectRoot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive Claude handoff for setup failures. Same role as
|
||||
* `offerClaudeAssist` but spawns an interactive session instead of
|
||||
* parsing a structured REASON/COMMAND response.
|
||||
*
|
||||
* Returns `true` if Claude was launched (the user may have fixed
|
||||
* things during the session), `false` if skipped/declined/unavailable.
|
||||
*/
|
||||
async function offerFailureHandoff(
|
||||
ctx: AssistContext,
|
||||
projectRoot: string,
|
||||
): Promise<boolean> {
|
||||
if (process.env.NANOCLAW_SKIP_CLAUDE_ASSIST === '1') return false;
|
||||
if (!(await ensureClaudeReady(projectRoot))) return false;
|
||||
|
||||
const want = ensureAnswer(
|
||||
await p.confirm({
|
||||
message: 'Want to debug this with Claude?',
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
if (!want) return false;
|
||||
|
||||
const systemPrompt = buildFailureSystemPrompt(ctx, projectRoot);
|
||||
|
||||
note(
|
||||
[
|
||||
"Launching Claude to help debug this failure.",
|
||||
"It has the context of what went wrong.",
|
||||
"",
|
||||
k.dim("Type /exit (or press Ctrl-D) when you're ready to come back to setup."),
|
||||
].join('\n'),
|
||||
'Handing off to Claude',
|
||||
);
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const child = spawn(
|
||||
'claude',
|
||||
[
|
||||
'--append-system-prompt',
|
||||
systemPrompt,
|
||||
'--permission-mode',
|
||||
'acceptEdits',
|
||||
],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
child.on('close', () => {
|
||||
p.log.success(brandBody("Back from Claude. Let's continue."));
|
||||
resolve(true);
|
||||
});
|
||||
child.on('error', () => {
|
||||
p.log.error("Couldn't launch Claude. Continuing without handoff.");
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildFailureSystemPrompt(ctx: AssistContext, projectRoot: string): string {
|
||||
const stepRefs = STEP_FILES[ctx.stepName] ?? [];
|
||||
const references = [
|
||||
...BIG_PICTURE_FILES,
|
||||
...stepRefs,
|
||||
'logs/setup.log',
|
||||
ctx.rawLogPath
|
||||
? path.relative(projectRoot, ctx.rawLogPath)
|
||||
: 'logs/setup-steps/',
|
||||
].filter((v, i, a) => a.indexOf(v) === i);
|
||||
|
||||
const lines: string[] = [
|
||||
"The user is running NanoClaw's interactive setup flow and hit a failure.",
|
||||
'',
|
||||
`Failed step: ${ctx.stepName}`,
|
||||
`Error: ${ctx.msg}`,
|
||||
];
|
||||
|
||||
if (ctx.hint) lines.push(`Hint: ${ctx.hint}`);
|
||||
|
||||
lines.push(
|
||||
'',
|
||||
'Your job: help them diagnose and fix this issue. Read the referenced files',
|
||||
'and logs to understand what went wrong, then help them fix it. You can read',
|
||||
'files, run commands, check logs, and explain what happened. Be concise.',
|
||||
"When they're ready to resume setup, tell them to type /exit.",
|
||||
'',
|
||||
'Relevant files (read as needed with the Read tool):',
|
||||
);
|
||||
for (const f of references) lines.push(` - ${f}`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
+7
-9
@@ -18,9 +18,9 @@ import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { offerClaudeAssist } from './claude-assist.js';
|
||||
import { offerClaudeOnFailure } from './claude-handoff.js';
|
||||
import { emit as phEmit } from './diagnostics.js';
|
||||
import { fitToWidth } from './theme.js';
|
||||
import { brandBody, fitToWidth, fmtDuration } from './theme.js';
|
||||
|
||||
export type Fields = Record<string, string>;
|
||||
export type Block = { type: string; fields: Fields };
|
||||
@@ -307,18 +307,16 @@ async function runUnderSpinner<
|
||||
): Promise<T> {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
s.start(fitToWidth(labels.running, ' (999s)'));
|
||||
s.start(fitToWidth(labels.running, ' (99m 59s)'));
|
||||
const tick = setInterval(() => {
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
const suffix = ` (${elapsed}s)`;
|
||||
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||
s.message(`${fitToWidth(labels.running, suffix)}${k.dim(suffix)}`);
|
||||
}, 1000);
|
||||
|
||||
const result = await work();
|
||||
|
||||
clearInterval(tick);
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
const suffix = ` (${elapsed}s)`;
|
||||
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||
if (result.ok) {
|
||||
const isSkipped = result.terminal?.fields.STATUS === 'skipped';
|
||||
const msg = isSkipped && labels.skipped ? labels.skipped : labels.done;
|
||||
@@ -369,7 +367,7 @@ export async function fail(
|
||||
if (hint) p.log.message(k.dim(hint));
|
||||
p.log.message(k.dim('Logs: logs/setup.log · Raw: logs/setup-steps/'));
|
||||
|
||||
const ranFix = await offerClaudeAssist({ stepName, msg, hint, rawLogPath });
|
||||
const ranFix = await offerClaudeOnFailure({ stepName, msg, hint, rawLogPath });
|
||||
|
||||
// If the user just ran a Claude-suggested fix, offer to resume the flow
|
||||
// at the step that failed instead of aborting. We re-exec via spawnSync
|
||||
@@ -390,7 +388,7 @@ export async function fail(
|
||||
const skipList = [
|
||||
...new Set([...existingSkip, ...setupLog.completedStepNames()]),
|
||||
].join(',');
|
||||
p.log.step(`Retrying from ${stepName}…`);
|
||||
p.log.step(brandBody(`Retrying from ${stepName}…`));
|
||||
const result = spawnSync('pnpm', ['--silent', 'run', 'setup:auto'], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, NANOCLAW_SKIP: skipList },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user