diff --git a/.claude/skills/add-slack/SKILL.md b/.claude/skills/add-slack/SKILL.md index 0d1974189..078f25b46 100644 --- a/.claude/skills/add-slack/SKILL.md +++ b/.claude/skills/add-slack/SKILL.md @@ -101,6 +101,15 @@ Set up event delivery (needs a public HTTPS URL for port 3000 — ngrok, a Cloud 3. Interactivity & Shortcuts → toggle Interactivity on, set the same Request URL, Save Changes, then reinstall the app when Slack prompts. ``` +## Restart + +Restart the service so it loads the Slack adapter and the credentials you just +stored, and wait for its CLI socket before wiring: + +```nc:run effect:restart +bash setup/lib/restart.sh +``` + ## Connect yourself Wire your own Slack account as the owner so you can talk to the assistant, and diff --git a/scripts/skill-apply.test.ts b/scripts/skill-apply.test.ts index e1d71e53b..cc4078243 100644 --- a/scripts/skill-apply.test.ts +++ b/scripts/skill-apply.test.ts @@ -429,4 +429,17 @@ describe('programmatic apply via inputs', () => { expect(env).toContain('B=fromPrompter'); // prompter filled the gap expect(asked).toEqual(['b']); // 'a' was never asked — it came from inputs }); + + it('skipEffects skips a run the caller owns (effect:restart) but runs the rest', async () => { + writeFileSync( + join(pskill, 'SKILL.md'), + '# restart demo\n\n```nc:run effect:build\npnpm run build\n```\n```nc:run effect:restart\nbash setup/lib/restart.sh\n```\n```nc:run effect:wire\nncl wire\n```\n', + ); + const cmds: string[] = []; + const res = await applySkill(pskill, proot, { inputs: {}, skipEffects: ['restart'], exec: (c) => void cmds.push(c) }); + expect(cmds).toContain('pnpm run build'); + expect(cmds).toContain('ncl wire'); + expect(cmds).not.toContain('bash setup/lib/restart.sh'); // restart owned by the caller → skipped + expect(res.skipped.some((s) => /run restart: owned by the caller/.test(s))).toBe(true); + }); }); diff --git a/scripts/skill-apply.ts b/scripts/skill-apply.ts index 2c5b7cc20..58bd3bd72 100644 --- a/scripts/skill-apply.ts +++ b/scripts/skill-apply.ts @@ -205,6 +205,10 @@ export interface ApplyOptions { // dep/run/branch-fetch; injectable for tests. Returns the command's stdout so // a `run capture:` can bind it into a {{var}} (the twin of `prompt`). exec?: (cmd: string) => string | void | Promise; + // 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. @@ -411,6 +415,11 @@ export async function applySkill(skillDir: string, root: string, opts: ApplyOpti 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; + } 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; } diff --git a/scripts/skill-directives.test.ts b/scripts/skill-directives.test.ts index 5c59cefd3..2a976c19b 100644 --- a/scripts/skill-directives.test.ts +++ b/scripts/skill-directives.test.ts @@ -21,6 +21,7 @@ describe('skill-directives parser, on the converted add-slack', () => { 'env-set', // credentials: write captured values to .env 'env-sync', // credentials: sync to container 'operator', // credentials: event-delivery walkthrough + 'run', // restart: load the adapter + creds, wait for the CLI socket 'prompt', // wire: owner member id 'prompt', // wire: target agent folder 'run', // wire: validate token (auth.test) @@ -58,6 +59,7 @@ describe('skill-directives parser, on the converted add-slack', () => { expect(directives.filter((d) => d.kind === 'run').map((d) => d.attrs.effect)).toEqual([ 'build', 'test', + 'restart', // load adapter + creds before wiring 'fetch', // validate: auth.test 'fetch', // resolve: conversations.open 'wire', // ncl wiring diff --git a/scripts/skill-directives.ts b/scripts/skill-directives.ts index 63b945f7d..23038bef4 100644 --- a/scripts/skill-directives.ts +++ b/scripts/skill-directives.ts @@ -20,12 +20,14 @@ // copy [from-branch:] body: `PATH` (src==dst) or `SRC -> DST` overwrite // append to: [at:] body: line(s) to add skip if present // dep [manager:pnpm] body: `pkg@` line(s) reinstall no-op -// run [effect:build|test|fetch|external|wire] [capture:] re-runnable +// run [effect:build|test|fetch|external|wire|restart] [capture:] 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). capture: binds the -// command's stdout into {{var}} (twin of prompt) — e.g. resolve an id -// from an API and feed it to a later directive. +// 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: 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. // prompt [secret] body: the question → binds {{var}} skip if satisfied // operator body: instructions for the human operator output-only // The SKILL.md is addressed to the coding agent; `operator` delineates the diff --git a/setup/lib/restart.sh b/setup/lib/restart.sh new file mode 100755 index 000000000..e5992ed0a --- /dev/null +++ b/setup/lib/restart.sh @@ -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