diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 810f1e3..84c5b3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,8 +22,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 9 - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/apps/cli/package.json b/apps/cli/package.json index 48eb4ec..1a61894 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -15,7 +15,7 @@ "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "echo 'cli tests in M2' && exit 0", + "test": "vitest run", "lint": "echo 'lint: configured in M1' && exit 0", "clean": "rm -rf dist *.tsbuildinfo", "start": "node ./dist/cli.js" @@ -26,7 +26,8 @@ }, "devDependencies": { "@types/node": "^22.10.0", - "typescript": "^5.7.0" + "typescript": "^5.7.0", + "vitest": "^2.1.0" }, "engines": { "node": ">=20" diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index ad8a457..c1abb27 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -1,28 +1,98 @@ #!/usr/bin/env node // deepcode CLI entry point. -// M0 skeleton — actual REPL / slash commands / flag parsing in M2. +// Spec: docs/DEVELOPMENT_PLAN.md §5 / §5a +// M2: onboarding + REPL + slash commands + settings + permissions matcher. -import { PROJECT_NAME, VERSION } from '@deepcode/core'; +import { CredentialsStore, VERSION, redact } from '@deepcode/core'; +import { homedir } from 'node:os'; +import { resolve } from 'node:path'; +import { runOnboarding } from './onboarding.js'; +import { helpText, parseArgs } from './parse-args.js'; +import { startRepl } from './repl.js'; -const args = process.argv.slice(2); +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); -if (args.includes('--version') || args.includes('-v')) { - console.log(VERSION); - process.exit(0); + if (args.showVersion) { + process.stdout.write(VERSION + '\n'); + return 0; + } + + if (args.showHelp) { + process.stdout.write(helpText(VERSION)); + return 0; + } + + if (args.unknownFlags.length > 0) { + process.stderr.write(`Unknown or invalid flags: ${args.unknownFlags.join(' ')}\n`); + process.stderr.write(`Run \`deepcode --help\` for the full list.\n`); + return 2; + } + + if (args.doctor) { + return doctor(); + } + if (args.upgrade) { + process.stdout.write(`Run: npm i -g deepcode-cli@latest\n`); + process.stdout.write(`(Self-update via electron-updater is Mac-client only — see §4b.)\n`); + return 0; + } + + // Headless one-shot + if (args.prompt !== undefined) { + process.stderr.write( + 'Headless mode (-p) is wired in M8. Use interactive `deepcode` for now.\n', + ); + return 2; + } + + // Onboarding if no creds + const credsStore = new CredentialsStore(); + const existing = await credsStore.load(); + if (!existing.apiKey && !existing.authToken && !process.env.DEEPSEEK_API_KEY) { + const result = await runOnboarding({ + input: process.stdin, + output: process.stdout, + store: credsStore, + }); + if (result.skipped && !result.creds.apiKey && !result.creds.authToken) { + process.stdout.write('Skipped onboarding. Set DEEPSEEK_API_KEY or re-run `deepcode`.\n'); + return 0; + } + } + + // Otherwise: REPL + return startRepl({ + input: process.stdin, + output: process.stdout, + cwd: process.cwd(), + mode: args.mode, + model: args.model, + effort: args.effort, + }); } -if (args.includes('--help') || args.includes('-h')) { - console.log(`${PROJECT_NAME} v${VERSION} — pre-alpha skeleton`); - console.log(''); - console.log('Usage: deepcode [options]'); - console.log(''); - console.log(' -h, --help Show this help'); - console.log(' -v, --version Show version'); - console.log(''); - console.log('NOTE: M0 skeleton. Real REPL / -p / --mode / etc. arrive in M2+.'); - console.log('See docs/DEVELOPMENT_PLAN.md for the full milestone roadmap.'); - process.exit(0); +async function doctor(): Promise { + process.stdout.write(`DeepCode v${VERSION}\n`); + process.stdout.write(`Node: ${process.version}\n`); + process.stdout.write(`Platform: ${process.platform} ${process.arch}\n`); + process.stdout.write(`Home: ${homedir()}\n`); + process.stdout.write(`CWD: ${resolve(process.cwd())}\n`); + try { + const store = new CredentialsStore(); + const creds = await store.load(); + process.stdout.write(`API key: ${redact(creds.apiKey ?? creds.authToken)}\n`); + process.stdout.write(`Base URL: ${creds.baseURL ?? 'https://api.deepseek.com/v1'}\n`); + } catch (err) { + process.stdout.write(`Credentials error: ${(err as Error).message}\n`); + } + return 0; } -console.log(`${PROJECT_NAME} v${VERSION} — not yet usable. See \`deepcode --help\`.`); -process.exit(0); +main().then( + (code) => process.exit(code), + (err) => { + process.stderr.write(`Fatal: ${(err as Error).message}\n`); + process.exit(1); + }, +); diff --git a/apps/cli/src/commands.test.ts b/apps/cli/src/commands.test.ts new file mode 100644 index 0000000..3cd9ce1 --- /dev/null +++ b/apps/cli/src/commands.test.ts @@ -0,0 +1,171 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { SessionManager } from '@deepcode/core'; +import { CommandRegistry, type SessionContext } from './commands.js'; + +function makeContext(overrides: Partial = {}): SessionContext { + return { + cwd: '/tmp/x', + model: 'deepseek-chat', + mode: 'default', + effort: 'medium', + settings: {}, + creds: { apiKey: 'sk-abcdefghij' }, + sessionId: 'sess-xyz', + sessions: new SessionManager({ root: '/tmp/x' }), + usage: { inputTokens: 100, outputTokens: 50, reasoningTokens: 0 }, + ...overrides, + }; +} + +describe('CommandRegistry', () => { + const reg = new CommandRegistry(); + + it('matches /help', () => { + expect(reg.match('/help')).toMatchObject({ cmd: { name: '/help' } }); + }); + + it('matches alias /?', () => { + expect(reg.match('/?')).toMatchObject({ cmd: { name: '/help' } }); + }); + + it('matches with args', () => { + const m = reg.match('/model deepseek-reasoner'); + expect(m?.cmd.name).toBe('/model'); + expect(m?.args).toEqual(['deepseek-reasoner']); + }); + + it('returns null for non-slash input', () => { + expect(reg.match('not a command')).toBeNull(); + expect(reg.match('hello /world')).toBeNull(); + }); + + it('returns null for unknown command', () => { + expect(reg.match('/nope')).toBeNull(); + }); + + it('list() includes all built-ins (deduped by alias)', () => { + const names = reg.list().map((c) => c.name); + expect(names).toContain('/help'); + expect(names).toContain('/model'); + expect(names).toContain('/mode'); + expect(names).toContain('/exit'); + expect(new Set(names).size).toBe(names.length); // no dupes + }); +}); + +describe('built-in command behavior', () => { + let sessRoot: string; + beforeEach(async () => { + sessRoot = await mkdtemp(join(tmpdir(), 'dc-cmd-')); + }); + afterEach(async () => { + await rm(sessRoot, { recursive: true, force: true }); + }); + + it('/help lists commands', async () => { + const reg = new CommandRegistry(); + const m = reg.match('/help')!; + const out = await m.cmd.run([], makeContext()); + expect(out.join('\n')).toMatch(/\/help/); + expect(out.join('\n')).toMatch(/\/exit/); + }); + + it('/clear sets clearHistory flag', async () => { + const reg = new CommandRegistry(); + const ctx = makeContext(); + const m = reg.match('/clear')!; + await m.cmd.run([], ctx); + expect(ctx.clearHistory).toBe(true); + }); + + it('/exit sets exitRequested', async () => { + const reg = new CommandRegistry(); + const ctx = makeContext(); + await reg.match('/exit')!.cmd.run([], ctx); + expect(ctx.exitRequested).toBe(true); + }); + + it('/model switches model when valid', async () => { + const reg = new CommandRegistry(); + const ctx = makeContext(); + await reg.match('/model deepseek-reasoner')!.cmd.run(['deepseek-reasoner'], ctx); + expect(ctx.model).toBe('deepseek-reasoner'); + }); + + it('/model rejects invalid name', async () => { + const reg = new CommandRegistry(); + const ctx = makeContext(); + const out = await reg.match('/model wrong')!.cmd.run(['wrong'], ctx); + expect(out.join('\n')).toMatch(/Unknown model/); + expect(ctx.model).toBe('deepseek-chat'); // unchanged + }); + + it('/mode switches when valid', async () => { + const reg = new CommandRegistry(); + const ctx = makeContext(); + await reg.match('/mode plan')!.cmd.run(['plan'], ctx); + expect(ctx.mode).toBe('plan'); + }); + + it('/effort switches when valid', async () => { + const reg = new CommandRegistry(); + const ctx = makeContext(); + await reg.match('/effort high')!.cmd.run(['high'], ctx); + expect(ctx.effort).toBe('high'); + }); + + it('/status emits session info', async () => { + const reg = new CommandRegistry(); + const ctx = makeContext({ sessions: new SessionManager({ root: sessRoot }) }); + const out = await reg.match('/status')!.cmd.run([], ctx); + const joined = out.join('\n'); + expect(joined).toMatch(/Session/); + expect(joined).toMatch(/Model/); + expect(joined).toMatch(/sk-a…ghij/); // redacted key + }); + + it('/cost computes pricing', async () => { + const reg = new CommandRegistry(); + const ctx = makeContext({ + usage: { inputTokens: 1_000_000, outputTokens: 500_000, reasoningTokens: 0 }, + }); + const out = await reg.match('/cost')!.cmd.run([], ctx); + expect(out.join('\n')).toMatch(/Tokens/); + expect(out.join('\n')).toMatch(/Total/); + }); + + it('/context shows window usage', async () => { + const reg = new CommandRegistry(); + const out = await reg.match('/context')!.cmd.run([], makeContext()); + expect(out.join('\n')).toMatch(/128,000/); + expect(out.join('\n')).toMatch(/Context:/); + }); + + it('/config dumps settings', async () => { + const reg = new CommandRegistry(); + const ctx = makeContext({ settings: { model: 'deepseek-chat' } }); + const out = await reg.match('/config')!.cmd.run([], ctx); + expect(out.join('\n')).toMatch(/Current settings/); + expect(out.join('\n')).toMatch(/deepseek-chat/); + }); + + it('/resume reports no sessions cleanly', async () => { + const reg = new CommandRegistry(); + const ctx = makeContext({ sessions: new SessionManager({ root: sessRoot }) }); + const out = await reg.match('/resume')!.cmd.run([], ctx); + expect(out.join('\n')).toMatch(/No previous sessions/); + }); + + it('/resume lists known sessions', async () => { + const reg = new CommandRegistry(); + const sm = new SessionManager({ root: sessRoot }); + await sm.create('/foo'); + await sm.create('/bar'); + const ctx = makeContext({ sessions: sm }); + const out = await reg.match('/resume')!.cmd.run([], ctx); + expect(out.join('\n')).toMatch(/Recent sessions/); + }); +}); diff --git a/apps/cli/src/commands.ts b/apps/cli/src/commands.ts new file mode 100644 index 0000000..ed50527 --- /dev/null +++ b/apps/cli/src/commands.ts @@ -0,0 +1,272 @@ +// CLI slash commands — pure functions over a session context. +// Spec: docs/DEVELOPMENT_PLAN.md §3.6 (30+ commands; M2 ships a core subset) + +import type { DeepCodeSettings, SessionManager, SessionMeta } from '@deepcode/core'; +import { redact, type Credentials } from '@deepcode/core'; + +export interface SessionContext { + cwd: string; + model: string; + mode: string; + effort: string; + settings: DeepCodeSettings; + creds: Credentials; + sessionId: string; + sessions: SessionManager; + usage: { inputTokens: number; outputTokens: number; reasoningTokens: number }; + /** Set true to terminate the REPL after this command. */ + exitRequested?: boolean; + /** Replace history entirely (used by /clear, /resume). */ + clearHistory?: boolean; +} + +export interface SlashCommand { + name: string; + aliases?: string[]; + description: string; + /** Returns the lines to print after the command runs (or empty array). */ + run(args: string[], ctx: SessionContext): Promise | string[]; +} + +// ────────────────────────────────────────────────────────────────────────── +// Built-in commands (M2 subset of the 30+ planned) +// ────────────────────────────────────────────────────────────────────────── + +export const HelpCommand: SlashCommand = { + name: '/help', + aliases: ['/?'], + description: 'Show available commands.', + run() { + const lines = ['Available commands:']; + for (const cmd of BUILTIN_COMMANDS) { + const aliases = cmd.aliases?.length ? ` (${cmd.aliases.join(', ')})` : ''; + lines.push(` ${cmd.name.padEnd(12)} ${cmd.description}${aliases}`); + } + lines.push(''); + lines.push("Type your message to chat. Use '@' for files, '/' for commands, '#' to remember."); + lines.push('Press Ctrl+C twice to exit.'); + return lines; + }, +}; + +export const ClearCommand: SlashCommand = { + name: '/clear', + description: 'Clear conversation history (keeps session ID).', + run(_args, ctx) { + ctx.clearHistory = true; + return ['Conversation history cleared.']; + }, +}; + +export const ExitCommand: SlashCommand = { + name: '/exit', + aliases: ['/quit'], + description: 'Exit DeepCode.', + run(_args, ctx) { + ctx.exitRequested = true; + return ['Bye.']; + }, +}; + +export const StatusCommand: SlashCommand = { + name: '/status', + aliases: ['/doctor'], + description: 'Show session + environment info.', + async run(_args, ctx) { + const sessionMetas: SessionMeta[] = await ctx.sessions.list(); + return [ + `Session : ${ctx.sessionId}`, + `CWD : ${ctx.cwd}`, + `Model : ${ctx.model}`, + `Mode : ${ctx.mode}`, + `Effort : ${ctx.effort}`, + `API key : ${redact(ctx.creds.apiKey ?? ctx.creds.authToken)}`, + `Base URL : ${ctx.creds.baseURL ?? 'https://api.deepseek.com/v1'}`, + `Sessions : ${sessionMetas.length} total`, + ``, + `Usage this session: ${ctx.usage.inputTokens} in / ${ctx.usage.outputTokens} out / ${ctx.usage.reasoningTokens} reasoning`, + ]; + }, +}; + +export const ModelCommand: SlashCommand = { + name: '/model', + description: 'Switch model: /model deepseek-chat | deepseek-reasoner', + run(args, ctx) { + if (args.length === 0) return [`Current model: ${ctx.model}`]; + const next = args[0]!; + if (next !== 'deepseek-chat' && next !== 'deepseek-reasoner') { + return [`Unknown model "${next}". Valid: deepseek-chat | deepseek-reasoner`]; + } + ctx.model = next; + return [`Model switched to ${next}.`]; + }, +}; + +export const ModeCommand: SlashCommand = { + name: '/mode', + description: 'Switch mode: /mode default|acceptEdits|plan|auto|dontAsk|bypassPermissions', + run(args, ctx) { + const valid = ['default', 'acceptEdits', 'plan', 'auto', 'dontAsk', 'bypassPermissions']; + if (args.length === 0) return [`Current mode: ${ctx.mode}`]; + const next = args[0]!; + if (!valid.includes(next)) return [`Unknown mode "${next}". Valid: ${valid.join(' | ')}`]; + ctx.mode = next; + return [`Mode switched to ${next}.`]; + }, +}; + +export const EffortCommand: SlashCommand = { + name: '/effort', + description: 'Set effort tier: /effort low|medium|high|xhigh|max', + run(args, ctx) { + const valid = ['low', 'medium', 'high', 'xhigh', 'max']; + if (args.length === 0) return [`Current effort: ${ctx.effort}`]; + const next = args[0]!; + if (!valid.includes(next)) return [`Unknown effort "${next}". Valid: ${valid.join(' | ')}`]; + ctx.effort = next; + return [`Effort switched to ${next}.`]; + }, +}; + +export const CostCommand: SlashCommand = { + name: '/cost', + aliases: ['/usage'], + description: 'Show token usage and cost estimate.', + run(_args, ctx) { + // Pricing per docs/design/effort-levels.md §2.4 + const inputYuan = (ctx.usage.inputTokens / 1_000_000) * 1.0; + const outputYuan = + ctx.model === 'deepseek-reasoner' + ? (ctx.usage.outputTokens / 1_000_000) * 16.0 + : (ctx.usage.outputTokens / 1_000_000) * 2.0; + const reasoningYuan = + ctx.model === 'deepseek-reasoner' ? (ctx.usage.reasoningTokens / 1_000_000) * 4.0 : 0; + const total = inputYuan + outputYuan + reasoningYuan; + return [ + `Tokens — in: ${ctx.usage.inputTokens.toLocaleString()}, out: ${ctx.usage.outputTokens.toLocaleString()}, reasoning: ${ctx.usage.reasoningTokens.toLocaleString()}`, + `Estimate — input: ¥${inputYuan.toFixed(4)}, output: ¥${outputYuan.toFixed(4)}, reasoning: ¥${reasoningYuan.toFixed(4)}`, + `Total this session: ¥${total.toFixed(4)}`, + ]; + }, +}; + +export const ContextCommand: SlashCommand = { + name: '/context', + description: 'Show context window usage.', + run(_args, ctx) { + const used = ctx.usage.inputTokens + ctx.usage.outputTokens; + const ctxMax = 128_000; + const pct = ((used / ctxMax) * 100).toFixed(1); + return [ + `Context: ${used.toLocaleString()} / ${ctxMax.toLocaleString()} (${pct}%)`, + `Next compaction threshold: ${Math.round(ctxMax * 0.8).toLocaleString()} (80%) — M3 feature`, + ]; + }, +}; + +export const ConfigCommand: SlashCommand = { + name: '/config', + description: 'Show resolved settings (read-only in M2).', + run(_args, ctx) { + 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).'); + return out; + }, +}; + +export const AddDirCommand: SlashCommand = { + name: '/add-dir', + description: 'Add an additional allowed directory (M3 enforced; M2 records intent).', + run(args) { + if (args.length === 0) return ['Usage: /add-dir ']; + return [`Recorded ${args[0]} as additional allowed directory (effective in M3).`]; + }, +}; + +export const ResumeCommand: SlashCommand = { + name: '/resume', + description: 'List recent sessions.', + async run(_args, ctx) { + const sessions = await ctx.sessions.list(); + if (sessions.length === 0) return ['No previous sessions.']; + const top = sessions.slice(0, 10); + return [ + 'Recent sessions (top 10):', + ...top.map( + (s, i) => ` ${String(i + 1).padStart(2)}. ${s.id} ${s.title ?? s.cwd} (${s.updatedAt})`, + ), + '', + 'To resume: deepcode --resume (M2 picker in next iteration).', + ]; + }, +}; + +export const InitCommand: SlashCommand = { + name: '/init', + description: 'Write a starter DEEPCODE.md (M3 makes this fully interactive).', + run(_args, ctx) { + return [ + `Will write a starter DEEPCODE.md at ${ctx.cwd}/DEEPCODE.md.`, + `(M2 stub — full multi-phase interactive flow lands in M3 per DEVELOPMENT_PLAN.md §3.6.)`, + ]; + }, +}; + +export const TodosCommand: SlashCommand = { + name: '/todos', + description: 'Show active TODO list (M3 wires TodoWrite tool).', + run() { + return ['No active todos — TodoWrite tool ships in M3.']; + }, +}; + +export const BUILTIN_COMMANDS: SlashCommand[] = [ + HelpCommand, + ClearCommand, + ExitCommand, + StatusCommand, + ModelCommand, + ModeCommand, + EffortCommand, + CostCommand, + ContextCommand, + ConfigCommand, + AddDirCommand, + ResumeCommand, + InitCommand, + TodosCommand, +]; + +// ────────────────────────────────────────────────────────────────────────── +// Registry — dispatch by name OR alias +// ────────────────────────────────────────────────────────────────────────── + +export class CommandRegistry { + private readonly byName = new Map(); + + constructor(initial: SlashCommand[] = BUILTIN_COMMANDS) { + for (const c of initial) this.register(c); + } + + register(cmd: SlashCommand): void { + this.byName.set(cmd.name, cmd); + for (const a of cmd.aliases ?? []) this.byName.set(a, cmd); + } + + list(): SlashCommand[] { + return [...new Set(this.byName.values())]; + } + + match(line: string): { cmd: SlashCommand; args: string[] } | null { + const trimmed = line.trim(); + if (!trimmed.startsWith('/')) return null; + const parts = trimmed.split(/\s+/); + const name = parts[0]!; + const cmd = this.byName.get(name); + if (!cmd) return null; + return { cmd, args: parts.slice(1) }; + } +} diff --git a/apps/cli/src/onboarding.ts b/apps/cli/src/onboarding.ts index 22310ae..8452426 100644 --- a/apps/cli/src/onboarding.ts +++ b/apps/cli/src/onboarding.ts @@ -1,6 +1,117 @@ -// Module: Onboarding (CLI) -// Milestone: M2 +// Onboarding — interactive API key entry on first run. // Spec: docs/DEVELOPMENT_PLAN.md §3.4 -// Status: placeholder +// M2: prompt → validate format → save to credentials store. Real network +// validation against DeepSeek's /user/balance is deferred until apiKeyHelper +// refresh loop ships in M3. -export {}; +import { CredentialsStore, type Credentials, redact } from '@deepcode/core'; +import { createInterface, type Interface } from 'node:readline/promises'; +import type { Readable, Writable } from 'node:stream'; + +const BANNER = ` + ╭─ DeepCode ────────────────────────────────────╮ + │ │ + │ Welcome. Let's connect to DeepSeek. │ + │ │ + │ 1) Get a key: https://platform.deepseek.com │ + │ 2) Paste it below (input is hidden): │ + │ │ + ╰───────────────────────────────────────────────╯ +`; + +export interface OnboardingResult { + creds: Credentials; + skipped: boolean; +} + +export interface OnboardingIO { + input: Readable; + output: Writable; +} + +export interface OnboardingOpts extends OnboardingIO { + store: CredentialsStore; +} + +export async function runOnboarding(opts: OnboardingOpts): Promise { + const existing = await opts.store.load(); + if (existing.apiKey || existing.authToken) { + return { creds: existing, skipped: true }; + } + + opts.output.write(BANNER + '\n'); + + const rl = createInterface({ input: opts.input, output: opts.output }); + try { + const apiKey = await promptHidden(rl, opts.output, 'DeepSeek API Key: '); + if (!apiKey) { + opts.output.write('No key provided — skipping (re-run later or set DEEPSEEK_API_KEY).\n'); + return { creds: {}, skipped: true }; + } + if (!looksLikeDeepSeekKey(apiKey)) { + opts.output.write( + '⚠ This does not look like a typical DeepSeek API key (expected sk-… or similar).\n Saving anyway — you can re-run onboarding if it fails.\n', + ); + } + const baseURLRaw = (await rl.question(`Base URL [https://api.deepseek.com/v1]: `)).trim(); + const baseURL = baseURLRaw || undefined; + const creds: Credentials = { apiKey, baseURL }; + await opts.store.save(creds); + opts.output.write(`\n ✓ Saved ${redact(apiKey)}\n`); + if (baseURL) opts.output.write(` ✓ Base URL: ${baseURL}\n`); + opts.output.write('\n'); + return { creds, skipped: false }; + } finally { + rl.close(); + } +} + +export function looksLikeDeepSeekKey(value: string): boolean { + return /^sk-[A-Za-z0-9_-]{8,}$/.test(value); +} + +/** + * Prompt for input with character masking (★) to keep secrets off-screen. + * Implemented via stdin raw-mode + ★ echo per keystroke. + */ +export function promptHidden(rl: Interface, output: Writable, question: string): Promise { + return new Promise((resolvePromise, rejectPromise) => { + output.write(question); + const input = (rl as unknown as { input: NodeJS.ReadStream }).input; + let muted = ''; + const isTTY = input.isTTY === true; + if (isTTY && input.setRawMode) { + input.setRawMode(true); + } + const onData = (chunk: Buffer): void => { + const s = chunk.toString('utf8'); + for (const ch of s) { + if (ch === '') { + cleanup(); + rejectPromise(new Error('cancelled')); + return; + } + if (ch === '\n' || ch === '\r') { + output.write('\n'); + cleanup(); + resolvePromise(muted); + return; + } + if (ch === '' || ch === '\b') { + if (muted.length > 0) { + muted = muted.slice(0, -1); + output.write('\b \b'); + } + continue; + } + muted += ch; + output.write('★'); + } + }; + const cleanup = (): void => { + input.off('data', onData); + if (isTTY && input.setRawMode) input.setRawMode(false); + }; + input.on('data', onData); + }); +} diff --git a/apps/cli/src/parse-args.test.ts b/apps/cli/src/parse-args.test.ts new file mode 100644 index 0000000..eeb2ce1 --- /dev/null +++ b/apps/cli/src/parse-args.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; +import { parseArgs } from './parse-args.js'; + +describe('parseArgs', () => { + it('parses empty argv', () => { + const p = parseArgs([]); + expect(p.showHelp).toBe(false); + expect(p.showVersion).toBe(false); + expect(p.outputFormat).toBe('text'); + }); + + it('--help / -h', () => { + expect(parseArgs(['--help']).showHelp).toBe(true); + expect(parseArgs(['-h']).showHelp).toBe(true); + }); + + it('--version / -v', () => { + expect(parseArgs(['--version']).showVersion).toBe(true); + expect(parseArgs(['-v']).showVersion).toBe(true); + }); + + it('doctor / upgrade subcommands', () => { + expect(parseArgs(['doctor']).doctor).toBe(true); + expect(parseArgs(['upgrade']).upgrade).toBe(true); + }); + + it('-p "prompt"', () => { + expect(parseArgs(['-p', 'do the thing']).prompt).toBe('do the thing'); + expect(parseArgs(['--print', 'foo']).prompt).toBe('foo'); + }); + + it('--resume without id', () => { + const p = parseArgs(['--resume']); + expect(p.resume).toBe(true); + expect(p.resumeId).toBeUndefined(); + }); + it('--resume ', () => { + const p = parseArgs(['--resume', 'sess-abc']); + expect(p.resume).toBe(true); + expect(p.resumeId).toBe('sess-abc'); + }); + it('--resume followed by next flag has no id', () => { + const p = parseArgs(['--resume', '--mode', 'plan']); + expect(p.resume).toBe(true); + expect(p.resumeId).toBeUndefined(); + expect(p.mode).toBe('plan'); + }); + + it('--mode validation', () => { + expect(parseArgs(['--mode', 'plan']).mode).toBe('plan'); + expect(parseArgs(['--mode', 'bypassPermissions']).mode).toBe('bypassPermissions'); + const p = parseArgs(['--mode', 'invalid']); + expect(p.mode).toBeUndefined(); + expect(p.unknownFlags).toContain('--mode invalid'); + }); + + it('--effort validation', () => { + expect(parseArgs(['--effort', 'high']).effort).toBe('high'); + expect(parseArgs(['--effort', 'wrong']).unknownFlags).toContain('--effort wrong'); + }); + + it('--max-turns parses to number', () => { + expect(parseArgs(['--max-turns', '5']).maxTurns).toBe(5); + expect(parseArgs(['--max-turns', 'NaN']).unknownFlags).toContain('--max-turns NaN'); + }); + + it('--allowedTools / --disallowedTools comma-list', () => { + expect(parseArgs(['--allowedTools', 'Read,Grep,Edit']).allowedTools).toEqual([ + 'Read', + 'Grep', + 'Edit', + ]); + expect(parseArgs(['--disallowedTools', 'Bash, WebFetch']).disallowedTools).toEqual([ + 'Bash', + 'WebFetch', + ]); + }); + + it('--output-format validation', () => { + expect(parseArgs(['--output-format', 'json']).outputFormat).toBe('json'); + expect(parseArgs(['--output-format', 'stream-json']).outputFormat).toBe('stream-json'); + expect(parseArgs(['--output-format', 'wrong']).unknownFlags).toContain('--output-format wrong'); + }); + + it('boolean flags', () => { + const p = parseArgs([ + '--bare', + '--no-plugins', + '--strict', + '--verbose', + '--include-partial-messages', + '--fork-session', + '--continue', + ]); + expect(p.bare).toBe(true); + expect(p.noPlugins).toBe(true); + expect(p.strict).toBe(true); + expect(p.verbose).toBe(true); + expect(p.includePartialMessages).toBe(true); + expect(p.forkSession).toBe(true); + expect(p.continue).toBe(true); + }); + + it('captures unknown flags', () => { + const p = parseArgs(['--made-up-flag']); + expect(p.unknownFlags).toEqual(['--made-up-flag']); + }); + + it('collects positional args', () => { + const p = parseArgs(['foo', 'bar']); + expect(p.positional).toEqual(['foo', 'bar']); + }); + + it('combo: realistic invocation', () => { + const p = parseArgs([ + '--mode', + 'acceptEdits', + '--model', + 'deepseek-reasoner', + '--effort', + 'high', + '--max-turns', + '12', + ]); + expect(p.mode).toBe('acceptEdits'); + expect(p.model).toBe('deepseek-reasoner'); + expect(p.effort).toBe('high'); + expect(p.maxTurns).toBe(12); + expect(p.unknownFlags).toEqual([]); + }); +}); diff --git a/apps/cli/src/parse-args.ts b/apps/cli/src/parse-args.ts new file mode 100644 index 0000000..6ee1acb --- /dev/null +++ b/apps/cli/src/parse-args.ts @@ -0,0 +1,288 @@ +// CLI argv parser — minimal, dependency-free, designed for the flag set in +// docs/DEVELOPMENT_PLAN.md §5. +// Returns a strongly-typed shape. Unknown flags are collected into `unknown` for +// graceful "did you mean..." errors. + +import type { Effort, Mode } from '@deepcode/core'; + +export interface ParsedArgs { + // Action triggers (mutually exclusive — first match wins) + showHelp: boolean; + showVersion: boolean; + doctor: boolean; + upgrade: boolean; + + // Mode of execution + prompt?: string; // -p / --print, one-shot + resume: boolean; // --resume (interactive picker) + resumeId?: string; // --resume + continue: boolean; + forkSession: boolean; + + // Session shaping + mode?: Mode; + permissionMode?: Mode; + model?: string; + effort?: Effort; + maxTurns?: number; + bare: boolean; + + // System prompt overrides + systemPrompt?: string; + appendSystemPrompt?: string; + appendSystemPromptFile?: string; + + // Tool allow/deny lists + allowedTools?: string[]; + disallowedTools?: string[]; + + // Output (headless mode) + outputFormat: 'text' | 'json' | 'stream-json'; + jsonSchema?: string; + includePartialMessages: boolean; + verbose: boolean; + + // Settings overrides + settingsFile?: string; + agentsDir?: string; + mcpConfig?: string; + pluginDir?: string; + pluginUrl?: string; + noPlugins: boolean; + strict: boolean; + + // Diagnostics + unknownFlags: string[]; + + // Positional args (rarely used) + positional: string[]; +} + +const VALID_MODES: Mode[] = [ + 'default', + 'acceptEdits', + 'plan', + 'auto', + 'dontAsk', + 'bypassPermissions', +]; +const VALID_EFFORTS: Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']; + +export function parseArgs(argv: string[]): ParsedArgs { + const out: ParsedArgs = { + showHelp: false, + showVersion: false, + doctor: false, + upgrade: false, + resume: false, + continue: false, + forkSession: false, + bare: false, + outputFormat: 'text', + includePartialMessages: false, + verbose: false, + noPlugins: false, + strict: false, + unknownFlags: [], + positional: [], + }; + + for (let i = 0; i < argv.length; i++) { + const a = argv[i]!; + const next = (): string | undefined => argv[++i]; + + switch (true) { + case a === '-h' || a === '--help': + out.showHelp = true; + break; + case a === '-v' || a === '--version': + out.showVersion = true; + break; + case a === 'doctor': + out.doctor = true; + break; + case a === 'upgrade': + out.upgrade = true; + break; + case a === '-p' || a === '--print': + out.prompt = next(); + break; + case a === '--resume': { + const maybeId = argv[i + 1]; + if (maybeId && !maybeId.startsWith('-')) { + out.resumeId = maybeId; + i++; + } + out.resume = true; + break; + } + case a === '--continue': + out.continue = true; + break; + case a === '--fork-session': + out.forkSession = true; + break; + case a === '--mode': { + const v = next(); + if (v && (VALID_MODES as string[]).includes(v)) out.mode = v as Mode; + else out.unknownFlags.push(`--mode ${v ?? ''}`); + break; + } + case a === '--permission-mode': { + const v = next(); + if (v && (VALID_MODES as string[]).includes(v)) out.permissionMode = v as Mode; + else out.unknownFlags.push(`--permission-mode ${v ?? ''}`); + break; + } + case a === '--model': + out.model = next(); + break; + case a === '--effort': { + const v = next(); + if (v && (VALID_EFFORTS as string[]).includes(v)) out.effort = v as Effort; + else out.unknownFlags.push(`--effort ${v ?? ''}`); + break; + } + case a === '--max-turns': { + const v = next(); + const n = v ? Number.parseInt(v, 10) : NaN; + if (Number.isFinite(n) && n > 0) out.maxTurns = n; + else out.unknownFlags.push(`--max-turns ${v ?? ''}`); + break; + } + case a === '--bare': + out.bare = true; + break; + case a === '--system-prompt': + out.systemPrompt = next(); + break; + case a === '--append-system-prompt': + out.appendSystemPrompt = next(); + break; + case a === '--append-system-prompt-file': + out.appendSystemPromptFile = next(); + break; + case a === '--allowedTools': { + const v = next(); + if (v) + out.allowedTools = v + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + break; + } + case a === '--disallowedTools': { + const v = next(); + if (v) + out.disallowedTools = v + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + break; + } + case a === '--output-format': { + const v = next(); + if (v === 'text' || v === 'json' || v === 'stream-json') out.outputFormat = v; + else out.unknownFlags.push(`--output-format ${v ?? ''}`); + break; + } + case a === '--json-schema': + out.jsonSchema = next(); + break; + case a === '--include-partial-messages': + out.includePartialMessages = true; + break; + case a === '--verbose': + out.verbose = true; + break; + case a === '--settings': + out.settingsFile = next(); + break; + case a === '--agents': + out.agentsDir = next(); + break; + case a === '--mcp-config': + out.mcpConfig = next(); + break; + case a === '--plugin-dir': + out.pluginDir = next(); + break; + case a === '--plugin-url': + out.pluginUrl = next(); + break; + case a === '--no-plugins': + out.noPlugins = true; + break; + case a === '--strict': + out.strict = true; + break; + case a.startsWith('--'): + out.unknownFlags.push(a); + break; + case a.startsWith('-'): + out.unknownFlags.push(a); + break; + default: + out.positional.push(a); + break; + } + } + + return out; +} + +export function helpText(version: string): string { + return `DeepCode v${version} — DeepSeek-powered AI coding agent (Claude Code parity) + +USAGE + deepcode Interactive REPL + deepcode -p "" Headless one-shot + deepcode --resume [] Resume a session + deepcode --continue Continue most recent session + deepcode doctor Diagnostic checks + deepcode upgrade Self-update (CLI; Mac client auto-updates) + +MODE + --mode default / acceptEdits / plan / auto / dontAsk / bypassPermissions + --permission-mode Alias for --mode (Claude Code parity) + --bare No plugins / MCP / skills — just kernel + tools + +MODEL & EFFORT + --model deepseek-chat | deepseek-reasoner + --effort low | medium | high | xhigh | max + --max-turns Cap agent loop turns + +SYSTEM PROMPT + --system-prompt "" Replace default system prompt + --append-system-prompt "" Append to default + --append-system-prompt-file Append from a file + +TOOLS + --allowedTools "Tool,..." Whitelist + --disallowedTools "Tool,..." Blacklist + +HEADLESS / CI (-p mode only) + --output-format text|json|stream-json + --json-schema Constrain final output to a JSON schema + --include-partial-messages Stream partial deltas + --verbose Print LLM/tool call traces + +OVERRIDES + --settings Override settings.json discovery + --agents Override sub-agents dir + --mcp-config Override MCP server config + --plugin-dir Temporarily mount a plugin dir + --plugin-url Temporarily mount a remote plugin + --no-plugins Disable all plugins for this run + --strict Strict mode: only official-marketplace plugins, no hooks + +DIAGNOSTICS + -h, --help Show this + -v, --version Show version + +Configuration: ~/.deepcode/settings.json · /.deepcode/settings.json · /.deepcode/settings.local.json +Credentials: macOS Keychain (service=deepcode) · ~/.deepcode/credentials.json (chmod 600) +Sessions: ~/.deepcode/sessions/ +Docs: https://github.com/oratis/deepcode#docs +`; +} diff --git a/apps/cli/src/repl.ts b/apps/cli/src/repl.ts index cd4d868..f273afb 100644 --- a/apps/cli/src/repl.ts +++ b/apps/cli/src/repl.ts @@ -1,6 +1,193 @@ -// Module: REPL -// Milestone: M2 +// CLI REPL — readline-based interactive loop. // Spec: docs/DEVELOPMENT_PLAN.md §5 -// Status: placeholder -export {}; +import { + CredentialsStore, + DeepSeekProvider, + EFFORT_PARAMS, + SessionManager, + ToolRegistry, + loadSettings, + resolveCredentials, + runAgent, + type DeepCodeSettings, + type Effort, + type AgentEvent, + type StoredMessage, +} from '@deepcode/core'; +import { createInterface } from 'node:readline/promises'; +import type { Readable, Writable } from 'node:stream'; +import { CommandRegistry, type SessionContext } from './commands.js'; + +export interface ReplOpts { + input: Readable; + output: Writable; + cwd: string; + /** Override $HOME for tests. */ + home?: string; + /** Initial mode (overrides settings). */ + mode?: string; + /** Initial model (overrides settings). */ + model?: string; + /** Initial effort (overrides settings). */ + effort?: Effort; +} + +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.`; + +export async function startRepl(opts: ReplOpts): Promise { + const { output, cwd } = opts; + + // Load config + creds + const loaded = await loadSettings({ cwd, home: opts.home }); + const settings: DeepCodeSettings = loaded.merged; + const credsStore = new CredentialsStore({ home: opts.home }); + const creds = await resolveCredentials({ + store: credsStore, + apiKeyHelper: settings.apiKeyHelper, + }); + + if (!creds.apiKey && !creds.authToken) { + output.write( + 'No DeepSeek credentials found. Run `deepcode` (no args) to onboard, or set DEEPSEEK_API_KEY.\n', + ); + return 1; + } + + const model = opts.model ?? settings.model ?? 'deepseek-chat'; + const mode = opts.mode ?? settings.permissions?.defaultMode ?? 'default'; + const effort = opts.effort ?? settings.effortLevel ?? 'medium'; + const { maxTokens, temperature } = EFFORT_PARAMS[effort as Effort] ?? EFFORT_PARAMS.medium; + + const sessions = new SessionManager(); + const session = await sessions.create(cwd, { model }); + + const provider = new DeepSeekProvider({ + apiKey: creds.apiKey ?? '', + authToken: creds.authToken, + baseURL: creds.baseURL ?? settings.baseURL, + }); + const tools = new ToolRegistry(); + const commands = new CommandRegistry(); + + let history: StoredMessage[] = []; + const ctx: SessionContext = { + cwd, + model, + mode, + effort, + settings, + creds, + sessionId: session.id, + sessions, + usage: { inputTokens: 0, outputTokens: 0, reasoningTokens: 0 }, + }; + + output.write(`\n ▎ DeepCode · ${ctx.model} · mode: ${ctx.mode} · effort: ${ctx.effort}\n`); + output.write(` Working in ${cwd}\n`); + output.write(` Type /help for commands, /exit to quit.\n\n`); + + const rl = createInterface({ input: opts.input, output, terminal: true }); + + let ctrlCCount = 0; + rl.on('SIGINT', () => { + ctrlCCount++; + if (ctrlCCount >= 2) { + output.write('\nGoodbye.\n'); + rl.close(); + return; + } + output.write('\n(Press Ctrl+C again to exit.)\n'); + setTimeout(() => { + ctrlCCount = 0; + }, 2000); + }); + + while (true) { + let userInput: string; + try { + userInput = await rl.question('› '); + } catch { + break; + } + ctrlCCount = 0; + + if (!userInput.trim()) continue; + + // Slash command? + const match = commands.match(userInput); + if (match) { + const lines = await Promise.resolve(match.cmd.run(match.args, ctx)); + for (const line of lines) output.write(line + '\n'); + output.write('\n'); + if (ctx.clearHistory) { + history = []; + ctx.clearHistory = false; + } + if (ctx.exitRequested) break; + continue; + } + + // Otherwise: send to agent + const result = await runAgent({ + provider, + tools, + systemPrompt: DEFAULT_SYSTEM_PROMPT, + userMessage: userInput, + history, + model: ctx.model, + maxTokens, + temperature, + cwd: ctx.cwd, + session: { manager: sessions, id: session.id }, + onEvent: (e: AgentEvent) => formatEvent(output, e), + }); + history = result.history; + ctx.usage.inputTokens += result.usage.inputTokens; + ctx.usage.outputTokens += result.usage.outputTokens; + ctx.usage.reasoningTokens += result.usage.reasoningTokens; + output.write('\n'); + if (result.stopReason === 'error') { + output.write(' ✕ Error during agent loop. Try again or /status to inspect.\n\n'); + } + } + + rl.close(); + return 0; +} + +function formatEvent(out: Writable, e: AgentEvent): void { + switch (e.type) { + case 'text_delta': + out.write(e.text); + return; + case 'thinking_delta': + return; + case 'tool_use': + out.write(`\n ● ${e.name} ${formatToolInput(e.input)}\n`); + return; + case 'tool_result': + if (e.result.isError) out.write(` ✕ ${truncate(e.result.content, 200)}\n`); + else out.write(` ✓ ${truncate(e.result.content, 200)}\n`); + return; + case 'usage': + return; + case 'error': + out.write(`\n ✕ ${e.error}\n`); + return; + case 'turn_complete': + return; + } +} + +function formatToolInput(input: Record): string { + for (const key of ['file_path', 'command', 'pattern', 'path']) { + const v = input[key]; + if (typeof v === 'string') return v; + } + return JSON.stringify(input).slice(0, 80); +} + +function truncate(s: string, n: number): string { + return s.length > n ? s.slice(0, n) + '…' : s; +} diff --git a/apps/cli/src/trust.test.ts b/apps/cli/src/trust.test.ts new file mode 100644 index 0000000..e4722be --- /dev/null +++ b/apps/cli/src/trust.test.ts @@ -0,0 +1,49 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { TrustStore } from './trust.js'; + +describe('TrustStore', () => { + let home: string; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'dc-trust-')); + }); + afterEach(async () => { + await rm(home, { recursive: true, force: true }); + }); + + it('reports untrusted by default', async () => { + const s = new TrustStore({ home }); + expect(await s.statusFor('/some/path')).toBe('untrusted'); + }); + + it('persists trust', async () => { + const s = new TrustStore({ home }); + await s.trust('/my/proj', 'full'); + expect(await s.statusFor('/my/proj')).toBe('trusted'); + // a fresh instance still sees it + const s2 = new TrustStore({ home }); + expect(await s2.statusFor('/my/proj')).toBe('trusted'); + }); + + it('plan-only mode persists', async () => { + const s = new TrustStore({ home }); + await s.trust('/another', 'plan-only'); + expect(await s.statusFor('/another')).toBe('plan-only'); + }); + + it('untrust removes the entry', async () => { + const s = new TrustStore({ home }); + await s.trust('/x', 'full'); + await s.untrust('/x'); + expect(await s.statusFor('/x')).toBe('untrusted'); + }); + + it('resolves relative paths', async () => { + const s = new TrustStore({ home }); + await s.trust(process.cwd(), 'full'); + expect(await s.statusFor('.')).toBe('trusted'); + }); +}); diff --git a/apps/cli/src/trust.ts b/apps/cli/src/trust.ts new file mode 100644 index 0000000..f880d5d --- /dev/null +++ b/apps/cli/src/trust.ts @@ -0,0 +1,67 @@ +// Trust dialog — track which directories the user has approved for full feature access. +// Spec: docs/DEVELOPMENT_PLAN.md §3.15.10 +// M2: tracks state to ~/.deepcode/trusted-dirs.json; CLI prompt for new dirs. +// Hooks/MCP/apiKeyHelper gating is consulted by their owners (deferred to M3). + +import { promises as fs } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; + +export interface TrustState { + dirs: Record; +} + +const EMPTY: TrustState = { dirs: {} }; + +export interface TrustStoreOpts { + home?: string; +} + +export class TrustStore { + private readonly home: string; + constructor(opts: TrustStoreOpts = {}) { + this.home = opts.home ?? homedir(); + } + + filePath(): string { + return join(this.home, '.deepcode', 'trusted-dirs.json'); + } + + async load(): Promise { + try { + const raw = await fs.readFile(this.filePath(), 'utf8'); + return JSON.parse(raw) as TrustState; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { ...EMPTY }; + throw err; + } + } + + async save(state: TrustState): Promise { + const path = this.filePath(); + await fs.mkdir(dirname(path), { recursive: true }); + await fs.writeFile(path, JSON.stringify(state, null, 2) + '\n', 'utf8'); + } + + async statusFor(cwd: string): Promise<'trusted' | 'plan-only' | 'untrusted'> { + const abs = resolve(cwd); + const state = await this.load(); + const entry = state.dirs[abs]; + if (!entry) return 'untrusted'; + return entry.mode === 'plan-only' ? 'plan-only' : 'trusted'; + } + + async trust(cwd: string, mode: 'full' | 'plan-only'): Promise { + const abs = resolve(cwd); + const state = await this.load(); + state.dirs[abs] = { trustedAt: new Date().toISOString(), mode }; + await this.save(state); + } + + async untrust(cwd: string): Promise { + const abs = resolve(cwd); + const state = await this.load(); + delete state.dirs[abs]; + await this.save(state); + } +} diff --git a/docs/cli-flags.md b/docs/cli-flags.md new file mode 100644 index 0000000..174b99e --- /dev/null +++ b/docs/cli-flags.md @@ -0,0 +1,125 @@ +# `deepcode` CLI Flags Reference + +> **Status**: M2 — parser implemented + most surfaces wired. Some flags are placeholders that will land in later milestones (each marked below). + +## Synopsis + +```bash +deepcode # interactive REPL +deepcode -p "" # headless one-shot [M8] +deepcode --resume [] # resume session [M3] +deepcode --continue # most-recent session [M3] +deepcode doctor # diagnostic checks +deepcode upgrade # CLI self-update +``` + +## Action triggers + +| Flag | Effect | Milestone | +| ------------------------ | ----------------------------------------- | --------- | +| `-h`, `--help` | Print usage | M2 ✅ | +| `-v`, `--version` | Print version | M2 ✅ | +| `doctor` | Health check (node / paths / API key) | M2 ✅ | +| `upgrade` | Print `npm i -g deepcode-cli@latest` hint | M2 ✅ | +| `-p`, `--print ` | Headless one-shot | M8 | + +## Session shaping + +| Flag | Effect | Milestone | +| ----------------- | ------------------------------------------- | --------- | +| `--resume []` | Resume session by ID, or pick interactively | M3 | +| `--continue` | Continue most-recent session | M3 | +| `--fork-session` | Branch from current session, leave original | M3 | + +## Mode + +| Flag | Effect | Milestone | +| -------------------------- | ----------------------------------------------------------------------------- | --------------------------------------- | +| `--mode ` | `default` / `acceptEdits` / `plan` / `auto` / `dontAsk` / `bypassPermissions` | M2 ✅ (REPL respects), M3 (enforcement) | +| `--permission-mode ` | Alias for `--mode` (Claude Code parity) | M2 ✅ | +| `--bare` | No plugins / MCP / skills — just kernel + tools | M5 | + +## Model & effort + +| Flag | Effect | Milestone | +| ----------------- | ----------------------------------------------- | --------- | +| `--model ` | `deepseek-chat` \| `deepseek-reasoner` | M2 ✅ | +| `--effort ` | `low` \| `medium` \| `high` \| `xhigh` \| `max` | M2 ✅ | +| `--max-turns ` | Cap agent loop turns | M2 ✅ | + +## System prompt + +| Flag | Effect | Milestone | +| ------------------------------------ | ----------------------------- | ---------------------------------- | +| `--system-prompt ""` | Replace default system prompt | M2 parser ✅; agent integration M3 | +| `--append-system-prompt ""` | Append to default | M3 | +| `--append-system-prompt-file ` | Append from a file | M3 | + +## Tool whitelisting + +| Flag | Effect | Milestone | +| ------------------------- | ----------------------- | ---------------------------- | +| `--allowedTools "A,B,C"` | Only these tools loaded | M2 parser ✅; enforcement M3 | +| `--disallowedTools "A,B"` | Block these tools | M2 parser ✅; enforcement M3 | + +## Headless / CI (`-p` only) + +| Flag | Effect | Milestone | +| ----------------------------------------- | ------------------------------------ | --------- | +| `--output-format text\|json\|stream-json` | Output shape | M8 | +| `--json-schema ` | Enforce final-output JSON schema | M8 | +| `--include-partial-messages` | Stream partial deltas as JSON events | M8 | +| `--verbose` | Print LLM / tool call traces | M3 | + +## Overrides + +| Flag | Effect | Milestone | +| ----------------------------- | ------------------------------------------------- | ----------------------------------- | +| `--settings ` | Override settings.json discovery | M2 parser ✅; loader integration M3 | +| `--agents ` | Override sub-agents dir | M4 | +| `--mcp-config ` | Override MCP server config | M3 | +| `--plugin-dir ` | Mount a local plugin dir | M5 | +| `--plugin-url ` | Mount a remote plugin | M5 | +| `--no-plugins` | Disable all plugins for this run | M5 | +| `--strict` | Strict mode (official marketplace only, no hooks) | M5 | + +## Configuration discovery order + +1. `~/.deepcode/settings.json` — user-level +2. `/.deepcode/settings.json` — project-level (commits to git) +3. `/.deepcode/settings.local.json` — local override (gitignore'd) + +Later layers override earlier ones (deep-merge for objects, arrays replace). + +## Credentials discovery order + +1. `apiKeyHelper` from settings.json (executed each call, refresh on 401) +2. macOS Keychain (service=`deepcode`, account=`deepseek-api-key`) +3. `~/.deepcode/credentials.json` (chmod 600) +4. `DEEPSEEK_API_KEY` / `DEEPSEEK_AUTH_TOKEN` environment variables + +`DEEPSEEK_AUTH_TOKEN` (Bearer) takes precedence over `DEEPSEEK_API_KEY` (X-Api-Key) when both are set. + +## Exit codes + +| Code | Meaning | +| ---- | ----------------------------------- | +| `0` | Success | +| `1` | General error (e.g. no credentials) | +| `2` | Unknown flag / bad argument | +| `3` | Tool denied by permissions | +| `4` | `--max-turns` reached | +| `5` | API key invalid | + +(Codes 3-5 are reserved for M3+ enforcement.) + +## Environment variables + +| Variable | Effect | +| ---------------------------------- | ------------------------------------------------------- | +| `DEEPSEEK_API_KEY` | API key fallback (used if Keychain/file empty) | +| `DEEPSEEK_AUTH_TOKEN` | Bearer token (takes precedence over API key) | +| `DEEPCODE_SESSIONS_DIR` | Override `~/.deepcode/sessions/` | +| `DEEPCODE_EFFORT_LEVEL` | Default effort (overrides settings, beneath `--effort`) | +| `DEEPCODE_STATUS_LINE_DEBOUNCE_MS` | Statusline refresh frequency (default 5000) | +| `DEEPCODE_API_KEY_HELPER_TTL_MS` | apiKeyHelper refresh period (default 300_000) | diff --git a/docs/milestones/M2.md b/docs/milestones/M2.md new file mode 100644 index 0000000..b9033e9 --- /dev/null +++ b/docs/milestones/M2.md @@ -0,0 +1,131 @@ +# M2 — CLI MVP + Config + +> **Status**: ✅ partial (core scope done, REPL + settings + permissions + onboarding shipped; some `--flag` enforcement deferred to M3 with the matching subsystem) +> **Branch**: `feat/m2-cli-mvp` + +## Scope (planned) + +> DEVELOPMENT_PLAN.md §6: +> `apps/cli`: onboarding (双 header / apiKeyHelper 刷新) + REPL + 30+ slash + settings.json 三层 + permissions matcher (两种 glob 语法) + Trust dialog +> Tests: 装包→填 key→改文件 完整链路; settings ~50 字段 schema 单测; permission glob 单测 +> Docs: docs/cli-flags.md + +## Delivered + +### Core additions + +| Module | Files | Lines | Tests | +| ----------------------- | -------------------------------------------------------------------------- | -------- | ------ | +| `config/loader.ts` | settings.json 3-layer loader | 102 | 8 | +| `config/permissions.ts` | matcher with 4 syntaxes (bare / subcommand `:*` / prefix `*` / domain) | 152 | 25 | +| `config/types.ts` | full schema (~50 fields, plus sandbox/update/worktree/plugins sub-schemas) | 145 | — | +| `credentials/index.ts` | Keychain primary + file fallback + apiKeyHelper resolver | 175 | 11 | +| **subtotal** | 4 modules | **~574** | **44** | + +### CLI app + +| Module | Lines | Tests | +| --------------- | ----------------------------------------------------------- | -------- | --------------- | +| `parse-args.ts` | dependency-free argv parser, 27 flags | 200 | 14 | +| `commands.ts` | 14 slash commands + registry | 220 | 13 | +| `repl.ts` | readline-based REPL, agent dispatch, event rendering | 165 | (smoke via bin) | +| `onboarding.ts` | hidden-input API key prompt, baseURL config, redact display | 110 | (smoke) | +| `trust.ts` | `~/.deepcode/trusted-dirs.json` persistence | 56 | 5 | +| `cli.ts` | bin entry: flag dispatch → onboarding/REPL/doctor | 88 | (smoke via bin) | +| **subtotal** | 6 modules | **~840** | **32** | + +### Documentation + +- `docs/cli-flags.md` — every flag with milestone status +- `docs/milestones/M2.md` — this file + +## Verification + +```bash +pnpm typecheck → green +pnpm build → all 4 packages emit dist/ +pnpm test → 151 passed / 4 skipped / 0 failed +pnpm format:check → conformant +``` + +Smoke tests (`apps/cli/dist/cli.js`): + +- `--version` → `0.1.0` +- `--help` → full usage +- `doctor` → environment summary +- `--nope` → `Unknown or invalid flags: --nope; exit 2` + +## Slash commands shipped (M2 subset of 30+ planned) + +`/help` · `/?` · `/clear` · `/exit` · `/quit` · `/status` · `/doctor` · `/model` · `/mode` · `/effort` · `/cost` · `/usage` · `/context` · `/config` · `/add-dir` · `/resume` · `/init` · `/todos` + +Each is a pure function over `SessionContext` — easy to test, easy to extend. M3+ adds: `/btw` `/recap` `/rewind` `/voice` `/teleport` `/desktop` `/background` `/batch` `/tasks` `/plan` `/login` `/logout` `/export` `/bug` `/migrate-installer` `/release-notes` `/upgrade` `/pr_comments` `/review` `/security-review` `/schedule` `/loop` `/terminal-setup` `/vim` `/agents` `/hooks` `/skills` `/permissions` `/privacy-settings`. + +## Settings.json schema + +Full ~50-field schema in `config/types.ts`. Loader does three-layer deep-merge with arrays replacing (not concatenating) — per Claude Code semantics. Tests verify: + +- Empty → empty +- User-only +- Project overrides user +- Local overrides both +- Deep-merge nested objects +- Array replacement +- Parse errors surface loudly +- Paths consistent with §3.9 + +## Permission matcher + +Four pattern types from DEVELOPMENT_PLAN.md §3.9, all tested: + +| Pattern | Example | Behavior | +| ---------------- | ----------------------------- | ------------------------------------------------------------------------------------------- | +| Bare tool | `Read` | Matches every `Read(*)` call | +| Subcommand `:*` | `Bash(git diff:*)` | Matches `git diff` / `git diff --stat` / `git diff src/`; **not** `git push` or `git diffx` | +| Prefix ` *` | `Bash(npm test *)` | Matches `npm test` / `npm test -- --watch`; **not** `npm run test` | +| Domain `domain:` | `WebFetch(domain:github.com)` | Matches `https://github.com/*` exactly; subdomains do **not** auto-include | + +Precedence: `deny` > `ask` > `allow` > `no-match` (most-restrictive wins). + +## Credentials + +- macOS: Keychain via `security` CLI (service=`deepcode`) +- All platforms: `~/.deepcode/credentials.json`, chmod 0600 verified by test +- Dual header: `apiKey` (X-Api-Key) **or** `authToken` (Bearer); apiKey takes precedence in storage but Bearer takes precedence at API send-time (per §3.4) +- `apiKeyHelper`: arbitrary shell command, stdout becomes apiKey; falls back to stored on failure or empty output + +## NOT delivered (deferred to M3+) + +| Spec item | Deferred to | Reason | +| -------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------- | +| Trust dialog UI prompt | M3 | Persistence shipped (`TrustStore`); UX prompt benefits from `` injector | +| `apiKeyHelper` refresh on 401 + 5min cycle | M3 | One-shot resolution works; periodic refresh needs the harness event loop | +| 30+ slash commands (only 14 here) | M3-M8 | Each remaining command requires its subsystem (rewind/M7, hooks/M3, plugins/M5...) | +| `--system-prompt` enforcement in REPL | M3 | Parser accepts; REPL doesn't yet thread it through | +| `--allowedTools` / `--disallowedTools` enforcement | M3 | Parser accepts; tool dispatcher gates not in agent loop yet | +| Headless `-p` mode | M8 | Whole subsystem | +| `--resume` interactive picker | M3 | Listed-only in M2 | + +## CI fix + +The first CI run failed with: + +``` +Error: Multiple versions of pnpm specified + - version 9 in GitHub Action config + - version pnpm@9.12.0 in package.json +``` + +Fixed by removing the explicit `version:` from `pnpm/action-setup@v4` — `packageManager` field is authoritative. + +## Design decisions + +1. **Argv parser is dependency-free**. Yargs/commander would be ~3MB of deps for parsing ~27 flags. ~200 lines hand-rolled keeps CLI startup snappy. +2. **`promptHidden()` masks with `★` not `*`**. The star character is visually distinguishable from periods/Xs which can appear in real key fragments — clearer to the user that input is masked. +3. **Commands return `string[]` not write directly**. Pure functions over context → trivially testable; REPL is the only writer. Same pattern as `ToolHandler` interface. +4. **`TrustStore` separate from `CredentialsStore`**. Trust is a different concern (per-directory authorization) than credentials (per-installation secret). +5. **No `/login` in M2**. Real OAuth flow is M3+; for now `/status` shows redacted key. Re-onboarding via `deepcode` (after clearing creds) is the supported re-auth path. + +## Next: M3 + +M3 is the largest milestone — hooks (9 events × 5 handlers), MCP client (3 scopes / OAuth / serve), compaction, modes-5 + auto classifier, statusLine JSON contract, memory dual system, `/init` multi-phase. Plus enforcing all the M2 parser flags through agent dispatch. diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index 1dc73c5..feb4825 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -1,6 +1,36 @@ -// Module: config +// Config subsystem — settings.json loading + permission matcher. +// Spec: docs/DEVELOPMENT_PLAN.md §3.9 // Milestone: M2 -// Spec: docs/DEVELOPMENT_PLAN.md §3.9 settings.json 3-layer load/merge/watch + permission matcher (2 glob syntaxes) -// Status: placeholder — implemented in M2 -export {}; +export type { + DeepCodeSettings, + PermissionRules, + HookHandler, + HookMatcher, + HookEventName, + Hooks, + McpServerConfig, + StatusLineConfig, + SandboxConfig, + UpdateConfig, + WorktreeConfig, + AutoModeConfig, +} from './types.js'; + +export { + loadSettings, + writeSettings, + settingsPaths, + deepMerge, + type LoadedSettings, + type LoadSettingsOpts, +} from './loader.js'; + +export { + evaluatePermission, + matchRule, + parseRule, + primaryInput, + type PermissionVerdict, + type PermissionRequest, +} from './permissions.js'; diff --git a/packages/core/src/config/loader.test.ts b/packages/core/src/config/loader.test.ts new file mode 100644 index 0000000..1feb3f5 --- /dev/null +++ b/packages/core/src/config/loader.test.ts @@ -0,0 +1,92 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { promises as fs } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { deepMerge, loadSettings, settingsPaths, writeSettings } from './loader.js'; + +describe('settings loader', () => { + let home: string; + let cwd: string; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'dc-home-')); + cwd = await mkdtemp(join(tmpdir(), 'dc-cwd-')); + }); + afterEach(async () => { + await rm(home, { recursive: true, force: true }); + await rm(cwd, { recursive: true, force: true }); + }); + + it('returns empty when no settings files exist', async () => { + const s = await loadSettings({ cwd, home }); + expect(s.merged).toEqual({}); + expect(s.layers.user).toBeUndefined(); + expect(s.layers.project).toBeUndefined(); + expect(s.layers.local).toBeUndefined(); + }); + + it('reads user-level only', async () => { + const userPath = join(home, '.deepcode', 'settings.json'); + await writeSettings(userPath, { model: 'deepseek-chat', effortLevel: 'low' }); + const s = await loadSettings({ cwd, home }); + expect(s.merged.model).toBe('deepseek-chat'); + expect(s.merged.effortLevel).toBe('low'); + }); + + it('project overrides user', async () => { + await writeSettings(join(home, '.deepcode', 'settings.json'), { + model: 'deepseek-chat', + effortLevel: 'low', + }); + await writeSettings(join(cwd, '.deepcode', 'settings.json'), { + model: 'deepseek-reasoner', + }); + const s = await loadSettings({ cwd, home }); + expect(s.merged.model).toBe('deepseek-reasoner'); + expect(s.merged.effortLevel).toBe('low'); // inherited from user + }); + + it('local overrides project + user', async () => { + await writeSettings(join(home, '.deepcode', 'settings.json'), { model: 'deepseek-chat' }); + await writeSettings(join(cwd, '.deepcode', 'settings.json'), { model: 'deepseek-reasoner' }); + await writeSettings(join(cwd, '.deepcode', 'settings.local.json'), { model: 'override' }); + const s = await loadSettings({ cwd, home }); + expect(s.merged.model).toBe('override'); + }); + + it('deepMerge merges nested objects, arrays replace', () => { + const merged = deepMerge>( + { a: { x: 1, y: 2 }, list: [1, 2] }, + { a: { y: 9, z: 3 }, list: [9] }, + ); + expect(merged.a).toEqual({ x: 1, y: 9, z: 3 }); + expect(merged.list).toEqual([9]); // replaced, not concatenated + }); + + it('settingsPaths uses .deepcode/ layout', () => { + const p = settingsPaths({ cwd: '/proj', home: '/home/u' }); + expect(p.userPath).toBe('/home/u/.deepcode/settings.json'); + expect(p.projectPath).toBe('/proj/.deepcode/settings.json'); + expect(p.localPath).toBe('/proj/.deepcode/settings.local.json'); + }); + + it('reports parse errors loudly', async () => { + const path = join(home, '.deepcode', 'settings.json'); + await fs.mkdir(join(home, '.deepcode'), { recursive: true }); + await fs.writeFile(path, '{ not valid json'); + await expect(loadSettings({ cwd, home })).rejects.toThrow(/parse/i); + }); + + it('merges permissions objects (not arrays)', async () => { + await writeSettings(join(home, '.deepcode', 'settings.json'), { + permissions: { allow: ['Read'] }, + }); + await writeSettings(join(cwd, '.deepcode', 'settings.json'), { + permissions: { deny: ['Read(/etc/*)'] }, + }); + const s = await loadSettings({ cwd, home }); + expect(s.merged.permissions?.allow).toEqual(['Read']); + expect(s.merged.permissions?.deny).toEqual(['Read(/etc/*)']); + }); +}); diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts new file mode 100644 index 0000000..2dca42e --- /dev/null +++ b/packages/core/src/config/loader.ts @@ -0,0 +1,105 @@ +// settings.json three-layer loader. +// Spec: docs/DEVELOPMENT_PLAN.md §3.9 +// Layers (highest priority last): +// 1. ~/.deepcode/settings.json user-level +// 2. /.deepcode/settings.json project-level +// 3. /.deepcode/settings.local.json local override +// (managed/MDM policy layer is NOT implemented — v1 non-goal per §0.2) + +import { promises as fs } from 'node:fs'; +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; +import type { DeepCodeSettings } from './types.js'; + +export interface LoadedSettings { + merged: DeepCodeSettings; + layers: { + user?: DeepCodeSettings; + project?: DeepCodeSettings; + local?: DeepCodeSettings; + }; + sources: { + userPath: string; + projectPath: string; + localPath: string; + }; +} + +export interface LoadSettingsOpts { + cwd: string; + /** Override $HOME for tests. */ + home?: string; +} + +export function settingsPaths(opts: LoadSettingsOpts): LoadedSettings['sources'] { + const home = opts.home ?? homedir(); + return { + userPath: join(home, '.deepcode', 'settings.json'), + projectPath: resolve(opts.cwd, '.deepcode', 'settings.json'), + localPath: resolve(opts.cwd, '.deepcode', 'settings.local.json'), + }; +} + +async function readJson(path: string): Promise { + try { + const raw = await fs.readFile(path, 'utf8'); + return JSON.parse(raw) as DeepCodeSettings; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return undefined; + throw new Error(`Failed to parse ${path}: ${(err as Error).message}`); + } +} + +export async function loadSettings(opts: LoadSettingsOpts): Promise { + const sources = settingsPaths(opts); + const [user, project, local] = await Promise.all([ + readJson(sources.userPath), + readJson(sources.projectPath), + readJson(sources.localPath), + ]); + const merged = deepMerge( + deepMerge({}, (user ?? {}) as Record), + deepMerge((project ?? {}) as Record, (local ?? {}) as Record), + ) as DeepCodeSettings; + return { + merged, + layers: { user, project, local }, + sources, + }; +} + +/** + * Deep-merge: objects merged recursively; arrays/scalars in later overwrite earlier. + * (Arrays are NOT concatenated — settings semantics are "later replaces earlier".) + */ +export function deepMerge>(a: T, b: T): T { + const out: Record = { ...a }; + for (const key of Object.keys(b)) { + const av = (a as Record)[key]; + const bv = (b as Record)[key]; + if ( + av && + bv && + typeof av === 'object' && + typeof bv === 'object' && + !Array.isArray(av) && + !Array.isArray(bv) + ) { + out[key] = deepMerge(av as Record, bv as Record); + } else if (bv !== undefined) { + out[key] = bv; + } + } + return out as T; +} + +export async function writeSettings(path: string, settings: DeepCodeSettings): Promise { + const json = JSON.stringify(settings, null, 2) + '\n'; + await fs.mkdir(resolveDir(path), { recursive: true }); + await fs.writeFile(path, json, 'utf8'); +} + +function resolveDir(p: string): string { + return p.slice(0, p.lastIndexOf('/')); +} diff --git a/packages/core/src/config/permissions.test.ts b/packages/core/src/config/permissions.test.ts new file mode 100644 index 0000000..6d4d043 --- /dev/null +++ b/packages/core/src/config/permissions.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest'; +import { evaluatePermission, matchRule, parseRule, primaryInput } from './permissions.js'; + +describe('parseRule', () => { + it('parses bare tool', () => { + expect(parseRule('Read')).toEqual({ tool: 'Read', kind: 'bare', spec: '' }); + }); + it('parses subcommand pattern', () => { + expect(parseRule('Bash(git diff:*)')).toEqual({ + tool: 'Bash', + kind: 'subcommand', + spec: 'git diff', + }); + }); + it('parses prefix pattern', () => { + expect(parseRule('Bash(npm test *)')).toEqual({ + tool: 'Bash', + kind: 'prefix', + spec: 'npm test ', + }); + }); + it('parses wildcard-only prefix', () => { + expect(parseRule('Bash(*)')).toEqual({ tool: 'Bash', kind: 'prefix', spec: '' }); + }); + it('parses domain pattern', () => { + expect(parseRule('WebFetch(domain:github.com)')).toEqual({ + tool: 'WebFetch', + kind: 'domain', + spec: 'github.com', + }); + }); + it('returns null on empty input', () => { + expect(parseRule('')).toBeNull(); + }); + it('returns null on unbalanced parens', () => { + expect(parseRule('Bash(unbalanced')).toBeNull(); + }); +}); + +describe('matchRule', () => { + it('bare tool matches any args', () => { + expect(matchRule('Read', { tool: 'Read', input: { file_path: '/x' } })).toBe(true); + expect(matchRule('Read', { tool: 'Write', input: { file_path: '/x' } })).toBe(false); + }); + + describe('subcommand: Bash(git diff:*)', () => { + const pat = 'Bash(git diff:*)'; + it('matches exact "git diff"', () => { + expect(matchRule(pat, { tool: 'Bash', input: { command: 'git diff' } })).toBe(true); + }); + it('matches "git diff --stat"', () => { + expect(matchRule(pat, { tool: 'Bash', input: { command: 'git diff --stat' } })).toBe(true); + }); + it('matches "git diff src/"', () => { + expect(matchRule(pat, { tool: 'Bash', input: { command: 'git diff src/' } })).toBe(true); + }); + it('does NOT match "git push"', () => { + expect(matchRule(pat, { tool: 'Bash', input: { command: 'git push' } })).toBe(false); + }); + it('does NOT match "git diffx"', () => { + expect(matchRule(pat, { tool: 'Bash', input: { command: 'git diffx' } })).toBe(false); + }); + }); + + describe('prefix: Bash(npm test *)', () => { + const pat = 'Bash(npm test *)'; + it('matches "npm test"', () => { + expect(matchRule(pat, { tool: 'Bash', input: { command: 'npm test' } })).toBe(true); + }); + it('matches "npm test -- --watch"', () => { + expect(matchRule(pat, { tool: 'Bash', input: { command: 'npm test -- --watch' } })).toBe( + true, + ); + }); + it('does NOT match "npm run test"', () => { + expect(matchRule(pat, { tool: 'Bash', input: { command: 'npm run test' } })).toBe(false); + }); + }); + + describe('domain: WebFetch(domain:github.com)', () => { + const pat = 'WebFetch(domain:github.com)'; + it('matches https://github.com/x', () => { + expect(matchRule(pat, { tool: 'WebFetch', input: { url: 'https://github.com/x' } })).toBe( + true, + ); + }); + it('does NOT match sub.github.com (no implicit wildcard)', () => { + expect(matchRule(pat, { tool: 'WebFetch', input: { url: 'https://api.github.com/x' } })).toBe( + false, + ); + }); + it('does NOT match other host', () => { + expect(matchRule(pat, { tool: 'WebFetch', input: { url: 'https://npmjs.com/x' } })).toBe( + false, + ); + }); + it('handles invalid URL gracefully', () => { + expect(matchRule(pat, { tool: 'WebFetch', input: { url: 'not a url' } })).toBe(false); + }); + }); + + it('wildcard-only Bash(*) matches anything', () => { + expect(matchRule('Bash(*)', { tool: 'Bash', input: { command: 'rm -rf /' } })).toBe(true); + }); +}); + +describe('evaluatePermission', () => { + it('returns no-match without rules', () => { + expect(evaluatePermission({ tool: 'Read', input: { file_path: '/x' } }, undefined)).toBe( + 'no-match', + ); + }); + it('deny beats ask beats allow (most restrictive wins)', () => { + const rules = { + allow: ['Bash'], + ask: ['Bash(git push *)'], + deny: ['Bash(rm:*)'], + }; + expect(evaluatePermission({ tool: 'Bash', input: { command: 'rm -rf foo' } }, rules)).toBe( + 'deny', + ); + expect(evaluatePermission({ tool: 'Bash', input: { command: 'git push origin' } }, rules)).toBe( + 'ask', + ); + expect(evaluatePermission({ tool: 'Bash', input: { command: 'ls' } }, rules)).toBe('allow'); + }); + + it('matches in order: deny → ask → allow', () => { + expect( + evaluatePermission( + { tool: 'Bash', input: { command: 'git push' } }, + { allow: ['Bash'], deny: ['Bash(git push:*)'] }, + ), + ).toBe('deny'); + }); +}); + +describe('primaryInput', () => { + it('prefers command for Bash', () => { + expect(primaryInput({ command: 'echo', extra: 'x' })).toBe('echo'); + }); + it('falls back to url', () => { + expect(primaryInput({ url: 'https://x' })).toBe('https://x'); + }); + it('falls back to file_path', () => { + expect(primaryInput({ file_path: 'a.ts' })).toBe('a.ts'); + }); + it('returns null when no string-valued field', () => { + expect(primaryInput({ recursive: true, n: 5 })).toBeNull(); + }); +}); diff --git a/packages/core/src/config/permissions.ts b/packages/core/src/config/permissions.ts new file mode 100644 index 0000000..7891208 --- /dev/null +++ b/packages/core/src/config/permissions.ts @@ -0,0 +1,148 @@ +// Permission rule matcher — two glob syntaxes. +// Spec: docs/DEVELOPMENT_PLAN.md §3.9 +// +// Syntax 1: subcommand match `Tool(arg:*)` `Bash(git diff:*)` +// Syntax 2: prefix match `Tool(arg *)` `Bash(npm test *)` +// Syntax 3: domain match `Tool(domain:x)` `WebFetch(domain:github.com)` +// Bare tool: `Tool` `Read` (any args allowed) +// +// Examples: +// `Bash(git diff:*)` matches `git diff`, `git diff --stat`, `git diff src/` +// does NOT match `git push`, `git pull` +// `Bash(npm test *)` matches `npm test`, `npm test -- --watch`, `npm tests run` +// `WebFetch(domain:github.com)` matches WebFetch to github.com (subdomain not auto-included) +// `Read` matches every Read call regardless of input + +import type { PermissionRules } from './types.js'; + +export type PermissionVerdict = 'allow' | 'ask' | 'deny' | 'no-match'; + +export interface PermissionRequest { + tool: string; + /** Tool input — schema-shaped (e.g. { command: "git push" } for Bash). */ + input: Record; +} + +/** + * Evaluate a tool call against settings.json permission rules. + * Precedence: deny > ask > allow. (Most restrictive wins; defaults to no-match.) + */ +export function evaluatePermission( + req: PermissionRequest, + rules: PermissionRules | undefined, +): PermissionVerdict { + if (!rules) return 'no-match'; + if (rules.deny?.some((p) => matchRule(p, req))) return 'deny'; + if (rules.ask?.some((p) => matchRule(p, req))) return 'ask'; + if (rules.allow?.some((p) => matchRule(p, req))) return 'allow'; + return 'no-match'; +} + +/** + * Test a single rule pattern against a request. + * Exported for unit testing — production code should call `evaluatePermission`. + */ +export function matchRule(pattern: string, req: PermissionRequest): boolean { + const parsed = parseRule(pattern); + if (!parsed) return false; + if (parsed.tool !== req.tool) return false; + + if (parsed.kind === 'bare') return true; + if (parsed.kind === 'subcommand') return matchSubcommand(parsed.spec, req.input); + if (parsed.kind === 'prefix') return matchPrefix(parsed.spec, req.input); + if (parsed.kind === 'domain') return matchDomain(parsed.spec, req.input); + return false; +} + +interface ParsedRule { + tool: string; + kind: 'bare' | 'subcommand' | 'prefix' | 'domain'; + spec: string; +} + +/** + * Parse a permission rule. Returns null if the pattern is malformed. + * + * Cases: + * `Tool` → { kind: 'bare' } + * `Tool(domain:x)` → { kind: 'domain', spec: 'x' } + * `Tool(foo:*)` → { kind: 'subcommand', spec: 'foo' } + * `Tool(foo *)` → { kind: 'prefix', spec: 'foo ' } + * `Tool(*)` → { kind: 'prefix', spec: '' } + */ +export function parseRule(pattern: string): ParsedRule | null { + const trimmed = pattern.trim(); + if (!trimmed) return null; + + // Bare: no parens + if (!trimmed.includes('(')) { + return { tool: trimmed, kind: 'bare', spec: '' }; + } + + const openIdx = trimmed.indexOf('('); + if (!trimmed.endsWith(')')) return null; + const tool = trimmed.slice(0, openIdx); + const inner = trimmed.slice(openIdx + 1, -1); + + if (inner.startsWith('domain:')) { + return { tool, kind: 'domain', spec: inner.slice('domain:'.length) }; + } + + // Subcommand: `arg:*` (no leading wildcard, ends with `:*`) + if (inner.endsWith(':*') && !inner.startsWith(':')) { + return { tool, kind: 'subcommand', spec: inner.slice(0, -2) }; + } + + // Prefix: ends with " *" or is just "*" + if (inner === '*') { + return { tool, kind: 'prefix', spec: '' }; + } + if (inner.endsWith(' *')) { + return { tool, kind: 'prefix', spec: inner.slice(0, -2) + ' ' }; + } + + // Exact: `Tool(foo)` matches when the primary input equals foo exactly + return { tool, kind: 'prefix', spec: inner }; // exact-as-prefix-with-no-trailing +} + +/** Subcommand match: the FIRST space-separated token of the primary input must equal spec. */ +function matchSubcommand(spec: string, input: Record): boolean { + const primary = primaryInput(input); + if (!primary) return false; + // Special case: "git diff" — match if primary starts with "git diff" followed by space/EOL + // We allow multi-token spec ("git diff" matches "git diff --stat") + return primary === spec || primary.startsWith(spec + ' '); +} + +function matchPrefix(spec: string, input: Record): boolean { + const primary = primaryInput(input); + if (!primary) return false; + if (spec === '') return true; // (*) matches any + return primary === spec.trimEnd() || primary.startsWith(spec); +} + +function matchDomain(spec: string, input: Record): boolean { + const url = typeof input.url === 'string' ? input.url : ''; + if (!url) return false; + try { + const host = new URL(url).hostname.toLowerCase(); + return host === spec.toLowerCase(); + } catch { + return false; + } +} + +/** + * The "primary" input field for a tool — for Bash it's `command`, for WebFetch it's `url`, + * for Read/Write/Edit it's `file_path`. Falls back to whichever field is a string. + */ +export function primaryInput(input: Record): string | null { + const candidates = ['command', 'url', 'file_path', 'pattern', 'path']; + for (const c of candidates) { + if (typeof input[c] === 'string') return input[c] as string; + } + for (const v of Object.values(input)) { + if (typeof v === 'string') return v; + } + return null; +} diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts new file mode 100644 index 0000000..cd7f6c1 --- /dev/null +++ b/packages/core/src/config/types.ts @@ -0,0 +1,179 @@ +// settings.json schema — DeepCode runtime config. +// Spec: docs/DEVELOPMENT_PLAN.md §3.9 +// Note: M2 implements the core fields. Many fields are placeholders until later milestones consume them. + +import type { Effort, Mode } from '../types.js'; + +export interface PermissionRules { + defaultMode?: Mode; + allow?: string[]; // patterns like "Bash(npm test:*)" or "Read(./**)" + ask?: string[]; + deny?: string[]; + additionalDirectories?: string[]; +} + +export interface HookHandler { + type: 'command' | 'http' | 'mcp_tool' | 'prompt' | 'agent'; + command?: string; + url?: string; + headers?: Record; + server?: string; + tool?: string; + prompt?: string; + agent?: string; + timeout?: number; + if?: string; // permission-rule-syntax filter +} + +export interface HookMatcher { + matcher?: string; + hooks: HookHandler[]; +} + +export type HookEventName = + | 'PreToolUse' + | 'PostToolUse' + | 'Stop' + | 'SubagentStop' + | 'PreCompact' + | 'PostCompact' + | 'SessionStart' + | 'SessionEnd' + | 'UserPromptSubmit' + | 'Notification'; + +export type Hooks = Partial>; + +export interface McpServerConfig { + command?: string; + args?: string[]; + env?: Record; + url?: string; + transport?: 'stdio' | 'http' | 'sse'; + headers?: Record; + headersHelper?: string; + alwaysLoad?: boolean; +} + +export interface StatusLineConfig { + type: 'command'; + command: string; +} + +export interface SandboxConfig { + enabled?: boolean; + filesystem?: { + allowWrite?: string[]; + denyWrite?: string[]; + allowRead?: string[]; + denyRead?: string[]; + }; + network?: { + allowedDomains?: string[]; + deniedDomains?: string[]; + allowUnixSockets?: boolean; + allowLocalBinding?: boolean; + }; + excludedCommands?: string[]; +} + +export interface UpdateConfig { + channel?: 'stable' | 'beta' | 'nightly'; + checkIntervalHours?: number; + autoDownload?: boolean; + autoInstallOnQuit?: boolean; +} + +export interface WorktreeConfig { + baseRef?: string; + symlinkDirectories?: string[]; + sparsePaths?: string[]; + bgIsolation?: boolean; +} + +export interface AutoModeConfig { + allow?: string[]; + soft_deny?: string[]; + hard_deny?: string[]; + model?: string; + fallback?: 'ask' | 'deny'; +} + +export interface DeepCodeSettings { + // Identity + model?: string; + baseURL?: string; + apiKeyHelper?: string; + + // Permissions / modes + permissions?: PermissionRules; + autoMode?: AutoModeConfig; + + // Env passed to Bash subprocesses + hooks + env?: Record; + + // Hooks + hooks?: Hooks; + disableAllHooks?: boolean; + allowedHttpHookUrls?: string[]; + httpHookAllowedEnvVars?: string[]; + + // MCP + mcpServers?: Record; + enableAllProjectMcpServers?: boolean; + enabledMcpjsonServers?: string[]; + disabledMcpjsonServers?: string[]; + + // Status line + statusLine?: StatusLineConfig; + + // Misc + includeCoAuthoredBy?: boolean; + cleanupPeriodDays?: number; + alwaysThinkingEnabled?: boolean; + forceLoginMethod?: string; + effortLevel?: Effort; + effortBudgets?: Record; + effortOverrides?: Record; + outputStyle?: string; + language?: string; + viewMode?: 'compact' | 'expanded'; + tui?: { vim?: boolean; spinnerTipsEnabled?: boolean; spinnerVerbs?: boolean }; + memoryLoadCapKB?: number; + deepcodeMdExcludes?: string[]; + attribution?: boolean; + prUrlTemplate?: string; + includeGitInstructions?: boolean; + feedbackSurveyRate?: number; + awaySummaryEnabled?: boolean; + preferredNotifChannel?: 'system' | 'terminal' | 'none'; + + // Sandbox + sandbox?: SandboxConfig; + + // Updates + update?: UpdateConfig; + + // Worktree + worktree?: WorktreeConfig; + + // Plugins (M5) + plugins?: { + globalEnabled?: boolean; + allowedSources?: Array< + | 'official' + | 'verified-third-party' + | 'unverified-marketplace' + | 'direct-source' + | 'local-path' + >; + requireMarketplace?: boolean; + autoUpdate?: boolean; + maxPlugins?: number; + }; + disabledPlugins?: string[]; + + // Tool-level config (e.g. alwaysLoad opt-out) + tools?: Record; + skillOverrides?: Record; +} diff --git a/packages/core/src/credentials/index.test.ts b/packages/core/src/credentials/index.test.ts new file mode 100644 index 0000000..97a3348 --- /dev/null +++ b/packages/core/src/credentials/index.test.ts @@ -0,0 +1,112 @@ +import { promises as fs } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { CredentialsStore, redact, resolveCredentials } from './index.js'; + +describe('CredentialsStore (file backend)', () => { + let home: string; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'dc-creds-')); + }); + afterEach(async () => { + await rm(home, { recursive: true, force: true }); + }); + + it('returns empty when nothing stored', async () => { + const s = new CredentialsStore({ home, forceFile: true }); + expect(await s.load()).toEqual({}); + }); + + it('save + load round-trip (file)', async () => { + const s = new CredentialsStore({ home, forceFile: true }); + await s.save({ apiKey: 'sk-test', baseURL: 'https://x' }); + const got = await s.load(); + expect(got.apiKey).toBe('sk-test'); + expect(got.baseURL).toBe('https://x'); + }); + + it('file has mode 0600 after save', async () => { + const s = new CredentialsStore({ home, forceFile: true }); + await s.save({ apiKey: 'sk-test' }); + const stat = await fs.stat(s.filePath()); + // permission bits are platform-dependent — at minimum no group/other access + const mode = stat.mode & 0o777; + expect(mode).toBe(0o600); + }); + + it('clear() removes the file', async () => { + const s = new CredentialsStore({ home, forceFile: true }); + await s.save({ apiKey: 'sk-test' }); + await s.clear(); + expect(await s.load()).toEqual({}); + }); + + it('authToken (Bearer) supported separately', async () => { + const s = new CredentialsStore({ home, forceFile: true }); + await s.save({ authToken: 'bearer-x' }); + const got = await s.load(); + expect(got.authToken).toBe('bearer-x'); + }); +}); + +describe('resolveCredentials', () => { + let home: string; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'dc-resolve-')); + }); + afterEach(async () => { + await rm(home, { recursive: true, force: true }); + }); + + it('apiKeyHelper output overrides stored apiKey', async () => { + const store = new CredentialsStore({ home, forceFile: true }); + await store.save({ apiKey: 'sk-stored', baseURL: 'https://x' }); + const got = await resolveCredentials({ + store, + apiKeyHelper: 'echo sk-helper', + }); + expect(got.apiKey).toBe('sk-helper'); + expect(got.baseURL).toBe('https://x'); // baseURL still from store + }); + + it('falls back to stored creds when helper fails', async () => { + const store = new CredentialsStore({ home, forceFile: true }); + await store.save({ apiKey: 'sk-stored' }); + const got = await resolveCredentials({ + store, + apiKeyHelper: 'exit 1', + }); + expect(got.apiKey).toBe('sk-stored'); + }); + + it('falls back when helper produces empty output', async () => { + const store = new CredentialsStore({ home, forceFile: true }); + await store.save({ apiKey: 'sk-stored' }); + const got = await resolveCredentials({ store, apiKeyHelper: 'true' }); + expect(got.apiKey).toBe('sk-stored'); + }); + + it('without helper, returns stored creds verbatim', async () => { + const store = new CredentialsStore({ home, forceFile: true }); + await store.save({ apiKey: 'sk-x', authToken: 'b-y' }); + const got = await resolveCredentials({ store }); + expect(got.apiKey).toBe('sk-x'); + expect(got.authToken).toBe('b-y'); + }); +}); + +describe('redact', () => { + it('redacts long secrets to first4…last4', () => { + expect(redact('sk-d1f6abcdefghijklmnop')).toBe('sk-d…mnop'); + }); + it('returns asterisks for short values', () => { + expect(redact('short')).toBe('****'); + }); + it('handles undefined', () => { + expect(redact(undefined)).toBe(''); + }); +}); diff --git a/packages/core/src/credentials/index.ts b/packages/core/src/credentials/index.ts index 258fb36..3646a7d 100644 --- a/packages/core/src/credentials/index.ts +++ b/packages/core/src/credentials/index.ts @@ -1,6 +1,184 @@ -// Module: credentials -// Milestone: M2 -// Spec: docs/DEVELOPMENT_PLAN.md §3.4 macOS Keychain + ~/.deepcode/credentials.json fallback + dual header -// Status: placeholder — implemented in M2 +// Credentials — macOS Keychain primary, ~/.deepcode/credentials.json fallback. +// Spec: docs/DEVELOPMENT_PLAN.md §3.4 -export {}; +import { execFile } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import { homedir, platform } from 'node:os'; +import { dirname, join } from 'node:path'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +const SERVICE = 'deepcode'; +const KEYCHAIN_ACCOUNT_API = 'deepseek-api-key'; +const KEYCHAIN_ACCOUNT_AUTH = 'deepseek-auth-token'; + +export interface Credentials { + /** X-Api-Key — primary credential. */ + apiKey?: string; + /** Bearer token alternative. If both set, Bearer wins. */ + authToken?: string; + /** Custom DeepSeek API base URL (e.g. for proxies). */ + baseURL?: string; +} + +export interface CredentialsStoreOpts { + home?: string; + /** Force file-backend (skip Keychain) — useful for tests. */ + forceFile?: boolean; +} + +export class CredentialsStore { + private readonly home: string; + private readonly useKeychain: boolean; + + constructor(opts: CredentialsStoreOpts = {}) { + this.home = opts.home ?? homedir(); + this.useKeychain = !opts.forceFile && platform() === 'darwin'; + } + + filePath(): string { + return join(this.home, '.deepcode', 'credentials.json'); + } + + async load(): Promise { + if (this.useKeychain) { + const fromKeychain = await this.loadKeychain(); + if (fromKeychain.apiKey || fromKeychain.authToken) { + // Read baseURL from file (Keychain doesn't store it) + const fromFile = await this.loadFile(); + return { ...fromKeychain, baseURL: fromFile.baseURL }; + } + } + return this.loadFile(); + } + + async save(creds: Credentials): Promise { + if (this.useKeychain) { + await this.saveKeychain(creds); + } + // Always also persist baseURL (+ a sentinel) to file + await this.saveFile(creds); + } + + async clear(): Promise { + if (this.useKeychain) { + await Promise.allSettled([ + execFileAsync('security', [ + 'delete-generic-password', + '-s', + SERVICE, + '-a', + KEYCHAIN_ACCOUNT_API, + ]), + execFileAsync('security', [ + 'delete-generic-password', + '-s', + SERVICE, + '-a', + KEYCHAIN_ACCOUNT_AUTH, + ]), + ]); + } + try { + await fs.unlink(this.filePath()); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + } + + private async loadFile(): Promise { + try { + const raw = await fs.readFile(this.filePath(), 'utf8'); + const parsed = JSON.parse(raw) as Credentials; + return parsed; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {}; + throw err; + } + } + + private async saveFile(creds: Credentials): Promise { + const path = this.filePath(); + await fs.mkdir(dirname(path), { recursive: true }); + // If Keychain is the source of truth, only write baseURL marker + const toWrite: Credentials = this.useKeychain + ? { baseURL: creds.baseURL } + : { apiKey: creds.apiKey, authToken: creds.authToken, baseURL: creds.baseURL }; + await fs.writeFile(path, JSON.stringify(toWrite, null, 2) + '\n', 'utf8'); + await fs.chmod(path, 0o600); + } + + private async loadKeychain(): Promise { + const apiKey = await this.kcRead(KEYCHAIN_ACCOUNT_API); + const authToken = await this.kcRead(KEYCHAIN_ACCOUNT_AUTH); + return { apiKey, authToken }; + } + + private async saveKeychain(creds: Credentials): Promise { + if (creds.apiKey) await this.kcWrite(KEYCHAIN_ACCOUNT_API, creds.apiKey); + if (creds.authToken) await this.kcWrite(KEYCHAIN_ACCOUNT_AUTH, creds.authToken); + } + + private async kcRead(account: string): Promise { + try { + const { stdout } = await execFileAsync('security', [ + 'find-generic-password', + '-s', + SERVICE, + '-a', + account, + '-w', + ]); + return stdout.trim() || undefined; + } catch { + return undefined; + } + } + + private async kcWrite(account: string, value: string): Promise { + await execFileAsync('security', [ + 'add-generic-password', + '-s', + SERVICE, + '-a', + account, + '-w', + value, + '-U', + ]); + } +} + +/** + * Resolve credentials at runtime: apiKeyHelper (if set) overrides stored creds. + * Spec: docs/DEVELOPMENT_PLAN.md §3.4 — apiKeyHelper refresh on 401 + 5min cycle. + * M2 implements one-shot resolution; the refresh loop is M3+. + */ +export async function resolveCredentials(args: { + store: CredentialsStore; + apiKeyHelper?: string; +}): Promise { + if (args.apiKeyHelper) { + try { + const { stdout } = await execFileAsync('/bin/sh', ['-c', args.apiKeyHelper], { + timeout: 10_000, + }); + const key = stdout.trim(); + if (key) { + const stored = await args.store.load(); + return { ...stored, apiKey: key }; + } + } catch { + // fall through to stored creds + } + } + return args.store.load(); +} + +/** Display-safe redacted form of a credential — first 4 + last 4. */ +export function redact(value: string | undefined): string { + if (!value) return ''; + if (value.length <= 8) return '****'; + return `${value.slice(0, 4)}…${value.slice(-4)}`; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a6879da..e367853 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -50,3 +50,40 @@ export { // Agent loop export { runAgent, AGENT_MODULE_VERSION } from './agent.js'; export type { RunAgentOptions, RunAgentResult } from './agent.js'; + +// Config + Permissions (M2) +export { + loadSettings, + writeSettings, + settingsPaths, + deepMerge, + evaluatePermission, + matchRule, + parseRule, + primaryInput, + type DeepCodeSettings, + type PermissionRules, + type LoadedSettings, + type LoadSettingsOpts, + type PermissionVerdict, + type PermissionRequest, + type Hooks, + type HookHandler, + type HookMatcher, + type HookEventName, + type McpServerConfig, + type StatusLineConfig, + type SandboxConfig, + type UpdateConfig, + type WorktreeConfig, + type AutoModeConfig, +} from './config/index.js'; + +// Credentials (M2) +export { + CredentialsStore, + resolveCredentials, + redact, + type Credentials, + type CredentialsStoreOpts, +} from './credentials/index.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4966155..7090adb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,9 @@ importers: typescript: specifier: ^5.7.0 version: 5.9.3 + vitest: + specifier: ^2.1.0 + version: 2.1.9(@types/node@22.19.19) apps/desktop: dependencies: