Compare commits

...

5 Commits

Author SHA1 Message Date
Koshkoshinsk cf3ca4dc36 fix(setup): hostExec tees each command to a per-skill raw log
The async hostExec already pipes stderr off the wizard screen; this adds
the level-3 paper trail. runSkill allocates one
logs/setup-steps/NN-skill-<name>.log per apply (default exec only) and
every command appends $ cmd + stdout/stderr — success and failure alike —
so silenced warnings (the Teams CLI libsecret note) stay inspectable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:57:30 +03:00
Koshkoshinsk ee1d2773c2 fix(setup): spawn hostExec so step spinners animate during skill steps
execSync blocks the event loop for the whole command, freezing every clack
ticker in the process — skill steps ran with a dead spinner while earlier
wizard phases (async spawn) animated fine. Spawn + promise keeps the loop
free; the engine's exec seam already awaits. The failure shape is unchanged:
`exit <code>: <first stderr line>` first, full stderr below.

Also retitles operator notes "Do this" -> "Your turn".
2026-07-07 14:40:32 +03:00
Koshkoshinsk 405643f8a7 fix(setup): SSF-002 follow-up — step children: silence logger info, force clack colors
QA on the VM showed two leaks in the effect:step tee: the host logger's
INFO lines (it always emits ANSI and defaults to info) landed on the
wizard screen, and the child's clack card drew colorless because
picocolors disables ANSI on a piped stdout, clashing with the wizard
theme.

hostExecStream now spawns steps with LOG_LEVEL=warn (operator-set level
wins; warnings/errors still pass) and FORCE_COLOR=1 when the operator's
terminal is a real TTY.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HB8rQxJN7itcFhNYVDntpm
2026-07-06 16:20:59 +03:00
Koshkoshinsk 1a3c3eaf9b fix(setup): wizard UX — pairing card, either/or selects, quiet bounces (SSF-002/003/004)
SSF-002 — the telegram pairing card renders via clack's static note/log
primitives instead of bare console.log. hostExecStream tees non-status
lines verbatim, and static clack output is just lines — only
interactive/animated widgets need the TTY the piped child doesn't have.

SSF-003 — an either/or nc:prompt (validate:^(a|b)$) renders as an
arrow-key p.select; the options come from the validate regex itself
(literalChoices), so no grammar addition and zero skill edits. Prefixes
and format unions keep the text prompt. The `?` help-escape doesn't
apply to selects — every choice is valid and self-describing.

SSF-004 — a bounced step no longer dumps a raw stacktrace or the skill's
reference prose into the wizard. hostExec recomposes failures as
`exit <code>: <first stderr line>` (full stderr kept below for the
agentTask reason an agent fixes from); run-channel-skill shows one line
per bounce and writes the full text to a logs/setup-steps raw log, which
fail() forwards to the Claude handoff.

The directive engine (scripts/skill-apply.ts, skill-directives.ts) is
untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HB8rQxJN7itcFhNYVDntpm
2026-07-06 16:06:26 +03:00
Koshkoshinsk 77a19a0c97 fix(skills): restore bot-event subscription in add-slack Socket Mode path (SSF-001)
'Subscribe to bot events' is shared by both delivery modes — only the
Request URL is webhook-specific. 2242f6c mis-sorted it into the webhook
operator block, so Socket Mode installs connected the socket but Slack
never pushed events: welcome DM sent, every reply dead on arrival.

