Compare commits

..

95 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:42:47 +03:00
gavrielc ac0a799cbf refactor(add-codex): install Codex CLI via cli-tools.json, not the Dockerfile
adfae67 moved the agent's global Node CLIs into container/cli-tools.json so a
skill adds one with a json-merge instead of editing the Dockerfile. The Codex
provider install was left behind — add-codex.sh still awk'd an ARG + RUN into
the Dockerfile and its test guarded that shape.

Migrate add-codex to the seam:
- add-codex.sh appends { name: "@openai/codex", version } to cli-tools.json
  (idempotent json-merge); install/idempotency gates read the manifest.
- SKILL.md / REMOVE.md document the manifest append/removal, not Dockerfile edits.
- codex-dockerfile.test.ts -> codex-cli-tools.test.ts, asserting the manifest
  entry (skips when the manifest is absent, e.g. the bare providers branch).

Pairs with the providers-branch commit that drops the codex Dockerfile lines,
renames the payload test, and points the setup install-check at the manifest.

Verified end-to-end: full add-codex install into a clean worktree leaves the
Dockerfile codex-free, the manifest correctly appended and idempotent; vitest
cli-tools.test.ts (6) and bun codex-cli-tools.test.ts (2) green; host tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 21:40:44 +03:00
github-actions[bot] e3986eb58c chore: bump version to 2.1.16 2026-06-14 18:29:28 +00:00
github-actions[bot] 6d0d48d585 docs: update token count to 195k tokens · 98% of context window 2026-06-14 18:29:25 +00:00
gavrielc a142c496f7 Merge pull request #2756 from nanocoai/provider-selection
feat(providers): operator-driven provider selection, switching, and memory migration
2026-06-14 21:29:12 +03:00
gavrielc c5b4d11536 Apply suggestion from @gavrielc 2026-06-14 21:16:19 +03:00
Daniel M ed8b4149e7 Merge pull request #2764 from glifocat/docs/fix-claude-md-relocated-paths
docs(CLAUDE.md): fix two relocated Key Files paths
2026-06-14 18:13:31 +03:00
glifocat d5ce02d1b8 docs(CLAUDE.md): fix two relocated Key Files paths
The Key Files table and the Secrets/OneCLI section referenced
src/onecli-approvals.ts and src/user-dm.ts, but both files were moved
under src/modules/ (src/modules/approvals/onecli-approvals.ts and
src/modules/permissions/user-dm.ts). onecli-approvals.ts is already
cited at its correct new path elsewhere in the same doc, so this was a
partial-rename miss. Docs only — no code changes.

Closes #2763

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:01:40 +02:00
omri-maya c8af599944 Merge branch 'main' into provider-selection 2026-06-14 15:17:13 +03:00
github-actions[bot] 435233a062 chore: bump version to 2.1.15 2026-06-14 11:04:33 +00:00
gavrielc 785fce3754 Merge pull request #2758 from nanocoai/feat/cli-tools-manifest
feat(container): data-drive global CLI installs from cli-tools.json
2026-06-14 14:04:16 +03:00
Omri Maya 6d521a9d8d refactor(memory): scope imported-memory doctrine to /migrate-memory
The "read imported-agent-memory.md, treat it as binding" doctrine sat in the
memory definition that every group loads, but it only matters when an import
actually happened. Move it into the /migrate-memory skill — the step that
writes the imported file and its index pointer (which the agent inlines into
its prompt each turn) — and drop the always-on block from definition.md.

Addresses review feedback on #2756.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:12:41 +03:00
gavrielc adfae67611 feat(container): data-drive global CLI installs from cli-tools.json
The agent's global Node CLIs (claude-code, agent-browser, vercel) were each
a hardcoded ARG + RUN layer in the Dockerfile, so adding or bumping one meant
editing the Dockerfile — a code reach-in every tool-installing skill had to make.

Move the tool list into container/cli-tools.json. A skill now adds a CLI by
appending a {name, version} entry (a json-merge) — the safest change shape:
deterministic, idempotent, removable. install-cli-tools.sh parses the manifest
with node (no new jq dep), writes the per-tool only-built-dependencies opt-ins,
and runs one pinned `pnpm install -g`, so the pnpm supply-chain path is unchanged.

Behavior is byte-for-byte: same opt-ins, same pinned installs. agent-browser is
now pinned (0.27.1, what `latest` last resolved to) instead of floating.

container/cli-tools.test.ts guards the seam: red if a baseline tool is dropped,
a version unpins, or the Dockerfile wiring / pnpm path is removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 12:07:14 +03:00
Omri Maya 13a37def89 feat(providers): operator-driven provider selection, switching, and memory migration
Make the agent provider a first-class, operator-chosen property instead of a
Claude-only assumption. Trunk gains the seams; the actual non-default payloads
(Codex first) install from the `providers` branch.

Setup
- A provider registry feeds a hard-wired setup picker (Claude | Codex). Picking
  a non-default provider installs its payload (setup/add-codex.sh, channel-style),
  runs a vault-only auth walkthrough (--step provider-auth), and records the pick
  on the first agent before its first spawn.
- Picking Claude changes nothing — default installs are byte-for-byte unaffected.

Provider as a DB property
- Provider lives on container_configs.provider (materialized to container.json,
  read by resolveProviderName). Creation stays provider-agnostic; the picked
  provider is applied via the picked-provider seam. The deprecated
  agent_groups.agent_provider path is not used.

Switching + memory
- Switch a live group with `ncl groups config update --provider` + restart.
- Memory never migrates at runtime — each provider keeps its own store. The
  /migrate-memory skill carries a group's memory across a switch in either
  direction (flat CLAUDE.local.md <-> memory/ scaffold). group-init seeds an
  imported-agent-memory note for non-default providers; the runner's memory
  definition reads it first turn. See docs/provider-migration.md.

No install-wide default, no runtime provider guard — switching is operator-by-
convention, consistent with the no-install-gating posture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 07:49:39 +03:00
github-actions[bot] 03382e9dd7 chore: bump version to 2.1.14 2026-06-13 13:05:30 +00:00
github-actions[bot] 9763551656 docs: update token count to 194k tokens · 97% of context window 2026-06-13 13:05:27 +00:00
gavrielc a9c9cb300d Merge pull request #2754 from nanocoai/oss/exchange-hook
feat(runner): onExchangeComplete provider hook + slash-command interruption
2026-06-13 16:05:14 +03:00
gavrielc a619fc1aa2 Apply suggestion from @gavrielc 2026-06-13 16:03:02 +03:00
Omri Maya 3d2f3e58ca feat(runner): onExchangeComplete provider hook + slash-command interruption
Inverts conversation archiving into an optional onExchangeComplete provider
hook: the runner never archives on a provider's behalf, and the markdown
writer ships with the provider that needs it. Dormant for the default
provider.

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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 15:56:43 +03:00
gavrielc 11afc64ba4 Merge pull request #2747 from nanocoai/oss/onecli-sdk-v2
feat(onecli): SDK 2.2.1 — credential-stub mounts + machine-checkable pins
2026-06-13 15:49:40 +03:00
github-actions[bot] 0ee75d393c chore: bump version to 2.1.13 2026-06-13 12:27:29 +00:00
github-actions[bot] 72b9cc7ed0 docs: update token count to 192k tokens · 96% of context window 2026-06-13 12:27:24 +00:00
gavrielc 5fcf234165 Merge pull request #2746 from nanocoai/oss/agent-surfaces
feat(providers): agent-surfaces capability seam
2026-06-13 15:27:12 +03:00
github-actions[bot] 9b1236505f chore: bump version to 2.1.12 2026-06-13 12:25:58 +00:00
github-actions[bot] 878cd68c1b docs: update token count to 191k tokens · 96% of context window 2026-06-13 12:25:52 +00:00
gavrielc fab1ebf2d6 Merge pull request #2745 from nanocoai/oss/memory-scaffold
feat(memory): opt-in persistent memory scaffold for providers
2026-06-13 15:25:39 +03:00
Omri Maya 3f9e89d345 feat(onecli): SDK 2.2.1 — credential-stub mounts + machine-checkable pins
Injects credentials as request-time stubs so no credential is ever written
into a container or to disk. Gateway and CLI versions move to versions.json
(machine-checkable pins); breaking upgrades are documented in
docs/onecli-upgrades.md as an agent-executable runbook (detect / why / fix /
verify / rollback), and the update flow follows linked docs and diffs the
pins.

BREAKING: requires a gateway upgrade; the doc carries the steps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 11:30:11 +03:00
Omri Maya 14810a5090 feat(providers): agent-surfaces capability seam
Host-side registry where a provider can declare, by capability rather than
by name, that it owns its agent surfaces (project doc, skills). Default
providers keep the standard surfaces; a surfaces-owning provider suppresses
them. Dormant until a provider registers — no change for existing installs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 11:30:10 +03:00
Omri Maya 2cfa86e570 feat(memory): opt-in persistent memory scaffold for providers
Adds a provider capability (usesMemoryScaffold) and a container-side boot
scaffold that materializes a persistent memory/ tree for providers that opt
in. Dormant for the default provider — the scaffold is only built when a
provider declares the capability, so existing installs are byte-identical
(asserted by a boot-gate wiring test).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 11:30:09 +03:00
github-actions[bot] 36cbf17e10 chore: bump version to 2.1.11 2026-06-11 17:16:51 +00:00
gavrielc 4459ab2e54 Merge pull request #2739 from nanocoai/feat/raw-webhook-registry
feat(webhook-server): raw-route registry — non-Chat-SDK webhooks become an append
2026-06-11 20:16:33 +03:00
gavrielc 9e6238d28f Merge main (channel instances): keep both webhook suites as separate files
The instance route-split suite (from #2733) keeps src/webhook-server.test.ts;
this branch's raw-route suite moves to src/webhook-server-raw.test.ts —
incompatible lifecycle setups (fixed port + afterEach vs random port +
afterAll) make a single merged file wrong. webhook-server.ts auto-merge
verified: raw routes take dispatch priority, stop clears both maps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:07:30 +03:00
github-actions[bot] d1bda5d15b chore: bump version to 2.1.10 2026-06-11 16:42:59 +00:00
gavrielc 7eddc7d8c9 Merge pull request #2738 from nanocoai/fix/write-outbound-direct-rw
fix(session-manager): writeOutboundDirect opens outbound.db read-only — command-gate denials never deliver
2026-06-11 19:42:39 +03:00
github-actions[bot] 991ef986f8 docs: update token count to 190k tokens · 95% of context window 2026-06-11 16:42:36 +00:00
github-actions[bot] 0f2557e2bc chore: bump version to 2.1.9 2026-06-11 16:42:32 +00:00
gavrielc 4e6552ed55 Merge pull request #2737 from nanocoai/feat/approval-resolved-hook
feat(approvals): approval-resolved callback registry — modules observe resolution additively
2026-06-11 19:42:12 +03:00
github-actions[bot] 978b998ee6 chore: bump version to 2.1.8 2026-06-11 16:41:56 +00:00
gavrielc 83951d7c01 Merge pull request #2736 from nanocoai/fix/host-sweep-wake-grace
fix(host-sweep): grace period for freshly-woken containers with stale processing claims
2026-06-11 19:41:38 +03:00
github-actions[bot] 76ef097521 chore: bump version to 2.1.7 2026-06-11 16:41:14 +00:00
gavrielc 1c85fd6e50 Merge pull request #2735 from nanocoai/fix/approval-card-actor-byline
fix(chat-sdk-bridge): record the acting user on resolved approval cards
2026-06-11 19:40:59 +03:00
github-actions[bot] 42275ede1f chore: bump version to 2.1.6 2026-06-11 16:40:40 +00:00
gavrielc 53e1989529 Merge pull request #2734 from nanocoai/feat/delivery-action-getter
feat(delivery): getDeliveryAction read side for the action registry
2026-06-11 19:40:20 +03:00
github-actions[bot] 6f2142d7c7 docs: update token count to 189k tokens · 95% of context window 2026-06-11 16:39:51 +00:00
github-actions[bot] 79a0226962 chore: bump version to 2.1.5 2026-06-11 16:39:41 +00:00
gavrielc 0b31695e92 Merge pull request #2733 from nanocoai/feat/channel-instances
feat(channels): native channel-instance dimension — multi-bot substrate
2026-06-11 19:39:19 +03:00
gavrielc 421f8707d2 Merge pull request #2741 from nanocoai/setup-handoff-kickoff-prompt
fix(setup): auto-submit handoff context as Claude's first prompt
2026-06-11 17:36:04 +03:00
gavrielc 67ccd9e74c fix(setup): auto-submit handoff context as Claude's first prompt
Interactive setup handoffs (mid-flow `?` escape and on-failure) spawned
claude with all context in --append-system-prompt and no user message,
so Claude sat at an empty REPL until the user re-explained themselves.

Move the context into a positional prompt that auto-submits as the
first user message: Claude starts orienting immediately, the context
stays visible in the transcript, and it survives --resume.

Also:
- Share one session across all handoffs in a setup run: pin a
  generated UUID via --session-id on the first spawn, --resume it on
  later ones (stdio is inherited, so Claude's own id is never visible).
- Switch --permission-mode from acceptEdits to auto.
- Dedupe the two spawn blocks into spawnInteractiveClaude().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:14:48 +03:00
gavrielc f69af07c57 feat(webhook-server): raw-route registry — non-Chat-SDK webhooks become an append
Add a RawWebhookHandler registry alongside the Chat SDK adapter routes
so modules can mount plain Node handlers at /webhook/{path} on the
shared server instead of editing webhook-server.ts or standing up a
second HTTP server on another port. Raw routes dispatch ahead of
adapter routes, handler throws surface as a 500, and stopWebhookServer
clears the registry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:58:51 +03:00
gavrielc 93a302b5db feat(approvals): add approval-resolved callback registry
Modules can already register to handle an approval (registerApprovalHandler),
but nothing lets a module observe that an approval was resolved — e.g. to
clear an "awaiting approval" status indicator it set when the card went out.
Today that observation is only possible by core importing module code.

Add registerApprovalResolvedHandler/notifyApprovalResolved to the approvals
primitive and fire it at the three resolution exits in the response handler
(reject, approve-with-no-handler, approve-after-handler). Callback errors are
logged and isolated so one bad callback never blocks resolution or other
callbacks. The hook only fires for authorized clicks (it sits behind the
isAuthorizedApprovalClick gate) and carries the namespaced user id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:54:21 +03:00
gavrielc eef285ba3b fix(session-manager): open outbound.db read-write in writeOutboundDirect
writeOutboundDirect opened the session's outbound DB through
openOutboundDb, which sets readonly: true. The INSERT it then runs threw
SQLITE_READONLY on every call, so the command-gate denial path
(router.ts) never delivered its 'Permission denied' response — the
sender just got silence, and the throw aborted routing for that inbound
event.

Switch to the openOutboundDbRw wrapper, which opens the same path with
write access (DELETE journal + busy_timeout). The host-side write to the
container-owned outbound.db is safe: both sides use DELETE journal mode,
and the even host seq stays out of the container's odd-seq space.

Adds a guard test that drives writeOutboundDirect against a real session
folder and asserts the denial rows land in messages_out with even seqs;
it goes red if the open call reverts to the readonly form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:54:17 +03:00
gavrielc a806534199 fix(host-sweep): grace period for freshly-woken containers with stale processing claims
The sweep tick that wakes a container for due messages also ran the
running-container SLA check in the same iteration. A fresh container that
inherits stale processing_ack rows from a previous crash hasn't had a chance
to run its startup cleanup (clearStaleProcessingAcks) yet, so the per-claim
stuck rule saw an hours-old claim, concluded the just-spawned container was
stuck, and SIGKILL'd it — an immediate spawn-kill loop.

Carry a justWoke flag from the wake step into the SLA gate and skip the
check for that one tick. The next tick (60s later) enforces the SLA
normally, so a genuinely stuck container is still killed.

Guarded by src/host-sweep-grace.test.ts, which drives two real sweep ticks
against on-disk session DBs: the wake tick must not kill, a later tick with
the claim still stale must kill claim-stuck.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:53:15 +03:00
gavrielc 0ac8073e34 fix(chat-sdk-bridge): record the acting user on resolved approval cards
When a button on an approval/question card is clicked, the bridge edits
the card down to the title and the selected answer — but not who clicked
it. In shared channels every member sees the same resolved card, so the
audit trail of which user approved or rejected is lost the moment the
buttons disappear.

Append an actor byline (" — <userName>", falling back to fullName) to
the edited card markdown. The shared chat.onAction handler covers every
Chat SDK webhook platform; cards edited for actors with no resolvable
name stay byline-free.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:52:45 +03:00
gavrielc 539a2b3c63 feat(delivery): getDeliveryAction read side for the action registry
registerDeliveryAction had no read side, so module registrations could
not be verified through the registry itself. Add a getter beside it and
a guard test covering lookup, miss, and overwrite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:52:00 +03:00
gavrielc fccaadf24c fix(channels,db): exact instance dispatch, FK-check scoping, migration-safe skill snippets
Review-round fixes on the instance dimension:
- delivery/typing resolve adapters by exact registry key, never the
  channelType fallback — a named instance with an offline adapter gets
  offline handling, not a cross-identity send through a sibling bot;
  the fallback scan (channelType-only callers) now warns when it
  resolves through a differently-keyed instance
- migration runner only fails on FK violations a migration introduced:
  pre-existing latent orphans (FK-OFF CLI surgery) are logged and
  carried, not turned into a boot crash-loop
- typing re-trigger updates the full address (channelType, platformId,
  threadId, instance) together — no torn entries on agent-shared
  sessions spanning instances
- bridge rejects empty/whitespace instance names (URL-route and
  state-namespace safety)
- add-github / add-linear SKILL.md wiring inserts include the NOT NULL
  instance column
- drop the 10s same-platform boot stagger: operational policy, not
  substrate — reintroducible skill-side for gateway-mode installs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:43:18 +03:00
github-actions[bot] 3329270c67 docs: update token count to 185k tokens · 93% of context window 2026-06-10 20:02:28 +00:00
Daniel M f16ea0c783 Merge pull request #2719 from amit-shafnir/feat/uninstall-script
feat: add uninstall.sh — per-copy uninstaller with confirmation, dry-run, and OneCLI agent cleanup
2026-06-10 23:02:11 +03:00
gavrielc 1c024bc976 docs: document the channel-instance dimension
- CLAUDE.md entity model: instance on messaging_groups.
- db-central.md: updated messaging_groups DDL (instance NOT NULL, triple
  UNIQUE, denied_at), instance semantics (default = channel_type via
  migration 016 backfill; inbound exact-on-instance, outbound
  default-first), and the user_dms per-platform (not per-instance)
  cold-DM note.
- architecture.md: same DDL update in the schema appendix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:07:22 +03:00
gavrielc 6c26f3ef08 feat(host): thread the channel instance through router, delivery, and typing
Inbound: src/index.ts onInbound stamps `instance: adapter.instance ??
adapter.channelType` — the single host-side stamping seam; adapters stay
instance-blind and onInboundEvent (CLI) passes events through unchanged.
The router resolves the thread-policy adapter and the messaging group by
the receiving instance (exact-only — an unknown named instance auto-creates
its own group, persisting the instance, instead of hijacking a sibling's
row).

Outbound: ChannelDeliveryAdapter.deliver/setTyping grow a trailing
`instance` param (host-internal interface only — messages_out, destinations
and session_routing schemas are untouched; containers never see instance).
deliverMessage resolves the messaging group ORIGIN-SESSION-FIRST, so a
named instance's session replies through its own adapter even when a
sibling default row shares the same (channel_type, platform_id); dispatch
goes through getChannelAdapter(instance ?? channelType).

Typing: TypingTarget stores the instance and all three tick sites
(immediate, 4s interval, re-trigger) forward it, so the indicator fires
through the bot that owns the chat.

Also updates a raw-SQL fixture in groups.test.ts for the NOT NULL instance
column.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:05:50 +03:00
gavrielc ab6ab6936c feat(channels): per-instance Chat SDK state namespaces and webhook routes
ChatSdkBridgeConfig gains `instance`. The bridge keeps channelType =
adapter.name (semantic platform identity is untouched) and threads the
instance into three places:

- Registry identity: bridge.name / bridge.instance follow config.instance.
- Chat SDK state: SqliteStateAdapter takes an optional namespace and
  prefixes every key at a single choke point (k()). All bridges share the
  chat_sdk_* tables and two same-platform instances see identical
  thread/message ids — without the namespace, the SDK's
  dedupe:${adapter.name}:${message.id} key makes the second bot silently
  drop every message the first processed, locks serialize across bots, and
  subscriptions leak engagement. The namespace applies ONLY when instance
  is set AND differs from adapter.name: the default instance stays on the
  legacy UNPREFIXED keyspace byte-identically, so live installs' existing
  subscriptions/kv/locks/lists rows are never orphaned. enqueue does not
  prefix (appendToList does) — layout is ns:queue:<tid>; acquireLock
  returns the raw threadId and release/extend re-apply k() at their SQL
  sites.
- Webhook route: registerWebhookAdapter(chat, adapterName, routingPath =
  adapterName) splits the URL segment from the chat.webhooks handler key,
  so each same-platform instance gets its own URL (and signing secret).
  Signature adopted verbatim from PR #2617 (credit @davekim917's #1804
  prototype); the handler body needed zero change — dispatch already read
  entry.adapterName, not the route key.

Instance names are validated URL-safe (no '/', '?', ':' or whitespace) at
bridge construction: the route regex is [^/?]+ and ':' is the namespace
delimiter. The Chat instance's inner adapters map stays keyed adapter.name
(the SDK resolves adapters via channelId.split(':')[0] and serializes by
adapter.name) — instance identity lives entirely outside the Chat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:57:33 +03:00
gavrielc 501afb4beb feat(channels): key the adapter registry by instance with channelType fallback
ChannelAdapter and InboundEvent gain an optional `instance` field — the
host-side routing identity for N adapters of one platform. channelType
stays the semantic platform key (user ids, formatting, container config).

Registry changes:
- activeAdapters keys by `adapter.instance ?? adapter.channelType`, so the
  default instance keeps today's channelType key byte-identically. A
  duplicate instance key warns loudly and overwrites (today's boot
  semantics, made visible).
- getChannelAdapter(key) resolves the exact instance key first, then falls
  back to the first-registered adapter of that channel type — channelType-
  only callers (cold DMs, user-id prefix resolution, approval delivery)
  still resolve deterministically when every instance of a platform is
  named.
- initChannelAdapters staggers same-channelType setups by 10s so two
  gateway bots of one platform don't identify simultaneously from one IP.
  Inert when no two registrations share a channelType.

No adapter sets `instance` today, so every existing install boots
identically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:53:01 +03:00
gavrielc 9040dbb86e feat(db): add messaging_groups.instance with FK-safe recreate migration
Adds the channel-instance dimension to the schema: an `instance` column
(NOT NULL, default instance = channel_type) on messaging_groups, relaxing
UNIQUE(channel_type, platform_id) to the triple so N adapter instances of
one platform can each own a row per chat.

SQLite can't relax a table-level UNIQUE in place, and DROP TABLE fails FK
integrity on live DBs with child rows (the failure that forced migration
011 to abandon its rebuild) — so the migration runner grows an opt-in
`disableForeignKeys` flag: foreign_keys=OFF around the transaction (the
pragma is a no-op inside one), PRAGMA foreign_key_check inside it so a
violating recreate rolls back atomically.

Query semantics (deliberately asymmetric, both documented):
- getMessagingGroupWithAgentCount (router fast path): exact-on-instance,
  no fallback — an unknown named instance returns null so the router
  auto-creates a per-instance group instead of hijacking a sibling's row.
  Default param (= channelType) keeps existing callers identical.
- getMessagingGroupByPlatform (outbound/cold-DM/setup): unset instance
  resolves default-instance-first with a deterministic ORDER BY; set
  instance is exact-only.

Existing rows are backfilled instance = channel_type, so single-instance
installs see zero behavior change and need no operator action.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:49:02 +03:00
Amit Shafnir d8748e3a45 fix: address uninstaller review findings
- .env backup and removal are now one atomic action: a failed backup
  throws into executePlan's catch and the deletion never runs (the bash
  original's set -e gave the same guarantee; the port had lost it)
- containers are re-listed by install label at removal time instead of
  removed from scan-time ids — the live host can spawn containers during
  the confirm phase
- uninstall telemetry no longer creates data/install-id (persistId:false
  on emit), so --dry-run truly changes nothing and the already-clean
  exit can fire
- runtime-tail failure notes are printed before the Done line instead
  of being discarded
- uninstall.sh translates the old short flags (-n/-y) instead of
  silently dropping them (-n used to fall through to a real interactive
  uninstall)
- nanoclaw.sh gates the TS uninstaller on node (tsx's interpreter), not
  pnpm, which the direct-exec path never uses
- detectExistingInstall also checks the system-level systemd unit
- a delete-onecli-agent spawn failure now notes the manual command
  instead of claiming the agent was already gone
- setupLog.userInput is skipped when logs/ is absent so the uninstall
  doesn't recreate it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:50:12 +03:00
Amit Shafnir 41a720dd59 feat: port uninstaller to TS, wire nanoclaw.sh --uninstall, detect existing installs in setup
Replaces the standalone bash uninstall.sh with a TypeScript flow inside the
setup driver (setup/uninstall/): scan (slug-scoped inventory), plan (pure
ordered removal actions), remove (per-action executor that absorbs failures
into notes), and flow (clack UI). uninstall.sh is now a 3-line pointer that
execs nanoclaw.sh --uninstall.

- nanoclaw.sh --uninstall short-circuits before diagnostics/bootstrap; with
  no node_modules it prints manual cleanup commands and exits 1
- setup:auto routes --uninstall before initProgressionLog so an uninstall
  never resets logs/setup.log
- fresh setup runs detect an existing install (service registration or
  data/v2.db) and offer keep-and-continue (default) or uninstall-and-exit;
  suppressed on fail()-retry and sg re-exec resumes
- self-deletion safety: static imports only, dist/ + node_modules/ removed
  dead last, nothing but console.log after the runtime tail
- --yes never deletes orphan ag-* vault agents; their manual delete
  commands (by vault uuid) are printed instead

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:50:12 +03:00
Amit Shafnir 6ae83f48ac feat: add uninstall.sh — per-copy uninstaller with confirmation, dry-run, and OneCLI agent cleanup
Removes only what belongs to this checkout (slug-scoped): background
service, containers + image, data/, logs/, groups/, ncl symlink, and
this copy's OneCLI vault agents. Shared tools (OneCLI app, credentials,
other copies) are left alone. Interactive per-group confirmation with
--dry-run and --yes modes; .env is backed up before removal.

Documented in README FAQ and the CLAUDE.md key-files table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:50:12 +03:00
gavrielc dc34ceb83d Merge pull request #2721 from nanocoai/docs/skills-model
docs: customizing intro, skills model, and skill guidelines
2026-06-10 11:41:40 +03:00
gavrielc ad3dfad3f5 docs: align CONTRIBUTING and README with the registry-branch install model
CONTRIBUTING still described feature skills as installed by merging a
skill/* branch, a design the shipped skills no longer use: /add-slack,
/add-telegram and the rest install by additive fetch from the channels
and providers registry branches (git fetch + git show per file), with
registration tests and a REMOVE.md. Rewrite the skill-type section to
match, point the authoring bar at docs/skill-guidelines.md, fix the
README FAQ line that sent every contribution to the registry branches,
and delete docs/skills-as-branches.md (the superseded merge-based
design, including a marketplace flow that was never the shipped path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:27:51 +03:00
gavrielc 0bdc6d2bb2 docs: customizing intro, skills model, and skill guidelines
Three public docs establishing the skills-based customization contract:

- docs/customizing.md: the short doorway. The problem (merge fights on
  update), the idea (every change is a skill), how to work (edit first,
  skillify after), the one rule (/update-nanoclaw, never raw git pull),
  and the two-sided deal.
- docs/skills-model.md: the full model. Recipes, skill anatomy, the
  two kinds of skills, registry branches (additive fetch, never merge),
  a test for every integration point, upgrading, migrations and the
  startup tripwire, the maintainer commitments, and the registry
  review rule.
- docs/skill-guidelines.md: the authoritative checklist for writing a
  skill. Two principles (minimal integration surface; a test per
  functional integration point), anatomy, change shapes, testing
  doctrine with archetypes, anti-patterns, worked examples.

Also: CLAUDE.md docs index rows for the three docs, and .gitignore
entries for local-only working artifacts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:12:17 +03:00
github-actions[bot] 820cd8ece6 docs: update token count to 185k tokens · 92% of context window 2026-06-09 19:31:53 +00:00
github-actions[bot] e44d497cdf chore: bump version to 2.1.4 2026-06-09 19:31:49 +00:00
gavrielc ac37ecbfd6 Merge pull request #2720 from nanocoai/security/authorize-create-agent
security: authorize create_agent host-side (approval for confined groups)
2026-06-09 22:31:36 +03:00
gavrielc c6627d32e2 security: authorize create_agent host-side (approval for confined groups)
create_agent writes central-DB state (agent_groups, container_configs,
agent_destinations) and scaffolds host filesystem state, but the only
gate lived inside the untrusted container and is bypassed by writing the
outbound system row directly (the "host re-checks permission" comment was
false). Authorize host-side by CLI scope: trusted owner agent groups
(global scope) create sub-agents directly; confined groups require admin
approval via requestApproval. Adds regression tests for the branch.

Alternative to #2383 (which denies confined groups outright); co-authored
from that work.

Co-Authored-By: hinotoi-agent <paperlantern.agent@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 22:29:57 +03:00
github-actions[bot] 51bf403b22 chore: bump version to 2.1.3 2026-06-09 19:29:33 +00:00
github-actions[bot] 265953ffec docs: update token count to 184k tokens · 92% of context window 2026-06-09 19:29:29 +00:00
gavrielc 6227bd1a5b Merge pull request #2478 from Hinotoi-agent/security/approval-response-admin-authz
[security] fix(approvals): require admin for approval responses
2026-06-09 22:29:07 +03:00
gavrielc 28032bc0ec Merge pull request #2468 from Hinotoi-agent/security/a2a-attachment-symlink-guard
[security] fix(agent-route): reject unsafe forwarded attachments
2026-06-09 22:29:03 +03:00
github-actions[bot] 3e3a2945a5 chore: bump version to 2.1.2 2026-06-09 18:04:39 +00:00
gavrielc f3fc18e56e chore: bump claude-code to 2.1.170 and agent SDK to 0.3.170
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 21:04:13 +03:00
github-actions[bot] d85efea229 chore: bump version to 2.1.1 2026-06-08 12:10:39 +00:00
github-actions[bot] c5b22cb308 docs: update token count to 183k tokens · 92% of context window 2026-06-08 12:10:36 +00:00
gavrielc 1592369201 Merge pull request #2713 from nanocoai/feat/egress-lockdown
feat(security): egress lockdown (opt-in, off by default)
2026-06-08 15:10:22 +03:00
Omri Maya 6420c0e254 feat(security): egress lockdown (opt-in) — agent egress only via OneCLI
Place agent containers on a Docker `--internal` network (no internet route)
with the OneCLI gateway attached, aliased host.docker.internal. The injected
proxy URL resolves only to the gateway, so a non-proxy-aware client or raw
socket has nowhere to go — closing the HTTPS_PROXY-bypass hole. The agent is
non-root with no NET_ADMIN, so it cannot undo this. Self-healing: the gateway
is re-attached at every spawn and on each host-sweep tick.

Fail-fast: when lockdown is enabled but the network/gateway can't be
established, refuse to spawn and surface a clear EgressLockdownError rather
than silently falling back to open egress. The host-sweep re-heal is the lone
exception — a heal failure there is logged, not fatal, since running agents
stay on the internal net (no leak) until the gateway returns.

Off by default — opt in with NANOCLAW_EGRESS_LOCKDOWN=true (so OSS users get
the prior behavior unchanged on pull). Also NANOCLAW_EGRESS_NETWORK and
ONECLI_GATEWAY_CONTAINER.

The lockdown logic lives in its own src/egress-lockdown.ts; container-runtime.ts
keeps only the generic runtime surface. Documented in docs/SECURITY.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:23:17 +03:00
gavrielc aef8d38b36 Merge pull request #2710 from markbala/docs/ollama-prefix-cache
docs(ollama): allow prompt caching by filtering the cache-busting hash
2026-06-07 23:21:45 +03:00
gavrielc 6d6f813deb Merge branch 'main' into docs/ollama-prefix-cache 2026-06-07 22:01:26 +03:00
markbala f9c86d0af2 docs(ollama): allow prompt caching by filtering the cache-busting hash
The Claude Agent SDK adds a per-request cch=<hash> to the front of every
prompt; it changes each turn, and Ollama's prompt cache only reuses a
prompt whose start is unchanged, so it re-reads the whole prompt every
time (slow). A tiny proxy filters the hash out (pins cch to a constant) so
caching kicks in. In our setup (31B on Apple Silicon) follow-up replies
went ~80s -> ~4s; numbers vary by model/hardware. Ollama ignores the hash,
so output is unchanged.

Scope: only the Claude-Code-CLI -> Ollama path; Codex/OpenCode emit no cch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 23:20:11 +08:00
github-actions[bot] 9edb33dd3a docs: update token count to 182k tokens · 91% of context window 2026-06-07 14:06:19 +00:00
gavrielc 8ba5261ae8 Merge pull request #2707 from nanocoai/feat/upgrade-tripwire
feat(upgrade): startup tripwire + upgrade marker
2026-06-07 17:06:03 +03:00
hinotoi-agent 728c6a641b fix(approvals): require admin for approval responses 2026-05-15 10:34:46 +08:00
hinotoi-agent 8385236c30 fix(agent-route): reject unsafe forwarded attachments 2026-05-14 21:04:04 +08:00
180 changed files with 10248 additions and 3403 deletions
+49 -54
View File
@@ -1,83 +1,78 @@
# Remove Codex provider
# Remove the Codex agent provider
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.
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.
## 1. Delete the barrel import lines (both trees)
## 1. Switch codex groups back to the default
Delete (do not comment out) the `import './codex.js';` line from each barrel:
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:
- `src/providers/index.ts`
- `container/agent-runner/src/providers/index.ts`
- `setup/providers/index.ts`
This unregisters the provider from both `listProviderContainerConfigNames()` (host) and `listProviderNames()` (container).
## 2. Delete the copied files (both trees)
## 3. Delete every copied file
```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/codex.factory.test.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-dockerfile.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
```
## 3. Revert the Dockerfile CLI install
This skill itself (`.claude/skills/add-codex/`) stays — it ships with trunk so the provider can be re-added later.
In `container/Dockerfile`, remove both Codex edits (skip whichever is already gone):
`container/AGENTS.md` stays only if another installed provider uses agent surfaces; otherwise remove it too.
**(a)** Delete the version ARG from the "Pin CLI versions" block:
## 4. Remove the CLI manifest entry
```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:
Delete the `@openai/codex` entry from `container/cli-tools.json`:
```bash
mkdir -p data/env && cp .env data/env/env
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");
'
```
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.
## 5. Vault secret (optional)
## 6. Rebuild and restart
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.
Run from your NanoClaw project root:
## 6. Rebuild and verify
```bash
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)
pnpm run build
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
./container/build.sh
pnpm test
cd container/agent-runner && bun test
```
## 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.
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).
+77 -142
View File
@@ -1,186 +1,121 @@
---
name: add-codex
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).
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).
---
# Codex agent provider
NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected with **`AGENT_PROVIDER`** (`claude` | `opencode` | `codex` | `mock`).
> 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.
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.
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.
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.
The provider runs `codex app-server` as a child process speaking JSON-RPC over stdio: native streaming, MCP tools, server-side conversation history (the continuation is a thread id, no on-disk transcript). Credentials are **vault-only**: OneCLI serves a sentinel `auth.json` stub into the container and swaps the real ChatGPT token or API key on the wire — no key in `.env`, nothing readable in the container.
The mechanical steps under **Install** carry `nc:` directive fences: an agent reads the prose and applies them, and a parser can apply them deterministically from the same document. Every directive is idempotent, so the whole skill is safe to re-run; anything a parser can't apply falls back to the prose beside it.
## Install
### Pre-flight
If all of the following are already present, skip to **Configuration**:
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**:
- `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`
- `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`
Missing pieces — continue below. All steps are idempotent; re-running is safe.
### 1. Fetch and copy the payload
### 1. Fetch the providers branch
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.
```bash
git fetch origin providers
```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. Copy the Codex source files and tests
### 2. Wire the barrels
Wholesale copies (owned entirely by this skill — user edits to these files won't survive a re-run, as designed):
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.
```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
```nc:append to:src/providers/index.ts
import './codex.js';
```
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
```nc:append to:container/agent-runner/src/providers/index.ts
import './codex.js';
```
`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
```nc:append to:setup/providers/index.ts
import './codex.js';
```
`container/agent-runner/src/providers/index.ts`:
### 3. CLI manifest
```typescript
import './codex.js';
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" }
```
### 4. Add the Codex CLI to the container Dockerfile
The version (`0.138.0`) is the canonical pin — this SKILL.md is the source of truth.
Two edits to `container/Dockerfile`, both idempotent (skip if already present):
### 4. Build
**(a)** In the "Pin CLI versions" ARG block (around line 18), add after `ARG CLAUDE_CODE_VERSION=...`:
```dockerfile
ARG CODEX_VERSION=0.124.0
```nc:run effect:build
pnpm run build
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
./container/build.sh
```
**(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:
### 5. Validate
```dockerfile
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g "@openai/codex@${CODEX_VERSION}"
```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/
```
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`.
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.
### 5. Build and validate
## 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:
```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
ncl groups config update --id <group-id> --provider codex
ncl groups restart --id <group-id>
```
All must be clean before proceeding.
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.
- 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.
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.
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`.
## Troubleshooting
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).
- **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).
@@ -1,30 +0,0 @@
// 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\}"/);
});
});
+63 -45
View File
@@ -5,61 +5,68 @@ description: Add Discord bot channel integration via Chat SDK.
# Add Discord Channel
Adds Discord bot support via the Chat SDK bridge.
Adds Discord bot support via the Chat SDK bridge. NanoClaw doesn't ship channels
in trunk — this skill copies the Discord adapter in from the `channels` branch.
## Install
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
reads the prose and applies them, and a parser can apply them deterministically
from the same document. Every directive is idempotent, so the whole skill is
safe to re-run; anything a parser can't apply falls back to the prose beside it.
NanoClaw doesn't ship channels in trunk. This skill copies the Discord adapter in from the `channels` branch.
## Apply
### Pre-flight (idempotent)
### 1. Copy the adapter and its registration test
Skip to **Credentials** if all of these are already in place:
Fetch the `channels` branch and copy the Discord adapter and its registration
test into `src/channels/` (overwrite — the branch is canonical):
- `src/channels/discord.ts` exists
- `src/channels/discord-registration.test.ts` exists
- `src/channels/index.ts` contains `import './discord.js';`
- `@chat-adapter/discord` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```nc:copy from-branch:channels
src/channels/discord.ts
src/channels/discord-registration.test.ts
```
### 2. Copy the adapter and its registration test
### 2. Register the adapter
```bash
git show origin/channels:src/channels/discord.ts > src/channels/discord.ts
git show origin/channels:src/channels/discord-registration.test.ts > src/channels/discord-registration.test.ts
```
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
```nc:append to:src/channels/index.ts
import './discord.js';
```
### 4. Install the adapter package (pinned)
### 3. Install the adapter package
```bash
pnpm install @chat-adapter/discord@4.27.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/discord@4.26.0
```
### 5. Build and validate
### 4. Build and validate
```bash
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
the dependency is installed. Then run the one integration test.
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/discord-registration.test.ts
```
Both must be clean before proceeding. `discord-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `discord`. It goes red if the `import './discord.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/discord` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
`discord-registration.test.ts` imports the real channel barrel and asserts the
registry contains `discord`. It goes red if the import line is deleted or drifts,
if the barrel fails to evaluate, or if `@chat-adapter/discord` isn't installed
(the import throws) — so it also covers the dependency from step 3. End-to-end
delivery against a real server is verified manually once the service runs.
## Credentials
Discord app setup is human and interactive — these steps are prose, not
directives (no parser can click through the Discord Developer Portal). A recipe
rebuild produces a compiling, registered adapter that cannot receive a message
until they're done.
### Create Discord Bot
1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
@@ -73,25 +80,36 @@ Both must be clean before proceeding. `discord-registration.test.ts` is the one
- Bot Permissions: select `Send Messages`, `Read Message History`, `Add Reactions`, `Attach Files`, `Use Slash Commands`
8. Copy the generated URL and open it in your browser to invite the bot to your server
### Configure environment
### Store the credentials
All three values are required — the adapter will fail to start without `DISCORD_PUBLIC_KEY` and `DISCORD_APPLICATION_ID`.
All three values are required — the adapter will fail to start without
`DISCORD_PUBLIC_KEY` and `DISCORD_APPLICATION_ID`. Capture them, then write them.
`prompt` only *asks* and binds the answer to a name; a separate directive
consumes it — so the same prompts could feed `ncl` or the OneCLI vault instead of
`.env` by swapping only the consumer. Here they go to `.env` (set-if-absent — a
value you've already filled in is never overwritten) and sync to the container:
Add to `.env`:
```bash
DISCORD_BOT_TOKEN=your-bot-token
DISCORD_APPLICATION_ID=your-application-id
DISCORD_PUBLIC_KEY=your-public-key
```nc:prompt bot_token secret
Paste the Bot Token — Bot tab. Click `Reset Token` if you need a new one.
```
```nc:prompt application_id
Paste the Application ID — General Information tab.
```
```nc:prompt public_key
Paste the Public Key — General Information tab.
```
```nc:env-set
DISCORD_BOT_TOKEN={{bot_token}}
DISCORD_APPLICATION_ID={{application_id}}
DISCORD_PUBLIC_KEY={{public_key}}
```
```nc:env-sync
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/manage-channels` to wire this channel to an agent group.
If you're in the middle of `/setup`, return to the setup flow now. Otherwise run
`/manage-channels` to wire this channel to an agent group.
## Channel Info
+4 -6
View File
@@ -54,10 +54,8 @@ Remove the NanoClaw block from your Emacs config (`config.el`, `~/.spacemacs`, o
Reload your config or restart Emacs.
## 5. Remove the messaging group (optional)
## 5. Messaging group (left intact)
To clean up the wired messaging group:
```bash
pnpm exec tsx scripts/q.ts data/v2.db "DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='emacs'); DELETE FROM messaging_groups WHERE channel_type='emacs';"
```
Your wired messaging group and conversation history are **not** removed — you
created them at runtime, not this skill's install. To purge them deliberately,
delete them yourself with `ncl messaging-groups delete <id>`.
+5 -6
View File
@@ -12,14 +12,13 @@ ncl groups config remove-mcp-server --id <group-id> --name calendar
## 2. Remove the `.calendar-mcp` mount from the DB (per group)
There is no `ncl groups config remove-mount` verb yet (tracked in [#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Until it ships, drop the entry via the in-tree wrapper (`scripts/q.ts`):
This is a **host-only / operator** verb — run it host-side. It's idempotent (a no-op if the mount is absent):
```bash
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \
WHERE json_extract(value, '\$.containerPath') != '.calendar-mcp'), \
updated_at = datetime('now') \
WHERE agent_group_id = '<group-id>';"
ncl groups config remove-mount \
--id <group-id> \
--host "$HOME/.calendar-mcp" \
--container .calendar-mcp
```
## 3. Delete the copied test file
+10 -13
View File
@@ -133,6 +133,8 @@ pnpm exec vitest run src/gcal-dockerfile.test.ts
`cp` overwrites in place, so re-running this skill is safe.
**This is the skill's only in-tree integration test.** The Phase 3 `ncl groups config add-mcp-server` and `add-mount` steps are runtime writes to the central DB — they leave no line in the source tree whose deletion a test could catch, so a registration test is structurally inapplicable. They're verified at runtime instead (Phase 5).
### Rebuild the container image
```bash
@@ -160,27 +162,22 @@ Approval behaviour depends on where you run it: from inside an agent's container
### Add the `.calendar-mcp` mount
There is no `ncl groups config add-mount` verb yet (tracked in [#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Until that ships, edit the DB directly via the in-tree wrapper (`scripts/q.ts` `setup/verify.ts:5` codifies that NanoClaw avoids depending on the `sqlite3` CLI binary, so don't shell out to it):
This is a **host-only / operator** verb — it's rejected from inside a container at any `cli_scope`, so run it host-side when you (the operator) apply this skill via `/setup`, `/customize`, or `/manage-mounts`. It's idempotent (skips if the mount is already present).
```bash
GROUP_ID='<group-id>'
HOST_PATH="$HOME/.calendar-mcp"
MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".calendar-mcp", readonly:false}')
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \
updated_at = datetime('now') \
WHERE agent_group_id = '$GROUP_ID';"
ncl groups config add-mount \
--id <group-id> \
--host "$HOME/.calendar-mcp" \
--container .calendar-mcp
```
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is ISO-string everywhere else in the schema, so use `datetime('now')` — not `strftime('%s','now')`, which would silently mix epoch ints into a column of YYYY-MM-DD HH:MM:SS strings.
`--container` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/<relative>`). No `--ro`: the MCP server may rewrite `credentials.json` on token refresh, so the mount must be read-write.
**Switch to `ncl groups config add-mount` once #2395 lands.** Update this skill at that time.
`containerPath` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/<relative>`).
The mount also needs to be in the external mount allowlist (`~/.config/nanoclaw/mount-allowlist.json`) to take effect at spawn — see the Phase 1 "Verify mount allowlist covers the path" step. A container restart (`ncl groups restart`) is needed for the mount to apply.
**Why this can't be `groups/<folder>/container.json`:** post-migration `014-container-configs`, `materializeContainerJson` in `src/container-config.ts` rewrites that file from the DB on every spawn. Anything hand-edited there is silently overwritten on next restart.
**Same-group-as-gmail tip:** if this group already has the gmail MCP + `.gmail-mcp` mount, both coexist — `ncl groups config add-mcp-server` only updates the named entry, and `json_insert` appends to `additional_mounts` without disturbing existing entries.
**Same-group-as-gmail tip:** if this group already has the gmail MCP + `.gmail-mcp` mount, both coexist — `ncl groups config add-mcp-server` only updates the named entry, and `add-mount` appends to `additional_mounts` without disturbing existing entries.
## Phase 4: Build and Restart
+65 -42
View File
@@ -5,63 +5,70 @@ description: Add Google Chat channel integration via Chat SDK.
# Add Google Chat Channel
Adds Google Chat support via the Chat SDK bridge.
Adds Google Chat support via the Chat SDK bridge. NanoClaw doesn't ship channels
in trunk — this skill copies the Google Chat adapter in from the `channels`
branch.
## Install
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
reads the prose and applies them, and a parser can apply them deterministically
from the same document. Every directive is idempotent, so the whole skill is
safe to re-run; anything a parser can't apply falls back to the prose beside it.
NanoClaw doesn't ship channels in trunk. This skill copies the Google Chat adapter in from the `channels` branch.
## Apply
### Pre-flight (idempotent)
### 1. Copy the adapter and its registration test
Skip to **Credentials** if all of these are already in place:
Fetch the `channels` branch and copy the Google Chat adapter and its
registration test into `src/channels/` (overwrite — the branch is canonical):
- `src/channels/gchat.ts` exists
- `src/channels/gchat-registration.test.ts` exists
- `src/channels/index.ts` contains `import './gchat.js';`
- `@chat-adapter/gchat` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```nc:copy from-branch:channels
src/channels/gchat.ts
src/channels/gchat-registration.test.ts
```
### 2. Copy the adapter and its registration test
### 2. Register the adapter
```bash
git show origin/channels:src/channels/gchat.ts > src/channels/gchat.ts
git show origin/channels:src/channels/gchat-registration.test.ts > src/channels/gchat-registration.test.ts
```
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
```nc:append to:src/channels/index.ts
import './gchat.js';
```
### 4. Install the adapter package (pinned)
### 3. Install the adapter package
```bash
pnpm install @chat-adapter/gchat@4.27.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/gchat@4.26.0
```
### 5. Build and validate
### 4. Build and validate
```bash
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
the dependency is installed. Then run the one integration test.
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/gchat-registration.test.ts
```
Both must be clean before proceeding. `gchat-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `gchat`. It goes red if the `import './gchat.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/gchat` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Google Chat space is verified manually once the service is running — see Next Steps and the webhook setup above.
`gchat-registration.test.ts` imports the real channel barrel and asserts the
registry contains `gchat`. It goes red if the import line is deleted or drifts,
if the barrel fails to evaluate, or if `@chat-adapter/gchat` isn't installed (the
import throws) — so it also covers the dependency from step 3. End-to-end
delivery against a real Google Chat space is verified manually once the service
runs — see Credentials and Next Steps.
## Credentials
Google Cloud setup is human and interactive — these steps are prose, not
directives (no parser can click through the Google Cloud Console). A recipe
rebuild produces a compiling, registered adapter that cannot receive a message
until they're done.
> 1. Go to [Google Cloud Console](https://console.cloud.google.com)
> 2. Create or select a project
> 3. Enable the **Google Chat API**
@@ -73,21 +80,35 @@ End-to-end message delivery against a real Google Chat space is verified manuall
> - Grant the Chat Bot role
> - Create a JSON key and download it
### Configure environment
### Store the credentials
Add the service account JSON as a single-line string to `.env`:
Capture the service account JSON, then write it. `prompt` only *asks* and binds
the answer to a name; a separate directive consumes it — so the same prompt
could feed `ncl` or the OneCLI vault instead of `.env` by swapping only the
consumer. Here it goes to `.env` (set-if-absent — a value you've already filled
in is never overwritten) as a single-line string, then syncs to the container:
```bash
GCHAT_CREDENTIALS={"type":"service_account","project_id":"...","private_key":"...","client_email":"..."}
```nc:prompt gchat_credentials secret
Paste the service account JSON as a single line — the key file you downloaded, e.g. `{"type":"service_account","project_id":"...","private_key":"...","client_email":"..."}`.
```
```nc:env-set
GCHAT_CREDENTIALS={{gchat_credentials}}
```
```nc:env-sync
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Webhook server
The Chat SDK bridge automatically starts a shared webhook server on port 3000
(`WEBHOOK_PORT` to change it), handling `/webhook/gchat`. This port must be
publicly reachable for Google Chat to deliver events — it's the HTTP endpoint
URL you set in the Connection settings above. Running locally, expose it with
ngrok (`ngrok http 3000`), a Cloudflare Tunnel, or a reverse proxy on a VPS.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/manage-channels` to wire this channel to an agent group.
If you're in the middle of `/setup`, return to the setup flow now. Otherwise run
`/manage-channels` to wire this channel to an agent group.
## Channel Info
@@ -97,3 +118,5 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group.
- **supports-threads**: yes
- **typical-use**: Interactive chat — team spaces or direct messages
- **default-isolation**: Same agent group for spaces where you're the primary user. Separate agent group for spaces with different teams or sensitive contexts.
</content>
</invoke>
+60 -43
View File
@@ -5,64 +5,68 @@ description: Add GitHub channel integration via Chat SDK. PR and issue comment t
# Add GitHub Channel
Adds GitHub support via the Chat SDK bridge. The agent participates in PR and issue comment threads.
Adds GitHub support via the Chat SDK bridge. The agent participates in PR and
issue comment threads. NanoClaw doesn't ship channels in trunk — this skill
copies the GitHub adapter in from the `channels` branch.
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
reads the prose and applies them, and a parser can apply them deterministically
from the same document. Every directive is idempotent, so the whole skill is
safe to re-run; anything a parser can't apply falls back to the prose beside it.
## Prerequisites
You need a **dedicated GitHub bot account** (not your personal account). The adapter uses this account to post replies and filters out its own messages to avoid loops. Create a free GitHub account for your bot (e.g. `my-org-bot`), then invite it as a collaborator with write access to the repos you want monitored.
## Install
## Apply
NanoClaw doesn't ship channels in trunk. This skill copies the GitHub adapter in from the `channels` branch.
### 1. Copy the adapter
### Pre-flight (idempotent)
Fetch the `channels` branch and copy the GitHub adapter into `src/channels/`
(overwrite — the branch is canonical):
Skip to **Credentials** if all of these are already in place:
- `src/channels/github.ts` exists
- `src/channels/github-registration.test.ts` exists
- `src/channels/index.ts` contains `import './github.js';`
- `@chat-adapter/github` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```nc:copy from-branch:channels
src/channels/github.ts
src/channels/github-registration.test.ts
```
### 2. Copy the adapter and its registration test
### 2. Register the adapter
```bash
git show origin/channels:src/channels/github.ts > src/channels/github.ts
git show origin/channels:src/channels/github-registration.test.ts > src/channels/github-registration.test.ts
```
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
```nc:append to:src/channels/index.ts
import './github.js';
```
### 4. Install the adapter package (pinned)
### 3. Install the adapter package
```bash
pnpm install @chat-adapter/github@4.27.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/github@4.26.0
```
### 5. Build and validate
### 4. Build and validate
```bash
The build guards the typed `createChatSdkBridge(...)` core call and proves the
dependency is installed (the adapter import throws if `@chat-adapter/github`
isn't present):
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/github-registration.test.ts
```
Both must be clean before proceeding. `github-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `github`. It goes red if the `import './github.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/github` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
`github-registration.test.ts` imports the real channel barrel and asserts the
registry contains `github`. It goes red if the import line is deleted or drifts,
if the barrel fails to evaluate, or if `@chat-adapter/github` isn't installed
(the import throws) — so it also covers the dependency from step 3.
End-to-end message delivery against a real GitHub repo is verified manually once the service is running — see Next Steps and the webhook setup above.
End-to-end message delivery against a real GitHub repo is verified manually once
the service is running — see Next Steps and the webhook setup below.
## Credentials
@@ -88,18 +92,31 @@ On each repo (logged in as the repo owner/admin):
### 3. Configure environment
Add to `.env`:
Capture the three values, then write them. `prompt` only *asks* and binds the
answer to a name; a separate directive consumes it — so the same prompts could
feed `ncl` or the OneCLI vault instead of `.env` by swapping only the consumer.
Here they go to `.env` (set-if-absent — a value you've already filled in is
never overwritten) and sync to the container:
```bash
GITHUB_TOKEN=github_pat_...
GITHUB_WEBHOOK_SECRET=your-webhook-secret
GITHUB_BOT_USERNAME=your-bot-username
```nc:prompt github_token secret
Paste the Fine-grained Personal Access Token for the bot account — starts with `github_pat_`.
```
```nc:prompt webhook_secret secret
Paste the webhook secret you generated for the repo webhook(s).
```
```nc:prompt bot_username
Enter the bot account's GitHub username exactly (used for @-mention detection).
```
```nc:env-set
GITHUB_TOKEN={{github_token}}
GITHUB_WEBHOOK_SECRET={{webhook_secret}}
GITHUB_BOT_USERNAME={{bot_username}}
```
```nc:env-sync
```
`GITHUB_BOT_USERNAME` must match the bot account's GitHub username exactly. This is used for @-mention detection — the agent responds when someone writes `@your-bot-username` in a PR or issue comment.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## Wiring
Ask the user: **Is this a private or public repo?**
@@ -111,8 +128,8 @@ Run `/manage-channels` to wire the GitHub channel to an agent group, or insert m
```sql
-- Create messaging group (one per repo)
INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
VALUES ('mg-github-myrepo', 'github', 'github:owner/repo', 'owner/repo', 1, '<policy>', datetime('now'));
INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
VALUES ('mg-github-myrepo', 'github', 'github:owner/repo', 'github', 'owner/repo', 1, '<policy>', datetime('now'));
-- Wire to agent group
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
+7 -7
View File
@@ -19,17 +19,17 @@ ncl groups config remove-mcp-server --id <group-id> --name gmail
## 3. Remove the `.gmail-mcp` mount (per group)
There is no `ncl groups config remove-mount` verb yet ([#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Edit the central DB via the in-tree wrapper (`scripts/q.ts` — NanoClaw avoids depending on the `sqlite3` CLI, `setup/verify.ts:5`). Run from your NanoClaw project root (where `data/v2.db` lives):
Remove the mount with the host-only `ncl groups config remove-mount` verb (operator-only; rejected from inside a container). For each group that had Gmail wired:
```bash
GROUP_ID='<group-id>'
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \
WHERE json_extract(value, '\$.containerPath') != '.gmail-mcp'), \
updated_at = datetime('now') \
WHERE agent_group_id = '$GROUP_ID';"
ncl groups config remove-mount \
--id <group-id> \
--host "$HOME/.gmail-mcp" \
--container .gmail-mcp
```
The verb is idempotent — a no-op if the mount is already absent.
## 4. Remove the Dockerfile install
In `container/Dockerfile`, delete the `ARG GMAIL_MCP_VERSION=...` line and the `pnpm install -g` `RUN` block that installs `@gongrzhe/server-gmail-autoauth-mcp` and `zod-to-json-schema`.
+6 -11
View File
@@ -181,21 +181,16 @@ Approval behaviour depends on where you run it: from inside an agent's container
### Add the `.gmail-mcp` mount
There is no `ncl groups config add-mount` verb yet (tracked in [#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Until that ships, edit the DB directly via the in-tree wrapper (`scripts/q.ts``setup/verify.ts:5` codifies that NanoClaw avoids depending on the `sqlite3` CLI binary, so don't shell out to it):
Register the mount with the host-only `ncl groups config add-mount` verb. For each chosen `<group-id>`:
```bash
GROUP_ID='<group-id>'
HOST_PATH="$HOME/.gmail-mcp"
MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".gmail-mcp", readonly:false}')
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \
updated_at = datetime('now') \
WHERE agent_group_id = '$GROUP_ID';"
ncl groups config add-mount \
--id <group-id> \
--host "$HOME/.gmail-mcp" \
--container .gmail-mcp
```
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is ISO-string everywhere else in the schema, so use `datetime('now')` — not `strftime('%s','now')`, which would silently mix epoch ints into a column of YYYY-MM-DD HH:MM:SS strings.
**Switch to `ncl groups config add-mount` once #2395 lands.** Update this skill at that time.
`--host` is the host path, `--container` is the in-container path (relative, lands at `/workspace/extra/.gmail-mcp`). No `--ro` — the MCP server writes refreshed token state back into the mount. The verb is idempotent (a re-run skips if the mount is already present) and operator-only (host-side; rejected from inside a container), so run it from a host operator shell when applying this skill.
**Why the container path is relative:** `mount-security` rejects absolute `containerPath` values. Additional mounts are prefixed with `/workspace/extra/`, so `containerPath: ".gmail-mcp"` lands at `/workspace/extra/.gmail-mcp`. The MCP server's `GMAIL_OAUTH_PATH` / `GMAIL_CREDENTIALS_PATH` env vars point at that absolute location inside the container.
@@ -8,10 +8,9 @@
* allowedTools: [ ...TOOL_ALLOWLIST, ...Object.keys(this.mcpServers).map(mcpAllowPattern) ]
*
* `mcpAllowPattern` is not exported and the call site lives inside the SDK query options,
* so we assert the derivation structurally. Delete or rename the derivation and this goes
* red — surfacing that `gmail` tools would silently be filtered out despite being registered.
*
* `mcpAllowPattern` itself is exercised directly to prove `gmail` -> `mcp__gmail__*`.
* so the derivation is non-invocable from a test — we guard it structurally. Delete or
* rename either half (the function or the spread into allowedTools) and this goes red,
* surfacing that `gmail` tools would silently be filtered out despite being registered.
*/
import fs from 'fs';
import path from 'path';
@@ -25,11 +24,6 @@ function source(): { sf: ts.SourceFile; text: string } {
return { sf: ts.createSourceFile(p, text, ts.ScriptTarget.Latest, true), text };
}
/** Reimplement the sanitizer the provider applies, to assert the gmail name maps cleanly. */
function expectedPattern(name: string): string {
return `mcp__${name.replace(/[^a-zA-Z0-9_-]/g, '_')}__*`;
}
describe('claude.ts derives MCP allow-patterns from the registered servers', () => {
const { sf, text } = source();
@@ -48,8 +42,4 @@ describe('claude.ts derives MCP allow-patterns from the registered servers', ()
const flat = text.replace(/\s+/g, ' ');
expect(flat).toContain('Object.keys(this.mcpServers).map(mcpAllowPattern)');
});
it('maps a gmail server name to mcp__gmail__*', () => {
expect(expectedPattern('gmail')).toBe('mcp__gmail__*');
});
});
+53 -36
View File
@@ -5,63 +5,71 @@ description: Add iMessage channel integration via Chat SDK. Local (macOS) or rem
# Add iMessage Channel
Adds iMessage support via the Chat SDK bridge. Two modes: local (macOS with Full Disk Access) or remote (Photon API).
Adds iMessage support via the Chat SDK bridge. Two modes: local (macOS with Full
Disk Access) or remote (Photon API). NanoClaw doesn't ship channels in trunk —
this skill copies the iMessage adapter in from the `channels` branch.
## Install
The mechanical steps under **Apply** carry `nc:` directive fences: an agent reads
the prose and applies them, and a parser can apply them deterministically from
the same document. Every directive is idempotent, so the whole skill is safe to
re-run; anything a parser can't apply falls back to the prose beside it.
NanoClaw doesn't ship channels in trunk. This skill copies the iMessage adapter in from the `channels` branch.
## Apply
### Pre-flight (idempotent)
### 1. Copy the adapter
Skip to **Credentials** if all of these are already in place:
Fetch the `channels` branch and copy the iMessage adapter into `src/channels/`
(overwrite — the branch is canonical):
- `src/channels/imessage.ts` exists
- `src/channels/imessage-registration.test.ts` exists
- `src/channels/index.ts` contains `import './imessage.js';`
- `chat-adapter-imessage` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```nc:copy from-branch:channels
src/channels/imessage.ts
src/channels/imessage-registration.test.ts
```
### 2. Copy the adapter and its registration test
### 2. Register the adapter
```bash
git show origin/channels:src/channels/imessage.ts > src/channels/imessage.ts
git show origin/channels:src/channels/imessage-registration.test.ts > src/channels/imessage-registration.test.ts
```
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
```nc:append to:src/channels/index.ts
import './imessage.js';
```
### 4. Install the adapter package (pinned)
### 3. Install the adapter package
```bash
pnpm install chat-adapter-imessage@0.1.1
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
chat-adapter-imessage@0.1.1
```
### 5. Build and validate
### 4. Build and validate
```bash
Build guards the typed `createChatSdkBridge(...)` core call and proves the
dependency is installed (the adapter's top-level `import` from
`chat-adapter-imessage` throws if it isn't):
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/imessage-registration.test.ts
```
Both must be clean before proceeding. `imessage-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `imessage`. It goes red if the `import './imessage.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `chat-adapter-imessage` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
`imessage-registration.test.ts` imports the real channel barrel and asserts the
registry contains `imessage` — it goes red if the import line is deleted or
drifts, if the barrel fails to evaluate, or if `chat-adapter-imessage` isn't
installed (the import throws), so it also covers the dependency from step 3.
End-to-end message delivery against a real iMessage account is verified manually once the service is running — see Next Steps.
End-to-end message delivery against a real iMessage account is verified manually
once the service is running — see Next Steps.
## Credentials
iMessage runs in one of two modes. Mode choice and the Full Disk Access /
Photon walkthrough are human and interactive — these steps stay prose, not
directives.
### Local Mode (macOS)
Requirements: macOS with Full Disk Access granted to the Node.js binary.
@@ -87,14 +95,19 @@ Stop and wait for the user to confirm before continuing.
### Configure environment
**Local mode** -- add to `.env`:
The two modes use different `.env` keys. Write only the keys for the chosen
mode, and remove the opposite mode's keys so a stale value can't confuse the
adapter's factory.
**Local mode** — add to `.env` (and remove `IMESSAGE_SERVER_URL` /
`IMESSAGE_API_KEY` if present):
```bash
IMESSAGE_ENABLED=true
IMESSAGE_LOCAL=true
```
**Remote mode** -- add to `.env`:
**Remote mode** — add to `.env` (and remove `IMESSAGE_ENABLED` if present):
```bash
IMESSAGE_LOCAL=false
@@ -102,7 +115,11 @@ IMESSAGE_SERVER_URL=https://your-photon-server.com
IMESSAGE_API_KEY=your-api-key
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
Once the keys for your mode are written, sync `.env` to the container (the host
mounts `data/env/env`):
```nc:env-sync
```
## Next Steps
+1 -10
View File
@@ -18,16 +18,7 @@ rm -f src/channels/linear.ts src/channels/linear-registration.test.ts
## 2. Remove credentials
Remove the Linear env vars from `.env`, then re-sync to the container:
```bash
LINEAR_CLIENT_ID
LINEAR_CLIENT_SECRET
LINEAR_API_KEY
LINEAR_WEBHOOK_SECRET
LINEAR_BOT_USERNAME
LINEAR_TEAM_KEY
```
Remove `LINEAR_CLIENT_ID`, `LINEAR_CLIENT_SECRET`, `LINEAR_API_KEY`, `LINEAR_WEBHOOK_SECRET`, `LINEAR_BOT_USERNAME`, and `LINEAR_TEAM_KEY` from `.env`, then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
+88 -51
View File
@@ -5,7 +5,15 @@ description: Add Linear channel integration via Chat SDK. Issue comment threads
# Add Linear Channel
Adds Linear support via the Chat SDK bridge. The agent participates in issue comment threads. Every comment on a Linear issue triggers the agent — no @-mention needed.
Adds Linear support via the Chat SDK bridge. The agent participates in issue
comment threads. Every comment on a Linear issue triggers the agent — no
@-mention needed. NanoClaw doesn't ship channels in trunk — this skill copies the
Linear adapter in from the `channels` branch.
The mechanical steps under **Apply** carry `nc:` directive fences: an agent reads
the prose and applies them, and a parser can apply them deterministically from
the same document. Every directive is idempotent, so the whole skill is safe to
re-run; anything a parser can't apply falls back to the prose beside it.
## Prerequisites
@@ -20,61 +28,65 @@ Adds Linear support via the Chat SDK bridge. The agent participates in issue com
**Alternative:** Use a Personal API Key (`LINEAR_API_KEY`) for simpler setup. The agent will post as you, and your own comments will be filtered (other team members' comments still work).
## Install
## Apply
NanoClaw doesn't ship channels in trunk. This skill copies the Linear adapter in from the `channels` branch and wires it into the channel registry. Linear OAuth apps post and read comments under an app identity that can't be @-mentioned, so when you wire the channel in `/manage-channels`, pick an engage mode that responds to plain comments rather than mention-only.
Linear OAuth apps post and read comments under an app identity that can't be
@-mentioned, so when you wire the channel in `/manage-channels`, pick an engage
mode that responds to plain comments rather than mention-only.
### Pre-flight (idempotent)
### 1. Copy the adapter and its registration test
Skip to **Credentials** if all of these are already in place:
Fetch the `channels` branch and copy the Linear adapter and its registration
test into `src/channels/` (overwrite — the branch is canonical):
- `src/channels/linear.ts` exists
- `src/channels/linear-registration.test.ts` exists
- `src/channels/index.ts` contains `import './linear.js';`
- `@chat-adapter/linear` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```nc:copy from-branch:channels
src/channels/linear.ts
src/channels/linear-registration.test.ts
```
### 2. Copy the adapter and its registration test
### 2. Register the adapter
```bash
git show origin/channels:src/channels/linear.ts > src/channels/linear.ts
git show origin/channels:src/channels/linear-registration.test.ts > src/channels/linear-registration.test.ts
```
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into the channel
registry:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
```nc:append to:src/channels/index.ts
import './linear.js';
```
### 4. Install the adapter package (pinned)
### 3. Install the adapter package
```bash
pnpm install @chat-adapter/linear@4.27.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/linear@4.26.0
```
### 5. Build and validate
### 4. Build and validate
```bash
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
the dependency is installed. Then run the one integration test.
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/linear-registration.test.ts
```
Both must be clean before proceeding. `linear-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `linear`. It goes red if the `import './linear.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/linear` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Linear workspace is verified manually once the service is running — see Wiring and Next Steps.
Both must be clean before proceeding. `linear-registration.test.ts` imports the
real channel barrel and asserts the registry contains `linear`. It goes red if
the `import './linear.js';` line is deleted or drifts, if the barrel fails to
evaluate, or if `@chat-adapter/linear` isn't installed (the import throws) — so
it also covers the dependency from step 3. End-to-end message delivery against a
real Linear workspace is verified manually once the service is running — see
Wiring and Next Steps.
## Credentials
Linear app and webhook setup is human and interactive — these steps are prose
(no parser can click through the Linear UI), except the final env write.
### 1. Set up a webhook
1. Go to **Linear Settings** > **API** > **Webhooks** > **New webhook**
@@ -86,27 +98,52 @@ End-to-end message delivery against a real Linear workspace is verified manually
Note: Linear webhook delivery may be delayed 1-5 minutes for new webhooks. This is normal.
### 2. Configure environment
### 2. Store the credentials
Add to `.env`:
Capture the values, then write them. `prompt` only *asks* and binds the answer
to a name; a separate directive consumes it. Here they go to `.env`
(set-if-absent — a value you've already filled in is never overwritten) and sync
to the container.
```bash
# OAuth app (recommended)
LINEAR_CLIENT_ID=your-client-id
LINEAR_CLIENT_SECRET=your-client-secret
Use **either** the OAuth app credentials (recommended) **or** a Personal API key.
For the API-key path, paste `none` at the OAuth prompts and set `LINEAR_API_KEY`
in `.env` by hand (commented in the template below). `LINEAR_BOT_USERNAME` is the
display name for the bot, used for self-message detection when using a Personal
API Key. `LINEAR_TEAM_KEY` is the Linear team key (e.g. `ENG`, `NAN`) — find it
in Linear under Settings > Teams; all issues in this team route to one messaging
group.
# OR Personal API key (simpler, but agent posts as you)
# LINEAR_API_KEY=lin_api_...
LINEAR_WEBHOOK_SECRET=your-webhook-signing-secret
LINEAR_BOT_USERNAME=NanoClaw Bot
LINEAR_TEAM_KEY=ENG
```nc:prompt linear_client_id secret
Paste the OAuth Client ID — Linear Settings > API > OAuth Applications. Paste `none` if using a Personal API key instead.
```
```nc:prompt linear_client_secret secret
Paste the OAuth Client Secret. Paste `none` if using a Personal API key instead.
```
```nc:prompt linear_webhook_secret secret
Paste the webhook signing secret from the webhook you just created.
```
```nc:prompt linear_team_key
Enter the Linear team key (e.g. `ENG`, `NAN`) — Settings > Teams.
```
```nc:prompt linear_bot_username
Enter the bot display name (e.g. `NanoClaw Bot`).
```
```nc:env-set
LINEAR_CLIENT_ID={{linear_client_id}}
LINEAR_CLIENT_SECRET={{linear_client_secret}}
LINEAR_WEBHOOK_SECRET={{linear_webhook_secret}}
LINEAR_TEAM_KEY={{linear_team_key}}
LINEAR_BOT_USERNAME={{linear_bot_username}}
```
```nc:env-sync
```
- `LINEAR_BOT_USERNAME`: display name for the bot (used for self-message detection when using a Personal API Key)
- `LINEAR_TEAM_KEY`: the Linear team key (e.g. `ENG`, `NAN`). Find it in Linear under Settings > Teams. All issues in this team route to one messaging group.
If you went the Personal API key route, add this line to `.env` instead of the
OAuth pair (agent posts as you, your own comments are filtered):
Sync to container: `mkdir -p data/env && cp .env data/env/env`
```bash
LINEAR_API_KEY=lin_api_...
```
## Wiring
@@ -119,8 +156,8 @@ Run `/manage-channels` to wire the Linear channel to an agent group, or insert m
```sql
-- Create messaging group (one per team)
INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'Engineering', 1, 'public', datetime('now'));
INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'linear', 'Engineering', 1, 'public', datetime('now'));
-- Wire to agent group
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
+1 -16
View File
@@ -18,22 +18,7 @@ rm -f src/channels/matrix.ts src/channels/matrix-registration.test.ts
## 2. Remove credentials
Remove the `MATRIX_*` lines from `.env`:
```bash
MATRIX_BASE_URL
MATRIX_USERNAME
MATRIX_PASSWORD
MATRIX_USER_ID
MATRIX_BOT_USERNAME
MATRIX_ACCESS_TOKEN
MATRIX_INVITE_AUTOJOIN
MATRIX_INVITE_AUTOJOIN_ALLOWLIST
MATRIX_RECOVERY_KEY
MATRIX_DEVICE_ID
```
Then re-sync to the container:
Remove the Matrix env vars apply set — `MATRIX_BASE_URL`, `MATRIX_USER_ID`, `MATRIX_BOT_USERNAME`, and whichever auth path you chose (`MATRIX_USERNAME` + `MATRIX_PASSWORD`, or `MATRIX_ACCESS_TOKEN`) — from `.env`, then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
+88 -42
View File
@@ -5,57 +5,53 @@ description: Add Matrix channel integration via Chat SDK. Works with any Matrix
# Add Matrix Channel
Adds Matrix support via the Chat SDK bridge.
Adds Matrix support via the Chat SDK bridge. NanoClaw doesn't ship channels in
trunk — this skill copies the Matrix adapter in from the `channels` branch.
## Install
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
reads the prose and applies them, and a parser can apply them deterministically
from the same document. Every directive is idempotent, so the whole skill is
safe to re-run; anything a parser can't apply falls back to the prose beside it.
NanoClaw doesn't ship channels in trunk. This skill copies the Matrix adapter in from the `channels` branch.
## Apply
### Pre-flight (idempotent)
### 1. Copy the adapter
Skip to **Credentials** if all of these are already in place:
Fetch the `channels` branch and copy the Matrix adapter into `src/channels/`
(overwrite — the branch is canonical):
- `src/channels/matrix.ts` exists
- `src/channels/matrix-registration.test.ts` exists
- `src/channels/index.ts` contains `import './matrix.js';`
- `@beeper/chat-adapter-matrix` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```nc:copy from-branch:channels
src/channels/matrix.ts
src/channels/matrix-registration.test.ts
```
### 2. Copy the adapter and its registration test
### 2. Register the adapter
```bash
git show origin/channels:src/channels/matrix.ts > src/channels/matrix.ts
git show origin/channels:src/channels/matrix-registration.test.ts > src/channels/matrix-registration.test.ts
```
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
```nc:append to:src/channels/index.ts
import './matrix.js';
```
### 4. Install the adapter package (pinned)
### 3. Install the adapter package
```bash
pnpm install @beeper/chat-adapter-matrix@0.2.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`.
The Matrix adapter lives in the `@beeper/` namespace and versions on its own
track (not the `@chat-adapter/*` family), so it carries its own pin:
```nc:dep
@beeper/chat-adapter-matrix@0.2.0
```
### 5. Patch matrix-js-sdk ESM imports
### 4. Patch matrix-js-sdk ESM imports
The adapter's published dist references `matrix-js-sdk/lib/...` without `.js`
extensions, which fails under Node 22 strict ESM resolution. Add the missing
extensions (idempotent — safe to re-run):
extensions (idempotent — safe to re-run). Re-run this after every `pnpm install`
that touches the adapter:
```bash
```nc:run effect:external
node -e '
const fs = require("fs"), path = require("path");
const root = "node_modules/.pnpm";
@@ -69,22 +65,32 @@ node -e '
'
```
Re-run this after every `pnpm install` that touches the adapter.
### 5. Build
### 6. Build and validate
Build guards the typed `createChatSdkBridge(...)` core call the adapter makes
and proves the dependency is installed and the ESM patch took. It also fails if
the `import './matrix.js';` line is missing or the barrel can't evaluate.
```bash
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/matrix-registration.test.ts
```
Both must be clean before proceeding. `matrix-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `matrix`. It goes red if the `import './matrix.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@beeper/chat-adapter-matrix` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
`matrix-registration.test.ts` imports the real channel barrel and asserts the
registry contains `matrix`. It goes red if the import line is deleted or drifts,
if the barrel fails to evaluate, or if `@beeper/chat-adapter-matrix` isn't
installed (the import throws) — so it also covers the dependency from step 3.
End-to-end message delivery against a real Matrix homeserver is verified manually once the service is running — see Next Steps.
End-to-end message delivery against a real Matrix homeserver is verified
manually once the service is running — see Next Steps.
## Credentials
The bot needs its own Matrix account — separate from the user's account. This is required because Matrix cannot send DMs to yourself.
The bot needs its own Matrix account — separate from the user's account. This is
required because Matrix cannot send DMs to yourself. These steps are human and
interactive (no parser can click through Element), so they stay prose.
### Create a bot account
@@ -131,12 +137,52 @@ MATRIX_RECOVERY_KEY=your-recovery-key # Enable E2EE cross-signing
MATRIX_DEVICE_ID=NANOCLAW01 # Stable device ID across restarts
```
### Configure environment
### Store the credentials
Add the chosen env vars to `.env`, then sync:
Capture the values for the auth method you chose, then write them. `prompt` only
*asks* and binds the answer to a name; a separate directive consumes it — so the
same prompts could feed `ncl` or the OneCLI vault instead of `.env` by swapping
only the consumer. The homeserver URL, the bot's user ID, and a display name are
shared across both auth methods:
```bash
mkdir -p data/env && cp .env data/env/env
```nc:prompt base_url
Paste the homeserver base URL, e.g. `https://matrix.org`.
```
```nc:prompt user_id
Paste the bot's full Matrix user ID, e.g. `@andybot:matrix.org`.
```
```nc:prompt bot_username
Paste a display name for the bot, e.g. `Andy`.
```
```nc:env-set
MATRIX_BASE_URL={{base_url}}
MATRIX_USER_ID={{user_id}}
MATRIX_BOT_USERNAME={{bot_username}}
```
For **Option A** capture the bot login, for **Option B** capture the access
token — set only the block matching your chosen method:
```nc:prompt username
Option A only — the bot's login username (the localpart, e.g. `andybot`).
```
```nc:prompt password secret
Option A only — the bot account's password.
```
```nc:env-set
MATRIX_USERNAME={{username}}
MATRIX_PASSWORD={{password}}
```
```nc:prompt access_token secret
Option B only — the access token from Element Settings > Help & About, or from the login API.
```
```nc:env-set
MATRIX_ACCESS_TOKEN={{access_token}}
```
Then sync `.env` into the container:
```nc:env-sync
```
## Next Steps
+69 -45
View File
@@ -5,61 +5,70 @@ description: Add Resend (email) channel integration via Chat SDK.
# Add Resend Email Channel
Connect NanoClaw to email via Resend for async email conversations.
Connect NanoClaw to email via Resend for async email conversations. NanoClaw
doesn't ship channels in trunk — this skill copies the Resend adapter in from the
`channels` branch.
## Install
The mechanical steps under **Apply** carry `nc:` directive fences: an agent reads
the prose and applies them, and a parser can apply them deterministically from
the same document. Every directive is idempotent, so the whole skill is safe to
re-run; anything a parser can't apply falls back to the prose beside it.
NanoClaw doesn't ship channels in trunk. This skill copies the Resend adapter in from the `channels` branch.
## Apply
### Pre-flight (idempotent)
### 1. Copy the adapter
Skip to **Credentials** if all of these are already in place:
Fetch the `channels` branch and copy the Resend adapter into `src/channels/`
(overwrite — the branch is canonical):
- `src/channels/resend.ts` exists
- `src/channels/resend-registration.test.ts` exists
- `src/channels/index.ts` contains `import './resend.js';`
- `@resend/chat-sdk-adapter` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```nc:copy from-branch:channels
src/channels/resend.ts
src/channels/resend-registration.test.ts
```
### 2. Copy the adapter and its registration test
### 2. Register the adapter
```bash
git show origin/channels:src/channels/resend.ts > src/channels/resend.ts
git show origin/channels:src/channels/resend-registration.test.ts > src/channels/resend-registration.test.ts
```
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
```nc:append to:src/channels/index.ts
import './resend.js';
```
### 4. Install the adapter package (pinned)
### 3. Install the adapter package
```bash
pnpm install @resend/chat-sdk-adapter@0.1.1
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@resend/chat-sdk-adapter@0.1.1
```
### 5. Build and validate
### 4. Build and validate
```bash
Build guards the typed `createChatSdkBridge(...)` core call and proves the
dependency is installed (the adapter imports `@resend/chat-sdk-adapter`; if it
isn't installed the barrel throws). End-to-end email delivery against a real
domain is verified manually once the service runs.
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/resend-registration.test.ts
```
Both must be clean before proceeding. `resend-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `resend`. It goes red if the `import './resend.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@resend/chat-sdk-adapter` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
`resend-registration.test.ts` imports the real channel barrel and asserts the
registry contains `resend`. It goes red if the import line is deleted or drifts,
if the barrel fails to evaluate, or if `@resend/chat-sdk-adapter` isn't installed
(the import throws) — so it also covers the dependency from step 3.
## Credentials
Resend account and domain setup is human and interactive — these steps are
prose, not directives (no parser can verify a sending domain or click through the
Resend UI). A recipe rebuild produces a compiling, registered adapter that cannot
receive a message until they're done.
1. Go to [resend.com](https://resend.com) and create an account.
2. Add and verify your sending domain.
3. Go to **API Keys** and create a new key.
@@ -69,30 +78,45 @@ Both must be clean before proceeding. `resend-registration.test.ts` is the one i
- Events: select **email.received**.
- Copy the signing secret.
### Configure environment
### Store the credentials
Add to `.env`:
Capture the secrets, then write them. `prompt` only *asks* and binds the answer
to a name; a separate directive consumes it — so the same prompts could feed
`ncl` or the OneCLI vault instead of `.env` by swapping only the consumer. Here
they go to `.env` (set-if-absent — a value you've already filled in is never
overwritten) and sync to the container:
```bash
RESEND_API_KEY=re_...
RESEND_FROM_ADDRESS=bot@yourdomain.com
RESEND_FROM_NAME=NanoClaw
RESEND_WEBHOOK_SECRET=your-webhook-secret
```nc:prompt api_key secret
Paste the Resend API key — API Keys, starts with `re_`.
```
```nc:prompt webhook_secret secret
Paste the webhook signing secret — Webhooks, the value you copied above.
```
```nc:prompt from_address
The bot's sending email address on your verified domain (e.g. `bot@yourdomain.com`).
```
```nc:prompt from_name
The display name to send as (e.g. `NanoClaw`).
```
```nc:env-set
RESEND_API_KEY={{api_key}}
RESEND_FROM_ADDRESS={{from_address}}
RESEND_FROM_NAME={{from_name}}
RESEND_WEBHOOK_SECRET={{webhook_secret}}
```
```nc:env-sync
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/manage-channels` to wire this channel to an agent group.
If you're in the middle of `/setup`, return to the setup flow now. Otherwise run
`/manage-channels` to wire this channel to an agent group.
## Channel Info
- **type**: `resend`
- **terminology**: Resend handles email. Each email thread (identified by subject/In-Reply-To headers) is a separate conversation. The "from address" is the bot's identity.
- **how-to-find-id**: The platform ID is the from email address (e.g. `bot@yourdomain.com`). Each sender's email thread becomes its own conversation.
- **supports-threads**: yes (via email threading headers -- replies to the same thread stay together)
- **supports-threads**: no (the adapter sets `supportsThreads: false`; replies still thread via email headers, but the router does not treat threads as the primary conversation unit)
- **typical-use**: Async communication -- email conversations with longer response expectations
- **default-isolation**: Same agent group if you want your agent to handle email alongside other channels. Separate agent group if email contains sensitive correspondence that shouldn't be accessible from other channels.
+5 -11
View File
@@ -4,21 +4,15 @@ Idempotent — safe to run even if some steps were never applied. Run Steps 1
## 1. Remove the mount from the container config
Read the current mounts, drop the entry whose `containerPath` is `/usr/local/bin/rtk`, and write the rest back.
Remove the rtk mount with the host-only `remove-mount` verb. It is idempotent — a no-op if the mount isn't present:
```bash
pnpm exec tsx scripts/q.ts data/v2.db \
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
ncl groups config remove-mount --id <group-id> \
--host ~/.local/bin/rtk \
--container /usr/local/bin/rtk
```
Write the filtered array (omit any entry with `"containerPath":"/usr/local/bin/rtk"`):
```bash
pnpm exec tsx scripts/q.ts data/v2.db \
"UPDATE container_configs SET additional_mounts = '<filtered-json>' WHERE agent_group_id = '<group-id>'"
```
If no rtk entry is present, leave the array as-is.
This verb is operator-only and runs host-side; it is rejected from inside a container.
## 2. Remove the PreToolUse hook from settings.json
+15 -21
View File
@@ -13,6 +13,10 @@ Install [rtk](https://github.com/rtk-ai/rtk) — a CLI proxy delivering 6090%
- `~/.local/bin/rtk` mounted read-only at `/usr/local/bin/rtk` inside the target agent group's containers
- `PreToolUse` hook in the agent group's `settings.json` so every Bash call is automatically filtered through rtk — no CLAUDE.md instructions needed
## Integration tests
This skill has **no in-tree integration test** by design. Its only functional reach-ins are runtime operator actions — the host-only `ncl groups config add-mount` (Step 3) and the `settings.json` `PreToolUse` hook write (Step 4) — neither of which leaves a line in the source tree whose deletion a test could catch. There are no package dependencies or Dockerfile edits to guard either. Conformance is idempotent apply + `REMOVE.md`; the mount and hook are verified at runtime (see Verify).
## Step 1 — Install rtk on the host
```bash
@@ -43,33 +47,24 @@ Note the group ID (e.g. `ag-1776342942165-ptgddd`). Repeat Steps 35 for each
## Step 3 — Mount rtk into the container config
`additional_mounts` is a JSON array column on `container_configs`. Read the current value, merge in the rtk entry, and write the merged array back.
Read current mounts first:
Mount the host rtk binary read-only into the container with the host-only `add-mount` verb. It is idempotent — re-running skips the entry if it is already present:
```bash
pnpm exec tsx scripts/q.ts data/v2.db \
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
ncl groups config add-mount --id <group-id> \
--host ~/.local/bin/rtk \
--container /usr/local/bin/rtk \
--ro
```
Build the merged array: keep every existing entry, drop any entry whose `containerPath` is `/usr/local/bin/rtk` (so re-running replaces rather than duplicates), then add the rtk entry:
This verb is operator-only and runs host-side (via `/setup`, `/customize`, or `/manage-mounts`); it is rejected from inside a container.
```json
{"hostPath":"/home/<user>/.local/bin/rtk","containerPath":"/usr/local/bin/rtk","readonly":true}
```
Write the merged array back:
```bash
pnpm exec tsx scripts/q.ts data/v2.db \
"UPDATE container_configs SET additional_mounts = '<merged-json>' WHERE agent_group_id = '<group-id>'"
```
The host root (`~/.local/bin`) must also be in the external mount allowlist at `~/.config/nanoclaw/mount-allowlist.json` for the mount to take effect at spawn. Add it there if it isn't already.
Verify:
```bash
pnpm exec tsx scripts/q.ts data/v2.db \
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
ncl groups config get --id <group-id>
# Look for the /usr/local/bin/rtk mount
```
## Step 4 — Add the PreToolUse hook to settings.json
@@ -120,9 +115,8 @@ Then ask the agent to run `git status` or any other supported command. rtk inter
Mount wasn't applied or container wasn't restarted:
```bash
pnpm exec tsx scripts/q.ts data/v2.db \
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
# Look for entry with /usr/local/bin/rtk
ncl groups config get --id <group-id>
# Look for the /usr/local/bin/rtk mount
ncl groups restart --id <group-id>
```
+74 -69
View File
@@ -5,114 +5,119 @@ description: Add Slack channel integration via Chat SDK.
# Add Slack Channel
Adds Slack support via the Chat SDK bridge.
Adds Slack support via the Chat SDK bridge. NanoClaw doesn't ship channels in
trunk — this skill copies the Slack adapter in from the `channels` branch.
## Install
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
reads the prose and applies them, and a parser can apply them deterministically
from the same document. Every directive is idempotent, so the whole skill is
safe to re-run; anything a parser can't apply falls back to the prose beside it.
NanoClaw doesn't ship channels in trunk. This skill copies the Slack adapter in from the `channels` branch.
## Apply
### Pre-flight (idempotent)
### 1. Copy the adapter and its registration test
Skip to **Credentials** if all of these are already in place:
Fetch the `channels` branch and copy the Slack adapter and its registration test
into `src/channels/` (overwrite — the branch is canonical):
- `src/channels/slack.ts` exists
- `src/channels/slack-registration.test.ts` exists
- `src/channels/index.ts` contains `import './slack.js';`
- `@chat-adapter/slack` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```nc:copy from-branch:channels
src/channels/slack.ts
src/channels/slack-registration.test.ts
```
### 2. Copy the adapter and its registration test
### 2. Register the adapter
```bash
git show origin/channels:src/channels/slack.ts > src/channels/slack.ts
git show origin/channels:src/channels/slack-registration.test.ts > src/channels/slack-registration.test.ts
```
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
```nc:append to:src/channels/index.ts
import './slack.js';
```
### 4. Install the adapter package (pinned)
### 3. Install the adapter package
```bash
pnpm install @chat-adapter/slack@4.27.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/slack@4.26.0
```
### 5. Build and validate
### 4. Build and validate
```bash
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
the dependency is installed. Then run the one integration test.
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/slack-registration.test.ts
```
Both must be clean before proceeding. `slack-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `slack`. It goes red if the `import './slack.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/slack` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Slack workspace is verified manually once the service is running — see Next Steps and the webhook setup above.
`slack-registration.test.ts` imports the real channel barrel and asserts the
registry contains `slack`. It goes red if the import line is deleted or drifts,
if the barrel fails to evaluate, or if `@chat-adapter/slack` isn't installed (the
import throws) — so it also covers the dependency from step 3. End-to-end
delivery against a real workspace is verified manually once the service runs.
## Credentials
### Create Slack App
Slack app setup is human and interactive — these steps are prose, not directives
(no parser can click through the Slack UI). A recipe rebuild produces a
compiling, registered adapter that cannot receive a message until they're done.
1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** > **From scratch**
2. Name it (e.g., "NanoClaw") and select your workspace
3. Go to **OAuth & Permissions** and add Bot Token Scopes:
- `chat:write`, `im:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`, `files:read`, `files:write`
4. Click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`)
5. Go to **Basic Information** and copy the **Signing Secret**
### Create the Slack app
1. Go to [api.slack.com/apps](https://api.slack.com/apps) → **Create New App** → **From scratch**.
2. Name it (e.g. "NanoClaw") and select your workspace.
3. **OAuth & Permissions** → add Bot Token Scopes: `chat:write`, `im:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`, `files:read`, `files:write`.
4. **Install to Workspace**, then copy the **Bot User OAuth Token** (`xoxb-…`).
5. **Basic Information** → copy the **Signing Secret**.
### Enable DMs
6. Go to **App Home** and enable the **Messages Tab**
7. Check **"Allow users to send Slash commands and messages from the messages tab"**
6. **App Home** → enable the **Messages Tab**.
7. Check **"Allow users to send Slash commands and messages from the messages tab."**
### Event Subscriptions
### Event Subscriptions & Interactivity
8. Go to **Event Subscriptions** and toggle **Enable Events**
9. Set the **Request URL** to `https://your-domain/webhook/slack` — Slack will send a verification challenge; it must pass before you can save
10. Under **Subscribe to bot events**, add:
- `message.channels`, `message.groups`, `message.im`, `app_mention`
11. Click **Save Changes**
8. **Event Subscriptions** → **Enable Events**. Set the **Request URL** to your public `https://your-domain/webhook/slack` (see Webhook server); Slack sends a challenge that must pass before you can save.
9. Under **Subscribe to bot events**, add `message.channels`, `message.groups`, `message.im`, `app_mention`. **Save Changes**.
10. **Interactivity & Shortcuts** → toggle **Interactivity** on, set the same Request URL, **Save Changes**, then **reinstall** the app when Slack prompts.
### Interactivity
### Store the credentials
12. Go to **Interactivity & Shortcuts** and toggle **Interactivity** on
13. Set the **Request URL** to the same `https://your-domain/webhook/slack`
14. Click **Save Changes**
15. Slack will show a banner asking you to **reinstall the app** — click it to apply the new settings
Capture the two values, then write them. `prompt` only *asks* and binds the
answer to a name; a separate directive consumes it — so the same prompts could
feed `ncl` or the OneCLI vault instead of `.env` by swapping only the consumer.
Here they go to `.env` (set-if-absent — a value you've already filled in is
never overwritten) and sync to the container:
### Configure environment
Add to `.env`:
```bash
SLACK_BOT_TOKEN=xoxb-your-bot-token
SLACK_SIGNING_SECRET=your-signing-secret
```nc:prompt bot_token secret
Paste the Bot User OAuth Token — OAuth & Permissions, starts with `xoxb-`.
```
```nc:prompt signing_secret secret
Paste the Signing Secret — Basic Information.
```
```nc:env-set
SLACK_BOT_TOKEN={{bot_token}}
SLACK_SIGNING_SECRET={{signing_secret}}
```
```nc:env-sync
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Webhook server
The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/webhook/slack` for Slack and other webhook-based adapters. This port must be publicly reachable from the internet for Slack to deliver events.
If running locally, discuss options for exposing the server — e.g. ngrok (`ngrok http 3000`), Cloudflare Tunnel, or a reverse proxy on a VPS. The resulting public URL becomes the base for `https://your-domain/webhook/slack`.
The Chat SDK bridge automatically starts a shared webhook server on port 3000
(`WEBHOOK_PORT` to change it), handling `/webhook/slack`. This port must be
publicly reachable for Slack to deliver events. Running locally, expose it with
ngrok (`ngrok http 3000`), a Cloudflare Tunnel, or a reverse proxy on a VPS —
the resulting public URL is the base for the Request URL above.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/manage-channels` to wire this channel to an agent group.
If you're in the middle of `/setup`, return to the setup flow now. Otherwise run
`/manage-channels` to wire this channel to an agent group.
## Channel Info
+1 -8
View File
@@ -18,14 +18,7 @@ rm -f src/channels/teams.ts src/channels/teams-registration.test.ts
## 2. Remove credentials
Remove the `TEAMS_*` lines from `.env`, then re-sync to the container:
```bash
TEAMS_APP_ID
TEAMS_APP_PASSWORD
TEAMS_APP_TENANT_ID
TEAMS_APP_TYPE
```
Remove `TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`, `TEAMS_APP_TENANT_ID`, and `TEAMS_APP_TYPE` from `.env`, then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
+65 -44
View File
@@ -5,64 +5,69 @@ description: Add Microsoft Teams channel integration via Chat SDK.
# Add Microsoft Teams Channel
Connect NanoClaw to Microsoft Teams for interactive chat in team channels, group chats, and direct messages.
Connect NanoClaw to Microsoft Teams for interactive chat in team channels, group
chats, and direct messages. NanoClaw doesn't ship channels in trunk — this skill
copies the Teams adapter in from the `channels` branch.
## Install
The mechanical steps under **Apply** carry `nc:` directive fences: an agent reads
the prose and applies them, and a parser can apply them deterministically from
the same document. Every directive is idempotent, so the whole skill is safe to
re-run; anything a parser can't apply falls back to the prose beside it.
NanoClaw doesn't ship channels in trunk. This skill copies the Teams adapter in from the `channels` branch.
## Apply
### Pre-flight (idempotent)
### 1. Copy the adapter
Skip to **Credentials** if all of these are already in place:
Fetch the `channels` branch and copy the Teams adapter into `src/channels/`
(overwrite — the branch is canonical):
- `src/channels/teams.ts` exists
- `src/channels/teams-registration.test.ts` exists
- `src/channels/index.ts` contains `import './teams.js';`
- `@chat-adapter/teams` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```nc:copy from-branch:channels
src/channels/teams.ts
src/channels/teams-registration.test.ts
```
### 2. Copy the adapter and its registration test
### 2. Register the adapter
```bash
git show origin/channels:src/channels/teams.ts > src/channels/teams.ts
git show origin/channels:src/channels/teams-registration.test.ts > src/channels/teams-registration.test.ts
```
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
```nc:append to:src/channels/index.ts
import './teams.js';
```
### 4. Install the adapter package (pinned)
### 3. Install the adapter package
```bash
pnpm install @chat-adapter/teams@4.27.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/teams@4.26.0
```
### 5. Build and validate
### 4. Build and validate
```bash
Build guards the typed `createChatSdkBridge(...)` core call and proves the
dependency is installed — the adapter import throws if `@chat-adapter/teams`
isn't there, so the barrel fails to evaluate and the build goes red.
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/teams-registration.test.ts
```
Both must be clean before proceeding. `teams-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `teams`. It goes red if the `import './teams.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/teams` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Teams workspace is verified manually once the service is running — see Next Steps and the webhook setup above.
`teams-registration.test.ts` imports the real channel barrel and asserts the
registry contains `teams` — it goes red if the import line is deleted or drifts,
if the barrel fails to evaluate, or if `@chat-adapter/teams` isn't installed (the
import throws), so it also covers the dependency from step 3. End-to-end message
delivery against a real Teams workspace is verified manually once the service is
running — see Credentials and the webhook setup below.
## Credentials
Two paths — manual (Azure Portal) or auto (Teams CLI).
Two paths — manual (Azure Portal) or auto (Teams CLI). Teams app setup is human
and interactive — these steps are prose, not directives (no parser can click
through the Azure or Teams UI).
### Auto: Teams CLI
@@ -220,17 +225,33 @@ By default, the bot only receives messages when @-mentioned. To receive all mess
### Configure environment
Add to `.env`:
Capture the credentials, then write them. `prompt` only *asks* and binds the
answer to a name; a separate directive consumes it — so the same prompts could
feed `ncl` or the OneCLI vault instead of `.env` by swapping only the consumer.
Here they go to `.env` (set-if-absent — a value you've already filled in is never
overwritten) and sync to the container. `TEAMS_APP_TENANT_ID` is required only
for Single Tenant apps; leave it blank for Multi Tenant.
```bash
TEAMS_APP_ID=your-app-id
TEAMS_APP_PASSWORD=your-client-secret
# For Single Tenant only:
TEAMS_APP_TENANT_ID=your-tenant-id
TEAMS_APP_TYPE=SingleTenant
```nc:prompt app_id
Paste the Application (client) ID — Azure App Registration Overview page (maps to TEAMS_APP_ID).
```
```nc:prompt app_password secret
Paste the client secret Value — Certificates & secrets (maps to TEAMS_APP_PASSWORD; shown only once).
```
```nc:prompt app_type
Enter the app type — `SingleTenant` or `MultiTenant` (must match your Azure Bot / App Registration).
```
```nc:prompt app_tenant_id
Paste the Directory (tenant) ID — Azure App Registration Overview page (TEAMS_APP_TENANT_ID; Single Tenant only, leave blank for Multi Tenant).
```
```nc:env-set
TEAMS_APP_ID={{app_id}}
TEAMS_APP_PASSWORD={{app_password}}
TEAMS_APP_TYPE={{app_type}}
TEAMS_APP_TENANT_ID={{app_tenant_id}}
```
```nc:env-sync
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Webhook server
+69 -48
View File
@@ -5,77 +5,91 @@ description: Add Telegram channel integration via Chat SDK.
# Add Telegram Channel
Adds Telegram bot support via the Chat SDK bridge.
Adds Telegram bot support via the Chat SDK bridge. NanoClaw doesn't ship
channels in trunk — this skill copies the Telegram adapter, its
formatting/pairing helpers, their tests, and the `pair-telegram` setup step in
from the `channels` branch.
## Install
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
reads the prose and applies them, and a parser can apply them deterministically
from the same document. Every directive is idempotent, so the whole skill is
safe to re-run; anything a parser can't apply falls back to the prose beside it.
NanoClaw doesn't ship channels in trunk. This skill copies the Telegram adapter, its formatting/pairing helpers, their tests, and the `pair-telegram` setup step in from the `channels` branch.
## Apply
### Pre-flight (idempotent)
### 1. Copy the adapter, helpers, tests, and setup step
Skip to **Credentials** if all of these are already in place:
Fetch the `channels` branch and copy the Telegram adapter, its pairing and
markdown-sanitize helpers (with their tests), and the `pair-telegram` setup step
into place (overwrite — the branch is canonical):
- `src/channels/telegram.ts`, `telegram-pairing.ts`, `telegram-markdown-sanitize.ts` (and their `.test.ts` siblings) all exist
- `src/channels/telegram-registration.test.ts` exists
- `src/channels/index.ts` contains `import './telegram.js';`
- `setup/pair-telegram.ts` exists and `setup/index.ts`'s `STEPS` map contains `'pair-telegram':`
- `@chat-adapter/telegram` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```nc:copy from-branch:channels
src/channels/telegram.ts
src/channels/telegram-pairing.ts
src/channels/telegram-pairing.test.ts
src/channels/telegram-markdown-sanitize.ts
src/channels/telegram-markdown-sanitize.test.ts
src/channels/telegram-registration.test.ts
setup/pair-telegram.ts
```
### 2. Copy the adapter, helpers, tests, registration test, and setup step
### 2. Register the adapter
```bash
git show origin/channels:src/channels/telegram.ts > src/channels/telegram.ts
git show origin/channels:src/channels/telegram-registration.test.ts > src/channels/telegram-registration.test.ts
git show origin/channels:src/channels/telegram-pairing.ts > src/channels/telegram-pairing.ts
git show origin/channels:src/channels/telegram-pairing.test.ts > src/channels/telegram-pairing.test.ts
git show origin/channels:src/channels/telegram-markdown-sanitize.ts > src/channels/telegram-markdown-sanitize.ts
git show origin/channels:src/channels/telegram-markdown-sanitize.test.ts > src/channels/telegram-markdown-sanitize.test.ts
git show origin/channels:setup/pair-telegram.ts > setup/pair-telegram.ts
```
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if already present):
```typescript
```nc:append to:src/channels/index.ts
import './telegram.js';
```
### 4. Register the setup step
### 3. Register the setup step
In `setup/index.ts`, add this entry to the `STEPS` map (right after the `register` line is fine; skip if already present):
Add the `pair-telegram` loader to the `STEPS` map in `setup/index.ts`, inside the
dormant marker region (skipped if already present — `pair-telegram` ships in core,
so this idempotent-skips on a normal install, but is expressed for a
clean-upstream rebuild):
```typescript
```nc:append to:setup/index.ts at:nanoclaw:setup-steps
'pair-telegram': () => import('./pair-telegram.js'),
```
### 5. Install the adapter package (pinned)
### 4. Install the adapter package
```bash
pnpm install @chat-adapter/telegram@4.27.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/telegram@4.26.0
```
### 6. Build and validate
### 5. Build and validate
```bash
Build guards the typed `createChatSdkBridge(...)` core call and proves the
dependency is installed — it goes red if the `import './telegram.js';` line is
deleted or drifts, if the barrel fails to evaluate, or if
`@chat-adapter/telegram` isn't installed (the import throws):
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/telegram-registration.test.ts
```
Both must be clean before proceeding. `telegram-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `telegram`. It goes red if the `import './telegram.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/telegram` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 5. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
`telegram-registration.test.ts` imports the real channel barrel and asserts the
registry contains `telegram` — it goes red if the `import './telegram.js';` line
is deleted or drifts, if the barrel fails to evaluate, or if
`@chat-adapter/telegram` isn't installed (the import throws), so it also covers
the dependency from step 4.
End-to-end message delivery against a real Telegram bot is verified manually once the service is running — see Next Steps and the pairing flow in Channel Info.
End-to-end message delivery against a real Telegram bot is verified manually once
the service is running — see Next Steps and the pairing flow in Channel Info.
## Credentials
Bot creation in Telegram is human and interactive — these steps are prose, not
directives (no parser can click through BotFather). A recipe rebuild produces a
compiling, registered adapter that cannot receive a message until they're done.
### Create Telegram Bot
1. Open Telegram and search for `@BotFather`
@@ -89,15 +103,22 @@ End-to-end message delivery against a real Telegram bot is verified manually onc
1. Open `@BotFather` > `/mybots` > select your bot
2. **Bot Settings** > **Group Privacy** > **Turn off**
### Configure environment
### Store the credentials
Add to `.env`:
Capture the bot token, then write it. `prompt` only *asks* and binds the answer
to a name; a separate directive consumes it — so the same prompt could feed `ncl`
or the OneCLI vault instead of `.env` by swapping only the consumer. Here it goes
to `.env` (set-if-absent — a value you've already filled in is never overwritten)
and syncs to the container:
```bash
TELEGRAM_BOT_TOKEN=your-bot-token
```nc:prompt bot_token secret
Paste the bot token from BotFather (looks like `123456:ABC-DEF...`).
```
```nc:env-set
TELEGRAM_BOT_TOKEN={{bot_token}}
```
```nc:env-sync
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## Next Steps
+75 -47
View File
@@ -5,85 +5,113 @@ description: Add Webex channel integration via Chat SDK.
# Add Webex Channel
Adds Cisco Webex support via the Chat SDK bridge.
Adds Cisco Webex support via the Chat SDK bridge. NanoClaw doesn't ship channels
in trunk — this skill copies the Webex adapter in from the `channels` branch.
## Install
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
reads the prose and applies them, and a parser can apply them deterministically
from the same document. Every directive is idempotent, so the whole skill is
safe to re-run; anything a parser can't apply falls back to the prose beside it.
NanoClaw doesn't ship channels in trunk. This skill copies the Webex adapter in from the `channels` branch.
## Apply
### Pre-flight (idempotent)
### 1. Copy the adapter and its registration test
Skip to **Credentials** if all of these are already in place:
Fetch the `channels` branch and copy the Webex adapter and its registration test
into `src/channels/` (overwrite — the branch is canonical):
- `src/channels/webex.ts` exists
- `src/channels/webex-registration.test.ts` exists
- `src/channels/index.ts` contains `import './webex.js';`
- `@bitbasti/chat-adapter-webex` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```nc:copy from-branch:channels
src/channels/webex.ts
src/channels/webex-registration.test.ts
```
### 2. Copy the adapter and its registration test
### 2. Register the adapter
```bash
git show origin/channels:src/channels/webex.ts > src/channels/webex.ts
git show origin/channels:src/channels/webex-registration.test.ts > src/channels/webex-registration.test.ts
```
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
```nc:append to:src/channels/index.ts
import './webex.js';
```
### 4. Install the adapter package (pinned)
### 3. Install the adapter package
```bash
pnpm install @bitbasti/chat-adapter-webex@0.1.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`.
The Webex adapter ships under the third-party `@bitbasti/*` namespace, not
`@chat-adapter/*`, so it carries its own version line (`0.1.0`) rather than
tracking the chat core version:
```nc:dep
@bitbasti/chat-adapter-webex@0.1.0
```
### 5. Build and validate
### 4. Build and validate
```bash
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
the dependency is installed. Then run the one integration test.
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/webex-registration.test.ts
```
Both must be clean before proceeding. `webex-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `webex`. It goes red if the `import './webex.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@bitbasti/chat-adapter-webex` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Webex space is verified manually once the service is running — see Next Steps and the webhook setup above.
`webex-registration.test.ts` imports the real channel barrel and asserts the
registry contains `webex`. It goes red if the import line is deleted or drifts,
if the barrel fails to evaluate, or if `@bitbasti/chat-adapter-webex` isn't
installed (the import throws) — so it also covers the dependency from step 3.
End-to-end delivery against a real Webex space is verified manually once the
service runs — see the webhook setup below.
## Credentials
1. Go to [developer.webex.com](https://developer.webex.com/my-apps/new/bot) and create a new bot
2. Copy the **Bot Access Token**
Webex bot setup is human and interactive — these steps are prose, not directives
(no parser can click through the Webex Developer Portal). A recipe rebuild
produces a compiling, registered adapter that cannot receive a message until
they're done.
### Create the Webex bot
1. Go to [developer.webex.com](https://developer.webex.com/my-apps/new/bot) and create a new bot.
2. Copy the **Bot Access Token**.
3. Set up a webhook:
- Use the Webex API or Developer Portal to create a webhook pointing to `https://your-domain/webhook/webex`
- Set a webhook secret for signature verification
- Use the Webex API or Developer Portal to create a webhook pointing to `https://your-domain/webhook/webex`.
- Set a webhook secret for signature verification.
### Configure environment
### Store the credentials
Add to `.env`:
Capture the two values, then write them. `prompt` only *asks* and binds the
answer to a name; a separate directive consumes it — so the same prompts could
feed `ncl` or the OneCLI vault instead of `.env` by swapping only the consumer.
Here they go to `.env` (set-if-absent — a value you've already filled in is
never overwritten) and sync to the container:
```bash
WEBEX_BOT_TOKEN=your-bot-token
WEBEX_WEBHOOK_SECRET=your-webhook-secret
```nc:prompt bot_token secret
Paste the Bot Access Token — from the Webex bot you created.
```
```nc:prompt webhook_secret secret
Paste the webhook secret you set for signature verification.
```
```nc:env-set
WEBEX_BOT_TOKEN={{bot_token}}
WEBEX_WEBHOOK_SECRET={{webhook_secret}}
```
```nc:env-sync
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Webhook server
The Chat SDK bridge automatically starts a shared webhook server on port 3000
(`WEBHOOK_PORT` to change it), handling `/webhook/webex`. This port must be
publicly reachable for Webex to deliver events. Running locally, expose it with
ngrok (`ngrok http 3000`), a Cloudflare Tunnel, or a reverse proxy on a VPS —
the resulting public URL is the base for the webhook URL above.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/manage-channels` to wire this channel to an agent group.
If you're in the middle of `/setup`, return to the setup flow now. Otherwise run
`/manage-channels` to wire this channel to an agent group.
## Channel Info
+5 -9
View File
@@ -36,16 +36,12 @@ pnpm uninstall wechat-ilink-client
rm -rf data/wechat
```
## 5. Remove DB wiring
The channel's messaging groups, wirings, and conversation history are **left
intact** — you created those at runtime (wiring + use), not this skill's install,
so removal doesn't touch them. To purge them deliberately, delete them yourself
with `ncl messaging-groups delete <id>`.
```sql
-- Remove any sessions first (foreign key)
DELETE FROM sessions WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type = 'wechat');
DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type = 'wechat');
DELETE FROM messaging_groups WHERE channel_type = 'wechat';
```
## 6. Rebuild and restart
## 5. Rebuild and restart
Run from your NanoClaw project root:
+76 -40
View File
@@ -6,62 +6,71 @@ description: Add WhatsApp Business Cloud API channel via Chat SDK. Official Meta
# Add WhatsApp Cloud API Channel
Connect NanoClaw to WhatsApp via the official Meta WhatsApp Business Cloud API.
NanoClaw doesn't ship channels in trunk — this skill copies the WhatsApp Cloud
adapter in from the `channels` branch.
## Install
The mechanical steps under **Apply** carry `nc:` directive fences: an agent reads
the prose and applies them, and a parser can apply them deterministically from
the same document. Every directive is idempotent, so the whole skill is safe to
re-run; anything a parser can't apply falls back to the prose beside it.
NanoClaw doesn't ship channels in trunk. This skill copies the WhatsApp Cloud adapter in from the `channels` branch.
## Apply
### Pre-flight (idempotent)
### 1. Copy the adapter
Skip to **Credentials** if all of these are already in place:
Fetch the `channels` branch and copy the WhatsApp Cloud adapter into
`src/channels/` (overwrite — the branch is canonical):
- `src/channels/whatsapp-cloud.ts` exists
- `src/channels/whatsapp-cloud-registration.test.ts` exists
- `src/channels/index.ts` contains `import './whatsapp-cloud.js';`
- `@chat-adapter/whatsapp` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```nc:copy from-branch:channels
src/channels/whatsapp-cloud.ts
src/channels/whatsapp-cloud-registration.test.ts
```
### 2. Copy the adapter and its registration test
### 2. Register the adapter
```bash
git show origin/channels:src/channels/whatsapp-cloud.ts > src/channels/whatsapp-cloud.ts
git show origin/channels:src/channels/whatsapp-cloud-registration.test.ts > src/channels/whatsapp-cloud-registration.test.ts
```
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
```nc:append to:src/channels/index.ts
import './whatsapp-cloud.js';
```
### 4. Install the adapter package (pinned)
### 3. Install the adapter package
```bash
pnpm install @chat-adapter/whatsapp@4.27.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/whatsapp@4.26.0
```
### 5. Build and validate
### 4. Build and validate
```bash
Build guards the typed `createChatSdkBridge(...)` core call and proves the
dependency is installed — the import throws at evaluation if `@chat-adapter/whatsapp`
is missing or the barrel drifts:
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/whatsapp-cloud-registration.test.ts
```
Both must be clean before proceeding. `whatsapp-cloud-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `whatsapp-cloud`. It goes red if the `import './whatsapp-cloud.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/whatsapp` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
`whatsapp-cloud-registration.test.ts` imports the real channel barrel and asserts
the registry contains `whatsapp-cloud` — it goes red if the import line is deleted
or drifts, if the barrel fails to evaluate, or if `@chat-adapter/whatsapp` isn't
installed (the import throws), so it also covers the dependency from step 3.
End-to-end message delivery against a real WhatsApp Business number is verified manually once the service is running — see Next Steps and the webhook setup above.
End-to-end message delivery against a real WhatsApp Business number is verified
manually once the service is running — see Next Steps and the webhook setup
below.
## Credentials
Meta app setup is human and interactive — these steps are prose, not directives
(no parser can click through the Meta dashboard). A recipe rebuild produces a
compiling, registered adapter that cannot receive a message until they're done.
1. Go to [Meta for Developers](https://developers.facebook.com/apps/) and create an app (type: Business).
2. Add the **WhatsApp** product.
3. Go to **WhatsApp** > **API Setup**:
@@ -73,18 +82,43 @@ End-to-end message delivery against a real WhatsApp Business number is verified
- Subscribe to webhook fields: `messages`.
5. Copy the **App Secret** from **Settings** > **Basic**.
### Configure environment
### Store the credentials
Add to `.env`:
Capture the four values, then write them. `prompt` only *asks* and binds the
answer to a name; a separate directive consumes it — so the same prompts could
feed `ncl` or the OneCLI vault instead of `.env` by swapping only the consumer.
Here they go to `.env` (set-if-absent — a value you've already filled in is
never overwritten) and sync to the container:
```bash
WHATSAPP_ACCESS_TOKEN=your-system-user-access-token
WHATSAPP_PHONE_NUMBER_ID=your-phone-number-id
WHATSAPP_APP_SECRET=your-app-secret
WHATSAPP_VERIFY_TOKEN=your-verify-token
```nc:prompt access_token secret
Paste the System User access token — WhatsApp > API Setup, with `whatsapp_business_messaging` permission.
```
```nc:prompt phone_number_id
Paste the Phone Number ID — WhatsApp > API Setup (not the phone number itself).
```
```nc:prompt app_secret secret
Paste the App Secret — Settings > Basic.
```
```nc:prompt verify_token secret
Paste the Verify Token — the random string you set under WhatsApp > Configuration.
```
```nc:env-set
WHATSAPP_ACCESS_TOKEN={{access_token}}
WHATSAPP_PHONE_NUMBER_ID={{phone_number_id}}
WHATSAPP_APP_SECRET={{app_secret}}
WHATSAPP_VERIFY_TOKEN={{verify_token}}
```
```nc:env-sync
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Webhook server
The Chat SDK bridge automatically starts a shared webhook server on port 3000
(`WEBHOOK_PORT` to change it), handling `/webhook/whatsapp`. This port must be
publicly reachable for Meta to deliver events. Running locally, expose it with
ngrok (`ngrok http 3000`), a Cloudflare Tunnel, or a reverse proxy on a VPS —
the resulting public URL is the base for the webhook URL set under WhatsApp >
Configuration above.
## Next Steps
@@ -100,3 +134,5 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group.
- **supports-threads**: no
- **typical-use**: Interactive 1:1 chat -- direct messages only
- **default-isolation**: Same agent group if you're the only person messaging the bot. Each additional person who messages gets their own conversation automatically, but they share the agent's workspace and memory -- use a separate agent group if you need information isolation between different contacts.
</content>
</invoke>
+3 -1
View File
@@ -71,6 +71,8 @@ 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}" \
@@ -80,7 +82,7 @@ npx tsx scripts/init-first-agent.ts \
--agent-name "${AGENT_NAME}"
```
Add `--welcome "System instruction: ..."` to override the default welcome prompt.
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.
The script:
1. Upserts the `users` row and grants `owner` role if no owner exists.
+2
View File
@@ -67,6 +67,8 @@ 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
+50
View File
@@ -0,0 +1,50 @@
---
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.
+7
View File
@@ -39,3 +39,10 @@ groups/*
.nanoclaw/
agents-sdk-docs
.agents
AGENTS.md
# Internal working docs, never committed
docs/maintainer-guide.md
docs/drafts/
forks.md
+11
View File
@@ -2,6 +2,17 @@
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).
+10 -4
View File
@@ -33,7 +33,7 @@ user_dms (user_id, channel_type, messaging_group_id) — cold-DM cache
agent_groups (workspace, memory, CLAUDE.md, personality, container config)
↕ many-to-many via messaging_group_agents (session_mode, trigger_rules, priority)
messaging_groups (one chat/channel on one platform; unknown_sender_policy)
messaging_groups (one chat/channel on one platform; instance = adapter-instance name, defaults to channel_type; unknown_sender_policy)
sessions (agent_group_id + messaging_group_id + thread_id → per-session container)
```
@@ -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/onecli-approvals.ts` | OneCLI credentialed-action approval bridge |
| `src/user-dm.ts` | Cold-DM resolution + `user_dms` cache |
| `src/modules/approvals/onecli-approvals.ts` | OneCLI credentialed-action approval bridge |
| `src/modules/permissions/user-dm.ts` | Cold-DM resolution + `user_dms` cache |
| `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills, agent-runner-src overlay) |
| `src/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 |
@@ -83,6 +83,7 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
| `groups/<folder>/` | Per-agent-group filesystem (CLAUDE.md, skills, per-group `agent-runner-src/` overlay) |
| `scripts/init-first-agent.ts` | Bootstrap the first DM-wired agent (used by `/init-first-agent` skill) |
| `migrate-v2.sh` + `setup/migrate-v2/` | v1→v2 migration. Standalone script: `bash migrate-v2.sh`. Seeds DB, copies groups/sessions, installs channels, builds container, offers service switchover, then hands off to `/migrate-from-v1` skill for owner setup and CLAUDE.md cleanup. See [docs/migration-dev.md](docs/migration-dev.md). |
| `nanoclaw.sh --uninstall` + `setup/uninstall/` | Uninstall this copy only (slug-scoped): service, containers + image, `data/`, `logs/`, `groups/`, this copy's OneCLI agents. Confirms per group; `--dry-run` previews, `--yes` skips prompts. Other copies and the shared OneCLI app are untouched. Bypasses bootstrap entirely; `uninstall.sh` is a pointer that execs it. |
## Admin CLI (`ncl`)
@@ -151,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/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/modules/approvals/onecli-approvals.ts`, `ensureAgent()` in `container-runner.ts`. Run `onecli --help`.
### Secret modes
@@ -192,6 +193,7 @@ 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
@@ -274,6 +276,10 @@ 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 |
## Container Build Cache
+24 -12
View File
@@ -19,6 +19,13 @@
**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.
@@ -29,26 +36,27 @@ Every user should have clean and minimal code that does exactly what they need.
### Skill types
#### 1. Feature skills (branch-based)
#### 1. Channel and provider skills (registry branches)
Add capabilities to NanoClaw by merging a git branch. The SKILL.md contains setup instructions; the actual code lives on a `skill/*` branch.
Add a messaging channel or an agent provider. The SKILL.md contains the install steps; the actual code lives on a long-lived registry branch (`channels` or `providers`) that we keep in sync with `main`.
**Location:** `.claude/skills/` on `main` (instructions only), code on `skill/*` branch
**Location:** `.claude/skills/` on `main` (instructions only), code on the `channels` or `providers` branch
**Examples:** `/add-telegram`, `/add-slack`, `/add-discord`, `/add-gmail`
**Examples:** `/add-telegram`, `/add-slack`, `/add-discord`, `/add-opencode`
**How they work:**
1. User runs `/add-telegram`
2. Claude follows the SKILL.md: fetches and merges the `skill/telegram` branch
3. Claude walks through interactive setup (env vars, bot creation, etc.)
2. Claude follows the SKILL.md: `git fetch origin channels`, then copies each file in with `git show origin/channels:<path> > <path>`. Install is an additive fetch, never a `git merge`.
3. The adapter's registration test is fetched the same way and run as verification
4. Claude walks through interactive setup (tokens, bot creation, etc.)
**Contributing a feature skill:**
**Contributing a channel or provider skill:**
1. Fork `nanocoai/nanoclaw` and branch from `main`
2. Make the code changes (new files, modified source, updated `package.json`, etc.)
3. Add a SKILL.md in `.claude/skills/<name>/` with setup instructions — step 1 should be merging the branch
4. Open a PR. We'll create the `skill/<name>` branch from your work
2. Build the adapter following [docs/skill-guidelines.md](docs/skill-guidelines.md): a self-registering module, one appended barrel import, and a registration test that imports the real barrel
3. Add a SKILL.md in `.claude/skills/<name>/` with the fetch-and-copy steps, and a REMOVE.md that reverses every change
4. Open a PR. We'll land the code on the registry branch from your work
See `/add-telegram` for a good example. See [docs/skills-as-branches.md](docs/skills-as-branches.md) for the full system design.
See `/add-slack` for a good example. See [docs/skills-model.md](docs/skills-model.md) for why install is a fetch, never a merge.
#### 2. Utility skills (with code files)
@@ -58,7 +66,7 @@ Standalone tools that ship code files alongside the SKILL.md. The SKILL.md tells
**Examples:** a self-contained CLI or helper shipped in a `scripts/` subfolder of the skill.
**Key difference from feature skills:** No branch merge needed. The code is self-contained in the skill directory and gets copied into place during installation.
**Key difference from channel/provider skills:** the code is self-contained in the skill directory and gets copied into place during installation; nothing is fetched from a registry branch.
**Guidelines:**
- Put code in separate files, not inline in the SKILL.md
@@ -93,6 +101,10 @@ Skills that run inside the agent container, not on the host. These teach the con
- Use `allowed-tools` frontmatter to scope tool permissions
- Keep them focused — the agent's context window is shared across all container skills
### Writing a good skill
The authoring bar is [docs/skill-guidelines.md](docs/skill-guidelines.md): mostly adds, minimal reach-ins into existing code, a test for every functional integration point, and a REMOVE.md whenever apply leaves anything behind. [docs/skills-model.md](docs/skills-model.md) explains the model behind it.
### SKILL.md format
All skills use the [Claude Code skills standard](https://code.claude.com/docs/en/skills):
+9 -1
View File
@@ -196,11 +196,19 @@ Ask Claude Code. "Why isn't the scheduler running?" "What's in the recent logs?"
If a step fails, `nanoclaw.sh` hands off to Claude Code to diagnose and resume. If that doesn't resolve it, run `claude`, then `/debug`. If Claude identifies an issue likely to affect other users, open a PR against the relevant setup step or skill.
**How do I uninstall NanoClaw?**
```bash
bash nanoclaw.sh --uninstall
```
Every install is tagged with a per-checkout id, so the uninstaller removes only what belongs to that copy: the background service, containers and image, app data and logs, your agents' files, and this copy's OneCLI vault agents. Shared things — the OneCLI app and your credentials, other NanoClaw copies on the machine — are left alone. It shows exactly what it found and asks for confirmation per group; nothing is deleted until you say yes. Use `--dry-run` to preview without changing anything, or `--yes` to skip the prompts. Your `.env` is backed up before removal. To finish, delete the checkout folder itself.
**What changes will be accepted into the codebase?**
Only security fixes, bug fixes, and clear improvements will be accepted to the base configuration. That's all.
Everything else (new capabilities, OS compatibility, hardware support, enhancements) should be contributed as skills on the `channels` or `providers` branch.
Everything else (new capabilities, OS compatibility, hardware support, enhancements) should be contributed as skills: channel and provider code on the `channels`/`providers` registry branches, everything else as a self-contained skill. See [docs/customizing.md](docs/customizing.md) and [CONTRIBUTING.md](CONTRIBUTING.md).
This keeps the base system minimal and lets every user customize their installation without inheriting features they don't want.
+11 -15
View File
@@ -16,12 +16,11 @@ FROM node:22-slim
# CJK fonts add ~200MB. Opt in only if you render Chinese/Japanese/Korean text.
ARG INSTALL_CJK_FONTS=false
# Pin CLI versions for reproducibility. Bump deliberately — unpinned installs
# mean every rebuild silently picks up the latest and can break in lockstep
# across all users.
ARG CLAUDE_CODE_VERSION=2.1.154
ARG AGENT_BROWSER_VERSION=latest
ARG VERCEL_VERSION=52.2.1
# 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.
ARG BUN_VERSION=1.3.12
# ---- System dependencies -----------------------------------------------------
@@ -99,16 +98,13 @@ 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 \
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}"
sh /tmp/install-cli-tools.sh /tmp/cli-tools.json
# ---- ncl CLI wrapper ----------------------------------------------------------
# Actual script lives in the mounted source at /app/src/cli/ncl.ts.
+10 -10
View File
@@ -5,7 +5,7 @@
"": {
"name": "nanoclaw-agent-runner",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.154",
"@anthropic-ai/claude-agent-sdk": "^0.3.170",
"@anthropic-ai/sdk": "^0.100.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"cron-parser": "^5.0.0",
@@ -19,23 +19,23 @@
},
},
"packages": {
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.154", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.154", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.154" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-iEn25urI2QrMPFIhId3h7v/7EG5gsmF7ooe+6EvsAosePeLmpVVerp5nXtHnlmBkMinLecurcPA+OddKw76jYw=="],
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.170", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.170", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.170" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-pAvhfk+iTodXZ6RF18Kz7BEUWFjL7EcR3tKuhUNdPpE1NAYCR3mSHGbafi72JsrNwKEDIs7FU31z3fqhwy8QzA=="],
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.154", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oFW3LD5lYrKAU+AKu27Z8hrzqkrh362qQrwi/i3DxGcud9BXUycsXYjShpDj3D3JZu169UzZuSPhx1Wajmbiwg=="],
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.170", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rwfgArIa5WI0QPNqFsRBgvtSI0mrtpynUm0oK6+l6/KX4hcgnYGEzciZR1bOeD9/7sSZlTdIgt+T9alKeZmXcg=="],
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.154", "", { "os": "darwin", "cpu": "x64" }, "sha512-5BgWEueP+cqoctWjZYhCbyltuaV/N2DmKDXD3/69cKaVmJp8XL9OCzlq/HEirA/+Ssjskx6hDUBaOcpuZ3iwQA=="],
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.170", "", { "os": "darwin", "cpu": "x64" }, "sha512-0e58h8UQMtsQxLGIv9r4foxfBFWKZ7NeDtoplLhuD7EwQonehomw1sBXCch77t/IfUS+q5vQ5zv+fOGmap5nLQ=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.154", "", { "os": "linux", "cpu": "arm64" }, "sha512-rRkW4SBL3W7zQvKscCIfIGlmoeuTbMV6dXFbPdmpRGvmYZIs79RpzO6xrGBnnhmm+B7znQ9oHAnffi/2FBgJbA=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.170", "", { "os": "linux", "cpu": "arm64" }, "sha512-gLbaFqcGppFJQd4DLNV4IXoeahejT/p2/M8bSSvRDbla9GOsBr1AxV5XLRyBn1e7xFGozZIAIQr3+1chp7NJgQ=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.154", "", { "os": "linux", "cpu": "arm64" }, "sha512-o2bCQN4Xn3UqCLErC5m4T7u0yYArJYmgFCUFnA6K96DdW2RERvx+gTKXxWuHEBkDO+eMoHLHLxk0u2jGES00Ng=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.170", "", { "os": "linux", "cpu": "arm64" }, "sha512-SRYfQcsXlOq+CD/FqkQBTSHbaD++w73GnnO+NUV9adLYrca3kfetRwWT1iguY1cNS0l34dCR3rlzCPq78vg1Jg=="],
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.154", "", { "os": "linux", "cpu": "x64" }, "sha512-GpiFF8Ez6PbM3m0gqtCo/FKM346qyRdP7VhbmJzdnbNKTiiUZ66vDQyEUPZPCG24ZkrG4m96KpRIUwY08rHiNg=="],
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.170", "", { "os": "linux", "cpu": "x64" }, "sha512-Xl/m7TaSC3T5IDBdHrZQ9fCQYyDmPELN34CL+MoyPIf7uSmuZnjE9fUOqDh2Rv26JxWssi1M6X+BBvVuKd6Cpg=="],
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.154", "", { "os": "linux", "cpu": "x64" }, "sha512-zA7S8Lm6O4QBsUpbhiOht8BgiXHOBBFUIo8ZLK6r5wAatK3Q44syWVxICeyCnR6wqfnkf3cugCw27ycS6vVgaA=="],
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.170", "", { "os": "linux", "cpu": "x64" }, "sha512-m4+I0qBEk7cxRKS+pL+eoWXbXTFOAo83fQ0tQvap4z/mDMm06IWJtEPoYTaMBwsp32GJWLkHWKbZSBCHZnp2DQ=="],
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.154", "", { "os": "win32", "cpu": "arm64" }, "sha512-cDW1YFbU/PJFlrGXhlAGcbkXt80sEO6WtnH8nN8YHXLn5NWduy2q7o/qC6i8XozgvRGf6t/eMoH7IasGIEDhDw=="],
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.170", "", { "os": "win32", "cpu": "arm64" }, "sha512-IG+8isJNNJKbnnhO7m+PGhfVCg+XoQ/MDxGde5eigFI0WsEfitjuWSWwx82bT9ghxI1aa6qNvI+UPgPcZuo5Fg=="],
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.154", "", { "os": "win32", "cpu": "x64" }, "sha512-tSKaIIpL72OPg3WfzZTCIl8OJgcbq4qieu8/fDWjsdeQuari9gQMIuEflFphk9HqNsxpSmDqKi8Sm5mW2V566Q=="],
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.170", "", { "os": "win32", "cpu": "x64" }, "sha512-7cuqSKbHVItPGVwRbd3A0BEJwcNtc7Fhoh6qHN4C6yrmjSrvdYYx3MLvq/VI768/RoG7mAMDxb+j7WfEfoP9BA=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.100.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-cAm3aXm6qAiHIvHxyIIGd6tVmsD2gDqlc2h0R20ijNUzGgVnIN822bit4mKbF6CkuV7qIrLQIPoAepHEpanrQQ=="],
+1 -1
View File
@@ -9,7 +9,7 @@
"test": "bun test"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.154",
"@anthropic-ai/claude-agent-sdk": "^0.3.170",
"@anthropic-ai/sdk": "^0.100.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"cron-parser": "^5.0.0",
+7
View File
@@ -27,6 +27,7 @@ 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';
@@ -95,6 +96,12 @@ 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,6 +5,7 @@ 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(() => {
@@ -304,6 +305,7 @@ async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSigna
provider,
providerName: 'mock',
cwd: '/tmp',
signal,
}),
new Promise<void>((_, reject) => {
signal.addEventListener('abort', () => reject(new Error('aborted')));
@@ -324,6 +326,86 @@ 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' });
@@ -462,3 +544,76 @@ 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;
}
})(),
};
}
}
@@ -5,8 +5,11 @@
* send_message(to="agent-name") since agents and channels share the
* unified destinations namespace.
*
* create_agent is admin-only. Non-admin containers never see this tool
* (see mcp-tools/index.ts). The host re-checks permission on receive.
* create_agent writes central-DB state. The host authorizes it by CLI scope:
* trusted owner agent groups (scope 'global') create directly; confined groups
* require admin approval (see src/modules/agent-to-agent/create-agent.ts). This
* tool just writes the outbound request; authorization is enforced host-side,
* not here the container is untrusted and cannot be relied on to gate itself.
*/
import { writeMessageOut } from '../db/messages-out.js';
import { registerTools } from './server.js';
@@ -32,7 +35,7 @@ export const createAgent: McpToolDefinition = {
tool: {
name: 'create_agent',
description:
'Create a long-lived companion sub-agent (research assistant, task manager, specialist) — the name becomes your destination for it. Admin-only. Fire-and-forget.',
'Create a long-lived companion sub-agent (research assistant, task manager, specialist) — the name becomes your destination for it. May require admin approval before the agent is created. Fire-and-forget.',
inputSchema: {
type: 'object' as const,
properties: {
@@ -0,0 +1,53 @@
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 });
}
});
});
@@ -0,0 +1,39 @@
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);
}
@@ -0,0 +1,22 @@
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'");
});
});
@@ -0,0 +1,23 @@
# 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.
@@ -0,0 +1,5 @@
# Memory Index
- [Memory system definition](system/definition.md)
- [Memories](memories/) - durable facts, people, projects, decisions
- [Data](data/) - structured reference data
+68 -8
View File
@@ -14,7 +14,7 @@ import {
type RoutingContext,
} from './formatter.js';
import { isUploadTraceCommand, uploadTrace } from './upload-trace.js';
import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js';
import type { AgentProvider, AgentQuery, ProviderEvent, ProviderExchange } from './providers/types.js';
const POLL_INTERVAL_MS = 1000;
const ACTIVE_POLL_INTERVAL_MS = 500;
@@ -63,6 +63,12 @@ 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;
}
/**
@@ -107,6 +113,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
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;
@@ -232,7 +239,15 @@ 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);
const result = await processQuery(
query,
routing,
processingIds,
config.providerName,
config.provider.onExchangeComplete?.bind(config.provider),
prompt,
continuation,
);
if (result.continuation && result.continuation !== continuation) {
continuation = result.continuation;
setContinuation(config.providerName, continuation);
@@ -313,10 +328,18 @@ async function processQuery(
routing: RoutingContext,
initialBatchIds: string[],
providerName: string,
onExchangeComplete: ((exchange: ProviderExchange) => void) | undefined,
initialPrompt: string,
initialContinuation: string | undefined,
): 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
@@ -342,13 +365,16 @@ 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. End the stream and leave the rows
// pending; the outer loop handles them on next iteration via the
// canonical command path + formatMessagesWithCommands.
// 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.
if (pending.some((m) => isRunnerCommand(m))) {
log('Pending slash command — ending stream so outer loop can process');
log('Pending slash command — aborting active stream so outer loop can process');
endedForCommand = true;
query.end();
query.abort();
return;
}
@@ -393,6 +419,7 @@ 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
@@ -456,7 +483,14 @@ async function processQuery(
markCompleted(initialBatchIds);
if (event.text) {
const { hasUnwrapped } = dispatchResultText(event.text, routing);
if (hasUnwrapped && !unwrappedNudged) {
const willRetryWrapping = hasUnwrapped && !unwrappedNudged;
notifyExchangeComplete(onExchangeComplete, {
prompt: archivePrompts[0] ?? initialPrompt,
result: event.text,
continuation: queryContinuation ?? initialContinuation,
status: hasUnwrapped ? 'undelivered' : 'completed',
});
if (willRetryWrapping) {
unwrappedNudged = true;
const destinations = getAllDestinations();
const names = destinations.map((d) => d.name).join(', ');
@@ -467,9 +501,23 @@ 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();
}
}
}
} 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);
@@ -478,6 +526,18 @@ 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,6 +6,25 @@ 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;
@@ -31,6 +50,16 @@ 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.
+5
View File
@@ -0,0 +1,5 @@
[
{ "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 }
]
+61
View File
@@ -0,0 +1,61 @@
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\[\]=/);
});
});
+29
View File
@@ -0,0 +1,29 @@
#!/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
-1
View File
@@ -9,6 +9,5 @@ The files in this directory are original design documents and developer referenc
| [SPEC.md](SPEC.md) | [Architecture](https://docs.nanoclaw.dev/concepts/architecture) |
| [SECURITY.md](SECURITY.md) | [Security model](https://docs.nanoclaw.dev/concepts/security) |
| [REQUIREMENTS.md](REQUIREMENTS.md) | [Introduction](https://docs.nanoclaw.dev/introduction) |
| [skills-as-branches.md](skills-as-branches.md) | [Skills system](https://docs.nanoclaw.dev/integrations/skills-system) |
| [docker-sandboxes.md](docker-sandboxes.md) | [Docker Sandboxes](https://docs.nanoclaw.dev/advanced/docker-sandboxes) |
| [APPLE-CONTAINER-NETWORKING.md](APPLE-CONTAINER-NETWORKING.md) | [Container runtime](https://docs.nanoclaw.dev/advanced/container-runtime) |
+42
View File
@@ -83,6 +83,48 @@ Each NanoClaw group gets its own OneCLI agent identity. This allows different cr
- Any credentials matching blocked patterns
- `.env` is shadowed with `/dev/null` in the project root mount
### 6. Egress Lockdown (Forced Proxy)
The `HTTPS_PROXY` env var only redirects *proxy-aware* clients — a tool that
ignores it (or a raw socket) could reach the internet directly and bypass
credential injection, approvals, and audit. Egress lockdown closes that hole at
the network layer.
**How it works:** agents are placed on a Docker `--internal` network
(`nanoclaw-egress`) that has **no route to the internet**. The OneCLI gateway
container is attached to that network, aliased as `host.docker.internal`, so the
injected proxy URL (`…@host.docker.internal:10255`) resolves to the gateway
*container-to-container*. The gateway is therefore the **only reachable hop**
anything else has nowhere to go. The agent is non-root with no `NET_ADMIN`, so
it cannot undo this. Identical mechanism on macOS and Linux (no host firewall,
no `host-gateway` route).
- **Self-healing:** the gateway is re-attached to the network at every spawn and
on each host-sweep tick, so an out-of-band detach (e.g. `docker compose up` on
the OneCLI stack — its compose lives in `~/.onecli`, not this repo) recovers
automatically.
- **Fail-fast:** if lockdown is on but the network can't be created or the
gateway can't be attached (e.g. a non-standard gateway container name, or the
gateway isn't running), nanoclaw **refuses to spawn the agent** and surfaces a
clear error — it never silently falls back to open egress. Fix the cause (or
set `NANOCLAW_EGRESS_LOCKDOWN=false`) and retry. The host-sweep re-heal is the
exception: a heal failure there is logged but not fatal, since already-running
agents stay on the internal net (no leak) until the gateway returns.
**Configuration:**
| Env | Default | Meaning |
| --- | --- | --- |
| `NANOCLAW_EGRESS_LOCKDOWN` | `false` | Set `true` to opt in (otherwise the host-gateway path is used). Enabled automatically by `/add-golden-registry`. |
| `NANOCLAW_EGRESS_NETWORK` | `nanoclaw-egress` | Network name. |
| `ONECLI_GATEWAY_CONTAINER` | `onecli` | Gateway container to attach. |
**⚠ Behavior when enabled:** with lockdown on, agents have **no direct
internet** — all traffic must go through OneCLI. Proxy-aware clients (npm, pnpm,
pip, curl, node/bun with the proxy env) are unaffected. Any workflow that relies
on a **non-proxy-aware** tool reaching the internet directly will fail by design.
Lockdown is **off by default**; opt in with `NANOCLAW_EGRESS_LOCKDOWN=true`.
## Privilege Comparison
| Capability | Main Group | Non-Main Group |
+5 -1
View File
@@ -668,15 +668,19 @@ CREATE TABLE agent_groups (
);
-- Platform groups/channels (WhatsApp group, Slack channel, Discord channel, email thread, etc.)
-- One row per chat PER ADAPTER INSTANCE. instance defaults to channel_type
-- (the "default instance"), so single-instance installs never see it.
CREATE TABLE messaging_groups (
id TEXT PRIMARY KEY,
channel_type TEXT NOT NULL, -- 'whatsapp', 'slack', 'discord', 'telegram', 'email'
platform_id TEXT NOT NULL, -- platform-specific ID (JID, channel ID, etc.)
instance TEXT NOT NULL, -- adapter-instance name; default = channel_type
name TEXT,
is_group INTEGER DEFAULT 0,
unknown_sender_policy TEXT NOT NULL DEFAULT 'strict', -- 'strict' | 'request_approval' | 'public'
created_at TEXT NOT NULL,
UNIQUE(channel_type, platform_id)
denied_at TEXT,
UNIQUE(channel_type, platform_id, instance)
);
-- Users (messaging platform identities, namespaced "<channel_type>:<handle>")
+36
View File
@@ -0,0 +1,36 @@
# Customizing NanoClaw
NanoClaw is made to be forked and changed. The catch with most projects is that once you edit the code, every upstream update turns into a merge fight, and the more you customized, the worse it gets.
NanoClaw avoids that with one simple idea: **every change you make is a skill.**
## The idea in a minute
- A **skill** is a small, self-contained add-on. It brings its own code and knows how to install itself.
- Your **fork is just a list of skills**, plus one "recipe" that says which skills you have and how they fit together.
- Because your changes live beside the core instead of tangled into it, **pulling in updates stays easy**.
## What makes it work
A good skill mostly **adds** things: new files, a line appended to an existing file, a dependency. It avoids rewriting existing code in place.
And it ships a test for each spot where it touches the rest of the system. When an update moves something your skill depends on, that test fails and points at the fix, instead of you finding out when things break in production.
## How you actually work
You don't have to think in skills while you're building. **Edit the code directly, get it working, then turn your changes into skills afterward.** A coding agent does the conversion for you, following [skill-guidelines.md](skill-guidelines.md).
The only rule worth remembering: **a change isn't really part of your fork until it's a skill**, because that's the form that survives an upgrade.
## Upgrading
Always upgrade by running `/update-nanoclaw`. **Don't just `git pull`.** The command sets a rollback point, pulls the upstream changes, runs your tests, and walks you through anything that needs fixing, usually a small, local fix in one skill.
## The deal
We keep the core small and stable, and every breaking change ships with its migration. You keep your changes as skills, with tests. Do that, and upgrades won't break you. Changes edited directly into the core are the one thing the model can't protect.
## Go deeper
- **[The skills model in full](skills-model.md)**: how skills, recipes, tests, and upgrades work under the hood.
- **[Skill guidelines](skill-guidelines.md)**: the authoritative checklist for writing one.
+6 -3
View File
@@ -27,21 +27,24 @@ CREATE TABLE agent_groups (
### 1.2 `messaging_groups`
One row per platform chat (one WhatsApp group, one Slack channel, one 1:1 DM, etc.).
One row per platform chat (one WhatsApp group, one Slack channel, one 1:1 DM, etc.) per adapter instance.
```sql
CREATE TABLE messaging_groups (
id TEXT PRIMARY KEY,
channel_type TEXT NOT NULL,
platform_id TEXT NOT NULL,
instance TEXT NOT NULL,
name TEXT,
is_group INTEGER DEFAULT 0,
unknown_sender_policy TEXT NOT NULL DEFAULT 'strict',
created_at TEXT NOT NULL,
UNIQUE(channel_type, platform_id)
denied_at TEXT,
UNIQUE(channel_type, platform_id, instance)
);
```
- `instance`: adapter-instance name — N adapters of one platform (e.g. three Slack apps in one workspace) each own their rows. The default instance IS the channel type: migration 016 backfills `instance = channel_type` and `createMessagingGroup` stamps the same default, so single-instance installs never see the dimension. Inbound lookups are exact-on-instance (an unknown named instance auto-creates its own row); outbound lookups resolve default-instance-first.
- `unknown_sender_policy`: `strict` (drop), `request_approval` (ask admin), `public` (allow).
- **Readers:** `src/router.ts`, `src/delivery.ts`, `src/session-manager.ts`
- **Writers:** `src/db/messaging-groups.ts`, channel setup flows
@@ -134,7 +137,7 @@ CREATE TABLE user_dms (
);
```
Populated lazily by `ensureUserDm()` in `src/user-dm.ts`.
Populated lazily by `ensureUserDm()` in `src/user-dm.ts`. Cold DMs resolve via the channel's default adapter instance — `PRIMARY KEY (user_id, channel_type)` is per-platform, not per-instance.
### 1.8 `sessions`
+74
View File
@@ -53,6 +53,80 @@ Model selection considerations for Apple Silicon:
The agent uses tool calls extensively (read/write files, shell commands). Models that support tool use reliably work best. Gemma 4 and Qwen 3 Coder both handle structured tool calls well.
## Allowing Prompt Caching (filter the cache-busting hash)
Out of the box this path is slow — every reply re-reads the whole multi-thousand-token system prompt from scratch, even for a one-word answer. Ollama has a prompt cache that should skip that repeated work, but on this path it never kicks in.
**Cause.** The Claude Agent SDK adds a per-request hash to the front of every prompt — `x-anthropic-billing-header: ...; cch=<hash>;`. It changes on every request, and Ollama's cache only reuses a prompt whose start is unchanged. So that one shifting value at the front makes Ollama treat every prompt as new and re-read all of it. (Ollama ignores the hash itself, so filtering it has no effect on output.)
**Fix.** Run a tiny proxy between the container and Ollama that filters the hash out (pins `cch=<hash>` to a constant). The start of the prompt is now stable, so the cache kicks in and only the new message gets processed. In our setup — a 31B model on Apple Silicon — follow-up replies dropped from ~80s to ~4s; your numbers will vary with model size and hardware. Output is unchanged, since Ollama ignores the value anyway.
Point the agent group's `ANTHROPIC_BASE_URL` at the proxy instead of Ollama directly (everything else from the sections above is unchanged):
```
ANTHROPIC_BASE_URL=http://host.docker.internal:11999 # the proxy
# proxy forwards to http://127.0.0.1:11434 (Ollama)
```
The proxy is ~40 lines of dependency-free Node:
```js
// ollama-cch-proxy.mjs — normalize the SDK's per-request cch nonce so Ollama's
// prefix cache survives across turns. Listens on :11999, forwards to Ollama.
import http from 'node:http';
const TARGET_HOST = process.env.OLLAMA_HOST || '127.0.0.1';
const TARGET_PORT = Number(process.env.OLLAMA_PORT || 11434);
const LISTEN_PORT = Number(process.env.PROXY_PORT || 11999);
const server = http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
let body = Buffer.concat(chunks);
if (req.method === 'POST' && body.length) {
body = Buffer.from(body.toString('utf8').replace(/cch=[0-9a-f]+;/g, 'cch=00000;'), 'utf8');
}
const headers = { ...req.headers, host: `${TARGET_HOST}:${TARGET_PORT}`, 'content-length': String(body.length) };
const proxyReq = http.request(
{ host: TARGET_HOST, port: TARGET_PORT, method: req.method, path: req.url, headers },
(proxyRes) => {
res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
proxyRes.pipe(res);
},
);
proxyReq.on('error', (e) => { res.writeHead(502); res.end(String(e)); });
proxyReq.end(body);
});
});
server.listen(LISTEN_PORT, '0.0.0.0', () => console.log(`cch-proxy :${LISTEN_PORT} -> ${TARGET_HOST}:${TARGET_PORT}`));
```
Run it durably so it survives reboots. On Linux, a systemd user service:
```ini
# ~/.config/systemd/user/ollama-cch-proxy.service
[Unit]
Description=Ollama cch-normalizing proxy for NanoClaw
After=network-online.target
[Service]
ExecStart=/usr/bin/node %h/.config/nanoclaw/ollama-cch-proxy.mjs
Restart=always
[Install]
WantedBy=default.target
```
```bash
systemctl --user enable --now ollama-cch-proxy
loginctl enable-linger "$USER" # so it runs without an active login session
```
On macOS use a `launchd` user agent (`~/Library/LaunchAgents/`) running the same script.
**Scope.** This only affects the Claude-Code-CLI → Ollama path described here. Codex and OpenCode don't use the Claude Agent SDK, so they never emit the `cch` hash and get prompt caching for free.
## What Changes at the Code Level
Three files need to support this feature. See `/add-ollama-provider` for the exact changes.
+83
View File
@@ -0,0 +1,83 @@
# 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.
+44
View File
@@ -0,0 +1,44 @@
# 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.
+2 -2
View File
@@ -187,13 +187,13 @@ leaking the token to disk outweighs the debugging value.
| File | Role |
|---|---|
| `nanoclaw.sh` | Top-level wrapper. Phase 1 (bootstrap) and phase 2 (setup:auto) orchestration. Writes bootstrap's raw log + progression entry. |
| `nanoclaw.sh` | Top-level wrapper. Phase 1 (bootstrap) and phase 2 (setup:auto) orchestration. Writes bootstrap's raw log + progression entry. `--uninstall` bypasses bootstrap entirely — it execs setup:auto directly (the flow lives in `setup/uninstall/`), or prints manual-cleanup guidance and exits 1 when the TS toolchain is missing. |
| `setup.sh` | Phase 1 bootstrap: Node, pnpm, native-module verify. Emits its own `BOOTSTRAP` status block (historically printed to stdout; now goes to the bootstrap raw log). |
| `setup/auto.ts` | Phase 2 driver. Orchestrates the clack UI, step execution, user prompts, and writes to all three log levels for every step it spawns. |
| `setup/logs.ts` | The logging primitives (`logStep`, `logUserInput`, `logComplete`, `stepRawLog`, `initSetupLog`). Single source of truth for level 2/3 formatting and file paths. |
| `setup/<step>.ts` | Individual step implementations. Must emit one terminal status block; must not write directly to the terminal. |
| `setup/register-claude-token.sh` | The Anthropic exception. Inherits stdio, prints its own UI, returns a status to the driver. |
| `setup/add-telegram.sh` | Non-interactive adapter installer. Reads `TELEGRAM_BOT_TOKEN` from env; never prompts. User-facing bits live in `auto.ts`. |
| `setup/channels/telegram.ts` | Telegram channel flow. Installs the adapter in-process by applying the `/add-telegram` skill (directive engine; SKILL.md is the single source of truth), feeding the collected bot token to the skill's `bot_token` prompt var. |
| `setup/pair-telegram.ts` | Emits `PAIR_TELEGRAM_CODE` / `PAIR_TELEGRAM_ATTEMPT` / `PAIR_TELEGRAM` status blocks. Never prints UI. The driver renders it via clack notes. |
## Common pitfalls
+168
View File
@@ -0,0 +1,168 @@
# Skill guidelines
The authoritative checklist for writing a NanoClaw skill: the bar that conformance tooling and registry review will hold every skill to. [customizing.md](customizing.md) is the short introduction; [skills-model.md](skills-model.md) explains why the model works this way. This document evolves with the system; when a rule here proves wrong, fix the rule.
---
## Principles
Every customization is an additive **skill**: not an edit buried in core, but a skill that carries its own code and knows how to install and remove itself. Two principles make a skill *maintainable*; everything else in this document follows from them.
### 1. Minimal integration surface
A skill adds files and makes the **smallest possible reach-ins** into existing code. Adding a file or a dependency never breaks on upgrade; reaching into existing code is the only thing that does, so the integration surface *is* the upgrade risk. Keep reach-ins few, tiny, and ideally a single line that *calls* into the skill's own code.
Follows from this:
- **Mostly add.** See the change shapes below, in safety order.
- **Push logic into skill-owned files** so the core edit is one call, not an inlined block. This shrinks the surface *and* makes the point testable.
- **Colocated, self-contained** edits over edits in two places.
- **Use an existing registry or hook when there is one**: appending to a registry is a smaller surface than reaching into code. When none exists, a true code-level edit is fine and first-class. (Whether to *add* a hook because a spot has become a hotspot is the maintainer's call, not the skill's.)
### 2. A test for every functional integration point
Every reach-in with a **functional consequence** gets a test that goes **red if the wiring is deleted or drifts**. That's what protects the fork from upstream changes. The tests are also the verification: there is no separate "verify" step.
Follows from this:
- **Tests target integration with core, not internal correctness.** Unit tests of a skill's own logic, or its behavior against an external service, are the creator's call: fine, just not required.
- **A direct unit test doesn't count**: calling the skill's own function bypasses the wiring and stays green when the reach-in is deleted. Drive the real entry, or assert the wiring structurally.
- **Build / typecheck is an always-on leg**: drift (moved imports, renamed fields) is the main enemy and slips past runtime tests.
- **The test lives where the point runs**: host code uses vitest under `src/`; container code uses `bun:test` under `container/agent-runner/`.
- **"Functional" is the filter**: weigh a reach-in by what breaks if it's gone. A cosmetic one (raising a log line's level) gets no test.
The two interlock: a minimal surface keeps the integration points few and testable; a test per point keeps the surface safe. *Maintainable = small surface, every functional point guarded.*
---
## Skill anatomy
A skill carries everything it needs:
- **Code**: the files it adds. They live in the skill's own folder, or, for large registry-backed skills like channels and providers, on a registry branch the skill fetches from. Apply copies them in.
- **Apply**: the steps in `SKILL.md`, written as prose an agent can run. Apply must be safe to re-run: upgrades re-run it, and a skill that half-applies twice is a bug.
- **Remove**: a separate `REMOVE.md` that reverses *every* change apply made: barrel lines deleted (not commented out), every copied file removed including tests, dependencies uninstalled, Dockerfile edits reverted, env lines removed. **REMOVE.md is required exactly when apply leaves anything behind.** A pure instruction-only skill that copies nothing needs none, and an empty one is noise.
- **Tests**: files that ship with the skill and are copied into the project's test tree on apply, so they run against the *composed* system.
- **Recipe entry**: how it composes with the fork's other skills (ordering, dependencies).
---
## Change shapes
In rough order of safety:
- **Add a file**: safest. New code in the skill's own files, or fetched from a registry branch (`git show origin/<branch>:path > path`).
- **Append to a file**: an import in a barrel, a line in `.env`, an entry at the end of a list.
- **Edit a value in JSON**: e.g. a `package.json` field.
- **Add a dependency**, pinned to an exact version.
- **Insert into existing code (an "integration point")**: the one risky move. Keep it to a line or two that *calls* code living in the skill's own files, never an inlined block of logic. A skill full of these is a smell.
Fetching from a registry branch is **additive, never a merge**. `git fetch origin <branch>` then `git show origin/<branch>:path > path` per file. Never `git merge` a registry branch into an install.
---
## Integration points
The integration point is wherever the skill reaches into existing code. Make it **minimal, colocated, and self-contained**:
- All real logic lives in the skill's own file behind a single entry function; the edit to core is just the call.
- **Prefer one colocated block** over edits in two places. For an inserted call, a dynamic import at the call site keeps the import and call together and avoids touching the top-of-file import block (itself a merge hotspot):
```typescript
const { startDashboard } = await import('./dashboard-pusher.js');
await startDashboard();
```
A static import + call is acceptable too; this is a recommendation, not a mandate.
- Keep any gating (feature flags, env checks) *inside* the skill's function, so the core edit stays a single call.
- When the reach-in lands inside an entangled function, extract a tiny skill-owned helper so the core touch is one line, like `args.push(...mySkillEnvArgs())`, rather than exporting the whole function or inlining the logic.
---
## Testing
**What the standard requires: integration with the NanoClaw system.**
- **Required:** a test for every functional integration point, and, where an added file consumes core (core APIs, data shapes, registries), a test that exercises that consumption against the real core. That's the leg that catches core drift.
- **Optional, the creator's call:** unit tests of the skill's own internal logic, or its behavior against an external service. Often good practice; not what defines a maintainable skill, because they don't protect against upstream changes.
### Choosing the test type
For a code-edit integration point, how you test the wiring depends on whether you can invoke the function the edit lives in. **Prefer behavior; fall back to structure.**
- **If the edit lives in an invocable function, test that function's behavior.** Calling it exercises the edit; remove or break the edit and the test goes red. This is the strongest option, and usually available, because a minimal integration point pushes the logic into the skill's own exported function anyway.
- **If the edit lives in a non-invocable entry point** (e.g. `main()` or boot), **use a structural / AST test.** Use the TypeScript compiler API and assert not just that the symbol exists but its **placement**: awaited, a direct statement of the right function, importing the right module path, correctly ordered. A present-but-misplaced call must go red.
Two more legs apply when relevant:
- **Build / typecheck** always applies: it catches a renamed symbol, a moved module, a bad signature.
- **A behavior test of how added code consumes core**, required when the added file reaches into core APIs or data at runtime. When the consumption is a *typed* call into a core API (a Chat SDK adapter calling `createChatSdkBridge`), the build leg already guards it and no separate behavior test is required. The behavior-test requirement targets runtime consumption: core DB state, data shapes, registries.
Together these cover deletion, misplacement, drift, and core consumption. Only true runtime-reachability (a call stranded behind a dead branch) needs the heavy option of booting the real entry point, a rare "real run" reserved for critical wiring.
### Registration reach-ins: behavior, not structural
A registry queryable at runtime gets a **behavior** test: import the real barrel, assert the registry contains the entry. A structural parse only proves the *source line* exists. It stays green when the barrel can't evaluate or the package isn't installed, which is exactly when the thing is actually broken. The behavior test goes red on a deleted barrel line, a barrel that won't evaluate, *and* an uninstalled package (the unmocked import throws), so it covers the dependency integration point for free.
Two consequences. First, **don't mock the adapter's package in the shipped test**: that would defeat the dependency check, and the test runs in the composed install where the package is present. Second, the only reason to fall back to a structural parse is an adapter with real import-time side effects (spawns a process, opens a socket, needs creds at load), which is an adapter smell to fix, not a reason to weaken the test. Conformant adapters do all side-effectful work in the factory or `setup()`, never at import.
### Test archetypes
The test matches the kind of integration point:
- **In-process seam with core** (a channel into the router, a pusher into the central DB): drive the real added component against the **real core collaborators** (DB, registry, router), faking only the external edge. The highest-value archetype: it exercises the added file's consumption of core, which is what catches core drift.
- **Wiring / registration** (a barrel import, a `main()` call, an entry in an `mcpServers` map): behavior test via the registry where queryable (see above); structural / AST test where not.
- **Config / container probe** (mounts, Dockerfile, a tool installed in the image): run the change where you can. Spin up a container to confirm a mount or binary. Checking that a line exists in a file is the last resort.
- **Agentic run** (operational, instruction-only skills): run the workflow with a small model; did it complete?
- **Patch behavior** (a patch skill that changes core logic): a behavior test of the changed behavior.
- **Provider (multi-point)**: a non-default agent backend reaches into *two* barrels (host `src/providers/index.ts`; container `container/agent-runner/src/providers/index.ts`), plus Dockerfile edits and a CLI or SDK dependency. Each is a separate way to break, and each needs its own guard. Ship a **barrel-driven registration test per tree** that imports *only* the real barrel and asserts the registry contains the provider. **The trap:** a `*.factory.test.ts` that imports the provider module directly self-registers it and stays green when the barrel line is deleted; that's a unit test, not a registration guard. REMOVE.md must reverse both barrel lines, all copied files in both trees, the dependency, and the Dockerfile edits.
- **Content / instruction-only** (a reference wiki, a pure workflow): makes no functional reach-in, so it owes no integration test. Conformance is anatomy: idempotent apply, plus REMOVE.md iff apply leaves anything behind.
### Dependencies are integration points
A skill that installs a package has made a reach-in: the code now assumes it's there. Guard it so a missing package goes red, in order of preference:
1. **An unmocked import in a behavior test**: the test imports real code that imports the package, so a missing package throws. Covers presence *and* exercises the real dependency.
2. **The build leg**: a typed import of a missing module fails typecheck. The fallback when the package genuinely can't be imported in a test (e.g. it binds a port on import). Only works if the validate step runs the build before or alongside the tests, so verify the order.
3. **A Dockerfile-installed CLI binary** is the case most often left unguarded: it isn't importable, so neither guard above sees it. Use a **structural test** asserting the Dockerfile `ARG <X>_VERSION=` and install line are present, optionally backed by a `<bin> --version` container probe. Pin the version; reject `latest`.
You do *not* need to test the dependency's own API contract; that's optional external-service coverage.
### When there is genuinely nothing to test in-tree
Some skills' only functional integration is a runtime operator action with no source footprint: registering an MCP server through `ncl`, or a mount through the sanctioned query wrapper (until the `ncl` add-mount verb lands). There's no line in the tree whose deletion a test could catch, so a registration test is structurally inapplicable. **State this explicitly in SKILL.md** rather than inventing a hollow test; conformance is then anatomy plus the dependency guard. This is a conformant outcome, valid only when the reach-in has no in-tree representation. (A raw-SQL write into core's schema to achieve the same thing is a smell, not a workaround.)
### Test rules
- **Hermetic at the external edge.** Mock genuinely external services (a fake HTTP server, stubbed creds), never the package under guard (see "Registration reach-ins").
- **Exercise the real entry, or assert it structurally.** A test that imports the skill's function directly does not test the integration.
- **Tests travel with the skill** and are copied in on apply; an integration test only means anything against the composed project.
- **Robustness check.** Apply the skill with a small, cheap model. If a small model fumbles the instructions, they're too vague. Fix the instructions, don't blame the model. (Small models also keep applying skills cheap.)
---
## Anti-patterns
Each with its fix. These are patterns to remove, not to test around: a drift-prone, untestable reach-in is usually a symptom of a bad pattern, not a missing test. Reviewers reject them; the conformance linter will flag them automatically.
1. **A separate VERIFY.md.** Delete it; tests are the verification. Fold any genuinely useful manual smoke check into SKILL.md's next steps.
2. **REMOVE.md soft-disable** (comments out an import; leaves copied files behind). DELETE the import line and `rm` every file the skill copied.
3. **REMOVE.md incomplete** (misses env vars, the package uninstall, copied tests). Reverse *every* change; read the env vars from the skill's own credentials section, don't guess.
4. **Raw SQL against a core DB** (read or write). Use a core helper or an `ncl` verb; the in-tree query wrapper is the sanctioned last resort. Never the `sqlite3` binary.
5. **Credential threading** (`-e KEY=…` or a stdin secrets payload into the container). OneCLI gateway only; it injects credentials per request.
6. **Branch-merge install** (`git merge` of a registry branch or any code branch). Install by additive fetch: `git fetch origin <branch>`, then `git show origin/<branch>:path > path` per file. For an update/reapply workflow, re-run each installed skill's additive apply, never merge.
7. **Diff-against-past framing** ("earlier versions…", "this is now redundant") and **documenting non-steps** ("no X needed"). Write present-tense DO steps only. A skill reads as a standalone artifact with no memory of its own edits.
8. **Stale reach-in targets** (an edit aimed at code that no longer exists; a reach-in already shipped in trunk). Verify the target exists *before* instructing the edit; reconcile already-in-trunk ones to a no-op. Before appending to an allowlist or list, check how it's consumed; the entry may already be derived from a registry, making the edit dead.
9. **Hand-maintained duplicate copies** (a mirror directory kept in sync by hand or sed). Generate the mirror from a single canonical source.
---
## Worked examples
In-tree exemplars for the code archetypes. (Two carry known smells, kept deliberately pending architectural fixes; they demonstrate the test shapes, not perfection.)
- `add-dashboard`: in-process seam with core (the pusher against the central DB), plus an AST wiring test for its `main()` call.
- `add-slack`: Chat SDK channel registration; the template for the whole channel family.
- `add-deltachat`: native channel registration.
- `add-atomic-chat-tool`: MCP-tool wiring across both runtimes (container registration and host env-helper call).
- `add-opencode` / `add-codex`: the provider multi-point archetype, with two barrels, Dockerfile pins, and per-tree registration tests.
-677
View File
@@ -1,677 +0,0 @@
# Skills as Branches
## Overview
This document covers **feature skills** — skills that add capabilities via git branch merges. This is the most complex skill type and the primary way NanoClaw is extended.
NanoClaw has four types of skills overall. See [CONTRIBUTING.md](../CONTRIBUTING.md) for the full taxonomy:
| Type | Location | How it works |
|------|----------|-------------|
| **Feature** (this doc) | `.claude/skills/` + `skill/*` branch | SKILL.md has instructions; code lives on a branch, applied via `git merge` |
| **Utility** | `.claude/skills/<name>/` with code files | Self-contained tools; code in skill directory, copied into place on install |
| **Operational** | `.claude/skills/` on `main` | Instruction-only workflows (setup, debug, update) |
| **Container** | `container/skills/` | Loaded inside agent containers at runtime |
---
Feature skills are distributed as git branches on the upstream repository. Applying a skill is a `git merge`. Updating core is a `git merge`. Everything is standard git.
This replaces the previous `skills-engine/` system (three-way file merging, `.nanoclaw/` state, manifest files, replay, backup/restore) with plain git operations and Claude for conflict resolution.
## How It Works
### Repository structure
The upstream repo (`nanocoai/nanoclaw`) maintains:
- `main` — core NanoClaw (no skill code)
- `skill/discord` — main + Discord integration
- `skill/telegram` — main + Telegram integration
- `skill/slack` — main + Slack integration
- `skill/gmail` — main + Gmail integration
- etc.
Each skill branch contains all the code changes for that skill: new files, modified source files, updated `package.json` dependencies, `.env.example` additions — everything. No manifest, no structured operations, no separate `add/` and `modify/` directories.
### Skill discovery and installation
Skills are split into two categories:
**Operational skills** (on `main`, always available):
- `/setup`, `/debug`, `/update-nanoclaw`, `/customize`, `/update-skills`
- These are instruction-only SKILL.md files — no code changes, just workflows
- Live in `.claude/skills/` on `main`, immediately available to every user
**Feature skills** (in marketplace, installed on demand):
- `/add-discord`, `/add-telegram`, `/add-slack`, `/add-gmail`, etc.
- Each has a SKILL.md with setup instructions and a corresponding `skill/*` branch with code
- Live in the marketplace repo (`nanocoai/nanoclaw-skills`)
Users never interact with the marketplace directly. The operational skills `/setup` and `/customize` handle plugin installation transparently:
```bash
# Claude runs this behind the scenes — users don't see it
claude plugin install nanoclaw-skills@nanoclaw-skills --scope project
```
Skills are hot-loaded after `claude plugin install` — no restart needed. This means `/setup` can install the marketplace plugin, then immediately run any feature skill, all in one session.
### Selective skill installation
`/setup` asks users what channels they want, then only offers relevant skills:
1. "Which messaging channels do you want to use?" → Discord, Telegram, Slack, WhatsApp
2. User picks Telegram → Claude installs the plugin and runs `/add-telegram`
3. After Telegram is set up: "Want to add Agent Swarm support for Telegram?" → offers `/add-telegram-swarm`
4. "Want to enable community skills?" → installs community marketplace plugins
Dependent skills (e.g., `telegram-swarm` depends on `telegram`) are only offered after their parent is installed. `/customize` follows the same pattern for post-setup additions.
### Marketplace configuration
NanoClaw's `.claude/settings.json` registers the official marketplace:
```json
{
"extraKnownMarketplaces": {
"nanoclaw-skills": {
"source": {
"source": "github",
"repo": "nanocoai/nanoclaw-skills"
}
}
}
}
```
The marketplace repo uses Claude Code's plugin structure:
```
nanocoai/nanoclaw-skills/
.claude-plugin/
marketplace.json # Plugin catalog
plugins/
nanoclaw-skills/ # Single plugin bundling all official skills
.claude-plugin/
plugin.json # Plugin manifest
skills/
add-discord/
SKILL.md # Setup instructions; step 1 is "merge the branch"
add-telegram/
SKILL.md
add-slack/
SKILL.md
...
```
Multiple skills are bundled in one plugin — installing `nanoclaw-skills` makes all feature skills available at once. Individual skills don't need separate installation.
Each SKILL.md tells Claude to merge the corresponding skill branch as step 1, then walks through interactive setup (env vars, bot creation, etc.).
### Applying a skill
User runs `/add-discord` (discovered via marketplace). Claude follows the SKILL.md:
1. `git fetch upstream skill/discord`
2. `git merge upstream/skill/discord`
3. Interactive setup (create bot, get token, configure env vars, etc.)
Or manually:
```bash
git fetch upstream skill/discord
git merge upstream/skill/discord
```
### Applying multiple skills
```bash
git merge upstream/skill/discord
git merge upstream/skill/telegram
```
Git handles the composition. If both skills modify the same lines, it's a real conflict and Claude resolves it.
### Updating core
```bash
git fetch upstream main
git merge upstream/main
```
Since skill branches are kept merged-forward with main (see CI section), the user's merged-in skill changes and upstream changes have proper common ancestors.
### Checking for skill updates
Users who previously merged a skill branch can check for updates. For each `upstream/skill/*` branch, check whether the branch has commits that aren't in the user's HEAD:
```bash
git fetch upstream
for branch in $(git branch -r | grep 'upstream/skill/'); do
# Check if user has merged this skill at some point
merge_base=$(git merge-base HEAD "$branch" 2>/dev/null) || continue
# Check if the skill branch has new commits beyond what the user has
if ! git merge-base --is-ancestor "$branch" HEAD 2>/dev/null; then
echo "$branch has updates available"
fi
done
```
This requires no state — it uses git history to determine which skills were previously merged and whether they have new commits.
This logic is available in two ways:
- Built into `/update-nanoclaw` — after merging main, optionally check for skill updates
- Standalone `/update-skills` — check and merge skill updates independently
### Conflict resolution
At any merge step, conflicts may arise. Claude resolves them — reading the conflicted files, understanding the intent of both sides, and producing the correct result. This is what makes the branch approach viable at scale: conflict resolution that previously required human judgment is now automated.
### Skill dependencies
Some skills depend on other skills. E.g., `skill/telegram-swarm` requires `skill/telegram`. Dependent skill branches are branched from their parent skill branch, not from `main`.
This means `skill/telegram-swarm` includes all of telegram's changes plus its own additions. When a user merges `skill/telegram-swarm`, they get both — no need to merge telegram separately.
Dependencies are implicit in git history — `git merge-base --is-ancestor` determines whether one skill branch is an ancestor of another. No separate dependency file is needed.
### Uninstalling a skill
```bash
# Find the merge commit
git log --merges --oneline | grep discord
# Revert it
git revert -m 1 <merge-commit>
```
This creates a new commit that undoes the skill's changes. Claude can handle the whole flow.
If the user has modified the skill's code since merging (custom changes on top), the revert might conflict — Claude resolves it.
If the user later wants to re-apply the skill, they need to revert the revert first (git treats reverted changes as "already applied and undone"). Claude handles this too.
## CI: Keeping Skill Branches Current
A GitHub Action runs on every push to `main`:
1. List all `skill/*` branches
2. For each skill branch, merge `main` into it (merge-forward, not rebase)
3. Run build and tests on the merged result
4. If tests pass, push the updated skill branch
5. If a skill fails (conflict, build error, test failure), open a GitHub issue for manual resolution
**Why merge-forward instead of rebase:**
- No force-push — preserves history for users who already merged the skill
- Users can re-merge a skill branch to pick up skill updates (bug fixes, improvements)
- Git has proper common ancestors throughout the merge graph
**Why this scales:** With a few hundred skills and a few commits to main per day, the CI cost is trivial. Haiku is fast and cheap. The approach that wouldn't have been feasible a year or two ago is now practical because Claude can resolve conflicts at scale.
## Installation Flow
### New users (recommended)
1. Fork `nanocoai/nanoclaw` on GitHub (click the Fork button)
2. Clone your fork:
```bash
git clone https://github.com/<you>/nanoclaw.git
cd nanoclaw
```
3. Run Claude Code:
```bash
claude
```
4. Run `/setup` — Claude handles dependencies, authentication, container setup, service configuration, and adds `upstream` remote if not present
Forking is recommended because it gives users a remote to push their customizations to. Clone-only works for trying things out but provides no remote backup.
### Existing users migrating from clone
Users who previously ran `git clone https://github.com/nanocoai/nanoclaw.git` and have local customizations:
1. Fork `nanocoai/nanoclaw` on GitHub
2. Reroute remotes:
```bash
git remote rename origin upstream
git remote add origin https://github.com/<you>/nanoclaw.git
git push --force origin main
```
The `--force` is needed because the fresh fork's main is at upstream's latest, but the user wants their (possibly behind) version. The fork was just created so there's nothing to lose.
3. From this point, `origin` = their fork, `upstream` = nanocoai/nanoclaw
### Existing users migrating from the old skills engine
Users who previously applied skills via the `skills-engine/` system have skill code in their tree but no merge commits linking to skill branches. Git doesn't know these changes came from a skill, so merging a skill branch on top would conflict or duplicate.
**For new skills going forward:** just merge skill branches as normal. No issue.
**For existing old-engine skills**, two migration paths:
**Option A: Per-skill reapply (keep your fork)**
1. For each old-engine skill: identify and revert the old changes, then merge the skill branch fresh
2. Claude assists with identifying what to revert and resolving any conflicts
3. Custom modifications (non-skill changes) are preserved
**Option B: Fresh start (cleanest)**
1. Create a new fork from upstream
2. Merge the skill branches you want
3. Manually re-apply your custom (non-skill) changes
4. Claude assists by diffing your old fork against the new one to identify custom changes
In both cases:
- Delete the `.nanoclaw/` directory (no longer needed)
- The `skills-engine/` code will be removed from upstream once all skills are migrated
- `/update-skills` only tracks skills applied via branch merge — old-engine skills won't appear in update checks
## User Workflows
### Custom changes
Users make custom changes directly on their main branch. This is the standard fork workflow — their `main` IS their customized version.
```bash
# Make changes
vim src/config.ts
git commit -am "change trigger word to @Bob"
git push origin main
```
Custom changes, skills, and core updates all coexist on their main branch. Git handles the three-way merging at each merge step because it can trace common ancestors through the merge history.
### Applying a skill
Run `/add-discord` in Claude Code (discovered via the marketplace plugin), or manually:
```bash
git fetch upstream skill/discord
git merge upstream/skill/discord
# Follow setup instructions for configuration
git push origin main
```
If the user is behind upstream's main when they merge a skill branch, the merge might bring in some core changes too (since skill branches are merged-forward with main). This is generally fine — they get a compatible version of everything.
### Updating core
```bash
git fetch upstream main
git merge upstream/main
git push origin main
```
This is the same as the existing `/update-nanoclaw` skill's merge path.
### Updating skills
Run `/update-skills` or let `/update-nanoclaw` check after a core update. For each previously-merged skill branch that has new commits, Claude offers to merge the updates.
### Contributing back to upstream
Users who want to submit a PR to upstream:
```bash
git fetch upstream main
git checkout -b my-fix upstream/main
# Make changes
git push origin my-fix
# Create PR from my-fix to nanocoai/nanoclaw:main
```
Standard fork contribution workflow. Their custom changes stay on their main and don't leak into the PR.
## Contributing a Skill
The flow below is for **feature skills** (branch-based). For utility skills (self-contained tools) and container skills, the contributor opens a PR that adds files directly to `.claude/skills/<name>/` or `container/skills/<name>/` — no branch extraction needed. See [CONTRIBUTING.md](../CONTRIBUTING.md) for all skill types.
### Contributor flow (feature skills)
1. Fork `nanocoai/nanoclaw`
2. Branch from `main`
3. Make the code changes (new channel file, modified integration points, updated package.json, .env.example additions, etc.)
4. Open a PR to `main`
The contributor opens a normal PR — they don't need to know about skill branches or marketplace repos. They just make code changes and submit.
### Maintainer flow
When a skill PR is reviewed and approved:
1. Create a `skill/<name>` branch from the PR's commits:
```bash
git fetch origin pull/<PR_NUMBER>/head:skill/<name>
git push origin skill/<name>
```
2. Force-push to the contributor's PR branch, replacing it with a single commit that adds the contributor to `CONTRIBUTORS.md` (removing all code changes)
3. Merge the slimmed PR into `main` (just the contributor addition)
4. Add the skill's SKILL.md to the marketplace repo (`nanocoai/nanoclaw-skills`)
This way:
- The contributor gets merge credit (their PR is merged)
- They're added to CONTRIBUTORS.md automatically by the maintainer
- The skill branch is created from their work
- `main` stays clean (no skill code)
- The contributor only had to do one thing: open a PR with code changes
**Note:** GitHub PRs from forks have "Allow edits from maintainers" checked by default, so the maintainer can push to the contributor's PR branch.
### Skill SKILL.md
The contributor can optionally provide a SKILL.md (either in the PR or separately). This goes into the marketplace repo and contains:
1. Frontmatter (name, description, triggers)
2. Step 1: Merge the skill branch
3. Steps 2-N: Interactive setup (create bot, get token, configure env vars, verify)
If the contributor doesn't provide a SKILL.md, the maintainer writes one based on the PR.
## Community Marketplaces
Anyone can maintain their own fork with skill branches and their own marketplace repo. This enables a community-driven skill ecosystem without requiring write access to the upstream repo.
### How it works
A community contributor:
1. Maintains a fork of NanoClaw (e.g., `alice/nanoclaw`)
2. Creates `skill/*` branches on their fork with their custom skills
3. Creates a marketplace repo (e.g., `alice/nanoclaw-skills`) with a `.claude-plugin/marketplace.json` and plugin structure
### Adding a community marketplace
If the community contributor is trusted, they can open a PR to add their marketplace to NanoClaw's `.claude/settings.json`:
```json
{
"extraKnownMarketplaces": {
"nanoclaw-skills": {
"source": {
"source": "github",
"repo": "nanocoai/nanoclaw-skills"
}
},
"alice-nanoclaw-skills": {
"source": {
"source": "github",
"repo": "alice/nanoclaw-skills"
}
}
}
}
```
Once merged, all NanoClaw users automatically discover the community marketplace alongside the official one.
### Installing community skills
`/setup` and `/customize` ask users whether they want to enable community skills. If yes, Claude installs community marketplace plugins via `claude plugin install`:
```bash
claude plugin install alice-skills@alice-nanoclaw-skills --scope project
```
Community skills are hot-loaded and immediately available — no restart needed. Dependent skills are only offered after their prerequisites are met (e.g., community Telegram add-ons only after Telegram is installed).
Users can also browse and install community plugins manually via `/plugin`.
### Properties of this system
- **No gatekeeping required.** Anyone can create skills on their fork without permission. They only need approval to be listed in the auto-discovered marketplaces.
- **Multiple marketplaces coexist.** Users see skills from all trusted marketplaces in `/plugin`.
- **Community skills use the same merge pattern.** The SKILL.md just points to a different remote:
```bash
git remote add alice https://github.com/alice/nanoclaw.git
git fetch alice skill/my-cool-feature
git merge alice/skill/my-cool-feature
```
- **Users can also add marketplaces manually.** Even without being listed in settings.json, users can run `/plugin marketplace add alice/nanoclaw-skills` to discover skills from any source.
- **CI is per-fork.** Each community maintainer runs their own CI to keep their skill branches merged-forward. They can use the same GitHub Action as the upstream repo.
## Flavors
A flavor is a curated fork of NanoClaw — a combination of skills, custom changes, and configuration tailored for a specific use case (e.g., "NanoClaw for Sales," "NanoClaw Minimal," "NanoClaw for Developers").
### Creating a flavor
1. Fork `nanocoai/nanoclaw`
2. Merge in the skills you want
3. Make custom changes (trigger word, prompts, integrations, etc.)
4. Your fork's `main` IS the flavor
### Installing a flavor
During `/setup`, users are offered a choice of flavors before any configuration happens. The setup skill reads `flavors.yaml` from the repo (shipped with upstream, always up to date) and presents options:
AskUserQuestion: "Start with a flavor or default NanoClaw?"
- Default NanoClaw
- NanoClaw for Sales — Gmail + Slack + CRM (maintained by alice)
- NanoClaw Minimal — Telegram-only, lightweight (maintained by bob)
If a flavor is chosen:
```bash
git remote add <flavor-name> https://github.com/alice/nanoclaw.git
git fetch <flavor-name> main
git merge <flavor-name>/main
```
Then setup continues normally (dependencies, auth, container, service).
**This choice is only offered on a fresh fork** — when the user's main matches or is close to upstream's main with no local commits. If `/setup` detects significant local changes (re-running setup on an existing install), it skips the flavor selection and goes straight to configuration.
After installation, the user's fork has three remotes:
- `origin` — their fork (push customizations here)
- `upstream``nanocoai/nanoclaw` (core updates)
- `<flavor-name>` — the flavor fork (flavor updates)
### Updating a flavor
```bash
git fetch <flavor-name> main
git merge <flavor-name>/main
```
The flavor maintainer keeps their fork updated (merging upstream, updating skills). Users pull flavor updates the same way they pull core updates.
### Flavors registry
`flavors.yaml` lives in the upstream repo:
```yaml
flavors:
- name: NanoClaw for Sales
repo: alice/nanoclaw
description: Gmail + Slack + CRM integration, daily pipeline summaries
maintainer: alice
- name: NanoClaw Minimal
repo: bob/nanoclaw
description: Telegram-only, no container overhead
maintainer: bob
```
Anyone can PR to add their flavor. The file is available locally when `/setup` runs since it's part of the cloned repo.
### Discoverability
- **During setup** — flavor selection is offered as part of the initial setup flow
- **`/browse-flavors` skill** — reads `flavors.yaml` and presents options at any time
- **GitHub topics** — flavor forks can tag themselves with `nanoclaw-flavor` for searchability
- **Discord / website** — community-curated lists
## Migration
Migration from the old skills engine to branches is complete. All feature skills now live on `skill/*` branches, and the skills engine has been removed.
### Skill branches
| Branch | Base | Description |
|--------|------|-------------|
| `skill/whatsapp` | `main` | WhatsApp channel |
| `skill/telegram` | `main` | Telegram channel |
| `skill/slack` | `main` | Slack channel |
| `skill/discord` | `main` | Discord channel |
| `skill/gmail` | `main` | Gmail channel |
| `skill/voice-transcription` | `skill/whatsapp` | OpenAI Whisper voice transcription |
| `skill/image-vision` | `skill/whatsapp` | Image attachment processing |
| `skill/pdf-reader` | `skill/whatsapp` | PDF attachment reading |
| `skill/local-whisper` | `skill/voice-transcription` | Local whisper.cpp transcription |
| `skill/ollama-tool` | `main` | Ollama MCP server for local models |
| `skill/apple-container` | `main` | Apple Container runtime |
| `skill/reactions` | `main` | WhatsApp emoji reactions |
### What was removed
- `skills-engine/` directory (entire engine)
- `scripts/apply-skill.ts`, `scripts/uninstall-skill.ts`, `scripts/rebase.ts`
- `scripts/fix-skill-drift.ts`, `scripts/validate-all-skills.ts`
- `.github/workflows/skill-drift.yml`, `.github/workflows/skill-pr.yml`
- All `add/`, `modify/`, `tests/`, and `manifest.yaml` from skill directories
- `.nanoclaw/` state directory
Operational skills (`setup`, `debug`, `update-nanoclaw`, `customize`, `update-skills`) remain on main in `.claude/skills/`.
## What Changes
### README Quick Start
Before:
```bash
git clone https://github.com/nanocoai/NanoClaw.git
cd NanoClaw
claude
```
After:
```
1. Fork nanocoai/nanoclaw on GitHub
2. git clone https://github.com/<you>/nanoclaw.git
3. cd nanoclaw
4. claude
5. /setup
```
### Setup skill (`/setup`)
Updates to the setup flow:
- Check if `upstream` remote exists; if not, add it: `git remote add upstream https://github.com/nanocoai/nanoclaw.git`
- Check if `origin` points to the user's fork (not nanocoai). If it points to nanocoai, guide them through the fork migration.
- **Install marketplace plugin:** `claude plugin install nanoclaw-skills@nanoclaw-skills --scope project` — makes all feature skills available (hot-loaded, no restart)
- **Ask which channels to add:** present channel options (Discord, Telegram, Slack, WhatsApp, Gmail), run corresponding `/add-*` skills for selected channels
- **Offer dependent skills:** after a channel is set up, offer relevant add-ons (e.g., Agent Swarm after Telegram, voice transcription after WhatsApp)
- **Optionally enable community marketplaces:** ask if the user wants community skills, install those marketplace plugins too
### `.claude/settings.json`
Marketplace configuration so the official marketplace is auto-registered:
```json
{
"extraKnownMarketplaces": {
"nanoclaw-skills": {
"source": {
"source": "github",
"repo": "nanocoai/nanoclaw-skills"
}
}
}
}
```
### Skills directory on main
The `.claude/skills/` directory on `main` retains only operational skills (setup, debug, update-nanoclaw, customize, update-skills). Feature skills (add-discord, add-telegram, etc.) live in the marketplace repo, installed via `claude plugin install` during `/setup` or `/customize`.
### Skills engine removal
The following can be removed:
- `skills-engine/` — entire directory (apply, merge, replay, state, backup, etc.)
- `scripts/apply-skill.ts`
- `scripts/uninstall-skill.ts`
- `scripts/fix-skill-drift.ts`
- `scripts/validate-all-skills.ts`
- `.nanoclaw/` — state directory
- `add/` and `modify/` subdirectories from all skill directories
- Feature skill SKILL.md files from `.claude/skills/` on main (they now live in the marketplace)
Operational skills (`setup`, `debug`, `update-nanoclaw`, `customize`, `update-skills`) remain on main in `.claude/skills/`.
### New infrastructure
- **Marketplace repo** (`nanocoai/nanoclaw-skills`) — single Claude Code plugin bundling SKILL.md files for all feature skills
- **CI GitHub Action** — merge-forward `main` into all `skill/*` branches on every push to `main`, using Claude (Haiku) for conflict resolution
- **`/update-skills` skill** — checks for and applies skill branch updates using git history
- **`CONTRIBUTORS.md`** — tracks skill contributors
### Update skill (`/update-nanoclaw`)
The update skill gets simpler with the branch-based approach. The old skills engine required replaying all applied skills after merging core updates — that entire step disappears. Skill changes are already in the user's git history, so `git merge upstream/main` just works.
**What stays the same:**
- Preflight (clean working tree, upstream remote)
- Backup branch + tag
- Preview (git log, git diff, file buckets)
- Merge/cherry-pick/rebase options
- Conflict preview (dry-run merge)
- Conflict resolution
- Build + test validation
- Rollback instructions
**What's removed:**
- Skill replay step (was needed by the old skills engine to re-apply skills after core update)
- Re-running structured operations (npm deps, env vars — these are part of git history now)
**What's added:**
- Optional step at the end: "Check for skill updates?" which runs the `/update-skills` logic
- This checks whether any previously-merged skill branches have new commits (bug fixes, improvements to the skill itself — not just merge-forwards from main)
**Why users don't need to re-merge skills after a core update:**
When the user merged a skill branch, those changes became part of their git history. When they later merge `upstream/main`, git performs a normal three-way merge — the skill changes in their tree are untouched, and only core changes are brought in. The merge-forward CI ensures skill branches stay compatible with latest main, but that's for new users applying the skill fresh. Existing users who already merged the skill don't need to do anything.
Users only need to re-merge a skill branch if the skill itself was updated (not just merged-forward with main). The `/update-skills` check detects this.
## Discord Announcement
### For existing users
> **Skills are now git branches**
>
> We've simplified how skills work in NanoClaw. Instead of a custom skills engine, skills are now git branches that you merge in.
>
> **What this means for you:**
> - Applying a skill: `git fetch upstream skill/discord && git merge upstream/skill/discord`
> - Updating core: `git fetch upstream main && git merge upstream/main`
> - Checking for skill updates: `/update-skills`
> - No more `.nanoclaw/` state directory or skills engine
>
> **We now recommend forking instead of cloning.** This gives you a remote to push your customizations to.
>
> **If you currently have a clone with local changes**, migrate to a fork:
> 1. Fork `nanocoai/nanoclaw` on GitHub
> 2. Run:
> ```
> git remote rename origin upstream
> git remote add origin https://github.com/<you>/nanoclaw.git
> git push --force origin main
> ```
> This works even if you're way behind — just push your current state.
>
> **If you previously applied skills via the old system**, your code changes are already in your working tree — nothing to redo. You can delete the `.nanoclaw/` directory. Future skills and updates use the branch-based approach.
>
> **Discovering skills:** Skills are now available through Claude Code's plugin marketplace. Run `/plugin` in Claude Code to browse and install available skills.
### For skill contributors
> **Contributing skills**
>
> To contribute a skill:
> 1. Fork `nanocoai/nanoclaw`
> 2. Branch from `main` and make your code changes
> 3. Open a regular PR
>
> That's it. We'll create a `skill/<name>` branch from your PR, add you to CONTRIBUTORS.md, and add the SKILL.md to the marketplace. CI automatically keeps skill branches merged-forward with `main` using Claude to resolve any conflicts.
>
> **Want to run your own skill marketplace?** Maintain skill branches on your fork and create a marketplace repo. Open a PR to add it to NanoClaw's auto-discovered marketplaces — or users can add it manually via `/plugin marketplace add`.
+150
View File
@@ -0,0 +1,150 @@
# The skills model
How NanoClaw stays customizable without breaking its forks. This is the full version; [customizing.md](customizing.md) is the short one, and [skill-guidelines.md](skill-guidelines.md) is the authoritative checklist for writing a skill.
## The problem
People fork NanoClaw and change the code. When we ship updates, their changes collide with ours and `git merge` turns into a fight. The more someone customized, the worse it gets. We can't grow the core without breaking everyone downstream.
## The bet
Every customization is a skill: not an edit buried in the core, but a skill that adds the change on top.
The core stays small and stable. Everything else composes on top as skills. Adding your 1st skill and your 500th skill is the same amount of work.
This works for any fork: a personal install with three tweaks, a company build with fifty.
## A fork is a recipe of skills
You don't track your changes as a pile of edits. You track them as skills.
- Each customization = one small skill.
- One "recipe" skill lists all your skills and how they fit together: the order, and any dependencies between them.
So a fork is defined by its recipe. Most upgrades don't need to run it (see "Upgrading"), but it's what lets you rebuild the fork from scratch on clean upstream, and it's how you hand your whole fork to someone else. It replaces every "what did I change" artifact you'd otherwise keep (a migration guide, a manifest, a pile of notes) with one runnable thing.
The recipe is the one fork-specific thing. It lives in your fork, never upstream. (A recipe is itself a skill: a SKILL.md listing the fork's skills in apply order.)
## What's in a skill
A skill carries everything it needs:
- **Its code**: the files it adds (see "Where a skill's files live").
- **Apply and remove.** Apply installs it; remove uninstalls it. Uninstall isn't a separate problem; it ships with the skill. (Remove is required exactly when apply leaves anything behind. A pure instruction-only skill that changes nothing needs none.)
- **Its tests**: see "A test for every integration point." The tests *are* the verification. If they pass against the composed project, the skill applied correctly and works; there is no separate "verify" step.
- **Its recipe entry**: how it composes with the others.
Apply must be safe to re-run. Upgrades re-run skills, so a skill that half-applies twice is a bug.
## Two kinds of skills
- **Capability skills** add something new: a channel, a provider, a tool, a dashboard.
- **Patch skills** make small tweaks or bug fixes to existing behavior, instead of adding a capability.
Patch skills follow the same rules: a test for every edit, and code pushed into independent files wherever possible instead of inline. To keep the overhead down, bundle several small patches into a single patch skill rather than making one skill per one-line fix.
One honest exception: a bug fix that genuinely changes an existing line can't always be moved into a new file. That single line is the one place an upgrade can still hard-conflict. If upstream touched the same line, the fix has to be re-derived against the new code. That's fine when it's small and tested; just don't pretend it's free.
(Packaging is a separate axis: some skills fetch code from a registry branch, some ship files in their own folder, some are pure instructions.)
## What makes a good skill
A good skill mostly just *adds* things:
- Adds new files.
- Adds a line to an existing file (an import, an entry, a line in `.env`).
- Adds a dependency.
- Changes a value in a JSON file like `package.json`.
These never really break.
The one risky move is when a skill has to *reach into* existing code and wire something in at a specific spot. That's the only part that breaks when we change the code later. Keep these rare, and keep them to a line or two that just *calls* code living in the skill's own files, not big chunks of logic inline.
Rule of thumb: aim for skills that are almost all "adds." Not 100%; some reach-ins are fine. But a skill full of reach-ins is a smell, and a sign that spot in the core should become a proper hook.
## Where a skill's files live
The files a skill adds live in the skill's own folder, and the skill copies them into the project when it runs. The skill is self-contained.
The exception is skills that plug into a registry: channels and providers. Their code is larger, multi-file, and has to stay in sync with the core as it changes over time. That code lives on a long-lived **registry branch** (`channels`, `providers`) that we forward-merge against main, and the skill fetches it from there (`git show origin/channels:path > path`). A frozen copy in a skill folder would go stale.
This fetch is **additive, never a merge**. The skill copies in the files it needs; it does *not* `git merge` the branch. Merging a registry branch into a customized install is exactly the conflict fight this model exists to avoid. A skill's **tests live on the branch alongside its code** and are fetched the same way; a channel's adapter travels with its registration test. A provider is the multi-point case: its code spans the host *and* container trees plus a Dockerfile edit, so it fetches files into both trees and ships a registration test per tree. See the provider archetype in [skill-guidelines.md](skill-guidelines.md).
Either way the skill brings its own code, from its folder or from its branch.
## A test for every integration point
The tests a skill *must* ship are the ones that prove it integrates with the core and keeps working as the core changes. That's the whole point. Tests of a skill's own internal logic, or of its behavior against an external service, are fine but optional: the creator's call, because they don't guard against upstream changes. A pure-add skill that touches nothing existing needs no required integration test at all.
The places that break on upgrade are the **integration points**: wherever a skill reaches into the existing system. That's not just the obvious code edit. An appended import, a config entry, a Dockerfile change, a mount, an installed dependency, and a direct read of the core's data all count. Each gets a guard that goes **red if it breaks or goes missing**:
- **A behavior or structural test of the wiring.** Prefer behavior when the seam is queryable at runtime: a channel's registration test imports the real barrel and asserts the registry contains it. Fall back to a structural test only for wiring with no invocable seam.
- **The build / typecheck.** Always on. It catches the drift a runtime test can't: a renamed symbol, a moved module, a changed signature.
- **Coverage of how an added file consumes the core.** When a skill's own file reaches into core APIs or data, a test must exercise that consumption against the *real* core. That's the leg that catches core drift.
Why points and not whole skills: a skill can have several, and each is a separate way to break. The count is honest signal: a skill's integration points are exactly its upgrade risk. Pure-add skills have zero and stay cheap.
This is what makes upgrades cheap to fix: when we move something in the core, the integration-point tests are exactly what fail, and that failing list *is* the set of skills to update.
**Tests travel with the skill.** They're files kept with the skill, in its folder or on its branch, and applying the skill copies them into the project's test tree. An integration-point test has to run against the *composed* system, so it only means anything once the skill is applied.
**The recipe tests the stack.** A single skill's tests prove that skill works alone. The recipe carries tests that run the skills *together*, in order. That's where you catch two skills that collide.
The full testing doctrine (how to pick the test type per point, the archetypes, the dependency cases) is in [skill-guidelines.md](skill-guidelines.md).
## How you actually work
You don't have to write a skill before you touch anything. Edit the code directly, get it working, then turn those edits into skills afterward; a coding agent does that conversion. Good authoring guidelines and a good recipe make skillifying-after-the-fact close to trivial.
The point isn't to slow you down at edit time. It's that nothing counts as part of your fork until it's a skill, because that's the only form that survives an upgrade.
## Upgrading
**Every update goes through `/update-nanoclaw`, never a raw `git pull`.** You don't know what an update contains until it lands; it might carry a breaking change with a migration. So the command inspects what's coming and runs the proper process: back up, pull the changes in, apply migrations, run tests, fix what broke, and flag when a fresh rebuild is needed instead.
Two different moves, two different rules. Your **fork pulls trunk**: that's a normal pull, run by the update command, and it's safe precisely because your changes live beside the core as skills rather than inside it. A **skill never merges**: it installs by fetching files and copying them in. If a skill's instructions say `git merge`, it isn't built to this model.
The update takes one of two paths:
**Normal upgrade: pull and fix what breaks.** Most of the time it pulls the latest upstream, resolves the occasional small conflict, runs the tests, and fixes whatever they flag. This stays cheap *because* the changes are small self-contained skills with tests: conflicts are rare, and when something does break, the failing test points at the exact skill and the fix is local.
**Rebuild from the recipe: the rare path.** Take fresh upstream and apply every skill from scratch. The command flags this when you've fallen far behind across many breaking changes (a clean rebuild beats catching up step by step). It's also how you hand your entire fork to someone else.
Around both:
- **The update skill updates itself first.** The first thing it does is fetch the latest version of the upgrade process. Otherwise you're upgrading with stale instructions.
- **Snapshot first, restore on failure.** The upgrade sets a rollback point before it starts: today a git backup branch and tag; the model calls for a full project snapshot (code, database, data, files) so anything that fails rolls back and retries. Until that snapshot lands, a migration that touches data makes its own data backup. Nothing in the upgrade needs its own undo logic.
- **Broken skills don't block you.** If a core change broke a skill, its test tells you, but the skill is usually still usable, and an agent fixes it at apply time. Skills are fixed lazily, when applied, not ahead of time for every core version.
## Migrations
Migrations are core, not an afterthought. Every breaking change ships with its migration, packaged together. A "migration" is broad: upgrading dependencies, a database change, a data backfill, moving files to new locations, whatever the change requires.
Migrations are **forward-only**. They don't need reverse scripts; the rollback point in front of the upgrade is the undo. If one fails, restore and retry.
A **startup tripwire** keeps installs on the supported path. Every sanctioned update path (install, update, migrate) stamps a marker with the version it reached; at startup the host checks that marker against the running code. If it's missing or doesn't match, because someone pulled by hand, the host stops, loudly, with the exact command to fix it instead of silently breaking.
The tripwire doesn't reason about *which* changes are breaking; it just enforces that the path was used. (DB schema migrations already run automatically at startup, so they aren't its concern; it guards everything else a raw `git pull` leaves undone.) To override, you stamp the marker yourself: an explicit "I know what I'm doing," not a deletion. If you have your **own** upgrade flow (a deploy script, a CI job), make stamping the last step after it succeeds: `pnpm exec tsx scripts/upgrade-state.ts set`. See [upgrade-recovery.md](upgrade-recovery.md).
## The maintainer's side of the deal
This is a two-sided contract. Users keep their changes as skills. In return, the maintainer keeps the core stable and owns the breakage.
As maintainer:
1. **Keep the core small and stable.** Resist hardwiring features into the core. Push them to skills too.
2. **Before shipping a core change, run the skills against it.** That tells you what you broke before users find out.
3. **When you break a skill, you fix it, not the users.** If a refactor moves something, update the affected skills or ship a migration. Don't make every user rediscover the same fix.
4. **Ship the migration with the breaking change.** Packaged together: code, DB, files. Not a separate "good luck" note.
5. **Watch for hotspots.** When lots of skills reach into the same spot in the core, that's the signal to add a proper hook there, so those reach-ins become clean adds.
6. **Test against real forks.** Every core change and migration runs against a fleet of real, skill-built forks before shipping. Real proof on real installs.
## The public registry
Skills will be shared and composed; that's the whole point. A skill runs real code when it applies (copies files, installs dependencies, edits the Dockerfile). So a public registry of skills is a trust surface.
The rule: **every skill is reviewed and approved before it goes into the public registry, and every new version is re-reviewed.** Approving once and trusting forever is how supply chains get poisoned. Automated checks (linting against the guidelines, plus a harness that applies the skill on fresh upstream, runs its tests, removes it, and applies it twice) will clear the mechanical part so human review can focus on intent and safety. First-party skills are trusted by where they come from; the gate is for the public registry.
## The promise
Build your changes as skills following this, and we won't break you. It's a promise we can only make for skills: changes edited directly into the core are beyond what we can protect.
+38
View File
@@ -25,6 +25,44 @@ set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$PROJECT_ROOT"
# ─── --uninstall: short-circuit before any setup work ──────────────────
# Never install dependencies just to uninstall. With the TS toolchain
# present, hand straight off to setup:auto (the flow lives in
# setup/uninstall/); without it, print manual cleanup guidance. Runs
# before diagnostics.sh is sourced so a pure uninstall doesn't emit
# setup_launched, and before all pre-flights/bootstrap.
for arg in "$@"; do
if [ "$arg" = "--uninstall" ]; then
# exec tsx directly rather than `pnpm run -- …`: pnpm passes the `--`
# separator through to the script, where the flag parser treats
# everything after it as positional args and the flags get dropped.
# Gate on node (tsx's shebang interpreter) — pnpm isn't used here.
if command -v node >/dev/null 2>&1 && [ -x "$PROJECT_ROOT/node_modules/.bin/tsx" ]; then
exec "$PROJECT_ROOT/node_modules/.bin/tsx" "$PROJECT_ROOT/setup/auto.ts" "$@"
fi
export NANOCLAW_PROJECT_ROOT="$PROJECT_ROOT"
# shellcheck source=setup/lib/install-slug.sh
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
UNINSTALL_RUNTIME="${CONTAINER_RUNTIME:-docker}"
echo "Can't run the uninstaller: dependencies are missing (node_modules/)."
echo "Either re-run 'bash nanoclaw.sh' once to restore them, or clean up manually:"
echo ""
if [ "$(uname -s)" = "Darwin" ]; then
echo " launchctl unload ~/Library/LaunchAgents/$(launchd_label).plist"
echo " rm -f ~/Library/LaunchAgents/$(launchd_label).plist"
else
echo " systemctl --user disable --now $(systemd_unit).service"
echo " rm -f ~/.config/systemd/user/$(systemd_unit).service && systemctl --user daemon-reload"
fi
echo " $UNINSTALL_RUNTIME ps -aq --filter label=nanoclaw-install=$(_nanoclaw_install_slug) | xargs -r $UNINSTALL_RUNTIME rm -f"
echo " $UNINSTALL_RUNTIME rmi $(container_image_base):latest"
echo " rm -f ~/.local/bin/ncl # only if it points at this folder"
echo ""
echo "Then back up $PROJECT_ROOT/.env if you need the keys, and delete the folder."
exit 1
fi
done
LOGS_DIR="$PROJECT_ROOT/logs"
STEPS_DIR="$LOGS_DIR/setup-steps"
PROGRESS_LOG="$LOGS_DIR/setup.log"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.1.0",
"version": "2.1.16",
"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": "^0.5.0",
"@onecli-sh/sdk": "2.2.1",
"better-sqlite3": "11.10.0",
"chat": "^4.24.0",
"cron-parser": "5.5.0",
+5 -5
View File
@@ -15,8 +15,8 @@ importers:
specifier: ^1.2.0
version: 1.2.0
'@onecli-sh/sdk':
specifier: ^0.5.0
version: 0.5.0
specifier: 2.2.1
version: 2.2.1
better-sqlite3:
specifier: 11.10.0
version: 11.10.0
@@ -303,8 +303,8 @@ packages:
'@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1
'@onecli-sh/sdk@0.5.0':
resolution: {integrity: sha512-oe5Yx9o98v6N1PgzcCR7nULHHqcqKWNJIDOHGOSNX+l20mLlZpFUqfKPeFmsojBNRQMoqbvZQKUlFMp6gVuYBA==}
'@onecli-sh/sdk@2.2.1':
resolution: {integrity: sha512-q2mCW4ZsARlLEoTxz/P0NQ4MiCh7Z2n28pxkSc7srS+tozyw40PdTnWYW7NI8hfSYplZTx5856Adq1iPi4KN3Q==}
engines: {node: '>=20'}
'@oxc-project/types@0.124.0':
@@ -1665,7 +1665,7 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
'@onecli-sh/sdk@0.5.0': {}
'@onecli-sh/sdk@2.2.1': {}
'@oxc-project/types@0.124.0': {}
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="181k tokens, 91% of context window">
<title>181k tokens, 91% of context window</title>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="195k tokens, 98% of context window">
<title>195k tokens, 98% of context window</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,8 +15,8 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
<text x="26" y="14">tokens</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">181k</text>
<text x="71" y="14">181k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">195k</text>
<text x="71" y="14">195k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+6
View File
@@ -21,6 +21,7 @@ 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,
@@ -102,6 +103,7 @@ 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');
@@ -123,6 +125,10 @@ 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);
+20 -8
View File
@@ -30,10 +30,11 @@
* 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 } from '../src/config.js';
import { DATA_DIR, GROUPS_DIR } from '../src/config.js';
import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js';
import { initDb } from '../src/db/connection.js';
import {
@@ -47,8 +48,7 @@ import { normalizeName } from '../src/modules/agent-to-agent/db/agent-destinatio
import { addMember } from '../src/modules/permissions/db/agent-group-members.js';
import { getUserRoles, grantRole } from '../src/modules/permissions/db/user-roles.js';
import { upsertUser } from '../src/modules/permissions/db/users.js';
import { updateContainerConfigScalars } from '../src/db/container-configs.js';
import { initGroupFilesystem } from '../src/group-init.js';
import { ensureContainerConfig, updateContainerConfigScalars } from '../src/db/container-configs.js';
import { namespacedPlatformId } from '../src/platform-id.js';
import type { AgentGroup, MessagingGroup } from '../src/types.js';
@@ -189,6 +189,7 @@ 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');
@@ -204,12 +205,23 @@ async function main(): Promise<void> {
} else {
console.log(`Reusing agent group: ${ag.id} (${folder})`);
}
initGroupFilesystem(ag, {
instructions:
`# ${args.agentName}\n\n` +
// 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` +
`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.',
});
'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',
);
// 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.
+226
View File
@@ -0,0 +1,226 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { applySkill, removeSkill, planSkill, type Prompter } from './skill-apply.js';
// A synthetic skill exercising the fs handlers for real (no network), plus one
// directive the engine can't handle — to prove it bounces to an agent, not abort.
const SKILL = `# demo skill
## Copy the file
\`\`\`nc:copy
resources/sample.ts -> src/sample.ts
\`\`\`
## Register it
\`\`\`nc:append to:src/barrel.ts
import './sample.js';
\`\`\`
## Capture and store a secret
\`\`\`nc:prompt token secret
Paste the demo token.
\`\`\`
\`\`\`nc:env-set
DEMO_TOKEN={{token}}
\`\`\`
## A step the engine can't do deterministically
Hand-edit the scheduler to register the demo hook.
\`\`\`nc:patch-scheduler
register demo
\`\`\`
`;
let root: string;
let skillDir: string;
const headless = (vals: Record<string, string>): Prompter => ({ async ask(name) { return vals[name]; } });
const recordingExec = () => {
const cmds: string[] = [];
return { cmds, exec: (c: string) => void cmds.push(c) };
};
beforeEach(() => {
skillDir = mkdtempSync(join(tmpdir(), 'nc-skill-'));
root = mkdtempSync(join(tmpdir(), 'nc-proj-'));
mkdirSync(join(skillDir, 'resources'), { recursive: true });
writeFileSync(join(skillDir, 'SKILL.md'), SKILL);
writeFileSync(join(skillDir, 'resources/sample.ts'), 'export const sample = true;\n');
mkdirSync(join(root, 'src'), { recursive: true });
writeFileSync(join(root, 'src/barrel.ts'), '// channel barrel\n');
writeFileSync(join(root, '.env'), '');
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
});
describe('apply engine lifecycle', () => {
it('applies fs directives, captures the secret, and bounces the unknown step to an agent', async () => {
const { exec } = recordingExec();
const res = await applySkill(skillDir, root, { prompter: headless({ token: 'sekret-123' }), exec });
// mutations happened
expect(existsSync(join(root, 'src/sample.ts'))).toBe(true);
expect(readFileSync(join(root, 'src/barrel.ts'), 'utf8')).toContain("import './sample.js';");
expect(readFileSync(join(root, '.env'), 'utf8')).toContain('DEMO_TOKEN=sekret-123');
// the unknown directive went to an agent — with prose — not the human, not an abort
expect(res.agentTasks).toHaveLength(1);
expect(res.agentTasks[0].kind).toBe('patch-scheduler');
expect(res.agentTasks[0].prose).toContain('Hand-edit the scheduler');
expect(res.deferred).toEqual([]);
expect(res.journal.length).toBeGreaterThanOrEqual(3); // wrote + appended + set-env
});
it('is idempotent — a second apply changes nothing', async () => {
const p = headless({ token: 'sekret-123' });
await applySkill(skillDir, root, { prompter: p, exec: () => {} });
const second = await applySkill(skillDir, root, { prompter: p, exec: () => {} });
expect(second.applied).toEqual([]); // everything already applied
expect(second.journal).toEqual([]); // nothing mutated
expect(second.skipped.length).toBeGreaterThanOrEqual(3);
});
it('removes cleanly from the journal — no hand-written REMOVE.md', async () => {
const res = await applySkill(skillDir, root, { prompter: headless({ token: 'sekret-123' }), exec: () => {} });
await removeSkill(root, res.journal);
expect(existsSync(join(root, 'src/sample.ts'))).toBe(false);
expect(readFileSync(join(root, 'src/barrel.ts'), 'utf8')).not.toContain("import './sample.js';");
expect(readFileSync(join(root, '.env'), 'utf8')).not.toContain('DEMO_TOKEN');
});
it('defers a prompt (and its consumer) when the prompter has no value — headless rebuild', async () => {
const res = await applySkill(skillDir, root, { prompter: headless({}), exec: () => {} });
expect(res.deferred).toContain('token'); // prompt deferred
expect(res.deferred.some((d) => /unresolved \{\{token\}\}/.test(d))).toBe(true); // env-set blocked on it
expect(readFileSync(join(root, '.env'), 'utf8')).not.toContain('DEMO_TOKEN');
});
it('plan marks the unknown step ↳agent and the prompt ? needs-input before any write', () => {
const { steps, agentSteps, needsInput } = planSkill(skillDir, root);
expect(agentSteps).toBe(1);
expect(needsInput).toContain('token');
expect(existsSync(join(root, 'src/sample.ts'))).toBe(false); // planning mutated nothing
});
});
// json-merge: push a body object into an array-of-objects JSON file, keyed.
const JSON_MERGE_SKILL = `# json-merge demo
## Register the CLI tool
\`\`\`nc:json-merge into:container/cli-tools.json key:name
{ "name": "@openai/codex", "version": "0.138.0" }
\`\`\`
`;
describe('json-merge directive', () => {
let jroot: string;
let jskill: string;
beforeEach(() => {
jskill = mkdtempSync(join(tmpdir(), 'nc-skill-'));
jroot = mkdtempSync(join(tmpdir(), 'nc-proj-'));
writeFileSync(join(jskill, 'SKILL.md'), JSON_MERGE_SKILL);
mkdirSync(join(jroot, 'container'), { recursive: true });
writeFileSync(join(jroot, 'container/cli-tools.json'), '[\n { "name": "vercel", "version": "52.2.1" }\n]\n');
});
it('pushes the object, preserving 2-space indent + trailing newline', async () => {
const res = await applySkill(jskill, jroot, { prompter: headless({}), exec: () => {} });
const out = readFileSync(join(jroot, 'container/cli-tools.json'), 'utf8');
expect(out.endsWith('\n')).toBe(true);
const arr = JSON.parse(out);
expect(arr).toEqual([
{ name: 'vercel', version: '52.2.1' },
{ name: '@openai/codex', version: '0.138.0' },
]);
expect(out).toBe(JSON.stringify(arr, null, 2) + '\n'); // 2-space indent
expect(res.journal.some((e) => e.op === 'json-merge')).toBe(true);
});
it('is idempotent — re-applying does not duplicate the element', async () => {
await applySkill(jskill, jroot, { prompter: headless({}), exec: () => {} });
const second = await applySkill(jskill, jroot, { prompter: headless({}), exec: () => {} });
expect(second.applied).toEqual([]);
expect(second.skipped.length).toBe(1);
const arr = JSON.parse(readFileSync(join(jroot, 'container/cli-tools.json'), 'utf8'));
expect(arr.filter((e: { name: string }) => e.name === '@openai/codex')).toHaveLength(1);
});
it('removeSkill drops the element whose key matches', async () => {
const res = await applySkill(jskill, jroot, { prompter: headless({}), exec: () => {} });
await removeSkill(jroot, res.journal);
const arr = JSON.parse(readFileSync(join(jroot, 'container/cli-tools.json'), 'utf8'));
expect(arr).toEqual([{ name: 'vercel', version: '52.2.1' }]);
});
it('plan marks it →apply when absent, ✓skip when present', () => {
const before = planSkill(jskill, jroot);
expect(before.steps[0].status).toBe('apply');
// simulate already-merged
writeFileSync(
join(jroot, 'container/cli-tools.json'),
JSON.stringify([{ name: '@openai/codex', version: '0.138.0' }], null, 2) + '\n',
);
const after = planSkill(jskill, jroot);
expect(after.steps[0].status).toBe('skip');
});
});
// append at:<marker>: insert before a dormant region's closing line.
const MARKER_FILE = ['const STEPS = {', " auth: () => import('./auth.js'),", ' // >>> nanoclaw:setup-steps', ' // <<< nanoclaw:setup-steps', '};', ''].join('\n');
const APPEND_AT_SKILL = `# append-at demo
## Register a setup step
\`\`\`nc:append to:setup/index.ts at:nanoclaw:setup-steps
codex: () => import('./codex.js'),
\`\`\`
`;
const APPEND_EOF_SKILL = `# append-eof demo
## Register at EOF
\`\`\`nc:append to:setup/index.ts
// trailing line
\`\`\`
`;
describe('append at:<marker>', () => {
let aroot: string;
let askill: string;
beforeEach(() => {
askill = mkdtempSync(join(tmpdir(), 'nc-skill-'));
aroot = mkdtempSync(join(tmpdir(), 'nc-proj-'));
mkdirSync(join(aroot, 'setup'), { recursive: true });
writeFileSync(join(aroot, 'setup/index.ts'), MARKER_FILE);
});
it('inserts before the `<<< marker` line, matching its indentation', async () => {
writeFileSync(join(askill, 'SKILL.md'), APPEND_AT_SKILL);
await applySkill(askill, aroot, { prompter: headless({}), exec: () => {} });
const out = readFileSync(join(aroot, 'setup/index.ts'), 'utf8').split('\n');
const closeIdx = out.findIndex((l) => l.includes('<<< nanoclaw:setup-steps'));
expect(out[closeIdx - 1]).toBe(" codex: () => import('./codex.js'),"); // inserted just above, 2-space indent
expect(out[closeIdx - 2]).toContain('>>> nanoclaw:setup-steps'); // open marker untouched
});
it('is idempotent (whole-file line check) regardless of position', async () => {
writeFileSync(join(askill, 'SKILL.md'), APPEND_AT_SKILL);
await applySkill(askill, aroot, { prompter: headless({}), exec: () => {} });
const second = await applySkill(askill, aroot, { prompter: headless({}), exec: () => {} });
expect(second.applied).toEqual([]);
const count = readFileSync(join(aroot, 'setup/index.ts'), 'utf8').split('\n').filter((l) => l.trim() === "codex: () => import('./codex.js'),").length;
expect(count).toBe(1);
});
it('removeSkill deletes the inserted line (position-agnostic, by trimmed line)', async () => {
writeFileSync(join(askill, 'SKILL.md'), APPEND_AT_SKILL);
const res = await applySkill(askill, aroot, { prompter: headless({}), exec: () => {} });
await removeSkill(aroot, res.journal);
expect(readFileSync(join(aroot, 'setup/index.ts'), 'utf8')).not.toContain("codex: () => import('./codex.js'),");
});
it('without at: still appends at EOF (unchanged behavior)', async () => {
writeFileSync(join(askill, 'SKILL.md'), APPEND_EOF_SKILL);
await applySkill(askill, aroot, { prompter: headless({}), exec: () => {} });
const lines = readFileSync(join(aroot, 'setup/index.ts'), 'utf8').split('\n').filter(Boolean);
expect(lines[lines.length - 1]).toBe('// trailing line'); // at EOF, not before the marker
});
});
+407
View File
@@ -0,0 +1,407 @@
// The skill application engine — executes `nc:` directives parsed from a SKILL.md.
//
// The agent is always the top-level applier; this engine is the deterministic
// accelerator it delegates to. Anything the engine can't do bounces back to the
// AGENT (which reads the same prose and applies it, the way skills work today) —
// never to the human, and never as a hard abort. The human is in the loop only
// for `prompt` inputs and inherently-human prose (e.g. clicking through Slack).
//
// Phases (the F2 runtime contract, minimal form):
// 1. parse + validate — lint; a malformed skill never reaches apply
// 2. PLAN — per directive: skip|apply|needs-input|agent — no writes
// 3. acquire inputs — resolve every `prompt` via the injected Prompter
// 4. mutate — copy/append/env-set, journaled + idempotent
// 5. run — build/test/fetch (+ dep install) via injected exec
// Remove is derived from the journal — no hand-written REMOVE.md.
//
// The Prompter is what makes one engine serve two contexts:
// • setup flow → interactive prompter asks the user inline
// • recipe rebuild → headless prompter returns from a values map, or defers
//
// Usage: pnpm exec tsx scripts/skill-apply.ts <skillDir> # plan (no writes)
import { execSync } from 'node:child_process';
import { readFileSync, existsSync, writeFileSync, appendFileSync, copyFileSync, mkdirSync, rmSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { parseDirectives, promptVar, type Directive } from './skill-directives.js';
export interface Prompter {
// Return the value, or undefined to DEFER (headless rebuild collects these).
ask(varName: string, question: string, secret: boolean): Promise<string | undefined>;
}
export type StepStatus = 'skip' | 'apply' | 'needs-input' | 'agent';
export interface PlanStep {
n: number;
kind: string;
line: number;
status: StepStatus;
detail: string;
}
const read = (p: string) => (existsSync(p) ? readFileSync(p, 'utf8') : '');
const has = (root: string, rel: string) => existsSync(join(root, rel));
const VAR_REF = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
const destOf = (line: string) => (line.includes('->') ? line.split('->')[1].trim() : line.trim());
const srcOf = (line: string) => (line.includes('->') ? line.split('->')[0].trim() : line.trim());
function fileHasLine(root: string, rel: string, line: string): boolean {
return read(join(root, rel))
.split('\n')
.some((l) => l.trim() === line.trim());
}
function pkgHasDep(root: string, name: string): boolean {
try {
const pkg = JSON.parse(read(join(root, 'package.json')) || '{}');
return Boolean(pkg.dependencies?.[name] || pkg.devDependencies?.[name]);
} catch {
return false;
}
}
function envKeySet(root: string, key: string): boolean {
return read(join(root, '.env'))
.split('\n')
.some((l) => {
const m = l.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/);
return m !== null && m[1] === key && m[2].trim().length > 0;
});
}
// Does the array-of-objects JSON at `rel` already contain an element whose
// [key] equals `value`? The idempotency probe for json-merge.
function jsonArrayHasKey(root: string, rel: string, key: string, value: unknown): boolean {
try {
const arr = JSON.parse(read(join(root, rel)) || '[]');
return Array.isArray(arr) && arr.some((el) => el !== null && typeof el === 'object' && (el as Record<string, unknown>)[key] === value);
} catch {
return false;
}
}
// Per-directive idempotency check + "what it would do". Read-only.
function selfStatus(d: Directive, root: string): { status: StepStatus; detail: string } {
switch (d.kind) {
case 'copy': {
const dests = d.body.map(destOf);
const missing = dests.filter((p) => !has(root, p));
const from = d.attrs['from-branch'] ? `fetch ${String(d.attrs['from-branch'])}` : '';
return missing.length
? { status: 'apply', detail: `${from}copy ${missing.join(', ')} (absent)` }
: { status: 'skip', detail: `${dests.join(', ')} present` };
}
case 'append': {
const to = String(d.attrs.to ?? '');
const line = d.body[0] ?? '';
return fileHasLine(root, to, line)
? { status: 'skip', detail: `${to} already has the line` }
: { status: 'apply', detail: `add to ${to}: ${line}` };
}
case 'dep': {
const missing = d.body.filter((s) => !pkgHasDep(root, s.slice(0, s.lastIndexOf('@'))));
return missing.length
? { status: 'apply', detail: `install ${missing.join(', ')}` }
: { status: 'skip', detail: `${d.body.join(', ')} present` };
}
case 'run':
return { status: 'apply', detail: `${String(d.attrs.effect ?? 'run')}: ${d.body.join(' && ')}` };
case 'env-set': {
const keys = d.body.map((l) => l.split('=')[0].trim());
const missing = keys.filter((k) => !envKeySet(root, k));
return missing.length
? { status: 'apply', detail: `set ${missing.join(', ')} in .env` }
: { status: 'skip', detail: `${keys.join(', ')} already set` };
}
case 'env-sync':
return { status: 'apply', detail: 'sync .env → data/env/env' };
case 'json-merge': {
const into = String(d.attrs.into ?? '');
const key = String(d.attrs.key ?? '');
let value: unknown;
try {
value = (JSON.parse(d.body.join('\n')) as Record<string, unknown>)[key];
} catch {
return { status: 'agent', detail: `nc:json-merge body is not parseable JSON — an agent applies it from the prose` };
}
return jsonArrayHasKey(root, into, key, value)
? { status: 'skip', detail: `${into} already has ${key}=${JSON.stringify(value)}` }
: { status: 'apply', detail: `merge ${key}=${JSON.stringify(value)} into ${into}` };
}
case 'prompt':
return { status: 'needs-input', detail: '' };
default:
return { status: 'agent', detail: `no deterministic handler for nc:${d.kind} — an agent applies it from the prose` };
}
}
export function planSkill(skillDir: string, root: string): { steps: PlanStep[]; needsInput: string[]; agentSteps: number } {
const directives = parseDirectives(read(join(skillDir, 'SKILL.md')));
const self = directives.map((d) => ({ d, ...selfStatus(d, root) }));
const consumers = new Map<string, number[]>();
self.forEach(({ d }, i) => {
for (const line of d.body) for (const m of line.matchAll(VAR_REF)) (consumers.get(m[1]) ?? consumers.set(m[1], []).get(m[1])!).push(i);
});
const steps: PlanStep[] = self.map(({ d, status, detail }, i) => {
if (d.kind !== 'prompt') return { n: i + 1, kind: d.kind, line: d.line, status, detail };
const v = promptVar(d) ?? '?';
const tag = `${v}${d.args.includes('secret') ? ' (secret)' : ''}`;
const cons = consumers.get(v) ?? [];
const satisfied = cons.length > 0 && cons.every((j) => self[j].status === 'skip');
return satisfied
? { n: i + 1, kind: d.kind, line: d.line, status: 'skip', detail: `${tag} — consumers already satisfied` }
: { n: i + 1, kind: d.kind, line: d.line, status: 'needs-input', detail: `${tag} → asked during apply` };
});
return {
steps,
needsInput: steps.filter((s) => s.status === 'needs-input').map((s) => s.detail.split(' ')[0]),
agentSteps: steps.filter((s) => s.status === 'agent').length,
};
}
// ---------------------------------------------------------------------------
// Apply (phases 35) + journal-derived remove.
// ---------------------------------------------------------------------------
export type JournalEntry =
| { op: 'wrote'; path: string }
| { op: 'appended'; path: string; line: string }
| { op: 'set-env'; key: string }
| { op: 'json-merge'; path: string; key: string; value: unknown }
| { op: 'ran'; cmd: string; undo?: string };
export interface AgentTask {
kind: string;
line: number;
reason: string;
prose: string; // the surrounding prose the agent reads to apply the step
}
export interface ApplyResult {
applied: string[];
skipped: string[];
deferred: string[]; // prompt vars / blocked consumers with no value yet
agentTasks: AgentTask[]; // bounced to an agent — NOT the human
journal: JournalEntry[];
}
export interface ApplyOptions {
prompter: Prompter;
exec?: (cmd: string) => void | Promise<void>; // dep/run/branch-fetch; injectable for tests
// Resolve which remote carries a `from-branch` registry branch. Defaults to a
// generic resolver (env override → first remote that has the branch → origin);
// setup injects one that reuses setup/lib/channels-remote.sh for exact parity.
resolveRemote?: (branch: string) => string;
}
// A hardcoded `origin` breaks forks where the registry branch lives on
// `upstream`. Generic mirror of channels-remote.sh: explicit override → the
// first remote that actually has the branch → origin.
function defaultResolveRemote(branch: string, root: string): string {
const override = process.env.NANOCLAW_CHANNELS_REMOTE;
if (override) return override;
const cap = (cmd: string): string => {
try {
return execSync(cmd, { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
} catch {
return '';
}
};
const remotes = cap('git remote').split('\n').map((s) => s.trim()).filter(Boolean);
const ordered = remotes.includes('origin') ? ['origin', ...remotes.filter((r) => r !== 'origin')] : remotes;
for (const r of ordered) if (cap(`git ls-remote --heads ${r} ${branch}`).trim()) return r;
return 'origin';
}
// The prose an agent reads when a step degrades: nearest heading + the
// paragraph immediately above the directive fence.
function proseFor(md: string, fenceLine1: number): string {
const lines = md.split('\n');
let i = fenceLine1 - 2;
while (i >= 0 && lines[i].trim() === '') i--;
const para: string[] = [];
while (i >= 0 && lines[i].trim() !== '' && !lines[i].startsWith('#')) para.unshift(lines[i--]);
let heading = '';
for (let h = i; h >= 0; h--) if (lines[h].startsWith('#')) { heading = lines[h]; break; }
return [heading, ...para].filter(Boolean).join('\n').trim();
}
function substitute(value: string, vars: Map<string, { value: string; secret: boolean }>): string {
return value.replace(VAR_REF, (_, name) => {
const v = vars.get(name);
if (!v) throw new Error(`unresolved {{${name}}}`);
return v.value;
});
}
// The mutating twin of selfStatus. Records what it did to the journal so remove
// is derivable. Throws on failure → caught and bounced to an agent.
async function applyOne(
d: Directive,
ctx: { root: string; skillDir: string; exec: (c: string) => void | Promise<void>; resolveRemote: (b: string) => string; vars: Map<string, { value: string; secret: boolean }>; journal: JournalEntry[] },
): Promise<void> {
const { root, skillDir, exec, vars, journal } = ctx;
switch (d.kind) {
case 'copy':
if (d.attrs['from-branch']) {
const b = String(d.attrs['from-branch']);
const remote = ctx.resolveRemote(b);
await exec(`git fetch ${remote} ${b}`);
for (const l of d.body) await exec(`git show ${remote}/${b}:${srcOf(l)} > ${destOf(l)}`);
} else {
for (const l of d.body) {
const dst = join(root, destOf(l));
mkdirSync(dirname(dst), { recursive: true });
copyFileSync(join(skillDir, srcOf(l)), dst);
}
}
for (const l of d.body) journal.push({ op: 'wrote', path: destOf(l) });
break;
case 'append': {
const to = String(d.attrs.to);
const marker = typeof d.attrs.at === 'string' ? d.attrs.at : undefined;
const target = join(root, to);
if (marker) {
// Insert before the `// <<< <marker>` closing line of a dormant marker
// region, matching that line's indentation. removeSkill still deletes
// by line (position-agnostic), so the journal entry is unchanged.
const close = `<<< ${marker}`;
for (const line of d.body) {
const lines = read(target).split('\n');
const idx = lines.findIndex((l) => l.includes(close));
if (idx === -1) throw new Error(`append marker "${marker}" not found in ${to}`);
const indent = lines[idx].match(/^\s*/)?.[0] ?? '';
lines.splice(idx, 0, indent + line);
writeFileSync(target, lines.join('\n'));
journal.push({ op: 'appended', path: to, line });
}
} else {
for (const line of d.body) {
appendFileSync(target, (read(target).endsWith('\n') || read(target) === '' ? '' : '\n') + line + '\n');
journal.push({ op: 'appended', path: to, line });
}
}
break;
}
case 'dep': {
await exec(`pnpm add ${d.body.join(' ')}`);
const names = d.body.map((s) => s.slice(0, s.lastIndexOf('@'))).join(' ');
journal.push({ op: 'ran', cmd: `pnpm add ${d.body.join(' ')}`, undo: `pnpm remove ${names}` });
break;
}
case 'run':
for (const cmd of d.body) {
await exec(cmd);
const undo = d.attrs.effect === 'external' && typeof d.attrs.remove === 'string' ? d.attrs.remove : undefined;
journal.push({ op: 'ran', cmd, undo });
}
break;
case 'env-set': {
const envPath = join(root, '.env');
for (const entry of d.body) {
const eq = entry.indexOf('=');
const key = entry.slice(0, eq).trim();
const value = substitute(entry.slice(eq + 1).trim(), vars); // throws if a {{var}} is unresolved
if (!envKeySet(root, key)) {
appendFileSync(envPath, (read(envPath).endsWith('\n') || read(envPath) === '' ? '' : '\n') + `${key}=${value}\n`);
journal.push({ op: 'set-env', key });
}
}
break;
}
case 'env-sync':
mkdirSync(join(root, 'data/env'), { recursive: true });
copyFileSync(join(root, '.env'), join(root, 'data/env/env'));
break;
case 'json-merge': {
const into = String(d.attrs.into);
const key = String(d.attrs.key);
const obj = JSON.parse(d.body.join('\n')) as Record<string, unknown>;
const target = join(root, into);
const arr = JSON.parse(read(target) || '[]') as unknown[];
if (!Array.isArray(arr)) throw new Error(`${into} is not a JSON array`);
const value = obj[key];
// Idempotent: only push when no element already matches on the key.
if (!arr.some((el) => el !== null && typeof el === 'object' && (el as Record<string, unknown>)[key] === value)) {
arr.push(obj);
writeFileSync(target, JSON.stringify(arr, null, 2) + '\n');
journal.push({ op: 'json-merge', path: into, key, value });
}
break;
}
default:
throw new Error(`no handler for nc:${d.kind}`);
}
}
export async function applySkill(skillDir: string, root: string, opts: ApplyOptions): Promise<ApplyResult> {
// Lint (validate()) is the authoring/CI gate, run before a skill ships — NOT
// here. Apply is best-effort: an unknown directive (a typo lint should have
// caught, or one newer than this engine) bounces to an agent, never blocks.
const md = read(join(skillDir, 'SKILL.md'));
const directives = parseDirectives(md);
const exec = opts.exec ?? (() => { throw new Error('no exec provided'); });
const resolveRemote = opts.resolveRemote ?? ((b: string) => defaultResolveRemote(b, root));
const vars = new Map<string, { value: string; secret: boolean }>();
const res: ApplyResult = { applied: [], skipped: [], deferred: [], agentTasks: [], journal: [] };
const bounce = (d: Directive, reason: string) => res.agentTasks.push({ kind: d.kind, line: d.line, reason, prose: proseFor(md, d.line) });
for (const d of directives) {
try {
if (d.kind === 'prompt') {
const v = promptVar(d)!;
const val = await opts.prompter.ask(v, d.body.join(' '), d.args.includes('secret'));
if (val === undefined) res.deferred.push(v);
else vars.set(v, { value: val, secret: d.args.includes('secret') });
continue;
}
const st = selfStatus(d, root);
if (st.status === 'agent') { bounce(d, 'no deterministic handler'); continue; }
if (st.status === 'skip') { res.skipped.push(`${d.kind}: ${st.detail}`); continue; }
await applyOne(d, { root, skillDir, exec, resolveRemote, vars, journal: res.journal });
res.applied.push(`${d.kind}: ${st.detail}`);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (/unresolved \{\{/.test(msg)) res.deferred.push(msg); // blocked on a prompt input
else bounce(d, `engine could not apply (${msg}) — an agent applies it from the prose`);
}
}
return res;
}
// Remove is the journal played backwards — no hand-written REMOVE.md.
export async function removeSkill(root: string, journal: JournalEntry[], exec?: (c: string) => void | Promise<void>): Promise<void> {
for (const e of [...journal].reverse()) {
if (e.op === 'wrote') rmSync(join(root, e.path), { force: true });
else if (e.op === 'appended') {
const p = join(root, e.path);
writeFileSync(p, read(p).split('\n').filter((l) => l.trim() !== e.line.trim()).join('\n'));
} else if (e.op === 'set-env') {
const p = join(root, '.env');
writeFileSync(p, read(p).split('\n').filter((l) => !l.startsWith(`${e.key}=`)).join('\n'));
} else if (e.op === 'json-merge') {
const p = join(root, e.path);
const arr = JSON.parse(read(p) || '[]') as unknown[];
if (Array.isArray(arr)) {
writeFileSync(p, JSON.stringify(arr.filter((el) => !(el !== null && typeof el === 'object' && (el as Record<string, unknown>)[e.key] === e.value)), null, 2) + '\n');
}
} else if (e.op === 'ran' && e.undo && exec) {
await exec(e.undo);
}
}
}
// CLI — the planner (no writes)
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
const skillDir = process.argv[2];
if (!skillDir) {
console.error('usage: pnpm exec tsx scripts/skill-apply.ts <skillDir>');
process.exit(2);
}
const root = process.cwd();
const { steps, needsInput, agentSteps } = planSkill(skillDir, root);
console.log(`PLAN ${skillDir} project: ${root}\n`);
const icon: Record<StepStatus, string> = { skip: '✓ skip', apply: '→ apply', 'needs-input': '? human', agent: '↳ agent' };
for (const s of steps) console.log(`${String(s.n).padStart(2)}. ${icon[s.status].padEnd(8)} ${s.kind.padEnd(9)} ${s.detail}`);
console.log(`\nneeds human input: ${needsInput.join(', ') || '(none)'} →agent: ${agentSteps}`);
}
+155
View File
@@ -0,0 +1,155 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { parseDirectives, validate, promptVar, resolveChatCoreVersion } from './skill-directives.js';
// Guards the structured-directive format against the converted add-slack skill:
// red if the conversion drifts (a directive dropped/renamed) or the parser breaks.
const slack = readFileSync('.claude/skills/add-slack/SKILL.md', 'utf8');
const directives = parseDirectives(slack);
describe('skill-directives parser, on the converted add-slack', () => {
it('extracts the apply + credential directives in document order', () => {
expect(directives.map((d) => d.kind)).toEqual([
'copy', // step 1: adapter + test from the channels branch
'append', // step 2: barrel registration
'dep', // step 3: pinned package
'run', // step 4: build
'run', // step 4: test
'prompt', // credentials: capture bot token
'prompt', // credentials: capture signing secret
'env-set', // credentials: write captured values to .env
'env-sync', // credentials: sync to container
]);
});
it('reads copy as a branch fetch with both files', () => {
const copy = directives.find((d) => d.kind === 'copy')!;
expect(copy.attrs['from-branch']).toBe('channels');
expect(copy.body).toEqual(['src/channels/slack.ts', 'src/channels/slack-registration.test.ts']);
});
it('reads the barrel append target and line', () => {
const append = directives.find((d) => d.kind === 'append')!;
expect(append.attrs.to).toBe('src/channels/index.ts');
expect(append.body).toEqual(["import './slack.js';"]);
});
it('reads the dependency pinned exactly', () => {
const dep = directives.find((d) => d.kind === 'dep')!;
expect(dep.body).toEqual(['@chat-adapter/slack@4.26.0']);
});
it('tags the runs with their effects', () => {
expect(directives.filter((d) => d.kind === 'run').map((d) => d.attrs.effect)).toEqual(['build', 'test']);
});
it('captures each prompt into a named, secret variable — no destination baked in', () => {
const prompts = directives.filter((d) => d.kind === 'prompt');
expect(prompts.map(promptVar)).toEqual(['bot_token', 'signing_secret']);
for (const p of prompts) expect(p.args).toContain('secret');
// The prompt body is the question; it does not mention env at all.
expect(prompts[0].body.join(' ')).toMatch(/Bot User OAuth Token/);
});
it('wires the captured variables into env-set via {{var}} references', () => {
const envSet = directives.find((d) => d.kind === 'env-set')!;
expect(envSet.body).toEqual(['SLACK_BOT_TOKEN={{bot_token}}', 'SLACK_SIGNING_SECRET={{signing_secret}}']);
});
it('passes validation (well-formed, pinned, every {{var}} captured first)', () => {
expect(validate(directives)).toEqual([]);
});
it('keeps its @chat-adapter pin in sync with our chat core (drift guard)', () => {
const chat = resolveChatCoreVersion(process.cwd());
expect(chat).toMatch(/^\d+\.\d+\.\d+/); // our lockfile resolves a real chat version
expect(validate(directives, { chatVersion: chat })).toEqual([]); // add-slack matches it
});
it('ignores plain (non-nc:) code fences so prose stays the floor', () => {
const withProse = slack + '\n```bash\nrm -rf /\n```\n';
expect(parseDirectives(withProse).map((d) => d.kind)).toEqual(directives.map((d) => d.kind));
});
});
describe('validation catches malformed directives', () => {
it('flags an unpinned dependency and an unknown directive', () => {
const md = ['```nc:dep', '@chat-adapter/slack@latest', '```', '', '```nc:frobnicate', 'x', '```'].join('\n');
const problems = validate(parseDirectives(md));
expect(problems.some((p) => /exact semver/.test(p.message))).toBe(true);
expect(problems.some((p) => /unknown directive/.test(p.message))).toBe(true);
});
it('flags an env-set that references a variable no prompt captured', () => {
const md = ['```nc:env-set', 'SLACK_BOT_TOKEN={{bot_token}}', '```'].join('\n');
const problems = validate(parseDirectives(md));
expect(problems.some((p) => /\{\{bot_token\}\} but no earlier nc:prompt/.test(p.message))).toBe(true);
});
it('flags a @chat-adapter pin that does not match the chat core', () => {
const md = ['```nc:dep', '@chat-adapter/slack@4.27.0', '```'].join('\n');
const problems = validate(parseDirectives(md), { chatVersion: '4.26.0' });
expect(problems.some((p) => /must match the chat package/.test(p.message))).toBe(true);
});
it('accepts a @chat-adapter pin that matches the chat core', () => {
const md = ['```nc:dep', '@chat-adapter/slack@4.26.0', '```'].join('\n');
expect(validate(parseDirectives(md), { chatVersion: '4.26.0' })).toEqual([]);
});
});
describe('json-merge directive', () => {
const codex = ['```nc:json-merge into:container/cli-tools.json key:name', '{ "name": "@openai/codex", "version": "0.138.0" }', '```'].join('\n');
it('parses into/key attrs and the JSON object body', () => {
const [d] = parseDirectives(codex);
expect(d.kind).toBe('json-merge');
expect(d.attrs.into).toBe('container/cli-tools.json');
expect(d.attrs.key).toBe('name');
expect(JSON.parse(d.body.join('\n'))).toEqual({ name: '@openai/codex', version: '0.138.0' });
});
it('passes validation when into + key + a parseable object are all present', () => {
expect(validate(parseDirectives(codex))).toEqual([]);
});
it('flags a missing into:', () => {
const md = ['```nc:json-merge key:name', '{ "name": "x" }', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /requires into:/.test(p.message))).toBe(true);
});
it('flags a missing key:', () => {
const md = ['```nc:json-merge into:container/cli-tools.json', '{ "name": "x" }', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /requires key:/.test(p.message))).toBe(true);
});
it('flags an unparseable body', () => {
const md = ['```nc:json-merge into:f.json key:name', '{ not json', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /parseable JSON object/.test(p.message))).toBe(true);
});
it('flags a body that is an array, not a single object', () => {
const md = ['```nc:json-merge into:f.json key:name', '[{ "name": "x" }]', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /single JSON object/.test(p.message))).toBe(true);
});
it('flags a body missing the match key field', () => {
const md = ['```nc:json-merge into:f.json key:name', '{ "version": "1.0.0" }', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /no "name" field/.test(p.message))).toBe(true);
});
});
describe('append at:<marker> attribute', () => {
it('parses an optional at:<marker> alongside to:', () => {
const md = ['```nc:append to:setup/index.ts at:nanoclaw:setup-steps', " codex: () => import('./codex.js'),", '```'].join('\n');
const [d] = parseDirectives(md);
expect(d.kind).toBe('append');
expect(d.attrs.to).toBe('setup/index.ts');
expect(d.attrs.at).toBe('nanoclaw:setup-steps');
});
it('still validates an append that carries at: (to + a line are all it needs)', () => {
const md = ['```nc:append to:setup/index.ts at:nanoclaw:setup-steps', " codex: () => import('./codex.js'),", '```'].join('\n');
expect(validate(parseDirectives(md))).toEqual([]);
});
});
+206
View File
@@ -0,0 +1,206 @@
// Extract `nc:` skill directives embedded in a SKILL.md.
//
// A fenced code block whose info-string starts with `nc:` is a load-bearing
// directive; every other fence (and all prose) is the human floor the parser
// ignores. That is the whole "two readers, one document" property: an agent
// applies the prose, a tool applies the directives, and anything the tool
// can't handle degrades to the prose beside it. This is the seed for both the
// conformance linter and the deterministic applier.
//
// Grammar, derived from add-slack:
//
// ```nc:<directive> <arg>... [key:value]...
// <body line>
// ```
//
// `prompt` only *acquires* a value and binds it to a name; a separate directive
// *applies* it, referenced as `{{name}}`. That keeps "ask the human" decoupled
// from "what you do with the answer" (env, ncl, the OneCLI vault, a file).
//
// copy [from-branch:<b>] body: `PATH` (src==dst) or `SRC -> DST` overwrite
// append to:<file> [at:<marker>] body: line(s) to add skip if present
// dep [manager:pnpm] body: `pkg@<exact-semver>` line(s) reinstall no-op
// run [effect:build|test|fetch|external] body: shell command(s) re-runnable
// prompt <var> [secret] body: the question → binds {{var}} skip if satisfied
// env-set body: `KEY=value` ({{var}} allowed) set-if-absent
// env-sync (no body) `.env` → data/env/env idempotent copy
// json-merge into:<file> key:<field> body: a JSON object push-if-absent
//
// `append` without `at:` adds to EOF; with `at:<marker>` it inserts before the
// `// <<< <marker>` closing line of a dormant marker region (see setup/index.ts).
// `json-merge` reads an array-of-objects JSON file and pushes the body object
// unless an element already has body[key]===element[key] (idempotent by key).
//
// Usage: pnpm exec tsx scripts/skill-directives.ts <SKILL.md>
import { readFileSync, existsSync, statSync } from 'node:fs';
import { join } from 'node:path';
export interface Directive {
kind: string;
args: string[]; // positional bare tokens, e.g. prompt's variable name
attrs: Record<string, string | true>; // key:value tokens
body: string[];
line: number; // 1-based line of the opening fence
}
export interface Problem {
line: number;
kind: string;
message: string;
}
const FENCE = /^```(\S.*)?$/;
const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
const VAR_REF = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
const KNOWN = new Set(['copy', 'append', 'dep', 'run', 'prompt', 'env-set', 'env-sync', 'json-merge']);
const PROMPT_FLAGS = new Set(['secret']);
export function parseDirectives(markdown: string): Directive[] {
const lines = markdown.split('\n');
const out: Directive[] = [];
let i = 0;
while (i < lines.length) {
const info = lines[i].match(FENCE)?.[1]?.trim();
if (info === undefined) {
i++;
continue;
}
// A fence opens here; consume to its closing fence either way.
let j = i + 1;
const body: string[] = [];
while (j < lines.length && !FENCE.test(lines[j])) {
body.push(lines[j]);
j++;
}
if (info.startsWith('nc:')) {
const [tag, ...rest] = info.split(/\s+/);
const args: string[] = [];
const attrs: Record<string, string | true> = {};
for (const tok of rest) {
const eq = tok.indexOf(':');
if (eq > 0) attrs[tok.slice(0, eq)] = tok.slice(eq + 1);
else args.push(tok);
}
out.push({
kind: tag.slice('nc:'.length),
args,
attrs,
body: body.map((l) => l.trim()).filter(Boolean),
line: i + 1,
});
}
i = j + 1; // skip past the closing fence (directive or plain code block)
}
return out;
}
/** The variable a `prompt` binds (the first positional that isn't a flag). */
export function promptVar(d: Directive): string | undefined {
return d.args.find((a) => !PROMPT_FLAGS.has(a));
}
/** `{{var}}` names referenced anywhere in a directive's body. */
function referencedVars(d: Directive): string[] {
const found: string[] = [];
for (const line of d.body) for (const m of line.matchAll(VAR_REF)) found.push(m[1]);
return found;
}
/**
* The resolved `chat` core version from our lockfile the single source of
* truth a `@chat-adapter/*` adapter pin must match (the adapter and the core
* move in lockstep). Reads the root importer's direct `chat` dependency, whose
* `specifier`/`version` pair is unique to importer deps (transitive entries in
* the packages section have no `specifier`). Returns undefined if not found.
*/
export function resolveChatCoreVersion(root: string): string | undefined {
let lock = '';
try {
lock = readFileSync(join(root, 'pnpm-lock.yaml'), 'utf8');
} catch {
return undefined;
}
const m = lock.match(/\n\s+chat:\n\s+specifier:[^\n]*\n\s+version:\s*([0-9][^\s(]*)/);
return m?.[1];
}
export function validate(directives: Directive[], ctx?: { chatVersion?: string }): Problem[] {
const problems: Problem[] = [];
const defined = new Set<string>();
const flag = (d: Directive, message: string) => problems.push({ line: d.line, kind: d.kind, message });
for (const d of directives) {
if (!KNOWN.has(d.kind)) flag(d, `unknown directive nc:${d.kind}`);
switch (d.kind) {
case 'dep':
for (const spec of d.body) {
const at = spec.lastIndexOf('@');
const name = at > 0 ? spec.slice(0, at) : spec;
const version = at > 0 ? spec.slice(at + 1) : '';
if (!EXACT_SEMVER.test(version)) flag(d, `dep "${spec}" must pin an exact semver (no ranges/latest)`);
// A @chat-adapter/* adapter must match the chat core version in our
// lockfile — the family moves together. This catches pin drift (the
// 4.27.0-vs-chat@4.26.0 mismatch) at lint time.
if (ctx?.chatVersion && name.startsWith('@chat-adapter/') && version !== ctx.chatVersion) {
flag(d, `${name} pinned ${version} but our chat core is ${ctx.chatVersion} — a @chat-adapter/* adapter must match the chat package`);
}
}
break;
case 'append':
if (!d.attrs.to) flag(d, 'append requires to:<file>');
if (d.body.length === 0) flag(d, 'append requires a line to add');
break;
case 'copy':
if (d.body.length === 0) flag(d, 'copy requires at least one path');
break;
case 'json-merge': {
if (!d.attrs.into) flag(d, 'json-merge requires into:<json-file>');
if (!d.attrs.key) flag(d, 'json-merge requires key:<field>');
if (d.body.length === 0) {
flag(d, 'json-merge requires a JSON object in its body');
} else {
let obj: unknown;
try {
obj = JSON.parse(d.body.join('\n'));
} catch {
flag(d, 'json-merge body must be a single parseable JSON object');
break;
}
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
flag(d, 'json-merge body must be a single JSON object (not an array or scalar)');
} else if (typeof d.attrs.key === 'string' && !(d.attrs.key in obj)) {
flag(d, `json-merge body has no "${d.attrs.key}" field to match on`);
}
}
break;
}
case 'prompt':
if (!promptVar(d)) flag(d, 'prompt requires a variable name, e.g. `nc:prompt token`');
if (d.body.length === 0) flag(d, 'prompt requires a question in its body');
break;
}
// A consumer can only reference a variable an earlier prompt captured.
for (const ref of referencedVars(d)) {
if (!defined.has(ref)) flag(d, `references {{${ref}}} but no earlier nc:prompt captured it`);
}
if (d.kind === 'prompt') {
const v = promptVar(d);
if (v) defined.add(v);
}
}
return problems;
}
// CLI
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
let path = process.argv[2];
if (!path) {
console.error('usage: pnpm exec tsx scripts/skill-directives.ts <skillDir|SKILL.md>');
process.exit(2);
}
if (existsSync(path) && statSync(path).isDirectory()) path = join(path, 'SKILL.md');
const directives = parseDirectives(readFileSync(path, 'utf8'));
const problems = validate(directives, { chatVersion: resolveChatCoreVersion(process.cwd()) });
console.log(JSON.stringify({ directives, problems }, null, 2));
process.exit(problems.length ? 1 : 0);
}
-130
View File
@@ -1,130 +0,0 @@
#!/usr/bin/env bash
#
# Install the Discord adapter, persist DISCORD_BOT_TOKEN / APPLICATION_ID /
# PUBLIC_KEY to .env + data/env/env, and restart the service. Non-interactive —
# the operator-facing "Create a bot" walkthrough, owner confirmation, and
# server-invite step live in setup/channels/discord.ts. Credentials come in via
# env vars: DISCORD_BOT_TOKEN, DISCORD_APPLICATION_ID, DISCORD_PUBLIC_KEY.
#
# Emits exactly one status block on stdout (ADD_DISCORD) at the end. All chatty
# progress messages go to stderr so setup:auto's raw-log capture sees the full
# story without cluttering the final block for the parser.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-discord/SKILL.md.
ADAPTER_VERSION="@chat-adapter/discord@4.26.0"
# Resolve which remote carries the channels branch — handles forks where
# upstream lives on a different remote than `origin`.
# shellcheck source=setup/lib/channels-remote.sh
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
CHANNELS_REMOTE=$(resolve_channels_remote)
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
emit_status() {
local status=$1 error=${2:-}
local already=${ADAPTER_ALREADY_INSTALLED:-false}
echo "=== NANOCLAW SETUP: ADD_DISCORD ==="
echo "STATUS: ${status}"
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
[ -n "$error" ] && echo "ERROR: ${error}"
echo "=== END ==="
}
log() { echo "[add-discord] $*" >&2; }
if [ -z "${DISCORD_BOT_TOKEN:-}" ]; then
emit_status failed "DISCORD_BOT_TOKEN env var not set"
exit 1
fi
if [ -z "${DISCORD_APPLICATION_ID:-}" ]; then
emit_status failed "DISCORD_APPLICATION_ID env var not set"
exit 1
fi
if [ -z "${DISCORD_PUBLIC_KEY:-}" ]; then
emit_status failed "DISCORD_PUBLIC_KEY env var not set"
exit 1
fi
need_install() {
[ ! -f src/channels/discord.ts ] && return 0
! grep -q "^import './discord.js';" src/channels/index.ts 2>/dev/null && return 0
return 1
}
ADAPTER_ALREADY_INSTALLED=true
if need_install; then
ADAPTER_ALREADY_INSTALLED=false
log "Fetching channels branch…"
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
exit 1
}
log "Copying adapter from ${CHANNELS_BRANCH}"
git show "${CHANNELS_BRANCH}:src/channels/discord.ts" > src/channels/discord.ts
# Append self-registration import if missing.
if ! grep -q "^import './discord.js';" src/channels/index.ts; then
echo "import './discord.js';" >> src/channels/index.ts
fi
log "Installing ${ADAPTER_VERSION}"
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
exit 1
}
log "Building…"
pnpm run build >&2 2>/dev/null || {
emit_status failed "pnpm run build failed"
exit 1
}
else
log "Adapter files already installed — skipping install phase."
fi
# Persist credentials. auto.ts validates before this point, so bad values here
# would be an internal bug rather than operator input.
touch .env
upsert_env() {
local key=$1 value=$2
if grep -q "^${key}=" .env; then
awk -v k="$key" -v v="$value" \
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
.env > .env.tmp && mv .env.tmp .env
else
echo "${key}=${value}" >> .env
fi
}
upsert_env DISCORD_BOT_TOKEN "$DISCORD_BOT_TOKEN"
upsert_env DISCORD_APPLICATION_ID "$DISCORD_APPLICATION_ID"
upsert_env DISCORD_PUBLIC_KEY "$DISCORD_PUBLIC_KEY"
# Container reads from data/env/env (the host mounts it).
mkdir -p data/env
cp .env data/env/env
log "Restarting service so the new adapter picks up the credentials…"
# shellcheck source=setup/lib/install-slug.sh
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
case "$(uname -s)" in
Darwin)
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
;;
Linux)
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|| true
;;
esac
# Give the Discord adapter a moment to finish gateway handshake before
# init-first-agent attempts delivery.
sleep 5
emit_status success
-160
View File
@@ -1,160 +0,0 @@
#!/usr/bin/env bash
#
# Install the iMessage adapter, persist mode/creds to .env + data/env/env,
# and restart the service. Non-interactive — the Full Disk Access walkthrough
# (local mode) and Photon URL/key prompts (remote mode) live in
# setup/channels/imessage.ts. Creds come in via env vars:
# IMESSAGE_LOCAL 'true' | 'false' (required)
# IMESSAGE_ENABLED 'true' (required when IMESSAGE_LOCAL=true)
# IMESSAGE_SERVER_URL (required when IMESSAGE_LOCAL=false)
# IMESSAGE_API_KEY (required when IMESSAGE_LOCAL=false)
#
# Emits exactly one status block on stdout (ADD_IMESSAGE) at the end.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-imessage/SKILL.md.
ADAPTER_VERSION="chat-adapter-imessage@0.1.1"
# Resolve which remote carries the channels branch — handles forks where
# upstream lives on a different remote than `origin`.
# shellcheck source=setup/lib/channels-remote.sh
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
CHANNELS_REMOTE=$(resolve_channels_remote)
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
emit_status() {
local status=$1 error=${2:-}
local already=${ADAPTER_ALREADY_INSTALLED:-false}
local mode=${IMESSAGE_LOCAL:-}
echo "=== NANOCLAW SETUP: ADD_IMESSAGE ==="
echo "STATUS: ${status}"
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
[ -n "$mode" ] && echo "MODE: $([ "$mode" = "true" ] && echo local || echo remote)"
[ -n "$error" ] && echo "ERROR: ${error}"
echo "=== END ==="
}
log() { echo "[add-imessage] $*" >&2; }
# Validate creds based on mode.
if [ -z "${IMESSAGE_LOCAL:-}" ]; then
emit_status failed "IMESSAGE_LOCAL env var not set (expected true|false)"
exit 1
fi
if [ "${IMESSAGE_LOCAL}" = "true" ]; then
if [ -z "${IMESSAGE_ENABLED:-}" ]; then
emit_status failed "IMESSAGE_ENABLED env var not set for local mode"
exit 1
fi
if [ "$(uname -s)" != "Darwin" ]; then
emit_status failed "local mode requires macOS"
exit 1
fi
else
if [ -z "${IMESSAGE_SERVER_URL:-}" ]; then
emit_status failed "IMESSAGE_SERVER_URL env var not set for remote mode"
exit 1
fi
if [ -z "${IMESSAGE_API_KEY:-}" ]; then
emit_status failed "IMESSAGE_API_KEY env var not set for remote mode"
exit 1
fi
fi
need_install() {
[ ! -f src/channels/imessage.ts ] && return 0
! grep -q "^import './imessage.js';" src/channels/index.ts 2>/dev/null && return 0
return 1
}
ADAPTER_ALREADY_INSTALLED=true
if need_install; then
ADAPTER_ALREADY_INSTALLED=false
log "Fetching channels branch…"
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
exit 1
}
log "Copying adapter from ${CHANNELS_BRANCH}"
git show "${CHANNELS_BRANCH}:src/channels/imessage.ts" > src/channels/imessage.ts
# Append self-registration import if missing.
if ! grep -q "^import './imessage.js';" src/channels/index.ts; then
echo "import './imessage.js';" >> src/channels/index.ts
fi
log "Installing ${ADAPTER_VERSION}"
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
exit 1
}
log "Building…"
pnpm run build >&2 2>/dev/null || {
emit_status failed "pnpm run build failed"
exit 1
}
else
log "Adapter files already installed — skipping install phase."
fi
touch .env
upsert_env() {
local key=$1 value=$2
if grep -q "^${key}=" .env; then
awk -v k="$key" -v v="$value" \
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
.env > .env.tmp && mv .env.tmp .env
else
echo "${key}=${value}" >> .env
fi
}
remove_env() {
local key=$1
if grep -q "^${key}=" .env 2>/dev/null; then
grep -v "^${key}=" .env > .env.tmp && mv .env.tmp .env
fi
}
# Write the canonical keys for the chosen mode, strip the opposite mode's
# keys so stale values can't confuse the adapter's factory.
upsert_env IMESSAGE_LOCAL "$IMESSAGE_LOCAL"
if [ "$IMESSAGE_LOCAL" = "true" ]; then
upsert_env IMESSAGE_ENABLED "$IMESSAGE_ENABLED"
remove_env IMESSAGE_SERVER_URL
remove_env IMESSAGE_API_KEY
else
upsert_env IMESSAGE_SERVER_URL "$IMESSAGE_SERVER_URL"
upsert_env IMESSAGE_API_KEY "$IMESSAGE_API_KEY"
remove_env IMESSAGE_ENABLED
fi
# Container reads from data/env/env (the host mounts it).
mkdir -p data/env
cp .env data/env/env
log "Restarting service so the new adapter picks up the creds…"
# shellcheck source=setup/lib/install-slug.sh
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
case "$(uname -s)" in
Darwin)
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
;;
Linux)
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|| true
;;
esac
# Give the adapter a moment to open chat.db (local) or handshake with
# Photon (remote) before emitting success.
sleep 3
emit_status success
-125
View File
@@ -1,125 +0,0 @@
#!/usr/bin/env bash
#
# Install the Slack adapter, persist SLACK_BOT_TOKEN + SLACK_SIGNING_SECRET to
# .env + data/env/env, and restart the service. Non-interactive — the
# operator-facing app creation walkthrough + credential paste live in
# setup/channels/slack.ts. Credentials come in via env vars:
# SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET.
#
# Emits exactly one status block on stdout (ADD_SLACK) at the end. All chatty
# progress messages go to stderr so setup:auto's raw-log capture sees the full
# story without cluttering the final block for the parser.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-slack/SKILL.md.
ADAPTER_VERSION="@chat-adapter/slack@4.26.0"
# Resolve which remote carries the channels branch — handles forks where
# upstream lives on a different remote than `origin`.
# shellcheck source=setup/lib/channels-remote.sh
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
CHANNELS_REMOTE=$(resolve_channels_remote)
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
emit_status() {
local status=$1 error=${2:-}
local already=${ADAPTER_ALREADY_INSTALLED:-false}
echo "=== NANOCLAW SETUP: ADD_SLACK ==="
echo "STATUS: ${status}"
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
[ -n "$error" ] && echo "ERROR: ${error}"
echo "=== END ==="
}
log() { echo "[add-slack] $*" >&2; }
if [ -z "${SLACK_BOT_TOKEN:-}" ]; then
emit_status failed "SLACK_BOT_TOKEN env var not set"
exit 1
fi
if [ -z "${SLACK_SIGNING_SECRET:-}" ]; then
emit_status failed "SLACK_SIGNING_SECRET env var not set"
exit 1
fi
need_install() {
[ ! -f src/channels/slack.ts ] && return 0
! grep -q "^import './slack.js';" src/channels/index.ts 2>/dev/null && return 0
return 1
}
ADAPTER_ALREADY_INSTALLED=true
if need_install; then
ADAPTER_ALREADY_INSTALLED=false
log "Fetching channels branch…"
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
exit 1
}
log "Copying adapter from ${CHANNELS_BRANCH}"
git show "${CHANNELS_BRANCH}:src/channels/slack.ts" > src/channels/slack.ts
# Append self-registration import if missing.
if ! grep -q "^import './slack.js';" src/channels/index.ts; then
echo "import './slack.js';" >> src/channels/index.ts
fi
log "Installing ${ADAPTER_VERSION}"
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
exit 1
}
log "Building…"
pnpm run build >&2 2>/dev/null || {
emit_status failed "pnpm run build failed"
exit 1
}
else
log "Adapter files already installed — skipping install phase."
fi
# Persist credentials. auto.ts validates via auth.test before this point, so
# bad values here would be an internal bug rather than operator input.
touch .env
upsert_env() {
local key=$1 value=$2
if grep -q "^${key}=" .env; then
awk -v k="$key" -v v="$value" \
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
.env > .env.tmp && mv .env.tmp .env
else
echo "${key}=${value}" >> .env
fi
}
upsert_env SLACK_BOT_TOKEN "$SLACK_BOT_TOKEN"
upsert_env SLACK_SIGNING_SECRET "$SLACK_SIGNING_SECRET"
# Container reads from data/env/env (the host mounts it).
mkdir -p data/env
cp .env data/env/env
log "Restarting service so the new adapter picks up the credentials…"
# shellcheck source=setup/lib/install-slug.sh
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
case "$(uname -s)" in
Darwin)
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
;;
Linux)
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|| true
;;
esac
# Give the Slack adapter a moment to finish starting the webhook listener
# before emitting success.
sleep 3
emit_status success
-139
View File
@@ -1,139 +0,0 @@
#!/usr/bin/env bash
#
# Install the Teams adapter, persist TEAMS_APP_ID / _PASSWORD / _TENANT_ID /
# _TYPE to .env + data/env/env, and restart the service. Non-interactive —
# the operator-facing Azure portal walkthroughs live in
# setup/channels/teams.ts. Credentials come in via env vars:
# TEAMS_APP_ID (required)
# TEAMS_APP_PASSWORD (required — client secret value from Azure)
# TEAMS_APP_TYPE (required — SingleTenant | MultiTenant)
# TEAMS_APP_TENANT_ID (required when type=SingleTenant)
#
# Emits exactly one status block on stdout (ADD_TEAMS) at the end. All chatty
# progress messages go to stderr so setup:auto's raw-log capture sees the
# full story without cluttering the final block for the parser.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-teams/SKILL.md.
ADAPTER_VERSION="@chat-adapter/teams@4.26.0"
# Resolve which remote carries the channels branch — handles forks where
# upstream lives on a different remote than `origin`.
# shellcheck source=setup/lib/channels-remote.sh
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
CHANNELS_REMOTE=$(resolve_channels_remote)
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
emit_status() {
local status=$1 error=${2:-}
local already=${ADAPTER_ALREADY_INSTALLED:-false}
echo "=== NANOCLAW SETUP: ADD_TEAMS ==="
echo "STATUS: ${status}"
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
[ -n "$error" ] && echo "ERROR: ${error}"
echo "=== END ==="
}
log() { echo "[add-teams] $*" >&2; }
if [ -z "${TEAMS_APP_ID:-}" ]; then
emit_status failed "TEAMS_APP_ID env var not set"
exit 1
fi
if [ -z "${TEAMS_APP_PASSWORD:-}" ]; then
emit_status failed "TEAMS_APP_PASSWORD env var not set"
exit 1
fi
if [ -z "${TEAMS_APP_TYPE:-}" ]; then
emit_status failed "TEAMS_APP_TYPE env var not set (SingleTenant|MultiTenant)"
exit 1
fi
if [ "${TEAMS_APP_TYPE}" = "SingleTenant" ] && [ -z "${TEAMS_APP_TENANT_ID:-}" ]; then
emit_status failed "TEAMS_APP_TENANT_ID required when TEAMS_APP_TYPE=SingleTenant"
exit 1
fi
need_install() {
[ ! -f src/channels/teams.ts ] && return 0
! grep -q "^import './teams.js';" src/channels/index.ts 2>/dev/null && return 0
return 1
}
ADAPTER_ALREADY_INSTALLED=true
if need_install; then
ADAPTER_ALREADY_INSTALLED=false
log "Fetching channels branch…"
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
exit 1
}
log "Copying adapter from ${CHANNELS_BRANCH}"
git show "${CHANNELS_BRANCH}:src/channels/teams.ts" > src/channels/teams.ts
# Append self-registration import if missing.
if ! grep -q "^import './teams.js';" src/channels/index.ts; then
echo "import './teams.js';" >> src/channels/index.ts
fi
log "Installing ${ADAPTER_VERSION}"
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
exit 1
}
log "Building…"
pnpm run build >&2 2>/dev/null || {
emit_status failed "pnpm run build failed"
exit 1
}
else
log "Adapter files already installed — skipping install phase."
fi
# Persist credentials.
touch .env
upsert_env() {
local key=$1 value=$2
if grep -q "^${key}=" .env; then
awk -v k="$key" -v v="$value" \
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
.env > .env.tmp && mv .env.tmp .env
else
echo "${key}=${value}" >> .env
fi
}
upsert_env TEAMS_APP_ID "$TEAMS_APP_ID"
upsert_env TEAMS_APP_PASSWORD "$TEAMS_APP_PASSWORD"
upsert_env TEAMS_APP_TYPE "$TEAMS_APP_TYPE"
if [ -n "${TEAMS_APP_TENANT_ID:-}" ]; then
upsert_env TEAMS_APP_TENANT_ID "$TEAMS_APP_TENANT_ID"
fi
# Container reads from data/env/env (the host mounts it).
mkdir -p data/env
cp .env data/env/env
log "Restarting service so the new adapter picks up the credentials…"
# shellcheck source=setup/lib/install-slug.sh
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
case "$(uname -s)" in
Darwin)
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
;;
Linux)
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|| true
;;
esac
# Give the Teams adapter a moment to register its webhook before the driver
# continues.
sleep 5
emit_status success
-164
View File
@@ -1,164 +0,0 @@
#!/usr/bin/env bash
#
# Install the Telegram adapter, persist the bot token to .env + data/env/env,
# restart the service, and open the bot's chat page in the local Telegram
# client. Non-interactive — the operator-facing "Create a bot" instructions
# and token paste live in setup/auto.ts. The token comes in via the
# TELEGRAM_BOT_TOKEN env var.
#
# Emits exactly one status block on stdout (ADD_TELEGRAM) at the end. All
# chatty progress messages go to stderr so setup:auto's raw-log capture
# sees the full story without cluttering the final block for the parser.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-telegram/SKILL.md.
ADAPTER_VERSION="@chat-adapter/telegram@4.26.0"
# Resolve which remote carries the channels branch — handles forks where
# upstream lives on a different remote than `origin`.
# shellcheck source=setup/lib/channels-remote.sh
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
CHANNELS_REMOTE=$(resolve_channels_remote)
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
emit_status() {
local status=$1 error=${2:-}
local already=${ADAPTER_ALREADY_INSTALLED:-false}
local username=${BOT_USERNAME:-}
echo "=== NANOCLAW SETUP: ADD_TELEGRAM ==="
echo "STATUS: ${status}"
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
[ -n "$username" ] && echo "BOT_USERNAME: ${username}"
[ -n "$error" ] && echo "ERROR: ${error}"
echo "=== END ==="
}
log() { echo "[add-telegram] $*" >&2; }
if [ -z "${TELEGRAM_BOT_TOKEN:-}" ]; then
emit_status failed "TELEGRAM_BOT_TOKEN env var not set"
exit 1
fi
if ! [[ "$TELEGRAM_BOT_TOKEN" =~ ^[0-9]+:[A-Za-z0-9_-]{35,}$ ]]; then
emit_status failed "token format invalid (expected <digits>:<chars>)"
exit 1
fi
need_install() {
[ ! -f src/channels/telegram.ts ] && return 0
! grep -q "^import './telegram.js';" src/channels/index.ts 2>/dev/null && return 0
return 1
}
ADAPTER_ALREADY_INSTALLED=true
if need_install; then
ADAPTER_ALREADY_INSTALLED=false
log "Fetching channels branch…"
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
exit 1
}
# pair-telegram.ts is maintained in this branch (setup-auto), so it's NOT
# in this list — do not overwrite the local version with the channels copy.
log "Copying adapter files from ${CHANNELS_BRANCH}"
for f in \
src/channels/telegram.ts \
src/channels/telegram-pairing.ts \
src/channels/telegram-pairing.test.ts \
src/channels/telegram-markdown-sanitize.ts \
src/channels/telegram-markdown-sanitize.test.ts
do
git show "${CHANNELS_BRANCH}:$f" > "$f"
done
# Append self-registration import if missing.
if ! grep -q "^import './telegram.js';" src/channels/index.ts; then
echo "import './telegram.js';" >> src/channels/index.ts
fi
# Register pair-telegram step if not already in the STEPS map.
# Uses node (not sed) since sed's in-place + escape semantics differ
# between BSD (macOS) and GNU.
node -e '
const fs = require("fs");
const p = "setup/index.ts";
let s = fs.readFileSync(p, "utf-8");
if (!s.includes("\047pair-telegram\047")) {
s = s.replace(
/(register: \(\) => import\(\x27\.\/register\.js\x27\),)/,
"$1\n \x27pair-telegram\x27: () => import(\x27./pair-telegram.js\x27),"
);
fs.writeFileSync(p, s);
}
'
log "Installing ${ADAPTER_VERSION}"
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
exit 1
}
log "Building…"
pnpm run build >&2 2>/dev/null || {
emit_status failed "pnpm run build failed"
exit 1
}
else
log "Adapter files already installed — skipping install phase."
fi
# Persist token. auto.ts validates before this point, so a bad token here
# would be an internal bug rather than operator input.
touch .env
if grep -q '^TELEGRAM_BOT_TOKEN=' .env; then
awk -v tok="$TELEGRAM_BOT_TOKEN" \
'/^TELEGRAM_BOT_TOKEN=/{print "TELEGRAM_BOT_TOKEN=" tok; next} {print}' \
.env > .env.tmp && mv .env.tmp .env
else
echo "TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}" >> .env
fi
# Look up the bot username (auto.ts already validated; we re-query here so
# standalone invocations still work — BOT_USERNAME is emitted in the status
# block for parent drivers to display).
INFO=$(curl -fsS --max-time 8 \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe" 2>/dev/null || true)
BOT_USERNAME=""
if echo "$INFO" | grep -q '"ok":true'; then
BOT_USERNAME=$(echo "$INFO" | sed -nE 's/.*"username":"([^"]+)".*/\1/p')
fi
# Container reads from data/env/env (the host mounts it).
mkdir -p data/env
cp .env data/env/env
# Browser/app deep-link is done by the parent driver (setup/channels/telegram.ts)
# BEFORE this script runs — gated on a clack confirm so focus-stealing doesn't
# surprise the user. Keeping it out of here means this script stays pure
# non-interactive install.
log "Restarting service so the new adapter picks up the token…"
# shellcheck source=setup/lib/install-slug.sh
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
case "$(uname -s)" in
Darwin)
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
;;
Linux)
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|| true
;;
esac
# Give the Telegram adapter a moment to finish starting before pair-telegram
# begins polling for the user's code message.
sleep 5
emit_status success
+141 -2
View File
@@ -38,8 +38,13 @@ 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,
@@ -48,6 +53,8 @@ import {
} from './lib/setup-config-parse.js';
import { runAdvancedScreen } from './lib/setup-config-screen.js';
import { runWindowedStep } from './lib/windowed-runner.js';
import { runUninstallFlow } from './uninstall/flow.js';
import { detectExistingInstall } from './uninstall/scan.js';
import { detectRegisteredGroups, detectExistingDisplayName } from './environment.js';
import { pollHealth } from './onecli.js';
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
@@ -88,6 +95,17 @@ async function main(): Promise<void> {
let configValues = { ...readFromEnv(), ...flagResult.values };
applyToEnv(configValues);
// --uninstall routes to the uninstall flow before any setup side effects —
// in particular before initProgressionLog(), so an uninstall never resets
// logs/setup.log on its way to (possibly) deleting logs/ entirely.
if (configValues.uninstall === true) {
await runUninstallFlow({
dryRun: configValues.dryRun === true,
yes: configValues.yes === true,
invokedFrom: 'flag',
});
}
printIntro();
initProgressionLog();
phEmit('auto_started');
@@ -121,6 +139,37 @@ async function main(): Promise<void> {
.filter(Boolean),
);
// Offer removal when setup lands on an existing install. Skipped on every
// resume path — both the fail() retry and the sg-docker re-exec pass
// NANOCLAW_SKIP (and the latter sets NANOCLAW_REEXEC_SG) — so the prompt
// appears at most once per fresh run.
const isResume = process.env.NANOCLAW_REEXEC_SG === '1' || skip.size > 0;
if (!isResume && detectExistingInstall(process.cwd())) {
const action = ensureAnswer(
await brightSelect<'keep' | 'uninstall'>({
message: 'NanoClaw is already installed in this folder. What would you like to do?',
options: [
{
value: 'keep',
label: 'Keep it & continue setup',
hint: 'recommended — re-running setup is safe',
},
{
value: 'uninstall',
label: 'Uninstall NanoClaw & exit',
hint: 'removes service, data, and agent files — asks before each step',
},
],
initialValue: 'keep',
}),
) as 'keep' | 'uninstall';
setupLog.userInput('existing_install', action);
phEmit('existing_install_detected', { action });
if (action === 'uninstall') {
await runUninstallFlow({ dryRun: false, yes: false, invokedFrom: 'setup-detection' });
}
}
if (!skip.has('environment')) {
const res = await runQuietStep('environment', {
running: 'Checking your system…',
@@ -277,8 +326,64 @@ async function main(): Promise<void> {
}
}
let agentProvider: string | undefined;
if (!skip.has('auth')) {
await runAuthStep();
// 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();
}
}
if (!skip.has('mounts')) {
@@ -704,6 +809,40 @@ 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.'));
@@ -1217,7 +1356,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;
+97 -22
View File
@@ -13,7 +13,8 @@
* 5. Confirm owner identity (falls back to a manual user-id prompt with
* Developer Mode instructions if declined or if the app is team-owned)
* 6. Print the OAuth invite URL, open it, wait for "I've added the bot"
* 7. Install the adapter via setup/add-discord.sh (non-interactive)
* 7. Apply the /add-discord skill via the directive engine (the skill's
* SKILL.md is the single source of truth) + restart the service
* 8. POST /users/@me/channels to open the DM channel (yields dm channel id)
* 9. Ask for the messaging-agent name (defaulting to "Nano")
* 10. Wire the agent via scripts/init-first-agent.ts, which sends the welcome
@@ -23,9 +24,12 @@
* entries in logs/setup.log, full raw output in per-step files under
* logs/setup-steps/. See docs/setup-flow.md.
*/
import { execSync } from 'node:child_process';
import * as p from '@clack/prompts';
import k from 'kleur';
import { applySkill, type Prompter } from '../../scripts/skill-apply.js';
import * as setupLog from '../logs.js';
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
import { brightSelect } from '../lib/bright-select.js';
@@ -76,31 +80,12 @@ export async function runDiscordChannel(displayName: string): Promise<ChannelFlo
await promptInviteBot(app.applicationId, botUsername);
const install = await runQuietChild(
'discord-install',
'bash',
['setup/add-discord.sh'],
{
running: `Connecting Discord to @${botUsername}`,
done: 'Discord connected.',
},
{
env: {
DISCORD_BOT_TOKEN: token,
DISCORD_APPLICATION_ID: app.applicationId,
DISCORD_PUBLIC_KEY: app.publicKey,
},
extraFields: {
BOT_USERNAME: botUsername,
APPLICATION_ID: app.applicationId,
},
},
);
const install = await applyDiscordSkill(token, app, botUsername);
if (!install.ok) {
await fail(
'discord-install',
"Couldn't connect Discord.",
'See logs/setup-steps/ for details, then retry setup.',
install.detail || 'See logs/setup-steps/ for details, then retry setup.',
);
}
@@ -145,6 +130,96 @@ export async function runDiscordChannel(displayName: string): Promise<ChannelFlo
}
}
/**
* Install the Discord adapter and persist credentials by applying the
* `/add-discord` skill through the structured-directive engine. The skill's
* SKILL.md is the single source of truth this replaces the hand-maintained
* setup/add-discord.sh, which had already drifted into a parallel copy of the
* pinned adapter version and install steps.
*
* The three credentials collected/derived above (bot token, application ID,
* public key) are handed to the skill's `prompt` directives through the
* in-process Prompter, so they never touch argv or disk en route. The engine
* runs copy/append/dep/build + env-set/env-sync; we restart the service after
* (the skill itself doesn't, by design). add-discord is fully deterministic and
* all three values are supplied, so a healthy apply leaves nothing for an agent
* and nothing deferred either bucket being non-empty means the install failed.
*/
async function applyDiscordSkill(
token: string,
app: AppInfo,
botUsername: string,
): Promise<{ ok: boolean; detail: string }> {
const projectRoot = process.cwd();
const s = p.spinner();
const start = Date.now();
s.start(`Connecting Discord to @${botUsername}`);
const prompter: Prompter = {
async ask(name) {
if (name === 'bot_token') return token;
if (name === 'application_id') return app.applicationId;
if (name === 'public_key') return app.publicKey;
return undefined;
},
};
try {
const result = await applySkill('.claude/skills/add-discord', projectRoot, {
prompter,
exec: (cmd) => {
execSync(cmd, { cwd: projectRoot, stdio: 'pipe' });
},
// Fork-aware: reuse the existing resolver (handles upstream/fork remotes
// and the auto-add-upstream fallback) instead of assuming `origin`.
resolveRemote: () =>
execSync('source setup/lib/channels-remote.sh; resolve_channels_remote', {
cwd: projectRoot,
shell: '/bin/bash',
encoding: 'utf8',
}).trim(),
});
if (result.agentTasks.length || result.deferred.length) {
const why = [...result.agentTasks.map((t) => t.reason), ...result.deferred].join('; ');
s.stop("Couldn't finish installing Discord.", 1);
setupLog.step('discord-install', 'failed', Date.now() - start, { ERROR: why });
return { ok: false, detail: why };
}
restartService(projectRoot);
s.stop('Discord adapter installed.');
setupLog.step('discord-install', 'success', Date.now() - start, {
APPLIED: String(result.applied.length),
SKIPPED: String(result.skipped.length),
BOT_USERNAME: botUsername,
APPLICATION_ID: app.applicationId,
});
return { ok: true, detail: '' };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
s.stop("Couldn't install the Discord adapter.", 1);
setupLog.step('discord-install', 'failed', Date.now() - start, { ERROR: message });
return { ok: false, detail: 'See logs/setup-steps/ for details, then retry setup.' };
}
}
/** Best-effort service restart so the new adapter + credentials take effect. */
function restartService(projectRoot: string): void {
const script = [
`source "${projectRoot}/setup/lib/install-slug.sh"`,
'case "$(uname -s)" in',
' Darwin) launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" ;;',
' Linux) systemctl --user restart "$(systemd_unit)" || sudo systemctl restart "$(systemd_unit)" ;;',
'esac',
].join('\n');
try {
execSync(script, { cwd: projectRoot, stdio: 'pipe', shell: '/bin/bash' });
} catch {
// The service may not be installed yet during a fresh setup — best-effort.
}
}
async function askHasBotToken(): Promise<'yes' | 'no' | 'back'> {
const answer = ensureAnswer(
await brightSelect({
+131 -26
View File
@@ -19,26 +19,29 @@
* Remote: prompt for Photon server URL + API key
* 3. Ask for the phone or email the operator messages from this is
* the platform-id for first-agent wiring
* 4. Install the adapter (setup/add-imessage.sh, non-interactive)
* 4. Install the adapter by applying the /add-imessage skill in-process
* (SKILL.md is the single source of truth) + restart the service
* 5. Wire the agent via scripts/init-first-agent.ts the welcome
* iMessage goes out through the normal delivery path
*
* All output obeys the three-level contract. See docs/setup-flow.md.
*/
import { execSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import { applySkill, type Prompter } from '../../scripts/skill-apply.js';
import * as setupLog from '../logs.js';
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
import { brightSelect } from '../lib/bright-select.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { accentGreen, note, wrapForGutter } from '../lib/theme.js';
import { readEnvKey } from '../environment.js';
import { readEnvKey, upsertEnvKey } from '../environment.js';
const DEFAULT_AGENT_NAME = 'Nano';
@@ -71,34 +74,12 @@ export async function runIMessageChannel(displayName: string): Promise<ChannelFl
const handle = await askOperatorHandle();
const install = await runQuietChild(
'imessage-install',
'bash',
['setup/add-imessage.sh'],
{
running:
mode === 'local'
? "Connecting the iMessage adapter to this Mac…"
: `Connecting the iMessage adapter to ${remoteCreds!.serverUrl}`,
done: 'iMessage adapter installed.',
},
{
env:
mode === 'local'
? { IMESSAGE_LOCAL: 'true', IMESSAGE_ENABLED: 'true' }
: {
IMESSAGE_LOCAL: 'false',
IMESSAGE_SERVER_URL: remoteCreds!.serverUrl,
IMESSAGE_API_KEY: remoteCreds!.apiKey,
},
extraFields: { MODE: mode },
},
);
const install = await applyIMessageSkill(mode, remoteCreds);
if (!install.ok) {
await fail(
'imessage-install',
"Couldn't install the iMessage adapter.",
'See logs/setup-steps/ for details, then retry setup.',
install.detail || 'See logs/setup-steps/ for details, then retry setup.',
);
}
@@ -141,6 +122,130 @@ export async function runIMessageChannel(displayName: string): Promise<ChannelFl
}
}
/**
* Install the iMessage adapter and persist mode/creds by applying the
* `/add-imessage` skill through the structured-directive engine. The skill's
* SKILL.md is the single source of truth this replaces the hand-maintained
* setup/add-imessage.sh.
*
* The skill's deterministic directives cover copy/append/dep/build/env-sync,
* but the mode-specific `.env` keys live in the skill's prose (the engine has no
* `prompt` directive for them, and the keys differ by mode). So this flow writes
* the canonical keys for the chosen mode and strips the opposite mode's keys
* before applying the skill; the skill's `nc:env-sync` directive then copies
* `.env` `data/env/env`. We restart the service after (the skill itself
* doesn't, by design).
*
* add-imessage has no `prompt` directives, so the Prompter is a no-op
* pass-through. A healthy apply leaves nothing for an agent and nothing
* deferred either bucket being non-empty means the install failed.
*/
async function applyIMessageSkill(
mode: Mode,
remoteCreds: RemoteCreds | null,
): Promise<{ ok: boolean; detail: string }> {
const projectRoot = process.cwd();
const s = p.spinner();
const start = Date.now();
s.start(
mode === 'local'
? 'Connecting the iMessage adapter to this Mac…'
: `Connecting the iMessage adapter to ${remoteCreds!.serverUrl}`,
);
// The mode keys are prose in SKILL.md — write them before applySkill so the
// skill's nc:env-sync picks them up. Strip the opposite mode's keys so a stale
// value can't confuse the adapter's factory.
if (mode === 'local') {
upsertEnvKey('IMESSAGE_LOCAL', 'true', projectRoot);
upsertEnvKey('IMESSAGE_ENABLED', 'true', projectRoot);
removeEnvKey('IMESSAGE_SERVER_URL', projectRoot);
removeEnvKey('IMESSAGE_API_KEY', projectRoot);
} else {
upsertEnvKey('IMESSAGE_LOCAL', 'false', projectRoot);
upsertEnvKey('IMESSAGE_SERVER_URL', remoteCreds!.serverUrl, projectRoot);
upsertEnvKey('IMESSAGE_API_KEY', remoteCreds!.apiKey, projectRoot);
removeEnvKey('IMESSAGE_ENABLED', projectRoot);
}
// add-imessage has no prompt vars; this satisfies the Prompter contract
// without ever asking the human (and never returns a value to defer on).
const prompter: Prompter = {
async ask() {
return undefined;
},
};
try {
const result = await applySkill('.claude/skills/add-imessage', projectRoot, {
prompter,
exec: (cmd) => {
execSync(cmd, { cwd: projectRoot, stdio: 'pipe' });
},
// Fork-aware: reuse the existing resolver (handles upstream/fork remotes
// and the auto-add-upstream fallback) instead of assuming `origin`.
resolveRemote: () =>
execSync('source setup/lib/channels-remote.sh; resolve_channels_remote', {
cwd: projectRoot,
shell: '/bin/bash',
encoding: 'utf8',
}).trim(),
});
if (result.agentTasks.length || result.deferred.length) {
const why = [...result.agentTasks.map((t) => t.reason), ...result.deferred].join('; ');
s.stop("Couldn't finish installing iMessage.", 1);
setupLog.step('imessage-install', 'failed', Date.now() - start, { ERROR: why });
return { ok: false, detail: why };
}
restartService(projectRoot);
s.stop('iMessage adapter installed.');
setupLog.step('imessage-install', 'success', Date.now() - start, {
APPLIED: String(result.applied.length),
SKIPPED: String(result.skipped.length),
MODE: mode,
});
return { ok: true, detail: '' };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
s.stop("Couldn't install the iMessage adapter.", 1);
setupLog.step('imessage-install', 'failed', Date.now() - start, { ERROR: message });
return { ok: false, detail: 'See logs/setup-steps/ for details, then retry setup.' };
}
}
/** Remove a single `KEY=…` line from `.env` (no-op if absent). */
function removeEnvKey(key: string, projectRoot: string): void {
const envPath = path.join(projectRoot, '.env');
let content: string;
try {
content = fs.readFileSync(envPath, 'utf-8');
} catch {
return; // no .env yet — nothing to remove
}
const kept = content
.split('\n')
.filter((l) => !l.trim().startsWith(`${key}=`));
fs.writeFileSync(envPath, kept.join('\n'));
}
/** Best-effort service restart so the new adapter + creds take effect. */
function restartService(projectRoot: string): void {
const script = [
`source "${projectRoot}/setup/lib/install-slug.sh"`,
'case "$(uname -s)" in',
' Darwin) launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" ;;',
' Linux) systemctl --user restart "$(systemd_unit)" || sudo systemctl restart "$(systemd_unit)" ;;',
'esac',
].join('\n');
try {
execSync(script, { cwd: projectRoot, stdio: 'pipe', shell: '/bin/bash' });
} catch {
// The service may not be installed yet during a fresh setup — best-effort.
}
}
async function askMode(isMac: boolean): Promise<Mode | 'back'> {
const baseOptions = isMac
? [
+95 -22
View File
@@ -8,7 +8,8 @@
* event subscriptions, and signing secret
* 2. Paste the bot token + signing secret (clack password prompts)
* 3. Validate via auth.test resolves workspace + bot identity
* 4. Install the adapter (setup/add-slack.sh, non-interactive)
* 4. Apply the /add-slack skill via the directive engine (the skill's
* SKILL.md is the single source of truth) + restart the service
* 5. Ask for the operator's Slack user ID
* 6. conversations.open to get the DM channel ID
* 7. Ask for the messaging-agent name (defaulting to "Nano")
@@ -21,9 +22,12 @@
*
* All output obeys the three-level contract. See docs/setup-flow.md.
*/
import { execSync } from 'node:child_process';
import * as p from '@clack/prompts';
import k from 'kleur';
import { applySkill, type Prompter } from '../../scripts/skill-apply.js';
import * as setupLog from '../logs.js';
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
import { brightSelect } from '../lib/bright-select.js';
@@ -53,31 +57,12 @@ export async function runSlackChannel(displayName: string): Promise<ChannelFlowR
const signingSecret = await collectSigningSecret();
const info = await validateSlackToken(token);
const install = await runQuietChild(
'slack-install',
'bash',
['setup/add-slack.sh'],
{
running: `Connecting Slack to @${info.botName} (${info.teamName})…`,
done: 'Slack adapter installed.',
},
{
env: {
SLACK_BOT_TOKEN: token,
SLACK_SIGNING_SECRET: signingSecret,
},
extraFields: {
BOT_NAME: info.botName,
TEAM_NAME: info.teamName,
TEAM_ID: info.teamId,
},
},
);
const install = await applySlackSkill(token, signingSecret, info);
if (!install.ok) {
await fail(
'slack-install',
"Couldn't connect Slack.",
'See logs/setup-steps/ for details, then retry setup.',
install.detail || 'See logs/setup-steps/ for details, then retry setup.',
);
}
@@ -125,6 +110,94 @@ export async function runSlackChannel(displayName: string): Promise<ChannelFlowR
showPostInstallChecklist(info);
}
/**
* Install the Slack adapter and persist credentials by applying the `/add-slack`
* skill through the structured-directive engine. The skill's SKILL.md is the
* single source of truth this replaces the hand-maintained setup/add-slack.sh,
* which had already drifted on the pinned adapter version.
*
* The two secrets collected above are handed to the skill's `prompt` directives
* through the in-process Prompter, so they never touch argv or disk. The engine
* runs copy/append/dep/build + env-set/env-sync; we restart the service after
* (the skill itself doesn't, by design). add-slack is fully deterministic and
* both secrets are supplied, so a healthy apply leaves nothing for an agent and
* nothing deferred either bucket being non-empty means the install failed.
*/
async function applySlackSkill(
token: string,
signingSecret: string,
info: WorkspaceInfo,
): Promise<{ ok: boolean; detail: string }> {
const projectRoot = process.cwd();
const s = p.spinner();
const start = Date.now();
s.start(`Connecting Slack to @${info.botName} (${info.teamName})…`);
const prompter: Prompter = {
async ask(name) {
if (name === 'bot_token') return token;
if (name === 'signing_secret') return signingSecret;
return undefined;
},
};
try {
const result = await applySkill('.claude/skills/add-slack', projectRoot, {
prompter,
exec: (cmd) => {
execSync(cmd, { cwd: projectRoot, stdio: 'pipe' });
},
// Fork-aware: reuse the existing resolver (handles upstream/fork remotes
// and the auto-add-upstream fallback) instead of assuming `origin`.
resolveRemote: () =>
execSync('source setup/lib/channels-remote.sh; resolve_channels_remote', {
cwd: projectRoot,
shell: '/bin/bash',
encoding: 'utf8',
}).trim(),
});
if (result.agentTasks.length || result.deferred.length) {
const why = [...result.agentTasks.map((t) => t.reason), ...result.deferred].join('; ');
s.stop("Couldn't finish installing Slack.", 1);
setupLog.step('slack-install', 'failed', Date.now() - start, { ERROR: why });
return { ok: false, detail: why };
}
restartService(projectRoot);
s.stop('Slack adapter installed.');
setupLog.step('slack-install', 'success', Date.now() - start, {
APPLIED: String(result.applied.length),
SKIPPED: String(result.skipped.length),
BOT_NAME: info.botName,
TEAM_NAME: info.teamName,
TEAM_ID: info.teamId,
});
return { ok: true, detail: '' };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
s.stop("Couldn't install the Slack adapter.", 1);
setupLog.step('slack-install', 'failed', Date.now() - start, { ERROR: message });
return { ok: false, detail: 'See logs/setup-steps/ for details, then retry setup.' };
}
}
/** Best-effort service restart so the new adapter + credentials take effect. */
function restartService(projectRoot: string): void {
const script = [
`source "${projectRoot}/setup/lib/install-slug.sh"`,
'case "$(uname -s)" in',
' Darwin) launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" ;;',
' Linux) systemctl --user restart "$(systemd_unit)" || sudo systemctl restart "$(systemd_unit)" ;;',
'esac',
].join('\n');
try {
execSync(script, { cwd: projectRoot, stdio: 'pipe', shell: '/bin/bash' });
} catch {
// The service may not be installed yet during a fresh setup — best-effort.
}
}
async function walkThroughAppCreation(): Promise<'continue' | 'back'> {
// Bright-white ANSI overrides the surrounding brand-cyan from `note()`'s
// per-line formatter so the URL stands out against the rest of the body.
+92 -25
View File
@@ -24,12 +24,14 @@
* stops there; the operator DMs the bot, NanoClaw auto-creates the
* messaging group, and they wire an agent via `/manage-channels`.
*/
import { execSync } from 'node:child_process';
import os from 'os';
import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import { applySkill, type Prompter } from '../../scripts/skill-apply.js';
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
import { brightSelect } from '../lib/bright-select.js';
import { confirmThenOpen } from '../lib/browser.js';
@@ -39,7 +41,7 @@ import {
validateWithHelpEscape,
type HandoffContext,
} from '../lib/claude-handoff.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { ensureAnswer, fail } from '../lib/runner.js';
import { buildTeamsAppPackage } from '../lib/teams-manifest.js';
import { note } from '../lib/theme.js';
import * as setupLog from '../logs.js';
@@ -539,33 +541,82 @@ async function stepSideload(args: {
// ─── step: install adapter ─────────────────────────────────────────────
/**
* Install the Teams adapter and persist credentials by applying the `/add-teams`
* skill through the structured-directive engine. The skill's SKILL.md is the
* single source of truth this replaces the hand-maintained setup/add-teams.sh,
* which duplicated the copy/append/dep/build/env steps and could drift on the
* pinned adapter version.
*
* The credentials collected above are handed to the skill's `prompt` directives
* through the in-process Prompter, so they never touch argv or disk until the
* skill's env-set/env-sync directives write them. The engine runs
* copy/append/dep/build + env-set/env-sync; we restart the service after (the
* skill itself doesn't, by design). add-teams is fully deterministic and every
* prompt var is supplied, so a healthy apply leaves nothing for an agent and
* nothing deferred either bucket being non-empty means the install failed.
*
* `app_tenant_id` is required only for Single Tenant apps; for Multi Tenant we
* supply an empty string so the env-set directive writes a blank
* TEAMS_APP_TENANT_ID, matching the skill's "leave blank for Multi Tenant" prose.
*/
async function installAdapter(collected: Collected): Promise<void> {
const env: Record<string, string> = {
TEAMS_APP_ID: collected.appId!,
TEAMS_APP_PASSWORD: collected.appPassword!,
TEAMS_APP_TYPE: collected.appType!,
};
if (collected.appType === 'SingleTenant') {
env.TEAMS_APP_TENANT_ID = collected.tenantId!;
}
const projectRoot = process.cwd();
const s = p.spinner();
const start = Date.now();
s.start('Installing the Teams adapter and restarting the service…');
const install = await runQuietChild(
'teams-install',
'bash',
['setup/add-teams.sh'],
{
running: 'Installing the Teams adapter and restarting the service…',
done: 'Teams adapter installed.',
const prompter: Prompter = {
async ask(name) {
if (name === 'app_id') return collected.appId;
if (name === 'app_password') return collected.appPassword;
if (name === 'app_type') return collected.appType;
if (name === 'app_tenant_id') {
return collected.appType === 'SingleTenant' ? collected.tenantId ?? '' : '';
}
return undefined;
},
{
env,
extraFields: {
APP_ID: collected.appId!,
APP_TYPE: collected.appType!,
};
try {
const result = await applySkill('.claude/skills/add-teams', projectRoot, {
prompter,
exec: (cmd) => {
execSync(cmd, { cwd: projectRoot, stdio: 'pipe' });
},
},
);
if (!install.ok) {
// Fork-aware: reuse the existing resolver (handles upstream/fork remotes
// and the auto-add-upstream fallback) instead of assuming `origin`.
resolveRemote: () =>
execSync('source setup/lib/channels-remote.sh; resolve_channels_remote', {
cwd: projectRoot,
shell: '/bin/bash',
encoding: 'utf8',
}).trim(),
});
if (result.agentTasks.length || result.deferred.length) {
const why = [...result.agentTasks.map((t) => t.reason), ...result.deferred].join('; ');
s.stop("Couldn't finish installing Teams.", 1);
setupLog.step('teams-install', 'failed', Date.now() - start, { ERROR: why });
fail(
'teams-install',
"Couldn't install the Teams adapter.",
why || 'See logs/setup-steps/ for details, then retry setup.',
);
}
restartService(projectRoot);
s.stop('Teams adapter installed.');
setupLog.step('teams-install', 'success', Date.now() - start, {
APPLIED: String(result.applied.length),
SKIPPED: String(result.skipped.length),
APP_ID: collected.appId!,
APP_TYPE: collected.appType!,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
s.stop("Couldn't install the Teams adapter.", 1);
setupLog.step('teams-install', 'failed', Date.now() - start, { ERROR: message });
fail(
'teams-install',
"Couldn't install the Teams adapter.",
@@ -574,6 +625,22 @@ async function installAdapter(collected: Collected): Promise<void> {
}
}
/** Best-effort service restart so the new adapter + credentials take effect. */
function restartService(projectRoot: string): void {
const script = [
`source "${projectRoot}/setup/lib/install-slug.sh"`,
'case "$(uname -s)" in',
' Darwin) launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" ;;',
' Linux) systemctl --user restart "$(systemd_unit)" || sudo systemctl restart "$(systemd_unit)" ;;',
'esac',
].join('\n');
try {
execSync(script, { cwd: projectRoot, stdio: 'pipe', shell: '/bin/bash' });
} catch {
// The service may not be installed yet during a fresh setup — best-effort.
}
}
// ─── post-install: hand off to Claude for the final wiring ────────────
async function finishWithHandoff(
@@ -688,7 +755,7 @@ async function offerHandoff(args: {
stepDescription: args.stepDescription,
completedSteps: args.args.completed.slice(),
collectedValues: redactCollected(args.args.collected),
files: ['setup/channels/teams.ts', 'setup/add-teams.sh'],
files: ['setup/channels/teams.ts', '.claude/skills/add-teams/SKILL.md'],
};
await offerClaudeHandoff(ctx);
}
+98 -15
View File
@@ -8,7 +8,8 @@
* 2. Paste the bot token (clack password) format-validated
* 3. getMe via the Bot API to resolve the bot's username
* 4. Confirm + deep-link into the bot's Telegram chat (tg://resolve)
* 5. Install the adapter (setup/add-telegram.sh, non-interactive)
* 5. Install the adapter by applying the /add-telegram skill in-process
* (directive engine; the skill's SKILL.md is the single source of truth)
* 6. Run the pair-telegram step, rendering code events as clack notes
* 7. Ask for the messaging-agent name (defaulting to "Nano")
* 8. Wire the agent via scripts/init-first-agent.ts
@@ -17,9 +18,12 @@
* structured entries in logs/setup.log, full raw output in per-step files
* under logs/setup-steps/. See docs/setup-flow.md.
*/
import { execSync } from 'node:child_process';
import * as p from '@clack/prompts';
import k from 'kleur';
import { applySkill, type Prompter } from '../../scripts/skill-apply.js';
import * as setupLog from '../logs.js';
import { isHeadless } from '../platform.js';
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
@@ -85,24 +89,12 @@ export async function runTelegramChannel(displayName: string): Promise<ChannelFl
openUrl(botUrl);
}
const install = await runQuietChild(
'telegram-install',
'bash',
['setup/add-telegram.sh'],
{
running: `Connecting Telegram to @${botUsername}`,
done: 'Telegram connected.',
},
{
env: { TELEGRAM_BOT_TOKEN: token },
extraFields: { BOT_USERNAME: botUsername },
},
);
const install = await applyTelegramSkill(token, botUsername);
if (!install.ok) {
await fail(
'telegram-install',
"Couldn't connect Telegram.",
'See logs/setup-steps/ for details, then retry setup.',
install.detail || 'See logs/setup-steps/ for details, then retry setup.',
);
}
@@ -159,6 +151,97 @@ export async function runTelegramChannel(displayName: string): Promise<ChannelFl
}
}
/**
* Install the Telegram adapter and persist the bot token by applying the
* `/add-telegram` skill through the structured-directive engine. The skill's
* SKILL.md is the single source of truth this replaces the hand-maintained
* setup/add-telegram.sh, which duplicated the copy/append/dep/build/env steps
* and had to be kept in sync with the skill by hand.
*
* The bot token collected above is handed to the skill's `prompt bot_token`
* directive through the in-process Prompter, so it never touches argv or disk
* (the engine's env-set/env-sync directives write it to .env + data/env/env).
* The engine runs copy/append/dep/build + env-set/env-sync; we restart the
* service after (the skill itself doesn't, by design) and let the caller settle
* before pairing so the polling adapter is up. add-telegram is fully
* deterministic and the token is supplied, so a healthy apply leaves nothing
* for an agent and nothing deferred either bucket being non-empty means the
* install failed.
*/
async function applyTelegramSkill(
token: string,
botUsername: string,
): Promise<{ ok: boolean; detail: string }> {
const projectRoot = process.cwd();
const s = p.spinner();
const start = Date.now();
s.start(`Connecting Telegram to @${botUsername}`);
const prompter: Prompter = {
async ask(name) {
if (name === 'bot_token') return token;
return undefined;
},
};
try {
const result = await applySkill('.claude/skills/add-telegram', projectRoot, {
prompter,
exec: (cmd) => {
execSync(cmd, { cwd: projectRoot, stdio: 'pipe' });
},
// Fork-aware: reuse the existing resolver (handles upstream/fork remotes
// and the auto-add-upstream fallback) instead of assuming `origin`.
resolveRemote: () =>
execSync('source setup/lib/channels-remote.sh; resolve_channels_remote', {
cwd: projectRoot,
shell: '/bin/bash',
encoding: 'utf8',
}).trim(),
});
if (result.agentTasks.length || result.deferred.length) {
const why = [...result.agentTasks.map((t) => t.reason), ...result.deferred].join('; ');
s.stop("Couldn't finish installing Telegram.", 1);
setupLog.step('telegram-install', 'failed', Date.now() - start, { ERROR: why });
return { ok: false, detail: why };
}
restartService(projectRoot);
// Give the Telegram adapter a moment to finish starting before pairing
// begins polling for the user's code message.
await new Promise((resolve) => setTimeout(resolve, 5000));
s.stop('Telegram connected.');
setupLog.step('telegram-install', 'success', Date.now() - start, {
APPLIED: String(result.applied.length),
SKIPPED: String(result.skipped.length),
BOT_USERNAME: botUsername,
});
return { ok: true, detail: '' };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
s.stop("Couldn't install the Telegram adapter.", 1);
setupLog.step('telegram-install', 'failed', Date.now() - start, { ERROR: message });
return { ok: false, detail: 'See logs/setup-steps/ for details, then retry setup.' };
}
}
/** Best-effort service restart so the new adapter + credentials take effect. */
function restartService(projectRoot: string): void {
const script = [
`source "${projectRoot}/setup/lib/install-slug.sh"`,
'case "$(uname -s)" in',
' Darwin) launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" ;;',
' Linux) systemctl --user restart "$(systemd_unit)" || sudo systemctl restart "$(systemd_unit)" ;;',
'esac',
].join('\n');
try {
execSync(script, { cwd: projectRoot, stdio: 'pipe', shell: '/bin/bash' });
} catch {
// The service may not be installed yet during a fresh setup — best-effort.
}
}
async function collectTelegramToken(): Promise<string | 'back'> {
const existing = readEnvKey('TELEGRAM_BOT_TOKEN');
if (existing && /^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(existing)) {
+8 -1
View File
@@ -68,8 +68,12 @@ 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 {
execFileSync('pnpm', scriptArgs, {
stdout = execFileSync('pnpm', scriptArgs, {
cwd: projectRoot,
stdio: ['ignore', 'pipe', 'pipe'],
encoding: 'utf-8',
@@ -90,10 +94,13 @@ 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',
});
+23
View File
@@ -35,6 +35,29 @@ 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;
+3
View File
@@ -23,7 +23,10 @@ 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> {
-46
View File
@@ -1,46 +0,0 @@
#!/usr/bin/env bash
# Setup helper: install-discord — bundles the preflight + install commands
# from the /add-discord skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the Discord adapter in from the `channels` branch; appends the
# self-registration import; installs the pinned @chat-adapter/discord package;
# builds. All steps are safe to re-run.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
echo "=== NANOCLAW SETUP: INSTALL_DISCORD ==="
needs_install=false
[[ -f src/channels/discord.ts ]] || needs_install=true
grep -q "import './discord.js';" src/channels/index.ts || needs_install=true
grep -q '"@chat-adapter/discord"' package.json || needs_install=true
[[ -d node_modules/@chat-adapter/discord ]] || needs_install=true
if ! $needs_install; then
echo "STATUS: already-installed"
echo "=== END ==="
exit 0
fi
echo "STEP: fetch-channels-branch"
git fetch origin channels
echo "STEP: copy-files"
git show origin/channels:src/channels/discord.ts > src/channels/discord.ts
echo "STEP: register-import"
if ! grep -q "import './discord.js';" src/channels/index.ts; then
printf "import './discord.js';\n" >> src/channels/index.ts
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/discord@4.26.0
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
-46
View File
@@ -1,46 +0,0 @@
#!/usr/bin/env bash
# Setup helper: install-gchat — bundles the preflight + install commands
# from the /add-gchat skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the Google Chat adapter in from the `channels` branch; appends the
# self-registration import; installs the pinned @chat-adapter/gchat package;
# builds. All steps are safe to re-run.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
echo "=== NANOCLAW SETUP: INSTALL_GCHAT ==="
needs_install=false
[[ -f src/channels/gchat.ts ]] || needs_install=true
grep -q "import './gchat.js';" src/channels/index.ts || needs_install=true
grep -q '"@chat-adapter/gchat"' package.json || needs_install=true
[[ -d node_modules/@chat-adapter/gchat ]] || needs_install=true
if ! $needs_install; then
echo "STATUS: already-installed"
echo "=== END ==="
exit 0
fi
echo "STEP: fetch-channels-branch"
git fetch origin channels
echo "STEP: copy-files"
git show origin/channels:src/channels/gchat.ts > src/channels/gchat.ts
echo "STEP: register-import"
if ! grep -q "import './gchat.js';" src/channels/index.ts; then
printf "import './gchat.js';\n" >> src/channels/index.ts
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/gchat@4.26.0
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
-47
View File
@@ -1,47 +0,0 @@
#!/usr/bin/env bash
# Setup helper: install-imessage — bundles the preflight + install commands
# from the /add-imessage skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the iMessage adapter in from the `channels` branch; appends the
# self-registration import; installs the pinned chat-adapter-imessage package;
# builds. Local vs remote mode pick stays in the skill — this script only
# handles the deterministic install. All steps are safe to re-run.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
echo "=== NANOCLAW SETUP: INSTALL_IMESSAGE ==="
needs_install=false
[[ -f src/channels/imessage.ts ]] || needs_install=true
grep -q "import './imessage.js';" src/channels/index.ts || needs_install=true
grep -q '"chat-adapter-imessage"' package.json || needs_install=true
[[ -d node_modules/chat-adapter-imessage ]] || needs_install=true
if ! $needs_install; then
echo "STATUS: already-installed"
echo "=== END ==="
exit 0
fi
echo "STEP: fetch-channels-branch"
git fetch origin channels
echo "STEP: copy-files"
git show origin/channels:src/channels/imessage.ts > src/channels/imessage.ts
echo "STEP: register-import"
if ! grep -q "import './imessage.js';" src/channels/index.ts; then
printf "import './imessage.js';\n" >> src/channels/index.ts
fi
echo "STEP: pnpm-install"
pnpm install chat-adapter-imessage@0.1.1
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
-95
View File
@@ -1,95 +0,0 @@
#!/usr/bin/env bash
# Setup helper: install-linear — bundles the preflight + install commands
# from the /add-linear skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the Linear adapter in from the `channels` branch; appends the
# self-registration import; patches src/channels/chat-sdk-bridge.ts to add
# catch-all forwarding (Linear OAuth apps can't be @-mentioned, so the
# onNewMention handler never fires — the bridge needs a catchAll path);
# installs the pinned @chat-adapter/linear package; builds. All steps are
# safe to re-run.
#
# Note: the bridge patch's onNewMessage handler passes `false` for isMention
# (current trunk signature requires the arg). The /add-linear SKILL's
# snippet omits the arg — this script uses the full signature so TypeScript
# builds cleanly.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
echo "=== NANOCLAW SETUP: INSTALL_LINEAR ==="
needs_install=false
[[ -f src/channels/linear.ts ]] || needs_install=true
grep -q "import './linear.js';" src/channels/index.ts || needs_install=true
grep -q '"@chat-adapter/linear"' package.json || needs_install=true
[[ -d node_modules/@chat-adapter/linear ]] || needs_install=true
grep -q 'catchAll' src/channels/chat-sdk-bridge.ts || needs_install=true
if ! $needs_install; then
echo "STATUS: already-installed"
echo "=== END ==="
exit 0
fi
echo "STEP: fetch-channels-branch"
git fetch origin channels
echo "STEP: copy-files"
git show origin/channels:src/channels/linear.ts > src/channels/linear.ts
echo "STEP: register-import"
if ! grep -q "import './linear.js';" src/channels/index.ts; then
printf "import './linear.js';\n" >> src/channels/index.ts
fi
echo "STEP: patch-bridge-catchall-field"
if ! grep -q 'catchAll?: boolean;' src/channels/chat-sdk-bridge.ts; then
awk '
/^export interface ChatSdkBridgeConfig \{/ { in_iface = 1 }
in_iface && /^\}/ && !inserted {
print " /**"
print " * Forward ALL messages in unsubscribed threads, not just @-mentions."
print " * Use for platforms where the bot identity can'\''t be @-mentioned (e.g."
print " * Linear OAuth apps). The thread is auto-subscribed on first message."
print " */"
print " catchAll?: boolean;"
inserted = 1
in_iface = 0
}
{ print }
' src/channels/chat-sdk-bridge.ts > src/channels/chat-sdk-bridge.ts.tmp \
&& mv src/channels/chat-sdk-bridge.ts.tmp src/channels/chat-sdk-bridge.ts
fi
echo "STEP: patch-bridge-catchall-handler"
if ! grep -q 'if (config.catchAll) {' src/channels/chat-sdk-bridge.ts; then
awk '
/ \/\/ DMs — apply engage rules too/ && !inserted {
print " // Catch-all for platforms where @-mention isn'\''t possible (e.g. Linear"
print " // OAuth apps). Forward every unsubscribed message and auto-subscribe."
print " if (config.catchAll) {"
print " chat.onNewMessage(/.*/, async (thread, message) => {"
print " const channelId = adapter.channelIdFromThreadId(thread.id);"
print " await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false));"
print " await thread.subscribe();"
print " });"
print " }"
print ""
inserted = 1
}
{ print }
' src/channels/chat-sdk-bridge.ts > src/channels/chat-sdk-bridge.ts.tmp \
&& mv src/channels/chat-sdk-bridge.ts.tmp src/channels/chat-sdk-bridge.ts
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/linear@4.26.0
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
-62
View File
@@ -1,62 +0,0 @@
#!/usr/bin/env bash
# Setup helper: install-matrix — bundles the preflight + install commands
# from the /add-matrix skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the Matrix adapter in from the `channels` branch; appends the
# self-registration import; installs the pinned @beeper/chat-adapter-matrix
# package; patches the adapter's published dist so its matrix-js-sdk/lib
# imports carry .js extensions (required under Node 22 strict ESM); builds.
# All steps are safe to re-run — re-run this script after any pnpm install
# that touches the adapter.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
echo "=== NANOCLAW SETUP: INSTALL_MATRIX ==="
needs_install=false
[[ -f src/channels/matrix.ts ]] || needs_install=true
grep -q "import './matrix.js';" src/channels/index.ts || needs_install=true
grep -q '"@beeper/chat-adapter-matrix"' package.json || needs_install=true
[[ -d node_modules/@beeper/chat-adapter-matrix ]] || needs_install=true
if ! $needs_install; then
echo "STATUS: already-installed"
echo "=== END ==="
exit 0
fi
echo "STEP: fetch-channels-branch"
git fetch origin channels
echo "STEP: copy-files"
git show origin/channels:src/channels/matrix.ts > src/channels/matrix.ts
echo "STEP: register-import"
if ! grep -q "import './matrix.js';" src/channels/index.ts; then
printf "import './matrix.js';\n" >> src/channels/index.ts
fi
echo "STEP: pnpm-install"
pnpm install @beeper/chat-adapter-matrix@0.2.0
echo "STEP: patch-esm-extensions"
node -e '
const fs = require("fs"), path = require("path");
const root = "node_modules/.pnpm";
const dir = fs.readdirSync(root).find(d => d.startsWith("@beeper+chat-adapter-matrix@"));
if (!dir) { console.log("Matrix adapter not installed"); process.exit(0); }
const f = path.join(root, dir, "node_modules/@beeper/chat-adapter-matrix/dist/index.js");
fs.writeFileSync(f, fs.readFileSync(f, "utf8").replace(
/from "(matrix-js-sdk\/lib\/[^"]+?)(?<!\.js)"/g, "from \"$1.js\""
));
console.log("Patched", f);
'
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
-46
View File
@@ -1,46 +0,0 @@
#!/usr/bin/env bash
# Setup helper: install-resend — bundles the preflight + install commands
# from the /add-resend skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the Resend adapter in from the `channels` branch; appends the
# self-registration import; installs the pinned @resend/chat-sdk-adapter
# package; builds. All steps are safe to re-run.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
echo "=== NANOCLAW SETUP: INSTALL_RESEND ==="
needs_install=false
[[ -f src/channels/resend.ts ]] || needs_install=true
grep -q "import './resend.js';" src/channels/index.ts || needs_install=true
grep -q '"@resend/chat-sdk-adapter"' package.json || needs_install=true
[[ -d node_modules/@resend/chat-sdk-adapter ]] || needs_install=true
if ! $needs_install; then
echo "STATUS: already-installed"
echo "=== END ==="
exit 0
fi
echo "STEP: fetch-channels-branch"
git fetch origin channels
echo "STEP: copy-files"
git show origin/channels:src/channels/resend.ts > src/channels/resend.ts
echo "STEP: register-import"
if ! grep -q "import './resend.js';" src/channels/index.ts; then
printf "import './resend.js';\n" >> src/channels/index.ts
fi
echo "STEP: pnpm-install"
pnpm install @resend/chat-sdk-adapter@0.1.1
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
-46
View File
@@ -1,46 +0,0 @@
#!/usr/bin/env bash
# Setup helper: install-slack — bundles the preflight + install commands
# from the /add-slack skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the Slack adapter in from the `channels` branch; appends the
# self-registration import; installs the pinned @chat-adapter/slack package;
# builds. All steps are safe to re-run.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
echo "=== NANOCLAW SETUP: INSTALL_SLACK ==="
needs_install=false
[[ -f src/channels/slack.ts ]] || needs_install=true
grep -q "import './slack.js';" src/channels/index.ts || needs_install=true
grep -q '"@chat-adapter/slack"' package.json || needs_install=true
[[ -d node_modules/@chat-adapter/slack ]] || needs_install=true
if ! $needs_install; then
echo "STATUS: already-installed"
echo "=== END ==="
exit 0
fi
echo "STEP: fetch-channels-branch"
git fetch origin channels
echo "STEP: copy-files"
git show origin/channels:src/channels/slack.ts > src/channels/slack.ts
echo "STEP: register-import"
if ! grep -q "import './slack.js';" src/channels/index.ts; then
printf "import './slack.js';\n" >> src/channels/index.ts
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/slack@4.26.0
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
-46
View File
@@ -1,46 +0,0 @@
#!/usr/bin/env bash
# Setup helper: install-teams — bundles the preflight + install commands
# from the /add-teams skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the Teams adapter in from the `channels` branch; appends the
# self-registration import; installs the pinned @chat-adapter/teams package;
# builds. All steps are safe to re-run.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
echo "=== NANOCLAW SETUP: INSTALL_TEAMS ==="
needs_install=false
[[ -f src/channels/teams.ts ]] || needs_install=true
grep -q "import './teams.js';" src/channels/index.ts || needs_install=true
grep -q '"@chat-adapter/teams"' package.json || needs_install=true
[[ -d node_modules/@chat-adapter/teams ]] || needs_install=true
if ! $needs_install; then
echo "STATUS: already-installed"
echo "=== END ==="
exit 0
fi
echo "STEP: fetch-channels-branch"
git fetch origin channels
echo "STEP: copy-files"
git show origin/channels:src/channels/teams.ts > src/channels/teams.ts
echo "STEP: register-import"
if ! grep -q "import './teams.js';" src/channels/index.ts; then
printf "import './teams.js';\n" >> src/channels/index.ts
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/teams@4.26.0
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
-72
View File
@@ -1,72 +0,0 @@
#!/usr/bin/env bash
# Setup helper: install-telegram — bundles the preflight + install commands
# from the /add-telegram skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials and pairing.
#
# Copies the Telegram adapter, helpers, tests, and the pair-telegram setup
# step in from the `channels` branch; appends the self-registration import;
# registers the `pair-telegram` entry in the setup STEPS map; installs the
# pinned @chat-adapter/telegram package; builds. All steps are safe to re-run.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
echo "=== NANOCLAW SETUP: INSTALL_TELEGRAM ==="
CHANNEL_FILES=(
src/channels/telegram.ts
src/channels/telegram-pairing.ts
src/channels/telegram-pairing.test.ts
src/channels/telegram-markdown-sanitize.ts
src/channels/telegram-markdown-sanitize.test.ts
setup/pair-telegram.ts
)
needs_install=false
for f in "${CHANNEL_FILES[@]}"; do
[[ -f "$f" ]] || needs_install=true
done
grep -q "import './telegram.js';" src/channels/index.ts || needs_install=true
grep -q "'pair-telegram':" setup/index.ts || needs_install=true
grep -q '"@chat-adapter/telegram"' package.json || needs_install=true
[[ -d node_modules/@chat-adapter/telegram ]] || needs_install=true
if ! $needs_install; then
echo "STATUS: already-installed"
echo "=== END ==="
exit 0
fi
echo "STEP: fetch-channels-branch"
git fetch origin channels
echo "STEP: copy-files"
for f in "${CHANNEL_FILES[@]}"; do
git show "origin/channels:$f" > "$f"
done
echo "STEP: register-import"
if ! grep -q "import './telegram.js';" src/channels/index.ts; then
printf "import './telegram.js';\n" >> src/channels/index.ts
fi
echo "STEP: register-setup-step"
if ! grep -q "'pair-telegram':" setup/index.ts; then
awk '
{ print }
/register: \(\) => import/ && !inserted {
print " '\''pair-telegram'\'': () => import('\''./pair-telegram.js'\''),"
inserted = 1
}
' setup/index.ts > setup/index.ts.tmp && mv setup/index.ts.tmp setup/index.ts
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/telegram@4.26.0
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
-46
View File
@@ -1,46 +0,0 @@
#!/usr/bin/env bash
# Setup helper: install-webex — bundles the preflight + install commands
# from the /add-webex skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the Webex adapter in from the `channels` branch; appends the
# self-registration import; installs the pinned @bitbasti/chat-adapter-webex
# package; builds. All steps are safe to re-run.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
echo "=== NANOCLAW SETUP: INSTALL_WEBEX ==="
needs_install=false
[[ -f src/channels/webex.ts ]] || needs_install=true
grep -q "import './webex.js';" src/channels/index.ts || needs_install=true
grep -q '"@bitbasti/chat-adapter-webex"' package.json || needs_install=true
[[ -d node_modules/@bitbasti/chat-adapter-webex ]] || needs_install=true
if ! $needs_install; then
echo "STATUS: already-installed"
echo "=== END ==="
exit 0
fi
echo "STEP: fetch-channels-branch"
git fetch origin channels
echo "STEP: copy-files"
git show origin/channels:src/channels/webex.ts > src/channels/webex.ts
echo "STEP: register-import"
if ! grep -q "import './webex.js';" src/channels/index.ts; then
printf "import './webex.js';\n" >> src/channels/index.ts
fi
echo "STEP: pnpm-install"
pnpm install @bitbasti/chat-adapter-webex@0.1.0
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
+27 -1
View File
@@ -66,17 +66,43 @@ 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 function brightSelect<T>(
export async 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,
+5 -5
View File
@@ -64,15 +64,15 @@ export const STEP_FILES: Record<string, string[]> = {
channel: ['setup/auto.ts'],
verify: ['setup/verify.ts'],
// Channel-specific sub-steps:
'telegram-install': ['setup/add-telegram.sh', 'setup/channels/telegram.ts'],
'telegram-install': ['.claude/skills/add-telegram/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/telegram.ts'],
'telegram-validate': ['setup/channels/telegram.ts'],
'pair-telegram': ['setup/pair-telegram.ts', 'setup/channels/telegram.ts'],
'discord-install': ['setup/add-discord.sh', 'setup/channels/discord.ts'],
'slack-install': ['setup/add-slack.sh', 'setup/channels/slack.ts'],
'discord-install': ['.claude/skills/add-discord/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/discord.ts'],
'slack-install': ['.claude/skills/add-slack/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/slack.ts'],
'slack-validate': ['setup/channels/slack.ts'],
'imessage-install': ['setup/add-imessage.sh', 'setup/channels/imessage.ts'],
'imessage-install': ['.claude/skills/add-imessage/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/imessage.ts'],
'imessage': ['setup/channels/imessage.ts'],
'teams-install': ['setup/add-teams.sh', 'setup/channels/teams.ts'],
'teams-install': ['.claude/skills/add-teams/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/teams.ts'],
'teams-manifest': ['setup/lib/teams-manifest.ts', 'setup/channels/teams.ts'],
'init-first-agent': [
'scripts/init-first-agent.ts',
+54 -47
View File
@@ -11,9 +11,17 @@
* 1. Build a handoff prompt from the caller's context: channel, current
* step, completed steps, collected values (secrets redacted), relevant
* files to read.
* 2. Spawn `claude --append-system-prompt "<context>"
* --permission-mode acceptEdits` with `stdio: 'inherit'` so Claude owns
* the terminal.
* 2. Spawn `claude "<prompt>" --permission-mode auto` with
* `stdio: 'inherit'` so Claude owns the terminal. The positional prompt
* is auto-submitted as the first user message, so Claude starts
* orienting immediately instead of sitting at an empty prompt and the
* context stays visible in the transcript and survives `--resume`,
* which an --append-system-prompt would not.
* 2a. All handoffs in one setup run share a single session: the first
* spawn pins a generated UUID via `--session-id`, later spawns pass
* `--resume <uuid>` so Claude keeps the context of earlier handoffs.
* (stdio is inherited, so we can't *read* the session id Claude picks
* pinning our own is the only way to find the session again.)
* 3. When Claude exits (user types /exit, Ctrl-D, or closes the session),
* control returns to the setup driver. The driver can then re-offer the
* same step (e.g., "How did that go?" select).
@@ -23,6 +31,7 @@
* attempting to parse it as a real answer.
*/
import { execSync, spawn } from 'child_process';
import { randomUUID } from 'crypto';
import path from 'path';
import * as p from '@clack/prompts';
@@ -61,8 +70,8 @@ export interface HandoffContext {
}
/**
* Spawn interactive Claude with context pre-loaded as a system-prompt
* append. Returns when Claude exits.
* Spawn interactive Claude with the handoff context as an auto-submitted
* first prompt. Returns when Claude exits.
*
* Silently no-ops (returns `false`) if `claude` isn't on PATH setup runs
* where the binary is guaranteed to exist (we install it in the auth step),
@@ -78,8 +87,6 @@ export async function offerClaudeHandoff(ctx: HandoffContext): Promise<boolean>
return false;
}
const systemPrompt = buildSystemPrompt(ctx);
note(
[
"I'm handing you off to Claude in interactive mode.",
@@ -90,18 +97,39 @@ export async function offerClaudeHandoff(ctx: HandoffContext): Promise<boolean>
'Handing off to Claude',
);
return spawnInteractiveClaude(buildHandoffPrompt(ctx));
}
// One session shared by every interactive handoff in this setup-driver
// process. We pin the id ourselves (--session-id) on the first spawn because
// stdio is inherited and Claude's own id is never visible to us; subsequent
// spawns --resume it so Claude remembers earlier handoffs. Separate from
// claude-assist's non-interactive session — the two formats don't mix.
const handoffSessionId = randomUUID();
let handoffSessionStarted = false;
/**
* Spawn interactive Claude with the handoff context auto-submitted as the
* first user message. Resolves when Claude exits and control returns to
* the setup driver.
*/
function spawnInteractiveClaude(prompt: string): Promise<boolean> {
const sessionArgs = handoffSessionStarted
? ['--resume', handoffSessionId]
: ['--session-id', handoffSessionId];
return new Promise<boolean>((resolve) => {
const child = spawn(
'claude',
[
'--append-system-prompt',
systemPrompt,
prompt,
'--permission-mode',
'acceptEdits',
'auto',
...sessionArgs,
],
{ stdio: 'inherit' },
);
child.on('close', () => {
handoffSessionStarted = true;
p.log.success(brandBody("Back from Claude. Let's continue."));
resolve(true);
});
@@ -164,20 +192,20 @@ function isClaudeUsable(): boolean {
}
}
function buildSystemPrompt(ctx: HandoffContext): string {
function buildHandoffPrompt(ctx: HandoffContext): string {
const lines: string[] = [
`The user is running NanoClaw's interactive \`setup:auto\` flow to wire the ${ctx.channel} channel.`,
`They got stuck at the step: "${ctx.step}" (${ctx.stepDescription}) and asked for help.`,
`I'm running NanoClaw's interactive \`setup:auto\` flow to wire the ${ctx.channel} channel`,
`and got stuck at the step: "${ctx.step}" (${ctx.stepDescription}).`,
'',
"Your job: help them complete this specific step and get back to setup.",
"You can read files, run commands (with acceptEdits permissions), search the web,",
"and explain concepts. Be concise. When they're ready to resume, tell them to type",
"/exit and they'll return to the setup flow at the same step.",
'Help me complete this specific step and get back to setup.',
'You can read files, run commands, search the web,',
"and explain concepts. Be concise. When I'm ready to resume, remind me to type",
"/exit and I'll return to the setup flow at the same step.",
'',
];
if (ctx.completedSteps && ctx.completedSteps.length > 0) {
lines.push('Steps they have already completed:');
lines.push("Steps I've already completed:");
for (const s of ctx.completedSteps) lines.push(`${s}`);
lines.push('');
}
@@ -243,8 +271,6 @@ async function offerFailureHandoff(
);
if (!want) return false;
const systemPrompt = buildFailureSystemPrompt(ctx, projectRoot);
note(
[
"Launching Claude to help debug this failure.",
@@ -255,29 +281,10 @@ async function offerFailureHandoff(
'Handing off to Claude',
);
return new Promise<boolean>((resolve) => {
const child = spawn(
'claude',
[
'--append-system-prompt',
systemPrompt,
'--permission-mode',
'acceptEdits',
],
{ stdio: 'inherit' },
);
child.on('close', () => {
p.log.success(brandBody("Back from Claude. Let's continue."));
resolve(true);
});
child.on('error', () => {
p.log.error("Couldn't launch Claude. Continuing without handoff.");
resolve(false);
});
});
return spawnInteractiveClaude(buildFailurePrompt(ctx, projectRoot));
}
function buildFailureSystemPrompt(ctx: AssistContext, projectRoot: string): string {
function buildFailurePrompt(ctx: AssistContext, projectRoot: string): string {
const stepRefs = STEP_FILES[ctx.stepName] ?? [];
const references = [
...BIG_PICTURE_FILES,
@@ -289,20 +296,20 @@ function buildFailureSystemPrompt(ctx: AssistContext, projectRoot: string): stri
].filter((v, i, a) => a.indexOf(v) === i);
const lines: string[] = [
"The user is running NanoClaw's interactive setup flow and hit a failure.",
"I'm running NanoClaw's interactive setup flow and hit a failure.",
'',
`Failed step: ${ctx.stepName}`,
`Error: ${ctx.msg}`,
];
if (ctx.hint) lines.push(`Hint: ${ctx.hint}`);
if (ctx.hint) lines.push(`Hint shown to me: ${ctx.hint}`);
lines.push(
'',
'Your job: help them diagnose and fix this issue. Read the referenced files',
'and logs to understand what went wrong, then help them fix it. You can read',
'files, run commands, check logs, and explain what happened. Be concise.',
"When they're ready to resume setup, tell them to type /exit.",
'Help me diagnose and fix this issue. Read the referenced files and logs',
'to understand what went wrong, then help me fix it. You can read files,',
'run commands, check logs, and explain what happened. Be concise.',
"When I'm ready to resume setup, remind me to type /exit.",
'',
'Relevant files (read as needed with the Read tool):',
);

Some files were not shown because too many files have changed in this diff Show More