Skip to content

Commit 6f193a7

Browse files
oratistclaude
authored
feat(cli): add /recap, /login, /logout, /pr_comments parity commands (#157)
* feat(cli): add /recap, /login, /logout, /pr_comments parity commands Four Claude-Code slash commands that were missing (🔄 in BEHAVIOR_PARITY): - /recap — provider-summarized recap of the session so far (ctx.provider + ctx.history → runTurn; honest empty/no-provider states) - /login [key] — show auth status, or store a new DeepSeek API key (applies on next launch); /logout — clear stored creds + exit - /pr_comments — `gh pr view` comments for the current branch's PR (graceful no-gh / no-PR handling); pure formatPrComments renderer Adds credsStore to SessionContext (REPL-injected) for /login + /logout. Tests (parity-commands.test.ts, 9 cases): /recap via a mock provider + empty/ no-provider; /login + /logout against a forceFile CredentialsStore under a temp HOME (never the keychain, never real creds); formatPrComments rendering + the no-PR/no-gh fallback. Full CLI suite: 133 pass. Verified the real `gh pr view` JSON shape matches the parser. Flips the /recap, /login//logout, /pr_comments rows in BEHAVIOR_PARITY → ✅. (Overlaps the open #154 audit on those rows — see PR note.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(cli): prettier-format parity-commands.test.ts Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: t <t@t> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c8bf71f commit 6f193a7

4 files changed

Lines changed: 290 additions & 3 deletions

File tree

apps/cli/src/commands.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// Spec: docs/DEVELOPMENT_PLAN.md §3.6 (30+ commands; M2 ships a core subset)
33

44
import type {
5+
ContentBlock,
6+
CredentialsStore,
57
DeepCodeSettings,
68
McpClientHandle,
79
Provider,
@@ -47,13 +49,66 @@ async function runGit(
4749
}
4850
}
4951

52+
/** Run a `gh` (GitHub CLI) subcommand in `cwd`, never throwing. `code` is the
53+
* spawn errno (e.g. 'ENOENT' when gh isn't installed). */
54+
async function runGh(
55+
cwd: string,
56+
args: string[],
57+
): Promise<{ ok: boolean; stdout: string; stderr: string; code?: string }> {
58+
try {
59+
const { stdout, stderr } = await execFileAsync('gh', args, {
60+
cwd,
61+
env: gitEnv(),
62+
maxBuffer: 8 * 1024 * 1024,
63+
});
64+
return { ok: true, stdout, stderr };
65+
} catch (err) {
66+
const e = err as { stdout?: string; stderr?: string; message?: string; code?: string };
67+
return {
68+
ok: false,
69+
stdout: e.stdout ?? '',
70+
stderr: e.stderr ?? e.message ?? 'gh failed',
71+
code: e.code,
72+
};
73+
}
74+
}
75+
76+
/** PR comments payload from `gh pr view --json`. */
77+
interface PrCommentsData {
78+
number: number;
79+
title: string;
80+
comments?: Array<{ author?: { login?: string }; body?: string; createdAt?: string }>;
81+
}
82+
83+
/** Render the `gh pr view` comments JSON into display lines (pure — unit-tested). */
84+
export function formatPrComments(data: PrCommentsData): string[] {
85+
const comments = data.comments ?? [];
86+
if (comments.length === 0) {
87+
return [`PR #${data.number}${data.title}`, '', 'No comments yet.'];
88+
}
89+
const lines: string[] = [
90+
`PR #${data.number}${data.title} (${comments.length} comment${comments.length === 1 ? '' : 's'})`,
91+
'',
92+
];
93+
for (const c of comments) {
94+
const who = c.author?.login ? `@${c.author.login}` : '(unknown)';
95+
const when = c.createdAt ? ` · ${c.createdAt.slice(0, 10)}` : '';
96+
lines.push(`${who}${when}`);
97+
for (const ln of (c.body ?? '').trim().split('\n')) lines.push(` ${ln}`);
98+
lines.push('');
99+
}
100+
return lines;
101+
}
102+
50103
export interface SessionContext {
51104
cwd: string;
52105
model: string;
53106
mode: string;
54107
effort: string;
55108
settings: DeepCodeSettings;
56109
creds: Credentials;
110+
/** Credentials store (REPL-injected) — backs /login and /logout. */
111+
credsStore?: CredentialsStore;
57112
sessionId: string;
58113
sessions: SessionManager;
59114
usage: { inputTokens: number; outputTokens: number; reasoningTokens: number };
@@ -870,6 +925,97 @@ export const BugCommand: SlashCommand = {
870925
},
871926
};
872927

928+
export const RecapCommand: SlashCommand = {
929+
name: '/recap',
930+
description: 'Summarize the conversation so far.',
931+
async run(_args, ctx) {
932+
if (!ctx.provider) return ['(/recap requires a provider — none configured.)'];
933+
const history = ctx.history ?? [];
934+
if (history.length === 0) return ['Nothing to recap yet — the conversation is empty.'];
935+
const result = await ctx.provider.runTurn({
936+
model: ctx.model,
937+
systemPrompt:
938+
'Recap this coding session for the user: the goal, what was explored or changed ' +
939+
'(files, key findings), decisions made, and what is still in progress. Use short ' +
940+
'bullet points. No preamble.',
941+
tools: [],
942+
messages: [
943+
...history,
944+
{
945+
role: 'user',
946+
content: [{ type: 'text', text: 'Recap where we are in this session so far.' }],
947+
},
948+
],
949+
});
950+
const text = result.content
951+
.filter((b: ContentBlock) => b.type === 'text')
952+
.map((b: ContentBlock) => (b as { text: string }).text)
953+
.join('\n')
954+
.trim();
955+
return text ? text.split('\n') : ['(no recap produced)'];
956+
},
957+
};
958+
959+
export const LoginCommand: SlashCommand = {
960+
name: '/login',
961+
description: 'Set or replace the stored DeepSeek API key.',
962+
async run(args, ctx) {
963+
if (!ctx.credsStore) return ['(/login unavailable — no credentials store.)'];
964+
const key = args[0]?.trim();
965+
if (!key) {
966+
const authed = !!(ctx.creds?.apiKey || ctx.creds?.authToken);
967+
return [
968+
authed ? 'Authenticated with a DeepSeek API key.' : 'Not authenticated.',
969+
'Usage: /login <DEEPSEEK_API_KEY> — stores a new key (applies on next launch).',
970+
];
971+
}
972+
await ctx.credsStore.save({ ...ctx.creds, apiKey: key });
973+
return [
974+
'Saved a new DeepSeek API key.',
975+
'Restart `deepcode` for it to take effect in a new session.',
976+
];
977+
},
978+
};
979+
980+
export const LogoutCommand: SlashCommand = {
981+
name: '/logout',
982+
description: 'Clear stored DeepSeek credentials and exit.',
983+
async run(_args, ctx) {
984+
if (!ctx.credsStore) return ['(/logout unavailable — no credentials store.)'];
985+
await ctx.credsStore.clear();
986+
ctx.exitRequested = true;
987+
return [
988+
'Logged out — stored DeepSeek credentials cleared.',
989+
'Run `deepcode` to sign in again.',
990+
];
991+
},
992+
};
993+
994+
export const PrCommentsCommand: SlashCommand = {
995+
name: '/pr_comments',
996+
description: "Show comments on the current branch's pull request (needs gh).",
997+
async run(_args, ctx) {
998+
const res = await runGh(ctx.cwd, ['pr', 'view', '--json', 'number,title,comments']);
999+
if (!res.ok) {
1000+
if (res.code === 'ENOENT') {
1001+
return ['/pr_comments needs the GitHub CLI (`gh`). Install: https://cli.github.com'];
1002+
}
1003+
const err = res.stderr.trim();
1004+
if (/no (pull requests|default remote|open)|not found|no git remotes/i.test(err)) {
1005+
return ['No open pull request found for the current branch.'];
1006+
}
1007+
return [`/pr_comments failed: ${err || 'unknown error'}`];
1008+
}
1009+
let data: PrCommentsData;
1010+
try {
1011+
data = JSON.parse(res.stdout) as PrCommentsData;
1012+
} catch {
1013+
return ['/pr_comments: could not parse gh output.'];
1014+
}
1015+
return formatPrComments(data);
1016+
},
1017+
};
1018+
8731019
export const BUILTIN_COMMANDS: SlashCommand[] = [
8741020
HelpCommand,
8751021
ClearCommand,
@@ -899,6 +1045,10 @@ export const BUILTIN_COMMANDS: SlashCommand[] = [
8991045
DiffCommand,
9001046
ReleaseNotesCommand,
9011047
BugCommand,
1048+
RecapCommand,
1049+
LoginCommand,
1050+
LogoutCommand,
1051+
PrCommentsCommand,
9021052
];
9031053

9041054
// ──────────────────────────────────────────────────────────────────────────
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// Tests for the parity slash commands: /recap, /login, /logout, /pr_comments.
2+
// Credentials tests use a forceFile store under a temp HOME (never the keychain,
3+
// never real creds). /recap uses a mock provider; /pr_comments' renderer is pure.
4+
5+
import { afterEach, describe, expect, it } from 'vitest';
6+
import { mkdtemp, rm } from 'node:fs/promises';
7+
import { tmpdir } from 'node:os';
8+
import { join } from 'node:path';
9+
import { CredentialsStore, SessionManager } from '@deepcode/core';
10+
import { CommandRegistry, formatPrComments, type SessionContext } from './commands.js';
11+
12+
const reg = new CommandRegistry();
13+
const tmps: string[] = [];
14+
async function tmpHome(): Promise<string> {
15+
const d = await mkdtemp(join(tmpdir(), 'dc-parity-'));
16+
tmps.push(d);
17+
return d;
18+
}
19+
afterEach(async () => {
20+
await Promise.all(tmps.splice(0).map((d) => rm(d, { recursive: true, force: true })));
21+
});
22+
23+
function ctx(overrides: Partial<SessionContext> = {}): SessionContext {
24+
return {
25+
cwd: '/tmp/x',
26+
model: 'deepseek-chat',
27+
mode: 'default',
28+
effort: 'medium',
29+
settings: {},
30+
creds: { apiKey: 'sk-test' },
31+
sessionId: 's1',
32+
sessions: new SessionManager({ root: '/tmp/x' }),
33+
usage: { inputTokens: 0, outputTokens: 0, reasoningTokens: 0 },
34+
...overrides,
35+
};
36+
}
37+
38+
const mockProvider = {
39+
runTurn: async () => ({
40+
content: [{ type: 'text', text: '- explored repo\n- decided on plan' }],
41+
stopReason: 'end_turn',
42+
usage: { inputTokens: 1, outputTokens: 1, reasoningTokens: 0 },
43+
}),
44+
} as unknown as SessionContext['provider'];
45+
46+
describe('/recap', () => {
47+
it('summarizes the conversation via the provider', async () => {
48+
const out = await reg.match('/recap')!.cmd.run(
49+
[],
50+
ctx({
51+
provider: mockProvider,
52+
history: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
53+
}),
54+
);
55+
expect(out.join('\n')).toContain('explored repo');
56+
});
57+
58+
it('reports an empty conversation', async () => {
59+
const out = await reg
60+
.match('/recap')!
61+
.cmd.run([], ctx({ provider: mockProvider, history: [] }));
62+
expect(out.join('\n')).toMatch(/nothing to recap/i);
63+
});
64+
65+
it('needs a provider', async () => {
66+
const out = await reg
67+
.match('/recap')!
68+
.cmd.run([], ctx({ history: [{ role: 'user', content: [{ type: 'text', text: 'x' }] }] }));
69+
expect(out.join('\n')).toMatch(/requires a provider/i);
70+
});
71+
});
72+
73+
describe('/login + /logout', () => {
74+
it('/logout clears stored credentials and requests exit', async () => {
75+
const home = await tmpHome();
76+
const store = new CredentialsStore({ home, forceFile: true });
77+
await store.save({ apiKey: 'sk-willbecleared' });
78+
const c = ctx({ credsStore: store, creds: { apiKey: 'sk-willbecleared' } });
79+
const out = await reg.match('/logout')!.cmd.run([], c);
80+
expect(out.join('\n')).toMatch(/logged out/i);
81+
expect(c.exitRequested).toBe(true);
82+
expect((await store.load()).apiKey).toBeFalsy();
83+
});
84+
85+
it('/login <key> saves a new key', async () => {
86+
const home = await tmpHome();
87+
const store = new CredentialsStore({ home, forceFile: true });
88+
const out = await reg
89+
.match('/login')!
90+
.cmd.run(['sk-brandnew456'], ctx({ credsStore: store, creds: {} }));
91+
expect(out.join('\n')).toMatch(/saved/i);
92+
expect((await store.load()).apiKey).toBe('sk-brandnew456');
93+
});
94+
95+
it('/login with no arg shows status + usage', async () => {
96+
const home = await tmpHome();
97+
const store = new CredentialsStore({ home, forceFile: true });
98+
const out = await reg
99+
.match('/login')!
100+
.cmd.run([], ctx({ credsStore: store, creds: { apiKey: 'sk-existing' } }));
101+
expect(out.join('\n')).toMatch(/authenticated/i);
102+
expect(out.join('\n')).toMatch(/usage: \/login/i);
103+
});
104+
});
105+
106+
describe('/pr_comments', () => {
107+
it('formatPrComments renders comments with author + body', () => {
108+
const lines = formatPrComments({
109+
number: 42,
110+
title: 'My PR',
111+
comments: [
112+
{
113+
author: { login: 'alice' },
114+
body: 'looks good\nship it',
115+
createdAt: '2026-06-04T01:00:00Z',
116+
},
117+
],
118+
}).join('\n');
119+
expect(lines).toContain('PR #42 — My PR');
120+
expect(lines).toContain('@alice');
121+
expect(lines).toContain('looks good');
122+
expect(lines).toContain('ship it');
123+
});
124+
125+
it('formatPrComments handles a PR with no comments', () => {
126+
const lines = formatPrComments({ number: 7, title: 'Quiet PR', comments: [] }).join('\n');
127+
expect(lines).toMatch(/no comments/i);
128+
});
129+
130+
it('reports gracefully outside a PR / without gh', async () => {
131+
const dir = await tmpHome();
132+
const out = (await reg.match('/pr_comments')!.cmd.run([], ctx({ cwd: dir }))).join('\n');
133+
// Whether gh is installed or not, we must not throw and must say something sane.
134+
expect(out).toMatch(/no open pull request|needs the github cli|failed/i);
135+
});
136+
});

apps/cli/src/repl.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,7 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
418418
effort,
419419
settings,
420420
creds,
421+
credsStore,
421422
sessionId: session.id,
422423
sessions,
423424
usage: { inputTokens: 0, outputTokens: 0, reasoningTokens: 0 },

docs/BEHAVIOR_PARITY.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ Legend: `✅` matches · `🟡` matches with caveats · `🔄` deferred · `⚠
3737
| `/plugins` ||| ✅ — lists wired plugins + contributed hook events + warnings (M5.2) |
3838
| `/compact` ||| ✅ — manual `/compact` + automatic threshold trigger in the agent loop |
3939
| `/btw` ||| 🔄 |
40-
| `/recap` || | 🔄 |
40+
| `/recap` || | ✅ — provider-summarized recap of the session so far |
4141
| `/rewind` ||| ✅ — 5 ops (code/conversation/both/summarize-from/up-to); `Esc Esc` bound |
4242
| `/voice` ||| 🔄 M8 |
4343
| `/teleport` ||| 🔄 M8 |
@@ -46,11 +46,11 @@ Legend: `✅` matches · `🟡` matches with caveats · `🔄` deferred · `⚠
4646
| `/batch` ||| 🔄 |
4747
| `/tasks` ||| 🔄 |
4848
| `/plan` ||| 🔄 — set via `/mode plan` in DeepCode |
49-
| `/login` / `/logout` || | 🔄DeepCode currently uses re-onboarding (clear creds + restart) |
49+
| `/login` / `/logout` || | /logout clears creds + exits; /login <key> stores a new key (next launch) |
5050
| `/export` ||| ✅ — writes the conversation to a markdown file |
5151
| `/bug` ||| 🔄 |
5252
| `/upgrade` || ✓ (hint only) | 🟡 |
53-
| `/pr_comments` || | 🔄 |
53+
| `/pr_comments` || | ✅ — `gh pr view` comments for the current branch's PR |
5454
| `/review` || ✗ (skill avail) | 🟡 — via Skill tool |
5555
| `/security-review` || ✗ (skill avail) | 🟡 — via Skill tool |
5656
| `/schedule` || ✗ (skill avail) | 🟡 |

0 commit comments

Comments
 (0)