mini-coder 0.5.13 → 0.6.0

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 (67) hide show
  1. package/README.md +25 -108
  2. package/bin/mc.ts +8 -11
  3. package/bun.lock +79 -269
  4. package/package.json +17 -22
  5. package/src/agent.ts +242 -915
  6. package/src/args.ts +289 -0
  7. package/src/headless.ts +43 -385
  8. package/src/index.ts +29 -836
  9. package/src/oauth.ts +117 -0
  10. package/src/prompt.ts +227 -276
  11. package/src/session.ts +57 -961
  12. package/src/shared.ts +117 -38
  13. package/src/tool-bash.ts +110 -0
  14. package/src/tool-edit.ts +133 -0
  15. package/src/tool-task.ts +114 -0
  16. package/src/tui-components.ts +150 -0
  17. package/src/tui-conversation.ts +262 -0
  18. package/src/tui-editor.ts +29 -0
  19. package/src/tui-overlay.ts +403 -0
  20. package/src/tui.ts +236 -0
  21. package/src/types.ts +160 -0
  22. package/tsconfig.json +17 -0
  23. package/BENCHMARK.md +0 -107
  24. package/LICENSE +0 -9
  25. package/PROGRESS.md +0 -4
  26. package/assets/icon-1-minimal.svg +0 -31
  27. package/assets/icon-2-dark-terminal.svg +0 -48
  28. package/assets/icon-3-gradient-modern.svg +0 -45
  29. package/assets/icon-4-filled-bold.svg +0 -54
  30. package/assets/icon-5-community-badge.svg +0 -63
  31. package/assets/mc-claude-smart.png +0 -0
  32. package/assets/mc-gpt-smart.png +0 -0
  33. package/assets/preview-0-5-0.png +0 -0
  34. package/assets/preview.gif +0 -0
  35. package/benchmark-baseline.sh +0 -15
  36. package/benchmark-loop.sh +0 -19
  37. package/skills-lock.json +0 -15
  38. package/src/cli.ts +0 -134
  39. package/src/errors.ts +0 -15
  40. package/src/git.ts +0 -247
  41. package/src/input.ts +0 -168
  42. package/src/mcp.ts +0 -609
  43. package/src/paths.ts +0 -37
  44. package/src/session-message.ts +0 -393
  45. package/src/settings.ts +0 -449
  46. package/src/skills.ts +0 -271
  47. package/src/submit.ts +0 -371
  48. package/src/text.ts +0 -71
  49. package/src/theme.ts +0 -330
  50. package/src/tool-common.ts +0 -93
  51. package/src/tool-grep.ts +0 -606
  52. package/src/tool-read.ts +0 -313
  53. package/src/tool-shell.ts +0 -1001
  54. package/src/tools.ts +0 -854
  55. package/src/ui/agent.ts +0 -317
  56. package/src/ui/commands.test.ts +0 -913
  57. package/src/ui/commands.ts +0 -834
  58. package/src/ui/conversation.test.ts +0 -585
  59. package/src/ui/conversation.ts +0 -1836
  60. package/src/ui/help.ts +0 -158
  61. package/src/ui/input.test.ts +0 -64
  62. package/src/ui/input.ts +0 -138
  63. package/src/ui/overlay.ts +0 -59
  64. package/src/ui/runtime.ts +0 -69
  65. package/src/ui/status.ts +0 -220
  66. package/src/ui.ts +0 -1190
  67. package/src/version.ts +0 -48
package/src/index.ts CHANGED
@@ -1,847 +1,40 @@
1
- /**
2
- * Entry point for mini-coder.
3
- *
4
- * Discovers available LLM providers, loads configured MCP tools,
5
- * loads prompt context (AGENTS.md, skills, and theme), opens the session
6
- * database, selects a model, and starts the TUI.
7
- *
8
- * @module
9
- */
1
+ import { basename } from "node:path";
2
+ import simpleGit from "simple-git";
3
+ import { handleArgv } from "./args.ts";
4
+ import { streamHeadless } from "./headless.ts";
5
+ import { initTUI } from "./tui.ts";
6
+ import type { TUIState } from "./types.ts";
10
7
 
