|
2 | 2 | // Spec: docs/DEVELOPMENT_PLAN.md §3.6 (30+ commands; M2 ships a core subset) |
3 | 3 |
|
4 | 4 | import type { |
| 5 | + ContentBlock, |
| 6 | + CredentialsStore, |
5 | 7 | DeepCodeSettings, |
6 | 8 | McpClientHandle, |
7 | 9 | Provider, |
@@ -47,13 +49,66 @@ async function runGit( |
47 | 49 | } |
48 | 50 | } |
49 | 51 |
|
| 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 | + |
50 | 103 | export interface SessionContext { |
51 | 104 | cwd: string; |
52 | 105 | model: string; |
53 | 106 | mode: string; |
54 | 107 | effort: string; |
55 | 108 | settings: DeepCodeSettings; |
56 | 109 | creds: Credentials; |
| 110 | + /** Credentials store (REPL-injected) — backs /login and /logout. */ |
| 111 | + credsStore?: CredentialsStore; |
57 | 112 | sessionId: string; |
58 | 113 | sessions: SessionManager; |
59 | 114 | usage: { inputTokens: number; outputTokens: number; reasoningTokens: number }; |
@@ -870,6 +925,97 @@ export const BugCommand: SlashCommand = { |
870 | 925 | }, |
871 | 926 | }; |
872 | 927 |
|
| 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 | + |
873 | 1019 | export const BUILTIN_COMMANDS: SlashCommand[] = [ |
874 | 1020 | HelpCommand, |
875 | 1021 | ClearCommand, |
@@ -899,6 +1045,10 @@ export const BUILTIN_COMMANDS: SlashCommand[] = [ |
899 | 1045 | DiffCommand, |
900 | 1046 | ReleaseNotesCommand, |
901 | 1047 | BugCommand, |
| 1048 | + RecapCommand, |
| 1049 | + LoginCommand, |
| 1050 | + LogoutCommand, |
| 1051 | + PrCommentsCommand, |
902 | 1052 | ]; |
903 | 1053 |
|
904 | 1054 | // ────────────────────────────────────────────────────────────────────────── |
|
0 commit comments