@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/LICENSE +21 -21
- package/README.md +274 -263
- package/bin/local-code.js +8 -8
- package/package.json +34 -32
- package/scripts/postinstall.js +14 -0
- package/src/checkpoint.js +125 -125
- package/src/cli.js +932 -932
- package/src/config.js +106 -106
- package/src/providers/lmstudio.js +49 -49
- package/src/providers/ollama.js +52 -52
- package/src/runtime.js +341 -341
- package/src/skills.js +130 -130
- package/src/syntaxCheck.js +62 -62
- package/src/tools.js +105 -105
- package/src/ui.js +281 -281
- package/src/workspace.js +224 -224
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
|
+
}
|