@yanzc/claude-pro 0.0.0-stage → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/.env.example +41 -0
  2. package/LICENSE +21 -0
  3. package/README.md +314 -3
  4. package/bin/claude-pro.js +32 -0
  5. package/package.json +40 -6
  6. package/src/agent.js +752 -0
  7. package/src/agents.js +186 -0
  8. package/src/api.js +529 -0
  9. package/src/cache.js +150 -0
  10. package/src/checkpoints.js +184 -0
  11. package/src/claude-code.js +60 -0
  12. package/src/codex/client.js +307 -0
  13. package/src/codex/translate-request.js +239 -0
  14. package/src/codex/translate-stream.js +328 -0
  15. package/src/commands.js +839 -0
  16. package/src/config.js +453 -0
  17. package/src/context.js +109 -0
  18. package/src/filestate.js +57 -0
  19. package/src/gateway.js +143 -0
  20. package/src/hooks.js +81 -0
  21. package/src/main.js +193 -0
  22. package/src/mcp.js +290 -0
  23. package/src/modes.js +154 -0
  24. package/src/oauth.js +287 -0
  25. package/src/peers.js +157 -0
  26. package/src/print.js +106 -0
  27. package/src/prompts.js +179 -0
  28. package/src/ratelimit.js +244 -0
  29. package/src/restart.js +115 -0
  30. package/src/runtime.js +162 -0
  31. package/src/selfcheck.js +71 -0
  32. package/src/session.js +139 -0
  33. package/src/supervisor.js +110 -0
  34. package/src/tasks.js +76 -0
  35. package/src/tools/bash.js +227 -0
  36. package/src/tools/effort.js +41 -0
  37. package/src/tools/files.js +352 -0
  38. package/src/tools/index.js +52 -0
  39. package/src/tools/interaction.js +157 -0
  40. package/src/tools/notebook.js +91 -0
  41. package/src/tools/peers.js +45 -0
  42. package/src/tools/restart.js +50 -0
  43. package/src/tools/search.js +458 -0
  44. package/src/tools/skill.js +32 -0
  45. package/src/tools/task.js +139 -0
  46. package/src/tools/web.js +283 -0
  47. package/src/ui/ansi.js +228 -0
  48. package/src/ui/dialogs.js +122 -0
  49. package/src/ui/editor.js +389 -0
  50. package/src/ui/markdown.js +249 -0
  51. package/src/ui/render.js +155 -0
  52. package/src/ui/repl.js +1460 -0
  53. package/src/ui/screen.js +259 -0
  54. package/src/updater.js +93 -0
  55. package/src/utils/clipboard.js +197 -0
  56. package/src/utils/diff.js +95 -0
  57. package/src/utils/paths.js +37 -0
  58. package/src/utils/shell.js +251 -0
