markov-cli 1.0.10 → 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,550 +1,86 @@
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
- };
52
-
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
- }
79
-
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
- }
132
-
133
- const TERM_WIDTH = 80;
134
- const ANSI_RE = /\x1b\[[0-9;]*m/g;
135
- const visLen = (s) => s.replace(ANSI_RE, '').length;
136
-
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);
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';
409
19
 
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
- }
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';
419
23
 
420
- // Append assistant message with tool_calls
421
- messages.push({
422
- role: 'assistant',
423
- content: message.content ?? '',
424
- tool_calls: toolCalls,
425
- });
24
+ // Extracted editor modules
25
+ import { applyCodeBlockEdits } from './editor/codeBlockEdits.js';
426
26
 
427
- onBeforeToolRun?.();
428
- onIteration?.(iteration, AGENT_LOOP_MAX_ITERATIONS, toolCalls.length);
27
+ // Extracted command modules
28
+ import { runSetupSteps, NEXTJS_STEPS, TANSTACK_STEPS, LARAVEL_STEPS } from './commands/setup.js';
429
29
 
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
- }
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];
476
40
 
477
- return {
478
- content: '(agent loop reached max iterations)',
479
- finalMessage: { role: 'assistant', content: '(max iterations)' },
480
- };
481
- }
41
+ /** Short intro shown on first load; /intro re-displays this. */
42
+ const INTRO_TEXT =
43
+ '\n' +
44
+ chalk.bold('Quick start:\n') +
45
+ chalk.cyan(' /help') + chalk.dim(' show all commands\n') +
46
+ chalk.cyan(' /login') + chalk.dim(' authenticate with email & password\n') +
47
+ chalk.cyan(' /cd [path]') + chalk.dim(' change working directory\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') +
51
+ chalk.cyan(' /yolo') + chalk.dim(' [prompt] plan in stream mode, then auto-run until done\n') +
52
+ chalk.dim('\nType a message · ') + chalk.cyan('@filename') + chalk.dim(' to attach · ctrl+q to cancel\n');
482
53
 
483
54
  const HELP_TEXT =
484
55
  '\n' +
485
56
  chalk.bold('Commands:\n') +
57
+ chalk.cyan(' /intro') + chalk.dim(' show quick start (same as on first load)\n') +
486
58
  chalk.cyan(' /help') + chalk.dim(' show this help\n') +
487
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') +
488
61
  chalk.cyan(' /setup-laravel') + chalk.dim(' scaffold a Laravel API\n') +
489
62
  chalk.cyan(' /models') + chalk.dim(' switch the active AI model\n') +
490
63
  chalk.cyan(' /cd [path]') + chalk.dim(' change working directory\n') +
491
64
  chalk.cyan(' /cmd [command]') + chalk.dim(' run a shell command in the current folder\n') +
492
65
  chalk.cyan(' /login') + chalk.dim(' authenticate with email & password\n') +
493
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') +
494
69
  chalk.cyan(' /debug') + chalk.dim(' toggle full payload dump (env MARKOV_DEBUG)\n') +
495
- chalk.cyan(' /plan') + chalk.dim(' [prompt] stream a plan (no tools)\n') +
496
- 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') +
497
73
  chalk.cyan(' /yolo') + chalk.dim(' [prompt] plan in stream mode, then auto-run until done\n') +
498
74
  chalk.dim('\nType a message · ') + chalk.cyan('@filename') + chalk.dim(' to attach · ctrl+q to cancel\n');
499
75
 
