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