@@ -0,0 +1,839 @@
1
+ // Slash commands for the interactive REPL.
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { c, padEnd, truncate } from './ui/ansi.js';
6
+ import { SelectDialog } from './ui/dialogs.js';
7
+ import { renderTodos, resultLines, formatTokens, formatDuration } from './ui/render.js';
8
+ import { loadConfig, settingsFiles, updateSettingsFile, rememberChoice, applyRememberedChoices, KNOWN_KEYS, VERSION, APP_NAME, CONFIG_DIR } from './config.js';
9
+ import { loginWithBrowser, readGatewayLogin, clearGatewayLogin, fetchGatewayAccount, fetchGatewayModels, cachedGatewayModel, gatewayEfforts, gatewayEffort, formatUsd } from './gateway.js';
10
+ import { codexLoginSummary, codexModels, codexEfforts, codexEffort } from './codex/client.js';
11
+ import { readClaudeCodeLogin } from './claude-code.js';
12
+ import { SessionStore } from './session.js';
13
+ import { memoryFiles, listAutoMemories } from './context.js';
14
+ import { INIT_PROMPT } from './prompts.js';
15
+ import { loadCustomCommands, renderSkill, expandCommandBody, expandShellEmbeds } from './agents.js';
16
+ import { listBackgroundShells } from './tools/bash.js';
17
+ import { estimateTokens } from './agent.js';
18
+ import { quickQuery, cacheRefusals, defaultEffort } from './api.js';
19
+ import { hasRipgrep } from './utils/shell.js';
20
+ import { displayPath } from './utils/paths.js';
21
+ import { rewind } from './checkpoints.js';
22
+ import { canRestart, runSelfCheck } from './restart.js';
23
+
24
+ const EFFORTS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
25
+ const isGpt = (modelId) => /^gpt-/i.test(modelId);
26
+ // The levels /effort offers for a model: gateway metadata first, then the local Codex cache, else EFFORTS.
27
+ const effortLevels = (modelId, modelInfo = null) => {
28
+ const remote = gatewayEfforts(modelInfo);
29
+ if (remote.length) return remote;
30
+ return isGpt(modelId) && codexEfforts(modelId).length ? codexEfforts(modelId) : EFFORTS;
31
+ };
32
+
33
+ export function buildCommands(app) {
34
+ const commands = new Map();
35
+ const add = (cmd) => commands.set(cmd.name, cmd);
36
+ const log = (...lines) => app.screen.log(...lines);
37
+ const out = (lines) => log(resultLines(Array.isArray(lines) ? lines : [lines]), '');
38
+
39
+ // /model and /effort choices are kept for later sessions (rememberChoice); say so, or why they are not.
40
+ const keep = (cfg, patch, forgotten) => {
41
+ try {
42
+ rememberChoice(cfg.provider, patch);
43
+ return c.dim(forgotten ? ' · the configured default from now on' : ' · kept for later sessions');
44
+ } catch (e) {
45
+ return c.yellow(` · for this session only (${e.message})`);
46
+ }
47
+ };
48
+
49
+ const reloadConfig = () => {
50
+ const fresh = loadConfig({ cwd: app.cwd, flags: app.flags });
51
+ const cfg = app.runtime.config;
52
+ const model = cfg.mainModel.id;
53
+ cfg.env = fresh.env;
54
+ cfg.sources = fresh.sources;
55
+ cfg.settings = fresh.settings;
56
+ cfg.ignoredFromDotEnv = fresh.ignoredFromDotEnv;
57
+ // What the system prompt is built from may have changed (memory switched off, say). It is not rebuilt in the
58
+ // middle of a conversation, which would lose its prompt cache; it is, at the next /clear or compaction. A
59
+ // different model is the exception: the prompt names it, and the new model has a cache of its own anyway.
60
+ if (cfg.mainModel.id !== model) app.runtime.invalidateSystemPrompt();
61
+ else app.runtime.deferSystemPromptRefresh();
62
+ };
63
+
64
+ // Claude Code asks before something makes the next request read the whole conversation without the prompt
65
+ // cache, but only while that cache is still warm: once it has expired there is nothing left to lose. (A model
66
+ // that answered the last request has the cache that is in use, so switching to it loses nothing.)
67
+ const confirmCacheLoss = async (what, { model = null } = {}) => {
68
+ const cache = app.agent.cache;
69
+ if (!app.agent.messages.length || !cache.isWarm() || (model && model === cache.model)) return true;
70
+ const choice = await app.showDialog(new SelectDialog({
71
+ title: what,
72
+ question: `The prompt cache is warm (about ${formatTokens(cache.expected)} tokens, ${formatDuration(cache.expiresAt - Date.now())} left). ${what} makes the next request read the whole conversation again without it, which costs more and takes longer. Go on?`,
73
+ color: c.blue,
74
+ options: [{ label: 'Yes, go on', value: 'yes' }, { label: 'No, leave it as it is', value: 'no' }],
75
+ cancelValue: 'no',
76
+ }));
77
+ return choice === 'yes';
78
+ };
79
+
80
+ add({
81
+ name: 'help',
82
+ description: 'Show help and available commands',
83
+ run() {
84
+ const lines = [
85
+ c.bold(`${APP_NAME} v${VERSION}`) + c.dim(' — a Claude Code style coding agent'),
86
+ '',
87
+ 'Always review the agent\'s changes before committing them.',
88
+ '',
89
+ c.bold('Usage modes:'),
90
+ ` • REPL: ${c.bold(APP_NAME)} (interactive session)`,
91
+ ` • Non-interactive: ${c.bold(`${APP_NAME} -p "question"`)}`,
92
+ ` Run ${c.bold(`${APP_NAME} -h`)} for all command line flags`,
93
+ '',
94
+ c.bold('Shortcuts:'),
95
+ ...app.shortcutsHelp(),
96
+ '',
97
+ c.bold('Commands:'),
98
+ ];
99
+ for (const cmd of commands.values()) {
100
+ if (cmd.hidden) continue;
101
+ lines.push(` ${c.bold(padEnd('/' + cmd.name + (cmd.argHint ? ' ' + cmd.argHint : ''), 28))} ${c.dim(cmd.description)}`);
102
+ }
103
+ log(lines, '');
104
+ },
105
+ });
106
+
107
+ add({
108
+ name: 'clear',
109
+ aliases: ['reset', 'new'],
110
+ description: 'Clear conversation history and free up context',
111
+ run() {
112
+ app.agent.resetConversation();
113
+ app.newSession();
114
+ app.runtime.invalidateSystemPrompt();
115
+ app.screen.clearScreen();
116
+ app.printWelcome();
117
+ },
118
+ });
119
+
120
+ add({
121
+ name: 'restart',
122
+ aliases: ['reload'],
123
+ argHint: '[force]',
124
+ description: "Restart to load changes to the program's own source code (the conversation continues)",
125
+ async run(args) {
126
+ if (!canRestart()) {
127
+ out(c.red(`Restarting needs an interactive session started with \`${APP_NAME}\` (not -p, and without CLAUDE_PRO_NO_SUPERVISOR). Quit and start it again.`));
128
+ return;
129
+ }
130
+ const force = /\bforce\b/i.test(args);
131
+ const blockers = app.restartBlockers();
132
+ if (blockers.length && !force) {
133
+ out(c.yellow(`A restart would kill: ${blockers.join('; ')}. Run /restart force to do it anyway.`));
134
+ return;
135
+ }
136
+ if (!force) {
137
+ out(c.dim('Checking that the new code starts…'));
138
+ const check = await runSelfCheck();
139
+ if (!check.ok) {
140
+ out([c.red('Not restarting: the new code does not start.'), ...check.output.split('\n').slice(0, 15).map((l) => c.dim(l)), c.dim('Fix it, or run /restart force to restart anyway.')]);
141
+ return;
142
+ }
143
+ }
144
+ const res = app.requestRestart({ force: true });
145
+ if (!res.ok) out(c.red(res.message));
146
+ },
147
+ });
148
+
149
+ add({
150
+ name: 'compact',
151
+ argHint: '[instructions]',
152
+ description: 'Clear history but keep a summary in context',
153
+ async run(args) {
154
+ if (!app.agent.messages.length) { out(c.dim('Nothing to compact yet.')); return; }
155
+ app.busy = true;
156
+ app.controller = new AbortController();
157
+ app.turnStart = Date.now();
158
+ app.turnTokens = 0;
159
+ try {
160
+ const summary = await app.agent.compact({ signal: app.controller.signal, instructions: args });
161
+ if (summary && app.verbose) log(c.dim(summary), '');
162
+ } catch (e) {
163
+ if (!app.controller.signal.aborted) app.logError(e);
164
+ else out(c.red('Compaction interrupted'));
165
+ } finally {
166
+ app.busy = false;
167
+ app.controller = null;
168
+ app.status = null;
169
+ app.rateWait = null; // cancelled while paused for a rate limit: no countdown may outlive it
170
+ app.sendWhatWaited(); // what was typed during a long compaction (or its pause) goes out now
171
+ }
172
+ },
173
+ });
174
+
175
+ add({
176
+ name: 'config',
177
+ argHint: '[set KEY VALUE | unset KEY] [--project|--local]',
178
+ description: 'Show or change configuration (API URL, models, effort, …)',
179
+ run(args) {
180
+ const parts = args.split(/\s+/).filter(Boolean);
181
+ const scope = parts.includes('--project') ? 'project' : parts.includes('--local') ? 'local' : 'user';
182
+ const rest = parts.filter((p) => p !== '--project' && p !== '--local');
183
+ const file = settingsFiles(app.cwd)[scope];
184
+ if (rest[0] === 'set' && rest[1] && rest.length >= 3) {
185
+ const value = rest.slice(2).join(' ');
186
+ updateSettingsFile(file, (json) => { json.env = { ...(json.env || {}), [rest[1]]: value }; });
187
+ reloadConfig();
188
+ out([`Set ${c.bold(rest[1])} in ${file}`, ...(app.runtime.config.sources[rest[1]] !== file ? [c.yellow(`Note: overridden by ${app.runtime.config.sources[rest[1]]}`)] : [])]);
189
+ return;
190
+ }
191
+ if (rest[0] === 'unset' && rest[1]) {
192
+ updateSettingsFile(file, (json) => { if (json.env) delete json.env[rest[1]]; });
193
+ reloadConfig();
194
+ out(`Removed ${c.bold(rest[1])} from ${file}`);
195
+ return;
196
+ }
197
+ const cfg = app.runtime.config;
198
+ const lines = [c.bold('Configuration') + c.dim(' (precedence: .env < ~/.claude-pro/settings.json < .claude-pro/settings.json < .claude-pro/settings.local.json < shell env < flags)'), ''];
199
+ for (const k of Object.keys(KNOWN_KEYS)) {
200
+ const v = cfg.display(k);
201
+ if (v === undefined) continue;
202
+ lines.push(`${padEnd(k, 34)} ${c.bold(v)} ${c.dim(cfg.sources[k] || '')}`);
203
+ }
204
+ if (cfg.ignoredFromDotEnv.length) lines.push('', c.dim(`Not applied from .env with the claude-code provider (it is the API provider's setup; Claude Code's defaults are used instead): ${cfg.ignoredFromDotEnv.join(', ')}. Set them with /config set or /effort to apply them here.`));
205
+ lines.push('', c.dim('Other keys: ' + Object.keys(KNOWN_KEYS).filter((k) => cfg.env[k] === undefined).join(', ')));
206
+ lines.push('', `${c.dim('Change with')} /config set KEY VALUE [--project|--local] ${c.dim('or edit')} ${settingsFiles(app.cwd).user}`);
207
+ log(lines, '');
208
+ },
209
+ });
210
+
211
+ add({
212
+ name: 'model',
213
+ argHint: '[model]',
214
+ description: 'Set the AI model (kept for later sessions; /model default forgets it)',
215
+ async run(args) {
216
+ const cfg = app.runtime.config;
217
+ let choice = args.trim();
218
+ if (!choice) {
219
+ const configured = cfg.configuredModelSpec;
220
+ const opts = [{ label: `Default ${c.dim(`(${configured} → ${cfg.resolveModel(configured).id})`)}`, value: '__default__' }];
221
+ if (cfg.usesCodex) {
222
+ // The models the local Codex install lists.
223
+ for (const m of codexModels({ ...process.env, ...cfg.env }).models.filter((x) => !x.hidden)) opts.push({ label: `${m.slug}${m.contextWindow ? c.dim(` (${formatTokens(m.contextWindow)} context)`) : ''}`, value: m.slug });
224
+ } else {
225
+ opts.push(...['opus', 'sonnet', 'haiku'].map((a) => ({ label: `${a} ${c.dim(`→ ${cfg.resolveModel(cfg.aliasTarget(a)).label}`)}`, value: a })));
226
+ }
227
+ if (cfg.usesGateway) {
228
+ // The other models the gateway serves (Fable, the GPT models, …); without an answer, gpt-6.1-sol.
229
+ const login = readGatewayLogin(cfg.gatewayUrl);
230
+ let served = null;
231
+ try { if (login) served = await fetchGatewayModels(login, { signal: AbortSignal.timeout(4000) }); } catch { /* offline: the fallback below */ }
232
+ const aliased = new Set(['opus', 'sonnet', 'haiku'].map((a) => cfg.resolveModel(cfg.aliasTarget(a)).id));
233
+ for (const m of served?.length ? served : [{ id: 'gpt-6.1-sol' }]) {
234
+ if (aliased.has(m.id)) continue;
235
+ const note = /^gpt-/i.test(m.id) ? 'OpenAI, through the gateway\'s Codex login' : m.display_name;
236
+ opts.push({ label: `${m.id}${note ? ` ${c.dim(`(${note})`)}` : ''}`, value: m.id });
237
+ }
238
+ }
239
+ if (cfg.provider === 'api' && cfg.env.CLAUDE_CODE_SUBAGENT_MODEL) opts.push({ label: `${cfg.env.CLAUDE_CODE_SUBAGENT_MODEL} ${c.dim('(sub-agent model)')}`, value: cfg.env.CLAUDE_CODE_SUBAGENT_MODEL });
240
+ choice = await app.showDialog(new SelectDialog({ title: 'Select model', question: `Current: ${c.bold(cfg.mainModel.label)}. Any name works too: /model <name>`, options: opts }));
241
+ if (!choice) return;
242
+ }
243
+ const forget = choice === '__default__' || choice === 'default';
244
+ const next = cfg.resolveModel(forget ? cfg.configuredModelSpec : choice).id;
245
+ const changed = next !== cfg.mainModel.id;
246
+ if (changed && !(await confirmCacheLoss('Switching the model', { model: next }))) { out(c.dim('The model was not changed.')); return; }
247
+ cfg.modelOverride = forget ? null : choice;
248
+ if (changed) app.runtime.invalidateSystemPrompt(); // the prompt names the model; the new model starts a cache of its own anyway
249
+ const kept = keep(cfg, { model: cfg.modelOverride }, forget);
250
+ out(`Model set to ${c.bold(cfg.mainModel.label)} ${c.dim(`(${formatTokens(cfg.mainModel.contextWindow)} context)`)}${kept}`);
251
+ },
252
+ });
253
+
254
+ add({
255
+ name: 'effort',
256
+ get argHint() {
257
+ const cfg = app.runtime.config;
258
+ const login = cfg.usesGateway ? readGatewayLogin(cfg.gatewayUrl) : null;
259
+ return `[${effortLevels(cfg.mainModel.id, cachedGatewayModel(login, cfg.mainModel.id)).join('|')}|auto|default]`;
260
+ },
261
+ description: 'Set reasoning effort (output_config.effort; kept for later sessions)',
262
+ async run(args) {
263
+ const cfg = app.runtime.config;
264
+ const id = cfg.mainModel.id;
265
+ let modelInfo = null;
266
+ if (cfg.usesGateway && isGpt(id)) {
267
+ const login = readGatewayLogin(cfg.gatewayUrl);
268
+ try { if (login) modelInfo = (await fetchGatewayModels(login, { signal: AbortSignal.timeout(4000) })).find((m) => m.id === id) || null; } catch { /* use local cache or generic levels */ }
269
+ }
270
+ const levels = effortLevels(id, modelInfo);
271
+ let choice = args.trim().toLowerCase();
272
+ if (!choice) {
273
+ const configured = (cfg.env.CLAUDE_CODE_EFFORT_LEVEL || '').trim().toLowerCase() || 'auto';
274
+ const fallback = defaultEffort(id, cfg);
275
+ // A level this GPT model does not take goes out as the nearest one it does.
276
+ const sent = cfg.effort && isGpt(id)
277
+ ? modelInfo ? gatewayEffort(modelInfo, cfg.effort) : codexEffort(id, cfg.effort)
278
+ : cfg.effort;
279
+ const current = !cfg.effort ? c.bold(`auto (${fallback ? `model default: ${fallback}` : 'not sent'})`)
280
+ : `${c.bold(cfg.effort)}${sent !== cfg.effort ? c.yellow(` (${id} does not take it: sent as ${sent})`) : ''}`;
281
+ choice = await app.showDialog(new SelectDialog({
282
+ title: `Reasoning effort · ${id}`,
283
+ question: `Current: ${current}`,
284
+ options: [
285
+ ...levels.map((e) => ({ label: e, value: e })),
286
+ { label: `auto ${c.dim(fallback ? `(model default: ${fallback})` : '(use API default)')}`, value: 'auto' },
287
+ { label: `default ${c.dim(`(configured: ${configured})`)}`, value: 'default' },
288
+ ],
289
+ }));
290
+ if (!choice) return;
291
+ }
292
+ if (choice !== 'auto' && choice !== 'default' && !levels.includes(choice)) { out(c.red(`${id} does not take effort "${choice}". Use one of: ${levels.join(', ')}, auto, default`)); return; }
293
+ const forget = choice === 'default';
294
+ const wanted = (forget ? (cfg.env.CLAUDE_CODE_EFFORT_LEVEL || '') : choice).trim().toLowerCase();
295
+ const next = wanted && wanted !== 'auto' && wanted !== 'default' ? wanted : null;
296
+ if (next !== cfg.effort && !(await confirmCacheLoss('Changing the effort'))) { out(c.dim('The effort was not changed.')); return; }
297
+ cfg.effortOverride = forget ? null : choice;
298
+ cfg.effortBoost = null; // the user's choice replaces what the model set for itself with the Effort tool
299
+ out(`Effort set to ${c.bold(cfg.effort || 'auto')}${keep(cfg, { effort: cfg.effortOverride }, forget)}`);
300
+ },
301
+ });
302
+
303
+ // Switch the provider (kept in the user settings) and take up its own remembered /model and /effort.
304
+ const switchProvider = (provider) => {
305
+ const cfg = app.runtime.config;
306
+ const files = settingsFiles(app.cwd);
307
+ // The choice goes where the setting that wins is now: a project's settings file overrides the user's,
308
+ // so writing only the user's file would change nothing in this project.
309
+ const winning = cfg.sources.CLAUDE_PRO_PROVIDER;
310
+ const file = [files.local, files.project].includes(winning) ? winning : files.user;
311
+ const set = (f) => updateSettingsFile(f, (json) => { json.env = { ...(json.env || {}), CLAUDE_PRO_PROVIDER: provider }; });
312
+ set(files.user); // the default for the other projects too
313
+ if (file !== files.user) set(file);
314
+ const model = cfg.mainModel.id;
315
+ reloadConfig();
316
+ cfg.modelOverride = null;
317
+ cfg.effortOverride = null;
318
+ cfg.effortBoost = null;
319
+ applyRememberedChoices(cfg, app.flags);
320
+ if (cfg.mainModel.id !== model) app.runtime.invalidateSystemPrompt();
321
+ if (cfg.provider !== provider) return c.red(` · NOT switched: CLAUDE_PRO_PROVIDER=${cfg.provider} is set by ${cfg.sources.CLAUDE_PRO_PROVIDER}, which overrides the settings files; change it there`);
322
+ return file !== files.user ? c.dim(` · also saved in ${displayPath(file, app.cwd)}, which overrides the user settings in this project`) : '';
323
+ };
324
+ const accountLine = (account) => `${account.name || ''}${account.email ? ` <${account.email}>` : ''} · balance ${c.bold(formatUsd(account.available_usd ?? account.balance_usd))}`;
325
+ let pendingLogin = null;
326
+
327
+ add({
328
+ name: 'login',
329
+ argHint: '[claude|gateway|codex]',
330
+ description: 'Choose where model requests go: the Claude subscription, the codex-gateway account (browser login), or the Codex subscription',
331
+ async run(args) {
332
+ const cfg = app.runtime.config;
333
+ let choice = args.trim().toLowerCase();
334
+ if (choice === 'claude') choice = 'claude-code';
335
+ if (!choice) {
336
+ const saved = readGatewayLogin(cfg.gatewayUrl);
337
+ const codex = codexLoginSummary({ ...process.env, ...cfg.env });
338
+ choice = await app.showDialog(new SelectDialog({
339
+ title: 'Log in',
340
+ question: `Now: ${c.bold(cfg.provider)} (${cfg.endpointLabel})`,
341
+ options: [
342
+ { label: `Gateway ${cfg.gatewayUrl} ${c.dim(saved ? `(logged in as ${saved.account?.email || 'this machine'}; paid from the account balance)` : '(log in in the browser; paid from the account balance)')}`, value: 'gateway' },
343
+ { label: `Claude subscription ${c.dim('(the local Claude Code login)')}`, value: 'claude-code' },
344
+ { label: `Codex subscription ${c.dim(codex.ok ? `(the local Codex login${codex.plan ? `, ${codex.plan}` : ''})` : '(run `codex login` first)')}`, value: 'codex' },
345
+ ],
346
+ }));
347
+ if (!choice) return;
348
+ }
349
+ if (!['claude-code', 'gateway', 'codex'].includes(choice)) { out(c.red(`Unknown choice "${choice}". Use claude, gateway or codex.`)); return; }
350
+ if (choice !== cfg.provider && !(await confirmCacheLoss('Switching the provider'))) { out(c.dim('Nothing was changed.')); return; }
351
+
352
+ if (choice === 'claude-code') {
353
+ try { readClaudeCodeLogin(cfg); } catch (e) {
354
+ // An expired token is renewed at the first request; a missing login is not.
355
+ if (!/expired/i.test(e.message)) {
356
+ out(c.red(`The Claude subscription login cannot be used: ${e.message}. Log in with the official \`claude\` once, then run /login again.`));
357
+ return;
358
+ }
359
+ }
360
+ const note = switchProvider('claude-code');
361
+ out(`Using the ${c.bold('Claude subscription')}: ${cfg.mainModel.label}${note}`);
362
+ return;
363
+ }
364
+ if (choice === 'codex') {
365
+ const codex = codexLoginSummary({ ...process.env, ...cfg.env });
366
+ if (!codex.ok) { out(c.red(`The Codex login cannot be used: ${codex.error}`)); return; }
367
+ const note = switchProvider('codex');
368
+ out(`Using the ${c.bold('Codex subscription')}${codex.email ? ` (${codex.email}${codex.plan ? `, ${codex.plan}` : ''})` : ''}: ${cfg.mainModel.label}${note}`);
369
+ return;
370
+ }
371
+
372
+ // The gateway: a saved key that still works is used as it is; otherwise log in through the browser.
373
+ const url = cfg.gatewayUrl;
374
+ const saved = readGatewayLogin(url);
375
+ if (saved) {
376
+ try {
377
+ const account = await fetchGatewayAccount(saved);
378
+ const note = switchProvider('gateway');
379
+ out([`Using the gateway ${c.bold(url)}: ${cfg.mainModel.label}${note}`, accountLine(account), c.dim(`Top up and usage: ${url}/user · another account: /logout, then /login`)]);
380
+ return;
381
+ } catch (e) {
382
+ if (e.status !== 401 && e.status !== 403) { out(c.red(`Cannot reach the gateway ${url}: ${e.message}`)); return; }
383
+ clearGatewayLogin(); // the key was revoked: log in again
384
+ }
385
+ }
386
+ pendingLogin?.abort();
387
+ const controller = new AbortController();
388
+ pendingLogin = controller;
389
+ try {
390
+ const login = await loginWithBrowser({
391
+ url,
392
+ signal: controller.signal,
393
+ onUrl: (page) => out([`Opening the gateway login in your browser. If it does not open, visit:`, c.blue(page), c.dim('Waiting for the browser (10 minutes at most)…')]),
394
+ });
395
+ const note = switchProvider('gateway');
396
+ out([`Logged in to the gateway ${c.bold(url)}: ${cfg.mainModel.label}${note}`, accountLine(login.balance || login.account), c.dim(`Top up and usage: ${url}/user`)]);
397
+ } catch (e) {
398
+ if (e.name !== 'AbortError') out(c.red(`Gateway login failed: ${e.message}`));
399
+ } finally {
400
+ if (pendingLogin === controller) pendingLogin = null;
401
+ }
402
+ },
403
+ });
404
+
405
+ add({
406
+ name: 'logout',
407
+ description: 'Forget the gateway login of this machine',
408
+ run() {
409
+ const cfg = app.runtime.config;
410
+ const had = clearGatewayLogin();
411
+ out(had ? `Logged out of the gateway ${cfg.gatewayUrl}.${cfg.usesGateway ? ' Run /login to log in again or choose another provider.' : ''}` : c.dim('There was no gateway login. (The Claude and Codex subscription logins belong to their own CLIs.)'));
412
+ },
413
+ });
414
+
415
+ add({
416
+ name: 'status',
417
+ description: 'Show version, model, API endpoint and session info',
418
+ run() {
419
+ const cfg = app.runtime.config;
420
+ const modes = app.runtime.modes;
421
+ const mem = memoryFiles(app.cwd);
422
+ const mcp = app.runtime.mcp.status();
423
+ const row = (k, v) => `${c.dim(padEnd(k, 18))} ${v}`;
424
+ log([
425
+ c.bold('Status'), '',
426
+ row('Version', VERSION),
427
+ row('Session ID', app.store.id),
428
+ row('cwd', app.cwd),
429
+ row('Provider', cfg.provider),
430
+ row('Endpoint', cfg.endpointLabel),
431
+ row('Auth', cfg.usesClaudeCode ? `existing Claude Code OAuth (${cfg.claudeCodeCredentialsFile})`
432
+ : cfg.usesGateway ? (readGatewayLogin(cfg.gatewayUrl) ? `gateway key of ${readGatewayLogin(cfg.gatewayUrl).account?.email || 'this machine'} ${c.dim('(/cost shows the balance)')}` : c.red('not logged in (/login)'))
433
+ : cfg.usesCodex ? (() => { const s = codexLoginSummary({ ...process.env, ...cfg.env }); return s.ok ? `Codex login${s.email ? ` of ${s.email}` : ''}${s.plan ? ` (${s.plan})` : ''} ${c.dim(s.file)}` : c.red(s.error); })()
434
+ : cfg.authToken ? `ANTHROPIC_AUTH_TOKEN (${cfg.display('ANTHROPIC_AUTH_TOKEN')})` : cfg.apiKey ? `ANTHROPIC_API_KEY (${cfg.display('ANTHROPIC_API_KEY')})` : c.red('none')),
435
+ row('Model', `${cfg.mainModel.label} ${c.dim(`(sent as "${cfg.mainModel.id}", ${formatTokens(cfg.mainModel.contextWindow)} context)`)}`),
436
+ row('Sub-agent model', cfg.subagentModel.label),
437
+ row('Small model', cfg.smallModel.label),
438
+ row('Effort', cfg.effort || 'auto'),
439
+ row('Max output', formatTokens(cfg.maxOutputTokens)),
440
+ row('Prompt cache', cfg.promptCachingDisabled(cfg.mainModel.id) ? 'off (DISABLE_PROMPT_CACHING)' : `on · TTL ${cfg.cacheTtl('main')} for the conversation, ${cfg.cacheTtl('other')} for sub-agents and compaction${cacheRefusals(cfg, cfg.mainModel.id).length ? c.yellow(` · the endpoint refused: ${cacheRefusals(cfg, cfg.mainModel.id).join(', ')}`) : ''}${app.runtime.systemPromptStale ? c.dim(' · memory/config changes reach the system prompt at /clear or /compact') : ''}`),
441
+ row('Auto-compact', cfg.autoCompactEnabled ? `at ${formatTokens(cfg.autoCompactThreshold())} tokens (window ${formatTokens(cfg.compactWindow())})` : 'disabled'),
442
+ row('Mode', modes.mode === 'plan' ? 'plan (read-only; shift+tab to leave)' : 'full access (no permission prompts)'),
443
+ row('Checkpoints', `${app.runtime.checkpoints.list().length} (/rewind)`),
444
+ row('Shell', `${app.runtime.shell.name} ${c.dim(app.runtime.shell.path)}`),
445
+ row('ripgrep', hasRipgrep(cfg) ? 'yes' : 'no (using JS fallback)'),
446
+ row('Memory', mem.length ? mem.map((m) => m.file).join(', ') : c.dim('none')),
447
+ row('MCP servers', mcp.length ? mcp.map((m) => `${m.name} (${m.status})`).join(', ') : c.dim('none')),
448
+ ], '');
449
+ },
450
+ });
451
+
452
+ add({
453
+ name: 'cost',
454
+ aliases: ['usage'],
455
+ description: 'Show token usage for this session (and the gateway balance)',
456
+ async run() {
457
+ const a = app.agent;
458
+ const u = a.totalUsage();
459
+ const b = a.subUsage;
460
+ const cacheLines = a.cache.describe();
461
+ out([
462
+ `Total input: ${formatTokens(u.input + u.cacheRead + u.cacheWrite)} tokens ${c.dim(`(${formatTokens(u.cacheRead)} cache read, ${formatTokens(u.cacheWrite)} cache write${u.cacheWrite1h ? `, ${formatTokens(u.cacheWrite1h)} of it for 1 hour` : ''})`)}`,
463
+ `Total output: ${formatTokens(u.output)} tokens`,
464
+ `API requests: ${u.requests}${u.compactions ? c.dim(` + ${u.compactions} to compact the conversation`) : ''}`,
465
+ ...cacheLines.map((line, i) => (i ? c.dim(line) : line)),
466
+ ...(b.runs ? [c.dim(`Sub-agents: ${b.runs} run${b.runs === 1 ? '' : 's'}, ${b.requests + b.compactions} request${b.requests + b.compactions === 1 ? '' : 's'}, ${formatTokens(b.input + b.cacheRead + b.cacheWrite)} input (${formatTokens(b.cacheRead)} cached), ${formatTokens(b.output)} output; included in the totals`)] : []),
467
+ `Session time: ${formatDuration(Date.now() - app.sessionStart)}`,
468
+ ]);
469
+ const cfg = app.runtime.config;
470
+ const saved = cfg.usesGateway && readGatewayLogin(cfg.gatewayUrl);
471
+ if (!saved) return;
472
+ try {
473
+ const account = await fetchGatewayAccount(saved);
474
+ out([
475
+ `Gateway: ${accountLine(account)}${Number(account.reserved_usd) ? c.dim(` (${formatUsd(account.reserved_usd)} held for requests in flight)`) : ''}`,
476
+ c.dim(` ${account.request_count} requests in all, ${formatTokens(account.input_tokens + account.cache_read_input_tokens + account.cache_creation_input_tokens)} input, ${formatTokens(account.output_tokens)} output · top up and usage: ${cfg.gatewayUrl}/user`),
477
+ ]);
478
+ } catch (e) {
479
+ out(c.yellow(`Gateway: could not read the balance (${e.message})`));
480
+ }
481
+ },
482
+ });
483
+
484
+ add({
485
+ name: 'context',
486
+ description: 'Show context window usage',
487
+ run() {
488
+ const cfg = app.runtime.config;
489
+ const a = app.agent;
490
+ const window = cfg.mainModel.contextWindow;
491
+ const threshold = cfg.autoCompactThreshold();
492
+ const sys = estimateTokens(app.runtime.systemPrompt());
493
+ const tools = estimateTokens(a.toolSchemas());
494
+ const msgs = estimateTokens(a.messages);
495
+ const used = a.lastContextTokens ? a.contextEstimate : sys + tools + msgs;
496
+ const barW = 40;
497
+ const fill = Math.min(barW, Math.round((used / window) * barW));
498
+ const mark = Math.min(barW - 1, Math.round((threshold / window) * barW));
499
+ let bar = '';
500
+ for (let i = 0; i < barW; i++) bar += i < fill ? c.claude('█') : i === mark ? c.yellow('┃') : c.dim('░');
501
+ out([
502
+ `${bar} ${c.bold(formatTokens(used))} / ${formatTokens(window)} tokens (${Math.round((used / window) * 100)}%)`,
503
+ c.dim(`System prompt ~${formatTokens(sys)} · Tools ~${formatTokens(tools)} · Messages ~${formatTokens(msgs)} (${a.messages.length})`),
504
+ cfg.autoCompactEnabled ? c.dim(`Auto-compact at ${formatTokens(threshold)} tokens (┃) — ${Math.max(0, Math.round((1 - used / threshold) * 100))}% left`) : c.dim('Auto-compact disabled'),
505
+ ]);
506
+ },
507
+ });
508
+
509
+ add({
510
+ name: 'init',
511
+ description: 'Initialize a CLAUDE.md file with codebase documentation',
512
+ run() { return app.startTurn(INIT_PROMPT, { echo: false }); },
513
+ });
514
+
515
+ add({
516
+ name: 'review',
517
+ argHint: '[focus]',
518
+ description: 'Review the current changes (git diff) for bugs',
519
+ run(args) {
520
+ return app.startTurn(`Review the uncommitted changes in this repository (run git status and git diff, including staged changes). Look for correctness bugs, edge cases, security issues and leftover debug code. Report concrete findings with file_path:line_number references, most severe first. Do not modify files.${args ? `\nFocus: ${args}` : ''}`, { echo: false });
521
+ },
522
+ });
523
+
524
+ add({
525
+ name: 'memory',
526
+ description: 'Show memory files (CLAUDE.md and auto memory); use # to add memories',
527
+ run() {
528
+ const mem = memoryFiles(app.cwd);
529
+ const lines = mem.length ? mem.map((m) => `${m.file} ${c.dim(`(${m.scope}, ${m.text.split('\n').length} lines)`)}`) : [c.dim('No CLAUDE.md files. Start a message with # to save one, or run /init.')];
530
+ if (app.runtime.config.autoMemoryEnabled) {
531
+ const files = listAutoMemories(app.runtime.memoryDir);
532
+ lines.push('', `Auto memory: ${app.runtime.memoryDir} ${c.dim(`(${files.length} memor${files.length === 1 ? 'y' : 'ies'}; the agent maintains these itself)`)}`);
533
+ lines.push(...files.slice(0, 30).map((f) => c.dim(` ${f}`)));
534
+ if (files.length > 30) lines.push(c.dim(` … +${files.length - 30} more`));
535
+ } else {
536
+ lines.push('', c.dim('Auto memory is off (CLAUDE_PRO_DISABLE_AUTO_MEMORY).'));
537
+ }
538
+ out(lines);
539
+ },
540
+ });
541
+
542
+ add({
543
+ name: 'verify',
544
+ argHint: '[command|off]',
545
+ description: 'Run a command (e.g. npm test) after turns that edit files; failures go back to the agent',
546
+ run(args) {
547
+ const cfg = app.runtime.config;
548
+ if (args.trim()) cfg.verifyOverride = args.trim();
549
+ out(cfg.verifyCommand
550
+ ? `Verify command: ${c.bold(cfg.verifyCommand)} ${c.dim(`(up to ${cfg.verifyMaxRounds} fix rounds; /verify off to disable)`)}`
551
+ : c.dim('Verification is off. Turn it on with /verify <command>, --verify <command> or CLAUDE_PRO_VERIFY_COMMAND.'));
552
+ },
553
+ });
554
+
555
+ add({
556
+ name: 'tasks',
557
+ argHint: '[kill ID]',
558
+ description: 'List background agents, or stop one',
559
+ run(args) {
560
+ const tasks = app.runtime.tasks;
561
+ const [action, id] = args.split(/\s+/).filter(Boolean);
562
+ if (action === 'kill' && id) { out(tasks.kill(id) ? `Stopping ${c.bold(id)}` : c.red(`${id} is not running`)); return; }
563
+ const list = tasks.list();
564
+ out(list.length
565
+ ? list.map((t) => `${c.bold(t.id)} ${t.status === 'running' ? c.yellow(t.status) : t.status === 'completed' ? c.green(t.status) : c.red(t.status)} ${c.dim(`${t.subagentType} · ${formatDuration((t.ended || Date.now()) - t.started)}`)} ${truncate(t.description, 60)}`)
566
+ : [c.dim('No background agents. The agent starts them with Task run_in_background.')]);
567
+ },
568
+ });
569
+
570
+ add({
571
+ name: 'update',
572
+ description: `Look for a newer ${APP_NAME} on npm now and install it`,
573
+ async run() { out(c.dim(`Version ${VERSION} · checking npm…`)); await app.autoUpdate({ force: true }); },
574
+ });
575
+
576
+ add({
577
+ name: 'peers',
578
+ aliases: ['sessions'],
579
+ argHint: '[name NEW-NAME]',
580
+ description: 'List the other sessions on this machine (send one a task with @name ...), or rename this one',
581
+ run(args) {
582
+ const node = app.peers;
583
+ if (!node) { out(c.dim('Talking to other sessions is off (CLAUDE_PRO_PEERS=0).')); return; }
584
+ const [action, ...rest] = args.split(/\s+/).filter(Boolean);
585
+ if (action === 'name' && rest.length) { out(`This session is now ${c.bold('@' + node.setName(rest.join('-')))}`); app.screen.schedule(); return; }
586
+ const peers = node.peers();
587
+ out([
588
+ `This session: ${c.bold('@' + node.name)} ${c.dim(displayPath(node.cwd, app.cwd))}`,
589
+ ...(peers.length
590
+ ? peers.map((p) => `${c.bold('@' + p.name)} ${c.dim(`${p.cwd} · pid ${p.pid}`)}`)
591
+ : [c.dim('No other session is running. Start claude-pro in another terminal.')]),
592
+ c.dim('Type "@name task" to send a task to a session; its answer is shown here. The agent can use ListPeers / MessagePeer too.'),
593
+ ]);
594
+ },
595
+ });
596
+
597
+ add({
598
+ name: 'plan',
599
+ description: 'Toggle read-only plan mode (same as shift+tab)',
600
+ run() {
601
+ const mode = app.runtime.modes.toggle();
602
+ out(mode === 'plan'
603
+ ? [`${c.plan('Plan mode on')}: the agent can only read and research, then proposes a plan with ExitPlanMode.`, c.dim('Run /plan or press shift+tab to leave it.')]
604
+ : `Plan mode off: ${c.bold('full access')}, tools run immediately.`);
605
+ },
606
+ });
607
+
608
+ add({
609
+ name: 'rewind',
610
+ aliases: ['checkpoint'],
611
+ description: 'Undo: restore files and/or the conversation to before an earlier prompt',
612
+ async run() {
613
+ const store = app.runtime.checkpoints;
614
+ const entries = store.list().slice(0, 30);
615
+ if (!entries.length) { out(c.dim('No checkpoints yet: one is created for every prompt you send.')); return; }
616
+ const id = await app.showDialog(new SelectDialog({
617
+ title: 'Rewind',
618
+ question: 'Go back to before which prompt?',
619
+ color: c.blue,
620
+ options: entries.map((e) => {
621
+ const n = store.filesSince(e.id).length;
622
+ return {
623
+ label: truncate(e.label || '(empty prompt)', 60),
624
+ value: e.id,
625
+ description: `${new Date(e.time).toLocaleTimeString()} · ${n ? `${n} file${n === 1 ? '' : 's'} changed since` : 'no file changes since'}`,
626
+ };
627
+ }),
628
+ }));
629
+ if (!id) return;
630
+ const entry = store.get(id);
631
+ const files = store.filesSince(id);
632
+ const options = [];
633
+ if (files.length && entry.conversationValid) options.push({ label: 'Restore code and conversation', value: 'both', description: `${files.length} file(s) go back and the chat continues from before this prompt` });
634
+ if (files.length) options.push({ label: 'Restore code only', value: 'code', description: `${files.length} file(s) go back; the conversation stays as it is` });
635
+ if (entry.conversationValid) options.push({ label: files.length ? 'Restore conversation only' : 'Rewind the conversation', value: 'conversation', description: 'Files are left as they are' });
636
+ if (!options.length) { out(c.dim('Nothing to restore for that prompt (the conversation was compacted and no files changed).')); return; }
637
+ options.push({ label: 'Never mind', value: 'cancel' });
638
+ const mode = await app.showDialog(new SelectDialog({ title: 'Rewind', question: `To before: ${c.bold(truncate(entry.label || '(empty prompt)', 60))}`, color: c.blue, options, cancelValue: 'cancel' }));
639
+ if (!mode || mode === 'cancel') return;
640
+ let result;
641
+ try { result = rewind(app.agent, store, id, mode); } catch (e) { out(c.red(e.message)); return; }
642
+ const lines = [];
643
+ if (result.restore) {
644
+ const r = result.restore;
645
+ lines.push(`Restored ${r.restored.length} file${r.restored.length === 1 ? '' : 's'}${r.removed.length ? `, deleted ${r.removed.length} created since` : ''}${r.skipped.length ? `, ${c.red(`could not restore ${r.skipped.length}`)}` : ''}`);
646
+ lines.push(...[...r.restored, ...r.removed].slice(0, 12).map((f) => c.dim(` ${displayPath(f, app.cwd)}`)));
647
+ lines.push(...r.skipped.slice(0, 5).map((f) => c.red(` skipped: ${f}`)));
648
+ }
649
+ if (result.conversation) lines.push(`Conversation rewound to before: ${truncate(result.prompt, 60)}`);
650
+ out(lines);
651
+ if (result.conversation && result.prompt) app.editor.set(result.prompt);
652
+ },
653
+ });
654
+
655
+ add({
656
+ name: 'resume',
657
+ argHint: '[session id]',
658
+ description: 'Resume a previous conversation',
659
+ async run(args) {
660
+ const sessions = SessionStore.list(app.cwd).filter((s) => s.id !== app.store.id);
661
+ if (!sessions.length) { out(c.dim('No previous conversations in this directory.')); return; }
662
+ let chosen = args ? sessions.find((s) => s.id.startsWith(args.trim())) : null;
663
+ if (!chosen) {
664
+ const id = await app.showDialog(new SelectDialog({
665
+ title: 'Resume a conversation',
666
+ options: sessions.slice(0, 20).map((s) => ({
667
+ label: `${truncate(s.title, 60)}`,
668
+ value: s.id,
669
+ description: `${new Date(s.mtime).toLocaleString()} · ${s.count} messages · ${s.id.slice(0, 8)}`,
670
+ })),
671
+ }));
672
+ if (!id) return;
673
+ chosen = sessions.find((s) => s.id === id);
674
+ }
675
+ app.agent.resetConversation();
676
+ app.loadSession(chosen.file, chosen.id);
677
+ },
678
+ });
679
+
680
+ add({
681
+ name: 'todos',
682
+ description: 'Show the current todo list',
683
+ run() {
684
+ const todos = app.agent.todos;
685
+ log(todos.length ? renderTodos(todos) : resultLines([c.dim('No todos')]), '');
686
+ },
687
+ });
688
+
689
+ add({
690
+ name: 'agents',
691
+ description: 'List available sub-agent types',
692
+ run() {
693
+ const lines = [];
694
+ for (const a of app.runtime.agents.values()) {
695
+ lines.push(`${c.bold(a.name)} ${c.dim(`(${a.source === 'built-in' ? 'built-in' : a.source})`)}`, c.dim(' ' + truncate(a.description, app.width - 12)));
696
+ }
697
+ lines.push(c.dim(`Add custom agents as markdown files in .claude/agents/ or ${path.join(CONFIG_DIR, 'agents')}`));
698
+ out(lines);
699
+ },
700
+ });
701
+
702
+ add({
703
+ name: 'mcp',
704
+ description: 'Show MCP server status',
705
+ run() {
706
+ const status = app.runtime.mcp.status();
707
+ if (!status.length) { out(c.dim('No MCP servers configured. Add them to .mcp.json or "mcpServers" in ~/.claude-pro/settings.json.')); return; }
708
+ out(status.map((s) => `${s.status === 'connected' ? c.green('✔') : c.red('✘')} ${c.bold(s.name)} ${c.dim(s.status === 'connected' ? `${s.tools} tools${s.serverInfo?.name ? ` · ${s.serverInfo.name} ${s.serverInfo.version || ''}` : ''}` : s.error || s.status)}`));
709
+ },
710
+ });
711
+
712
+ add({
713
+ name: 'bashes',
714
+ description: 'List background shells',
715
+ run() {
716
+ const list = listBackgroundShells();
717
+ out(list.length ? list.map((b) => `${c.bold(b.id)} ${b.status}${b.exitCode !== null ? ` (exit ${b.exitCode})` : ''} ${c.dim(truncate(b.command, 60))}`) : [c.dim('No background shells')]);
718
+ },
719
+ });
720
+
721
+ add({
722
+ name: 'verbose',
723
+ aliases: ['thinking'],
724
+ description: 'Toggle verbose output (thinking, full tool results)',
725
+ run() {
726
+ app.verbose = !app.verbose;
727
+ out(`Verbose mode ${app.verbose ? 'on' : 'off'}`);
728
+ },
729
+ });
730
+
731
+ add({
732
+ name: 'export',
733
+ argHint: '[file]',
734
+ description: 'Export the conversation to a markdown file',
735
+ run(args) {
736
+ const file = path.resolve(app.cwd, args || `conversation-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.md`);
737
+ const parts = [];
738
+ for (const m of app.agent.messages) {
739
+ const blocks = typeof m.content === 'string' ? [{ type: 'text', text: m.content }] : m.content;
740
+ for (const b of blocks) {
741
+ if (b.type === 'text' && !b.text.startsWith('<system-reminder>')) parts.push(m.role === 'user' ? `## User\n\n${b.text}` : `## Assistant\n\n${b.text}`);
742
+ else if (b.type === 'tool_use') parts.push(`**Tool: ${b.name}**\n\n\`\`\`json\n${JSON.stringify(b.input, null, 2)}\n\`\`\``);
743
+ else if (b.type === 'tool_result') {
744
+ const t = typeof b.content === 'string' ? b.content : (b.content || []).map((x) => x.text || '').join('\n');
745
+ parts.push(`<details><summary>Result${b.is_error ? ' (error)' : ''}</summary>\n\n\`\`\`\n${t.slice(0, 5000)}\n\`\`\`\n</details>`);
746
+ }
747
+ }
748
+ }
749
+ fs.writeFileSync(file, `# ${APP_NAME} conversation ${app.store.id}\n\n${parts.join('\n\n')}\n`);
750
+ out(`Exported to ${file}`);
751
+ },
752
+ });
753
+
754
+ add({
755
+ name: 'doctor',
756
+ description: 'Check the installation and test the API connection',
757
+ async run() {
758
+ const cfg = app.runtime.config;
759
+ const rg = hasRipgrep(cfg);
760
+ const lines = [
761
+ `${c.green('✔')} Node ${process.versions.node}`,
762
+ `${app.runtime.shell.kind === 'bash' ? c.green('✔') : c.yellow('!')} Shell: ${app.runtime.shell.name} (${app.runtime.shell.path})`,
763
+ `${rg ? c.green('✔') : c.yellow('!')} ripgrep ${rg ? 'found' : 'not found (Grep uses the built-in JS scanner, which is slower; set CLAUDE_PRO_RIPGREP_PATH to use an rg.exe)'}`,
764
+ cfg.usesClaudeCode
765
+ ? `${c.yellow('!')} Existing Claude Code OAuth: ${cfg.claudeCodeCredentialsFile} (checked by the request below)`
766
+ : cfg.usesGateway ? `${readGatewayLogin(cfg.gatewayUrl) ? c.green('✔') : c.red('✘')} Gateway login for ${cfg.gatewayUrl}${readGatewayLogin(cfg.gatewayUrl) ? '' : ' missing (/login)'}`
767
+ : cfg.usesCodex ? (() => { const s = codexLoginSummary({ ...process.env, ...cfg.env }); return `${s.ok ? c.green('✔') : c.red('✘')} Codex login ${s.ok ? s.file : s.error}`; })()
768
+ : `${cfg.authToken || cfg.apiKey ? c.green('✔') : c.red('✘')} Credentials ${cfg.authToken ? 'ANTHROPIC_AUTH_TOKEN' : cfg.apiKey ? 'ANTHROPIC_API_KEY' : 'missing'}`,
769
+ ];
770
+ out(lines);
771
+ app.busy = true;
772
+ app.status = 'Testing API';
773
+ app.turnStart = Date.now();
774
+ app.controller = new AbortController();
775
+ const started = Date.now();
776
+ try {
777
+ const reply = await quickQuery(cfg, { model: cfg.mainModel, system: 'Reply with exactly: pong', prompt: 'ping', maxTokens: 200, signal: app.controller.signal, waitForRateLimit: false });
778
+ out(`${c.green('✔')} ${cfg.endpointLabel} · model ${cfg.mainModel.id} replied "${truncate(reply, 40)}" in ${Date.now() - started}ms`);
779
+ } catch (e) {
780
+ out(`${c.red('✘')} API request failed: ${e.message}`);
781
+ } finally {
782
+ app.busy = false;
783
+ app.status = null;
784
+ app.controller = null;
785
+ app.sendWhatWaited();
786
+ }
787
+ },
788
+ });
789
+
790
+ add({
791
+ name: 'exit',
792
+ aliases: ['quit'],
793
+ description: `Exit ${APP_NAME}`,
794
+ run() { app.exit(0); },
795
+ });
796
+
797
+ // Custom commands from markdown files.
798
+ for (const custom of loadCustomCommands(app.cwd).values()) {
799
+ if (commands.has(custom.name)) continue;
800
+ add({
801
+ name: custom.name,
802
+ argHint: custom.argumentHint,
803
+ description: `${custom.description} ${c.dim(`(${custom.scope})`)}`,
804
+ async run(args) {
805
+ const bash = app.runtime.tools.find((t) => t.name === 'Bash');
806
+ const body = await expandShellEmbeds(expandCommandBody(custom.body, args), async (command) => {
807
+ const r = await bash.call({ command }, app.agent.makeCtx());
808
+ return typeof r.content === 'string' ? r.content : '';
809
+ });
810
+ return app.startTurn(body, { echo: false });
811
+ },
812
+ });
813
+ }
814
+
815
+ // Skills can be run directly as /<skill-name> [arguments].
816
+ for (const skill of app.runtime.skills.values()) {
817
+ if (commands.has(skill.name)) continue;
818
+ add({
819
+ name: skill.name,
820
+ argHint: '[arguments]',
821
+ description: `${truncate(skill.description, 70)} ${c.dim('(skill)')}`,
822
+ run(args) { return app.startTurn(renderSkill(skill, args), { echo: false }); },
823
+ });
824
+ }
825
+
826
+ add({
827
+ name: 'skills',
828
+ description: 'List available skills',
829
+ run() {
830
+ const list = [...app.runtime.skills.values()];
831
+ out(list.length
832
+ ? list.flatMap((s) => [`${c.bold(s.name)} ${c.dim(`(${s.scope})`)}`, c.dim(` ${truncate(s.description, app.width - 12)}`)])
833
+ : [c.dim(`No skills. Add <name>/SKILL.md (with name and description in the frontmatter) under .claude/skills/ or ${path.join(CONFIG_DIR, 'skills')}.`)]);
834
+ },
835
+ });
836
+
837
+ app.sessionStart = Date.now();
838
+ return commands;
839
+ }