Skip to content

Commit cafd4c5

Browse files
authored
Merge pull request #187 from proofgeist/codex/issue-185-auto-non-interactive
2 parents 37bcdab + 0d2038b commit cafd4c5

5 files changed

Lines changed: 156 additions & 10 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@proofkit/cli": patch
3+
---
4+
5+
Auto-detect non-interactive terminals for CLI commands in CI, scripted runs, and coding-agent environments.

packages/cli/src/index.ts

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { planInit } from "~/core/planInit.js";
3434
import { resolveInitRequest } from "~/core/resolveInitRequest.js";
3535
import type { CliFlags } from "~/core/types.js";
3636
import { makeLiveLayer } from "~/services/live.js";
37+
import { resolveNonInteractiveMode } from "~/utils/nonInteractive.js";
3738
import { intro } from "~/utils/prompts.js";
3839
import { proofGradient, renderTitle } from "~/utils/renderTitle.js";
3940

@@ -76,13 +77,6 @@ export const runDefaultCommand = (rawFlags?: Partial<CliFlags>) =>
7677
const fsService = yield* FileSystemService;
7778
const consoleService = yield* ConsoleService;
7879
const flags = { ...defaultCliFlags, ...rawFlags };
79-
80-
if (cliContext.nonInteractive || flags.CI || flags.nonInteractive) {
81-
throw new Error(
82-
"The default command is interactive-only in non-interactive mode. Run an explicit command such as `proofkit init <name> --non-interactive`.",
83-
);
84-
}
85-
8680
const settingsPath = path.join(cliContext.cwd, "proofkit.json");
8781
const hasProofKitProject = yield* Effect.promise(() => fsService.exists(settingsPath));
8882

@@ -98,6 +92,12 @@ export const runDefaultCommand = (rawFlags?: Partial<CliFlags>) =>
9892
return;
9993
}
10094

