markov-cli 1.0.11 → 1.0.13

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, getOllamaKey, setOllamaKey } 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
- }
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';
106
19
 
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
- }
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';
132
23
 
133
- const TERM_WIDTH = 80;
134
- const ANSI_RE = /\x1b\[[0-9;]*m/g;
135
- const visLen = (s) => s.replace(ANSI_RE, '').length;
24
+ // Extracted editor modules
25
+ import { applyCodeBlockEdits } from './editor/codeBlockEdits.js';
136
26
 
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
- }
27
+ // Extracted command modules
28
+ import { runSetupSteps, NEXTJS_STEPS, TANSTACK_STEPS, LARAVEL_STEPS, LARAVEL_BLOG_PROMPT } from './commands/setup.js';
292
29
 
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
- }
448
-
449
- if (onToolCall) onToolCall(name ?? 'unknown', args ?? {});
30
+ const agentGradient = gradient(['#22c55e', '#16a34a', '#4ade80']);
450
31
 
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
- }
32
+ const PLAN_FILE = 'plan.md';
33
+ const getPlanPath = () => resolve(process.cwd(), PLAN_FILE);
466
34
 
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
- }
35
+ const MARKOV_FILE = 'markov.md';
36
+ const getMarkovPath = () => resolve(process.cwd(), MARKOV_FILE);
476
37
 
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,9 +45,10 @@ 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') +
492
- chalk.cyan(' /yolo') + chalk.dim(' [prompt] plan in stream mode, then auto-run until done\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') +
493
52
  chalk.dim('\nType a message Ā· ') + chalk.cyan('@filename') + chalk.dim(' to attach Ā· ctrl+q to cancel\n');
494
53
 
495
54
  const HELP_TEXT =
@@ -498,66 +57,31 @@ 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') +
62
+ chalk.cyan(' /laravel') + chalk.dim(' set up Laravel "my-blog" with blog route (agent)\n') +
502
63
  chalk.cyan(' /models') + chalk.dim(' switch the active AI model\n') +
503
64
  chalk.cyan(' /cd [path]') + chalk.dim(' change working directory\n') +
504
65
  chalk.cyan(' /cmd [command]') + chalk.dim(' run a shell command in the current folder\n') +
505
66
  chalk.cyan(' /login') + chalk.dim(' authenticate with email & password\n') +
506
67
  chalk.cyan(' /logout') + chalk.dim(' clear saved auth token\n') +
68
+ chalk.cyan(' /clear') + chalk.dim(' clear chat history and stored plan\n') +
69
+ chalk.cyan(' /env') + chalk.dim(' show which .env vars are loaded (for debugging)\n') +
507
70
  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') +
71
+ chalk.cyan(' /init') + chalk.dim(' [prompt] create markov.md with project summary\n') +
72
+ chalk.cyan(' /plan') + chalk.dim(' [prompt] stream a plan and save to plan.md\n') +
73
+ chalk.cyan(' /build') + chalk.dim(' execute plan from plan.md\n') +
510
74
  chalk.cyan(' /yolo') + chalk.dim(' [prompt] plan in stream mode, then auto-run until done\n') +
511
75
  chalk.dim('\nType a message Ā· ') + chalk.cyan('@filename') + chalk.dim(' to attach Ā· ctrl+q to cancel\n');
512
76
 
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;
77
+ /** If MARKOV_DEBUG is set, print the raw model output after completion. */
78
+ function maybePrintRawModelOutput(rawText) {
79
+ if (!process.env.MARKOV_DEBUG || rawText == null) return;
80
+ const text = typeof rawText === 'string' ? rawText : String(rawText);
81
+ if (!text.trim()) return;
82
+ console.log(chalk.dim('\n--- MARKOV_DEBUG: raw model output ---'));
83
+ console.log(text);
84
+ console.log(chalk.dim('--- end raw output ---\n'));
561
85
  }
562
86
 