500
- /**
501
- * Run a list of setup steps (mkdir / cd / run / write) in sequence.
502
- * Returns true if all steps succeeded, false if any failed.
503
- */
504
- async function runSetupSteps(steps) {
505
- for (const step of steps) {
506
- if (step.type === 'mkdir') {
507
- process.stdout.write(chalk.dim(` mkdir: ${step.path}\n`));
508
- try {
509
- mkdirSync(resolve(process.cwd(), step.path), { recursive: true });
510
- console.log(chalk.green(` ✓ created ${step.path}\n`));
511
- } catch (err) {
512
- console.log(chalk.red(` ✗ ${err.message}\n`));
513
- return false;
514
- }
515
- } else if (step.type === 'cd') {
516
- try {
517
- process.chdir(resolve(process.cwd(), step.path));
518
- console.log(chalk.dim(` 📁 ${process.cwd()}\n`));
519
- } catch (err) {
520
- console.log(chalk.red(` ✗ no such directory: ${step.path}\n`));
521
- return false;
522
- }
523
- } else if (step.type === 'run') {
524
- process.stdout.write(chalk.dim(` running: ${step.cmd}\n`));
525
- const { stdout, stderr, exitCode } = await execCommand(step.cmd);
526
- const output = [stdout, stderr].filter(Boolean).join('\n').trim();
527
- if (output) console.log(chalk.dim(output));
528
- if (exitCode !== 0) {
529
- console.log(chalk.red(` ✗ command failed (exit ${exitCode})\n`));
530
- return false;
531
- }
532
- console.log(chalk.green(` ✓ done\n`));
533
- } else if (step.type === 'write') {
534
- process.stdout.write(chalk.dim(` write: ${step.path}\n`));
535
- try {
536
- const abs = resolve(process.cwd(), step.path);
537
- const dir = abs.split('/').slice(0, -1).join('/');
538
- mkdirSync(dir, { recursive: true });
539
- writeFileSync(abs, step.content);
540
- console.log(chalk.green(` ✓ wrote ${step.path}\n`));
541
- } catch (err) {
542
- console.log(chalk.red(` ✗ ${err.message}\n`));
543
- return false;
544
- }
545
- }
546
- }
547
- 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'));
548
84
  }
549
85
 
550
86
  export async function startInteractive() {
@@ -553,8 +89,8 @@ export async function startInteractive() {
553
89
  let allFiles = getFilesAndDirs();
554
90
  const chatMessages = [];
555
91
 
556
- console.log(chalk.dim(`Chat with Markov (${MODEL}).`));
557
- console.log(HELP_TEXT);
92
+ console.log(chalk.dim(`Chat with Markov (${getModelDisplayName()}).`));
93
+ console.log(INTRO_TEXT);
558
94
 
559
95
  if (!getToken()) {
560
96
  console.log(chalk.yellow('⚠ Not logged in. Use /login to authenticate.\n'));
@@ -562,6 +98,7 @@ export async function startInteractive() {
562
98
 
563
99
  let pendingMessage = null;
564
100
  let lastPlan = null;
101
+ const inputHistory = [];
565
102
 
566
103
  while (true) {
567
104
  let raw;
@@ -570,7 +107,7 @@ export async function startInteractive() {
570
107
  pendingMessage = null;
571
108
  console.log(chalk.magenta('you> ') + raw + '\n');
572
109
  } else {
573
- raw = await chatPrompt(chalk.magenta('you> '), allFiles);
110
+ raw = await chatPrompt(chalk.magenta('you> '), allFiles, inputHistory);
574
111
  }
575
112
  if (raw === null) continue;
576
113
  const trimmed = raw.trim();
@@ -597,6 +134,20 @@ export async function startInteractive() {
597
134
  continue;
598
135
  }
599
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
+
145
+ // /intro — show quick start (same as on first load)
146
+ if (trimmed === '/intro') {
147
+ console.log(INTRO_TEXT);
148
+ continue;
149
+ }
150
+
600
151
  // /help — list all commands
601
152
  if (trimmed === '/help') {
602
153
  console.log(HELP_TEXT);
@@ -615,6 +166,27 @@ export async function startInteractive() {
615
166
  continue;
616
167
  }
617
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
+
618
190
  // /plan [prompt] — stream a plan (no tools), store as lastPlan
619
191
  if (trimmed === '/plan' || trimmed.startsWith('/plan ')) {
620
192
  const rawUserContent = trimmed.startsWith('/plan ')
@@ -647,31 +219,69 @@ export async function startInteractive() {
647
219
  }
648
220
  };
649
221
  try {
650
- const fullPlanText = await streamChat(
651
- planMessages,
652
- (token) => {
653
- if (firstContent) {
654
- clearPlanSpinner();
655
- firstContent = false;
656
- }
657
- process.stdout.write(token);
658
- },
659
- MODEL,
660
- planAbort.signal,
661
- {
662
- onThinkingToken: (token) => {
663
- if (!thinkingStarted) {
664
- clearPlanSpinner();
665
- process.stdout.write(chalk.dim('Thinking: '));
666
- thinkingStarted = true;
667
- }
668
- 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
+ },
669
247
  },
248
+ planAbort.signal,
249
+ null
250
+ );
251
+ fullPlanText = content ?? '';
252
+ if (!toolCalls?.length) {
253
+ printTokenUsage(usage);
254
+ break;
255
+ }
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
+ });
670
275
  }
671
- );
276
+ }
672
277
  chatMessages.push({ role: 'assistant', content: fullPlanText });
673
- lastPlan = fullPlanText;
674
- 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);
675
285
  } catch (err) {
676
286
  if (planSpinner) {
677
287
  clearInterval(planSpinner);
@@ -700,31 +310,65 @@ export async function startInteractive() {
700
310
  let thinkingStarted = false;
701
311
  let fullPlanText = '';
702
312
  try {
703
- fullPlanText = await streamChat(
704
- planMessages,
705
- (token) => process.stdout.write(token),
706
- MODEL,
707
- yoloAbort.signal,
708
- {
709
- onThinkingToken: (token) => {
710
- if (!thinkingStarted) {
711
- process.stdout.write(chalk.dim('Thinking: '));
712
- thinkingStarted = true;
713
- }
714
- 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
+ },
715
330
  },
331
+ yoloAbort.signal,
332
+ null
333
+ );
334
+ fullPlanText = yoloPlanContent ?? '';
335
+ if (!toolCalls?.length) {
336
+ printTokenUsage(usage);
337
+ break;
338
+ }
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
+ });
716
358
  }
717
- );
359
+ }
718
360
  chatMessages.push({ role: 'assistant', content: fullPlanText });
