markov-cli 1.0.6 → 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.6",
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,141 +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
- 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}`;
436
194
  }
437
195
 
438
- async function streamWithViewport(chatMessages, signal) {
439
- // Re-resolve @file refs fresh on every request so the model always sees the latest file contents.
440
- const resolvedMessages = chatMessages.map(msg => {
441
- if (msg.role !== 'user') return msg;
442
- const { content } = resolveFileRefs(msg.content);
443
- return { ...msg, content };
444
- });
445
-
446
- const DOTS = ['.', '..', '...'];
447
- let dotIdx = 0;
448
- let firstToken = true;
449
- let fullText = '';
450
- 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
+ }
451
212
 
452
- process.stdout.write(chalk.dim('\nMarkov '));
453
- const spinner = setInterval(() => {
454
- process.stdout.write('\r' + chalk.dim('Markov ') + markovGradient(DOTS[dotIdx % DOTS.length]) + ' ');
455
- dotIdx++;
456
- }, 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
+ }
457
239
 
458
- const onCancel = (data) => { if (data.toString() === '\x11') signal.abort(); };
459
- process.stdin.setRawMode(true);
460
- process.stdin.resume();
461
- process.stdin.setEncoding('utf8');
462
- 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
+ }
463
251
 
252
+ /** One-line summary of tool result for display */
253
+ function formatToolResultSummary(name, resultJson) {
254
+ let obj;
464
255
  try {
465
- const reply = await streamChat([buildSystemMessage(), ...resolvedMessages], (token) => {
466
- if (firstToken) {
467
- clearInterval(spinner);
468
- firstToken = false;
469
- process.stdout.write('\r\x1b[0J' + chalk.dim('Markov ›\n\n'));
470
- }
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
+ }
471
274
 
472
- 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
+ }
473
304
 
474
- // Build the viewport: last VIEWPORT_LINES lines of buffered text.
475
- const w = process.stdout.columns || TERM_WIDTH;
476
- const displayWidth = Math.min(w, TERM_WIDTH);
477
- const lines = wrapText(fullText, displayWidth).split('\n');
478
- const viewLines = lines;
479
- 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
+ });
480
311
 
481
- // Count actual terminal rows rendered (accounts for line wrapping).
482
- 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
+ }
483
332
 
484
- // Move cursor up to top of current viewport, clear down, rewrite.
485
- if (viewportRows > 0) {
486
- 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
+ }
487
349
  }
488
- process.stdout.write(rendered);
489
- viewportRows = newRows;
490
- }, undefined, signal);
491
-
492
- process.stdin.removeListener('data', onCancel);
493
- process.stdin.setRawMode(false);
494
- process.stdin.pause();
495
- clearInterval(spinner);
496
-
497
- if (signal.aborted) {
498
- if (viewportRows > 0) process.stdout.write(`\x1b[${viewportRows}A\r\x1b[0J`);
499
- console.log(chalk.dim('(cancelled)\n'));
500
- return null;
501
- }
502
350
 
503
- // Replace viewport with the full response.
504
- if (viewportRows > 0) process.stdout.write(`\x1b[${viewportRows}A\r\x1b[0J`);
505
- const finalWidth = Math.min(process.stdout.columns || TERM_WIDTH, TERM_WIDTH);
506
- process.stdout.write(wrapText(fullText, finalWidth) + '\n\n');
507
-
508
- return reply;
509
- } catch (err) {
510
- clearInterval(spinner);
511
- process.stdin.removeListener('data', onCancel);
512
- process.stdin.setRawMode(false);
513
- process.stdin.pause();
514
- if (!signal.aborted) throw err;
515
- 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
+ }
516
359
  }
360
+
361
+ return {
362
+ content: '(agent loop reached max iterations)',
363
+ finalMessage: { role: 'assistant', content: '(max iterations)' },
364
+ };
517
365
  }
518
366
 
519
367
  const HELP_TEXT =
520
368
  '\n' +
521
369
  chalk.bold('Commands:\n') +
522
370
  chalk.cyan(' /help') + chalk.dim(' show this help\n') +
523
- 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') +
524
372
  chalk.cyan(' /setup-nextjs') + chalk.dim(' scaffold a Next.js app\n') +
525
373
  chalk.cyan(' /setup-laravel') + chalk.dim(' scaffold a Laravel API\n') +
526
374
  chalk.cyan(' /models') + chalk.dim(' switch the active AI model\n') +
527
375
  chalk.cyan(' /cd [path]') + chalk.dim(' change working directory\n') +
528
- chalk.dim('\nNormal chat: generates a plan · /build to execute · ') + 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
+ }
529
429
 
530
430
  export async function startInteractive() {
531
431
  printLogo();
532
432
 
533
433
  let allFiles = getFilesAndDirs();
534
434
  const chatMessages = [];
535
- let currentPlan = null; // { text: string } | null
536
435
 
537
436
  console.log(chalk.dim(`Chat with Markov (${MODEL}).`));
538
437
  console.log(HELP_TEXT);
539
438
 
439
+ if (!getToken()) {
440
+ console.log(chalk.yellow('⚠ Not logged in. Use /login to authenticate.\n'));
441
+ }
442
+
540
443
  while (true) {
541
444
  const raw = await chatPrompt(chalk.magenta('you> '), allFiles);
542
445
  if (raw === null) continue;
@@ -544,6 +447,26 @@ export async function startInteractive() {
544
447
 
545
448
  if (!trimmed) continue;
546
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
+
547
470
  // /help — list all commands
548
471
  if (trimmed === '/help') {
549
472
  console.log(HELP_TEXT);
@@ -576,137 +499,157 @@ export async function startInteractive() {
576
499
  continue;
577
500
  }
578
501
 
579
- // /setup-nextjs — scaffold a Next.js app
502
+ // /setup-nextjs — scaffold a Next.js app via script
580
503
  if (trimmed === '/setup-nextjs') {
581
- const msg = 'Set up a new Next.js app in a subfolder. Follow the SETUP NEXT.JS APP instructions exactly.';
582
- chatMessages.push({ role: 'user', content: msg });
583
- const abortController = new AbortController();
584
- try {
585
- const reply = await streamWithViewport(chatMessages, abortController.signal);
586
- if (reply === null) { chatMessages.pop(); }
587
- else {
588
- chatMessages.push({ role: 'assistant', content: reply });
589
- allFiles = await handleFileOps(reply, [], { autoConfirm: true });
590
- console.log(chalk.green('✓ Next.js app created.\n'));
591
- }
592
- } catch (err) {
593
- if (!abortController.signal.aborted) console.log(chalk.red(`\n${err.message}\n`));
594
- }
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`));
595
514
  continue;
596
515
  }
597
516
 
598
- // /setup-laravel — scaffold a Laravel API (hardcoded, no AI)
517
+ // /setup-laravel — scaffold a Laravel API via script
599
518
  if (trimmed === '/setup-laravel') {
519
+ const name = (await promptLine(chalk.bold('App folder name: '))).trim() || 'laravel-api';
600
520
  const steps = [
601
- { type: 'mkdir', path: 'laravel-api' },
602
- { type: 'cd', path: 'laravel-api' },
603
- { type: 'run', cmd: 'composer create-project --prefer-dist laravel/laravel .' },
604
- { type: 'run', cmd: 'php artisan serve' },
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' },
605
525
  ];
606
- for (const step of steps) {
607
- if (step.type === 'mkdir') {
608
- process.stdout.write(chalk.dim(` mkdir: ${step.path}\n`));
609
- try {
610
- mkdirSync(resolve(process.cwd(), step.path), { recursive: true });
611
- allFiles = getFilesAndDirs();
612
- console.log(chalk.green(` ✓ created ${step.path}\n`));
613
- } catch (err) {
614
- console.log(chalk.red(` ✗ ${err.message}\n`));
615
- break;
616
- }
617
- } else if (step.type === 'cd') {
618
- try {
619
- process.chdir(resolve(process.cwd(), step.path));
620
- allFiles = getFilesAndDirs();
621
- console.log(chalk.dim(` 📁 ${process.cwd()}\n`));
622
- } catch (err) {
623
- console.log(chalk.red(` ✗ no such directory: ${step.path}\n`));
624
- break;
625
- }
626
- } else if (step.type === 'run') {
627
- process.stdout.write(chalk.dim(` running: ${step.cmd}\n`));
628
- const { stdout, stderr, exitCode } = await execCommand(step.cmd);
629
- const output = [stdout, stderr].filter(Boolean).join('\n').trim();
630
- if (output) console.log(chalk.dim(output));
631
- if (exitCode !== 0) {
632
- console.log(chalk.red(` ✗ command failed (exit ${exitCode})\n`));
633
- break;
634
- }
635
- console.log(chalk.green(` ✓ done\n`));
636
- allFiles = getFilesAndDirs();
637
- }
638
- }
639
- console.log(chalk.green('✓ Laravel API created.\n'));
526
+ const ok = await runSetupSteps(steps);
527
+ allFiles = getFilesAndDirs();
528
+ if (ok) console.log(chalk.green(`✓ Laravel API created in ${name}.\n`));
640
529
  continue;
641
530
  }
642
531
 
643
-
644
- // /build execute the stored plan with file ops
645
- if (trimmed === '/build') {
646
- if (!currentPlan) {
647
- console.log(chalk.yellow('\n⚠ No plan stored. Describe what you want first.\n'));
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'));
648
539
  continue;
649
540
  }
650
-
651
- const autoConfirm = await confirm(chalk.bold('Auto-confirm all operations? [y/N] '));
652
-
653
- const buildPrompt =
654
- `Execute the following plan. Use !!write: path then a fenced code block for file writes, !!mkdir: for folders, !!delete: for deletions.\n\n` +
655
- `Plan:\n${currentPlan.text}`;
656
-
657
- chatMessages.push({ role: 'user', content: buildPrompt });
541
+ chatMessages.push({ role: 'user', content: userContent });
542
+ const agentMessages = [buildAgentSystemMessage(), ...chatMessages];
658
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
+
659
560
  try {
660
- const reply = await streamWithViewport(chatMessages, abortController.signal);
661
- if (reply === null) {
662
- chatMessages.pop();
663
- } else {
664
- chatMessages.push({ role: 'assistant', content: reply });
665
- allFiles = await handleFileOps(reply, [], { autoConfirm });
666
- currentPlan = null;
667
- 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'));
668
586
  }
669
587
  } catch (err) {
670
- 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`));
671
591
  }
672
592
  continue;
673
593
  }
674
594
 
675
- // Normal chat generate a plan, store it, require /build to execute
676
- const { loaded, failed } = resolveFileRefs(trimmed);
677
-
595
+ // Handle message with agent (tools)
596
+ const { loaded, failed, content: resolvedContent } = await resolveFileRefs(trimmed);
678
597
  if (loaded.length > 0) {
679
598
  console.log(chalk.dim(`\n📎 attached: ${loaded.map(f => chalk.cyan(`@${f}`)).join(', ')}`));
680
599
  }
681
600
  if (failed.length > 0) {
682
601
  console.log(chalk.yellow(`\n⚠ not found: ${failed.map(f => `@${f}`).join(', ')}`));
683
602
  }
603
+ const userContent = resolvedContent ?? trimmed;
604
+ chatMessages.push({ role: 'user', content: userContent });
684
605
 
685
- const planPrompt =
686
- `Create a detailed, numbered plan for the following task:\n\n${trimmed}\n\n` +
687
- `For each step, specify exactly what will happen and which syntax will be used:\n` +
688
- `- Writing or editing a file → !!write: path/to/file then fenced code block\n` +
689
- `- Creating an empty file → !!touch: path/to/file\n` +
690
- `- Creating a folder → !!mkdir: path/to/folder\n` +
691
- `- Removing a folder → !!rmdir: path/to/folder\n` +
692
- `- Deleting a file → !!delete: path/to/file\n\n` +
693
- `Do NOT output any actual file contents or commands yet — only the plan.`;
694
-
695
- chatMessages.push({ role: 'user', content: planPrompt });
606
+ const agentMessages = [buildAgentSystemMessage(), ...chatMessages];
696
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);
697
622
  try {
698
- const reply = await streamWithViewport(chatMessages, abortController.signal);
699
- if (reply === null) {
700
- chatMessages.pop();
701
- } else {
702
- chatMessages.push({ role: 'assistant', content: reply });
703
- currentPlan = { text: reply };
704
- console.log(chalk.green('\n✓ Plan stored. Use /build to execute it.\n'));
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'));
705
648
  }
706
649
  } catch (err) {
707
- if (!abortController.signal.aborted) {
708
- console.log(chalk.red(`\n${err.message}\n`));
709
- }
650
+ clearInterval(spinner);
651
+ process.stdout.write('\r\x1b[0J');
652
+ if (!abortController.signal?.aborted) console.log(chalk.red(`\n${err.message}\n`));
710
653
  }
711
654
  }
712
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();