markov-cli 1.0.11 → 1.0.12

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.
@@ -1,484 +1,42 @@
1
1
  import chalk from 'chalk';
2
2
 
3
3
  import gradient from 'gradient-string';
4
+ import { writeFileSync, readFileSync, existsSync } from 'fs';
4
5
  import { homedir } from 'os';
5
6
  import { resolve } from 'path';
6
- import { mkdirSync, writeFileSync } from 'fs';
7
7
  import { printLogo } from './ui/logo.js';
8
- import { chatWithTools, streamChat, MODEL, MODELS, setModel } from './ollama.js';
8
+ import { chatWithTools, streamChat, streamChatWithTools, MODEL, MODEL_OPTIONS, setModelAndProvider, getModelDisplayName } from './ollama.js';
9
9
  import { resolveFileRefs } from './files.js';
10
- import { execCommand, AGENT_TOOLS, runTool } from './tools.js';
11
- import { parseEdits, renderDiff, applyEdit } from './editor.js';
10
+ import { RUN_TERMINAL_COMMAND_TOOL, WEB_SEARCH_TOOL, runTool } from './tools.js';
12
11
  import { chatPrompt } from './input.js';
13
- import { getFiles, getFilesAndDirs } from './ui/picker.js';
14
- import { getToken, login, clearToken } from './auth.js';
12
+ import { getFilesAndDirs } from './ui/picker.js';
13
+ import { getToken, login, clearToken, getClaudeKey, setClaudeKey, getOpenAIKey, setOpenAIKey } from './auth.js';
15
14
 
16
- const agentGradient = gradient(['#22c55e', '#16a34a', '#4ade80']);
17
-
18
- /** Arrow-key selector. Returns the chosen string or null if cancelled. */
19
- function selectFrom(options, label) {
20
- return new Promise((resolve) => {
21
- let idx = 0;
22
-
23
- const draw = () => {
24
- process.stdout.write('\r\x1b[0J');
25
- process.stdout.write(chalk.dim(label) + '\n');
26
- options.forEach((o, i) => {
27
- process.stdout.write(
28
- i === idx
29
- ? ' ' + chalk.bgCyan.black(` ${o} `) + '\n'
30
- : ' ' + chalk.dim(o) + '\n'
31
- );
32
- });
33
- // Move cursor back up to keep it stable
34
- process.stdout.write(`\x1b[${options.length + 1}A`);
35
- };
36
-
37
- const cleanup = () => {
38
- process.stdin.removeListener('data', onKey);
39
- process.stdin.setRawMode(false);
40
- process.stdin.pause();
41
- // Clear the drawn lines
42
- process.stdout.write('\r\x1b[0J');
43
- };
44
-
45
- const onKey = (data) => {
46
- const key = data.toString();
47
- if (key === '\x1b[A') { idx = (idx - 1 + options.length) % options.length; draw(); return; }
48
- if (key === '\x1b[B') { idx = (idx + 1) % options.length; draw(); return; }
49
- if (key === '\r' || key === '\n') { cleanup(); resolve(options[idx]); return; }
50
- if (key === '\x03' || key === '\x11') { cleanup(); resolve(null); return; }
51
- };
15
+ // Extracted UI modules
16
+ import { selectFrom, confirm, promptLine, promptSecret } from './ui/prompts.js';
17
+ import { formatResponseWithCodeBlocks, formatFileEditPreview, formatToolCallSummary, formatToolResultSummary, printTokenUsage } from './ui/formatting.js';
18
+ import { createSpinner } from './ui/spinner.js';
52
19
 
53
- process.stdin.setRawMode(true);
54
- process.stdin.resume();
55
- process.stdin.setEncoding('utf8');
56
- process.stdin.on('data', onKey);
57
- draw();
58
- });
59
- }
60
-
61
- /** Prompt y/n in raw mode, returns true for y/Y. */
62
- function confirm(question) {
63
- return new Promise((resolve) => {
64
- process.stdout.write(question);
65
- process.stdin.setRawMode(true);
66
- process.stdin.resume();
67
- process.stdin.setEncoding('utf8');
68
- const onKey = (key) => {
69
- process.stdin.removeListener('data', onKey);
70
- process.stdin.setRawMode(false);
71
- process.stdin.pause();
72
- const answer = key.toLowerCase() === 'y';
73
- process.stdout.write(answer ? chalk.green('y\n') : chalk.dim('n\n'));
74
- resolve(answer);
75
- };
76
- process.stdin.on('data', onKey);
77
- });
78
- }
20
+ // Extracted agent modules
21
+ import { buildPlanSystemMessage, buildAgentSystemMessage, buildInitSystemMessage, getLsContext, getGrepContext } from './agent/context.js';
22
+ import { runAgentLoop, maybePrintFullPayload, AGENT_LOOP_MAX_ITERATIONS } from './agent/agentLoop.js';
79
23
 
80
- /** Read a visible line of input. */
81
- function promptLine(label) {
82
- return new Promise((resolve) => {
83
- process.stdout.write(label);
84
- let buf = '';
85
- const onData = (data) => {
86
- const key = data.toString();
87
- if (key === '\r' || key === '\n') {
88
- process.stdin.removeListener('data', onData);
89
- process.stdin.setRawMode(false);
90
- process.stdin.pause();
91
- process.stdout.write('\n');
92
- resolve(buf);
93
- } else if (key === '\x7f' || key === '\b') {
94
- if (buf.length > 0) { buf = buf.slice(0, -1); process.stdout.write('\b \b'); }
95
- } else if (key >= ' ') {
96
- buf += key;
97
- process.stdout.write(key);
98
- }
99
- };
100
- process.stdin.setRawMode(true);
101
- process.stdin.resume();
102
- process.stdin.setEncoding('utf8');
103
- process.stdin.on('data', onData);
104
- });
105
- }
106
-
107
- /** Read a line of input without echo (for passwords). */
108
- function promptSecret(label) {
109
- return new Promise((resolve) => {
110
- process.stdout.write(label);
111
- let buf = '';
112
- const onData = (data) => {
113
- const key = data.toString();
114
- if (key === '\r' || key === '\n') {
115
- process.stdin.removeListener('data', onData);
116
- process.stdin.setRawMode(false);
117
- process.stdin.pause();
118
- process.stdout.write('\n');
119
- resolve(buf);
120
- } else if (key === '\x7f' || key === '\b') {
121
- if (buf.length > 0) buf = buf.slice(0, -1);
122
- } else if (key >= ' ') {
123
- buf += key;
124
- }
125
- };
126
- process.stdin.setRawMode(true);
127
- process.stdin.resume();
128
- process.stdin.setEncoding('utf8');
129
- process.stdin.on('data', onData);
130
- });
131
- }
24
+ // Extracted editor modules
25
+ import { applyCodeBlockEdits } from './editor/codeBlockEdits.js';
132
26
 