11
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
- import { homedir } from "node:os";
13
- import { basename, dirname, join } from "node:path";
14
- import { isDeepStrictEqual } from "node:util";
15
- import type {
16
- KnownProvider,
17
- Model,
18
- OAuthCredentials,
19
- ThinkingLevel,
20
- Tool,
21
- UserMessage,
22
- } from "@mariozechner/pi-ai";
23
- import { getEnvApiKey, getModels, getProviders } from "@mariozechner/pi-ai";
24
- import { getOAuthApiKey, getOAuthProviders } from "@mariozechner/pi-ai/oauth";
25
- import type { ToolHandler } from "./agent.ts";
26
- import {
27
- type CliOptions,
28
- parseCliArgs,
29
- resolveHeadlessPrompt,
30
- shouldUseHeadlessMode,
31
- type TtyState,
32
- } from "./cli.ts";
33
- import { getErrorMessage } from "./errors.ts";
34
- import { type GitState, getGitState } from "./git.ts";
35
- import { discoverMcpServers, type McpServerState } from "./mcp.ts";
36
- import { canonicalizePath } from "./paths.ts";
37
- import {
38
- type AgentsMdFile,
39
- buildSystemPrompt,
40
- discoverAgentsMd,
41
- resolveAgentsScanRoot,
42
- } from "./prompt.ts";
43
- import {
44
- appendMessage,
45
- createConversationSnapshot,
46
- createSession,
47
- type loadMessages,
48
- openDatabase,
49
- type Session,
50
- type SessionStats,
51
- truncateSessions,
52
- } from "./session.ts";
53
- import {
54
- type CustomProvider,
55
- loadStartupSettings,
56
- mergeUserSettings,
57
- resolveStartupSettings,
58
- type UserSettings,
59
- } from "./settings.ts";
60
- import { discoverSkills, type Skill } from "./skills.ts";
61
- import { DEFAULT_THEME, type Theme } from "./theme.ts";
62
- import {
63
- createTodoReadToolHandler,
64
- createTodoWriteToolHandler,
65
- editTool,
66
- editToolHandler,
67
- grepTool,
68
- grepToolHandler,
69
- readImageTool,
70
- readImageToolHandler,
71
- readTool,
72
- readToolHandler,
73
- shellTool,
74
- shellToolHandler,
75
- todoReadTool,
76
- todoWriteTool,
77
- } from "./tools.ts";
78
- import { resolveAppVersionLabel } from "./version.ts";
79
-
80
- // ---------------------------------------------------------------------------
81
- // Constants
82
- // ---------------------------------------------------------------------------
83
-
84
- /** App data directory. */
85
- const DATA_DIR = join(homedir(), ".config", "mini-coder");
86
-
87
- /** SQLite database path. */
88
- const DB_PATH = join(DATA_DIR, "mini-coder.db");
89
-
90
- /** OAuth credentials file path. */
91
- const AUTH_PATH = join(DATA_DIR, "auth.json");
92
-
93
- /** User settings file path. */
94
- const SETTINGS_PATH = join(DATA_DIR, "settings.json");
95
-
96
- export { DEFAULT_SHOW_REASONING, DEFAULT_VERBOSE } from "./settings.ts";
97
-
98
- /** Maximum sessions to keep per CWD. */
99
- export const MAX_SESSIONS_PER_CWD = 20;
100
-
101
- /** Maximum raw prompt-history entries to retain globally. */
102
- export const MAX_PROMPT_HISTORY = 1_000;
103
-
104
- // ---------------------------------------------------------------------------
105
- // OAuth credential persistence
106
- // ---------------------------------------------------------------------------
107
-
108
- /** Load saved OAuth credentials from disk. */
109
- function loadOAuthCredentials(
110
- path = AUTH_PATH,
111
- ): Record<string, OAuthCredentials> {
112
- if (!existsSync(path)) return {};
113
-
114
- let parsed: unknown;
115
- try {
116
- parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown;
117
- } catch (error) {
118
- throw new Error(
119
- `Failed to read OAuth credentials ${path}: ${getErrorMessage(error)}`,
120
- );
121
- }
122
-
123
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
124
- throw new Error(
125
- `Failed to read OAuth credentials ${path}: expected a JSON object`,
126
- );
127
- }
128
-
129
- return parsed as Record<string, OAuthCredentials>;
130
- }
131
-
132
- /** Save OAuth credentials to disk. */
133
- function saveOAuthCredentials(
134
- creds: Record<string, OAuthCredentials>,
135
- path = AUTH_PATH,
136
- ): void {
137
- mkdirSync(dirname(path), { recursive: true });
138
- writeFileSync(path, JSON.stringify(creds, null, 2), "utf-8");
139
- }
140
-
141
- /** Return whether refreshed OAuth credentials differ from the persisted value. */
142
- export function didOAuthCredentialsChange(
143
- current: OAuthCredentials | undefined,
144
- next: OAuthCredentials,
145
- ): boolean {
146
- return !isDeepStrictEqual(current, next);
147
- }
148
-
149
- // ---------------------------------------------------------------------------
150
- // Provider discovery
151
- // ---------------------------------------------------------------------------
152
-
153
- /** Result of provider discovery: available providers + OAuth state. */
154
- interface DiscoveryResult {
155
- /** Provider → API key map for all ready-to-use providers. */
156
- providers: Map<string, string>;
157
- /** OAuth credentials (possibly refreshed during discovery). */
158
- oauthCredentials: Record<string, OAuthCredentials>;
159
- }
160
-
161
- /**
162
- * Discover which providers have usable credentials.
163
- *
164
- * Checks env-based API keys first, then saved OAuth tokens. Refreshes
165
- * expired OAuth tokens and persists updated credentials.
166
- */
167
- async function discoverProviders(): Promise<DiscoveryResult> {
168
- const providers = new Map<string, string>();
169
-
170
- // 1. Check env-based API keys
171
- for (const provider of getProviders()) {
172
- const key = getEnvApiKey(provider);
173
- if (key) {
174
- providers.set(provider, key);
175
- }
176
- }
177
-
178
- // 2. Check OAuth credentials
179
- const oauthCredentials = loadOAuthCredentials();
180
- let credsModified = false;
181
-
182
- for (const oauthProvider of getOAuthProviders()) {
183
- // Skip if already available via env key
184
- if (providers.has(oauthProvider.id)) continue;
185
-
186
- try {
187
- const result = await getOAuthApiKey(oauthProvider.id, oauthCredentials);
188
- if (result) {
189
- providers.set(oauthProvider.id, result.apiKey);
190
- // Update credentials if they were refreshed
191
- if (
192
- didOAuthCredentialsChange(
193
- oauthCredentials[oauthProvider.id],
194
- result.newCredentials,
195
- )
196
- ) {
197
- oauthCredentials[oauthProvider.id] = result.newCredentials;
198
- credsModified = true;
199
- }
200
- }
201
- } catch {
202
- // Token refresh failed — skip this provider
203
- }
204
- }
205
-
206
- if (credsModified) {
207
- saveOAuthCredentials(oauthCredentials);
208
- }
209
-
210
- return { providers, oauthCredentials };
211
- }
212
-
213
- // ---------------------------------------------------------------------------
214
- // Custom provider discovery
215
- // ---------------------------------------------------------------------------
216
-
217
- /** Timeout for custom provider model discovery requests. */
218
- const CUSTOM_PROVIDER_TIMEOUT_MS = 3_000;
219
-
220
- /** Default API key for custom providers that don't require authentication. */
221
- const CUSTOM_PROVIDER_DEFAULT_KEY = "no-key";
222
-
223
- /** Result of custom provider discovery. */
224
- interface CustomDiscoveryResult {
225
- /** Discovered models from all reachable custom providers. */
226
- models: Model<"openai-completions">[];
227
- /** Provider name → API key for discovered providers. */
228
- providers: Map<string, string>;
229
- /** Warning messages for unreachable or invalid providers. */
230
- warnings: string[];
231
- }
232
-
233
- /**
234
- * Discover models from user-configured OpenAI-compatible endpoints.
235
- *
236
- * Queries each provider's `/models` endpoint and constructs pi-ai Model
237
- * objects from the response. Unreachable endpoints produce a warning
238
- * instead of failing startup.
239
- *
240
- * @param customProviders - Configured custom provider entries.
241
- * @param builtInProviderNames - Names of built-in providers (to detect collisions).
242
- * @returns Discovered models, provider credentials, and warnings.
243
- */
244
- export async function discoverCustomProviders(
245
- customProviders: readonly CustomProvider[],
246
- builtInProviderNames: ReadonlySet<string>,
247
- ): Promise<CustomDiscoveryResult> {
248
- const models: Model<"openai-completions">[] = [];
249
- const providers = new Map<string, string>();
250
- const warnings: string[] = [];
251
-
252
- for (const entry of customProviders) {
253
- if (builtInProviderNames.has(entry.name)) {
254
- warnings.push(
255
- `Custom provider "${entry.name}" skipped: name conflicts with a built-in provider.`,
256
- );
257
- continue;
258
- }
259
-
260
- const apiKey = entry.apiKey ?? CUSTOM_PROVIDER_DEFAULT_KEY;
261
- const modelsUrl = `${entry.baseUrl}/models`;
262
-
263
- try {
264
- const response = await fetch(modelsUrl, {
265
- signal: AbortSignal.timeout(CUSTOM_PROVIDER_TIMEOUT_MS),
266
- });
267
-
268
- if (!response.ok) {
269
- warnings.push(
270
- `Custom provider "${entry.name}": ${response.status} ${response.statusText} (${modelsUrl})`,
271
- );
272
- continue;
273
- }
274
-
275
- const body = (await response.json()) as {
276
- data?: { id: string }[];
277
- };
278
- const modelList = body.data ?? [];
279
-
280
- for (const item of modelList) {
281
- if (typeof item.id !== "string" || !item.id) continue;
282
-
283
- models.push({
284
- id: item.id,
285
- name: item.id,
286
- api: "openai-completions",
287
- provider: entry.name,
288
- baseUrl: entry.baseUrl,
289
- reasoning: false,
290
- input: ["text"],
291
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
292
- contextWindow: 131072,
293
- maxTokens: 8192,
294
- });
295
- }
296
-
297
- providers.set(entry.name, apiKey);
298
- } catch (error) {
299
- warnings.push(
300
- `Custom provider "${entry.name}": ${getErrorMessage(error)} (${modelsUrl})`,
301
- );
302
- }
303
- }
304
-
305
- return { models, providers, warnings };
306
- }
307
-
308
- // ---------------------------------------------------------------------------
309
- // Model selection
310
- // ---------------------------------------------------------------------------
311
-
312
- /**
313
- * List all models from authenticated providers.
314
- *
315
- * @param availableProviders - Providers with usable credentials.
316
- * @returns Flat list of available models.
317
- */
318
- function listAvailableModels(
319
- availableProviders: Map<string, string>,
320
- ): Model<string>[] {
321
- const result: Model<string>[] = [];
322
- for (const provider of availableProviders.keys()) {
323
- const models = getModels(provider as KnownProvider);
324
- for (const model of models) {
325
- result.push(model);
326
- }
327
- }
328
- return result;
329
- }
330
-
331
- /**
332
- * Select a model by id from the available model list.
333
- *
334
- * @param models - Available models.
335
- * @param modelId - Preferred provider/model identifier.
336
- * @returns Matching model, or `null` when none is selected.
337
- */
338
- function selectModel(
339
- models: readonly Model<string>[],
340
- modelId: string | null,
341
- ): Model<string> | null {
342
- if (modelId == null) {
343
- return null;
344
- }
345
- return (
346
- models.find((model) => `${model.provider}/${model.id}` === modelId) ?? null
347
- );
348
- }
349
-
350
- // ---------------------------------------------------------------------------
351
- // Tool wiring
352
- // ---------------------------------------------------------------------------
353
-
354
- /**
355
- * Build tool definitions and handler map for the current model.
356
- *
357
- * Returns the `Tool[]` to send to the model and the handler map
358
- * for the agent loop to dispatch tool calls.
359
- */
360
- function buildTools(
361
- model: Model<string>,
362
- messages: AppState["messages"],
363
- mcpServers: readonly McpServerState[],
364
- ): { tools: Tool[]; toolHandlers: Map<string, ToolHandler> } {
365
- const tools: Tool[] = [
366
- shellTool,
367
- readTool,
368
- grepTool,
369
- editTool,
370
- todoWriteTool,
371
- todoReadTool,
372
- ];
373
- const toolHandlers = new Map<string, ToolHandler>([
374
- [shellTool.name, shellToolHandler],
375
- [readTool.name, readToolHandler],
376
- [grepTool.name, grepToolHandler],
377
- [editTool.name, editToolHandler],
378
- [todoWriteTool.name, createTodoWriteToolHandler(messages)],
379
- [todoReadTool.name, createTodoReadToolHandler(messages)],
380
- ]);
381
-
382
- for (const server of mcpServers) {
383
- if (!server.enabled || !server.connected) {
384
- continue;
385
- }
8
+ export async function main(): Promise<void> {
9
+ const cwd = basename(process.cwd());
10
+ const options = await handleArgv(process.argv.slice(2));
386
11
 
387
- tools.push(...server.tools);
388
- for (const [name, handler] of server.toolHandlers) {
389
- toolHandlers.set(name, handler);
390
- }
12
+ function leave(msg?: string) {
13
+ if (msg) console.log(msg);
14
+ process.exit(0);
391
15
  }
392
16
 
393
- // Conditionally register readImage for vision-capable models
394
- if (model.input.includes("image")) {
395
- tools.push(readImageTool);
396
- toolHandlers.set(readImageTool.name, readImageToolHandler);
17
+ if (options.prompt) {
18
+ await streamHeadless(options, leave);
19
+ process.exit(0);
397
20
  }
398
21
 
399
- return { tools, toolHandlers };
400
- }
401
-
402
- // ---------------------------------------------------------------------------
403
- // Skill scan paths
404
- // ---------------------------------------------------------------------------
405
-
406
- /** Build the list of skill scan paths per the spec. */
407
- function getSkillScanPaths(cwd: string, gitRoot: string | null): string[] {
408
- const home = homedir();
409
- const project = gitRoot ?? cwd;
410
- return [
411
- join(project, ".mini-coder", "skills"),
412
- join(project, ".agents", "skills"),
413
- join(home, ".mini-coder", "skills"),
414
- join(home, ".agents", "skills"),
415
- ];
416
- }
417
-
418
- /** Load AGENTS.md files, skills, git state, and the active theme. */
419
- export async function loadPromptContext(opts?: { cwd?: string }): Promise<{
420
- cwd: string;
421
- canonicalCwd: string;
422
- git: GitState | null;
423
- agentsMd: AgentsMdFile[];
424
- skills: Skill[];
425
- theme: Theme;
426
- }> {
427
- const cwd = opts?.cwd ?? process.cwd();
428
- const canonicalCwd = canonicalizePath(cwd);
429
- const git = await getGitState(cwd);
430
- const gitRoot = git?.root ?? null;
431
- const home = homedir();
432
- const scanRoot = resolveAgentsScanRoot(
433
- cwd,
434
- gitRoot,
435
- home,
436
- process.env.MC_AGENTS_ROOT,
437
- );
438
- const agentsMd = discoverAgentsMd(cwd, scanRoot, join(home, ".agents"));
439
- const skills = discoverSkills(getSkillScanPaths(canonicalCwd, gitRoot));
440
-
441
- return {
22
+ const state: TUIState = {
23
+ options,
24
+ prompt: "",
25
+ messages: [],
26
+ streaming: false,
27
+ stickToBottom: true,
28
+ scrollOffset: 0,
442
29
  cwd,
443
- canonicalCwd,
444
- git,
445
- agentsMd,
446
- skills,
447
- theme: DEFAULT_THEME,
448
- };
449
- }
450
-
451
- /**
452
- * Load global and repo-local settings for the current launch.
453
- *
454
- * The repo-local overlay is read only and is loaded only when a git root is
455
- * known. Invalid startup content in either file is treated as empty settings.
456
- *
457
- * @param opts - Optional settings path and git-root override for tests.
458
- * @returns Global settings, repo-local overlay settings, and the merged result.
459
- */
460
- export function loadUserSettingsForLaunch(opts?: {
461
- settingsPath?: string;
462
- gitRoot?: string | null;
463
- }): {
464
- settings: UserSettings;
465
- repoSettings: UserSettings;
466
- effectiveSettings: UserSettings;
467
- } {
468
- const settingsPath = opts?.settingsPath ?? SETTINGS_PATH;
469
- const gitRoot = opts?.gitRoot ?? null;
470
- const settings = loadStartupSettings(settingsPath);
471
- const repoSettings = gitRoot
472
- ? loadStartupSettings(join(gitRoot, ".mini-coder", "settings.json"))
473
- : {};
474
-
475
- return {
476
- settings,
477
- repoSettings,
478
- effectiveSettings: mergeUserSettings(settings, repoSettings),
479
30
  };
480
- }
481
-
482
- /** Refresh the current prompt/session context at a reload boundary like `/new`. */
483
- export async function reloadPromptContext(
484
- state: AppState,
485
- runtime?: {
486
- loadPromptContext?: typeof loadPromptContext;
487
- },
488
- ): Promise<void> {
489
- const loadContext = runtime?.loadPromptContext ?? loadPromptContext;
490
- const context = await loadContext();
491
-
492
- state.cwd = context.cwd;
493
- state.canonicalCwd = context.canonicalCwd;
494
- state.git = context.git;
495
- state.agentsMd = context.agentsMd;
496
- state.skills = context.skills;
497
- state.theme = context.theme;
498
- }
499
-
500
- // ---------------------------------------------------------------------------
501
- // App state
502
- // ---------------------------------------------------------------------------
503
-
504
- /** All mutable application state in one place. */
505
- export interface AppState {
506
- /** Open database handle. */
507
- db: ReturnType<typeof openDatabase>;
508
- /** Current session, created lazily on the first user message. */
509
- session: Session | null;
510
- /** Current model, or `null` if no providers are available yet. */
511
- model: Model<string> | null;
512
- /** Current effort level. */
513
- effort: ThinkingLevel;
514
- /** Message history for the active session. */
515
- messages: ReturnType<typeof loadMessages>;
516
- /** Cumulative session input/output/cost stats for the status bar. */
517
- stats: SessionStats;
518
- /** Estimated model-visible context tokens for the next request. */
519
- contextTokens: number;
520
- /** Discovered AGENTS.md files. */
521
- agentsMd: AgentsMdFile[];
522
- /** Discovered skills. */
523
- skills: Skill[];
524
- /** Active theme. */
525
- theme: Theme;
526
- /** Version label shown in the empty conversation banner. */
527
- versionLabel: string;
528
- /** Current git state (null if not in a repo). */
529
- git: GitState | null;
530
- /** Available provider credentials (provider → API key). */
531
- providers: Map<string, string>;
532
- /** OAuth credentials on disk. */
533
- oauthCredentials: Record<string, OAuthCredentials>;
534
- /** Loaded global user settings. */
535
- settings: UserSettings;
536
- /** Loaded repo-local settings overlay for the current app run. */
537
- repoSettings: UserSettings;
538
- /** Absolute path to the global settings file. */
539
- settingsPath: string;
540
- /** Working directory as entered by the user/shell (for display and tool execution). */
541
- cwd: string;
542
- /** Canonical working directory (for path identity and session scoping). */
543
- canonicalCwd: string;
544
- /** Whether the agent loop is currently running. */
545
- running: boolean;
546
- /** Abort controller for the current agent run. */
547
- abortController: AbortController | null;
548
- /** Promise for the active conversational turn, used to serialize cleanup like `/undo`. */
549
- activeTurnPromise: Promise<void> | null;
550
- /** Resolved user messages queued while the current run is still active. */
551
- queuedUserMessages: UserMessage[];
552
- /** Whether to show thinking content. */
553
- showReasoning: boolean;
554
- /** Whether to show full (un-truncated) tool output. */
555
- verbose: boolean;
556
- /** Configured MCP servers, including their current enabled/disabled state. */
557
- mcpServers: McpServerState[];
558
- /** Models discovered from custom OpenAI-compatible providers. */
559
- customModels: Model<string>[];
560
- /** Warnings from startup (e.g. unreachable custom providers or MCP servers). */
561
- startupWarnings: string[];
562
- }
563
-
564
- // ---------------------------------------------------------------------------
565
- // Main
566
- // ---------------------------------------------------------------------------
567
31
 
568
- /** Initialize and return the full app state. */
569
- export async function init(): Promise<AppState> {
570
- const cwd = process.cwd();
571
-
572
- // Ensure data directory exists
573
- mkdirSync(DATA_DIR, { recursive: true });
574
-
575
- // Discover providers (env + OAuth)
576
- const { providers, oauthCredentials } = await discoverProviders();
577
-
578
- const promptContext = await loadPromptContext({ cwd });
579
- const { settings, repoSettings, effectiveSettings } =
580
- loadUserSettingsForLaunch({
581
- settingsPath: SETTINGS_PATH,
582
- gitRoot: promptContext.git?.root ?? null,
583
- });
584
-
585
- // Discover custom providers from effective settings
586
- const builtInProviderNames = new Set(providers.keys());
587
- const customResult = await discoverCustomProviders(
588
- effectiveSettings.customProviders ?? [],
589
- builtInProviderNames,
590
- );
591
-
592
- // Merge custom provider credentials
593
- for (const [name, key] of customResult.providers) {
594
- providers.set(name, key);
595
- }
596
-
597
- const mcpResult = await discoverMcpServers(effectiveSettings.mcp);
598
-
599
- const builtInModels = listAvailableModels(providers);
600
- const availableModels = [...builtInModels, ...customResult.models];
601
- const startup = resolveStartupSettings(
602
- effectiveSettings,
603
- availableModels.map((model) => `${model.provider}/${model.id}`),
604
- );
605
- const model = selectModel(availableModels, startup.modelId);
606
-
607
- // Open database. Sessions are created lazily on the first user message.
608
- const db = openDatabase(DB_PATH);
609
- const effort = startup.effort;
610
- const conversation = createConversationSnapshot();
611
-
612
- return {
613
- db,
614
- session: null,
615
- model,
616
- effort,
617
- messages: conversation.messages,
618
- stats: conversation.stats,
619
- contextTokens: conversation.contextTokens,
620
- agentsMd: promptContext.agentsMd,
621
- skills: promptContext.skills,
622
- theme: promptContext.theme,
623
- versionLabel: resolveAppVersionLabel(),
624
- git: promptContext.git,
625
- providers,
626
- oauthCredentials,
627
- settings,
628
- repoSettings,
629
- settingsPath: SETTINGS_PATH,
630
- cwd: promptContext.cwd,
631
- canonicalCwd: promptContext.canonicalCwd,
632
- running: false,
633
- abortController: null,
634
- activeTurnPromise: null,
635
- queuedUserMessages: [],
636
- showReasoning: startup.showReasoning,
637
- verbose: startup.verbose,
638
- mcpServers: mcpResult.servers,
639
- customModels: customResult.models,
640
- startupWarnings: [...customResult.warnings, ...mcpResult.warnings],
641
- };
642
- }
643
-
644
- /** Resolve the shell label shown in the system prompt. */
645
- function resolvePromptShell(): string {
646
- return basename(process.env.SHELL || "/bin/sh");
647
- }
648
-
649
- /** Resolve the normalized OS label shown in the system prompt. */
650
- function resolvePromptOs(): "linux" | "mac" | "docker" {
651
- if (process.platform === "darwin") {
652
- return "mac";
653
- }
654
- if (existsSync("/.dockerenv") || existsSync("/run/.containerenv")) {
655
- return "docker";
656
- }
657
- return "linux";
658
- }
659
-
660
- /**
661
- * Build the system prompt for the current state.
662
- *
663
- * Separated from `init` because turns still rebuild the assembled prompt
664
- * from the session-stable prompt context plus the current runtime state.
665
- */
666
- export function buildPrompt(state: AppState): string {
667
- return buildSystemPrompt({
668
- cwd: state.cwd,
669
- modelLabel: state.model
670
- ? `${state.model.provider}/${state.model.id}`
671
- : "unknown",
672
- os: resolvePromptOs(),
673
- shell: resolvePromptShell(),
674
- supportsImages: state.model?.input.includes("image") ?? false,
675
- git: state.git,
676
- agentsMd: state.agentsMd,
677
- skills: state.skills,
678
- });
679
- }
680
-
681
- /** Build the tool list for the current model. */
682
- export function buildToolList(state: AppState): {
683
- tools: Tool[];
684
- toolHandlers: Map<string, ToolHandler>;
685
- } {
686
- if (!state.model) return { tools: [], toolHandlers: new Map() };
687
- return buildTools(state.model, state.messages, state.mcpServers);
688
- }
689
-
690
- /**
691
- * Ensure the app has an active persisted session.
692
- *
693
- * Creates the session lazily on the first submitted prompt and backfills any
694
- * already-present messages into the new session.
695
- *
696
- * @param state - Application state.
697
- * @returns The active persisted session.
698
- */
699
- export function ensureSession(
700
- state: AppState,
701
- ): NonNullable<AppState["session"]> {
702
- if (state.session) {
703
- return state.session;
704
- }
705
-
706
- const modelLabel = state.model
707
- ? `${state.model.provider}/${state.model.id}`
708
- : undefined;
709
- const session = createSession(state.db, {
710
- cwd: state.canonicalCwd,
711
- model: modelLabel,
712
- effort: state.effort,
713
- });
714
- truncateSessions(state.db, state.canonicalCwd, MAX_SESSIONS_PER_CWD);
715
- state.session = session;
716
-
717
- for (const message of state.messages) {
718
- appendMessage(state.db, session.id, message);
719
- }
720
-
721
- return session;
722
- }
723
-
724
- /**
725
- * Get all models from authenticated providers.
726
- *
727
- * Returns a flat list of models from providers the user has credentials
728
- * for, suitable for the `/model` selector.
729
- */
730
- export function getAvailableModels(state: AppState): Model<string>[] {
731
- return [...listAvailableModels(state.providers), ...state.customModels];
732
- }
733
-
734
- /** Clean up resources on shutdown. */
735
- export async function shutdown(state: AppState): Promise<void> {
736
- await Promise.allSettled(state.mcpServers.map((server) => server.close()));
737
- state.db.close();
738
- }
739
-
740
- // ---------------------------------------------------------------------------
741
- // OAuth helpers (re-exported for /login and /logout commands)
742
- // ---------------------------------------------------------------------------
743
-
744
- export {
745
- AUTH_PATH,
746
- DATA_DIR,
747
- loadOAuthCredentials,
748
- SETTINGS_PATH,
749
- saveOAuthCredentials,
750
- };
751
-
752
- // ---------------------------------------------------------------------------
753
- // Headless CLI
754
- // ---------------------------------------------------------------------------
755
-
756
- type HeadlessCliStopReason = "stop" | "length" | "error" | "aborted";
757
-
758
- /**
759
- * Run one headless CLI prompt using the output mode selected by the parsed CLI flags.
760
- *
761
- * Non-TTY detection only decides whether headless mode should run at all.
762
- * Once headless mode is selected, `--json` is the only switch that chooses
763
- * NDJSON streaming versus the default text mode (stdout final answer plus
764
- * stderr activity snippets).
765
- *
766
- * @param state - Initialized application state for the run.
767
- * @param cli - Parsed CLI options.
768
- * @param tty - Current TTY availability.
769
- * @param deps - Injected I/O and runner callbacks.
770
- * @returns The terminal stop reason for the headless run.
771
- */
772
- export async function runHeadlessCli(
773
- state: AppState,
774
- cli: CliOptions,
775
- tty: TtyState,
776
- deps: {
777
- readStdin: () => Promise<string>;
778
- runJson: (
779
- state: AppState,
780
- rawPrompt: string,
781
- ) => Promise<HeadlessCliStopReason>;
782
- runText: (
783
- state: AppState,
784
- rawPrompt: string,
785
- ) => Promise<HeadlessCliStopReason>;
786
- },
787
- ): Promise<HeadlessCliStopReason> {
788
- const rawPrompt = await resolveHeadlessPrompt(cli, tty, deps.readStdin);
789
- return cli.json
790
- ? deps.runJson(state, rawPrompt)
791
- : deps.runText(state, rawPrompt);
792
- }
793
-
794
- // ---------------------------------------------------------------------------
795
- // Main
796
- // ---------------------------------------------------------------------------
797
-
798
- /**
799
- * Start the mini-coder CLI.
800
- *
801
- * Initializes application state and launches either the interactive TUI or
802
- * the headless one-shot runner based on CLI flags and TTY availability.
803
- *
804
- * @returns A promise that resolves once startup is complete.
805
- */
806
- export async function main(): Promise<void> {
807
- const cli = parseCliArgs(process.argv.slice(2));
808
- const tty = {
809
- stdinIsTTY: process.stdin.isTTY ?? false,
810
- stdoutIsTTY: process.stdout.isTTY ?? false,
811
- };
812
- const state = await init();
813
-
814
- if (shouldUseHeadlessMode(cli, tty)) {
815
- try {
816
- const stopReason = await runHeadlessCli(state, cli, tty, {
817
- readStdin: async () => Bun.stdin.text(),
818
- runJson: async (headlessState, rawPrompt) => {
819
- const { runHeadlessPrompt } = await import("./headless.ts");
820
- return runHeadlessPrompt(headlessState, rawPrompt);
821
- },
822
- runText: async (headlessState, rawPrompt) => {
823
- const { runHeadlessPromptText } = await import("./headless.ts");
824
- return runHeadlessPromptText(headlessState, rawPrompt);
825
- },
826
- });
827
- if (stopReason === "aborted") {
828
- process.exitCode = 130;
829
- } else if (stopReason === "error") {
830
- process.exitCode = 1;
831
- }
832
- return;
833
- } finally {
834
- await shutdown(state);
835
- }
836
- }
837
-
838
- const { startUI } = await import("./ui.ts");
839
- startUI(state);
840
- }
32
+ const git = simpleGit();
33
+ try {
34
+ const gitStatus = (await git.status()).isClean() ? "" : "*";
35
+ const gitBranch = (await git.branch()).current;
36
+ state.gitBranch = `${gitBranch}${gitStatus}`;
37
+ } catch (_) {} // No git
841
38
 
842
- if (import.meta.main) {
843
- main().catch((err) => {
844
- console.error(err instanceof Error ? err.message : String(err));
845
- process.exit(1);
846
- });
39
+ initTUI(state, leave);
847
40
  }