diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index 3afbddf..22e9d38 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -129,6 +129,7 @@ async function main(): Promise { allowedTools: args.allowedTools, disallowedTools: args.disallowedTools, maxTurns: args.maxTurns, + settingsPath: args.settingsFile, jsonSchema: args.jsonSchema, includePartialMessages: args.includePartialMessages, }); @@ -169,6 +170,7 @@ async function main(): Promise { forkSession: args.forkSession, bare: args.bare, noPlugins: args.noPlugins, + settingsPath: args.settingsFile, }); } diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index a4b57a0..c24213b 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -70,6 +70,8 @@ export interface HeadlessOpts { allowedTools?: string[]; disallowedTools?: string[]; maxTurns?: number; + /** `--settings ` → a settings file that wins over discovered layers. */ + settingsPath?: string; /** Path to a JSON schema file. Final output (text in `text` mode, JSON * object in `json` mode) is validated against it; mismatch → exit 1. */ jsonSchema?: string; @@ -89,7 +91,7 @@ export async function runHeadless(opts: HeadlessOpts): Promise { // Trust-gate: a headless run against an untrusted checkout (e.g. a PR branch) // must not execute that project's hooks/mcpServers/apiKeyHelper/statusLine. // The user-global layer stays trusted. Pre-trust with `deepcode trust`. - const loaded = await loadSettings({ cwd, home: opts.home }); + const loaded = await loadSettings({ cwd, home: opts.home, settingsPath: opts.settingsPath }); const trustStore = new TrustStore({ home: opts.home }); const trustStatus = await trustStore.statusFor(cwd); const { settings, gated } = gateUntrustedSettings(loaded, trustStatus); diff --git a/apps/cli/src/repl.ts b/apps/cli/src/repl.ts index 8d57d99..72b2ac6 100644 --- a/apps/cli/src/repl.ts +++ b/apps/cli/src/repl.ts @@ -93,6 +93,8 @@ export interface ReplOpts { bare?: boolean; /** `--no-plugins` → skip discovering + wiring installed plugins. */ noPlugins?: boolean; + /** `--settings ` → a settings file that wins over discovered layers. */ + settingsPath?: string; } const DEFAULT_SYSTEM_PROMPT = `You are DeepCode, an AI coding assistant powered by DeepSeek. Help the user with their codebase using the available tools (Read, Write, Edit, Bash, Grep, Glob). Be concise and accurate. When you modify files, briefly explain what you changed and why.`; @@ -205,7 +207,7 @@ export async function startRepl(opts: ReplOpts): Promise { // Load config + creds. Trust-gate first: in an untrusted directory, project // /local hooks·mcpServers·apiKeyHelper·statusLine are stripped (the user-global // layer is always trusted) so a freshly-cloned repo can't run code on launch. - const loaded = await loadSettings({ cwd, home: opts.home }); + const loaded = await loadSettings({ cwd, home: opts.home, settingsPath: opts.settingsPath }); const trustStore = new TrustStore({ home: opts.home }); const trustStatus = await trustStore.statusFor(cwd); const { settings, gated } = gateUntrustedSettings(loaded, trustStatus); diff --git a/docs/BEHAVIOR_PARITY.md b/docs/BEHAVIOR_PARITY.md index 75f734b..9ec6a90 100644 --- a/docs/BEHAVIOR_PARITY.md +++ b/docs/BEHAVIOR_PARITY.md @@ -170,22 +170,22 @@ Specific deviations: ## CLI flags -| Flag | Status | -| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `--help` / `--version` | ✅ | -| `--mode` | ✅ | -| `--permission-mode` | ✅ — true `--mode` alias (sets `mode`; last of `--mode`/`--permission-mode` wins), wired in PR #159 | -| `--model` / `--effort` | ✅ | -| `--max-turns` | ✅ | -| `-C` / `--cd ` | ✅ — chdir before running (Codex parity); validated eagerly, bad path exits 2 | -| `--system-prompt` / `--append-system-prompt[-file]` | ✅ | -| `--allowedTools` / `--disallowedTools` | ✅ | -| `--bare` | ✅ — suppresses the REPL startup banner (scripting / minimal output) | -| `--settings` / `--agents` / `--mcp-config` / `--plugin-dir` / `--plugin-url` | 🔄 (parsed only) | -| `--no-plugins` / `--strict` | 🟡 — `--no-plugins` skips plugin discovery + wiring; `--strict` still parsed-only | -| `-p` headless | ✅ text/json/stream-json, 5 exit codes | -| `--output-format` / `--json-schema` / `--include-partial-messages` | ✅ output-format + json-schema (lightweight top-level validation) + include-partial-messages all implemented (`headless.ts`) | -| `--resume ` / `--continue` / `--fork-session` | ✅ resume by id (picker if no id, `-r`), most-recent-in-cwd (`-c`), fork-into-new | +| Flag | Status | +| ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--help` / `--version` | ✅ | +| `--mode` | ✅ | +| `--permission-mode` | ✅ — true `--mode` alias (sets `mode`; last of `--mode`/`--permission-mode` wins), wired in PR #159 | +| `--model` / `--effort` | ✅ | +| `--max-turns` | ✅ | +| `-C` / `--cd ` | ✅ — chdir before running (Codex parity); validated eagerly, bad path exits 2 | +| `--system-prompt` / `--append-system-prompt[-file]` | ✅ | +| `--allowedTools` / `--disallowedTools` | ✅ | +| `--bare` | ✅ — suppresses the REPL startup banner (scripting / minimal output) | +| `--settings` / `--agents` / `--mcp-config` / `--plugin-dir` / `--plugin-url` | 🟡 — `--settings ` is a trusted highest-precedence override layer; `--agents`/`--mcp-config`/`--plugin-dir`/`--plugin-url` still parsed-only | +| `--no-plugins` / `--strict` | 🟡 — `--no-plugins` skips plugin discovery + wiring; `--strict` still parsed-only | +| `-p` headless | ✅ text/json/stream-json, 5 exit codes | +| `--output-format` / `--json-schema` / `--include-partial-messages` | ✅ output-format + json-schema (lightweight top-level validation) + include-partial-messages all implemented (`headless.ts`) | +| `--resume ` / `--continue` / `--fork-session` | ✅ resume by id (picker if no id, `-r`), most-recent-in-cwd (`-c`), fork-into-new | ## What DeepCode adds that Claude Code doesn't have (yet) diff --git a/packages/core/src/config/loader.test.ts b/packages/core/src/config/loader.test.ts index 8685591..9413a22 100644 --- a/packages/core/src/config/loader.test.ts +++ b/packages/core/src/config/loader.test.ts @@ -40,6 +40,29 @@ describe('settings loader', () => { expect(s.merged.effortLevel).toBe('low'); }); + it('--settings overrides all discovered layers (highest precedence)', async () => { + await writeSettings(join(home, '.deepcode', 'settings.json'), { + model: 'deepseek-chat', + effortLevel: 'low', + }); + await writeSettings(join(cwd, '.deepcode', 'settings.local.json'), { + model: 'deepseek-reasoner', + }); + const overridePath = join(cwd, 'custom-settings.json'); + await writeSettings(overridePath, { effortLevel: 'max' }); + const s = await loadSettings({ cwd, home, settingsPath: overridePath }); + expect(s.merged.model).toBe('deepseek-reasoner'); // override didn't set model → local wins + expect(s.merged.effortLevel).toBe('max'); // override wins over user's low + expect(s.layers.override).toEqual({ effortLevel: 'max' }); + expect(s.sources.overridePath).toBe(overridePath); + }); + + it('--settings with a missing file is a hard error', async () => { + await expect( + loadSettings({ cwd, home, settingsPath: join(cwd, 'does-not-exist.json') }), + ).rejects.toThrow(/--settings/); + }); + it('project overrides user', async () => { await writeSettings(join(home, '.deepcode', 'settings.json'), { model: 'deepseek-chat', diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts index 3b7868a..302ad98 100644 --- a/packages/core/src/config/loader.ts +++ b/packages/core/src/config/loader.ts @@ -17,11 +17,14 @@ export interface LoadedSettings { user?: DeepCodeSettings; project?: DeepCodeSettings; local?: DeepCodeSettings; + /** `--settings ` override — highest precedence, treated as trusted. */ + override?: DeepCodeSettings; }; sources: { userPath: string; projectPath: string; localPath: string; + overridePath?: string; }; } @@ -29,6 +32,8 @@ export interface LoadSettingsOpts { cwd: string; /** Override $HOME for tests. */ home?: string; + /** `--settings `: a settings file that wins over all discovered layers. */ + settingsPath?: string; } export function settingsPaths(opts: LoadSettingsOpts): LoadedSettings['sources'] { @@ -51,21 +56,40 @@ async function readJson(path: string): Promise { } } +/** Like readJson but the file is REQUIRED (explicit --settings path): a missing + * or unparseable file is a hard error, not a silent skip. */ +async function readJsonRequired(path: string): Promise { + try { + const raw = await fs.readFile(path, 'utf8'); + return JSON.parse(raw) as DeepCodeSettings; + } catch (err) { + throw new Error(`--settings: cannot load ${path}: ${(err as Error).message}`); + } +} + export async function loadSettings(opts: LoadSettingsOpts): Promise { const sources = settingsPaths(opts); - const [user, project, local] = await Promise.all([ + const [user, project, local, override] = await Promise.all([ readJson(sources.userPath), readJson(sources.projectPath), readJson(sources.localPath), + opts.settingsPath ? readJsonRequired(opts.settingsPath) : Promise.resolve(undefined), ]); - const merged = deepMerge( + let merged = deepMerge( deepMerge({}, (user ?? {}) as Record), deepMerge((project ?? {}) as Record, (local ?? {}) as Record), ) as DeepCodeSettings; + // --settings wins over everything discovered on disk. + if (override) { + merged = deepMerge( + merged as Record, + override as Record, + ) as DeepCodeSettings; + } return { merged, - layers: { user, project, local }, - sources, + layers: { user, project, local, override }, + sources: { ...sources, overridePath: opts.settingsPath }, }; } diff --git a/packages/core/src/config/trust-gate.test.ts b/packages/core/src/config/trust-gate.test.ts index b6a2e82..414283b 100644 --- a/packages/core/src/config/trust-gate.test.ts +++ b/packages/core/src/config/trust-gate.test.ts @@ -55,6 +55,19 @@ describe('gateUntrustedSettings', () => { expect(r.gated).toContain('apiKeyHelper'); }); + it('untrusted: --settings override is trusted — its exec fields survive', () => { + const l = loaded({ + user: { model: 'deepseek-chat' }, + project: { hooks: { Stop: [{ hooks: [{ type: 'command', command: 'rm -rf /' }] }] } }, + override: { hooks: { Stop: [{ hooks: [{ type: 'command', command: 'echo trusted' }] }] } }, + }); + const r = gateUntrustedSettings(l, 'untrusted'); + // the project layer's hooks are gated, but an explicit --settings override is + // a deliberate user choice → its hooks survive. + expect(r.gated).toContain('hooks'); + expect(JSON.stringify(r.settings.hooks)).toContain('echo trusted'); + }); + it('untrusted: gates a field set only in the local layer', () => { const l = loaded({ user: {}, diff --git a/packages/core/src/config/trust-gate.ts b/packages/core/src/config/trust-gate.ts index 7c04fd6..c6c3a20 100644 --- a/packages/core/src/config/trust-gate.ts +++ b/packages/core/src/config/trust-gate.ts @@ -40,13 +40,17 @@ export function gateUntrustedSettings(loaded: LoadedSettings, status: TrustStatu if (status === 'trusted') return { settings: loaded.merged, gated: [] }; const user = loaded.layers.user ?? {}; - const { project, local } = loaded.layers; + const { project, local, override } = loaded.layers; const settings: DeepCodeSettings = { ...loaded.merged }; const gated: TrustGatedField[] = []; for (const key of TRUST_GATED_FIELDS) { if (project?.[key] !== undefined || local?.[key] !== undefined) gated.push(key); + // Reset to the always-trusted user layer (strips untrusted project/local). copyOrDelete(settings, user, key); + // `--settings ` is an explicit user choice → trusted; re-apply its + // value on top so an override's hooks/mcp survive in an untrusted dir. + if (override?.[key] !== undefined) (settings as Record)[key] = override[key]; } return { settings, gated }; }