From cf3ca4dc364465f199bdabda62125afae458ae1c Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Tue, 7 Jul 2026 14:57:30 +0300 Subject: [PATCH] fix(setup): hostExec tees each command to a per-skill raw log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The async hostExec already pipes stderr off the wizard screen; this adds the level-3 paper trail. runSkill allocates one logs/setup-steps/NN-skill-.log per apply (default exec only) and every command appends $ cmd + stdout/stderr — success and failure alike — so silenced warnings (the Teams CLI libsecret note) stay inspectable. Co-Authored-By: Claude Fable 5 --- setup/lib/skill-driver.test.ts | 14 ++++++++++++++ setup/lib/skill-driver.ts | 29 +++++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/setup/lib/skill-driver.test.ts b/setup/lib/skill-driver.test.ts index 00005b44a..ed76e3e5f 100644 --- a/setup/lib/skill-driver.test.ts +++ b/setup/lib/skill-driver.test.ts @@ -139,6 +139,20 @@ describe('thin skill driver', () => { 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 () => { const root = mkdtempSync(join(tmpdir(), 'driver-step-')); const out = await hostExecStream(root)( diff --git a/setup/lib/skill-driver.ts b/setup/lib/skill-driver.ts index 086d7b374..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'; @@ -251,8 +252,19 @@ async function reuseFromEnv( * `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) => Promise { +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) => new Promise((resolve, reject) => { const child = spawn('bash', ['-c', cmd], { @@ -266,6 +278,7 @@ export function hostExec(projectRoot: string): (cmd: string) => Promise 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'; @@ -497,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,