@jc20231028/local-code-agent 0.1.0 → 0.1.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.
package/src/cli.js CHANGED
@@ -1,932 +1,932 @@
1
- import path from "node:path";
2
- import readline from "node:readline/promises";
3
- import { stdin as input, stdout as output } from "node:process";
4
- import {
5
- loadAppState,
6
- loadConfig,
7
- saveAppState,
8
- saveConfigSelections
9
- } from "./config.js";
10
- import { createAgentSession, listProviderModels, runAgent } from "./agent.js";
11
- import {
12
- buildProviderDiagnostics,
13
- buildProviderProblemMessage,
14
- getProviderUnavailableReason,
15
- inspectProviders,
16
- isProviderReady,
17
- pickAutoProvider,
18
- summarizeProvider
19
- } from "./runtime.js";
20
- import {
21
- color,
22
- isInteractive,
23
- printNote,
24
- printSplash,
25
- renderStartupDashboard,
26
- renderDiagnostics,
27
- selectMenu,
28
- withSpinner
29
- } from "./ui.js";
30
- import { Workspace } from "./workspace.js";
31
- import { loadSkills, matchSkillInvocation } from "./skills.js";
32
- import {
33
- createCheckpoint,
34
- extractRecentPrompts,
35
- findActiveCheckpoint,
36
- formatCheckpoint,
37
- formatCheckpointSummaryLine,
38
- loadCheckpoints,
39
- saveCheckpoints
40
- } from "./checkpoint.js";
41
-
42
- export async function main(argv) {
43
- const parsed = parseArgs(argv);
44
- const cwd = process.cwd();
45
- let config = await loadConfig(cwd, parsed.options);
46
- const skills = await loadSkills(config.workspace);
47
- const startupContext = await loadStartupContext(config, parsed.command);
48
-
49
- if (["run", "chat", "models"].includes(parsed.command)) {
50
- printSplash({
51
- workspace: config.workspace,
52
- command: parsed.command,
53
- lastUsedProvider: startupContext.lastUsedProvider,
54
- lastUsedModel: startupContext.lastUsedModel,
55
- lastTaskSummary: startupContext.lastTaskSummary,
56
- recentFiles: startupContext.recentFiles
57
- });
58
- config = await prepareRuntimeConfig(config, {
59
- requireModelSelection: parsed.command !== "models",
60
- cliOptions: parsed.options,
61
- command: parsed.command,
62
- startupContext
63
- });
64
- }
65
-
66
- switch (parsed.command) {
67
- case "run":
68
- await runOnce(config, parsed.prompt, skills);
69
- return;
70
- case "chat":
71
- await runChat(config, skills);
72
- return;
73
- case "models":
74
- await printModels(config);
75
- return;
76
- case "init":
77
- await printInitExample(cwd);
78
- return;
79
- case "skills":
80
- printSkills(skills);
81
- return;
82
- case "checkpoint": {
83
- const [subcommand, ...rest] = parsed.positionals;
84
- await runCheckpointCommand(config, subcommand, rest);
85
- return;
86
- }
87
- case "help":
88
- default:
89
- printHelp();
90
- }
91
- }
92
-
93
- async function runOnce(config, prompt, skills) {
94
- if (!prompt) {
95
- throw new Error("Missing prompt. Usage: local-code run \"your task\"");
96
- }
97
-
98
- const invocation = matchSkillInvocation(prompt, skills);
99
- if (invocation.type === "unknown") {
100
- console.error(buildUnknownSkillMessage(invocation.token, skills));
101
- process.exitCode = 1;
102
- return;
103
- }
104
-
105
- const runPrompt = invocation.type === "skill" ? invocation.rest : prompt;
106
- const skillOptions = invocation.type === "skill" ? { skill: invocation.skill } : {};
107
-
108
- console.error(`provider=${config.provider} model=${config.model} workspace=${config.workspace}`);
109
- const result = await runAgent(config, runPrompt, buildProgressHooks(), skillOptions);
110
- await rememberTask(config, prompt);
111
- printResult(result);
112
- }
113
-
114
- function buildProgressHooks() {
115
- return {
116
- onStep(step) {
117
- console.error(`step ${step}: waiting for model...`);
118
- },
119
- onToolCall(toolCall) {
120
- console.error(`step result: called tool "${toolCall.tool}"`);
121
- },
122
- onToolCallError({ step, reason, message, attempt }) {
123
- const label = reason === "truncated" ? "reply cut off (length limit)" : "invalid tool call JSON";
124
- console.error(`step ${step} result: ${label}, asking model to retry (attempt ${attempt})`);
125
- if (message) {
126
- console.error(` ${message}`);
127
- }
128
- }
129
- };
130
- }
131
-
132
- function printResult(result) {
133
- if (result.failed) {
134
- console.log(`⚠️ ${result.content}`);
135
- return;
136
- }
137
-
138
- console.log(result.content);
139
- }
140
-
141
- function printSkills(skills) {
142
- const uniqueSkills = [...new Set(skills.values())];
143
- if (uniqueSkills.length === 0) {
144
- console.log("No skills found. Add .md files under .local-code/skills/ (project) or ~/.local-code/skills/ (user).");
145
- return;
146
- }
147
-
148
- for (const skill of uniqueSkills) {
149
- const keywords = skill.keywords.length > 0 ? skill.keywords.join(", ") : "-";
150
- const tools = skill.tools ? skill.tools.join(", ") : "all";
151
- console.log(`/${skill.name} [${skill.scope}]`);
152
- console.log(` ${skill.description}`);
153
- console.log(` keywords: ${keywords}`);
154
- console.log(` tools: ${tools}`);
155
- console.log(` source: ${skill.sourcePath}`);
156
- console.log("");
157
- }
158
- }
159
-
160
- function buildUnknownSkillMessage(token, skills) {
161
- const names = [...new Set(skills.values())].map((skill) => skill.name);
162
- const available = names.length > 0 ? names.join(", ") : "(none)";
163
- return `Unknown skill /${token}. Available skills: ${available}`;
164
- }
165
-
166
- async function runChat(config, skills) {
167
- const rl = readline.createInterface({ input, output });
168
- let activeConfig = { ...config };
169
- let chatState = await loadChatState(activeConfig);
170
- let session = createChatSession(activeConfig, chatState.history);
171
-
172
- console.log(`local-code chat (${activeConfig.provider}:${activeConfig.model || "no-model"})`);
173
- console.log("Type /exit to quit. Use /provider, /model, /status, /checkpoint, or /reset during chat.");
174
- if (chatState.history.length > 0) {
175
- console.log(`restored saved chat history (${countUserTurns(chatState.history)} turn(s))`);
176
- console.log("If the model keeps repeating an outdated claim (e.g. from before a tool was fixed), try /reset to start fresh.");
177
- }
178
-
179
- const activeCheckpoint = findActiveCheckpoint(await loadCheckpoints(activeConfig));
180
- if (activeCheckpoint) {
181
- console.log("\nUnfinished checkpoint detected:");
182
- console.log(formatCheckpoint(activeCheckpoint));
183
- console.log("Continue from the pending steps above, or run /checkpoint complete once done.\n");
184
- }
185
-
186
- try {
187
- while (true) {
188
- const prompt = (await rl.question("> ")).trim();
189
- if (!prompt) {
190
- continue;
191
- }
192
- if (prompt === "/exit") {
193
- break;
194
- }
195
-
196
- if (prompt === "/status") {
197
- console.log(renderChatStatus(activeConfig, chatState));
198
- continue;
199
- }
200
-
201
- if (prompt === "/skills") {
202
- printSkills(skills);
203
- continue;
204
- }
205
-
206
- if (prompt === "/checkpoint" || prompt.startsWith("/checkpoint ")) {
207
- const [, subcommand, ...rest] = prompt.split(/\s+/);
208
- await runChatCheckpointCommand(rl, activeConfig, session, subcommand, rest);
209
- continue;
210
- }
211
-
212
- if (prompt === "/reset") {
213
- chatState = createEmptyChatState(activeConfig);
214
- await saveChatState(activeConfig, chatState);
215
- session = createChatSession(activeConfig, chatState.history);
216
- console.log("chat history cleared. Starting a fresh conversation (same provider/model/workspace).");
217
- continue;
218
- }
219
-
220
- if (prompt === "/provider") {
221
- activeConfig = await prepareRuntimeConfig(
222
- {
223
- ...activeConfig,
224
- provider: "",
225
- model: ""
226
- },
227
- {
228
- requireModelSelection: true,
229
- cliOptions: {},
230
- command: "chat",
231
- startupContext: await loadStartupContext(activeConfig, "chat", {
232
- lastUsedProvider: activeConfig.provider,
233
- lastUsedModel: activeConfig.model
234
- })
235
- }
236
- );
237
- chatState = createEmptyChatState(activeConfig);
238
- await saveChatState(activeConfig, chatState);
239
- session = createChatSession(activeConfig, chatState.history);
240
- console.log(`switched to ${activeConfig.provider}:${activeConfig.model}`);
241
- continue;
242
- }
243
-
244
- if (prompt === "/model") {
245
- activeConfig = await prepareRuntimeConfig(
246
- {
247
- ...activeConfig,
248
- model: ""
249
- },
250
- {
251
- requireModelSelection: true,
252
- cliOptions: {},
253
- command: "chat",
254
- startupContext: await loadStartupContext(activeConfig, "chat", {
255
- lastUsedProvider: activeConfig.provider,
256
- lastUsedModel: activeConfig.model
257
- })
258
- }
259
- );
260
- chatState = createEmptyChatState(activeConfig);
261
- await saveChatState(activeConfig, chatState);
262
- session = createChatSession(activeConfig, chatState.history);
263
- console.log(`switched to ${activeConfig.provider}:${activeConfig.model}`);
264
- continue;
265
- }
266
-
267
- const invocation = matchSkillInvocation(prompt, skills);
268
- if (invocation.type === "unknown") {
269
- console.log(buildUnknownSkillMessage(invocation.token, skills));
270
- continue;
271
- }
272
-
273
- const chatPrompt = invocation.type === "skill" ? invocation.rest : prompt;
274
- const chatSkillOptions = invocation.type === "skill" ? { skill: invocation.skill } : {};
275
-
276
- let result;
277
- try {
278
- result = await session.ask(chatPrompt, chatSkillOptions);
279
- } catch (error) {
280
- console.error(error instanceof Error ? error.message : String(error));
281
- continue;
282
- }
283
-
284
- await rememberTask(activeConfig, prompt);
285
- chatState = {
286
- ...chatState,
287
- provider: activeConfig.provider,
288
- model: activeConfig.model,
289
- workspace: activeConfig.workspace,
290
- history: session.getHistory(),
291
- updatedAt: new Date().toISOString()
292
- };
293
- await saveChatState(activeConfig, chatState);
294
- printResult(result);
295
- }
296
- } finally {
297
- rl.close();
298
- }
299
- }
300
-
301
- async function runChatCheckpointCommand(rl, config, session, subcommand, rest) {
302
- switch (subcommand) {
303
- case undefined:
304
- case "save": {
305
- const fields = await collectCheckpointFields(rl);
306
- const recentPrompts = extractRecentPrompts(session.getHistory());
307
- const checkpoint = createCheckpoint({ ...fields, recentPrompts });
308
- const checkpoints = [...(await loadCheckpoints(config)), checkpoint];
309
- await saveCheckpoints(config, checkpoints);
310
- console.log(`\ncheckpoint saved: ${checkpoint.id}`);
311
- return;
312
- }
313
- case "list":
314
- await cmdCheckpointList(config);
315
- return;
316
- case "show":
317
- await cmdCheckpointShow(config, rest[0]);
318
- return;
319
- case "complete":
320
- await cmdCheckpointComplete(config, rest[0]);
321
- return;
322
- default:
323
- console.log(`Unknown checkpoint subcommand: ${subcommand}`);
324
- console.log("Usage: /checkpoint [save|list|show|complete] [id]");
325
- }
326
- }
327
-
328
- async function runCheckpointCommand(config, subcommand, rest) {
329
- switch (subcommand) {
330
- case undefined:
331
- case "save":
332
- await cmdCheckpointSave(config);
333
- return;
334
- case "list":
335
- await cmdCheckpointList(config);
336
- return;
337
- case "show":
338
- await cmdCheckpointShow(config, rest[0]);
339
- return;
340
- case "complete":
341
- await cmdCheckpointComplete(config, rest[0]);
342
- return;
343
- default:
344
- console.log(`Unknown checkpoint subcommand: ${subcommand}`);
345
- console.log("Usage: local-code checkpoint <save|list|show|complete> [id]");
346
- }
347
- }
348
-
349
- async function cmdCheckpointSave(config) {
350
- const rl = readline.createInterface({ input, output });
351
- try {
352
- const fields = await collectCheckpointFields(rl);
353
- const state = await loadAppState(config.statePath);
354
- const recentPrompts = extractRecentPrompts(state.chatSession?.history);
355
-
356
- const checkpoint = createCheckpoint({ ...fields, recentPrompts });
357
- const checkpoints = [...(await loadCheckpoints(config)), checkpoint];
358
- await saveCheckpoints(config, checkpoints);
359
-
360
- console.log(`\ncheckpoint saved: ${checkpoint.id}`);
361
- } finally {
362
- rl.close();
363
- }
364
- }
365
-
366
- async function cmdCheckpointList(config) {
367
- const checkpoints = await loadCheckpoints(config);
368
- if (checkpoints.length === 0) {
369
- console.log("No checkpoints saved yet.");
370
- return;
371
- }
372
-
373
- console.log("");
374
- for (const checkpoint of [...checkpoints].reverse()) {
375
- console.log(formatCheckpointSummaryLine(checkpoint));
376
- }
377
- console.log("");
378
- }
379
-
380
- async function cmdCheckpointShow(config, id) {
381
- const checkpoints = await loadCheckpoints(config);
382
- const checkpoint = id ? checkpoints.find((entry) => entry.id === id) : findActiveCheckpoint(checkpoints);
383
- if (!checkpoint) {
384
- console.log(id ? `Checkpoint not found: ${id}` : "No active checkpoint.");
385
- return;
386
- }
387
-
388
- console.log("");
389
- console.log(formatCheckpoint(checkpoint));
390
- console.log("");
391
- }
392
-
393
- async function cmdCheckpointComplete(config, id) {
394
- const checkpoints = await loadCheckpoints(config);
395
- const target = id ? checkpoints.find((entry) => entry.id === id) : findActiveCheckpoint(checkpoints);
396
- if (!target) {
397
- console.log(id ? `Checkpoint not found: ${id}` : "No active checkpoint.");
398
- return;
399
- }
400
-
401
- target.completed = true;
402
- target.completedAt = new Date().toISOString();
403
- await saveCheckpoints(config, checkpoints);
404
- console.log(`checkpoint marked complete: ${target.id}`);
405
- }
406
-
407
- async function collectCheckpointFields(rl) {
408
- console.log("\n=== Save checkpoint ===\n");
409
- const goal = await askLine(rl, "Goal (what is this task trying to do)");
410
- const status = await askLine(rl, "Current status");
411
- const completedSteps = await askMultiline(rl, "Completed steps");
412
- const pendingSteps = await askMultiline(rl, "Pending steps (continue here next time)");
413
- const contextNotes = await askMultiline(rl, "Context / decisions (optional)");
414
- const blockers = await askMultiline(rl, "Blockers (optional)");
415
- const keyFilesRaw = await askLine(rl, "Key files (comma separated, optional)");
416
- const keyFiles = keyFilesRaw ? keyFilesRaw.split(",").map((file) => file.trim()).filter(Boolean) : [];
417
-
418
- return { goal, status, completedSteps, pendingSteps, contextNotes, blockers, keyFiles };
419
- }
420
-
421
- async function askLine(rl, label, defaultValue = "") {
422
- const suffix = defaultValue ? ` [${defaultValue}]` : "";
423
- const answer = (await rl.question(`${label}${suffix}: `)).trim();
424
- return answer || defaultValue;
425
- }
426
-
427
- async function askMultiline(rl, label) {
428
- console.log(`${label} (one per line, blank line to finish):`);
429
- const items = [];
430
- while (true) {
431
- const line = (await rl.question(" > ")).trim();
432
- if (!line) {
433
- break;
434
- }
435
- items.push(line);
436
- }
437
- return items;
438
- }
439
-
440
- async function printModels(config) {
441
- const models = await listProviderModels(config);
442
- for (const model of models) {
443
- console.log(model);
444
- }
445
- }
446
-
447
- async function printInitExample(cwd) {
448
- const example = {
449
- provider: "",
450
- model: "",
451
- workspace: ".",
452
- ollamaBaseUrl: "http://127.0.0.1:11434",
453
- lmStudioBaseUrl: "http://127.0.0.1:1234",
454
- maxSteps: 12,
455
- allowCommands: false,
456
- temperature: 0.2
457
- };
458
-
459
- console.log(`Create ${path.join(cwd, ".local-code.json")} with:`);
460
- console.log(JSON.stringify(example, null, 2));
461
- console.log("");
462
- console.log("If provider or model is empty, the CLI will ask the user to choose at startup.");
463
- console.log("Wizard selections are saved back to .local-code.json unless you override them with CLI flags.");
464
- }
465
-
466
- function parseArgs(argv) {
467
- const command = argv[0] || "help";
468
- const options = {};
469
- const positionals = [];
470
-
471
- for (let index = 1; index < argv.length; index += 1) {
472
- const current = argv[index];
473
- if (!current.startsWith("--")) {
474
- positionals.push(current);
475
- continue;
476
- }
477
-
478
- const name = current.slice(2);
479
- const next = argv[index + 1];
480
- if (next == null || next.startsWith("--")) {
481
- options[camelCase(name)] = true;
482
- continue;
483
- }
484
-
485
- options[camelCase(name)] = next;
486
- index += 1;
487
- }
488
-
489
- return {
490
- command,
491
- prompt: positionals.join(" "),
492
- positionals,
493
- options
494
- };
495
- }
496
-
497
- function camelCase(value) {
498
- return value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
499
- }
500
-
501
- function printHelp() {
502
- console.log(`local-code
503
-
504
- Usage:
505
- local-code init
506
- local-code models --provider ollama
507
- local-code run "read the repo and fix the bug"
508
- local-code run "/reviewer look for bugs in src/agent.js"
509
- local-code chat
510
- local-code skills
511
- local-code checkpoint save
512
- local-code checkpoint list
513
- local-code checkpoint show [id]
514
- local-code checkpoint complete [id]
515
-
516
- Options:
517
- --provider ollama|lmstudio
518
- --model MODEL_NAME
519
- --workspace PATH
520
- --allow-commands
521
- --max-steps 12
522
- --temperature 0.2
523
- --ollama-base-url http://127.0.0.1:11434
524
- --lm-studio-base-url http://127.0.0.1:1234
525
-
526
- Startup behavior:
527
- If provider or model is missing, the CLI will detect local Ollama / LM Studio
528
- and ask the user to choose an available option.
529
- If a provider is offline, the wizard offers Retry detection.
530
- Provider and model chosen in the wizard are saved to .local-code.json.
531
- Chat sessions are restored automatically when provider, model, and workspace match.
532
-
533
- Skills:
534
- Put .md files under .local-code/skills/ (project) or ~/.local-code/skills/
535
- (user). Trigger one with a leading /name, e.g. /reviewer or /rv, in both
536
- "run" and "chat". Run "local-code skills" to list what's available.
537
-
538
- Chat commands:
539
- /provider Switch provider and model without restarting chat
540
- /model Switch model for the current provider
541
- /status Show current provider, model, workspace, and saved chat info
542
- /reset Clear saved chat history and start fresh (same provider/model/workspace)
543
- /checkpoint Save a checkpoint (goal/status/pending steps), auto-attaches recent prompts
544
- /checkpoint list List saved checkpoints
545
- /checkpoint show Show the active checkpoint (or a specific one by id)
546
- /checkpoint complete Mark the active checkpoint (or a specific one by id) as done
547
- /skills List available skills
548
- /name Invoke a skill by name or keyword (e.g. /reviewer ...)
549
- /exit Quit chat
550
-
551
- Restored chat history persists across restarts. If the model keeps repeating
552
- something that is no longer true (e.g. it saw a tool fail before you upgraded
553
- local-code, or before you enabled --allow-commands), use /reset instead of
554
- trying to convince it in the same conversation.
555
-
556
- Checkpoints track task progress across restarts, separate from chat history.
557
- An unfinished checkpoint is shown automatically when "chat" starts. Saving one
558
- (via /checkpoint or "local-code checkpoint save") auto-attaches the most
559
- recent real user prompts from the conversation, so you don't have to retype
560
- what you were doing.
561
- `);
562
- }
563
-
564
- async function prepareRuntimeConfig(config, options) {
565
- let nextConfig = { ...config };
566
- const selectedValues = {};
567
- const lastUsed = {
568
- provider: options.startupContext.lastUsedProvider || config.provider,
569
- model: options.startupContext.lastUsedModel || config.model
570
- };
571
-
572
- while (true) {
573
- const providerStatuses = await scanProviderStatuses(config);
574
-
575
- if (!nextConfig.provider) {
576
- const providerSelection = await resolveProvider(providerStatuses, {
577
- workspace: config.workspace,
578
- command: options.command,
579
- lastUsed,
580
- startupContext: options.startupContext
581
- });
582
-
583
- if (providerSelection === "__retry__") {
584
- continue;
585
- }
586
-
587
- nextConfig.provider = providerSelection;
588
- nextConfig.model = "";
589
- selectedValues.provider = nextConfig.provider;
590
- }
591
-
592
- const selectedStatus = providerStatuses[nextConfig.provider];
593
- if (!selectedStatus) {
594
- throw new Error(`Unsupported provider: ${nextConfig.provider}`);
595
- }
596
-
597
- if (!isProviderReady(selectedStatus)) {
598
- const recoveryAction = await resolveProviderRecovery(selectedStatus, providerStatuses, {
599
- cliOptions: options.cliOptions,
600
- workspace: config.workspace,
601
- command: options.command,
602
- lastUsed,
603
- startupContext: options.startupContext
604
- });
605
-
606
- if (recoveryAction === "__retry__") {
607
- continue;
608
- }
609
-
610
- if (recoveryAction === "__switch__") {
611
- nextConfig.provider = "";
612
- nextConfig.model = "";
613
- continue;
614
- }
615
-
616
- throw new Error(buildProviderProblemMessage(selectedStatus));
617
- }
618
-
619
- if (options.requireModelSelection) {
620
- if (!nextConfig.model || !selectedStatus.models.includes(nextConfig.model)) {
621
- if (nextConfig.model && !selectedStatus.models.includes(nextConfig.model) && !isInteractive()) {
622
- throw new Error(
623
- [
624
- `Model not found for ${selectedStatus.label}: ${nextConfig.model}`,
625
- `Available models: ${selectedStatus.models.join(", ")}`
626
- ].join("\n")
627
- );
628
- }
629
-
630
- nextConfig.model = await resolveModel(nextConfig.model, selectedStatus.models, {
631
- workspace: config.workspace,
632
- command: options.command,
633
- provider: nextConfig.provider,
634
- lastUsed,
635
- startupContext: options.startupContext
636
- });
637
- selectedValues.model = nextConfig.model;
638
- }
639
- }
640
-
641
- await persistSelectionsIfNeeded(config, options.cliOptions, selectedValues);
642
- return nextConfig;
643
- }
644
- }
645
-
646
- async function resolveProvider(providerStatuses, context) {
647
- if (!isInteractive()) {
648
- const autoProvider = pickAutoProvider(providerStatuses);
649
- if (autoProvider) {
650
- return autoProvider;
651
- }
652
-
653
- throw new Error(buildProviderSelectionMessage(providerStatuses));
654
- }
655
-
656
- const optionList = [providerStatuses.ollama, providerStatuses.lmstudio];
657
- const readyCount = optionList.filter(isProviderReady).length;
658
- const options = optionList.map((status) => ({
659
- value: status.provider,
660
- label: `${status.label} ${color(`(${summarizeProvider(status)})`, isProviderReady(status) ? "green" : "yellow")}`,
661
- description: `API ${status.baseUrl}`,
662
- hint: isProviderReady(status)
663
- ? `Models: ${status.models.slice(0, 4).join(", ")}`
664
- : getProviderUnavailableReason(status),
665
- disabled: !isProviderReady(status)
666
- }));
667
-
668
- if (readyCount === 0) {
669
- options.push({
670
- value: "__retry__",
671
- label: "Retry detection",
672
- description: "Scan Ollama and LM Studio again.",
673
- hint: "Use this after starting the local server or loading a model.",
674
- disabled: false,
675
- badgeLabel: "action",
676
- badgeTone: "cyan"
677
- });
678
- }
679
-
680
- return selectMenu({
681
- title: "Local Model Provider",
682
- subtitle: "Use arrow keys to choose a ready provider. Unavailable entries show the missing requirement.",
683
- headerLines: buildStartupHeaderLines(context, providerStatuses),
684
- footer: "Enter = confirm, Ctrl+C = cancel",
685
- options
686
- });
687
- }
688
-
689
- async function resolveModel(currentModel, models, context) {
690
- if (currentModel) {
691
- return currentModel;
692
- }
693
-
694
- if (models.length === 1) {
695
- return models[0];
696
- }
697
-
698
- if (!isInteractive()) {
699
- throw new Error(
700
- [
701
- "No model configured.",
702
- `Available models: ${models.join(", ")}`,
703
- "Set --model or LOCAL_CODE_MODEL."
704
- ].join("\n")
705
- );
706
- }
707
-
708
- return selectMenu({
709
- title: "Local Model",
710
- subtitle: "Choose the model this session should use.",
711
- headerLines: buildModelHeaderLines(context, models),
712
- footer: "Enter = confirm, Ctrl+C = cancel",
713
- options: models.map((model) => ({
714
- value: model,
715
- label: model,
716
- description: "",
717
- hint: "",
718
- disabled: false
719
- }))
720
- });
721
- }
722
-
723
- async function resolveProviderRecovery(selectedStatus, providerStatuses, context) {
724
- if (!isInteractive()) {
725
- return "__exit__";
726
- }
727
-
728
- const hasAlternativeProvider = Object.values(providerStatuses).some(
729
- (status) => status.provider !== selectedStatus.provider
730
- );
731
- const options = [
732
- {
733
- value: "__retry__",
734
- label: "Retry detection",
735
- description: `Scan ${selectedStatus.label} again.`,
736
- hint: "Use this after starting the local API server or loading a model.",
737
- disabled: false,
738
- badgeLabel: "action",
739
- badgeTone: "cyan"
740
- }
741
- ];
742
-
743
- if (!context.cliOptions.provider && hasAlternativeProvider) {
744
- options.push({
745
- value: "__switch__",
746
- label: "Choose another provider",
747
- description: "Go back to the provider list.",
748
- hint: "Use this if the other provider becomes ready first.",
749
- disabled: false,
750
- badgeLabel: "action",
751
- badgeTone: "yellow"
752
- });
753
- }
754
-
755
- options.push({
756
- value: "__exit__",
757
- label: "Exit",
758
- description: "Stop startup and keep the current diagnosis.",
759
- hint: "",
760
- disabled: false,
761
- badgeLabel: "action",
762
- badgeTone: "red"
763
- });
764
-
765
- return selectMenu({
766
- title: `${selectedStatus.label} Needs Attention`,
767
- subtitle: buildProviderProblemMessage(selectedStatus),
768
- headerLines: buildStartupHeaderLines(context, providerStatuses),
769
- footer: "Enter = confirm, Ctrl+C = cancel",
770
- options
771
- });
772
- }
773
-
774
- function buildProviderSelectionMessage(providerStatuses) {
775
- return renderDiagnostics(
776
- "Local provider check failed",
777
- buildProviderDiagnostics(providerStatuses)
778
- );
779
- }
780
-
781
- async function scanProviderStatuses(config) {
782
- return withSpinner("Scanning local providers and models...", () => inspectProviders(config));
783
- }
784
-
785
- async function persistSelectionsIfNeeded(config, cliOptions, selectedValues) {
786
- const providerSelected = typeof selectedValues.provider === "string";
787
- const modelSelected = typeof selectedValues.model === "string";
788
- if (!providerSelected && !modelSelected) {
789
- return;
790
- }
791
-
792
- if (cliOptions.provider || cliOptions.model) {
793
- return;
794
- }
795
-
796
- await saveConfigSelections(config.configPath, selectedValues);
797
- if (isInteractive()) {
798
- const pieces = [];
799
- if (providerSelected) {
800
- pieces.push(`provider=${selectedValues.provider}`);
801
- }
802
- if (modelSelected) {
803
- pieces.push(`model=${selectedValues.model}`);
804
- }
805
- printNote(`Saved selection to .local-code.json: ${pieces.join(" ")}`);
806
- }
807
- }
808
-
809
- function buildStartupHeaderLines(context, providerStatuses) {
810
- const readyProviders = Object.values(providerStatuses)
811
- .filter(isProviderReady)
812
- .map((status) => status.label);
813
-
814
- return renderStartupDashboard({
815
- workspace: context.workspace,
816
- command: context.command,
817
- lastUsedProvider: context.lastUsed.provider,
818
- lastUsedModel: context.lastUsed.model,
819
- lastTaskSummary: context.startupContext.lastTaskSummary,
820
- readyCount: readyProviders.length,
821
- totalProviders: Object.keys(providerStatuses).length,
822
- readyProviders,
823
- recentFiles: context.startupContext.recentFiles
824
- }).split("\n");
825
- }
826
-
827
- function buildModelHeaderLines(context, models) {
828
- return renderStartupDashboard({
829
- workspace: context.workspace,
830
- command: context.command,
831
- lastUsedProvider: context.lastUsed.provider || context.provider,
832
- lastUsedModel: context.lastUsed.model,
833
- lastTaskSummary: context.startupContext.lastTaskSummary,
834
- readyCount: 1,
835
- totalProviders: 1,
836
- readyProviders: [context.provider],
837
- recentFiles: context.startupContext.recentFiles
838
- })
839
- .split("\n")
840
- .concat([
841
- "",
842
- `Available models: ${models.length}`
843
- ]);
844
- }
845
-
846
- function createChatSession(config, history = []) {
847
- return createAgentSession(config, buildProgressHooks(), {
848
- history
849
- });
850
- }
851
-
852
- async function loadStartupContext(config, command, overrides = {}) {
853
- const [state, recentFiles] = await Promise.all([
854
- loadAppState(config.statePath),
855
- new Workspace(config.workspace).listRecentFiles(4)
856
- ]);
857
-
858
- return {
859
- command,
860
- lastUsedProvider: overrides.lastUsedProvider ?? config.provider,
861
- lastUsedModel: overrides.lastUsedModel ?? config.model,
862
- lastTaskSummary: state.lastTaskSummary ?? "",
863
- recentFiles: recentFiles.map((item) => item.path)
864
- };
865
- }
866
-
867
- async function rememberTask(config, prompt) {
868
- await saveAppState(config.statePath, {
869
- lastTaskSummary: summarizeTask(prompt),
870
- lastTaskAt: new Date().toISOString()
871
- });
872
- }
873
-
874
- function summarizeTask(prompt) {
875
- const compact = prompt.replace(/\s+/g, " ").trim();
876
- if (compact.length <= 80) {
877
- return compact;
878
- }
879
-
880
- return `${compact.slice(0, 77)}...`;
881
- }
882
-
883
- async function loadChatState(config) {
884
- const state = await loadAppState(config.statePath);
885
- const saved = state.chatSession;
886
- if (
887
- saved &&
888
- saved.provider === config.provider &&
889
- saved.model === config.model &&
890
- saved.workspace === config.workspace &&
891
- Array.isArray(saved.history)
892
- ) {
893
- return saved;
894
- }
895
-
896
- return createEmptyChatState(config);
897
- }
898
-
899
- async function saveChatState(config, chatState) {
900
- await saveAppState(config.statePath, {
901
- chatSession: chatState
902
- });
903
- }
904
-
905
- function createEmptyChatState(config) {
906
- return {
907
- provider: config.provider,
908
- model: config.model,
909
- workspace: config.workspace,
910
- history: [],
911
- updatedAt: ""
912
- };
913
- }
914
-
915
- function renderChatStatus(config, chatState) {
916
- const userTurns = countUserTurns(chatState.history);
917
-
918
- return [
919
- `provider=${config.provider}`,
920
- `model=${config.model}`,
921
- `workspace=${config.workspace}`,
922
- `saved_turns=${userTurns}`,
923
- `history_messages=${chatState.history.length}`,
924
- `history_updated_at=${chatState.updatedAt || "none"}`
925
- ].join("\n");
926
- }
927
-
928
- function countUserTurns(history) {
929
- return history.filter(
930
- (message) => message.role === "user" && !message.content.startsWith("<tool_result ")
931
- ).length;
932
- }
1
+ import path from "node:path";
2
+ import readline from "node:readline/promises";
3
+ import { stdin as input, stdout as output } from "node:process";
4
+ import {
5
+ loadAppState,
6
+ loadConfig,
7
+ saveAppState,
8
+ saveConfigSelections
9
+ } from "./config.js";
10
+ import { createAgentSession, listProviderModels, runAgent } from "./agent.js";
11
+ import {
12
+ buildProviderDiagnostics,
13
+ buildProviderProblemMessage,
14
+ getProviderUnavailableReason,
15
+ inspectProviders,
16
+ isProviderReady,
17
+ pickAutoProvider,
18
+ summarizeProvider
19
+ } from "./runtime.js";
20
+ import {
21
+ color,
22
+ isInteractive,
23
+ printNote,
24
+ printSplash,
25
+ renderStartupDashboard,
26
+ renderDiagnostics,
27
+ selectMenu,
28
+ withSpinner
29
+ } from "./ui.js";
30
+ import { Workspace } from "./workspace.js";
31
+ import { loadSkills, matchSkillInvocation } from "./skills.js";
32
+ import {
33
+ createCheckpoint,
34
+ extractRecentPrompts,
35
+ findActiveCheckpoint,
36
+ formatCheckpoint,
37
+ formatCheckpointSummaryLine,
38
+ loadCheckpoints,
39
+ saveCheckpoints
40
+ } from "./checkpoint.js";
41
+
42
+ export async function main(argv) {
43
+ const parsed = parseArgs(argv);
44
+ const cwd = process.cwd();
45
+ let config = await loadConfig(cwd, parsed.options);
46
+ const skills = await loadSkills(config.workspace);
47
+ const startupContext = await loadStartupContext(config, parsed.command);
48
+
49
+ if (["run", "chat", "models"].includes(parsed.command)) {
50
+ printSplash({
51
+ workspace: config.workspace,
52
+ command: parsed.command,
53
+ lastUsedProvider: startupContext.lastUsedProvider,
54
+ lastUsedModel: startupContext.lastUsedModel,
55
+ lastTaskSummary: startupContext.lastTaskSummary,
56
+ recentFiles: startupContext.recentFiles
57
+ });
58
+ config = await prepareRuntimeConfig(config, {
59
+ requireModelSelection: parsed.command !== "models",
60
+ cliOptions: parsed.options,
61
+ command: parsed.command,
62
+ startupContext
63
+ });
64
+ }
65
+
66
+ switch (parsed.command) {
67
+ case "run":
68
+ await runOnce(config, parsed.prompt, skills);
69
+ return;
70
+ case "chat":
71
+ await runChat(config, skills);
72
+ return;
73
+ case "models":
74
+ await printModels(config);
75
+ return;
76
+ case "init":
77
+ await printInitExample(cwd);
78
+ return;
79
+ case "skills":
80
+ printSkills(skills);
81
+ return;
82
+ case "checkpoint": {
83
+ const [subcommand, ...rest] = parsed.positionals;
84
+ await runCheckpointCommand(config, subcommand, rest);
85
+ return;
86
+ }
87
+ case "help":
88
+ default:
89
+ printHelp();
90
+ }
91
+ }
92
+
93
+ async function runOnce(config, prompt, skills) {
94
+ if (!prompt) {
95
+ throw new Error("Missing prompt. Usage: local-code run \"your task\"");
96
+ }
97
+
98
+ const invocation = matchSkillInvocation(prompt, skills);
99
+ if (invocation.type === "unknown") {
100
+ console.error(buildUnknownSkillMessage(invocation.token, skills));
101
+ process.exitCode = 1;
102
+ return;
103
+ }
104
+
105
+ const runPrompt = invocation.type === "skill" ? invocation.rest : prompt;
106
+ const skillOptions = invocation.type === "skill" ? { skill: invocation.skill } : {};
107
+
108
+ console.error(`provider=${config.provider} model=${config.model} workspace=${config.workspace}`);
109
+ const result = await runAgent(config, runPrompt, buildProgressHooks(), skillOptions);
110
+ await rememberTask(config, prompt);
111
+ printResult(result);
112
+ }
113
+
114
+ function buildProgressHooks() {
115
+ return {
116
+ onStep(step) {
117
+ console.error(`step ${step}: waiting for model...`);
118
+ },
119
+ onToolCall(toolCall) {
120
+ console.error(`step result: called tool "${toolCall.tool}"`);
121
+ },
122
+ onToolCallError({ step, reason, message, attempt }) {
123
+ const label = reason === "truncated" ? "reply cut off (length limit)" : "invalid tool call JSON";
124
+ console.error(`step ${step} result: ${label}, asking model to retry (attempt ${attempt})`);
125
+ if (message) {
126
+ console.error(` ${message}`);
127
+ }
128
+ }
129
+ };
130
+ }
131
+
132
+ function printResult(result) {
133
+ if (result.failed) {
134
+ console.log(`⚠️ ${result.content}`);
135
+ return;
136
+ }
137
+
138
+ console.log(result.content);
139
+ }
140
+
141
+ function printSkills(skills) {
142
+ const uniqueSkills = [...new Set(skills.values())];
143
+ if (uniqueSkills.length === 0) {
144
+ console.log("No skills found. Add .md files under .local-code/skills/ (project) or ~/.local-code/skills/ (user).");
145
+ return;
146
+ }
147
+
148
+ for (const skill of uniqueSkills) {
149
+ const keywords = skill.keywords.length > 0 ? skill.keywords.join(", ") : "-";
150
+ const tools = skill.tools ? skill.tools.join(", ") : "all";
151
+ console.log(`/${skill.name} [${skill.scope}]`);
152
+ console.log(` ${skill.description}`);
153
+ console.log(` keywords: ${keywords}`);
154
+ console.log(` tools: ${tools}`);
155
+ console.log(` source: ${skill.sourcePath}`);
156
+ console.log("");
157
+ }
158
+ }
159
+
160
+ function buildUnknownSkillMessage(token, skills) {
161
+ const names = [...new Set(skills.values())].map((skill) => skill.name);
162
+ const available = names.length > 0 ? names.join(", ") : "(none)";
163
+ return `Unknown skill /${token}. Available skills: ${available}`;
164
+ }
165
+
166
+ async function runChat(config, skills) {
167
+ const rl = readline.createInterface({ input, output });
168
+ let activeConfig = { ...config };
169
+ let chatState = await loadChatState(activeConfig);
170
+ let session = createChatSession(activeConfig, chatState.history);
171
+
172
+ console.log(`local-code chat (${activeConfig.provider}:${activeConfig.model || "no-model"})`);
173
+ console.log("Type /exit to quit. Use /provider, /model, /status, /checkpoint, or /reset during chat.");
174
+ if (chatState.history.length > 0) {
175
+ console.log(`restored saved chat history (${countUserTurns(chatState.history)} turn(s))`);
176
+ console.log("If the model keeps repeating an outdated claim (e.g. from before a tool was fixed), try /reset to start fresh.");
177
+ }
178
+
179
+ const activeCheckpoint = findActiveCheckpoint(await loadCheckpoints(activeConfig));
180
+ if (activeCheckpoint) {
181
+ console.log("\nUnfinished checkpoint detected:");
182
+ console.log(formatCheckpoint(activeCheckpoint));
183
+ console.log("Continue from the pending steps above, or run /checkpoint complete once done.\n");
184
+ }
185
+
186
+ try {
187
+ while (true) {
188
+ const prompt = (await rl.question("> ")).trim();
189
+ if (!prompt) {
190
+ continue;
191
+ }
192
+ if (prompt === "/exit") {
193
+ break;
194
+ }
195
+
196
+ if (prompt === "/status") {
197
+ console.log(renderChatStatus(activeConfig, chatState));
198
+ continue;
199
+ }
200
+
201
+ if (prompt === "/skills") {
202
+ printSkills(skills);
203
+ continue;
204
+ }
205
+
206
+ if (prompt === "/checkpoint" || prompt.startsWith("/checkpoint ")) {
207
+ const [, subcommand, ...rest] = prompt.split(/\s+/);
208
+ await runChatCheckpointCommand(rl, activeConfig, session, subcommand, rest);
209
+ continue;
210
+ }
211
+
212
+ if (prompt === "/reset") {
213
+ chatState = createEmptyChatState(activeConfig);
214
+ await saveChatState(activeConfig, chatState);
215
+ session = createChatSession(activeConfig, chatState.history);
216
+ console.log("chat history cleared. Starting a fresh conversation (same provider/model/workspace).");
217
+ continue;
218
+ }
219
+
220
+ if (prompt === "/provider") {
221
+ activeConfig = await prepareRuntimeConfig(
222
+ {
223
+ ...activeConfig,
224
+ provider: "",
225
+ model: ""
226
+ },
227
+ {
228
+ requireModelSelection: true,
229
+ cliOptions: {},
230
+ command: "chat",
231
+ startupContext: await loadStartupContext(activeConfig, "chat", {
232
+ lastUsedProvider: activeConfig.provider,
233
+ lastUsedModel: activeConfig.model
234
+ })
235
+ }
236
+ );
237
+ chatState = createEmptyChatState(activeConfig);
238
+ await saveChatState(activeConfig, chatState);
239
+ session = createChatSession(activeConfig, chatState.history);
240
+ console.log(`switched to ${activeConfig.provider}:${activeConfig.model}`);
241
+ continue;
242
+ }
243
+
244
+ if (prompt === "/model") {
245
+ activeConfig = await prepareRuntimeConfig(
246
+ {
247
+ ...activeConfig,
248
+ model: ""
249
+ },
250
+ {
251
+ requireModelSelection: true,
252
+ cliOptions: {},
253
+ command: "chat",
254
+ startupContext: await loadStartupContext(activeConfig, "chat", {
255
+ lastUsedProvider: activeConfig.provider,
256
+ lastUsedModel: activeConfig.model
257
+ })
258
+ }
259
+ );
260
+ chatState = createEmptyChatState(activeConfig);
261
+ await saveChatState(activeConfig, chatState);
262
+ session = createChatSession(activeConfig, chatState.history);
263
+ console.log(`switched to ${activeConfig.provider}:${activeConfig.model}`);
264
+ continue;
265
+ }
266
+
267
+ const invocation = matchSkillInvocation(prompt, skills);
268
+ if (invocation.type === "unknown") {
269
+ console.log(buildUnknownSkillMessage(invocation.token, skills));
270
+ continue;
271
+ }
272
+
273
+ const chatPrompt = invocation.type === "skill" ? invocation.rest : prompt;
274
+ const chatSkillOptions = invocation.type === "skill" ? { skill: invocation.skill } : {};
275
+
276
+ let result;
277
+ try {
278
+ result = await session.ask(chatPrompt, chatSkillOptions);
279
+ } catch (error) {
280
+ console.error(error instanceof Error ? error.message : String(error));
281
+ continue;
282
+ }
283
+
284
+ await rememberTask(activeConfig, prompt);
285
+ chatState = {
286
+ ...chatState,
287
+ provider: activeConfig.provider,
288
+ model: activeConfig.model,
289
+ workspace: activeConfig.workspace,
290
+ history: session.getHistory(),
291
+ updatedAt: new Date().toISOString()
292
+ };
293
+ await saveChatState(activeConfig, chatState);
294
+ printResult(result);
295
+ }
296
+ } finally {
297
+ rl.close();
298
+ }
299
+ }
300
+
301
+ async function runChatCheckpointCommand(rl, config, session, subcommand, rest) {
302
+ switch (subcommand) {
303
+ case undefined:
304
+ case "save": {
305
+ const fields = await collectCheckpointFields(rl);
306
+ const recentPrompts = extractRecentPrompts(session.getHistory());
307
+ const checkpoint = createCheckpoint({ ...fields, recentPrompts });
308
+ const checkpoints = [...(await loadCheckpoints(config)), checkpoint];
309
+ await saveCheckpoints(config, checkpoints);
310
+ console.log(`\ncheckpoint saved: ${checkpoint.id}`);
311
+ return;
312
+ }
313
+ case "list":
314
+ await cmdCheckpointList(config);
315
+ return;
316
+ case "show":
317
+ await cmdCheckpointShow(config, rest[0]);
318
+ return;
319
+ case "complete":
320
+ await cmdCheckpointComplete(config, rest[0]);
321
+ return;
322
+ default:
323
+ console.log(`Unknown checkpoint subcommand: ${subcommand}`);
324
+ console.log("Usage: /checkpoint [save|list|show|complete] [id]");
325
+ }
326
+ }
327
+
328
+ async function runCheckpointCommand(config, subcommand, rest) {
329
+ switch (subcommand) {
330
+ case undefined:
331
+ case "save":
332
+ await cmdCheckpointSave(config);
333
+ return;
334
+ case "list":
335
+ await cmdCheckpointList(config);
336
+ return;
337
+ case "show":
338
+ await cmdCheckpointShow(config, rest[0]);
339
+ return;
340
+ case "complete":
341
+ await cmdCheckpointComplete(config, rest[0]);
342
+ return;
343
+ default:
344
+ console.log(`Unknown checkpoint subcommand: ${subcommand}`);
345
+ console.log("Usage: local-code checkpoint <save|list|show|complete> [id]");
346
+ }
347
+ }
348
+
349
+ async function cmdCheckpointSave(config) {
350
+ const rl = readline.createInterface({ input, output });
351
+ try {
352
+ const fields = await collectCheckpointFields(rl);
353
+ const state = await loadAppState(config.statePath);
354
+ const recentPrompts = extractRecentPrompts(state.chatSession?.history);
355
+
356
+ const checkpoint = createCheckpoint({ ...fields, recentPrompts });
357
+ const checkpoints = [...(await loadCheckpoints(config)), checkpoint];
358
+ await saveCheckpoints(config, checkpoints);
359
+
360
+ console.log(`\ncheckpoint saved: ${checkpoint.id}`);
361
+ } finally {
362
+ rl.close();
363
+ }
364
+ }
365
+
366
+ async function cmdCheckpointList(config) {
367
+ const checkpoints = await loadCheckpoints(config);
368
+ if (checkpoints.length === 0) {
369
+ console.log("No checkpoints saved yet.");
370
+ return;
371
+ }
372
+
373
+ console.log("");
374
+ for (const checkpoint of [...checkpoints].reverse()) {
375
+ console.log(formatCheckpointSummaryLine(checkpoint));
376
+ }
377
+ console.log("");
378
+ }
379
+
380
+ async function cmdCheckpointShow(config, id) {
381
+ const checkpoints = await loadCheckpoints(config);
382
+ const checkpoint = id ? checkpoints.find((entry) => entry.id === id) : findActiveCheckpoint(checkpoints);
383
+ if (!checkpoint) {
384
+ console.log(id ? `Checkpoint not found: ${id}` : "No active checkpoint.");
385
+ return;
386
+ }
387
+
388
+ console.log("");
389
+ console.log(formatCheckpoint(checkpoint));
390
+ console.log("");
391
+ }
392
+
393
+ async function cmdCheckpointComplete(config, id) {
394
+ const checkpoints = await loadCheckpoints(config);
395
+ const target = id ? checkpoints.find((entry) => entry.id === id) : findActiveCheckpoint(checkpoints);
396
+ if (!target) {
397
+ console.log(id ? `Checkpoint not found: ${id}` : "No active checkpoint.");
398
+ return;
399
+ }
400
+
401
+ target.completed = true;
402
+ target.completedAt = new Date().toISOString();
403
+ await saveCheckpoints(config, checkpoints);
404
+ console.log(`checkpoint marked complete: ${target.id}`);
405
+ }
406
+
407
+ async function collectCheckpointFields(rl) {
408
+ console.log("\n=== Save checkpoint ===\n");
409
+ const goal = await askLine(rl, "Goal (what is this task trying to do)");
410
+ const status = await askLine(rl, "Current status");
411
+ const completedSteps = await askMultiline(rl, "Completed steps");
412
+ const pendingSteps = await askMultiline(rl, "Pending steps (continue here next time)");
413
+ const contextNotes = await askMultiline(rl, "Context / decisions (optional)");
414
+ const blockers = await askMultiline(rl, "Blockers (optional)");
415
+ const keyFilesRaw = await askLine(rl, "Key files (comma separated, optional)");
416
+ const keyFiles = keyFilesRaw ? keyFilesRaw.split(",").map((file) => file.trim()).filter(Boolean) : [];
417
+
418
+ return { goal, status, completedSteps, pendingSteps, contextNotes, blockers, keyFiles };
419
+ }
420
+
421
+ async function askLine(rl, label, defaultValue = "") {
422
+ const suffix = defaultValue ? ` [${defaultValue}]` : "";
423
+ const answer = (await rl.question(`${label}${suffix}: `)).trim();
424
+ return answer || defaultValue;
425
+ }
426
+
427
+ async function askMultiline(rl, label) {
428
+ console.log(`${label} (one per line, blank line to finish):`);
429
+ const items = [];
430
+ while (true) {
431
+ const line = (await rl.question(" > ")).trim();
432
+ if (!line) {
433
+ break;
434
+ }
435
+ items.push(line);
436
+ }
437
+ return items;
438
+ }
439
+
440
+ async function printModels(config) {
441
+ const models = await listProviderModels(config);
442
+ for (const model of models) {
443
+ console.log(model);
444
+ }
445
+ }
446
+
447
+ async function printInitExample(cwd) {
448
+ const example = {
449
+ provider: "",
450
+ model: "",
451
+ workspace: ".",
452
+ ollamaBaseUrl: "http://127.0.0.1:11434",
453
+ lmStudioBaseUrl: "http://127.0.0.1:1234",
454
+ maxSteps: 12,
455
+ allowCommands: false,
456
+ temperature: 0.2
457
+ };
458
+
459
+ console.log(`Create ${path.join(cwd, ".local-code.json")} with:`);
460
+ console.log(JSON.stringify(example, null, 2));
461
+ console.log("");
462
+ console.log("If provider or model is empty, the CLI will ask the user to choose at startup.");
463
+ console.log("Wizard selections are saved back to .local-code.json unless you override them with CLI flags.");
464
+ }
465
+
466
+ function parseArgs(argv) {
467
+ const command = argv[0] || "help";
468
+ const options = {};
469
+ const positionals = [];
470
+
471
+ for (let index = 1; index < argv.length; index += 1) {
472
+ const current = argv[index];
473
+ if (!current.startsWith("--")) {
474
+ positionals.push(current);
475
+ continue;
476
+ }
477
+
478
+ const name = current.slice(2);
479
+ const next = argv[index + 1];
480
+ if (next == null || next.startsWith("--")) {
481
+ options[camelCase(name)] = true;
482
+ continue;
483
+ }
484
+
485
+ options[camelCase(name)] = next;
486
+ index += 1;
487
+ }
488
+
489
+ return {
490
+ command,
491
+ prompt: positionals.join(" "),
492
+ positionals,
493
+ options
494
+ };
495
+ }
496
+
497
+ function camelCase(value) {
498
+ return value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
499
+ }
500
+
501
+ function printHelp() {
502
+ console.log(`local-code
503
+
504
+ Usage:
505
+ local-code init
506
+ local-code models --provider ollama
507
+ local-code run "read the repo and fix the bug"
508
+ local-code run "/reviewer look for bugs in src/agent.js"
509
+ local-code chat
510
+ local-code skills
511
+ local-code checkpoint save
512
+ local-code checkpoint list
513
+ local-code checkpoint show [id]
514
+ local-code checkpoint complete [id]
515
+
516
+ Options:
517
+ --provider ollama|lmstudio
518
+ --model MODEL_NAME
519
+ --workspace PATH
520
+ --allow-commands
521
+ --max-steps 12
522
+ --temperature 0.2
523
+ --ollama-base-url http://127.0.0.1:11434
524
+ --lm-studio-base-url http://127.0.0.1:1234
525
+
526
+ Startup behavior:
527
+ If provider or model is missing, the CLI will detect local Ollama / LM Studio
528
+ and ask the user to choose an available option.
529
+ If a provider is offline, the wizard offers Retry detection.
530
+ Provider and model chosen in the wizard are saved to .local-code.json.
531
+ Chat sessions are restored automatically when provider, model, and workspace match.
532
+
533
+ Skills:
534
+ Put .md files under .local-code/skills/ (project) or ~/.local-code/skills/
535
+ (user). Trigger one with a leading /name, e.g. /reviewer or /rv, in both
536
+ "run" and "chat". Run "local-code skills" to list what's available.
537
+
538
+ Chat commands:
539
+ /provider Switch provider and model without restarting chat
540
+ /model Switch model for the current provider
541
+ /status Show current provider, model, workspace, and saved chat info
542
+ /reset Clear saved chat history and start fresh (same provider/model/workspace)
543
+ /checkpoint Save a checkpoint (goal/status/pending steps), auto-attaches recent prompts
544
+ /checkpoint list List saved checkpoints
545
+ /checkpoint show Show the active checkpoint (or a specific one by id)
546
+ /checkpoint complete Mark the active checkpoint (or a specific one by id) as done
547
+ /skills List available skills
548
+ /name Invoke a skill by name or keyword (e.g. /reviewer ...)
549
+ /exit Quit chat
550
+
551
+ Restored chat history persists across restarts. If the model keeps repeating
552
+ something that is no longer true (e.g. it saw a tool fail before you upgraded
553
+ local-code, or before you enabled --allow-commands), use /reset instead of
554
+ trying to convince it in the same conversation.
555
+
556
+ Checkpoints track task progress across restarts, separate from chat history.
557
+ An unfinished checkpoint is shown automatically when "chat" starts. Saving one
558
+ (via /checkpoint or "local-code checkpoint save") auto-attaches the most
559
+ recent real user prompts from the conversation, so you don't have to retype
560
+ what you were doing.
561
+ `);
562
+ }
563
+
564
+ async function prepareRuntimeConfig(config, options) {
565
+ let nextConfig = { ...config };
566
+ const selectedValues = {};
567
+ const lastUsed = {
568
+ provider: options.startupContext.lastUsedProvider || config.provider,
569
+ model: options.startupContext.lastUsedModel || config.model
570
+ };
571
+
572
+ while (true) {
573
+ const providerStatuses = await scanProviderStatuses(config);
574
+
575
+ if (!nextConfig.provider) {
576
+ const providerSelection = await resolveProvider(providerStatuses, {
577
+ workspace: config.workspace,
578
+ command: options.command,
579
+ lastUsed,
580
+ startupContext: options.startupContext
581
+ });
582
+
583
+ if (providerSelection === "__retry__") {
584
+ continue;
585
+ }
586
+
587
+ nextConfig.provider = providerSelection;
588
+ nextConfig.model = "";
589
+ selectedValues.provider = nextConfig.provider;
590
+ }
591
+
592
+ const selectedStatus = providerStatuses[nextConfig.provider];
593
+ if (!selectedStatus) {
594
+ throw new Error(`Unsupported provider: ${nextConfig.provider}`);
595
+ }
596
+
597
+ if (!isProviderReady(selectedStatus)) {
598
+ const recoveryAction = await resolveProviderRecovery(selectedStatus, providerStatuses, {
599
+ cliOptions: options.cliOptions,
600
+ workspace: config.workspace,
601
+ command: options.command,
602
+ lastUsed,
603
+ startupContext: options.startupContext
604
+ });
605
+
606
+ if (recoveryAction === "__retry__") {
607
+ continue;
608
+ }
609
+
610
+ if (recoveryAction === "__switch__") {
611
+ nextConfig.provider = "";
612
+ nextConfig.model = "";
613
+ continue;
614
+ }
615
+
616
+ throw new Error(buildProviderProblemMessage(selectedStatus));
617
+ }
618
+
619
+ if (options.requireModelSelection) {
620
+ if (!nextConfig.model || !selectedStatus.models.includes(nextConfig.model)) {
621
+ if (nextConfig.model && !selectedStatus.models.includes(nextConfig.model) && !isInteractive()) {
622
+ throw new Error(
623
+ [
624
+ `Model not found for ${selectedStatus.label}: ${nextConfig.model}`,
625
+ `Available models: ${selectedStatus.models.join(", ")}`
626
+ ].join("\n")
627
+ );
628
+ }
629
+
630
+ nextConfig.model = await resolveModel(nextConfig.model, selectedStatus.models, {
631
+ workspace: config.workspace,
632
+ command: options.command,
633
+ provider: nextConfig.provider,
634
+ lastUsed,
635
+ startupContext: options.startupContext
636
+ });
637
+ selectedValues.model = nextConfig.model;
638
+ }
639
+ }
640
+
641
+ await persistSelectionsIfNeeded(config, options.cliOptions, selectedValues);
642
+ return nextConfig;
643
+ }
644
+ }
645
+
646
+ async function resolveProvider(providerStatuses, context) {
647
+ if (!isInteractive()) {
648
+ const autoProvider = pickAutoProvider(providerStatuses);
649
+ if (autoProvider) {
650
+ return autoProvider;
651
+ }
652
+
653
+ throw new Error(buildProviderSelectionMessage(providerStatuses));
654
+ }
655
+
656
+ const optionList = [providerStatuses.ollama, providerStatuses.lmstudio];
657
+ const readyCount = optionList.filter(isProviderReady).length;
658
+ const options = optionList.map((status) => ({
659
+ value: status.provider,
660
+ label: `${status.label} ${color(`(${summarizeProvider(status)})`, isProviderReady(status) ? "green" : "yellow")}`,
661
+ description: `API ${status.baseUrl}`,
662
+ hint: isProviderReady(status)
663
+ ? `Models: ${status.models.slice(0, 4).join(", ")}`
664
+ : getProviderUnavailableReason(status),
665
+ disabled: !isProviderReady(status)
666
+ }));
667
+
668
+ if (readyCount === 0) {
669
+ options.push({
670
+ value: "__retry__",
671
+ label: "Retry detection",
672
+ description: "Scan Ollama and LM Studio again.",
673
+ hint: "Use this after starting the local server or loading a model.",
674
+ disabled: false,
675
+ badgeLabel: "action",
676
+ badgeTone: "cyan"
677
+ });
678
+ }
679
+
680
+ return selectMenu({
681
+ title: "Local Model Provider",
682
+ subtitle: "Use arrow keys to choose a ready provider. Unavailable entries show the missing requirement.",
683
+ headerLines: buildStartupHeaderLines(context, providerStatuses),
684
+ footer: "Enter = confirm, Ctrl+C = cancel",
685
+ options
686
+ });
687
+ }
688
+
689
+ async function resolveModel(currentModel, models, context) {
690
+ if (currentModel) {
691
+ return currentModel;
692
+ }
693
+
694
+ if (models.length === 1) {
695
+ return models[0];
696
+ }
697
+
698
+ if (!isInteractive()) {
699
+ throw new Error(
700
+ [
701
+ "No model configured.",
702
+ `Available models: ${models.join(", ")}`,
703
+ "Set --model or LOCAL_CODE_MODEL."
704
+ ].join("\n")
705
+ );
706
+ }
707
+
708
+ return selectMenu({
709
+ title: "Local Model",
710
+ subtitle: "Choose the model this session should use.",
711
+ headerLines: buildModelHeaderLines(context, models),
712
+ footer: "Enter = confirm, Ctrl+C = cancel",
713
+ options: models.map((model) => ({
714
+ value: model,
715
+ label: model,
716
+ description: "",
717
+ hint: "",
718
+ disabled: false
719
+ }))
720
+ });
721
+ }
722
+
723
+ async function resolveProviderRecovery(selectedStatus, providerStatuses, context) {
724
+ if (!isInteractive()) {
725
+ return "__exit__";
726
+ }
727
+
728
+ const hasAlternativeProvider = Object.values(providerStatuses).some(
729
+ (status) => status.provider !== selectedStatus.provider
730
+ );
731
+ const options = [
732
+ {
733
+ value: "__retry__",
734
+ label: "Retry detection",
735
+ description: `Scan ${selectedStatus.label} again.`,
736
+ hint: "Use this after starting the local API server or loading a model.",
737
+ disabled: false,
738
+ badgeLabel: "action",
739
+ badgeTone: "cyan"
740
+ }
741
+ ];
742
+
743
+ if (!context.cliOptions.provider && hasAlternativeProvider) {
744
+ options.push({
745
+ value: "__switch__",
746
+ label: "Choose another provider",
747
+ description: "Go back to the provider list.",
748
+ hint: "Use this if the other provider becomes ready first.",
749
+ disabled: false,
750
+ badgeLabel: "action",
751
+ badgeTone: "yellow"
752
+ });
753
+ }
754
+
755
+ options.push({
756
+ value: "__exit__",
757
+ label: "Exit",
758
+ description: "Stop startup and keep the current diagnosis.",
759
+ hint: "",
760
+ disabled: false,
761
+ badgeLabel: "action",
762
+ badgeTone: "red"
763
+ });
764
+
765
+ return selectMenu({
766
+ title: `${selectedStatus.label} Needs Attention`,
767
+ subtitle: buildProviderProblemMessage(selectedStatus),
768
+ headerLines: buildStartupHeaderLines(context, providerStatuses),
769
+ footer: "Enter = confirm, Ctrl+C = cancel",
770
+ options
771
+ });
772
+ }
773
+
774
+ function buildProviderSelectionMessage(providerStatuses) {
775
+ return renderDiagnostics(
776
+ "Local provider check failed",
777
+ buildProviderDiagnostics(providerStatuses)
778
+ );
779
+ }
780
+
781
+ async function scanProviderStatuses(config) {
782
+ return withSpinner("Scanning local providers and models...", () => inspectProviders(config));
783
+ }
784
+
785
+ async function persistSelectionsIfNeeded(config, cliOptions, selectedValues) {
786
+ const providerSelected = typeof selectedValues.provider === "string";
787
+ const modelSelected = typeof selectedValues.model === "string";
788
+ if (!providerSelected && !modelSelected) {
789
+ return;
790
+ }
791
+
792
+ if (cliOptions.provider || cliOptions.model) {
793
+ return;
794
+ }
795
+
796
+ await saveConfigSelections(config.configPath, selectedValues);
797
+ if (isInteractive()) {
798
+ const pieces = [];
799
+ if (providerSelected) {
800
+ pieces.push(`provider=${selectedValues.provider}`);
801
+ }
802
+ if (modelSelected) {
803
+ pieces.push(`model=${selectedValues.model}`);
804
+ }
805
+ printNote(`Saved selection to .local-code.json: ${pieces.join(" ")}`);
806
+ }
807
+ }
808
+
809
+ function buildStartupHeaderLines(context, providerStatuses) {
810
+ const readyProviders = Object.values(providerStatuses)
811
+ .filter(isProviderReady)
812
+ .map((status) => status.label);
813
+
814
+ return renderStartupDashboard({
815
+ workspace: context.workspace,
816
+ command: context.command,
817
+ lastUsedProvider: context.lastUsed.provider,
818
+ lastUsedModel: context.lastUsed.model,
819
+ lastTaskSummary: context.startupContext.lastTaskSummary,
820
+ readyCount: readyProviders.length,
821
+ totalProviders: Object.keys(providerStatuses).length,
822
+ readyProviders,
823
+ recentFiles: context.startupContext.recentFiles
824
+ }).split("\n");
825
+ }
826
+
827
+ function buildModelHeaderLines(context, models) {
828
+ return renderStartupDashboard({
829
+ workspace: context.workspace,
830
+ command: context.command,
831
+ lastUsedProvider: context.lastUsed.provider || context.provider,
832
+ lastUsedModel: context.lastUsed.model,
833
+ lastTaskSummary: context.startupContext.lastTaskSummary,
834
+ readyCount: 1,
835
+ totalProviders: 1,
836
+ readyProviders: [context.provider],
837
+ recentFiles: context.startupContext.recentFiles
838
+ })
839
+ .split("\n")
840
+ .concat([
841
+ "",
842
+ `Available models: ${models.length}`
843
+ ]);
844
+ }
845
+
846
+ function createChatSession(config, history = []) {
847
+ return createAgentSession(config, buildProgressHooks(), {
848
+ history
849
+ });
850
+ }
851
+
852
+ async function loadStartupContext(config, command, overrides = {}) {
853
+ const [state, recentFiles] = await Promise.all([
854
+ loadAppState(config.statePath),
855
+ new Workspace(config.workspace).listRecentFiles(4)
856
+ ]);
857
+
858
+ return {
859
+ command,
860
+ lastUsedProvider: overrides.lastUsedProvider ?? config.provider,
861
+ lastUsedModel: overrides.lastUsedModel ?? config.model,
862
+ lastTaskSummary: state.lastTaskSummary ?? "",
863
+ recentFiles: recentFiles.map((item) => item.path)
864
+ };
865
+ }
866
+
867
+ async function rememberTask(config, prompt) {
868
+ await saveAppState(config.statePath, {
869
+ lastTaskSummary: summarizeTask(prompt),
870
+ lastTaskAt: new Date().toISOString()
871
+ });
872
+ }
873
+
874
+ function summarizeTask(prompt) {
875
+ const compact = prompt.replace(/\s+/g, " ").trim();
876
+ if (compact.length <= 80) {
877
+ return compact;
878
+ }
879
+
880
+ return `${compact.slice(0, 77)}...`;
881
+ }
882
+
883
+ async function loadChatState(config) {
884
+ const state = await loadAppState(config.statePath);
885
+ const saved = state.chatSession;
886
+ if (
887
+ saved &&
888
+ saved.provider === config.provider &&
889
+ saved.model === config.model &&
890
+ saved.workspace === config.workspace &&
891
+ Array.isArray(saved.history)
892
+ ) {
893
+ return saved;
894
+ }
895
+
896
+ return createEmptyChatState(config);
897
+ }
898
+
899
+ async function saveChatState(config, chatState) {
900
+ await saveAppState(config.statePath, {
901
+ chatSession: chatState
902
+ });
903
+ }
904
+
905
+ function createEmptyChatState(config) {
906
+ return {
907
+ provider: config.provider,
908
+ model: config.model,
909
+ workspace: config.workspace,
910
+ history: [],
911
+ updatedAt: ""
912
+ };
913
+ }
914
+
915
+ function renderChatStatus(config, chatState) {
916
+ const userTurns = countUserTurns(chatState.history);
917
+
918
+ return [
919
+ `provider=${config.provider}`,
920
+ `model=${config.model}`,
921
+ `workspace=${config.workspace}`,
922
+ `saved_turns=${userTurns}`,
923
+ `history_messages=${chatState.history.length}`,
924
+ `history_updated_at=${chatState.updatedAt || "none"}`
925
+ ].join("\n");
926
+ }
927
+
928
+ function countUserTurns(history) {
929
+ return history.filter(
930
+ (message) => message.role === "user" && !message.content.startsWith("<tool_result ")
931
+ ).length;
932
+ }