markov-cli 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "markov-cli",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "LivingCloud's CLI AI Agent",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,6 +26,7 @@
26
26
  ".env.example"
27
27
  ],
28
28
  "dependencies": {
29
+ "boxen": "^8.0.1",
29
30
  "chalk": "^5.6.2",
30
31
  "commander": "^14.0.3",
31
32
  "dotenv": "^16.4.5",
package/src/auth.js CHANGED
@@ -17,6 +17,12 @@ export function saveToken(token) {
17
17
  writeFileSync(TOKEN_PATH, token, 'utf-8');
18
18
  }
19
19
 
20
+ export function clearToken() {
21
+ if (existsSync(TOKEN_PATH)) {
22
+ writeFileSync(TOKEN_PATH, '', 'utf-8');
23
+ }
24
+ }
25
+
20
26
  export async function login(email, password) {
21
27
  const res = await fetch(`${API_URL}/auth/login`, {
22
28
  method: 'POST',
package/src/files.js CHANGED
@@ -76,9 +76,7 @@ export function resolveFileRefs(input, cwd = process.cwd()) {
76
76
 
77
77
  const contextBlock = blocks.length > 0
78
78
  ? `The user referenced the following file(s) as context:\n\n${blocks.join('\n\n')}\n\n` +
79
- `If you need to edit a file, output the complete new file content in a fenced code block tagged with the EXACT filename, like:\n` +
80
- `${loaded.map(f => `\`\`\`${f}\n// full new content\n\`\`\``).join('\n')}\n\n` +
81
- `IMPORTANT: Use the exact filename shown above as the code block tag, not the language name.\n\n`
79
+ `ATTACHED FILES RULE: The user attached the file(s) above. If they ask you to edit, add, fix, or create anything in these files, you MUST use the write_file or search_replace tool — do not output the new or modified file content in your reply (no code blocks with file content). Use the exact paths shown above (e.g. ${loaded[0] ?? 'path'}).\n\n`
82
80
  : '';
83
81
 
84
82
  return { content: contextBlock + input, loaded, failed };
@@ -1,265 +1,18 @@
1
1
  import chalk from 'chalk';
2
+ import boxen from 'boxen';
2
3
  import gradient from 'gradient-string';
3
4
  import { homedir } from 'os';
4
- import { resolve, relative, isAbsolute } from 'path';
5
- import { mkdirSync, rmSync, writeFileSync, unlinkSync, existsSync } from 'fs';
6
- import { join } from 'path';
5
+ import { resolve } from 'path';
6
+ import { mkdirSync, writeFileSync } from 'fs';
7
7
  import { printLogo } from './ui/logo.js';
8
- import { streamChat, MODEL, MODELS, setModel } from './ollama.js';
8
+ import { chatWithTools, MODEL, MODELS, setModel } from './ollama.js';
9
9
  import { resolveFileRefs } from './files.js';
10
- import { execCommand } from './tools.js';
11
- import { parseEdits, applyEdit, renderDiff } from './editor.js';
10
+ import { execCommand, AGENT_TOOLS, runTool } from './tools.js';
12
11
  import { chatPrompt } from './input.js';
13
12
  import { getFiles, getFilesAndDirs } from './ui/picker.js';
13
+ import { getToken, login, clearToken } from './auth.js';
14
14
 
15
- const markovGradient = gradient(['#6366f1', '#a855f7', '#ec4899']);
16
-
17
- /** If path is absolute and under cwd, return relative path; otherwise return as-is. */
18
- function toRelativePath(p) {
19
- const cwd = process.cwd();
20
- if (!isAbsolute(p)) return p;
21
- const rel = relative(cwd, p);
22
- if (!rel.startsWith('..') && !isAbsolute(rel)) return rel;
23
- return p;
24
- }
25
-
26
- /** Extract every !!run: command from a model response. */
27
- function parseRunCommands(text) {
28
- const commands = [];
29
- for (const line of text.split('\n')) {
30
- const match = line.match(/^!!run:\s*(.+)$/);
31
- if (match) commands.push(match[1].trim());
32
- }
33
- return commands;
34
- }
35
-
36
- /** Extract every !!mkdir: path from a model response. */
37
- function parseMkdirCommands(text) {
38
- const paths = [];
39
- for (const line of text.split('\n')) {
40
- const match = line.match(/^!!mkdir:\s*(.+)$/);
41
- if (match) paths.push(match[1].trim());
42
- }
43
- return paths;
44
- }
45
-
46
- /** Extract every !!rmdir: path from a model response. */
47
- function parseRmdirCommands(text) {
48
- const paths = [];
49
- for (const line of text.split('\n')) {
50
- const match = line.match(/^!!rmdir:\s*(.+)$/);
51
- if (match) paths.push(match[1].trim());
52
- }
53
- return paths;
54
- }
55
-
56
- /** Extract every !!touch: path from a model response. */
57
- function parseTouchCommands(text) {
58
- const paths = [];
59
- for (const line of text.split('\n')) {
60
- const match = line.match(/^!!touch:\s*(.+)$/);
61
- if (match) paths.push(match[1].trim());
62
- }
63
- return paths;
64
- }
65
-
66
- /** Extract every !!delete: path from a model response. */
67
- function parseDeleteCommands(text) {
68
- const paths = [];
69
- for (const line of text.split('\n')) {
70
- const match = line.match(/^!!delete:\s*(.+)$/);
71
- if (match) paths.push(match[1].trim());
72
- }
73
- return paths;
74
- }
75
-
76
- /** Extract every !!write: path and its following fenced code block from a model response. */
77
- function parseWriteCommands(text) {
78
- const edits = [];
79
- const lines = text.split('\n');
80
- let i = 0;
81
- const blockRegex = /```(?:[\w./\-]*)\n([\s\S]*?)```/;
82
- while (i < lines.length) {
83
- const line = lines[i];
84
- const writeMatch = line.match(/^!!write:\s*(.+)$/);
85
- if (writeMatch) {
86
- const filepath = writeMatch[1].trim();
87
- if (filepath) {
88
- const rest = lines.slice(i + 1).join('\n');
89
- const blockMatch = rest.match(blockRegex);
90
- if (blockMatch) {
91
- edits.push({ filepath, content: blockMatch[1] });
92
- const blockText = blockMatch[0];
93
- const beforeBlock = rest.substring(0, rest.indexOf(blockText));
94
- const linesBeforeBlock = beforeBlock.split('\n').length;
95
- const linesInBlock = blockText.split('\n').length;
96
- i += 1 + linesBeforeBlock + linesInBlock;
97
- continue;
98
- }
99
- }
100
- }
101
- i++;
102
- }
103
- return edits;
104
- }
105
-
106
- /**
107
- * Parse and apply all file operations from a model reply.
108
- * Shows diffs/confirmations for each op. Returns updated allFiles list.
109
- * options.autoConfirm: if true, skip y/n prompts and apply all ops.
110
- */
111
- async function handleFileOps(reply, loadedFiles, options = {}) {
112
- const autoConfirm = options.autoConfirm === true;
113
- let allFiles = getFilesAndDirs();
114
-
115
- // Create folders first (so !!run: cd <folder> can succeed later)
116
- for (const folderPath of parseMkdirCommands(reply)) {
117
- const path = toRelativePath(folderPath);
118
- process.stdout.write(chalk.dim(` mkdir: ${path} — `));
119
- const confirmed = autoConfirm ? true : await confirm(chalk.bold(`Create folder ${chalk.cyan(path)}? [y/N] `));
120
- if (confirmed) {
121
- try {
122
- mkdirSync(resolve(process.cwd(), path), { recursive: true });
123
- allFiles = getFilesAndDirs();
124
- console.log(chalk.green(`✓ created ${path}\n`));
125
- } catch (err) {
126
- console.log(chalk.red(`✗ could not create ${path}: ${err.message}\n`));
127
- }
128
- } else {
129
- console.log(chalk.dim('skipped\n'));
130
- }
131
- }
132
-
133
- // Create empty files
134
- for (const filePath of parseTouchCommands(reply)) {
135
- const path = toRelativePath(filePath);
136
- const confirmed = autoConfirm ? true : await confirm(chalk.bold(`Create file ${chalk.cyan(path)}? [y/N] `));
137
- if (confirmed) {
138
- try {
139
- const abs = resolve(process.cwd(), path);
140
- const parentDir = abs.split('/').slice(0, -1).join('/');
141
- mkdirSync(parentDir, { recursive: true });
142
- writeFileSync(abs, '', { flag: 'wx' });
143
- allFiles = getFilesAndDirs();
144
- console.log(chalk.green(`✓ created ${path}\n`));
145
- } catch (err) {
146
- console.log(chalk.red(`✗ could not create ${path}: ${err.message}\n`));
147
- }
148
- } else {
149
- console.log(chalk.dim('skipped\n'));
150
- }
151
- }
152
-
153
- // Write files via !!write: path + fenced block
154
- for (const { filepath, content } of parseWriteCommands(reply)) {
155
- const path = toRelativePath(filepath);
156
- renderDiff(path, content);
157
- const confirmed = autoConfirm ? true : await confirm(chalk.bold(`Write ${chalk.cyan(path)}? [y/N] `));
158
- if (confirmed) {
159
- try {
160
- applyEdit(path, content);
161
- allFiles = getFilesAndDirs();
162
- console.log(chalk.green(`✓ wrote ${path}\n`));
163
- } catch (err) {
164
- console.log(chalk.red(`✗ could not write ${path}: ${err.message}\n`));
165
- }
166
- } else {
167
- console.log(chalk.dim('skipped\n'));
168
- }
169
- }
170
-
171
- // Write / edit files (fenced blocks with path/language or single attached file)
172
- const edits = parseEdits(reply, loadedFiles);
173
- for (const { filepath, content } of edits) {
174
- const path = toRelativePath(filepath);
175
- renderDiff(path, content);
176
- const confirmed = autoConfirm ? true : await confirm(chalk.bold(`Write ${chalk.cyan(path)}? [y/N] `));
177
- if (confirmed) {
178
- try {
179
- applyEdit(path, content);
180
- allFiles = getFilesAndDirs();
181
- console.log(chalk.green(`✓ wrote ${path}\n`));
182
- } catch (err) {
183
- console.log(chalk.red(`✗ could not write ${path}: ${err.message}\n`));
184
- }
185
- } else {
186
- console.log(chalk.dim('skipped\n'));
187
- }
188
- }
189
-
190
- // Run terminal commands (after folders/files exist, so cd works)
191
- for (const cmd of parseRunCommands(reply)) {
192
- const ok = autoConfirm ? true : await confirm(chalk.bold(`Run: ${chalk.cyan(cmd)}? [y/N] `));
193
- if (ok) {
194
- // cd must be handled in-process — child processes can't change the parent's cwd
195
- const cdMatch = cmd.match(/^cd\s+(.+)$/);
196
- if (cdMatch) {
197
- const target = resolve(process.cwd(), cdMatch[1].trim().replace(/^~/, homedir()));
198
- try {
199
- process.chdir(target);
200
- allFiles = getFilesAndDirs();
201
- console.log(chalk.dim(` 📁 ${process.cwd()}\n`));
202
- } catch (err) {
203
- console.log(chalk.red(` no such directory: ${target}\n`));
204
- }
205
- continue;
206
- }
207
- process.stdout.write(chalk.dim(` running: ${cmd}\n`));
208
- const { stdout, stderr, exitCode } = await execCommand(cmd);
209
- const output = [stdout, stderr].filter(Boolean).join('\n').trim();
210
- console.log(output ? chalk.dim(output) + '\n' : chalk.dim(` (exit ${exitCode})\n`));
211
- allFiles = getFilesAndDirs();
212
- } else {
213
- console.log(chalk.dim('skipped\n'));
214
- }
215
- }
216
-
217
- // Remove directories
218
- for (const dirPath of parseRmdirCommands(reply)) {
219
- const path = toRelativePath(dirPath);
220
- const confirmed = autoConfirm ? true : await confirm(chalk.bold(`Remove directory ${chalk.cyan(path)}? [y/N] `));
221
- if (confirmed) {
222
- const abs = resolve(process.cwd(), path);
223
- if (existsSync(abs)) {
224
- try {
225
- rmSync(abs, { recursive: true, force: true });
226
- allFiles = getFilesAndDirs();
227
- console.log(chalk.green(`✓ removed ${path}\n`));
228
- } catch (err) {
229
- console.log(chalk.red(`✗ could not remove ${path}: ${err.message}\n`));
230
- }
231
- } else {
232
- console.log(chalk.yellow(`⚠ not found: ${path}\n`));
233
- }
234
- } else {
235
- console.log(chalk.dim('skipped\n'));
236
- }
237
- }
238
-
239
- // Delete files
240
- for (const filePath of parseDeleteCommands(reply)) {
241
- const path = toRelativePath(filePath);
242
- const confirmed = autoConfirm ? true : await confirm(chalk.bold(`Delete ${chalk.cyan(path)}? [y/N] `));
243
- if (confirmed) {
244
- const abs = resolve(process.cwd(), path);
245
- if (existsSync(abs)) {
246
- try {
247
- unlinkSync(abs);
248
- allFiles = getFilesAndDirs();
249
- console.log(chalk.green(`✓ deleted ${path}\n`));
250
- } catch (err) {
251
- console.log(chalk.red(`✗ could not delete ${path}: ${err.message}\n`));
252
- }
253
- } else {
254
- console.log(chalk.yellow(`⚠ not found: ${path}\n`));
255
- }
256
- } else {
257
- console.log(chalk.dim('skipped\n'));
258
- }
259
- }
260
-
261
- return allFiles;
262
- }
15
+ const agentGradient = gradient(['#22c55e', '#16a34a', '#4ade80']);
263
16
 
264
17
  /** Arrow-key selector. Returns the chosen string or null if cancelled. */
265
18
  function selectFrom(options, label) {
@@ -376,7 +129,6 @@ function promptSecret(label) {
376
129
  });
377
130
  }
378
131
 
379
- const VIEWPORT_LINES = 5;
380
132
  const TERM_WIDTH = 80;
381
133
  const ANSI_RE = /\x1b\[[0-9;]*m/g;
382
134
  const visLen = (s) => s.replace(ANSI_RE, '').length;
@@ -402,144 +154,292 @@ function wrapText(text, width) {
402
154
  }).join('\n');
403
155
  }
404
156
 
405
- /** Count the number of terminal rows a list of lines occupies, accounting for wrapping. */
406
- function countRows(lines, w) {
407
- return lines.reduce((sum, l) => sum + Math.max(1, Math.ceil(visLen(l) / w)), 0);
157
+ /** Parse fenced code blocks (```lang\n...\n```) and render them with boxen. Non-code segments are wrapped. */
158
+ function formatResponseWithCodeBlocks(text, width) {
159
+ if (!text || typeof text !== 'string') return '';
160
+ const re = /```(\w*)\n([\s\S]*?)```/g;
161
+ const parts = [];
162
+ let lastIndex = 0;
163
+ let m;
164
+ while ((m = re.exec(text)) !== null) {
165
+ if (m.index > lastIndex) {
166
+ const textSegment = text.slice(lastIndex, m.index);
167
+ if (textSegment) parts.push({ type: 'text', content: textSegment });
168
+ }
169
+ parts.push({ type: 'code', lang: m[1], content: m[2].trim() });
170
+ lastIndex = re.lastIndex;
171
+ }
172
+ if (lastIndex < text.length) {
173
+ const textSegment = text.slice(lastIndex);
174
+ if (textSegment) parts.push({ type: 'text', content: textSegment });
175
+ }
176
+ if (parts.length === 0) return wrapText(text, width);
177
+ return parts.map((p) => {
178
+ if (p.type === 'text') return wrapText(p.content, width);
179
+ const title = p.lang ? p.lang : 'code';
180
+ return boxen(p.content, {
181
+ borderColor: 'cyan',
182
+ borderStyle: 'round',
183
+ title,
184
+ padding: 1,
185
+ });
186
+ }).join('\n\n');
408
187
  }
409
188
 
410
- /**
411
- * Stream a chat response with a live 5-line viewport.
412
- * While streaming, only the last VIEWPORT_LINES lines are shown in-place.
413
- * After streaming finishes, the viewport is replaced with the full response.
414
- * Returns the full reply string.
415
- */
416
- function buildSystemMessage() {
189
+ /** Shared system message base: Markov intro, cwd, file list (no !!run instructions). */
190
+ function getSystemMessageBase() {
417
191
  const files = getFiles();
418
192
  const fileList = files.length > 0 ? `\nFiles in working directory:\n${files.map(f => ` ${f}`).join('\n')}\n` : '';
419
- const fileOpsInstructions =
420
- `\nFILE OPERATIONS — use these exact syntaxes when needed:\n` +
421
- `- Always use RELATIVE paths (e.g. path/to/file or markov/next-app/README.md), never absolute paths like /Users/...\n` +
422
- `- Run a terminal command: output exactly on its own line: !!run: <command>\n` +
423
- `- Write or edit a file: output exactly on its own line !!write: path/to/file, then a fenced code block with the full file content.\n` +
424
- `- Create an empty file: output exactly on its own line: !!touch: path/to/file\n` +
425
- `- Create a folder: output exactly on its own line: !!mkdir: path/to/folder\n` +
426
- `- Remove a folder: output exactly on its own line: !!rmdir: path/to/folder\n` +
427
- `- Delete a file: output exactly on its own line: !!delete: path/to/file\n` +
428
- `- You may combine multiple operations in one response.\n` +
429
- `- NEVER put commands in fenced code blocks — always use !!run: syntax for commands.\n` +
430
- `- NEVER use && inside !!run: — use one !!run: per command. For "cd then do X", output !!run: cd <path> on one line, then !!run: <command> on the next (cd changes the working directory for all following commands).\n` +
431
- `\nSETUP NEXT.JS APP (or any new project in a subfolder):\n` +
432
- `1. Create the folder first: !!mkdir: next-app (or the requested name).\n` +
433
- `2. Change into it on its own line: !!run: cd next-app (nothing after the path).\n` +
434
- `3. Run each following command on its own !!run: line: e.g. !!run: npx create-next-app@latest . --yes, then !!run: git init, !!run: git add ., !!run: git commit -m "Initial commit".\n` +
435
- `\nSETUP LARAVEL API (output exactly this):\n` +
436
- `!!mkdir: laravel-api then !!run: cd laravel-api then !!run: composer create-project --prefer-dist laravel/laravel . then !!run: php artisan serve. One per line; nothing after the dot in composer; no composer run dev or dev-server. No custom routes.\n` +
437
- `\nSETUP ROUTES (API only — use when user asks to add routes, /health, /users, or API endpoints):\n` +
438
- `Only after the Laravel app exists. Add in routes/api.php only (not web.php): GET /api/health returning response()->json(['status' => 'ok']), and /api/users as a resource (index, show, store, update, destroy) using User model and a controller. Register api in bootstrap/app.php if needed. Use !!write: for routes/api.php and the controller; prefix with laravel-api/ if cwd is parent. Do not use routes/web.php.\n`;
439
- return { role: 'system', content: `You are Markov, an AI coding assistant.\nWorking directory: ${process.cwd()}\n${fileList}${fileOpsInstructions}` };
193
+ return `You are Markov, an AI coding assistant.\nWorking directory: ${process.cwd()}\n${fileList}`;
440
194
  }
441
195
 
442
- async function streamWithViewport(chatMessages, signal) {
443
- // Re-resolve @file refs fresh on every request so the model always sees the latest file contents.
444
- const resolvedMessages = chatMessages.map(msg => {
445
- if (msg.role !== 'user') return msg;
446
- const { content } = resolveFileRefs(msg.content);
447
- return { ...msg, content };
448
- });
449
-
450
- const DOTS = ['.', '..', '...'];
451
- let dotIdx = 0;
452
- let firstToken = true;
453
- let fullText = '';
454
- let viewportRows = 0; // actual terminal rows currently rendered in the viewport
196
+ /** System message for agent mode: tool-only instructions (no !!run / !!write). */
197
+ function buildAgentSystemMessage() {
198
+ const toolInstructions =
199
+ `\nTOOL MODE you have tools; use them. \n` +
200
+ `- run_terminal_command: run shell commands (npm install, npx create-next-app, etc.). One command per call.\n` +
201
+ `- create_folder: create directories.\n` +
202
+ `- read_file: read file contents before editing.\n` +
203
+ `- write_file: create or overwrite a file with full content.\n` +
204
+ `- search_replace: replace first occurrence of text in a file.\n` +
205
+ `- delete_file: delete a file.\n` +
206
+ `- list_dir: list directory contents (path optional, defaults to current dir).\n` +
207
+ `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), call these tools — do not only list commands or describe steps in your reply. Execute with tool calls.\n` +
208
+ `When the user has ATTACHED FILES (FILE: path ... in the message), any edit, add, or fix they ask for in those files MUST be done with write_file or search_replace — never by pasting the modified file content in your reply.\n` +
209
+ `Use RELATIVE paths.\n`;
210
+ return { role: 'system', content: getSystemMessageBase() + toolInstructions };
211
+ }
455
212
 
456
- process.stdout.write(chalk.dim('\nMarkov '));
457
- const spinner = setInterval(() => {
458
- process.stdout.write('\r' + chalk.dim('Markov ') + markovGradient(DOTS[dotIdx % DOTS.length]) + ' ');
459
- dotIdx++;
460
- }, 400);
213
+ const AGENT_LOOP_MAX_ITERATIONS = 20;
214
+
215
+ /** Preview of a file edit for confirmation (write_file / search_replace) */
216
+ function formatFileEditPreview(name, args) {
217
+ const path = args?.path ?? '(no path)';
218
+ if (name === 'search_replace') {
219
+ const oldStr = String(args?.old_string ?? '');
220
+ const newStr = String(args?.new_string ?? '');
221
+ const max = 120;
222
+ const oldPreview = oldStr.length > max ? oldStr.slice(0, max) + '…' : oldStr;
223
+ const newPreview = newStr.length > max ? newStr.slice(0, max) + '…' : newStr;
224
+ return (
225
+ chalk.cyan(path) + '\n' +
226
+ chalk.red(' - ' + (oldPreview || '(empty)').replace(/\n/g, '\n ')) + '\n' +
227
+ chalk.green(' + ' + (newPreview || '(empty)').replace(/\n/g, '\n '))
228
+ );
229
+ }
230
+ if (name === 'write_file') {
231
+ const content = String(args?.content ?? '');
232
+ const lines = content.split('\n');
233
+ const previewLines = lines.slice(0, 25);
234
+ const more = lines.length > 25 ? chalk.dim(` ... ${lines.length - 25} more lines`) : '';
235
+ return chalk.cyan(path) + '\n' + previewLines.map(l => ' ' + l).join('\n') + (more ? '\n' + more : '');
236
+ }
237
+ return path;
238
+ }
461
239
 
462
- const onCancel = (data) => { if (data.toString() === '\x11') signal.abort(); };
463
- process.stdin.setRawMode(true);
464
- process.stdin.resume();
465
- process.stdin.setEncoding('utf8');
466
- process.stdin.on('data', onCancel);
240
+ /** One-line summary of tool args for display */
241
+ function formatToolCallSummary(name, args) {
242
+ const a = args ?? {};
243
+ if (name === 'run_terminal_command') return (a.command ?? '').trim() || '(empty)';
244
+ if (name === 'write_file') return a.path ?? '(no path)';
245
+ if (name === 'search_replace') return (a.path ?? '') + (a.old_string ? ` "${String(a.old_string).slice(0, 30)}…"` : '');
246
+ if (name === 'read_file' || name === 'delete_file') return a.path ?? '(no path)';
247
+ if (name === 'create_folder') return a.path ?? '(no path)';
248
+ if (name === 'list_dir') return (a.path ?? '.') || '.';
249
+ return JSON.stringify(a).slice(0, 50);
250
+ }
467
251
 
252
+ /** One-line summary of tool result for display */
253
+ function formatToolResultSummary(name, resultJson) {
254
+ let obj;
468
255
  try {
469
- const reply = await streamChat([buildSystemMessage(), ...resolvedMessages], (token) => {
470
- if (firstToken) {
471
- clearInterval(spinner);
472
- firstToken = false;
473
- process.stdout.write('\r\x1b[0J' + chalk.dim('Markov ›\n\n'));
474
- }
256
+ obj = typeof resultJson === 'string' ? JSON.parse(resultJson) : resultJson;
257
+ } catch {
258
+ return resultJson?.slice(0, 60) ?? '—';
259
+ }
260
+ if (obj.error) return chalk.red('' + obj.error);
261
+ if (obj.declined) return chalk.yellow('✗ declined');
262
+ if (name === 'run_terminal_command') {
263
+ const code = obj.exitCode ?? obj.exit_code;
264
+ if (code === 0) return chalk.green('✓ exit 0') + (obj.stdout ? chalk.dim(' ' + String(obj.stdout).trim().slice(0, 80).replace(/\n/g, ' ')) : '');
265
+ return chalk.red(`✗ exit ${code}`) + (obj.stderr ? chalk.dim(' ' + String(obj.stderr).trim().slice(0, 80)) : '');
266
+ }
267
+ if (name === 'write_file' || name === 'search_replace' || name === 'delete_file' || name === 'create_folder') {
268
+ return obj.success !== false ? chalk.green('✓ ' + (obj.path ? obj.path : 'ok')) : chalk.red('✗ ' + (obj.error || 'failed'));
269
+ }
270
+ if (name === 'read_file') return obj.content != null ? chalk.green('✓ ' + (obj.path ?? '') + chalk.dim(` (${String(obj.content).length} chars)`)) : chalk.red('✗ ' + (obj.error || ''));
271
+ if (name === 'list_dir') return obj.entries ? chalk.green('✓ ' + (obj.entries.length ?? 0) + ' entries') : chalk.red('✗ ' + (obj.error || ''));
272
+ return chalk.dim(JSON.stringify(obj).slice(0, 60));
273
+ }
475
274
 
476
- fullText += token;
275
+ /**
276
+ * Run the agent loop: call chatWithTools, run any tool_calls locally, append results, repeat until the model returns a final response.
277
+ * 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.
278
+ * @param {Array<{ role: string; content?: string; tool_calls?: unknown[]; tool_name?: string }>} messages - Full message list (system + conversation)
279
+ * @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
280
+ * @returns {Promise<{ content: string; finalMessage: object } | null>} Final assistant content and message, or null if cancelled/error
281
+ */
282
+ async function runAgentLoop(messages, opts = {}) {
283
+ const cwd = opts.cwd ?? process.cwd();
284
+ const confirmFn = opts.confirmFn;
285
+ const confirmFileEdit = opts.confirmFileEdit;
286
+ const onBeforeToolRun = opts.onBeforeToolRun;
287
+ const onToolCall = opts.onToolCall;
288
+ const onToolResult = opts.onToolResult;
289
+ let iteration = 0;
290
+
291
+ while (iteration < AGENT_LOOP_MAX_ITERATIONS) {
292
+ iteration += 1;
293
+ const data = await chatWithTools(messages, AGENT_TOOLS, MODEL, opts.signal ?? null);
294
+
295
+ const message = data?.message;
296
+ if (!message) {
297
+ return { content: '', finalMessage: { role: 'assistant', content: '' } };
298
+ }
299
+
300
+ const toolCalls = message.tool_calls;
301
+ if (!toolCalls || toolCalls.length === 0) {
302
+ return { content: message.content ?? '', finalMessage: message };
303
+ }
477
304
 
478
- // Build the viewport: last VIEWPORT_LINES lines of buffered text.
479
- const w = process.stdout.columns || TERM_WIDTH;
480
- const displayWidth = Math.min(w, TERM_WIDTH);
481
- const lines = wrapText(fullText, displayWidth).split('\n');
482
- const viewLines = lines;
483
- const rendered = viewLines.join('\n');
305
+ // Append assistant message with tool_calls
306
+ messages.push({
307
+ role: 'assistant',
308
+ content: message.content ?? '',
309
+ tool_calls: toolCalls,
310
+ });
484
311
 
485
- // Count actual terminal rows rendered (accounts for line wrapping).
486
- const newRows = countRows(viewLines, w) - 1;
312
+ onBeforeToolRun?.();
313
+
314
+ for (const tc of toolCalls) {
315
+ const name = tc?.function?.name;
316
+ const rawArgs = tc?.function?.arguments;
317
+ let args = rawArgs;
318
+ if (typeof rawArgs === 'string') {
319
+ try {
320
+ args = JSON.parse(rawArgs);
321
+ } catch {
322
+ messages.push({
323
+ role: 'tool',
324
+ tool_name: name ?? 'unknown',
325
+ content: JSON.stringify({ error: 'Invalid JSON in arguments' }),
326
+ });
327
+ if (onToolCall) onToolCall(name ?? 'unknown', {});
328
+ if (onToolResult) onToolResult(name ?? 'unknown', JSON.stringify({ error: 'Invalid JSON in arguments' }));
329
+ continue;
330
+ }
331
+ }
487
332
 
488
- // Move cursor up to top of current viewport, clear down, rewrite.
489
- if (viewportRows > 0) {
490
- process.stdout.write(`\x1b[${viewportRows}A\r\x1b[0J`);
333
+ if (onToolCall) onToolCall(name ?? 'unknown', args ?? {});
334
+
335
+ const isFileEdit = name === 'write_file' || name === 'search_replace';
336
+ let result;
337
+ if (isFileEdit && confirmFileEdit) {
338
+ const ok = await confirmFileEdit(name, args ?? {});
339
+ if (!ok) {
340
+ result = JSON.stringify({ declined: true, message: 'User declined the change' });
341
+ if (onToolResult) onToolResult(name ?? 'unknown', result);
342
+ messages.push({
343
+ role: 'tool',
344
+ tool_name: name ?? 'unknown',
345
+ content: result,
346
+ });
347
+ continue;
348
+ }
491
349
  }
492
- process.stdout.write(rendered);
493
- viewportRows = newRows;
494
- }, undefined, signal);
495
-
496
- process.stdin.removeListener('data', onCancel);
497
- process.stdin.setRawMode(false);
498
- process.stdin.pause();
499
- clearInterval(spinner);
500
-
501
- if (signal.aborted) {
502
- if (viewportRows > 0) process.stdout.write(`\x1b[${viewportRows}A\r\x1b[0J`);
503
- console.log(chalk.dim('(cancelled)\n'));
504
- return null;
505
- }
506
350
 
507
- // Replace viewport with the full response.
508
- if (viewportRows > 0) process.stdout.write(`\x1b[${viewportRows}A\r\x1b[0J`);
509
- const finalWidth = Math.min(process.stdout.columns || TERM_WIDTH, TERM_WIDTH);
510
- process.stdout.write(wrapText(fullText, finalWidth) + '\n\n');
511
-
512
- return reply;
513
- } catch (err) {
514
- clearInterval(spinner);
515
- process.stdin.removeListener('data', onCancel);
516
- process.stdin.setRawMode(false);
517
- process.stdin.pause();
518
- if (!signal.aborted) throw err;
519
- return null;
351
+ result = await runTool(name, args ?? {}, { cwd, confirmFn });
352
+ if (onToolResult) onToolResult(name ?? 'unknown', typeof result === 'string' ? result : JSON.stringify(result));
353
+ messages.push({
354
+ role: 'tool',
355
+ tool_name: name ?? 'unknown',
356
+ content: typeof result === 'string' ? result : JSON.stringify(result),
357
+ });
358
+ }
520
359
  }
360
+
361
+ return {
362
+ content: '(agent loop reached max iterations)',
363
+ finalMessage: { role: 'assistant', content: '(max iterations)' },
364
+ };
521
365
  }
522
366
 
523
367
  const HELP_TEXT =
524
368
  '\n' +
525
369
  chalk.bold('Commands:\n') +
526
370
  chalk.cyan(' /help') + chalk.dim(' show this help\n') +
527
- chalk.cyan(' /plan <message>') + chalk.dim(' ask AI to create a plan (no files written)\n') +
528
- chalk.cyan(' /build') + chalk.dim(' execute the stored plan\n') +
371
+ chalk.cyan(' /agent') + chalk.dim(' [prompt] run with tools (run commands, create folders)\n') +
372
+ chalk.cyan(' /setup-nextjs') + chalk.dim(' scaffold a Next.js app\n') +
373
+ chalk.cyan(' /setup-laravel') + chalk.dim(' scaffold a Laravel API\n') +
529
374
  chalk.cyan(' /models') + chalk.dim(' switch the active AI model\n') +
530
375
  chalk.cyan(' /cd [path]') + chalk.dim(' change working directory\n') +
531
- chalk.dim('\nNormal chat: plan then build (no y/n). Tips: ') + chalk.cyan('@filename') + chalk.dim(' to attach a file · ctrl+q to cancel\n');
376
+ chalk.cyan(' /login') + chalk.dim(' authenticate with email & password\n') +
377
+ chalk.cyan(' /logout') + chalk.dim(' clear saved auth token\n') +
378
+ chalk.dim('\nType a message · ') + chalk.cyan('@filename') + chalk.dim(' to attach · ctrl+q to cancel\n');
379
+
380
+ /**
381
+ * Run a list of setup steps (mkdir / cd / run / write) in sequence.
382
+ * Returns true if all steps succeeded, false if any failed.
383
+ */
384
+ async function runSetupSteps(steps) {
385
+ for (const step of steps) {
386
+ if (step.type === 'mkdir') {
387
+ process.stdout.write(chalk.dim(` mkdir: ${step.path}\n`));
388
+ try {
389
+ mkdirSync(resolve(process.cwd(), step.path), { recursive: true });
390
+ console.log(chalk.green(` ✓ created ${step.path}\n`));
391
+ } catch (err) {
392
+ console.log(chalk.red(` ✗ ${err.message}\n`));
393
+ return false;
394
+ }
395
+ } else if (step.type === 'cd') {
396
+ try {
397
+ process.chdir(resolve(process.cwd(), step.path));
398
+ console.log(chalk.dim(` 📁 ${process.cwd()}\n`));
399
+ } catch (err) {
400
+ console.log(chalk.red(` ✗ no such directory: ${step.path}\n`));
401
+ return false;
402
+ }
403
+ } else if (step.type === 'run') {
404
+ process.stdout.write(chalk.dim(` running: ${step.cmd}\n`));
405
+ const { stdout, stderr, exitCode } = await execCommand(step.cmd);
406
+ const output = [stdout, stderr].filter(Boolean).join('\n').trim();
407
+ if (output) console.log(chalk.dim(output));
408
+ if (exitCode !== 0) {
409
+ console.log(chalk.red(` ✗ command failed (exit ${exitCode})\n`));
410
+ return false;
411
+ }
412
+ console.log(chalk.green(` ✓ done\n`));
413
+ } else if (step.type === 'write') {
414
+ process.stdout.write(chalk.dim(` write: ${step.path}\n`));
415
+ try {
416
+ const abs = resolve(process.cwd(), step.path);
417
+ const dir = abs.split('/').slice(0, -1).join('/');
418
+ mkdirSync(dir, { recursive: true });
419
+ writeFileSync(abs, step.content);
420
+ console.log(chalk.green(` ✓ wrote ${step.path}\n`));
421
+ } catch (err) {
422
+ console.log(chalk.red(` ✗ ${err.message}\n`));
423
+ return false;
424
+ }
425
+ }
426
+ }
427
+ return true;
428
+ }
532
429
 
533
430
  export async function startInteractive() {
534
431
  printLogo();
535
432
 
536
433
  let allFiles = getFilesAndDirs();
537
434
  const chatMessages = [];
538
- let currentPlan = null; // { text: string } | null
539
435
 
540
436
  console.log(chalk.dim(`Chat with Markov (${MODEL}).`));
541
437
  console.log(HELP_TEXT);
542
438
 
439
+ if (!getToken()) {
440
+ console.log(chalk.yellow('⚠ Not logged in. Use /login to authenticate.\n'));
441
+ }
442
+
543
443
  while (true) {
544
444
  const raw = await chatPrompt(chalk.magenta('you> '), allFiles);
545
445
  if (raw === null) continue;
@@ -547,6 +447,26 @@ export async function startInteractive() {
547
447
 
548
448
  if (!trimmed) continue;
549
449
 
450
+ // /login — authenticate and save token
451
+ if (trimmed === '/login') {
452
+ const email = await promptLine('Email: ');
453
+ const password = await promptSecret('Password: ');
454
+ try {
455
+ await login(email, password);
456
+ console.log(chalk.green('✓ logged in\n'));
457
+ } catch (err) {
458
+ console.log(chalk.red(`✗ ${err.message}\n`));
459
+ }
460
+ continue;
461
+ }
462
+
463
+ // /logout — clear saved token
464
+ if (trimmed === '/logout') {
465
+ clearToken();
466
+ console.log(chalk.green('✓ logged out\n'));
467
+ continue;
468
+ }
469
+
550
470
  // /help — list all commands
551
471
  if (trimmed === '/help') {
552
472
  console.log(HELP_TEXT);
@@ -579,110 +499,157 @@ export async function startInteractive() {
579
499
  continue;
580
500
  }
581
501
 
582
- // /plan <message> ask LLM to produce a plan as a normal chat message, store it
583
- if (trimmed.startsWith('/plan ')) {
584
- const planRequest = trimmed.slice(6).trim();
585
- const planPrompt =
586
- `Create a detailed, numbered plan for the following task:\n\n${planRequest}\n\n` +
587
- `For each step, specify exactly what will happen and which syntax will be used:\n` +
588
- `- Writing or editing a file → !!write: path/to/file then fenced code block\n` +
589
- `- Creating an empty file → !!touch: path/to/file\n` +
590
- `- Creating a folder → !!mkdir: path/to/folder\n` +
591
- `- Removing a folder → !!rmdir: path/to/folder\n` +
592
- `- Deleting a file → !!delete: path/to/file\n\n` +
593
- `Do NOT output any actual file contents or commands yet — only the plan.`;
594
- chatMessages.push({ role: 'user', content: planPrompt });
595
- const abortController = new AbortController();
596
- try {
597
- const reply = await streamWithViewport(chatMessages, abortController.signal);
598
- if (reply === null) {
599
- chatMessages.pop();
600
- } else {
601
- chatMessages.push({ role: 'assistant', content: reply });
602
- currentPlan = { text: reply };
603
- console.log(chalk.green('\n✓ Plan stored. Use /build to execute it.\n'));
604
- }
605
- } catch (err) {
606
- if (!abortController.signal.aborted) console.log(chalk.red(`\n${err.message}\n`));
607
- }
502
+ // /setup-nextjsscaffold a Next.js app via script
503
+ if (trimmed === '/setup-nextjs') {
504
+ const name = (await promptLine(chalk.bold('App folder name: '))).trim() || 'next-app';
505
+ const steps = [
506
+ { type: 'mkdir', path: name },
507
+ { type: 'cd', path: name },
508
+ { type: 'run', cmd: 'npx create-next-app@latest . --yes' },
509
+ { type: 'run', cmd: 'npm install sass' },
510
+ ];
511
+ const ok = await runSetupSteps(steps);
512
+ allFiles = getFilesAndDirs();
513
+ if (ok) console.log(chalk.green(`✓ Next.js app created in ${name}.\n`));
608
514
  continue;
609
515
  }
610
516
 
611
- // /buildexecute the stored plan with full file ops
612
- if (trimmed === '/build') {
613
- if (!currentPlan) {
614
- console.log(chalk.yellow('\n⚠ No plan stored. Use /plan <message> first.\n'));
517
+ // /setup-laravelscaffold a Laravel API via script
518
+ if (trimmed === '/setup-laravel') {
519
+ const name = (await promptLine(chalk.bold('App folder name: '))).trim() || 'laravel-api';
520
+ const steps = [
521
+ { type: 'mkdir', path: name },
522
+ { type: 'cd', path: name },
523
+ { type: 'run', cmd: 'composer create-project --prefer-dist laravel/laravel .' },
524
+ { type: 'run', cmd: 'php artisan serve' },
525
+ ];
526
+ const ok = await runSetupSteps(steps);
527
+ allFiles = getFilesAndDirs();
528
+ if (ok) console.log(chalk.green(`✓ Laravel API created in ${name}.\n`));
529
+ continue;
530
+ }
531
+
532
+ // /agent [prompt] — run with tools (run_terminal_command, create_folder, file tools), loop until final response
533
+ if (trimmed === '/agent' || trimmed.startsWith('/agent ')) {
534
+ const userContent = trimmed.startsWith('/agent ')
535
+ ? trimmed.slice(7).trim()
536
+ : (await promptLine(chalk.bold('Agent prompt: '))).trim();
537
+ if (!userContent) {
538
+ console.log(chalk.yellow('No prompt given.\n'));
615
539
  continue;
616
540
  }
617
-
618
- const buildPrompt =
619
- `Execute the following plan. Use !!write: path then a fenced code block for file writes, !!mkdir: for folders, !!delete: for deletions.\n\n` +
620
- `Plan:\n${currentPlan.text}`;
621
-
622
- chatMessages.push({ role: 'user', content: buildPrompt });
541
+ chatMessages.push({ role: 'user', content: userContent });
542
+ const agentMessages = [buildAgentSystemMessage(), ...chatMessages];
623
543
  const abortController = new AbortController();
544
+ const confirmFn = (cmd) => confirm(chalk.bold(`Run: ${cmd}? [y/N] `));
545
+ const confirmFileEdit = async (name, args) => {
546
+ console.log(chalk.dim('\n Proposed change:\n'));
547
+ console.log(formatFileEditPreview(name, args));
548
+ console.log('');
549
+ return confirm(chalk.bold('Apply this change? [y/N] '));
550
+ };
551
+
552
+ const DOTS = ['.', '..', '...'];
553
+ let dotIdx = 0;
554
+ process.stdout.write(chalk.dim('\nAgent › '));
555
+ const spinner = setInterval(() => {
556
+ process.stdout.write('\r' + chalk.dim('Agent › ') + agentGradient(DOTS[dotIdx % DOTS.length]) + ' ');
557
+ dotIdx++;
558
+ }, 400);
559
+
624
560
  try {
625
- const reply = await streamWithViewport(chatMessages, abortController.signal);
626
- if (reply === null) {
627
- chatMessages.pop();
628
- } else {
629
- chatMessages.push({ role: 'assistant', content: reply });
630
- allFiles = await handleFileOps(reply, []);
631
- currentPlan = null;
632
- console.log(chalk.green('✓ Plan executed.\n'));
561
+ const result = await runAgentLoop(agentMessages, {
562
+ signal: abortController.signal,
563
+ cwd: process.cwd(),
564
+ confirmFn,
565
+ confirmFileEdit,
566
+ onBeforeToolRun: () => {
567
+ clearInterval(spinner);
568
+ process.stdout.write('\r\x1b[0J');
569
+ },
570
+ onToolCall: (name, args) => {
571
+ const summary = formatToolCallSummary(name, args);
572
+ console.log(chalk.cyan(' ▶ ') + chalk.bold(name) + chalk.dim(' ') + chalk.white(summary));
573
+ },
574
+ onToolResult: (name, resultStr) => {
575
+ console.log(chalk.dim(' ') + formatToolResultSummary(name, resultStr));
576
+ },
577
+ });
578
+ clearInterval(spinner);
579
+ process.stdout.write('\r\x1b[0J');
580
+ if (result) {
581
+ chatMessages.push(result.finalMessage);
582
+ const width = Math.min(process.stdout.columns || 80, 80);
583
+ process.stdout.write(formatResponseWithCodeBlocks(result.content, width) + '\n\n');
584
+ allFiles = getFilesAndDirs();
585
+ console.log(chalk.green('✓ Agent done.\n'));
633
586
  }
634
587
  } catch (err) {
635
- if (!abortController.signal.aborted) console.log(chalk.red(`\n${err.message}\n`));
588
+ clearInterval(spinner);
589
+ process.stdout.write('\r\x1b[0J');
590
+ if (!abortController.signal?.aborted) console.log(chalk.red(`\n${err.message}\n`));
636
591
  }
637
592
  continue;
638
593
  }
639
594
 
640
- // Normal chat plan then build (two phases), apply file ops without y/n
641
- const { loaded, failed } = resolveFileRefs(trimmed);
642
-
595
+ // Handle message with agent (tools)
596
+ const { loaded, failed, content: resolvedContent } = await resolveFileRefs(trimmed);
643
597
  if (loaded.length > 0) {
644
598
  console.log(chalk.dim(`\n📎 attached: ${loaded.map(f => chalk.cyan(`@${f}`)).join(', ')}`));
645
599
  }
646
600
  if (failed.length > 0) {
647
601
  console.log(chalk.yellow(`\n⚠ not found: ${failed.map(f => `@${f}`).join(', ')}`));
648
602
  }
603
+ const userContent = resolvedContent ?? trimmed;
604
+ chatMessages.push({ role: 'user', content: userContent });
649
605
 
650
- const planPrompt =
651
- `Create a detailed, numbered plan for the following task:\n\n${trimmed}\n\n` +
652
- `For each step, specify exactly what will happen and which syntax will be used:\n` +
653
- `- Writing or editing a file → !!write: path/to/file then fenced code block\n` +
654
- `- Creating an empty file → !!touch: path/to/file\n` +
655
- `- Creating a folder → !!mkdir: path/to/folder\n` +
656
- `- Removing a folder → !!rmdir: path/to/folder\n` +
657
- `- Deleting a file → !!delete: path/to/file\n\n` +
658
- `Do NOT output any actual file contents or commands yet — only the plan.`;
659
-
660
- chatMessages.push({ role: 'user', content: planPrompt });
606
+ const agentMessages = [buildAgentSystemMessage(), ...chatMessages];
661
607
  const abortController = new AbortController();
608
+ const confirmFn = (cmd) => confirm(chalk.bold(`Run: ${cmd}? [y/N] `));
609
+ const confirmFileEdit = async (name, args) => {
610
+ console.log(chalk.dim('\n Proposed change:\n'));
611
+ console.log(formatFileEditPreview(name, args));
612
+ console.log('');
613
+ return confirm(chalk.bold('Apply this change? [y/N] '));
614
+ };
615
+ const DOTS = ['.', '..', '...'];
616
+ let dotIdx = 0;
617
+ process.stdout.write(chalk.dim('\nAgent › '));
618
+ const spinner = setInterval(() => {
619
+ process.stdout.write('\r' + chalk.dim('Agent › ') + agentGradient(DOTS[dotIdx % DOTS.length]) + ' ');
620
+ dotIdx++;
621
+ }, 400);
662
622
  try {
663
- const planReply = await streamWithViewport(chatMessages, abortController.signal);
664
- if (planReply === null) {
665
- chatMessages.pop();
666
- continue;
667
- }
668
- chatMessages.push({ role: 'assistant', content: planReply });
669
-
670
- const buildPrompt =
671
- `Execute the following plan. Use !!write: path then a fenced code block for file writes, !!mkdir: for folders, !!delete: for deletions.\n\n` +
672
- `Plan:\n${planReply}`;
673
-
674
- chatMessages.push({ role: 'user', content: buildPrompt });
675
- const buildReply = await streamWithViewport(chatMessages, abortController.signal);
676
- if (buildReply === null) {
677
- chatMessages.pop(); // remove build prompt
678
- continue;
623
+ const result = await runAgentLoop(agentMessages, {
624
+ signal: abortController.signal,
625
+ cwd: process.cwd(),
626
+ confirmFn,
627
+ confirmFileEdit,
628
+ onBeforeToolRun: () => {
629
+ clearInterval(spinner);
630
+ process.stdout.write('\r\x1b[0J');
631
+ },
632
+ onToolCall: (name, args) => {
633
+ const summary = formatToolCallSummary(name, args);
634
+ console.log(chalk.cyan(' ▶ ') + chalk.bold(name) + chalk.dim(' ') + chalk.white(summary));
635
+ },
636
+ onToolResult: (name, resultStr) => {
637
+ console.log(chalk.dim(' ') + formatToolResultSummary(name, resultStr));
638
+ },
639
+ });
640
+ clearInterval(spinner);
641
+ process.stdout.write('\r\x1b[0J');
642
+ if (result) {
643
+ chatMessages.push(result.finalMessage);
644
+ const width = Math.min(process.stdout.columns || 80, 80);
645
+ process.stdout.write(formatResponseWithCodeBlocks(result.content, width) + '\n\n');
646
+ allFiles = getFilesAndDirs();
647
+ console.log(chalk.green('✓ Agent done.\n'));
679
648
  }
680
- chatMessages.push({ role: 'assistant', content: buildReply });
681
- allFiles = await handleFileOps(buildReply, loaded, { autoConfirm: true });
682
649
  } catch (err) {
683
- if (!abortController.signal.aborted) {
684
- console.log(chalk.red(`\n${err.message}\n`));
685
- }
650
+ clearInterval(spinner);
651
+ process.stdout.write('\r\x1b[0J');
652
+ if (!abortController.signal?.aborted) console.log(chalk.red(`\n${err.message}\n`));
686
653
  }
687
654
  }
688
655
  }
package/src/tools.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { exec } from 'child_process';
2
2
  import { promisify } from 'util';
3
- import { mkdirSync } from 'fs';
4
- import { resolve } from 'path';
3
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync, readdirSync, statSync } from 'fs';
4
+ import { resolve, dirname } from 'path';
5
5
 
6
6
  const execAsync = promisify(exec);
7
7
 
@@ -47,6 +47,130 @@ export const CREATE_FOLDER_TOOL = {
47
47
  },
48
48
  };
49
49
 
50
+ /** Ollama-format tool definition for reading a file */
51
+ export const READ_FILE_TOOL = {
52
+ type: 'function',
53
+ function: {
54
+ name: 'read_file',
55
+ description: 'Read the contents of a file. Use before editing to see current content. Path is relative to the current working directory.',
56
+ parameters: {
57
+ type: 'object',
58
+ required: ['path'],
59
+ properties: {
60
+ path: {
61
+ type: 'string',
62
+ description: 'Relative path to the file (e.g. "src/index.js").',
63
+ },
64
+ },
65
+ },
66
+ },
67
+ };
68
+
69
+ /** Ollama-format tool definition for writing or overwriting a file */
70
+ export const WRITE_FILE_TOOL = {
71
+ type: 'function',
72
+ function: {
73
+ name: 'write_file',
74
+ description: 'Create or overwrite a file with the given content. Creates parent directories if needed. Use for new files or when replacing the entire file.',
75
+ parameters: {
76
+ type: 'object',
77
+ required: ['path', 'content'],
78
+ properties: {
79
+ path: {
80
+ type: 'string',
81
+ description: 'Relative path to the file (e.g. "src/App.js").',
82
+ },
83
+ content: {
84
+ type: 'string',
85
+ description: 'Full file content to write.',
86
+ },
87
+ },
88
+ },
89
+ },
90
+ };
91
+
92
+ /** Ollama-format tool definition for search-and-replace in a file */
93
+ export const SEARCH_REPLACE_TOOL = {
94
+ type: 'function',
95
+ function: {
96
+ name: 'search_replace',
97
+ description: 'Replace the first occurrence of old_string with new_string in a file. Use for small, targeted edits. old_string must match exactly (including whitespace).',
98
+ parameters: {
99
+ type: 'object',
100
+ required: ['path', 'old_string', 'new_string'],
101
+ properties: {
102
+ path: {
103
+ type: 'string',
104
+ description: 'Relative path to the file.',
105
+ },
106
+ old_string: {
107
+ type: 'string',
108
+ description: 'Exact substring to find and replace (first occurrence only).',
109
+ },
110
+ new_string: {
111
+ type: 'string',
112
+ description: 'Replacement text.',
113
+ },
114
+ },
115
+ },
116
+ },
117
+ };
118
+
119
+ /** Ollama-format tool definition for deleting a file */
120
+ export const DELETE_FILE_TOOL = {
121
+ type: 'function',
122
+ function: {
123
+ name: 'delete_file',
124
+ description: 'Delete a file. Does not remove directories.',
125
+ parameters: {
126
+ type: 'object',
127
+ required: ['path'],
128
+ properties: {
129
+ path: {
130
+ type: 'string',
131
+ description: 'Relative path to the file to delete.',
132
+ },
133
+ },
134
+ },
135
+ },
136
+ };
137
+
138
+ /** Ollama-format tool definition for listing directory contents */
139
+ export const LIST_DIR_TOOL = {
140
+ type: 'function',
141
+ function: {
142
+ name: 'list_dir',
143
+ description: 'List files and folders in a directory. Use to discover paths before reading or editing.',
144
+ parameters: {
145
+ type: 'object',
146
+ required: [],
147
+ properties: {
148
+ path: {
149
+ type: 'string',
150
+ description: 'Relative path to the directory (default "." for current directory).',
151
+ },
152
+ },
153
+ },
154
+ },
155
+ };
156
+
157
+ /** Tools array for agent loop (chatWithTools). */
158
+ export const AGENT_TOOLS = [
159
+ RUN_TERMINAL_COMMAND_TOOL,
160
+ CREATE_FOLDER_TOOL,
161
+ READ_FILE_TOOL,
162
+ WRITE_FILE_TOOL,
163
+ SEARCH_REPLACE_TOOL,
164
+ DELETE_FILE_TOOL,
165
+ LIST_DIR_TOOL,
166
+ ];
167
+
168
+ function getPath(args, opts) {
169
+ const raw = typeof args.path === 'string' ? args.path : String(args?.path ?? '');
170
+ const cwd = opts.cwd ?? process.cwd();
171
+ return { path: raw.trim(), cwd, absPath: resolve(cwd, raw.trim()) };
172
+ }
173
+
50
174
  const TOOLS_MAP = {
51
175
  create_folder: async (args, opts = {}) => {
52
176
  const folderPath = typeof args.path === 'string' ? args.path : String(args?.path ?? '');
@@ -62,6 +186,77 @@ const TOOLS_MAP = {
62
186
  return { success: false, error: err.message };
63
187
  }
64
188
  },
189
+ read_file: async (args, opts = {}) => {
190
+ const { path: relPath, absPath } = getPath(args, opts);
191
+ if (!relPath) return { error: 'Error: empty path' };
192
+ try {
193
+ if (!existsSync(absPath)) return { error: 'File not found' };
194
+ const stat = statSync(absPath);
195
+ if (!stat.isFile()) return { error: 'Not a file (is directory)' };
196
+ const content = readFileSync(absPath, 'utf-8');
197
+ return { content, path: relPath };
198
+ } catch (err) {
199
+ return { error: err.message };
200
+ }
201
+ },
202
+ write_file: async (args, opts = {}) => {
203
+ const { path: relPath, absPath } = getPath(args, opts);
204
+ const content = typeof args.content === 'string' ? args.content : String(args?.content ?? '');
205
+ if (!relPath) return { success: false, error: 'Error: empty path' };
206
+ try {
207
+ mkdirSync(dirname(absPath), { recursive: true });
208
+ writeFileSync(absPath, content, 'utf-8');
209
+ return { success: true, path: relPath };
210
+ } catch (err) {
211
+ return { success: false, error: err.message };
212
+ }
213
+ },
214
+ search_replace: async (args, opts = {}) => {
215
+ const { path: relPath, absPath } = getPath(args, opts);
216
+ const oldStr = typeof args.old_string === 'string' ? args.old_string : String(args?.old_string ?? '');
217
+ const newStr = typeof args.new_string === 'string' ? args.new_string : String(args?.new_string ?? '');
218
+ if (!relPath) return { success: false, error: 'Error: empty path' };
219
+ try {
220
+ if (!existsSync(absPath)) return { success: false, error: 'File not found' };
221
+ const content = readFileSync(absPath, 'utf-8');
222
+ const idx = content.indexOf(oldStr);
223
+ if (idx === -1) return { success: false, error: 'old_string not found in file' };
224
+ const newContent = content.slice(0, idx) + newStr + content.slice(idx + oldStr.length);
225
+ writeFileSync(absPath, newContent, 'utf-8');
226
+ return { success: true, path: relPath };
227
+ } catch (err) {
228
+ return { success: false, error: err.message };
229
+ }
230
+ },
231
+ delete_file: async (args, opts = {}) => {
232
+ const { path: relPath, absPath } = getPath(args, opts);
233
+ if (!relPath) return { success: false, error: 'Error: empty path' };
234
+ try {
235
+ if (!existsSync(absPath)) return { success: false, error: 'File not found' };
236
+ const stat = statSync(absPath);
237
+ if (!stat.isFile()) return { success: false, error: 'Not a file (use run_terminal_command to remove directories)' };
238
+ unlinkSync(absPath);
239
+ return { success: true, path: relPath };
240
+ } catch (err) {
241
+ return { success: false, error: err.message };
242
+ }
243
+ },
244
+ list_dir: async (args, opts = {}) => {
245
+ const cwd = opts.cwd ?? process.cwd();
246
+ const raw = typeof args.path === 'string' ? args.path : String(args?.path ?? '');
247
+ const dirPath = raw.trim() || '.';
248
+ const targetAbs = resolve(cwd, dirPath);
249
+ try {
250
+ if (!existsSync(targetAbs)) return { error: 'Directory not found' };
251
+ const stat = statSync(targetAbs);
252
+ if (!stat.isDirectory()) return { error: 'Not a directory' };
253
+ const entries = readdirSync(targetAbs, { withFileTypes: true });
254
+ const list = entries.map((e) => (e.isDirectory() ? `${e.name}/` : e.name));
255
+ return { path: dirPath, entries: list };
256
+ } catch (err) {
257
+ return { error: err.message };
258
+ }
259
+ },
65
260
  run_terminal_command: async (args, opts = {}) => {
66
261
  const command = typeof args.command === 'string' ? args.command : String(args?.command ?? '');
67
262
  const cwd = typeof args.cwd === 'string' ? args.cwd : opts.cwd ?? process.cwd();