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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
108 changes: 89 additions & 19 deletions apps/cli/src/cli.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
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<number> {
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);
},
);
171 changes: 171 additions & 0 deletions apps/cli/src/commands.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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/);
});
});
Loading
Loading