From 8b38529e2448815c64cce49d3f7c05ba84425806 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 7 Jun 2026 17:38:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(cli):=20/config=20set=20=20=20?= =?UTF-8?q?=E2=80=94=20edit=20settings=20from=20the=20REPL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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: Claude Opus 4.8 (1M context) --- apps/cli/src/commands.ts | 55 ++++++++++++++++++++++++++-- apps/cli/src/parity-commands.test.ts | 30 ++++++++++++++- apps/cli/src/repl.ts | 1 + docs/BEHAVIOR_PARITY.md | 2 +- 4 files changed, 83 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/commands.ts b/apps/cli/src/commands.ts index 0fa5c6c..7f8bd42 100644 --- a/apps/cli/src/commands.ts +++ b/apps/cli/src/commands.ts @@ -15,12 +15,14 @@ import { contextWindowFor, estimateCost, redact, + writeSettings, EFFORT_PARAMS, VERSION, type Credentials, type Effort, } from '@deepcode/core'; import { execFile } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); @@ -102,6 +104,18 @@ export function formatPrComments(data: PrCommentsData): string[] { return lines; } +/** Set a possibly-dotted key path on an object, creating intermediate objects. */ +function setDeep(obj: Record, path: string, value: unknown): void { + const keys = path.split('.'); + let o = obj; + for (let i = 0; i < keys.length - 1; i++) { + const k = keys[i]!; + if (typeof o[k] !== 'object' || o[k] === null || Array.isArray(o[k])) o[k] = {}; + o = o[k] as Record; + } + o[keys[keys.length - 1]!] = value; +} + export interface SessionContext { cwd: string; model: string; @@ -111,6 +125,8 @@ export interface SessionContext { creds: Credentials; /** Credentials store (REPL-injected) — backs /login and /logout. */ credsStore?: CredentialsStore; + /** User settings.json path (REPL-injected, honors --home) — backs /config set. */ + userSettingsPath?: string; sessionId: string; sessions: SessionManager; usage: { @@ -345,12 +361,45 @@ export const ContextCommand: SlashCommand = { export const ConfigCommand: SlashCommand = { name: '/config', - description: 'Show resolved settings (read-only in M2).', - run(_args, ctx) { + description: 'Show settings, or `/config set ` to edit (dotted keys ok).', + async run(args, ctx) { + if (args[0] === 'set') { + const key = args[1]?.trim(); + const valueRaw = args.slice(2).join(' ').trim(); + if (!key || !valueRaw) { + return [ + 'Usage: /config set ', + ' key may be dotted (e.g. permissions.defaultMode); value is parsed as JSON, else kept as a string.', + ]; + } + if (!ctx.userSettingsPath) return ['(/config set is unavailable here.)']; + let value: unknown; + try { + value = JSON.parse(valueRaw); + } catch { + value = valueRaw; + } + let current: Record = {}; + try { + current = JSON.parse(await readFile(ctx.userSettingsPath, 'utf8')) as Record< + string, + unknown + >; + } catch { + /* missing/empty → start fresh */ + } + setDeep(current, key, value); + await writeSettings(ctx.userSettingsPath, current as DeepCodeSettings); + return [ + `Set ${key} = ${JSON.stringify(value)}`, + `→ ${ctx.userSettingsPath}`, + 'Applies to new sessions (model / mode / effort change live via /model, /mode, /effort).', + ]; + } const out = ['Current settings (merged):']; out.push(JSON.stringify(ctx.settings, null, 2).split('\n').slice(0, 40).join('\n')); out.push(''); - out.push('Edit ~/.deepcode/settings.json (user) or .deepcode/settings.json (project).'); + out.push('Edit with `/config set `, or ~/.deepcode/settings.json directly.'); return out; }, }; diff --git a/apps/cli/src/parity-commands.test.ts b/apps/cli/src/parity-commands.test.ts index 4e9a790..45667c9 100644 --- a/apps/cli/src/parity-commands.test.ts +++ b/apps/cli/src/parity-commands.test.ts @@ -3,7 +3,7 @@ // never real creds). /recap uses a mock provider; /pr_comments' renderer is pure. import { afterEach, describe, expect, it } from 'vitest'; -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { CredentialsStore, SessionManager } from '@deepcode/core'; @@ -154,3 +154,31 @@ describe('/upgrade + /privacy-settings', () => { expect(out).toMatch(/security-model\.md/); }); }); + +describe('/config set', () => { + it('writes a dotted key to the user settings file', async () => { + const path = join(await tmpHome(), 'settings.json'); + const out = await reg + .match('/config')! + .cmd.run(['set', 'permissions.defaultMode', 'plan'], ctx({ userSettingsPath: path })); + expect(out.join('\n')).toMatch(/Set permissions\.defaultMode/); + const written = JSON.parse(await readFile(path, 'utf8')) as { + permissions?: { defaultMode?: string }; + }; + expect(written.permissions?.defaultMode).toBe('plan'); + }); + + it('parses a JSON value (number, not string)', async () => { + const path = join(await tmpHome(), 'settings.json'); + await reg + .match('/config')! + .cmd.run(['set', 'memoryLoadCapKB', '200'], ctx({ userSettingsPath: path })); + const written = JSON.parse(await readFile(path, 'utf8')) as { memoryLoadCapKB?: number }; + expect(written.memoryLoadCapKB).toBe(200); + }); + + it('shows usage for `/config set` with no key/value', async () => { + const out = await reg.match('/config')!.cmd.run(['set'], ctx()); + expect(out.join('\n')).toMatch(/Usage: \/config set/); + }); +}); diff --git a/apps/cli/src/repl.ts b/apps/cli/src/repl.ts index 72b2ac6..6c2c059 100644 --- a/apps/cli/src/repl.ts +++ b/apps/cli/src/repl.ts @@ -435,6 +435,7 @@ export async function startRepl(opts: ReplOpts): Promise { settings, creds, credsStore, + userSettingsPath: settingsPaths({ cwd, home: opts.home }).userPath, sessionId: session.id, sessions, usage: { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, cacheReadTokens: 0 }, diff --git a/docs/BEHAVIOR_PARITY.md b/docs/BEHAVIOR_PARITY.md index 9ec6a90..aa1fa20 100644 --- a/docs/BEHAVIOR_PARITY.md +++ b/docs/BEHAVIOR_PARITY.md @@ -32,7 +32,7 @@ Legend: `✅` matches · `🟡` matches with caveats · `🔄` deferred · `⚠ | `/effort` | ✓ | ✓ | 🟡 — CLI prints the tier table (numbers from `EFFORT_PARAMS` SSOT); switch via `/effort `; arrow-key selector is GUI-only (M6) | | `/cost` / `/usage` | ✓ | ✓ | ✅ | | `/context` | ✓ | ✓ | ✅ | -| `/config` | ✓ | ✓ (read-only) | 🟡 — Claude Code's `/config` is interactive editor; ours is JSON dump (M3c-ext for editor) | +| `/config` | ✓ | ✓ | 🟡 — dumps merged settings + `/config set ` (dotted keys, JSON values) writes user settings; no full arrow-key editor | | `/resume` | ✓ | ✓ (list only) | 🟡 — Claude Code has fuzzy picker; ours lists; pick via `--resume ` | | `/init` | ✓ | ✓ | ✅ — interactive 3-phase REPL flow (scan → draft → approve-write `AGENTS.md`) | | `/mcp` | ✓ | ✓ | ✅ |