mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-18 18:29:35 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c277deae21 | |||
| 0cc8ade34c | |||
| d33806389c | |||
| f350ed24e1 | |||
| cc07387025 | |||
| 59460e9a5c | |||
| ae48986e42 |
@@ -11,6 +11,8 @@ NanoClaw selects each group's agent backend from `container_configs.provider` (d
|
||||
|
||||
The provider runs `codex app-server` as a child process speaking JSON-RPC over stdio: native streaming, MCP tools, server-side conversation history (the continuation is a thread id, no on-disk transcript). Credentials are **vault-only**: OneCLI serves a sentinel `auth.json` stub into the container and swaps the real ChatGPT token or API key on the wire — no key in `.env`, nothing readable in the container.
|
||||
|
||||
The mechanical steps under **Install** carry `nc:` directive fences: an agent reads the prose and applies them, and a parser can apply them deterministically from the same document. Every directive is idempotent, so the whole skill is safe to re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
## Install
|
||||
|
||||
### Pre-flight
|
||||
@@ -23,92 +25,69 @@ Check whether the payload is already wired (a prior apply, or a trunk that still
|
||||
- `import './codex.js';` in `src/providers/index.ts`, `container/agent-runner/src/providers/index.ts`, and `setup/providers/index.ts`
|
||||
- an `@openai/codex` entry in `container/cli-tools.json`
|
||||
|
||||
### Fetch and copy
|
||||
### 1. Fetch and copy the payload
|
||||
|
||||
```bash
|
||||
git fetch origin providers
|
||||
Fetch the `providers` branch and copy the Codex payload into all three trees (additive — overwrite each file, never merge the branch). The host files are the provider contribution + AGENTS.md compose + their guards; the container files are the provider runtime (turn loop, JSON-RPC wrapper, per-exchange archiver) + their guards; the setup file is the picker entry + vault auth walk-through; `container/AGENTS.md` is the runtime-contract base the composed AGENTS.md embeds.
|
||||
|
||||
```nc:copy from-branch:providers
|
||||
src/providers/codex.ts
|
||||
src/providers/codex-agents-md.ts
|
||||
src/providers/codex-registration.test.ts
|
||||
src/providers/codex-host-contribution.test.ts
|
||||
src/providers/codex-agents-md.test.ts
|
||||
container/agent-runner/src/providers/codex.ts
|
||||
container/agent-runner/src/providers/codex-app-server.ts
|
||||
container/agent-runner/src/providers/exchange-archive.ts
|
||||
container/agent-runner/src/providers/exchange-archive.test.ts
|
||||
container/agent-runner/src/providers/codex-registration.test.ts
|
||||
container/agent-runner/src/providers/codex.factory.test.ts
|
||||
container/agent-runner/src/providers/codex.turns.test.ts
|
||||
container/agent-runner/src/providers/codex-app-server.test.ts
|
||||
container/agent-runner/src/providers/codex-cli-tools.test.ts
|
||||
setup/providers/codex.ts
|
||||
setup/providers/codex.test.ts
|
||||
setup/providers/codex-registration.test.ts
|
||||
container/AGENTS.md
|
||||
```
|
||||
|
||||
Copy each file with `git show origin/providers:<path> > <path>` (additive — never merge the branch):
|
||||
### 2. Wire the barrels
|
||||
|
||||
Host (`src/providers/`):
|
||||
- `codex.ts` — provider contribution: per-group `.codex-shared` state dir, AGENTS.md compose, skill links
|
||||
- `codex-agents-md.ts` — AGENTS.md composition (32KB Codex cap: degrades by dropping the largest instruction sections, never blocks a spawn)
|
||||
- `codex-registration.test.ts` — barrel-driven host registration guard
|
||||
- `codex-host-contribution.test.ts` — drives the real contribution against a real test DB (the "consumes core" leg)
|
||||
- `codex-agents-md.test.ts` — cap-degradation behavior
|
||||
Append the self-registration import to each of the three provider barrels (skipped if the line is already present). Each barrel-registration test imports its real barrel and asserts `codex` is registered — they go red the moment a barrel line is missing or drifts.
|
||||
|
||||
Container (`container/agent-runner/src/providers/`):
|
||||
- `codex.ts` — the provider (turn loop, steering, memory scaffold + `onExchangeComplete` archiving)
|
||||
- `codex-app-server.ts` — JSON-RPC child-process wrapper
|
||||
- `exchange-archive.ts` — per-exchange markdown writer the `onExchangeComplete` hook uses (provider-owned, not runner code)
|
||||
- `exchange-archive.test.ts` — writer behavior
|
||||
- `codex-registration.test.ts` — barrel-driven container registration guard
|
||||
- `codex.factory.test.ts`, `codex.turns.test.ts`, `codex-app-server.test.ts` — provider behavior
|
||||
- `codex-cli-tools.test.ts` — structural guard for the Codex entry in `container/cli-tools.json`
|
||||
|
||||
Setup (`setup/providers/`):
|
||||
- `codex.ts` — picker entry self-registration + the vault auth walk-through + install check
|
||||
- `codex.test.ts` — install-check coverage
|
||||
- `codex-registration.test.ts` — barrel-driven setup registration guard
|
||||
|
||||
Shared base (skip if present):
|
||||
- `container/AGENTS.md` — the runtime-contract base the composed AGENTS.md embeds
|
||||
|
||||
### Wire the barrels
|
||||
|
||||
Append `import './codex.js';` to each of:
|
||||
- `src/providers/index.ts`
|
||||
- `container/agent-runner/src/providers/index.ts`
|
||||
- `setup/providers/index.ts`
|
||||
|
||||
### CLI manifest
|
||||
|
||||
The agent's global Node CLIs install from `container/cli-tools.json` (a json-merge seam), not hand-edited Dockerfile layers. Add Codex by appending one entry — `@openai/codex` has no native postinstall, so no `onlyBuilt`:
|
||||
|
||||
```bash
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const file = "container/cli-tools.json";
|
||||
const tools = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
if (!tools.some((t) => t.name === "@openai/codex")) {
|
||||
tools.push({ name: "@openai/codex", version: "0.138.0" });
|
||||
const fmt = (t) => " { " + Object.entries(t).map(([k, v]) => JSON.stringify(k) + ": " + JSON.stringify(v)).join(", ") + " }";
|
||||
fs.writeFileSync(file, "[\n" + tools.map(fmt).join(",\n") + "\n]\n");
|
||||
}
|
||||
'
|
||||
```nc:append to:src/providers/index.ts
|
||||
import './codex.js';
|
||||
```
|
||||
```nc:append to:container/agent-runner/src/providers/index.ts
|
||||
import './codex.js';
|
||||
```
|
||||
```nc:append to:setup/providers/index.ts
|
||||
import './codex.js';
|
||||
```
|
||||
|
||||
The version (`0.138.0`) is the canonical pin — keep it in sync with `setup/add-codex.sh`. The Dockerfile already installs every manifest entry via pinned `pnpm install -g`; no Dockerfile edit is needed.
|
||||
### 3. CLI manifest
|
||||
|
||||
### Build
|
||||
The agent's global Node CLIs install from `container/cli-tools.json` (a json-merge seam), not hand-edited Dockerfile layers. Add Codex by appending one entry — idempotent on `name`, so a re-run is a no-op. `@openai/codex` has no native postinstall, so no `onlyBuilt`. The Dockerfile already installs every manifest entry via pinned `pnpm install -g`; no Dockerfile edit is needed.
|
||||
|
||||
```bash
|
||||
```nc:json-merge into:container/cli-tools.json key:name
|
||||
{ "name": "@openai/codex", "version": "0.138.0" }
|
||||
```
|
||||
|
||||
The version (`0.138.0`) is the canonical pin — this SKILL.md is the source of truth.
|
||||
|
||||
### 4. Build
|
||||
|
||||
```nc:run effect:build
|
||||
pnpm run build
|
||||
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
### Restart the host
|
||||
### 5. Validate
|
||||
|
||||
The image rebuild does not reload the **host**. Codex's host contribution
|
||||
(`src/providers/codex.ts`) registers the `/home/node/.codex` bind mount + env
|
||||
passthrough, and the running host only picks it up on restart. Skip this and the
|
||||
first Codex turn fails with `EACCES` writing `/home/node/.codex/config.toml` —
|
||||
with no mount, Docker auto-creates the dir root-owned and the non-root container
|
||||
user can't write to it.
|
||||
|
||||
```bash
|
||||
# macOS (launchd)
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
# Linux (systemd)
|
||||
systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
### Validate
|
||||
|
||||
```bash
|
||||
```nc:run effect:test
|
||||
pnpm vitest run src/providers/codex-registration.test.ts src/providers/codex-host-contribution.test.ts src/providers/codex-agents-md.test.ts setup/providers/
|
||||
```
|
||||
```nc:run effect:test
|
||||
cd container/agent-runner && bun test src/providers/
|
||||
```
|
||||
|
||||
@@ -116,9 +95,7 @@ The registration tests import only the real barrels — they go red if a barrel
|
||||
|
||||
## Authenticate
|
||||
|
||||
> **Run this in a separate, real terminal — it is interactive.** It prompts for ChatGPT-subscription vs OpenAI-API-key and then drives a browser/device login, so it needs a TTY to answer prompts.
|
||||
|
||||
```bash
|
||||
```nc:run effect:external
|
||||
pnpm exec tsx setup/index.ts --step provider-auth codex
|
||||
```
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
// Structural guard for the Codex CLI install in container/cli-tools.json.
|
||||
//
|
||||
// @openai/codex is a CLI *binary* installed from the global-CLI manifest (a
|
||||
// json-merge seam), not an importable package, so the barrel-driven
|
||||
// registration tests cannot see it. This test reads the real cli-tools.json
|
||||
// and asserts the @openai/codex entry is present and pinned to an exact
|
||||
// version. It goes red if the manifest entry is dropped or unpins.
|
||||
//
|
||||
// Runs under bun (same suite as the container registration test):
|
||||
// cd container/agent-runner && bun test src/providers/codex-cli-tools.test.ts
|
||||
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
|
||||
// container/agent-runner/src/providers/ -> container/cli-tools.json
|
||||
const MANIFEST = path.join(import.meta.dir, '..', '..', '..', 'cli-tools.json');
|
||||
const manifestPresent = existsSync(MANIFEST);
|
||||
|
||||
// Read lazily — `describe.skipIf` still runs the body to register tests, so the
|
||||
// read has to be guarded for the bare-branch (no manifest) case.
|
||||
const tools: Array<{ name: string; version: string }> = manifestPresent
|
||||
? JSON.parse(readFileSync(MANIFEST, 'utf8'))
|
||||
: [];
|
||||
const codex = tools.find((t) => t.name === '@openai/codex');
|
||||
|
||||
// cli-tools.json is a trunk file; on the bare providers branch it isn't present,
|
||||
// so skip there. In an installed tree (trunk + this payload) it must carry the
|
||||
// pinned @openai/codex entry.
|
||||
describe.skipIf(!manifestPresent)('container/cli-tools.json codex CLI install', () => {
|
||||
it('includes the @openai/codex entry', () => {
|
||||
expect(codex).toBeDefined();
|
||||
});
|
||||
|
||||
it('pins it to an exact semver (no latest, no ranges)', () => {
|
||||
expect(codex?.version).toMatch(/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -54,10 +54,8 @@ Remove the NanoClaw block from your Emacs config (`config.el`, `~/.spacemacs`, o
|
||||
|
||||
Reload your config or restart Emacs.
|
||||
|
||||
## 5. Remove the messaging group (optional)
|
||||
## 5. Messaging group (left intact)
|
||||
|
||||
To clean up the wired messaging group:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='emacs'); DELETE FROM messaging_groups WHERE channel_type='emacs';"
|
||||
```
|
||||
Your wired messaging group and conversation history are **not** removed — you
|
||||
created them at runtime, not this skill's install. To purge them deliberately,
|
||||
delete them yourself with `ncl messaging-groups delete <id>`.
|
||||
|
||||
@@ -12,14 +12,13 @@ ncl groups config remove-mcp-server --id <group-id> --name calendar
|
||||
|
||||
## 2. Remove the `.calendar-mcp` mount from the DB (per group)
|
||||
|
||||
There is no `ncl groups config remove-mount` verb yet (tracked in [#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Until it ships, drop the entry via the in-tree wrapper (`scripts/q.ts`):
|
||||
This is a **host-only / operator** verb — run it host-side. It's idempotent (a no-op if the mount is absent):
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
|
||||
SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \
|
||||
WHERE json_extract(value, '\$.containerPath') != '.calendar-mcp'), \
|
||||
updated_at = datetime('now') \
|
||||
WHERE agent_group_id = '<group-id>';"
|
||||
ncl groups config remove-mount \
|
||||
--id <group-id> \
|
||||
--host "$HOME/.calendar-mcp" \
|
||||
--container .calendar-mcp
|
||||
```
|
||||
|
||||
## 3. Delete the copied test file
|
||||
|
||||
@@ -133,6 +133,8 @@ pnpm exec vitest run src/gcal-dockerfile.test.ts
|
||||
|
||||
`cp` overwrites in place, so re-running this skill is safe.
|
||||
|
||||
**This is the skill's only in-tree integration test.** The Phase 3 `ncl groups config add-mcp-server` and `add-mount` steps are runtime writes to the central DB — they leave no line in the source tree whose deletion a test could catch, so a registration test is structurally inapplicable. They're verified at runtime instead (Phase 5).
|
||||
|
||||
### Rebuild the container image
|
||||
|
||||
```bash
|
||||
@@ -160,27 +162,22 @@ Approval behaviour depends on where you run it: from inside an agent's container
|
||||
|
||||
### Add the `.calendar-mcp` mount
|
||||
|
||||
There is no `ncl groups config add-mount` verb yet (tracked in [#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Until that ships, edit the DB directly via the in-tree wrapper (`scripts/q.ts` — `setup/verify.ts:5` codifies that NanoClaw avoids depending on the `sqlite3` CLI binary, so don't shell out to it):
|
||||
This is a **host-only / operator** verb — it's rejected from inside a container at any `cli_scope`, so run it host-side when you (the operator) apply this skill via `/setup`, `/customize`, or `/manage-mounts`. It's idempotent (skips if the mount is already present).
|
||||
|
||||
```bash
|
||||
GROUP_ID='<group-id>'
|
||||
HOST_PATH="$HOME/.calendar-mcp"
|
||||
MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".calendar-mcp", readonly:false}')
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
|
||||
SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \
|
||||
updated_at = datetime('now') \
|
||||
WHERE agent_group_id = '$GROUP_ID';"
|
||||
ncl groups config add-mount \
|
||||
--id <group-id> \
|
||||
--host "$HOME/.calendar-mcp" \
|
||||
--container .calendar-mcp
|
||||
```
|
||||
|
||||
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is ISO-string everywhere else in the schema, so use `datetime('now')` — not `strftime('%s','now')`, which would silently mix epoch ints into a column of YYYY-MM-DD HH:MM:SS strings.
|
||||
`--container` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/<relative>`). No `--ro`: the MCP server may rewrite `credentials.json` on token refresh, so the mount must be read-write.
|
||||
|
||||
**Switch to `ncl groups config add-mount` once #2395 lands.** Update this skill at that time.
|
||||
|
||||
`containerPath` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/<relative>`).
|
||||
The mount also needs to be in the external mount allowlist (`~/.config/nanoclaw/mount-allowlist.json`) to take effect at spawn — see the Phase 1 "Verify mount allowlist covers the path" step. A container restart (`ncl groups restart`) is needed for the mount to apply.
|
||||
|
||||
**Why this can't be `groups/<folder>/container.json`:** post-migration `014-container-configs`, `materializeContainerJson` in `src/container-config.ts` rewrites that file from the DB on every spawn. Anything hand-edited there is silently overwritten on next restart.
|
||||
|
||||
**Same-group-as-gmail tip:** if this group already has the gmail MCP + `.gmail-mcp` mount, both coexist — `ncl groups config add-mcp-server` only updates the named entry, and `json_insert` appends to `additional_mounts` without disturbing existing entries.
|
||||
**Same-group-as-gmail tip:** if this group already has the gmail MCP + `.gmail-mcp` mount, both coexist — `ncl groups config add-mcp-server` only updates the named entry, and `add-mount` appends to `additional_mounts` without disturbing existing entries.
|
||||
|
||||
## Phase 4: Build and Restart
|
||||
|
||||
|
||||
@@ -5,63 +5,70 @@ description: Add Google Chat channel integration via Chat SDK.
|
||||
|
||||
# Add Google Chat Channel
|
||||
|
||||
Adds Google Chat support via the Chat SDK bridge.
|
||||
Adds Google Chat support via the Chat SDK bridge. NanoClaw doesn't ship channels
|
||||
in trunk — this skill copies the Google Chat adapter in from the `channels`
|
||||
branch.
|
||||
|
||||
## Install
|
||||
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
|
||||
reads the prose and applies them, and a parser can apply them deterministically
|
||||
from the same document. Every directive is idempotent, so the whole skill is
|
||||
safe to re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Google Chat adapter in from the `channels` branch.
|
||||
## Apply
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
### 1. Copy the adapter and its registration test
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
Fetch the `channels` branch and copy the Google Chat adapter and its
|
||||
registration test into `src/channels/` (overwrite — the branch is canonical):
|
||||
|
||||
- `src/channels/gchat.ts` exists
|
||||
- `src/channels/gchat-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './gchat.js';`
|
||||
- `@chat-adapter/gchat` is listed in `package.json` dependencies
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
### 1. Fetch the channels branch
|
||||
|
||||
```bash
|
||||
git fetch origin channels
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/gchat.ts
|
||||
src/channels/gchat-registration.test.ts
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Register the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/gchat.ts > src/channels/gchat.ts
|
||||
git show origin/channels:src/channels/gchat-registration.test.ts > src/channels/gchat-registration.test.ts
|
||||
```
|
||||
Append the self-registration import to the channel barrel (skipped if the line
|
||||
is already present). This one line is the skill's only reach-in into core:
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
```nc:append to:src/channels/index.ts
|
||||
import './gchat.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
### 3. Install the adapter package
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/gchat@4.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>
|
||||
|
||||
@@ -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?**
|
||||
|
||||
@@ -19,17 +19,17 @@ ncl groups config remove-mcp-server --id <group-id> --name gmail
|
||||
|
||||
## 3. Remove the `.gmail-mcp` mount (per group)
|
||||
|
||||
There is no `ncl groups config remove-mount` verb yet ([#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Edit the central DB via the in-tree wrapper (`scripts/q.ts` — NanoClaw avoids depending on the `sqlite3` CLI, `setup/verify.ts:5`). Run from your NanoClaw project root (where `data/v2.db` lives):
|
||||
Remove the mount with the host-only `ncl groups config remove-mount` verb (operator-only; rejected from inside a container). For each group that had Gmail wired:
|
||||
|
||||
```bash
|
||||
GROUP_ID='<group-id>'
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
|
||||
SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \
|
||||
WHERE json_extract(value, '\$.containerPath') != '.gmail-mcp'), \
|
||||
updated_at = datetime('now') \
|
||||
WHERE agent_group_id = '$GROUP_ID';"
|
||||
ncl groups config remove-mount \
|
||||
--id <group-id> \
|
||||
--host "$HOME/.gmail-mcp" \
|
||||
--container .gmail-mcp
|
||||
```
|
||||
|
||||
The verb is idempotent — a no-op if the mount is already absent.
|
||||
|
||||
## 4. Remove the Dockerfile install
|
||||
|
||||
In `container/Dockerfile`, delete the `ARG GMAIL_MCP_VERSION=...` line and the `pnpm install -g` `RUN` block that installs `@gongrzhe/server-gmail-autoauth-mcp` and `zod-to-json-schema`.
|
||||
|
||||
@@ -181,21 +181,16 @@ Approval behaviour depends on where you run it: from inside an agent's container
|
||||
|
||||
### Add the `.gmail-mcp` mount
|
||||
|
||||
There is no `ncl groups config add-mount` verb yet (tracked in [#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Until that ships, edit the DB directly via the in-tree wrapper (`scripts/q.ts` — `setup/verify.ts:5` codifies that NanoClaw avoids depending on the `sqlite3` CLI binary, so don't shell out to it):
|
||||
Register the mount with the host-only `ncl groups config add-mount` verb. For each chosen `<group-id>`:
|
||||
|
||||
```bash
|
||||
GROUP_ID='<group-id>'
|
||||
HOST_PATH="$HOME/.gmail-mcp"
|
||||
MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".gmail-mcp", readonly:false}')
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
|
||||
SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \
|
||||
updated_at = datetime('now') \
|
||||
WHERE agent_group_id = '$GROUP_ID';"
|
||||
ncl groups config add-mount \
|
||||
--id <group-id> \
|
||||
--host "$HOME/.gmail-mcp" \
|
||||
--container .gmail-mcp
|
||||
```
|
||||
|
||||
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is ISO-string everywhere else in the schema, so use `datetime('now')` — not `strftime('%s','now')`, which would silently mix epoch ints into a column of YYYY-MM-DD HH:MM:SS strings.
|
||||
|
||||
**Switch to `ncl groups config add-mount` once #2395 lands.** Update this skill at that time.
|
||||
`--host` is the host path, `--container` is the in-container path (relative, lands at `/workspace/extra/.gmail-mcp`). No `--ro` — the MCP server writes refreshed token state back into the mount. The verb is idempotent (a re-run skips if the mount is already present) and operator-only (host-side; rejected from inside a container), so run it from a host operator shell when applying this skill.
|
||||
|
||||
**Why the container path is relative:** `mount-security` rejects absolute `containerPath` values. Additional mounts are prefixed with `/workspace/extra/`, so `containerPath: ".gmail-mcp"` lands at `/workspace/extra/.gmail-mcp`. The MCP server's `GMAIL_OAUTH_PATH` / `GMAIL_CREDENTIALS_PATH` env vars point at that absolute location inside the container.
|
||||
|
||||
|
||||
@@ -8,10 +8,9 @@
|
||||
* allowedTools: [ ...TOOL_ALLOWLIST, ...Object.keys(this.mcpServers).map(mcpAllowPattern) ]
|
||||
*
|
||||
* `mcpAllowPattern` is not exported and the call site lives inside the SDK query options,
|
||||
* so we assert the derivation structurally. Delete or rename the derivation and this goes
|
||||
* red — surfacing that `gmail` tools would silently be filtered out despite being registered.
|
||||
*
|
||||
* `mcpAllowPattern` itself is exercised directly to prove `gmail` -> `mcp__gmail__*`.
|
||||
* so the derivation is non-invocable from a test — we guard it structurally. Delete or
|
||||
* rename either half (the function or the spread into allowedTools) and this goes red,
|
||||
* surfacing that `gmail` tools would silently be filtered out despite being registered.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
@@ -25,11 +24,6 @@ function source(): { sf: ts.SourceFile; text: string } {
|
||||
return { sf: ts.createSourceFile(p, text, ts.ScriptTarget.Latest, true), text };
|
||||
}
|
||||
|
||||
/** Reimplement the sanitizer the provider applies, to assert the gmail name maps cleanly. */
|
||||
function expectedPattern(name: string): string {
|
||||
return `mcp__${name.replace(/[^a-zA-Z0-9_-]/g, '_')}__*`;
|
||||
}
|
||||
|
||||
describe('claude.ts derives MCP allow-patterns from the registered servers', () => {
|
||||
const { sf, text } = source();
|
||||
|
||||
@@ -48,8 +42,4 @@ describe('claude.ts derives MCP allow-patterns from the registered servers', ()
|
||||
const flat = text.replace(/\s+/g, ' ');
|
||||
expect(flat).toContain('Object.keys(this.mcpServers).map(mcpAllowPattern)');
|
||||
});
|
||||
|
||||
it('maps a gmail server name to mcp__gmail__*', () => {
|
||||
expect(expectedPattern('gmail')).toBe('mcp__gmail__*');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,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
|
||||
|
||||
|
||||
@@ -18,16 +18,7 @@ rm -f src/channels/linear.ts src/channels/linear-registration.test.ts
|
||||
|
||||
## 2. Remove credentials
|
||||
|
||||
Remove the Linear env vars from `.env`, then re-sync to the container:
|
||||
|
||||
```bash
|
||||
LINEAR_CLIENT_ID
|
||||
LINEAR_CLIENT_SECRET
|
||||
LINEAR_API_KEY
|
||||
LINEAR_WEBHOOK_SECRET
|
||||
LINEAR_BOT_USERNAME
|
||||
LINEAR_TEAM_KEY
|
||||
```
|
||||
Remove `LINEAR_CLIENT_ID`, `LINEAR_CLIENT_SECRET`, `LINEAR_API_KEY`, `LINEAR_WEBHOOK_SECRET`, `LINEAR_BOT_USERNAME`, and `LINEAR_TEAM_KEY` from `.env`, then re-sync to the container:
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
|
||||
@@ -5,7 +5,15 @@ description: Add Linear channel integration via Chat SDK. Issue comment threads
|
||||
|
||||
# Add Linear Channel
|
||||
|
||||
Adds Linear support via the Chat SDK bridge. The agent participates in issue comment threads. Every comment on a Linear issue triggers the agent — no @-mention needed.
|
||||
Adds Linear support via the Chat SDK bridge. The agent participates in issue
|
||||
comment threads. Every comment on a Linear issue triggers the agent — no
|
||||
@-mention needed. NanoClaw doesn't ship channels in trunk — this skill copies the
|
||||
Linear adapter in from the `channels` branch.
|
||||
|
||||
The mechanical steps under **Apply** carry `nc:` directive fences: an agent reads
|
||||
the prose and applies them, and a parser can apply them deterministically from
|
||||
the same document. Every directive is idempotent, so the whole skill is safe to
|
||||
re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -20,61 +28,65 @@ Adds Linear support via the Chat SDK bridge. The agent participates in issue com
|
||||
|
||||
**Alternative:** Use a Personal API Key (`LINEAR_API_KEY`) for simpler setup. The agent will post as you, and your own comments will be filtered (other team members' comments still work).
|
||||
|
||||
## Install
|
||||
## Apply
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Linear adapter in from the `channels` branch and wires it into the channel registry. Linear OAuth apps post and read comments under an app identity that can't be @-mentioned, so when you wire the channel in `/manage-channels`, pick an engage mode that responds to plain comments rather than mention-only.
|
||||
Linear OAuth apps post and read comments under an app identity that can't be
|
||||
@-mentioned, so when you wire the channel in `/manage-channels`, pick an engage
|
||||
mode that responds to plain comments rather than mention-only.
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
### 1. Copy the adapter and its registration test
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
Fetch the `channels` branch and copy the Linear adapter and its registration
|
||||
test into `src/channels/` (overwrite — the branch is canonical):
|
||||
|
||||
- `src/channels/linear.ts` exists
|
||||
- `src/channels/linear-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './linear.js';`
|
||||
- `@chat-adapter/linear` is listed in `package.json` dependencies
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
### 1. Fetch the channels branch
|
||||
|
||||
```bash
|
||||
git fetch origin channels
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/linear.ts
|
||||
src/channels/linear-registration.test.ts
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Register the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/linear.ts > src/channels/linear.ts
|
||||
git show origin/channels:src/channels/linear-registration.test.ts > src/channels/linear-registration.test.ts
|
||||
```
|
||||
Append the self-registration import to the channel barrel (skipped if the line
|
||||
is already present). This one line is the skill's only reach-in into the channel
|
||||
registry:
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
```nc:append to:src/channels/index.ts
|
||||
import './linear.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
### 3. Install the adapter package
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/linear@4.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
|
||||
|
||||
|
||||
@@ -18,22 +18,7 @@ rm -f src/channels/matrix.ts src/channels/matrix-registration.test.ts
|
||||
|
||||
## 2. Remove credentials
|
||||
|
||||
Remove the `MATRIX_*` lines from `.env`:
|
||||
|
||||
```bash
|
||||
MATRIX_BASE_URL
|
||||
MATRIX_USERNAME
|
||||
MATRIX_PASSWORD
|
||||
MATRIX_USER_ID
|
||||
MATRIX_BOT_USERNAME
|
||||
MATRIX_ACCESS_TOKEN
|
||||
MATRIX_INVITE_AUTOJOIN
|
||||
MATRIX_INVITE_AUTOJOIN_ALLOWLIST
|
||||
MATRIX_RECOVERY_KEY
|
||||
MATRIX_DEVICE_ID
|
||||
```
|
||||
|
||||
Then re-sync to the container:
|
||||
Remove the Matrix env vars apply set — `MATRIX_BASE_URL`, `MATRIX_USER_ID`, `MATRIX_BOT_USERNAME`, and whichever auth path you chose (`MATRIX_USERNAME` + `MATRIX_PASSWORD`, or `MATRIX_ACCESS_TOKEN`) — from `.env`, then re-sync to the container:
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
|
||||
@@ -5,57 +5,53 @@ description: Add Matrix channel integration via Chat SDK. Works with any Matrix
|
||||
|
||||
# Add Matrix Channel
|
||||
|
||||
Adds Matrix support via the Chat SDK bridge.
|
||||
Adds Matrix support via the Chat SDK bridge. NanoClaw doesn't ship channels in
|
||||
trunk — this skill copies the Matrix adapter in from the `channels` branch.
|
||||
|
||||
## Install
|
||||
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
|
||||
reads the prose and applies them, and a parser can apply them deterministically
|
||||
from the same document. Every directive is idempotent, so the whole skill is
|
||||
safe to re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Matrix adapter in from the `channels` branch.
|
||||
## Apply
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
### 1. Copy the adapter
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
Fetch the `channels` branch and copy the Matrix adapter into `src/channels/`
|
||||
(overwrite — the branch is canonical):
|
||||
|
||||
- `src/channels/matrix.ts` exists
|
||||
- `src/channels/matrix-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './matrix.js';`
|
||||
- `@beeper/chat-adapter-matrix` is listed in `package.json` dependencies
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
### 1. Fetch the channels branch
|
||||
|
||||
```bash
|
||||
git fetch origin channels
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/matrix.ts
|
||||
src/channels/matrix-registration.test.ts
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and its registration test
|
||||
### 2. Register the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/matrix.ts > src/channels/matrix.ts
|
||||
git show origin/channels:src/channels/matrix-registration.test.ts > src/channels/matrix-registration.test.ts
|
||||
```
|
||||
Append the self-registration import to the channel barrel (skipped if the line
|
||||
is already present). This one line is the skill's only reach-in into core:
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
```nc:append to:src/channels/index.ts
|
||||
import './matrix.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
### 3. Install the adapter package
|
||||
|
||||
```bash
|
||||
pnpm install @beeper/chat-adapter-matrix@0.2.0
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`.
|
||||
The Matrix adapter lives in the `@beeper/` namespace and versions on its own
|
||||
track (not the `@chat-adapter/*` family), so it carries its own pin:
|
||||
|
||||
```nc:dep
|
||||
@beeper/chat-adapter-matrix@0.2.0
|
||||
```
|
||||
|
||||
### 5. Patch matrix-js-sdk ESM imports
|
||||
### 4. Patch matrix-js-sdk ESM imports
|
||||
|
||||
The adapter's published dist references `matrix-js-sdk/lib/...` without `.js`
|
||||
extensions, which fails under Node 22 strict ESM resolution. Add the missing
|
||||
extensions (idempotent — safe to re-run):
|
||||
extensions (idempotent — safe to re-run). Re-run this after every `pnpm install`
|
||||
that touches the adapter:
|
||||
|
||||
```bash
|
||||
```nc:run effect:external
|
||||
node -e '
|
||||
const fs = require("fs"), path = require("path");
|
||||
const root = "node_modules/.pnpm";
|
||||
@@ -69,22 +65,32 @@ node -e '
|
||||
'
|
||||
```
|
||||
|
||||
Re-run this after every `pnpm install` that touches the adapter.
|
||||
### 5. Build
|
||||
|
||||
### 6. Build and validate
|
||||
Build guards the typed `createChatSdkBridge(...)` core call the adapter makes
|
||||
and proves the dependency is installed and the ESM patch took. It also fails if
|
||||
the `import './matrix.js';` line is missing or the barrel can't evaluate.
|
||||
|
||||
```bash
|
||||
```nc:run effect:build
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/matrix-registration.test.ts
|
||||
```
|
||||
|
||||
Both must be clean before proceeding. `matrix-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `matrix`. It goes red if the `import './matrix.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@beeper/chat-adapter-matrix` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
`matrix-registration.test.ts` imports the real channel barrel and asserts the
|
||||
registry contains `matrix`. It goes red if the import line is deleted or drifts,
|
||||
if the barrel fails to evaluate, or if `@beeper/chat-adapter-matrix` isn't
|
||||
installed (the import throws) — so it also covers the dependency from step 3.
|
||||
|
||||
End-to-end message delivery against a real Matrix homeserver is verified manually once the service is running — see Next Steps.
|
||||
End-to-end message delivery against a real Matrix homeserver is verified
|
||||
manually once the service is running — see Next Steps.
|
||||
|
||||
## Credentials
|
||||
|
||||
The bot needs its own Matrix account — separate from the user's account. This is required because Matrix cannot send DMs to yourself.
|
||||
The bot needs its own Matrix account — separate from the user's account. This is
|
||||
required because Matrix cannot send DMs to yourself. These steps are human and
|
||||
interactive (no parser can click through Element), so they stay prose.
|
||||
|
||||
### Create a bot account
|
||||
|
||||
@@ -131,12 +137,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -4,21 +4,15 @@ Idempotent — safe to run even if some steps were never applied. Run Steps 1–
|
||||
|
||||
## 1. Remove the mount from the container config
|
||||
|
||||
Read the current mounts, drop the entry whose `containerPath` is `/usr/local/bin/rtk`, and write the rest back.
|
||||
Remove the rtk mount with the host-only `remove-mount` verb. It is idempotent — a no-op if the mount isn't present:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
|
||||
ncl groups config remove-mount --id <group-id> \
|
||||
--host ~/.local/bin/rtk \
|
||||
--container /usr/local/bin/rtk
|
||||
```
|
||||
|
||||
Write the filtered array (omit any entry with `"containerPath":"/usr/local/bin/rtk"`):
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"UPDATE container_configs SET additional_mounts = '<filtered-json>' WHERE agent_group_id = '<group-id>'"
|
||||
```
|
||||
|
||||
If no rtk entry is present, leave the array as-is.
|
||||
This verb is operator-only and runs host-side; it is rejected from inside a container.
|
||||
|
||||
## 2. Remove the PreToolUse hook from settings.json
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ Install [rtk](https://github.com/rtk-ai/rtk) — a CLI proxy delivering 60–90%
|
||||
- `~/.local/bin/rtk` mounted read-only at `/usr/local/bin/rtk` inside the target agent group's containers
|
||||
- `PreToolUse` hook in the agent group's `settings.json` so every Bash call is automatically filtered through rtk — no CLAUDE.md instructions needed
|
||||
|
||||
## Integration tests
|
||||
|
||||
This skill has **no in-tree integration test** by design. Its only functional reach-ins are runtime operator actions — the host-only `ncl groups config add-mount` (Step 3) and the `settings.json` `PreToolUse` hook write (Step 4) — neither of which leaves a line in the source tree whose deletion a test could catch. There are no package dependencies or Dockerfile edits to guard either. Conformance is idempotent apply + `REMOVE.md`; the mount and hook are verified at runtime (see Verify).
|
||||
|
||||
## Step 1 — Install rtk on the host
|
||||
|
||||
```bash
|
||||
@@ -43,33 +47,24 @@ Note the group ID (e.g. `ag-1776342942165-ptgddd`). Repeat Steps 3–5 for each
|
||||
|
||||
## Step 3 — Mount rtk into the container config
|
||||
|
||||
`additional_mounts` is a JSON array column on `container_configs`. Read the current value, merge in the rtk entry, and write the merged array back.
|
||||
|
||||
Read current mounts first:
|
||||
Mount the host rtk binary read-only into the container with the host-only `add-mount` verb. It is idempotent — re-running skips the entry if it is already present:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
|
||||
ncl groups config add-mount --id <group-id> \
|
||||
--host ~/.local/bin/rtk \
|
||||
--container /usr/local/bin/rtk \
|
||||
--ro
|
||||
```
|
||||
|
||||
Build the merged array: keep every existing entry, drop any entry whose `containerPath` is `/usr/local/bin/rtk` (so re-running replaces rather than duplicates), then add the rtk entry:
|
||||
This verb is operator-only and runs host-side (via `/setup`, `/customize`, or `/manage-mounts`); it is rejected from inside a container.
|
||||
|
||||
```json
|
||||
{"hostPath":"/home/<user>/.local/bin/rtk","containerPath":"/usr/local/bin/rtk","readonly":true}
|
||||
```
|
||||
|
||||
Write the merged array back:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"UPDATE container_configs SET additional_mounts = '<merged-json>' WHERE agent_group_id = '<group-id>'"
|
||||
```
|
||||
The host root (`~/.local/bin`) must also be in the external mount allowlist at `~/.config/nanoclaw/mount-allowlist.json` for the mount to take effect at spawn. Add it there if it isn't already.
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
|
||||
ncl groups config get --id <group-id>
|
||||
# Look for the /usr/local/bin/rtk mount
|
||||
```
|
||||
|
||||
## Step 4 — Add the PreToolUse hook to settings.json
|
||||
@@ -120,9 +115,8 @@ Then ask the agent to run `git status` or any other supported command. rtk inter
|
||||
Mount wasn't applied or container wasn't restarted:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
|
||||
# Look for entry with /usr/local/bin/rtk
|
||||
ncl groups config get --id <group-id>
|
||||
# Look for the /usr/local/bin/rtk mount
|
||||
ncl groups restart --id <group-id>
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
-1
@@ -4,7 +4,7 @@ All notable changes to NanoClaw will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- [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
-1
@@ -193,7 +193,7 @@ leaking the token to disk outweighs the debugging value.
|
||||
| `setup/logs.ts` | The logging primitives (`logStep`, `logUserInput`, `logComplete`, `stepRawLog`, `initSetupLog`). Single source of truth for level 2/3 formatting and file paths. |
|
||||
| `setup/<step>.ts` | Individual step implementations. Must emit one terminal status block; must not write directly to the terminal. |
|
||||
| `setup/register-claude-token.sh` | The Anthropic exception. Inherits stdio, prints its own UI, returns a status to the driver. |
|
||||
| `setup/add-telegram.sh` | Non-interactive adapter installer. Reads `TELEGRAM_BOT_TOKEN` from env; never prompts. User-facing bits live in `auto.ts`. |
|
||||
| `setup/channels/telegram.ts` | Telegram channel flow. Installs the adapter in-process by applying the `/add-telegram` skill (directive engine; SKILL.md is the single source of truth), feeding the collected bot token to the skill's `bot_token` prompt var. |
|
||||
| `setup/pair-telegram.ts` | Emits `PAIR_TELEGRAM_CODE` / `PAIR_TELEGRAM_ATTEMPT` / `PAIR_TELEGRAM` status blocks. Never prints UI. The driver renders it via clack notes. |
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
@@ -0,0 +1,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
|
||||
});
|
||||
});
|
||||
@@ -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 3–5) + journal-derived remove.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type JournalEntry =
|
||||
| { op: 'wrote'; path: string }
|
||||
| { op: 'appended'; path: string; line: string }
|
||||
| { op: 'set-env'; key: string }
|
||||
| { op: 'json-merge'; path: string; key: string; value: unknown }
|
||||
| { op: 'ran'; cmd: string; undo?: string };
|
||||
|
||||
export interface AgentTask {
|
||||
kind: string;
|
||||
line: number;
|
||||
reason: string;
|
||||
prose: string; // the surrounding prose the agent reads to apply the step
|
||||
}
|
||||
|
||||
export interface ApplyResult {
|
||||
applied: string[];
|
||||
skipped: string[];
|
||||
deferred: string[]; // prompt vars / blocked consumers with no value yet
|
||||
agentTasks: AgentTask[]; // bounced to an agent — NOT the human
|
||||
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}`);
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the Codex agent provider non-interactively: copy the payload from the
|
||||
# `providers` branch, wire the three provider barrels, and add the Codex CLI to
|
||||
# the container manifest (container/cli-tools.json). The image rebuild is the
|
||||
# caller's job (the setup container step / `./container/build.sh`).
|
||||
#
|
||||
# Emits exactly one status block on stdout (ADD_CODEX); all chatty progress
|
||||
# goes to stderr. Keep in sync with .claude/skills/add-codex/SKILL.md.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with add-codex SKILL.md. This is the canonical Codex CLI pin —
|
||||
# it lands in container/cli-tools.json (the global-CLI manifest), not the Dockerfile.
|
||||
CODEX_VERSION="0.138.0"
|
||||
|
||||
# Resolve the remote carrying the providers branch (same nanoclaw remote that
|
||||
# carries channels — handles forks where it isn't `origin`).
|
||||
# shellcheck source=setup/lib/channels-remote.sh
|
||||
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
|
||||
REMOTE=$(resolve_channels_remote)
|
||||
BRANCH="${REMOTE}/providers"
|
||||
|
||||
# The codex payload — host provider, container runtime, setup module, doctrine.
|
||||
# Barrels are appended to, not copied.
|
||||
PAYLOAD_FILES=(
|
||||
src/providers/codex.ts
|
||||
src/providers/codex-agents-md.ts
|
||||
src/providers/codex-registration.test.ts
|
||||
src/providers/codex-host-contribution.test.ts
|
||||
src/providers/codex-agents-md.test.ts
|
||||
container/agent-runner/src/providers/codex.ts
|
||||
container/agent-runner/src/providers/codex-app-server.ts
|
||||
container/agent-runner/src/providers/exchange-archive.ts
|
||||
container/agent-runner/src/providers/exchange-archive.test.ts
|
||||
container/agent-runner/src/providers/codex-registration.test.ts
|
||||
container/agent-runner/src/providers/codex.factory.test.ts
|
||||
container/agent-runner/src/providers/codex.turns.test.ts
|
||||
container/agent-runner/src/providers/codex-app-server.test.ts
|
||||
container/agent-runner/src/providers/codex-cli-tools.test.ts
|
||||
setup/providers/codex.ts
|
||||
setup/providers/codex.test.ts
|
||||
setup/providers/codex-registration.test.ts
|
||||
container/AGENTS.md
|
||||
)
|
||||
BARRELS=(
|
||||
src/providers/index.ts
|
||||
container/agent-runner/src/providers/index.ts
|
||||
setup/providers/index.ts
|
||||
)
|
||||
|
||||
ALREADY_INSTALLED=true
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
echo "=== NANOCLAW SETUP: ADD_CODEX ==="
|
||||
echo "STATUS: ${status}"
|
||||
echo "CODEX_VERSION: ${CODEX_VERSION}"
|
||||
echo "ALREADY_INSTALLED: ${ALREADY_INSTALLED}"
|
||||
[ -n "$error" ] && echo "ERROR: ${error}"
|
||||
echo "=== END ==="
|
||||
}
|
||||
log() { echo "[add-codex] $*" >&2; }
|
||||
|
||||
# Idempotent: a complete install has the host provider file, the host barrel
|
||||
# import, and the Codex CLI in the container manifest. Any missing → (re)install.
|
||||
need_install() {
|
||||
[ ! -f src/providers/codex.ts ] && return 0
|
||||
! grep -q "^import './codex.js';" src/providers/index.ts 2>/dev/null && return 0
|
||||
! grep -q '@openai/codex' container/cli-tools.json 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
if need_install; then
|
||||
ALREADY_INSTALLED=false
|
||||
|
||||
log "Fetching providers branch from ${REMOTE}…"
|
||||
git fetch "$REMOTE" providers >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch ${REMOTE} providers failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Copying Codex payload from ${BRANCH}…"
|
||||
for f in "${PAYLOAD_FILES[@]}"; do
|
||||
mkdir -p "$(dirname "$f")"
|
||||
git show "${BRANCH}:$f" > "$f" 2>/dev/null || {
|
||||
emit_status failed "providers branch is missing ${f}"
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
log "Wiring provider barrels…"
|
||||
for b in "${BARRELS[@]}"; do
|
||||
grep -q "^import './codex.js';" "$b" || printf "import './codex.js';\n" >> "$b"
|
||||
done
|
||||
|
||||
log "Adding the Codex CLI to the container manifest (cli-tools.json)…"
|
||||
# A json-merge: append { name, version } if absent. The Dockerfile installs
|
||||
# every manifest entry via pinned `pnpm install -g` — no Dockerfile edit, no
|
||||
# awk surgery. @openai/codex has no native postinstall, so no "onlyBuilt".
|
||||
MANIFEST=container/cli-tools.json
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const [file, name, version] = process.argv.slice(1);
|
||||
const tools = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
if (!tools.some((t) => t.name === name)) {
|
||||
tools.push({ name, version });
|
||||
const fmt = (t) =>
|
||||
" { " +
|
||||
Object.entries(t).map(([k, v]) => JSON.stringify(k) + ": " + JSON.stringify(v)).join(", ") +
|
||||
" }";
|
||||
fs.writeFileSync(file, "[\n" + tools.map(fmt).join(",\n") + "\n]\n");
|
||||
}
|
||||
' "$MANIFEST" "@openai/codex" "${CODEX_VERSION}" || {
|
||||
emit_status failed "failed to add @openai/codex to ${MANIFEST}"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
emit_status ok
|
||||
@@ -1,130 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the Discord adapter, persist DISCORD_BOT_TOKEN / APPLICATION_ID /
|
||||
# PUBLIC_KEY to .env + data/env/env, and restart the service. Non-interactive —
|
||||
# the operator-facing "Create a bot" walkthrough, owner confirmation, and
|
||||
# server-invite step live in setup/channels/discord.ts. Credentials come in via
|
||||
# env vars: DISCORD_BOT_TOKEN, DISCORD_APPLICATION_ID, DISCORD_PUBLIC_KEY.
|
||||
#
|
||||
# Emits exactly one status block on stdout (ADD_DISCORD) at the end. All chatty
|
||||
# progress messages go to stderr so setup:auto's raw-log capture sees the full
|
||||
# story without cluttering the final block for the parser.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-discord/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/discord@4.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
|
||||
@@ -1,160 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the iMessage adapter, persist mode/creds to .env + data/env/env,
|
||||
# and restart the service. Non-interactive — the Full Disk Access walkthrough
|
||||
# (local mode) and Photon URL/key prompts (remote mode) live in
|
||||
# setup/channels/imessage.ts. Creds come in via env vars:
|
||||
# IMESSAGE_LOCAL 'true' | 'false' (required)
|
||||
# IMESSAGE_ENABLED 'true' (required when IMESSAGE_LOCAL=true)
|
||||
# IMESSAGE_SERVER_URL (required when IMESSAGE_LOCAL=false)
|
||||
# IMESSAGE_API_KEY (required when IMESSAGE_LOCAL=false)
|
||||
#
|
||||
# Emits exactly one status block on stdout (ADD_IMESSAGE) at the end.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-imessage/SKILL.md.
|
||||
ADAPTER_VERSION="chat-adapter-imessage@0.1.1"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
# shellcheck source=setup/lib/channels-remote.sh
|
||||
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
|
||||
CHANNELS_REMOTE=$(resolve_channels_remote)
|
||||
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
local already=${ADAPTER_ALREADY_INSTALLED:-false}
|
||||
local mode=${IMESSAGE_LOCAL:-}
|
||||
echo "=== NANOCLAW SETUP: ADD_IMESSAGE ==="
|
||||
echo "STATUS: ${status}"
|
||||
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
|
||||
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
|
||||
[ -n "$mode" ] && echo "MODE: $([ "$mode" = "true" ] && echo local || echo remote)"
|
||||
[ -n "$error" ] && echo "ERROR: ${error}"
|
||||
echo "=== END ==="
|
||||
}
|
||||
|
||||
log() { echo "[add-imessage] $*" >&2; }
|
||||
|
||||
# Validate creds based on mode.
|
||||
if [ -z "${IMESSAGE_LOCAL:-}" ]; then
|
||||
emit_status failed "IMESSAGE_LOCAL env var not set (expected true|false)"
|
||||
exit 1
|
||||
fi
|
||||
if [ "${IMESSAGE_LOCAL}" = "true" ]; then
|
||||
if [ -z "${IMESSAGE_ENABLED:-}" ]; then
|
||||
emit_status failed "IMESSAGE_ENABLED env var not set for local mode"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(uname -s)" != "Darwin" ]; then
|
||||
emit_status failed "local mode requires macOS"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
if [ -z "${IMESSAGE_SERVER_URL:-}" ]; then
|
||||
emit_status failed "IMESSAGE_SERVER_URL env var not set for remote mode"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${IMESSAGE_API_KEY:-}" ]; then
|
||||
emit_status failed "IMESSAGE_API_KEY env var not set for remote mode"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
need_install() {
|
||||
[ ! -f src/channels/imessage.ts ] && return 0
|
||||
! grep -q "^import './imessage.js';" src/channels/index.ts 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
ADAPTER_ALREADY_INSTALLED=true
|
||||
if need_install; then
|
||||
ADAPTER_ALREADY_INSTALLED=false
|
||||
log "Fetching channels branch…"
|
||||
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Copying adapter from ${CHANNELS_BRANCH}…"
|
||||
git show "${CHANNELS_BRANCH}:src/channels/imessage.ts" > src/channels/imessage.ts
|
||||
|
||||
# Append self-registration import if missing.
|
||||
if ! grep -q "^import './imessage.js';" src/channels/index.ts; then
|
||||
echo "import './imessage.js';" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
log "Installing ${ADAPTER_VERSION}…"
|
||||
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Building…"
|
||||
pnpm run build >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm run build failed"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
log "Adapter files already installed — skipping install phase."
|
||||
fi
|
||||
|
||||
touch .env
|
||||
upsert_env() {
|
||||
local key=$1 value=$2
|
||||
if grep -q "^${key}=" .env; then
|
||||
awk -v k="$key" -v v="$value" \
|
||||
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
|
||||
.env > .env.tmp && mv .env.tmp .env
|
||||
else
|
||||
echo "${key}=${value}" >> .env
|
||||
fi
|
||||
}
|
||||
|
||||
remove_env() {
|
||||
local key=$1
|
||||
if grep -q "^${key}=" .env 2>/dev/null; then
|
||||
grep -v "^${key}=" .env > .env.tmp && mv .env.tmp .env
|
||||
fi
|
||||
}
|
||||
|
||||
# Write the canonical keys for the chosen mode, strip the opposite mode's
|
||||
# keys so stale values can't confuse the adapter's factory.
|
||||
upsert_env IMESSAGE_LOCAL "$IMESSAGE_LOCAL"
|
||||
if [ "$IMESSAGE_LOCAL" = "true" ]; then
|
||||
upsert_env IMESSAGE_ENABLED "$IMESSAGE_ENABLED"
|
||||
remove_env IMESSAGE_SERVER_URL
|
||||
remove_env IMESSAGE_API_KEY
|
||||
else
|
||||
upsert_env IMESSAGE_SERVER_URL "$IMESSAGE_SERVER_URL"
|
||||
upsert_env IMESSAGE_API_KEY "$IMESSAGE_API_KEY"
|
||||
remove_env IMESSAGE_ENABLED
|
||||
fi
|
||||
|
||||
# Container reads from data/env/env (the host mounts it).
|
||||
mkdir -p data/env
|
||||
cp .env data/env/env
|
||||
|
||||
log "Restarting service so the new adapter picks up the creds…"
|
||||
# shellcheck source=setup/lib/install-slug.sh
|
||||
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
|
||||
;;
|
||||
Linux)
|
||||
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| true
|
||||
;;
|
||||
esac
|
||||
|
||||
# Give the adapter a moment to open chat.db (local) or handshake with
|
||||
# Photon (remote) before emitting success.
|
||||
sleep 3
|
||||
|
||||
emit_status success
|
||||
@@ -1,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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)) {
|
||||
|
||||
@@ -25,6 +25,8 @@ const STEPS: Record<
|
||||
auth: () => import('./auth.js'),
|
||||
'provider-auth': () => import('./provider-auth.js'),
|
||||
'cli-agent': () => import('./cli-agent.js'),
|
||||
// >>> nanoclaw:setup-steps
|
||||
// <<< nanoclaw:setup-steps
|
||||
};
|
||||
|
||||
async function main(): Promise<void> {
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-discord — bundles the preflight + install commands
|
||||
# from the /add-discord skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Discord adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned @chat-adapter/discord package;
|
||||
# builds. All steps are safe to re-run.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_DISCORD ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/discord.ts ]] || needs_install=true
|
||||
grep -q "import './discord.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@chat-adapter/discord"' package.json || needs_install=true
|
||||
[[ -d node_modules/@chat-adapter/discord ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/discord.ts > src/channels/discord.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './discord.js';" src/channels/index.ts; then
|
||||
printf "import './discord.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/discord@4.26.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-gchat — bundles the preflight + install commands
|
||||
# from the /add-gchat skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Google Chat adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned @chat-adapter/gchat package;
|
||||
# builds. All steps are safe to re-run.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_GCHAT ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/gchat.ts ]] || needs_install=true
|
||||
grep -q "import './gchat.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@chat-adapter/gchat"' package.json || needs_install=true
|
||||
[[ -d node_modules/@chat-adapter/gchat ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/gchat.ts > src/channels/gchat.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './gchat.js';" src/channels/index.ts; then
|
||||
printf "import './gchat.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/gchat@4.26.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
@@ -1,47 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-imessage — bundles the preflight + install commands
|
||||
# from the /add-imessage skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the iMessage adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned chat-adapter-imessage package;
|
||||
# builds. Local vs remote mode pick stays in the skill — this script only
|
||||
# handles the deterministic install. All steps are safe to re-run.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_IMESSAGE ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/imessage.ts ]] || needs_install=true
|
||||
grep -q "import './imessage.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"chat-adapter-imessage"' package.json || needs_install=true
|
||||
[[ -d node_modules/chat-adapter-imessage ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/imessage.ts > src/channels/imessage.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './imessage.js';" src/channels/index.ts; then
|
||||
printf "import './imessage.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install chat-adapter-imessage@0.1.1
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
@@ -1,95 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-linear — bundles the preflight + install commands
|
||||
# from the /add-linear skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Linear adapter in from the `channels` branch; appends the
|
||||
# self-registration import; patches src/channels/chat-sdk-bridge.ts to add
|
||||
# catch-all forwarding (Linear OAuth apps can't be @-mentioned, so the
|
||||
# onNewMention handler never fires — the bridge needs a catchAll path);
|
||||
# installs the pinned @chat-adapter/linear package; builds. All steps are
|
||||
# safe to re-run.
|
||||
#
|
||||
# Note: the bridge patch's onNewMessage handler passes `false` for isMention
|
||||
# (current trunk signature requires the arg). The /add-linear SKILL's
|
||||
# snippet omits the arg — this script uses the full signature so TypeScript
|
||||
# builds cleanly.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_LINEAR ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/linear.ts ]] || needs_install=true
|
||||
grep -q "import './linear.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@chat-adapter/linear"' package.json || needs_install=true
|
||||
[[ -d node_modules/@chat-adapter/linear ]] || needs_install=true
|
||||
grep -q 'catchAll' src/channels/chat-sdk-bridge.ts || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/linear.ts > src/channels/linear.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './linear.js';" src/channels/index.ts; then
|
||||
printf "import './linear.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: patch-bridge-catchall-field"
|
||||
if ! grep -q 'catchAll?: boolean;' src/channels/chat-sdk-bridge.ts; then
|
||||
awk '
|
||||
/^export interface ChatSdkBridgeConfig \{/ { in_iface = 1 }
|
||||
in_iface && /^\}/ && !inserted {
|
||||
print " /**"
|
||||
print " * Forward ALL messages in unsubscribed threads, not just @-mentions."
|
||||
print " * Use for platforms where the bot identity can'\''t be @-mentioned (e.g."
|
||||
print " * Linear OAuth apps). The thread is auto-subscribed on first message."
|
||||
print " */"
|
||||
print " catchAll?: boolean;"
|
||||
inserted = 1
|
||||
in_iface = 0
|
||||
}
|
||||
{ print }
|
||||
' src/channels/chat-sdk-bridge.ts > src/channels/chat-sdk-bridge.ts.tmp \
|
||||
&& mv src/channels/chat-sdk-bridge.ts.tmp src/channels/chat-sdk-bridge.ts
|
||||
fi
|
||||
|
||||
echo "STEP: patch-bridge-catchall-handler"
|
||||
if ! grep -q 'if (config.catchAll) {' src/channels/chat-sdk-bridge.ts; then
|
||||
awk '
|
||||
/ \/\/ DMs — apply engage rules too/ && !inserted {
|
||||
print " // Catch-all for platforms where @-mention isn'\''t possible (e.g. Linear"
|
||||
print " // OAuth apps). Forward every unsubscribed message and auto-subscribe."
|
||||
print " if (config.catchAll) {"
|
||||
print " chat.onNewMessage(/.*/, async (thread, message) => {"
|
||||
print " const channelId = adapter.channelIdFromThreadId(thread.id);"
|
||||
print " await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false));"
|
||||
print " await thread.subscribe();"
|
||||
print " });"
|
||||
print " }"
|
||||
print ""
|
||||
inserted = 1
|
||||
}
|
||||
{ print }
|
||||
' src/channels/chat-sdk-bridge.ts > src/channels/chat-sdk-bridge.ts.tmp \
|
||||
&& mv src/channels/chat-sdk-bridge.ts.tmp src/channels/chat-sdk-bridge.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/linear@4.26.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
@@ -1,62 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-matrix — bundles the preflight + install commands
|
||||
# from the /add-matrix skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Matrix adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned @beeper/chat-adapter-matrix
|
||||
# package; patches the adapter's published dist so its matrix-js-sdk/lib
|
||||
# imports carry .js extensions (required under Node 22 strict ESM); builds.
|
||||
# All steps are safe to re-run — re-run this script after any pnpm install
|
||||
# that touches the adapter.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_MATRIX ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/matrix.ts ]] || needs_install=true
|
||||
grep -q "import './matrix.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@beeper/chat-adapter-matrix"' package.json || needs_install=true
|
||||
[[ -d node_modules/@beeper/chat-adapter-matrix ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/matrix.ts > src/channels/matrix.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './matrix.js';" src/channels/index.ts; then
|
||||
printf "import './matrix.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @beeper/chat-adapter-matrix@0.2.0
|
||||
|
||||
echo "STEP: patch-esm-extensions"
|
||||
node -e '
|
||||
const fs = require("fs"), path = require("path");
|
||||
const root = "node_modules/.pnpm";
|
||||
const dir = fs.readdirSync(root).find(d => d.startsWith("@beeper+chat-adapter-matrix@"));
|
||||
if (!dir) { console.log("Matrix adapter not installed"); process.exit(0); }
|
||||
const f = path.join(root, dir, "node_modules/@beeper/chat-adapter-matrix/dist/index.js");
|
||||
fs.writeFileSync(f, fs.readFileSync(f, "utf8").replace(
|
||||
/from "(matrix-js-sdk\/lib\/[^"]+?)(?<!\.js)"/g, "from \"$1.js\""
|
||||
));
|
||||
console.log("Patched", f);
|
||||
'
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-resend — bundles the preflight + install commands
|
||||
# from the /add-resend skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Resend adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned @resend/chat-sdk-adapter
|
||||
# package; builds. All steps are safe to re-run.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_RESEND ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/resend.ts ]] || needs_install=true
|
||||
grep -q "import './resend.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@resend/chat-sdk-adapter"' package.json || needs_install=true
|
||||
[[ -d node_modules/@resend/chat-sdk-adapter ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/resend.ts > src/channels/resend.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './resend.js';" src/channels/index.ts; then
|
||||
printf "import './resend.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @resend/chat-sdk-adapter@0.1.1
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-slack — bundles the preflight + install commands
|
||||
# from the /add-slack skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Slack adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned @chat-adapter/slack package;
|
||||
# builds. All steps are safe to re-run.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_SLACK ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/slack.ts ]] || needs_install=true
|
||||
grep -q "import './slack.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@chat-adapter/slack"' package.json || needs_install=true
|
||||
[[ -d node_modules/@chat-adapter/slack ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/slack.ts > src/channels/slack.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './slack.js';" src/channels/index.ts; then
|
||||
printf "import './slack.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/slack@4.26.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-teams — bundles the preflight + install commands
|
||||
# from the /add-teams skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Teams adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned @chat-adapter/teams package;
|
||||
# builds. All steps are safe to re-run.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_TEAMS ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/teams.ts ]] || needs_install=true
|
||||
grep -q "import './teams.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@chat-adapter/teams"' package.json || needs_install=true
|
||||
[[ -d node_modules/@chat-adapter/teams ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/teams.ts > src/channels/teams.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './teams.js';" src/channels/index.ts; then
|
||||
printf "import './teams.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/teams@4.26.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
@@ -1,72 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-telegram — bundles the preflight + install commands
|
||||
# from the /add-telegram skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials and pairing.
|
||||
#
|
||||
# Copies the Telegram adapter, helpers, tests, and the pair-telegram setup
|
||||
# step in from the `channels` branch; appends the self-registration import;
|
||||
# registers the `pair-telegram` entry in the setup STEPS map; installs the
|
||||
# pinned @chat-adapter/telegram package; builds. All steps are safe to re-run.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_TELEGRAM ==="
|
||||
|
||||
CHANNEL_FILES=(
|
||||
src/channels/telegram.ts
|
||||
src/channels/telegram-pairing.ts
|
||||
src/channels/telegram-pairing.test.ts
|
||||
src/channels/telegram-markdown-sanitize.ts
|
||||
src/channels/telegram-markdown-sanitize.test.ts
|
||||
setup/pair-telegram.ts
|
||||
)
|
||||
|
||||
needs_install=false
|
||||
for f in "${CHANNEL_FILES[@]}"; do
|
||||
[[ -f "$f" ]] || needs_install=true
|
||||
done
|
||||
grep -q "import './telegram.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q "'pair-telegram':" setup/index.ts || needs_install=true
|
||||
grep -q '"@chat-adapter/telegram"' package.json || needs_install=true
|
||||
[[ -d node_modules/@chat-adapter/telegram ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
for f in "${CHANNEL_FILES[@]}"; do
|
||||
git show "origin/channels:$f" > "$f"
|
||||
done
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './telegram.js';" src/channels/index.ts; then
|
||||
printf "import './telegram.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: register-setup-step"
|
||||
if ! grep -q "'pair-telegram':" setup/index.ts; then
|
||||
awk '
|
||||
{ print }
|
||||
/register: \(\) => import/ && !inserted {
|
||||
print " '\''pair-telegram'\'': () => import('\''./pair-telegram.js'\''),"
|
||||
inserted = 1
|
||||
}
|
||||
' setup/index.ts > setup/index.ts.tmp && mv setup/index.ts.tmp setup/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/telegram@4.26.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-webex — bundles the preflight + install commands
|
||||
# from the /add-webex skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Webex adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned @bitbasti/chat-adapter-webex
|
||||
# package; builds. All steps are safe to re-run.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_WEBEX ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/webex.ts ]] || needs_install=true
|
||||
grep -q "import './webex.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@bitbasti/chat-adapter-webex"' package.json || needs_install=true
|
||||
[[ -d node_modules/@bitbasti/chat-adapter-webex ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/webex.ts > src/channels/webex.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './webex.js';" src/channels/index.ts; then
|
||||
printf "import './webex.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @bitbasti/chat-adapter-webex@0.1.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
@@ -64,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',
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.` };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user