719
- 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);
720
364
  } catch (err) {
721
365
  if (!yoloAbort.signal?.aborted) console.log(chalk.red(`\n${err.message}\n`));
722
366
  continue;
723
367
  }
724
368
  const buildContent = (await getLsContext()) + (await getGrepContext()) +
725
- '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.';
726
370
  chatMessages.push({ role: 'user', content: buildContent });
727
- const agentMessages = [buildAgentSystemMessage(), ...chatMessages];
371
+ const agentMessages = [buildAgentSystemMessage(), { role: 'user', content: buildContent }];
728
372
  maybePrintFullPayload(agentMessages);
729
373
  const abortController = new AbortController();
730
374
  const confirmFn = () => Promise.resolve(true);
@@ -778,11 +422,7 @@ export async function startInteractive() {
778
422
  await applyCodeBlockEdits(result.content, []);
779
423
  allFiles = getFilesAndDirs();
780
424
  console.log(chalk.green(`✓ Yolo done.`) + chalk.dim(` (${elapsed}s)\n`));
781
- const opts1 = extractNumberedOptions(result.content);
782
- if (opts1.length >= 2) {
783
- const chosen = await selectFrom(opts1, 'Select an option:');
784
- if (chosen) pendingMessage = chosen;
785
- }
425
+ maybePrintRawModelOutput(result.content);
786
426
  }
