mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-03 18:45:07 +08:00
feat(setup): help-escape handoff — a lone "?" at any prompt hands off to Claude
clackPrompter now wraps each prompt's validate with the tested validateWithHelpEscape helper so a lone "?" short-circuits format checks, then intercepts it post-prompt: it calls offerClaudeHandoff with the run's channel + step context and the prompt question, then re-asks the same prompt. Recursion is operator-bounded. Gated on a TTY, so a headless / non-TTY run is a no-op (no interactive child spawned). The channel + step label are plumbed through RunSkillOptions → runSkill → clackPrompter, and run-channel-skill threads the channel it is wiring so the handoff has the right context. An injected prompter is unaffected (the injector owns its I/O). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -106,6 +106,10 @@ export async function runChannelSkill(
|
||||
skipEffects: overrides.skipEffects,
|
||||
reporter: overrides.reporter, // undefined ⇒ runSkill's TTY-gated spinner
|
||||
reuse: overrides.reuse ?? true, // offer to reuse credentials already in .env
|
||||
// Handoff context for the `?` help-escape: a lone `?` at any of this skill's
|
||||
// prompts hands the operator off to interactive Claude scoped to this channel.
|
||||
channel: overrides.channel ?? channel,
|
||||
step: overrides.step ?? `${channel}-install`,
|
||||
});
|
||||
if (!fullyApplied(res)) {
|
||||
if (res.deferred.length) p.log.warn(`Still needs: ${res.deferred.join(', ')}`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, chmodSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
@@ -6,6 +6,35 @@ import { join } from 'node:path';
|
||||
import { runSkill, hostExec, hostExecStream, promptValidator, clackPrompter, type RunSkillOptions } from './skill-driver.js';
|
||||
import { fullyApplied, type Prompter, type StepReporter } from '../../scripts/skill-apply.js';
|
||||
|
||||
// Shared test state for the clack + claude-handoff mocks (hoisted so the vi.mock
|
||||
// factories — which run before imports — can close over it). `answers` is the
|
||||
// queue each mocked text/password prompt pops from; `handoffSpy` stands in for
|
||||
// the interactive Claude handoff; `lastValidate` captures the validate callback
|
||||
// the prompter handed clack so we can prove the `?` help-escape is wired in.
|
||||
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) },
|
||||
}));
|
||||
|
||||
// Keep isHelpEscape + validateWithHelpEscape real (clackPrompter uses them); only
|
||||
// the interactive handoff is replaced with a spy so the test never spawns Claude.
|
||||
vi.mock('./claude-handoff.js', async (importActual) => {
|
||||
const actual = await importActual<typeof import('./claude-handoff.js')>();
|
||||
return { ...actual, offerClaudeHandoff: ce.handoffSpy };
|
||||
});
|
||||
|
||||
// Drive clackPrompter's prompts from the `answers` queue and record the validate
|
||||
// callback it passes through, instead of opening a real TTY prompt.
|
||||
vi.mock('@clack/prompts', async (importActual) => {
|
||||
const actual = await importActual<typeof import('@clack/prompts')>();
|
||||
const fromQueue = async (o: { validate?: (v: string) => string | Error | void | undefined }): Promise<string> => {
|
||||
ce.lastValidate.fn = o?.validate;
|
||||
return ce.answers.shift() ?? '';
|
||||
};
|
||||
return { ...actual, text: vi.fn(fromQueue), password: vi.fn(fromQueue) };
|
||||
});
|
||||
|
||||
// A small SKILL.md exercising the three things the driver wires: an operator
|
||||
// block (relayed via tell), a secret prompt (asked via ask), and a wire run
|
||||
// (executed via exec) consuming the captured input.
|
||||
@@ -193,6 +222,42 @@ describe('thin skill driver', () => {
|
||||
expect(typeof clackPrompter().open).toBe('function');
|
||||
});
|
||||
|
||||
it('clackPrompter intercepts a lone "?" → hands off to Claude with context, then re-asks', async () => {
|
||||
const prevTTY = process.stdout.isTTY;
|
||||
process.stdout.isTTY = true; // the `?` help-escape only fires at a real terminal
|
||||
try {
|
||||
ce.handoffSpy.mockClear();
|
||||
ce.answers = ['?', 'real-token']; // first answer is the help-escape, second is the real value
|
||||
const ans = await clackPrompter({ channel: 'telegram', step: 'paste-token' }).ask(
|
||||
'token',
|
||||
'Paste your token.',
|
||||
false,
|
||||
'^[0-9a-zA-Z-]+$',
|
||||
);
|
||||
expect(ans).toBe('real-token'); // re-asked after the handoff, returns the second answer
|
||||
expect(ce.handoffSpy).toHaveBeenCalledTimes(1);
|
||||
expect(ce.handoffSpy.mock.calls[0][0]).toEqual({
|
||||
channel: 'telegram',
|
||||
step: 'paste-token',
|
||||
stepDescription: 'Paste your token.',
|
||||
});
|
||||
// The validate handed to clack lets `?` through (so the escape reaches us)
|
||||
// but still rejects a value that fails the prompt's own regex.
|
||||
expect(ce.lastValidate.fn?.('?')).toBeUndefined();
|
||||
expect(ce.lastValidate.fn?.('has space')).toBeTruthy();
|
||||
} finally {
|
||||
process.stdout.isTTY = prevTTY;
|
||||
}
|
||||
});
|
||||
|
||||
it('clackPrompter passes a normal answer straight through — no handoff', async () => {
|
||||
ce.handoffSpy.mockClear();
|
||||
ce.answers = ['just-a-token'];
|
||||
const ans = await clackPrompter({ channel: 'telegram', step: 'paste-token' }).ask('token', 'Paste your token.', false);
|
||||
expect(ans).toBe('just-a-token');
|
||||
expect(ce.handoffSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('promptValidator honors flags:i (case-insensitive) and min (rejects short); error overrides the message', () => {
|
||||
const ci = promptValidator('^https://', { flags: 'i' });
|
||||
expect(ci).toBeDefined();
|
||||
|
||||
+62
-13
@@ -17,6 +17,7 @@ import { applySkill, fullyApplied, type ApplyResult, type Prompter, type PromptO
|
||||
import { parseDirectives, promptVar } from '../../scripts/skill-directives.js';
|
||||
import { isHeadless } from '../platform.js';
|
||||
import { openUrl } from './browser.js';
|
||||
import { isHelpEscape, offerClaudeHandoff, validateWithHelpEscape } from './claude-handoff.js';
|
||||
import { startSpinner } from './runner.js';
|
||||
|
||||
/**
|
||||
@@ -48,19 +49,59 @@ export function promptValidator(
|
||||
};
|
||||
}
|
||||
|
||||
export function clackPrompter(): Prompter {
|
||||
/**
|
||||
* Handoff context for the `?` help-escape (Step 8 / mechanism M3). A lone `?` at
|
||||
* any prompt hands the operator to interactive Claude with this context, then
|
||||
* re-asks the same prompt. Both fields are optional so a bare `clackPrompter()`
|
||||
* (e.g. the standalone CLI below) still works — it just hands off with a generic
|
||||
* `setup` channel and the prompt's own var name as the step.
|
||||
*/
|
||||
export interface PrompterContext {
|
||||
/** Channel this run is wiring (e.g. 'telegram') — surfaced to the handoff. */
|
||||
channel?: string;
|
||||
/** Short label for the current setup step — surfaced to the handoff. */
|
||||
step?: string;
|
||||
}
|
||||
|
||||
export function clackPrompter(ctx: PrompterContext = {}): Prompter {
|
||||
// The `?` help-escape is only meaningful at a real terminal: it hands the
|
||||
// operator off to an interactive Claude session (stdio inherited). In a
|
||||
// headless / non-TTY run nobody can type `?` into a clack prompt anyway, and
|
||||
// we must never spawn an interactive child without a TTY — so it's a no-op
|
||||
// there (read at ask-time so a re-run picks up a terminal that appears later).
|
||||
async function ask(
|
||||
varName: string,
|
||||
question: string,
|
||||
secret: boolean,
|
||||
validate?: string,
|
||||
opts?: PromptOpts,
|
||||
): Promise<string | undefined> {
|
||||
const check = promptValidator(validate, opts);
|
||||
// Wrap the validator so a lone `?` short-circuits format checks and comes
|
||||
// back as a literal "?" instead of being rejected — we intercept it below.
|
||||
const guarded = validateWithHelpEscape(check);
|
||||
// clearOnError wipes a rejected secret so the operator re-pastes cleanly
|
||||
// (a half-pasted token isn't left masked in the field).
|
||||
const ans = secret
|
||||
? await p.password({ message: question, validate: guarded, clearOnError: true })
|
||||
: await p.text({ message: question, validate: guarded });
|
||||
if (p.isCancel(ans)) return undefined; // cancelled ⇒ defer
|
||||
if (isHelpEscape(ans) && process.stdout.isTTY) {
|
||||
// Operator asked for help: hand off to interactive Claude with this
|
||||
// prompt's context, then re-ask the same prompt. Recursion is operator-
|
||||
// bounded — they decide when to stop typing `?`.
|
||||
await offerClaudeHandoff({
|
||||
channel: ctx.channel ?? 'setup',
|
||||
step: ctx.step ?? varName,
|
||||
stepDescription: question,
|
||||
});
|
||||
return ask(varName, question, secret, validate, opts);
|
||||
}
|
||||
const v = String(ans).trim();
|
||||
return v.length ? v : undefined;
|
||||
}
|
||||
return {
|
||||
async ask(_varName, question, secret, validate, opts) {
|
||||
const check = promptValidator(validate, opts);
|
||||
// clearOnError wipes a rejected secret so the operator re-pastes cleanly
|
||||
// (a half-pasted token isn't left masked in the field).
|
||||
const ans = secret
|
||||
? await p.password({ message: question, validate: check, clearOnError: true })
|
||||
: await p.text({ message: question, validate: check });
|
||||
if (p.isCancel(ans)) return undefined; // cancelled ⇒ defer
|
||||
const v = String(ans).trim();
|
||||
return v.length ? v : undefined;
|
||||
},
|
||||
ask,
|
||||
tell(text) {
|
||||
p.note(text, 'Do this');
|
||||
},
|
||||
@@ -263,6 +304,14 @@ export interface RunSkillOptions {
|
||||
* (`spinnerReporter`); pass a fake in tests or a no-op to silence.
|
||||
*/
|
||||
reporter?: StepReporter;
|
||||
/**
|
||||
* Handoff context for the `?` help-escape (Step 8 / mechanism M3), threaded
|
||||
* into the default `clackPrompter`. A lone `?` at any prompt hands the operator
|
||||
* off to interactive Claude with this `channel` + `step` label, then re-asks.
|
||||
* Ignored when an explicit `prompter` is injected (the injector owns its I/O).
|
||||
*/
|
||||
channel?: string;
|
||||
step?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -272,7 +321,7 @@ export interface RunSkillOptions {
|
||||
*/
|
||||
export async function runSkill(skillDir: string, opts: RunSkillOptions = {}): Promise<ApplyResult> {
|
||||
const projectRoot = opts.projectRoot ?? process.cwd();
|
||||
const prompter = opts.prompter ?? clackPrompter();
|
||||
const prompter = opts.prompter ?? clackPrompter({ channel: opts.channel, step: opts.step });
|
||||
let inputs = opts.inputs;
|
||||
// Offer to reuse credentials already in .env before the engine prompts for them.
|
||||
if (opts.reuse && prompter.confirm) {
|
||||
|
||||
Reference in New Issue
Block a user