Compare commits

..

31 Commits

Author SHA1 Message Date
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
179 changed files with 7075 additions and 10001 deletions
-1
View File
@@ -1 +0,0 @@
../.claude/skills
+51 -89
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
```
@@ -135,22 +112,7 @@ ncl groups restart --id <group-id>
Switching is an operator action — run it from the host. Memory does NOT carry over automatically — each provider keeps its own store; run `/migrate-memory` to carry it across. See [docs/provider-migration.md](../../docs/provider-migration.md) for the carry-over table and rollback.
### Default new groups to codex (optional)
New groups are created on the **instance default** (`DEFAULT_AGENT_PROVIDER` in `.env`, or `claude` when unset). Installing this skill wires codex in but does NOT change that default — "installed" is not "authenticated", so the default stays claude until you opt in explicitly.
After install, ask the operator before flipping it:
> "Codex is installed. Default new agent groups to codex? Existing groups keep their current provider."
On yes — set it, then restart the host so it takes effect:
```bash
pnpm exec tsx setup/index.ts --step set-env -- --key DEFAULT_AGENT_PROVIDER --value codex
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS; Linux: systemctl --user restart nanoclaw
```
This affects only groups created afterward. Per-group `ncl groups config update --provider` still overrides the default in either direction. Creation itself stays provider-agnostic (no `--provider` flag — provider is a DB property stamped from the instance default at creation).
There is no install-wide default provider. Setup's provider picker sets codex on the first agent it creates; creation itself is provider-agnostic (no `--provider` flag — provider is a DB property). Any group switches afterward via `ncl groups config update --provider` as above.
## Troubleshooting
@@ -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.-]+)?$/);
});
});
+115 -58
View File
@@ -5,98 +5,155 @@ 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.26.0
```
### 5. Build and validate
### 4. Build and validate
```bash
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
the dependency is installed. Then run the one integration test.
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/discord-registration.test.ts
```
Both must be clean before proceeding. `discord-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `discord`. It goes red if the `import './discord.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/discord` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
`discord-registration.test.ts` imports the real channel barrel and asserts the
registry contains `discord`. It goes red if the import line is deleted or drifts,
if the barrel fails to evaluate, or if `@chat-adapter/discord` isn't installed
(the import throws) — so it also covers the dependency from step 3. End-to-end
delivery against a real server is verified manually once the service runs.
## Credentials
### 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) — and
sync them to the container:
```nc:env-set
DISCORD_BOT_TOKEN={{bot_token}}
DISCORD_APPLICATION_ID={{application_id}}
DISCORD_PUBLIC_KEY={{public_key}}
```
```nc:env-sync
```
## 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:https://discord.com/oauth2/authorize?client_id={{application_id}}&scope=bot&permissions=2147584064
Open the invite link 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
+65 -42
View File
@@ -5,63 +5,70 @@ description: Add Google Chat channel integration via Chat SDK.
# Add Google Chat Channel
Adds Google Chat support via the Chat SDK bridge.
Adds Google Chat support via the Chat SDK bridge. NanoClaw doesn't ship channels
in trunk — this skill copies the Google Chat adapter in from the `channels`
branch.
## Install
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
reads the prose and applies them, and a parser can apply them deterministically
from the same document. Every directive is idempotent, so the whole skill is
safe to re-run; anything a parser can't apply falls back to the prose beside it.
NanoClaw doesn't ship channels in trunk. This skill copies the Google Chat adapter in from the `channels` branch.
## Apply
### Pre-flight (idempotent)
### 1. Copy the adapter and its registration test
Skip to **Credentials** if all of these are already in place:
Fetch the `channels` branch and copy the Google Chat adapter and its
registration test into `src/channels/` (overwrite — the branch is canonical):
- `src/channels/gchat.ts` exists
- `src/channels/gchat-registration.test.ts` exists
- `src/channels/index.ts` contains `import './gchat.js';`
- `@chat-adapter/gchat` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```nc:copy from-branch:channels
src/channels/gchat.ts
src/channels/gchat-registration.test.ts
```
### 2. Copy the adapter and its registration test
### 2. Register the adapter
```bash
git show origin/channels:src/channels/gchat.ts > src/channels/gchat.ts
git show origin/channels:src/channels/gchat-registration.test.ts > src/channels/gchat-registration.test.ts
```
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
```nc:append to:src/channels/index.ts
import './gchat.js';
```
### 4. Install the adapter package (pinned)
### 3. Install the adapter package
```bash
pnpm install @chat-adapter/gchat@4.29.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/gchat@4.26.0
```
### 5. Build and validate
### 4. Build and validate
```bash
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
the dependency is installed. Then run the one integration test.
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/gchat-registration.test.ts
```
Both must be clean before proceeding. `gchat-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `gchat`. It goes red if the `import './gchat.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/gchat` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Google Chat space is verified manually once the service is running — see Next Steps and the webhook setup above.
`gchat-registration.test.ts` imports the real channel barrel and asserts the
registry contains `gchat`. It goes red if the import line is deleted or drifts,
if the barrel fails to evaluate, or if `@chat-adapter/gchat` isn't installed (the
import throws) — so it also covers the dependency from step 3. End-to-end
delivery against a real Google Chat space is verified manually once the service
runs — see Credentials and Next Steps.
## Credentials
Google Cloud setup is human and interactive — these steps are prose, not
directives (no parser can click through the Google Cloud Console). A recipe
rebuild produces a compiling, registered adapter that cannot receive a message
until they're done.
> 1. Go to [Google Cloud Console](https://console.cloud.google.com)
> 2. Create or select a project
> 3. Enable the **Google Chat API**
@@ -73,21 +80,35 @@ End-to-end message delivery against a real Google Chat space is verified manuall
> - Grant the Chat Bot role
> - Create a JSON key and download it
### Configure environment
### Store the credentials
Add the service account JSON as a single-line string to `.env`:
Capture the service account JSON, then write it. `prompt` only *asks* and binds
the answer to a name; a separate directive consumes it — so the same prompt
could feed `ncl` or the OneCLI vault instead of `.env` by swapping only the
consumer. Here it goes to `.env` (set-if-absent — a value you've already filled
in is never overwritten) as a single-line string, then syncs to the container:
```bash
GCHAT_CREDENTIALS={"type":"service_account","project_id":"...","private_key":"...","client_email":"..."}
```nc:prompt gchat_credentials secret
Paste the service account JSON as a single line — the key file you downloaded, e.g. `{"type":"service_account","project_id":"...","private_key":"...","client_email":"..."}`.
```
```nc:env-set
GCHAT_CREDENTIALS={{gchat_credentials}}
```
```nc:env-sync
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Webhook server
The Chat SDK bridge automatically starts a shared webhook server on port 3000
(`WEBHOOK_PORT` to change it), handling `/webhook/gchat`. This port must be
publicly reachable for Google Chat to deliver events — it's the HTTP endpoint
URL you set in the Connection settings above. Running locally, expose it with
ngrok (`ngrok http 3000`), a Cloudflare Tunnel, or a reverse proxy on a VPS.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/manage-channels` to wire this channel to an agent group.
If you're in the middle of `/setup`, return to the setup flow now. Otherwise run
`/manage-channels` to wire this channel to an agent group.
## Channel Info
@@ -97,3 +118,5 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group.
- **supports-threads**: yes
- **typical-use**: Interactive chat — team spaces or direct messages
- **default-isolation**: Same agent group for spaces where you're the primary user. Separate agent group for spaces with different teams or sensitive contexts.
</content>
</invoke>
+58 -41
View File
@@ -5,64 +5,68 @@ description: Add GitHub channel integration via Chat SDK. PR and issue comment t
# Add GitHub Channel
Adds GitHub support via the Chat SDK bridge. The agent participates in PR and issue comment threads.
Adds GitHub support via the Chat SDK bridge. The agent participates in PR and
issue comment threads. NanoClaw doesn't ship channels in trunk — this skill
copies the GitHub adapter in from the `channels` branch.
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
reads the prose and applies them, and a parser can apply them deterministically
from the same document. Every directive is idempotent, so the whole skill is
safe to re-run; anything a parser can't apply falls back to the prose beside it.
## Prerequisites
You need a **dedicated GitHub bot account** (not your personal account). The adapter uses this account to post replies and filters out its own messages to avoid loops. Create a free GitHub account for your bot (e.g. `my-org-bot`), then invite it as a collaborator with write access to the repos you want monitored.
## Install
## Apply
NanoClaw doesn't ship channels in trunk. This skill copies the GitHub adapter in from the `channels` branch.
### 1. Copy the adapter
### Pre-flight (idempotent)
Fetch the `channels` branch and copy the GitHub adapter into `src/channels/`
(overwrite — the branch is canonical):
Skip to **Credentials** if all of these are already in place:
- `src/channels/github.ts` exists
- `src/channels/github-registration.test.ts` exists
- `src/channels/index.ts` contains `import './github.js';`
- `@chat-adapter/github` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```nc:copy from-branch:channels
src/channels/github.ts
src/channels/github-registration.test.ts
```
### 2. Copy the adapter and its registration test
### 2. Register the adapter
```bash
git show origin/channels:src/channels/github.ts > src/channels/github.ts
git show origin/channels:src/channels/github-registration.test.ts > src/channels/github-registration.test.ts
```
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
```nc:append to:src/channels/index.ts
import './github.js';
```
### 4. Install the adapter package (pinned)
### 3. Install the adapter package
```bash
pnpm install @chat-adapter/github@4.29.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/github@4.26.0
```
### 5. Build and validate
### 4. Build and validate
```bash
The build guards the typed `createChatSdkBridge(...)` core call and proves the
dependency is installed (the adapter import throws if `@chat-adapter/github`
isn't present):
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/github-registration.test.ts
```
Both must be clean before proceeding. `github-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `github`. It goes red if the `import './github.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/github` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
`github-registration.test.ts` imports the real channel barrel and asserts the
registry contains `github`. It goes red if the import line is deleted or drifts,
if the barrel fails to evaluate, or if `@chat-adapter/github` isn't installed
(the import throws) — so it also covers the dependency from step 3.
End-to-end message delivery against a real GitHub repo is verified manually once the service is running — see Next Steps and the webhook setup above.
End-to-end message delivery against a real GitHub repo is verified manually once
the service is running — see Next Steps and the webhook setup below.
## Credentials
@@ -88,18 +92,31 @@ On each repo (logged in as the repo owner/admin):
### 3. Configure environment
Add to `.env`:
Capture the three values, then write them. `prompt` only *asks* and binds the
answer to a name; a separate directive consumes it — so the same prompts could
feed `ncl` or the OneCLI vault instead of `.env` by swapping only the consumer.
Here they go to `.env` (set-if-absent — a value you've already filled in is
never overwritten) and sync to the container:
```bash
GITHUB_TOKEN=github_pat_...
GITHUB_WEBHOOK_SECRET=your-webhook-secret
GITHUB_BOT_USERNAME=your-bot-username
```nc:prompt github_token secret
Paste the Fine-grained Personal Access Token for the bot account — starts with `github_pat_`.
```
```nc:prompt webhook_secret secret
Paste the webhook secret you generated for the repo webhook(s).
```
```nc:prompt bot_username
Enter the bot account's GitHub username exactly (used for @-mention detection).
```
```nc:env-set
GITHUB_TOKEN={{github_token}}
GITHUB_WEBHOOK_SECRET={{webhook_secret}}
GITHUB_BOT_USERNAME={{bot_username}}
```
```nc:env-sync
```
`GITHUB_BOT_USERNAME` must match the bot account's GitHub username exactly. This is used for @-mention detection — the agent responds when someone writes `@your-bot-username` in a PR or issue comment.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## Wiring
Ask the user: **Is this a private or public repo?**
+7 -7
View File
@@ -19,17 +19,17 @@ ncl groups config remove-mcp-server --id <group-id> --name gmail
## 3. Remove the `.gmail-mcp` mount (per group)
There is no `ncl groups config remove-mount` verb yet ([#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Edit the central DB via the in-tree wrapper (`scripts/q.ts` — NanoClaw avoids depending on the `sqlite3` CLI, `setup/verify.ts:5`). Run from your NanoClaw project root (where `data/v2.db` lives):
Remove the mount with the host-only `ncl groups config remove-mount` verb (operator-only; rejected from inside a container). For each group that had Gmail wired:
```bash
GROUP_ID='<group-id>'
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \
WHERE json_extract(value, '\$.containerPath') != '.gmail-mcp'), \
updated_at = datetime('now') \
WHERE agent_group_id = '$GROUP_ID';"
ncl groups config remove-mount \
--id <group-id> \
--host "$HOME/.gmail-mcp" \
--container .gmail-mcp
```
The verb is idempotent — a no-op if the mount is already absent.
## 4. Remove the Dockerfile install
In `container/Dockerfile`, delete the `ARG GMAIL_MCP_VERSION=...` line and the `pnpm install -g` `RUN` block that installs `@gongrzhe/server-gmail-autoauth-mcp` and `zod-to-json-schema`.
+6 -11
View File
@@ -181,21 +181,16 @@ Approval behaviour depends on where you run it: from inside an agent's container
### Add the `.gmail-mcp` mount
There is no `ncl groups config add-mount` verb yet (tracked in [#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Until that ships, edit the DB directly via the in-tree wrapper (`scripts/q.ts``setup/verify.ts:5` codifies that NanoClaw avoids depending on the `sqlite3` CLI binary, so don't shell out to it):
Register the mount with the host-only `ncl groups config add-mount` verb. For each chosen `<group-id>`:
```bash
GROUP_ID='<group-id>'
HOST_PATH="$HOME/.gmail-mcp"
MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".gmail-mcp", readonly:false}')
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \
updated_at = datetime('now') \
WHERE agent_group_id = '$GROUP_ID';"
ncl groups config add-mount \
--id <group-id> \
--host "$HOME/.gmail-mcp" \
--container .gmail-mcp
```
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is ISO-string everywhere else in the schema, so use `datetime('now')` — not `strftime('%s','now')`, which would silently mix epoch ints into a column of YYYY-MM-DD HH:MM:SS strings.
**Switch to `ncl groups config add-mount` once #2395 lands.** Update this skill at that time.
`--host` is the host path, `--container` is the in-container path (relative, lands at `/workspace/extra/.gmail-mcp`). No `--ro` — the MCP server writes refreshed token state back into the mount. The verb is idempotent (a re-run skips if the mount is already present) and operator-only (host-side; rejected from inside a container), so run it from a host operator shell when applying this skill.
**Why the container path is relative:** `mount-security` rejects absolute `containerPath` values. Additional mounts are prefixed with `/workspace/extra/`, so `containerPath: ".gmail-mcp"` lands at `/workspace/extra/.gmail-mcp`. The MCP server's `GMAIL_OAUTH_PATH` / `GMAIL_CREDENTIALS_PATH` env vars point at that absolute location inside the container.
@@ -8,10 +8,9 @@
* allowedTools: [ ...TOOL_ALLOWLIST, ...Object.keys(this.mcpServers).map(mcpAllowPattern) ]
*
* `mcpAllowPattern` is not exported and the call site lives inside the SDK query options,
* so we assert the derivation structurally. Delete or rename the derivation and this goes
* red — surfacing that `gmail` tools would silently be filtered out despite being registered.
*
* `mcpAllowPattern` itself is exercised directly to prove `gmail` -> `mcp__gmail__*`.
* so the derivation is non-invocable from a test — we guard it structurally. Delete or
* rename either half (the function or the spread into allowedTools) and this goes red,
* surfacing that `gmail` tools would silently be filtered out despite being registered.
*/
import fs from 'fs';
import path from 'path';
@@ -25,11 +24,6 @@ function source(): { sf: ts.SourceFile; text: string } {
return { sf: ts.createSourceFile(p, text, ts.ScriptTarget.Latest, true), text };
}
/** Reimplement the sanitizer the provider applies, to assert the gmail name maps cleanly. */
function expectedPattern(name: string): string {
return `mcp__${name.replace(/[^a-zA-Z0-9_-]/g, '_')}__*`;
}
describe('claude.ts derives MCP allow-patterns from the registered servers', () => {
const { sf, text } = source();
@@ -48,8 +42,4 @@ describe('claude.ts derives MCP allow-patterns from the registered servers', ()
const flat = text.replace(/\s+/g, ' ');
expect(flat).toContain('Object.keys(this.mcpServers).map(mcpAllowPattern)');
});
it('maps a gmail server name to mcp__gmail__*', () => {
expect(expectedPattern('gmail')).toBe('mcp__gmail__*');
});
});
+148 -56
View File
@@ -5,110 +5,202 @@ 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`
Once the keys for your mode are written, sync `.env` to the container (the host
mounts `data/env/env`):
```nc:env-sync
```
## 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
+99 -64
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.26.0
```
### 5. Build and validate
### 4. Build and validate
```bash
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
the dependency is installed. Then run the one integration test.
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/linear-registration.test.ts
```
Both must be clean before proceeding. `linear-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `linear`. It goes red if the `import './linear.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/linear` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Linear workspace is verified manually once the service is running — see Wiring and Next Steps.
Both must be clean before proceeding. `linear-registration.test.ts` imports the
real channel barrel and asserts the registry contains `linear`. It goes red if
the `import './linear.js';` line is deleted or drifts, if the barrel fails to
evaluate, or if `@chat-adapter/linear` isn't installed (the import throws) — so
it also covers the dependency from step 3. End-to-end message delivery against a
real Linear workspace is verified manually once the service is running — see
Wiring and Next Steps.
## Credentials
Linear app and webhook setup is human and interactive — these steps are prose
(no parser can click through the Linear UI), except the final env write.
### 1. Set up a webhook
1. Go to **Linear Settings** > **API** > **Webhooks** > **New webhook**
@@ -86,48 +98,71 @@ End-to-end message delivery against a real Linear workspace is verified manually
Note: Linear webhook delivery may be delayed 1-5 minutes for new webhooks. This is normal.
### 2. Configure environment
### 2. Store the credentials
Add to `.env`:
Capture the values, then write them. `prompt` only *asks* and binds the answer
to a name; a separate directive consumes it. Here they go to `.env`
(set-if-absent — a value you've already filled in is never overwritten) and sync
to the container.
```bash
# OAuth app (recommended)
LINEAR_CLIENT_ID=your-client-id
LINEAR_CLIENT_SECRET=your-client-secret
Use **either** the OAuth app credentials (recommended) **or** a Personal API key.
For the API-key path, paste `none` at the OAuth prompts and set `LINEAR_API_KEY`
in `.env` by hand (commented in the template below). `LINEAR_BOT_USERNAME` is the
display name for the bot, used for self-message detection when using a Personal
API Key. `LINEAR_TEAM_KEY` is the Linear team key (e.g. `ENG`, `NAN`) — find it
in Linear under Settings > Teams; all issues in this team route to one messaging
group.
# OR Personal API key (simpler, but agent posts as you)
# LINEAR_API_KEY=lin_api_...
LINEAR_WEBHOOK_SECRET=your-webhook-signing-secret
LINEAR_BOT_USERNAME=NanoClaw Bot
LINEAR_TEAM_KEY=ENG
```nc:prompt linear_client_id secret
Paste the OAuth Client ID — Linear Settings > API > OAuth Applications. Paste `none` if using a Personal API key instead.
```
```nc:prompt linear_client_secret secret
Paste the OAuth Client Secret. Paste `none` if using a Personal API key instead.
```
```nc:prompt linear_webhook_secret secret
Paste the webhook signing secret from the webhook you just created.
```
```nc:prompt linear_team_key
Enter the Linear team key (e.g. `ENG`, `NAN`) — Settings > Teams.
```
```nc:prompt linear_bot_username
Enter the bot display name (e.g. `NanoClaw Bot`).
```
```nc:env-set
LINEAR_CLIENT_ID={{linear_client_id}}
LINEAR_CLIENT_SECRET={{linear_client_secret}}
LINEAR_WEBHOOK_SECRET={{linear_webhook_secret}}
LINEAR_TEAM_KEY={{linear_team_key}}
LINEAR_BOT_USERNAME={{linear_bot_username}}
```
```nc:env-sync
```
- `LINEAR_BOT_USERNAME`: display name for the bot (used for self-message detection when using a Personal API Key)
- `LINEAR_TEAM_KEY`: the Linear team key (e.g. `ENG`, `NAN`). Find it in Linear under Settings > Teams. All issues in this team route to one messaging group.
If you went the Personal API key route, add this line to `.env` instead of the
OAuth pair (agent posts as you, your own comments are filtered):
Sync to container: `mkdir -p data/env && cp .env data/env/env`
```bash
LINEAR_API_KEY=lin_api_...
```
## Wiring
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
+88 -42
View File
@@ -5,57 +5,53 @@ description: Add Matrix channel integration via Chat SDK. Works with any Matrix
# Add Matrix Channel
Adds Matrix support via the Chat SDK bridge.
Adds Matrix support via the Chat SDK bridge. NanoClaw doesn't ship channels in
trunk — this skill copies the Matrix adapter in from the `channels` branch.
## Install
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
reads the prose and applies them, and a parser can apply them deterministically
from the same document. Every directive is idempotent, so the whole skill is
safe to re-run; anything a parser can't apply falls back to the prose beside it.
NanoClaw doesn't ship channels in trunk. This skill copies the Matrix adapter in from the `channels` branch.
## Apply
### Pre-flight (idempotent)
### 1. Copy the adapter
Skip to **Credentials** if all of these are already in place:
Fetch the `channels` branch and copy the Matrix adapter into `src/channels/`
(overwrite — the branch is canonical):
- `src/channels/matrix.ts` exists
- `src/channels/matrix-registration.test.ts` exists
- `src/channels/index.ts` contains `import './matrix.js';`
- `@beeper/chat-adapter-matrix` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```nc:copy from-branch:channels
src/channels/matrix.ts
src/channels/matrix-registration.test.ts
```
### 2. Copy the adapter and its registration test
### 2. Register the adapter
```bash
git show origin/channels:src/channels/matrix.ts > src/channels/matrix.ts
git show origin/channels:src/channels/matrix-registration.test.ts > src/channels/matrix-registration.test.ts
```
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
```nc:append to:src/channels/index.ts
import './matrix.js';
```
### 4. Install the adapter package (pinned)
### 3. Install the adapter package
```bash
pnpm install @beeper/chat-adapter-matrix@0.2.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`.
The Matrix adapter lives in the `@beeper/` namespace and versions on its own
track (not the `@chat-adapter/*` family), so it carries its own pin:
```nc:dep
@beeper/chat-adapter-matrix@0.2.0
```
### 5. Patch matrix-js-sdk ESM imports
### 4. Patch matrix-js-sdk ESM imports
The adapter's published dist references `matrix-js-sdk/lib/...` without `.js`
extensions, which fails under Node 22 strict ESM resolution. Add the missing
extensions (idempotent — safe to re-run):
extensions (idempotent — safe to re-run). Re-run this after every `pnpm install`
that touches the adapter:
```bash
```nc:run effect:external
node -e '
const fs = require("fs"), path = require("path");
const root = "node_modules/.pnpm";
@@ -69,22 +65,32 @@ node -e '
'
```
Re-run this after every `pnpm install` that touches the adapter.
### 5. Build
### 6. Build and validate
Build guards the typed `createChatSdkBridge(...)` core call the adapter makes
and proves the dependency is installed and the ESM patch took. It also fails if
the `import './matrix.js';` line is missing or the barrel can't evaluate.
```bash
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/matrix-registration.test.ts
```
Both must be clean before proceeding. `matrix-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `matrix`. It goes red if the `import './matrix.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@beeper/chat-adapter-matrix` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
`matrix-registration.test.ts` imports the real channel barrel and asserts the
registry contains `matrix`. It goes red if the import line is deleted or drifts,
if the barrel fails to evaluate, or if `@beeper/chat-adapter-matrix` isn't
installed (the import throws) — so it also covers the dependency from step 3.
End-to-end message delivery against a real Matrix homeserver is verified manually once the service is running — see Next Steps.
End-to-end message delivery against a real Matrix homeserver is verified
manually once the service is running — see Next Steps.
## Credentials
The bot needs its own Matrix account — separate from the user's account. This is required because Matrix cannot send DMs to yourself.
The bot needs its own Matrix account — separate from the user's account. This is
required because Matrix cannot send DMs to yourself. These steps are human and
interactive (no parser can click through Element), so they stay prose.
### Create a bot account
@@ -131,12 +137,52 @@ MATRIX_RECOVERY_KEY=your-recovery-key # Enable E2EE cross-signing
MATRIX_DEVICE_ID=NANOCLAW01 # Stable device ID across restarts
```
### Configure environment
### Store the credentials
Add the chosen env vars to `.env`, then sync:
Capture the values for the auth method you chose, then write them. `prompt` only
*asks* and binds the answer to a name; a separate directive consumes it — so the
same prompts could feed `ncl` or the OneCLI vault instead of `.env` by swapping
only the consumer. The homeserver URL, the bot's user ID, and a display name are
shared across both auth methods:
```bash
mkdir -p data/env && cp .env data/env/env
```nc:prompt base_url
Paste the homeserver base URL, e.g. `https://matrix.org`.
```
```nc:prompt user_id
Paste the bot's full Matrix user ID, e.g. `@andybot:matrix.org`.
```
```nc:prompt bot_username
Paste a display name for the bot, e.g. `Andy`.
```
```nc:env-set
MATRIX_BASE_URL={{base_url}}
MATRIX_USER_ID={{user_id}}
MATRIX_BOT_USERNAME={{bot_username}}
```
For **Option A** capture the bot login, for **Option B** capture the access
token — set only the block matching your chosen method:
```nc:prompt username
Option A only — the bot's login username (the localpart, e.g. `andybot`).
```
```nc:prompt password secret
Option A only — the bot account's password.
```
```nc:env-set
MATRIX_USERNAME={{username}}
MATRIX_PASSWORD={{password}}
```
```nc:prompt access_token secret
Option B only — the access token from Element Settings > Help & About, or from the login API.
```
```nc:env-set
MATRIX_ACCESS_TOKEN={{access_token}}
```
Then sync `.env` into the container:
```nc:env-sync
```
## Next Steps
+100 -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,75 @@ Both must be clean before proceeding. `resend-registration.test.ts` is the one i
- Events: select **email.received**.
- Copy the signing secret.
### Configure environment
### Store the credentials
Add to `.env`:
Capture the secrets, then write them. `prompt` only *asks* and binds the answer
to a name; a separate directive consumes it — so the same prompts could feed
`ncl` or the OneCLI vault instead of `.env` by swapping only the consumer. Here
they go to `.env` (set-if-absent — a value you've already filled in is never
overwritten) and sync to the container:
```bash
RESEND_API_KEY=re_...
RESEND_FROM_ADDRESS=bot@yourdomain.com
RESEND_FROM_NAME=NanoClaw
RESEND_WEBHOOK_SECRET=your-webhook-secret
```nc:prompt api_key secret
Paste the Resend API key — API Keys, starts with `re_`.
```
```nc:prompt webhook_secret secret
Paste the webhook signing secret — Webhooks, the value you copied above.
```
```nc:prompt from_address
The bot's sending email address on your verified domain (e.g. `bot@yourdomain.com`).
```
```nc:prompt from_name
The display name to send as (e.g. `NanoClaw`).
```
```nc:env-set
RESEND_API_KEY={{api_key}}
RESEND_FROM_ADDRESS={{from_address}}
RESEND_FROM_NAME={{from_name}}
RESEND_WEBHOOK_SECRET={{webhook_secret}}
```
```nc:env-sync
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## 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`).
```
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>
```
+157 -188
View File
@@ -1,53 +1,171 @@
---
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}}
```
```nc:env-sync
```
## 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 +225,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 +250,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 +260,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 +281,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 +299,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.
+108 -76
View File
@@ -5,114 +5,146 @@ 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.26.0
```
### 5. Build and validate
### 4. Build and validate
```bash
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
the dependency is installed. Then run the one integration test.
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/slack-registration.test.ts
```
Both must be clean before proceeding. `slack-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `slack`. It goes red if the `import './slack.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/slack` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Slack workspace is verified manually once the service is running — see Next Steps and the webhook setup above.
`slack-registration.test.ts` imports the real channel barrel and asserts the
registry contains `slack`. It goes red if the import line is deleted or drifts,
if the barrel fails to evaluate, or if `@chat-adapter/slack` isn't installed (the
import throws) — so it also covers the dependency from step 3. End-to-end
delivery against a real workspace is verified manually once the service runs.
## Credentials
### Create Slack App
Walk the operator through creating the Slack app, then collect the two secrets it
hands back. The adapter is already installed and registered — it just can't
receive a message until this is done. Tell the user:
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:operator
Create the Slack app:
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.
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
Collect the two secrets and store them (the bridge reads them from `.env`):
### Webhook server
```nc:prompt bot_token secret validate:^xoxb-
Paste the Bot User OAuth Token — OAuth & Permissions, starts with `xoxb-`.
```
```nc:prompt signing_secret secret validate:^[a-fA-F0-9]{16,}$
Paste the Signing Secret — Basic Information.
```
```nc:env-set
SLACK_BOT_TOKEN={{bot_token}}
SLACK_SIGNING_SECRET={{signing_secret}}
```
```nc:env-sync
```
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.
The bridge serves the webhook on port 3000 at `/webhook/slack` automatically; to
receive replies, that port must be reachable from the internet and registered
with Slack. 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
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; to receive
replies, finish the Event Subscriptions and Interactivity steps above.
## 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
+248 -153
View File
@@ -5,68 +5,258 @@ 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.26.0
```
### 5. Build and validate
### 4. Build and validate
```bash
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
the dependency is installed. Then run the one integration test.
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/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 open:https://portal.azure.com
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 min:20
Paste the client secret Value — Certificates & secrets (shown only once).
```
### Store the credentials
The adapter reads these from `.env` (set-if-absent, so a value you've already
filled in is never overwritten) and syncs them to the container.
`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}}
```
```nc:env-sync
```
### 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 gate
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 gate
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 +287,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 +301,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 +324,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` and synced to `data/env/env` (re-run the env-sync step if not).
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.
+114 -66
View File
@@ -5,111 +5,159 @@ 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.26.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}}
```
```nc:env-sync
```
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:https://t.me/{{bot_username}}
Open @{{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.
+75 -47
View File
@@ -5,85 +5,113 @@ description: Add Webex channel integration via Chat SDK.
# Add Webex Channel
Adds Cisco Webex support via the Chat SDK bridge.
Adds Cisco Webex support via the Chat SDK bridge. NanoClaw doesn't ship channels
in trunk — this skill copies the Webex adapter in from the `channels` branch.
## Install
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
reads the prose and applies them, and a parser can apply them deterministically
from the same document. Every directive is idempotent, so the whole skill is
safe to re-run; anything a parser can't apply falls back to the prose beside it.
NanoClaw doesn't ship channels in trunk. This skill copies the Webex adapter in from the `channels` branch.
## Apply
### Pre-flight (idempotent)
### 1. Copy the adapter and its registration test
Skip to **Credentials** if all of these are already in place:
Fetch the `channels` branch and copy the Webex adapter and its registration test
into `src/channels/` (overwrite — the branch is canonical):
- `src/channels/webex.ts` exists
- `src/channels/webex-registration.test.ts` exists
- `src/channels/index.ts` contains `import './webex.js';`
- `@bitbasti/chat-adapter-webex` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```nc:copy from-branch:channels
src/channels/webex.ts
src/channels/webex-registration.test.ts
```
### 2. Copy the adapter and its registration test
### 2. Register the adapter
```bash
git show origin/channels:src/channels/webex.ts > src/channels/webex.ts
git show origin/channels:src/channels/webex-registration.test.ts > src/channels/webex-registration.test.ts
```
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
```nc:append to:src/channels/index.ts
import './webex.js';
```
### 4. Install the adapter package (pinned)
### 3. Install the adapter package
```bash
pnpm install @bitbasti/chat-adapter-webex@0.1.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`.
The Webex adapter ships under the third-party `@bitbasti/*` namespace, not
`@chat-adapter/*`, so it carries its own version line (`0.1.0`) rather than
tracking the chat core version:
```nc:dep
@bitbasti/chat-adapter-webex@0.1.0
```
### 5. Build and validate
### 4. Build and validate
```bash
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
the dependency is installed. Then run the one integration test.
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/webex-registration.test.ts
```
Both must be clean before proceeding. `webex-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `webex`. It goes red if the `import './webex.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@bitbasti/chat-adapter-webex` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Webex space is verified manually once the service is running — see Next Steps and the webhook setup above.
`webex-registration.test.ts` imports the real channel barrel and asserts the
registry contains `webex`. It goes red if the import line is deleted or drifts,
if the barrel fails to evaluate, or if `@bitbasti/chat-adapter-webex` isn't
installed (the import throws) — so it also covers the dependency from step 3.
End-to-end delivery against a real Webex space is verified manually once the
service runs — see the webhook setup below.
## Credentials
1. Go to [developer.webex.com](https://developer.webex.com/my-apps/new/bot) and create a new bot
2. Copy the **Bot Access Token**
Webex bot setup is human and interactive — these steps are prose, not directives
(no parser can click through the Webex Developer Portal). A recipe rebuild
produces a compiling, registered adapter that cannot receive a message until
they're done.
### Create the Webex bot
1. Go to [developer.webex.com](https://developer.webex.com/my-apps/new/bot) and create a new bot.
2. Copy the **Bot Access Token**.
3. Set up a webhook:
- Use the Webex API or Developer Portal to create a webhook pointing to `https://your-domain/webhook/webex`
- Set a webhook secret for signature verification
- Use the Webex API or Developer Portal to create a webhook pointing to `https://your-domain/webhook/webex`.
- Set a webhook secret for signature verification.
### Configure environment
### Store the credentials
Add to `.env`:
Capture the two values, then write them. `prompt` only *asks* and binds the
answer to a name; a separate directive consumes it — so the same prompts could
feed `ncl` or the OneCLI vault instead of `.env` by swapping only the consumer.
Here they go to `.env` (set-if-absent — a value you've already filled in is
never overwritten) and sync to the container:
```bash
WEBEX_BOT_TOKEN=your-bot-token
WEBEX_WEBHOOK_SECRET=your-webhook-secret
```nc:prompt bot_token secret
Paste the Bot Access Token — from the Webex bot you created.
```
```nc:prompt webhook_secret secret
Paste the webhook secret you set for signature verification.
```
```nc:env-set
WEBEX_BOT_TOKEN={{bot_token}}
WEBEX_WEBHOOK_SECRET={{webhook_secret}}
```
```nc:env-sync
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Webhook server
The Chat SDK bridge automatically starts a shared webhook server on port 3000
(`WEBHOOK_PORT` to change it), handling `/webhook/webex`. This port must be
publicly reachable for Webex to deliver events. Running locally, expose it with
ngrok (`ngrok http 3000`), a Cloudflare Tunnel, or a reverse proxy on a VPS —
the resulting public URL is the base for the webhook URL above.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/manage-channels` to wire this channel to an agent group.
If you're in the middle of `/setup`, return to the setup flow now. Otherwise run
`/manage-channels` to wire this channel to an agent group.
## Channel Info
+5 -9
View File
@@ -36,16 +36,12 @@ pnpm uninstall wechat-ilink-client
rm -rf data/wechat
```
## 5. Remove DB wiring
The channel's messaging groups, wirings, and conversation history are **left
intact** — you created those at runtime (wiring + use), not this skill's install,
so removal doesn't touch them. To purge them deliberately, delete them yourself
with `ncl messaging-groups delete <id>`.
```sql
-- Remove any sessions first (foreign key)
DELETE FROM sessions WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type = 'wechat');
DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type = 'wechat');
DELETE FROM messaging_groups WHERE channel_type = 'wechat';
```
## 6. Rebuild and restart
## 5. Rebuild and restart
Run from your NanoClaw project root:
+76 -40
View File
@@ -6,62 +6,71 @@ description: Add WhatsApp Business Cloud API channel via Chat SDK. Official Meta
# Add WhatsApp Cloud API Channel
Connect NanoClaw to WhatsApp via the official Meta WhatsApp Business Cloud API.
NanoClaw doesn't ship channels in trunk — this skill copies the WhatsApp Cloud
adapter in from the `channels` branch.
## Install
The mechanical steps under **Apply** carry `nc:` directive fences: an agent reads
the prose and applies them, and a parser can apply them deterministically from
the same document. Every directive is idempotent, so the whole skill is safe to
re-run; anything a parser can't apply falls back to the prose beside it.
NanoClaw doesn't ship channels in trunk. This skill copies the WhatsApp Cloud adapter in from the `channels` branch.
## Apply
### Pre-flight (idempotent)
### 1. Copy the adapter
Skip to **Credentials** if all of these are already in place:
Fetch the `channels` branch and copy the WhatsApp Cloud adapter into
`src/channels/` (overwrite — the branch is canonical):
- `src/channels/whatsapp-cloud.ts` exists
- `src/channels/whatsapp-cloud-registration.test.ts` exists
- `src/channels/index.ts` contains `import './whatsapp-cloud.js';`
- `@chat-adapter/whatsapp` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```nc:copy from-branch:channels
src/channels/whatsapp-cloud.ts
src/channels/whatsapp-cloud-registration.test.ts
```
### 2. Copy the adapter and its registration test
### 2. Register the adapter
```bash
git show origin/channels:src/channels/whatsapp-cloud.ts > src/channels/whatsapp-cloud.ts
git show origin/channels:src/channels/whatsapp-cloud-registration.test.ts > src/channels/whatsapp-cloud-registration.test.ts
```
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
```nc:append to:src/channels/index.ts
import './whatsapp-cloud.js';
```
### 4. Install the adapter package (pinned)
### 3. Install the adapter package
```bash
pnpm install @chat-adapter/whatsapp@4.29.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/whatsapp@4.26.0
```
### 5. Build and validate
### 4. Build and validate
```bash
Build guards the typed `createChatSdkBridge(...)` core call and proves the
dependency is installed — the import throws at evaluation if `@chat-adapter/whatsapp`
is missing or the barrel drifts:
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/whatsapp-cloud-registration.test.ts
```
Both must be clean before proceeding. `whatsapp-cloud-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `whatsapp-cloud`. It goes red if the `import './whatsapp-cloud.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/whatsapp` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
`whatsapp-cloud-registration.test.ts` imports the real channel barrel and asserts
the registry contains `whatsapp-cloud` — it goes red if the import line is deleted
or drifts, if the barrel fails to evaluate, or if `@chat-adapter/whatsapp` isn't
installed (the import throws), so it also covers the dependency from step 3.
End-to-end message delivery against a real WhatsApp Business number is verified manually once the service is running — see Next Steps and the webhook setup above.
End-to-end message delivery against a real WhatsApp Business number is verified
manually once the service is running — see Next Steps and the webhook setup
below.
## Credentials
Meta app setup is human and interactive — these steps are prose, not directives
(no parser can click through the Meta dashboard). A recipe rebuild produces a
compiling, registered adapter that cannot receive a message until they're done.
1. Go to [Meta for Developers](https://developers.facebook.com/apps/) and create an app (type: Business).
2. Add the **WhatsApp** product.
3. Go to **WhatsApp** > **API Setup**:
@@ -73,18 +82,43 @@ End-to-end message delivery against a real WhatsApp Business number is verified
- Subscribe to webhook fields: `messages`.
5. Copy the **App Secret** from **Settings** > **Basic**.
### Configure environment
### Store the credentials
Add to `.env`:
Capture the four values, then write them. `prompt` only *asks* and binds the
answer to a name; a separate directive consumes it — so the same prompts could
feed `ncl` or the OneCLI vault instead of `.env` by swapping only the consumer.
Here they go to `.env` (set-if-absent — a value you've already filled in is
never overwritten) and sync to the container:
```bash
WHATSAPP_ACCESS_TOKEN=your-system-user-access-token
WHATSAPP_PHONE_NUMBER_ID=your-phone-number-id
WHATSAPP_APP_SECRET=your-app-secret
WHATSAPP_VERIFY_TOKEN=your-verify-token
```nc:prompt access_token secret
Paste the System User access token — WhatsApp > API Setup, with `whatsapp_business_messaging` permission.
```
```nc:prompt phone_number_id
Paste the Phone Number ID — WhatsApp > API Setup (not the phone number itself).
```
```nc:prompt app_secret secret
Paste the App Secret — Settings > Basic.
```
```nc:prompt verify_token secret
Paste the Verify Token — the random string you set under WhatsApp > Configuration.
```
```nc:env-set
WHATSAPP_ACCESS_TOKEN={{access_token}}
WHATSAPP_PHONE_NUMBER_ID={{phone_number_id}}
WHATSAPP_APP_SECRET={{app_secret}}
WHATSAPP_VERIFY_TOKEN={{verify_token}}
```
```nc:env-sync
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Webhook server
The Chat SDK bridge automatically starts a shared webhook server on port 3000
(`WEBHOOK_PORT` to change it), handling `/webhook/whatsapp`. This port must be
publicly reachable for Meta to deliver events. Running locally, expose it with
ngrok (`ngrok http 3000`), a Cloudflare Tunnel, or a reverse proxy on a VPS —
the resulting public URL is the base for the webhook URL set under WhatsApp >
Configuration above.
## Next Steps
@@ -100,3 +134,5 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group.
- **supports-threads**: no
- **typical-use**: Interactive 1:1 chat -- direct messages only
- **default-isolation**: Same agent group if you're the only person messaging the bot. Each additional person who messages gets their own conversation automatically, but they share the agent's workspace and memory -- use a separate agent group if you need information isolation between different contacts.
</content>
</invoke>
+202 -153
View File
@@ -5,216 +5,205 @@ 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
```
```nc:env-sync when:number_kind=dedicated
```
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 +217,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 +295,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 +307,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 +326,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.
+1 -1
View File
@@ -82,7 +82,7 @@ npx tsx scripts/init-first-agent.ts \
--agent-name "${AGENT_NAME}"
```
The new group is created on the instance default provider (`DEFAULT_AGENT_PROVIDER` in `.env`, or `claude` when unset). To put it on a different provider, switch after creation with `ncl groups config update --id <group-id> --provider <name>`. Add `--welcome "System instruction: ..."` to override the default welcome prompt.
Add `--provider <name>` when the user picked a non-default provider (there is no install-wide default — the choice is explicit per group). Add `--welcome "System instruction: ..."` to override the default welcome prompt.
The script:
1. Upserts the `users` row and grants `owner` role if no owner exists.
-97
View File
@@ -1,97 +0,0 @@
---
name: learn
description: "Distill a reusable skill from anything — a directory, a URL, pasted notes, or what you just did together — or refine an existing skill with new learnings. Use when the user says '/learn', 'learn this', 'turn this into a skill', 'capture this workflow', 'make a skill from <source>', or 'improve/update the <name> skill'. Produces or updates a .claude/skills/<name>/SKILL.md authored to NanoClaw's skill guidelines. (This CREATES or REFINES a skill from a source; it does not install existing skills from a registry.)"
---
# Learn — Distill a Skill from Anything
Turn a source — a directory, a URL, pasted notes, or the work just done in this conversation — into a clean, reusable NanoClaw skill. The output is a new `.claude/skills/<name>/SKILL.md` (plus optional `scripts/`, `references/`, `templates/`) authored to the project's skill guidelines.
This skill is **instruction-only**: it uses the tools you already have (`Read`, `Grep`, `Glob`, `WebFetch`, `Write`) — there is no separate distillation engine and no reach-ins into core code.
## When to use
Invoke when the user wants to *capture* a workflow as a reusable skill:
- `/learn <path>` — read a project/dir and build a skill for working with it
- `/learn <url>` — read docs / an API page and build a usage skill
- `/learn what we just did` — distill the current conversation's workflow
- `/learn` + pasted notes — turn notes into a structured skill
If the user instead wants to *find and install* an existing community skill, that is a different task — this skill **creates** new skills, it does not import them.
## Workflow
### 1. Identify the source — and whether this is a new skill or a refine
- A **path** → read the code/files.
- A **URL** → fetch and read the page.
- **"what we just did" / "this"** → use the current conversation as the source.
- **Pasted text** → use it directly.
Then check `.claude/skills/` for an existing skill that already covers this topic (the user may name it, e.g. *"update the wow-on-steam-deck skill"*, or the subject may obviously match one). **If one exists, this is a REFINE, not a fresh create** — go to step 4's "Refining" branch.
If it is ambiguous what the skill should *do*, ask one clarifying question before proceeding.
### 2. Gather the material
- **Path:** `Glob` the structure, `Read` the key files, `Grep` for the important entry points. Read enough to understand the *repeatable procedure*, not every line.
- **URL:** `WebFetch` the page; pull out the concrete commands/steps, not the prose.
- **Conversation:** re-read what was actually done — the commands, the gotchas, the decisions — and keep the parts that generalize.
### 3. Distill — find the reusable procedure
Strip the one-off specifics; keep the *repeatable* shape. A good skill answers: *"Next time someone needs to do X, what are the exact steps, files, commands, and gotchas?"* Capture:
- the trigger / when-to-use,
- the step-by-step procedure (commands, file paths, decision points),
- the non-obvious **gotchas** that were hit — usually the most valuable part,
- any scripts or templates worth shipping alongside.
### 4. Author the SKILL.md
**Refining an existing skill?** First `Read` the current `.claude/skills/<name>/SKILL.md`, then *update it in place* — do not blindly overwrite:
- Keep what is still correct; weave the new learnings into the right sections.
- **Dedupe** — don't append a near-duplicate step or a second gotcha that says the same thing.
- Correct anything the new source proves stale (a changed path, command, or flag).
- Preserve the existing `name`/folder and overall structure; the diff should read as a focused improvement, not a rewrite.
**New skill?** Write `.claude/skills/<kebab-name>/SKILL.md`.
**Frontmatter (required):**
```yaml
---
name: <kebab-case, matches the folder>
description: "<what it does + when to use it + likely trigger phrases>"
---
```
`description` is what the agent reads to decide relevance — make it concrete and include the phrases a user would actually say.
**Body:** open with one paragraph on what the skill does, then a `## When to use` section and a `## Workflow` of numbered steps (the actual procedure). Use tables for command/file references, and add a short examples or troubleshooting section when the gotchas warrant it.
**House authoring rules (from `docs/skill-guidelines.md`):**
- **Additive, minimal reach-ins** — prefer adding files; make the *smallest possible* edit to existing code, and only via single-line calls into skill-owned functions.
- **Instruction-only when possible** — if Claude can do it by following prose plus existing tools, ship no code. These are the easiest skills to maintain and to merge.
- If apply leaves anything behind, ship a **`REMOVE.md`** that fully reverses every change (no soft-disabled/commented-out removals).
- If the skill adds an integration point in core code, add a **test that goes red if the wiring is deleted or drifts**.
- Anti-patterns to avoid: separate `VERIFY.md` files, incomplete cleanup, raw SQL against core DBs, branch merges (use additive fetch), hand-maintained duplicate copies.
### 5. Place and verify
- Write into `.claude/skills/<name>/`; confirm the folder name matches the `name` frontmatter and the YAML parses.
- If feasible, dry-run the procedure the skill describes to confirm it is correct.
- Tell the user the skill exists and how to invoke it (`/<name>`).
## Example
`/learn what we just did` after a multi-step setup:
1. Re-read the conversation's commands and gotchas.
2. Distill the repeatable procedure.
3. Write `.claude/skills/<topic>-setup/SKILL.md` with the steps, file paths, and the gotchas hit along the way.
4. Report: *"Created `/<topic>-setup` — invoke it next time to repeat this."*
## Notes
- Keep skills **focused** — one capability per skill (mirrors the project's "one change per PR" rule).
- The most valuable content is the **gotchas**, not the happy path.
- This skill is prose and safe to re-run — use it again to refine an existing skill.
+1 -1
View File
@@ -67,7 +67,7 @@ pnpm exec tsx setup/index.ts --step register -- \
The `register` step creates the agent group (reusing it if the folder already exists), the messaging group, and the wiring row. `createMessagingGroupAgent` auto-creates the companion `agent_destinations` row so the agent can address the channel by name.
New agent groups are created on the instance default provider (`DEFAULT_AGENT_PROVIDER` in `.env`, or `claude` when unset). To run a group on a different provider, switch it after creation with `ncl groups config update --provider <name>` (e.g. `codex`).
When creating a NEW agent group on a non-default provider, append `--provider <name>` (e.g. `--provider codex`) — there is no install-wide default; existing groups switch via `ncl groups config update --provider` instead.
For separate agents, also ask for a folder name and optionally a different assistant name.
+21 -37
View File
@@ -121,7 +121,6 @@ Bucket the upstream changed files:
- **Host source** (`src/`): may conflict if user modified the same files
- **Container** (`container/`): triggers container rebuild (+ typecheck if `agent-runner/src/` changed)
- **Build/config** (`package.json`, `pnpm-lock.yaml`, `tsconfig*.json`): lockfile changes trigger dep install
- **Version pins** (`versions.json`): a changed `onecli-gateway` / `onecli-cli` value requires upgrading the OneCLI gateway/CLI to match — see Step 5.5
- **Other**: docs, tests, setup scripts, misc
**Large drift check:** If the upstream commit count and age suggest the user has a lot of catching up to do, mention that `/migrate-nanoclaw` might be a better fit — it extracts customizations and reapplies them on clean upstream instead of merging. Offer it as an option but don't push.
@@ -216,11 +215,6 @@ If build fails:
- Do not refactor unrelated code.
- If unclear, ask the user before making changes.
# Step 5.5: OneCLI upgrade (if pins moved)
The OneCLI gateway and CLI are external components pinned in `versions.json`; when a pin moves, the running version must be upgraded to match or the new code may fail against it.
If `git diff <backup-tag-from-step-1>..HEAD -- versions.json` shows the `onecli-gateway` or `onecli-cli` value changed, follow `docs/onecli-upgrades.md` before the service restart (Step 8). Otherwise skip.
# Step 6: Breaking changes check
After validation succeeds, check if the update introduced any breaking changes.
@@ -233,7 +227,7 @@ Parse the diff output for lines that contain `[BREAKING]` anywhere in the line.
```
If no `[BREAKING]` lines are found:
- Skip this step silently. Proceed to Step 7.
- Skip this step silently. Proceed to Step 7 (skill updates check).
If one or more `[BREAKING]` lines are found:
- Display a warning header to the user: "This update includes breaking changes that may require action:"
@@ -244,42 +238,32 @@ If one or more `[BREAKING]` lines are found:
- "Skip — I'll handle these manually"
- Set `multiSelect: true` so the user can pick multiple skills if there are several breaking changes.
- For each skill the user selects, invoke it using the Skill tool.
- After all selected skills complete (or if user chose Skip), proceed to Step 7.
- After all selected skills complete (or if user chose Skip), proceed to Step 7 (skill updates check).
# Step 7: Skill updates (part of updating NanoClaw)
# Step 7: Check for skill and channel/provider updates
Updating your installed skills is **part of** updating NanoClaw, not an optional
extra. Channel and provider code ships on long-lived branches (`channels`,
`providers`) that the host merge above doesn't touch — so stopping here leaves
that code on whatever version you installed, which is how an important upstream
fix gets silently left behind. The default is to continue into `/update-skills`,
which re-applies your installed channels/providers to pull their latest code.
## 7a: Skill branches
Check if skills are distributed as branches in this repo:
- `git branch -r --list 'upstream/skill/*'`
Detect whether anything is installed: read `src/channels/index.ts` and
`src/providers/index.ts`, collecting `import './<name>.js';` lines (excluding
`cli`).
If any `upstream/skill/*` branches exist:
- Use AskUserQuestion to ask: "Upstream has skill branches. Would you like to check for skill updates?"
- Option 1: "Yes, check for updates" (description: "Runs /update-skills to check for and apply skill branch updates")
- Option 2: "No, skip" (description: "You can run /update-skills later any time")
- If user selects yes, invoke `/update-skills` using the Skill tool.
- If nothing is installed: skip silently and proceed to Step 7.9.
- If one or more are installed: continue into skill updates.
## 7b: Channel and provider updates
Detect installed channels by reading `src/channels/index.ts` and collecting all `import './<name>.js';` lines (excluding `cli`). For providers, check `src/providers/index.ts` the same way.
**Hand-off — default in, minimal opt-out.** Use AskUserQuestion (single-select).
Name the installed skills in the question so the choice is concrete:
- Question: "Skill updates are part of this NanoClaw update — your installed
channels/providers (<list the detected ones>) ride separate branches the host
update didn't touch. Continue into `/update-skills` to bring them up to date?"
- Option 1 (Recommended): "Continue into skill updates" — description: "Runs
`/update-skills`, which re-applies your installed channels/providers to pull
their latest upstream code. You pick which ones there."
- Option 2: "Skip — I'll run `/update-skills` myself later" — description: "Your
installed skill code stays as-is and may be behind upstream."
If any channels/providers are installed AND `upstream/channels` or `upstream/providers` branches exist:
- List the installed channels/providers.
- Use AskUserQuestion to ask: "Would you like to update your installed channels/providers? Re-running `/add-<name>` is safe — it only updates code files, credentials and wiring are untouched."
- One option per installed channel/provider (e.g., "Update Slack (/add-slack)")
- "Skip — I'll update them later"
- Set `multiSelect: true`
- For each selected option, invoke the corresponding `/add-<channel>` or `/add-<provider>` skill.
Keep it to these two options — the per-skill selection lives inside
`/update-skills`, not here.
- On "Continue": invoke `/update-skills` using the Skill tool. (If the re-apply
touches container code, `/update-skills` rebuilds the agent image itself — see
its Step 4 — so nothing container-related is owed back here.)
- On "Skip": note that `/update-skills` can be run anytime, then proceed.
If no channels/providers are installed, skip silently.
Proceed to Step 7.9.
-1
View File
@@ -85,7 +85,6 @@ For each selected skill (process one at a time):
After all selected skills are re-applied:
- `pnpm run build`
- `pnpm test` (do not fail the flow if tests are not configured)
- If the re-apply changed any files under `container/` (`git diff --name-only -- container/` is non-empty), rebuild the agent image so new sessions pick up the new code: `./container/build.sh`. Skill code that lives in the container (e.g. a provider's runtime) keeps running the old image until this is done — the rebuild is what makes the fix live, not the file copy. If nothing under `container/` changed (e.g. only a channel adapter was re-applied), skip it.
Each channel/provider skill copies in its own registration test; those run as part of `pnpm test` and assert the barrel still registers the adapter against the freshly fetched code.
-1
View File
@@ -1 +0,0 @@
CLAUDE.md
+2 -5
View File
@@ -4,13 +4,10 @@ All notable changes to NanoClaw will be documented in this file.
## [Unreleased]
- **Optional per-container resource caps.** `CONTAINER_CPU_LIMIT` and `CONTAINER_MEMORY_LIMIT` pass through to `docker run` as `--cpus` / `--memory` (`container-runner.ts`). Both empty by default — no flag added, spawn args byte-identical to today — so existing installs are unaffected. Set them to cap an agent container's CPU/memory so one agent can't monopolize the host (e.g. `CONTAINER_CPU_LIMIT=2`, `CONTAINER_MEMORY_LIMIT=8g`). Swap is intentionally not managed here: `--memory` is a hard cap on a swapless host.
- [BREAKING] **Chat SDK pinned to `4.29.0` (was `4.26.0` via `^4.24.0`).** `chat` and the `@chat-adapter/*` channel adapters are version-locked — the adapter's `ChatInstance` must match the bridge's, so a mismatched pair fails to typecheck at `createChatSdkBridge(...)`. `chat` is therefore pinned exactly, and the channel-adapter install pins move with it — the `/add-<channel>` SKILL.md steps and `setup/*.sh` scripts on `main`, plus the adapter code on the `channels` branch. Core installs with no channel (only `cli`) are unaffected. **Migration:** if any channel is installed (Slack, Discord, Telegram, Teams, …), re-run its `/add-<channel>` skill to pull the matching `4.29.0` adapter.
- **Budget/billing-exhausted LLM turns now reach the user instead of being silently dropped.** When a turn ends in a non-retryable provider error (e.g. an Anthropic `403 billing_error`) with no `<message>` wrapping, the agent-runner delivers the provider's notice to the originating channel and stops re-nudging the failing gateway. `providers/claude.ts` now surfaces the SDK's `is_error` flag (and the error subtype's `errors[]` text); `poll-loop.ts` delivers that text and skips the re-wrap retry. Fixes the case where a spend-limit notice produced silence plus a turn-after-turn retry loop.
- [BREAKING] **`@onecli-sh/sdk` 0.5.0 -> 2.2.1 — requires a OneCLI server with the `/v1` API** (older servers 404 every SDK call). The sanctioned gateway and CLI versions are pinned in `versions.json`. **The gateway is a separate component — updating NanoClaw does not upgrade it for you:** `/update-nanoclaw` upgrades it when the pin moves, otherwise upgrade manually. **Migration:** [docs/onecli-upgrades.md](docs/onecli-upgrades.md).
- [BREAKING] **`@onecli-sh/sdk` 0.5.0 -> 2.2.1 — requires a OneCLI server with the `/v1` API** (older servers 404 every SDK call). The sanctioned gateway and CLI versions are pinned in `versions.json`; the `onecli` setup step enforces them. **Migration:** [docs/onecli-upgrades.md](docs/onecli-upgrades.md).
- **New agent provider: Codex (OpenAI) — run `/add-codex`.** Full runtime via `codex app-server` (planning, MCP tools, server-side history, resume). Trunk ships the seams and the skill; the payload installs from the `providers` branch (the skill, the setup picker, or `--step provider-auth codex`). Auth is vault-only — no credential ever enters a container.
- **Setup can now select, install, and authenticate a non-default agent provider.** A provider registry feeds the setup picker, an installer pulls the provider's payload from its branch, a vault auth walkthrough runs (`--step provider-auth`), and the picked provider is set on the first agent (a DB property) before its first spawn. Default (Claude) installs are unaffected — picking Claude changes nothing.
- **New groups inherit an instance-wide default provider.** `DEFAULT_AGENT_PROVIDER` in `.env` (default `claude`) sets which provider newly created agent groups get at creation; provider stays a per-group DB property, overridable via `ncl groups config update --provider` + restart. Existing groups are untouched — no migration, no retroactive flips.
- **Provider choice is explicit per group — no install-wide default.** Provider is a DB property set via `ncl groups config update --provider` + restart; creation is provider-agnostic.
- **Memory migrates via `/migrate-memory`, never at runtime.** Each provider keeps its own store; fresh groups on a surfaces-owning provider see no stale `CLAUDE.*` files. See [docs/provider-migration.md](docs/provider-migration.md).
- **Per-exchange archiving is provider-owned** — the `onExchangeComplete` hook; the markdown writer ships with the codex payload.
- **Container boot failures now say why** — the last stderr lines are logged at `warn` on a non-zero exit instead of a silent crash loop.
-1
View File
@@ -280,7 +280,6 @@ This project uses pnpm with `minimumReleaseAge: 4320` (3 days) in `pnpm-workspac
| [docs/customizing.md](docs/customizing.md) | Short intro to customizing via skills |
| [docs/skills-model.md](docs/skills-model.md) | The skills model in full: recipes, tests, upgrades, migrations |
| [docs/skill-guidelines.md](docs/skill-guidelines.md) | Authoritative checklist for writing a skill |
| [docs/templates.md](docs/templates.md) | Agent templates: what they are, stamping via `ncl groups create --template` + the setup wizard, the OneCLI/MCP-credential model, supported providers, and how to contribute one |
## Container Build Cache
-4
View File
@@ -125,10 +125,6 @@ Instructions here...
- Put code in separate files, not inline in the markdown
- See the [skills standard](https://code.claude.com/docs/en/skills) for all available frontmatter fields
## Templates
Agent templates (reusable bundles of instructions + MCP servers + skills) ship in the separate [`nanocoai/nanoclaw-templates`](https://github.com/nanocoai/nanoclaw-templates) repo, not this one. Contribute them there via PR (its README has the anatomy and checklist). For how templates load and the OneCLI credential model, see [docs/templates.md](docs/templates.md).
## Testing
Test your contribution on a fresh clone before submitting. For skills, run the skill end-to-end and verify it works.
-2
View File
@@ -11,7 +11,6 @@
<a href="https://docs.nanoclaw.dev">docs</a>&nbsp; • &nbsp;
<a href="README_zh.md">中文</a>&nbsp; • &nbsp;
<a href="README_ja.md">日本語</a>&nbsp; • &nbsp;
<a href="README_ko.md">한국어</a>&nbsp; • &nbsp;
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>&nbsp; • &nbsp;
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
</p>
@@ -82,7 +81,6 @@ See [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) for what's different an
- **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
- **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).
## Usage
-1
View File
@@ -11,7 +11,6 @@
<a href="https://docs.nanoclaw.dev">ドキュメント</a>&nbsp; • &nbsp;
<a href="README.md">English</a>&nbsp; • &nbsp;
<a href="README_zh.md">中文</a>&nbsp; • &nbsp;
<a href="README_ko.md">한국어</a>&nbsp; • &nbsp;
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>&nbsp; • &nbsp;
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
</p>
-228
View File
@@ -1,228 +0,0 @@
<p align="center">
<img src="assets/nanoclaw-logo.png" alt="NanoClaw" width="400">
</p>
<p align="center">
에이전트를 각자의 컨테이너에서 안전하게 실행하는 AI 어시스턴트입니다. 가볍고, 쉽게 이해할 수 있으며, 여러분의 필요에 맞게 완전히 커스터마이즈할 수 있도록 만들어졌습니다.
</p>
<p align="center">
<a href="https://nanoclaw.dev">nanoclaw.dev</a>&nbsp; • &nbsp;
<a href="https://docs.nanoclaw.dev">문서</a>&nbsp; • &nbsp;
<a href="README.md">English</a>&nbsp; • &nbsp;
<a href="README_zh.md">中文</a>&nbsp; • &nbsp;
<a href="README_ja.md">日本語</a>&nbsp; • &nbsp;
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>&nbsp; • &nbsp;
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
</p>
---
## NanoClaw를 만든 이유
[OpenClaw](https://github.com/openclaw/openclaw)는 인상적인 프로젝트지만, 제가 이해하지 못하는 복잡한 소프트웨어에 제 삶 전체에 대한 접근 권한을 줬다면 저는 잠을 이루지 못했을 것입니다. OpenClaw는 거의 50만 줄에 달하는 코드, 53개의 설정 파일, 70개 이상의 의존성을 가지고 있습니다. 보안은 진정한 OS 수준의 격리가 아니라 애플리케이션 수준(허용 목록, 페어링 코드)에 의존합니다. 모든 것이 메모리를 공유하는 하나의 Node 프로세스에서 실행됩니다.
NanoClaw는 그와 동일한 핵심 기능을 제공하지만, 이해할 수 있을 만큼 작은 코드베이스로 구현합니다. 하나의 프로세스와 몇 개의 파일뿐입니다. Claude 에이전트는 단순한 권한 검사 뒤가 아니라, 파일시스템이 격리된 각자의 Linux 컨테이너에서 실행됩니다.
## 빠른 시작
```bash
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash nanoclaw.sh
```
`nanoclaw.sh`는 갓 준비한 머신에서 시작해 메시지를 보낼 수 있는 이름 붙은 에이전트까지 안내합니다. 누락된 경우 Node, pnpm, Docker를 설치하고, Anthropic 자격 증명을 OneCLI에 등록하며, 에이전트 컨테이너를 빌드하고, 첫 채널(Telegram, Discord, WhatsApp 또는 로컬 CLI)을 페어링합니다. 어떤 단계가 실패하면 Claude Code가 자동으로 호출되어 원인을 진단하고 중단된 지점부터 재개합니다.
<details>
<summary><strong>NanoClaw v1에서 마이그레이션하시나요?</strong></summary>
기존 v1 설치 옆에 새로운 v2 체크아웃을 만들어 실행하세요:
```bash
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash migrate-v2.sh
```
`migrate-v2.sh`는 v1 설치(형제 디렉터리, 또는 `NANOCLAW_V1_PATH=/path/to/nanoclaw`)를 찾아 상태를 v2 체크아웃으로 마이그레이션한 다음, 판단이 필요한 부분(소유자 시딩, CLAUDE.local.md 정리, 포크 커스터마이징 재적용)을 마무리하기 위해 Claude Code로 `exec`합니다.
이 스크립트는 Claude 세션 내부가 아니라 직접 실행하세요. 결정론적인 부분에서 Node/pnpm 부트스트랩, Docker, OneCLI, 컨테이너 빌드를 위해 대화형 프롬프트와 실제 셸 I/O가 필요합니다.
**무엇을 하는가:** `.env`를 병합하고, `registered_groups`로부터 v2 DB를 시딩하며, 그룹 폴더 + 세션 데이터 + 예약 작업을 복사하고, 선택한 채널 어댑터를 설치하며, 채널 인증 상태(WhatsApp의 Baileys 키스토어 + LID 매핑 포함)를 복사하고, 에이전트 컨테이너를 빌드합니다.
**무엇을 하지 않는가:** 시스템 서비스를 전환하지 않습니다. 프롬프트에서 *"switch to v2"*를 선택하거나, 테스트 후 수동으로 전환하세요. 기존 v1 설치는 그대로 유지됩니다.
무엇이 달라졌는지는 [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md)를, 개발 노트는 [docs/migration-dev.md](docs/migration-dev.md)를 참고하세요.
</details>
## 철학
**이해할 수 있을 만큼 작게.** 하나의 프로세스, 몇 개의 소스 파일, 마이크로서비스 없음. NanoClaw 코드베이스 전체를 이해하고 싶다면 Claude Code에게 안내해 달라고 요청하기만 하면 됩니다.
**격리를 통한 보안.** 에이전트는 Linux 컨테이너에서 실행되며 명시적으로 마운트된 것만 볼 수 있습니다. 명령이 호스트가 아니라 컨테이너 안에서 실행되기 때문에 Bash 접근도 안전합니다.
**개별 사용자를 위해 설계.** NanoClaw는 거대한 단일 프레임워크가 아니라, 각 사용자의 정확한 필요에 맞는 소프트웨어입니다. 비대한 소프트웨어가 되는 대신, NanoClaw는 맞춤형이 되도록 설계되었습니다. 직접 포크를 만들고 Claude Code가 여러분의 필요에 맞게 수정하도록 합니다.
**커스터마이징 = 코드 변경.** 설정의 난립이 없습니다. 다른 동작을 원하시나요? 코드를 수정하세요. 코드베이스가 충분히 작아서 안전하게 변경할 수 있습니다.
**AI 네이티브, 설계상 하이브리드.** 설치와 온보딩 흐름은 최적화된 스크립트 경로로, 빠르고 결정론적입니다. 어떤 단계에 판단이 필요할 때 — 설치 실패, 안내가 필요한 결정, 커스터마이징 등 — 제어권이 Claude Code로 매끄럽게 넘어갑니다. 설정 이후에도 모니터링 대시보드나 디버깅 UI가 없습니다. 채팅으로 문제를 설명하면 Claude Code가 처리합니다.
**기능보다 스킬.** 트렁크는 특정 채널 어댑터나 대체 에이전트 프로바이더가 아니라 레지스트리와 인프라를 제공합니다. 채널(Discord, Slack, Telegram, WhatsApp, …)은 오래 유지되는 `channels` 브랜치에, 대체 프로바이더(OpenCode, Ollama)는 `providers` 브랜치에 있습니다. `/add-telegram`, `/add-opencode` 등을 실행하면 스킬이 여러분이 필요로 하는 모듈만 정확히 포크로 복사합니다. 요청하지 않은 기능은 없습니다.
**최고의 하니스, 최고의 모델.** NanoClaw는 Anthropic의 공식 Claude Agent SDK를 통해 Claude Code를 네이티브로 사용하므로, 최신 Claude 모델과 Claude Code의 전체 도구 세트를 누릴 수 있습니다. 여기에는 자신의 NanoClaw 포크를 직접 수정하고 확장하는 능력도 포함됩니다. 다른 프로바이더는 드롭인 옵션입니다. OpenAI의 Codex는 `/add-codex`(ChatGPT 구독 또는 API 키), OpenRouter·Google·DeepSeek 등은 OpenCode를 통한 `/add-opencode`, 로컬 오픈 웨이트 모델은 `/add-ollama-provider`로 추가합니다. 프로바이더는 에이전트 그룹별로 설정할 수 있습니다.
## 지원 기능
- **멀티 채널 메시징** — WhatsApp, Telegram, Discord, Slack, Microsoft Teams, iMessage, Matrix, Google Chat, Webex, Linear, GitHub, WeChat, 그리고 Resend를 통한 이메일. `/add-<channel>` 스킬로 필요할 때 설치합니다. 하나 또는 여러 개를 동시에 실행할 수 있습니다.
- **유연한 격리** — 완전한 프라이버시를 위해 각 채널을 자체 에이전트에 연결하거나, 대화는 분리하되 메모리는 통합하기 위해 하나의 에이전트를 여러 채널에서 공유하거나, 여러 채널을 하나의 공유 세션으로 묶어 하나의 대화가 여러 채널에 걸쳐 이어지도록 할 수 있습니다. `/manage-channels`로 채널별로 선택하세요. [docs/isolation-model.md](docs/isolation-model.md)를 참고하세요.
- **에이전트별 작업 공간** — 각 에이전트 그룹은 자체 `CLAUDE.md`, 자체 메모리, 자체 컨테이너, 그리고 여러분이 허용한 마운트만 갖습니다. 직접 연결하지 않는 한 경계를 넘는 것은 아무것도 없습니다.
- **예약 작업** — Claude를 실행하고 여러분에게 다시 메시지를 보낼 수 있는 반복 작업
- **웹 접근** — 웹에서 검색하고 콘텐츠를 가져오기
- **컨테이너 격리** — 에이전트는 Docker(macOS/Linux/WSL2)에서 샌드박스화되며, 선택적으로 [Docker Sandboxes](docs/docker-sandboxes.md) 마이크로 VM 격리나 macOS 네이티브 런타임인 Apple Container를 사용할 수 있습니다
- **자격 증명 보안** — 에이전트는 원시 API 키를 절대 보유하지 않습니다. 아웃바운드 요청은 [OneCLI의 Agent Vault](https://github.com/onecli/onecli)를 통해 라우팅되며, 요청 시점에 자격 증명을 주입하고 에이전트별 정책과 속도 제한을 적용합니다.
## 사용법
트리거 단어(기본값: `@Andy`)로 어시스턴트에게 말을 거세요:
```
@Andy 매주 평일 오전 9시에 영업 파이프라인 개요를 보내줘 (내 Obsidian 보관함 폴더에 접근 가능)
@Andy 매주 금요일에 지난 한 주간의 git 히스토리를 검토하고, 내용이 어긋나면 README를 업데이트해줘
@Andy 매주 월요일 오전 8시에 Hacker News와 TechCrunch에서 AI 관련 소식을 모아 브리핑을 보내줘
```
여러분이 소유하거나 관리하는 채널에서는 그룹과 작업을 관리할 수 있습니다:
```
@Andy 모든 그룹에 걸친 예약 작업을 전부 나열해줘
@Andy 월요일 브리핑 작업을 일시 정지해줘
@Andy Family Chat 그룹에 참여해줘
```
## 커스터마이징
NanoClaw는 설정 파일을 사용하지 않습니다. 변경하려면 Claude Code에게 원하는 것을 말하기만 하면 됩니다:
- "트리거 단어를 @Bob으로 바꿔줘"
- "앞으로는 응답을 더 짧고 직접적으로 하도록 기억해줘"
- "내가 좋은 아침이라고 인사하면 맞춤 인사를 추가해줘"
- "매주 대화 요약을 저장해줘"
또는 안내형 변경을 위해 `/customize`를 실행하세요.
코드베이스가 충분히 작아서 Claude가 안전하게 수정할 수 있습니다.
## 기여하기
**기능을 추가하지 마세요. 스킬을 추가하세요.**
새로운 채널이나 에이전트 프로바이더를 추가하고 싶다면 트렁크에 추가하지 마세요. 새 채널 어댑터는 `channels` 브랜치에, 새 에이전트 프로바이더는 `providers` 브랜치에 들어갑니다. 사용자는 `/add-<name>` 스킬로 자신의 포크에 설치하며, 이 스킬은 관련 모듈을 표준 경로로 복사하고, 등록을 연결하며, 의존성을 고정합니다.
이를 통해 트렁크는 순수한 레지스트리이자 인프라로 유지되고, 모든 포크는 가벼운 상태를 유지합니다. 사용자는 요청한 채널과 프로바이더만 얻고 그 외에는 아무것도 얻지 않습니다.
### RFS (Request for Skills)
저희가 보고 싶은 스킬:
**커뮤니케이션 채널**
- `/add-signal` — Signal을 채널로 추가
## 요구 사항
- macOS 또는 Linux (Windows는 WSL2 경유)
- Node.js 20+ 및 pnpm 10+ (설치 프로그램이 누락 시 둘 다 설치합니다)
- [Docker Desktop](https://docker.com/products/docker-desktop) (macOS/Windows) 또는 Docker Engine (Linux)
- `/customize`, `/debug`, 설정 중 오류 복구, 그리고 모든 `/add-<channel>` 스킬을 위한 [Claude Code](https://claude.ai/download)
## 아키텍처
```
메시징 앱 → 호스트 프로세스(라우터) → inbound.db → 컨테이너(Bun, Claude Agent SDK) → outbound.db → 호스트 프로세스(전송) → 메시징 앱
```
하나의 Node 호스트가 세션별 에이전트 컨테이너를 오케스트레이션합니다. 메시지가 도착하면 호스트는 엔티티 모델(사용자 → 메시징 그룹 → 에이전트 그룹 → 세션)을 통해 라우팅하고, 세션의 `inbound.db`에 기록한 뒤 컨테이너를 깨웁니다. 컨테이너 내부의 에이전트 러너는 `inbound.db`를 폴링하고, Claude를 실행하며, 응답을 `outbound.db`에 기록합니다. 호스트는 `outbound.db`를 폴링하여 채널 어댑터를 통해 다시 전송합니다.
세션당 두 개의 SQLite 파일이 있으며 각각 정확히 하나의 작성자만 갖습니다. 교차 마운트 경합이 없고, IPC가 없으며, stdin 파이핑이 없습니다. 채널과 대체 프로바이더는 시작 시 자체 등록됩니다. 트렁크는 레지스트리와 Chat SDK 브리지를 제공하고, 어댑터 자체는 포크별로 스킬을 통해 설치됩니다.
전체 아키텍처 설명은 [docs/architecture.md](docs/architecture.md)를, 3단계 격리 모델은 [docs/isolation-model.md](docs/isolation-model.md)를 참고하세요.
핵심 파일:
- `src/index.ts` — 진입점: DB 초기화, 채널 어댑터, 전송 폴링, 스윕
- `src/router.ts` — 인바운드 라우팅: 메시징 그룹 → 에이전트 그룹 → 세션 → `inbound.db`
- `src/delivery.ts``outbound.db` 폴링, 어댑터를 통한 전송, 시스템 액션 처리
- `src/host-sweep.ts` — 60초 스윕: 정체 감지, 예정 메시지 깨우기, 반복 처리
- `src/session-manager.ts` — 세션 확인, `inbound.db` / `outbound.db` 열기
- `src/container-runner.ts` — 에이전트 그룹별 컨테이너 생성, OneCLI 자격 증명 주입
- `src/db/` — 중앙 DB (사용자, 역할, 에이전트 그룹, 메시징 그룹, 연결, 마이그레이션)
- `src/channels/` — 채널 어댑터 인프라 (어댑터는 `/add-<channel>` 스킬로 설치)
- `src/providers/` — 호스트 측 프로바이더 설정 (`claude`는 기본 내장, 그 외는 스킬 경유)
- `container/agent-runner/` — Bun 에이전트 러너: 폴 루프, MCP 도구, 프로바이더 추상화
- `groups/<folder>/` — 에이전트 그룹별 파일시스템 (`CLAUDE.md`, 스킬, 컨테이너 설정)
## FAQ
**왜 Docker인가요?**
Docker는 크로스 플랫폼 지원(macOS, Linux, 그리고 WSL2 경유 Windows)과 성숙한 생태계를 제공합니다. macOS에서는 더 가벼운 네이티브 런타임인 Apple Container도 지원됩니다. 추가 격리를 위해 [Docker Sandboxes](docs/docker-sandboxes.md)는 각 컨테이너를 마이크로 VM 안에서 실행합니다.
**Linux나 Windows에서 실행할 수 있나요?**
네. Docker가 기본 런타임이며 macOS, Linux, Windows(WSL2 경유)에서 작동합니다. `bash nanoclaw.sh`를 실행하기만 하면 됩니다.
**이것은 안전한가요?**
에이전트는 애플리케이션 수준의 권한 검사 뒤가 아니라 컨테이너에서 실행됩니다. 명시적으로 마운트된 디렉터리만 접근할 수 있습니다. 자격 증명은 컨테이너에 들어가지 않습니다. 아웃바운드 API 요청은 [OneCLI의 Agent Vault](https://github.com/onecli/onecli)를 통해 라우팅되며, 프록시 수준에서 인증을 주입하고 속도 제한과 접근 정책을 지원합니다. 여전히 실행하는 것을 검토해야 하지만, 코드베이스가 충분히 작아서 실제로 검토할 수 있습니다. 전체 보안 모델은 [보안 문서](https://docs.nanoclaw.dev/concepts/security)를 참고하세요.
**왜 설정 파일이 없나요?**
설정의 난립을 원하지 않습니다. 모든 사용자는 일반적인 시스템을 설정하는 대신, 코드가 정확히 원하는 대로 동작하도록 NanoClaw를 커스터마이즈해야 합니다. 설정 파일을 선호한다면 Claude에게 추가해 달라고 할 수 있습니다.
**서드파티나 오픈소스 모델을 사용할 수 있나요?**
네. 지원되는 경로는 `/add-opencode`(OpenCode 설정을 통한 OpenRouter, OpenAI, Google, DeepSeek 등) 또는 `/add-ollama-provider`(Ollama를 통한 로컬 오픈 웨이트 모델)입니다. 둘 다 에이전트 그룹별로 설정할 수 있으므로, 같은 설치 내에서 서로 다른 에이전트가 서로 다른 백엔드에서 실행될 수 있습니다.
일회성 실험의 경우, Claude API 호환 엔드포인트라면 `.env`를 통해서도 작동합니다:
```bash
ANTHROPIC_BASE_URL=https://your-api-endpoint.com
ANTHROPIC_AUTH_TOKEN=your-token-here
```
**문제를 어떻게 디버깅하나요?**
Claude Code에게 물어보세요. "스케줄러가 왜 실행되지 않지?" "최근 로그에 뭐가 있지?" "이 메시지는 왜 응답을 받지 못했지?" 그것이 NanoClaw의 바탕에 깔린 AI 네이티브 접근 방식입니다.
**설정이 왜 작동하지 않나요?**
어떤 단계가 실패하면 `nanoclaw.sh`는 진단하고 재개하기 위해 Claude Code로 넘깁니다. 그래도 해결되지 않으면 `claude`를 실행한 뒤 `/debug`를 실행하세요. Claude가 다른 사용자에게도 영향을 줄 만한 문제를 발견하면, 관련 설정 단계나 스킬에 대한 PR을 열어주세요.
**NanoClaw를 어떻게 제거하나요?**
```bash
bash nanoclaw.sh --uninstall
```
모든 설치는 체크아웃별 ID로 태깅되므로, 제거 프로그램은 해당 사본에 속한 것만 제거합니다: 백그라운드 서비스, 컨테이너와 이미지, 앱 데이터와 로그, 에이전트 파일, 그리고 이 사본의 OneCLI 볼트 에이전트입니다. 공유되는 것 — OneCLI 앱과 여러분의 자격 증명, 머신의 다른 NanoClaw 사본 — 은 그대로 둡니다. 무엇을 발견했는지 정확히 보여주고 그룹별로 확인을 요청합니다. 여러분이 동의하기 전까지는 아무것도 삭제되지 않습니다. 변경 없이 미리 보려면 `--dry-run`을, 프롬프트를 건너뛰려면 `--yes`를 사용하세요. `.env`는 제거 전에 백업됩니다. 마무리하려면 체크아웃 폴더 자체를 삭제하세요.
**어떤 변경이 코드베이스에 받아들여지나요?**
기본 구성에는 보안 수정, 버그 수정, 명확한 개선만 받아들여집니다. 그게 전부입니다.
그 외의 모든 것(새로운 기능, OS 호환성, 하드웨어 지원, 향상)은 스킬로 기여해야 합니다. 채널과 프로바이더 코드는 `channels`/`providers` 레지스트리 브랜치에, 그 외에는 자체 완결형 스킬로 기여합니다. [docs/customizing.md](docs/customizing.md)와 [CONTRIBUTING.md](CONTRIBUTING.md)를 참고하세요.
이를 통해 기본 시스템을 최소한으로 유지하고, 모든 사용자가 원하지 않는 기능을 떠안지 않으면서 자신의 설치를 커스터마이즈할 수 있습니다.
## 커뮤니티
질문이 있나요? 아이디어가 있나요? [Discord에 참여하세요](https://discord.gg/VDdww8qS42).
## 변경 이력
호환성을 깨는 변경 사항은 [CHANGELOG.md](CHANGELOG.md)를, 또는 문서 사이트의 [전체 릴리스 히스토리](https://docs.nanoclaw.dev/changelog)를 참고하세요.
## 라이선스
MIT
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=47894bd5-353b-42fe-bb97-74144e6df0bf" />
-1
View File
@@ -11,7 +11,6 @@
<a href="https://docs.nanoclaw.dev">文档</a>&nbsp; • &nbsp;
<a href="README.md">English</a>&nbsp; • &nbsp;
<a href="README_ja.md">日本語</a>&nbsp; • &nbsp;
<a href="README_ko.md">한국어</a>&nbsp; • &nbsp;
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>&nbsp; • &nbsp;
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
</p>
+12 -12
View File
@@ -5,8 +5,8 @@
"": {
"name": "nanoclaw-agent-runner",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.197",
"@anthropic-ai/sdk": "^0.108.0",
"@anthropic-ai/claude-agent-sdk": "^0.3.170",
"@anthropic-ai/sdk": "^0.100.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"cron-parser": "^5.0.0",
"zod": "^4.0.0",
@@ -19,25 +19,25 @@
},
},
"packages": {
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.197", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.197", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.197", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.197", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.197", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.197", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.197", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.197", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.197" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-XNIi8W1tb+QfMkcK+5kepOC6BsxG8wtupd72H+pIPzIJypVQhHy7FoX+KBMtTRYwtl+5dsjKyABhjWXebeUilw=="],
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.170", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.170", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.170" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-pAvhfk+iTodXZ6RF18Kz7BEUWFjL7EcR3tKuhUNdPpE1NAYCR3mSHGbafi72JsrNwKEDIs7FU31z3fqhwy8QzA=="],
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.197", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jC6WvH5Hr6APTfbMjo4nC6LlyMMqbpCMwiHXIw7/AsQXIHQhZ+cRRMesQlV6UFI1l3O53gLZHzsG9cXwfrPHKw=="],
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.170", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rwfgArIa5WI0QPNqFsRBgvtSI0mrtpynUm0oK6+l6/KX4hcgnYGEzciZR1bOeD9/7sSZlTdIgt+T9alKeZmXcg=="],
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.197", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZQNvGkMrTyatBlHTIQ4w2i2aLBuvq355UP/FDLnVXIH8l23RsL1x/0w9P+dqB7EmY9OZi/cPxSrpskpo+dZWLA=="],
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.170", "", { "os": "darwin", "cpu": "x64" }, "sha512-0e58h8UQMtsQxLGIv9r4foxfBFWKZ7NeDtoplLhuD7EwQonehomw1sBXCch77t/IfUS+q5vQ5zv+fOGmap5nLQ=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.197", "", { "os": "linux", "cpu": "arm64" }, "sha512-pWhQgCtAft4EGM4Zn24HRad1a/k2u6oA+2uM/KCdjehfKtooDiHfMNd1yzXY/n9AEBWP0RHB2Vz3mJ30X2pVAg=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.170", "", { "os": "linux", "cpu": "arm64" }, "sha512-gLbaFqcGppFJQd4DLNV4IXoeahejT/p2/M8bSSvRDbla9GOsBr1AxV5XLRyBn1e7xFGozZIAIQr3+1chp7NJgQ=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.197", "", { "os": "linux", "cpu": "arm64" }, "sha512-VuIGXsLGK/aqSQ0tTBqqPVNzjefWS5SWnK8mlYyQitT4s5UDzHXJm0UZBTGxRtlcS0e2+QAHKwbGBCq1ZKSXjg=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.170", "", { "os": "linux", "cpu": "arm64" }, "sha512-SRYfQcsXlOq+CD/FqkQBTSHbaD++w73GnnO+NUV9adLYrca3kfetRwWT1iguY1cNS0l34dCR3rlzCPq78vg1Jg=="],
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.197", "", { "os": "linux", "cpu": "x64" }, "sha512-AUccrbdcv4Hy/GteP/gYLjG/zDP+fe2BFtDMctEfRFVz40DazYDcOyW1+nIgSTQtxf5jSTAVVf3cNuXB2CZwlw=="],
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.170", "", { "os": "linux", "cpu": "x64" }, "sha512-Xl/m7TaSC3T5IDBdHrZQ9fCQYyDmPELN34CL+MoyPIf7uSmuZnjE9fUOqDh2Rv26JxWssi1M6X+BBvVuKd6Cpg=="],
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.197", "", { "os": "linux", "cpu": "x64" }, "sha512-3Tuy7XhD4UIKE4A4RPmKJcbL7Q/3dcB1hEWQt2lKP7c/DlixeEv+tRzvpnFZKhFX2hy0tkBk3QjkozSAacMC/w=="],
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.170", "", { "os": "linux", "cpu": "x64" }, "sha512-m4+I0qBEk7cxRKS+pL+eoWXbXTFOAo83fQ0tQvap4z/mDMm06IWJtEPoYTaMBwsp32GJWLkHWKbZSBCHZnp2DQ=="],
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.197", "", { "os": "win32", "cpu": "arm64" }, "sha512-Wx8uiAKBenDuL8lWQmrqnX5ppljaH5unQ9cKiCz2/9Kgf09dgnrwbX8n/FhndCZR8PmYw539eWwYVrSVc/bl6w=="],
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.170", "", { "os": "win32", "cpu": "arm64" }, "sha512-IG+8isJNNJKbnnhO7m+PGhfVCg+XoQ/MDxGde5eigFI0WsEfitjuWSWwx82bT9ghxI1aa6qNvI+UPgPcZuo5Fg=="],
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.197", "", { "os": "win32", "cpu": "x64" }, "sha512-ZXJO/VvR3SI4G0gwthWeFXWdHB5RXPu3rtfGRcKZ/YgtDeW17rQ+LZIJTk2ywzbLb8EvlghR5JPgn293hC179Q=="],
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.170", "", { "os": "win32", "cpu": "x64" }, "sha512-7cuqSKbHVItPGVwRbd3A0BEJwcNtc7Fhoh6qHN4C6yrmjSrvdYYx3MLvq/VI768/RoG7mAMDxb+j7WfEfoP9BA=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.108.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-XBnl7Nszpbzg0aLnOCmdBi0bOU5goAsQ/L+NPNiuUPowDj8Mbzx0vlIIc1M79BjIvmw5nUM5G3jbrCBStT/0fQ=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.100.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-cAm3aXm6qAiHIvHxyIIGd6tVmsD2gDqlc2h0R20ijNUzGgVnIN822bit4mKbF6CkuV7qIrLQIPoAepHEpanrQQ=="],
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
+2 -2
View File
@@ -9,8 +9,8 @@
"test": "bun test"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.197",
"@anthropic-ai/sdk": "^0.108.0",
"@anthropic-ai/claude-agent-sdk": "^0.3.170",
"@anthropic-ai/sdk": "^0.100.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"cron-parser": "^5.0.0",
"zod": "^4.0.0"
+1 -60
View File
@@ -4,9 +4,8 @@ import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from '
import { getPendingMessages, markCompleted } from './db/messages-in.js';
import { getUndeliveredMessages } from './db/messages-out.js';
import { formatMessages, extractRouting } from './formatter.js';
import { isCorruptionError, processQuery } from './poll-loop.js';
import { isCorruptionError } from './poll-loop.js';
import { MockProvider } from './providers/mock.js';
import type { AgentQuery, ProviderEvent } from './providers/types.js';
beforeEach(() => {
initTestSessionDb();
@@ -380,64 +379,6 @@ describe('end-to-end with mock provider', () => {
});
});
/**
* Build a one-shot stub query that yields init + a single result event, then
* ends. `pushes` records any follow-ups the loop tried to inject (e.g. the
* re-wrap nudge), so a test can assert the loop did NOT re-hammer.
*/
function makeResultQuery(result: ProviderEvent): { query: AgentQuery; pushes: string[] } {
const pushes: string[] = [];
async function* events(): AsyncGenerator<ProviderEvent> {
yield { type: 'init', continuation: 'sess-1' };
yield result;
}
return {
pushes,
query: {
push: (m: string) => {
pushes.push(m);
},
end: () => {},
events: events(),
abort: () => {},
},
};
}
const ERR_ROUTING = {
platformId: 'chan-1',
channelType: 'discord',
threadId: null,
inReplyTo: 'm1',
};
describe('error result with no <message> envelope', () => {
it('delivers a budget/billing error to the triggering channel and does not nudge', async () => {
const budgetText = 'Spending limit reached. Add your own key at https://example.com/keys';
const { query, pushes } = makeResultQuery({ type: 'result', text: budgetText, isError: true });
await processQuery(query, ERR_ROUTING, ['m1'], 'claude', undefined, 'prompt', undefined);
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(JSON.parse(out[0].content).text).toBe(budgetText);
expect(out[0].platform_id).toBe('chan-1');
expect(out[0].channel_type).toBe('discord');
// No re-wrap nudge — an error result must not re-hammer the gateway.
expect(pushes).toHaveLength(0);
});
it('still nudges (and does not deliver) a normal unwrapped result', async () => {
const { query, pushes } = makeResultQuery({ type: 'result', text: 'bare text, no envelope' });
await processQuery(query, ERR_ROUTING, ['m1'], 'claude', undefined, 'prompt', undefined);
expect(getUndeliveredMessages()).toHaveLength(0);
expect(pushes).toHaveLength(1);
expect(pushes[0]).toContain('was not delivered');
});
});
describe('isCorruptionError', () => {
it('matches the Docker Desktop macOS torn-read symptom', () => {
expect(isCorruptionError('database disk image is malformed')).toBe(true);
+22 -57
View File
@@ -323,7 +323,7 @@ interface QueryResult {
continuation?: string;
}
export async function processQuery(
async function processQuery(
query: AgentQuery,
routing: RoutingContext,
initialBatchIds: string[],
@@ -482,43 +482,28 @@ export async function processQuery(
// at all — either way the turn is finished.
markCompleted(initialBatchIds);
if (event.text) {
const { sent, hasUnwrapped } = dispatchResultText(event.text, routing);
if (sent === 0 && event.isError === true) {
// Non-retryable error turn (e.g. a 403 billing_error) with no
// <message> envelope: deliver the notice instead of dropping it as
// scratchpad, and skip the re-wrap nudge — it would just re-hammer
// the failing gateway turn after turn.
deliverErrorResult(event.text, routing);
notifyExchangeComplete(onExchangeComplete, {
prompt: archivePrompts[0] ?? initialPrompt,
result: event.text,
continuation: queryContinuation ?? initialContinuation,
status: 'error',
});
archivePrompts.shift();
} else {
const willRetryWrapping = hasUnwrapped && !unwrappedNudged;
notifyExchangeComplete(onExchangeComplete, {
prompt: archivePrompts[0] ?? initialPrompt,
result: event.text,
continuation: queryContinuation ?? initialContinuation,
status: hasUnwrapped ? 'undelivered' : 'completed',
});
if (willRetryWrapping) {
unwrappedNudged = true;
const destinations = getAllDestinations();
const names = destinations.map((d) => d.name).join(', ');
query.push(
`<system>Your response was not delivered — it was not wrapped in <message to="name">...</message> blocks. ` +
`All output must be wrapped: use <message to="name"> for content to send, or <internal> for scratchpad. ` +
`Your destinations: ${names}. ` +
`Please re-send your response with the correct wrapping.</system>`,
);
}
// The wrapping-retry result answers the SAME user prompt — keep it
// queued so the retry archives against it, not the nudge text.
if (!willRetryWrapping) archivePrompts.shift();
const { hasUnwrapped } = dispatchResultText(event.text, routing);
const willRetryWrapping = hasUnwrapped && !unwrappedNudged;
notifyExchangeComplete(onExchangeComplete, {
prompt: archivePrompts[0] ?? initialPrompt,
result: event.text,
continuation: queryContinuation ?? initialContinuation,
status: hasUnwrapped ? 'undelivered' : 'completed',
});
if (willRetryWrapping) {
unwrappedNudged = true;
const destinations = getAllDestinations();
const names = destinations.map((d) => d.name).join(', ');
query.push(
`<system>Your response was not delivered — it was not wrapped in <message to="name">...</message> blocks. ` +
`All output must be wrapped: use <message to="name"> for content to send, or <internal> for scratchpad. ` +
`Your destinations: ${names}. ` +
`Please re-send your response with the correct wrapping.</system>`,
);
}
// The wrapping-retry result answers the SAME user prompt — keep it
// queued so the retry archives against it, not the nudge text.
if (!willRetryWrapping) archivePrompts.shift();
} else {
archivePrompts.shift();
}
@@ -572,26 +557,6 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
}
}
/**
* Deliver a turn's text straight to the channel the batch arrived on. Used when
* a turn ends in a provider error (e.g. a non-retryable 403 billing_error) with
* no <message> envelope: the notice would otherwise be dropped as scratchpad.
* This is the same user-facing write the outer catch block does, minus the
* `Error:` prefix the provider's text is already a user-facing message.
*/
function deliverErrorResult(text: string, routing: RoutingContext): void {
log('Error result with no <message> envelope — delivering to channel');
writeMessageOut({
id: generateId(),
in_reply_to: routing.inReplyTo,
kind: 'chat',
platform_id: routing.platformId,
channel_type: routing.channelType,
thread_id: routing.threadId,
content: JSON.stringify({ text }),
});
}
/**
* Parse the agent's final text for <message to="name">...</message> blocks
* and dispatch each one to its resolved destination. Text outside of blocks
@@ -440,13 +440,8 @@ export class ClaudeProvider implements AgentProvider {
if (message.type === 'system' && message.subtype === 'init') {
yield { type: 'init', continuation: message.session_id };
} else if (message.type === 'result') {
// `result` text exists only on subtype:"success"; error subtypes
// (e.g. a non-retryable 403 billing_error) carry their message in
// `errors[]` instead. Surface either so the poll-loop can deliver a
// billing/quota notice to the user rather than dropping the turn.
const m = message as { result?: string; is_error?: boolean; errors?: string[] };
const text = m.result ?? (m.errors && m.errors.length > 0 ? m.errors.join('\n') : null);
yield { type: 'result', text, isError: m.is_error === true };
const text = 'result' in message ? (message as { result?: string }).result ?? null : null;
yield { type: 'result', text };
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'api_retry') {
yield { type: 'error', message: 'API retry', retryable: true };
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'rate_limit_event') {
@@ -125,13 +125,7 @@ export interface AgentQuery {
export type ProviderEvent =
| { type: 'init'; continuation: string }
/**
* A completed turn. `isError` is set when the underlying SDK flagged the
* turn as an error (e.g. a non-retryable Anthropic 403 billing_error). The
* poll-loop uses it to surface the result text to the user instead of
* dropping it as un-wrapped scratchpad, and to skip the re-wrap nudge.
*/
| { type: 'result'; text: string | null; isError?: boolean }
| { type: 'result'; text: string | null }
| { type: 'error'; message: string; retryable: boolean; classification?: string }
| { type: 'progress'; message: string }
/**
+1 -1
View File
@@ -1,5 +1,5 @@
[
{ "name": "vercel", "version": "52.2.1" },
{ "name": "agent-browser", "version": "0.27.1", "onlyBuilt": true },
{ "name": "@anthropic-ai/claude-code", "version": "2.1.197", "onlyBuilt": true }
{ "name": "@anthropic-ai/claude-code", "version": "2.1.170", "onlyBuilt": true }
]
-6
View File
@@ -341,12 +341,6 @@ export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || 'nanoclaw-agent:la
export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '1800000', 10); // 30min default
export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min — keep container alive after last result
export const MAX_CONCURRENT_CONTAINERS = Math.max(1, parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5);
// Per-container resource caps → `docker run --cpus/--memory`. Empty default =
// no flag = unbounded (today's behavior). Opt in to bound a fleet sharing one
// host: CONTAINER_CPU_LIMIT=2, CONTAINER_MEMORY_LIMIT=8g. Swap is a host concern
// (run the host swapless to make --memory a hard cap); not managed here.
export const CONTAINER_CPU_LIMIT = process.env.CONTAINER_CPU_LIMIT || '';
export const CONTAINER_MEMORY_LIMIT = process.env.CONTAINER_MEMORY_LIMIT || '';
export const TRIGGER_PATTERN = new RegExp(`^@${ASSISTANT_NAME}\\b`, 'i');
```
+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
-177
View File
@@ -1,177 +0,0 @@
# Agent Templates
A **template** is a reusable folder you stamp into a working agent group: it
carries the agent's standing instructions, its MCP tool servers, and its skills,
but **no secrets and no provider**. Point `ncl` (or the setup wizard) at one and
you get a configured agent in seconds; you choose the runtime/provider
separately.
Templates are purely additive: no DB migration, no new dependency. **At runtime,
templates are resolved only from a local directory**: `templates/` at the
project root by default (committed but shipped empty), or whatever
`NANOCLAW_TEMPLATES_DIR` points at (a local path only). The setup wizard can also
discover templates from the public registry
([`nanocoai/nanoclaw-templates`](https://github.com/nanocoai/nanoclaw-templates))
and copy a chosen one into your local `templates/` before stamping.
## Using a template
**During install.** `bash nanoclaw.sh` opens the setup wizard. Choose **Template
setup**, then either **NanoClaw template library** (clones the public registry,
copies the template you pick into your local `templates/`) or **Local templates**
(lists what's already in `templates/`). The normal auth step then picks the
runtime, and the wizard stamps and wires your first agent.
**Anytime, via the CLI:**
```bash
ncl groups create --template sales/sdr --name "SDR Agent"
```
This stamps the group but does **not** wire it to a channel. Run
`/manage-channels` (or `ncl wirings create`) afterward, exactly as for a
hand-built group.
### The template ref
`--template <ref>` is a path **relative to the local templates directory**
(`templates/` by default, or `NANOCLAW_TEMPLATES_DIR`). Refs are multi-segment,
e.g. `sales/sdr``templates/sales/sdr`.
For safety the ref must stay inside the templates directory: absolute paths, a
leading `~`, and `../` escapes are rejected. There is no `--source`, no git URL,
and no remote fetch at `ncl` time. Populate `templates/` first (by hand, or via
the setup wizard's library option), then stamp.
`NANOCLAW_TEMPLATES_DIR` may point the library at another **local** directory; it
is never a URL and never changes at runtime.
## What's in a template
The full authoring reference lives in the
[templates repo README](https://github.com/nanocoai/nanoclaw-templates#anatomy-of-a-template).
The short version: only `context/instructions.md` is required; everything else
is optional and defaults sensibly:
```
<template>/
├── context/
│ ├── instructions.md # REQUIRED: the agent's standing persona; marks the folder as a template
│ └── additional_context/ # optional: extra .md files, referenced from instructions.md by relative path
│ └── *.md
├── .mcp.json # optional: MCP servers (command + args), NO secrets
├── skills/<name>/ # optional: one folder per skill (SKILL.md + any references/), copied whole
└── README.md # recommended: per-template docs
```
| Path | Loaded as | Required |
|------|-----------|----------|
| `context/instructions.md` | The agent's persona, prepended to its `CLAUDE.md`/`AGENTS.md` every spawn (system-prompt tier, any provider) | **Yes** |
| `context/**/*.md` (others) | Extra context, copied into the agent's workspace with the same layout relative to `instructions.md` | No |
| `.mcp.json``mcpServers` | MCP tool servers (written verbatim to container config) | No |
| `skills/<name>/` | A skill, auto-triggered by its `description` | No |
Notes:
- **No provider, model, effort, or packages in a template.** Those are set on
the agent later via `ncl groups config update`. The runtime defaults to the
install's configured provider.
- **Keep `instructions.md` focused (under ~200 lines).** It's always in the
agent's prompt, and some providers cap that doc (Codex ~32 KB), so an over-long
persona gets truncated. Put bulk material in `skills/` or extra context files instead.
- Skills are copied into the agent's own skills overlay, keyed to that group,
never shared across groups.
### Referencing extra context files
Extra `.md` files under `context/` (by convention in an `additional_context/`
subfolder) are copied into the agent's workspace preserving their position
relative to `instructions.md` — a template file at
`context/additional_context/pricing.md` is readable by the agent as
`additional_context/pricing.md`, the same relative path you'd use from
`instructions.md` itself. Nothing is injected automatically: the agent only
reads an extra file if `instructions.md` points to it, so reference every file
you ship.
```markdown
Pricing rules live in `additional_context/pricing.md`. Read it before quoting a price.
```
Context files are copied when you stamp, so files added to the template later
won't reach an already-created agent. Re-stamp the same name to update it.
## MCP servers and credentials
**Templates declare MCP servers, not secrets.** `.mcp.json` carries `command` +
`args` only:
```json
{
"mcpServers": {
"hubspot": { "command": "npx", "args": ["-y", "@hubspot/mcp-server"] },
"exa": { "command": "npx", "args": ["-y", "exa-mcp-server"] }
}
}
```
Credentials are held by the **credentials proxy** and injected into outbound
HTTPS calls at the proxy boundary, matched by API host, at request time. The key
never sits in `.mcp.json`, the container env, or chat context. See
[the credentials proxy section in CLAUDE.md](../CLAUDE.md#secrets--credentials--onecli)
for the model.
Two ways a credential gets connected:
1. **Up front.** Register the secret with the credentials proxy (its web UI or
CLI), matched to the service's API host (e.g. `api.example.com`). Matching
credentials are injected automatically, so usually nothing else is needed.
2. **On demand (the common path).** Don't set anything up first. The first time
the agent calls a service with no credential, the API returns **401/403** and
the agent replies with a prefilled connect link for that host. The user opens
it, pastes the key, and asks the agent to retry. The key lands in the
credentials proxy, which injects it on every later call.
### MCP servers that require an env var to boot
Some MCP servers refuse to start unless an env var is *present*, even though the
real credential should come from the credentials proxy, not the env. Because `.mcp.json`'s `env`
block passes through verbatim to the agent's container config, put a **placeholder
value** there to satisfy the boot check:
```json
{
"mcpServers": {
"acme": {
"command": "npx",
"args": ["-y", "@acme/mcp-server"],
"env": { "ACME_API_KEY": "placeholder" }
}
}
}
```
The server starts; its real outbound calls are still authenticated by the
credentials proxy. **Never put a real key in `env`**: a placeholder only, and only when
the server won't boot without one.
### Approval-gating sensitive actions
The credentials proxy can *hold* a credentialed outbound request and require a
human to approve it before it leaves the proxy: enforcement the agent can't talk
around. This is matched on the outbound HTTP request (host + method + path),
configured on the credentials proxy, and answered by NanoClaw (it DMs an approver). The host side is
already wired; see
[the credentialed-approval flow in CLAUDE.md](../CLAUDE.md#requiring-approval-for-credential-use)
and the [`sales/sdr` template README](https://github.com/nanocoai/nanoclaw-templates/blob/main/sales/sdr/README.md)
for a worked example.
## Contributing a template
Templates ship in the separate
[`nanocoai/nanoclaw-templates`](https://github.com/nanocoai/nanoclaw-templates)
repo, not this one. To add one: fork that repo, drop a folder at
`<category>/<template>/` with at least `context/instructions.md`, test it end to
end (copy it under `templates/` and run
`ncl groups create --template <category>/<template> --name Test`), confirm
no secrets are committed, and open a PR. The repo's README has the full anatomy,
category conventions, and checklist.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.1.24",
"version": "2.1.16",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
@@ -32,7 +32,7 @@
"@clack/prompts": "^1.2.0",
"@onecli-sh/sdk": "2.2.1",
"better-sqlite3": "11.10.0",
"chat": "4.29.0",
"chat": "^4.24.0",
"cron-parser": "5.5.0",
"kleur": "^4.1.5"
},
+5 -14
View File
@@ -21,8 +21,8 @@ importers:
specifier: 11.10.0
version: 11.10.0
chat:
specifier: 4.29.0
version: 4.29.0
specifier: ^4.24.0
version: 4.26.0
cron-parser:
specifier: 5.5.0
version: 5.5.0
@@ -609,17 +609,8 @@ packages:
character-entities@2.0.2:
resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
chat@4.29.0:
resolution: {integrity: sha512-KdPfzaie5ivYytyRICTERg5xT+LeCbYefokvNAqTHe92eqkFaoTMXXkSitikxJVWhZIb2YoXF1b9UZHyzSzKzw==}
engines: {node: '>=20'}
peerDependencies:
ai: ^6.0.182
zod: ^3.0.0 || ^4.0.0
peerDependenciesMeta:
ai:
optional: true
zod:
optional: true
chat@4.26.0:
resolution: {integrity: sha512-QToDnIEGpyb8yQA6YLMHOSRK30YVk4RtsyFyuWFYyB2c4jQlyIrSWtwVK7qyvmvqzQp9uDwCdJRAhS8GtCHAGQ==}
chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
@@ -1972,7 +1963,7 @@ snapshots:
character-entities@2.0.2: {}
chat@4.29.0:
chat@4.26.0:
dependencies:
'@workflow/serde': 4.1.0-beta.2
mdast-util-to-string: 4.0.0
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="204k tokens, 102% of context window">
<title>204k tokens, 102% of context window</title>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="195k tokens, 98% of context window">
<title>195k tokens, 98% of context window</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,8 +15,8 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
<text x="26" y="14">tokens</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">204k</text>
<text x="71" y="14">204k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">195k</text>
<text x="71" y="14">195k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+5 -4
View File
@@ -21,6 +21,7 @@ import path from 'path';
import { DATA_DIR } from '../src/config.js';
import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js';
import { updateContainerConfigScalars } from '../src/db/container-configs.js';
import { initDb } from '../src/db/connection.js';
import {
createMessagingGroup,
@@ -123,11 +124,11 @@ async function main(): Promise<void> {
`# ${args.agentName}\n\n` +
`You are ${args.agentName}, a personal NanoClaw agent for ${args.displayName}. ` +
'When the user first reaches out, introduce yourself briefly and invite them to chat. Keep replies concise.',
// The operator's setup pick (NANOCLAW_PICKED_PROVIDER) when set; otherwise
// undefined, so initGroupFilesystem falls back to the instance default and
// stamps it onto the fresh config row.
provider: pickedProvider,
});
// Runtime provider lives on the config row, not the deprecated agent_provider.
if (pickedProvider && pickedProvider !== 'claude') {
updateContainerConfigScalars(ag.id, { provider: pickedProvider });
}
// 3. CLI messaging group + wiring.
let cliMg: MessagingGroup | undefined = getMessagingGroupByPlatform(CLI_CHANNEL, CLI_PLATFORM_ID);
+9 -7
View File
@@ -205,13 +205,15 @@ async function main(): Promise<void> {
} else {
console.log(`Reusing agent group: ${ag.id} (${folder})`);
}
// Seed the config row, stamped with the effective provider: the operator's
// setup pick (NANOCLAW_PICKED_PROVIDER) when this runs inside a setup run,
// otherwise the persisted instance default. Workspace scaffolding is deferred
// to the first spawn (group-init), where the DB-resolved provider decides the
// surface (Claude: CLAUDE.local.md; a surfaces-owning provider: the memory
// scaffold). A reused group keeps its provider (INSERT OR IGNORE).
ensureContainerConfig(ag.id, pickedProvider);
// Ensure the config row exists; defer workspace scaffolding to the first
// spawn (group-init), where the DB-resolved provider decides the surface
// (Claude: CLAUDE.local.md; a surfaces-owning provider: the memory scaffold)
// — so a non-Claude group never gets stale CLAUDE.* files written here.
ensureContainerConfig(ag.id);
// Runtime provider lives on the config row, not the deprecated agent_provider.
if (pickedProvider && pickedProvider !== 'claude') {
updateContainerConfigScalars(ag.id, { provider: pickedProvider });
}
const groupDir = path.resolve(GROUPS_DIR, folder);
fs.mkdirSync(groupDir, { recursive: true });
fs.writeFileSync(
File diff suppressed because it is too large Load Diff
+885
View File
@@ -0,0 +1,885 @@
// 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 the injected Prompter
// 4. mutate — copy/append/env-set, journaled + idempotent
// 5. run — build/test/fetch (+ dep install) via injected exec
// Remove is derived from the journal — no hand-written REMOVE.md.
//
// Inputs + the Prompter make one engine serve three contexts:
// • programmatic → pass `inputs` (var→value); no prompter, runs through fully
// • setup flow → interactive prompter asks the user inline for 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';
// Optional per-prompt UX hints an `nc:prompt`'s attrs carry. Every field is
// trailing-optional, so an existing `async ask(name)` test fake is untouched.
// • flags regex flags for `validate` (e.g. `i` → case-insensitive match)
// • min a minimum length the interactive prompter enforces (re-asks if short)
// • error the validation message the prompter surfaces on a mismatch
// • normalize a deterministic transform applied AT BIND (see `normalizeValue`) to
// BOTH `inputs` and interactive answers: trim | rstrip-slash | lower
export interface PromptOpts {
flags?: string;
min?: number;
error?: string;
normalize?: string;
}
export interface Prompter {
// Return the value, or undefined to DEFER (headless rebuild collects these).
// `validate` is an optional regex (from `nc:prompt … validate:<re>`) the
// interactive prompter enforces, re-asking until the answer matches. `opts`
// carries the trailing-optional PromptOpts (flags/min/error — normalize is the
// engine's, applied at bind, not the prompter's). A fake may ignore both.
ask(varName: string, question: string, secret: boolean, validate?: string, opts?: PromptOpts): Promise<string | undefined>;
// Show an `nc:operator` block to the human operator (a clack note in setup, a
// channel message when a coding agent relays). Absent ⇒ no operator present
// (headless rebuild), so the instructions are simply skipped.
tell?(text: string): Promise<void> | void;
// Ask the operator a yes/no question (used by the driver's reuse-existing
// offer and an `nc:operator gate` barrier). Absent ⇒ no operator present, so
// the offer / barrier is skipped (headless/programmatic apply proceeds).
confirm?(message: string): Promise<boolean>;
// Open a URL in the operator's browser — an `nc:operator open:<url>` deep-link
// (the Azure portal, a Telegram bot chat, a Discord invite). Best-effort and
// trailing-optional: absent ⇒ no display (headless/programmatic), so the open
// is skipped — the URL is already in the rendered operator text for copy-paste.
open?(url: string): Promise<void> | void;
}
// 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>;
}
// The apply-lifecycle hook the setup driver uses to render a per-step spinner.
// The engine fires `stepStart` immediately before it mutates (applyOne) and
// `stepEnd` immediately after — every start has a matching end (including the
// failure path), so a driver-side spinner is never left hanging. `label` is the
// human spinner caption from `stepLabel` — null for an instant/cheap step the
// driver should NOT spin on. Optional everywhere: no reporter ⇒ silent, the
// headless/programmatic apply is unchanged.
export interface StepReporter {
stepStart(e: { kind: string; line: number; label: string | null }): void;
stepEnd(e: { kind: string; line: number; label: string | null; ok: boolean; durationMs: number; error?: string }): void;
}
export type StepStatus = 'skip' | 'apply' | 'needs-input' | 'agent';
export interface PlanStep {
n: number;
kind: string;
line: number;
status: StepStatus;
detail: string;
}
const read = (p: string) => (existsSync(p) ? readFileSync(p, 'utf8') : '');
const has = (root: string, rel: string) => existsSync(join(root, rel));
const VAR_REF = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
const destOf = (line: string) => (line.includes('->') ? line.split('->')[1].trim() : line.trim());
const srcOf = (line: string) => (line.includes('->') ? line.split('->')[0].trim() : line.trim());
function fileHasLine(root: string, rel: string, line: string): boolean {
return read(join(root, rel))
.split('\n')
.some((l) => l.trim() === line.trim());
}
function pkgHasDep(root: string, name: string): boolean {
try {
const pkg = JSON.parse(read(join(root, 'package.json')) || '{}');
return Boolean(pkg.dependencies?.[name] || pkg.devDependencies?.[name]);
} catch {
return false;
}
}
function envKeySet(root: string, key: string): boolean {
return read(join(root, '.env'))
.split('\n')
.some((l) => {
const m = l.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/);
return m !== null && m[1] === key && m[2].trim().length > 0;
});
}
// Does the array-of-objects JSON at `rel` already contain an element whose
// [key] equals `value`? The idempotency probe for json-merge.
function jsonArrayHasKey(root: string, rel: string, key: string, value: unknown): boolean {
try {
const arr = JSON.parse(read(join(root, rel)) || '[]');
return Array.isArray(arr) && arr.some((el) => el !== null && typeof el === 'object' && (el as Record<string, unknown>)[key] === value);
} catch {
return false;
}
}
// Per-directive idempotency check + "what it would do". Read-only.
function selfStatus(d: Directive, root: string): { status: StepStatus; detail: string } {
switch (d.kind) {
case 'copy': {
const dests = d.body.map(destOf);
const missing = dests.filter((p) => !has(root, p));
const from = d.attrs['from-branch'] ? `fetch ${String(d.attrs['from-branch'])}` : '';
return missing.length
? { status: 'apply', detail: `${from}copy ${missing.join(', ')} (absent)` }
: { status: 'skip', detail: `${dests.join(', ')} present` };
}
case 'append': {
const to = String(d.attrs.to ?? '');
const line = d.body[0] ?? '';
return fileHasLine(root, to, line)
? { status: 'skip', detail: `${to} already has the line` }
: { status: 'apply', detail: `add to ${to}: ${line}` };
}
case 'dep': {
const missing = d.body.filter((s) => !pkgHasDep(root, s.slice(0, s.lastIndexOf('@'))));
return missing.length
? { status: 'apply', detail: `install ${missing.join(', ')}` }
: { status: 'skip', detail: `${d.body.join(', ')} present` };
}
case 'run':
return { status: 'apply', detail: `${String(d.attrs.effect ?? 'run')}: ${d.body.join(' && ')}` };
case 'env-set': {
const keys = d.body.map((l) => l.split('=')[0].trim());
const missing = keys.filter((k) => !envKeySet(root, k));
return missing.length
? { status: 'apply', detail: `set ${missing.join(', ')} in .env` }
: { status: 'skip', detail: `${keys.join(', ')} already set` };
}
case 'env-sync':
return { status: 'apply', detail: 'sync .env → data/env/env' };
case 'json-merge': {
const into = String(d.attrs.into ?? '');
const key = String(d.attrs.key ?? '');
let value: unknown;
try {
value = (JSON.parse(d.body.join('\n')) as Record<string, unknown>)[key];
} catch {
return { status: 'agent', detail: `nc:json-merge body is not parseable JSON — an agent applies it from the prose` };
}
return jsonArrayHasKey(root, into, key, value)
? { status: 'skip', detail: `${into} already has ${key}=${JSON.stringify(value)}` }
: { status: 'apply', detail: `merge ${key}=${JSON.stringify(value)} into ${into}` };
}
case 'prompt':
return { status: 'needs-input', detail: '' };
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
// A concise failure hint surfaced to the operator (and the Claude handoff)
// when this step bounces. Defaults to the trimmed prose; an optional
// `on-fail:<token>` attr on the fence narrows it to the single prose line that
// diagnoses the failure. See `failHint`.
hint?: string;
}
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 before the
// prompter, so a caller that has every answer needs no prompter at all and the
// whole skill runs through with no human interaction (fully programmatic apply).
inputs?: Record<string, string>;
// Interactive prompter for any prompt not covered by `inputs`. Optional — omit
// it (with full `inputs`) for a headless run; a prompt with neither defers.
prompter?: Prompter;
// 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;
// Lifecycle hook the setup driver uses to spin on each slow apply step
// (stepStart/stepEnd bracket every applyOne). Absent ⇒ no reporting; the
// headless/programmatic apply runs identically. See `stepLabel`.
reporter?: StepReporter;
}
/**
* 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.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 concise failure hint surfaced to the operator when a directive bounces.
// Defaults to the surrounding prose (so a stripped fence still reads coherently:
// prose-primary, never a leak). An optional `on-fail:<token>` attr on the fence
// narrows it to the single prose LINE that diagnoses this failure — but the attr
// is stripped along with the fence when a skill degrades to prose, so the SAME
// diagnosis must already live in the prose. We enforce that by falling back to
// the full prose when no prose line contains the token, never surfacing a bare
// token the operator would otherwise never see.
function failHint(d: Directive, prose: string): string {
const token = typeof d.attrs['on-fail'] === 'string' ? d.attrs['on-fail'] : undefined;
if (token) {
const needle = token.toLowerCase();
const line = prose
.split('\n')
.map((l) => l.replace(/^#+\s*/, '').trim())
.find((l) => l.length > 0 && l.toLowerCase().includes(needle));
if (line) return line;
}
return prose.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 the driver shows on a per-step spinner or `null` for an
* instant/cheap step (a local file copy, an env write, a json-merge) the driver
* must NOT spin on. An explicit `label:<word>` attr on the fence wins; otherwise
* the caption is the nearest heading above the directive (so the spinner reads
* like the section it's in), falling back to a kind/effect default. The attr
* lives on the directive fence, so it's stripped along with the fence when a
* skill degrades to prose invisible to the agent, never narrated.
*/
export function stepLabel(d: Directive, md: string): string | null {
if (typeof d.attrs.label === 'string') return d.attrs.label;
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
// prompter, means a programmatic `inputs` value and a typed answer land identically.
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 PromptOpts an `nc:prompt`'s attrs carry. Stripped with the fence when a
// skill degrades to prose — invisible to the agent — so a plain re-read still
// reads as a normal question. `min` parses to a number; the rest pass through.
function promptOptsOf(d: Directive): PromptOpts {
const opts: PromptOpts = {};
if (typeof d.attrs.flags === 'string') opts.flags = d.attrs.flags;
if (typeof d.attrs.error === 'string') opts.error = d.attrs.error;
if (typeof d.attrs.normalize === 'string') opts.normalize = d.attrs.normalize;
if (typeof d.attrs.min === 'string' && /^\d+$/.test(d.attrs.min)) opts.min = Number(d.attrs.min);
return opts;
}
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 'env-sync':
mkdirSync(join(root, 'data/env'), { recursive: true });
copyFileSync(join(root, '.env'), join(root, 'data/env/env'));
break;
case 'json-merge': {
const into = String(d.attrs.into);
const key = String(d.attrs.key);
const obj = JSON.parse(d.body.join('\n')) as Record<string, unknown>;
const target = join(root, into);
const arr = JSON.parse(read(target) || '[]') as unknown[];
if (!Array.isArray(arr)) throw new Error(`${into} is not a JSON array`);
const value = obj[key];
// Idempotent: only push when no element already matches on the key.
if (!arr.some((el) => el !== null && typeof el === 'object' && (el as Record<string, unknown>)[key] === value)) {
arr.push(obj);
writeFileSync(target, JSON.stringify(arr, null, 2) + '\n');
journal.push({ op: 'json-merge', path: into, key, value });
}
break;
}
default:
throw new Error(`no handler for nc:${d.kind}`);
}
}
export async function applySkill(skillDir: string, root: string, opts: ApplyOptions): Promise<ApplyResult> {
// Lint (validate()) is the authoring/CI gate, run before a skill ships — NOT
// here. Apply is best-effort: an unknown directive (a typo lint should have
// caught, or one newer than this engine) bounces to an agent, never blocks.
const md = read(join(skillDir, 'SKILL.md'));
const directives = parseDirectives(md);
const exec = opts.exec ?? (() => { throw new Error('no exec provided'); });
const resolveRemote = opts.resolveRemote ?? ((b: string) => defaultResolveRemote(b, root));
const vars = new Map<string, { value: string; secret: boolean }>();
const res: ApplyResult = { applied: [], skipped: [], deferred: [], agentTasks: [], 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;
const prose = proseFor(md, d.line);
res.agentTasks.push({ kind: d.kind, line: d.line, reason, prose, hint: failHint(d, prose) });
};
for (const d of directives) {
// Tracks an in-flight reported step so the catch can always close a matching
// stepEnd (start/end stay balanced even when applyOne throws — the driver's
// spinner is never orphaned). Set only after stepStart 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 promptOpts = promptOptsOf(d);
// Pre-supplied inputs win (fully-programmatic apply); fall back to the
// interactive prompter; still undefined ⇒ defer (headless, no answer).
let val = opts.inputs?.[v];
if (val === undefined) val = await opts.prompter?.ask(v, d.body.join(' '), secret, validate, promptOpts);
if (val === undefined) res.deferred.push(v);
// 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.
else vars.set(v, { value: normalizeValue(val, promptOpts.normalize), secret });
continue;
}
if (d.kind === 'operator') {
// Always collect the human-facing instructions into the result so a
// programmatic caller can relay/output them; also render live when an
// interactive prompter is present. {{vars}} render so a resolved value
// can be shown (throws → deferred if a referenced var is unset).
const text = substitute(d.body.join('\n'), vars);
// open:<url> deep-links the operator to the page the steps describe.
// Resolve it up front so an unresolved {{var}} in the URL defers the
// whole block (consistent with the body) instead of half-rendering it.
const openTarget = typeof d.attrs.open === 'string' ? substitute(d.attrs.open, vars) : undefined;
res.operatorMessages.push(text);
await opts.prompter?.tell?.(text);
// After rendering, open the deep-link (best-effort; absent method ⇒ the
// URL is already in the rendered text for copy-paste — never a crash).
if (openTarget !== undefined) await opts.prompter?.open?.(openTarget);
// A bare `gate` flag turns the block into a human BARRIER: wait on a
// confirm before the following side-effecting directives run (a manifest
// build, a restart), so a manual UI step (the Azure app must exist) is
// finished first. Pure polish — a stripped fence leaves the same prose,
// and a prompter without confirm (headless/programmatic) just proceeds.
if (d.args.includes('gate')) await opts.prompter?.confirm?.('Done with the steps above? Continue when you are ready.');
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 the lifecycle reporter so the driver can
// spin on the slow ones. `label` is null for instant kinds (the driver
// stays silent on those). `inFlight` is set only after stepStart fires.
const label = stepLabel(d, md);
opts.reporter?.stepStart({ 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 });
opts.reporter?.stepEnd({ kind: d.kind, line: d.line, label, ok: true, durationMs: Date.now() - inFlight.at });
inFlight = null;
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 stepStart/stepEnd
// balanced whether the throw becomes a deferred (unresolved input) or a
// bounce (a real failure, handled below).
if (inFlight) {
opts.reporter?.stepEnd({ kind: d.kind, line: d.line, label: inFlight.label, ok: false, durationMs: Date.now() - inFlight.at, error: msg });
}
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}`);
}
+356
View File
@@ -0,0 +1,356 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { parseDirectives, validate, promptVar, resolveChatCoreVersion, lintReferenceFloor } 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
'operator', // credentials: create-app walkthrough (addressed to the operator)
'prompt', // credentials: capture bot token
'prompt', // credentials: capture signing secret
'env-set', // credentials: write captured values to .env
'env-sync', // credentials: sync to container
'operator', // credentials: event-delivery walkthrough
'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(2);
expect(ops[0].body.join('\n')).toMatch(/Create the Slack app/);
expect(ops[0].body.join('\n')).toMatch(/Bot Token Scopes/);
expect(ops[1].body.join('\n')).toMatch(/Event Subscriptions/);
});
it('reads copy as a branch fetch with both files', () => {
const copy = directives.find((d) => d.kind === 'copy')!;
expect(copy.attrs['from-branch']).toBe('channels');
expect(copy.body).toEqual(['src/channels/slack.ts', 'src/channels/slack-registration.test.ts']);
});
it('reads the barrel append target and line', () => {
const append = directives.find((d) => d.kind === 'append')!;
expect(append.attrs.to).toBe('src/channels/index.ts');
expect(append.body).toEqual(["import './slack.js';"]);
});
it('reads the dependency pinned exactly', () => {
const dep = directives.find((d) => d.kind === 'dep')!;
expect(dep.body).toEqual(['@chat-adapter/slack@4.26.0']);
});
it('tags the runs with their effects', () => {
expect(directives.filter((d) => d.kind === 'run').map((d) => d.attrs.effect)).toEqual([
'build',
'test',
'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 handle not', () => {
const prompts = directives.filter((d) => d.kind === 'prompt');
expect(prompts.map(promptVar)).toEqual(['bot_token', 'signing_secret', 'owner_handle']);
expect(prompts[0].args).toContain('secret'); // bot_token
expect(prompts[1].args).toContain('secret'); // signing_secret
expect(prompts[2].args).not.toContain('secret'); // owner_handle — a plain id, not a secret
// The prompt body is the question; it does not mention env at all.
expect(prompts[0].body.join(' ')).toMatch(/Bot User OAuth Token/);
});
it('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', () => {
const envSet = directives.find((d) => d.kind === 'env-set')!;
expect(envSet.body).toEqual(['SLACK_BOT_TOKEN={{bot_token}}', 'SLACK_SIGNING_SECRET={{signing_secret}}']);
});
it('passes validation (well-formed, pinned, every {{var}} captured first)', () => {
expect(validate(directives)).toEqual([]);
});
it('keeps its @chat-adapter pin in sync with our chat core (drift guard)', () => {
const chat = resolveChatCoreVersion(process.cwd());
expect(chat).toMatch(/^\d+\.\d+\.\d+/); // our lockfile resolves a real chat version
expect(validate(directives, { chatVersion: chat })).toEqual([]); // add-slack matches it
});
it('ignores plain (non-nc:) code fences so prose stays the floor', () => {
const withProse = slack + '\n```bash\nrm -rf /\n```\n';
expect(parseDirectives(withProse).map((d) => d.kind)).toEqual(directives.map((d) => d.kind));
});
});
describe('validation catches malformed directives', () => {
it('flags an unpinned dependency and an unknown directive', () => {
const md = ['```nc:dep', '@chat-adapter/slack@latest', '```', '', '```nc:frobnicate', 'x', '```'].join('\n');
const problems = validate(parseDirectives(md));
expect(problems.some((p) => /exact semver/.test(p.message))).toBe(true);
expect(problems.some((p) => /unknown directive/.test(p.message))).toBe(true);
});
it('flags an env-set that references a variable no prompt captured', () => {
const md = ['```nc:env-set', 'SLACK_BOT_TOKEN={{bot_token}}', '```'].join('\n');
const problems = validate(parseDirectives(md));
expect(problems.some((p) => /\{\{bot_token\}\} but no earlier nc:prompt/.test(p.message))).toBe(true);
});
it('flags a @chat-adapter pin that does not match the chat core', () => {
const md = ['```nc:dep', '@chat-adapter/slack@4.27.0', '```'].join('\n');
const problems = validate(parseDirectives(md), { chatVersion: '4.26.0' });
expect(problems.some((p) => /must match the chat package/.test(p.message))).toBe(true);
});
it('accepts a @chat-adapter pin that matches the chat core', () => {
const md = ['```nc:dep', '@chat-adapter/slack@4.26.0', '```'].join('\n');
expect(validate(parseDirectives(md), { chatVersion: '4.26.0' })).toEqual([]);
});
});
describe('json-merge directive', () => {
const codex = ['```nc:json-merge into:container/cli-tools.json key:name', '{ "name": "@openai/codex", "version": "0.138.0" }', '```'].join('\n');
it('parses into/key attrs and the JSON object body', () => {
const [d] = parseDirectives(codex);
expect(d.kind).toBe('json-merge');
expect(d.attrs.into).toBe('container/cli-tools.json');
expect(d.attrs.key).toBe('name');
expect(JSON.parse(d.body.join('\n'))).toEqual({ name: '@openai/codex', version: '0.138.0' });
});
it('passes validation when into + key + a parseable object are all present', () => {
expect(validate(parseDirectives(codex))).toEqual([]);
});
it('flags a missing into:', () => {
const md = ['```nc:json-merge key:name', '{ "name": "x" }', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /requires into:/.test(p.message))).toBe(true);
});
it('flags a missing key:', () => {
const md = ['```nc:json-merge into:container/cli-tools.json', '{ "name": "x" }', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /requires key:/.test(p.message))).toBe(true);
});
it('flags an unparseable body', () => {
const md = ['```nc:json-merge into:f.json key:name', '{ not json', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /parseable JSON object/.test(p.message))).toBe(true);
});
it('flags a body that is an array, not a single object', () => {
const md = ['```nc:json-merge into:f.json key:name', '[{ "name": "x" }]', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /single JSON object/.test(p.message))).toBe(true);
});
it('flags a body missing the match key field', () => {
const md = ['```nc:json-merge into:f.json key:name', '{ "version": "1.0.0" }', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /no "name" field/.test(p.message))).toBe(true);
});
});
describe('append at:<marker> attribute', () => {
it('parses an optional at:<marker> alongside to:', () => {
const md = ['```nc:append to:setup/index.ts at:nanoclaw:setup-steps', " codex: () => import('./codex.js'),", '```'].join('\n');
const [d] = parseDirectives(md);
expect(d.kind).toBe('append');
expect(d.attrs.to).toBe('setup/index.ts');
expect(d.attrs.at).toBe('nanoclaw:setup-steps');
});
it('still validates an append that carries at: (to + a line are all it needs)', () => {
const md = ['```nc:append to:setup/index.ts at:nanoclaw:setup-steps', " codex: () => import('./codex.js'),", '```'].join('\n');
expect(validate(parseDirectives(md))).toEqual([]);
});
});
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 PromptOpts attrs (flags/min/normalize/error/reuse)', () => {
it('parses flags/min/normalize/reuse into attrs alongside the var + secret flag', () => {
const md = [
'```nc:prompt server_url secret validate:^https?:// flags:i min:8 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.min).toBe('8');
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 a non-numeric min:', () => {
const md = ['```nc:prompt u min:lots', 'q', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /min:lots must be a non-negative integer/.test(p.message))).toBe(true);
});
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([]);
}
});
});
describe('operator open: + gate attrs', () => {
it('parses open:<url> into attrs and the bare gate flag into args', () => {
const md = ['```nc:prompt bot', 'Bot?', '```', '```nc:operator gate open:https://t.me/{{bot}}', 'Open @{{bot}}.', '```'].join('\n');
const ds = parseDirectives(md);
const op = ds.find((d) => d.kind === 'operator')!;
expect(op.attrs.open).toBe('https://t.me/{{bot}}'); // the full URL (first colon splits the key, not the scheme)
expect(op.args).toContain('gate'); // bare flag → args, not attrs
expect(validate(ds)).toEqual([]); // {{bot}} defined by the earlier prompt → lints clean
});
it('lints clean when an open: URL interpolates an earlier-captured var (the Discord invite case)', () => {
const md = [
'```nc:run capture:application_id=.id effect:fetch',
'curl -sf https://example/app',
'```',
'```nc:operator open:https://discord.com/oauth2/authorize?client_id={{application_id}}&scope=bot',
'Open the invite link.',
'```',
].join('\n');
expect(validate(parseDirectives(md))).toEqual([]);
});
it('flags an operator open: URL referencing an undefined var', () => {
const md = ['```nc:operator open:https://x/{{nope}}', 'Open it.', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /open:.*references \{\{nope\}\}/.test(p.message))).toBe(true);
});
it('flags an empty operator open:', () => {
const md = ['```nc:operator open:', 'Do a thing.', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /operator open: requires a URL/.test(p.message))).toBe(true);
});
});
+365
View File
@@ -0,0 +1,365 @@
// 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>] [min:<n>]
// [normalize:trim|rstrip-slash|lower] [error:<msg>] [reuse:<ENV_KEY>]
// body: the question → binds {{var}} skip if satisfied
// validate:<re> is a regex the interactive prompter enforces (e.g.
// validate:^xoxb- to require a Slack bot token); `inputs` bypass it.
// flags:<re-flags> are regex flags applied to validate (e.g. flags:i → a
// case-insensitive scheme match). min:<n> is a minimum length the prompter
// enforces (re-asks if shorter; `inputs` bypass it). error:<msg> overrides
// the message shown on a validation miss (single token — no spaces).
// normalize:<how> deterministically transforms the value AT BIND — 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 [gate] [open:<url>] 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).
// open:<url> additionally opens that URL in the operator's browser after
// rendering — a deep-link to the page the steps describe ({{vars}} render,
// e.g. a captured application_id in a Discord invite). gate turns the block
// into a human BARRIER: after rendering, the engine waits on a confirm
// before the following side-effecting directives run (a manifest build, a
// restart), so a manual UI step is finished first. Both are pure polish —
// a stripped fence leaves the same prose, and a prompter without
// open/confirm (headless/programmatic) simply skips them.
// env-set body: `KEY=value` ({{var}} allowed) set-if-absent
// env-sync (no body) `.env` → data/env/env idempotent copy
// json-merge into:<file> key:<field> body: a JSON object push-if-absent
//
// `append` without `at:` adds to EOF; with `at:<marker>` it inserts before the
// `// <<< <marker>` closing line of a dormant marker region (see setup/index.ts).
// `json-merge` reads an array-of-objects JSON file and pushes the body object
// unless an element already has body[key]===element[key] (idempotent by key).
//
// 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`.
//
// 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', 'env-sync', 'json-merge']);
const PROMPT_FLAGS = new Set(['secret']);
export function parseDirectives(markdown: string): Directive[] {
const lines = markdown.split('\n');
const out: Directive[] = [];
let i = 0;
while (i < lines.length) {
const info = lines[i].match(FENCE)?.[1]?.trim();
if (info === undefined) {
i++;
continue;
}
// A fence opens here; consume to its closing fence either way.
let j = i + 1;
const body: string[] = [];
while (j < lines.length && !FENCE.test(lines[j])) {
body.push(lines[j]);
j++;
}
if (info.startsWith('nc:')) {
const [tag, ...rest] = info.split(/\s+/);
const args: string[] = [];
const attrs: Record<string, string | true> = {};
for (const tok of rest) {
const eq = tok.indexOf(':');
if (eq > 0) attrs[tok.slice(0, eq)] = tok.slice(eq + 1);
else args.push(tok);
}
out.push({
kind: tag.slice('nc:'.length),
args,
attrs,
body: body.map((l) => l.trim()).filter(Boolean),
line: i + 1,
});
}
i = j + 1; // skip past the closing fence (directive or plain code block)
}
return out;
}
/** The variable a `prompt` binds (the first positional that isn't a flag). */
export function promptVar(d: Directive): string | undefined {
return d.args.find((a) => !PROMPT_FLAGS.has(a));
}
/**
* 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 (!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`);
}
}
if (typeof d.attrs.min === 'string' && !/^\d+$/.test(d.attrs.min)) {
flag(d, `prompt min:${d.attrs.min} must be a non-negative integer`);
}
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');
if (typeof d.attrs.open === 'string' && d.attrs.open.trim() === '') flag(d, 'operator open: requires a URL');
break;
}
// 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`);
}
// An operator's open:<url> may interpolate earlier-defined {{vars}} (e.g. a
// captured application_id in a Discord invite link); validate them like body refs.
if (d.kind === 'operator' && typeof d.attrs.open === 'string') {
for (const m of d.attrs.open.matchAll(VAR_REF)) {
if (!defined.has(m[1])) flag(d, `open:${d.attrs.open} references {{${m[1]}}} 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)',
}];
}
// 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()) });
// Reference-floor warnings are advisory only — printed for the author, never
// folded into the exit code (a missing ## Troubleshooting is a smell, not a
// gate). Exit stays driven solely by `validate` problems.
const warnings = 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);
}
-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
+45 -58
View File
@@ -12,8 +12,6 @@
* NANOCLAW_AGENT_NAME messaging-channel agent name (consumed by the
* channel flow). The CLI scratch agent is always
* "Terminal Agent".
* NANOCLAW_AGENT_PROVIDER preselect the setup provider and skip the picker
* (for packaged flows). Example: claude.
* NANOCLAW_SKIP comma-separated step names to skip
* (environment|container|onecli|auth|mounts|
* service|cli-agent|timezone|channel|
@@ -32,21 +30,15 @@ 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';
import { offerClaudeOnFailure } from './lib/claude-handoff.js';
import { setPickedProvider } from './lib/picked-provider.js';
import { upsertEnvVar } from './set-env.js';
import {
applyToEnv,
parseFlags,
@@ -66,7 +58,6 @@ import { ensureAnswer, fail, runQuietChild, runQuietStep, spawnQuiet } from './l
import { emit as phEmit } from './lib/diagnostics.js';
import { accentGreen, brandBody, brandBold, brandChip, dimWrap, fitToWidth, fmtDuration, note, wrapForGutter } from './lib/theme.js';
import { isValidTimezone } from '../src/timezone.js';
import { DEFAULT_AGENT_PROVIDER } from '../src/config.js';
const CLI_AGENT_NAME = 'Terminal Agent';
const RUN_START = Date.now();
@@ -346,26 +337,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`);
@@ -377,12 +378,6 @@ async function main(): Promise<void> {
} else {
await runAuthStep();
}
// Persist the pick as the instance-wide default so every future group
// (channel-approved, ncl-created) is created on this provider. Read from
// .env at host start; per-group `ncl groups config update --provider` wins.
// Only after install + auth succeeded — a failed setup must not leave new
// groups defaulting to an unauthenticated runtime.
upsertEnvVar('DEFAULT_AGENT_PROVIDER', agentProvider);
}
if (!skip.has('mounts')) {
@@ -557,20 +552,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 {
@@ -811,7 +810,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;
@@ -820,33 +820,20 @@ 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 })),
...available.map((prov) => ({ value: prov.value, label: prov.label, hint: `${prov.hint} — installs now` })),
];
const preset = process.env.NANOCLAW_AGENT_PROVIDER?.trim().toLowerCase();
if (preset) {
if (!options.some((option) => option.value === preset)) {
throw new Error(`NANOCLAW_AGENT_PROVIDER=${preset} is not available in this NanoClaw install`);
}
setupLog.userInput('agent_provider', preset);
phEmit('agent_provider_chosen', { provider: preset, preset: true });
return preset;
}
// The pick is persisted as the instance default (DEFAULT_AGENT_PROVIDER), so
// pre-select the current default — a re-run Enter-through then preserves it
// instead of silently resetting it to claude. Fall back to claude if the
// persisted default isn't an offered option (e.g. its provider was removed).
const currentDefault = options.some((o) => o.value === DEFAULT_AGENT_PROVIDER)
? DEFAULT_AGENT_PROVIDER
: 'claude';
// The pick installs and authenticates a runtime — it is not an
// install-wide default, so re-runs safely Enter-through on claude (its
// auth flow short-circuits when the secret already exists).
const choice = ensureAnswer(
await brightSelect<string>({
message: 'Which agent runtime should power your assistant?',
options,
initialValue: currentDefault,
initialValue: 'claude',
}),
) as string;
setupLog.userInput('agent_provider', choice);
-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;
}
+227
View File
@@ -0,0 +1,227 @@
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
inputs: { bot_token: 'xoxb-x', signing_secret: 's', owner_handle: 'U1' },
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:U1', // 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.
it('deferWire (Teams): runs install + handoff with gate barriers + portal open, 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,
// A stub prompter so the `nc:operator gate` barriers don't reach a real clack
// confirm (which would hang in CI) and the portal open: doesn't launch a real
// browser — while still asserting both fire, and the gate fires first.
prompter: {
async ask() { return undefined; },
tell() {},
async confirm() { log.push('gate'); return true; },
open: (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: 'sekret',
},
wire: (a) => {
wired.push(a);
return true;
},
});
// install + manifest ran…
expect(log.some((c) => c.includes('teams-manifest-build'))).toBe(true);
// …the Azure portal was opened for the operator…
expect(opened.some((u) => /portal\.azure\.com/.test(u))).toBe(true);
// …a gate barrier fired BEFORE the manifest build (the manifest-before-the-app hazard fix)…
const firstGate = log.indexOf('gate');
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: { bot_token: 'xoxb-x', signing_secret: 's', owner_handle: 'U1' },
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
});
});
+176
View File
@@ -0,0 +1,176 @@
/**
* 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,
prompter: overrides.prompter,
resolveRemote: overrides.resolveRemote,
inputs: overrides.inputs,
skipEffects: overrides.skipEffects,
reporter: overrides.reporter, // undefined ⇒ runSkill's TTY-gated spinner
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 ==="
+1 -1
View File
@@ -37,7 +37,7 @@ if ! grep -q "import './github.js';" src/channels/index.ts; then
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/github@4.29.0
pnpm install @chat-adapter/github@4.26.0
echo "STEP: pnpm-build"
pnpm run build
-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 ==="
+1 -1
View File
@@ -37,7 +37,7 @@ if ! grep -q "import './whatsapp-cloud.js';" src/channels/index.ts; then
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/whatsapp@4.29.0
pnpm install @chat-adapter/whatsapp@4.26.0
echo "STEP: pnpm-build"
pnpm run build
+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';
}
-44
View File
@@ -1,44 +0,0 @@
import { describe, expect, it } from 'vitest';
import { extractClaudeOAuthToken } from './captured-token.js';
// A syntactically valid token: sk-ant-oat + 93 token chars + AA.
const TOKEN = `sk-ant-oat01-${'a'.repeat(90)}AA`;
describe('extractClaudeOAuthToken', () => {
it('extracts the token from clean single-line output (normal terminal)', () => {
const raw = `Login successful.\nYour token:\n${TOKEN}\n`;
expect(extractClaudeOAuthToken(raw)).toBe(TOKEN);
});
// The actual sbx failure shape: the real token wrapped across two lines AND
// the `export CLAUDE_CODE_OAUTH_TOKEN=<token>` placeholder in the same
// capture. The old parser returned null (matched only the first fragment);
// the normalizer must un-wrap the real token and never mistake the
// placeholder for it.
it('extracts the real wrapped token from sbx capture and ignores the placeholder export', () => {
const head = TOKEN.slice(0, 72);
const tail = TOKEN.slice(72);
const raw = `
\x1b[?2026h Long-lived authentication token created successfully!
Your OAuth token (valid for 1 year):
${head}
${tail}
Store this token securely. You won't be able to see it again.
Use this token by setting: export CLAUDE_CODE_OAUTH_TOKEN=<token>
`;
expect(extractClaudeOAuthToken(raw)).toBe(TOKEN);
});
it('returns null for the placeholder env-var line, not a real token', () => {
expect(extractClaudeOAuthToken('export CLAUDE_CODE_OAUTH_TOKEN=<token>\n')).toBeNull();
});
it('returns null when no token is present', () => {
expect(extractClaudeOAuthToken('claude: authentication cancelled\n')).toBeNull();
});
});
-73
View File
@@ -1,73 +0,0 @@
/**
* Parse a provider auth token out of interactive CLI output captured through
* a PTY (`script(1)`).
*
* Secret this module hides: the menagerie of PTY-capture artifacts that
* corrupt an otherwise whitespace-free secret. A real terminal wraps long
* lines, pads with spaces, and interleaves ANSI/control sequences, so a token
* the CLI printed as one string lands in the capture split across lines with
* escape codes embedded. Provider login itself succeeds only our parse of
* the human-oriented output fails.
*
* A normalize step strips the capture artifacts; the extractor matches the
* token shape against the clean string. A future provider adds its own
* extractor here rather than regexing raw `script(1)` output.
*
* Runnable as a CLI for the bash callers that can't import TS:
* tsx setup/lib/captured-token.ts claude <capture-file>
* Prints the token and exits 0, or exits 1 with nothing on stdout.
*/
import fs from 'fs';
import { pathToFileURL } from 'url';
/* eslint-disable no-control-regex -- these patterns exist precisely to match
the ESC/control bytes a PTY capture is full of. */
// CSI sequences (colors, cursor moves): ESC [ , optional private '?' /
// parameter bytes, optional intermediate bytes, one final byte. Stripped
// explicitly because a colour reset mid-token (sk…\x1b[0m…AA) would otherwise
// leave a `[` that breaks the token's character run.
const CSI = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
// Everything <= space (control bytes incl. any stray ESC, CR/LF, tabs, and the
// wrap-padding spaces inserted mid-token) plus DEL. Tokens contain none of these.
const CONTROL_AND_SPACE = /[\x00-\x20\x7f]/g;
/* eslint-enable no-control-regex */
/**
* Collapse PTY-capture artifacts so a whitespace-free secret printed across
* wrapped lines becomes a single contiguous string. Drops ALL whitespace by
* design these captures exist only to recover a token, never prose.
*/
function normalizeCapturedTerminalOutput(raw: string): string {
return raw.replace(CSI, '').replace(CONTROL_AND_SPACE, '');
}
// Claude subscription OAuth tokens: sk-ant-oat<base64url>AA. Bounded length
// keeps a greedy match from running off the end of the token.
const CLAUDE_OAUTH_TOKEN = /sk-ant-oat[A-Za-z0-9_-]{80,500}AA/g;
/**
* Extract the Claude OAuth token from a PTY capture of `claude setup-token`,
* or `null` if none is present. Returns the LAST match setup-token can echo
* partial/intermediate output before the final token. Placeholder strings like
* `<token>` never match (they lack the `sk-ant-oat` prefix).
*/
export function extractClaudeOAuthToken(raw: string): string | null {
const matches = normalizeCapturedTerminalOutput(raw).match(CLAUDE_OAUTH_TOKEN);
return matches ? matches[matches.length - 1] : null;
}
function runCli(argv: string[]): number {
const [provider, file] = argv;
if (provider !== 'claude' || !file) {
process.stderr.write('usage: captured-token.ts claude <capture-file>\n');
return 2;
}
const token = extractClaudeOAuthToken(fs.readFileSync(file, 'utf-8'));
if (!token) return 1;
process.stdout.write(token);
return 0;
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
process.exit(runCli(process.argv.slice(2)));
}
+13 -9
View File
@@ -27,7 +27,6 @@ import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import { extractClaudeOAuthToken } from './captured-token.js';
import { ensureAnswer } from './runner.js';
import { brandBody, fitToWidth, fmtDuration, note } from './theme.js';
@@ -65,15 +64,15 @@ export const STEP_FILES: Record<string, string[]> = {
channel: ['setup/auto.ts'],
verify: ['setup/verify.ts'],
// Channel-specific sub-steps:
'telegram-install': ['setup/add-telegram.sh', 'setup/channels/telegram.ts'],
'telegram-install': ['.claude/skills/add-telegram/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/telegram.ts'],
'telegram-validate': ['setup/channels/telegram.ts'],
'pair-telegram': ['setup/pair-telegram.ts', 'setup/channels/telegram.ts'],
'discord-install': ['setup/add-discord.sh', 'setup/channels/discord.ts'],
'slack-install': ['setup/add-slack.sh', 'setup/channels/slack.ts'],
'discord-install': ['.claude/skills/add-discord/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/discord.ts'],
'slack-install': ['.claude/skills/add-slack/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/slack.ts'],
'slack-validate': ['setup/channels/slack.ts'],
'imessage-install': ['setup/add-imessage.sh', 'setup/channels/imessage.ts'],
'imessage-install': ['.claude/skills/add-imessage/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/imessage.ts'],
'imessage': ['setup/channels/imessage.ts'],
'teams-install': ['setup/add-teams.sh', 'setup/channels/teams.ts'],
'teams-install': ['.claude/skills/add-teams/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/teams.ts'],
'teams-manifest': ['setup/lib/teams-manifest.ts', 'setup/channels/teams.ts'],
'init-first-agent': [
'scripts/init-first-agent.ts',
@@ -208,11 +207,16 @@ export async function ensureClaudeReady(projectRoot: string): Promise<boolean> {
});
if (!isClaudeAuthenticated() && fs.existsSync(tmpfile)) {
const token = extractClaudeOAuthToken(fs.readFileSync(tmpfile, 'utf-8'));
if (token) process.env.CLAUDE_CODE_OAUTH_TOKEN = token;
const raw = fs.readFileSync(tmpfile, 'utf-8');
const stripped = raw
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
.replace(/[\n\r]/g, '');
const matches = stripped.match(/(sk-ant-oat[A-Za-z0-9_-]{80,500}AA)/g);
if (matches) {
process.env.CLAUDE_CODE_OAUTH_TOKEN = matches[matches.length - 1];
}
}
} finally {
// eslint-disable-next-line no-empty -- best-effort temp cleanup
try { fs.unlinkSync(tmpfile); } catch {}
}
+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):');
+11 -12
View File
@@ -1,17 +1,16 @@
/**
* The agent runtime the operator picked in THIS setup run, carried to the
* group-creation child processes over the process boundary.
* The agent runtime the operator picked in THIS setup run.
*
* There is no `--provider` flag in the creation contract provider is a DB
* property of a group. Setup persists the pick two ways: as the install-wide
* default (`DEFAULT_AGENT_PROVIDER` in `.env`, see src/config.ts), which every
* future group inherits at creation via the `ensureContainerConfig` chokepoint;
* and here, in a setup-run-scoped env var, so the FIRST agent created in the
* same run (by `init-first-agent` / `init-cli-agent`, which run as child
* processes) is stamped with the pick before the welcome wakes the container
* without waiting for the host to restart and reload `.env`. `undefined` /
* `'claude'` means no run-scoped pick; the creation scripts then fall back to
* the install-wide default.
* There is no install-wide default provider and no `--provider` in the
* creation contract provider is a DB property of a group. Setup is the one
* orchestrator that knows the operator's pick, so it stashes it here (set once
* at the auth step). The group-creation scripts (`init-first-agent`,
* `init-cli-agent`) run as **child processes**, so the pick is carried over the
* process boundary via an environment variable they inherit; they apply it to
* the group at creation, before the welcome wakes the container. This is the
* only place the value lives a setup-run-scoped global, NOT a persisted
* install default. `undefined` / `'claude'` means the built-in default and no
* provider write at all.
*/
const ENV_KEY = 'NANOCLAW_PICKED_PROVIDER';
+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;
}

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