563
87
  export async function startInteractive() {
@@ -566,7 +90,7 @@ export async function startInteractive() {
566
90
  let allFiles = getFilesAndDirs();
567
91
  const chatMessages = [];
568
92
 
569
- console.log(chalk.dim(`Chat with Markov (${MODEL}).`));
93
+ console.log(chalk.dim(`Chat with Markov (${getModelDisplayName()}).`));
570
94
  console.log(INTRO_TEXT);
571
95
 
572
96
  if (!getToken()) {
@@ -575,6 +99,7 @@ export async function startInteractive() {
575
99
 
576
100
  let pendingMessage = null;
577
101
  let lastPlan = null;
102
+ const inputHistory = [];
578
103
 
579
104
  while (true) {
580
105
  let raw;
@@ -583,7 +108,7 @@ export async function startInteractive() {
583
108
  pendingMessage = null;
584
109
  console.log(chalk.magenta('you> ') + raw + '\n');
585
110
  } else {
586
- raw = await chatPrompt(chalk.magenta('you> '), allFiles);
111
+ raw = await chatPrompt(chalk.magenta('you> '), allFiles, inputHistory);
587
112
  }
588
113
  if (raw === null) continue;
589
114
  const trimmed = raw.trim();
@@ -610,6 +135,14 @@ export async function startInteractive() {
610
135
  continue;
611
136
  }
612
137
 
138
+ // /clear — clear chat history and stored plan
139
+ if (trimmed === '/clear') {
140
+ chatMessages.length = 0;
141
+ lastPlan = null;
142
+ console.log(chalk.green('āœ“ Chat and context cleared.\n'));
143
+ continue;
144
+ }
145
+
613
146
  // /intro — show quick start (same as on first load)
614
147
  if (trimmed === '/intro') {
615
148
  console.log(INTRO_TEXT);
@@ -634,6 +167,27 @@ export async function startInteractive() {
634
167
  continue;
635
168
  }
636
169
 
170
+ // /env — show which .env vars are loaded (for debugging)
171
+ if (trimmed === '/env') {
172
+ const mask = (v) => (v && v.length > 8 ? v.slice(0, 8) + '…' + v.slice(-4) : v ? '***' : null);
173
+ const vars = [
174
+ ['ANTHROPIC_API_KEY', process.env.ANTHROPIC_API_KEY],
175
+ ['ANTHROPIC_MODEL', process.env.ANTHROPIC_MODEL],
176
+ ['OPENAI_API_KEY', process.env.OPENAI_API_KEY],
177
+ ['OPENAI_MODEL', process.env.OPENAI_MODEL],
178
+ ['MARKOV_SEARCH_API_KEY', process.env.MARKOV_SEARCH_API_KEY],
179
+ ['MARKOV_DEBUG', process.env.MARKOV_DEBUG],
180
+ ];
181
+ console.log(chalk.dim('\nEnvironment (from .env or shell):\n'));
182
+ for (const [name, val] of vars) {
183
+ const status = val ? chalk.green('set') : chalk.red('not set');
184
+ const preview = val ? chalk.dim(' ' + mask(val)) : '';
185
+ console.log(chalk.dim(' ') + name + chalk.dim(': ') + status + preview);
186
+ }
187
+ console.log('');
188
+ continue;
189
+ }
190
+
637
191
  // /plan [prompt] — stream a plan (no tools), store as lastPlan
638
192
  if (trimmed === '/plan' || trimmed.startsWith('/plan ')) {
639
193
  const rawUserContent = trimmed.startsWith('/plan ')
@@ -643,6 +197,7 @@ export async function startInteractive() {
643
197
  console.log(chalk.yellow('No prompt given.\n'));
644
198
  continue;
645
199
  }
200
+ console.log(chalk.dim('You: ') + rawUserContent);
646
201
  const userContent = (await getLsContext()) + (await getGrepContext()) + 'Create a step-by-step plan for: ' + rawUserContent;
647
202
  chatMessages.push({ role: 'user', content: userContent });
648
203
  const planMessages = [buildPlanSystemMessage(), ...chatMessages];
@@ -666,31 +221,69 @@ export async function startInteractive() {
666
221
  }
667
222
  };
668
223
  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));
224
+ let currentPlanMessages = planMessages;
225
+ let fullPlanText = '';
226
+ const planMaxIter = 10;
227
+ for (let planIter = 0; planIter < planMaxIter; planIter++) {
228
+ const { content, toolCalls, usage } = await streamChatWithTools(
229
+ currentPlanMessages,
230
+ PLAN_YOLO_TOOLS,
231
+ MODEL,
232
+ {
233
+ think: true, // plan mode only: request thinking from backend
234
+ onContent: (token) => {
235
+ if (firstContent) {
236
+ clearPlanSpinner();
237
+ firstContent = false;
238
+ }
239
+ process.stdout.write(token);
240
+ },
241
+ onThinking: (token) => {
242
+ if (!thinkingStarted) {
243
+ clearPlanSpinner();
244
+ process.stdout.write(chalk.dim('Thinking: '));
245
+ thinkingStarted = true;
246
+ }
247
+ process.stdout.write(chalk.dim(token));
248
+ },
688
249
  },
250
+ planAbort.signal,
251
+ null
252
+ );
253
+ fullPlanText = content ?? '';
254
+ if (!toolCalls?.length) {
255
+ printTokenUsage(usage);
256
+ break;
257
+ }
258
+ currentPlanMessages = [...currentPlanMessages, { role: 'assistant', content: content ?? '', tool_calls: toolCalls }];
259
+ for (const tc of toolCalls) {
260
+ const name = tc?.function?.name ?? 'unknown';
261
+ let args = tc?.function?.arguments;
262
+ if (typeof args === 'string') {
263
+ try {
264
+ args = JSON.parse(args);
265
+ } catch {
266
+ currentPlanMessages.push({ role: 'tool', tool_name: name, content: JSON.stringify({ error: 'Invalid JSON in arguments' }) });
267
+ continue;
268
+ }
269
+ }
270
+ console.log(chalk.cyan('\n ā–¶ ') + chalk.bold(name) + chalk.dim(' ') + (name === 'web_search' ? (args?.query ?? '') : (args?.command ?? '')));
271
+ const result = await runTool(name, args ?? {}, { cwd: process.cwd(), confirmFn: () => Promise.resolve(true) });
272
+ currentPlanMessages.push({
273
+ role: 'tool',
274
+ tool_name: name,
275
+ content: typeof result === 'string' ? result : JSON.stringify(result),
276
+ });
689
277
  }
690
- );
278
+ }
691
279
  chatMessages.push({ role: 'assistant', content: fullPlanText });
692
- lastPlan = fullPlanText;
693
- console.log('\n' + chalk.dim('Plan saved. Use /build to execute.\n'));
280
+ // Store only plan (stream content), not thinking; write to plan.md for /build.
281
+ if ((fullPlanText ?? '').trim()) {
282
+ lastPlan = fullPlanText;
283
+ writeFileSync(getPlanPath(), fullPlanText.trim(), 'utf-8');
284
+ }
285
+ console.log('\n' + chalk.dim('Plan saved to plan.md. Use ') + chalk.green('/build') + chalk.dim(' to execute.\n'));
286
+ maybePrintRawModelOutput(fullPlanText);
694
287
  } catch (err) {
695
288
  if (planSpinner) {
696
289
  clearInterval(planSpinner);
@@ -711,6 +304,7 @@ export async function startInteractive() {
711
304
  console.log(chalk.yellow('No prompt given.\n'));
712
305
  continue;
713
306
  }
307
+ console.log(chalk.dim('You: ') + rawUserContent);
714
308
  const planUserContent = (await getLsContext()) + (await getGrepContext()) + 'Create a step-by-step plan for: ' + rawUserContent;
715
309
  chatMessages.push({ role: 'user', content: planUserContent });
716
310
  const planMessages = [buildPlanSystemMessage(), ...chatMessages];
@@ -719,31 +313,65 @@ export async function startInteractive() {
719
313
  let thinkingStarted = false;
720
314
  let fullPlanText = '';
721
315
  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));
316
+ let currentPlanMessages = planMessages;
317
+ const yoloPlanMaxIter = 10;
318
+ for (let planIter = 0; planIter < yoloPlanMaxIter; planIter++) {
319
+ const { content: yoloPlanContent, toolCalls, usage } = await streamChatWithTools(
320
+ currentPlanMessages,
321
+ PLAN_YOLO_TOOLS,
322
+ MODEL,
323
+ {
324
+ think: true, // plan phase: request thinking from backend
325
+ onContent: (token) => process.stdout.write(token),
326
+ onThinking: (token) => {
327
+ if (!thinkingStarted) {
328
+ process.stdout.write(chalk.dim('Thinking: '));
329
+ thinkingStarted = true;
330
+ }
331
+ process.stdout.write(chalk.dim(token));
332
+ },
734
333
  },
334
+ yoloAbort.signal,
335
+ null
336
+ );
337
+ fullPlanText = yoloPlanContent ?? '';
338
+ if (!toolCalls?.length) {
339
+ printTokenUsage(usage);
340
+ break;
735
341
  }
736
- );
342
+ currentPlanMessages = [...currentPlanMessages, { role: 'assistant', content: yoloPlanContent ?? '', tool_calls: toolCalls }];
343
+ for (const tc of toolCalls) {
344
+ const name = tc?.function?.name ?? 'unknown';
345
+ let args = tc?.function?.arguments;
346
+ if (typeof args === 'string') {
347
+ try {
348
+ args = JSON.parse(args);
349
+ } catch {
350
+ currentPlanMessages.push({ role: 'tool', tool_name: name, content: JSON.stringify({ error: 'Invalid JSON in arguments' }) });
351
+ continue;
352
+ }
353
+ }
354
+ console.log(chalk.cyan('\n ā–¶ ') + chalk.bold(name) + chalk.dim(' ') + (name === 'web_search' ? (args?.query ?? '') : (args?.command ?? '')));
355
+ const result = await runTool(name, args ?? {}, { cwd: process.cwd(), confirmFn: () => Promise.resolve(true) });
356
+ currentPlanMessages.push({
357
+ role: 'tool',
358
+ tool_name: name,
359
+ content: typeof result === 'string' ? result : JSON.stringify(result),
360
+ });
361
+ }
362
+ }
737
363
  chatMessages.push({ role: 'assistant', content: fullPlanText });
738
- lastPlan = fullPlanText;
364
+ // Store only plan (stream content), not thinking, so build phase uses exactly this.
365
+ if ((fullPlanText ?? '').trim()) lastPlan = fullPlanText;
366
+ maybePrintRawModelOutput(fullPlanText);
739
367
  } catch (err) {
740
368
  if (!yoloAbort.signal?.aborted) console.log(chalk.red(`\n${err.message}\n`));
741
369
  continue;
742
370
  }
743
371
  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.';
372
+ '\n\nPlan:\n' + lastPlan + '\n\nExecute this plan using your tools. Run commands and edit files as needed.';
745
373
  chatMessages.push({ role: 'user', content: buildContent });
746
- const agentMessages = [buildAgentSystemMessage(), ...chatMessages];
374
+ const agentMessages = [buildAgentSystemMessage(), { role: 'user', content: buildContent }];
747
375
  maybePrintFullPayload(agentMessages);
748
376
  const abortController = new AbortController();
749
377
  const confirmFn = () => Promise.resolve(true);
@@ -782,7 +410,7 @@ export async function startInteractive() {
782
410
  },
783
411
  onToolCall: (name, args) => {
784
412
  const summary = formatToolCallSummary(name, args);
785
- console.log(chalk.cyan(' ā–¶ ') + chalk.bold(name) + chalk.dim(' ') + chalk.white(summary));
413
+ console.log(chalk.cyan('\n ā–¶ ') + chalk.bold(name) + chalk.dim(' ') + chalk.white(summary));
786
414
  },
787
415
  onToolResult: (name, resultStr) => {
788
416
  console.log(chalk.dim(' ') + formatToolResultSummary(name, resultStr));
@@ -797,11 +425,7 @@ export async function startInteractive() {
797
425
  await applyCodeBlockEdits(result.content, []);
798
426
  allFiles = getFilesAndDirs();
799
427
  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
- }
428
+ maybePrintRawModelOutput(result.content);
805
429
  }
806
430
  } catch (err) {
807
431
  stopSpinner();
@@ -810,16 +434,119 @@ export async function startInteractive() {
810
434
  continue;
811
435
  }
812
436
 
813
- // /build — execute last plan with tools
437
+ // /init [prompt] — create markov.md with project summary (agent writes file via tools)
438
+ if (trimmed === '/init' || trimmed.startsWith('/init ')) {
439
+ const rawUserContent = trimmed.startsWith('/init ')
440
+ ? trimmed.slice(6).trim()
441
+ : (await promptLine(chalk.bold('Describe the project to summarize (optional): '))).trim();
442
+ const userContent = (await getLsContext()) + (await getGrepContext()) +
443
+ (rawUserContent ? `Create markov.md with a project summary. Focus on: ${rawUserContent}` : 'Create markov.md with a concise project summary.');
444
+ const initMessages = [buildInitSystemMessage(), { role: 'user', content: userContent }];
445
+ const initAbort = new AbortController();
446
+ process.stdout.write(chalk.dim('\nInit › '));
447
+ const DOTS = ['.', '..', '...'];
448
+ let dotIdx = 0;
449
+ const initStartTime = Date.now();
450
+ let initSpinner = setInterval(() => {
451
+ const elapsed = ((Date.now() - initStartTime) / 1000).toFixed(1);
452
+ process.stdout.write('\r' + chalk.dim('Init › ') + chalk.dim(elapsed + 's ') + agentGradient(DOTS[dotIdx % DOTS.length]) + ' ');
453
+ dotIdx++;
454
+ }, 400);
455
+ let thinkingStarted = false;
456
+ let firstContent = true;
457
+ const clearInitSpinner = () => {
458
+ if (initSpinner) {
459
+ clearInterval(initSpinner);
460
+ initSpinner = null;
461
+ process.stdout.write('\r\x1b[0J');
462
+ }
463
+ };
464
+ try {
465
+ let currentInitMessages = initMessages;
466
+ const initMaxIter = 10;
467
+ for (let initIter = 0; initIter < initMaxIter; initIter++) {
468
+ const { content, toolCalls, usage } = await streamChatWithTools(
469
+ currentInitMessages,
470
+ PLAN_YOLO_TOOLS,
471
+ MODEL,
472
+ {
473
+ think: true,
474
+ onContent: (token) => {
475
+ if (firstContent) {
476
+ clearInitSpinner();
477
+ firstContent = false;
478
+ }
479
+ process.stdout.write(token);
480
+ },
481
+ onThinking: (token) => {
482
+ if (!thinkingStarted) {
483
+ clearInitSpinner();
484
+ process.stdout.write(chalk.dim('Thinking: '));
485
+ thinkingStarted = true;
486
+ }
487
+ process.stdout.write(chalk.dim(token));
488
+ },
489
+ },
490
+ initAbort.signal,
491
+ null
492
+ );
493
+ if (!toolCalls?.length) {
494
+ printTokenUsage(usage);
495
+ break;
496
+ }
497
+ currentInitMessages = [...currentInitMessages, { role: 'assistant', content: content ?? '', tool_calls: toolCalls }];
498
+ for (const tc of toolCalls) {
499
+ const name = tc?.function?.name ?? 'unknown';
500
+ let args = tc?.function?.arguments;
501
+ if (typeof args === 'string') {
502
+ try {
503
+ args = JSON.parse(args);
504
+ } catch {
505
+ currentInitMessages.push({ role: 'tool', tool_name: name, content: JSON.stringify({ error: 'Invalid JSON in arguments' }) });
506
+ continue;
507
+ }
508
+ }
509
+ console.log(chalk.cyan('\n ā–¶ ') + chalk.bold(name) + chalk.dim(' ') + (name === 'web_search' ? (args?.query ?? '') : (args?.command ?? '')));
510
+ const result = await runTool(name, args ?? {}, { cwd: process.cwd(), confirmFn: () => Promise.resolve(true) });
511
+ currentInitMessages.push({
512
+ role: 'tool',
513
+ tool_name: name,
514
+ content: typeof result === 'string' ? result : JSON.stringify(result),
515
+ });
516
+ }
517
+ }
518
+ if (existsSync(getMarkovPath())) {
519
+ console.log('\n' + chalk.green('āœ“ markov.md created. It will be included in the system message from now on.\n'));
520
+ } else {
521
+ console.log('\n' + chalk.yellow('Init finished but markov.md was not created. The agent may need another run or a clearer prompt.\n'));
522
+ }
523
+ } catch (err) {
524
+ if (initSpinner) {
525
+ clearInterval(initSpinner);
526
+ initSpinner = null;
527
+ process.stdout.write('\r\x1b[0J');
528
+ }
529
+ if (!initAbort.signal?.aborted) console.log(chalk.red(`\n${err.message}\n`));
530
+ }
531
+ continue;
532
+ }
533
+
534
+ // /build — execute plan from plan.md only
814
535
  if (trimmed === '/build') {
815
- if (!lastPlan || lastPlan.trim() === '') {
816
- console.log(chalk.yellow('Run /plan first to create a plan.\n'));
536
+ const planPath = getPlanPath();
537
+ if (!existsSync(planPath)) {
538
+ console.log(chalk.yellow('plan.md not found. Run /plan first to create a plan.\n'));
539
+ continue;
540
+ }
541
+ const planToUse = readFileSync(planPath, 'utf-8').trim();
542
+ if (!planToUse) {
543
+ console.log(chalk.yellow('plan.md is empty. Run /plan first to create a plan.\n'));
817
544
  continue;
818
545
  }
819
546
  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.';
547
+ '\n\nPlan:\n' + planToUse + '\n\nExecute this plan using your tools. Run commands and edit files as needed. ';
821
548
  chatMessages.push({ role: 'user', content: buildContent });
822
- const agentMessages = [buildAgentSystemMessage(), ...chatMessages];
549
+ const agentMessages = [buildAgentSystemMessage(), { role: 'user', content: buildContent }];
823
550
  maybePrintFullPayload(agentMessages);
824
551
  const abortController = new AbortController();
825
552
  const confirmFn = (cmd) => confirm(chalk.bold(`Run: ${cmd}? [y/N] `));
@@ -863,7 +590,7 @@ export async function startInteractive() {
863
590
  },
864
591
  onToolCall: (name, args) => {
865
592
  const summary = formatToolCallSummary(name, args);
866
- console.log(chalk.cyan(' ā–¶ ') + chalk.bold(name) + chalk.dim(' ') + chalk.white(summary));
593
+ console.log(chalk.cyan('\n ā–¶ ') + chalk.bold(name) + chalk.dim(' ') + chalk.white(summary));
867
594
  },
868
595
  onToolResult: (name, resultStr) => {
869
596
  console.log(chalk.dim(' ') + formatToolResultSummary(name, resultStr));
@@ -878,11 +605,7 @@ export async function startInteractive() {
878
605
  await applyCodeBlockEdits(result.content, []);
879
606
  allFiles = getFilesAndDirs();
880
607
  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
- }
608
+ maybePrintRawModelOutput(result.content);
886
609
  }
887
610
  } catch (err) {
888
611
  stopSpinner();
@@ -891,12 +614,40 @@ export async function startInteractive() {
891
614
  continue;
892
615
  }
893
616
 
894
- // /models — pick active model
617
+ // /models — pick active model (Claude or Ollama)
895
618
  if (trimmed === '/models') {
896
- const chosen = await selectFrom(MODELS, 'Select model:');
619
+ const labels = MODEL_OPTIONS.map((o) => o.label);
620
+ const chosen = await selectFrom(labels, 'Select model:');
897
621
  if (chosen) {
898
- setModel(chosen);
899
- console.log(chalk.dim(`\nšŸ¤– switched to ${chalk.cyan(chosen)}\n`));
622
+ const opt = MODEL_OPTIONS.find((o) => o.label === chosen);
623
+ if (opt) {
624
+ if (opt.provider === 'claude' && !getClaudeKey()) {
625
+ const enteredKey = (await promptSecret('Claude API key (paste then Enter): ')).trim();
626
+ if (!enteredKey) {
627
+ console.log(chalk.yellow('\nNo key entered. Model not switched.\n'));
628
+ continue;
629
+ }
630
+ setClaudeKey(enteredKey);
631
+ }
632
+ if (opt.provider === 'openai' && !getOpenAIKey()) {
633
+ const enteredKey = (await promptSecret('OpenAI API key (paste then Enter): ')).trim();
634
+ if (!enteredKey) {
635
+ console.log(chalk.yellow('\nNo key entered. Model not switched.\n'));
636
+ continue;
637
+ }
638
+ setOpenAIKey(enteredKey);
639
+ }
640
+ if (opt.provider === 'ollama' && opt.model.endsWith('-cloud') && !getOllamaKey()) {
641
+ const enteredKey = (await promptSecret('Ollama API key for cloud models (paste then Enter): ')).trim();
642
+ if (!enteredKey) {
643
+ console.log(chalk.yellow('\nNo key entered. Model not switched.\n'));
644
+ continue;
645
+ }
646
+ setOllamaKey(enteredKey);
647
+ }
648
+ setModelAndProvider(opt.provider, opt.model);
649
+ console.log(chalk.dim(`\nšŸ¤– switched to ${chalk.cyan(getModelDisplayName())}\n`));
650
+ }
900
651
  }
901
652
  continue;
902
653
  }
@@ -942,33 +693,94 @@ export async function startInteractive() {
942
693
  continue;
943
694
  }
944
695
 
945
- // /setup-nextjs — scaffold a Next.js app via script
696
+ // /setup-nextjs — scaffold a Next.js app
946
697
  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);
698
+ const ok = await runSetupSteps(NEXTJS_STEPS);
955
699
  allFiles = getFilesAndDirs();
956
- if (ok) console.log(chalk.green(`āœ“ Next.js app created in ${name}.\n`));
700
+ if (ok) console.log(chalk.green('āœ“ Next.js app created.\n'));
957
701
  continue;
958
702
  }
959
703
 
960
- // /setup-laravel — scaffold a Laravel API via script
704
+ // /setup-tanstack — scaffold a TanStack Start app
705
+ if (trimmed === '/setup-tanstack') {
706
+ const ok = await runSetupSteps(TANSTACK_STEPS);
707
+ allFiles = getFilesAndDirs();
708
+ if (ok) console.log(chalk.green('āœ“ TanStack Start app created.\n'));
709
+ continue;
710
+ }
711
+
712
+ // /setup-laravel — scaffold a Laravel API
961
713
  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);
