diff --git a/.claude/skills/add-slack/SKILL.md b/.claude/skills/add-slack/SKILL.md index 7db224e25..4cb4dca03 100644 --- a/.claude/skills/add-slack/SKILL.md +++ b/.claude/skills/add-slack/SKILL.md @@ -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): diff --git a/setup/channels/run-channel-skill.ts b/setup/channels/run-channel-skill.ts index 9ae468839..cc9ed7241 100644 --- a/setup/channels/run-channel-skill.ts +++ b/setup/channels/run-channel-skill.ts @@ -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 : ` — 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, ); } diff --git a/setup/lib/skill-driver.test.ts b/setup/lib/skill-driver.test.ts index 2501bfaab..ed76e3e5f 100644 --- a/setup/lib/skill-driver.test.ts +++ b/setup/lib/skill-driver.test.ts @@ -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 => { + 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 : ` with the full stderr kept below', async () => { + const root = mkdtempSync(join(tmpdir(), 'driver-fail-')); + const run = (): Promise => 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. diff --git a/setup/lib/skill-driver.ts b/setup/lib/skill-driver.ts index dbc51ebf2..439dc3679 100644 --- a/setup/lib/skill-driver.ts +++ b/setup/lib/skill-driver.ts @@ -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 { // 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:` 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 : ` — 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 { + 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 { 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 }> = []; @@ -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; /** Defaults to `hostExec`. */ - exec?: (cmd: string) => string | void; + exec?: (cmd: string) => string | void | Promise; /** Defaults to `hostExecStream`. Streaming exec for `nc:run effect:step`. */ execStream?: (cmd: string) => Promise; /** 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, diff --git a/setup/pair-telegram.ts b/setup/pair-telegram.ts index 6ba12a713..ed10ad7a1 100644 --- a/setup/pair-telegram.ts +++ b/setup/pair-telegram.ts @@ -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 { @@ -115,7 +109,7 @@ export async function run(args: string[]): Promise { }, }); - console.log('\nTelegram paired.'); + p.log.success('Telegram paired.'); emitStatus('PAIR_TELEGRAM', { STATUS: 'success', CODE: record.code,