95+
if (cliContext.nonInteractive || flags.CI || flags.nonInteractive) {
96+
throw new Error(
97+
"The default command is interactive-only in non-interactive mode. Run an explicit command such as `proofkit init <name> --non-interactive`.",
98+
);
99+
}
100+
101101
intro(`No ${proofGradient("ProofKit")} project found, running \`init\``);
102102
yield* runInit(undefined, {
103103
...flags,
@@ -117,11 +117,23 @@ function optionalChoiceOption<Choices extends readonly string[]>(name: string, c
117117
return optionalOption(choiceOption(name, choices).pipe(withOptionDescription(description)));
118118
}
119119

120+
function getCurrentTTYState() {
121+
return {
122+
stdinIsTTY: process.stdin?.isTTY,
123+
stdoutIsTTY: process.stdout?.isTTY,
124+
};
125+
}
126+
120127
function legacyEffect<T>(runLegacy: () => Promise<T>, options?: { nonInteractive?: boolean; debug?: boolean }) {
128+
const nonInteractive = resolveNonInteractiveMode({
129+
nonInteractive: options?.nonInteractive,
130+
...getCurrentTTYState(),
131+
});
132+
121133
return makeLiveLayer({
122134
cwd: process.cwd(),
123135
debug: options?.debug === true,
124-
nonInteractive: options?.nonInteractive === true,
136+
nonInteractive,
125137
})(Effect.promise(runLegacy));
126138
}
127139

@@ -155,6 +167,12 @@ function makeInitCommand() {
155167
debug: booleanOption("debug").pipe(withOptionDescription("Run in debug mode")),
156168
},
157169
({ dir, ...options }) => {
170+
const nonInteractive = resolveNonInteractiveMode({
171+
CI: options.CI,
172+
nonInteractive: options.nonInteractive,
173+
...getCurrentTTYState(),
174+
});
175+
158176
const flags: CliFlags = {
159177
...defaultCliFlags,
160178
appType: getOrUndefined(options.appType),
@@ -177,7 +195,7 @@ function makeInitCommand() {
177195
return makeLiveLayer({
178196
cwd: process.cwd(),
179197
debug: flags.debug === true,
180-
nonInteractive: Boolean(flags.CI || flags.nonInteractive),
198+
nonInteractive,
181199
})(runInit(getOrUndefined(dir), flags));
182200
},
183201
).pipe(withCommandDescription("Create a new project with ProofKit"));
@@ -336,7 +354,11 @@ const rootCommand = makeCommand(
336354
makeLiveLayer({
337355
cwd: process.cwd(),
338356
debug: options.debug === true,
339-
nonInteractive: Boolean(options.CI || options.nonInteractive),
357+
nonInteractive: resolveNonInteractiveMode({
358+
CI: options.CI,
359+
nonInteractive: options.nonInteractive,
360+
...getCurrentTTYState(),
361+
}),
340362
})(
341363
runDefaultCommand({
342364
...defaultCliFlags,
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
const NON_INTERACTIVE_ENV_VARS = [
2+
"CI",
3+
"GITHUB_ACTIONS",
4+
"CODEX",
5+
"OPENAI_CODEX",
6+
"CLAUDE_CODE",
7+
"JENKINS_URL",
8+
"BUILDKITE",
9+
] as const;
10+
11+
export function detectNonInteractiveTerminal(options?: {
12+
stdinIsTTY?: boolean;
13+
stdoutIsTTY?: boolean;
14+
env?: NodeJS.ProcessEnv;
15+
}): boolean {
16+
const env = options?.env ?? process.env;
17+
const hasTTY = options?.stdinIsTTY === true && options?.stdoutIsTTY === true;
18+
const hasNonInteractiveEnv = NON_INTERACTIVE_ENV_VARS.some((name) => Boolean(env[name]));
19+
const hasDumbTerm = env.TERM === "dumb";
20+
return !hasTTY || hasNonInteractiveEnv || hasDumbTerm;
21+
}
22+
23+
export function resolveNonInteractiveMode(options?: {
24+
CI?: boolean;
25+
nonInteractive?: boolean;
26+
stdinIsTTY?: boolean;
27+
stdoutIsTTY?: boolean;
28+
env?: NodeJS.ProcessEnv;
29+
}) {
30+
if (options?.nonInteractive === true || options?.CI === true) {
31+
return true;
32+
}
33+
34+
return detectNonInteractiveTerminal(options);
35+
}

packages/cli/tests/cli.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,17 @@ describe("proofkit CLI", () => {
5858
expect(`${result.stdout}\n${result.stderr}`).toContain("proofkit init <name> --non-interactive");
5959
});
6060

61+
it("auto-detects piped execution as non-interactive", () => {
62+
const result = spawnSync("node", [distEntry], {
63+
cwd: packageDir,
64+
stdio: "pipe",
65+
encoding: "utf8",
66+
});
67+
68+
expect(result.status).not.toBe(0);
69+
expect(`${result.stdout}\n${result.stderr}`).toContain("interactive-only in non-interactive mode");
70+
});
71+
6172
it("runs when invoked through a symlinked bin path", async () => {
6273
const shimDir = await fs.mkdtemp(path.join(os.tmpdir(), "proofkit-new-cli-shim-"));
6374
const shimPath = path.join(shimDir, "proofkit");
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { describe, expect, it } from "vitest";
2+
import { detectNonInteractiveTerminal, resolveNonInteractiveMode } from "~/utils/nonInteractive.js";
3+
4+
describe("non-interactive detection", () => {
5+
it("treats piped terminals as non-interactive", () => {
6+
expect(
7+
detectNonInteractiveTerminal({
8+
stdinIsTTY: false,
9+
stdoutIsTTY: true,
10+
env: {},
11+
}),
12+
).toBe(true);
13+
});
14+
15+
it("treats TERM=dumb as non-interactive", () => {
16+
expect(
17+
detectNonInteractiveTerminal({
18+
stdinIsTTY: true,
19+
stdoutIsTTY: true,
20+
env: { TERM: "dumb" },
21+
}),
22+
).toBe(true);
23+
});
24+
25+
it("treats coding-agent env vars as non-interactive even with a tty", () => {
26+
expect(
27+
detectNonInteractiveTerminal({
28+
stdinIsTTY: true,
29+
stdoutIsTTY: true,
30+
env: { CODEX: "1" },
31+
}),
32+
).toBe(true);
33+
});
34+
35+
it("treats OPENAI_CODEX as non-interactive even with a tty", () => {
36+
expect(
37+
detectNonInteractiveTerminal({
38+
stdinIsTTY: true,
39+
stdoutIsTTY: true,
40+
env: { OPENAI_CODEX: "1" },
41+
}),
42+
).toBe(true);
43+
});
44+
45+
it("keeps real terminals interactive when no signals are present", () => {
46+
expect(
47+
detectNonInteractiveTerminal({
48+
stdinIsTTY: true,
49+
stdoutIsTTY: true,
50+
env: {},
51+
}),
52+
).toBe(false);
53+
});
54+
55+
it("lets explicit flags force non-interactive mode", () => {
56+
expect(
57+
resolveNonInteractiveMode({
58+
nonInteractive: true,
59+
stdinIsTTY: true,
60+
stdoutIsTTY: true,
61+
env: {},
62+
}),
63+
).toBe(true);
64+
expect(
65+
resolveNonInteractiveMode({
66+
CI: true,
67+
stdinIsTTY: true,
68+
stdoutIsTTY: true,
69+
env: {},
70+
}),
71+
).toBe(true);
72+
});
73+
});

0 commit comments

Comments
 (0)