markov-cli 1.0.1 → 1.0.3

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,7 +1,7 @@
1
1
  {
2
2
  "name": "markov-cli",
3
- "version": "1.0.1",
4
- "description": "A friendly CLI tool",
3
+ "version": "1.0.3",
4
+ "description": "LivingCloud's CLI AI Agent",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "markov": "./bin/markov.js"
@@ -11,6 +11,8 @@
11
11
  },
12
12
  "keywords": [
13
13
  "cli",
14
+ "livingcloud",
15
+ "livingcloud-cli",
14
16
  "markov",
15
17
  "markov-cli",
16
18
  "agent",
package/src/editor.js CHANGED
@@ -1,5 +1,5 @@
1
- import { writeFileSync, readFileSync, existsSync } from 'fs';
2
- import { join, extname } from 'path';
1
+ import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
2
+ import { join, extname, dirname } from 'path';
3
3
  import chalk from 'chalk';
4
4
 
5
5
  // Maps common language identifiers (including short aliases) to file extensions.
@@ -141,9 +141,16 @@ export function renderDiff(filepath, newContent, cwd = process.cwd()) {
141
141
 
142
142
  export function parseEdits(responseText, loadedFiles = []) {
143
143
  const edits = [];
144
- const regex = /```([\w./\-]*)\n([\s\S]*?)```/g;
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;
145
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
+ }
146
152
 
153
+ const regex = /```([\w./\-]*)\n([\s\S]*?)```/g;
147
154
  while ((match = regex.exec(responseText)) !== null) {
148
155
  const tag = match[1].trim();
149
156
  const content = match[2];
@@ -169,5 +176,7 @@ export function parseEdits(responseText, loadedFiles = []) {
169
176
  }
170
177
 
171
178
  export function applyEdit(filepath, content, cwd = process.cwd()) {
172
- writeFileSync(join(cwd, filepath), content, 'utf-8');
179
+ const abs = join(cwd, filepath);
180
+ mkdirSync(dirname(abs), { recursive: true });
181
+ writeFileSync(abs, content, 'utf-8');
173
182
  }
package/src/files.js CHANGED
@@ -1,8 +1,20 @@
1
- import { readFileSync, existsSync } from 'fs';
2
- import { join } from 'path';
1
+ import { readFileSync, existsSync, statSync } from 'fs';
2
+ import { join, relative } from 'path';
3
+ import { getFiles } from './ui/picker.js';
3
4
 
4
5
  const FILE_REF_REGEX = /@([\w./\-]+)/g;
5
6
 
7
+ /** Infer language for fenced block from file extension */
8
+ function langForPath(path) {
9
+ const ext = path.split('.').pop()?.toLowerCase();
10
+ const map = {
11
+ js: 'javascript', ts: 'typescript', tsx: 'tsx', jsx: 'jsx',
12
+ html: 'html', css: 'css', scss: 'scss', json: 'json',
13
+ md: 'markdown', py: 'python', sh: 'shell', yaml: 'yaml', yml: 'yaml',
14
+ };
15
+ return map[ext] ?? 'text';
16
+ }
17
+
6
18
  /**
7
19
  * Finds all @ref patterns in a string and returns the unique file paths.
8
20
  */
@@ -29,15 +41,34 @@ export function resolveFileRefs(input, cwd = process.cwd()) {
29
41
  const blocks = [];
30
42
 
31
43
  for (const ref of refs) {
32
- const fullPath = join(cwd, ref);
44
+ const cleanRef = ref.endsWith('/') ? ref.slice(0, -1) : ref;
45
+ const fullPath = join(cwd, cleanRef);
33
46
  if (!existsSync(fullPath)) {
34
47
  failed.push(ref);
35
48
  continue;
36
49
  }
50
+
51
+ // If it's a directory, expand to all files inside
52
+ if (statSync(fullPath).isDirectory()) {
53
+ const dirFiles = getFiles(fullPath, cwd);
54
+ for (const filePath of dirFiles) {
55
+ try {
56
+ const text = readFileSync(join(cwd, filePath), 'utf-8');
57
+ const lang = langForPath(filePath);
58
+ blocks.push(`FILE: ${filePath}\n\`\`\`${lang}\n${text}\n\`\`\``);
59
+ loaded.push(filePath);
60
+ } catch {
61
+ failed.push(filePath);
62
+ }
63
+ }
64
+ continue;
65
+ }
66
+
37
67
  try {
38
68
  const text = readFileSync(fullPath, 'utf-8');
39
- blocks.push(`--- @${ref} ---\n${text}\n---`);
40
- loaded.push(ref);
69
+ const lang = langForPath(cleanRef);
70
+ blocks.push(`FILE: ${cleanRef}\n\`\`\`${lang}\n${text}\n\`\`\``);
71
+ loaded.push(cleanRef);
41
72
  } catch {
42
73
  failed.push(ref);
43
74
  }
package/src/input.js CHANGED
@@ -7,7 +7,7 @@ const visibleLen = (s) => s.replace(/\x1b\[[0-9;]*m/g, '').length;
7
7
 
8
8
  const PREFIX = '❯ ';
9
9
  const HINT = chalk.dim(' Ask Markov anything...');
10
- const STATUS_LEFT = chalk.dim('ctrl+q cancel · @ attach files · /cd change dir · /models switch model · /login auth');
10
+ const STATUS_LEFT = chalk.dim('/help');
11
11
  const PICKER_MAX = 6;
12
12
 
13
13
  function border() {
@@ -83,13 +83,22 @@ export function chatPrompt(_promptStr, allFiles) {
83
83
  stdout.write(rows.join('\n'));
84
84
 
85
85
  // rows = [border, inputLine, border, ...pickerRows(N), statusBar]
86
- // inputLine is always at index 1. After writing all rows cursor is on
87
- // statusBar. Move up (pickerRows.length + 2) to land back on inputLine.
86
+ // upAmount is always pickerRows.length + 2 regardless of input line wrapping
87
+ // because the extra visual lines from wrapping and the shifted target cancel out.
88
88
  const upAmount = pickerRows.length + 2;
89
89
  stdout.write(`\x1b[${upAmount}A\r`);
90
- cursorLineOffset = 1; // inputLine is always 1 line from the top
91
90
 
92
- const col = visibleLen(chalk.cyan(PREFIX)) + visibleLen(buffer) + 1;
91
+ // Account for input line wrapping: if the input is wider than the terminal,
92
+ // it takes multiple visual lines and we must move up by that many to reach
93
+ // the top border on the next redraw.
94
+ const w = process.stdout.columns || 80;
95
+ const inputVisualLen = visibleLen(chalk.cyan(PREFIX)) + visibleLen(buffer || HINT);
96
+ const inputVisualLines = Math.max(1, Math.ceil(inputVisualLen / w));
97
+ cursorLineOffset = inputVisualLines;
98
+
99
+ // Position cursor at the correct column on the last visual line of the input.
100
+ const totalInputLen = visibleLen(chalk.cyan(PREFIX)) + visibleLen(buffer);
101
+ const col = (totalInputLen % w) + 1;
93
102
  stdout.write(`\x1b[${col}G`);
94
103
  };
95
104
 
@@ -1,13 +1,201 @@
1
1
  import chalk from 'chalk';
2
+ import gradient from 'gradient-string';
2
3
  import { homedir } from 'os';
3
4
  import { resolve } from 'path';
5
+ import { mkdirSync, rmSync, writeFileSync, unlinkSync, existsSync } from 'fs';
6
+ import { join } from 'path';
4
7
  import { printLogo } from './ui/logo.js';
5
8
  import { streamChat, MODEL, MODELS, setModel } from './ollama.js';
6
9
  import { resolveFileRefs } from './files.js';
10
+ import { execCommand } from './tools.js';
7
11
  import { parseEdits, applyEdit, renderDiff } from './editor.js';
8
12
  import { chatPrompt } from './input.js';
9
- import { getFiles } from './ui/picker.js';
10
- import { getToken, login } from './auth.js';
13
+ import { getFiles, getFilesAndDirs } from './ui/picker.js';
14
+
15
+ const markovGradient = gradient(['#6366f1', '#a855f7', '#ec4899']);
16
+
17
+ /** Extract every !!run: command from a model response. */
18
+ function parseRunCommands(text) {
19
+ const commands = [];
20
+ for (const line of text.split('\n')) {
21
+ const match = line.match(/^!!run:\s*(.+)$/);
22
+ if (match) commands.push(match[1].trim());
23
+ }
24
+ return commands;
25
+ }
26
+
27
+ /** Extract every !!mkdir: path from a model response. */
28
+ function parseMkdirCommands(text) {
29
+ const paths = [];
30
+ for (const line of text.split('\n')) {
31
+ const match = line.match(/^!!mkdir:\s*(.+)$/);
32
+ if (match) paths.push(match[1].trim());
33
+ }
34
+ return paths;
35
+ }
36
+
37
+ /** Extract every !!rmdir: path from a model response. */
38
+ function parseRmdirCommands(text) {
39
+ const paths = [];
40
+ for (const line of text.split('\n')) {
41
+ const match = line.match(/^!!rmdir:\s*(.+)$/);
42
+ if (match) paths.push(match[1].trim());
43
+ }
44
+ return paths;
45
+ }
46
+
47
+ /** Extract every !!touch: path from a model response. */
48
+ function parseTouchCommands(text) {
49
+ const paths = [];
50
+ for (const line of text.split('\n')) {
51
+ const match = line.match(/^!!touch:\s*(.+)$/);
52
+ if (match) paths.push(match[1].trim());
53
+ }
54
+ return paths;
55
+ }
56
+
57
+ /** Extract every !!delete: path from a model response. */
58
+ function parseDeleteCommands(text) {
59
+ const paths = [];
60
+ for (const line of text.split('\n')) {
61
+ const match = line.match(/^!!delete:\s*(.+)$/);
62
+ if (match) paths.push(match[1].trim());
63
+ }
64
+ return paths;
65
+ }
66
+
67
+ /**
68
+ * Parse and apply all file operations from a model reply.
69
+ * Shows diffs/confirmations for each op. Returns updated allFiles list.
70
+ */
71
+ async function handleFileOps(reply, loadedFiles) {
72
+ let allFiles = getFilesAndDirs();
73
+
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;
90
+ }
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
+ } else {
97
+ console.log(chalk.dim('skipped\n'));
98
+ }
99
+ }
100
+
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] `));
106
+ if (confirmed) {
107
+ try {
108
+ applyEdit(filepath, content);
109
+ allFiles = getFilesAndDirs();
110
+ console.log(chalk.green(`✓ wrote ${filepath}\n`));
111
+ } catch (err) {
112
+ console.log(chalk.red(`✗ could not write ${filepath}: ${err.message}\n`));
113
+ }
114
+ } else {
115
+ console.log(chalk.dim('skipped\n'));
116
+ }
117
+ }
118
+
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] `));
123
+ if (confirmed) {
124
+ try {
125
+ mkdirSync(resolve(process.cwd(), folderPath), { recursive: true });
126
+ allFiles = getFilesAndDirs();
127
+ console.log(chalk.green(`✓ created ${folderPath}\n`));
128
+ } catch (err) {
129
+ console.log(chalk.red(`✗ could not create ${folderPath}: ${err.message}\n`));
130
+ }
131
+ } else {
132
+ console.log(chalk.dim('skipped\n'));
133
+ }
134
+ }
135
+
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] `));
139
+ if (confirmed) {
140
+ 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' });
145
+ allFiles = getFilesAndDirs();
146
+ console.log(chalk.green(`✓ created ${filePath}\n`));
147
+ } catch (err) {
148
+ console.log(chalk.red(`✗ could not create ${filePath}: ${err.message}\n`));
149
+ }
150
+ } else {
151
+ console.log(chalk.dim('skipped\n'));
152
+ }
153
+ }
154
+
155
+ // Remove directories
156
+ for (const dirPath of parseRmdirCommands(reply)) {
157
+ const confirmed = await confirm(chalk.bold(`Remove directory ${chalk.cyan(dirPath)}? [y/N] `));
158
+ if (confirmed) {
159
+ const abs = resolve(process.cwd(), dirPath);
160
+ if (existsSync(abs)) {
161
+ try {
162
+ rmSync(abs, { recursive: true, force: true });
163
+ allFiles = getFilesAndDirs();
164
+ console.log(chalk.green(`✓ removed ${dirPath}\n`));
165
+ } catch (err) {
166
+ console.log(chalk.red(`✗ could not remove ${dirPath}: ${err.message}\n`));
167
+ }
168
+ } else {
169
+ console.log(chalk.yellow(`⚠ not found: ${dirPath}\n`));
170
+ }
171
+ } else {
172
+ console.log(chalk.dim('skipped\n'));
173
+ }
174
+ }
175
+
176
+ // Delete files
177
+ for (const filePath of parseDeleteCommands(reply)) {
178
+ const confirmed = await confirm(chalk.bold(`Delete ${chalk.cyan(filePath)}? [y/N] `));
179
+ if (confirmed) {
180
+ const abs = resolve(process.cwd(), filePath);
181
+ if (existsSync(abs)) {
182
+ try {
183
+ unlinkSync(abs);
184
+ allFiles = getFilesAndDirs();
185
+ console.log(chalk.green(`✓ deleted ${filePath}\n`));
186
+ } catch (err) {
187
+ console.log(chalk.red(`✗ could not delete ${filePath}: ${err.message}\n`));
188
+ }
189
+ } else {
190
+ console.log(chalk.yellow(`⚠ not found: ${filePath}\n`));
191
+ }
192
+ } else {
193
+ console.log(chalk.dim('skipped\n'));
194
+ }
195
+ }
196
+
197
+ return allFiles;
198
+ }
11
199
 
12
200
  /** Arrow-key selector. Returns the chosen string or null if cancelled. */
13
201
  function selectFrom(options, label) {
@@ -124,17 +312,159 @@ function promptSecret(label) {
124
312
  });
125
313
  }
126
314
 
315
+ const VIEWPORT_LINES = 5;
316
+ const TERM_WIDTH = 80;
317
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
318
+ const visLen = (s) => s.replace(ANSI_RE, '').length;
319
+
320
+ /** Word-wrap plain text to a given column width, preserving existing newlines. */
321
+ function wrapText(text, width) {
322
+ return text.split('\n').map(line => {
323
+ if (visLen(line) <= width) return line;
324
+ const words = line.split(' ');
325
+ const wrapped = [];
326
+ let current = '';
327
+ for (const word of words) {
328
+ const test = current ? current + ' ' + word : word;
329
+ if (visLen(test) <= width) {
330
+ current = test;
331
+ } else {
332
+ if (current) wrapped.push(current);
333
+ current = word;
334
+ }
335
+ }
336
+ if (current) wrapped.push(current);
337
+ return wrapped.join('\n');
338
+ }).join('\n');
339
+ }
340
+
341
+ /** Count the number of terminal rows a list of lines occupies, accounting for wrapping. */
342
+ function countRows(lines, w) {
343
+ return lines.reduce((sum, l) => sum + Math.max(1, Math.ceil(visLen(l) / w)), 0);
344
+ }
345
+
346
+ /**
347
+ * Stream a chat response with a live 5-line viewport.
348
+ * While streaming, only the last VIEWPORT_LINES lines are shown in-place.
349
+ * After streaming finishes, the viewport is replaced with the full response.
350
+ * Returns the full reply string.
351
+ */
352
+ function buildSystemMessage() {
353
+ const files = getFiles();
354
+ const fileList = files.length > 0 ? `\nFiles in working directory:\n${files.map(f => ` ${f}`).join('\n')}\n` : '';
355
+ const fileOpsInstructions =
356
+ `\nFILE OPERATIONS — use these exact syntaxes when needed:\n` +
357
+ `- 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` +
359
+ `- Create an empty file: output exactly on its own line: !!touch: path/to/file\n` +
360
+ `- Create a folder: output exactly on its own line: !!mkdir: path/to/folder\n` +
361
+ `- Remove a folder: output exactly on its own line: !!rmdir: path/to/folder\n` +
362
+ `- Delete a file: output exactly on its own line: !!delete: path/to/file\n` +
363
+ `- You may combine multiple operations in one response.\n` +
364
+ `- NEVER put commands in fenced code blocks — always use !!run: syntax for commands.\n`;
365
+ return { role: 'system', content: `You are Markov, an AI coding assistant.\nWorking directory: ${process.cwd()}\n${fileList}${fileOpsInstructions}` };
366
+ }
367
+
368
+ async function streamWithViewport(chatMessages, signal) {
369
+ // Re-resolve @file refs fresh on every request so the model always sees the latest file contents.
370
+ const resolvedMessages = chatMessages.map(msg => {
371
+ if (msg.role !== 'user') return msg;
372
+ const { content } = resolveFileRefs(msg.content);
373
+ return { ...msg, content };
374
+ });
375
+
376
+ const DOTS = ['.', '..', '...'];
377
+ let dotIdx = 0;
378
+ let firstToken = true;
379
+ let fullText = '';
380
+ let viewportRows = 0; // actual terminal rows currently rendered in the viewport
381
+
382
+ process.stdout.write(chalk.dim('\nMarkov › '));
383
+ const spinner = setInterval(() => {
384
+ process.stdout.write('\r' + chalk.dim('Markov › ') + markovGradient(DOTS[dotIdx % DOTS.length]) + ' ');
385
+ dotIdx++;
386
+ }, 400);
387
+
388
+ const onCancel = (data) => { if (data.toString() === '\x11') signal.abort(); };
389
+ process.stdin.setRawMode(true);
390
+ process.stdin.resume();
391
+ process.stdin.setEncoding('utf8');
392
+ process.stdin.on('data', onCancel);
393
+
394
+ try {
395
+ const reply = await streamChat([buildSystemMessage(), ...resolvedMessages], (token) => {
396
+ if (firstToken) {
397
+ clearInterval(spinner);
398
+ firstToken = false;
399
+ process.stdout.write('\r\x1b[0J' + chalk.dim('Markov ›\n\n'));
400
+ }
401
+
402
+ fullText += token;
403
+
404
+ // Build the viewport: last VIEWPORT_LINES lines of buffered text.
405
+ const w = process.stdout.columns || TERM_WIDTH;
406
+ const displayWidth = Math.min(w, TERM_WIDTH);
407
+ const lines = wrapText(fullText, displayWidth).split('\n');
408
+ const viewLines = lines;
409
+ const rendered = viewLines.join('\n');
410
+
411
+ // Count actual terminal rows rendered (accounts for line wrapping).
412
+ const newRows = countRows(viewLines, w) - 1;
413
+
414
+ // Move cursor up to top of current viewport, clear down, rewrite.
415
+ if (viewportRows > 0) {
416
+ process.stdout.write(`\x1b[${viewportRows}A\r\x1b[0J`);
417
+ }
418
+ process.stdout.write(rendered);
419
+ viewportRows = newRows;
420
+ }, undefined, signal);
421
+
422
+ process.stdin.removeListener('data', onCancel);
423
+ process.stdin.setRawMode(false);
424
+ process.stdin.pause();
425
+ clearInterval(spinner);
426
+
427
+ if (signal.aborted) {
428
+ if (viewportRows > 0) process.stdout.write(`\x1b[${viewportRows}A\r\x1b[0J`);
429
+ console.log(chalk.dim('(cancelled)\n'));
430
+ return null;
431
+ }
432
+
433
+ // Replace viewport with the full response.
434
+ if (viewportRows > 0) process.stdout.write(`\x1b[${viewportRows}A\r\x1b[0J`);
435
+ const finalWidth = Math.min(process.stdout.columns || TERM_WIDTH, TERM_WIDTH);
436
+ process.stdout.write(wrapText(fullText, finalWidth) + '\n\n');
437
+
438
+ return reply;
439
+ } catch (err) {
440
+ clearInterval(spinner);
441
+ process.stdin.removeListener('data', onCancel);
442
+ process.stdin.setRawMode(false);
443
+ process.stdin.pause();
444
+ if (!signal.aborted) throw err;
445
+ return null;
446
+ }
447
+ }
448
+
449
+ const HELP_TEXT =
450
+ '\n' +
451
+ chalk.bold('Commands:\n') +
452
+ chalk.cyan(' /help') + chalk.dim(' show this help\n') +
453
+ chalk.cyan(' /plan <message>') + chalk.dim(' ask AI to create a plan (no files written)\n') +
454
+ chalk.cyan(' /build') + chalk.dim(' execute the stored plan\n') +
455
+ chalk.cyan(' /models') + chalk.dim(' switch the active AI model\n') +
456
+ 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');
458
+
127
459
  export async function startInteractive() {
128
460
  printLogo();
129
461
 
130
- let allFiles = getFiles();
462
+ let allFiles = getFilesAndDirs();
131
463
  const chatMessages = [];
464
+ let currentPlan = null; // { text: string } | null
132
465
 
133
- console.log(chalk.dim(`Chat with Markov (${MODEL}).\n`));
134
-
135
- if (!getToken()) {
136
- console.log(chalk.yellow('⚠ Not logged in. Use /login to authenticate.\n'));
137
- }
466
+ console.log(chalk.dim(`Chat with Markov (${MODEL}).`));
467
+ console.log(HELP_TEXT);
138
468
 
139
469
  while (true) {
140
470
  const raw = await chatPrompt(chalk.magenta('you> '), allFiles);
@@ -143,16 +473,9 @@ export async function startInteractive() {
143
473
 
144
474
  if (!trimmed) continue;
145
475
 
146
- // /loginauthenticate and save token
147
- if (trimmed === '/login') {
148
- const email = await promptLine('Email: ');
149
- const password = await promptSecret('Password: ');
150
- try {
151
- await login(email, password);
152
- console.log(chalk.green('✓ logged in\n'));
153
- } catch (err) {
154
- console.log(chalk.red(`✗ ${err.message}\n`));
155
- }
476
+ // /helplist all commands
477
+ if (trimmed === '/help') {
478
+ console.log(HELP_TEXT);
156
479
  continue;
157
480
  }
158
481
 
@@ -174,7 +497,7 @@ export async function startInteractive() {
174
497
  : homedir();
175
498
  try {
176
499
  process.chdir(target);
177
- allFiles = getFiles();
500
+ allFiles = getFilesAndDirs();
178
501
  console.log(chalk.dim(`\n📁 ${process.cwd()}\n`));
179
502
  } catch {
180
503
  console.log(chalk.red(`\nno such directory: ${target}\n`));
@@ -182,10 +505,66 @@ export async function startInteractive() {
182
505
  continue;
183
506
  }
184
507
 
185
- const isEditMode = trimmed.startsWith('/edit ');
186
- const message = isEditMode ? trimmed.slice(6).trim() : trimmed;
508
+ // /plan <message> ask LLM to produce a plan as a normal chat message, store it
509
+ if (trimmed.startsWith('/plan ')) {
510
+ const planRequest = trimmed.slice(6).trim();
511
+ const planPrompt =
512
+ `Create a detailed, numbered plan for the following task:\n\n${planRequest}\n\n` +
513
+ `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` +
515
+ `- Creating an empty file → !!touch: path/to/file\n` +
516
+ `- Creating a folder → !!mkdir: path/to/folder\n` +
517
+ `- Removing a folder → !!rmdir: path/to/folder\n` +
518
+ `- Deleting a file → !!delete: path/to/file\n\n` +
519
+ `Do NOT output any actual file contents or commands yet — only the plan.`;
520
+ chatMessages.push({ role: 'user', content: planPrompt });
521
+ const abortController = new AbortController();
522
+ try {
523
+ const reply = await streamWithViewport(chatMessages, abortController.signal);
524
+ if (reply === null) {
525
+ chatMessages.pop();
526
+ } else {
527
+ chatMessages.push({ role: 'assistant', content: reply });
528
+ currentPlan = { text: reply };
529
+ console.log(chalk.green('\n✓ Plan stored. Use /build to execute it.\n'));
530
+ }
531
+ } catch (err) {
532
+ if (!abortController.signal.aborted) console.log(chalk.red(`\n${err.message}\n`));
533
+ }
534
+ continue;
535
+ }
536
+
537
+ // /build — execute the stored plan with full file ops
538
+ if (trimmed === '/build') {
539
+ if (!currentPlan) {
540
+ console.log(chalk.yellow('\n⚠ No plan stored. Use /plan <message> first.\n'));
541
+ continue;
542
+ }
543
+
544
+ const buildPrompt =
545
+ `Execute the following plan. Use FILE: syntax for file writes, !!mkdir: for folders, !!delete: for deletions.\n\n` +
546
+ `Plan:\n${currentPlan.text}`;
547
+
548
+ chatMessages.push({ role: 'user', content: buildPrompt });
549
+ const abortController = new AbortController();
550
+ try {
551
+ const reply = await streamWithViewport(chatMessages, abortController.signal);
552
+ if (reply === null) {
553
+ chatMessages.pop();
554
+ } else {
555
+ chatMessages.push({ role: 'assistant', content: reply });
556
+ allFiles = await handleFileOps(reply, []);
557
+ currentPlan = null;
558
+ console.log(chalk.green('✓ Plan executed.\n'));
559
+ }
560
+ } catch (err) {
561
+ if (!abortController.signal.aborted) console.log(chalk.red(`\n${err.message}\n`));
562
+ }
563
+ continue;
564
+ }
187
565
 
188
- const { content, loaded, failed } = resolveFileRefs(message);
566
+ // Normal chat file ops always available
567
+ const { loaded, failed } = resolveFileRefs(trimmed);
189
568
 
190
569
  if (loaded.length > 0) {
191
570
  console.log(chalk.dim(`\n📎 attached: ${loaded.map(f => chalk.cyan(`@${f}`)).join(', ')}`));
@@ -194,82 +573,20 @@ export async function startInteractive() {
194
573
  console.log(chalk.yellow(`\n⚠ not found: ${failed.map(f => `@${f}`).join(', ')}`));
195
574
  }
196
575
 
197
- chatMessages.push({ role: 'user', content });
198
-
199
- // Animated waiting dots — cleared on first token
200
- const DOTS = ['.', '..', '...'];
201
- let dotIdx = 0;
202
- let firstToken = true;
203
- process.stdout.write(chalk.dim('\nMarkov › '));
204
- const spinner = setInterval(() => {
205
- process.stdout.write('\r' + chalk.dim('Markov › ' + DOTS[dotIdx % DOTS.length] + ' '));
206
- dotIdx++;
207
- }, 400);
576
+ // Store raw message — @refs are re-resolved fresh on every API call
577
+ chatMessages.push({ role: 'user', content: trimmed });
208
578
 
209
579
  const abortController = new AbortController();
210
580
  try {
581
+ const reply = await streamWithViewport(chatMessages, abortController.signal);
211
582
 
212
- // Listen for Ctrl+Q on raw stdin to cancel the stream
213
- const onCancel = (data) => {
214
- if (data.toString() === '\x11') abortController.abort();
215
- };
216
- process.stdin.setRawMode(true);
217
- process.stdin.resume();
218
- process.stdin.setEncoding('utf8');
219
- process.stdin.on('data', onCancel);
220
-
221
- const reply = await streamChat(chatMessages, (token) => {
222
- if (firstToken) {
223
- clearInterval(spinner);
224
- firstToken = false;
225
- process.stdout.write('\r\x1b[0J' + chalk.dim('Markov ›\n\n'));
226
- }
227
- process.stdout.write(token);
228
- }, undefined, abortController.signal);
229
-
230
- process.stdin.removeListener('data', onCancel);
231
- process.stdin.setRawMode(false);
232
- process.stdin.pause();
233
- clearInterval(spinner);
234
-
235
- if (abortController.signal.aborted) {
236
- console.log(chalk.dim('\n(cancelled)\n'));
583
+ if (reply === null) {
584
+ chatMessages.pop();
237
585
  } else {
238
- console.log('\n');
239
586
  chatMessages.push({ role: 'assistant', content: reply });
240
-
241
- // Detect file edits in the response (only in /edit mode)
242
- const edits = isEditMode ? parseEdits(reply, loaded) : [];
243
- const appliedEdits = [];
244
- for (const { filepath, content } of edits) {
245
- renderDiff(filepath, content);
246
- const confirmed = await confirm(chalk.bold(`Apply changes to ${chalk.cyan(filepath)}? [y/N] `));
247
- if (confirmed) {
248
- try {
249
- applyEdit(filepath, content);
250
- console.log(chalk.green(`✓ saved ${filepath}\n`));
251
- appliedEdits.push({ filepath, content });
252
- } catch (err) {
253
- console.log(chalk.red(`✗ could not write ${filepath}: ${err.message}\n`));
254
- }
255
- } else {
256
- console.log(chalk.dim('skipped\n'));
257
- }
258
- }
259
-
260
- // Refresh chat context with updated file contents so the model
261
- // doesn't reference stale content from earlier in the conversation.
262
- if (appliedEdits.length > 0) {
263
- const blocks = appliedEdits.map(({ filepath, content }) => `--- @${filepath} ---\n${content}\n---`);
264
- chatMessages.push({
265
- role: 'user',
266
- content: `The following file(s) were just saved with the applied changes:\n\n${blocks.join('\n\n')}`,
267
- });
268
- chatMessages.push({ role: 'assistant', content: 'Got it, I have the updated file contents.' });
269
- }
587
+ allFiles = await handleFileOps(reply, loaded);
270
588
  }
271
589
  } catch (err) {
272
- clearInterval(spinner);
273
590
  if (!abortController.signal.aborted) {
274
591
  console.log(chalk.red(`\n${err.message}\n`));
275
592
  }
package/src/ollama.js CHANGED
@@ -4,7 +4,7 @@ const getHeaders = () => {
4
4
  const token = getToken();
5
5
  return { 'Content-Type': 'application/json', ...(token && { Authorization: `Bearer ${token}` }) };
6
6
  };
7
- export const MODELS = ['gemma3:4b', 'qwen2.5-coder:0.5b', 'qwen2.5-coder:7b', 'qwen3:14b'];
7
+ export const MODELS = ['gemma3:4b', 'qwen2.5-coder:0.5b', 'qwen2.5-coder:7b', 'qwen2.5:14b-instruct', 'qwen3:14b'];
8
8
  export let MODEL = 'qwen3:14b';
9
9
  export function setModel(m) { MODEL = m; }
10
10
 
@@ -69,3 +69,23 @@ export async function streamChat(messages, onToken, _model = MODEL, signal = nul
69
69
 
70
70
  return fullText;
71
71
  }
72
+
73
+ /**
74
+ * Chat with tools (function calling). Non-streaming; returns full response with optional tool_calls.
75
+ * Use for agent loop: if message.tool_calls present, run tools locally and call again with updated messages.
76
+ */
77
+ export async function chatWithTools(messages, tools, model = MODEL, signal = null) {
78
+ const controller = signal ? { signal } : {};
79
+ const res = await fetch(`${API_URL}/ai/chat/tools`, {
80
+ method: 'POST',
81
+ headers: getHeaders(),
82
+ body: JSON.stringify({ messages, tools, model, temperature: 0.2 }),
83
+ ...controller,
84
+ });
85
+ if (!res.ok) {
86
+ const body = await res.text().catch(() => '');
87
+ throw new Error(`API error ${res.status} ${res.statusText}${body ? ': ' + body : ''}`);
88
+ }
89
+ const data = await res.json();
90
+ return data;
91
+ }
package/src/tools.js ADDED
@@ -0,0 +1,129 @@
1
+ import { exec } from 'child_process';
2
+ import { promisify } from 'util';
3
+ import { mkdirSync } from 'fs';
4
+ import { resolve } from 'path';
5
+
6
+ const execAsync = promisify(exec);
7
+
8
+ /** Ollama-format tool definition for running a shell command on the user's machine */
9
+ export const RUN_TERMINAL_COMMAND_TOOL = {
10
+ type: 'function',
11
+ function: {
12
+ name: 'run_terminal_command',
13
+ description: 'Run a single shell command in the user\'s terminal. You MUST call this tool (do not only list commands in your reply) when the user asks to set up a project, install dependencies (npm install, pip install, etc.), run init/build commands (npm init, npx create-react-app, etc.), or execute any shell command. Use for listing files (ls), checking status (git status), and running scripts. Commands run in the current working directory. Do not run interactive or long-running daemons. Run one command per tool call; for multiple commands, make multiple tool calls.',
14
+ parameters: {
15
+ type: 'object',
16
+ required: ['command'],
17
+ properties: {
18
+ command: {
19
+ type: 'string',
20
+ description: 'The shell command to run (e.g. "ls -la", "git status"). Single command only, no pipes or multiple commands unless the shell supports it.',
21
+ },
22
+ cwd: {
23
+ type: 'string',
24
+ description: 'Working directory for the command. Defaults to current directory if omitted.',
25
+ },
26
+ },
27
+ },
28
+ },
29
+ };
30
+
31
+ /** Ollama-format tool definition for creating a directory */
32
+ export const CREATE_FOLDER_TOOL = {
33
+ type: 'function',
34
+ function: {
35
+ name: 'create_folder',
36
+ description: 'Create a folder (and any necessary parent folders) in the current working directory.',
37
+ parameters: {
38
+ type: 'object',
39
+ required: ['path'],
40
+ properties: {
41
+ path: {
42
+ type: 'string',
43
+ description: 'Relative path of the folder to create (e.g. "src/components").',
44
+ },
45
+ },
46
+ },
47
+ },
48
+ };
49
+
50
+ const TOOLS_MAP = {
51
+ create_folder: async (args, opts = {}) => {
52
+ const folderPath = typeof args.path === 'string' ? args.path : String(args?.path ?? '');
53
+ if (!folderPath.trim()) {
54
+ return { success: false, error: 'Error: empty path' };
55
+ }
56
+ const cwd = opts.cwd ?? process.cwd();
57
+ const absPath = resolve(cwd, folderPath);
58
+ try {
59
+ mkdirSync(absPath, { recursive: true });
60
+ return { success: true, path: folderPath };
61
+ } catch (err) {
62
+ return { success: false, error: err.message };
63
+ }
64
+ },
65
+ run_terminal_command: async (args, opts = {}) => {
66
+ const command = typeof args.command === 'string' ? args.command : String(args?.command ?? '');
67
+ const cwd = typeof args.cwd === 'string' ? args.cwd : opts.cwd ?? process.cwd();
68
+ if (!command.trim()) {
69
+ return { stdout: '', stderr: 'Error: empty command', exitCode: 1 };
70
+ }
71
+ if (opts.confirmFn) {
72
+ const ok = await opts.confirmFn(command);
73
+ if (!ok) {
74
+ return { stdout: '', stderr: 'Command cancelled by user', exitCode: -1 };
75
+ }
76
+ }
77
+ const timeout = opts.timeout ?? 30_000;
78
+
79
+ const maxBuffer = opts.maxBuffer ?? 1024 * 1024;
80
+ try {
81
+ const { stdout, stderr } = await execAsync(command, {
82
+ cwd,
83
+ timeout,
84
+ maxBuffer,
85
+ shell: true,
86
+ });
87
+ return { stdout: stdout || '', stderr: stderr || '', exitCode: 0 };
88
+ } catch (err) {
89
+ const stderr = err.stderr ?? err.message ?? String(err);
90
+ const stdout = err.stdout ?? '';
91
+ const exitCode = err.code ?? 1;
92
+ return { stdout, stderr, exitCode };
93
+ }
94
+ },
95
+ };
96
+
97
+ /**
98
+ * Execute a shell command directly and return its output.
99
+ * @param {string} command
100
+ * @param {string} [cwd]
101
+ * @returns {Promise<{ stdout: string, stderr: string, exitCode: number }>}
102
+ */
103
+ export async function execCommand(command, cwd = process.cwd()) {
104
+ if (!command.trim()) return { stdout: '', stderr: 'Error: empty command', exitCode: 1 };
105
+ const timeout = 30_000;
106
+ const maxBuffer = 1024 * 1024;
107
+ try {
108
+ const { stdout, stderr } = await execAsync(command, { cwd, timeout, maxBuffer, shell: true });
109
+ return { stdout: stdout || '', stderr: stderr || '', exitCode: 0 };
110
+ } catch (err) {
111
+ return { stdout: err.stdout ?? '', stderr: err.stderr ?? err.message ?? String(err), exitCode: err.code ?? 1 };
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Run a tool by name with the given arguments.
117
+ * @param {string} name - Tool name (e.g. 'run_terminal_command')
118
+ * @param {Record<string, unknown>} args - Arguments object (e.g. { command: 'ls -la' })
119
+ * @param {{ cwd?: string, confirmFn?: (command: string) => Promise<boolean>, timeout?: number }} opts - Optional cwd, confirmation callback for terminal commands, timeout
120
+ * @returns {Promise<string>} - Result string to send back to the model (e.g. JSON or plain text)
121
+ */
122
+ export async function runTool(name, args, opts = {}) {
123
+ const fn = TOOLS_MAP[name];
124
+ if (!fn) {
125
+ return JSON.stringify({ error: `Unknown tool: ${name}` });
126
+ }
127
+ const result = await fn(args ?? {}, opts);
128
+ return typeof result === 'string' ? result : JSON.stringify(result);
129
+ }
package/src/ui/logo.js CHANGED
@@ -9,19 +9,8 @@ const ASCII_ART = `
9
9
  ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═══╝
10
10
  `;
11
11
 
12
- const SMALL_MARKOV = `
13
- █ █ ██ █ ██ █ █ ██ █ █
14
- ██ ██ █ ██ ██ ███ █ █ ███
15
- █ █ █ █ █ █ █ ██ ██ █ █
16
- `;
17
-
18
12
  const markovGradient = gradient(['#6366f1', '#a855f7', '#ec4899']);
19
13
 
20
14
  export function printLogo() {
21
- console.log(markovGradient.multiline(ASCII_ART));
22
- console.log(' A friendly CLI tool\n');
23
- }
24
-
25
- export function printSmallMarkov() {
26
- console.log(markovGradient.multiline(SMALL_MARKOV));
15
+ console.log(markovGradient.multiline(ASCII_ART + ' LivingCloud\'s AI Agent'));
27
16
  }
package/src/ui/picker.js CHANGED
@@ -24,6 +24,26 @@ export function getFiles(dir = process.cwd(), base = dir, depth = 0, acc = []) {
24
24
  return acc;
25
25
  }
26
26
 
27
+ /** Returns files AND directories (dirs have a trailing /). */
28
+ export function getFilesAndDirs(dir = process.cwd(), base = dir, depth = 0, acc = []) {
29
+ if (depth > 5 || acc.length >= MAX_FILES) return acc;
30
+ let entries;
31
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return acc; }
32
+ for (const entry of entries) {
33
+ if (IGNORE.has(entry.name) || entry.name.startsWith('.')) continue;
34
+ const full = join(dir, entry.name);
35
+ const rel = relative(base, full);
36
+ if (entry.isDirectory()) {
37
+ acc.push(rel + '/');
38
+ getFilesAndDirs(full, base, depth + 1, acc);
39
+ } else {
40
+ acc.push(rel);
41
+ }
42
+ if (acc.length >= MAX_FILES) break;
43
+ }
44
+ return acc;
45
+ }
46
+
27
47
  export function filterFiles(files, query) {
28
48
  if (!query) return files;
29
49
  const q = query.toLowerCase();