mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-21 18:30:15 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a141f24f69 | |||
| bb1db4e35b | |||
| 5684cfd69b |
@@ -1,78 +1,83 @@
|
||||
# Remove the Codex agent provider
|
||||
# Remove Codex provider
|
||||
|
||||
Reverses every change `/add-codex` makes and returns every group to the default provider. Safe to run when partially installed — skip any step whose target is already absent.
|
||||
Idempotent — safe to run even if some steps were never applied. Reverses both the host (`src/providers/`) and container (`container/agent-runner/src/providers/`) trees, plus the Dockerfile CLI install.
|
||||
|
||||
## 1. Switch codex groups back to the default
|
||||
## 1. Delete the barrel import lines (both trees)
|
||||
|
||||
List groups still on codex and switch each one (each group's `memory/` tree stays on disk and readable; run `/migrate-memory` per group if its memory should carry back to Claude — see [docs/provider-migration.md](../../docs/provider-migration.md)):
|
||||
|
||||
```bash
|
||||
ncl groups list
|
||||
# for each group whose config shows provider=codex:
|
||||
ncl groups config update --id <group-id> --provider claude
|
||||
ncl groups restart --id <group-id>
|
||||
```
|
||||
|
||||
## 2. Delete the barrel imports
|
||||
|
||||
Delete (do not comment out) the `import './codex.js';` line from each of:
|
||||
Delete (do not comment out) the `import './codex.js';` line from each barrel:
|
||||
|
||||
- `src/providers/index.ts`
|
||||
- `container/agent-runner/src/providers/index.ts`
|
||||
- `setup/providers/index.ts`
|
||||
|
||||
## 3. Delete every copied file
|
||||
This unregisters the provider from both `listProviderContainerConfigNames()` (host) and `listProviderNames()` (container).
|
||||
|
||||
## 2. Delete the copied files (both trees)
|
||||
|
||||
```bash
|
||||
rm -f 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/agent-runner/src/providers/codex-registration.test.ts \
|
||||
container/agent-runner/src/providers/codex-dockerfile.test.ts
|
||||
```
|
||||
|
||||
This skill itself (`.claude/skills/add-codex/`) stays — it ships with trunk so the provider can be re-added later.
|
||||
## 3. Revert the Dockerfile CLI install
|
||||
|
||||
`container/AGENTS.md` stays only if another installed provider uses agent surfaces; otherwise remove it too.
|
||||
In `container/Dockerfile`, remove both Codex edits (skip whichever is already gone):
|
||||
|
||||
## 4. Remove the CLI manifest entry
|
||||
**(a)** Delete the version ARG from the "Pin CLI versions" block:
|
||||
|
||||
Delete the `@openai/codex` entry from `container/cli-tools.json`:
|
||||
```dockerfile
|
||||
ARG CODEX_VERSION=0.124.0
|
||||
```
|
||||
|
||||
**(b)** Delete the standalone Codex install layer:
|
||||
|
||||
```dockerfile
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g "@openai/codex@${CODEX_VERSION}"
|
||||
```
|
||||
|
||||
Leave the other per-CLI install layers (claude-code, agent-browser, vercel) untouched.
|
||||
|
||||
## 4. Dependency
|
||||
|
||||
Codex is a CLI binary installed via the Dockerfile — there is no agent-runner package dependency to uninstall. Step 3 removes the only install surface; no `bun remove` / `pnpm uninstall` is needed.
|
||||
|
||||
## 5. Unset Codex env vars
|
||||
|
||||
Remove any Codex-specific lines you added to `.env` (`OPENAI_API_KEY`, `OPENAI_BASE_URL`, `CODEX_MODEL`) if no other integration uses them, then re-sync to the container:
|
||||
|
||||
```bash
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const file = "container/cli-tools.json";
|
||||
const tools = JSON.parse(fs.readFileSync(file, "utf8")).filter((t) => t.name !== "@openai/codex");
|
||||
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");
|
||||
'
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
```
|
||||
|
||||
## 5. Vault secret (optional)
|
||||
Switch any group still on Codex back to the default provider — set `"provider": "claude"` in `groups/<folder>/container.json` and clear `agent_provider` on the group/session in the DB.
|
||||
|
||||
The ChatGPT/OpenAI secret in the OneCLI vault grants nothing once the provider is gone. To remove it: `onecli secrets list`, then `onecli secrets delete --id <id>` for the `chatgpt.com` / `api.openai.com` entry.
|
||||
## 6. Rebuild and restart
|
||||
|
||||
## 6. Rebuild and verify
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
|
||||
./container/build.sh
|
||||
pnpm test
|
||||
cd container/agent-runner && bun test
|
||||
pnpm run build && ./container/build.sh
|
||||
source setup/lib/install-slug.sh
|
||||
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
|
||||
|
||||
# Linux
|
||||
systemctl --user restart $(systemd_unit)
|
||||
```
|
||||
|
||||
All suites green and `ncl groups list` showing no codex groups means the removal is complete. Restart the service (`launchctl kickstart -k gui/$(id -u)/<label>` on macOS, `systemctl --user restart <unit>` on Linux).
|
||||
## Verification
|
||||
|
||||
After removal, the registration guards no longer apply (their files are gone). Confirm the provider is fully unwired:
|
||||
|
||||
```bash
|
||||
grep -R "codex.js" src/providers/index.ts container/agent-runner/src/providers/index.ts # no output
|
||||
grep "@openai/codex" container/Dockerfile # no output
|
||||
```
|
||||
|
||||
In a wired agent, requesting `agent_provider = 'codex'` should fall back to the default provider since `codex` is no longer in the registry.
|
||||
|
||||
@@ -1,121 +1,186 @@
|
||||
---
|
||||
name: add-codex
|
||||
description: Use Codex (OpenAI's codex app-server) as a full agent provider — planning, tool orchestration, MCP tools, server-side history, session resume — alongside or instead of Claude. ChatGPT subscription or OpenAI API key, vault-only via OneCLI. Per-group via `ncl groups config update --provider codex`. Distinct from using OpenAI as an MCP tool (where Claude remains the planner).
|
||||
description: Use Codex (CLI + AppServer) as the full agent provider — planning, tool orchestration, native compaction, MCP tools, session resume — in place of the Claude Agent SDK. ChatGPT subscription or OPENAI_API_KEY. Per-group via agent_provider. Distinct from using OpenAI as an MCP tool (where Claude remains the planner).
|
||||
---
|
||||
|
||||
# Codex agent provider
|
||||
|
||||
> Shortcut: `pnpm exec tsx setup/index.ts --step provider-auth codex` performs this whole install (manifest-driven from the providers branch: files, barrels, CLI manifest entry, image rebuild) plus auth in one command. The steps below are the same operations, for agent-driven or manual application.
|
||||
NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected with **`AGENT_PROVIDER`** (`claude` | `opencode` | `codex` | `mock`).
|
||||
|
||||
NanoClaw selects each group's agent backend from `container_configs.provider` (default `claude`). This skill installs the Codex provider: copy the payload from the `providers` branch, append one import to each of the three provider barrels, add the pinned Codex CLI to the container manifest (`container/cli-tools.json`), rebuild, then run the vault auth walk-through.
|
||||
Trunk ships with only the `claude` provider baked in. This skill copies the Codex provider files in from the `providers` branch, wires them into the host and container barrels, updates the Dockerfile to install the Codex CLI, and rebuilds the image.
|
||||
|
||||
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.
|
||||
The Codex provider runs `codex app-server` as a child process and speaks JSON-RPC over stdio. That gives it native session resume, streaming events, MCP tool access, and `thread/compact/start` compaction — same feature bar as the Claude Agent SDK, without the Anthropic-only lock-in.
|
||||
|
||||
## Install
|
||||
|
||||
### Pre-flight
|
||||
|
||||
Check whether the payload is already wired (a prior apply, or a trunk that still carries it). All of these present means installed — skip to **Authenticate**:
|
||||
If all of the following are already present, skip to **Configuration**:
|
||||
|
||||
- `src/providers/codex.ts` and `src/providers/codex-agents-md.ts`
|
||||
- `container/agent-runner/src/providers/codex.ts` and `codex-app-server.ts`
|
||||
- `setup/providers/codex.ts`
|
||||
- `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`
|
||||
- `src/providers/codex.ts`
|
||||
- `src/providers/codex-registration.test.ts`
|
||||
- `container/agent-runner/src/providers/codex.ts`
|
||||
- `container/agent-runner/src/providers/codex-app-server.ts`
|
||||
- `container/agent-runner/src/providers/codex.factory.test.ts`
|
||||
- `container/agent-runner/src/providers/codex-registration.test.ts`
|
||||
- `container/agent-runner/src/providers/codex-dockerfile.test.ts`
|
||||
- `import './codex.js';` line in `src/providers/index.ts`
|
||||
- `import './codex.js';` line in `container/agent-runner/src/providers/index.ts`
|
||||
- `ARG CODEX_VERSION` and `"@openai/codex@${CODEX_VERSION}"` in the pnpm global-install block in `container/Dockerfile`
|
||||
|
||||
### 1. Fetch and copy the payload
|
||||
Missing pieces — continue below. All steps are idempotent; re-running is safe.
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
### 2. Wire the barrels
|
||||
|
||||
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.
|
||||
|
||||
```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';
|
||||
```
|
||||
|
||||
### 3. 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 — 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.
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
### 5. Validate
|
||||
|
||||
```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/
|
||||
```
|
||||
|
||||
The registration tests import only the real barrels — they go red if a barrel line is missing, a barrel fails to evaluate, or the payload is broken.
|
||||
|
||||
## Authenticate
|
||||
|
||||
```nc:run effect:external
|
||||
pnpm exec tsx setup/index.ts --step provider-auth codex
|
||||
```
|
||||
|
||||
The same walk-through fresh installs get from the setup picker: ChatGPT subscription (browser login or device pairing) or an OpenAI API key, landed in the OneCLI vault. Idempotent — it short-circuits when a matching secret already exists. It finishes with the install check.
|
||||
|
||||
## Use it
|
||||
|
||||
Per group:
|
||||
### 1. Fetch the providers branch
|
||||
|
||||
```bash
|
||||
ncl groups config update --id <group-id> --provider codex
|
||||
ncl groups restart --id <group-id>
|
||||
git fetch origin providers
|
||||
```
|
||||
|
||||
Switching is an operator action — run it from the host. Memory does NOT carry over automatically — each provider keeps its own store; run `/migrate-memory` to carry it across. See [docs/provider-migration.md](../../docs/provider-migration.md) for the carry-over table and rollback.
|
||||
### 2. Copy the Codex source files and tests
|
||||
|
||||
There is no install-wide default provider. Setup's provider picker sets codex on the first agent it creates; creation itself is provider-agnostic (no `--provider` flag — provider is a DB property). Any group switches afterward via `ncl groups config update --provider` as above.
|
||||
Wholesale copies (owned entirely by this skill — user edits to these files won't survive a re-run, as designed):
|
||||
|
||||
## Troubleshooting
|
||||
```bash
|
||||
git show origin/providers:src/providers/codex.ts > src/providers/codex.ts
|
||||
git show origin/providers:src/providers/codex-registration.test.ts > src/providers/codex-registration.test.ts
|
||||
git show origin/providers:container/agent-runner/src/providers/codex.ts > container/agent-runner/src/providers/codex.ts
|
||||
git show origin/providers:container/agent-runner/src/providers/codex-app-server.ts > container/agent-runner/src/providers/codex-app-server.ts
|
||||
git show origin/providers:container/agent-runner/src/providers/codex.factory.test.ts > container/agent-runner/src/providers/codex.factory.test.ts
|
||||
git show origin/providers:container/agent-runner/src/providers/codex-registration.test.ts > container/agent-runner/src/providers/codex-registration.test.ts
|
||||
```
|
||||
|
||||
- **Container dies at boot, channel silent:** `grep 'Container exited non-zero' logs/nanoclaw.error.log` — the `stderrTail` carries the reason (e.g. `Unknown provider: codex. Registered: claude` means the barrels aren't wired in the running build).
|
||||
- **In-channel `Error: spawn codex ENOENT` on every message:** the image predates the manifest entry — re-run `./container/build.sh`.
|
||||
- **Auth errors mid-conversation:** the vault secret is missing or stale — re-run `pnpm exec tsx setup/index.ts --step provider-auth codex` (subscription re-login updates the vault copy).
|
||||
The two `codex-registration.test.ts` files are the **registration guards**. Each imports only the real barrel — the host one calls `listProviderContainerConfigNames()` from `src/providers/index.ts`, the container one calls `listProviderNames()` from `container/agent-runner/src/providers/index.ts` — and asserts `codex` is present. They go red the instant a barrel import line is deleted or drifts. (`codex.factory.test.ts` imports `./codex.js` directly and self-registers, so it stays green even if the barrel line is gone — keep it as a unit test of provider behavior, but it is **not** the registration guard.)
|
||||
|
||||
If `git show origin/providers:.../codex-registration.test.ts` errors with `path ... does not exist`, the registration tests have not landed on `origin/providers` yet. Run `git fetch origin providers` again; once the branch carries them, the copies above succeed. The rest of the install proceeds regardless — the Dockerfile and factory tests still run.
|
||||
|
||||
Copy the Dockerfile structural test that ships with this skill into the container provider tree:
|
||||
|
||||
```bash
|
||||
cp .claude/skills/add-codex/codex-dockerfile.test.ts container/agent-runner/src/providers/codex-dockerfile.test.ts
|
||||
```
|
||||
|
||||
`codex-dockerfile.test.ts` reads the real `container/Dockerfile` and asserts the `ARG CODEX_VERSION=` line and the `pnpm install -g "@openai/codex@${CODEX_VERSION}"` line are both present. The Codex CLI is a binary, not an importable package, so the registration tests cannot see it — this structural test is what guards the Dockerfile edits in step 4.
|
||||
|
||||
### 3. Append the self-registration imports
|
||||
|
||||
Each barrel gets one line — alphabetical placement keeps diffs small.
|
||||
|
||||
`src/providers/index.ts`:
|
||||
|
||||
```typescript
|
||||
import './codex.js';
|
||||
```
|
||||
|
||||
`container/agent-runner/src/providers/index.ts`:
|
||||
|
||||
```typescript
|
||||
import './codex.js';
|
||||
```
|
||||
|
||||
### 4. Add the Codex CLI to the container Dockerfile
|
||||
|
||||
Two edits to `container/Dockerfile`, both idempotent (skip if already present):
|
||||
|
||||
**(a)** In the "Pin CLI versions" ARG block (around line 18), add after `ARG CLAUDE_CODE_VERSION=...`:
|
||||
|
||||
```dockerfile
|
||||
ARG CODEX_VERSION=0.124.0
|
||||
```
|
||||
|
||||
**(b)** Add a new standalone `RUN` block for the Codex CLI, after the existing per-CLI install blocks (around line 106, right after the `@anthropic-ai/claude-code` block). The Dockerfile splits each global CLI into its own layer for cache granularity — keep that pattern; do not collapse them into a single combined `pnpm install -g` call:
|
||||
|
||||
```dockerfile
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g "@openai/codex@${CODEX_VERSION}"
|
||||
```
|
||||
|
||||
Note: **no agent-runner package dependency** — Codex is a CLI binary, not a library. Unlike OpenCode, there's nothing to add to `container/agent-runner/package.json`.
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
```bash
|
||||
pnpm run build # host
|
||||
pnpm exec vitest run src/providers/codex-registration.test.ts # host registration guard
|
||||
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit # container typecheck
|
||||
cd container/agent-runner && bun test src/providers/codex-registration.test.ts && cd - # container registration guard
|
||||
cd container/agent-runner && bun test src/providers/codex-dockerfile.test.ts && cd - # Dockerfile structural guard
|
||||
./container/build.sh # agent image
|
||||
```
|
||||
|
||||
All must be clean before proceeding.
|
||||
|
||||
- The **host** `codex-registration.test.ts` imports the real host barrel (`src/providers/index.ts`) and asserts `listProviderContainerConfigNames()` contains `codex`. It goes red if the `import './codex.js';` line is deleted or drifts, or if the barrel fails to evaluate.
|
||||
- The **container** `codex-registration.test.ts` imports the real container barrel (`container/agent-runner/src/providers/index.ts`) and asserts `listProviderNames()` contains `codex`. Same failure surface for the container-side import line.
|
||||
- The **Dockerfile** `codex-dockerfile.test.ts` reads `container/Dockerfile` and asserts the `ARG CODEX_VERSION=` and `@openai/codex@${CODEX_VERSION}` install lines are present — red if either edit is dropped.
|
||||
|
||||
The `@openai/codex` CLI binary is guarded by the Dockerfile structural test plus the container build (`./container/build.sh` fails if the install line is bad), **not** by the registration test — Codex is a CLI binary, not an importable package, so nothing imports it for the registration guard to trip on. To confirm the binary is actually present after the image rebuild, probe it inside a running container with `docker exec <container> codex --version`.
|
||||
|
||||
The host-side provider also consumes core APIs (per-session `~/.codex` mount, env passthrough); that typed core-API consumption is guarded by `pnpm run build`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Codex supports two primary auth paths and one experimental BYO-endpoint path. Pick the one that matches your setup.
|
||||
|
||||
### Option A — ChatGPT subscription (recommended for individuals)
|
||||
|
||||
On the host (not inside the container), run Codex's OAuth login:
|
||||
|
||||
```bash
|
||||
codex login
|
||||
```
|
||||
|
||||
This writes `~/.codex/auth.json` with a subscription token. The host-side Codex provider ([src/providers/codex.ts](../../../src/providers/codex.ts)) copies `auth.json` into a per-session `~/.codex` directory mounted into the container — your host's own Codex CLI is never touched.
|
||||
|
||||
No `.env` variables required for this mode.
|
||||
|
||||
### Option B — API key (recommended for CI or API billing)
|
||||
|
||||
```env
|
||||
OPENAI_API_KEY=sk-...
|
||||
CODEX_MODEL=gpt-5.4-mini
|
||||
```
|
||||
|
||||
The host forwards both variables into the container. If both subscription (`auth.json`) and `OPENAI_API_KEY` are present, Codex prefers the subscription.
|
||||
|
||||
### Option C — BYO OpenAI-compatible endpoint (experimental)
|
||||
|
||||
Codex's built-in `openai` provider honors the `OPENAI_BASE_URL` env var directly. Point it at any OpenAI-compatible endpoint — Groq, Together, self-hosted vLLM, an OpenAI proxy, etc.
|
||||
|
||||
```env
|
||||
OPENAI_API_KEY=...
|
||||
OPENAI_BASE_URL=https://api.groq.com/openai/v1
|
||||
CODEX_MODEL=llama-3.3-70b-versatile
|
||||
```
|
||||
|
||||
Codex also ships first-class local-runner flags — `codex --oss --local-provider ollama` or `--local-provider lmstudio` — that auto-detect a local server. To use those inside NanoClaw, set `CODEX_MODEL` to a model your local runner serves and add the corresponding base URL; see the Codex CLI docs for the full `model_provider = oss` configuration.
|
||||
|
||||
**Experimental caveat:** tool-calling quality depends on the model and endpoint. Not every OpenAI-compat provider implements the full function-calling spec, and smaller models (< 30B) often struggle with multi-step tool orchestration. Test before committing.
|
||||
|
||||
### Per group / per session
|
||||
|
||||
Set `"provider": "codex"` in the group's **`container.json`** (`groups/<folder>/container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session `~/.codex` mount, `OPENAI_*` / `CODEX_MODEL` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json` → `'claude'`.
|
||||
|
||||
`CODEX_MODEL` applies process-wide via `.env`; if you need different models for different groups, set them via `container_config.env` on the group.
|
||||
|
||||
Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host. The runner merges them into the same `mcpServers` object passed to all providers.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- **Spawn-per-query:** Codex's app-server is spawned fresh per query invocation, matching the OpenCode pattern. No long-lived daemon to keep healthy across sessions.
|
||||
- **Per-session `~/.codex` isolation:** each group gets its own copy of the host's `auth.json`. The container can rewrite `config.toml` freely on every wake without touching the host's Codex config.
|
||||
- **Native compaction:** kicks in automatically at 40K cumulative input tokens between turns, via `thread/compact/start`. If compaction fails, the provider logs and continues uncompacted — no fatal error.
|
||||
- **Approvals:** auto-accepted inside the container (the container is the sandbox; same posture as Claude/OpenCode).
|
||||
- **Mid-turn input:** Codex turns don't accept mid-turn messages. Follow-up `push()` calls queue and drain between turns, matching the OpenCode pattern. The poll-loop only pushes between turns anyway, so no messages are dropped.
|
||||
- **Stale thread recovery:** `isSessionInvalid` matches on stale-thread-ID errors (`thread not found`, `unknown thread`, etc.) so a cold-started app-server can recover cleanly when it sees a stored continuation it no longer has.
|
||||
|
||||
## Next Steps
|
||||
|
||||
The registration and Dockerfile guards in **Build and validate** confirm the wiring. For a live end-to-end check, set `agent_provider = 'codex'` on a test group and send a message after the image rebuild. A successful round-trip looks like:
|
||||
|
||||
- `init` event with a stable thread ID as continuation
|
||||
- One or more `activity` / `progress` events during the turn
|
||||
- `result` event with the model's reply
|
||||
|
||||
If the agent hangs or errors, check `~/.codex/auth.json` exists on the host (Option A) or that `OPENAI_API_KEY` is forwarding correctly (Option B) — `docker exec` into a running container and `env | grep -i openai` to confirm. To confirm the CLI binary itself landed in the image, `docker exec <container> codex --version`.
|
||||
|
||||
To back this provider out, follow [REMOVE.md](REMOVE.md).
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Structural guard for the Codex CLI install in container/Dockerfile.
|
||||
//
|
||||
// @openai/codex is a CLI *binary* installed via the Dockerfile, not an
|
||||
// importable package, so the barrel-driven registration tests cannot see it.
|
||||
// This test reads the real Dockerfile and asserts the version ARG and the
|
||||
// `pnpm install -g` line for @openai/codex are both present. It goes red if
|
||||
// either Dockerfile edit is dropped or drifts.
|
||||
//
|
||||
// Runs under bun (same suite as the container registration test):
|
||||
// cd container/agent-runner && bun test src/providers/codex-dockerfile.test.ts
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
|
||||
// container/agent-runner/src/providers/ -> container/Dockerfile
|
||||
const DOCKERFILE = path.join(import.meta.dir, '..', '..', '..', 'Dockerfile');
|
||||
|
||||
describe('container/Dockerfile codex CLI install', () => {
|
||||
const dockerfile = readFileSync(DOCKERFILE, 'utf8');
|
||||
|
||||
it('declares the CODEX_VERSION ARG', () => {
|
||||
expect(dockerfile).toMatch(/ARG\s+CODEX_VERSION=/);
|
||||
});
|
||||
|
||||
it('installs the @openai/codex CLI pinned to that ARG', () => {
|
||||
expect(dockerfile).toMatch(/pnpm install -g\s+"@openai\/codex@\$\{CODEX_VERSION\}"/);
|
||||
});
|
||||
});
|
||||
@@ -5,68 +5,61 @@ description: Add Discord bot channel integration via Chat SDK.
|
||||
|
||||
# Add Discord Channel
|
||||
|
||||
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.
|
||||
Adds Discord bot support via the Chat SDK bridge.
|
||||
|
||||
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.
|
||||
## Install
|
||||
|
||||
## Apply
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Discord adapter in from the `channels` branch.
|
||||
|
||||
### 1. Copy the adapter and its registration test
|
||||
### Pre-flight (idempotent)
|
||||
|
||||
Fetch the `channels` branch and copy the Discord adapter and its registration
|
||||
test into `src/channels/` (overwrite — the branch is canonical):
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/discord.ts
|
||||
src/channels/discord-registration.test.ts
|
||||
- `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
|
||||
```
|
||||
|
||||
### 2. Register the adapter
|
||||
### 2. Copy the adapter and its registration test
|
||||
|
||||
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:
|
||||
```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
|
||||
```
|
||||
|
||||
```nc:append to:src/channels/index.ts
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
import './discord.js';
|
||||
```
|
||||
|
||||
### 3. Install the adapter package
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
|
||||
|
||||
```nc:dep
|
||||
@chat-adapter/discord@4.26.0
|
||||
```bash
|
||||
pnpm install @chat-adapter/discord@4.27.0
|
||||
```
|
||||
|
||||
### 4. Build and validate
|
||||
### 5. Build and validate
|
||||
|
||||
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
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/discord-registration.test.ts
|
||||
```
|
||||
|
||||
`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.
|
||||
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`.
|
||||
|
||||
## 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)
|
||||
@@ -80,36 +73,25 @@ until they're done.
|
||||
- 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
|
||||
|
||||
### Store the credentials
|
||||
### Configure environment
|
||||
|
||||
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:
|
||||
All three values are required — the adapter will fail to start without `DISCORD_PUBLIC_KEY` and `DISCORD_APPLICATION_ID`.
|
||||
|
||||
```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
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
DISCORD_BOT_TOKEN=your-bot-token
|
||||
DISCORD_APPLICATION_ID=your-application-id
|
||||
DISCORD_PUBLIC_KEY=your-public-key
|
||||
```
|
||||
|
||||
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,8 +54,10 @@ Remove the NanoClaw block from your Emacs config (`config.el`, `~/.spacemacs`, o
|
||||
|
||||
Reload your config or restart Emacs.
|
||||
|
||||
## 5. Messaging group (left intact)
|
||||
## 5. Remove the messaging group (optional)
|
||||
|
||||
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>`.
|
||||
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';"
|
||||
```
|
||||
|
||||
@@ -12,13 +12,14 @@ ncl groups config remove-mcp-server --id <group-id> --name calendar
|
||||
|
||||
## 2. Remove the `.calendar-mcp` mount from the DB (per group)
|
||||
|
||||
This is a **host-only / operator** verb — run it host-side. It's idempotent (a no-op if the mount is absent):
|
||||
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`):
|
||||
|
||||
```bash
|
||||
ncl groups config remove-mount \
|
||||
--id <group-id> \
|
||||
--host "$HOME/.calendar-mcp" \
|
||||
--container .calendar-mcp
|
||||
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>';"
|
||||
```
|
||||
|
||||
## 3. Delete the copied test file
|
||||
|
||||
@@ -133,8 +133,6 @@ 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
|
||||
@@ -162,22 +160,27 @@ Approval behaviour depends on where you run it: from inside an agent's container
|
||||
|
||||
### Add the `.calendar-mcp` mount
|
||||
|
||||
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).
|
||||
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):
|
||||
|
||||
```bash
|
||||
ncl groups config add-mount \
|
||||
--id <group-id> \
|
||||
--host "$HOME/.calendar-mcp" \
|
||||
--container .calendar-mcp
|
||||
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';"
|
||||
```
|
||||
|
||||
`--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.
|
||||
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.
|
||||
|
||||
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.
|
||||
**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>`).
|
||||
|
||||
**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 `add-mount` 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 `json_insert` appends to `additional_mounts` without disturbing existing entries.
|
||||
|
||||
## Phase 4: Build and Restart
|
||||
|
||||
|
||||
@@ -5,70 +5,63 @@ description: Add Google Chat channel integration via Chat SDK.
|
||||
|
||||
# Add Google Chat Channel
|
||||
|
||||
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.
|
||||
Adds Google Chat support via the Chat SDK bridge.
|
||||
|
||||
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.
|
||||
## Install
|
||||
|
||||
## Apply
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Google Chat adapter in from the `channels` branch.
|
||||
|
||||
### 1. Copy the adapter and its registration test
|
||||
### Pre-flight (idempotent)
|
||||
|
||||
Fetch the `channels` branch and copy the Google Chat adapter and its
|
||||
registration test into `src/channels/` (overwrite — the branch is canonical):
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/gchat.ts
|
||||
src/channels/gchat-registration.test.ts
|
||||
- `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
|
||||
```
|
||||
|
||||
### 2. Register the adapter
|
||||
### 2. Copy the adapter and its registration test
|
||||
|
||||
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:
|
||||
```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
|
||||
```
|
||||
|
||||
```nc:append to:src/channels/index.ts
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
import './gchat.js';
|
||||
```
|
||||
|
||||
### 3. Install the adapter package
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
|
||||
|
||||
```nc:dep
|
||||
@chat-adapter/gchat@4.26.0
|
||||
```bash
|
||||
pnpm install @chat-adapter/gchat@4.27.0
|
||||
```
|
||||
|
||||
### 4. Build and validate
|
||||
### 5. Build and validate
|
||||
|
||||
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
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/gchat-registration.test.ts
|
||||
```
|
||||
|
||||
`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.
|
||||
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.
|
||||
|
||||
## 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**
|
||||
@@ -80,35 +73,21 @@ until they're done.
|
||||
> - Grant the Chat Bot role
|
||||
> - Create a JSON key and download it
|
||||
|
||||
### Store the credentials
|
||||
### Configure environment
|
||||
|
||||
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:
|
||||
Add the service account JSON as a single-line string to `.env`:
|
||||
|
||||
```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
|
||||
```bash
|
||||
GCHAT_CREDENTIALS={"type":"service_account","project_id":"...","private_key":"...","client_email":"..."}
|
||||
```
|
||||
|
||||
### 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.
|
||||
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
|
||||
|
||||
@@ -118,5 +97,3 @@ If you're in the middle of `/setup`, return to the setup flow now. Otherwise run
|
||||
- **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,68 +5,64 @@ 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. 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.
|
||||
Adds GitHub support via the Chat SDK bridge. The agent participates in PR and issue comment threads.
|
||||
|
||||
## 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.
|
||||
|
||||
## Apply
|
||||
## Install
|
||||
|
||||
### 1. Copy the adapter
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the GitHub adapter in from the `channels` branch.
|
||||
|
||||
Fetch the `channels` branch and copy the GitHub adapter into `src/channels/`
|
||||
(overwrite — the branch is canonical):
|
||||
### Pre-flight (idempotent)
|
||||
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/github.ts
|
||||
src/channels/github-registration.test.ts
|
||||
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
|
||||
```
|
||||
|
||||
### 2. Register the adapter
|
||||
### 2. Copy the adapter and its registration test
|
||||
|
||||
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:
|
||||
```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
|
||||
```
|
||||
|
||||
```nc:append to:src/channels/index.ts
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
import './github.js';
|
||||
```
|
||||
|
||||
### 3. Install the adapter package
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
|
||||
|
||||
```nc:dep
|
||||
@chat-adapter/github@4.26.0
|
||||
```bash
|
||||
pnpm install @chat-adapter/github@4.27.0
|
||||
```
|
||||
|
||||
### 4. Build and validate
|
||||
### 5. Build and validate
|
||||
|
||||
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
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/github-registration.test.ts
|
||||
```
|
||||
|
||||
`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.
|
||||
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`.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Credentials
|
||||
|
||||
@@ -92,31 +88,18 @@ On each repo (logged in as the repo owner/admin):
|
||||
|
||||
### 3. Configure environment
|
||||
|
||||
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:
|
||||
Add to `.env`:
|
||||
|
||||
```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
|
||||
```bash
|
||||
GITHUB_TOKEN=github_pat_...
|
||||
GITHUB_WEBHOOK_SECRET=your-webhook-secret
|
||||
GITHUB_BOT_USERNAME=your-bot-username
|
||||
```
|
||||
|
||||
`GITHUB_BOT_USERNAME` must match the bot account's GitHub username exactly. This is used for @-mention detection — the agent responds when someone writes `@your-bot-username` in a PR or issue comment.
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
## Wiring
|
||||
|
||||
Ask the user: **Is this a private or public repo?**
|
||||
|
||||
@@ -19,17 +19,17 @@ ncl groups config remove-mcp-server --id <group-id> --name gmail
|
||||
|
||||
## 3. Remove the `.gmail-mcp` mount (per group)
|
||||
|
||||
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:
|
||||
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):
|
||||
|
||||
```bash
|
||||
ncl groups config remove-mount \
|
||||
--id <group-id> \
|
||||
--host "$HOME/.gmail-mcp" \
|
||||
--container .gmail-mcp
|
||||
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';"
|
||||
```
|
||||
|
||||
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,16 +181,21 @@ Approval behaviour depends on where you run it: from inside an agent's container
|
||||
|
||||
### Add the `.gmail-mcp` mount
|
||||
|
||||
Register the mount with the host-only `ncl groups config add-mount` verb. For each chosen `<group-id>`:
|
||||
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):
|
||||
|
||||
```bash
|
||||
ncl groups config add-mount \
|
||||
--id <group-id> \
|
||||
--host "$HOME/.gmail-mcp" \
|
||||
--container .gmail-mcp
|
||||
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';"
|
||||
```
|
||||
|
||||
`--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.
|
||||
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.
|
||||
|
||||
**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,9 +8,10 @@
|
||||
* allowedTools: [ ...TOOL_ALLOWLIST, ...Object.keys(this.mcpServers).map(mcpAllowPattern) ]
|
||||
*
|
||||
* `mcpAllowPattern` is not exported and the call site lives inside the SDK query options,
|
||||
* 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.
|
||||
* 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__*`.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
@@ -24,6 +25,11 @@ 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();
|
||||
|
||||
@@ -42,4 +48,8 @@ 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,71 +5,63 @@ 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). NanoClaw doesn't ship channels in trunk —
|
||||
this skill copies the iMessage adapter in from the `channels` branch.
|
||||
Adds iMessage support via the Chat SDK bridge. Two modes: local (macOS with Full Disk Access) or remote (Photon API).
|
||||
|
||||
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.
|
||||
## Install
|
||||
|
||||
## Apply
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the iMessage adapter in from the `channels` branch.
|
||||
|
||||
### 1. Copy the adapter
|
||||
### Pre-flight (idempotent)
|
||||
|
||||
Fetch the `channels` branch and copy the iMessage adapter into `src/channels/`
|
||||
(overwrite — the branch is canonical):
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/imessage.ts
|
||||
src/channels/imessage-registration.test.ts
|
||||
- `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
|
||||
```
|
||||
|
||||
### 2. Register the adapter
|
||||
### 2. Copy the adapter and its registration test
|
||||
|
||||
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:
|
||||
```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
|
||||
```
|
||||
|
||||
```nc:append to:src/channels/index.ts
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
import './imessage.js';
|
||||
```
|
||||
|
||||
### 3. Install the adapter package
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
|
||||
|
||||
```nc:dep
|
||||
chat-adapter-imessage@0.1.1
|
||||
```bash
|
||||
pnpm install chat-adapter-imessage@0.1.1
|
||||
```
|
||||
|
||||
### 4. Build and validate
|
||||
### 5. Build and validate
|
||||
|
||||
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
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/imessage-registration.test.ts
|
||||
```
|
||||
|
||||
`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.
|
||||
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`.
|
||||
|
||||
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.
|
||||
@@ -95,19 +87,14 @@ Stop and wait for the user to confirm before continuing.
|
||||
|
||||
### Configure environment
|
||||
|
||||
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):
|
||||
**Local mode** -- add to `.env`:
|
||||
|
||||
```bash
|
||||
IMESSAGE_ENABLED=true
|
||||
IMESSAGE_LOCAL=true
|
||||
```
|
||||
|
||||
**Remote mode** — add to `.env` (and remove `IMESSAGE_ENABLED` if present):
|
||||
**Remote mode** -- add to `.env`:
|
||||
|
||||
```bash
|
||||
IMESSAGE_LOCAL=false
|
||||
@@ -115,11 +102,7 @@ IMESSAGE_SERVER_URL=https://your-photon-server.com
|
||||
IMESSAGE_API_KEY=your-api-key
|
||||
```
|
||||
|
||||
Once the keys for your mode are written, sync `.env` to the container (the host
|
||||
mounts `data/env/env`):
|
||||
|
||||
```nc:env-sync
|
||||
```
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
## Next Steps
|
||||
|
||||
|
||||
@@ -18,7 +18,16 @@ rm -f src/channels/linear.ts src/channels/linear-registration.test.ts
|
||||
|
||||
## 2. Remove credentials
|
||||
|
||||
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:
|
||||
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
|
||||
```
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
|
||||
@@ -5,15 +5,7 @@ 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. 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.
|
||||
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.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -28,65 +20,61 @@ re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
**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).
|
||||
|
||||
## Apply
|
||||
## Install
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### 1. Copy the adapter and its registration test
|
||||
### Pre-flight (idempotent)
|
||||
|
||||
Fetch the `channels` branch and copy the Linear adapter and its registration
|
||||
test into `src/channels/` (overwrite — the branch is canonical):
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/linear.ts
|
||||
src/channels/linear-registration.test.ts
|
||||
- `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
|
||||
```
|
||||
|
||||
### 2. Register the adapter
|
||||
### 2. Copy the adapter and its registration test
|
||||
|
||||
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:
|
||||
```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
|
||||
```
|
||||
|
||||
```nc:append to:src/channels/index.ts
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
import './linear.js';
|
||||
```
|
||||
|
||||
### 3. Install the adapter package
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
|
||||
|
||||
```nc:dep
|
||||
@chat-adapter/linear@4.26.0
|
||||
```bash
|
||||
pnpm install @chat-adapter/linear@4.27.0
|
||||
```
|
||||
|
||||
### 4. Build and validate
|
||||
### 5. Build and validate
|
||||
|
||||
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
|
||||
```bash
|
||||
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` 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.
|
||||
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.
|
||||
|
||||
## 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**
|
||||
@@ -98,53 +86,28 @@ Linear app and webhook setup is human and interactive — these steps are prose
|
||||
|
||||
Note: Linear webhook delivery may be delayed 1-5 minutes for new webhooks. This is normal.
|
||||
|
||||
### 2. Store the credentials
|
||||
### 2. Configure environment
|
||||
|
||||
Capture the values, then write them. `prompt` only *asks* and binds the answer
|
||||
to a name; a separate directive consumes it. Here they go to `.env`
|
||||
(set-if-absent — a value you've already filled in is never overwritten) and sync
|
||||
to the container.
|
||||
|
||||
Use **either** the OAuth app credentials (recommended) **or** a Personal API key.
|
||||
For the API-key path, paste `none` at the OAuth prompts and set `LINEAR_API_KEY`
|
||||
in `.env` by hand (commented in the template below). `LINEAR_BOT_USERNAME` is the
|
||||
display name for the bot, used for self-message detection when using a Personal
|
||||
API Key. `LINEAR_TEAM_KEY` is the Linear team key (e.g. `ENG`, `NAN`) — find it
|
||||
in Linear under Settings > Teams; all issues in this team route to one messaging
|
||||
group.
|
||||
|
||||
```nc:prompt linear_client_id secret
|
||||
Paste the OAuth Client ID — Linear Settings > API > OAuth Applications. Paste `none` if using a Personal API key instead.
|
||||
```
|
||||
```nc:prompt linear_client_secret secret
|
||||
Paste the OAuth Client Secret. Paste `none` if using a Personal API key instead.
|
||||
```
|
||||
```nc:prompt linear_webhook_secret secret
|
||||
Paste the webhook signing secret from the webhook you just created.
|
||||
```
|
||||
```nc:prompt linear_team_key
|
||||
Enter the Linear team key (e.g. `ENG`, `NAN`) — Settings > Teams.
|
||||
```
|
||||
```nc:prompt linear_bot_username
|
||||
Enter the bot display name (e.g. `NanoClaw Bot`).
|
||||
```
|
||||
```nc:env-set
|
||||
LINEAR_CLIENT_ID={{linear_client_id}}
|
||||
LINEAR_CLIENT_SECRET={{linear_client_secret}}
|
||||
LINEAR_WEBHOOK_SECRET={{linear_webhook_secret}}
|
||||
LINEAR_TEAM_KEY={{linear_team_key}}
|
||||
LINEAR_BOT_USERNAME={{linear_bot_username}}
|
||||
```
|
||||
```nc:env-sync
|
||||
```
|
||||
|
||||
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):
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
LINEAR_API_KEY=lin_api_...
|
||||
# OAuth app (recommended)
|
||||
LINEAR_CLIENT_ID=your-client-id
|
||||
LINEAR_CLIENT_SECRET=your-client-secret
|
||||
|
||||
# OR Personal API key (simpler, but agent posts as you)
|
||||
# LINEAR_API_KEY=lin_api_...
|
||||
|
||||
LINEAR_WEBHOOK_SECRET=your-webhook-signing-secret
|
||||
LINEAR_BOT_USERNAME=NanoClaw Bot
|
||||
LINEAR_TEAM_KEY=ENG
|
||||
```
|
||||
|
||||
- `LINEAR_BOT_USERNAME`: display name for the bot (used for self-message detection when using a Personal API Key)
|
||||
- `LINEAR_TEAM_KEY`: the Linear team key (e.g. `ENG`, `NAN`). Find it in Linear under Settings > Teams. All issues in this team route to one messaging group.
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
## Wiring
|
||||
|
||||
Ask the user: **Is this a private or public Linear workspace?**
|
||||
|
||||
@@ -18,7 +18,22 @@ rm -f src/channels/matrix.ts src/channels/matrix-registration.test.ts
|
||||
|
||||
## 2. Remove credentials
|
||||
|
||||
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:
|
||||
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:
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
|
||||
@@ -5,53 +5,57 @@ description: Add Matrix channel integration via Chat SDK. Works with any Matrix
|
||||
|
||||
# Add Matrix Channel
|
||||
|
||||
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.
|
||||
Adds Matrix support via the Chat SDK bridge.
|
||||
|
||||
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.
|
||||
## Install
|
||||
|
||||
## Apply
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Matrix adapter in from the `channels` branch.
|
||||
|
||||
### 1. Copy the adapter
|
||||
### Pre-flight (idempotent)
|
||||
|
||||
Fetch the `channels` branch and copy the Matrix adapter into `src/channels/`
|
||||
(overwrite — the branch is canonical):
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/matrix.ts
|
||||
src/channels/matrix-registration.test.ts
|
||||
- `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
|
||||
```
|
||||
|
||||
### 2. Register the adapter
|
||||
### 2. Copy the adapter and its registration test
|
||||
|
||||
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:
|
||||
```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
|
||||
```
|
||||
|
||||
```nc:append to:src/channels/index.ts
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
import './matrix.js';
|
||||
```
|
||||
|
||||
### 3. Install the adapter package
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
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
|
||||
```bash
|
||||
pnpm install @beeper/chat-adapter-matrix@0.2.0
|
||||
```
|
||||
|
||||
### 4. Patch matrix-js-sdk ESM imports
|
||||
### 5. 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). Re-run this after every `pnpm install`
|
||||
that touches the adapter:
|
||||
extensions (idempotent — safe to re-run):
|
||||
|
||||
```nc:run effect:external
|
||||
```bash
|
||||
node -e '
|
||||
const fs = require("fs"), path = require("path");
|
||||
const root = "node_modules/.pnpm";
|
||||
@@ -65,32 +69,22 @@ node -e '
|
||||
'
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
Re-run this after every `pnpm install` that touches the adapter.
|
||||
|
||||
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.
|
||||
### 6. Build and validate
|
||||
|
||||
```nc:run effect:build
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/matrix-registration.test.ts
|
||||
```
|
||||
|
||||
`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.
|
||||
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`.
|
||||
|
||||
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. These steps are human and
|
||||
interactive (no parser can click through Element), so they stay prose.
|
||||
The bot needs its own Matrix account — separate from the user's account. This is required because Matrix cannot send DMs to yourself.
|
||||
|
||||
### Create a bot account
|
||||
|
||||
@@ -137,52 +131,12 @@ MATRIX_RECOVERY_KEY=your-recovery-key # Enable E2EE cross-signing
|
||||
MATRIX_DEVICE_ID=NANOCLAW01 # Stable device ID across restarts
|
||||
```
|
||||
|
||||
### Store the credentials
|
||||
### Configure environment
|
||||
|
||||
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:
|
||||
Add the chosen env vars to `.env`, then sync:
|
||||
|
||||
```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
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
@@ -5,70 +5,61 @@ description: Add Resend (email) channel integration via Chat SDK.
|
||||
|
||||
# Add Resend Email Channel
|
||||
|
||||
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.
|
||||
Connect NanoClaw to email via Resend for async email conversations.
|
||||
|
||||
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.
|
||||
## Install
|
||||
|
||||
## Apply
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Resend adapter in from the `channels` branch.
|
||||
|
||||
### 1. Copy the adapter
|
||||
### Pre-flight (idempotent)
|
||||
|
||||
Fetch the `channels` branch and copy the Resend adapter into `src/channels/`
|
||||
(overwrite — the branch is canonical):
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/resend.ts
|
||||
src/channels/resend-registration.test.ts
|
||||
- `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
|
||||
```
|
||||
|
||||
### 2. Register the adapter
|
||||
### 2. Copy the adapter and its registration test
|
||||
|
||||
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:
|
||||
```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
|
||||
```
|
||||
|
||||
```nc:append to:src/channels/index.ts
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
import './resend.js';
|
||||
```
|
||||
|
||||
### 3. Install the adapter package
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
|
||||
|
||||
```nc:dep
|
||||
@resend/chat-sdk-adapter@0.1.1
|
||||
```bash
|
||||
pnpm install @resend/chat-sdk-adapter@0.1.1
|
||||
```
|
||||
|
||||
### 4. Build and validate
|
||||
### 5. Build and validate
|
||||
|
||||
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
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/resend-registration.test.ts
|
||||
```
|
||||
|
||||
`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.
|
||||
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`.
|
||||
|
||||
## 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.
|
||||
@@ -78,45 +69,30 @@ receive a message until they're done.
|
||||
- Events: select **email.received**.
|
||||
- Copy the signing secret.
|
||||
|
||||
### Store the credentials
|
||||
### Configure environment
|
||||
|
||||
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:
|
||||
Add to `.env`:
|
||||
|
||||
```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
|
||||
```bash
|
||||
RESEND_API_KEY=re_...
|
||||
RESEND_FROM_ADDRESS=bot@yourdomain.com
|
||||
RESEND_FROM_NAME=NanoClaw
|
||||
RESEND_WEBHOOK_SECRET=your-webhook-secret
|
||||
```
|
||||
|
||||
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**: no (the adapter sets `supportsThreads: false`; replies still thread via email headers, but the router does not treat threads as the primary conversation unit)
|
||||
- **supports-threads**: yes (via email threading headers -- replies to the same thread stay together)
|
||||
- **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,15 +4,21 @@ Idempotent — safe to run even if some steps were never applied. Run Steps 1–
|
||||
|
||||
## 1. Remove the mount from the container config
|
||||
|
||||
Remove the rtk mount with the host-only `remove-mount` verb. It is idempotent — a no-op if the mount isn't present:
|
||||
Read the current mounts, drop the entry whose `containerPath` is `/usr/local/bin/rtk`, and write the rest back.
|
||||
|
||||
```bash
|
||||
ncl groups config remove-mount --id <group-id> \
|
||||
--host ~/.local/bin/rtk \
|
||||
--container /usr/local/bin/rtk
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
|
||||
```
|
||||
|
||||
This verb is operator-only and runs host-side; it is rejected from inside a container.
|
||||
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.
|
||||
|
||||
## 2. Remove the PreToolUse hook from settings.json
|
||||
|
||||
|
||||
@@ -13,10 +13,6 @@ 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
|
||||
@@ -47,24 +43,33 @@ Note the group ID (e.g. `ag-1776342942165-ptgddd`). Repeat Steps 3–5 for each
|
||||
|
||||
## Step 3 — Mount rtk into the container config
|
||||
|
||||
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:
|
||||
`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:
|
||||
|
||||
```bash
|
||||
ncl groups config add-mount --id <group-id> \
|
||||
--host ~/.local/bin/rtk \
|
||||
--container /usr/local/bin/rtk \
|
||||
--ro
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
|
||||
```
|
||||
|
||||
This verb is operator-only and runs host-side (via `/setup`, `/customize`, or `/manage-mounts`); it is rejected from inside a container.
|
||||
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:
|
||||
|
||||
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.
|
||||
```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>'"
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
ncl groups config get --id <group-id>
|
||||
# Look for the /usr/local/bin/rtk mount
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
|
||||
```
|
||||
|
||||
## Step 4 — Add the PreToolUse hook to settings.json
|
||||
@@ -115,8 +120,9 @@ 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
|
||||
ncl groups config get --id <group-id>
|
||||
# Look for the /usr/local/bin/rtk mount
|
||||
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 restart --id <group-id>
|
||||
```
|
||||
|
||||
|
||||
@@ -5,119 +5,114 @@ description: Add Slack channel integration via Chat SDK.
|
||||
|
||||
# Add Slack Channel
|
||||
|
||||
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.
|
||||
Adds Slack support via the Chat SDK bridge.
|
||||
|
||||
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.
|
||||
## Install
|
||||
|
||||
## Apply
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Slack adapter in from the `channels` branch.
|
||||
|
||||
### 1. Copy the adapter and its registration test
|
||||
### Pre-flight (idempotent)
|
||||
|
||||
Fetch the `channels` branch and copy the Slack adapter and its registration test
|
||||
into `src/channels/` (overwrite — the branch is canonical):
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/slack.ts
|
||||
src/channels/slack-registration.test.ts
|
||||
- `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
|
||||
```
|
||||
|
||||
### 2. Register the adapter
|
||||
### 2. Copy the adapter and its registration test
|
||||
|
||||
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:
|
||||
```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
|
||||
```
|
||||
|
||||
```nc:append to:src/channels/index.ts
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
import './slack.js';
|
||||
```
|
||||
|
||||
### 3. Install the adapter package
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
|
||||
|
||||
```nc:dep
|
||||
@chat-adapter/slack@4.26.0
|
||||
```bash
|
||||
pnpm install @chat-adapter/slack@4.27.0
|
||||
```
|
||||
|
||||
### 4. Build and validate
|
||||
### 5. Build and validate
|
||||
|
||||
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
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/slack-registration.test.ts
|
||||
```
|
||||
|
||||
`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.
|
||||
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.
|
||||
|
||||
## Credentials
|
||||
|
||||
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.
|
||||
### Create Slack App
|
||||
|
||||
### 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**.
|
||||
1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** > **From scratch**
|
||||
2. Name it (e.g., "NanoClaw") and select your workspace
|
||||
3. Go to **OAuth & Permissions** and add Bot Token Scopes:
|
||||
- `chat:write`, `im:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`, `files:read`, `files:write`
|
||||
4. Click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`)
|
||||
5. Go to **Basic Information** and copy the **Signing Secret**
|
||||
|
||||
### Enable DMs
|
||||
|
||||
6. **App Home** → enable the **Messages Tab**.
|
||||
7. Check **"Allow users to send Slash commands and messages from the messages tab."**
|
||||
6. Go to **App Home** and enable the **Messages Tab**
|
||||
7. Check **"Allow users to send Slash commands and messages from the messages tab"**
|
||||
|
||||
### Event Subscriptions & Interactivity
|
||||
### Event Subscriptions
|
||||
|
||||
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.
|
||||
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**
|
||||
|
||||
### Store the credentials
|
||||
### Interactivity
|
||||
|
||||
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:
|
||||
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
|
||||
|
||||
```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
|
||||
### Configure environment
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
SLACK_BOT_TOKEN=xoxb-your-bot-token
|
||||
SLACK_SIGNING_SECRET=your-signing-secret
|
||||
```
|
||||
|
||||
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/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.
|
||||
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`.
|
||||
|
||||
## 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,7 +18,14 @@ rm -f src/channels/teams.ts src/channels/teams-registration.test.ts
|
||||
|
||||
## 2. Remove credentials
|
||||
|
||||
Remove `TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`, `TEAMS_APP_TENANT_ID`, and `TEAMS_APP_TYPE` from `.env`, then re-sync to the container:
|
||||
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
|
||||
```
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
|
||||
@@ -5,69 +5,64 @@ 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. NanoClaw doesn't ship channels in trunk — this skill
|
||||
copies the Teams adapter in from the `channels` branch.
|
||||
Connect NanoClaw to Microsoft Teams for interactive chat in team channels, group chats, and direct messages.
|
||||
|
||||
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.
|
||||
## Install
|
||||
|
||||
## Apply
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Teams adapter in from the `channels` branch.
|
||||
|
||||
### 1. Copy the adapter
|
||||
### Pre-flight (idempotent)
|
||||
|
||||
Fetch the `channels` branch and copy the Teams adapter into `src/channels/`
|
||||
(overwrite — the branch is canonical):
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/teams.ts
|
||||
src/channels/teams-registration.test.ts
|
||||
- `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
|
||||
```
|
||||
|
||||
### 2. Register the adapter
|
||||
### 2. Copy the adapter and its registration test
|
||||
|
||||
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:
|
||||
```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
|
||||
```
|
||||
|
||||
```nc:append to:src/channels/index.ts
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
import './teams.js';
|
||||
```
|
||||
|
||||
### 3. Install the adapter package
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
|
||||
|
||||
```nc:dep
|
||||
@chat-adapter/teams@4.26.0
|
||||
```bash
|
||||
pnpm install @chat-adapter/teams@4.27.0
|
||||
```
|
||||
|
||||
### 4. Build and validate
|
||||
### 5. Build and validate
|
||||
|
||||
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
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/teams-registration.test.ts
|
||||
```
|
||||
|
||||
`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.
|
||||
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.
|
||||
|
||||
## Credentials
|
||||
|
||||
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).
|
||||
Two paths — manual (Azure Portal) or auto (Teams CLI).
|
||||
|
||||
### Auto: Teams CLI
|
||||
|
||||
@@ -225,34 +220,18 @@ By default, the bot only receives messages when @-mentioned. To receive all mess
|
||||
|
||||
### Configure environment
|
||||
|
||||
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.
|
||||
Add to `.env`:
|
||||
|
||||
```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
|
||||
```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
|
||||
```
|
||||
|
||||
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 `/api/webhooks/teams` for Teams and other webhook-based adapters. This port must be publicly reachable from the internet for Azure Bot Service to deliver activities.
|
||||
|
||||
@@ -5,91 +5,77 @@ description: Add Telegram channel integration via Chat SDK.
|
||||
|
||||
# Add Telegram Channel
|
||||
|
||||
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.
|
||||
Adds Telegram bot support via the Chat SDK bridge.
|
||||
|
||||
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.
|
||||
## Install
|
||||
|
||||
## Apply
|
||||
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.
|
||||
|
||||
### 1. Copy the adapter, helpers, tests, and setup step
|
||||
### Pre-flight (idempotent)
|
||||
|
||||
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):
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
```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
|
||||
- `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
|
||||
```
|
||||
|
||||
### 2. Register the adapter
|
||||
### 2. Copy the adapter, helpers, tests, registration test, and setup step
|
||||
|
||||
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:
|
||||
```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
|
||||
```
|
||||
|
||||
```nc:append to:src/channels/index.ts
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if already present):
|
||||
|
||||
```typescript
|
||||
import './telegram.js';
|
||||
```
|
||||
|
||||
### 3. Register the setup step
|
||||
### 4. Register the setup step
|
||||
|
||||
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):
|
||||
In `setup/index.ts`, add this entry to the `STEPS` map (right after the `register` line is fine; skip if already present):
|
||||
|
||||
```nc:append to:setup/index.ts at:nanoclaw:setup-steps
|
||||
```typescript
|
||||
'pair-telegram': () => import('./pair-telegram.js'),
|
||||
```
|
||||
|
||||
### 4. Install the adapter package
|
||||
### 5. Install the adapter package (pinned)
|
||||
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
|
||||
|
||||
```nc:dep
|
||||
@chat-adapter/telegram@4.26.0
|
||||
```bash
|
||||
pnpm install @chat-adapter/telegram@4.27.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
### 6. Build and validate
|
||||
|
||||
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
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/telegram-registration.test.ts
|
||||
```
|
||||
|
||||
`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.
|
||||
Both must be clean before proceeding. `telegram-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `telegram`. It goes red if the `import './telegram.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/telegram` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 5. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
|
||||
|
||||
End-to-end message delivery against a real Telegram bot is verified manually once
|
||||
the service is running — see Next Steps and the pairing flow in Channel Info.
|
||||
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`
|
||||
@@ -103,23 +89,16 @@ compiling, registered adapter that cannot receive a message until they're done.
|
||||
1. Open `@BotFather` > `/mybots` > select your bot
|
||||
2. **Bot Settings** > **Group Privacy** > **Turn off**
|
||||
|
||||
### Store the credentials
|
||||
### Configure environment
|
||||
|
||||
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:
|
||||
Add to `.env`:
|
||||
|
||||
```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
|
||||
```bash
|
||||
TELEGRAM_BOT_TOKEN=your-bot-token
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
@@ -5,113 +5,85 @@ description: Add Webex channel integration via Chat SDK.
|
||||
|
||||
# Add Webex Channel
|
||||
|
||||
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.
|
||||
Adds Cisco Webex support via the Chat SDK bridge.
|
||||
|
||||
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.
|
||||
## Install
|
||||
|
||||
## Apply
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Webex adapter in from the `channels` branch.
|
||||
|
||||
### 1. Copy the adapter and its registration test
|
||||
### Pre-flight (idempotent)
|
||||
|
||||
Fetch the `channels` branch and copy the Webex adapter and its registration test
|
||||
into `src/channels/` (overwrite — the branch is canonical):
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/webex.ts
|
||||
src/channels/webex-registration.test.ts
|
||||
- `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
|
||||
```
|
||||
|
||||
### 2. Register the adapter
|
||||
### 2. Copy the adapter and its registration test
|
||||
|
||||
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:
|
||||
```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
|
||||
```
|
||||
|
||||
```nc:append to:src/channels/index.ts
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
import './webex.js';
|
||||
```
|
||||
|
||||
### 3. Install the adapter package
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
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
|
||||
```bash
|
||||
pnpm install @bitbasti/chat-adapter-webex@0.1.0
|
||||
```
|
||||
|
||||
### 4. Build and validate
|
||||
### 5. Build and validate
|
||||
|
||||
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
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/webex-registration.test.ts
|
||||
```
|
||||
|
||||
`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.
|
||||
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.
|
||||
|
||||
## Credentials
|
||||
|
||||
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**.
|
||||
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
|
||||
|
||||
### Store the credentials
|
||||
### Configure environment
|
||||
|
||||
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:
|
||||
Add to `.env`:
|
||||
|
||||
```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
|
||||
```bash
|
||||
WEBEX_BOT_TOKEN=your-bot-token
|
||||
WEBEX_WEBHOOK_SECRET=your-webhook-secret
|
||||
```
|
||||
|
||||
### 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.
|
||||
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
|
||||
|
||||
|
||||
@@ -36,12 +36,16 @@ pnpm uninstall wechat-ilink-client
|
||||
rm -rf data/wechat
|
||||
```
|
||||
|
||||
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>`.
|
||||
## 5. Remove DB wiring
|
||||
|
||||
## 5. Rebuild and restart
|
||||
```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
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
|
||||
@@ -6,71 +6,62 @@ 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.
|
||||
|
||||
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.
|
||||
## Install
|
||||
|
||||
## Apply
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the WhatsApp Cloud adapter in from the `channels` branch.
|
||||
|
||||
### 1. Copy the adapter
|
||||
### Pre-flight (idempotent)
|
||||
|
||||
Fetch the `channels` branch and copy the WhatsApp Cloud adapter into
|
||||
`src/channels/` (overwrite — the branch is canonical):
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
```nc:copy from-branch:channels
|
||||
src/channels/whatsapp-cloud.ts
|
||||
src/channels/whatsapp-cloud-registration.test.ts
|
||||
- `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
|
||||
```
|
||||
|
||||
### 2. Register the adapter
|
||||
### 2. Copy the adapter and its registration test
|
||||
|
||||
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:
|
||||
```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
|
||||
```
|
||||
|
||||
```nc:append to:src/channels/index.ts
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
import './whatsapp-cloud.js';
|
||||
```
|
||||
|
||||
### 3. Install the adapter package
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
|
||||
|
||||
```nc:dep
|
||||
@chat-adapter/whatsapp@4.26.0
|
||||
```bash
|
||||
pnpm install @chat-adapter/whatsapp@4.27.0
|
||||
```
|
||||
|
||||
### 4. Build and validate
|
||||
### 5. Build and validate
|
||||
|
||||
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
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
```nc:run effect:test
|
||||
pnpm exec vitest run src/channels/whatsapp-cloud-registration.test.ts
|
||||
```
|
||||
|
||||
`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.
|
||||
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`.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## 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**:
|
||||
@@ -82,43 +73,18 @@ compiling, registered adapter that cannot receive a message until they're done.
|
||||
- Subscribe to webhook fields: `messages`.
|
||||
5. Copy the **App Secret** from **Settings** > **Basic**.
|
||||
|
||||
### Store the credentials
|
||||
### Configure environment
|
||||
|
||||
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:
|
||||
Add to `.env`:
|
||||
|
||||
```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
|
||||
```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
|
||||
```
|
||||
|
||||
### 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.
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
## Next Steps
|
||||
|
||||
@@ -134,5 +100,3 @@ 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>
|
||||
|
||||
@@ -71,8 +71,6 @@ Parse the `PAIR_TELEGRAM_ISSUED` status block for `CODE` and follow the `REMINDE
|
||||
|
||||
## 4. Run the init script
|
||||
|
||||
First, pick the agent provider. Read `src/providers/index.ts` and collect the installed providers from its `import './<name>.js';` lines — `claude` is always available as the built-in default. If a non-default provider is installed (e.g. codex), ask the user which one this agent should run on; if only claude is available, skip the question and omit the flag.
|
||||
|
||||
```bash
|
||||
npx tsx scripts/init-first-agent.ts \
|
||||
--channel "${CHANNEL}" \
|
||||
@@ -82,7 +80,7 @@ npx tsx scripts/init-first-agent.ts \
|
||||
--agent-name "${AGENT_NAME}"
|
||||
```
|
||||
|
||||
Add `--provider <name>` when the user picked a non-default provider (there is no install-wide default — the choice is explicit per group). Add `--welcome "System instruction: ..."` to override the default welcome prompt.
|
||||
Add `--welcome "System instruction: ..."` to override the default welcome prompt.
|
||||
|
||||
The script:
|
||||
1. Upserts the `users` row and grants `owner` role if no owner exists.
|
||||
|
||||
@@ -67,8 +67,6 @@ pnpm exec tsx setup/index.ts --step register -- \
|
||||
|
||||
The `register` step creates the agent group (reusing it if the folder already exists), the messaging group, and the wiring row. `createMessagingGroupAgent` auto-creates the companion `agent_destinations` row so the agent can address the channel by name.
|
||||
|
||||
When creating a NEW agent group on a non-default provider, append `--provider <name>` (e.g. `--provider codex`) — there is no install-wide default; existing groups switch via `ncl groups config update --provider` instead.
|
||||
|
||||
For separate agents, also ask for a folder name and optionally a different assistant name.
|
||||
|
||||
## Add Channel Group
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
name: migrate-memory
|
||||
description: Carry an agent group's memory across a provider switch, in either direction (e.g. Claude ↔ Codex, or any provider to/from another). Run after the operator switches a group's provider with `ncl groups config update --provider`. The coding agent reads the source provider's memory store, distills it into the target provider's store, and restarts the group. Triggers on "migrate memory", "carry memory over", "the agent forgot everything after the switch".
|
||||
---
|
||||
|
||||
# Migrate memory across a provider switch
|
||||
|
||||
NanoClaw does not migrate memory at runtime — each provider keeps its own store, and carrying content across is the operator's move, executed by you (the coding agent). This skill is the whole mechanism: read the source store, **infer** what is durable, write it into the target store, restart.
|
||||
|
||||
You translate between **store shapes**, not provider names. There are two:
|
||||
|
||||
- **Flat file** — `CLAUDE.local.md` at the group workspace root (the Claude provider; may reference satellite files in the workspace).
|
||||
- **Scaffold tree** — `memory/` (any provider with `usesMemoryScaffold`, e.g. Codex). `memory/index.md` is the index; durable notes live under `memory/memories/`; `memory/memories/imported-agent-memory.md` is the conventional landing file for imported memory.
|
||||
|
||||
A switch only needs migration when it **crosses shapes**. Two providers that both use the scaffold share the same `memory/` tree, so switching between them carries nothing — the memory is already there. The work is always one of: flat → scaffold, or scaffold → flat.
|
||||
|
||||
Principles: **copy, never move** (the source store stays intact — it IS the rollback), **idempotent** (re-running must not duplicate), **distill, don't dump** (you are the inference step: keep identity/seed instructions, user preferences, durable facts; drop conversational residue).
|
||||
|
||||
## Step 1: Identify the group, both providers, and the direction
|
||||
|
||||
- `ncl groups list`, then `ncl groups config get --id <group-id>` — note the current (target) `provider`. Ask the operator which group, and which provider it switched *from*, if either is ambiguous.
|
||||
- Map each provider to its store shape (flat `CLAUDE.local.md` vs `memory/` scaffold), then inspect `groups/<folder>/`:
|
||||
- **Same shape on both sides** (e.g. scaffold → scaffold) → the store is shared; nothing to migrate. Tell the operator and stop.
|
||||
- **Flat → scaffold** (source has `CLAUDE.local.md` content, target uses the scaffold) → Step 2.
|
||||
- **Scaffold → flat** (source has a `memory/` tree, target is Claude) → Step 3.
|
||||
- Source missing or empty → nothing to migrate; tell the operator and stop.
|
||||
|
||||
## Step 2: flat → scaffold (`CLAUDE.local.md` → `memory/`)
|
||||
|
||||
1. Read `groups/<folder>/CLAUDE.local.md` and any workspace files it references.
|
||||
2. If `memory/memories/imported-agent-memory.md` already exists, a previous import happened — show the operator what's there and ask before overwriting; integrate only what's new.
|
||||
3. Distill the content into `groups/<folder>/memory/memories/imported-agent-memory.md` (create the directories if missing — the container scaffolds the rest of the tree at boot and never clobbers your files). Lead with anything that defines who the agent is or how it must behave; references to satellite files keep their workspace-root paths.
|
||||
4. If `memory/index.md` exists, add the following: `- [Imported agent memory](memories/imported-agent-memory.md) — seed instructions and memory carried over from a previous provider. Read it first and treat it as binding; it may define who you are and how to behave. Integrate its facts into your memory as you work; never modify files that belong to another provider's memory system.`
|
||||
5. Leave the source store exactly as it is.
|
||||
|
||||
## Step 3: scaffold → flat (`memory/` → `CLAUDE.local.md`)
|
||||
|
||||
1. Read `memory/index.md`, then the files it points to under `memory/memories/` (and `memory/data/` where durable).
|
||||
2. Integrate the durable facts into `groups/<folder>/CLAUDE.local.md` under a clearly marked section (e.g. `## Imported from memory/ (<date>)`), deduplicating against what's already there. If the section already exists, update it instead of appending a second one.
|
||||
3. Leave the source store exactly as it is.
|
||||
|
||||
## Step 4: Restart and verify
|
||||
|
||||
```bash
|
||||
ncl groups restart --id <group-id>
|
||||
```
|
||||
|
||||
Tell the operator to send the group a quick test message that depends on a migrated fact (a preference, a project name). If the agent doesn't know it, re-check that the target file landed in the right group folder.
|
||||
|
||||
Note: switching the provider is an operator action — `ncl groups config update --id <group-id> --provider <name>` from the host. See [docs/provider-migration.md](../../../docs/provider-migration.md) for what carries over automatically.
|
||||
@@ -2,17 +2,6 @@
|
||||
|
||||
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 `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.
|
||||
- **Memory migrates via `/migrate-memory`, never at runtime.** Each provider keeps its own store; fresh groups on a surfaces-owning provider see no stale `CLAUDE.*` files. See [docs/provider-migration.md](docs/provider-migration.md).
|
||||
- **Per-exchange archiving is provider-owned** — the `onExchangeComplete` hook; the markdown writer ships with the codex payload.
|
||||
- **Container boot failures now say why** — the last stderr lines are logged at `warn` on a non-zero exit instead of a silent crash loop.
|
||||
- **Slash commands now interrupt an in-flight turn.** A runner-handled command (`/clear`, `/compact`, `/cost`, …) arriving mid-turn aborts the active stream and runs immediately instead of waiting out the turn.
|
||||
|
||||
## [2.1.0] - 2026-06-07
|
||||
|
||||
- [BREAKING] **Startup now requires an upgrade marker.** The host refuses to boot unless `data/upgrade-state.json` records that this install reached the current version through a sanctioned path (`/setup`, `/update-nanoclaw`, `/migrate-nanoclaw`). After this update completes — and before restarting the service — stamp the marker by running `pnpm exec tsx scripts/upgrade-state.ts set`. If the host has already tripped on restart with "update did not go through the supported path", that same command clears it. See [docs/upgrade-recovery.md](docs/upgrade-recovery.md).
|
||||
|
||||
@@ -69,8 +69,8 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
|
||||
| `src/modules/permissions/access.ts` | `canAccessAgentGroup` — owner / global admin / scoped admin / member resolution against `user_roles` + `agent_group_members` |
|
||||
| `src/modules/approvals/primitive.ts` | `pickApprover`, `pickApprovalDelivery`, `requestApproval`, approval-handler registry |
|
||||
| `src/command-gate.ts` | Router-side admin command gate — queries `user_roles` directly (no env var, no container-side check) |
|
||||
| `src/modules/approvals/onecli-approvals.ts` | OneCLI credentialed-action approval bridge |
|
||||
| `src/modules/permissions/user-dm.ts` | Cold-DM resolution + `user_dms` cache |
|
||||
| `src/onecli-approvals.ts` | OneCLI credentialed-action approval bridge |
|
||||
| `src/user-dm.ts` | Cold-DM resolution + `user_dms` cache |
|
||||
| `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills, agent-runner-src overlay) |
|
||||
| `src/db/container-configs.ts` | CRUD for `container_configs` table (per-group container runtime config) |
|
||||
| `src/backfill-container-configs.ts` | Migrates legacy `container.json` files into the DB on startup |
|
||||
@@ -152,7 +152,7 @@ Key files: `src/container-restart.ts`, `src/container-runner.ts` (`killContainer
|
||||
|
||||
## Secrets / Credentials / OneCLI
|
||||
|
||||
API keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway. Secrets are injected into per-agent containers at request time — none are passed in env vars or through chat context. The container agent sees this via the `onecli-gateway` container skill (`container/skills/onecli-gateway/SKILL.md`), which teaches it how the proxy works, how to handle auth errors, and to never ask for raw credentials. Host-side wiring: `src/modules/approvals/onecli-approvals.ts`, `ensureAgent()` in `container-runner.ts`. Run `onecli --help`.
|
||||
API keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway. Secrets are injected into per-agent containers at request time — none are passed in env vars or through chat context. The container agent sees this via the `onecli-gateway` container skill (`container/skills/onecli-gateway/SKILL.md`), which teaches it how the proxy works, how to handle auth errors, and to never ask for raw credentials. Host-side wiring: `src/onecli-approvals.ts`, `ensureAgent()` in `container-runner.ts`. Run `onecli --help`.
|
||||
|
||||
### Secret modes
|
||||
|
||||
@@ -193,7 +193,6 @@ Four types of skills. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxono
|
||||
| `/debug` | Container issues, logs, troubleshooting |
|
||||
| `/update-nanoclaw` | Bring upstream updates into a customized install |
|
||||
| `/init-onecli` | Install OneCLI Agent Vault and migrate `.env` credentials |
|
||||
| `/migrate-memory` | Carry a group's agent memory across a provider switch (operator-run, both directions) |
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -276,7 +275,6 @@ This project uses pnpm with `minimumReleaseAge: 4320` (3 days) in `pnpm-workspac
|
||||
| [docs/build-and-runtime.md](docs/build-and-runtime.md) | Runtime split (Node host + Bun container), lockfiles, image build surface, CI, key invariants |
|
||||
| [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) | v1→v2 architecture diff — vocabulary for where v1 things moved |
|
||||
| [docs/migration-dev.md](docs/migration-dev.md) | Migration development guide — testing, debugging, dev loop |
|
||||
| [docs/provider-migration.md](docs/provider-migration.md) | Switching a live agent group between providers (e.g. Claude → Codex) — what carries over, rollback |
|
||||
| [docs/customizing.md](docs/customizing.md) | Short intro to customizing via skills |
|
||||
| [docs/skills-model.md](docs/skills-model.md) | The skills model in full: recipes, tests, upgrades, migrations |
|
||||
| [docs/skill-guidelines.md](docs/skill-guidelines.md) | Authoritative checklist for writing a skill |
|
||||
|
||||
@@ -19,13 +19,6 @@
|
||||
|
||||
**Not accepted:** Features, capabilities, compatibility, enhancements. These should be skills.
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
Breaking changes are allowed; **silent** ones are not. NanoClaw does not migrate user installs at runtime — the user's coding agent is the migrator, so every breaking change must ship a migration path that agent can execute without a human reverse-engineering the diff:
|
||||
|
||||
1. **Every `[BREAKING]` CHANGELOG entry must reference its migration path** — either a skill to run (`Run /<skill-name> to <action>`) or a `docs/` page covering **detect / why / fix / verify / rollback** (see [docs/onecli-upgrades.md](docs/onecli-upgrades.md) for the shape). `/update-nanoclaw` surfaces these entries after every update and walks the user through them.
|
||||
2. **If the change moves an external component's sanctioned version** (gateway, pinned CLI binary, …), update its pin in [`versions.json`](versions.json). The changelog stays human-narrative; `versions.json` is the machine-checkable signal — `/update-nanoclaw` diffs it across the update and routes the user to the linked doc for any pin that moved.
|
||||
|
||||
## Skills
|
||||
|
||||
NanoClaw uses [Claude Code skills](https://code.claude.com/docs/en/skills) — markdown files with optional supporting files that teach Claude how to do something. There are four types of skills in NanoClaw, each serving a different purpose.
|
||||
|
||||
+15
-11
@@ -16,11 +16,12 @@ FROM node:22-slim
|
||||
# CJK fonts add ~200MB. Opt in only if you render Chinese/Japanese/Korean text.
|
||||
ARG INSTALL_CJK_FONTS=false
|
||||
|
||||
# Pin versions for reproducibility. Bump deliberately — unpinned installs mean
|
||||
# every rebuild silently picks up the latest and can break in lockstep across
|
||||
# all users. The global Node CLIs (claude-code, agent-browser, vercel) are
|
||||
# pinned in cli-tools.json so a skill can add one with a json-merge; Bun (the
|
||||
# runtime) is pinned here because it installs from a different source.
|
||||
# Pin CLI versions for reproducibility. Bump deliberately — unpinned installs
|
||||
# mean every rebuild silently picks up the latest and can break in lockstep
|
||||
# across all users.
|
||||
ARG CLAUDE_CODE_VERSION=2.1.170
|
||||
ARG AGENT_BROWSER_VERSION=latest
|
||||
ARG VERCEL_VERSION=52.2.1
|
||||
ARG BUN_VERSION=1.3.12
|
||||
|
||||
# ---- System dependencies -----------------------------------------------------
|
||||
@@ -98,13 +99,16 @@ ENV PATH="$PNPM_HOME:$PATH"
|
||||
ARG PNPM_VERSION=10.33.0
|
||||
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
|
||||
|
||||
# Global Node CLIs the agent invokes at runtime live in cli-tools.json so a
|
||||
# skill can add one with a json-merge instead of editing this Dockerfile.
|
||||
# install-cli-tools.sh installs each via pnpm (pinned), writing the per-tool
|
||||
# only-built-dependencies opt-ins it reads from the manifest.
|
||||
COPY cli-tools.json install-cli-tools.sh /tmp/
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
sh /tmp/install-cli-tools.sh /tmp/cli-tools.json
|
||||
echo "only-built-dependencies[]=agent-browser" > /root/.npmrc && \
|
||||
echo "only-built-dependencies[]=@anthropic-ai/claude-code" >> /root/.npmrc && \
|
||||
pnpm install -g "vercel@${VERCEL_VERSION}"
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g "agent-browser@${AGENT_BROWSER_VERSION}"
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"
|
||||
|
||||
# ---- ncl CLI wrapper ----------------------------------------------------------
|
||||
# Actual script lives in the mounted source at /app/src/cli/ncl.ts.
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface RunnerConfig {
|
||||
groupName: string;
|
||||
agentGroupId: string;
|
||||
maxMessagesPerPrompt: number;
|
||||
/** Idle window in ms after which the poll loop exits cleanly. 0 = disabled. */
|
||||
idleTimeoutMs: number;
|
||||
mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }>;
|
||||
model?: string;
|
||||
effort?: string;
|
||||
@@ -44,6 +46,7 @@ export function loadConfig(): RunnerConfig {
|
||||
groupName: (raw.groupName as string) || '',
|
||||
agentGroupId: (raw.agentGroupId as string) || '',
|
||||
maxMessagesPerPrompt: (raw.maxMessagesPerPrompt as number) || DEFAULT_MAX_MESSAGES,
|
||||
idleTimeoutMs: (raw.idleTimeoutMs as number) || 0,
|
||||
mcpServers: (raw.mcpServers as RunnerConfig['mcpServers']) || {},
|
||||
model: (raw.model as string) || undefined,
|
||||
effort: (raw.effort as string) || undefined,
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Idle-timeout guard — the machinery that lets ephemeral sessions exit
|
||||
* cleanly instead of riding until host-sweep's absolute ceiling.
|
||||
*
|
||||
* Behavior leg: the idle tracker with an injected clock (markActivity /
|
||||
* shouldExit semantics, including the hasProcessedAtLeastOne gate and the
|
||||
* idleTimeoutMs <= 0 disable).
|
||||
*
|
||||
* AST legs (runPollLoop is an infinite loop — not invocable in a test):
|
||||
* - runPollLoop destructures idleTimeoutMs from loadConfig() (the
|
||||
* destructure may carry other keys; this only pins idleTimeoutMs);
|
||||
* - the empty-poll branch exits via process.exit(0) gated on
|
||||
* idle.shouldExit();
|
||||
* - idle.markActivity() runs after the batch-completion
|
||||
* markCompleted(processingIds) so the idle window restarts per batch;
|
||||
* - the processQuery call site threads idleTimeoutMs as the 5th argument;
|
||||
* - the 'result' event arm calls query.end() gated on
|
||||
* `idleTimeoutMs > 0 && !hasUnwrapped` — never unconditionally, or the
|
||||
* unwrapped-output re-send nudge would be cut off mid-stream;
|
||||
* - loadConfig()'s returned literal carries the idleTimeoutMs field
|
||||
* (RunnerConfig's type is covered by the typecheck leg).
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import ts from 'typescript';
|
||||
|
||||
import { createIdleTracker } from './idle-tracker.js';
|
||||
|
||||
describe('idle tracker behavior', () => {
|
||||
it('never exits before the first processed batch, regardless of elapsed time', () => {
|
||||
let clock = 0;
|
||||
const tracker = createIdleTracker(1000, () => clock);
|
||||
clock = 1_000_000;
|
||||
expect(tracker.shouldExit()).toBe(false);
|
||||
});
|
||||
|
||||
it('exits only after the idle window elapses past the last activity', () => {
|
||||
let clock = 0;
|
||||
const tracker = createIdleTracker(1000, () => clock);
|
||||
|
||||
tracker.markActivity(); // first batch completes at t=0
|
||||
clock = 900;
|
||||
expect(tracker.shouldExit()).toBe(false);
|
||||
clock = 1001;
|
||||
expect(tracker.shouldExit()).toBe(true);
|
||||
|
||||
// New activity re-arms the window.
|
||||
tracker.markActivity();
|
||||
clock = 1900;
|
||||
expect(tracker.shouldExit()).toBe(false);
|
||||
clock = 2002;
|
||||
expect(tracker.shouldExit()).toBe(true);
|
||||
});
|
||||
|
||||
it('idleTimeoutMs <= 0 disables idle exit entirely', () => {
|
||||
let clock = 0;
|
||||
const tracker = createIdleTracker(0, () => clock);
|
||||
tracker.markActivity();
|
||||
clock = 10_000_000;
|
||||
expect(tracker.shouldExit()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── AST legs ──
|
||||
|
||||
function parse(file: string): ts.SourceFile {
|
||||
const source = fs.readFileSync(path.join(import.meta.dir, file), 'utf8');
|
||||
return ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true);
|
||||
}
|
||||
|
||||
function findAll<T extends ts.Node>(root: ts.Node, pred: (n: ts.Node) => n is T): T[] {
|
||||
const out: T[] = [];
|
||||
const visit = (n: ts.Node): void => {
|
||||
if (pred(n)) out.push(n);
|
||||
n.forEachChild(visit);
|
||||
};
|
||||
visit(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
function hasAncestor(node: ts.Node, pred: (n: ts.Node) => boolean): boolean {
|
||||
let cur: ts.Node | undefined = node.parent;
|
||||
while (cur) {
|
||||
if (pred(cur)) return true;
|
||||
cur = cur.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
describe('poll-loop.ts idle wiring', () => {
|
||||
const sf = parse('poll-loop.ts');
|
||||
const runPollLoop = findAll(sf, ts.isFunctionDeclaration).find((f) => f.name?.text === 'runPollLoop');
|
||||
|
||||
it('destructures idleTimeoutMs from loadConfig()', () => {
|
||||
const decls = findAll(runPollLoop!, ts.isVariableDeclaration).filter(
|
||||
(d) =>
|
||||
d.initializer !== undefined &&
|
||||
ts.isCallExpression(d.initializer) &&
|
||||
ts.isIdentifier(d.initializer.expression) &&
|
||||
d.initializer.expression.text === 'loadConfig' &&
|
||||
ts.isObjectBindingPattern(d.name),
|
||||
);
|
||||
expect(decls.length).toBeGreaterThanOrEqual(1);
|
||||
const hasKey = decls.some((d) =>
|
||||
(d.name as ts.ObjectBindingPattern).elements.some(
|
||||
(e) => ts.isIdentifier(e.name) && e.name.text === 'idleTimeoutMs',
|
||||
),
|
||||
);
|
||||
expect(hasKey).toBe(true);
|
||||
});
|
||||
|
||||
it('the empty-poll branch exits 0 gated on idle.shouldExit()', () => {
|
||||
const exits = findAll(runPollLoop!, ts.isCallExpression).filter(
|
||||
(c) =>
|
||||
ts.isPropertyAccessExpression(c.expression) &&
|
||||
c.expression.getText(sf) === 'process.exit' &&
|
||||
c.arguments[0]?.getText(sf) === '0',
|
||||
);
|
||||
const gated = exits.filter((c) =>
|
||||
hasAncestor(
|
||||
c,
|
||||
(n) => ts.isIfStatement(n) && n.expression.getText(sf).replace(/\s+/g, '') === 'idle.shouldExit()',
|
||||
),
|
||||
);
|
||||
expect(gated.length).toBe(1);
|
||||
// And the gate itself sits inside the messages.length === 0 branch.
|
||||
expect(
|
||||
hasAncestor(gated[0], (n) => ts.isIfStatement(n) && n.expression.getText(sf).includes('messages.length === 0')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('marks activity after markCompleted so the idle window restarts per batch', () => {
|
||||
const marks = findAll(runPollLoop!, ts.isCallExpression).filter(
|
||||
(c) => c.expression.getText(sf).replace(/\s+/g, '') === 'idle.markActivity',
|
||||
);
|
||||
expect(marks.length).toBe(1);
|
||||
// The batch-completion call is markCompleted(processingIds) — the others
|
||||
// handle command/skip bookkeeping and must not arm the idle window.
|
||||
const completed = findAll(runPollLoop!, ts.isCallExpression).filter(
|
||||
(c) =>
|
||||
ts.isIdentifier(c.expression) &&
|
||||
c.expression.text === 'markCompleted' &&
|
||||
c.arguments[0]?.getText(sf) === 'processingIds',
|
||||
);
|
||||
expect(completed.length).toBe(1);
|
||||
expect(marks[0].getStart(sf)).toBeGreaterThan(completed[0].getStart(sf));
|
||||
});
|
||||
|
||||
it("threads idleTimeoutMs as processQuery's 5th argument", () => {
|
||||
const calls = findAll(runPollLoop!, ts.isCallExpression).filter(
|
||||
(c) => ts.isIdentifier(c.expression) && c.expression.text === 'processQuery',
|
||||
);
|
||||
expect(calls.length).toBe(1);
|
||||
expect(calls[0].arguments.length).toBe(5);
|
||||
expect(calls[0].arguments[4].getText(sf)).toBe('idleTimeoutMs');
|
||||
});
|
||||
|
||||
it("the 'result' event arm ends the stream gated on idleTimeoutMs > 0 && !hasUnwrapped", () => {
|
||||
const processQuery = findAll(sf, ts.isFunctionDeclaration).find((f) => f.name?.text === 'processQuery');
|
||||
expect(processQuery).toBeDefined();
|
||||
const ends = findAll(processQuery!, ts.isCallExpression).filter(
|
||||
(c) => c.expression.getText(sf).replace(/\s+/g, '') === 'query.end',
|
||||
);
|
||||
// The !hasUnwrapped half of the gate is load-bearing: an unconditional
|
||||
// (or idleTimeoutMs-only) end would close the stream right after the
|
||||
// unwrapped-output nudge was pushed, stranding the re-sent response.
|
||||
const gated = ends.filter((c) =>
|
||||
hasAncestor(
|
||||
c,
|
||||
(n) =>
|
||||
ts.isIfStatement(n) && n.expression.getText(sf).replace(/\s+/g, '') === 'idleTimeoutMs>0&&!hasUnwrapped',
|
||||
),
|
||||
);
|
||||
expect(gated.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('config.ts idle wiring', () => {
|
||||
const sf = parse('config.ts');
|
||||
|
||||
it('loadConfig returns an idleTimeoutMs field', () => {
|
||||
const loadConfig = findAll(sf, ts.isFunctionDeclaration).find((f) => f.name?.text === 'loadConfig');
|
||||
expect(loadConfig).toBeDefined();
|
||||
const props = findAll(loadConfig!, ts.isPropertyAssignment).filter(
|
||||
(p) => ts.isIdentifier(p.name) && p.name.text === 'idleTimeoutMs',
|
||||
);
|
||||
expect(props.length).toBe(1);
|
||||
// Reads the raw container.json key with a 0 default (0 = disabled).
|
||||
expect(props[0].initializer.getText(sf).replace(/\s+/g, '')).toContain('raw.idleTimeoutMs');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Idle-exit tracker for ephemeral sessions.
|
||||
*
|
||||
* The poll loop creates one tracker per run and makes two one-line calls:
|
||||
*
|
||||
* - `markActivity()` after a batch completes — records the last time the
|
||||
* agent did real work and arms the tracker (an agent that never processed
|
||||
* anything must not idle-exit before its first trigger arrives).
|
||||
* - `shouldExit()` in the empty-poll branch — true once idleTimeoutMs > 0,
|
||||
* at least one batch has been processed, and the idle window has elapsed.
|
||||
*
|
||||
* `idleTimeoutMs` comes from the group's container.json (RunnerConfig),
|
||||
* materialized from the `container_configs.idle_timeout_ms` column. A value
|
||||
* of 0 (the default) disables idle exit entirely — the container then rides
|
||||
* until host-sweep's absolute ceiling, exactly as before this tracker existed.
|
||||
*/
|
||||
|
||||
export interface IdleTracker {
|
||||
/** Record activity: arms the tracker and resets the idle window. */
|
||||
markActivity(): void;
|
||||
/** True when the session has been idle past the timeout and may exit 0. */
|
||||
shouldExit(): boolean;
|
||||
}
|
||||
|
||||
export function createIdleTracker(idleTimeoutMs: number, now: () => number = Date.now): IdleTracker {
|
||||
let lastActivityAt = now();
|
||||
let hasProcessedAtLeastOne = false;
|
||||
|
||||
return {
|
||||
markActivity(): void {
|
||||
lastActivityAt = now();
|
||||
hasProcessedAtLeastOne = true;
|
||||
},
|
||||
shouldExit(): boolean {
|
||||
if (idleTimeoutMs <= 0) return false;
|
||||
if (!hasProcessedAtLeastOne) return false;
|
||||
return now() - lastActivityAt > idleTimeoutMs;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -27,7 +27,6 @@ import { fileURLToPath } from 'url';
|
||||
|
||||
import { loadConfig } from './config.js';
|
||||
import { buildSystemPromptAddendum } from './destinations.js';
|
||||
import { ensureMemoryScaffold } from './memory-scaffold.js';
|
||||
// Providers barrel — each enabled provider self-registers on import.
|
||||
// Provider skills append imports to providers/index.ts.
|
||||
import './providers/index.js';
|
||||
@@ -96,12 +95,6 @@ async function main(): Promise<void> {
|
||||
effort: config.effort,
|
||||
});
|
||||
|
||||
// Providers that lack native memory opt in via `usesMemoryScaffold`; for them
|
||||
// the runner creates a persistent memory/ tree in its host-backed workspace at
|
||||
// boot (idempotent). Default off — the trunk default (Claude) omits the flag
|
||||
// and keeps its native memory untouched.
|
||||
if (provider.usesMemoryScaffold) ensureMemoryScaffold();
|
||||
|
||||
await runPollLoop({
|
||||
provider,
|
||||
providerName,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { getUndeliveredMessages } from './db/messages-out.js';
|
||||
import { getPendingMessages } from './db/messages-in.js';
|
||||
import { getContinuation, setContinuation } from './db/session-state.js';
|
||||
import { MockProvider } from './providers/mock.js';
|
||||
import type { ProviderExchange } from './providers/types.js';
|
||||
import { runPollLoop } from './poll-loop.js';
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -305,7 +304,6 @@ async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSigna
|
||||
provider,
|
||||
providerName: 'mock',
|
||||
cwd: '/tmp',
|
||||
signal,
|
||||
}),
|
||||
new Promise<void>((_, reject) => {
|
||||
signal.addEventListener('abort', () => reject(new Error('aborted')));
|
||||
@@ -326,86 +324,6 @@ function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
describe('poll loop — exchange hook (onExchangeComplete)', () => {
|
||||
// A provider that declares the per-exchange hook. The hook call is the
|
||||
// wiring under test — these tests go red if the poll-loop seam is severed.
|
||||
// What the provider DOES with an exchange (e.g. write markdown into
|
||||
// conversations/) ships with the provider, not the runner.
|
||||
class HookedMockProvider extends MockProvider {
|
||||
readonly exchanges: ProviderExchange[] = [];
|
||||
onExchangeComplete(exchange: ProviderExchange): void {
|
||||
this.exchanges.push(exchange);
|
||||
}
|
||||
}
|
||||
|
||||
it('reports each exchange to a provider that declares the hook', async () => {
|
||||
insertMessage('m1', { sender: 'Alice', text: 'please archive this' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
const provider = new HookedMockProvider({}, () => '<message to="discord-test">archived answer</message>');
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||
|
||||
await waitFor(() => provider.exchanges.length > 0, 2000);
|
||||
controller.abort();
|
||||
|
||||
expect(provider.exchanges.length).toBe(1);
|
||||
const exchange = provider.exchanges[0];
|
||||
expect(exchange.prompt).toContain('please archive this');
|
||||
expect(exchange.result).toContain('archived answer');
|
||||
expect(exchange.continuation).toStartWith('mock-session-');
|
||||
expect(exchange.status).toBe('completed');
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('does not report the internal wrapping-retry nudge as a user prompt', async () => {
|
||||
insertMessage('m1', { sender: 'Alice', text: 'wrap this later' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
let calls = 0;
|
||||
const provider = new HookedMockProvider({}, () => {
|
||||
calls += 1;
|
||||
// First result is unwrapped (triggers the retry nudge), second is wrapped.
|
||||
return calls === 1 ? 'unwrapped text' : '<message to="discord-test">wrapped now</message>';
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 3000);
|
||||
|
||||
await waitFor(() => provider.exchanges.length >= 2, 3000);
|
||||
controller.abort();
|
||||
|
||||
// Both exchanges attribute themselves to the real user prompt, never the nudge.
|
||||
for (const exchange of provider.exchanges) {
|
||||
expect(exchange.prompt).not.toContain('Your response was not delivered');
|
||||
expect(exchange.prompt).toContain('wrap this later');
|
||||
}
|
||||
expect(provider.exchanges.map((e) => e.status)).toEqual(['undelivered', 'completed']);
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('a throwing hook never breaks delivery', async () => {
|
||||
insertMessage('m1', { sender: 'Alice', text: 'still deliver this' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
class ThrowingHookProvider extends MockProvider {
|
||||
onExchangeComplete(): void {
|
||||
throw new Error('hook exploded');
|
||||
}
|
||||
}
|
||||
const provider = new ThrowingHookProvider({}, () => '<message to="discord-test">delivered anyway</message>');
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||
controller.abort();
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out.length).toBe(1);
|
||||
expect(out[0].content).toContain('delivered anyway');
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('poll loop — provider error recovery', () => {
|
||||
it('writes error to outbound and continues loop on provider throw', async () => {
|
||||
insertMessage('m1', { sender: 'Alice', text: 'trigger error' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
@@ -544,76 +462,3 @@ class InvalidSessionProvider {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('poll loop — slash command during active query', () => {
|
||||
it('aborts the active query when /clear arrives as a follow-up', async () => {
|
||||
insertMessage('m-active', { sender: 'Alice', text: 'long running request' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
const provider = new BlockingProvider();
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 3000);
|
||||
|
||||
await waitFor(() => provider.queries === 1, 2000);
|
||||
insertMessage('m-clear-active', { sender: 'Alice', text: '/clear' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
await waitFor(() => provider.aborts === 1, 2000);
|
||||
await waitFor(
|
||||
() => getUndeliveredMessages().some((msg) => JSON.parse(msg.content).text === 'Session cleared.'),
|
||||
2000,
|
||||
);
|
||||
controller.abort();
|
||||
|
||||
expect(provider.ends).toBe(0);
|
||||
expect(getContinuation('mock')).toBeUndefined();
|
||||
expect(getPendingMessages()).toHaveLength(0);
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Provider whose query never completes until ended/aborted — for testing how
|
||||
* the loop interrupts an active stream.
|
||||
*/
|
||||
class BlockingProvider {
|
||||
readonly supportsNativeSlashCommands = false;
|
||||
queries = 0;
|
||||
aborts = 0;
|
||||
ends = 0;
|
||||
|
||||
isSessionInvalid(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
query() {
|
||||
const owner = this;
|
||||
this.queries += 1;
|
||||
let wake: (() => void) | null = null;
|
||||
let ended = false;
|
||||
let aborted = false;
|
||||
|
||||
return {
|
||||
push() {},
|
||||
end: () => {
|
||||
owner.ends += 1;
|
||||
ended = true;
|
||||
wake?.();
|
||||
},
|
||||
abort: () => {
|
||||
owner.aborts += 1;
|
||||
aborted = true;
|
||||
wake?.();
|
||||
},
|
||||
events: (async function* () {
|
||||
yield { type: 'activity' as const };
|
||||
yield { type: 'init' as const, continuation: 'blocking-session' };
|
||||
while (!ended && !aborted) {
|
||||
await new Promise<void>((resolve) => {
|
||||
wake = resolve;
|
||||
});
|
||||
wake = null;
|
||||
}
|
||||
})(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { ensureMemoryScaffold } from './memory-scaffold.js';
|
||||
|
||||
describe('ensureMemoryScaffold', () => {
|
||||
it('deterministically creates the memory tree', () => {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-mem-'));
|
||||
try {
|
||||
ensureMemoryScaffold(base);
|
||||
|
||||
expect(fs.existsSync(path.join(base, 'memory', 'index.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(base, 'memory', 'system', 'definition.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(base, 'memory', 'memories'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(base, 'memory', 'data'))).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('never touches workspace memory it did not create — CLAUDE.local.md stays untouched', () => {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-mem-'));
|
||||
try {
|
||||
fs.writeFileSync(path.join(base, 'CLAUDE.local.md'), '# group memory\nuser prefers terse replies\n');
|
||||
|
||||
ensureMemoryScaffold(base);
|
||||
|
||||
// Migration between memory stores is the operator's move (/migrate-memory),
|
||||
// never a boot side effect.
|
||||
expect(fs.existsSync(path.join(base, 'memory', 'memories', 'imported-agent-memory.md'))).toBe(false);
|
||||
expect(fs.readFileSync(path.join(base, 'CLAUDE.local.md'), 'utf-8')).toContain('terse replies');
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('is idempotent and never clobbers the agent edits', () => {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-mem-'));
|
||||
try {
|
||||
ensureMemoryScaffold(base);
|
||||
const indexFile = path.join(base, 'memory', 'index.md');
|
||||
fs.writeFileSync(indexFile, '# my own index\n');
|
||||
|
||||
ensureMemoryScaffold(base);
|
||||
|
||||
expect(fs.readFileSync(indexFile, 'utf-8')).toBe('# my own index\n');
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
/**
|
||||
* Create the agent's persistent memory scaffold, container-side, at boot.
|
||||
*
|
||||
* The runner owns its own workspace: it writes the memory tree straight into
|
||||
* `/workspace/agent` (the host-backed, RW group dir, so it persists across the
|
||||
* ephemeral container). No host-side step, nothing mounted in.
|
||||
*
|
||||
* The default `definition.md` / `index.md` live as real markdown templates next
|
||||
* to this module (under `memory-templates/`) — not as strings in code — so the
|
||||
* doctrine is editable as markdown and the agent receives an unescaped copy.
|
||||
* They ship in the mounted `/app/src` tree, so no image change is needed.
|
||||
*
|
||||
* Idempotent — only writes what's missing, so the agent's own edits and
|
||||
* accumulated memory are never clobbered on a later wake. Provider-agnostic:
|
||||
* the runner makes no assumption about which harness is running — a provider
|
||||
* opts in via `usesMemoryScaffold`.
|
||||
*/
|
||||
const TEMPLATES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'memory-templates');
|
||||
|
||||
export function ensureMemoryScaffold(baseDir = '/workspace/agent'): void {
|
||||
const memoryDir = path.join(baseDir, 'memory');
|
||||
const systemDir = path.join(memoryDir, 'system');
|
||||
|
||||
for (const dir of [systemDir, path.join(memoryDir, 'memories'), path.join(memoryDir, 'data')]) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
copyTemplateIfMissing('definition.md', path.join(systemDir, 'definition.md'));
|
||||
copyTemplateIfMissing('index.md', path.join(memoryDir, 'index.md'));
|
||||
}
|
||||
|
||||
function copyTemplateIfMissing(template: string, dest: string): void {
|
||||
if (fs.existsSync(dest)) return;
|
||||
fs.copyFileSync(path.join(TEMPLATES_DIR, template), dest);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Wiring guard for the memory-scaffold seam: the boot gate in index.ts
|
||||
// (`if (provider.usesMemoryScaffold) ensureMemoryScaffold()`) is the seam's
|
||||
// single functional reach-in. The unit tests in memory-scaffold.test.ts drive
|
||||
// ensureMemoryScaffold directly and stay green if the gate is deleted — this
|
||||
// test goes red. main() can't be driven in-process (it reads
|
||||
// /workspace/agent/container.json and enters the poll loop), so the guard is
|
||||
// structural: gate + import must both be present in the real entry point.
|
||||
describe('memory scaffold boot wiring', () => {
|
||||
const indexSrc = fs.readFileSync(path.join(import.meta.dir, 'index.ts'), 'utf-8');
|
||||
|
||||
it('gates the scaffold on the provider capability in main()', () => {
|
||||
expect(indexSrc).toContain('if (provider.usesMemoryScaffold) ensureMemoryScaffold()');
|
||||
});
|
||||
|
||||
it('imports ensureMemoryScaffold from the seam module', () => {
|
||||
expect(indexSrc).toContain("import { ensureMemoryScaffold } from './memory-scaffold.js'");
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
# Agent Memory System
|
||||
|
||||
This editable file defines how your persistent memory works. It is a starting
|
||||
point, not a contract — reorganize it as the work demands. If the user or another
|
||||
memory system replaces this definition, follow the replacement.
|
||||
|
||||
Start every memory task at `memory/index.md`, then follow the narrowest relevant index.
|
||||
Treat indexes as core data: keep them accurate and concise.
|
||||
Every folder of durable memory has its own `index.md` describing its contents.
|
||||
When an index grows past roughly 20 entries, group related items into subfolders,
|
||||
and give each new subfolder its own `index.md` linked from the parent.
|
||||
|
||||
Use `memory/memories/` for durable facts, project context, people, decisions, and entity notes.
|
||||
Use `memory/data/` for structured reference data, datasets, tables, and reusable records.
|
||||
Use entity folders for things that matter: projects, people, places, organizations, decisions.
|
||||
|
||||
When the user shares something that should survive future turns, store it in the
|
||||
smallest useful file; prefer updating an existing file over creating duplicates.
|
||||
Write concise, source-aware notes; include dates when timing matters.
|
||||
If a fact is corrected, update the memory and keep only useful history.
|
||||
When you add, move, or remove memory, update the nearest index.
|
||||
Before answering from memory, read the relevant index or file instead of guessing;
|
||||
if memory is missing or uncertain, say so and verify when it matters.
|
||||
@@ -1,5 +0,0 @@
|
||||
# Memory Index
|
||||
|
||||
- [Memory system definition](system/definition.md)
|
||||
- [Memories](memories/) - durable facts, people, projects, decisions
|
||||
- [Data](data/) - structured reference data
|
||||
@@ -4,6 +4,8 @@ import { writeMessageOut } from './db/messages-out.js';
|
||||
import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
||||
import { clearContinuation, migrateLegacyContinuation, setContinuation } from './db/session-state.js';
|
||||
import { clearCurrentInReplyTo, setCurrentInReplyTo } from './current-batch.js';
|
||||
import { loadConfig } from './config.js';
|
||||
import { createIdleTracker } from './idle-tracker.js';
|
||||
import {
|
||||
formatMessages,
|
||||
extractRouting,
|
||||
@@ -14,7 +16,7 @@ import {
|
||||
type RoutingContext,
|
||||
} from './formatter.js';
|
||||
import { isUploadTraceCommand, uploadTrace } from './upload-trace.js';
|
||||
import type { AgentProvider, AgentQuery, ProviderEvent, ProviderExchange } from './providers/types.js';
|
||||
import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js';
|
||||
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
const ACTIVE_POLL_INTERVAL_MS = 500;
|
||||
@@ -63,12 +65,6 @@ export interface PollLoopConfig {
|
||||
systemContext?: {
|
||||
instructions?: string;
|
||||
};
|
||||
/**
|
||||
* Optional stop signal. In production the loop runs until the container
|
||||
* dies; tests pass a signal so an abandoned loop actually exits instead of
|
||||
* polling forever and stealing messages from the next test's DB.
|
||||
*/
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,10 +106,15 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
// This lets the new container re-process those messages.
|
||||
clearStaleProcessingAcks();
|
||||
|
||||
// Idle exit: when the group's container config sets idle_timeout_ms, an
|
||||
// idle container exits 0 after the window elapses instead of riding until
|
||||
// host-sweep's absolute ceiling kills it. Unset/0 = disabled (default).
|
||||
const { idleTimeoutMs } = loadConfig();
|
||||
const idle = createIdleTracker(idleTimeoutMs);
|
||||
|
||||
let pollCount = 0;
|
||||
let isFirstPoll = true;
|
||||
while (true) {
|
||||
if (config.signal?.aborted) return;
|
||||
// Skip system messages — they're responses for MCP tools (e.g., ask_user_question)
|
||||
const messages = getPendingMessages(isFirstPoll).filter((m) => m.kind !== 'system');
|
||||
isFirstPoll = false;
|
||||
@@ -125,6 +126,10 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
if (idle.shouldExit()) {
|
||||
log(`Idle timeout (${idleTimeoutMs}ms) — exiting`);
|
||||
process.exit(0);
|
||||
}
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
continue;
|
||||
}
|
||||
@@ -239,15 +244,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
// can stamp it on outbound rows — needed for a2a return-path routing.
|
||||
setCurrentInReplyTo(routing.inReplyTo);
|
||||
try {
|
||||
const result = await processQuery(
|
||||
query,
|
||||
routing,
|
||||
processingIds,
|
||||
config.providerName,
|
||||
config.provider.onExchangeComplete?.bind(config.provider),
|
||||
prompt,
|
||||
continuation,
|
||||
);
|
||||
const result = await processQuery(query, routing, processingIds, config.providerName, idleTimeoutMs);
|
||||
if (result.continuation && result.continuation !== continuation) {
|
||||
continuation = result.continuation;
|
||||
setContinuation(config.providerName, continuation);
|
||||
@@ -281,6 +278,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
// Ensure completed even if processQuery ended without a result event
|
||||
// (e.g. stream closed unexpectedly).
|
||||
markCompleted(processingIds);
|
||||
idle.markActivity();
|
||||
log(`Completed ${ids.length} message(s)`);
|
||||
}
|
||||
}
|
||||
@@ -328,18 +326,11 @@ async function processQuery(
|
||||
routing: RoutingContext,
|
||||
initialBatchIds: string[],
|
||||
providerName: string,
|
||||
onExchangeComplete: ((exchange: ProviderExchange) => void) | undefined,
|
||||
initialPrompt: string,
|
||||
initialContinuation: string | undefined,
|
||||
idleTimeoutMs: number = 0,
|
||||
): Promise<QueryResult> {
|
||||
let queryContinuation: string | undefined;
|
||||
let done = false;
|
||||
let unwrappedNudged = false;
|
||||
// Prompt queue for the exchange hook — each result event consumes the
|
||||
// oldest unanswered prompt, except a wrapping-retry result, which answers
|
||||
// the same prompt again. Unused (and unmaintained) when the provider
|
||||
// doesn't implement `onExchangeComplete`.
|
||||
const archivePrompts: string[] = [initialPrompt];
|
||||
|
||||
// Concurrent polling: push follow-ups into the active query as they arrive.
|
||||
// We do NOT force-end the stream on silence — keeping the query open avoids
|
||||
@@ -365,16 +356,13 @@ async function processQuery(
|
||||
// resume id (fixed at sdkQuery() time); admin/passthrough commands
|
||||
// (/compact, /cost, …) only dispatch when they're the first input
|
||||
// of a query — pushed mid-stream they arrive as plain text and
|
||||
// the SDK never runs them. Abort the active stream and leave the
|
||||
// rows pending; the outer loop handles them on next iteration via
|
||||
// the canonical command path + formatMessagesWithCommands. Abort,
|
||||
// not end: end() lets an in-flight turn run to completion, which
|
||||
// can block the command (e.g. /clear during a long task) for as
|
||||
// long as the turn takes.
|
||||
// the SDK never runs them. End the stream and leave the rows
|
||||
// pending; the outer loop handles them on next iteration via the
|
||||
// canonical command path + formatMessagesWithCommands.
|
||||
if (pending.some((m) => isRunnerCommand(m))) {
|
||||
log('Pending slash command — aborting active stream so outer loop can process');
|
||||
log('Pending slash command — ending stream so outer loop can process');
|
||||
endedForCommand = true;
|
||||
query.abort();
|
||||
query.end();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -419,7 +407,6 @@ async function processQuery(
|
||||
log(`Pushing ${keep.length} follow-up message(s) into active query`);
|
||||
unwrappedNudged = false;
|
||||
query.push(prompt);
|
||||
archivePrompts.push(prompt);
|
||||
markCompleted(keptIds);
|
||||
} catch (err) {
|
||||
// Without this catch the rejection escapes the void IIFE and Node
|
||||
@@ -481,16 +468,10 @@ async function processQuery(
|
||||
// (send_message) mid-turn, or the message may not need a response
|
||||
// at all — either way the turn is finished.
|
||||
markCompleted(initialBatchIds);
|
||||
let hasUnwrapped = false;
|
||||
if (event.text) {
|
||||
const { hasUnwrapped } = dispatchResultText(event.text, routing);
|
||||
const willRetryWrapping = hasUnwrapped && !unwrappedNudged;
|
||||
notifyExchangeComplete(onExchangeComplete, {
|
||||
prompt: archivePrompts[0] ?? initialPrompt,
|
||||
result: event.text,
|
||||
continuation: queryContinuation ?? initialContinuation,
|
||||
status: hasUnwrapped ? 'undelivered' : 'completed',
|
||||
});
|
||||
if (willRetryWrapping) {
|
||||
({ hasUnwrapped } = dispatchResultText(event.text, routing));
|
||||
if (hasUnwrapped && !unwrappedNudged) {
|
||||
unwrappedNudged = true;
|
||||
const destinations = getAllDestinations();
|
||||
const names = destinations.map((d) => d.name).join(', ');
|
||||
@@ -501,23 +482,16 @@ async function processQuery(
|
||||
`Please re-send your response with the correct wrapping.</system>`,
|
||||
);
|
||||
}
|
||||
// The wrapping-retry result answers the SAME user prompt — keep it
|
||||
// queued so the retry archives against it, not the nudge text.
|
||||
if (!willRetryWrapping) archivePrompts.shift();
|
||||
} else {
|
||||
archivePrompts.shift();
|
||||
}
|
||||
// When idleTimeoutMs is set, end the stream once the turn completes
|
||||
// so the outer loop can evaluate the idle window. Skipped while the
|
||||
// turn's output was unwrapped — the re-send nudge pushed above needs
|
||||
// the stream to stay open for the corrected response.
|
||||
if (idleTimeoutMs > 0 && !hasUnwrapped) {
|
||||
query.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
notifyExchangeComplete(onExchangeComplete, {
|
||||
prompt: archivePrompts[0] ?? initialPrompt,
|
||||
result: `Error: ${errMsg}`,
|
||||
continuation: queryContinuation ?? initialContinuation,
|
||||
status: 'error',
|
||||
});
|
||||
throw err;
|
||||
} finally {
|
||||
done = true;
|
||||
clearInterval(pollHandle);
|
||||
@@ -526,18 +500,6 @@ async function processQuery(
|
||||
return { continuation: queryContinuation };
|
||||
}
|
||||
|
||||
function notifyExchangeComplete(
|
||||
hook: ((exchange: ProviderExchange) => void) | undefined,
|
||||
exchange: ProviderExchange,
|
||||
): void {
|
||||
if (!hook) return;
|
||||
try {
|
||||
hook(exchange);
|
||||
} catch (err) {
|
||||
log(`onExchangeComplete failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
|
||||
switch (event.type) {
|
||||
case 'init':
|
||||
|
||||
@@ -6,25 +6,6 @@ export interface AgentProvider {
|
||||
*/
|
||||
readonly supportsNativeSlashCommands: boolean;
|
||||
|
||||
/**
|
||||
* Optional. When true, the runner scaffolds a persistent `memory/` tree in the
|
||||
* agent's workspace at boot. Providers with their own native memory (e.g.
|
||||
* Claude's `CLAUDE.local.md`) omit this and get nothing — memory is opt-in per
|
||||
* provider, never gated on a provider name.
|
||||
*/
|
||||
readonly usesMemoryScaffold?: boolean;
|
||||
|
||||
/**
|
||||
* Optional. Called by the poll-loop after each completed exchange (a
|
||||
* result, a wrapping retry, or an error). Providers whose harness keeps no
|
||||
* on-disk transcript implement this to persist exchanges themselves (e.g.
|
||||
* markdown into the agent's `conversations/` dir); providers that persist
|
||||
* and archive their own transcript (e.g. the Claude Agent SDK's `.jsonl`)
|
||||
* omit it. Best-effort: the loop catches and logs anything it throws. The
|
||||
* implementation lives with the provider, never in the runner.
|
||||
*/
|
||||
onExchangeComplete?(exchange: ProviderExchange): void;
|
||||
|
||||
/** Start a new query. Returns a handle for streaming input and output. */
|
||||
query(input: QueryInput): AgentQuery;
|
||||
|
||||
@@ -50,16 +31,6 @@ export interface AgentProvider {
|
||||
maybeRotateContinuation?(continuation: string, cwd: string): string | null;
|
||||
}
|
||||
|
||||
/** One prompt/result round-trip, as reported to `onExchangeComplete`. */
|
||||
export interface ProviderExchange {
|
||||
/** The user prompt this exchange answers (never an internal retry nudge). */
|
||||
prompt: string;
|
||||
result: string | null;
|
||||
/** Continuation/thread id in effect for the exchange, if any. */
|
||||
continuation?: string;
|
||||
status: 'completed' | 'undelivered' | 'error';
|
||||
}
|
||||
|
||||
/**
|
||||
* Options passed to provider constructors. Fields are common to most
|
||||
* providers; individual providers may ignore any they don't need.
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
[
|
||||
{ "name": "vercel", "version": "52.2.1" },
|
||||
{ "name": "agent-browser", "version": "0.27.1", "onlyBuilt": true },
|
||||
{ "name": "@anthropic-ai/claude-code", "version": "2.1.170", "onlyBuilt": true }
|
||||
]
|
||||
@@ -1,61 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
// Guards the cli-tools.json seam: the global CLIs the agent invokes at runtime
|
||||
// are installed from the manifest (a skill adds one with a json-merge), not
|
||||
// hand-edited into the Dockerfile. These go red on a bad merge that drops a
|
||||
// baseline tool, or on dewiring the Dockerfile / switching the installer off
|
||||
// the pnpm supply-chain path.
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const manifest = JSON.parse(readFileSync(join(here, 'cli-tools.json'), 'utf8')) as Array<{
|
||||
name: string;
|
||||
version: string;
|
||||
onlyBuilt?: boolean;
|
||||
}>;
|
||||
const dockerfile = readFileSync(join(here, 'Dockerfile'), 'utf8');
|
||||
const installer = readFileSync(join(here, 'install-cli-tools.sh'), 'utf8');
|
||||
|
||||
describe('cli-tools manifest', () => {
|
||||
it('is a non-empty array of { name, version }', () => {
|
||||
expect(Array.isArray(manifest)).toBe(true);
|
||||
expect(manifest.length).toBeGreaterThan(0);
|
||||
for (const tool of manifest) {
|
||||
expect(typeof tool.name).toBe('string');
|
||||
expect(tool.name.length).toBeGreaterThan(0);
|
||||
expect(typeof tool.version).toBe('string');
|
||||
expect(tool.version.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('has unique tool names (json-merge is keyed on name)', () => {
|
||||
const names = manifest.map((t) => t.name);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
|
||||
it('pins every version to an exact semver (no latest, no ranges — supply-chain policy)', () => {
|
||||
for (const tool of manifest) {
|
||||
expect(tool.version, `${tool.name} must be an exact semver, not "${tool.version}"`).toMatch(
|
||||
/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the baseline CLIs the agent depends on', () => {
|
||||
const names = manifest.map((t) => t.name);
|
||||
for (const required of ['vercel', 'agent-browser', '@anthropic-ai/claude-code']) {
|
||||
expect(names).toContain(required);
|
||||
}
|
||||
});
|
||||
|
||||
it('is wired into the Dockerfile build (COPY manifest + run installer)', () => {
|
||||
expect(dockerfile).toMatch(/COPY cli-tools\.json install-cli-tools\.sh/);
|
||||
expect(dockerfile).toMatch(/install-cli-tools\.sh \/tmp\/cli-tools\.json/);
|
||||
});
|
||||
|
||||
it('installs via pnpm and writes only-built opt-ins (preserves the supply-chain path)', () => {
|
||||
expect(installer).toMatch(/pnpm install -g/);
|
||||
expect(installer).toMatch(/only-built-dependencies\[\]=/);
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Install the global Node CLIs the agent invokes at runtime, from cli-tools.json.
|
||||
#
|
||||
# A skill adds a tool by appending a { "name", "version" } entry to that
|
||||
# manifest (a json-merge) instead of editing the Dockerfile — the reach-in
|
||||
# becomes the safest change shape, deterministic and removable.
|
||||
#
|
||||
# Every tool is installed via `pnpm install -g`, pinned to an exact version, so
|
||||
# the pnpm supply-chain policy still applies. Tools with a native postinstall
|
||||
# set "onlyBuilt": true to opt in to running build scripts (pnpm skips them by
|
||||
# default). Run as root before `USER node`, so /root/.npmrc is the right home.
|
||||
set -eu
|
||||
|
||||
MANIFEST="${1:-/tmp/cli-tools.json}"
|
||||
|
||||
# Write the per-tool only-built-dependencies opt-ins pnpm reads at install time.
|
||||
node -e '
|
||||
const tools = require(process.argv[1]);
|
||||
const optIns = tools.filter((t) => t.onlyBuilt).map((t) => "only-built-dependencies[]=" + t.name);
|
||||
require("fs").writeFileSync("/root/.npmrc", optIns.join("\n") + (optIns.length ? "\n" : ""));
|
||||
' "$MANIFEST"
|
||||
|
||||
# Install every tool, pinned. name@version specs never contain spaces, so the
|
||||
# unquoted expansion word-splits cleanly into positional args.
|
||||
# shellcheck disable=SC2046
|
||||
set -- $(node -e 'require(process.argv[1]).forEach((t) => console.log(t.name + "@" + t.version))' "$MANIFEST")
|
||||
if [ "$#" -gt 0 ]; then
|
||||
pnpm install -g "$@"
|
||||
fi
|
||||
@@ -310,6 +310,7 @@ CREATE TABLE container_configs (
|
||||
image_tag TEXT,
|
||||
assistant_name TEXT,
|
||||
max_messages_per_prompt INTEGER,
|
||||
idle_timeout_ms INTEGER, -- idle-exit window (ms); NULL/0 = disabled
|
||||
skills TEXT NOT NULL DEFAULT '"all"',
|
||||
mcp_servers TEXT NOT NULL DEFAULT '{}',
|
||||
packages_apt TEXT NOT NULL DEFAULT '[]',
|
||||
@@ -344,6 +345,7 @@ Migrations live in `src/db/migrations/`, one file per migration. Runner: `runMig
|
||||
| 009 | `009-drop-pending-credentials.ts` | Drop the defunct `pending_credentials` table |
|
||||
| 014 | `014-container-configs.ts` | `container_configs` — per-agent-group container runtime config |
|
||||
| 015 | `015-cli-scope.ts` | `ALTER TABLE container_configs ADD COLUMN cli_scope` |
|
||||
| 016 | `016-container-idle-timeout.ts` | `ALTER TABLE container_configs ADD COLUMN idle_timeout_ms` |
|
||||
|
||||
Numbers 005 and 006 are intentionally absent — migrations were renumbered during early development.
|
||||
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
# Upgrading the OneCLI gateway
|
||||
|
||||
NanoClaw talks to the OneCLI gateway (credential vault + egress proxy) through `@onecli-sh/sdk`. The gateway is an external component with its own release line, so NanoClaw pins the **sanctioned gateway version** in [`versions.json`](../versions.json) under `onecli-gateway`. When an update moves that pin, the gateway must be upgraded — this doc is the migration path. It is written to be handed to a coding agent verbatim: detect → upgrade → verify → rollback.
|
||||
|
||||
There is deliberately **no runtime version check, and setup does not migrate the gateway for you**: the gateway is a separate out-of-band component, and the migrator is your coding agent running `/update-nanoclaw` — it diffs `versions.json` across the update and routes you here when the `onecli-gateway` pin moved. (Setup detects a pre-`/v1` gateway and points at this doc, but never upgrades it.) Run the steps below verbatim.
|
||||
|
||||
## 1. Detect
|
||||
|
||||
Find out what is running and what is required:
|
||||
|
||||
```bash
|
||||
cat versions.json # the sanctioned pin
|
||||
curl -s http://127.0.0.1:10254/api/health # reports the running gateway version
|
||||
curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:10254/v1/health
|
||||
```
|
||||
|
||||
If the last command prints `404`, the server predates the `/v1` API that `@onecli-sh/sdk` 2.x requires — every SDK call will fail with 404s that look transient but are permanent. If your gateway is remote, substitute its host for `127.0.0.1` (it's in `.env` as `ONECLI_URL` / `NANOCLAW_ONECLI_API_HOST`).
|
||||
|
||||
Why gateways fall behind: the OneCLI installer's docker-compose tracks the `latest` image tag, but Docker never re-pulls a tag — the server freezes at whatever `latest` meant on install day.
|
||||
|
||||
## 2. Upgrade
|
||||
|
||||
The gateway runs as a Docker service in `~/.onecli`. Upgrade just that container to the pinned `onecli-gateway` version — vault data lives in named Docker volumes and survives. This upgrades only the gateway; the CLI binary is pinned separately (see below).
|
||||
|
||||
**Local gateway (the common case):**
|
||||
|
||||
```bash
|
||||
cd ~/.onecli && ONECLI_VERSION=<onecli-gateway pin from versions.json> docker compose pull onecli && docker compose up -d
|
||||
```
|
||||
|
||||
**Remote gateway** — run the same command on the gateway's host (NanoClaw can't reach it over SSH).
|
||||
|
||||
## 3. Verify
|
||||
|
||||
Host-side health is necessary but **not sufficient**:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:10254/v1/health # must return {"status":"ok",...}
|
||||
```
|
||||
|
||||
**Verify the bind interface (container reachability).** Agent containers reach the gateway over the docker bridge (`host.docker.internal` → e.g. `172.17.0.1`), so a server bound only to `127.0.0.1` boots clean host-side while every credentialed call from containers dies at the proxy:
|
||||
|
||||
```bash
|
||||
docker run --rm --add-host=host.docker.internal:host-gateway \
|
||||
curlimages/curl -s -o /dev/null -w '%{http_code}' http://host.docker.internal:10254/v1/health
|
||||
```
|
||||
|
||||
This must print `200`. If it can't connect while the host-side check passed, set the bind address in `~/.onecli/.env` to the docker-bridge IP (or `0.0.0.0` on a host with a closed firewall) and `cd ~/.onecli && docker compose up -d`. Symptom if skipped: host log clean, agents fail all API calls.
|
||||
|
||||
Finally, restart the NanoClaw service (per-install names — derive with `setup/lib/install-slug.sh`):
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
source setup/lib/install-slug.sh && launchctl kickstart -k gui/$(id -u)/$(launchd_label)
|
||||
# Linux
|
||||
source setup/lib/install-slug.sh && systemctl --user restart $(systemd_unit)
|
||||
```
|
||||
|
||||
## 4. Rollback
|
||||
|
||||
```bash
|
||||
cd ~/.onecli && ONECLI_VERSION=<old-version> docker compose up -d
|
||||
```
|
||||
|
||||
If the NanoClaw update itself is being rolled back, also pin `@onecli-sh/sdk` back to its previous version in `package.json` and run `pnpm install`. Vault data is unaffected in both directions.
|
||||
|
||||
## The CLI binary (`onecli-cli` pin)
|
||||
|
||||
The `onecli` host CLI is pinned the same way, under `onecli-cli` in `versions.json`. Setup installs exactly that version by direct release download — it never resolves "latest". When an update moves this pin, replace the binary with the pinned release:
|
||||
|
||||
```bash
|
||||
onecli --version # detect: what is installed
|
||||
V=<onecli-cli pin from versions.json>
|
||||
OS=$(uname -s | tr '[:upper:]' '[:lower:]') # darwin | linux
|
||||
ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') # amd64 | arm64
|
||||
curl -fsSL -o /tmp/onecli.tgz \
|
||||
"https://github.com/onecli/onecli-cli/releases/download/v${V}/onecli_${V}_${OS}_${ARCH}.tar.gz"
|
||||
tar -xzf /tmp/onecli.tgz -C /tmp
|
||||
install -m 0755 /tmp/onecli "$(command -v onecli || echo ~/.local/bin/onecli)"
|
||||
onecli --version # verify: must match versions.json
|
||||
```
|
||||
|
||||
To roll back, run the same block after reverting `versions.json` (or checking out the previous NanoClaw version). The CLI is stateless — vault data lives in the gateway, so swapping the binary in either direction loses nothing.
|
||||
@@ -1,44 +0,0 @@
|
||||
# Switching an agent group between providers
|
||||
|
||||
How an **operator** moves a live agent group from one agent provider to another (e.g. Claude → Codex) and back. Switching is an operator action: it runs from the host via `ncl groups config update --provider` + restart.
|
||||
|
||||
NanoClaw's runtime does not migrate anything when you switch. Provider-neutral state simply stays where it is; provider-specific state (memory, in-flight context) stays with its provider, and carrying memory across is a separate, explicit operator step (`/migrate-memory`, executed by your coding agent).
|
||||
|
||||
## Preconditions
|
||||
|
||||
1. **The target provider is installed** — run its `/add-<provider>` skill and rebuild the container image (`./container/build.sh`). If the provider isn't installed (or the name is a typo), the container fails at boot and the host surfaces its last words in the logs: look for `Container exited non-zero` with a `stderrTail` like `Unknown provider: codexx. Registered: claude, codex`.
|
||||
2. **Auth is configured** — each provider documents its own auth in its install skill (for Codex: a ChatGPT-subscription or API-key secret in the OneCLI vault).
|
||||
|
||||
## Switching
|
||||
|
||||
```bash
|
||||
ncl groups config update --id <group-id> --provider codex
|
||||
ncl groups restart --id <group-id>
|
||||
```
|
||||
|
||||
Sessions resolve their provider at container spawn (`sessions.agent_provider` is only set when you've explicitly pinned a session), so existing sessions pick up the new provider on their next wake.
|
||||
|
||||
## What carries over automatically
|
||||
|
||||
| State | How |
|
||||
|-------|-----|
|
||||
| Group identity, wiring, members, roles, destinations | Provider-neutral, in the central DB — untouched |
|
||||
| Container config (model aside), skills, MCP servers, packages, mounts, cli_scope | Provider-neutral — untouched |
|
||||
| Workspace files (`groups/<folder>/` — notes, data files the agent created) | Same workspace, mounted for every provider |
|
||||
| Conversation archives (`conversations/`) | Provider-neutral markdown — readable by the new provider |
|
||||
| Agent surfaces (system instructions / project docs) | Composed fresh at every spawn from the same sources — nothing to migrate |
|
||||
|
||||
## What does NOT carry over
|
||||
|
||||
- **Agent memory.** Each provider keeps its own store: Claude's per-group memory is `CLAUDE.local.md` in the workspace; scaffold providers (e.g. Codex) keep a `memory/` tree. Neither is touched by a switch — the old store sits intact, the new provider starts with its own. To carry memory across, run **`/migrate-memory`**: your coding agent reads the source store, distills it into the target store (copy, never move), and restarts the group. Both directions work.
|
||||
- **In-flight conversation context.** Continuations are provider-specific (a Claude SDK session, a Codex thread) and stored in separate per-provider slots — the new provider starts a fresh thread. The old slot is kept, not deleted. Recent context is recoverable from `conversations/` archives.
|
||||
- **Provider state dirs** (`.claude-shared/`, `.codex-shared/`). Each provider keeps its own; they sit idle while unused and are reused if you switch back.
|
||||
|
||||
## Rolling back
|
||||
|
||||
```bash
|
||||
ncl groups config update --id <group-id> --provider claude
|
||||
ncl groups restart --id <group-id>
|
||||
```
|
||||
|
||||
Rollback is lossless by construction: the per-provider continuation slot means Claude resumes its previous session (subject to normal transcript-rotation age limits), and `CLAUDE.local.md` was never modified by the switch. Memory written **while on the other provider** lives in that provider's store — run `/migrate-memory` again if you want it carried back.
|
||||
+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/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/add-telegram.sh` | Non-interactive adapter installer. Reads `TELEGRAM_BOT_TOKEN` from env; never prompts. User-facing bits live in `auto.ts`. |
|
||||
| `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
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.1.16",
|
||||
"version": "2.1.10",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
@@ -30,7 +30,7 @@
|
||||
"dependencies": {
|
||||
"@clack/core": "^1.2.0",
|
||||
"@clack/prompts": "^1.2.0",
|
||||
"@onecli-sh/sdk": "2.2.1",
|
||||
"@onecli-sh/sdk": "^0.5.0",
|
||||
"better-sqlite3": "11.10.0",
|
||||
"chat": "^4.24.0",
|
||||
"cron-parser": "5.5.0",
|
||||
|
||||
Generated
+5
-5
@@ -15,8 +15,8 @@ importers:
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0
|
||||
'@onecli-sh/sdk':
|
||||
specifier: 2.2.1
|
||||
version: 2.2.1
|
||||
specifier: ^0.5.0
|
||||
version: 0.5.0
|
||||
better-sqlite3:
|
||||
specifier: 11.10.0
|
||||
version: 11.10.0
|
||||
@@ -303,8 +303,8 @@ packages:
|
||||
'@emnapi/core': ^1.7.1
|
||||
'@emnapi/runtime': ^1.7.1
|
||||
|
||||
'@onecli-sh/sdk@2.2.1':
|
||||
resolution: {integrity: sha512-q2mCW4ZsARlLEoTxz/P0NQ4MiCh7Z2n28pxkSc7srS+tozyw40PdTnWYW7NI8hfSYplZTx5856Adq1iPi4KN3Q==}
|
||||
'@onecli-sh/sdk@0.5.0':
|
||||
resolution: {integrity: sha512-oe5Yx9o98v6N1PgzcCR7nULHHqcqKWNJIDOHGOSNX+l20mLlZpFUqfKPeFmsojBNRQMoqbvZQKUlFMp6gVuYBA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@oxc-project/types@0.124.0':
|
||||
@@ -1665,7 +1665,7 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.10.1
|
||||
optional: true
|
||||
|
||||
'@onecli-sh/sdk@2.2.1': {}
|
||||
'@onecli-sh/sdk@0.5.0': {}
|
||||
|
||||
'@oxc-project/types@0.124.0': {}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="195k tokens, 98% of context window">
|
||||
<title>195k tokens, 98% of context window</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="190k tokens, 95% of context window">
|
||||
<title>190k tokens, 95% of context window</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,8 +15,8 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||
<text x="26" y="14">tokens</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">195k</text>
|
||||
<text x="71" y="14">195k</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">190k</text>
|
||||
<text x="71" y="14">190k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -21,7 +21,6 @@ import path from 'path';
|
||||
|
||||
import { DATA_DIR } from '../src/config.js';
|
||||
import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js';
|
||||
import { updateContainerConfigScalars } from '../src/db/container-configs.js';
|
||||
import { initDb } from '../src/db/connection.js';
|
||||
import {
|
||||
createMessagingGroup,
|
||||
@@ -103,7 +102,6 @@ async function main(): Promise<void> {
|
||||
|
||||
// 2. Agent group + filesystem.
|
||||
const folder = args.folder || `cli-with-${normalizeName(args.displayName)}`;
|
||||
const pickedProvider = process.env.NANOCLAW_PICKED_PROVIDER?.trim().toLowerCase();
|
||||
let ag: AgentGroup | undefined = getAgentGroupByFolder(folder);
|
||||
if (!ag) {
|
||||
const agId = generateId('ag');
|
||||
@@ -125,10 +123,6 @@ async function main(): Promise<void> {
|
||||
`You are ${args.agentName}, a personal NanoClaw agent for ${args.displayName}. ` +
|
||||
'When the user first reaches out, introduce yourself briefly and invite them to chat. Keep replies concise.',
|
||||
});
|
||||
// Runtime provider lives on the config row, not the deprecated agent_provider.
|
||||
if (pickedProvider && pickedProvider !== 'claude') {
|
||||
updateContainerConfigScalars(ag.id, { provider: pickedProvider });
|
||||
}
|
||||
|
||||
// 3. CLI messaging group + wiring.
|
||||
let cliMg: MessagingGroup | undefined = getMessagingGroupByPlatform(CLI_CHANNEL, CLI_PLATFORM_ID);
|
||||
|
||||
@@ -30,11 +30,10 @@
|
||||
* For direct-addressable channels (telegram, whatsapp, etc.), --platform-id
|
||||
* is typically the same as the handle in --user-id, with the channel prefix.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import net from 'net';
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR, GROUPS_DIR } from '../src/config.js';
|
||||
import { DATA_DIR } from '../src/config.js';
|
||||
import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js';
|
||||
import { initDb } from '../src/db/connection.js';
|
||||
import {
|
||||
@@ -48,7 +47,8 @@ import { normalizeName } from '../src/modules/agent-to-agent/db/agent-destinatio
|
||||
import { addMember } from '../src/modules/permissions/db/agent-group-members.js';
|
||||
import { getUserRoles, grantRole } from '../src/modules/permissions/db/user-roles.js';
|
||||
import { upsertUser } from '../src/modules/permissions/db/users.js';
|
||||
import { ensureContainerConfig, updateContainerConfigScalars } from '../src/db/container-configs.js';
|
||||
import { updateContainerConfigScalars } from '../src/db/container-configs.js';
|
||||
import { initGroupFilesystem } from '../src/group-init.js';
|
||||
import { namespacedPlatformId } from '../src/platform-id.js';
|
||||
import type { AgentGroup, MessagingGroup } from '../src/types.js';
|
||||
|
||||
@@ -189,7 +189,6 @@ async function main(): Promise<void> {
|
||||
|
||||
// 2. Agent group + filesystem.
|
||||
const folder = `dm-with-${normalizeName(args.displayName)}`;
|
||||
const pickedProvider = process.env.NANOCLAW_PICKED_PROVIDER?.trim().toLowerCase();
|
||||
let ag: AgentGroup | undefined = getAgentGroupByFolder(folder);
|
||||
if (!ag) {
|
||||
const agId = generateId('ag');
|
||||
@@ -205,23 +204,12 @@ async function main(): Promise<void> {
|
||||
} else {
|
||||
console.log(`Reusing agent group: ${ag.id} (${folder})`);
|
||||
}
|
||||
// Ensure the config row exists; defer workspace scaffolding to the first
|
||||
// spawn (group-init), where the DB-resolved provider decides the surface
|
||||
// (Claude: CLAUDE.local.md; a surfaces-owning provider: the memory scaffold)
|
||||
// — so a non-Claude group never gets stale CLAUDE.* files written here.
|
||||
ensureContainerConfig(ag.id);
|
||||
// Runtime provider lives on the config row, not the deprecated agent_provider.
|
||||
if (pickedProvider && pickedProvider !== 'claude') {
|
||||
updateContainerConfigScalars(ag.id, { provider: pickedProvider });
|
||||
}
|
||||
const groupDir = path.resolve(GROUPS_DIR, folder);
|
||||
fs.mkdirSync(groupDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(groupDir, '.seed.md'),
|
||||
`# ${args.agentName}\n\n` +
|
||||
initGroupFilesystem(ag, {
|
||||
instructions:
|
||||
`# ${args.agentName}\n\n` +
|
||||
`You are ${args.agentName}, a personal NanoClaw agent for ${args.displayName}. ` +
|
||||
'When the user first reaches out (or you receive a system welcome prompt), introduce yourself briefly and invite them to chat. Keep replies concise.\n',
|
||||
);
|
||||
'When the user first reaches out (or you receive a system welcome prompt), introduce yourself briefly and invite them to chat. Keep replies concise.',
|
||||
});
|
||||
|
||||
// 2b. Assign the user a role for this agent group. The caller picks via
|
||||
// --role; the channel drivers default to 'owner' for the self-host case.
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
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
|
||||
});
|
||||
});
|
||||
@@ -1,407 +0,0 @@
|
||||
// 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}`);
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -1,206 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
Executable
+130
@@ -0,0 +1,130 @@
|
||||
#!/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
|
||||
Executable
+160
@@ -0,0 +1,160 @@
|
||||
#!/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
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/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
|
||||
Executable
+139
@@ -0,0 +1,139 @@
|
||||
#!/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
|
||||
Executable
+164
@@ -0,0 +1,164 @@
|
||||
#!/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
|
||||
+2
-97
@@ -38,13 +38,8 @@ import { runTeamsChannel } from './channels/teams.js';
|
||||
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';
|
||||
import { offerClaudeOnFailure } from './lib/claude-handoff.js';
|
||||
import { setPickedProvider } from './lib/picked-provider.js';
|
||||
import {
|
||||
applyToEnv,
|
||||
parseFlags,
|
||||
@@ -326,64 +321,8 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
let agentProvider: string | undefined;
|
||||
if (!skip.has('auth')) {
|
||||
// Agent runtime pick. Claude is the default and a no-op — choosing it
|
||||
// runs the existing Claude auth flow unchanged. A branch provider walks
|
||||
// its own auth (e.g. Codex: ChatGPT subscription or API key, vault-only)
|
||||
// and verifies its payload is wired. The pick installs and authenticates
|
||||
// the runtime; it is NOT an install-wide default — and it is NOT a
|
||||
// creation flag. Provider is a DB property of a group: the creation flows
|
||||
// create provider-agnostic groups, and setup sets the picked provider on
|
||||
// each via `ncl groups config update --provider` right after creating it
|
||||
// (the creation scripts inherit it and apply at create — see picked-provider). Existing groups switch the
|
||||
// same way (docs/provider-migration.md).
|
||||
agentProvider = await askAgentProviderChoice();
|
||||
setPickedProvider(agentProvider);
|
||||
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 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}.`,
|
||||
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`);
|
||||
providerEntry = getSetupProvider(agentProvider);
|
||||
}
|
||||
if (providerEntry?.runAuth) {
|
||||
await providerEntry.runAuth();
|
||||
await providerEntry.runInstallCheck?.();
|
||||
} else {
|
||||
await runAuthStep();
|
||||
}
|
||||
await runAuthStep();
|
||||
}
|
||||
|
||||
if (!skip.has('mounts')) {
|
||||
@@ -809,40 +748,6 @@ function sendChatMessage(message: string): Promise<void> {
|
||||
|
||||
// ─── auth step (select → branch) ────────────────────────────────────────
|
||||
|
||||
// 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 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;
|
||||
|
||||
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 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 })),
|
||||
...available.map((prov) => ({ value: prov.value, label: prov.label, hint: `${prov.hint} — installs now` })),
|
||||
];
|
||||
// The pick installs and authenticates a runtime — it is not an
|
||||
// install-wide default, so re-runs safely Enter-through on claude (its
|
||||
// auth flow short-circuits when the secret already exists).
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect<string>({
|
||||
message: 'Which agent runtime should power your assistant?',
|
||||
options,
|
||||
initialValue: 'claude',
|
||||
}),
|
||||
) as string;
|
||||
setupLog.userInput('agent_provider', choice);
|
||||
phEmit('agent_provider_chosen', { provider: choice });
|
||||
return choice;
|
||||
}
|
||||
|
||||
async function runAuthStep(): Promise<void> {
|
||||
if (anthropicSecretExists()) {
|
||||
p.log.success(brandBody('Your Claude account is already connected.'));
|
||||
@@ -1356,7 +1261,7 @@ function detectExistingOnecli(): { version: string; apiHost: string } | null {
|
||||
} catch {
|
||||
// not JSON — try to extract a URL directly
|
||||
}
|
||||
const m = raw.match(/https?:\/\/[\w.-]+(?::\d+)?/);
|
||||
const m = raw.match(/https?:\/\/[\w.\-]+(?::\d+)?/);
|
||||
return m ? { version, apiHost: m[0] } : null;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
+22
-97
@@ -13,8 +13,7 @@
|
||||
* 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. Apply the /add-discord skill via the directive engine (the skill's
|
||||
* SKILL.md is the single source of truth) + restart the service
|
||||
* 7. Install the adapter via setup/add-discord.sh (non-interactive)
|
||||
* 8. POST /users/@me/channels to open the DM channel (yields dm channel id)
|
||||
* 9. Ask for the messaging-agent name (defaulting to "Nano")
|
||||
* 10. Wire the agent via scripts/init-first-agent.ts, which sends the welcome
|
||||
@@ -24,12 +23,9 @@
|
||||
* 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';
|
||||
@@ -80,12 +76,31 @@ export async function runDiscordChannel(displayName: string): Promise<ChannelFlo
|
||||
|
||||
await promptInviteBot(app.applicationId, botUsername);
|
||||
|
||||
const install = await applyDiscordSkill(token, app, botUsername);
|
||||
const install = await runQuietChild(
|
||||
'discord-install',
|
||||
'bash',
|
||||
['setup/add-discord.sh'],
|
||||
{
|
||||
running: `Connecting Discord to @${botUsername}…`,
|
||||
done: 'Discord connected.',
|
||||
},
|
||||
{
|
||||
env: {
|
||||
DISCORD_BOT_TOKEN: token,
|
||||
DISCORD_APPLICATION_ID: app.applicationId,
|
||||
DISCORD_PUBLIC_KEY: app.publicKey,
|
||||
},
|
||||
extraFields: {
|
||||
BOT_USERNAME: botUsername,
|
||||
APPLICATION_ID: app.applicationId,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
await fail(
|
||||
'discord-install',
|
||||
"Couldn't connect Discord.",
|
||||
install.detail || 'See logs/setup-steps/ for details, then retry setup.',
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -130,96 +145,6 @@ 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({
|
||||
|
||||
+26
-131
@@ -19,29 +19,26 @@
|
||||
* 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 by applying the /add-imessage skill in-process
|
||||
* (SKILL.md is the single source of truth) + restart the service
|
||||
* 4. Install the adapter (setup/add-imessage.sh, non-interactive)
|
||||
* 5. Wire the agent via scripts/init-first-agent.ts — the welcome
|
||||
* iMessage goes out through the normal delivery path
|
||||
*
|
||||
* All output obeys the three-level contract. See docs/setup-flow.md.
|
||||
*/
|
||||
import { execSync } from 'child_process';
|
||||
import 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, upsertEnvKey } from '../environment.js';
|
||||
import { readEnvKey } from '../environment.js';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
|
||||
@@ -74,12 +71,34 @@ export async function runIMessageChannel(displayName: string): Promise<ChannelFl
|
||||
|
||||
const handle = await askOperatorHandle();
|
||||
|
||||
const install = await applyIMessageSkill(mode, remoteCreds);
|
||||
const install = await runQuietChild(
|
||||
'imessage-install',
|
||||
'bash',
|
||||
['setup/add-imessage.sh'],
|
||||
{
|
||||
running:
|
||||
mode === 'local'
|
||||
? "Connecting the iMessage adapter to this Mac…"
|
||||
: `Connecting the iMessage adapter to ${remoteCreds!.serverUrl}…`,
|
||||
done: 'iMessage adapter installed.',
|
||||
},
|
||||
{
|
||||
env:
|
||||
mode === 'local'
|
||||
? { IMESSAGE_LOCAL: 'true', IMESSAGE_ENABLED: 'true' }
|
||||
: {
|
||||
IMESSAGE_LOCAL: 'false',
|
||||
IMESSAGE_SERVER_URL: remoteCreds!.serverUrl,
|
||||
IMESSAGE_API_KEY: remoteCreds!.apiKey,
|
||||
},
|
||||
extraFields: { MODE: mode },
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
await fail(
|
||||
'imessage-install',
|
||||
"Couldn't install the iMessage adapter.",
|
||||
install.detail || 'See logs/setup-steps/ for details, then retry setup.',
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,130 +141,6 @@ 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
|
||||
? [
|
||||
|
||||
+22
-95
@@ -8,8 +8,7 @@
|
||||
* 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. Apply the /add-slack skill via the directive engine (the skill's
|
||||
* SKILL.md is the single source of truth) + restart the service
|
||||
* 4. Install the adapter (setup/add-slack.sh, non-interactive)
|
||||
* 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")
|
||||
@@ -22,12 +21,9 @@
|
||||
*
|
||||
* 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';
|
||||
@@ -57,12 +53,31 @@ export async function runSlackChannel(displayName: string): Promise<ChannelFlowR
|
||||
const signingSecret = await collectSigningSecret();
|
||||
const info = await validateSlackToken(token);
|
||||
|
||||
const install = await applySlackSkill(token, signingSecret, info);
|
||||
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,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
await fail(
|
||||
'slack-install',
|
||||
"Couldn't connect Slack.",
|
||||
install.detail || 'See logs/setup-steps/ for details, then retry setup.',
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -110,94 +125,6 @@ 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.
|
||||
|
||||
+25
-92
@@ -24,14 +24,12 @@
|
||||
* 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';
|
||||
@@ -41,7 +39,7 @@ import {
|
||||
validateWithHelpEscape,
|
||||
type HandoffContext,
|
||||
} from '../lib/claude-handoff.js';
|
||||
import { ensureAnswer, fail } from '../lib/runner.js';
|
||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||
import { buildTeamsAppPackage } from '../lib/teams-manifest.js';
|
||||
import { note } from '../lib/theme.js';
|
||||
import * as setupLog from '../logs.js';
|
||||
@@ -541,82 +539,33 @@ 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 projectRoot = process.cwd();
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
s.start('Installing the Teams adapter and restarting the service…');
|
||||
|
||||
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;
|
||||
},
|
||||
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!;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await applySkill('.claude/skills/add-teams', projectRoot, {
|
||||
prompter,
|
||||
exec: (cmd) => {
|
||||
execSync(cmd, { cwd: projectRoot, stdio: 'pipe' });
|
||||
const install = await runQuietChild(
|
||||
'teams-install',
|
||||
'bash',
|
||||
['setup/add-teams.sh'],
|
||||
{
|
||||
running: 'Installing the Teams adapter and restarting the service…',
|
||||
done: 'Teams adapter installed.',
|
||||
},
|
||||
{
|
||||
env,
|
||||
extraFields: {
|
||||
APP_ID: collected.appId!,
|
||||
APP_TYPE: collected.appType!,
|
||||
},
|
||||
// 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 });
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
fail(
|
||||
'teams-install',
|
||||
"Couldn't install the Teams adapter.",
|
||||
@@ -625,22 +574,6 @@ 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(
|
||||
@@ -755,7 +688,7 @@ async function offerHandoff(args: {
|
||||
stepDescription: args.stepDescription,
|
||||
completedSteps: args.args.completed.slice(),
|
||||
collectedValues: redactCollected(args.args.collected),
|
||||
files: ['setup/channels/teams.ts', '.claude/skills/add-teams/SKILL.md'],
|
||||
files: ['setup/channels/teams.ts', 'setup/add-teams.sh'],
|
||||
};
|
||||
await offerClaudeHandoff(ctx);
|
||||
}
|
||||
|
||||
+15
-98
@@ -8,8 +8,7 @@
|
||||
* 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 by applying the /add-telegram skill in-process
|
||||
* (directive engine; the skill's SKILL.md is the single source of truth)
|
||||
* 5. Install the adapter (setup/add-telegram.sh, non-interactive)
|
||||
* 6. Run the pair-telegram step, rendering code events as clack notes
|
||||
* 7. Ask for the messaging-agent name (defaulting to "Nano")
|
||||
* 8. Wire the agent via scripts/init-first-agent.ts
|
||||
@@ -18,12 +17,9 @@
|
||||
* 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';
|
||||
@@ -89,12 +85,24 @@ export async function runTelegramChannel(displayName: string): Promise<ChannelFl
|
||||
openUrl(botUrl);
|
||||
}
|
||||
|
||||
const install = await applyTelegramSkill(token, botUsername);
|
||||
const install = await runQuietChild(
|
||||
'telegram-install',
|
||||
'bash',
|
||||
['setup/add-telegram.sh'],
|
||||
{
|
||||
running: `Connecting Telegram to @${botUsername}…`,
|
||||
done: 'Telegram connected.',
|
||||
},
|
||||
{
|
||||
env: { TELEGRAM_BOT_TOKEN: token },
|
||||
extraFields: { BOT_USERNAME: botUsername },
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
await fail(
|
||||
'telegram-install',
|
||||
"Couldn't connect Telegram.",
|
||||
install.detail || 'See logs/setup-steps/ for details, then retry setup.',
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,97 +159,6 @@ 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)) {
|
||||
|
||||
+1
-8
@@ -68,12 +68,8 @@ export async function run(args: string[]): Promise<void> {
|
||||
|
||||
log.info('Invoking init-cli-agent', { displayName, agentName });
|
||||
|
||||
// Provider-agnostic: init-cli-agent creates a default group and emits its id.
|
||||
// Surface that id so the orchestrator can set the picked provider on it (via
|
||||
// ncl) before the ping — provider is a DB property, never a creation flag.
|
||||
let stdout = '';
|
||||
try {
|
||||
stdout = execFileSync('pnpm', scriptArgs, {
|
||||
execFileSync('pnpm', scriptArgs, {
|
||||
cwd: projectRoot,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
encoding: 'utf-8',
|
||||
@@ -94,13 +90,10 @@ export async function run(args: string[]): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const agentGroupId = stdout.match(/^AGENT_GROUP_ID:\s*(\S+)/m)?.[1];
|
||||
|
||||
emitStatus('CLI_AGENT', {
|
||||
DISPLAY_NAME: displayName,
|
||||
AGENT_NAME: agentName || displayName,
|
||||
CHANNEL: 'cli/local',
|
||||
...(agentGroupId ? { AGENT_GROUP_ID: agentGroupId } : {}),
|
||||
STATUS: 'success',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
|
||||
@@ -35,29 +35,6 @@ export function readEnvKey(key: string, projectRoot?: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set (or replace) a single `KEY=value` line in `.env`, creating the file if
|
||||
* needed. Non-secret config only — secrets belong in the OneCLI vault.
|
||||
*/
|
||||
export function upsertEnvKey(key: string, value: string, projectRoot?: string): void {
|
||||
const envPath = path.join(projectRoot ?? process.cwd(), '.env');
|
||||
let content = '';
|
||||
try {
|
||||
content = fs.readFileSync(envPath, 'utf-8');
|
||||
} catch {
|
||||
/* no .env yet */
|
||||
}
|
||||
const line = `${key}=${value}`;
|
||||
const lines = content.split('\n');
|
||||
const idx = lines.findIndex((l) => l.trim().startsWith(`${key}=`));
|
||||
if (idx >= 0) lines[idx] = line;
|
||||
else {
|
||||
while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop();
|
||||
lines.push(line);
|
||||
}
|
||||
fs.writeFileSync(envPath, lines.join('\n') + '\n');
|
||||
}
|
||||
|
||||
export function detectExistingDisplayName(projectRoot: string): string | null {
|
||||
const dbPath = path.join(projectRoot, 'data', 'v2.db');
|
||||
if (!fs.existsSync(dbPath)) return null;
|
||||
|
||||
@@ -23,10 +23,7 @@ const STEPS: Record<
|
||||
verify: () => import('./verify.js'),
|
||||
onecli: () => import('./onecli.js'),
|
||||
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> {
|
||||
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/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 ==="
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/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 ==="
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/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 ==="
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/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 ==="
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/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 ==="
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/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 ==="
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/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 ==="
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/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 ==="
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/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 ==="
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/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 ==="
|
||||
@@ -66,43 +66,17 @@ export interface BrightSelectOptions<T> {
|
||||
initialValue?: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard any stdin buffered while no prompt was reading — keypresses made
|
||||
* during spinners and installs otherwise get consumed by the next select the
|
||||
* instant it opens, submitting it before it ever renders for the user (a
|
||||
* stray `↓`+`Enter` silently picks option 2). Raw-mode reads only see kernel
|
||||
* tty data via the event loop, so the drain needs a real (short) window.
|
||||
*/
|
||||
export function flushStdin(windowMs = 50): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const stdin = process.stdin;
|
||||
if (!stdin.isTTY) return resolve();
|
||||
const wasRaw = stdin.isRaw === true;
|
||||
stdin.setRawMode?.(true);
|
||||
const discard = (): void => {};
|
||||
stdin.on('data', discard);
|
||||
stdin.resume();
|
||||
setTimeout(() => {
|
||||
stdin.off('data', discard);
|
||||
stdin.pause();
|
||||
if (!wasRaw) stdin.setRawMode?.(false);
|
||||
resolve();
|
||||
}, windowMs);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches the return shape of `p.select` — resolves to the selected value
|
||||
* on submit, or to clack's cancel symbol on Ctrl-C / Esc. Callers pass
|
||||
* the result through `ensureAnswer(...)` the same way they do for
|
||||
* `p.select`.
|
||||
*/
|
||||
export async function brightSelect<T>(
|
||||
export function brightSelect<T>(
|
||||
opts: BrightSelectOptions<T>,
|
||||
): Promise<T | symbol> {
|
||||
const { message, options, initialValue } = opts;
|
||||
|
||||
await flushStdin();
|
||||
return new SelectPrompt({
|
||||
options: options as Array<{ value: T; label?: string; hint?: string }>,
|
||||
initialValue,
|
||||
|
||||
@@ -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': ['.claude/skills/add-telegram/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/telegram.ts'],
|
||||
'telegram-install': ['setup/add-telegram.sh', 'setup/channels/telegram.ts'],
|
||||
'telegram-validate': ['setup/channels/telegram.ts'],
|
||||
'pair-telegram': ['setup/pair-telegram.ts', 'setup/channels/telegram.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'],
|
||||
'discord-install': ['setup/add-discord.sh', 'setup/channels/discord.ts'],
|
||||
'slack-install': ['setup/add-slack.sh', 'setup/channels/slack.ts'],
|
||||
'slack-validate': ['setup/channels/slack.ts'],
|
||||
'imessage-install': ['.claude/skills/add-imessage/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/imessage.ts'],
|
||||
'imessage-install': ['setup/add-imessage.sh', 'setup/channels/imessage.ts'],
|
||||
'imessage': ['setup/channels/imessage.ts'],
|
||||
'teams-install': ['.claude/skills/add-teams/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/teams.ts'],
|
||||
'teams-install': ['setup/add-teams.sh', 'setup/channels/teams.ts'],
|
||||
'teams-manifest': ['setup/lib/teams-manifest.ts', 'setup/channels/teams.ts'],
|
||||
'init-first-agent': [
|
||||
'scripts/init-first-agent.ts',
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* The agent runtime the operator picked in THIS setup run.
|
||||
*
|
||||
* There is no install-wide default provider and no `--provider` in the
|
||||
* creation contract — provider is a DB property of a group. Setup is the one
|
||||
* orchestrator that knows the operator's pick, so it stashes it here (set once
|
||||
* at the auth step). The group-creation scripts (`init-first-agent`,
|
||||
* `init-cli-agent`) run as **child processes**, so the pick is carried over the
|
||||
* process boundary via an environment variable they inherit; they apply it to
|
||||
* the group at creation, before the welcome wakes the container. This is the
|
||||
* only place the value lives — a setup-run-scoped global, NOT a persisted
|
||||
* install default. `undefined` / `'claude'` means the built-in default and no
|
||||
* provider write at all.
|
||||
*/
|
||||
const ENV_KEY = 'NANOCLAW_PICKED_PROVIDER';
|
||||
|
||||
export function setPickedProvider(provider: string | undefined): void {
|
||||
const normalized = provider?.trim().toLowerCase() || undefined;
|
||||
if (normalized && normalized !== 'claude') {
|
||||
process.env[ENV_KEY] = normalized;
|
||||
} else {
|
||||
delete process.env[ENV_KEY];
|
||||
}
|
||||
}
|
||||
|
||||
export function getPickedProvider(): string | undefined {
|
||||
return process.env[ENV_KEY]?.trim().toLowerCase() || undefined;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* versions.json is the machine-checkable source for sanctioned component
|
||||
* versions: setup steps read it, /update-nanoclaw diffs it across updates.
|
||||
* These tests go red if the file, the pin, or the onecli-step wiring is
|
||||
* deleted — the pin moving back to a hardcoded constant is the regression
|
||||
* this guards against.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { readVersionPin } from './version-pins.js';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
describe('readVersionPin', () => {
|
||||
it('resolves the onecli-gateway pin from the real versions.json', () => {
|
||||
expect(readVersionPin('onecli-gateway')).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
});
|
||||
|
||||
it('resolves the onecli-cli pin from the real versions.json', () => {
|
||||
expect(readVersionPin('onecli-cli')).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
});
|
||||
|
||||
it('throws for a component with no pin', () => {
|
||||
expect(() => readVersionPin('no-such-component')).toThrow(/no pin/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onecli step wiring', () => {
|
||||
it('reads its gateway pin from versions.json, not a hardcoded constant', () => {
|
||||
const source = fs.readFileSync(path.join(here, '..', 'onecli.ts'), 'utf-8');
|
||||
expect(source).toContain("readVersionPin('onecli-gateway')");
|
||||
expect(source).not.toMatch(/ONECLI_GATEWAY_VERSION = '\d/);
|
||||
});
|
||||
|
||||
it('reads its CLI pin from versions.json and never resolves "latest"', () => {
|
||||
const source = fs.readFileSync(path.join(here, '..', 'onecli.ts'), 'utf-8');
|
||||
expect(source).toContain("readVersionPin('onecli-cli')");
|
||||
expect(source).not.toMatch(/ONECLI_CLI(?:_FALLBACK)?_VERSION = '\d/);
|
||||
// The upstream installer and the /releases/latest redirect probe both
|
||||
// chase "latest" — reintroducing either bypasses the sanctioned pin.
|
||||
expect(source).not.toContain('onecli.sh/cli/install');
|
||||
expect(source).not.toContain('/releases/latest');
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* Sanctioned version pins for external components (`versions.json` at the
|
||||
* repo root) — the single machine-checkable source. Setup steps read their
|
||||
* pin here; `/update-nanoclaw` diffs the file across an update and routes
|
||||
* the user to the migration doc for any pin that moved (see CONTRIBUTING.md,
|
||||
* "Breaking changes").
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const VERSIONS_FILE = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'..',
|
||||
'versions.json',
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns the pinned version for a component, e.g.
|
||||
* `readVersionPin('onecli-gateway')`. Throws when the file or the pin is
|
||||
* missing — a missing pin is an install-tree defect, not a runtime condition.
|
||||
*/
|
||||
export function readVersionPin(component: string): string {
|
||||
const pins: unknown = JSON.parse(fs.readFileSync(VERSIONS_FILE, 'utf-8'));
|
||||
const value = (pins as Record<string, unknown>)[component];
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new Error(`versions.json has no pin for "${component}"`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* The step DETECTS gateway /v1 compatibility and warns (pointing at
|
||||
* docs/onecli-upgrades.md) — it does not migrate the gateway; that's the
|
||||
* agent's job via /update-nanoclaw. The verify helper must distinguish
|
||||
* incompatible (pre-/v1 server: warn) from unreachable (transient: nothing to
|
||||
* say) so the warning only fires on a real pre-/v1 server.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { verifyGatewayV1 } from './onecli.js';
|
||||
|
||||
function fakeFetch(behavior: 'ok' | '404' | 'down'): typeof fetch {
|
||||
return (async () => {
|
||||
if (behavior === 'down') throw new Error('ECONNREFUSED');
|
||||
return { ok: behavior === 'ok' } as Response;
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
describe('verifyGatewayV1', () => {
|
||||
it('ok when /v1/health answers', async () => {
|
||||
expect(await verifyGatewayV1('http://x', fakeFetch('ok'))).toBe('ok');
|
||||
});
|
||||
it('incompatible when the server answers HTTP without /v1', async () => {
|
||||
expect(await verifyGatewayV1('http://x', fakeFetch('404'))).toBe('incompatible');
|
||||
});
|
||||
it('unreachable on connection failure', async () => {
|
||||
expect(await verifyGatewayV1('http://x', fakeFetch('down'))).toBe('unreachable');
|
||||
});
|
||||
});
|
||||
+54
-61
@@ -17,7 +17,6 @@ import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { log } from '../src/log.js';
|
||||
import { readVersionPin } from './lib/version-pins.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
const LOCAL_BIN = path.join(os.homedir(), '.local', 'bin');
|
||||
@@ -103,18 +102,20 @@ function writeEnvOnecliUrl(url: string): void {
|
||||
writeEnvVar('ONECLI_URL', url);
|
||||
}
|
||||
|
||||
// The SANCTIONED gateway version: fresh installs pin to it. Upgrading an
|
||||
// existing gateway is NOT done here — the gateway is a separate out-of-band
|
||||
// component, and the migrator is the user's coding agent following
|
||||
// docs/onecli-upgrades.md during /update-nanoclaw. The pin lives in
|
||||
// versions.json ("onecli-gateway") so that flow can diff it across updates and
|
||||
// route the agent to the doc; bump it there deliberately on a new release.
|
||||
const ONECLI_GATEWAY_VERSION = readVersionPin('onecli-gateway');
|
||||
// The CLI binary follows the same convention: installed at its pin
|
||||
// ("onecli-cli" in versions.json), never at whatever "latest" means today.
|
||||
const ONECLI_CLI_VERSION = readVersionPin('onecli-cli');
|
||||
// Last-known-good CLI release. Used only if BOTH the upstream installer
|
||||
// and the redirect-based version probe fail. Bump deliberately when a
|
||||
// new CLI release ships.
|
||||
const ONECLI_GATEWAY_VERSION = '1.23.0';
|
||||
const ONECLI_CLI_FALLBACK_VERSION = '1.3.0';
|
||||
const ONECLI_CLI_REPO = 'onecli/onecli-cli';
|
||||
|
||||
function installOnecliCliOnly(): { stdout: string; ok: boolean } {
|
||||
const upstream = runInstall('curl -fsSL onecli.sh/cli/install | sh');
|
||||
if (upstream.ok) return { stdout: upstream.stdout, ok: true };
|
||||
const fallback = installOnecliCliDirect();
|
||||
return { stdout: upstream.stdout + (upstream.stderr ?? '') + '\n' + fallback.stdout, ok: fallback.ok };
|
||||
}
|
||||
|
||||
// Remove containers in the "onecli" compose project whose service name isn't
|
||||
// in the v2 set. Pre-v2 OneCLI used service "app" (container onecli-app-1);
|
||||
// v2 uses "onecli". Compose flags the old container as an orphan but won't
|
||||
@@ -160,10 +161,24 @@ function installOnecli(): { stdout: string; ok: boolean } {
|
||||
return { stdout: stdout + (gw.stderr ?? ''), ok: false };
|
||||
}
|
||||
|
||||
const cli = installOnecliCliDirect();
|
||||
stdout += cli.stdout;
|
||||
if (!cli.ok) {
|
||||
log.error('OneCLI CLI install failed');
|
||||
// CLI install. The upstream script calls the GitHub releases API
|
||||
// (api.github.com) to resolve the latest tag — which 403s anonymous
|
||||
// callers after 60 requests/hour per IP. Try upstream first; on failure
|
||||
// resolve the version ourselves (via HTTP redirect, which isn't
|
||||
// API-throttled) and download the release archive directly.
|
||||
const upstream = runInstall('curl -fsSL onecli.sh/cli/install | sh');
|
||||
stdout += upstream.stdout;
|
||||
if (upstream.ok) return { stdout, ok: true };
|
||||
|
||||
log.warn('Upstream CLI installer failed — falling back to direct download', {
|
||||
stderr: upstream.stderr,
|
||||
});
|
||||
stdout += (upstream.stderr ?? '') + '\n';
|
||||
|
||||
const fallback = installOnecliCliDirect();
|
||||
stdout += fallback.stdout;
|
||||
if (!fallback.ok) {
|
||||
log.error('OneCLI CLI install failed (both upstream and direct fallback)');
|
||||
return { stdout, ok: false };
|
||||
}
|
||||
return { stdout, ok: true };
|
||||
@@ -183,11 +198,11 @@ function runInstall(cmd: string): { stdout: string; stderr?: string; ok: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the OneCLI CLI at the sanctioned pin by downloading the release
|
||||
* archive straight from GitHub. Deliberately no "latest" resolution — the
|
||||
* upstream installer script always chases the newest release, which would
|
||||
* drift from the pin. PATH setup is not lost by skipping it:
|
||||
* ensureShellProfilePath() in run() covers it.
|
||||
* Reinstate the OneCLI CLI install without hitting GitHub's rate-limited
|
||||
* releases API. Resolves the version via the HTTP redirect from
|
||||
* /releases/latest → /releases/tag/vX.Y.Z, then downloads the archive
|
||||
* directly. Falls back to ONECLI_CLI_FALLBACK_VERSION if the redirect
|
||||
* probe also fails.
|
||||
*/
|
||||
function installOnecliCliDirect(): { stdout: string; ok: boolean } {
|
||||
const lines: string[] = [];
|
||||
@@ -206,7 +221,24 @@ function installOnecliCliDirect(): { stdout: string; ok: boolean } {
|
||||
return { stdout: lines.join('\n'), ok: false };
|
||||
}
|
||||
|
||||
const version = ONECLI_CLI_VERSION;
|
||||
let version: string | null = null;
|
||||
try {
|
||||
const redirect = execSync(
|
||||
`curl -fsSL -o /dev/null -w '%{url_effective}' https://github.com/${ONECLI_CLI_REPO}/releases/latest`,
|
||||
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },
|
||||
).trim();
|
||||
const m = redirect.match(/\/tag\/v?([^/]+)$/);
|
||||
if (m) version = m[1];
|
||||
} catch {
|
||||
// redirect probe failed — we'll pin the fallback
|
||||
}
|
||||
if (!version) {
|
||||
version = ONECLI_CLI_FALLBACK_VERSION;
|
||||
append(`Version probe failed; installing pinned fallback ${version}.`);
|
||||
} else {
|
||||
append(`Resolved onecli CLI ${version} via release redirect.`);
|
||||
}
|
||||
|
||||
const archive = `onecli_${version}_${osName}_${arch}.tar.gz`;
|
||||
const url = `https://github.com/${ONECLI_CLI_REPO}/releases/download/v${version}/${archive}`;
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'onecli-'));
|
||||
@@ -243,39 +275,6 @@ function installOnecliCliDirect(): { stdout: string; ok: boolean } {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* /v1 API compatibility check. @onecli-sh/sdk 2.x requires the server's /v1
|
||||
* API; servers older than the cutover answer 404 on every SDK call (permanent,
|
||||
* but presents as transient per-spawn failures). This is detect-only — setup
|
||||
* does not migrate the gateway. The upgrade is an out-of-band action on a
|
||||
* separate component that the agent runs via docs/onecli-upgrades.md during
|
||||
* /update-nanoclaw, so this step only surfaces the condition and points there.
|
||||
*/
|
||||
export async function verifyGatewayV1(
|
||||
url: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<'ok' | 'incompatible' | 'unreachable'> {
|
||||
try {
|
||||
const res = await fetchImpl(`${url}/v1/health`, { signal: AbortSignal.timeout(5000) });
|
||||
return res.ok ? 'ok' : 'incompatible';
|
||||
} catch {
|
||||
return 'unreachable';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect-and-warn helper: returns a status HINT (and logs) when the gateway is
|
||||
* pre-/v1, else null. Never fails the step or auto-upgrades — the agent owns
|
||||
* the upgrade via docs/onecli-upgrades.md.
|
||||
*/
|
||||
function gatewayV1Hint(result: 'ok' | 'incompatible' | 'unreachable'): string | null {
|
||||
if (result !== 'incompatible') return null;
|
||||
log.warn('OneCLI gateway lacks the /v1 API @onecli-sh/sdk 2.x requires', {
|
||||
pin: ONECLI_GATEWAY_VERSION,
|
||||
});
|
||||
return 'OneCLI gateway lacks the /v1 API @onecli-sh/sdk 2.x requires — upgrade it: docs/onecli-upgrades.md';
|
||||
}
|
||||
|
||||
export async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
|
||||
// `/api/health` matches the path probe.sh uses — keep them aligned.
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
@@ -301,7 +300,7 @@ export async function run(args: string[]): Promise<void> {
|
||||
// Remote-mode: install only the CLI, point it at the remote gateway, and
|
||||
// record the URL in .env. No local gateway is started.
|
||||
log.info('Installing OneCLI CLI for remote gateway', { remoteUrl });
|
||||
const res = installOnecliCliDirect();
|
||||
const res = installOnecliCliOnly();
|
||||
if (!res.ok || !onecliVersion()) {
|
||||
emitStatus('ONECLI', {
|
||||
INSTALLED: false,
|
||||
@@ -340,14 +339,12 @@ export async function run(args: string[]): Promise<void> {
|
||||
log.info('Wrote ONECLI_API_KEY to .env');
|
||||
}
|
||||
const healthy = await pollHealth(remoteUrl, 5000);
|
||||
const v1Hint = healthy ? gatewayV1Hint(await verifyGatewayV1(remoteUrl)) : null;
|
||||
emitStatus('ONECLI', {
|
||||
INSTALLED: true,
|
||||
REMOTE: true,
|
||||
ONECLI_URL: remoteUrl,
|
||||
HEALTHY: healthy,
|
||||
STATUS: 'success',
|
||||
...(v1Hint ? { GATEWAY_HINT: v1Hint } : {}),
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
return;
|
||||
@@ -381,14 +378,12 @@ export async function run(args: string[]): Promise<void> {
|
||||
writeEnvOnecliUrl(url);
|
||||
log.info('Reusing existing OneCLI', { url });
|
||||
const healthy = await pollHealth(url, 5000);
|
||||
const v1Hint = healthy ? gatewayV1Hint(await verifyGatewayV1(url)) : null;
|
||||
emitStatus('ONECLI', {
|
||||
INSTALLED: true,
|
||||
REUSED: true,
|
||||
ONECLI_URL: url,
|
||||
HEALTHY: healthy,
|
||||
STATUS: 'success',
|
||||
...(v1Hint ? { GATEWAY_HINT: v1Hint } : {}),
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
return;
|
||||
@@ -441,7 +436,6 @@ export async function run(args: string[]): Promise<void> {
|
||||
log.info('Wrote ONECLI_URL to .env', { url });
|
||||
|
||||
const healthy = await pollHealth(url, 15000);
|
||||
const v1Hint = healthy ? gatewayV1Hint(await verifyGatewayV1(url)) : null;
|
||||
|
||||
emitStatus('ONECLI', {
|
||||
INSTALLED: true,
|
||||
@@ -452,7 +446,6 @@ export async function run(args: string[]): Promise<void> {
|
||||
// The next step (auth) will surface a genuinely broken gateway via
|
||||
// `onecli secrets list`, so don't trigger rescue attempts from here.
|
||||
STATUS: 'success',
|
||||
...(v1Hint ? { GATEWAY_HINT: v1Hint } : {}),
|
||||
...(healthy
|
||||
? {}
|
||||
: {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* IS_GROUP, PAIRED_USER_ID }
|
||||
* or { STATUS=failed, CODE, ERROR }
|
||||
*
|
||||
* Depends on src/channels/telegram-pairing.js, which the /add-telegram skill
|
||||
* Depends on src/channels/telegram-pairing.js, which setup/add-telegram.sh
|
||||
* 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.
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* Standalone provider auth — the late-adopter entry point.
|
||||
*
|
||||
* Fresh installs reach a provider's auth walk-through via the setup picker;
|
||||
* an existing install adding a provider later runs THIS instead:
|
||||
*
|
||||
* pnpm exec tsx setup/index.ts --step provider-auth codex
|
||||
*
|
||||
* Same walk-through, same vault-only invariant, idempotent (each provider's
|
||||
* runAuth short-circuits when its secret already exists) — and unlike
|
||||
* re-running full setup, it touches nothing else: no install-wide default
|
||||
* provider rewrite, no service changes. Provider install skills call this as
|
||||
* their auth step so there is exactly one auth implementation per provider.
|
||||
*/
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
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 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> {
|
||||
const name = args[0]?.trim().toLowerCase();
|
||||
const withAuth = listSetupProviders().filter((entry) => entry.runAuth);
|
||||
|
||||
if (!name) {
|
||||
console.error(
|
||||
`Usage: pnpm exec tsx setup/index.ts --step provider-auth <provider>\n` +
|
||||
`Providers with an auth step: ${withAuth.map((entry) => entry.value).join(', ') || '(none installed)'}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let entry = getSetupProvider(name);
|
||||
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}…`);
|
||||
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) {
|
||||
await import(`./providers/${name}.js`);
|
||||
entry = getSetupProvider(name);
|
||||
}
|
||||
if (!entry) {
|
||||
console.error(`Install completed but ${name} did not register — check setup/providers/${name}.ts`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else if (!entry) {
|
||||
console.error(
|
||||
`Unknown provider: ${name}. Installed: ${listSetupProviders()
|
||||
.map((e) => e.value)
|
||||
.join(', ')}.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!entry.runAuth) {
|
||||
console.error(`Provider "${name}" uses the standard auth flow — run the full setup, or /add-${name}'s steps.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await entry.runAuth();
|
||||
await entry.runInstallCheck?.();
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
/**
|
||||
* Provider is a DB property of a group, set only via
|
||||
* `ncl groups config update --provider`. The group-creation contract that a
|
||||
* fork's coding agent and its skills depend on must carry zero provider
|
||||
* vocabulary — no `--provider` flag passed to, parsed by, or threaded through
|
||||
* any creation path. These guards go red if that flag creeps back in.
|
||||
*
|
||||
* (Prose references to the ncl surface in comments are fine — we assert the
|
||||
* absence of the `'--provider'` arg *literal*, not the substring.)
|
||||
*/
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
function read(rel: string): string {
|
||||
return fs.readFileSync(path.join(repoRoot, rel), 'utf-8');
|
||||
}
|
||||
|
||||
const CREATION_FILES = [
|
||||
'scripts/init-first-agent.ts',
|
||||
'scripts/init-cli-agent.ts',
|
||||
'setup/register.ts',
|
||||
'setup/cli-agent.ts',
|
||||
'setup/channels/telegram.ts',
|
||||
'setup/channels/discord.ts',
|
||||
'setup/channels/slack.ts',
|
||||
'setup/channels/whatsapp.ts',
|
||||
'setup/channels/signal.ts',
|
||||
'setup/channels/imessage.ts',
|
||||
'setup/channels/teams.ts',
|
||||
];
|
||||
|
||||
describe('creation is provider-agnostic', () => {
|
||||
for (const file of CREATION_FILES) {
|
||||
it(`${file} passes/parses no --provider flag`, () => {
|
||||
const src = read(file);
|
||||
expect(src).not.toContain("'--provider'");
|
||||
expect(src).not.toMatch(/case '--provider'/);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('setup carries the picked provider to creation via a setup-run env var', () => {
|
||||
it('picked-provider stashes/reads the pick in the NANOCLAW_PICKED_PROVIDER env var', () => {
|
||||
const src = read('setup/lib/picked-provider.ts');
|
||||
expect(src).toContain('NANOCLAW_PICKED_PROVIDER');
|
||||
// The pick is set into process.env so child creation scripts inherit it —
|
||||
// an in-process module global can't cross the process boundary.
|
||||
expect(src).toMatch(/process\.env\[/);
|
||||
});
|
||||
|
||||
// The creation scripts run as child processes, inherit the env var, and apply
|
||||
// it to the group's runtime config — container_configs.provider, the source of
|
||||
// truth materialized into container.json (agent_provider is deprecated) — before
|
||||
// the welcome wakes the container. No `--provider` flag in the contract (above).
|
||||
for (const file of ['scripts/init-first-agent.ts', 'scripts/init-cli-agent.ts']) {
|
||||
it(`${file} applies the env-carried provider to container_configs.provider`, () => {
|
||||
const src = read(file);
|
||||
expect(src).toContain('NANOCLAW_PICKED_PROVIDER');
|
||||
expect(src).toMatch(/updateContainerConfigScalars\([^)]*provider:\s*pickedProvider/);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
// 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('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('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');
|
||||
});
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
// Setup-side provider barrel. Provider payloads with their own setup surface
|
||||
// (picker entry, auth walk-through, install check) self-register on import.
|
||||
// Skills add a provider by appending one import line below.
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* Setup-side provider registration guards.
|
||||
*
|
||||
* Behavior (barrel-driven): imports the real setup/providers barrel and
|
||||
* asserts the built-in default — red if the barrel fails to evaluate.
|
||||
* Per-provider registration guards ship WITH each provider payload (the
|
||||
* skill copies them in), same archetype as the host/container registration
|
||||
* tests.
|
||||
*
|
||||
* Structural: the picker and the standalone provider-auth step are wiring
|
||||
* inside non-invocable entry flows (setup main, STEPS map) — assert their
|
||||
* consumption of the registry in source, so deleting either reach-in goes red.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getSetupProvider, listSetupProviders } from './registry.js';
|
||||
import './index.js'; // the real setup provider barrel — triggers self-registration
|
||||
|
||||
describe('setup provider registry', () => {
|
||||
it('always carries claude as the built-in default with the standard auth flow', () => {
|
||||
const claude = getSetupProvider('claude');
|
||||
expect(claude).toBeDefined();
|
||||
expect(claude!.runAuth).toBeUndefined();
|
||||
expect(listSetupProviders()[0]!.value).toBe('claude');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setup flow consumes the registry (structural)', () => {
|
||||
it('the picker renders options from listSetupProviders', () => {
|
||||
const src = fs.readFileSync(path.join(process.cwd(), 'setup', 'auto.ts'), 'utf-8');
|
||||
expect(src).toContain('listSetupProviders()');
|
||||
expect(src).toContain("import './providers/index.js'");
|
||||
// The capability-keyed branch — a provider's own auth runs iff it declares one.
|
||||
expect(src).toMatch(/providerEntry\?\.runAuth/);
|
||||
});
|
||||
|
||||
it('the standalone provider-auth step is reachable from the STEPS map', () => {
|
||||
const src = fs.readFileSync(path.join(process.cwd(), 'setup', 'index.ts'), 'utf-8');
|
||||
expect(src).toContain("'provider-auth'");
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* Setup-side provider registry — the picker and the standalone `provider-auth`
|
||||
* step render from this map instead of hardcoding provider names in the setup
|
||||
* flow (same capability-not-name rule as the host provider-container registry).
|
||||
*
|
||||
* `claude` is the built-in default: it has no `runAuth` of its own, which the
|
||||
* setup flow reads as "run the standard auth step". A provider payload adds
|
||||
* itself by shipping a `setup/providers/<name>.ts` with a top-level
|
||||
* `registerSetupProvider(...)` call and appending one import line to the
|
||||
* `setup/providers/index.ts` barrel — the same shape as the host and container
|
||||
* provider registries, guarded the same way (a barrel-driven registration test).
|
||||
*/
|
||||
import type { AssistContext } from '../lib/claude-assist.js'; // type-only — registry stays runtime-dependency-free
|
||||
|
||||
/**
|
||||
* Outcome of a provider-owned failure-assist hook:
|
||||
* - 'launched' — the provider's debugger ran (user may have fixed things).
|
||||
* - 'declined' — the user said no; do NOT offer another debugger.
|
||||
* - 'unavailable' — the provider's CLI can't be used here; the dispatcher
|
||||
* falls back to the guarded Claude offer (never install/sign-in).
|
||||
*/
|
||||
export type FailureAssistResult = 'launched' | 'declined' | 'unavailable';
|
||||
|
||||
export interface SetupProviderEntry {
|
||||
value: string;
|
||||
label: string;
|
||||
hint: string;
|
||||
/** Provider-owned auth walk-through (vault-only). Absent → standard auth step. */
|
||||
runAuth?: () => Promise<void>;
|
||||
/** Verifies the provider's payload is wired (files, barrels, Dockerfile pin). */
|
||||
runInstallCheck?: () => Promise<void>;
|
||||
/** Provider-owned interactive failure debugger. 'unavailable' → dispatcher
|
||||
* falls back to the guarded Claude offer (never install/sign-in). */
|
||||
offerFailureAssist?: (ctx: AssistContext, projectRoot: string) => Promise<FailureAssistResult>;
|
||||
}
|
||||
|
||||
const registry = new Map<string, SetupProviderEntry>();
|
||||
|
||||
registry.set('claude', {
|
||||
value: 'claude',
|
||||
label: 'Claude',
|
||||
hint: 'default — Anthropic subscription or API key',
|
||||
});
|
||||
|
||||
export function registerSetupProvider(entry: SetupProviderEntry): void {
|
||||
if (registry.has(entry.value)) {
|
||||
throw new Error(`Setup provider already registered: ${entry.value}`);
|
||||
}
|
||||
registry.set(entry.value, entry);
|
||||
}
|
||||
|
||||
export function getSetupProvider(name: string): SetupProviderEntry | undefined {
|
||||
return registry.get(name.toLowerCase());
|
||||
}
|
||||
|
||||
/** Claude (the default) first, then the rest in registration order. */
|
||||
export function listSetupProviders(): SetupProviderEntry[] {
|
||||
return [...registry.values()];
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user