714
+ const ok = await runSetupSteps(LARAVEL_STEPS);
970
715
  allFiles = getFilesAndDirs();
971
- if (ok) console.log(chalk.green(`āœ“ Laravel API created in ${name}.\n`));
716
+ if (ok) console.log(chalk.green('āœ“ Laravel API created.\n'));
717
+ continue;
718
+ }
719
+
720
+ // /laravel — run agent with Laravel "my-blog" blog setup prompt (auto-confirm like /yolo)
721
+ if (trimmed === '/laravel') {
722
+ const userContent = (await getLsContext()) + (await getGrepContext()) + LARAVEL_BLOG_PROMPT;
723
+ chatMessages.push({ role: 'user', content: userContent });
724
+ const agentMessages = [buildAgentSystemMessage(), ...chatMessages];
725
+ maybePrintFullPayload(agentMessages);
726
+ const abortController = new AbortController();
727
+ const confirmFn = () => Promise.resolve(true);
728
+ const confirmFileEdit = async () => true;
729
+ const startTime = Date.now();
730
+ const DOTS = ['.', '..', '...'];
731
+ let dotIdx = 0;
732
+ let spinner = null;
733
+ const startSpinner = () => {
734
+ if (spinner) { clearInterval(spinner); spinner = null; }
735
+ dotIdx = 0;
736
+ process.stdout.write(chalk.dim('\nLaravel › '));
737
+ spinner = setInterval(() => {
738
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
739
+ process.stdout.write('\r' + chalk.dim('Laravel › ') + chalk.dim(elapsed + 's ') + agentGradient(DOTS[dotIdx % DOTS.length]) + ' ');
740
+ dotIdx++;
741
+ }, 400);
742
+ };
743
+ const stopSpinner = () => {
744
+ if (spinner) { clearInterval(spinner); spinner = null; }
745
+ process.stdout.write('\r\x1b[0J');
746
+ };
747
+ try {
748
+ const result = await runAgentLoop(agentMessages, {
749
+ signal: abortController.signal,
750
+ cwd: process.cwd(),
751
+ confirmFn,
752
+ confirmFileEdit,
753
+ onThinking: () => { startSpinner(); },
754
+ onBeforeToolRun: () => { stopSpinner(); },
755
+ onIteration: (iter, max, toolCount) => {
756
+ const w = process.stdout.columns || 80;
757
+ const label = ` Step ${iter} `;
758
+ const line = chalk.dim('──') + chalk.bold.white(label) + chalk.dim('─'.repeat(Math.max(0, w - label.length - 2)));
759
+ console.log(line);
760
+ },
761
+ onToolCall: (name, args) => {
762
+ const summary = formatToolCallSummary(name, args);
763
+ console.log(chalk.cyan('\n ā–¶ ') + chalk.bold(name) + chalk.dim(' ') + chalk.white(summary));
764
+ },
765
+ onToolResult: (name, resultStr) => {
766
+ console.log(chalk.dim(' ') + formatToolResultSummary(name, resultStr));
767
+ },
768
+ });
769
+ stopSpinner();
770
+ if (result) {
771
+ chatMessages.push(result.finalMessage);
772
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
773
+ const width = Math.min(process.stdout.columns || 80, 80);
774
+ process.stdout.write(formatResponseWithCodeBlocks(result.content, width) + '\n\n');
775
+ await applyCodeBlockEdits(result.content, []);
776
+ allFiles = getFilesAndDirs();
777
+ console.log(chalk.green('āœ“ Laravel blog setup done.') + chalk.dim(` (${elapsed}s)\n`));
778
+ maybePrintRawModelOutput(result.content);
779
+ }
780
+ } catch (err) {
781
+ stopSpinner();
782
+ if (!abortController.signal?.aborted) console.log(chalk.red(`\n${err.message}\n`));
783
+ }
972
784
  continue;
973
785
  }
