diff --git a/setup/channels/run-channel-skill.ts b/setup/channels/run-channel-skill.ts index b1248affc..0d1d6aea1 100644 --- a/setup/channels/run-channel-skill.ts +++ b/setup/channels/run-channel-skill.ts @@ -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(', ')}`); diff --git a/setup/lib/skill-driver.test.ts b/setup/lib/skill-driver.test.ts index b7db604ff..4ac725a0a 100644 --- a/setup/lib/skill-driver.test.ts +++ b/setup/lib/skill-driver.test.ts @@ -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(); + 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(); + const fromQueue = async (o: { validate?: (v: string) => string | Error | void | undefined }): Promise => { + 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(); diff --git a/setup/lib/skill-driver.ts b/setup/lib/skill-driver.ts index 99c065597..6b513c7d0 100644 --- a/setup/lib/skill-driver.ts +++ b/setup/lib/skill-driver.ts @@ -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 { + 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 { 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) {