Compare commits

..

7 Commits

Author SHA1 Message Date
gavrielc c277deae21 fix(skills): conformance batch — REMOVE hygiene, explicit no-test notes, dead test
Mechanical follow-ups from the skill-guideline audit (no behavior change to apply):

- teams/matrix/linear REMOVE.md: env-var removal followed the discord template
  — names inlined in prose, the `bash` block holds only the re-sync command —
  instead of a non-functional `bash` fence that just listed bare var names.
  matrix also drops the 4 optional vars (INVITE_AUTOJOIN[_ALLOWLIST],
  RECOVERY_KEY, DEVICE_ID) that apply never sets.
- gcal/rtk SKILL.md: state explicitly that the runtime `ncl add-mcp-server` /
  `add-mount` (and rtk's settings.json hook) reach-ins have no in-tree source
  footprint, so a registration test is structurally inapplicable — per the
  guidelines' "nothing to test in-tree" rule.
- gmail-allow-pattern.test.ts: drop the tautological third test (it asserted a
  locally reimplemented `expectedPattern`, not the real `mcpAllowPattern`) and
  the false "exercised directly" comment. The derivation is non-invocable
  (unexported, call site inside SDK query options), so the two structural guards
  are the correct archetype; no core export added to keep the surface minimal.
- resend SKILL.md: supports-threads yes → no (adapter sets supportsThreads:false).
- add-codex: delete the orphan codex-cli-tools.test.ts duplicate (SKILL.md copies
  it from the providers branch; the skill-folder copy was unreferenced).

Telegram setup-step placement deferred: it's a trunk leftover from the v2
"move adapters off trunk" refactor (setup/pair-telegram.ts + setup/channels/
telegram.ts live in trunk), entangled with uncommitted telegram work — a
separate decision, not a skill fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 23:10:17 +03:00
gavrielc 0cc8ade34c fix(skills): drop add-linear's dead bridge catch-all patch
The patch added a `catchAll?` field + handler to chat-sdk-bridge.ts, but:
- its second awk anchored on a stale marker (`// DMs — apply engage rules
  too`) that no longer exists, so the handler was never inserted;
- the linear adapter never sets `catchAll: true` (it documents relying on
  the bridge's default onNewMessage catch-all), and no adapter anywhere
  consumes `catchAll` — so the block was dead even when the marker matched.

Every Linear comment is already delivered via the base bridge's universal
onNewMessage(/[\s\S]*/) forwarding → router evaluateEngage (pattern mode
returns true for all messages); subscription is handled router-side. Dropping
the patch is a behavior-preserving no-op and collapses add-linear into the
standard barrel-only channel template — resolving the stale-marker break, the
REMOVE incompleteness (nothing left to reverse), and the missing patch test
(now fully covered by linear-registration.test.ts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 23:03:20 +03:00
gavrielc d33806389c fix(skills): gmail/gcal/rtk mounts use ncl add-mount, not raw SQL
The three mount-using skills wrote container_configs.additional_mounts directly
via scripts/q.ts (raw SQL over core schema — smell #1: untyped, drift-prone).
They now call the operator-run `ncl groups config add-mount` / `remove-mount`
verb. Dropped the stale "no add-mount verb yet (#2395)" notes; the mount-allowlist
and restart prose are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:15:47 +03:00
gavrielc f350ed24e1 feat(cli): host-only ncl groups config add-mount / remove-mount
Mounting a host directory into a group's containers is a filesystem-access
boundary, so this is an OPERATOR-ONLY verb. A new `hostOnly` flag on commands,
enforced in dispatch BEFORE scope/approval, rejects any container (agent) caller
regardless of cli_scope — even `global`, even with approval — because the mount
allowlist is the boundary cli_scope itself lives inside.

Mirrors add-package: writes additional_mounts, idempotent, paired remove. It's
the WHO layer, complementing the existing spawn-time mount allowlist (the WHAT,
stored outside the project root). Tests: host-only rejection at global scope +
add/idempotent/remove behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:15:46 +03:00
gavrielc cc07387025 test(skills): copy + run the registration test in all chat-sdk channels
The 7 channels that didn't (telegram, teams, imessage, github, resend, matrix,
whatsapp-cloud) now match the slack/discord standard: their nc:copy fetches
<channel>-registration.test.ts from the channels branch, and a nc:run effect:test
runs it. Every chat-sdk channel now ships + runs its registration guard — the
red-on-drift check that the barrel registers the adapter (and covers the dep).

Verified in an isolated worktree: all 7 install (exact pins), build clean, and
their registration tests pass; engine suite 36/36.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:00:35 +03:00
gavrielc 59460e9a5c feat(skills): convert all chat-sdk channels + codex to nc: format, wire setup
Builds on the structured-skill engine (slack was the first conversion). Adds the
two directives the rest of the family needed, converts the 12 remaining skills,
and routes their setup flows through the engine — deleting the hand-maintained
shell scripts that had drifted from the skills.

Engine (scripts/):
- json-merge: merge a keyed JSON object into an array file (container/cli-tools.json),
  idempotent + journal-removable. add-codex uses it for its @openai/codex entry.
- append at:<marker>: insert before a `// <<< <marker>` line instead of EOF.
- setup/index.ts: a dormant `nanoclaw:setup-steps` marker in the STEPS map.

Conversions (.claude/skills/): discord, telegram, teams, imessage, linear, github,
webex, resend, matrix, gchat, whatsapp-cloud, codex — each aligned with its
now-deleted setup script (versions, copied-file lists). @chat-adapter/* pins match
our chat core (4.26.0); the lint enforces it.

Setup integration (setup/): the discord/telegram/teams/imessage channel flows and
the codex provider now apply their skill in-process via applySkill (secrets via the
Prompter, fork-aware remote resolution), mirroring slack. Deleted 5 add-*.sh + 9
install-*.sh drifted duplicates; rewired the claude-assist diagnostics map.

Channel remove no longer tears down the DB: wechat/emacs REMOVE.md stop deleting
messaging_groups/sessions/wirings. Those are user runtime data the skill never
created, so remove must not touch them — and orphan rows are inert (adapters start
from the registry, not the DB).

Verified: all 12 skills lint clean; 168/168 setup+scripts tests pass; no deps
installed by the conversion and no core barrels applied.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 21:48:30 +03:00
gavrielc ae48986e42 feat(skills): structured nc: directive format + apply engine, first applied to Slack
Introduces the optionally-deterministic skill format. Official skills carry
`nc:` directive fences (copy/append/dep/run/prompt/env-set/env-sync) embedded in
prose, so one SKILL.md is both agent-readable and machine-appliable. Robustness
lives in the whole system — graceful degradation to an agent, plus lint + tests —
not in the syntax, so the directives stay minimal and readable.

scripts/skill-directives.ts — parser + linter. Extracts nc: directives; flags
  unpinned deps, undefined {{var}} references, and a @chat-adapter/* pin that
  doesn't match our lockfile's `chat` core (the drift that put add-slack on the
  wrong version).
scripts/skill-apply.ts — the application engine. Plan (idempotency, prompt
  resolution, no writes) → mutate (copy/append/env-set, journaled) → run
  (dep/build/test). Remove is the journal played back (no hand-written
  REMOVE.md). Anything the engine can't do bounces to an AGENT with its prose —
  never the human, never a hard abort. A Prompter abstraction lets one engine
  serve both interactive setup and headless rebuilds; fork-aware remote
  resolution replaces a hardcoded `origin`.

.claude/skills/add-slack/SKILL.md — converted to the format; `prompt` split from
  `env-set` so a captured secret can feed env, ncl, or the vault; pinned 4.26.0
  to match our chat core.

setup/channels/slack.ts — the Slack setup flow now applies the skill through the
  engine in-process (secrets via the Prompter, never argv/disk), deleting the
  hand-maintained setup/add-slack.sh + install-slack.sh, which had drifted from
  the skill (they pinned 4.26.0 vs the skill's stale 4.27.0). One source of truth.

Verified end-to-end in an isolated worktree: apply copies the adapter + its
registration test, installs 4.26.0, builds clean against chat@4.26.0, and the
registration test passes. 19 unit tests for the parser + engine.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:42:47 +03:00
82 changed files with 2837 additions and 2847 deletions
+50 -73
View File
@@ -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.-]+)?$/);
});
});
+63 -45
View File
@@ -5,61 +5,68 @@ 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.27.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/discord@4.26.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
Discord app setup is human and interactive — these steps are prose, not
directives (no parser can click through the Discord Developer Portal). A recipe
rebuild produces a compiling, registered adapter that cannot receive a message
until they're done.
### Create Discord Bot
1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
@@ -73,25 +80,36 @@ Both must be clean before proceeding. `discord-registration.test.ts` is the one
- 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
### Store the credentials
All three values are required — the adapter will fail to start without `DISCORD_PUBLIC_KEY` and `DISCORD_APPLICATION_ID`.
All three values are required — the adapter will fail to start without
`DISCORD_PUBLIC_KEY` and `DISCORD_APPLICATION_ID`. Capture them, 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) and sync to the container:
Add to `.env`:
```bash
DISCORD_BOT_TOKEN=your-bot-token
DISCORD_APPLICATION_ID=your-application-id
DISCORD_PUBLIC_KEY=your-public-key
```nc:prompt bot_token secret
Paste the Bot Token — Bot tab. Click `Reset Token` if you need a new one.
```
```nc:prompt application_id
Paste the Application ID — General Information tab.
```
```nc:prompt public_key
Paste the Public Key — General Information tab.
```
```nc:env-set
DISCORD_BOT_TOKEN={{bot_token}}
DISCORD_APPLICATION_ID={{application_id}}
DISCORD_PUBLIC_KEY={{public_key}}
```
```nc:env-sync
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## 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
+4 -6
View File
@@ -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>`.
+5 -6
View File
@@ -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
+10 -13
View 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
+65 -42
View File
@@ -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.27.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/gchat@4.26.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,35 @@ 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, then syncs to the container:
```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}}
```
```nc:env-sync
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Webhook server
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 +118,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>
+58 -41
View File
@@ -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.27.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/github@4.26.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,31 @@ 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) and sync to the container:
```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}}
```
```nc:env-sync
```
`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?**
+7 -7
View File
@@ -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`.
+6 -11
View File
@@ -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__*');
});
});
+53 -36
View File
@@ -5,63 +5,71 @@ 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. Mode choice and the Full Disk Access /
Photon walkthrough are human and interactive — these steps stay prose, not
directives.
### Local Mode (macOS)
Requirements: macOS with Full Disk Access granted to the Node.js binary.
@@ -87,14 +95,19 @@ Stop and wait for the user to confirm before continuing.
### Configure environment
**Local mode** -- add to `.env`:
The two modes use different `.env` keys. Write only the keys for the chosen
mode, and remove the opposite mode's keys so a stale value can't confuse the
adapter's factory.
**Local mode** — add to `.env` (and remove `IMESSAGE_SERVER_URL` /
`IMESSAGE_API_KEY` if present):
```bash
IMESSAGE_ENABLED=true
IMESSAGE_LOCAL=true
```
**Remote mode** -- add to `.env`:
**Remote mode** — add to `.env` (and remove `IMESSAGE_ENABLED` if present):
```bash
IMESSAGE_LOCAL=false
@@ -102,7 +115,11 @@ IMESSAGE_SERVER_URL=https://your-photon-server.com
IMESSAGE_API_KEY=your-api-key
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
Once the keys for your mode are written, sync `.env` to the container (the host
mounts `data/env/env`):
```nc:env-sync
```
## Next Steps
+1 -10
View File
@@ -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
+86 -49
View File
@@ -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.27.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/linear@4.26.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,27 +98,52 @@ 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.
```bash
# OAuth app (recommended)
LINEAR_CLIENT_ID=your-client-id
LINEAR_CLIENT_SECRET=your-client-secret
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.
# 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
```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}}
```
```nc:env-sync
```
- `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.
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):
Sync to container: `mkdir -p data/env && cp .env data/env/env`
```bash
LINEAR_API_KEY=lin_api_...
```
## Wiring
+1 -16
View File
@@ -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
+88 -42
View File
@@ -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,52 @@ 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}}
```
Then sync `.env` into the container:
```nc:env-sync
```
## Next Steps
+69 -45
View File
@@ -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,45 @@ 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) and sync to the container:
```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}}
```
```nc:env-sync
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## 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
- **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)
- **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.
+5 -11
View File
@@ -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
+15 -21
View File
@@ -13,6 +13,10 @@ Install [rtk](https://github.com/rtk-ai/rtk) — a CLI proxy delivering 6090%
- `~/.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 35 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>
```
+74 -69
View File
@@ -5,114 +5,119 @@ 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.27.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/slack@4.26.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 app setup is human and interactive — these steps are prose, not directives
(no parser can click through the Slack UI). A recipe rebuild produces a
compiling, registered adapter that cannot receive a message until they're done.
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**
### Create the Slack app
1. Go to [api.slack.com/apps](https://api.slack.com/apps) → **Create New App** → **From scratch**.
2. Name it (e.g. "NanoClaw") and select your workspace.
3. **OAuth & Permissions** → 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. **Install to Workspace**, then copy the **Bot User OAuth Token** (`xoxb-…`).
5. **Basic Information** → 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"**
6. **App Home** → enable the **Messages Tab**.
7. Check **"Allow users to send Slash commands and messages from the messages tab."**
### Event Subscriptions
### Event Subscriptions & Interactivity
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**
8. **Event Subscriptions** → **Enable Events**. Set the **Request URL** to your public `https://your-domain/webhook/slack` (see Webhook server); Slack sends a challenge that must pass before you can save.
9. Under **Subscribe to bot events**, add `message.channels`, `message.groups`, `message.im`, `app_mention`. **Save Changes**.
10. **Interactivity & Shortcuts** → toggle **Interactivity** on, set the same Request URL, **Save Changes**, then **reinstall** the app when Slack prompts.
### Interactivity
### Store the credentials
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
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) and sync to the container:
### Configure environment
Add to `.env`:
```bash
SLACK_BOT_TOKEN=xoxb-your-bot-token
SLACK_SIGNING_SECRET=your-signing-secret
```nc:prompt bot_token secret
Paste the Bot User OAuth Token — OAuth & Permissions, starts with `xoxb-`.
```
```nc:prompt signing_secret secret
Paste the Signing Secret — Basic Information.
```
```nc:env-set
SLACK_BOT_TOKEN={{bot_token}}
SLACK_SIGNING_SECRET={{signing_secret}}
```
```nc:env-sync
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Webhook server
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.
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`.
The Chat SDK bridge automatically starts a shared webhook server on port 3000
(`WEBHOOK_PORT` to change it), handling `/webhook/slack`. This port must be
publicly reachable for Slack 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 Request 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
+1 -8
View File
@@ -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
+65 -44
View File
@@ -5,64 +5,69 @@ 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.
Connect NanoClaw to Microsoft Teams for 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.
## 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 Teams adapter into `src/channels/`
(overwrite — the branch is canonical):
- `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
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.27.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/teams@4.26.0
```
### 5. Build and validate
### 4. Build and validate
```bash
Build guards the typed `createChatSdkBridge(...)` core call and proves the
dependency is installed — the adapter import throws if `@chat-adapter/teams`
isn't there, so the barrel fails to evaluate and the build goes red.
```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 message
delivery against a real Teams workspace is verified manually once the service is
running — see Credentials and the webhook setup below.
## Credentials
Two paths — manual (Azure Portal) or auto (Teams CLI).
Two paths — manual (Azure Portal) or auto (Teams CLI). Teams app setup is human
and interactive — these steps are prose, not directives (no parser can click
through the Azure or Teams UI).
### Auto: Teams CLI
@@ -220,17 +225,33 @@ By default, the bot only receives messages when @-mentioned. To receive all mess
### Configure environment
Add to `.env`:
Capture the credentials, 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) and sync to the container. `TEAMS_APP_TENANT_ID` is required only
for Single Tenant apps; leave it blank for Multi Tenant.
```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
```nc:prompt app_id
Paste the Application (client) ID — Azure App Registration Overview page (maps to TEAMS_APP_ID).
```
```nc:prompt app_password secret
Paste the client secret Value — Certificates & secrets (maps to TEAMS_APP_PASSWORD; shown only once).
```
```nc:prompt app_type
Enter the app type — `SingleTenant` or `MultiTenant` (must match your Azure Bot / App Registration).
```
```nc:prompt app_tenant_id
Paste the Directory (tenant) ID — Azure App Registration Overview page (TEAMS_APP_TENANT_ID; Single Tenant only, leave blank for Multi Tenant).
```
```nc:env-set
TEAMS_APP_ID={{app_id}}
TEAMS_APP_PASSWORD={{app_password}}
TEAMS_APP_TYPE={{app_type}}
TEAMS_APP_TENANT_ID={{app_tenant_id}}
```
```nc:env-sync
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Webhook server
+69 -48
View File
@@ -5,77 +5,91 @@ 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, their tests, and the `pair-telegram` setup step 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 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, tests, and setup step
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 `pair-telegram` setup step
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
setup/pair-telegram.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 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):
```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.27.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/telegram@4.26.0
```
### 6. Build and validate
### 5. Build and validate
```bash
Build guards the typed `createChatSdkBridge(...)` core call and proves the
dependency is installed — 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):
```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`.
`telegram-registration.test.ts` 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 covers
the dependency from step 4.
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.
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.
## Credentials
Bot creation in Telegram is human and interactive — these steps are prose, not
directives (no parser can click through BotFather). A recipe rebuild produces a
compiling, registered adapter that cannot receive a message until they're done.
### Create Telegram Bot
1. Open Telegram and search for `@BotFather`
@@ -89,15 +103,22 @@ End-to-end message delivery against a real Telegram bot is verified manually onc
1. Open `@BotFather` > `/mybots` > select your bot
2. **Bot Settings** > **Group Privacy** > **Turn off**
### Configure environment
### Store the credentials
Add to `.env`:
Capture the bot token, 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)
and syncs to the container:
```bash
TELEGRAM_BOT_TOKEN=your-bot-token
```nc:prompt bot_token secret
Paste the bot token from BotFather (looks like `123456:ABC-DEF...`).
```
```nc:env-set
TELEGRAM_BOT_TOKEN={{bot_token}}
```
```nc:env-sync
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## Next Steps
+75 -47
View File
@@ -5,85 +5,113 @@ 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) and sync to the container:
```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}}
```
```nc:env-sync
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Webhook server
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
+5 -9
View File
@@ -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:
+76 -40
View File
@@ -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.27.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/whatsapp@4.26.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,43 @@ 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) and sync to the container:
```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}}
```
```nc:env-sync
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Webhook server
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 +134,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>
-6
View File
@@ -121,7 +121,6 @@ Bucket the upstream changed files:
- **Host source** (`src/`): may conflict if user modified the same files
- **Container** (`container/`): triggers container rebuild (+ typecheck if `agent-runner/src/` changed)
- **Build/config** (`package.json`, `pnpm-lock.yaml`, `tsconfig*.json`): lockfile changes trigger dep install
- **Version pins** (`versions.json`): a changed `onecli-gateway` / `onecli-cli` value requires upgrading the OneCLI gateway/CLI to match — see Step 5.5
- **Other**: docs, tests, setup scripts, misc
**Large drift check:** If the upstream commit count and age suggest the user has a lot of catching up to do, mention that `/migrate-nanoclaw` might be a better fit — it extracts customizations and reapplies them on clean upstream instead of merging. Offer it as an option but don't push.
@@ -216,11 +215,6 @@ If build fails:
- Do not refactor unrelated code.
- If unclear, ask the user before making changes.
# Step 5.5: OneCLI upgrade (if pins moved)
The OneCLI gateway and CLI are external components pinned in `versions.json`; when a pin moves, the running version must be upgraded to match or the new code may fail against it.
If `git diff <backup-tag-from-step-1>..HEAD -- versions.json` shows the `onecli-gateway` or `onecli-cli` value changed, follow `docs/onecli-upgrades.md` before the service restart (Step 8). Otherwise skip.
# Step 6: Breaking changes check
After validation succeeds, check if the update introduced any breaking changes.
+1 -2
View File
@@ -4,8 +4,7 @@ All notable changes to NanoClaw will be documented in this file.
## [Unreleased]
- **Budget/billing-exhausted LLM turns now reach the user instead of being silently dropped.** When a turn ends in a non-retryable provider error (e.g. an Anthropic `403 billing_error`) with no `<message>` wrapping, the agent-runner delivers the provider's notice to the originating channel and stops re-nudging the failing gateway. `providers/claude.ts` now surfaces the SDK's `is_error` flag (and the error subtype's `errors[]` text); `poll-loop.ts` delivers that text and skips the re-wrap retry. Fixes the case where a spend-limit notice produced silence plus a turn-after-turn retry loop.
- [BREAKING] **`@onecli-sh/sdk` 0.5.0 -> 2.2.1 — requires a OneCLI server with the `/v1` API** (older servers 404 every SDK call). The sanctioned gateway and CLI versions are pinned in `versions.json`. **The gateway is a separate component — updating NanoClaw does not upgrade it for you:** `/update-nanoclaw` upgrades it when the pin moves, otherwise upgrade manually. **Migration:** [docs/onecli-upgrades.md](docs/onecli-upgrades.md).
- [BREAKING] **`@onecli-sh/sdk` 0.5.0 -> 2.2.1 — requires a OneCLI server with the `/v1` API** (older servers 404 every SDK call). The sanctioned gateway and CLI versions are pinned in `versions.json`; the `onecli` setup step enforces them. **Migration:** [docs/onecli-upgrades.md](docs/onecli-upgrades.md).
- **New agent provider: Codex (OpenAI) — run `/add-codex`.** Full runtime via `codex app-server` (planning, MCP tools, server-side history, resume). Trunk ships the seams and the skill; the payload installs from the `providers` branch (the skill, the setup picker, or `--step provider-auth codex`). Auth is vault-only — no credential ever enters a container.
- **Setup can now select, install, and authenticate a non-default agent provider.** A provider registry feeds the setup picker, an installer pulls the provider's payload from its branch, a vault auth walkthrough runs (`--step provider-auth`), and the picked provider is set on the first agent (a DB property) before its first spawn. Default (Claude) installs are unaffected — picking Claude changes nothing.
- **Provider choice is explicit per group — no install-wide default.** Provider is a DB property set via `ncl groups config update --provider` + restart; creation is provider-agnostic.
-1
View File
@@ -11,7 +11,6 @@
<a href="https://docs.nanoclaw.dev">docs</a>&nbsp; • &nbsp;
<a href="README_zh.md">中文</a>&nbsp; • &nbsp;
<a href="README_ja.md">日本語</a>&nbsp; • &nbsp;
<a href="README_ko.md">한국어</a>&nbsp; • &nbsp;
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>&nbsp; • &nbsp;
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
</p>
-1
View File
@@ -11,7 +11,6 @@
<a href="https://docs.nanoclaw.dev">ドキュメント</a>&nbsp; • &nbsp;
<a href="README.md">English</a>&nbsp; • &nbsp;
<a href="README_zh.md">中文</a>&nbsp; • &nbsp;
<a href="README_ko.md">한국어</a>&nbsp; • &nbsp;
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>&nbsp; • &nbsp;
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
</p>
-228
View File
@@ -1,228 +0,0 @@
<p align="center">
<img src="assets/nanoclaw-logo.png" alt="NanoClaw" width="400">
</p>
<p align="center">
에이전트를 각자의 컨테이너에서 안전하게 실행하는 AI 어시스턴트입니다. 가볍고, 쉽게 이해할 수 있으며, 여러분의 필요에 맞게 완전히 커스터마이즈할 수 있도록 만들어졌습니다.
</p>
<p align="center">
<a href="https://nanoclaw.dev">nanoclaw.dev</a>&nbsp; • &nbsp;
<a href="https://docs.nanoclaw.dev">문서</a>&nbsp; • &nbsp;
<a href="README.md">English</a>&nbsp; • &nbsp;
<a href="README_zh.md">中文</a>&nbsp; • &nbsp;
<a href="README_ja.md">日本語</a>&nbsp; • &nbsp;
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>&nbsp; • &nbsp;
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
</p>
---
## NanoClaw를 만든 이유
[OpenClaw](https://github.com/openclaw/openclaw)는 인상적인 프로젝트지만, 제가 이해하지 못하는 복잡한 소프트웨어에 제 삶 전체에 대한 접근 권한을 줬다면 저는 잠을 이루지 못했을 것입니다. OpenClaw는 거의 50만 줄에 달하는 코드, 53개의 설정 파일, 70개 이상의 의존성을 가지고 있습니다. 보안은 진정한 OS 수준의 격리가 아니라 애플리케이션 수준(허용 목록, 페어링 코드)에 의존합니다. 모든 것이 메모리를 공유하는 하나의 Node 프로세스에서 실행됩니다.
NanoClaw는 그와 동일한 핵심 기능을 제공하지만, 이해할 수 있을 만큼 작은 코드베이스로 구현합니다. 하나의 프로세스와 몇 개의 파일뿐입니다. Claude 에이전트는 단순한 권한 검사 뒤가 아니라, 파일시스템이 격리된 각자의 Linux 컨테이너에서 실행됩니다.
## 빠른 시작
```bash
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash nanoclaw.sh
```
`nanoclaw.sh`는 갓 준비한 머신에서 시작해 메시지를 보낼 수 있는 이름 붙은 에이전트까지 안내합니다. 누락된 경우 Node, pnpm, Docker를 설치하고, Anthropic 자격 증명을 OneCLI에 등록하며, 에이전트 컨테이너를 빌드하고, 첫 채널(Telegram, Discord, WhatsApp 또는 로컬 CLI)을 페어링합니다. 어떤 단계가 실패하면 Claude Code가 자동으로 호출되어 원인을 진단하고 중단된 지점부터 재개합니다.
<details>
<summary><strong>NanoClaw v1에서 마이그레이션하시나요?</strong></summary>
기존 v1 설치 옆에 새로운 v2 체크아웃을 만들어 실행하세요:
```bash
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash migrate-v2.sh
```
`migrate-v2.sh`는 v1 설치(형제 디렉터리, 또는 `NANOCLAW_V1_PATH=/path/to/nanoclaw`)를 찾아 상태를 v2 체크아웃으로 마이그레이션한 다음, 판단이 필요한 부분(소유자 시딩, CLAUDE.local.md 정리, 포크 커스터마이징 재적용)을 마무리하기 위해 Claude Code로 `exec`합니다.
이 스크립트는 Claude 세션 내부가 아니라 직접 실행하세요. 결정론적인 부분에서 Node/pnpm 부트스트랩, Docker, OneCLI, 컨테이너 빌드를 위해 대화형 프롬프트와 실제 셸 I/O가 필요합니다.
**무엇을 하는가:** `.env`를 병합하고, `registered_groups`로부터 v2 DB를 시딩하며, 그룹 폴더 + 세션 데이터 + 예약 작업을 복사하고, 선택한 채널 어댑터를 설치하며, 채널 인증 상태(WhatsApp의 Baileys 키스토어 + LID 매핑 포함)를 복사하고, 에이전트 컨테이너를 빌드합니다.
**무엇을 하지 않는가:** 시스템 서비스를 전환하지 않습니다. 프롬프트에서 *"switch to v2"*를 선택하거나, 테스트 후 수동으로 전환하세요. 기존 v1 설치는 그대로 유지됩니다.
무엇이 달라졌는지는 [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md)를, 개발 노트는 [docs/migration-dev.md](docs/migration-dev.md)를 참고하세요.
</details>
## 철학
**이해할 수 있을 만큼 작게.** 하나의 프로세스, 몇 개의 소스 파일, 마이크로서비스 없음. NanoClaw 코드베이스 전체를 이해하고 싶다면 Claude Code에게 안내해 달라고 요청하기만 하면 됩니다.
**격리를 통한 보안.** 에이전트는 Linux 컨테이너에서 실행되며 명시적으로 마운트된 것만 볼 수 있습니다. 명령이 호스트가 아니라 컨테이너 안에서 실행되기 때문에 Bash 접근도 안전합니다.
**개별 사용자를 위해 설계.** NanoClaw는 거대한 단일 프레임워크가 아니라, 각 사용자의 정확한 필요에 맞는 소프트웨어입니다. 비대한 소프트웨어가 되는 대신, NanoClaw는 맞춤형이 되도록 설계되었습니다. 직접 포크를 만들고 Claude Code가 여러분의 필요에 맞게 수정하도록 합니다.
**커스터마이징 = 코드 변경.** 설정의 난립이 없습니다. 다른 동작을 원하시나요? 코드를 수정하세요. 코드베이스가 충분히 작아서 안전하게 변경할 수 있습니다.
**AI 네이티브, 설계상 하이브리드.** 설치와 온보딩 흐름은 최적화된 스크립트 경로로, 빠르고 결정론적입니다. 어떤 단계에 판단이 필요할 때 — 설치 실패, 안내가 필요한 결정, 커스터마이징 등 — 제어권이 Claude Code로 매끄럽게 넘어갑니다. 설정 이후에도 모니터링 대시보드나 디버깅 UI가 없습니다. 채팅으로 문제를 설명하면 Claude Code가 처리합니다.
**기능보다 스킬.** 트렁크는 특정 채널 어댑터나 대체 에이전트 프로바이더가 아니라 레지스트리와 인프라를 제공합니다. 채널(Discord, Slack, Telegram, WhatsApp, …)은 오래 유지되는 `channels` 브랜치에, 대체 프로바이더(OpenCode, Ollama)는 `providers` 브랜치에 있습니다. `/add-telegram`, `/add-opencode` 등을 실행하면 스킬이 여러분이 필요로 하는 모듈만 정확히 포크로 복사합니다. 요청하지 않은 기능은 없습니다.
**최고의 하니스, 최고의 모델.** NanoClaw는 Anthropic의 공식 Claude Agent SDK를 통해 Claude Code를 네이티브로 사용하므로, 최신 Claude 모델과 Claude Code의 전체 도구 세트를 누릴 수 있습니다. 여기에는 자신의 NanoClaw 포크를 직접 수정하고 확장하는 능력도 포함됩니다. 다른 프로바이더는 드롭인 옵션입니다. OpenAI의 Codex는 `/add-codex`(ChatGPT 구독 또는 API 키), OpenRouter·Google·DeepSeek 등은 OpenCode를 통한 `/add-opencode`, 로컬 오픈 웨이트 모델은 `/add-ollama-provider`로 추가합니다. 프로바이더는 에이전트 그룹별로 설정할 수 있습니다.
## 지원 기능
- **멀티 채널 메시징** — WhatsApp, Telegram, Discord, Slack, Microsoft Teams, iMessage, Matrix, Google Chat, Webex, Linear, GitHub, WeChat, 그리고 Resend를 통한 이메일. `/add-<channel>` 스킬로 필요할 때 설치합니다. 하나 또는 여러 개를 동시에 실행할 수 있습니다.
- **유연한 격리** — 완전한 프라이버시를 위해 각 채널을 자체 에이전트에 연결하거나, 대화는 분리하되 메모리는 통합하기 위해 하나의 에이전트를 여러 채널에서 공유하거나, 여러 채널을 하나의 공유 세션으로 묶어 하나의 대화가 여러 채널에 걸쳐 이어지도록 할 수 있습니다. `/manage-channels`로 채널별로 선택하세요. [docs/isolation-model.md](docs/isolation-model.md)를 참고하세요.
- **에이전트별 작업 공간** — 각 에이전트 그룹은 자체 `CLAUDE.md`, 자체 메모리, 자체 컨테이너, 그리고 여러분이 허용한 마운트만 갖습니다. 직접 연결하지 않는 한 경계를 넘는 것은 아무것도 없습니다.
- **예약 작업** — Claude를 실행하고 여러분에게 다시 메시지를 보낼 수 있는 반복 작업
- **웹 접근** — 웹에서 검색하고 콘텐츠를 가져오기
- **컨테이너 격리** — 에이전트는 Docker(macOS/Linux/WSL2)에서 샌드박스화되며, 선택적으로 [Docker Sandboxes](docs/docker-sandboxes.md) 마이크로 VM 격리나 macOS 네이티브 런타임인 Apple Container를 사용할 수 있습니다
- **자격 증명 보안** — 에이전트는 원시 API 키를 절대 보유하지 않습니다. 아웃바운드 요청은 [OneCLI의 Agent Vault](https://github.com/onecli/onecli)를 통해 라우팅되며, 요청 시점에 자격 증명을 주입하고 에이전트별 정책과 속도 제한을 적용합니다.
## 사용법
트리거 단어(기본값: `@Andy`)로 어시스턴트에게 말을 거세요:
```
@Andy 매주 평일 오전 9시에 영업 파이프라인 개요를 보내줘 (내 Obsidian 보관함 폴더에 접근 가능)
@Andy 매주 금요일에 지난 한 주간의 git 히스토리를 검토하고, 내용이 어긋나면 README를 업데이트해줘
@Andy 매주 월요일 오전 8시에 Hacker News와 TechCrunch에서 AI 관련 소식을 모아 브리핑을 보내줘
```
여러분이 소유하거나 관리하는 채널에서는 그룹과 작업을 관리할 수 있습니다:
```
@Andy 모든 그룹에 걸친 예약 작업을 전부 나열해줘
@Andy 월요일 브리핑 작업을 일시 정지해줘
@Andy Family Chat 그룹에 참여해줘
```
## 커스터마이징
NanoClaw는 설정 파일을 사용하지 않습니다. 변경하려면 Claude Code에게 원하는 것을 말하기만 하면 됩니다:
- "트리거 단어를 @Bob으로 바꿔줘"
- "앞으로는 응답을 더 짧고 직접적으로 하도록 기억해줘"
- "내가 좋은 아침이라고 인사하면 맞춤 인사를 추가해줘"
- "매주 대화 요약을 저장해줘"
또는 안내형 변경을 위해 `/customize`를 실행하세요.
코드베이스가 충분히 작아서 Claude가 안전하게 수정할 수 있습니다.
## 기여하기
**기능을 추가하지 마세요. 스킬을 추가하세요.**
새로운 채널이나 에이전트 프로바이더를 추가하고 싶다면 트렁크에 추가하지 마세요. 새 채널 어댑터는 `channels` 브랜치에, 새 에이전트 프로바이더는 `providers` 브랜치에 들어갑니다. 사용자는 `/add-<name>` 스킬로 자신의 포크에 설치하며, 이 스킬은 관련 모듈을 표준 경로로 복사하고, 등록을 연결하며, 의존성을 고정합니다.
이를 통해 트렁크는 순수한 레지스트리이자 인프라로 유지되고, 모든 포크는 가벼운 상태를 유지합니다. 사용자는 요청한 채널과 프로바이더만 얻고 그 외에는 아무것도 얻지 않습니다.
### RFS (Request for Skills)
저희가 보고 싶은 스킬:
**커뮤니케이션 채널**
- `/add-signal` — Signal을 채널로 추가
## 요구 사항
- macOS 또는 Linux (Windows는 WSL2 경유)
- Node.js 20+ 및 pnpm 10+ (설치 프로그램이 누락 시 둘 다 설치합니다)
- [Docker Desktop](https://docker.com/products/docker-desktop) (macOS/Windows) 또는 Docker Engine (Linux)
- `/customize`, `/debug`, 설정 중 오류 복구, 그리고 모든 `/add-<channel>` 스킬을 위한 [Claude Code](https://claude.ai/download)
## 아키텍처
```
메시징 앱 → 호스트 프로세스(라우터) → inbound.db → 컨테이너(Bun, Claude Agent SDK) → outbound.db → 호스트 프로세스(전송) → 메시징 앱
```
하나의 Node 호스트가 세션별 에이전트 컨테이너를 오케스트레이션합니다. 메시지가 도착하면 호스트는 엔티티 모델(사용자 → 메시징 그룹 → 에이전트 그룹 → 세션)을 통해 라우팅하고, 세션의 `inbound.db`에 기록한 뒤 컨테이너를 깨웁니다. 컨테이너 내부의 에이전트 러너는 `inbound.db`를 폴링하고, Claude를 실행하며, 응답을 `outbound.db`에 기록합니다. 호스트는 `outbound.db`를 폴링하여 채널 어댑터를 통해 다시 전송합니다.
세션당 두 개의 SQLite 파일이 있으며 각각 정확히 하나의 작성자만 갖습니다. 교차 마운트 경합이 없고, IPC가 없으며, stdin 파이핑이 없습니다. 채널과 대체 프로바이더는 시작 시 자체 등록됩니다. 트렁크는 레지스트리와 Chat SDK 브리지를 제공하고, 어댑터 자체는 포크별로 스킬을 통해 설치됩니다.
전체 아키텍처 설명은 [docs/architecture.md](docs/architecture.md)를, 3단계 격리 모델은 [docs/isolation-model.md](docs/isolation-model.md)를 참고하세요.
핵심 파일:
- `src/index.ts` — 진입점: DB 초기화, 채널 어댑터, 전송 폴링, 스윕
- `src/router.ts` — 인바운드 라우팅: 메시징 그룹 → 에이전트 그룹 → 세션 → `inbound.db`
- `src/delivery.ts``outbound.db` 폴링, 어댑터를 통한 전송, 시스템 액션 처리
- `src/host-sweep.ts` — 60초 스윕: 정체 감지, 예정 메시지 깨우기, 반복 처리
- `src/session-manager.ts` — 세션 확인, `inbound.db` / `outbound.db` 열기
- `src/container-runner.ts` — 에이전트 그룹별 컨테이너 생성, OneCLI 자격 증명 주입
- `src/db/` — 중앙 DB (사용자, 역할, 에이전트 그룹, 메시징 그룹, 연결, 마이그레이션)
- `src/channels/` — 채널 어댑터 인프라 (어댑터는 `/add-<channel>` 스킬로 설치)
- `src/providers/` — 호스트 측 프로바이더 설정 (`claude`는 기본 내장, 그 외는 스킬 경유)
- `container/agent-runner/` — Bun 에이전트 러너: 폴 루프, MCP 도구, 프로바이더 추상화
- `groups/<folder>/` — 에이전트 그룹별 파일시스템 (`CLAUDE.md`, 스킬, 컨테이너 설정)
## FAQ
**왜 Docker인가요?**
Docker는 크로스 플랫폼 지원(macOS, Linux, 그리고 WSL2 경유 Windows)과 성숙한 생태계를 제공합니다. macOS에서는 더 가벼운 네이티브 런타임인 Apple Container도 지원됩니다. 추가 격리를 위해 [Docker Sandboxes](docs/docker-sandboxes.md)는 각 컨테이너를 마이크로 VM 안에서 실행합니다.
**Linux나 Windows에서 실행할 수 있나요?**
네. Docker가 기본 런타임이며 macOS, Linux, Windows(WSL2 경유)에서 작동합니다. `bash nanoclaw.sh`를 실행하기만 하면 됩니다.
**이것은 안전한가요?**
에이전트는 애플리케이션 수준의 권한 검사 뒤가 아니라 컨테이너에서 실행됩니다. 명시적으로 마운트된 디렉터리만 접근할 수 있습니다. 자격 증명은 컨테이너에 들어가지 않습니다. 아웃바운드 API 요청은 [OneCLI의 Agent Vault](https://github.com/onecli/onecli)를 통해 라우팅되며, 프록시 수준에서 인증을 주입하고 속도 제한과 접근 정책을 지원합니다. 여전히 실행하는 것을 검토해야 하지만, 코드베이스가 충분히 작아서 실제로 검토할 수 있습니다. 전체 보안 모델은 [보안 문서](https://docs.nanoclaw.dev/concepts/security)를 참고하세요.
**왜 설정 파일이 없나요?**
설정의 난립을 원하지 않습니다. 모든 사용자는 일반적인 시스템을 설정하는 대신, 코드가 정확히 원하는 대로 동작하도록 NanoClaw를 커스터마이즈해야 합니다. 설정 파일을 선호한다면 Claude에게 추가해 달라고 할 수 있습니다.
**서드파티나 오픈소스 모델을 사용할 수 있나요?**
네. 지원되는 경로는 `/add-opencode`(OpenCode 설정을 통한 OpenRouter, OpenAI, Google, DeepSeek 등) 또는 `/add-ollama-provider`(Ollama를 통한 로컬 오픈 웨이트 모델)입니다. 둘 다 에이전트 그룹별로 설정할 수 있으므로, 같은 설치 내에서 서로 다른 에이전트가 서로 다른 백엔드에서 실행될 수 있습니다.
일회성 실험의 경우, Claude API 호환 엔드포인트라면 `.env`를 통해서도 작동합니다:
```bash
ANTHROPIC_BASE_URL=https://your-api-endpoint.com
ANTHROPIC_AUTH_TOKEN=your-token-here
```
**문제를 어떻게 디버깅하나요?**
Claude Code에게 물어보세요. "스케줄러가 왜 실행되지 않지?" "최근 로그에 뭐가 있지?" "이 메시지는 왜 응답을 받지 못했지?" 그것이 NanoClaw의 바탕에 깔린 AI 네이티브 접근 방식입니다.
**설정이 왜 작동하지 않나요?**
어떤 단계가 실패하면 `nanoclaw.sh`는 진단하고 재개하기 위해 Claude Code로 넘깁니다. 그래도 해결되지 않으면 `claude`를 실행한 뒤 `/debug`를 실행하세요. Claude가 다른 사용자에게도 영향을 줄 만한 문제를 발견하면, 관련 설정 단계나 스킬에 대한 PR을 열어주세요.
**NanoClaw를 어떻게 제거하나요?**
```bash
bash nanoclaw.sh --uninstall
```
모든 설치는 체크아웃별 ID로 태깅되므로, 제거 프로그램은 해당 사본에 속한 것만 제거합니다: 백그라운드 서비스, 컨테이너와 이미지, 앱 데이터와 로그, 에이전트 파일, 그리고 이 사본의 OneCLI 볼트 에이전트입니다. 공유되는 것 — OneCLI 앱과 여러분의 자격 증명, 머신의 다른 NanoClaw 사본 — 은 그대로 둡니다. 무엇을 발견했는지 정확히 보여주고 그룹별로 확인을 요청합니다. 여러분이 동의하기 전까지는 아무것도 삭제되지 않습니다. 변경 없이 미리 보려면 `--dry-run`을, 프롬프트를 건너뛰려면 `--yes`를 사용하세요. `.env`는 제거 전에 백업됩니다. 마무리하려면 체크아웃 폴더 자체를 삭제하세요.
**어떤 변경이 코드베이스에 받아들여지나요?**
기본 구성에는 보안 수정, 버그 수정, 명확한 개선만 받아들여집니다. 그게 전부입니다.
그 외의 모든 것(새로운 기능, OS 호환성, 하드웨어 지원, 향상)은 스킬로 기여해야 합니다. 채널과 프로바이더 코드는 `channels`/`providers` 레지스트리 브랜치에, 그 외에는 자체 완결형 스킬로 기여합니다. [docs/customizing.md](docs/customizing.md)와 [CONTRIBUTING.md](CONTRIBUTING.md)를 참고하세요.
이를 통해 기본 시스템을 최소한으로 유지하고, 모든 사용자가 원하지 않는 기능을 떠안지 않으면서 자신의 설치를 커스터마이즈할 수 있습니다.
## 커뮤니티
질문이 있나요? 아이디어가 있나요? [Discord에 참여하세요](https://discord.gg/VDdww8qS42).
## 변경 이력
호환성을 깨는 변경 사항은 [CHANGELOG.md](CHANGELOG.md)를, 또는 문서 사이트의 [전체 릴리스 히스토리](https://docs.nanoclaw.dev/changelog)를 참고하세요.
## 라이선스
MIT
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=47894bd5-353b-42fe-bb97-74144e6df0bf" />
-1
View File
@@ -11,7 +11,6 @@
<a href="https://docs.nanoclaw.dev">文档</a>&nbsp; • &nbsp;
<a href="README.md">English</a>&nbsp; • &nbsp;
<a href="README_ja.md">日本語</a>&nbsp; • &nbsp;
<a href="README_ko.md">한국어</a>&nbsp; • &nbsp;
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>&nbsp; • &nbsp;
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
</p>
+1 -60
View File
@@ -4,9 +4,8 @@ import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from '
import { getPendingMessages, markCompleted } from './db/messages-in.js';
import { getUndeliveredMessages } from './db/messages-out.js';
import { formatMessages, extractRouting } from './formatter.js';
import { isCorruptionError, processQuery } from './poll-loop.js';
import { isCorruptionError } from './poll-loop.js';
import { MockProvider } from './providers/mock.js';
import type { AgentQuery, ProviderEvent } from './providers/types.js';
beforeEach(() => {
initTestSessionDb();
@@ -380,64 +379,6 @@ describe('end-to-end with mock provider', () => {
});
});
/**
* Build a one-shot stub query that yields init + a single result event, then
* ends. `pushes` records any follow-ups the loop tried to inject (e.g. the
* re-wrap nudge), so a test can assert the loop did NOT re-hammer.
*/
function makeResultQuery(result: ProviderEvent): { query: AgentQuery; pushes: string[] } {
const pushes: string[] = [];
async function* events(): AsyncGenerator<ProviderEvent> {
yield { type: 'init', continuation: 'sess-1' };
yield result;
}
return {
pushes,
query: {
push: (m: string) => {
pushes.push(m);
},
end: () => {},
events: events(),
abort: () => {},
},
};
}
const ERR_ROUTING = {
platformId: 'chan-1',
channelType: 'discord',
threadId: null,
inReplyTo: 'm1',
};
describe('error result with no <message> envelope', () => {
it('delivers a budget/billing error to the triggering channel and does not nudge', async () => {
const budgetText = 'Spending limit reached. Add your own key at https://example.com/keys';
const { query, pushes } = makeResultQuery({ type: 'result', text: budgetText, isError: true });
await processQuery(query, ERR_ROUTING, ['m1'], 'claude', undefined, 'prompt', undefined);
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(JSON.parse(out[0].content).text).toBe(budgetText);
expect(out[0].platform_id).toBe('chan-1');
expect(out[0].channel_type).toBe('discord');
// No re-wrap nudge — an error result must not re-hammer the gateway.
expect(pushes).toHaveLength(0);
});
it('still nudges (and does not deliver) a normal unwrapped result', async () => {
const { query, pushes } = makeResultQuery({ type: 'result', text: 'bare text, no envelope' });
await processQuery(query, ERR_ROUTING, ['m1'], 'claude', undefined, 'prompt', undefined);
expect(getUndeliveredMessages()).toHaveLength(0);
expect(pushes).toHaveLength(1);
expect(pushes[0]).toContain('was not delivered');
});
});
describe('isCorruptionError', () => {
it('matches the Docker Desktop macOS torn-read symptom', () => {
expect(isCorruptionError('database disk image is malformed')).toBe(true);
+22 -57
View File
@@ -323,7 +323,7 @@ interface QueryResult {
continuation?: string;
}
export async function processQuery(
async function processQuery(
query: AgentQuery,
routing: RoutingContext,
initialBatchIds: string[],
@@ -482,43 +482,28 @@ export async function processQuery(
// at all — either way the turn is finished.
markCompleted(initialBatchIds);
if (event.text) {
const { sent, hasUnwrapped } = dispatchResultText(event.text, routing);
if (sent === 0 && event.isError === true) {
// Non-retryable error turn (e.g. a 403 billing_error) with no
// <message> envelope: deliver the notice instead of dropping it as
// scratchpad, and skip the re-wrap nudge — it would just re-hammer
// the failing gateway turn after turn.
deliverErrorResult(event.text, routing);
notifyExchangeComplete(onExchangeComplete, {
prompt: archivePrompts[0] ?? initialPrompt,
result: event.text,
continuation: queryContinuation ?? initialContinuation,
status: 'error',
});
archivePrompts.shift();
} else {
const willRetryWrapping = hasUnwrapped && !unwrappedNudged;
notifyExchangeComplete(onExchangeComplete, {
prompt: archivePrompts[0] ?? initialPrompt,
result: event.text,
continuation: queryContinuation ?? initialContinuation,
status: hasUnwrapped ? 'undelivered' : 'completed',
});
if (willRetryWrapping) {
unwrappedNudged = true;
const destinations = getAllDestinations();
const names = destinations.map((d) => d.name).join(', ');
query.push(
`<system>Your response was not delivered — it was not wrapped in <message to="name">...</message> blocks. ` +
`All output must be wrapped: use <message to="name"> for content to send, or <internal> for scratchpad. ` +
`Your destinations: ${names}. ` +
`Please re-send your response with the correct wrapping.</system>`,
);
}
// The wrapping-retry result answers the SAME user prompt — keep it
// queued so the retry archives against it, not the nudge text.
if (!willRetryWrapping) archivePrompts.shift();
const { hasUnwrapped } = dispatchResultText(event.text, routing);
const willRetryWrapping = hasUnwrapped && !unwrappedNudged;
notifyExchangeComplete(onExchangeComplete, {
prompt: archivePrompts[0] ?? initialPrompt,
result: event.text,
continuation: queryContinuation ?? initialContinuation,
status: hasUnwrapped ? 'undelivered' : 'completed',
});
if (willRetryWrapping) {
unwrappedNudged = true;
const destinations = getAllDestinations();
const names = destinations.map((d) => d.name).join(', ');
query.push(
`<system>Your response was not delivered — it was not wrapped in <message to="name">...</message> blocks. ` +
`All output must be wrapped: use <message to="name"> for content to send, or <internal> for scratchpad. ` +
`Your destinations: ${names}. ` +
`Please re-send your response with the correct wrapping.</system>`,
);
}
// The wrapping-retry result answers the SAME user prompt — keep it
// queued so the retry archives against it, not the nudge text.
if (!willRetryWrapping) archivePrompts.shift();
} else {
archivePrompts.shift();
}
@@ -572,26 +557,6 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
}
}
/**
* Deliver a turn's text straight to the channel the batch arrived on. Used when
* a turn ends in a provider error (e.g. a non-retryable 403 billing_error) with
* no <message> envelope: the notice would otherwise be dropped as scratchpad.
* This is the same user-facing write the outer catch block does, minus the
* `Error:` prefix — the provider's text is already a user-facing message.
*/
function deliverErrorResult(text: string, routing: RoutingContext): void {
log('Error result with no <message> envelope — delivering to channel');
writeMessageOut({
id: generateId(),
in_reply_to: routing.inReplyTo,
kind: 'chat',
platform_id: routing.platformId,
channel_type: routing.channelType,
thread_id: routing.threadId,
content: JSON.stringify({ text }),
});
}
/**
* Parse the agent's final text for <message to="name">...</message> blocks
* and dispatch each one to its resolved destination. Text outside of blocks
@@ -440,13 +440,8 @@ export class ClaudeProvider implements AgentProvider {
if (message.type === 'system' && message.subtype === 'init') {
yield { type: 'init', continuation: message.session_id };
} else if (message.type === 'result') {
// `result` text exists only on subtype:"success"; error subtypes
// (e.g. a non-retryable 403 billing_error) carry their message in
// `errors[]` instead. Surface either so the poll-loop can deliver a
// billing/quota notice to the user rather than dropping the turn.
const m = message as { result?: string; is_error?: boolean; errors?: string[] };
const text = m.result ?? (m.errors && m.errors.length > 0 ? m.errors.join('\n') : null);
yield { type: 'result', text, isError: m.is_error === true };
const text = 'result' in message ? (message as { result?: string }).result ?? null : null;
yield { type: 'result', text };
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'api_retry') {
yield { type: 'error', message: 'API retry', retryable: true };
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'rate_limit_event') {
@@ -125,13 +125,7 @@ export interface AgentQuery {
export type ProviderEvent =
| { type: 'init'; continuation: string }
/**
* A completed turn. `isError` is set when the underlying SDK flagged the
* turn as an error (e.g. a non-retryable Anthropic 403 billing_error). The
* poll-loop uses it to surface the result text to the user instead of
* dropping it as un-wrapped scratchpad, and to skip the re-wrap nudge.
*/
| { type: 'result'; text: string | null; isError?: boolean }
| { type: 'result'; text: string | null }
| { type: 'error'; message: string; retryable: boolean; classification?: string }
| { type: 'progress'; message: string }
/**
+1 -1
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.1.18",
"version": "2.1.16",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="196k tokens, 98% of context window">
<title>196k tokens, 98% of context window</title>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="195k tokens, 98% of context window">
<title>195k tokens, 98% of context window</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,8 +15,8 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
<text x="26" y="14">tokens</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">196k</text>
<text x="71" y="14">196k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">195k</text>
<text x="71" y="14">195k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+226
View File
@@ -0,0 +1,226 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { applySkill, removeSkill, planSkill, type Prompter } from './skill-apply.js';
// A synthetic skill exercising the fs handlers for real (no network), plus one
// directive the engine can't handle — to prove it bounces to an agent, not abort.
const SKILL = `# demo skill
## Copy the file
\`\`\`nc:copy
resources/sample.ts -> src/sample.ts
\`\`\`
## Register it
\`\`\`nc:append to:src/barrel.ts
import './sample.js';
\`\`\`
## Capture and store a secret
\`\`\`nc:prompt token secret
Paste the demo token.
\`\`\`
\`\`\`nc:env-set
DEMO_TOKEN={{token}}
\`\`\`
## A step the engine can't do deterministically
Hand-edit the scheduler to register the demo hook.
\`\`\`nc:patch-scheduler
register demo
\`\`\`
`;
let root: string;
let skillDir: string;
const headless = (vals: Record<string, string>): Prompter => ({ async ask(name) { return vals[name]; } });
const recordingExec = () => {
const cmds: string[] = [];
return { cmds, exec: (c: string) => void cmds.push(c) };
};
beforeEach(() => {
skillDir = mkdtempSync(join(tmpdir(), 'nc-skill-'));
root = mkdtempSync(join(tmpdir(), 'nc-proj-'));
mkdirSync(join(skillDir, 'resources'), { recursive: true });
writeFileSync(join(skillDir, 'SKILL.md'), SKILL);
writeFileSync(join(skillDir, 'resources/sample.ts'), 'export const sample = true;\n');
mkdirSync(join(root, 'src'), { recursive: true });
writeFileSync(join(root, 'src/barrel.ts'), '// channel barrel\n');
writeFileSync(join(root, '.env'), '');
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
});
describe('apply engine lifecycle', () => {
it('applies fs directives, captures the secret, and bounces the unknown step to an agent', async () => {
const { exec } = recordingExec();
const res = await applySkill(skillDir, root, { prompter: headless({ token: 'sekret-123' }), exec });
// mutations happened
expect(existsSync(join(root, 'src/sample.ts'))).toBe(true);
expect(readFileSync(join(root, 'src/barrel.ts'), 'utf8')).toContain("import './sample.js';");
expect(readFileSync(join(root, '.env'), 'utf8')).toContain('DEMO_TOKEN=sekret-123');
// the unknown directive went to an agent — with prose — not the human, not an abort
expect(res.agentTasks).toHaveLength(1);
expect(res.agentTasks[0].kind).toBe('patch-scheduler');
expect(res.agentTasks[0].prose).toContain('Hand-edit the scheduler');
expect(res.deferred).toEqual([]);
expect(res.journal.length).toBeGreaterThanOrEqual(3); // wrote + appended + set-env
});
it('is idempotent — a second apply changes nothing', async () => {
const p = headless({ token: 'sekret-123' });
await applySkill(skillDir, root, { prompter: p, exec: () => {} });
const second = await applySkill(skillDir, root, { prompter: p, exec: () => {} });
expect(second.applied).toEqual([]); // everything already applied
expect(second.journal).toEqual([]); // nothing mutated
expect(second.skipped.length).toBeGreaterThanOrEqual(3);
});
it('removes cleanly from the journal — no hand-written REMOVE.md', async () => {
const res = await applySkill(skillDir, root, { prompter: headless({ token: 'sekret-123' }), exec: () => {} });
await removeSkill(root, res.journal);
expect(existsSync(join(root, 'src/sample.ts'))).toBe(false);
expect(readFileSync(join(root, 'src/barrel.ts'), 'utf8')).not.toContain("import './sample.js';");
expect(readFileSync(join(root, '.env'), 'utf8')).not.toContain('DEMO_TOKEN');
});
it('defers a prompt (and its consumer) when the prompter has no value — headless rebuild', async () => {
const res = await applySkill(skillDir, root, { prompter: headless({}), exec: () => {} });
expect(res.deferred).toContain('token'); // prompt deferred
expect(res.deferred.some((d) => /unresolved \{\{token\}\}/.test(d))).toBe(true); // env-set blocked on it
expect(readFileSync(join(root, '.env'), 'utf8')).not.toContain('DEMO_TOKEN');
});
it('plan marks the unknown step ↳agent and the prompt ? needs-input before any write', () => {
const { steps, agentSteps, needsInput } = planSkill(skillDir, root);
expect(agentSteps).toBe(1);
expect(needsInput).toContain('token');
expect(existsSync(join(root, 'src/sample.ts'))).toBe(false); // planning mutated nothing
});
});
// json-merge: push a body object into an array-of-objects JSON file, keyed.
const JSON_MERGE_SKILL = `# json-merge demo
## Register the CLI tool
\`\`\`nc:json-merge into:container/cli-tools.json key:name
{ "name": "@openai/codex", "version": "0.138.0" }
\`\`\`
`;
describe('json-merge directive', () => {
let jroot: string;
let jskill: string;
beforeEach(() => {
jskill = mkdtempSync(join(tmpdir(), 'nc-skill-'));
jroot = mkdtempSync(join(tmpdir(), 'nc-proj-'));
writeFileSync(join(jskill, 'SKILL.md'), JSON_MERGE_SKILL);
mkdirSync(join(jroot, 'container'), { recursive: true });
writeFileSync(join(jroot, 'container/cli-tools.json'), '[\n { "name": "vercel", "version": "52.2.1" }\n]\n');
});
it('pushes the object, preserving 2-space indent + trailing newline', async () => {
const res = await applySkill(jskill, jroot, { prompter: headless({}), exec: () => {} });
const out = readFileSync(join(jroot, 'container/cli-tools.json'), 'utf8');
expect(out.endsWith('\n')).toBe(true);
const arr = JSON.parse(out);
expect(arr).toEqual([
{ name: 'vercel', version: '52.2.1' },
{ name: '@openai/codex', version: '0.138.0' },
]);
expect(out).toBe(JSON.stringify(arr, null, 2) + '\n'); // 2-space indent
expect(res.journal.some((e) => e.op === 'json-merge')).toBe(true);
});
it('is idempotent — re-applying does not duplicate the element', async () => {
await applySkill(jskill, jroot, { prompter: headless({}), exec: () => {} });
const second = await applySkill(jskill, jroot, { prompter: headless({}), exec: () => {} });
expect(second.applied).toEqual([]);
expect(second.skipped.length).toBe(1);
const arr = JSON.parse(readFileSync(join(jroot, 'container/cli-tools.json'), 'utf8'));
expect(arr.filter((e: { name: string }) => e.name === '@openai/codex')).toHaveLength(1);
});
it('removeSkill drops the element whose key matches', async () => {
const res = await applySkill(jskill, jroot, { prompter: headless({}), exec: () => {} });
await removeSkill(jroot, res.journal);
const arr = JSON.parse(readFileSync(join(jroot, 'container/cli-tools.json'), 'utf8'));
expect(arr).toEqual([{ name: 'vercel', version: '52.2.1' }]);
});
it('plan marks it →apply when absent, ✓skip when present', () => {
const before = planSkill(jskill, jroot);
expect(before.steps[0].status).toBe('apply');
// simulate already-merged
writeFileSync(
join(jroot, 'container/cli-tools.json'),
JSON.stringify([{ name: '@openai/codex', version: '0.138.0' }], null, 2) + '\n',
);
const after = planSkill(jskill, jroot);
expect(after.steps[0].status).toBe('skip');
});
});
// append at:<marker>: insert before a dormant region's closing line.
const MARKER_FILE = ['const STEPS = {', " auth: () => import('./auth.js'),", ' // >>> nanoclaw:setup-steps', ' // <<< nanoclaw:setup-steps', '};', ''].join('\n');
const APPEND_AT_SKILL = `# append-at demo
## Register a setup step
\`\`\`nc:append to:setup/index.ts at:nanoclaw:setup-steps
codex: () => import('./codex.js'),
\`\`\`
`;
const APPEND_EOF_SKILL = `# append-eof demo
## Register at EOF
\`\`\`nc:append to:setup/index.ts
// trailing line
\`\`\`
`;
describe('append at:<marker>', () => {
let aroot: string;
let askill: string;
beforeEach(() => {
askill = mkdtempSync(join(tmpdir(), 'nc-skill-'));
aroot = mkdtempSync(join(tmpdir(), 'nc-proj-'));
mkdirSync(join(aroot, 'setup'), { recursive: true });
writeFileSync(join(aroot, 'setup/index.ts'), MARKER_FILE);
});
it('inserts before the `<<< marker` line, matching its indentation', async () => {
writeFileSync(join(askill, 'SKILL.md'), APPEND_AT_SKILL);
await applySkill(askill, aroot, { prompter: headless({}), exec: () => {} });
const out = readFileSync(join(aroot, 'setup/index.ts'), 'utf8').split('\n');
const closeIdx = out.findIndex((l) => l.includes('<<< nanoclaw:setup-steps'));
expect(out[closeIdx - 1]).toBe(" codex: () => import('./codex.js'),"); // inserted just above, 2-space indent
expect(out[closeIdx - 2]).toContain('>>> nanoclaw:setup-steps'); // open marker untouched
});
it('is idempotent (whole-file line check) regardless of position', async () => {
writeFileSync(join(askill, 'SKILL.md'), APPEND_AT_SKILL);
await applySkill(askill, aroot, { prompter: headless({}), exec: () => {} });
const second = await applySkill(askill, aroot, { prompter: headless({}), exec: () => {} });
expect(second.applied).toEqual([]);
const count = readFileSync(join(aroot, 'setup/index.ts'), 'utf8').split('\n').filter((l) => l.trim() === "codex: () => import('./codex.js'),").length;
expect(count).toBe(1);
});
it('removeSkill deletes the inserted line (position-agnostic, by trimmed line)', async () => {
writeFileSync(join(askill, 'SKILL.md'), APPEND_AT_SKILL);
const res = await applySkill(askill, aroot, { prompter: headless({}), exec: () => {} });
await removeSkill(aroot, res.journal);
expect(readFileSync(join(aroot, 'setup/index.ts'), 'utf8')).not.toContain("codex: () => import('./codex.js'),");
});
it('without at: still appends at EOF (unchanged behavior)', async () => {
writeFileSync(join(askill, 'SKILL.md'), APPEND_EOF_SKILL);
await applySkill(askill, aroot, { prompter: headless({}), exec: () => {} });
const lines = readFileSync(join(aroot, 'setup/index.ts'), 'utf8').split('\n').filter(Boolean);
expect(lines[lines.length - 1]).toBe('// trailing line'); // at EOF, not before the marker
});
});
+407
View File
@@ -0,0 +1,407 @@
// 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 inherently-human prose (e.g. clicking through Slack).
//
// 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 the injected Prompter
// 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.
//
// The Prompter is what makes one engine serve two contexts:
// • setup flow → interactive prompter asks the user inline
// • recipe rebuild → headless prompter returns from a values map, or defers
//
// 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';
export interface Prompter {
// Return the value, or undefined to DEFER (headless rebuild collects these).
ask(varName: string, question: string, secret: boolean): Promise<string | undefined>;
}
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 'env-sync':
return { status: 'apply', detail: 'sync .env → data/env/env' };
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: '' };
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 35) + 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
journal: JournalEntry[];
}
export interface ApplyOptions {
prompter: Prompter;
exec?: (cmd: string) => void | Promise<void>; // dep/run/branch-fetch; injectable for tests
// 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;
}
// 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();
}
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;
});
}
// 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) => void | Promise<void>; 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':
for (const cmd of d.body) {
await exec(cmd);
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 'env-sync':
mkdirSync(join(root, 'data/env'), { recursive: true });
copyFileSync(join(root, '.env'), join(root, 'data/env/env'));
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: [], journal: [] };
const bounce = (d: Directive, reason: string) => res.agentTasks.push({ kind: d.kind, line: d.line, reason, prose: proseFor(md, d.line) });
for (const d of directives) {
try {
if (d.kind === 'prompt') {
const v = promptVar(d)!;
const val = await opts.prompter.ask(v, d.body.join(' '), d.args.includes('secret'));
if (val === undefined) res.deferred.push(v);
else vars.set(v, { value: val, secret: d.args.includes('secret') });
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; }
await applyOne(d, { root, skillDir, exec, resolveRemote, vars, journal: res.journal });
res.applied.push(`${d.kind}: ${st.detail}`);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
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`);
}
}
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}`);
}
+155
View File
@@ -0,0 +1,155 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { parseDirectives, validate, promptVar, resolveChatCoreVersion } 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 the apply + credential directives in document order', () => {
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: capture bot token
'prompt', // credentials: capture signing secret
'env-set', // credentials: write captured values to .env
'env-sync', // credentials: sync to container
]);
});
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.26.0']);
});
it('tags the runs with their effects', () => {
expect(directives.filter((d) => d.kind === 'run').map((d) => d.attrs.effect)).toEqual(['build', 'test']);
});
it('captures each prompt into a named, secret variable — no destination baked in', () => {
const prompts = directives.filter((d) => d.kind === 'prompt');
expect(prompts.map(promptVar)).toEqual(['bot_token', 'signing_secret']);
for (const p of prompts) expect(p.args).toContain('secret');
// The prompt body is the question; it does not mention env at all.
expect(prompts[0].body.join(' ')).toMatch(/Bot User OAuth Token/);
});
it('wires the captured variables into env-set via {{var}} references', () => {
const envSet = directives.find((d) => d.kind === 'env-set')!;
expect(envSet.body).toEqual(['SLACK_BOT_TOKEN={{bot_token}}', 'SLACK_SIGNING_SECRET={{signing_secret}}']);
});
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([]);
});
});
+206
View File
@@ -0,0 +1,206 @@
// 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] body: shell command(s) re-runnable
// prompt <var> [secret] body: the question → binds {{var}} skip if satisfied
// env-set body: `KEY=value` ({{var}} allowed) set-if-absent
// env-sync (no body) `.env` → data/env/env idempotent copy
// 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).
//
// 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', 'env-set', 'env-sync', 'json-merge']);
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));
}
/** `{{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 (!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');
break;
}
// A consumer can only reference a variable an earlier prompt captured.
for (const ref of referencedVars(d)) {
if (!defined.has(ref)) flag(d, `references {{${ref}}} but no earlier nc:prompt captured it`);
}
if (d.kind === 'prompt') {
const v = promptVar(d);
if (v) defined.add(v);
}
}
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 directives = parseDirectives(readFileSync(path, 'utf8'));
const problems = validate(directives, { chatVersion: resolveChatCoreVersion(process.cwd()) });
console.log(JSON.stringify({ directives, problems }, null, 2));
process.exit(problems.length ? 1 : 0);
}
-121
View File
@@ -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
-130
View File
@@ -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.26.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
-160
View File
@@ -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
-125
View File
@@ -1,125 +0,0 @@
#!/usr/bin/env bash
#
# Install the Slack adapter, persist SLACK_BOT_TOKEN + SLACK_SIGNING_SECRET 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, 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.26.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
if [ -z "${SLACK_SIGNING_SECRET:-}" ]; then
emit_status failed "SLACK_SIGNING_SECRET env var not set"
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"
upsert_env SLACK_SIGNING_SECRET "$SLACK_SIGNING_SECRET"
# 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
-139
View File
@@ -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.26.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
-164
View File
@@ -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.26.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
+29 -17
View File
@@ -39,6 +39,7 @@ import { runTelegramChannel } from './channels/telegram.js';
import { runWhatsAppChannel } from './channels/whatsapp.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';
@@ -342,26 +343,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`);
@@ -801,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;
@@ -810,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 })),
+97 -22
View File
@@ -13,7 +13,8 @@
* 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)
* 7. Apply the /add-discord skill via the directive engine (the skill's
* SKILL.md is the single source of truth) + restart the service
* 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
@@ -23,9 +24,12 @@
* entries in logs/setup.log, full raw output in per-step files under
* logs/setup-steps/. See docs/setup-flow.md.
*/
import { execSync } from 'node:child_process';
import * as p from '@clack/prompts';
import k from 'kleur';
import { applySkill, type Prompter } from '../../scripts/skill-apply.js';
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';
@@ -76,31 +80,12 @@ export async function runDiscordChannel(displayName: string): Promise<ChannelFlo
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,
},
},
);
const install = await applyDiscordSkill(token, app, botUsername);
if (!install.ok) {
await fail(
'discord-install',
"Couldn't connect Discord.",
'See logs/setup-steps/ for details, then retry setup.',
install.detail || 'See logs/setup-steps/ for details, then retry setup.',
);
}
@@ -145,6 +130,96 @@ export async function runDiscordChannel(displayName: string): Promise<ChannelFlo
}
}
/**
* Install the Discord adapter and persist credentials by applying the
* `/add-discord` skill through the structured-directive engine. The skill's
* SKILL.md is the single source of truth this replaces the hand-maintained
* setup/add-discord.sh, which had already drifted into a parallel copy of the
* pinned adapter version and install steps.
*
* The three credentials collected/derived above (bot token, application ID,
* public key) are handed to the skill's `prompt` directives through the
* in-process Prompter, so they never touch argv or disk en route. The engine
* runs copy/append/dep/build + env-set/env-sync; we restart the service after
* (the skill itself doesn't, by design). add-discord is fully deterministic and
* all three values are supplied, so a healthy apply leaves nothing for an agent
* and nothing deferred either bucket being non-empty means the install failed.
*/
async function applyDiscordSkill(
token: string,
app: AppInfo,
botUsername: string,
): Promise<{ ok: boolean; detail: string }> {
const projectRoot = process.cwd();
const s = p.spinner();
const start = Date.now();
s.start(`Connecting Discord to @${botUsername}`);
const prompter: Prompter = {
async ask(name) {
if (name === 'bot_token') return token;
if (name === 'application_id') return app.applicationId;
if (name === 'public_key') return app.publicKey;
return undefined;
},
};
try {
const result = await applySkill('.claude/skills/add-discord', projectRoot, {
prompter,
exec: (cmd) => {
execSync(cmd, { cwd: projectRoot, stdio: 'pipe' });
},
// Fork-aware: reuse the existing resolver (handles upstream/fork remotes
// and the auto-add-upstream fallback) instead of assuming `origin`.
resolveRemote: () =>
execSync('source setup/lib/channels-remote.sh; resolve_channels_remote', {
cwd: projectRoot,
shell: '/bin/bash',
encoding: 'utf8',
}).trim(),
});
if (result.agentTasks.length || result.deferred.length) {
const why = [...result.agentTasks.map((t) => t.reason), ...result.deferred].join('; ');
s.stop("Couldn't finish installing Discord.", 1);
setupLog.step('discord-install', 'failed', Date.now() - start, { ERROR: why });
return { ok: false, detail: why };
}
restartService(projectRoot);
s.stop('Discord adapter installed.');
setupLog.step('discord-install', 'success', Date.now() - start, {
APPLIED: String(result.applied.length),
SKIPPED: String(result.skipped.length),
BOT_USERNAME: botUsername,
APPLICATION_ID: app.applicationId,
});
return { ok: true, detail: '' };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
s.stop("Couldn't install the Discord adapter.", 1);
setupLog.step('discord-install', 'failed', Date.now() - start, { ERROR: message });
return { ok: false, detail: 'See logs/setup-steps/ for details, then retry setup.' };
}
}
/** Best-effort service restart so the new adapter + credentials take effect. */
function restartService(projectRoot: string): void {
const script = [
`source "${projectRoot}/setup/lib/install-slug.sh"`,
'case "$(uname -s)" in',
' Darwin) launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" ;;',
' Linux) systemctl --user restart "$(systemd_unit)" || sudo systemctl restart "$(systemd_unit)" ;;',
'esac',
].join('\n');
try {
execSync(script, { cwd: projectRoot, stdio: 'pipe', shell: '/bin/bash' });
} catch {
// The service may not be installed yet during a fresh setup — best-effort.
}
}
async function askHasBotToken(): Promise<'yes' | 'no' | 'back'> {
const answer = ensureAnswer(
await brightSelect({
+131 -26
View File
@@ -19,26 +19,29 @@
* 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)
* 4. Install the adapter by applying the /add-imessage skill in-process
* (SKILL.md is the single source of truth) + restart the service
* 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 fs from 'fs';
import os from 'os';
import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import { applySkill, type Prompter } from '../../scripts/skill-apply.js';
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';
import { readEnvKey, upsertEnvKey } from '../environment.js';
const DEFAULT_AGENT_NAME = 'Nano';
@@ -71,34 +74,12 @@ export async function runIMessageChannel(displayName: string): Promise<ChannelFl
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 },
},
);
const install = await applyIMessageSkill(mode, remoteCreds);
if (!install.ok) {
await fail(
'imessage-install',
"Couldn't install the iMessage adapter.",
'See logs/setup-steps/ for details, then retry setup.',
install.detail || 'See logs/setup-steps/ for details, then retry setup.',
);
}
@@ -141,6 +122,130 @@ export async function runIMessageChannel(displayName: string): Promise<ChannelFl
}
}
/**
* Install the iMessage adapter and persist mode/creds by applying the
* `/add-imessage` skill through the structured-directive engine. The skill's
* SKILL.md is the single source of truth this replaces the hand-maintained
* setup/add-imessage.sh.
*
* The skill's deterministic directives cover copy/append/dep/build/env-sync,
* but the mode-specific `.env` keys live in the skill's prose (the engine has no
* `prompt` directive for them, and the keys differ by mode). So this flow writes
* the canonical keys for the chosen mode and strips the opposite mode's keys
* before applying the skill; the skill's `nc:env-sync` directive then copies
* `.env` `data/env/env`. We restart the service after (the skill itself
* doesn't, by design).
*
* add-imessage has no `prompt` directives, so the Prompter is a no-op
* pass-through. A healthy apply leaves nothing for an agent and nothing
* deferred either bucket being non-empty means the install failed.
*/
async function applyIMessageSkill(
mode: Mode,
remoteCreds: RemoteCreds | null,
): Promise<{ ok: boolean; detail: string }> {
const projectRoot = process.cwd();
const s = p.spinner();
const start = Date.now();
s.start(
mode === 'local'
? 'Connecting the iMessage adapter to this Mac…'
: `Connecting the iMessage adapter to ${remoteCreds!.serverUrl}`,
);
// The mode keys are prose in SKILL.md — write them before applySkill so the
// skill's nc:env-sync picks them up. Strip the opposite mode's keys so a stale
// value can't confuse the adapter's factory.
if (mode === 'local') {
upsertEnvKey('IMESSAGE_LOCAL', 'true', projectRoot);
upsertEnvKey('IMESSAGE_ENABLED', 'true', projectRoot);
removeEnvKey('IMESSAGE_SERVER_URL', projectRoot);
removeEnvKey('IMESSAGE_API_KEY', projectRoot);
} else {
upsertEnvKey('IMESSAGE_LOCAL', 'false', projectRoot);
upsertEnvKey('IMESSAGE_SERVER_URL', remoteCreds!.serverUrl, projectRoot);
upsertEnvKey('IMESSAGE_API_KEY', remoteCreds!.apiKey, projectRoot);
removeEnvKey('IMESSAGE_ENABLED', projectRoot);
}
// add-imessage has no prompt vars; this satisfies the Prompter contract
// without ever asking the human (and never returns a value to defer on).
const prompter: Prompter = {
async ask() {
return undefined;
},
};
try {
const result = await applySkill('.claude/skills/add-imessage', projectRoot, {
prompter,
exec: (cmd) => {
execSync(cmd, { cwd: projectRoot, stdio: 'pipe' });
},
// Fork-aware: reuse the existing resolver (handles upstream/fork remotes
// and the auto-add-upstream fallback) instead of assuming `origin`.
resolveRemote: () =>
execSync('source setup/lib/channels-remote.sh; resolve_channels_remote', {
cwd: projectRoot,
shell: '/bin/bash',
encoding: 'utf8',
}).trim(),
});
if (result.agentTasks.length || result.deferred.length) {
const why = [...result.agentTasks.map((t) => t.reason), ...result.deferred].join('; ');
s.stop("Couldn't finish installing iMessage.", 1);
setupLog.step('imessage-install', 'failed', Date.now() - start, { ERROR: why });
return { ok: false, detail: why };
}
restartService(projectRoot);
s.stop('iMessage adapter installed.');
setupLog.step('imessage-install', 'success', Date.now() - start, {
APPLIED: String(result.applied.length),
SKIPPED: String(result.skipped.length),
MODE: mode,
});
return { ok: true, detail: '' };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
s.stop("Couldn't install the iMessage adapter.", 1);
setupLog.step('imessage-install', 'failed', Date.now() - start, { ERROR: message });
return { ok: false, detail: 'See logs/setup-steps/ for details, then retry setup.' };
}
}
/** Remove a single `KEY=…` line from `.env` (no-op if absent). */
function removeEnvKey(key: string, projectRoot: string): void {
const envPath = path.join(projectRoot, '.env');
let content: string;
try {
content = fs.readFileSync(envPath, 'utf-8');
} catch {
return; // no .env yet — nothing to remove
}
const kept = content
.split('\n')
.filter((l) => !l.trim().startsWith(`${key}=`));
fs.writeFileSync(envPath, kept.join('\n'));
}
/** Best-effort service restart so the new adapter + creds take effect. */
function restartService(projectRoot: string): void {
const script = [
`source "${projectRoot}/setup/lib/install-slug.sh"`,
'case "$(uname -s)" in',
' Darwin) launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" ;;',
' Linux) systemctl --user restart "$(systemd_unit)" || sudo systemctl restart "$(systemd_unit)" ;;',
'esac',
].join('\n');
try {
execSync(script, { cwd: projectRoot, stdio: 'pipe', shell: '/bin/bash' });
} catch {
// The service may not be installed yet during a fresh setup — best-effort.
}
}
async function askMode(isMac: boolean): Promise<Mode | 'back'> {
const baseOptions = isMac
? [
+95 -22
View File
@@ -8,7 +8,8 @@
* event subscriptions, and signing secret
* 2. Paste the bot token + signing secret (clack password prompts)
* 3. Validate via auth.test resolves workspace + bot identity
* 4. Install the adapter (setup/add-slack.sh, non-interactive)
* 4. Apply the /add-slack skill via the directive engine (the skill's
* SKILL.md is the single source of truth) + restart the service
* 5. Ask for the operator's Slack user ID
* 6. conversations.open to get the DM channel ID
* 7. Ask for the messaging-agent name (defaulting to "Nano")
@@ -21,9 +22,12 @@
*
* All output obeys the three-level contract. See docs/setup-flow.md.
*/
import { execSync } from 'node:child_process';
import * as p from '@clack/prompts';
import k from 'kleur';
import { applySkill, type Prompter } from '../../scripts/skill-apply.js';
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';
@@ -53,31 +57,12 @@ export async function runSlackChannel(displayName: string): Promise<ChannelFlowR
const signingSecret = await collectSigningSecret();
const info = await validateSlackToken(token);
const install = await runQuietChild(
'slack-install',
'bash',
['setup/add-slack.sh'],
{
running: `Connecting Slack to @${info.botName} (${info.teamName})…`,
done: 'Slack adapter installed.',
},
{
env: {
SLACK_BOT_TOKEN: token,
SLACK_SIGNING_SECRET: signingSecret,
},
extraFields: {
BOT_NAME: info.botName,
TEAM_NAME: info.teamName,
TEAM_ID: info.teamId,
},
},
);
const install = await applySlackSkill(token, signingSecret, info);
if (!install.ok) {
await fail(
'slack-install',
"Couldn't connect Slack.",
'See logs/setup-steps/ for details, then retry setup.',
install.detail || 'See logs/setup-steps/ for details, then retry setup.',
);
}
@@ -125,6 +110,94 @@ export async function runSlackChannel(displayName: string): Promise<ChannelFlowR
showPostInstallChecklist(info);
}
/**
* Install the Slack adapter and persist credentials by applying the `/add-slack`
* skill through the structured-directive engine. The skill's SKILL.md is the
* single source of truth this replaces the hand-maintained setup/add-slack.sh,
* which had already drifted on the pinned adapter version.
*
* The two secrets collected above are handed to the skill's `prompt` directives
* through the in-process Prompter, so they never touch argv or disk. The engine
* runs copy/append/dep/build + env-set/env-sync; we restart the service after
* (the skill itself doesn't, by design). add-slack is fully deterministic and
* both secrets are supplied, so a healthy apply leaves nothing for an agent and
* nothing deferred either bucket being non-empty means the install failed.
*/
async function applySlackSkill(
token: string,
signingSecret: string,
info: WorkspaceInfo,
): Promise<{ ok: boolean; detail: string }> {
const projectRoot = process.cwd();
const s = p.spinner();
const start = Date.now();
s.start(`Connecting Slack to @${info.botName} (${info.teamName})…`);
const prompter: Prompter = {
async ask(name) {
if (name === 'bot_token') return token;
if (name === 'signing_secret') return signingSecret;
return undefined;
},
};
try {
const result = await applySkill('.claude/skills/add-slack', projectRoot, {
prompter,
exec: (cmd) => {
execSync(cmd, { cwd: projectRoot, stdio: 'pipe' });
},
// Fork-aware: reuse the existing resolver (handles upstream/fork remotes
// and the auto-add-upstream fallback) instead of assuming `origin`.
resolveRemote: () =>
execSync('source setup/lib/channels-remote.sh; resolve_channels_remote', {
cwd: projectRoot,
shell: '/bin/bash',
encoding: 'utf8',
}).trim(),
});
if (result.agentTasks.length || result.deferred.length) {
const why = [...result.agentTasks.map((t) => t.reason), ...result.deferred].join('; ');
s.stop("Couldn't finish installing Slack.", 1);
setupLog.step('slack-install', 'failed', Date.now() - start, { ERROR: why });
return { ok: false, detail: why };
}
restartService(projectRoot);
s.stop('Slack adapter installed.');
setupLog.step('slack-install', 'success', Date.now() - start, {
APPLIED: String(result.applied.length),
SKIPPED: String(result.skipped.length),
BOT_NAME: info.botName,
TEAM_NAME: info.teamName,
TEAM_ID: info.teamId,
});
return { ok: true, detail: '' };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
s.stop("Couldn't install the Slack adapter.", 1);
setupLog.step('slack-install', 'failed', Date.now() - start, { ERROR: message });
return { ok: false, detail: 'See logs/setup-steps/ for details, then retry setup.' };
}
}
/** Best-effort service restart so the new adapter + credentials take effect. */
function restartService(projectRoot: string): void {
const script = [
`source "${projectRoot}/setup/lib/install-slug.sh"`,
'case "$(uname -s)" in',
' Darwin) launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" ;;',
' Linux) systemctl --user restart "$(systemd_unit)" || sudo systemctl restart "$(systemd_unit)" ;;',
'esac',
].join('\n');
try {
execSync(script, { cwd: projectRoot, stdio: 'pipe', shell: '/bin/bash' });
} catch {
// The service may not be installed yet during a fresh setup — best-effort.
}
}
async function walkThroughAppCreation(): Promise<'continue' | 'back'> {
// Bright-white ANSI overrides the surrounding brand-cyan from `note()`'s
// per-line formatter so the URL stands out against the rest of the body.
+92 -25
View File
@@ -24,12 +24,14 @@
* stops there; the operator DMs the bot, NanoClaw auto-creates the
* messaging group, and they wire an agent via `/manage-channels`.
*/
import { execSync } from 'node:child_process';
import os from 'os';
import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import { applySkill, type Prompter } from '../../scripts/skill-apply.js';
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
import { brightSelect } from '../lib/bright-select.js';
import { confirmThenOpen } from '../lib/browser.js';
@@ -39,7 +41,7 @@ import {
validateWithHelpEscape,
type HandoffContext,
} from '../lib/claude-handoff.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { ensureAnswer, fail } from '../lib/runner.js';
import { buildTeamsAppPackage } from '../lib/teams-manifest.js';
import { note } from '../lib/theme.js';
import * as setupLog from '../logs.js';
@@ -539,33 +541,82 @@ async function stepSideload(args: {
// ─── step: install adapter ─────────────────────────────────────────────
/**
* Install the Teams adapter and persist credentials by applying the `/add-teams`
* skill through the structured-directive engine. The skill's SKILL.md is the
* single source of truth this replaces the hand-maintained setup/add-teams.sh,
* which duplicated the copy/append/dep/build/env steps and could drift on the
* pinned adapter version.
*
* The credentials collected above are handed to the skill's `prompt` directives
* through the in-process Prompter, so they never touch argv or disk until the
* skill's env-set/env-sync directives write them. The engine runs
* copy/append/dep/build + env-set/env-sync; we restart the service after (the
* skill itself doesn't, by design). add-teams is fully deterministic and every
* prompt var is supplied, so a healthy apply leaves nothing for an agent and
* nothing deferred either bucket being non-empty means the install failed.
*
* `app_tenant_id` is required only for Single Tenant apps; for Multi Tenant we
* supply an empty string so the env-set directive writes a blank
* TEAMS_APP_TENANT_ID, matching the skill's "leave blank for Multi Tenant" prose.
*/
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 projectRoot = process.cwd();
const s = p.spinner();
const start = Date.now();
s.start('Installing the Teams adapter and restarting the service…');
const install = await runQuietChild(
'teams-install',
'bash',
['setup/add-teams.sh'],
{
running: 'Installing the Teams adapter and restarting the service…',
done: 'Teams adapter installed.',
const prompter: Prompter = {
async ask(name) {
if (name === 'app_id') return collected.appId;
if (name === 'app_password') return collected.appPassword;
if (name === 'app_type') return collected.appType;
if (name === 'app_tenant_id') {
return collected.appType === 'SingleTenant' ? collected.tenantId ?? '' : '';
}
return undefined;
},
{
env,
extraFields: {
APP_ID: collected.appId!,
APP_TYPE: collected.appType!,
};
try {
const result = await applySkill('.claude/skills/add-teams', projectRoot, {
prompter,
exec: (cmd) => {
execSync(cmd, { cwd: projectRoot, stdio: 'pipe' });
},
},
);
if (!install.ok) {
// Fork-aware: reuse the existing resolver (handles upstream/fork remotes
// and the auto-add-upstream fallback) instead of assuming `origin`.
resolveRemote: () =>
execSync('source setup/lib/channels-remote.sh; resolve_channels_remote', {
cwd: projectRoot,
shell: '/bin/bash',
encoding: 'utf8',
}).trim(),
});
if (result.agentTasks.length || result.deferred.length) {
const why = [...result.agentTasks.map((t) => t.reason), ...result.deferred].join('; ');
s.stop("Couldn't finish installing Teams.", 1);
setupLog.step('teams-install', 'failed', Date.now() - start, { ERROR: why });
fail(
'teams-install',
"Couldn't install the Teams adapter.",
why || 'See logs/setup-steps/ for details, then retry setup.',
);
}
restartService(projectRoot);
s.stop('Teams adapter installed.');
setupLog.step('teams-install', 'success', Date.now() - start, {
APPLIED: String(result.applied.length),
SKIPPED: String(result.skipped.length),
APP_ID: collected.appId!,
APP_TYPE: collected.appType!,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
s.stop("Couldn't install the Teams adapter.", 1);
setupLog.step('teams-install', 'failed', Date.now() - start, { ERROR: message });
fail(
'teams-install',
"Couldn't install the Teams adapter.",
@@ -574,6 +625,22 @@ async function installAdapter(collected: Collected): Promise<void> {
}
}
/** Best-effort service restart so the new adapter + credentials take effect. */
function restartService(projectRoot: string): void {
const script = [
`source "${projectRoot}/setup/lib/install-slug.sh"`,
'case "$(uname -s)" in',
' Darwin) launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" ;;',
' Linux) systemctl --user restart "$(systemd_unit)" || sudo systemctl restart "$(systemd_unit)" ;;',
'esac',
].join('\n');
try {
execSync(script, { cwd: projectRoot, stdio: 'pipe', shell: '/bin/bash' });
} catch {
// The service may not be installed yet during a fresh setup — best-effort.
}
}
// ─── post-install: hand off to Claude for the final wiring ────────────
async function finishWithHandoff(
@@ -688,7 +755,7 @@ async function offerHandoff(args: {
stepDescription: args.stepDescription,
completedSteps: args.args.completed.slice(),
collectedValues: redactCollected(args.args.collected),
files: ['setup/channels/teams.ts', 'setup/add-teams.sh'],
files: ['setup/channels/teams.ts', '.claude/skills/add-teams/SKILL.md'],
};
await offerClaudeHandoff(ctx);
}
+98 -15
View File
@@ -8,7 +8,8 @@
* 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)
* 5. Install the adapter by applying the /add-telegram skill in-process
* (directive engine; the skill's SKILL.md is the single source of truth)
* 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
@@ -17,9 +18,12 @@
* structured entries in logs/setup.log, full raw output in per-step files
* under logs/setup-steps/. See docs/setup-flow.md.
*/
import { execSync } from 'node:child_process';
import * as p from '@clack/prompts';
import k from 'kleur';
import { applySkill, type Prompter } from '../../scripts/skill-apply.js';
import * as setupLog from '../logs.js';
import { isHeadless } from '../platform.js';
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
@@ -85,24 +89,12 @@ export async function runTelegramChannel(displayName: string): Promise<ChannelFl
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 },
},
);
const install = await applyTelegramSkill(token, botUsername);
if (!install.ok) {
await fail(
'telegram-install',
"Couldn't connect Telegram.",
'See logs/setup-steps/ for details, then retry setup.',
install.detail || 'See logs/setup-steps/ for details, then retry setup.',
);
}
@@ -159,6 +151,97 @@ export async function runTelegramChannel(displayName: string): Promise<ChannelFl
}
}
/**
* Install the Telegram adapter and persist the bot token by applying the
* `/add-telegram` skill through the structured-directive engine. The skill's
* SKILL.md is the single source of truth this replaces the hand-maintained
* setup/add-telegram.sh, which duplicated the copy/append/dep/build/env steps
* and had to be kept in sync with the skill by hand.
*
* The bot token collected above is handed to the skill's `prompt bot_token`
* directive through the in-process Prompter, so it never touches argv or disk
* (the engine's env-set/env-sync directives write it to .env + data/env/env).
* The engine runs copy/append/dep/build + env-set/env-sync; we restart the
* service after (the skill itself doesn't, by design) and let the caller settle
* before pairing so the polling adapter is up. add-telegram is fully
* deterministic and the token is supplied, so a healthy apply leaves nothing
* for an agent and nothing deferred either bucket being non-empty means the
* install failed.
*/
async function applyTelegramSkill(
token: string,
botUsername: string,
): Promise<{ ok: boolean; detail: string }> {
const projectRoot = process.cwd();
const s = p.spinner();
const start = Date.now();
s.start(`Connecting Telegram to @${botUsername}`);
const prompter: Prompter = {
async ask(name) {
if (name === 'bot_token') return token;
return undefined;
},
};
try {
const result = await applySkill('.claude/skills/add-telegram', projectRoot, {
prompter,
exec: (cmd) => {
execSync(cmd, { cwd: projectRoot, stdio: 'pipe' });
},
// Fork-aware: reuse the existing resolver (handles upstream/fork remotes
// and the auto-add-upstream fallback) instead of assuming `origin`.
resolveRemote: () =>
execSync('source setup/lib/channels-remote.sh; resolve_channels_remote', {
cwd: projectRoot,
shell: '/bin/bash',
encoding: 'utf8',
}).trim(),
});
if (result.agentTasks.length || result.deferred.length) {
const why = [...result.agentTasks.map((t) => t.reason), ...result.deferred].join('; ');
s.stop("Couldn't finish installing Telegram.", 1);
setupLog.step('telegram-install', 'failed', Date.now() - start, { ERROR: why });
return { ok: false, detail: why };
}
restartService(projectRoot);
// Give the Telegram adapter a moment to finish starting before pairing
// begins polling for the user's code message.
await new Promise((resolve) => setTimeout(resolve, 5000));
s.stop('Telegram connected.');
setupLog.step('telegram-install', 'success', Date.now() - start, {
APPLIED: String(result.applied.length),
SKIPPED: String(result.skipped.length),
BOT_USERNAME: botUsername,
});
return { ok: true, detail: '' };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
s.stop("Couldn't install the Telegram adapter.", 1);
setupLog.step('telegram-install', 'failed', Date.now() - start, { ERROR: message });
return { ok: false, detail: 'See logs/setup-steps/ for details, then retry setup.' };
}
}
/** Best-effort service restart so the new adapter + credentials take effect. */
function restartService(projectRoot: string): void {
const script = [
`source "${projectRoot}/setup/lib/install-slug.sh"`,
'case "$(uname -s)" in',
' Darwin) launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" ;;',
' Linux) systemctl --user restart "$(systemd_unit)" || sudo systemctl restart "$(systemd_unit)" ;;',
'esac',
].join('\n');
try {
execSync(script, { cwd: projectRoot, stdio: 'pipe', shell: '/bin/bash' });
} catch {
// The service may not be installed yet during a fresh setup — best-effort.
}
}
async function collectTelegramToken(): Promise<string | 'back'> {
const existing = readEnvKey('TELEGRAM_BOT_TOKEN');
if (existing && /^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(existing)) {
+2
View File
@@ -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> {
-46
View File
@@ -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.26.0
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
-46
View File
@@ -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.26.0
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
-47
View File
@@ -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 ==="
-95
View File
@@ -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.26.0
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
-62
View File
@@ -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 ==="
-46
View File
@@ -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 ==="
-46
View File
@@ -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.26.0
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
-46
View File
@@ -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.26.0
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
-72
View File
@@ -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.26.0
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
-46
View File
@@ -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 ==="
-44
View File
@@ -1,44 +0,0 @@
import { describe, expect, it } from 'vitest';
import { extractClaudeOAuthToken } from './captured-token.js';
// A syntactically valid token: sk-ant-oat + 93 token chars + AA.
const TOKEN = `sk-ant-oat01-${'a'.repeat(90)}AA`;
describe('extractClaudeOAuthToken', () => {
it('extracts the token from clean single-line output (normal terminal)', () => {
const raw = `Login successful.\nYour token:\n${TOKEN}\n`;
expect(extractClaudeOAuthToken(raw)).toBe(TOKEN);
});
// The actual sbx failure shape: the real token wrapped across two lines AND
// the `export CLAUDE_CODE_OAUTH_TOKEN=<token>` placeholder in the same
// capture. The old parser returned null (matched only the first fragment);
// the normalizer must un-wrap the real token and never mistake the
// placeholder for it.
it('extracts the real wrapped token from sbx capture and ignores the placeholder export', () => {
const head = TOKEN.slice(0, 72);
const tail = TOKEN.slice(72);
const raw = `
\x1b[?2026h Long-lived authentication token created successfully!
Your OAuth token (valid for 1 year):
${head}
${tail}
Store this token securely. You won't be able to see it again.
Use this token by setting: export CLAUDE_CODE_OAUTH_TOKEN=<token>
`;
expect(extractClaudeOAuthToken(raw)).toBe(TOKEN);
});
it('returns null for the placeholder env-var line, not a real token', () => {
expect(extractClaudeOAuthToken('export CLAUDE_CODE_OAUTH_TOKEN=<token>\n')).toBeNull();
});
it('returns null when no token is present', () => {
expect(extractClaudeOAuthToken('claude: authentication cancelled\n')).toBeNull();
});
});
-73
View File
@@ -1,73 +0,0 @@
/**
* Parse a provider auth token out of interactive CLI output captured through
* a PTY (`script(1)`).
*
* Secret this module hides: the menagerie of PTY-capture artifacts that
* corrupt an otherwise whitespace-free secret. A real terminal wraps long
* lines, pads with spaces, and interleaves ANSI/control sequences, so a token
* the CLI printed as one string lands in the capture split across lines with
* escape codes embedded. Provider login itself succeeds only our parse of
* the human-oriented output fails.
*
* A normalize step strips the capture artifacts; the extractor matches the
* token shape against the clean string. A future provider adds its own
* extractor here rather than regexing raw `script(1)` output.
*
* Runnable as a CLI for the bash callers that can't import TS:
* tsx setup/lib/captured-token.ts claude <capture-file>
* Prints the token and exits 0, or exits 1 with nothing on stdout.
*/
import fs from 'fs';
import { pathToFileURL } from 'url';
/* eslint-disable no-control-regex -- these patterns exist precisely to match
the ESC/control bytes a PTY capture is full of. */
// CSI sequences (colors, cursor moves): ESC [ , optional private '?' /
// parameter bytes, optional intermediate bytes, one final byte. Stripped
// explicitly because a colour reset mid-token (sk…\x1b[0m…AA) would otherwise
// leave a `[` that breaks the token's character run.
const CSI = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
// Everything <= space (control bytes incl. any stray ESC, CR/LF, tabs, and the
// wrap-padding spaces inserted mid-token) plus DEL. Tokens contain none of these.
const CONTROL_AND_SPACE = /[\x00-\x20\x7f]/g;
/* eslint-enable no-control-regex */
/**
* Collapse PTY-capture artifacts so a whitespace-free secret printed across
* wrapped lines becomes a single contiguous string. Drops ALL whitespace by
* design these captures exist only to recover a token, never prose.
*/
function normalizeCapturedTerminalOutput(raw: string): string {
return raw.replace(CSI, '').replace(CONTROL_AND_SPACE, '');
}
// Claude subscription OAuth tokens: sk-ant-oat<base64url>AA. Bounded length
// keeps a greedy match from running off the end of the token.
const CLAUDE_OAUTH_TOKEN = /sk-ant-oat[A-Za-z0-9_-]{80,500}AA/g;
/**
* Extract the Claude OAuth token from a PTY capture of `claude setup-token`,
* or `null` if none is present. Returns the LAST match setup-token can echo
* partial/intermediate output before the final token. Placeholder strings like
* `<token>` never match (they lack the `sk-ant-oat` prefix).
*/
export function extractClaudeOAuthToken(raw: string): string | null {
const matches = normalizeCapturedTerminalOutput(raw).match(CLAUDE_OAUTH_TOKEN);
return matches ? matches[matches.length - 1] : null;
}
function runCli(argv: string[]): number {
const [provider, file] = argv;
if (provider !== 'claude' || !file) {
process.stderr.write('usage: captured-token.ts claude <capture-file>\n');
return 2;
}
const token = extractClaudeOAuthToken(fs.readFileSync(file, 'utf-8'));
if (!token) return 1;
process.stdout.write(token);
return 0;
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
process.exit(runCli(process.argv.slice(2)));
}
+13 -9
View File
@@ -27,7 +27,6 @@ import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import { extractClaudeOAuthToken } from './captured-token.js';
import { ensureAnswer } from './runner.js';
import { brandBody, fitToWidth, fmtDuration, note } from './theme.js';
@@ -65,15 +64,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',
@@ -208,11 +207,16 @@ export async function ensureClaudeReady(projectRoot: string): Promise<boolean> {
});
if (!isClaudeAuthenticated() && fs.existsSync(tmpfile)) {
const token = extractClaudeOAuthToken(fs.readFileSync(tmpfile, 'utf-8'));
if (token) process.env.CLAUDE_CODE_OAUTH_TOKEN = token;
const raw = fs.readFileSync(tmpfile, 'utf-8');
const stripped = raw
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
.replace(/[\n\r]/g, '');
const matches = stripped.match(/(sk-ant-oat[A-Za-z0-9_-]{80,500}AA)/g);
if (matches) {
process.env.CLAUDE_CODE_OAUTH_TOKEN = matches[matches.length - 1];
}
}
} finally {
// eslint-disable-next-line no-empty -- best-effort temp cleanup
try { fs.unlinkSync(tmpfile); } catch {}
}
+1 -1
View File
@@ -13,7 +13,7 @@
* IS_GROUP, PAIRED_USER_ID }
* or { STATUS=failed, CODE, ERROR }
*
* Depends on src/channels/telegram-pairing.js, which setup/add-telegram.sh
* Depends on src/channels/telegram-pairing.js, which the /add-telegram skill
* copies in from the `channels` branch before this step runs. setup/ is
* excluded from the host tsconfig, so this file's import resolves only at
* runtime tsc won't complain on branches that haven't run add-telegram yet.
+23 -18
View File
@@ -13,18 +13,19 @@
* their auth step so there is exactly one auth implementation per provider.
*/
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { getSetupProvider, listSetupProviders } from './providers/registry.js';
import { applyProviderSkill } from './providers/install.js';
// Provider payloads self-register on import.
import './providers/index.js';
// Hard-wired install scripts — the audited control surface (no branch
// enumeration). Each setup/add-<name>.sh is idempotent and self-skips when the
// payload is already wired. Codex is the only manifest-style provider today.
const INSTALL_SCRIPTS: Record<string, string> = {
codex: 'setup/add-codex.sh',
// Hard-wired install skills — the audited control surface (no branch
// enumeration). Each `/add-<name>` SKILL.md is idempotent and self-skips when
// the payload is already wired; it is applied in-process via the directive
// engine (no shell-out to a drift-prone setup/add-<name>.sh). Codex is the only
// manifest-style provider today.
const INSTALL_SKILLS: Record<string, string> = {
codex: '.claude/skills/add-codex',
};
export async function run(args: string[]): Promise<void> {
@@ -40,18 +41,22 @@ export async function run(args: string[]): Promise<void> {
}
let entry = getSetupProvider(name);
const script = INSTALL_SCRIPTS[name];
if (script) {
// Install OR refresh: the script is idempotent and is also the upgrade
// path — payload files resync and a bumped Dockerfile pin replaces the
// local one. Rebuild the image only when the Dockerfile actually changed
// (payload code is mounted, not baked).
const dfPath = path.join(process.cwd(), 'container', 'Dockerfile');
const dfBefore = fs.readFileSync(dfPath, 'utf-8');
const skillDir = INSTALL_SKILLS[name];
if (skillDir) {
// Install OR refresh: the skill is idempotent and is also the upgrade path
// — payload files resync and a bumped CLI-manifest pin replaces the local
// one. Applied in-process via the directive engine; build + auth are this
// flow's job (the engine's build/test/auth run directives are skipped), so
// we rebuild the image whenever the install mutated anything (the container
// CLI manifest is baked into the image, unlike the mounted payload code).
console.log(`${entry ? 'Refreshing' : 'Installing'} ${name}`);
execSync(`bash ${script}`, { stdio: 'inherit' });
if (fs.readFileSync(dfPath, 'utf-8') !== dfBefore) {
console.log('Dockerfile pin changed — rebuilding the container image…');
const { changed, blockers } = await applyProviderSkill(skillDir, process.cwd());
if (blockers.length) {
console.error(`Couldn't install ${name}: ${blockers.join('; ')}`);
process.exit(1);
}
if (changed) {
console.log('Provider payload installed — rebuilding the container image…');
execSync('./container/build.sh', { stdio: 'inherit' });
}
if (!entry) {
+21 -7
View File
@@ -65,19 +65,33 @@ describe('setup carries the picked provider to creation via a setup-run env var'
}
});
describe('codex installs from a hard-wired self-contained script', () => {
describe('codex installs from its hard-wired /add-codex skill in-process', () => {
// The provider picker no longer enumerates a remote manifest branch (an
// unaudited control surface). Codex is offered in trunk and installed by its
// own setup/add-<name>.sh, exactly like a channel adapter.
it('setup/add-codex.sh exists', () => {
expect(fs.existsSync(path.join(repoRoot, 'setup/add-codex.sh'))).toBe(true);
// unaudited control surface). Codex is offered in trunk and installed by
// applying its `/add-codex` SKILL.md in-process via the directive engine —
// the same path channel adapters now take (no drift-prone setup/add-<name>.sh).
it('the /add-codex skill ships in trunk', () => {
expect(fs.existsSync(path.join(repoRoot, '.claude/skills/add-codex/SKILL.md'))).toBe(true);
});
it('setup/auto.ts installs the picked provider by running setup/add-<name>.sh', () => {
it('the bespoke setup/add-codex.sh install script is gone', () => {
expect(fs.existsSync(path.join(repoRoot, 'setup/add-codex.sh'))).toBe(false);
});
it('setup/auto.ts installs the picked provider in-process via applyProviderSkill', () => {
const src = read('setup/auto.ts');
expect(src).toContain('setup/add-${agentProvider}.sh');
expect(src).toContain('applyProviderSkill');
expect(src).toContain('.claude/skills/add-${agentProvider}');
// No shell-out to a per-provider install script.
expect(src).not.toContain('setup/add-${agentProvider}.sh');
// The removed branch-enumeration machinery must not creep back in.
expect(src).not.toContain('listBranchProviderManifests');
expect(src).not.toContain('installProviderFromBranch');
});
it('setup/provider-auth.ts installs the picked provider in-process via applyProviderSkill', () => {
const src = read('setup/provider-auth.ts');
expect(src).toContain('applyProviderSkill');
expect(src).not.toContain('setup/add-codex.sh');
});
});
+91
View File
@@ -0,0 +1,91 @@
/**
* In-process provider install the setup-side twin of the channel installs.
*
* A provider's `/add-<name>` SKILL.md is the single source of truth for what an
* install does (copy the payload from the `providers` branch, wire the three
* provider barrels, merge the CLI manifest entry). This applies that SKILL.md
* directly through the directive engine (`scripts/skill-apply.ts`) instead of
* shelling out to a hand-maintained `setup/add-<name>.sh` that has to be kept in
* lockstep with it the same move `setup/channels/slack.ts` made for adapters.
*
* The provider case differs from a channel in two ways, both handled here:
*
* 1. **No install-time secrets.** A provider's credentials are vault-only and
* land in a separate auth walk-through (`runAuth`), so the SKILL.md carries
* no `nc:prompt` directives. The Prompter therefore defers everything; it
* is never actually called.
* 2. **Build + auth are owned by the surrounding flow.** The provider SKILL.md
* ends with `nc:run effect:build` / `effect:test` / `effect:external` (the
* external one re-invokes `--step provider-auth`, which would recurse). The
* setup flow already rebuilds the image and runs auth around this call, so
* we scope `exec` to apply only the file-mutating commands the engine emits
* (the `nc:copy from-branch` git fetch/show) and skip those heavyweight run
* directives. The fork-aware remote resolver mirrors slack.ts exactly.
*
* Returns the engine's ApplyResult so the caller can decide whether a rebuild is
* warranted (a fresh install always applied something) and surface any step the
* engine couldn't apply deterministically (agentTasks / deferred install
* failed: a provider install is fully deterministic with no prompts).
*/
import { execSync } from 'node:child_process';
import { applySkill, type ApplyResult, type Prompter } from '../../scripts/skill-apply.js';
/** Commands the directive engine emits that the surrounding setup flow owns. */
function isFlowOwnedCommand(cmd: string): boolean {
return (
/\bpnpm\s+run\s+build\b/.test(cmd) ||
/\btsc\b/.test(cmd) ||
/container\/build\.sh/.test(cmd) ||
/\bvitest\b/.test(cmd) ||
/\bbun\s+test\b/.test(cmd) ||
// The skill's auth step re-invokes `--step provider-auth` — running it from
// inside the install would recurse. The flow runs runAuth itself.
/provider-auth/.test(cmd)
);
}
export interface ProviderInstallResult {
apply: ApplyResult;
/** True when the engine applied at least one mutation (fresh/refreshed install). */
changed: boolean;
/** Non-deterministic leftovers — non-empty means the install did not fully apply. */
blockers: string[];
}
export async function applyProviderSkill(
skillDir: string,
projectRoot: string,
): Promise<ProviderInstallResult> {
// A provider SKILL.md has no prompt directives (vault-only auth runs
// separately), so the Prompter defers everything and is never called.
const prompter: Prompter = {
async ask() {
return undefined;
},
};
const result = await applySkill(skillDir, projectRoot, {
prompter,
exec: (cmd) => {
if (isFlowOwnedCommand(cmd)) return; // build/test/auth are the flow's job
execSync(cmd, { cwd: projectRoot, stdio: 'pipe' });
},
// Fork-aware: reuse the existing resolver (handles upstream/fork remotes and
// the auto-add-upstream fallback) instead of assuming `origin` — same call
// setup/channels/slack.ts makes for the `channels` branch.
resolveRemote: () =>
execSync('source setup/lib/channels-remote.sh; resolve_channels_remote', {
cwd: projectRoot,
shell: '/bin/bash',
encoding: 'utf8',
}).trim(),
});
const blockers = [...result.agentTasks.map((t) => t.reason), ...result.deferred];
return {
apply: result,
changed: result.applied.length > 0,
blockers,
};
}
+7 -7
View File
@@ -9,8 +9,7 @@ set -euo pipefail
# Flow:
# 1. Run `claude setup-token` under a PTY (via script(1)) so the browser
# OAuth dance works and its token is captured into a tempfile.
# 2. Parse the sk-ant-oat…AA token out of the capture via the shared
# PTY-capture parser (setup/lib/captured-token.ts).
# 2. Regex the sk-ant-oat…AA token out of the ANSI-stripped capture.
# 3. Register it with OneCLI.
#
# Env overrides:
@@ -100,11 +99,12 @@ else
script -q "$tmpfile" $cmd
fi
# Extract the token via the shared PTY-capture parser (setup/lib/captured-token.ts),
# so this script and setup/lib/claude-assist.ts stay in lockstep on the
# normalization rules (ANSI/control stripping, un-wrapping the token).
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
token=$(pnpm exec tsx "$SCRIPT_DIR/lib/captured-token.ts" claude "$tmpfile" || true)
# Strip ANSI codes + newlines (TTY wraps the token mid-string), then match
# the sk-ant-oat…AA token. perl because BSD grep caps {n,m} at 255.
token=$(sed $'s/\x1b\\[[0-9;]*[a-zA-Z]//g' "$tmpfile" \
| tr -d '\n\r' \
| perl -ne 'print "$1\n" while /(sk-ant-oat[A-Za-z0-9_-]{80,500}AA)/g' \
| tail -1 || true)
if [ -z "$token" ]; then
keep=$(mktemp -t claude-setup-token-log.XXXXXX)
+3
View File
@@ -36,6 +36,8 @@ export interface ColumnDef {
export interface CustomOperation {
access: Access;
/** Operator-only: never runnable from inside a container (see CommandDef.hostOnly). */
hostOnly?: boolean;
description: string;
args?: ColumnDef[];
handler: (args: Record<string, unknown>, ctx: CallerContext) => Promise<unknown>;
@@ -290,6 +292,7 @@ export function registerResource(def: ResourceDef): void {
name: `${def.plural}-${verb.replace(/ /g, '-')}`,
description: op.description,
access: op.access,
hostOnly: op.hostOnly,
resource: def.plural,
parseArgs: (raw) => normalizeArgs(raw),
handler: async (args, ctx) => op.handler(args as Record<string, unknown>, ctx),
+28
View File
@@ -98,6 +98,16 @@ register({
handler: async (args) => ({ echo: args }),
});
register({
name: 'host-only-cmd',
description: 'test command (operator-only, like add-mount)',
resource: 'groups',
access: 'approval',
hostOnly: true,
parseArgs: (raw) => raw,
handler: async (args) => ({ echo: args }),
});
// Commands that return data shaped like real resources (for post-handler filtering tests)
register({
name: 'groups-list-data',
@@ -178,6 +188,24 @@ function agentCtx(overrides?: Partial<Extract<CallerContext, { caller: 'agent' }
// --- Tests ---
describe('host-only commands (operator-only)', () => {
it('rejects an agent caller even at global scope', async () => {
// global scope is otherwise unrestricted — hostOnly must still reject.
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
const resp = await dispatch({ id: '1', command: 'host-only-cmd', args: {} }, agentCtx());
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('forbidden');
expect(resp.error.message).toContain('operator-only');
}
});
it('passes the gate for a host (operator) caller', async () => {
const resp = await dispatch({ id: '1', command: 'host-only-cmd', args: { x: 1 } }, { caller: 'host' });
expect(resp.ok).toBe(true);
});
});
describe('CLI scope enforcement', () => {
it('disabled: rejects all CLI requests from agent', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'disabled' });
+8
View File
@@ -38,6 +38,14 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<R
return err(req.id, 'unknown-command', `no command "${req.command}"`);
}
// Host-only commands (e.g. mount management) are operator-only: rejected for
// ANY container caller, regardless of cli_scope (even `global`) or approval.
// The mount allowlist is the boundary cli_scope itself lives inside, so an
// agent must never alter it — not even with admin approval.
if (cmd.hostOnly && ctx.caller !== 'host') {
return err(req.id, 'forbidden', `"${req.command}" is operator-only and cannot be run from inside a container.`);
}
// CLI scope enforcement for agent callers
if (ctx.caller === 'agent') {
const configRow = getContainerConfig(ctx.agentGroupId);
+7
View File
@@ -13,6 +13,13 @@ export type CommandDef<TArgs = unknown, TData = unknown> = {
name: string;
description: string;
access: Access;
/**
* Operator-only: rejected for ANY container (agent) caller regardless of
* cli_scope (even `global`) or approval. For privileged host-boundary ops
* like mount management the mount allowlist is the boundary cli_scope
* itself lives inside, so an agent must never be able to alter it.
*/
hostOnly?: boolean;
/** Resource this command belongs to (for help grouping). */
resource?: string;
/**
+41
View File
@@ -32,6 +32,7 @@ const TEST_DIR = '/tmp/nanoclaw-test-cli-groups';
import { initTestDb, closeDb, runMigrations, createAgentGroup, getDb } from '../../db/index.js';
import { createSession } from '../../db/sessions.js';
import { dispatch } from '../dispatch.js';
import { ensureContainerConfig, getContainerConfig } from '../../db/container-configs.js';
// Side-effect import: registers the `groups-*` commands (including delete).
import './groups.js';
@@ -218,3 +219,43 @@ describe('groups CLI delete cascades dependent rows (#2525)', () => {
expect((resp as { ok: false; error: { code: string; message: string } }).error.message).toMatch(/not found/i);
});
});
describe('groups config add-mount / remove-mount (host-only)', () => {
beforeEach(() => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
fs.mkdirSync(TEST_DIR, { recursive: true });
runMigrations(initTestDb());
});
afterEach(() => {
closeDb();
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
});
it('adds a mount idempotently and removes it (host caller)', async () => {
const GID = 'ag-mount';
createAgentGroup({ id: GID, name: 'm', folder: 'm', agent_provider: null, created_at: now() });
ensureContainerConfig(GID);
const args = { id: GID, host: '/data/.gmail-mcp', container: '/home/node/.gmail-mcp', ro: true };
const add = await dispatch({ id: 'r1', command: 'groups-config-add-mount', args }, { caller: 'host' });
expect(add.ok).toBe(true);
expect(JSON.parse(getContainerConfig(GID)!.additional_mounts)).toEqual([
{ hostPath: '/data/.gmail-mcp', containerPath: '/home/node/.gmail-mcp', readonly: true },
]);
// idempotent: a second add does not duplicate
await dispatch({ id: 'r2', command: 'groups-config-add-mount', args }, { caller: 'host' });
expect(JSON.parse(getContainerConfig(GID)!.additional_mounts)).toHaveLength(1);
const rm = await dispatch(
{
id: 'r3',
command: 'groups-config-remove-mount',
args: { id: GID, host: '/data/.gmail-mcp', container: '/home/node/.gmail-mcp' },
},
{ caller: 'host' },
);
expect(rm.ok).toBe(true);
expect(JSON.parse(getContainerConfig(GID)!.additional_mounts)).toEqual([]);
});
});
+53 -1
View File
@@ -1,4 +1,4 @@
import type { McpServerConfig } from '../../container-config.js';
import type { AdditionalMountConfig, McpServerConfig } from '../../container-config.js';
import { buildAgentGroupImage, killContainer, wakeContainer } from '../../container-runner.js';
import { restartAgentGroupContainers } from '../../container-restart.js';
import { getDb, hasTable } from '../../db/connection.js';
@@ -369,5 +369,57 @@ registerResource({
};
},
},
'config add-mount': {
access: 'approval',
hostOnly: true,
description:
"Mount a host directory into a group's containers. OPERATOR-ONLY — never runnable from " +
'inside a container (mounting host paths is a filesystem-access boundary). Requires ' +
'`ncl groups restart` to take effect. Use --id <group-id> --host <host-path> --container <container-path> [--ro].',
handler: async (args) => {
const id = args.id as string;
if (!id) throw new Error('--id is required');
const hostPath = (args.host ?? args['host-path']) as string | undefined;
const containerPath = (args.container ?? args['container-path']) as string | undefined;
if (!hostPath || !containerPath) throw new Error('Provide --host <host-path> and --container <container-path>');
const row = getContainerConfig(id);
if (!row) throw new Error(`No container config for group: ${id}`);
const mount: AdditionalMountConfig = {
hostPath,
containerPath,
...(args.ro || args.readonly ? { readonly: true } : {}),
};
const existing = JSON.parse(row.additional_mounts) as AdditionalMountConfig[];
if (!existing.some((m) => m.hostPath === hostPath && m.containerPath === containerPath)) {
existing.push(mount);
updateContainerConfigJson(id, 'additional_mounts', existing);
}
return { added: mount, note: `Run \`ncl groups restart --id ${id}\` for the mount to take effect.` };
},
},
'config remove-mount': {
access: 'approval',
hostOnly: true,
description:
'Remove a host mount from a group. OPERATOR-ONLY. Requires `ncl groups restart` to take effect. ' +
'Use --id <group-id> --host <host-path> --container <container-path>.',
handler: async (args) => {
const id = args.id as string;
if (!id) throw new Error('--id is required');
const hostPath = (args.host ?? args['host-path']) as string | undefined;
const containerPath = (args.container ?? args['container-path']) as string | undefined;
if (!hostPath || !containerPath) throw new Error('Provide --host <host-path> and --container <container-path>');
const row = getContainerConfig(id);
if (!row) throw new Error(`No container config for group: ${id}`);
const existing = JSON.parse(row.additional_mounts) as AdditionalMountConfig[];
const filtered = existing.filter((m) => !(m.hostPath === hostPath && m.containerPath === containerPath));
updateContainerConfigJson(id, 'additional_mounts', filtered);
return { removed: { hostPath, containerPath }, note: `Run \`ncl groups restart --id ${id}\` to apply.` };
},
},
},
});
+7 -1
View File
@@ -2,7 +2,7 @@ import path from 'path';
import { describe, expect, it } from 'vitest';
import { isValidGroupFolder, resolveGroupFolderPath } from './group-folder.js';
import { isValidGroupFolder, resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
describe('group folder validation', () => {
it('accepts normal group folder names', () => {
@@ -23,7 +23,13 @@ describe('group folder validation', () => {
expect(resolved.endsWith(`${path.sep}groups${path.sep}family-chat`)).toBe(true);
});
it('resolves safe paths under data ipc directory', () => {
const resolved = resolveGroupIpcPath('family-chat');
expect(resolved.endsWith(`${path.sep}data${path.sep}ipc${path.sep}family-chat`)).toBe(true);
});
it('throws for unsafe folder names', () => {
expect(() => resolveGroupFolderPath('../../etc')).toThrow();
expect(() => resolveGroupIpcPath('/tmp')).toThrow();
});
});
+9 -1
View File
@@ -1,6 +1,6 @@
import path from 'path';
import { GROUPS_DIR } from './config.js';
import { DATA_DIR, GROUPS_DIR } from './config.js';
const GROUP_FOLDER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
const RESERVED_FOLDERS = new Set(['global']);
@@ -34,3 +34,11 @@ export function resolveGroupFolderPath(folder: string): string {
ensureWithinBase(GROUPS_DIR, groupPath);
return groupPath;
}
export function resolveGroupIpcPath(folder: string): string {
assertValidGroupFolder(folder);
const ipcBaseDir = path.resolve(DATA_DIR, 'ipc');
const ipcPath = path.resolve(ipcBaseDir, folder);
ensureWithinBase(ipcBaseDir, ipcPath);
return ipcPath;
}