mini-coder 0.4.1 → 0.5.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 (51) hide show
  1. package/README.md +89 -48
  2. package/assets/icon-1-minimal.svg +31 -0
  3. package/assets/icon-2-dark-terminal.svg +48 -0
  4. package/assets/icon-3-gradient-modern.svg +45 -0
  5. package/assets/icon-4-filled-bold.svg +54 -0
  6. package/assets/icon-5-community-badge.svg +63 -0
  7. package/assets/preview-0-5-0.png +0 -0
  8. package/assets/preview.gif +0 -0
  9. package/bin/mc.ts +14 -0
  10. package/bun.lock +438 -0
  11. package/package.json +12 -29
  12. package/src/agent.ts +640 -0
  13. package/src/cli.ts +124 -0
  14. package/src/git.ts +171 -0
  15. package/src/headless.ts +140 -0
  16. package/src/index.ts +666 -0
  17. package/src/input.ts +155 -0
  18. package/src/paths.ts +37 -0
  19. package/src/plugins.ts +183 -0
  20. package/src/prompt.ts +301 -0
  21. package/src/session.ts +1043 -0
  22. package/src/settings.ts +191 -0
  23. package/src/skills.ts +262 -0
  24. package/src/submit.ts +323 -0
  25. package/src/theme.ts +147 -0
  26. package/src/tools.ts +636 -0
  27. package/src/ui/agent.test.ts +49 -0
  28. package/src/ui/agent.ts +210 -0
  29. package/src/ui/commands.test.ts +610 -0
  30. package/src/ui/commands.ts +638 -0
  31. package/src/ui/conversation.test.ts +892 -0
  32. package/src/ui/conversation.ts +926 -0
  33. package/src/ui/help.test.ts +44 -0
  34. package/src/ui/help.ts +125 -0
  35. package/src/ui/input.test.ts +74 -0
  36. package/src/ui/input.ts +138 -0
  37. package/src/ui/overlay.test.ts +42 -0
  38. package/src/ui/overlay.ts +59 -0
  39. package/src/ui/status.test.ts +451 -0
  40. package/src/ui/status.ts +357 -0
  41. package/src/ui.ts +694 -0
  42. package/.claude/settings.local.json +0 -54
  43. package/.prettierignore +0 -7
  44. package/dist/mc-edit.js +0 -275
  45. package/dist/mc.js +0 -7355
  46. package/docs/KNOWN_ISSUES.md +0 -13
  47. package/docs/design-decisions.md +0 -31
  48. package/docs/mini-coder.1.md +0 -227
  49. package/docs/superpowers/plans/2026-03-30-anthropic-oauth-removal.md +0 -61
  50. package/docs/superpowers/specs/2026-03-30-anthropic-oauth-removal-design.md +0 -47
  51. package/lefthook.yml +0 -4
