markov-cli 1.0.0

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/bin/markov.js ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { startInteractive } = await import('../src/interactive.js');
4
+ startInteractive();
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "markov-cli",
3
+ "version": "1.0.0",
4
+ "description": "A friendly CLI tool",
5
+ "type": "module",
6
+ "bin": {
7
+ "markov-cli": "./bin/markov.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node bin/markov-cli.js"
11
+ },
12
+ "keywords": [
13
+ "cli",
14
+ "markov",
15
+ "markov-cli"
16
+ ],
17
+ "author": "",
18
+ "license": "MIT",
19
+ "files": [
20
+ "bin",
21
+ "src",
22
+ ".env.example"
23
+ ],
24
+ "dependencies": {
25
+ "chalk": "^5.6.2",
26
+ "commander": "^14.0.3",
27
+ "dotenv": "^16.4.5",
28
+ "gradient-string": "^3.0.0"
29
+ }
30
+ }
package/src/auth.js ADDED
@@ -0,0 +1,35 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+
5
+ const CONFIG_DIR = join(homedir(), '.markov');
6
+ const TOKEN_PATH = join(CONFIG_DIR, 'token');
7
+
8
+ export const API_URL = 'https://api.livingcloud.app/api';
9
+
10
+ export function getToken() {
11
+ if (!existsSync(TOKEN_PATH)) return null;
12
+ return readFileSync(TOKEN_PATH, 'utf-8').trim() || null;
13
+ }
14
+
15
+ export function saveToken(token) {
16
+ mkdirSync(CONFIG_DIR, { recursive: true });
17
+ writeFileSync(TOKEN_PATH, token, 'utf-8');
18
+ }
19
+
20
+ export async function login(email, password) {
21
+ const res = await fetch(`${API_URL}/auth/login`, {
22
+ method: 'POST',
23
+ headers: { 'Content-Type': 'application/json' },
24
+ body: JSON.stringify({ email, password }),
25
+ });
26
+ if (!res.ok) {
27
+ const body = await res.text().catch(() => '');
28
+ throw new Error(`Login failed (${res.status})${body ? ': ' + body : ''}`);
29
+ }
30
+ const data = await res.json();
31
+ const token = data.data?.token ?? data.token ?? data.accessToken ?? data.access_token;
32
+ if (!token) throw new Error('No token received from server');
33
+ saveToken(token);
34
+ return token;
35
+ }
package/src/editor.js ADDED
@@ -0,0 +1,173 @@
1
+ import { writeFileSync, readFileSync, existsSync } from 'fs';
2
+ import { join, extname } from 'path';
3
+ import chalk from 'chalk';
4
+
5
+ // Maps common language identifiers (including short aliases) to file extensions.
6
+ const LANG_TO_EXT = {
7
+ typescript: ['.ts', '.tsx'],
8
+ ts: ['.ts', '.tsx'],
9
+ tsx: ['.tsx'],
10
+ javascript: ['.js', '.jsx', '.mjs', '.cjs'],
11
+ js: ['.js', '.jsx', '.mjs', '.cjs'],
12
+ jsx: ['.jsx'],
13
+ python: ['.py'],
14
+ py: ['.py'],
15
+ css: ['.css'],
16
+ scss: ['.scss'],
17
+ html: ['.html'],
18
+ json: ['.json'],
19
+ bash: ['.sh'],
20
+ sh: ['.sh'],
21
+ markdown: ['.md'],
22
+ md: ['.md'],
23
+ };
24
+
25
+ // --- Diff engine (LCS-based) ---
26
+
27
+ function lcs(a, b) {
28
+ const m = a.length, n = b.length;
29
+ const dp = Array.from({ length: m + 1 }, () => new Uint32Array(n + 1));
30
+ for (let i = 1; i <= m; i++)
31
+ for (let j = 1; j <= n; j++)
32
+ dp[i][j] = a[i-1] === b[j-1] ? dp[i-1][j-1] + 1 : Math.max(dp[i-1][j], dp[i][j-1]);
33
+
34
+ const result = [];
35
+ let i = m, j = n;
36
+ while (i > 0 || j > 0) {
37
+ if (i > 0 && j > 0 && a[i-1] === b[j-1]) {
38
+ result.unshift({ type: 'equal', line: a[i-1] }); i--; j--;
39
+ } else if (j > 0 && (i === 0 || dp[i][j-1] >= dp[i-1][j])) {
40
+ result.unshift({ type: 'add', line: b[j-1] }); j--;
41
+ } else {
42
+ result.unshift({ type: 'del', line: a[i-1] }); i--;
43
+ }
44
+ }
45
+ return result;
46
+ }
47
+
48
+ const CONTEXT = 3; // unchanged lines to show around each change
49
+
50
+ function buildHunks(diff) {
51
+ // Mark which equal lines are within CONTEXT of a change
52
+ const changed = diff.map(d => d.type !== 'equal');
53
+ const show = diff.map((_, i) => {
54
+ if (changed[i]) return true;
55
+ for (let d = 1; d <= CONTEXT; d++)
56
+ if (changed[i - d] || changed[i + d]) return true;
57
+ return false;
58
+ });
59
+
60
+ const hunks = [];
61
+ let hunk = null;
62
+ for (let i = 0; i < diff.length; i++) {
63
+ if (!show[i]) {
64
+ if (hunk) { hunks.push(hunk); hunk = null; }
65
+ continue;
66
+ }
67
+ if (!hunk) hunk = [];
68
+ hunk.push(diff[i]);
69
+ }
70
+ if (hunk) hunks.push(hunk);
71
+ return hunks;
72
+ }
73
+
74
+ /**
75
+ * Renders a colour diff to stdout and returns { added, removed } counts.
76
+ */
77
+ export function renderDiff(filepath, newContent, cwd = process.cwd()) {
78
+ const fullPath = join(cwd, filepath);
79
+ const oldContent = existsSync(fullPath) ? readFileSync(fullPath, 'utf-8') : '';
80
+ const oldLines = oldContent.split('\n');
81
+ const newLines = newContent.split('\n');
82
+
83
+ const diff = lcs(oldLines, newLines);
84
+ const hunks = buildHunks(diff);
85
+
86
+ const w = process.stdout.columns || 80;
87
+ const header = ` ✏ ${filepath} `;
88
+ const pad = Math.max(0, w - header.length);
89
+ const lpad = Math.floor(pad / 2);
90
+ const rpad = pad - lpad;
91
+
92
+ console.log('\n' + chalk.dim('─'.repeat(lpad)) + chalk.bold.white(header) + chalk.dim('─'.repeat(rpad)));
93
+
94
+ let added = 0, removed = 0;
95
+ let oldLine = 1, newLine = 1;
96
+
97
+ // Recalculate line numbers
98
+ const lineNums = [];
99
+ { let o = 1, n = 1;
100
+ for (const d of diff) {
101
+ if (d.type === 'equal') { lineNums.push({ o, n }); o++; n++; }
102
+ else if (d.type === 'del') { lineNums.push({ o, n: null }); o++; }
103
+ else { lineNums.push({ o: null, n }); n++; }
104
+ }
105
+ }
106
+
107
+ let diffIdx = 0;
108
+ for (const hunk of hunks) {
109
+ // Find start in full diff
110
+ const startIdx = diff.indexOf(hunk[0]);
111
+ const sep = chalk.dim('@@ ' + '─'.repeat(w - 4));
112
+ if (hunks.indexOf(hunk) > 0) console.log(sep);
113
+
114
+ for (let k = 0; k < hunk.length; k++) {
115
+ const d = hunk[k];
116
+ const idx = startIdx + k;
117
+ const ln = lineNums[idx];
118
+ const num = String(ln.n ?? ln.o).padStart(4);
119
+
120
+ if (d.type === 'equal') {
121
+ console.log(chalk.dim(`${num} ${d.line}`));
122
+ } else if (d.type === 'del') {
123
+ console.log(chalk.red(`${num} - ${d.line}`));
124
+ removed++;
125
+ } else {
126
+ console.log(chalk.green(`${num} + ${d.line}`));
127
+ added++;
128
+ }
129
+ }
130
+ }
131
+
132
+ if (hunks.length === 0) console.log(chalk.dim(' (no changes)'));
133
+
134
+ const summary = chalk.green(`+${added}`) + chalk.dim(' / ') + chalk.red(`-${removed}`);
135
+ console.log(chalk.dim('─'.repeat(w)) + '\n' + ' ' + summary + '\n');
136
+
137
+ return { added, removed, oldContent };
138
+ }
139
+
140
+ // --- Parse & apply ---
141
+
142
+ export function parseEdits(responseText, loadedFiles = []) {
143
+ const edits = [];
144
+ const regex = /```([\w./\-]*)\n([\s\S]*?)```/g;
145
+ let match;
146
+
147
+ while ((match = regex.exec(responseText)) !== null) {
148
+ const tag = match[1].trim();
149
+ const content = match[2];
150
+
151
+ if (tag && (tag.includes('.') || tag.includes('/'))) {
152
+ const exact = loadedFiles.find(f => f === tag || f.endsWith('/' + tag));
153
+ edits.push({ filepath: exact || tag, content });
154
+ continue;
155
+ }
156
+
157
+ if (tag) {
158
+ const exts = LANG_TO_EXT[tag.toLowerCase()] || [];
159
+ const matched = loadedFiles.find(f => exts.includes(extname(f).toLowerCase()));
160
+ if (matched) { edits.push({ filepath: matched, content }); continue; }
161
+ }
162
+
163
+ if (loadedFiles.length === 1) {
164
+ edits.push({ filepath: loadedFiles[0], content });
165
+ }
166
+ }
167
+
168
+ return edits;
169
+ }
170
+
171
+ export function applyEdit(filepath, content, cwd = process.cwd()) {
172
+ writeFileSync(join(cwd, filepath), content, 'utf-8');
173
+ }
package/src/files.js ADDED
@@ -0,0 +1,54 @@
1
+ import { readFileSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ const FILE_REF_REGEX = /@([\w./\-]+)/g;
5
+
6
+ /**
7
+ * Finds all @ref patterns in a string and returns the unique file paths.
8
+ */
9
+ export function parseFileRefs(input) {
10
+ const refs = [];
11
+ let match;
12
+ const regex = new RegExp(FILE_REF_REGEX.source, 'g');
13
+ while ((match = regex.exec(input)) !== null) {
14
+ if (!refs.includes(match[1])) refs.push(match[1]);
15
+ }
16
+ return refs;
17
+ }
18
+
19
+ /**
20
+ * Reads each @ref file from cwd, returns { content, loaded, failed }.
21
+ * `content` is the enriched message with file contents prepended.
22
+ */
23
+ export function resolveFileRefs(input, cwd = process.cwd()) {
24
+ const refs = parseFileRefs(input);
25
+ if (refs.length === 0) return { content: input, loaded: [], failed: [] };
26
+
27
+ const loaded = [];
28
+ const failed = [];
29
+ const blocks = [];
30
+
31
+ for (const ref of refs) {
32
+ const fullPath = join(cwd, ref);
33
+ if (!existsSync(fullPath)) {
34
+ failed.push(ref);
35
+ continue;
36
+ }
37
+ try {
38
+ const text = readFileSync(fullPath, 'utf-8');
39
+ blocks.push(`--- @${ref} ---\n${text}\n---`);
40
+ loaded.push(ref);
41
+ } catch {
42
+ failed.push(ref);
43
+ }
44
+ }
45
+
46
+ const contextBlock = blocks.length > 0
47
+ ? `The user referenced the following file(s) as context:\n\n${blocks.join('\n\n')}\n\n` +
48
+ `If you need to edit a file, output the complete new file content in a fenced code block tagged with the EXACT filename, like:\n` +
49
+ `${loaded.map(f => `\`\`\`${f}\n// full new content\n\`\`\``).join('\n')}\n\n` +
50
+ `IMPORTANT: Use the exact filename shown above as the code block tag, not the language name.\n\n`
51
+ : '';
52
+
53
+ return { content: contextBlock + input, loaded, failed };
54
+ }
package/src/input.js ADDED
@@ -0,0 +1,194 @@
1
+ import chalk from 'chalk';
2
+ import { filterFiles } from './ui/picker.js';
3
+ import { MODEL } from './ollama.js';
4
+
5
+ // Strip ANSI escape codes to get the true visible length of a string.
6
+ const visibleLen = (s) => s.replace(/\x1b\[[0-9;]*m/g, '').length;
7
+
8
+ const PREFIX = '❯ ';
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');
11
+ const PICKER_MAX = 6;
12
+
13
+ function border() {
14
+ return chalk.dim('─'.repeat(process.stdout.columns || 80));
15
+ }
16
+
17
+ function statusBar() {
18
+ const right = chalk.dim(`Markov (${MODEL})`);
19
+ const w = process.stdout.columns || 80;
20
+ const gap = Math.max(1, w - visibleLen(STATUS_LEFT) - visibleLen(right));
21
+ return STATUS_LEFT + ' '.repeat(gap) + right;
22
+ }
23
+
24
+ /**
25
+ * Show an interactive raw-mode prompt that supports @file autocomplete.
26
+ * Returns a Promise<string|null> — null means the prompt was cancelled (Ctrl+Q).
27
+ */
28
+ export function chatPrompt(_promptStr, allFiles) {
29
+ return new Promise((resolve) => {
30
+ const stdin = process.stdin;
31
+ const stdout = process.stdout;
32
+
33
+ let buffer = '';
34
+ let pickerFiles = [];
35
+ let pickerIndex = 0;
36
+ let cursorLineOffset = 0; // lines from top of drawn block to where cursor sits
37
+
38
+ const getAtPos = () => {
39
+ for (let i = buffer.length - 1; i >= 0; i--) {
40
+ if (buffer[i] === ' ') return -1;
41
+ if (buffer[i] === '@') return i;
42
+ }
43
+ return -1;
44
+ };
45
+
46
+ const updatePicker = () => {
47
+ const atPos = getAtPos();
48
+ if (atPos === -1) { pickerFiles = []; pickerIndex = 0; return; }
49
+ pickerFiles = filterFiles(allFiles, buffer.slice(atPos + 1));
50
+ pickerIndex = 0;
51
+ };
52
+
53
+ const redraw = () => {
54
+ const inputLine = chalk.cyan(PREFIX) + (buffer || HINT);
55
+
56
+ // Build scrollable picker window of PICKER_MAX items.
57
+ const total = pickerFiles.length;
58
+ const scrollStart = total <= PICKER_MAX ? 0
59
+ : Math.min(Math.max(0, pickerIndex - Math.floor(PICKER_MAX / 2)), total - PICKER_MAX);
60
+ const windowFiles = pickerFiles.slice(scrollStart, scrollStart + PICKER_MAX);
61
+ const pickerRows = windowFiles.map((f, i) => {
62
+ const absIndex = scrollStart + i;
63
+ return absIndex === pickerIndex
64
+ ? ' ' + chalk.bgCyan.black(' ' + f + ' ')
65
+ : ' ' + chalk.dim(f);
66
+ });
67
+ if (scrollStart > 0) pickerRows.unshift(chalk.dim(` ↑ ${scrollStart} more`));
68
+ const below = total - scrollStart - PICKER_MAX;
69
+ if (below > 0) pickerRows.push(chalk.dim(` ↓ ${below} more`));
70
+
71
+ const rows = [
72
+ border(),
73
+ inputLine,
74
+ border(),
75
+ ...pickerRows,
76
+ statusBar(),
77
+ ];
78
+
79
+ // Move cursor back to the top of the drawn block, then clear down.
80
+ if (cursorLineOffset > 0) stdout.write(`\x1b[${cursorLineOffset}A`);
81
+ stdout.write('\r\x1b[0J');
82
+
83
+ stdout.write(rows.join('\n'));
84
+
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.
88
+ const upAmount = pickerRows.length + 2;
89
+ stdout.write(`\x1b[${upAmount}A\r`);
90
+ cursorLineOffset = 1; // inputLine is always 1 line from the top
91
+
92
+ const col = visibleLen(chalk.cyan(PREFIX)) + visibleLen(buffer) + 1;
93
+ stdout.write(`\x1b[${col}G`);
94
+ };
95
+
96
+ // Clear the panel (called before resolving so output flows cleanly below).
97
+ const clearPanel = () => {
98
+ if (cursorLineOffset > 0) stdout.write(`\x1b[${cursorLineOffset}A`);
99
+ stdout.write('\r\x1b[0J');
100
+ cursorLineOffset = 0;
101
+ };
102
+
103
+ const onSigint = () => {
104
+ clearPanel();
105
+ cleanup();
106
+ stdout.write('\n');
107
+ process.exit(0);
108
+ };
109
+
110
+ const cleanup = () => {
111
+ stdin.removeListener('data', onData);
112
+ process.removeListener('SIGINT', onSigint);
113
+ stdin.setRawMode(false);
114
+ stdin.pause();
115
+ };
116
+
117
+ const onData = (data) => {
118
+ const key = data.toString();
119
+
120
+ // Ctrl+C → exit
121
+ if (key === '\x03') { onSigint(); return; }
122
+
123
+ // Ctrl+Q → cancel prompt
124
+ if (key === '\x11') {
125
+ clearPanel();
126
+ cleanup();
127
+ stdout.write(chalk.dim('(cancelled)\n'));
128
+ resolve(null);
129
+ return;
130
+ }
131
+
132
+ // Ctrl+D → exit
133
+ if (key === '\x04') { onSigint(); return; }
134
+
135
+ // Enter
136
+ if (key === '\r' || key === '\n') {
137
+ if (pickerFiles.length > 0) {
138
+ const atPos = getAtPos();
139
+ if (atPos !== -1) {
140
+ buffer = buffer.slice(0, atPos) + '@' + pickerFiles[pickerIndex] + ' ';
141
+ }
142
+ pickerFiles = [];
143
+ pickerIndex = 0;
144
+ redraw();
145
+ return;
146
+ }
147
+ clearPanel();
148
+ cleanup();
149
+ stdout.write('\n');
150
+ resolve(buffer);
151
+ return;
152
+ }
153
+
154
+ // Escape → dismiss picker
155
+ if (key === '\x1b' || (key.startsWith('\x1b') && key.length > 1 && !key.startsWith('\x1b['))) {
156
+ pickerFiles = [];
157
+ redraw();
158
+ return;
159
+ }
160
+
161
+ // Arrow keys
162
+ if (key === '\x1b[A') {
163
+ if (pickerFiles.length > 0) { pickerIndex = (pickerIndex - 1 + pickerFiles.length) % pickerFiles.length; redraw(); }
164
+ return;
165
+ }
166
+ if (key === '\x1b[B') {
167
+ if (pickerFiles.length > 0) { pickerIndex = (pickerIndex + 1) % pickerFiles.length; redraw(); }
168
+ return;
169
+ }
170
+ if (key === '\x1b[C' || key === '\x1b[D') return; // left/right — ignore
171
+
172
+ // Backspace
173
+ if (key === '\x7f' || key === '\b') {
174
+ if (buffer.length > 0) { buffer = buffer.slice(0, -1); updatePicker(); redraw(); }
175
+ return;
176
+ }
177
+
178
+ // Ignore other control chars
179
+ if (key < ' ') return;
180
+
181
+ // Printable
182
+ buffer += key;
183
+ updatePicker();
184
+ redraw();
185
+ };
186
+
187
+ stdin.setRawMode(true);
188
+ stdin.resume();
189
+ stdin.setEncoding('utf8');
190
+ stdin.on('data', onData);
191
+ process.on('SIGINT', onSigint);
192
+ redraw();
193
+ });
194
+ }
@@ -0,0 +1,278 @@
1
+ import chalk from 'chalk';
2
+ import { homedir } from 'os';
3
+ import { resolve } from 'path';
4
+ import { printLogo } from './ui/logo.js';
5
+ import { streamChat, MODEL, MODELS, setModel } from './ollama.js';
6
+ import { resolveFileRefs } from './files.js';
7
+ import { parseEdits, applyEdit, renderDiff } from './editor.js';
8
+ import { chatPrompt } from './input.js';
9
+ import { getFiles } from './ui/picker.js';
10
+ import { getToken, login } from './auth.js';
11
+
12
+ /** Arrow-key selector. Returns the chosen string or null if cancelled. */
13
+ function selectFrom(options, label) {
14
+ return new Promise((resolve) => {
15
+ let idx = 0;
16
+
17
+ const draw = () => {
18
+ process.stdout.write('\r\x1b[0J');
19
+ process.stdout.write(chalk.dim(label) + '\n');
20
+ options.forEach((o, i) => {
21
+ process.stdout.write(
22
+ i === idx
23
+ ? ' ' + chalk.bgCyan.black(` ${o} `) + '\n'
24
+ : ' ' + chalk.dim(o) + '\n'
25
+ );
26
+ });
27
+ // Move cursor back up to keep it stable
28
+ process.stdout.write(`\x1b[${options.length + 1}A`);
29
+ };
30
+
31
+ const cleanup = () => {
32
+ process.stdin.removeListener('data', onKey);
33
+ process.stdin.setRawMode(false);
34
+ process.stdin.pause();
35
+ // Clear the drawn lines
36
+ process.stdout.write('\r\x1b[0J');
37
+ };
38
+
39
+ const onKey = (data) => {
40
+ const key = data.toString();
41
+ if (key === '\x1b[A') { idx = (idx - 1 + options.length) % options.length; draw(); return; }
42
+ if (key === '\x1b[B') { idx = (idx + 1) % options.length; draw(); return; }
43
+ if (key === '\r' || key === '\n') { cleanup(); resolve(options[idx]); return; }
44
+ if (key === '\x03' || key === '\x11') { cleanup(); resolve(null); return; }
45
+ };
46
+
47
+ process.stdin.setRawMode(true);
48
+ process.stdin.resume();
49
+ process.stdin.setEncoding('utf8');
50
+ process.stdin.on('data', onKey);
51
+ draw();
52
+ });
53
+ }
54
+
55
+ /** Prompt y/n in raw mode, returns true for y/Y. */
56
+ function confirm(question) {
57
+ return new Promise((resolve) => {
58
+ process.stdout.write(question);
59
+ process.stdin.setRawMode(true);
60
+ process.stdin.resume();
61
+ process.stdin.setEncoding('utf8');
62
+ const onKey = (key) => {
63
+ process.stdin.removeListener('data', onKey);
64
+ process.stdin.setRawMode(false);
65
+ process.stdin.pause();
66
+ const answer = key.toLowerCase() === 'y';
67
+ process.stdout.write(answer ? chalk.green('y\n') : chalk.dim('n\n'));
68
+ resolve(answer);
69
+ };
70
+ process.stdin.on('data', onKey);
71
+ });
72
+ }
73
+
74
+ /** Read a visible line of input. */
75
+ function promptLine(label) {
76
+ return new Promise((resolve) => {
77
+ process.stdout.write(label);
78
+ let buf = '';
79
+ const onData = (data) => {
80
+ const key = data.toString();
81
+ if (key === '\r' || key === '\n') {
82
+ process.stdin.removeListener('data', onData);
83
+ process.stdin.setRawMode(false);
84
+ process.stdin.pause();
85
+ process.stdout.write('\n');
86
+ resolve(buf);
87
+ } else if (key === '\x7f' || key === '\b') {
88
+ if (buf.length > 0) { buf = buf.slice(0, -1); process.stdout.write('\b \b'); }
89
+ } else if (key >= ' ') {
90
+ buf += key;
91
+ process.stdout.write(key);
92
+ }
93
+ };
94
+ process.stdin.setRawMode(true);
95
+ process.stdin.resume();
96
+ process.stdin.setEncoding('utf8');
97
+ process.stdin.on('data', onData);
98
+ });
99
+ }
100
+
101
+ /** Read a line of input without echo (for passwords). */
102
+ function promptSecret(label) {
103
+ return new Promise((resolve) => {
104
+ process.stdout.write(label);
105
+ let buf = '';
106
+ const onData = (data) => {
107
+ const key = data.toString();
108
+ if (key === '\r' || key === '\n') {
109
+ process.stdin.removeListener('data', onData);
110
+ process.stdin.setRawMode(false);
111
+ process.stdin.pause();
112
+ process.stdout.write('\n');
113
+ resolve(buf);
114
+ } else if (key === '\x7f' || key === '\b') {
115
+ if (buf.length > 0) buf = buf.slice(0, -1);
116
+ } else if (key >= ' ') {
117
+ buf += key;
118
+ }
119
+ };
120
+ process.stdin.setRawMode(true);
121
+ process.stdin.resume();
122
+ process.stdin.setEncoding('utf8');
123
+ process.stdin.on('data', onData);
124
+ });
125
+ }
126
+
127
+ export async function startInteractive() {
128
+ printLogo();
129
+
130
+ let allFiles = getFiles();
131
+ const chatMessages = [];
132
+
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
+ }
138
+
139
+ while (true) {
140
+ const raw = await chatPrompt(chalk.magenta('you> '), allFiles);
141
+ if (raw === null) continue;
142
+ const trimmed = raw.trim();
143
+
144
+ if (!trimmed) continue;
145
+
146
+ // /login — authenticate 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
+ }
156
+ continue;
157
+ }
158
+
159
+ // /models — pick active model
160
+ if (trimmed === '/models') {
161
+ const chosen = await selectFrom(MODELS, 'Select model:');
162
+ if (chosen) {
163
+ setModel(chosen);
164
+ console.log(chalk.dim(`\n🤖 switched to ${chalk.cyan(chosen)}\n`));
165
+ }
166
+ continue;
167
+ }
168
+
169
+ // /cd [path] — change working directory within this session
170
+ if (trimmed.startsWith('/cd')) {
171
+ const arg = trimmed.slice(3).trim();
172
+ const target = arg
173
+ ? resolve(process.cwd(), arg.replace(/^~/, homedir()))
174
+ : homedir();
175
+ try {
176
+ process.chdir(target);
177
+ allFiles = getFiles();
178
+ console.log(chalk.dim(`\n📁 ${process.cwd()}\n`));
179
+ } catch {
180
+ console.log(chalk.red(`\nno such directory: ${target}\n`));
181
+ }
182
+ continue;
183
+ }
184
+
185
+ const isEditMode = trimmed.startsWith('/edit ');
186
+ const message = isEditMode ? trimmed.slice(6).trim() : trimmed;
187
+
188
+ const { content, loaded, failed } = resolveFileRefs(message);
189
+
190
+ if (loaded.length > 0) {
191
+ console.log(chalk.dim(`\n📎 attached: ${loaded.map(f => chalk.cyan(`@${f}`)).join(', ')}`));
192
+ }
193
+ if (failed.length > 0) {
194
+ console.log(chalk.yellow(`\n⚠ not found: ${failed.map(f => `@${f}`).join(', ')}`));
195
+ }
196
+
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);
208
+
209
+ const abortController = new AbortController();
210
+ try {
211
+
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'));
237
+ } else {
238
+ console.log('\n');
239
+ 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
+ }
270
+ }
271
+ } catch (err) {
272
+ clearInterval(spinner);
273
+ if (!abortController.signal.aborted) {
274
+ console.log(chalk.red(`\n${err.message}\n`));
275
+ }
276
+ }
277
+ }
278
+ }
package/src/ollama.js ADDED
@@ -0,0 +1,71 @@
1
+ import { API_URL, getToken } from './auth.js';
2
+
3
+ const getHeaders = () => {
4
+ const token = getToken();
5
+ return { 'Content-Type': 'application/json', ...(token && { Authorization: `Bearer ${token}` }) };
6
+ };
7
+ export const MODELS = ['gemma3:4b', 'qwen2.5-coder:0.5b', 'qwen2.5-coder:7b', 'qwen3:14b'];
8
+ export let MODEL = 'qwen3:14b';
9
+ export function setModel(m) { MODEL = m; }
10
+
11
+ /**
12
+ * Stream a chat response from Ollama.
13
+ * Calls onToken(string) for each token, returns the full response string.
14
+ * Pass an AbortSignal to allow cancellation mid-stream.
15
+ */
16
+ export async function streamChat(messages, onToken, _model = MODEL, signal = null) {
17
+ let response;
18
+ try {
19
+ response = await fetch(`${API_URL}/ai/chat/stream`, {
20
+ method: 'POST',
21
+ headers: getHeaders(),
22
+ body: JSON.stringify({ messages, temperature: 0.2 }),
23
+ signal,
24
+ });
25
+ } catch (err) {
26
+ if (signal?.aborted) return '';
27
+ throw new Error(`Could not connect to ${API_URL}/ai/chat/stream — ${err.message}`);
28
+ }
29
+
30
+ if (!response.ok) {
31
+ const body = await response.text().catch(() => '');
32
+ throw new Error(`API error ${response.status} ${response.statusText}${body ? ': ' + body : ''}`);
33
+ }
34
+
35
+ const reader = response.body.getReader();
36
+ const decoder = new TextDecoder();
37
+ let fullText = '';
38
+ let buffer = '';
39
+
40
+ try {
41
+ while (true) {
42
+ if (signal?.aborted) { reader.cancel(); break; }
43
+
44
+ const { done, value } = await reader.read();
45
+ if (done) break;
46
+
47
+ buffer += decoder.decode(value, { stream: true });
48
+ const lines = buffer.split('\n');
49
+ buffer = lines.pop() || '';
50
+
51
+ for (const line of lines) {
52
+ if (!line.startsWith('data: ')) continue;
53
+ const data = line.slice(6);
54
+ if (data === '[DONE]') return fullText;
55
+ try {
56
+ const parsed = JSON.parse(data);
57
+ if (parsed.content) {
58
+ onToken(parsed.content);
59
+ fullText += parsed.content;
60
+ }
61
+ } catch { /* skip malformed */ }
62
+ }
63
+ }
64
+ } catch (err) {
65
+ if (!signal?.aborted) throw err;
66
+ } finally {
67
+ reader.releaseLock();
68
+ }
69
+
70
+ return fullText;
71
+ }
package/src/ui/logo.js ADDED
@@ -0,0 +1,27 @@
1
+ import gradient from 'gradient-string';
2
+
3
+ const ASCII_ART = `
4
+ ███╗ ███╗ █████╗ ██████╗ ██╗ ██╗ ██████╗ ██╗ ██╗
5
+ ████╗ ████║██╔══██╗██╔══██╗██║ ██╔╝██╔═══██╗██║ ██║
6
+ ██╔████╔██║███████║██████╔╝█████╔╝ ██ ██║██║ ██║
7
+ ██║╚██╔╝██║██╔══██║██╔══██╗██╔═██╗ ██║ ██║╚██╗ ██╔╝
8
+ ██║ ╚═╝ ██║██║ ██║██║ ██║██║ ██╗╚██████╔╝ ╚████╔╝
9
+ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═══╝
10
+ `;
11
+
12
+ const SMALL_MARKOV = `
13
+ █ █ ██ █ ██ █ █ ██ █ █
14
+ ██ ██ █ ██ ██ ███ █ █ ███
15
+ █ █ █ █ █ █ █ ██ ██ █ █
16
+ `;
17
+
18
+ const markovGradient = gradient(['#6366f1', '#a855f7', '#ec4899']);
19
+
20
+ 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));
27
+ }
@@ -0,0 +1,31 @@
1
+ import { readdirSync } from 'fs';
2
+ import { join, relative } from 'path';
3
+
4
+ const IGNORE = new Set([
5
+ '.git', 'node_modules', '.next', 'dist', 'build', '.cache',
6
+ 'coverage', '__pycache__', '.venv', 'venv', '.DS_Store',
7
+ ]);
8
+ const MAX_FILES = 300;
9
+
10
+ export function getFiles(dir = process.cwd(), base = dir, depth = 0, acc = []) {
11
+ if (depth > 5 || acc.length >= MAX_FILES) return acc;
12
+ let entries;
13
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return acc; }
14
+ for (const entry of entries) {
15
+ if (IGNORE.has(entry.name) || entry.name.startsWith('.')) continue;
16
+ const full = join(dir, entry.name);
17
+ if (entry.isDirectory()) {
18
+ getFiles(full, base, depth + 1, acc);
19
+ } else {
20
+ acc.push(relative(base, full));
21
+ }
22
+ if (acc.length >= MAX_FILES) break;
23
+ }
24
+ return acc;
25
+ }
26
+
27
+ export function filterFiles(files, query) {
28
+ if (!query) return files;
29
+ const q = query.toLowerCase();
30
+ return files.filter(f => f.toLowerCase().includes(q));
31
+ }