mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-06 18:52:03 +08:00
Compare commits
94 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2242f6c997 | |||
| 40b65521c0 | |||
| 81fea1d7ec | |||
| b6cb53e21c | |||
| 5a7e75f854 | |||
| d770e56596 | |||
| 08a1ac9753 | |||
| 273489badf | |||
| 504651633f | |||
| 694ab74aa1 | |||
| aae81321e9 | |||
| 6c46b1e43d | |||
| 71453707ba | |||
| 0aa9e668f6 | |||
| 023128def5 | |||
| c4a1679666 | |||
| 2e40e17155 | |||
| f896caefa0 | |||
| 55c003c5f4 | |||
| 20f2bae5cd | |||
| d6b82e6473 | |||
| fea5ac5f2c | |||
| 2b86bbef19 | |||
| 9dc0a7e62f | |||
| 2035033397 | |||
| 1c294ff9a5 | |||
| 43198310e1 | |||
| b7d6eebf4d | |||
| 803f3413ec | |||
| a8b7da7bcf | |||
| 0b6ad5550d | |||
| c1965cfcaf | |||
| 3cefbfccf4 | |||
| 6f22c73aac | |||
| 31dd37b3a8 | |||
| 0dfde3aa5b | |||
| 33d2366252 | |||
| 9bf1514f26 | |||
| be3502c23b | |||
| e3f38dbed9 | |||
| d8a6b99f0e | |||
| 8ca7564fbc | |||
| afa07f0566 | |||
| 8059ee4eec | |||
| 41c486cacc | |||
| 190a7d4f43 | |||
| 8a3444a68c | |||
| 0c020e664d | |||
| c02c002caa | |||
| 498b80b344 | |||
| 76c1f13ca3 | |||
| 22b294e233 | |||
| 79060c5407 | |||
| 0835089a51 | |||
| c82f062d57 | |||
| 3906104960 | |||
| ed9a3e330d | |||
| 102ce80fda | |||
| 0626dcec92 | |||
| 912a89bf94 | |||
| b42329551d | |||
| 855f204d8a | |||
| e3b2ffce36 | |||
| 9c91c00c93 | |||
| f8214ed1d8 | |||
| 665b0dca77 | |||
| 46de9ae05a | |||
| d7d104aa0b | |||
| bd0962fd97 | |||
| d997d545cc | |||
| 8455eb0809 | |||
| 462619d287 | |||
| c249989b5a | |||
| 3b41171071 | |||
| 6f616824d6 | |||
| ab596d31d3 | |||
| 564e605900 | |||
| be0ca10630 | |||
| 0a5ffde133 | |||
| 4c4b2ecbbe | |||
| f6c13eb6f3 | |||
| f629ff25fa | |||
| 3eca5588f9 | |||
| e33fa970f5 | |||
| 3c14310928 | |||
| f387d371fd | |||
| 2f26d4ebc1 | |||
| c277deae21 | |||
| 0cc8ade34c | |||
| d33806389c | |||
| f350ed24e1 | |||
| cc07387025 | |||
| 59460e9a5c | |||
| ae48986e42 |
@@ -11,6 +11,8 @@ NanoClaw selects each group's agent backend from `container_configs.provider` (d
|
||||
|
||||
The provider runs `codex app-server` as a child process speaking JSON-RPC over stdio: native streaming, MCP tools, server-side conversation history (the continuation is a thread id, no on-disk transcript). Credentials are **vault-only**: OneCLI serves a sentinel `auth.json` stub into the container and swaps the real ChatGPT token or API key on the wire — no key in `.env`, nothing readable in the container.
|
||||
|
||||
The mechanical steps under **Install** carry `nc:` directive fences: an agent reads the prose and applies them, and a parser can apply them deterministically from the same document. Every directive is idempotent, so the whole skill is safe to re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
## Install
|
||||
|
||||
### Pre-flight
|
||||
@@ -23,92 +25,69 @@ Check whether the payload is already wired (a prior apply, or a trunk that still
|
||||
- `import './codex.js';` in `src/providers/index.ts`, `container/agent-runner/src/providers/index.ts`, and `setup/providers/index.ts`
|
||||
- an `@openai/codex` entry in `container/cli-tools.json`
|
||||
|
||||
### Fetch and copy
|
||||
### 1. Fetch and copy the payload
|
||||
|
||||
```bash
|
||||
git fetch origin providers
|
||||
Fetch the `providers` branch and copy the Codex payload into all three trees (additive — overwrite each file, never merge the branch). The host files are the provider contribution + AGENTS.md compose + their guards; the container files are the provider runtime (turn loop, JSON-RPC wrapper, per-exchange archiver) + their guards; the setup file is the picker entry + vault auth walk-through; `container/AGENTS.md` is the runtime-contract base the composed AGENTS.md embeds.
|
||||
|
||||
```nc:copy from-branch:providers
|
||||
src/providers/codex.ts
|
||||
src/providers/codex-agents-md.ts
|
||||
src/providers/codex-registration.test.ts
|
||||
src/providers/codex-host-contribution.test.ts
|
||||
src/providers/codex-agents-md.test.ts
|
||||
container/agent-runner/src/providers/codex.ts
|
||||
container/agent-runner/src/providers/codex-app-server.ts
|
||||
container/agent-runner/src/providers/exchange-archive.ts
|
||||
container/agent-runner/src/providers/exchange-archive.test.ts
|
||||
container/agent-runner/src/providers/codex-registration.test.ts
|
||||
container/agent-runner/src/providers/codex.factory.test.ts
|
||||
container/agent-runner/src/providers/codex.turns.test.ts
|
||||
container/agent-runner/src/providers/codex-app-server.test.ts
|
||||
container/agent-runner/src/providers/codex-cli-tools.test.ts
|
||||
setup/providers/codex.ts
|
||||
setup/providers/codex.test.ts
|
||||
setup/providers/codex-registration.test.ts
|
||||
container/AGENTS.md
|
||||
```
|
||||
|
||||
Copy each file with `git show origin/providers:<path> > <path>` (additive — never merge the branch):
|
||||
### 2. Wire the barrels
|
||||
|
||||
Host (`src/providers/`):
|
||||
- `codex.ts` — provider contribution: per-group `.codex-shared` state dir, AGENTS.md compose, skill links
|
||||
- `codex-agents-md.ts` — AGENTS.md composition (32KB Codex cap: degrades by dropping the largest instruction sections, never blocks a spawn)
|
||||
- `codex-registration.test.ts` — barrel-driven host registration guard
|
||||
- `codex-host-contribution.test.ts` — drives the real contribution against a real test DB (the "consumes core" leg)
|
||||
- `codex-agents-md.test.ts` — cap-degradation behavior
|
||||
Append the self-registration import to each of the three provider barrels (skipped if the line is already present). Each barrel-registration test imports its real barrel and asserts `codex` is registered — they go red the moment a barrel line is missing or drifts.
|
||||
|
||||
Container (`container/agent-runner/src/providers/`):
|
||||
- `codex.ts` — the provider (turn loop, steering, memory scaffold + `onExchangeComplete` archiving)
|
||||
- `codex-app-server.ts` — JSON-RPC child-process wrapper
|
||||
- `exchange-archive.ts` — per-exchange markdown writer the `onExchangeComplete` hook uses (provider-owned, not runner code)
|
||||
- `exchange-archive.test.ts` — writer behavior
|
||||
- `codex-registration.test.ts` — barrel-driven container registration guard
|
||||
- `codex.factory.test.ts`, `codex.turns.test.ts`, `codex-app-server.test.ts` — provider behavior
|
||||
- `codex-cli-tools.test.ts` — structural guard for the Codex entry in `container/cli-tools.json`
|
||||
|
||||
Setup (`setup/providers/`):
|
||||
- `codex.ts` — picker entry self-registration + the vault auth walk-through + install check
|
||||
- `codex.test.ts` — install-check coverage
|
||||
- `codex-registration.test.ts` — barrel-driven setup registration guard
|
||||
|
||||
Shared base (skip if present):
|
||||
- `container/AGENTS.md` — the runtime-contract base the composed AGENTS.md embeds
|
||||
|
||||
### Wire the barrels
|
||||
|
||||
Append `import './codex.js';` to each of:
|
||||
- `src/providers/index.ts`
|
||||
- `container/agent-runner/src/providers/index.ts`
|
||||
- `setup/providers/index.ts`
|
||||
|
||||
### CLI manifest
|
||||
|
||||
The agent's global Node CLIs install from `container/cli-tools.json` (a json-merge seam), not hand-edited Dockerfile layers. Add Codex by appending one entry — `@openai/codex` has no native postinstall, so no `onlyBuilt`:
|
||||
|
||||
```bash
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const file = "container/cli-tools.json";
|
||||
const tools = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
if (!tools.some((t) => t.name === "@openai/codex")) {
|
||||
tools.push({ name: "@openai/codex", version: "0.138.0" });
|
||||
const fmt = (t) => " { " + Object.entries(t).map(([k, v]) => JSON.stringify(k) + ": " + JSON.stringify(v)).join(", ") + " }";
|
||||
fs.writeFileSync(file, "[\n" + tools.map(fmt).join(",\n") + "\n]\n");
|
||||
}
|
||||
'
|
||||
```nc:append to:src/providers/index.ts
|
||||
import './codex.js';
|
||||
```
|
||||
```nc:append to:container/agent-runner/src/providers/index.ts
|
||||
import './codex.js';
|
||||
```
|
||||
```nc:append to:setup/providers/index.ts
|
||||
import './codex.js';
|
||||
```
|
||||
|
||||
The version (`0.138.0`) is the canonical pin — keep it in sync with `setup/add-codex.sh`. The Dockerfile already installs every manifest entry via pinned `pnpm install -g`; no Dockerfile edit is needed.
|
||||
### 3. CLI manifest
|
||||
|
||||
### Build
|
||||
The agent's global Node CLIs install from `container/cli-tools.json` (a json-merge seam), not hand-edited Dockerfile layers. Add Codex by appending one entry — idempotent on `name`, so a re-run is a no-op. `@openai/codex` has no native postinstall, so no `onlyBuilt`. The Dockerfile already installs every manifest entry via pinned `pnpm install -g`; no Dockerfile edit is needed.
|
||||
|
||||
```bash
|
||||
```nc:json-merge into:container/cli-tools.json key:name
|
||||
{ "name": "@openai/codex", "version": "0.138.0" }
|
||||
```
|
||||
|
||||
The version (`0.138.0`) is the canonical pin — this SKILL.md is the source of truth.
|
||||
|
||||
### 4. Build
|
||||
|
||||
```nc:run effect:build
|
||||
pnpm run build
|
||||
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
### Restart the host
|
||||
### 5. Validate
|
||||
|
||||
The image rebuild does not reload the **host**. Codex's host contribution
|
||||
(`src/providers/codex.ts`) registers the `/home/node/.codex` bind mount + env
|
||||
passthrough, and the running host only picks it up on restart. Skip this and the
|
||||
first Codex turn fails with `EACCES` writing `/home/node/.codex/config.toml` —
|
||||
with no mount, Docker auto-creates the dir root-owned and the non-root container
|
||||
user can't write to it.
|
||||
|
||||
```bash
|
||||
# macOS (launchd)
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
# Linux (systemd)
|
||||
systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
### Validate
|
||||
|
||||
```bash
|
||||
```nc:run effect:test
|
||||
pnpm vitest run src/providers/codex-registration.test.ts src/providers/codex-host-contribution.test.ts src/providers/codex-agents-md.test.ts setup/providers/
|
||||
```
|
||||
```nc:run effect:test
|
||||
cd container/agent-runner && bun test src/providers/
|
||||
```
|
||||
|
||||
@@ -116,9 +95,7 @@ The registration tests import only the real barrels — they go red if a barrel
|
||||
|
||||
## Authenticate
|
||||
|
||||
> **Run this in a separate, real terminal — it is interactive.** It prompts for ChatGPT-subscription vs OpenAI-API-key and then drives a browser/device login, so it needs a TTY to answer prompts.
|
||||
|
||||
```bash
|
||||
```nc:run effect:external
|
||||
pnpm exec tsx setup/index.ts --step provider-auth codex
|
||||
```
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
// Structural guard for the Codex CLI install in container/cli-tools.json.
|
||||
//
|
||||
// @openai/codex is a CLI *binary* installed from the global-CLI manifest (a
|
||||
// json-merge seam), not an importable package, so the barrel-driven
|
||||
// registration tests cannot see it. This test reads the real cli-tools.json
|
||||
// and asserts the @openai/codex entry is present and pinned to an exact
|
||||
// version. It goes red if the manifest entry is dropped or unpins.
|
||||
//
|
||||
// Runs under bun (same suite as the container registration test):
|
||||
// cd container/agent-runner && bun test src/providers/codex-cli-tools.test.ts
|
||||
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
|
||||
// container/agent-runner/src/providers/ -> container/cli-tools.json
|
||||
const MANIFEST = path.join(import.meta.dir, '..', '..', '..', 'cli-tools.json');
|
||||
const manifestPresent = existsSync(MANIFEST);
|
||||
|
||||
// Read lazily — `describe.skipIf` still runs the body to register tests, so the
|
||||
// read has to be guarded for the bare-branch (no manifest) case.
|
||||
const tools: Array<{ name: string; version: string }> = manifestPresent
|
||||
? JSON.parse(readFileSync(MANIFEST, 'utf8'))
|
||||
: [];
|
||||
const codex = tools.find((t) => t.name === '@openai/codex');
|
||||
|
||||
// cli-tools.json is a trunk file; on the bare providers branch it isn't present,
|
||||
// so skip there. In an installed tree (trunk + this payload) it must carry the
|
||||
// pinned @openai/codex entry.
|
||||
describe.skipIf(!manifestPresent)('container/cli-tools.json codex CLI install', () => {
|
||||
it('includes the @openai/codex entry', () => {
|
||||
expect(codex).toBeDefined();
|
||||
});
|
||||
|
||||
it('pins it to an exact semver (no latest, no ranges)', () => {
|
||||
expect(codex?.version).toMatch(/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/);
|
||||
});
|
||||
});
|
||||
@@ -89,7 +89,6 @@ 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
|
||||
|
||||
|
||||
@@ -5,98 +5,151 @@ description: Add Discord bot channel integration via Chat SDK.
|
||||
|
||||
# Add Discord Channel
|
||||
|
||||
Adds Discord bot support via the Chat SDK bridge.
|
||||
Adds Discord bot support via the Chat SDK bridge. NanoClaw doesn't ship channels
|
||||
in trunk — this skill copies the Discord adapter in from the `channels` branch.
|
||||
|
||||
## Install
|
||||
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
|
||||
reads the prose and applies them, and a parser can apply them deterministically
|
||||
from the same document. Every directive is idempotent, so the whole skill is
|
||||
safe to re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Discord adapter in from the `channels` branch.
|
||||
## Apply
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
### 1. Copy the adapter and its registration test
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
Fetch the `channels` branch and copy the Discord adapter and its registration
|
||||
test into `src/channels/` (overwrite — the branch is canonical):
|
||||
|
||||
- `src/channels/discord.ts` exists
|
||||
- `src/channels/discord-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './discord.js';`
|
||||
- `@chat-adapter/discord` 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
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/discord.ts
|
||||
src/channels/discord-registration.test.ts
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Register the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/discord.ts > src/channels/discord.ts
|
||||
git show origin/channels:src/channels/discord-registration.test.ts > src/channels/discord-registration.test.ts
|
||||
```
|
||||
Append the self-registration import to the channel barrel (skipped if the line
|
||||
is already present). This one line is the skill's only reach-in into core:
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
```nc:append to:src/channels/index.ts
|
||||
import './discord.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
### 3. Install the adapter package
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/discord@4.29.0
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
|
||||
|
||||
```nc:dep
|
||||
@chat-adapter/discord@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
### 4. Build and validate
|
||||
|
||||
```bash
|
||||
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
|
||||
the dependency is installed. Then run the one integration test.
|
||||
|
||||
```nc:run effect:build
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/discord-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `discord-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `discord`. It goes red if the `import './discord.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/discord` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
`discord-registration.test.ts` imports the real channel barrel and asserts the
|
||||
registry contains `discord`. It goes red if the import line is deleted or drifts,
|
||||
if the barrel fails to evaluate, or if `@chat-adapter/discord` isn't installed
|
||||
(the import throws) — so it also covers the dependency from step 3. End-to-end
|
||||
delivery against a real server is verified manually once the service runs.
|
||||
|
||||
## Credentials
|
||||
|
||||
### Create Discord Bot
|
||||
Discord app setup is human and interactive — no parser can click through the
|
||||
Discord Developer Portal. The adapter is installed and registered, but it can't
|
||||
receive a message until the bot exists, has Message Content Intent, and shares a
|
||||
server with you. Tell the user:
|
||||
|
||||
1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
|
||||
2. Click **New Application** and give it a name (e.g., "NanoClaw Assistant")
|
||||
3. From the **General Information** tab, copy the **Application ID** and **Public Key**
|
||||
4. Go to the **Bot** tab and click **Add Bot** if needed
|
||||
5. Copy the Bot Token (click **Reset Token** if you need a new one — you can only see it once)
|
||||
6. Under **Privileged Gateway Intents**, enable **Message Content Intent**
|
||||
7. Go to **OAuth2** > **URL Generator**:
|
||||
- Scopes: select `bot`
|
||||
- Bot Permissions: select `Send Messages`, `Read Message History`, `Add Reactions`, `Attach Files`, `Use Slash Commands`
|
||||
8. Copy the generated URL and open it in your browser to invite the bot to your server
|
||||
|
||||
### Configure environment
|
||||
|
||||
All three values are required — the adapter will fail to start without `DISCORD_PUBLIC_KEY` and `DISCORD_APPLICATION_ID`.
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
DISCORD_BOT_TOKEN=your-bot-token
|
||||
DISCORD_APPLICATION_ID=your-application-id
|
||||
DISCORD_PUBLIC_KEY=your-public-key
|
||||
```nc:operator
|
||||
Create the Discord bot:
|
||||
1. Go to https://discord.com/developers/applications → New Application. Name it (e.g. "NanoClaw Assistant").
|
||||
2. Bot tab → Add Bot if needed → Reset Token, then copy the Bot Token (it's shown only once).
|
||||
3. Bot tab → Privileged Gateway Intents → enable Message Content Intent.
|
||||
4. OAuth2 → URL Generator → Scopes: bot; Bot Permissions: Send Messages, Read Message History, Add Reactions, Attach Files, Use Slash Commands.
|
||||
5. Open the generated URL and invite the bot to a server you're also in (a personal server is fine) — the bot can only DM you once you share a server.
|
||||
```
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
Paste the Bot Token (it's shown only once). You don't paste the Application ID or
|
||||
the Public Key by hand — the bot's own application record carries both, so a
|
||||
single call derives them from the token:
|
||||
|
||||
```nc:prompt bot_token secret validate:^[A-Za-z0-9._-]{50,}$
|
||||
Paste the Bot Token — Bot tab. Click `Reset Token` if you need a new one.
|
||||
```
|
||||
|
||||
Read the application's own record. `GET /oauth2/applications/@me` returns the
|
||||
Application ID (`id`), the Public Key (`verify_key`), and your own account as the
|
||||
app's owner (`owner.id`) — so the App ID, the Public Key, and your Discord user ID
|
||||
all come from this one call instead of being copied by hand. A bad token fails
|
||||
here, before the restart, rather than silently later:
|
||||
|
||||
```nc:run capture:application_id=.id,public_key=.verify_key,owner_handle=.owner.id effect:fetch
|
||||
curl -sf https://discord.com/api/v10/oauth2/applications/@me -H "Authorization: Bot {{bot_token}}"
|
||||
```
|
||||
|
||||
Store the token and the two derived credentials — the adapter reads them from
|
||||
`.env` and fails to start without `DISCORD_PUBLIC_KEY` and `DISCORD_APPLICATION_ID`
|
||||
(set-if-absent, so a value you've already filled in is never overwritten):
|
||||
|
||||
```nc:env-set
|
||||
DISCORD_BOT_TOKEN={{bot_token}}
|
||||
DISCORD_APPLICATION_ID={{application_id}}
|
||||
DISCORD_PUBLIC_KEY={{public_key}}
|
||||
```
|
||||
## Restart
|
||||
|
||||
Restart the service so it loads the Discord adapter and the credentials you just
|
||||
stored, and wait for its CLI socket before resolving:
|
||||
|
||||
```nc:run effect:restart
|
||||
bash setup/lib/restart.sh
|
||||
```
|
||||
|
||||
## Invite the bot to a shared server
|
||||
|
||||
The bot can only DM you once it shares a server with you. If you didn't already
|
||||
invite it via the OAuth2 URL Generator while setting up the app, do it now: add
|
||||
the bot to a server you're also in (a personal server is fine). Tell the user:
|
||||
|
||||
```nc:operator
|
||||
Open the invite link — https://discord.com/oauth2/authorize?client_id={{application_id}}&scope=bot&permissions=2147584064 — and add the bot to a server you're also in (a personal server works fine); the bot can only DM you once you share a server. If you already invited it while setting up the app, you can skip this.
|
||||
```
|
||||
|
||||
## Resolve your DM channel
|
||||
|
||||
The agent talks to you in your direct-message channel with the bot. Your Discord
|
||||
user ID was already derived as the application's owner (`owner_handle`), so all
|
||||
that's left is to open the DM and read back its channel id.
|
||||
|
||||
Open the DM with `POST /users/@me/channels` and take the channel id it returns as
|
||||
the conversation address `discord:@me:<channelId>` (if Discord refuses, the bot
|
||||
doesn't share a server with you yet — invite it, then retry):
|
||||
|
||||
```nc:run capture:platform_id effect:fetch
|
||||
curl -s -X POST https://discord.com/api/v10/users/@me/channels -H "Authorization: Bot {{bot_token}}" -H "Content-Type: application/json" -d '{"recipient_id":"{{owner_handle}}"}' | jq -er '"discord:@me:" + .id'
|
||||
```
|
||||
|
||||
`owner_handle` and `platform_id` are what the owner-wiring step needs. The
|
||||
greeting goes out over the DM channel, which works as soon as the bot shares a
|
||||
server with you.
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
|
||||
Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
||||
If you're in the middle of `/setup`, return to the setup flow now. Otherwise wire
|
||||
this channel with `/init-first-agent` (or `/manage-channels`).
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `discord`
|
||||
- **terminology**: Discord has "servers" (also called "guilds") containing "channels." Text channels start with #. The bot can also receive direct messages.
|
||||
- **platform-id-format**: `discord:@me:{dmChannelId}` for the owner DM (e.g. `discord:@me:1399...`), `discord:{guildId}:{channelId}` for server channels — both IDs required for channels.
|
||||
- **how-to-find-id**: Enable Developer Mode in Discord (Settings > App Settings > Advanced > Developer Mode). Then right-click a server and select "Copy Server ID" for the guild ID, and right-click the text channel and select "Copy Channel ID." The platform ID format used in registration is `discord:{guildId}:{channelId}` — both IDs are required.
|
||||
- **supports-threads**: yes
|
||||
- **typical-use**: Interactive chat — server channels or direct messages
|
||||
|
||||
@@ -54,10 +54,8 @@ Remove the NanoClaw block from your Emacs config (`config.el`, `~/.spacemacs`, o
|
||||
|
||||
Reload your config or restart Emacs.
|
||||
|
||||
## 5. Remove the messaging group (optional)
|
||||
## 5. Messaging group (left intact)
|
||||
|
||||
To clean up the wired messaging group:
|
||||
|
||||
```bash
|
||||
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';"
|
||||
```
|
||||
Your wired messaging group and conversation history are **not** removed — you
|
||||
created them at runtime, not this skill's install. To purge them deliberately,
|
||||
delete them yourself with `ncl messaging-groups delete <id>`.
|
||||
|
||||
@@ -12,14 +12,13 @@ ncl groups config remove-mcp-server --id <group-id> --name calendar
|
||||
|
||||
## 2. Remove the `.calendar-mcp` mount from the DB (per group)
|
||||
|
||||
There is no `ncl groups config remove-mount` verb yet (tracked in [#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Until it ships, drop the entry via the in-tree wrapper (`scripts/q.ts`):
|
||||
This is a **host-only / operator** verb — run it host-side. It's idempotent (a no-op if the mount is absent):
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
|
||||
SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \
|
||||
WHERE json_extract(value, '\$.containerPath') != '.calendar-mcp'), \
|
||||
updated_at = datetime('now') \
|
||||
WHERE agent_group_id = '<group-id>';"
|
||||
ncl groups config remove-mount \
|
||||
--id <group-id> \
|
||||
--host "$HOME/.calendar-mcp" \
|
||||
--container .calendar-mcp
|
||||
```
|
||||
|
||||
## 3. Delete the copied test file
|
||||
|
||||
@@ -133,6 +133,8 @@ pnpm exec vitest run src/gcal-dockerfile.test.ts
|
||||
|
||||
`cp` overwrites in place, so re-running this skill is safe.
|
||||
|
||||
**This is the skill's only in-tree integration test.** The Phase 3 `ncl groups config add-mcp-server` and `add-mount` steps are runtime writes to the central DB — they leave no line in the source tree whose deletion a test could catch, so a registration test is structurally inapplicable. They're verified at runtime instead (Phase 5).
|
||||
|
||||
### Rebuild the container image
|
||||
|
||||
```bash
|
||||
@@ -160,27 +162,22 @@ Approval behaviour depends on where you run it: from inside an agent's container
|
||||
|
||||
### Add the `.calendar-mcp` mount
|
||||
|
||||
There is no `ncl groups config add-mount` verb yet (tracked in [#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Until that ships, edit the DB directly via the in-tree wrapper (`scripts/q.ts` — `setup/verify.ts:5` codifies that NanoClaw avoids depending on the `sqlite3` CLI binary, so don't shell out to it):
|
||||
This is a **host-only / operator** verb — it's rejected from inside a container at any `cli_scope`, so run it host-side when you (the operator) apply this skill via `/setup`, `/customize`, or `/manage-mounts`. It's idempotent (skips if the mount is already present).
|
||||
|
||||
```bash
|
||||
GROUP_ID='<group-id>'
|
||||
HOST_PATH="$HOME/.calendar-mcp"
|
||||
MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".calendar-mcp", readonly:false}')
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
|
||||
SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \
|
||||
updated_at = datetime('now') \
|
||||
WHERE agent_group_id = '$GROUP_ID';"
|
||||
ncl groups config add-mount \
|
||||
--id <group-id> \
|
||||
--host "$HOME/.calendar-mcp" \
|
||||
--container .calendar-mcp
|
||||
```
|
||||
|
||||
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is ISO-string everywhere else in the schema, so use `datetime('now')` — not `strftime('%s','now')`, which would silently mix epoch ints into a column of YYYY-MM-DD HH:MM:SS strings.
|
||||
`--container` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/<relative>`). No `--ro`: the MCP server may rewrite `credentials.json` on token refresh, so the mount must be read-write.
|
||||
|
||||
**Switch to `ncl groups config add-mount` once #2395 lands.** Update this skill at that time.
|
||||
|
||||
`containerPath` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/<relative>`).
|
||||
The mount also needs to be in the external mount allowlist (`~/.config/nanoclaw/mount-allowlist.json`) to take effect at spawn — see the Phase 1 "Verify mount allowlist covers the path" step. A container restart (`ncl groups restart`) is needed for the mount to apply.
|
||||
|
||||
**Why this can't be `groups/<folder>/container.json`:** post-migration `014-container-configs`, `materializeContainerJson` in `src/container-config.ts` rewrites that file from the DB on every spawn. Anything hand-edited there is silently overwritten on next restart.
|
||||
|
||||
**Same-group-as-gmail tip:** if this group already has the gmail MCP + `.gmail-mcp` mount, both coexist — `ncl groups config add-mcp-server` only updates the named entry, and `json_insert` appends to `additional_mounts` without disturbing existing entries.
|
||||
**Same-group-as-gmail tip:** if this group already has the gmail MCP + `.gmail-mcp` mount, both coexist — `ncl groups config add-mcp-server` only updates the named entry, and `add-mount` appends to `additional_mounts` without disturbing existing entries.
|
||||
|
||||
## Phase 4: Build and Restart
|
||||
|
||||
|
||||
@@ -5,63 +5,70 @@ description: Add Google Chat channel integration via Chat SDK.
|
||||
|
||||
# Add Google Chat Channel
|
||||
|
||||
Adds Google Chat support via the Chat SDK bridge.
|
||||
Adds Google Chat support via the Chat SDK bridge. NanoClaw doesn't ship channels
|
||||
in trunk — this skill copies the Google Chat adapter in from the `channels`
|
||||
branch.
|
||||
|
||||
## Install
|
||||
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
|
||||
reads the prose and applies them, and a parser can apply them deterministically
|
||||
from the same document. Every directive is idempotent, so the whole skill is
|
||||
safe to re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Google Chat adapter in from the `channels` branch.
|
||||
## Apply
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
### 1. Copy the adapter and its registration test
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
Fetch the `channels` branch and copy the Google Chat adapter and its
|
||||
registration test into `src/channels/` (overwrite — the branch is canonical):
|
||||
|
||||
- `src/channels/gchat.ts` exists
|
||||
- `src/channels/gchat-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './gchat.js';`
|
||||
- `@chat-adapter/gchat` 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
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/gchat.ts
|
||||
src/channels/gchat-registration.test.ts
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Register the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/gchat.ts > src/channels/gchat.ts
|
||||
git show origin/channels:src/channels/gchat-registration.test.ts > src/channels/gchat-registration.test.ts
|
||||
```
|
||||
Append the self-registration import to the channel barrel (skipped if the line
|
||||
is already present). This one line is the skill's only reach-in into core:
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
```nc:append to:src/channels/index.ts
|
||||
import './gchat.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
### 3. Install the adapter package
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/gchat@4.29.0
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
|
||||
|
||||
```nc:dep
|
||||
@chat-adapter/gchat@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
### 4. Build and validate
|
||||
|
||||
```bash
|
||||
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
|
||||
the dependency is installed. Then run the one integration test.
|
||||
|
||||
```nc:run effect:build
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/gchat-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `gchat-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `gchat`. It goes red if the `import './gchat.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/gchat` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
|
||||
End-to-end message delivery against a real Google Chat space is verified manually once the service is running — see Next Steps and the webhook setup above.
|
||||
`gchat-registration.test.ts` imports the real channel barrel and asserts the
|
||||
registry contains `gchat`. It goes red if the import line is deleted or drifts,
|
||||
if the barrel fails to evaluate, or if `@chat-adapter/gchat` isn't installed (the
|
||||
import throws) — so it also covers the dependency from step 3. End-to-end
|
||||
delivery against a real Google Chat space is verified manually once the service
|
||||
runs — see Credentials and Next Steps.
|
||||
|
||||
## Credentials
|
||||
|
||||
Google Cloud setup is human and interactive — these steps are prose, not
|
||||
directives (no parser can click through the Google Cloud Console). A recipe
|
||||
rebuild produces a compiling, registered adapter that cannot receive a message
|
||||
until they're done.
|
||||
|
||||
> 1. Go to [Google Cloud Console](https://console.cloud.google.com)
|
||||
> 2. Create or select a project
|
||||
> 3. Enable the **Google Chat API**
|
||||
@@ -73,21 +80,32 @@ End-to-end message delivery against a real Google Chat space is verified manuall
|
||||
> - Grant the Chat Bot role
|
||||
> - Create a JSON key and download it
|
||||
|
||||
### Configure environment
|
||||
### Store the credentials
|
||||
|
||||
Add the service account JSON as a single-line string to `.env`:
|
||||
Capture the service account JSON, then write it. `prompt` only *asks* and binds
|
||||
the answer to a name; a separate directive consumes it — so the same prompt
|
||||
could feed `ncl` or the OneCLI vault instead of `.env` by swapping only the
|
||||
consumer. Here it goes to `.env` (set-if-absent — a value you've already filled
|
||||
in is never overwritten) as a single-line string:
|
||||
|
||||
```bash
|
||||
GCHAT_CREDENTIALS={"type":"service_account","project_id":"...","private_key":"...","client_email":"..."}
|
||||
```nc:prompt gchat_credentials secret
|
||||
Paste the service account JSON as a single line — the key file you downloaded, e.g. `{"type":"service_account","project_id":"...","private_key":"...","client_email":"..."}`.
|
||||
```
|
||||
```nc:env-set
|
||||
GCHAT_CREDENTIALS={{gchat_credentials}}
|
||||
```
|
||||
### Webhook server
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
The Chat SDK bridge automatically starts a shared webhook server on port 3000
|
||||
(`WEBHOOK_PORT` to change it), handling `/webhook/gchat`. This port must be
|
||||
publicly reachable for Google Chat to deliver events — it's the HTTP endpoint
|
||||
URL you set in the Connection settings above. Running locally, expose it with
|
||||
ngrok (`ngrok http 3000`), a Cloudflare Tunnel, or a reverse proxy on a VPS.
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
|
||||
Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
||||
If you're in the middle of `/setup`, return to the setup flow now. Otherwise run
|
||||
`/manage-channels` to wire this channel to an agent group.
|
||||
|
||||
## Channel Info
|
||||
|
||||
@@ -97,3 +115,5 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
||||
- **supports-threads**: yes
|
||||
- **typical-use**: Interactive chat — team spaces or direct messages
|
||||
- **default-isolation**: Same agent group for spaces where you're the primary user. Separate agent group for spaces with different teams or sensitive contexts.
|
||||
</content>
|
||||
</invoke>
|
||||
|
||||
@@ -5,64 +5,68 @@ description: Add GitHub channel integration via Chat SDK. PR and issue comment t
|
||||
|
||||
# Add GitHub Channel
|
||||
|
||||
Adds GitHub support via the Chat SDK bridge. The agent participates in PR and issue comment threads.
|
||||
Adds GitHub support via the Chat SDK bridge. The agent participates in PR and
|
||||
issue comment threads. NanoClaw doesn't ship channels in trunk — this skill
|
||||
copies the GitHub adapter in from the `channels` branch.
|
||||
|
||||
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
|
||||
reads the prose and applies them, and a parser can apply them deterministically
|
||||
from the same document. Every directive is idempotent, so the whole skill is
|
||||
safe to re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
You need a **dedicated GitHub bot account** (not your personal account). The adapter uses this account to post replies and filters out its own messages to avoid loops. Create a free GitHub account for your bot (e.g. `my-org-bot`), then invite it as a collaborator with write access to the repos you want monitored.
|
||||
|
||||
## Install
|
||||
## Apply
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the GitHub adapter in from the `channels` branch.
|
||||
### 1. Copy the adapter
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
Fetch the `channels` branch and copy the GitHub adapter into `src/channels/`
|
||||
(overwrite — the branch is canonical):
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
- `src/channels/github.ts` exists
|
||||
- `src/channels/github-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './github.js';`
|
||||
- `@chat-adapter/github` 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
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/github.ts
|
||||
src/channels/github-registration.test.ts
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Register the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/github.ts > src/channels/github.ts
|
||||
git show origin/channels:src/channels/github-registration.test.ts > src/channels/github-registration.test.ts
|
||||
```
|
||||
Append the self-registration import to the channel barrel (skipped if the line
|
||||
is already present). This one line is the skill's only reach-in into core:
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
```nc:append to:src/channels/index.ts
|
||||
import './github.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
### 3. Install the adapter package
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/github@4.29.0
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
|
||||
|
||||
```nc:dep
|
||||
@chat-adapter/github@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
### 4. Build and validate
|
||||
|
||||
```bash
|
||||
The build guards the typed `createChatSdkBridge(...)` core call and proves the
|
||||
dependency is installed (the adapter import throws if `@chat-adapter/github`
|
||||
isn't present):
|
||||
|
||||
```nc:run effect:build
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/github-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `github-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `github`. It goes red if the `import './github.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/github` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
`github-registration.test.ts` imports the real channel barrel and asserts the
|
||||
registry contains `github`. It goes red if the import line is deleted or drifts,
|
||||
if the barrel fails to evaluate, or if `@chat-adapter/github` isn't installed
|
||||
(the import throws) — so it also covers the dependency from step 3.
|
||||
|
||||
End-to-end message delivery against a real GitHub repo is verified manually once the service is running — see Next Steps and the webhook setup above.
|
||||
End-to-end message delivery against a real GitHub repo is verified manually once
|
||||
the service is running — see Next Steps and the webhook setup below.
|
||||
|
||||
## Credentials
|
||||
|
||||
@@ -88,18 +92,28 @@ On each repo (logged in as the repo owner/admin):
|
||||
|
||||
### 3. Configure environment
|
||||
|
||||
Add to `.env`:
|
||||
Capture the three values, then write them. `prompt` only *asks* and binds the
|
||||
answer to a name; a separate directive consumes it — so the same prompts could
|
||||
feed `ncl` or the OneCLI vault instead of `.env` by swapping only the consumer.
|
||||
Here they go to `.env` (set-if-absent — a value you've already filled in is
|
||||
never overwritten):
|
||||
|
||||
```bash
|
||||
GITHUB_TOKEN=github_pat_...
|
||||
GITHUB_WEBHOOK_SECRET=your-webhook-secret
|
||||
GITHUB_BOT_USERNAME=your-bot-username
|
||||
```nc:prompt github_token secret
|
||||
Paste the Fine-grained Personal Access Token for the bot account — starts with `github_pat_`.
|
||||
```
|
||||
```nc:prompt webhook_secret secret
|
||||
Paste the webhook secret you generated for the repo webhook(s).
|
||||
```
|
||||
```nc:prompt bot_username
|
||||
Enter the bot account's GitHub username exactly (used for @-mention detection).
|
||||
```
|
||||
```nc:env-set
|
||||
GITHUB_TOKEN={{github_token}}
|
||||
GITHUB_WEBHOOK_SECRET={{webhook_secret}}
|
||||
GITHUB_BOT_USERNAME={{bot_username}}
|
||||
```
|
||||
|
||||
`GITHUB_BOT_USERNAME` must match the bot account's GitHub username exactly. This is used for @-mention detection — the agent responds when someone writes `@your-bot-username` in a PR or issue comment.
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
## Wiring
|
||||
|
||||
Ask the user: **Is this a private or public repo?**
|
||||
|
||||
@@ -19,17 +19,17 @@ ncl groups config remove-mcp-server --id <group-id> --name gmail
|
||||
|
||||
## 3. Remove the `.gmail-mcp` mount (per group)
|
||||
|
||||
There is no `ncl groups config remove-mount` verb yet ([#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Edit the central DB via the in-tree wrapper (`scripts/q.ts` — NanoClaw avoids depending on the `sqlite3` CLI, `setup/verify.ts:5`). Run from your NanoClaw project root (where `data/v2.db` lives):
|
||||
Remove the mount with the host-only `ncl groups config remove-mount` verb (operator-only; rejected from inside a container). For each group that had Gmail wired:
|
||||
|
||||
```bash
|
||||
GROUP_ID='<group-id>'
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
|
||||
SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \
|
||||
WHERE json_extract(value, '\$.containerPath') != '.gmail-mcp'), \
|
||||
updated_at = datetime('now') \
|
||||
WHERE agent_group_id = '$GROUP_ID';"
|
||||
ncl groups config remove-mount \
|
||||
--id <group-id> \
|
||||
--host "$HOME/.gmail-mcp" \
|
||||
--container .gmail-mcp
|
||||
```
|
||||
|
||||
The verb is idempotent — a no-op if the mount is already absent.
|
||||
|
||||
## 4. Remove the Dockerfile install
|
||||
|
||||
In `container/Dockerfile`, delete the `ARG GMAIL_MCP_VERSION=...` line and the `pnpm install -g` `RUN` block that installs `@gongrzhe/server-gmail-autoauth-mcp` and `zod-to-json-schema`.
|
||||
|
||||
@@ -181,21 +181,16 @@ Approval behaviour depends on where you run it: from inside an agent's container
|
||||
|
||||
### Add the `.gmail-mcp` mount
|
||||
|
||||
There is no `ncl groups config add-mount` verb yet (tracked in [#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Until that ships, edit the DB directly via the in-tree wrapper (`scripts/q.ts` — `setup/verify.ts:5` codifies that NanoClaw avoids depending on the `sqlite3` CLI binary, so don't shell out to it):
|
||||
Register the mount with the host-only `ncl groups config add-mount` verb. For each chosen `<group-id>`:
|
||||
|
||||
```bash
|
||||
GROUP_ID='<group-id>'
|
||||
HOST_PATH="$HOME/.gmail-mcp"
|
||||
MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".gmail-mcp", readonly:false}')
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
|
||||
SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \
|
||||
updated_at = datetime('now') \
|
||||
WHERE agent_group_id = '$GROUP_ID';"
|
||||
ncl groups config add-mount \
|
||||
--id <group-id> \
|
||||
--host "$HOME/.gmail-mcp" \
|
||||
--container .gmail-mcp
|
||||
```
|
||||
|
||||
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is ISO-string everywhere else in the schema, so use `datetime('now')` — not `strftime('%s','now')`, which would silently mix epoch ints into a column of YYYY-MM-DD HH:MM:SS strings.
|
||||
|
||||
**Switch to `ncl groups config add-mount` once #2395 lands.** Update this skill at that time.
|
||||
`--host` is the host path, `--container` is the in-container path (relative, lands at `/workspace/extra/.gmail-mcp`). No `--ro` — the MCP server writes refreshed token state back into the mount. The verb is idempotent (a re-run skips if the mount is already present) and operator-only (host-side; rejected from inside a container), so run it from a host operator shell when applying this skill.
|
||||
|
||||
**Why the container path is relative:** `mount-security` rejects absolute `containerPath` values. Additional mounts are prefixed with `/workspace/extra/`, so `containerPath: ".gmail-mcp"` lands at `/workspace/extra/.gmail-mcp`. The MCP server's `GMAIL_OAUTH_PATH` / `GMAIL_CREDENTIALS_PATH` env vars point at that absolute location inside the container.
|
||||
|
||||
|
||||
@@ -8,10 +8,9 @@
|
||||
* allowedTools: [ ...TOOL_ALLOWLIST, ...Object.keys(this.mcpServers).map(mcpAllowPattern) ]
|
||||
*
|
||||
* `mcpAllowPattern` is not exported and the call site lives inside the SDK query options,
|
||||
* so we assert the derivation structurally. Delete or rename the derivation and this goes
|
||||
* red — surfacing that `gmail` tools would silently be filtered out despite being registered.
|
||||
*
|
||||
* `mcpAllowPattern` itself is exercised directly to prove `gmail` -> `mcp__gmail__*`.
|
||||
* so the derivation is non-invocable from a test — we guard it structurally. Delete or
|
||||
* rename either half (the function or the spread into allowedTools) and this goes red,
|
||||
* surfacing that `gmail` tools would silently be filtered out despite being registered.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
@@ -25,11 +24,6 @@ function source(): { sf: ts.SourceFile; text: string } {
|
||||
return { sf: ts.createSourceFile(p, text, ts.ScriptTarget.Latest, true), text };
|
||||
}
|
||||
|
||||
/** Reimplement the sanitizer the provider applies, to assert the gmail name maps cleanly. */
|
||||
function expectedPattern(name: string): string {
|
||||
return `mcp__${name.replace(/[^a-zA-Z0-9_-]/g, '_')}__*`;
|
||||
}
|
||||
|
||||
describe('claude.ts derives MCP allow-patterns from the registered servers', () => {
|
||||
const { sf, text } = source();
|
||||
|
||||
@@ -48,8 +42,4 @@ describe('claude.ts derives MCP allow-patterns from the registered servers', ()
|
||||
const flat = text.replace(/\s+/g, ' ');
|
||||
expect(flat).toContain('Object.keys(this.mcpServers).map(mcpAllowPattern)');
|
||||
});
|
||||
|
||||
it('maps a gmail server name to mcp__gmail__*', () => {
|
||||
expect(expectedPattern('gmail')).toBe('mcp__gmail__*');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,110 +5,196 @@ description: Add iMessage channel integration via Chat SDK. Local (macOS) or rem
|
||||
|
||||
# Add iMessage Channel
|
||||
|
||||
Adds iMessage support via the Chat SDK bridge. Two modes: local (macOS with Full Disk Access) or remote (Photon API).
|
||||
Adds iMessage support via the Chat SDK bridge. Two modes: local (macOS with Full
|
||||
Disk Access) or remote (Photon API). NanoClaw doesn't ship channels in trunk —
|
||||
this skill copies the iMessage adapter in from the `channels` branch.
|
||||
|
||||
## Install
|
||||
The mechanical steps under **Apply** carry `nc:` directive fences: an agent reads
|
||||
the prose and applies them, and a parser can apply them deterministically from
|
||||
the same document. Every directive is idempotent, so the whole skill is safe to
|
||||
re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the iMessage adapter in from the `channels` branch.
|
||||
## Apply
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
### 1. Copy the adapter
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
Fetch the `channels` branch and copy the iMessage adapter into `src/channels/`
|
||||
(overwrite — the branch is canonical):
|
||||
|
||||
- `src/channels/imessage.ts` exists
|
||||
- `src/channels/imessage-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './imessage.js';`
|
||||
- `chat-adapter-imessage` 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
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/imessage.ts
|
||||
src/channels/imessage-registration.test.ts
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Register the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/imessage.ts > src/channels/imessage.ts
|
||||
git show origin/channels:src/channels/imessage-registration.test.ts > src/channels/imessage-registration.test.ts
|
||||
```
|
||||
Append the self-registration import to the channel barrel (skipped if the line
|
||||
is already present). This one line is the skill's only reach-in into core:
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
```nc:append to:src/channels/index.ts
|
||||
import './imessage.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
### 3. Install the adapter package
|
||||
|
||||
```bash
|
||||
pnpm install chat-adapter-imessage@0.1.1
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
|
||||
|
||||
```nc:dep
|
||||
chat-adapter-imessage@0.1.1
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
### 4. Build and validate
|
||||
|
||||
```bash
|
||||
Build guards the typed `createChatSdkBridge(...)` core call and proves the
|
||||
dependency is installed (the adapter's top-level `import` from
|
||||
`chat-adapter-imessage` throws if it isn't):
|
||||
|
||||
```nc:run effect:build
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/imessage-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `imessage-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `imessage`. It goes red if the `import './imessage.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `chat-adapter-imessage` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
`imessage-registration.test.ts` imports the real channel barrel and asserts the
|
||||
registry contains `imessage` — it goes red if the import line is deleted or
|
||||
drifts, if the barrel fails to evaluate, or if `chat-adapter-imessage` isn't
|
||||
installed (the import throws), so it also covers the dependency from step 3.
|
||||
|
||||
End-to-end message delivery against a real iMessage account is verified manually once the service is running — see Next Steps.
|
||||
End-to-end message delivery against a real iMessage account is verified manually
|
||||
once the service is running — see Next Steps.
|
||||
|
||||
## Credentials
|
||||
|
||||
iMessage runs in one of two modes:
|
||||
|
||||
- **Local (macOS)** — the bot runs on this Mac and talks via the signed-in
|
||||
iMessage account. Reading `chat.db` needs Full Disk Access granted to the
|
||||
Node binary the host runs under.
|
||||
- **Remote (Photon API)** — the bot talks to a separate Photon server that owns
|
||||
an iMessage account on another Mac. Use this off macOS, or to keep this Mac's
|
||||
chat history out of the loop.
|
||||
|
||||
Mode choice and the Full Disk Access / Photon walkthroughs are human and
|
||||
interactive. Pick the mode first (local is the macOS default; remote is the only
|
||||
option off macOS), then walk only that mode's setup — the other mode's steps are
|
||||
skipped:
|
||||
|
||||
```nc:prompt mode validate:^(local|remote)$
|
||||
How should iMessage run — `local` (this Mac, needs Full Disk Access) or `remote` (a Photon server)?
|
||||
```
|
||||
|
||||
### Local Mode (macOS)
|
||||
|
||||
Requirements: macOS with Full Disk Access granted to the Node.js binary.
|
||||
Requirements: macOS, with Full Disk Access granted to the Node binary. Without
|
||||
it the adapter can't read `chat.db` and inbound messages never arrive.
|
||||
|
||||
The Node binary path is buried deep (e.g. `~/.nvm/versions/node/v22.x.x/bin/node`). To make it easy, open the folder in Finder so the user can drag the file into System Settings:
|
||||
Local mode only works on a Mac — it reads this machine's iMessage `chat.db`
|
||||
directly, and there is no such database off macOS. On any other OS, stop here and
|
||||
use remote (Photon) mode instead; otherwise you'd write a local config that can
|
||||
never receive a message:
|
||||
|
||||
```bash
|
||||
open "$(dirname "$(which node)")"
|
||||
```nc:run effect:check when:mode=local
|
||||
[ "$(uname)" = Darwin ]
|
||||
```
|
||||
|
||||
The Node binary path is buried deep (e.g. `~/.nvm/versions/node/v22.x.x/bin/node`),
|
||||
so open its folder in Finder to make the drag-and-drop target obvious. Harmless
|
||||
off a desktop (SSH/headless) — it just no-ops:
|
||||
|
||||
```nc:run effect:external when:mode=local
|
||||
open "$(dirname "$(which node)")" 2>/dev/null || true
|
||||
```
|
||||
|
||||
Then tell the user:
|
||||
|
||||
1. Open **System Settings** > **Privacy & Security** > **Full Disk Access**
|
||||
2. Click **+**, then drag the `node` file from the Finder window that just opened
|
||||
3. Toggle it on
|
||||
```nc:operator when:mode=local
|
||||
Grant Full Disk Access to Node so iMessage can read your chat history:
|
||||
1. Open System Settings > Privacy & Security > Full Disk Access.
|
||||
2. Click +, then drag the "node" file from the Finder window that just opened.
|
||||
3. Toggle it on, then come back here.
|
||||
```
|
||||
|
||||
Stop and wait for the user to confirm before continuing.
|
||||
Stop and wait for the user to confirm Full Disk Access is granted before
|
||||
continuing.
|
||||
|
||||
### Remote Mode (Photon API)
|
||||
|
||||
1. Set up a [Photon](https://photon.codes) account
|
||||
2. Get your server URL and API key
|
||||
Photon is a separate service that owns an iMessage account and exposes it over
|
||||
HTTP; NanoClaw talks to it via its API. Tell the user:
|
||||
|
||||
```nc:operator when:mode=remote
|
||||
Set up remote iMessage via Photon:
|
||||
1. Create a Photon server: https://photon.codes
|
||||
2. Copy the server URL and API key from your Photon dashboard.
|
||||
```
|
||||
|
||||
Then collect the two values:
|
||||
|
||||
```nc:prompt server_url when:mode=remote validate:^https?:// flags:i reuse:IMESSAGE_SERVER_URL
|
||||
Your Photon server URL — starts with http:// or https:// (e.g. https://photon.example.com).
|
||||
```
|
||||
```nc:prompt api_key secret when:mode=remote reuse:IMESSAGE_API_KEY
|
||||
Your Photon API key — from the Photon dashboard.
|
||||
```
|
||||
|
||||
### Configure environment
|
||||
|
||||
**Local mode** -- add to `.env`:
|
||||
The two modes use different `.env` keys. Write only the keys for the chosen
|
||||
mode, and strip the opposite mode's keys so a stale value can't confuse the
|
||||
adapter's factory. The configure script owns this upsert-and-remove (a plain
|
||||
set-if-absent env write can neither replace a stale value nor delete a key):
|
||||
|
||||
```bash
|
||||
IMESSAGE_ENABLED=true
|
||||
IMESSAGE_LOCAL=true
|
||||
**Local mode** — writes `IMESSAGE_LOCAL=true` and `IMESSAGE_ENABLED=true`, and
|
||||
removes `IMESSAGE_SERVER_URL` / `IMESSAGE_API_KEY` if present:
|
||||
|
||||
```nc:run effect:external when:mode=local
|
||||
bash setup/channels/imessage-configure.sh local
|
||||
```
|
||||
|
||||
**Remote mode** -- add to `.env`:
|
||||
**Remote mode** — writes `IMESSAGE_LOCAL=false`, `IMESSAGE_SERVER_URL`, and
|
||||
`IMESSAGE_API_KEY`, and removes `IMESSAGE_ENABLED` if present:
|
||||
|
||||
```bash
|
||||
IMESSAGE_LOCAL=false
|
||||
IMESSAGE_SERVER_URL=https://your-photon-server.com
|
||||
IMESSAGE_API_KEY=your-api-key
|
||||
```nc:run effect:external when:mode=remote
|
||||
bash setup/channels/imessage-configure.sh remote "{{server_url}}" "{{api_key}}"
|
||||
```
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
## Restart
|
||||
|
||||
Restart the service so it loads the iMessage adapter and the credentials you
|
||||
just stored, and wait for its CLI socket before wiring:
|
||||
|
||||
```nc:run effect:restart
|
||||
bash setup/lib/restart.sh
|
||||
```
|
||||
|
||||
## Resolve your iMessage handle
|
||||
|
||||
The agent greets you in the iMessage conversation tied to the phone number or
|
||||
Apple ID email you message from — that handle is both your identity and the
|
||||
conversation address. Resolve it so the owner-wiring step can target it.
|
||||
|
||||
```nc:prompt owner_handle validate:^(\+\d{8,15}|[^\s@]+@[^\s@]+\.[^\s@]+)$
|
||||
The phone number or email you iMessage from — a +E.164 number (e.g. +14155551234) or an email / Apple ID (e.g. you@icloud.com).
|
||||
```
|
||||
|
||||
iMessage is a native adapter: it sends the raw handle as the conversation
|
||||
address, with no channel prefix — so the messaging-group platform id is that
|
||||
handle as-is.
|
||||
|
||||
```nc:run capture:platform_id
|
||||
echo "{{owner_handle}}"
|
||||
```
|
||||
|
||||
`owner_handle` and `platform_id` are what the owner-wiring step needs. The
|
||||
welcome iMessage goes out through the adapter once the service is running — in
|
||||
local mode that needs Full Disk Access granted (above); in remote mode it goes
|
||||
via your Photon server.
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
|
||||
Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
||||
If you're in the middle of `/setup`, return to the setup flow now. Otherwise wire
|
||||
this channel with `/init-first-agent` (or `/manage-channels`).
|
||||
|
||||
## Channel Info
|
||||
|
||||
|
||||
@@ -18,16 +18,7 @@ rm -f src/channels/linear.ts src/channels/linear-registration.test.ts
|
||||
|
||||
## 2. Remove credentials
|
||||
|
||||
Remove the Linear env vars from `.env`, then re-sync to the container:
|
||||
|
||||
```bash
|
||||
LINEAR_CLIENT_ID
|
||||
LINEAR_CLIENT_SECRET
|
||||
LINEAR_API_KEY
|
||||
LINEAR_WEBHOOK_SECRET
|
||||
LINEAR_BOT_USERNAME
|
||||
LINEAR_TEAM_KEY
|
||||
```
|
||||
Remove `LINEAR_CLIENT_ID`, `LINEAR_CLIENT_SECRET`, `LINEAR_API_KEY`, `LINEAR_WEBHOOK_SECRET`, `LINEAR_BOT_USERNAME`, and `LINEAR_TEAM_KEY` from `.env`, then re-sync to the container:
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
|
||||
@@ -5,7 +5,15 @@ description: Add Linear channel integration via Chat SDK. Issue comment threads
|
||||
|
||||
# Add Linear Channel
|
||||
|
||||
Adds Linear support via the Chat SDK bridge. The agent participates in issue comment threads. Every comment on a Linear issue triggers the agent — no @-mention needed.
|
||||
Adds Linear support via the Chat SDK bridge. The agent participates in issue
|
||||
comment threads. Every comment on a Linear issue triggers the agent — no
|
||||
@-mention needed. NanoClaw doesn't ship channels in trunk — this skill copies the
|
||||
Linear adapter in from the `channels` branch.
|
||||
|
||||
The mechanical steps under **Apply** carry `nc:` directive fences: an agent reads
|
||||
the prose and applies them, and a parser can apply them deterministically from
|
||||
the same document. Every directive is idempotent, so the whole skill is safe to
|
||||
re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -20,61 +28,65 @@ Adds Linear support via the Chat SDK bridge. The agent participates in issue com
|
||||
|
||||
**Alternative:** Use a Personal API Key (`LINEAR_API_KEY`) for simpler setup. The agent will post as you, and your own comments will be filtered (other team members' comments still work).
|
||||
|
||||
## Install
|
||||
## Apply
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Linear adapter in from the `channels` branch and wires it into the channel registry. Linear OAuth apps post and read comments under an app identity that can't be @-mentioned, so when you wire the channel in `/manage-channels`, pick an engage mode that responds to plain comments rather than mention-only.
|
||||
Linear OAuth apps post and read comments under an app identity that can't be
|
||||
@-mentioned, so when you wire the channel in `/manage-channels`, pick an engage
|
||||
mode that responds to plain comments rather than mention-only.
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
### 1. Copy the adapter and its registration test
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
Fetch the `channels` branch and copy the Linear adapter and its registration
|
||||
test into `src/channels/` (overwrite — the branch is canonical):
|
||||
|
||||
- `src/channels/linear.ts` exists
|
||||
- `src/channels/linear-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './linear.js';`
|
||||
- `@chat-adapter/linear` 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
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/linear.ts
|
||||
src/channels/linear-registration.test.ts
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Register the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/linear.ts > src/channels/linear.ts
|
||||
git show origin/channels:src/channels/linear-registration.test.ts > src/channels/linear-registration.test.ts
|
||||
```
|
||||
Append the self-registration import to the channel barrel (skipped if the line
|
||||
is already present). This one line is the skill's only reach-in into the channel
|
||||
registry:
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
```nc:append to:src/channels/index.ts
|
||||
import './linear.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
### 3. Install the adapter package
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/linear@4.29.0
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
|
||||
|
||||
```nc:dep
|
||||
@chat-adapter/linear@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
### 4. Build and validate
|
||||
|
||||
```bash
|
||||
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
|
||||
the dependency is installed. Then run the one integration test.
|
||||
|
||||
```nc:run effect:build
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/linear-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `linear-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `linear`. It goes red if the `import './linear.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/linear` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
|
||||
End-to-end message delivery against a real Linear workspace is verified manually once the service is running — see Wiring and Next Steps.
|
||||
Both must be clean before proceeding. `linear-registration.test.ts` imports the
|
||||
real channel barrel and asserts the registry contains `linear`. It goes red if
|
||||
the `import './linear.js';` line is deleted or drifts, if the barrel fails to
|
||||
evaluate, or if `@chat-adapter/linear` isn't installed (the import throws) — so
|
||||
it also covers the dependency from step 3. End-to-end message delivery against a
|
||||
real Linear workspace is verified manually once the service is running — see
|
||||
Wiring and Next Steps.
|
||||
|
||||
## Credentials
|
||||
|
||||
Linear app and webhook setup is human and interactive — these steps are prose
|
||||
(no parser can click through the Linear UI), except the final env write.
|
||||
|
||||
### 1. Set up a webhook
|
||||
|
||||
1. Go to **Linear Settings** > **API** > **Webhooks** > **New webhook**
|
||||
@@ -86,48 +98,68 @@ End-to-end message delivery against a real Linear workspace is verified manually
|
||||
|
||||
Note: Linear webhook delivery may be delayed 1-5 minutes for new webhooks. This is normal.
|
||||
|
||||
### 2. Configure environment
|
||||
### 2. Store the credentials
|
||||
|
||||
Add to `.env`:
|
||||
Capture the values, then write them. `prompt` only *asks* and binds the answer
|
||||
to a name; a separate directive consumes it. Here they go to `.env`
|
||||
(set-if-absent — a value you've already filled in is never overwritten) and sync
|
||||
to the container.
|
||||
|
||||
Use **either** the OAuth app credentials (recommended) **or** a Personal API key.
|
||||
For the API-key path, paste `none` at the OAuth prompts and set `LINEAR_API_KEY`
|
||||
in `.env` by hand (commented in the template below). `LINEAR_BOT_USERNAME` is the
|
||||
display name for the bot, used for self-message detection when using a Personal
|
||||
API Key. `LINEAR_TEAM_KEY` is the Linear team key (e.g. `ENG`, `NAN`) — find it
|
||||
in Linear under Settings > Teams; all issues in this team route to one messaging
|
||||
group.
|
||||
|
||||
```nc:prompt linear_client_id secret
|
||||
Paste the OAuth Client ID — Linear Settings > API > OAuth Applications. Paste `none` if using a Personal API key instead.
|
||||
```
|
||||
```nc:prompt linear_client_secret secret
|
||||
Paste the OAuth Client Secret. Paste `none` if using a Personal API key instead.
|
||||
```
|
||||
```nc:prompt linear_webhook_secret secret
|
||||
Paste the webhook signing secret from the webhook you just created.
|
||||
```
|
||||
```nc:prompt linear_team_key
|
||||
Enter the Linear team key (e.g. `ENG`, `NAN`) — Settings > Teams.
|
||||
```
|
||||
```nc:prompt linear_bot_username
|
||||
Enter the bot display name (e.g. `NanoClaw Bot`).
|
||||
```
|
||||
```nc:env-set
|
||||
LINEAR_CLIENT_ID={{linear_client_id}}
|
||||
LINEAR_CLIENT_SECRET={{linear_client_secret}}
|
||||
LINEAR_WEBHOOK_SECRET={{linear_webhook_secret}}
|
||||
LINEAR_TEAM_KEY={{linear_team_key}}
|
||||
LINEAR_BOT_USERNAME={{linear_bot_username}}
|
||||
```
|
||||
If you went the Personal API key route, add this line to `.env` instead of the
|
||||
OAuth pair (agent posts as you, your own comments are filtered):
|
||||
|
||||
```bash
|
||||
# OAuth app (recommended)
|
||||
LINEAR_CLIENT_ID=your-client-id
|
||||
LINEAR_CLIENT_SECRET=your-client-secret
|
||||
|
||||
# OR Personal API key (simpler, but agent posts as you)
|
||||
# LINEAR_API_KEY=lin_api_...
|
||||
|
||||
LINEAR_WEBHOOK_SECRET=your-webhook-signing-secret
|
||||
LINEAR_BOT_USERNAME=NanoClaw Bot
|
||||
LINEAR_TEAM_KEY=ENG
|
||||
LINEAR_API_KEY=lin_api_...
|
||||
```
|
||||
|
||||
- `LINEAR_BOT_USERNAME`: display name for the bot (used for self-message detection when using a Personal API Key)
|
||||
- `LINEAR_TEAM_KEY`: the Linear team key (e.g. `ENG`, `NAN`). Find it in Linear under Settings > Teams. All issues in this team route to one messaging group.
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
## Wiring
|
||||
|
||||
Ask the user: **Is this a private or public Linear workspace?**
|
||||
Linear is team-routed: the assistant watches one team and answers *every* comment
|
||||
on its issues (it can't be @-mentioned). Wire the team you set up to an agent —
|
||||
pick which one should answer (`ncl groups list` shows their folders):
|
||||
|
||||
- **Private workspace** — use `unknown_sender_policy: 'public'`. Only workspace members can comment.
|
||||
- **Public workspace** — use `unknown_sender_policy: 'strict'` and add trusted members (see GitHub skill for member registration example).
|
||||
|
||||
Run `/manage-channels` to wire the Linear channel to an agent group, or insert manually:
|
||||
|
||||
```sql
|
||||
-- Create messaging group (one per team)
|
||||
INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'linear', 'Engineering', 1, 'public', datetime('now'));
|
||||
|
||||
-- Wire to agent group
|
||||
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
|
||||
VALUES ('mga-linear-eng', 'mg-linear-eng', '<your-agent-group-id>', '', 'all', 'per-thread', 10, datetime('now'));
|
||||
```nc:prompt agent_folder
|
||||
Which agent should answer Linear comments? Enter its folder (run `ncl groups list`).
|
||||
```
|
||||
```nc:run effect:wire
|
||||
ncl messaging-groups create --channel-type linear --platform-id linear:{{linear_team_key}} --is-group 1 --unknown-sender-policy public --name {{linear_team_key}}
|
||||
ncl wirings create --channel-type linear --platform-id linear:{{linear_team_key}} --agent-group {{agent_folder}} --engage-mode pattern --engage-pattern . --session-mode per-thread
|
||||
```
|
||||
|
||||
The `platform_id` must be `linear:<TEAM_KEY>` matching the `LINEAR_TEAM_KEY` env var. Use `per-thread` session mode so each issue comment thread gets its own agent session.
|
||||
Each issue thread becomes its own conversation. There's no welcome — Linear has
|
||||
no direct message, so the assistant greets people when it first answers a comment.
|
||||
For a public-internet workspace, restrict it to people you've registered with
|
||||
`--unknown-sender-policy strict` (see the GitHub skill for adding members).
|
||||
|
||||
## Next Steps
|
||||
|
||||
|
||||
@@ -18,22 +18,7 @@ rm -f src/channels/matrix.ts src/channels/matrix-registration.test.ts
|
||||
|
||||
## 2. Remove credentials
|
||||
|
||||
Remove the `MATRIX_*` lines from `.env`:
|
||||
|
||||
```bash
|
||||
MATRIX_BASE_URL
|
||||
MATRIX_USERNAME
|
||||
MATRIX_PASSWORD
|
||||
MATRIX_USER_ID
|
||||
MATRIX_BOT_USERNAME
|
||||
MATRIX_ACCESS_TOKEN
|
||||
MATRIX_INVITE_AUTOJOIN
|
||||
MATRIX_INVITE_AUTOJOIN_ALLOWLIST
|
||||
MATRIX_RECOVERY_KEY
|
||||
MATRIX_DEVICE_ID
|
||||
```
|
||||
|
||||
Then re-sync to the container:
|
||||
Remove the Matrix env vars apply set — `MATRIX_BASE_URL`, `MATRIX_USER_ID`, `MATRIX_BOT_USERNAME`, and whichever auth path you chose (`MATRIX_USERNAME` + `MATRIX_PASSWORD`, or `MATRIX_ACCESS_TOKEN`) — from `.env`, then re-sync to the container:
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
|
||||
@@ -5,57 +5,53 @@ description: Add Matrix channel integration via Chat SDK. Works with any Matrix
|
||||
|
||||
# Add Matrix Channel
|
||||
|
||||
Adds Matrix support via the Chat SDK bridge.
|
||||
Adds Matrix support via the Chat SDK bridge. NanoClaw doesn't ship channels in
|
||||
trunk — this skill copies the Matrix adapter in from the `channels` branch.
|
||||
|
||||
## Install
|
||||
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
|
||||
reads the prose and applies them, and a parser can apply them deterministically
|
||||
from the same document. Every directive is idempotent, so the whole skill is
|
||||
safe to re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Matrix adapter in from the `channels` branch.
|
||||
## Apply
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
### 1. Copy the adapter
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
Fetch the `channels` branch and copy the Matrix adapter into `src/channels/`
|
||||
(overwrite — the branch is canonical):
|
||||
|
||||
- `src/channels/matrix.ts` exists
|
||||
- `src/channels/matrix-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './matrix.js';`
|
||||
- `@beeper/chat-adapter-matrix` 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
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/matrix.ts
|
||||
src/channels/matrix-registration.test.ts
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Register the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/matrix.ts > src/channels/matrix.ts
|
||||
git show origin/channels:src/channels/matrix-registration.test.ts > src/channels/matrix-registration.test.ts
|
||||
```
|
||||
Append the self-registration import to the channel barrel (skipped if the line
|
||||
is already present). This one line is the skill's only reach-in into core:
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
```nc:append to:src/channels/index.ts
|
||||
import './matrix.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
### 3. Install the adapter package
|
||||
|
||||
```bash
|
||||
pnpm install @beeper/chat-adapter-matrix@0.2.0
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`.
|
||||
The Matrix adapter lives in the `@beeper/` namespace and versions on its own
|
||||
track (not the `@chat-adapter/*` family), so it carries its own pin:
|
||||
|
||||
```nc:dep
|
||||
@beeper/chat-adapter-matrix@0.2.0
|
||||
```
|
||||
|
||||
### 5. Patch matrix-js-sdk ESM imports
|
||||
### 4. Patch matrix-js-sdk ESM imports
|
||||
|
||||
The adapter's published dist references `matrix-js-sdk/lib/...` without `.js`
|
||||
extensions, which fails under Node 22 strict ESM resolution. Add the missing
|
||||
extensions (idempotent — safe to re-run):
|
||||
extensions (idempotent — safe to re-run). Re-run this after every `pnpm install`
|
||||
that touches the adapter:
|
||||
|
||||
```bash
|
||||
```nc:run effect:external
|
||||
node -e '
|
||||
const fs = require("fs"), path = require("path");
|
||||
const root = "node_modules/.pnpm";
|
||||
@@ -69,22 +65,32 @@ node -e '
|
||||
'
|
||||
```
|
||||
|
||||
Re-run this after every `pnpm install` that touches the adapter.
|
||||
### 5. Build
|
||||
|
||||
### 6. Build and validate
|
||||
Build guards the typed `createChatSdkBridge(...)` core call the adapter makes
|
||||
and proves the dependency is installed and the ESM patch took. It also fails if
|
||||
the `import './matrix.js';` line is missing or the barrel can't evaluate.
|
||||
|
||||
```bash
|
||||
```nc:run effect:build
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/matrix-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `matrix-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `matrix`. It goes red if the `import './matrix.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@beeper/chat-adapter-matrix` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
`matrix-registration.test.ts` imports the real channel barrel and asserts the
|
||||
registry contains `matrix`. It goes red if the import line is deleted or drifts,
|
||||
if the barrel fails to evaluate, or if `@beeper/chat-adapter-matrix` isn't
|
||||
installed (the import throws) — so it also covers the dependency from step 3.
|
||||
|
||||
End-to-end message delivery against a real Matrix homeserver is verified manually once the service is running — see Next Steps.
|
||||
End-to-end message delivery against a real Matrix homeserver is verified
|
||||
manually once the service is running — see Next Steps.
|
||||
|
||||
## Credentials
|
||||
|
||||
The bot needs its own Matrix account — separate from the user's account. This is required because Matrix cannot send DMs to yourself.
|
||||
The bot needs its own Matrix account — separate from the user's account. This is
|
||||
required because Matrix cannot send DMs to yourself. These steps are human and
|
||||
interactive (no parser can click through Element), so they stay prose.
|
||||
|
||||
### Create a bot account
|
||||
|
||||
@@ -131,12 +137,47 @@ MATRIX_RECOVERY_KEY=your-recovery-key # Enable E2EE cross-signing
|
||||
MATRIX_DEVICE_ID=NANOCLAW01 # Stable device ID across restarts
|
||||
```
|
||||
|
||||
### Configure environment
|
||||
### Store the credentials
|
||||
|
||||
Add the chosen env vars to `.env`, then sync:
|
||||
Capture the values for the auth method you chose, then write them. `prompt` only
|
||||
*asks* and binds the answer to a name; a separate directive consumes it — so the
|
||||
same prompts could feed `ncl` or the OneCLI vault instead of `.env` by swapping
|
||||
only the consumer. The homeserver URL, the bot's user ID, and a display name are
|
||||
shared across both auth methods:
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
```nc:prompt base_url
|
||||
Paste the homeserver base URL, e.g. `https://matrix.org`.
|
||||
```
|
||||
```nc:prompt user_id
|
||||
Paste the bot's full Matrix user ID, e.g. `@andybot:matrix.org`.
|
||||
```
|
||||
```nc:prompt bot_username
|
||||
Paste a display name for the bot, e.g. `Andy`.
|
||||
```
|
||||
```nc:env-set
|
||||
MATRIX_BASE_URL={{base_url}}
|
||||
MATRIX_USER_ID={{user_id}}
|
||||
MATRIX_BOT_USERNAME={{bot_username}}
|
||||
```
|
||||
|
||||
For **Option A** capture the bot login, for **Option B** capture the access
|
||||
token — set only the block matching your chosen method:
|
||||
|
||||
```nc:prompt username
|
||||
Option A only — the bot's login username (the localpart, e.g. `andybot`).
|
||||
```
|
||||
```nc:prompt password secret
|
||||
Option A only — the bot account's password.
|
||||
```
|
||||
```nc:env-set
|
||||
MATRIX_USERNAME={{username}}
|
||||
MATRIX_PASSWORD={{password}}
|
||||
```
|
||||
```nc:prompt access_token secret
|
||||
Option B only — the access token from Element Settings > Help & About, or from the login API.
|
||||
```
|
||||
```nc:env-set
|
||||
MATRIX_ACCESS_TOKEN={{access_token}}
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
@@ -9,13 +9,13 @@ Installs [mnemon](https://github.com/mnemon-dev/mnemon) in the agent container i
|
||||
|
||||
## Provider Compatibility
|
||||
|
||||
mnemon hooks fire only under `--target claude-code`. Use this skill on agent groups that run the default Claude provider (`AGENT_PROVIDER=claude`). Confirm the provider before applying:
|
||||
mnemon hooks fire only under `--target claude-code`. Use this skill on agent groups that run the default Claude provider. The provider is the materialized `provider` key in each group's `container.json` (absent or `claude` = default Claude provider). Confirm it before applying:
|
||||
|
||||
```bash
|
||||
grep AGENT_PROVIDER .env groups/*/container.json 2>/dev/null
|
||||
grep -H '"provider"' groups/*/container.json 2>/dev/null # no match, or "provider": "claude" = Claude
|
||||
```
|
||||
|
||||
If a group uses a different provider (e.g. `AGENT_PROVIDER=opencode`), it spawns its own process and never invokes the `claude` CLI, so the hooks registered by `mnemon setup` do not run for that group.
|
||||
If a group sets a different provider (e.g. `"provider": "opencode"`), it spawns its own process and never invokes the `claude` CLI, so the hooks registered by `mnemon setup` do not run for that group.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
name: add-opencode
|
||||
description: Use OpenCode as an agent provider (AGENT_PROVIDER=opencode). OpenRouter, OpenAI, Google, DeepSeek, etc. via OpenCode config — not the Anthropic Agent SDK. Per-session and per-group via agent_provider; host passes OPENCODE_* and XDG mount when spawning containers.
|
||||
description: Use OpenCode as an agent provider. OpenRouter, OpenAI, Google, DeepSeek, etc. via OpenCode config — not the Anthropic Agent SDK. Per group via `ncl groups config update --provider opencode`; host passes OPENCODE_* and XDG mount when spawning containers.
|
||||
---
|
||||
|
||||
# OpenCode agent provider
|
||||
|
||||
NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected with **`AGENT_PROVIDER`** (`claude` | `opencode` | `mock`).
|
||||
NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected per agent group by the **`provider`** key in that group's `container.json` (materialized from the `container_configs` table) — set it with `ncl groups config update --provider opencode`. Default is `claude`.
|
||||
|
||||
Trunk ships with only the `claude` provider baked in. This skill copies the OpenCode provider files in from the `providers` branch, wires them into the host and container barrels, installs dependencies, and rebuilds the image.
|
||||
|
||||
@@ -148,7 +148,7 @@ done
|
||||
|
||||
Set model/provider strings in the form OpenCode expects (often `provider/model-id`). **Put comments on their own lines** — a `#` inside a value is kept verbatim and breaks model IDs.
|
||||
|
||||
These variables are read **on the host** and passed into the container only when the effective provider is `opencode`. They do not switch the provider by themselves; the DB still needs `agent_provider` set (below).
|
||||
These variables are read **on the host** and passed into the container only when the effective provider is `opencode`. They do not switch the provider by themselves; the group still needs `provider` set to `opencode` (see [Select the provider](#select-the-provider) below).
|
||||
|
||||
- `OPENCODE_PROVIDER` — OpenCode provider id, e.g. `openrouter`, `anthropic`, `deepseek`.
|
||||
- `OPENCODE_MODEL` — full model id in `provider/model` form, e.g. `deepseek/deepseek-chat`.
|
||||
@@ -215,7 +215,7 @@ OPENCODE_SMALL_MODEL=anthropic/claude-haiku-4-5-20251001
|
||||
|
||||
Zen's HTTP API (e.g. `POST …/zen/v1/messages`) expects the key in the **`x-api-key`** header. If OneCLI injects **`Authorization: Bearer …`** only, Zen often returns **401 / "Missing API key"** even though the gateway is working.
|
||||
|
||||
**Naming:** NanoClaw **`AGENT_PROVIDER=opencode`** (DB `agent_provider`) means "run the **OpenCode agent provider**." Separately, **`OPENCODE_PROVIDER=opencode`** in `.env` is OpenCode's **Zen provider id** inside the OpenCode config (see [Zen docs](https://opencode.ai/docs/zen/)).
|
||||
**Naming:** NanoClaw's **`provider: opencode`** (the `container.json` key, set via `ncl groups config update --provider opencode`) means "run the **OpenCode agent provider**." Separately, **`OPENCODE_PROVIDER=opencode`** in `.env` is OpenCode's **Zen provider id** inside the OpenCode config (see [Zen docs](https://opencode.ai/docs/zen/)).
|
||||
|
||||
**Host `.env` (typical Zen shape):**
|
||||
|
||||
@@ -236,9 +236,16 @@ onecli secrets create --name "OpenCode Zen" --type generic \
|
||||
--header-name "x-api-key" --value-format "{value}"
|
||||
```
|
||||
|
||||
### Per group / per session
|
||||
### Select the provider
|
||||
|
||||
Set `"provider": "opencode"` in the group's **`container.json`** (`groups/<folder>/container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session XDG mount, `OPENCODE_*` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json` → `'claude'`.
|
||||
Per group, from the host:
|
||||
|
||||
```bash
|
||||
ncl groups config update --id <group-id> --provider opencode
|
||||
ncl groups restart --id <group-id>
|
||||
```
|
||||
|
||||
`ncl groups config update --provider` writes the `provider` value into the `container_configs` table; the host materializes it into `groups/<folder>/container.json` at spawn time and the in-container runner reads `provider` from there (defaulting to `claude`). The restart picks up the change. Switching is an operator action — run it from the host. Memory does NOT carry over automatically between providers — run `/migrate-memory` to carry it across.
|
||||
|
||||
Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host; the runner merges them into the same `mcpServers` object passed to **both** Claude and OpenCode providers.
|
||||
|
||||
@@ -250,6 +257,6 @@ Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config
|
||||
|
||||
## Next Steps
|
||||
|
||||
The registration and Dockerfile guards in step 7 verify the wiring. To confirm an end-to-end round-trip, set `agent_provider = 'opencode'` (or `"provider": "opencode"` in the group's `container.json`) on a test group, register the matching provider key in OneCLI, and send a message. A clean exchange returns the model's reply with no `Unknown provider: opencode` error and no UUID/session warnings in the logs.
|
||||
The registration and Dockerfile guards in step 7 verify the wiring. To confirm an end-to-end round-trip, switch a test group with `ncl groups config update --id <group-id> --provider opencode && ncl groups restart --id <group-id>`, register the matching provider key in OneCLI, and send a message. A clean exchange returns the model's reply with no `Unknown provider: opencode` error and no UUID/session warnings in the logs.
|
||||
|
||||
To remove this provider, see [REMOVE.md](REMOVE.md).
|
||||
|
||||
@@ -5,61 +5,70 @@ description: Add Resend (email) channel integration via Chat SDK.
|
||||
|
||||
# Add Resend Email Channel
|
||||
|
||||
Connect NanoClaw to email via Resend for async email conversations.
|
||||
Connect NanoClaw to email via Resend for async email conversations. NanoClaw
|
||||
doesn't ship channels in trunk — this skill copies the Resend adapter in from the
|
||||
`channels` branch.
|
||||
|
||||
## Install
|
||||
The mechanical steps under **Apply** carry `nc:` directive fences: an agent reads
|
||||
the prose and applies them, and a parser can apply them deterministically from
|
||||
the same document. Every directive is idempotent, so the whole skill is safe to
|
||||
re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Resend adapter in from the `channels` branch.
|
||||
## Apply
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
### 1. Copy the adapter
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
Fetch the `channels` branch and copy the Resend adapter into `src/channels/`
|
||||
(overwrite — the branch is canonical):
|
||||
|
||||
- `src/channels/resend.ts` exists
|
||||
- `src/channels/resend-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './resend.js';`
|
||||
- `@resend/chat-sdk-adapter` 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
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/resend.ts
|
||||
src/channels/resend-registration.test.ts
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Register the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/resend.ts > src/channels/resend.ts
|
||||
git show origin/channels:src/channels/resend-registration.test.ts > src/channels/resend-registration.test.ts
|
||||
```
|
||||
Append the self-registration import to the channel barrel (skipped if the line
|
||||
is already present). This one line is the skill's only reach-in into core:
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
```nc:append to:src/channels/index.ts
|
||||
import './resend.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
### 3. Install the adapter package
|
||||
|
||||
```bash
|
||||
pnpm install @resend/chat-sdk-adapter@0.1.1
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
|
||||
|
||||
```nc:dep
|
||||
@resend/chat-sdk-adapter@0.1.1
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
### 4. Build and validate
|
||||
|
||||
```bash
|
||||
Build guards the typed `createChatSdkBridge(...)` core call and proves the
|
||||
dependency is installed (the adapter imports `@resend/chat-sdk-adapter`; if it
|
||||
isn't installed the barrel throws). End-to-end email delivery against a real
|
||||
domain is verified manually once the service runs.
|
||||
|
||||
```nc:run effect:build
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/resend-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `resend-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `resend`. It goes red if the `import './resend.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@resend/chat-sdk-adapter` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
`resend-registration.test.ts` imports the real channel barrel and asserts the
|
||||
registry contains `resend`. It goes red if the import line is deleted or drifts,
|
||||
if the barrel fails to evaluate, or if `@resend/chat-sdk-adapter` isn't installed
|
||||
(the import throws) — so it also covers the dependency from step 3.
|
||||
|
||||
## Credentials
|
||||
|
||||
Resend account and domain setup is human and interactive — these steps are
|
||||
prose, not directives (no parser can verify a sending domain or click through the
|
||||
Resend UI). A recipe rebuild produces a compiling, registered adapter that cannot
|
||||
receive a message until they're done.
|
||||
|
||||
1. Go to [resend.com](https://resend.com) and create an account.
|
||||
2. Add and verify your sending domain.
|
||||
3. Go to **API Keys** and create a new key.
|
||||
@@ -69,30 +78,72 @@ Both must be clean before proceeding. `resend-registration.test.ts` is the one i
|
||||
- Events: select **email.received**.
|
||||
- Copy the signing secret.
|
||||
|
||||
### Configure environment
|
||||
### Store the credentials
|
||||
|
||||
Add to `.env`:
|
||||
Capture the secrets, then write them. `prompt` only *asks* and binds the answer
|
||||
to a name; a separate directive consumes it — so the same prompts could feed
|
||||
`ncl` or the OneCLI vault instead of `.env` by swapping only the consumer. Here
|
||||
they go to `.env` (set-if-absent — a value you've already filled in is never
|
||||
overwritten):
|
||||
|
||||
```bash
|
||||
RESEND_API_KEY=re_...
|
||||
RESEND_FROM_ADDRESS=bot@yourdomain.com
|
||||
RESEND_FROM_NAME=NanoClaw
|
||||
RESEND_WEBHOOK_SECRET=your-webhook-secret
|
||||
```nc:prompt api_key secret
|
||||
Paste the Resend API key — API Keys, starts with `re_`.
|
||||
```
|
||||
```nc:prompt webhook_secret secret
|
||||
Paste the webhook signing secret — Webhooks, the value you copied above.
|
||||
```
|
||||
```nc:prompt from_address
|
||||
The bot's sending email address on your verified domain (e.g. `bot@yourdomain.com`).
|
||||
```
|
||||
```nc:prompt from_name
|
||||
The display name to send as (e.g. `NanoClaw`).
|
||||
```
|
||||
```nc:env-set
|
||||
RESEND_API_KEY={{api_key}}
|
||||
RESEND_FROM_ADDRESS={{from_address}}
|
||||
RESEND_FROM_NAME={{from_name}}
|
||||
RESEND_WEBHOOK_SECRET={{webhook_secret}}
|
||||
```
|
||||
## Connect yourself
|
||||
|
||||
Because email is direct-addressable, the bot can write to you first — so wire
|
||||
your own address as the owner and have it email you a hello. Tell it your address
|
||||
and which agent should answer your email (`ncl groups list` shows their folders):
|
||||
|
||||
```nc:prompt owner_email
|
||||
Your email address — I'll wire you as owner and email you a hello.
|
||||
```
|
||||
```nc:prompt agent_folder
|
||||
Which agent should answer your email? Enter its folder (run `ncl groups list`).
|
||||
```
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
Register yourself as the owner, wire your address so the agent answers your email,
|
||||
and send the hello:
|
||||
|
||||
```nc:run effect:wire
|
||||
ncl users create --id resend:{{owner_email}} --kind resend --display-name Owner
|
||||
ncl roles grant --user resend:{{owner_email}} --role owner
|
||||
ncl messaging-groups create --channel-type resend --platform-id resend:{{owner_email}} --is-group 0
|
||||
ncl wirings create --channel-type resend --platform-id resend:{{owner_email}} --agent-group {{agent_folder}} --engage-mode pattern --engage-pattern .
|
||||
ncl messaging-groups send --channel-type resend --platform-id resend:{{owner_email}} --sender-id resend:{{owner_email}} --sender Owner --text "Hi — I'm your NanoClaw assistant, reachable by email now. Reply to this thread anytime."
|
||||
```
|
||||
|
||||
The hello arrives as a fresh email thread; reply to keep the conversation going.
|
||||
Your own address is the conversation key (`resend:<your-address>`).
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
|
||||
Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
||||
If you're in the middle of `/setup`, return to the setup flow now. (Answering an
|
||||
*open* inbox — anyone who emails in, not just you — is a separate, not-yet-wired
|
||||
case: email is plain-message, so the router never auto-creates a group for an
|
||||
unknown sender; each correspondent's `resend:<their-address>` must be wired
|
||||
explicitly.)
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `resend`
|
||||
- **terminology**: Resend handles email. Each email thread (identified by subject/In-Reply-To headers) is a separate conversation. The "from address" is the bot's identity.
|
||||
- **how-to-find-id**: The platform ID is the from email address (e.g. `bot@yourdomain.com`). Each sender's email thread becomes its own conversation.
|
||||
- **supports-threads**: yes (via email threading headers -- replies to the same thread stay together)
|
||||
- **terminology**: Resend handles email. The bot has one fixed sending identity (`RESEND_FROM_ADDRESS`, e.g. `bot@yourdomain.com`); every *external correspondent* the bot emails with is a separate conversation, keyed by *their* address.
|
||||
- **how-to-find-id**: The platform ID is the **correspondent's** email address, prefixed — `resend:<their-address>` (e.g. `resend:you@example.com`) — **not** the bot's from-address. The adapter derives it from the reply-to party (`channelIdFromThreadId` returns `resend:<address>`); each distinct email thread from that person (by root `Message-ID`) is a sub-conversation under it.
|
||||
- **supports-threads**: no (the adapter sets `supportsThreads: false`; replies still thread via email headers, but the router does not treat threads as the primary conversation unit)
|
||||
- **typical-use**: Async communication -- email conversations with longer response expectations
|
||||
- **default-isolation**: Same agent group if you want your agent to handle email alongside other channels. Separate agent group if email contains sensitive correspondence that shouldn't be accessible from other channels.
|
||||
|
||||
@@ -4,21 +4,15 @@ Idempotent — safe to run even if some steps were never applied. Run Steps 1–
|
||||
|
||||
## 1. Remove the mount from the container config
|
||||
|
||||
Read the current mounts, drop the entry whose `containerPath` is `/usr/local/bin/rtk`, and write the rest back.
|
||||
Remove the rtk mount with the host-only `remove-mount` verb. It is idempotent — a no-op if the mount isn't present:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
|
||||
ncl groups config remove-mount --id <group-id> \
|
||||
--host ~/.local/bin/rtk \
|
||||
--container /usr/local/bin/rtk
|
||||
```
|
||||
|
||||
Write the filtered array (omit any entry with `"containerPath":"/usr/local/bin/rtk"`):
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"UPDATE container_configs SET additional_mounts = '<filtered-json>' WHERE agent_group_id = '<group-id>'"
|
||||
```
|
||||
|
||||
If no rtk entry is present, leave the array as-is.
|
||||
This verb is operator-only and runs host-side; it is rejected from inside a container.
|
||||
|
||||
## 2. Remove the PreToolUse hook from settings.json
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ Install [rtk](https://github.com/rtk-ai/rtk) — a CLI proxy delivering 60–90%
|
||||
- `~/.local/bin/rtk` mounted read-only at `/usr/local/bin/rtk` inside the target agent group's containers
|
||||
- `PreToolUse` hook in the agent group's `settings.json` so every Bash call is automatically filtered through rtk — no CLAUDE.md instructions needed
|
||||
|
||||
## Integration tests
|
||||
|
||||
This skill has **no in-tree integration test** by design. Its only functional reach-ins are runtime operator actions — the host-only `ncl groups config add-mount` (Step 3) and the `settings.json` `PreToolUse` hook write (Step 4) — neither of which leaves a line in the source tree whose deletion a test could catch. There are no package dependencies or Dockerfile edits to guard either. Conformance is idempotent apply + `REMOVE.md`; the mount and hook are verified at runtime (see Verify).
|
||||
|
||||
## Step 1 — Install rtk on the host
|
||||
|
||||
```bash
|
||||
@@ -43,33 +47,24 @@ Note the group ID (e.g. `ag-1776342942165-ptgddd`). Repeat Steps 3–5 for each
|
||||
|
||||
## Step 3 — Mount rtk into the container config
|
||||
|
||||
`additional_mounts` is a JSON array column on `container_configs`. Read the current value, merge in the rtk entry, and write the merged array back.
|
||||
|
||||
Read current mounts first:
|
||||
Mount the host rtk binary read-only into the container with the host-only `add-mount` verb. It is idempotent — re-running skips the entry if it is already present:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
|
||||
ncl groups config add-mount --id <group-id> \
|
||||
--host ~/.local/bin/rtk \
|
||||
--container /usr/local/bin/rtk \
|
||||
--ro
|
||||
```
|
||||
|
||||
Build the merged array: keep every existing entry, drop any entry whose `containerPath` is `/usr/local/bin/rtk` (so re-running replaces rather than duplicates), then add the rtk entry:
|
||||
This verb is operator-only and runs host-side (via `/setup`, `/customize`, or `/manage-mounts`); it is rejected from inside a container.
|
||||
|
||||
```json
|
||||
{"hostPath":"/home/<user>/.local/bin/rtk","containerPath":"/usr/local/bin/rtk","readonly":true}
|
||||
```
|
||||
|
||||
Write the merged array back:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"UPDATE container_configs SET additional_mounts = '<merged-json>' WHERE agent_group_id = '<group-id>'"
|
||||
```
|
||||
The host root (`~/.local/bin`) must also be in the external mount allowlist at `~/.config/nanoclaw/mount-allowlist.json` for the mount to take effect at spawn. Add it there if it isn't already.
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
|
||||
ncl groups config get --id <group-id>
|
||||
# Look for the /usr/local/bin/rtk mount
|
||||
```
|
||||
|
||||
## Step 4 — Add the PreToolUse hook to settings.json
|
||||
@@ -120,9 +115,8 @@ Then ask the agent to run `git status` or any other supported command. rtk inter
|
||||
Mount wasn't applied or container wasn't restarted:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
|
||||
# Look for entry with /usr/local/bin/rtk
|
||||
ncl groups config get --id <group-id>
|
||||
# Look for the /usr/local/bin/rtk mount
|
||||
ncl groups restart --id <group-id>
|
||||
```
|
||||
|
||||
|
||||
+154
-188
@@ -1,53 +1,168 @@
|
||||
---
|
||||
name: add-signal
|
||||
description: Add Signal channel integration via signal-cli TCP daemon. Native adapter — no Chat SDK bridge.
|
||||
description: Add Signal channel integration via signal-cli device-link. Native adapter — no Chat SDK bridge.
|
||||
---
|
||||
|
||||
# Add Signal Channel
|
||||
|
||||
Adds Signal messaging support via a native adapter that speaks JSON-RPC to a [signal-cli](https://github.com/AsamK/signal-cli) TCP daemon. No Chat SDK bridge — only Node.js builtins (`node:net`, `node:child_process`, `node:fs`).
|
||||
Adds Signal support via a native adapter that speaks JSON-RPC to a
|
||||
[signal-cli](https://github.com/AsamK/signal-cli) daemon — no Chat SDK bridge,
|
||||
only Node.js builtins. NanoClaw links to Signal as a *secondary device* on your
|
||||
existing phone: no new number, no bot API. Your assistant sends and receives as
|
||||
the number on the phone that scans the link.
|
||||
|
||||
Unlike Telegram or Discord, Signal has no bot API. NanoClaw registers as a full Signal account on a dedicated phone number (recommended) or links as a secondary device on your existing number.
|
||||
## Apply
|
||||
|
||||
## Prerequisites
|
||||
### 1. Install signal-cli
|
||||
|
||||
### Java
|
||||
NanoClaw talks to Signal through signal-cli, which has no bot API of its own.
|
||||
Install it if it isn't on PATH yet — Homebrew on macOS, the native release binary
|
||||
on Linux (neither needs Java). If it's already installed this is a no-op:
|
||||
|
||||
signal-cli requires Java 17+:
|
||||
|
||||
```bash
|
||||
java -version
|
||||
```nc:run effect:external
|
||||
command -v signal-cli >/dev/null 2>&1 || bash setup/install-signal-cli.sh
|
||||
```
|
||||
|
||||
If missing:
|
||||
- **macOS:** `brew install --cask temurin@17`
|
||||
- **Debian/Ubuntu:** `sudo apt-get install -y default-jre`
|
||||
- **RHEL/Fedora:** `sudo dnf install -y java-17-openjdk`
|
||||
### 2. Copy the adapter and its registration test
|
||||
|
||||
Java 17–25 all work.
|
||||
Fetch the `channels` branch and copy the Signal adapter and its registration test
|
||||
into `src/channels/` (overwrite — the branch is canonical):
|
||||
|
||||
### signal-cli
|
||||
|
||||
- **macOS:** `brew install signal-cli`
|
||||
- **Linux:** download the native binary from [GitHub releases](https://github.com/AsamK/signal-cli/releases):
|
||||
|
||||
```bash
|
||||
SIGNAL_CLI_VERSION=$(curl -fsSL https://api.github.com/repos/AsamK/signal-cli/releases/latest | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'][1:])")
|
||||
curl -fsSL "https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VERSION}/signal-cli-${SIGNAL_CLI_VERSION}-Linux-native.tar.gz" \
|
||||
| tar -xz -C ~/.local
|
||||
ln -sf ~/.local/signal-cli ~/.local/bin/signal-cli
|
||||
signal-cli --version
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/signal.ts
|
||||
src/channels/signal-registration.test.ts
|
||||
```
|
||||
|
||||
> The Linux native tarball extracts a single binary directly to `~/.local/signal-cli` (not into a subdirectory). The symlink above puts it on PATH.
|
||||
### 3. Register the adapter
|
||||
|
||||
## Registration
|
||||
Append the self-registration import to the channel barrel (skipped if the line is
|
||||
already present). This one line is the skill's only reach-in into core:
|
||||
|
||||
Two paths. The new-number path is recommended and battle-tested.
|
||||
```nc:append to:src/channels/index.ts
|
||||
import './signal.js';
|
||||
```
|
||||
|
||||
### Path A: Register a new number (recommended)
|
||||
### 4. Install the QR-rendering dependency
|
||||
|
||||
Use a dedicated SIM or VoIP number. NanoClaw owns it entirely.
|
||||
The device-link step renders the linking URL as a terminal QR via `qrcode`.
|
||||
Pinned to exact versions — the supply-chain policy rejects ranges and `latest`:
|
||||
|
||||
```nc:dep
|
||||
qrcode@1.5.4
|
||||
@types/qrcode@1.5.6
|
||||
```
|
||||
|
||||
The adapter itself consumes only Node.js builtins, so there is no adapter package
|
||||
to install — `qrcode` is purely for rendering the link during setup.
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
Build first: it guards the adapter's typed core-API consumption. Then run the one
|
||||
integration test.
|
||||
|
||||
```nc:run effect:build
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/signal-registration.test.ts
|
||||
```
|
||||
|
||||
`signal-registration.test.ts` imports the real channel barrel and asserts the
|
||||
registry contains `signal`. It goes red if the `import './signal.js';` line is
|
||||
deleted or drifts, or if the barrel fails to evaluate — so the channel genuinely
|
||||
would not register. The adapter has no npm dependency to guard; its typed
|
||||
core-API consumption is covered by the build. End-to-end delivery against a real
|
||||
Signal account is verified manually once the service runs.
|
||||
|
||||
## Link your Signal account
|
||||
|
||||
This is the whole credential step. signal-cli opens a device-link handshake,
|
||||
prints a `sgnl://linkdevice…` URL, and renders it as a scannable QR. You scan it
|
||||
once from the phone that already runs Signal; that phone's number becomes the
|
||||
account NanoClaw sends and receives as — no number is registered.
|
||||
|
||||
The device-link runs signal-cli, so it must be reachable first — on `PATH`, or at
|
||||
`$SIGNAL_CLI_PATH`. If step 1's install didn't land, the link step has nothing to
|
||||
drive; confirm it's present before linking (re-run step 1 if this fails):
|
||||
|
||||
```nc:run effect:check
|
||||
command -v signal-cli >/dev/null 2>&1 || [ -x "$SIGNAL_CLI_PATH" ]
|
||||
```
|
||||
|
||||
Tell the user:
|
||||
|
||||
```nc:operator
|
||||
Link NanoClaw to your Signal account:
|
||||
1. On the phone that runs Signal, open Signal → Settings → Linked Devices → Link New Device.
|
||||
2. Scan the QR code shown below — or open the `sgnl://linkdevice…` link printed under it on that phone.
|
||||
3. Wait for confirmation. The linking URL expires after ~3 minutes; re-run this step for a fresh one.
|
||||
```
|
||||
|
||||
Run the device-link. It blocks until you scan, then reports the linked phone
|
||||
number back as the account — that number is both your owner handle and the
|
||||
conversation address the wiring step needs:
|
||||
|
||||
```nc:run effect:step capture:platform_id=ACCOUNT,owner_handle=ACCOUNT
|
||||
pnpm exec tsx setup/index.ts --step signal-auth
|
||||
```
|
||||
|
||||
`owner_handle` and `platform_id` both come back as the bare phone number (e.g.
|
||||
`+15551234567`). Your assistant reaches you through Signal's Note to Self, so the
|
||||
owner conversation is addressed by your own number — not a per-contact UUID.
|
||||
|
||||
## Persist the account
|
||||
|
||||
Store the linked number so the adapter binds the right account on start, then sync
|
||||
it into the container env:
|
||||
|
||||
```nc:env-set
|
||||
SIGNAL_ACCOUNT={{platform_id}}
|
||||
```
|
||||
## Restart
|
||||
|
||||
Restart the service so it loads the Signal adapter and binds the account you just
|
||||
linked, and wait for its CLI socket before wiring:
|
||||
|
||||
```nc:run effect:restart
|
||||
bash setup/lib/restart.sh
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now. Otherwise wire
|
||||
this channel with `/init-first-agent` (or `/manage-channels`).
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `signal`
|
||||
- **terminology**: Signal has "chats" (1:1 DMs) and "groups." The owner reaches their own assistant through Note to Self.
|
||||
- **platform-id-format**:
|
||||
- Owner DM (Note to Self): the bare phone number `+<number>` (e.g. `+15551234567`) — your own messages route back as inbound with `isFromMe`, addressed by your number.
|
||||
- Third-party DM: `signal:{UUID}` — the sender's Signal ACI, **not** their phone number.
|
||||
- Group: `signal:{base64GroupId}` — base64-encoded GroupV2 ID.
|
||||
- **how-to-find-id**: The owner number comes back from the device-link step above. For third parties or groups, send a message to the bot, then query `messaging_groups`.
|
||||
- **supports-threads**: no
|
||||
- **typical-use**: Personal assistant via Signal DMs or small group chats
|
||||
- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode.
|
||||
|
||||
### Features
|
||||
|
||||
- Markdown formatting — `**bold**`, `*italic*` / `_italic_`, `` `code` ``, ` ```code fence``` `, `~~strike~~`, `||spoiler||` (converted to Signal's offset-based text styles).
|
||||
- Quoted replies — `replyTo*` fields populated from Signal quotes.
|
||||
- Typing indicators — DMs only (Signal doesn't support group typing).
|
||||
- Note to Self — messages you send to your own account from another device route to the agent as inbound with `isFromMe: true`.
|
||||
- Voice attachments — detected but not transcribed by default; the agent receives a `[Voice Message]` placeholder. Run `/add-voice-transcription` for local transcription.
|
||||
|
||||
Not supported yet: outbound file attachments (logged and dropped), edit/delete messages, reactions.
|
||||
|
||||
## Alternatives
|
||||
|
||||
### Register a dedicated number instead of linking
|
||||
|
||||
The device-link above joins Signal as a *secondary device* on an existing number.
|
||||
If you'd rather give the assistant its own number, register a dedicated SIM or
|
||||
VoIP number that NanoClaw owns entirely. This path takes a captcha, an SMS (or
|
||||
voice) verification, and an optional profile name.
|
||||
|
||||
> **VoIP numbers:** Signal requires SMS verification before voice. Some VoIP providers are blocked even for voice calls. If registration fails with an auth error, try a different provider or a physical SIM.
|
||||
|
||||
@@ -107,69 +222,12 @@ signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName"
|
||||
systemctl --user start $(systemd_unit)
|
||||
```
|
||||
|
||||
### Path B: Link as secondary device
|
||||
Once registered, set `SIGNAL_ACCOUNT` to this number (as under **Persist the account** above) and restart the service.
|
||||
|
||||
Joins an existing Signal account as a secondary device. Simpler, but NanoClaw shares your personal number.
|
||||
## Optional configuration
|
||||
|
||||
```bash
|
||||
signal-cli -a +1YOURNUMBER link --name "NanoClaw"
|
||||
```
|
||||
|
||||
This prints a `tsdevice:` URI. Scan it as a QR code on your phone: **Settings → Linked Devices → Link New Device**. QR codes expire in ~30 seconds — re-run if it expires.
|
||||
|
||||
## Install
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
- `src/channels/signal.ts` exists
|
||||
- `src/channels/signal.test.ts` exists
|
||||
- `src/channels/signal-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './signal.js';`
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
### 1. Fetch the channels branch
|
||||
|
||||
```bash
|
||||
git fetch origin channels
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and tests
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/signal.ts > src/channels/signal.ts
|
||||
git show origin/channels:src/channels/signal.test.ts > src/channels/signal.test.ts
|
||||
git show origin/channels:src/channels/signal-registration.test.ts > src/channels/signal-registration.test.ts
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
import './signal.js';
|
||||
```
|
||||
|
||||
### 4. Build and validate
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm exec vitest run src/channels/signal-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `signal-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `signal`. It goes red if the `import './signal.js';` line is deleted or drifts, or if the barrel fails to evaluate (so the channel genuinely would not register). The adapter consumes only Node.js builtins, so there is no npm dependency to guard for this channel. The adapter's typed core-API consumption is guarded by `pnpm run build`.
|
||||
|
||||
## Credentials
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
SIGNAL_ACCOUNT=+1YOURNUMBER
|
||||
```
|
||||
|
||||
### Optional settings
|
||||
These `.env` keys tune how NanoClaw talks to the signal-cli daemon. All are
|
||||
optional — the defaults work for the device-link flow above.
|
||||
|
||||
```bash
|
||||
# TCP daemon host and port (default: 127.0.0.1:7583)
|
||||
@@ -189,94 +247,6 @@ SIGNAL_DATA_DIR=~/.local/share/signal-cli
|
||||
|
||||
**Security note:** keep the TCP host on `127.0.0.1`. The daemon has no auth — binding it to a public interface would expose your full Signal account to the network.
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
### Restart
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
|
||||
|
||||
# Linux
|
||||
systemctl --user restart $(systemd_unit)
|
||||
```
|
||||
|
||||
## Wiring
|
||||
|
||||
### DMs
|
||||
|
||||
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
|
||||
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"
|
||||
```
|
||||
|
||||
Pass the `id` to `/init-first-agent` or `/manage-channels` to wire it to an agent group.
|
||||
|
||||
### Groups
|
||||
|
||||
Add the Signal number to a group from your phone, send any message, then wire the resulting row the same way. For isolated per-group sessions:
|
||||
|
||||
```bash
|
||||
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
|
||||
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
|
||||
('mga-'||hex(randomblob(8)), 'mg-GROUPID', 'ag-AGENTID', 'isolated', 0, '$NOW');
|
||||
"
|
||||
```
|
||||
|
||||
### Grant user access
|
||||
|
||||
New Signal users (including the owner's Signal identity) are silently dropped with `not_member` until granted access. After the user's first message appears in `messaging_groups`:
|
||||
|
||||
```bash
|
||||
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
|
||||
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)
|
||||
VALUES ('signal:UUID', 'ag-AGENTID', 'system', '$NOW');
|
||||
"
|
||||
```
|
||||
|
||||
Find the UUID from `messaging_groups.platform_id` or the `users` table.
|
||||
|
||||
## 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 Signal DM, or `/manage-channels` to wire this channel to an existing agent group.
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `signal`
|
||||
- **terminology**: Signal has "chats" (1:1 DMs) and "groups"
|
||||
- **supports-threads**: no
|
||||
- **platform-id-format**:
|
||||
- DM: `signal:{UUID}` — sender's Signal UUID (ACI), **not** their phone number
|
||||
- Group: `signal:{base64GroupId}` — base64-encoded GroupV2 ID
|
||||
- **how-to-find-id**: Send a message to the bot, then query `messaging_groups` as shown above
|
||||
- **typical-use**: Personal assistant via Signal DMs or small group chats
|
||||
- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode
|
||||
|
||||
### Features
|
||||
|
||||
- Markdown formatting — `**bold**`, `*italic*` / `_italic_`, `` `code` ``, ` ```code fence``` `, `~~strike~~`, `||spoiler||` (converted to Signal's offset-based text styles)
|
||||
- Quoted replies — `replyTo*` fields populated from Signal quotes
|
||||
- Typing indicators — DMs only (Signal doesn't support group typing)
|
||||
- Echo suppression — outbound messages matched on `(platformId, text)` within a 10 s TTL to avoid syncMessage loops
|
||||
- Note to Self — messages you send to your own account from another device route to the agent as inbound with `isFromMe: true`
|
||||
- Voice attachments — detected but not transcribed by default; the agent receives `[Voice Message]` placeholder text. Run `/add-voice-transcription` for local transcription via parakeet-mlx
|
||||
|
||||
Not supported yet: outbound file attachments (logged and dropped), edit/delete messages, reactions.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Daemon not reachable
|
||||
@@ -287,9 +257,9 @@ grep "Signal" logs/nanoclaw.log | tail
|
||||
|
||||
If you see `Signal daemon failed to start. Is signal-cli installed and your account linked?`:
|
||||
- Confirm `signal-cli` is on PATH (or set `SIGNAL_CLI_PATH`)
|
||||
- Confirm the account is linked: `signal-cli -a +1YOURNUMBER listIdentities` should succeed without prompting
|
||||
- Confirm the account is linked: `signal-cli -a +YOURNUMBER listIdentities` should succeed without prompting
|
||||
|
||||
If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DAEMON=false`, start the daemon yourself: `signal-cli -a +1YOURNUMBER daemon --tcp 127.0.0.1:7583`.
|
||||
If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DAEMON=false`, start the daemon yourself: `signal-cli -a +YOURNUMBER daemon --tcp 127.0.0.1:7583`.
|
||||
|
||||
### Bot not responding
|
||||
|
||||
@@ -308,7 +278,7 @@ If you see `Signal channel lost TCP connection to signal-cli daemon` in the logs
|
||||
|
||||
### Messages dropped with `not_member`
|
||||
|
||||
The Signal user hasn't been granted membership. See "Grant user access" above. This affects every new Signal user, including the owner's Signal identity — which is a separate user record from their identity on other channels even if it's the same person.
|
||||
The Signal user hasn't been granted membership. New Signal senders — including the owner's Signal identity — are gated until granted access. `/init-first-agent` grants the owner automatically; for other users, wire access with `/manage-channels` after their first message appears in `messaging_groups`. This affects every new Signal user, since their Signal identity is a separate user record from their identity on other channels even if it's the same person.
|
||||
|
||||
### Captcha required
|
||||
|
||||
@@ -326,10 +296,6 @@ signal-cli holds an exclusive lock on its data directory while the daemon is run
|
||||
|
||||
Modern Signal groups use GroupV2. The adapter must extract the group ID from `envelope?.dataMessage?.groupV2?.id` — not `groupInfo?.groupId`, which is GroupV1/legacy. If group messages are routing as DMs, check `src/channels/signal.ts` and confirm the groupId extraction falls through to `groupV2.id`.
|
||||
|
||||
### Java not found
|
||||
### QR / linking URL expired
|
||||
|
||||
Install Java 17+ — see the Prerequisites section above.
|
||||
|
||||
### QR code expired (Path B)
|
||||
|
||||
QR codes expire in ~30 seconds. Re-run the link command to generate a new one.
|
||||
The `sgnl://linkdevice…` URL (and the Path A registration captcha) expire after a few minutes. Re-run the device-link step to get a fresh QR.
|
||||
|
||||
@@ -5,114 +5,179 @@ description: Add Slack channel integration via Chat SDK.
|
||||
|
||||
# Add Slack Channel
|
||||
|
||||
Adds Slack support via the Chat SDK bridge.
|
||||
Adds Slack support via the Chat SDK bridge. NanoClaw doesn't ship channels in
|
||||
trunk — this skill copies the Slack adapter in from the `channels` branch.
|
||||
|
||||
## Install
|
||||
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
|
||||
reads the prose and applies them, and a parser can apply them deterministically
|
||||
from the same document. Every directive is idempotent, so the whole skill is
|
||||
safe to re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Slack adapter in from the `channels` branch.
|
||||
## Apply
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
### 1. Copy the adapter and its registration test
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
Fetch the `channels` branch and copy the Slack adapter and its registration test
|
||||
into `src/channels/` (overwrite — the branch is canonical):
|
||||
|
||||
- `src/channels/slack.ts` exists
|
||||
- `src/channels/slack-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './slack.js';`
|
||||
- `@chat-adapter/slack` 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
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/slack.ts
|
||||
src/channels/slack-registration.test.ts
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Register the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/slack.ts > src/channels/slack.ts
|
||||
git show origin/channels:src/channels/slack-registration.test.ts > src/channels/slack-registration.test.ts
|
||||
```
|
||||
Append the self-registration import to the channel barrel (skipped if the line
|
||||
is already present). This one line is the skill's only reach-in into core:
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
```nc:append to:src/channels/index.ts
|
||||
import './slack.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
### 3. Install the adapter package
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/slack@4.29.0
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
|
||||
|
||||
```nc:dep
|
||||
@chat-adapter/slack@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
### 4. Build and validate
|
||||
|
||||
```bash
|
||||
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
|
||||
the dependency is installed. Then run the one integration test.
|
||||
|
||||
```nc:run effect:build
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/slack-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `slack-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `slack`. It goes red if the `import './slack.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/slack` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
|
||||
End-to-end message delivery against a real Slack workspace is verified manually once the service is running — see Next Steps and the webhook setup above.
|
||||
`slack-registration.test.ts` imports the real channel barrel and asserts the
|
||||
registry contains `slack`. It goes red if the import line is deleted or drifts,
|
||||
if the barrel fails to evaluate, or if `@chat-adapter/slack` isn't installed (the
|
||||
import throws) — so it also covers the dependency from step 3. End-to-end
|
||||
delivery against a real workspace is verified manually once the service runs.
|
||||
|
||||
## Credentials
|
||||
|
||||
### Create Slack App
|
||||
Slack can deliver events two ways. Socket Mode holds an outbound WebSocket open
|
||||
— no public URL, so it works on a laptop or behind NAT — and is the right
|
||||
default. Webhook delivery needs a public HTTPS Request URL but avoids the
|
||||
long-lived socket. The adapter picks Socket Mode automatically whenever
|
||||
`SLACK_APP_TOKEN` is set; otherwise it serves the webhook.
|
||||
|
||||
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`, `im:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`, `files:read`, `files:write`
|
||||
4. Click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`)
|
||||
5. Go to **Basic Information** and copy the **Signing Secret**
|
||||
|
||||
### Enable DMs
|
||||
|
||||
6. Go to **App Home** and enable the **Messages Tab**
|
||||
7. Check **"Allow users to send Slash commands and messages from the messages tab"**
|
||||
|
||||
### Event Subscriptions
|
||||
|
||||
8. Go to **Event Subscriptions** and toggle **Enable Events**
|
||||
9. Set the **Request URL** to `https://your-domain/webhook/slack` — Slack will send a verification challenge; it must pass before you can save
|
||||
10. Under **Subscribe to bot events**, add:
|
||||
- `message.channels`, `message.groups`, `message.im`, `app_mention`
|
||||
11. Click **Save Changes**
|
||||
|
||||
### 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
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
SLACK_BOT_TOKEN=xoxb-your-bot-token
|
||||
SLACK_SIGNING_SECRET=your-signing-secret
|
||||
```nc:prompt connection validate:^(socket|webhook)$
|
||||
How should Slack deliver events? `socket` (Socket Mode — no public URL, recommended for local or behind-NAT installs) or `webhook` (needs a public HTTPS Request URL).
|
||||
```
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
Walk the operator through creating the Slack app, then collect the secrets it
|
||||
hands back. The adapter is already installed and registered — it just can't
|
||||
receive a message until this is done. For Socket Mode, tell the user:
|
||||
|
||||
### Webhook server
|
||||
```nc:operator when:connection=socket
|
||||
Create the Slack app (Socket Mode):
|
||||
1. Go to api.slack.com/apps → Create New App → From scratch. Name it (e.g. "NanoClaw") and pick your workspace.
|
||||
2. OAuth & Permissions → add these Bot Token Scopes: chat:write, im:write, channels:history, groups:history, im:history, channels:read, groups:read, users:read, reactions:write, files:read, files:write.
|
||||
3. App Home → enable the Messages Tab, and check "Allow users to send Slash commands and messages from the messages tab."
|
||||
4. Basic Information → App-Level Tokens → "Generate Token and Scopes" → add the connections:write scope → copy the token (starts with xapp-).
|
||||
5. Socket Mode → toggle "Enable Socket Mode" on.
|
||||
6. Install to Workspace, then copy the Bot User OAuth Token (starts with xoxb-).
|
||||
```
|
||||
|
||||
The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/webhook/slack` for Slack and other webhook-based adapters. This port must be publicly reachable from the internet for Slack to deliver events.
|
||||
For webhook delivery, tell the user:
|
||||
|
||||
If running locally, discuss options for exposing the server — e.g. ngrok (`ngrok http 3000`), Cloudflare Tunnel, or a reverse proxy on a VPS. The resulting public URL becomes the base for `https://your-domain/webhook/slack`.
|
||||
```nc:operator when:connection=webhook
|
||||
Create the Slack app (webhook delivery):
|
||||
1. Go to api.slack.com/apps → Create New App → From scratch. Name it (e.g. "NanoClaw") and pick your workspace.
|
||||
2. OAuth & Permissions → add these Bot Token Scopes: chat:write, im:write, channels:history, groups:history, im:history, channels:read, groups:read, users:read, reactions:write, files:read, files:write.
|
||||
3. App Home → enable the Messages Tab, and check "Allow users to send Slash commands and messages from the messages tab."
|
||||
4. Install to Workspace, then copy the Bot User OAuth Token (starts with xoxb-).
|
||||
5. Basic Information → copy the Signing Secret.
|
||||
```
|
||||
|
||||
Collect the secrets and store them (the bridge reads them from `.env`; the
|
||||
app-level token doubles as the Socket Mode switch, the signing secret
|
||||
authenticates webhook requests — each mode needs only its own):
|
||||
|
||||
```nc:prompt bot_token secret validate:^xoxb-
|
||||
Paste the Bot User OAuth Token — OAuth & Permissions, starts with `xoxb-`.
|
||||
```
|
||||
```nc:prompt app_token secret validate:^xapp- reuse:SLACK_APP_TOKEN when:connection=socket
|
||||
Paste the App-Level Token — Basic Information → App-Level Tokens, starts with `xapp-`.
|
||||
```
|
||||
```nc:prompt signing_secret secret validate:^[a-fA-F0-9]{16,}$ when:connection=webhook
|
||||
Paste the Signing Secret — Basic Information.
|
||||
```
|
||||
```nc:env-set
|
||||
SLACK_BOT_TOKEN={{bot_token}}
|
||||
```
|
||||
```nc:env-set when:connection=socket
|
||||
SLACK_APP_TOKEN={{app_token}}
|
||||
```
|
||||
```nc:env-set when:connection=webhook
|
||||
SLACK_SIGNING_SECRET={{signing_secret}}
|
||||
```
|
||||
|
||||
With webhook delivery, the bridge serves port 3000 at `/webhook/slack`
|
||||
automatically; to receive replies, that port must be reachable from the internet
|
||||
and registered with Slack (Socket Mode skips all of this — events arrive over
|
||||
the socket as soon as the service restarts). Tell the user:
|
||||
|
||||
```nc:operator when:connection=webhook
|
||||
Set up event delivery (needs a public HTTPS URL for port 3000 — ngrok, a Cloudflare Tunnel, or a reverse proxy on a VPS):
|
||||
1. Event Subscriptions → Enable Events. Set the Request URL to https://<your-public-host>/webhook/slack and wait for the challenge to pass.
|
||||
2. Subscribe to bot events: message.channels, message.groups, message.im, app_mention. Save Changes.
|
||||
3. Interactivity & Shortcuts → toggle Interactivity on, set the same Request URL, Save Changes, then reinstall the app when Slack prompts.
|
||||
```
|
||||
|
||||
## Resolve your DM channel
|
||||
|
||||
The agent talks to you in your direct-message channel with the bot. Resolve its
|
||||
address so the owner-wiring step can target it. Validating the token here, before
|
||||
the restart, fast-fails a bad credential while it's still cheap to fix. You'll
|
||||
need your Slack member ID: open your profile (your avatar, bottom-left), then
|
||||
**⋮** → **Copy member ID** — it starts with `U`.
|
||||
|
||||
```nc:prompt owner_handle validate:^U[A-Z0-9]{8,}$
|
||||
Your Slack member ID (Profile → ⋮ → "Copy member ID"; starts with U).
|
||||
```
|
||||
|
||||
Confirm the bot token works and capture the bot identity — `auth.test` returns the
|
||||
bot user and workspace, and fails here if the token is bad:
|
||||
|
||||
```nc:run capture:connected_as effect:fetch
|
||||
curl -sf -X POST https://slack.com/api/auth.test -H "Authorization: Bearer {{bot_token}}" | jq -er '"@" + .user + " in " + .team'
|
||||
```
|
||||
|
||||
Open the DM with `conversations.open` and take the channel id it returns as the
|
||||
conversation address `slack:<channelId>` (if Slack returns no channel, the bot is
|
||||
missing the `im:write` scope — add it and reinstall):
|
||||
|
||||
```nc:run capture:platform_id effect:fetch
|
||||
curl -s -X POST https://slack.com/api/conversations.open -H "Authorization: Bearer {{bot_token}}" -H "Content-Type: application/json" -d '{"users":"{{owner_handle}}"}' | jq -er '"slack:" + .channel.id'
|
||||
```
|
||||
|
||||
`owner_handle` and `platform_id` are what the owner-wiring step needs. The
|
||||
greeting goes out over `chat.postMessage`, which works right away. Receiving
|
||||
replies needs the event path live: with Socket Mode that happens as soon as the
|
||||
service restarts below; with webhook delivery, finish the Event Subscriptions
|
||||
and Interactivity steps above first.
|
||||
|
||||
## Restart
|
||||
|
||||
With the credential validated, restart the service so it loads the Slack adapter
|
||||
and the secrets you just stored, and wait for its CLI socket before wiring:
|
||||
|
||||
```nc:run effect:restart
|
||||
bash setup/lib/restart.sh
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
|
||||
Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
||||
If you're in the middle of `/setup`, return to the setup flow now. Otherwise wire
|
||||
this channel with `/init-first-agent` (or `/manage-channels`).
|
||||
|
||||
## Channel Info
|
||||
|
||||
|
||||
@@ -18,14 +18,7 @@ rm -f src/channels/teams.ts src/channels/teams-registration.test.ts
|
||||
|
||||
## 2. Remove credentials
|
||||
|
||||
Remove the `TEAMS_*` lines from `.env`, then re-sync to the container:
|
||||
|
||||
```bash
|
||||
TEAMS_APP_ID
|
||||
TEAMS_APP_PASSWORD
|
||||
TEAMS_APP_TENANT_ID
|
||||
TEAMS_APP_TYPE
|
||||
```
|
||||
Remove `TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`, `TEAMS_APP_TENANT_ID`, and `TEAMS_APP_TYPE` from `.env`, then re-sync to the container:
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
|
||||
+245
-153
@@ -5,68 +5,255 @@ description: Add Microsoft Teams channel integration via Chat SDK.
|
||||
|
||||
# Add Microsoft Teams Channel
|
||||
|
||||
Connect NanoClaw to Microsoft Teams for interactive chat in team channels, group chats, and direct messages.
|
||||
Adds Microsoft Teams support via the Chat SDK bridge — interactive chat in team
|
||||
channels, group chats, and direct messages. NanoClaw doesn't ship channels in
|
||||
trunk — this skill copies the Teams adapter in from the `channels` branch.
|
||||
|
||||
## Install
|
||||
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
|
||||
reads the prose and applies them, and a parser can apply them deterministically
|
||||
from the same document. Every directive is idempotent, so the whole skill is
|
||||
safe to re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Teams adapter in from the `channels` branch.
|
||||
Teams is the most involved channel NanoClaw supports — there's no "paste a
|
||||
token" shortcut. You'll walk through about seven Azure portal steps (app
|
||||
registration, client secret, Azure Bot resource, messaging endpoint, Teams
|
||||
channel, app package, sideload). Take them one at a time; the prompts below
|
||||
collect each value as you produce it.
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
## Apply
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
### 1. Copy the adapter and its registration test
|
||||
|
||||
- `src/channels/teams.ts` exists
|
||||
- `src/channels/teams-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './teams.js';`
|
||||
- `@chat-adapter/teams` is listed in `package.json` dependencies
|
||||
Fetch the `channels` branch and copy the Teams adapter and its registration test
|
||||
into `src/channels/` (overwrite — the branch is canonical):
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
### 1. Fetch the channels branch
|
||||
|
||||
```bash
|
||||
git fetch origin channels
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/teams.ts
|
||||
src/channels/teams-registration.test.ts
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Register the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/teams.ts > src/channels/teams.ts
|
||||
git show origin/channels:src/channels/teams-registration.test.ts > src/channels/teams-registration.test.ts
|
||||
```
|
||||
Append the self-registration import to the channel barrel (skipped if the line
|
||||
is already present). This one line is the skill's only reach-in into core:
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
```nc:append to:src/channels/index.ts
|
||||
import './teams.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
### 3. Install the adapter package
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/teams@4.29.0
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
|
||||
|
||||
```nc:dep
|
||||
@chat-adapter/teams@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
### 4. Build and validate
|
||||
|
||||
```bash
|
||||
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
|
||||
the dependency is installed. Then run the one integration test.
|
||||
|
||||
```nc:run effect:build
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/teams-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `teams-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `teams`. It goes red if the `import './teams.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/teams` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
|
||||
End-to-end message delivery against a real Teams workspace is verified manually once the service is running — see Next Steps and the webhook setup above.
|
||||
`teams-registration.test.ts` imports the real channel barrel and asserts the
|
||||
registry contains `teams`. It goes red if the import line is deleted or drifts,
|
||||
if the barrel fails to evaluate, or if `@chat-adapter/teams` isn't installed (the
|
||||
import throws) — so it also covers the dependency from step 3. End-to-end
|
||||
delivery against a real Teams workspace is verified manually once the service
|
||||
runs.
|
||||
|
||||
## Credentials
|
||||
|
||||
Two paths — manual (Azure Portal) or auto (Teams CLI).
|
||||
The adapter is installed and registered, but it can't receive a message until a
|
||||
bot exists in Azure, points at this machine, and is sideloaded into Teams. None
|
||||
of those steps can be clicked through by a parser, so they're operator
|
||||
instructions — relay each one, then collect the value it produces.
|
||||
|
||||
Before you start, tell the user:
|
||||
|
||||
```nc:operator
|
||||
Confirm you have everything Teams setup needs:
|
||||
1. A Microsoft 365 tenant where you can sideload custom apps — free personal Teams does NOT support this; you need a Microsoft 365 Business / EDU / developer tenant with Teams admin or developer rights.
|
||||
2. A way to expose an HTTPS endpoint from this machine (ngrok, a Cloudflare Tunnel, or a reverse-proxied VPS). Azure Bot Service delivers activities to it.
|
||||
```
|
||||
|
||||
### Public URL
|
||||
|
||||
Azure Bot Service delivers messages to an HTTPS endpoint you control; it has to
|
||||
reach this machine's webhook server (port 3000) at `/api/webhooks/teams`. If you
|
||||
don't have a tunnel running yet, start one in another terminal first — e.g.
|
||||
`ngrok http 3000` gives you `https://abcd1234.ngrok.io`.
|
||||
|
||||
```nc:prompt public_url validate:^https:// normalize:rstrip-slash
|
||||
Paste your public base URL (https://…, no trailing path) — e.g. https://abcd1234.ngrok.io.
|
||||
```
|
||||
|
||||
### Register the Azure app
|
||||
|
||||
Tell the user:
|
||||
|
||||
```nc:operator
|
||||
Create the Azure AD app registration:
|
||||
1. In https://portal.azure.com, search "App registrations" → "New registration".
|
||||
2. Name it (e.g. "NanoClaw").
|
||||
3. Supported account types: Single tenant (your org only — most common for self-host) OR Multi tenant (any Microsoft 365 tenant can add the bot).
|
||||
4. Click Register.
|
||||
5. On the Overview page, copy the Application (client) ID and, for a single-tenant app, the Directory (tenant) ID.
|
||||
```
|
||||
|
||||
```nc:prompt app_id validate:^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$
|
||||
Paste the Application (client) ID — App registration Overview page.
|
||||
```
|
||||
```nc:prompt app_type validate:^(SingleTenant|MultiTenant)$
|
||||
Enter the app type — `SingleTenant` or `MultiTenant` (must match the account type you picked).
|
||||
```
|
||||
```nc:prompt app_tenant_id when:app_type=SingleTenant validate:^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$
|
||||
Paste the Directory (tenant) ID — App registration Overview page (Single Tenant only).
|
||||
```
|
||||
|
||||
### Create a client secret
|
||||
|
||||
Tell the user:
|
||||
|
||||
```nc:operator
|
||||
Create the client secret:
|
||||
1. In your app registration, open "Certificates & secrets".
|
||||
2. Click "New client secret" — Description "nanoclaw", Expires 180 days (recommended) or longer.
|
||||
3. Click Add.
|
||||
4. COPY THE VALUE NOW — Azure only shows it once (the Value column, not the Secret ID).
|
||||
```
|
||||
|
||||
```nc:prompt app_password secret validate:^.{20,}$
|
||||
Paste the client secret Value — Certificates & secrets (shown only once, at least 20 characters).
|
||||
```
|
||||
|
||||
### Store the credentials
|
||||
|
||||
The adapter reads these from `.env` (set-if-absent, so a value you've already
|
||||
filled in is never overwritten).
|
||||
`TEAMS_APP_TENANT_ID` is written only for a Single Tenant app; Multi Tenant
|
||||
doesn't need it.
|
||||
|
||||
```nc:env-set
|
||||
TEAMS_APP_ID={{app_id}}
|
||||
TEAMS_APP_PASSWORD={{app_password}}
|
||||
TEAMS_APP_TYPE={{app_type}}
|
||||
```
|
||||
```nc:env-set when:app_type=SingleTenant
|
||||
TEAMS_APP_TENANT_ID={{app_tenant_id}}
|
||||
```
|
||||
### Create the Azure Bot resource
|
||||
|
||||
Tell the user:
|
||||
|
||||
```nc:operator
|
||||
Create the Azure Bot resource and point it at this machine:
|
||||
1. In https://portal.azure.com, search "Azure Bot" → Create.
|
||||
2. Bot handle: a unique name, e.g. nanoclaw-bot.
|
||||
3. Type of App: {{app_type}} — Creation type: Use existing app registration.
|
||||
4. App ID: {{app_id}}.
|
||||
5. After creating, open the bot → Configuration and set Messaging endpoint to {{public_url}}/api/webhooks/teams, then Apply.
|
||||
```
|
||||
|
||||
### Enable the Teams channel
|
||||
|
||||
Tell the user (finish every Azure step above before continuing — the package
|
||||
built next bakes in the app registration, so the Azure app and bot must already
|
||||
exist):
|
||||
|
||||
```nc:operator
|
||||
Enable the Microsoft Teams channel on the bot:
|
||||
1. Open your Azure Bot resource → Channels.
|
||||
2. Click Microsoft Teams → Accept terms → Apply.
|
||||
When the Azure app, bot resource, and Teams channel are all in place, continue.
|
||||
```
|
||||
|
||||
### Build the Teams app package
|
||||
|
||||
The manifest bakes in the Application (client) ID, so the Azure app registration
|
||||
above must be done first — building with a blank `app_id` produces a package that
|
||||
no bot can claim. Confirm it's set before generating the zip:
|
||||
|
||||
```nc:run effect:check
|
||||
[ -n "{{app_id}}" ]
|
||||
```
|
||||
|
||||
Generate the zip you'll sideload into Teams (manifest + icons, written to
|
||||
`data/teams/teams-app-package.zip`). Re-running regenerates a fresh zip, so this
|
||||
is safe to repeat.
|
||||
|
||||
```nc:run effect:external
|
||||
pnpm exec tsx setup/channels/teams-manifest-build.ts --app-id "{{app_id}}" --url "{{public_url}}"
|
||||
```
|
||||
|
||||
### Sideload the app into Teams
|
||||
|
||||
Tell the user (do this before the restart below — the service should come up with
|
||||
the app already sideloaded):
|
||||
|
||||
```nc:operator
|
||||
Sideload the generated app package into Teams:
|
||||
1. Open Microsoft Teams → Apps → Manage your apps → Upload an app.
|
||||
2. Click "Upload a custom app" (or "Upload for me or my teams").
|
||||
3. Select data/teams/teams-app-package.zip and click Add.
|
||||
4. If "Upload a custom app" is missing, your tenant admin has disabled sideloading — enable it in Teams Admin Center → Teams apps → Setup policies → Global → Upload custom apps = On.
|
||||
Once the app is added in Teams, continue.
|
||||
```
|
||||
|
||||
## Restart
|
||||
|
||||
Restart the service so it loads the Teams adapter and the credentials you just
|
||||
stored:
|
||||
|
||||
```nc:run effect:restart
|
||||
bash setup/lib/restart.sh
|
||||
```
|
||||
|
||||
## Finish wiring
|
||||
|
||||
Unlike Discord or Slack, a Teams bot's platform ID isn't known until you DM the
|
||||
bot for the first time — the adapter derives it from the inbound activity. So
|
||||
this skill installs the adapter and stops here; you finish the wiring once the
|
||||
bot has seen its first message. Tell the user:
|
||||
|
||||
```nc:operator
|
||||
The Teams adapter is live and the service is running. One thing is left: your Teams bot's platform ID (which NanoClaw needs to wire it to an agent group) only becomes known after you DM the bot for the first time. To finish:
|
||||
1. Find your bot in Teams (search by name, or via the app you just sideloaded) and send it a message ("hi" is fine).
|
||||
2. Tail logs/nanoclaw.log for the inbound — the router auto-creates a row in messaging_groups in data/v2.db.
|
||||
3. Run scripts/init-first-agent.ts with --channel teams, the discovered platform_id, and your AAD user id — OR run /manage-channels to wire it interactively.
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now. Otherwise,
|
||||
once you've DM'd the bot, wire this channel with `/init-first-agent` (or
|
||||
`/manage-channels`).
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `teams`
|
||||
- **terminology**: Teams has "teams" containing "channels." The bot can also receive DMs (personal scope) and group chat messages. Channels support threaded replies.
|
||||
- **platform-id-format**: `teams:{base64url-conversation-id}:{base64url-service-url}` — auto-generated by the adapter from the first inbound activity, not human-readable. Use the auto-created messaging group for wiring.
|
||||
- **how-to-find-id**: Send a message to the bot in the channel or a DM. NanoClaw auto-creates a messaging group and logs the platform ID. Use that messaging group for wiring.
|
||||
- **supports-threads**: yes (channels only; DMs and group chats are flat)
|
||||
- **typical-use**: Team collaboration with the bot in channels; personal assistant via DMs
|
||||
- **default-isolation**: Separate agent group per team. DMs can share an agent group with your main channel for unified personal memory.
|
||||
|
||||
## Alternatives
|
||||
|
||||
### Auto: Teams CLI
|
||||
|
||||
Requires Node.js 18+, a Microsoft 365 account with sideloading permissions, and a public HTTPS endpoint (ngrok, Cloudflare Tunnel, or similar).
|
||||
The Credentials flow above walks the manual Azure Portal path. If you'd rather
|
||||
script it, the Microsoft Teams CLI creates the Entra app, client secret, and bot
|
||||
registration in one command. Requires Node.js 18+, a Microsoft 365 account with
|
||||
sideloading permissions, and a public HTTPS endpoint (ngrok, Cloudflare Tunnel,
|
||||
or similar).
|
||||
|
||||
1. Install the CLI:
|
||||
|
||||
@@ -97,38 +284,10 @@ Requires Node.js 18+, a Microsoft 365 account with sideloading permissions, and
|
||||
|
||||
4. Pick **Install in Teams** from the post-create menu and confirm in the Teams dialog.
|
||||
|
||||
Continue to [Configure environment](#configure-environment).
|
||||
### Azure CLI for the bot resource
|
||||
|
||||
---
|
||||
|
||||
The steps below describe the **manual Azure Portal path**.
|
||||
|
||||
### Step 1: Create an Azure AD App Registration
|
||||
|
||||
1. Go to [Azure Portal](https://portal.azure.com) > **App registrations** > **New registration**
|
||||
2. Name it (e.g., "NanoClaw")
|
||||
3. Supported account types: **Single tenant** (your org only) or **Multi tenant** (any org)
|
||||
4. Click **Register**
|
||||
5. Copy the **Application (client) ID** and **Directory (tenant) ID** from the Overview page
|
||||
|
||||
### Step 2: Create a Client Secret
|
||||
|
||||
1. In the App Registration, go to **Certificates & secrets**
|
||||
2. Click **New client secret**, description "nanoclaw", expiry 180 days
|
||||
3. Click **Add** and **copy the Value immediately** (shown only once)
|
||||
|
||||
### Step 3: Create an Azure Bot
|
||||
|
||||
1. Go to Azure Portal > search **Azure Bot** > **Create**
|
||||
2. Fill in:
|
||||
- **Bot handle**: unique name (e.g., "nanoclaw-bot")
|
||||
- **Type of App**: match your app registration (Single or Multi Tenant)
|
||||
- **Creation type**: **Use existing app registration**
|
||||
- **App ID**: paste from Step 1
|
||||
- **App tenant ID**: paste from Step 1 (Single Tenant only)
|
||||
3. Click **Review + create** > **Create**
|
||||
|
||||
Or use Azure CLI:
|
||||
The Azure Bot resource and Teams channel can also be created with `az` instead of
|
||||
clicking through the portal:
|
||||
|
||||
```bash
|
||||
az group create --name nanoclaw-rg --location eastus
|
||||
@@ -139,72 +298,16 @@ az bot create \
|
||||
--appid YOUR_APP_ID \
|
||||
--tenant-id YOUR_TENANT_ID \
|
||||
--endpoint "https://your-domain/api/webhooks/teams"
|
||||
```
|
||||
|
||||
### Step 4: Configure Messaging Endpoint
|
||||
|
||||
1. Go to your Azure Bot resource > **Configuration**
|
||||
2. Set **Messaging endpoint** to `https://your-domain/api/webhooks/teams`
|
||||
3. Click **Apply**
|
||||
|
||||
### Step 5: Enable Teams Channel
|
||||
|
||||
1. In the Azure Bot resource, go to **Channels**
|
||||
2. Click **Microsoft Teams** > Accept terms > **Apply**
|
||||
|
||||
Or via CLI:
|
||||
|
||||
```bash
|
||||
az bot msteams create --resource-group nanoclaw-rg --name nanoclaw-bot
|
||||
```
|
||||
|
||||
### Step 6: Create and Sideload Teams App
|
||||
## Optional configuration
|
||||
|
||||
Create a `manifest.json`:
|
||||
### Receive all channel messages (without @-mention)
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.schema.json",
|
||||
"manifestVersion": "1.16",
|
||||
"version": "1.0.0",
|
||||
"id": "YOUR_APP_ID",
|
||||
"packageName": "com.nanoclaw.bot",
|
||||
"developer": {
|
||||
"name": "NanoClaw",
|
||||
"websiteUrl": "https://your-domain",
|
||||
"privacyUrl": "https://your-domain",
|
||||
"termsOfUseUrl": "https://your-domain"
|
||||
},
|
||||
"name": { "short": "NanoClaw", "full": "NanoClaw Assistant" },
|
||||
"description": {
|
||||
"short": "NanoClaw assistant bot",
|
||||
"full": "NanoClaw personal assistant powered by Claude."
|
||||
},
|
||||
"icons": { "outline": "outline.png", "color": "color.png" },
|
||||
"accentColor": "#4A90D9",
|
||||
"bots": [{
|
||||
"botId": "YOUR_APP_ID",
|
||||
"scopes": ["personal", "team", "groupchat"],
|
||||
"supportsFiles": false,
|
||||
"isNotificationOnly": false
|
||||
}],
|
||||
"permissions": ["identity", "messageTeamMembers"],
|
||||
"validDomains": ["your-domain"]
|
||||
}
|
||||
```
|
||||
|
||||
Create two icon PNGs (32x32 `outline.png`, 192x192 `color.png`), zip all three files together.
|
||||
|
||||
**Sideload in Teams:**
|
||||
1. Open Teams > **Apps** > **Manage your apps**
|
||||
2. Click **Upload an app** > **Upload a custom app**
|
||||
3. Select the zip file
|
||||
|
||||
Sideloading requires Teams admin access. Free personal Teams does NOT support sideloading. Use a Microsoft 365 Business account or developer tenant.
|
||||
|
||||
### Step 7: Receive All Messages (Optional)
|
||||
|
||||
By default, the bot only receives messages when @-mentioned. To receive all messages in a channel without @-mention, add RSC permissions to `manifest.json`:
|
||||
By default the bot only receives messages when @-mentioned. To receive every
|
||||
message in a channel, add an RSC (resource-specific consent) permission to your
|
||||
Teams app `manifest.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -218,38 +321,27 @@ By default, the bot only receives messages when @-mentioned. To receive all mess
|
||||
}
|
||||
```
|
||||
|
||||
### Configure environment
|
||||
Re-sideload the updated app package for the change to take effect.
|
||||
|
||||
Add to `.env`:
|
||||
## Troubleshooting
|
||||
|
||||
```bash
|
||||
TEAMS_APP_ID=your-app-id
|
||||
TEAMS_APP_PASSWORD=your-client-secret
|
||||
# For Single Tenant only:
|
||||
TEAMS_APP_TENANT_ID=your-tenant-id
|
||||
TEAMS_APP_TYPE=SingleTenant
|
||||
```
|
||||
### "Upload a custom app" is missing in Teams
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
Your tenant admin has disabled sideloading. Enable it in Teams Admin Center →
|
||||
**Teams apps** → **Setup policies** → **Global** → **Upload custom apps** = On.
|
||||
Free personal Teams does not support sideloading at all — use a Microsoft 365
|
||||
Business / EDU / developer tenant.
|
||||
|
||||
### Webhook server
|
||||
### Bot never receives messages
|
||||
|
||||
The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/api/webhooks/teams` for Teams and other webhook-based adapters. This port must be publicly reachable from the internet for Azure Bot Service to deliver activities.
|
||||
1. The tunnel is up and the messaging endpoint matches it: Azure Bot → **Configuration** → **Messaging endpoint** must be `https://<your-domain>/api/webhooks/teams`, and your tunnel (e.g. `ngrok http 3000`) must be forwarding to this machine's port 3000.
|
||||
2. The adapter started: `grep -i teams logs/nanoclaw.log | tail`.
|
||||
3. The credentials are in `.env` (`TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`, `TEAMS_APP_TYPE`).
|
||||
|
||||
For local development without a public URL, use a tunnel (e.g., `ngrok http 3000`) and update the messaging endpoint in Azure Bot Configuration.
|
||||
### `Unauthorized` / 401 from Azure Bot Service
|
||||
|
||||
## Next Steps
|
||||
The client secret is wrong or expired, or — for a Single Tenant app — `TEAMS_APP_TENANT_ID` is missing. Regenerate the secret in **Certificates & secrets** (copy the Value, not the Secret ID), update `.env`, and restart.
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
### Replies land in the wrong place
|
||||
|
||||
Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `teams`
|
||||
- **terminology**: Teams has "teams" containing "channels." The bot can also receive DMs (personal scope) and group chat messages. Channels support threaded replies.
|
||||
- **platform-id-format**: `teams:{base64-encoded-conversation-id}:{base64-encoded-service-url}` — auto-generated by the adapter, not human-readable. Use the auto-created messaging group ID for wiring.
|
||||
- **how-to-find-id**: Send a message to the bot in the channel. NanoClaw auto-creates a messaging group and logs the platform ID. Use that messaging group ID for wiring.
|
||||
- **supports-threads**: yes (channels only; DMs and group chats are flat)
|
||||
- **typical-use**: Team collaboration with the bot in channels; personal assistant via DMs
|
||||
- **default-isolation**: Separate agent group per team. DMs can share an agent group with your main channel for unified personal memory.
|
||||
A Teams bot's platform ID is derived from the first inbound activity, so wire the messaging group that the router auto-creates after you DM the bot — don't guess the platform ID. See **Finish wiring** above.
|
||||
|
||||
@@ -5,111 +5,156 @@ description: Add Telegram channel integration via Chat SDK.
|
||||
|
||||
# Add Telegram Channel
|
||||
|
||||
Adds Telegram bot support via the Chat SDK bridge.
|
||||
Adds Telegram bot support via the Chat SDK bridge. NanoClaw doesn't ship
|
||||
channels in trunk — this skill copies the Telegram adapter, its
|
||||
formatting/pairing helpers, and their tests in from the `channels` branch. The
|
||||
`pair-telegram` setup step is maintained in trunk, so it is not copied here.
|
||||
|
||||
## Install
|
||||
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
|
||||
reads the prose and applies them, and a parser can apply them deterministically
|
||||
from the same document. Every directive is idempotent, so the whole skill is
|
||||
safe to re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Telegram adapter, its formatting/pairing helpers, their tests, and the `pair-telegram` setup step in from the `channels` branch.
|
||||
## Apply
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
### 1. Copy the adapter, helpers, and tests
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
Fetch the `channels` branch and copy the Telegram adapter, its pairing and
|
||||
markdown-sanitize helpers (with their tests), and the registration test into
|
||||
place (overwrite — the branch is canonical):
|
||||
|
||||
- `src/channels/telegram.ts`, `telegram-pairing.ts`, `telegram-markdown-sanitize.ts` (and their `.test.ts` siblings) all exist
|
||||
- `src/channels/telegram-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './telegram.js';`
|
||||
- `setup/pair-telegram.ts` exists and `setup/index.ts`'s `STEPS` map contains `'pair-telegram':`
|
||||
- `@chat-adapter/telegram` 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
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/telegram.ts
|
||||
src/channels/telegram-pairing.ts
|
||||
src/channels/telegram-pairing.test.ts
|
||||
src/channels/telegram-markdown-sanitize.ts
|
||||
src/channels/telegram-markdown-sanitize.test.ts
|
||||
src/channels/telegram-registration.test.ts
|
||||
```
|
||||
|
||||
### 2. Copy the adapter, helpers, tests, registration test, and setup step
|
||||
### 2. Register the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/telegram.ts > src/channels/telegram.ts
|
||||
git show origin/channels:src/channels/telegram-registration.test.ts > src/channels/telegram-registration.test.ts
|
||||
git show origin/channels:src/channels/telegram-pairing.ts > src/channels/telegram-pairing.ts
|
||||
git show origin/channels:src/channels/telegram-pairing.test.ts > src/channels/telegram-pairing.test.ts
|
||||
git show origin/channels:src/channels/telegram-markdown-sanitize.ts > src/channels/telegram-markdown-sanitize.ts
|
||||
git show origin/channels:src/channels/telegram-markdown-sanitize.test.ts > src/channels/telegram-markdown-sanitize.test.ts
|
||||
git show origin/channels:setup/pair-telegram.ts > setup/pair-telegram.ts
|
||||
```
|
||||
Append the self-registration import to the channel barrel (skipped if the line
|
||||
is already present). This one line is the skill's only reach-in into core:
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if already present):
|
||||
|
||||
```typescript
|
||||
```nc:append to:src/channels/index.ts
|
||||
import './telegram.js';
|
||||
```
|
||||
|
||||
### 4. Register the setup step
|
||||
### 3. Register the pairing setup step
|
||||
|
||||
In `setup/index.ts`, add this entry to the `STEPS` map (right after the `register` line is fine; skip if already present):
|
||||
Add the `pair-telegram` loader to the `STEPS` map in `setup/index.ts`, inside the
|
||||
dormant marker region (skipped if already present — `pair-telegram` ships in core,
|
||||
so this idempotent-skips on a normal install, but is expressed for a
|
||||
clean-upstream rebuild). The pairing handshake below spawns this step:
|
||||
|
||||
```typescript
|
||||
```nc:append to:setup/index.ts at:nanoclaw:setup-steps
|
||||
'pair-telegram': () => import('./pair-telegram.js'),
|
||||
```
|
||||
|
||||
### 5. Install the adapter package (pinned)
|
||||
### 4. Install the adapter package
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/telegram@4.29.0
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
|
||||
|
||||
```nc:dep
|
||||
@chat-adapter/telegram@4.29.0
|
||||
```
|
||||
|
||||
### 6. Build and validate
|
||||
### 5. Build and validate
|
||||
|
||||
```bash
|
||||
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
|
||||
the dependency is installed. Then run the one integration test.
|
||||
|
||||
```nc:run effect:build
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/telegram-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `telegram-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `telegram`. It goes red if the `import './telegram.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/telegram` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 5. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
|
||||
End-to-end message delivery against a real Telegram bot is verified manually once the service is running — see Next Steps and the pairing flow in Channel Info.
|
||||
`telegram-registration.test.ts` imports the real channel barrel and asserts the
|
||||
registry contains `telegram`. It goes red if the import line is deleted or drifts,
|
||||
if the barrel fails to evaluate, or if `@chat-adapter/telegram` isn't installed
|
||||
(the import throws) — so it also covers the dependency from step 4. End-to-end
|
||||
delivery against a real bot is verified manually once the service runs.
|
||||
|
||||
## Credentials
|
||||
|
||||
### Create Telegram Bot
|
||||
Bot creation in Telegram is human and interactive — no parser can click through
|
||||
BotFather. The adapter is installed and registered, but it can't receive a
|
||||
message until the bot exists. Tell the user:
|
||||
|
||||
1. Open Telegram and search for `@BotFather`
|
||||
2. Send `/newbot` and follow the prompts:
|
||||
- Bot name: Something friendly (e.g., "NanoClaw Assistant")
|
||||
- Bot username: Must end with "bot" (e.g., "nanoclaw_bot")
|
||||
3. Copy the bot token (looks like `123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`)
|
||||
|
||||
**Important for group chats**: By default, Telegram bots only see @mentions and commands in groups. To let the bot see all messages:
|
||||
|
||||
1. Open `@BotFather` > `/mybots` > select your bot
|
||||
2. **Bot Settings** > **Group Privacy** > **Turn off**
|
||||
|
||||
### Configure environment
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
TELEGRAM_BOT_TOKEN=your-bot-token
|
||||
```nc:operator
|
||||
Create the Telegram bot:
|
||||
1. Open Telegram and message @BotFather — Telegram's official bot for creating bots.
|
||||
2. Send /newbot and follow the prompts: a friendly name, then a username that must end in "bot".
|
||||
3. Copy the bot token it gives you (looks like 123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11).
|
||||
4. Planning to use the bot in group chats? Send /mybots → your bot → Bot Settings → Group Privacy → Turn off, so the bot can see all messages and not just @mentions.
|
||||
```
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
Collect the bot token and store it — the bridge reads it from `.env` (set-if-absent,
|
||||
so a value you've already filled in is never overwritten) and syncs it to the
|
||||
container:
|
||||
|
||||
```nc:prompt bot_token secret validate:^[0-9]+:[A-Za-z0-9_-]{35,}$
|
||||
Paste the bot token from BotFather (looks like `123456:ABC-DEF...`).
|
||||
```
|
||||
```nc:env-set
|
||||
TELEGRAM_BOT_TOKEN={{bot_token}}
|
||||
```
|
||||
Confirm the token works and capture the bot's handle — `getMe` returns the bot
|
||||
account and fails here if the token is bad. You'll use the handle to open the
|
||||
right chat just before pairing:
|
||||
|
||||
```nc:run capture:bot_username effect:fetch
|
||||
curl -sf https://api.telegram.org/bot{{bot_token}}/getMe | jq -er '.result.username'
|
||||
```
|
||||
|
||||
## Restart
|
||||
|
||||
Restart the service so it loads the Telegram adapter and the token you just
|
||||
stored, and wait for its CLI socket. The adapter must be live and polling before
|
||||
pairing — it's the thing that observes the code you send:
|
||||
|
||||
```nc:run effect:restart
|
||||
bash setup/lib/restart.sh
|
||||
```
|
||||
|
||||
## Pair your chat
|
||||
|
||||
Telegram tokens carry no user binding, so the agent proves you own the chat with
|
||||
a one-time pairing handshake: it issues a 4-digit code, you send those exact 4
|
||||
digits to the bot from the chat you want to register, and the live adapter
|
||||
matches them. Open the bot first so you're on the right screen when the code
|
||||
appears. Tell the user:
|
||||
|
||||
```nc:operator
|
||||
Open @{{bot_username}} (https://t.me/{{bot_username}}) in Telegram now and keep it on screen — a 4-digit pairing code is about to appear in this terminal. When it does, send just those 4 digits to the bot as a message (in a group chat with Group Privacy on, prefix them with @{{bot_username}}). A wrong guess is rejected and a fresh code is issued automatically.
|
||||
```
|
||||
|
||||
Run the pairing handshake. It prints the code, streams "waiting…" and wrong-code
|
||||
feedback while it watches for your message, and resolves your chat address
|
||||
`telegram:<chatId>` plus your Telegram user id once the code matches:
|
||||
|
||||
```nc:run effect:step capture:platform_id=PLATFORM_ID,owner_handle=ADMIN_USER_ID
|
||||
pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main
|
||||
```
|
||||
|
||||
`owner_handle` (your Telegram user id) and `platform_id` (`telegram:<chatId>`)
|
||||
are what the owner-wiring step needs. The greeting goes out over the same chat as
|
||||
soon as pairing completes.
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
|
||||
Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
||||
If you're in the middle of `/setup`, return to the setup flow now. Otherwise wire
|
||||
this channel with `/init-first-agent` (or `/manage-channels`).
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `telegram`
|
||||
- **terminology**: Telegram calls them "groups" and "chats." A "group" has multiple members; a "chat" is a 1:1 conversation with the bot.
|
||||
- **how-to-find-id**: Do NOT ask the user for a chat ID. Telegram registration uses pairing — run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent <main|wire-to:folder|new-agent:folder>`, show the user the 4-digit `CODE` from the `PAIR_TELEGRAM_ISSUED` block (follow the `REMINDER_TO_ASSISTANT` line in that block), and tell them to send just the 4 digits as a message from the chat they want to register (DM the bot for `main`, post in the group otherwise). In groups with Group Privacy ON, prefix with the bot handle: `@<botname> CODE`. Wrong guesses invalidate the code — if a `PAIR_TELEGRAM_ATTEMPT` block arrives with a mismatched `RECEIVED_CODE`, a `PAIR_TELEGRAM_NEW_CODE` block will follow automatically (up to 5 regenerations); show the new code. On `PAIR_TELEGRAM STATUS=failed ERROR=max-regenerations-exceeded`, ask the user if they want to try again and re-invoke the step — each invocation starts a fresh 5-attempt batch. Success emits `PAIR_TELEGRAM STATUS=success` with `PLATFORM_ID`, `IS_GROUP`, and `ADMIN_USER_ID`. The service must be running for this to work (the polling adapter is what observes the code).
|
||||
- **platform-id-format**: `telegram:{chatId}` (e.g. `telegram:123456789` for a DM, `telegram:-1001234567890` for a group — negative chat IDs are groups/channels).
|
||||
- **how-to-find-id**: Do NOT ask the user for a chat ID. Telegram registration uses pairing — run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent <main|wire-to:folder|new-agent:folder>`. The step prints a 4-digit code (and re-prints a fresh one if a wrong code invalidates it, up to 5 times); tell the user to send just those 4 digits from the chat they want to register (DM the bot for `main`, post in the group otherwise; with Group Privacy ON, prefix `@<botname> CODE`). Success emits a `PAIR_TELEGRAM` block with `STATUS=success`, `PLATFORM_ID`, `IS_GROUP`, `ADMIN_USER_ID` (the bare Telegram user id) and `PAIRED_USER_ID` (the `telegram:`-prefixed form). The service must be running — the polling adapter is what observes the code.
|
||||
- **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.
|
||||
|
||||
@@ -5,85 +5,110 @@ description: Add Webex channel integration via Chat SDK.
|
||||
|
||||
# Add Webex Channel
|
||||
|
||||
Adds Cisco Webex support via the Chat SDK bridge.
|
||||
Adds Cisco Webex support via the Chat SDK bridge. NanoClaw doesn't ship channels
|
||||
in trunk — this skill copies the Webex adapter in from the `channels` branch.
|
||||
|
||||
## Install
|
||||
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
|
||||
reads the prose and applies them, and a parser can apply them deterministically
|
||||
from the same document. Every directive is idempotent, so the whole skill is
|
||||
safe to re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Webex adapter in from the `channels` branch.
|
||||
## Apply
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
### 1. Copy the adapter and its registration test
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
Fetch the `channels` branch and copy the Webex adapter and its registration test
|
||||
into `src/channels/` (overwrite — the branch is canonical):
|
||||
|
||||
- `src/channels/webex.ts` exists
|
||||
- `src/channels/webex-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './webex.js';`
|
||||
- `@bitbasti/chat-adapter-webex` 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
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/webex.ts
|
||||
src/channels/webex-registration.test.ts
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Register the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/webex.ts > src/channels/webex.ts
|
||||
git show origin/channels:src/channels/webex-registration.test.ts > src/channels/webex-registration.test.ts
|
||||
```
|
||||
Append the self-registration import to the channel barrel (skipped if the line
|
||||
is already present). This one line is the skill's only reach-in into core:
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
```nc:append to:src/channels/index.ts
|
||||
import './webex.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
### 3. Install the adapter package
|
||||
|
||||
```bash
|
||||
pnpm install @bitbasti/chat-adapter-webex@0.1.0
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`.
|
||||
The Webex adapter ships under the third-party `@bitbasti/*` namespace, not
|
||||
`@chat-adapter/*`, so it carries its own version line (`0.1.0`) rather than
|
||||
tracking the chat core version:
|
||||
|
||||
```nc:dep
|
||||
@bitbasti/chat-adapter-webex@0.1.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
### 4. Build and validate
|
||||
|
||||
```bash
|
||||
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
|
||||
the dependency is installed. Then run the one integration test.
|
||||
|
||||
```nc:run effect:build
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/webex-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `webex-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `webex`. It goes red if the `import './webex.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@bitbasti/chat-adapter-webex` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
|
||||
End-to-end message delivery against a real Webex space is verified manually once the service is running — see Next Steps and the webhook setup above.
|
||||
`webex-registration.test.ts` imports the real channel barrel and asserts the
|
||||
registry contains `webex`. It goes red if the import line is deleted or drifts,
|
||||
if the barrel fails to evaluate, or if `@bitbasti/chat-adapter-webex` isn't
|
||||
installed (the import throws) — so it also covers the dependency from step 3.
|
||||
End-to-end delivery against a real Webex space is verified manually once the
|
||||
service runs — see the webhook setup below.
|
||||
|
||||
## Credentials
|
||||
|
||||
1. Go to [developer.webex.com](https://developer.webex.com/my-apps/new/bot) and create a new bot
|
||||
2. Copy the **Bot Access Token**
|
||||
Webex bot setup is human and interactive — these steps are prose, not directives
|
||||
(no parser can click through the Webex Developer Portal). A recipe rebuild
|
||||
produces a compiling, registered adapter that cannot receive a message until
|
||||
they're done.
|
||||
|
||||
### Create the Webex bot
|
||||
|
||||
1. Go to [developer.webex.com](https://developer.webex.com/my-apps/new/bot) and create a new bot.
|
||||
2. Copy the **Bot Access Token**.
|
||||
3. Set up a webhook:
|
||||
- Use the Webex API or Developer Portal to create a webhook pointing to `https://your-domain/webhook/webex`
|
||||
- Set a webhook secret for signature verification
|
||||
- Use the Webex API or Developer Portal to create a webhook pointing to `https://your-domain/webhook/webex`.
|
||||
- Set a webhook secret for signature verification.
|
||||
|
||||
### Configure environment
|
||||
### Store the credentials
|
||||
|
||||
Add to `.env`:
|
||||
Capture the two values, then write them. `prompt` only *asks* and binds the
|
||||
answer to a name; a separate directive consumes it — so the same prompts could
|
||||
feed `ncl` or the OneCLI vault instead of `.env` by swapping only the consumer.
|
||||
Here they go to `.env` (set-if-absent — a value you've already filled in is
|
||||
never overwritten):
|
||||
|
||||
```bash
|
||||
WEBEX_BOT_TOKEN=your-bot-token
|
||||
WEBEX_WEBHOOK_SECRET=your-webhook-secret
|
||||
```nc:prompt bot_token secret
|
||||
Paste the Bot Access Token — from the Webex bot you created.
|
||||
```
|
||||
```nc:prompt webhook_secret secret
|
||||
Paste the webhook secret you set for signature verification.
|
||||
```
|
||||
```nc:env-set
|
||||
WEBEX_BOT_TOKEN={{bot_token}}
|
||||
WEBEX_WEBHOOK_SECRET={{webhook_secret}}
|
||||
```
|
||||
### Webhook server
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
The Chat SDK bridge automatically starts a shared webhook server on port 3000
|
||||
(`WEBHOOK_PORT` to change it), handling `/webhook/webex`. This port must be
|
||||
publicly reachable for Webex to deliver events. Running locally, expose it with
|
||||
ngrok (`ngrok http 3000`), a Cloudflare Tunnel, or a reverse proxy on a VPS —
|
||||
the resulting public URL is the base for the webhook URL above.
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
|
||||
Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
||||
If you're in the middle of `/setup`, return to the setup flow now. Otherwise run
|
||||
`/manage-channels` to wire this channel to an agent group.
|
||||
|
||||
## Channel Info
|
||||
|
||||
|
||||
@@ -36,16 +36,12 @@ pnpm uninstall wechat-ilink-client
|
||||
rm -rf data/wechat
|
||||
```
|
||||
|
||||
## 5. Remove DB wiring
|
||||
The channel's messaging groups, wirings, and conversation history are **left
|
||||
intact** — you created those at runtime (wiring + use), not this skill's install,
|
||||
so removal doesn't touch them. To purge them deliberately, delete them yourself
|
||||
with `ncl messaging-groups delete <id>`.
|
||||
|
||||
```sql
|
||||
-- Remove any sessions first (foreign key)
|
||||
DELETE FROM sessions WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type = 'wechat');
|
||||
DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type = 'wechat');
|
||||
DELETE FROM messaging_groups WHERE channel_type = 'wechat';
|
||||
```
|
||||
|
||||
## 6. Rebuild and restart
|
||||
## 5. Rebuild and restart
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
|
||||
@@ -85,7 +85,6 @@ Add to `.env`:
|
||||
WECHAT_ENABLED=true
|
||||
```
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
### 2. Start the service and scan the QR
|
||||
|
||||
|
||||
@@ -6,62 +6,71 @@ description: Add WhatsApp Business Cloud API channel via Chat SDK. Official Meta
|
||||
# Add WhatsApp Cloud API Channel
|
||||
|
||||
Connect NanoClaw to WhatsApp via the official Meta WhatsApp Business Cloud API.
|
||||
NanoClaw doesn't ship channels in trunk — this skill copies the WhatsApp Cloud
|
||||
adapter in from the `channels` branch.
|
||||
|
||||
## Install
|
||||
The mechanical steps under **Apply** carry `nc:` directive fences: an agent reads
|
||||
the prose and applies them, and a parser can apply them deterministically from
|
||||
the same document. Every directive is idempotent, so the whole skill is safe to
|
||||
re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the WhatsApp Cloud adapter in from the `channels` branch.
|
||||
## Apply
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
### 1. Copy the adapter
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
Fetch the `channels` branch and copy the WhatsApp Cloud adapter into
|
||||
`src/channels/` (overwrite — the branch is canonical):
|
||||
|
||||
- `src/channels/whatsapp-cloud.ts` exists
|
||||
- `src/channels/whatsapp-cloud-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './whatsapp-cloud.js';`
|
||||
- `@chat-adapter/whatsapp` 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
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/whatsapp-cloud.ts
|
||||
src/channels/whatsapp-cloud-registration.test.ts
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Register the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/whatsapp-cloud.ts > src/channels/whatsapp-cloud.ts
|
||||
git show origin/channels:src/channels/whatsapp-cloud-registration.test.ts > src/channels/whatsapp-cloud-registration.test.ts
|
||||
```
|
||||
Append the self-registration import to the channel barrel (skipped if the line
|
||||
is already present). This one line is the skill's only reach-in into core:
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
```nc:append to:src/channels/index.ts
|
||||
import './whatsapp-cloud.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
### 3. Install the adapter package
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/whatsapp@4.29.0
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
|
||||
|
||||
```nc:dep
|
||||
@chat-adapter/whatsapp@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
### 4. Build and validate
|
||||
|
||||
```bash
|
||||
Build guards the typed `createChatSdkBridge(...)` core call and proves the
|
||||
dependency is installed — the import throws at evaluation if `@chat-adapter/whatsapp`
|
||||
is missing or the barrel drifts:
|
||||
|
||||
```nc:run effect:build
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/whatsapp-cloud-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `whatsapp-cloud-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `whatsapp-cloud`. It goes red if the `import './whatsapp-cloud.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/whatsapp` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
`whatsapp-cloud-registration.test.ts` imports the real channel barrel and asserts
|
||||
the registry contains `whatsapp-cloud` — it goes red if the import line is deleted
|
||||
or drifts, if the barrel fails to evaluate, or if `@chat-adapter/whatsapp` isn't
|
||||
installed (the import throws), so it also covers the dependency from step 3.
|
||||
|
||||
End-to-end message delivery against a real WhatsApp Business number is verified manually once the service is running — see Next Steps and the webhook setup above.
|
||||
End-to-end message delivery against a real WhatsApp Business number is verified
|
||||
manually once the service is running — see Next Steps and the webhook setup
|
||||
below.
|
||||
|
||||
## Credentials
|
||||
|
||||
Meta app setup is human and interactive — these steps are prose, not directives
|
||||
(no parser can click through the Meta dashboard). A recipe rebuild produces a
|
||||
compiling, registered adapter that cannot receive a message until they're done.
|
||||
|
||||
1. Go to [Meta for Developers](https://developers.facebook.com/apps/) and create an app (type: Business).
|
||||
2. Add the **WhatsApp** product.
|
||||
3. Go to **WhatsApp** > **API Setup**:
|
||||
@@ -73,18 +82,40 @@ End-to-end message delivery against a real WhatsApp Business number is verified
|
||||
- Subscribe to webhook fields: `messages`.
|
||||
5. Copy the **App Secret** from **Settings** > **Basic**.
|
||||
|
||||
### Configure environment
|
||||
### Store the credentials
|
||||
|
||||
Add to `.env`:
|
||||
Capture the four values, then write them. `prompt` only *asks* and binds the
|
||||
answer to a name; a separate directive consumes it — so the same prompts could
|
||||
feed `ncl` or the OneCLI vault instead of `.env` by swapping only the consumer.
|
||||
Here they go to `.env` (set-if-absent — a value you've already filled in is
|
||||
never overwritten):
|
||||
|
||||
```bash
|
||||
WHATSAPP_ACCESS_TOKEN=your-system-user-access-token
|
||||
WHATSAPP_PHONE_NUMBER_ID=your-phone-number-id
|
||||
WHATSAPP_APP_SECRET=your-app-secret
|
||||
WHATSAPP_VERIFY_TOKEN=your-verify-token
|
||||
```nc:prompt access_token secret
|
||||
Paste the System User access token — WhatsApp > API Setup, with `whatsapp_business_messaging` permission.
|
||||
```
|
||||
```nc:prompt phone_number_id
|
||||
Paste the Phone Number ID — WhatsApp > API Setup (not the phone number itself).
|
||||
```
|
||||
```nc:prompt app_secret secret
|
||||
Paste the App Secret — Settings > Basic.
|
||||
```
|
||||
```nc:prompt verify_token secret
|
||||
Paste the Verify Token — the random string you set under WhatsApp > Configuration.
|
||||
```
|
||||
```nc:env-set
|
||||
WHATSAPP_ACCESS_TOKEN={{access_token}}
|
||||
WHATSAPP_PHONE_NUMBER_ID={{phone_number_id}}
|
||||
WHATSAPP_APP_SECRET={{app_secret}}
|
||||
WHATSAPP_VERIFY_TOKEN={{verify_token}}
|
||||
```
|
||||
### Webhook server
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
The Chat SDK bridge automatically starts a shared webhook server on port 3000
|
||||
(`WEBHOOK_PORT` to change it), handling `/webhook/whatsapp`. This port must be
|
||||
publicly reachable for Meta to deliver events. Running locally, expose it with
|
||||
ngrok (`ngrok http 3000`), a Cloudflare Tunnel, or a reverse proxy on a VPS —
|
||||
the resulting public URL is the base for the webhook URL set under WhatsApp >
|
||||
Configuration above.
|
||||
|
||||
## Next Steps
|
||||
|
||||
@@ -100,3 +131,5 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
||||
- **supports-threads**: no
|
||||
- **typical-use**: Interactive 1:1 chat -- direct messages only
|
||||
- **default-isolation**: Same agent group if you're the only person messaging the bot. Each additional person who messages gets their own conversation automatically, but they share the agent's workspace and memory -- use a separate agent group if you need information isolation between different contacts.
|
||||
</content>
|
||||
</invoke>
|
||||
|
||||
@@ -5,216 +5,202 @@ description: Add WhatsApp channel via native Baileys adapter. Direct connection
|
||||
|
||||
# Add WhatsApp Channel
|
||||
|
||||
Adds WhatsApp support via the native Baileys adapter (no Chat SDK bridge).
|
||||
Adds WhatsApp support via the native Baileys adapter — a direct WhatsApp Web
|
||||
connection, no Chat SDK bridge. NanoClaw doesn't ship channels in trunk — this
|
||||
skill copies the WhatsApp adapter in from the `channels` branch.
|
||||
|
||||
## Install
|
||||
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
|
||||
reads the prose and applies them, and a parser can apply them deterministically
|
||||
from the same document. Every directive is idempotent, so the whole skill is
|
||||
safe to re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the native WhatsApp (Baileys) adapter and its `whatsapp-auth` setup step in from the `channels` branch. No Chat SDK bridge.
|
||||
## Apply
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
### 1. Copy the adapter and its registration test
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
Fetch the `channels` branch and copy the WhatsApp adapter and its registration
|
||||
test into `src/channels/` (overwrite — the branch is canonical). The
|
||||
`whatsapp-auth` setup step is maintained in trunk, so it is not copied here:
|
||||
|
||||
- `src/channels/whatsapp.ts` exists
|
||||
- `src/channels/whatsapp-registration.test.ts` exists
|
||||
- `src/channels/whatsapp.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './whatsapp.js';`
|
||||
- `setup/whatsapp-auth.ts` and `setup/groups.ts` both exist
|
||||
- `setup/index.ts`'s `STEPS` map contains both `'whatsapp-auth':` and `groups:`
|
||||
- `@whiskeysockets/baileys`, `qrcode`, `pino` are listed in `package.json` dependencies
|
||||
- `.claude/skills/add-whatsapp/scripts/wa-qr-browser.ts` exists (ships with this skill)
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
### 1. Fetch the channels branch
|
||||
|
||||
```bash
|
||||
git fetch origin channels
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/whatsapp.ts
|
||||
src/channels/whatsapp-registration.test.ts
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and setup steps
|
||||
### 2. Register the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/whatsapp.ts > src/channels/whatsapp.ts
|
||||
git show origin/channels:src/channels/whatsapp-registration.test.ts > src/channels/whatsapp-registration.test.ts
|
||||
git show origin/channels:src/channels/whatsapp.test.ts > src/channels/whatsapp.test.ts
|
||||
git show origin/channels:setup/whatsapp-auth.ts > setup/whatsapp-auth.ts
|
||||
git show origin/channels:setup/groups.ts > setup/groups.ts
|
||||
```
|
||||
Append the self-registration import to the channel barrel (skipped if the line
|
||||
is already present). This one line is the skill's only reach-in into core:
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if already present):
|
||||
|
||||
```typescript
|
||||
```nc:append to:src/channels/index.ts
|
||||
import './whatsapp.js';
|
||||
```
|
||||
|
||||
### 4. Register the setup steps
|
||||
### 3. Install the adapter packages
|
||||
|
||||
In `setup/index.ts`, add these entries to the `STEPS` map (skip lines already present):
|
||||
Pinned to exact versions — the supply-chain policy rejects ranges and `latest`.
|
||||
Baileys is the WhatsApp Web client; `qrcode` renders the device-link QR in the
|
||||
terminal; `pino` is Baileys' logger:
|
||||
|
||||
```typescript
|
||||
groups: () => import('./groups.js'),
|
||||
'whatsapp-auth': () => import('./whatsapp-auth.js'),
|
||||
```nc:dep
|
||||
@whiskeysockets/baileys@7.0.0-rc.9
|
||||
qrcode@1.5.4
|
||||
@types/qrcode@1.5.6
|
||||
pino@9.6.0
|
||||
```
|
||||
|
||||
### 5. Install the adapter packages (pinned)
|
||||
### 4. Build and validate
|
||||
|
||||
```bash
|
||||
pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||
```
|
||||
Build first: it typechecks the adapter against core and proves the dependencies
|
||||
are installed. Then run the one integration test.
|
||||
|
||||
### 6. Build and validate
|
||||
|
||||
```bash
|
||||
```nc:run effect:build
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/whatsapp-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `whatsapp-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `whatsapp`. It goes red if the `import './whatsapp.js';` line is deleted or drifts, if the barrel fails to evaluate (so the channel genuinely would not register), or if `@whiskeysockets/baileys` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 5.
|
||||
`whatsapp-registration.test.ts` imports the real channel barrel and asserts the
|
||||
registry contains `whatsapp`. It goes red if the `import './whatsapp.js';` line
|
||||
is deleted or drifts, if the barrel fails to evaluate, or if
|
||||
`@whiskeysockets/baileys` isn't installed (the import throws) — so it also covers
|
||||
the dependency from step 3. End-to-end delivery against a real WhatsApp number is
|
||||
verified manually once the service runs.
|
||||
|
||||
End-to-end message delivery against a real WhatsApp number is verified manually once the service is running — see Credentials, Wiring, and Troubleshooting.
|
||||
## Authenticate
|
||||
|
||||
## Credentials
|
||||
WhatsApp uses linked-device authentication — no API key, just a one-time pairing
|
||||
from your phone. The adapter is installed and registered, but its factory returns
|
||||
`null` (and the channel stays dark) until `store/auth/creds.json` exists.
|
||||
|
||||
WhatsApp uses linked-device authentication — no API key, just a one-time pairing from your phone.
|
||||
Pick how to link the device. `qr` shows a rotating QR you scan with your phone's
|
||||
camera; `pairing-code` shows an 8-character code you type into WhatsApp (no camera
|
||||
needed, but it needs your phone number):
|
||||
|
||||
### Check current state
|
||||
|
||||
Check if WhatsApp is already authenticated. If `store/auth/creds.json` exists, skip to "Shared vs dedicated number".
|
||||
|
||||
```bash
|
||||
test -f store/auth/creds.json && echo "WhatsApp auth exists" || echo "No WhatsApp auth"
|
||||
```nc:prompt auth_method validate:^(qr|pairing-code)$
|
||||
How do you want to link WhatsApp? Type `qr` to scan a QR code in this terminal, or `pairing-code` to enter a code on your phone (no camera needed).
|
||||
```
|
||||
|
||||
### Detect environment
|
||||
The pairing-code method needs the number you're linking, the way WhatsApp expects
|
||||
it — digits only, country code first, no `+`, spaces, or dashes (the QR method
|
||||
skips this entirely):
|
||||
|
||||
Check whether the environment is headless (no display server):
|
||||
|
||||
```bash
|
||||
[[ -z "$DISPLAY" && -z "$WAYLAND_DISPLAY" && "$OSTYPE" != darwin* ]] && echo "IS_HEADLESS=true" || echo "IS_HEADLESS=false"
|
||||
```nc:prompt phone validate:^\d{8,15}$ when:auth_method=pairing-code
|
||||
Your WhatsApp phone number — digits only, country code first (e.g. 14155551234 for +1 415-555-1234).
|
||||
```
|
||||
|
||||
### Ask the user
|
||||
Point the user at the right screen before the code appears. For the QR method,
|
||||
tell the user:
|
||||
|
||||
Use `AskUserQuestion` to collect configuration. **Adapt auth options based on environment:**
|
||||
|
||||
If IS_HEADLESS=true AND not WSL → AskUserQuestion: How do you want to authenticate WhatsApp?
|
||||
- **Pairing code** (Recommended) - Enter a numeric code on your phone (no camera needed, requires phone number)
|
||||
- **QR code in terminal** - Displays QR code in the terminal (can be too small on some displays)
|
||||
|
||||
Otherwise (macOS, desktop Linux, or WSL) → AskUserQuestion: How do you want to authenticate WhatsApp?
|
||||
- **QR code in browser** (Recommended) - Runs a small local HTTP server that renders the rotating QR as a PNG and auto-opens your default browser
|
||||
- **Pairing code** - Enter a numeric code on your phone (no camera needed, requires phone number)
|
||||
- **QR code in terminal** - Displays QR code in the terminal (can be too small on some displays)
|
||||
|
||||
If they chose pairing code:
|
||||
|
||||
AskUserQuestion: What is your phone number? (Digits only — country code followed by your 10-digit number, no + prefix, spaces, or dashes. Example: 14155551234 where 1 is the US country code and 4155551234 is the phone number.)
|
||||
|
||||
### Clean previous auth state (if re-authenticating)
|
||||
|
||||
```bash
|
||||
rm -rf store/auth/
|
||||
```nc:operator when:auth_method=qr
|
||||
Link WhatsApp by QR:
|
||||
1. On your phone, open WhatsApp → Settings → Linked Devices → Link a Device.
|
||||
2. A QR code will appear in this terminal below and refresh every ~20 seconds. Point your phone's camera at it to scan.
|
||||
```
|
||||
|
||||
### Run WhatsApp authentication
|
||||
For the pairing-code method, tell the user:
|
||||
|
||||
For QR code in browser (recommended):
|
||||
|
||||
```bash
|
||||
pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts
|
||||
```nc:operator when:auth_method=pairing-code
|
||||
Link WhatsApp by pairing code:
|
||||
1. On your phone, open WhatsApp → Settings → Linked Devices → Link a Device → tap "Link with phone number instead".
|
||||
2. An 8-character code will appear in this terminal below. Enter it on your phone immediately — it expires in about 60 seconds.
|
||||
```
|
||||
|
||||
(Bash timeout: 150000ms)
|
||||
Now run the linked-device handshake. It streams the live QR (or the pairing-code
|
||||
card) to this terminal and, on success, reports the linked WhatsApp number. Run
|
||||
the command for the method chosen above — `qr` or `pairing-code`:
|
||||
|
||||
The wrapper spawns `setup/index.ts --step whatsapp-auth -- --method qr`, parses each rotating QR from its `WHATSAPP_AUTH_QR` status blocks, and serves the current QR as a PNG on a local HTTP server (default port `8765`, falls back to a free port). Flags: `--clean` (wipes `store/auth/` before spawning) and `--port N`.
|
||||
|
||||
Tell the user:
|
||||
|
||||
> A browser window will open with a QR code.
|
||||
>
|
||||
> 1. Open WhatsApp > **Settings** > **Linked Devices** > **Link a Device**
|
||||
> 2. Scan the QR code in the browser
|
||||
> 3. The page will show "Authenticated!" when done
|
||||
|
||||
For QR code in terminal:
|
||||
|
||||
```bash
|
||||
```nc:run effect:step capture:bot_phone=PHONE when:auth_method=qr
|
||||
pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr
|
||||
```
|
||||
|
||||
(Bash timeout: 150000ms)
|
||||
|
||||
The setup driver emits each rotating QR as a `WHATSAPP_AUTH_QR` status block; when run directly (not through `setup:auto`) the raw QR string is printed and your terminal must render it as ASCII. If your terminal can't render it readably, use the browser method above.
|
||||
|
||||
Tell the user:
|
||||
|
||||
> 1. Open WhatsApp > **Settings** > **Linked Devices** > **Link a Device**
|
||||
> 2. Scan the QR code displayed in the terminal
|
||||
|
||||
For pairing code:
|
||||
|
||||
Tell the user to have WhatsApp open on **Settings > Linked Devices > Link a Device**, ready to tap **"Link with phone number instead"** — the code expires in ~60 seconds and must be entered immediately.
|
||||
|
||||
Run the auth process in the background and poll `store/pairing-code.txt` for the code:
|
||||
|
||||
```bash
|
||||
rm -f store/pairing-code.txt && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method pairing-code --phone <their-phone-number> > /tmp/wa-auth.log 2>&1 &
|
||||
```nc:run effect:step capture:bot_phone=PHONE when:auth_method=pairing-code
|
||||
pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method pairing-code --phone {{phone}}
|
||||
```
|
||||
|
||||
Then immediately poll for the code (do NOT wait for the background command to finish):
|
||||
If the handshake fails (`logged_out` or a timeout), the code expired — clear
|
||||
`store/auth/` and run the step again for a fresh one. See Troubleshooting.
|
||||
|
||||
```bash
|
||||
for i in $(seq 1 20); do [ -f store/pairing-code.txt ] && cat store/pairing-code.txt && break; sleep 1; done
|
||||
A successful link reports the number back as `bot_phone`. If it came back empty,
|
||||
the device never confirmed (an expired QR or pairing code), so don't restart or
|
||||
wire against a blank number — clear `store/auth/` and re-run the link step first:
|
||||
|
||||
```nc:run effect:check
|
||||
[ -n "{{bot_phone}}" ]
|
||||
```
|
||||
|
||||
Display the code to the user the moment it appears. Tell them:
|
||||
## Restart
|
||||
|
||||
> **Enter this code now** — it expires in ~60 seconds.
|
||||
>
|
||||
> 1. Open WhatsApp > **Settings** > **Linked Devices** > **Link a Device**
|
||||
> 2. Tap **Link with phone number instead**
|
||||
> 3. Enter the code immediately
|
||||
Restart the service so it loads the WhatsApp adapter and picks up the
|
||||
credentials you just linked, and wait for its CLI socket before resolving:
|
||||
|
||||
After the user enters the code, poll for authentication to complete:
|
||||
|
||||
```bash
|
||||
for i in $(seq 1 60); do grep -q 'STATUS: authenticated' /tmp/wa-auth.log 2>/dev/null && echo "authenticated" && break; grep -q 'STATUS: failed' /tmp/wa-auth.log 2>/dev/null && echo "failed" && break; sleep 2; done
|
||||
```nc:run effect:restart
|
||||
bash setup/lib/restart.sh
|
||||
```
|
||||
|
||||
**If failed:** logged_out → delete `store/auth/` and re-run. timeout → ask user, offer retry.
|
||||
## Resolve your DM channel
|
||||
|
||||
### Verify authentication succeeded
|
||||
The agent talks to you in your WhatsApp chat. Tell the user which number that
|
||||
chat happens on — usually the same one they just linked:
|
||||
|
||||
```bash
|
||||
test -f store/auth/creds.json && echo "Authentication successful" || echo "Authentication failed"
|
||||
```nc:operator
|
||||
You're linked to WhatsApp as +{{bot_phone}}.
|
||||
|
||||
- "shared" — you'll message the assistant from this same (personal) WhatsApp number. Replies land in your own "You" / self-chat.
|
||||
- "dedicated" — the assistant has its own separate phone/SIM, and you'll message it from a different number.
|
||||
```
|
||||
|
||||
### Shared vs dedicated number
|
||||
Pick which it is. Most people use `shared`:
|
||||
|
||||
AskUserQuestion: Is this a shared phone number (personal WhatsApp) or a dedicated number?
|
||||
- **Shared number** — your personal WhatsApp (bot prefixes messages with its name)
|
||||
- **Dedicated number** — a separate phone/SIM for the assistant
|
||||
```nc:prompt number_kind validate:^(shared|dedicated)$
|
||||
Is the assistant on a `shared` number (your personal WhatsApp) or a `dedicated` number (a separate line for the assistant)?
|
||||
```
|
||||
|
||||
If dedicated, add to `.env`:
|
||||
For a dedicated number, collect the number you'll actually chat from (skipped
|
||||
entirely for a shared number):
|
||||
|
||||
```bash
|
||||
```nc:prompt chat_phone validate:^\d{8,15}$ when:number_kind=dedicated
|
||||
The phone number you'll message the assistant from — digits only, country code first (e.g. 14155551234).
|
||||
```
|
||||
|
||||
A dedicated number means the assistant owns its own line, so outbound replies
|
||||
shouldn't be prefixed with its name. Record that (skipped for a shared number):
|
||||
|
||||
```nc:env-set when:number_kind=dedicated
|
||||
ASSISTANT_HAS_OWN_NUMBER=true
|
||||
```
|
||||
Resolve the conversation address as the WhatsApp JID for the number you chat
|
||||
from — the linked number for a shared account, or the dedicated number you just
|
||||
gave. Run the one matching the choice above:
|
||||
|
||||
```nc:run capture:platform_id effect:fetch when:number_kind=shared
|
||||
echo "{{bot_phone}}@s.whatsapp.net"
|
||||
```
|
||||
```nc:run capture:platform_id effect:fetch when:number_kind=dedicated
|
||||
echo "{{chat_phone}}@s.whatsapp.net"
|
||||
```
|
||||
|
||||
For WhatsApp, your owner handle is that same JID:
|
||||
|
||||
```nc:run capture:owner_handle effect:fetch
|
||||
echo "{{platform_id}}"
|
||||
```
|
||||
|
||||
`owner_handle` and `platform_id` are what the owner-wiring step needs. The
|
||||
greeting goes out over your WhatsApp chat as soon as the service reconnects with
|
||||
the linked credentials.
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
|
||||
Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
||||
If you're in the middle of `/setup`, return to the setup flow now. Otherwise wire
|
||||
this channel with `/init-first-agent` (or `/manage-channels`).
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **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 `pnpm exec tsx scripts/q.ts data/v2.db "SELECT platform_id, name FROM messaging_groups WHERE channel_type='whatsapp' AND is_group=1"`.
|
||||
- **platform-id-format**: DMs use `<phone>@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `<id>@g.us`. Native adapter — the JID is the platform ID as-is, no `whatsapp:` prefix.
|
||||
- **how-to-find-id**: To find your linked number after auth: `node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0].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.
|
||||
@@ -228,18 +214,74 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
||||
- Typing indicators — composing presence updates
|
||||
- Credential requests — text fallback (WhatsApp has no modal support)
|
||||
|
||||
Not supported (WhatsApp linked device limitation): edit messages, delete messages.
|
||||
Not supported (WhatsApp linked-device limitation): edit messages, delete messages.
|
||||
|
||||
## Alternatives
|
||||
|
||||
### QR code in a browser
|
||||
|
||||
Besides the in-terminal QR and the pairing code the Apply flow uses, this skill
|
||||
ships a helper that renders the rotating QR as a PNG in your default browser —
|
||||
handy when the terminal QR is too small to scan reliably. It spawns the same
|
||||
`whatsapp-auth` step, parses each rotating QR from its `WHATSAPP_AUTH_QR` status
|
||||
blocks, and serves the current one on a local HTTP server (default port `8765`,
|
||||
falls back to a free port):
|
||||
|
||||
```bash
|
||||
pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts
|
||||
```
|
||||
|
||||
Flags: `--clean` wipes `store/auth/` before spawning, `--port N` pins the port.
|
||||
|
||||
A browser window opens with a QR code. On your phone, open WhatsApp →
|
||||
**Settings** → **Linked Devices** → **Link a Device**, scan the QR, and the page
|
||||
shows "Authenticated!" when done.
|
||||
|
||||
### Headless environments
|
||||
|
||||
On a headless host (no display server — no `$DISPLAY`/`$WAYLAND_DISPLAY`, not
|
||||
macOS), the browser method can't open a window. Detect it and fall back to the
|
||||
pairing-code method (no camera needed):
|
||||
|
||||
```bash
|
||||
[[ -z "$DISPLAY" && -z "$WAYLAND_DISPLAY" && "$OSTYPE" != darwin* ]] && echo "IS_HEADLESS=true" || echo "IS_HEADLESS=false"
|
||||
```
|
||||
|
||||
## Optional configuration
|
||||
|
||||
If the assistant runs on a dedicated number (its own phone/SIM, not your personal
|
||||
WhatsApp), tell the adapter so it doesn't prefix outbound replies with its name:
|
||||
|
||||
```bash
|
||||
ASSISTANT_HAS_OWN_NUMBER=true
|
||||
```
|
||||
|
||||
The Apply flow sets this for you when you pick a `dedicated` number; this is the
|
||||
key it writes, for reference. A shared (personal) number leaves it unset.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### QR code expired
|
||||
### QR code or pairing code expired
|
||||
|
||||
QR codes expire after ~60 seconds. The browser wrapper rotates automatically as long as it's running; if it was stopped, re-run with `--clean`:
|
||||
Codes expire after ~60 seconds. The QR rotates automatically while the auth step
|
||||
is running; if the step exited, clear the auth state and re-run it:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts --clean
|
||||
rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr
|
||||
```
|
||||
|
||||
For pairing code, ensure digits only (no `+`), the phone has internet, and
|
||||
WhatsApp is updated:
|
||||
|
||||
```bash
|
||||
rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method pairing-code --phone <phone>
|
||||
```
|
||||
|
||||
WhatsApp's pairing-code flow occasionally rejects valid codes with "Couldn't link
|
||||
device." This is a server-side rejection unrelated to the code itself. If you hit
|
||||
it more than once, switch to the QR method — it has a noticeably higher success
|
||||
rate.
|
||||
|
||||
### Pairing code not working
|
||||
|
||||
Codes expire in ~60 seconds. Delete auth and retry:
|
||||
@@ -250,7 +292,11 @@ rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --met
|
||||
|
||||
Ensure: digits only (no `+`), phone has internet, WhatsApp is updated.
|
||||
|
||||
WhatsApp's pairing-code flow occasionally rejects valid codes with "Couldn't link device — An error happened. Please try again." This is a server-side rejection unrelated to the code itself; we've seen it happen twice in a row on fresh dedicated numbers. If you hit it more than once, switch to QR-browser auth — it has a noticeably higher success rate:
|
||||
WhatsApp's pairing-code flow occasionally rejects valid codes with "Couldn't link
|
||||
device — An error happened. Please try again." This is a server-side rejection
|
||||
unrelated to the code itself; we've seen it happen twice in a row on fresh
|
||||
dedicated numbers. If you hit it more than once, switch to QR-browser auth — it
|
||||
has a noticeably higher success rate:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts --clean
|
||||
@@ -258,9 +304,8 @@ pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts --clean
|
||||
|
||||
### "waiting for this message" on reactions
|
||||
|
||||
Signal sessions corrupted from rapid restarts. Clear sessions.
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
WhatsApp sessions corrupted from rapid restarts. Clear sessions, then restart the
|
||||
service. Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
@@ -278,4 +323,5 @@ systemctl --user start $(systemd_unit)
|
||||
|
||||
### "conflict" disconnection
|
||||
|
||||
Two instances connected with same credentials. Ensure only one NanoClaw process is running.
|
||||
Two instances connected with the same credentials. Ensure only one NanoClaw
|
||||
process is running.
|
||||
|
||||
@@ -13,18 +13,18 @@ Configure which host directories NanoClaw agent containers can access. The mount
|
||||
cat ~/.config/nanoclaw/mount-allowlist.json 2>/dev/null || echo "No mount allowlist configured"
|
||||
```
|
||||
|
||||
Show the current config to the user in a readable format: which directories are allowed, whether non-main agents are read-only.
|
||||
Show the current config to the user in a readable format: which directories are allowed, and whether each is read-only or read-write.
|
||||
|
||||
## Add Directories
|
||||
|
||||
Ask which directories the user wants agents to access. For each path:
|
||||
- Validate the path exists
|
||||
- Ask if it should be read-only for non-main agents (default: yes)
|
||||
- Ask if it should be read-write (`allowReadWrite: true`) or read-only (`allowReadWrite: false`, the safer default)
|
||||
|
||||
Build the JSON config and write it:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[{"path":"/path/to/dir","readOnly":false}],"blockedPatterns":[],"nonMainReadOnly":true}'
|
||||
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[{"path":"/path/to/dir","allowReadWrite":true}],"blockedPatterns":[]}'
|
||||
```
|
||||
|
||||
Use `--force` to overwrite the existing config.
|
||||
@@ -34,7 +34,7 @@ Use `--force` to overwrite the existing config.
|
||||
Read the current config, show it, ask which entry to remove, then write the updated config through the same write path (build the trimmed JSON and pass it to `--step mounts --force -- --json`):
|
||||
|
||||
```bash
|
||||
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[],"blockedPatterns":[],"nonMainReadOnly":true}'
|
||||
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[],"blockedPatterns":[]}'
|
||||
```
|
||||
|
||||
## Reset to Empty
|
||||
@@ -45,12 +45,10 @@ pnpm exec tsx setup/index.ts --step mounts --force -- --empty
|
||||
|
||||
## After Changes
|
||||
|
||||
Restart the service so containers pick up the new config (the unit/label names are per-install — see `setup/lib/install-slug.sh`).
|
||||
The allowlist is read fresh when a container is spawned, so new mounts apply to newly spawned containers automatically — no service restart needed.
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
To apply the new config to a group that already has a running container, restart just that group:
|
||||
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
systemctl --user restart $(systemd_unit) # Linux
|
||||
ncl groups restart --id <group-id>
|
||||
```
|
||||
|
||||
@@ -71,7 +71,7 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
|
||||
| `src/command-gate.ts` | Router-side admin command gate — queries `user_roles` directly (no env var, no container-side check) |
|
||||
| `src/modules/approvals/onecli-approvals.ts` | OneCLI credentialed-action approval bridge |
|
||||
| `src/modules/permissions/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/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills) — agent-runner source is a shared read-only mount, not copied per group |
|
||||
| `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 |
|
||||
@@ -79,8 +79,8 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
|
||||
| `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 (`onecli-gateway`, `welcome`, `self-customize`, `agent-browser`, `slack-formatting`) |
|
||||
| `groups/<folder>/` | Per-agent-group filesystem (CLAUDE.md, skills, per-group `agent-runner-src/` overlay) |
|
||||
| `container/skills/` | Container skills mounted into every agent session (`agent-browser`, `frontend-engineer`, `onecli-gateway`, `self-customize`, `slack-formatting`, `vercel-cli`, `welcome`, `whatsapp-formatting`) |
|
||||
| `groups/<folder>/` | Per-agent-group filesystem (CLAUDE.md, skills) — agent-runner source is a shared read-only mount, not copied per group |
|
||||
| `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). |
|
||||
| `nanoclaw.sh --uninstall` + `setup/uninstall/` | Uninstall this copy only (slug-scoped): service, containers + image, `data/`, `logs/`, `groups/`, this copy's OneCLI agents. Confirms per group; `--dry-run` previews, `--yes` skips prompts. Other copies and the shared OneCLI app are untouched. Bypasses bootstrap entirely; `uninstall.sh` is a pointer that execs it. |
|
||||
@@ -182,7 +182,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. a `scripts/` CLI or helper).
|
||||
- **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/`: `onecli-gateway`, `welcome`, `self-customize`, `agent-browser`, `slack-formatting`).
|
||||
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `agent-browser`, `frontend-engineer`, `onecli-gateway`, `self-customize`, `slack-formatting`, `vercel-cli`, `welcome`, `whatsapp-formatting`).
|
||||
|
||||
| Skill | When to Use |
|
||||
|-------|-------------|
|
||||
|
||||
@@ -80,7 +80,7 @@ See [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) for what's different an
|
||||
- **Per-agent workspace** — each agent group has its own `CLAUDE.md`, its own memory, its own container, and only the mounts you allow. Nothing crosses the boundary unless you wire it to.
|
||||
- **Scheduled tasks** — recurring jobs that run Claude and can message you back
|
||||
- **Web access** — search and fetch content from the web
|
||||
- **Container isolation** — agents are sandboxed in Docker (macOS/Linux/WSL2), with optional [Docker Sandboxes](docs/docker-sandboxes.md) micro-VM isolation or Apple Container as a macOS-native opt-in
|
||||
- **Container isolation** — agents are sandboxed in Docker (macOS/Linux/WSL2), with optional Docker Sandboxes micro-VM isolation
|
||||
- **Credential security** — agents never hold raw API keys. Outbound requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects credentials at request time and enforces per-agent policies and rate limits.
|
||||
- **Agent templates**: stamp a ready-to-run agent (instructions + MCP tools + skills, no secrets) from a reusable bundle, via the setup wizard or `ncl groups create --template <ref>`. Load from the [public library](https://github.com/nanocoai/nanoclaw-templates), a local folder, or any git repo. See [docs/templates.md](docs/templates.md).
|
||||
|
||||
@@ -165,7 +165,7 @@ Key files:
|
||||
|
||||
**Why Docker?**
|
||||
|
||||
Docker provides cross-platform support (macOS, Linux and Windows via WSL2) and a mature ecosystem. On macOS, Apple Container is also supported as a lighter-weight native runtime. For additional isolation, [Docker Sandboxes](docs/docker-sandboxes.md) run each container inside a micro VM.
|
||||
Docker provides cross-platform support (macOS, Linux and Windows via WSL2) and a mature ecosystem. For additional isolation, Docker Sandboxes run each container inside a micro VM.
|
||||
|
||||
**Can I run this on Linux or Windows?**
|
||||
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,11 @@ export function openInboundDb(): Database {
|
||||
// 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;
|
||||
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');
|
||||
@@ -260,11 +264,3 @@ export function closeSessionDb(): void {
|
||||
_outbound?.close();
|
||||
_outbound = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use getInboundDb() / getOutboundDb() instead.
|
||||
* Kept for backward compatibility during migration.
|
||||
*/
|
||||
export function getSessionDb(): Database {
|
||||
return getInboundDb();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export {
|
||||
getInboundDb,
|
||||
getOutboundDb,
|
||||
getSessionDb,
|
||||
initTestSessionDb,
|
||||
closeSessionDb,
|
||||
touchHeartbeat,
|
||||
|
||||
@@ -77,3 +77,47 @@ export function setContinuation(providerName: string, id: string): void {
|
||||
export function clearContinuation(providerName: string): void {
|
||||
deleteValue(continuationKey(providerName));
|
||||
}
|
||||
|
||||
/**
|
||||
* The a2a reply stamp: the id of the first inbound message in the batch the
|
||||
* agent is currently processing. The poll loop publishes it at batch start;
|
||||
* MCP tools (`send_message`, `send_file`) read it and stamp it onto outbound
|
||||
* rows so the host's a2a return-path routing can correlate replies back to
|
||||
* the originating session.
|
||||
*
|
||||
* This lives in outbound.db rather than module state because the MCP server
|
||||
* runs as a separate stdio subprocess from the poll loop — module state set
|
||||
* by the poll loop is invisible to it. Both processes open outbound.db
|
||||
* (journal_mode=DELETE + busy_timeout make intra-container access safe).
|
||||
*/
|
||||
const IN_REPLY_TO_KEY = 'current_in_reply_to';
|
||||
|
||||
/**
|
||||
* Ignore a stamp older than this. The poll loop clears the stamp in a
|
||||
* finally, but a container killed mid-batch (SIGKILL) can leave one behind;
|
||||
* the guard stops a later out-of-batch read from picking up a dead stamp.
|
||||
* Generous so a long-running batch's late sends still stamp correctly.
|
||||
*/
|
||||
const IN_REPLY_TO_MAX_AGE_MS = 30 * 60 * 1000;
|
||||
|
||||
export function setCurrentInReplyTo(id: string | null): void {
|
||||
if (id === null) {
|
||||
clearCurrentInReplyTo();
|
||||
return;
|
||||
}
|
||||
setValue(IN_REPLY_TO_KEY, id);
|
||||
}
|
||||
|
||||
export function clearCurrentInReplyTo(): void {
|
||||
deleteValue(IN_REPLY_TO_KEY);
|
||||
}
|
||||
|
||||
export function getCurrentInReplyTo(): string | null {
|
||||
const row = getOutboundDb()
|
||||
.prepare('SELECT value, updated_at FROM session_state WHERE key = ?')
|
||||
.get(IN_REPLY_TO_KEY) as { value: string; updated_at: string } | undefined;
|
||||
if (!row) return null;
|
||||
const age = Date.now() - new Date(row.updated_at).getTime();
|
||||
if (!Number.isFinite(age) || age > IN_REPLY_TO_MAX_AGE_MS) return null;
|
||||
return row.value;
|
||||
}
|
||||
|
||||
@@ -4,14 +4,31 @@
|
||||
* 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.
|
||||
*
|
||||
* The stamp is published through session_state in outbound.db, not module
|
||||
* state — the MCP server runs as a separate stdio subprocess from the poll
|
||||
* loop, so it can only see the stamp through the shared DB. These tests seed
|
||||
* it the same way the poll-loop process does (a direct DB write) rather than
|
||||
* via any in-memory helper, so they exercise the real process boundary.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
|
||||
import { initTestSessionDb, closeSessionDb, getInboundDb } from '../db/connection.js';
|
||||
import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from '../db/connection.js';
|
||||
import { getUndeliveredMessages } from '../db/messages-out.js';
|
||||
import { setCurrentInReplyTo, clearCurrentInReplyTo } from '../current-batch.js';
|
||||
import { sendMessage } from './core.js';
|
||||
|
||||
/**
|
||||
* Publish the a2a reply stamp the way the poll loop does: a direct write to
|
||||
* session_state in outbound.db. `ageMs` back-dates updated_at to exercise the
|
||||
* staleness guard MCP tools apply when reading it.
|
||||
*/
|
||||
function publishInReplyTo(id: string, ageMs = 0): void {
|
||||
const updatedAt = new Date(Date.now() - ageMs).toISOString();
|
||||
getOutboundDb()
|
||||
.prepare('INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES (?, ?, ?)')
|
||||
.run('current_in_reply_to', id, updatedAt);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
initTestSessionDb();
|
||||
// Seed a peer agent destination
|
||||
@@ -24,13 +41,12 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
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');
|
||||
it('stamps the batch in_reply_to (published via the DB) on outbound rows', async () => {
|
||||
publishInReplyTo('inbound-msg-1');
|
||||
|
||||
await sendMessage.handler({ to: 'peer', text: 'hello' });
|
||||
|
||||
@@ -40,7 +56,17 @@ describe('send_message MCP tool — in_reply_to plumbing', () => {
|
||||
});
|
||||
|
||||
it('writes null when no batch is active', async () => {
|
||||
// No setCurrentInReplyTo before this call — simulates ad-hoc / out-of-batch invocation.
|
||||
// Nothing published to session_state — 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();
|
||||
});
|
||||
|
||||
it('ignores a stale stamp left behind by a killed container', async () => {
|
||||
publishInReplyTo('inbound-msg-1', 60 * 60 * 1000); // an hour old
|
||||
|
||||
await sendMessage.handler({ to: 'peer', text: 'hello' });
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
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 { getCurrentInReplyTo } from '../db/session-state.js';
|
||||
import { getSessionRouting } from '../db/session-routing.js';
|
||||
import { registerTools } from './server.js';
|
||||
import type { McpToolDefinition } from './types.js';
|
||||
|
||||
@@ -2,8 +2,13 @@ import { findByName, getAllDestinations, type DestinationEntry } from './destina
|
||||
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
|
||||
import { writeMessageOut } from './db/messages-out.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,
|
||||
clearCurrentInReplyTo,
|
||||
migrateLegacyContinuation,
|
||||
setContinuation,
|
||||
setCurrentInReplyTo,
|
||||
} from './db/session-state.js';
|
||||
import {
|
||||
formatMessages,
|
||||
extractRouting,
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
# Apple Container Networking Setup (macOS 26)
|
||||
|
||||
Apple Container's vmnet networking requires manual configuration for containers to access the internet. Without this, containers can communicate with the host but cannot reach external services (DNS, HTTPS, APIs).
|
||||
|
||||
## Quick Setup
|
||||
|
||||
Run these two commands (requires `sudo`):
|
||||
|
||||
```bash
|
||||
# 1. Enable IP forwarding so the host routes container traffic
|
||||
sudo sysctl -w net.inet.ip.forwarding=1
|
||||
|
||||
# 2. Enable NAT so container traffic gets masqueraded through your internet interface
|
||||
echo "nat on en0 from 192.168.64.0/24 to any -> (en0)" | sudo pfctl -ef -
|
||||
```
|
||||
|
||||
> **Note:** Replace `en0` with your active internet interface. Check with: `route get 8.8.8.8 | grep interface`
|
||||
|
||||
## Making It Persistent
|
||||
|
||||
These settings reset on reboot. To make them permanent:
|
||||
|
||||
**IP Forwarding** — add to `/etc/sysctl.conf`:
|
||||
```
|
||||
net.inet.ip.forwarding=1
|
||||
```
|
||||
|
||||
**NAT Rules** — add to `/etc/pf.conf` (before any existing rules):
|
||||
```
|
||||
nat on en0 from 192.168.64.0/24 to any -> (en0)
|
||||
```
|
||||
|
||||
Then reload: `sudo pfctl -f /etc/pf.conf`
|
||||
|
||||
## IPv6 DNS Issue
|
||||
|
||||
By default, DNS resolvers return IPv6 (AAAA) records before IPv4 (A) records. Since our NAT only handles IPv4, Node.js applications inside containers will try IPv6 first and fail.
|
||||
|
||||
The container image and runner are configured to prefer IPv4 via:
|
||||
```
|
||||
NODE_OPTIONS=--dns-result-order=ipv4first
|
||||
```
|
||||
|
||||
This is set both in the `Dockerfile` and passed via `-e` flag in `container-runner.ts`.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Check IP forwarding is enabled
|
||||
sysctl net.inet.ip.forwarding
|
||||
# Expected: net.inet.ip.forwarding: 1
|
||||
|
||||
# Test container internet access
|
||||
container run --rm --entrypoint curl nanoclaw-agent:latest \
|
||||
-s4 --connect-timeout 5 -o /dev/null -w "%{http_code}" https://api.anthropic.com
|
||||
# Expected: 404
|
||||
|
||||
# Check bridge interface (only exists when a container is running)
|
||||
ifconfig bridge100
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| `curl: (28) Connection timed out` | IP forwarding disabled | `sudo sysctl -w net.inet.ip.forwarding=1` |
|
||||
| HTTP works, HTTPS times out | IPv6 DNS resolution | Add `NODE_OPTIONS=--dns-result-order=ipv4first` |
|
||||
| `Could not resolve host` | DNS not forwarded | Check bridge100 exists, verify pfctl NAT rules |
|
||||
| Container hangs after output | Missing `process.exit(0)` in agent-runner | Rebuild container image |
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Container VM (192.168.64.x)
|
||||
│
|
||||
├── eth0 → gateway 192.168.64.1
|
||||
│
|
||||
bridge100 (192.168.64.1) ← host bridge, created by vmnet when container runs
|
||||
│
|
||||
├── IP forwarding (sysctl) routes packets from bridge100 → en0
|
||||
│
|
||||
├── NAT (pfctl) masquerades 192.168.64.0/24 → en0's IP
|
||||
│
|
||||
en0 (your WiFi/Ethernet) → Internet
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [apple/container#469](https://github.com/apple/container/issues/469) — No network from container on macOS 26
|
||||
- [apple/container#656](https://github.com/apple/container/issues/656) — Cannot access internet URLs during building
|
||||
@@ -6,8 +6,5 @@ The files in this directory are original design documents and developer referenc
|
||||
|
||||
| This directory | Documentation site |
|
||||
|---|---|
|
||||
| [SPEC.md](SPEC.md) | [Architecture](https://docs.nanoclaw.dev/concepts/architecture) |
|
||||
| [SECURITY.md](SECURITY.md) | [Security model](https://docs.nanoclaw.dev/concepts/security) |
|
||||
| [REQUIREMENTS.md](REQUIREMENTS.md) | [Introduction](https://docs.nanoclaw.dev/introduction) |
|
||||
| [docker-sandboxes.md](docker-sandboxes.md) | [Docker Sandboxes](https://docs.nanoclaw.dev/advanced/docker-sandboxes) |
|
||||
| [APPLE-CONTAINER-NETWORKING.md](APPLE-CONTAINER-NETWORKING.md) | [Container runtime](https://docs.nanoclaw.dev/advanced/container-runtime) |
|
||||
|
||||
+106
-60
@@ -1,70 +1,106 @@
|
||||
# NanoClaw Security Model
|
||||
|
||||
> The canonical, continuously-verified version of this model lives at
|
||||
> [docs.nanoclaw.dev/concepts/security](https://docs.nanoclaw.dev/concepts/security).
|
||||
> This in-repo copy can drift; if the two disagree, verify against
|
||||
> `src/container-runner.ts` (`buildMounts`).
|
||||
|
||||
## Trust Model
|
||||
|
||||
Privilege is **user-level**, persisted in the `user_roles` table (owner /
|
||||
admin, global or scoped to an agent group) plus `agent_group_members` (the
|
||||
unprivileged access gate).
|
||||
|
||||
| Entity | Trust Level | Rationale |
|
||||
|--------|-------------|-----------|
|
||||
| Main group | Trusted | Private self-chat, admin control |
|
||||
| Non-main groups | Untrusted | Other users may be malicious |
|
||||
| Container agents | Sandboxed | Isolated execution environment |
|
||||
| Incoming messages | User input | Potential prompt injection |
|
||||
| Owners / admins (`user_roles`) | Trusted | Hold owner/admin roles; gate admin commands and approve credentialed actions |
|
||||
| Group members (`agent_group_members`) | Access-gated | Membership grants access to an agent group, but their messages are still untrusted input |
|
||||
| Unregistered senders | Untrusted | Subject to each messaging group's `unknown_sender_policy` |
|
||||
| Agent containers | Sandboxed | Long-lived per-session container; isolated by mounts, non-root, no host reach |
|
||||
| Incoming messages | User input | Potential prompt injection regardless of who sent them |
|
||||
|
||||
## Security Boundaries
|
||||
|
||||
### 1. Container Isolation (Primary Boundary)
|
||||
|
||||
Agents execute in containers (lightweight Linux VMs), providing:
|
||||
- **Process isolation** - Container processes cannot affect the host
|
||||
- **Filesystem isolation** - Only explicitly mounted directories are visible
|
||||
- **Non-root execution** - Runs as unprivileged `node` user (uid 1000)
|
||||
- **Ephemeral containers** - Fresh environment per invocation (`--rm`)
|
||||
Agents execute in containers (Docker), providing:
|
||||
- **Process isolation** — container processes cannot affect the host
|
||||
- **Filesystem isolation** — only explicitly mounted directories are visible
|
||||
- **Non-root execution** — runs as an unprivileged user (`node`, uid 1000, or the host uid remapped in)
|
||||
- **Per-session containers** — one long-lived container per session polls that session's DBs and handles many messages, then is torn down (`--rm`) when the session goes idle.
|
||||
|
||||
This is the primary security boundary. Rather than relying on application-level permission checks, the attack surface is limited by what's mounted.
|
||||
This is the primary security boundary. Rather than relying on application-level
|
||||
permission checks, the attack surface is limited by what's mounted.
|
||||
|
||||
### 2. Mount Security
|
||||
|
||||
**External Allowlist** - Mount permissions stored at `~/.config/nanoclaw/mount-allowlist.json`, which is:
|
||||
- Outside project root
|
||||
- Never mounted into containers
|
||||
- Cannot be modified by agents
|
||||
`buildMounts` (`src/container-runner.ts`) composes a fixed set of mounts per
|
||||
spawn. For the default (Claude) provider these are:
|
||||
|
||||
**Default Blocked Patterns:**
|
||||
| Container path | Host source | Mode | Purpose |
|
||||
|---|---|---|---|
|
||||
| `/workspace` | `data/v2-sessions/<group>/<session>/` | RW | Session folder — `inbound.db`, `outbound.db`, `outbox/`, `.claude/` |
|
||||
| `/workspace/agent` | `groups/<folder>/` | RW | Agent group working files + `CLAUDE.local.md` |
|
||||
| `/workspace/agent/container.json` | group `container.json` | RO | Container config — readable, not writable |
|
||||
| `/workspace/agent/CLAUDE.md` | composed `CLAUDE.md` | RO | Regenerated every spawn; agent edits would be clobbered |
|
||||
| `/workspace/agent/.claude-fragments` | group `.claude-fragments/` | RO | Composer skill/MCP fragments |
|
||||
| `/app/CLAUDE.md` | `container/CLAUDE.md` | RO | Shared base doc imported by the composed entry point |
|
||||
| `/home/node/.claude` | `data/v2-sessions/<group>/.claude-shared/` | RW | Claude state, settings, skill symlinks |
|
||||
| `/app/src` | `container/agent-runner/src/` | RO | Shared agent-runner source (same for all groups) |
|
||||
| `/app/skills` | `container/skills/` | RO | Shared container skills |
|
||||
| `/workspace/extra/<name>` | allowlisted host dir | RO (RW only if allowed) | Operator-configured additional mounts |
|
||||
|
||||
The config mounts (`container.json`, `CLAUDE.md`, `.claude-fragments`) are
|
||||
**nested read-only mounts on top of the read-write group dir** — the agent can
|
||||
read its config but cannot modify it. The project root is **never mounted**: the
|
||||
container only ever sees the paths above plus any provider-contributed mounts
|
||||
(e.g. an OpenCode XDG dir). Host application source (`src/`, `dist/`,
|
||||
`package.json`) is not reachable.
|
||||
|
||||
**Additional-mount allowlist** — extra mounts from a group's container config
|
||||
are validated against an allowlist at `~/.config/nanoclaw/mount-allowlist.json`,
|
||||
which is:
|
||||
- Outside the project root
|
||||
- Never mounted into containers
|
||||
- Not modifiable by agents
|
||||
|
||||
Its schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"allowedRoots": [
|
||||
{ "path": "~/projects", "allowReadWrite": true, "description": "Dev projects" },
|
||||
{ "path": "~/Documents/work", "allowReadWrite": false, "description": "Read-only" }
|
||||
],
|
||||
"blockedPatterns": ["password", "secret", "token"]
|
||||
}
|
||||
```
|
||||
.ssh, .gnupg, .aws, .azure, .gcloud, .kube, .docker,
|
||||
credentials, .env, .netrc, .npmrc, id_rsa, id_ed25519,
|
||||
|
||||
**Default blocked patterns** (merged with any in the file):
|
||||
```
|
||||
.ssh, .gnupg, .gpg, .aws, .azure, .gcloud, .kube, .docker,
|
||||
credentials, .env, .netrc, .npmrc, .pypirc, id_rsa, id_ed25519,
|
||||
private_key, .secret
|
||||
```
|
||||
|
||||
**Protections:**
|
||||
- Symlink resolution before validation (prevents traversal attacks)
|
||||
- Container path validation (rejects `..` and absolute paths)
|
||||
- `nonMainReadOnly` option forces read-only for non-main groups
|
||||
|
||||
**Read-Only Project Root:**
|
||||
|
||||
The main group's project root is mounted read-only. Writable paths the agent needs (store, group folder, IPC, `.claude/`) are mounted separately. This prevents the agent from modifying host application code (`src/`, `dist/`, `package.json`, etc.) which would bypass the sandbox entirely on next restart. The `store/` directory is mounted read-write so the main agent can access the SQLite database directly.
|
||||
**Enforcement** (`src/modules/mount-security/index.ts`):
|
||||
- **No allowlist file ⇒ every additional mount is blocked** — the fixed mounts above are unaffected, but nothing extra is granted until the operator creates the file.
|
||||
- Symlinks are resolved to their real path (`realpathSync`) before any check, defeating traversal via symlink.
|
||||
- The real path is rejected if it matches a blocked pattern, and rejected unless it sits under one of `allowedRoots`.
|
||||
- The container path is validated: relative, non-empty, no `..`, no leading `/`, no `:` (blocks Docker `-v` option injection). It is mounted under `/workspace/extra/`.
|
||||
- **Read-write is granted only when the mount requests it (`readonly: false`) *and* the matched root has `allowReadWrite: true`.** Otherwise the mount is forced read-only.
|
||||
|
||||
### 3. Session Isolation
|
||||
|
||||
Each group has isolated Claude sessions at `data/sessions/{group}/.claude/`:
|
||||
- Groups cannot see other groups' conversation history
|
||||
- Session data includes full message history and file contents read
|
||||
- Prevents cross-group information disclosure
|
||||
Per-session state lives under `data/v2-sessions/<agent-group>/<session>/`
|
||||
(`inbound.db`, `outbound.db`, `outbox/`, `.claude/`). Claude state
|
||||
(`.claude-shared`) and the working folder are scoped to the agent group, so:
|
||||
- Different agent groups cannot see each other's conversation history or files.
|
||||
- A group's sessions share that group's memory but keep separate message DBs.
|
||||
|
||||
### 4. IPC Authorization
|
||||
This prevents cross-group information disclosure.
|
||||
|
||||
Messages and task operations are verified against group identity:
|
||||
|
||||
| Operation | Main Group | Non-Main Group |
|
||||
|-----------|------------|----------------|
|
||||
| Send message to own chat | ✓ | ✓ |
|
||||
| Send message to other chats | ✓ | ✗ |
|
||||
| Schedule task for self | ✓ | ✓ |
|
||||
| Schedule task for others | ✓ | ✗ |
|
||||
| View all tasks | ✓ | Own only |
|
||||
| Manage other groups | ✓ | ✗ |
|
||||
|
||||
### 5. Credential Isolation (OneCLI Agent Vault)
|
||||
### 4. Credential Isolation (OneCLI Agent Vault)
|
||||
|
||||
Real API credentials **never enter containers**. NanoClaw uses [OneCLI's Agent Vault](https://github.com/onecli/onecli) to proxy outbound requests and inject credentials at the gateway level.
|
||||
|
||||
@@ -77,13 +113,12 @@ Real API credentials **never enter containers**. NanoClaw uses [OneCLI's Agent V
|
||||
**Per-agent policies:**
|
||||
Each NanoClaw group gets its own OneCLI agent identity. This allows different credential policies per group (e.g. your sales agent vs. support agent). OneCLI supports rate limits, and time-bound access and approval flows are on the roadmap.
|
||||
|
||||
**NOT Mounted:**
|
||||
- Channel auth sessions (`store/auth/`) — host only
|
||||
- Mount allowlist — external, never mounted
|
||||
- Any credentials matching blocked patterns
|
||||
- `.env` is shadowed with `/dev/null` in the project root mount
|
||||
**Never on the container filesystem:**
|
||||
- The project root and `.env` — never mounted; the container only receives the paths in the mount table above.
|
||||
- The mount allowlist — external (`~/.config/nanoclaw/…`), never mounted.
|
||||
- Real credentials — injected per request by the OneCLI gateway, never written into any mount.
|
||||
|
||||
### 6. Egress Lockdown (Forced Proxy)
|
||||
### 5. Egress Lockdown (Forced Proxy)
|
||||
|
||||
The `HTTPS_PROXY` env var only redirects *proxy-aware* clients — a tool that
|
||||
ignores it (or a raw socket) could reach the internet directly and bypass
|
||||
@@ -111,31 +146,42 @@ no `host-gateway` route).
|
||||
exception: a heal failure there is logged but not fatal, since already-running
|
||||
agents stay on the internal net (no leak) until the gateway returns.
|
||||
|
||||
**Default: egress is open.** Lockdown is **off** unless you opt in; by default
|
||||
the agent reaches the OneCLI gateway over the host-gateway path and outbound
|
||||
traffic is not confined to the internal network.
|
||||
|
||||
**Configuration:**
|
||||
|
||||
| Env | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `NANOCLAW_EGRESS_LOCKDOWN` | `false` | Set `true` to opt in (otherwise the host-gateway path is used). Enabled automatically by `/add-golden-registry`. |
|
||||
| `NANOCLAW_EGRESS_LOCKDOWN` | `false` | Set `true` to opt in (otherwise the host-gateway path is used). |
|
||||
| `NANOCLAW_EGRESS_NETWORK` | `nanoclaw-egress` | Network name. |
|
||||
| `ONECLI_GATEWAY_CONTAINER` | `onecli` | Gateway container to attach. |
|
||||
|
||||
These variables are read from the **host process** environment (the service's
|
||||
environment / `.env`), not from inside the container. The agent container is
|
||||
started with only `TZ` and any provider-declared variables — host environment
|
||||
variables, including secrets, are never forwarded into the agent.
|
||||
|
||||
**⚠ Behavior when enabled:** with lockdown on, agents have **no direct
|
||||
internet** — all traffic must go through OneCLI. Proxy-aware clients (npm, pnpm,
|
||||
pip, curl, node/bun with the proxy env) are unaffected. Any workflow that relies
|
||||
on a **non-proxy-aware** tool reaching the internet directly will fail by design.
|
||||
Lockdown is **off by default**; opt in with `NANOCLAW_EGRESS_LOCKDOWN=true`.
|
||||
|
||||
## Privilege Comparison
|
||||
## Resource Limits
|
||||
|
||||
| Capability | Main Group | Non-Main Group |
|
||||
|------------|------------|----------------|
|
||||
| Project root access | `/workspace/project` (ro) | None |
|
||||
| Store (SQLite DB) | `/workspace/project/store` (rw) | None |
|
||||
| Group folder | `/workspace/group` (rw) | `/workspace/group` (rw) |
|
||||
| Global memory | Implicit via project | `/workspace/global` (ro) |
|
||||
| Additional mounts | Configurable | Read-only unless allowed |
|
||||
| Network access | Unrestricted | Unrestricted |
|
||||
| MCP tools | All | All |
|
||||
Per-container CPU and memory caps are **opt-in and unset by default** — a runaway
|
||||
agent is not throttled unless the operator configures a limit:
|
||||
|
||||
| Env | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `CONTAINER_CPU_LIMIT` | *(empty — unbounded)* | Passed to `--cpus` when set (e.g. `2`). |
|
||||
| `CONTAINER_MEMORY_LIMIT` | *(empty — unbounded)* | Passed to `--memory` when set (e.g. `8g`). |
|
||||
|
||||
Only `--memory` is a container-level cap; whether it's a *hard* cap depends on
|
||||
the host having no swap (a deployment concern). On a swapless host a runaway is
|
||||
OOM-killed at the limit.
|
||||
|
||||
## Security Architecture Diagram
|
||||
|
||||
@@ -149,7 +195,7 @@ Lockdown is **off by default**; opt in with `NANOCLAW_EGRESS_LOCKDOWN=true`.
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ HOST PROCESS (TRUSTED) │
|
||||
│ • Message routing │
|
||||
│ • IPC authorization │
|
||||
│ • Role / access checks (user_roles, agent_group_members) │
|
||||
│ • Mount validation (external allowlist) │
|
||||
│ • Container lifecycle │
|
||||
│ • OneCLI Agent Vault (injects credentials, enforces policies) │
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# NanoClaw Specification
|
||||
|
||||
> **⚠️ Historical v1 spec.** This document describes the original NanoClaw v1 architecture — the single `store/messages.db`, the file-based IPC watcher, the `task-scheduler.ts` loop, the `MAX_CONCURRENT_CONTAINERS` cap, and the `groups/{channel}_{name}/` folder convention. **None of these exist in v2.** v2 replaced them with the two-DB session split (`inbound.db`/`outbound.db`), the entity model (users → messaging groups → agent groups → sessions), and the system-action delivery path. Kept for reference only. For the current architecture start at [architecture.md](architecture.md) and the root [CLAUDE.md](../CLAUDE.md); the v1→v2 diff is in [v1-to-v2-changes.md](v1-to-v2-changes.md).
|
||||
|
||||
A personal Claude assistant with multi-channel support, persistent memory per conversation, scheduled tasks, and container-isolated agent execution.
|
||||
|
||||
---
|
||||
|
||||
@@ -596,7 +596,7 @@ Schedule a one-shot or recurring task.
|
||||
}
|
||||
```
|
||||
|
||||
Implementation: write a `messages_in` row (to self) with `kind: 'task'`, `process_after`, and optionally `recurrence`. The host sweep picks it up when due.
|
||||
Implementation: the container can't write host-owned `inbound.db`, so this writes a `messages_out` row with `kind: 'system'` and `action: 'schedule_task'` (`container/agent-runner/src/mcp-tools/scheduling.ts`). During delivery the host's action handler (`src/modules/scheduling/actions.ts` → `insertTask()` in `src/modules/scheduling/db.ts`) inserts the `kind: 'task'` row into `inbound.db` with `process_after` and optionally `recurrence`. The host sweep picks it up when due.
|
||||
|
||||
#### list_tasks
|
||||
|
||||
@@ -609,7 +609,7 @@ List active scheduled/recurring tasks.
|
||||
}
|
||||
```
|
||||
|
||||
Implementation: query `messages_in WHERE recurrence IS NOT NULL AND status != 'failed'`.
|
||||
Implementation: a read, not a write — the container may read the read-only `inbound.db` mount directly. Returns one row per series (the live pending/paused occurrence): `SELECT series_id AS id, ... FROM messages_in WHERE kind = 'task' AND status IN ('pending','paused') GROUP BY series_id`. See `container/agent-runner/src/mcp-tools/scheduling.ts`.
|
||||
|
||||
#### cancel_task / pause_task / resume_task / update_task
|
||||
|
||||
@@ -625,7 +625,7 @@ Modify a scheduled task.
|
||||
// update_task: merge { prompt?, recurrence?, processAfter?, script? } into the live row
|
||||
```
|
||||
|
||||
Implementation: cancel/pause/resume update the live row(s) directly. update_task is sent as a system action — the host reads current content, merges supplied fields, and writes back. All four match by `(id = ? OR series_id = ?) AND kind='task' AND status IN ('pending','paused')`, so they reach the live next occurrence of a recurring task even when the agent passes the original (now-completed) id.
|
||||
Implementation: all four are sent as system actions (`messages_out`, `kind: 'system'`, `action: 'cancel_task' | 'pause_task' | 'resume_task' | 'update_task'`) — the container never writes `inbound.db`. The host's handlers in `src/modules/scheduling/actions.ts` apply the change against `inbound.db` via `src/modules/scheduling/db.ts`: cancel/pause/resume flip status on the live row(s); update_task reads current content, merges supplied fields, and writes back. All four match by `(id = ? OR series_id = ?) AND kind='task' AND status IN ('pending','paused')`, so they reach the live next occurrence of a recurring task even when the agent passes the original (now-completed) id.
|
||||
|
||||
#### register_agent_group
|
||||
|
||||
@@ -712,9 +712,9 @@ These are ephemeral to the container's lifetime. When the container is killed an
|
||||
|
||||
The agent-runner receives configuration via:
|
||||
|
||||
- **Environment variables:** `AGENT_PROVIDER` (claude/codex/opencode), `NANOCLAW_ADMIN_USER_ID`, provider-specific vars (API keys, model overrides), `TZ`
|
||||
- **`container.json`:** The provider name, model, assistant name, MCP servers, and other NanoClaw config are read from `/workspace/agent/container.json` (materialized by the host from the `container_configs` table), not from environment variables. See `container/agent-runner/src/config.ts`.
|
||||
- **Environment variables:** provider-specific vars only (API keys, model overrides), `TZ`.
|
||||
- **Fixed mount paths:** Session DB at `/workspace/session.db`. Agent group folder at `/workspace/agent/`. System prompt from `/workspace/agent/CLAUDE.md` and `/workspace/global/CLAUDE.md`.
|
||||
- **Optional startup config:** Some config may be passed as a JSON file at a fixed path (e.g., `/workspace/config.json`) for things like the session ID to resume, assistant name, and admin user ID. This avoids overloading environment variables.
|
||||
|
||||
The agent-runner reads config, creates the provider, and enters the poll loop. No stdin, no initial prompt — messages are already in the session DB.
|
||||
|
||||
@@ -731,7 +731,7 @@ function createProvider(name: ProviderName, config: ProviderConfig): AgentProvid
|
||||
}
|
||||
```
|
||||
|
||||
The provider name comes from the container's environment (`AGENT_PROVIDER` env var), set by the host based on `agent_groups.agent_provider` or `sessions.agent_provider`.
|
||||
The provider name comes from the `provider` key in `/workspace/agent/container.json` (defaulting to `'claude'`), which the host materializes from the `container_configs` table — set it with `ncl groups config update --provider`. It is not an environment variable.
|
||||
|
||||
`ProviderConfig` contains provider-specific settings (API keys, model overrides, etc.) passed via environment variables — not via the interface. Each provider reads what it needs from `env`.
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# NanoClaw Architecture (Draft)
|
||||
|
||||
> **Draft — design intent, not a line-by-line spec.** Some passages predate the current implementation and can drift from it. The root [CLAUDE.md](../CLAUDE.md) and the cited source files (`src/`, `container/agent-runner/src/`) are the source of truth; when this doc and the code disagree, trust the code. Notably, scheduling MCP tools do **not** write `inbound.db` directly — they emit `messages_out` system actions that the host applies (see [agent-runner-details.md](agent-runner-details.md) and `src/modules/scheduling/`).
|
||||
|
||||
## Core Idea
|
||||
|
||||
Each agent session has a mounted SQLite DB. The DB is the one and only IO mechanism between host and container. No IPC files, no stdin piping. Two tables: messages_in (host → agent-runner) and messages_out (agent-runner → host). Everything is a message.
|
||||
@@ -128,7 +130,6 @@ Non-Chat-SDK channels (WhatsApp via Baileys, Gmail, custom integrations) impleme
|
||||
The host is an orchestrator:
|
||||
1. **Spawn** — when wakeUpAgent is called and no container exists for the session
|
||||
2. **Idle kill** — when a container has no unprocessed messages for some timeout period
|
||||
3. **Limits** — MAX_CONCURRENT_CONTAINERS caps active containers
|
||||
|
||||
When a container spins up, the agent-runner immediately starts polling its session DB. Messages are already there waiting.
|
||||
|
||||
@@ -244,7 +245,7 @@ One-shot and recurring tasks use the same tables — no separate scheduler.
|
||||
|
||||
**Active container poll** (~1s) checks the same conditions but only for sessions with running containers.
|
||||
|
||||
**Agent-runner creates schedules** by writing messages_in (to itself) or messages_out (reminders/notifications) with `process_after` and optionally `recurrence`.
|
||||
**Agent-runner creates schedules** by emitting a `messages_out` row with `kind: 'system'` and an `action` (`schedule_task`, `cancel_task`, …) — it cannot write host-owned `inbound.db` directly. The host applies the action during delivery (`src/modules/scheduling/actions.ts`), inserting/updating the `kind: 'task'` `messages_in` row with `process_after` and optionally `recurrence`.
|
||||
|
||||
### messages_in content by kind
|
||||
|
||||
@@ -555,7 +556,7 @@ const DISCORD_TOKEN = process.env.DISCORD_BOT_TOKEN;
|
||||
const GMAIL_CREDS = process.env.GMAIL_CREDENTIALS_PATH;
|
||||
```
|
||||
|
||||
Shared config (DATA_DIR, TIMEZONE, MAX_CONCURRENT_CONTAINERS) stays in `config.ts`. Channel/skill-specific config stays in the module that uses it.
|
||||
Shared config (DATA_DIR, TIMEZONE) stays in `config.ts`. Channel/skill-specific config stays in the module that uses it.
|
||||
|
||||
### Code Style
|
||||
|
||||
@@ -829,7 +830,7 @@ Mixed batches (e.g., a chat message + a system result both pending) are combined
|
||||
|
||||
### MCP Tools
|
||||
|
||||
MCP tools write directly to the session DB.
|
||||
MCP tools write to the container's own `outbound.db`. Anything that needs a change in host-owned `inbound.db` (schedule/cancel/pause/resume/update a task, register a group) is emitted as a `kind: 'system'` `messages_out` action that the host applies during delivery — the container never writes `inbound.db`.
|
||||
|
||||
**Core tools:**
|
||||
|
||||
@@ -837,9 +838,9 @@ MCP tools write directly to the session DB.
|
||||
|------|-------------|
|
||||
| `send_message` | Write `messages_out` row, `kind: 'chat'` |
|
||||
| `send_file` | Move file to `outbox/{msg_id}/`, write `messages_out` with filenames |
|
||||
| `schedule_task` | Write `messages_in` row (to self) with `process_after` + `recurrence`. Or `messages_out` with `deliver_after` for outbound reminders. |
|
||||
| `list_tasks` | Query `messages_in WHERE recurrence IS NOT NULL` |
|
||||
| `pause_task` / `resume_task` / `cancel_task` | Modify `messages_in` rows (update status, clear/set recurrence) |
|
||||
| `schedule_task` | Write `messages_out`, `kind: 'system'`, `action: 'schedule_task'`; host inserts the `kind: 'task'` `messages_in` row with `process_after` + optional `recurrence` |
|
||||
| `list_tasks` | Read `messages_in` (read-only mount) — one row per series: `kind = 'task' AND status IN ('pending','paused') GROUP BY series_id` |
|
||||
| `pause_task` / `resume_task` / `cancel_task` | Write `messages_out`, `kind: 'system'`, matching `action`; host updates the live `messages_in` row(s) |
|
||||
| `register_agent_group` | Write `messages_out`, `kind: 'system'`, `action: 'register_agent_group'` |
|
||||
|
||||
**New tools:**
|
||||
|
||||
+2
-2
@@ -17,7 +17,7 @@ data/v2-sessions/<agent_group_id>/<session_id>/
|
||||
outbox/<message_id>/ ← attachments the agent produced
|
||||
```
|
||||
|
||||
One session = one folder = one pair of DBs. The `agent_group_id` parent directory also holds per-group state (`.claude-shared/`, `agent-runner-src/`) that is shared across every session of that agent group.
|
||||
One session = one folder = one pair of DBs. The `agent_group_id` parent directory also holds per-group state (`.claude-shared/`) that is shared across every session of that agent group. (The agent-runner source is not copied per group — it's a shared read-only mount from `container/agent-runner/src` into every container; see `src/container-runner.ts`.)
|
||||
|
||||
Path helpers in `src/session-manager.ts`: `sessionDir()`, `inboundDbPath()`, `outboundDbPath()`, `heartbeatPath()`.
|
||||
|
||||
@@ -55,7 +55,7 @@ CREATE INDEX idx_messages_in_series ON messages_in(series_id);
|
||||
|
||||
Content shapes: see [api-details.md §Session DB Schema Details](api-details.md#session-db-schema-details).
|
||||
|
||||
**Writers (host):** `insertMessage()`, `insertTask()`, `insertRecurrence()` — all in `src/db/session-db.ts`. Each calls `nextEvenSeq()`.
|
||||
**Writers (host):** `insertMessage()` (and `nextEvenSeq()`) in `src/db/session-db.ts`; `insertTask()` and `insertRecurrence()` in `src/modules/scheduling/db.ts`. Each calls `nextEvenSeq()`.
|
||||
**Reader (container):** `container/agent-runner/src/db/messages-in.ts` — polls `status='pending' AND (process_after IS NULL OR process_after <= now)`.
|
||||
|
||||
### 2.2 `delivered`
|
||||
|
||||
@@ -35,7 +35,6 @@ data/
|
||||
v2-sessions/
|
||||
<agent_group_id>/
|
||||
.claude-shared/ ← shared Claude state for the agent group
|
||||
agent-runner-src/ ← per-group agent-runner overlay
|
||||
<session_id>/
|
||||
inbound.db ← host writes, container reads
|
||||
outbound.db ← container writes, host reads
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
# Running NanoClaw in Docker Sandboxes (Manual Setup)
|
||||
|
||||
This guide walks through setting up NanoClaw inside a [Docker Sandbox](https://docs.docker.com/ai/sandboxes/) from scratch — no install script, no pre-built fork. You'll clone the upstream repo, apply the necessary patches, and have agents running in full hypervisor-level isolation.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Host (macOS / Windows WSL)
|
||||
└── Docker Sandbox (micro VM with isolated kernel)
|
||||
├── NanoClaw process (Node.js)
|
||||
│ ├── Channel adapters (WhatsApp, Telegram, etc.)
|
||||
│ └── Container spawner → nested Docker daemon
|
||||
└── Docker-in-Docker
|
||||
└── nanoclaw-agent containers
|
||||
└── Claude Agent SDK
|
||||
```
|
||||
|
||||
Each agent runs in its own container, inside a micro VM that is fully isolated from your host. Two layers of isolation: per-agent containers + the VM boundary.
|
||||
|
||||
The sandbox provides a MITM proxy at `host.docker.internal:3128` that handles network access and injects your Anthropic API key automatically.
|
||||
|
||||
> **Note:** This guide is based on a validated setup running on macOS (Apple Silicon) with WhatsApp. Other channels (Telegram, Slack, etc.) and environments (Windows WSL) may require additional proxy patches for their specific HTTP/WebSocket clients. The core patches (container runner, credential proxy, Dockerfile) apply universally — channel-specific proxy configuration varies.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Docker Desktop v4.40+** with Sandbox support
|
||||
- **Anthropic API key** (the sandbox proxy manages injection)
|
||||
- For **Telegram**: a bot token from [@BotFather](https://t.me/BotFather) and your chat ID
|
||||
- For **WhatsApp**: a phone with WhatsApp installed
|
||||
|
||||
Verify sandbox support:
|
||||
```bash
|
||||
docker sandbox version
|
||||
```
|
||||
|
||||
## Step 1: Create the Sandbox
|
||||
|
||||
On your host machine:
|
||||
|
||||
```bash
|
||||
# Create a workspace directory
|
||||
mkdir -p ~/nanoclaw-workspace
|
||||
|
||||
# Create a shell sandbox with the workspace mounted
|
||||
docker sandbox create shell ~/nanoclaw-workspace
|
||||
```
|
||||
|
||||
If you're using WhatsApp, configure proxy bypass so WhatsApp's Noise protocol isn't MITM-inspected:
|
||||
|
||||
```bash
|
||||
docker sandbox network proxy shell-nanoclaw-workspace \
|
||||
--bypass-host web.whatsapp.com \
|
||||
--bypass-host "*.whatsapp.com" \
|
||||
--bypass-host "*.whatsapp.net"
|
||||
```
|
||||
|
||||
Telegram does not need proxy bypass.
|
||||
|
||||
Enter the sandbox:
|
||||
```bash
|
||||
docker sandbox run shell-nanoclaw-workspace
|
||||
```
|
||||
|
||||
## Step 2: Install Prerequisites
|
||||
|
||||
Inside the sandbox:
|
||||
|
||||
```bash
|
||||
sudo apt-get update && sudo apt-get install -y build-essential python3
|
||||
npm config set strict-ssl false
|
||||
```
|
||||
|
||||
## Step 3: Clone and Install NanoClaw
|
||||
|
||||
NanoClaw must live inside the workspace directory — Docker-in-Docker can only bind-mount from the shared workspace path.
|
||||
|
||||
```bash
|
||||
# Clone to home first (virtiofs can corrupt git pack files during clone)
|
||||
cd ~
|
||||
git clone https://github.com/nanocoai/nanoclaw.git
|
||||
|
||||
# Replace with YOUR workspace path (the host path you passed to `docker sandbox create`)
|
||||
WORKSPACE=/Users/you/nanoclaw-workspace
|
||||
|
||||
# Move into workspace so DinD mounts work
|
||||
mv nanoclaw "$WORKSPACE/nanoclaw"
|
||||
cd "$WORKSPACE/nanoclaw"
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
pnpm install https-proxy-agent
|
||||
```
|
||||
|
||||
## Step 4: Apply Proxy and Sandbox Patches
|
||||
|
||||
NanoClaw needs several patches to work inside a Docker Sandbox. These handle proxy routing, CA certificates, and Docker-in-Docker mount restrictions.
|
||||
|
||||
### 4a. Dockerfile — proxy args for container image build
|
||||
|
||||
`pnpm install` inside `docker build` fails with `SELF_SIGNED_CERT_IN_CHAIN` because the sandbox's MITM proxy presents its own certificate. Add proxy build args to `container/Dockerfile`:
|
||||
|
||||
Add these lines after the `FROM` line:
|
||||
|
||||
```dockerfile
|
||||
# Accept proxy build args
|
||||
ARG http_proxy
|
||||
ARG https_proxy
|
||||
ARG no_proxy
|
||||
ARG NODE_EXTRA_CA_CERTS
|
||||
ARG npm_config_strict_ssl=true
|
||||
RUN npm config set strict-ssl ${npm_config_strict_ssl}
|
||||
```
|
||||
|
||||
And after the `RUN pnpm install` line:
|
||||
|
||||
```dockerfile
|
||||
RUN npm config set strict-ssl true
|
||||
```
|
||||
|
||||
### 4b. Build script — forward proxy args
|
||||
|
||||
Patch `container/build.sh` to pass proxy env vars to `docker build`:
|
||||
|
||||
Add these `--build-arg` flags to the `docker build` command:
|
||||
|
||||
```bash
|
||||
--build-arg http_proxy="${http_proxy:-$HTTP_PROXY}" \
|
||||
--build-arg https_proxy="${https_proxy:-$HTTPS_PROXY}" \
|
||||
--build-arg no_proxy="${no_proxy:-$NO_PROXY}" \
|
||||
--build-arg npm_config_strict_ssl=false \
|
||||
```
|
||||
|
||||
### 4c. Container runner — proxy forwarding, CA cert mount, /dev/null fix
|
||||
|
||||
Three changes to `src/container-runner.ts`:
|
||||
|
||||
**Replace `/dev/null` shadow mount.** The sandbox rejects `/dev/null` bind mounts. Find where `.env` is shadow-mounted to `/dev/null` and replace it with an empty file:
|
||||
|
||||
```typescript
|
||||
// Create an empty file to shadow .env (Docker Sandbox rejects /dev/null mounts)
|
||||
const emptyEnvPath = path.join(DATA_DIR, 'empty-env');
|
||||
if (!fs.existsSync(emptyEnvPath)) fs.writeFileSync(emptyEnvPath, '');
|
||||
// Use emptyEnvPath instead of '/dev/null' in the mount
|
||||
```
|
||||
|
||||
**Forward proxy env vars** to spawned agent containers. Add `-e` flags for `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY` and their lowercase variants.
|
||||
|
||||
**Mount CA certificate.** If `NODE_EXTRA_CA_CERTS` or `SSL_CERT_FILE` is set, copy the cert into the project directory and mount it into agent containers:
|
||||
|
||||
```typescript
|
||||
const caCertSrc = process.env.NODE_EXTRA_CA_CERTS || process.env.SSL_CERT_FILE;
|
||||
if (caCertSrc) {
|
||||
const certDir = path.join(DATA_DIR, 'ca-cert');
|
||||
fs.mkdirSync(certDir, { recursive: true });
|
||||
fs.copyFileSync(caCertSrc, path.join(certDir, 'proxy-ca.crt'));
|
||||
// Mount: certDir -> /workspace/ca-cert (read-only)
|
||||
// Set NODE_EXTRA_CA_CERTS=/workspace/ca-cert/proxy-ca.crt in the container
|
||||
}
|
||||
```
|
||||
|
||||
### 4d. Container runtime — prevent self-termination
|
||||
|
||||
In `src/container-runtime.ts`, the `cleanupOrphans()` function matches containers by the `nanoclaw-` prefix. Inside a sandbox, the sandbox container itself may match (e.g., `nanoclaw-docker-sandbox`). Filter out the current hostname:
|
||||
|
||||
```typescript
|
||||
// In cleanupOrphans(), filter out os.hostname() from the list of containers to stop
|
||||
```
|
||||
|
||||
### 4e. Credential proxy — route through MITM proxy
|
||||
|
||||
In `src/credential-proxy.ts`, upstream API requests need to go through the sandbox proxy. Add `HttpsProxyAgent` to outbound requests:
|
||||
|
||||
```typescript
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
|
||||
const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy;
|
||||
const upstreamAgent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
|
||||
// Pass upstreamAgent to https.request() options
|
||||
```
|
||||
|
||||
### 4f. Setup script — proxy build args
|
||||
|
||||
Patch `setup/container.ts` to pass the same proxy `--build-arg` flags as `build.sh` (Step 4b).
|
||||
|
||||
## Step 5: Build
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
bash container/build.sh
|
||||
```
|
||||
|
||||
## Step 6: Add a Channel
|
||||
|
||||
### Telegram
|
||||
|
||||
```bash
|
||||
# Apply the Telegram skill
|
||||
pnpm exec tsx scripts/apply-skill.ts .claude/skills/add-telegram
|
||||
|
||||
# Rebuild after applying the skill
|
||||
pnpm run build
|
||||
|
||||
# Configure .env
|
||||
cat > .env << EOF
|
||||
TELEGRAM_BOT_TOKEN=<your-token-from-botfather>
|
||||
ASSISTANT_NAME=nanoclaw
|
||||
ANTHROPIC_API_KEY=proxy-managed
|
||||
EOF
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
|
||||
# Register your chat
|
||||
pnpm exec tsx setup/index.ts --step register \
|
||||
--jid "tg:<your-chat-id>" \
|
||||
--name "My Chat" \
|
||||
--trigger "@nanoclaw" \
|
||||
--folder "telegram_main" \
|
||||
--channel telegram \
|
||||
--assistant-name "nanoclaw" \
|
||||
--is-main \
|
||||
--no-trigger-required
|
||||
```
|
||||
|
||||
**To find your chat ID:** Send any message to your bot, then:
|
||||
```bash
|
||||
curl -s --proxy $HTTPS_PROXY "https://api.telegram.org/bot<TOKEN>/getUpdates" | python3 -m json.tool
|
||||
```
|
||||
|
||||
**Telegram in groups:** Disable Group Privacy in @BotFather (`/mybots` > Bot Settings > Group Privacy > Turn off), then remove and re-add the bot.
|
||||
|
||||
**Important:** If the Telegram skill creates `src/channels/telegram.ts`, you'll need to patch it for proxy support. Add an `HttpsProxyAgent` and pass it to grammy's `Bot` constructor via `baseFetchConfig.agent`. Then rebuild.
|
||||
|
||||
### WhatsApp
|
||||
|
||||
Make sure you configured proxy bypass in [Step 1](#step-1-create-the-sandbox) first.
|
||||
|
||||
```bash
|
||||
# Apply the WhatsApp skill
|
||||
pnpm exec tsx scripts/apply-skill.ts .claude/skills/add-whatsapp
|
||||
|
||||
# Rebuild
|
||||
pnpm run build
|
||||
|
||||
# Configure .env
|
||||
cat > .env << EOF
|
||||
ASSISTANT_NAME=nanoclaw
|
||||
ANTHROPIC_API_KEY=proxy-managed
|
||||
EOF
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
|
||||
# Authenticate (choose one):
|
||||
|
||||
# QR code — scan with WhatsApp camera:
|
||||
pnpm exec tsx src/whatsapp-auth.ts
|
||||
|
||||
# OR pairing code — enter code in WhatsApp > Linked Devices > Link with phone number:
|
||||
pnpm exec tsx src/whatsapp-auth.ts --pairing-code --phone <phone-number-no-plus>
|
||||
|
||||
# Register your chat (JID = your phone number + @s.whatsapp.net)
|
||||
pnpm exec tsx setup/index.ts --step register \
|
||||
--jid "<phone>@s.whatsapp.net" \
|
||||
--name "My Chat" \
|
||||
--trigger "@nanoclaw" \
|
||||
--folder "whatsapp_main" \
|
||||
--channel whatsapp \
|
||||
--assistant-name "nanoclaw" \
|
||||
--is-main \
|
||||
--no-trigger-required
|
||||
```
|
||||
|
||||
**Important:** The WhatsApp skill files (`src/channels/whatsapp.ts` and `src/whatsapp-auth.ts`) also need proxy patches — add `HttpsProxyAgent` for WebSocket connections and a proxy-aware version fetch. Then rebuild.
|
||||
|
||||
### Both Channels
|
||||
|
||||
Apply both skills, patch both for proxy support, combine the `.env` variables, and register each chat separately.
|
||||
|
||||
## Step 7: Run
|
||||
|
||||
```bash
|
||||
pnpm start
|
||||
```
|
||||
|
||||
You don't need to set `ANTHROPIC_API_KEY` manually. The sandbox proxy intercepts requests and replaces `proxy-managed` with your real key automatically.
|
||||
|
||||
## Networking Details
|
||||
|
||||
### How the proxy works
|
||||
|
||||
All traffic from the sandbox routes through the host proxy at `host.docker.internal:3128`:
|
||||
|
||||
```
|
||||
Agent container → DinD bridge → Sandbox VM → host.docker.internal:3128 → Host proxy → api.anthropic.com
|
||||
```
|
||||
|
||||
**"Bypass" does not mean traffic skips the proxy.** It means the proxy passes traffic through without MITM inspection. Node.js doesn't automatically use `HTTP_PROXY` env vars — you need explicit `HttpsProxyAgent` configuration in every HTTP/WebSocket client.
|
||||
|
||||
### Shared paths for DinD mounts
|
||||
|
||||
Only the workspace directory is available for Docker-in-Docker bind mounts. Paths outside the workspace fail with "path not shared":
|
||||
- `/dev/null` → replace with an empty file in the project dir
|
||||
- `/usr/local/share/ca-certificates/` → copy cert to project dir
|
||||
- `/home/agent/` → clone to workspace instead
|
||||
|
||||
### Git clone and virtiofs
|
||||
|
||||
The workspace is mounted via virtiofs. Git's pack file handling can corrupt over virtiofs during clone. Workaround: clone to `/home/agent` first, then `mv` into the workspace.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### pnpm install fails with SELF_SIGNED_CERT_IN_CHAIN
|
||||
```bash
|
||||
npm config set strict-ssl false
|
||||
```
|
||||
|
||||
### Container build fails with proxy errors
|
||||
```bash
|
||||
docker build \
|
||||
--build-arg http_proxy=$http_proxy \
|
||||
--build-arg https_proxy=$https_proxy \
|
||||
-t nanoclaw-agent:latest container/
|
||||
```
|
||||
|
||||
### Agent containers fail with "path not shared"
|
||||
All bind-mounted paths must be under the workspace directory. Check:
|
||||
- Is NanoClaw cloned into the workspace? (not `/home/agent/`)
|
||||
- Is the CA cert copied to the project root?
|
||||
- Has the empty `.env` shadow file been created?
|
||||
|
||||
### Agent containers can't reach Anthropic API
|
||||
Verify proxy env vars are forwarded to agent containers. Check container logs for `HTTP_PROXY=http://host.docker.internal:3128`.
|
||||
|
||||
### WhatsApp error 405
|
||||
The version fetch is returning a stale version. Make sure the proxy-aware `fetchWaVersionViaProxy` patch is applied — it fetches `sw.js` through `HttpsProxyAgent` and parses `client_revision`.
|
||||
|
||||
### WhatsApp "Connection failed" immediately
|
||||
Proxy bypass not configured. From the **host**, run:
|
||||
```bash
|
||||
docker sandbox network proxy <sandbox-name> \
|
||||
--bypass-host web.whatsapp.com \
|
||||
--bypass-host "*.whatsapp.com" \
|
||||
--bypass-host "*.whatsapp.net"
|
||||
```
|
||||
|
||||
### Telegram bot doesn't receive messages
|
||||
1. Check the grammy proxy patch is applied (look for `HttpsProxyAgent` in `src/channels/telegram.ts`)
|
||||
2. Check Group Privacy is disabled in @BotFather if using in groups
|
||||
|
||||
### Git clone fails with "inflate: data stream error"
|
||||
Clone to a non-workspace path first, then move:
|
||||
```bash
|
||||
cd ~ && git clone https://github.com/nanocoai/nanoclaw.git && mv nanoclaw /path/to/workspace/nanoclaw
|
||||
```
|
||||
|
||||
### WhatsApp QR code doesn't display
|
||||
Run the auth command interactively inside the sandbox (not piped through `docker sandbox exec`):
|
||||
```bash
|
||||
docker sandbox run shell-nanoclaw-workspace
|
||||
# Then inside:
|
||||
pnpm exec tsx src/whatsapp-auth.ts
|
||||
```
|
||||
+1
-1
@@ -193,7 +193,7 @@ leaking the token to disk outweighs the debugging value.
|
||||
| `setup/logs.ts` | The logging primitives (`logStep`, `logUserInput`, `logComplete`, `stepRawLog`, `initSetupLog`). Single source of truth for level 2/3 formatting and file paths. |
|
||||
| `setup/<step>.ts` | Individual step implementations. Must emit one terminal status block; must not write directly to the terminal. |
|
||||
| `setup/register-claude-token.sh` | The Anthropic exception. Inherits stdio, prints its own UI, returns a status to the driver. |
|
||||
| `setup/add-telegram.sh` | Non-interactive adapter installer. Reads `TELEGRAM_BOT_TOKEN` from env; never prompts. User-facing bits live in `auto.ts`. |
|
||||
| `setup/channels/telegram.ts` | Telegram channel flow. Installs the adapter in-process by applying the `/add-telegram` skill (directive engine; SKILL.md is the single source of truth), feeding the collected bot token to the skill's `bot_token` prompt var. |
|
||||
| `setup/pair-telegram.ts` | Emits `PAIR_TELEGRAM_CODE` / `PAIR_TELEGRAM_ATTEMPT` / `PAIR_TELEGRAM` status blocks. Never prints UI. The driver renders it via clack notes. |
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
@@ -0,0 +1,646 @@
|
||||
# The skill-engine seam: declare/emit vs. acquire/present
|
||||
|
||||
Status: SPEC — approved boundary decision, pre-implementation, review findings folded. Branch: `feat/structured-skill-format` (pre-merge; clean break, no compat shims).
|
||||
|
||||
## 1. The boundary rule
|
||||
|
||||
> **The engine may DECLARE needs and EMIT events; it may never ACQUIRE input or PRESENT anything.**
|
||||
|
||||
`scripts/skill-apply.ts` accumulated interactive-setup-wizard concerns in its core
|
||||
contract: a `Prompter` with `tell`/`confirm`/`open`, authored presentation attrs
|
||||
(`gate`, `open:`, `min:`, `error:`, `label:`, `on-fail:`), and a `StepReporter`
|
||||
shaped around a clack spinner. That couples the deterministic applier to one
|
||||
consumer (the setup wizard) when there are three:
|
||||
|
||||
1. **wizard** — interactive setup (`setup/lib/skill-driver.ts` + `setup/channels/run-channel-skill.ts`, clack UI)
|
||||
2. **agent-relay** — a coding agent driving a skill conversationally over chat
|
||||
3. **pipeline** — CI/CD & customer deployments: inputs from env, no human, `operatorMessages` consumed as a "manual steps" report
|
||||
|
||||
Declaration & semantics (what a value must look like, what a step is, what the
|
||||
human must be told) = **core**. Acquisition & presentation (how the value is
|
||||
collected, how the message is rendered, when to pause) = **consumer**. The
|
||||
rule binds only the core: a driver may define whatever interaction types it
|
||||
wants on its side of the seam.
|
||||
|
||||
### Invariants (non-negotiable)
|
||||
|
||||
- **Prose-primary / oblivious-to-auto-apply**: with `nc:` fences stripped, every SKILL.md reads as a normal skill. Never narrate the engine.
|
||||
- **Degrade-to-agent**: anything the engine can't do bounces to an `agentTask` — never a crash, never a silent drop.
|
||||
- **Option A split untouched**: no change to any resolve/wire logic; every `platform_id` / `user_id` byte produced by the **production code paths** is identical. The Option-A test (`setup/channels/run-channel-skill.test.ts:24-70`) keeps its assertion structure, but its fixture credentials MUST be updated to valid-shaped values in the same step that lands validate-at-bind (§4): today's `signing_secret: 's'` fails add-slack's `^[a-fA-F0-9]{16,}$` and `owner_handle: 'U1'` fails `^U[A-Z0-9]{8,}$` (both bypass validation only because inputs bypass it today). The `userId` assertion updates consistently (e.g. `owner_handle: 'U12345678'` ⇒ `'slack:U12345678'`). Byte-parity is a claim about production code, not about test-fixture literals shaped to exploit the removed bypass.
|
||||
- **Coverage parity**: suite is 680 passed | 1 skipped today. Seam-touching tests are reworked *at the seam*; gate/open attr tests become driver-policy tests proving the parity claims in §5.
|
||||
- **Never stage/commit** the pre-existing unrelated local changes: `package.json`, `pnpm-lock.yaml`, `src/channels/index.ts`, `src/providers/claude.ts`, `src/providers/index.ts` — nor untracked files this work did not create.
|
||||
|
||||
## 2. The new core interface
|
||||
|
||||
The entire interaction surface of the engine, after the refactor
|
||||
(`scripts/skill-apply.ts`):
|
||||
|
||||
```ts
|
||||
// What an nc:prompt declares about the value it needs. Passed to resolveInput
|
||||
// so a consumer can run its OWN re-ask loop (clack validate, a chat exchange).
|
||||
export interface InputMeta {
|
||||
question: string; // the prompt body (verbatim)
|
||||
secret: boolean; // consumer must mask
|
||||
validate?: string; // regex source (nc:prompt validate:<re>)
|
||||
flags?: string; // regex flags (nc:prompt flags:<f>)
|
||||
normalize?: 'trim' | 'rstrip-slash' | 'lower'; // applied by the ENGINE at bind
|
||||
}
|
||||
|
||||
// Everything the engine emits. `onEvent` is AWAITED before the engine
|
||||
// proceeds — that ordering guarantee is what lets a consumer implement
|
||||
// gating (hold the operator event until the human confirms readiness).
|
||||
export type ApplyEvent =
|
||||
| { type: 'step-start'; kind: string; line: number; label: string | null }
|
||||
| { type: 'step-end'; kind: string; line: number; label: string | null;
|
||||
ok: boolean; durationMs: number; error?: string }
|
||||
| { type: 'operator'; line: number; text: string };
|
||||
// text = the rendered, {{var}}-substituted block body;
|
||||
// line = the directive's opening-fence line (keys driver policy maps)
|
||||
|
||||
export interface ApplyOptions {
|
||||
// Pre-supplied answers (var → value). Checked FIRST. Unchanged.
|
||||
inputs?: Record<string, string>;
|
||||
// Replaces Prompter.ask. undefined ⇒ defer (unchanged semantics).
|
||||
resolveInput?: (name: string, meta: InputMeta) => Promise<string | undefined>;
|
||||
// Unchanged.
|
||||
exec?: (cmd: string) => string | void | Promise<string | void>;
|
||||
// Unchanged.
|
||||
execStream?: (cmd: string) => Promise<StepOutcome>;
|
||||
// Unchanged.
|
||||
skipEffects?: string[];
|
||||
// Unchanged.
|
||||
resolveRemote?: (branch: string) => string;
|
||||
// Replaces BOTH StepReporter and Prompter.tell. Awaited before proceeding.
|
||||
onEvent?: (e: ApplyEvent) => void | Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
**Gone from the core entirely**: `Prompter` (ask/tell/confirm/open), `PromptOpts`,
|
||||
`StepReporter`, `ApplyOptions.prompter`, `ApplyOptions.reporter`.
|
||||
|
||||
**`ApplyResult` — unchanged fields**: `applied`, `skipped`, `agentTasks`,
|
||||
`operatorMessages` (still collected in the result — the pipeline reads them
|
||||
there; the `operator` *event* is for live rendering), `vars`, `journal`,
|
||||
`referenceProse`. `fullyApplied()` and `firstFailureHint()` unchanged.
|
||||
Two adjustments:
|
||||
|
||||
- `deferred: string[]` — same field, one new entry form: an input rejected by
|
||||
validate-at-bind is recorded as `` `<var>: invalid value (does not match validate:<re>)` ``
|
||||
(see §4). Missing-input entries stay the bare var name; unresolved-`{{var}}`
|
||||
entries stay the thrown message (`skill-apply.ts:841` today).
|
||||
- `AgentTask.hint` is **dropped** (it existed only for `on-fail:`; with that attr
|
||||
gone, hint ≡ prose). `firstFailureHint` reads `prose` directly.
|
||||
|
||||
**Derived metadata stays core** (exposure, not authored presentation):
|
||||
`stepLabel` (heading-derived only — the `label:` attr override at
|
||||
`skill-apply.ts:458` is removed), `AgentTask` prose/`reason` (proseFor-derived),
|
||||
`firstFailureHint`, `referenceProse`, `operatorMessages`.
|
||||
|
||||
**`stepLabel` null semantics, re-documented.** Today's doc comment
|
||||
(`skill-apply.ts:73-79`, `:442-446`) frames `label: null` as "the driver should
|
||||
NOT spin on this" — spinner advice, i.e. presentation smuggled into the core.
|
||||
The contract wording changes (no payload change): `label: null` means *instant/
|
||||
cheap, or the step renders its own live operator-facing output* (`effect:step`'s
|
||||
QR card / pairing code). That is step-cost/interactivity **declaration**; the
|
||||
event carries `kind` + `line`, so a consumer wanting a different render policy
|
||||
can derive its own.
|
||||
|
||||
**Event ordering contract** (normative):
|
||||
|
||||
1. Per directive, `step-start` fires immediately before the mutation, `step-end`
|
||||
immediately after (or on the failure path) — always balanced. The payload is
|
||||
today's `StepReporter` payload (`skill-apply.ts:80-83` — it already includes
|
||||
`line`) unchanged, plus only the discriminating `type` field.
|
||||
2. For an `nc:operator`, the engine substitutes `{{vars}}`, pushes to
|
||||
`res.operatorMessages`, then `await onEvent({type:'operator', …})` before
|
||||
evaluating the next directive. An unresolved `{{var}}` in the body defers the
|
||||
whole block before any event fires (today's behavior, `skill-apply.ts:787`).
|
||||
**Once the run is `blocked`** (an earlier bounce), operator directives are
|
||||
skipped instead — no event, no `operatorMessages` entry, recorded in
|
||||
`skipped`. Walking the human through steps whose side effects the run has
|
||||
already gated ("a pairing code is about to appear" → nothing appears) is
|
||||
actively misleading, and a failed run's manual-steps report must not include
|
||||
steps predicated on the failure.
|
||||
3. Every `onEvent` call is awaited; a rejection from `onEvent` is treated like
|
||||
any other throw at that directive (bounce, not crash). This applies to
|
||||
operator events too: a consumer that throws from `onEvent` **accepts the
|
||||
bounce consequence**, including the `blocked` latch cascading over later
|
||||
side effects. The engine itself never defers/bounces an operator block
|
||||
(open question 7); consequently a well-behaved driver's handler must never
|
||||
throw for a *declined* confirm — decline semantics are defined in §5.1.
|
||||
|
||||
## 3. Migration table
|
||||
|
||||
Every existing hook / attr / caller → destination. "core seam" = survives in
|
||||
`ApplyOptions`/`ApplyResult`; "driver policy" = reimplemented from document
|
||||
structure (shared policy module + `setup/lib/skill-driver.ts`, §5); "deleted" =
|
||||
removed with no replacement syntax.
|
||||
|
||||
### Engine hooks
|
||||
|
||||
| Today | Where (file:line) | Destination |
|
||||
|---|---|---|
|
||||
| `ApplyOptions.inputs` | `scripts/skill-apply.ts:263` | core seam (unchanged, but validated — §4) |
|
||||
| `Prompter.ask(name, question, secret, validate, opts)` | `scripts/skill-apply.ts:50`, called at `:774` | core seam → `resolveInput(name, meta)` |
|
||||
| `Prompter.tell(text)` | `scripts/skill-apply.ts:54`, called at `:793` | core seam → `onEvent` `operator` event |
|
||||
| `Prompter.confirm(msg)` | `scripts/skill-apply.ts:58`, called at `:802` (gate; result discarded — decline proceeds today) | driver policy (natural-barrier gating, §5.1) + driver-owned reuse offer — both via the new `RunSkillOptions.confirm` seam (§5.0) |
|
||||
| `Prompter.open(url)` | `scripts/skill-apply.ts:63`, called at `:796` | driver policy (URL offer, §5.2) via the new `RunSkillOptions.openUrl` seam (§5.0) |
|
||||
| `StepReporter.stepStart/stepEnd` | `scripts/skill-apply.ts:80-83`, fired at `:827,:830,:839` | core seam → `onEvent` `step-start`/`step-end` events (payload + balance guarantee identical; only `type` added) |
|
||||
| `ApplyOptions.prompter` | `scripts/skill-apply.ts:266` | deleted (split into `resolveInput` + `onEvent`) |
|
||||
| `ApplyOptions.reporter` | `scripts/skill-apply.ts:287` | deleted (folded into `onEvent`) |
|
||||
| `ApplyOptions.exec` / `execStream` | `scripts/skill-apply.ts:269,:275` | core seam (unchanged) |
|
||||
| `ApplyOptions.skipEffects` | `scripts/skill-apply.ts:279` | core seam (unchanged) |
|
||||
| `ApplyOptions.resolveRemote` | `scripts/skill-apply.ts:283` | core seam (unchanged) |
|
||||
| `PromptOpts` (flags/min/error/normalize) | `scripts/skill-apply.ts:37-42`, built at `:499` (`promptOptsOf`) | type deleted; `flags`/`normalize` move into `InputMeta`; `min`/`error` deleted (§ grammar) |
|
||||
| `normalizeValue` at bind | `scripts/skill-apply.ts:483`, applied `:779` | core seam (unchanged; now paired with validate-at-bind, §4) |
|
||||
| `stepLabel` | `scripts/skill-apply.ts:457-474` | core seam, minus the `label:` attr branch (`:458`); null semantics re-documented (§2) |
|
||||
| `failHint` / `AgentTask.hint` | `scripts/skill-apply.ts:419-430`, `:236`, `:749` | deleted (`on-fail:` gone ⇒ hint ≡ prose; `firstFailureHint` at `:308` reads `prose`) |
|
||||
| run-health gate (`blocked` latch) | `scripts/skill-apply.ts:744-750,:816` | core seam (unchanged) |
|
||||
| `when:` guard | `scripts/skill-apply.ts:521-525,:762` | core seam (unchanged) |
|
||||
| `effect:check` / `effect:step` + terminal-block capture | `scripts/skill-apply.ts:646-667` | core seam (unchanged) |
|
||||
| multi-field JSON capture + validate-on-capture | `scripts/skill-apply.ts:551-572` | core seam (unchanged) |
|
||||
| journal / `removeSkill` | `scripts/skill-apply.ts:851-870` | core seam (unchanged) |
|
||||
| `referenceProse` / `operatorMessages` / `vars` in result | `scripts/skill-apply.ts:239-257` | core seam (unchanged) |
|
||||
|
||||
### Authored grammar (`scripts/skill-directives.ts`)
|
||||
|
||||
| Attr / syntax | Where | Destination |
|
||||
|---|---|---|
|
||||
| `nc:operator open:<url>` | grammar doc `:61-73`; lint `:277`, `:287-291`; engine `:791,:796` | **deleted**. Driver URL-offer policy scans the rendered text (§5.2). URLs must live in the prose. |
|
||||
| `nc:operator gate` | grammar doc `:68-71`; engine `:802` | **deleted**. Driver natural-barrier policy (§5.1). |
|
||||
| `nc:prompt min:<n>` | grammar doc `:53-54`; lint `:264-266`; driver enforcement `setup/lib/skill-driver.ts:46` | **deleted**. Authors re-encode as regex, e.g. `min:20` → `validate:^.{20,}$`. |
|
||||
| `nc:prompt error:<msg>` | grammar doc `:55`; driver `setup/lib/skill-driver.ts:46-47` | **deleted**. Error text derived from the question prose (§5.3). |
|
||||
| `nc:run label:<word>` | `scripts/skill-apply.ts:458`; doc-comment mention `:451` | **deleted**. Labels are heading-derived only. |
|
||||
| `on-fail:<token>` | `scripts/skill-apply.ts:419-430` (no lint rule exists) | **deleted**. Hint is always the surrounding prose. |
|
||||
| `validate:` + `flags:` (prompt & run-capture) | lint `:249-263,:311-317` | **kept** (data semantics). Prompt validate now enforced at bind (§4). |
|
||||
| `normalize:trim\|rstrip-slash\|lower` | lint `:267-269`; bind `skill-apply.ts:483` | **kept** (canonical-value semantics). |
|
||||
| `reuse:<ENV_KEY>` | lint `:270-272`; driver `setup/lib/skill-driver.ts:169-172` | **kept** (binding metadata; consumed only by the driver's reuse offer). |
|
||||
| `when:`, `effect:check`, `effect:step`, capture forms, journal semantics | various | **kept**, unchanged. |
|
||||
|
||||
Lint addition: `validate()` gains errors for the six removed attrs
|
||||
(`operator open:/gate`, `prompt min:/error:`, any-directive `label:`/`on-fail:`)
|
||||
so stale authorship fails loudly instead of silently no-oping. Lint also gains a
|
||||
**warning** for an unguarded `nc:operator` immediately followed by `when:`-guarded
|
||||
directives spanning more than one branch value (the static gate policy cannot
|
||||
know which branch runs — see §5.1 and open question 1).
|
||||
|
||||
### Authored skills carrying removed attrs (strip + prose check)
|
||||
|
||||
| Skill | Line | Change |
|
||||
|---|---|---|
|
||||
| `.claude/skills/add-teams/SKILL.md` | `:101` | drop `open:https://portal.azure.com` (URL already in the body — step 1, body line 2; body line 1 is the heading sentence) |
|
||||
| `.claude/skills/add-teams/SKILL.md` | `:132` | `min:20` → `validate:^.{20,}$` |
|
||||
| `.claude/skills/add-teams/SKILL.md` | `:173`, `:203` | drop `gate` (policy reproduces both — §5.1 parity) |
|
||||
| `.claude/skills/add-telegram/SKILL.md` | `:134` | drop `open:https://t.me/{{bot_username}}` **and fold the URL into the body** (verified: body says "Open @{{bot_username}}" — the URL exists only in the attr today) |
|
||||
| `.claude/skills/add-discord/SKILL.md` | `:125` | drop `open:https://discord.com/...` **and fold the invite URL into the body** (verified: body says "Open the invite link" — URL only in the attr today) |
|
||||
|
||||
No skill uses `error:`, `label:`, or `on-fail:` (grep verified). Re-lint every
|
||||
touched skill (`pnpm exec tsx scripts/skill-directives.ts <dir>`).
|
||||
|
||||
### Prompter / reporter implementers & callers (all migrate — clean break)
|
||||
|
||||
| Caller / implementer | Where | Migration |
|
||||
|---|---|---|
|
||||
| `clackPrompter` (the wizard Prompter) | `setup/lib/skill-driver.ts:66-120` | becomes the driver's `resolveInput` impl (ask + `?` help-escape + clearOnError + secret masking) — `tell`/`confirm`/`open` dissolve into the `onEvent` handler + the `confirm`/`openUrl` seams (§5) |
|
||||
| `promptValidator` | `setup/lib/skill-driver.ts:37-50` | driver-side; loses `min`/`error`, gains prose-derived message (§5.3) |
|
||||
| `spinnerReporter` | `setup/lib/skill-driver.ts:259-274` | folded into the driver's `onEvent` handler (step-start/step-end branch), still built on `startSpinner` (`setup/lib/runner.ts:314`) |
|
||||
| `runSkill` + `RunSkillOptions` | `setup/lib/skill-driver.ts:286-340` | `prompter?`/`reporter?` options become `resolveInput?`/`onEvent?`; **new** `confirm?`/`openUrl?` options (§5.0); `reuse`, `channel`/`step` (help-escape ctx, `:308-314`), `reuseFromEnv` (`:143-188`, now validate-pre-filtered — §5.4) stay driver-side |
|
||||
| skill-driver CLI | `setup/lib/skill-driver.ts:343-360` | uses the new defaults; no interface change visible to the operator |
|
||||
| `runChannelSkill` overrides | `setup/channels/run-channel-skill.ts:122` (`prompter`), `:126` (`reporter`) | override fields renamed to `resolveInput`/`onEvent`; `confirm`/`openUrl` passthroughs added; fail-path (`:133-151`) unchanged |
|
||||
| `applyProviderSkill` defer-all Prompter | `setup/providers/install.ts:62-66` | delete the stub — omit `resolveInput` entirely (absent ⇒ defer, same semantics) |
|
||||
| `setup/auto.ts` call sites | `:350` (provider), `:560-572` (channels) | no signature change needed (they pass no prompter/reporter) |
|
||||
| `setup/provider-auth.ts` | `:53` | unchanged (blockers contract survives) |
|
||||
| engine test fakes | `scripts/skill-apply.test.ts:39` (`headless`), `:447`, `:505`, `:518`, `:534`, `:545`, `:613`, `:637`, `:1219` | `headless(vals)` becomes `{ resolveInput: async (n) => vals[n] }`; tell/open/confirm fakes become recorded `onEvent` handlers or move to driver-policy tests (§9); any fixture whose `inputs` violate a declared `validate:` updates to valid-shaped values (§4) |
|
||||
| driver test fakes | `setup/lib/skill-driver.test.ts:73`, `:100` (reporter) | mechanical rewrite to `resolveInput`/`onEvent` |
|
||||
| driver reuse-offer tests | `setup/lib/skill-driver.test.ts:149`, `:167`, `:202` | NOT a mechanical rewrite — today they queue answers through fake `prompter.confirm`s; they migrate to the new `RunSkillOptions.confirm` seam (§5.0) |
|
||||
| run-channel-skill test stub prompter | `setup/channels/run-channel-skill.test.ts:95-100` (teams gate/open assertions `:115-124`) | becomes a driver-policy assertion running the **default** `onEvent` policy handler with `confirm`/`openUrl` injected (§5.0 injection semantics) — proving the §5.1/§5.2 parity claims. Fixture `app_password: 'sekret'` → a 20+-char value (§4); Option-A slack fixture (`:41-70`) updates `signing_secret`/`owner_handle` + the `userId` assertion (§1 invariant) |
|
||||
| `back-nav.ts` `backGate`, `claude-handoff.ts` help-escape | `setup/lib/back-nav.ts:31`, `setup/lib/claude-handoff.ts:82,:164,:182` | untouched (already driver-side) |
|
||||
|
||||
## 4. Behavior change: validate + normalize apply to EVERY bound value
|
||||
|
||||
Today `validate:` is enforced only by the interactive prompter
|
||||
(`setup/lib/skill-driver.ts:37-50`); `inputs` bypass it
|
||||
(documented at `scripts/skill-directives.ts:51`). That is data validation
|
||||
misfiled as prompt UX. New rule, at the single bind point
|
||||
(`skill-apply.ts:766-780` region):
|
||||
|
||||
1. Resolve the raw value: `inputs[var]` first, else `await resolveInput(var, meta)`.
|
||||
Both `undefined` ⇒ defer (push bare var name — unchanged).
|
||||
2. Apply `normalize:` (unchanged, already both-paths — `normalizeValue`, `:483`).
|
||||
3. **New:** if the prompt carries `validate:` (+ `flags:`), test the *normalized*
|
||||
value. On mismatch: the var stays **unbound**, and
|
||||
`` `<var>: invalid value (does not match validate:<re>)` `` is pushed to
|
||||
`deferred`. Not an agentTask, not a throw — downstream consumers of the var
|
||||
defer exactly as if the value were never supplied, and `fullyApplied` is
|
||||
`false`. A pipeline passing a malformed env value fails loudly.
|
||||
|
||||
Notes:
|
||||
- Normalize-then-validate order is normative (a trailing-slash URL is stripped
|
||||
before the `^https://` check — matches the teams `public_url` authoring).
|
||||
- An invalid `inputs` value does **not** fall through to `resolveInput` — inputs
|
||||
win outright, and a caller that pre-supplied a value gets a loud rejection,
|
||||
never a surprise second acquisition path. The interactive dead-end this could
|
||||
create for reused `.env` credentials is closed on the driver side instead:
|
||||
`reuseFromEnv` pre-filters every offer through the prompt's
|
||||
`normalize`/`validate`/`flags` meta, so a stale credential that no longer
|
||||
matches the declared shape is **never offered** and the operator is prompted
|
||||
fresh (§5.4). A caller passing raw `inputs` (pipeline, tests) still fails
|
||||
loudly — that is the point.
|
||||
- The interactive re-ask loop moves into the wizard's `resolveInput` (clack
|
||||
`validate`), so engine-level rejection rarely fires interactively; it is the
|
||||
backstop for programmatic paths.
|
||||
- Secret values never appear in the deferred entry (only the var name and the
|
||||
regex source).
|
||||
- run-capture `validate:` is unchanged (it already throws → bounces,
|
||||
`skill-apply.ts:551-572` — a command's output has no human to re-ask).
|
||||
- **Test-fixture consequence** (part of the step that lands this change): every
|
||||
in-tree fixture that supplies an `inputs` value violating its prompt's
|
||||
declared `validate:` must update to a valid-shaped value. Known: the Option-A
|
||||
slack fixture (`run-channel-skill.test.ts:49` — `signing_secret`,
|
||||
`owner_handle`, with the `userId` assertion at `:62` updated consistently)
|
||||
and the teams deferWire fixture (`:102-107` — `app_password` vs. the new
|
||||
`^.{20,}$`). Sweep `scripts/skill-apply.test.ts` fixtures the same way.
|
||||
|
||||
## 5. Wizard driver policy (presentation derived from document structure)
|
||||
|
||||
### 5.0 Where the policy lives, and the driver's own seams
|
||||
|
||||
The policy **logic** is UI-free and shared: a new module
|
||||
`scripts/skill-policy.ts` beside the parser exports `gatePolicy(md)` (→ map of
|
||||
operator line → needs-confirm + confirm flavor, §5.1) and `extractOfferUrl(text)`
|
||||
(§5.2), both built on the shared `parseDirectives`. The wizard driver consumes
|
||||
it; an agent-relay consumer (§7) imports the same module instead of duplicating
|
||||
the judgment or dragging in clack. The §9 policy unit tests live at this shared
|
||||
home.
|
||||
|
||||
The wizard driver (`setup/lib/skill-driver.ts`) keeps the clack rendering and
|
||||
gains two injectable interaction seams on `RunSkillOptions` — these are
|
||||
*driver* options, allowed by the boundary rule (it binds only the core):
|
||||
|
||||
- `confirm?: (message: string) => Promise<boolean>` — used by the reuse offer
|
||||
(§5.4), the natural-barrier gate (§5.1), and the URL offer (§5.2). Default:
|
||||
clack `p.confirm`, **TTY-gated exactly like `spinnerReporter`**
|
||||
(`skill-driver.ts:260`) — non-TTY resolves `true` (proceed), preserving
|
||||
today's headless-prompter-without-confirm semantics (`skill-apply.ts:802`'s
|
||||
optional chain). A non-TTY run with full inputs never stalls.
|
||||
- `openUrl?: (url: string) => Promise<void>` — used by the URL offer. Default:
|
||||
`setup/lib/browser.ts` `openUrl`, attempted only after a `confirm` yes.
|
||||
|
||||
**Injection semantics (normative):** an injected `onEvent` **replaces** the
|
||||
driver's default policy handler entirely — same rule as today's injected
|
||||
prompter ("the injector owns its I/O", `skill-driver.ts:311`). Therefore
|
||||
driver-policy parity tests must run the **default** handler and inject
|
||||
`confirm`/`openUrl` (the run-channel teams test does exactly this — §3, §9);
|
||||
injecting `onEvent` to observe policy behavior would only observe itself.
|
||||
|
||||
Because the engine awaits `onEvent` (§2), a confirm inside the default handler
|
||||
blocks the engine — that is the entire gating mechanism.
|
||||
|
||||
### 5.1 Natural-barrier gate policy
|
||||
|
||||
For each `nc:operator` directive at line L, `gatePolicy` computes
|
||||
`needsConfirm(L)`:
|
||||
|
||||
1. Scan forward through subsequent directives, skipping **only** directives
|
||||
whose `when:<var>=<value>` guard is **incompatible** with this operator's own
|
||||
guard — same var, different value. No guard, or an identical guard, is
|
||||
compatible. (This makes mutually-exclusive branches gate on their *own* next
|
||||
action: imessage's `when:mode=local` operator at `:111` skips the two
|
||||
remote-only prompts and gates on the local configure run at `:151`;
|
||||
whatsapp's `when:auth_method=qr` operator at `:96` skips the pairing-code
|
||||
operator at `:104` — guard-incompatible — and gates on the qr step at `:114`.)
|
||||
2. Next compatible directive is another `operator` → **no confirm** — the chain's
|
||||
**last** operator carries the barrier. (Operators are NOT skipped-and-scanned-past:
|
||||
that would make the earlier block of a chain inherit the later block's barrier
|
||||
and double-confirm — the exact bug the teams parity table below forbids.)
|
||||
3. Next compatible directive is a `prompt` → **no confirm** (the prompt is the barrier).
|
||||
4. No such directive (end of document) → **no confirm** (a final handoff block, e.g. teams `:228`).
|
||||
5. Anything else (`run`, `copy`, `dep`, `append`, `env-set`, `json-merge`) →
|
||||
**confirm** after rendering. Confirm wording is derived from the barrier's
|
||||
*flavor* — the next compatible directive's effect: `effect:step` →
|
||||
readiness phrasing (`"Ready? The next step starts immediately."` — the block
|
||||
describes future action: "a pairing code is about to appear"); anything else
|
||||
→ completed-work phrasing (`"Done with the steps above? Continue when you're
|
||||
ready."`). `gatePolicy` returns the flavor with the boolean.
|
||||
|
||||
**Decline semantics (normative):** the barrier confirm is a *pause*, not a
|
||||
branch. A "No"/cancel answer proceeds anyway — matching today's engine, which
|
||||
discards the gate confirm's result (`skill-apply.ts:802`). The driver's handler
|
||||
must never throw for a decline (an operator-event throw would bounce + latch
|
||||
`blocked`, §2.3). A driver MAY upgrade decline to a re-ask loop as pure polish;
|
||||
it must not abort.
|
||||
|
||||
**Known limitation (lint-warned, open question 1):** an *unguarded* operator
|
||||
followed by guarded directives of more than one branch value keys its barrier
|
||||
decision off a directive that may be runtime-skipped. No in-tree skill authors
|
||||
this; the §3 lint warning flags it.
|
||||
|
||||
At runtime, on each `operator` event the default handler: renders the clack
|
||||
note (`p.note(text, 'Do this')`), runs the URL offer (§5.2), then the confirm
|
||||
if `needsConfirm(line)`.
|
||||
|
||||
**Verified parity against today's tree** (re-derived under rules 1–5 above):
|
||||
|
||||
- teams `:80` → prompt `:93`: no confirm. `:101` → prompt `:110`: no confirm.
|
||||
`:124` → prompt `:132`: no confirm. `:158` → next compatible is **operator**
|
||||
`:173` (rule 2): **no confirm** — the chain's last block carries the barrier.
|
||||
`:173` → `run effect:check` `:186`: **confirm**. `:203` → `run effect:restart`
|
||||
`:217`: **confirm**. `:228` → end: no confirm. Exactly the two authored
|
||||
`gate`s reproduced — behavior identical.
|
||||
- telegram `:134` (→ `run effect:step` pairing `:142`) **gains** a confirm with
|
||||
readiness phrasing — this restores the old bespoke flow's readiness pause
|
||||
that the directive port lost.
|
||||
- signal `:94` (→ `effect:step` `:105`) and both whatsapp operators (`:96` →
|
||||
guard-skip `:104` → `effect:step` `:114`; `:104` → guard-skip `:114` →
|
||||
`effect:step` `:117`) gain the same readiness pause before a QR/pairing
|
||||
appears. whatsapp `:146` → prompt `:155`: no confirm.
|
||||
- imessage `:111` (`when:mode=local`) → guard-skips `:126`,`:134`,`:137` →
|
||||
`run effect:external` `:151`: **confirm**. `:126` (`when:mode=remote`) →
|
||||
prompt `:134`: no confirm.
|
||||
- discord `:125` (→ `run effect:fetch` `:139`, the DM resolve) gains a confirm —
|
||||
desirable: the DM open fails until the bot is invited (open question 2). Slack's
|
||||
operators (`:69` → prompt `:80`; `:97` → prompt `:112`) are prompt-followed →
|
||||
unchanged.
|
||||
|
||||
### 5.2 URL offer (replaces `open:`)
|
||||
|
||||
On an `operator` event, `extractOfferUrl(text)` scans the **rendered** text for
|
||||
the first *offerable* URL: matches `/https?:\/\/[^\s)>\]]+/`, then **excludes**
|
||||
candidates containing `<` or `{{` (template placeholders / unsubstituted vars)
|
||||
and requires the candidate to parse via `new URL()` with a well-formed host.
|
||||
Without the exclusion, slack's `:97` block — `https://<your-public-host>/webhook/slack`
|
||||
— would produce a nonsense "Open https://<your-public-host?" offer (the char
|
||||
class stops at `>` but not `<`). Slack `:97` is the normative negative fixture.
|
||||
|
||||
If an offerable URL is found and the run is interactive:
|
||||
`confirm("Open <url> in your browser?")` → on yes, `openUrl` (both via the §5.0
|
||||
seams — TTY-gated, non-TTY skips). Confirm-then-open matches the old bespoke
|
||||
flows; prose-primary already forces URLs into the text (after the §3 skill
|
||||
edits), so `open:` was redundant authorship. Order within the handler:
|
||||
note → URL offer → natural-barrier confirm.
|
||||
|
||||
**Full operator-body URL inventory** (the offer scans *every* operator body,
|
||||
not just ex-`open:` blocks — audited like §5.1):
|
||||
|
||||
| Site | URL | Outcome |
|
||||
|---|---|---|
|
||||
| teams `:101` (body line 2) | `https://portal.azure.com` | offer — preserves today's `open:` behavior |
|
||||
| telegram `:134`, discord `:125` (after the §3 fold-into-prose edits) | t.me / invite URL | offer — preserves today's `open:` behavior |
|
||||
| teams `:158` (body) | `https://portal.azure.com` | **new** offer (no `open:` today) — accepted, same judgment as the discord confirm; open question 3 |
|
||||
| discord `:70` (body `:72`) | `https://discord.com/developers/applications` | **new** offer — accepted; open question 3 |
|
||||
| imessage `:126` (body `:128`) | `https://photon.codes` | **new** offer — accepted; open question 3 |
|
||||
| slack `:97` (body `:99`) | `https://<your-public-host>/webhook/slack` | **excluded** (placeholder) — slack stays offer-free, as today |
|
||||
| slack `:69` (`api.slack.com/apps`, no scheme), signal `:94` (`sgnl://…`), all other operators | — | no match |
|
||||
|
||||
### 5.3 Validation error text (replaces `error:`)
|
||||
|
||||
`promptValidator(validate, flags, question)` — on a regex miss the message is
|
||||
`` `That doesn't match the expected format. ${question}` `` (the full prompt
|
||||
body, which by authoring convention describes the expected shape, e.g.
|
||||
"Paste the bot token from BotFather (looks like `123456:ABC-DEF...`)."). No
|
||||
`min` branch (regex-encoded now), no `error:` override.
|
||||
|
||||
### 5.4 Stays driver-side, unchanged in spirit
|
||||
|
||||
`clearOnError` secret re-paste, secret masking (`p.password`), the masked reuse
|
||||
offer (`reuseFromEnv` + its `reuse:` linkage — confirm now via the §5.0 seam,
|
||||
`skill-driver.ts:327-328` today), the `?` help-escape (channel/step ctx already
|
||||
threaded via `RunSkillOptions`, `run-channel-skill.ts:130-131`), `backGate`,
|
||||
spinners (now driven by step events), `hostExec`/`hostExecStream`.
|
||||
|
||||
**One behavior addition:** `reuseFromEnv` pre-filters offers through the target
|
||||
prompt's `normalize`/`validate`/`flags` (parsed from the same directives it
|
||||
already walks) — an `.env` value that would fail validate-at-bind is silently
|
||||
not offered, so the operator is prompted fresh instead of hitting a §4
|
||||
dead-end (`fullyApplied` false → `failWith`). This is the driver-side closure
|
||||
of the stale-credential path; raw `inputs` remain loud-fail (§4).
|
||||
|
||||
## 6. Pipeline consumer contract
|
||||
|
||||
- **Inputs from env — convention:** for each prompt var `foo_bar`, read
|
||||
`NC_INPUT_FOO_BAR` (prefix `NC_INPUT_`, var uppercased). A small helper
|
||||
`inputsFromEnv(md: string, env = process.env)` (driver-agnostic, may live
|
||||
beside the engine) parses the skill's prompt vars via `parseDirectives` and
|
||||
returns the `inputs` record. Var names are case-sensitive in the grammar
|
||||
(`skill-directives.ts:111`), so uppercasing can collide (`bot_token` vs
|
||||
`Bot_Token`): `inputsFromEnv` **errors on a collision**. All in-tree vars are
|
||||
lowercase today; a lint rule requiring lowercase prompt/capture var names is
|
||||
cheap and makes the mapping bijective (open question 6). No `resolveInput`,
|
||||
no `onEvent` required (optionally an `onEvent` that logs step events as CI
|
||||
lines).
|
||||
- **Run:** `applySkill(skillDir, root, { inputs, exec, execStream?, skipEffects })`
|
||||
with `skipEffects` per deployment (e.g. `['restart']` when the deploy restarts once).
|
||||
- **What a pipeline can fully apply (normative boundary):** `inputs` binds
|
||||
**prompt** vars only — the engine never reads it for `run`/`step` captures
|
||||
(`skill-apply.ts:773` — prompt branch only), so a pipeline cannot pre-supply
|
||||
a capture-bound var like `platform_id`. And `effect:step` without a real
|
||||
`execStream` throws → bounces (`skill-apply.ts:655-656`);
|
||||
`skipEffects:['step']` avoids the bounce but leaves the step's capture vars
|
||||
unbound, so downstream consumers defer either way. Consequence: **skills with
|
||||
an `effect:step` are wizard/relay-only for full application** — today that is
|
||||
telegram (`:142`), whatsapp (`:114`,`:117`), and signal (`:105`). slack,
|
||||
discord, teams, and imessage carry no `effect:step` and can go fully green
|
||||
from env inputs (+ `exec`), with their operator blocks landing in the
|
||||
manual-steps report. A pipeline-grade `execStream`, or letting `inputs`
|
||||
pre-bind capture vars (bound var ⇒ capture skipped), would move that
|
||||
boundary — deliberately out of scope here (open question 10).
|
||||
- **Consume the result:**
|
||||
- `res.operatorMessages` → emitted verbatim, numbered, as the "manual steps"
|
||||
report artifact (the human steps the pipeline cannot do).
|
||||
- `fullyApplied(res)` gates the job: `false` ⇒ non-zero exit, printing
|
||||
`res.deferred` (which now includes §4 invalid-input reasons — a malformed
|
||||
env value is a loud failure) and `firstFailureHint(res)` + each
|
||||
`agentTask.reason` for real bounces.
|
||||
- `res.vars` → exported for downstream jobs (e.g. `platform_id` into a wire step).
|
||||
|
||||
## 7. Agent-relay consumer sketch
|
||||
|
||||
A coding agent driving a skill over chat implements the two seams:
|
||||
|
||||
- `resolveInput(name, meta)`: send `meta.question` to the chat; for
|
||||
`meta.secret`, instruct the user to supply the value out-of-band (or via the
|
||||
platform's redaction affordance) and never echo it back. Run its own re-ask
|
||||
loop against `meta.validate`/`meta.flags` conversationally ("that doesn't look
|
||||
like a bot token — it should start with `xoxb-`"); return the final answer, or
|
||||
`undefined` if the user says skip (⇒ defer, degrade-to-agent semantics apply
|
||||
downstream).
|
||||
- `onEvent(e)`: `operator` → relay the text as a chat message and (because the
|
||||
engine awaits) hold the return until the user replies "done" when the next
|
||||
action is side-effecting — importing `gatePolicy` from the shared
|
||||
`scripts/skill-policy.ts` (§5.0) for the same natural-barrier judgment as the
|
||||
wizard, with no clack/TTY baggage; or simply always ask.
|
||||
`step-start`/`step-end` → optional progress messages ("Building… ok, 12s").
|
||||
- Everything else (`exec`, journal, bounce handling) is identical to the wizard;
|
||||
the agent reads `agentTasks[].prose` and applies bounced steps itself — which
|
||||
is exactly the degrade-to-agent path the prose was written for.
|
||||
|
||||
## 8. Implementation step plan
|
||||
|
||||
Each step must be independently green — `pnpm exec tsc --noEmit` (root) +
|
||||
`pnpm test` (full vitest suite) + skill lint on every touched skill — and
|
||||
committable on its own. Core lands before consumers migrate; the old seam is
|
||||
deleted only after no consumer uses it (transitional coexistence inside the
|
||||
branch is fine; the *merged* result has no compat layer).
|
||||
|
||||
1. **Core: add the new seam (additive) + validate-at-bind.**
|
||||
`scripts/skill-apply.ts`: add `resolveInput` + `onEvent` + `InputMeta` +
|
||||
`ApplyEvent`; engine prefers them when present (falls back to
|
||||
`prompter`/`reporter` if not — temporary); implement validate-at-bind (§4)
|
||||
and the awaited-event ordering; re-document `stepLabel` null semantics (§2).
|
||||
**Includes the §4 fixture sweep**: Option-A slack inputs + `userId`
|
||||
assertion, teams deferWire `app_password`, and any `skill-apply.test.ts`
|
||||
fixture with shape-violating inputs — updated in this same commit so the
|
||||
suite is green. New tests: event union payloads + balance,
|
||||
await-before-proceed ordering (an async `onEvent` that records completion),
|
||||
`resolveInput` meta contents, validate-at-bind for inputs AND resolveInput
|
||||
answers (normalize-then-validate, no-fallthrough from invalid inputs to
|
||||
`resolveInput`, deferred entry format, `fullyApplied` false, secret never in
|
||||
the entry), onEvent-throw ⇒ bounce (incl. on an operator event).
|
||||
2. **Shared policy module + wizard driver migration.** New
|
||||
`scripts/skill-policy.ts`: `gatePolicy(md)` (§5.1 rules incl. operator-chain
|
||||
termination, guard-compatibility, confirm flavor) + `extractOfferUrl(text)`
|
||||
(§5.2 incl. placeholder exclusion) — with the parity-table unit tests.
|
||||
`setup/lib/skill-driver.ts`: `resolveInput` impl (ex-`clackPrompter.ask`,
|
||||
help-escape intact), default `onEvent` handler (spinner branch from
|
||||
`spinnerReporter` + operator branch consuming `gatePolicy`/`extractOfferUrl`),
|
||||
new `confirm`/`openUrl` options with TTY-gated defaults (§5.0), decline =
|
||||
proceed, `reuseFromEnv` validate-pre-filter (§5.4), §5.3 error text;
|
||||
`RunSkillOptions.prompter/reporter` → `resolveInput/onEvent` (+ documented
|
||||
replacement semantics for injected `onEvent`);
|
||||
`setup/channels/run-channel-skill.ts` override renames + `confirm`/`openUrl`
|
||||
passthrough; `setup/providers/install.ts` drops its stub Prompter. Reuse
|
||||
tests migrate to the `confirm` seam; the teams run-channel test runs the
|
||||
default handler with injected `confirm`/`openUrl` (§9). After this step no
|
||||
in-tree caller passes `prompter`/`reporter`.
|
||||
3. **Core: delete the old seam.** Remove `Prompter`, `PromptOpts`,
|
||||
`StepReporter`, `ApplyOptions.prompter/reporter`; remove engine handling of
|
||||
`open:`/`gate` (`:791,:796,:802`), `label:` (`:458`), `on-fail:`+`AgentTask.hint`
|
||||
(`:419-430,:236`), `min:`/`error:` plumbing (`:499-506`). Rework/delete the
|
||||
engine tests that asserted removed behavior (§9).
|
||||
4. **Skills cleanup.** Strip attrs per §3 table; fold the telegram + discord
|
||||
URLs into their operator prose; teams `min:20` → `validate:^.{20,}$`.
|
||||
Re-lint all touched skills; the run-channel teams parity test (now
|
||||
driver-policy-based from step 2) must still prove both barriers fire and the
|
||||
portal offer survives the `open:` removal (body URL, §5.2 inventory).
|
||||
5. **Grammar diet.** `scripts/skill-directives.ts`: drop `min:`/`error:`/`open:`/`gate`
|
||||
validation + grammar doc; add lint errors rejecting the six removed attrs
|
||||
and the §3 unguarded-operator/multi-branch warning; rework directive tests.
|
||||
(Ordered after step 4 so lint never fails on skills still carrying the attrs.)
|
||||
6. **Sweep + parity audit.** Grep proves zero remaining references to
|
||||
`Prompter|PromptOpts|StepReporter|on-fail:|label:|min:|error:` (in the seam
|
||||
sense) and `operator.*(open:|gate)`; container typecheck
|
||||
(`pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit`) untouched;
|
||||
full suite count ≥ 680 passed | 1 skipped; Option-A test assertion structure
|
||||
unmodified (fixture values per §1/§4); production resolve/wire paths
|
||||
untouched.
|
||||
|
||||
## 9. Test-migration notes (per step)
|
||||
|
||||
- **Step 1:** all existing tests stay green — the ONLY permitted edits are the
|
||||
§4 shape-violating input fixtures (Option-A slack `:49`/`:62`, teams deferWire
|
||||
`:106`, plus any `skill-apply.test.ts` siblings the sweep finds). New
|
||||
describe blocks in `scripts/skill-apply.test.ts`: `onEvent` events (mirror of
|
||||
the reporter suite at `:1003-1054`, plus operator events + await-ordering) and
|
||||
validate-at-bind (extends the PromptOpts suite pattern at `:1189-1242`).
|
||||
- **Step 2:** `setup/lib/skill-driver.test.ts` fakes (`:73,:100`) rewritten
|
||||
mechanically to `resolveInput`/`onEvent`; the reuse-offer tests
|
||||
(`:149,:167,:202`) migrate to the injected `confirm` option (accept /
|
||||
decline / helper-reuse paths preserved); the `clackPrompter.open`
|
||||
existence test (`:218-223`) becomes a URL-offer policy test; help-escape tests
|
||||
(`:225-259`) keep their shape (the escape lives in the driver's `resolveInput`);
|
||||
`promptValidator` test (`:261-271`) loses min/error, gains prose-derived-message
|
||||
assertions. **Policy tests at `scripts/skill-policy.ts`'s home** encode the
|
||||
§5.1 parity table: teams (confirm ×2, **no confirm on the `:158` chain head** —
|
||||
the operator-chain rule's regression fixture), telegram/signal/whatsapp
|
||||
(readiness-flavor pause), imessage (guard-compatibility), end-of-document;
|
||||
plus `extractOfferUrl` fixtures: the §5.2 inventory incl. slack's placeholder
|
||||
as the negative case. `run-channel-skill.test.ts:75-125` (teams) re-asserts
|
||||
gate-before-manifest + portal-URL-offer by running the **default** `onEvent`
|
||||
handler with injected `confirm`/`openUrl` (never an injected `onEvent`, which
|
||||
replaces the policy — §5.0).
|
||||
- **Step 3:** delete engine tests for removed syntax: `nc:operator open + gate`
|
||||
(`scripts/skill-apply.test.ts:492-551`), `label:` override + `on-fail:` cases
|
||||
(`:1064,:1072`, `:1145-1162`); keep their *semantic* siblings (heading labels,
|
||||
prose hint default, unresolved-var deferral of an operator body). The prompter
|
||||
threading tests (`:634-645,:1212-1231`) become `InputMeta` assertions.
|
||||
- **Step 4:** no test-file changes beyond fixtures; skill lint is the gate.
|
||||
- **Step 5:** `scripts/skill-directives.test.ts`: drop `:268-271` (min) and
|
||||
`:325-355` (open/gate) as validations-of-supported-syntax; re-add them
|
||||
inverted (the new lint errors reject the attrs). PromptOpts-parse test
|
||||
(`:247-261`) drops `min:`.
|
||||
- **Throughout:** the coverage-parity bar is behavioral, not file-shaped: every
|
||||
guarantee an old test proved (barrier ordering, open-after-render, balanced
|
||||
spinner events, hint provenance) must have a successor at its new home.
|
||||
|
||||
## 10. Open questions (decisions this spec made that the design left open)
|
||||
|
||||
1. **Guard-compatibility in the gate scan (§5.1.1).** The design said "followed
|
||||
by"; this spec defines *followed by* as skipping `when:`-incompatible
|
||||
directives so mutually-exclusive branches gate correctly (imessage local,
|
||||
whatsapp qr/pairing). Alternative (pure document order) would miss imessage's
|
||||
"stop and wait" and double-gate whatsapp. Two scrutiny points: (a) the
|
||||
compatibility rule compares only same-var/different-value; different-var
|
||||
guards are treated as compatible (conservative). (b) An **unguarded**
|
||||
operator followed by guarded directives of more than one branch value keys
|
||||
its decision off a directive that may be runtime-skipped (e.g. unguarded
|
||||
operator → `prompt when:mode=remote` → `run when:mode=local`: policy says
|
||||
no-confirm, but at runtime mode=local skips the prompt). No in-tree skill
|
||||
authors this; §3's lint warning covers it.
|
||||
2. **Discord gains a confirm** (`add-discord:125` → `effect:fetch`). The design's
|
||||
parity list mentioned teams + telegram only; the policy also pauses before
|
||||
discord's DM resolve. Judged desirable (the fetch fails until the bot is
|
||||
invited) — but it is a new prompt in an existing flow.
|
||||
3. **Three new URL offers** (§5.2 inventory): teams `:158` (portal.azure.com),
|
||||
discord `:70` (developers portal), imessage `:126` (photon.codes) had no
|
||||
`open:` today and gain an offer because the scan covers every operator body.
|
||||
Accepted — same judgment as the discord confirm — but they are behavior
|
||||
changes in existing flows.
|
||||
4. **Confirm triggers on ALL non-prompt/non-operator directives** (§5.1.5), not
|
||||
just the engine's dangerous-side-effect set (`restart|step|wire`). Needed for
|
||||
teams parity (its first gate precedes `effect:check`+`external`). Consequence:
|
||||
an operator block directly followed by e.g. an `env-set` would also confirm —
|
||||
no such authoring exists today.
|
||||
5. **Invalid-input failure shape (§4): deferred, not agentTask.** Chosen because
|
||||
a bad value is a missing-*valid*-value (re-supply and re-run), not a step an
|
||||
agent can apply from prose; it also keeps the run-health gate un-tripped so a
|
||||
re-run with a fixed env var completes. The alternative (bounce) would
|
||||
cascade-block later side effects. Relatedly: invalid `inputs` do NOT fall
|
||||
through to `resolveInput` (§4) — the driver's reuse pre-filter (§5.4) is the
|
||||
interactive recovery path.
|
||||
6. **Env-input convention `NC_INPUT_<VAR>` (§6)** and that `inputsFromEnv` is a
|
||||
helper, not an engine feature (inputs stay the only env-agnostic seam). Prefix
|
||||
bikeshed welcome; the engine never reads `process.env` for inputs.
|
||||
`inputsFromEnv` errors on an uppercase collision; a lowercase-var lint rule
|
||||
would make the mapping bijective.
|
||||
7. **Operator event fires even with no consumer** — with `onEvent` absent the
|
||||
block is still collected in `operatorMessages` (today's headless behavior,
|
||||
`skill-apply.test.ts:452-456`). The **engine** never defers/bounces an
|
||||
operator block on its own; a consumer that *throws* from `onEvent` opts into
|
||||
the standard bounce path (§2.3) — that is the consumer's choice, not an
|
||||
engine judgment, and the wizard's default handler never throws (decline =
|
||||
proceed, §5.1).
|
||||
8. **`AgentTask.hint` field removal** (vs. keeping it always-equal-to-prose for
|
||||
result-shape stability). Removal chosen for the clean break; any external
|
||||
consumer of `hint` (none in-tree) would break.
|
||||
9. **Teams `min:20` regex** chosen as `^.{20,}$` (any 20+ chars). If the intent
|
||||
was tighter (Azure secret alphabet), tighten in the skill edit — the seam
|
||||
doesn't care.
|
||||
10. **Pipeline boundary for `effect:step` skills (§6).** telegram/whatsapp/signal
|
||||
cannot go fully green in a pipeline (no human at the QR/pairing step, and
|
||||
`inputs` cannot pre-bind capture vars). Two possible future escapes — a
|
||||
pipeline-grade `execStream` contract, or `inputs` pre-binding capture vars
|
||||
(bound var ⇒ capture skipped) — both deliberately out of scope; the spec'd
|
||||
behavior is the loud `fullyApplied:false`.
|
||||
11. **`step-end` on validate-at-bind rejection:** none — prompts never emitted
|
||||
step events (they are not mutations) and still don't. Only the deferred entry
|
||||
records the rejection. If wizards want live feedback, their own `resolveInput`
|
||||
loop already provided it.
|
||||
|
||||
## 11. Rejected review findings
|
||||
|
||||
- **"`run-channel-skill.ts:122` is `exec`; the `prompter` passthrough is at `:123`"**
|
||||
(review 1, citation sweep) — rejected: `grep -n` shows `:121 exec: overrides.exec,`
|
||||
and `:122 prompter: overrides.prompter,`. The spec's `:122` citation was
|
||||
correct as written. (The same review's other three citation fixes —
|
||||
`resolveRemote :283`, teams `:101` body-line wording, `label:` doc mention
|
||||
`:451` — were verified correct and are folded.)
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.1.32",
|
||||
"version": "2.1.38",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,876 @@
|
||||
// The skill application engine — executes `nc:` directives parsed from a SKILL.md.
|
||||
//
|
||||
// The agent is always the top-level applier; this engine is the deterministic
|
||||
// accelerator it delegates to. Anything the engine can't do bounces back to the
|
||||
// AGENT (which reads the same prose and applies it, the way skills work today) —
|
||||
// never to the human, and never as a hard abort. The human is in the loop only
|
||||
// for `prompt` inputs and `operator` instructions — the parts addressed to the
|
||||
// human (e.g. clicking through the Slack UI), which the agent relays.
|
||||
//
|
||||
// Phases (the F2 runtime contract, minimal form):
|
||||
// 1. parse + validate — lint; a malformed skill never reaches apply
|
||||
// 2. PLAN — per directive: skip|apply|needs-input|agent — no writes
|
||||
// 3. acquire inputs — resolve every `prompt` via `inputs` / `resolveInput`
|
||||
// 4. mutate — copy/append/env-set, journaled + idempotent
|
||||
// 5. run — build/test/fetch (+ dep install) via injected exec
|
||||
// Remove is derived from the journal — no hand-written REMOVE.md.
|
||||
//
|
||||
// Inputs + `resolveInput` make one engine serve three contexts:
|
||||
// • programmatic → pass `inputs` (var→value); no resolver, runs through fully
|
||||
// • setup flow → an interactive `resolveInput` collects anything left
|
||||
// • recipe rebuild → headless: no answer for a prompt ⇒ it (and its consumers) defer
|
||||
//
|
||||
// Usage: pnpm exec tsx scripts/skill-apply.ts <skillDir> # plan (no writes)
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync, existsSync, writeFileSync, appendFileSync, copyFileSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { parseDirectives, promptVar, type Directive } from './skill-directives.js';
|
||||
|
||||
// What an `nc:prompt` DECLARES about the value it needs — the core seam's input
|
||||
// contract, passed to `resolveInput` so a consumer can run its OWN re-ask loop
|
||||
// (clack validate, a chat exchange). Declaration only: how the value is
|
||||
// ACQUIRED (a masked TTY prompt, a chat message) is the consumer's business.
|
||||
export interface InputMeta {
|
||||
question: string; // the prompt body (verbatim)
|
||||
secret: boolean; // consumer must mask
|
||||
validate?: string; // regex source (nc:prompt validate:<re>)
|
||||
flags?: string; // regex flags (nc:prompt flags:<f>)
|
||||
normalize?: 'trim' | 'rstrip-slash' | 'lower'; // applied by the ENGINE at bind
|
||||
}
|
||||
|
||||
// Everything the engine EMITS — the core seam's output contract. Every
|
||||
// `onEvent` call is AWAITED before the engine proceeds; that ordering guarantee
|
||||
// is what lets a consumer implement gating (hold the operator event until the
|
||||
// human confirms readiness). For step events, `label` is `stepLabel`'s
|
||||
// declaration: null means the step is instant/cheap, OR it renders its own live
|
||||
// operator-facing output (an `effect:step` QR card / pairing code) — a
|
||||
// step-cost/interactivity declaration, not render advice; the event carries
|
||||
// `kind` + `line`, so a consumer wanting a different render policy can derive
|
||||
// its own.
|
||||
export type ApplyEvent =
|
||||
| { type: 'step-start'; kind: string; line: number; label: string | null }
|
||||
| { type: 'step-end'; kind: string; line: number; label: string | null; ok: boolean; durationMs: number; error?: string }
|
||||
| { type: 'operator'; line: number; text: string };
|
||||
// operator: text = the rendered, {{var}}-substituted block body;
|
||||
// line = the directive's opening-fence line (keys driver policy maps)
|
||||
|
||||
// The result of a streaming `nc:run effect:step`: the spawn's exit success plus
|
||||
// the terminal status block's fields, which `capture:<var>=<FIELD>` binds.
|
||||
export interface StepOutcome {
|
||||
ok: boolean;
|
||||
fields: Record<string, string>;
|
||||
}
|
||||
|
||||
export type StepStatus = 'skip' | 'apply' | 'needs-input' | 'agent';
|
||||
export interface PlanStep {
|
||||
n: number;
|
||||
kind: string;
|
||||
line: number;
|
||||
status: StepStatus;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
const read = (p: string) => (existsSync(p) ? readFileSync(p, 'utf8') : '');
|
||||
const has = (root: string, rel: string) => existsSync(join(root, rel));
|
||||
const VAR_REF = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
|
||||
const destOf = (line: string) => (line.includes('->') ? line.split('->')[1].trim() : line.trim());
|
||||
const srcOf = (line: string) => (line.includes('->') ? line.split('->')[0].trim() : line.trim());
|
||||
|
||||
function fileHasLine(root: string, rel: string, line: string): boolean {
|
||||
return read(join(root, rel))
|
||||
.split('\n')
|
||||
.some((l) => l.trim() === line.trim());
|
||||
}
|
||||
function pkgHasDep(root: string, name: string): boolean {
|
||||
try {
|
||||
const pkg = JSON.parse(read(join(root, 'package.json')) || '{}');
|
||||
return Boolean(pkg.dependencies?.[name] || pkg.devDependencies?.[name]);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function envKeySet(root: string, key: string): boolean {
|
||||
return read(join(root, '.env'))
|
||||
.split('\n')
|
||||
.some((l) => {
|
||||
const m = l.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/);
|
||||
return m !== null && m[1] === key && m[2].trim().length > 0;
|
||||
});
|
||||
}
|
||||
// Does the array-of-objects JSON at `rel` already contain an element whose
|
||||
// [key] equals `value`? The idempotency probe for json-merge.
|
||||
function jsonArrayHasKey(root: string, rel: string, key: string, value: unknown): boolean {
|
||||
try {
|
||||
const arr = JSON.parse(read(join(root, rel)) || '[]');
|
||||
return Array.isArray(arr) && arr.some((el) => el !== null && typeof el === 'object' && (el as Record<string, unknown>)[key] === value);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Per-directive idempotency check + "what it would do". Read-only.
|
||||
function selfStatus(d: Directive, root: string): { status: StepStatus; detail: string } {
|
||||
switch (d.kind) {
|
||||
case 'copy': {
|
||||
const dests = d.body.map(destOf);
|
||||
const missing = dests.filter((p) => !has(root, p));
|
||||
const from = d.attrs['from-branch'] ? `fetch ${String(d.attrs['from-branch'])} → ` : '';
|
||||
return missing.length
|
||||
? { status: 'apply', detail: `${from}copy ${missing.join(', ')} (absent)` }
|
||||
: { status: 'skip', detail: `${dests.join(', ')} present` };
|
||||
}
|
||||
case 'append': {
|
||||
const to = String(d.attrs.to ?? '');
|
||||
const line = d.body[0] ?? '';
|
||||
return fileHasLine(root, to, line)
|
||||
? { status: 'skip', detail: `${to} already has the line` }
|
||||
: { status: 'apply', detail: `add to ${to}: ${line}` };
|
||||
}
|
||||
case 'dep': {
|
||||
const missing = d.body.filter((s) => !pkgHasDep(root, s.slice(0, s.lastIndexOf('@'))));
|
||||
return missing.length
|
||||
? { status: 'apply', detail: `install ${missing.join(', ')}` }
|
||||
: { status: 'skip', detail: `${d.body.join(', ')} present` };
|
||||
}
|
||||
case 'run':
|
||||
return { status: 'apply', detail: `${String(d.attrs.effect ?? 'run')}: ${d.body.join(' && ')}` };
|
||||
case 'env-set': {
|
||||
const keys = d.body.map((l) => l.split('=')[0].trim());
|
||||
const missing = keys.filter((k) => !envKeySet(root, k));
|
||||
return missing.length
|
||||
? { status: 'apply', detail: `set ${missing.join(', ')} in .env` }
|
||||
: { status: 'skip', detail: `${keys.join(', ')} already set` };
|
||||
}
|
||||
case 'json-merge': {
|
||||
const into = String(d.attrs.into ?? '');
|
||||
const key = String(d.attrs.key ?? '');
|
||||
let value: unknown;
|
||||
try {
|
||||
value = (JSON.parse(d.body.join('\n')) as Record<string, unknown>)[key];
|
||||
} catch {
|
||||
return { status: 'agent', detail: `nc:json-merge body is not parseable JSON — an agent applies it from the prose` };
|
||||
}
|
||||
return jsonArrayHasKey(root, into, key, value)
|
||||
? { status: 'skip', detail: `${into} already has ${key}=${JSON.stringify(value)}` }
|
||||
: { status: 'apply', detail: `merge ${key}=${JSON.stringify(value)} into ${into}` };
|
||||
}
|
||||
case 'prompt':
|
||||
return { status: 'needs-input', detail: '' };
|
||||
case 'operator':
|
||||
return { status: 'apply', detail: `show operator: ${(d.body[0] ?? '').slice(0, 50)}…` };
|
||||
default:
|
||||
return { status: 'agent', detail: `no deterministic handler for nc:${d.kind} — an agent applies it from the prose` };
|
||||
}
|
||||
}
|
||||
|
||||
export function planSkill(skillDir: string, root: string): { steps: PlanStep[]; needsInput: string[]; agentSteps: number } {
|
||||
const directives = parseDirectives(read(join(skillDir, 'SKILL.md')));
|
||||
const self = directives.map((d) => ({ d, ...selfStatus(d, root) }));
|
||||
|
||||
const consumers = new Map<string, number[]>();
|
||||
self.forEach(({ d }, i) => {
|
||||
for (const line of d.body) for (const m of line.matchAll(VAR_REF)) (consumers.get(m[1]) ?? consumers.set(m[1], []).get(m[1])!).push(i);
|
||||
});
|
||||
|
||||
const steps: PlanStep[] = self.map(({ d, status, detail }, i) => {
|
||||
if (d.kind !== 'prompt') return { n: i + 1, kind: d.kind, line: d.line, status, detail };
|
||||
const v = promptVar(d) ?? '?';
|
||||
const tag = `${v}${d.args.includes('secret') ? ' (secret)' : ''}`;
|
||||
const cons = consumers.get(v) ?? [];
|
||||
const satisfied = cons.length > 0 && cons.every((j) => self[j].status === 'skip');
|
||||
return satisfied
|
||||
? { n: i + 1, kind: d.kind, line: d.line, status: 'skip', detail: `${tag} — consumers already satisfied` }
|
||||
: { n: i + 1, kind: d.kind, line: d.line, status: 'needs-input', detail: `${tag} → asked during apply` };
|
||||
});
|
||||
|
||||
return {
|
||||
steps,
|
||||
needsInput: steps.filter((s) => s.status === 'needs-input').map((s) => s.detail.split(' ')[0]),
|
||||
agentSteps: steps.filter((s) => s.status === 'agent').length,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Apply (phases 3–5) + journal-derived remove.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type JournalEntry =
|
||||
| { op: 'wrote'; path: string }
|
||||
| { op: 'appended'; path: string; line: string }
|
||||
| { op: 'set-env'; key: string }
|
||||
| { op: 'json-merge'; path: string; key: string; value: unknown }
|
||||
| { op: 'ran'; cmd: string; undo?: string };
|
||||
|
||||
export interface AgentTask {
|
||||
kind: string;
|
||||
line: number;
|
||||
reason: string;
|
||||
prose: string; // the surrounding prose the agent reads to apply the step
|
||||
}
|
||||
|
||||
export interface ApplyResult {
|
||||
applied: string[];
|
||||
skipped: string[];
|
||||
deferred: string[]; // prompt vars / blocked consumers with no value yet
|
||||
agentTasks: AgentTask[]; // bounced to an agent — NOT the human
|
||||
operatorMessages: string[]; // `nc:operator` bodies to relay to the human operator
|
||||
// Non-secret resolved values (prompt answers + `run capture:<var>` outputs) so
|
||||
// a caller can read what the skill produced — e.g. a channel skill resolves
|
||||
// `owner_handle` + `platform_id`, the setup flow reads them to wire the agent.
|
||||
vars: Record<string, string>;
|
||||
journal: JournalEntry[];
|
||||
// The skill's author-written REFERENCE floor — its `## Alternatives`,
|
||||
// `## Optional configuration`, and `## Troubleshooting` sections, sliced
|
||||
// verbatim from the RAW markdown (see `referenceProse`). The driver surfaces
|
||||
// this beside the agentTasks on a bounce: the same prose a human reader would
|
||||
// scroll to when a step doesn't apply cleanly. Sliced on the author headings,
|
||||
// never the resolved {{var}} map, so a resolved {{secret}} can never leak in.
|
||||
referenceProse: string;
|
||||
}
|
||||
|
||||
export interface ApplyOptions {
|
||||
// Pre-supplied answers for `prompt` vars (var name → value). Checked FIRST, so
|
||||
// a caller that has every answer needs no resolver at all and the whole skill
|
||||
// runs through with no human interaction (fully programmatic apply).
|
||||
inputs?: Record<string, string>;
|
||||
// The core input seam: resolve a prompt var the caller didn't pre-supply.
|
||||
// `meta` carries the declared semantics (question, secret,
|
||||
// validate/flags/normalize) so a consumer can run its OWN re-ask loop.
|
||||
// Returning undefined ⇒ defer. Optional — omit it (with full `inputs`) for a
|
||||
// headless run; a prompt with neither defers.
|
||||
resolveInput?: (name: string, meta: InputMeta) => Promise<string | undefined>;
|
||||
// The core output seam: every engine emission — the step-start/step-end
|
||||
// brackets and each rendered `nc:operator` block — flows through this one
|
||||
// handler, and every call is AWAITED before the engine proceeds (that
|
||||
// ordering is what lets a consumer gate on an operator block). A rejection is
|
||||
// treated like any other throw at that directive: bounce, never crash — a
|
||||
// consumer that throws on an operator event accepts the bounce consequence,
|
||||
// including the `blocked` latch cascading over later side effects. Absent ⇒
|
||||
// silent; the headless/programmatic apply runs identically.
|
||||
onEvent?: (e: ApplyEvent) => void | Promise<void>;
|
||||
// dep/run/branch-fetch; injectable for tests. Returns the command's stdout so
|
||||
// a `run capture:<var>` can bind it into a {{var}} (the twin of `prompt`).
|
||||
exec?: (cmd: string) => string | void | Promise<string | void>;
|
||||
// Streaming exec for `nc:run effect:step`: spawns a long-running, operator-
|
||||
// interactive step (a pairing code, a QR device-link) that emits
|
||||
// `=== NANOCLAW SETUP: … ===` status blocks, renders them to the operator live,
|
||||
// and resolves with the terminal block's fields (bound via capture:<var>=<FIELD>).
|
||||
// Absent ⇒ a step directive degrades to an agent (runs the step from the prose).
|
||||
execStream?: (cmd: string) => Promise<StepOutcome>;
|
||||
// Run effects the CALLER owns and will perform itself — those runs are skipped
|
||||
// (not executed). e.g. a headless rebuild or a setup that restarts once at the
|
||||
// end passes ['restart']; applyProviderSkill passes ['build','test'].
|
||||
skipEffects?: string[];
|
||||
// Resolve which remote carries a `from-branch` registry branch. Defaults to a
|
||||
// generic resolver (env override → first remote that has the branch → origin);
|
||||
// setup injects one that reuses setup/lib/channels-remote.sh for exact parity.
|
||||
resolveRemote?: (branch: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a skill applied completely — nothing deferred for a missing input and
|
||||
* nothing bounced to an agent. The check a programmatic caller makes to confirm a
|
||||
* fully-headless run-through succeeded.
|
||||
*/
|
||||
export function fullyApplied(res: ApplyResult): boolean {
|
||||
return res.deferred.length === 0 && res.agentTasks.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The failure diagnosis for the FIRST directive that bounced to an agent, in
|
||||
* document order: a concise headline (the nearest section heading) plus the
|
||||
* bounced step's own prose as the hint. The setup driver surfaces this when a
|
||||
* channel skill doesn't fully apply — the prose beside the step that failed
|
||||
* becomes the operator's failure hint and the Claude-handoff context, instead
|
||||
* of a generic "couldn't finish" message. Returns undefined when nothing
|
||||
* bounced (e.g. a headless rebuild only left prompts deferred — not a failure).
|
||||
*/
|
||||
export function firstFailureHint(res: ApplyResult): { headline: string; hint: string } | undefined {
|
||||
const first = res.agentTasks[0];
|
||||
if (!first) return undefined;
|
||||
const hint = first.prose.trim();
|
||||
// The concise headline: the nearest `#`-heading the prose carries, stripped of
|
||||
// its markers; failing that, the first prose line; failing that, the reason.
|
||||
const lines = first.prose.split('\n').map((l) => l.trim()).filter(Boolean);
|
||||
const heading = lines.find((l) => l.startsWith('#'));
|
||||
const headline = heading ? heading.replace(/^#+\s*/, '').trim() : (lines[0] ?? first.reason);
|
||||
return { headline, hint };
|
||||
}
|
||||
|
||||
// The author-written REFERENCE sections the apply engine ignores entirely:
|
||||
// `## Alternatives`, `## Optional configuration`, `## Troubleshooting`. Matched
|
||||
// on the heading text (lowercased), level-2 only.
|
||||
const REFERENCE_HEADINGS = new Set(['alternatives', 'optional configuration', 'troubleshooting']);
|
||||
|
||||
/**
|
||||
* Slice a skill's reference floor out of its raw markdown — the
|
||||
* `## Alternatives` / `## Optional configuration` / `## Troubleshooting` sections
|
||||
* the engine never executes. This is the human floor a reader scrolls to (a
|
||||
* dedicated-number path, optional env knobs, dropped-symptom fixes); the driver
|
||||
* surfaces it beside the bounced agentTasks so the operator has the same
|
||||
* reference. Returned VERBATIM from the author text keyed on the headings — never
|
||||
* from the resolved {{var}} map — so a resolved {{secret}} can never leak into it
|
||||
* (a `{{token}}` placeholder, if a reference section ever wrote one, stays a
|
||||
* literal placeholder). Any stray `nc:` directive fence inside a section is
|
||||
* dropped: reference prose is plain bash/json/text only — an `nc:` block belongs
|
||||
* under Apply, never here. Fence state is tracked so a `# comment` line inside a
|
||||
* code block is never mistaken for a markdown heading that would end the slice.
|
||||
*/
|
||||
export function referenceProse(md: string): string {
|
||||
const sections: string[] = [];
|
||||
let cur: string[] | null = null; // lines of the section being collected, or null
|
||||
let fence: string | null = null; // open fence's info-string ('' for a bare fence), or null
|
||||
const keep = (line: string): void => {
|
||||
// Inside (or toggling) an `nc:` fence ⇒ drop; otherwise collect when capturing.
|
||||
if (cur && !(fence ?? '').startsWith('nc:')) cur.push(line);
|
||||
};
|
||||
for (const line of md.split('\n')) {
|
||||
if (line.startsWith('```')) {
|
||||
if (fence === null) {
|
||||
fence = line.slice(3).trim();
|
||||
keep(line);
|
||||
} else {
|
||||
keep(line); // closing fence — `fence` still holds the opening info-string
|
||||
fence = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (fence !== null) { keep(line); continue; } // fence body
|
||||
const h = line.match(/^(#{1,6})\s+(.*)$/);
|
||||
if (h) {
|
||||
const level = h[1].length;
|
||||
const text = h[2].trim().toLowerCase();
|
||||
if (level === 2 && REFERENCE_HEADINGS.has(text)) {
|
||||
if (cur) sections.push(cur.join('\n').trim());
|
||||
cur = [line]; // open a new reference section
|
||||
} else if (level <= 2) {
|
||||
if (cur) { sections.push(cur.join('\n').trim()); cur = null; } // a non-reference h1/h2 closes the slice
|
||||
} else if (cur) {
|
||||
cur.push(line); // a subsection (### …) inside a captured reference section
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (cur) cur.push(line);
|
||||
}
|
||||
if (cur) sections.push(cur.join('\n').trim());
|
||||
return sections.filter(Boolean).join('\n\n').trim();
|
||||
}
|
||||
|
||||
// A hardcoded `origin` breaks forks where the registry branch lives on
|
||||
// `upstream`. Generic mirror of channels-remote.sh: explicit override → the
|
||||
// first remote that actually has the branch → origin.
|
||||
function defaultResolveRemote(branch: string, root: string): string {
|
||||
const override = process.env.NANOCLAW_CHANNELS_REMOTE;
|
||||
if (override) return override;
|
||||
const cap = (cmd: string): string => {
|
||||
try {
|
||||
return execSync(cmd, { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
const remotes = cap('git remote').split('\n').map((s) => s.trim()).filter(Boolean);
|
||||
const ordered = remotes.includes('origin') ? ['origin', ...remotes.filter((r) => r !== 'origin')] : remotes;
|
||||
for (const r of ordered) if (cap(`git ls-remote --heads ${r} ${branch}`).trim()) return r;
|
||||
return 'origin';
|
||||
}
|
||||
|
||||
// The prose an agent reads when a step degrades: nearest heading + the
|
||||
// paragraph immediately above the directive fence.
|
||||
function proseFor(md: string, fenceLine1: number): string {
|
||||
const lines = md.split('\n');
|
||||
let i = fenceLine1 - 2;
|
||||
while (i >= 0 && lines[i].trim() === '') i--;
|
||||
const para: string[] = [];
|
||||
while (i >= 0 && lines[i].trim() !== '' && !lines[i].startsWith('#')) para.unshift(lines[i--]);
|
||||
let heading = '';
|
||||
for (let h = i; h >= 0; h--) if (lines[h].startsWith('#')) { heading = lines[h]; break; }
|
||||
return [heading, ...para].filter(Boolean).join('\n').trim();
|
||||
}
|
||||
|
||||
// The nearest `#`-prefixed heading above a fence (the same upward scan proseFor
|
||||
// uses), stripped of its leading `#`s — a concise caption for a step spinner.
|
||||
function headingAbove(md: string, fenceLine1: number): string {
|
||||
const lines = md.split('\n');
|
||||
for (let h = fenceLine1 - 2; h >= 0; h--) {
|
||||
if (lines[h].startsWith('#')) return lines[h].replace(/^#+\s*/, '').trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// The run effects worth a spinner — the slow, operator-waits-on-it ones.
|
||||
// `effect:step` is deliberately absent: it renders its own live operator output
|
||||
// (a QR card, a pairing code) that a concurrent spinner would clobber, so it
|
||||
// stays unlabelled (null) like the instant kinds.
|
||||
const SPIN_EFFECTS = new Set(['build', 'test', 'fetch', 'wire', 'restart', 'external']);
|
||||
|
||||
/**
|
||||
* The human caption a consumer may show for a step. `null` is a DECLARATION,
|
||||
* not render advice: the step is instant/cheap (a local file copy, an env
|
||||
* write, a json-merge), or it renders its own live operator-facing output
|
||||
* (`effect:step`'s QR card / pairing code) — the step event still carries
|
||||
* `kind` + `line`, so a consumer wanting a different render policy can derive
|
||||
* its own. Labels are HEADING-DERIVED only: the caption is the nearest heading
|
||||
* above the directive (so a consumer's progress line reads like the section
|
||||
* it's in), falling back to a kind/effect default.
|
||||
*/
|
||||
export function stepLabel(d: Directive, md: string): string | null {
|
||||
const effect = typeof d.attrs.effect === 'string' ? d.attrs.effect : undefined;
|
||||
const spins =
|
||||
d.kind === 'dep' ||
|
||||
(d.kind === 'copy' && typeof d.attrs['from-branch'] === 'string') ||
|
||||
(d.kind === 'run' && (effect === undefined || SPIN_EFFECTS.has(effect)));
|
||||
if (!spins) return null;
|
||||
const heading = headingAbove(md, d.line);
|
||||
if (heading) return heading;
|
||||
if (d.kind === 'dep') return 'Installing dependencies';
|
||||
if (d.kind === 'copy') return 'Fetching files';
|
||||
const byEffect: Record<string, string> = {
|
||||
build: 'Building', test: 'Testing', fetch: 'Fetching',
|
||||
wire: 'Wiring', restart: 'Restarting', external: 'Running',
|
||||
};
|
||||
return (effect && byEffect[effect]) || 'Running';
|
||||
}
|
||||
|
||||
// Deterministic input normalization applied AT BIND to every prompt value —
|
||||
// `inputs` AND interactive answers alike — driven by `nc:prompt normalize:<how>`:
|
||||
// trim strip leading/trailing whitespace
|
||||
// rstrip-slash drop trailing slash(es) — a base URL with no trailing path
|
||||
// lower lowercase
|
||||
// Absent/unknown ⇒ a no-op (lint gates the known set). Doing it here, not in the
|
||||
// consumer, means a programmatic `inputs` value and a typed answer land identically.
|
||||
// Exported so the driver's reuse-offer pre-filter (§5.4) tests an `.env` value
|
||||
// against the SAME normalize-then-validate the engine will apply at bind.
|
||||
export function normalizeValue(value: string, normalize: string | undefined): string {
|
||||
switch (normalize) {
|
||||
case 'trim':
|
||||
return value.trim();
|
||||
case 'rstrip-slash':
|
||||
return value.replace(/\/+$/, '');
|
||||
case 'lower':
|
||||
return value.toLowerCase();
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// The engine-applied normalize transforms (see `normalizeValue`) — the set
|
||||
// InputMeta.normalize narrows to. Lint gates authorship to these; an unknown
|
||||
// value simply isn't declared in the meta (and normalizeValue no-ops on it).
|
||||
const NORMALIZE_KINDS: ReadonlySet<string> = new Set(['trim', 'rstrip-slash', 'lower']);
|
||||
|
||||
// The InputMeta an `nc:prompt` declares — handed to `resolveInput` so a
|
||||
// consumer can run its own re-ask loop against the same semantics the engine
|
||||
// enforces at bind. The attrs live on the directive fence, so they're stripped
|
||||
// along with the fence when a skill degrades to prose — invisible to the agent.
|
||||
function inputMetaOf(d: Directive, secret: boolean, validate: string | undefined): InputMeta {
|
||||
const meta: InputMeta = { question: d.body.join('\n'), secret };
|
||||
if (validate !== undefined) meta.validate = validate;
|
||||
if (typeof d.attrs.flags === 'string') meta.flags = d.attrs.flags;
|
||||
if (typeof d.attrs.normalize === 'string' && NORMALIZE_KINDS.has(d.attrs.normalize)) {
|
||||
meta.normalize = d.attrs.normalize as InputMeta['normalize'];
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
function substitute(value: string, vars: Map<string, { value: string; secret: boolean }>): string {
|
||||
return value.replace(VAR_REF, (_, name) => {
|
||||
const v = vars.get(name);
|
||||
if (!v) throw new Error(`unresolved {{${name}}}`);
|
||||
return v.value;
|
||||
});
|
||||
}
|
||||
|
||||
// A `when:<var>=<value>` guard: the directive applies only when an earlier
|
||||
// prompt/capture bound <var> to exactly <value>. Unmet — including the var still
|
||||
// unresolved (a deferred prompt) — skips the directive, so a guarded prompt is
|
||||
// skipped, never deferred. This is how a skill expresses mutually-exclusive
|
||||
// branches (e.g. local vs remote install mode) in plain document order.
|
||||
function whenMet(when: string, vars: Map<string, { value: string; secret: boolean }>): boolean {
|
||||
const eq = when.indexOf('=');
|
||||
if (eq < 1) return true; // malformed → don't block (lint is the gate)
|
||||
return vars.get(when.slice(0, eq).trim())?.value === when.slice(eq + 1).trim();
|
||||
}
|
||||
|
||||
// Resolve a jq-style dot-path (`.id`, `.owner.id`) into a parsed JSON value.
|
||||
// A missing/non-object hop yields undefined — the caller coerces that to ''.
|
||||
function dotPath(obj: unknown, path: string): unknown {
|
||||
let cur: unknown = obj;
|
||||
for (const key of path.replace(/^\./, '').split('.').filter(Boolean)) {
|
||||
if (cur === null || typeof cur !== 'object') return undefined;
|
||||
cur = (cur as Record<string, unknown>)[key];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
// Bind a `run capture:<spec>` from a command's stdout into one or more {{vars}}.
|
||||
// • bare `capture:var` → binds the trimmed stdout as-is (unchanged).
|
||||
// • `capture:a=.x,b=.owner.id` → parses the stdout as JSON and binds each var
|
||||
// to its dot-path, so ONE API call resolves
|
||||
// several values (the structured twin of the
|
||||
// effect:step terminal-block capture — those
|
||||
// two are distinguished by effect: step reads
|
||||
// the status block, fetch/external read JSON
|
||||
// stdout). Unparseable JSON throws → the outer
|
||||
// catch bounces it to an agent.
|
||||
// An optional `validate:<re>` is enforced against every bound value; a mismatch
|
||||
// THROWS so the run bounces to an agent — a command's output has no human to
|
||||
// re-prompt, so an invalid capture is a real failure, not a re-ask.
|
||||
function bindCapture(
|
||||
spec: string,
|
||||
stdout: string,
|
||||
validate: string | undefined,
|
||||
vars: Map<string, { value: string; secret: boolean }>,
|
||||
): void {
|
||||
const re = validate ? new RegExp(validate) : undefined;
|
||||
const set = (name: string, value: string): void => {
|
||||
if (re && !re.test(value)) throw new Error(`captured ${name}="${value}" does not match validate:${validate}`);
|
||||
vars.set(name, { value, secret: false });
|
||||
};
|
||||
if (!spec.includes('=')) {
|
||||
set(spec, stdout);
|
||||
return;
|
||||
}
|
||||
const json = JSON.parse(stdout) as unknown; // not JSON → throws → outer catch bounces
|
||||
for (const pair of spec.split(',')) {
|
||||
const eq = pair.indexOf('=');
|
||||
if (eq < 1) continue;
|
||||
set(pair.slice(0, eq).trim(), String(dotPath(json, pair.slice(eq + 1).trim()) ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
// The mutating twin of selfStatus. Records what it did to the journal so remove
|
||||
// is derivable. Throws on failure → caught and bounced to an agent.
|
||||
async function applyOne(
|
||||
d: Directive,
|
||||
ctx: { root: string; skillDir: string; exec: (c: string) => string | void | Promise<string | void>; execStream?: (c: string) => Promise<StepOutcome>; resolveRemote: (b: string) => string; vars: Map<string, { value: string; secret: boolean }>; journal: JournalEntry[] },
|
||||
): Promise<void> {
|
||||
const { root, skillDir, exec, vars, journal } = ctx;
|
||||
switch (d.kind) {
|
||||
case 'copy':
|
||||
if (d.attrs['from-branch']) {
|
||||
const b = String(d.attrs['from-branch']);
|
||||
const remote = ctx.resolveRemote(b);
|
||||
await exec(`git fetch ${remote} ${b}`);
|
||||
for (const l of d.body) await exec(`git show ${remote}/${b}:${srcOf(l)} > ${destOf(l)}`);
|
||||
} else {
|
||||
for (const l of d.body) {
|
||||
const dst = join(root, destOf(l));
|
||||
mkdirSync(dirname(dst), { recursive: true });
|
||||
copyFileSync(join(skillDir, srcOf(l)), dst);
|
||||
}
|
||||
}
|
||||
for (const l of d.body) journal.push({ op: 'wrote', path: destOf(l) });
|
||||
break;
|
||||
case 'append': {
|
||||
const to = String(d.attrs.to);
|
||||
const marker = typeof d.attrs.at === 'string' ? d.attrs.at : undefined;
|
||||
const target = join(root, to);
|
||||
if (marker) {
|
||||
// Insert before the `// <<< <marker>` closing line of a dormant marker
|
||||
// region, matching that line's indentation. removeSkill still deletes
|
||||
// by line (position-agnostic), so the journal entry is unchanged.
|
||||
const close = `<<< ${marker}`;
|
||||
for (const line of d.body) {
|
||||
const lines = read(target).split('\n');
|
||||
const idx = lines.findIndex((l) => l.includes(close));
|
||||
if (idx === -1) throw new Error(`append marker "${marker}" not found in ${to}`);
|
||||
const indent = lines[idx].match(/^\s*/)?.[0] ?? '';
|
||||
lines.splice(idx, 0, indent + line);
|
||||
writeFileSync(target, lines.join('\n'));
|
||||
journal.push({ op: 'appended', path: to, line });
|
||||
}
|
||||
} else {
|
||||
for (const line of d.body) {
|
||||
appendFileSync(target, (read(target).endsWith('\n') || read(target) === '' ? '' : '\n') + line + '\n');
|
||||
journal.push({ op: 'appended', path: to, line });
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'dep': {
|
||||
await exec(`pnpm add ${d.body.join(' ')}`);
|
||||
const names = d.body.map((s) => s.slice(0, s.lastIndexOf('@'))).join(' ');
|
||||
journal.push({ op: 'ran', cmd: `pnpm add ${d.body.join(' ')}`, undo: `pnpm remove ${names}` });
|
||||
break;
|
||||
}
|
||||
case 'run': {
|
||||
// `capture:<var>` binds the command's stdout into a {{var}} — the twin of
|
||||
// `prompt` (which binds human input). Lets a run resolve a value from an
|
||||
// API (e.g. Slack conversations.open → the DM channel id) and feed it to a
|
||||
// later directive, so a flow that validates/resolves stays pure directives.
|
||||
const capture = typeof d.attrs.capture === 'string' ? d.attrs.capture : undefined;
|
||||
// A `validate:<re>` shape-guard the stdout capture enforces (see bindCapture).
|
||||
const validate = typeof d.attrs.validate === 'string' ? d.attrs.validate : undefined;
|
||||
// effect:check runs the body as a shell PREDICATE — a precondition gate
|
||||
// that mutates NOTHING. It pushes no journal entry and binds no capture: a
|
||||
// zero exit is a silent pass; a non-zero exit throws → the outer catch
|
||||
// bounces it to an agent (which reads the prose and decides); an unresolved
|
||||
// {{var}} throws from substitute first → deferred (like any other run, e.g.
|
||||
// a headless rebuild before the value is collected). Because a bounce here
|
||||
// latches `blocked`, a failed precondition gates the dangerous side effects
|
||||
// (a restart, a pairing/QR step, a wire) that follow — a broken local
|
||||
// config or an un-registered app never reaches a doomed restart/QR.
|
||||
if (d.attrs.effect === 'check') {
|
||||
for (const cmd of d.body) await exec(substitute(cmd, vars));
|
||||
break;
|
||||
}
|
||||
// effect:step runs a long-running, operator-interactive step (a pairing
|
||||
// code, a QR device-link) through the streaming exec and binds the terminal
|
||||
// status block's named fields via capture:<var>=<FIELD>[,…] — the structured,
|
||||
// multi-valued twin of stdout capture. No streaming exec ⇒ throw → an agent
|
||||
// runs the step from the prose (degrade, not crash).
|
||||
if (d.attrs.effect === 'step') {
|
||||
if (!ctx.execStream) throw new Error('effect:step needs a streaming exec — an agent runs the step from the prose');
|
||||
const { ok, fields } = await ctx.execStream(substitute(d.body.join('\n'), vars));
|
||||
if (!ok) throw new Error('the step did not complete');
|
||||
if (capture) {
|
||||
for (const pair of capture.split(',')) {
|
||||
const eq = pair.indexOf('=');
|
||||
if (eq < 1) continue;
|
||||
vars.set(pair.slice(0, eq).trim(), { value: (fields[pair.slice(eq + 1).trim()] ?? '').trim(), secret: false });
|
||||
}
|
||||
}
|
||||
journal.push({ op: 'ran', cmd: d.body.join('\n') });
|
||||
break;
|
||||
}
|
||||
for (const cmd of d.body) {
|
||||
// Interpolate prompted {{vars}} the same way env-set does, so a run can
|
||||
// call `ncl ... {{owner_email}}` to wire from collected input. A command
|
||||
// with no {{...}} (build/test) is returned unchanged; an unresolved var
|
||||
// throws → caught → deferred (the prompt hasn't been answered yet).
|
||||
const out = await exec(substitute(cmd, vars));
|
||||
// Last command wins for capture (a capture run should be a single command).
|
||||
// bindCapture binds stdout-as-is OR a multi-field JSON spec, and enforces
|
||||
// validate:<re> — a mismatch / unparseable JSON throws → bounced to an agent.
|
||||
if (capture) bindCapture(capture, typeof out === 'string' ? out.trim() : '', validate, vars);
|
||||
// Journal the ORIGINAL command (placeholders intact) — never the
|
||||
// substituted form — so a secret interpolated into a run never lands in
|
||||
// the journal (or a remove replay).
|
||||
const undo = d.attrs.effect === 'external' && typeof d.attrs.remove === 'string' ? d.attrs.remove : undefined;
|
||||
journal.push({ op: 'ran', cmd, undo });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'env-set': {
|
||||
const envPath = join(root, '.env');
|
||||
for (const entry of d.body) {
|
||||
const eq = entry.indexOf('=');
|
||||
const key = entry.slice(0, eq).trim();
|
||||
const value = substitute(entry.slice(eq + 1).trim(), vars); // throws if a {{var}} is unresolved
|
||||
if (!envKeySet(root, key)) {
|
||||
appendFileSync(envPath, (read(envPath).endsWith('\n') || read(envPath) === '' ? '' : '\n') + `${key}=${value}\n`);
|
||||
journal.push({ op: 'set-env', key });
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'json-merge': {
|
||||
const into = String(d.attrs.into);
|
||||
const key = String(d.attrs.key);
|
||||
const obj = JSON.parse(d.body.join('\n')) as Record<string, unknown>;
|
||||
const target = join(root, into);
|
||||
const arr = JSON.parse(read(target) || '[]') as unknown[];
|
||||
if (!Array.isArray(arr)) throw new Error(`${into} is not a JSON array`);
|
||||
const value = obj[key];
|
||||
// Idempotent: only push when no element already matches on the key.
|
||||
if (!arr.some((el) => el !== null && typeof el === 'object' && (el as Record<string, unknown>)[key] === value)) {
|
||||
arr.push(obj);
|
||||
writeFileSync(target, JSON.stringify(arr, null, 2) + '\n');
|
||||
journal.push({ op: 'json-merge', path: into, key, value });
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`no handler for nc:${d.kind}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function applySkill(skillDir: string, root: string, opts: ApplyOptions): Promise<ApplyResult> {
|
||||
// Lint (validate()) is the authoring/CI gate, run before a skill ships — NOT
|
||||
// here. Apply is best-effort: an unknown directive (a typo lint should have
|
||||
// caught, or one newer than this engine) bounces to an agent, never blocks.
|
||||
const md = read(join(skillDir, 'SKILL.md'));
|
||||
const directives = parseDirectives(md);
|
||||
const exec = opts.exec ?? (() => { throw new Error('no exec provided'); });
|
||||
const resolveRemote = opts.resolveRemote ?? ((b: string) => defaultResolveRemote(b, root));
|
||||
const vars = new Map<string, { value: string; secret: boolean }>();
|
||||
const res: ApplyResult = { applied: [], skipped: [], deferred: [], agentTasks: [], operatorMessages: [], vars: {}, journal: [], referenceProse: referenceProse(md) };
|
||||
// A run-health gate: once ANY directive bounces to an agent, the skill is no
|
||||
// longer in a known-good state, so the dangerous side effects below must not
|
||||
// fire on their own — a live restart, an interactive pairing/QR step, or a wire
|
||||
// launched after an upstream failure just wastes the operator's time (a doomed
|
||||
// QR, a restart that loads a bad credential). `blocked` latches on the first
|
||||
// bounce; a later side-effecting run becomes its own bounce so the agent
|
||||
// finishes it from the prose once the upstream failure is fixed. A DEFERRED
|
||||
// prompt (headless rebuild, no answer) is not a failure — it never bounces, so
|
||||
// `blocked` stays false and a later restart remains runnable.
|
||||
let blocked = false;
|
||||
const SIDE_EFFECTS = new Set(['restart', 'step', 'wire']);
|
||||
const bounce = (d: Directive, reason: string) => {
|
||||
blocked = true;
|
||||
res.agentTasks.push({ kind: d.kind, line: d.line, reason, prose: proseFor(md, d.line) });
|
||||
};
|
||||
|
||||
for (const d of directives) {
|
||||
// Tracks an in-flight step so the catch can always close a matching
|
||||
// step-end (start/end stay balanced even when applyOne throws — a consumer's
|
||||
// spinner is never orphaned). Set only after step-start fires.
|
||||
let inFlight: { label: string | null; at: number } | null = null;
|
||||
try {
|
||||
// A `when:<var>=<value>` guard that isn't met skips the directive entirely —
|
||||
// before prompt (so a guarded prompt is skipped, never deferred), operator,
|
||||
// and run handling. This is how mutually-exclusive branches coexist in one
|
||||
// skill while a fully-programmatic apply still completes.
|
||||
if (typeof d.attrs.when === 'string' && !whenMet(d.attrs.when, vars)) {
|
||||
res.skipped.push(`${d.kind}: when ${d.attrs.when} not met`);
|
||||
continue;
|
||||
}
|
||||
if (d.kind === 'prompt') {
|
||||
const v = promptVar(d)!;
|
||||
const secret = d.args.includes('secret');
|
||||
const validate = typeof d.attrs.validate === 'string' ? d.attrs.validate : undefined;
|
||||
const flags = typeof d.attrs.flags === 'string' ? d.attrs.flags : undefined;
|
||||
const normalize = typeof d.attrs.normalize === 'string' ? d.attrs.normalize : undefined;
|
||||
// Pre-supplied inputs win OUTRIGHT (fully-programmatic apply) — an
|
||||
// invalid `inputs` value never falls through to a second acquisition
|
||||
// path (validation below rejects it loudly instead). Otherwise resolve
|
||||
// via `resolveInput`; still undefined ⇒ defer (headless, no answer).
|
||||
let val = opts.inputs?.[v];
|
||||
if (val === undefined) val = await opts.resolveInput?.(v, inputMetaOf(d, secret, validate));
|
||||
if (val === undefined) { res.deferred.push(v); continue; }
|
||||
// normalize:<how> binds DETERMINISTICALLY for both inputs and answers, so
|
||||
// an `inputs` value and a typed one land identically (a trailing slash
|
||||
// stripped, whitespace trimmed) — see normalizeValue.
|
||||
const bound = normalizeValue(val, normalize);
|
||||
// Validate-at-bind: `validate:` (+ `flags:`) is DATA validation, enforced
|
||||
// on the NORMALIZED value no matter where it came from (normalize-then-
|
||||
// validate is normative: a trailing slash is stripped before an anchor
|
||||
// check). On a mismatch the var stays UNBOUND and only the var name +
|
||||
// regex source land in the deferred entry — never the value, so a secret
|
||||
// can't leak. Not an agentTask, not a throw: downstream consumers defer
|
||||
// exactly as if the value were never supplied, `fullyApplied` is false,
|
||||
// and a pipeline passing a malformed env value fails loudly. The
|
||||
// interactive re-ask loop lives in the consumer's `resolveInput`; this is
|
||||
// the backstop for programmatic paths.
|
||||
if (validate !== undefined && !new RegExp(validate, flags).test(bound)) {
|
||||
res.deferred.push(`${v}: invalid value (does not match validate:${validate})`);
|
||||
continue;
|
||||
}
|
||||
vars.set(v, { value: bound, secret });
|
||||
continue;
|
||||
}
|
||||
if (d.kind === 'operator') {
|
||||
// Once the run is blocked, walking the human through further manual
|
||||
// steps is actively misleading — the side effects those instructions
|
||||
// lead up to ("a pairing code is about to appear") have already been
|
||||
// gated. Skip: no event (so a consumer's URL offer / readiness confirm
|
||||
// never fires), no operatorMessages entry (a failed run's manual-steps
|
||||
// report must not include steps predicated on the failed one).
|
||||
if (blocked) {
|
||||
res.skipped.push('operator: skipped after an earlier failure');
|
||||
continue;
|
||||
}
|
||||
// Always collect the human-facing instructions into the result so a
|
||||
// programmatic caller can relay/output them. {{vars}} render so a
|
||||
// resolved value can be shown (throws → deferred if a referenced var is
|
||||
// unset — the whole block defers before any event fires).
|
||||
const text = substitute(d.body.join('\n'), vars);
|
||||
res.operatorMessages.push(text);
|
||||
// The core seam: emit the rendered block and AWAIT the consumer before
|
||||
// evaluating the next directive — that ordering is what lets a consumer
|
||||
// gate (hold the event until the human confirms readiness). The engine
|
||||
// itself never defers/bounces an operator block; a handler that throws
|
||||
// opts into the standard bounce path via the outer catch (including
|
||||
// the `blocked` latch over later side effects).
|
||||
if (opts.onEvent) await opts.onEvent({ type: 'operator', line: d.line, text });
|
||||
res.applied.push(`operator: ${(d.body[0] ?? '').slice(0, 50)}`);
|
||||
continue;
|
||||
}
|
||||
// A run whose effect the caller owns (e.g. restart) is skipped here.
|
||||
if (d.kind === 'run' && typeof d.attrs.effect === 'string' && opts.skipEffects?.includes(d.attrs.effect)) {
|
||||
res.skipped.push(`run ${d.attrs.effect}: owned by the caller`);
|
||||
continue;
|
||||
}
|
||||
// Run-health gate: after an earlier bounce, never fire a dangerous side
|
||||
// effect (a live restart, an interactive pairing/QR step, a wire) on its
|
||||
// own — bounce it too so the agent runs it from the prose once the upstream
|
||||
// failure is fixed. (A deferred prompt did NOT set `blocked`, so this only
|
||||
// trips on a real failure, never a headless rebuild's missing input.)
|
||||
if (d.kind === 'run' && typeof d.attrs.effect === 'string' && SIDE_EFFECTS.has(d.attrs.effect) && blocked) {
|
||||
bounce(d, 'skipped: an earlier step did not complete — run this from the prose after fixing it');
|
||||
continue;
|
||||
}
|
||||
const st = selfStatus(d, root);
|
||||
if (st.status === 'agent') { bounce(d, 'no deterministic handler'); continue; }
|
||||
if (st.status === 'skip') { res.skipped.push(`${d.kind}: ${st.detail}`); continue; }
|
||||
// Bracket the real mutation with step events so a consumer can render
|
||||
// progress. `label` null is a step-cost/interactivity declaration (see
|
||||
// `stepLabel`). `inFlight` is set only after step-start fires; the ok:true
|
||||
// step-end clears it BEFORE its own (awaited) emission, so a consumer
|
||||
// throw there never double-closes.
|
||||
const label = stepLabel(d, md);
|
||||
if (opts.onEvent) await opts.onEvent({ type: 'step-start', kind: d.kind, line: d.line, label });
|
||||
inFlight = { label, at: Date.now() };
|
||||
await applyOne(d, { root, skillDir, exec, execStream: opts.execStream, resolveRemote, vars, journal: res.journal });
|
||||
const durationMs = Date.now() - inFlight.at;
|
||||
inFlight = null;
|
||||
if (opts.onEvent) await opts.onEvent({ type: 'step-end', kind: d.kind, line: d.line, label, ok: true, durationMs });
|
||||
res.applied.push(`${d.kind}: ${st.detail}`);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
// Close the step as failed before classifying — keeps step-start/step-end
|
||||
// balanced whether the throw becomes a deferred (unresolved input) or a
|
||||
// bounce (a real failure, handled below). The failure-path close is
|
||||
// best-effort: a consumer that also throws here can't change the outcome —
|
||||
// we're already on the failure path.
|
||||
if (inFlight && opts.onEvent) {
|
||||
const end = { kind: d.kind, line: d.line, label: inFlight.label, ok: false, durationMs: Date.now() - inFlight.at, error: msg };
|
||||
try { await opts.onEvent({ type: 'step-end', ...end }); } catch { /* already failing — the close is best-effort */ }
|
||||
}
|
||||
if (/unresolved \{\{/.test(msg)) res.deferred.push(msg); // blocked on a prompt input
|
||||
else bounce(d, `engine could not apply (${msg}) — an agent applies it from the prose`);
|
||||
}
|
||||
}
|
||||
// Surface the non-secret resolved values for a caller to consume.
|
||||
for (const [k, v] of vars) if (!v.secret) res.vars[k] = v.value;
|
||||
return res;
|
||||
}
|
||||
|
||||
// Remove is the journal played backwards — no hand-written REMOVE.md.
|
||||
export async function removeSkill(root: string, journal: JournalEntry[], exec?: (c: string) => void | Promise<void>): Promise<void> {
|
||||
for (const e of [...journal].reverse()) {
|
||||
if (e.op === 'wrote') rmSync(join(root, e.path), { force: true });
|
||||
else if (e.op === 'appended') {
|
||||
const p = join(root, e.path);
|
||||
writeFileSync(p, read(p).split('\n').filter((l) => l.trim() !== e.line.trim()).join('\n'));
|
||||
} else if (e.op === 'set-env') {
|
||||
const p = join(root, '.env');
|
||||
writeFileSync(p, read(p).split('\n').filter((l) => !l.startsWith(`${e.key}=`)).join('\n'));
|
||||
} else if (e.op === 'json-merge') {
|
||||
const p = join(root, e.path);
|
||||
const arr = JSON.parse(read(p) || '[]') as unknown[];
|
||||
if (Array.isArray(arr)) {
|
||||
writeFileSync(p, JSON.stringify(arr.filter((el) => !(el !== null && typeof el === 'object' && (el as Record<string, unknown>)[e.key] === e.value)), null, 2) + '\n');
|
||||
}
|
||||
} else if (e.op === 'ran' && e.undo && exec) {
|
||||
await exec(e.undo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CLI — the planner (no writes)
|
||||
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
|
||||
const skillDir = process.argv[2];
|
||||
if (!skillDir) {
|
||||
console.error('usage: pnpm exec tsx scripts/skill-apply.ts <skillDir>');
|
||||
process.exit(2);
|
||||
}
|
||||
const root = process.cwd();
|
||||
const { steps, needsInput, agentSteps } = planSkill(skillDir, root);
|
||||
console.log(`PLAN ${skillDir} project: ${root}\n`);
|
||||
const icon: Record<StepStatus, string> = { skip: '✓ skip', apply: '→ apply', 'needs-input': '? human', agent: '↳ agent' };
|
||||
for (const s of steps) console.log(`${String(s.n).padStart(2)}. ${icon[s.status].padEnd(8)} ${s.kind.padEnd(9)} ${s.detail}`);
|
||||
console.log(`\nneeds human input: ${needsInput.join(', ') || '(none)'} →agent: ${agentSteps}`);
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { parseDirectives, validate, promptVar, resolveChatCoreVersion, lintReferenceFloor, lintGateAmbiguity } from './skill-directives.js';
|
||||
|
||||
// Guards the structured-directive format against the converted add-slack skill:
|
||||
// red if the conversion drifts (a directive dropped/renamed) or the parser breaks.
|
||||
const slack = readFileSync('.claude/skills/add-slack/SKILL.md', 'utf8');
|
||||
const directives = parseDirectives(slack);
|
||||
|
||||
describe('skill-directives parser, on the converted add-slack', () => {
|
||||
it('extracts every directive in document order — install, credentials, resolve, restart', () => {
|
||||
expect(directives.map((d) => d.kind)).toEqual([
|
||||
'copy', // step 1: adapter + test from the channels branch
|
||||
'append', // step 2: barrel registration
|
||||
'dep', // step 3: pinned package
|
||||
'run', // step 4: build
|
||||
'run', // step 4: test
|
||||
'prompt', // credentials: socket vs webhook delivery mode
|
||||
'operator', // credentials: create-app walkthrough, Socket Mode variant
|
||||
'operator', // credentials: create-app walkthrough, webhook variant
|
||||
'prompt', // credentials: capture bot token
|
||||
'prompt', // credentials: capture app-level token (socket only)
|
||||
'prompt', // credentials: capture signing secret (webhook only)
|
||||
'env-set', // credentials: bot token (both modes)
|
||||
'env-set', // credentials: app token — doubles as the Socket Mode switch
|
||||
'env-set', // credentials: signing secret (webhook only)
|
||||
'operator', // credentials: event-delivery walkthrough (webhook only)
|
||||
'prompt', // resolve: owner member id (owner_handle)
|
||||
'run', // resolve: validate token (auth.test) — fast-fail before the restart
|
||||
'run', // resolve: DM channel (conversations.open → capture:platform_id)
|
||||
'run', // restart: load the adapter + creds once the credential is validated
|
||||
]);
|
||||
// The wire (owner role, messaging-group, wiring, /welcome) is NOT in the
|
||||
// skill — it's the shared init-first-agent, called by the setup flow.
|
||||
expect(directives.some((d) => d.attrs.effect === 'wire')).toBe(false);
|
||||
});
|
||||
|
||||
it('delineates the human UI steps as nc:operator (not agent prose or a run)', () => {
|
||||
const ops = directives.filter((d) => d.kind === 'operator');
|
||||
expect(ops).toHaveLength(3);
|
||||
expect(ops[0].body.join('\n')).toMatch(/Create the Slack app \(Socket Mode\)/);
|
||||
expect(ops[0].body.join('\n')).toMatch(/connections:write/);
|
||||
expect(ops[1].body.join('\n')).toMatch(/Create the Slack app \(webhook delivery\)/);
|
||||
expect(ops[1].body.join('\n')).toMatch(/Bot Token Scopes/);
|
||||
expect(ops[2].body.join('\n')).toMatch(/Event Subscriptions/);
|
||||
// the mode branches are guard-delineated, one per delivery mode
|
||||
expect(ops[0].attrs.when).toBe('connection=socket');
|
||||
expect(ops[1].attrs.when).toBe('connection=webhook');
|
||||
expect(ops[2].attrs.when).toBe('connection=webhook');
|
||||
});
|
||||
|
||||
it('reads copy as a branch fetch with both files', () => {
|
||||
const copy = directives.find((d) => d.kind === 'copy')!;
|
||||
expect(copy.attrs['from-branch']).toBe('channels');
|
||||
expect(copy.body).toEqual(['src/channels/slack.ts', 'src/channels/slack-registration.test.ts']);
|
||||
});
|
||||
|
||||
it('reads the barrel append target and line', () => {
|
||||
const append = directives.find((d) => d.kind === 'append')!;
|
||||
expect(append.attrs.to).toBe('src/channels/index.ts');
|
||||
expect(append.body).toEqual(["import './slack.js';"]);
|
||||
});
|
||||
|
||||
it('reads the dependency pinned exactly', () => {
|
||||
const dep = directives.find((d) => d.kind === 'dep')!;
|
||||
expect(dep.body).toEqual(['@chat-adapter/slack@4.29.0']);
|
||||
});
|
||||
|
||||
it('tags the runs with their effects', () => {
|
||||
expect(directives.filter((d) => d.kind === 'run').map((d) => d.attrs.effect)).toEqual([
|
||||
'build',
|
||||
'test',
|
||||
'fetch', // validate: auth.test — credential checked first
|
||||
'fetch', // resolve: conversations.open
|
||||
'restart', // load adapter + creds after the credential is validated, before wiring
|
||||
]);
|
||||
});
|
||||
|
||||
it('captures prompts into named vars — credentials secret, the mode and handle not', () => {
|
||||
const prompts = directives.filter((d) => d.kind === 'prompt');
|
||||
expect(prompts.map(promptVar)).toEqual(['connection', 'bot_token', 'app_token', 'signing_secret', 'owner_handle']);
|
||||
expect(prompts[0].args).not.toContain('secret'); // connection — a mode choice, not a secret
|
||||
expect(prompts[1].args).toContain('secret'); // bot_token
|
||||
expect(prompts[2].args).toContain('secret'); // app_token
|
||||
expect(prompts[3].args).toContain('secret'); // signing_secret
|
||||
expect(prompts[4].args).not.toContain('secret'); // owner_handle — a plain id, not a secret
|
||||
// Each mode's credential is guard-scoped to its branch.
|
||||
expect(prompts[2].attrs.when).toBe('connection=socket');
|
||||
expect(prompts[3].attrs.when).toBe('connection=webhook');
|
||||
// The prompt body is the question; it does not mention env at all.
|
||||
expect(prompts[1].body.join(' ')).toMatch(/Bot User OAuth Token/);
|
||||
});
|
||||
|
||||
it('resolves the conversation address into capture:platform_id (the wire input)', () => {
|
||||
const runs = directives.filter((d) => d.kind === 'run');
|
||||
const resolve = runs.find((d) => d.attrs.capture === 'platform_id')!;
|
||||
expect(resolve).toBeTruthy();
|
||||
expect(resolve.body.join(' ')).toMatch(/conversations\.open/);
|
||||
expect(resolve.body.join(' ')).toMatch(/"slack:" \+ \.channel\.id/); // emits the slack:<id> platform_id
|
||||
});
|
||||
|
||||
it('wires the captured variables into env-set via {{var}} references, one per mode', () => {
|
||||
const envSets = directives.filter((d) => d.kind === 'env-set');
|
||||
expect(envSets.map((d) => d.body)).toEqual([
|
||||
['SLACK_BOT_TOKEN={{bot_token}}'],
|
||||
['SLACK_APP_TOKEN={{app_token}}'],
|
||||
['SLACK_SIGNING_SECRET={{signing_secret}}'],
|
||||
]);
|
||||
expect(envSets[1].attrs.when).toBe('connection=socket');
|
||||
expect(envSets[2].attrs.when).toBe('connection=webhook');
|
||||
});
|
||||
|
||||
it('passes validation (well-formed, pinned, every {{var}} captured first)', () => {
|
||||
expect(validate(directives)).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps its @chat-adapter pin in sync with our chat core (drift guard)', () => {
|
||||
const chat = resolveChatCoreVersion(process.cwd());
|
||||
expect(chat).toMatch(/^\d+\.\d+\.\d+/); // our lockfile resolves a real chat version
|
||||
expect(validate(directives, { chatVersion: chat })).toEqual([]); // add-slack matches it
|
||||
});
|
||||
|
||||
it('ignores plain (non-nc:) code fences so prose stays the floor', () => {
|
||||
const withProse = slack + '\n```bash\nrm -rf /\n```\n';
|
||||
expect(parseDirectives(withProse).map((d) => d.kind)).toEqual(directives.map((d) => d.kind));
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation catches malformed directives', () => {
|
||||
it('flags an unpinned dependency and an unknown directive', () => {
|
||||
const md = ['```nc:dep', '@chat-adapter/slack@latest', '```', '', '```nc:frobnicate', 'x', '```'].join('\n');
|
||||
const problems = validate(parseDirectives(md));
|
||||
expect(problems.some((p) => /exact semver/.test(p.message))).toBe(true);
|
||||
expect(problems.some((p) => /unknown directive/.test(p.message))).toBe(true);
|
||||
});
|
||||
|
||||
it('flags an env-set that references a variable no prompt captured', () => {
|
||||
const md = ['```nc:env-set', 'SLACK_BOT_TOKEN={{bot_token}}', '```'].join('\n');
|
||||
const problems = validate(parseDirectives(md));
|
||||
expect(problems.some((p) => /\{\{bot_token\}\} but no earlier nc:prompt/.test(p.message))).toBe(true);
|
||||
});
|
||||
|
||||
it('flags a @chat-adapter pin that does not match the chat core', () => {
|
||||
const md = ['```nc:dep', '@chat-adapter/slack@4.27.0', '```'].join('\n');
|
||||
const problems = validate(parseDirectives(md), { chatVersion: '4.26.0' });
|
||||
expect(problems.some((p) => /must match the chat package/.test(p.message))).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a @chat-adapter pin that matches the chat core', () => {
|
||||
const md = ['```nc:dep', '@chat-adapter/slack@4.26.0', '```'].join('\n');
|
||||
expect(validate(parseDirectives(md), { chatVersion: '4.26.0' })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('json-merge directive', () => {
|
||||
const codex = ['```nc:json-merge into:container/cli-tools.json key:name', '{ "name": "@openai/codex", "version": "0.138.0" }', '```'].join('\n');
|
||||
|
||||
it('parses into/key attrs and the JSON object body', () => {
|
||||
const [d] = parseDirectives(codex);
|
||||
expect(d.kind).toBe('json-merge');
|
||||
expect(d.attrs.into).toBe('container/cli-tools.json');
|
||||
expect(d.attrs.key).toBe('name');
|
||||
expect(JSON.parse(d.body.join('\n'))).toEqual({ name: '@openai/codex', version: '0.138.0' });
|
||||
});
|
||||
|
||||
it('passes validation when into + key + a parseable object are all present', () => {
|
||||
expect(validate(parseDirectives(codex))).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags a missing into:', () => {
|
||||
const md = ['```nc:json-merge key:name', '{ "name": "x" }', '```'].join('\n');
|
||||
expect(validate(parseDirectives(md)).some((p) => /requires into:/.test(p.message))).toBe(true);
|
||||
});
|
||||
|
||||
it('flags a missing key:', () => {
|
||||
const md = ['```nc:json-merge into:container/cli-tools.json', '{ "name": "x" }', '```'].join('\n');
|
||||
expect(validate(parseDirectives(md)).some((p) => /requires key:/.test(p.message))).toBe(true);
|
||||
});
|
||||
|
||||
it('flags an unparseable body', () => {
|
||||
const md = ['```nc:json-merge into:f.json key:name', '{ not json', '```'].join('\n');
|
||||
expect(validate(parseDirectives(md)).some((p) => /parseable JSON object/.test(p.message))).toBe(true);
|
||||
});
|
||||
|
||||
it('flags a body that is an array, not a single object', () => {
|
||||
const md = ['```nc:json-merge into:f.json key:name', '[{ "name": "x" }]', '```'].join('\n');
|
||||
expect(validate(parseDirectives(md)).some((p) => /single JSON object/.test(p.message))).toBe(true);
|
||||
});
|
||||
|
||||
it('flags a body missing the match key field', () => {
|
||||
const md = ['```nc:json-merge into:f.json key:name', '{ "version": "1.0.0" }', '```'].join('\n');
|
||||
expect(validate(parseDirectives(md)).some((p) => /no "name" field/.test(p.message))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('append at:<marker> attribute', () => {
|
||||
it('parses an optional at:<marker> alongside to:', () => {
|
||||
const md = ['```nc:append to:setup/index.ts at:nanoclaw:setup-steps', " codex: () => import('./codex.js'),", '```'].join('\n');
|
||||
const [d] = parseDirectives(md);
|
||||
expect(d.kind).toBe('append');
|
||||
expect(d.attrs.to).toBe('setup/index.ts');
|
||||
expect(d.attrs.at).toBe('nanoclaw:setup-steps');
|
||||
});
|
||||
|
||||
it('still validates an append that carries at: (to + a line are all it needs)', () => {
|
||||
const md = ['```nc:append to:setup/index.ts at:nanoclaw:setup-steps', " codex: () => import('./codex.js'),", '```'].join('\n');
|
||||
expect(validate(parseDirectives(md))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retired directives', () => {
|
||||
it('flags nc:env-sync with a targeted retirement error, not a generic unknown', () => {
|
||||
const probs = validate(parseDirectives(['```nc:env-sync', '```'].join('\n')));
|
||||
expect(probs).toHaveLength(1);
|
||||
expect(probs[0].message).toMatch(/retired/);
|
||||
expect(probs[0].message).toMatch(/data\/env\/env/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when: guard + multi-field capture', () => {
|
||||
it('parses when: into attrs and lints a guard whose var an earlier prompt defined', () => {
|
||||
const md = ['```nc:prompt mode', 'local or remote', '```', '```nc:prompt server_url when:mode=remote', 'url', '```'].join('\n');
|
||||
const ds = parseDirectives(md);
|
||||
expect(ds[1].attrs.when).toBe('mode=remote');
|
||||
expect(validate(ds)).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags a when: guard whose var no earlier prompt/capture defined', () => {
|
||||
const probs = validate(parseDirectives(['```nc:env-set when:mode=remote', 'X=1', '```'].join('\n')));
|
||||
expect(probs.some((p) => /when:mode=remote references \{\{mode\}\}/.test(p.message))).toBe(true);
|
||||
});
|
||||
|
||||
it('flags a malformed when: with no =', () => {
|
||||
const md = ['```nc:prompt mode', 'm', '```', '```nc:env-set when:mode', 'X=1', '```'].join('\n');
|
||||
const probs = validate(parseDirectives(md));
|
||||
expect(probs.some((p) => /when:mode must be <var>=<value>/.test(p.message))).toBe(true);
|
||||
});
|
||||
|
||||
it('registers each capture:<var>=<FIELD> as defined so downstream {{vars}} pass lint', () => {
|
||||
const md = [
|
||||
'```nc:run effect:step capture:platform_id=PLATFORM_ID,owner_handle=ADMIN_ID',
|
||||
'run the step',
|
||||
'```',
|
||||
'```nc:env-set',
|
||||
'P={{platform_id}}',
|
||||
'O={{owner_handle}}',
|
||||
'```',
|
||||
].join('\n');
|
||||
expect(validate(parseDirectives(md))).toEqual([]);
|
||||
});
|
||||
|
||||
it('registers each capture:<var>=<dot-path> (JSON multi-field) var as defined for downstream {{vars}}', () => {
|
||||
const md = [
|
||||
'```nc:run capture:application_id=.id,public_key=.verify_key,owner_handle=.owner.id effect:fetch',
|
||||
'curl -sf https://example/app',
|
||||
'```',
|
||||
'```nc:env-set',
|
||||
'APP={{application_id}}',
|
||||
'PUB={{public_key}}',
|
||||
'OWN={{owner_handle}}',
|
||||
'```',
|
||||
].join('\n');
|
||||
expect(validate(parseDirectives(md))).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags an invalid run capture validate:<re> regex', () => {
|
||||
const md = ['```nc:run capture:app_id=.id effect:fetch validate:^[', 'curl x', '```'].join('\n');
|
||||
expect(validate(parseDirectives(md)).some((p) => /run validate:.*is not a valid regex/.test(p.message))).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a valid run capture validate:<re> regex', () => {
|
||||
const md = ['```nc:run capture:app_id=.id effect:fetch validate:^\\d+$', 'curl x', '```'].join('\n');
|
||||
expect(validate(parseDirectives(md))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prompt attrs (flags/normalize/reuse)', () => {
|
||||
it('parses flags/normalize/reuse into attrs alongside the var + secret flag', () => {
|
||||
const md = [
|
||||
'```nc:prompt server_url secret validate:^https?:// flags:i normalize:rstrip-slash reuse:IMESSAGE_SERVER_URL',
|
||||
'URL?',
|
||||
'```',
|
||||
].join('\n');
|
||||
const [d] = parseDirectives(md);
|
||||
expect(promptVar(d)).toBe('server_url'); // the var, not `secret`
|
||||
expect(d.attrs.flags).toBe('i');
|
||||
expect(d.attrs.normalize).toBe('rstrip-slash');
|
||||
expect(d.attrs.reuse).toBe('IMESSAGE_SERVER_URL');
|
||||
expect(validate([d])).toEqual([]); // a well-formed prompt with all attrs lints clean
|
||||
});
|
||||
|
||||
it('accepts validate:<re> combined with flags:i (a case-insensitive regex is still valid)', () => {
|
||||
const md = ['```nc:prompt u validate:^https?:// flags:i', 'URL?', '```'].join('\n');
|
||||
expect(validate(parseDirectives(md))).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags an unknown normalize: value', () => {
|
||||
const md = ['```nc:prompt u normalize:uppercase', 'q', '```'].join('\n');
|
||||
expect(validate(parseDirectives(md)).some((p) => /normalize:uppercase must be one of/.test(p.message))).toBe(true);
|
||||
});
|
||||
|
||||
it('flags a reuse: that is not a valid ENV_KEY', () => {
|
||||
const md = ['```nc:prompt u reuse:not-an-env-key', 'q', '```'].join('\n');
|
||||
expect(validate(parseDirectives(md)).some((p) => /reuse:not-an-env-key must be a valid ENV_KEY/.test(p.message))).toBe(true);
|
||||
});
|
||||
|
||||
it('flags illegal regex flags:', () => {
|
||||
const md = ['```nc:prompt u validate:^x flags:zzz', 'q', '```'].join('\n');
|
||||
expect(validate(parseDirectives(md)).some((p) => /is not a valid regex/.test(p.message))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// lintReferenceFloor is a WARN-ONLY smell check (never an error, never blocks):
|
||||
// a credentialed (nc:prompt secret) or interactive (nc:run effect:step) skill
|
||||
// should ship a ## Troubleshooting section — the human floor when a live step
|
||||
// misbehaves. It warns when that floor is absent and is silent otherwise.
|
||||
describe('lintReferenceFloor (warn-only reference floor)', () => {
|
||||
it('warns when a secret-bearing skill has no ## Troubleshooting', () => {
|
||||
const md = ['```nc:prompt token secret', 'Paste it.', '```'].join('\n');
|
||||
const warnings = lintReferenceFloor(md);
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0].kind).toBe('reference-floor');
|
||||
expect(warnings[0].message).toMatch(/## Troubleshooting/);
|
||||
});
|
||||
|
||||
it('warns when an interactive effect:step skill has no ## Troubleshooting', () => {
|
||||
const md = ['```nc:run effect:step capture:platform_id=PLATFORM_ID', 'pair', '```'].join('\n');
|
||||
expect(lintReferenceFloor(md)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('is silent once a ## Troubleshooting section is present', () => {
|
||||
const md = ['```nc:prompt token secret', 'Paste it.', '```', '', '## Troubleshooting', 'Check the logs.'].join('\n');
|
||||
expect(lintReferenceFloor(md)).toEqual([]);
|
||||
});
|
||||
|
||||
it('is silent for a skill with no secret prompt or effect:step (no floor expected)', () => {
|
||||
const md = ['```nc:prompt handle', 'Your handle.', '```', '```nc:env-set', 'H={{handle}}', '```'].join('\n');
|
||||
expect(lintReferenceFloor(md)).toEqual([]);
|
||||
});
|
||||
|
||||
it('never warns on the real credentialed channel skills — they ship a ## Troubleshooting', () => {
|
||||
for (const ch of ['add-signal', 'add-whatsapp', 'add-teams']) {
|
||||
const md = readFileSync(`.claude/skills/${ch}/SKILL.md`, 'utf8');
|
||||
expect(lintReferenceFloor(md)).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Grammar diet: the six removed presentation attrs are hard lint ERRORS so
|
||||
// stale authorship fails loudly instead of silently no-oping. Each error points
|
||||
// at the attr's replacement (validate: regex, question prose, body prose,
|
||||
// document structure, the preceding heading, the surrounding prose).
|
||||
describe('removed presentation attrs are lint errors', () => {
|
||||
it('rejects operator open: — the URL belongs in the body prose', () => {
|
||||
const md = ['```nc:operator open:https://portal.azure.com', 'Visit https://portal.azure.com and click through.', '```'].join('\n');
|
||||
const probs = validate(parseDirectives(md));
|
||||
expect(probs.some((p) => /operator open: was removed — put the URL in the body prose/.test(p.message))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects the bare operator gate flag — the barrier is structure-derived', () => {
|
||||
const md = ['```nc:operator gate', 'Finish the UI steps.', '```'].join('\n');
|
||||
const probs = validate(parseDirectives(md));
|
||||
expect(probs.some((p) => /operator gate was removed — the human barrier is derived from document structure/.test(p.message))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects prompt min: — length is regex-encoded now', () => {
|
||||
const md = ['```nc:prompt app_password secret min:20', 'Paste the client secret.', '```'].join('\n');
|
||||
const probs = validate(parseDirectives(md));
|
||||
expect(probs.some((p) => /prompt min: was removed — encode the length in validate:/.test(p.message))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects prompt error: — the miss message derives from the question prose', () => {
|
||||
const md = ['```nc:prompt token error:bad-token', 'Paste the token.', '```'].join('\n');
|
||||
const probs = validate(parseDirectives(md));
|
||||
expect(probs.some((p) => /prompt error: was removed — the validation-miss message derives from the question prose/.test(p.message))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects label: on any directive — labels are heading-derived only', () => {
|
||||
const md = ['```nc:run effect:build label:build', 'pnpm run build', '```'].join('\n');
|
||||
const probs = validate(parseDirectives(md));
|
||||
expect(probs.some((p) => /label: was removed — step labels derive from the preceding heading/.test(p.message))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects on-fail: on any directive — the hint is always the surrounding prose', () => {
|
||||
const md = ['```nc:run effect:test on-fail:rerun', 'pnpm test', '```'].join('\n');
|
||||
const probs = validate(parseDirectives(md));
|
||||
expect(probs.some((p) => /on-fail: was removed — the failure hint is always the surrounding prose/.test(p.message))).toBe(true);
|
||||
});
|
||||
|
||||
it('still accepts a plain operator block (body-only, no attrs)', () => {
|
||||
const md = ['```nc:prompt bot', 'Bot?', '```', '```nc:operator', 'Open @{{bot}} and press Start.', '```'].join('\n');
|
||||
expect(validate(parseDirectives(md))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// lintGateAmbiguity is a WARN-ONLY check (never an error, never blocks): an
|
||||
// UNGUARDED operator followed by when:-guarded directives spanning more than
|
||||
// one branch value keys its natural-barrier decision off a directive that may
|
||||
// be runtime-skipped — the static policy cannot know which branch runs.
|
||||
describe('lintGateAmbiguity (warn-only unguarded-operator/multi-branch)', () => {
|
||||
const branchy = [
|
||||
'```nc:prompt mode', 'local or remote?', '```',
|
||||
'```nc:operator', 'Get ready.', '```',
|
||||
'```nc:prompt server_url when:mode=remote', 'URL?', '```',
|
||||
'```nc:run effect:external when:mode=local', './configure.sh', '```',
|
||||
].join('\n');
|
||||
|
||||
it('warns on an unguarded operator followed by guards spanning two branch values', () => {
|
||||
const warnings = lintGateAmbiguity(parseDirectives(branchy));
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0].kind).toBe('gate-ambiguity');
|
||||
expect(warnings[0].line).toBe(4); // the operator's opening fence
|
||||
expect(warnings[0].message).toMatch(/mode=remote/);
|
||||
expect(warnings[0].message).toMatch(/mode=local/);
|
||||
});
|
||||
|
||||
it('the warning is not a validate() error — the skill still lints clean', () => {
|
||||
expect(validate(parseDirectives(branchy))).toEqual([]);
|
||||
});
|
||||
|
||||
it('is silent when the operator itself is guarded (mutually-exclusive branches gate on their own next action)', () => {
|
||||
const md = [
|
||||
'```nc:prompt mode', 'local or remote?', '```',
|
||||
'```nc:operator when:mode=local', 'Get ready.', '```',
|
||||
'```nc:prompt server_url when:mode=remote', 'URL?', '```',
|
||||
'```nc:run effect:external when:mode=local', './configure.sh', '```',
|
||||
].join('\n');
|
||||
expect(lintGateAmbiguity(parseDirectives(md))).toEqual([]);
|
||||
});
|
||||
|
||||
it('is silent when the following guards all share one branch value', () => {
|
||||
const md = [
|
||||
'```nc:prompt mode', 'local or remote?', '```',
|
||||
'```nc:operator', 'Get ready.', '```',
|
||||
'```nc:prompt server_url when:mode=remote', 'URL?', '```',
|
||||
'```nc:prompt api_key when:mode=remote', 'Key?', '```',
|
||||
].join('\n');
|
||||
expect(lintGateAmbiguity(parseDirectives(md))).toEqual([]);
|
||||
});
|
||||
|
||||
it('stops scanning at the first unguarded directive (it always runs — no ambiguity past it)', () => {
|
||||
const md = [
|
||||
'```nc:prompt mode', 'local or remote?', '```',
|
||||
'```nc:operator', 'Get ready.', '```',
|
||||
'```nc:run effect:build', 'pnpm run build', '```',
|
||||
'```nc:prompt server_url when:mode=remote', 'URL?', '```',
|
||||
'```nc:run effect:external when:mode=local', './configure.sh', '```',
|
||||
].join('\n');
|
||||
expect(lintGateAmbiguity(parseDirectives(md))).toEqual([]);
|
||||
});
|
||||
|
||||
it('never warns on the in-tree channel skills (none author the pattern)', () => {
|
||||
for (const ch of ['add-slack', 'add-discord', 'add-telegram', 'add-teams', 'add-whatsapp', 'add-signal', 'add-imessage']) {
|
||||
const md = readFileSync(`.claude/skills/${ch}/SKILL.md`, 'utf8');
|
||||
expect(lintGateAmbiguity(parseDirectives(md))).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,420 @@
|
||||
// Extract `nc:` skill directives embedded in a SKILL.md.
|
||||
//
|
||||
// A fenced code block whose info-string starts with `nc:` is a load-bearing
|
||||
// directive; every other fence (and all prose) is the human floor the parser
|
||||
// ignores. That is the whole "two readers, one document" property: an agent
|
||||
// applies the prose, a tool applies the directives, and anything the tool
|
||||
// can't handle degrades to the prose beside it. This is the seed for both the
|
||||
// conformance linter and the deterministic applier.
|
||||
//
|
||||
// Grammar, derived from add-slack:
|
||||
//
|
||||
// ```nc:<directive> <arg>... [key:value]...
|
||||
// <body line>
|
||||
// ```
|
||||
//
|
||||
// `prompt` only *acquires* a value and binds it to a name; a separate directive
|
||||
// *applies* it, referenced as `{{name}}`. That keeps "ask the human" decoupled
|
||||
// from "what you do with the answer" (env, ncl, the OneCLI vault, a file).
|
||||
//
|
||||
// copy [from-branch:<b>] body: `PATH` (src==dst) or `SRC -> DST` overwrite
|
||||
// append to:<file> [at:<marker>] body: line(s) to add skip if present
|
||||
// dep [manager:pnpm] body: `pkg@<exact-semver>` line(s) reinstall no-op
|
||||
// run [effect:build|test|fetch|external|wire|restart|step|check] [capture:<spec>] re-runnable
|
||||
// body: shell command(s). {{vars}} are substituted in. effect:wire runs
|
||||
// `ncl …` to wire collected input (no undo — the rows it creates are user
|
||||
// runtime data, not reversed on skill remove). effect:restart restarts the
|
||||
// service so following `ncl` runs reach it; a caller that owns the restart
|
||||
// (rebuild, or a setup that restarts once) skips it via ApplyOptions.
|
||||
// skipEffects. capture:<var> binds the command's stdout into {{var}} (twin
|
||||
// of prompt) — e.g. resolve an id from an API and feed it to a later step.
|
||||
// capture:<var>=<dot-path>[,<var2>=<dot-path2>…] parses the stdout as JSON
|
||||
// and binds each var to its jq-style dot-path (.id, .owner.id), so ONE API
|
||||
// call resolves several values at once. validate:<re> shape-guards each
|
||||
// captured value (e.g. validate:^discord:); a mismatch bounces the run to
|
||||
// an agent (a command's output has no human to re-prompt — unlike prompt).
|
||||
// effect:step runs a long-running, operator-interactive step (a pairing
|
||||
// code, a QR device-link) through the streaming exec: its
|
||||
// `=== NANOCLAW SETUP: … ===` status blocks render to the operator live and
|
||||
// `capture:<var>=<FIELD>[,<var2>=<FIELD2>…]` binds the terminal block's
|
||||
// named fields into {{vars}} (multi-valued, structured twin of stdout
|
||||
// capture). Degrades to an agent when no streaming exec is wired.
|
||||
// effect:check runs the body as a shell PREDICATE (a precondition gate):
|
||||
// it mutates nothing (no journal, no capture). A zero exit passes silently;
|
||||
// a non-zero exit bounces to an agent (degrade, not crash) and, via the
|
||||
// run-health gate, blocks the dangerous side effects that follow it (a
|
||||
// restart, a pairing/QR step, a wire). An unresolved {{var}} defers.
|
||||
// prompt <var> [secret] [validate:<re>] [flags:<re-flags>]
|
||||
// [normalize:trim|rstrip-slash|lower] [reuse:<ENV_KEY>]
|
||||
// body: the question → binds {{var}} skip if satisfied
|
||||
// validate:<re> is a regex enforced AT BIND for EVERY value — `inputs` and
|
||||
// interactive answers alike (e.g. validate:^xoxb- to require a Slack bot
|
||||
// token); a mismatch leaves the var unbound and records a deferred entry.
|
||||
// A minimum length is regex-encoded (e.g. validate:^.{20,}$).
|
||||
// flags:<re-flags> are regex flags applied to validate (e.g. flags:i → a
|
||||
// case-insensitive scheme match). normalize:<how> deterministically
|
||||
// transforms the value AT BIND, before validate — for BOTH `inputs` and
|
||||
// interactive answers — one of trim | rstrip-slash | lower.
|
||||
// reuse:<ENV_KEY> lets a re-run offer an existing .env value for a credential
|
||||
// a HELPER SCRIPT owns (written by effect:external, not nc:env-set) — the
|
||||
// masked reuse offer the env-set→ENV_KEY inference can't otherwise see.
|
||||
// operator body: instructions for the human operator output-only
|
||||
// The SKILL.md is addressed to the coding agent; `operator` delineates the
|
||||
// parts meant for the HUMAN (e.g. clicking through the Slack UI). Lead it
|
||||
// with agent-facing prose like "Tell the user:" so the agent relays it;
|
||||
// the engine renders the body to the operator ({{vars}} substituted in).
|
||||
// The block carries NO presentation attrs: a URL to visit lives in the
|
||||
// body prose (a consumer may offer to open it), and whether a consumer
|
||||
// pauses for confirmation before the next side effect is derived from
|
||||
// document structure (scripts/skill-policy.ts), never authored here.
|
||||
// env-set body: `KEY=value` ({{var}} allowed) set-if-absent
|
||||
// json-merge into:<file> key:<field> body: a JSON object push-if-absent
|
||||
//
|
||||
// `append` without `at:` adds to EOF; with `at:<marker>` it inserts before the
|
||||
// `// <<< <marker>` closing line of a dormant marker region (see setup/index.ts).
|
||||
// `json-merge` reads an array-of-objects JSON file and pushes the body object
|
||||
// unless an element already has body[key]===element[key] (idempotent by key).
|
||||
//
|
||||
// Any directive may carry `when:<var>=<value>` — a guard evaluated against an
|
||||
// earlier prompt/capture var. If it doesn't match (including the var being
|
||||
// unresolved), the directive is skipped — a guarded prompt is skipped, never
|
||||
// deferred — so one skill can express mutually-exclusive branches (e.g. a local
|
||||
// vs remote install mode) in document order while still running fully
|
||||
// programmatically from `inputs`.
|
||||
//
|
||||
// Removed presentation attrs — `min:`/`error:` on prompt, `open:`/`gate` on
|
||||
// operator, `label:`/`on-fail:` on any directive — are lint ERRORS, so stale
|
||||
// authorship fails loudly instead of silently no-oping. Their jobs moved:
|
||||
// length checks into validate: regexes, miss messages derive from the question
|
||||
// prose, URLs live in the operator body, gating and step labels derive from
|
||||
// document structure (scripts/skill-policy.ts / the preceding heading), and the
|
||||
// failure hint is always the surrounding prose.
|
||||
//
|
||||
// Usage: pnpm exec tsx scripts/skill-directives.ts <SKILL.md>
|
||||
|
||||
import { readFileSync, existsSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export interface Directive {
|
||||
kind: string;
|
||||
args: string[]; // positional bare tokens, e.g. prompt's variable name
|
||||
attrs: Record<string, string | true>; // key:value tokens
|
||||
body: string[];
|
||||
line: number; // 1-based line of the opening fence
|
||||
}
|
||||
|
||||
export interface Problem {
|
||||
line: number;
|
||||
kind: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const FENCE = /^```(\S.*)?$/;
|
||||
const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
|
||||
const VAR_REF = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
|
||||
const KNOWN = new Set(['copy', 'append', 'dep', 'run', 'prompt', 'operator', 'env-set', 'json-merge']);
|
||||
// Retired directives get a targeted lint error (not just "unknown") so an
|
||||
// author knows the removal was deliberate and what to do instead.
|
||||
const RETIRED: Record<string, string> = {
|
||||
'env-sync': 'nc:env-sync was retired — nothing reads the data/env/env mirror (and it copied live tokens); delete the fence, the adapter reads .env directly',
|
||||
};
|
||||
const PROMPT_FLAGS = new Set(['secret']);
|
||||
|
||||
export function parseDirectives(markdown: string): Directive[] {
|
||||
const lines = markdown.split('\n');
|
||||
const out: Directive[] = [];
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const info = lines[i].match(FENCE)?.[1]?.trim();
|
||||
if (info === undefined) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// A fence opens here; consume to its closing fence either way.
|
||||
let j = i + 1;
|
||||
const body: string[] = [];
|
||||
while (j < lines.length && !FENCE.test(lines[j])) {
|
||||
body.push(lines[j]);
|
||||
j++;
|
||||
}
|
||||
if (info.startsWith('nc:')) {
|
||||
const [tag, ...rest] = info.split(/\s+/);
|
||||
const args: string[] = [];
|
||||
const attrs: Record<string, string | true> = {};
|
||||
for (const tok of rest) {
|
||||
const eq = tok.indexOf(':');
|
||||
if (eq > 0) attrs[tok.slice(0, eq)] = tok.slice(eq + 1);
|
||||
else args.push(tok);
|
||||
}
|
||||
out.push({
|
||||
kind: tag.slice('nc:'.length),
|
||||
args,
|
||||
attrs,
|
||||
body: body.map((l) => l.trim()).filter(Boolean),
|
||||
line: i + 1,
|
||||
});
|
||||
}
|
||||
i = j + 1; // skip past the closing fence (directive or plain code block)
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The variable a `prompt` binds (the first positional that isn't a flag). */
|
||||
export function promptVar(d: Directive): string | undefined {
|
||||
return d.args.find((a) => !PROMPT_FLAGS.has(a));
|
||||
}
|
||||
|
||||
/**
|
||||
* The variable name(s) a `run capture:<spec>` binds. `capture:dm_channel` →
|
||||
* `['dm_channel']` (stdout form); `capture:platform_id=PLATFORM_ID,owner=ACCOUNT`
|
||||
* → `['platform_id','owner']` (effect:step field form).
|
||||
*/
|
||||
export function captureVars(spec: string): string[] {
|
||||
if (!spec.includes('=')) return [spec];
|
||||
return spec
|
||||
.split(',')
|
||||
.map((pair) => pair.slice(0, pair.indexOf('=')).trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** `{{var}}` names referenced anywhere in a directive's body. */
|
||||
function referencedVars(d: Directive): string[] {
|
||||
const found: string[] = [];
|
||||
for (const line of d.body) for (const m of line.matchAll(VAR_REF)) found.push(m[1]);
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* The resolved `chat` core version from our lockfile — the single source of
|
||||
* truth a `@chat-adapter/*` adapter pin must match (the adapter and the core
|
||||
* move in lockstep). Reads the root importer's direct `chat` dependency, whose
|
||||
* `specifier`/`version` pair is unique to importer deps (transitive entries in
|
||||
* the packages section have no `specifier`). Returns undefined if not found.
|
||||
*/
|
||||
export function resolveChatCoreVersion(root: string): string | undefined {
|
||||
let lock = '';
|
||||
try {
|
||||
lock = readFileSync(join(root, 'pnpm-lock.yaml'), 'utf8');
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const m = lock.match(/\n\s+chat:\n\s+specifier:[^\n]*\n\s+version:\s*([0-9][^\s(]*)/);
|
||||
return m?.[1];
|
||||
}
|
||||
|
||||
export function validate(directives: Directive[], ctx?: { chatVersion?: string }): Problem[] {
|
||||
const problems: Problem[] = [];
|
||||
const defined = new Set<string>();
|
||||
const flag = (d: Directive, message: string) => problems.push({ line: d.line, kind: d.kind, message });
|
||||
for (const d of directives) {
|
||||
if (RETIRED[d.kind]) flag(d, RETIRED[d.kind]);
|
||||
else if (!KNOWN.has(d.kind)) flag(d, `unknown directive nc:${d.kind}`);
|
||||
switch (d.kind) {
|
||||
case 'dep':
|
||||
for (const spec of d.body) {
|
||||
const at = spec.lastIndexOf('@');
|
||||
const name = at > 0 ? spec.slice(0, at) : spec;
|
||||
const version = at > 0 ? spec.slice(at + 1) : '';
|
||||
if (!EXACT_SEMVER.test(version)) flag(d, `dep "${spec}" must pin an exact semver (no ranges/latest)`);
|
||||
// A @chat-adapter/* adapter must match the chat core version in our
|
||||
// lockfile — the family moves together. This catches pin drift (the
|
||||
// 4.27.0-vs-chat@4.26.0 mismatch) at lint time.
|
||||
if (ctx?.chatVersion && name.startsWith('@chat-adapter/') && version !== ctx.chatVersion) {
|
||||
flag(d, `${name} pinned ${version} but our chat core is ${ctx.chatVersion} — a @chat-adapter/* adapter must match the chat package`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'append':
|
||||
if (!d.attrs.to) flag(d, 'append requires to:<file>');
|
||||
if (d.body.length === 0) flag(d, 'append requires a line to add');
|
||||
break;
|
||||
case 'copy':
|
||||
if (d.body.length === 0) flag(d, 'copy requires at least one path');
|
||||
break;
|
||||
case 'json-merge': {
|
||||
if (!d.attrs.into) flag(d, 'json-merge requires into:<json-file>');
|
||||
if (!d.attrs.key) flag(d, 'json-merge requires key:<field>');
|
||||
if (d.body.length === 0) {
|
||||
flag(d, 'json-merge requires a JSON object in its body');
|
||||
} else {
|
||||
let obj: unknown;
|
||||
try {
|
||||
obj = JSON.parse(d.body.join('\n'));
|
||||
} catch {
|
||||
flag(d, 'json-merge body must be a single parseable JSON object');
|
||||
break;
|
||||
}
|
||||
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
|
||||
flag(d, 'json-merge body must be a single JSON object (not an array or scalar)');
|
||||
} else if (typeof d.attrs.key === 'string' && !(d.attrs.key in obj)) {
|
||||
flag(d, `json-merge body has no "${d.attrs.key}" field to match on`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'prompt': {
|
||||
if (!promptVar(d)) flag(d, 'prompt requires a variable name, e.g. `nc:prompt token`');
|
||||
if (d.body.length === 0) flag(d, 'prompt requires a question in its body');
|
||||
const flags = typeof d.attrs.flags === 'string' ? d.attrs.flags : undefined;
|
||||
if (typeof d.attrs.validate === 'string') {
|
||||
try {
|
||||
new RegExp(d.attrs.validate, flags);
|
||||
} catch {
|
||||
flag(d, `prompt validate:${d.attrs.validate}${flags ? ` flags:${flags}` : ''} is not a valid regex`);
|
||||
}
|
||||
} else if (flags !== undefined) {
|
||||
// flags without validate: still verify they're legal regex flags.
|
||||
try {
|
||||
new RegExp('', flags);
|
||||
} catch {
|
||||
flag(d, `prompt flags:${flags} are not valid regex flags`);
|
||||
}
|
||||
}
|
||||
// Removed presentation attrs — reject loudly (they would silently no-op).
|
||||
if (d.attrs.min !== undefined) {
|
||||
flag(d, 'prompt min: was removed — encode the length in validate:, e.g. min:20 → validate:^.{20,}$');
|
||||
}
|
||||
if (d.attrs.error !== undefined) {
|
||||
flag(d, 'prompt error: was removed — the validation-miss message derives from the question prose');
|
||||
}
|
||||
if (typeof d.attrs.normalize === 'string' && !['trim', 'rstrip-slash', 'lower'].includes(d.attrs.normalize)) {
|
||||
flag(d, `prompt normalize:${d.attrs.normalize} must be one of trim|rstrip-slash|lower`);
|
||||
}
|
||||
if (typeof d.attrs.reuse === 'string' && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(d.attrs.reuse)) {
|
||||
flag(d, `prompt reuse:${d.attrs.reuse} must be a valid ENV_KEY`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'operator':
|
||||
if (d.body.length === 0) flag(d, 'operator requires instructions for the human in its body');
|
||||
// Removed presentation attrs — reject loudly (they would silently no-op).
|
||||
if (d.attrs.open !== undefined) {
|
||||
flag(d, 'operator open: was removed — put the URL in the body prose (a consumer offers to open it)');
|
||||
}
|
||||
if (d.args.includes('gate') || d.attrs.gate !== undefined) {
|
||||
flag(d, 'operator gate was removed — the human barrier is derived from document structure, never authored');
|
||||
}
|
||||
break;
|
||||
}
|
||||
// Removed on every directive: label: (labels are heading-derived only) and
|
||||
// on-fail: (the failure hint is always the surrounding prose).
|
||||
if (d.attrs.label !== undefined) {
|
||||
flag(d, 'label: was removed — step labels derive from the preceding heading');
|
||||
}
|
||||
if (d.attrs['on-fail'] !== undefined) {
|
||||
flag(d, 'on-fail: was removed — the failure hint is always the surrounding prose');
|
||||
}
|
||||
// A consumer can only reference a variable an earlier prompt captured, or an
|
||||
// earlier `run capture:<var>` bound from a command's output.
|
||||
for (const ref of referencedVars(d)) {
|
||||
if (!defined.has(ref)) flag(d, `references {{${ref}}} but no earlier nc:prompt or nc:run capture defined it`);
|
||||
}
|
||||
// A `when:<var>=<value>` guard references an earlier-defined var by bare name.
|
||||
if (typeof d.attrs.when === 'string') {
|
||||
const eq = d.attrs.when.indexOf('=');
|
||||
if (eq < 1) {
|
||||
flag(d, `when:${d.attrs.when} must be <var>=<value>`);
|
||||
} else {
|
||||
const wvar = d.attrs.when.slice(0, eq).trim();
|
||||
if (!defined.has(wvar)) flag(d, `when:${d.attrs.when} references {{${wvar}}} but no earlier nc:prompt or nc:run capture defined it`);
|
||||
}
|
||||
}
|
||||
if (d.kind === 'prompt') {
|
||||
const v = promptVar(d);
|
||||
if (v) defined.add(v);
|
||||
}
|
||||
// capture:<var> binds stdout; capture:<var>=<FIELD>,… binds step block fields.
|
||||
if (d.kind === 'run' && typeof d.attrs.capture === 'string') {
|
||||
for (const v of captureVars(d.attrs.capture)) defined.add(v);
|
||||
}
|
||||
// A run's capture validate:<re> (the stdout shape-guard) must be a valid regex.
|
||||
if (d.kind === 'run' && typeof d.attrs.validate === 'string') {
|
||||
try {
|
||||
new RegExp(d.attrs.validate);
|
||||
} catch {
|
||||
flag(d, `run validate:${d.attrs.validate} is not a valid regex`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* A WARN-ONLY reference-floor check — never an error, never blocks a build. A
|
||||
* credentialed or interactive skill (one that prompts for a `secret`, or runs an
|
||||
* `nc:run effect:step` — a pairing code, a QR device-link) should ship a
|
||||
* `## Troubleshooting` section: the human floor a reader scrolls to when the live
|
||||
* step misbehaves. Missing it is a smell, not a failure — the skill still applies
|
||||
* cleanly — so this returns a warning the CLI prints but never exits non-zero on,
|
||||
* keeping it strictly advisory. Returns [] when no secret/step is present (no
|
||||
* floor is expected) or a `## Troubleshooting` heading already exists.
|
||||
*/
|
||||
export function lintReferenceFloor(markdown: string): Problem[] {
|
||||
const directives = parseDirectives(markdown);
|
||||
const isFloorBearing = (d: Directive): boolean =>
|
||||
(d.kind === 'prompt' && d.args.includes('secret')) || (d.kind === 'run' && d.attrs.effect === 'step');
|
||||
const anchor = directives.find(isFloorBearing);
|
||||
if (!anchor) return []; // no credential / interactive step ⇒ no floor expected
|
||||
const hasTroubleshooting = markdown.split('\n').some((l) => /^##\s+Troubleshooting\s*$/.test(l.trim()));
|
||||
if (hasTroubleshooting) return [];
|
||||
return [{
|
||||
line: anchor.line,
|
||||
kind: 'reference-floor',
|
||||
message: 'a credentialed/interactive skill should carry a ## Troubleshooting section (the human floor when a live step misbehaves)',
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
* A WARN-ONLY gate-ambiguity check — never an error, never blocks a build. The
|
||||
* driver's natural-barrier policy (scripts/skill-policy.ts) keys an operator's
|
||||
* pause decision off the next guard-compatible directive; an UNGUARDED operator
|
||||
* treats every following directive as compatible, so when the directives
|
||||
* immediately after it are `when:`-guarded and span more than one branch value,
|
||||
* the static decision keys off a directive that may be runtime-skipped (e.g.
|
||||
* unguarded operator → `prompt when:mode=remote` → `run when:mode=local`:
|
||||
* policy says no-confirm, but at runtime mode=local skips the prompt). No
|
||||
* in-tree skill authors this; warn so new authorship guards the operator (or
|
||||
* restructures) instead of getting a silently wrong barrier. The scan stops at
|
||||
* the first unguarded directive — that one always runs, so no ambiguity past it.
|
||||
*/
|
||||
export function lintGateAmbiguity(directives: Directive[]): Problem[] {
|
||||
const problems: Problem[] = [];
|
||||
for (let i = 0; i < directives.length; i++) {
|
||||
const d = directives[i];
|
||||
if (d.kind !== 'operator' || typeof d.attrs.when === 'string') continue;
|
||||
const branches = new Set<string>();
|
||||
for (let j = i + 1; j < directives.length; j++) {
|
||||
const g = directives[j].attrs.when;
|
||||
if (typeof g !== 'string') break;
|
||||
branches.add(g);
|
||||
}
|
||||
if (branches.size > 1) {
|
||||
problems.push({
|
||||
line: d.line,
|
||||
kind: 'gate-ambiguity',
|
||||
message: `unguarded nc:operator followed by when:-guarded directives spanning ${branches.size} branch values (${[...branches].join(', ')}) — the barrier decision may key off a runtime-skipped directive; guard the operator or restructure`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
// CLI
|
||||
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
|
||||
let path = process.argv[2];
|
||||
if (!path) {
|
||||
console.error('usage: pnpm exec tsx scripts/skill-directives.ts <skillDir|SKILL.md>');
|
||||
process.exit(2);
|
||||
}
|
||||
if (existsSync(path) && statSync(path).isDirectory()) path = join(path, 'SKILL.md');
|
||||
const md = readFileSync(path, 'utf8');
|
||||
const directives = parseDirectives(md);
|
||||
const problems = validate(directives, { chatVersion: resolveChatCoreVersion(process.cwd()) });
|
||||
// Warnings (gate ambiguity, reference floor) are advisory only — printed for
|
||||
// the author, never folded into the exit code (a smell, not a gate). Exit
|
||||
// stays driven solely by `validate` problems.
|
||||
const warnings = [...lintGateAmbiguity(directives), ...lintReferenceFloor(md)];
|
||||
for (const w of warnings) console.error(`warning: ${w.kind} (line ${w.line}): ${w.message}`);
|
||||
console.log(JSON.stringify({ directives, problems, warnings }, null, 2));
|
||||
process.exit(problems.length ? 1 : 0);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { gatePolicy, extractOfferUrl, type GateDecision } from './skill-policy.js';
|
||||
import { parseDirectives } from './skill-directives.js';
|
||||
|
||||
// The parity fixtures are the REAL in-tree channel skills: the policy's whole
|
||||
// claim is that it reproduces (and deliberately extends — the readiness pauses,
|
||||
// the discord confirm) the barrier behavior the authored `gate` attrs encoded,
|
||||
// from document structure alone. Keyed by operator ORDER, not line number, so
|
||||
// the assertions survive unrelated prose edits.
|
||||
const loadSkill = (channel: string): string =>
|
||||
readFileSync(join(process.cwd(), `.claude/skills/add-${channel}/SKILL.md`), 'utf8');
|
||||
|
||||
/** The gate decisions for a skill's operator blocks, in document order. */
|
||||
function decisions(md: string): GateDecision[] {
|
||||
const gates = gatePolicy(md);
|
||||
return parseDirectives(md)
|
||||
.filter((d) => d.kind === 'operator')
|
||||
.map((d) => gates.get(d.line)!);
|
||||
}
|
||||
|
||||
/** The rendered-ish body (raw, un-substituted) of the nth operator block. */
|
||||
function operatorBody(md: string, n: number): string {
|
||||
const ops = parseDirectives(md).filter((d) => d.kind === 'operator');
|
||||
return ops[n].body.join('\n');
|
||||
}
|
||||
|
||||
describe('gatePolicy — §5.1 parity table (real skills)', () => {
|
||||
it('teams: exactly the two authored gates — and NO confirm on the operator-chain head', () => {
|
||||
// Operators in order: prerequisites, portal app, client secret, bot resource
|
||||
// (chain head), enable-channel (chain tail), sideload, final handoff.
|
||||
const d = decisions(loadSkill('teams'));
|
||||
expect(d).toHaveLength(7);
|
||||
expect(d.map((g) => g.needsConfirm)).toEqual([
|
||||
false, // → prompt public_url (prompt is the barrier)
|
||||
false, // → prompt app_id
|
||||
false, // → prompt app_password
|
||||
false, // bot-resource block → next compatible is another OPERATOR (rule 2):
|
||||
// the chain's LAST operator carries the barrier — never double-confirm
|
||||
true, // enable-channel → run effect:check (the manifest hazard gate)
|
||||
true, // sideload → run effect:restart
|
||||
false, // final handoff → end of document
|
||||
]);
|
||||
// Both confirms are completed-work flavor (check/restart, not effect:step).
|
||||
expect(d[4].flavor).toBe('completed');
|
||||
expect(d[5].flavor).toBe('completed');
|
||||
});
|
||||
|
||||
it('telegram: the pairing operator gains a readiness pause before the effect:step', () => {
|
||||
const d = decisions(loadSkill('telegram'));
|
||||
expect(d.map((g) => g.needsConfirm)).toEqual([false, true]); // BotFather → prompt; pairing → step
|
||||
expect(d[1].flavor).toBe('readiness'); // "a 4-digit code is about to appear"
|
||||
});
|
||||
|
||||
it('signal: readiness pause before the QR device-link step', () => {
|
||||
const d = decisions(loadSkill('signal'));
|
||||
expect(d.map((g) => g.needsConfirm)).toEqual([true]);
|
||||
expect(d[0].flavor).toBe('readiness');
|
||||
});
|
||||
|
||||
it('whatsapp: each auth-method operator skips the OTHER branch and gates on its own step', () => {
|
||||
const d = decisions(loadSkill('whatsapp'));
|
||||
// qr operator skips the pairing-code operator + step (guard-incompatible)
|
||||
// and gates on the qr effect:step; pairing-code likewise; the shared/dedicated
|
||||
// block is prompt-followed.
|
||||
expect(d.map((g) => g.needsConfirm)).toEqual([true, true, false]);
|
||||
expect(d[0].flavor).toBe('readiness');
|
||||
expect(d[1].flavor).toBe('readiness');
|
||||
});
|
||||
|
||||
it('imessage: guard-compatibility — the local block skips the remote-only prompts and gates on the local configure run', () => {
|
||||
const d = decisions(loadSkill('imessage'));
|
||||
// local (when:mode=local) → skips remote operator + 2 remote prompts →
|
||||
// run effect:external when:mode=local ⇒ confirm ("stop and wait" restored);
|
||||
// remote (when:mode=remote) → prompt server_url (identical guard) ⇒ no confirm.
|
||||
expect(d.map((g) => g.needsConfirm)).toEqual([true, false]);
|
||||
expect(d[0].flavor).toBe('completed');
|
||||
});
|
||||
|
||||
it('discord: the invite operator gains a confirm before the DM resolve (effect:fetch)', () => {
|
||||
const d = decisions(loadSkill('discord'));
|
||||
expect(d.map((g) => g.needsConfirm)).toEqual([false, true]);
|
||||
expect(d[1].flavor).toBe('completed');
|
||||
});
|
||||
|
||||
it('slack: all three operators are prompt-followed — no confirm, unchanged', () => {
|
||||
// socket-create (guard-skips its webhook twin, lands on the bot_token
|
||||
// prompt), webhook-create (bot_token prompt), event-delivery (owner_handle
|
||||
// prompt in Resolve) — every barrier is a natural prompt barrier.
|
||||
expect(decisions(loadSkill('slack')).map((g) => g.needsConfirm)).toEqual([false, false, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gatePolicy — rules on synthetic fixtures', () => {
|
||||
const fence = (info: string, body: string): string => `\`\`\`${info}\n${body}\n\`\`\`\n`;
|
||||
|
||||
it('end of document → no confirm (a final handoff block)', () => {
|
||||
const md = `# t\n${fence('nc:operator', 'All done — go DM the bot.')}`;
|
||||
expect(decisions(md)).toEqual([{ needsConfirm: false, flavor: 'completed' }]);
|
||||
});
|
||||
|
||||
it('operator chain: only the LAST operator of a chain carries the barrier', () => {
|
||||
const md = `# t\n${fence('nc:operator', 'first block')}${fence('nc:operator', 'second block')}${fence('nc:run effect:external', 'do-it')}`;
|
||||
expect(decisions(md).map((g) => g.needsConfirm)).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it('confirm fires for ALL non-prompt/non-operator barriers — even an env-set', () => {
|
||||
const md = `# t\n${fence('nc:prompt v', 'Value?')}${fence('nc:operator', 'go get v')}${fence('nc:env-set', 'K={{v}}')}`;
|
||||
expect(decisions(md)).toEqual([{ needsConfirm: true, flavor: 'completed' }]);
|
||||
});
|
||||
|
||||
it('flavor: an effect:step barrier is a readiness pause, anything else completed-work', () => {
|
||||
const step = `# t\n${fence('nc:operator', 'a code is about to appear')}${fence('nc:run effect:step capture:x=X', 'pair')}`;
|
||||
expect(decisions(step)[0]).toEqual({ needsConfirm: true, flavor: 'readiness' });
|
||||
const run = `# t\n${fence('nc:operator', 'finish the portal steps')}${fence('nc:run effect:check', 'true')}`;
|
||||
expect(decisions(run)[0]).toEqual({ needsConfirm: true, flavor: 'completed' });
|
||||
});
|
||||
|
||||
it('guard-compatibility: same-var/different-value directives are skipped; different-var guards are compatible', () => {
|
||||
// Mutually-exclusive branch: the m=a operator skips the m=b prompt+run and
|
||||
// gates on its OWN run.
|
||||
const branch =
|
||||
`# t\n${fence('nc:prompt m', 'mode?')}` +
|
||||
fence('nc:operator when:m=a', 'branch a steps') +
|
||||
fence('nc:prompt other when:m=b', 'b only') +
|
||||
fence('nc:run effect:external when:m=b', 'b-run') +
|
||||
fence('nc:run effect:external when:m=a', 'a-run');
|
||||
expect(decisions(branch)).toEqual([{ needsConfirm: true, flavor: 'completed' }]);
|
||||
// Different-var guard = compatible (conservative): the prompt is the barrier.
|
||||
const diffVar =
|
||||
`# t\n${fence('nc:prompt m', 'mode?')}` +
|
||||
fence('nc:operator when:m=a', 'branch a steps') +
|
||||
fence('nc:prompt other when:x=y', 'unrelated guard');
|
||||
expect(decisions(diffVar).map((g) => g.needsConfirm)).toEqual([false]);
|
||||
});
|
||||
});
|
||||
|
||||
// §5.2 URL-offer inventory — every operator body in the tree, plus the
|
||||
// normative negative fixture (slack's placeholder URL).
|
||||
describe('extractOfferUrl — §5.2 inventory', () => {
|
||||
it('teams: both portal blocks offer a clean https://portal.azure.com (trailing comma stripped)', () => {
|
||||
const md = loadSkill('teams');
|
||||
expect(extractOfferUrl(operatorBody(md, 1))).toBe('https://portal.azure.com'); // app registration
|
||||
expect(extractOfferUrl(operatorBody(md, 3))).toBe('https://portal.azure.com'); // bot resource (new offer)
|
||||
});
|
||||
|
||||
it('slack — the <your-public-host> placeholder is EXCLUDED (normative negative fixture)', () => {
|
||||
const md = loadSkill('slack');
|
||||
const body = operatorBody(md, 2); // event-delivery block (after the two create-app variants)
|
||||
expect(body).toContain('https://<your-public-host>/webhook/slack'); // fixture still authored as a placeholder
|
||||
expect(extractOfferUrl(body)).toBeUndefined(); // slack stays offer-free
|
||||
});
|
||||
|
||||
it('slack :69 — a scheme-less mention (api.slack.com/apps) never matches', () => {
|
||||
expect(extractOfferUrl(operatorBody(loadSkill('slack'), 0))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('discord: the developers-portal block gains an offer; imessage: photon.codes', () => {
|
||||
expect(extractOfferUrl(operatorBody(loadSkill('discord'), 0))).toBe('https://discord.com/developers/applications');
|
||||
expect(extractOfferUrl(operatorBody(loadSkill('imessage'), 1))).toBe('https://photon.codes');
|
||||
});
|
||||
|
||||
it('signal: a non-http scheme (sgnl://…) never matches', () => {
|
||||
expect(extractOfferUrl(operatorBody(loadSkill('signal'), 0))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('an unsubstituted {{var}} in the URL is excluded; the substituted form offers', () => {
|
||||
// The telegram/discord shape once their URLs live in the prose: rendered
|
||||
// text has the var substituted; a defer path could surface the raw form.
|
||||
expect(extractOfferUrl('Open https://t.me/{{bot_username}} in Telegram now.')).toBeUndefined();
|
||||
expect(extractOfferUrl('Open https://t.me/my_helper_bot in Telegram now.')).toBe('https://t.me/my_helper_bot');
|
||||
});
|
||||
|
||||
it('strips trailing sentence punctuation and stops at ), ], >', () => {
|
||||
expect(extractOfferUrl('Visit https://example.com.')).toBe('https://example.com');
|
||||
expect(extractOfferUrl('(see https://example.com/docs) for details')).toBe('https://example.com/docs');
|
||||
expect(extractOfferUrl('nothing to open here')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the FIRST offerable URL, skipping earlier excluded candidates', () => {
|
||||
expect(extractOfferUrl('Set https://<host>/hook then read https://docs.example.com/setup.')).toBe(
|
||||
'https://docs.example.com/setup',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
// Shared, UI-free driver policy for `nc:operator` blocks — presentation derived
|
||||
// from DOCUMENT STRUCTURE, never from authored presentation attrs.
|
||||
//
|
||||
// The apply engine (scripts/skill-apply.ts) only DECLARES and EMITS: it renders
|
||||
// an operator block's text and awaits the consumer's `onEvent` before evaluating
|
||||
// the next directive. Whether that block needs a human BARRIER (a confirm before
|
||||
// the next side-effecting step runs) and whether its text carries a URL worth
|
||||
// offering to open are judgments about the skill DOCUMENT — the same judgment
|
||||
// for every consumer that renders live (the setup wizard, an agent relaying over
|
||||
// chat). This module is that judgment, built on the shared parser, with no
|
||||
// clack/TTY baggage — a consumer imports it instead of duplicating the policy.
|
||||
//
|
||||
// Two exports:
|
||||
// • gatePolicy(md) → per operator line: does the block need a confirm
|
||||
// after rendering, and with which flavor of wording?
|
||||
// • extractOfferUrl(text) → the first offerable URL in a RENDERED operator
|
||||
// body (template placeholders excluded), or undefined.
|
||||
|
||||
import { parseDirectives, type Directive } from './skill-directives.js';
|
||||
|
||||
/**
|
||||
* The wording flavor a barrier confirm should carry, derived from the barrier
|
||||
* directive's effect:
|
||||
* • 'readiness' — the next step is an `effect:step` (a pairing code, a QR
|
||||
* device-link): the block describes FUTURE action ("a code is about to
|
||||
* appear"), so the confirm asks for readiness before it starts.
|
||||
* • 'completed' — anything else: the block describes work the human must have
|
||||
* FINISHED (an Azure app that must exist before the manifest bakes it in),
|
||||
* so the confirm asks whether the steps above are done.
|
||||
*/
|
||||
export type ConfirmFlavor = 'readiness' | 'completed';
|
||||
|
||||
export interface GateDecision {
|
||||
/** Confirm after rendering this operator block? */
|
||||
needsConfirm: boolean;
|
||||
/** Wording flavor for the confirm — meaningful only when `needsConfirm`. */
|
||||
flavor: ConfirmFlavor;
|
||||
}
|
||||
|
||||
// A directive's `when:<var>=<value>` guard, parsed. Malformed (no `=`) ⇒ treated
|
||||
// as unguarded — conservative, and lint is the authoring gate anyway.
|
||||
function guardOf(d: Directive): { v: string; value: string } | undefined {
|
||||
if (typeof d.attrs.when !== 'string') return undefined;
|
||||
const eq = d.attrs.when.indexOf('=');
|
||||
if (eq < 1) return undefined;
|
||||
return { v: d.attrs.when.slice(0, eq).trim(), value: d.attrs.when.slice(eq + 1).trim() };
|
||||
}
|
||||
|
||||
/**
|
||||
* The natural-barrier gate policy: for each `nc:operator` directive, decide
|
||||
* whether the consumer should hold for a human confirm after rendering it —
|
||||
* keyed by the directive's opening-fence line (the `line` the engine's operator
|
||||
* event carries).
|
||||
*
|
||||
* Rules (normative — the §5.1 seam spec):
|
||||
* 1. Scan forward through subsequent directives, skipping ONLY those whose
|
||||
* `when:` guard is INCOMPATIBLE with this operator's own guard — same var,
|
||||
* different value. No guard, or an identical guard, is compatible
|
||||
* (different-var guards are conservatively compatible too). This makes
|
||||
* mutually-exclusive branches gate on their OWN next action.
|
||||
* 2. Next compatible directive is another `operator` → no confirm — the
|
||||
* chain's LAST operator carries the barrier. (Operators are NOT skipped-
|
||||
* and-scanned-past: that would make the earlier block of a chain inherit
|
||||
* the later block's barrier and double-confirm.)
|
||||
* 3. Next compatible directive is a `prompt` → no confirm (the prompt is the
|
||||
* barrier — the human can't paste a token before doing the steps).
|
||||
* 4. No such directive (end of document) → no confirm (a final handoff block).
|
||||
* 5. Anything else (`run`, `copy`, `dep`, `append`, `env-set`,
|
||||
* `json-merge`) → confirm, with the flavor derived from that barrier
|
||||
* directive's effect (`effect:step` → readiness, else completed).
|
||||
*
|
||||
* Known limitation (lint-warned upstream): an UNGUARDED operator followed by
|
||||
* guarded directives of more than one branch value keys its decision off a
|
||||
* directive that may be runtime-skipped. No in-tree skill authors this.
|
||||
*/
|
||||
export function gatePolicy(md: string): Map<number, GateDecision> {
|
||||
const directives = parseDirectives(md);
|
||||
const out = new Map<number, GateDecision>();
|
||||
directives.forEach((d, i) => {
|
||||
if (d.kind !== 'operator') return;
|
||||
const own = guardOf(d);
|
||||
let barrier: Directive | undefined;
|
||||
for (let j = i + 1; j < directives.length; j++) {
|
||||
const g = guardOf(directives[j]);
|
||||
if (own && g && own.v === g.v && own.value !== g.value) continue; // incompatible branch — skip
|
||||
barrier = directives[j];
|
||||
break;
|
||||
}
|
||||
if (!barrier || barrier.kind === 'operator' || barrier.kind === 'prompt') {
|
||||
out.set(d.line, { needsConfirm: false, flavor: 'completed' });
|
||||
} else {
|
||||
out.set(d.line, { needsConfirm: true, flavor: barrier.attrs.effect === 'step' ? 'readiness' : 'completed' });
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// A URL candidate in prose. The char class stops at whitespace, `)`, `>`, `]` —
|
||||
// deliberately NOT at `<`, so a template placeholder like
|
||||
// `https://<your-public-host>/webhook/slack` yields a candidate that still
|
||||
// CONTAINS `<` and gets excluded below (instead of a nonsense truncated offer).
|
||||
const URL_CANDIDATE = /https?:\/\/[^\s)>\]]+/g;
|
||||
|
||||
/**
|
||||
* The first *offerable* URL in a rendered operator body, or undefined.
|
||||
*
|
||||
* A candidate matches `https?://…` up to whitespace/`)`/`>`/`]`, then:
|
||||
* • trailing sentence punctuation is stripped (so "In https://portal.azure.com,
|
||||
* search…" offers the clean URL, not one with a comma glued on);
|
||||
* • candidates containing `<` or `{{` are EXCLUDED — template placeholders
|
||||
* (`https://<your-public-host>/…`) and unsubstituted `{{vars}}` are authored
|
||||
* shapes, not real destinations;
|
||||
* • the survivor must parse via `new URL()` with a non-empty host.
|
||||
*
|
||||
* Scheme-less mentions (`api.slack.com/apps`) and non-http schemes (`sgnl://…`)
|
||||
* never match — the offer is only for pages a browser can open.
|
||||
*/
|
||||
export function extractOfferUrl(text: string): string | undefined {
|
||||
for (const m of text.matchAll(URL_CANDIDATE)) {
|
||||
const candidate = m[0].replace(/[.,;:!?'"]+$/, '');
|
||||
if (candidate.includes('<') || candidate.includes('{{')) continue;
|
||||
try {
|
||||
if (!new URL(candidate).hostname) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -33,7 +33,9 @@ db.exec(`
|
||||
`);
|
||||
|
||||
// Insert test message
|
||||
db.prepare(`INSERT INTO messages_in (id, kind, timestamp, status, content) VALUES (?, 'chat', datetime('now'), 'pending', ?)`).run(
|
||||
db.prepare(
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, content) VALUES (?, 'chat', datetime('now'), 'pending', ?)`,
|
||||
).run(
|
||||
'test-1',
|
||||
JSON.stringify({ sender: 'Gavriel', text: 'Say "Hello from v2!" and nothing else. Do not use any tools.' }),
|
||||
);
|
||||
@@ -44,7 +46,7 @@ db.close();
|
||||
process.env.SESSION_DB_PATH = DB_PATH;
|
||||
process.env.AGENT_PROVIDER = 'claude';
|
||||
|
||||
const { getSessionDb, closeSessionDb } = await import('../container/agent-runner/src/db/connection.js');
|
||||
const { getInboundDb, closeSessionDb } = await import('../container/agent-runner/src/db/connection.js');
|
||||
const { getUndeliveredMessages } = await import('../container/agent-runner/src/db/messages-out.js');
|
||||
const { getPendingMessages } = await import('../container/agent-runner/src/db/messages-in.js');
|
||||
const { createProvider } = await import('../container/agent-runner/src/providers/factory.js');
|
||||
|
||||
@@ -71,7 +71,7 @@ import { routeInbound } from '../src/router.js';
|
||||
import { setDeliveryAdapter, startActiveDeliveryPoll, stopDeliveryPolls } from '../src/delivery.js';
|
||||
import { getChannelAdapter, registerChannelAdapter, initChannelAdapters } from '../src/channels/channel-registry.js';
|
||||
import { findSession } from '../src/db/sessions.js';
|
||||
import { sessionDbPath } from '../src/session-manager.js';
|
||||
import { inboundDbPath } from '../src/session-manager.js';
|
||||
import type { ChannelAdapter, ChannelSetup, OutboundMessage } from '../src/channels/adapter.js';
|
||||
|
||||
// Track delivered messages
|
||||
@@ -99,7 +99,9 @@ const mockAdapter: ChannelAdapter = {
|
||||
|
||||
async setTyping() {},
|
||||
async teardown() {},
|
||||
isConnected() { return true; },
|
||||
isConnected() {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
// Register mock adapter
|
||||
@@ -179,15 +181,19 @@ console.log(`✓ Container status: ${session.container_status}`);
|
||||
import { execSync } from 'child_process';
|
||||
const checkContainerLogs = () => {
|
||||
try {
|
||||
const containers = execSync('docker ps -a --filter name=nanoclaw-v2-test-channel --format "{{.Names}}"').toString().trim();
|
||||
const containers = execSync('docker ps -a --filter name=nanoclaw-v2-test-channel --format "{{.Names}}"')
|
||||
.toString()
|
||||
.trim();
|
||||
for (const name of containers.split('\n').filter(Boolean)) {
|
||||
console.log(`\nContainer logs (${name}):`);
|
||||
console.log(execSync(`docker logs ${name} 2>&1`).toString());
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const sessDbPath = sessionDbPath('ag-chan', session.id);
|
||||
const sessDbPath = inboundDbPath('ag-chan', session.id);
|
||||
console.log(`✓ Session DB: ${sessDbPath}`);
|
||||
|
||||
// --- Step 4: Wait for delivery through mock adapter ---
|
||||
@@ -210,7 +216,9 @@ await new Promise<void>((resolve) => {
|
||||
console.log(` messages_out rows: ${out.length}`);
|
||||
if (out.length > 0) console.log(' (messages exist but delivery failed)');
|
||||
db.close();
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
checkContainerLogs();
|
||||
cleanup();
|
||||
process.exit(1);
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the Codex agent provider non-interactively: copy the payload from the
|
||||
# `providers` branch, wire the three provider barrels, and add the Codex CLI to
|
||||
# the container manifest (container/cli-tools.json). The image rebuild is the
|
||||
# caller's job (the setup container step / `./container/build.sh`).
|
||||
#
|
||||
# Emits exactly one status block on stdout (ADD_CODEX); all chatty progress
|
||||
# goes to stderr. Keep in sync with .claude/skills/add-codex/SKILL.md.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with add-codex SKILL.md. This is the canonical Codex CLI pin —
|
||||
# it lands in container/cli-tools.json (the global-CLI manifest), not the Dockerfile.
|
||||
CODEX_VERSION="0.138.0"
|
||||
|
||||
# Resolve the remote carrying the providers branch (same nanoclaw remote that
|
||||
# carries channels — handles forks where it isn't `origin`).
|
||||
# shellcheck source=setup/lib/channels-remote.sh
|
||||
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
|
||||
REMOTE=$(resolve_channels_remote)
|
||||
BRANCH="${REMOTE}/providers"
|
||||
|
||||
# The codex payload — host provider, container runtime, setup module, doctrine.
|
||||
# Barrels are appended to, not copied.
|
||||
PAYLOAD_FILES=(
|
||||
src/providers/codex.ts
|
||||
src/providers/codex-agents-md.ts
|
||||
src/providers/codex-registration.test.ts
|
||||
src/providers/codex-host-contribution.test.ts
|
||||
src/providers/codex-agents-md.test.ts
|
||||
container/agent-runner/src/providers/codex.ts
|
||||
container/agent-runner/src/providers/codex-app-server.ts
|
||||
container/agent-runner/src/providers/exchange-archive.ts
|
||||
container/agent-runner/src/providers/exchange-archive.test.ts
|
||||
container/agent-runner/src/providers/codex-registration.test.ts
|
||||
container/agent-runner/src/providers/codex.factory.test.ts
|
||||
container/agent-runner/src/providers/codex.turns.test.ts
|
||||
container/agent-runner/src/providers/codex-app-server.test.ts
|
||||
container/agent-runner/src/providers/codex-cli-tools.test.ts
|
||||
setup/providers/codex.ts
|
||||
setup/providers/codex.test.ts
|
||||
setup/providers/codex-registration.test.ts
|
||||
container/AGENTS.md
|
||||
)
|
||||
BARRELS=(
|
||||
src/providers/index.ts
|
||||
container/agent-runner/src/providers/index.ts
|
||||
setup/providers/index.ts
|
||||
)
|
||||
|
||||
ALREADY_INSTALLED=true
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
echo "=== NANOCLAW SETUP: ADD_CODEX ==="
|
||||
echo "STATUS: ${status}"
|
||||
echo "CODEX_VERSION: ${CODEX_VERSION}"
|
||||
echo "ALREADY_INSTALLED: ${ALREADY_INSTALLED}"
|
||||
[ -n "$error" ] && echo "ERROR: ${error}"
|
||||
echo "=== END ==="
|
||||
}
|
||||
log() { echo "[add-codex] $*" >&2; }
|
||||
|
||||
# Idempotent: a complete install has the host provider file, the host barrel
|
||||
# import, and the Codex CLI in the container manifest. Any missing → (re)install.
|
||||
need_install() {
|
||||
[ ! -f src/providers/codex.ts ] && return 0
|
||||
! grep -q "^import './codex.js';" src/providers/index.ts 2>/dev/null && return 0
|
||||
! grep -q '@openai/codex' container/cli-tools.json 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
if need_install; then
|
||||
ALREADY_INSTALLED=false
|
||||
|
||||
log "Fetching providers branch from ${REMOTE}…"
|
||||
git fetch "$REMOTE" providers >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch ${REMOTE} providers failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Copying Codex payload from ${BRANCH}…"
|
||||
for f in "${PAYLOAD_FILES[@]}"; do
|
||||
mkdir -p "$(dirname "$f")"
|
||||
git show "${BRANCH}:$f" > "$f" 2>/dev/null || {
|
||||
emit_status failed "providers branch is missing ${f}"
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
log "Wiring provider barrels…"
|
||||
for b in "${BARRELS[@]}"; do
|
||||
grep -q "^import './codex.js';" "$b" || printf "import './codex.js';\n" >> "$b"
|
||||
done
|
||||
|
||||
log "Adding the Codex CLI to the container manifest (cli-tools.json)…"
|
||||
# A json-merge: append { name, version } if absent. The Dockerfile installs
|
||||
# every manifest entry via pinned `pnpm install -g` — no Dockerfile edit, no
|
||||
# awk surgery. @openai/codex has no native postinstall, so no "onlyBuilt".
|
||||
MANIFEST=container/cli-tools.json
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const [file, name, version] = process.argv.slice(1);
|
||||
const tools = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
if (!tools.some((t) => t.name === name)) {
|
||||
tools.push({ name, version });
|
||||
const fmt = (t) =>
|
||||
" { " +
|
||||
Object.entries(t).map(([k, v]) => JSON.stringify(k) + ": " + JSON.stringify(v)).join(", ") +
|
||||
" }";
|
||||
fs.writeFileSync(file, "[\n" + tools.map(fmt).join(",\n") + "\n]\n");
|
||||
}
|
||||
' "$MANIFEST" "@openai/codex" "${CODEX_VERSION}" || {
|
||||
emit_status failed "failed to add @openai/codex to ${MANIFEST}"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
emit_status ok
|
||||
@@ -1,130 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the Discord adapter, persist DISCORD_BOT_TOKEN / APPLICATION_ID /
|
||||
# PUBLIC_KEY to .env + data/env/env, and restart the service. Non-interactive —
|
||||
# the operator-facing "Create a bot" walkthrough, owner confirmation, and
|
||||
# server-invite step live in setup/channels/discord.ts. Credentials come in via
|
||||
# env vars: DISCORD_BOT_TOKEN, DISCORD_APPLICATION_ID, DISCORD_PUBLIC_KEY.
|
||||
#
|
||||
# Emits exactly one status block on stdout (ADD_DISCORD) at the end. All chatty
|
||||
# progress messages go to stderr so setup:auto's raw-log capture sees the full
|
||||
# story without cluttering the final block for the parser.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-discord/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/discord@4.29.0"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
# shellcheck source=setup/lib/channels-remote.sh
|
||||
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
|
||||
CHANNELS_REMOTE=$(resolve_channels_remote)
|
||||
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
local already=${ADAPTER_ALREADY_INSTALLED:-false}
|
||||
echo "=== NANOCLAW SETUP: ADD_DISCORD ==="
|
||||
echo "STATUS: ${status}"
|
||||
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
|
||||
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
|
||||
[ -n "$error" ] && echo "ERROR: ${error}"
|
||||
echo "=== END ==="
|
||||
}
|
||||
|
||||
log() { echo "[add-discord] $*" >&2; }
|
||||
|
||||
if [ -z "${DISCORD_BOT_TOKEN:-}" ]; then
|
||||
emit_status failed "DISCORD_BOT_TOKEN env var not set"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${DISCORD_APPLICATION_ID:-}" ]; then
|
||||
emit_status failed "DISCORD_APPLICATION_ID env var not set"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${DISCORD_PUBLIC_KEY:-}" ]; then
|
||||
emit_status failed "DISCORD_PUBLIC_KEY env var not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
need_install() {
|
||||
[ ! -f src/channels/discord.ts ] && return 0
|
||||
! grep -q "^import './discord.js';" src/channels/index.ts 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
ADAPTER_ALREADY_INSTALLED=true
|
||||
if need_install; then
|
||||
ADAPTER_ALREADY_INSTALLED=false
|
||||
log "Fetching channels branch…"
|
||||
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Copying adapter from ${CHANNELS_BRANCH}…"
|
||||
git show "${CHANNELS_BRANCH}:src/channels/discord.ts" > src/channels/discord.ts
|
||||
|
||||
# Append self-registration import if missing.
|
||||
if ! grep -q "^import './discord.js';" src/channels/index.ts; then
|
||||
echo "import './discord.js';" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
log "Installing ${ADAPTER_VERSION}…"
|
||||
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Building…"
|
||||
pnpm run build >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm run build failed"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
log "Adapter files already installed — skipping install phase."
|
||||
fi
|
||||
|
||||
# Persist credentials. auto.ts validates before this point, so bad values here
|
||||
# would be an internal bug rather than operator input.
|
||||
touch .env
|
||||
upsert_env() {
|
||||
local key=$1 value=$2
|
||||
if grep -q "^${key}=" .env; then
|
||||
awk -v k="$key" -v v="$value" \
|
||||
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
|
||||
.env > .env.tmp && mv .env.tmp .env
|
||||
else
|
||||
echo "${key}=${value}" >> .env
|
||||
fi
|
||||
}
|
||||
upsert_env DISCORD_BOT_TOKEN "$DISCORD_BOT_TOKEN"
|
||||
upsert_env DISCORD_APPLICATION_ID "$DISCORD_APPLICATION_ID"
|
||||
upsert_env DISCORD_PUBLIC_KEY "$DISCORD_PUBLIC_KEY"
|
||||
|
||||
# Container reads from data/env/env (the host mounts it).
|
||||
mkdir -p data/env
|
||||
cp .env data/env/env
|
||||
|
||||
log "Restarting service so the new adapter picks up the credentials…"
|
||||
# shellcheck source=setup/lib/install-slug.sh
|
||||
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
|
||||
;;
|
||||
Linux)
|
||||
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| true
|
||||
;;
|
||||
esac
|
||||
|
||||
# Give the Discord adapter a moment to finish gateway handshake before
|
||||
# init-first-agent attempts delivery.
|
||||
sleep 5
|
||||
|
||||
emit_status success
|
||||
@@ -1,160 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the iMessage adapter, persist mode/creds to .env + data/env/env,
|
||||
# and restart the service. Non-interactive — the Full Disk Access walkthrough
|
||||
# (local mode) and Photon URL/key prompts (remote mode) live in
|
||||
# setup/channels/imessage.ts. Creds come in via env vars:
|
||||
# IMESSAGE_LOCAL 'true' | 'false' (required)
|
||||
# IMESSAGE_ENABLED 'true' (required when IMESSAGE_LOCAL=true)
|
||||
# IMESSAGE_SERVER_URL (required when IMESSAGE_LOCAL=false)
|
||||
# IMESSAGE_API_KEY (required when IMESSAGE_LOCAL=false)
|
||||
#
|
||||
# Emits exactly one status block on stdout (ADD_IMESSAGE) at the end.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-imessage/SKILL.md.
|
||||
ADAPTER_VERSION="chat-adapter-imessage@0.1.1"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
# shellcheck source=setup/lib/channels-remote.sh
|
||||
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
|
||||
CHANNELS_REMOTE=$(resolve_channels_remote)
|
||||
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
local already=${ADAPTER_ALREADY_INSTALLED:-false}
|
||||
local mode=${IMESSAGE_LOCAL:-}
|
||||
echo "=== NANOCLAW SETUP: ADD_IMESSAGE ==="
|
||||
echo "STATUS: ${status}"
|
||||
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
|
||||
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
|
||||
[ -n "$mode" ] && echo "MODE: $([ "$mode" = "true" ] && echo local || echo remote)"
|
||||
[ -n "$error" ] && echo "ERROR: ${error}"
|
||||
echo "=== END ==="
|
||||
}
|
||||
|
||||
log() { echo "[add-imessage] $*" >&2; }
|
||||
|
||||
# Validate creds based on mode.
|
||||
if [ -z "${IMESSAGE_LOCAL:-}" ]; then
|
||||
emit_status failed "IMESSAGE_LOCAL env var not set (expected true|false)"
|
||||
exit 1
|
||||
fi
|
||||
if [ "${IMESSAGE_LOCAL}" = "true" ]; then
|
||||
if [ -z "${IMESSAGE_ENABLED:-}" ]; then
|
||||
emit_status failed "IMESSAGE_ENABLED env var not set for local mode"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(uname -s)" != "Darwin" ]; then
|
||||
emit_status failed "local mode requires macOS"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
if [ -z "${IMESSAGE_SERVER_URL:-}" ]; then
|
||||
emit_status failed "IMESSAGE_SERVER_URL env var not set for remote mode"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${IMESSAGE_API_KEY:-}" ]; then
|
||||
emit_status failed "IMESSAGE_API_KEY env var not set for remote mode"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
need_install() {
|
||||
[ ! -f src/channels/imessage.ts ] && return 0
|
||||
! grep -q "^import './imessage.js';" src/channels/index.ts 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
ADAPTER_ALREADY_INSTALLED=true
|
||||
if need_install; then
|
||||
ADAPTER_ALREADY_INSTALLED=false
|
||||
log "Fetching channels branch…"
|
||||
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Copying adapter from ${CHANNELS_BRANCH}…"
|
||||
git show "${CHANNELS_BRANCH}:src/channels/imessage.ts" > src/channels/imessage.ts
|
||||
|
||||
# Append self-registration import if missing.
|
||||
if ! grep -q "^import './imessage.js';" src/channels/index.ts; then
|
||||
echo "import './imessage.js';" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
log "Installing ${ADAPTER_VERSION}…"
|
||||
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Building…"
|
||||
pnpm run build >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm run build failed"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
log "Adapter files already installed — skipping install phase."
|
||||
fi
|
||||
|
||||
touch .env
|
||||
upsert_env() {
|
||||
local key=$1 value=$2
|
||||
if grep -q "^${key}=" .env; then
|
||||
awk -v k="$key" -v v="$value" \
|
||||
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
|
||||
.env > .env.tmp && mv .env.tmp .env
|
||||
else
|
||||
echo "${key}=${value}" >> .env
|
||||
fi
|
||||
}
|
||||
|
||||
remove_env() {
|
||||
local key=$1
|
||||
if grep -q "^${key}=" .env 2>/dev/null; then
|
||||
grep -v "^${key}=" .env > .env.tmp && mv .env.tmp .env
|
||||
fi
|
||||
}
|
||||
|
||||
# Write the canonical keys for the chosen mode, strip the opposite mode's
|
||||
# keys so stale values can't confuse the adapter's factory.
|
||||
upsert_env IMESSAGE_LOCAL "$IMESSAGE_LOCAL"
|
||||
if [ "$IMESSAGE_LOCAL" = "true" ]; then
|
||||
upsert_env IMESSAGE_ENABLED "$IMESSAGE_ENABLED"
|
||||
remove_env IMESSAGE_SERVER_URL
|
||||
remove_env IMESSAGE_API_KEY
|
||||
else
|
||||
upsert_env IMESSAGE_SERVER_URL "$IMESSAGE_SERVER_URL"
|
||||
upsert_env IMESSAGE_API_KEY "$IMESSAGE_API_KEY"
|
||||
remove_env IMESSAGE_ENABLED
|
||||
fi
|
||||
|
||||
# Container reads from data/env/env (the host mounts it).
|
||||
mkdir -p data/env
|
||||
cp .env data/env/env
|
||||
|
||||
log "Restarting service so the new adapter picks up the creds…"
|
||||
# shellcheck source=setup/lib/install-slug.sh
|
||||
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
|
||||
;;
|
||||
Linux)
|
||||
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| true
|
||||
;;
|
||||
esac
|
||||
|
||||
# Give the adapter a moment to open chat.db (local) or handshake with
|
||||
# Photon (remote) before emitting success.
|
||||
sleep 3
|
||||
|
||||
emit_status success
|
||||
@@ -1,133 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the Slack adapter, persist SLACK_BOT_TOKEN plus the mode-specific
|
||||
# secret (SLACK_APP_TOKEN for Socket Mode, SLACK_SIGNING_SECRET for webhook) to
|
||||
# .env + data/env/env, and restart the service. Non-interactive — the
|
||||
# operator-facing app creation walkthrough + credential paste live in
|
||||
# setup/channels/slack.ts. Credentials come in via env vars:
|
||||
# SLACK_BOT_TOKEN, and SLACK_APP_TOKEN and/or SLACK_SIGNING_SECRET.
|
||||
#
|
||||
# Emits exactly one status block on stdout (ADD_SLACK) at the end. All chatty
|
||||
# progress messages go to stderr so setup:auto's raw-log capture sees the full
|
||||
# story without cluttering the final block for the parser.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-slack/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/slack@4.29.0"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
# shellcheck source=setup/lib/channels-remote.sh
|
||||
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
|
||||
CHANNELS_REMOTE=$(resolve_channels_remote)
|
||||
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
local already=${ADAPTER_ALREADY_INSTALLED:-false}
|
||||
echo "=== NANOCLAW SETUP: ADD_SLACK ==="
|
||||
echo "STATUS: ${status}"
|
||||
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
|
||||
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
|
||||
[ -n "$error" ] && echo "ERROR: ${error}"
|
||||
echo "=== END ==="
|
||||
}
|
||||
|
||||
log() { echo "[add-slack] $*" >&2; }
|
||||
|
||||
if [ -z "${SLACK_BOT_TOKEN:-}" ]; then
|
||||
emit_status failed "SLACK_BOT_TOKEN env var not set"
|
||||
exit 1
|
||||
fi
|
||||
# Socket Mode authenticates with SLACK_APP_TOKEN; webhook mode with
|
||||
# SLACK_SIGNING_SECRET. Require at least one.
|
||||
if [ -z "${SLACK_APP_TOKEN:-}" ] && [ -z "${SLACK_SIGNING_SECRET:-}" ]; then
|
||||
emit_status failed "Set SLACK_APP_TOKEN (Socket Mode) or SLACK_SIGNING_SECRET (webhook)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
need_install() {
|
||||
[ ! -f src/channels/slack.ts ] && return 0
|
||||
! grep -q "^import './slack.js';" src/channels/index.ts 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
ADAPTER_ALREADY_INSTALLED=true
|
||||
if need_install; then
|
||||
ADAPTER_ALREADY_INSTALLED=false
|
||||
log "Fetching channels branch…"
|
||||
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Copying adapter from ${CHANNELS_BRANCH}…"
|
||||
git show "${CHANNELS_BRANCH}:src/channels/slack.ts" > src/channels/slack.ts
|
||||
|
||||
# Append self-registration import if missing.
|
||||
if ! grep -q "^import './slack.js';" src/channels/index.ts; then
|
||||
echo "import './slack.js';" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
log "Installing ${ADAPTER_VERSION}…"
|
||||
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Building…"
|
||||
pnpm run build >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm run build failed"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
log "Adapter files already installed — skipping install phase."
|
||||
fi
|
||||
|
||||
# Persist credentials. auto.ts validates via auth.test before this point, so
|
||||
# bad values here would be an internal bug rather than operator input.
|
||||
touch .env
|
||||
upsert_env() {
|
||||
local key=$1 value=$2
|
||||
if grep -q "^${key}=" .env; then
|
||||
awk -v k="$key" -v v="$value" \
|
||||
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
|
||||
.env > .env.tmp && mv .env.tmp .env
|
||||
else
|
||||
echo "${key}=${value}" >> .env
|
||||
fi
|
||||
}
|
||||
upsert_env SLACK_BOT_TOKEN "$SLACK_BOT_TOKEN"
|
||||
if [ -n "${SLACK_APP_TOKEN:-}" ]; then
|
||||
upsert_env SLACK_APP_TOKEN "$SLACK_APP_TOKEN"
|
||||
fi
|
||||
if [ -n "${SLACK_SIGNING_SECRET:-}" ]; then
|
||||
upsert_env SLACK_SIGNING_SECRET "$SLACK_SIGNING_SECRET"
|
||||
fi
|
||||
|
||||
# Container reads from data/env/env (the host mounts it).
|
||||
mkdir -p data/env
|
||||
cp .env data/env/env
|
||||
|
||||
log "Restarting service so the new adapter picks up the credentials…"
|
||||
# shellcheck source=setup/lib/install-slug.sh
|
||||
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
|
||||
;;
|
||||
Linux)
|
||||
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| true
|
||||
;;
|
||||
esac
|
||||
|
||||
# Give the Slack adapter a moment to finish starting the webhook listener
|
||||
# before emitting success.
|
||||
sleep 3
|
||||
|
||||
emit_status success
|
||||
@@ -1,139 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the Teams adapter, persist TEAMS_APP_ID / _PASSWORD / _TENANT_ID /
|
||||
# _TYPE to .env + data/env/env, and restart the service. Non-interactive —
|
||||
# the operator-facing Azure portal walkthroughs live in
|
||||
# setup/channels/teams.ts. Credentials come in via env vars:
|
||||
# TEAMS_APP_ID (required)
|
||||
# TEAMS_APP_PASSWORD (required — client secret value from Azure)
|
||||
# TEAMS_APP_TYPE (required — SingleTenant | MultiTenant)
|
||||
# TEAMS_APP_TENANT_ID (required when type=SingleTenant)
|
||||
#
|
||||
# Emits exactly one status block on stdout (ADD_TEAMS) at the end. All chatty
|
||||
# progress messages go to stderr so setup:auto's raw-log capture sees the
|
||||
# full story without cluttering the final block for the parser.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-teams/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/teams@4.29.0"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
# shellcheck source=setup/lib/channels-remote.sh
|
||||
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
|
||||
CHANNELS_REMOTE=$(resolve_channels_remote)
|
||||
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
local already=${ADAPTER_ALREADY_INSTALLED:-false}
|
||||
echo "=== NANOCLAW SETUP: ADD_TEAMS ==="
|
||||
echo "STATUS: ${status}"
|
||||
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
|
||||
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
|
||||
[ -n "$error" ] && echo "ERROR: ${error}"
|
||||
echo "=== END ==="
|
||||
}
|
||||
|
||||
log() { echo "[add-teams] $*" >&2; }
|
||||
|
||||
if [ -z "${TEAMS_APP_ID:-}" ]; then
|
||||
emit_status failed "TEAMS_APP_ID env var not set"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${TEAMS_APP_PASSWORD:-}" ]; then
|
||||
emit_status failed "TEAMS_APP_PASSWORD env var not set"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${TEAMS_APP_TYPE:-}" ]; then
|
||||
emit_status failed "TEAMS_APP_TYPE env var not set (SingleTenant|MultiTenant)"
|
||||
exit 1
|
||||
fi
|
||||
if [ "${TEAMS_APP_TYPE}" = "SingleTenant" ] && [ -z "${TEAMS_APP_TENANT_ID:-}" ]; then
|
||||
emit_status failed "TEAMS_APP_TENANT_ID required when TEAMS_APP_TYPE=SingleTenant"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
need_install() {
|
||||
[ ! -f src/channels/teams.ts ] && return 0
|
||||
! grep -q "^import './teams.js';" src/channels/index.ts 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
ADAPTER_ALREADY_INSTALLED=true
|
||||
if need_install; then
|
||||
ADAPTER_ALREADY_INSTALLED=false
|
||||
log "Fetching channels branch…"
|
||||
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Copying adapter from ${CHANNELS_BRANCH}…"
|
||||
git show "${CHANNELS_BRANCH}:src/channels/teams.ts" > src/channels/teams.ts
|
||||
|
||||
# Append self-registration import if missing.
|
||||
if ! grep -q "^import './teams.js';" src/channels/index.ts; then
|
||||
echo "import './teams.js';" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
log "Installing ${ADAPTER_VERSION}…"
|
||||
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Building…"
|
||||
pnpm run build >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm run build failed"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
log "Adapter files already installed — skipping install phase."
|
||||
fi
|
||||
|
||||
# Persist credentials.
|
||||
touch .env
|
||||
upsert_env() {
|
||||
local key=$1 value=$2
|
||||
if grep -q "^${key}=" .env; then
|
||||
awk -v k="$key" -v v="$value" \
|
||||
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
|
||||
.env > .env.tmp && mv .env.tmp .env
|
||||
else
|
||||
echo "${key}=${value}" >> .env
|
||||
fi
|
||||
}
|
||||
upsert_env TEAMS_APP_ID "$TEAMS_APP_ID"
|
||||
upsert_env TEAMS_APP_PASSWORD "$TEAMS_APP_PASSWORD"
|
||||
upsert_env TEAMS_APP_TYPE "$TEAMS_APP_TYPE"
|
||||
if [ -n "${TEAMS_APP_TENANT_ID:-}" ]; then
|
||||
upsert_env TEAMS_APP_TENANT_ID "$TEAMS_APP_TENANT_ID"
|
||||
fi
|
||||
|
||||
# Container reads from data/env/env (the host mounts it).
|
||||
mkdir -p data/env
|
||||
cp .env data/env/env
|
||||
|
||||
log "Restarting service so the new adapter picks up the credentials…"
|
||||
# shellcheck source=setup/lib/install-slug.sh
|
||||
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
|
||||
;;
|
||||
Linux)
|
||||
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| true
|
||||
;;
|
||||
esac
|
||||
|
||||
# Give the Teams adapter a moment to register its webhook before the driver
|
||||
# continues.
|
||||
sleep 5
|
||||
|
||||
emit_status success
|
||||
@@ -1,164 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the Telegram adapter, persist the bot token to .env + data/env/env,
|
||||
# restart the service, and open the bot's chat page in the local Telegram
|
||||
# client. Non-interactive — the operator-facing "Create a bot" instructions
|
||||
# and token paste live in setup/auto.ts. The token comes in via the
|
||||
# TELEGRAM_BOT_TOKEN env var.
|
||||
#
|
||||
# Emits exactly one status block on stdout (ADD_TELEGRAM) at the end. All
|
||||
# chatty progress messages go to stderr so setup:auto's raw-log capture
|
||||
# sees the full story without cluttering the final block for the parser.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-telegram/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/telegram@4.29.0"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
# shellcheck source=setup/lib/channels-remote.sh
|
||||
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
|
||||
CHANNELS_REMOTE=$(resolve_channels_remote)
|
||||
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
local already=${ADAPTER_ALREADY_INSTALLED:-false}
|
||||
local username=${BOT_USERNAME:-}
|
||||
echo "=== NANOCLAW SETUP: ADD_TELEGRAM ==="
|
||||
echo "STATUS: ${status}"
|
||||
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
|
||||
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
|
||||
[ -n "$username" ] && echo "BOT_USERNAME: ${username}"
|
||||
[ -n "$error" ] && echo "ERROR: ${error}"
|
||||
echo "=== END ==="
|
||||
}
|
||||
|
||||
log() { echo "[add-telegram] $*" >&2; }
|
||||
|
||||
if [ -z "${TELEGRAM_BOT_TOKEN:-}" ]; then
|
||||
emit_status failed "TELEGRAM_BOT_TOKEN env var not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! [[ "$TELEGRAM_BOT_TOKEN" =~ ^[0-9]+:[A-Za-z0-9_-]{35,}$ ]]; then
|
||||
emit_status failed "token format invalid (expected <digits>:<chars>)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
need_install() {
|
||||
[ ! -f src/channels/telegram.ts ] && return 0
|
||||
! grep -q "^import './telegram.js';" src/channels/index.ts 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
ADAPTER_ALREADY_INSTALLED=true
|
||||
if need_install; then
|
||||
ADAPTER_ALREADY_INSTALLED=false
|
||||
log "Fetching channels branch…"
|
||||
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# pair-telegram.ts is maintained in this branch (setup-auto), so it's NOT
|
||||
# in this list — do not overwrite the local version with the channels copy.
|
||||
log "Copying adapter files from ${CHANNELS_BRANCH}…"
|
||||
for f in \
|
||||
src/channels/telegram.ts \
|
||||
src/channels/telegram-pairing.ts \
|
||||
src/channels/telegram-pairing.test.ts \
|
||||
src/channels/telegram-markdown-sanitize.ts \
|
||||
src/channels/telegram-markdown-sanitize.test.ts
|
||||
do
|
||||
git show "${CHANNELS_BRANCH}:$f" > "$f"
|
||||
done
|
||||
|
||||
# Append self-registration import if missing.
|
||||
if ! grep -q "^import './telegram.js';" src/channels/index.ts; then
|
||||
echo "import './telegram.js';" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
# Register pair-telegram step if not already in the STEPS map.
|
||||
# Uses node (not sed) since sed's in-place + escape semantics differ
|
||||
# between BSD (macOS) and GNU.
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const p = "setup/index.ts";
|
||||
let s = fs.readFileSync(p, "utf-8");
|
||||
if (!s.includes("\047pair-telegram\047")) {
|
||||
s = s.replace(
|
||||
/(register: \(\) => import\(\x27\.\/register\.js\x27\),)/,
|
||||
"$1\n \x27pair-telegram\x27: () => import(\x27./pair-telegram.js\x27),"
|
||||
);
|
||||
fs.writeFileSync(p, s);
|
||||
}
|
||||
'
|
||||
|
||||
log "Installing ${ADAPTER_VERSION}…"
|
||||
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Building…"
|
||||
pnpm run build >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm run build failed"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
log "Adapter files already installed — skipping install phase."
|
||||
fi
|
||||
|
||||
# Persist token. auto.ts validates before this point, so a bad token here
|
||||
# would be an internal bug rather than operator input.
|
||||
touch .env
|
||||
if grep -q '^TELEGRAM_BOT_TOKEN=' .env; then
|
||||
awk -v tok="$TELEGRAM_BOT_TOKEN" \
|
||||
'/^TELEGRAM_BOT_TOKEN=/{print "TELEGRAM_BOT_TOKEN=" tok; next} {print}' \
|
||||
.env > .env.tmp && mv .env.tmp .env
|
||||
else
|
||||
echo "TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}" >> .env
|
||||
fi
|
||||
|
||||
# Look up the bot username (auto.ts already validated; we re-query here so
|
||||
# standalone invocations still work — BOT_USERNAME is emitted in the status
|
||||
# block for parent drivers to display).
|
||||
INFO=$(curl -fsS --max-time 8 \
|
||||
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe" 2>/dev/null || true)
|
||||
BOT_USERNAME=""
|
||||
if echo "$INFO" | grep -q '"ok":true'; then
|
||||
BOT_USERNAME=$(echo "$INFO" | sed -nE 's/.*"username":"([^"]+)".*/\1/p')
|
||||
fi
|
||||
|
||||
# Container reads from data/env/env (the host mounts it).
|
||||
mkdir -p data/env
|
||||
cp .env data/env/env
|
||||
|
||||
# Browser/app deep-link is done by the parent driver (setup/channels/telegram.ts)
|
||||
# BEFORE this script runs — gated on a clack confirm so focus-stealing doesn't
|
||||
# surprise the user. Keeping it out of here means this script stays pure
|
||||
# non-interactive install.
|
||||
|
||||
log "Restarting service so the new adapter picks up the token…"
|
||||
# shellcheck source=setup/lib/install-slug.sh
|
||||
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
|
||||
;;
|
||||
Linux)
|
||||
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| true
|
||||
;;
|
||||
esac
|
||||
|
||||
# Give the Telegram adapter a moment to finish starting before pair-telegram
|
||||
# begins polling for the user's code message.
|
||||
sleep 5
|
||||
|
||||
emit_status success
|
||||
+41
-31
@@ -32,15 +32,10 @@ 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';
|
||||
import { runSlackChannel } from './channels/slack.js';
|
||||
import { runTeamsChannel } from './channels/teams.js';
|
||||
import { runTelegramChannel } from './channels/telegram.js';
|
||||
import { runWhatsAppChannel } from './channels/whatsapp.js';
|
||||
import { runChannelSkill } from './channels/run-channel-skill.js';
|
||||
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
|
||||
import { getSetupProvider, listSetupProviders } from './providers/registry.js';
|
||||
import { applyProviderSkill } from './providers/install.js';
|
||||
// Provider payloads self-register their picker entry + auth on import.
|
||||
import './providers/index.js';
|
||||
import { brightSelect } from './lib/bright-select.js';
|
||||
@@ -344,26 +339,36 @@ async function main(): Promise<void> {
|
||||
let providerEntry = getSetupProvider(agentProvider);
|
||||
if (agentProvider !== 'claude' && !providerEntry) {
|
||||
// A non-claude provider picked from the hard-wired list isn't wired in
|
||||
// this install yet — install it via its self-contained script (channel
|
||||
// style, idempotent: self-skips if already installed), rebuild the image
|
||||
// (the container step already ran, the Dockerfile just changed), then
|
||||
// load the payload's setup module so it self-registers.
|
||||
const install = await runQuietChild(
|
||||
`add-${agentProvider}`,
|
||||
'bash',
|
||||
[`setup/add-${agentProvider}.sh`],
|
||||
{
|
||||
running: `Installing ${agentProvider}…`,
|
||||
done: `${agentProvider} installed.`,
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
// this install yet — install it by applying its `/add-<name>` SKILL.md
|
||||
// in-process via the directive engine (channel style, idempotent:
|
||||
// self-skips if already installed), rebuild the image (the container step
|
||||
// already ran, the CLI manifest just changed), then load the payload's
|
||||
// setup module so it self-registers.
|
||||
const skillDir = `.claude/skills/add-${agentProvider}`;
|
||||
const s = p.spinner();
|
||||
s.start(`Installing ${agentProvider}…`);
|
||||
let blockers: string[];
|
||||
try {
|
||||
({ blockers } = await applyProviderSkill(skillDir, process.cwd()));
|
||||
} catch (err) {
|
||||
s.stop(`Couldn't install ${agentProvider}.`, 1);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await fail(
|
||||
`add-${agentProvider}`,
|
||||
`Couldn't install ${agentProvider}.`,
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
message,
|
||||
);
|
||||
return; // unreachable — fail() exits — but narrows blockers for TS
|
||||
}
|
||||
if (blockers.length) {
|
||||
s.stop(`Couldn't install ${agentProvider}.`, 1);
|
||||
await fail(
|
||||
`add-${agentProvider}`,
|
||||
`Couldn't install ${agentProvider}.`,
|
||||
blockers.join('; '),
|
||||
);
|
||||
}
|
||||
s.stop(`${agentProvider} installed.`);
|
||||
p.log.info(brandBody('Rebuilding the container image with the new provider…'));
|
||||
spawnSync('./container/build.sh', [], { stdio: 'inherit' });
|
||||
await import(`./providers/${agentProvider}.js`);
|
||||
@@ -549,20 +554,24 @@ async function main(): Promise<void> {
|
||||
await resolveDisplayName();
|
||||
}
|
||||
let result: void | typeof BACK_TO_CHANNEL_SELECTION;
|
||||
// Every channel now runs through the SKILL.md-driven flow — the whole
|
||||
// connect+wire procedure lives in each add-<channel>/SKILL.md. Teams
|
||||
// defers the wire (its platform_id only exists after the first inbound),
|
||||
// so it installs + hands off rather than wiring inline.
|
||||
if (channelChoice === 'telegram') {
|
||||
result = await runTelegramChannel(displayName!);
|
||||
result = await runChannelSkill('telegram', displayName!, { offerBack: true });
|
||||
} else if (channelChoice === 'discord') {
|
||||
result = await runDiscordChannel(displayName!);
|
||||
result = await runChannelSkill('discord', displayName!, { offerBack: true });
|
||||
} else if (channelChoice === 'whatsapp') {
|
||||
result = await runWhatsAppChannel(displayName!);
|
||||
result = await runChannelSkill('whatsapp', displayName!, { offerBack: true });
|
||||
} else if (channelChoice === 'signal') {
|
||||
result = await runSignalChannel(displayName!);
|
||||
result = await runChannelSkill('signal', displayName!, { offerBack: true });
|
||||
} else if (channelChoice === 'teams') {
|
||||
result = await runTeamsChannel(displayName!);
|
||||
result = await runChannelSkill('teams', displayName!, { deferWire: true, offerBack: true });
|
||||
} else if (channelChoice === 'slack') {
|
||||
result = await runSlackChannel(displayName!);
|
||||
result = await runChannelSkill('slack', displayName!, { offerBack: true });
|
||||
} else if (channelChoice === 'imessage') {
|
||||
result = await runIMessageChannel(displayName!);
|
||||
result = await runChannelSkill('imessage', displayName!, { offerBack: true });
|
||||
} else if (channelChoice === 'other') {
|
||||
result = await askOtherChannelName();
|
||||
} else {
|
||||
@@ -803,7 +812,8 @@ function sendChatMessage(message: string): Promise<void> {
|
||||
// Providers offered for install are hard-wired in trunk — an audited control
|
||||
// surface (no branch enumeration that anyone with write access could extend).
|
||||
// Codex is the only one offered here; opencode/ollama install via their own
|
||||
// /add-* skills. Each is installed by its self-contained setup/add-<name>.sh.
|
||||
// /add-* skills. Each is installed by applying its `/add-<name>` SKILL.md
|
||||
// in-process via the directive engine.
|
||||
const INSTALLABLE_PROVIDERS = [
|
||||
{ value: 'codex', label: 'Codex', hint: 'OpenAI — ChatGPT subscription or API key' },
|
||||
] as const;
|
||||
@@ -812,7 +822,7 @@ async function askAgentProviderChoice(): Promise<string> {
|
||||
const installed = listSetupProviders();
|
||||
const installedNames = new Set(installed.map((entry) => entry.value));
|
||||
// Offer the hard-wired installable providers this install hasn't wired yet —
|
||||
// selecting one installs it via setup/add-<name>.sh.
|
||||
// selecting one applies its `/add-<name>` SKILL.md in-process.
|
||||
const available = INSTALLABLE_PROVIDERS.filter((prov) => !installedNames.has(prov.value));
|
||||
const options = [
|
||||
...installed.map(({ value, label, hint }) => ({ value, label, hint })),
|
||||
|
||||
@@ -1,528 +0,0 @@
|
||||
/**
|
||||
* Discord channel flow for setup:auto.
|
||||
*
|
||||
* `runDiscordChannel(displayName)` owns the full branch from "do you have a
|
||||
* bot?" through the welcome DM:
|
||||
*
|
||||
* 1. Ask if they have a bot already; walk them through Dev Portal creation
|
||||
* if not
|
||||
* 2. Paste the bot token (clack password) — format-validated
|
||||
* 3. GET /users/@me to confirm the token and resolve bot username
|
||||
* 4. GET /oauth2/applications/@me to derive application_id, verify_key
|
||||
* (public key), and owner — no separate paste needed in the common case
|
||||
* 5. Confirm owner identity (falls back to a manual user-id prompt with
|
||||
* Developer Mode instructions if declined or if the app is team-owned)
|
||||
* 6. Print the OAuth invite URL, open it, wait for "I've added the bot"
|
||||
* 7. Install the adapter via setup/add-discord.sh (non-interactive)
|
||||
* 8. POST /users/@me/channels to open the DM channel (yields dm channel id)
|
||||
* 9. Ask for the messaging-agent name (defaulting to "Nano")
|
||||
* 10. Wire the agent via scripts/init-first-agent.ts, which sends the welcome
|
||||
* DM through the normal delivery path
|
||||
*
|
||||
* All output obeys the three-level contract: clack UI for the user, structured
|
||||
* entries in logs/setup.log, full raw output in per-step files under
|
||||
* logs/setup-steps/. See docs/setup-flow.md.
|
||||
*/
|
||||
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, 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';
|
||||
|
||||
// Send Messages (0x800) + Add Reactions (0x40) + Attach Files (0x8000)
|
||||
// + Read Message History (0x10000) = 100416.
|
||||
// Matches the permissions set documented in .claude/skills/add-discord/SKILL.md.
|
||||
const INVITE_PERMISSIONS = '100416';
|
||||
|
||||
interface AppInfo {
|
||||
applicationId: string;
|
||||
publicKey: string;
|
||||
owner: { id: string; username: string } | null;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
// Even users who said "yes" often can't find the token on demand — the
|
||||
// Dev Portal resets it if you don't store it, and people forget which
|
||||
// app it belongs to. A quick reminder before the paste prompt is cheap.
|
||||
showTokenLocationReminder(hasBot);
|
||||
|
||||
const token = await collectDiscordToken();
|
||||
const botUsername = await validateDiscordToken(token);
|
||||
const app = await fetchApplicationInfo(token);
|
||||
|
||||
const ownerUserId = await resolveOwnerUserId(app.owner);
|
||||
|
||||
// Before inviting: do they have a server to invite into? Walkthrough if
|
||||
// not — a fresh Discord account without a server makes the invite page a
|
||||
// dead end.
|
||||
if (!(await askHasDiscordServer())) {
|
||||
await walkThroughServerCreation();
|
||||
}
|
||||
|
||||
await promptInviteBot(app.applicationId, botUsername);
|
||||
|
||||
const install = await runQuietChild(
|
||||
'discord-install',
|
||||
'bash',
|
||||
['setup/add-discord.sh'],
|
||||
{
|
||||
running: `Connecting Discord to @${botUsername}…`,
|
||||
done: 'Discord connected.',
|
||||
},
|
||||
{
|
||||
env: {
|
||||
DISCORD_BOT_TOKEN: token,
|
||||
DISCORD_APPLICATION_ID: app.applicationId,
|
||||
DISCORD_PUBLIC_KEY: app.publicKey,
|
||||
},
|
||||
extraFields: {
|
||||
BOT_USERNAME: botUsername,
|
||||
APPLICATION_ID: app.applicationId,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
await fail(
|
||||
'discord-install',
|
||||
"Couldn't connect Discord.",
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
}
|
||||
|
||||
const dmChannelId = await openDmChannel(token, ownerUserId);
|
||||
const platformId = `discord:@me:${dmChannelId}`;
|
||||
|
||||
const role = await askOperatorRole('Discord');
|
||||
setupLog.userInput('discord_role', role);
|
||||
|
||||
const agentName = await resolveAgentName();
|
||||
|
||||
const init = await runQuietChild(
|
||||
'init-first-agent',
|
||||
'pnpm',
|
||||
[
|
||||
'exec', 'tsx', 'scripts/init-first-agent.ts',
|
||||
'--channel', 'discord',
|
||||
'--user-id', `discord:${ownerUserId}`,
|
||||
'--platform-id', platformId,
|
||||
'--display-name', displayName,
|
||||
'--agent-name', agentName,
|
||||
'--role', role,
|
||||
],
|
||||
{
|
||||
running: `Connecting ${agentName} to your Discord DMs…`,
|
||||
done: `${agentName} is ready. Check Discord for a welcome message.`,
|
||||
},
|
||||
{
|
||||
extraFields: {
|
||||
CHANNEL: 'discord',
|
||||
AGENT_NAME: agentName,
|
||||
PLATFORM_ID: platformId,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!init.ok) {
|
||||
await fail(
|
||||
'init-first-agent',
|
||||
`Couldn't finish connecting ${agentName}.`,
|
||||
'Most likely the bot and you don\'t share a server yet — invite the bot, then retry later with `/manage-channels`.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 as 'yes' | 'no' | 'back';
|
||||
}
|
||||
|
||||
async function walkThroughBotCreation(): Promise<void> {
|
||||
const url = 'https://discord.com/developers/applications';
|
||||
note(
|
||||
[
|
||||
"You'll create a Discord bot in the Developer Portal. It's free and takes about a minute.",
|
||||
'',
|
||||
' 1. Click "New Application", give it a name (e.g. "NanoClaw")',
|
||||
' 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)',
|
||||
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');
|
||||
|
||||
ensureAnswer(
|
||||
await p.confirm({
|
||||
message: "Got your bot token?",
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function showTokenLocationReminder(hasExistingBot: boolean): void {
|
||||
// If we just walked them through creating a bot, they're staring at the
|
||||
// token. If they came in with an existing one, they may still need a nudge
|
||||
// to find it — tokens in the Dev Portal aren't visible after first reveal,
|
||||
// and "Reset Token" issues a new one.
|
||||
if (hasExistingBot) {
|
||||
note(
|
||||
[
|
||||
"Where to find your bot token:",
|
||||
'',
|
||||
' 1. discord.com/developers/applications → pick your app',
|
||||
' 2. "Bot" tab → "Reset Token" (the old one stops working)',
|
||||
' 3. Copy the new token',
|
||||
].join('\n'),
|
||||
'Reminder',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function askHasDiscordServer(): Promise<boolean> {
|
||||
const answer = ensureAnswer(
|
||||
await brightSelect({
|
||||
message: 'Do you have a Discord server you can add the bot to?',
|
||||
options: [
|
||||
{ value: 'yes', label: 'Yes, I have a server' },
|
||||
{ value: 'no', label: "No, walk me through creating one" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
setupLog.userInput('discord_has_server', String(answer));
|
||||
return answer === 'yes';
|
||||
}
|
||||
|
||||
async function walkThroughServerCreation(): Promise<void> {
|
||||
// Discord doesn't have a stable deep-link for "create server" so we open
|
||||
// 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';
|
||||
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")',
|
||||
formatNoteLink(url),
|
||||
].filter((line): line is string => line !== null).join('\n'),
|
||||
'Create a Discord server',
|
||||
);
|
||||
await confirmThenOpen(url, 'Press Enter to open Discord');
|
||||
|
||||
ensureAnswer(
|
||||
await p.confirm({
|
||||
message: "Server created?",
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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';
|
||||
// Discord bot tokens are base64url segments separated by dots.
|
||||
// Be lenient on length; the real check is /users/@me.
|
||||
if (!/^[A-Za-z0-9._-]{50,}$/.test(t)) {
|
||||
return "That doesn't look like a Discord bot token";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const token = (answer as string).trim();
|
||||
setupLog.userInput(
|
||||
'discord_token',
|
||||
`${token.slice(0, 10)}…${token.slice(-4)}`,
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
async function validateDiscordToken(token: string): Promise<string> {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
s.start('Checking your bot token…');
|
||||
try {
|
||||
const res = await fetch(`${DISCORD_API}/users/@me`, {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
});
|
||||
const data = (await res.json()) as {
|
||||
id?: string;
|
||||
username?: string;
|
||||
message?: string;
|
||||
};
|
||||
if (res.ok && data.username) {
|
||||
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 ?? '',
|
||||
});
|
||||
return data.username;
|
||||
}
|
||||
const reason = data.message ?? `HTTP ${res.status}`;
|
||||
s.stop(`Discord didn't accept that token: ${reason}`, 1);
|
||||
setupLog.step('discord-validate', 'failed', Date.now() - start, {
|
||||
ERROR: reason,
|
||||
});
|
||||
await fail(
|
||||
'discord-validate',
|
||||
"Discord didn't accept that token.",
|
||||
'Copy the token again from the Developer Portal and retry setup.',
|
||||
);
|
||||
} catch (err) {
|
||||
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,
|
||||
});
|
||||
await fail(
|
||||
'discord-validate',
|
||||
"Couldn't reach Discord.",
|
||||
'Check your internet connection and retry setup.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchApplicationInfo(token: string): Promise<AppInfo> {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
s.start('Looking up your bot application…');
|
||||
try {
|
||||
const res = await fetch(`${DISCORD_API}/oauth2/applications/@me`, {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
});
|
||||
const data = (await res.json()) as {
|
||||
id?: string;
|
||||
verify_key?: string;
|
||||
owner?: { id: string; username: string } | null;
|
||||
team?: unknown;
|
||||
message?: string;
|
||||
};
|
||||
if (!res.ok || !data.id || !data.verify_key) {
|
||||
const reason = data.message ?? `HTTP ${res.status}`;
|
||||
s.stop(`Couldn't read application info: ${reason}`, 1);
|
||||
setupLog.step('discord-app-info', 'failed', Date.now() - start, {
|
||||
ERROR: reason,
|
||||
});
|
||||
await fail(
|
||||
'discord-app-info',
|
||||
"Couldn't read your Discord application details.",
|
||||
'Re-run setup. If it keeps failing, check the bot token has the right scopes.',
|
||||
);
|
||||
}
|
||||
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 =
|
||||
data.owner && data.owner.id && data.owner.username
|
||||
? { id: data.owner.id, username: data.owner.username }
|
||||
: null;
|
||||
setupLog.step('discord-app-info', 'success', Date.now() - start, {
|
||||
APPLICATION_ID: data.id,
|
||||
OWNER_USERNAME: owner?.username ?? '',
|
||||
TEAM_OWNED: data.team ? 'true' : 'false',
|
||||
});
|
||||
return {
|
||||
applicationId: data.id,
|
||||
publicKey: data.verify_key,
|
||||
owner,
|
||||
};
|
||||
} catch (err) {
|
||||
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,
|
||||
});
|
||||
await fail(
|
||||
'discord-app-info',
|
||||
"Couldn't reach Discord.",
|
||||
'Check your internet connection and retry setup.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveOwnerUserId(
|
||||
owner: { id: string; username: string } | null,
|
||||
): Promise<string> {
|
||||
if (owner) {
|
||||
const confirmed = ensureAnswer(
|
||||
await p.confirm({
|
||||
message: `Is @${owner.username} your Discord account?`,
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
if (confirmed === true) {
|
||||
setupLog.userInput('discord_owner_confirmed', owner.username);
|
||||
return owner.id;
|
||||
}
|
||||
} else {
|
||||
p.log.info(
|
||||
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> {
|
||||
note(
|
||||
[
|
||||
"To get your Discord user ID:",
|
||||
'',
|
||||
' 1. Open Discord → Settings (⚙️) → Advanced',
|
||||
' 2. Turn on "Developer Mode"',
|
||||
' 3. Right-click your own name/avatar → "Copy User ID"',
|
||||
].join('\n'),
|
||||
'Find your Discord user ID',
|
||||
);
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'Paste your Discord user ID',
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'User ID is required';
|
||||
if (!/^\d{17,20}$/.test(t)) {
|
||||
return "That doesn't look like a Discord user ID (17-20 digits)";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const id = (answer as string).trim();
|
||||
setupLog.userInput('discord_user_id', id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function promptInviteBot(
|
||||
applicationId: string,
|
||||
botUsername: string,
|
||||
): Promise<void> {
|
||||
const url =
|
||||
`https://discord.com/api/oauth2/authorize` +
|
||||
`?client_id=${applicationId}` +
|
||||
`&scope=bot` +
|
||||
`&permissions=${INVITE_PERMISSIONS}`;
|
||||
|
||||
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"',
|
||||
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');
|
||||
|
||||
ensureAnswer(
|
||||
await p.confirm({
|
||||
message: "I've added the bot to a server",
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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(`${DISCORD_API}/users/@me/channels`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bot ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ recipient_id: userId }),
|
||||
});
|
||||
const data = (await res.json()) as { id?: string; message?: string };
|
||||
if (!res.ok || !data.id) {
|
||||
const reason = data.message ?? `HTTP ${res.status}`;
|
||||
s.stop(`Couldn't open a DM channel: ${reason}`, 1);
|
||||
setupLog.step('discord-open-dm', 'failed', Date.now() - start, {
|
||||
ERROR: reason,
|
||||
});
|
||||
await fail(
|
||||
'discord-open-dm',
|
||||
"Couldn't open a DM channel with you.",
|
||||
'Make sure the bot is in a server you\'re also in, then retry setup.',
|
||||
);
|
||||
}
|
||||
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) {
|
||||
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,
|
||||
});
|
||||
await fail(
|
||||
'discord-open-dm',
|
||||
"Couldn't reach Discord.",
|
||||
'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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
# Write the mode-exclusive iMessage `.env` keys, and strip the opposite mode's
|
||||
# keys so a stale value can't confuse the adapter's factory.
|
||||
#
|
||||
# The two iMessage modes use different keys:
|
||||
# local — IMESSAGE_LOCAL=true, IMESSAGE_ENABLED=true (no server/key)
|
||||
# remote — IMESSAGE_LOCAL=false, IMESSAGE_SERVER_URL, IMESSAGE_API_KEY
|
||||
#
|
||||
# This is an upsert-and-remove: it replaces a key in place if present (else
|
||||
# appends it) and deletes the other mode's keys. The skill engine's plain
|
||||
# env write is set-if-absent only — it can neither replace a stale value nor
|
||||
# delete a key — so that logic lives here, in one script the skill invokes
|
||||
# once per mode.
|
||||
#
|
||||
# bash setup/channels/imessage-configure.sh local
|
||||
# bash setup/channels/imessage-configure.sh remote "<server-url>" "<api-key>"
|
||||
set -u
|
||||
|
||||
mode="${1:-}"
|
||||
server_url="${2:-}"
|
||||
api_key="${3:-}"
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
root="$(cd "$here/../.." && pwd)"
|
||||
env_file="$root/.env"
|
||||
|
||||
# Replace `KEY=...` in place if present, else append it. Mirrors
|
||||
# setup/environment.ts:upsertEnvKey (set-or-replace, file ends with a newline).
|
||||
set_key() {
|
||||
local key="$1" val="$2" tmp found=0
|
||||
tmp="$(mktemp)"
|
||||
if [ -f "$env_file" ]; then
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
case "$line" in
|
||||
"${key}="*) printf '%s=%s\n' "$key" "$val" >> "$tmp"; found=1 ;;
|
||||
*) printf '%s\n' "$line" >> "$tmp" ;;
|
||||
esac
|
||||
done < "$env_file"
|
||||
fi
|
||||
[ "$found" -eq 0 ] && printf '%s=%s\n' "$key" "$val" >> "$tmp"
|
||||
mv "$tmp" "$env_file"
|
||||
}
|
||||
|
||||
# Drop every `KEY=...` line. Mirrors setup/environment.ts removeEnvKey.
|
||||
remove_key() {
|
||||
local key="$1" tmp
|
||||
[ -f "$env_file" ] || return 0
|
||||
tmp="$(mktemp)"
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
case "$line" in
|
||||
"${key}="*) ;;
|
||||
*) printf '%s\n' "$line" >> "$tmp" ;;
|
||||
esac
|
||||
done < "$env_file"
|
||||
mv "$tmp" "$env_file"
|
||||
}
|
||||
|
||||
case "$mode" in
|
||||
local)
|
||||
set_key IMESSAGE_LOCAL true
|
||||
set_key IMESSAGE_ENABLED true
|
||||
remove_key IMESSAGE_SERVER_URL
|
||||
remove_key IMESSAGE_API_KEY
|
||||
;;
|
||||
remote)
|
||||
set_key IMESSAGE_LOCAL false
|
||||
set_key IMESSAGE_SERVER_URL "$server_url"
|
||||
set_key IMESSAGE_API_KEY "$api_key"
|
||||
remove_key IMESSAGE_ENABLED
|
||||
;;
|
||||
*)
|
||||
echo "imessage-configure: unknown mode '${mode}' (expected local|remote)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1,336 +0,0 @@
|
||||
/**
|
||||
* iMessage channel flow for setup:auto.
|
||||
*
|
||||
* `runIMessageChannel(displayName)` covers both deployment modes:
|
||||
*
|
||||
* Local (macOS): the bot runs on this Mac and talks via the signed-in
|
||||
* iMessage account. Reading chat.db needs Full Disk Access granted to
|
||||
* the Node binary — we open the directory for them so they can drag
|
||||
* the `node` file into System Settings.
|
||||
*
|
||||
* Remote (Photon API): the bot talks to a separate server (Photon)
|
||||
* that owns an iMessage account on another Mac. Used when this host
|
||||
* is Linux, or when the operator wants to keep their daily-driver
|
||||
* Mac's chat history out of the loop.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Pick mode (auto-defaults to local on macOS, remote elsewhere)
|
||||
* 2. Local: FDA walkthrough (open node bin directory, wait for ack)
|
||||
* Remote: prompt for Photon server URL + API key
|
||||
* 3. Ask for the phone or email the operator messages from — this is
|
||||
* the platform-id for first-agent wiring
|
||||
* 4. Install the adapter (setup/add-imessage.sh, non-interactive)
|
||||
* 5. Wire the agent via scripts/init-first-agent.ts — the welcome
|
||||
* iMessage goes out through the normal delivery path
|
||||
*
|
||||
* All output obeys the three-level contract. See docs/setup-flow.md.
|
||||
*/
|
||||
import { execSync } from 'child_process';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
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 { accentGreen, note, wrapForGutter } from '../lib/theme.js';
|
||||
import { readEnvKey } from '../environment.js';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
|
||||
type Mode = 'local' | 'remote';
|
||||
|
||||
interface RemoteCreds {
|
||||
serverUrl: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
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') {
|
||||
if (!isMac) {
|
||||
await fail(
|
||||
'imessage',
|
||||
"Local iMessage mode only works on macOS.",
|
||||
'Choose remote mode (Photon API) on Linux/WSL, or run setup from your Mac.',
|
||||
);
|
||||
}
|
||||
await walkThroughFullDiskAccess();
|
||||
} else {
|
||||
remoteCreds = await collectRemoteCreds();
|
||||
}
|
||||
|
||||
const handle = await askOperatorHandle();
|
||||
|
||||
const install = await runQuietChild(
|
||||
'imessage-install',
|
||||
'bash',
|
||||
['setup/add-imessage.sh'],
|
||||
{
|
||||
running:
|
||||
mode === 'local'
|
||||
? "Connecting the iMessage adapter to this Mac…"
|
||||
: `Connecting the iMessage adapter to ${remoteCreds!.serverUrl}…`,
|
||||
done: 'iMessage adapter installed.',
|
||||
},
|
||||
{
|
||||
env:
|
||||
mode === 'local'
|
||||
? { IMESSAGE_LOCAL: 'true', IMESSAGE_ENABLED: 'true' }
|
||||
: {
|
||||
IMESSAGE_LOCAL: 'false',
|
||||
IMESSAGE_SERVER_URL: remoteCreds!.serverUrl,
|
||||
IMESSAGE_API_KEY: remoteCreds!.apiKey,
|
||||
},
|
||||
extraFields: { MODE: mode },
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
await fail(
|
||||
'imessage-install',
|
||||
"Couldn't install the iMessage adapter.",
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
}
|
||||
|
||||
const role = await askOperatorRole('iMessage');
|
||||
setupLog.userInput('imessage_role', role);
|
||||
|
||||
const agentName = await resolveAgentName();
|
||||
|
||||
const init = await runQuietChild(
|
||||
'init-first-agent',
|
||||
'pnpm',
|
||||
[
|
||||
'exec', 'tsx', 'scripts/init-first-agent.ts',
|
||||
'--channel', 'imessage',
|
||||
'--user-id', handle,
|
||||
'--platform-id', handle,
|
||||
'--display-name', displayName,
|
||||
'--agent-name', agentName,
|
||||
'--role', role,
|
||||
],
|
||||
{
|
||||
running: `Connecting ${agentName} to iMessage…`,
|
||||
done: `${agentName} is ready. Check iMessage for a welcome message.`,
|
||||
},
|
||||
{
|
||||
extraFields: {
|
||||
CHANNEL: 'imessage',
|
||||
AGENT_NAME: agentName,
|
||||
PLATFORM_ID: handle,
|
||||
MODE: mode,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!init.ok) {
|
||||
await fail(
|
||||
'init-first-agent',
|
||||
`Couldn't finish connecting ${agentName}.`,
|
||||
'Double-check Full Disk Access (local mode) or Photon credentials (remote), then retry.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 | 'back'>({
|
||||
message: 'How should iMessage run?',
|
||||
initialValue: isMac ? 'local' : 'remote',
|
||||
options: [
|
||||
...baseOptions,
|
||||
{ value: 'back', label: '← Back to channel selection' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
if (choice !== 'back') setupLog.userInput('imessage_mode', String(choice));
|
||||
return choice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant Full Disk Access to the Node binary the host runs under — without
|
||||
* it, the adapter can't read chat.db and inbound messages never arrive.
|
||||
* Opening the containing directory in Finder makes the drag-and-drop
|
||||
* target obvious; falling back to printing the path keeps us working in
|
||||
* SSH/headless contexts where `open` is a no-op.
|
||||
*/
|
||||
async function walkThroughFullDiskAccess(): Promise<void> {
|
||||
let nodePath = process.execPath;
|
||||
try {
|
||||
// `which node` picks up the user's shell-resolved node, which may differ
|
||||
// from process.execPath (e.g. they launched setup under a different
|
||||
// Node via `nvm`). If it succeeds and is resolvable, prefer it.
|
||||
const which = execSync('which node', { encoding: 'utf-8' }).trim();
|
||||
if (which) nodePath = which;
|
||||
} catch {
|
||||
// fall back to process.execPath
|
||||
}
|
||||
const nodeDir = path.dirname(nodePath);
|
||||
|
||||
note(
|
||||
wrapForGutter(
|
||||
[
|
||||
`iMessage needs Full Disk Access granted to the Node binary:`,
|
||||
'',
|
||||
` ${nodePath}`,
|
||||
'',
|
||||
' 1. System Settings → Privacy & Security → Full Disk Access',
|
||||
` 2. Click +, then drag the "node" file from the Finder window`,
|
||||
' we just opened for you',
|
||||
' 3. Toggle it on, then come back here',
|
||||
].join('\n'),
|
||||
6,
|
||||
),
|
||||
'Grant Full Disk Access',
|
||||
);
|
||||
|
||||
try {
|
||||
execSync(`open "${nodeDir}"`, { stdio: 'ignore' });
|
||||
} catch {
|
||||
// No Finder (SSH/headless) — user sees the path in the note above.
|
||||
}
|
||||
|
||||
ensureAnswer(
|
||||
await p.confirm({
|
||||
message: "Granted Full Disk Access?",
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
setupLog.userInput('imessage_fda_confirmed', 'true');
|
||||
}
|
||||
|
||||
async function collectRemoteCreds(): Promise<RemoteCreds> {
|
||||
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.",
|
||||
'',
|
||||
' 1. Set up a Photon server: https://photon.codes',
|
||||
' 2. Copy the server URL and API key from your Photon dashboard',
|
||||
].join('\n'),
|
||||
'Remote iMessage via Photon',
|
||||
);
|
||||
|
||||
const urlAnswer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'Photon server URL',
|
||||
placeholder: 'https://photon.example.com',
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'URL is required';
|
||||
if (!/^https?:\/\//i.test(t)) return 'Must start with http:// or https://';
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const serverUrl = (urlAnswer as string).trim();
|
||||
|
||||
const keyAnswer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Photon API key',
|
||||
clearOnError: true,
|
||||
validate: (v) => ((v ?? '').trim() ? undefined : 'API key is required'),
|
||||
}),
|
||||
);
|
||||
const apiKey = (keyAnswer as string).trim();
|
||||
|
||||
setupLog.userInput('imessage_server_url', serverUrl);
|
||||
setupLog.userInput(
|
||||
'imessage_api_key',
|
||||
`${apiKey.slice(0, 4)}…${apiKey.slice(-4)}`,
|
||||
);
|
||||
return { serverUrl, apiKey };
|
||||
}
|
||||
|
||||
async function askOperatorHandle(): Promise<string> {
|
||||
note(
|
||||
[
|
||||
"What phone number or email do you iMessage with?",
|
||||
"That's where your assistant will send its welcome message.",
|
||||
'',
|
||||
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',
|
||||
);
|
||||
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'Phone number or email',
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Required';
|
||||
const isPhone = /^\+\d{8,15}$/.test(t);
|
||||
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t);
|
||||
if (!isPhone && !isEmail) {
|
||||
return "Use a +E.164 phone number or an email address";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const handle = (answer as string).trim();
|
||||
setupLog.userInput('imessage_handle', handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { runChannelSkill } from './run-channel-skill.js';
|
||||
import { BACK_TO_CHANNEL_SELECTION, backGate } from '../lib/back-nav.js';
|
||||
|
||||
// Drive the first-prompt back gate (back-nav's brightSelect) from a queue
|
||||
// instead of opening a real TTY select. Hoisted so the vi.mock factory — which
|
||||
// runs before imports — can close over it. The existing Option-A tests never
|
||||
// opt into offerBack (and pass `role` so askOperatorRole's brightSelect isn't
|
||||
// reached either), so the mock is inert for them.
|
||||
const bs = vi.hoisted(() => ({ answers: [] as string[] }));
|
||||
vi.mock('../lib/bright-select.js', async (importActual) => {
|
||||
const actual = await importActual<typeof import('../lib/bright-select.js')>();
|
||||
return { ...actual, brightSelect: vi.fn(async () => bs.answers.shift() ?? 'continue') };
|
||||
});
|
||||
|
||||
// Drives the real add-slack skill through the adapter with every side effect
|
||||
// injected (no real ncl/git/clack/init-first-agent): confirms it runs the skill
|
||||
// (install + creds + resolve), reads the resolved owner_handle + platform_id from
|
||||
// the result, and hands them to the shared wire with a composed user-id.
|
||||
describe('runChannelSkill adapter (Option A)', () => {
|
||||
it('resolves via the skill, then wires through init-first-agent', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'rcs-'));
|
||||
mkdirSync(join(root, 'src/channels'), { recursive: true });
|
||||
writeFileSync(join(root, 'src/channels/index.ts'), '// barrel\n');
|
||||
writeFileSync(join(root, '.env'), '');
|
||||
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
|
||||
|
||||
const cmds: string[] = [];
|
||||
const exec = (c: string): string | void => {
|
||||
cmds.push(c);
|
||||
if (c.includes('auth.test')) return '@bot in Acme\n'; // identity capture
|
||||
// the resolve run: conversations.open piped through jq → "slack:<channel>"
|
||||
if (c.includes('conversations.open')) return 'slack:D0SLACK\n';
|
||||
};
|
||||
const wired: Array<Record<string, unknown>> = [];
|
||||
|
||||
await runChannelSkill('slack', 'Bob Smith', {
|
||||
projectRoot: root,
|
||||
exec,
|
||||
resolveRemote: () => 'origin',
|
||||
agentName: 'Nano',
|
||||
role: 'owner',
|
||||
// the secrets + handle a human would supply; the skill resolves platform_id.
|
||||
// Values are valid-shaped for the prompts' validate: regexes — validate-at-bind
|
||||
// now enforces them on `inputs` too (they used to bypass validation).
|
||||
inputs: { connection: 'webhook', bot_token: 'xoxb-x', signing_secret: '0123456789abcdef', owner_handle: 'U12345678' },
|
||||
wire: (a) => {
|
||||
wired.push(a);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// the channel-specific resolve ran
|
||||
expect(cmds.some((c) => c.includes('auth.test'))).toBe(true);
|
||||
expect(cmds.some((c) => c.includes('conversations.open'))).toBe(true);
|
||||
// ...and the shared wire got the composed user-id + resolved platform_id
|
||||
expect(wired).toHaveLength(1);
|
||||
expect(wired[0]).toMatchObject({
|
||||
channel: 'slack',
|
||||
userId: 'slack:U12345678', // channel + owner_handle
|
||||
platformId: 'slack:D0SLACK', // captured from conversations.open
|
||||
displayName: 'Bob Smith',
|
||||
agentName: 'Nano',
|
||||
role: 'owner',
|
||||
});
|
||||
// the adapter no longer emits any ncl wiring itself — that's init-first-agent's job
|
||||
expect(cmds.some((c) => c.startsWith('ncl '))).toBe(false);
|
||||
});
|
||||
|
||||
// Teams' platform_id only exists after the first inbound, so its SKILL.md
|
||||
// installs + hands off and runChannelSkill is called with deferWire — it must
|
||||
// run the skill but never reach the shared wire. This is the driver-policy
|
||||
// parity fixture: it runs the DEFAULT onEvent handler (never an injected
|
||||
// onEvent, which would replace the policy — §5.0) and injects the
|
||||
// confirm/openUrl seams to prove both natural barriers fire and the portal
|
||||
// URL offer survives from the operator prose alone.
|
||||
it('deferWire (Teams): default policy fires the gate barriers + portal URL offer, never reaches the shared wire', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'rcs-teams-'));
|
||||
mkdirSync(join(root, 'src/channels'), { recursive: true });
|
||||
writeFileSync(join(root, 'src/channels/index.ts'), '// barrel\n');
|
||||
writeFileSync(join(root, '.env'), '');
|
||||
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
|
||||
|
||||
const log: string[] = [];
|
||||
const opened: string[] = [];
|
||||
const wired: unknown[] = [];
|
||||
|
||||
await runChannelSkill('teams', 'Acme Corp', {
|
||||
projectRoot: root,
|
||||
exec: (c) => void log.push(`exec:${c}`),
|
||||
resolveRemote: () => 'origin',
|
||||
reuse: false,
|
||||
deferWire: true,
|
||||
// The injectable interaction seams — the default handler consults them for
|
||||
// the URL offer and the natural-barrier confirms, so no real clack confirm
|
||||
// (which would hang in CI) and no real browser open is reached.
|
||||
confirm: async (m) => {
|
||||
log.push(`confirm:${m}`);
|
||||
return true;
|
||||
},
|
||||
openUrl: async (u) => void opened.push(u),
|
||||
// a MultiTenant app, so the SingleTenant-guarded app_tenant_id prompt is skipped
|
||||
inputs: {
|
||||
public_url: 'https://acme.example',
|
||||
app_id: '12345678-1234-1234-1234-123456789abc',
|
||||
app_type: 'MultiTenant',
|
||||
app_password: 'a-much-longer-app-password', // 20+ chars — valid for the declared shape
|
||||
},
|
||||
wire: (a) => {
|
||||
wired.push(a);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// install + manifest ran…
|
||||
expect(log.some((c) => c.includes('teams-manifest-build'))).toBe(true);
|
||||
// …the Azure portal offer came from the operator BODY text (policy §5.2)…
|
||||
expect(opened.some((u) => /portal\.azure\.com/.test(u))).toBe(true);
|
||||
// …a natural-barrier confirm (not a URL offer) fired BEFORE the manifest
|
||||
// build (the manifest-before-the-app hazard fix, now derived from document
|
||||
// structure instead of an authored gate attr)…
|
||||
const firstGate = log.findIndex((c) => c.startsWith('confirm:') && !c.startsWith('confirm:Open '));
|
||||
const manifestAt = log.findIndex((c) => c.includes('teams-manifest-build'));
|
||||
expect(firstGate).toBeGreaterThanOrEqual(0);
|
||||
expect(firstGate).toBeLessThan(manifestAt);
|
||||
// …but the shared wire was never reached (no owner_handle/platform_id needed)
|
||||
expect(wired).toHaveLength(0);
|
||||
});
|
||||
|
||||
// The engine reads `.claude/skills/add-<channel>/SKILL.md` relative to cwd (the
|
||||
// repo root in tests — same as the real add-slack the test above drives), so a
|
||||
// bounce-fixture skill is created there and torn down afterward.
|
||||
const failChannel = 'failtest';
|
||||
const failSkillDir = join(process.cwd(), '.claude/skills', `add-${failChannel}`);
|
||||
afterEach(() => rmSync(failSkillDir, { recursive: true, force: true }));
|
||||
|
||||
// When the skill doesn't fully apply (a directive bounced to an agent), the
|
||||
// generic "couldn't finish" message is replaced by the bounced step's OWN
|
||||
// prose: the section heading becomes fail()'s headline and the surrounding
|
||||
// prose becomes the dimmed hint (which fail() also forwards to the Claude
|
||||
// handoff). Asserted via an injected fail spy (the real fail() process.exits).
|
||||
it('threads the bounced step prose into fail() when the skill does not fully apply', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'rcs-fail-'));
|
||||
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
|
||||
writeFileSync(join(root, '.env'), '');
|
||||
// A skill whose only directive bounces — the engine has no handler for
|
||||
// nc:hand-wire, so it degrades to an agent and the run is not fully applied.
|
||||
mkdirSync(failSkillDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(failSkillDir, 'SKILL.md'),
|
||||
[
|
||||
`# add ${failChannel}`,
|
||||
'',
|
||||
'## Register the webhook by hand',
|
||||
'Open the Faily dashboard and paste the webhook URL into the bot settings.',
|
||||
'```nc:hand-wire',
|
||||
'register webhook',
|
||||
'```',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const failCalls: Array<{ step: string; msg: string; hint?: string }> = [];
|
||||
const fakeFail = (step: string, msg: string, hint?: string): Promise<never> => {
|
||||
failCalls.push({ step, msg, hint });
|
||||
// The real fail() process.exits and never returns; emulate that by aborting
|
||||
// the flow so control doesn't fall through to the resolve/wire steps.
|
||||
return Promise.reject(new Error('__failed__'));
|
||||
};
|
||||
|
||||
await expect(
|
||||
runChannelSkill(failChannel, 'Bob', {
|
||||
projectRoot: root,
|
||||
exec: () => {},
|
||||
resolveRemote: () => 'origin',
|
||||
agentName: 'Nano',
|
||||
role: 'owner',
|
||||
reuse: false,
|
||||
inputs: {},
|
||||
fail: fakeFail,
|
||||
wire: () => true,
|
||||
}),
|
||||
).rejects.toThrow('__failed__');
|
||||
|
||||
expect(failCalls).toHaveLength(1);
|
||||
expect(failCalls[0].step).toBe(`${failChannel}-install`);
|
||||
expect(failCalls[0].msg).toBe('Register the webhook by hand'); // heading → headline
|
||||
expect(failCalls[0].hint).toContain('Open the Faily dashboard'); // prose → hint
|
||||
expect(failCalls[0].hint).not.toBe('See logs/setup-steps/ for details, then retry setup.'); // not the generic
|
||||
});
|
||||
});
|
||||
|
||||
// M5 backGate — the first-prompt "← Back to channel selection" gate. It's a
|
||||
// brightSelect (mocked above) wrapped in ensureAnswer; on back it returns the
|
||||
// existing BACK_TO_CHANNEL_SELECTION sentinel that setup/auto.ts already catches.
|
||||
describe('backGate (first-prompt back-to-channel-selection)', () => {
|
||||
it('returns the sentinel on back and continue otherwise', async () => {
|
||||
bs.answers = ['back'];
|
||||
expect(await backGate('Slack DMs')).toBe(BACK_TO_CHANNEL_SELECTION);
|
||||
|
||||
bs.answers = ['continue'];
|
||||
expect(await backGate('Slack DMs')).toBe('continue');
|
||||
});
|
||||
|
||||
// offerBack runs the gate at the very top — before resolveAgentName/role, the
|
||||
// skill run, and the wire. Picking back returns the sentinel without touching
|
||||
// any side effect (no exec, no wire).
|
||||
it('runChannelSkill with offerBack returns the sentinel before running the skill', async () => {
|
||||
bs.answers = ['back'];
|
||||
const cmds: string[] = [];
|
||||
const wired: unknown[] = [];
|
||||
|
||||
const result = await runChannelSkill('slack', 'Bob Smith', {
|
||||
offerBack: true,
|
||||
exec: (c) => void cmds.push(c),
|
||||
resolveRemote: () => 'origin',
|
||||
agentName: 'Nano',
|
||||
role: 'owner',
|
||||
inputs: { connection: 'webhook', bot_token: 'xoxb-x', signing_secret: '0123456789abcdef', owner_handle: 'U12345678' },
|
||||
wire: (a) => {
|
||||
wired.push(a);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBe(BACK_TO_CHANNEL_SELECTION);
|
||||
expect(cmds).toHaveLength(0); // the skill never ran
|
||||
expect(wired).toHaveLength(0); // the wire was never reached
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Generic channel onboarding for setup:auto — the replacement for the bespoke
|
||||
* per-channel `run<Channel>Channel` flows.
|
||||
*
|
||||
* Split of responsibilities (Option A):
|
||||
* - The channel's SKILL.md owns the channel-specific part: install the adapter,
|
||||
* collect credentials, and resolve the wire inputs `owner_handle` +
|
||||
* `platform_id` (e.g. Slack `conversations.open`). The engine surfaces those
|
||||
* resolved values in `ApplyResult.vars`.
|
||||
* - This flow owns the shared part: the operator's agent name + role (the
|
||||
* polish), and the wire itself — `scripts/init-first-agent.ts`, which creates
|
||||
* the agent group, grants the owner role (+ cli_scope=global), creates the
|
||||
* messaging group + wiring, and sends the `/welcome` system instruction.
|
||||
*
|
||||
* So the wire lives in exactly one place (init-first-agent) and is never
|
||||
* duplicated across channel skills.
|
||||
*/
|
||||
import * as p from '@clack/prompts';
|
||||
|
||||
import { firstFailureHint, fullyApplied } from '../../scripts/skill-apply.js';
|
||||
import { BACK_TO_CHANNEL_SELECTION, backGate, type ChannelFlowResult } from '../lib/back-nav.js';
|
||||
import { askOperatorRole, type OperatorRole } from '../lib/role-prompt.js';
|
||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||
import { runSkill, type RunSkillOptions } from '../lib/skill-driver.js';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
|
||||
interface WireArgs {
|
||||
channel: string;
|
||||
userId: string;
|
||||
platformId: string;
|
||||
displayName: string;
|
||||
agentName: string;
|
||||
role: OperatorRole;
|
||||
}
|
||||
|
||||
async function resolveAgentName(): Promise<string> {
|
||||
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
|
||||
if (preset) return preset;
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'What should your assistant be called?',
|
||||
placeholder: DEFAULT_AGENT_NAME,
|
||||
defaultValue: DEFAULT_AGENT_NAME,
|
||||
}),
|
||||
);
|
||||
return (answer as string).trim() || DEFAULT_AGENT_NAME;
|
||||
}
|
||||
|
||||
/** The shared wire: init-first-agent (group + owner role + cli_scope + wiring + /welcome). */
|
||||
async function initFirstAgent(args: WireArgs): Promise<boolean> {
|
||||
const res = await runQuietChild(
|
||||
'init-first-agent',
|
||||
'pnpm',
|
||||
[
|
||||
'exec', 'tsx', 'scripts/init-first-agent.ts',
|
||||
'--channel', args.channel,
|
||||
'--user-id', args.userId,
|
||||
'--platform-id', args.platformId,
|
||||
'--display-name', args.displayName,
|
||||
'--agent-name', args.agentName,
|
||||
'--role', args.role,
|
||||
],
|
||||
{ running: `Wiring ${args.agentName} to your ${args.channel} DMs…`, done: 'Agent wired.' },
|
||||
{ extraFields: { CHANNEL: args.channel, AGENT_NAME: args.agentName, PLATFORM_ID: args.platformId } },
|
||||
);
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
export interface ChannelSkillOverrides extends Partial<RunSkillOptions> {
|
||||
agentName?: string;
|
||||
role?: OperatorRole;
|
||||
/** The shared wire; defaults to init-first-agent. Injectable for tests. */
|
||||
wire?: (args: WireArgs) => Promise<boolean> | boolean;
|
||||
/**
|
||||
* Skip the resolve+wire half. For a channel whose platform_id only exists
|
||||
* after the first inbound (Teams): the SKILL.md installs the adapter and ends
|
||||
* with an `nc:operator` handoff telling the operator to DM the bot and finish
|
||||
* wiring later. No owner_handle/platform_id is expected, no agent name/role is
|
||||
* asked, and init-first-agent is not run.
|
||||
*/
|
||||
deferWire?: boolean;
|
||||
/**
|
||||
* Offer the "← Back to channel selection" gate as the very first prompt,
|
||||
* before any side effect (agent-name/role prompts, the skill run, the wire —
|
||||
* and the deferWire/Teams path too). On back, returns the
|
||||
* `BACK_TO_CHANNEL_SELECTION` sentinel and does nothing else. Opt-in so
|
||||
* headless callers (and the existing tests) never see the extra prompt.
|
||||
*/
|
||||
offerBack?: boolean;
|
||||
/** The first-prompt back gate; defaults to back-nav.ts `backGate`. Injectable for tests. */
|
||||
backGate?: (label: string) => Promise<'continue' | typeof BACK_TO_CHANNEL_SELECTION>;
|
||||
/** The abort path; defaults to runner.ts `fail` (which exits). Injectable for tests. */
|
||||
fail?: (stepName: string, msg: string, hint?: string, rawLogPath?: string) => Promise<never>;
|
||||
}
|
||||
|
||||
export async function runChannelSkill(
|
||||
channel: string,
|
||||
displayName: string,
|
||||
overrides: ChannelSkillOverrides = {},
|
||||
): Promise<ChannelFlowResult> {
|
||||
// First-prompt back gate — the very first thing, before any side effect
|
||||
// (agent-name/role prompts, the skill run, the wire; covers deferWire too).
|
||||
// Opt-in via offerBack so headless callers + existing tests are unaffected.
|
||||
if (overrides.offerBack) {
|
||||
const label = channel.charAt(0).toUpperCase() + channel.slice(1);
|
||||
const gate = await (overrides.backGate ?? backGate)(label);
|
||||
if (gate === BACK_TO_CHANNEL_SELECTION) return BACK_TO_CHANNEL_SELECTION;
|
||||
}
|
||||
|
||||
const projectRoot = overrides.projectRoot ?? process.cwd();
|
||||
const failWith = overrides.fail ?? fail;
|
||||
// The agent name + role are wire inputs — skip the prompts when the wire is deferred.
|
||||
const agentName = overrides.deferWire ? '' : overrides.agentName ?? (await resolveAgentName());
|
||||
const role = overrides.deferWire ? undefined : overrides.role ?? (await askOperatorRole(channel));
|
||||
|
||||
// Channel-specific: install adapter, collect credentials, resolve the wire
|
||||
// inputs. The whole channel-specific procedure lives in the SKILL.md.
|
||||
const res = await runSkill(`.claude/skills/add-${channel}`, {
|
||||
projectRoot,
|
||||
exec: overrides.exec,
|
||||
resolveInput: overrides.resolveInput,
|
||||
resolveRemote: overrides.resolveRemote,
|
||||
inputs: overrides.inputs,
|
||||
skipEffects: overrides.skipEffects,
|
||||
// undefined ⇒ runSkill's default policy handler (TTY-gated spinner + operator
|
||||
// note → URL offer → natural-barrier confirm). An injected onEvent replaces
|
||||
// that policy entirely; inject confirm/openUrl to observe the default policy.
|
||||
onEvent: overrides.onEvent,
|
||||
confirm: overrides.confirm,
|
||||
openUrl: overrides.openUrl,
|
||||
reuse: overrides.reuse ?? true, // offer to reuse credentials already in .env
|
||||
// Handoff context for the `?` help-escape: a lone `?` at any of this skill's
|
||||
// prompts hands the operator off to interactive Claude scoped to this channel.
|
||||
channel: overrides.channel ?? channel,
|
||||
step: overrides.step ?? `${channel}-install`,
|
||||
});
|
||||
if (!fullyApplied(res)) {
|
||||
if (res.deferred.length) p.log.warn(`Still needs: ${res.deferred.join(', ')}`);
|
||||
for (const t of res.agentTasks) p.log.warn(`Needs an agent (${t.kind}): ${t.reason}`);
|
||||
// On a real bounce, also surface the skill's reference floor (Alternatives /
|
||||
// Optional configuration / Troubleshooting) — the same prose a human reader
|
||||
// would scroll to when a step doesn't apply cleanly. Only on a bounce
|
||||
// (agentTasks), and only when the skill actually ships a reference section.
|
||||
if (res.agentTasks.length && res.referenceProse) p.log.info(res.referenceProse);
|
||||
// Surface the bounced step's OWN prose as the failure hint + Claude-handoff
|
||||
// context (fail() dims the hint and forwards it to offerClaudeOnFailure),
|
||||
// instead of a generic "couldn't finish" message. Only a real bounce yields a
|
||||
// diagnosis; a purely-deferred run (a missing input) falls back to the generic.
|
||||
const diag = firstFailureHint(res);
|
||||
await failWith(
|
||||
`${channel}-install`,
|
||||
diag?.headline ?? `Couldn't finish setting up ${channel}.`,
|
||||
diag?.hint ?? 'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
}
|
||||
|
||||
// Identity confirmation captured by the skill (e.g. add-slack's auth.test).
|
||||
if (res.vars.connected_as) p.log.success(`Connected to ${channel} as ${res.vars.connected_as}.`);
|
||||
|
||||
// Deferred wire (Teams): the SKILL's operator handoff owns the rest. Done here.
|
||||
if (overrides.deferWire) return;
|
||||
|
||||
const ownerHandle = res.vars.owner_handle;
|
||||
const platformId = res.vars.platform_id;
|
||||
if (!ownerHandle || !platformId) {
|
||||
await failWith(
|
||||
`${channel}-resolve`,
|
||||
`Couldn't resolve your ${channel} address.`,
|
||||
'The skill did not produce owner_handle + platform_id.',
|
||||
);
|
||||
}
|
||||
|
||||
// Shared wire — the same procedure for every channel. role is defined here:
|
||||
// it's only undefined in deferWire mode, which returned above.
|
||||
const wire = overrides.wire ?? initFirstAgent;
|
||||
const ok = await wire({ channel, userId: `${channel}:${ownerHandle}`, platformId, displayName, agentName, role: role! });
|
||||
if (!ok) {
|
||||
await failWith('init-first-agent', `Couldn't finish connecting ${agentName}.`, 'You can retry later with `/init-first-agent`.');
|
||||
}
|
||||
}
|
||||
@@ -1,417 +0,0 @@
|
||||
/**
|
||||
* Signal channel flow for setup:auto.
|
||||
*
|
||||
* `runSignalChannel(displayName)` owns the full branch from signal-cli
|
||||
* presence check through the welcome DM:
|
||||
*
|
||||
* 1. Probe signal-cli on PATH (or SIGNAL_CLI_PATH). On macOS without it,
|
||||
* offer `brew install signal-cli` inline. On Linux, surface the
|
||||
* GitHub releases URL and bail with an actionable error.
|
||||
* 2. Install the adapter + qrcode via setup/add-signal.sh (idempotent).
|
||||
* 3. Run the signal-auth step, rendering each SIGNAL_AUTH_QR block as
|
||||
* a terminal QR the operator scans from Signal → Linked Devices.
|
||||
* 4. Persist SIGNAL_ACCOUNT to .env (+ data/env/env).
|
||||
* 5. Kick the service so the adapter picks up the new credentials.
|
||||
* 6. Ask operator role + agent name.
|
||||
* 7. Wire the agent via scripts/init-first-agent.ts; the existing welcome
|
||||
* DM path delivers the greeting through the adapter.
|
||||
*
|
||||
* Signal's `link` flow creates a *secondary* device. The phone number
|
||||
* comes from the primary (the phone that scanned the QR); this host then
|
||||
* sends/receives as that primary number. No registration of new numbers.
|
||||
*
|
||||
* Output obeys the three-level contract: clack UI for the user, structured
|
||||
* entries in logs/setup.log, full raw output in per-step files under
|
||||
* logs/setup-steps/. See docs/setup-flow.md.
|
||||
*/
|
||||
import { spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
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,
|
||||
dumpTranscriptOnFailure,
|
||||
ensureAnswer,
|
||||
fail,
|
||||
runQuietChild,
|
||||
spawnStep,
|
||||
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<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(
|
||||
'signal-install',
|
||||
'bash',
|
||||
['setup/add-signal.sh'],
|
||||
{
|
||||
running: 'Installing the Signal adapter…',
|
||||
done: 'Signal adapter installed.',
|
||||
skipped: 'Signal adapter already installed.',
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
await fail(
|
||||
'signal-install',
|
||||
"Couldn't install the Signal adapter.",
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
}
|
||||
|
||||
const auth = await runSignalAuth();
|
||||
if (!auth.ok) {
|
||||
const reason = auth.terminal?.fields.ERROR ?? 'unknown';
|
||||
await fail(
|
||||
'signal-auth',
|
||||
`Signal link failed (${reason}).`,
|
||||
reason === 'qr_timeout'
|
||||
? 'The code expired. Re-run setup to get a fresh one.'
|
||||
: 'Re-run setup to try again.',
|
||||
);
|
||||
}
|
||||
|
||||
const account = auth.terminal?.fields.ACCOUNT;
|
||||
if (!account) {
|
||||
await fail(
|
||||
'signal-auth',
|
||||
'Linked with Signal but couldn\'t read the phone number back.',
|
||||
'Run `signal-cli listAccounts` to confirm, then re-run setup.',
|
||||
);
|
||||
}
|
||||
|
||||
writeSignalAccount(account!);
|
||||
await restartService();
|
||||
|
||||
const role = await askOperatorRole('Signal');
|
||||
setupLog.userInput('signal_role', role);
|
||||
|
||||
const agentName = await resolveAgentName();
|
||||
|
||||
const init = await runQuietChild(
|
||||
'init-first-agent',
|
||||
'pnpm',
|
||||
[
|
||||
'exec', 'tsx', 'scripts/init-first-agent.ts',
|
||||
'--channel', 'signal',
|
||||
'--user-id', account!,
|
||||
'--platform-id', account!,
|
||||
'--display-name', displayName,
|
||||
'--agent-name', agentName,
|
||||
'--role', role,
|
||||
],
|
||||
{
|
||||
running: `Connecting ${agentName} to Signal…`,
|
||||
done: `${agentName} is ready. Check Signal for a welcome message.`,
|
||||
},
|
||||
{
|
||||
extraFields: {
|
||||
CHANNEL: 'signal',
|
||||
AGENT_NAME: agentName,
|
||||
PLATFORM_ID: account!,
|
||||
ROLE: role,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!init.ok) {
|
||||
await fail(
|
||||
'init-first-agent',
|
||||
`Couldn't finish connecting ${agentName}.`,
|
||||
'You can retry later with `/manage-channels`.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSignalCli(): Promise<void> {
|
||||
const cli = process.env.SIGNAL_CLI_PATH || 'signal-cli';
|
||||
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') {
|
||||
note(
|
||||
[
|
||||
"We couldn't install signal-cli automatically.",
|
||||
reason === 'homebrew_not_installed'
|
||||
? ' Reason: Homebrew is not installed.'
|
||||
: ` Reason: ${reason ?? 'unknown'}.`,
|
||||
'',
|
||||
'You can install it manually:',
|
||||
'',
|
||||
k.cyan(' brew install signal-cli'),
|
||||
'',
|
||||
'Then re-run setup.',
|
||||
].join('\n'),
|
||||
"Couldn't install signal-cli",
|
||||
);
|
||||
} else {
|
||||
note(
|
||||
[
|
||||
"We couldn't install signal-cli automatically.",
|
||||
` Reason: ${reason ?? 'unknown'}.`,
|
||||
'',
|
||||
'You can install it manually from GitHub:',
|
||||
'',
|
||||
k.cyan(' https://github.com/AsamK/signal-cli/releases'),
|
||||
'',
|
||||
'Then re-run setup.',
|
||||
].join('\n'),
|
||||
"Couldn't install signal-cli",
|
||||
);
|
||||
}
|
||||
await fail(
|
||||
'install-signal-cli',
|
||||
'signal-cli is required but the auto-install failed.',
|
||||
'Install it manually and re-run setup.',
|
||||
);
|
||||
}
|
||||
|
||||
async function runSignalAuth(): Promise<
|
||||
StepResult & { rawLog: string; durationMs: number }
|
||||
> {
|
||||
const rawLog = setupLog.stepRawLog('signal-auth');
|
||||
const start = Date.now();
|
||||
const s = p.spinner();
|
||||
s.start('Starting Signal link…');
|
||||
let spinnerActive = true;
|
||||
|
||||
const stopSpinner = (msg: string, code?: number): void => {
|
||||
if (spinnerActive) {
|
||||
s.stop(msg, code);
|
||||
spinnerActive = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Tracks how many lines the QR block occupies so we can wipe it in-place
|
||||
// once linking succeeds (Signal's link URL doesn't rotate like WhatsApp's,
|
||||
// but we still want to erase the QR from screen once it's served).
|
||||
let qrLinesPrinted = 0;
|
||||
|
||||
const result = await spawnStep(
|
||||
'signal-auth',
|
||||
[],
|
||||
(block: Block) => {
|
||||
if (block.type === 'SIGNAL_AUTH_QR') {
|
||||
const qr = block.fields.QR ?? '';
|
||||
if (!qr) return;
|
||||
void renderQr(qr).then((lines) => {
|
||||
stopSpinner('Scan this QR from Signal → Settings → Linked Devices.');
|
||||
process.stdout.write(lines.join('\n') + '\n');
|
||||
qrLinesPrinted = lines.length;
|
||||
s.start('Waiting for you to scan…');
|
||||
spinnerActive = true;
|
||||
});
|
||||
} else if (block.type === 'SIGNAL_AUTH') {
|
||||
const status = block.fields.STATUS;
|
||||
// Wipe the QR block regardless of outcome — it's either scanned
|
||||
// and useless, or expired and misleading.
|
||||
if (qrLinesPrinted > 0) {
|
||||
process.stdout.write(`\x1b[${qrLinesPrinted}A\x1b[0J`);
|
||||
qrLinesPrinted = 0;
|
||||
}
|
||||
const account = block.fields.ACCOUNT;
|
||||
if (status === 'skipped') {
|
||||
stopSpinner(
|
||||
account
|
||||
? `Signal already linked as ${k.cyan(account)}.`
|
||||
: 'Signal already linked.',
|
||||
);
|
||||
} else if (status === 'success') {
|
||||
stopSpinner(`Signal linked as ${k.cyan(String(account ?? ''))}.`);
|
||||
} else if (status === 'failed') {
|
||||
const err = block.fields.ERROR ?? 'unknown';
|
||||
stopSpinner(`Signal link failed: ${err}`, 1);
|
||||
}
|
||||
}
|
||||
},
|
||||
rawLog,
|
||||
);
|
||||
const durationMs = Date.now() - start;
|
||||
|
||||
if (spinnerActive) {
|
||||
stopSpinner(
|
||||
result.ok ? 'Done.' : 'Signal link ended unexpectedly.',
|
||||
result.ok ? 0 : 1,
|
||||
);
|
||||
if (!result.ok) dumpTranscriptOnFailure(result.transcript);
|
||||
}
|
||||
|
||||
writeStepEntry('signal-auth', result, durationMs, rawLog);
|
||||
return { ...result, rawLog, durationMs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the raw linking URL as a block-art QR, returned line-by-line so
|
||||
* the caller can count lines for in-place cleanup. Uses small-mode so the
|
||||
* code stays scannable on 24-row terminals. If qrcode isn't installed
|
||||
* (add-signal.sh should have handled it, but we're defensive), fall back
|
||||
* to the raw URL and ask the user to paste it into an external renderer.
|
||||
*/
|
||||
async function renderQr(url: string): Promise<string[]> {
|
||||
try {
|
||||
const QRCode = await import('qrcode');
|
||||
const qrText = await QRCode.toString(url, { type: 'terminal', small: true });
|
||||
const caption = k.dim(
|
||||
' Signal → Settings → Linked Devices → Link New Device → scan.',
|
||||
);
|
||||
return [...qrText.trimEnd().split('\n'), '', caption];
|
||||
} catch {
|
||||
return [
|
||||
'Linking URL (render at https://qr.io or similar):',
|
||||
'',
|
||||
url,
|
||||
'',
|
||||
k.dim('Signal → Settings → Linked Devices → Link New Device → scan.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist SIGNAL_ACCOUNT to .env and mirror to data/env/env for the container. */
|
||||
function writeSignalAccount(account: string): void {
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
let contents = '';
|
||||
try {
|
||||
contents = fs.readFileSync(envPath, 'utf-8');
|
||||
} catch {
|
||||
contents = '';
|
||||
}
|
||||
if (/^SIGNAL_ACCOUNT=/m.test(contents)) {
|
||||
contents = contents.replace(
|
||||
/^SIGNAL_ACCOUNT=.*$/m,
|
||||
`SIGNAL_ACCOUNT=${account}`,
|
||||
);
|
||||
} else {
|
||||
if (contents.length > 0 && !contents.endsWith('\n')) contents += '\n';
|
||||
contents += `SIGNAL_ACCOUNT=${account}\n`;
|
||||
}
|
||||
fs.writeFileSync(envPath, contents);
|
||||
|
||||
const containerEnvDir = path.join(process.cwd(), 'data', 'env');
|
||||
fs.mkdirSync(containerEnvDir, { recursive: true });
|
||||
fs.copyFileSync(envPath, path.join(containerEnvDir, 'env'));
|
||||
|
||||
setupLog.userInput('signal_account', account);
|
||||
}
|
||||
|
||||
async function restartService(): Promise<void> {
|
||||
const s = p.spinner();
|
||||
s.start('Restarting NanoClaw so it sees your Signal account…');
|
||||
const start = Date.now();
|
||||
const platform = process.platform;
|
||||
try {
|
||||
if (platform === 'darwin') {
|
||||
spawnSync(
|
||||
'launchctl',
|
||||
['kickstart', '-k', `gui/${process.getuid?.() ?? 501}/${getLaunchdLabel()}`],
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
} else if (platform === 'linux') {
|
||||
const unit = getSystemdUnit();
|
||||
const user = spawnSync('systemctl', ['--user', 'restart', unit], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
if (user.status !== 0) {
|
||||
spawnSync('sudo', ['systemctl', 'restart', unit], { stdio: 'ignore' });
|
||||
}
|
||||
}
|
||||
// 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));
|
||||
s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||
setupLog.step('signal-restart', 'success', Date.now() - start, {
|
||||
PLATFORM: platform,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
s.stop(`Restart may have failed: ${message}`, 1);
|
||||
setupLog.step('signal-restart', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
});
|
||||
// Non-fatal — the user can restart manually if init-first-agent fails.
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,551 +0,0 @@
|
||||
/**
|
||||
* Slack channel flow for setup:auto.
|
||||
*
|
||||
* `runSlackChannel(displayName)` owns the full branch from creating a
|
||||
* Slack app through the welcome DM:
|
||||
*
|
||||
* 1. Ask the delivery mode: Socket Mode (outbound WebSocket, no public
|
||||
* URL) or a public webhook
|
||||
* 2. Walk through creating a Slack app (api.slack.com/apps) — scopes,
|
||||
* events, and the mode-specific credential (app-level token for
|
||||
* Socket Mode, signing secret for webhook)
|
||||
* 3. Paste the bot token + that credential (clack password prompts)
|
||||
* 4. Validate via auth.test → resolves workspace + bot identity
|
||||
* 5. Install the adapter (setup/add-slack.sh, non-interactive)
|
||||
* 6. Ask for the operator's Slack user ID
|
||||
* 7. conversations.open to get the DM channel ID
|
||||
* 8. Ask for the messaging-agent name (defaulting to "Nano")
|
||||
* 9. Wire the agent via scripts/init-first-agent.ts
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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 { 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 { 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;
|
||||
teamId: string;
|
||||
botName: string;
|
||||
botUserId: string;
|
||||
}
|
||||
|
||||
// Socket Mode (SLACK_APP_TOKEN, xapp-…) needs no public URL; webhook mode
|
||||
// (SLACK_SIGNING_SECRET) needs a public Request URL. The adapter picks the mode
|
||||
// purely from SLACK_APP_TOKEN's presence — this choice just decides which
|
||||
// credential to collect and which post-install guidance to show.
|
||||
type SlackMode = 'socket' | 'webhook';
|
||||
|
||||
export async function runSlackChannel(displayName: string): Promise<ChannelFlowResult> {
|
||||
const mode = await askSlackMode();
|
||||
const intro = await walkThroughAppCreation(mode);
|
||||
if (intro === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
|
||||
const token = await collectBotToken();
|
||||
const appToken = mode === 'socket' ? await collectAppToken() : undefined;
|
||||
const signingSecret = mode === 'webhook' ? await collectSigningSecret() : undefined;
|
||||
const info = await validateSlackToken(token);
|
||||
|
||||
const env: Record<string, string> = { SLACK_BOT_TOKEN: token };
|
||||
if (appToken) env.SLACK_APP_TOKEN = appToken;
|
||||
if (signingSecret) env.SLACK_SIGNING_SECRET = signingSecret;
|
||||
|
||||
const install = await runQuietChild(
|
||||
'slack-install',
|
||||
'bash',
|
||||
['setup/add-slack.sh'],
|
||||
{
|
||||
running: `Connecting Slack to @${info.botName} (${info.teamName})…`,
|
||||
done: 'Slack adapter installed.',
|
||||
},
|
||||
{
|
||||
env,
|
||||
extraFields: {
|
||||
MODE: mode,
|
||||
BOT_NAME: info.botName,
|
||||
TEAM_NAME: info.teamName,
|
||||
TEAM_ID: info.teamId,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
await fail(
|
||||
'slack-install',
|
||||
"Couldn't connect Slack.",
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
}
|
||||
|
||||
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, mode);
|
||||
}
|
||||
|
||||
async function askSlackMode(): Promise<SlackMode> {
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect<SlackMode>({
|
||||
message: 'How should Slack deliver events to NanoClaw?',
|
||||
initialValue: 'socket',
|
||||
options: [
|
||||
{
|
||||
value: 'socket',
|
||||
label: 'Socket Mode',
|
||||
hint: 'no public URL — recommended for local or behind NAT',
|
||||
},
|
||||
{
|
||||
value: 'webhook',
|
||||
label: 'Public webhook',
|
||||
hint: 'needs a public HTTPS Request URL',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
setupLog.userInput('slack_mode', String(choice));
|
||||
return choice;
|
||||
}
|
||||
|
||||
async function walkThroughAppCreation(mode: SlackMode): Promise<'continue' | 'back'> {
|
||||
const credSteps =
|
||||
mode === 'socket'
|
||||
? [
|
||||
' 4. Basic Information → App-Level Tokens → "Generate Token and',
|
||||
' Scopes" → add the connections:write scope → copy it (xapp-…)',
|
||||
' 5. Socket Mode → toggle "Enable Socket Mode" on',
|
||||
' 6. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
|
||||
]
|
||||
: [
|
||||
' 4. Basic Information → copy the "Signing Secret"',
|
||||
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
|
||||
];
|
||||
// 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:',
|
||||
' • im:write, im:history',
|
||||
' • channels:read, channels:history',
|
||||
' • groups:read, groups:history',
|
||||
' • chat:write',
|
||||
' • users:read',
|
||||
' • reactions:write',
|
||||
' • files:read, files:write',
|
||||
' 3. App Home → enable "Messages Tab" and "Allow users to send',
|
||||
' slash commands and messages from the messages tab"',
|
||||
...credSteps,
|
||||
].join('\n'),
|
||||
'Create a Slack app',
|
||||
);
|
||||
|
||||
// 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({
|
||||
message:
|
||||
mode === 'socket'
|
||||
? 'Got your bot token and app-level token?'
|
||||
: 'Got your bot token and signing secret?',
|
||||
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';
|
||||
if (!t.startsWith('xoxb-')) return 'Bot tokens start with xoxb-';
|
||||
if (t.length < 24) return "That's shorter than a real Slack bot token";
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const token = (answer as string).trim();
|
||||
setupLog.userInput(
|
||||
'slack_bot_token',
|
||||
`${token.slice(0, 10)}…${token.slice(-4)}`,
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
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';
|
||||
// Slack signing secrets are 32-char hex strings, but newer apps
|
||||
// sometimes emit longer variants — leniently require hex only.
|
||||
if (!/^[a-f0-9]{16,}$/i.test(t)) {
|
||||
return 'Signing secrets are a string of hex characters';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const secret = (answer as string).trim();
|
||||
setupLog.userInput(
|
||||
'slack_signing_secret',
|
||||
`${secret.slice(0, 4)}…${secret.slice(-4)}`,
|
||||
);
|
||||
return secret;
|
||||
}
|
||||
|
||||
async function collectAppToken(): Promise<string> {
|
||||
const existing = readEnvKey('SLACK_APP_TOKEN');
|
||||
if (existing && existing.startsWith('xapp-') && existing.length >= 24) {
|
||||
const reuse = ensureAnswer(await p.confirm({
|
||||
message: `Found an existing Slack app-level token (${existing.slice(0, 10)}…). Use it?`,
|
||||
initialValue: true,
|
||||
}));
|
||||
if (reuse) {
|
||||
setupLog.userInput('slack_app_token', 'reused-existing');
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Paste your Slack app-level token (Socket Mode)',
|
||||
clearOnError: true,
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'App-level token is required for Socket Mode';
|
||||
if (!t.startsWith('xapp-')) return 'App-level tokens start with xapp-';
|
||||
if (t.length < 24) return "That's shorter than a real Slack app-level token";
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const token = (answer as string).trim();
|
||||
setupLog.userInput(
|
||||
'slack_app_token',
|
||||
`${token.slice(0, 10)}…${token.slice(-4)}`,
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
s.start('Checking your bot token…');
|
||||
try {
|
||||
const res = await fetch(`${SLACK_API}/auth.test`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
});
|
||||
const data = (await res.json()) as {
|
||||
ok?: boolean;
|
||||
team?: string;
|
||||
team_id?: string;
|
||||
user?: string;
|
||||
user_id?: string;
|
||||
error?: string;
|
||||
};
|
||||
if (data.ok && data.team && data.user) {
|
||||
s.stop(
|
||||
`Connected to ${data.team} as @${data.user}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`,
|
||||
);
|
||||
const info: WorkspaceInfo = {
|
||||
teamName: data.team,
|
||||
teamId: data.team_id ?? '',
|
||||
botName: data.user,
|
||||
botUserId: data.user_id ?? '',
|
||||
};
|
||||
setupLog.step('slack-validate', 'success', Date.now() - start, {
|
||||
BOT_NAME: info.botName,
|
||||
BOT_USER_ID: info.botUserId,
|
||||
TEAM_NAME: info.teamName,
|
||||
TEAM_ID: info.teamId,
|
||||
});
|
||||
return info;
|
||||
}
|
||||
const reason = data.error ?? `HTTP ${res.status}`;
|
||||
s.stop(`Slack didn't accept that token: ${reason}`, 1);
|
||||
setupLog.step('slack-validate', 'failed', Date.now() - start, {
|
||||
ERROR: reason,
|
||||
});
|
||||
await fail(
|
||||
'slack-validate',
|
||||
"Slack didn't accept that token.",
|
||||
reason === 'invalid_auth' || reason === 'token_revoked'
|
||||
? 'Copy the token again from OAuth & Permissions and retry setup.'
|
||||
: `Slack said "${reason}". Check the token scopes and workspace install, 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-validate', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
});
|
||||
await fail(
|
||||
'slack-validate',
|
||||
"Couldn't reach Slack.",
|
||||
'Check your internet connection and retry setup.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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, mode: SlackMode): void {
|
||||
if (mode === 'socket') {
|
||||
note(
|
||||
wrapForGutter(
|
||||
[
|
||||
`Your agent is wired to Slack and a welcome DM is on its way.`,
|
||||
`Socket Mode is on — ${info.teamName} reaches NanoClaw over an outbound`,
|
||||
`WebSocket, so there's no public URL to configure.`,
|
||||
'',
|
||||
' • Just DM @' + info.botName + ' from Slack — replies flow straight away.',
|
||||
'',
|
||||
' • Keep the NanoClaw host running to hold the socket open —',
|
||||
' Slack does not retry delivery while it is down.',
|
||||
].join('\n'),
|
||||
6,
|
||||
),
|
||||
'Finish setting up Slack',
|
||||
);
|
||||
return;
|
||||
}
|
||||
note(
|
||||
wrapForGutter(
|
||||
[
|
||||
`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. 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 Changes',
|
||||
'',
|
||||
' 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,
|
||||
),
|
||||
'Finish setting up Slack',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Tiny CLI wrapper around `buildTeamsAppPackage` so the add-teams SKILL.md can
|
||||
* generate the sideload app package with a single declarative step instead of an
|
||||
* inline `tsx -e` blob. The whole zip-building logic (manifest + icons, no
|
||||
* external image deps) already lives in `setup/lib/teams-manifest.ts`; this just
|
||||
* maps a couple of CLI flags onto it and prints the resulting zip path.
|
||||
*
|
||||
* Mirrors the bespoke `stepGenerateManifest` in the old setup/channels/teams.ts:
|
||||
* the short name comes from NANOCLAW_AGENT_NAME (falling back to "NanoClaw"), the
|
||||
* description is "<name> personal assistant powered by NanoClaw.", the website
|
||||
* URL is the operator's public base URL, and the output lands in data/teams/.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm exec tsx setup/channels/teams-manifest-build.ts \
|
||||
* --app-id <azure-app-id> --url https://your-domain [--out data/teams]
|
||||
*/
|
||||
import { buildTeamsAppPackage } from '../lib/teams-manifest.js';
|
||||
|
||||
function flag(name: string): string | undefined {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
return i >= 0 ? process.argv[i + 1] : undefined;
|
||||
}
|
||||
|
||||
const appId = flag('app-id');
|
||||
const url = flag('url');
|
||||
const outDir = flag('out') ?? 'data/teams';
|
||||
const shortName = process.env.NANOCLAW_AGENT_NAME?.trim() || 'NanoClaw';
|
||||
|
||||
if (!appId || !url) {
|
||||
console.error(
|
||||
'usage: teams-manifest-build.ts --app-id <azure-app-id> --url <https-url> [--out <dir>]',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const result = buildTeamsAppPackage({
|
||||
appId,
|
||||
shortName,
|
||||
longDescription: `${shortName} personal assistant powered by NanoClaw.`,
|
||||
websiteUrl: url,
|
||||
outDir,
|
||||
});
|
||||
|
||||
console.log(`Teams app package: ${result.zipPath}`);
|
||||
@@ -1,753 +0,0 @@
|
||||
/**
|
||||
* Microsoft Teams channel flow for setup:auto.
|
||||
*
|
||||
* Teams is the most complex channel NanoClaw supports — the Slack/Discord
|
||||
* "paste a token" shortcut doesn't exist. The operator has to walk through
|
||||
* ~7 Azure portal steps (app registration, client secret, Azure Bot
|
||||
* resource, messaging endpoint, Teams channel enable, manifest, sideload).
|
||||
*
|
||||
* This driver's job is to make each of those steps as guided as possible
|
||||
* inside the terminal:
|
||||
* 1. Print a clack note with the exact sub-steps and the portal URL.
|
||||
* 2. Ask for the value(s) that step yields (App ID, secret, tenant, etc.).
|
||||
* 3. At every step boundary, offer `stepGate` — a Done / Stuck / Show-again
|
||||
* select. "Stuck" hands off to interactive Claude with full context.
|
||||
*
|
||||
* Text/password prompts also accept `?` as an answer to trigger the handoff,
|
||||
* so the operator can escape at any paste point without scrolling back to a
|
||||
* step boundary.
|
||||
*
|
||||
* What's deferred (known limitation, instruct user how to finish manually):
|
||||
* - Wait-for-first-DM to capture the auto-generated Teams platformId.
|
||||
* Unlike Discord/Telegram, the Teams platform_id is only discoverable
|
||||
* after the first inbound activity. The driver installs the adapter and
|
||||
* stops there; the operator DMs the bot, NanoClaw auto-creates the
|
||||
* messaging group, and they wire an agent via `/manage-channels`.
|
||||
*/
|
||||
import os from 'os';
|
||||
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 {
|
||||
isHelpEscape,
|
||||
offerClaudeHandoff,
|
||||
validateWithHelpEscape,
|
||||
type HandoffContext,
|
||||
} 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');
|
||||
const AZURE_PORTAL_URL = 'https://portal.azure.com';
|
||||
|
||||
interface Collected {
|
||||
publicUrl?: string;
|
||||
appId?: string;
|
||||
tenantId?: string;
|
||||
appType?: 'SingleTenant' | 'MultiTenant';
|
||||
appPassword?: string;
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
const prereqsResult = await confirmPrereqs({ collected, completed });
|
||||
if (prereqsResult === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
await stepPublicUrl({ 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 });
|
||||
if (
|
||||
await stepSideload({ collected, completed, zipPath: manifestResult.zipPath })
|
||||
=== 'back'
|
||||
) {
|
||||
return BACK_TO_CHANNEL_SELECTION;
|
||||
}
|
||||
|
||||
await installAdapter(collected);
|
||||
completed.push('Adapter installed and service restarted.');
|
||||
|
||||
await finishWithHandoff(collected, completed);
|
||||
}
|
||||
|
||||
// ─── step: intro / prereqs ──────────────────────────────────────────────
|
||||
|
||||
function printIntro(): void {
|
||||
note(
|
||||
[
|
||||
'Setting up Teams is more involved than the other channels — about',
|
||||
'7 steps across the Azure portal and Teams admin.',
|
||||
'',
|
||||
k.dim("At any prompt you can type '?' and press Enter to hand off"),
|
||||
k.dim("to Claude interactive mode with your current progress."),
|
||||
k.dim("You can also pick 'Stuck' at any Done/Stuck/Show-again prompt."),
|
||||
].join('\n'),
|
||||
'Microsoft Teams setup',
|
||||
);
|
||||
}
|
||||
|
||||
async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise<'continue' | 'back'> {
|
||||
note(
|
||||
[
|
||||
'Before we start, confirm you have:',
|
||||
'',
|
||||
' • A Microsoft 365 tenant where you can sideload custom apps',
|
||||
' (free personal Teams does NOT support this — you need a',
|
||||
' Microsoft 365 Business / EDU / developer tenant)',
|
||||
' • Teams admin or developer tenant rights',
|
||||
' • A way to expose an HTTPS endpoint from this machine',
|
||||
' (ngrok, Cloudflare Tunnel, or a reverse-proxied VPS)',
|
||||
].join('\n'),
|
||||
'Prereqs',
|
||||
);
|
||||
|
||||
// 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> {
|
||||
note(
|
||||
[
|
||||
"Azure Bot Service delivers messages to an HTTPS endpoint you",
|
||||
"control. The endpoint needs to reach this machine's webhook",
|
||||
"server at /api/webhooks/teams.",
|
||||
'',
|
||||
k.dim('Examples:'),
|
||||
k.dim(' ngrok http 3000 → https://abcd1234.ngrok.io'),
|
||||
k.dim(' cloudflared tunnel … → https://<tunnel>.trycloudflare.com'),
|
||||
k.dim(' or a reverse proxy on your own domain'),
|
||||
'',
|
||||
"If you don't have a tunnel running yet, start one in another",
|
||||
"terminal, then come back here.",
|
||||
].join('\n'),
|
||||
'Public HTTPS URL',
|
||||
);
|
||||
|
||||
while (true) {
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'Paste your public base URL (e.g. https://abcd1234.ngrok.io)',
|
||||
placeholder: 'https://…',
|
||||
validate: validateWithHelpEscape((v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Required';
|
||||
if (!/^https:\/\/[^\s/]+/.test(t)) {
|
||||
return 'Must be an https:// URL (Azure rejects http)';
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
}),
|
||||
);
|
||||
if (isHelpEscape(answer)) {
|
||||
await offerHandoff({
|
||||
step: 'teams-public-url',
|
||||
stepDescription:
|
||||
'setting up a public HTTPS tunnel to reach this machine on port 3000',
|
||||
args,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const url = (answer as string).trim().replace(/\/$/, '');
|
||||
args.collected.publicUrl = url;
|
||||
setupLog.userInput('teams_public_url', url);
|
||||
break;
|
||||
}
|
||||
|
||||
args.completed.push(`Public URL: ${args.collected.publicUrl}`);
|
||||
}
|
||||
|
||||
// ─── step: Azure App Registration ──────────────────────────────────────
|
||||
|
||||
async function stepAppRegistration(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
}): Promise<'continue' | 'back'> {
|
||||
note(
|
||||
[
|
||||
`1. In ${AZURE_PORTAL_URL}, search "App registrations" → "New registration"`,
|
||||
'2. Name it (e.g. "NanoClaw")',
|
||||
'3. Supported account types: Single tenant (your org only) OR',
|
||||
' Multi tenant (any Microsoft 365 tenant can add the bot)',
|
||||
'4. Click Register',
|
||||
'5. On the Overview page, copy:',
|
||||
' • Application (client) ID',
|
||||
' • Directory (tenant) ID',
|
||||
].join('\n'),
|
||||
'Step 1 of 6 — Create Azure App Registration',
|
||||
);
|
||||
await confirmThenOpen(
|
||||
AZURE_PORTAL_URL,
|
||||
'Press Enter to open the Azure portal',
|
||||
);
|
||||
|
||||
args.collected.appType = await askAppType(args);
|
||||
args.collected.appId = await askUuid(
|
||||
'Paste the Application (client) ID',
|
||||
'teams-app-id',
|
||||
args,
|
||||
);
|
||||
if (args.collected.appType === 'SingleTenant') {
|
||||
args.collected.tenantId = await askUuid(
|
||||
'Paste the Directory (tenant) ID',
|
||||
'teams-tenant-id',
|
||||
args,
|
||||
);
|
||||
}
|
||||
|
||||
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: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
}): Promise<'SingleTenant' | 'MultiTenant'> {
|
||||
while (true) {
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect({
|
||||
message: 'Which account type did you pick?',
|
||||
options: [
|
||||
{
|
||||
value: 'SingleTenant',
|
||||
label: 'Single tenant',
|
||||
hint: 'your org only — most common for self-host',
|
||||
},
|
||||
{
|
||||
value: 'MultiTenant',
|
||||
label: 'Multi tenant',
|
||||
hint: 'any Microsoft 365 tenant can install the bot',
|
||||
},
|
||||
{ value: 'help', label: 'Stuck — hand me off to Claude' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
if (choice === 'help') {
|
||||
await offerHandoff({
|
||||
step: 'teams-app-type',
|
||||
stepDescription: "deciding between Single tenant and Multi tenant for their Azure app",
|
||||
args,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
return choice as 'SingleTenant' | 'MultiTenant';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── step: client secret ───────────────────────────────────────────────
|
||||
|
||||
async function stepClientSecret(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
}): Promise<'continue' | 'back'> {
|
||||
note(
|
||||
[
|
||||
`1. In your app registration, open "Certificates & secrets"`,
|
||||
'2. Click "New client secret"',
|
||||
' Description: nanoclaw',
|
||||
' Expires: 180 days (recommended) or longer',
|
||||
'3. Click Add',
|
||||
'4. ' + k.yellow('COPY THE VALUE NOW — Azure only shows it once'),
|
||||
' (the Value column, not the Secret ID)',
|
||||
].join('\n'),
|
||||
'Step 2 of 6 — Create a client secret',
|
||||
);
|
||||
|
||||
while (true) {
|
||||
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';
|
||||
if (t.length < 20) return "That looks too short — make sure you copied the Value, not the Secret ID";
|
||||
return undefined;
|
||||
}),
|
||||
}),
|
||||
);
|
||||
if (isHelpEscape(answer)) {
|
||||
await offerHandoff({
|
||||
step: 'teams-client-secret',
|
||||
stepDescription: 'creating and copying the client secret value from Azure',
|
||||
args,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
args.collected.appPassword = (answer as string).trim();
|
||||
setupLog.userInput(
|
||||
'teams_client_secret',
|
||||
`${args.collected.appPassword.slice(0, 4)}…${args.collected.appPassword.slice(-4)}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
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 ──────────────────────────────────────────
|
||||
|
||||
async function stepAzureBot(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
}): Promise<'continue' | 'back'> {
|
||||
const endpoint = `${args.collected.publicUrl}/api/webhooks/teams`;
|
||||
const tenantFlag =
|
||||
args.collected.appType === 'SingleTenant'
|
||||
? `--tenant-id ${args.collected.tenantId} `
|
||||
: '';
|
||||
const cliCommand =
|
||||
`az bot create \\\n` +
|
||||
` --resource-group nanoclaw-rg \\\n` +
|
||||
` --name nanoclaw-bot \\\n` +
|
||||
` --app-type ${args.collected.appType} \\\n` +
|
||||
` --appid ${args.collected.appId} \\\n` +
|
||||
` ${tenantFlag}--endpoint "${endpoint}"`;
|
||||
|
||||
note(
|
||||
[
|
||||
`In ${AZURE_PORTAL_URL}, search "Azure Bot" → Create.`,
|
||||
'',
|
||||
' • Bot handle: unique name, e.g. nanoclaw-bot',
|
||||
` • Type of App: ${args.collected.appType}`,
|
||||
' • Creation type: Use existing app registration',
|
||||
` • App ID: ${args.collected.appId ?? '<pending>'}`,
|
||||
...(args.collected.appType === 'SingleTenant'
|
||||
? [` • App tenant ID: ${args.collected.tenantId ?? '<pending>'}`]
|
||||
: []),
|
||||
'',
|
||||
'After creating, open the bot → Configuration and set:',
|
||||
` Messaging endpoint: ${k.cyan(endpoint)}`,
|
||||
'',
|
||||
k.dim('Or via Azure CLI (if you have az installed):'),
|
||||
k.dim(cliCommand),
|
||||
].join('\n'),
|
||||
'Step 3 of 6 — Create Azure Bot resource',
|
||||
);
|
||||
|
||||
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 ────────────────────────────────────────
|
||||
|
||||
async function stepEnableTeamsChannel(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
}): Promise<'continue' | 'back'> {
|
||||
note(
|
||||
[
|
||||
'1. Open your Azure Bot resource → Channels',
|
||||
'2. Click Microsoft Teams → Accept terms → Apply',
|
||||
'',
|
||||
k.dim('CLI alternative:'),
|
||||
k.dim(' az bot msteams create --resource-group nanoclaw-rg --name nanoclaw-bot'),
|
||||
].join('\n'),
|
||||
'Step 4 of 6 — Enable Teams channel on the bot',
|
||||
);
|
||||
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 ────────────────────────────────────────────────
|
||||
|
||||
async function stepGenerateManifest(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
}): Promise<{ zipPath: string }> {
|
||||
if (!args.collected.appId) {
|
||||
fail(
|
||||
'teams-manifest',
|
||||
'Missing Azure App ID.',
|
||||
"That's an internal bug — open an issue or retry setup.",
|
||||
);
|
||||
}
|
||||
const shortName =
|
||||
process.env.NANOCLAW_AGENT_NAME?.trim() || 'NanoClaw';
|
||||
|
||||
const s = p.spinner();
|
||||
s.start('Generating your Teams app package…');
|
||||
try {
|
||||
const result = buildTeamsAppPackage({
|
||||
appId: args.collected.appId!,
|
||||
shortName,
|
||||
longDescription: `${shortName} personal assistant powered by NanoClaw.`,
|
||||
websiteUrl: args.collected.publicUrl!,
|
||||
outDir: MANIFEST_DIR,
|
||||
});
|
||||
s.stop(`Package ready: ${k.cyan(shortPath(result.zipPath))}`);
|
||||
setupLog.step('teams-manifest', 'success', 0, {
|
||||
ZIP: result.zipPath,
|
||||
});
|
||||
args.completed.push(`Generated manifest zip at ${shortPath(result.zipPath)}.`);
|
||||
return { zipPath: result.zipPath };
|
||||
} catch (err) {
|
||||
s.stop("Couldn't build the manifest zip.", 1);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setupLog.step('teams-manifest', 'failed', 0, { ERROR: message });
|
||||
fail(
|
||||
'teams-manifest',
|
||||
"Couldn't generate the Teams app package.",
|
||||
'Make sure `zip` is available on your PATH, then retry.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── step: sideload ────────────────────────────────────────────────────
|
||||
|
||||
async function stepSideload(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
zipPath: string;
|
||||
}): Promise<'continue' | 'back'> {
|
||||
note(
|
||||
[
|
||||
'1. Open Microsoft Teams',
|
||||
'2. Go to Apps → Manage your apps → Upload an app',
|
||||
'3. Click "Upload a custom app" (or "Upload for me or my teams")',
|
||||
`4. Select: ${k.cyan(args.zipPath)}`,
|
||||
'5. Click Add',
|
||||
'',
|
||||
k.dim('If "Upload a custom app" is missing, your tenant admin has'),
|
||||
k.dim('disabled sideloading. Enable it in Teams Admin Center →'),
|
||||
k.dim('Teams apps → Setup policies → Global → Upload custom apps = On'),
|
||||
].join('\n'),
|
||||
'Step 5 of 6 — Sideload the app into Teams',
|
||||
);
|
||||
const gate = await stepGate({
|
||||
stepName: 'teams-sideload',
|
||||
stepDescription: 'uploading the generated zip into Teams as a custom app',
|
||||
reshow: () => stepSideload({ ...args, zipPath: args.zipPath }),
|
||||
args,
|
||||
});
|
||||
if (gate === 'back') return 'back';
|
||||
args.completed.push('App sideloaded into Teams.');
|
||||
return 'continue';
|
||||
}
|
||||
|
||||
// ─── step: install adapter ─────────────────────────────────────────────
|
||||
|
||||
async function installAdapter(collected: Collected): Promise<void> {
|
||||
const env: Record<string, string> = {
|
||||
TEAMS_APP_ID: collected.appId!,
|
||||
TEAMS_APP_PASSWORD: collected.appPassword!,
|
||||
TEAMS_APP_TYPE: collected.appType!,
|
||||
};
|
||||
if (collected.appType === 'SingleTenant') {
|
||||
env.TEAMS_APP_TENANT_ID = collected.tenantId!;
|
||||
}
|
||||
|
||||
const install = await runQuietChild(
|
||||
'teams-install',
|
||||
'bash',
|
||||
['setup/add-teams.sh'],
|
||||
{
|
||||
running: 'Installing the Teams adapter and restarting the service…',
|
||||
done: 'Teams adapter installed.',
|
||||
},
|
||||
{
|
||||
env,
|
||||
extraFields: {
|
||||
APP_ID: collected.appId!,
|
||||
APP_TYPE: collected.appType!,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
fail(
|
||||
'teams-install',
|
||||
"Couldn't install the Teams adapter.",
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── post-install: hand off to Claude for the final wiring ────────────
|
||||
|
||||
async function finishWithHandoff(
|
||||
collected: Collected,
|
||||
completed: string[],
|
||||
): Promise<void> {
|
||||
note(
|
||||
[
|
||||
'The Teams adapter is live and the service is running.',
|
||||
'',
|
||||
"One thing left: your Teams bot's platform ID (which NanoClaw needs",
|
||||
'to wire to an agent group) only becomes known after you DM the bot',
|
||||
'for the first time. Claude can walk you through that interactively —',
|
||||
'watch the logs for your first inbound, find the auto-created',
|
||||
'messaging group in the DB, run scripts/init-first-agent.ts with',
|
||||
'the right flags, and verify end-to-end.',
|
||||
].join('\n'),
|
||||
'Step 6 of 6 — Finish wiring',
|
||||
);
|
||||
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect({
|
||||
message: 'Ready to finish?',
|
||||
options: [
|
||||
{
|
||||
value: 'handoff',
|
||||
label: 'Hand me off to Claude to walk me through it',
|
||||
hint: 'recommended',
|
||||
},
|
||||
{ value: 'self', label: "I'll do it myself" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
if (choice === 'self') {
|
||||
note(
|
||||
[
|
||||
' 1. Find your bot in Teams (search by name, or via the sideloaded',
|
||||
' app) and send it a message ("hi" is fine)',
|
||||
' 2. Tail ' + k.cyan('logs/nanoclaw.log') + ' for the inbound; the router',
|
||||
' auto-creates a row in ' + k.cyan('messaging_groups') + ' in data/v2.db',
|
||||
' 3. Run ' + k.cyan('scripts/init-first-agent.ts') + ' with --channel teams,',
|
||||
' the discovered platform_id, and your AAD user id, OR use',
|
||||
' ' + k.cyan('/manage-channels') + ' to wire interactively',
|
||||
].join('\n'),
|
||||
'Manual finish',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await offerClaudeHandoff({
|
||||
channel: CHANNEL,
|
||||
step: 'teams-finish-wiring',
|
||||
stepDescription:
|
||||
'finishing the Teams wiring: watch for the first inbound, discover the auto-created messaging group in data/v2.db, and run scripts/init-first-agent.ts to wire it to an agent group',
|
||||
completedSteps: completed,
|
||||
collectedValues: redactCollected(collected),
|
||||
files: [
|
||||
'scripts/init-first-agent.ts',
|
||||
'src/router.ts',
|
||||
'src/db/messaging-groups.ts',
|
||||
'logs/nanoclaw.log',
|
||||
'.claude/skills/manage-channels/SKILL.md',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ─── shared step gate ──────────────────────────────────────────────────
|
||||
|
||||
async function stepGate(args: {
|
||||
stepName: string;
|
||||
stepDescription: string;
|
||||
reshow: () => Promise<'continue' | 'back'>;
|
||||
args: { collected: Collected; completed: string[] };
|
||||
}): Promise<'continue' | 'back'> {
|
||||
while (true) {
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect({
|
||||
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 === 'done') return 'continue';
|
||||
if (choice === 'back') return 'back';
|
||||
if (choice === 'help') {
|
||||
await offerHandoff({
|
||||
step: args.stepName,
|
||||
stepDescription: args.stepDescription,
|
||||
args: args.args,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (choice === 'reshow') {
|
||||
return args.reshow();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function offerHandoff(args: {
|
||||
step: string;
|
||||
stepDescription: string;
|
||||
args: { collected: Collected; completed: string[] };
|
||||
}): Promise<void> {
|
||||
const ctx: HandoffContext = {
|
||||
channel: CHANNEL,
|
||||
step: args.step,
|
||||
stepDescription: args.stepDescription,
|
||||
completedSteps: args.args.completed.slice(),
|
||||
collectedValues: redactCollected(args.args.collected),
|
||||
files: ['setup/channels/teams.ts', 'setup/add-teams.sh'],
|
||||
};
|
||||
await offerClaudeHandoff(ctx);
|
||||
}
|
||||
|
||||
function redactCollected(c: Collected): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (c.publicUrl) out.publicUrl = c.publicUrl;
|
||||
if (c.appId) out.appId = c.appId;
|
||||
if (c.tenantId) out.tenantId = c.tenantId;
|
||||
if (c.appType) out.appType = c.appType;
|
||||
if (c.appPassword) {
|
||||
out.appPassword = `${c.appPassword.slice(0, 4)}…${c.appPassword.slice(-4)}`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── shared: UUID paste with help escape ───────────────────────────────
|
||||
|
||||
async function askUuid(
|
||||
message: string,
|
||||
logKey: string,
|
||||
args: { collected: Collected; completed: string[] },
|
||||
): Promise<string> {
|
||||
while (true) {
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message,
|
||||
placeholder: '00000000-0000-0000-0000-000000000000',
|
||||
validate: validateWithHelpEscape((v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Required';
|
||||
if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(t)) {
|
||||
return 'Expected a UUID like 00000000-0000-0000-0000-000000000000';
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
}),
|
||||
);
|
||||
if (isHelpEscape(answer)) {
|
||||
await offerHandoff({
|
||||
step: logKey,
|
||||
stepDescription: `entering a UUID for ${logKey}`,
|
||||
args,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const value = (answer as string).trim().toLowerCase();
|
||||
setupLog.userInput(logKey, value);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── path helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function shortPath(abs: string): string {
|
||||
const home = os.homedir();
|
||||
const cwd = process.cwd();
|
||||
if (abs.startsWith(`${cwd}/`)) return abs.slice(cwd.length + 1);
|
||||
if (abs.startsWith(`${home}/`)) return `~/${abs.slice(home.length + 1)}`;
|
||||
return abs;
|
||||
}
|
||||
|
||||
@@ -1,361 +0,0 @@
|
||||
/**
|
||||
* Telegram channel flow for setup:auto.
|
||||
*
|
||||
* `runTelegramChannel(displayName)` owns the full branch from the
|
||||
* BotFather instructions through the welcome DM:
|
||||
*
|
||||
* 1. BotFather instructions (clack note)
|
||||
* 2. Paste the bot token (clack password) — format-validated
|
||||
* 3. getMe via the Bot API to resolve the bot's username
|
||||
* 4. Confirm + deep-link into the bot's Telegram chat (tg://resolve)
|
||||
* 5. Install the adapter (setup/add-telegram.sh, non-interactive)
|
||||
* 6. Run the pair-telegram step, rendering code events as clack notes
|
||||
* 7. Ask for the messaging-agent name (defaulting to "Nano")
|
||||
* 8. Wire the agent via scripts/init-first-agent.ts
|
||||
*
|
||||
* All output obeys the three-level contract: clack UI for the user,
|
||||
* structured entries in logs/setup.log, full raw output in per-step files
|
||||
* under logs/setup-steps/. See docs/setup-flow.md.
|
||||
*/
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.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,
|
||||
type StepResult,
|
||||
dumpTranscriptOnFailure,
|
||||
ensureAnswer,
|
||||
fail,
|
||||
runQuietChild,
|
||||
spawnStep,
|
||||
writeStepEntry,
|
||||
} from '../lib/runner.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<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
|
||||
// by the time pair-telegram prints the code. https://t.me/<bot> works
|
||||
// everywhere: browsers show an "Open in Telegram" button when the app is
|
||||
// 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}`;
|
||||
// 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.`,
|
||||
'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',
|
||||
'bash',
|
||||
['setup/add-telegram.sh'],
|
||||
{
|
||||
running: `Connecting Telegram to @${botUsername}…`,
|
||||
done: 'Telegram connected.',
|
||||
},
|
||||
{
|
||||
env: { TELEGRAM_BOT_TOKEN: token },
|
||||
extraFields: { BOT_USERNAME: botUsername },
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
await fail(
|
||||
'telegram-install',
|
||||
"Couldn't connect Telegram.",
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
}
|
||||
|
||||
const pair = await runPairTelegram();
|
||||
if (!pair.ok) {
|
||||
await fail(
|
||||
'pair-telegram',
|
||||
"Couldn't pair with Telegram.",
|
||||
'Re-run setup to try again.',
|
||||
);
|
||||
}
|
||||
|
||||
const platformId = pair.terminal?.fields.PLATFORM_ID;
|
||||
const pairedUserId = pair.terminal?.fields.PAIRED_USER_ID;
|
||||
if (!platformId || !pairedUserId) {
|
||||
await fail(
|
||||
'pair-telegram',
|
||||
'Pairing completed but came back incomplete.',
|
||||
'Re-run setup to try again.',
|
||||
);
|
||||
}
|
||||
|
||||
const role = await askOperatorRole('Telegram');
|
||||
setupLog.userInput('telegram_role', role);
|
||||
|
||||
const agentName = await resolveAgentName();
|
||||
|
||||
const init = await runQuietChild(
|
||||
'init-first-agent',
|
||||
'pnpm',
|
||||
[
|
||||
'exec', 'tsx', 'scripts/init-first-agent.ts',
|
||||
'--channel', 'telegram',
|
||||
'--user-id', pairedUserId,
|
||||
'--platform-id', platformId,
|
||||
'--display-name', displayName,
|
||||
'--agent-name', agentName,
|
||||
'--role', role,
|
||||
],
|
||||
{
|
||||
running: `Connecting ${agentName} to your Telegram chat…`,
|
||||
done: `${agentName} is ready. Check Telegram for a welcome message.`,
|
||||
},
|
||||
{
|
||||
extraFields: { CHANNEL: 'telegram', AGENT_NAME: agentName, PLATFORM_ID: platformId },
|
||||
},
|
||||
);
|
||||
if (!init.ok) {
|
||||
await fail(
|
||||
'init-first-agent',
|
||||
`Couldn't finish connecting ${agentName}.`,
|
||||
'You can retry later with `/manage-channels`.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 — 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>)',
|
||||
'',
|
||||
k.dim('Planning to add your assistant to group chats? In @BotFather:'),
|
||||
k.dim(' /mybots → your bot → Bot Settings → Group Privacy → OFF'),
|
||||
].join('\n'),
|
||||
'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())) {
|
||||
return "That doesn't look right. It should be <digits>:<chars>";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const token = (answer as string).trim();
|
||||
setupLog.userInput(
|
||||
'telegram_token',
|
||||
`${token.slice(0, 12)}…${token.slice(-4)}`,
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
async function validateTelegramToken(token: string): Promise<string> {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
s.start('Checking your bot token…');
|
||||
try {
|
||||
const res = await fetch(`https://api.telegram.org/bot${token}/getMe`);
|
||||
const data = (await res.json()) as {
|
||||
ok?: boolean;
|
||||
result?: { username?: string; id?: number };
|
||||
description?: string;
|
||||
};
|
||||
if (data.ok && data.result?.username) {
|
||||
const username = data.result.username;
|
||||
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 ?? '',
|
||||
});
|
||||
return username;
|
||||
}
|
||||
const reason = data.description ?? 'token rejected by Telegram';
|
||||
s.stop(`Telegram didn't accept that token: ${reason}`, 1);
|
||||
setupLog.step('telegram-validate', 'failed', Date.now() - start, {
|
||||
ERROR: reason,
|
||||
});
|
||||
await fail(
|
||||
'telegram-validate',
|
||||
"Telegram didn't accept that token.",
|
||||
'Copy the token again from @BotFather and try setup once more.',
|
||||
);
|
||||
} catch (err) {
|
||||
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,
|
||||
});
|
||||
await fail(
|
||||
'telegram-validate',
|
||||
"Couldn't reach Telegram.",
|
||||
'Check your internet connection and retry setup.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function runPairTelegram(): Promise<
|
||||
StepResult & { rawLog: string; durationMs: number }
|
||||
> {
|
||||
const rawLog = setupLog.stepRawLog('pair-telegram');
|
||||
const start = Date.now();
|
||||
const s = p.spinner();
|
||||
s.start('Generating a secret code for your bot…');
|
||||
let spinnerActive = true;
|
||||
|
||||
const stopSpinner = (msg: string, code?: number) => {
|
||||
if (spinnerActive) {
|
||||
s.stop(msg, code);
|
||||
spinnerActive = false;
|
||||
}
|
||||
};
|
||||
|
||||
const result = await spawnStep(
|
||||
'pair-telegram',
|
||||
['--intent', 'main'],
|
||||
(block: Block) => {
|
||||
if (block.type === 'PAIR_TELEGRAM_CODE') {
|
||||
const reason = block.fields.REASON ?? 'initial';
|
||||
if (reason === 'initial') {
|
||||
stopSpinner('Your secret code is ready.');
|
||||
} else {
|
||||
stopSpinner("Old code expired. Here's a fresh one.");
|
||||
}
|
||||
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(fitToWidth('Waiting for the correct code…', ''));
|
||||
spinnerActive = true;
|
||||
} else if (block.type === 'PAIR_TELEGRAM') {
|
||||
if (block.fields.STATUS === 'success') {
|
||||
stopSpinner('Telegram paired.');
|
||||
} else {
|
||||
stopSpinner(`Pairing failed: ${block.fields.ERROR ?? 'unknown'}`, 1);
|
||||
}
|
||||
}
|
||||
},
|
||||
rawLog,
|
||||
);
|
||||
const durationMs = Date.now() - start;
|
||||
|
||||
// Safety net: if the child died without emitting a terminal block, make
|
||||
// sure we don't leave the spinner running.
|
||||
if (spinnerActive) {
|
||||
stopSpinner(
|
||||
result.ok ? 'Done.' : 'Pairing ended unexpectedly.',
|
||||
result.ok ? 0 : 1,
|
||||
);
|
||||
if (!result.ok) dumpTranscriptOnFailure(result.transcript);
|
||||
}
|
||||
|
||||
writeStepEntry('pair-telegram', result, durationMs, rawLog);
|
||||
return { ...result, rawLog, durationMs };
|
||||
}
|
||||
|
||||
function formatCodeCard(code: string): string {
|
||||
const spaced = code.split('').join(' ');
|
||||
return [
|
||||
'',
|
||||
` ${brandBold(spaced)}`,
|
||||
'',
|
||||
k.dim(' Send this code to your bot from Telegram.'),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,478 +0,0 @@
|
||||
/**
|
||||
* WhatsApp (community/Baileys) channel flow for setup:auto.
|
||||
*
|
||||
* `runWhatsAppChannel(displayName)` owns the full branch from auth-method
|
||||
* picker through the welcome DM:
|
||||
*
|
||||
* 1. Ask how to authenticate (QR code in terminal, default, or pairing code)
|
||||
* 2. If pairing-code: collect the phone number
|
||||
* 3. Install the adapter + Baileys + QR + pino via setup/add-whatsapp.sh
|
||||
* 4. Run the whatsapp-auth step, rendering status blocks as clack UI:
|
||||
* - WHATSAPP_AUTH_QR (repeating): render the QR as terminal block art
|
||||
* inside a clack note. On rotation we clear the previous QR in-place
|
||||
* via ANSI escapes so the terminal doesn't fill up with stale codes.
|
||||
* - WHATSAPP_AUTH_PAIRING_CODE (one-shot): centred code card.
|
||||
* 5. Read store/auth/creds.json → extract the authenticated (bot) phone
|
||||
* 6. Kick the service so the adapter picks up the new credentials
|
||||
* 7. Ask the operator for the phone they'll chat from (defaults to the
|
||||
* authed number). Different number ⇒ dedicated mode ⇒ also writes
|
||||
* ASSISTANT_HAS_OWN_NUMBER=true so outbound replies aren't prefixed
|
||||
* 8. Ask for the messaging-agent name (defaulting to "Nano")
|
||||
* 9. Wire the agent via scripts/init-first-agent.ts; the existing welcome
|
||||
* DM path delivers the greeting through the adapter
|
||||
*
|
||||
* All output obeys the three-level contract: clack UI for the user, structured
|
||||
* entries in logs/setup.log, full raw output in per-step files under
|
||||
* logs/setup-steps/. See docs/setup-flow.md.
|
||||
*/
|
||||
import { spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
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 {
|
||||
type Block,
|
||||
type StepResult,
|
||||
dumpTranscriptOnFailure,
|
||||
ensureAnswer,
|
||||
fail,
|
||||
runQuietChild,
|
||||
spawnStep,
|
||||
writeStepEntry,
|
||||
} from '../lib/runner.js';
|
||||
import { askOperatorRole } from '../lib/role-prompt.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<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(
|
||||
'whatsapp-install',
|
||||
'bash',
|
||||
['setup/add-whatsapp.sh'],
|
||||
{
|
||||
running: 'Installing the WhatsApp adapter…',
|
||||
done: 'WhatsApp adapter installed.',
|
||||
skipped: 'WhatsApp adapter already installed.',
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
fail(
|
||||
'whatsapp-install',
|
||||
"Couldn't install the WhatsApp adapter.",
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
}
|
||||
|
||||
const auth = await runWhatsAppAuth(method, phone);
|
||||
if (!auth.ok) {
|
||||
const reason = auth.terminal?.fields.ERROR ?? 'unknown';
|
||||
fail(
|
||||
'whatsapp-auth',
|
||||
`WhatsApp authentication failed (${reason}).`,
|
||||
reason === 'qr_timeout' || reason === 'timeout'
|
||||
? 'The code expired. Re-run setup to get a fresh one.'
|
||||
: 'Re-run setup to try again.',
|
||||
);
|
||||
}
|
||||
|
||||
const botPhone = readAuthedPhone();
|
||||
if (!botPhone) {
|
||||
fail(
|
||||
'whatsapp-auth',
|
||||
"Authenticated but couldn't read your WhatsApp number from the saved credentials.",
|
||||
'Re-run setup to try again.',
|
||||
);
|
||||
}
|
||||
|
||||
await restartService();
|
||||
|
||||
const chatPhone = await askChatPhone(botPhone);
|
||||
const isDedicated = chatPhone !== botPhone;
|
||||
if (isDedicated) {
|
||||
writeAssistantHasOwnNumber();
|
||||
}
|
||||
|
||||
const role = await askOperatorRole('WhatsApp');
|
||||
setupLog.userInput('whatsapp_role', role);
|
||||
|
||||
const agentName = await resolveAgentName();
|
||||
|
||||
const platformId = `${chatPhone}@s.whatsapp.net`;
|
||||
|
||||
const init = await runQuietChild(
|
||||
'init-first-agent',
|
||||
'pnpm',
|
||||
[
|
||||
'exec', 'tsx', 'scripts/init-first-agent.ts',
|
||||
'--channel', 'whatsapp',
|
||||
'--user-id', platformId,
|
||||
'--platform-id', platformId,
|
||||
'--display-name', displayName,
|
||||
'--agent-name', agentName,
|
||||
'--role', role,
|
||||
],
|
||||
{
|
||||
running: `Connecting ${agentName} to WhatsApp…`,
|
||||
done: isDedicated
|
||||
? `${agentName} is ready. Check WhatsApp for a welcome message.`
|
||||
: `${agentName} is ready. Look in your "You" chat on WhatsApp for the welcome.`,
|
||||
},
|
||||
{
|
||||
extraFields: {
|
||||
CHANNEL: 'whatsapp',
|
||||
AGENT_NAME: agentName,
|
||||
PLATFORM_ID: platformId,
|
||||
MODE: isDedicated ? 'dedicated' : 'shared',
|
||||
ROLE: role,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!init.ok) {
|
||||
fail(
|
||||
'init-first-agent',
|
||||
`Couldn't finish connecting ${agentName}.`,
|
||||
'You can retry later with `/manage-channels`.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function askAuthMethod(): Promise<AuthMethod | 'back'> {
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect({
|
||||
message: 'How would you like to authenticate with WhatsApp?',
|
||||
options: [
|
||||
{
|
||||
value: 'qr',
|
||||
label: 'Scan a QR code in this terminal',
|
||||
hint: 'recommended',
|
||||
},
|
||||
{
|
||||
value: 'pairing-code',
|
||||
label: 'Enter a pairing code on your phone',
|
||||
hint: 'no camera needed',
|
||||
},
|
||||
{
|
||||
value: 'back',
|
||||
label: '← Back to channel selection',
|
||||
},
|
||||
],
|
||||
}),
|
||||
) as AuthMethod | 'back';
|
||||
if (choice !== 'back') setupLog.userInput('whatsapp_auth_method', choice);
|
||||
return choice;
|
||||
}
|
||||
|
||||
async function askPhoneNumber(): Promise<string> {
|
||||
note(
|
||||
[
|
||||
"Enter your phone number the way WhatsApp expects it:",
|
||||
'',
|
||||
' • Digits only — no +, spaces, or dashes',
|
||||
' • Country code first, then the rest of the number',
|
||||
'',
|
||||
k.dim('Example: 14155551234 (country code 1, then 4155551234)'),
|
||||
].join('\n'),
|
||||
'Your phone number',
|
||||
);
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'Phone number',
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Phone number is required';
|
||||
if (!/^\d{8,15}$/.test(t)) {
|
||||
return "That doesn't look right. Digits only, country code included.";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const phone = (answer as string).trim();
|
||||
setupLog.userInput('whatsapp_phone', phone);
|
||||
return phone;
|
||||
}
|
||||
|
||||
async function runWhatsAppAuth(
|
||||
method: AuthMethod,
|
||||
phone: string | undefined,
|
||||
): Promise<StepResult & { rawLog: string; durationMs: number }> {
|
||||
const rawLog = setupLog.stepRawLog('whatsapp-auth');
|
||||
const start = Date.now();
|
||||
const s = p.spinner();
|
||||
s.start('Starting WhatsApp authentication…');
|
||||
let spinnerActive = true;
|
||||
|
||||
const stopSpinner = (msg: string, code?: number) => {
|
||||
if (spinnerActive) {
|
||||
s.stop(msg, code);
|
||||
spinnerActive = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Tracks the QR render so we can overwrite it in-place on rotation. null
|
||||
// before the first QR is printed.
|
||||
let qrLinesPrinted = 0;
|
||||
|
||||
const extra =
|
||||
method === 'pairing-code' && phone
|
||||
? ['--method', 'pairing-code', '--phone', phone]
|
||||
: ['--method', 'qr'];
|
||||
|
||||
const result = await spawnStep(
|
||||
'whatsapp-auth',
|
||||
extra,
|
||||
(block: Block) => {
|
||||
if (block.type === 'WHATSAPP_AUTH_QR') {
|
||||
const qr = block.fields.QR ?? '';
|
||||
if (!qr) return;
|
||||
// Fire-and-forget — await inside spawnStep's sync onBlock is fine
|
||||
// since spawnStep's own logic keeps running in parallel.
|
||||
void renderQr(qr).then((lines) => {
|
||||
if (qrLinesPrinted === 0) {
|
||||
stopSpinner('QR code ready — scan with WhatsApp.');
|
||||
} else {
|
||||
// Cursor up N lines + clear from there to end of screen. Wipes
|
||||
// the previous QR + caption so the new one renders in place.
|
||||
process.stdout.write(`\x1b[${qrLinesPrinted}A\x1b[0J`);
|
||||
}
|
||||
process.stdout.write(lines.join('\n') + '\n');
|
||||
qrLinesPrinted = lines.length;
|
||||
});
|
||||
} else if (block.type === 'WHATSAPP_AUTH_PAIRING_CODE') {
|
||||
const code = block.fields.CODE ?? '????';
|
||||
stopSpinner('Your pairing code is ready.');
|
||||
note(formatPairingCard(code), 'Pairing code');
|
||||
s.start('Waiting for you to enter the code…');
|
||||
spinnerActive = true;
|
||||
} else if (block.type === 'WHATSAPP_AUTH') {
|
||||
const status = block.fields.STATUS;
|
||||
if (status === 'skipped') {
|
||||
stopSpinner('WhatsApp is already authenticated.');
|
||||
} else if (status === 'success') {
|
||||
// Erase the QR block if one was on screen — it's served its purpose.
|
||||
if (qrLinesPrinted > 0) {
|
||||
process.stdout.write(`\x1b[${qrLinesPrinted}A\x1b[0J`);
|
||||
qrLinesPrinted = 0;
|
||||
}
|
||||
// In QR flow the spinner was stopped when the first QR landed.
|
||||
// Fall back to a plain success line so the user sees confirmation.
|
||||
if (spinnerActive) {
|
||||
stopSpinner('WhatsApp linked.');
|
||||
} else {
|
||||
p.log.success(brandBody('WhatsApp linked.'));
|
||||
}
|
||||
} else if (status === 'failed') {
|
||||
if (qrLinesPrinted > 0) {
|
||||
process.stdout.write(`\x1b[${qrLinesPrinted}A\x1b[0J`);
|
||||
qrLinesPrinted = 0;
|
||||
}
|
||||
const err = block.fields.ERROR ?? 'unknown';
|
||||
if (spinnerActive) {
|
||||
stopSpinner(`Authentication failed: ${err}`, 1);
|
||||
} else {
|
||||
p.log.error(`Authentication failed: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
rawLog,
|
||||
);
|
||||
const durationMs = Date.now() - start;
|
||||
|
||||
// Safety net — if the step died without emitting a terminal block, don't
|
||||
// leave the spinner running.
|
||||
if (spinnerActive) {
|
||||
stopSpinner(
|
||||
result.ok ? 'Done.' : 'Authentication ended unexpectedly.',
|
||||
result.ok ? 0 : 1,
|
||||
);
|
||||
if (!result.ok) dumpTranscriptOnFailure(result.transcript);
|
||||
}
|
||||
|
||||
writeStepEntry('whatsapp-auth', result, durationMs, rawLog);
|
||||
return { ...result, rawLog, durationMs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the raw QR string to an array of terminal lines (block-art QR +
|
||||
* a caption). Returned as an array so the caller can count lines for the
|
||||
* in-place rewrite on rotation. Uses the small-mode QR to keep the height
|
||||
* manageable on 24-row terminals.
|
||||
*/
|
||||
async function renderQr(qr: string): Promise<string[]> {
|
||||
try {
|
||||
const QRCode = await import('qrcode');
|
||||
const qrText = await QRCode.toString(qr, { type: 'terminal', small: true });
|
||||
const caption = k.dim(
|
||||
' Open WhatsApp → You / Settings → Linked Devices → Link a Device → scan.',
|
||||
);
|
||||
return [...qrText.trimEnd().split('\n'), '', caption];
|
||||
} catch {
|
||||
return ['QR code (raw): ' + qr];
|
||||
}
|
||||
}
|
||||
|
||||
function formatPairingCard(code: string): string {
|
||||
// WhatsApp pairing codes are 8 characters; render with two-wide gap so the
|
||||
// digits read clearly in the terminal.
|
||||
const spaced = code.split('').join(' ');
|
||||
return [
|
||||
'',
|
||||
` ${brandBold(spaced)}`,
|
||||
'',
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the authenticated WhatsApp phone out of store/auth/creds.json.
|
||||
* `creds.me.id` looks like `14155551234:<device>@s.whatsapp.net` — we want
|
||||
* just the leading digit run.
|
||||
*/
|
||||
function readAuthedPhone(): string {
|
||||
try {
|
||||
const raw = fs.readFileSync(AUTH_CREDS_PATH, 'utf-8');
|
||||
const creds = JSON.parse(raw) as { me?: { id?: string } };
|
||||
const id = creds.me?.id;
|
||||
if (!id) return '';
|
||||
return id.split(':')[0].split('@')[0];
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async function restartService(): Promise<void> {
|
||||
const s = p.spinner();
|
||||
s.start('Restarting NanoClaw so it sees your WhatsApp credentials…');
|
||||
const start = Date.now();
|
||||
const platform = process.platform;
|
||||
try {
|
||||
if (platform === 'darwin') {
|
||||
spawnSync(
|
||||
'launchctl',
|
||||
['kickstart', '-k', `gui/${process.getuid?.() ?? 501}/${getLaunchdLabel()}`],
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
} else if (platform === 'linux') {
|
||||
const unit = getSystemdUnit();
|
||||
const user = spawnSync(
|
||||
'systemctl',
|
||||
['--user', 'restart', unit],
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
if (user.status !== 0) {
|
||||
spawnSync('sudo', ['systemctl', 'restart', unit], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
}
|
||||
}
|
||||
// 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));
|
||||
s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||
setupLog.step('whatsapp-restart', 'success', Date.now() - start, {
|
||||
PLATFORM: platform,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
s.stop(`Restart may have failed: ${message}`, 1);
|
||||
setupLog.step('whatsapp-restart', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
});
|
||||
// Non-fatal — the user can restart manually if init-first-agent fails.
|
||||
}
|
||||
}
|
||||
|
||||
async function askChatPhone(authedPhone: string): Promise<string> {
|
||||
note(
|
||||
[
|
||||
`Authenticated with ${k.cyan('+' + authedPhone)}.`,
|
||||
'',
|
||||
"What's the phone number you'll chat with your agent from?",
|
||||
'',
|
||||
k.dim(
|
||||
'Same number = messages will land in your "You" / self-chat on WhatsApp\n' +
|
||||
"(you won't be able to reply to yourself — use a different number for a\n" +
|
||||
'two-way chat).',
|
||||
),
|
||||
].join('\n'),
|
||||
'Your chat number',
|
||||
);
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'Your personal phone number',
|
||||
placeholder: authedPhone,
|
||||
defaultValue: authedPhone,
|
||||
validate: (v) => {
|
||||
const t = (v ?? authedPhone).trim();
|
||||
if (!/^\d{8,15}$/.test(t)) {
|
||||
return 'Digits only, country code included.';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const phone = ((answer as string) || authedPhone).trim();
|
||||
setupLog.userInput('whatsapp_chat_phone', phone);
|
||||
return phone;
|
||||
}
|
||||
|
||||
/** Persist ASSISTANT_HAS_OWN_NUMBER=true to .env and data/env/env. */
|
||||
function writeAssistantHasOwnNumber(): void {
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
let contents = '';
|
||||
try {
|
||||
contents = fs.readFileSync(envPath, 'utf-8');
|
||||
} catch {
|
||||
contents = '';
|
||||
}
|
||||
if (/^ASSISTANT_HAS_OWN_NUMBER=/m.test(contents)) {
|
||||
contents = contents.replace(
|
||||
/^ASSISTANT_HAS_OWN_NUMBER=.*$/m,
|
||||
'ASSISTANT_HAS_OWN_NUMBER=true',
|
||||
);
|
||||
} else {
|
||||
if (contents.length > 0 && !contents.endsWith('\n')) contents += '\n';
|
||||
contents += 'ASSISTANT_HAS_OWN_NUMBER=true\n';
|
||||
}
|
||||
fs.writeFileSync(envPath, contents);
|
||||
|
||||
// Container reads from data/env/env.
|
||||
const containerEnvDir = path.join(process.cwd(), 'data', 'env');
|
||||
fs.mkdirSync(containerEnvDir, { recursive: true });
|
||||
fs.copyFileSync(envPath, path.join(containerEnvDir, 'env'));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -25,6 +25,8 @@ const STEPS: Record<
|
||||
auth: () => import('./auth.js'),
|
||||
'provider-auth': () => import('./provider-auth.js'),
|
||||
'cli-agent': () => import('./cli-agent.js'),
|
||||
// >>> nanoclaw:setup-steps
|
||||
// <<< nanoclaw:setup-steps
|
||||
};
|
||||
|
||||
async function main(): Promise<void> {
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-discord — bundles the preflight + install commands
|
||||
# from the /add-discord skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Discord adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned @chat-adapter/discord package;
|
||||
# builds. All steps are safe to re-run.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_DISCORD ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/discord.ts ]] || needs_install=true
|
||||
grep -q "import './discord.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@chat-adapter/discord"' package.json || needs_install=true
|
||||
[[ -d node_modules/@chat-adapter/discord ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/discord.ts > src/channels/discord.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './discord.js';" src/channels/index.ts; then
|
||||
printf "import './discord.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/discord@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-gchat — bundles the preflight + install commands
|
||||
# from the /add-gchat skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Google Chat adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned @chat-adapter/gchat package;
|
||||
# builds. All steps are safe to re-run.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_GCHAT ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/gchat.ts ]] || needs_install=true
|
||||
grep -q "import './gchat.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@chat-adapter/gchat"' package.json || needs_install=true
|
||||
[[ -d node_modules/@chat-adapter/gchat ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/gchat.ts > src/channels/gchat.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './gchat.js';" src/channels/index.ts; then
|
||||
printf "import './gchat.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/gchat@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
@@ -1,47 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-imessage — bundles the preflight + install commands
|
||||
# from the /add-imessage skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the iMessage adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned chat-adapter-imessage package;
|
||||
# builds. Local vs remote mode pick stays in the skill — this script only
|
||||
# handles the deterministic install. All steps are safe to re-run.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_IMESSAGE ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/imessage.ts ]] || needs_install=true
|
||||
grep -q "import './imessage.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"chat-adapter-imessage"' package.json || needs_install=true
|
||||
[[ -d node_modules/chat-adapter-imessage ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/imessage.ts > src/channels/imessage.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './imessage.js';" src/channels/index.ts; then
|
||||
printf "import './imessage.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install chat-adapter-imessage@0.1.1
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
@@ -1,95 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-linear — bundles the preflight + install commands
|
||||
# from the /add-linear skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Linear adapter in from the `channels` branch; appends the
|
||||
# self-registration import; patches src/channels/chat-sdk-bridge.ts to add
|
||||
# catch-all forwarding (Linear OAuth apps can't be @-mentioned, so the
|
||||
# onNewMention handler never fires — the bridge needs a catchAll path);
|
||||
# installs the pinned @chat-adapter/linear package; builds. All steps are
|
||||
# safe to re-run.
|
||||
#
|
||||
# Note: the bridge patch's onNewMessage handler passes `false` for isMention
|
||||
# (current trunk signature requires the arg). The /add-linear SKILL's
|
||||
# snippet omits the arg — this script uses the full signature so TypeScript
|
||||
# builds cleanly.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_LINEAR ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/linear.ts ]] || needs_install=true
|
||||
grep -q "import './linear.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@chat-adapter/linear"' package.json || needs_install=true
|
||||
[[ -d node_modules/@chat-adapter/linear ]] || needs_install=true
|
||||
grep -q 'catchAll' src/channels/chat-sdk-bridge.ts || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/linear.ts > src/channels/linear.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './linear.js';" src/channels/index.ts; then
|
||||
printf "import './linear.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: patch-bridge-catchall-field"
|
||||
if ! grep -q 'catchAll?: boolean;' src/channels/chat-sdk-bridge.ts; then
|
||||
awk '
|
||||
/^export interface ChatSdkBridgeConfig \{/ { in_iface = 1 }
|
||||
in_iface && /^\}/ && !inserted {
|
||||
print " /**"
|
||||
print " * Forward ALL messages in unsubscribed threads, not just @-mentions."
|
||||
print " * Use for platforms where the bot identity can'\''t be @-mentioned (e.g."
|
||||
print " * Linear OAuth apps). The thread is auto-subscribed on first message."
|
||||
print " */"
|
||||
print " catchAll?: boolean;"
|
||||
inserted = 1
|
||||
in_iface = 0
|
||||
}
|
||||
{ print }
|
||||
' src/channels/chat-sdk-bridge.ts > src/channels/chat-sdk-bridge.ts.tmp \
|
||||
&& mv src/channels/chat-sdk-bridge.ts.tmp src/channels/chat-sdk-bridge.ts
|
||||
fi
|
||||
|
||||
echo "STEP: patch-bridge-catchall-handler"
|
||||
if ! grep -q 'if (config.catchAll) {' src/channels/chat-sdk-bridge.ts; then
|
||||
awk '
|
||||
/ \/\/ DMs — apply engage rules too/ && !inserted {
|
||||
print " // Catch-all for platforms where @-mention isn'\''t possible (e.g. Linear"
|
||||
print " // OAuth apps). Forward every unsubscribed message and auto-subscribe."
|
||||
print " if (config.catchAll) {"
|
||||
print " chat.onNewMessage(/.*/, async (thread, message) => {"
|
||||
print " const channelId = adapter.channelIdFromThreadId(thread.id);"
|
||||
print " await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false));"
|
||||
print " await thread.subscribe();"
|
||||
print " });"
|
||||
print " }"
|
||||
print ""
|
||||
inserted = 1
|
||||
}
|
||||
{ print }
|
||||
' src/channels/chat-sdk-bridge.ts > src/channels/chat-sdk-bridge.ts.tmp \
|
||||
&& mv src/channels/chat-sdk-bridge.ts.tmp src/channels/chat-sdk-bridge.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/linear@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
@@ -1,62 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-matrix — bundles the preflight + install commands
|
||||
# from the /add-matrix skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Matrix adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned @beeper/chat-adapter-matrix
|
||||
# package; patches the adapter's published dist so its matrix-js-sdk/lib
|
||||
# imports carry .js extensions (required under Node 22 strict ESM); builds.
|
||||
# All steps are safe to re-run — re-run this script after any pnpm install
|
||||
# that touches the adapter.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_MATRIX ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/matrix.ts ]] || needs_install=true
|
||||
grep -q "import './matrix.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@beeper/chat-adapter-matrix"' package.json || needs_install=true
|
||||
[[ -d node_modules/@beeper/chat-adapter-matrix ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/matrix.ts > src/channels/matrix.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './matrix.js';" src/channels/index.ts; then
|
||||
printf "import './matrix.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @beeper/chat-adapter-matrix@0.2.0
|
||||
|
||||
echo "STEP: patch-esm-extensions"
|
||||
node -e '
|
||||
const fs = require("fs"), path = require("path");
|
||||
const root = "node_modules/.pnpm";
|
||||
const dir = fs.readdirSync(root).find(d => d.startsWith("@beeper+chat-adapter-matrix@"));
|
||||
if (!dir) { console.log("Matrix adapter not installed"); process.exit(0); }
|
||||
const f = path.join(root, dir, "node_modules/@beeper/chat-adapter-matrix/dist/index.js");
|
||||
fs.writeFileSync(f, fs.readFileSync(f, "utf8").replace(
|
||||
/from "(matrix-js-sdk\/lib\/[^"]+?)(?<!\.js)"/g, "from \"$1.js\""
|
||||
));
|
||||
console.log("Patched", f);
|
||||
'
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-resend — bundles the preflight + install commands
|
||||
# from the /add-resend skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Resend adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned @resend/chat-sdk-adapter
|
||||
# package; builds. All steps are safe to re-run.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_RESEND ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/resend.ts ]] || needs_install=true
|
||||
grep -q "import './resend.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@resend/chat-sdk-adapter"' package.json || needs_install=true
|
||||
[[ -d node_modules/@resend/chat-sdk-adapter ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/resend.ts > src/channels/resend.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './resend.js';" src/channels/index.ts; then
|
||||
printf "import './resend.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @resend/chat-sdk-adapter@0.1.1
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-slack — bundles the preflight + install commands
|
||||
# from the /add-slack skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Slack adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned @chat-adapter/slack package;
|
||||
# builds. All steps are safe to re-run.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_SLACK ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/slack.ts ]] || needs_install=true
|
||||
grep -q "import './slack.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@chat-adapter/slack"' package.json || needs_install=true
|
||||
[[ -d node_modules/@chat-adapter/slack ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/slack.ts > src/channels/slack.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './slack.js';" src/channels/index.ts; then
|
||||
printf "import './slack.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/slack@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-teams — bundles the preflight + install commands
|
||||
# from the /add-teams skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Teams adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned @chat-adapter/teams package;
|
||||
# builds. All steps are safe to re-run.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_TEAMS ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/teams.ts ]] || needs_install=true
|
||||
grep -q "import './teams.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@chat-adapter/teams"' package.json || needs_install=true
|
||||
[[ -d node_modules/@chat-adapter/teams ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/teams.ts > src/channels/teams.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './teams.js';" src/channels/index.ts; then
|
||||
printf "import './teams.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/teams@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
@@ -1,72 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-telegram — bundles the preflight + install commands
|
||||
# from the /add-telegram skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials and pairing.
|
||||
#
|
||||
# Copies the Telegram adapter, helpers, tests, and the pair-telegram setup
|
||||
# step in from the `channels` branch; appends the self-registration import;
|
||||
# registers the `pair-telegram` entry in the setup STEPS map; installs the
|
||||
# pinned @chat-adapter/telegram package; builds. All steps are safe to re-run.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_TELEGRAM ==="
|
||||
|
||||
CHANNEL_FILES=(
|
||||
src/channels/telegram.ts
|
||||
src/channels/telegram-pairing.ts
|
||||
src/channels/telegram-pairing.test.ts
|
||||
src/channels/telegram-markdown-sanitize.ts
|
||||
src/channels/telegram-markdown-sanitize.test.ts
|
||||
setup/pair-telegram.ts
|
||||
)
|
||||
|
||||
needs_install=false
|
||||
for f in "${CHANNEL_FILES[@]}"; do
|
||||
[[ -f "$f" ]] || needs_install=true
|
||||
done
|
||||
grep -q "import './telegram.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q "'pair-telegram':" setup/index.ts || needs_install=true
|
||||
grep -q '"@chat-adapter/telegram"' package.json || needs_install=true
|
||||
[[ -d node_modules/@chat-adapter/telegram ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
for f in "${CHANNEL_FILES[@]}"; do
|
||||
git show "origin/channels:$f" > "$f"
|
||||
done
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './telegram.js';" src/channels/index.ts; then
|
||||
printf "import './telegram.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: register-setup-step"
|
||||
if ! grep -q "'pair-telegram':" setup/index.ts; then
|
||||
awk '
|
||||
{ print }
|
||||
/register: \(\) => import/ && !inserted {
|
||||
print " '\''pair-telegram'\'': () => import('\''./pair-telegram.js'\''),"
|
||||
inserted = 1
|
||||
}
|
||||
' setup/index.ts > setup/index.ts.tmp && mv setup/index.ts.tmp setup/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/telegram@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-webex — bundles the preflight + install commands
|
||||
# from the /add-webex skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Webex adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned @bitbasti/chat-adapter-webex
|
||||
# package; builds. All steps are safe to re-run.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_WEBEX ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/webex.ts ]] || needs_install=true
|
||||
grep -q "import './webex.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@bitbasti/chat-adapter-webex"' package.json || needs_install=true
|
||||
[[ -d node_modules/@bitbasti/chat-adapter-webex ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/webex.ts > src/channels/webex.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './webex.js';" src/channels/index.ts; then
|
||||
printf "import './webex.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @bitbasti/chat-adapter-webex@0.1.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
@@ -12,6 +12,34 @@
|
||||
* (subsequent steps may have side effects like opening browsers, hitting
|
||||
* APIs, or installing adapter packages, none of which are easily undone).
|
||||
*/
|
||||
import { brightSelect } from './bright-select.js';
|
||||
import { ensureAnswer } from './runner.js';
|
||||
|
||||
export const BACK_TO_CHANNEL_SELECTION = Symbol('BACK_TO_CHANNEL_SELECTION');
|
||||
|
||||
export type ChannelFlowResult = void | typeof BACK_TO_CHANNEL_SELECTION;
|
||||
|
||||
/**
|
||||
* The shared first-prompt back gate. Rendered as the very first interactive
|
||||
* prompt of a channel sub-flow (before any side effect), it lets the operator
|
||||
* either commit to connecting `label` or bounce straight back to the channel
|
||||
* chooser. Returns the existing `BACK_TO_CHANNEL_SELECTION` sentinel on back —
|
||||
* which the `setup/auto.ts` channel loop already catches — and `'continue'`
|
||||
* otherwise. Esc / Ctrl-C unwinds through `ensureAnswer` (exit 0), the same as
|
||||
* every other setup prompt.
|
||||
*/
|
||||
export async function backGate(
|
||||
label: string,
|
||||
): Promise<'continue' | typeof BACK_TO_CHANNEL_SELECTION> {
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect<'continue' | 'back'>({
|
||||
message: `Connect ${label}?`,
|
||||
initialValue: 'continue',
|
||||
options: [
|
||||
{ value: 'continue', label: `Yes, connect ${label}` },
|
||||
{ value: 'back', label: '← Back to channel selection' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
return choice === 'back' ? BACK_TO_CHANNEL_SELECTION : 'continue';
|
||||
}
|
||||
|
||||
@@ -20,10 +20,14 @@ resolve_channels_remote() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Anchor the repo-name tail: the pattern must match the nanoclaw repo itself,
|
||||
# never a sibling channel repo like qwibitai/nanoclaw-discord (which carries
|
||||
# no channels branch — an unanchored `*nanoclaw*` glob picked those on
|
||||
# multi-remote machines and broke every from-branch copy).
|
||||
local remote url
|
||||
while IFS=$'\t' read -r remote url; do
|
||||
case "$url" in
|
||||
*qwibitai/nanoclaw*|*nanocoai/nanoclaw*)
|
||||
*qwibitai/nanoclaw|*qwibitai/nanoclaw.git|*nanocoai/nanoclaw|*nanocoai/nanoclaw.git)
|
||||
printf '%s' "$remote"
|
||||
return 0
|
||||
;;
|
||||
|
||||
@@ -65,15 +65,15 @@ export const STEP_FILES: Record<string, string[]> = {
|
||||
channel: ['setup/auto.ts'],
|
||||
verify: ['setup/verify.ts'],
|
||||
// Channel-specific sub-steps:
|
||||
'telegram-install': ['setup/add-telegram.sh', 'setup/channels/telegram.ts'],
|
||||
'telegram-install': ['.claude/skills/add-telegram/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/telegram.ts'],
|
||||
'telegram-validate': ['setup/channels/telegram.ts'],
|
||||
'pair-telegram': ['setup/pair-telegram.ts', 'setup/channels/telegram.ts'],
|
||||
'discord-install': ['setup/add-discord.sh', 'setup/channels/discord.ts'],
|
||||
'slack-install': ['setup/add-slack.sh', 'setup/channels/slack.ts'],
|
||||
'discord-install': ['.claude/skills/add-discord/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/discord.ts'],
|
||||
'slack-install': ['.claude/skills/add-slack/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/slack.ts'],
|
||||
'slack-validate': ['setup/channels/slack.ts'],
|
||||
'imessage-install': ['setup/add-imessage.sh', 'setup/channels/imessage.ts'],
|
||||
'imessage-install': ['.claude/skills/add-imessage/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/imessage.ts'],
|
||||
'imessage': ['setup/channels/imessage.ts'],
|
||||
'teams-install': ['setup/add-teams.sh', 'setup/channels/teams.ts'],
|
||||
'teams-install': ['.claude/skills/add-teams/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/teams.ts'],
|
||||
'teams-manifest': ['setup/lib/teams-manifest.ts', 'setup/channels/teams.ts'],
|
||||
'init-first-agent': [
|
||||
'scripts/init-first-agent.ts',
|
||||
|
||||
@@ -222,8 +222,9 @@ function buildHandoffPrompt(ctx: HandoffContext): string {
|
||||
...(ctx.files ?? []),
|
||||
'logs/setup.log',
|
||||
'logs/setup-steps/',
|
||||
// The bespoke `setup/channels/<channel>.ts` flows are deleted — point the
|
||||
// agent at the channel's SKILL.md, which now owns the whole procedure.
|
||||
`.claude/skills/add-${ctx.channel}/SKILL.md`,
|
||||
`setup/channels/${ctx.channel}.ts`,
|
||||
].filter((v, i, a) => a.indexOf(v) === i);
|
||||
|
||||
lines.push('Relevant files (read as needed with the Read tool):');
|
||||
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# Restart the NanoClaw service, then wait (best-effort) for its `ncl` CLI socket
|
||||
# so a following wiring directive doesn't race the restart. Channel skills call
|
||||
# this as `nc:run effect:restart`. Best-effort throughout: a fresh setup may not
|
||||
# have the service installed yet, and the wiring's own `ncl` call is the real
|
||||
# signal if the socket never appears — so a wait timeout does not fail the step.
|
||||
set -u
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
root="$(cd "$here/../.." && pwd)"
|
||||
# shellcheck source=/dev/null
|
||||
source "$here/install-slug.sh"
|
||||
|
||||
case "$(uname -s)" in
|
||||
Darwin) launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" 2>/dev/null || true ;;
|
||||
Linux) systemctl --user restart "$(systemd_unit)" 2>/dev/null \
|
||||
|| sudo systemctl restart "$(systemd_unit)" 2>/dev/null || true ;;
|
||||
esac
|
||||
|
||||
# Wait up to ~30s for the CLI socket so `ncl` can connect on the next directive.
|
||||
for _ in $(seq 1 60); do
|
||||
[ -S "$root/data/ncl.sock" ] && exit 0
|
||||
sleep 0.5
|
||||
done
|
||||
echo "nanoclaw: ncl socket not up yet after restart — the wiring step may need a retry" >&2
|
||||
exit 0
|
||||
+44
-20
@@ -299,12 +299,21 @@ export function summariseTerminalFields(block: Block | null): Record<string, str
|
||||
return out;
|
||||
}
|
||||
|
||||
async function runUnderSpinner<
|
||||
T extends { ok: boolean; transcript: string; terminal?: Block | null },
|
||||
>(
|
||||
labels: SpinnerLabels,
|
||||
work: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
/**
|
||||
* A live clack spinner with an elapsed-time ticker — the brand-styled,
|
||||
* width-fit rendering primitive behind `runUnderSpinner`, exposed so a caller
|
||||
* that doesn't have a single `work()` promise to await can drive it itself.
|
||||
* The apply engine (scripts/skill-apply.ts) fires stepStart/stepEnd *events*
|
||||
* around each step, so the skill driver's per-step reporter starts the spinner
|
||||
* on stepStart and calls the returned `.stop(...)` on stepEnd.
|
||||
*
|
||||
* `stop({ ok, skipped, transcript })` clears the ticker and renders the
|
||||
* done/skipped/failed headline (bold) with the elapsed time (dim); on a failure
|
||||
* it also dumps the transcript tail when one is supplied.
|
||||
*/
|
||||
export function startSpinner(labels: SpinnerLabels): {
|
||||
stop: (outcome: { ok: boolean; skipped?: boolean; transcript?: string }) => void;
|
||||
} {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
s.start(fitToWidth(labels.running, ' (99m 59s)'));
|
||||
@@ -312,22 +321,37 @@ async function runUnderSpinner<
|
||||
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||
s.message(`${fitToWidth(labels.running, suffix)}${k.dim(suffix)}`);
|
||||
}, 1000);
|
||||
return {
|
||||
stop({ ok, skipped, transcript }) {
|
||||
clearInterval(tick);
|
||||
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||
if (ok) {
|
||||
const msg = skipped && labels.skipped ? labels.skipped : labels.done;
|
||||
// Bold the outcome so the step's headline reads stronger than the prose
|
||||
// body copy around it. The trailing `(Ns)` timing stays dim.
|
||||
s.stop(`${k.bold(fitToWidth(msg, suffix))}${k.dim(suffix)}`);
|
||||
} else {
|
||||
const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed');
|
||||
s.stop(`${k.bold(fitToWidth(failMsg, suffix))}${k.dim(suffix)}`, 1);
|
||||
if (transcript) dumpTranscriptOnFailure(transcript);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runUnderSpinner<
|
||||
T extends { ok: boolean; transcript: string; terminal?: Block | null },
|
||||
>(
|
||||
labels: SpinnerLabels,
|
||||
work: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const spinner = startSpinner(labels);
|
||||
const result = await work();
|
||||
|
||||
clearInterval(tick);
|
||||
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;
|
||||
// Bold the outcome so the step's headline reads stronger than the prose
|
||||
// body copy around it. The trailing `(Ns)` timing stays dim.
|
||||
s.stop(`${k.bold(fitToWidth(msg, suffix))}${k.dim(suffix)}`);
|
||||
} else {
|
||||
const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed');
|
||||
s.stop(`${k.bold(fitToWidth(failMsg, suffix))}${k.dim(suffix)}`, 1);
|
||||
dumpTranscriptOnFailure(result.transcript);
|
||||
}
|
||||
spinner.stop({
|
||||
ok: result.ok,
|
||||
skipped: result.terminal?.fields.STATUS === 'skipped',
|
||||
transcript: result.transcript,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { runSkill, hostExec, hostExecStream, labelOrdinals, promptValidator, clackResolveInput, type RunSkillOptions } from './skill-driver.js';
|
||||
import { fullyApplied, type ApplyEvent } from '../../scripts/skill-apply.js';
|
||||
|
||||
// Shared test state for the clack + claude-handoff mocks (hoisted so the vi.mock
|
||||
// factories — which run before imports — can close over it). `answers` is the
|
||||
// queue each mocked text/password prompt pops from; `handoffSpy` stands in for
|
||||
// the interactive Claude handoff; `lastValidate` captures the validate callback
|
||||
// the resolveInput impl handed clack so we can prove the `?` help-escape is wired.
|
||||
const ce = vi.hoisted(() => ({
|
||||
handoffSpy: vi.fn(async (_ctx: { channel: string; step: string; stepDescription: string }) => true),
|
||||
answers: [] as string[],
|
||||
lastValidate: { fn: undefined as undefined | ((v: string) => string | Error | void | undefined) },
|
||||
}));
|
||||
|
||||
// Keep isHelpEscape + validateWithHelpEscape real (clackResolveInput uses them);
|
||||
// only the interactive handoff is replaced with a spy so the test never spawns Claude.
|
||||
vi.mock('./claude-handoff.js', async (importActual) => {
|
||||
const actual = await importActual<typeof import('./claude-handoff.js')>();
|
||||
return { ...actual, offerClaudeHandoff: ce.handoffSpy };
|
||||
});
|
||||
|
||||
// Drive clackResolveInput's prompts from the `answers` queue and record the
|
||||
// validate callback it passes through, instead of opening a real TTY prompt.
|
||||
vi.mock('@clack/prompts', async (importActual) => {
|
||||
const actual = await importActual<typeof import('@clack/prompts')>();
|
||||
const fromQueue = async (o: { validate?: (v: string) => string | Error | void | undefined }): Promise<string> => {
|
||||
ce.lastValidate.fn = o?.validate;
|
||||
return ce.answers.shift() ?? '';
|
||||
};
|
||||
return { ...actual, text: vi.fn(fromQueue), password: vi.fn(fromQueue) };
|
||||
});
|
||||
|
||||
// A small SKILL.md exercising the three things the driver wires: an operator
|
||||
// block (emitted as an operator event), a secret prompt (collected via
|
||||
// resolveInput), and a wire run (executed via exec) consuming the captured input.
|
||||
const SKILL = `# driver demo
|
||||
|
||||
## Set up
|
||||
Tell the user:
|
||||
\`\`\`nc:operator
|
||||
Go create the app and copy the token.
|
||||
\`\`\`
|
||||
\`\`\`nc:prompt token secret
|
||||
Paste the token.
|
||||
\`\`\`
|
||||
|
||||
## Wire
|
||||
\`\`\`nc:run effect:wire
|
||||
ncl wire --token {{token}}
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
function scratch(): { root: string; skill: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), 'driver-'));
|
||||
const skill = mkdtempSync(join(tmpdir(), 'driver-skill-'));
|
||||
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
|
||||
writeFileSync(join(root, '.env'), '');
|
||||
writeFileSync(join(skill, 'SKILL.md'), SKILL);
|
||||
return { root, skill };
|
||||
}
|
||||
|
||||
describe('thin skill driver', () => {
|
||||
it('resolves prompts via resolveInput, emits operator events, and execs wiring', async () => {
|
||||
const { root, skill } = scratch();
|
||||
const asked: Array<{ name: string; secret: boolean }> = [];
|
||||
const told: string[] = [];
|
||||
const ran: string[] = [];
|
||||
const opts: RunSkillOptions = {
|
||||
projectRoot: root,
|
||||
resolveInput: async (name, meta) => {
|
||||
asked.push({ name, secret: meta.secret });
|
||||
return 'T0KEN';
|
||||
},
|
||||
// An injected onEvent REPLACES the default policy handler (the injector owns its I/O).
|
||||
onEvent: (e: ApplyEvent) => {
|
||||
if (e.type === 'operator') told.push(e.text);
|
||||
},
|
||||
exec: (c) => void ran.push(c),
|
||||
};
|
||||
const res = await runSkill(skill, opts);
|
||||
|
||||
expect(asked).toEqual([{ name: 'token', secret: true }]); // the prompt was driven through resolveInput, meta intact
|
||||
expect(told).toEqual(['Go create the app and copy the token.']); // operator relayed through the event
|
||||
expect(ran).toContain('ncl wire --token T0KEN'); // wiring executed with the answer substituted in
|
||||
expect(res.operatorMessages).toEqual(['Go create the app and copy the token.']);
|
||||
});
|
||||
|
||||
it('runs fully from inputs — resolveInput never touched', async () => {
|
||||
const { root, skill } = scratch();
|
||||
const ran: string[] = [];
|
||||
const res = await runSkill(skill, { projectRoot: root, inputs: { token: 'FROM-INPUTS' }, exec: (c) => void ran.push(c) });
|
||||
expect(fullyApplied(res)).toBe(true);
|
||||
expect(ran).toContain('ncl wire --token FROM-INPUTS');
|
||||
});
|
||||
|
||||
it('emits step events through an injected onEvent — the wire run under its heading', async () => {
|
||||
const { root, skill } = scratch();
|
||||
const starts: Array<{ kind: string; label: string | null }> = [];
|
||||
await runSkill(skill, {
|
||||
projectRoot: root,
|
||||
inputs: { token: 'T' },
|
||||
exec: () => {},
|
||||
onEvent: (e) => {
|
||||
if (e.type === 'step-start') starts.push({ kind: e.kind, label: e.label });
|
||||
},
|
||||
});
|
||||
// the demo SKILL's only mutating step is `nc:run effect:wire` under `## Wire`
|
||||
expect(starts).toEqual([{ kind: 'run', label: 'Wire' }]);
|
||||
});
|
||||
|
||||
it('hostExec puts the project bin/ on PATH so a bare command resolves to it', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'driver-bin-'));
|
||||
mkdirSync(join(root, 'bin'));
|
||||
writeFileSync(join(root, 'bin/greet'), '#!/usr/bin/env bash\necho hi-from-bin\n');
|
||||
chmodSync(join(root, 'bin/greet'), 0o755);
|
||||
const out = hostExec(root)('greet'); // bare name, not ./bin/greet
|
||||
expect(String(out).trim()).toBe('hi-from-bin');
|
||||
});
|
||||
|
||||
it('hostExec returns stdout so a capture run can bind it', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'driver-cap-'));
|
||||
expect(String(hostExec(root)('echo D0CHANNEL')).trim()).toBe('D0CHANNEL');
|
||||
});
|
||||
|
||||
it('hostExecStream runs a step and captures the terminal status block fields (for effect:step)', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'driver-step-'));
|
||||
const out = await hostExecStream(root)(
|
||||
'echo show-this-to-the-operator; echo "=== NANOCLAW SETUP: PAIR ==="; echo "STATUS: success"; echo "PLATFORM_ID: telegram:42"; echo "=== END ==="',
|
||||
);
|
||||
expect(out.ok).toBe(true);
|
||||
expect(out.fields.PLATFORM_ID).toBe('telegram:42');
|
||||
});
|
||||
|
||||
function reuseScratch(): { root: string; skill: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), 'reuse-'));
|
||||
const skill = mkdtempSync(join(tmpdir(), 'reuse-skill-'));
|
||||
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
|
||||
writeFileSync(join(root, '.env'), 'SLACK_BOT_TOKEN=xoxb-existing-token\n');
|
||||
// a skill whose env-set maps bot_token → SLACK_BOT_TOKEN (the reuse linkage)
|
||||
writeFileSync(
|
||||
join(skill, 'SKILL.md'),
|
||||
'# reuse demo\n\n```nc:prompt bot_token secret\nPaste the token.\n```\n```nc:env-set\nSLACK_BOT_TOKEN={{bot_token}}\n```\n```nc:run effect:wire\nuse {{bot_token}}\n```\n',
|
||||
);
|
||||
return { root, skill };
|
||||
}
|
||||
|
||||
it('reuse:true offers an existing .env credential via the confirm seam and skips the prompt when accepted', async () => {
|
||||
const { root, skill } = reuseScratch();
|
||||
const asked: string[] = [];
|
||||
const cmds: string[] = [];
|
||||
await runSkill(skill, {
|
||||
projectRoot: root,
|
||||
reuse: true,
|
||||
confirm: async () => true, // yes, reuse the existing value
|
||||
resolveInput: async (n) => {
|
||||
asked.push(n);
|
||||
return 'NEWLY-PASTED';
|
||||
},
|
||||
exec: (c) => void cmds.push(c),
|
||||
});
|
||||
expect(asked).not.toContain('bot_token'); // reused from .env → never prompted
|
||||
expect(cmds).toContain('use xoxb-existing-token'); // the reused value flowed downstream
|
||||
});
|
||||
|
||||
it('reuse: declining keeps the prompt', async () => {
|
||||
const { root, skill } = reuseScratch();
|
||||
const asked: string[] = [];
|
||||
const cmds: string[] = [];
|
||||
await runSkill(skill, {
|
||||
projectRoot: root,
|
||||
reuse: true,
|
||||
confirm: async () => false, // no, ask me
|
||||
resolveInput: async (n) => {
|
||||
asked.push(n);
|
||||
return 'NEWLY-PASTED';
|
||||
},
|
||||
exec: (c) => void cmds.push(c),
|
||||
});
|
||||
expect(asked).toContain('bot_token'); // declined → prompted
|
||||
expect(cmds).toContain('use NEWLY-PASTED');
|
||||
});
|
||||
|
||||
// A cred a HELPER SCRIPT owns (written by effect:external, not nc:env-set) has no
|
||||
// env-set→ENV_KEY linkage to infer. An explicit `nc:prompt … reuse:<ENV_KEY>`
|
||||
// restores the masked reuse offer — the imessage Photon case.
|
||||
function helperReuseScratch(): { root: string; skill: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), 'reuse-helper-'));
|
||||
const skill = mkdtempSync(join(tmpdir(), 'reuse-helper-skill-'));
|
||||
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
|
||||
// present in .env, but NOT written by any nc:env-set in the skill below
|
||||
writeFileSync(join(root, '.env'), 'IMESSAGE_SERVER_URL=https://photon.example.com\n');
|
||||
writeFileSync(
|
||||
join(skill, 'SKILL.md'),
|
||||
'# helper reuse demo\n\n```nc:prompt server_url validate:^https?:// reuse:IMESSAGE_SERVER_URL\nYour Photon server URL.\n```\n```nc:run effect:external\nbash configure.sh "{{server_url}}"\n```\n',
|
||||
);
|
||||
return { root, skill };
|
||||
}
|
||||
|
||||
it('reuse: offers an existing .env value for a HELPER-owned cred (no env-set linkage)', async () => {
|
||||
const { root, skill } = helperReuseScratch();
|
||||
const asked: string[] = [];
|
||||
const cmds: string[] = [];
|
||||
const confirmed: string[] = [];
|
||||
await runSkill(skill, {
|
||||
projectRoot: root,
|
||||
reuse: true,
|
||||
confirm: async (msg) => {
|
||||
confirmed.push(msg);
|
||||
return true; // yes, reuse the existing helper-owned value
|
||||
},
|
||||
resolveInput: async (n) => {
|
||||
asked.push(n);
|
||||
return 'https://typed.example';
|
||||
},
|
||||
exec: (c) => void cmds.push(c),
|
||||
});
|
||||
expect(confirmed.some((m) => /IMESSAGE_SERVER_URL/.test(m))).toBe(true); // the reuse: link surfaced the offer
|
||||
expect(asked).not.toContain('server_url'); // accepted → never re-prompted
|
||||
expect(cmds).toContain('bash configure.sh "https://photon.example.com"'); // reused value flowed downstream
|
||||
});
|
||||
|
||||
// §5.4 pre-filter: a stale .env value that would fail the prompt's declared
|
||||
// validate-at-bind is NEVER offered — the operator is prompted fresh instead
|
||||
// of the reused input rejecting loudly with no interactive recovery.
|
||||
it('reuse: a stale .env value failing the prompt validate is never offered — prompted fresh', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'reuse-stale-'));
|
||||
const skill = mkdtempSync(join(tmpdir(), 'reuse-stale-skill-'));
|
||||
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
|
||||
writeFileSync(join(root, '.env'), 'SLACK_BOT_TOKEN=legacy-not-a-bot-token\n'); // fails ^xoxb-
|
||||
writeFileSync(
|
||||
join(skill, 'SKILL.md'),
|
||||
'# stale reuse demo\n\n```nc:prompt bot_token secret validate:^xoxb-\nPaste the token.\n```\n```nc:env-set\nSLACK_BOT_TOKEN={{bot_token}}\n```\n```nc:run effect:wire\nuse {{bot_token}}\n```\n',
|
||||
);
|
||||
const asked: string[] = [];
|
||||
const confirmed: string[] = [];
|
||||
const res = await runSkill(skill, {
|
||||
projectRoot: root,
|
||||
reuse: true,
|
||||
confirm: async (m) => {
|
||||
confirmed.push(m);
|
||||
return true;
|
||||
},
|
||||
resolveInput: async (n) => {
|
||||
asked.push(n);
|
||||
return 'xoxb-fresh-token';
|
||||
},
|
||||
exec: () => {},
|
||||
});
|
||||
expect(confirmed).toHaveLength(0); // the stale value was silently not offered
|
||||
expect(asked).toContain('bot_token'); // prompted fresh
|
||||
expect(fullyApplied(res)).toBe(true); // no §4 dead-end
|
||||
});
|
||||
|
||||
it('reuse pre-filter mirrors bind order: normalize-then-validate, so a normalizable value still offers', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'reuse-norm-'));
|
||||
const skill = mkdtempSync(join(tmpdir(), 'reuse-norm-skill-'));
|
||||
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
|
||||
writeFileSync(join(root, '.env'), 'BASE_URL=https://x.example/\n'); // trailing slash — valid only after rstrip-slash
|
||||
writeFileSync(
|
||||
join(skill, 'SKILL.md'),
|
||||
'# norm reuse demo\n\n```nc:prompt base_url normalize:rstrip-slash validate:^https://[^/]+$\nYour base URL.\n```\n```nc:env-set\nBASE_URL={{base_url}}\n```\n',
|
||||
);
|
||||
const confirmed: string[] = [];
|
||||
const res = await runSkill(skill, {
|
||||
projectRoot: root,
|
||||
reuse: true,
|
||||
confirm: async (m) => {
|
||||
confirmed.push(m);
|
||||
return true;
|
||||
},
|
||||
resolveInput: async () => undefined,
|
||||
exec: () => {},
|
||||
});
|
||||
expect(confirmed.some((m) => /BASE_URL/.test(m))).toBe(true); // offered — normalized form passes
|
||||
expect(fullyApplied(res)).toBe(true); // engine normalizes at bind, so it binds cleanly too
|
||||
});
|
||||
|
||||
// The default onEvent policy handler (never injected here — injecting onEvent
|
||||
// would replace the policy, §5.0): note → URL offer (confirm → openUrl) →
|
||||
// natural-barrier confirm, all through the injectable confirm/openUrl seams.
|
||||
it('default handler: offers the operator-body URL, then the barrier confirm — in that order', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'offer-'));
|
||||
const skill = mkdtempSync(join(tmpdir(), 'offer-skill-'));
|
||||
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
|
||||
writeFileSync(
|
||||
join(skill, 'SKILL.md'),
|
||||
'# offer demo\n\n## Portal step\nTell the user:\n```nc:operator\nOpen https://example.com/setup and finish the app.\n```\n\n## Build\n```nc:run effect:build\necho build\n```\n',
|
||||
);
|
||||
const confirms: string[] = [];
|
||||
const opened: string[] = [];
|
||||
const res = await runSkill(skill, {
|
||||
projectRoot: root,
|
||||
exec: () => {},
|
||||
confirm: async (m) => {
|
||||
confirms.push(m);
|
||||
return true;
|
||||
},
|
||||
openUrl: async (u) => void opened.push(u),
|
||||
});
|
||||
expect(opened).toEqual(['https://example.com/setup']); // offered + accepted → opened
|
||||
expect(confirms).toEqual([
|
||||
'Open https://example.com/setup in your browser?',
|
||||
"Done with the steps above? Continue when you're ready.", // run barrier, completed flavor
|
||||
]);
|
||||
expect(fullyApplied(res)).toBe(true);
|
||||
});
|
||||
|
||||
it('default handler: readiness flavor before an effect:step; decline = proceed (never an abort)', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'gate-step-'));
|
||||
const skill = mkdtempSync(join(tmpdir(), 'gate-step-skill-'));
|
||||
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
|
||||
writeFileSync(
|
||||
join(skill, 'SKILL.md'),
|
||||
'# gate demo\n\n## Pair\nTell the user:\n```nc:operator\nA pairing code is about to appear.\n```\n```nc:run effect:step capture:pid=PID\npair-now\n```\n',
|
||||
);
|
||||
const confirms: string[] = [];
|
||||
const res = await runSkill(skill, {
|
||||
projectRoot: root,
|
||||
exec: () => {},
|
||||
execStream: async () => ({ ok: true, fields: { PID: 'p1' } }),
|
||||
confirm: async (m) => {
|
||||
confirms.push(m);
|
||||
return false; // DECLINE everything — the barrier is a pause, not a branch
|
||||
},
|
||||
openUrl: async () => {},
|
||||
});
|
||||
expect(confirms).toEqual(['Ready? The next step starts immediately.']); // no URL in the body → only the barrier
|
||||
expect(res.vars.pid).toBe('p1'); // the step still ran — decline proceeded
|
||||
expect(fullyApplied(res)).toBe(true);
|
||||
});
|
||||
|
||||
it('default handler: declining the URL offer skips the open', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'offer-no-'));
|
||||
const skill = mkdtempSync(join(tmpdir(), 'offer-no-skill-'));
|
||||
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
|
||||
writeFileSync(
|
||||
join(skill, 'SKILL.md'),
|
||||
'# offer decline demo\n\n```nc:operator\nGo to https://example.com/app and make the app.\n```\n',
|
||||
);
|
||||
const opened: string[] = [];
|
||||
await runSkill(skill, {
|
||||
projectRoot: root,
|
||||
exec: () => {},
|
||||
confirm: async () => false,
|
||||
openUrl: async (u) => void opened.push(u),
|
||||
});
|
||||
expect(opened).toHaveLength(0); // declined → never opened
|
||||
});
|
||||
|
||||
it('clackResolveInput intercepts a lone "?" → hands off to Claude with context, then re-asks', async () => {
|
||||
const prevTTY = process.stdout.isTTY;
|
||||
process.stdout.isTTY = true; // the `?` help-escape only fires at a real terminal
|
||||
try {
|
||||
ce.handoffSpy.mockClear();
|
||||
ce.answers = ['?', 'real-token']; // first answer is the help-escape, second is the real value
|
||||
const ans = await clackResolveInput({ channel: 'telegram', step: 'paste-token' })('token', {
|
||||
question: 'Paste your token.',
|
||||
secret: false,
|
||||
validate: '^[0-9a-zA-Z-]+$',
|
||||
});
|
||||
expect(ans).toBe('real-token'); // re-asked after the handoff, returns the second answer
|
||||
expect(ce.handoffSpy).toHaveBeenCalledTimes(1);
|
||||
expect(ce.handoffSpy.mock.calls[0][0]).toEqual({
|
||||
channel: 'telegram',
|
||||
step: 'paste-token',
|
||||
stepDescription: 'Paste your token.',
|
||||
});
|
||||
// The validate handed to clack lets `?` through (so the escape reaches us)
|
||||
// but still rejects a value that fails the prompt's own regex.
|
||||
expect(ce.lastValidate.fn?.('?')).toBeUndefined();
|
||||
expect(ce.lastValidate.fn?.('has space')).toBeTruthy();
|
||||
} finally {
|
||||
process.stdout.isTTY = prevTTY;
|
||||
}
|
||||
});
|
||||
|
||||
it('clackResolveInput passes a normal answer straight through — no handoff', async () => {
|
||||
ce.handoffSpy.mockClear();
|
||||
ce.answers = ['just-a-token'];
|
||||
const ans = await clackResolveInput({ channel: 'telegram', step: 'paste-token' })('token', {
|
||||
question: 'Paste your token.',
|
||||
secret: false,
|
||||
});
|
||||
expect(ans).toBe('just-a-token');
|
||||
expect(ce.handoffSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('promptValidator honors flags:i and derives its rejection message from the question prose', () => {
|
||||
const question = 'Paste your public base URL (looks like `https://abcd1234.ngrok.io`).';
|
||||
const ci = promptValidator('^https://', 'i', question);
|
||||
expect(ci).toBeDefined();
|
||||
expect(ci!('HTTPS://example.com')).toBeUndefined(); // case-insensitive match passes
|
||||
// the message is the generic lead-in + the QUESTION prose — the prose
|
||||
// describes the expected shape, so no authored error: string exists anymore
|
||||
expect(ci!('ftp://example.com')).toBe(`That doesn't match the expected format. ${question}`);
|
||||
expect(promptValidator(undefined, undefined, question)).toBeUndefined(); // no regex ⇒ no validator
|
||||
});
|
||||
});
|
||||
|
||||
// Two steps under one heading share a spinner caption (build + test both read
|
||||
// "Build and validate") — the ordinal suffix marks them as a sequence, not a
|
||||
// stuttered duplicate. Solo captions stay unsuffixed.
|
||||
describe('labelOrdinals (repeated-caption disambiguation)', () => {
|
||||
const MD = [
|
||||
'# demo',
|
||||
'',
|
||||
'## Build and validate',
|
||||
'```nc:run effect:build',
|
||||
'pnpm run build',
|
||||
'```',
|
||||
'```nc:run effect:test',
|
||||
'pnpm exec vitest run x.test.ts',
|
||||
'```',
|
||||
'',
|
||||
'## Restart',
|
||||
'```nc:run effect:restart',
|
||||
'bash restart.sh',
|
||||
'```',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
it('suffixes (1/2)/(2/2) on the shared caption and leaves the solo one alone', () => {
|
||||
const ords = labelOrdinals(MD);
|
||||
const buildLine = 4; // opening fence of the build run (1-based)
|
||||
const testLine = 7;
|
||||
const restartLine = 12;
|
||||
expect(ords.get(buildLine)).toBe(' (1/2)');
|
||||
expect(ords.get(testLine)).toBe(' (2/2)');
|
||||
expect(ords.has(restartLine)).toBe(false); // unique caption — no suffix
|
||||
});
|
||||
|
||||
it('the real add-telegram build+test pair (the live stutter) gets ordinals', () => {
|
||||
const md = readFileSync(join(process.cwd(), '.claude/skills/add-telegram/SKILL.md'), 'utf8');
|
||||
const suffixes = [...labelOrdinals(md).values()];
|
||||
expect(suffixes).toContain(' (1/2)');
|
||||
expect(suffixes).toContain(' (2/2)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* The thin generic driver: render a SKILL.md's human I/O through clack and run
|
||||
* the directive engine. The entire connect+wire procedure now lives in the
|
||||
* SKILL.md — operator walkthroughs (`nc:operator`), credential prompts
|
||||
* (`nc:prompt`), the service restart (`nc:run effect:restart`), and the wiring
|
||||
* (`nc:run effect:wire`, `ncl …`). So the driver is just: ask the prompts
|
||||
* (`resolveInput`), render the engine's events (`onEvent` — spinners for step
|
||||
* events, notes + policy for operator blocks), run the engine in document order.
|
||||
*
|
||||
* The engine only DECLARES and EMITS (scripts/skill-apply.ts); everything
|
||||
* presentational lives here, derived from document structure via the shared
|
||||
* policy module (scripts/skill-policy.ts): the natural-barrier gate confirm,
|
||||
* the URL offer, the prose-derived validation message.
|
||||
*/
|
||||
import { execSync, spawn } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
|
||||
import {
|
||||
applySkill,
|
||||
fullyApplied,
|
||||
normalizeValue,
|
||||
stepLabel,
|
||||
type ApplyEvent,
|
||||
type ApplyResult,
|
||||
type InputMeta,
|
||||
type StepOutcome,
|
||||
} from '../../scripts/skill-apply.js';
|
||||
import { parseDirectives, promptVar } from '../../scripts/skill-directives.js';
|
||||
import { extractOfferUrl, gatePolicy } from '../../scripts/skill-policy.js';
|
||||
import { isHeadless } from '../platform.js';
|
||||
import { openUrl } from './browser.js';
|
||||
import { isHelpEscape, offerClaudeHandoff, validateWithHelpEscape } from './claude-handoff.js';
|
||||
import { startSpinner } from './runner.js';
|
||||
|
||||
/**
|
||||
* Build the clack `validate` callback an `nc:prompt` carries — the interactive
|
||||
* enforcement of `validate:<re>` (with `flags:`). On a miss the message is
|
||||
* derived from the QUESTION PROSE, which by authoring convention describes the
|
||||
* expected shape ("Paste the bot token (looks like `123456:ABC-DEF...`)."), so
|
||||
* there is no separate authored error string. Returns undefined when the prompt
|
||||
* declares no regex. Exported so the policy is unit-testable without a TTY.
|
||||
* Normalization is NOT here: it's deterministic, applied at bind by the engine
|
||||
* (skill-apply `normalizeValue`), so it lands the same for `inputs` and typed
|
||||
* answers.
|
||||
*/
|
||||
export function promptValidator(
|
||||
validate: string | undefined,
|
||||
flags: string | undefined,
|
||||
question: string,
|
||||
): ((v: string | undefined) => string | undefined) | undefined {
|
||||
if (!validate) return undefined;
|
||||
const re = new RegExp(validate, flags);
|
||||
return (v) => (re.test((v ?? '').trim()) ? undefined : `That doesn't match the expected format. ${question}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handoff context for the `?` help-escape (Step 8 / mechanism M3). A lone `?` at
|
||||
* any prompt hands the operator to interactive Claude with this context, then
|
||||
* re-asks the same prompt. Both fields are optional so a bare
|
||||
* `clackResolveInput()` (e.g. the standalone CLI below) still works — it just
|
||||
* hands off with a generic `setup` channel and the prompt's own var name as the
|
||||
* step.
|
||||
*/
|
||||
export interface PrompterContext {
|
||||
/** Channel this run is wiring (e.g. 'telegram') — surfaced to the handoff. */
|
||||
channel?: string;
|
||||
/** Short label for the current setup step — surfaced to the handoff. */
|
||||
step?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The wizard's `resolveInput` implementation: collect an `nc:prompt` through
|
||||
* clack (password for secrets, text otherwise; a cancel defers), running the
|
||||
* interactive re-ask loop against the prompt's declared `validate:`/`flags:`
|
||||
* (the engine's validate-at-bind is the programmatic backstop, not the UX).
|
||||
*/
|
||||
export function clackResolveInput(ctx: PrompterContext = {}): (name: string, meta: InputMeta) => Promise<string | undefined> {
|
||||
// The `?` help-escape is only meaningful at a real terminal: it hands the
|
||||
// operator off to an interactive Claude session (stdio inherited). In a
|
||||
// headless / non-TTY run nobody can type `?` into a clack prompt anyway, and
|
||||
// we must never spawn an interactive child without a TTY — so it's a no-op
|
||||
// there (read at ask-time so a re-run picks up a terminal that appears later).
|
||||
async function ask(name: string, meta: InputMeta): Promise<string | undefined> {
|
||||
const check = promptValidator(meta.validate, meta.flags, meta.question);
|
||||
// Wrap the validator so a lone `?` short-circuits format checks and comes
|
||||
// back as a literal "?" instead of being rejected — we intercept it below.
|
||||
const guarded = validateWithHelpEscape(check);
|
||||
// clearOnError wipes a rejected secret so the operator re-pastes cleanly
|
||||
// (a half-pasted token isn't left masked in the field).
|
||||
const ans = meta.secret
|
||||
? await p.password({ message: meta.question, validate: guarded, clearOnError: true })
|
||||
: await p.text({ message: meta.question, validate: guarded });
|
||||
if (p.isCancel(ans)) return undefined; // cancelled ⇒ defer
|
||||
if (isHelpEscape(ans) && process.stdout.isTTY) {
|
||||
// Operator asked for help: hand off to interactive Claude with this
|
||||
// prompt's context, then re-ask the same prompt. Recursion is operator-
|
||||
// bounded — they decide when to stop typing `?`.
|
||||
await offerClaudeHandoff({
|
||||
channel: ctx.channel ?? 'setup',
|
||||
step: ctx.step ?? name,
|
||||
stepDescription: meta.question,
|
||||
});
|
||||
return ask(name, meta);
|
||||
}
|
||||
const v = String(ans).trim();
|
||||
return v.length ? v : undefined;
|
||||
}
|
||||
return ask;
|
||||
}
|
||||
|
||||
/**
|
||||
* The default `confirm` seam: a clack yes/no, TTY-gated exactly like the step
|
||||
* spinner — a non-TTY run resolves TRUE (proceed), so a headless run with full
|
||||
* inputs never stalls on a barrier or an offer. A cancel counts as decline.
|
||||
*/
|
||||
async function defaultConfirm(message: string): Promise<boolean> {
|
||||
if (!process.stdout.isTTY) return true;
|
||||
const ans = await p.confirm({ message });
|
||||
return ans === true; // cancel ⇒ false
|
||||
}
|
||||
|
||||
/**
|
||||
* The default `openUrl` seam: best-effort browser open (setup/lib/browser.ts).
|
||||
* Headless-safe: on a machine with no display we skip the open entirely — the
|
||||
* URL is already in the rendered operator note for copy-paste.
|
||||
*/
|
||||
async function defaultOpenUrl(url: string): Promise<void> {
|
||||
if (isHeadless()) return;
|
||||
openUrl(url);
|
||||
}
|
||||
|
||||
/** Mask a credential for display: first 6 + last 4. */
|
||||
function maskValue(v: string): string {
|
||||
return v.length <= 12 ? '••••' : `${v.slice(0, 6)}…${v.slice(-4)}`;
|
||||
}
|
||||
|
||||
/** Parse `KEY=value` lines from a .env file body. */
|
||||
function parseEnv(body: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const line of body.split('\n')) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/);
|
||||
if (m && m[2].trim()) out[m[1]] = m[2].trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Offer to reuse credentials already in `.env` so a re-run doesn't re-prompt for
|
||||
* them. The prompt var → ENV_KEY mapping comes from the skill's own `env-set`
|
||||
* directives, so this stays generic. Returns the inputs the operator chose to
|
||||
* reuse (interactive: each is confirmed via the `confirm` seam).
|
||||
*
|
||||
* Every offer is PRE-FILTERED through the target prompt's declared
|
||||
* `normalize`/`validate`/`flags`: an `.env` value that would fail the engine's
|
||||
* validate-at-bind is silently not offered, so the operator is prompted fresh
|
||||
* instead of hitting a dead-end (`inputs` win outright at bind — a stale
|
||||
* credential passed through would reject loudly with no re-ask).
|
||||
*/
|
||||
async function reuseFromEnv(
|
||||
skillDir: string,
|
||||
projectRoot: string,
|
||||
alreadyHave: Record<string, string>,
|
||||
confirm: (message: string) => Promise<boolean>,
|
||||
): Promise<Record<string, string>> {
|
||||
let md: string;
|
||||
try {
|
||||
md = readFileSync(join(skillDir, 'SKILL.md'), 'utf8');
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
const varToKey = new Map<string, string>();
|
||||
const promptShape = new Map<string, { validate?: string; flags?: string; normalize?: string }>();
|
||||
for (const d of parseDirectives(md)) {
|
||||
// 1st pass: infer var → ENV_KEY from env-set directives (KEY={{var}}).
|
||||
if (d.kind === 'env-set') {
|
||||
for (const line of d.body) {
|
||||
const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/);
|
||||
if (m) varToKey.set(m[2], m[1]); // var → ENV_KEY
|
||||
}
|
||||
}
|
||||
if (d.kind === 'prompt') {
|
||||
const v = promptVar(d);
|
||||
if (!v) continue;
|
||||
// Record each prompt's declared value shape for the reuse pre-filter.
|
||||
promptShape.set(v, {
|
||||
validate: typeof d.attrs.validate === 'string' ? d.attrs.validate : undefined,
|
||||
flags: typeof d.attrs.flags === 'string' ? d.attrs.flags : undefined,
|
||||
normalize: typeof d.attrs.normalize === 'string' ? d.attrs.normalize : undefined,
|
||||
});
|
||||
// 2nd pass: an explicit `nc:prompt … reuse:<ENV_KEY>` links a prompt to a
|
||||
// credential a HELPER SCRIPT owns — written by effect:external, not
|
||||
// nc:env-set (e.g. imessage's Photon IMESSAGE_SERVER_URL /
|
||||
// IMESSAGE_API_KEY). The env-set inference above can't see those, so the
|
||||
// prompt states the linkage to regain the masked reuse offer on a re-run.
|
||||
if (typeof d.attrs.reuse === 'string') varToKey.set(v, d.attrs.reuse);
|
||||
}
|
||||
}
|
||||
let env: Record<string, string> = {};
|
||||
try {
|
||||
env = parseEnv(readFileSync(join(projectRoot, '.env'), 'utf8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
const reuse: Record<string, string> = {};
|
||||
for (const [v, key] of varToKey) {
|
||||
if (v in alreadyHave) continue; // caller already supplied it
|
||||
const existing = env[key];
|
||||
if (!existing) continue;
|
||||
// Pre-filter: normalize-then-validate, mirroring the engine's bind order. A
|
||||
// stale credential that no longer matches the declared shape is never
|
||||
// offered — prompting fresh beats a loud validate-at-bind dead-end.
|
||||
const shape = promptShape.get(v);
|
||||
if (shape?.validate && !new RegExp(shape.validate, shape.flags).test(normalizeValue(existing, shape.normalize))) continue;
|
||||
if (await confirm(`Found an existing ${key} (${maskValue(existing)}). Use it?`)) reuse[v] = existing;
|
||||
}
|
||||
return reuse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Host exec for the engine's run directives. Returns stdout so a
|
||||
* `run capture:<var>` can bind it. Puts the project's `bin/` on PATH so a bare
|
||||
* `ncl …` in a wire directive resolves to `bin/ncl` even when it isn't
|
||||
* symlinked onto the operator's PATH.
|
||||
*/
|
||||
export function hostExec(projectRoot: string): (cmd: string) => string {
|
||||
return (cmd) =>
|
||||
execSync(cmd, {
|
||||
cwd: projectRoot,
|
||||
shell: '/bin/bash',
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, PATH: `${join(projectRoot, 'bin')}:${process.env.PATH ?? ''}` },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming host exec for `nc:run effect:step`. Spawns the step through a shell,
|
||||
* tees its human-facing output to the operator's terminal live (so a pairing code
|
||||
* card or a QR rendered by the step shows), parses the `=== NANOCLAW SETUP: TYPE
|
||||
* ===` status blocks, and resolves with the terminal (last STATUS-bearing) block's
|
||||
* fields so the engine can `capture:<var>=<FIELD>` them. The block protocol mirrors
|
||||
* setup/lib/runner.ts's StatusStream — a step is just a command that emits blocks.
|
||||
*/
|
||||
export function hostExecStream(projectRoot: string): (cmd: string) => Promise<StepOutcome> {
|
||||
return (cmd) =>
|
||||
new Promise((resolve) => {
|
||||
const child = spawn('bash', ['-c', cmd], {
|
||||
cwd: projectRoot,
|
||||
env: { ...process.env, PATH: `${join(projectRoot, 'bin')}:${process.env.PATH ?? ''}` },
|
||||
stdio: ['inherit', 'pipe', 'pipe'],
|
||||
});
|
||||
const blocks: Array<{ fields: Record<string, string> }> = [];
|
||||
let current: { fields: Record<string, string> } | null = null;
|
||||
let buf = '';
|
||||
const onChunk = (chunk: Buffer): void => {
|
||||
buf += chunk.toString('utf8');
|
||||
let idx: number;
|
||||
while ((idx = buf.indexOf('\n')) !== -1) {
|
||||
const line = buf.slice(0, idx);
|
||||
buf = buf.slice(idx + 1);
|
||||
if (/^=== NANOCLAW SETUP: \S+ ===/.test(line)) { current = { fields: {} }; continue; }
|
||||
if (line.startsWith('=== END ===')) { if (current) blocks.push(current); current = null; continue; }
|
||||
if (current) {
|
||||
const c = line.indexOf(':');
|
||||
if (c > 0) current.fields[line.slice(0, c).trim()] = line.slice(c + 1).trim();
|
||||
continue;
|
||||
}
|
||||
process.stdout.write(line + '\n'); // operator-facing line (a QR, a code) — show it live
|
||||
}
|
||||
};
|
||||
child.stdout.on('data', onChunk);
|
||||
child.stderr.on('data', onChunk);
|
||||
child.on('close', (code) => {
|
||||
const terminal = [...blocks].reverse().find((b) => b.fields.STATUS) ?? null;
|
||||
const status = terminal?.fields.STATUS;
|
||||
resolve({ ok: code === 0 && (status === 'success' || status === 'skipped'), fields: terminal?.fields ?? {} });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// The barrier-confirm wording per gate flavor (§5.1.5). Decline = proceed: the
|
||||
// barrier is a PAUSE, not a branch — the result is deliberately discarded, and
|
||||
// the handler never throws for a decline (an operator-event throw would bounce
|
||||
// and latch the engine's `blocked` gate over later side effects).
|
||||
const GATE_WORDING: Record<'readiness' | 'completed', string> = {
|
||||
readiness: 'Ready? The next step starts immediately.',
|
||||
completed: "Done with the steps above? Continue when you're ready.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Where several steps under one heading share a spinner caption (a build and a
|
||||
* test both labelled "5. Build and validate"), suffix an ordinal — "(1/2)",
|
||||
* "(2/2)" — so consecutive spinners read as distinct steps, not a stutter.
|
||||
* Keyed by directive line (the step events carry it). Counting is static
|
||||
* (document order over the parsed directives); a runtime-skipped sibling can
|
||||
* leave a gap in the rendered sequence — cosmetic only.
|
||||
*/
|
||||
export function labelOrdinals(md: string): Map<number, string> {
|
||||
const byLabel = new Map<string, number[]>();
|
||||
for (const d of parseDirectives(md)) {
|
||||
const label = stepLabel(d, md);
|
||||
if (label === null) continue;
|
||||
const lines = byLabel.get(label) ?? [];
|
||||
lines.push(d.line);
|
||||
byLabel.set(label, lines);
|
||||
}
|
||||
const out = new Map<number, string>();
|
||||
for (const lines of byLabel.values()) {
|
||||
if (lines.length < 2) continue;
|
||||
lines.forEach((ln, i) => out.set(ln, ` (${i + 1}/${lines.length})`));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The driver's DEFAULT `onEvent` handler — the whole wizard presentation policy:
|
||||
*
|
||||
* • step-start/step-end → a per-step clack spinner (built on runner.ts's
|
||||
* `startSpinner`), TTY-gated so piped/CI/test runs stay quiet. A null label
|
||||
* is the engine's instant/renders-its-own-output declaration — no spinner.
|
||||
* Repeated captions under one heading get an ordinal suffix (labelOrdinals).
|
||||
* • operator → render the block as a clack note, then the URL offer
|
||||
* (extractOfferUrl → confirm → openUrl), then the natural-barrier confirm
|
||||
* when gatePolicy says this block precedes a side-effecting directive.
|
||||
*
|
||||
* An INJECTED `onEvent` replaces this handler entirely (the injector owns its
|
||||
* I/O) — which is why driver-policy tests run this default and inject the
|
||||
* `confirm`/`openUrl` seams instead.
|
||||
*/
|
||||
function defaultOnEvent(
|
||||
md: string,
|
||||
confirm: (message: string) => Promise<boolean>,
|
||||
open: (url: string) => Promise<void>,
|
||||
): (e: ApplyEvent) => Promise<void> {
|
||||
const gates = gatePolicy(md);
|
||||
const ordinals = labelOrdinals(md);
|
||||
let active: ReturnType<typeof startSpinner> | null = null;
|
||||
return async (e) => {
|
||||
if (e.type === 'step-start') {
|
||||
if (!process.stdout.isTTY || e.label === null) return; // quiet: non-TTY, or instant/cheap step
|
||||
const base = e.label.replace(/…+$/, '') + (ordinals.get(e.line) ?? '');
|
||||
active = startSpinner({ running: `${base}…`, done: base, failed: `${base} failed` });
|
||||
return;
|
||||
}
|
||||
if (e.type === 'step-end') {
|
||||
if (!active) return; // never started a spinner for this one
|
||||
active.stop({ ok: e.ok });
|
||||
active = null;
|
||||
return;
|
||||
}
|
||||
// operator: note → URL offer → natural-barrier confirm.
|
||||
p.note(e.text, 'Do this');
|
||||
const url = extractOfferUrl(e.text);
|
||||
if (url !== undefined && (await confirm(`Open ${url} in your browser?`))) await open(url);
|
||||
const gate = gates.get(e.line);
|
||||
// Decline = proceed (result discarded): the confirm is a pause so a manual
|
||||
// UI step is finished first — never an abort, never a throw.
|
||||
if (gate?.needsConfirm) await confirm(GATE_WORDING[gate.flavor]);
|
||||
};
|
||||
}
|
||||
|
||||
/** Fork-aware registry-branch remote (same resolver setup/channels/slack.ts uses). */
|
||||
function channelsRemote(projectRoot: string): () => string {
|
||||
return () =>
|
||||
execSync('source setup/lib/channels-remote.sh; resolve_channels_remote', {
|
||||
cwd: projectRoot,
|
||||
shell: '/bin/bash',
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
}
|
||||
|
||||
export interface RunSkillOptions {
|
||||
projectRoot?: string;
|
||||
/** Pre-supplied prompt answers — pass them all for a fully programmatic run. */
|
||||
inputs?: Record<string, string>;
|
||||
/**
|
||||
* Resolve a prompt var the caller didn't pre-supply. Defaults to the clack
|
||||
* collector (`clackResolveInput` — masked secrets, validate re-ask loop, `?`
|
||||
* help-escape); inject a fake for tests or a relay for a coding agent.
|
||||
*/
|
||||
resolveInput?: (name: string, meta: InputMeta) => Promise<string | undefined>;
|
||||
/** Defaults to `hostExec`. */
|
||||
exec?: (cmd: string) => string | void;
|
||||
/** Defaults to `hostExecStream`. Streaming exec for `nc:run effect:step`. */
|
||||
execStream?: (cmd: string) => Promise<StepOutcome>;
|
||||
/** Defaults to the fork-aware channels-branch resolver. */
|
||||
resolveRemote?: (branch: string) => string;
|
||||
/** Run effects the caller owns (e.g. `['restart']` when it restarts once). */
|
||||
skipEffects?: string[];
|
||||
/** Offer to reuse credentials already in `.env` instead of re-prompting. */
|
||||
reuse?: boolean;
|
||||
/**
|
||||
* Consumer for every engine emission (step events + operator blocks). When
|
||||
* injected it REPLACES the driver's default policy handler ENTIRELY — the
|
||||
* spinner, the note, the URL offer, and the natural-barrier confirm are all
|
||||
* the default handler's; the injector owns its I/O. To observe the default
|
||||
* policy in tests, inject `confirm`/`openUrl` instead.
|
||||
*/
|
||||
onEvent?: (e: ApplyEvent) => void | Promise<void>;
|
||||
/**
|
||||
* Yes/no seam used by the reuse offer, the natural-barrier gate, and the URL
|
||||
* offer. Defaults to a clack confirm, TTY-gated like the spinner — a non-TTY
|
||||
* run resolves true (proceed) so a headless run with full inputs never stalls.
|
||||
*/
|
||||
confirm?: (message: string) => Promise<boolean>;
|
||||
/**
|
||||
* Browser-open seam for the URL offer, attempted only after a `confirm` yes.
|
||||
* Defaults to setup/lib/browser.ts `openUrl` (headless ⇒ no-op).
|
||||
*/
|
||||
openUrl?: (url: string) => Promise<void>;
|
||||
/**
|
||||
* Handoff context for the `?` help-escape (Step 8 / mechanism M3), threaded
|
||||
* into the default `clackResolveInput`. A lone `?` at any prompt hands the
|
||||
* operator off to interactive Claude with this `channel` + `step` label, then
|
||||
* re-asks. Ignored when an explicit `resolveInput` is injected (the injector
|
||||
* owns its I/O).
|
||||
*/
|
||||
channel?: string;
|
||||
step?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a SKILL.md end-to-end through the directive engine with host-wired I/O.
|
||||
* Returns the engine's result; `fullyApplied(res)` tells the caller whether the
|
||||
* run completed or left prompts deferred / steps for an agent.
|
||||
*/
|
||||
export async function runSkill(skillDir: string, opts: RunSkillOptions = {}): Promise<ApplyResult> {
|
||||
const projectRoot = opts.projectRoot ?? process.cwd();
|
||||
const confirm = opts.confirm ?? defaultConfirm;
|
||||
const open = opts.openUrl ?? defaultOpenUrl;
|
||||
let inputs = opts.inputs;
|
||||
// Offer to reuse credentials already in .env before the engine prompts for them.
|
||||
if (opts.reuse) {
|
||||
const reused = await reuseFromEnv(skillDir, projectRoot, inputs ?? {}, confirm);
|
||||
if (Object.keys(reused).length) inputs = { ...inputs, ...reused };
|
||||
}
|
||||
// The default operator policy derives from the skill document itself
|
||||
// (gatePolicy keys on directive lines) — read it once here; the engine reads
|
||||
// the same file for the actual apply.
|
||||
let md = '';
|
||||
try {
|
||||
md = readFileSync(join(skillDir, 'SKILL.md'), 'utf8');
|
||||
} catch {
|
||||
// missing SKILL.md — the engine will produce an empty result anyway
|
||||
}
|
||||
return applySkill(skillDir, projectRoot, {
|
||||
inputs,
|
||||
resolveInput: opts.resolveInput ?? clackResolveInput({ channel: opts.channel, step: opts.step }),
|
||||
onEvent: opts.onEvent ?? defaultOnEvent(md, confirm, open),
|
||||
exec: opts.exec ?? hostExec(projectRoot),
|
||||
execStream: opts.execStream ?? hostExecStream(projectRoot),
|
||||
resolveRemote: opts.resolveRemote ?? channelsRemote(projectRoot),
|
||||
skipEffects: opts.skipEffects,
|
||||
});
|
||||
}
|
||||
|
||||
// CLI: pnpm exec tsx setup/lib/skill-driver.ts <skillDir> — apply a skill interactively.
|
||||
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
|
||||
void (async () => {
|
||||
const skillDir = process.argv[2];
|
||||
if (!skillDir) {
|
||||
console.error('usage: pnpm exec tsx setup/lib/skill-driver.ts <skillDir>');
|
||||
process.exit(2);
|
||||
}
|
||||
p.intro(`Applying ${skillDir}`);
|
||||
const res = await runSkill(skillDir);
|
||||
if (fullyApplied(res)) {
|
||||
p.outro('Done — fully applied.');
|
||||
} else {
|
||||
if (res.deferred.length) p.log.warn(`No value yet for: ${res.deferred.join(', ')}`);
|
||||
for (const t of res.agentTasks) p.log.warn(`Needs an agent (${t.kind}): ${t.reason}`);
|
||||
p.outro('Applied with gaps — see above.');
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -116,15 +116,6 @@ function main(): void {
|
||||
channelsProcessed++;
|
||||
}
|
||||
|
||||
// Sync to data/env/env
|
||||
if (fs.existsSync(v2EnvPath)) {
|
||||
const containerEnvDir = path.join(process.cwd(), 'data', 'env');
|
||||
try {
|
||||
fs.mkdirSync(containerEnvDir, { recursive: true });
|
||||
fs.copyFileSync(v2EnvPath, path.join(containerEnvDir, 'env'));
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
console.log(`OK:channels=${channelsProcessed},env_keys=${envKeysCopied},files=${filesCopied}`);
|
||||
if (missing.length > 0) {
|
||||
console.log(`MISSING:${missing.join(',')}`);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user