mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-09 18:57:08 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf3ca4dc36 | |||
| ee1d2773c2 | |||
| 405643f8a7 | |||
| 1a3c3eaf9b | |||
| 77a19a0c97 |
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user