133
- const TERM_WIDTH = 80;
134
- const ANSI_RE = /\x1b\[[0-9;]*m/g;
135
- const visLen = (s) => s.replace(ANSI_RE, '').length;
27
+ // Extracted command modules
28
+ import { runSetupSteps, NEXTJS_STEPS, TANSTACK_STEPS, LARAVEL_STEPS } from './commands/setup.js';
136
29
 
137
- /** Word-wrap plain text to a given column width, preserving existing newlines. */
138
- function wrapText(text, width) {
139
- return text.split('\n').map(line => {
140
- if (visLen(line) <= width) return line;
141
- const words = line.split(' ');
142
- const wrapped = [];
143
- let current = '';
144
- for (const word of words) {
145
- const test = current ? current + ' ' + word : word;
146
- if (visLen(test) <= width) {
147
- current = test;
148
- } else {
149
- if (current) wrapped.push(current);
150
- current = word;
151
- }
152
- }
153
- if (current) wrapped.push(current);
154
- return wrapped.join('\n');
155
- }).join('\n');
156
- }
157
-
158
- /** Extract numbered options (1. foo 2. bar 3. baz) from AI response text. Returns display strings or []. */
159
- function extractNumberedOptions(text) {
160
- if (!text) return [];
161
- const re = /^\s*(\d+)\.\s+(.+)$/gm;
162
- const items = [];
163
- let match;
164
- while ((match = re.exec(text)) !== null) {
165
- items.push({ num: parseInt(match[1], 10), label: match[2].trim() });
166
- }
167
- let best = [];
168
- for (let i = 0; i < items.length; i++) {
169
- if (items[i].num !== 1) continue;
170
- const run = [items[i]];
171
- for (let j = i + 1; j < items.length; j++) {
172
- if (items[j].num === run.length + 1) run.push(items[j]);
173
- else break;
174
- }
175
- if (run.length > best.length) best = run;
176
- }
177
- return best.length >= 2 ? best.map(r => `${r.num}. ${r.label}`) : [];
178
- }
179
-
180
- /** Parse fenced code blocks (```lang\n...\n```) and render them with plain styling. Non-code segments are wrapped. */
181
- function formatResponseWithCodeBlocks(text, width) {
182
- if (!text || typeof text !== 'string') return '';
183
- const re = /```(\w*)\n([\s\S]*?)```/g;
184
- const parts = [];
185
- let lastIndex = 0;
186
- let m;
187
- while ((m = re.exec(text)) !== null) {
188
- if (m.index > lastIndex) {
189
- const textSegment = text.slice(lastIndex, m.index);
190
- if (textSegment) parts.push({ type: 'text', content: textSegment });
191
- }
192
- parts.push({ type: 'code', lang: m[1], content: m[2].trim() });
193
- lastIndex = re.lastIndex;
194
- }
195
- if (lastIndex < text.length) {
196
- const textSegment = text.slice(lastIndex);
197
- if (textSegment) parts.push({ type: 'text', content: textSegment });
198
- }
199
- if (parts.length === 0) return wrapText(text, width);
200
- return parts.map((p) => {
201
- if (p.type === 'text') return wrapText(p.content, width);
202
- const label = p.lang ? p.lang : 'code';
203
- const header = chalk.dim('─── ') + chalk.cyan(label) + chalk.dim(' ' + '─'.repeat(Math.max(0, width - label.length - 5)));
204
- const code = p.content.split('\n').map(l => chalk.dim(' ') + l).join('\n');
205
- return header + '\n' + code + '\n' + chalk.dim('─'.repeat(width));
206
- }).join('\n\n');
207
- }
208
-
209
- /**
210
- * Scan AI response text for fenced code blocks that reference files.
211
- * Show a diff preview for each and apply on user confirmation.
212
- * @param {string} responseText - The AI's response content
213
- * @param {string[]} loadedFiles - Files attached via @ref
214
- */
215
- async function applyCodeBlockEdits(responseText, loadedFiles = []) {
216
- const edits = parseEdits(responseText, loadedFiles);
217
- if (edits.length === 0) return;
218
-
219
- console.log(chalk.dim(`\n📝 Detected ${edits.length} file edit(s) in response:\n`));
220
-
221
- for (const edit of edits) {
222
- renderDiff(edit.filepath, edit.content);
223
- const ok = await confirm(chalk.bold(`Apply changes to ${chalk.cyan(edit.filepath)}? [y/N] `));
224
- if (ok) {
225
- applyEdit(edit.filepath, edit.content);
226
- console.log(chalk.green(` ✓ ${edit.filepath} updated\n`));
227
- } else {
228
- console.log(chalk.dim(` ✗ skipped ${edit.filepath}\n`));
229
- }
230
- }
231
- }
232
-
233
- /** Run `ls -la` in cwd and return a context string to prepend to user messages. */
234
- async function getLsContext(cwd = process.cwd()) {
235
- try {
236
- const { stdout, stderr, exitCode } = await execCommand('ls -la', cwd);
237
- const out = [stdout, stderr].filter(Boolean).join('\n').trim();
238
- if (exitCode === 0 && out) {
239
- return `[Current directory: ${cwd}]\n$ ls -la\n${out}\n\n`;
240
- }
241
- } catch (_) {}
242
- return `[Current directory: ${cwd}]\n(listing unavailable)\n\n`;
243
- }
244
-
245
- const GREP_DEFAULT_PATTERN = 'function |class |export |def ';
246
- const GREP_MAX_LINES_DEFAULT = 400;
247
- const GREP_INCLUDE = "--include='*.js' --include='*.ts' --include='*.jsx' --include='*.tsx' --include='*.py'";
248
- /** Portable: filter paths with grep -v (BSD grep may not support --exclude-dir). */
249
- const GREP_FILTER_PATHS = "| grep -v '/node_modules/' | grep -v '/.git/' | grep -v '/.next/' | grep -v '/dist/' | grep -v '/build/' | grep -v '/coverage/'";
250
-
251
- /** Safe chars for MARKOV_GREP_PATTERN (no shell metacharacters). */
252
- function isValidGrepPattern(p) {
253
- if (typeof p !== 'string' || p.length > 200) return false;
254
- return /^[a-zA-Z0-9 \|\.\-_\\\[\]\(\)\?\+\*]+$/.test(p);
255
- }
256
-
257
- /** Run grep for key definitions; return context block or empty string. Only runs when MARKOV_GREP_CONTEXT=1. */
258
- async function getGrepContext(cwd = process.cwd()) {
259
- if (!process.env.MARKOV_GREP_CONTEXT) return '';
260
-
261
- const rawPattern = process.env.MARKOV_GREP_PATTERN;
262
- const pattern = (rawPattern && isValidGrepPattern(rawPattern.trim())) ? rawPattern.trim() : GREP_DEFAULT_PATTERN;
263
- const maxLines = Math.min(2000, Math.max(1, parseInt(process.env.MARKOV_GREP_MAX_LINES, 10) || GREP_MAX_LINES_DEFAULT));
264
- const escaped = pattern.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
265
- const cmd = `grep -rn -E "${escaped}" ${GREP_INCLUDE} . 2>/dev/null ${GREP_FILTER_PATHS} | head -n ${maxLines}`;
266
-
267
- try {
268
- const { stdout, exitCode } = await execCommand(cmd, cwd);
269
- const out = (stdout || '').trim();
270
- if (exitCode === 0 && out) {
271
- return `[Grep: key definitions in repo]\n$ grep -rn -E '${pattern}' ...\n${out}\n\n`;
272
- }
273
- } catch (_) {}
274
- return `[Grep: key definitions in repo]\n(grep context unavailable)\n\n`;
275
- }
276
-
277
- /** Shared system message base: Markov intro, cwd, file list. */
278
- function getSystemMessageBase() {
279
- const files = getFiles();
280
- const fileList = files.length > 0 ? `\nFiles in working directory:\n${files.map(f => ` ${f}`).join('\n')}\n` : '';
281
- return `You are Markov, an AI coding assistant.\nWorking directory: ${process.cwd()}\n${fileList}`;
282
- }
283
-
284
- /** System message for /plan: output a step-by-step plan only (no tools). */
285
- function buildPlanSystemMessage() {
286
- return {
287
- role: 'system',
288
- content: getSystemMessageBase() +
289
- '\nOutput a clear, step-by-step plan only. Do not run any commands or edit files—just describe the steps. No tools.',
290
- };
291
- }
292
-
293
- /** System message for agent mode: tool-only instructions (no !!run / !!write). */
294
- function buildAgentSystemMessage() {
295
- const toolInstructions =
296
- `\nTOOL MODE — you have tools; use them. \n` +
297
- `- run_terminal_command: run shell commands (npm install, npx create-next-app, etc.). One command per call.\n` +
298
- `- create_folder: create directories.\n` +
299
- `- read_file: read file contents before editing.\n` +
300
- `- write_file: create or overwrite a file with full content.\n` +
301
- `- search_replace: replace first occurrence of text in a file.\n` +
302
- `- delete_file: delete a file.\n` +
303
- `- list_dir: list directory contents (path optional, defaults to current dir).\n` +
304
- `When the user asks to run commands, create/edit/delete files, scaffold projects, or to "plan" or "start" an app (e.g. npm run dev), you MUST call the appropriate tool. Do not only describe steps in your reply.\n` +
305
- `You MUST ALWAYS use write_file or search_replace for any file modification, creation, or fix. Never paste full file contents directly in your reply.\n` +
306
- `All file operations must use RELATIVE paths.\n` +
307
- `Do not output modified file contents in chat — apply changes through tool calls only.\n`;
308
- return { role: 'system', content: getSystemMessageBase() + toolInstructions };
309
- }
310
-
311
- const AGENT_LOOP_MAX_ITERATIONS = 20;
312
-
313
- /** If MARKOV_DEBUG is set, print the full message payload (system + conversation) before sending. */
314
- function maybePrintFullPayload(messages) {
315
- if (!process.env.MARKOV_DEBUG) return;
316
- const payload = messages.map((m) => ({
317
- role: m.role,
318
- ...(m.tool_calls && { tool_calls: m.tool_calls.length }),
319
- ...(m.tool_name && { tool_name: m.tool_name }),
320
- content: typeof m.content === 'string' ? m.content : '(binary/object)',
321
- }));
322
- console.log(chalk.dim('\n--- MARKOV_DEBUG: full payload sent to API ---'));
323
- console.log(JSON.stringify(payload, null, 2));
324
- console.log(chalk.dim('--- end payload ---\n'));
325
- }
326
-
327
- /** Preview of a file edit for confirmation (write_file / search_replace) */
328
- function formatFileEditPreview(name, args) {
329
- const path = args?.path ?? '(no path)';
330
- if (name === 'search_replace') {
331
- const oldStr = String(args?.old_string ?? '');
332
- const newStr = String(args?.new_string ?? '');
333
- const max = 120;
334
- const oldPreview = oldStr.length > max ? oldStr.slice(0, max) + '…' : oldStr;
335
- const newPreview = newStr.length > max ? newStr.slice(0, max) + '…' : newStr;
336
- return (
337
- chalk.cyan(path) + '\n' +
338
- chalk.red(' - ' + (oldPreview || '(empty)').replace(/\n/g, '\n ')) + '\n' +
339
- chalk.green(' + ' + (newPreview || '(empty)').replace(/\n/g, '\n '))
340
- );
341
- }
342
- if (name === 'write_file') {
343
- const content = String(args?.content ?? '');
344
- const lines = content.split('\n');
345
- const previewLines = lines.slice(0, 25);
346
- const more = lines.length > 25 ? chalk.dim(` ... ${lines.length - 25} more lines`) : '';
347
- return chalk.cyan(path) + '\n' + previewLines.map(l => ' ' + l).join('\n') + (more ? '\n' + more : '');
348
- }
349
- return path;
350
- }
351
-
352
- /** One-line summary of tool args for display */
353
- function formatToolCallSummary(name, args) {
354
- const a = args ?? {};
355
- if (name === 'run_terminal_command') return (a.command ?? '').trim() || '(empty)';
356
- if (name === 'write_file') return a.path ?? '(no path)';
357
- if (name === 'search_replace') return (a.path ?? '') + (a.old_string ? ` "${String(a.old_string).slice(0, 30)}…"` : '');
358
- if (name === 'read_file' || name === 'delete_file') return a.path ?? '(no path)';
359
- if (name === 'create_folder') return a.path ?? '(no path)';
360
- if (name === 'list_dir') return (a.path ?? '.') || '.';
361
- return JSON.stringify(a).slice(0, 50);
362
- }
363
-
364
- /** One-line summary of tool result for display */
365
- function formatToolResultSummary(name, resultJson) {
366
- let obj;
367
- try {
368
- obj = typeof resultJson === 'string' ? JSON.parse(resultJson) : resultJson;
369
- } catch {
370
- return resultJson?.slice(0, 60) ?? '—';
371
- }
372
- if (obj.error) return chalk.red('✗ ' + obj.error);
373
- if (obj.declined) return chalk.yellow('✗ declined');
374
- if (name === 'run_terminal_command') {
375
- const code = obj.exitCode ?? obj.exit_code;
376
- if (code === 0) return chalk.green('✓ exit 0') + (obj.stdout ? chalk.dim(' ' + String(obj.stdout).trim().slice(0, 80).replace(/\n/g, ' ')) : '');
377
- return chalk.red(`✗ exit ${code}`) + (obj.stderr ? chalk.dim(' ' + String(obj.stderr).trim().slice(0, 80)) : '');
378
- }
379
- if (name === 'write_file' || name === 'search_replace' || name === 'delete_file' || name === 'create_folder') {
380
- return obj.success !== false ? chalk.green('✓ ' + (obj.path ? obj.path : 'ok')) : chalk.red('✗ ' + (obj.error || 'failed'));
381
- }
382
- if (name === 'read_file') return obj.content != null ? chalk.green('✓ ' + (obj.path ?? '') + chalk.dim(` (${String(obj.content).length} chars)`)) : chalk.red('✗ ' + (obj.error || ''));
383
- if (name === 'list_dir') return obj.entries ? chalk.green('✓ ' + (obj.entries.length ?? 0) + ' entries') : chalk.red('✗ ' + (obj.error || ''));
384
- return chalk.dim(JSON.stringify(obj).slice(0, 60));
385
- }
386
-
387
- /**
388
- * Run the agent loop: call chatWithTools, run any tool_calls locally, append results, repeat until the model returns a final response.
389
- * For write_file and search_replace, confirmFileEdit is called first; if it returns false, the change is skipped and the model is told the user declined.
390
- * @param {Array<{ role: string; content?: string; tool_calls?: unknown[]; tool_name?: string }>} messages - Full message list (system + conversation)
391
- * @param {{ signal?: AbortSignal; cwd?: string; confirmFn?: (command: string) => Promise<boolean>; confirmFileEdit?: (name: string, args: object) => Promise<boolean>; onBeforeToolRun?: () => void; onToolCall?: (name: string, args: object) => void; onToolResult?: (name: string, result: string) => void }} opts
392
- * @returns {Promise<{ content: string; finalMessage: object } | null>} Final assistant content and message, or null if cancelled/error
393
- */
394
- async function runAgentLoop(messages, opts = {}) {
395
- const cwd = opts.cwd ?? process.cwd();
396
- const confirmFn = opts.confirmFn;
397
- const confirmFileEdit = opts.confirmFileEdit;
398
- const onBeforeToolRun = opts.onBeforeToolRun;
399
- const onToolCall = opts.onToolCall;
400
- const onToolResult = opts.onToolResult;
401
- const onIteration = opts.onIteration;
402
- const onThinking = opts.onThinking;
403
- let iteration = 0;
404
-
405
- while (iteration < AGENT_LOOP_MAX_ITERATIONS) {
406
- iteration += 1;
407
- onThinking?.(iteration);
408
- const data = await chatWithTools(messages, AGENT_TOOLS, MODEL, opts.signal ?? null);
409
-
410
- const message = data?.message;
411
- if (!message) {
412
- return { content: '', finalMessage: { role: 'assistant', content: '' } };
413
- }
414
-
415
- const toolCalls = message.tool_calls;
416
- if (!toolCalls || toolCalls.length === 0) {
417
- return { content: message.content ?? '', finalMessage: message };
418
- }
419
-
420
- // Append assistant message with tool_calls
421
- messages.push({
422
- role: 'assistant',
423
- content: message.content ?? '',
424
- tool_calls: toolCalls,
425
- });
426
-
427
- onBeforeToolRun?.();
428
- onIteration?.(iteration, AGENT_LOOP_MAX_ITERATIONS, toolCalls.length);
429
-
430
- for (const tc of toolCalls) {
431
- const name = tc?.function?.name;
432
- const rawArgs = tc?.function?.arguments;
433
- let args = rawArgs;
434
- if (typeof rawArgs === 'string') {
435
- try {
436
- args = JSON.parse(rawArgs);
437
- } catch {
438
- messages.push({
439
- role: 'tool',
440
- tool_name: name ?? 'unknown',
441
- content: JSON.stringify({ error: 'Invalid JSON in arguments' }),
442
- });
443
- if (onToolCall) onToolCall(name ?? 'unknown', {});
444
- if (onToolResult) onToolResult(name ?? 'unknown', JSON.stringify({ error: 'Invalid JSON in arguments' }));
445
- continue;
446
- }
447
- }
30
+ const agentGradient = gradient(['#22c55e', '#16a34a', '#4ade80']);
448
31
 
449
- if (onToolCall) onToolCall(name ?? 'unknown', args ?? {});
32
+ const PLAN_FILE = 'plan.md';
33
+ const getPlanPath = () => resolve(process.cwd(), PLAN_FILE);
450
34
 
451
- const isFileEdit = name === 'write_file' || name === 'search_replace';
452
- let result;
453
- if (isFileEdit && confirmFileEdit) {
454
- const ok = await confirmFileEdit(name, args ?? {});
455
- if (!ok) {
456
- result = JSON.stringify({ declined: true, message: 'User declined the change' });
457
- if (onToolResult) onToolResult(name ?? 'unknown', result);
458
- messages.push({
459
- role: 'tool',
460
- tool_name: name ?? 'unknown',
461
- content: result,
462
- });
463
- continue;
464
- }
465
- }
35
+ const MARKOV_FILE = 'markov.md';
36
+ const getMarkovPath = () => resolve(process.cwd(), MARKOV_FILE);
466
37
 
467
- result = await runTool(name, args ?? {}, { cwd, confirmFn });
468
- if (onToolResult) onToolResult(name ?? 'unknown', typeof result === 'string' ? result : JSON.stringify(result));
469
- messages.push({
470
- role: 'tool',
471
- tool_name: name ?? 'unknown',
472
- content: typeof result === 'string' ? result : JSON.stringify(result),
473
- });
474
- }
475
- }
476
-
477
- return {
478
- content: '(agent loop reached max iterations)',
479
- finalMessage: { role: 'assistant', content: '(max iterations)' },
480
- };
481
- }
38
+ /** Tools allowed during /plan and /yolo plan phase: web search + run command for research only (e.g. ls, cat). */
39
+ const PLAN_YOLO_TOOLS = [WEB_SEARCH_TOOL, RUN_TERMINAL_COMMAND_TOOL];
482
40
 
483
41
  /** Short intro shown on first load; /intro re-displays this. */
484
42
  const INTRO_TEXT =
@@ -487,8 +45,9 @@ const INTRO_TEXT =
487
45
  chalk.cyan(' /help') + chalk.dim(' show all commands\n') +
488
46
  chalk.cyan(' /login') + chalk.dim(' authenticate with email & password\n') +
489
47
  chalk.cyan(' /cd [path]') + chalk.dim(' change working directory\n') +
490
- chalk.cyan(' /plan') + chalk.dim(' [prompt] stream a plan (no tools)\n') +
491
- chalk.cyan(' /build') + chalk.dim(' execute last plan with tools\n') +
48
+ chalk.cyan(' /init') + chalk.dim(' [prompt] create markov.md with project summary\n') +
49
+ chalk.cyan(' /plan') + chalk.dim(' [prompt] stream a plan and save to plan.md\n') +
50
+ chalk.cyan(' /build') + chalk.dim(' execute plan from plan.md\n') +
492
51
  chalk.cyan(' /yolo') + chalk.dim(' [prompt] plan in stream mode, then auto-run until done\n') +
493
52
  chalk.dim('\nType a message · ') + chalk.cyan('@filename') + chalk.dim(' to attach · ctrl+q to cancel\n');
494
53
 
@@ -498,66 +57,30 @@ const HELP_TEXT =
498
57
  chalk.cyan(' /intro') + chalk.dim(' show quick start (same as on first load)\n') +
499
58
  chalk.cyan(' /help') + chalk.dim(' show this help\n') +
500
59
  chalk.cyan(' /setup-nextjs') + chalk.dim(' scaffold a Next.js app\n') +
60
+ chalk.cyan(' /setup-tanstack') + chalk.dim(' scaffold a TanStack Start app\n') +
501
61
  chalk.cyan(' /setup-laravel') + chalk.dim(' scaffold a Laravel API\n') +
502
62
  chalk.cyan(' /models') + chalk.dim(' switch the active AI model\n') +
503
63
  chalk.cyan(' /cd [path]') + chalk.dim(' change working directory\n') +
504
64
  chalk.cyan(' /cmd [command]') + chalk.dim(' run a shell command in the current folder\n') +
505
65
  chalk.cyan(' /login') + chalk.dim(' authenticate with email & password\n') +
506
66
  chalk.cyan(' /logout') + chalk.dim(' clear saved auth token\n') +
67
+ chalk.cyan(' /clear') + chalk.dim(' clear chat history and stored plan\n') +
68
+ chalk.cyan(' /env') + chalk.dim(' show which .env vars are loaded (for debugging)\n') +
507
69
  chalk.cyan(' /debug') + chalk.dim(' toggle full payload dump (env MARKOV_DEBUG)\n') +
508
- chalk.cyan(' /plan') + chalk.dim(' [prompt] stream a plan (no tools)\n') +
509
- chalk.cyan(' /build') + chalk.dim(' execute last plan with tools\n') +
70
+ chalk.cyan(' /init') + chalk.dim(' [prompt] create markov.md with project summary\n') +
71
+ chalk.cyan(' /plan') + chalk.dim(' [prompt] stream a plan and save to plan.md\n') +
72
+ chalk.cyan(' /build') + chalk.dim(' execute plan from plan.md\n') +
510
73
  chalk.cyan(' /yolo') + chalk.dim(' [prompt] plan in stream mode, then auto-run until done\n') +
511
74
  chalk.dim('\nType a message · ') + chalk.cyan('@filename') + chalk.dim(' to attach · ctrl+q to cancel\n');
512
75
 
513
- /**
514
- * Run a list of setup steps (mkdir / cd / run / write) in sequence.
515
- * Returns true if all steps succeeded, false if any failed.
516
- */
517
- async function runSetupSteps(steps) {
518
- for (const step of steps) {
519
- if (step.type === 'mkdir') {
520
- process.stdout.write(chalk.dim(` mkdir: ${step.path}\n`));
521
- try {
522
- mkdirSync(resolve(process.cwd(), step.path), { recursive: true });
523
- console.log(chalk.green(` ✓ created ${step.path}\n`));
524
- } catch (err) {
525
- console.log(chalk.red(` ✗ ${err.message}\n`));
526
- return false;
527
- }
528
- } else if (step.type === 'cd') {
529
- try {
530
- process.chdir(resolve(process.cwd(), step.path));
531
- console.log(chalk.dim(` 📁 ${process.cwd()}\n`));
532
- } catch (err) {
533
- console.log(chalk.red(` ✗ no such directory: ${step.path}\n`));
534
- return false;
535
- }
536
- } else if (step.type === 'run') {
537
- process.stdout.write(chalk.dim(` running: ${step.cmd}\n`));
538
- const { stdout, stderr, exitCode } = await execCommand(step.cmd);
539
- const output = [stdout, stderr].filter(Boolean).join('\n').trim();
540
- if (output) console.log(chalk.dim(output));
541
- if (exitCode !== 0) {
542
- console.log(chalk.red(` ✗ command failed (exit ${exitCode})\n`));
543
- return false;
544
- }
545
- console.log(chalk.green(` ✓ done\n`));
546
- } else if (step.type === 'write') {
547
- process.stdout.write(chalk.dim(` write: ${step.path}\n`));
548
- try {
549
- const abs = resolve(process.cwd(), step.path);
550
- const dir = abs.split('/').slice(0, -1).join('/');
551
- mkdirSync(dir, { recursive: true });
552
- writeFileSync(abs, step.content);
553
- console.log(chalk.green(` ✓ wrote ${step.path}\n`));
554
- } catch (err) {
555
- console.log(chalk.red(` ✗ ${err.message}\n`));
556
- return false;
557
- }
558
- }
559
- }
560
- return true;
76
+ /** If MARKOV_DEBUG is set, print the raw model output after completion. */
77
+ function maybePrintRawModelOutput(rawText) {
78
+ if (!process.env.MARKOV_DEBUG || rawText == null) return;
79
+ const text = typeof rawText === 'string' ? rawText : String(rawText);
80
+ if (!text.trim()) return;
81
+ console.log(chalk.dim('\n--- MARKOV_DEBUG: raw model output ---'));
82
+ console.log(text);
83
+ console.log(chalk.dim('--- end raw output ---\n'));
561
84
  }
562
85
 
563
86
  export async function startInteractive() {
@@ -566,7 +89,7 @@ export async function startInteractive() {
566
89
  let allFiles = getFilesAndDirs();
567
90
  const chatMessages = [];
568
91
 
569
- console.log(chalk.dim(`Chat with Markov (${MODEL}).`));
92
+ console.log(chalk.dim(`Chat with Markov (${getModelDisplayName()}).`));
570
93
  console.log(INTRO_TEXT);
571
94
 
572
95
  if (!getToken()) {
@@ -575,6 +98,7 @@ export async function startInteractive() {
575
98
 
576
99
  let pendingMessage = null;
577
100
  let lastPlan = null;
101
+ const inputHistory = [];
578
102
 
579
103
  while (true) {
580
104
  let raw;
@@ -583,7 +107,7 @@ export async function startInteractive() {
583
107
  pendingMessage = null;
584
108
  console.log(chalk.magenta('you> ') + raw + '\n');
585
109
  } else {
586
- raw = await chatPrompt(chalk.magenta('you> '), allFiles);
110
+ raw = await chatPrompt(chalk.magenta('you> '), allFiles, inputHistory);
587
111
  }
588
112
  if (raw === null) continue;
589
113
  const trimmed = raw.trim();
@@ -610,6 +134,14 @@ export async function startInteractive() {
610
134
  continue;
611
135
  }
612
136
 
137
+ // /clear — clear chat history and stored plan
138
+ if (trimmed === '/clear') {
139
+ chatMessages.length = 0;
140
+ lastPlan = null;
141
+ console.log(chalk.green('✓ Chat and context cleared.\n'));
142
+ continue;
143
+ }
144
+
613
145
  // /intro — show quick start (same as on first load)
614
146
  if (trimmed === '/intro') {
615
147
  console.log(INTRO_TEXT);
@@ -634,6 +166,27 @@ export async function startInteractive() {
634
166
  continue;
635
167
  }
636
168
 
169
+ // /env — show which .env vars are loaded (for debugging)
170
+ if (trimmed === '/env') {
171
+ const mask = (v) => (v && v.length > 8 ? v.slice(0, 8) + '…' + v.slice(-4) : v ? '***' : null);
172
+ const vars = [
173
+ ['ANTHROPIC_API_KEY', process.env.ANTHROPIC_API_KEY],
174
+ ['ANTHROPIC_MODEL', process.env.ANTHROPIC_MODEL],
175
+ ['OPENAI_API_KEY', process.env.OPENAI_API_KEY],
176
+ ['OPENAI_MODEL', process.env.OPENAI_MODEL],
177
+ ['MARKOV_SEARCH_API_KEY', process.env.MARKOV_SEARCH_API_KEY],
178
+ ['MARKOV_DEBUG', process.env.MARKOV_DEBUG],
179
+ ];
180
+ console.log(chalk.dim('\nEnvironment (from .env or shell):\n'));
181
+ for (const [name, val] of vars) {
182
+ const status = val ? chalk.green('set') : chalk.red('not set');
183
+ const preview = val ? chalk.dim(' ' + mask(val)) : '';
184
+ console.log(chalk.dim(' ') + name + chalk.dim(': ') + status + preview);
185
+ }
186
+ console.log('');
187
+ continue;
188
+ }
189
+
637
190
  // /plan [prompt] — stream a plan (no tools), store as lastPlan
638
191
  if (trimmed === '/plan' || trimmed.startsWith('/plan ')) {
639
192
  const rawUserContent = trimmed.startsWith('/plan ')
@@ -666,31 +219,69 @@ export async function startInteractive() {
666
219
  }
667
220
  };
668
221
  try {
669
- const fullPlanText = await streamChat(
670
- planMessages,
671
- (token) => {
672
- if (firstContent) {
673
- clearPlanSpinner();
674
- firstContent = false;
675
- }
676
- process.stdout.write(token);
677
- },
678
- MODEL,
679
- planAbort.signal,
680
- {
681
- onThinkingToken: (token) => {
682
- if (!thinkingStarted) {
683
- clearPlanSpinner();
684
- process.stdout.write(chalk.dim('Thinking: '));
685
- thinkingStarted = true;
686
- }
687
- process.stdout.write(chalk.dim(token));
222
+ let currentPlanMessages = planMessages;
223
+ let fullPlanText = '';
224
+ const planMaxIter = 10;
225
+ for (let planIter = 0; planIter < planMaxIter; planIter++) {
226
+ const { content, toolCalls, usage } = await streamChatWithTools(
227
+ currentPlanMessages,
228
+ PLAN_YOLO_TOOLS,
229
+ MODEL,
230
+ {
231
+ think: true, // plan mode only: request thinking from backend
232
+ onContent: (token) => {
233
+ if (firstContent) {
234
+ clearPlanSpinner();
235
+ firstContent = false;
236
+ }
237
+ process.stdout.write(token);
238
+ },
239
+ onThinking: (token) => {
240
+ if (!thinkingStarted) {
241
+ clearPlanSpinner();
242
+ process.stdout.write(chalk.dim('Thinking: '));
243
+ thinkingStarted = true;
244
+ }
245
+ process.stdout.write(chalk.dim(token));
246
+ },
688
247
  },
248
+ planAbort.signal,
249
+ null
250
+ );
251
+ fullPlanText = content ?? '';
252
+ if (!toolCalls?.length) {
253
+ printTokenUsage(usage);
254
+ break;
689
255
  }
690
- );
256
+ currentPlanMessages = [...currentPlanMessages, { role: 'assistant', content: content ?? '', tool_calls: toolCalls }];
257
+ for (const tc of toolCalls) {
258
+ const name = tc?.function?.name ?? 'unknown';
259
+ let args = tc?.function?.arguments;
260
+ if (typeof args === 'string') {
261
+ try {
262
+ args = JSON.parse(args);
263
+ } catch {
264
+ currentPlanMessages.push({ role: 'tool', tool_name: name, content: JSON.stringify({ error: 'Invalid JSON in arguments' }) });
265
+ continue;
266
+ }
267
+ }
268
+ console.log(chalk.cyan(' ▶ ') + chalk.bold(name) + chalk.dim(' ') + (name === 'web_search' ? (args?.query ?? '') : (args?.command ?? '')));
269
+ const result = await runTool(name, args ?? {}, { cwd: process.cwd(), confirmFn: () => Promise.resolve(true) });
270
+ currentPlanMessages.push({
271
+ role: 'tool',
272
+ tool_name: name,
273
+ content: typeof result === 'string' ? result : JSON.stringify(result),
274
+ });
275
+ }
276
+ }
691
277
  chatMessages.push({ role: 'assistant', content: fullPlanText });
692
- lastPlan = fullPlanText;
693
- console.log('\n' + chalk.dim('Plan saved. Use /build to execute.\n'));
278
+ // Store only plan (stream content), not thinking; write to plan.md for /build.
279
+ if ((fullPlanText ?? '').trim()) {
280
+ lastPlan = fullPlanText;
281
+ writeFileSync(getPlanPath(), fullPlanText.trim(), 'utf-8');
282
+ }
283
+ console.log('\n' + chalk.dim('Plan saved to plan.md. Use ') + chalk.green('/build') + chalk.dim(' to execute.\n'));
284
+ maybePrintRawModelOutput(fullPlanText);
694
285
  } catch (err) {
695
286
  if (planSpinner) {
696
287
  clearInterval(planSpinner);
@@ -719,31 +310,65 @@ export async function startInteractive() {
719
310
  let thinkingStarted = false;
720
311
  let fullPlanText = '';
721
312
  try {
722
- fullPlanText = await streamChat(
723
- planMessages,
724
- (token) => process.stdout.write(token),
725
- MODEL,
726
- yoloAbort.signal,
727
- {
728
- onThinkingToken: (token) => {
729
- if (!thinkingStarted) {
730
- process.stdout.write(chalk.dim('Thinking: '));
731
- thinkingStarted = true;
732
- }
733
- process.stdout.write(chalk.dim(token));
313
+ let currentPlanMessages = planMessages;
314
+ const yoloPlanMaxIter = 10;
315
+ for (let planIter = 0; planIter < yoloPlanMaxIter; planIter++) {
316
+ const { content: yoloPlanContent, toolCalls, usage } = await streamChatWithTools(
317
+ currentPlanMessages,
318
+ PLAN_YOLO_TOOLS,
319
+ MODEL,
320
+ {
321
+ think: true, // plan phase: request thinking from backend
322
+ onContent: (token) => process.stdout.write(token),
323
+ onThinking: (token) => {
324
+ if (!thinkingStarted) {
325
+ process.stdout.write(chalk.dim('Thinking: '));
326
+ thinkingStarted = true;
327
+ }
328
+ process.stdout.write(chalk.dim(token));
329
+ },
734
330
  },
331
+ yoloAbort.signal,
332
+ null
333
+ );
334
+ fullPlanText = yoloPlanContent ?? '';
335
+ if (!toolCalls?.length) {
336
+ printTokenUsage(usage);
337
+ break;
735
338
  }
736
- );
339
+ currentPlanMessages = [...currentPlanMessages, { role: 'assistant', content: yoloPlanContent ?? '', tool_calls: toolCalls }];
340
+ for (const tc of toolCalls) {
341
+ const name = tc?.function?.name ?? 'unknown';
342
+ let args = tc?.function?.arguments;
343
+ if (typeof args === 'string') {
344
+ try {
345
+ args = JSON.parse(args);
346
+ } catch {
347
+ currentPlanMessages.push({ role: 'tool', tool_name: name, content: JSON.stringify({ error: 'Invalid JSON in arguments' }) });
348
+ continue;
349
+ }
350
+ }
351
+ console.log(chalk.cyan(' ▶ ') + chalk.bold(name) + chalk.dim(' ') + (name === 'web_search' ? (args?.query ?? '') : (args?.command ?? '')));
352
+ const result = await runTool(name, args ?? {}, { cwd: process.cwd(), confirmFn: () => Promise.resolve(true) });
353
+ currentPlanMessages.push({
354
+ role: 'tool',
355
+ tool_name: name,
356
+ content: typeof result === 'string' ? result : JSON.stringify(result),
357
+ });
358
+ }
359
+ }
737
360
  chatMessages.push({ role: 'assistant', content: fullPlanText });
738
- lastPlan = fullPlanText;
361
+ // Store only plan (stream content), not thinking, so build phase uses exactly this.
362
+ if ((fullPlanText ?? '').trim()) lastPlan = fullPlanText;
363
+ maybePrintRawModelOutput(fullPlanText);
739
364
  } catch (err) {
740
365
  if (!yoloAbort.signal?.aborted) console.log(chalk.red(`\n${err.message}\n`));
741
366
  continue;
742
367
  }
743
368
  const buildContent = (await getLsContext()) + (await getGrepContext()) +
744
- 'Execute the plan above using your tools. Run commands and edit files as needed. Do not only describe—use run_terminal_command, write_file, etc.';
369
+ '\n\nPlan:\n' + lastPlan + '\n\nExecute this plan using your tools. Run commands and edit files as needed.';
745
370
  chatMessages.push({ role: 'user', content: buildContent });
746
- const agentMessages = [buildAgentSystemMessage(), ...chatMessages];
371
+ const agentMessages = [buildAgentSystemMessage(), { role: 'user', content: buildContent }];
747
372
  maybePrintFullPayload(agentMessages);
748
373
  const abortController = new AbortController();
749
374
  const confirmFn = () => Promise.resolve(true);
@@ -797,11 +422,7 @@ export async function startInteractive() {
797
422
  await applyCodeBlockEdits(result.content, []);
798
423
  allFiles = getFilesAndDirs();
799
424
  console.log(chalk.green(`✓ Yolo done.`) + chalk.dim(` (${elapsed}s)\n`));
800
- const opts1 = extractNumberedOptions(result.content);
801
- if (opts1.length >= 2) {
802
- const chosen = await selectFrom(opts1, 'Select an option:');
803
- if (chosen) pendingMessage = chosen;
804
- }
425
+ maybePrintRawModelOutput(result.content);
805
426
  }
806
427
  } catch (err) {
807
428
  stopSpinner();
@@ -810,16 +431,119 @@ export async function startInteractive() {
810
431
  continue;
811
432
  }
812
433
 
813
- // /buildexecute last plan with tools
434
+ // /init [prompt] create markov.md with project summary (agent writes file via tools)
435
+ if (trimmed === '/init' || trimmed.startsWith('/init ')) {
436
+ const rawUserContent = trimmed.startsWith('/init ')
437
+ ? trimmed.slice(6).trim()
438
+ : (await promptLine(chalk.bold('Describe the project to summarize (optional): '))).trim();
439
+ const userContent = (await getLsContext()) + (await getGrepContext()) +
440
+ (rawUserContent ? `Create markov.md with a project summary. Focus on: ${rawUserContent}` : 'Create markov.md with a concise project summary.');
441
+ const initMessages = [buildInitSystemMessage(), { role: 'user', content: userContent }];
442
+ const initAbort = new AbortController();
443
+ process.stdout.write(chalk.dim('\nInit › '));
444
+ const DOTS = ['.', '..', '...'];
445
+ let dotIdx = 0;
446
+ const initStartTime = Date.now();
447
+ let initSpinner = setInterval(() => {
448
+ const elapsed = ((Date.now() - initStartTime) / 1000).toFixed(1);
449
+ process.stdout.write('\r' + chalk.dim('Init › ') + chalk.dim(elapsed + 's ') + agentGradient(DOTS[dotIdx % DOTS.length]) + ' ');
450
+ dotIdx++;
451
+ }, 400);
452
+ let thinkingStarted = false;
453
+ let firstContent = true;
454
+ const clearInitSpinner = () => {
455
+ if (initSpinner) {
456
+ clearInterval(initSpinner);
457
+ initSpinner = null;
458
+ process.stdout.write('\r\x1b[0J');
459
+ }
460
+ };
461
+ try {
462
+ let currentInitMessages = initMessages;
463
+ const initMaxIter = 10;
464
+ for (let initIter = 0; initIter < initMaxIter; initIter++) {
465
+ const { content, toolCalls, usage } = await streamChatWithTools(
466
+ currentInitMessages,
467
+ PLAN_YOLO_TOOLS,
468
+ MODEL,
469
+ {
470
+ think: true,
471
+ onContent: (token) => {
472
+ if (firstContent) {
473
+ clearInitSpinner();
474
+ firstContent = false;
475
+ }
476
+ process.stdout.write(token);
477
+ },
478
+ onThinking: (token) => {
479
+ if (!thinkingStarted) {
480
+ clearInitSpinner();
481
+ process.stdout.write(chalk.dim('Thinking: '));
482
+ thinkingStarted = true;
483
+ }
484
+ process.stdout.write(chalk.dim(token));
485
+ },
486
+ },
487
+ initAbort.signal,
488
+ null
489
+ );
490
+ if (!toolCalls?.length) {
491
+ printTokenUsage(usage);
492
+ break;
493
+ }
494
+ currentInitMessages = [...currentInitMessages, { role: 'assistant', content: content ?? '', tool_calls: toolCalls }];
495
+ for (const tc of toolCalls) {
496
+ const name = tc?.function?.name ?? 'unknown';
497
+ let args = tc?.function?.arguments;
498
+ if (typeof args === 'string') {
499
+ try {
500
+ args = JSON.parse(args);
501
+ } catch {
502
+ currentInitMessages.push({ role: 'tool', tool_name: name, content: JSON.stringify({ error: 'Invalid JSON in arguments' }) });
503
+ continue;
504
+ }
505
+ }
506
+ console.log(chalk.cyan(' ▶ ') + chalk.bold(name) + chalk.dim(' ') + (name === 'web_search' ? (args?.query ?? '') : (args?.command ?? '')));
507
+ const result = await runTool(name, args ?? {}, { cwd: process.cwd(), confirmFn: () => Promise.resolve(true) });
508
+ currentInitMessages.push({
509
+ role: 'tool',
510
+ tool_name: name,
511
+ content: typeof result === 'string' ? result : JSON.stringify(result),
512
+ });
513
+ }
514
+ }
515
+ if (existsSync(getMarkovPath())) {
516
+ console.log('\n' + chalk.green('✓ markov.md created. It will be included in the system message from now on.\n'));
517
+ } else {
518
+ console.log('\n' + chalk.yellow('Init finished but markov.md was not created. The agent may need another run or a clearer prompt.\n'));
519
+ }
520
+ } catch (err) {
521
+ if (initSpinner) {
522
+ clearInterval(initSpinner);
523
+ initSpinner = null;
524
+ process.stdout.write('\r\x1b[0J');
525
+ }
526
+ if (!initAbort.signal?.aborted) console.log(chalk.red(`\n${err.message}\n`));
527
+ }
528
+ continue;
529
+ }
530
+
531
+ // /build — execute plan from plan.md only
814
532
  if (trimmed === '/build') {
815
- if (!lastPlan || lastPlan.trim() === '') {
816
- console.log(chalk.yellow('Run /plan first to create a plan.\n'));
533
+ const planPath = getPlanPath();
534
+ if (!existsSync(planPath)) {
535
+ console.log(chalk.yellow('plan.md not found. Run /plan first to create a plan.\n'));
536
+ continue;
537
+ }
538
+ const planToUse = readFileSync(planPath, 'utf-8').trim();
539
+ if (!planToUse) {
540
+ console.log(chalk.yellow('plan.md is empty. Run /plan first to create a plan.\n'));
817
541
  continue;
818
542
  }
819
543
  const buildContent = (await getLsContext()) + (await getGrepContext()) +
820
- 'Execute the plan above using your tools. Run commands and edit files as needed. Do not only describe—use run_terminal_command, write_file, etc.';
544
+ '\n\nPlan:\n' + planToUse + '\n\nExecute this plan using your tools. Run commands and edit files as needed. ';
821
545
  chatMessages.push({ role: 'user', content: buildContent });
822
- const agentMessages = [buildAgentSystemMessage(), ...chatMessages];
546
+ const agentMessages = [buildAgentSystemMessage(), { role: 'user', content: buildContent }];
823
547
  maybePrintFullPayload(agentMessages);
824
548
  const abortController = new AbortController();
825
549
  const confirmFn = (cmd) => confirm(chalk.bold(`Run: ${cmd}? [y/N] `));
@@ -878,11 +602,7 @@ export async function startInteractive() {
878
602
  await applyCodeBlockEdits(result.content, []);
879
603
  allFiles = getFilesAndDirs();
880
604
  console.log(chalk.green(`✓ Build done.`) + chalk.dim(` (${elapsed}s)\n`));
881
- const opts1 = extractNumberedOptions(result.content);
882
- if (opts1.length >= 2) {
883
- const chosen = await selectFrom(opts1, 'Select an option:');
884
- if (chosen) pendingMessage = chosen;
885
- }
605
+ maybePrintRawModelOutput(result.content);
886
606
  }
887
607
  } catch (err) {
888
608
  stopSpinner();
@@ -891,12 +611,32 @@ export async function startInteractive() {
891
611
  continue;
892
612
  }
893
613
 
894
- // /models — pick active model
614
+ // /models — pick active model (Claude or Ollama)
895
615
  if (trimmed === '/models') {
896
- const chosen = await selectFrom(MODELS, 'Select model:');
616
+ const labels = MODEL_OPTIONS.map((o) => o.label);
617
+ const chosen = await selectFrom(labels, 'Select model:');
897
618
  if (chosen) {
898
- setModel(chosen);
899
- console.log(chalk.dim(`\n🤖 switched to ${chalk.cyan(chosen)}\n`));
619
+ const opt = MODEL_OPTIONS.find((o) => o.label === chosen);
620
+ if (opt) {
621
+ if (opt.provider === 'claude' && !getClaudeKey()) {
622
+ const enteredKey = (await promptSecret('Claude API key (paste then Enter): ')).trim();
623
+ if (!enteredKey) {
624
+ console.log(chalk.yellow('\nNo key entered. Model not switched.\n'));
625
+ continue;
626
+ }
627
+ setClaudeKey(enteredKey);
628
+ }
629
+ if (opt.provider === 'openai' && !getOpenAIKey()) {
630
+ const enteredKey = (await promptSecret('OpenAI API key (paste then Enter): ')).trim();
631
+ if (!enteredKey) {
632
+ console.log(chalk.yellow('\nNo key entered. Model not switched.\n'));
633
+ continue;
634
+ }
635
+ setOpenAIKey(enteredKey);
636
+ }
637
+ setModelAndProvider(opt.provider, opt.model);
638
+ console.log(chalk.dim(`\n🤖 switched to ${chalk.cyan(getModelDisplayName())}\n`));
639
+ }
900
640
  }
901
641
  continue;
902
642
  }
@@ -942,33 +682,27 @@ export async function startInteractive() {
942
682
  continue;
943
683
  }
944
684
 
945
- // /setup-nextjs — scaffold a Next.js app via script
685
+ // /setup-nextjs — scaffold a Next.js app
946
686
  if (trimmed === '/setup-nextjs') {
947
- const name = (await promptLine(chalk.bold('App folder name: '))).trim() || 'next-app';
948
- const steps = [
949
- { type: 'mkdir', path: name },
950
- { type: 'cd', path: name },
951
- { type: 'run', cmd: 'npx create-next-app@latest . --yes' },
952
- { type: 'run', cmd: 'npm install sass' },
953
- ];
954
- const ok = await runSetupSteps(steps);
687
+ const ok = await runSetupSteps(NEXTJS_STEPS);
688
+ allFiles = getFilesAndDirs();
689
+ if (ok) console.log(chalk.green(' Next.js app created.\n'));
690
+ continue;
691
+ }
692
+
693
+ // /setup-tanstack — scaffold a TanStack Start app
694
+ if (trimmed === '/setup-tanstack') {
695
+ const ok = await runSetupSteps(TANSTACK_STEPS);
955
696
  allFiles = getFilesAndDirs();
956
- if (ok) console.log(chalk.green(`✓ Next.js app created in ${name}.\n`));
697
+ if (ok) console.log(chalk.green('✓ TanStack Start app created.\n'));
957
698
  continue;
958
699
  }
959
700
 
960
- // /setup-laravel — scaffold a Laravel API via script
701
+ // /setup-laravel — scaffold a Laravel API
961
702
  if (trimmed === '/setup-laravel') {
962
- const name = (await promptLine(chalk.bold('App folder name: '))).trim() || 'laravel-api';
963
- const steps = [
964
- { type: 'mkdir', path: name },
965
- { type: 'cd', path: name },
966
- { type: 'run', cmd: 'composer create-project --prefer-dist laravel/laravel .' },
967
- { type: 'run', cmd: 'php artisan serve' },
968
- ];
969
- const ok = await runSetupSteps(steps);
703
+ const ok = await runSetupSteps(LARAVEL_STEPS);
970
704
  allFiles = getFilesAndDirs();
971
- if (ok) console.log(chalk.green(`✓ Laravel API created in ${name}.\n`));
705
+ if (ok) console.log(chalk.green('✓ Laravel API created.\n'));
972
706
  continue;
973
707
  }
974
708
 
@@ -1049,11 +783,7 @@ export async function startInteractive() {
1049
783
  await applyCodeBlockEdits(result.content, loaded);
1050
784
  allFiles = getFilesAndDirs();
1051
785
  console.log(chalk.green(`✓ Agent done.`) + chalk.dim(` (${elapsed}s)\n`));
1052
- const opts2 = extractNumberedOptions(result.content);
1053
- if (opts2.length >= 2) {
1054
- const chosen = await selectFrom(opts2, 'Select an option:');
1055
- if (chosen) pendingMessage = chosen;
1056
- }
786
+ maybePrintRawModelOutput(result.content);
1057
787
  }
1058
788
  } catch (err) {
1059
789
  stopSpinner();