package/src/index.ts ADDED
@@ -0,0 +1,666 @@
1
+ /**
2
+ * Entry point for mini-coder.
3
+ *
4
+ * Discovers available LLM providers, loads context (AGENTS.md, skills,
5
+ * plugins), opens the session database, selects a model, and starts
6
+ * the TUI.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { dirname, join } from "node:path";
14
+ import { isDeepStrictEqual } from "node:util";
15
+ import type {
16
+ KnownProvider,
17
+ Message,
18
+ Model,
19
+ OAuthCredentials,
20
+ ThinkingLevel,
21
+ Tool,
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
+ parseCliArgs,
28
+ resolveHeadlessPrompt,
29
+ shouldUseHeadlessMode,
30
+ } from "./cli.ts";
31
+ import { type GitState, getGitState } from "./git.ts";
32
+ import { canonicalizePath } from "./paths.ts";
33
+ import {
34
+ type AgentContext,
35
+ destroyPlugins,
36
+ initPlugins,
37
+ type LoadedPlugin,
38
+ loadPluginConfig,
39
+ type PluginEntry,
40
+ } from "./plugins.ts";
41
+ import {
42
+ type AgentsMdFile,
43
+ buildSystemPrompt,
44
+ discoverAgentsMd,
45
+ resolveAgentsScanRoot,
46
+ } from "./prompt.ts";
47
+ import {
48
+ appendMessage,
49
+ computeStats,
50
+ createSession,
51
+ filterModelMessages,
52
+ type loadMessages,
53
+ openDatabase,
54
+ type Session,
55
+ type SessionStats,
56
+ truncateSessions,
57
+ } from "./session.ts";
58
+ import {
59
+ loadSettings,
60
+ resolveStartupSettings,
61
+ type UserSettings,
62
+ } from "./settings.ts";
63
+ import { discoverSkills, type Skill } from "./skills.ts";
64
+ import { DEFAULT_THEME, mergeThemes, type Theme } from "./theme.ts";
65
+ import {
66
+ editTool,
67
+ executeEdit,
68
+ executeReadImage,
69
+ executeShell,
70
+ readImageTool,
71
+ shellTool,
72
+ } from "./tools.ts";
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Constants
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /** App data directory. */
79
+ const DATA_DIR = join(homedir(), ".config", "mini-coder");
80
+
81
+ /** SQLite database path. */
82
+ const DB_PATH = join(DATA_DIR, "mini-coder.db");
83
+
84
+ /** Plugin config file path. */
85
+ const PLUGIN_CONFIG_PATH = join(DATA_DIR, "plugins.json");
86
+
87
+ /** OAuth credentials file path. */
88
+ const AUTH_PATH = join(DATA_DIR, "auth.json");
89
+
90
+ /** User settings file path. */
91
+ const SETTINGS_PATH = join(DATA_DIR, "settings.json");
92
+
93
+ export { DEFAULT_SHOW_REASONING, DEFAULT_VERBOSE } from "./settings.ts";
94
+
95
+ /** Maximum sessions to keep per CWD. */
96
+ export const MAX_SESSIONS_PER_CWD = 20;
97
+
98
+ /** Maximum raw prompt-history entries to retain globally. */
99
+ export const MAX_PROMPT_HISTORY = 1_000;
100
+
101
+ function getErrorMessage(error: unknown): string {
102
+ return error instanceof Error ? error.message : String(error);
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // OAuth credential persistence
107
+ // ---------------------------------------------------------------------------
108
+
109
+ /** Load saved OAuth credentials from disk. */
110
+ function loadOAuthCredentials(
111
+ path = AUTH_PATH,
112
+ ): Record<string, OAuthCredentials> {
113
+ if (!existsSync(path)) return {};
114
+
115
+ let parsed: unknown;
116
+ try {
117
+ parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown;
118
+ } catch (error) {
119
+ throw new Error(
120
+ `Failed to read OAuth credentials ${path}: ${getErrorMessage(error)}`,
121
+ );
122
+ }
123
+
124
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
125
+ throw new Error(
126
+ `Failed to read OAuth credentials ${path}: expected a JSON object`,
127
+ );
128
+ }
129
+
130
+ return parsed as Record<string, OAuthCredentials>;
131
+ }
132
+
133
+ /** Save OAuth credentials to disk. */
134
+ function saveOAuthCredentials(
135
+ creds: Record<string, OAuthCredentials>,
136
+ path = AUTH_PATH,
137
+ ): void {
138
+ mkdirSync(dirname(path), { recursive: true });
139
+ writeFileSync(path, JSON.stringify(creds, null, 2), "utf-8");
140
+ }
141
+
142
+ /** Return whether refreshed OAuth credentials differ from the persisted value. */
143
+ export function didOAuthCredentialsChange(
144
+ current: OAuthCredentials | undefined,
145
+ next: OAuthCredentials,
146
+ ): boolean {
147
+ return !isDeepStrictEqual(current, next);
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Provider discovery
152
+ // ---------------------------------------------------------------------------
153
+
154
+ /** Result of provider discovery: available providers + OAuth state. */
155
+ interface DiscoveryResult {
156
+ /** Provider → API key map for all ready-to-use providers. */
157
+ providers: Map<string, string>;
158
+ /** OAuth credentials (possibly refreshed during discovery). */
159
+ oauthCredentials: Record<string, OAuthCredentials>;
160
+ }
161
+
162
+ /**
163
+ * Discover which providers have usable credentials.
164
+ *
165
+ * Checks env-based API keys first, then saved OAuth tokens. Refreshes
166
+ * expired OAuth tokens and persists updated credentials.
167
+ */
168
+ async function discoverProviders(): Promise<DiscoveryResult> {
169
+ const providers = new Map<string, string>();
170
+
171
+ // 1. Check env-based API keys
172
+ for (const provider of getProviders()) {
173
+ const key = getEnvApiKey(provider);
174
+ if (key) {
175
+ providers.set(provider, key);
176
+ }
177
+ }
178
+
179
+ // 2. Check OAuth credentials
180
+ const oauthCredentials = loadOAuthCredentials();
181
+ let credsModified = false;
182
+
183
+ for (const oauthProvider of getOAuthProviders()) {
184
+ // Skip if already available via env key
185
+ if (providers.has(oauthProvider.id)) continue;
186
+
187
+ try {
188
+ const result = await getOAuthApiKey(oauthProvider.id, oauthCredentials);
189
+ if (result) {
190
+ providers.set(oauthProvider.id, result.apiKey);
191
+ // Update credentials if they were refreshed
192
+ if (
193
+ didOAuthCredentialsChange(
194
+ oauthCredentials[oauthProvider.id],
195
+ result.newCredentials,
196
+ )
197
+ ) {
198
+ oauthCredentials[oauthProvider.id] = result.newCredentials;
199
+ credsModified = true;
200
+ }
201
+ }
202
+ } catch {
203
+ // Token refresh failed — skip this provider
204
+ }
205
+ }
206
+
207
+ if (credsModified) {
208
+ saveOAuthCredentials(oauthCredentials);
209
+ }
210
+
211
+ return { providers, oauthCredentials };
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Model selection
216
+ // ---------------------------------------------------------------------------
217
+
218
+ /**
219
+ * List all models from authenticated providers.
220
+ *
221
+ * @param availableProviders - Providers with usable credentials.
222
+ * @returns Flat list of available models.
223
+ */
224
+ function listAvailableModels(
225
+ availableProviders: Map<string, string>,
226
+ ): Model<string>[] {
227
+ const result: Model<string>[] = [];
228
+ for (const provider of availableProviders.keys()) {
229
+ const models = getModels(provider as KnownProvider);
230
+ for (const model of models) {
231
+ result.push(model);
232
+ }
233
+ }
234
+ return result;
235
+ }
236
+
237
+ /**
238
+ * Select a model by id from the available model list.
239
+ *
240
+ * @param models - Available models.
241
+ * @param modelId - Preferred provider/model identifier.
242
+ * @returns Matching model, or `null` when none is selected.
243
+ */
244
+ function selectModel(
245
+ models: readonly Model<string>[],
246
+ modelId: string | null,
247
+ ): Model<string> | null {
248
+ if (modelId == null) {
249
+ return null;
250
+ }
251
+ return (
252
+ models.find((model) => `${model.provider}/${model.id}` === modelId) ?? null
253
+ );
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Tool wiring
258
+ // ---------------------------------------------------------------------------
259
+
260
+ /** Built-in tool handlers keyed by tool name. */
261
+ const BUILTIN_HANDLERS: Record<string, ToolHandler> = {
262
+ edit: (args, cwd) =>
263
+ executeEdit(
264
+ {
265
+ path: args.path as string,
266
+ oldText: args.oldText as string,
267
+ newText: args.newText as string,
268
+ },
269
+ cwd,
270
+ ),
271
+ shell: (args, cwd, signal, onUpdate) =>
272
+ executeShell({ command: args.command as string }, cwd, {
273
+ ...(signal ? { signal } : {}),
274
+ ...(onUpdate ? { onUpdate } : {}),
275
+ }),
276
+ readImage: (args, cwd) =>
277
+ executeReadImage({ path: args.path as string }, cwd),
278
+ };
279
+
280
+ /**
281
+ * Build tool definitions and handler map for the current model.
282
+ *
283
+ * Returns the `Tool[]` to send to the model and the handler map
284
+ * for the agent loop to dispatch tool calls.
285
+ */
286
+ function buildTools(
287
+ model: Model<string>,
288
+ plugins: LoadedPlugin[],
289
+ ): { tools: Tool[]; toolHandlers: Map<string, ToolHandler> } {
290
+ const tools: Tool[] = [editTool, shellTool];
291
+ const toolHandlers = new Map<string, ToolHandler>([
292
+ [editTool.name, BUILTIN_HANDLERS.edit!],
293
+ [shellTool.name, BUILTIN_HANDLERS.shell!],
294
+ ]);
295
+
296
+ // Conditionally register readImage for vision-capable models
297
+ if (model.input.includes("image")) {
298
+ tools.push(readImageTool);
299
+ toolHandlers.set(readImageTool.name, BUILTIN_HANDLERS.readImage!);
300
+ }
301
+
302
+ // Add plugin tools
303
+ for (const plugin of plugins) {
304
+ if (plugin.result.tools) {
305
+ for (const tool of plugin.result.tools) {
306
+ tools.push(tool);
307
+ }
308
+ }
309
+ if (plugin.result.toolHandlers) {
310
+ for (const [name, handler] of plugin.result.toolHandlers) {
311
+ toolHandlers.set(name, handler);
312
+ }
313
+ }
314
+ }
315
+
316
+ return { tools, toolHandlers };
317
+ }
318
+
319
+ // ---------------------------------------------------------------------------
320
+ // Skill scan paths
321
+ // ---------------------------------------------------------------------------
322
+
323
+ /** Build the list of skill scan paths per the spec. */
324
+ function getSkillScanPaths(cwd: string, gitRoot: string | null): string[] {
325
+ const home = homedir();
326
+ const project = gitRoot ?? cwd;
327
+ return [
328
+ join(project, ".mini-coder", "skills"),
329
+ join(project, ".agents", "skills"),
330
+ join(home, ".mini-coder", "skills"),
331
+ join(home, ".agents", "skills"),
332
+ ];
333
+ }
334
+
335
+ /** Load AGENTS.md files, skills, plugins, git state, and the merged theme. */
336
+ export async function loadPromptContext(
337
+ messages: readonly Message[],
338
+ opts?: {
339
+ cwd?: string;
340
+ pluginEntries?: PluginEntry[];
341
+ pluginConfigPath?: string;
342
+ },
343
+ ): Promise<{
344
+ cwd: string;
345
+ canonicalCwd: string;
346
+ git: GitState | null;
347
+ agentsMd: AgentsMdFile[];
348
+ skills: Skill[];
349
+ plugins: LoadedPlugin[];
350
+ theme: Theme;
351
+ }> {
352
+ const cwd = opts?.cwd ?? process.cwd();
353
+ const canonicalCwd = canonicalizePath(cwd);
354
+ const git = await getGitState(cwd);
355
+ const gitRoot = git?.root ?? null;
356
+ const home = homedir();
357
+ const scanRoot = resolveAgentsScanRoot(
358
+ cwd,
359
+ gitRoot,
360
+ home,
361
+ process.env.MC_AGENTS_ROOT,
362
+ );
363
+ const agentsMd = discoverAgentsMd(cwd, scanRoot, join(home, ".agents"));
364
+ const skills = discoverSkills(getSkillScanPaths(canonicalCwd, gitRoot));
365
+ const pluginEntries =
366
+ opts?.pluginEntries ??
367
+ loadPluginConfig(opts?.pluginConfigPath ?? PLUGIN_CONFIG_PATH);
368
+ const context: AgentContext = {
369
+ cwd,
370
+ messages,
371
+ dataDir: DATA_DIR,
372
+ };
373
+ const plugins = await initPlugins(pluginEntries, context, (entry, err) => {
374
+ console.error(`Plugin "${entry.name}" failed to init: ${err.message}`);
375
+ });
376
+ const themeOverrides = plugins
377
+ .map((plugin) => plugin.result.theme)
378
+ .filter((theme): theme is Partial<Theme> => theme != null);
379
+
380
+ return {
381
+ cwd,
382
+ canonicalCwd,
383
+ git,
384
+ agentsMd,
385
+ skills,
386
+ plugins,
387
+ theme: mergeThemes(DEFAULT_THEME, ...themeOverrides),
388
+ };
389
+ }
390
+
391
+ /** Refresh the current prompt/session context at a reload boundary like `/new`. */
392
+ export async function reloadPromptContext(
393
+ state: AppState,
394
+ runtime?: {
395
+ loadPromptContext?: typeof loadPromptContext;
396
+ destroyPlugins?: typeof destroyPlugins;
397
+ },
398
+ ): Promise<void> {
399
+ const loadContext = runtime?.loadPromptContext ?? loadPromptContext;
400
+ const destroyLoadedPlugins = runtime?.destroyPlugins ?? destroyPlugins;
401
+ const previousPlugins = state.plugins;
402
+ const context = await loadContext(filterModelMessages(state.messages));
403
+
404
+ state.cwd = context.cwd;
405
+ state.canonicalCwd = context.canonicalCwd;
406
+ state.git = context.git;
407
+ state.agentsMd = context.agentsMd;
408
+ state.skills = context.skills;
409
+ state.plugins = context.plugins;
410
+ state.theme = context.theme;
411
+
412
+ await destroyLoadedPlugins(previousPlugins, (entry, err) => {
413
+ console.error(`Plugin "${entry.name}" failed to destroy: ${err.message}`);
414
+ });
415
+ }
416
+
417
+ // ---------------------------------------------------------------------------
418
+ // App state
419
+ // ---------------------------------------------------------------------------
420
+
421
+ /** All mutable application state in one place. */
422
+ export interface AppState {
423
+ /** Open database handle. */
424
+ db: ReturnType<typeof openDatabase>;
425
+ /** Current session, created lazily on the first user message. */
426
+ session: Session | null;
427
+ /** Current model, or `null` if no providers are available yet. */
428
+ model: Model<string> | null;
429
+ /** Current effort level. */
430
+ effort: ThinkingLevel;
431
+ /** Message history for the active session. */
432
+ messages: ReturnType<typeof loadMessages>;
433
+ /** Cumulative session input/output/cost stats for the status bar. */
434
+ stats: SessionStats;
435
+ /** Discovered AGENTS.md files. */
436
+ agentsMd: AgentsMdFile[];
437
+ /** Discovered skills. */
438
+ skills: Skill[];
439
+ /** Loaded plugins. */
440
+ plugins: LoadedPlugin[];
441
+ /** Active theme (default + plugin overrides). */
442
+ theme: Theme;
443
+ /** Current git state (null if not in a repo). */
444
+ git: GitState | null;
445
+ /** Available provider credentials (provider → API key). */
446
+ providers: Map<string, string>;
447
+ /** OAuth credentials on disk. */
448
+ oauthCredentials: Record<string, OAuthCredentials>;
449
+ /** Loaded global user settings. */
450
+ settings: UserSettings;
451
+ /** Absolute path to the global settings file. */
452
+ settingsPath: string;
453
+ /** Working directory as entered by the user/shell (for display and tool execution). */
454
+ cwd: string;
455
+ /** Canonical working directory (for path identity and session scoping). */
456
+ canonicalCwd: string;
457
+ /** Whether the agent loop is currently running. */
458
+ running: boolean;
459
+ /** Abort controller for the current agent run. */
460
+ abortController: AbortController | null;
461
+ /** Promise for the active conversational turn, used to serialize cleanup like `/undo`. */
462
+ activeTurnPromise: Promise<void> | null;
463
+ /** Whether to show thinking content. */
464
+ showReasoning: boolean;
465
+ /** Whether to show full (un-truncated) tool output. */
466
+ verbose: boolean;
467
+ }
468
+
469
+ // ---------------------------------------------------------------------------
470
+ // Main
471
+ // ---------------------------------------------------------------------------
472
+
473
+ /** Initialize and return the full app state. */
474
+ export async function init(): Promise<AppState> {
475
+ const cwd = process.cwd();
476
+
477
+ // Ensure data directory exists
478
+ mkdirSync(DATA_DIR, { recursive: true });
479
+
480
+ // Discover providers (env + OAuth)
481
+ const { providers, oauthCredentials } = await discoverProviders();
482
+
483
+ // Load user settings and resolve startup defaults
484
+ const settings = loadSettings(SETTINGS_PATH);
485
+ const availableModels = listAvailableModels(providers);
486
+ const startup = resolveStartupSettings(
487
+ settings,
488
+ availableModels.map((model) => `${model.provider}/${model.id}`),
489
+ );
490
+ const model = selectModel(availableModels, startup.modelId);
491
+
492
+ // Open database. Sessions are created lazily on the first user message.
493
+ const db = openDatabase(DB_PATH);
494
+ const effort = startup.effort;
495
+ const messages: ReturnType<typeof loadMessages> = [];
496
+ const stats = computeStats(messages);
497
+ const promptContext = await loadPromptContext(filterModelMessages(messages), {
498
+ cwd,
499
+ });
500
+
501
+ return {
502
+ db,
503
+ session: null,
504
+ model,
505
+ effort,
506
+ messages,
507
+ stats,
508
+ agentsMd: promptContext.agentsMd,
509
+ skills: promptContext.skills,
510
+ plugins: promptContext.plugins,
511
+ theme: promptContext.theme,
512
+ git: promptContext.git,
513
+ providers,
514
+ oauthCredentials,
515
+ settings,
516
+ settingsPath: SETTINGS_PATH,
517
+ cwd: promptContext.cwd,
518
+ canonicalCwd: promptContext.canonicalCwd,
519
+ running: false,
520
+ abortController: null,
521
+ activeTurnPromise: null,
522
+ showReasoning: startup.showReasoning,
523
+ verbose: startup.verbose,
524
+ };
525
+ }
526
+
527
+ /**
528
+ * Build the system prompt for the current state.
529
+ *
530
+ * Separated from `init` because it's called on every turn (git state
531
+ * may change between turns).
532
+ */
533
+ export function buildPrompt(state: AppState): string {
534
+ return buildSystemPrompt({
535
+ cwd: state.cwd,
536
+ date: new Date().toISOString().slice(0, 10),
537
+ git: state.git,
538
+ agentsMd: state.agentsMd,
539
+ skills: state.skills,
540
+ pluginSuffixes: state.plugins
541
+ .map((p) => p.result.systemPromptSuffix)
542
+ .filter((s): s is string => s != null),
543
+ });
544
+ }
545
+
546
+ /** Build the tool list for the current model. */
547
+ export function buildToolList(state: AppState): {
548
+ tools: Tool[];
549
+ toolHandlers: Map<string, ToolHandler>;
550
+ } {
551
+ if (!state.model) return { tools: [], toolHandlers: new Map() };
552
+ return buildTools(state.model, state.plugins);
553
+ }
554
+
555
+ /**
556
+ * Ensure the app has an active persisted session.
557
+ *
558
+ * Creates the session lazily on the first submitted prompt and backfills any
559
+ * already-present messages into the new session.
560
+ *
561
+ * @param state - Application state.
562
+ * @returns The active persisted session.
563
+ */
564
+ export function ensureSession(
565
+ state: AppState,
566
+ ): NonNullable<AppState["session"]> {
567
+ if (state.session) {
568
+ return state.session;
569
+ }
570
+
571
+ const modelLabel = state.model
572
+ ? `${state.model.provider}/${state.model.id}`
573
+ : undefined;
574
+ const session = createSession(state.db, {
575
+ cwd: state.canonicalCwd,
576
+ model: modelLabel,
577
+ effort: state.effort,
578
+ });
579
+ truncateSessions(state.db, state.canonicalCwd, MAX_SESSIONS_PER_CWD);
580
+ state.session = session;
581
+
582
+ for (const message of state.messages) {
583
+ appendMessage(state.db, session.id, message);
584
+ }
585
+
586
+ return session;
587
+ }
588
+
589
+ /**
590
+ * Get all models from authenticated providers.
591
+ *
592
+ * Returns a flat list of models from providers the user has credentials
593
+ * for, suitable for the `/model` selector.
594
+ */
595
+ export function getAvailableModels(state: AppState): Model<string>[] {
596
+ return listAvailableModels(state.providers);
597
+ }
598
+
599
+ /** Clean up resources on shutdown. */
600
+ export async function shutdown(state: AppState): Promise<void> {
601
+ await destroyPlugins(state.plugins, (entry, err) => {
602
+ console.error(`Plugin "${entry.name}" failed to destroy: ${err.message}`);
603
+ });
604
+ state.db.close();
605
+ }
606
+
607
+ // ---------------------------------------------------------------------------
608
+ // OAuth helpers (re-exported for /login and /logout commands)
609
+ // ---------------------------------------------------------------------------
610
+
611
+ export {
612
+ AUTH_PATH,
613
+ DATA_DIR,
614
+ loadOAuthCredentials,
615
+ SETTINGS_PATH,
616
+ saveOAuthCredentials,
617
+ };
618
+
619
+ // ---------------------------------------------------------------------------
620
+ // Main
621
+ // ---------------------------------------------------------------------------
622
+
623
+ /**
624
+ * Start the mini-coder CLI.
625
+ *
626
+ * Initializes application state and launches either the interactive TUI or
627
+ * the headless one-shot runner based on CLI flags and TTY availability.
628
+ *
629
+ * @returns A promise that resolves once startup is complete.
630
+ */
631
+ export async function main(): Promise<void> {
632
+ const cli = parseCliArgs(process.argv.slice(2));
633
+ const tty = {
634
+ stdinIsTTY: process.stdin.isTTY ?? false,
635
+ stdoutIsTTY: process.stdout.isTTY ?? false,
636
+ };
637
+ const state = await init();
638
+
639
+ if (shouldUseHeadlessMode(cli, tty)) {
640
+ try {
641
+ const rawPrompt = await resolveHeadlessPrompt(cli, tty, async () => {
642
+ return Bun.stdin.text();
643
+ });
644
+ const { runHeadlessPrompt } = await import("./headless.ts");
645
+ const stopReason = await runHeadlessPrompt(state, rawPrompt);
646
+ if (stopReason === "aborted") {
647
+ process.exitCode = 130;
648
+ } else if (stopReason === "error") {
649
+ process.exitCode = 1;
650
+ }
651
+ return;
652
+ } finally {
653
+ await shutdown(state);
654
+ }
655
+ }
656
+
657
+ const { startUI } = await import("./ui.ts");
658
+ startUI(state);
659
+ }
660
+
661
+ if (import.meta.main) {
662
+ main().catch((err) => {
663
+ console.error(err instanceof Error ? err.message : String(err));
664
+ process.exit(1);
665
+ });
666
+ }