From cd88163e42cf598c8540a580ed3e3d4de969074a Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:09:29 +0800 Subject: [PATCH] fix(cli): show refusals for blocked setup dry runs Render a terminal dry-run plan only after setup admission succeeds. Blocked skill or registration plans use the existing remediation output. Reuse one bounded PTY helper for success and refusal workflows; verify no linked target or client configuration changes. Follow-up-to: #3108 Validation: public CLI PTY tests and RPC owner controls pass (77 tests); build and static checks pass. --- src/cli/setupCommands.ts | 10 ++- tests/boundary/cli/cliSetup.test.ts | 129 +++++++++++++++++++--------- 2 files changed, 96 insertions(+), 43 deletions(-) diff --git a/src/cli/setupCommands.ts b/src/cli/setupCommands.ts index 483de20cb4..092b27141e 100644 --- a/src/cli/setupCommands.ts +++ b/src/cli/setupCommands.ts @@ -226,8 +226,14 @@ const runSetupCommand = async ( : undefined, ); if (interactive) renderInteractiveSetupResult(result, environment); - else if (options.dryRun && !input.formatExplicit && hasInteractiveTerminal()) - renderSetupDryRun(result.plannedActions); + else if ( + options.dryRun && + !input.formatExplicit && + hasInteractiveTerminal() + ) { + if (result.status === "planned") renderSetupDryRun(result.plannedActions); + else renderInteractiveSetupResult(result, environment); + } return result; }; diff --git a/tests/boundary/cli/cliSetup.test.ts b/tests/boundary/cli/cliSetup.test.ts index 38bc24bb3e..ad07a5bb8f 100644 --- a/tests/boundary/cli/cliSetup.test.ts +++ b/tests/boundary/cli/cliSetup.test.ts @@ -1,6 +1,15 @@ import { execFile } from "node:child_process"; -import { access, rm } from "node:fs/promises"; -import { join } from "node:path"; +import { + access, + lstat, + mkdir, + readFile, + readdir, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { dirname, join } from "node:path"; import { promisify } from "node:util"; import { spawn } from "@lydell/node-pty"; @@ -74,48 +83,53 @@ const clientStates = [ const agentActions = actions.filter(({ kind }) => kind !== "install_hopper"); +const runSetupDryRunInTty = async ( + home: string, + arguments_: readonly string[], +): Promise<{ readonly exitCode: number; readonly output: string }> => { + const terminal = spawn( + process.execPath, + ["scripts/rea.mjs", "setup", "--dry-run", ...arguments_], + { + cwd: process.cwd(), + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: join(home, ".config"), + REA_LOG_LEVEL: "silent", + NO_COLOR: "1", + }, + name: "xterm-256color", + cols: 120, + rows: 40, + }, + ); + let output = ""; + terminal.onData((data) => { + output += data; + }); + const exitCode = await new Promise((resolvePromise, reject) => { + const timeout = setTimeout(() => { + terminal.kill(); + reject(new Error(`TTY dry run timed out after output:\n${output}`)); + }, 15_000); + terminal.onExit(({ exitCode: code }) => { + clearTimeout(timeout); + resolvePromise(code); + }); + }); + return { exitCode, output }; +}; + describe("TTY setup dry run", () => { it("prints the plan and no-mutation notice for a TTY dry run", async () => { const home = await createTestTempDirectory("rea-cli-setup-dry-run-"); - const terminal = spawn( - process.execPath, - [ - "scripts/rea.mjs", - "setup", - "--dry-run", - "--client", - "codex", - "--skill=false", - ], - { - cwd: process.cwd(), - env: { - ...process.env, - HOME: home, - USERPROFILE: home, - XDG_CONFIG_HOME: join(home, ".config"), - REA_LOG_LEVEL: "silent", - NO_COLOR: "1", - }, - name: "xterm-256color", - cols: 120, - rows: 40, - }, - ); - let output = ""; - terminal.onData((data) => { - output += data; - }); - const exitCode = await new Promise((resolvePromise, reject) => { - const timeout = setTimeout(() => { - terminal.kill(); - reject(new Error(`TTY dry run timed out after output:\n${output}`)); - }, 15_000); - terminal.onExit(({ exitCode: code }) => { - clearTimeout(timeout); - resolvePromise(code); - }); - }); + const { exitCode, output } = await runSetupDryRunInTty(home, [ + "--client", + "codex", + "--skill=false", + ]); expect(exitCode, output).toBe(0); expect(output).toContain("CREATE Codex"); @@ -127,6 +141,39 @@ describe("TTY setup dry run", () => { code: "ENOENT", }); }); + + it("shows the refusal instead of a blocked skill plan for a TTY dry run", async () => { + const home = await createTestTempDirectory("rea-cli-setup-linked-dry-run-"); + const managed = join(home, "managed-skill"); + const linked = join(home, ".claude/skills/reverse-engineer-anything"); + const original = "user-managed skill bytes\n"; + await mkdir(managed); + await writeFile(join(managed, "SKILL.md"), original); + await mkdir(dirname(linked), { recursive: true }); + await symlink( + managed, + linked, + process.platform === "win32" ? "junction" : "dir", + ); + + const { exitCode, output } = await runSetupDryRunInTty(home, [ + "--client", + "claude_code", + ]); + + expect(exitCode, output).toBe(1); + expect(output).toContain("Skill directory is a symbolic link."); + expect(output).toContain(linked); + expect(output).toContain("--skill=false"); + expect(output).not.toContain("REA setup plan"); + expect(output).not.toContain("Install or update the REA skill"); + expect((await lstat(linked)).isSymbolicLink()).toBe(true); + expect(await readFile(join(managed, "SKILL.md"), "utf8")).toBe(original); + expect(await readdir(managed)).toEqual(["SKILL.md"]); + await expect(access(join(home, ".claude.json"))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); }); describe("interactive setup journey", () => {