markov-cli 1.0.6 → 1.0.8
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 +3 -2
- package/src/auth.js +6 -0
- package/src/files.js +1 -3
- package/src/interactive.js +503 -452
- package/src/tools.js +197 -2
- package/src/ui/logo.js +22 -8
package/src/interactive.js
CHANGED
|
@@ -1,265 +1,19 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
+
|
|
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,
|
|
10
|
+
import { execCommand, AGENT_TOOLS, runTool } from './tools.js';
|
|
11
|
+
import { parseEdits, renderDiff, applyEdit } from './editor.js';
|
|
12
12
|
import { chatPrompt } from './input.js';
|
|
13
13
|
import { getFiles, getFilesAndDirs } from './ui/picker.js';
|
|
14
|
+
import { getToken, login, clearToken } from './auth.js';
|
|
14
15
|
|
|
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
|
-
}
|
|
16
|
+
const agentGradient = gradient(['#22c55e', '#16a34a', '#4ade80']);
|
|
263
17
|
|
|
264
18
|
/** Arrow-key selector. Returns the chosen string or null if cancelled. */
|
|
265
19
|
function selectFrom(options, label) {
|
|
@@ -376,7 +130,6 @@ function promptSecret(label) {
|
|
|
376
130
|
});
|
|
377
131
|
}
|
|
378
132
|
|
|
379
|
-
const VIEWPORT_LINES = 5;
|
|
380
133
|
const TERM_WIDTH = 80;
|
|
381
134
|
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
382
135
|
const visLen = (s) => s.replace(ANSI_RE, '').length;
|
|
@@ -402,148 +155,375 @@ function wrapText(text, width) {
|
|
|
402
155
|
}).join('\n');
|
|
403
156
|
}
|
|
404
157
|
|
|
405
|
-
/**
|
|
406
|
-
function
|
|
407
|
-
|
|
158
|
+
/** Extract numbered options (1. foo 2. bar 3. baz) from AI response text. Returns display strings or []. */
|
|
159
|
+
function extractNumberedOptions(text) {
|
|
160
|
+
if (!text) return [];
|
|
161
|
+
const re = /^\s*(\d+)\.\s+(.+)$/gm;
|
|
162
|
+
const items = [];
|
|
163
|
+
let match;
|
|
164
|
+
while ((match = re.exec(text)) !== null) {
|
|
165
|
+
items.push({ num: parseInt(match[1], 10), label: match[2].trim() });
|
|
166
|
+
}
|
|
167
|
+
let best = [];
|
|
168
|
+
for (let i = 0; i < items.length; i++) {
|
|
169
|
+
if (items[i].num !== 1) continue;
|
|
170
|
+
const run = [items[i]];
|
|
171
|
+
for (let j = i + 1; j < items.length; j++) {
|
|
172
|
+
if (items[j].num === run.length + 1) run.push(items[j]);
|
|
173
|
+
else break;
|
|
174
|
+
}
|
|
175
|
+
if (run.length > best.length) best = run;
|
|
176
|
+
}
|
|
177
|
+
return best.length >= 2 ? best.map(r => `${r.num}. ${r.label}`) : [];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Parse fenced code blocks (```lang\n...\n```) and render them with plain styling. Non-code segments are wrapped. */
|
|
181
|
+
function formatResponseWithCodeBlocks(text, width) {
|
|
182
|
+
if (!text || typeof text !== 'string') return '';
|
|
183
|
+
const re = /```(\w*)\n([\s\S]*?)```/g;
|
|
184
|
+
const parts = [];
|
|
185
|
+
let lastIndex = 0;
|
|
186
|
+
let m;
|
|
187
|
+
while ((m = re.exec(text)) !== null) {
|
|
188
|
+
if (m.index > lastIndex) {
|
|
189
|
+
const textSegment = text.slice(lastIndex, m.index);
|
|
190
|
+
if (textSegment) parts.push({ type: 'text', content: textSegment });
|
|
191
|
+
}
|
|
192
|
+
parts.push({ type: 'code', lang: m[1], content: m[2].trim() });
|
|
193
|
+
lastIndex = re.lastIndex;
|
|
194
|
+
}
|
|
195
|
+
if (lastIndex < text.length) {
|
|
196
|
+
const textSegment = text.slice(lastIndex);
|
|
197
|
+
if (textSegment) parts.push({ type: 'text', content: textSegment });
|
|
198
|
+
}
|
|
199
|
+
if (parts.length === 0) return wrapText(text, width);
|
|
200
|
+
return parts.map((p) => {
|
|
201
|
+
if (p.type === 'text') return wrapText(p.content, width);
|
|
202
|
+
const label = p.lang ? p.lang : 'code';
|
|
203
|
+
const header = chalk.dim('─── ') + chalk.cyan(label) + chalk.dim(' ' + '─'.repeat(Math.max(0, width - label.length - 5)));
|
|
204
|
+
const code = p.content.split('\n').map(l => chalk.dim(' ') + l).join('\n');
|
|
205
|
+
return header + '\n' + code + '\n' + chalk.dim('─'.repeat(width));
|
|
206
|
+
}).join('\n\n');
|
|
408
207
|
}
|
|
409
208
|
|
|
410
209
|
/**
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
210
|
+
* Scan AI response text for fenced code blocks that reference files.
|
|
211
|
+
* Show a diff preview for each and apply on user confirmation.
|
|
212
|
+
* @param {string} responseText - The AI's response content
|
|
213
|
+
* @param {string[]} loadedFiles - Files attached via @ref
|
|
415
214
|
*/
|
|
416
|
-
function
|
|
215
|
+
async function applyCodeBlockEdits(responseText, loadedFiles = []) {
|
|
216
|
+
const edits = parseEdits(responseText, loadedFiles);
|
|
217
|
+
if (edits.length === 0) return;
|
|
218
|
+
|
|
219
|
+
console.log(chalk.dim(`\n📝 Detected ${edits.length} file edit(s) in response:\n`));
|
|
220
|
+
|
|
221
|
+
for (const edit of edits) {
|
|
222
|
+
renderDiff(edit.filepath, edit.content);
|
|
223
|
+
const ok = await confirm(chalk.bold(`Apply changes to ${chalk.cyan(edit.filepath)}? [y/N] `));
|
|
224
|
+
if (ok) {
|
|
225
|
+
applyEdit(edit.filepath, edit.content);
|
|
226
|
+
console.log(chalk.green(` ✓ ${edit.filepath} updated\n`));
|
|
227
|
+
} else {
|
|
228
|
+
console.log(chalk.dim(` ✗ skipped ${edit.filepath}\n`));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Shared system message base: Markov intro, cwd, file list. */
|
|
234
|
+
function getSystemMessageBase() {
|
|
417
235
|
const files = getFiles();
|
|
418
236
|
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}` };
|
|
237
|
+
return `You are Markov, an AI coding assistant.\nWorking directory: ${process.cwd()}\n${fileList}`;
|
|
436
238
|
}
|
|
437
239
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
240
|
+
/** System message for agent mode: tool-only instructions (no !!run / !!write). */
|
|
241
|
+
function buildAgentSystemMessage() {
|
|
242
|
+
const toolInstructions =
|
|
243
|
+
`\nTOOL MODE — you have tools; use them. \n` +
|
|
244
|
+
`- run_terminal_command: run shell commands (npm install, npx create-next-app, etc.). One command per call.\n` +
|
|
245
|
+
`- create_folder: create directories.\n` +
|
|
246
|
+
`- read_file: read file contents before editing.\n` +
|
|
247
|
+
`- write_file: create or overwrite a file with full content.\n` +
|
|
248
|
+
`- search_replace: replace first occurrence of text in a file.\n` +
|
|
249
|
+
`- delete_file: delete a file.\n` +
|
|
250
|
+
`- list_dir: list directory contents (path optional, defaults to current dir).\n` +
|
|
251
|
+
`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` +
|
|
252
|
+
`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` +
|
|
253
|
+
`Use RELATIVE paths.\n`;
|
|
254
|
+
return { role: 'system', content: getSystemMessageBase() + toolInstructions };
|
|
255
|
+
}
|
|
451
256
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
257
|
+
const AGENT_LOOP_MAX_ITERATIONS = 20;
|
|
258
|
+
|
|
259
|
+
/** Preview of a file edit for confirmation (write_file / search_replace) */
|
|
260
|
+
function formatFileEditPreview(name, args) {
|
|
261
|
+
const path = args?.path ?? '(no path)';
|
|
262
|
+
if (name === 'search_replace') {
|
|
263
|
+
const oldStr = String(args?.old_string ?? '');
|
|
264
|
+
const newStr = String(args?.new_string ?? '');
|
|
265
|
+
const max = 120;
|
|
266
|
+
const oldPreview = oldStr.length > max ? oldStr.slice(0, max) + '…' : oldStr;
|
|
267
|
+
const newPreview = newStr.length > max ? newStr.slice(0, max) + '…' : newStr;
|
|
268
|
+
return (
|
|
269
|
+
chalk.cyan(path) + '\n' +
|
|
270
|
+
chalk.red(' - ' + (oldPreview || '(empty)').replace(/\n/g, '\n ')) + '\n' +
|
|
271
|
+
chalk.green(' + ' + (newPreview || '(empty)').replace(/\n/g, '\n '))
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
if (name === 'write_file') {
|
|
275
|
+
const content = String(args?.content ?? '');
|
|
276
|
+
const lines = content.split('\n');
|
|
277
|
+
const previewLines = lines.slice(0, 25);
|
|
278
|
+
const more = lines.length > 25 ? chalk.dim(` ... ${lines.length - 25} more lines`) : '';
|
|
279
|
+
return chalk.cyan(path) + '\n' + previewLines.map(l => ' ' + l).join('\n') + (more ? '\n' + more : '');
|
|
280
|
+
}
|
|
281
|
+
return path;
|
|
282
|
+
}
|
|
457
283
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
284
|
+
/** One-line summary of tool args for display */
|
|
285
|
+
function formatToolCallSummary(name, args) {
|
|
286
|
+
const a = args ?? {};
|
|
287
|
+
if (name === 'run_terminal_command') return (a.command ?? '').trim() || '(empty)';
|
|
288
|
+
if (name === 'write_file') return a.path ?? '(no path)';
|
|
289
|
+
if (name === 'search_replace') return (a.path ?? '') + (a.old_string ? ` "${String(a.old_string).slice(0, 30)}…"` : '');
|
|
290
|
+
if (name === 'read_file' || name === 'delete_file') return a.path ?? '(no path)';
|
|
291
|
+
if (name === 'create_folder') return a.path ?? '(no path)';
|
|
292
|
+
if (name === 'list_dir') return (a.path ?? '.') || '.';
|
|
293
|
+
return JSON.stringify(a).slice(0, 50);
|
|
294
|
+
}
|
|
463
295
|
|
|
296
|
+
/** One-line summary of tool result for display */
|
|
297
|
+
function formatToolResultSummary(name, resultJson) {
|
|
298
|
+
let obj;
|
|
464
299
|
try {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
300
|
+
obj = typeof resultJson === 'string' ? JSON.parse(resultJson) : resultJson;
|
|
301
|
+
} catch {
|
|
302
|
+
return resultJson?.slice(0, 60) ?? '—';
|
|
303
|
+
}
|
|
304
|
+
if (obj.error) return chalk.red('✗ ' + obj.error);
|
|
305
|
+
if (obj.declined) return chalk.yellow('✗ declined');
|
|
306
|
+
if (name === 'run_terminal_command') {
|
|
307
|
+
const code = obj.exitCode ?? obj.exit_code;
|
|
308
|
+
if (code === 0) return chalk.green('✓ exit 0') + (obj.stdout ? chalk.dim(' ' + String(obj.stdout).trim().slice(0, 80).replace(/\n/g, ' ')) : '');
|
|
309
|
+
return chalk.red(`✗ exit ${code}`) + (obj.stderr ? chalk.dim(' ' + String(obj.stderr).trim().slice(0, 80)) : '');
|
|
310
|
+
}
|
|
311
|
+
if (name === 'write_file' || name === 'search_replace' || name === 'delete_file' || name === 'create_folder') {
|
|
312
|
+
return obj.success !== false ? chalk.green('✓ ' + (obj.path ? obj.path : 'ok')) : chalk.red('✗ ' + (obj.error || 'failed'));
|
|
313
|
+
}
|
|
314
|
+
if (name === 'read_file') return obj.content != null ? chalk.green('✓ ' + (obj.path ?? '') + chalk.dim(` (${String(obj.content).length} chars)`)) : chalk.red('✗ ' + (obj.error || ''));
|
|
315
|
+
if (name === 'list_dir') return obj.entries ? chalk.green('✓ ' + (obj.entries.length ?? 0) + ' entries') : chalk.red('✗ ' + (obj.error || ''));
|
|
316
|
+
return chalk.dim(JSON.stringify(obj).slice(0, 60));
|
|
317
|
+
}
|
|
471
318
|
|
|
472
|
-
|
|
319
|
+
/**
|
|
320
|
+
* Run the agent loop: call chatWithTools, run any tool_calls locally, append results, repeat until the model returns a final response.
|
|
321
|
+
* 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.
|
|
322
|
+
* @param {Array<{ role: string; content?: string; tool_calls?: unknown[]; tool_name?: string }>} messages - Full message list (system + conversation)
|
|
323
|
+
* @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
|
|
324
|
+
* @returns {Promise<{ content: string; finalMessage: object } | null>} Final assistant content and message, or null if cancelled/error
|
|
325
|
+
*/
|
|
326
|
+
async function runAgentLoop(messages, opts = {}) {
|
|
327
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
328
|
+
const confirmFn = opts.confirmFn;
|
|
329
|
+
const confirmFileEdit = opts.confirmFileEdit;
|
|
330
|
+
const onBeforeToolRun = opts.onBeforeToolRun;
|
|
331
|
+
const onToolCall = opts.onToolCall;
|
|
332
|
+
const onToolResult = opts.onToolResult;
|
|
333
|
+
const onIteration = opts.onIteration;
|
|
334
|
+
const onThinking = opts.onThinking;
|
|
335
|
+
let iteration = 0;
|
|
336
|
+
|
|
337
|
+
while (iteration < AGENT_LOOP_MAX_ITERATIONS) {
|
|
338
|
+
iteration += 1;
|
|
339
|
+
onThinking?.(iteration);
|
|
340
|
+
const data = await chatWithTools(messages, AGENT_TOOLS, MODEL, opts.signal ?? null);
|
|
341
|
+
|
|
342
|
+
const message = data?.message;
|
|
343
|
+
if (!message) {
|
|
344
|
+
return { content: '', finalMessage: { role: 'assistant', content: '' } };
|
|
345
|
+
}
|
|
473
346
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const viewLines = lines;
|
|
479
|
-
const rendered = viewLines.join('\n');
|
|
347
|
+
const toolCalls = message.tool_calls;
|
|
348
|
+
if (!toolCalls || toolCalls.length === 0) {
|
|
349
|
+
return { content: message.content ?? '', finalMessage: message };
|
|
350
|
+
}
|
|
480
351
|
|
|
481
|
-
|
|
482
|
-
|
|
352
|
+
// Append assistant message with tool_calls
|
|
353
|
+
messages.push({
|
|
354
|
+
role: 'assistant',
|
|
355
|
+
content: message.content ?? '',
|
|
356
|
+
tool_calls: toolCalls,
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
onBeforeToolRun?.();
|
|
360
|
+
onIteration?.(iteration, AGENT_LOOP_MAX_ITERATIONS, toolCalls.length);
|
|
361
|
+
|
|
362
|
+
for (const tc of toolCalls) {
|
|
363
|
+
const name = tc?.function?.name;
|
|
364
|
+
const rawArgs = tc?.function?.arguments;
|
|
365
|
+
let args = rawArgs;
|
|
366
|
+
if (typeof rawArgs === 'string') {
|
|
367
|
+
try {
|
|
368
|
+
args = JSON.parse(rawArgs);
|
|
369
|
+
} catch {
|
|
370
|
+
messages.push({
|
|
371
|
+
role: 'tool',
|
|
372
|
+
tool_name: name ?? 'unknown',
|
|
373
|
+
content: JSON.stringify({ error: 'Invalid JSON in arguments' }),
|
|
374
|
+
});
|
|
375
|
+
if (onToolCall) onToolCall(name ?? 'unknown', {});
|
|
376
|
+
if (onToolResult) onToolResult(name ?? 'unknown', JSON.stringify({ error: 'Invalid JSON in arguments' }));
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
483
380
|
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
381
|
+
if (onToolCall) onToolCall(name ?? 'unknown', args ?? {});
|
|
382
|
+
|
|
383
|
+
const isFileEdit = name === 'write_file' || name === 'search_replace';
|
|
384
|
+
let result;
|
|
385
|
+
if (isFileEdit && confirmFileEdit) {
|
|
386
|
+
const ok = await confirmFileEdit(name, args ?? {});
|
|
387
|
+
if (!ok) {
|
|
388
|
+
result = JSON.stringify({ declined: true, message: 'User declined the change' });
|
|
389
|
+
if (onToolResult) onToolResult(name ?? 'unknown', result);
|
|
390
|
+
messages.push({
|
|
391
|
+
role: 'tool',
|
|
392
|
+
tool_name: name ?? 'unknown',
|
|
393
|
+
content: result,
|
|
394
|
+
});
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
487
397
|
}
|
|
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
398
|
|
|
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;
|
|
399
|
+
result = await runTool(name, args ?? {}, { cwd, confirmFn });
|
|
400
|
+
if (onToolResult) onToolResult(name ?? 'unknown', typeof result === 'string' ? result : JSON.stringify(result));
|
|
401
|
+
messages.push({
|
|
402
|
+
role: 'tool',
|
|
403
|
+
tool_name: name ?? 'unknown',
|
|
404
|
+
content: typeof result === 'string' ? result : JSON.stringify(result),
|
|
405
|
+
});
|
|
406
|
+
}
|
|
516
407
|
}
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
content: '(agent loop reached max iterations)',
|
|
411
|
+
finalMessage: { role: 'assistant', content: '(max iterations)' },
|
|
412
|
+
};
|
|
517
413
|
}
|
|
518
414
|
|
|
519
415
|
const HELP_TEXT =
|
|
520
416
|
'\n' +
|
|
521
417
|
chalk.bold('Commands:\n') +
|
|
522
418
|
chalk.cyan(' /help') + chalk.dim(' show this help\n') +
|
|
523
|
-
chalk.cyan(' /
|
|
419
|
+
chalk.cyan(' /agent') + chalk.dim(' [prompt] run with tools (run commands, create folders)\n') +
|
|
524
420
|
chalk.cyan(' /setup-nextjs') + chalk.dim(' scaffold a Next.js app\n') +
|
|
525
421
|
chalk.cyan(' /setup-laravel') + chalk.dim(' scaffold a Laravel API\n') +
|
|
526
422
|
chalk.cyan(' /models') + chalk.dim(' switch the active AI model\n') +
|
|
527
423
|
chalk.cyan(' /cd [path]') + chalk.dim(' change working directory\n') +
|
|
528
|
-
chalk.
|
|
424
|
+
chalk.cyan(' /login') + chalk.dim(' authenticate with email & password\n') +
|
|
425
|
+
chalk.cyan(' /logout') + chalk.dim(' clear saved auth token\n') +
|
|
426
|
+
chalk.dim('\nType a message · ') + chalk.cyan('@filename') + chalk.dim(' to attach · ctrl+q to cancel\n');
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Run a list of setup steps (mkdir / cd / run / write) in sequence.
|
|
430
|
+
* Returns true if all steps succeeded, false if any failed.
|
|
431
|
+
*/
|
|
432
|
+
async function runSetupSteps(steps) {
|
|
433
|
+
for (const step of steps) {
|
|
434
|
+
if (step.type === 'mkdir') {
|
|
435
|
+
process.stdout.write(chalk.dim(` mkdir: ${step.path}\n`));
|
|
436
|
+
try {
|
|
437
|
+
mkdirSync(resolve(process.cwd(), step.path), { recursive: true });
|
|
438
|
+
console.log(chalk.green(` ✓ created ${step.path}\n`));
|
|
439
|
+
} catch (err) {
|
|
440
|
+
console.log(chalk.red(` ✗ ${err.message}\n`));
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
} else if (step.type === 'cd') {
|
|
444
|
+
try {
|
|
445
|
+
process.chdir(resolve(process.cwd(), step.path));
|
|
446
|
+
console.log(chalk.dim(` 📁 ${process.cwd()}\n`));
|
|
447
|
+
} catch (err) {
|
|
448
|
+
console.log(chalk.red(` ✗ no such directory: ${step.path}\n`));
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
} else if (step.type === 'run') {
|
|
452
|
+
process.stdout.write(chalk.dim(` running: ${step.cmd}\n`));
|
|
453
|
+
const { stdout, stderr, exitCode } = await execCommand(step.cmd);
|
|
454
|
+
const output = [stdout, stderr].filter(Boolean).join('\n').trim();
|
|
455
|
+
if (output) console.log(chalk.dim(output));
|
|
456
|
+
if (exitCode !== 0) {
|
|
457
|
+
console.log(chalk.red(` ✗ command failed (exit ${exitCode})\n`));
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
console.log(chalk.green(` ✓ done\n`));
|
|
461
|
+
} else if (step.type === 'write') {
|
|
462
|
+
process.stdout.write(chalk.dim(` write: ${step.path}\n`));
|
|
463
|
+
try {
|
|
464
|
+
const abs = resolve(process.cwd(), step.path);
|
|
465
|
+
const dir = abs.split('/').slice(0, -1).join('/');
|
|
466
|
+
mkdirSync(dir, { recursive: true });
|
|
467
|
+
writeFileSync(abs, step.content);
|
|
468
|
+
console.log(chalk.green(` ✓ wrote ${step.path}\n`));
|
|
469
|
+
} catch (err) {
|
|
470
|
+
console.log(chalk.red(` ✗ ${err.message}\n`));
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return true;
|
|
476
|
+
}
|
|
529
477
|
|
|
530
478
|
export async function startInteractive() {
|
|
531
479
|
printLogo();
|
|
532
480
|
|
|
533
481
|
let allFiles = getFilesAndDirs();
|
|
534
482
|
const chatMessages = [];
|
|
535
|
-
let currentPlan = null; // { text: string } | null
|
|
536
483
|
|
|
537
484
|
console.log(chalk.dim(`Chat with Markov (${MODEL}).`));
|
|
538
485
|
console.log(HELP_TEXT);
|
|
539
486
|
|
|
487
|
+
if (!getToken()) {
|
|
488
|
+
console.log(chalk.yellow('⚠ Not logged in. Use /login to authenticate.\n'));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
let pendingMessage = null;
|
|
492
|
+
|
|
540
493
|
while (true) {
|
|
541
|
-
|
|
494
|
+
let raw;
|
|
495
|
+
if (pendingMessage) {
|
|
496
|
+
raw = pendingMessage;
|
|
497
|
+
pendingMessage = null;
|
|
498
|
+
console.log(chalk.magenta('you> ') + raw + '\n');
|
|
499
|
+
} else {
|
|
500
|
+
raw = await chatPrompt(chalk.magenta('you> '), allFiles);
|
|
501
|
+
}
|
|
542
502
|
if (raw === null) continue;
|
|
543
503
|
const trimmed = raw.trim();
|
|
544
504
|
|
|
545
505
|
if (!trimmed) continue;
|
|
546
506
|
|
|
507
|
+
// /login — authenticate and save token
|
|
508
|
+
if (trimmed === '/login') {
|
|
509
|
+
const email = await promptLine('Email: ');
|
|
510
|
+
const password = await promptSecret('Password: ');
|
|
511
|
+
try {
|
|
512
|
+
await login(email, password);
|
|
513
|
+
console.log(chalk.green('✓ logged in\n'));
|
|
514
|
+
} catch (err) {
|
|
515
|
+
console.log(chalk.red(`✗ ${err.message}\n`));
|
|
516
|
+
}
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// /logout — clear saved token
|
|
521
|
+
if (trimmed === '/logout') {
|
|
522
|
+
clearToken();
|
|
523
|
+
console.log(chalk.green('✓ logged out\n'));
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
|
|
547
527
|
// /help — list all commands
|
|
548
528
|
if (trimmed === '/help') {
|
|
549
529
|
console.log(HELP_TEXT);
|
|
@@ -576,137 +556,208 @@ export async function startInteractive() {
|
|
|
576
556
|
continue;
|
|
577
557
|
}
|
|
578
558
|
|
|
579
|
-
// /setup-nextjs — scaffold a Next.js app
|
|
559
|
+
// /setup-nextjs — scaffold a Next.js app via script
|
|
580
560
|
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
|
-
}
|
|
561
|
+
const name = (await promptLine(chalk.bold('App folder name: '))).trim() || 'next-app';
|
|
562
|
+
const steps = [
|
|
563
|
+
{ type: 'mkdir', path: name },
|
|
564
|
+
{ type: 'cd', path: name },
|
|
565
|
+
{ type: 'run', cmd: 'npx create-next-app@latest . --yes' },
|
|
566
|
+
{ type: 'run', cmd: 'npm install sass' },
|
|
567
|
+
];
|
|
568
|
+
const ok = await runSetupSteps(steps);
|
|
569
|
+
allFiles = getFilesAndDirs();
|
|
570
|
+
if (ok) console.log(chalk.green(`✓ Next.js app created in ${name}.\n`));
|
|
595
571
|
continue;
|
|
596
572
|
}
|
|
597
573
|
|
|
598
|
-
// /setup-laravel — scaffold a Laravel API
|
|
574
|
+
// /setup-laravel — scaffold a Laravel API via script
|
|
599
575
|
if (trimmed === '/setup-laravel') {
|
|
576
|
+
const name = (await promptLine(chalk.bold('App folder name: '))).trim() || 'laravel-api';
|
|
600
577
|
const steps = [
|
|
601
|
-
{ type: 'mkdir', path:
|
|
602
|
-
{ type: 'cd',
|
|
603
|
-
{ type: 'run',
|
|
604
|
-
{ type: 'run',
|
|
578
|
+
{ type: 'mkdir', path: name },
|
|
579
|
+
{ type: 'cd', path: name },
|
|
580
|
+
{ type: 'run', cmd: 'composer create-project --prefer-dist laravel/laravel .' },
|
|
581
|
+
{ type: 'run', cmd: 'php artisan serve' },
|
|
605
582
|
];
|
|
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'));
|
|
583
|
+
const ok = await runSetupSteps(steps);
|
|
584
|
+
allFiles = getFilesAndDirs();
|
|
585
|
+
if (ok) console.log(chalk.green(`✓ Laravel API created in ${name}.\n`));
|
|
640
586
|
continue;
|
|
641
587
|
}
|
|
642
588
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
589
|
+
// /agent [prompt] — run with tools (run_terminal_command, create_folder, file tools), loop until final response
|
|
590
|
+
if (trimmed === '/agent' || trimmed.startsWith('/agent ')) {
|
|
591
|
+
const userContent = trimmed.startsWith('/agent ')
|
|
592
|
+
? trimmed.slice(7).trim()
|
|
593
|
+
: (await promptLine(chalk.bold('Agent prompt: '))).trim();
|
|
594
|
+
if (!userContent) {
|
|
595
|
+
console.log(chalk.yellow('No prompt given.\n'));
|
|
648
596
|
continue;
|
|
649
597
|
}
|
|
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 });
|
|
598
|
+
chatMessages.push({ role: 'user', content: userContent });
|
|
599
|
+
const agentMessages = [buildAgentSystemMessage(), ...chatMessages];
|
|
658
600
|
const abortController = new AbortController();
|
|
601
|
+
const confirmFn = (cmd) => confirm(chalk.bold(`Run: ${cmd}? [y/N] `));
|
|
602
|
+
const confirmFileEdit = async (name, args) => {
|
|
603
|
+
console.log(chalk.dim('\n Proposed change:\n'));
|
|
604
|
+
console.log(formatFileEditPreview(name, args));
|
|
605
|
+
console.log('');
|
|
606
|
+
return confirm(chalk.bold('Apply this change? [y/N] '));
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
const startTime = Date.now();
|
|
610
|
+
const DOTS = ['.', '..', '...'];
|
|
611
|
+
let dotIdx = 0;
|
|
612
|
+
let spinner = null;
|
|
613
|
+
|
|
614
|
+
const startSpinner = () => {
|
|
615
|
+
if (spinner) { clearInterval(spinner); spinner = null; }
|
|
616
|
+
dotIdx = 0;
|
|
617
|
+
process.stdout.write(chalk.dim('\nAgent › '));
|
|
618
|
+
spinner = setInterval(() => {
|
|
619
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
620
|
+
process.stdout.write('\r' + chalk.dim('Agent › ') + chalk.dim(elapsed + 's ') + agentGradient(DOTS[dotIdx % DOTS.length]) + ' ');
|
|
621
|
+
dotIdx++;
|
|
622
|
+
}, 400);
|
|
623
|
+
};
|
|
624
|
+
const stopSpinner = () => {
|
|
625
|
+
if (spinner) { clearInterval(spinner); spinner = null; }
|
|
626
|
+
process.stdout.write('\r\x1b[0J');
|
|
627
|
+
};
|
|
628
|
+
|
|
659
629
|
try {
|
|
660
|
-
const
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
630
|
+
const result = await runAgentLoop(agentMessages, {
|
|
631
|
+
signal: abortController.signal,
|
|
632
|
+
cwd: process.cwd(),
|
|
633
|
+
confirmFn,
|
|
634
|
+
confirmFileEdit,
|
|
635
|
+
onThinking: () => {
|
|
636
|
+
startSpinner();
|
|
637
|
+
},
|
|
638
|
+
onBeforeToolRun: () => {
|
|
639
|
+
stopSpinner();
|
|
640
|
+
},
|
|
641
|
+
onIteration: (iter, max, toolCount) => {
|
|
642
|
+
const w = process.stdout.columns || 80;
|
|
643
|
+
const label = ` Step ${iter}/${max} `;
|
|
644
|
+
const line = chalk.dim('──') + chalk.bold.white(label) + chalk.dim('─'.repeat(Math.max(0, w - label.length - 2)));
|
|
645
|
+
console.log(line);
|
|
646
|
+
},
|
|
647
|
+
onToolCall: (name, args) => {
|
|
648
|
+
const summary = formatToolCallSummary(name, args);
|
|
649
|
+
console.log(chalk.cyan(' ▶ ') + chalk.bold(name) + chalk.dim(' ') + chalk.white(summary));
|
|
650
|
+
},
|
|
651
|
+
onToolResult: (name, resultStr) => {
|
|
652
|
+
console.log(chalk.dim(' ') + formatToolResultSummary(name, resultStr));
|
|
653
|
+
},
|
|
654
|
+
});
|
|
655
|
+
stopSpinner();
|
|
656
|
+
if (result) {
|
|
657
|
+
chatMessages.push(result.finalMessage);
|
|
658
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
659
|
+
const width = Math.min(process.stdout.columns || 80, 80);
|
|
660
|
+
process.stdout.write(formatResponseWithCodeBlocks(result.content, width) + '\n\n');
|
|
661
|
+
await applyCodeBlockEdits(result.content, []);
|
|
662
|
+
allFiles = getFilesAndDirs();
|
|
663
|
+
console.log(chalk.green(`✓ Agent done.`) + chalk.dim(` (${elapsed}s)\n`));
|
|
664
|
+
const opts1 = extractNumberedOptions(result.content);
|
|
665
|
+
if (opts1.length >= 2) {
|
|
666
|
+
const chosen = await selectFrom(opts1, 'Select an option:');
|
|
667
|
+
if (chosen) pendingMessage = chosen;
|
|
668
|
+
}
|
|
668
669
|
}
|
|
669
670
|
} catch (err) {
|
|
670
|
-
|
|
671
|
+
stopSpinner();
|
|
672
|
+
if (!abortController.signal?.aborted) console.log(chalk.red(`\n${err.message}\n`));
|
|
671
673
|
}
|
|
672
674
|
continue;
|
|
673
675
|
}
|
|
674
676
|
|
|
675
|
-
//
|
|
676
|
-
const { loaded, failed } = resolveFileRefs(trimmed);
|
|
677
|
-
|
|
677
|
+
// Handle message with agent (tools)
|
|
678
|
+
const { loaded, failed, content: resolvedContent } = await resolveFileRefs(trimmed);
|
|
678
679
|
if (loaded.length > 0) {
|
|
679
680
|
console.log(chalk.dim(`\n📎 attached: ${loaded.map(f => chalk.cyan(`@${f}`)).join(', ')}`));
|
|
680
681
|
}
|
|
681
682
|
if (failed.length > 0) {
|
|
682
683
|
console.log(chalk.yellow(`\n⚠ not found: ${failed.map(f => `@${f}`).join(', ')}`));
|
|
683
684
|
}
|
|
685
|
+
const userContent = resolvedContent ?? trimmed;
|
|
686
|
+
chatMessages.push({ role: 'user', content: userContent });
|
|
684
687
|
|
|
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 });
|
|
688
|
+
const agentMessages = [buildAgentSystemMessage(), ...chatMessages];
|
|
696
689
|
const abortController = new AbortController();
|
|
690
|
+
const confirmFn = (cmd) => confirm(chalk.bold(`Run: ${cmd}? [y/N] `));
|
|
691
|
+
const confirmFileEdit = async (name, args) => {
|
|
692
|
+
console.log(chalk.dim('\n Proposed change:\n'));
|
|
693
|
+
console.log(formatFileEditPreview(name, args));
|
|
694
|
+
console.log('');
|
|
695
|
+
return confirm(chalk.bold('Apply this change? [y/N] '));
|
|
696
|
+
};
|
|
697
|
+
const startTime = Date.now();
|
|
698
|
+
const DOTS = ['.', '..', '...'];
|
|
699
|
+
let dotIdx = 0;
|
|
700
|
+
let spinner = null;
|
|
701
|
+
|
|
702
|
+
const startSpinner = () => {
|
|
703
|
+
if (spinner) { clearInterval(spinner); spinner = null; }
|
|
704
|
+
dotIdx = 0;
|
|
705
|
+
process.stdout.write(chalk.dim('\nAgent › '));
|
|
706
|
+
spinner = setInterval(() => {
|
|
707
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
708
|
+
process.stdout.write('\r' + chalk.dim('Agent › ') + chalk.dim(elapsed + 's ') + agentGradient(DOTS[dotIdx % DOTS.length]) + ' ');
|
|
709
|
+
dotIdx++;
|
|
710
|
+
}, 400);
|
|
711
|
+
};
|
|
712
|
+
const stopSpinner = () => {
|
|
713
|
+
if (spinner) { clearInterval(spinner); spinner = null; }
|
|
714
|
+
process.stdout.write('\r\x1b[0J');
|
|
715
|
+
};
|
|
716
|
+
|
|
697
717
|
try {
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
718
|
+
const result = await runAgentLoop(agentMessages, {
|
|
719
|
+
signal: abortController.signal,
|
|
720
|
+
cwd: process.cwd(),
|
|
721
|
+
confirmFn,
|
|
722
|
+
confirmFileEdit,
|
|
723
|
+
onThinking: () => {
|
|
724
|
+
startSpinner();
|
|
725
|
+
},
|
|
726
|
+
onBeforeToolRun: () => {
|
|
727
|
+
stopSpinner();
|
|
728
|
+
},
|
|
729
|
+
onIteration: (iter, max, toolCount) => {
|
|
730
|
+
const w = process.stdout.columns || 80;
|
|
731
|
+
const label = ` Step ${iter}/${max} `;
|
|
732
|
+
const line = chalk.dim('──') + chalk.bold.white(label) + chalk.dim('─'.repeat(Math.max(0, w - label.length - 2)));
|
|
733
|
+
console.log(line);
|
|
734
|
+
},
|
|
735
|
+
onToolCall: (name, args) => {
|
|
736
|
+
const summary = formatToolCallSummary(name, args);
|
|
737
|
+
console.log(chalk.cyan(' ▶ ') + chalk.bold(name) + chalk.dim(' ') + chalk.white(summary));
|
|
738
|
+
},
|
|
739
|
+
onToolResult: (name, resultStr) => {
|
|
740
|
+
console.log(chalk.dim(' ') + formatToolResultSummary(name, resultStr));
|
|
741
|
+
},
|
|
742
|
+
});
|
|
743
|
+
stopSpinner();
|
|
744
|
+
if (result) {
|
|
745
|
+
chatMessages.push(result.finalMessage);
|
|
746
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
747
|
+
const width = Math.min(process.stdout.columns || 80, 80);
|
|
748
|
+
process.stdout.write(formatResponseWithCodeBlocks(result.content, width) + '\n\n');
|
|
749
|
+
await applyCodeBlockEdits(result.content, loaded);
|
|
750
|
+
allFiles = getFilesAndDirs();
|
|
751
|
+
console.log(chalk.green(`✓ Agent done.`) + chalk.dim(` (${elapsed}s)\n`));
|
|
752
|
+
const opts2 = extractNumberedOptions(result.content);
|
|
753
|
+
if (opts2.length >= 2) {
|
|
754
|
+
const chosen = await selectFrom(opts2, 'Select an option:');
|
|
755
|
+
if (chosen) pendingMessage = chosen;
|
|
756
|
+
}
|
|
705
757
|
}
|
|
706
758
|
} catch (err) {
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
}
|
|
759
|
+
stopSpinner();
|
|
760
|
+
if (!abortController.signal?.aborted) console.log(chalk.red(`\n${err.message}\n`));
|
|
710
761
|
}
|
|
711
762
|
}
|
|
712
763
|
}
|