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:
gavrielc
2026-06-27 23:55:53 +03:00
parent 46de9ae05a
commit 665b0dca77
3 changed files with 132 additions and 14 deletions
+4
View File
@@ -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(', ')}`);
+66 -1
View File
@@ -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
View File
@@ -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) {