Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ async function main(): Promise<number> {
allowedTools: args.allowedTools,
disallowedTools: args.disallowedTools,
maxTurns: args.maxTurns,
settingsPath: args.settingsFile,
jsonSchema: args.jsonSchema,
includePartialMessages: args.includePartialMessages,
});
Expand Down Expand Up @@ -169,6 +170,7 @@ async function main(): Promise<number> {
forkSession: args.forkSession,
bare: args.bare,
noPlugins: args.noPlugins,
settingsPath: args.settingsFile,
});
}

Expand Down
4 changes: 3 additions & 1 deletion apps/cli/src/headless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export interface HeadlessOpts {
allowedTools?: string[];
disallowedTools?: string[];
maxTurns?: number;
/** `--settings <file>` → 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;
Expand All @@ -89,7 +91,7 @@ export async function runHeadless(opts: HeadlessOpts): Promise<number> {
// 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);
Expand Down
4 changes: 3 additions & 1 deletion apps/cli/src/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ export interface ReplOpts {
bare?: boolean;
/** `--no-plugins` → skip discovering + wiring installed plugins. */
noPlugins?: boolean;
/** `--settings <file>` → 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.`;
Expand Down Expand Up @@ -205,7 +207,7 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
// 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);
Expand Down
32 changes: 16 additions & 16 deletions docs/BEHAVIOR_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dir>` | ✅ — 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 <id>` / `--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 <dir>` | ✅ — 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 <file>` 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 <id>` / `--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)

Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/config/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
32 changes: 28 additions & 4 deletions packages/core/src/config/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,23 @@ export interface LoadedSettings {
user?: DeepCodeSettings;
project?: DeepCodeSettings;
local?: DeepCodeSettings;
/** `--settings <file>` override — highest precedence, treated as trusted. */
override?: DeepCodeSettings;
};
sources: {
userPath: string;
projectPath: string;
localPath: string;
overridePath?: string;
};
}

export interface LoadSettingsOpts {
cwd: string;
/** Override $HOME for tests. */
home?: string;
/** `--settings <file>`: a settings file that wins over all discovered layers. */
settingsPath?: string;
}

export function settingsPaths(opts: LoadSettingsOpts): LoadedSettings['sources'] {
Expand All @@ -51,21 +56,40 @@ async function readJson(path: string): Promise<DeepCodeSettings | undefined> {
}
}

/** 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<DeepCodeSettings> {
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<LoadedSettings> {
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<string, unknown>),
deepMerge((project ?? {}) as Record<string, unknown>, (local ?? {}) as Record<string, unknown>),
) as DeepCodeSettings;
// --settings wins over everything discovered on disk.
if (override) {
merged = deepMerge(
merged as Record<string, unknown>,
override as Record<string, unknown>,
) as DeepCodeSettings;
}
return {
merged,
layers: { user, project, local },
sources,
layers: { user, project, local, override },
sources: { ...sources, overridePath: opts.settingsPath },
};
}

Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/config/trust-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/config/trust-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>` 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<string, unknown>)[key] = override[key];
}
return { settings, gated };
}
Loading