787
427
  } catch (err) {
788
428
  stopSpinner();
@@ -791,16 +431,119 @@ export async function startInteractive() {
791
431
  continue;
792
432
  }
793
433
 
794
- // /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
795
532
  if (trimmed === '/build') {
796
- if (!lastPlan || lastPlan.trim() === '') {
797
- 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'));
798
541
  continue;
799
542
  }
800
543
  const buildContent = (await getLsContext()) + (await getGrepContext()) +
801
- '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. ';
802
545
  chatMessages.push({ role: 'user', content: buildContent });
803
- const agentMessages = [buildAgentSystemMessage(), ...chatMessages];
546
+ const agentMessages = [buildAgentSystemMessage(), { role: 'user', content: buildContent }];
804
547
  maybePrintFullPayload(agentMessages);
805
548
  const abortController = new AbortController();
806
549
  const confirmFn = (cmd) => confirm(chalk.bold(`Run: ${cmd}? [y/N] `));
@@ -859,11 +602,7 @@ export async function startInteractive() {
859
602
  await applyCodeBlockEdits(result.content, []);
860
603
  allFiles = getFilesAndDirs();
861
604
  console.log(chalk.green(`✓ Build done.`) + chalk.dim(` (${elapsed}s)\n`));
862
- const opts1 = extractNumberedOptions(result.content);
863
- if (opts1.length >= 2) {
864
- const chosen = await selectFrom(opts1, 'Select an option:');
865
- if (chosen) pendingMessage = chosen;
866
- }
605
+ maybePrintRawModelOutput(result.content);
867
606
  }
868
607
  } catch (err) {
869
608
  stopSpinner();
@@ -872,12 +611,32 @@ export async function startInteractive() {
872
611
  continue;
873
612
  }
874
613
 
875
- // /models — pick active model
614
+ // /models — pick active model (Claude or Ollama)
876
615
  if (trimmed === '/models') {
877
- const chosen = await selectFrom(MODELS, 'Select model:');
616
+ const labels = MODEL_OPTIONS.map((o) => o.label);
617
+ const chosen = await selectFrom(labels, 'Select model:');
878
618
  if (chosen) {
879
- setModel(chosen);
880
- 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
+ }
881
640
  }
882
641
  continue;
883
642
  }
@@ -923,33 +682,27 @@ export async function startInteractive() {
923
682
  continue;
924
683
  }
925
684
 
926
- // /setup-nextjs — scaffold a Next.js app via script
685
+ // /setup-nextjs — scaffold a Next.js app
927
686
  if (trimmed === '/setup-nextjs') {
928
- const name = (await promptLine(chalk.bold('App folder name: '))).trim() || 'next-app';
929
- const steps = [
930
- { type: 'mkdir', path: name },
931
- { type: 'cd', path: name },
932
- { type: 'run', cmd: 'npx create-next-app@latest . --yes' },
933
- { type: 'run', cmd: 'npm install sass' },
934
- ];
935
- const ok = await runSetupSteps(steps);
687
+ const ok = await runSetupSteps(NEXTJS_STEPS);
936
688
  allFiles = getFilesAndDirs();
937
- if (ok) console.log(chalk.green(`✓ Next.js app created in ${name}.\n`));
689
+ if (ok) console.log(chalk.green('✓ Next.js app created.\n'));
938
690
  continue;
939
691
  }
940
692
 
941
- // /setup-laravel — scaffold a Laravel API via script
693
+ // /setup-tanstack — scaffold a TanStack Start app
694
+ if (trimmed === '/setup-tanstack') {
695
+ const ok = await runSetupSteps(TANSTACK_STEPS);
696
+ allFiles = getFilesAndDirs();
697
+ if (ok) console.log(chalk.green('✓ TanStack Start app created.\n'));
698
+ continue;
699
+ }
700
+
701
+ // /setup-laravel — scaffold a Laravel API
942
702
  if (trimmed === '/setup-laravel') {
943
- const name = (await promptLine(chalk.bold('App folder name: '))).trim() || 'laravel-api';
944
- const steps = [
945
- { type: 'mkdir', path: name },
946
- { type: 'cd', path: name },
947
- { type: 'run', cmd: 'composer create-project --prefer-dist laravel/laravel .' },
948
- { type: 'run', cmd: 'php artisan serve' },
949
- ];
950
- const ok = await runSetupSteps(steps);
703
+ const ok = await runSetupSteps(LARAVEL_STEPS);
951
704
  allFiles = getFilesAndDirs();
952
- if (ok) console.log(chalk.green(`✓ Laravel API created in ${name}.\n`));
705
+ if (ok) console.log(chalk.green('✓ Laravel API created.\n'));
953
706
  continue;
954
707
  }
955
708
 
@@ -1030,11 +783,7 @@ export async function startInteractive() {
1030
783
  await applyCodeBlockEdits(result.content, loaded);
1031
784
  allFiles = getFilesAndDirs();
1032
785
  console.log(chalk.green(`✓ Agent done.`) + chalk.dim(` (${elapsed}s)\n`));
1033
- const opts2 = extractNumberedOptions(result.content);
1034
- if (opts2.length >= 2) {
1035
- const chosen = await selectFrom(opts2, 'Select an option:');
1036
- if (chosen) pendingMessage = chosen;
1037
- }
786
+ maybePrintRawModelOutput(result.content);
1038
787
  }
1039
788
  } catch (err) {
1040
789
  stopSpinner();