Files
nanoclaw/setup/lib/skill-driver.ts
T
gavrielc 8a3444a68c feat(setup): ordinal-suffix repeated spinner captions — (1/2), (2/2)
Two steps under one heading share a heading-derived caption (add-telegram's
build + test both render "5. Build and validate"), which reads as a
stuttered duplicate in the wizard. The driver now pre-computes labelOrdinals
from the parsed document (same pre-parse the gate policy uses) and suffixes
" (i/n)" when a caption repeats — keyed by the directive line the step
events already carry. Pure driver-side presentation; no engine change, no
event-payload change. Solo captions stay unsuffixed; counting is static, so
a runtime-skipped sibling can leave a cosmetic gap in the sequence.

Live stutter observed in the telegram wizard smoke run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 18:36:40 +03:00

478 lines
21 KiB
TypeScript

/**
* The thin generic driver: render a SKILL.md's human I/O through clack and run
* the directive engine. The entire connect+wire procedure now lives in the
* SKILL.md — operator walkthroughs (`nc:operator`), credential prompts
* (`nc:prompt`), the service restart (`nc:run effect:restart`), and the wiring
* (`nc:run effect:wire`, `ncl …`). So the driver is just: ask the prompts
* (`resolveInput`), render the engine's events (`onEvent` — spinners for step
* events, notes + policy for operator blocks), run the engine in document order.
*
* The engine only DECLARES and EMITS (scripts/skill-apply.ts); everything
* presentational lives here, derived from document structure via the shared
* policy module (scripts/skill-policy.ts): the natural-barrier gate confirm,
* the URL offer, the prose-derived validation message.
*/
import { execSync, spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import * as p from '@clack/prompts';
import {
applySkill,
fullyApplied,
normalizeValue,
stepLabel,
type ApplyEvent,
type ApplyResult,
type InputMeta,
type StepOutcome,
} from '../../scripts/skill-apply.js';
import { parseDirectives, promptVar } from '../../scripts/skill-directives.js';
import { extractOfferUrl, gatePolicy } from '../../scripts/skill-policy.js';
import { isHeadless } from '../platform.js';
import { openUrl } from './browser.js';
import { isHelpEscape, offerClaudeHandoff, validateWithHelpEscape } from './claude-handoff.js';
import { startSpinner } from './runner.js';
/**
* Build the clack `validate` callback an `nc:prompt` carries — the interactive
* enforcement of `validate:<re>` (with `flags:`). On a miss the message is
* derived from the QUESTION PROSE, which by authoring convention describes the
* expected shape ("Paste the bot token (looks like `123456:ABC-DEF...`)."), so
* there is no separate authored error string. Returns undefined when the prompt
* declares no regex. Exported so the policy is unit-testable without a TTY.
* Normalization is NOT here: it's deterministic, applied at bind by the engine
* (skill-apply `normalizeValue`), so it lands the same for `inputs` and typed
* answers.
*/
export function promptValidator(
validate: string | undefined,
flags: string | undefined,
question: string,
): ((v: string | undefined) => string | undefined) | undefined {
if (!validate) return undefined;
const re = new RegExp(validate, flags);
return (v) => (re.test((v ?? '').trim()) ? undefined : `That doesn't match the expected format. ${question}`);
}
/**
* Handoff context for the `?` help-escape (Step 8 / mechanism M3). A lone `?` at
* any prompt hands the operator to interactive Claude with this context, then
* re-asks the same prompt. Both fields are optional so a bare
* `clackResolveInput()` (e.g. the standalone CLI below) still works — it just
* hands off with a generic `setup` channel and the prompt's own var name as the
* step.
*/
export interface PrompterContext {
/** Channel this run is wiring (e.g. 'telegram') — surfaced to the handoff. */
channel?: string;
/** Short label for the current setup step — surfaced to the handoff. */
step?: string;
}
/**
* The wizard's `resolveInput` implementation: collect an `nc:prompt` through
* clack (password for secrets, text otherwise; a cancel defers), running the
* interactive re-ask loop against the prompt's declared `validate:`/`flags:`
* (the engine's validate-at-bind is the programmatic backstop, not the UX).
*/
export function clackResolveInput(ctx: PrompterContext = {}): (name: string, meta: InputMeta) => Promise<string | undefined> {
// The `?` help-escape is only meaningful at a real terminal: it hands the
// operator off to an interactive Claude session (stdio inherited). In a
// headless / non-TTY run nobody can type `?` into a clack prompt anyway, and
// we must never spawn an interactive child without a TTY — so it's a no-op
// there (read at ask-time so a re-run picks up a terminal that appears later).
async function ask(name: string, meta: InputMeta): Promise<string | undefined> {
const check = promptValidator(meta.validate, meta.flags, meta.question);
// Wrap the validator so a lone `?` short-circuits format checks and comes
// back as a literal "?" instead of being rejected — we intercept it below.
const guarded = validateWithHelpEscape(check);
// clearOnError wipes a rejected secret so the operator re-pastes cleanly
// (a half-pasted token isn't left masked in the field).
const ans = meta.secret
? await p.password({ message: meta.question, validate: guarded, clearOnError: true })
: await p.text({ message: meta.question, validate: guarded });
if (p.isCancel(ans)) return undefined; // cancelled ⇒ defer
if (isHelpEscape(ans) && process.stdout.isTTY) {
// Operator asked for help: hand off to interactive Claude with this
// prompt's context, then re-ask the same prompt. Recursion is operator-
// bounded — they decide when to stop typing `?`.
await offerClaudeHandoff({
channel: ctx.channel ?? 'setup',
step: ctx.step ?? name,
stepDescription: meta.question,
});
return ask(name, meta);
}
const v = String(ans).trim();
return v.length ? v : undefined;
}
return ask;
}
/**
* The default `confirm` seam: a clack yes/no, TTY-gated exactly like the step
* spinner — a non-TTY run resolves TRUE (proceed), so a headless run with full
* inputs never stalls on a barrier or an offer. A cancel counts as decline.
*/
async function defaultConfirm(message: string): Promise<boolean> {
if (!process.stdout.isTTY) return true;
const ans = await p.confirm({ message });
return ans === true; // cancel ⇒ false
}
/**
* The default `openUrl` seam: best-effort browser open (setup/lib/browser.ts).
* Headless-safe: on a machine with no display we skip the open entirely — the
* URL is already in the rendered operator note for copy-paste.
*/
async function defaultOpenUrl(url: string): Promise<void> {
if (isHeadless()) return;
openUrl(url);
}
/** Mask a credential for display: first 6 + last 4. */
function maskValue(v: string): string {
return v.length <= 12 ? '••••' : `${v.slice(0, 6)}${v.slice(-4)}`;
}
/** Parse `KEY=value` lines from a .env file body. */
function parseEnv(body: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of body.split('\n')) {
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/);
if (m && m[2].trim()) out[m[1]] = m[2].trim();
}
return out;
}
/**
* Offer to reuse credentials already in `.env` so a re-run doesn't re-prompt for
* them. The prompt var → ENV_KEY mapping comes from the skill's own `env-set`
* directives, so this stays generic. Returns the inputs the operator chose to
* reuse (interactive: each is confirmed via the `confirm` seam).
*
* Every offer is PRE-FILTERED through the target prompt's declared
* `normalize`/`validate`/`flags`: an `.env` value that would fail the engine's
* validate-at-bind is silently not offered, so the operator is prompted fresh
* instead of hitting a dead-end (`inputs` win outright at bind — a stale
* credential passed through would reject loudly with no re-ask).
*/
async function reuseFromEnv(
skillDir: string,
projectRoot: string,
alreadyHave: Record<string, string>,
confirm: (message: string) => Promise<boolean>,
): Promise<Record<string, string>> {
let md: string;
try {
md = readFileSync(join(skillDir, 'SKILL.md'), 'utf8');
} catch {
return {};
}
const varToKey = new Map<string, string>();
const promptShape = new Map<string, { validate?: string; flags?: string; normalize?: string }>();
for (const d of parseDirectives(md)) {
// 1st pass: infer var → ENV_KEY from env-set directives (KEY={{var}}).
if (d.kind === 'env-set') {
for (const line of d.body) {
const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/);
if (m) varToKey.set(m[2], m[1]); // var → ENV_KEY
}
}
if (d.kind === 'prompt') {
const v = promptVar(d);
if (!v) continue;
// Record each prompt's declared value shape for the reuse pre-filter.
promptShape.set(v, {
validate: typeof d.attrs.validate === 'string' ? d.attrs.validate : undefined,
flags: typeof d.attrs.flags === 'string' ? d.attrs.flags : undefined,
normalize: typeof d.attrs.normalize === 'string' ? d.attrs.normalize : undefined,
});
// 2nd pass: an explicit `nc:prompt … reuse:<ENV_KEY>` links a prompt to a
// credential a HELPER SCRIPT owns — written by effect:external, not
// nc:env-set (e.g. imessage's Photon IMESSAGE_SERVER_URL /
// IMESSAGE_API_KEY). The env-set inference above can't see those, so the
// prompt states the linkage to regain the masked reuse offer on a re-run.
if (typeof d.attrs.reuse === 'string') varToKey.set(v, d.attrs.reuse);
}
}
let env: Record<string, string> = {};
try {
env = parseEnv(readFileSync(join(projectRoot, '.env'), 'utf8'));
} catch {
return {};
}
const reuse: Record<string, string> = {};
for (const [v, key] of varToKey) {
if (v in alreadyHave) continue; // caller already supplied it
const existing = env[key];
if (!existing) continue;
// Pre-filter: normalize-then-validate, mirroring the engine's bind order. A
// stale credential that no longer matches the declared shape is never
// offered — prompting fresh beats a loud validate-at-bind dead-end.
const shape = promptShape.get(v);
if (shape?.validate && !new RegExp(shape.validate, shape.flags).test(normalizeValue(existing, shape.normalize))) continue;
if (await confirm(`Found an existing ${key} (${maskValue(existing)}). Use it?`)) reuse[v] = existing;
}
return reuse;
}
/**
* Host exec for the engine's run directives. Returns stdout so a
* `run capture:<var>` can bind it. Puts the project's `bin/` on PATH so a bare
* `ncl …` in a wire directive resolves to `bin/ncl` even when it isn't
* symlinked onto the operator's PATH.
*/
export function hostExec(projectRoot: string): (cmd: string) => string {
return (cmd) =>
execSync(cmd, {
cwd: projectRoot,
shell: '/bin/bash',
encoding: 'utf8',
env: { ...process.env, PATH: `${join(projectRoot, 'bin')}:${process.env.PATH ?? ''}` },
});
}
/**
* Streaming host exec for `nc:run effect:step`. Spawns the step through a shell,
* tees its human-facing output to the operator's terminal live (so a pairing code
* card or a QR rendered by the step shows), parses the `=== NANOCLAW SETUP: TYPE
* ===` status blocks, and resolves with the terminal (last STATUS-bearing) block's
* fields so the engine can `capture:<var>=<FIELD>` them. The block protocol mirrors
* setup/lib/runner.ts's StatusStream — a step is just a command that emits blocks.
*/
export function hostExecStream(projectRoot: string): (cmd: string) => Promise<StepOutcome> {
return (cmd) =>
new Promise((resolve) => {
const child = spawn('bash', ['-c', cmd], {
cwd: projectRoot,
env: { ...process.env, PATH: `${join(projectRoot, 'bin')}:${process.env.PATH ?? ''}` },
stdio: ['inherit', 'pipe', 'pipe'],
});
const blocks: Array<{ fields: Record<string, string> }> = [];
let current: { fields: Record<string, string> } | null = null;
let buf = '';
const onChunk = (chunk: Buffer): void => {
buf += chunk.toString('utf8');
let idx: number;
while ((idx = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, idx);
buf = buf.slice(idx + 1);
if (/^=== NANOCLAW SETUP: \S+ ===/.test(line)) { current = { fields: {} }; continue; }
if (line.startsWith('=== END ===')) { if (current) blocks.push(current); current = null; continue; }
if (current) {
const c = line.indexOf(':');
if (c > 0) current.fields[line.slice(0, c).trim()] = line.slice(c + 1).trim();
continue;
}
process.stdout.write(line + '\n'); // operator-facing line (a QR, a code) — show it live
}
};
child.stdout.on('data', onChunk);
child.stderr.on('data', onChunk);
child.on('close', (code) => {
const terminal = [...blocks].reverse().find((b) => b.fields.STATUS) ?? null;
const status = terminal?.fields.STATUS;
resolve({ ok: code === 0 && (status === 'success' || status === 'skipped'), fields: terminal?.fields ?? {} });
});
});
}
// The barrier-confirm wording per gate flavor (§5.1.5). Decline = proceed: the
// barrier is a PAUSE, not a branch — the result is deliberately discarded, and
// the handler never throws for a decline (an operator-event throw would bounce
// and latch the engine's `blocked` gate over later side effects).
const GATE_WORDING: Record<'readiness' | 'completed', string> = {
readiness: 'Ready? The next step starts immediately.',
completed: "Done with the steps above? Continue when you're ready.",
};
/**
* Where several steps under one heading share a spinner caption (a build and a
* test both labelled "5. Build and validate"), suffix an ordinal — "(1/2)",
* "(2/2)" — so consecutive spinners read as distinct steps, not a stutter.
* Keyed by directive line (the step events carry it). Counting is static
* (document order over the parsed directives); a runtime-skipped sibling can
* leave a gap in the rendered sequence — cosmetic only.
*/
export function labelOrdinals(md: string): Map<number, string> {
const byLabel = new Map<string, number[]>();
for (const d of parseDirectives(md)) {
const label = stepLabel(d, md);
if (label === null) continue;
const lines = byLabel.get(label) ?? [];
lines.push(d.line);
byLabel.set(label, lines);
}
const out = new Map<number, string>();
for (const lines of byLabel.values()) {
if (lines.length < 2) continue;
lines.forEach((ln, i) => out.set(ln, ` (${i + 1}/${lines.length})`));
}
return out;
}
/**
* The driver's DEFAULT `onEvent` handler — the whole wizard presentation policy:
*
* • step-start/step-end → a per-step clack spinner (built on runner.ts's
* `startSpinner`), TTY-gated so piped/CI/test runs stay quiet. A null label
* is the engine's instant/renders-its-own-output declaration — no spinner.
* Repeated captions under one heading get an ordinal suffix (labelOrdinals).
* • operator → render the block as a clack note, then the URL offer
* (extractOfferUrl → confirm → openUrl), then the natural-barrier confirm
* when gatePolicy says this block precedes a side-effecting directive.
*
* An INJECTED `onEvent` replaces this handler entirely (the injector owns its
* I/O) — which is why driver-policy tests run this default and inject the
* `confirm`/`openUrl` seams instead.
*/
function defaultOnEvent(
md: string,
confirm: (message: string) => Promise<boolean>,
open: (url: string) => Promise<void>,
): (e: ApplyEvent) => Promise<void> {
const gates = gatePolicy(md);
const ordinals = labelOrdinals(md);
let active: ReturnType<typeof startSpinner> | null = null;
return async (e) => {
if (e.type === 'step-start') {
if (!process.stdout.isTTY || e.label === null) return; // quiet: non-TTY, or instant/cheap step
const base = e.label.replace(/…+$/, '') + (ordinals.get(e.line) ?? '');
active = startSpinner({ running: `${base}`, done: base, failed: `${base} failed` });
return;
}
if (e.type === 'step-end') {
if (!active) return; // never started a spinner for this one
active.stop({ ok: e.ok });
active = null;
return;
}
// operator: note → URL offer → natural-barrier confirm.
p.note(e.text, 'Do this');
const url = extractOfferUrl(e.text);
if (url !== undefined && (await confirm(`Open ${url} in your browser?`))) await open(url);
const gate = gates.get(e.line);
// Decline = proceed (result discarded): the confirm is a pause so a manual
// UI step is finished first — never an abort, never a throw.
if (gate?.needsConfirm) await confirm(GATE_WORDING[gate.flavor]);
};
}
/** Fork-aware registry-branch remote (same resolver setup/channels/slack.ts uses). */
function channelsRemote(projectRoot: string): () => string {
return () =>
execSync('source setup/lib/channels-remote.sh; resolve_channels_remote', {
cwd: projectRoot,
shell: '/bin/bash',
encoding: 'utf8',
}).trim();
}
export interface RunSkillOptions {
projectRoot?: string;
/** Pre-supplied prompt answers — pass them all for a fully programmatic run. */
inputs?: Record<string, string>;
/**
* Resolve a prompt var the caller didn't pre-supply. Defaults to the clack
* collector (`clackResolveInput` — masked secrets, validate re-ask loop, `?`
* help-escape); inject a fake for tests or a relay for a coding agent.
*/
resolveInput?: (name: string, meta: InputMeta) => Promise<string | undefined>;
/** Defaults to `hostExec`. */
exec?: (cmd: string) => string | void;
/** Defaults to `hostExecStream`. Streaming exec for `nc:run effect:step`. */
execStream?: (cmd: string) => Promise<StepOutcome>;
/** Defaults to the fork-aware channels-branch resolver. */
resolveRemote?: (branch: string) => string;
/** Run effects the caller owns (e.g. `['restart']` when it restarts once). */
skipEffects?: string[];
/** Offer to reuse credentials already in `.env` instead of re-prompting. */
reuse?: boolean;
/**
* Consumer for every engine emission (step events + operator blocks). When
* injected it REPLACES the driver's default policy handler ENTIRELY — the
* spinner, the note, the URL offer, and the natural-barrier confirm are all
* the default handler's; the injector owns its I/O. To observe the default
* policy in tests, inject `confirm`/`openUrl` instead.
*/
onEvent?: (e: ApplyEvent) => void | Promise<void>;
/**
* Yes/no seam used by the reuse offer, the natural-barrier gate, and the URL
* offer. Defaults to a clack confirm, TTY-gated like the spinner — a non-TTY
* run resolves true (proceed) so a headless run with full inputs never stalls.
*/
confirm?: (message: string) => Promise<boolean>;
/**
* Browser-open seam for the URL offer, attempted only after a `confirm` yes.
* Defaults to setup/lib/browser.ts `openUrl` (headless ⇒ no-op).
*/
openUrl?: (url: string) => Promise<void>;
/**
* Handoff context for the `?` help-escape (Step 8 / mechanism M3), threaded
* into the default `clackResolveInput`. A lone `?` at any prompt hands the
* operator off to interactive Claude with this `channel` + `step` label, then
* re-asks. Ignored when an explicit `resolveInput` is injected (the injector
* owns its I/O).
*/
channel?: string;
step?: string;
}
/**
* Run a SKILL.md end-to-end through the directive engine with host-wired I/O.
* Returns the engine's result; `fullyApplied(res)` tells the caller whether the
* run completed or left prompts deferred / steps for an agent.
*/
export async function runSkill(skillDir: string, opts: RunSkillOptions = {}): Promise<ApplyResult> {
const projectRoot = opts.projectRoot ?? process.cwd();
const confirm = opts.confirm ?? defaultConfirm;
const open = opts.openUrl ?? defaultOpenUrl;
let inputs = opts.inputs;
// Offer to reuse credentials already in .env before the engine prompts for them.
if (opts.reuse) {
const reused = await reuseFromEnv(skillDir, projectRoot, inputs ?? {}, confirm);
if (Object.keys(reused).length) inputs = { ...inputs, ...reused };
}
// The default operator policy derives from the skill document itself
// (gatePolicy keys on directive lines) — read it once here; the engine reads
// the same file for the actual apply.
let md = '';
try {
md = readFileSync(join(skillDir, 'SKILL.md'), 'utf8');
} catch {
// missing SKILL.md — the engine will produce an empty result anyway
}
return applySkill(skillDir, projectRoot, {
inputs,
resolveInput: opts.resolveInput ?? clackResolveInput({ channel: opts.channel, step: opts.step }),
onEvent: opts.onEvent ?? defaultOnEvent(md, confirm, open),
exec: opts.exec ?? hostExec(projectRoot),
execStream: opts.execStream ?? hostExecStream(projectRoot),
resolveRemote: opts.resolveRemote ?? channelsRemote(projectRoot),
skipEffects: opts.skipEffects,
});
}
// CLI: pnpm exec tsx setup/lib/skill-driver.ts <skillDir> — apply a skill interactively.
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
void (async () => {
const skillDir = process.argv[2];
if (!skillDir) {
console.error('usage: pnpm exec tsx setup/lib/skill-driver.ts <skillDir>');
process.exit(2);
}
p.intro(`Applying ${skillDir}`);
const res = await runSkill(skillDir);
if (fullyApplied(res)) {
p.outro('Done — fully applied.');
} else {
if (res.deferred.length) p.log.warn(`No value yet for: ${res.deferred.join(', ')}`);
for (const t of res.agentTasks) p.log.warn(`Needs an agent (${t.kind}): ${t.reason}`);
p.outro('Applied with gaps — see above.');
}
})();
}