974
786
 
@@ -981,6 +793,7 @@ export async function startInteractive() {
981
793
  console.log(chalk.yellow(`\n⚠ not found: ${failed.map(f => `@${f}`).join(', ')}`));
982
794
  }
983
795
  const rawUserContent = resolvedContent ?? trimmed;
796
+ console.log(chalk.dim('You: ') + rawUserContent);
984
797
  const userContent = (await getLsContext()) + (await getGrepContext()) + rawUserContent;
985
798
  chatMessages.push({ role: 'user', content: userContent });
986
799
 
@@ -1034,7 +847,7 @@ export async function startInteractive() {
1034
847
  },
1035
848
  onToolCall: (name, args) => {
1036
849
  const summary = formatToolCallSummary(name, args);
1037
- console.log(chalk.cyan(' ā–¶ ') + chalk.bold(name) + chalk.dim(' ') + chalk.white(summary));
850
+ console.log(chalk.cyan('\n ā–¶ ') + chalk.bold(name) + chalk.dim(' ') + chalk.white(summary));
1038
851
  },
1039
852
  onToolResult: (name, resultStr) => {
1040
853
  console.log(chalk.dim(' ') + formatToolResultSummary(name, resultStr));
@@ -1049,11 +862,7 @@ export async function startInteractive() {
1049
862
  await applyCodeBlockEdits(result.content, loaded);
1050
863
  allFiles = getFilesAndDirs();
1051
864
  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
- }
865
+ maybePrintRawModelOutput(result.content);
1057
866
  }
1058
867
  } catch (err) {
1059
868
  stopSpinner();