Adds the Event Subscriptions step to the socket block (after Socket Mode
is enabled, so the page doesn't demand a Request URL) and corrects the
false "Socket Mode skips all of this" line. Audited the other when:-split
skills (imessage, whatsapp, teams) — no other mis-sorted shared steps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HB8rQxJN7itcFhNYVDntpm
2026-07-06 15:49:29 +03:00
5 changed files with 210 additions and 55 deletions
+5 -3
View File
@@ -83,7 +83,8 @@ Create the Slack app (Socket Mode):
3. App Home → enable the Messages Tab, and check "Allow users to send Slash commands and messages from the messages tab."
4. Basic Information → App-Level Tokens → "Generate Token and Scopes" → add the connections:write scope → copy the token (starts with xapp-).
5. Socket Mode → toggle "Enable Socket Mode" on.
6. Install to Workspace, then copy the Bot User OAuth Token (starts with xoxb-).
6. Event Subscriptions → toggle "Enable Events" on, then under "Subscribe to bot events" add: message.channels, message.groups, message.im, app_mention. Save Changes. (No Request URL is needed in Socket Mode.)
7. Install to Workspace, then copy the Bot User OAuth Token (starts with xoxb-).
```
For webhook delivery, tell the user:
@@ -122,8 +123,9 @@ SLACK_SIGNING_SECRET={{signing_secret}}
With webhook delivery, the bridge serves port 3000 at `/webhook/slack`
automatically; to receive replies, that port must be reachable from the internet
and registered with Slack (Socket Mode skips all of this — events arrive over
the socket as soon as the service restarts). Tell the user:
and registered with Slack as the Request URL (Socket Mode needs no public URL —
with the bot events subscribed above, events arrive over the socket as soon as
the service restarts). Tell the user:
```nc:operator when:connection=webhook
Set up event delivery (needs a public HTTPS URL for port 3000 — ngrok, a Cloudflare Tunnel, or a reverse proxy on a VPS):
+19 -6
View File
@@ -15,9 +15,12 @@
* So the wire lives in exactly one place (init-first-agent) and is never
* duplicated across channel skills.
*/
import { writeFileSync } from 'node:fs';
import * as p from '@clack/prompts';
import { firstFailureHint, fullyApplied } from '../../scripts/skill-apply.js';
import * as setupLog from '../logs.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';
@@ -137,12 +140,21 @@ export async function runChannelSkill(
});
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);
// A bounced reason can carry a full stderr dump (a Node stacktrace). The
// terminal gets ONE line per bounce — the first line, which hostExec
// composes as `exit <code>: <first stderr line>` — and the full text goes
// to a raw step log, written only when there's actually more than one line
// to keep (SSF-004; the reference prose is deliberately not dumped either).
let rawLog: string | undefined;
if (res.agentTasks.some((t) => t.reason.includes('\n'))) {
rawLog = setupLog.stepRawLog(`${channel}-install-bounce`);
writeFileSync(rawLog, res.agentTasks.map((t) => `## ${t.kind} (line ${t.line})\n${t.reason}\n`).join('\n'));
}
for (const t of res.agentTasks) {
const lines = t.reason.split('\n').map((l) => l.trim()).filter(Boolean);
const more = lines.length > 1 ? ` (+${lines.length - 1} more lines in ${rawLog})` : '';
p.log.warn(`Needs an agent (${t.kind}): ${lines[0] ?? t.reason}${more}`);
}
// 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
@@ -152,6 +164,7 @@ export async function runChannelSkill(
`${channel}-install`,
diag?.headline ?? `Couldn't finish setting up ${channel}.`,
diag?.hint ?? 'See logs/setup-steps/ for details, then retry setup.',
rawLog,
);
}
+79 -6
View File
@@ -3,7 +3,7 @@ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from '
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runSkill, hostExec, hostExecStream, labelOrdinals, promptValidator, clackResolveInput, type RunSkillOptions } from './skill-driver.js';
import { runSkill, hostExec, hostExecStream, labelOrdinals, literalChoices, promptValidator, clackResolveInput, type RunSkillOptions } from './skill-driver.js';
import { fullyApplied, type ApplyEvent } from '../../scripts/skill-apply.js';
// Shared test state for the clack + claude-handoff mocks (hoisted so the vi.mock
@@ -15,6 +15,7 @@ const ce = vi.hoisted(() => ({
handoffSpy: vi.fn(async (_ctx: { channel: string; step: string; stepDescription: string }) => true),
answers: [] as string[],
lastValidate: { fn: undefined as undefined | ((v: string) => string | Error | void | undefined) },
lastSelectOptions: { values: undefined as undefined | string[] },
}));
// Keep isHelpEscape + validateWithHelpEscape real (clackResolveInput uses them);
@@ -32,7 +33,11 @@ vi.mock('@clack/prompts', async (importActual) => {
ce.lastValidate.fn = o?.validate;
return ce.answers.shift() ?? '';
};
return { ...actual, text: vi.fn(fromQueue), password: vi.fn(fromQueue) };
const fromQueueSelect = async (o: { options: Array<{ value: string }> }): Promise<string> => {
ce.lastSelectOptions.values = o.options.map((x) => x.value);
return ce.answers.shift() ?? o.options[0].value;
};
return { ...actual, text: vi.fn(fromQueue), password: vi.fn(fromQueue), select: vi.fn(fromQueueSelect) };
});
// A small SKILL.md exercising the three things the driver wires: an operator
@@ -113,18 +118,39 @@ describe('thin skill driver', () => {
expect(starts).toEqual([{ kind: 'run', label: 'Wire' }]);
});
it('hostExec puts the project bin/ on PATH so a bare command resolves to it', () => {
it('hostExec puts the project bin/ on PATH so a bare command resolves to it', async () => {
const root = mkdtempSync(join(tmpdir(), 'driver-bin-'));
mkdirSync(join(root, 'bin'));
writeFileSync(join(root, 'bin/greet'), '#!/usr/bin/env bash\necho hi-from-bin\n');
chmodSync(join(root, 'bin/greet'), 0o755);
const out = hostExec(root)('greet'); // bare name, not ./bin/greet
const out = await hostExec(root)('greet'); // bare name, not ./bin/greet
expect(String(out).trim()).toBe('hi-from-bin');
});
it('hostExec returns stdout so a capture run can bind it', () => {
it('hostExec returns stdout so a capture run can bind it', async () => {
const root = mkdtempSync(join(tmpdir(), 'driver-cap-'));
expect(String(hostExec(root)('echo D0CHANNEL')).trim()).toBe('D0CHANNEL');
expect(String(await hostExec(root)('echo D0CHANNEL')).trim()).toBe('D0CHANNEL');
});
it('hostExec recomposes a failure as `exit <code>: <first stderr line>` with the full stderr kept below', async () => {
const root = mkdtempSync(join(tmpdir(), 'driver-fail-'));
const run = (): Promise<string> => hostExec(root)('echo "boom: first line" >&2; echo "stack line two" >&2; exit 7');
await expect(run).rejects.toThrow(/^exit 7: boom: first line/); // one-line consumers read this
await expect(run).rejects.toThrow(/stack line two/); // full stderr survives for the agentTask reason
});
it('hostExec tees each command + stdout/stderr to the raw log, success and failure alike', async () => {
const root = mkdtempSync(join(tmpdir(), 'driver-tee-'));
const rawLog = join(root, 'raw.log');
const exec = hostExec(root, rawLog);
await exec('echo out-line; echo warn-line >&2');
await expect(exec('echo dying-gasp >&2; exit 3')).rejects.toThrow(/exit 3/);
const log = readFileSync(rawLog, 'utf8');
expect(log).toContain('$ echo out-line; echo warn-line >&2');
expect(log).toContain('out-line');
expect(log).toContain('warn-line'); // stderr captured, not echoed to the wizard
expect(log).toContain('$ echo dying-gasp >&2; exit 3');
expect(log).toContain('dying-gasp'); // the failing command's output survives too
});
it('hostExecStream runs a step and captures the terminal status block fields (for effect:step)', async () => {
@@ -136,6 +162,20 @@ describe('thin skill driver', () => {
expect(out.fields.PLATFORM_ID).toBe('telegram:42');
});
it('hostExecStream children run with LOG_LEVEL=warn — host logger info noise stays off the wizard', async () => {
const root = mkdtempSync(join(tmpdir(), 'driver-loglevel-'));
const prev = process.env.LOG_LEVEL;
delete process.env.LOG_LEVEL; // simulate an operator who didn't set one
try {
const out = await hostExecStream(root)(
'echo "=== NANOCLAW SETUP: ENV ==="; echo "STATUS: success"; echo "LVL: $LOG_LEVEL"; echo "=== END ==="',
);
expect(out.fields.LVL).toBe('warn');
} finally {
if (prev !== undefined) process.env.LOG_LEVEL = prev;
}
});
function reuseScratch(): { root: string; skill: string } {
const root = mkdtempSync(join(tmpdir(), 'reuse-'));
const skill = mkdtempSync(join(tmpdir(), 'reuse-skill-'));
@@ -379,6 +419,18 @@ describe('thin skill driver', () => {
}
});
it('clackResolveInput renders an either/or validate as an arrow-key select over the literal choices', async () => {
ce.answers = ['webhook'];
ce.lastSelectOptions.values = undefined;
const ans = await clackResolveInput()('connection', {
question: 'How should Slack deliver events?',
secret: false,
validate: '^(socket|webhook)$',
});
expect(ans).toBe('webhook');
expect(ce.lastSelectOptions.values).toEqual(['socket', 'webhook']); // the options came from the regex
});
it('clackResolveInput passes a normal answer straight through — no handoff', async () => {
ce.handoffSpy.mockClear();
ce.answers = ['just-a-token'];
@@ -402,6 +454,27 @@ describe('thin skill driver', () => {
});
});
// An either/or `nc:prompt` renders as a select — the choices come from the
// validate regex itself (no grammar addition). Only a fully-anchored
// pure-literal alternation qualifies; anything with real regex syntax stays a
// text prompt (SSF-003).
describe('literalChoices (either/or prompt → select)', () => {
it('extracts the choices from a pure-literal alternation', () => {
expect(literalChoices('^(socket|webhook)$')).toEqual(['socket', 'webhook']);
expect(literalChoices('^(qr|pairing-code)$')).toEqual(['qr', 'pairing-code']);
expect(literalChoices('^(SingleTenant|MultiTenant)$')).toEqual(['SingleTenant', 'MultiTenant']);
});
it('leaves prefixes, format unions, and non-alternations as text prompts', () => {
expect(literalChoices('^xoxb-')).toBeNull(); // unanchored prefix
expect(literalChoices('^https?://')).toBeNull(); // regex metachars
expect(literalChoices('^(\\+\\d{8,15}|[^\\s@]+@[^\\s@]+\\.[^\\s@]+)$')).toBeNull(); // imessage phone|email union
expect(literalChoices('^[0-9a-zA-Z-]+$')).toBeNull(); // char class, no alternation
expect(literalChoices('^(solo)$')).toBeNull(); // one option is not a choice
expect(literalChoices(undefined)).toBeNull();
});
});
// Two steps under one heading share a spinner caption (build + test both read
// "Build and validate") — the ordinal suffix marks them as a sequence, not a
// stuttered duplicate. Solo captions stay unsuffixed.
+91 -18
View File
@@ -13,8 +13,8 @@
* 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 { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
import { basename, join } from 'node:path';
import * as p from '@clack/prompts';
@@ -30,6 +30,7 @@ import {
} from '../../scripts/skill-apply.js';
import { parseDirectives, promptVar } from '../../scripts/skill-directives.js';
import { extractOfferUrl, gatePolicy } from '../../scripts/skill-policy.js';
import * as setupLog from '../logs.js';
import { isHeadless } from '../platform.js';
import { openUrl } from './browser.js';
import { isHelpEscape, offerClaudeHandoff, validateWithHelpEscape } from './claude-handoff.js';
@@ -56,6 +57,19 @@ export function promptValidator(
return (v) => (re.test((v ?? '').trim()) ? undefined : `That doesn't match the expected format. ${question}`);
}
/**
* The literal alternatives of a fully-anchored pure-literal alternation —
* `^(socket|webhook)$` → ['socket', 'webhook'] — or null for anything else
* (an unanchored prefix like `^xoxb-`, a format union with real regex syntax
* like imessage's `^(\+\d{8,15}|…)$`). This is what lets an either/or
* `nc:prompt` render as an arrow-key select with no grammar addition: the
* validate regex already enumerates the choices. Exported for tests.
*/
export function literalChoices(validate: string | undefined): string[] | null {
const m = validate?.match(/^\^\(([A-Za-z0-9_-]+(?:\|[A-Za-z0-9_-]+)+)\)\$$/);
return m ? m[1].split('|') : null;
}
/**
* 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
@@ -73,9 +87,10 @@ export interface PrompterContext {
/**
* 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).
* clack (password for secrets, an arrow-key select for an either/or validate
* regex, 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
@@ -90,9 +105,15 @@ export function clackResolveInput(ctx: PrompterContext = {}): (name: string, met
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 });
// An either/or prompt renders as an arrow-key select — the options come
// straight from the validate regex (literalChoices). No re-ask loop and no
// `?` help-escape there: every choice is valid and self-describing.
const choices = meta.secret ? null : literalChoices(meta.validate);
const ans = choices
? await p.select({ message: meta.question, options: choices.map((c) => ({ value: c, label: c })) })
: 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
@@ -224,14 +245,45 @@ async function reuseFromEnv(
* `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.
*
* Async (spawn, not execSync) so the step spinner keeps animating: a sync exec
* blocks the event loop for the whole command and freezes every ticker in the
* process. A failure rejects with the FIRST line as the actionable summary —
* `exit <code>: <first stderr line>` — and the full stderr kept below, so
* one-line consumers (run-channel-skill's bounce warn) stay readable while the
* agentTask reason an agent fixes from still carries everything.
*
* Non-step effects are captured-output steps — the spinner is the only UI, and
* stderr is piped, never echoed (a chatty tool's warnings don't belong on the
* wizard screen). When `rawLog` is given, every command's stdout+stderr is
* appended there (level 3, like runner.ts's per-step raw logs) so the silenced
* noise stays inspectable.
*/
export function hostExec(projectRoot: string): (cmd: string) => string {
export function hostExec(projectRoot: string, rawLog?: string): (cmd: string) => Promise<string> {
const tee = (cmd: string, stdout: string, stderr: string): void => {
if (!rawLog) return;
const body = [stdout, stderr].filter(Boolean).join('');
appendFileSync(rawLog, `$ ${cmd}\n${body}${body && !body.endsWith('\n') ? '\n' : ''}\n`);
};
return (cmd) =>
execSync(cmd, {
cwd: projectRoot,
shell: '/bin/bash',
encoding: 'utf8',
env: { ...process.env, PATH: `${join(projectRoot, 'bin')}:${process.env.PATH ?? ''}` },
new Promise((resolve, reject) => {
const child = spawn('bash', ['-c', cmd], {
cwd: projectRoot,
env: { ...process.env, PATH: `${join(projectRoot, 'bin')}:${process.env.PATH ?? ''}` },
stdio: ['ignore', 'pipe', 'pipe'],
});
let out = '';
let err = '';
child.stdout.on('data', (c: Buffer) => { out += c.toString('utf8'); });
child.stderr.on('data', (c: Buffer) => { err += c.toString('utf8'); });
child.on('error', reject);
child.on('close', (code) => {
tee(cmd, out, err);
if (code === 0) return resolve(out);
const stderr = err.trim();
const head = stderr.split('\n').map((l) => l.trim()).find(Boolean) ?? 'command failed';
reject(new Error(`exit ${code ?? '?'}: ${head}${stderr ? `\n${stderr}` : ''}`));
});
});
}
@@ -248,7 +300,20 @@ export function hostExecStream(projectRoot: string): (cmd: string) => Promise<St
new Promise((resolve) => {
const child = spawn('bash', ['-c', cmd], {
cwd: projectRoot,
env: { ...process.env, PATH: `${join(projectRoot, 'bin')}:${process.env.PATH ?? ''}` },
env: {
...process.env,
PATH: `${join(projectRoot, 'bin')}:${process.env.PATH ?? ''}`,
// A step renders curated operator UI (a code card, a QR) — the host
// logger's info noise doesn't belong on the wizard screen, and it
// always emits ANSI so it can't be filtered by stream. Warnings and
// errors still pass. An operator-set LOG_LEVEL wins (debugging).
LOG_LEVEL: process.env.LOG_LEVEL ?? 'warn',
// The child's stdout is a pipe, so picocolors would strip its clack
// rendering to bare box chars that clash with the wizard theme.
// When the OPERATOR's terminal is a real TTY, the teed lines land
// there — force color so the child's card matches the parent.
...(process.stdout.isTTY ? { FORCE_COLOR: '1' } : {}),
},
stdio: ['inherit', 'pipe', 'pipe'],
});
const blocks: Array<{ fields: Record<string, string> }> = [];
@@ -351,7 +416,7 @@ function defaultOnEvent(
return;
}
// operator: note → URL offer → natural-barrier confirm.
p.note(e.text, 'Do this');
p.note(e.text, 'Your turn');
const url = extractOfferUrl(e.text);
if (url !== undefined && (await confirm(`Open ${url} in your browser?`))) await open(url);
const gate = gates.get(e.line);
@@ -382,7 +447,7 @@ export interface RunSkillOptions {
*/
resolveInput?: (name: string, meta: InputMeta) => Promise<string | undefined>;
/** Defaults to `hostExec`. */
exec?: (cmd: string) => string | void;
exec?: (cmd: string) => string | void | Promise<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. */
@@ -445,11 +510,19 @@ export async function runSkill(skillDir: string, opts: RunSkillOptions = {}): Pr
} catch {
// missing SKILL.md — the engine will produce an empty result anyway
}
// One raw log per skill apply (level 3): every default-exec command appends
// its `$ cmd` + output there. Allocated only when the default exec is used —
// an injected exec (tests, agent relay) owns its own capture.
let rawLog: string | undefined;
if (!opts.exec) {
rawLog = setupLog.stepRawLog(`skill-${basename(skillDir)}`);
writeFileSync(rawLog, `# skill ${basename(skillDir)}${new Date().toISOString()}\n\n`);
}
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),
exec: opts.exec ?? hostExec(projectRoot, rawLog),
execStream: opts.execStream ?? hostExecStream(projectRoot),
resolveRemote: opts.resolveRemote ?? channelsRemote(projectRoot),
skipEffects: opts.skipEffects,
+16 -22
View File
@@ -2,9 +2,9 @@
* Step: pair-telegram — issue a one-time pairing code and wait for the
* operator to send the code from the chat they want to register.
*
* Emits machine-readable status blocks only. The parent driver
* (`setup:auto`) renders the code / attempt / success UI with clack. Running
* this step directly will look sparse — that's intentional.
* Renders the human-facing code card itself (see printCodeCard) and emits
* machine-readable status blocks alongside for the programmatic callers
* (/manage-channels, /init-first-agent) that parse them.
*
* Blocks emitted:
* PAIR_TELEGRAM_CODE { CODE, REASON=initial|regenerated }
@@ -20,6 +20,8 @@
*/
import path from 'path';
import * as p from '@clack/prompts';
import {
createPairing,
waitForPairing,
@@ -56,34 +58,26 @@ function intentToString(intent: PairingIntent): string {
}
/**
* Render the pairing code and live feedback as PLAIN stdout lines.
* Render the pairing code card with clack's STATIC primitives (note/log).
*
* The Option A driver's streaming exec (setup/lib/skill-driver.ts
* `hostExecStream`) CONSUMES the `=== NANOCLAW SETUP: … ===` status blocks (it
* does not show them) and tees every OTHER stdout line straight to the
* operator's terminal. So the human-facing code card has to be printed as plain
* lines here — the bespoke setup/channels/telegram.ts used to render these from
* the blocks, and that rendering now lives in the step itself. The structured
* blocks are still emitted alongside for the agent-driven callers
* (/manage-channels, /init-first-agent) that parse them.
* does not show them) and tees every OTHER stdout line verbatim to the
* operator's terminal. Static clack output is just lines, so it survives that
* tee and reads like the rest of the wizard — only INTERACTIVE/animated clack
* widgets need the real TTY the piped child doesn't have (SSF-002).
*/
function printCodeCard(code: string, reason: 'initial' | 'regenerated'): void {
const spaced = code.split('').join(' ');
console.log('');
console.log(
reason === 'initial'
? 'Your pairing code is ready.'
: 'That code was used up — here is a fresh one.',
p.note(
`${spaced}\n\nSend these 4 digits to your bot from Telegram.`,
reason === 'initial' ? 'Your pairing code is ready' : 'That code was used up — here is a fresh one',
);
console.log('');
console.log(` ${spaced}`);
console.log('');
console.log('Send these 4 digits to your bot from Telegram.');
console.log('Waiting for you to send the code…');
p.log.message('Waiting for you to send the code…');
}
function printAttempt(candidate: string): void {
console.log(`Got "${candidate}", which doesn't match — waiting for the correct code…`);
p.log.warn(`Got "${candidate}", which doesn't match — waiting for the correct code…`);
}
export async function run(args: string[]): Promise<void> {
@@ -115,7 +109,7 @@ export async function run(args: string[]): Promise<void> {
},
});
console.log('\nTelegram paired.');
p.log.success('Telegram paired.');
emitStatus('PAIR_TELEGRAM', {
STATUS: 'success',
CODE: record.code,