Compare commits

..

94 Commits

Author SHA1 Message Date
gavrielc 2242f6c997 feat(skills): port Slack Socket Mode into add-slack — when:-guarded delivery modes
Upstream added Socket Mode to the guided setup (cf8478ff) in the bespoke
setup/channels/slack.ts this branch deleted. The port expresses it the way
the engine was built for: a `connection` prompt (socket|webhook) with
when:-guarded branches —

- socket: app-level token walkthrough (connections:write scope, Socket Mode
  toggle), `nc:prompt app_token` (xapp-, reuse:SLACK_APP_TOKEN),
  SLACK_APP_TOKEN env-set. The adapter enables Socket Mode purely from the
  token's presence, so no public URL and no signing secret.
- webhook: the existing signing-secret + Event Subscriptions path, now
  guard-scoped to its branch.

Gate-policy parity holds: all three operator blocks remain prompt-barriered
(no confirms), matching the §5.1 rules; the placeholder-URL negative fixture
moves to operator index 2. Structure-tracking tests updated (directive
order, operator/prompt/env-set inventories, Option-A inputs gain
connection:webhook).

Suite 827 passed | 1 skipped; add-slack lint exit 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 08:32:19 +03:00
gavrielc 40b65521c0 refactor(skills): retire nc:env-sync — the data/env/env mirror is dead
Upstream removed every setup-side writer of data/env/env (c82f062d) because
nothing has read it since the container mount was dropped — before this
branch even forked. Our engine codified the dead pattern as a directive:
every apply copied the full .env (live tokens included) into a file nothing
consumes.

- engine: env-sync handler removed from selfStatus + applyOne
- grammar: dropped from KNOWN; a new RETIRED table gives a targeted lint
  error ("delete the fence, the adapter reads .env directly") instead of a
  generic unknown-directive message
- skills: fence stripped from the 14 converted skills; orphaned "sync to the
  container" prose cleaned (incl. add-deltachat/add-wechat old-format prose
  upstream's sweep missed); teams troubleshooting entry rewritten
- policy/spec doc lists updated

Suite 827 passed | 1 skipped; all nc: skills lint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 08:27:40 +03:00
gavrielc 81fea1d7ec Merge origin/main (185 commits) into feat/structured-skill-format
Conflict resolutions:
- 9 SKILL.mds we rewrote in the nc: format: kept ours, then bumped every
  @chat-adapter pin 4.26.0 -> 4.29.0 to match the merged chat core (the
  lockstep lint enforces equality; pin-test expectation updated).
- 14 setup/add-*.sh, install-*.sh + setup/channels/{slack,signal,whatsapp}.ts:
  stay deleted — replaced by the directive engine + runChannelSkill. Upstream's
  Slack Socket Mode (cf8478ff, setup-side) will be ported into add-slack's
  SKILL.md as a when:-guarded mode in a follow-up commit.
- src/cli/resources/groups.ts: fused create — upstream's --template branch
  first, else our idempotent-on-folder create with ensureContainerConfig.
- src/cli/registry.ts + dispatch.test.ts: both sides' additions kept
  (hostOnly field/doc + upstream's resource doc and approval-context fixture).

Known follow-ups (next commits): remove the now-dead nc:env-sync directive
(upstream removed the data/env/env mirror writers; the reader died pre-fork),
port Slack Socket Mode into the SKILL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 08:21:49 +03:00
glifocat b6cb53e21c Merge pull request #2953 from nanocoai/docs-staleness-fixes
docs: correct stale mount topology row + removed env var
2026-07-04 23:06:27 +02:00
gavrielc 5a7e75f854 Apply suggestion from @gavrielc 2026-07-04 23:36:05 +03:00
glifocat d770e56596 docs: correct stale mount topology + removed env var vs code 2026-07-04 19:39:13 +00:00
github-actions[bot] 08a1ac9753 chore: bump version to 2.1.38 2026-07-04 16:58:45 +00:00
gavrielc 273489badf Merge pull request #2931 from nanocoai/cleanup/async-image-build
Build agent images asynchronously instead of blocking the host
2026-07-04 19:58:34 +03:00
gavrielc 504651633f Merge branch 'main' into cleanup/async-image-build 2026-07-04 19:58:25 +03:00
github-actions[bot] 694ab74aa1 chore: bump version to 2.1.37 2026-07-04 16:55:14 +00:00
gavrielc aae81321e9 Merge pull request #2948 from nanocoai/cleanup/arch-scheduling-provider-docs
Fix stale architecture, scheduling, provider-config, and overlay docs
2026-07-04 19:55:02 +03:00
gavrielc 6c46b1e43d Merge branch 'main' into cleanup/arch-scheduling-provider-docs 2026-07-04 19:54:49 +03:00
gavrielc 71453707ba Apply suggestion from @gavrielc 2026-07-04 19:54:15 +03:00
gavrielc 0aa9e668f6 Apply suggestion from @gavrielc 2026-07-04 19:53:24 +03:00
gavrielc 023128def5 Merge pull request #2946 from nanocoai/cleanup/remove-env-secrets-mirror
Remove the dead data/env/env secrets mirror
2026-07-04 19:52:00 +03:00
gavrielc c4a1679666 Merge branch 'main' into cleanup/remove-env-secrets-mirror 2026-07-04 19:51:30 +03:00
gavrielc 2e40e17155 Merge pull request #2945 from nanocoai/cleanup/security-docs-v2
Rewrite the security docs to match the v2 perimeter
2026-07-04 19:51:10 +03:00
gavrielc f896caefa0 Merge branch 'main' into cleanup/security-docs-v2 2026-07-04 19:50:59 +03:00
gavrielc 55c003c5f4 Apply suggestion from @gavrielc 2026-07-04 19:50:43 +03:00
gavrielc 20f2bae5cd Apply suggestion from @gavrielc 2026-07-04 19:50:10 +03:00
gavrielc d6b82e6473 Apply suggestion from @gavrielc 2026-07-04 19:47:01 +03:00
gavrielc fea5ac5f2c Apply suggestion from @gavrielc 2026-07-04 19:45:37 +03:00
gavrielc 2b86bbef19 Apply suggestion from @gavrielc 2026-07-04 19:45:04 +03:00
gavrielc 9dc0a7e62f Apply suggestion from @gavrielc 2026-07-04 19:44:33 +03:00
gavrielc 2035033397 Delete docs/docker-sandboxes.md 2026-07-04 19:44:00 +03:00
gavrielc 1c294ff9a5 Delete docs/APPLE-CONTAINER-NETWORKING.md 2026-07-04 19:43:27 +03:00
github-actions[bot] 43198310e1 chore: bump version to 2.1.36 2026-07-04 16:41:48 +00:00
gavrielc b7d6eebf4d Merge pull request #2943 from nanocoai/cleanup/mount-allowlist-readonly-and-cache
Mount allowlist: honor the readOnly key and stop caching parse errors
2026-07-04 19:41:35 +03:00
gavrielc 803f3413ec Merge branch 'main' into cleanup/mount-allowlist-readonly-and-cache 2026-07-04 19:41:26 +03:00
github-actions[bot] a8b7da7bcf docs: update token count to 208k tokens · 104% of context window 2026-07-04 16:40:39 +00:00
github-actions[bot] 0b6ad5550d chore: bump version to 2.1.35 2026-07-04 16:40:37 +00:00
gavrielc c1965cfcaf Merge pull request #2942 from nanocoai/cleanup/a2a-batch-stamp-crossprocess
Fix the agent-to-agent in_reply_to stamp (cross-process no-op)
2026-07-04 19:40:23 +03:00
gavrielc 3cefbfccf4 Merge branch 'main' into cleanup/a2a-batch-stamp-crossprocess 2026-07-04 19:38:09 +03:00
github-actions[bot] 6f22c73aac docs: update token count to 207k tokens · 104% of context window 2026-07-04 16:38:00 +00:00
gavrielc 31dd37b3a8 Merge pull request #2940 from nanocoai/cleanup/remove-onedb-shims
Delete one-DB-era @deprecated shims and dead exports
2026-07-04 19:37:47 +03:00
gavrielc 0dfde3aa5b Merge branch 'main' into cleanup/remove-onedb-shims 2026-07-04 19:37:36 +03:00
github-actions[bot] 33d2366252 docs: update token count to 208k tokens · 104% of context window 2026-07-04 16:35:58 +00:00
github-actions[bot] 9bf1514f26 chore: bump version to 2.1.34 2026-07-04 16:35:56 +00:00
gavrielc be3502c23b Merge pull request #2937 from nanocoai/cleanup/resolve-session-reprovision
Re-provision a missing session folder so the documented reset works
2026-07-04 19:35:44 +03:00
gavrielc e3f38dbed9 Merge branch 'main' into cleanup/resolve-session-reprovision 2026-07-04 19:35:26 +03:00
github-actions[bot] d8a6b99f0e chore: bump version to 2.1.33 2026-07-04 16:35:06 +00:00
gavrielc 8ca7564fbc Merge pull request #2936 from nanocoai/cleanup/cli-protocol-vocab
Clean up dead ncl CLI protocol vocabulary
2026-07-04 19:34:51 +03:00
gavrielc afa07f0566 Merge branch 'main' into cleanup/cli-protocol-vocab 2026-07-04 19:34:42 +03:00
github-actions[bot] 8059ee4eec docs: update token count to 207k tokens · 104% of context window 2026-07-04 16:34:08 +00:00
gavrielc 41c486cacc Merge branch 'main' into cleanup/cli-protocol-vocab 2026-07-04 19:34:02 +03:00
gavrielc 190a7d4f43 Merge pull request #2935 from nanocoai/cleanup/remove-dead-v1-config
Delete dead v1 config knobs and the broken pnpm auth script
2026-07-04 19:33:53 +03:00
gavrielc 8a3444a68c feat(setup): ordinal-suffix repeated spinner captions — (1/2), (2/2)
Two steps under one heading share a heading-derived caption (add-telegram's
build + test both render "5. Build and validate"), which reads as a
stuttered duplicate in the wizard. The driver now pre-computes labelOrdinals
from the parsed document (same pre-parse the gate policy uses) and suffixes
" (i/n)" when a caption repeats — keyed by the directive line the step
events already carry. Pure driver-side presentation; no engine change, no
event-payload change. Solo captions stay unsuffixed; counting is static, so
a runtime-skipped sibling can leave a cosmetic gap in the sequence.

Live stutter observed in the telegram wizard smoke run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 18:36:40 +03:00
gavrielc 0c020e664d fix(setup): live-smoke fixes — anchor the channels-remote glob; silence operator blocks once blocked
Two bugs the first live wizard run surfaced:

- channels-remote.sh's unanchored `*qwibitai/nanoclaw*` glob matched sibling
  channel repos (qwibitai/nanoclaw-discord sorts first on multi-remote
  machines) and resolved the from-branch copy to a remote with no channels
  ref, failing every from-branch fetch. The pattern now anchors the repo-name
  tail (nanoclaw / nanoclaw.git only).

- Once the run-health gate latched `blocked`, later nc:operator blocks still
  rendered — walking the human through "a pairing code is about to appear"
  and a readiness confirm for a step the engine had already gated. Blocked
  runs now skip operator directives: no event (so no URL offer / readiness
  confirm), no operatorMessages entry, recorded in skipped. Spec §2 updated.

Suite 722 passed | 1 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 18:05:59 +03:00
gavrielc c02c002caa refactor(skills): grammar diet — lint rejects the six removed presentation attrs
Drop min:/error:/open:/gate validation and their grammar doc from
scripts/skill-directives.ts; add hard lint errors for all six removed
attrs (operator open:/gate, prompt min:/error:, any-directive
label:/on-fail:) so stale authorship fails loudly instead of silently
no-oping, each pointing at its replacement. Add the warn-only
lintGateAmbiguity check (unguarded operator followed by when:-guarded
directives spanning more than one branch value — spec §3/§5.1 open
question 1), wired into the CLI warnings alongside the reference floor.
Prompt/operator grammar doc rewritten for validate-at-bind and
structure-derived gating; the dead open: {{var}} ref check removed.
Directive tests reworked: min: dropped from the attrs fixture, the
open/gate supported-syntax suite re-added inverted, gate-ambiguity
fixtures incl. an all-channel-skills negative sweep.

Spec: docs/skill-engine-seam.md §8 step 5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:50:45 +03:00
gavrielc 498b80b344 refactor(skills): skills cleanup — strip gate/open:/min: attrs, fold URLs into prose
Per docs/skill-engine-seam.md §8 step 4 (+§3 table, §5.2 inventory):
- add-teams: drop open:https://portal.azure.com (URL already in the body),
  drop both gate attrs (natural-barrier policy reproduces them — §5.1 parity),
  min:20 → validate:^.{20,}$ with "(at least 20 characters)" folded into the
  question prose (§5.3 derives error text from it).
- add-telegram: drop open:, fold https://t.me/{{bot_username}} into the
  operator body — the URL offer (§5.2) preserves the open-in-browser behavior.
- add-discord: drop open:, fold the OAuth2 invite URL into the operator body.

All three skills re-linted clean; the teams run-channel parity test still
proves both barriers fire and the portal offer survives from prose alone.
Suite: 713 passed | 1 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:45:26 +03:00
gavrielc 76c1f13ca3 refactor(skills): core deletion of the old seam — Prompter/PromptOpts/StepReporter and the presentation attrs leave the engine
The engine now declares and emits only: inputs/resolveInput acquire,
onEvent renders. Removed the legacy Prompter (ask/tell/confirm/open),
PromptOpts, StepReporter, ApplyOptions.prompter/reporter, and engine
handling of open:/gate (driver policy now owns both), label: (labels
are heading-derived only), on-fail:+AgentTask.hint (hint is always the
prose — firstFailureHint reads it directly), and the min:/error:
plumbing (flags/normalize live on InputMeta). Engine tests for the
removed syntax deleted; semantic siblings kept (heading labels,
prose-hint default, operator-body {{var}} deferral — plus a new
no-event-fired assertion for the deferred block).

Spec: docs/skill-engine-seam.md §8 step 3, §9.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:42:00 +03:00
gavrielc 22b294e233 refactor(skills): shared policy module + wizard driver migration — presentation derived from document structure
scripts/skill-policy.ts: UI-free gatePolicy(md) (§5.1 rules 1-5: guard-
compatibility, operator-chain termination, prompt/end-of-doc barriers,
confirm flavor) + extractOfferUrl(text) (§5.2 placeholder exclusion; slack's
<your-public-host> is the normative negative), unit-tested against the full
parity table over the real in-tree channel skills.

setup/lib/skill-driver.ts: clackPrompter/spinnerReporter dissolve into
clackResolveInput (help-escape intact) + a default onEvent policy handler
(spinner branch + operator note → URL offer → natural-barrier confirm,
decline = proceed); new TTY-gated confirm/openUrl seams on RunSkillOptions;
an injected onEvent replaces the default policy entirely; reuseFromEnv
pre-filters offers through the prompt's normalize/validate/flags (§5.4);
promptValidator loses min/error and derives its message from the question
prose (§5.3). run-channel-skill renames the overrides and passes the new
seams through; providers/install drops its defer-all stub Prompter. No
in-tree caller passes prompter/reporter anymore; normalizeValue is exported
for the reuse pre-filter. Suite: 723 passed | 1 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:32:10 +03:00
gavrielc 79060c5407 refactor(skills): core seam (additive) + validate-at-bind — resolveInput/onEvent land beside the legacy prompter/reporter
Step 1 of the skill-engine seam plan (docs/skill-engine-seam.md §8.1):

- New core interface (§2): InputMeta + ApplyEvent types;
  ApplyOptions.resolveInput (replaces Prompter.ask) and
  ApplyOptions.onEvent (replaces StepReporter + Prompter.tell). The
  engine prefers the new seams when present and falls back to the
  legacy prompter/reporter otherwise (transitional — deleted in step 3).
- Awaited-event ordering (§2.3): every onEvent call is awaited before
  the next directive; a handler throw is the standard bounce path,
  including on operator events (blocked latch cascades).
- Validate-at-bind (§4): normalize-then-validate at the single bind
  point for inputs, resolveInput answers, and legacy prompter answers
  alike. A mismatch leaves the var unbound and records
  `<var>: invalid value (does not match validate:<re>)` in deferred —
  never the value (secrets can't leak), never a fallthrough from
  invalid inputs to resolveInput.
- stepLabel null semantics re-documented (§2): a step-cost/interactivity
  declaration, not spinner advice.
- §4 fixture sweep: Option-A slack inputs (signing_secret, owner_handle)
  + the userId assertion updated to validate-passing values; teams
  deferWire app_password to a 20+-char value. Assertion structure
  unchanged.

Suite: 699 passed | 1 skipped (was 680 | 1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:19:18 +03:00
gavrielc 0835089a51 Fix stale architecture, scheduling, provider-config, and overlay docs
Correct docs (and one code comment) that describe systems that no longer
exist in v2: mark docs/SPEC.md as the historical v1 spec; replace the
impossible "write messages_in (to self)" / stale list_tasks scheduling
model with the real messages_out system-action path in
agent-runner-details.md and architecture.md; drop the false
MAX_CONCURRENT_CONTAINERS cap claim; remove the per-group agent-runner-src
overlay from the live DB map (source is a shared read-only mount) and fix
the insertTask attribution to src/modules/scheduling/db.ts; correct the
container-skills count to 8; repoint the deleted-doc comment in
src/claude-md-compose.ts; and replace the AGENT_PROVIDER / hand-edit
container.json provider-config instructions in add-opencode and add-mnemon
with the real `ncl groups config update --provider` flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:18:08 +03:00
gavrielc c82f062d57 Remove the dead data/env/env secrets mirror from setup
Nothing has read data/env/env since commit 1a07869 removed the container
mount, yet setup still wrote the full .env (live tokens included) there.
Drop every writer and correct the stale comments/docs that described it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:11:09 +03:00
gavrielc 3906104960 docs: rewrite security docs to match the v2 perimeter
Rewrite docs/SECURITY.md against the real v2 codepaths: the actual
buildMounts mount table, the allowReadWrite allowlist schema the mount
validator enforces, and the true defaults (egress open, no CPU/mem
limits, additional mounts blocked until an allowlist exists). Replace the
deleted v1 perimeter (main/non-main groups, ephemeral containers, IPC
authorization, /dev/null .env shadow, data/sessions path) with a v2 Trust
Model built on user_roles and long-lived per-session containers. Drop the
dangling /add-golden-registry reference. Mark docs/docker-sandboxes.md and
docs/APPLE-CONTAINER-NETWORKING.md as v1-historical and update the
docs/README.md portal rows. Remove the Apple Container native-runtime
claims from README.md (no runtime seam exists; container-runtime.ts
hardcodes docker).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:10:46 +03:00
gavrielc ed9a3e330d Mount allowlist: honor the readOnly key and stop caching parse errors
Translate the per-root `readOnly` key (and tolerate the top-level
`nonMainReadOnly` key) that /manage-mounts and setup actually write, so
read-write grants are no longer silently forced read-only. Read+validate
the allowlist per call (mtime-keyed cache) instead of caching it — and its
parse errors — for the whole process lifetime. Add tests and fix the
skill docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:08:46 +03:00
gavrielc 102ce80fda Fix a2a in_reply_to stamp: publish through outbound.db, not module state
The active batch's inReplyTo lived in module-level state in current-batch.ts,
but the nanoclaw MCP server runs as a separate stdio subprocess from the poll
loop, so getCurrentInReplyTo() always read null there. The a2a reply stamp was
therefore dead — only the host peer-affinity fallback kept replies routing.

Publish the stamp through session_state in outbound.db (both processes already
open it): the poll loop writes it at batch start and clears it in the existing
finally; the MCP tools read it with an updated_at staleness guard so a stamp
left behind by a killed container isn't reused. Delete current-batch.ts and
reseed core.test.ts via the DB so it reflects the real process boundary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:07:49 +03:00
gavrielc 0626dcec92 Delete one-DB-era @deprecated shims and dead exports
These are leftovers from the April-2026 inbound/outbound session-DB
split, marked @deprecated "kept temporarily for test compatibility":
sessionDbPath, openSessionDb, writeSystemResponse (session-manager),
getStuckProcessingIds (session-db, superseded by getProcessingClaims),
getSessionDb (container connection + barrel re-export), and the dead
registerResponseHandler/onShutdown re-exports from index.ts. The only
real callers are two dev scripts, repointed to inboundDbPath /
getInboundDb.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:07:02 +03:00
gavrielc 912a89bf94 docs: skill-engine seam spec — declaration/acquisition split
Spec for splitting scripts/skill-apply.ts into a declare/emit core
(inputs, resolveInput, onEvent) and consumer-side acquisition/
presentation (wizard gate policy, URL offer, reuse confirm). Folds the
confirmed review findings: operator-chain gate rule fix (teams :158
double-confirm), Option-A fixture updates under validate-at-bind,
placeholder-URL exclusion, shared scripts/skill-policy.ts module,
driver confirm/openUrl seams, pipeline effect:step boundary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:06:33 +03:00
gavrielc b42329551d Re-provision a missing session folder so the documented reset works
The /debug skill tells operators to rm -rf a session folder to reset a
stuck session, but the sessions row survives. The next message then takes
the existing-session path and opens inbound.db in a directory that no
longer exists — better-sqlite3 throws and the message is dropped forever.

writeSessionMessage now calls the idempotent initSessionFolder when the
inbound.db path is missing, so the documented reset actually re-provisions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:05:55 +03:00
gavrielc 855f204d8a Clean up dead ncl CLI protocol vocabulary
Remove scaffold-era vocabulary in the ncl protocol that no code produces:
the duplicate `Access` union in crud.ts (now imported from registry.ts),
the unused `'hidden'` access level, and the never-emitted `'permission-denied'`
and `'not-found'` error codes. Also refresh three stale docstrings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:05:43 +03:00
gavrielc e3b2ffce36 Build agent images asynchronously instead of blocking the host
Replace the execSync docker build in buildAgentGroupImage with an awaited
promisified exec so the single-threaded host stays responsive during the
image build (up to 15 minutes) that a package-install approval or
`ncl groups restart --rebuild` triggers. Timeout, buffered stdio, and
non-zero-exit error propagation are preserved; both callers already await.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:04:56 +03:00
gavrielc 9c91c00c93 feat(setup): reference-prose floor — restore dropped reference prose + kill engine-narration leak
Add referenceProse(md) to slice the engine-ignored reference sections
(## Alternatives / ## Optional configuration / ## Troubleshooting) verbatim
from the raw markdown, surfaced read-only on ApplyResult.referenceProse;
run-channel-skill shows it beside agentTasks on a bounce (the degrade floor a
human would scroll to). Add lintReferenceFloor(md) — WARN-ONLY (never blocks)
when a credentialed/interactive skill (nc:prompt secret or nc:run effect:step)
ships no ## Troubleshooting.

Restore the dropped agent-facing reference prose in add-signal (Path-A
dedicated-number registration, the 5 daemon env keys + TCP security note, the
~7 dropped Troubleshooting entries — correctly dropping the wiring SQL and the
Java prereq), add-whatsapp (QR-in-browser alternative + headless detection,
ASSISTANT_HAS_OWN_NUMBER, Pairing-code-not-working), and add-teams (Teams-CLI/az
auto path, RSC receive-all manifest block, a new Troubleshooting section). Also
delete the engine-narrating leak paragraph at the top of add-signal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 00:12:06 +03:00
gavrielc f8214ed1d8 feat(setup): backGate — first-prompt back-to-channel-selection
Add backGate(label) to setup/lib/back-nav.ts: a brightSelect ["Yes,
connect <label>", "← Back to channel selection"] wrapped in ensureAnswer
(Esc/Ctrl-C → exit 0), returning the existing BACK_TO_CHANNEL_SELECTION
sentinel on back. runChannelSkill grows an opt-in offerBack flag that
runs the gate at the very top — before resolveAgentName/role, the skill
run, and the wire (covers Teams/deferWire too). All auto.ts channel
dispatch sites opt in. Kept opt-in so headless callers and existing
tests are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 00:01:51 +03:00
gavrielc 665b0dca77 feat(setup): help-escape handoff — a lone "?" at any prompt hands off to Claude
clackPrompter now wraps each prompt's validate with the tested
validateWithHelpEscape helper so a lone "?" short-circuits format checks,
then intercepts it post-prompt: it calls offerClaudeHandoff with the run's
channel + step context and the prompt question, then re-asks the same
prompt. Recursion is operator-bounded. Gated on a TTY, so a headless /
non-TTY run is a no-op (no interactive child spawned).

The channel + step label are plumbed through RunSkillOptions → runSkill →
clackPrompter, and run-channel-skill threads the channel it is wiring so
the handoff has the right context. An injected prompter is unaffected (the
injector owns its I/O).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 23:55:53 +03:00
gavrielc 46de9ae05a feat(setup): Prompter.open + nc:operator open:/gate — human barrier for manual UI steps
Add a trailing-optional Prompter.open?(url) (wired in clackPrompter to
setup/lib/browser.ts openUrl, headless-safe). Extend the nc:operator handler
with two pure-polish attrs: open:<url> deep-links the operator to the page the
steps describe (after rendering, {{vars}} substituted), and a bare gate flag
turns the block into a human BARRIER — a confirm the engine waits on before the
following side-effecting directives run. Both degrade invisibly: a stripped
fence leaves the same prose, and a prompter without open/confirm skips them.

Grammar/validator awareness in skill-directives (open: var-ref check + empty-URL
flag). Applied: add-teams gates the operator blocks before the manifest build +
restart (fixes the manifest-builds-before-the-Azure-app-exists hazard) and opens
the Azure portal; add-telegram opens the t.me bot deep-link; add-discord opens
the OAuth invite URL (pre-filled with the captured application_id).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 23:47:13 +03:00
gavrielc d7d104aa0b feat(setup): M3 PromptOpts — flags/min/normalize/error + helper-owned reuse
Widen Prompter.ask with a trailing-optional PromptOpts (flags/min/error/
normalize); existing async ask(name) fakes are untouched. clackPrompter builds
the regex with opts.flags, enforces min-length, surfaces opts.error, and sets
clearOnError on the secret prompt — via an exported, TTY-free promptValidator.
normalize (trim | rstrip-slash | lower) binds DETERMINISTICALLY in the engine,
so an inputs value and a typed answer land identically. The grammar + validator
learn flags/min/normalize/error/reuse on nc:prompt. reuseFromEnv gains a second
pass over reuse:<ENV_KEY> so a credential a helper script owns (imessage Photon
IMESSAGE_SERVER_URL/API_KEY, written by effect:external) regains the masked
reuse offer. Applied: add-teams app_password min:20, public_url
normalize:rstrip-slash; add-imessage server_url flags:i + reuse on the Photon
prompts. SKILLs stay lint-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 23:32:50 +03:00
gavrielc bd0962fd97 feat(setup): bounced-step prose as failure hint — M4 surface the diagnosis
When a channel skill doesn't fully apply, surface the prose beside the
directive that bounced as the operator's failure hint instead of a generic
"couldn't finish" message.

- skill-apply.ts: AgentTask gains hint? (default = trimmed prose; optional
  on-fail:<token> attr narrows it to the prose LINE diagnosing the failure,
  falling back to full prose when the token has no matching line so a stripped
  fence never leaks a bare token). New exported firstFailureHint(res) returns the
  first bounce's heading as a concise headline + its hint.
- run-channel-skill.ts: the !fullyApplied path threads firstFailureHint into
  fail(`<channel>-install`, headline, hint) so runner.ts dims the hint and
  forwards it to offerClaudeOnFailure. fail is now injectable for tests.
- claude-handoff.ts: repoint buildHandoffPrompt's stale setup/channels/<ch>.ts
  reference to the channel's SKILL.md (the bespoke files are deleted).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 23:24:12 +03:00
gavrielc d997d545cc feat(setup): multi-field JSON capture + validate-on-capture — derive Discord creds from one call
Extend the stdout-capture branch (effect:fetch/external) so a
`capture:a=.x,b=.owner.id` spec parses the command stdout as JSON and binds
each var to its jq-style dot-path — one API call resolves several values. A
bare `capture:var` (no =) still binds stdout as-is. effect:step's terminal-
block field capture is untouched (distinguished by effect). A `validate:<re>`
on a run capture shape-guards each bound value; a mismatch (or unparseable
JSON) throws → bounces to an agent (a command's output has no human to
re-prompt). The linter validates the run-capture regex too.

Rewrite add-discord Resolve to derive application_id + public_key +
owner_handle from one `GET /oauth2/applications/@me` call, removing the two
hand-pastes and the shape-only prompt validation. Resolved owner_handle /
platform_id stay byte-identical to the prior hand-entered flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 23:15:23 +03:00
gavrielc 8455eb0809 feat(setup): effect:check predicate gate — precondition checks that bounce/defer/gate
Add an `effect:check` branch to the run-case in the apply engine: it runs the
body as a shell predicate, mutating nothing (no journal, no capture). A non-zero
exit bounces to an agent and latches the run-health `blocked` flag, so a failed
precondition gates the dangerous side effects (restart, pairing/QR step, wire)
that follow; an unresolved {{var}} defers. Document the new effect in the
directive grammar.

Author the four lost safety preconditions as one-line nc:run effect:check blocks
beside their prose: add-imessage (non-Mac local hard-fail), add-whatsapp (empty
captured bot_phone guard), add-teams (Azure app_id set before manifest build),
add-signal (signal-cli presence probe before linking).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 23:04:48 +03:00
gavrielc 462619d287 feat(setup): apply-lifecycle reporter — per-step driver spinners
The apply engine now brackets each real mutation (applyOne) with a
StepReporter (stepStart/stepEnd, balanced even on the failure path) and
derives a human spinner caption per step via stepLabel: the nearest
heading for slow/effectful kinds (dep, run, branch-fetch copy), null for
instant kinds (local copy, env-set, json-merge, env-sync) and for the
self-rendering effect:step so a spinner never clobbers a QR/pairing card.
A label:<word> fence attr overrides; it's stripped with the fence, so it
degrades invisibly to prose.

runner.ts exposes its spinner machinery as startSpinner (runUnderSpinner
now delegates to it, behavior unchanged). skill-driver.ts builds a
TTY-gated spinnerReporter from it and passes it into applySkill by
default, so piped/CI/test runs stay silent and unchanged. run-channel-skill
threads an optional reporter override and keeps the connected_as line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 22:59:28 +03:00
gavrielc c249989b5a feat(setup): run-health gate — a bounce blocks later side effects
Once any directive bounces to an agent, the skill is no longer in a
known-good state, so the engine must not fire a dangerous side effect on
its own — a live restart, an interactive pairing/QR step, or a wire after
an upstream failure just wastes the operator's time (a doomed QR, a
restart that loads a bad credential). `blocked` latches on the first
bounce and converts any later effect:restart / effect:step / effect:wire
into its own bounce, so the agent finishes it from the prose after fixing
the failure. A deferred prompt (headless rebuild, no answer) never
bounces, so `blocked` stays false and a later restart remains runnable.

Reorder add-slack so auth.test / conversations.open validate the
credential ABOVE effect:restart — fast-fail a bad token while it's still
cheap, matching the old bespoke-flow position.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 22:48:34 +03:00
gavrielc 3b41171071 feat(setup): convert all 5 remaining channels to the SKILL.md flow; delete bespoke .ts
telegram, signal, whatsapp, imessage, teams now run through the directive
engine via runChannelSkill — the bespoke setup/channels/<ch>.ts flows
(2.6k lines) are deleted. Built on the when:/effect:step primitives:

- imessage — when:mode=local/remote guards + setup/channels/imessage-configure.sh
  (effect:external) for the mode-exclusive env writes. Wires normally.
- signal / telegram / whatsapp — effect:step against their trunk step scripts
  (signal-auth / pair-telegram / whatsapp-auth), which now render their own
  operator display (QR / code card) as plain stdout lines and emit the capture
  fields in the terminal block. Wire via the resolved owner_handle + platform_id.
- teams — platform_id is runtime-only, so the SKILL installs + manifests + ends
  with an nc:operator handoff; runChannelSkill gains a deferWire path that skips
  resolve+wire (mirrors the old finishWithHandoff).

auto.ts dispatches every channel through runChannelSkill; provider-contract
CREATION_FILES drops the deleted flows. Byte-parity for platform_id + user_id
was adversarially re-derived per channel and confirmed identical.

Verified: typecheck clean; full suite 620 passed | 1 skipped; all 5 skills
lint clean; the deferWire teams path is exercised end-to-end (fake exec).
NOT yet integration-tested: the effect:step live streaming handshake against
real Telegram/Signal/WhatsApp accounts (no credentials available) — wiring,
byte-parity, and the step-script tweaks are verified statically only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 21:07:26 +03:00
gavrielc 6f616824d6 feat(skills): when: guards + effect:step streaming run with multi-field capture
Two generic engine primitives, derived from how the bespoke setup flows
already work, so the remaining channels can move to SKILL.md:

- `when:<var>=<value>` — a guard on any directive, evaluated against an
  earlier prompt/capture var. Unmet (incl. unresolved) skips the directive;
  a guarded prompt is skipped, never deferred, so a fully-programmatic apply
  still completes across mutually-exclusive branches (e.g. imessage's
  local vs remote mode). Lint requires the guard var be defined earlier.

- `nc:run effect:step` + `capture:<var>=<FIELD>,…` — directive access to the
  existing spawnStep/StatusStream mechanism: runs a long-running,
  operator-interactive step (a pairing code, a QR device-link), tees its
  `=== NANOCLAW SETUP ===` blocks to the operator live, and binds the
  terminal block's named fields into vars. Degrades to an agent when no
  streaming exec is wired. New seam: ApplyOptions.execStream; the driver
  provides hostExecStream. One primitive covers telegram/whatsapp/signal.

Full suite 624 passed | 1 skipped; the six already-converted skills still
lint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 20:27:54 +03:00
gavrielc ab596d31d3 feat(setup): convert discord to the Option A SKILL.md flow; delete discord.ts
Ran a multi-agent workflow over the remaining chat-SDK channels
(discord/teams/imessage/telegram), each converting its SKILL.md to the Option A
resolve pattern with an adversarial verify that the captured platform_id is
byte-identical to what the old bespoke .ts passed init-first-agent.

Outcome:
- discord: CONVERTED + verified. Resolve prompts owner_handle (Discord user id,
  validate ^\d{17,20}$), captures connected_as (/users/@me) and platform_id
  (POST /users/@me/channels → "discord:@me:" + .id — byte-identical to the old
  discord:@me:<dmChannelId>). auto.ts now calls runChannelSkill('discord');
  setup/channels/discord.ts deleted. (Minor deviation: application_id/public_key
  are prompted-with-validate rather than auto-derived from /oauth2/applications/@me
  as the old flow did — values identical, one extra paste standalone.)
- imessage: resolve added (owner_handle = phone/Apple-ID handle; platform_id =
  the raw handle, byte-identical — native adapter, no prefix). NOT yet wired: its
  credentials branch on local vs remote mode, which a directive can't express
  deterministically (needs a shell-conditional run). Bespoke imessage.ts stays.
- teams: does NOT fit — the DM platform_id (teams:<b64 conv>:<b64 url>) only
  exists after the first inbound activity; no synchronous API resolves it.
- telegram: does NOT fit — resolve is an interactive pairing handshake
  (pair-telegram.ts), not a directive.

613 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:22:00 +03:00
gavrielc 564e605900 feat(setup): finish the polish — identity display + reuse-existing credentials
Closes the last two gaps to near-parity with the old bespoke setup.

Identity display ("Connected to slack as @bot in Acme"):
- add-slack's token check now captures the identity — auth.test piped through
  jq into `capture:connected_as` (still fails on a bad token). runChannelSkill
  shows res.vars.connected_as after the skill runs.

Reuse-existing credentials:
- Prompter gains `confirm`; clackPrompter implements it via p.confirm.
- runSkill gains a `reuse` option: before prompting, it maps each prompt var to
  its ENV_KEY from the skill's own `env-set` directives, checks `.env`, and for
  any already set offers "Found an existing SLACK_BOT_TOKEN (xoxb-…). Use it?".
  Accepted values become inputs, so the prompt is skipped. runChannelSkill opts
  in (reuse: true). Generic — works for any skill, no per-channel logic.

Tests: reuse accept/decline; identity capture flows through the adapter. 614 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:04:45 +03:00
gavrielc be0ca10630 feat(skills): nc:prompt validate:<re> — restore input format validation
Restores the old setup's credential format checks. A prompt can carry
`validate:<regex>`; the clack prompter re-asks until the answer matches (a bad
paste is caught at entry, not later). `inputs` bypass it (programmatic apply is
trusted). Lint rejects a non-compiling regex.

add-slack prompts now validate: bot_token `^xoxb-`, signing_secret
`^[a-fA-F0-9]{16,}$`, owner_handle `^U[A-Z0-9]{8,}$`.

612 pass. Remaining setup polish: "Connected as @bot" identity display and
reuse-existing-credential prompts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 18:43:49 +03:00
gavrielc 0a5ffde133 refactor(setup): Option A — shared wire stays in init-first-agent, no duplication
The connect/wire procedure (owner role + cli_scope=global, messaging-group,
wiring, /welcome) is identical across channels, so it should NOT be re-expressed
in every channel SKILL.md. It stays in scripts/init-first-agent.ts. A channel
SKILL.md owns only the channel-specific part: install + credentials + resolving
the wire inputs (owner_handle + platform_id).

- Engine: ApplyResult gains `vars` — the non-secret resolved values (prompt
  answers + capture outputs), so a caller can read what a skill produced.
  Secrets are never surfaced.
- add-slack SKILL.md: the ncl wire block is removed. "Connect yourself" becomes
  "Resolve your DM channel" — prompt owner_handle, validate auth.test, and
  capture platform_id from conversations.open as `slack:<channelId>`.
- runChannelSkill: re-adds the shared polish (agent name default "Nano", role
  owner|admin) and routes the wire through init-first-agent with the composed
  user-id and the resolved platform_id. No ncl wiring emitted by the adapter.

This restores the two regressions for free (cli_scope=global and the /welcome
system instruction come from init-first-agent) plus role choice and the
agent-name prompt — with the wire in exactly one place. Dry-run on the real
add-slack: res.vars = {owner_handle, platform_id}, secrets excluded. 611 pass.

Remaining polish (next): prompt format-validation, "Connected as @bot" identity
display, reuse-existing-credential prompts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 18:40:19 +03:00
gavrielc 4c4b2ecbbe feat(setup): wire /setup slack through the SKILL.md driver — delete slack.ts
auto.ts now applies add-slack via the thin driver instead of the bespoke
runSlackChannel. The whole connect+wire procedure lives in the SKILL.md; the
host side is just the driver plus a small adapter that ensures the wire-target
agent group.

- runChannelSkill(channel, displayName): ensures the `dm-with-<name>` agent
  group exists over ncl (idempotent — a 2nd DM channel reuses it), then runs
  `.claude/skills/add-<channel>` with that folder pre-supplied as agent_folder.
  Reports the outcome via fullyApplied. All I/O injectable for tests.
- `ncl groups create` → custom op: creates the agent group AND its container
  config (a working group needs it; generic create made only a bare row),
  idempotent on folder. The minimal wire-target slice of the deferred #3.
- skill-driver: resolveRemote is now an option (so the adapter/tests can inject).
- Deleted setup/channels/slack.ts — the first bespoke channel flow retired.
  provider-contract test now points at run-channel-skill.ts.

Only slack is migrated; the other channels keep run<Channel>Channel until their
SKILL.md wire sections are converted. Tests: groups-create scaffold+idempotent;
the adapter drives the real add-slack with injected I/O (ensures group → wires
to it). 610 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 17:32:29 +03:00
gavrielc f6c13eb6f3 feat(setup): thin generic skill driver — render + ask + run, no phasing
The whole connect+wire procedure now lives in the SKILL.md (operator
walkthroughs, prompts, restart, ncl wiring), so the host-side driver is just:
render nc:operator via clack notes, ask nc:prompt via clack text/password, run
the engine in document order. One function replaces the bespoke per-channel
setup/channels/<channel>.ts flows.

setup/lib/skill-driver.ts:
- clackPrompter(): ask (password for secrets / text; cancel defers) + tell (note).
- hostExec(root): returns stdout (so `run capture:<var>` binds it) and puts the
  project bin/ on PATH so a bare `ncl …` resolves to bin/ncl.
- runSkill(skillDir, opts): applySkill wired with the clack prompter, host exec,
  fork-aware channels remote; accepts `inputs` (fully programmatic) and
  `skipEffects` (e.g. ['restart'] when the caller restarts once). Returns the
  ApplyResult; fullyApplied(res) is the success check.
- CLI entry: `tsx setup/lib/skill-driver.ts <skillDir>` applies interactively.

Tests: drives prompt/operator/wire through an injected prompter; runs fully from
inputs; hostExec PATH-resolves a bare command and returns stdout. typecheck +
608 host tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 17:18:37 +03:00
gavrielc f629ff25fa feat(skills): effect:restart directive — phasing is just directive order
The install→restart→wire "phases" aren't a driver concern; they're directives in
document order. Restart is a run, placed between the install/credentials and the
wiring, and the engine already runs directives top-to-bottom — so no phase
orchestration is needed.

- effect:restart: a run whose body restarts the service and waits for the ncl
  socket so a following wiring directive doesn't race it. New helper
  setup/lib/restart.sh (platform-aware restart + best-effort socket wait; the
  engine execs each body line separately, so one self-contained script).
- ApplyOptions.skipEffects: run effects the CALLER owns and performs itself are
  skipped (not executed). A standalone /add-slack runs the restart; a headless
  rebuild or a setup that restarts once at the end passes ['restart'] and it's
  skipped. The declarative form of applyProviderSkill's isFlowOwnedCommand.

add-slack now carries the restart between Credentials and the wire, so the whole
flow is one self-contained ordered directive sequence. Dry-run: standalone runs
the restart, a flow caller skips it — both fully apply, both wire. 604 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 17:11:35 +03:00
gavrielc 3eca5588f9 feat(skills): inputs option — fully programmatic apply, no prompter needed
ApplyOptions gains `inputs` (prompt var → value), checked before the prompter.
Pass every answer and the whole skill runs through with no human interaction and
no prompter; the prompter (now optional) only fills prompts `inputs` doesn't
cover. A prompt with neither still defers (headless rebuild, unchanged).

- ApplyResult gains `operatorMessages`: every `nc:operator` body is collected
  (and still rendered live when an interactive prompter is present), so a
  programmatic caller relays/outputs the human steps instead of them blocking.
- `fullyApplied(res)` — the check a programmatic caller makes: nothing deferred
  for a missing input, nothing bounced to an agent.

Proof: the real add-slack skill applied from `inputs` alone (no prompter) →
fullyApplied true, 0 deferred / 0 agentTasks, the captured DM channel flows into
all 5 ncl commands, and both operator walkthroughs are collected for relay.

Tests: run-through-from-inputs, missing-input-defers, inputs-win-over-prompter.
604 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 16:56:32 +03:00
gavrielc e33fa970f5 feat(skills): nc:operator — delineate operator-facing content from agent prose
The SKILL.md is addressed to the coding agent (the interpreter), so its prose is
agent-facing by default. The parts meant for the HUMAN operator — clicking
through the Slack UI — were undifferentiated prose, so an agent couldn't tell
"relay this to the user" from "do this yourself", and the engine couldn't render
them as human steps.

nc:operator is the output twin of nc:prompt (prompt: ask the operator for input;
operator: give the operator instructions). Lead it with agent-facing prose like
"Tell the user:" so the agent relays it. The engine renders the body via the
Prompter's new optional tell() (a clack note in setup, a channel message when an
agent relays; absent in a headless rebuild → skipped). {{vars}} substitute in, so
a resolved value can be shown to the operator.

The content model is now: agent prose (unmarked, the degrade floor) / execution
directives / operator I/O (prompt in, operator out). add-slack's app-creation and
event-delivery walkthroughs are now nc:operator blocks; strip the fences and each
reads "Tell the user: <steps>" — natural, never narrating the engine.

Dry-run on the real skill: the two operator blocks route to tell() (the human),
the ncl commands to exec() (the agent) — cleanly separated. 600 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 16:37:12 +03:00
gavrielc 3c14310928 docs(skills): make the wire prose prose-primary again — oblivious to auto-apply
The wire sections had drifted into explaining the engine ("capture:dm_channel
binds it", "the journal keeps the placeholder", "expressed as directives",
"every ncl create is idempotent"). Strip the nc: fences and it read as docs of
the apply system, not a skill — so a degrade would drop an agent into debugging
a jq pipeline instead of reading "open your DM channel with the bot" and doing it.

Rewrote slack/resend/linear wire prose as natural instructions a person or agent
follows, with the directives as the precise encoding underneath. Domain facts
stay (the Slack API calls, the slack:<channel> / resend:<address> id formats,
team-routing); engine machinery is gone. Directives themselves unchanged — lint
clean, slack template guard + apply tests green (42).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 16:19:12 +03:00
gavrielc f387d371fd feat(skills): nc:run capture — output into a {{var}}; slack flow is pure SKILL.md
Output-capture is the twin of prompt: prompt binds human input into {{var}},
`run capture:<var>` binds a command's stdout. With it, the two steps that made
slack.ts "resist" being a SKILL.md — validate the token, resolve the DM channel —
are ordinary directives, so nothing in the bespoke flow is left unexpressible.

Engine:
- `exec` now returns the command's stdout; a `run capture:<var>` binds the
  trimmed stdout into the vars map (a single-command run; last wins). Composes
  with substitution: a captured value flows into later {{refs}}. The journal
  still records the original (placeholder) command — secrets/values don't leak.
- Linter treats `run capture:<var>` as defining the var, so a downstream
  {{var}} doesn't false-flag; doc + error message updated.

add-slack: the full procedure setup/channels/slack.ts ran is now in the SKILL.md
Wire section — prompt the member id + agent folder, `run effect:fetch` to
validate the bot token (auth.test) and resolve the DM channel
(conversations.open → `capture:dm_channel`), then `run effect:wire` to
users create → roles grant → messaging-groups create → wirings create →
messaging-groups send welcome. Dry-run against the real skill: the captured
`D0SLACK999` flows into every ncl command; zero deferred, zero agentTasks —
fully deterministic, the secret token never journaled.

Tests: capture binding + downstream substitution + lint-accepts-captured-var;
add-slack template guard updated to the complete install→credentials→wire flow
incl. the capture step. Full suite 597 passed | 1 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 16:09:47 +03:00
gavrielc 2f26d4ebc1 feat(skills): programmatic wiring via ncl — no nc:wire directive
Wiring a channel is just "collect input + call ncl", so it needs no new
directive — only that nc:run interpolates prompted {{vars}} and that the ncl
wiring verbs are idempotent + natural-key addressable.

Engine:
- nc:run now substitutes prompted {{vars}} into commands (same VAR_REF as
  env-set), so a run can `ncl … {{owner_email}}`. The journal records the
  ORIGINAL command (placeholders intact) so a substituted secret never lands in
  the journal/remove replay. An unanswered prompt → unresolved → deferred
  (degrade), never a crash. New documented effect:wire (no undo — the rows it
  creates are user runtime data, not reversed on remove).

ncl:
- crud gains optional `naturalKey`: generic create returns an existing row
  instead of a UNIQUE violation, making re-applied wiring idempotent (users).
- `messaging-groups create` → custom op: defaults the NOT NULL `instance` to
  channel_type and is idempotent on (channel_type, platform_id, instance).
- `messaging-groups send` → new op: injects an inbound (the cli admin-transport
  InboundEvent) to wake the wired agent — the welcome, as a plain ncl call.
- `wirings create` → custom op: resolves natural keys (channel_type+platform_id
  → messaging group; agent-group folder → agent group) and is idempotent on the
  pair. Direct --messaging-group-id / --agent-group-id still work.

Skills (proof, one per archetype):
- add-resend: owner-bootstrap wiring is now nc:prompt + nc:run effect:wire
  (users create → roles grant → messaging-groups create → wirings create →
  messaging-groups send welcome), replacing the init-first-agent shell block.
- add-linear: pre-wire is now nc:prompt + nc:run effect:wire, reusing the
  {{linear_team_key}} collected in Credentials — also retires the raw-SQL
  INSERTs (anti-pattern #4, and they referenced a stale column set).

Tests: nc:run substitution (interpolate / journal-original / defer-on-unanswered);
ncl verb idempotency + natural-key resolution via host-caller dispatch. Full
suite 594 passed | 1 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 15:46:12 +03:00
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
128 changed files with 8892 additions and 7011 deletions
+50 -73
View File
@@ -11,6 +11,8 @@ NanoClaw selects each group's agent backend from `container_configs.provider` (d
The provider runs `codex app-server` as a child process speaking JSON-RPC over stdio: native streaming, MCP tools, server-side conversation history (the continuation is a thread id, no on-disk transcript). Credentials are **vault-only**: OneCLI serves a sentinel `auth.json` stub into the container and swaps the real ChatGPT token or API key on the wire — no key in `.env`, nothing readable in the container.
The mechanical steps under **Install** carry `nc:` directive fences: an agent reads the prose and applies them, and a parser can apply them deterministically from the same document. Every directive is idempotent, so the whole skill is safe to re-run; anything a parser can't apply falls back to the prose beside it.
## Install
### Pre-flight
@@ -23,92 +25,69 @@ Check whether the payload is already wired (a prior apply, or a trunk that still
- `import './codex.js';` in `src/providers/index.ts`, `container/agent-runner/src/providers/index.ts`, and `setup/providers/index.ts`
- an `@openai/codex` entry in `container/cli-tools.json`
### Fetch and copy
### 1. Fetch and copy the payload
```bash
git fetch origin providers
Fetch the `providers` branch and copy the Codex payload into all three trees (additive — overwrite each file, never merge the branch). The host files are the provider contribution + AGENTS.md compose + their guards; the container files are the provider runtime (turn loop, JSON-RPC wrapper, per-exchange archiver) + their guards; the setup file is the picker entry + vault auth walk-through; `container/AGENTS.md` is the runtime-contract base the composed AGENTS.md embeds.
```nc:copy from-branch:providers
src/providers/codex.ts
src/providers/codex-agents-md.ts
src/providers/codex-registration.test.ts
src/providers/codex-host-contribution.test.ts
src/providers/codex-agents-md.test.ts
container/agent-runner/src/providers/codex.ts
container/agent-runner/src/providers/codex-app-server.ts
container/agent-runner/src/providers/exchange-archive.ts
container/agent-runner/src/providers/exchange-archive.test.ts
container/agent-runner/src/providers/codex-registration.test.ts
container/agent-runner/src/providers/codex.factory.test.ts
container/agent-runner/src/providers/codex.turns.test.ts
container/agent-runner/src/providers/codex-app-server.test.ts
container/agent-runner/src/providers/codex-cli-tools.test.ts
setup/providers/codex.ts
setup/providers/codex.test.ts
setup/providers/codex-registration.test.ts
container/AGENTS.md
```
Copy each file with `git show origin/providers:<path> > <path>` (additive — never merge the branch):
### 2. Wire the barrels
Host (`src/providers/`):
- `codex.ts` — provider contribution: per-group `.codex-shared` state dir, AGENTS.md compose, skill links
- `codex-agents-md.ts` — AGENTS.md composition (32KB Codex cap: degrades by dropping the largest instruction sections, never blocks a spawn)
- `codex-registration.test.ts` — barrel-driven host registration guard
- `codex-host-contribution.test.ts` — drives the real contribution against a real test DB (the "consumes core" leg)
- `codex-agents-md.test.ts` — cap-degradation behavior
Append the self-registration import to each of the three provider barrels (skipped if the line is already present). Each barrel-registration test imports its real barrel and asserts `codex` is registered — they go red the moment a barrel line is missing or drifts.
Container (`container/agent-runner/src/providers/`):
- `codex.ts` — the provider (turn loop, steering, memory scaffold + `onExchangeComplete` archiving)
- `codex-app-server.ts` — JSON-RPC child-process wrapper
- `exchange-archive.ts` — per-exchange markdown writer the `onExchangeComplete` hook uses (provider-owned, not runner code)
- `exchange-archive.test.ts` — writer behavior
- `codex-registration.test.ts` — barrel-driven container registration guard
- `codex.factory.test.ts`, `codex.turns.test.ts`, `codex-app-server.test.ts` — provider behavior
- `codex-cli-tools.test.ts` — structural guard for the Codex entry in `container/cli-tools.json`
Setup (`setup/providers/`):
- `codex.ts` — picker entry self-registration + the vault auth walk-through + install check
- `codex.test.ts` — install-check coverage
- `codex-registration.test.ts` — barrel-driven setup registration guard
Shared base (skip if present):
- `container/AGENTS.md` — the runtime-contract base the composed AGENTS.md embeds
### Wire the barrels
Append `import './codex.js';` to each of:
- `src/providers/index.ts`
- `container/agent-runner/src/providers/index.ts`
- `setup/providers/index.ts`
### CLI manifest
The agent's global Node CLIs install from `container/cli-tools.json` (a json-merge seam), not hand-edited Dockerfile layers. Add Codex by appending one entry — `@openai/codex` has no native postinstall, so no `onlyBuilt`:
```bash
node -e '
const fs = require("fs");
const file = "container/cli-tools.json";
const tools = JSON.parse(fs.readFileSync(file, "utf8"));
if (!tools.some((t) => t.name === "@openai/codex")) {
tools.push({ name: "@openai/codex", version: "0.138.0" });
const fmt = (t) => " { " + Object.entries(t).map(([k, v]) => JSON.stringify(k) + ": " + JSON.stringify(v)).join(", ") + " }";
fs.writeFileSync(file, "[\n" + tools.map(fmt).join(",\n") + "\n]\n");
}
'
```nc:append to:src/providers/index.ts
import './codex.js';
```
```nc:append to:container/agent-runner/src/providers/index.ts
import './codex.js';
```
```nc:append to:setup/providers/index.ts
import './codex.js';
```
The version (`0.138.0`) is the canonical pin — keep it in sync with `setup/add-codex.sh`. The Dockerfile already installs every manifest entry via pinned `pnpm install -g`; no Dockerfile edit is needed.
### 3. CLI manifest
### Build
The agent's global Node CLIs install from `container/cli-tools.json` (a json-merge seam), not hand-edited Dockerfile layers. Add Codex by appending one entry — idempotent on `name`, so a re-run is a no-op. `@openai/codex` has no native postinstall, so no `onlyBuilt`. The Dockerfile already installs every manifest entry via pinned `pnpm install -g`; no Dockerfile edit is needed.
```bash
```nc:json-merge into:container/cli-tools.json key:name
{ "name": "@openai/codex", "version": "0.138.0" }
```
The version (`0.138.0`) is the canonical pin — this SKILL.md is the source of truth.
### 4. Build
```nc:run effect:build
pnpm run build
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
./container/build.sh
```
### Restart the host
### 5. Validate
The image rebuild does not reload the **host**. Codex's host contribution
(`src/providers/codex.ts`) registers the `/home/node/.codex` bind mount + env
passthrough, and the running host only picks it up on restart. Skip this and the
first Codex turn fails with `EACCES` writing `/home/node/.codex/config.toml`
with no mount, Docker auto-creates the dir root-owned and the non-root container
user can't write to it.
```bash
# macOS (launchd)
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
# Linux (systemd)
systemctl --user restart nanoclaw
```
### Validate
```bash
```nc:run effect:test
pnpm vitest run src/providers/codex-registration.test.ts src/providers/codex-host-contribution.test.ts src/providers/codex-agents-md.test.ts setup/providers/
```
```nc:run effect:test
cd container/agent-runner && bun test src/providers/
```
@@ -116,9 +95,7 @@ The registration tests import only the real barrels — they go red if a barrel
## Authenticate
> **Run this in a separate, real terminal — it is interactive.** It prompts for ChatGPT-subscription vs OpenAI-API-key and then drives a browser/device login, so it needs a TTY to answer prompts.
```bash
```nc:run effect:external
pnpm exec tsx setup/index.ts --step provider-auth codex
```
@@ -1,39 +0,0 @@
// Structural guard for the Codex CLI install in container/cli-tools.json.
//
// @openai/codex is a CLI *binary* installed from the global-CLI manifest (a
// json-merge seam), not an importable package, so the barrel-driven
// registration tests cannot see it. This test reads the real cli-tools.json
// and asserts the @openai/codex entry is present and pinned to an exact
// version. It goes red if the manifest entry is dropped or unpins.
//
// Runs under bun (same suite as the container registration test):
// cd container/agent-runner && bun test src/providers/codex-cli-tools.test.ts
import { existsSync, readFileSync } from 'fs';
import path from 'path';
import { describe, it, expect } from 'bun:test';
// container/agent-runner/src/providers/ -> container/cli-tools.json
const MANIFEST = path.join(import.meta.dir, '..', '..', '..', 'cli-tools.json');
const manifestPresent = existsSync(MANIFEST);
// Read lazily — `describe.skipIf` still runs the body to register tests, so the
// read has to be guarded for the bare-branch (no manifest) case.
const tools: Array<{ name: string; version: string }> = manifestPresent
? JSON.parse(readFileSync(MANIFEST, 'utf8'))
: [];
const codex = tools.find((t) => t.name === '@openai/codex');
// cli-tools.json is a trunk file; on the bare providers branch it isn't present,
// so skip there. In an installed tree (trunk + this payload) it must carry the
// pinned @openai/codex entry.
describe.skipIf(!manifestPresent)('container/cli-tools.json codex CLI install', () => {
it('includes the @openai/codex entry', () => {
expect(codex).toBeDefined();
});
it('pins it to an exact semver (no latest, no ranges)', () => {
expect(codex?.version).toMatch(/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/);
});
});
-1
View File
@@ -89,7 +89,6 @@ DC_SMTP_SECURITY=2 # 2=STARTTLS (default), 1=SSL/TLS, 3=plain
Security settings are applied on every startup, so changing them in `.env` and restarting takes effect without wiping the account.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Optional settings
+111 -58
View File
@@ -5,98 +5,151 @@ description: Add Discord bot channel integration via Chat SDK.
# Add Discord Channel
Adds Discord bot support via the Chat SDK bridge.
Adds Discord bot support via the Chat SDK bridge. NanoClaw doesn't ship channels
in trunk — this skill copies the Discord adapter in from the `channels` branch.
## Install
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
reads the prose and applies them, and a parser can apply them deterministically
from the same document. Every directive is idempotent, so the whole skill is
safe to re-run; anything a parser can't apply falls back to the prose beside it.
NanoClaw doesn't ship channels in trunk. This skill copies the Discord adapter in from the `channels` branch.
## Apply
### Pre-flight (idempotent)
### 1. Copy the adapter and its registration test
Skip to **Credentials** if all of these are already in place:
Fetch the `channels` branch and copy the Discord adapter and its registration
test into `src/channels/` (overwrite — the branch is canonical):
- `src/channels/discord.ts` exists
- `src/channels/discord-registration.test.ts` exists
- `src/channels/index.ts` contains `import './discord.js';`
- `@chat-adapter/discord` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```nc:copy from-branch:channels
src/channels/discord.ts
src/channels/discord-registration.test.ts
```
### 2. Copy the adapter and its registration test
### 2. Register the adapter
```bash
git show origin/channels:src/channels/discord.ts > src/channels/discord.ts
git show origin/channels:src/channels/discord-registration.test.ts > src/channels/discord-registration.test.ts
```
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
```nc:append to:src/channels/index.ts
import './discord.js';
```
### 4. Install the adapter package (pinned)
### 3. Install the adapter package
```bash
pnpm install @chat-adapter/discord@4.29.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/discord@4.29.0
```
### 5. Build and validate
### 4. Build and validate
```bash
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
the dependency is installed. Then run the one integration test.
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/discord-registration.test.ts
```
Both must be clean before proceeding. `discord-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `discord`. It goes red if the `import './discord.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/discord` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
`discord-registration.test.ts` imports the real channel barrel and asserts the
registry contains `discord`. It goes red if the import line is deleted or drifts,
if the barrel fails to evaluate, or if `@chat-adapter/discord` isn't installed
(the import throws) — so it also covers the dependency from step 3. End-to-end
delivery against a real server is verified manually once the service runs.
## Credentials
### Create Discord Bot
Discord app setup is human and interactive — no parser can click through the
Discord Developer Portal. The adapter is installed and registered, but it can't
receive a message until the bot exists, has Message Content Intent, and shares a
server with you. Tell the user:
1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
2. Click **New Application** and give it a name (e.g., "NanoClaw Assistant")
3. From the **General Information** tab, copy the **Application ID** and **Public Key**
4. Go to the **Bot** tab and click **Add Bot** if needed
5. Copy the Bot Token (click **Reset Token** if you need a new one — you can only see it once)
6. Under **Privileged Gateway Intents**, enable **Message Content Intent**
7. Go to **OAuth2** > **URL Generator**:
- Scopes: select `bot`
- Bot Permissions: select `Send Messages`, `Read Message History`, `Add Reactions`, `Attach Files`, `Use Slash Commands`
8. Copy the generated URL and open it in your browser to invite the bot to your server
### Configure environment
All three values are required — the adapter will fail to start without `DISCORD_PUBLIC_KEY` and `DISCORD_APPLICATION_ID`.
Add to `.env`:
```bash
DISCORD_BOT_TOKEN=your-bot-token
DISCORD_APPLICATION_ID=your-application-id
DISCORD_PUBLIC_KEY=your-public-key
```nc:operator
Create the Discord bot:
1. Go to https://discord.com/developers/applications → New Application. Name it (e.g. "NanoClaw Assistant").
2. Bot tab → Add Bot if needed → Reset Token, then copy the Bot Token (it's shown only once).
3. Bot tab → Privileged Gateway Intents → enable Message Content Intent.
4. OAuth2 → URL Generator → Scopes: bot; Bot Permissions: Send Messages, Read Message History, Add Reactions, Attach Files, Use Slash Commands.
5. Open the generated URL and invite the bot to a server you're also in (a personal server is fine) — the bot can only DM you once you share a server.
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
Paste the Bot Token (it's shown only once). You don't paste the Application ID or
the Public Key by hand — the bot's own application record carries both, so a
single call derives them from the token:
```nc:prompt bot_token secret validate:^[A-Za-z0-9._-]{50,}$
Paste the Bot Token — Bot tab. Click `Reset Token` if you need a new one.
```
Read the application's own record. `GET /oauth2/applications/@me` returns the
Application ID (`id`), the Public Key (`verify_key`), and your own account as the
app's owner (`owner.id`) — so the App ID, the Public Key, and your Discord user ID
all come from this one call instead of being copied by hand. A bad token fails
here, before the restart, rather than silently later:
```nc:run capture:application_id=.id,public_key=.verify_key,owner_handle=.owner.id effect:fetch
curl -sf https://discord.com/api/v10/oauth2/applications/@me -H "Authorization: Bot {{bot_token}}"
```
Store the token and the two derived credentials — the adapter reads them from
`.env` and fails to start without `DISCORD_PUBLIC_KEY` and `DISCORD_APPLICATION_ID`
(set-if-absent, so a value you've already filled in is never overwritten):
```nc:env-set
DISCORD_BOT_TOKEN={{bot_token}}
DISCORD_APPLICATION_ID={{application_id}}
DISCORD_PUBLIC_KEY={{public_key}}
```
## Restart
Restart the service so it loads the Discord adapter and the credentials you just
stored, and wait for its CLI socket before resolving:
```nc:run effect:restart
bash setup/lib/restart.sh
```
## Invite the bot to a shared server
The bot can only DM you once it shares a server with you. If you didn't already
invite it via the OAuth2 URL Generator while setting up the app, do it now: add
the bot to a server you're also in (a personal server is fine). Tell the user:
```nc:operator
Open the invite link — https://discord.com/oauth2/authorize?client_id={{application_id}}&scope=bot&permissions=2147584064 — and add the bot to a server you're also in (a personal server works fine); the bot can only DM you once you share a server. If you already invited it while setting up the app, you can skip this.
```
## Resolve your DM channel
The agent talks to you in your direct-message channel with the bot. Your Discord
user ID was already derived as the application's owner (`owner_handle`), so all
that's left is to open the DM and read back its channel id.
Open the DM with `POST /users/@me/channels` and take the channel id it returns as
the conversation address `discord:@me:<channelId>` (if Discord refuses, the bot
doesn't share a server with you yet — invite it, then retry):
```nc:run capture:platform_id effect:fetch
curl -s -X POST https://discord.com/api/v10/users/@me/channels -H "Authorization: Bot {{bot_token}}" -H "Content-Type: application/json" -d '{"recipient_id":"{{owner_handle}}"}' | jq -er '"discord:@me:" + .id'
```
`owner_handle` and `platform_id` are what the owner-wiring step needs. The
greeting goes out over the DM channel, which works as soon as the bot shares a
server with you.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/manage-channels` to wire this channel to an agent group.
If you're in the middle of `/setup`, return to the setup flow now. Otherwise wire
this channel with `/init-first-agent` (or `/manage-channels`).
## Channel Info
- **type**: `discord`
- **terminology**: Discord has "servers" (also called "guilds") containing "channels." Text channels start with #. The bot can also receive direct messages.
- **platform-id-format**: `discord:@me:{dmChannelId}` for the owner DM (e.g. `discord:@me:1399...`), `discord:{guildId}:{channelId}` for server channels — both IDs required for channels.
- **how-to-find-id**: Enable Developer Mode in Discord (Settings > App Settings > Advanced > Developer Mode). Then right-click a server and select "Copy Server ID" for the guild ID, and right-click the text channel and select "Copy Channel ID." The platform ID format used in registration is `discord:{guildId}:{channelId}` — both IDs are required.
- **supports-threads**: yes
- **typical-use**: Interactive chat — server channels or direct messages
+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
+62 -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.29.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/gchat@4.29.0
```
### 5. Build and validate
### 4. Build and validate
```bash
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
the dependency is installed. Then run the one integration test.
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/gchat-registration.test.ts
```
Both must be clean before proceeding. `gchat-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `gchat`. It goes red if the `import './gchat.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/gchat` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Google Chat space is verified manually once the service is running — see Next Steps and the webhook setup above.
`gchat-registration.test.ts` imports the real channel barrel and asserts the
registry contains `gchat`. It goes red if the import line is deleted or drifts,
if the barrel fails to evaluate, or if `@chat-adapter/gchat` isn't installed (the
import throws) — so it also covers the dependency from step 3. End-to-end
delivery against a real Google Chat space is verified manually once the service
runs — see Credentials and Next Steps.
## Credentials
Google Cloud setup is human and interactive — these steps are prose, not
directives (no parser can click through the Google Cloud Console). A recipe
rebuild produces a compiling, registered adapter that cannot receive a message
until they're done.
> 1. Go to [Google Cloud Console](https://console.cloud.google.com)
> 2. Create or select a project
> 3. Enable the **Google Chat API**
@@ -73,21 +80,32 @@ End-to-end message delivery against a real Google Chat space is verified manuall
> - Grant the Chat Bot role
> - Create a JSON key and download it
### Configure environment
### Store the credentials
Add the service account JSON as a single-line string to `.env`:
Capture the service account JSON, then write it. `prompt` only *asks* and binds
the answer to a name; a separate directive consumes it — so the same prompt
could feed `ncl` or the OneCLI vault instead of `.env` by swapping only the
consumer. Here it goes to `.env` (set-if-absent — a value you've already filled
in is never overwritten) as a single-line string:
```bash
GCHAT_CREDENTIALS={"type":"service_account","project_id":"...","private_key":"...","client_email":"..."}
```nc:prompt gchat_credentials secret
Paste the service account JSON as a single line — the key file you downloaded, e.g. `{"type":"service_account","project_id":"...","private_key":"...","client_email":"..."}`.
```
```nc:env-set
GCHAT_CREDENTIALS={{gchat_credentials}}
```
### Webhook server
Sync to container: `mkdir -p data/env && cp .env data/env/env`
The Chat SDK bridge automatically starts a shared webhook server on port 3000
(`WEBHOOK_PORT` to change it), handling `/webhook/gchat`. This port must be
publicly reachable for Google Chat to deliver events — it's the HTTP endpoint
URL you set in the Connection settings above. Running locally, expose it with
ngrok (`ngrok http 3000`), a Cloudflare Tunnel, or a reverse proxy on a VPS.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/manage-channels` to wire this channel to an agent group.
If you're in the middle of `/setup`, return to the setup flow now. Otherwise run
`/manage-channels` to wire this channel to an agent group.
## Channel Info
@@ -97,3 +115,5 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group.
- **supports-threads**: yes
- **typical-use**: Interactive chat — team spaces or direct messages
- **default-isolation**: Same agent group for spaces where you're the primary user. Separate agent group for spaces with different teams or sensitive contexts.
</content>
</invoke>
+56 -42
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.29.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/github@4.29.0
```
### 5. Build and validate
### 4. Build and validate
```bash
The build guards the typed `createChatSdkBridge(...)` core call and proves the
dependency is installed (the adapter import throws if `@chat-adapter/github`
isn't present):
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/github-registration.test.ts
```
Both must be clean before proceeding. `github-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `github`. It goes red if the `import './github.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/github` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
`github-registration.test.ts` imports the real channel barrel and asserts the
registry contains `github`. It goes red if the import line is deleted or drifts,
if the barrel fails to evaluate, or if `@chat-adapter/github` isn't installed
(the import throws) — so it also covers the dependency from step 3.
End-to-end message delivery against a real GitHub repo is verified manually once the service is running — see Next Steps and the webhook setup above.
End-to-end message delivery against a real GitHub repo is verified manually once
the service is running — see Next Steps and the webhook setup below.
## Credentials
@@ -88,18 +92,28 @@ On each repo (logged in as the repo owner/admin):
### 3. Configure environment
Add to `.env`:
Capture the three values, then write them. `prompt` only *asks* and binds the
answer to a name; a separate directive consumes it — so the same prompts could
feed `ncl` or the OneCLI vault instead of `.env` by swapping only the consumer.
Here they go to `.env` (set-if-absent — a value you've already filled in is
never overwritten):
```bash
GITHUB_TOKEN=github_pat_...
GITHUB_WEBHOOK_SECRET=your-webhook-secret
GITHUB_BOT_USERNAME=your-bot-username
```nc:prompt github_token secret
Paste the Fine-grained Personal Access Token for the bot account — starts with `github_pat_`.
```
```nc:prompt webhook_secret secret
Paste the webhook secret you generated for the repo webhook(s).
```
```nc:prompt bot_username
Enter the bot account's GitHub username exactly (used for @-mention detection).
```
```nc:env-set
GITHUB_TOKEN={{github_token}}
GITHUB_WEBHOOK_SECRET={{webhook_secret}}
GITHUB_BOT_USERNAME={{bot_username}}
```
`GITHUB_BOT_USERNAME` must match the bot account's GitHub username exactly. This is used for @-mention detection — the agent responds when someone writes `@your-bot-username` in a PR or issue comment.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## Wiring
Ask the user: **Is this a private or public repo?**
+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__*');
});
});
+142 -56
View File
@@ -5,110 +5,196 @@ description: Add iMessage channel integration via Chat SDK. Local (macOS) or rem
# Add iMessage Channel
Adds iMessage support via the Chat SDK bridge. Two modes: local (macOS with Full Disk Access) or remote (Photon API).
Adds iMessage support via the Chat SDK bridge. Two modes: local (macOS with Full
Disk Access) or remote (Photon API). NanoClaw doesn't ship channels in trunk —
this skill copies the iMessage adapter in from the `channels` branch.
## Install
The mechanical steps under **Apply** carry `nc:` directive fences: an agent reads
the prose and applies them, and a parser can apply them deterministically from
the same document. Every directive is idempotent, so the whole skill is safe to
re-run; anything a parser can't apply falls back to the prose beside it.
NanoClaw doesn't ship channels in trunk. This skill copies the iMessage adapter in from the `channels` branch.
## Apply
### Pre-flight (idempotent)
### 1. Copy the adapter
Skip to **Credentials** if all of these are already in place:
Fetch the `channels` branch and copy the iMessage adapter into `src/channels/`
(overwrite — the branch is canonical):
- `src/channels/imessage.ts` exists
- `src/channels/imessage-registration.test.ts` exists
- `src/channels/index.ts` contains `import './imessage.js';`
- `chat-adapter-imessage` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```nc:copy from-branch:channels
src/channels/imessage.ts
src/channels/imessage-registration.test.ts
```
### 2. Copy the adapter and its registration test
### 2. Register the adapter
```bash
git show origin/channels:src/channels/imessage.ts > src/channels/imessage.ts
git show origin/channels:src/channels/imessage-registration.test.ts > src/channels/imessage-registration.test.ts
```
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
```nc:append to:src/channels/index.ts
import './imessage.js';
```
### 4. Install the adapter package (pinned)
### 3. Install the adapter package
```bash
pnpm install chat-adapter-imessage@0.1.1
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
chat-adapter-imessage@0.1.1
```
### 5. Build and validate
### 4. Build and validate
```bash
Build guards the typed `createChatSdkBridge(...)` core call and proves the
dependency is installed (the adapter's top-level `import` from
`chat-adapter-imessage` throws if it isn't):
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/imessage-registration.test.ts
```
Both must be clean before proceeding. `imessage-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `imessage`. It goes red if the `import './imessage.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `chat-adapter-imessage` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
`imessage-registration.test.ts` imports the real channel barrel and asserts the
registry contains `imessage` — it goes red if the import line is deleted or
drifts, if the barrel fails to evaluate, or if `chat-adapter-imessage` isn't
installed (the import throws), so it also covers the dependency from step 3.
End-to-end message delivery against a real iMessage account is verified manually once the service is running — see Next Steps.
End-to-end message delivery against a real iMessage account is verified manually
once the service is running — see Next Steps.
## Credentials
iMessage runs in one of two modes:
- **Local (macOS)** — the bot runs on this Mac and talks via the signed-in
iMessage account. Reading `chat.db` needs Full Disk Access granted to the
Node binary the host runs under.
- **Remote (Photon API)** — the bot talks to a separate Photon server that owns
an iMessage account on another Mac. Use this off macOS, or to keep this Mac's
chat history out of the loop.
Mode choice and the Full Disk Access / Photon walkthroughs are human and
interactive. Pick the mode first (local is the macOS default; remote is the only
option off macOS), then walk only that mode's setup — the other mode's steps are
skipped:
```nc:prompt mode validate:^(local|remote)$
How should iMessage run — `local` (this Mac, needs Full Disk Access) or `remote` (a Photon server)?
```
### Local Mode (macOS)
Requirements: macOS with Full Disk Access granted to the Node.js binary.
Requirements: macOS, with Full Disk Access granted to the Node binary. Without
it the adapter can't read `chat.db` and inbound messages never arrive.
The Node binary path is buried deep (e.g. `~/.nvm/versions/node/v22.x.x/bin/node`). To make it easy, open the folder in Finder so the user can drag the file into System Settings:
Local mode only works on a Mac — it reads this machine's iMessage `chat.db`
directly, and there is no such database off macOS. On any other OS, stop here and
use remote (Photon) mode instead; otherwise you'd write a local config that can
never receive a message:
```bash
open "$(dirname "$(which node)")"
```nc:run effect:check when:mode=local
[ "$(uname)" = Darwin ]
```
The Node binary path is buried deep (e.g. `~/.nvm/versions/node/v22.x.x/bin/node`),
so open its folder in Finder to make the drag-and-drop target obvious. Harmless
off a desktop (SSH/headless) — it just no-ops:
```nc:run effect:external when:mode=local
open "$(dirname "$(which node)")" 2>/dev/null || true
```
Then tell the user:
1. Open **System Settings** > **Privacy & Security** > **Full Disk Access**
2. Click **+**, then drag the `node` file from the Finder window that just opened
3. Toggle it on
```nc:operator when:mode=local
Grant Full Disk Access to Node so iMessage can read your chat history:
1. Open System Settings > Privacy & Security > Full Disk Access.
2. Click +, then drag the "node" file from the Finder window that just opened.
3. Toggle it on, then come back here.
```
Stop and wait for the user to confirm before continuing.
Stop and wait for the user to confirm Full Disk Access is granted before
continuing.
### Remote Mode (Photon API)
1. Set up a [Photon](https://photon.codes) account
2. Get your server URL and API key
Photon is a separate service that owns an iMessage account and exposes it over
HTTP; NanoClaw talks to it via its API. Tell the user:
```nc:operator when:mode=remote
Set up remote iMessage via Photon:
1. Create a Photon server: https://photon.codes
2. Copy the server URL and API key from your Photon dashboard.
```
Then collect the two values:
```nc:prompt server_url when:mode=remote validate:^https?:// flags:i reuse:IMESSAGE_SERVER_URL
Your Photon server URL — starts with http:// or https:// (e.g. https://photon.example.com).
```
```nc:prompt api_key secret when:mode=remote reuse:IMESSAGE_API_KEY
Your Photon API key — from the Photon dashboard.
```
### Configure environment
**Local mode** -- add to `.env`:
The two modes use different `.env` keys. Write only the keys for the chosen
mode, and strip the opposite mode's keys so a stale value can't confuse the
adapter's factory. The configure script owns this upsert-and-remove (a plain
set-if-absent env write can neither replace a stale value nor delete a key):
```bash
IMESSAGE_ENABLED=true
IMESSAGE_LOCAL=true
**Local mode** — writes `IMESSAGE_LOCAL=true` and `IMESSAGE_ENABLED=true`, and
removes `IMESSAGE_SERVER_URL` / `IMESSAGE_API_KEY` if present:
```nc:run effect:external when:mode=local
bash setup/channels/imessage-configure.sh local
```
**Remote mode** -- add to `.env`:
**Remote mode** — writes `IMESSAGE_LOCAL=false`, `IMESSAGE_SERVER_URL`, and
`IMESSAGE_API_KEY`, and removes `IMESSAGE_ENABLED` if present:
```bash
IMESSAGE_LOCAL=false
IMESSAGE_SERVER_URL=https://your-photon-server.com
IMESSAGE_API_KEY=your-api-key
```nc:run effect:external when:mode=remote
bash setup/channels/imessage-configure.sh remote "{{server_url}}" "{{api_key}}"
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## Restart
Restart the service so it loads the iMessage adapter and the credentials you
just stored, and wait for its CLI socket before wiring:
```nc:run effect:restart
bash setup/lib/restart.sh
```
## Resolve your iMessage handle
The agent greets you in the iMessage conversation tied to the phone number or
Apple ID email you message from — that handle is both your identity and the
conversation address. Resolve it so the owner-wiring step can target it.
```nc:prompt owner_handle validate:^(\+\d{8,15}|[^\s@]+@[^\s@]+\.[^\s@]+)$
The phone number or email you iMessage from — a +E.164 number (e.g. +14155551234) or an email / Apple ID (e.g. you@icloud.com).
```
iMessage is a native adapter: it sends the raw handle as the conversation
address, with no channel prefix — so the messaging-group platform id is that
handle as-is.
```nc:run capture:platform_id
echo "{{owner_handle}}"
```
`owner_handle` and `platform_id` are what the owner-wiring step needs. The
welcome iMessage goes out through the adapter once the service is running — in
local mode that needs Full Disk Access granted (above); in remote mode it goes
via your Photon server.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/manage-channels` to wire this channel to an agent group.
If you're in the middle of `/setup`, return to the setup flow now. Otherwise wire
this channel with `/init-first-agent` (or `/manage-channels`).
## Channel Info
+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
+98 -66
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.29.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/linear@4.29.0
```
### 5. Build and validate
### 4. Build and validate
```bash
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
the dependency is installed. Then run the one integration test.
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/linear-registration.test.ts
```
Both must be clean before proceeding. `linear-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `linear`. It goes red if the `import './linear.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/linear` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Linear workspace is verified manually once the service is running — see Wiring and Next Steps.
Both must be clean before proceeding. `linear-registration.test.ts` imports the
real channel barrel and asserts the registry contains `linear`. It goes red if
the `import './linear.js';` line is deleted or drifts, if the barrel fails to
evaluate, or if `@chat-adapter/linear` isn't installed (the import throws) — so
it also covers the dependency from step 3. End-to-end message delivery against a
real Linear workspace is verified manually once the service is running — see
Wiring and Next Steps.
## Credentials
Linear app and webhook setup is human and interactive — these steps are prose
(no parser can click through the Linear UI), except the final env write.
### 1. Set up a webhook
1. Go to **Linear Settings** > **API** > **Webhooks** > **New webhook**
@@ -86,48 +98,68 @@ End-to-end message delivery against a real Linear workspace is verified manually
Note: Linear webhook delivery may be delayed 1-5 minutes for new webhooks. This is normal.
### 2. Configure environment
### 2. Store the credentials
Add to `.env`:
Capture the values, then write them. `prompt` only *asks* and binds the answer
to a name; a separate directive consumes it. Here they go to `.env`
(set-if-absent — a value you've already filled in is never overwritten) and sync
to the container.
Use **either** the OAuth app credentials (recommended) **or** a Personal API key.
For the API-key path, paste `none` at the OAuth prompts and set `LINEAR_API_KEY`
in `.env` by hand (commented in the template below). `LINEAR_BOT_USERNAME` is the
display name for the bot, used for self-message detection when using a Personal
API Key. `LINEAR_TEAM_KEY` is the Linear team key (e.g. `ENG`, `NAN`) — find it
in Linear under Settings > Teams; all issues in this team route to one messaging
group.
```nc:prompt linear_client_id secret
Paste the OAuth Client ID — Linear Settings > API > OAuth Applications. Paste `none` if using a Personal API key instead.
```
```nc:prompt linear_client_secret secret
Paste the OAuth Client Secret. Paste `none` if using a Personal API key instead.
```
```nc:prompt linear_webhook_secret secret
Paste the webhook signing secret from the webhook you just created.
```
```nc:prompt linear_team_key
Enter the Linear team key (e.g. `ENG`, `NAN`) — Settings > Teams.
```
```nc:prompt linear_bot_username
Enter the bot display name (e.g. `NanoClaw Bot`).
```
```nc:env-set
LINEAR_CLIENT_ID={{linear_client_id}}
LINEAR_CLIENT_SECRET={{linear_client_secret}}
LINEAR_WEBHOOK_SECRET={{linear_webhook_secret}}
LINEAR_TEAM_KEY={{linear_team_key}}
LINEAR_BOT_USERNAME={{linear_bot_username}}
```
If you went the Personal API key route, add this line to `.env` instead of the
OAuth pair (agent posts as you, your own comments are filtered):
```bash
# OAuth app (recommended)
LINEAR_CLIENT_ID=your-client-id
LINEAR_CLIENT_SECRET=your-client-secret
# OR Personal API key (simpler, but agent posts as you)
# LINEAR_API_KEY=lin_api_...
LINEAR_WEBHOOK_SECRET=your-webhook-signing-secret
LINEAR_BOT_USERNAME=NanoClaw Bot
LINEAR_TEAM_KEY=ENG
LINEAR_API_KEY=lin_api_...
```
- `LINEAR_BOT_USERNAME`: display name for the bot (used for self-message detection when using a Personal API Key)
- `LINEAR_TEAM_KEY`: the Linear team key (e.g. `ENG`, `NAN`). Find it in Linear under Settings > Teams. All issues in this team route to one messaging group.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## Wiring
Ask the user: **Is this a private or public Linear workspace?**
Linear is team-routed: the assistant watches one team and answers *every* comment
on its issues (it can't be @-mentioned). Wire the team you set up to an agent —
pick which one should answer (`ncl groups list` shows their folders):
- **Private workspace** — use `unknown_sender_policy: 'public'`. Only workspace members can comment.
- **Public workspace** — use `unknown_sender_policy: 'strict'` and add trusted members (see GitHub skill for member registration example).
Run `/manage-channels` to wire the Linear channel to an agent group, or insert manually:
```sql
-- Create messaging group (one per team)
INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'linear', 'Engineering', 1, 'public', datetime('now'));
-- Wire to agent group
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
VALUES ('mga-linear-eng', 'mg-linear-eng', '<your-agent-group-id>', '', 'all', 'per-thread', 10, datetime('now'));
```nc:prompt agent_folder
Which agent should answer Linear comments? Enter its folder (run `ncl groups list`).
```
```nc:run effect:wire
ncl messaging-groups create --channel-type linear --platform-id linear:{{linear_team_key}} --is-group 1 --unknown-sender-policy public --name {{linear_team_key}}
ncl wirings create --channel-type linear --platform-id linear:{{linear_team_key}} --agent-group {{agent_folder}} --engage-mode pattern --engage-pattern . --session-mode per-thread
```
The `platform_id` must be `linear:<TEAM_KEY>` matching the `LINEAR_TEAM_KEY` env var. Use `per-thread` session mode so each issue comment thread gets its own agent session.
Each issue thread becomes its own conversation. There's no welcome — Linear has
no direct message, so the assistant greets people when it first answers a comment.
For a public-internet workspace, restrict it to people you've registered with
`--unknown-sender-policy strict` (see the GitHub skill for adding members).
## Next Steps
+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
+83 -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,47 @@ MATRIX_RECOVERY_KEY=your-recovery-key # Enable E2EE cross-signing
MATRIX_DEVICE_ID=NANOCLAW01 # Stable device ID across restarts
```
### Configure environment
### Store the credentials
Add the chosen env vars to `.env`, then sync:
Capture the values for the auth method you chose, then write them. `prompt` only
*asks* and binds the answer to a name; a separate directive consumes it — so the
same prompts could feed `ncl` or the OneCLI vault instead of `.env` by swapping
only the consumer. The homeserver URL, the bot's user ID, and a display name are
shared across both auth methods:
```bash
mkdir -p data/env && cp .env data/env/env
```nc:prompt base_url
Paste the homeserver base URL, e.g. `https://matrix.org`.
```
```nc:prompt user_id
Paste the bot's full Matrix user ID, e.g. `@andybot:matrix.org`.
```
```nc:prompt bot_username
Paste a display name for the bot, e.g. `Andy`.
```
```nc:env-set
MATRIX_BASE_URL={{base_url}}
MATRIX_USER_ID={{user_id}}
MATRIX_BOT_USERNAME={{bot_username}}
```
For **Option A** capture the bot login, for **Option B** capture the access
token — set only the block matching your chosen method:
```nc:prompt username
Option A only — the bot's login username (the localpart, e.g. `andybot`).
```
```nc:prompt password secret
Option A only — the bot account's password.
```
```nc:env-set
MATRIX_USERNAME={{username}}
MATRIX_PASSWORD={{password}}
```
```nc:prompt access_token secret
Option B only — the access token from Element Settings > Help & About, or from the login API.
```
```nc:env-set
MATRIX_ACCESS_TOKEN={{access_token}}
```
## Next Steps
+3 -3
View File
@@ -9,13 +9,13 @@ Installs [mnemon](https://github.com/mnemon-dev/mnemon) in the agent container i
## Provider Compatibility
mnemon hooks fire only under `--target claude-code`. Use this skill on agent groups that run the default Claude provider (`AGENT_PROVIDER=claude`). Confirm the provider before applying:
mnemon hooks fire only under `--target claude-code`. Use this skill on agent groups that run the default Claude provider. The provider is the materialized `provider` key in each group's `container.json` (absent or `claude` = default Claude provider). Confirm it before applying:
```bash
grep AGENT_PROVIDER .env groups/*/container.json 2>/dev/null
grep -H '"provider"' groups/*/container.json 2>/dev/null # no match, or "provider": "claude" = Claude
```
If a group uses a different provider (e.g. `AGENT_PROVIDER=opencode`), it spawns its own process and never invokes the `claude` CLI, so the hooks registered by `mnemon setup` do not run for that group.
If a group sets a different provider (e.g. `"provider": "opencode"`), it spawns its own process and never invokes the `claude` CLI, so the hooks registered by `mnemon setup` do not run for that group.
## Phase 1: Pre-flight
+14 -7
View File
@@ -1,11 +1,11 @@
---
name: add-opencode
description: Use OpenCode as an agent provider (AGENT_PROVIDER=opencode). OpenRouter, OpenAI, Google, DeepSeek, etc. via OpenCode config — not the Anthropic Agent SDK. Per-session and per-group via agent_provider; host passes OPENCODE_* and XDG mount when spawning containers.
description: Use OpenCode as an agent provider. OpenRouter, OpenAI, Google, DeepSeek, etc. via OpenCode config — not the Anthropic Agent SDK. Per group via `ncl groups config update --provider opencode`; host passes OPENCODE_* and XDG mount when spawning containers.
---
# OpenCode agent provider
NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected with **`AGENT_PROVIDER`** (`claude` | `opencode` | `mock`).
NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected per agent group by the **`provider`** key in that group's `container.json` (materialized from the `container_configs` table) — set it with `ncl groups config update --provider opencode`. Default is `claude`.
Trunk ships with only the `claude` provider baked in. This skill copies the OpenCode provider files in from the `providers` branch, wires them into the host and container barrels, installs dependencies, and rebuilds the image.
@@ -148,7 +148,7 @@ done
Set model/provider strings in the form OpenCode expects (often `provider/model-id`). **Put comments on their own lines** — a `#` inside a value is kept verbatim and breaks model IDs.
These variables are read **on the host** and passed into the container only when the effective provider is `opencode`. They do not switch the provider by themselves; the DB still needs `agent_provider` set (below).
These variables are read **on the host** and passed into the container only when the effective provider is `opencode`. They do not switch the provider by themselves; the group still needs `provider` set to `opencode` (see [Select the provider](#select-the-provider) below).
- `OPENCODE_PROVIDER` — OpenCode provider id, e.g. `openrouter`, `anthropic`, `deepseek`.
- `OPENCODE_MODEL` — full model id in `provider/model` form, e.g. `deepseek/deepseek-chat`.
@@ -215,7 +215,7 @@ OPENCODE_SMALL_MODEL=anthropic/claude-haiku-4-5-20251001
Zen's HTTP API (e.g. `POST …/zen/v1/messages`) expects the key in the **`x-api-key`** header. If OneCLI injects **`Authorization: Bearer …`** only, Zen often returns **401 / "Missing API key"** even though the gateway is working.
**Naming:** NanoClaw **`AGENT_PROVIDER=opencode`** (DB `agent_provider`) means "run the **OpenCode agent provider**." Separately, **`OPENCODE_PROVIDER=opencode`** in `.env` is OpenCode's **Zen provider id** inside the OpenCode config (see [Zen docs](https://opencode.ai/docs/zen/)).
**Naming:** NanoClaw's **`provider: opencode`** (the `container.json` key, set via `ncl groups config update --provider opencode`) means "run the **OpenCode agent provider**." Separately, **`OPENCODE_PROVIDER=opencode`** in `.env` is OpenCode's **Zen provider id** inside the OpenCode config (see [Zen docs](https://opencode.ai/docs/zen/)).
**Host `.env` (typical Zen shape):**
@@ -236,9 +236,16 @@ onecli secrets create --name "OpenCode Zen" --type generic \
--header-name "x-api-key" --value-format "{value}"
```
### Per group / per session
### Select the provider
Set `"provider": "opencode"` in the group's **`container.json`** (`groups/<folder>/container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session XDG mount, `OPENCODE_*` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json``'claude'`.
Per group, from the host:
```bash
ncl groups config update --id <group-id> --provider opencode
ncl groups restart --id <group-id>
```
`ncl groups config update --provider` writes the `provider` value into the `container_configs` table; the host materializes it into `groups/<folder>/container.json` at spawn time and the in-container runner reads `provider` from there (defaulting to `claude`). The restart picks up the change. Switching is an operator action — run it from the host. Memory does NOT carry over automatically between providers — run `/migrate-memory` to carry it across.
Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host; the runner merges them into the same `mcpServers` object passed to **both** Claude and OpenCode providers.
@@ -250,6 +257,6 @@ Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config
## Next Steps
The registration and Dockerfile guards in step 7 verify the wiring. To confirm an end-to-end round-trip, set `agent_provider = 'opencode'` (or `"provider": "opencode"` in the group's `container.json`) on a test group, register the matching provider key in OneCLI, and send a message. A clean exchange returns the model's reply with no `Unknown provider: opencode` error and no UUID/session warnings in the logs.
The registration and Dockerfile guards in step 7 verify the wiring. To confirm an end-to-end round-trip, switch a test group with `ncl groups config update --id <group-id> --provider opencode && ncl groups restart --id <group-id>`, register the matching provider key in OneCLI, and send a message. A clean exchange returns the model's reply with no `Unknown provider: opencode` error and no UUID/session warnings in the logs.
To remove this provider, see [REMOVE.md](REMOVE.md).
+97 -46
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,72 @@ Both must be clean before proceeding. `resend-registration.test.ts` is the one i
- Events: select **email.received**.
- Copy the signing secret.
### Configure environment
### Store the credentials
Add to `.env`:
Capture the secrets, then write them. `prompt` only *asks* and binds the answer
to a name; a separate directive consumes it — so the same prompts could feed
`ncl` or the OneCLI vault instead of `.env` by swapping only the consumer. Here
they go to `.env` (set-if-absent — a value you've already filled in is never
overwritten):
```bash
RESEND_API_KEY=re_...
RESEND_FROM_ADDRESS=bot@yourdomain.com
RESEND_FROM_NAME=NanoClaw
RESEND_WEBHOOK_SECRET=your-webhook-secret
```nc:prompt api_key secret
Paste the Resend API key — API Keys, starts with `re_`.
```
```nc:prompt webhook_secret secret
Paste the webhook signing secret — Webhooks, the value you copied above.
```
```nc:prompt from_address
The bot's sending email address on your verified domain (e.g. `bot@yourdomain.com`).
```
```nc:prompt from_name
The display name to send as (e.g. `NanoClaw`).
```
```nc:env-set
RESEND_API_KEY={{api_key}}
RESEND_FROM_ADDRESS={{from_address}}
RESEND_FROM_NAME={{from_name}}
RESEND_WEBHOOK_SECRET={{webhook_secret}}
```
## Connect yourself
Because email is direct-addressable, the bot can write to you first — so wire
your own address as the owner and have it email you a hello. Tell it your address
and which agent should answer your email (`ncl groups list` shows their folders):
```nc:prompt owner_email
Your email address — I'll wire you as owner and email you a hello.
```
```nc:prompt agent_folder
Which agent should answer your email? Enter its folder (run `ncl groups list`).
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
Register yourself as the owner, wire your address so the agent answers your email,
and send the hello:
```nc:run effect:wire
ncl users create --id resend:{{owner_email}} --kind resend --display-name Owner
ncl roles grant --user resend:{{owner_email}} --role owner
ncl messaging-groups create --channel-type resend --platform-id resend:{{owner_email}} --is-group 0
ncl wirings create --channel-type resend --platform-id resend:{{owner_email}} --agent-group {{agent_folder}} --engage-mode pattern --engage-pattern .
ncl messaging-groups send --channel-type resend --platform-id resend:{{owner_email}} --sender-id resend:{{owner_email}} --sender Owner --text "Hi — I'm your NanoClaw assistant, reachable by email now. Reply to this thread anytime."
```
The hello arrives as a fresh email thread; reply to keep the conversation going.
Your own address is the conversation key (`resend:<your-address>`).
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/manage-channels` to wire this channel to an agent group.
If you're in the middle of `/setup`, return to the setup flow now. (Answering an
*open* inbox — anyone who emails in, not just you — is a separate, not-yet-wired
case: email is plain-message, so the router never auto-creates a group for an
unknown sender; each correspondent's `resend:<their-address>` must be wired
explicitly.)
## Channel Info
- **type**: `resend`
- **terminology**: Resend handles email. Each email thread (identified by subject/In-Reply-To headers) is a separate conversation. The "from address" is the bot's identity.
- **how-to-find-id**: The platform ID is the from email address (e.g. `bot@yourdomain.com`). Each sender's email thread becomes its own conversation.
- **supports-threads**: yes (via email threading headers -- replies to the same thread stay together)
- **terminology**: Resend handles email. The bot has one fixed sending identity (`RESEND_FROM_ADDRESS`, e.g. `bot@yourdomain.com`); every *external correspondent* the bot emails with is a separate conversation, keyed by *their* address.
- **how-to-find-id**: The platform ID is the **correspondent's** email address, prefixed — `resend:<their-address>` (e.g. `resend:you@example.com`) — **not** the bot's from-address. The adapter derives it from the reply-to party (`channelIdFromThreadId` returns `resend:<address>`); each distinct email thread from that person (by root `Message-ID`) is a sub-conversation under it.
- **supports-threads**: no (the adapter sets `supportsThreads: false`; replies still thread via email headers, but the router does not treat threads as the primary conversation unit)
- **typical-use**: Async communication -- email conversations with longer response expectations
- **default-isolation**: Same agent group if you want your agent to handle email alongside other channels. Separate agent group if email contains sensitive correspondence that shouldn't be accessible from other channels.
+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>
```
+154 -188
View File
@@ -1,53 +1,168 @@
---
name: add-signal
description: Add Signal channel integration via signal-cli TCP daemon. Native adapter — no Chat SDK bridge.
description: Add Signal channel integration via signal-cli device-link. Native adapter — no Chat SDK bridge.
---
# Add Signal Channel
Adds Signal messaging support via a native adapter that speaks JSON-RPC to a [signal-cli](https://github.com/AsamK/signal-cli) TCP daemon. No Chat SDK bridge — only Node.js builtins (`node:net`, `node:child_process`, `node:fs`).
Adds Signal support via a native adapter that speaks JSON-RPC to a
[signal-cli](https://github.com/AsamK/signal-cli) daemon — no Chat SDK bridge,
only Node.js builtins. NanoClaw links to Signal as a *secondary device* on your
existing phone: no new number, no bot API. Your assistant sends and receives as
the number on the phone that scans the link.
Unlike Telegram or Discord, Signal has no bot API. NanoClaw registers as a full Signal account on a dedicated phone number (recommended) or links as a secondary device on your existing number.
## Apply
## Prerequisites
### 1. Install signal-cli
### Java
NanoClaw talks to Signal through signal-cli, which has no bot API of its own.
Install it if it isn't on PATH yet — Homebrew on macOS, the native release binary
on Linux (neither needs Java). If it's already installed this is a no-op:
signal-cli requires Java 17+:
```bash
java -version
```nc:run effect:external
command -v signal-cli >/dev/null 2>&1 || bash setup/install-signal-cli.sh
```
If missing:
- **macOS:** `brew install --cask temurin@17`
- **Debian/Ubuntu:** `sudo apt-get install -y default-jre`
- **RHEL/Fedora:** `sudo dnf install -y java-17-openjdk`
### 2. Copy the adapter and its registration test
Java 1725 all work.
Fetch the `channels` branch and copy the Signal adapter and its registration test
into `src/channels/` (overwrite — the branch is canonical):
### signal-cli
- **macOS:** `brew install signal-cli`
- **Linux:** download the native binary from [GitHub releases](https://github.com/AsamK/signal-cli/releases):
```bash
SIGNAL_CLI_VERSION=$(curl -fsSL https://api.github.com/repos/AsamK/signal-cli/releases/latest | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'][1:])")
curl -fsSL "https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VERSION}/signal-cli-${SIGNAL_CLI_VERSION}-Linux-native.tar.gz" \
| tar -xz -C ~/.local
ln -sf ~/.local/signal-cli ~/.local/bin/signal-cli
signal-cli --version
```nc:copy from-branch:channels
src/channels/signal.ts
src/channels/signal-registration.test.ts
```
> The Linux native tarball extracts a single binary directly to `~/.local/signal-cli` (not into a subdirectory). The symlink above puts it on PATH.
### 3. Register the adapter
## Registration
Append the self-registration import to the channel barrel (skipped if the line is
already present). This one line is the skill's only reach-in into core:
Two paths. The new-number path is recommended and battle-tested.
```nc:append to:src/channels/index.ts
import './signal.js';
```
### Path A: Register a new number (recommended)
### 4. Install the QR-rendering dependency
Use a dedicated SIM or VoIP number. NanoClaw owns it entirely.
The device-link step renders the linking URL as a terminal QR via `qrcode`.
Pinned to exact versions — the supply-chain policy rejects ranges and `latest`:
```nc:dep
qrcode@1.5.4
@types/qrcode@1.5.6
```
The adapter itself consumes only Node.js builtins, so there is no adapter package
to install — `qrcode` is purely for rendering the link during setup.
### 5. Build and validate
Build first: it guards the adapter's typed core-API consumption. Then run the one
integration test.
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/signal-registration.test.ts
```
`signal-registration.test.ts` imports the real channel barrel and asserts the
registry contains `signal`. It goes red if the `import './signal.js';` line is
deleted or drifts, or if the barrel fails to evaluate — so the channel genuinely
would not register. The adapter has no npm dependency to guard; its typed
core-API consumption is covered by the build. End-to-end delivery against a real
Signal account is verified manually once the service runs.
## Link your Signal account
This is the whole credential step. signal-cli opens a device-link handshake,
prints a `sgnl://linkdevice…` URL, and renders it as a scannable QR. You scan it
once from the phone that already runs Signal; that phone's number becomes the
account NanoClaw sends and receives as — no number is registered.
The device-link runs signal-cli, so it must be reachable first — on `PATH`, or at
`$SIGNAL_CLI_PATH`. If step 1's install didn't land, the link step has nothing to
drive; confirm it's present before linking (re-run step 1 if this fails):
```nc:run effect:check
command -v signal-cli >/dev/null 2>&1 || [ -x "$SIGNAL_CLI_PATH" ]
```
Tell the user:
```nc:operator
Link NanoClaw to your Signal account:
1. On the phone that runs Signal, open Signal → Settings → Linked Devices → Link New Device.
2. Scan the QR code shown below — or open the `sgnl://linkdevice…` link printed under it on that phone.
3. Wait for confirmation. The linking URL expires after ~3 minutes; re-run this step for a fresh one.
```
Run the device-link. It blocks until you scan, then reports the linked phone
number back as the account — that number is both your owner handle and the
conversation address the wiring step needs:
```nc:run effect:step capture:platform_id=ACCOUNT,owner_handle=ACCOUNT
pnpm exec tsx setup/index.ts --step signal-auth
```
`owner_handle` and `platform_id` both come back as the bare phone number (e.g.
`+15551234567`). Your assistant reaches you through Signal's Note to Self, so the
owner conversation is addressed by your own number — not a per-contact UUID.
## Persist the account
Store the linked number so the adapter binds the right account on start, then sync
it into the container env:
```nc:env-set
SIGNAL_ACCOUNT={{platform_id}}
```
## Restart
Restart the service so it loads the Signal adapter and binds the account you just
linked, and wait for its CLI socket before wiring:
```nc:run effect:restart
bash setup/lib/restart.sh
```
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now. Otherwise wire
this channel with `/init-first-agent` (or `/manage-channels`).
## Channel Info
- **type**: `signal`
- **terminology**: Signal has "chats" (1:1 DMs) and "groups." The owner reaches their own assistant through Note to Self.
- **platform-id-format**:
- Owner DM (Note to Self): the bare phone number `+<number>` (e.g. `+15551234567`) — your own messages route back as inbound with `isFromMe`, addressed by your number.
- Third-party DM: `signal:{UUID}` — the sender's Signal ACI, **not** their phone number.
- Group: `signal:{base64GroupId}` — base64-encoded GroupV2 ID.
- **how-to-find-id**: The owner number comes back from the device-link step above. For third parties or groups, send a message to the bot, then query `messaging_groups`.
- **supports-threads**: no
- **typical-use**: Personal assistant via Signal DMs or small group chats
- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode.
### Features
- Markdown formatting — `**bold**`, `*italic*` / `_italic_`, `` `code` ``, ` ```code fence``` `, `~~strike~~`, `||spoiler||` (converted to Signal's offset-based text styles).
- Quoted replies — `replyTo*` fields populated from Signal quotes.
- Typing indicators — DMs only (Signal doesn't support group typing).
- Note to Self — messages you send to your own account from another device route to the agent as inbound with `isFromMe: true`.
- Voice attachments — detected but not transcribed by default; the agent receives a `[Voice Message]` placeholder. Run `/add-voice-transcription` for local transcription.
Not supported yet: outbound file attachments (logged and dropped), edit/delete messages, reactions.
## Alternatives
### Register a dedicated number instead of linking
The device-link above joins Signal as a *secondary device* on an existing number.
If you'd rather give the assistant its own number, register a dedicated SIM or
VoIP number that NanoClaw owns entirely. This path takes a captcha, an SMS (or
voice) verification, and an optional profile name.
> **VoIP numbers:** Signal requires SMS verification before voice. Some VoIP providers are blocked even for voice calls. If registration fails with an auth error, try a different provider or a physical SIM.
@@ -107,69 +222,12 @@ signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName"
systemctl --user start $(systemd_unit)
```
### Path B: Link as secondary device
Once registered, set `SIGNAL_ACCOUNT` to this number (as under **Persist the account** above) and restart the service.
Joins an existing Signal account as a secondary device. Simpler, but NanoClaw shares your personal number.
## Optional configuration
```bash
signal-cli -a +1YOURNUMBER link --name "NanoClaw"
```
This prints a `tsdevice:` URI. Scan it as a QR code on your phone: **Settings → Linked Devices → Link New Device**. QR codes expire in ~30 seconds — re-run if it expires.
## Install
### Pre-flight (idempotent)
Skip to **Credentials** if all of these are already in place:
- `src/channels/signal.ts` exists
- `src/channels/signal.test.ts` exists
- `src/channels/signal-registration.test.ts` exists
- `src/channels/index.ts` contains `import './signal.js';`
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```
### 2. Copy the adapter and tests
```bash
git show origin/channels:src/channels/signal.ts > src/channels/signal.ts
git show origin/channels:src/channels/signal.test.ts > src/channels/signal.test.ts
git show origin/channels:src/channels/signal-registration.test.ts > src/channels/signal-registration.test.ts
```
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
import './signal.js';
```
### 4. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/signal-registration.test.ts
```
Both must be clean before proceeding. `signal-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `signal`. It goes red if the `import './signal.js';` line is deleted or drifts, or if the barrel fails to evaluate (so the channel genuinely would not register). The adapter consumes only Node.js builtins, so there is no npm dependency to guard for this channel. The adapter's typed core-API consumption is guarded by `pnpm run build`.
## Credentials
Add to `.env`:
```bash
SIGNAL_ACCOUNT=+1YOURNUMBER
```
### Optional settings
These `.env` keys tune how NanoClaw talks to the signal-cli daemon. All are
optional — the defaults work for the device-link flow above.
```bash
# TCP daemon host and port (default: 127.0.0.1:7583)
@@ -189,94 +247,6 @@ SIGNAL_DATA_DIR=~/.local/share/signal-cli
**Security note:** keep the TCP host on `127.0.0.1`. The daemon has no auth — binding it to a public interface would expose your full Signal account to the network.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Restart
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
# macOS
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
# Linux
systemctl --user restart $(systemd_unit)
```
## Wiring
### DMs
After the service starts, send any message to the Signal number from your personal Signal app. The router auto-creates a `messaging_groups` row. Then:
```bash
pnpm exec tsx scripts/q.ts data/v2.db \
"SELECT id, platform_id FROM messaging_groups WHERE channel_type='signal' ORDER BY created_at DESC LIMIT 5"
```
Pass the `id` to `/init-first-agent` or `/manage-channels` to wire it to an agent group.
### Groups
Add the Signal number to a group from your phone, send any message, then wire the resulting row the same way. For isolated per-group sessions:
```bash
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
pnpm exec tsx scripts/q.ts data/v2.db "
INSERT OR IGNORE INTO messaging_group_agents
(id, messaging_group_id, agent_group_id, session_mode, priority, created_at)
VALUES
('mga-'||hex(randomblob(8)), 'mg-GROUPID', 'ag-AGENTID', 'isolated', 0, '$NOW');
"
```
### Grant user access
New Signal users (including the owner's Signal identity) are silently dropped with `not_member` until granted access. After the user's first message appears in `messaging_groups`:
```bash
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
pnpm exec tsx scripts/q.ts data/v2.db "
INSERT OR REPLACE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at)
VALUES ('signal:UUID', 'owner', NULL, 'system', '$NOW');
INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at)
VALUES ('signal:UUID', 'ag-AGENTID', 'system', '$NOW');
"
```
Find the UUID from `messaging_groups.platform_id` or the `users` table.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/init-first-agent` to create an agent and wire it to your Signal DM, or `/manage-channels` to wire this channel to an existing agent group.
## Channel Info
- **type**: `signal`
- **terminology**: Signal has "chats" (1:1 DMs) and "groups"
- **supports-threads**: no
- **platform-id-format**:
- DM: `signal:{UUID}` — sender's Signal UUID (ACI), **not** their phone number
- Group: `signal:{base64GroupId}` — base64-encoded GroupV2 ID
- **how-to-find-id**: Send a message to the bot, then query `messaging_groups` as shown above
- **typical-use**: Personal assistant via Signal DMs or small group chats
- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode
### Features
- Markdown formatting — `**bold**`, `*italic*` / `_italic_`, `` `code` ``, ` ```code fence``` `, `~~strike~~`, `||spoiler||` (converted to Signal's offset-based text styles)
- Quoted replies — `replyTo*` fields populated from Signal quotes
- Typing indicators — DMs only (Signal doesn't support group typing)
- Echo suppression — outbound messages matched on `(platformId, text)` within a 10 s TTL to avoid syncMessage loops
- Note to Self — messages you send to your own account from another device route to the agent as inbound with `isFromMe: true`
- Voice attachments — detected but not transcribed by default; the agent receives `[Voice Message]` placeholder text. Run `/add-voice-transcription` for local transcription via parakeet-mlx
Not supported yet: outbound file attachments (logged and dropped), edit/delete messages, reactions.
## Troubleshooting
### Daemon not reachable
@@ -287,9 +257,9 @@ grep "Signal" logs/nanoclaw.log | tail
If you see `Signal daemon failed to start. Is signal-cli installed and your account linked?`:
- Confirm `signal-cli` is on PATH (or set `SIGNAL_CLI_PATH`)
- Confirm the account is linked: `signal-cli -a +1YOURNUMBER listIdentities` should succeed without prompting
- Confirm the account is linked: `signal-cli -a +YOURNUMBER listIdentities` should succeed without prompting
If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DAEMON=false`, start the daemon yourself: `signal-cli -a +1YOURNUMBER daemon --tcp 127.0.0.1:7583`.
If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DAEMON=false`, start the daemon yourself: `signal-cli -a +YOURNUMBER daemon --tcp 127.0.0.1:7583`.
### Bot not responding
@@ -308,7 +278,7 @@ If you see `Signal channel lost TCP connection to signal-cli daemon` in the logs
### Messages dropped with `not_member`
The Signal user hasn't been granted membership. See "Grant user access" above. This affects every new Signal user, including the owner's Signal identity — which is a separate user record from their identity on other channels even if it's the same person.
The Signal user hasn't been granted membership. New Signal senders — including the owner's Signal identity — are gated until granted access. `/init-first-agent` grants the owner automatically; for other users, wire access with `/manage-channels` after their first message appears in `messaging_groups`. This affects every new Signal user, since their Signal identity is a separate user record from their identity on other channels even if it's the same person.
### Captcha required
@@ -326,10 +296,6 @@ signal-cli holds an exclusive lock on its data directory while the daemon is run
Modern Signal groups use GroupV2. The adapter must extract the group ID from `envelope?.dataMessage?.groupV2?.id` — not `groupInfo?.groupId`, which is GroupV1/legacy. If group messages are routing as DMs, check `src/channels/signal.ts` and confirm the groupId extraction falls through to `groupV2.id`.
### Java not found
### QR / linking URL expired
Install Java 17+ — see the Prerequisites section above.
### QR code expired (Path B)
QR codes expire in ~30 seconds. Re-run the link command to generate a new one.
The `sgnl://linkdevice…` URL (and the Path A registration captcha) expire after a few minutes. Re-run the device-link step to get a fresh QR.
+141 -76
View File
@@ -5,114 +5,179 @@ description: Add Slack channel integration via Chat SDK.
# Add Slack Channel
Adds Slack support via the Chat SDK bridge.
Adds Slack support via the Chat SDK bridge. NanoClaw doesn't ship channels in
trunk — this skill copies the Slack adapter in from the `channels` branch.
## Install
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
reads the prose and applies them, and a parser can apply them deterministically
from the same document. Every directive is idempotent, so the whole skill is
safe to re-run; anything a parser can't apply falls back to the prose beside it.
NanoClaw doesn't ship channels in trunk. This skill copies the Slack adapter in from the `channels` branch.
## Apply
### Pre-flight (idempotent)
### 1. Copy the adapter and its registration test
Skip to **Credentials** if all of these are already in place:
Fetch the `channels` branch and copy the Slack adapter and its registration test
into `src/channels/` (overwrite — the branch is canonical):
- `src/channels/slack.ts` exists
- `src/channels/slack-registration.test.ts` exists
- `src/channels/index.ts` contains `import './slack.js';`
- `@chat-adapter/slack` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```nc:copy from-branch:channels
src/channels/slack.ts
src/channels/slack-registration.test.ts
```
### 2. Copy the adapter and its registration test
### 2. Register the adapter
```bash
git show origin/channels:src/channels/slack.ts > src/channels/slack.ts
git show origin/channels:src/channels/slack-registration.test.ts > src/channels/slack-registration.test.ts
```
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
```nc:append to:src/channels/index.ts
import './slack.js';
```
### 4. Install the adapter package (pinned)
### 3. Install the adapter package
```bash
pnpm install @chat-adapter/slack@4.29.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/slack@4.29.0
```
### 5. Build and validate
### 4. Build and validate
```bash
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
the dependency is installed. Then run the one integration test.
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/slack-registration.test.ts
```
Both must be clean before proceeding. `slack-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `slack`. It goes red if the `import './slack.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/slack` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Slack workspace is verified manually once the service is running — see Next Steps and the webhook setup above.
`slack-registration.test.ts` imports the real channel barrel and asserts the
registry contains `slack`. It goes red if the import line is deleted or drifts,
if the barrel fails to evaluate, or if `@chat-adapter/slack` isn't installed (the
import throws) — so it also covers the dependency from step 3. End-to-end
delivery against a real workspace is verified manually once the service runs.
## Credentials
### Create Slack App
Slack can deliver events two ways. Socket Mode holds an outbound WebSocket open
— no public URL, so it works on a laptop or behind NAT — and is the right
default. Webhook delivery needs a public HTTPS Request URL but avoids the
long-lived socket. The adapter picks Socket Mode automatically whenever
`SLACK_APP_TOKEN` is set; otherwise it serves the webhook.
1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** > **From scratch**
2. Name it (e.g., "NanoClaw") and select your workspace
3. Go to **OAuth & Permissions** and add Bot Token Scopes:
- `chat:write`, `im:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`, `files:read`, `files:write`
4. Click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`)
5. Go to **Basic Information** and copy the **Signing Secret**
### Enable DMs
6. Go to **App Home** and enable the **Messages Tab**
7. Check **"Allow users to send Slash commands and messages from the messages tab"**
### Event Subscriptions
8. Go to **Event Subscriptions** and toggle **Enable Events**
9. Set the **Request URL** to `https://your-domain/webhook/slack` — Slack will send a verification challenge; it must pass before you can save
10. Under **Subscribe to bot events**, add:
- `message.channels`, `message.groups`, `message.im`, `app_mention`
11. Click **Save Changes**
### Interactivity
12. Go to **Interactivity & Shortcuts** and toggle **Interactivity** on
13. Set the **Request URL** to the same `https://your-domain/webhook/slack`
14. Click **Save Changes**
15. Slack will show a banner asking you to **reinstall the app** — click it to apply the new settings
### Configure environment
Add to `.env`:
```bash
SLACK_BOT_TOKEN=xoxb-your-bot-token
SLACK_SIGNING_SECRET=your-signing-secret
```nc:prompt connection validate:^(socket|webhook)$
How should Slack deliver events? `socket` (Socket Mode — no public URL, recommended for local or behind-NAT installs) or `webhook` (needs a public HTTPS Request URL).
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
Walk the operator through creating the Slack app, then collect the secrets it
hands back. The adapter is already installed and registered — it just can't
receive a message until this is done. For Socket Mode, tell the user:
### Webhook server
```nc:operator when:connection=socket
Create the Slack app (Socket Mode):
1. Go to api.slack.com/apps → Create New App → From scratch. Name it (e.g. "NanoClaw") and pick your workspace.
2. OAuth & Permissions → add these Bot Token Scopes: chat:write, im:write, channels:history, groups:history, im:history, channels:read, groups:read, users:read, reactions:write, files:read, files:write.
3. App Home → enable the Messages Tab, and check "Allow users to send Slash commands and messages from the messages tab."
4. Basic Information → App-Level Tokens → "Generate Token and Scopes" → add the connections:write scope → copy the token (starts with xapp-).
5. Socket Mode → toggle "Enable Socket Mode" on.
6. Install to Workspace, then copy the Bot User OAuth Token (starts with xoxb-).
```
The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/webhook/slack` for Slack and other webhook-based adapters. This port must be publicly reachable from the internet for Slack to deliver events.
For webhook delivery, tell the user:
If running locally, discuss options for exposing the server — e.g. ngrok (`ngrok http 3000`), Cloudflare Tunnel, or a reverse proxy on a VPS. The resulting public URL becomes the base for `https://your-domain/webhook/slack`.
```nc:operator when:connection=webhook
Create the Slack app (webhook delivery):
1. Go to api.slack.com/apps → Create New App → From scratch. Name it (e.g. "NanoClaw") and pick your workspace.
2. OAuth & Permissions → add these Bot Token Scopes: chat:write, im:write, channels:history, groups:history, im:history, channels:read, groups:read, users:read, reactions:write, files:read, files:write.
3. App Home → enable the Messages Tab, and check "Allow users to send Slash commands and messages from the messages tab."
4. Install to Workspace, then copy the Bot User OAuth Token (starts with xoxb-).
5. Basic Information → copy the Signing Secret.
```
Collect the secrets and store them (the bridge reads them from `.env`; the
app-level token doubles as the Socket Mode switch, the signing secret
authenticates webhook requests — each mode needs only its own):
```nc:prompt bot_token secret validate:^xoxb-
Paste the Bot User OAuth Token — OAuth & Permissions, starts with `xoxb-`.
```
```nc:prompt app_token secret validate:^xapp- reuse:SLACK_APP_TOKEN when:connection=socket
Paste the App-Level Token — Basic Information → App-Level Tokens, starts with `xapp-`.
```
```nc:prompt signing_secret secret validate:^[a-fA-F0-9]{16,}$ when:connection=webhook
Paste the Signing Secret — Basic Information.
```
```nc:env-set
SLACK_BOT_TOKEN={{bot_token}}
```
```nc:env-set when:connection=socket
SLACK_APP_TOKEN={{app_token}}
```
```nc:env-set when:connection=webhook
SLACK_SIGNING_SECRET={{signing_secret}}
```
With webhook delivery, the bridge serves port 3000 at `/webhook/slack`
automatically; to receive replies, that port must be reachable from the internet
and registered with Slack (Socket Mode skips all of this — events arrive over
the socket as soon as the service restarts). Tell the user:
```nc:operator when:connection=webhook
Set up event delivery (needs a public HTTPS URL for port 3000 — ngrok, a Cloudflare Tunnel, or a reverse proxy on a VPS):
1. Event Subscriptions → Enable Events. Set the Request URL to https://<your-public-host>/webhook/slack and wait for the challenge to pass.
2. Subscribe to bot events: message.channels, message.groups, message.im, app_mention. Save Changes.
3. Interactivity & Shortcuts → toggle Interactivity on, set the same Request URL, Save Changes, then reinstall the app when Slack prompts.
```
## Resolve your DM channel
The agent talks to you in your direct-message channel with the bot. Resolve its
address so the owner-wiring step can target it. Validating the token here, before
the restart, fast-fails a bad credential while it's still cheap to fix. You'll
need your Slack member ID: open your profile (your avatar, bottom-left), then
**⋮** → **Copy member ID** — it starts with `U`.
```nc:prompt owner_handle validate:^U[A-Z0-9]{8,}$
Your Slack member ID (Profile → ⋮ → "Copy member ID"; starts with U).
```
Confirm the bot token works and capture the bot identity — `auth.test` returns the
bot user and workspace, and fails here if the token is bad:
```nc:run capture:connected_as effect:fetch
curl -sf -X POST https://slack.com/api/auth.test -H "Authorization: Bearer {{bot_token}}" | jq -er '"@" + .user + " in " + .team'
```
Open the DM with `conversations.open` and take the channel id it returns as the
conversation address `slack:<channelId>` (if Slack returns no channel, the bot is
missing the `im:write` scope — add it and reinstall):
```nc:run capture:platform_id effect:fetch
curl -s -X POST https://slack.com/api/conversations.open -H "Authorization: Bearer {{bot_token}}" -H "Content-Type: application/json" -d '{"users":"{{owner_handle}}"}' | jq -er '"slack:" + .channel.id'
```
`owner_handle` and `platform_id` are what the owner-wiring step needs. The
greeting goes out over `chat.postMessage`, which works right away. Receiving
replies needs the event path live: with Socket Mode that happens as soon as the
service restarts below; with webhook delivery, finish the Event Subscriptions
and Interactivity steps above first.
## Restart
With the credential validated, restart the service so it loads the Slack adapter
and the secrets you just stored, and wait for its CLI socket before wiring:
```nc:run effect:restart
bash setup/lib/restart.sh
```
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/manage-channels` to wire this channel to an agent group.
If you're in the middle of `/setup`, return to the setup flow now. Otherwise wire
this channel with `/init-first-agent` (or `/manage-channels`).
## Channel Info
+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
+245 -153
View File
@@ -5,68 +5,255 @@ description: Add Microsoft Teams channel integration via Chat SDK.
# Add Microsoft Teams Channel
Connect NanoClaw to Microsoft Teams for interactive chat in team channels, group chats, and direct messages.
Adds Microsoft Teams support via the Chat SDK bridge — interactive chat in team
channels, group chats, and direct messages. NanoClaw doesn't ship channels in
trunk — this skill copies the Teams adapter in from the `channels` branch.
## Install
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
reads the prose and applies them, and a parser can apply them deterministically
from the same document. Every directive is idempotent, so the whole skill is
safe to re-run; anything a parser can't apply falls back to the prose beside it.
NanoClaw doesn't ship channels in trunk. This skill copies the Teams adapter in from the `channels` branch.
Teams is the most involved channel NanoClaw supports — there's no "paste a
token" shortcut. You'll walk through about seven Azure portal steps (app
registration, client secret, Azure Bot resource, messaging endpoint, Teams
channel, app package, sideload). Take them one at a time; the prompts below
collect each value as you produce it.
### Pre-flight (idempotent)
## Apply
Skip to **Credentials** if all of these are already in place:
### 1. Copy the adapter and its registration test
- `src/channels/teams.ts` exists
- `src/channels/teams-registration.test.ts` exists
- `src/channels/index.ts` contains `import './teams.js';`
- `@chat-adapter/teams` is listed in `package.json` dependencies
Fetch the `channels` branch and copy the Teams adapter and its registration test
into `src/channels/` (overwrite — the branch is canonical):
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```nc:copy from-branch:channels
src/channels/teams.ts
src/channels/teams-registration.test.ts
```
### 2. Copy the adapter and its registration test
### 2. Register the adapter
```bash
git show origin/channels:src/channels/teams.ts > src/channels/teams.ts
git show origin/channels:src/channels/teams-registration.test.ts > src/channels/teams-registration.test.ts
```
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
```nc:append to:src/channels/index.ts
import './teams.js';
```
### 4. Install the adapter package (pinned)
### 3. Install the adapter package
```bash
pnpm install @chat-adapter/teams@4.29.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/teams@4.29.0
```
### 5. Build and validate
### 4. Build and validate
```bash
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
the dependency is installed. Then run the one integration test.
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/teams-registration.test.ts
```
Both must be clean before proceeding. `teams-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `teams`. It goes red if the `import './teams.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/teams` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Teams workspace is verified manually once the service is running — see Next Steps and the webhook setup above.
`teams-registration.test.ts` imports the real channel barrel and asserts the
registry contains `teams`. It goes red if the import line is deleted or drifts,
if the barrel fails to evaluate, or if `@chat-adapter/teams` isn't installed (the
import throws) — so it also covers the dependency from step 3. End-to-end
delivery against a real Teams workspace is verified manually once the service
runs.
## Credentials
Two paths — manual (Azure Portal) or auto (Teams CLI).
The adapter is installed and registered, but it can't receive a message until a
bot exists in Azure, points at this machine, and is sideloaded into Teams. None
of those steps can be clicked through by a parser, so they're operator
instructions — relay each one, then collect the value it produces.
Before you start, tell the user:
```nc:operator
Confirm you have everything Teams setup needs:
1. A Microsoft 365 tenant where you can sideload custom apps — free personal Teams does NOT support this; you need a Microsoft 365 Business / EDU / developer tenant with Teams admin or developer rights.
2. A way to expose an HTTPS endpoint from this machine (ngrok, a Cloudflare Tunnel, or a reverse-proxied VPS). Azure Bot Service delivers activities to it.
```
### Public URL
Azure Bot Service delivers messages to an HTTPS endpoint you control; it has to
reach this machine's webhook server (port 3000) at `/api/webhooks/teams`. If you
don't have a tunnel running yet, start one in another terminal first — e.g.
`ngrok http 3000` gives you `https://abcd1234.ngrok.io`.
```nc:prompt public_url validate:^https:// normalize:rstrip-slash
Paste your public base URL (https://…, no trailing path) — e.g. https://abcd1234.ngrok.io.
```
### Register the Azure app
Tell the user:
```nc:operator
Create the Azure AD app registration:
1. In https://portal.azure.com, search "App registrations" → "New registration".
2. Name it (e.g. "NanoClaw").
3. Supported account types: Single tenant (your org only — most common for self-host) OR Multi tenant (any Microsoft 365 tenant can add the bot).
4. Click Register.
5. On the Overview page, copy the Application (client) ID and, for a single-tenant app, the Directory (tenant) ID.
```
```nc:prompt app_id validate:^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$
Paste the Application (client) ID — App registration Overview page.
```
```nc:prompt app_type validate:^(SingleTenant|MultiTenant)$
Enter the app type — `SingleTenant` or `MultiTenant` (must match the account type you picked).
```
```nc:prompt app_tenant_id when:app_type=SingleTenant validate:^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$
Paste the Directory (tenant) ID — App registration Overview page (Single Tenant only).
```
### Create a client secret
Tell the user:
```nc:operator
Create the client secret:
1. In your app registration, open "Certificates & secrets".
2. Click "New client secret" — Description "nanoclaw", Expires 180 days (recommended) or longer.
3. Click Add.
4. COPY THE VALUE NOW — Azure only shows it once (the Value column, not the Secret ID).
```
```nc:prompt app_password secret validate:^.{20,}$
Paste the client secret Value — Certificates & secrets (shown only once, at least 20 characters).
```
### Store the credentials
The adapter reads these from `.env` (set-if-absent, so a value you've already
filled in is never overwritten).
`TEAMS_APP_TENANT_ID` is written only for a Single Tenant app; Multi Tenant
doesn't need it.
```nc:env-set
TEAMS_APP_ID={{app_id}}
TEAMS_APP_PASSWORD={{app_password}}
TEAMS_APP_TYPE={{app_type}}
```
```nc:env-set when:app_type=SingleTenant
TEAMS_APP_TENANT_ID={{app_tenant_id}}
```
### Create the Azure Bot resource
Tell the user:
```nc:operator
Create the Azure Bot resource and point it at this machine:
1. In https://portal.azure.com, search "Azure Bot" → Create.
2. Bot handle: a unique name, e.g. nanoclaw-bot.
3. Type of App: {{app_type}} — Creation type: Use existing app registration.
4. App ID: {{app_id}}.
5. After creating, open the bot → Configuration and set Messaging endpoint to {{public_url}}/api/webhooks/teams, then Apply.
```
### Enable the Teams channel
Tell the user (finish every Azure step above before continuing — the package
built next bakes in the app registration, so the Azure app and bot must already
exist):
```nc:operator
Enable the Microsoft Teams channel on the bot:
1. Open your Azure Bot resource → Channels.
2. Click Microsoft Teams → Accept terms → Apply.
When the Azure app, bot resource, and Teams channel are all in place, continue.
```
### Build the Teams app package
The manifest bakes in the Application (client) ID, so the Azure app registration
above must be done first — building with a blank `app_id` produces a package that
no bot can claim. Confirm it's set before generating the zip:
```nc:run effect:check
[ -n "{{app_id}}" ]
```
Generate the zip you'll sideload into Teams (manifest + icons, written to
`data/teams/teams-app-package.zip`). Re-running regenerates a fresh zip, so this
is safe to repeat.
```nc:run effect:external
pnpm exec tsx setup/channels/teams-manifest-build.ts --app-id "{{app_id}}" --url "{{public_url}}"
```
### Sideload the app into Teams
Tell the user (do this before the restart below — the service should come up with
the app already sideloaded):
```nc:operator
Sideload the generated app package into Teams:
1. Open Microsoft Teams → Apps → Manage your apps → Upload an app.
2. Click "Upload a custom app" (or "Upload for me or my teams").
3. Select data/teams/teams-app-package.zip and click Add.
4. If "Upload a custom app" is missing, your tenant admin has disabled sideloading — enable it in Teams Admin Center → Teams apps → Setup policies → Global → Upload custom apps = On.
Once the app is added in Teams, continue.
```
## Restart
Restart the service so it loads the Teams adapter and the credentials you just
stored:
```nc:run effect:restart
bash setup/lib/restart.sh
```
## Finish wiring
Unlike Discord or Slack, a Teams bot's platform ID isn't known until you DM the
bot for the first time — the adapter derives it from the inbound activity. So
this skill installs the adapter and stops here; you finish the wiring once the
bot has seen its first message. Tell the user:
```nc:operator
The Teams adapter is live and the service is running. One thing is left: your Teams bot's platform ID (which NanoClaw needs to wire it to an agent group) only becomes known after you DM the bot for the first time. To finish:
1. Find your bot in Teams (search by name, or via the app you just sideloaded) and send it a message ("hi" is fine).
2. Tail logs/nanoclaw.log for the inbound — the router auto-creates a row in messaging_groups in data/v2.db.
3. Run scripts/init-first-agent.ts with --channel teams, the discovered platform_id, and your AAD user id — OR run /manage-channels to wire it interactively.
```
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now. Otherwise,
once you've DM'd the bot, wire this channel with `/init-first-agent` (or
`/manage-channels`).
## Channel Info
- **type**: `teams`
- **terminology**: Teams has "teams" containing "channels." The bot can also receive DMs (personal scope) and group chat messages. Channels support threaded replies.
- **platform-id-format**: `teams:{base64url-conversation-id}:{base64url-service-url}` — auto-generated by the adapter from the first inbound activity, not human-readable. Use the auto-created messaging group for wiring.
- **how-to-find-id**: Send a message to the bot in the channel or a DM. NanoClaw auto-creates a messaging group and logs the platform ID. Use that messaging group for wiring.
- **supports-threads**: yes (channels only; DMs and group chats are flat)
- **typical-use**: Team collaboration with the bot in channels; personal assistant via DMs
- **default-isolation**: Separate agent group per team. DMs can share an agent group with your main channel for unified personal memory.
## Alternatives
### Auto: Teams CLI
Requires Node.js 18+, a Microsoft 365 account with sideloading permissions, and a public HTTPS endpoint (ngrok, Cloudflare Tunnel, or similar).
The Credentials flow above walks the manual Azure Portal path. If you'd rather
script it, the Microsoft Teams CLI creates the Entra app, client secret, and bot
registration in one command. Requires Node.js 18+, a Microsoft 365 account with
sideloading permissions, and a public HTTPS endpoint (ngrok, Cloudflare Tunnel,
or similar).
1. Install the CLI:
@@ -97,38 +284,10 @@ Requires Node.js 18+, a Microsoft 365 account with sideloading permissions, and
4. Pick **Install in Teams** from the post-create menu and confirm in the Teams dialog.
Continue to [Configure environment](#configure-environment).
### Azure CLI for the bot resource
---
The steps below describe the **manual Azure Portal path**.
### Step 1: Create an Azure AD App Registration
1. Go to [Azure Portal](https://portal.azure.com) > **App registrations** > **New registration**
2. Name it (e.g., "NanoClaw")
3. Supported account types: **Single tenant** (your org only) or **Multi tenant** (any org)
4. Click **Register**
5. Copy the **Application (client) ID** and **Directory (tenant) ID** from the Overview page
### Step 2: Create a Client Secret
1. In the App Registration, go to **Certificates & secrets**
2. Click **New client secret**, description "nanoclaw", expiry 180 days
3. Click **Add** and **copy the Value immediately** (shown only once)
### Step 3: Create an Azure Bot
1. Go to Azure Portal > search **Azure Bot** > **Create**
2. Fill in:
- **Bot handle**: unique name (e.g., "nanoclaw-bot")
- **Type of App**: match your app registration (Single or Multi Tenant)
- **Creation type**: **Use existing app registration**
- **App ID**: paste from Step 1
- **App tenant ID**: paste from Step 1 (Single Tenant only)
3. Click **Review + create** > **Create**
Or use Azure CLI:
The Azure Bot resource and Teams channel can also be created with `az` instead of
clicking through the portal:
```bash
az group create --name nanoclaw-rg --location eastus
@@ -139,72 +298,16 @@ az bot create \
--appid YOUR_APP_ID \
--tenant-id YOUR_TENANT_ID \
--endpoint "https://your-domain/api/webhooks/teams"
```
### Step 4: Configure Messaging Endpoint
1. Go to your Azure Bot resource > **Configuration**
2. Set **Messaging endpoint** to `https://your-domain/api/webhooks/teams`
3. Click **Apply**
### Step 5: Enable Teams Channel
1. In the Azure Bot resource, go to **Channels**
2. Click **Microsoft Teams** > Accept terms > **Apply**
Or via CLI:
```bash
az bot msteams create --resource-group nanoclaw-rg --name nanoclaw-bot
```
### Step 6: Create and Sideload Teams App
## Optional configuration
Create a `manifest.json`:
### Receive all channel messages (without @-mention)
```json
{
"$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.schema.json",
"manifestVersion": "1.16",
"version": "1.0.0",
"id": "YOUR_APP_ID",
"packageName": "com.nanoclaw.bot",
"developer": {
"name": "NanoClaw",
"websiteUrl": "https://your-domain",
"privacyUrl": "https://your-domain",
"termsOfUseUrl": "https://your-domain"
},
"name": { "short": "NanoClaw", "full": "NanoClaw Assistant" },
"description": {
"short": "NanoClaw assistant bot",
"full": "NanoClaw personal assistant powered by Claude."
},
"icons": { "outline": "outline.png", "color": "color.png" },
"accentColor": "#4A90D9",
"bots": [{
"botId": "YOUR_APP_ID",
"scopes": ["personal", "team", "groupchat"],
"supportsFiles": false,
"isNotificationOnly": false
}],
"permissions": ["identity", "messageTeamMembers"],
"validDomains": ["your-domain"]
}
```
Create two icon PNGs (32x32 `outline.png`, 192x192 `color.png`), zip all three files together.
**Sideload in Teams:**
1. Open Teams > **Apps** > **Manage your apps**
2. Click **Upload an app** > **Upload a custom app**
3. Select the zip file
Sideloading requires Teams admin access. Free personal Teams does NOT support sideloading. Use a Microsoft 365 Business account or developer tenant.
### Step 7: Receive All Messages (Optional)
By default, the bot only receives messages when @-mentioned. To receive all messages in a channel without @-mention, add RSC permissions to `manifest.json`:
By default the bot only receives messages when @-mentioned. To receive every
message in a channel, add an RSC (resource-specific consent) permission to your
Teams app `manifest.json`:
```json
{
@@ -218,38 +321,27 @@ By default, the bot only receives messages when @-mentioned. To receive all mess
}
```
### Configure environment
Re-sideload the updated app package for the change to take effect.
Add to `.env`:
## Troubleshooting
```bash
TEAMS_APP_ID=your-app-id
TEAMS_APP_PASSWORD=your-client-secret
# For Single Tenant only:
TEAMS_APP_TENANT_ID=your-tenant-id
TEAMS_APP_TYPE=SingleTenant
```
### "Upload a custom app" is missing in Teams
Sync to container: `mkdir -p data/env && cp .env data/env/env`
Your tenant admin has disabled sideloading. Enable it in Teams Admin Center →
**Teams apps** → **Setup policies** → **Global** → **Upload custom apps** = On.
Free personal Teams does not support sideloading at all — use a Microsoft 365
Business / EDU / developer tenant.
### Webhook server
### Bot never receives messages
The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/api/webhooks/teams` for Teams and other webhook-based adapters. This port must be publicly reachable from the internet for Azure Bot Service to deliver activities.
1. The tunnel is up and the messaging endpoint matches it: Azure Bot → **Configuration** → **Messaging endpoint** must be `https://<your-domain>/api/webhooks/teams`, and your tunnel (e.g. `ngrok http 3000`) must be forwarding to this machine's port 3000.
2. The adapter started: `grep -i teams logs/nanoclaw.log | tail`.
3. The credentials are in `.env` (`TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`, `TEAMS_APP_TYPE`).
For local development without a public URL, use a tunnel (e.g., `ngrok http 3000`) and update the messaging endpoint in Azure Bot Configuration.
### `Unauthorized` / 401 from Azure Bot Service
## Next Steps
The client secret is wrong or expired, or — for a Single Tenant app — `TEAMS_APP_TENANT_ID` is missing. Regenerate the secret in **Certificates & secrets** (copy the Value, not the Secret ID), update `.env`, and restart.
If you're in the middle of `/setup`, return to the setup flow now.
### Replies land in the wrong place
Otherwise, run `/manage-channels` to wire this channel to an agent group.
## Channel Info
- **type**: `teams`
- **terminology**: Teams has "teams" containing "channels." The bot can also receive DMs (personal scope) and group chat messages. Channels support threaded replies.
- **platform-id-format**: `teams:{base64-encoded-conversation-id}:{base64-encoded-service-url}` — auto-generated by the adapter, not human-readable. Use the auto-created messaging group ID for wiring.
- **how-to-find-id**: Send a message to the bot in the channel. NanoClaw auto-creates a messaging group and logs the platform ID. Use that messaging group ID for wiring.
- **supports-threads**: yes (channels only; DMs and group chats are flat)
- **typical-use**: Team collaboration with the bot in channels; personal assistant via DMs
- **default-isolation**: Separate agent group per team. DMs can share an agent group with your main channel for unified personal memory.
A Teams bot's platform ID is derived from the first inbound activity, so wire the messaging group that the router auto-creates after you DM the bot — don't guess the platform ID. See **Finish wiring** above.
+111 -66
View File
@@ -5,111 +5,156 @@ description: Add Telegram channel integration via Chat SDK.
# Add Telegram Channel
Adds Telegram bot support via the Chat SDK bridge.
Adds Telegram bot support via the Chat SDK bridge. NanoClaw doesn't ship
channels in trunk — this skill copies the Telegram adapter, its
formatting/pairing helpers, and their tests in from the `channels` branch. The
`pair-telegram` setup step is maintained in trunk, so it is not copied here.
## Install
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
reads the prose and applies them, and a parser can apply them deterministically
from the same document. Every directive is idempotent, so the whole skill is
safe to re-run; anything a parser can't apply falls back to the prose beside it.
NanoClaw doesn't ship channels in trunk. This skill copies the Telegram adapter, its formatting/pairing helpers, their tests, and the `pair-telegram` setup step in from the `channels` branch.
## Apply
### Pre-flight (idempotent)
### 1. Copy the adapter, helpers, and tests
Skip to **Credentials** if all of these are already in place:
Fetch the `channels` branch and copy the Telegram adapter, its pairing and
markdown-sanitize helpers (with their tests), and the registration test into
place (overwrite — the branch is canonical):
- `src/channels/telegram.ts`, `telegram-pairing.ts`, `telegram-markdown-sanitize.ts` (and their `.test.ts` siblings) all exist
- `src/channels/telegram-registration.test.ts` exists
- `src/channels/index.ts` contains `import './telegram.js';`
- `setup/pair-telegram.ts` exists and `setup/index.ts`'s `STEPS` map contains `'pair-telegram':`
- `@chat-adapter/telegram` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```nc:copy from-branch:channels
src/channels/telegram.ts
src/channels/telegram-pairing.ts
src/channels/telegram-pairing.test.ts
src/channels/telegram-markdown-sanitize.ts
src/channels/telegram-markdown-sanitize.test.ts
src/channels/telegram-registration.test.ts
```
### 2. Copy the adapter, helpers, tests, registration test, and setup step
### 2. Register the adapter
```bash
git show origin/channels:src/channels/telegram.ts > src/channels/telegram.ts
git show origin/channels:src/channels/telegram-registration.test.ts > src/channels/telegram-registration.test.ts
git show origin/channels:src/channels/telegram-pairing.ts > src/channels/telegram-pairing.ts
git show origin/channels:src/channels/telegram-pairing.test.ts > src/channels/telegram-pairing.test.ts
git show origin/channels:src/channels/telegram-markdown-sanitize.ts > src/channels/telegram-markdown-sanitize.ts
git show origin/channels:src/channels/telegram-markdown-sanitize.test.ts > src/channels/telegram-markdown-sanitize.test.ts
git show origin/channels:setup/pair-telegram.ts > setup/pair-telegram.ts
```
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if already present):
```typescript
```nc:append to:src/channels/index.ts
import './telegram.js';
```
### 4. Register the setup step
### 3. Register the pairing setup step
In `setup/index.ts`, add this entry to the `STEPS` map (right after the `register` line is fine; skip if already present):
Add the `pair-telegram` loader to the `STEPS` map in `setup/index.ts`, inside the
dormant marker region (skipped if already present — `pair-telegram` ships in core,
so this idempotent-skips on a normal install, but is expressed for a
clean-upstream rebuild). The pairing handshake below spawns this step:
```typescript
```nc:append to:setup/index.ts at:nanoclaw:setup-steps
'pair-telegram': () => import('./pair-telegram.js'),
```
### 5. Install the adapter package (pinned)
### 4. Install the adapter package
```bash
pnpm install @chat-adapter/telegram@4.29.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/telegram@4.29.0
```
### 6. Build and validate
### 5. Build and validate
```bash
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
the dependency is installed. Then run the one integration test.
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/telegram-registration.test.ts
```
Both must be clean before proceeding. `telegram-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `telegram`. It goes red if the `import './telegram.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/telegram` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 5. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Telegram bot is verified manually once the service is running — see Next Steps and the pairing flow in Channel Info.
`telegram-registration.test.ts` imports the real channel barrel and asserts the
registry contains `telegram`. It goes red if the import line is deleted or drifts,
if the barrel fails to evaluate, or if `@chat-adapter/telegram` isn't installed
(the import throws) — so it also covers the dependency from step 4. End-to-end
delivery against a real bot is verified manually once the service runs.
## Credentials
### Create Telegram Bot
Bot creation in Telegram is human and interactive — no parser can click through
BotFather. The adapter is installed and registered, but it can't receive a
message until the bot exists. Tell the user:
1. Open Telegram and search for `@BotFather`
2. Send `/newbot` and follow the prompts:
- Bot name: Something friendly (e.g., "NanoClaw Assistant")
- Bot username: Must end with "bot" (e.g., "nanoclaw_bot")
3. Copy the bot token (looks like `123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`)
**Important for group chats**: By default, Telegram bots only see @mentions and commands in groups. To let the bot see all messages:
1. Open `@BotFather` > `/mybots` > select your bot
2. **Bot Settings** > **Group Privacy** > **Turn off**
### Configure environment
Add to `.env`:
```bash
TELEGRAM_BOT_TOKEN=your-bot-token
```nc:operator
Create the Telegram bot:
1. Open Telegram and message @BotFather — Telegram's official bot for creating bots.
2. Send /newbot and follow the prompts: a friendly name, then a username that must end in "bot".
3. Copy the bot token it gives you (looks like 123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11).
4. Planning to use the bot in group chats? Send /mybots → your bot → Bot Settings → Group Privacy → Turn off, so the bot can see all messages and not just @mentions.
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
Collect the bot token and store it — the bridge reads it from `.env` (set-if-absent,
so a value you've already filled in is never overwritten) and syncs it to the
container:
```nc:prompt bot_token secret validate:^[0-9]+:[A-Za-z0-9_-]{35,}$
Paste the bot token from BotFather (looks like `123456:ABC-DEF...`).
```
```nc:env-set
TELEGRAM_BOT_TOKEN={{bot_token}}
```
Confirm the token works and capture the bot's handle — `getMe` returns the bot
account and fails here if the token is bad. You'll use the handle to open the
right chat just before pairing:
```nc:run capture:bot_username effect:fetch
curl -sf https://api.telegram.org/bot{{bot_token}}/getMe | jq -er '.result.username'
```
## Restart
Restart the service so it loads the Telegram adapter and the token you just
stored, and wait for its CLI socket. The adapter must be live and polling before
pairing — it's the thing that observes the code you send:
```nc:run effect:restart
bash setup/lib/restart.sh
```
## Pair your chat
Telegram tokens carry no user binding, so the agent proves you own the chat with
a one-time pairing handshake: it issues a 4-digit code, you send those exact 4
digits to the bot from the chat you want to register, and the live adapter
matches them. Open the bot first so you're on the right screen when the code
appears. Tell the user:
```nc:operator
Open @{{bot_username}} (https://t.me/{{bot_username}}) in Telegram now and keep it on screen — a 4-digit pairing code is about to appear in this terminal. When it does, send just those 4 digits to the bot as a message (in a group chat with Group Privacy on, prefix them with @{{bot_username}}). A wrong guess is rejected and a fresh code is issued automatically.
```
Run the pairing handshake. It prints the code, streams "waiting…" and wrong-code
feedback while it watches for your message, and resolves your chat address
`telegram:<chatId>` plus your Telegram user id once the code matches:
```nc:run effect:step capture:platform_id=PLATFORM_ID,owner_handle=ADMIN_USER_ID
pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main
```
`owner_handle` (your Telegram user id) and `platform_id` (`telegram:<chatId>`)
are what the owner-wiring step needs. The greeting goes out over the same chat as
soon as pairing completes.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/manage-channels` to wire this channel to an agent group.
If you're in the middle of `/setup`, return to the setup flow now. Otherwise wire
this channel with `/init-first-agent` (or `/manage-channels`).
## Channel Info
- **type**: `telegram`
- **terminology**: Telegram calls them "groups" and "chats." A "group" has multiple members; a "chat" is a 1:1 conversation with the bot.
- **how-to-find-id**: Do NOT ask the user for a chat ID. Telegram registration uses pairing — run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent <main|wire-to:folder|new-agent:folder>`, show the user the 4-digit `CODE` from the `PAIR_TELEGRAM_ISSUED` block (follow the `REMINDER_TO_ASSISTANT` line in that block), and tell them to send just the 4 digits as a message from the chat they want to register (DM the bot for `main`, post in the group otherwise). In groups with Group Privacy ON, prefix with the bot handle: `@<botname> CODE`. Wrong guesses invalidate the code — if a `PAIR_TELEGRAM_ATTEMPT` block arrives with a mismatched `RECEIVED_CODE`, a `PAIR_TELEGRAM_NEW_CODE` block will follow automatically (up to 5 regenerations); show the new code. On `PAIR_TELEGRAM STATUS=failed ERROR=max-regenerations-exceeded`, ask the user if they want to try again and re-invoke the step — each invocation starts a fresh 5-attempt batch. Success emits `PAIR_TELEGRAM STATUS=success` with `PLATFORM_ID`, `IS_GROUP`, and `ADMIN_USER_ID`. The service must be running for this to work (the polling adapter is what observes the code).
- **platform-id-format**: `telegram:{chatId}` (e.g. `telegram:123456789` for a DM, `telegram:-1001234567890` for a group — negative chat IDs are groups/channels).
- **how-to-find-id**: Do NOT ask the user for a chat ID. Telegram registration uses pairing — run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent <main|wire-to:folder|new-agent:folder>`. The step prints a 4-digit code (and re-prints a fresh one if a wrong code invalidates it, up to 5 times); tell the user to send just those 4 digits from the chat they want to register (DM the bot for `main`, post in the group otherwise; with Group Privacy ON, prefix `@<botname> CODE`). Success emits a `PAIR_TELEGRAM` block with `STATUS=success`, `PLATFORM_ID`, `IS_GROUP`, `ADMIN_USER_ID` (the bare Telegram user id) and `PAIRED_USER_ID` (the `telegram:`-prefixed form). The service must be running — the polling adapter is what observes the code.
- **supports-threads**: no
- **typical-use**: Interactive chat — direct messages or small groups
- **default-isolation**: Same agent group if you're the only participant across multiple chats. Separate agent group if different people are in different groups.
+72 -47
View File
@@ -5,85 +5,110 @@ description: Add Webex channel integration via Chat SDK.
# Add Webex Channel
Adds Cisco Webex support via the Chat SDK bridge.
Adds Cisco Webex support via the Chat SDK bridge. NanoClaw doesn't ship channels
in trunk — this skill copies the Webex adapter in from the `channels` branch.
## Install
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
reads the prose and applies them, and a parser can apply them deterministically
from the same document. Every directive is idempotent, so the whole skill is
safe to re-run; anything a parser can't apply falls back to the prose beside it.
NanoClaw doesn't ship channels in trunk. This skill copies the Webex adapter in from the `channels` branch.
## Apply
### Pre-flight (idempotent)
### 1. Copy the adapter and its registration test
Skip to **Credentials** if all of these are already in place:
Fetch the `channels` branch and copy the Webex adapter and its registration test
into `src/channels/` (overwrite — the branch is canonical):
- `src/channels/webex.ts` exists
- `src/channels/webex-registration.test.ts` exists
- `src/channels/index.ts` contains `import './webex.js';`
- `@bitbasti/chat-adapter-webex` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```nc:copy from-branch:channels
src/channels/webex.ts
src/channels/webex-registration.test.ts
```
### 2. Copy the adapter and its registration test
### 2. Register the adapter
```bash
git show origin/channels:src/channels/webex.ts > src/channels/webex.ts
git show origin/channels:src/channels/webex-registration.test.ts > src/channels/webex-registration.test.ts
```
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
```nc:append to:src/channels/index.ts
import './webex.js';
```
### 4. Install the adapter package (pinned)
### 3. Install the adapter package
```bash
pnpm install @bitbasti/chat-adapter-webex@0.1.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`.
The Webex adapter ships under the third-party `@bitbasti/*` namespace, not
`@chat-adapter/*`, so it carries its own version line (`0.1.0`) rather than
tracking the chat core version:
```nc:dep
@bitbasti/chat-adapter-webex@0.1.0
```
### 5. Build and validate
### 4. Build and validate
```bash
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
the dependency is installed. Then run the one integration test.
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/webex-registration.test.ts
```
Both must be clean before proceeding. `webex-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `webex`. It goes red if the `import './webex.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@bitbasti/chat-adapter-webex` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Webex space is verified manually once the service is running — see Next Steps and the webhook setup above.
`webex-registration.test.ts` imports the real channel barrel and asserts the
registry contains `webex`. It goes red if the import line is deleted or drifts,
if the barrel fails to evaluate, or if `@bitbasti/chat-adapter-webex` isn't
installed (the import throws) — so it also covers the dependency from step 3.
End-to-end delivery against a real Webex space is verified manually once the
service runs — see the webhook setup below.
## Credentials
1. Go to [developer.webex.com](https://developer.webex.com/my-apps/new/bot) and create a new bot
2. Copy the **Bot Access Token**
Webex bot setup is human and interactive — these steps are prose, not directives
(no parser can click through the Webex Developer Portal). A recipe rebuild
produces a compiling, registered adapter that cannot receive a message until
they're done.
### Create the Webex bot
1. Go to [developer.webex.com](https://developer.webex.com/my-apps/new/bot) and create a new bot.
2. Copy the **Bot Access Token**.
3. Set up a webhook:
- Use the Webex API or Developer Portal to create a webhook pointing to `https://your-domain/webhook/webex`
- Set a webhook secret for signature verification
- Use the Webex API or Developer Portal to create a webhook pointing to `https://your-domain/webhook/webex`.
- Set a webhook secret for signature verification.
### Configure environment
### Store the credentials
Add to `.env`:
Capture the two values, then write them. `prompt` only *asks* and binds the
answer to a name; a separate directive consumes it — so the same prompts could
feed `ncl` or the OneCLI vault instead of `.env` by swapping only the consumer.
Here they go to `.env` (set-if-absent — a value you've already filled in is
never overwritten):
```bash
WEBEX_BOT_TOKEN=your-bot-token
WEBEX_WEBHOOK_SECRET=your-webhook-secret
```nc:prompt bot_token secret
Paste the Bot Access Token — from the Webex bot you created.
```
```nc:prompt webhook_secret secret
Paste the webhook secret you set for signature verification.
```
```nc:env-set
WEBEX_BOT_TOKEN={{bot_token}}
WEBEX_WEBHOOK_SECRET={{webhook_secret}}
```
### Webhook server
Sync to container: `mkdir -p data/env && cp .env data/env/env`
The Chat SDK bridge automatically starts a shared webhook server on port 3000
(`WEBHOOK_PORT` to change it), handling `/webhook/webex`. This port must be
publicly reachable for Webex to deliver events. Running locally, expose it with
ngrok (`ngrok http 3000`), a Cloudflare Tunnel, or a reverse proxy on a VPS —
the resulting public URL is the base for the webhook URL above.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/manage-channels` to wire this channel to an agent group.
If you're in the middle of `/setup`, return to the setup flow now. Otherwise run
`/manage-channels` to wire this channel to an agent group.
## Channel Info
+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:
-1
View File
@@ -85,7 +85,6 @@ Add to `.env`:
WECHAT_ENABLED=true
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### 2. Start the service and scan the QR
+73 -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.29.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/whatsapp@4.29.0
```
### 5. Build and validate
### 4. Build and validate
```bash
Build guards the typed `createChatSdkBridge(...)` core call and proves the
dependency is installed — the import throws at evaluation if `@chat-adapter/whatsapp`
is missing or the barrel drifts:
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/whatsapp-cloud-registration.test.ts
```
Both must be clean before proceeding. `whatsapp-cloud-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `whatsapp-cloud`. It goes red if the `import './whatsapp-cloud.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/whatsapp` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
`whatsapp-cloud-registration.test.ts` imports the real channel barrel and asserts
the registry contains `whatsapp-cloud` — it goes red if the import line is deleted
or drifts, if the barrel fails to evaluate, or if `@chat-adapter/whatsapp` isn't
installed (the import throws), so it also covers the dependency from step 3.
End-to-end message delivery against a real WhatsApp Business number is verified manually once the service is running — see Next Steps and the webhook setup above.
End-to-end message delivery against a real WhatsApp Business number is verified
manually once the service is running — see Next Steps and the webhook setup
below.
## Credentials
Meta app setup is human and interactive — these steps are prose, not directives
(no parser can click through the Meta dashboard). A recipe rebuild produces a
compiling, registered adapter that cannot receive a message until they're done.
1. Go to [Meta for Developers](https://developers.facebook.com/apps/) and create an app (type: Business).
2. Add the **WhatsApp** product.
3. Go to **WhatsApp** > **API Setup**:
@@ -73,18 +82,40 @@ End-to-end message delivery against a real WhatsApp Business number is verified
- Subscribe to webhook fields: `messages`.
5. Copy the **App Secret** from **Settings** > **Basic**.
### Configure environment
### Store the credentials
Add to `.env`:
Capture the four values, then write them. `prompt` only *asks* and binds the
answer to a name; a separate directive consumes it — so the same prompts could
feed `ncl` or the OneCLI vault instead of `.env` by swapping only the consumer.
Here they go to `.env` (set-if-absent — a value you've already filled in is
never overwritten):
```bash
WHATSAPP_ACCESS_TOKEN=your-system-user-access-token
WHATSAPP_PHONE_NUMBER_ID=your-phone-number-id
WHATSAPP_APP_SECRET=your-app-secret
WHATSAPP_VERIFY_TOKEN=your-verify-token
```nc:prompt access_token secret
Paste the System User access token — WhatsApp > API Setup, with `whatsapp_business_messaging` permission.
```
```nc:prompt phone_number_id
Paste the Phone Number ID — WhatsApp > API Setup (not the phone number itself).
```
```nc:prompt app_secret secret
Paste the App Secret — Settings > Basic.
```
```nc:prompt verify_token secret
Paste the Verify Token — the random string you set under WhatsApp > Configuration.
```
```nc:env-set
WHATSAPP_ACCESS_TOKEN={{access_token}}
WHATSAPP_PHONE_NUMBER_ID={{phone_number_id}}
WHATSAPP_APP_SECRET={{app_secret}}
WHATSAPP_VERIFY_TOKEN={{verify_token}}
```
### Webhook server
Sync to container: `mkdir -p data/env && cp .env data/env/env`
The Chat SDK bridge automatically starts a shared webhook server on port 3000
(`WEBHOOK_PORT` to change it), handling `/webhook/whatsapp`. This port must be
publicly reachable for Meta to deliver events. Running locally, expose it with
ngrok (`ngrok http 3000`), a Cloudflare Tunnel, or a reverse proxy on a VPS —
the resulting public URL is the base for the webhook URL set under WhatsApp >
Configuration above.
## Next Steps
@@ -100,3 +131,5 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group.
- **supports-threads**: no
- **typical-use**: Interactive 1:1 chat -- direct messages only
- **default-isolation**: Same agent group if you're the only person messaging the bot. Each additional person who messages gets their own conversation automatically, but they share the agent's workspace and memory -- use a separate agent group if you need information isolation between different contacts.
</content>
</invoke>
+199 -153
View File
@@ -5,216 +5,202 @@ description: Add WhatsApp channel via native Baileys adapter. Direct connection
# Add WhatsApp Channel
Adds WhatsApp support via the native Baileys adapter (no Chat SDK bridge).
Adds WhatsApp support via the native Baileys adapter — a direct WhatsApp Web
connection, no Chat SDK bridge. NanoClaw doesn't ship channels in trunk — this
skill copies the WhatsApp adapter in from the `channels` branch.
## Install
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
reads the prose and applies them, and a parser can apply them deterministically
from the same document. Every directive is idempotent, so the whole skill is
safe to re-run; anything a parser can't apply falls back to the prose beside it.
NanoClaw doesn't ship channels in trunk. This skill copies the native WhatsApp (Baileys) adapter and its `whatsapp-auth` setup step in from the `channels` branch. No Chat SDK bridge.
## Apply
### Pre-flight (idempotent)
### 1. Copy the adapter and its registration test
Skip to **Credentials** if all of these are already in place:
Fetch the `channels` branch and copy the WhatsApp adapter and its registration
test into `src/channels/` (overwrite — the branch is canonical). The
`whatsapp-auth` setup step is maintained in trunk, so it is not copied here:
- `src/channels/whatsapp.ts` exists
- `src/channels/whatsapp-registration.test.ts` exists
- `src/channels/whatsapp.test.ts` exists
- `src/channels/index.ts` contains `import './whatsapp.js';`
- `setup/whatsapp-auth.ts` and `setup/groups.ts` both exist
- `setup/index.ts`'s `STEPS` map contains both `'whatsapp-auth':` and `groups:`
- `@whiskeysockets/baileys`, `qrcode`, `pino` are listed in `package.json` dependencies
- `.claude/skills/add-whatsapp/scripts/wa-qr-browser.ts` exists (ships with this skill)
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```nc:copy from-branch:channels
src/channels/whatsapp.ts
src/channels/whatsapp-registration.test.ts
```
### 2. Copy the adapter and setup steps
### 2. Register the adapter
```bash
git show origin/channels:src/channels/whatsapp.ts > src/channels/whatsapp.ts
git show origin/channels:src/channels/whatsapp-registration.test.ts > src/channels/whatsapp-registration.test.ts
git show origin/channels:src/channels/whatsapp.test.ts > src/channels/whatsapp.test.ts
git show origin/channels:setup/whatsapp-auth.ts > setup/whatsapp-auth.ts
git show origin/channels:setup/groups.ts > setup/groups.ts
```
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if already present):
```typescript
```nc:append to:src/channels/index.ts
import './whatsapp.js';
```
### 4. Register the setup steps
### 3. Install the adapter packages
In `setup/index.ts`, add these entries to the `STEPS` map (skip lines already present):
Pinned to exact versions — the supply-chain policy rejects ranges and `latest`.
Baileys is the WhatsApp Web client; `qrcode` renders the device-link QR in the
terminal; `pino` is Baileys' logger:
```typescript
groups: () => import('./groups.js'),
'whatsapp-auth': () => import('./whatsapp-auth.js'),
```nc:dep
@whiskeysockets/baileys@7.0.0-rc.9
qrcode@1.5.4
@types/qrcode@1.5.6
pino@9.6.0
```
### 5. Install the adapter packages (pinned)
### 4. Build and validate
```bash
pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
```
Build first: it typechecks the adapter against core and proves the dependencies
are installed. Then run the one integration test.
### 6. Build and validate
```bash
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/whatsapp-registration.test.ts
```
Both must be clean before proceeding. `whatsapp-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `whatsapp`. It goes red if the `import './whatsapp.js';` line is deleted or drifts, if the barrel fails to evaluate (so the channel genuinely would not register), or if `@whiskeysockets/baileys` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 5.
`whatsapp-registration.test.ts` imports the real channel barrel and asserts the
registry contains `whatsapp`. It goes red if the `import './whatsapp.js';` line
is deleted or drifts, if the barrel fails to evaluate, or if
`@whiskeysockets/baileys` isn't installed (the import throws) — so it also covers
the dependency from step 3. End-to-end delivery against a real WhatsApp number is
verified manually once the service runs.
End-to-end message delivery against a real WhatsApp number is verified manually once the service is running — see Credentials, Wiring, and Troubleshooting.
## Authenticate
## Credentials
WhatsApp uses linked-device authentication — no API key, just a one-time pairing
from your phone. The adapter is installed and registered, but its factory returns
`null` (and the channel stays dark) until `store/auth/creds.json` exists.
WhatsApp uses linked-device authentication — no API key, just a one-time pairing from your phone.
Pick how to link the device. `qr` shows a rotating QR you scan with your phone's
camera; `pairing-code` shows an 8-character code you type into WhatsApp (no camera
needed, but it needs your phone number):
### Check current state
Check if WhatsApp is already authenticated. If `store/auth/creds.json` exists, skip to "Shared vs dedicated number".
```bash
test -f store/auth/creds.json && echo "WhatsApp auth exists" || echo "No WhatsApp auth"
```nc:prompt auth_method validate:^(qr|pairing-code)$
How do you want to link WhatsApp? Type `qr` to scan a QR code in this terminal, or `pairing-code` to enter a code on your phone (no camera needed).
```
### Detect environment
The pairing-code method needs the number you're linking, the way WhatsApp expects
it — digits only, country code first, no `+`, spaces, or dashes (the QR method
skips this entirely):
Check whether the environment is headless (no display server):
```bash
[[ -z "$DISPLAY" && -z "$WAYLAND_DISPLAY" && "$OSTYPE" != darwin* ]] && echo "IS_HEADLESS=true" || echo "IS_HEADLESS=false"
```nc:prompt phone validate:^\d{8,15}$ when:auth_method=pairing-code
Your WhatsApp phone number — digits only, country code first (e.g. 14155551234 for +1 415-555-1234).
```
### Ask the user
Point the user at the right screen before the code appears. For the QR method,
tell the user:
Use `AskUserQuestion` to collect configuration. **Adapt auth options based on environment:**
If IS_HEADLESS=true AND not WSL → AskUserQuestion: How do you want to authenticate WhatsApp?
- **Pairing code** (Recommended) - Enter a numeric code on your phone (no camera needed, requires phone number)
- **QR code in terminal** - Displays QR code in the terminal (can be too small on some displays)
Otherwise (macOS, desktop Linux, or WSL) → AskUserQuestion: How do you want to authenticate WhatsApp?
- **QR code in browser** (Recommended) - Runs a small local HTTP server that renders the rotating QR as a PNG and auto-opens your default browser
- **Pairing code** - Enter a numeric code on your phone (no camera needed, requires phone number)
- **QR code in terminal** - Displays QR code in the terminal (can be too small on some displays)
If they chose pairing code:
AskUserQuestion: What is your phone number? (Digits only — country code followed by your 10-digit number, no + prefix, spaces, or dashes. Example: 14155551234 where 1 is the US country code and 4155551234 is the phone number.)
### Clean previous auth state (if re-authenticating)
```bash
rm -rf store/auth/
```nc:operator when:auth_method=qr
Link WhatsApp by QR:
1. On your phone, open WhatsApp → Settings → Linked Devices → Link a Device.
2. A QR code will appear in this terminal below and refresh every ~20 seconds. Point your phone's camera at it to scan.
```
### Run WhatsApp authentication
For the pairing-code method, tell the user:
For QR code in browser (recommended):
```bash
pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts
```nc:operator when:auth_method=pairing-code
Link WhatsApp by pairing code:
1. On your phone, open WhatsApp → Settings → Linked Devices → Link a Device → tap "Link with phone number instead".
2. An 8-character code will appear in this terminal below. Enter it on your phone immediately — it expires in about 60 seconds.
```
(Bash timeout: 150000ms)
Now run the linked-device handshake. It streams the live QR (or the pairing-code
card) to this terminal and, on success, reports the linked WhatsApp number. Run
the command for the method chosen above — `qr` or `pairing-code`:
The wrapper spawns `setup/index.ts --step whatsapp-auth -- --method qr`, parses each rotating QR from its `WHATSAPP_AUTH_QR` status blocks, and serves the current QR as a PNG on a local HTTP server (default port `8765`, falls back to a free port). Flags: `--clean` (wipes `store/auth/` before spawning) and `--port N`.
Tell the user:
> A browser window will open with a QR code.
>
> 1. Open WhatsApp > **Settings** > **Linked Devices** > **Link a Device**
> 2. Scan the QR code in the browser
> 3. The page will show "Authenticated!" when done
For QR code in terminal:
```bash
```nc:run effect:step capture:bot_phone=PHONE when:auth_method=qr
pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr
```
(Bash timeout: 150000ms)
The setup driver emits each rotating QR as a `WHATSAPP_AUTH_QR` status block; when run directly (not through `setup:auto`) the raw QR string is printed and your terminal must render it as ASCII. If your terminal can't render it readably, use the browser method above.
Tell the user:
> 1. Open WhatsApp > **Settings** > **Linked Devices** > **Link a Device**
> 2. Scan the QR code displayed in the terminal
For pairing code:
Tell the user to have WhatsApp open on **Settings > Linked Devices > Link a Device**, ready to tap **"Link with phone number instead"** — the code expires in ~60 seconds and must be entered immediately.
Run the auth process in the background and poll `store/pairing-code.txt` for the code:
```bash
rm -f store/pairing-code.txt && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method pairing-code --phone <their-phone-number> > /tmp/wa-auth.log 2>&1 &
```nc:run effect:step capture:bot_phone=PHONE when:auth_method=pairing-code
pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method pairing-code --phone {{phone}}
```
Then immediately poll for the code (do NOT wait for the background command to finish):
If the handshake fails (`logged_out` or a timeout), the code expired — clear
`store/auth/` and run the step again for a fresh one. See Troubleshooting.
```bash
for i in $(seq 1 20); do [ -f store/pairing-code.txt ] && cat store/pairing-code.txt && break; sleep 1; done
A successful link reports the number back as `bot_phone`. If it came back empty,
the device never confirmed (an expired QR or pairing code), so don't restart or
wire against a blank number — clear `store/auth/` and re-run the link step first:
```nc:run effect:check
[ -n "{{bot_phone}}" ]
```
Display the code to the user the moment it appears. Tell them:
## Restart
> **Enter this code now** — it expires in ~60 seconds.
>
> 1. Open WhatsApp > **Settings** > **Linked Devices** > **Link a Device**
> 2. Tap **Link with phone number instead**
> 3. Enter the code immediately
Restart the service so it loads the WhatsApp adapter and picks up the
credentials you just linked, and wait for its CLI socket before resolving:
After the user enters the code, poll for authentication to complete:
```bash
for i in $(seq 1 60); do grep -q 'STATUS: authenticated' /tmp/wa-auth.log 2>/dev/null && echo "authenticated" && break; grep -q 'STATUS: failed' /tmp/wa-auth.log 2>/dev/null && echo "failed" && break; sleep 2; done
```nc:run effect:restart
bash setup/lib/restart.sh
```
**If failed:** logged_out → delete `store/auth/` and re-run. timeout → ask user, offer retry.
## Resolve your DM channel
### Verify authentication succeeded
The agent talks to you in your WhatsApp chat. Tell the user which number that
chat happens on — usually the same one they just linked:
```bash
test -f store/auth/creds.json && echo "Authentication successful" || echo "Authentication failed"
```nc:operator
You're linked to WhatsApp as +{{bot_phone}}.
- "shared" — you'll message the assistant from this same (personal) WhatsApp number. Replies land in your own "You" / self-chat.
- "dedicated" — the assistant has its own separate phone/SIM, and you'll message it from a different number.
```
### Shared vs dedicated number
Pick which it is. Most people use `shared`:
AskUserQuestion: Is this a shared phone number (personal WhatsApp) or a dedicated number?
- **Shared number** — your personal WhatsApp (bot prefixes messages with its name)
- **Dedicated number** — a separate phone/SIM for the assistant
```nc:prompt number_kind validate:^(shared|dedicated)$
Is the assistant on a `shared` number (your personal WhatsApp) or a `dedicated` number (a separate line for the assistant)?
```
If dedicated, add to `.env`:
For a dedicated number, collect the number you'll actually chat from (skipped
entirely for a shared number):
```bash
```nc:prompt chat_phone validate:^\d{8,15}$ when:number_kind=dedicated
The phone number you'll message the assistant from — digits only, country code first (e.g. 14155551234).
```
A dedicated number means the assistant owns its own line, so outbound replies
shouldn't be prefixed with its name. Record that (skipped for a shared number):
```nc:env-set when:number_kind=dedicated
ASSISTANT_HAS_OWN_NUMBER=true
```
Resolve the conversation address as the WhatsApp JID for the number you chat
from — the linked number for a shared account, or the dedicated number you just
gave. Run the one matching the choice above:
```nc:run capture:platform_id effect:fetch when:number_kind=shared
echo "{{bot_phone}}@s.whatsapp.net"
```
```nc:run capture:platform_id effect:fetch when:number_kind=dedicated
echo "{{chat_phone}}@s.whatsapp.net"
```
For WhatsApp, your owner handle is that same JID:
```nc:run capture:owner_handle effect:fetch
echo "{{platform_id}}"
```
`owner_handle` and `platform_id` are what the owner-wiring step needs. The
greeting goes out over your WhatsApp chat as soon as the service reconnects with
the linked credentials.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/manage-channels` to wire this channel to an agent group.
If you're in the middle of `/setup`, return to the setup flow now. Otherwise wire
this channel with `/init-first-agent` (or `/manage-channels`).
## Channel Info
- **type**: `whatsapp`
- **terminology**: WhatsApp calls them "groups" and "chats." A "chat" is a 1:1 DM; a "group" has multiple members.
- **how-to-find-id**: DMs use `<phone>@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `<id>@g.us`. To find your number: `node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')"`. Groups are auto-discoveredcheck `pnpm exec tsx scripts/q.ts data/v2.db "SELECT platform_id, name FROM messaging_groups WHERE channel_type='whatsapp' AND is_group=1"`.
- **platform-id-format**: DMs use `<phone>@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `<id>@g.us`. Native adapterthe JID is the platform ID as-is, no `whatsapp:` prefix.
- **how-to-find-id**: To find your linked number after auth: `node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0].split('@')[0]+'@s.whatsapp.net')"`. Groups are auto-discovered — check `pnpm exec tsx scripts/q.ts data/v2.db "SELECT platform_id, name FROM messaging_groups WHERE channel_type='whatsapp' AND is_group=1"`.
- **supports-threads**: no
- **typical-use**: Interactive chat — direct messages or small groups
- **default-isolation**: Same agent group if you're the only participant across multiple chats. Separate agent group if different people are in different groups.
@@ -228,18 +214,74 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group.
- Typing indicators — composing presence updates
- Credential requests — text fallback (WhatsApp has no modal support)
Not supported (WhatsApp linked device limitation): edit messages, delete messages.
Not supported (WhatsApp linked-device limitation): edit messages, delete messages.
## Alternatives
### QR code in a browser
Besides the in-terminal QR and the pairing code the Apply flow uses, this skill
ships a helper that renders the rotating QR as a PNG in your default browser —
handy when the terminal QR is too small to scan reliably. It spawns the same
`whatsapp-auth` step, parses each rotating QR from its `WHATSAPP_AUTH_QR` status
blocks, and serves the current one on a local HTTP server (default port `8765`,
falls back to a free port):
```bash
pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts
```
Flags: `--clean` wipes `store/auth/` before spawning, `--port N` pins the port.
A browser window opens with a QR code. On your phone, open WhatsApp →
**Settings** → **Linked Devices** → **Link a Device**, scan the QR, and the page
shows "Authenticated!" when done.
### Headless environments
On a headless host (no display server — no `$DISPLAY`/`$WAYLAND_DISPLAY`, not
macOS), the browser method can't open a window. Detect it and fall back to the
pairing-code method (no camera needed):
```bash
[[ -z "$DISPLAY" && -z "$WAYLAND_DISPLAY" && "$OSTYPE" != darwin* ]] && echo "IS_HEADLESS=true" || echo "IS_HEADLESS=false"
```
## Optional configuration
If the assistant runs on a dedicated number (its own phone/SIM, not your personal
WhatsApp), tell the adapter so it doesn't prefix outbound replies with its name:
```bash
ASSISTANT_HAS_OWN_NUMBER=true
```
The Apply flow sets this for you when you pick a `dedicated` number; this is the
key it writes, for reference. A shared (personal) number leaves it unset.
## Troubleshooting
### QR code expired
### QR code or pairing code expired
QR codes expire after ~60 seconds. The browser wrapper rotates automatically as long as it's running; if it was stopped, re-run with `--clean`:
Codes expire after ~60 seconds. The QR rotates automatically while the auth step
is running; if the step exited, clear the auth state and re-run it:
```bash
pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts --clean
rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr
```
For pairing code, ensure digits only (no `+`), the phone has internet, and
WhatsApp is updated:
```bash
rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method pairing-code --phone <phone>
```
WhatsApp's pairing-code flow occasionally rejects valid codes with "Couldn't link
device." This is a server-side rejection unrelated to the code itself. If you hit
it more than once, switch to the QR method — it has a noticeably higher success
rate.
### Pairing code not working
Codes expire in ~60 seconds. Delete auth and retry:
@@ -250,7 +292,11 @@ rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --met
Ensure: digits only (no `+`), phone has internet, WhatsApp is updated.
WhatsApp's pairing-code flow occasionally rejects valid codes with "Couldn't link device — An error happened. Please try again." This is a server-side rejection unrelated to the code itself; we've seen it happen twice in a row on fresh dedicated numbers. If you hit it more than once, switch to QR-browser auth — it has a noticeably higher success rate:
WhatsApp's pairing-code flow occasionally rejects valid codes with "Couldn't link
device — An error happened. Please try again." This is a server-side rejection
unrelated to the code itself; we've seen it happen twice in a row on fresh
dedicated numbers. If you hit it more than once, switch to QR-browser auth — it
has a noticeably higher success rate:
```bash
pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts --clean
@@ -258,9 +304,8 @@ pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts --clean
### "waiting for this message" on reactions
Signal sessions corrupted from rapid restarts. Clear sessions.
Run from your NanoClaw project root:
WhatsApp sessions corrupted from rapid restarts. Clear sessions, then restart the
service. Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
@@ -278,4 +323,5 @@ systemctl --user start $(systemd_unit)
### "conflict" disconnection
Two instances connected with same credentials. Ensure only one NanoClaw process is running.
Two instances connected with the same credentials. Ensure only one NanoClaw
process is running.
+7 -9
View File
@@ -13,18 +13,18 @@ Configure which host directories NanoClaw agent containers can access. The mount
cat ~/.config/nanoclaw/mount-allowlist.json 2>/dev/null || echo "No mount allowlist configured"
```
Show the current config to the user in a readable format: which directories are allowed, whether non-main agents are read-only.
Show the current config to the user in a readable format: which directories are allowed, and whether each is read-only or read-write.
## Add Directories
Ask which directories the user wants agents to access. For each path:
- Validate the path exists
- Ask if it should be read-only for non-main agents (default: yes)
- Ask if it should be read-write (`allowReadWrite: true`) or read-only (`allowReadWrite: false`, the safer default)
Build the JSON config and write it:
```bash
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[{"path":"/path/to/dir","readOnly":false}],"blockedPatterns":[],"nonMainReadOnly":true}'
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[{"path":"/path/to/dir","allowReadWrite":true}],"blockedPatterns":[]}'
```
Use `--force` to overwrite the existing config.
@@ -34,7 +34,7 @@ Use `--force` to overwrite the existing config.
Read the current config, show it, ask which entry to remove, then write the updated config through the same write path (build the trimmed JSON and pass it to `--step mounts --force -- --json`):
```bash
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[],"blockedPatterns":[],"nonMainReadOnly":true}'
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[],"blockedPatterns":[]}'
```
## Reset to Empty
@@ -45,12 +45,10 @@ pnpm exec tsx setup/index.ts --step mounts --force -- --empty
## After Changes
Restart the service so containers pick up the new config (the unit/label names are per-install — see `setup/lib/install-slug.sh`).
The allowlist is read fresh when a container is spawned, so new mounts apply to newly spawned containers automaticallyno service restart needed.
Run from your NanoClaw project root:
To apply the new config to a group that already has a running container, restart just that group:
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
systemctl --user restart $(systemd_unit) # Linux
ncl groups restart --id <group-id>
```
+4 -4
View File
@@ -71,7 +71,7 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
| `src/command-gate.ts` | Router-side admin command gate — queries `user_roles` directly (no env var, no container-side check) |
| `src/modules/approvals/onecli-approvals.ts` | OneCLI credentialed-action approval bridge |
| `src/modules/permissions/user-dm.ts` | Cold-DM resolution + `user_dms` cache |
| `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills, agent-runner-src overlay) |
| `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills) — agent-runner source is a shared read-only mount, not copied per group |
| `src/db/container-configs.ts` | CRUD for `container_configs` table (per-group container runtime config) |
| `src/backfill-container-configs.ts` | Migrates legacy `container.json` files into the DB on startup |
| `src/container-restart.ts` | Kill + on-wake respawn for agent group containers |
@@ -79,8 +79,8 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
| `src/channels/` | Channel adapter infra (registry, Chat SDK bridge); specific channel adapters are skill-installed from the `channels` branch |
| `src/providers/` | Host-side provider container-config (`claude` baked in; `opencode` etc. installed from the `providers` branch) |
| `container/agent-runner/src/` | Agent-runner: poll loop, formatter, provider abstraction, MCP tools, destinations |
| `container/skills/` | Container skills mounted into every agent session (`onecli-gateway`, `welcome`, `self-customize`, `agent-browser`, `slack-formatting`) |
| `groups/<folder>/` | Per-agent-group filesystem (CLAUDE.md, skills, per-group `agent-runner-src/` overlay) |
| `container/skills/` | Container skills mounted into every agent session (`agent-browser`, `frontend-engineer`, `onecli-gateway`, `self-customize`, `slack-formatting`, `vercel-cli`, `welcome`, `whatsapp-formatting`) |
| `groups/<folder>/` | Per-agent-group filesystem (CLAUDE.md, skills) — agent-runner source is a shared read-only mount, not copied per group |
| `scripts/init-first-agent.ts` | Bootstrap the first DM-wired agent (used by `/init-first-agent` skill) |
| `migrate-v2.sh` + `setup/migrate-v2/` | v1→v2 migration. Standalone script: `bash migrate-v2.sh`. Seeds DB, copies groups/sessions, installs channels, builds container, offers service switchover, then hands off to `/migrate-from-v1` skill for owner setup and CLAUDE.md cleanup. See [docs/migration-dev.md](docs/migration-dev.md). |
| `nanoclaw.sh --uninstall` + `setup/uninstall/` | Uninstall this copy only (slug-scoped): service, containers + image, `data/`, `logs/`, `groups/`, this copy's OneCLI agents. Confirms per group; `--dry-run` previews, `--yes` skips prompts. Other copies and the shared OneCLI app are untouched. Bypasses bootstrap entirely; `uninstall.sh` is a pointer that execs it. |
@@ -182,7 +182,7 @@ Four types of skills. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxono
- **Channel/provider install skills** — copy the relevant module(s) in from the `channels` or `providers` branch, wire imports, install pinned deps (e.g. `/add-discord`, `/add-slack`, `/add-whatsapp`, `/add-opencode`).
- **Utility skills** — ship code files alongside `SKILL.md` (e.g. a `scripts/` CLI or helper).
- **Operational skills** — instruction-only workflows (`/setup`, `/debug`, `/customize`, `/init-first-agent`, `/manage-channels`, `/init-onecli`, `/update-nanoclaw`).
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `onecli-gateway`, `welcome`, `self-customize`, `agent-browser`, `slack-formatting`).
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `agent-browser`, `frontend-engineer`, `onecli-gateway`, `self-customize`, `slack-formatting`, `vercel-cli`, `welcome`, `whatsapp-formatting`).
| Skill | When to Use |
|-------|-------------|
+2 -2
View File
@@ -80,7 +80,7 @@ See [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) for what's different an
- **Per-agent workspace** — each agent group has its own `CLAUDE.md`, its own memory, its own container, and only the mounts you allow. Nothing crosses the boundary unless you wire it to.
- **Scheduled tasks** — recurring jobs that run Claude and can message you back
- **Web access** — search and fetch content from the web
- **Container isolation** — agents are sandboxed in Docker (macOS/Linux/WSL2), with optional [Docker Sandboxes](docs/docker-sandboxes.md) micro-VM isolation or Apple Container as a macOS-native opt-in
- **Container isolation** — agents are sandboxed in Docker (macOS/Linux/WSL2), with optional Docker Sandboxes micro-VM isolation
- **Credential security** — agents never hold raw API keys. Outbound requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects credentials at request time and enforces per-agent policies and rate limits.
- **Agent templates**: stamp a ready-to-run agent (instructions + MCP tools + skills, no secrets) from a reusable bundle, via the setup wizard or `ncl groups create --template <ref>`. Load from the [public library](https://github.com/nanocoai/nanoclaw-templates), a local folder, or any git repo. See [docs/templates.md](docs/templates.md).
@@ -165,7 +165,7 @@ Key files:
**Why Docker?**
Docker provides cross-platform support (macOS, Linux and Windows via WSL2) and a mature ecosystem. On macOS, Apple Container is also supported as a lighter-weight native runtime. For additional isolation, [Docker Sandboxes](docs/docker-sandboxes.md) run each container inside a micro VM.
Docker provides cross-platform support (macOS, Linux and Windows via WSL2) and a mature ecosystem. For additional isolation, Docker Sandboxes run each container inside a micro VM.
**Can I run this on Linux or Windows?**
@@ -1,29 +0,0 @@
/**
* Per-batch context the poll loop publishes for downstream consumers
* (MCP tools, etc.) that don't sit on the poll-loop's call stack.
*
* Today the only field is `inReplyTo` the id of the first inbound
* message in the batch the agent is currently processing. MCP tools like
* `send_message` and `send_file` read this and stamp it onto the outbound
* row so the host's a2a return-path routing can correlate replies back to
* the originating session.
*
* This is module-level state on purpose: the agent-runner is single-process
* and processes one batch at a time. Poll-loop calls `setCurrentInReplyTo`
* before invoking the provider and `clearCurrentInReplyTo` after the batch
* completes (or errors out).
*/
let currentInReplyTo: string | null = null;
export function setCurrentInReplyTo(id: string | null): void {
currentInReplyTo = id;
}
export function clearCurrentInReplyTo(): void {
currentInReplyTo = null;
}
export function getCurrentInReplyTo(): string | null {
return currentInReplyTo;
}
+5 -9
View File
@@ -48,7 +48,11 @@ export function openInboundDb(): Database {
// so the singleton survives for the rest of the test.
if (_testMode && _inbound) {
const db = _inbound;
return { prepare: (sql: string) => db.prepare(sql), exec: (sql: string) => db.exec(sql), close: () => {} } as unknown as Database;
return {
prepare: (sql: string) => db.prepare(sql),
exec: (sql: string) => db.exec(sql),
close: () => {},
} as unknown as Database;
}
const db = new Database(DEFAULT_INBOUND_PATH, { readonly: true });
db.exec('PRAGMA busy_timeout = 5000');
@@ -260,11 +264,3 @@ export function closeSessionDb(): void {
_outbound?.close();
_outbound = null;
}
/**
* @deprecated Use getInboundDb() / getOutboundDb() instead.
* Kept for backward compatibility during migration.
*/
export function getSessionDb(): Database {
return getInboundDb();
}
-1
View File
@@ -1,7 +1,6 @@
export {
getInboundDb,
getOutboundDb,
getSessionDb,
initTestSessionDb,
closeSessionDb,
touchHeartbeat,
@@ -77,3 +77,47 @@ export function setContinuation(providerName: string, id: string): void {
export function clearContinuation(providerName: string): void {
deleteValue(continuationKey(providerName));
}
/**
* The a2a reply stamp: the id of the first inbound message in the batch the
* agent is currently processing. The poll loop publishes it at batch start;
* MCP tools (`send_message`, `send_file`) read it and stamp it onto outbound
* rows so the host's a2a return-path routing can correlate replies back to
* the originating session.
*
* This lives in outbound.db rather than module state because the MCP server
* runs as a separate stdio subprocess from the poll loop module state set
* by the poll loop is invisible to it. Both processes open outbound.db
* (journal_mode=DELETE + busy_timeout make intra-container access safe).
*/
const IN_REPLY_TO_KEY = 'current_in_reply_to';
/**
* Ignore a stamp older than this. The poll loop clears the stamp in a
* finally, but a container killed mid-batch (SIGKILL) can leave one behind;
* the guard stops a later out-of-batch read from picking up a dead stamp.
* Generous so a long-running batch's late sends still stamp correctly.
*/
const IN_REPLY_TO_MAX_AGE_MS = 30 * 60 * 1000;
export function setCurrentInReplyTo(id: string | null): void {
if (id === null) {
clearCurrentInReplyTo();
return;
}
setValue(IN_REPLY_TO_KEY, id);
}
export function clearCurrentInReplyTo(): void {
deleteValue(IN_REPLY_TO_KEY);
}
export function getCurrentInReplyTo(): string | null {
const row = getOutboundDb()
.prepare('SELECT value, updated_at FROM session_state WHERE key = ?')
.get(IN_REPLY_TO_KEY) as { value: string; updated_at: string } | undefined;
if (!row) return null;
const age = Date.now() - new Date(row.updated_at).getTime();
if (!Number.isFinite(age) || age > IN_REPLY_TO_MAX_AGE_MS) return null;
return row.value;
}
@@ -4,14 +4,31 @@
* batch in poll-loop, and outbound writes from MCP tools (send_message,
* send_file) must pick it up so a2a return-path routing on the host can
* correlate replies back to the originating session.
*
* The stamp is published through session_state in outbound.db, not module
* state the MCP server runs as a separate stdio subprocess from the poll
* loop, so it can only see the stamp through the shared DB. These tests seed
* it the same way the poll-loop process does (a direct DB write) rather than
* via any in-memory helper, so they exercise the real process boundary.
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { initTestSessionDb, closeSessionDb, getInboundDb } from '../db/connection.js';
import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from '../db/connection.js';
import { getUndeliveredMessages } from '../db/messages-out.js';
import { setCurrentInReplyTo, clearCurrentInReplyTo } from '../current-batch.js';
import { sendMessage } from './core.js';
/**
* Publish the a2a reply stamp the way the poll loop does: a direct write to
* session_state in outbound.db. `ageMs` back-dates updated_at to exercise the
* staleness guard MCP tools apply when reading it.
*/
function publishInReplyTo(id: string, ageMs = 0): void {
const updatedAt = new Date(Date.now() - ageMs).toISOString();
getOutboundDb()
.prepare('INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES (?, ?, ?)')
.run('current_in_reply_to', id, updatedAt);
}
beforeEach(() => {
initTestSessionDb();
// Seed a peer agent destination
@@ -24,13 +41,12 @@ beforeEach(() => {
});
afterEach(() => {
clearCurrentInReplyTo();
closeSessionDb();
});
describe('send_message MCP tool — in_reply_to plumbing', () => {
it('stamps current batch in_reply_to on outbound rows', async () => {
setCurrentInReplyTo('inbound-msg-1');
it('stamps the batch in_reply_to (published via the DB) on outbound rows', async () => {
publishInReplyTo('inbound-msg-1');
await sendMessage.handler({ to: 'peer', text: 'hello' });
@@ -40,7 +56,17 @@ describe('send_message MCP tool — in_reply_to plumbing', () => {
});
it('writes null when no batch is active', async () => {
// No setCurrentInReplyTo before this call — simulates ad-hoc / out-of-batch invocation.
// Nothing published to session_state — simulates ad-hoc / out-of-batch invocation.
await sendMessage.handler({ to: 'peer', text: 'hello' });
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(out[0].in_reply_to).toBeNull();
});
it('ignores a stale stamp left behind by a killed container', async () => {
publishInReplyTo('inbound-msg-1', 60 * 60 * 1000); // an hour old
await sendMessage.handler({ to: 'peer', text: 'hello' });
const out = getUndeliveredMessages();
+1 -1
View File
@@ -9,9 +9,9 @@
import fs from 'fs';
import path from 'path';
import { getCurrentInReplyTo } from '../current-batch.js';
import { findByName, getAllDestinations } from '../destinations.js';
import { getMessageIdBySeq, getRoutingBySeq, writeMessageOut } from '../db/messages-out.js';
import { getCurrentInReplyTo } from '../db/session-state.js';
import { getSessionRouting } from '../db/session-routing.js';
import { registerTools } from './server.js';
import type { McpToolDefinition } from './types.js';
+7 -2
View File
@@ -2,8 +2,13 @@ import { findByName, getAllDestinations, type DestinationEntry } from './destina
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
import { writeMessageOut } from './db/messages-out.js';
import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
import { clearContinuation, migrateLegacyContinuation, setContinuation } from './db/session-state.js';
import { clearCurrentInReplyTo, setCurrentInReplyTo } from './current-batch.js';
import {
clearContinuation,
clearCurrentInReplyTo,
migrateLegacyContinuation,
setContinuation,
setCurrentInReplyTo,
} from './db/session-state.js';
import {
formatMessages,
extractRouting,
-90
View File
@@ -1,90 +0,0 @@
# Apple Container Networking Setup (macOS 26)
Apple Container's vmnet networking requires manual configuration for containers to access the internet. Without this, containers can communicate with the host but cannot reach external services (DNS, HTTPS, APIs).
## Quick Setup
Run these two commands (requires `sudo`):
```bash
# 1. Enable IP forwarding so the host routes container traffic
sudo sysctl -w net.inet.ip.forwarding=1
# 2. Enable NAT so container traffic gets masqueraded through your internet interface
echo "nat on en0 from 192.168.64.0/24 to any -> (en0)" | sudo pfctl -ef -
```
> **Note:** Replace `en0` with your active internet interface. Check with: `route get 8.8.8.8 | grep interface`
## Making It Persistent
These settings reset on reboot. To make them permanent:
**IP Forwarding** — add to `/etc/sysctl.conf`:
```
net.inet.ip.forwarding=1
```
**NAT Rules** — add to `/etc/pf.conf` (before any existing rules):
```
nat on en0 from 192.168.64.0/24 to any -> (en0)
```
Then reload: `sudo pfctl -f /etc/pf.conf`
## IPv6 DNS Issue
By default, DNS resolvers return IPv6 (AAAA) records before IPv4 (A) records. Since our NAT only handles IPv4, Node.js applications inside containers will try IPv6 first and fail.
The container image and runner are configured to prefer IPv4 via:
```
NODE_OPTIONS=--dns-result-order=ipv4first
```
This is set both in the `Dockerfile` and passed via `-e` flag in `container-runner.ts`.
## Verification
```bash
# Check IP forwarding is enabled
sysctl net.inet.ip.forwarding
# Expected: net.inet.ip.forwarding: 1
# Test container internet access
container run --rm --entrypoint curl nanoclaw-agent:latest \
-s4 --connect-timeout 5 -o /dev/null -w "%{http_code}" https://api.anthropic.com
# Expected: 404
# Check bridge interface (only exists when a container is running)
ifconfig bridge100
```
## Troubleshooting
| Symptom | Cause | Fix |
|---------|-------|-----|
| `curl: (28) Connection timed out` | IP forwarding disabled | `sudo sysctl -w net.inet.ip.forwarding=1` |
| HTTP works, HTTPS times out | IPv6 DNS resolution | Add `NODE_OPTIONS=--dns-result-order=ipv4first` |
| `Could not resolve host` | DNS not forwarded | Check bridge100 exists, verify pfctl NAT rules |
| Container hangs after output | Missing `process.exit(0)` in agent-runner | Rebuild container image |
## How It Works
```
Container VM (192.168.64.x)
├── eth0 → gateway 192.168.64.1
bridge100 (192.168.64.1) ← host bridge, created by vmnet when container runs
├── IP forwarding (sysctl) routes packets from bridge100 → en0
├── NAT (pfctl) masquerades 192.168.64.0/24 → en0's IP
en0 (your WiFi/Ethernet) → Internet
```
## References
- [apple/container#469](https://github.com/apple/container/issues/469) — No network from container on macOS 26
- [apple/container#656](https://github.com/apple/container/issues/656) — Cannot access internet URLs during building
-3
View File
@@ -6,8 +6,5 @@ The files in this directory are original design documents and developer referenc
| This directory | Documentation site |
|---|---|
| [SPEC.md](SPEC.md) | [Architecture](https://docs.nanoclaw.dev/concepts/architecture) |
| [SECURITY.md](SECURITY.md) | [Security model](https://docs.nanoclaw.dev/concepts/security) |
| [REQUIREMENTS.md](REQUIREMENTS.md) | [Introduction](https://docs.nanoclaw.dev/introduction) |
| [docker-sandboxes.md](docker-sandboxes.md) | [Docker Sandboxes](https://docs.nanoclaw.dev/advanced/docker-sandboxes) |
| [APPLE-CONTAINER-NETWORKING.md](APPLE-CONTAINER-NETWORKING.md) | [Container runtime](https://docs.nanoclaw.dev/advanced/container-runtime) |
+106 -60
View File
@@ -1,70 +1,106 @@
# NanoClaw Security Model
> The canonical, continuously-verified version of this model lives at
> [docs.nanoclaw.dev/concepts/security](https://docs.nanoclaw.dev/concepts/security).
> This in-repo copy can drift; if the two disagree, verify against
> `src/container-runner.ts` (`buildMounts`).
## Trust Model
Privilege is **user-level**, persisted in the `user_roles` table (owner /
admin, global or scoped to an agent group) plus `agent_group_members` (the
unprivileged access gate).
| Entity | Trust Level | Rationale |
|--------|-------------|-----------|
| Main group | Trusted | Private self-chat, admin control |
| Non-main groups | Untrusted | Other users may be malicious |
| Container agents | Sandboxed | Isolated execution environment |
| Incoming messages | User input | Potential prompt injection |
| Owners / admins (`user_roles`) | Trusted | Hold owner/admin roles; gate admin commands and approve credentialed actions |
| Group members (`agent_group_members`) | Access-gated | Membership grants access to an agent group, but their messages are still untrusted input |
| Unregistered senders | Untrusted | Subject to each messaging group's `unknown_sender_policy` |
| Agent containers | Sandboxed | Long-lived per-session container; isolated by mounts, non-root, no host reach |
| Incoming messages | User input | Potential prompt injection regardless of who sent them |
## Security Boundaries
### 1. Container Isolation (Primary Boundary)
Agents execute in containers (lightweight Linux VMs), providing:
- **Process isolation** - Container processes cannot affect the host
- **Filesystem isolation** - Only explicitly mounted directories are visible
- **Non-root execution** - Runs as unprivileged `node` user (uid 1000)
- **Ephemeral containers** - Fresh environment per invocation (`--rm`)
Agents execute in containers (Docker), providing:
- **Process isolation** — container processes cannot affect the host
- **Filesystem isolation** — only explicitly mounted directories are visible
- **Non-root execution** — runs as an unprivileged user (`node`, uid 1000, or the host uid remapped in)
- **Per-session containers** — one long-lived container per session polls that session's DBs and handles many messages, then is torn down (`--rm`) when the session goes idle.
This is the primary security boundary. Rather than relying on application-level permission checks, the attack surface is limited by what's mounted.
This is the primary security boundary. Rather than relying on application-level
permission checks, the attack surface is limited by what's mounted.
### 2. Mount Security
**External Allowlist** - Mount permissions stored at `~/.config/nanoclaw/mount-allowlist.json`, which is:
- Outside project root
- Never mounted into containers
- Cannot be modified by agents
`buildMounts` (`src/container-runner.ts`) composes a fixed set of mounts per
spawn. For the default (Claude) provider these are:
**Default Blocked Patterns:**
| Container path | Host source | Mode | Purpose |
|---|---|---|---|
| `/workspace` | `data/v2-sessions/<group>/<session>/` | RW | Session folder — `inbound.db`, `outbound.db`, `outbox/`, `.claude/` |
| `/workspace/agent` | `groups/<folder>/` | RW | Agent group working files + `CLAUDE.local.md` |
| `/workspace/agent/container.json` | group `container.json` | RO | Container config — readable, not writable |
| `/workspace/agent/CLAUDE.md` | composed `CLAUDE.md` | RO | Regenerated every spawn; agent edits would be clobbered |
| `/workspace/agent/.claude-fragments` | group `.claude-fragments/` | RO | Composer skill/MCP fragments |
| `/app/CLAUDE.md` | `container/CLAUDE.md` | RO | Shared base doc imported by the composed entry point |
| `/home/node/.claude` | `data/v2-sessions/<group>/.claude-shared/` | RW | Claude state, settings, skill symlinks |
| `/app/src` | `container/agent-runner/src/` | RO | Shared agent-runner source (same for all groups) |
| `/app/skills` | `container/skills/` | RO | Shared container skills |
| `/workspace/extra/<name>` | allowlisted host dir | RO (RW only if allowed) | Operator-configured additional mounts |
The config mounts (`container.json`, `CLAUDE.md`, `.claude-fragments`) are
**nested read-only mounts on top of the read-write group dir** — the agent can
read its config but cannot modify it. The project root is **never mounted**: the
container only ever sees the paths above plus any provider-contributed mounts
(e.g. an OpenCode XDG dir). Host application source (`src/`, `dist/`,
`package.json`) is not reachable.
**Additional-mount allowlist** — extra mounts from a group's container config
are validated against an allowlist at `~/.config/nanoclaw/mount-allowlist.json`,
which is:
- Outside the project root
- Never mounted into containers
- Not modifiable by agents
Its schema:
```json
{
"allowedRoots": [
{ "path": "~/projects", "allowReadWrite": true, "description": "Dev projects" },
{ "path": "~/Documents/work", "allowReadWrite": false, "description": "Read-only" }
],
"blockedPatterns": ["password", "secret", "token"]
}
```
.ssh, .gnupg, .aws, .azure, .gcloud, .kube, .docker,
credentials, .env, .netrc, .npmrc, id_rsa, id_ed25519,
**Default blocked patterns** (merged with any in the file):
```
.ssh, .gnupg, .gpg, .aws, .azure, .gcloud, .kube, .docker,
credentials, .env, .netrc, .npmrc, .pypirc, id_rsa, id_ed25519,
private_key, .secret
```
**Protections:**
- Symlink resolution before validation (prevents traversal attacks)
- Container path validation (rejects `..` and absolute paths)
- `nonMainReadOnly` option forces read-only for non-main groups
**Read-Only Project Root:**
The main group's project root is mounted read-only. Writable paths the agent needs (store, group folder, IPC, `.claude/`) are mounted separately. This prevents the agent from modifying host application code (`src/`, `dist/`, `package.json`, etc.) which would bypass the sandbox entirely on next restart. The `store/` directory is mounted read-write so the main agent can access the SQLite database directly.
**Enforcement** (`src/modules/mount-security/index.ts`):
- **No allowlist file ⇒ every additional mount is blocked** — the fixed mounts above are unaffected, but nothing extra is granted until the operator creates the file.
- Symlinks are resolved to their real path (`realpathSync`) before any check, defeating traversal via symlink.
- The real path is rejected if it matches a blocked pattern, and rejected unless it sits under one of `allowedRoots`.
- The container path is validated: relative, non-empty, no `..`, no leading `/`, no `:` (blocks Docker `-v` option injection). It is mounted under `/workspace/extra/`.
- **Read-write is granted only when the mount requests it (`readonly: false`) *and* the matched root has `allowReadWrite: true`.** Otherwise the mount is forced read-only.
### 3. Session Isolation
Each group has isolated Claude sessions at `data/sessions/{group}/.claude/`:
- Groups cannot see other groups' conversation history
- Session data includes full message history and file contents read
- Prevents cross-group information disclosure
Per-session state lives under `data/v2-sessions/<agent-group>/<session>/`
(`inbound.db`, `outbound.db`, `outbox/`, `.claude/`). Claude state
(`.claude-shared`) and the working folder are scoped to the agent group, so:
- Different agent groups cannot see each other's conversation history or files.
- A group's sessions share that group's memory but keep separate message DBs.
### 4. IPC Authorization
This prevents cross-group information disclosure.
Messages and task operations are verified against group identity:
| Operation | Main Group | Non-Main Group |
|-----------|------------|----------------|
| Send message to own chat | ✓ | ✓ |
| Send message to other chats | ✓ | ✗ |
| Schedule task for self | ✓ | ✓ |
| Schedule task for others | ✓ | ✗ |
| View all tasks | ✓ | Own only |
| Manage other groups | ✓ | ✗ |
### 5. Credential Isolation (OneCLI Agent Vault)
### 4. Credential Isolation (OneCLI Agent Vault)
Real API credentials **never enter containers**. NanoClaw uses [OneCLI's Agent Vault](https://github.com/onecli/onecli) to proxy outbound requests and inject credentials at the gateway level.
@@ -77,13 +113,12 @@ Real API credentials **never enter containers**. NanoClaw uses [OneCLI's Agent V
**Per-agent policies:**
Each NanoClaw group gets its own OneCLI agent identity. This allows different credential policies per group (e.g. your sales agent vs. support agent). OneCLI supports rate limits, and time-bound access and approval flows are on the roadmap.
**NOT Mounted:**
- Channel auth sessions (`store/auth/`) — host only
- Mount allowlist — external, never mounted
- Any credentials matching blocked patterns
- `.env` is shadowed with `/dev/null` in the project root mount
**Never on the container filesystem:**
- The project root and `.env` — never mounted; the container only receives the paths in the mount table above.
- The mount allowlist — external (`~/.config/nanoclaw/…`), never mounted.
- Real credentials — injected per request by the OneCLI gateway, never written into any mount.
### 6. Egress Lockdown (Forced Proxy)
### 5. Egress Lockdown (Forced Proxy)
The `HTTPS_PROXY` env var only redirects *proxy-aware* clients — a tool that
ignores it (or a raw socket) could reach the internet directly and bypass
@@ -111,31 +146,42 @@ no `host-gateway` route).
exception: a heal failure there is logged but not fatal, since already-running
agents stay on the internal net (no leak) until the gateway returns.
**Default: egress is open.** Lockdown is **off** unless you opt in; by default
the agent reaches the OneCLI gateway over the host-gateway path and outbound
traffic is not confined to the internal network.
**Configuration:**
| Env | Default | Meaning |
| --- | --- | --- |
| `NANOCLAW_EGRESS_LOCKDOWN` | `false` | Set `true` to opt in (otherwise the host-gateway path is used). Enabled automatically by `/add-golden-registry`. |
| `NANOCLAW_EGRESS_LOCKDOWN` | `false` | Set `true` to opt in (otherwise the host-gateway path is used). |
| `NANOCLAW_EGRESS_NETWORK` | `nanoclaw-egress` | Network name. |
| `ONECLI_GATEWAY_CONTAINER` | `onecli` | Gateway container to attach. |
These variables are read from the **host process** environment (the service's
environment / `.env`), not from inside the container. The agent container is
started with only `TZ` and any provider-declared variables — host environment
variables, including secrets, are never forwarded into the agent.
**⚠ Behavior when enabled:** with lockdown on, agents have **no direct
internet** — all traffic must go through OneCLI. Proxy-aware clients (npm, pnpm,
pip, curl, node/bun with the proxy env) are unaffected. Any workflow that relies
on a **non-proxy-aware** tool reaching the internet directly will fail by design.
Lockdown is **off by default**; opt in with `NANOCLAW_EGRESS_LOCKDOWN=true`.
## Privilege Comparison
## Resource Limits
| Capability | Main Group | Non-Main Group |
|------------|------------|----------------|
| Project root access | `/workspace/project` (ro) | None |
| Store (SQLite DB) | `/workspace/project/store` (rw) | None |
| Group folder | `/workspace/group` (rw) | `/workspace/group` (rw) |
| Global memory | Implicit via project | `/workspace/global` (ro) |
| Additional mounts | Configurable | Read-only unless allowed |
| Network access | Unrestricted | Unrestricted |
| MCP tools | All | All |
Per-container CPU and memory caps are **opt-in and unset by default** — a runaway
agent is not throttled unless the operator configures a limit:
| Env | Default | Meaning |
| --- | --- | --- |
| `CONTAINER_CPU_LIMIT` | *(empty — unbounded)* | Passed to `--cpus` when set (e.g. `2`). |
| `CONTAINER_MEMORY_LIMIT` | *(empty — unbounded)* | Passed to `--memory` when set (e.g. `8g`). |
Only `--memory` is a container-level cap; whether it's a *hard* cap depends on
the host having no swap (a deployment concern). On a swapless host a runaway is
OOM-killed at the limit.
## Security Architecture Diagram
@@ -149,7 +195,7 @@ Lockdown is **off by default**; opt in with `NANOCLAW_EGRESS_LOCKDOWN=true`.
┌──────────────────────────────────────────────────────────────────┐
│ HOST PROCESS (TRUSTED) │
│ • Message routing │
│ • IPC authorization
│ • Role / access checks (user_roles, agent_group_members)
│ • Mount validation (external allowlist) │
│ • Container lifecycle │
│ • OneCLI Agent Vault (injects credentials, enforces policies) │
+2
View File
@@ -1,5 +1,7 @@
# NanoClaw Specification
> **⚠️ Historical v1 spec.** This document describes the original NanoClaw v1 architecture — the single `store/messages.db`, the file-based IPC watcher, the `task-scheduler.ts` loop, the `MAX_CONCURRENT_CONTAINERS` cap, and the `groups/{channel}_{name}/` folder convention. **None of these exist in v2.** v2 replaced them with the two-DB session split (`inbound.db`/`outbound.db`), the entity model (users → messaging groups → agent groups → sessions), and the system-action delivery path. Kept for reference only. For the current architecture start at [architecture.md](architecture.md) and the root [CLAUDE.md](../CLAUDE.md); the v1→v2 diff is in [v1-to-v2-changes.md](v1-to-v2-changes.md).
A personal Claude assistant with multi-channel support, persistent memory per conversation, scheduled tasks, and container-isolated agent execution.
---
+6 -6
View File
@@ -596,7 +596,7 @@ Schedule a one-shot or recurring task.
}
```
Implementation: write a `messages_in` row (to self) with `kind: 'task'`, `process_after`, and optionally `recurrence`. The host sweep picks it up when due.
Implementation: the container can't write host-owned `inbound.db`, so this writes a `messages_out` row with `kind: 'system'` and `action: 'schedule_task'` (`container/agent-runner/src/mcp-tools/scheduling.ts`). During delivery the host's action handler (`src/modules/scheduling/actions.ts``insertTask()` in `src/modules/scheduling/db.ts`) inserts the `kind: 'task'` row into `inbound.db` with `process_after` and optionally `recurrence`. The host sweep picks it up when due.
#### list_tasks
@@ -609,7 +609,7 @@ List active scheduled/recurring tasks.
}
```
Implementation: query `messages_in WHERE recurrence IS NOT NULL AND status != 'failed'`.
Implementation: a read, not a write — the container may read the read-only `inbound.db` mount directly. Returns one row per series (the live pending/paused occurrence): `SELECT series_id AS id, ... FROM messages_in WHERE kind = 'task' AND status IN ('pending','paused') GROUP BY series_id`. See `container/agent-runner/src/mcp-tools/scheduling.ts`.
#### cancel_task / pause_task / resume_task / update_task
@@ -625,7 +625,7 @@ Modify a scheduled task.
// update_task: merge { prompt?, recurrence?, processAfter?, script? } into the live row
```
Implementation: cancel/pause/resume update the live row(s) directly. update_task is sent as a system action — the host reads current content, merges supplied fields, and writes back. All four match by `(id = ? OR series_id = ?) AND kind='task' AND status IN ('pending','paused')`, so they reach the live next occurrence of a recurring task even when the agent passes the original (now-completed) id.
Implementation: all four are sent as system actions (`messages_out`, `kind: 'system'`, `action: 'cancel_task' | 'pause_task' | 'resume_task' | 'update_task'`) — the container never writes `inbound.db`. The host's handlers in `src/modules/scheduling/actions.ts` apply the change against `inbound.db` via `src/modules/scheduling/db.ts`: cancel/pause/resume flip status on the live row(s); update_task reads current content, merges supplied fields, and writes back. All four match by `(id = ? OR series_id = ?) AND kind='task' AND status IN ('pending','paused')`, so they reach the live next occurrence of a recurring task even when the agent passes the original (now-completed) id.
#### register_agent_group
@@ -712,9 +712,9 @@ These are ephemeral to the container's lifetime. When the container is killed an
The agent-runner receives configuration via:
- **Environment variables:** `AGENT_PROVIDER` (claude/codex/opencode), `NANOCLAW_ADMIN_USER_ID`, provider-specific vars (API keys, model overrides), `TZ`
- **`container.json`:** The provider name, model, assistant name, MCP servers, and other NanoClaw config are read from `/workspace/agent/container.json` (materialized by the host from the `container_configs` table), not from environment variables. See `container/agent-runner/src/config.ts`.
- **Environment variables:** provider-specific vars only (API keys, model overrides), `TZ`.
- **Fixed mount paths:** Session DB at `/workspace/session.db`. Agent group folder at `/workspace/agent/`. System prompt from `/workspace/agent/CLAUDE.md` and `/workspace/global/CLAUDE.md`.
- **Optional startup config:** Some config may be passed as a JSON file at a fixed path (e.g., `/workspace/config.json`) for things like the session ID to resume, assistant name, and admin user ID. This avoids overloading environment variables.
The agent-runner reads config, creates the provider, and enters the poll loop. No stdin, no initial prompt — messages are already in the session DB.
@@ -731,7 +731,7 @@ function createProvider(name: ProviderName, config: ProviderConfig): AgentProvid
}
```
The provider name comes from the container's environment (`AGENT_PROVIDER` env var), set by the host based on `agent_groups.agent_provider` or `sessions.agent_provider`.
The provider name comes from the `provider` key in `/workspace/agent/container.json` (defaulting to `'claude'`), which the host materializes from the `container_configs` table — set it with `ncl groups config update --provider`. It is not an environment variable.
`ProviderConfig` contains provider-specific settings (API keys, model overrides, etc.) passed via environment variables — not via the interface. Each provider reads what it needs from `env`.
+8 -7
View File
@@ -1,5 +1,7 @@
# NanoClaw Architecture (Draft)
> **Draft — design intent, not a line-by-line spec.** Some passages predate the current implementation and can drift from it. The root [CLAUDE.md](../CLAUDE.md) and the cited source files (`src/`, `container/agent-runner/src/`) are the source of truth; when this doc and the code disagree, trust the code. Notably, scheduling MCP tools do **not** write `inbound.db` directly — they emit `messages_out` system actions that the host applies (see [agent-runner-details.md](agent-runner-details.md) and `src/modules/scheduling/`).
## Core Idea
Each agent session has a mounted SQLite DB. The DB is the one and only IO mechanism between host and container. No IPC files, no stdin piping. Two tables: messages_in (host → agent-runner) and messages_out (agent-runner → host). Everything is a message.
@@ -128,7 +130,6 @@ Non-Chat-SDK channels (WhatsApp via Baileys, Gmail, custom integrations) impleme
The host is an orchestrator:
1. **Spawn** — when wakeUpAgent is called and no container exists for the session
2. **Idle kill** — when a container has no unprocessed messages for some timeout period
3. **Limits** — MAX_CONCURRENT_CONTAINERS caps active containers
When a container spins up, the agent-runner immediately starts polling its session DB. Messages are already there waiting.
@@ -244,7 +245,7 @@ One-shot and recurring tasks use the same tables — no separate scheduler.
**Active container poll** (~1s) checks the same conditions but only for sessions with running containers.
**Agent-runner creates schedules** by writing messages_in (to itself) or messages_out (reminders/notifications) with `process_after` and optionally `recurrence`.
**Agent-runner creates schedules** by emitting a `messages_out` row with `kind: 'system'` and an `action` (`schedule_task`, `cancel_task`, …) — it cannot write host-owned `inbound.db` directly. The host applies the action during delivery (`src/modules/scheduling/actions.ts`), inserting/updating the `kind: 'task'` `messages_in` row with `process_after` and optionally `recurrence`.
### messages_in content by kind
@@ -555,7 +556,7 @@ const DISCORD_TOKEN = process.env.DISCORD_BOT_TOKEN;
const GMAIL_CREDS = process.env.GMAIL_CREDENTIALS_PATH;
```
Shared config (DATA_DIR, TIMEZONE, MAX_CONCURRENT_CONTAINERS) stays in `config.ts`. Channel/skill-specific config stays in the module that uses it.
Shared config (DATA_DIR, TIMEZONE) stays in `config.ts`. Channel/skill-specific config stays in the module that uses it.
### Code Style
@@ -829,7 +830,7 @@ Mixed batches (e.g., a chat message + a system result both pending) are combined
### MCP Tools
MCP tools write directly to the session DB.
MCP tools write to the container's own `outbound.db`. Anything that needs a change in host-owned `inbound.db` (schedule/cancel/pause/resume/update a task, register a group) is emitted as a `kind: 'system'` `messages_out` action that the host applies during delivery — the container never writes `inbound.db`.
**Core tools:**
@@ -837,9 +838,9 @@ MCP tools write directly to the session DB.
|------|-------------|
| `send_message` | Write `messages_out` row, `kind: 'chat'` |
| `send_file` | Move file to `outbox/{msg_id}/`, write `messages_out` with filenames |
| `schedule_task` | Write `messages_in` row (to self) with `process_after` + `recurrence`. Or `messages_out` with `deliver_after` for outbound reminders. |
| `list_tasks` | Query `messages_in WHERE recurrence IS NOT NULL` |
| `pause_task` / `resume_task` / `cancel_task` | Modify `messages_in` rows (update status, clear/set recurrence) |
| `schedule_task` | Write `messages_out`, `kind: 'system'`, `action: 'schedule_task'`; host inserts the `kind: 'task'` `messages_in` row with `process_after` + optional `recurrence` |
| `list_tasks` | Read `messages_in` (read-only mount) — one row per series: `kind = 'task' AND status IN ('pending','paused') GROUP BY series_id` |
| `pause_task` / `resume_task` / `cancel_task` | Write `messages_out`, `kind: 'system'`, matching `action`; host updates the live `messages_in` row(s) |
| `register_agent_group` | Write `messages_out`, `kind: 'system'`, `action: 'register_agent_group'` |
**New tools:**
+2 -2
View File
@@ -17,7 +17,7 @@ data/v2-sessions/<agent_group_id>/<session_id>/
outbox/<message_id>/ ← attachments the agent produced
```
One session = one folder = one pair of DBs. The `agent_group_id` parent directory also holds per-group state (`.claude-shared/`, `agent-runner-src/`) that is shared across every session of that agent group.
One session = one folder = one pair of DBs. The `agent_group_id` parent directory also holds per-group state (`.claude-shared/`) that is shared across every session of that agent group. (The agent-runner source is not copied per group — it's a shared read-only mount from `container/agent-runner/src` into every container; see `src/container-runner.ts`.)
Path helpers in `src/session-manager.ts`: `sessionDir()`, `inboundDbPath()`, `outboundDbPath()`, `heartbeatPath()`.
@@ -55,7 +55,7 @@ CREATE INDEX idx_messages_in_series ON messages_in(series_id);
Content shapes: see [api-details.md §Session DB Schema Details](api-details.md#session-db-schema-details).
**Writers (host):** `insertMessage()`, `insertTask()`, `insertRecurrence()` — all in `src/db/session-db.ts`. Each calls `nextEvenSeq()`.
**Writers (host):** `insertMessage()` (and `nextEvenSeq()`) in `src/db/session-db.ts`; `insertTask()` and `insertRecurrence()` in `src/modules/scheduling/db.ts`. Each calls `nextEvenSeq()`.
**Reader (container):** `container/agent-runner/src/db/messages-in.ts` — polls `status='pending' AND (process_after IS NULL OR process_after <= now)`.
### 2.2 `delivered`
-1
View File
@@ -35,7 +35,6 @@ data/
v2-sessions/
<agent_group_id>/
.claude-shared/ ← shared Claude state for the agent group
agent-runner-src/ ← per-group agent-runner overlay
<session_id>/
inbound.db ← host writes, container reads
outbound.db ← container writes, host reads
-359
View File
@@ -1,359 +0,0 @@
# Running NanoClaw in Docker Sandboxes (Manual Setup)
This guide walks through setting up NanoClaw inside a [Docker Sandbox](https://docs.docker.com/ai/sandboxes/) from scratch — no install script, no pre-built fork. You'll clone the upstream repo, apply the necessary patches, and have agents running in full hypervisor-level isolation.
## Architecture
```
Host (macOS / Windows WSL)
└── Docker Sandbox (micro VM with isolated kernel)
├── NanoClaw process (Node.js)
│ ├── Channel adapters (WhatsApp, Telegram, etc.)
│ └── Container spawner → nested Docker daemon
└── Docker-in-Docker
└── nanoclaw-agent containers
└── Claude Agent SDK
```
Each agent runs in its own container, inside a micro VM that is fully isolated from your host. Two layers of isolation: per-agent containers + the VM boundary.
The sandbox provides a MITM proxy at `host.docker.internal:3128` that handles network access and injects your Anthropic API key automatically.
> **Note:** This guide is based on a validated setup running on macOS (Apple Silicon) with WhatsApp. Other channels (Telegram, Slack, etc.) and environments (Windows WSL) may require additional proxy patches for their specific HTTP/WebSocket clients. The core patches (container runner, credential proxy, Dockerfile) apply universally — channel-specific proxy configuration varies.
## Prerequisites
- **Docker Desktop v4.40+** with Sandbox support
- **Anthropic API key** (the sandbox proxy manages injection)
- For **Telegram**: a bot token from [@BotFather](https://t.me/BotFather) and your chat ID
- For **WhatsApp**: a phone with WhatsApp installed
Verify sandbox support:
```bash
docker sandbox version
```
## Step 1: Create the Sandbox
On your host machine:
```bash
# Create a workspace directory
mkdir -p ~/nanoclaw-workspace
# Create a shell sandbox with the workspace mounted
docker sandbox create shell ~/nanoclaw-workspace
```
If you're using WhatsApp, configure proxy bypass so WhatsApp's Noise protocol isn't MITM-inspected:
```bash
docker sandbox network proxy shell-nanoclaw-workspace \
--bypass-host web.whatsapp.com \
--bypass-host "*.whatsapp.com" \
--bypass-host "*.whatsapp.net"
```
Telegram does not need proxy bypass.
Enter the sandbox:
```bash
docker sandbox run shell-nanoclaw-workspace
```
## Step 2: Install Prerequisites
Inside the sandbox:
```bash
sudo apt-get update && sudo apt-get install -y build-essential python3
npm config set strict-ssl false
```
## Step 3: Clone and Install NanoClaw
NanoClaw must live inside the workspace directory — Docker-in-Docker can only bind-mount from the shared workspace path.
```bash
# Clone to home first (virtiofs can corrupt git pack files during clone)
cd ~
git clone https://github.com/nanocoai/nanoclaw.git
# Replace with YOUR workspace path (the host path you passed to `docker sandbox create`)
WORKSPACE=/Users/you/nanoclaw-workspace
# Move into workspace so DinD mounts work
mv nanoclaw "$WORKSPACE/nanoclaw"
cd "$WORKSPACE/nanoclaw"
# Install dependencies
pnpm install
pnpm install https-proxy-agent
```
## Step 4: Apply Proxy and Sandbox Patches
NanoClaw needs several patches to work inside a Docker Sandbox. These handle proxy routing, CA certificates, and Docker-in-Docker mount restrictions.
### 4a. Dockerfile — proxy args for container image build
`pnpm install` inside `docker build` fails with `SELF_SIGNED_CERT_IN_CHAIN` because the sandbox's MITM proxy presents its own certificate. Add proxy build args to `container/Dockerfile`:
Add these lines after the `FROM` line:
```dockerfile
# Accept proxy build args
ARG http_proxy
ARG https_proxy
ARG no_proxy
ARG NODE_EXTRA_CA_CERTS
ARG npm_config_strict_ssl=true
RUN npm config set strict-ssl ${npm_config_strict_ssl}
```
And after the `RUN pnpm install` line:
```dockerfile
RUN npm config set strict-ssl true
```
### 4b. Build script — forward proxy args
Patch `container/build.sh` to pass proxy env vars to `docker build`:
Add these `--build-arg` flags to the `docker build` command:
```bash
--build-arg http_proxy="${http_proxy:-$HTTP_PROXY}" \
--build-arg https_proxy="${https_proxy:-$HTTPS_PROXY}" \
--build-arg no_proxy="${no_proxy:-$NO_PROXY}" \
--build-arg npm_config_strict_ssl=false \
```
### 4c. Container runner — proxy forwarding, CA cert mount, /dev/null fix
Three changes to `src/container-runner.ts`:
**Replace `/dev/null` shadow mount.** The sandbox rejects `/dev/null` bind mounts. Find where `.env` is shadow-mounted to `/dev/null` and replace it with an empty file:
```typescript
// Create an empty file to shadow .env (Docker Sandbox rejects /dev/null mounts)
const emptyEnvPath = path.join(DATA_DIR, 'empty-env');
if (!fs.existsSync(emptyEnvPath)) fs.writeFileSync(emptyEnvPath, '');
// Use emptyEnvPath instead of '/dev/null' in the mount
```
**Forward proxy env vars** to spawned agent containers. Add `-e` flags for `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY` and their lowercase variants.
**Mount CA certificate.** If `NODE_EXTRA_CA_CERTS` or `SSL_CERT_FILE` is set, copy the cert into the project directory and mount it into agent containers:
```typescript
const caCertSrc = process.env.NODE_EXTRA_CA_CERTS || process.env.SSL_CERT_FILE;
if (caCertSrc) {
const certDir = path.join(DATA_DIR, 'ca-cert');
fs.mkdirSync(certDir, { recursive: true });
fs.copyFileSync(caCertSrc, path.join(certDir, 'proxy-ca.crt'));
// Mount: certDir -> /workspace/ca-cert (read-only)
// Set NODE_EXTRA_CA_CERTS=/workspace/ca-cert/proxy-ca.crt in the container
}
```
### 4d. Container runtime — prevent self-termination
In `src/container-runtime.ts`, the `cleanupOrphans()` function matches containers by the `nanoclaw-` prefix. Inside a sandbox, the sandbox container itself may match (e.g., `nanoclaw-docker-sandbox`). Filter out the current hostname:
```typescript
// In cleanupOrphans(), filter out os.hostname() from the list of containers to stop
```
### 4e. Credential proxy — route through MITM proxy
In `src/credential-proxy.ts`, upstream API requests need to go through the sandbox proxy. Add `HttpsProxyAgent` to outbound requests:
```typescript
import { HttpsProxyAgent } from 'https-proxy-agent';
const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy;
const upstreamAgent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
// Pass upstreamAgent to https.request() options
```
### 4f. Setup script — proxy build args
Patch `setup/container.ts` to pass the same proxy `--build-arg` flags as `build.sh` (Step 4b).
## Step 5: Build
```bash
pnpm run build
bash container/build.sh
```
## Step 6: Add a Channel
### Telegram
```bash
# Apply the Telegram skill
pnpm exec tsx scripts/apply-skill.ts .claude/skills/add-telegram
# Rebuild after applying the skill
pnpm run build
# Configure .env
cat > .env << EOF
TELEGRAM_BOT_TOKEN=<your-token-from-botfather>
ASSISTANT_NAME=nanoclaw
ANTHROPIC_API_KEY=proxy-managed
EOF
mkdir -p data/env && cp .env data/env/env
# Register your chat
pnpm exec tsx setup/index.ts --step register \
--jid "tg:<your-chat-id>" \
--name "My Chat" \
--trigger "@nanoclaw" \
--folder "telegram_main" \
--channel telegram \
--assistant-name "nanoclaw" \
--is-main \
--no-trigger-required
```
**To find your chat ID:** Send any message to your bot, then:
```bash
curl -s --proxy $HTTPS_PROXY "https://api.telegram.org/bot<TOKEN>/getUpdates" | python3 -m json.tool
```
**Telegram in groups:** Disable Group Privacy in @BotFather (`/mybots` > Bot Settings > Group Privacy > Turn off), then remove and re-add the bot.
**Important:** If the Telegram skill creates `src/channels/telegram.ts`, you'll need to patch it for proxy support. Add an `HttpsProxyAgent` and pass it to grammy's `Bot` constructor via `baseFetchConfig.agent`. Then rebuild.
### WhatsApp
Make sure you configured proxy bypass in [Step 1](#step-1-create-the-sandbox) first.
```bash
# Apply the WhatsApp skill
pnpm exec tsx scripts/apply-skill.ts .claude/skills/add-whatsapp
# Rebuild
pnpm run build
# Configure .env
cat > .env << EOF
ASSISTANT_NAME=nanoclaw
ANTHROPIC_API_KEY=proxy-managed
EOF
mkdir -p data/env && cp .env data/env/env
# Authenticate (choose one):
# QR code — scan with WhatsApp camera:
pnpm exec tsx src/whatsapp-auth.ts
# OR pairing code — enter code in WhatsApp > Linked Devices > Link with phone number:
pnpm exec tsx src/whatsapp-auth.ts --pairing-code --phone <phone-number-no-plus>
# Register your chat (JID = your phone number + @s.whatsapp.net)
pnpm exec tsx setup/index.ts --step register \
--jid "<phone>@s.whatsapp.net" \
--name "My Chat" \
--trigger "@nanoclaw" \
--folder "whatsapp_main" \
--channel whatsapp \
--assistant-name "nanoclaw" \
--is-main \
--no-trigger-required
```
**Important:** The WhatsApp skill files (`src/channels/whatsapp.ts` and `src/whatsapp-auth.ts`) also need proxy patches — add `HttpsProxyAgent` for WebSocket connections and a proxy-aware version fetch. Then rebuild.
### Both Channels
Apply both skills, patch both for proxy support, combine the `.env` variables, and register each chat separately.
## Step 7: Run
```bash
pnpm start
```
You don't need to set `ANTHROPIC_API_KEY` manually. The sandbox proxy intercepts requests and replaces `proxy-managed` with your real key automatically.
## Networking Details
### How the proxy works
All traffic from the sandbox routes through the host proxy at `host.docker.internal:3128`:
```
Agent container → DinD bridge → Sandbox VM → host.docker.internal:3128 → Host proxy → api.anthropic.com
```
**"Bypass" does not mean traffic skips the proxy.** It means the proxy passes traffic through without MITM inspection. Node.js doesn't automatically use `HTTP_PROXY` env vars — you need explicit `HttpsProxyAgent` configuration in every HTTP/WebSocket client.
### Shared paths for DinD mounts
Only the workspace directory is available for Docker-in-Docker bind mounts. Paths outside the workspace fail with "path not shared":
- `/dev/null` → replace with an empty file in the project dir
- `/usr/local/share/ca-certificates/` → copy cert to project dir
- `/home/agent/` → clone to workspace instead
### Git clone and virtiofs
The workspace is mounted via virtiofs. Git's pack file handling can corrupt over virtiofs during clone. Workaround: clone to `/home/agent` first, then `mv` into the workspace.
## Troubleshooting
### pnpm install fails with SELF_SIGNED_CERT_IN_CHAIN
```bash
npm config set strict-ssl false
```
### Container build fails with proxy errors
```bash
docker build \
--build-arg http_proxy=$http_proxy \
--build-arg https_proxy=$https_proxy \
-t nanoclaw-agent:latest container/
```
### Agent containers fail with "path not shared"
All bind-mounted paths must be under the workspace directory. Check:
- Is NanoClaw cloned into the workspace? (not `/home/agent/`)
- Is the CA cert copied to the project root?
- Has the empty `.env` shadow file been created?
### Agent containers can't reach Anthropic API
Verify proxy env vars are forwarded to agent containers. Check container logs for `HTTP_PROXY=http://host.docker.internal:3128`.
### WhatsApp error 405
The version fetch is returning a stale version. Make sure the proxy-aware `fetchWaVersionViaProxy` patch is applied — it fetches `sw.js` through `HttpsProxyAgent` and parses `client_revision`.
### WhatsApp "Connection failed" immediately
Proxy bypass not configured. From the **host**, run:
```bash
docker sandbox network proxy <sandbox-name> \
--bypass-host web.whatsapp.com \
--bypass-host "*.whatsapp.com" \
--bypass-host "*.whatsapp.net"
```
### Telegram bot doesn't receive messages
1. Check the grammy proxy patch is applied (look for `HttpsProxyAgent` in `src/channels/telegram.ts`)
2. Check Group Privacy is disabled in @BotFather if using in groups
### Git clone fails with "inflate: data stream error"
Clone to a non-workspace path first, then move:
```bash
cd ~ && git clone https://github.com/nanocoai/nanoclaw.git && mv nanoclaw /path/to/workspace/nanoclaw
```
### WhatsApp QR code doesn't display
Run the auth command interactively inside the sandbox (not piped through `docker sandbox exec`):
```bash
docker sandbox run shell-nanoclaw-workspace
# Then inside:
pnpm exec tsx src/whatsapp-auth.ts
```
+1 -1
View File
@@ -193,7 +193,7 @@ leaking the token to disk outweighs the debugging value.
| `setup/logs.ts` | The logging primitives (`logStep`, `logUserInput`, `logComplete`, `stepRawLog`, `initSetupLog`). Single source of truth for level 2/3 formatting and file paths. |
| `setup/<step>.ts` | Individual step implementations. Must emit one terminal status block; must not write directly to the terminal. |
| `setup/register-claude-token.sh` | The Anthropic exception. Inherits stdio, prints its own UI, returns a status to the driver. |
| `setup/add-telegram.sh` | Non-interactive adapter installer. Reads `TELEGRAM_BOT_TOKEN` from env; never prompts. User-facing bits live in `auto.ts`. |
| `setup/channels/telegram.ts` | Telegram channel flow. Installs the adapter in-process by applying the `/add-telegram` skill (directive engine; SKILL.md is the single source of truth), feeding the collected bot token to the skill's `bot_token` prompt var. |
| `setup/pair-telegram.ts` | Emits `PAIR_TELEGRAM_CODE` / `PAIR_TELEGRAM_ATTEMPT` / `PAIR_TELEGRAM` status blocks. Never prints UI. The driver renders it via clack notes. |
## Common pitfalls
+646
View File
@@ -0,0 +1,646 @@
# The skill-engine seam: declare/emit vs. acquire/present
Status: SPEC — approved boundary decision, pre-implementation, review findings folded. Branch: `feat/structured-skill-format` (pre-merge; clean break, no compat shims).
## 1. The boundary rule
> **The engine may DECLARE needs and EMIT events; it may never ACQUIRE input or PRESENT anything.**
`scripts/skill-apply.ts` accumulated interactive-setup-wizard concerns in its core
contract: a `Prompter` with `tell`/`confirm`/`open`, authored presentation attrs
(`gate`, `open:`, `min:`, `error:`, `label:`, `on-fail:`), and a `StepReporter`
shaped around a clack spinner. That couples the deterministic applier to one
consumer (the setup wizard) when there are three:
1. **wizard** — interactive setup (`setup/lib/skill-driver.ts` + `setup/channels/run-channel-skill.ts`, clack UI)
2. **agent-relay** — a coding agent driving a skill conversationally over chat
3. **pipeline** — CI/CD & customer deployments: inputs from env, no human, `operatorMessages` consumed as a "manual steps" report
Declaration & semantics (what a value must look like, what a step is, what the
human must be told) = **core**. Acquisition & presentation (how the value is
collected, how the message is rendered, when to pause) = **consumer**. The
rule binds only the core: a driver may define whatever interaction types it
wants on its side of the seam.
### Invariants (non-negotiable)
- **Prose-primary / oblivious-to-auto-apply**: with `nc:` fences stripped, every SKILL.md reads as a normal skill. Never narrate the engine.
- **Degrade-to-agent**: anything the engine can't do bounces to an `agentTask` — never a crash, never a silent drop.
- **Option A split untouched**: no change to any resolve/wire logic; every `platform_id` / `user_id` byte produced by the **production code paths** is identical. The Option-A test (`setup/channels/run-channel-skill.test.ts:24-70`) keeps its assertion structure, but its fixture credentials MUST be updated to valid-shaped values in the same step that lands validate-at-bind (§4): today's `signing_secret: 's'` fails add-slack's `^[a-fA-F0-9]{16,}$` and `owner_handle: 'U1'` fails `^U[A-Z0-9]{8,}$` (both bypass validation only because inputs bypass it today). The `userId` assertion updates consistently (e.g. `owner_handle: 'U12345678'``'slack:U12345678'`). Byte-parity is a claim about production code, not about test-fixture literals shaped to exploit the removed bypass.
- **Coverage parity**: suite is 680 passed | 1 skipped today. Seam-touching tests are reworked *at the seam*; gate/open attr tests become driver-policy tests proving the parity claims in §5.
- **Never stage/commit** the pre-existing unrelated local changes: `package.json`, `pnpm-lock.yaml`, `src/channels/index.ts`, `src/providers/claude.ts`, `src/providers/index.ts` — nor untracked files this work did not create.
## 2. The new core interface
The entire interaction surface of the engine, after the refactor
(`scripts/skill-apply.ts`):
```ts
// What an nc:prompt declares about the value it needs. Passed to resolveInput
// so a consumer can run its OWN re-ask loop (clack validate, a chat exchange).
export interface InputMeta {
question: string; // the prompt body (verbatim)
secret: boolean; // consumer must mask
validate?: string; // regex source (nc:prompt validate:<re>)
flags?: string; // regex flags (nc:prompt flags:<f>)
normalize?: 'trim' | 'rstrip-slash' | 'lower'; // applied by the ENGINE at bind
}
// Everything the engine emits. `onEvent` is AWAITED before the engine
// proceeds — that ordering guarantee is what lets a consumer implement
// gating (hold the operator event until the human confirms readiness).
export type ApplyEvent =
| { type: 'step-start'; kind: string; line: number; label: string | null }
| { type: 'step-end'; kind: string; line: number; label: string | null;
ok: boolean; durationMs: number; error?: string }
| { type: 'operator'; line: number; text: string };
// text = the rendered, {{var}}-substituted block body;
// line = the directive's opening-fence line (keys driver policy maps)
export interface ApplyOptions {
// Pre-supplied answers (var → value). Checked FIRST. Unchanged.
inputs?: Record<string, string>;
// Replaces Prompter.ask. undefined ⇒ defer (unchanged semantics).
resolveInput?: (name: string, meta: InputMeta) => Promise<string | undefined>;
// Unchanged.
exec?: (cmd: string) => string | void | Promise<string | void>;
// Unchanged.
execStream?: (cmd: string) => Promise<StepOutcome>;
// Unchanged.
skipEffects?: string[];
// Unchanged.
resolveRemote?: (branch: string) => string;
// Replaces BOTH StepReporter and Prompter.tell. Awaited before proceeding.
onEvent?: (e: ApplyEvent) => void | Promise<void>;
}
```
**Gone from the core entirely**: `Prompter` (ask/tell/confirm/open), `PromptOpts`,
`StepReporter`, `ApplyOptions.prompter`, `ApplyOptions.reporter`.
**`ApplyResult` — unchanged fields**: `applied`, `skipped`, `agentTasks`,
`operatorMessages` (still collected in the result — the pipeline reads them
there; the `operator` *event* is for live rendering), `vars`, `journal`,
`referenceProse`. `fullyApplied()` and `firstFailureHint()` unchanged.
Two adjustments:
- `deferred: string[]` — same field, one new entry form: an input rejected by
validate-at-bind is recorded as `` `<var>: invalid value (does not match validate:<re>)` ``
(see §4). Missing-input entries stay the bare var name; unresolved-`{{var}}`
entries stay the thrown message (`skill-apply.ts:841` today).
- `AgentTask.hint` is **dropped** (it existed only for `on-fail:`; with that attr
gone, hint ≡ prose). `firstFailureHint` reads `prose` directly.
**Derived metadata stays core** (exposure, not authored presentation):
`stepLabel` (heading-derived only — the `label:` attr override at
`skill-apply.ts:458` is removed), `AgentTask` prose/`reason` (proseFor-derived),
`firstFailureHint`, `referenceProse`, `operatorMessages`.
**`stepLabel` null semantics, re-documented.** Today's doc comment
(`skill-apply.ts:73-79`, `:442-446`) frames `label: null` as "the driver should
NOT spin on this" — spinner advice, i.e. presentation smuggled into the core.
The contract wording changes (no payload change): `label: null` means *instant/
cheap, or the step renders its own live operator-facing output* (`effect:step`'s
QR card / pairing code). That is step-cost/interactivity **declaration**; the
event carries `kind` + `line`, so a consumer wanting a different render policy
can derive its own.
**Event ordering contract** (normative):
1. Per directive, `step-start` fires immediately before the mutation, `step-end`
immediately after (or on the failure path) — always balanced. The payload is
today's `StepReporter` payload (`skill-apply.ts:80-83` — it already includes
`line`) unchanged, plus only the discriminating `type` field.
2. For an `nc:operator`, the engine substitutes `{{vars}}`, pushes to
`res.operatorMessages`, then `await onEvent({type:'operator', …})` before
evaluating the next directive. An unresolved `{{var}}` in the body defers the
whole block before any event fires (today's behavior, `skill-apply.ts:787`).
**Once the run is `blocked`** (an earlier bounce), operator directives are
skipped instead — no event, no `operatorMessages` entry, recorded in
`skipped`. Walking the human through steps whose side effects the run has
already gated ("a pairing code is about to appear" → nothing appears) is
actively misleading, and a failed run's manual-steps report must not include
steps predicated on the failure.
3. Every `onEvent` call is awaited; a rejection from `onEvent` is treated like
any other throw at that directive (bounce, not crash). This applies to
operator events too: a consumer that throws from `onEvent` **accepts the
bounce consequence**, including the `blocked` latch cascading over later
side effects. The engine itself never defers/bounces an operator block
(open question 7); consequently a well-behaved driver's handler must never
throw for a *declined* confirm — decline semantics are defined in §5.1.
## 3. Migration table
Every existing hook / attr / caller → destination. "core seam" = survives in
`ApplyOptions`/`ApplyResult`; "driver policy" = reimplemented from document
structure (shared policy module + `setup/lib/skill-driver.ts`, §5); "deleted" =
removed with no replacement syntax.
### Engine hooks
| Today | Where (file:line) | Destination |
|---|---|---|
| `ApplyOptions.inputs` | `scripts/skill-apply.ts:263` | core seam (unchanged, but validated — §4) |
| `Prompter.ask(name, question, secret, validate, opts)` | `scripts/skill-apply.ts:50`, called at `:774` | core seam → `resolveInput(name, meta)` |
| `Prompter.tell(text)` | `scripts/skill-apply.ts:54`, called at `:793` | core seam → `onEvent` `operator` event |
| `Prompter.confirm(msg)` | `scripts/skill-apply.ts:58`, called at `:802` (gate; result discarded — decline proceeds today) | driver policy (natural-barrier gating, §5.1) + driver-owned reuse offer — both via the new `RunSkillOptions.confirm` seam (§5.0) |
| `Prompter.open(url)` | `scripts/skill-apply.ts:63`, called at `:796` | driver policy (URL offer, §5.2) via the new `RunSkillOptions.openUrl` seam (§5.0) |
| `StepReporter.stepStart/stepEnd` | `scripts/skill-apply.ts:80-83`, fired at `:827,:830,:839` | core seam → `onEvent` `step-start`/`step-end` events (payload + balance guarantee identical; only `type` added) |
| `ApplyOptions.prompter` | `scripts/skill-apply.ts:266` | deleted (split into `resolveInput` + `onEvent`) |
| `ApplyOptions.reporter` | `scripts/skill-apply.ts:287` | deleted (folded into `onEvent`) |
| `ApplyOptions.exec` / `execStream` | `scripts/skill-apply.ts:269,:275` | core seam (unchanged) |
| `ApplyOptions.skipEffects` | `scripts/skill-apply.ts:279` | core seam (unchanged) |
| `ApplyOptions.resolveRemote` | `scripts/skill-apply.ts:283` | core seam (unchanged) |
| `PromptOpts` (flags/min/error/normalize) | `scripts/skill-apply.ts:37-42`, built at `:499` (`promptOptsOf`) | type deleted; `flags`/`normalize` move into `InputMeta`; `min`/`error` deleted (§ grammar) |
| `normalizeValue` at bind | `scripts/skill-apply.ts:483`, applied `:779` | core seam (unchanged; now paired with validate-at-bind, §4) |
| `stepLabel` | `scripts/skill-apply.ts:457-474` | core seam, minus the `label:` attr branch (`:458`); null semantics re-documented (§2) |
| `failHint` / `AgentTask.hint` | `scripts/skill-apply.ts:419-430`, `:236`, `:749` | deleted (`on-fail:` gone ⇒ hint ≡ prose; `firstFailureHint` at `:308` reads `prose`) |
| run-health gate (`blocked` latch) | `scripts/skill-apply.ts:744-750,:816` | core seam (unchanged) |
| `when:` guard | `scripts/skill-apply.ts:521-525,:762` | core seam (unchanged) |
| `effect:check` / `effect:step` + terminal-block capture | `scripts/skill-apply.ts:646-667` | core seam (unchanged) |
| multi-field JSON capture + validate-on-capture | `scripts/skill-apply.ts:551-572` | core seam (unchanged) |
| journal / `removeSkill` | `scripts/skill-apply.ts:851-870` | core seam (unchanged) |
| `referenceProse` / `operatorMessages` / `vars` in result | `scripts/skill-apply.ts:239-257` | core seam (unchanged) |
### Authored grammar (`scripts/skill-directives.ts`)
| Attr / syntax | Where | Destination |
|---|---|---|
| `nc:operator open:<url>` | grammar doc `:61-73`; lint `:277`, `:287-291`; engine `:791,:796` | **deleted**. Driver URL-offer policy scans the rendered text (§5.2). URLs must live in the prose. |
| `nc:operator gate` | grammar doc `:68-71`; engine `:802` | **deleted**. Driver natural-barrier policy (§5.1). |
| `nc:prompt min:<n>` | grammar doc `:53-54`; lint `:264-266`; driver enforcement `setup/lib/skill-driver.ts:46` | **deleted**. Authors re-encode as regex, e.g. `min:20``validate:^.{20,}$`. |
| `nc:prompt error:<msg>` | grammar doc `:55`; driver `setup/lib/skill-driver.ts:46-47` | **deleted**. Error text derived from the question prose (§5.3). |
| `nc:run label:<word>` | `scripts/skill-apply.ts:458`; doc-comment mention `:451` | **deleted**. Labels are heading-derived only. |
| `on-fail:<token>` | `scripts/skill-apply.ts:419-430` (no lint rule exists) | **deleted**. Hint is always the surrounding prose. |
| `validate:` + `flags:` (prompt & run-capture) | lint `:249-263,:311-317` | **kept** (data semantics). Prompt validate now enforced at bind (§4). |
| `normalize:trim\|rstrip-slash\|lower` | lint `:267-269`; bind `skill-apply.ts:483` | **kept** (canonical-value semantics). |
| `reuse:<ENV_KEY>` | lint `:270-272`; driver `setup/lib/skill-driver.ts:169-172` | **kept** (binding metadata; consumed only by the driver's reuse offer). |
| `when:`, `effect:check`, `effect:step`, capture forms, journal semantics | various | **kept**, unchanged. |
Lint addition: `validate()` gains errors for the six removed attrs
(`operator open:/gate`, `prompt min:/error:`, any-directive `label:`/`on-fail:`)
so stale authorship fails loudly instead of silently no-oping. Lint also gains a
**warning** for an unguarded `nc:operator` immediately followed by `when:`-guarded
directives spanning more than one branch value (the static gate policy cannot
know which branch runs — see §5.1 and open question 1).
### Authored skills carrying removed attrs (strip + prose check)
| Skill | Line | Change |
|---|---|---|
| `.claude/skills/add-teams/SKILL.md` | `:101` | drop `open:https://portal.azure.com` (URL already in the body — step 1, body line 2; body line 1 is the heading sentence) |
| `.claude/skills/add-teams/SKILL.md` | `:132` | `min:20``validate:^.{20,}$` |
| `.claude/skills/add-teams/SKILL.md` | `:173`, `:203` | drop `gate` (policy reproduces both — §5.1 parity) |
| `.claude/skills/add-telegram/SKILL.md` | `:134` | drop `open:https://t.me/{{bot_username}}` **and fold the URL into the body** (verified: body says "Open @{{bot_username}}" — the URL exists only in the attr today) |
| `.claude/skills/add-discord/SKILL.md` | `:125` | drop `open:https://discord.com/...` **and fold the invite URL into the body** (verified: body says "Open the invite link" — URL only in the attr today) |
No skill uses `error:`, `label:`, or `on-fail:` (grep verified). Re-lint every
touched skill (`pnpm exec tsx scripts/skill-directives.ts <dir>`).
### Prompter / reporter implementers & callers (all migrate — clean break)
| Caller / implementer | Where | Migration |
|---|---|---|
| `clackPrompter` (the wizard Prompter) | `setup/lib/skill-driver.ts:66-120` | becomes the driver's `resolveInput` impl (ask + `?` help-escape + clearOnError + secret masking) — `tell`/`confirm`/`open` dissolve into the `onEvent` handler + the `confirm`/`openUrl` seams (§5) |
| `promptValidator` | `setup/lib/skill-driver.ts:37-50` | driver-side; loses `min`/`error`, gains prose-derived message (§5.3) |
| `spinnerReporter` | `setup/lib/skill-driver.ts:259-274` | folded into the driver's `onEvent` handler (step-start/step-end branch), still built on `startSpinner` (`setup/lib/runner.ts:314`) |
| `runSkill` + `RunSkillOptions` | `setup/lib/skill-driver.ts:286-340` | `prompter?`/`reporter?` options become `resolveInput?`/`onEvent?`; **new** `confirm?`/`openUrl?` options (§5.0); `reuse`, `channel`/`step` (help-escape ctx, `:308-314`), `reuseFromEnv` (`:143-188`, now validate-pre-filtered — §5.4) stay driver-side |
| skill-driver CLI | `setup/lib/skill-driver.ts:343-360` | uses the new defaults; no interface change visible to the operator |
| `runChannelSkill` overrides | `setup/channels/run-channel-skill.ts:122` (`prompter`), `:126` (`reporter`) | override fields renamed to `resolveInput`/`onEvent`; `confirm`/`openUrl` passthroughs added; fail-path (`:133-151`) unchanged |
| `applyProviderSkill` defer-all Prompter | `setup/providers/install.ts:62-66` | delete the stub — omit `resolveInput` entirely (absent ⇒ defer, same semantics) |
| `setup/auto.ts` call sites | `:350` (provider), `:560-572` (channels) | no signature change needed (they pass no prompter/reporter) |
| `setup/provider-auth.ts` | `:53` | unchanged (blockers contract survives) |
| engine test fakes | `scripts/skill-apply.test.ts:39` (`headless`), `:447`, `:505`, `:518`, `:534`, `:545`, `:613`, `:637`, `:1219` | `headless(vals)` becomes `{ resolveInput: async (n) => vals[n] }`; tell/open/confirm fakes become recorded `onEvent` handlers or move to driver-policy tests (§9); any fixture whose `inputs` violate a declared `validate:` updates to valid-shaped values (§4) |
| driver test fakes | `setup/lib/skill-driver.test.ts:73`, `:100` (reporter) | mechanical rewrite to `resolveInput`/`onEvent` |
| driver reuse-offer tests | `setup/lib/skill-driver.test.ts:149`, `:167`, `:202` | NOT a mechanical rewrite — today they queue answers through fake `prompter.confirm`s; they migrate to the new `RunSkillOptions.confirm` seam (§5.0) |
| run-channel-skill test stub prompter | `setup/channels/run-channel-skill.test.ts:95-100` (teams gate/open assertions `:115-124`) | becomes a driver-policy assertion running the **default** `onEvent` policy handler with `confirm`/`openUrl` injected (§5.0 injection semantics) — proving the §5.1/§5.2 parity claims. Fixture `app_password: 'sekret'` → a 20+-char value (§4); Option-A slack fixture (`:41-70`) updates `signing_secret`/`owner_handle` + the `userId` assertion (§1 invariant) |
| `back-nav.ts` `backGate`, `claude-handoff.ts` help-escape | `setup/lib/back-nav.ts:31`, `setup/lib/claude-handoff.ts:82,:164,:182` | untouched (already driver-side) |
## 4. Behavior change: validate + normalize apply to EVERY bound value
Today `validate:` is enforced only by the interactive prompter
(`setup/lib/skill-driver.ts:37-50`); `inputs` bypass it
(documented at `scripts/skill-directives.ts:51`). That is data validation
misfiled as prompt UX. New rule, at the single bind point
(`skill-apply.ts:766-780` region):
1. Resolve the raw value: `inputs[var]` first, else `await resolveInput(var, meta)`.
Both `undefined` ⇒ defer (push bare var name — unchanged).
2. Apply `normalize:` (unchanged, already both-paths — `normalizeValue`, `:483`).
3. **New:** if the prompt carries `validate:` (+ `flags:`), test the *normalized*
value. On mismatch: the var stays **unbound**, and
`` `<var>: invalid value (does not match validate:<re>)` `` is pushed to
`deferred`. Not an agentTask, not a throw — downstream consumers of the var
defer exactly as if the value were never supplied, and `fullyApplied` is
`false`. A pipeline passing a malformed env value fails loudly.
Notes:
- Normalize-then-validate order is normative (a trailing-slash URL is stripped
before the `^https://` check — matches the teams `public_url` authoring).
- An invalid `inputs` value does **not** fall through to `resolveInput` — inputs
win outright, and a caller that pre-supplied a value gets a loud rejection,
never a surprise second acquisition path. The interactive dead-end this could
create for reused `.env` credentials is closed on the driver side instead:
`reuseFromEnv` pre-filters every offer through the prompt's
`normalize`/`validate`/`flags` meta, so a stale credential that no longer
matches the declared shape is **never offered** and the operator is prompted
fresh (§5.4). A caller passing raw `inputs` (pipeline, tests) still fails
loudly — that is the point.
- The interactive re-ask loop moves into the wizard's `resolveInput` (clack
`validate`), so engine-level rejection rarely fires interactively; it is the
backstop for programmatic paths.
- Secret values never appear in the deferred entry (only the var name and the
regex source).
- run-capture `validate:` is unchanged (it already throws → bounces,
`skill-apply.ts:551-572` — a command's output has no human to re-ask).
- **Test-fixture consequence** (part of the step that lands this change): every
in-tree fixture that supplies an `inputs` value violating its prompt's
declared `validate:` must update to a valid-shaped value. Known: the Option-A
slack fixture (`run-channel-skill.test.ts:49``signing_secret`,
`owner_handle`, with the `userId` assertion at `:62` updated consistently)
and the teams deferWire fixture (`:102-107``app_password` vs. the new
`^.{20,}$`). Sweep `scripts/skill-apply.test.ts` fixtures the same way.
## 5. Wizard driver policy (presentation derived from document structure)
### 5.0 Where the policy lives, and the driver's own seams
The policy **logic** is UI-free and shared: a new module
`scripts/skill-policy.ts` beside the parser exports `gatePolicy(md)` (→ map of
operator line → needs-confirm + confirm flavor, §5.1) and `extractOfferUrl(text)`
(§5.2), both built on the shared `parseDirectives`. The wizard driver consumes
it; an agent-relay consumer (§7) imports the same module instead of duplicating
the judgment or dragging in clack. The §9 policy unit tests live at this shared
home.
The wizard driver (`setup/lib/skill-driver.ts`) keeps the clack rendering and
gains two injectable interaction seams on `RunSkillOptions` — these are
*driver* options, allowed by the boundary rule (it binds only the core):
- `confirm?: (message: string) => Promise<boolean>` — used by the reuse offer
(§5.4), the natural-barrier gate (§5.1), and the URL offer (§5.2). Default:
clack `p.confirm`, **TTY-gated exactly like `spinnerReporter`**
(`skill-driver.ts:260`) — non-TTY resolves `true` (proceed), preserving
today's headless-prompter-without-confirm semantics (`skill-apply.ts:802`'s
optional chain). A non-TTY run with full inputs never stalls.
- `openUrl?: (url: string) => Promise<void>` — used by the URL offer. Default:
`setup/lib/browser.ts` `openUrl`, attempted only after a `confirm` yes.
**Injection semantics (normative):** an injected `onEvent` **replaces** the
driver's default policy handler entirely — same rule as today's injected
prompter ("the injector owns its I/O", `skill-driver.ts:311`). Therefore
driver-policy parity tests must run the **default** handler and inject
`confirm`/`openUrl` (the run-channel teams test does exactly this — §3, §9);
injecting `onEvent` to observe policy behavior would only observe itself.
Because the engine awaits `onEvent` (§2), a confirm inside the default handler
blocks the engine — that is the entire gating mechanism.
### 5.1 Natural-barrier gate policy
For each `nc:operator` directive at line L, `gatePolicy` computes
`needsConfirm(L)`:
1. Scan forward through subsequent directives, skipping **only** directives
whose `when:<var>=<value>` guard is **incompatible** with this operator's own
guard — same var, different value. No guard, or an identical guard, is
compatible. (This makes mutually-exclusive branches gate on their *own* next
action: imessage's `when:mode=local` operator at `:111` skips the two
remote-only prompts and gates on the local configure run at `:151`;
whatsapp's `when:auth_method=qr` operator at `:96` skips the pairing-code
operator at `:104` — guard-incompatible — and gates on the qr step at `:114`.)
2. Next compatible directive is another `operator`**no confirm** — the chain's
**last** operator carries the barrier. (Operators are NOT skipped-and-scanned-past:
that would make the earlier block of a chain inherit the later block's barrier
and double-confirm — the exact bug the teams parity table below forbids.)
3. Next compatible directive is a `prompt`**no confirm** (the prompt is the barrier).
4. No such directive (end of document) → **no confirm** (a final handoff block, e.g. teams `:228`).
5. Anything else (`run`, `copy`, `dep`, `append`, `env-set`, `json-merge`) →
**confirm** after rendering. Confirm wording is derived from the barrier's
*flavor* — the next compatible directive's effect: `effect:step`
readiness phrasing (`"Ready? The next step starts immediately."` — the block
describes future action: "a pairing code is about to appear"); anything else
→ completed-work phrasing (`"Done with the steps above? Continue when you're
ready."`). `gatePolicy` returns the flavor with the boolean.
**Decline semantics (normative):** the barrier confirm is a *pause*, not a
branch. A "No"/cancel answer proceeds anyway — matching today's engine, which
discards the gate confirm's result (`skill-apply.ts:802`). The driver's handler
must never throw for a decline (an operator-event throw would bounce + latch
`blocked`, §2.3). A driver MAY upgrade decline to a re-ask loop as pure polish;
it must not abort.
**Known limitation (lint-warned, open question 1):** an *unguarded* operator
followed by guarded directives of more than one branch value keys its barrier
decision off a directive that may be runtime-skipped. No in-tree skill authors
this; the §3 lint warning flags it.
At runtime, on each `operator` event the default handler: renders the clack
note (`p.note(text, 'Do this')`), runs the URL offer (§5.2), then the confirm
if `needsConfirm(line)`.
**Verified parity against today's tree** (re-derived under rules 15 above):
- teams `:80` → prompt `:93`: no confirm. `:101` → prompt `:110`: no confirm.
`:124` → prompt `:132`: no confirm. `:158` → next compatible is **operator**
`:173` (rule 2): **no confirm** — the chain's last block carries the barrier.
`:173``run effect:check` `:186`: **confirm**. `:203``run effect:restart`
`:217`: **confirm**. `:228` → end: no confirm. Exactly the two authored
`gate`s reproduced — behavior identical.
- telegram `:134` (→ `run effect:step` pairing `:142`) **gains** a confirm with
readiness phrasing — this restores the old bespoke flow's readiness pause
that the directive port lost.
- signal `:94` (→ `effect:step` `:105`) and both whatsapp operators (`:96`
guard-skip `:104``effect:step` `:114`; `:104` → guard-skip `:114`
`effect:step` `:117`) gain the same readiness pause before a QR/pairing
appears. whatsapp `:146` → prompt `:155`: no confirm.
- imessage `:111` (`when:mode=local`) → guard-skips `:126`,`:134`,`:137`
`run effect:external` `:151`: **confirm**. `:126` (`when:mode=remote`) →
prompt `:134`: no confirm.
- discord `:125` (→ `run effect:fetch` `:139`, the DM resolve) gains a confirm —
desirable: the DM open fails until the bot is invited (open question 2). Slack's
operators (`:69` → prompt `:80`; `:97` → prompt `:112`) are prompt-followed →
unchanged.
### 5.2 URL offer (replaces `open:`)
On an `operator` event, `extractOfferUrl(text)` scans the **rendered** text for
the first *offerable* URL: matches `/https?:\/\/[^\s)>\]]+/`, then **excludes**
candidates containing `<` or `{{` (template placeholders / unsubstituted vars)
and requires the candidate to parse via `new URL()` with a well-formed host.
Without the exclusion, slack's `:97` block — `https://<your-public-host>/webhook/slack`
— would produce a nonsense "Open https://<your-public-host?" offer (the char
class stops at `>` but not `<`). Slack `:97` is the normative negative fixture.
If an offerable URL is found and the run is interactive:
`confirm("Open <url> in your browser?")` → on yes, `openUrl` (both via the §5.0
seams — TTY-gated, non-TTY skips). Confirm-then-open matches the old bespoke
flows; prose-primary already forces URLs into the text (after the §3 skill
edits), so `open:` was redundant authorship. Order within the handler:
note → URL offer → natural-barrier confirm.
**Full operator-body URL inventory** (the offer scans *every* operator body,
not just ex-`open:` blocks — audited like §5.1):
| Site | URL | Outcome |
|---|---|---|
| teams `:101` (body line 2) | `https://portal.azure.com` | offer — preserves today's `open:` behavior |
| telegram `:134`, discord `:125` (after the §3 fold-into-prose edits) | t.me / invite URL | offer — preserves today's `open:` behavior |
| teams `:158` (body) | `https://portal.azure.com` | **new** offer (no `open:` today) — accepted, same judgment as the discord confirm; open question 3 |
| discord `:70` (body `:72`) | `https://discord.com/developers/applications` | **new** offer — accepted; open question 3 |
| imessage `:126` (body `:128`) | `https://photon.codes` | **new** offer — accepted; open question 3 |
| slack `:97` (body `:99`) | `https://<your-public-host>/webhook/slack` | **excluded** (placeholder) — slack stays offer-free, as today |
| slack `:69` (`api.slack.com/apps`, no scheme), signal `:94` (`sgnl://…`), all other operators | — | no match |
### 5.3 Validation error text (replaces `error:`)
`promptValidator(validate, flags, question)` — on a regex miss the message is
`` `That doesn't match the expected format. ${question}` `` (the full prompt
body, which by authoring convention describes the expected shape, e.g.
"Paste the bot token from BotFather (looks like `123456:ABC-DEF...`)."). No
`min` branch (regex-encoded now), no `error:` override.
### 5.4 Stays driver-side, unchanged in spirit
`clearOnError` secret re-paste, secret masking (`p.password`), the masked reuse
offer (`reuseFromEnv` + its `reuse:` linkage — confirm now via the §5.0 seam,
`skill-driver.ts:327-328` today), the `?` help-escape (channel/step ctx already
threaded via `RunSkillOptions`, `run-channel-skill.ts:130-131`), `backGate`,
spinners (now driven by step events), `hostExec`/`hostExecStream`.
**One behavior addition:** `reuseFromEnv` pre-filters offers through the target
prompt's `normalize`/`validate`/`flags` (parsed from the same directives it
already walks) — an `.env` value that would fail validate-at-bind is silently
not offered, so the operator is prompted fresh instead of hitting a §4
dead-end (`fullyApplied` false → `failWith`). This is the driver-side closure
of the stale-credential path; raw `inputs` remain loud-fail (§4).
## 6. Pipeline consumer contract
- **Inputs from env — convention:** for each prompt var `foo_bar`, read
`NC_INPUT_FOO_BAR` (prefix `NC_INPUT_`, var uppercased). A small helper
`inputsFromEnv(md: string, env = process.env)` (driver-agnostic, may live
beside the engine) parses the skill's prompt vars via `parseDirectives` and
returns the `inputs` record. Var names are case-sensitive in the grammar
(`skill-directives.ts:111`), so uppercasing can collide (`bot_token` vs
`Bot_Token`): `inputsFromEnv` **errors on a collision**. All in-tree vars are
lowercase today; a lint rule requiring lowercase prompt/capture var names is
cheap and makes the mapping bijective (open question 6). No `resolveInput`,
no `onEvent` required (optionally an `onEvent` that logs step events as CI
lines).
- **Run:** `applySkill(skillDir, root, { inputs, exec, execStream?, skipEffects })`
with `skipEffects` per deployment (e.g. `['restart']` when the deploy restarts once).
- **What a pipeline can fully apply (normative boundary):** `inputs` binds
**prompt** vars only — the engine never reads it for `run`/`step` captures
(`skill-apply.ts:773` — prompt branch only), so a pipeline cannot pre-supply
a capture-bound var like `platform_id`. And `effect:step` without a real
`execStream` throws → bounces (`skill-apply.ts:655-656`);
`skipEffects:['step']` avoids the bounce but leaves the step's capture vars
unbound, so downstream consumers defer either way. Consequence: **skills with
an `effect:step` are wizard/relay-only for full application** — today that is
telegram (`:142`), whatsapp (`:114`,`:117`), and signal (`:105`). slack,
discord, teams, and imessage carry no `effect:step` and can go fully green
from env inputs (+ `exec`), with their operator blocks landing in the
manual-steps report. A pipeline-grade `execStream`, or letting `inputs`
pre-bind capture vars (bound var ⇒ capture skipped), would move that
boundary — deliberately out of scope here (open question 10).
- **Consume the result:**
- `res.operatorMessages` → emitted verbatim, numbered, as the "manual steps"
report artifact (the human steps the pipeline cannot do).
- `fullyApplied(res)` gates the job: `false` ⇒ non-zero exit, printing
`res.deferred` (which now includes §4 invalid-input reasons — a malformed
env value is a loud failure) and `firstFailureHint(res)` + each
`agentTask.reason` for real bounces.
- `res.vars` → exported for downstream jobs (e.g. `platform_id` into a wire step).
## 7. Agent-relay consumer sketch
A coding agent driving a skill over chat implements the two seams:
- `resolveInput(name, meta)`: send `meta.question` to the chat; for
`meta.secret`, instruct the user to supply the value out-of-band (or via the
platform's redaction affordance) and never echo it back. Run its own re-ask
loop against `meta.validate`/`meta.flags` conversationally ("that doesn't look
like a bot token — it should start with `xoxb-`"); return the final answer, or
`undefined` if the user says skip (⇒ defer, degrade-to-agent semantics apply
downstream).
- `onEvent(e)`: `operator` → relay the text as a chat message and (because the
engine awaits) hold the return until the user replies "done" when the next
action is side-effecting — importing `gatePolicy` from the shared
`scripts/skill-policy.ts` (§5.0) for the same natural-barrier judgment as the
wizard, with no clack/TTY baggage; or simply always ask.
`step-start`/`step-end` → optional progress messages ("Building… ok, 12s").
- Everything else (`exec`, journal, bounce handling) is identical to the wizard;
the agent reads `agentTasks[].prose` and applies bounced steps itself — which
is exactly the degrade-to-agent path the prose was written for.
## 8. Implementation step plan
Each step must be independently green — `pnpm exec tsc --noEmit` (root) +
`pnpm test` (full vitest suite) + skill lint on every touched skill — and
committable on its own. Core lands before consumers migrate; the old seam is
deleted only after no consumer uses it (transitional coexistence inside the
branch is fine; the *merged* result has no compat layer).
1. **Core: add the new seam (additive) + validate-at-bind.**
`scripts/skill-apply.ts`: add `resolveInput` + `onEvent` + `InputMeta` +
`ApplyEvent`; engine prefers them when present (falls back to
`prompter`/`reporter` if not — temporary); implement validate-at-bind (§4)
and the awaited-event ordering; re-document `stepLabel` null semantics (§2).
**Includes the §4 fixture sweep**: Option-A slack inputs + `userId`
assertion, teams deferWire `app_password`, and any `skill-apply.test.ts`
fixture with shape-violating inputs — updated in this same commit so the
suite is green. New tests: event union payloads + balance,
await-before-proceed ordering (an async `onEvent` that records completion),
`resolveInput` meta contents, validate-at-bind for inputs AND resolveInput
answers (normalize-then-validate, no-fallthrough from invalid inputs to
`resolveInput`, deferred entry format, `fullyApplied` false, secret never in
the entry), onEvent-throw ⇒ bounce (incl. on an operator event).
2. **Shared policy module + wizard driver migration.** New
`scripts/skill-policy.ts`: `gatePolicy(md)` (§5.1 rules incl. operator-chain
termination, guard-compatibility, confirm flavor) + `extractOfferUrl(text)`
(§5.2 incl. placeholder exclusion) — with the parity-table unit tests.
`setup/lib/skill-driver.ts`: `resolveInput` impl (ex-`clackPrompter.ask`,
help-escape intact), default `onEvent` handler (spinner branch from
`spinnerReporter` + operator branch consuming `gatePolicy`/`extractOfferUrl`),
new `confirm`/`openUrl` options with TTY-gated defaults (§5.0), decline =
proceed, `reuseFromEnv` validate-pre-filter (§5.4), §5.3 error text;
`RunSkillOptions.prompter/reporter``resolveInput/onEvent` (+ documented
replacement semantics for injected `onEvent`);
`setup/channels/run-channel-skill.ts` override renames + `confirm`/`openUrl`
passthrough; `setup/providers/install.ts` drops its stub Prompter. Reuse
tests migrate to the `confirm` seam; the teams run-channel test runs the
default handler with injected `confirm`/`openUrl` (§9). After this step no
in-tree caller passes `prompter`/`reporter`.
3. **Core: delete the old seam.** Remove `Prompter`, `PromptOpts`,
`StepReporter`, `ApplyOptions.prompter/reporter`; remove engine handling of
`open:`/`gate` (`:791,:796,:802`), `label:` (`:458`), `on-fail:`+`AgentTask.hint`
(`:419-430,:236`), `min:`/`error:` plumbing (`:499-506`). Rework/delete the
engine tests that asserted removed behavior (§9).
4. **Skills cleanup.** Strip attrs per §3 table; fold the telegram + discord
URLs into their operator prose; teams `min:20``validate:^.{20,}$`.
Re-lint all touched skills; the run-channel teams parity test (now
driver-policy-based from step 2) must still prove both barriers fire and the
portal offer survives the `open:` removal (body URL, §5.2 inventory).
5. **Grammar diet.** `scripts/skill-directives.ts`: drop `min:`/`error:`/`open:`/`gate`
validation + grammar doc; add lint errors rejecting the six removed attrs
and the §3 unguarded-operator/multi-branch warning; rework directive tests.
(Ordered after step 4 so lint never fails on skills still carrying the attrs.)
6. **Sweep + parity audit.** Grep proves zero remaining references to
`Prompter|PromptOpts|StepReporter|on-fail:|label:|min:|error:` (in the seam
sense) and `operator.*(open:|gate)`; container typecheck
(`pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit`) untouched;
full suite count ≥ 680 passed | 1 skipped; Option-A test assertion structure
unmodified (fixture values per §1/§4); production resolve/wire paths
untouched.
## 9. Test-migration notes (per step)
- **Step 1:** all existing tests stay green — the ONLY permitted edits are the
§4 shape-violating input fixtures (Option-A slack `:49`/`:62`, teams deferWire
`:106`, plus any `skill-apply.test.ts` siblings the sweep finds). New
describe blocks in `scripts/skill-apply.test.ts`: `onEvent` events (mirror of
the reporter suite at `:1003-1054`, plus operator events + await-ordering) and
validate-at-bind (extends the PromptOpts suite pattern at `:1189-1242`).
- **Step 2:** `setup/lib/skill-driver.test.ts` fakes (`:73,:100`) rewritten
mechanically to `resolveInput`/`onEvent`; the reuse-offer tests
(`:149,:167,:202`) migrate to the injected `confirm` option (accept /
decline / helper-reuse paths preserved); the `clackPrompter.open`
existence test (`:218-223`) becomes a URL-offer policy test; help-escape tests
(`:225-259`) keep their shape (the escape lives in the driver's `resolveInput`);
`promptValidator` test (`:261-271`) loses min/error, gains prose-derived-message
assertions. **Policy tests at `scripts/skill-policy.ts`'s home** encode the
§5.1 parity table: teams (confirm ×2, **no confirm on the `:158` chain head**
the operator-chain rule's regression fixture), telegram/signal/whatsapp
(readiness-flavor pause), imessage (guard-compatibility), end-of-document;
plus `extractOfferUrl` fixtures: the §5.2 inventory incl. slack's placeholder
as the negative case. `run-channel-skill.test.ts:75-125` (teams) re-asserts
gate-before-manifest + portal-URL-offer by running the **default** `onEvent`
handler with injected `confirm`/`openUrl` (never an injected `onEvent`, which
replaces the policy — §5.0).
- **Step 3:** delete engine tests for removed syntax: `nc:operator open + gate`
(`scripts/skill-apply.test.ts:492-551`), `label:` override + `on-fail:` cases
(`:1064,:1072`, `:1145-1162`); keep their *semantic* siblings (heading labels,
prose hint default, unresolved-var deferral of an operator body). The prompter
threading tests (`:634-645,:1212-1231`) become `InputMeta` assertions.
- **Step 4:** no test-file changes beyond fixtures; skill lint is the gate.
- **Step 5:** `scripts/skill-directives.test.ts`: drop `:268-271` (min) and
`:325-355` (open/gate) as validations-of-supported-syntax; re-add them
inverted (the new lint errors reject the attrs). PromptOpts-parse test
(`:247-261`) drops `min:`.
- **Throughout:** the coverage-parity bar is behavioral, not file-shaped: every
guarantee an old test proved (barrier ordering, open-after-render, balanced
spinner events, hint provenance) must have a successor at its new home.
## 10. Open questions (decisions this spec made that the design left open)
1. **Guard-compatibility in the gate scan (§5.1.1).** The design said "followed
by"; this spec defines *followed by* as skipping `when:`-incompatible
directives so mutually-exclusive branches gate correctly (imessage local,
whatsapp qr/pairing). Alternative (pure document order) would miss imessage's
"stop and wait" and double-gate whatsapp. Two scrutiny points: (a) the
compatibility rule compares only same-var/different-value; different-var
guards are treated as compatible (conservative). (b) An **unguarded**
operator followed by guarded directives of more than one branch value keys
its decision off a directive that may be runtime-skipped (e.g. unguarded
operator → `prompt when:mode=remote``run when:mode=local`: policy says
no-confirm, but at runtime mode=local skips the prompt). No in-tree skill
authors this; §3's lint warning covers it.
2. **Discord gains a confirm** (`add-discord:125``effect:fetch`). The design's
parity list mentioned teams + telegram only; the policy also pauses before
discord's DM resolve. Judged desirable (the fetch fails until the bot is
invited) — but it is a new prompt in an existing flow.
3. **Three new URL offers** (§5.2 inventory): teams `:158` (portal.azure.com),
discord `:70` (developers portal), imessage `:126` (photon.codes) had no
`open:` today and gain an offer because the scan covers every operator body.
Accepted — same judgment as the discord confirm — but they are behavior
changes in existing flows.
4. **Confirm triggers on ALL non-prompt/non-operator directives** (§5.1.5), not
just the engine's dangerous-side-effect set (`restart|step|wire`). Needed for
teams parity (its first gate precedes `effect:check`+`external`). Consequence:
an operator block directly followed by e.g. an `env-set` would also confirm —
no such authoring exists today.
5. **Invalid-input failure shape (§4): deferred, not agentTask.** Chosen because
a bad value is a missing-*valid*-value (re-supply and re-run), not a step an
agent can apply from prose; it also keeps the run-health gate un-tripped so a
re-run with a fixed env var completes. The alternative (bounce) would
cascade-block later side effects. Relatedly: invalid `inputs` do NOT fall
through to `resolveInput` (§4) — the driver's reuse pre-filter (§5.4) is the
interactive recovery path.
6. **Env-input convention `NC_INPUT_<VAR>` (§6)** and that `inputsFromEnv` is a
helper, not an engine feature (inputs stay the only env-agnostic seam). Prefix
bikeshed welcome; the engine never reads `process.env` for inputs.
`inputsFromEnv` errors on an uppercase collision; a lowercase-var lint rule
would make the mapping bijective.
7. **Operator event fires even with no consumer** — with `onEvent` absent the
block is still collected in `operatorMessages` (today's headless behavior,
`skill-apply.test.ts:452-456`). The **engine** never defers/bounces an
operator block on its own; a consumer that *throws* from `onEvent` opts into
the standard bounce path (§2.3) — that is the consumer's choice, not an
engine judgment, and the wizard's default handler never throws (decline =
proceed, §5.1).
8. **`AgentTask.hint` field removal** (vs. keeping it always-equal-to-prose for
result-shape stability). Removal chosen for the clean break; any external
consumer of `hint` (none in-tree) would break.
9. **Teams `min:20` regex** chosen as `^.{20,}$` (any 20+ chars). If the intent
was tighter (Azure secret alphabet), tighten in the skill edit — the seam
doesn't care.
10. **Pipeline boundary for `effect:step` skills (§6).** telegram/whatsapp/signal
cannot go fully green in a pipeline (no human at the QR/pairing step, and
`inputs` cannot pre-bind capture vars). Two possible future escapes — a
pipeline-grade `execStream` contract, or `inputs` pre-binding capture vars
(bound var ⇒ capture skipped) — both deliberately out of scope; the spec'd
behavior is the loud `fullyApplied:false`.
11. **`step-end` on validate-at-bind rejection:** none — prompts never emitted
step events (they are not mutations) and still don't. Only the deferred entry
records the rejection. If wizards want live feedback, their own `resolveInput`
loop already provided it.
## 11. Rejected review findings
- **"`run-channel-skill.ts:122` is `exec`; the `prompter` passthrough is at `:123`"**
(review 1, citation sweep) — rejected: `grep -n` shows `:121 exec: overrides.exec,`
and `:122 prompter: overrides.prompter,`. The spec's `:122` citation was
correct as written. (The same review's other three citation fixes —
`resolveRemote :283`, teams `:101` body-line wording, `label:` doc mention
`:451` — were verified correct and are folded.)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.1.32",
"version": "2.1.38",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
File diff suppressed because it is too large Load Diff
+876
View File
@@ -0,0 +1,876 @@
// The skill application engine — executes `nc:` directives parsed from a SKILL.md.
//
// The agent is always the top-level applier; this engine is the deterministic
// accelerator it delegates to. Anything the engine can't do bounces back to the
// AGENT (which reads the same prose and applies it, the way skills work today) —
// never to the human, and never as a hard abort. The human is in the loop only
// for `prompt` inputs and `operator` instructions — the parts addressed to the
// human (e.g. clicking through the Slack UI), which the agent relays.
//
// Phases (the F2 runtime contract, minimal form):
// 1. parse + validate — lint; a malformed skill never reaches apply
// 2. PLAN — per directive: skip|apply|needs-input|agent — no writes
// 3. acquire inputs — resolve every `prompt` via `inputs` / `resolveInput`
// 4. mutate — copy/append/env-set, journaled + idempotent
// 5. run — build/test/fetch (+ dep install) via injected exec
// Remove is derived from the journal — no hand-written REMOVE.md.
//
// Inputs + `resolveInput` make one engine serve three contexts:
// • programmatic → pass `inputs` (var→value); no resolver, runs through fully
// • setup flow → an interactive `resolveInput` collects anything left
// • recipe rebuild → headless: no answer for a prompt ⇒ it (and its consumers) defer
//
// Usage: pnpm exec tsx scripts/skill-apply.ts <skillDir> # plan (no writes)
import { execSync } from 'node:child_process';
import { readFileSync, existsSync, writeFileSync, appendFileSync, copyFileSync, mkdirSync, rmSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { parseDirectives, promptVar, type Directive } from './skill-directives.js';
// What an `nc:prompt` DECLARES about the value it needs — the core seam's input
// contract, passed to `resolveInput` so a consumer can run its OWN re-ask loop
// (clack validate, a chat exchange). Declaration only: how the value is
// ACQUIRED (a masked TTY prompt, a chat message) is the consumer's business.
export interface InputMeta {
question: string; // the prompt body (verbatim)
secret: boolean; // consumer must mask
validate?: string; // regex source (nc:prompt validate:<re>)
flags?: string; // regex flags (nc:prompt flags:<f>)
normalize?: 'trim' | 'rstrip-slash' | 'lower'; // applied by the ENGINE at bind
}
// Everything the engine EMITS — the core seam's output contract. Every
// `onEvent` call is AWAITED before the engine proceeds; that ordering guarantee
// is what lets a consumer implement gating (hold the operator event until the
// human confirms readiness). For step events, `label` is `stepLabel`'s
// declaration: null means the step is instant/cheap, OR it renders its own live
// operator-facing output (an `effect:step` QR card / pairing code) — a
// step-cost/interactivity declaration, not render advice; the event carries
// `kind` + `line`, so a consumer wanting a different render policy can derive
// its own.
export type ApplyEvent =
| { type: 'step-start'; kind: string; line: number; label: string | null }
| { type: 'step-end'; kind: string; line: number; label: string | null; ok: boolean; durationMs: number; error?: string }
| { type: 'operator'; line: number; text: string };
// operator: text = the rendered, {{var}}-substituted block body;
// line = the directive's opening-fence line (keys driver policy maps)
// The result of a streaming `nc:run effect:step`: the spawn's exit success plus
// the terminal status block's fields, which `capture:<var>=<FIELD>` binds.
export interface StepOutcome {
ok: boolean;
fields: Record<string, string>;
}
export type StepStatus = 'skip' | 'apply' | 'needs-input' | 'agent';
export interface PlanStep {
n: number;
kind: string;
line: number;
status: StepStatus;
detail: string;
}
const read = (p: string) => (existsSync(p) ? readFileSync(p, 'utf8') : '');
const has = (root: string, rel: string) => existsSync(join(root, rel));
const VAR_REF = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
const destOf = (line: string) => (line.includes('->') ? line.split('->')[1].trim() : line.trim());
const srcOf = (line: string) => (line.includes('->') ? line.split('->')[0].trim() : line.trim());
function fileHasLine(root: string, rel: string, line: string): boolean {
return read(join(root, rel))
.split('\n')
.some((l) => l.trim() === line.trim());
}
function pkgHasDep(root: string, name: string): boolean {
try {
const pkg = JSON.parse(read(join(root, 'package.json')) || '{}');
return Boolean(pkg.dependencies?.[name] || pkg.devDependencies?.[name]);
} catch {
return false;
}
}
function envKeySet(root: string, key: string): boolean {
return read(join(root, '.env'))
.split('\n')
.some((l) => {
const m = l.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/);
return m !== null && m[1] === key && m[2].trim().length > 0;
});
}
// Does the array-of-objects JSON at `rel` already contain an element whose
// [key] equals `value`? The idempotency probe for json-merge.
function jsonArrayHasKey(root: string, rel: string, key: string, value: unknown): boolean {
try {
const arr = JSON.parse(read(join(root, rel)) || '[]');
return Array.isArray(arr) && arr.some((el) => el !== null && typeof el === 'object' && (el as Record<string, unknown>)[key] === value);
} catch {
return false;
}
}
// Per-directive idempotency check + "what it would do". Read-only.
function selfStatus(d: Directive, root: string): { status: StepStatus; detail: string } {
switch (d.kind) {
case 'copy': {
const dests = d.body.map(destOf);
const missing = dests.filter((p) => !has(root, p));
const from = d.attrs['from-branch'] ? `fetch ${String(d.attrs['from-branch'])}` : '';
return missing.length
? { status: 'apply', detail: `${from}copy ${missing.join(', ')} (absent)` }
: { status: 'skip', detail: `${dests.join(', ')} present` };
}
case 'append': {
const to = String(d.attrs.to ?? '');
const line = d.body[0] ?? '';
return fileHasLine(root, to, line)
? { status: 'skip', detail: `${to} already has the line` }
: { status: 'apply', detail: `add to ${to}: ${line}` };
}
case 'dep': {
const missing = d.body.filter((s) => !pkgHasDep(root, s.slice(0, s.lastIndexOf('@'))));
return missing.length
? { status: 'apply', detail: `install ${missing.join(', ')}` }
: { status: 'skip', detail: `${d.body.join(', ')} present` };
}
case 'run':
return { status: 'apply', detail: `${String(d.attrs.effect ?? 'run')}: ${d.body.join(' && ')}` };
case 'env-set': {
const keys = d.body.map((l) => l.split('=')[0].trim());
const missing = keys.filter((k) => !envKeySet(root, k));
return missing.length
? { status: 'apply', detail: `set ${missing.join(', ')} in .env` }
: { status: 'skip', detail: `${keys.join(', ')} already set` };
}
case 'json-merge': {
const into = String(d.attrs.into ?? '');
const key = String(d.attrs.key ?? '');
let value: unknown;
try {
value = (JSON.parse(d.body.join('\n')) as Record<string, unknown>)[key];
} catch {
return { status: 'agent', detail: `nc:json-merge body is not parseable JSON — an agent applies it from the prose` };
}
return jsonArrayHasKey(root, into, key, value)
? { status: 'skip', detail: `${into} already has ${key}=${JSON.stringify(value)}` }
: { status: 'apply', detail: `merge ${key}=${JSON.stringify(value)} into ${into}` };
}
case 'prompt':
return { status: 'needs-input', detail: '' };
case 'operator':
return { status: 'apply', detail: `show operator: ${(d.body[0] ?? '').slice(0, 50)}` };
default:
return { status: 'agent', detail: `no deterministic handler for nc:${d.kind} — an agent applies it from the prose` };
}
}
export function planSkill(skillDir: string, root: string): { steps: PlanStep[]; needsInput: string[]; agentSteps: number } {
const directives = parseDirectives(read(join(skillDir, 'SKILL.md')));
const self = directives.map((d) => ({ d, ...selfStatus(d, root) }));
const consumers = new Map<string, number[]>();
self.forEach(({ d }, i) => {
for (const line of d.body) for (const m of line.matchAll(VAR_REF)) (consumers.get(m[1]) ?? consumers.set(m[1], []).get(m[1])!).push(i);
});
const steps: PlanStep[] = self.map(({ d, status, detail }, i) => {
if (d.kind !== 'prompt') return { n: i + 1, kind: d.kind, line: d.line, status, detail };
const v = promptVar(d) ?? '?';
const tag = `${v}${d.args.includes('secret') ? ' (secret)' : ''}`;
const cons = consumers.get(v) ?? [];
const satisfied = cons.length > 0 && cons.every((j) => self[j].status === 'skip');
return satisfied
? { n: i + 1, kind: d.kind, line: d.line, status: 'skip', detail: `${tag} — consumers already satisfied` }
: { n: i + 1, kind: d.kind, line: d.line, status: 'needs-input', detail: `${tag} → asked during apply` };
});
return {
steps,
needsInput: steps.filter((s) => s.status === 'needs-input').map((s) => s.detail.split(' ')[0]),
agentSteps: steps.filter((s) => s.status === 'agent').length,
};
}
// ---------------------------------------------------------------------------
// Apply (phases 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
operatorMessages: string[]; // `nc:operator` bodies to relay to the human operator
// Non-secret resolved values (prompt answers + `run capture:<var>` outputs) so
// a caller can read what the skill produced — e.g. a channel skill resolves
// `owner_handle` + `platform_id`, the setup flow reads them to wire the agent.
vars: Record<string, string>;
journal: JournalEntry[];
// The skill's author-written REFERENCE floor — its `## Alternatives`,
// `## Optional configuration`, and `## Troubleshooting` sections, sliced
// verbatim from the RAW markdown (see `referenceProse`). The driver surfaces
// this beside the agentTasks on a bounce: the same prose a human reader would
// scroll to when a step doesn't apply cleanly. Sliced on the author headings,
// never the resolved {{var}} map, so a resolved {{secret}} can never leak in.
referenceProse: string;
}
export interface ApplyOptions {
// Pre-supplied answers for `prompt` vars (var name → value). Checked FIRST, so
// a caller that has every answer needs no resolver at all and the whole skill
// runs through with no human interaction (fully programmatic apply).
inputs?: Record<string, string>;
// The core input seam: resolve a prompt var the caller didn't pre-supply.
// `meta` carries the declared semantics (question, secret,
// validate/flags/normalize) so a consumer can run its OWN re-ask loop.
// Returning undefined ⇒ defer. Optional — omit it (with full `inputs`) for a
// headless run; a prompt with neither defers.
resolveInput?: (name: string, meta: InputMeta) => Promise<string | undefined>;
// The core output seam: every engine emission — the step-start/step-end
// brackets and each rendered `nc:operator` block — flows through this one
// handler, and every call is AWAITED before the engine proceeds (that
// ordering is what lets a consumer gate on an operator block). A rejection is
// treated like any other throw at that directive: bounce, never crash — a
// consumer that throws on an operator event accepts the bounce consequence,
// including the `blocked` latch cascading over later side effects. Absent ⇒
// silent; the headless/programmatic apply runs identically.
onEvent?: (e: ApplyEvent) => void | Promise<void>;
// dep/run/branch-fetch; injectable for tests. Returns the command's stdout so
// a `run capture:<var>` can bind it into a {{var}} (the twin of `prompt`).
exec?: (cmd: string) => string | void | Promise<string | void>;
// Streaming exec for `nc:run effect:step`: spawns a long-running, operator-
// interactive step (a pairing code, a QR device-link) that emits
// `=== NANOCLAW SETUP: … ===` status blocks, renders them to the operator live,
// and resolves with the terminal block's fields (bound via capture:<var>=<FIELD>).
// Absent ⇒ a step directive degrades to an agent (runs the step from the prose).
execStream?: (cmd: string) => Promise<StepOutcome>;
// Run effects the CALLER owns and will perform itself — those runs are skipped
// (not executed). e.g. a headless rebuild or a setup that restarts once at the
// end passes ['restart']; applyProviderSkill passes ['build','test'].
skipEffects?: string[];
// Resolve which remote carries a `from-branch` registry branch. Defaults to a
// generic resolver (env override → first remote that has the branch → origin);
// setup injects one that reuses setup/lib/channels-remote.sh for exact parity.
resolveRemote?: (branch: string) => string;
}
/**
* True when a skill applied completely nothing deferred for a missing input and
* nothing bounced to an agent. The check a programmatic caller makes to confirm a
* fully-headless run-through succeeded.
*/
export function fullyApplied(res: ApplyResult): boolean {
return res.deferred.length === 0 && res.agentTasks.length === 0;
}
/**
* The failure diagnosis for the FIRST directive that bounced to an agent, in
* document order: a concise headline (the nearest section heading) plus the
* bounced step's own prose as the hint. The setup driver surfaces this when a
* channel skill doesn't fully apply the prose beside the step that failed
* becomes the operator's failure hint and the Claude-handoff context, instead
* of a generic "couldn't finish" message. Returns undefined when nothing
* bounced (e.g. a headless rebuild only left prompts deferred not a failure).
*/
export function firstFailureHint(res: ApplyResult): { headline: string; hint: string } | undefined {
const first = res.agentTasks[0];
if (!first) return undefined;
const hint = first.prose.trim();
// The concise headline: the nearest `#`-heading the prose carries, stripped of
// its markers; failing that, the first prose line; failing that, the reason.
const lines = first.prose.split('\n').map((l) => l.trim()).filter(Boolean);
const heading = lines.find((l) => l.startsWith('#'));
const headline = heading ? heading.replace(/^#+\s*/, '').trim() : (lines[0] ?? first.reason);
return { headline, hint };
}
// The author-written REFERENCE sections the apply engine ignores entirely:
// `## Alternatives`, `## Optional configuration`, `## Troubleshooting`. Matched
// on the heading text (lowercased), level-2 only.
const REFERENCE_HEADINGS = new Set(['alternatives', 'optional configuration', 'troubleshooting']);
/**
* Slice a skill's reference floor out of its raw markdown the
* `## Alternatives` / `## Optional configuration` / `## Troubleshooting` sections
* the engine never executes. This is the human floor a reader scrolls to (a
* dedicated-number path, optional env knobs, dropped-symptom fixes); the driver
* surfaces it beside the bounced agentTasks so the operator has the same
* reference. Returned VERBATIM from the author text keyed on the headings never
* from the resolved {{var}} map so a resolved {{secret}} can never leak into it
* (a `{{token}}` placeholder, if a reference section ever wrote one, stays a
* literal placeholder). Any stray `nc:` directive fence inside a section is
* dropped: reference prose is plain bash/json/text only an `nc:` block belongs
* under Apply, never here. Fence state is tracked so a `# comment` line inside a
* code block is never mistaken for a markdown heading that would end the slice.
*/
export function referenceProse(md: string): string {
const sections: string[] = [];
let cur: string[] | null = null; // lines of the section being collected, or null
let fence: string | null = null; // open fence's info-string ('' for a bare fence), or null
const keep = (line: string): void => {
// Inside (or toggling) an `nc:` fence ⇒ drop; otherwise collect when capturing.
if (cur && !(fence ?? '').startsWith('nc:')) cur.push(line);
};
for (const line of md.split('\n')) {
if (line.startsWith('```')) {
if (fence === null) {
fence = line.slice(3).trim();
keep(line);
} else {
keep(line); // closing fence — `fence` still holds the opening info-string
fence = null;
}
continue;
}
if (fence !== null) { keep(line); continue; } // fence body
const h = line.match(/^(#{1,6})\s+(.*)$/);
if (h) {
const level = h[1].length;
const text = h[2].trim().toLowerCase();
if (level === 2 && REFERENCE_HEADINGS.has(text)) {
if (cur) sections.push(cur.join('\n').trim());
cur = [line]; // open a new reference section
} else if (level <= 2) {
if (cur) { sections.push(cur.join('\n').trim()); cur = null; } // a non-reference h1/h2 closes the slice
} else if (cur) {
cur.push(line); // a subsection (### …) inside a captured reference section
}
continue;
}
if (cur) cur.push(line);
}
if (cur) sections.push(cur.join('\n').trim());
return sections.filter(Boolean).join('\n\n').trim();
}
// A hardcoded `origin` breaks forks where the registry branch lives on
// `upstream`. Generic mirror of channels-remote.sh: explicit override → the
// first remote that actually has the branch → origin.
function defaultResolveRemote(branch: string, root: string): string {
const override = process.env.NANOCLAW_CHANNELS_REMOTE;
if (override) return override;
const cap = (cmd: string): string => {
try {
return execSync(cmd, { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
} catch {
return '';
}
};
const remotes = cap('git remote').split('\n').map((s) => s.trim()).filter(Boolean);
const ordered = remotes.includes('origin') ? ['origin', ...remotes.filter((r) => r !== 'origin')] : remotes;
for (const r of ordered) if (cap(`git ls-remote --heads ${r} ${branch}`).trim()) return r;
return 'origin';
}
// The prose an agent reads when a step degrades: nearest heading + the
// paragraph immediately above the directive fence.
function proseFor(md: string, fenceLine1: number): string {
const lines = md.split('\n');
let i = fenceLine1 - 2;
while (i >= 0 && lines[i].trim() === '') i--;
const para: string[] = [];
while (i >= 0 && lines[i].trim() !== '' && !lines[i].startsWith('#')) para.unshift(lines[i--]);
let heading = '';
for (let h = i; h >= 0; h--) if (lines[h].startsWith('#')) { heading = lines[h]; break; }
return [heading, ...para].filter(Boolean).join('\n').trim();
}
// The nearest `#`-prefixed heading above a fence (the same upward scan proseFor
// uses), stripped of its leading `#`s — a concise caption for a step spinner.
function headingAbove(md: string, fenceLine1: number): string {
const lines = md.split('\n');
for (let h = fenceLine1 - 2; h >= 0; h--) {
if (lines[h].startsWith('#')) return lines[h].replace(/^#+\s*/, '').trim();
}
return '';
}
// The run effects worth a spinner — the slow, operator-waits-on-it ones.
// `effect:step` is deliberately absent: it renders its own live operator output
// (a QR card, a pairing code) that a concurrent spinner would clobber, so it
// stays unlabelled (null) like the instant kinds.
const SPIN_EFFECTS = new Set(['build', 'test', 'fetch', 'wire', 'restart', 'external']);
/**
* The human caption a consumer may show for a step. `null` is a DECLARATION,
* not render advice: the step is instant/cheap (a local file copy, an env
* write, a json-merge), or it renders its own live operator-facing output
* (`effect:step`'s QR card / pairing code) the step event still carries
* `kind` + `line`, so a consumer wanting a different render policy can derive
* its own. Labels are HEADING-DERIVED only: the caption is the nearest heading
* above the directive (so a consumer's progress line reads like the section
* it's in), falling back to a kind/effect default.
*/
export function stepLabel(d: Directive, md: string): string | null {
const effect = typeof d.attrs.effect === 'string' ? d.attrs.effect : undefined;
const spins =
d.kind === 'dep' ||
(d.kind === 'copy' && typeof d.attrs['from-branch'] === 'string') ||
(d.kind === 'run' && (effect === undefined || SPIN_EFFECTS.has(effect)));
if (!spins) return null;
const heading = headingAbove(md, d.line);
if (heading) return heading;
if (d.kind === 'dep') return 'Installing dependencies';
if (d.kind === 'copy') return 'Fetching files';
const byEffect: Record<string, string> = {
build: 'Building', test: 'Testing', fetch: 'Fetching',
wire: 'Wiring', restart: 'Restarting', external: 'Running',
};
return (effect && byEffect[effect]) || 'Running';
}
// Deterministic input normalization applied AT BIND to every prompt value —
// `inputs` AND interactive answers alike — driven by `nc:prompt normalize:<how>`:
// trim strip leading/trailing whitespace
// rstrip-slash drop trailing slash(es) — a base URL with no trailing path
// lower lowercase
// Absent/unknown ⇒ a no-op (lint gates the known set). Doing it here, not in the
// consumer, means a programmatic `inputs` value and a typed answer land identically.
// Exported so the driver's reuse-offer pre-filter (§5.4) tests an `.env` value
// against the SAME normalize-then-validate the engine will apply at bind.
export function normalizeValue(value: string, normalize: string | undefined): string {
switch (normalize) {
case 'trim':
return value.trim();
case 'rstrip-slash':
return value.replace(/\/+$/, '');
case 'lower':
return value.toLowerCase();
default:
return value;
}
}
// The engine-applied normalize transforms (see `normalizeValue`) — the set
// InputMeta.normalize narrows to. Lint gates authorship to these; an unknown
// value simply isn't declared in the meta (and normalizeValue no-ops on it).
const NORMALIZE_KINDS: ReadonlySet<string> = new Set(['trim', 'rstrip-slash', 'lower']);
// The InputMeta an `nc:prompt` declares — handed to `resolveInput` so a
// consumer can run its own re-ask loop against the same semantics the engine
// enforces at bind. The attrs live on the directive fence, so they're stripped
// along with the fence when a skill degrades to prose — invisible to the agent.
function inputMetaOf(d: Directive, secret: boolean, validate: string | undefined): InputMeta {
const meta: InputMeta = { question: d.body.join('\n'), secret };
if (validate !== undefined) meta.validate = validate;
if (typeof d.attrs.flags === 'string') meta.flags = d.attrs.flags;
if (typeof d.attrs.normalize === 'string' && NORMALIZE_KINDS.has(d.attrs.normalize)) {
meta.normalize = d.attrs.normalize as InputMeta['normalize'];
}
return meta;
}
function substitute(value: string, vars: Map<string, { value: string; secret: boolean }>): string {
return value.replace(VAR_REF, (_, name) => {
const v = vars.get(name);
if (!v) throw new Error(`unresolved {{${name}}}`);
return v.value;
});
}
// A `when:<var>=<value>` guard: the directive applies only when an earlier
// prompt/capture bound <var> to exactly <value>. Unmet — including the var still
// unresolved (a deferred prompt) — skips the directive, so a guarded prompt is
// skipped, never deferred. This is how a skill expresses mutually-exclusive
// branches (e.g. local vs remote install mode) in plain document order.
function whenMet(when: string, vars: Map<string, { value: string; secret: boolean }>): boolean {
const eq = when.indexOf('=');
if (eq < 1) return true; // malformed → don't block (lint is the gate)
return vars.get(when.slice(0, eq).trim())?.value === when.slice(eq + 1).trim();
}
// Resolve a jq-style dot-path (`.id`, `.owner.id`) into a parsed JSON value.
// A missing/non-object hop yields undefined — the caller coerces that to ''.
function dotPath(obj: unknown, path: string): unknown {
let cur: unknown = obj;
for (const key of path.replace(/^\./, '').split('.').filter(Boolean)) {
if (cur === null || typeof cur !== 'object') return undefined;
cur = (cur as Record<string, unknown>)[key];
}
return cur;
}
// Bind a `run capture:<spec>` from a command's stdout into one or more {{vars}}.
// • bare `capture:var` → binds the trimmed stdout as-is (unchanged).
// • `capture:a=.x,b=.owner.id` → parses the stdout as JSON and binds each var
// to its dot-path, so ONE API call resolves
// several values (the structured twin of the
// effect:step terminal-block capture — those
// two are distinguished by effect: step reads
// the status block, fetch/external read JSON
// stdout). Unparseable JSON throws → the outer
// catch bounces it to an agent.
// An optional `validate:<re>` is enforced against every bound value; a mismatch
// THROWS so the run bounces to an agent — a command's output has no human to
// re-prompt, so an invalid capture is a real failure, not a re-ask.
function bindCapture(
spec: string,
stdout: string,
validate: string | undefined,
vars: Map<string, { value: string; secret: boolean }>,
): void {
const re = validate ? new RegExp(validate) : undefined;
const set = (name: string, value: string): void => {
if (re && !re.test(value)) throw new Error(`captured ${name}="${value}" does not match validate:${validate}`);
vars.set(name, { value, secret: false });
};
if (!spec.includes('=')) {
set(spec, stdout);
return;
}
const json = JSON.parse(stdout) as unknown; // not JSON → throws → outer catch bounces
for (const pair of spec.split(',')) {
const eq = pair.indexOf('=');
if (eq < 1) continue;
set(pair.slice(0, eq).trim(), String(dotPath(json, pair.slice(eq + 1).trim()) ?? ''));
}
}
// The mutating twin of selfStatus. Records what it did to the journal so remove
// is derivable. Throws on failure → caught and bounced to an agent.
async function applyOne(
d: Directive,
ctx: { root: string; skillDir: string; exec: (c: string) => string | void | Promise<string | void>; execStream?: (c: string) => Promise<StepOutcome>; resolveRemote: (b: string) => string; vars: Map<string, { value: string; secret: boolean }>; journal: JournalEntry[] },
): Promise<void> {
const { root, skillDir, exec, vars, journal } = ctx;
switch (d.kind) {
case 'copy':
if (d.attrs['from-branch']) {
const b = String(d.attrs['from-branch']);
const remote = ctx.resolveRemote(b);
await exec(`git fetch ${remote} ${b}`);
for (const l of d.body) await exec(`git show ${remote}/${b}:${srcOf(l)} > ${destOf(l)}`);
} else {
for (const l of d.body) {
const dst = join(root, destOf(l));
mkdirSync(dirname(dst), { recursive: true });
copyFileSync(join(skillDir, srcOf(l)), dst);
}
}
for (const l of d.body) journal.push({ op: 'wrote', path: destOf(l) });
break;
case 'append': {
const to = String(d.attrs.to);
const marker = typeof d.attrs.at === 'string' ? d.attrs.at : undefined;
const target = join(root, to);
if (marker) {
// Insert before the `// <<< <marker>` closing line of a dormant marker
// region, matching that line's indentation. removeSkill still deletes
// by line (position-agnostic), so the journal entry is unchanged.
const close = `<<< ${marker}`;
for (const line of d.body) {
const lines = read(target).split('\n');
const idx = lines.findIndex((l) => l.includes(close));
if (idx === -1) throw new Error(`append marker "${marker}" not found in ${to}`);
const indent = lines[idx].match(/^\s*/)?.[0] ?? '';
lines.splice(idx, 0, indent + line);
writeFileSync(target, lines.join('\n'));
journal.push({ op: 'appended', path: to, line });
}
} else {
for (const line of d.body) {
appendFileSync(target, (read(target).endsWith('\n') || read(target) === '' ? '' : '\n') + line + '\n');
journal.push({ op: 'appended', path: to, line });
}
}
break;
}
case 'dep': {
await exec(`pnpm add ${d.body.join(' ')}`);
const names = d.body.map((s) => s.slice(0, s.lastIndexOf('@'))).join(' ');
journal.push({ op: 'ran', cmd: `pnpm add ${d.body.join(' ')}`, undo: `pnpm remove ${names}` });
break;
}
case 'run': {
// `capture:<var>` binds the command's stdout into a {{var}} — the twin of
// `prompt` (which binds human input). Lets a run resolve a value from an
// API (e.g. Slack conversations.open → the DM channel id) and feed it to a
// later directive, so a flow that validates/resolves stays pure directives.
const capture = typeof d.attrs.capture === 'string' ? d.attrs.capture : undefined;
// A `validate:<re>` shape-guard the stdout capture enforces (see bindCapture).
const validate = typeof d.attrs.validate === 'string' ? d.attrs.validate : undefined;
// effect:check runs the body as a shell PREDICATE — a precondition gate
// that mutates NOTHING. It pushes no journal entry and binds no capture: a
// zero exit is a silent pass; a non-zero exit throws → the outer catch
// bounces it to an agent (which reads the prose and decides); an unresolved
// {{var}} throws from substitute first → deferred (like any other run, e.g.
// a headless rebuild before the value is collected). Because a bounce here
// latches `blocked`, a failed precondition gates the dangerous side effects
// (a restart, a pairing/QR step, a wire) that follow — a broken local
// config or an un-registered app never reaches a doomed restart/QR.
if (d.attrs.effect === 'check') {
for (const cmd of d.body) await exec(substitute(cmd, vars));
break;
}
// effect:step runs a long-running, operator-interactive step (a pairing
// code, a QR device-link) through the streaming exec and binds the terminal
// status block's named fields via capture:<var>=<FIELD>[,…] — the structured,
// multi-valued twin of stdout capture. No streaming exec ⇒ throw → an agent
// runs the step from the prose (degrade, not crash).
if (d.attrs.effect === 'step') {
if (!ctx.execStream) throw new Error('effect:step needs a streaming exec — an agent runs the step from the prose');
const { ok, fields } = await ctx.execStream(substitute(d.body.join('\n'), vars));
if (!ok) throw new Error('the step did not complete');
if (capture) {
for (const pair of capture.split(',')) {
const eq = pair.indexOf('=');
if (eq < 1) continue;
vars.set(pair.slice(0, eq).trim(), { value: (fields[pair.slice(eq + 1).trim()] ?? '').trim(), secret: false });
}
}
journal.push({ op: 'ran', cmd: d.body.join('\n') });
break;
}
for (const cmd of d.body) {
// Interpolate prompted {{vars}} the same way env-set does, so a run can
// call `ncl ... {{owner_email}}` to wire from collected input. A command
// with no {{...}} (build/test) is returned unchanged; an unresolved var
// throws → caught → deferred (the prompt hasn't been answered yet).
const out = await exec(substitute(cmd, vars));
// Last command wins for capture (a capture run should be a single command).
// bindCapture binds stdout-as-is OR a multi-field JSON spec, and enforces
// validate:<re> — a mismatch / unparseable JSON throws → bounced to an agent.
if (capture) bindCapture(capture, typeof out === 'string' ? out.trim() : '', validate, vars);
// Journal the ORIGINAL command (placeholders intact) — never the
// substituted form — so a secret interpolated into a run never lands in
// the journal (or a remove replay).
const undo = d.attrs.effect === 'external' && typeof d.attrs.remove === 'string' ? d.attrs.remove : undefined;
journal.push({ op: 'ran', cmd, undo });
}
break;
}
case 'env-set': {
const envPath = join(root, '.env');
for (const entry of d.body) {
const eq = entry.indexOf('=');
const key = entry.slice(0, eq).trim();
const value = substitute(entry.slice(eq + 1).trim(), vars); // throws if a {{var}} is unresolved
if (!envKeySet(root, key)) {
appendFileSync(envPath, (read(envPath).endsWith('\n') || read(envPath) === '' ? '' : '\n') + `${key}=${value}\n`);
journal.push({ op: 'set-env', key });
}
}
break;
}
case 'json-merge': {
const into = String(d.attrs.into);
const key = String(d.attrs.key);
const obj = JSON.parse(d.body.join('\n')) as Record<string, unknown>;
const target = join(root, into);
const arr = JSON.parse(read(target) || '[]') as unknown[];
if (!Array.isArray(arr)) throw new Error(`${into} is not a JSON array`);
const value = obj[key];
// Idempotent: only push when no element already matches on the key.
if (!arr.some((el) => el !== null && typeof el === 'object' && (el as Record<string, unknown>)[key] === value)) {
arr.push(obj);
writeFileSync(target, JSON.stringify(arr, null, 2) + '\n');
journal.push({ op: 'json-merge', path: into, key, value });
}
break;
}
default:
throw new Error(`no handler for nc:${d.kind}`);
}
}
export async function applySkill(skillDir: string, root: string, opts: ApplyOptions): Promise<ApplyResult> {
// Lint (validate()) is the authoring/CI gate, run before a skill ships — NOT
// here. Apply is best-effort: an unknown directive (a typo lint should have
// caught, or one newer than this engine) bounces to an agent, never blocks.
const md = read(join(skillDir, 'SKILL.md'));
const directives = parseDirectives(md);
const exec = opts.exec ?? (() => { throw new Error('no exec provided'); });
const resolveRemote = opts.resolveRemote ?? ((b: string) => defaultResolveRemote(b, root));
const vars = new Map<string, { value: string; secret: boolean }>();
const res: ApplyResult = { applied: [], skipped: [], deferred: [], agentTasks: [], operatorMessages: [], vars: {}, journal: [], referenceProse: referenceProse(md) };
// A run-health gate: once ANY directive bounces to an agent, the skill is no
// longer in a known-good state, so the dangerous side effects below must not
// fire on their own — a live restart, an interactive pairing/QR step, or a wire
// launched after an upstream failure just wastes the operator's time (a doomed
// QR, a restart that loads a bad credential). `blocked` latches on the first
// bounce; a later side-effecting run becomes its own bounce so the agent
// finishes it from the prose once the upstream failure is fixed. A DEFERRED
// prompt (headless rebuild, no answer) is not a failure — it never bounces, so
// `blocked` stays false and a later restart remains runnable.
let blocked = false;
const SIDE_EFFECTS = new Set(['restart', 'step', 'wire']);
const bounce = (d: Directive, reason: string) => {
blocked = true;
res.agentTasks.push({ kind: d.kind, line: d.line, reason, prose: proseFor(md, d.line) });
};
for (const d of directives) {
// Tracks an in-flight step so the catch can always close a matching
// step-end (start/end stay balanced even when applyOne throws — a consumer's
// spinner is never orphaned). Set only after step-start fires.
let inFlight: { label: string | null; at: number } | null = null;
try {
// A `when:<var>=<value>` guard that isn't met skips the directive entirely —
// before prompt (so a guarded prompt is skipped, never deferred), operator,
// and run handling. This is how mutually-exclusive branches coexist in one
// skill while a fully-programmatic apply still completes.
if (typeof d.attrs.when === 'string' && !whenMet(d.attrs.when, vars)) {
res.skipped.push(`${d.kind}: when ${d.attrs.when} not met`);
continue;
}
if (d.kind === 'prompt') {
const v = promptVar(d)!;
const secret = d.args.includes('secret');
const validate = typeof d.attrs.validate === 'string' ? d.attrs.validate : undefined;
const flags = typeof d.attrs.flags === 'string' ? d.attrs.flags : undefined;
const normalize = typeof d.attrs.normalize === 'string' ? d.attrs.normalize : undefined;
// Pre-supplied inputs win OUTRIGHT (fully-programmatic apply) — an
// invalid `inputs` value never falls through to a second acquisition
// path (validation below rejects it loudly instead). Otherwise resolve
// via `resolveInput`; still undefined ⇒ defer (headless, no answer).
let val = opts.inputs?.[v];
if (val === undefined) val = await opts.resolveInput?.(v, inputMetaOf(d, secret, validate));
if (val === undefined) { res.deferred.push(v); continue; }
// normalize:<how> binds DETERMINISTICALLY for both inputs and answers, so
// an `inputs` value and a typed one land identically (a trailing slash
// stripped, whitespace trimmed) — see normalizeValue.
const bound = normalizeValue(val, normalize);
// Validate-at-bind: `validate:` (+ `flags:`) is DATA validation, enforced
// on the NORMALIZED value no matter where it came from (normalize-then-
// validate is normative: a trailing slash is stripped before an anchor
// check). On a mismatch the var stays UNBOUND and only the var name +
// regex source land in the deferred entry — never the value, so a secret
// can't leak. Not an agentTask, not a throw: downstream consumers defer
// exactly as if the value were never supplied, `fullyApplied` is false,
// and a pipeline passing a malformed env value fails loudly. The
// interactive re-ask loop lives in the consumer's `resolveInput`; this is
// the backstop for programmatic paths.
if (validate !== undefined && !new RegExp(validate, flags).test(bound)) {
res.deferred.push(`${v}: invalid value (does not match validate:${validate})`);
continue;
}
vars.set(v, { value: bound, secret });
continue;
}
if (d.kind === 'operator') {
// Once the run is blocked, walking the human through further manual
// steps is actively misleading — the side effects those instructions
// lead up to ("a pairing code is about to appear") have already been
// gated. Skip: no event (so a consumer's URL offer / readiness confirm
// never fires), no operatorMessages entry (a failed run's manual-steps
// report must not include steps predicated on the failed one).
if (blocked) {
res.skipped.push('operator: skipped after an earlier failure');
continue;
}
// Always collect the human-facing instructions into the result so a
// programmatic caller can relay/output them. {{vars}} render so a
// resolved value can be shown (throws → deferred if a referenced var is
// unset — the whole block defers before any event fires).
const text = substitute(d.body.join('\n'), vars);
res.operatorMessages.push(text);
// The core seam: emit the rendered block and AWAIT the consumer before
// evaluating the next directive — that ordering is what lets a consumer
// gate (hold the event until the human confirms readiness). The engine
// itself never defers/bounces an operator block; a handler that throws
// opts into the standard bounce path via the outer catch (including
// the `blocked` latch over later side effects).
if (opts.onEvent) await opts.onEvent({ type: 'operator', line: d.line, text });
res.applied.push(`operator: ${(d.body[0] ?? '').slice(0, 50)}`);
continue;
}
// A run whose effect the caller owns (e.g. restart) is skipped here.
if (d.kind === 'run' && typeof d.attrs.effect === 'string' && opts.skipEffects?.includes(d.attrs.effect)) {
res.skipped.push(`run ${d.attrs.effect}: owned by the caller`);
continue;
}
// Run-health gate: after an earlier bounce, never fire a dangerous side
// effect (a live restart, an interactive pairing/QR step, a wire) on its
// own — bounce it too so the agent runs it from the prose once the upstream
// failure is fixed. (A deferred prompt did NOT set `blocked`, so this only
// trips on a real failure, never a headless rebuild's missing input.)
if (d.kind === 'run' && typeof d.attrs.effect === 'string' && SIDE_EFFECTS.has(d.attrs.effect) && blocked) {
bounce(d, 'skipped: an earlier step did not complete — run this from the prose after fixing it');
continue;
}
const st = selfStatus(d, root);
if (st.status === 'agent') { bounce(d, 'no deterministic handler'); continue; }
if (st.status === 'skip') { res.skipped.push(`${d.kind}: ${st.detail}`); continue; }
// Bracket the real mutation with step events so a consumer can render
// progress. `label` null is a step-cost/interactivity declaration (see
// `stepLabel`). `inFlight` is set only after step-start fires; the ok:true
// step-end clears it BEFORE its own (awaited) emission, so a consumer
// throw there never double-closes.
const label = stepLabel(d, md);
if (opts.onEvent) await opts.onEvent({ type: 'step-start', kind: d.kind, line: d.line, label });
inFlight = { label, at: Date.now() };
await applyOne(d, { root, skillDir, exec, execStream: opts.execStream, resolveRemote, vars, journal: res.journal });
const durationMs = Date.now() - inFlight.at;
inFlight = null;
if (opts.onEvent) await opts.onEvent({ type: 'step-end', kind: d.kind, line: d.line, label, ok: true, durationMs });
res.applied.push(`${d.kind}: ${st.detail}`);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
// Close the step as failed before classifying — keeps step-start/step-end
// balanced whether the throw becomes a deferred (unresolved input) or a
// bounce (a real failure, handled below). The failure-path close is
// best-effort: a consumer that also throws here can't change the outcome —
// we're already on the failure path.
if (inFlight && opts.onEvent) {
const end = { kind: d.kind, line: d.line, label: inFlight.label, ok: false, durationMs: Date.now() - inFlight.at, error: msg };
try { await opts.onEvent({ type: 'step-end', ...end }); } catch { /* already failing — the close is best-effort */ }
}
if (/unresolved \{\{/.test(msg)) res.deferred.push(msg); // blocked on a prompt input
else bounce(d, `engine could not apply (${msg}) — an agent applies it from the prose`);
}
}
// Surface the non-secret resolved values for a caller to consume.
for (const [k, v] of vars) if (!v.secret) res.vars[k] = v.value;
return res;
}
// Remove is the journal played backwards — no hand-written REMOVE.md.
export async function removeSkill(root: string, journal: JournalEntry[], exec?: (c: string) => void | Promise<void>): Promise<void> {
for (const e of [...journal].reverse()) {
if (e.op === 'wrote') rmSync(join(root, e.path), { force: true });
else if (e.op === 'appended') {
const p = join(root, e.path);
writeFileSync(p, read(p).split('\n').filter((l) => l.trim() !== e.line.trim()).join('\n'));
} else if (e.op === 'set-env') {
const p = join(root, '.env');
writeFileSync(p, read(p).split('\n').filter((l) => !l.startsWith(`${e.key}=`)).join('\n'));
} else if (e.op === 'json-merge') {
const p = join(root, e.path);
const arr = JSON.parse(read(p) || '[]') as unknown[];
if (Array.isArray(arr)) {
writeFileSync(p, JSON.stringify(arr.filter((el) => !(el !== null && typeof el === 'object' && (el as Record<string, unknown>)[e.key] === e.value)), null, 2) + '\n');
}
} else if (e.op === 'ran' && e.undo && exec) {
await exec(e.undo);
}
}
}
// CLI — the planner (no writes)
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
const skillDir = process.argv[2];
if (!skillDir) {
console.error('usage: pnpm exec tsx scripts/skill-apply.ts <skillDir>');
process.exit(2);
}
const root = process.cwd();
const { steps, needsInput, agentSteps } = planSkill(skillDir, root);
console.log(`PLAN ${skillDir} project: ${root}\n`);
const icon: Record<StepStatus, string> = { skip: '✓ skip', apply: '→ apply', 'needs-input': '? human', agent: '↳ agent' };
for (const s of steps) console.log(`${String(s.n).padStart(2)}. ${icon[s.status].padEnd(8)} ${s.kind.padEnd(9)} ${s.detail}`);
console.log(`\nneeds human input: ${needsInput.join(', ') || '(none)'} →agent: ${agentSteps}`);
}
+458
View File
@@ -0,0 +1,458 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { parseDirectives, validate, promptVar, resolveChatCoreVersion, lintReferenceFloor, lintGateAmbiguity } from './skill-directives.js';
// Guards the structured-directive format against the converted add-slack skill:
// red if the conversion drifts (a directive dropped/renamed) or the parser breaks.
const slack = readFileSync('.claude/skills/add-slack/SKILL.md', 'utf8');
const directives = parseDirectives(slack);
describe('skill-directives parser, on the converted add-slack', () => {
it('extracts every directive in document order — install, credentials, resolve, restart', () => {
expect(directives.map((d) => d.kind)).toEqual([
'copy', // step 1: adapter + test from the channels branch
'append', // step 2: barrel registration
'dep', // step 3: pinned package
'run', // step 4: build
'run', // step 4: test
'prompt', // credentials: socket vs webhook delivery mode
'operator', // credentials: create-app walkthrough, Socket Mode variant
'operator', // credentials: create-app walkthrough, webhook variant
'prompt', // credentials: capture bot token
'prompt', // credentials: capture app-level token (socket only)
'prompt', // credentials: capture signing secret (webhook only)
'env-set', // credentials: bot token (both modes)
'env-set', // credentials: app token — doubles as the Socket Mode switch
'env-set', // credentials: signing secret (webhook only)
'operator', // credentials: event-delivery walkthrough (webhook only)
'prompt', // resolve: owner member id (owner_handle)
'run', // resolve: validate token (auth.test) — fast-fail before the restart
'run', // resolve: DM channel (conversations.open → capture:platform_id)
'run', // restart: load the adapter + creds once the credential is validated
]);
// The wire (owner role, messaging-group, wiring, /welcome) is NOT in the
// skill — it's the shared init-first-agent, called by the setup flow.
expect(directives.some((d) => d.attrs.effect === 'wire')).toBe(false);
});
it('delineates the human UI steps as nc:operator (not agent prose or a run)', () => {
const ops = directives.filter((d) => d.kind === 'operator');
expect(ops).toHaveLength(3);
expect(ops[0].body.join('\n')).toMatch(/Create the Slack app \(Socket Mode\)/);
expect(ops[0].body.join('\n')).toMatch(/connections:write/);
expect(ops[1].body.join('\n')).toMatch(/Create the Slack app \(webhook delivery\)/);
expect(ops[1].body.join('\n')).toMatch(/Bot Token Scopes/);
expect(ops[2].body.join('\n')).toMatch(/Event Subscriptions/);
// the mode branches are guard-delineated, one per delivery mode
expect(ops[0].attrs.when).toBe('connection=socket');
expect(ops[1].attrs.when).toBe('connection=webhook');
expect(ops[2].attrs.when).toBe('connection=webhook');
});
it('reads copy as a branch fetch with both files', () => {
const copy = directives.find((d) => d.kind === 'copy')!;
expect(copy.attrs['from-branch']).toBe('channels');
expect(copy.body).toEqual(['src/channels/slack.ts', 'src/channels/slack-registration.test.ts']);
});
it('reads the barrel append target and line', () => {
const append = directives.find((d) => d.kind === 'append')!;
expect(append.attrs.to).toBe('src/channels/index.ts');
expect(append.body).toEqual(["import './slack.js';"]);
});
it('reads the dependency pinned exactly', () => {
const dep = directives.find((d) => d.kind === 'dep')!;
expect(dep.body).toEqual(['@chat-adapter/slack@4.29.0']);
});
it('tags the runs with their effects', () => {
expect(directives.filter((d) => d.kind === 'run').map((d) => d.attrs.effect)).toEqual([
'build',
'test',
'fetch', // validate: auth.test — credential checked first
'fetch', // resolve: conversations.open
'restart', // load adapter + creds after the credential is validated, before wiring
]);
});
it('captures prompts into named vars — credentials secret, the mode and handle not', () => {
const prompts = directives.filter((d) => d.kind === 'prompt');
expect(prompts.map(promptVar)).toEqual(['connection', 'bot_token', 'app_token', 'signing_secret', 'owner_handle']);
expect(prompts[0].args).not.toContain('secret'); // connection — a mode choice, not a secret
expect(prompts[1].args).toContain('secret'); // bot_token
expect(prompts[2].args).toContain('secret'); // app_token
expect(prompts[3].args).toContain('secret'); // signing_secret
expect(prompts[4].args).not.toContain('secret'); // owner_handle — a plain id, not a secret
// Each mode's credential is guard-scoped to its branch.
expect(prompts[2].attrs.when).toBe('connection=socket');
expect(prompts[3].attrs.when).toBe('connection=webhook');
// The prompt body is the question; it does not mention env at all.
expect(prompts[1].body.join(' ')).toMatch(/Bot User OAuth Token/);
});
it('resolves the conversation address into capture:platform_id (the wire input)', () => {
const runs = directives.filter((d) => d.kind === 'run');
const resolve = runs.find((d) => d.attrs.capture === 'platform_id')!;
expect(resolve).toBeTruthy();
expect(resolve.body.join(' ')).toMatch(/conversations\.open/);
expect(resolve.body.join(' ')).toMatch(/"slack:" \+ \.channel\.id/); // emits the slack:<id> platform_id
});
it('wires the captured variables into env-set via {{var}} references, one per mode', () => {
const envSets = directives.filter((d) => d.kind === 'env-set');
expect(envSets.map((d) => d.body)).toEqual([
['SLACK_BOT_TOKEN={{bot_token}}'],
['SLACK_APP_TOKEN={{app_token}}'],
['SLACK_SIGNING_SECRET={{signing_secret}}'],
]);
expect(envSets[1].attrs.when).toBe('connection=socket');
expect(envSets[2].attrs.when).toBe('connection=webhook');
});
it('passes validation (well-formed, pinned, every {{var}} captured first)', () => {
expect(validate(directives)).toEqual([]);
});
it('keeps its @chat-adapter pin in sync with our chat core (drift guard)', () => {
const chat = resolveChatCoreVersion(process.cwd());
expect(chat).toMatch(/^\d+\.\d+\.\d+/); // our lockfile resolves a real chat version
expect(validate(directives, { chatVersion: chat })).toEqual([]); // add-slack matches it
});
it('ignores plain (non-nc:) code fences so prose stays the floor', () => {
const withProse = slack + '\n```bash\nrm -rf /\n```\n';
expect(parseDirectives(withProse).map((d) => d.kind)).toEqual(directives.map((d) => d.kind));
});
});
describe('validation catches malformed directives', () => {
it('flags an unpinned dependency and an unknown directive', () => {
const md = ['```nc:dep', '@chat-adapter/slack@latest', '```', '', '```nc:frobnicate', 'x', '```'].join('\n');
const problems = validate(parseDirectives(md));
expect(problems.some((p) => /exact semver/.test(p.message))).toBe(true);
expect(problems.some((p) => /unknown directive/.test(p.message))).toBe(true);
});
it('flags an env-set that references a variable no prompt captured', () => {
const md = ['```nc:env-set', 'SLACK_BOT_TOKEN={{bot_token}}', '```'].join('\n');
const problems = validate(parseDirectives(md));
expect(problems.some((p) => /\{\{bot_token\}\} but no earlier nc:prompt/.test(p.message))).toBe(true);
});
it('flags a @chat-adapter pin that does not match the chat core', () => {
const md = ['```nc:dep', '@chat-adapter/slack@4.27.0', '```'].join('\n');
const problems = validate(parseDirectives(md), { chatVersion: '4.26.0' });
expect(problems.some((p) => /must match the chat package/.test(p.message))).toBe(true);
});
it('accepts a @chat-adapter pin that matches the chat core', () => {
const md = ['```nc:dep', '@chat-adapter/slack@4.26.0', '```'].join('\n');
expect(validate(parseDirectives(md), { chatVersion: '4.26.0' })).toEqual([]);
});
});
describe('json-merge directive', () => {
const codex = ['```nc:json-merge into:container/cli-tools.json key:name', '{ "name": "@openai/codex", "version": "0.138.0" }', '```'].join('\n');
it('parses into/key attrs and the JSON object body', () => {
const [d] = parseDirectives(codex);
expect(d.kind).toBe('json-merge');
expect(d.attrs.into).toBe('container/cli-tools.json');
expect(d.attrs.key).toBe('name');
expect(JSON.parse(d.body.join('\n'))).toEqual({ name: '@openai/codex', version: '0.138.0' });
});
it('passes validation when into + key + a parseable object are all present', () => {
expect(validate(parseDirectives(codex))).toEqual([]);
});
it('flags a missing into:', () => {
const md = ['```nc:json-merge key:name', '{ "name": "x" }', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /requires into:/.test(p.message))).toBe(true);
});
it('flags a missing key:', () => {
const md = ['```nc:json-merge into:container/cli-tools.json', '{ "name": "x" }', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /requires key:/.test(p.message))).toBe(true);
});
it('flags an unparseable body', () => {
const md = ['```nc:json-merge into:f.json key:name', '{ not json', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /parseable JSON object/.test(p.message))).toBe(true);
});
it('flags a body that is an array, not a single object', () => {
const md = ['```nc:json-merge into:f.json key:name', '[{ "name": "x" }]', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /single JSON object/.test(p.message))).toBe(true);
});
it('flags a body missing the match key field', () => {
const md = ['```nc:json-merge into:f.json key:name', '{ "version": "1.0.0" }', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /no "name" field/.test(p.message))).toBe(true);
});
});
describe('append at:<marker> attribute', () => {
it('parses an optional at:<marker> alongside to:', () => {
const md = ['```nc:append to:setup/index.ts at:nanoclaw:setup-steps', " codex: () => import('./codex.js'),", '```'].join('\n');
const [d] = parseDirectives(md);
expect(d.kind).toBe('append');
expect(d.attrs.to).toBe('setup/index.ts');
expect(d.attrs.at).toBe('nanoclaw:setup-steps');
});
it('still validates an append that carries at: (to + a line are all it needs)', () => {
const md = ['```nc:append to:setup/index.ts at:nanoclaw:setup-steps', " codex: () => import('./codex.js'),", '```'].join('\n');
expect(validate(parseDirectives(md))).toEqual([]);
});
});
describe('retired directives', () => {
it('flags nc:env-sync with a targeted retirement error, not a generic unknown', () => {
const probs = validate(parseDirectives(['```nc:env-sync', '```'].join('\n')));
expect(probs).toHaveLength(1);
expect(probs[0].message).toMatch(/retired/);
expect(probs[0].message).toMatch(/data\/env\/env/);
});
});
describe('when: guard + multi-field capture', () => {
it('parses when: into attrs and lints a guard whose var an earlier prompt defined', () => {
const md = ['```nc:prompt mode', 'local or remote', '```', '```nc:prompt server_url when:mode=remote', 'url', '```'].join('\n');
const ds = parseDirectives(md);
expect(ds[1].attrs.when).toBe('mode=remote');
expect(validate(ds)).toEqual([]);
});
it('flags a when: guard whose var no earlier prompt/capture defined', () => {
const probs = validate(parseDirectives(['```nc:env-set when:mode=remote', 'X=1', '```'].join('\n')));
expect(probs.some((p) => /when:mode=remote references \{\{mode\}\}/.test(p.message))).toBe(true);
});
it('flags a malformed when: with no =', () => {
const md = ['```nc:prompt mode', 'm', '```', '```nc:env-set when:mode', 'X=1', '```'].join('\n');
const probs = validate(parseDirectives(md));
expect(probs.some((p) => /when:mode must be <var>=<value>/.test(p.message))).toBe(true);
});
it('registers each capture:<var>=<FIELD> as defined so downstream {{vars}} pass lint', () => {
const md = [
'```nc:run effect:step capture:platform_id=PLATFORM_ID,owner_handle=ADMIN_ID',
'run the step',
'```',
'```nc:env-set',
'P={{platform_id}}',
'O={{owner_handle}}',
'```',
].join('\n');
expect(validate(parseDirectives(md))).toEqual([]);
});
it('registers each capture:<var>=<dot-path> (JSON multi-field) var as defined for downstream {{vars}}', () => {
const md = [
'```nc:run capture:application_id=.id,public_key=.verify_key,owner_handle=.owner.id effect:fetch',
'curl -sf https://example/app',
'```',
'```nc:env-set',
'APP={{application_id}}',
'PUB={{public_key}}',
'OWN={{owner_handle}}',
'```',
].join('\n');
expect(validate(parseDirectives(md))).toEqual([]);
});
it('flags an invalid run capture validate:<re> regex', () => {
const md = ['```nc:run capture:app_id=.id effect:fetch validate:^[', 'curl x', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /run validate:.*is not a valid regex/.test(p.message))).toBe(true);
});
it('accepts a valid run capture validate:<re> regex', () => {
const md = ['```nc:run capture:app_id=.id effect:fetch validate:^\\d+$', 'curl x', '```'].join('\n');
expect(validate(parseDirectives(md))).toEqual([]);
});
});
describe('prompt attrs (flags/normalize/reuse)', () => {
it('parses flags/normalize/reuse into attrs alongside the var + secret flag', () => {
const md = [
'```nc:prompt server_url secret validate:^https?:// flags:i normalize:rstrip-slash reuse:IMESSAGE_SERVER_URL',
'URL?',
'```',
].join('\n');
const [d] = parseDirectives(md);
expect(promptVar(d)).toBe('server_url'); // the var, not `secret`
expect(d.attrs.flags).toBe('i');
expect(d.attrs.normalize).toBe('rstrip-slash');
expect(d.attrs.reuse).toBe('IMESSAGE_SERVER_URL');
expect(validate([d])).toEqual([]); // a well-formed prompt with all attrs lints clean
});
it('accepts validate:<re> combined with flags:i (a case-insensitive regex is still valid)', () => {
const md = ['```nc:prompt u validate:^https?:// flags:i', 'URL?', '```'].join('\n');
expect(validate(parseDirectives(md))).toEqual([]);
});
it('flags an unknown normalize: value', () => {
const md = ['```nc:prompt u normalize:uppercase', 'q', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /normalize:uppercase must be one of/.test(p.message))).toBe(true);
});
it('flags a reuse: that is not a valid ENV_KEY', () => {
const md = ['```nc:prompt u reuse:not-an-env-key', 'q', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /reuse:not-an-env-key must be a valid ENV_KEY/.test(p.message))).toBe(true);
});
it('flags illegal regex flags:', () => {
const md = ['```nc:prompt u validate:^x flags:zzz', 'q', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /is not a valid regex/.test(p.message))).toBe(true);
});
});
// lintReferenceFloor is a WARN-ONLY smell check (never an error, never blocks):
// a credentialed (nc:prompt secret) or interactive (nc:run effect:step) skill
// should ship a ## Troubleshooting section — the human floor when a live step
// misbehaves. It warns when that floor is absent and is silent otherwise.
describe('lintReferenceFloor (warn-only reference floor)', () => {
it('warns when a secret-bearing skill has no ## Troubleshooting', () => {
const md = ['```nc:prompt token secret', 'Paste it.', '```'].join('\n');
const warnings = lintReferenceFloor(md);
expect(warnings).toHaveLength(1);
expect(warnings[0].kind).toBe('reference-floor');
expect(warnings[0].message).toMatch(/## Troubleshooting/);
});
it('warns when an interactive effect:step skill has no ## Troubleshooting', () => {
const md = ['```nc:run effect:step capture:platform_id=PLATFORM_ID', 'pair', '```'].join('\n');
expect(lintReferenceFloor(md)).toHaveLength(1);
});
it('is silent once a ## Troubleshooting section is present', () => {
const md = ['```nc:prompt token secret', 'Paste it.', '```', '', '## Troubleshooting', 'Check the logs.'].join('\n');
expect(lintReferenceFloor(md)).toEqual([]);
});
it('is silent for a skill with no secret prompt or effect:step (no floor expected)', () => {
const md = ['```nc:prompt handle', 'Your handle.', '```', '```nc:env-set', 'H={{handle}}', '```'].join('\n');
expect(lintReferenceFloor(md)).toEqual([]);
});
it('never warns on the real credentialed channel skills — they ship a ## Troubleshooting', () => {
for (const ch of ['add-signal', 'add-whatsapp', 'add-teams']) {
const md = readFileSync(`.claude/skills/${ch}/SKILL.md`, 'utf8');
expect(lintReferenceFloor(md)).toEqual([]);
}
});
});
// Grammar diet: the six removed presentation attrs are hard lint ERRORS so
// stale authorship fails loudly instead of silently no-oping. Each error points
// at the attr's replacement (validate: regex, question prose, body prose,
// document structure, the preceding heading, the surrounding prose).
describe('removed presentation attrs are lint errors', () => {
it('rejects operator open: — the URL belongs in the body prose', () => {
const md = ['```nc:operator open:https://portal.azure.com', 'Visit https://portal.azure.com and click through.', '```'].join('\n');
const probs = validate(parseDirectives(md));
expect(probs.some((p) => /operator open: was removed — put the URL in the body prose/.test(p.message))).toBe(true);
});
it('rejects the bare operator gate flag — the barrier is structure-derived', () => {
const md = ['```nc:operator gate', 'Finish the UI steps.', '```'].join('\n');
const probs = validate(parseDirectives(md));
expect(probs.some((p) => /operator gate was removed — the human barrier is derived from document structure/.test(p.message))).toBe(true);
});
it('rejects prompt min: — length is regex-encoded now', () => {
const md = ['```nc:prompt app_password secret min:20', 'Paste the client secret.', '```'].join('\n');
const probs = validate(parseDirectives(md));
expect(probs.some((p) => /prompt min: was removed — encode the length in validate:/.test(p.message))).toBe(true);
});
it('rejects prompt error: — the miss message derives from the question prose', () => {
const md = ['```nc:prompt token error:bad-token', 'Paste the token.', '```'].join('\n');
const probs = validate(parseDirectives(md));
expect(probs.some((p) => /prompt error: was removed — the validation-miss message derives from the question prose/.test(p.message))).toBe(true);
});
it('rejects label: on any directive — labels are heading-derived only', () => {
const md = ['```nc:run effect:build label:build', 'pnpm run build', '```'].join('\n');
const probs = validate(parseDirectives(md));
expect(probs.some((p) => /label: was removed — step labels derive from the preceding heading/.test(p.message))).toBe(true);
});
it('rejects on-fail: on any directive — the hint is always the surrounding prose', () => {
const md = ['```nc:run effect:test on-fail:rerun', 'pnpm test', '```'].join('\n');
const probs = validate(parseDirectives(md));
expect(probs.some((p) => /on-fail: was removed — the failure hint is always the surrounding prose/.test(p.message))).toBe(true);
});
it('still accepts a plain operator block (body-only, no attrs)', () => {
const md = ['```nc:prompt bot', 'Bot?', '```', '```nc:operator', 'Open @{{bot}} and press Start.', '```'].join('\n');
expect(validate(parseDirectives(md))).toEqual([]);
});
});
// lintGateAmbiguity is a WARN-ONLY check (never an error, never blocks): an
// UNGUARDED operator followed by when:-guarded directives spanning more than
// one branch value keys its natural-barrier decision off a directive that may
// be runtime-skipped — the static policy cannot know which branch runs.
describe('lintGateAmbiguity (warn-only unguarded-operator/multi-branch)', () => {
const branchy = [
'```nc:prompt mode', 'local or remote?', '```',
'```nc:operator', 'Get ready.', '```',
'```nc:prompt server_url when:mode=remote', 'URL?', '```',
'```nc:run effect:external when:mode=local', './configure.sh', '```',
].join('\n');
it('warns on an unguarded operator followed by guards spanning two branch values', () => {
const warnings = lintGateAmbiguity(parseDirectives(branchy));
expect(warnings).toHaveLength(1);
expect(warnings[0].kind).toBe('gate-ambiguity');
expect(warnings[0].line).toBe(4); // the operator's opening fence
expect(warnings[0].message).toMatch(/mode=remote/);
expect(warnings[0].message).toMatch(/mode=local/);
});
it('the warning is not a validate() error — the skill still lints clean', () => {
expect(validate(parseDirectives(branchy))).toEqual([]);
});
it('is silent when the operator itself is guarded (mutually-exclusive branches gate on their own next action)', () => {
const md = [
'```nc:prompt mode', 'local or remote?', '```',
'```nc:operator when:mode=local', 'Get ready.', '```',
'```nc:prompt server_url when:mode=remote', 'URL?', '```',
'```nc:run effect:external when:mode=local', './configure.sh', '```',
].join('\n');
expect(lintGateAmbiguity(parseDirectives(md))).toEqual([]);
});
it('is silent when the following guards all share one branch value', () => {
const md = [
'```nc:prompt mode', 'local or remote?', '```',
'```nc:operator', 'Get ready.', '```',
'```nc:prompt server_url when:mode=remote', 'URL?', '```',
'```nc:prompt api_key when:mode=remote', 'Key?', '```',
].join('\n');
expect(lintGateAmbiguity(parseDirectives(md))).toEqual([]);
});
it('stops scanning at the first unguarded directive (it always runs — no ambiguity past it)', () => {
const md = [
'```nc:prompt mode', 'local or remote?', '```',
'```nc:operator', 'Get ready.', '```',
'```nc:run effect:build', 'pnpm run build', '```',
'```nc:prompt server_url when:mode=remote', 'URL?', '```',
'```nc:run effect:external when:mode=local', './configure.sh', '```',
].join('\n');
expect(lintGateAmbiguity(parseDirectives(md))).toEqual([]);
});
it('never warns on the in-tree channel skills (none author the pattern)', () => {
for (const ch of ['add-slack', 'add-discord', 'add-telegram', 'add-teams', 'add-whatsapp', 'add-signal', 'add-imessage']) {
const md = readFileSync(`.claude/skills/${ch}/SKILL.md`, 'utf8');
expect(lintGateAmbiguity(parseDirectives(md))).toEqual([]);
}
});
});
+420
View File
@@ -0,0 +1,420 @@
// Extract `nc:` skill directives embedded in a SKILL.md.
//
// A fenced code block whose info-string starts with `nc:` is a load-bearing
// directive; every other fence (and all prose) is the human floor the parser
// ignores. That is the whole "two readers, one document" property: an agent
// applies the prose, a tool applies the directives, and anything the tool
// can't handle degrades to the prose beside it. This is the seed for both the
// conformance linter and the deterministic applier.
//
// Grammar, derived from add-slack:
//
// ```nc:<directive> <arg>... [key:value]...
// <body line>
// ```
//
// `prompt` only *acquires* a value and binds it to a name; a separate directive
// *applies* it, referenced as `{{name}}`. That keeps "ask the human" decoupled
// from "what you do with the answer" (env, ncl, the OneCLI vault, a file).
//
// copy [from-branch:<b>] body: `PATH` (src==dst) or `SRC -> DST` overwrite
// append to:<file> [at:<marker>] body: line(s) to add skip if present
// dep [manager:pnpm] body: `pkg@<exact-semver>` line(s) reinstall no-op
// run [effect:build|test|fetch|external|wire|restart|step|check] [capture:<spec>] re-runnable
// body: shell command(s). {{vars}} are substituted in. effect:wire runs
// `ncl …` to wire collected input (no undo — the rows it creates are user
// runtime data, not reversed on skill remove). effect:restart restarts the
// service so following `ncl` runs reach it; a caller that owns the restart
// (rebuild, or a setup that restarts once) skips it via ApplyOptions.
// skipEffects. capture:<var> binds the command's stdout into {{var}} (twin
// of prompt) — e.g. resolve an id from an API and feed it to a later step.
// capture:<var>=<dot-path>[,<var2>=<dot-path2>…] parses the stdout as JSON
// and binds each var to its jq-style dot-path (.id, .owner.id), so ONE API
// call resolves several values at once. validate:<re> shape-guards each
// captured value (e.g. validate:^discord:); a mismatch bounces the run to
// an agent (a command's output has no human to re-prompt — unlike prompt).
// effect:step runs a long-running, operator-interactive step (a pairing
// code, a QR device-link) through the streaming exec: its
// `=== NANOCLAW SETUP: … ===` status blocks render to the operator live and
// `capture:<var>=<FIELD>[,<var2>=<FIELD2>…]` binds the terminal block's
// named fields into {{vars}} (multi-valued, structured twin of stdout
// capture). Degrades to an agent when no streaming exec is wired.
// effect:check runs the body as a shell PREDICATE (a precondition gate):
// it mutates nothing (no journal, no capture). A zero exit passes silently;
// a non-zero exit bounces to an agent (degrade, not crash) and, via the
// run-health gate, blocks the dangerous side effects that follow it (a
// restart, a pairing/QR step, a wire). An unresolved {{var}} defers.
// prompt <var> [secret] [validate:<re>] [flags:<re-flags>]
// [normalize:trim|rstrip-slash|lower] [reuse:<ENV_KEY>]
// body: the question → binds {{var}} skip if satisfied
// validate:<re> is a regex enforced AT BIND for EVERY value — `inputs` and
// interactive answers alike (e.g. validate:^xoxb- to require a Slack bot
// token); a mismatch leaves the var unbound and records a deferred entry.
// A minimum length is regex-encoded (e.g. validate:^.{20,}$).
// flags:<re-flags> are regex flags applied to validate (e.g. flags:i → a
// case-insensitive scheme match). normalize:<how> deterministically
// transforms the value AT BIND, before validate — for BOTH `inputs` and
// interactive answers — one of trim | rstrip-slash | lower.
// reuse:<ENV_KEY> lets a re-run offer an existing .env value for a credential
// a HELPER SCRIPT owns (written by effect:external, not nc:env-set) — the
// masked reuse offer the env-set→ENV_KEY inference can't otherwise see.
// operator body: instructions for the human operator output-only
// The SKILL.md is addressed to the coding agent; `operator` delineates the
// parts meant for the HUMAN (e.g. clicking through the Slack UI). Lead it
// with agent-facing prose like "Tell the user:" so the agent relays it;
// the engine renders the body to the operator ({{vars}} substituted in).
// The block carries NO presentation attrs: a URL to visit lives in the
// body prose (a consumer may offer to open it), and whether a consumer
// pauses for confirmation before the next side effect is derived from
// document structure (scripts/skill-policy.ts), never authored here.
// env-set body: `KEY=value` ({{var}} allowed) set-if-absent
// json-merge into:<file> key:<field> body: a JSON object push-if-absent
//
// `append` without `at:` adds to EOF; with `at:<marker>` it inserts before the
// `// <<< <marker>` closing line of a dormant marker region (see setup/index.ts).
// `json-merge` reads an array-of-objects JSON file and pushes the body object
// unless an element already has body[key]===element[key] (idempotent by key).
//
// Any directive may carry `when:<var>=<value>` — a guard evaluated against an
// earlier prompt/capture var. If it doesn't match (including the var being
// unresolved), the directive is skipped — a guarded prompt is skipped, never
// deferred — so one skill can express mutually-exclusive branches (e.g. a local
// vs remote install mode) in document order while still running fully
// programmatically from `inputs`.
//
// Removed presentation attrs — `min:`/`error:` on prompt, `open:`/`gate` on
// operator, `label:`/`on-fail:` on any directive — are lint ERRORS, so stale
// authorship fails loudly instead of silently no-oping. Their jobs moved:
// length checks into validate: regexes, miss messages derive from the question
// prose, URLs live in the operator body, gating and step labels derive from
// document structure (scripts/skill-policy.ts / the preceding heading), and the
// failure hint is always the surrounding prose.
//
// Usage: pnpm exec tsx scripts/skill-directives.ts <SKILL.md>
import { readFileSync, existsSync, statSync } from 'node:fs';
import { join } from 'node:path';
export interface Directive {
kind: string;
args: string[]; // positional bare tokens, e.g. prompt's variable name
attrs: Record<string, string | true>; // key:value tokens
body: string[];
line: number; // 1-based line of the opening fence
}
export interface Problem {
line: number;
kind: string;
message: string;
}
const FENCE = /^```(\S.*)?$/;
const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
const VAR_REF = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
const KNOWN = new Set(['copy', 'append', 'dep', 'run', 'prompt', 'operator', 'env-set', 'json-merge']);
// Retired directives get a targeted lint error (not just "unknown") so an
// author knows the removal was deliberate and what to do instead.
const RETIRED: Record<string, string> = {
'env-sync': 'nc:env-sync was retired — nothing reads the data/env/env mirror (and it copied live tokens); delete the fence, the adapter reads .env directly',
};
const PROMPT_FLAGS = new Set(['secret']);
export function parseDirectives(markdown: string): Directive[] {
const lines = markdown.split('\n');
const out: Directive[] = [];
let i = 0;
while (i < lines.length) {
const info = lines[i].match(FENCE)?.[1]?.trim();
if (info === undefined) {
i++;
continue;
}
// A fence opens here; consume to its closing fence either way.
let j = i + 1;
const body: string[] = [];
while (j < lines.length && !FENCE.test(lines[j])) {
body.push(lines[j]);
j++;
}
if (info.startsWith('nc:')) {
const [tag, ...rest] = info.split(/\s+/);
const args: string[] = [];
const attrs: Record<string, string | true> = {};
for (const tok of rest) {
const eq = tok.indexOf(':');
if (eq > 0) attrs[tok.slice(0, eq)] = tok.slice(eq + 1);
else args.push(tok);
}
out.push({
kind: tag.slice('nc:'.length),
args,
attrs,
body: body.map((l) => l.trim()).filter(Boolean),
line: i + 1,
});
}
i = j + 1; // skip past the closing fence (directive or plain code block)
}
return out;
}
/** The variable a `prompt` binds (the first positional that isn't a flag). */
export function promptVar(d: Directive): string | undefined {
return d.args.find((a) => !PROMPT_FLAGS.has(a));
}
/**
* The variable name(s) a `run capture:<spec>` binds. `capture:dm_channel`
* `['dm_channel']` (stdout form); `capture:platform_id=PLATFORM_ID,owner=ACCOUNT`
* `['platform_id','owner']` (effect:step field form).
*/
export function captureVars(spec: string): string[] {
if (!spec.includes('=')) return [spec];
return spec
.split(',')
.map((pair) => pair.slice(0, pair.indexOf('=')).trim())
.filter(Boolean);
}
/** `{{var}}` names referenced anywhere in a directive's body. */
function referencedVars(d: Directive): string[] {
const found: string[] = [];
for (const line of d.body) for (const m of line.matchAll(VAR_REF)) found.push(m[1]);
return found;
}
/**
* The resolved `chat` core version from our lockfile the single source of
* truth a `@chat-adapter/*` adapter pin must match (the adapter and the core
* move in lockstep). Reads the root importer's direct `chat` dependency, whose
* `specifier`/`version` pair is unique to importer deps (transitive entries in
* the packages section have no `specifier`). Returns undefined if not found.
*/
export function resolveChatCoreVersion(root: string): string | undefined {
let lock = '';
try {
lock = readFileSync(join(root, 'pnpm-lock.yaml'), 'utf8');
} catch {
return undefined;
}
const m = lock.match(/\n\s+chat:\n\s+specifier:[^\n]*\n\s+version:\s*([0-9][^\s(]*)/);
return m?.[1];
}
export function validate(directives: Directive[], ctx?: { chatVersion?: string }): Problem[] {
const problems: Problem[] = [];
const defined = new Set<string>();
const flag = (d: Directive, message: string) => problems.push({ line: d.line, kind: d.kind, message });
for (const d of directives) {
if (RETIRED[d.kind]) flag(d, RETIRED[d.kind]);
else if (!KNOWN.has(d.kind)) flag(d, `unknown directive nc:${d.kind}`);
switch (d.kind) {
case 'dep':
for (const spec of d.body) {
const at = spec.lastIndexOf('@');
const name = at > 0 ? spec.slice(0, at) : spec;
const version = at > 0 ? spec.slice(at + 1) : '';
if (!EXACT_SEMVER.test(version)) flag(d, `dep "${spec}" must pin an exact semver (no ranges/latest)`);
// A @chat-adapter/* adapter must match the chat core version in our
// lockfile — the family moves together. This catches pin drift (the
// 4.27.0-vs-chat@4.26.0 mismatch) at lint time.
if (ctx?.chatVersion && name.startsWith('@chat-adapter/') && version !== ctx.chatVersion) {
flag(d, `${name} pinned ${version} but our chat core is ${ctx.chatVersion} — a @chat-adapter/* adapter must match the chat package`);
}
}
break;
case 'append':
if (!d.attrs.to) flag(d, 'append requires to:<file>');
if (d.body.length === 0) flag(d, 'append requires a line to add');
break;
case 'copy':
if (d.body.length === 0) flag(d, 'copy requires at least one path');
break;
case 'json-merge': {
if (!d.attrs.into) flag(d, 'json-merge requires into:<json-file>');
if (!d.attrs.key) flag(d, 'json-merge requires key:<field>');
if (d.body.length === 0) {
flag(d, 'json-merge requires a JSON object in its body');
} else {
let obj: unknown;
try {
obj = JSON.parse(d.body.join('\n'));
} catch {
flag(d, 'json-merge body must be a single parseable JSON object');
break;
}
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
flag(d, 'json-merge body must be a single JSON object (not an array or scalar)');
} else if (typeof d.attrs.key === 'string' && !(d.attrs.key in obj)) {
flag(d, `json-merge body has no "${d.attrs.key}" field to match on`);
}
}
break;
}
case 'prompt': {
if (!promptVar(d)) flag(d, 'prompt requires a variable name, e.g. `nc:prompt token`');
if (d.body.length === 0) flag(d, 'prompt requires a question in its body');
const flags = typeof d.attrs.flags === 'string' ? d.attrs.flags : undefined;
if (typeof d.attrs.validate === 'string') {
try {
new RegExp(d.attrs.validate, flags);
} catch {
flag(d, `prompt validate:${d.attrs.validate}${flags ? ` flags:${flags}` : ''} is not a valid regex`);
}
} else if (flags !== undefined) {
// flags without validate: still verify they're legal regex flags.
try {
new RegExp('', flags);
} catch {
flag(d, `prompt flags:${flags} are not valid regex flags`);
}
}
// Removed presentation attrs — reject loudly (they would silently no-op).
if (d.attrs.min !== undefined) {
flag(d, 'prompt min: was removed — encode the length in validate:, e.g. min:20 → validate:^.{20,}$');
}
if (d.attrs.error !== undefined) {
flag(d, 'prompt error: was removed — the validation-miss message derives from the question prose');
}
if (typeof d.attrs.normalize === 'string' && !['trim', 'rstrip-slash', 'lower'].includes(d.attrs.normalize)) {
flag(d, `prompt normalize:${d.attrs.normalize} must be one of trim|rstrip-slash|lower`);
}
if (typeof d.attrs.reuse === 'string' && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(d.attrs.reuse)) {
flag(d, `prompt reuse:${d.attrs.reuse} must be a valid ENV_KEY`);
}
break;
}
case 'operator':
if (d.body.length === 0) flag(d, 'operator requires instructions for the human in its body');
// Removed presentation attrs — reject loudly (they would silently no-op).
if (d.attrs.open !== undefined) {
flag(d, 'operator open: was removed — put the URL in the body prose (a consumer offers to open it)');
}
if (d.args.includes('gate') || d.attrs.gate !== undefined) {
flag(d, 'operator gate was removed — the human barrier is derived from document structure, never authored');
}
break;
}
// Removed on every directive: label: (labels are heading-derived only) and
// on-fail: (the failure hint is always the surrounding prose).
if (d.attrs.label !== undefined) {
flag(d, 'label: was removed — step labels derive from the preceding heading');
}
if (d.attrs['on-fail'] !== undefined) {
flag(d, 'on-fail: was removed — the failure hint is always the surrounding prose');
}
// A consumer can only reference a variable an earlier prompt captured, or an
// earlier `run capture:<var>` bound from a command's output.
for (const ref of referencedVars(d)) {
if (!defined.has(ref)) flag(d, `references {{${ref}}} but no earlier nc:prompt or nc:run capture defined it`);
}
// A `when:<var>=<value>` guard references an earlier-defined var by bare name.
if (typeof d.attrs.when === 'string') {
const eq = d.attrs.when.indexOf('=');
if (eq < 1) {
flag(d, `when:${d.attrs.when} must be <var>=<value>`);
} else {
const wvar = d.attrs.when.slice(0, eq).trim();
if (!defined.has(wvar)) flag(d, `when:${d.attrs.when} references {{${wvar}}} but no earlier nc:prompt or nc:run capture defined it`);
}
}
if (d.kind === 'prompt') {
const v = promptVar(d);
if (v) defined.add(v);
}
// capture:<var> binds stdout; capture:<var>=<FIELD>,… binds step block fields.
if (d.kind === 'run' && typeof d.attrs.capture === 'string') {
for (const v of captureVars(d.attrs.capture)) defined.add(v);
}
// A run's capture validate:<re> (the stdout shape-guard) must be a valid regex.
if (d.kind === 'run' && typeof d.attrs.validate === 'string') {
try {
new RegExp(d.attrs.validate);
} catch {
flag(d, `run validate:${d.attrs.validate} is not a valid regex`);
}
}
}
return problems;
}
/**
* A WARN-ONLY reference-floor check never an error, never blocks a build. A
* credentialed or interactive skill (one that prompts for a `secret`, or runs an
* `nc:run effect:step` a pairing code, a QR device-link) should ship a
* `## Troubleshooting` section: the human floor a reader scrolls to when the live
* step misbehaves. Missing it is a smell, not a failure the skill still applies
* cleanly so this returns a warning the CLI prints but never exits non-zero on,
* keeping it strictly advisory. Returns [] when no secret/step is present (no
* floor is expected) or a `## Troubleshooting` heading already exists.
*/
export function lintReferenceFloor(markdown: string): Problem[] {
const directives = parseDirectives(markdown);
const isFloorBearing = (d: Directive): boolean =>
(d.kind === 'prompt' && d.args.includes('secret')) || (d.kind === 'run' && d.attrs.effect === 'step');
const anchor = directives.find(isFloorBearing);
if (!anchor) return []; // no credential / interactive step ⇒ no floor expected
const hasTroubleshooting = markdown.split('\n').some((l) => /^##\s+Troubleshooting\s*$/.test(l.trim()));
if (hasTroubleshooting) return [];
return [{
line: anchor.line,
kind: 'reference-floor',
message: 'a credentialed/interactive skill should carry a ## Troubleshooting section (the human floor when a live step misbehaves)',
}];
}
/**
* A WARN-ONLY gate-ambiguity check never an error, never blocks a build. The
* driver's natural-barrier policy (scripts/skill-policy.ts) keys an operator's
* pause decision off the next guard-compatible directive; an UNGUARDED operator
* treats every following directive as compatible, so when the directives
* immediately after it are `when:`-guarded and span more than one branch value,
* the static decision keys off a directive that may be runtime-skipped (e.g.
* unguarded operator `prompt when:mode=remote` `run when:mode=local`:
* policy says no-confirm, but at runtime mode=local skips the prompt). No
* in-tree skill authors this; warn so new authorship guards the operator (or
* restructures) instead of getting a silently wrong barrier. The scan stops at
* the first unguarded directive that one always runs, so no ambiguity past it.
*/
export function lintGateAmbiguity(directives: Directive[]): Problem[] {
const problems: Problem[] = [];
for (let i = 0; i < directives.length; i++) {
const d = directives[i];
if (d.kind !== 'operator' || typeof d.attrs.when === 'string') continue;
const branches = new Set<string>();
for (let j = i + 1; j < directives.length; j++) {
const g = directives[j].attrs.when;
if (typeof g !== 'string') break;
branches.add(g);
}
if (branches.size > 1) {
problems.push({
line: d.line,
kind: 'gate-ambiguity',
message: `unguarded nc:operator followed by when:-guarded directives spanning ${branches.size} branch values (${[...branches].join(', ')}) — the barrier decision may key off a runtime-skipped directive; guard the operator or restructure`,
});
}
}
return problems;
}
// CLI
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
let path = process.argv[2];
if (!path) {
console.error('usage: pnpm exec tsx scripts/skill-directives.ts <skillDir|SKILL.md>');
process.exit(2);
}
if (existsSync(path) && statSync(path).isDirectory()) path = join(path, 'SKILL.md');
const md = readFileSync(path, 'utf8');
const directives = parseDirectives(md);
const problems = validate(directives, { chatVersion: resolveChatCoreVersion(process.cwd()) });
// Warnings (gate ambiguity, reference floor) are advisory only — printed for
// the author, never folded into the exit code (a smell, not a gate). Exit
// stays driven solely by `validate` problems.
const warnings = [...lintGateAmbiguity(directives), ...lintReferenceFloor(md)];
for (const w of warnings) console.error(`warning: ${w.kind} (line ${w.line}): ${w.message}`);
console.log(JSON.stringify({ directives, problems, warnings }, null, 2));
process.exit(problems.length ? 1 : 0);
}
+187
View File
@@ -0,0 +1,187 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { gatePolicy, extractOfferUrl, type GateDecision } from './skill-policy.js';
import { parseDirectives } from './skill-directives.js';
// The parity fixtures are the REAL in-tree channel skills: the policy's whole
// claim is that it reproduces (and deliberately extends — the readiness pauses,
// the discord confirm) the barrier behavior the authored `gate` attrs encoded,
// from document structure alone. Keyed by operator ORDER, not line number, so
// the assertions survive unrelated prose edits.
const loadSkill = (channel: string): string =>
readFileSync(join(process.cwd(), `.claude/skills/add-${channel}/SKILL.md`), 'utf8');
/** The gate decisions for a skill's operator blocks, in document order. */
function decisions(md: string): GateDecision[] {
const gates = gatePolicy(md);
return parseDirectives(md)
.filter((d) => d.kind === 'operator')
.map((d) => gates.get(d.line)!);
}
/** The rendered-ish body (raw, un-substituted) of the nth operator block. */
function operatorBody(md: string, n: number): string {
const ops = parseDirectives(md).filter((d) => d.kind === 'operator');
return ops[n].body.join('\n');
}
describe('gatePolicy — §5.1 parity table (real skills)', () => {
it('teams: exactly the two authored gates — and NO confirm on the operator-chain head', () => {
// Operators in order: prerequisites, portal app, client secret, bot resource
// (chain head), enable-channel (chain tail), sideload, final handoff.
const d = decisions(loadSkill('teams'));
expect(d).toHaveLength(7);
expect(d.map((g) => g.needsConfirm)).toEqual([
false, // → prompt public_url (prompt is the barrier)
false, // → prompt app_id
false, // → prompt app_password
false, // bot-resource block → next compatible is another OPERATOR (rule 2):
// the chain's LAST operator carries the barrier — never double-confirm
true, // enable-channel → run effect:check (the manifest hazard gate)
true, // sideload → run effect:restart
false, // final handoff → end of document
]);
// Both confirms are completed-work flavor (check/restart, not effect:step).
expect(d[4].flavor).toBe('completed');
expect(d[5].flavor).toBe('completed');
});
it('telegram: the pairing operator gains a readiness pause before the effect:step', () => {
const d = decisions(loadSkill('telegram'));
expect(d.map((g) => g.needsConfirm)).toEqual([false, true]); // BotFather → prompt; pairing → step
expect(d[1].flavor).toBe('readiness'); // "a 4-digit code is about to appear"
});
it('signal: readiness pause before the QR device-link step', () => {
const d = decisions(loadSkill('signal'));
expect(d.map((g) => g.needsConfirm)).toEqual([true]);
expect(d[0].flavor).toBe('readiness');
});
it('whatsapp: each auth-method operator skips the OTHER branch and gates on its own step', () => {
const d = decisions(loadSkill('whatsapp'));
// qr operator skips the pairing-code operator + step (guard-incompatible)
// and gates on the qr effect:step; pairing-code likewise; the shared/dedicated
// block is prompt-followed.
expect(d.map((g) => g.needsConfirm)).toEqual([true, true, false]);
expect(d[0].flavor).toBe('readiness');
expect(d[1].flavor).toBe('readiness');
});
it('imessage: guard-compatibility — the local block skips the remote-only prompts and gates on the local configure run', () => {
const d = decisions(loadSkill('imessage'));
// local (when:mode=local) → skips remote operator + 2 remote prompts →
// run effect:external when:mode=local ⇒ confirm ("stop and wait" restored);
// remote (when:mode=remote) → prompt server_url (identical guard) ⇒ no confirm.
expect(d.map((g) => g.needsConfirm)).toEqual([true, false]);
expect(d[0].flavor).toBe('completed');
});
it('discord: the invite operator gains a confirm before the DM resolve (effect:fetch)', () => {
const d = decisions(loadSkill('discord'));
expect(d.map((g) => g.needsConfirm)).toEqual([false, true]);
expect(d[1].flavor).toBe('completed');
});
it('slack: all three operators are prompt-followed — no confirm, unchanged', () => {
// socket-create (guard-skips its webhook twin, lands on the bot_token
// prompt), webhook-create (bot_token prompt), event-delivery (owner_handle
// prompt in Resolve) — every barrier is a natural prompt barrier.
expect(decisions(loadSkill('slack')).map((g) => g.needsConfirm)).toEqual([false, false, false]);
});
});
describe('gatePolicy — rules on synthetic fixtures', () => {
const fence = (info: string, body: string): string => `\`\`\`${info}\n${body}\n\`\`\`\n`;
it('end of document → no confirm (a final handoff block)', () => {
const md = `# t\n${fence('nc:operator', 'All done — go DM the bot.')}`;
expect(decisions(md)).toEqual([{ needsConfirm: false, flavor: 'completed' }]);
});
it('operator chain: only the LAST operator of a chain carries the barrier', () => {
const md = `# t\n${fence('nc:operator', 'first block')}${fence('nc:operator', 'second block')}${fence('nc:run effect:external', 'do-it')}`;
expect(decisions(md).map((g) => g.needsConfirm)).toEqual([false, true]);
});
it('confirm fires for ALL non-prompt/non-operator barriers — even an env-set', () => {
const md = `# t\n${fence('nc:prompt v', 'Value?')}${fence('nc:operator', 'go get v')}${fence('nc:env-set', 'K={{v}}')}`;
expect(decisions(md)).toEqual([{ needsConfirm: true, flavor: 'completed' }]);
});
it('flavor: an effect:step barrier is a readiness pause, anything else completed-work', () => {
const step = `# t\n${fence('nc:operator', 'a code is about to appear')}${fence('nc:run effect:step capture:x=X', 'pair')}`;
expect(decisions(step)[0]).toEqual({ needsConfirm: true, flavor: 'readiness' });
const run = `# t\n${fence('nc:operator', 'finish the portal steps')}${fence('nc:run effect:check', 'true')}`;
expect(decisions(run)[0]).toEqual({ needsConfirm: true, flavor: 'completed' });
});
it('guard-compatibility: same-var/different-value directives are skipped; different-var guards are compatible', () => {
// Mutually-exclusive branch: the m=a operator skips the m=b prompt+run and
// gates on its OWN run.
const branch =
`# t\n${fence('nc:prompt m', 'mode?')}` +
fence('nc:operator when:m=a', 'branch a steps') +
fence('nc:prompt other when:m=b', 'b only') +
fence('nc:run effect:external when:m=b', 'b-run') +
fence('nc:run effect:external when:m=a', 'a-run');
expect(decisions(branch)).toEqual([{ needsConfirm: true, flavor: 'completed' }]);
// Different-var guard = compatible (conservative): the prompt is the barrier.
const diffVar =
`# t\n${fence('nc:prompt m', 'mode?')}` +
fence('nc:operator when:m=a', 'branch a steps') +
fence('nc:prompt other when:x=y', 'unrelated guard');
expect(decisions(diffVar).map((g) => g.needsConfirm)).toEqual([false]);
});
});
// §5.2 URL-offer inventory — every operator body in the tree, plus the
// normative negative fixture (slack's placeholder URL).
describe('extractOfferUrl — §5.2 inventory', () => {
it('teams: both portal blocks offer a clean https://portal.azure.com (trailing comma stripped)', () => {
const md = loadSkill('teams');
expect(extractOfferUrl(operatorBody(md, 1))).toBe('https://portal.azure.com'); // app registration
expect(extractOfferUrl(operatorBody(md, 3))).toBe('https://portal.azure.com'); // bot resource (new offer)
});
it('slack — the <your-public-host> placeholder is EXCLUDED (normative negative fixture)', () => {
const md = loadSkill('slack');
const body = operatorBody(md, 2); // event-delivery block (after the two create-app variants)
expect(body).toContain('https://<your-public-host>/webhook/slack'); // fixture still authored as a placeholder
expect(extractOfferUrl(body)).toBeUndefined(); // slack stays offer-free
});
it('slack :69 — a scheme-less mention (api.slack.com/apps) never matches', () => {
expect(extractOfferUrl(operatorBody(loadSkill('slack'), 0))).toBeUndefined();
});
it('discord: the developers-portal block gains an offer; imessage: photon.codes', () => {
expect(extractOfferUrl(operatorBody(loadSkill('discord'), 0))).toBe('https://discord.com/developers/applications');
expect(extractOfferUrl(operatorBody(loadSkill('imessage'), 1))).toBe('https://photon.codes');
});
it('signal: a non-http scheme (sgnl://…) never matches', () => {
expect(extractOfferUrl(operatorBody(loadSkill('signal'), 0))).toBeUndefined();
});
it('an unsubstituted {{var}} in the URL is excluded; the substituted form offers', () => {
// The telegram/discord shape once their URLs live in the prose: rendered
// text has the var substituted; a defer path could surface the raw form.
expect(extractOfferUrl('Open https://t.me/{{bot_username}} in Telegram now.')).toBeUndefined();
expect(extractOfferUrl('Open https://t.me/my_helper_bot in Telegram now.')).toBe('https://t.me/my_helper_bot');
});
it('strips trailing sentence punctuation and stops at ), ], >', () => {
expect(extractOfferUrl('Visit https://example.com.')).toBe('https://example.com');
expect(extractOfferUrl('(see https://example.com/docs) for details')).toBe('https://example.com/docs');
expect(extractOfferUrl('nothing to open here')).toBeUndefined();
});
it('returns the FIRST offerable URL, skipping earlier excluded candidates', () => {
expect(extractOfferUrl('Set https://<host>/hook then read https://docs.example.com/setup.')).toBe(
'https://docs.example.com/setup',
);
});
});
+130
View File
@@ -0,0 +1,130 @@
// Shared, UI-free driver policy for `nc:operator` blocks — presentation derived
// from DOCUMENT STRUCTURE, never from authored presentation attrs.
//
// The apply engine (scripts/skill-apply.ts) only DECLARES and EMITS: it renders
// an operator block's text and awaits the consumer's `onEvent` before evaluating
// the next directive. Whether that block needs a human BARRIER (a confirm before
// the next side-effecting step runs) and whether its text carries a URL worth
// offering to open are judgments about the skill DOCUMENT — the same judgment
// for every consumer that renders live (the setup wizard, an agent relaying over
// chat). This module is that judgment, built on the shared parser, with no
// clack/TTY baggage — a consumer imports it instead of duplicating the policy.
//
// Two exports:
// • gatePolicy(md) → per operator line: does the block need a confirm
// after rendering, and with which flavor of wording?
// • extractOfferUrl(text) → the first offerable URL in a RENDERED operator
// body (template placeholders excluded), or undefined.
import { parseDirectives, type Directive } from './skill-directives.js';
/**
* The wording flavor a barrier confirm should carry, derived from the barrier
* directive's effect:
* 'readiness' the next step is an `effect:step` (a pairing code, a QR
* device-link): the block describes FUTURE action ("a code is about to
* appear"), so the confirm asks for readiness before it starts.
* 'completed' anything else: the block describes work the human must have
* FINISHED (an Azure app that must exist before the manifest bakes it in),
* so the confirm asks whether the steps above are done.
*/
export type ConfirmFlavor = 'readiness' | 'completed';
export interface GateDecision {
/** Confirm after rendering this operator block? */
needsConfirm: boolean;
/** Wording flavor for the confirm — meaningful only when `needsConfirm`. */
flavor: ConfirmFlavor;
}
// A directive's `when:<var>=<value>` guard, parsed. Malformed (no `=`) ⇒ treated
// as unguarded — conservative, and lint is the authoring gate anyway.
function guardOf(d: Directive): { v: string; value: string } | undefined {
if (typeof d.attrs.when !== 'string') return undefined;
const eq = d.attrs.when.indexOf('=');
if (eq < 1) return undefined;
return { v: d.attrs.when.slice(0, eq).trim(), value: d.attrs.when.slice(eq + 1).trim() };
}
/**
* The natural-barrier gate policy: for each `nc:operator` directive, decide
* whether the consumer should hold for a human confirm after rendering it
* keyed by the directive's opening-fence line (the `line` the engine's operator
* event carries).
*
* Rules (normative the §5.1 seam spec):
* 1. Scan forward through subsequent directives, skipping ONLY those whose
* `when:` guard is INCOMPATIBLE with this operator's own guard same var,
* different value. No guard, or an identical guard, is compatible
* (different-var guards are conservatively compatible too). This makes
* mutually-exclusive branches gate on their OWN next action.
* 2. Next compatible directive is another `operator` no confirm the
* chain's LAST operator carries the barrier. (Operators are NOT skipped-
* and-scanned-past: that would make the earlier block of a chain inherit
* the later block's barrier and double-confirm.)
* 3. Next compatible directive is a `prompt` no confirm (the prompt is the
* barrier the human can't paste a token before doing the steps).
* 4. No such directive (end of document) no confirm (a final handoff block).
* 5. Anything else (`run`, `copy`, `dep`, `append`, `env-set`,
* `json-merge`) confirm, with the flavor derived from that barrier
* directive's effect (`effect:step` readiness, else completed).
*
* Known limitation (lint-warned upstream): an UNGUARDED operator followed by
* guarded directives of more than one branch value keys its decision off a
* directive that may be runtime-skipped. No in-tree skill authors this.
*/
export function gatePolicy(md: string): Map<number, GateDecision> {
const directives = parseDirectives(md);
const out = new Map<number, GateDecision>();
directives.forEach((d, i) => {
if (d.kind !== 'operator') return;
const own = guardOf(d);
let barrier: Directive | undefined;
for (let j = i + 1; j < directives.length; j++) {
const g = guardOf(directives[j]);
if (own && g && own.v === g.v && own.value !== g.value) continue; // incompatible branch — skip
barrier = directives[j];
break;
}
if (!barrier || barrier.kind === 'operator' || barrier.kind === 'prompt') {
out.set(d.line, { needsConfirm: false, flavor: 'completed' });
} else {
out.set(d.line, { needsConfirm: true, flavor: barrier.attrs.effect === 'step' ? 'readiness' : 'completed' });
}
});
return out;
}
// A URL candidate in prose. The char class stops at whitespace, `)`, `>`, `]` —
// deliberately NOT at `<`, so a template placeholder like
// `https://<your-public-host>/webhook/slack` yields a candidate that still
// CONTAINS `<` and gets excluded below (instead of a nonsense truncated offer).
const URL_CANDIDATE = /https?:\/\/[^\s)>\]]+/g;
/**
* The first *offerable* URL in a rendered operator body, or undefined.
*
* A candidate matches `https?://…` up to whitespace/`)`/`>`/`]`, then:
* trailing sentence punctuation is stripped (so "In https://portal.azure.com,
* search" offers the clean URL, not one with a comma glued on);
* candidates containing `<` or `{{` are EXCLUDED template placeholders
* (`https://<your-public-host>/…`) and unsubstituted `{{vars}}` are authored
* shapes, not real destinations;
* the survivor must parse via `new URL()` with a non-empty host.
*
* Scheme-less mentions (`api.slack.com/apps`) and non-http schemes (`sgnl://…`)
* never match the offer is only for pages a browser can open.
*/
export function extractOfferUrl(text: string): string | undefined {
for (const m of text.matchAll(URL_CANDIDATE)) {
const candidate = m[0].replace(/[.,;:!?'"]+$/, '');
if (candidate.includes('<') || candidate.includes('{{')) continue;
try {
if (!new URL(candidate).hostname) continue;
} catch {
continue;
}
return candidate;
}
return undefined;
}
+4 -2
View File
@@ -33,7 +33,9 @@ db.exec(`
`);
// Insert test message
db.prepare(`INSERT INTO messages_in (id, kind, timestamp, status, content) VALUES (?, 'chat', datetime('now'), 'pending', ?)`).run(
db.prepare(
`INSERT INTO messages_in (id, kind, timestamp, status, content) VALUES (?, 'chat', datetime('now'), 'pending', ?)`,
).run(
'test-1',
JSON.stringify({ sender: 'Gavriel', text: 'Say "Hello from v2!" and nothing else. Do not use any tools.' }),
);
@@ -44,7 +46,7 @@ db.close();
process.env.SESSION_DB_PATH = DB_PATH;
process.env.AGENT_PROVIDER = 'claude';
const { getSessionDb, closeSessionDb } = await import('../container/agent-runner/src/db/connection.js');
const { getInboundDb, closeSessionDb } = await import('../container/agent-runner/src/db/connection.js');
const { getUndeliveredMessages } = await import('../container/agent-runner/src/db/messages-out.js');
const { getPendingMessages } = await import('../container/agent-runner/src/db/messages-in.js');
const { createProvider } = await import('../container/agent-runner/src/providers/factory.js');
+14 -6
View File
@@ -71,7 +71,7 @@ import { routeInbound } from '../src/router.js';
import { setDeliveryAdapter, startActiveDeliveryPoll, stopDeliveryPolls } from '../src/delivery.js';
import { getChannelAdapter, registerChannelAdapter, initChannelAdapters } from '../src/channels/channel-registry.js';
import { findSession } from '../src/db/sessions.js';
import { sessionDbPath } from '../src/session-manager.js';
import { inboundDbPath } from '../src/session-manager.js';
import type { ChannelAdapter, ChannelSetup, OutboundMessage } from '../src/channels/adapter.js';
// Track delivered messages
@@ -99,7 +99,9 @@ const mockAdapter: ChannelAdapter = {
async setTyping() {},
async teardown() {},
isConnected() { return true; },
isConnected() {
return true;
},
};
// Register mock adapter
@@ -179,15 +181,19 @@ console.log(`✓ Container status: ${session.container_status}`);
import { execSync } from 'child_process';
const checkContainerLogs = () => {
try {
const containers = execSync('docker ps -a --filter name=nanoclaw-v2-test-channel --format "{{.Names}}"').toString().trim();
const containers = execSync('docker ps -a --filter name=nanoclaw-v2-test-channel --format "{{.Names}}"')
.toString()
.trim();
for (const name of containers.split('\n').filter(Boolean)) {
console.log(`\nContainer logs (${name}):`);
console.log(execSync(`docker logs ${name} 2>&1`).toString());
}
} catch { /* ignore */ }
} catch {
/* ignore */
}
};
const sessDbPath = sessionDbPath('ag-chan', session.id);
const sessDbPath = inboundDbPath('ag-chan', session.id);
console.log(`✓ Session DB: ${sessDbPath}`);
// --- Step 4: Wait for delivery through mock adapter ---
@@ -210,7 +216,9 @@ await new Promise<void>((resolve) => {
console.log(` messages_out rows: ${out.length}`);
if (out.length > 0) console.log(' (messages exist but delivery failed)');
db.close();
} catch { /* ignore */ }
} catch {
/* ignore */
}
checkContainerLogs();
cleanup();
process.exit(1);
-121
View File
@@ -1,121 +0,0 @@
#!/usr/bin/env bash
#
# Install the Codex agent provider non-interactively: copy the payload from the
# `providers` branch, wire the three provider barrels, and add the Codex CLI to
# the container manifest (container/cli-tools.json). The image rebuild is the
# caller's job (the setup container step / `./container/build.sh`).
#
# Emits exactly one status block on stdout (ADD_CODEX); all chatty progress
# goes to stderr. Keep in sync with .claude/skills/add-codex/SKILL.md.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with add-codex SKILL.md. This is the canonical Codex CLI pin —
# it lands in container/cli-tools.json (the global-CLI manifest), not the Dockerfile.
CODEX_VERSION="0.138.0"
# Resolve the remote carrying the providers branch (same nanoclaw remote that
# carries channels — handles forks where it isn't `origin`).
# shellcheck source=setup/lib/channels-remote.sh
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
REMOTE=$(resolve_channels_remote)
BRANCH="${REMOTE}/providers"
# The codex payload — host provider, container runtime, setup module, doctrine.
# Barrels are appended to, not copied.
PAYLOAD_FILES=(
src/providers/codex.ts
src/providers/codex-agents-md.ts
src/providers/codex-registration.test.ts
src/providers/codex-host-contribution.test.ts
src/providers/codex-agents-md.test.ts
container/agent-runner/src/providers/codex.ts
container/agent-runner/src/providers/codex-app-server.ts
container/agent-runner/src/providers/exchange-archive.ts
container/agent-runner/src/providers/exchange-archive.test.ts
container/agent-runner/src/providers/codex-registration.test.ts
container/agent-runner/src/providers/codex.factory.test.ts
container/agent-runner/src/providers/codex.turns.test.ts
container/agent-runner/src/providers/codex-app-server.test.ts
container/agent-runner/src/providers/codex-cli-tools.test.ts
setup/providers/codex.ts
setup/providers/codex.test.ts
setup/providers/codex-registration.test.ts
container/AGENTS.md
)
BARRELS=(
src/providers/index.ts
container/agent-runner/src/providers/index.ts
setup/providers/index.ts
)
ALREADY_INSTALLED=true
emit_status() {
local status=$1 error=${2:-}
echo "=== NANOCLAW SETUP: ADD_CODEX ==="
echo "STATUS: ${status}"
echo "CODEX_VERSION: ${CODEX_VERSION}"
echo "ALREADY_INSTALLED: ${ALREADY_INSTALLED}"
[ -n "$error" ] && echo "ERROR: ${error}"
echo "=== END ==="
}
log() { echo "[add-codex] $*" >&2; }
# Idempotent: a complete install has the host provider file, the host barrel
# import, and the Codex CLI in the container manifest. Any missing → (re)install.
need_install() {
[ ! -f src/providers/codex.ts ] && return 0
! grep -q "^import './codex.js';" src/providers/index.ts 2>/dev/null && return 0
! grep -q '@openai/codex' container/cli-tools.json 2>/dev/null && return 0
return 1
}
if need_install; then
ALREADY_INSTALLED=false
log "Fetching providers branch from ${REMOTE}"
git fetch "$REMOTE" providers >&2 2>/dev/null || {
emit_status failed "git fetch ${REMOTE} providers failed"
exit 1
}
log "Copying Codex payload from ${BRANCH}"
for f in "${PAYLOAD_FILES[@]}"; do
mkdir -p "$(dirname "$f")"
git show "${BRANCH}:$f" > "$f" 2>/dev/null || {
emit_status failed "providers branch is missing ${f}"
exit 1
}
done
log "Wiring provider barrels…"
for b in "${BARRELS[@]}"; do
grep -q "^import './codex.js';" "$b" || printf "import './codex.js';\n" >> "$b"
done
log "Adding the Codex CLI to the container manifest (cli-tools.json)…"
# A json-merge: append { name, version } if absent. The Dockerfile installs
# every manifest entry via pinned `pnpm install -g` — no Dockerfile edit, no
# awk surgery. @openai/codex has no native postinstall, so no "onlyBuilt".
MANIFEST=container/cli-tools.json
node -e '
const fs = require("fs");
const [file, name, version] = process.argv.slice(1);
const tools = JSON.parse(fs.readFileSync(file, "utf8"));
if (!tools.some((t) => t.name === name)) {
tools.push({ name, version });
const fmt = (t) =>
" { " +
Object.entries(t).map(([k, v]) => JSON.stringify(k) + ": " + JSON.stringify(v)).join(", ") +
" }";
fs.writeFileSync(file, "[\n" + tools.map(fmt).join(",\n") + "\n]\n");
}
' "$MANIFEST" "@openai/codex" "${CODEX_VERSION}" || {
emit_status failed "failed to add @openai/codex to ${MANIFEST}"
exit 1
}
fi
emit_status ok
-130
View File
@@ -1,130 +0,0 @@
#!/usr/bin/env bash
#
# Install the Discord adapter, persist DISCORD_BOT_TOKEN / APPLICATION_ID /
# PUBLIC_KEY to .env + data/env/env, and restart the service. Non-interactive —
# the operator-facing "Create a bot" walkthrough, owner confirmation, and
# server-invite step live in setup/channels/discord.ts. Credentials come in via
# env vars: DISCORD_BOT_TOKEN, DISCORD_APPLICATION_ID, DISCORD_PUBLIC_KEY.
#
# Emits exactly one status block on stdout (ADD_DISCORD) at the end. All chatty
# progress messages go to stderr so setup:auto's raw-log capture sees the full
# story without cluttering the final block for the parser.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-discord/SKILL.md.
ADAPTER_VERSION="@chat-adapter/discord@4.29.0"
# Resolve which remote carries the channels branch — handles forks where
# upstream lives on a different remote than `origin`.
# shellcheck source=setup/lib/channels-remote.sh
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
CHANNELS_REMOTE=$(resolve_channels_remote)
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
emit_status() {
local status=$1 error=${2:-}
local already=${ADAPTER_ALREADY_INSTALLED:-false}
echo "=== NANOCLAW SETUP: ADD_DISCORD ==="
echo "STATUS: ${status}"
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
[ -n "$error" ] && echo "ERROR: ${error}"
echo "=== END ==="
}
log() { echo "[add-discord] $*" >&2; }
if [ -z "${DISCORD_BOT_TOKEN:-}" ]; then
emit_status failed "DISCORD_BOT_TOKEN env var not set"
exit 1
fi
if [ -z "${DISCORD_APPLICATION_ID:-}" ]; then
emit_status failed "DISCORD_APPLICATION_ID env var not set"
exit 1
fi
if [ -z "${DISCORD_PUBLIC_KEY:-}" ]; then
emit_status failed "DISCORD_PUBLIC_KEY env var not set"
exit 1
fi
need_install() {
[ ! -f src/channels/discord.ts ] && return 0
! grep -q "^import './discord.js';" src/channels/index.ts 2>/dev/null && return 0
return 1
}
ADAPTER_ALREADY_INSTALLED=true
if need_install; then
ADAPTER_ALREADY_INSTALLED=false
log "Fetching channels branch…"
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
exit 1
}
log "Copying adapter from ${CHANNELS_BRANCH}"
git show "${CHANNELS_BRANCH}:src/channels/discord.ts" > src/channels/discord.ts
# Append self-registration import if missing.
if ! grep -q "^import './discord.js';" src/channels/index.ts; then
echo "import './discord.js';" >> src/channels/index.ts
fi
log "Installing ${ADAPTER_VERSION}"
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
exit 1
}
log "Building…"
pnpm run build >&2 2>/dev/null || {
emit_status failed "pnpm run build failed"
exit 1
}
else
log "Adapter files already installed — skipping install phase."
fi
# Persist credentials. auto.ts validates before this point, so bad values here
# would be an internal bug rather than operator input.
touch .env
upsert_env() {
local key=$1 value=$2
if grep -q "^${key}=" .env; then
awk -v k="$key" -v v="$value" \
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
.env > .env.tmp && mv .env.tmp .env
else
echo "${key}=${value}" >> .env
fi
}
upsert_env DISCORD_BOT_TOKEN "$DISCORD_BOT_TOKEN"
upsert_env DISCORD_APPLICATION_ID "$DISCORD_APPLICATION_ID"
upsert_env DISCORD_PUBLIC_KEY "$DISCORD_PUBLIC_KEY"
# Container reads from data/env/env (the host mounts it).
mkdir -p data/env
cp .env data/env/env
log "Restarting service so the new adapter picks up the credentials…"
# shellcheck source=setup/lib/install-slug.sh
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
case "$(uname -s)" in
Darwin)
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
;;
Linux)
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|| true
;;
esac
# Give the Discord adapter a moment to finish gateway handshake before
# init-first-agent attempts delivery.
sleep 5
emit_status success
-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
-133
View File
@@ -1,133 +0,0 @@
#!/usr/bin/env bash
#
# Install the Slack adapter, persist SLACK_BOT_TOKEN plus the mode-specific
# secret (SLACK_APP_TOKEN for Socket Mode, SLACK_SIGNING_SECRET for webhook) to
# .env + data/env/env, and restart the service. Non-interactive — the
# operator-facing app creation walkthrough + credential paste live in
# setup/channels/slack.ts. Credentials come in via env vars:
# SLACK_BOT_TOKEN, and SLACK_APP_TOKEN and/or SLACK_SIGNING_SECRET.
#
# Emits exactly one status block on stdout (ADD_SLACK) at the end. All chatty
# progress messages go to stderr so setup:auto's raw-log capture sees the full
# story without cluttering the final block for the parser.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-slack/SKILL.md.
ADAPTER_VERSION="@chat-adapter/slack@4.29.0"
# Resolve which remote carries the channels branch — handles forks where
# upstream lives on a different remote than `origin`.
# shellcheck source=setup/lib/channels-remote.sh
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
CHANNELS_REMOTE=$(resolve_channels_remote)
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
emit_status() {
local status=$1 error=${2:-}
local already=${ADAPTER_ALREADY_INSTALLED:-false}
echo "=== NANOCLAW SETUP: ADD_SLACK ==="
echo "STATUS: ${status}"
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
[ -n "$error" ] && echo "ERROR: ${error}"
echo "=== END ==="
}
log() { echo "[add-slack] $*" >&2; }
if [ -z "${SLACK_BOT_TOKEN:-}" ]; then
emit_status failed "SLACK_BOT_TOKEN env var not set"
exit 1
fi
# Socket Mode authenticates with SLACK_APP_TOKEN; webhook mode with
# SLACK_SIGNING_SECRET. Require at least one.
if [ -z "${SLACK_APP_TOKEN:-}" ] && [ -z "${SLACK_SIGNING_SECRET:-}" ]; then
emit_status failed "Set SLACK_APP_TOKEN (Socket Mode) or SLACK_SIGNING_SECRET (webhook)"
exit 1
fi
need_install() {
[ ! -f src/channels/slack.ts ] && return 0
! grep -q "^import './slack.js';" src/channels/index.ts 2>/dev/null && return 0
return 1
}
ADAPTER_ALREADY_INSTALLED=true
if need_install; then
ADAPTER_ALREADY_INSTALLED=false
log "Fetching channels branch…"
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
exit 1
}
log "Copying adapter from ${CHANNELS_BRANCH}"
git show "${CHANNELS_BRANCH}:src/channels/slack.ts" > src/channels/slack.ts
# Append self-registration import if missing.
if ! grep -q "^import './slack.js';" src/channels/index.ts; then
echo "import './slack.js';" >> src/channels/index.ts
fi
log "Installing ${ADAPTER_VERSION}"
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
exit 1
}
log "Building…"
pnpm run build >&2 2>/dev/null || {
emit_status failed "pnpm run build failed"
exit 1
}
else
log "Adapter files already installed — skipping install phase."
fi
# Persist credentials. auto.ts validates via auth.test before this point, so
# bad values here would be an internal bug rather than operator input.
touch .env
upsert_env() {
local key=$1 value=$2
if grep -q "^${key}=" .env; then
awk -v k="$key" -v v="$value" \
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
.env > .env.tmp && mv .env.tmp .env
else
echo "${key}=${value}" >> .env
fi
}
upsert_env SLACK_BOT_TOKEN "$SLACK_BOT_TOKEN"
if [ -n "${SLACK_APP_TOKEN:-}" ]; then
upsert_env SLACK_APP_TOKEN "$SLACK_APP_TOKEN"
fi
if [ -n "${SLACK_SIGNING_SECRET:-}" ]; then
upsert_env SLACK_SIGNING_SECRET "$SLACK_SIGNING_SECRET"
fi
# Container reads from data/env/env (the host mounts it).
mkdir -p data/env
cp .env data/env/env
log "Restarting service so the new adapter picks up the credentials…"
# shellcheck source=setup/lib/install-slug.sh
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
case "$(uname -s)" in
Darwin)
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
;;
Linux)
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|| true
;;
esac
# Give the Slack adapter a moment to finish starting the webhook listener
# before emitting success.
sleep 3
emit_status success
-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.29.0"
# Resolve which remote carries the channels branch — handles forks where
# upstream lives on a different remote than `origin`.
# shellcheck source=setup/lib/channels-remote.sh
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
CHANNELS_REMOTE=$(resolve_channels_remote)
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
emit_status() {
local status=$1 error=${2:-}
local already=${ADAPTER_ALREADY_INSTALLED:-false}
echo "=== NANOCLAW SETUP: ADD_TEAMS ==="
echo "STATUS: ${status}"
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
[ -n "$error" ] && echo "ERROR: ${error}"
echo "=== END ==="
}
log() { echo "[add-teams] $*" >&2; }
if [ -z "${TEAMS_APP_ID:-}" ]; then
emit_status failed "TEAMS_APP_ID env var not set"
exit 1
fi
if [ -z "${TEAMS_APP_PASSWORD:-}" ]; then
emit_status failed "TEAMS_APP_PASSWORD env var not set"
exit 1
fi
if [ -z "${TEAMS_APP_TYPE:-}" ]; then
emit_status failed "TEAMS_APP_TYPE env var not set (SingleTenant|MultiTenant)"
exit 1
fi
if [ "${TEAMS_APP_TYPE}" = "SingleTenant" ] && [ -z "${TEAMS_APP_TENANT_ID:-}" ]; then
emit_status failed "TEAMS_APP_TENANT_ID required when TEAMS_APP_TYPE=SingleTenant"
exit 1
fi
need_install() {
[ ! -f src/channels/teams.ts ] && return 0
! grep -q "^import './teams.js';" src/channels/index.ts 2>/dev/null && return 0
return 1
}
ADAPTER_ALREADY_INSTALLED=true
if need_install; then
ADAPTER_ALREADY_INSTALLED=false
log "Fetching channels branch…"
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
exit 1
}
log "Copying adapter from ${CHANNELS_BRANCH}"
git show "${CHANNELS_BRANCH}:src/channels/teams.ts" > src/channels/teams.ts
# Append self-registration import if missing.
if ! grep -q "^import './teams.js';" src/channels/index.ts; then
echo "import './teams.js';" >> src/channels/index.ts
fi
log "Installing ${ADAPTER_VERSION}"
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
exit 1
}
log "Building…"
pnpm run build >&2 2>/dev/null || {
emit_status failed "pnpm run build failed"
exit 1
}
else
log "Adapter files already installed — skipping install phase."
fi
# Persist credentials.
touch .env
upsert_env() {
local key=$1 value=$2
if grep -q "^${key}=" .env; then
awk -v k="$key" -v v="$value" \
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
.env > .env.tmp && mv .env.tmp .env
else
echo "${key}=${value}" >> .env
fi
}
upsert_env TEAMS_APP_ID "$TEAMS_APP_ID"
upsert_env TEAMS_APP_PASSWORD "$TEAMS_APP_PASSWORD"
upsert_env TEAMS_APP_TYPE "$TEAMS_APP_TYPE"
if [ -n "${TEAMS_APP_TENANT_ID:-}" ]; then
upsert_env TEAMS_APP_TENANT_ID "$TEAMS_APP_TENANT_ID"
fi
# Container reads from data/env/env (the host mounts it).
mkdir -p data/env
cp .env data/env/env
log "Restarting service so the new adapter picks up the credentials…"
# shellcheck source=setup/lib/install-slug.sh
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
case "$(uname -s)" in
Darwin)
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
;;
Linux)
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|| true
;;
esac
# Give the Teams adapter a moment to register its webhook before the driver
# continues.
sleep 5
emit_status success
-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.29.0"
# Resolve which remote carries the channels branch — handles forks where
# upstream lives on a different remote than `origin`.
# shellcheck source=setup/lib/channels-remote.sh
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
CHANNELS_REMOTE=$(resolve_channels_remote)
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
emit_status() {
local status=$1 error=${2:-}
local already=${ADAPTER_ALREADY_INSTALLED:-false}
local username=${BOT_USERNAME:-}
echo "=== NANOCLAW SETUP: ADD_TELEGRAM ==="
echo "STATUS: ${status}"
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
[ -n "$username" ] && echo "BOT_USERNAME: ${username}"
[ -n "$error" ] && echo "ERROR: ${error}"
echo "=== END ==="
}
log() { echo "[add-telegram] $*" >&2; }
if [ -z "${TELEGRAM_BOT_TOKEN:-}" ]; then
emit_status failed "TELEGRAM_BOT_TOKEN env var not set"
exit 1
fi
if ! [[ "$TELEGRAM_BOT_TOKEN" =~ ^[0-9]+:[A-Za-z0-9_-]{35,}$ ]]; then
emit_status failed "token format invalid (expected <digits>:<chars>)"
exit 1
fi
need_install() {
[ ! -f src/channels/telegram.ts ] && return 0
! grep -q "^import './telegram.js';" src/channels/index.ts 2>/dev/null && return 0
return 1
}
ADAPTER_ALREADY_INSTALLED=true
if need_install; then
ADAPTER_ALREADY_INSTALLED=false
log "Fetching channels branch…"
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
exit 1
}
# pair-telegram.ts is maintained in this branch (setup-auto), so it's NOT
# in this list — do not overwrite the local version with the channels copy.
log "Copying adapter files from ${CHANNELS_BRANCH}"
for f in \
src/channels/telegram.ts \
src/channels/telegram-pairing.ts \
src/channels/telegram-pairing.test.ts \
src/channels/telegram-markdown-sanitize.ts \
src/channels/telegram-markdown-sanitize.test.ts
do
git show "${CHANNELS_BRANCH}:$f" > "$f"
done
# Append self-registration import if missing.
if ! grep -q "^import './telegram.js';" src/channels/index.ts; then
echo "import './telegram.js';" >> src/channels/index.ts
fi
# Register pair-telegram step if not already in the STEPS map.
# Uses node (not sed) since sed's in-place + escape semantics differ
# between BSD (macOS) and GNU.
node -e '
const fs = require("fs");
const p = "setup/index.ts";
let s = fs.readFileSync(p, "utf-8");
if (!s.includes("\047pair-telegram\047")) {
s = s.replace(
/(register: \(\) => import\(\x27\.\/register\.js\x27\),)/,
"$1\n \x27pair-telegram\x27: () => import(\x27./pair-telegram.js\x27),"
);
fs.writeFileSync(p, s);
}
'
log "Installing ${ADAPTER_VERSION}"
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
exit 1
}
log "Building…"
pnpm run build >&2 2>/dev/null || {
emit_status failed "pnpm run build failed"
exit 1
}
else
log "Adapter files already installed — skipping install phase."
fi
# Persist token. auto.ts validates before this point, so a bad token here
# would be an internal bug rather than operator input.
touch .env
if grep -q '^TELEGRAM_BOT_TOKEN=' .env; then
awk -v tok="$TELEGRAM_BOT_TOKEN" \
'/^TELEGRAM_BOT_TOKEN=/{print "TELEGRAM_BOT_TOKEN=" tok; next} {print}' \
.env > .env.tmp && mv .env.tmp .env
else
echo "TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}" >> .env
fi
# Look up the bot username (auto.ts already validated; we re-query here so
# standalone invocations still work — BOT_USERNAME is emitted in the status
# block for parent drivers to display).
INFO=$(curl -fsS --max-time 8 \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe" 2>/dev/null || true)
BOT_USERNAME=""
if echo "$INFO" | grep -q '"ok":true'; then
BOT_USERNAME=$(echo "$INFO" | sed -nE 's/.*"username":"([^"]+)".*/\1/p')
fi
# Container reads from data/env/env (the host mounts it).
mkdir -p data/env
cp .env data/env/env
# Browser/app deep-link is done by the parent driver (setup/channels/telegram.ts)
# BEFORE this script runs — gated on a clack confirm so focus-stealing doesn't
# surprise the user. Keeping it out of here means this script stays pure
# non-interactive install.
log "Restarting service so the new adapter picks up the token…"
# shellcheck source=setup/lib/install-slug.sh
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
case "$(uname -s)" in
Darwin)
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
;;
Linux)
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|| true
;;
esac
# Give the Telegram adapter a moment to finish starting before pair-telegram
# begins polling for the user's code message.
sleep 5
emit_status success
+41 -31
View File
@@ -32,15 +32,10 @@ import * as p from '@clack/prompts';
import k from 'kleur';
import { BACK_TO_CHANNEL_SELECTION } from './lib/back-nav.js';
import { runDiscordChannel } from './channels/discord.js';
import { runIMessageChannel } from './channels/imessage.js';
import { runSignalChannel } from './channels/signal.js';
import { runSlackChannel } from './channels/slack.js';
import { runTeamsChannel } from './channels/teams.js';
import { runTelegramChannel } from './channels/telegram.js';
import { runWhatsAppChannel } from './channels/whatsapp.js';
import { runChannelSkill } from './channels/run-channel-skill.js';
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
import { getSetupProvider, listSetupProviders } from './providers/registry.js';
import { applyProviderSkill } from './providers/install.js';
// Provider payloads self-register their picker entry + auth on import.
import './providers/index.js';
import { brightSelect } from './lib/bright-select.js';
@@ -344,26 +339,36 @@ async function main(): Promise<void> {
let providerEntry = getSetupProvider(agentProvider);
if (agentProvider !== 'claude' && !providerEntry) {
// A non-claude provider picked from the hard-wired list isn't wired in
// this install yet — install it via its self-contained script (channel
// style, idempotent: self-skips if already installed), rebuild the image
// (the container step already ran, the Dockerfile just changed), then
// load the payload's setup module so it self-registers.
const install = await runQuietChild(
`add-${agentProvider}`,
'bash',
[`setup/add-${agentProvider}.sh`],
{
running: `Installing ${agentProvider}`,
done: `${agentProvider} installed.`,
},
);
if (!install.ok) {
// this install yet — install it by applying its `/add-<name>` SKILL.md
// in-process via the directive engine (channel style, idempotent:
// self-skips if already installed), rebuild the image (the container step
// already ran, the CLI manifest just changed), then load the payload's
// setup module so it self-registers.
const skillDir = `.claude/skills/add-${agentProvider}`;
const s = p.spinner();
s.start(`Installing ${agentProvider}`);
let blockers: string[];
try {
({ blockers } = await applyProviderSkill(skillDir, process.cwd()));
} catch (err) {
s.stop(`Couldn't install ${agentProvider}.`, 1);
const message = err instanceof Error ? err.message : String(err);
await fail(
`add-${agentProvider}`,
`Couldn't install ${agentProvider}.`,
'See logs/setup-steps/ for details, then retry setup.',
message,
);
return; // unreachable — fail() exits — but narrows blockers for TS
}
if (blockers.length) {
s.stop(`Couldn't install ${agentProvider}.`, 1);
await fail(
`add-${agentProvider}`,
`Couldn't install ${agentProvider}.`,
blockers.join('; '),
);
}
s.stop(`${agentProvider} installed.`);
p.log.info(brandBody('Rebuilding the container image with the new provider…'));
spawnSync('./container/build.sh', [], { stdio: 'inherit' });
await import(`./providers/${agentProvider}.js`);
@@ -549,20 +554,24 @@ async function main(): Promise<void> {
await resolveDisplayName();
}
let result: void | typeof BACK_TO_CHANNEL_SELECTION;
// Every channel now runs through the SKILL.md-driven flow — the whole
// connect+wire procedure lives in each add-<channel>/SKILL.md. Teams
// defers the wire (its platform_id only exists after the first inbound),
// so it installs + hands off rather than wiring inline.
if (channelChoice === 'telegram') {
result = await runTelegramChannel(displayName!);
result = await runChannelSkill('telegram', displayName!, { offerBack: true });
} else if (channelChoice === 'discord') {
result = await runDiscordChannel(displayName!);
result = await runChannelSkill('discord', displayName!, { offerBack: true });
} else if (channelChoice === 'whatsapp') {
result = await runWhatsAppChannel(displayName!);
result = await runChannelSkill('whatsapp', displayName!, { offerBack: true });
} else if (channelChoice === 'signal') {
result = await runSignalChannel(displayName!);
result = await runChannelSkill('signal', displayName!, { offerBack: true });
} else if (channelChoice === 'teams') {
result = await runTeamsChannel(displayName!);
result = await runChannelSkill('teams', displayName!, { deferWire: true, offerBack: true });
} else if (channelChoice === 'slack') {
result = await runSlackChannel(displayName!);
result = await runChannelSkill('slack', displayName!, { offerBack: true });
} else if (channelChoice === 'imessage') {
result = await runIMessageChannel(displayName!);
result = await runChannelSkill('imessage', displayName!, { offerBack: true });
} else if (channelChoice === 'other') {
result = await askOtherChannelName();
} else {
@@ -803,7 +812,8 @@ function sendChatMessage(message: string): Promise<void> {
// Providers offered for install are hard-wired in trunk — an audited control
// surface (no branch enumeration that anyone with write access could extend).
// Codex is the only one offered here; opencode/ollama install via their own
// /add-* skills. Each is installed by its self-contained setup/add-<name>.sh.
// /add-* skills. Each is installed by applying its `/add-<name>` SKILL.md
// in-process via the directive engine.
const INSTALLABLE_PROVIDERS = [
{ value: 'codex', label: 'Codex', hint: 'OpenAI — ChatGPT subscription or API key' },
] as const;
@@ -812,7 +822,7 @@ async function askAgentProviderChoice(): Promise<string> {
const installed = listSetupProviders();
const installedNames = new Set(installed.map((entry) => entry.value));
// Offer the hard-wired installable providers this install hasn't wired yet —
// selecting one installs it via setup/add-<name>.sh.
// selecting one applies its `/add-<name>` SKILL.md in-process.
const available = INSTALLABLE_PROVIDERS.filter((prov) => !installedNames.has(prov.value));
const options = [
...installed.map(({ value, label, hint }) => ({ value, label, hint })),
-528
View File
@@ -1,528 +0,0 @@
/**
* Discord channel flow for setup:auto.
*
* `runDiscordChannel(displayName)` owns the full branch from "do you have a
* bot?" through the welcome DM:
*
* 1. Ask if they have a bot already; walk them through Dev Portal creation
* if not
* 2. Paste the bot token (clack password) format-validated
* 3. GET /users/@me to confirm the token and resolve bot username
* 4. GET /oauth2/applications/@me to derive application_id, verify_key
* (public key), and owner no separate paste needed in the common case
* 5. Confirm owner identity (falls back to a manual user-id prompt with
* Developer Mode instructions if declined or if the app is team-owned)
* 6. Print the OAuth invite URL, open it, wait for "I've added the bot"
* 7. Install the adapter via setup/add-discord.sh (non-interactive)
* 8. POST /users/@me/channels to open the DM channel (yields dm channel id)
* 9. Ask for the messaging-agent name (defaulting to "Nano")
* 10. Wire the agent via scripts/init-first-agent.ts, which sends the welcome
* DM through the normal delivery path
*
* All output obeys the three-level contract: clack UI for the user, structured
* entries in logs/setup.log, full raw output in per-step files under
* logs/setup-steps/. See docs/setup-flow.md.
*/
import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
import { brightSelect } from '../lib/bright-select.js';
import { confirmThenOpen, formatNoteLink } from '../lib/browser.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { readEnvKey } from '../environment.js';
import { accentGreen, brandBody, fmtDuration, note } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano';
const DISCORD_API = 'https://discord.com/api/v10';
// Send Messages (0x800) + Add Reactions (0x40) + Attach Files (0x8000)
// + Read Message History (0x10000) = 100416.
// Matches the permissions set documented in .claude/skills/add-discord/SKILL.md.
const INVITE_PERMISSIONS = '100416';
interface AppInfo {
applicationId: string;
publicKey: string;
owner: { id: string; username: string } | null;
}
export async function runDiscordChannel(displayName: string): Promise<ChannelFlowResult> {
const choice = await askHasBotToken();
if (choice === 'back') return BACK_TO_CHANNEL_SELECTION;
const hasBot = choice === 'yes';
if (!hasBot) {
await walkThroughBotCreation();
}
// Even users who said "yes" often can't find the token on demand — the
// Dev Portal resets it if you don't store it, and people forget which
// app it belongs to. A quick reminder before the paste prompt is cheap.
showTokenLocationReminder(hasBot);
const token = await collectDiscordToken();
const botUsername = await validateDiscordToken(token);
const app = await fetchApplicationInfo(token);
const ownerUserId = await resolveOwnerUserId(app.owner);
// Before inviting: do they have a server to invite into? Walkthrough if
// not — a fresh Discord account without a server makes the invite page a
// dead end.
if (!(await askHasDiscordServer())) {
await walkThroughServerCreation();
}
await promptInviteBot(app.applicationId, botUsername);
const install = await runQuietChild(
'discord-install',
'bash',
['setup/add-discord.sh'],
{
running: `Connecting Discord to @${botUsername}`,
done: 'Discord connected.',
},
{
env: {
DISCORD_BOT_TOKEN: token,
DISCORD_APPLICATION_ID: app.applicationId,
DISCORD_PUBLIC_KEY: app.publicKey,
},
extraFields: {
BOT_USERNAME: botUsername,
APPLICATION_ID: app.applicationId,
},
},
);
if (!install.ok) {
await fail(
'discord-install',
"Couldn't connect Discord.",
'See logs/setup-steps/ for details, then retry setup.',
);
}
const dmChannelId = await openDmChannel(token, ownerUserId);
const platformId = `discord:@me:${dmChannelId}`;
const role = await askOperatorRole('Discord');
setupLog.userInput('discord_role', role);
const agentName = await resolveAgentName();
const init = await runQuietChild(
'init-first-agent',
'pnpm',
[
'exec', 'tsx', 'scripts/init-first-agent.ts',
'--channel', 'discord',
'--user-id', `discord:${ownerUserId}`,
'--platform-id', platformId,
'--display-name', displayName,
'--agent-name', agentName,
'--role', role,
],
{
running: `Connecting ${agentName} to your Discord DMs…`,
done: `${agentName} is ready. Check Discord for a welcome message.`,
},
{
extraFields: {
CHANNEL: 'discord',
AGENT_NAME: agentName,
PLATFORM_ID: platformId,
},
},
);
if (!init.ok) {
await fail(
'init-first-agent',
`Couldn't finish connecting ${agentName}.`,
'Most likely the bot and you don\'t share a server yet — invite the bot, then retry later with `/manage-channels`.',
);
}
}
async function askHasBotToken(): Promise<'yes' | 'no' | 'back'> {
const answer = ensureAnswer(
await brightSelect({
message: 'Do you already have a Discord bot?',
options: [
{ value: 'yes', label: 'Yes, I have a bot token ready' },
{ value: 'no', label: "No, walk me through creating one" },
{ value: 'back', label: '← Back to channel selection' },
],
}),
);
return answer as 'yes' | 'no' | 'back';
}
async function walkThroughBotCreation(): Promise<void> {
const url = 'https://discord.com/developers/applications';
note(
[
"You'll create a Discord bot in the Developer Portal. It's free and takes about a minute.",
'',
' 1. Click "New Application", give it a name (e.g. "NanoClaw")',
' 2. In the "Bot" tab, click "Reset Token" and copy the token',
' 3. On the same tab, enable "Message Content Intent"',
' (under Privileged Gateway Intents)',
formatNoteLink(url),
].filter((line): line is string => line !== null).join('\n'),
'Create a Discord bot',
);
await confirmThenOpen(url, 'Press Enter to open the Developer Portal');
ensureAnswer(
await p.confirm({
message: "Got your bot token?",
initialValue: true,
}),
);
}
function showTokenLocationReminder(hasExistingBot: boolean): void {
// If we just walked them through creating a bot, they're staring at the
// token. If they came in with an existing one, they may still need a nudge
// to find it — tokens in the Dev Portal aren't visible after first reveal,
// and "Reset Token" issues a new one.
if (hasExistingBot) {
note(
[
"Where to find your bot token:",
'',
' 1. discord.com/developers/applications → pick your app',
' 2. "Bot" tab → "Reset Token" (the old one stops working)',
' 3. Copy the new token',
].join('\n'),
'Reminder',
);
}
}
async function askHasDiscordServer(): Promise<boolean> {
const answer = ensureAnswer(
await brightSelect({
message: 'Do you have a Discord server you can add the bot to?',
options: [
{ value: 'yes', label: 'Yes, I have a server' },
{ value: 'no', label: "No, walk me through creating one" },
],
}),
);
setupLog.userInput('discord_has_server', String(answer));
return answer === 'yes';
}
async function walkThroughServerCreation(): Promise<void> {
// Discord doesn't have a stable deep-link for "create server" so we open
// the web client and rely on the + button being visible. The steps below
// are the same whether they're in the desktop app or the browser.
const url = 'https://discord.com/channels/@me';
note(
[
"A Discord server is just a private space for you and the bot. Free and takes 30 seconds.",
'',
' 1. In Discord, click the "+" at the bottom of the server list',
' 2. Choose "Create My Own" → "For me and my friends"',
' 3. Give it any name (e.g. "NanoClaw")',
formatNoteLink(url),
].filter((line): line is string => line !== null).join('\n'),
'Create a Discord server',
);
await confirmThenOpen(url, 'Press Enter to open Discord');
ensureAnswer(
await p.confirm({
message: "Server created?",
initialValue: true,
}),
);
}
async function collectDiscordToken(): Promise<string> {
const existing = readEnvKey('DISCORD_BOT_TOKEN');
if (existing && /^[A-Za-z0-9._-]{50,}$/.test(existing)) {
const reuse = ensureAnswer(await p.confirm({
message: `Found an existing Discord bot token (${existing.slice(0, 10)}…). Use it?`,
initialValue: true,
}));
if (reuse) {
setupLog.userInput('discord_token', 'reused-existing');
return existing;
}
}
const answer = ensureAnswer(
await p.password({
message: 'Paste your bot token',
clearOnError: true,
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Token is required';
// Discord bot tokens are base64url segments separated by dots.
// Be lenient on length; the real check is /users/@me.
if (!/^[A-Za-z0-9._-]{50,}$/.test(t)) {
return "That doesn't look like a Discord bot token";
}
return undefined;
},
}),
);
const token = (answer as string).trim();
setupLog.userInput(
'discord_token',
`${token.slice(0, 10)}${token.slice(-4)}`,
);
return token;
}
async function validateDiscordToken(token: string): Promise<string> {
const s = p.spinner();
const start = Date.now();
s.start('Checking your bot token…');
try {
const res = await fetch(`${DISCORD_API}/users/@me`, {
headers: { Authorization: `Bot ${token}` },
});
const data = (await res.json()) as {
id?: string;
username?: string;
message?: string;
};
if (res.ok && data.username) {
s.stop(`Found your bot: @${data.username}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('discord-validate', 'success', Date.now() - start, {
BOT_USERNAME: data.username,
BOT_ID: data.id ?? '',
});
return data.username;
}
const reason = data.message ?? `HTTP ${res.status}`;
s.stop(`Discord didn't accept that token: ${reason}`, 1);
setupLog.step('discord-validate', 'failed', Date.now() - start, {
ERROR: reason,
});
await fail(
'discord-validate',
"Discord didn't accept that token.",
'Copy the token again from the Developer Portal and retry setup.',
);
} catch (err) {
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('discord-validate', 'failed', Date.now() - start, {
ERROR: message,
});
await fail(
'discord-validate',
"Couldn't reach Discord.",
'Check your internet connection and retry setup.',
);
}
}
async function fetchApplicationInfo(token: string): Promise<AppInfo> {
const s = p.spinner();
const start = Date.now();
s.start('Looking up your bot application…');
try {
const res = await fetch(`${DISCORD_API}/oauth2/applications/@me`, {
headers: { Authorization: `Bot ${token}` },
});
const data = (await res.json()) as {
id?: string;
verify_key?: string;
owner?: { id: string; username: string } | null;
team?: unknown;
message?: string;
};
if (!res.ok || !data.id || !data.verify_key) {
const reason = data.message ?? `HTTP ${res.status}`;
s.stop(`Couldn't read application info: ${reason}`, 1);
setupLog.step('discord-app-info', 'failed', Date.now() - start, {
ERROR: reason,
});
await fail(
'discord-app-info',
"Couldn't read your Discord application details.",
'Re-run setup. If it keeps failing, check the bot token has the right scopes.',
);
}
s.stop(`Got your application details. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
// owner is populated for solo applications; team-owned apps return a
// team object instead and we'll fall back to a manual user-id prompt.
const owner =
data.owner && data.owner.id && data.owner.username
? { id: data.owner.id, username: data.owner.username }
: null;
setupLog.step('discord-app-info', 'success', Date.now() - start, {
APPLICATION_ID: data.id,
OWNER_USERNAME: owner?.username ?? '',
TEAM_OWNED: data.team ? 'true' : 'false',
});
return {
applicationId: data.id,
publicKey: data.verify_key,
owner,
};
} catch (err) {
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('discord-app-info', 'failed', Date.now() - start, {
ERROR: message,
});
await fail(
'discord-app-info',
"Couldn't reach Discord.",
'Check your internet connection and retry setup.',
);
}
}
async function resolveOwnerUserId(
owner: { id: string; username: string } | null,
): Promise<string> {
if (owner) {
const confirmed = ensureAnswer(
await p.confirm({
message: `Is @${owner.username} your Discord account?`,
initialValue: true,
}),
);
if (confirmed === true) {
setupLog.userInput('discord_owner_confirmed', owner.username);
return owner.id;
}
} else {
p.log.info(
brandBody("Your bot is owned by a Developer Team, so we need your Discord user ID directly."),
);
}
return await promptForUserIdWithDevMode();
}
async function promptForUserIdWithDevMode(): Promise<string> {
note(
[
"To get your Discord user ID:",
'',
' 1. Open Discord → Settings (⚙️) → Advanced',
' 2. Turn on "Developer Mode"',
' 3. Right-click your own name/avatar → "Copy User ID"',
].join('\n'),
'Find your Discord user ID',
);
const answer = ensureAnswer(
await p.text({
message: 'Paste your Discord user ID',
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'User ID is required';
if (!/^\d{17,20}$/.test(t)) {
return "That doesn't look like a Discord user ID (17-20 digits)";
}
return undefined;
},
}),
);
const id = (answer as string).trim();
setupLog.userInput('discord_user_id', id);
return id;
}
async function promptInviteBot(
applicationId: string,
botUsername: string,
): Promise<void> {
const url =
`https://discord.com/api/oauth2/authorize` +
`?client_id=${applicationId}` +
`&scope=bot` +
`&permissions=${INVITE_PERMISSIONS}`;
note(
[
`@${botUsername} needs to share a server with you before it can DM you.`,
'',
' 1. Pick any server you\'re in (a personal one is fine)',
' 2. Click "Authorize"',
formatNoteLink(url),
].filter((line): line is string => line !== null).join('\n'),
'Add bot to a server',
);
await confirmThenOpen(url, 'Press Enter to open the invite page');
ensureAnswer(
await p.confirm({
message: "I've added the bot to a server",
initialValue: true,
}),
);
}
async function openDmChannel(token: string, userId: string): Promise<string> {
const s = p.spinner();
const start = Date.now();
s.start('Opening a DM channel…');
try {
const res = await fetch(`${DISCORD_API}/users/@me/channels`, {
method: 'POST',
headers: {
Authorization: `Bot ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ recipient_id: userId }),
});
const data = (await res.json()) as { id?: string; message?: string };
if (!res.ok || !data.id) {
const reason = data.message ?? `HTTP ${res.status}`;
s.stop(`Couldn't open a DM channel: ${reason}`, 1);
setupLog.step('discord-open-dm', 'failed', Date.now() - start, {
ERROR: reason,
});
await fail(
'discord-open-dm',
"Couldn't open a DM channel with you.",
'Make sure the bot is in a server you\'re also in, then retry setup.',
);
}
s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('discord-open-dm', 'success', Date.now() - start, {
DM_CHANNEL_ID: data.id,
});
return data.id;
} catch (err) {
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('discord-open-dm', 'failed', Date.now() - start, {
ERROR: message,
});
await fail(
'discord-open-dm',
"Couldn't reach Discord.",
'Check your internet connection and retry setup.',
);
}
}
async function resolveAgentName(): Promise<string> {
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
if (preset) {
setupLog.userInput('agent_name', preset);
return preset;
}
const answer = ensureAnswer(
await p.text({
message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
);
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
setupLog.userInput('agent_name', value);
return value;
}
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# Write the mode-exclusive iMessage `.env` keys, and strip the opposite mode's
# keys so a stale value can't confuse the adapter's factory.
#
# The two iMessage modes use different keys:
# local — IMESSAGE_LOCAL=true, IMESSAGE_ENABLED=true (no server/key)
# remote — IMESSAGE_LOCAL=false, IMESSAGE_SERVER_URL, IMESSAGE_API_KEY
#
# This is an upsert-and-remove: it replaces a key in place if present (else
# appends it) and deletes the other mode's keys. The skill engine's plain
# env write is set-if-absent only — it can neither replace a stale value nor
# delete a key — so that logic lives here, in one script the skill invokes
# once per mode.
#
# bash setup/channels/imessage-configure.sh local
# bash setup/channels/imessage-configure.sh remote "<server-url>" "<api-key>"
set -u
mode="${1:-}"
server_url="${2:-}"
api_key="${3:-}"
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root="$(cd "$here/../.." && pwd)"
env_file="$root/.env"
# Replace `KEY=...` in place if present, else append it. Mirrors
# setup/environment.ts:upsertEnvKey (set-or-replace, file ends with a newline).
set_key() {
local key="$1" val="$2" tmp found=0
tmp="$(mktemp)"
if [ -f "$env_file" ]; then
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
"${key}="*) printf '%s=%s\n' "$key" "$val" >> "$tmp"; found=1 ;;
*) printf '%s\n' "$line" >> "$tmp" ;;
esac
done < "$env_file"
fi
[ "$found" -eq 0 ] && printf '%s=%s\n' "$key" "$val" >> "$tmp"
mv "$tmp" "$env_file"
}
# Drop every `KEY=...` line. Mirrors setup/environment.ts removeEnvKey.
remove_key() {
local key="$1" tmp
[ -f "$env_file" ] || return 0
tmp="$(mktemp)"
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
"${key}="*) ;;
*) printf '%s\n' "$line" >> "$tmp" ;;
esac
done < "$env_file"
mv "$tmp" "$env_file"
}
case "$mode" in
local)
set_key IMESSAGE_LOCAL true
set_key IMESSAGE_ENABLED true
remove_key IMESSAGE_SERVER_URL
remove_key IMESSAGE_API_KEY
;;
remote)
set_key IMESSAGE_LOCAL false
set_key IMESSAGE_SERVER_URL "$server_url"
set_key IMESSAGE_API_KEY "$api_key"
remove_key IMESSAGE_ENABLED
;;
*)
echo "imessage-configure: unknown mode '${mode}' (expected local|remote)" >&2
exit 1
;;
esac
-336
View File
@@ -1,336 +0,0 @@
/**
* iMessage channel flow for setup:auto.
*
* `runIMessageChannel(displayName)` covers both deployment modes:
*
* Local (macOS): the bot runs on this Mac and talks via the signed-in
* iMessage account. Reading chat.db needs Full Disk Access granted to
* the Node binary we open the directory for them so they can drag
* the `node` file into System Settings.
*
* Remote (Photon API): the bot talks to a separate server (Photon)
* that owns an iMessage account on another Mac. Used when this host
* is Linux, or when the operator wants to keep their daily-driver
* Mac's chat history out of the loop.
*
* Flow:
* 1. Pick mode (auto-defaults to local on macOS, remote elsewhere)
* 2. Local: FDA walkthrough (open node bin directory, wait for ack)
* Remote: prompt for Photon server URL + API key
* 3. Ask for the phone or email the operator messages from this is
* the platform-id for first-agent wiring
* 4. Install the adapter (setup/add-imessage.sh, non-interactive)
* 5. Wire the agent via scripts/init-first-agent.ts the welcome
* iMessage goes out through the normal delivery path
*
* All output obeys the three-level contract. See docs/setup-flow.md.
*/
import { execSync } from 'child_process';
import os from 'os';
import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
import { brightSelect } from '../lib/bright-select.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { accentGreen, note, wrapForGutter } from '../lib/theme.js';
import { readEnvKey } from '../environment.js';
const DEFAULT_AGENT_NAME = 'Nano';
type Mode = 'local' | 'remote';
interface RemoteCreds {
serverUrl: string;
apiKey: string;
}
export async function runIMessageChannel(displayName: string): Promise<ChannelFlowResult> {
const isMac = os.platform() === 'darwin';
const mode = await askMode(isMac);
if (mode === 'back') return BACK_TO_CHANNEL_SELECTION;
let remoteCreds: RemoteCreds | null = null;
if (mode === 'local') {
if (!isMac) {
await fail(
'imessage',
"Local iMessage mode only works on macOS.",
'Choose remote mode (Photon API) on Linux/WSL, or run setup from your Mac.',
);
}
await walkThroughFullDiskAccess();
} else {
remoteCreds = await collectRemoteCreds();
}
const handle = await askOperatorHandle();
const install = await runQuietChild(
'imessage-install',
'bash',
['setup/add-imessage.sh'],
{
running:
mode === 'local'
? "Connecting the iMessage adapter to this Mac…"
: `Connecting the iMessage adapter to ${remoteCreds!.serverUrl}`,
done: 'iMessage adapter installed.',
},
{
env:
mode === 'local'
? { IMESSAGE_LOCAL: 'true', IMESSAGE_ENABLED: 'true' }
: {
IMESSAGE_LOCAL: 'false',
IMESSAGE_SERVER_URL: remoteCreds!.serverUrl,
IMESSAGE_API_KEY: remoteCreds!.apiKey,
},
extraFields: { MODE: mode },
},
);
if (!install.ok) {
await fail(
'imessage-install',
"Couldn't install the iMessage adapter.",
'See logs/setup-steps/ for details, then retry setup.',
);
}
const role = await askOperatorRole('iMessage');
setupLog.userInput('imessage_role', role);
const agentName = await resolveAgentName();
const init = await runQuietChild(
'init-first-agent',
'pnpm',
[
'exec', 'tsx', 'scripts/init-first-agent.ts',
'--channel', 'imessage',
'--user-id', handle,
'--platform-id', handle,
'--display-name', displayName,
'--agent-name', agentName,
'--role', role,
],
{
running: `Connecting ${agentName} to iMessage…`,
done: `${agentName} is ready. Check iMessage for a welcome message.`,
},
{
extraFields: {
CHANNEL: 'imessage',
AGENT_NAME: agentName,
PLATFORM_ID: handle,
MODE: mode,
},
},
);
if (!init.ok) {
await fail(
'init-first-agent',
`Couldn't finish connecting ${agentName}.`,
'Double-check Full Disk Access (local mode) or Photon credentials (remote), then retry.',
);
}
}
async function askMode(isMac: boolean): Promise<Mode | 'back'> {
const baseOptions = isMac
? [
{
value: 'local' as const,
label: 'Local (this Mac)',
hint: "uses this machine's iMessage account",
},
{
value: 'remote' as const,
label: 'Remote (Photon API)',
hint: 'the bot lives on another server',
},
]
: [
{
value: 'remote' as const,
label: 'Remote (Photon API)',
hint: 'only option off macOS',
},
];
const choice = ensureAnswer(
await brightSelect<Mode | 'back'>({
message: 'How should iMessage run?',
initialValue: isMac ? 'local' : 'remote',
options: [
...baseOptions,
{ value: 'back', label: '← Back to channel selection' },
],
}),
);
if (choice !== 'back') setupLog.userInput('imessage_mode', String(choice));
return choice;
}
/**
* Grant Full Disk Access to the Node binary the host runs under without
* it, the adapter can't read chat.db and inbound messages never arrive.
* Opening the containing directory in Finder makes the drag-and-drop
* target obvious; falling back to printing the path keeps us working in
* SSH/headless contexts where `open` is a no-op.
*/
async function walkThroughFullDiskAccess(): Promise<void> {
let nodePath = process.execPath;
try {
// `which node` picks up the user's shell-resolved node, which may differ
// from process.execPath (e.g. they launched setup under a different
// Node via `nvm`). If it succeeds and is resolvable, prefer it.
const which = execSync('which node', { encoding: 'utf-8' }).trim();
if (which) nodePath = which;
} catch {
// fall back to process.execPath
}
const nodeDir = path.dirname(nodePath);
note(
wrapForGutter(
[
`iMessage needs Full Disk Access granted to the Node binary:`,
'',
` ${nodePath}`,
'',
' 1. System Settings → Privacy & Security → Full Disk Access',
` 2. Click +, then drag the "node" file from the Finder window`,
' we just opened for you',
' 3. Toggle it on, then come back here',
].join('\n'),
6,
),
'Grant Full Disk Access',
);
try {
execSync(`open "${nodeDir}"`, { stdio: 'ignore' });
} catch {
// No Finder (SSH/headless) — user sees the path in the note above.
}
ensureAnswer(
await p.confirm({
message: "Granted Full Disk Access?",
initialValue: true,
}),
);
setupLog.userInput('imessage_fda_confirmed', 'true');
}
async function collectRemoteCreds(): Promise<RemoteCreds> {
const existingUrl = readEnvKey('IMESSAGE_SERVER_URL');
const existingKey = readEnvKey('IMESSAGE_API_KEY');
if (existingUrl && existingKey && /^https?:\/\//i.test(existingUrl)) {
const reuse = ensureAnswer(await p.confirm({
message: `Found existing Photon credentials (${existingUrl}). Use them?`,
initialValue: true,
}));
if (reuse) {
setupLog.userInput('imessage_remote_creds', 'reused-existing');
return { serverUrl: existingUrl, apiKey: existingKey };
}
}
note(
[
"Photon is a separate service that owns an iMessage account and",
"exposes it over HTTP. NanoClaw will talk to it via its API.",
'',
' 1. Set up a Photon server: https://photon.codes',
' 2. Copy the server URL and API key from your Photon dashboard',
].join('\n'),
'Remote iMessage via Photon',
);
const urlAnswer = ensureAnswer(
await p.text({
message: 'Photon server URL',
placeholder: 'https://photon.example.com',
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'URL is required';
if (!/^https?:\/\//i.test(t)) return 'Must start with http:// or https://';
return undefined;
},
}),
);
const serverUrl = (urlAnswer as string).trim();
const keyAnswer = ensureAnswer(
await p.password({
message: 'Photon API key',
clearOnError: true,
validate: (v) => ((v ?? '').trim() ? undefined : 'API key is required'),
}),
);
const apiKey = (keyAnswer as string).trim();
setupLog.userInput('imessage_server_url', serverUrl);
setupLog.userInput(
'imessage_api_key',
`${apiKey.slice(0, 4)}${apiKey.slice(-4)}`,
);
return { serverUrl, apiKey };
}
async function askOperatorHandle(): Promise<string> {
note(
[
"What phone number or email do you iMessage with?",
"That's where your assistant will send its welcome message.",
'',
k.dim(' • Phone: start with + and your country code, no spaces or dashes'),
k.dim(' Example: +14155551234 (country code 1, then 4155551234)'),
k.dim(' • Email: whatever iMessage recognises (Apple ID, iCloud alias, …)'),
].join('\n'),
'Your iMessage handle',
);
const answer = ensureAnswer(
await p.text({
message: 'Phone number or email',
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Required';
const isPhone = /^\+\d{8,15}$/.test(t);
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t);
if (!isPhone && !isEmail) {
return "Use a +E.164 phone number or an email address";
}
return undefined;
},
}),
);
const handle = (answer as string).trim();
setupLog.userInput('imessage_handle', handle);
return handle;
}
async function resolveAgentName(): Promise<string> {
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
if (preset) {
setupLog.userInput('agent_name', preset);
return preset;
}
const answer = ensureAnswer(
await p.text({
message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
);
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
setupLog.userInput('agent_name', value);
return value;
}
+234
View File
@@ -0,0 +1,234 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runChannelSkill } from './run-channel-skill.js';
import { BACK_TO_CHANNEL_SELECTION, backGate } from '../lib/back-nav.js';
// Drive the first-prompt back gate (back-nav's brightSelect) from a queue
// instead of opening a real TTY select. Hoisted so the vi.mock factory — which
// runs before imports — can close over it. The existing Option-A tests never
// opt into offerBack (and pass `role` so askOperatorRole's brightSelect isn't
// reached either), so the mock is inert for them.
const bs = vi.hoisted(() => ({ answers: [] as string[] }));
vi.mock('../lib/bright-select.js', async (importActual) => {
const actual = await importActual<typeof import('../lib/bright-select.js')>();
return { ...actual, brightSelect: vi.fn(async () => bs.answers.shift() ?? 'continue') };
});
// Drives the real add-slack skill through the adapter with every side effect
// injected (no real ncl/git/clack/init-first-agent): confirms it runs the skill
// (install + creds + resolve), reads the resolved owner_handle + platform_id from
// the result, and hands them to the shared wire with a composed user-id.
describe('runChannelSkill adapter (Option A)', () => {
it('resolves via the skill, then wires through init-first-agent', async () => {
const root = mkdtempSync(join(tmpdir(), 'rcs-'));
mkdirSync(join(root, 'src/channels'), { recursive: true });
writeFileSync(join(root, 'src/channels/index.ts'), '// barrel\n');
writeFileSync(join(root, '.env'), '');
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
const cmds: string[] = [];
const exec = (c: string): string | void => {
cmds.push(c);
if (c.includes('auth.test')) return '@bot in Acme\n'; // identity capture
// the resolve run: conversations.open piped through jq → "slack:<channel>"
if (c.includes('conversations.open')) return 'slack:D0SLACK\n';
};
const wired: Array<Record<string, unknown>> = [];
await runChannelSkill('slack', 'Bob Smith', {
projectRoot: root,
exec,
resolveRemote: () => 'origin',
agentName: 'Nano',
role: 'owner',
// the secrets + handle a human would supply; the skill resolves platform_id.
// Values are valid-shaped for the prompts' validate: regexes — validate-at-bind
// now enforces them on `inputs` too (they used to bypass validation).
inputs: { connection: 'webhook', bot_token: 'xoxb-x', signing_secret: '0123456789abcdef', owner_handle: 'U12345678' },
wire: (a) => {
wired.push(a);
return true;
},
});
// the channel-specific resolve ran
expect(cmds.some((c) => c.includes('auth.test'))).toBe(true);
expect(cmds.some((c) => c.includes('conversations.open'))).toBe(true);
// ...and the shared wire got the composed user-id + resolved platform_id
expect(wired).toHaveLength(1);
expect(wired[0]).toMatchObject({
channel: 'slack',
userId: 'slack:U12345678', // channel + owner_handle
platformId: 'slack:D0SLACK', // captured from conversations.open
displayName: 'Bob Smith',
agentName: 'Nano',
role: 'owner',
});
// the adapter no longer emits any ncl wiring itself — that's init-first-agent's job
expect(cmds.some((c) => c.startsWith('ncl '))).toBe(false);
});
// Teams' platform_id only exists after the first inbound, so its SKILL.md
// installs + hands off and runChannelSkill is called with deferWire — it must
// run the skill but never reach the shared wire. This is the driver-policy
// parity fixture: it runs the DEFAULT onEvent handler (never an injected
// onEvent, which would replace the policy — §5.0) and injects the
// confirm/openUrl seams to prove both natural barriers fire and the portal
// URL offer survives from the operator prose alone.
it('deferWire (Teams): default policy fires the gate barriers + portal URL offer, never reaches the shared wire', async () => {
const root = mkdtempSync(join(tmpdir(), 'rcs-teams-'));
mkdirSync(join(root, 'src/channels'), { recursive: true });
writeFileSync(join(root, 'src/channels/index.ts'), '// barrel\n');
writeFileSync(join(root, '.env'), '');
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
const log: string[] = [];
const opened: string[] = [];
const wired: unknown[] = [];
await runChannelSkill('teams', 'Acme Corp', {
projectRoot: root,
exec: (c) => void log.push(`exec:${c}`),
resolveRemote: () => 'origin',
reuse: false,
deferWire: true,
// The injectable interaction seams — the default handler consults them for
// the URL offer and the natural-barrier confirms, so no real clack confirm
// (which would hang in CI) and no real browser open is reached.
confirm: async (m) => {
log.push(`confirm:${m}`);
return true;
},
openUrl: async (u) => void opened.push(u),
// a MultiTenant app, so the SingleTenant-guarded app_tenant_id prompt is skipped
inputs: {
public_url: 'https://acme.example',
app_id: '12345678-1234-1234-1234-123456789abc',
app_type: 'MultiTenant',
app_password: 'a-much-longer-app-password', // 20+ chars — valid for the declared shape
},
wire: (a) => {
wired.push(a);
return true;
},
});
// install + manifest ran…
expect(log.some((c) => c.includes('teams-manifest-build'))).toBe(true);
// …the Azure portal offer came from the operator BODY text (policy §5.2)…
expect(opened.some((u) => /portal\.azure\.com/.test(u))).toBe(true);
// …a natural-barrier confirm (not a URL offer) fired BEFORE the manifest
// build (the manifest-before-the-app hazard fix, now derived from document
// structure instead of an authored gate attr)…
const firstGate = log.findIndex((c) => c.startsWith('confirm:') && !c.startsWith('confirm:Open '));
const manifestAt = log.findIndex((c) => c.includes('teams-manifest-build'));
expect(firstGate).toBeGreaterThanOrEqual(0);
expect(firstGate).toBeLessThan(manifestAt);
// …but the shared wire was never reached (no owner_handle/platform_id needed)
expect(wired).toHaveLength(0);
});
// The engine reads `.claude/skills/add-<channel>/SKILL.md` relative to cwd (the
// repo root in tests — same as the real add-slack the test above drives), so a
// bounce-fixture skill is created there and torn down afterward.
const failChannel = 'failtest';
const failSkillDir = join(process.cwd(), '.claude/skills', `add-${failChannel}`);
afterEach(() => rmSync(failSkillDir, { recursive: true, force: true }));
// When the skill doesn't fully apply (a directive bounced to an agent), the
// generic "couldn't finish" message is replaced by the bounced step's OWN
// prose: the section heading becomes fail()'s headline and the surrounding
// prose becomes the dimmed hint (which fail() also forwards to the Claude
// handoff). Asserted via an injected fail spy (the real fail() process.exits).
it('threads the bounced step prose into fail() when the skill does not fully apply', async () => {
const root = mkdtempSync(join(tmpdir(), 'rcs-fail-'));
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
writeFileSync(join(root, '.env'), '');
// A skill whose only directive bounces — the engine has no handler for
// nc:hand-wire, so it degrades to an agent and the run is not fully applied.
mkdirSync(failSkillDir, { recursive: true });
writeFileSync(
join(failSkillDir, 'SKILL.md'),
[
`# add ${failChannel}`,
'',
'## Register the webhook by hand',
'Open the Faily dashboard and paste the webhook URL into the bot settings.',
'```nc:hand-wire',
'register webhook',
'```',
'',
].join('\n'),
);
const failCalls: Array<{ step: string; msg: string; hint?: string }> = [];
const fakeFail = (step: string, msg: string, hint?: string): Promise<never> => {
failCalls.push({ step, msg, hint });
// The real fail() process.exits and never returns; emulate that by aborting
// the flow so control doesn't fall through to the resolve/wire steps.
return Promise.reject(new Error('__failed__'));
};
await expect(
runChannelSkill(failChannel, 'Bob', {
projectRoot: root,
exec: () => {},
resolveRemote: () => 'origin',
agentName: 'Nano',
role: 'owner',
reuse: false,
inputs: {},
fail: fakeFail,
wire: () => true,
}),
).rejects.toThrow('__failed__');
expect(failCalls).toHaveLength(1);
expect(failCalls[0].step).toBe(`${failChannel}-install`);
expect(failCalls[0].msg).toBe('Register the webhook by hand'); // heading → headline
expect(failCalls[0].hint).toContain('Open the Faily dashboard'); // prose → hint
expect(failCalls[0].hint).not.toBe('See logs/setup-steps/ for details, then retry setup.'); // not the generic
});
});
// M5 backGate — the first-prompt "← Back to channel selection" gate. It's a
// brightSelect (mocked above) wrapped in ensureAnswer; on back it returns the
// existing BACK_TO_CHANNEL_SELECTION sentinel that setup/auto.ts already catches.
describe('backGate (first-prompt back-to-channel-selection)', () => {
it('returns the sentinel on back and continue otherwise', async () => {
bs.answers = ['back'];
expect(await backGate('Slack DMs')).toBe(BACK_TO_CHANNEL_SELECTION);
bs.answers = ['continue'];
expect(await backGate('Slack DMs')).toBe('continue');
});
// offerBack runs the gate at the very top — before resolveAgentName/role, the
// skill run, and the wire. Picking back returns the sentinel without touching
// any side effect (no exec, no wire).
it('runChannelSkill with offerBack returns the sentinel before running the skill', async () => {
bs.answers = ['back'];
const cmds: string[] = [];
const wired: unknown[] = [];
const result = await runChannelSkill('slack', 'Bob Smith', {
offerBack: true,
exec: (c) => void cmds.push(c),
resolveRemote: () => 'origin',
agentName: 'Nano',
role: 'owner',
inputs: { connection: 'webhook', bot_token: 'xoxb-x', signing_secret: '0123456789abcdef', owner_handle: 'U12345678' },
wire: (a) => {
wired.push(a);
return true;
},
});
expect(result).toBe(BACK_TO_CHANNEL_SELECTION);
expect(cmds).toHaveLength(0); // the skill never ran
expect(wired).toHaveLength(0); // the wire was never reached
});
});
+181
View File
@@ -0,0 +1,181 @@
/**
* Generic channel onboarding for setup:auto the replacement for the bespoke
* per-channel `run<Channel>Channel` flows.
*
* Split of responsibilities (Option A):
* - The channel's SKILL.md owns the channel-specific part: install the adapter,
* collect credentials, and resolve the wire inputs `owner_handle` +
* `platform_id` (e.g. Slack `conversations.open`). The engine surfaces those
* resolved values in `ApplyResult.vars`.
* - This flow owns the shared part: the operator's agent name + role (the
* polish), and the wire itself `scripts/init-first-agent.ts`, which creates
* the agent group, grants the owner role (+ cli_scope=global), creates the
* messaging group + wiring, and sends the `/welcome` system instruction.
*
* So the wire lives in exactly one place (init-first-agent) and is never
* duplicated across channel skills.
*/
import * as p from '@clack/prompts';
import { firstFailureHint, fullyApplied } from '../../scripts/skill-apply.js';
import { BACK_TO_CHANNEL_SELECTION, backGate, type ChannelFlowResult } from '../lib/back-nav.js';
import { askOperatorRole, type OperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { runSkill, type RunSkillOptions } from '../lib/skill-driver.js';
const DEFAULT_AGENT_NAME = 'Nano';
interface WireArgs {
channel: string;
userId: string;
platformId: string;
displayName: string;
agentName: string;
role: OperatorRole;
}
async function resolveAgentName(): Promise<string> {
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
if (preset) return preset;
const answer = ensureAnswer(
await p.text({
message: 'What should your assistant be called?',
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
);
return (answer as string).trim() || DEFAULT_AGENT_NAME;
}
/** The shared wire: init-first-agent (group + owner role + cli_scope + wiring + /welcome). */
async function initFirstAgent(args: WireArgs): Promise<boolean> {
const res = await runQuietChild(
'init-first-agent',
'pnpm',
[
'exec', 'tsx', 'scripts/init-first-agent.ts',
'--channel', args.channel,
'--user-id', args.userId,
'--platform-id', args.platformId,
'--display-name', args.displayName,
'--agent-name', args.agentName,
'--role', args.role,
],
{ running: `Wiring ${args.agentName} to your ${args.channel} DMs…`, done: 'Agent wired.' },
{ extraFields: { CHANNEL: args.channel, AGENT_NAME: args.agentName, PLATFORM_ID: args.platformId } },
);
return res.ok;
}
export interface ChannelSkillOverrides extends Partial<RunSkillOptions> {
agentName?: string;
role?: OperatorRole;
/** The shared wire; defaults to init-first-agent. Injectable for tests. */
wire?: (args: WireArgs) => Promise<boolean> | boolean;
/**
* Skip the resolve+wire half. For a channel whose platform_id only exists
* after the first inbound (Teams): the SKILL.md installs the adapter and ends
* with an `nc:operator` handoff telling the operator to DM the bot and finish
* wiring later. No owner_handle/platform_id is expected, no agent name/role is
* asked, and init-first-agent is not run.
*/
deferWire?: boolean;
/**
* Offer the "← Back to channel selection" gate as the very first prompt,
* before any side effect (agent-name/role prompts, the skill run, the wire
* and the deferWire/Teams path too). On back, returns the
* `BACK_TO_CHANNEL_SELECTION` sentinel and does nothing else. Opt-in so
* headless callers (and the existing tests) never see the extra prompt.
*/
offerBack?: boolean;
/** The first-prompt back gate; defaults to back-nav.ts `backGate`. Injectable for tests. */
backGate?: (label: string) => Promise<'continue' | typeof BACK_TO_CHANNEL_SELECTION>;
/** The abort path; defaults to runner.ts `fail` (which exits). Injectable for tests. */
fail?: (stepName: string, msg: string, hint?: string, rawLogPath?: string) => Promise<never>;
}
export async function runChannelSkill(
channel: string,
displayName: string,
overrides: ChannelSkillOverrides = {},
): Promise<ChannelFlowResult> {
// First-prompt back gate — the very first thing, before any side effect
// (agent-name/role prompts, the skill run, the wire; covers deferWire too).
// Opt-in via offerBack so headless callers + existing tests are unaffected.
if (overrides.offerBack) {
const label = channel.charAt(0).toUpperCase() + channel.slice(1);
const gate = await (overrides.backGate ?? backGate)(label);
if (gate === BACK_TO_CHANNEL_SELECTION) return BACK_TO_CHANNEL_SELECTION;
}
const projectRoot = overrides.projectRoot ?? process.cwd();
const failWith = overrides.fail ?? fail;
// The agent name + role are wire inputs — skip the prompts when the wire is deferred.
const agentName = overrides.deferWire ? '' : overrides.agentName ?? (await resolveAgentName());
const role = overrides.deferWire ? undefined : overrides.role ?? (await askOperatorRole(channel));
// Channel-specific: install adapter, collect credentials, resolve the wire
// inputs. The whole channel-specific procedure lives in the SKILL.md.
const res = await runSkill(`.claude/skills/add-${channel}`, {
projectRoot,
exec: overrides.exec,
resolveInput: overrides.resolveInput,
resolveRemote: overrides.resolveRemote,
inputs: overrides.inputs,
skipEffects: overrides.skipEffects,
// undefined ⇒ runSkill's default policy handler (TTY-gated spinner + operator
// note → URL offer → natural-barrier confirm). An injected onEvent replaces
// that policy entirely; inject confirm/openUrl to observe the default policy.
onEvent: overrides.onEvent,
confirm: overrides.confirm,
openUrl: overrides.openUrl,
reuse: overrides.reuse ?? true, // offer to reuse credentials already in .env
// Handoff context for the `?` help-escape: a lone `?` at any of this skill's
// prompts hands the operator off to interactive Claude scoped to this channel.
channel: overrides.channel ?? channel,
step: overrides.step ?? `${channel}-install`,
});
if (!fullyApplied(res)) {
if (res.deferred.length) p.log.warn(`Still needs: ${res.deferred.join(', ')}`);
for (const t of res.agentTasks) p.log.warn(`Needs an agent (${t.kind}): ${t.reason}`);
// On a real bounce, also surface the skill's reference floor (Alternatives /
// Optional configuration / Troubleshooting) — the same prose a human reader
// would scroll to when a step doesn't apply cleanly. Only on a bounce
// (agentTasks), and only when the skill actually ships a reference section.
if (res.agentTasks.length && res.referenceProse) p.log.info(res.referenceProse);
// Surface the bounced step's OWN prose as the failure hint + Claude-handoff
// context (fail() dims the hint and forwards it to offerClaudeOnFailure),
// instead of a generic "couldn't finish" message. Only a real bounce yields a
// diagnosis; a purely-deferred run (a missing input) falls back to the generic.
const diag = firstFailureHint(res);
await failWith(
`${channel}-install`,
diag?.headline ?? `Couldn't finish setting up ${channel}.`,
diag?.hint ?? 'See logs/setup-steps/ for details, then retry setup.',
);
}
// Identity confirmation captured by the skill (e.g. add-slack's auth.test).
if (res.vars.connected_as) p.log.success(`Connected to ${channel} as ${res.vars.connected_as}.`);
// Deferred wire (Teams): the SKILL's operator handoff owns the rest. Done here.
if (overrides.deferWire) return;
const ownerHandle = res.vars.owner_handle;
const platformId = res.vars.platform_id;
if (!ownerHandle || !platformId) {
await failWith(
`${channel}-resolve`,
`Couldn't resolve your ${channel} address.`,
'The skill did not produce owner_handle + platform_id.',
);
}
// Shared wire — the same procedure for every channel. role is defined here:
// it's only undefined in deferWire mode, which returned above.
const wire = overrides.wire ?? initFirstAgent;
const ok = await wire({ channel, userId: `${channel}:${ownerHandle}`, platformId, displayName, agentName, role: role! });
if (!ok) {
await failWith('init-first-agent', `Couldn't finish connecting ${agentName}.`, 'You can retry later with `/init-first-agent`.');
}
}
-417
View File
@@ -1,417 +0,0 @@
/**
* Signal channel flow for setup:auto.
*
* `runSignalChannel(displayName)` owns the full branch from signal-cli
* presence check through the welcome DM:
*
* 1. Probe signal-cli on PATH (or SIGNAL_CLI_PATH). On macOS without it,
* offer `brew install signal-cli` inline. On Linux, surface the
* GitHub releases URL and bail with an actionable error.
* 2. Install the adapter + qrcode via setup/add-signal.sh (idempotent).
* 3. Run the signal-auth step, rendering each SIGNAL_AUTH_QR block as
* a terminal QR the operator scans from Signal Linked Devices.
* 4. Persist SIGNAL_ACCOUNT to .env (+ data/env/env).
* 5. Kick the service so the adapter picks up the new credentials.
* 6. Ask operator role + agent name.
* 7. Wire the agent via scripts/init-first-agent.ts; the existing welcome
* DM path delivers the greeting through the adapter.
*
* Signal's `link` flow creates a *secondary* device. The phone number
* comes from the primary (the phone that scanned the QR); this host then
* sends/receives as that primary number. No registration of new numbers.
*
* Output obeys the three-level contract: clack UI for the user, structured
* entries in logs/setup.log, full raw output in per-step files under
* logs/setup-steps/. See docs/setup-flow.md.
*/
import { spawnSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js';
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
import { brightSelect } from '../lib/bright-select.js';
import {
type Block,
type StepResult,
dumpTranscriptOnFailure,
ensureAnswer,
fail,
runQuietChild,
spawnStep,
writeStepEntry,
} from '../lib/runner.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { accentGreen, fmtDuration, note } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano';
export async function runSignalChannel(displayName: string): Promise<ChannelFlowResult> {
note(
[
"NanoClaw links to Signal as a *secondary* device on your existing",
"phone — no new number needed. Your assistant will send and receive",
"messages as the number on that phone.",
'',
"Here's what's about to happen — no input needed for any of it:",
'',
' 1. Set up signal-cli (auto-installs if missing)',
' 2. Install the Signal adapter',
' 3. Show a QR code — scan it from Signal → Settings → Linked Devices',
' 4. Wire your assistant and send a welcome message',
].join('\n'),
'Set up Signal',
);
const proceed = ensureAnswer(await brightSelect<'continue' | 'back'>({
message: 'Ready to set up Signal?',
options: [
{ value: 'continue', label: 'Continue' },
{ value: 'back', label: '← Back to channel selection' },
],
initialValue: 'continue',
}));
if (proceed === 'back') return BACK_TO_CHANNEL_SELECTION;
await ensureSignalCli();
const install = await runQuietChild(
'signal-install',
'bash',
['setup/add-signal.sh'],
{
running: 'Installing the Signal adapter…',
done: 'Signal adapter installed.',
skipped: 'Signal adapter already installed.',
},
);
if (!install.ok) {
await fail(
'signal-install',
"Couldn't install the Signal adapter.",
'See logs/setup-steps/ for details, then retry setup.',
);
}
const auth = await runSignalAuth();
if (!auth.ok) {
const reason = auth.terminal?.fields.ERROR ?? 'unknown';
await fail(
'signal-auth',
`Signal link failed (${reason}).`,
reason === 'qr_timeout'
? 'The code expired. Re-run setup to get a fresh one.'
: 'Re-run setup to try again.',
);
}
const account = auth.terminal?.fields.ACCOUNT;
if (!account) {
await fail(
'signal-auth',
'Linked with Signal but couldn\'t read the phone number back.',
'Run `signal-cli listAccounts` to confirm, then re-run setup.',
);
}
writeSignalAccount(account!);
await restartService();
const role = await askOperatorRole('Signal');
setupLog.userInput('signal_role', role);
const agentName = await resolveAgentName();
const init = await runQuietChild(
'init-first-agent',
'pnpm',
[
'exec', 'tsx', 'scripts/init-first-agent.ts',
'--channel', 'signal',
'--user-id', account!,
'--platform-id', account!,
'--display-name', displayName,
'--agent-name', agentName,
'--role', role,
],
{
running: `Connecting ${agentName} to Signal…`,
done: `${agentName} is ready. Check Signal for a welcome message.`,
},
{
extraFields: {
CHANNEL: 'signal',
AGENT_NAME: agentName,
PLATFORM_ID: account!,
ROLE: role,
},
},
);
if (!init.ok) {
await fail(
'init-first-agent',
`Couldn't finish connecting ${agentName}.`,
'You can retry later with `/manage-channels`.',
);
}
}
async function ensureSignalCli(): Promise<void> {
const cli = process.env.SIGNAL_CLI_PATH || 'signal-cli';
const probeFor = (): boolean => {
const r = spawnSync(cli, ['--version'], {
stdio: ['ignore', 'pipe', 'pipe'],
});
return !r.error && r.status === 0;
};
if (probeFor()) return;
note(
[
"NanoClaw talks to Signal through signal-cli, which isn't installed yet.",
"We'll install it for you now — about 30 seconds, one-time only.",
'',
process.platform === 'darwin'
? "On this Mac we'll use Homebrew (no admin password needed)."
: "On Linux we'll grab the native release binary (no Java needed) and install it to ~/.local/bin.",
].join('\n'),
'Setting up signal-cli',
);
const install = await runQuietChild(
'install-signal-cli',
'bash',
['setup/install-signal-cli.sh'],
{
running: 'Installing signal-cli…',
done: 'signal-cli installed.',
},
);
if (install.ok && probeFor()) return;
const reason = install.terminal?.fields.ERROR;
if (process.platform === 'darwin') {
note(
[
"We couldn't install signal-cli automatically.",
reason === 'homebrew_not_installed'
? ' Reason: Homebrew is not installed.'
: ` Reason: ${reason ?? 'unknown'}.`,
'',
'You can install it manually:',
'',
k.cyan(' brew install signal-cli'),
'',
'Then re-run setup.',
].join('\n'),
"Couldn't install signal-cli",
);
} else {
note(
[
"We couldn't install signal-cli automatically.",
` Reason: ${reason ?? 'unknown'}.`,
'',
'You can install it manually from GitHub:',
'',
k.cyan(' https://github.com/AsamK/signal-cli/releases'),
'',
'Then re-run setup.',
].join('\n'),
"Couldn't install signal-cli",
);
}
await fail(
'install-signal-cli',
'signal-cli is required but the auto-install failed.',
'Install it manually and re-run setup.',
);
}
async function runSignalAuth(): Promise<
StepResult & { rawLog: string; durationMs: number }
> {
const rawLog = setupLog.stepRawLog('signal-auth');
const start = Date.now();
const s = p.spinner();
s.start('Starting Signal link…');
let spinnerActive = true;
const stopSpinner = (msg: string, code?: number): void => {
if (spinnerActive) {
s.stop(msg, code);
spinnerActive = false;
}
};
// Tracks how many lines the QR block occupies so we can wipe it in-place
// once linking succeeds (Signal's link URL doesn't rotate like WhatsApp's,
// but we still want to erase the QR from screen once it's served).
let qrLinesPrinted = 0;
const result = await spawnStep(
'signal-auth',
[],
(block: Block) => {
if (block.type === 'SIGNAL_AUTH_QR') {
const qr = block.fields.QR ?? '';
if (!qr) return;
void renderQr(qr).then((lines) => {
stopSpinner('Scan this QR from Signal → Settings → Linked Devices.');
process.stdout.write(lines.join('\n') + '\n');
qrLinesPrinted = lines.length;
s.start('Waiting for you to scan…');
spinnerActive = true;
});
} else if (block.type === 'SIGNAL_AUTH') {
const status = block.fields.STATUS;
// Wipe the QR block regardless of outcome — it's either scanned
// and useless, or expired and misleading.
if (qrLinesPrinted > 0) {
process.stdout.write(`\x1b[${qrLinesPrinted}A\x1b[0J`);
qrLinesPrinted = 0;
}
const account = block.fields.ACCOUNT;
if (status === 'skipped') {
stopSpinner(
account
? `Signal already linked as ${k.cyan(account)}.`
: 'Signal already linked.',
);
} else if (status === 'success') {
stopSpinner(`Signal linked as ${k.cyan(String(account ?? ''))}.`);
} else if (status === 'failed') {
const err = block.fields.ERROR ?? 'unknown';
stopSpinner(`Signal link failed: ${err}`, 1);
}
}
},
rawLog,
);
const durationMs = Date.now() - start;
if (spinnerActive) {
stopSpinner(
result.ok ? 'Done.' : 'Signal link ended unexpectedly.',
result.ok ? 0 : 1,
);
if (!result.ok) dumpTranscriptOnFailure(result.transcript);
}
writeStepEntry('signal-auth', result, durationMs, rawLog);
return { ...result, rawLog, durationMs };
}
/**
* Render the raw linking URL as a block-art QR, returned line-by-line so
* the caller can count lines for in-place cleanup. Uses small-mode so the
* code stays scannable on 24-row terminals. If qrcode isn't installed
* (add-signal.sh should have handled it, but we're defensive), fall back
* to the raw URL and ask the user to paste it into an external renderer.
*/
async function renderQr(url: string): Promise<string[]> {
try {
const QRCode = await import('qrcode');
const qrText = await QRCode.toString(url, { type: 'terminal', small: true });
const caption = k.dim(
' Signal → Settings → Linked Devices → Link New Device → scan.',
);
return [...qrText.trimEnd().split('\n'), '', caption];
} catch {
return [
'Linking URL (render at https://qr.io or similar):',
'',
url,
'',
k.dim('Signal → Settings → Linked Devices → Link New Device → scan.'),
];
}
}
/** Persist SIGNAL_ACCOUNT to .env and mirror to data/env/env for the container. */
function writeSignalAccount(account: string): void {
const envPath = path.join(process.cwd(), '.env');
let contents = '';
try {
contents = fs.readFileSync(envPath, 'utf-8');
} catch {
contents = '';
}
if (/^SIGNAL_ACCOUNT=/m.test(contents)) {
contents = contents.replace(
/^SIGNAL_ACCOUNT=.*$/m,
`SIGNAL_ACCOUNT=${account}`,
);
} else {
if (contents.length > 0 && !contents.endsWith('\n')) contents += '\n';
contents += `SIGNAL_ACCOUNT=${account}\n`;
}
fs.writeFileSync(envPath, contents);
const containerEnvDir = path.join(process.cwd(), 'data', 'env');
fs.mkdirSync(containerEnvDir, { recursive: true });
fs.copyFileSync(envPath, path.join(containerEnvDir, 'env'));
setupLog.userInput('signal_account', account);
}
async function restartService(): Promise<void> {
const s = p.spinner();
s.start('Restarting NanoClaw so it sees your Signal account…');
const start = Date.now();
const platform = process.platform;
try {
if (platform === 'darwin') {
spawnSync(
'launchctl',
['kickstart', '-k', `gui/${process.getuid?.() ?? 501}/${getLaunchdLabel()}`],
{ stdio: 'ignore' },
);
} else if (platform === 'linux') {
const unit = getSystemdUnit();
const user = spawnSync('systemctl', ['--user', 'restart', unit], {
stdio: 'ignore',
});
if (user.status !== 0) {
spawnSync('sudo', ['systemctl', 'restart', unit], { stdio: 'ignore' });
}
}
// Give the adapter a moment to connect to signal-cli before
// init-first-agent's welcome DM hits the delivery path.
await new Promise((r) => setTimeout(r, 5000));
s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('signal-restart', 'success', Date.now() - start, {
PLATFORM: platform,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
s.stop(`Restart may have failed: ${message}`, 1);
setupLog.step('signal-restart', 'failed', Date.now() - start, {
ERROR: message,
});
// Non-fatal — the user can restart manually if init-first-agent fails.
}
}
async function resolveAgentName(): Promise<string> {
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
if (preset) {
setupLog.userInput('agent_name', preset);
return preset;
}
const answer = ensureAnswer(
await p.text({
message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
);
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
setupLog.userInput('agent_name', value);
return value;
}
-551
View File
@@ -1,551 +0,0 @@
/**
* Slack channel flow for setup:auto.
*
* `runSlackChannel(displayName)` owns the full branch from creating a
* Slack app through the welcome DM:
*
* 1. Ask the delivery mode: Socket Mode (outbound WebSocket, no public
* URL) or a public webhook
* 2. Walk through creating a Slack app (api.slack.com/apps) scopes,
* events, and the mode-specific credential (app-level token for
* Socket Mode, signing secret for webhook)
* 3. Paste the bot token + that credential (clack password prompts)
* 4. Validate via auth.test resolves workspace + bot identity
* 5. Install the adapter (setup/add-slack.sh, non-interactive)
* 6. Ask for the operator's Slack user ID
* 7. conversations.open to get the DM channel ID
* 8. Ask for the messaging-agent name (defaulting to "Nano")
* 9. Wire the agent via scripts/init-first-agent.ts
*
* The welcome DM is sent via outbound delivery (chat.postMessage), which
* works without Event Subscriptions being configured. The user sees the
* greeting in Slack immediately; inbound replies require webhooks, so the
* post-install note covers that.
*
* All output obeys the three-level contract. See docs/setup-flow.md.
*/
import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
import { brightSelect } from '../lib/bright-select.js';
import { openUrl } from '../lib/browser.js';
import { isHeadless } from '../platform.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { readEnvKey } from '../environment.js';
import { accentGreen, fmtDuration, note, wrapForGutter } from '../lib/theme.js';
const SLACK_API = 'https://slack.com/api';
const SLACK_APPS_URL = 'https://api.slack.com/apps';
const DEFAULT_AGENT_NAME = 'Nano';
interface WorkspaceInfo {
teamName: string;
teamId: string;
botName: string;
botUserId: string;
}
// Socket Mode (SLACK_APP_TOKEN, xapp-…) needs no public URL; webhook mode
// (SLACK_SIGNING_SECRET) needs a public Request URL. The adapter picks the mode
// purely from SLACK_APP_TOKEN's presence — this choice just decides which
// credential to collect and which post-install guidance to show.
type SlackMode = 'socket' | 'webhook';
export async function runSlackChannel(displayName: string): Promise<ChannelFlowResult> {
const mode = await askSlackMode();
const intro = await walkThroughAppCreation(mode);
if (intro === 'back') return BACK_TO_CHANNEL_SELECTION;
const token = await collectBotToken();
const appToken = mode === 'socket' ? await collectAppToken() : undefined;
const signingSecret = mode === 'webhook' ? await collectSigningSecret() : undefined;
const info = await validateSlackToken(token);
const env: Record<string, string> = { SLACK_BOT_TOKEN: token };
if (appToken) env.SLACK_APP_TOKEN = appToken;
if (signingSecret) env.SLACK_SIGNING_SECRET = signingSecret;
const install = await runQuietChild(
'slack-install',
'bash',
['setup/add-slack.sh'],
{
running: `Connecting Slack to @${info.botName} (${info.teamName})…`,
done: 'Slack adapter installed.',
},
{
env,
extraFields: {
MODE: mode,
BOT_NAME: info.botName,
TEAM_NAME: info.teamName,
TEAM_ID: info.teamId,
},
},
);
if (!install.ok) {
await fail(
'slack-install',
"Couldn't connect Slack.",
'See logs/setup-steps/ for details, then retry setup.',
);
}
const ownerUserId = await collectSlackUserId();
const dmChannelId = await openDmChannel(token, ownerUserId);
const platformId = `slack:${dmChannelId}`;
const role = await askOperatorRole('Slack');
setupLog.userInput('slack_role', role);
const agentName = await resolveAgentName();
const init = await runQuietChild(
'init-first-agent',
'pnpm',
[
'exec', 'tsx', 'scripts/init-first-agent.ts',
'--channel', 'slack',
'--user-id', `slack:${ownerUserId}`,
'--platform-id', platformId,
'--display-name', displayName,
'--agent-name', agentName,
'--role', role,
],
{
running: `Wiring ${agentName} to your Slack DMs…`,
done: 'Agent wired.',
},
{
extraFields: {
CHANNEL: 'slack',
AGENT_NAME: agentName,
PLATFORM_ID: platformId,
},
},
);
if (!init.ok) {
await fail(
'init-first-agent',
`Couldn't finish connecting ${agentName}.`,
'You can retry later with `/init-first-agent` in Claude Code.',
);
}
showPostInstallChecklist(info, mode);
}
async function askSlackMode(): Promise<SlackMode> {
const choice = ensureAnswer(
await brightSelect<SlackMode>({
message: 'How should Slack deliver events to NanoClaw?',
initialValue: 'socket',
options: [
{
value: 'socket',
label: 'Socket Mode',
hint: 'no public URL — recommended for local or behind NAT',
},
{
value: 'webhook',
label: 'Public webhook',
hint: 'needs a public HTTPS Request URL',
},
],
}),
);
setupLog.userInput('slack_mode', String(choice));
return choice;
}
async function walkThroughAppCreation(mode: SlackMode): Promise<'continue' | 'back'> {
const credSteps =
mode === 'socket'
? [
' 4. Basic Information → App-Level Tokens → "Generate Token and',
' Scopes" → add the connections:write scope → copy it (xapp-…)',
' 5. Socket Mode → toggle "Enable Socket Mode" on',
' 6. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
]
: [
' 4. Basic Information → copy the "Signing Secret"',
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
];
// Bright-white ANSI overrides the surrounding brand-cyan from `note()`'s
// per-line formatter so the URL stands out against the rest of the body.
const linkBlock = isHeadless()
? [`\x1b[97mGet started: ${SLACK_APPS_URL}\x1b[39m`, '']
: [];
note(
[
"You'll create a Slack app that the assistant talks through.",
"Free and stays inside the workspaces you pick.",
'',
...linkBlock,
' 1. Create a new app "From scratch", name it, pick a workspace',
' 2. OAuth & Permissions → add Bot Token Scopes:',
' • im:write, im:history',
' • channels:read, channels:history',
' • groups:read, groups:history',
' • chat:write',
' • users:read',
' • reactions:write',
' • files:read, files:write',
' 3. App Home → enable "Messages Tab" and "Allow users to send',
' slash commands and messages from the messages tab"',
...credSteps,
].join('\n'),
'Create a Slack app',
);
// Back-aware gate replacing the old `confirmThenOpen` "Press Enter to open
// Slack app settings" so users can bail out of Slack before we open the
// browser or ask for tokens.
const choice = ensureAnswer(await brightSelect<'open' | 'back'>({
message: 'Open Slack app settings in your browser?',
options: [
{ value: 'open', label: 'Open Slack app settings' },
{ value: 'back', label: '← Back to channel selection' },
],
initialValue: 'open',
}));
if (choice === 'back') return 'back';
if (!isHeadless()) openUrl(SLACK_APPS_URL);
ensureAnswer(
await p.confirm({
message:
mode === 'socket'
? 'Got your bot token and app-level token?'
: 'Got your bot token and signing secret?',
initialValue: true,
}),
);
return 'continue';
}
async function collectBotToken(): Promise<string> {
const existing = readEnvKey('SLACK_BOT_TOKEN');
if (existing && existing.startsWith('xoxb-') && existing.length >= 24) {
const reuse = ensureAnswer(await p.confirm({
message: `Found an existing Slack bot token (${existing.slice(0, 10)}…). Use it?`,
initialValue: true,
}));
if (reuse) {
setupLog.userInput('slack_bot_token', 'reused-existing');
return existing;
}
}
const answer = ensureAnswer(
await p.password({
message: 'Paste your Slack bot token',
clearOnError: true,
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Token is required';
if (!t.startsWith('xoxb-')) return 'Bot tokens start with xoxb-';
if (t.length < 24) return "That's shorter than a real Slack bot token";
return undefined;
},
}),
);
const token = (answer as string).trim();
setupLog.userInput(
'slack_bot_token',
`${token.slice(0, 10)}${token.slice(-4)}`,
);
return token;
}
async function collectSigningSecret(): Promise<string> {
const existing = readEnvKey('SLACK_SIGNING_SECRET');
if (existing && /^[a-f0-9]{16,}$/i.test(existing)) {
const reuse = ensureAnswer(await p.confirm({
message: 'Found an existing Slack signing secret. Use it?',
initialValue: true,
}));
if (reuse) {
setupLog.userInput('slack_signing_secret', 'reused-existing');
return existing;
}
}
const answer = ensureAnswer(
await p.password({
message: 'Paste your Slack signing secret',
clearOnError: true,
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Signing secret is required';
// Slack signing secrets are 32-char hex strings, but newer apps
// sometimes emit longer variants — leniently require hex only.
if (!/^[a-f0-9]{16,}$/i.test(t)) {
return 'Signing secrets are a string of hex characters';
}
return undefined;
},
}),
);
const secret = (answer as string).trim();
setupLog.userInput(
'slack_signing_secret',
`${secret.slice(0, 4)}${secret.slice(-4)}`,
);
return secret;
}
async function collectAppToken(): Promise<string> {
const existing = readEnvKey('SLACK_APP_TOKEN');
if (existing && existing.startsWith('xapp-') && existing.length >= 24) {
const reuse = ensureAnswer(await p.confirm({
message: `Found an existing Slack app-level token (${existing.slice(0, 10)}…). Use it?`,
initialValue: true,
}));
if (reuse) {
setupLog.userInput('slack_app_token', 'reused-existing');
return existing;
}
}
const answer = ensureAnswer(
await p.password({
message: 'Paste your Slack app-level token (Socket Mode)',
clearOnError: true,
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'App-level token is required for Socket Mode';
if (!t.startsWith('xapp-')) return 'App-level tokens start with xapp-';
if (t.length < 24) return "That's shorter than a real Slack app-level token";
return undefined;
},
}),
);
const token = (answer as string).trim();
setupLog.userInput(
'slack_app_token',
`${token.slice(0, 10)}${token.slice(-4)}`,
);
return token;
}
async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
const s = p.spinner();
const start = Date.now();
s.start('Checking your bot token…');
try {
const res = await fetch(`${SLACK_API}/auth.test`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
});
const data = (await res.json()) as {
ok?: boolean;
team?: string;
team_id?: string;
user?: string;
user_id?: string;
error?: string;
};
if (data.ok && data.team && data.user) {
s.stop(
`Connected to ${data.team} as @${data.user}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`,
);
const info: WorkspaceInfo = {
teamName: data.team,
teamId: data.team_id ?? '',
botName: data.user,
botUserId: data.user_id ?? '',
};
setupLog.step('slack-validate', 'success', Date.now() - start, {
BOT_NAME: info.botName,
BOT_USER_ID: info.botUserId,
TEAM_NAME: info.teamName,
TEAM_ID: info.teamId,
});
return info;
}
const reason = data.error ?? `HTTP ${res.status}`;
s.stop(`Slack didn't accept that token: ${reason}`, 1);
setupLog.step('slack-validate', 'failed', Date.now() - start, {
ERROR: reason,
});
await fail(
'slack-validate',
"Slack didn't accept that token.",
reason === 'invalid_auth' || reason === 'token_revoked'
? 'Copy the token again from OAuth & Permissions and retry setup.'
: `Slack said "${reason}". Check the token scopes and workspace install, then retry.`,
);
} catch (err) {
s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('slack-validate', 'failed', Date.now() - start, {
ERROR: message,
});
await fail(
'slack-validate',
"Couldn't reach Slack.",
'Check your internet connection and retry setup.',
);
}
}
async function collectSlackUserId(): Promise<string> {
note(
[
"To get your Slack member ID:",
'',
' 1. In Slack, click your profile picture (bottom left)',
' 2. Click "Profile"',
' 3. Click the three dots (⋮) → "Copy member ID"',
].join('\n'),
'Find your Slack user ID',
);
const answer = ensureAnswer(
await p.text({
message: 'Paste your Slack member ID',
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Member ID is required';
if (!/^U[A-Z0-9]{8,}$/.test(t)) {
return "That doesn't look like a Slack member ID (starts with U)";
}
return undefined;
},
}),
);
const id = (answer as string).trim();
setupLog.userInput('slack_user_id', id);
return id;
}
async function openDmChannel(token: string, userId: string): Promise<string> {
const s = p.spinner();
const start = Date.now();
s.start('Opening a DM channel…');
try {
const res = await fetch(`${SLACK_API}/conversations.open`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ users: userId }),
});
const data = (await res.json()) as {
ok?: boolean;
channel?: { id?: string };
error?: string;
};
if (data.ok && data.channel?.id) {
s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('slack-open-dm', 'success', Date.now() - start, {
DM_CHANNEL_ID: data.channel.id,
});
return data.channel.id;
}
const reason = data.error ?? `HTTP ${res.status}`;
s.stop(`Couldn't open a DM channel: ${reason}`, 1);
setupLog.step('slack-open-dm', 'failed', Date.now() - start, {
ERROR: reason,
});
if (reason === 'missing_scope') {
await fail(
'slack-open-dm',
"Your Slack app is missing the im:write scope.",
'Go to OAuth & Permissions in your Slack app settings, add the im:write scope, reinstall the app, then retry setup.',
);
}
await fail(
'slack-open-dm',
"Couldn't open a DM channel with you.",
`Slack said "${reason}". Check the member ID and app permissions, then retry.`,
);
} catch (err) {
s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('slack-open-dm', 'failed', Date.now() - start, {
ERROR: message,
});
await fail(
'slack-open-dm',
"Couldn't reach Slack.",
'Check your internet connection and retry setup.',
);
}
}
async function resolveAgentName(): Promise<string> {
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
if (preset) {
setupLog.userInput('agent_name', preset);
return preset;
}
const answer = ensureAnswer(
await p.text({
message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
);
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
setupLog.userInput('agent_name', value);
return value;
}
function showPostInstallChecklist(info: WorkspaceInfo, mode: SlackMode): void {
if (mode === 'socket') {
note(
wrapForGutter(
[
`Your agent is wired to Slack and a welcome DM is on its way.`,
`Socket Mode is on — ${info.teamName} reaches NanoClaw over an outbound`,
`WebSocket, so there's no public URL to configure.`,
'',
' • Just DM @' + info.botName + ' from Slack — replies flow straight away.',
'',
' • Keep the NanoClaw host running to hold the socket open —',
' Slack does not retry delivery while it is down.',
].join('\n'),
6,
),
'Finish setting up Slack',
);
return;
}
note(
wrapForGutter(
[
`Your agent is wired to Slack and a welcome DM is on its way.`,
`To receive replies, Slack needs a public URL for delivering events:`,
'',
' 1. Expose NanoClaw\'s webhook server (port 3000) via ngrok,',
' Cloudflare Tunnel, or a reverse proxy on a VPS.',
'',
' 2. In your Slack app → Event Subscriptions:',
' • Toggle "Enable Events" on',
` • Request URL: https://<your-public-host>/webhook/slack`,
' • Subscribe to bot events: message.channels, message.groups,',
' message.im, app_mention',
' • Save Changes',
'',
' 3. In your Slack app → Interactivity & Shortcuts:',
' • Toggle "Interactivity" on',
` • Request URL: https://<your-public-host>/webhook/slack`,
' • Save Changes',
'',
' 4. Slack will prompt you to reinstall the app — do it to apply',
' the new settings',
].join('\n'),
6,
),
'Finish setting up Slack',
);
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Tiny CLI wrapper around `buildTeamsAppPackage` so the add-teams SKILL.md can
* generate the sideload app package with a single declarative step instead of an
* inline `tsx -e` blob. The whole zip-building logic (manifest + icons, no
* external image deps) already lives in `setup/lib/teams-manifest.ts`; this just
* maps a couple of CLI flags onto it and prints the resulting zip path.
*
* Mirrors the bespoke `stepGenerateManifest` in the old setup/channels/teams.ts:
* the short name comes from NANOCLAW_AGENT_NAME (falling back to "NanoClaw"), the
* description is "<name> personal assistant powered by NanoClaw.", the website
* URL is the operator's public base URL, and the output lands in data/teams/.
*
* Usage:
* pnpm exec tsx setup/channels/teams-manifest-build.ts \
* --app-id <azure-app-id> --url https://your-domain [--out data/teams]
*/
import { buildTeamsAppPackage } from '../lib/teams-manifest.js';
function flag(name: string): string | undefined {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 ? process.argv[i + 1] : undefined;
}
const appId = flag('app-id');
const url = flag('url');
const outDir = flag('out') ?? 'data/teams';
const shortName = process.env.NANOCLAW_AGENT_NAME?.trim() || 'NanoClaw';
if (!appId || !url) {
console.error(
'usage: teams-manifest-build.ts --app-id <azure-app-id> --url <https-url> [--out <dir>]',
);
process.exit(2);
}
const result = buildTeamsAppPackage({
appId,
shortName,
longDescription: `${shortName} personal assistant powered by NanoClaw.`,
websiteUrl: url,
outDir,
});
console.log(`Teams app package: ${result.zipPath}`);
-753
View File
@@ -1,753 +0,0 @@
/**
* Microsoft Teams channel flow for setup:auto.
*
* Teams is the most complex channel NanoClaw supports the Slack/Discord
* "paste a token" shortcut doesn't exist. The operator has to walk through
* ~7 Azure portal steps (app registration, client secret, Azure Bot
* resource, messaging endpoint, Teams channel enable, manifest, sideload).
*
* This driver's job is to make each of those steps as guided as possible
* inside the terminal:
* 1. Print a clack note with the exact sub-steps and the portal URL.
* 2. Ask for the value(s) that step yields (App ID, secret, tenant, etc.).
* 3. At every step boundary, offer `stepGate` a Done / Stuck / Show-again
* select. "Stuck" hands off to interactive Claude with full context.
*
* Text/password prompts also accept `?` as an answer to trigger the handoff,
* so the operator can escape at any paste point without scrolling back to a
* step boundary.
*
* What's deferred (known limitation, instruct user how to finish manually):
* - Wait-for-first-DM to capture the auto-generated Teams platformId.
* Unlike Discord/Telegram, the Teams platform_id is only discoverable
* after the first inbound activity. The driver installs the adapter and
* stops there; the operator DMs the bot, NanoClaw auto-creates the
* messaging group, and they wire an agent via `/manage-channels`.
*/
import os from 'os';
import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
import { brightSelect } from '../lib/bright-select.js';
import { confirmThenOpen } from '../lib/browser.js';
import {
isHelpEscape,
offerClaudeHandoff,
validateWithHelpEscape,
type HandoffContext,
} from '../lib/claude-handoff.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { buildTeamsAppPackage } from '../lib/teams-manifest.js';
import { note } from '../lib/theme.js';
import * as setupLog from '../logs.js';
import { readEnvKey } from '../environment.js';
const CHANNEL = 'teams';
const MANIFEST_DIR = path.join(process.cwd(), 'data', 'teams');
const AZURE_PORTAL_URL = 'https://portal.azure.com';
interface Collected {
publicUrl?: string;
appId?: string;
tenantId?: string;
appType?: 'SingleTenant' | 'MultiTenant';
appPassword?: string;
agentName?: string;
}
export async function runTeamsChannel(_displayName: string): Promise<ChannelFlowResult> {
const collected: Collected = {};
const completed: string[] = [];
const existingAppId = readEnvKey('TEAMS_APP_ID');
const existingPassword = readEnvKey('TEAMS_APP_PASSWORD');
if (existingAppId && existingPassword) {
const choice = ensureAnswer(await brightSelect<'yes' | 'no' | 'back'>({
message: `Found existing Teams credentials (App ID: ${existingAppId.slice(0, 8)}…). Use them?`,
options: [
{ value: 'yes', label: 'Yes, use the existing credentials' },
{ value: 'no', label: "No, set up new ones" },
{ value: 'back', label: '← Back to channel selection' },
],
initialValue: 'yes',
}));
if (choice === 'back') return BACK_TO_CHANNEL_SELECTION;
if (choice === 'yes') {
collected.appId = existingAppId;
collected.appPassword = existingPassword;
collected.appType = (readEnvKey('TEAMS_APP_TYPE') as 'SingleTenant' | 'MultiTenant') || 'MultiTenant';
if (collected.appType === 'SingleTenant') {
collected.tenantId = readEnvKey('TEAMS_APP_TENANT_ID') ?? undefined;
}
setupLog.userInput('teams_credentials', 'reused-existing');
await installAdapter(collected);
completed.push('Adapter installed and service restarted (reused existing credentials).');
await finishWithHandoff(collected, completed);
return;
}
}
printIntro();
const prereqsResult = await confirmPrereqs({ collected, completed });
if (prereqsResult === 'back') return BACK_TO_CHANNEL_SELECTION;
await stepPublicUrl({ collected, completed });
if (await stepAppRegistration({ collected, completed }) === 'back') {
return BACK_TO_CHANNEL_SELECTION;
}
if (await stepClientSecret({ collected, completed }) === 'back') {
return BACK_TO_CHANNEL_SELECTION;
}
if (await stepAzureBot({ collected, completed }) === 'back') {
return BACK_TO_CHANNEL_SELECTION;
}
if (await stepEnableTeamsChannel({ collected, completed }) === 'back') {
return BACK_TO_CHANNEL_SELECTION;
}
const manifestResult = await stepGenerateManifest({ collected, completed });
if (
await stepSideload({ collected, completed, zipPath: manifestResult.zipPath })
=== 'back'
) {
return BACK_TO_CHANNEL_SELECTION;
}
await installAdapter(collected);
completed.push('Adapter installed and service restarted.');
await finishWithHandoff(collected, completed);
}
// ─── step: intro / prereqs ──────────────────────────────────────────────
function printIntro(): void {
note(
[
'Setting up Teams is more involved than the other channels — about',
'7 steps across the Azure portal and Teams admin.',
'',
k.dim("At any prompt you can type '?' and press Enter to hand off"),
k.dim("to Claude interactive mode with your current progress."),
k.dim("You can also pick 'Stuck' at any Done/Stuck/Show-again prompt."),
].join('\n'),
'Microsoft Teams setup',
);
}
async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise<'continue' | 'back'> {
note(
[
'Before we start, confirm you have:',
'',
' • A Microsoft 365 tenant where you can sideload custom apps',
' (free personal Teams does NOT support this — you need a',
' Microsoft 365 Business / EDU / developer tenant)',
' • Teams admin or developer tenant rights',
' • A way to expose an HTTPS endpoint from this machine',
' (ngrok, Cloudflare Tunnel, or a reverse-proxied VPS)',
].join('\n'),
'Prereqs',
);
// Back-aware variant of stepGate — Back is only offered on the very first
// step of the Teams flow so users can bail out before any state is taken.
while (true) {
const choice = ensureAnswer(
await brightSelect<'done' | 'help' | 'reshow' | 'back'>({
message: 'How did that go?',
options: [
{ value: 'done', label: "Done — let's continue" },
{ value: 'help', label: 'Stuck — hand me off to Claude' },
{ value: 'reshow', label: 'Show me the steps again' },
{ value: 'back', label: '← Back to channel selection' },
],
}),
);
if (choice === 'back') return 'back';
if (choice === 'done') break;
if (choice === 'help') {
await offerHandoff({
step: 'teams-prereqs',
stepDescription: 'confirming they have the right Microsoft 365 tenant and tunnel',
args,
});
continue;
}
if (choice === 'reshow') {
return confirmPrereqs(args);
}
}
args.completed.push('Prereqs confirmed.');
return 'continue';
}
// ─── step: public URL ──────────────────────────────────────────────────
async function stepPublicUrl(args: { collected: Collected; completed: string[] }): Promise<void> {
note(
[
"Azure Bot Service delivers messages to an HTTPS endpoint you",
"control. The endpoint needs to reach this machine's webhook",
"server at /api/webhooks/teams.",
'',
k.dim('Examples:'),
k.dim(' ngrok http 3000 → https://abcd1234.ngrok.io'),
k.dim(' cloudflared tunnel … → https://<tunnel>.trycloudflare.com'),
k.dim(' or a reverse proxy on your own domain'),
'',
"If you don't have a tunnel running yet, start one in another",
"terminal, then come back here.",
].join('\n'),
'Public HTTPS URL',
);
while (true) {
const answer = ensureAnswer(
await p.text({
message: 'Paste your public base URL (e.g. https://abcd1234.ngrok.io)',
placeholder: 'https://…',
validate: validateWithHelpEscape((v) => {
const t = (v ?? '').trim();
if (!t) return 'Required';
if (!/^https:\/\/[^\s/]+/.test(t)) {
return 'Must be an https:// URL (Azure rejects http)';
}
return undefined;
}),
}),
);
if (isHelpEscape(answer)) {
await offerHandoff({
step: 'teams-public-url',
stepDescription:
'setting up a public HTTPS tunnel to reach this machine on port 3000',
args,
});
continue;
}
const url = (answer as string).trim().replace(/\/$/, '');
args.collected.publicUrl = url;
setupLog.userInput('teams_public_url', url);
break;
}
args.completed.push(`Public URL: ${args.collected.publicUrl}`);
}
// ─── step: Azure App Registration ──────────────────────────────────────
async function stepAppRegistration(args: {
collected: Collected;
completed: string[];
}): Promise<'continue' | 'back'> {
note(
[
`1. In ${AZURE_PORTAL_URL}, search "App registrations" → "New registration"`,
'2. Name it (e.g. "NanoClaw")',
'3. Supported account types: Single tenant (your org only) OR',
' Multi tenant (any Microsoft 365 tenant can add the bot)',
'4. Click Register',
'5. On the Overview page, copy:',
' • Application (client) ID',
' • Directory (tenant) ID',
].join('\n'),
'Step 1 of 6 — Create Azure App Registration',
);
await confirmThenOpen(
AZURE_PORTAL_URL,
'Press Enter to open the Azure portal',
);
args.collected.appType = await askAppType(args);
args.collected.appId = await askUuid(
'Paste the Application (client) ID',
'teams-app-id',
args,
);
if (args.collected.appType === 'SingleTenant') {
args.collected.tenantId = await askUuid(
'Paste the Directory (tenant) ID',
'teams-tenant-id',
args,
);
}
const gate = await stepGate({
stepName: 'teams-app-registration',
stepDescription: 'registering an app in Azure and collecting App ID + tenant type',
reshow: () => stepAppRegistration(args),
args,
});
if (gate === 'back') return 'back';
args.completed.push(
`App registered: ${args.collected.appId} (${args.collected.appType})`,
);
return 'continue';
}
async function askAppType(args: {
collected: Collected;
completed: string[];
}): Promise<'SingleTenant' | 'MultiTenant'> {
while (true) {
const choice = ensureAnswer(
await brightSelect({
message: 'Which account type did you pick?',
options: [
{
value: 'SingleTenant',
label: 'Single tenant',
hint: 'your org only — most common for self-host',
},
{
value: 'MultiTenant',
label: 'Multi tenant',
hint: 'any Microsoft 365 tenant can install the bot',
},
{ value: 'help', label: 'Stuck — hand me off to Claude' },
],
}),
);
if (choice === 'help') {
await offerHandoff({
step: 'teams-app-type',
stepDescription: "deciding between Single tenant and Multi tenant for their Azure app",
args,
});
continue;
}
return choice as 'SingleTenant' | 'MultiTenant';
}
}
// ─── step: client secret ───────────────────────────────────────────────
async function stepClientSecret(args: {
collected: Collected;
completed: string[];
}): Promise<'continue' | 'back'> {
note(
[
`1. In your app registration, open "Certificates & secrets"`,
'2. Click "New client secret"',
' Description: nanoclaw',
' Expires: 180 days (recommended) or longer',
'3. Click Add',
'4. ' + k.yellow('COPY THE VALUE NOW — Azure only shows it once'),
' (the Value column, not the Secret ID)',
].join('\n'),
'Step 2 of 6 — Create a client secret',
);
while (true) {
const answer = ensureAnswer(
await p.password({
message: 'Paste the client secret Value',
clearOnError: true,
validate: validateWithHelpEscape((v) => {
const t = (v ?? '').trim();
if (!t) return 'Required';
if (t.length < 20) return "That looks too short — make sure you copied the Value, not the Secret ID";
return undefined;
}),
}),
);
if (isHelpEscape(answer)) {
await offerHandoff({
step: 'teams-client-secret',
stepDescription: 'creating and copying the client secret value from Azure',
args,
});
continue;
}
args.collected.appPassword = (answer as string).trim();
setupLog.userInput(
'teams_client_secret',
`${args.collected.appPassword.slice(0, 4)}${args.collected.appPassword.slice(-4)}`,
);
break;
}
const gate = await stepGate({
stepName: 'teams-client-secret',
stepDescription: 'creating and copying the client secret',
reshow: () => stepClientSecret(args),
args,
});
if (gate === 'back') return 'back';
args.completed.push('Client secret captured.');
return 'continue';
}
// ─── step: Azure Bot resource ──────────────────────────────────────────
async function stepAzureBot(args: {
collected: Collected;
completed: string[];
}): Promise<'continue' | 'back'> {
const endpoint = `${args.collected.publicUrl}/api/webhooks/teams`;
const tenantFlag =
args.collected.appType === 'SingleTenant'
? `--tenant-id ${args.collected.tenantId} `
: '';
const cliCommand =
`az bot create \\\n` +
` --resource-group nanoclaw-rg \\\n` +
` --name nanoclaw-bot \\\n` +
` --app-type ${args.collected.appType} \\\n` +
` --appid ${args.collected.appId} \\\n` +
` ${tenantFlag}--endpoint "${endpoint}"`;
note(
[
`In ${AZURE_PORTAL_URL}, search "Azure Bot" → Create.`,
'',
' • Bot handle: unique name, e.g. nanoclaw-bot',
` • Type of App: ${args.collected.appType}`,
' • Creation type: Use existing app registration',
` • App ID: ${args.collected.appId ?? '<pending>'}`,
...(args.collected.appType === 'SingleTenant'
? [` • App tenant ID: ${args.collected.tenantId ?? '<pending>'}`]
: []),
'',
'After creating, open the bot → Configuration and set:',
` Messaging endpoint: ${k.cyan(endpoint)}`,
'',
k.dim('Or via Azure CLI (if you have az installed):'),
k.dim(cliCommand),
].join('\n'),
'Step 3 of 6 — Create Azure Bot resource',
);
const gate = await stepGate({
stepName: 'teams-azure-bot',
stepDescription:
'creating an Azure Bot resource linked to the app registration and setting the messaging endpoint',
reshow: () => stepAzureBot(args),
args,
});
if (gate === 'back') return 'back';
args.completed.push('Azure Bot created; messaging endpoint configured.');
return 'continue';
}
// ─── step: enable Teams channel ────────────────────────────────────────
async function stepEnableTeamsChannel(args: {
collected: Collected;
completed: string[];
}): Promise<'continue' | 'back'> {
note(
[
'1. Open your Azure Bot resource → Channels',
'2. Click Microsoft Teams → Accept terms → Apply',
'',
k.dim('CLI alternative:'),
k.dim(' az bot msteams create --resource-group nanoclaw-rg --name nanoclaw-bot'),
].join('\n'),
'Step 4 of 6 — Enable Teams channel on the bot',
);
const gate = await stepGate({
stepName: 'teams-enable-channel',
stepDescription: 'enabling the Microsoft Teams channel on the Azure Bot resource',
reshow: () => stepEnableTeamsChannel(args),
args,
});
if (gate === 'back') return 'back';
args.completed.push('Teams channel enabled on the bot.');
return 'continue';
}
// ─── step: manifest zip ────────────────────────────────────────────────
async function stepGenerateManifest(args: {
collected: Collected;
completed: string[];
}): Promise<{ zipPath: string }> {
if (!args.collected.appId) {
fail(
'teams-manifest',
'Missing Azure App ID.',
"That's an internal bug — open an issue or retry setup.",
);
}
const shortName =
process.env.NANOCLAW_AGENT_NAME?.trim() || 'NanoClaw';
const s = p.spinner();
s.start('Generating your Teams app package…');
try {
const result = buildTeamsAppPackage({
appId: args.collected.appId!,
shortName,
longDescription: `${shortName} personal assistant powered by NanoClaw.`,
websiteUrl: args.collected.publicUrl!,
outDir: MANIFEST_DIR,
});
s.stop(`Package ready: ${k.cyan(shortPath(result.zipPath))}`);
setupLog.step('teams-manifest', 'success', 0, {
ZIP: result.zipPath,
});
args.completed.push(`Generated manifest zip at ${shortPath(result.zipPath)}.`);
return { zipPath: result.zipPath };
} catch (err) {
s.stop("Couldn't build the manifest zip.", 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('teams-manifest', 'failed', 0, { ERROR: message });
fail(
'teams-manifest',
"Couldn't generate the Teams app package.",
'Make sure `zip` is available on your PATH, then retry.',
);
}
}
// ─── step: sideload ────────────────────────────────────────────────────
async function stepSideload(args: {
collected: Collected;
completed: string[];
zipPath: string;
}): Promise<'continue' | 'back'> {
note(
[
'1. Open Microsoft Teams',
'2. Go to Apps → Manage your apps → Upload an app',
'3. Click "Upload a custom app" (or "Upload for me or my teams")',
`4. Select: ${k.cyan(args.zipPath)}`,
'5. Click Add',
'',
k.dim('If "Upload a custom app" is missing, your tenant admin has'),
k.dim('disabled sideloading. Enable it in Teams Admin Center →'),
k.dim('Teams apps → Setup policies → Global → Upload custom apps = On'),
].join('\n'),
'Step 5 of 6 — Sideload the app into Teams',
);
const gate = await stepGate({
stepName: 'teams-sideload',
stepDescription: 'uploading the generated zip into Teams as a custom app',
reshow: () => stepSideload({ ...args, zipPath: args.zipPath }),
args,
});
if (gate === 'back') return 'back';
args.completed.push('App sideloaded into Teams.');
return 'continue';
}
// ─── step: install adapter ─────────────────────────────────────────────
async function installAdapter(collected: Collected): Promise<void> {
const env: Record<string, string> = {
TEAMS_APP_ID: collected.appId!,
TEAMS_APP_PASSWORD: collected.appPassword!,
TEAMS_APP_TYPE: collected.appType!,
};
if (collected.appType === 'SingleTenant') {
env.TEAMS_APP_TENANT_ID = collected.tenantId!;
}
const install = await runQuietChild(
'teams-install',
'bash',
['setup/add-teams.sh'],
{
running: 'Installing the Teams adapter and restarting the service…',
done: 'Teams adapter installed.',
},
{
env,
extraFields: {
APP_ID: collected.appId!,
APP_TYPE: collected.appType!,
},
},
);
if (!install.ok) {
fail(
'teams-install',
"Couldn't install the Teams adapter.",
'See logs/setup-steps/ for details, then retry setup.',
);
}
}
// ─── post-install: hand off to Claude for the final wiring ────────────
async function finishWithHandoff(
collected: Collected,
completed: string[],
): Promise<void> {
note(
[
'The Teams adapter is live and the service is running.',
'',
"One thing left: your Teams bot's platform ID (which NanoClaw needs",
'to wire to an agent group) only becomes known after you DM the bot',
'for the first time. Claude can walk you through that interactively —',
'watch the logs for your first inbound, find the auto-created',
'messaging group in the DB, run scripts/init-first-agent.ts with',
'the right flags, and verify end-to-end.',
].join('\n'),
'Step 6 of 6 — Finish wiring',
);
const choice = ensureAnswer(
await brightSelect({
message: 'Ready to finish?',
options: [
{
value: 'handoff',
label: 'Hand me off to Claude to walk me through it',
hint: 'recommended',
},
{ value: 'self', label: "I'll do it myself" },
],
}),
);
if (choice === 'self') {
note(
[
' 1. Find your bot in Teams (search by name, or via the sideloaded',
' app) and send it a message ("hi" is fine)',
' 2. Tail ' + k.cyan('logs/nanoclaw.log') + ' for the inbound; the router',
' auto-creates a row in ' + k.cyan('messaging_groups') + ' in data/v2.db',
' 3. Run ' + k.cyan('scripts/init-first-agent.ts') + ' with --channel teams,',
' the discovered platform_id, and your AAD user id, OR use',
' ' + k.cyan('/manage-channels') + ' to wire interactively',
].join('\n'),
'Manual finish',
);
return;
}
await offerClaudeHandoff({
channel: CHANNEL,
step: 'teams-finish-wiring',
stepDescription:
'finishing the Teams wiring: watch for the first inbound, discover the auto-created messaging group in data/v2.db, and run scripts/init-first-agent.ts to wire it to an agent group',
completedSteps: completed,
collectedValues: redactCollected(collected),
files: [
'scripts/init-first-agent.ts',
'src/router.ts',
'src/db/messaging-groups.ts',
'logs/nanoclaw.log',
'.claude/skills/manage-channels/SKILL.md',
],
});
}
// ─── shared step gate ──────────────────────────────────────────────────
async function stepGate(args: {
stepName: string;
stepDescription: string;
reshow: () => Promise<'continue' | 'back'>;
args: { collected: Collected; completed: string[] };
}): Promise<'continue' | 'back'> {
while (true) {
const choice = ensureAnswer(
await brightSelect({
message: 'How did that go?',
options: [
{ value: 'done', label: "Done — let's continue" },
{ value: 'help', label: 'Stuck — hand me off to Claude' },
{ value: 'reshow', label: 'Show me the steps again' },
{ value: 'back', label: '← Back to channel selection' },
],
}),
);
if (choice === 'done') return 'continue';
if (choice === 'back') return 'back';
if (choice === 'help') {
await offerHandoff({
step: args.stepName,
stepDescription: args.stepDescription,
args: args.args,
});
continue;
}
if (choice === 'reshow') {
return args.reshow();
}
}
}
async function offerHandoff(args: {
step: string;
stepDescription: string;
args: { collected: Collected; completed: string[] };
}): Promise<void> {
const ctx: HandoffContext = {
channel: CHANNEL,
step: args.step,
stepDescription: args.stepDescription,
completedSteps: args.args.completed.slice(),
collectedValues: redactCollected(args.args.collected),
files: ['setup/channels/teams.ts', 'setup/add-teams.sh'],
};
await offerClaudeHandoff(ctx);
}
function redactCollected(c: Collected): Record<string, string> {
const out: Record<string, string> = {};
if (c.publicUrl) out.publicUrl = c.publicUrl;
if (c.appId) out.appId = c.appId;
if (c.tenantId) out.tenantId = c.tenantId;
if (c.appType) out.appType = c.appType;
if (c.appPassword) {
out.appPassword = `${c.appPassword.slice(0, 4)}${c.appPassword.slice(-4)}`;
}
return out;
}
// ─── shared: UUID paste with help escape ───────────────────────────────
async function askUuid(
message: string,
logKey: string,
args: { collected: Collected; completed: string[] },
): Promise<string> {
while (true) {
const answer = ensureAnswer(
await p.text({
message,
placeholder: '00000000-0000-0000-0000-000000000000',
validate: validateWithHelpEscape((v) => {
const t = (v ?? '').trim();
if (!t) return 'Required';
if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(t)) {
return 'Expected a UUID like 00000000-0000-0000-0000-000000000000';
}
return undefined;
}),
}),
);
if (isHelpEscape(answer)) {
await offerHandoff({
step: logKey,
stepDescription: `entering a UUID for ${logKey}`,
args,
});
continue;
}
const value = (answer as string).trim().toLowerCase();
setupLog.userInput(logKey, value);
return value;
}
}
// ─── path helpers ──────────────────────────────────────────────────────
function shortPath(abs: string): string {
const home = os.homedir();
const cwd = process.cwd();
if (abs.startsWith(`${cwd}/`)) return abs.slice(cwd.length + 1);
if (abs.startsWith(`${home}/`)) return `~/${abs.slice(home.length + 1)}`;
return abs;
}
-361
View File
@@ -1,361 +0,0 @@
/**
* Telegram channel flow for setup:auto.
*
* `runTelegramChannel(displayName)` owns the full branch from the
* BotFather instructions through the welcome DM:
*
* 1. BotFather instructions (clack note)
* 2. Paste the bot token (clack password) format-validated
* 3. getMe via the Bot API to resolve the bot's username
* 4. Confirm + deep-link into the bot's Telegram chat (tg://resolve)
* 5. Install the adapter (setup/add-telegram.sh, non-interactive)
* 6. Run the pair-telegram step, rendering code events as clack notes
* 7. Ask for the messaging-agent name (defaulting to "Nano")
* 8. Wire the agent via scripts/init-first-agent.ts
*
* All output obeys the three-level contract: clack UI for the user,
* structured entries in logs/setup.log, full raw output in per-step files
* under logs/setup-steps/. See docs/setup-flow.md.
*/
import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { isHeadless } from '../platform.js';
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
import { confirmThenOpen, formatNoteLink, openUrl } from '../lib/browser.js';
import { brightSelect } from '../lib/bright-select.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import {
type Block,
type StepResult,
dumpTranscriptOnFailure,
ensureAnswer,
fail,
runQuietChild,
spawnStep,
writeStepEntry,
} from '../lib/runner.js';
import { readEnvKey } from '../environment.js';
import { accentGreen, brandBold, fitToWidth, fmtDuration, note } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano';
export async function runTelegramChannel(displayName: string): Promise<ChannelFlowResult> {
const tokenOrBack = await collectTelegramToken();
if (tokenOrBack === 'back') return BACK_TO_CHANNEL_SELECTION;
const token = tokenOrBack;
const botUsername = await validateTelegramToken(token);
// Deep-link the user into the bot's chat so they're on the right screen
// by the time pair-telegram prints the code. https://t.me/<bot> works
// everywhere: browsers show an "Open in Telegram" button when the app is
// installed, or the bot's web profile if not. tg://resolve?domain= is
// more direct but silently fails when the scheme isn't registered.
const botUrl = `https://t.me/${botUsername}`;
// Two card variants — auto-open fires only on GUI, so headless users
// need full self-serve instructions inside the card itself, while GUI
// users get a leaner status line plus the auto-open + a single
// combined dim fallback line (URL + mobile alternative) on the
// confirm prompt below.
if (isHeadless()) {
note(
[
`Open @${botUsername} in Telegram now — the pairing code is coming next, and that's where you'll send it.`,
'',
`Get started: ${botUrl}`,
'',
`Don't have Telegram installed here? Open it on any device and search for @${botUsername}`,
].join('\n'),
'Open Telegram',
);
} else {
note(
`Opening @${botUsername} in Telegram so it's ready when the pairing code shows up.`,
'Open Telegram',
);
ensureAnswer(
await p.confirm({
message: `Press Enter to open Telegram (must be installed here)\n${k.dim(
`If browser does not appear, please visit: ${botUrl} — or search for @${botUsername} in Telegram`,
)}`,
initialValue: true,
}),
);
openUrl(botUrl);
}
const install = await runQuietChild(
'telegram-install',
'bash',
['setup/add-telegram.sh'],
{
running: `Connecting Telegram to @${botUsername}`,
done: 'Telegram connected.',
},
{
env: { TELEGRAM_BOT_TOKEN: token },
extraFields: { BOT_USERNAME: botUsername },
},
);
if (!install.ok) {
await fail(
'telegram-install',
"Couldn't connect Telegram.",
'See logs/setup-steps/ for details, then retry setup.',
);
}
const pair = await runPairTelegram();
if (!pair.ok) {
await fail(
'pair-telegram',
"Couldn't pair with Telegram.",
'Re-run setup to try again.',
);
}
const platformId = pair.terminal?.fields.PLATFORM_ID;
const pairedUserId = pair.terminal?.fields.PAIRED_USER_ID;
if (!platformId || !pairedUserId) {
await fail(
'pair-telegram',
'Pairing completed but came back incomplete.',
'Re-run setup to try again.',
);
}
const role = await askOperatorRole('Telegram');
setupLog.userInput('telegram_role', role);
const agentName = await resolveAgentName();
const init = await runQuietChild(
'init-first-agent',
'pnpm',
[
'exec', 'tsx', 'scripts/init-first-agent.ts',
'--channel', 'telegram',
'--user-id', pairedUserId,
'--platform-id', platformId,
'--display-name', displayName,
'--agent-name', agentName,
'--role', role,
],
{
running: `Connecting ${agentName} to your Telegram chat…`,
done: `${agentName} is ready. Check Telegram for a welcome message.`,
},
{
extraFields: { CHANNEL: 'telegram', AGENT_NAME: agentName, PLATFORM_ID: platformId },
},
);
if (!init.ok) {
await fail(
'init-first-agent',
`Couldn't finish connecting ${agentName}.`,
'You can retry later with `/manage-channels`.',
);
}
}
async function collectTelegramToken(): Promise<string | 'back'> {
const existing = readEnvKey('TELEGRAM_BOT_TOKEN');
if (existing && /^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(existing)) {
const choice = ensureAnswer(await brightSelect<'yes' | 'no' | 'back'>({
message: `Found an existing Telegram bot token (${existing.slice(0, 8)}…). Use it?`,
options: [
{ value: 'yes', label: 'Yes, use the existing token' },
{ value: 'no', label: 'No, paste a new one' },
{ value: 'back', label: '← Back to channel selection' },
],
initialValue: 'yes',
}));
if (choice === 'back') return 'back';
if (choice === 'yes') {
setupLog.userInput('telegram_token', 'reused-existing');
return existing;
}
// 'no' falls through to the paste flow below
}
note(
[
"Your assistant talks to you through a Telegram bot you create.",
"Here's how:",
'',
" 1. Open Telegram and message @BotFather — Telegram's official bot for creating and managing bots",
' 2. Send /newbot and follow the prompts',
' 3. Copy the token it gives you (it looks like <digits>:<chars>)',
'',
k.dim('Planning to add your assistant to group chats? In @BotFather:'),
k.dim(' /mybots → your bot → Bot Settings → Group Privacy → OFF'),
].join('\n'),
'Set up your Telegram bot',
);
// Back-aware gate before the password prompt — `p.password` doesn't
// accept extra options, so we offer Back as a separate brightSelect
// immediately after the BotFather instructions and before the paste.
const proceed = ensureAnswer(await brightSelect<'continue' | 'back'>({
message: 'Ready to paste your bot token?',
options: [
{ value: 'continue', label: 'Yes, paste it on the next prompt' },
{ value: 'back', label: '← Back to channel selection' },
],
initialValue: 'continue',
}));
if (proceed === 'back') return 'back';
const answer = ensureAnswer(
await p.password({
message: 'Paste your bot token',
clearOnError: true,
validate: (v) => {
if (!v || !v.trim()) return "Token is required";
if (!/^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(v.trim())) {
return "That doesn't look right. It should be <digits>:<chars>";
}
return undefined;
},
}),
);
const token = (answer as string).trim();
setupLog.userInput(
'telegram_token',
`${token.slice(0, 12)}${token.slice(-4)}`,
);
return token;
}
async function validateTelegramToken(token: string): Promise<string> {
const s = p.spinner();
const start = Date.now();
s.start('Checking your bot token…');
try {
const res = await fetch(`https://api.telegram.org/bot${token}/getMe`);
const data = (await res.json()) as {
ok?: boolean;
result?: { username?: string; id?: number };
description?: string;
};
if (data.ok && data.result?.username) {
const username = data.result.username;
s.stop(`Found your bot: @${username}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('telegram-validate', 'success', Date.now() - start, {
BOT_USERNAME: username,
BOT_ID: data.result.id ?? '',
});
return username;
}
const reason = data.description ?? 'token rejected by Telegram';
s.stop(`Telegram didn't accept that token: ${reason}`, 1);
setupLog.step('telegram-validate', 'failed', Date.now() - start, {
ERROR: reason,
});
await fail(
'telegram-validate',
"Telegram didn't accept that token.",
'Copy the token again from @BotFather and try setup once more.',
);
} catch (err) {
s.stop(`Couldn't reach Telegram. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('telegram-validate', 'failed', Date.now() - start, {
ERROR: message,
});
await fail(
'telegram-validate',
"Couldn't reach Telegram.",
'Check your internet connection and retry setup.',
);
}
}
async function runPairTelegram(): Promise<
StepResult & { rawLog: string; durationMs: number }
> {
const rawLog = setupLog.stepRawLog('pair-telegram');
const start = Date.now();
const s = p.spinner();
s.start('Generating a secret code for your bot…');
let spinnerActive = true;
const stopSpinner = (msg: string, code?: number) => {
if (spinnerActive) {
s.stop(msg, code);
spinnerActive = false;
}
};
const result = await spawnStep(
'pair-telegram',
['--intent', 'main'],
(block: Block) => {
if (block.type === 'PAIR_TELEGRAM_CODE') {
const reason = block.fields.REASON ?? 'initial';
if (reason === 'initial') {
stopSpinner('Your secret code is ready.');
} else {
stopSpinner("Old code expired. Here's a fresh one.");
}
note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code');
s.start(fitToWidth('Waiting for you to send the code from Telegram…', ''));
spinnerActive = true;
} else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') {
stopSpinner(`Got "${block.fields.CANDIDATE ?? '?'}", not a match.`);
s.start(fitToWidth('Waiting for the correct code…', ''));
spinnerActive = true;
} else if (block.type === 'PAIR_TELEGRAM') {
if (block.fields.STATUS === 'success') {
stopSpinner('Telegram paired.');
} else {
stopSpinner(`Pairing failed: ${block.fields.ERROR ?? 'unknown'}`, 1);
}
}
},
rawLog,
);
const durationMs = Date.now() - start;
// Safety net: if the child died without emitting a terminal block, make
// sure we don't leave the spinner running.
if (spinnerActive) {
stopSpinner(
result.ok ? 'Done.' : 'Pairing ended unexpectedly.',
result.ok ? 0 : 1,
);
if (!result.ok) dumpTranscriptOnFailure(result.transcript);
}
writeStepEntry('pair-telegram', result, durationMs, rawLog);
return { ...result, rawLog, durationMs };
}
function formatCodeCard(code: string): string {
const spaced = code.split('').join(' ');
return [
'',
` ${brandBold(spaced)}`,
'',
k.dim(' Send this code to your bot from Telegram.'),
].join('\n');
}
async function resolveAgentName(): Promise<string> {
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
if (preset) {
setupLog.userInput('agent_name', preset);
return preset;
}
const answer = ensureAnswer(
await p.text({
message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
);
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
setupLog.userInput('agent_name', value);
return value;
}
-478
View File
@@ -1,478 +0,0 @@
/**
* WhatsApp (community/Baileys) channel flow for setup:auto.
*
* `runWhatsAppChannel(displayName)` owns the full branch from auth-method
* picker through the welcome DM:
*
* 1. Ask how to authenticate (QR code in terminal, default, or pairing code)
* 2. If pairing-code: collect the phone number
* 3. Install the adapter + Baileys + QR + pino via setup/add-whatsapp.sh
* 4. Run the whatsapp-auth step, rendering status blocks as clack UI:
* - WHATSAPP_AUTH_QR (repeating): render the QR as terminal block art
* inside a clack note. On rotation we clear the previous QR in-place
* via ANSI escapes so the terminal doesn't fill up with stale codes.
* - WHATSAPP_AUTH_PAIRING_CODE (one-shot): centred code card.
* 5. Read store/auth/creds.json extract the authenticated (bot) phone
* 6. Kick the service so the adapter picks up the new credentials
* 7. Ask the operator for the phone they'll chat from (defaults to the
* authed number). Different number dedicated mode also writes
* ASSISTANT_HAS_OWN_NUMBER=true so outbound replies aren't prefixed
* 8. Ask for the messaging-agent name (defaulting to "Nano")
* 9. Wire the agent via scripts/init-first-agent.ts; the existing welcome
* DM path delivers the greeting through the adapter
*
* All output obeys the three-level contract: clack UI for the user, structured
* entries in logs/setup.log, full raw output in per-step files under
* logs/setup-steps/. See docs/setup-flow.md.
*/
import { spawnSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
import { brightSelect } from '../lib/bright-select.js';
import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js';
import {
type Block,
type StepResult,
dumpTranscriptOnFailure,
ensureAnswer,
fail,
runQuietChild,
spawnStep,
writeStepEntry,
} from '../lib/runner.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { accentGreen, brandBody, brandBold, fmtDuration, note } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano';
const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json');
type AuthMethod = 'qr' | 'pairing-code';
export async function runWhatsAppChannel(displayName: string): Promise<ChannelFlowResult> {
const method = await askAuthMethod();
if (method === 'back') return BACK_TO_CHANNEL_SELECTION;
const phone = method === 'pairing-code' ? await askPhoneNumber() : undefined;
const install = await runQuietChild(
'whatsapp-install',
'bash',
['setup/add-whatsapp.sh'],
{
running: 'Installing the WhatsApp adapter…',
done: 'WhatsApp adapter installed.',
skipped: 'WhatsApp adapter already installed.',
},
);
if (!install.ok) {
fail(
'whatsapp-install',
"Couldn't install the WhatsApp adapter.",
'See logs/setup-steps/ for details, then retry setup.',
);
}
const auth = await runWhatsAppAuth(method, phone);
if (!auth.ok) {
const reason = auth.terminal?.fields.ERROR ?? 'unknown';
fail(
'whatsapp-auth',
`WhatsApp authentication failed (${reason}).`,
reason === 'qr_timeout' || reason === 'timeout'
? 'The code expired. Re-run setup to get a fresh one.'
: 'Re-run setup to try again.',
);
}
const botPhone = readAuthedPhone();
if (!botPhone) {
fail(
'whatsapp-auth',
"Authenticated but couldn't read your WhatsApp number from the saved credentials.",
'Re-run setup to try again.',
);
}
await restartService();
const chatPhone = await askChatPhone(botPhone);
const isDedicated = chatPhone !== botPhone;
if (isDedicated) {
writeAssistantHasOwnNumber();
}
const role = await askOperatorRole('WhatsApp');
setupLog.userInput('whatsapp_role', role);
const agentName = await resolveAgentName();
const platformId = `${chatPhone}@s.whatsapp.net`;
const init = await runQuietChild(
'init-first-agent',
'pnpm',
[
'exec', 'tsx', 'scripts/init-first-agent.ts',
'--channel', 'whatsapp',
'--user-id', platformId,
'--platform-id', platformId,
'--display-name', displayName,
'--agent-name', agentName,
'--role', role,
],
{
running: `Connecting ${agentName} to WhatsApp…`,
done: isDedicated
? `${agentName} is ready. Check WhatsApp for a welcome message.`
: `${agentName} is ready. Look in your "You" chat on WhatsApp for the welcome.`,
},
{
extraFields: {
CHANNEL: 'whatsapp',
AGENT_NAME: agentName,
PLATFORM_ID: platformId,
MODE: isDedicated ? 'dedicated' : 'shared',
ROLE: role,
},
},
);
if (!init.ok) {
fail(
'init-first-agent',
`Couldn't finish connecting ${agentName}.`,
'You can retry later with `/manage-channels`.',
);
}
}
async function askAuthMethod(): Promise<AuthMethod | 'back'> {
const choice = ensureAnswer(
await brightSelect({
message: 'How would you like to authenticate with WhatsApp?',
options: [
{
value: 'qr',
label: 'Scan a QR code in this terminal',
hint: 'recommended',
},
{
value: 'pairing-code',
label: 'Enter a pairing code on your phone',
hint: 'no camera needed',
},
{
value: 'back',
label: '← Back to channel selection',
},
],
}),
) as AuthMethod | 'back';
if (choice !== 'back') setupLog.userInput('whatsapp_auth_method', choice);
return choice;
}
async function askPhoneNumber(): Promise<string> {
note(
[
"Enter your phone number the way WhatsApp expects it:",
'',
' • Digits only — no +, spaces, or dashes',
' • Country code first, then the rest of the number',
'',
k.dim('Example: 14155551234 (country code 1, then 4155551234)'),
].join('\n'),
'Your phone number',
);
const answer = ensureAnswer(
await p.text({
message: 'Phone number',
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Phone number is required';
if (!/^\d{8,15}$/.test(t)) {
return "That doesn't look right. Digits only, country code included.";
}
return undefined;
},
}),
);
const phone = (answer as string).trim();
setupLog.userInput('whatsapp_phone', phone);
return phone;
}
async function runWhatsAppAuth(
method: AuthMethod,
phone: string | undefined,
): Promise<StepResult & { rawLog: string; durationMs: number }> {
const rawLog = setupLog.stepRawLog('whatsapp-auth');
const start = Date.now();
const s = p.spinner();
s.start('Starting WhatsApp authentication…');
let spinnerActive = true;
const stopSpinner = (msg: string, code?: number) => {
if (spinnerActive) {
s.stop(msg, code);
spinnerActive = false;
}
};
// Tracks the QR render so we can overwrite it in-place on rotation. null
// before the first QR is printed.
let qrLinesPrinted = 0;
const extra =
method === 'pairing-code' && phone
? ['--method', 'pairing-code', '--phone', phone]
: ['--method', 'qr'];
const result = await spawnStep(
'whatsapp-auth',
extra,
(block: Block) => {
if (block.type === 'WHATSAPP_AUTH_QR') {
const qr = block.fields.QR ?? '';
if (!qr) return;
// Fire-and-forget — await inside spawnStep's sync onBlock is fine
// since spawnStep's own logic keeps running in parallel.
void renderQr(qr).then((lines) => {
if (qrLinesPrinted === 0) {
stopSpinner('QR code ready — scan with WhatsApp.');
} else {
// Cursor up N lines + clear from there to end of screen. Wipes
// the previous QR + caption so the new one renders in place.
process.stdout.write(`\x1b[${qrLinesPrinted}A\x1b[0J`);
}
process.stdout.write(lines.join('\n') + '\n');
qrLinesPrinted = lines.length;
});
} else if (block.type === 'WHATSAPP_AUTH_PAIRING_CODE') {
const code = block.fields.CODE ?? '????';
stopSpinner('Your pairing code is ready.');
note(formatPairingCard(code), 'Pairing code');
s.start('Waiting for you to enter the code…');
spinnerActive = true;
} else if (block.type === 'WHATSAPP_AUTH') {
const status = block.fields.STATUS;
if (status === 'skipped') {
stopSpinner('WhatsApp is already authenticated.');
} else if (status === 'success') {
// Erase the QR block if one was on screen — it's served its purpose.
if (qrLinesPrinted > 0) {
process.stdout.write(`\x1b[${qrLinesPrinted}A\x1b[0J`);
qrLinesPrinted = 0;
}
// In QR flow the spinner was stopped when the first QR landed.
// Fall back to a plain success line so the user sees confirmation.
if (spinnerActive) {
stopSpinner('WhatsApp linked.');
} else {
p.log.success(brandBody('WhatsApp linked.'));
}
} else if (status === 'failed') {
if (qrLinesPrinted > 0) {
process.stdout.write(`\x1b[${qrLinesPrinted}A\x1b[0J`);
qrLinesPrinted = 0;
}
const err = block.fields.ERROR ?? 'unknown';
if (spinnerActive) {
stopSpinner(`Authentication failed: ${err}`, 1);
} else {
p.log.error(`Authentication failed: ${err}`);
}
}
}
},
rawLog,
);
const durationMs = Date.now() - start;
// Safety net — if the step died without emitting a terminal block, don't
// leave the spinner running.
if (spinnerActive) {
stopSpinner(
result.ok ? 'Done.' : 'Authentication ended unexpectedly.',
result.ok ? 0 : 1,
);
if (!result.ok) dumpTranscriptOnFailure(result.transcript);
}
writeStepEntry('whatsapp-auth', result, durationMs, rawLog);
return { ...result, rawLog, durationMs };
}
/**
* Render the raw QR string to an array of terminal lines (block-art QR +
* a caption). Returned as an array so the caller can count lines for the
* in-place rewrite on rotation. Uses the small-mode QR to keep the height
* manageable on 24-row terminals.
*/
async function renderQr(qr: string): Promise<string[]> {
try {
const QRCode = await import('qrcode');
const qrText = await QRCode.toString(qr, { type: 'terminal', small: true });
const caption = k.dim(
' Open WhatsApp → You / Settings → Linked Devices → Link a Device → scan.',
);
return [...qrText.trimEnd().split('\n'), '', caption];
} catch {
return ['QR code (raw): ' + qr];
}
}
function formatPairingCard(code: string): string {
// WhatsApp pairing codes are 8 characters; render with two-wide gap so the
// digits read clearly in the terminal.
const spaced = code.split('').join(' ');
return [
'',
` ${brandBold(spaced)}`,
'',
k.dim(' Open WhatsApp → You / Settings → Linked Devices → Link a Device'),
k.dim(' → "Link with phone number instead" → enter this code.'),
k.dim(' It expires in ~60 seconds.'),
].join('\n');
}
/**
* Pull the authenticated WhatsApp phone out of store/auth/creds.json.
* `creds.me.id` looks like `14155551234:<device>@s.whatsapp.net` we want
* just the leading digit run.
*/
function readAuthedPhone(): string {
try {
const raw = fs.readFileSync(AUTH_CREDS_PATH, 'utf-8');
const creds = JSON.parse(raw) as { me?: { id?: string } };
const id = creds.me?.id;
if (!id) return '';
return id.split(':')[0].split('@')[0];
} catch {
return '';
}
}
async function restartService(): Promise<void> {
const s = p.spinner();
s.start('Restarting NanoClaw so it sees your WhatsApp credentials…');
const start = Date.now();
const platform = process.platform;
try {
if (platform === 'darwin') {
spawnSync(
'launchctl',
['kickstart', '-k', `gui/${process.getuid?.() ?? 501}/${getLaunchdLabel()}`],
{ stdio: 'ignore' },
);
} else if (platform === 'linux') {
const unit = getSystemdUnit();
const user = spawnSync(
'systemctl',
['--user', 'restart', unit],
{ stdio: 'ignore' },
);
if (user.status !== 0) {
spawnSync('sudo', ['systemctl', 'restart', unit], {
stdio: 'ignore',
});
}
}
// Give the adapter a moment to reconnect before init-first-agent's
// welcome DM hits the delivery path.
await new Promise((r) => setTimeout(r, 5000));
s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('whatsapp-restart', 'success', Date.now() - start, {
PLATFORM: platform,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
s.stop(`Restart may have failed: ${message}`, 1);
setupLog.step('whatsapp-restart', 'failed', Date.now() - start, {
ERROR: message,
});
// Non-fatal — the user can restart manually if init-first-agent fails.
}
}
async function askChatPhone(authedPhone: string): Promise<string> {
note(
[
`Authenticated with ${k.cyan('+' + authedPhone)}.`,
'',
"What's the phone number you'll chat with your agent from?",
'',
k.dim(
'Same number = messages will land in your "You" / self-chat on WhatsApp\n' +
"(you won't be able to reply to yourself — use a different number for a\n" +
'two-way chat).',
),
].join('\n'),
'Your chat number',
);
const answer = ensureAnswer(
await p.text({
message: 'Your personal phone number',
placeholder: authedPhone,
defaultValue: authedPhone,
validate: (v) => {
const t = (v ?? authedPhone).trim();
if (!/^\d{8,15}$/.test(t)) {
return 'Digits only, country code included.';
}
return undefined;
},
}),
);
const phone = ((answer as string) || authedPhone).trim();
setupLog.userInput('whatsapp_chat_phone', phone);
return phone;
}
/** Persist ASSISTANT_HAS_OWN_NUMBER=true to .env and data/env/env. */
function writeAssistantHasOwnNumber(): void {
const envPath = path.join(process.cwd(), '.env');
let contents = '';
try {
contents = fs.readFileSync(envPath, 'utf-8');
} catch {
contents = '';
}
if (/^ASSISTANT_HAS_OWN_NUMBER=/m.test(contents)) {
contents = contents.replace(
/^ASSISTANT_HAS_OWN_NUMBER=.*$/m,
'ASSISTANT_HAS_OWN_NUMBER=true',
);
} else {
if (contents.length > 0 && !contents.endsWith('\n')) contents += '\n';
contents += 'ASSISTANT_HAS_OWN_NUMBER=true\n';
}
fs.writeFileSync(envPath, contents);
// Container reads from data/env/env.
const containerEnvDir = path.join(process.cwd(), 'data', 'env');
fs.mkdirSync(containerEnvDir, { recursive: true });
fs.copyFileSync(envPath, path.join(containerEnvDir, 'env'));
}
async function resolveAgentName(): Promise<string> {
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
if (preset) {
setupLog.userInput('agent_name', preset);
return preset;
}
const answer = ensureAnswer(
await p.text({
message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
);
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
setupLog.userInput('agent_name', value);
return value;
}
+2
View File
@@ -25,6 +25,8 @@ const STEPS: Record<
auth: () => import('./auth.js'),
'provider-auth': () => import('./provider-auth.js'),
'cli-agent': () => import('./cli-agent.js'),
// >>> nanoclaw:setup-steps
// <<< nanoclaw:setup-steps
};
async function main(): Promise<void> {
-46
View File
@@ -1,46 +0,0 @@
#!/usr/bin/env bash
# Setup helper: install-discord — bundles the preflight + install commands
# from the /add-discord skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the Discord adapter in from the `channels` branch; appends the
# self-registration import; installs the pinned @chat-adapter/discord package;
# builds. All steps are safe to re-run.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
echo "=== NANOCLAW SETUP: INSTALL_DISCORD ==="
needs_install=false
[[ -f src/channels/discord.ts ]] || needs_install=true
grep -q "import './discord.js';" src/channels/index.ts || needs_install=true
grep -q '"@chat-adapter/discord"' package.json || needs_install=true
[[ -d node_modules/@chat-adapter/discord ]] || needs_install=true
if ! $needs_install; then
echo "STATUS: already-installed"
echo "=== END ==="
exit 0
fi
echo "STEP: fetch-channels-branch"
git fetch origin channels
echo "STEP: copy-files"
git show origin/channels:src/channels/discord.ts > src/channels/discord.ts
echo "STEP: register-import"
if ! grep -q "import './discord.js';" src/channels/index.ts; then
printf "import './discord.js';\n" >> src/channels/index.ts
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/discord@4.29.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.29.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.29.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.29.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.29.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.29.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 ==="
+28
View File
@@ -12,6 +12,34 @@
* (subsequent steps may have side effects like opening browsers, hitting
* APIs, or installing adapter packages, none of which are easily undone).
*/
import { brightSelect } from './bright-select.js';
import { ensureAnswer } from './runner.js';
export const BACK_TO_CHANNEL_SELECTION = Symbol('BACK_TO_CHANNEL_SELECTION');
export type ChannelFlowResult = void | typeof BACK_TO_CHANNEL_SELECTION;
/**
* The shared first-prompt back gate. Rendered as the very first interactive
* prompt of a channel sub-flow (before any side effect), it lets the operator
* either commit to connecting `label` or bounce straight back to the channel
* chooser. Returns the existing `BACK_TO_CHANNEL_SELECTION` sentinel on back
* which the `setup/auto.ts` channel loop already catches and `'continue'`
* otherwise. Esc / Ctrl-C unwinds through `ensureAnswer` (exit 0), the same as
* every other setup prompt.
*/
export async function backGate(
label: string,
): Promise<'continue' | typeof BACK_TO_CHANNEL_SELECTION> {
const choice = ensureAnswer(
await brightSelect<'continue' | 'back'>({
message: `Connect ${label}?`,
initialValue: 'continue',
options: [
{ value: 'continue', label: `Yes, connect ${label}` },
{ value: 'back', label: '← Back to channel selection' },
],
}),
);
return choice === 'back' ? BACK_TO_CHANNEL_SELECTION : 'continue';
}
+5 -1
View File
@@ -20,10 +20,14 @@ resolve_channels_remote() {
return 0
fi
# Anchor the repo-name tail: the pattern must match the nanoclaw repo itself,
# never a sibling channel repo like qwibitai/nanoclaw-discord (which carries
# no channels branch — an unanchored `*nanoclaw*` glob picked those on
# multi-remote machines and broke every from-branch copy).
local remote url
while IFS=$'\t' read -r remote url; do
case "$url" in
*qwibitai/nanoclaw*|*nanocoai/nanoclaw*)
*qwibitai/nanoclaw|*qwibitai/nanoclaw.git|*nanocoai/nanoclaw|*nanocoai/nanoclaw.git)
printf '%s' "$remote"
return 0
;;
+5 -5
View File
@@ -65,15 +65,15 @@ export const STEP_FILES: Record<string, string[]> = {
channel: ['setup/auto.ts'],
verify: ['setup/verify.ts'],
// Channel-specific sub-steps:
'telegram-install': ['setup/add-telegram.sh', 'setup/channels/telegram.ts'],
'telegram-install': ['.claude/skills/add-telegram/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/telegram.ts'],
'telegram-validate': ['setup/channels/telegram.ts'],
'pair-telegram': ['setup/pair-telegram.ts', 'setup/channels/telegram.ts'],
'discord-install': ['setup/add-discord.sh', 'setup/channels/discord.ts'],
'slack-install': ['setup/add-slack.sh', 'setup/channels/slack.ts'],
'discord-install': ['.claude/skills/add-discord/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/discord.ts'],
'slack-install': ['.claude/skills/add-slack/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/slack.ts'],
'slack-validate': ['setup/channels/slack.ts'],
'imessage-install': ['setup/add-imessage.sh', 'setup/channels/imessage.ts'],
'imessage-install': ['.claude/skills/add-imessage/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/imessage.ts'],
'imessage': ['setup/channels/imessage.ts'],
'teams-install': ['setup/add-teams.sh', 'setup/channels/teams.ts'],
'teams-install': ['.claude/skills/add-teams/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/teams.ts'],
'teams-manifest': ['setup/lib/teams-manifest.ts', 'setup/channels/teams.ts'],
'init-first-agent': [
'scripts/init-first-agent.ts',
+2 -1
View File
@@ -222,8 +222,9 @@ function buildHandoffPrompt(ctx: HandoffContext): string {
...(ctx.files ?? []),
'logs/setup.log',
'logs/setup-steps/',
// The bespoke `setup/channels/<channel>.ts` flows are deleted — point the
// agent at the channel's SKILL.md, which now owns the whole procedure.
`.claude/skills/add-${ctx.channel}/SKILL.md`,
`setup/channels/${ctx.channel}.ts`,
].filter((v, i, a) => a.indexOf(v) === i);
lines.push('Relevant files (read as needed with the Read tool):');
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Restart the NanoClaw service, then wait (best-effort) for its `ncl` CLI socket
# so a following wiring directive doesn't race the restart. Channel skills call
# this as `nc:run effect:restart`. Best-effort throughout: a fresh setup may not
# have the service installed yet, and the wiring's own `ncl` call is the real
# signal if the socket never appears — so a wait timeout does not fail the step.
set -u
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root="$(cd "$here/../.." && pwd)"
# shellcheck source=/dev/null
source "$here/install-slug.sh"
case "$(uname -s)" in
Darwin) launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" 2>/dev/null || true ;;
Linux) systemctl --user restart "$(systemd_unit)" 2>/dev/null \
|| sudo systemctl restart "$(systemd_unit)" 2>/dev/null || true ;;
esac
# Wait up to ~30s for the CLI socket so `ncl` can connect on the next directive.
for _ in $(seq 1 60); do
[ -S "$root/data/ncl.sock" ] && exit 0
sleep 0.5
done
echo "nanoclaw: ncl socket not up yet after restart — the wiring step may need a retry" >&2
exit 0
+44 -20
View File
@@ -299,12 +299,21 @@ export function summariseTerminalFields(block: Block | null): Record<string, str
return out;
}
async function runUnderSpinner<
T extends { ok: boolean; transcript: string; terminal?: Block | null },
>(
labels: SpinnerLabels,
work: () => Promise<T>,
): Promise<T> {
/**
* A live clack spinner with an elapsed-time ticker the brand-styled,
* width-fit rendering primitive behind `runUnderSpinner`, exposed so a caller
* that doesn't have a single `work()` promise to await can drive it itself.
* The apply engine (scripts/skill-apply.ts) fires stepStart/stepEnd *events*
* around each step, so the skill driver's per-step reporter starts the spinner
* on stepStart and calls the returned `.stop(...)` on stepEnd.
*
* `stop({ ok, skipped, transcript })` clears the ticker and renders the
* done/skipped/failed headline (bold) with the elapsed time (dim); on a failure
* it also dumps the transcript tail when one is supplied.
*/
export function startSpinner(labels: SpinnerLabels): {
stop: (outcome: { ok: boolean; skipped?: boolean; transcript?: string }) => void;
} {
const s = p.spinner();
const start = Date.now();
s.start(fitToWidth(labels.running, ' (99m 59s)'));
@@ -312,22 +321,37 @@ async function runUnderSpinner<
const suffix = ` (${fmtDuration(Date.now() - start)})`;
s.message(`${fitToWidth(labels.running, suffix)}${k.dim(suffix)}`);
}, 1000);
return {
stop({ ok, skipped, transcript }) {
clearInterval(tick);
const suffix = ` (${fmtDuration(Date.now() - start)})`;
if (ok) {
const msg = skipped && labels.skipped ? labels.skipped : labels.done;
// Bold the outcome so the step's headline reads stronger than the prose
// body copy around it. The trailing `(Ns)` timing stays dim.
s.stop(`${k.bold(fitToWidth(msg, suffix))}${k.dim(suffix)}`);
} else {
const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed');
s.stop(`${k.bold(fitToWidth(failMsg, suffix))}${k.dim(suffix)}`, 1);
if (transcript) dumpTranscriptOnFailure(transcript);
}
},
};
}
async function runUnderSpinner<
T extends { ok: boolean; transcript: string; terminal?: Block | null },
>(
labels: SpinnerLabels,
work: () => Promise<T>,
): Promise<T> {
const spinner = startSpinner(labels);
const result = await work();
clearInterval(tick);
const suffix = ` (${fmtDuration(Date.now() - start)})`;
if (result.ok) {
const isSkipped = result.terminal?.fields.STATUS === 'skipped';
const msg = isSkipped && labels.skipped ? labels.skipped : labels.done;
// Bold the outcome so the step's headline reads stronger than the prose
// body copy around it. The trailing `(Ns)` timing stays dim.
s.stop(`${k.bold(fitToWidth(msg, suffix))}${k.dim(suffix)}`);
} else {
const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed');
s.stop(`${k.bold(fitToWidth(failMsg, suffix))}${k.dim(suffix)}`, 1);
dumpTranscriptOnFailure(result.transcript);
}
spinner.stop({
ok: result.ok,
skipped: result.terminal?.fields.STATUS === 'skipped',
transcript: result.transcript,
});
return result;
}
+443
View File
@@ -0,0 +1,443 @@
import { describe, it, expect, vi } from 'vitest';
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runSkill, hostExec, hostExecStream, labelOrdinals, promptValidator, clackResolveInput, type RunSkillOptions } from './skill-driver.js';
import { fullyApplied, type ApplyEvent } from '../../scripts/skill-apply.js';
// Shared test state for the clack + claude-handoff mocks (hoisted so the vi.mock
// factories — which run before imports — can close over it). `answers` is the
// queue each mocked text/password prompt pops from; `handoffSpy` stands in for
// the interactive Claude handoff; `lastValidate` captures the validate callback
// the resolveInput impl handed clack so we can prove the `?` help-escape is wired.
const ce = vi.hoisted(() => ({
handoffSpy: vi.fn(async (_ctx: { channel: string; step: string; stepDescription: string }) => true),
answers: [] as string[],
lastValidate: { fn: undefined as undefined | ((v: string) => string | Error | void | undefined) },
}));
// Keep isHelpEscape + validateWithHelpEscape real (clackResolveInput uses them);
// only the interactive handoff is replaced with a spy so the test never spawns Claude.
vi.mock('./claude-handoff.js', async (importActual) => {
const actual = await importActual<typeof import('./claude-handoff.js')>();
return { ...actual, offerClaudeHandoff: ce.handoffSpy };
});
// Drive clackResolveInput's prompts from the `answers` queue and record the
// validate callback it passes through, instead of opening a real TTY prompt.
vi.mock('@clack/prompts', async (importActual) => {
const actual = await importActual<typeof import('@clack/prompts')>();
const fromQueue = async (o: { validate?: (v: string) => string | Error | void | undefined }): Promise<string> => {
ce.lastValidate.fn = o?.validate;
return ce.answers.shift() ?? '';
};
return { ...actual, text: vi.fn(fromQueue), password: vi.fn(fromQueue) };
});
// A small SKILL.md exercising the three things the driver wires: an operator
// block (emitted as an operator event), a secret prompt (collected via
// resolveInput), and a wire run (executed via exec) consuming the captured input.
const SKILL = `# driver demo
## Set up
Tell the user:
\`\`\`nc:operator
Go create the app and copy the token.
\`\`\`
\`\`\`nc:prompt token secret
Paste the token.
\`\`\`
## Wire
\`\`\`nc:run effect:wire
ncl wire --token {{token}}
\`\`\`
`;
function scratch(): { root: string; skill: string } {
const root = mkdtempSync(join(tmpdir(), 'driver-'));
const skill = mkdtempSync(join(tmpdir(), 'driver-skill-'));
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
writeFileSync(join(root, '.env'), '');
writeFileSync(join(skill, 'SKILL.md'), SKILL);
return { root, skill };
}
describe('thin skill driver', () => {
it('resolves prompts via resolveInput, emits operator events, and execs wiring', async () => {
const { root, skill } = scratch();
const asked: Array<{ name: string; secret: boolean }> = [];
const told: string[] = [];
const ran: string[] = [];
const opts: RunSkillOptions = {
projectRoot: root,
resolveInput: async (name, meta) => {
asked.push({ name, secret: meta.secret });
return 'T0KEN';
},
// An injected onEvent REPLACES the default policy handler (the injector owns its I/O).
onEvent: (e: ApplyEvent) => {
if (e.type === 'operator') told.push(e.text);
},
exec: (c) => void ran.push(c),
};
const res = await runSkill(skill, opts);
expect(asked).toEqual([{ name: 'token', secret: true }]); // the prompt was driven through resolveInput, meta intact
expect(told).toEqual(['Go create the app and copy the token.']); // operator relayed through the event
expect(ran).toContain('ncl wire --token T0KEN'); // wiring executed with the answer substituted in
expect(res.operatorMessages).toEqual(['Go create the app and copy the token.']);
});
it('runs fully from inputs — resolveInput never touched', async () => {
const { root, skill } = scratch();
const ran: string[] = [];
const res = await runSkill(skill, { projectRoot: root, inputs: { token: 'FROM-INPUTS' }, exec: (c) => void ran.push(c) });
expect(fullyApplied(res)).toBe(true);
expect(ran).toContain('ncl wire --token FROM-INPUTS');
});
it('emits step events through an injected onEvent — the wire run under its heading', async () => {
const { root, skill } = scratch();
const starts: Array<{ kind: string; label: string | null }> = [];
await runSkill(skill, {
projectRoot: root,
inputs: { token: 'T' },
exec: () => {},
onEvent: (e) => {
if (e.type === 'step-start') starts.push({ kind: e.kind, label: e.label });
},
});
// the demo SKILL's only mutating step is `nc:run effect:wire` under `## Wire`
expect(starts).toEqual([{ kind: 'run', label: 'Wire' }]);
});
it('hostExec puts the project bin/ on PATH so a bare command resolves to it', () => {
const root = mkdtempSync(join(tmpdir(), 'driver-bin-'));
mkdirSync(join(root, 'bin'));
writeFileSync(join(root, 'bin/greet'), '#!/usr/bin/env bash\necho hi-from-bin\n');
chmodSync(join(root, 'bin/greet'), 0o755);
const out = hostExec(root)('greet'); // bare name, not ./bin/greet
expect(String(out).trim()).toBe('hi-from-bin');
});
it('hostExec returns stdout so a capture run can bind it', () => {
const root = mkdtempSync(join(tmpdir(), 'driver-cap-'));
expect(String(hostExec(root)('echo D0CHANNEL')).trim()).toBe('D0CHANNEL');
});
it('hostExecStream runs a step and captures the terminal status block fields (for effect:step)', async () => {
const root = mkdtempSync(join(tmpdir(), 'driver-step-'));
const out = await hostExecStream(root)(
'echo show-this-to-the-operator; echo "=== NANOCLAW SETUP: PAIR ==="; echo "STATUS: success"; echo "PLATFORM_ID: telegram:42"; echo "=== END ==="',
);
expect(out.ok).toBe(true);
expect(out.fields.PLATFORM_ID).toBe('telegram:42');
});
function reuseScratch(): { root: string; skill: string } {
const root = mkdtempSync(join(tmpdir(), 'reuse-'));
const skill = mkdtempSync(join(tmpdir(), 'reuse-skill-'));
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
writeFileSync(join(root, '.env'), 'SLACK_BOT_TOKEN=xoxb-existing-token\n');
// a skill whose env-set maps bot_token → SLACK_BOT_TOKEN (the reuse linkage)
writeFileSync(
join(skill, 'SKILL.md'),
'# reuse demo\n\n```nc:prompt bot_token secret\nPaste the token.\n```\n```nc:env-set\nSLACK_BOT_TOKEN={{bot_token}}\n```\n```nc:run effect:wire\nuse {{bot_token}}\n```\n',
);
return { root, skill };
}
it('reuse:true offers an existing .env credential via the confirm seam and skips the prompt when accepted', async () => {
const { root, skill } = reuseScratch();
const asked: string[] = [];
const cmds: string[] = [];
await runSkill(skill, {
projectRoot: root,
reuse: true,
confirm: async () => true, // yes, reuse the existing value
resolveInput: async (n) => {
asked.push(n);
return 'NEWLY-PASTED';
},
exec: (c) => void cmds.push(c),
});
expect(asked).not.toContain('bot_token'); // reused from .env → never prompted
expect(cmds).toContain('use xoxb-existing-token'); // the reused value flowed downstream
});
it('reuse: declining keeps the prompt', async () => {
const { root, skill } = reuseScratch();
const asked: string[] = [];
const cmds: string[] = [];
await runSkill(skill, {
projectRoot: root,
reuse: true,
confirm: async () => false, // no, ask me
resolveInput: async (n) => {
asked.push(n);
return 'NEWLY-PASTED';
},
exec: (c) => void cmds.push(c),
});
expect(asked).toContain('bot_token'); // declined → prompted
expect(cmds).toContain('use NEWLY-PASTED');
});
// A cred a HELPER SCRIPT owns (written by effect:external, not nc:env-set) has no
// env-set→ENV_KEY linkage to infer. An explicit `nc:prompt … reuse:<ENV_KEY>`
// restores the masked reuse offer — the imessage Photon case.
function helperReuseScratch(): { root: string; skill: string } {
const root = mkdtempSync(join(tmpdir(), 'reuse-helper-'));
const skill = mkdtempSync(join(tmpdir(), 'reuse-helper-skill-'));
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
// present in .env, but NOT written by any nc:env-set in the skill below
writeFileSync(join(root, '.env'), 'IMESSAGE_SERVER_URL=https://photon.example.com\n');
writeFileSync(
join(skill, 'SKILL.md'),
'# helper reuse demo\n\n```nc:prompt server_url validate:^https?:// reuse:IMESSAGE_SERVER_URL\nYour Photon server URL.\n```\n```nc:run effect:external\nbash configure.sh "{{server_url}}"\n```\n',
);
return { root, skill };
}
it('reuse: offers an existing .env value for a HELPER-owned cred (no env-set linkage)', async () => {
const { root, skill } = helperReuseScratch();
const asked: string[] = [];
const cmds: string[] = [];
const confirmed: string[] = [];
await runSkill(skill, {
projectRoot: root,
reuse: true,
confirm: async (msg) => {
confirmed.push(msg);
return true; // yes, reuse the existing helper-owned value
},
resolveInput: async (n) => {
asked.push(n);
return 'https://typed.example';
},
exec: (c) => void cmds.push(c),
});
expect(confirmed.some((m) => /IMESSAGE_SERVER_URL/.test(m))).toBe(true); // the reuse: link surfaced the offer
expect(asked).not.toContain('server_url'); // accepted → never re-prompted
expect(cmds).toContain('bash configure.sh "https://photon.example.com"'); // reused value flowed downstream
});
// §5.4 pre-filter: a stale .env value that would fail the prompt's declared
// validate-at-bind is NEVER offered — the operator is prompted fresh instead
// of the reused input rejecting loudly with no interactive recovery.
it('reuse: a stale .env value failing the prompt validate is never offered — prompted fresh', async () => {
const root = mkdtempSync(join(tmpdir(), 'reuse-stale-'));
const skill = mkdtempSync(join(tmpdir(), 'reuse-stale-skill-'));
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
writeFileSync(join(root, '.env'), 'SLACK_BOT_TOKEN=legacy-not-a-bot-token\n'); // fails ^xoxb-
writeFileSync(
join(skill, 'SKILL.md'),
'# stale reuse demo\n\n```nc:prompt bot_token secret validate:^xoxb-\nPaste the token.\n```\n```nc:env-set\nSLACK_BOT_TOKEN={{bot_token}}\n```\n```nc:run effect:wire\nuse {{bot_token}}\n```\n',
);
const asked: string[] = [];
const confirmed: string[] = [];
const res = await runSkill(skill, {
projectRoot: root,
reuse: true,
confirm: async (m) => {
confirmed.push(m);
return true;
},
resolveInput: async (n) => {
asked.push(n);
return 'xoxb-fresh-token';
},
exec: () => {},
});
expect(confirmed).toHaveLength(0); // the stale value was silently not offered
expect(asked).toContain('bot_token'); // prompted fresh
expect(fullyApplied(res)).toBe(true); // no §4 dead-end
});
it('reuse pre-filter mirrors bind order: normalize-then-validate, so a normalizable value still offers', async () => {
const root = mkdtempSync(join(tmpdir(), 'reuse-norm-'));
const skill = mkdtempSync(join(tmpdir(), 'reuse-norm-skill-'));
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
writeFileSync(join(root, '.env'), 'BASE_URL=https://x.example/\n'); // trailing slash — valid only after rstrip-slash
writeFileSync(
join(skill, 'SKILL.md'),
'# norm reuse demo\n\n```nc:prompt base_url normalize:rstrip-slash validate:^https://[^/]+$\nYour base URL.\n```\n```nc:env-set\nBASE_URL={{base_url}}\n```\n',
);
const confirmed: string[] = [];
const res = await runSkill(skill, {
projectRoot: root,
reuse: true,
confirm: async (m) => {
confirmed.push(m);
return true;
},
resolveInput: async () => undefined,
exec: () => {},
});
expect(confirmed.some((m) => /BASE_URL/.test(m))).toBe(true); // offered — normalized form passes
expect(fullyApplied(res)).toBe(true); // engine normalizes at bind, so it binds cleanly too
});
// The default onEvent policy handler (never injected here — injecting onEvent
// would replace the policy, §5.0): note → URL offer (confirm → openUrl) →
// natural-barrier confirm, all through the injectable confirm/openUrl seams.
it('default handler: offers the operator-body URL, then the barrier confirm — in that order', async () => {
const root = mkdtempSync(join(tmpdir(), 'offer-'));
const skill = mkdtempSync(join(tmpdir(), 'offer-skill-'));
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
writeFileSync(
join(skill, 'SKILL.md'),
'# offer demo\n\n## Portal step\nTell the user:\n```nc:operator\nOpen https://example.com/setup and finish the app.\n```\n\n## Build\n```nc:run effect:build\necho build\n```\n',
);
const confirms: string[] = [];
const opened: string[] = [];
const res = await runSkill(skill, {
projectRoot: root,
exec: () => {},
confirm: async (m) => {
confirms.push(m);
return true;
},
openUrl: async (u) => void opened.push(u),
});
expect(opened).toEqual(['https://example.com/setup']); // offered + accepted → opened
expect(confirms).toEqual([
'Open https://example.com/setup in your browser?',
"Done with the steps above? Continue when you're ready.", // run barrier, completed flavor
]);
expect(fullyApplied(res)).toBe(true);
});
it('default handler: readiness flavor before an effect:step; decline = proceed (never an abort)', async () => {
const root = mkdtempSync(join(tmpdir(), 'gate-step-'));
const skill = mkdtempSync(join(tmpdir(), 'gate-step-skill-'));
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
writeFileSync(
join(skill, 'SKILL.md'),
'# gate demo\n\n## Pair\nTell the user:\n```nc:operator\nA pairing code is about to appear.\n```\n```nc:run effect:step capture:pid=PID\npair-now\n```\n',
);
const confirms: string[] = [];
const res = await runSkill(skill, {
projectRoot: root,
exec: () => {},
execStream: async () => ({ ok: true, fields: { PID: 'p1' } }),
confirm: async (m) => {
confirms.push(m);
return false; // DECLINE everything — the barrier is a pause, not a branch
},
openUrl: async () => {},
});
expect(confirms).toEqual(['Ready? The next step starts immediately.']); // no URL in the body → only the barrier
expect(res.vars.pid).toBe('p1'); // the step still ran — decline proceeded
expect(fullyApplied(res)).toBe(true);
});
it('default handler: declining the URL offer skips the open', async () => {
const root = mkdtempSync(join(tmpdir(), 'offer-no-'));
const skill = mkdtempSync(join(tmpdir(), 'offer-no-skill-'));
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
writeFileSync(
join(skill, 'SKILL.md'),
'# offer decline demo\n\n```nc:operator\nGo to https://example.com/app and make the app.\n```\n',
);
const opened: string[] = [];
await runSkill(skill, {
projectRoot: root,
exec: () => {},
confirm: async () => false,
openUrl: async (u) => void opened.push(u),
});
expect(opened).toHaveLength(0); // declined → never opened
});
it('clackResolveInput intercepts a lone "?" → hands off to Claude with context, then re-asks', async () => {
const prevTTY = process.stdout.isTTY;
process.stdout.isTTY = true; // the `?` help-escape only fires at a real terminal
try {
ce.handoffSpy.mockClear();
ce.answers = ['?', 'real-token']; // first answer is the help-escape, second is the real value
const ans = await clackResolveInput({ channel: 'telegram', step: 'paste-token' })('token', {
question: 'Paste your token.',
secret: false,
validate: '^[0-9a-zA-Z-]+$',
});
expect(ans).toBe('real-token'); // re-asked after the handoff, returns the second answer
expect(ce.handoffSpy).toHaveBeenCalledTimes(1);
expect(ce.handoffSpy.mock.calls[0][0]).toEqual({
channel: 'telegram',
step: 'paste-token',
stepDescription: 'Paste your token.',
});
// The validate handed to clack lets `?` through (so the escape reaches us)
// but still rejects a value that fails the prompt's own regex.
expect(ce.lastValidate.fn?.('?')).toBeUndefined();
expect(ce.lastValidate.fn?.('has space')).toBeTruthy();
} finally {
process.stdout.isTTY = prevTTY;
}
});
it('clackResolveInput passes a normal answer straight through — no handoff', async () => {
ce.handoffSpy.mockClear();
ce.answers = ['just-a-token'];
const ans = await clackResolveInput({ channel: 'telegram', step: 'paste-token' })('token', {
question: 'Paste your token.',
secret: false,
});
expect(ans).toBe('just-a-token');
expect(ce.handoffSpy).not.toHaveBeenCalled();
});
it('promptValidator honors flags:i and derives its rejection message from the question prose', () => {
const question = 'Paste your public base URL (looks like `https://abcd1234.ngrok.io`).';
const ci = promptValidator('^https://', 'i', question);
expect(ci).toBeDefined();
expect(ci!('HTTPS://example.com')).toBeUndefined(); // case-insensitive match passes
// the message is the generic lead-in + the QUESTION prose — the prose
// describes the expected shape, so no authored error: string exists anymore
expect(ci!('ftp://example.com')).toBe(`That doesn't match the expected format. ${question}`);
expect(promptValidator(undefined, undefined, question)).toBeUndefined(); // no regex ⇒ no validator
});
});
// Two steps under one heading share a spinner caption (build + test both read
// "Build and validate") — the ordinal suffix marks them as a sequence, not a
// stuttered duplicate. Solo captions stay unsuffixed.
describe('labelOrdinals (repeated-caption disambiguation)', () => {
const MD = [
'# demo',
'',
'## Build and validate',
'```nc:run effect:build',
'pnpm run build',
'```',
'```nc:run effect:test',
'pnpm exec vitest run x.test.ts',
'```',
'',
'## Restart',
'```nc:run effect:restart',
'bash restart.sh',
'```',
'',
].join('\n');
it('suffixes (1/2)/(2/2) on the shared caption and leaves the solo one alone', () => {
const ords = labelOrdinals(MD);
const buildLine = 4; // opening fence of the build run (1-based)
const testLine = 7;
const restartLine = 12;
expect(ords.get(buildLine)).toBe(' (1/2)');
expect(ords.get(testLine)).toBe(' (2/2)');
expect(ords.has(restartLine)).toBe(false); // unique caption — no suffix
});
it('the real add-telegram build+test pair (the live stutter) gets ordinals', () => {
const md = readFileSync(join(process.cwd(), '.claude/skills/add-telegram/SKILL.md'), 'utf8');
const suffixes = [...labelOrdinals(md).values()];
expect(suffixes).toContain(' (1/2)');
expect(suffixes).toContain(' (2/2)');
});
});
+477
View File
@@ -0,0 +1,477 @@
/**
* The thin generic driver: render a SKILL.md's human I/O through clack and run
* the directive engine. The entire connect+wire procedure now lives in the
* SKILL.md operator walkthroughs (`nc:operator`), credential prompts
* (`nc:prompt`), the service restart (`nc:run effect:restart`), and the wiring
* (`nc:run effect:wire`, `ncl …`). So the driver is just: ask the prompts
* (`resolveInput`), render the engine's events (`onEvent` spinners for step
* events, notes + policy for operator blocks), run the engine in document order.
*
* The engine only DECLARES and EMITS (scripts/skill-apply.ts); everything
* presentational lives here, derived from document structure via the shared
* policy module (scripts/skill-policy.ts): the natural-barrier gate confirm,
* the URL offer, the prose-derived validation message.
*/
import { execSync, spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import * as p from '@clack/prompts';
import {
applySkill,
fullyApplied,
normalizeValue,
stepLabel,
type ApplyEvent,
type ApplyResult,
type InputMeta,
type StepOutcome,
} from '../../scripts/skill-apply.js';
import { parseDirectives, promptVar } from '../../scripts/skill-directives.js';
import { extractOfferUrl, gatePolicy } from '../../scripts/skill-policy.js';
import { isHeadless } from '../platform.js';
import { openUrl } from './browser.js';
import { isHelpEscape, offerClaudeHandoff, validateWithHelpEscape } from './claude-handoff.js';
import { startSpinner } from './runner.js';
/**
* Build the clack `validate` callback an `nc:prompt` carries the interactive
* enforcement of `validate:<re>` (with `flags:`). On a miss the message is
* derived from the QUESTION PROSE, which by authoring convention describes the
* expected shape ("Paste the bot token (looks like `123456:ABC-DEF...`)."), so
* there is no separate authored error string. Returns undefined when the prompt
* declares no regex. Exported so the policy is unit-testable without a TTY.
* Normalization is NOT here: it's deterministic, applied at bind by the engine
* (skill-apply `normalizeValue`), so it lands the same for `inputs` and typed
* answers.
*/
export function promptValidator(
validate: string | undefined,
flags: string | undefined,
question: string,
): ((v: string | undefined) => string | undefined) | undefined {
if (!validate) return undefined;
const re = new RegExp(validate, flags);
return (v) => (re.test((v ?? '').trim()) ? undefined : `That doesn't match the expected format. ${question}`);
}
/**
* Handoff context for the `?` help-escape (Step 8 / mechanism M3). A lone `?` at
* any prompt hands the operator to interactive Claude with this context, then
* re-asks the same prompt. Both fields are optional so a bare
* `clackResolveInput()` (e.g. the standalone CLI below) still works it just
* hands off with a generic `setup` channel and the prompt's own var name as the
* step.
*/
export interface PrompterContext {
/** Channel this run is wiring (e.g. 'telegram') — surfaced to the handoff. */
channel?: string;
/** Short label for the current setup step — surfaced to the handoff. */
step?: string;
}
/**
* The wizard's `resolveInput` implementation: collect an `nc:prompt` through
* clack (password for secrets, text otherwise; a cancel defers), running the
* interactive re-ask loop against the prompt's declared `validate:`/`flags:`
* (the engine's validate-at-bind is the programmatic backstop, not the UX).
*/
export function clackResolveInput(ctx: PrompterContext = {}): (name: string, meta: InputMeta) => Promise<string | undefined> {
// The `?` help-escape is only meaningful at a real terminal: it hands the
// operator off to an interactive Claude session (stdio inherited). In a
// headless / non-TTY run nobody can type `?` into a clack prompt anyway, and
// we must never spawn an interactive child without a TTY — so it's a no-op
// there (read at ask-time so a re-run picks up a terminal that appears later).
async function ask(name: string, meta: InputMeta): Promise<string | undefined> {
const check = promptValidator(meta.validate, meta.flags, meta.question);
// Wrap the validator so a lone `?` short-circuits format checks and comes
// back as a literal "?" instead of being rejected — we intercept it below.
const guarded = validateWithHelpEscape(check);
// clearOnError wipes a rejected secret so the operator re-pastes cleanly
// (a half-pasted token isn't left masked in the field).
const ans = meta.secret
? await p.password({ message: meta.question, validate: guarded, clearOnError: true })
: await p.text({ message: meta.question, validate: guarded });
if (p.isCancel(ans)) return undefined; // cancelled ⇒ defer
if (isHelpEscape(ans) && process.stdout.isTTY) {
// Operator asked for help: hand off to interactive Claude with this
// prompt's context, then re-ask the same prompt. Recursion is operator-
// bounded — they decide when to stop typing `?`.
await offerClaudeHandoff({
channel: ctx.channel ?? 'setup',
step: ctx.step ?? name,
stepDescription: meta.question,
});
return ask(name, meta);
}
const v = String(ans).trim();
return v.length ? v : undefined;
}
return ask;
}
/**
* The default `confirm` seam: a clack yes/no, TTY-gated exactly like the step
* spinner a non-TTY run resolves TRUE (proceed), so a headless run with full
* inputs never stalls on a barrier or an offer. A cancel counts as decline.
*/
async function defaultConfirm(message: string): Promise<boolean> {
if (!process.stdout.isTTY) return true;
const ans = await p.confirm({ message });
return ans === true; // cancel ⇒ false
}
/**
* The default `openUrl` seam: best-effort browser open (setup/lib/browser.ts).
* Headless-safe: on a machine with no display we skip the open entirely the
* URL is already in the rendered operator note for copy-paste.
*/
async function defaultOpenUrl(url: string): Promise<void> {
if (isHeadless()) return;
openUrl(url);
}
/** Mask a credential for display: first 6 + last 4. */
function maskValue(v: string): string {
return v.length <= 12 ? '••••' : `${v.slice(0, 6)}${v.slice(-4)}`;
}
/** Parse `KEY=value` lines from a .env file body. */
function parseEnv(body: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of body.split('\n')) {
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/);
if (m && m[2].trim()) out[m[1]] = m[2].trim();
}
return out;
}
/**
* Offer to reuse credentials already in `.env` so a re-run doesn't re-prompt for
* them. The prompt var ENV_KEY mapping comes from the skill's own `env-set`
* directives, so this stays generic. Returns the inputs the operator chose to
* reuse (interactive: each is confirmed via the `confirm` seam).
*
* Every offer is PRE-FILTERED through the target prompt's declared
* `normalize`/`validate`/`flags`: an `.env` value that would fail the engine's
* validate-at-bind is silently not offered, so the operator is prompted fresh
* instead of hitting a dead-end (`inputs` win outright at bind a stale
* credential passed through would reject loudly with no re-ask).
*/
async function reuseFromEnv(
skillDir: string,
projectRoot: string,
alreadyHave: Record<string, string>,
confirm: (message: string) => Promise<boolean>,
): Promise<Record<string, string>> {
let md: string;
try {
md = readFileSync(join(skillDir, 'SKILL.md'), 'utf8');
} catch {
return {};
}
const varToKey = new Map<string, string>();
const promptShape = new Map<string, { validate?: string; flags?: string; normalize?: string }>();
for (const d of parseDirectives(md)) {
// 1st pass: infer var → ENV_KEY from env-set directives (KEY={{var}}).
if (d.kind === 'env-set') {
for (const line of d.body) {
const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/);
if (m) varToKey.set(m[2], m[1]); // var → ENV_KEY
}
}
if (d.kind === 'prompt') {
const v = promptVar(d);
if (!v) continue;
// Record each prompt's declared value shape for the reuse pre-filter.
promptShape.set(v, {
validate: typeof d.attrs.validate === 'string' ? d.attrs.validate : undefined,
flags: typeof d.attrs.flags === 'string' ? d.attrs.flags : undefined,
normalize: typeof d.attrs.normalize === 'string' ? d.attrs.normalize : undefined,
});
// 2nd pass: an explicit `nc:prompt … reuse:<ENV_KEY>` links a prompt to a
// credential a HELPER SCRIPT owns — written by effect:external, not
// nc:env-set (e.g. imessage's Photon IMESSAGE_SERVER_URL /
// IMESSAGE_API_KEY). The env-set inference above can't see those, so the
// prompt states the linkage to regain the masked reuse offer on a re-run.
if (typeof d.attrs.reuse === 'string') varToKey.set(v, d.attrs.reuse);
}
}
let env: Record<string, string> = {};
try {
env = parseEnv(readFileSync(join(projectRoot, '.env'), 'utf8'));
} catch {
return {};
}
const reuse: Record<string, string> = {};
for (const [v, key] of varToKey) {
if (v in alreadyHave) continue; // caller already supplied it
const existing = env[key];
if (!existing) continue;
// Pre-filter: normalize-then-validate, mirroring the engine's bind order. A
// stale credential that no longer matches the declared shape is never
// offered — prompting fresh beats a loud validate-at-bind dead-end.
const shape = promptShape.get(v);
if (shape?.validate && !new RegExp(shape.validate, shape.flags).test(normalizeValue(existing, shape.normalize))) continue;
if (await confirm(`Found an existing ${key} (${maskValue(existing)}). Use it?`)) reuse[v] = existing;
}
return reuse;
}
/**
* Host exec for the engine's run directives. Returns stdout so a
* `run capture:<var>` can bind it. Puts the project's `bin/` on PATH so a bare
* `ncl …` in a wire directive resolves to `bin/ncl` even when it isn't
* symlinked onto the operator's PATH.
*/
export function hostExec(projectRoot: string): (cmd: string) => string {
return (cmd) =>
execSync(cmd, {
cwd: projectRoot,
shell: '/bin/bash',
encoding: 'utf8',
env: { ...process.env, PATH: `${join(projectRoot, 'bin')}:${process.env.PATH ?? ''}` },
});
}
/**
* Streaming host exec for `nc:run effect:step`. Spawns the step through a shell,
* tees its human-facing output to the operator's terminal live (so a pairing code
* card or a QR rendered by the step shows), parses the `=== NANOCLAW SETUP: TYPE
* ===` status blocks, and resolves with the terminal (last STATUS-bearing) block's
* fields so the engine can `capture:<var>=<FIELD>` them. The block protocol mirrors
* setup/lib/runner.ts's StatusStream a step is just a command that emits blocks.
*/
export function hostExecStream(projectRoot: string): (cmd: string) => Promise<StepOutcome> {
return (cmd) =>
new Promise((resolve) => {
const child = spawn('bash', ['-c', cmd], {
cwd: projectRoot,
env: { ...process.env, PATH: `${join(projectRoot, 'bin')}:${process.env.PATH ?? ''}` },
stdio: ['inherit', 'pipe', 'pipe'],
});
const blocks: Array<{ fields: Record<string, string> }> = [];
let current: { fields: Record<string, string> } | null = null;
let buf = '';
const onChunk = (chunk: Buffer): void => {
buf += chunk.toString('utf8');
let idx: number;
while ((idx = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, idx);
buf = buf.slice(idx + 1);
if (/^=== NANOCLAW SETUP: \S+ ===/.test(line)) { current = { fields: {} }; continue; }
if (line.startsWith('=== END ===')) { if (current) blocks.push(current); current = null; continue; }
if (current) {
const c = line.indexOf(':');
if (c > 0) current.fields[line.slice(0, c).trim()] = line.slice(c + 1).trim();
continue;
}
process.stdout.write(line + '\n'); // operator-facing line (a QR, a code) — show it live
}
};
child.stdout.on('data', onChunk);
child.stderr.on('data', onChunk);
child.on('close', (code) => {
const terminal = [...blocks].reverse().find((b) => b.fields.STATUS) ?? null;
const status = terminal?.fields.STATUS;
resolve({ ok: code === 0 && (status === 'success' || status === 'skipped'), fields: terminal?.fields ?? {} });
});
});
}
// The barrier-confirm wording per gate flavor (§5.1.5). Decline = proceed: the
// barrier is a PAUSE, not a branch — the result is deliberately discarded, and
// the handler never throws for a decline (an operator-event throw would bounce
// and latch the engine's `blocked` gate over later side effects).
const GATE_WORDING: Record<'readiness' | 'completed', string> = {
readiness: 'Ready? The next step starts immediately.',
completed: "Done with the steps above? Continue when you're ready.",
};
/**
* Where several steps under one heading share a spinner caption (a build and a
* test both labelled "5. Build and validate"), suffix an ordinal "(1/2)",
* "(2/2)" so consecutive spinners read as distinct steps, not a stutter.
* Keyed by directive line (the step events carry it). Counting is static
* (document order over the parsed directives); a runtime-skipped sibling can
* leave a gap in the rendered sequence cosmetic only.
*/
export function labelOrdinals(md: string): Map<number, string> {
const byLabel = new Map<string, number[]>();
for (const d of parseDirectives(md)) {
const label = stepLabel(d, md);
if (label === null) continue;
const lines = byLabel.get(label) ?? [];
lines.push(d.line);
byLabel.set(label, lines);
}
const out = new Map<number, string>();
for (const lines of byLabel.values()) {
if (lines.length < 2) continue;
lines.forEach((ln, i) => out.set(ln, ` (${i + 1}/${lines.length})`));
}
return out;
}
/**
* The driver's DEFAULT `onEvent` handler the whole wizard presentation policy:
*
* step-start/step-end a per-step clack spinner (built on runner.ts's
* `startSpinner`), TTY-gated so piped/CI/test runs stay quiet. A null label
* is the engine's instant/renders-its-own-output declaration no spinner.
* Repeated captions under one heading get an ordinal suffix (labelOrdinals).
* operator render the block as a clack note, then the URL offer
* (extractOfferUrl confirm openUrl), then the natural-barrier confirm
* when gatePolicy says this block precedes a side-effecting directive.
*
* An INJECTED `onEvent` replaces this handler entirely (the injector owns its
* I/O) which is why driver-policy tests run this default and inject the
* `confirm`/`openUrl` seams instead.
*/
function defaultOnEvent(
md: string,
confirm: (message: string) => Promise<boolean>,
open: (url: string) => Promise<void>,
): (e: ApplyEvent) => Promise<void> {
const gates = gatePolicy(md);
const ordinals = labelOrdinals(md);
let active: ReturnType<typeof startSpinner> | null = null;
return async (e) => {
if (e.type === 'step-start') {
if (!process.stdout.isTTY || e.label === null) return; // quiet: non-TTY, or instant/cheap step
const base = e.label.replace(/…+$/, '') + (ordinals.get(e.line) ?? '');
active = startSpinner({ running: `${base}`, done: base, failed: `${base} failed` });
return;
}
if (e.type === 'step-end') {
if (!active) return; // never started a spinner for this one
active.stop({ ok: e.ok });
active = null;
return;
}
// operator: note → URL offer → natural-barrier confirm.
p.note(e.text, 'Do this');
const url = extractOfferUrl(e.text);
if (url !== undefined && (await confirm(`Open ${url} in your browser?`))) await open(url);
const gate = gates.get(e.line);
// Decline = proceed (result discarded): the confirm is a pause so a manual
// UI step is finished first — never an abort, never a throw.
if (gate?.needsConfirm) await confirm(GATE_WORDING[gate.flavor]);
};
}
/** Fork-aware registry-branch remote (same resolver setup/channels/slack.ts uses). */
function channelsRemote(projectRoot: string): () => string {
return () =>
execSync('source setup/lib/channels-remote.sh; resolve_channels_remote', {
cwd: projectRoot,
shell: '/bin/bash',
encoding: 'utf8',
}).trim();
}
export interface RunSkillOptions {
projectRoot?: string;
/** Pre-supplied prompt answers — pass them all for a fully programmatic run. */
inputs?: Record<string, string>;
/**
* Resolve a prompt var the caller didn't pre-supply. Defaults to the clack
* collector (`clackResolveInput` masked secrets, validate re-ask loop, `?`
* help-escape); inject a fake for tests or a relay for a coding agent.
*/
resolveInput?: (name: string, meta: InputMeta) => Promise<string | undefined>;
/** Defaults to `hostExec`. */
exec?: (cmd: string) => string | void;
/** Defaults to `hostExecStream`. Streaming exec for `nc:run effect:step`. */
execStream?: (cmd: string) => Promise<StepOutcome>;
/** Defaults to the fork-aware channels-branch resolver. */
resolveRemote?: (branch: string) => string;
/** Run effects the caller owns (e.g. `['restart']` when it restarts once). */
skipEffects?: string[];
/** Offer to reuse credentials already in `.env` instead of re-prompting. */
reuse?: boolean;
/**
* Consumer for every engine emission (step events + operator blocks). When
* injected it REPLACES the driver's default policy handler ENTIRELY the
* spinner, the note, the URL offer, and the natural-barrier confirm are all
* the default handler's; the injector owns its I/O. To observe the default
* policy in tests, inject `confirm`/`openUrl` instead.
*/
onEvent?: (e: ApplyEvent) => void | Promise<void>;
/**
* Yes/no seam used by the reuse offer, the natural-barrier gate, and the URL
* offer. Defaults to a clack confirm, TTY-gated like the spinner a non-TTY
* run resolves true (proceed) so a headless run with full inputs never stalls.
*/
confirm?: (message: string) => Promise<boolean>;
/**
* Browser-open seam for the URL offer, attempted only after a `confirm` yes.
* Defaults to setup/lib/browser.ts `openUrl` (headless no-op).
*/
openUrl?: (url: string) => Promise<void>;
/**
* Handoff context for the `?` help-escape (Step 8 / mechanism M3), threaded
* into the default `clackResolveInput`. A lone `?` at any prompt hands the
* operator off to interactive Claude with this `channel` + `step` label, then
* re-asks. Ignored when an explicit `resolveInput` is injected (the injector
* owns its I/O).
*/
channel?: string;
step?: string;
}
/**
* Run a SKILL.md end-to-end through the directive engine with host-wired I/O.
* Returns the engine's result; `fullyApplied(res)` tells the caller whether the
* run completed or left prompts deferred / steps for an agent.
*/
export async function runSkill(skillDir: string, opts: RunSkillOptions = {}): Promise<ApplyResult> {
const projectRoot = opts.projectRoot ?? process.cwd();
const confirm = opts.confirm ?? defaultConfirm;
const open = opts.openUrl ?? defaultOpenUrl;
let inputs = opts.inputs;
// Offer to reuse credentials already in .env before the engine prompts for them.
if (opts.reuse) {
const reused = await reuseFromEnv(skillDir, projectRoot, inputs ?? {}, confirm);
if (Object.keys(reused).length) inputs = { ...inputs, ...reused };
}
// The default operator policy derives from the skill document itself
// (gatePolicy keys on directive lines) — read it once here; the engine reads
// the same file for the actual apply.
let md = '';
try {
md = readFileSync(join(skillDir, 'SKILL.md'), 'utf8');
} catch {
// missing SKILL.md — the engine will produce an empty result anyway
}
return applySkill(skillDir, projectRoot, {
inputs,
resolveInput: opts.resolveInput ?? clackResolveInput({ channel: opts.channel, step: opts.step }),
onEvent: opts.onEvent ?? defaultOnEvent(md, confirm, open),
exec: opts.exec ?? hostExec(projectRoot),
execStream: opts.execStream ?? hostExecStream(projectRoot),
resolveRemote: opts.resolveRemote ?? channelsRemote(projectRoot),
skipEffects: opts.skipEffects,
});
}
// CLI: pnpm exec tsx setup/lib/skill-driver.ts <skillDir> — apply a skill interactively.
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
void (async () => {
const skillDir = process.argv[2];
if (!skillDir) {
console.error('usage: pnpm exec tsx setup/lib/skill-driver.ts <skillDir>');
process.exit(2);
}
p.intro(`Applying ${skillDir}`);
const res = await runSkill(skillDir);
if (fullyApplied(res)) {
p.outro('Done — fully applied.');
} else {
if (res.deferred.length) p.log.warn(`No value yet for: ${res.deferred.join(', ')}`);
for (const t of res.agentTasks) p.log.warn(`Needs an agent (${t.kind}): ${t.reason}`);
p.outro('Applied with gaps — see above.');
}
})();
}
-9
View File
@@ -116,15 +116,6 @@ function main(): void {
channelsProcessed++;
}
// Sync to data/env/env
if (fs.existsSync(v2EnvPath)) {
const containerEnvDir = path.join(process.cwd(), 'data', 'env');
try {
fs.mkdirSync(containerEnvDir, { recursive: true });
fs.copyFileSync(v2EnvPath, path.join(containerEnvDir, 'env'));
} catch { /* non-fatal */ }
}
console.log(`OK:channels=${channelsProcessed},env_keys=${envKeysCopied},files=${filesCopied}`);
if (missing.length > 0) {
console.log(`MISSING:${missing.join(',')}`);

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