Skip to content

Commit dfc8440

Browse files
oratistclaude
authored
feat(cli): /config set <key> <value> — edit settings from the REPL (#165)
/config was a read-only dump. Add a `set` subcommand that writes to the user settings.json: - dotted keys nest (e.g. `permissions.defaultMode`); - the value is parsed as JSON (number / bool / object / array), falling back to a string; - writes via the REPL-injected userSettingsPath (honors --home, so tests + a custom $HOME never touch the real config). Applies to new sessions (model/mode/effort still change live via /model etc.). Tests (parity-commands.test.ts, +3): dotted-key write, JSON-number parse, usage. cli 140. Doc: /config row notes `set`. Co-authored-by: t <t@t> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 675c909 commit dfc8440

4 files changed

Lines changed: 83 additions & 5 deletions

File tree

apps/cli/src/commands.ts

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@ import {
1515
contextWindowFor,
1616
estimateCost,
1717
redact,
18+
writeSettings,
1819
EFFORT_PARAMS,
1920
VERSION,
2021
type Credentials,
2122
type Effort,
2223
} from '@deepcode/core';
2324
import { execFile } from 'node:child_process';
25+
import { readFile } from 'node:fs/promises';
2426
import { promisify } from 'node:util';
2527

2628
const execFileAsync = promisify(execFile);
@@ -102,6 +104,18 @@ export function formatPrComments(data: PrCommentsData): string[] {
102104
return lines;
103105
}
104106

107+
/** Set a possibly-dotted key path on an object, creating intermediate objects. */
108+
function setDeep(obj: Record<string, unknown>, path: string, value: unknown): void {
109+
const keys = path.split('.');
110+
let o = obj;
111+
for (let i = 0; i < keys.length - 1; i++) {
112+
const k = keys[i]!;
113+
if (typeof o[k] !== 'object' || o[k] === null || Array.isArray(o[k])) o[k] = {};
114+
o = o[k] as Record<string, unknown>;
115+
}
116+
o[keys[keys.length - 1]!] = value;
117+
}
118+
105119
export interface SessionContext {
106120
cwd: string;
107121
model: string;
@@ -111,6 +125,8 @@ export interface SessionContext {
111125
creds: Credentials;
112126
/** Credentials store (REPL-injected) — backs /login and /logout. */
113127
credsStore?: CredentialsStore;
128+
/** User settings.json path (REPL-injected, honors --home) — backs /config set. */
129+
userSettingsPath?: string;
114130
sessionId: string;
115131
sessions: SessionManager;
116132
usage: {
@@ -345,12 +361,45 @@ export const ContextCommand: SlashCommand = {
345361

346362
export const ConfigCommand: SlashCommand = {
347363
name: '/config',
348-
description: 'Show resolved settings (read-only in M2).',
349-
run(_args, ctx) {
364+
description: 'Show settings, or `/config set <key> <value>` to edit (dotted keys ok).',
365+
async run(args, ctx) {
366+
if (args[0] === 'set') {
367+
const key = args[1]?.trim();
368+
const valueRaw = args.slice(2).join(' ').trim();
369+
if (!key || !valueRaw) {
370+
return [
371+
'Usage: /config set <key> <value>',
372+
' key may be dotted (e.g. permissions.defaultMode); value is parsed as JSON, else kept as a string.',
373+
];
374+
}
375+
if (!ctx.userSettingsPath) return ['(/config set is unavailable here.)'];
376+
let value: unknown;
377+
try {
378+
value = JSON.parse(valueRaw);
379+
} catch {
380+
value = valueRaw;
381+
}
382+
let current: Record<string, unknown> = {};
383+
try {
384+
current = JSON.parse(await readFile(ctx.userSettingsPath, 'utf8')) as Record<
385+
string,
386+
unknown
387+
>;
388+
} catch {
389+
/* missing/empty → start fresh */
390+
}
391+
setDeep(current, key, value);
392+
await writeSettings(ctx.userSettingsPath, current as DeepCodeSettings);
393+
return [
394+
`Set ${key} = ${JSON.stringify(value)}`,
395+
`→ ${ctx.userSettingsPath}`,
396+
'Applies to new sessions (model / mode / effort change live via /model, /mode, /effort).',
397+
];
398+
}
350399
const out = ['Current settings (merged):'];
351400
out.push(JSON.stringify(ctx.settings, null, 2).split('\n').slice(0, 40).join('\n'));
352401
out.push('');
353-
out.push('Edit ~/.deepcode/settings.json (user) or .deepcode/settings.json (project).');
402+
out.push('Edit with `/config set <key> <value>`, or ~/.deepcode/settings.json directly.');
354403
return out;
355404
},
356405
};

apps/cli/src/parity-commands.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// never real creds). /recap uses a mock provider; /pr_comments' renderer is pure.
44

55
import { afterEach, describe, expect, it } from 'vitest';
6-
import { mkdtemp, rm } from 'node:fs/promises';
6+
import { mkdtemp, readFile, rm } from 'node:fs/promises';
77
import { tmpdir } from 'node:os';
88
import { join } from 'node:path';
99
import { CredentialsStore, SessionManager } from '@deepcode/core';
@@ -154,3 +154,31 @@ describe('/upgrade + /privacy-settings', () => {
154154
expect(out).toMatch(/security-model\.md/);
155155
});
156156
});
157+
158+
describe('/config set', () => {
159+
it('writes a dotted key to the user settings file', async () => {
160+
const path = join(await tmpHome(), 'settings.json');
161+
const out = await reg
162+
.match('/config')!
163+
.cmd.run(['set', 'permissions.defaultMode', 'plan'], ctx({ userSettingsPath: path }));
164+
expect(out.join('\n')).toMatch(/Set permissions\.defaultMode/);
165+
const written = JSON.parse(await readFile(path, 'utf8')) as {
166+
permissions?: { defaultMode?: string };
167+
};
168+
expect(written.permissions?.defaultMode).toBe('plan');
169+
});
170+
171+
it('parses a JSON value (number, not string)', async () => {
172+
const path = join(await tmpHome(), 'settings.json');
173+
await reg
174+
.match('/config')!
175+
.cmd.run(['set', 'memoryLoadCapKB', '200'], ctx({ userSettingsPath: path }));
176+
const written = JSON.parse(await readFile(path, 'utf8')) as { memoryLoadCapKB?: number };
177+
expect(written.memoryLoadCapKB).toBe(200);
178+
});
179+
180+
it('shows usage for `/config set` with no key/value', async () => {
181+
const out = await reg.match('/config')!.cmd.run(['set'], ctx());
182+
expect(out.join('\n')).toMatch(/Usage: \/config set/);
183+
});
184+
});

apps/cli/src/repl.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,7 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
435435
settings,
436436
creds,
437437
credsStore,
438+
userSettingsPath: settingsPaths({ cwd, home: opts.home }).userPath,
438439
sessionId: session.id,
439440
sessions,
440441
usage: { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, cacheReadTokens: 0 },

docs/BEHAVIOR_PARITY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ Legend: `✅` matches · `🟡` matches with caveats · `🔄` deferred · `⚠
3232
| `/effort` ||| 🟡 — CLI prints the tier table (numbers from `EFFORT_PARAMS` SSOT); switch via `/effort <tier>`; arrow-key selector is GUI-only (M6) |
3333
| `/cost` / `/usage` ||||
3434
| `/context` ||||
35-
| `/config` ||(read-only) | 🟡 — Claude Code's `/config` is interactive editor; ours is JSON dump (M3c-ext for editor) |
35+
| `/config` || | 🟡 — dumps merged settings + `/config set <key> <value>` (dotted keys, JSON values) writes user settings; no full arrow-key editor |
3636
| `/resume` || ✓ (list only) | 🟡 — Claude Code has fuzzy picker; ours lists; pick via `--resume <id>` |
3737
| `/init` ||| ✅ — interactive 3-phase REPL flow (scan → draft → approve-write `AGENTS.md`) |
3838
| `/mcp` ||||

0 commit comments

Comments
 (0)