Skip to content

Commit 55e67b0

Browse files
committed
feat(cli): support using -s/--settings as an explicit configuration
1 parent 4ba8d73 commit 55e67b0

11 files changed

Lines changed: 388 additions & 38 deletions

File tree

‎packages/cli/src/cli-args.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { isValidSessionId } from "@vegamo/deepcode-core";
1313
export { isValidSessionId };
1414

1515
export interface ParsedCliArgs {
16+
/** Replace the project-level settings input. */
17+
settingsFile: string | undefined;
1618
/** Configure the DeepCode Plus API key and exit. */
1719
plus: boolean;
1820
/** Prompt text from -p / --prompt */
@@ -88,6 +90,12 @@ async function configureYargs(argv?: string[]) {
8890
default: false,
8991
describe: "Configure your DeepCode Plus API Key",
9092
})
93+
.option("settings", {
94+
alias: "s",
95+
type: "string",
96+
requiresArg: true,
97+
describe: "Path to a settings JSON file",
98+
})
9199
.option("prompt", {
92100
alias: "p",
93101
type: "string",
@@ -134,7 +142,8 @@ async function configureYargs(argv?: string[]) {
134142

135143
if (
136144
argv["plus"] === true &&
137-
(prompt !== undefined ||
145+
(argv["settings"] !== undefined ||
146+
prompt !== undefined ||
138147
hasPositionalQuery ||
139148
exec ||
140149
sessionId !== undefined ||
@@ -146,6 +155,10 @@ async function configureYargs(argv?: string[]) {
146155
return "--plus must be used on its own.";
147156
}
148157

158+
if (typeof argv["settings"] === "string" && argv["settings"].trim() === "") {
159+
return "--settings / -s requires a non-empty file path.";
160+
}
161+
149162
if (sessionId !== undefined) {
150163
if (!exec) return "--session-id requires --exec / -x.";
151164
if (argv["resume"] !== undefined || argv["fork"] !== undefined || argv["last"] === true) {
@@ -251,6 +264,7 @@ export async function parseArguments(argv?: string[]): Promise<ParsedCliArgs> {
251264
}
252265

253266
return {
267+
settingsFile: parsed.settings as string | undefined,
254268
plus: parsed.plus === true,
255269
prompt: parsed.prompt as string | undefined,
256270
exec: parsed.exec === true,

‎packages/cli/src/cli.tsx‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { render } from "ink";
22
import { readFileSync } from "node:fs";
3-
import { join } from "node:path";
3+
import { join, resolve } from "node:path";
44
import { homedir } from "node:os";
5-
import { setShellIfWindows, getProjectCode } from "@vegamo/deepcode-core";
5+
import { setShellIfWindows, getProjectCode, resolveCurrentSettings } from "@vegamo/deepcode-core";
66
import { checkForNpmUpdate, promptForPendingUpdate } from "./common/update-check";
77
import { AppContainer } from "./ui";
88
import { parseArguments } from "./cli-args";
@@ -29,6 +29,18 @@ async function main(): Promise<void> {
2929
return;
3030
}
3131

32+
const projectRoot = process.cwd();
33+
const settingsFile = parsed.settingsFile === undefined ? undefined : resolve(projectRoot, parsed.settingsFile);
34+
if (settingsFile !== undefined) {
35+
try {
36+
resolveCurrentSettings(projectRoot, settingsFile);
37+
} catch (error) {
38+
writeStderrLine(`deepcode: ${error instanceof Error ? error.message : String(error)}`);
39+
process.exitCode = 1;
40+
return;
41+
}
42+
}
43+
3244
// Configure Windows shell AFTER --version/--help handling.
3345
// On Windows without Git Bash, setShellIfWindows() throws and calls process.exit(1).
3446
// If called before argument parsing, --help and --version would fail on those machines.
@@ -37,8 +49,6 @@ async function main(): Promise<void> {
3749
let initialPrompt = parsed.prompt;
3850
let resumeSessionId = parsed.resume;
3951
let forkSessionId = parsed.fork;
40-
const projectRoot = process.cwd();
41-
4252
// Resolve --last to the most recent session ID for the current project
4353
if (parsed.last) {
4454
const projectCode = getProjectCode(projectRoot);
@@ -80,6 +90,7 @@ async function main(): Promise<void> {
8090
process.exitCode = await runExecMode({
8191
prompt: parsed.prompt!,
8292
projectRoot,
93+
settingsFile,
8394
sessionId: parsed.sessionId,
8495
json: parsed.json,
8596
resumeSessionId: typeof resumeSessionId === "string" ? resumeSessionId : undefined,
@@ -145,6 +156,7 @@ async function main(): Promise<void> {
145156
const inkInstance = render(
146157
<AppContainer
147158
projectRoot={projectRoot}
159+
settingsFile={settingsFile}
148160
version={packageInfo?.version ?? CLI_VERSION}
149161
initialPrompt={appInitialPrompt}
150162
resumeSessionId={appResumeSessionId}

‎packages/cli/src/exec-runner.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ interface SignalTarget {
3232
export interface ExecRunnerOptions {
3333
prompt: string;
3434
projectRoot: string;
35+
settingsFile?: string;
3536
sessionId?: string;
3637
json?: boolean;
3738
resumeSessionId?: string;
@@ -74,11 +75,11 @@ export async function runExecMode(
7475

7576
deps.signalTarget.on("SIGINT", handleSigint);
7677
try {
77-
const settings = deps.resolveSettings(options.projectRoot);
78+
const settings = deps.resolveSettings(options.projectRoot, options.settingsFile);
7879
manager = deps.createSessionManager({
7980
projectRoot: options.projectRoot,
80-
createOpenAIClient: createOpenAIClientFactory(options.projectRoot),
81-
getResolvedSettings: () => deps.resolveSettings(options.projectRoot),
81+
createOpenAIClient: createOpenAIClientFactory(options.projectRoot, options.settingsFile),
82+
getResolvedSettings: () => deps.resolveSettings(options.projectRoot, options.settingsFile),
8283
renderMarkdown: (text) => text,
8384
nonInteractive: true,
8485
onAssistantMessage: (message, shouldConnect) => {

‎packages/cli/src/tests/cli-args.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,3 +454,21 @@ test("parseArguments exits when --last is combined with bare --resume", async ()
454454
assert.ok(exitSpy.calls.length >= 1);
455455
});
456456
});
457+
458+
for (const flag of ["-s", "--settings"]) {
459+
test(`parseArguments accepts ${flag} with all session modes`, async () => {
460+
for (const mode of [[], ["-x", "-p", "hello"], ["-r"], ["-f"], ["-l"]]) {
461+
const parsed = await parseArguments([flag, "config/custom settings.json", ...mode]);
462+
assert.equal(parsed.settingsFile, "config/custom settings.json");
463+
}
464+
assert.equal((await parseArguments([flag, "/tmp/settings.json"])).settingsFile, "/tmp/settings.json");
465+
assert.equal((await parseArguments([])).settingsFile, undefined);
466+
});
467+
for (const args of [[flag], [flag, ""], [flag, " "], [flag, "--exec"], ["--plus", flag, "settings.json"]]) {
468+
test(`parseArguments rejects settings arguments ${JSON.stringify(args)}`, async () => {
469+
await withMockedExit(async () => {
470+
await assert.rejects(parseArguments(args), /process.exit\(1\)/);
471+
});
472+
});
473+
}
474+
}

‎packages/cli/src/tests/exec-runner.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,3 +487,22 @@ test("runExecMode disposes resources when stdin cannot be read", async () => {
487487
assert.match(harness.stderr.join("\n"), /Failed to read stdin: stdin unavailable/);
488488
assert.equal(harness.disposed, 1);
489489
});
490+
491+
test("exec uses the selected settings file for startup and subsequent session reads", async () => {
492+
const h = createHarness();
493+
const reads: unknown[][] = [];
494+
h.dependencies.resolveSettings = (...args) => {
495+
reads.push(args);
496+
return createSettings();
497+
};
498+
const code = await runExecMode(
499+
{ prompt: "hello", projectRoot: "/workspace", settingsFile: "/config/custom.json", input: ttyInput() },
500+
h.dependencies
501+
);
502+
assert.equal(code, 0);
503+
h.managerOptions!.getResolvedSettings();
504+
assert.deepEqual(reads, [
505+
["/workspace", "/config/custom.json"],
506+
["/workspace", "/config/custom.json"],
507+
]);
508+
});
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import assert from "node:assert/strict";
2+
import fs from "node:fs";
3+
import os from "node:os";
4+
import path from "node:path";
5+
import { spawnSync } from "node:child_process";
6+
import { fileURLToPath } from "node:url";
7+
import { createRequire, syncBuiltinESMExports } from "node:module";
8+
import { test } from "node:test";
9+
import React from "react";
10+
import { Models } from "openai/resources/models";
11+
import { SessionManager, resolveCurrentSettings } from "@vegamo/deepcode-core";
12+
import AppContainer from "../ui/views/AppContainer";
13+
import { terminal } from "./terminal-helper";
14+
15+
for (const hasProject of [true, false]) {
16+
test(`TUI uses custom settings while preserving save scope (project exists: ${hasProject})`, async (t) => {
17+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "cli-settings-file-"));
18+
const project = path.join(root, "project");
19+
const projectFile = path.join(project, ".deepcode/settings.json");
20+
const userFile = path.join(root, ".deepcode/settings.json");
21+
const settingsFile = path.join(root, "custom.json");
22+
fs.mkdirSync(path.dirname(projectFile), { recursive: true });
23+
fs.mkdirSync(path.dirname(userFile), { recursive: true });
24+
fs.writeFileSync(userFile, "{}");
25+
if (hasProject) fs.writeFileSync(projectFile, "{}");
26+
const custom = JSON.stringify({
27+
model: "deepseek-v4-pro",
28+
env: { API_KEY: "test-key", BASE_URL: "https://custom.invalid" },
29+
permissions: { defaultMode: "askAll" },
30+
statusline: { enabled: false },
31+
});
32+
fs.writeFileSync(settingsFile, custom);
33+
const env = process.env;
34+
process.env = Object.fromEntries(Object.entries(env).filter(([key]) => !key.startsWith("DEEPCODE_")));
35+
t.mock.method(os, "homedir", () => root);
36+
syncBuiltinESMExports();
37+
t.mock.method(Models.prototype, "list", async () => ({ data: [] }));
38+
t.mock.method(SessionManager.prototype, "initMcpServers", async () => {});
39+
t.mock.method(SessionManager.prototype, "listSkills", async () => []);
40+
t.mock.method(SessionManager.prototype, "listSessions", () => []);
41+
const element = (key: number) =>
42+
React.createElement(AppContainer, {
43+
key,
44+
projectRoot: project,
45+
settingsFile,
46+
version: "test",
47+
initialPrompt: undefined,
48+
resumeSessionId: undefined,
49+
forkSessionId: undefined,
50+
onRestart() {},
51+
});
52+
const h = await terminal(t, element(0), 120);
53+
t.after(() => {
54+
t.mock.restoreAll();
55+
syncBuiltinESMExports();
56+
process.env = env;
57+
fs.rmSync(root, { recursive: true, force: true });
58+
});
59+
assert.match(h.frame(), /deepseek-v4-pro/);
60+
await h.send("/model");
61+
await h.send("\r");
62+
assert.match(h.frame(), hasProject ? /All Projects +\[This Project\]/ : /\[All Projects\] +This Project/);
63+
await h.send("\u001b[A"); // Select the preceding model (deepseek-flash).
64+
await h.send("\r");
65+
await h.send("\r");
66+
const targetFile = hasProject ? projectFile : userFile;
67+
assert.equal(JSON.parse(fs.readFileSync(targetFile, "utf8")).model, "deepseek-flash");
68+
assert.equal(resolveCurrentSettings(project, settingsFile).model, "deepseek-v4-pro");
69+
await h.send("/permissions");
70+
await h.send("\r");
71+
assert.match(h.frame(), hasProject ? /All Projects +\[This Project\]/ : /\[All Projects\] +This Project/);
72+
await h.send("\r"); // Edit defaultMode.
73+
await h.send("\u001b[A"); // askAll.
74+
await h.send("\r");
75+
await h.send("\u001b[A"); // Save.
76+
await h.send("\r");
77+
assert.equal(JSON.parse(fs.readFileSync(targetFile, "utf8")).permissions.defaultMode, "askAll");
78+
assert.equal(fs.readFileSync(settingsFile, "utf8"), custom);
79+
h.rerender(element(1));
80+
await h.send("");
81+
assert.match(h.frame(), /deepseek-v4-pro/);
82+
});
83+
}
84+
85+
test("CLI rejects invalid explicit files before startup but help and version do not read them", (t) => {
86+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "cli-settings-errors-"));
87+
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
88+
const file = path.join(root, "settings.json");
89+
const cli = fileURLToPath(new URL("../cli.tsx", import.meta.url));
90+
const config = fileURLToPath(new URL("../../tsconfig.json", import.meta.url));
91+
const tsx = createRequire(import.meta.url).resolve("tsx");
92+
const run = (...args: string[]) =>
93+
spawnSync(process.execPath, ["--import", tsx, cli, ...args], {
94+
cwd: root,
95+
encoding: "utf8",
96+
env: { ...process.env, TSX_TSCONFIG_PATH: config },
97+
timeout: 15_000,
98+
});
99+
for (const raw of [undefined, '{"secret":"do-not-print",broken}', "[]"]) {
100+
if (raw !== undefined) fs.writeFileSync(file, raw);
101+
for (const mode of [[], ["-x", "-p", "hello"]]) {
102+
const result = run("-s", "settings.json", ...mode);
103+
assert.equal(result.status, 1, result.stderr);
104+
assert.ok(result.stderr.includes(file), result.stderr);
105+
assert.doesNotMatch(result.stderr, /do-not-print/);
106+
assert.equal(result.stdout, "");
107+
}
108+
}
109+
for (const flag of ["--help", "--version"]) {
110+
const result = run("--settings", "missing.json", flag);
111+
assert.equal(result.status, 0, result.stderr);
112+
assert.equal(result.stderr, "");
113+
if (flag === "--help") assert.match(result.stdout, /-s, --settings +Path to a settings JSON file/);
114+
}
115+
});

0 commit comments

Comments
 (0)