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 +2 -1
- package/src/auth.js +6 -0
- package/src/files.js +1 -3
- package/src/interactive.js +396 -453
- package/src/tools.js +197 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "markov-cli",
|
|
3
|
-
"version": "1.0.
|
|
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
|
|
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 };
|
package/src/interactive.js
CHANGED
|
@@ -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
|
|
5
|
-
import { mkdirSync,
|
|
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 {
|
|
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
|
|
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
|
-
/**
|
|
406
|
-
function
|
|
407
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
482
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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(' /
|
|
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.
|
|
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
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
|
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:
|
|
602
|
-
{ type: 'cd',
|
|
603
|
-
{ type: 'run',
|
|
604
|
-
{ type: 'run',
|
|
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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
|
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
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
708
|
-
|
|
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();
|