Files
nanoclaw/scripts/skill-apply.ts
T
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

801 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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). Absent ⇒ no operator present, so the offer is skipped.
confirm?(message: string): Promise<boolean>;
}
// 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[];
}
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 };
}
// 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: [] };
// 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);
res.operatorMessages.push(text);
await opts.prompter?.tell?.(text);
res.applied.push(`operator: ${(d.body[0] ?? '').slice(0, 50)}`);
continue;
}
// A run whose effect the caller owns (e.g. restart) is skipped here.
if (d.kind === 'run' && typeof d.attrs.effect === 'string' && opts.skipEffects?.includes(d.attrs.effect)) {
res.skipped.push(`run ${d.attrs.effect}: owned by the caller`);
continue;
}
// Run-health gate: after an earlier bounce, never fire a dangerous side
// effect (a live restart, an interactive pairing/QR step, a wire) on its
// own — bounce it too so the agent runs it from the prose once the upstream
// failure is fixed. (A deferred prompt did NOT set `blocked`, so this only
// trips on a real failure, never a headless rebuild's missing input.)
if (d.kind === 'run' && typeof d.attrs.effect === 'string' && SIDE_EFFECTS.has(d.attrs.effect) && blocked) {
bounce(d, 'skipped: an earlier step did not complete — run this from the prose after fixing it');
continue;
}
const st = selfStatus(d, root);
if (st.status === 'agent') { bounce(d, 'no deterministic handler'); continue; }
if (st.status === 'skip') { res.skipped.push(`${d.kind}: ${st.detail}`); continue; }
// Bracket the real mutation with 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}`);
}