markov-cli 1.0.3 โ†’ 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "markov-cli",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "LivingCloud's CLI AI Agent",
5
5
  "type": "module",
6
6
  "bin": {
package/src/editor.js CHANGED
@@ -141,16 +141,9 @@ export function renderDiff(filepath, newContent, cwd = process.cwd()) {
141
141
 
142
142
  export function parseEdits(responseText, loadedFiles = []) {
143
143
  const edits = [];
144
- // Match "FILE: path" followed by a fenced code block (so model can use FILE: path + ```lang)
145
- const filePrefixRegex = /FILE:\s*([^\n]+)\s*\n\s*```(?:[\w./\-]*)\n([\s\S]*?)```/g;
146
- let match;
147
- while ((match = filePrefixRegex.exec(responseText)) !== null) {
148
- const filepath = match[1].trim();
149
- const content = match[2];
150
- if (filepath) edits.push({ filepath, content });
151
- }
152
-
144
+ // File writes use !!write: in interactive.js; here we only handle fenced blocks with path/language or single attached file
153
145
  const regex = /```([\w./\-]*)\n([\s\S]*?)```/g;
146
+ let match;
154
147
  while ((match = regex.exec(responseText)) !== null) {
155
148
  const tag = match[1].trim();
156
149
  const content = match[2];
@@ -1,7 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import gradient from 'gradient-string';
3
3
  import { homedir } from 'os';
4
- import { resolve } from 'path';
4
+ import { resolve, relative, isAbsolute } from 'path';
5
5
  import { mkdirSync, rmSync, writeFileSync, unlinkSync, existsSync } from 'fs';
6
6
  import { join } from 'path';
7
7
  import { printLogo } from './ui/logo.js';
@@ -14,6 +14,15 @@ import { getFiles, getFilesAndDirs } from './ui/picker.js';
14
14
 
15
15
  const markovGradient = gradient(['#6366f1', '#a855f7', '#ec4899']);
16
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
+
17
26
  /** Extract every !!run: command from a model response. */
18
27
  function parseRunCommands(text) {
19
28
  const commands = [];
@@ -64,109 +73,163 @@ function parseDeleteCommands(text) {
64
73
  return paths;
65
74
  }
66
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
+
67
106
  /**
68
107
  * Parse and apply all file operations from a model reply.
69
108
  * Shows diffs/confirmations for each op. Returns updated allFiles list.
109
+ * options.autoConfirm: if true, skip y/n prompts and apply all ops.
70
110
  */
71
- async function handleFileOps(reply, loadedFiles) {
111
+ async function handleFileOps(reply, loadedFiles, options = {}) {
112
+ const autoConfirm = options.autoConfirm === true;
72
113
  let allFiles = getFilesAndDirs();
73
114
 
74
- // Run terminal commands
75
- for (const cmd of parseRunCommands(reply)) {
76
- const ok = await confirm(chalk.bold(`Run: ${chalk.cyan(cmd)}? [y/N] `));
77
- if (ok) {
78
- // cd must be handled in-process โ€” child processes can't change the parent's cwd
79
- const cdMatch = cmd.match(/^cd\s+(.+)$/);
80
- if (cdMatch) {
81
- const target = resolve(process.cwd(), cdMatch[1].trim().replace(/^~/, homedir()));
82
- try {
83
- process.chdir(target);
84
- allFiles = getFilesAndDirs();
85
- console.log(chalk.dim(` ๐Ÿ“ ${process.cwd()}\n`));
86
- } catch (err) {
87
- console.log(chalk.red(` no such directory: ${target}\n`));
88
- }
89
- continue;
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`));
90
127
  }
91
- process.stdout.write(chalk.dim(` running: ${cmd}\n`));
92
- const { stdout, stderr, exitCode } = await execCommand(cmd);
93
- const output = [stdout, stderr].filter(Boolean).join('\n').trim();
94
- console.log(output ? chalk.dim(output) + '\n' : chalk.dim(` (exit ${exitCode})\n`));
95
- allFiles = getFilesAndDirs();
96
128
  } else {
97
129
  console.log(chalk.dim('skipped\n'));
98
130
  }
99
131
  }
100
132
 
101
- // Write / edit files
102
- const edits = parseEdits(reply, loadedFiles);
103
- for (const { filepath, content } of edits) {
104
- renderDiff(filepath, content);
105
- const confirmed = await confirm(chalk.bold(`Write ${chalk.cyan(filepath)}? [y/N] `));
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] `));
106
137
  if (confirmed) {
107
138
  try {
108
- applyEdit(filepath, content);
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' });
109
143
  allFiles = getFilesAndDirs();
110
- console.log(chalk.green(`โœ“ wrote ${filepath}\n`));
144
+ console.log(chalk.green(`โœ“ created ${path}\n`));
111
145
  } catch (err) {
112
- console.log(chalk.red(`โœ— could not write ${filepath}: ${err.message}\n`));
146
+ console.log(chalk.red(`โœ— could not create ${path}: ${err.message}\n`));
113
147
  }
114
148
  } else {
115
149
  console.log(chalk.dim('skipped\n'));
116
150
  }
117
151
  }
118
152
 
119
- // Create folders
120
- for (const folderPath of parseMkdirCommands(reply)) {
121
- process.stdout.write(chalk.dim(` mkdir: ${folderPath} โ€” `));
122
- const confirmed = await confirm(chalk.bold(`Create folder ${chalk.cyan(folderPath)}? [y/N] `));
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] `));
123
158
  if (confirmed) {
124
159
  try {
125
- mkdirSync(resolve(process.cwd(), folderPath), { recursive: true });
160
+ applyEdit(path, content);
126
161
  allFiles = getFilesAndDirs();
127
- console.log(chalk.green(`โœ“ created ${folderPath}\n`));
162
+ console.log(chalk.green(`โœ“ wrote ${path}\n`));
128
163
  } catch (err) {
129
- console.log(chalk.red(`โœ— could not create ${folderPath}: ${err.message}\n`));
164
+ console.log(chalk.red(`โœ— could not write ${path}: ${err.message}\n`));
130
165
  }
131
166
  } else {
132
167
  console.log(chalk.dim('skipped\n'));
133
168
  }
134
169
  }
135
170
 
136
- // Create empty files
137
- for (const filePath of parseTouchCommands(reply)) {
138
- const confirmed = await confirm(chalk.bold(`Create file ${chalk.cyan(filePath)}? [y/N] `));
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] `));
139
177
  if (confirmed) {
140
178
  try {
141
- const abs = resolve(process.cwd(), filePath);
142
- const parentDir = abs.split('/').slice(0, -1).join('/');
143
- mkdirSync(parentDir, { recursive: true });
144
- writeFileSync(abs, '', { flag: 'wx' });
179
+ applyEdit(path, content);
145
180
  allFiles = getFilesAndDirs();
146
- console.log(chalk.green(`โœ“ created ${filePath}\n`));
181
+ console.log(chalk.green(`โœ“ wrote ${path}\n`));
147
182
  } catch (err) {
148
- console.log(chalk.red(`โœ— could not create ${filePath}: ${err.message}\n`));
183
+ console.log(chalk.red(`โœ— could not write ${path}: ${err.message}\n`));
149
184
  }
150
185
  } else {
151
186
  console.log(chalk.dim('skipped\n'));
152
187
  }
153
188
  }
154
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
+
155
217
  // Remove directories
156
218
  for (const dirPath of parseRmdirCommands(reply)) {
157
- const confirmed = await confirm(chalk.bold(`Remove directory ${chalk.cyan(dirPath)}? [y/N] `));
219
+ const path = toRelativePath(dirPath);
220
+ const confirmed = autoConfirm ? true : await confirm(chalk.bold(`Remove directory ${chalk.cyan(path)}? [y/N] `));
158
221
  if (confirmed) {
159
- const abs = resolve(process.cwd(), dirPath);
222
+ const abs = resolve(process.cwd(), path);
160
223
  if (existsSync(abs)) {
161
224
  try {
162
225
  rmSync(abs, { recursive: true, force: true });
163
226
  allFiles = getFilesAndDirs();
164
- console.log(chalk.green(`โœ“ removed ${dirPath}\n`));
227
+ console.log(chalk.green(`โœ“ removed ${path}\n`));
165
228
  } catch (err) {
166
- console.log(chalk.red(`โœ— could not remove ${dirPath}: ${err.message}\n`));
229
+ console.log(chalk.red(`โœ— could not remove ${path}: ${err.message}\n`));
167
230
  }
168
231
  } else {
169
- console.log(chalk.yellow(`โš  not found: ${dirPath}\n`));
232
+ console.log(chalk.yellow(`โš  not found: ${path}\n`));
170
233
  }
171
234
  } else {
172
235
  console.log(chalk.dim('skipped\n'));
@@ -175,19 +238,20 @@ async function handleFileOps(reply, loadedFiles) {
175
238
 
176
239
  // Delete files
177
240
  for (const filePath of parseDeleteCommands(reply)) {
178
- const confirmed = await confirm(chalk.bold(`Delete ${chalk.cyan(filePath)}? [y/N] `));
241
+ const path = toRelativePath(filePath);
242
+ const confirmed = autoConfirm ? true : await confirm(chalk.bold(`Delete ${chalk.cyan(path)}? [y/N] `));
179
243
  if (confirmed) {
180
- const abs = resolve(process.cwd(), filePath);
244
+ const abs = resolve(process.cwd(), path);
181
245
  if (existsSync(abs)) {
182
246
  try {
183
247
  unlinkSync(abs);
184
248
  allFiles = getFilesAndDirs();
185
- console.log(chalk.green(`โœ“ deleted ${filePath}\n`));
249
+ console.log(chalk.green(`โœ“ deleted ${path}\n`));
186
250
  } catch (err) {
187
- console.log(chalk.red(`โœ— could not delete ${filePath}: ${err.message}\n`));
251
+ console.log(chalk.red(`โœ— could not delete ${path}: ${err.message}\n`));
188
252
  }
189
253
  } else {
190
- console.log(chalk.yellow(`โš  not found: ${filePath}\n`));
254
+ console.log(chalk.yellow(`โš  not found: ${path}\n`));
191
255
  }
192
256
  } else {
193
257
  console.log(chalk.dim('skipped\n'));
@@ -354,14 +418,24 @@ function buildSystemMessage() {
354
418
  const fileList = files.length > 0 ? `\nFiles in working directory:\n${files.map(f => ` ${f}`).join('\n')}\n` : '';
355
419
  const fileOpsInstructions =
356
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` +
357
422
  `- Run a terminal command: output exactly on its own line: !!run: <command>\n` +
358
- `- Write or edit a file: output "FILE: path/to/file" followed by a fenced code block with the full file content.\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` +
359
424
  `- Create an empty file: output exactly on its own line: !!touch: path/to/file\n` +
360
425
  `- Create a folder: output exactly on its own line: !!mkdir: path/to/folder\n` +
361
426
  `- Remove a folder: output exactly on its own line: !!rmdir: path/to/folder\n` +
362
427
  `- Delete a file: output exactly on its own line: !!delete: path/to/file\n` +
363
428
  `- You may combine multiple operations in one response.\n` +
364
- `- NEVER put commands in fenced code blocks โ€” always use !!run: syntax for commands.\n`;
429
+ `- NEVER put commands in fenced code blocks โ€” always use !!run: syntax for commands.\n` +
430
+ `- NEVER use && inside !!run: โ€” use one !!run: per command. For "cd then do X", output !!run: cd <path> on one line, then !!run: <command> on the next (cd changes the working directory for all following commands).\n` +
431
+ `\nSETUP NEXT.JS APP (or any new project in a subfolder):\n` +
432
+ `1. Create the folder first: !!mkdir: next-app (or the requested name).\n` +
433
+ `2. Change into it on its own line: !!run: cd next-app (nothing after the path).\n` +
434
+ `3. Run each following command on its own !!run: line: e.g. !!run: npx create-next-app@latest . --yes, then !!run: git init, !!run: git add ., !!run: git commit -m "Initial commit".\n` +
435
+ `\nSETUP LARAVEL API (output exactly this):\n` +
436
+ `!!mkdir: laravel-api then !!run: cd laravel-api then !!run: composer create-project --prefer-dist laravel/laravel . then !!run: php artisan serve. One per line; nothing after the dot in composer; no composer run dev or dev-server. No custom routes.\n` +
437
+ `\nSETUP ROUTES (API only โ€” use when user asks to add routes, /health, /users, or API endpoints):\n` +
438
+ `Only after the Laravel app exists. Add in routes/api.php only (not web.php): GET /api/health returning response()->json(['status' => 'ok']), and /api/users as a resource (index, show, store, update, destroy) using User model and a controller. Register api in bootstrap/app.php if needed. Use !!write: for routes/api.php and the controller; prefix with laravel-api/ if cwd is parent. Do not use routes/web.php.\n`;
365
439
  return { role: 'system', content: `You are Markov, an AI coding assistant.\nWorking directory: ${process.cwd()}\n${fileList}${fileOpsInstructions}` };
366
440
  }
367
441
 
@@ -454,7 +528,7 @@ const HELP_TEXT =
454
528
  chalk.cyan(' /build') + chalk.dim(' execute the stored plan\n') +
455
529
  chalk.cyan(' /models') + chalk.dim(' switch the active AI model\n') +
456
530
  chalk.cyan(' /cd [path]') + chalk.dim(' change working directory\n') +
457
- chalk.dim('\nTips: use ') + chalk.cyan('@filename') + chalk.dim(' to attach a file ยท ctrl+q to cancel a response\n');
531
+ chalk.dim('\nNormal chat: plan then build (no y/n). Tips: ') + chalk.cyan('@filename') + chalk.dim(' to attach a file ยท ctrl+q to cancel\n');
458
532
 
459
533
  export async function startInteractive() {
460
534
  printLogo();
@@ -511,7 +585,7 @@ export async function startInteractive() {
511
585
  const planPrompt =
512
586
  `Create a detailed, numbered plan for the following task:\n\n${planRequest}\n\n` +
513
587
  `For each step, specify exactly what will happen and which syntax will be used:\n` +
514
- `- Writing or editing a file โ†’ FILE: path/to/file + fenced code block\n` +
588
+ `- Writing or editing a file โ†’ !!write: path/to/file then fenced code block\n` +
515
589
  `- Creating an empty file โ†’ !!touch: path/to/file\n` +
516
590
  `- Creating a folder โ†’ !!mkdir: path/to/folder\n` +
517
591
  `- Removing a folder โ†’ !!rmdir: path/to/folder\n` +
@@ -542,7 +616,7 @@ export async function startInteractive() {
542
616
  }
543
617
 
544
618
  const buildPrompt =
545
- `Execute the following plan. Use FILE: syntax for file writes, !!mkdir: for folders, !!delete: for deletions.\n\n` +
619
+ `Execute the following plan. Use !!write: path then a fenced code block for file writes, !!mkdir: for folders, !!delete: for deletions.\n\n` +
546
620
  `Plan:\n${currentPlan.text}`;
547
621
 
548
622
  chatMessages.push({ role: 'user', content: buildPrompt });
@@ -563,7 +637,7 @@ export async function startInteractive() {
563
637
  continue;
564
638
  }
565
639
 
566
- // Normal chat โ€” file ops always available
640
+ // Normal chat โ€” plan then build (two phases), apply file ops without y/n
567
641
  const { loaded, failed } = resolveFileRefs(trimmed);
568
642
 
569
643
  if (loaded.length > 0) {
@@ -573,19 +647,38 @@ export async function startInteractive() {
573
647
  console.log(chalk.yellow(`\nโš  not found: ${failed.map(f => `@${f}`).join(', ')}`));
574
648
  }
575
649
 
576
- // Store raw message โ€” @refs are re-resolved fresh on every API call
577
- chatMessages.push({ role: 'user', content: trimmed });
578
-
650
+ const planPrompt =
651
+ `Create a detailed, numbered plan for the following task:\n\n${trimmed}\n\n` +
652
+ `For each step, specify exactly what will happen and which syntax will be used:\n` +
653
+ `- Writing or editing a file โ†’ !!write: path/to/file then fenced code block\n` +
654
+ `- Creating an empty file โ†’ !!touch: path/to/file\n` +
655
+ `- Creating a folder โ†’ !!mkdir: path/to/folder\n` +
656
+ `- Removing a folder โ†’ !!rmdir: path/to/folder\n` +
657
+ `- Deleting a file โ†’ !!delete: path/to/file\n\n` +
658
+ `Do NOT output any actual file contents or commands yet โ€” only the plan.`;
659
+
660
+ chatMessages.push({ role: 'user', content: planPrompt });
579
661
  const abortController = new AbortController();
580
662
  try {
581
- const reply = await streamWithViewport(chatMessages, abortController.signal);
582
-
583
- if (reply === null) {
663
+ const planReply = await streamWithViewport(chatMessages, abortController.signal);
664
+ if (planReply === null) {
584
665
  chatMessages.pop();
585
- } else {
586
- chatMessages.push({ role: 'assistant', content: reply });
587
- allFiles = await handleFileOps(reply, loaded);
666
+ continue;
667
+ }
668
+ chatMessages.push({ role: 'assistant', content: planReply });
669
+
670
+ const buildPrompt =
671
+ `Execute the following plan. Use !!write: path then a fenced code block for file writes, !!mkdir: for folders, !!delete: for deletions.\n\n` +
672
+ `Plan:\n${planReply}`;
673
+
674
+ chatMessages.push({ role: 'user', content: buildPrompt });
675
+ const buildReply = await streamWithViewport(chatMessages, abortController.signal);
676
+ if (buildReply === null) {
677
+ chatMessages.pop(); // remove build prompt
678
+ continue;
588
679
  }
680
+ chatMessages.push({ role: 'assistant', content: buildReply });
681
+ allFiles = await handleFileOps(buildReply, loaded, { autoConfirm: true });
589
682
  } catch (err) {
590
683
  if (!abortController.signal.aborted) {
591
684
  console.log(chalk.red(`\n${err.message}\n`));