markov-cli 1.0.10 → 1.0.12
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/.env.example +12 -0
- package/bin/markov.js +15 -0
- package/package.json +1 -1
- package/src/agent/agentLoop.js +131 -0
- package/src/agent/context.js +102 -0
- package/src/auth.js +46 -0
- package/src/claude.js +318 -0
- package/src/commands/setup.js +72 -0
- package/src/editor/codeBlockEdits.js +27 -0
- package/src/files.js +1 -1
- package/src/input.js +67 -13
- package/src/interactive.js +348 -599
- package/src/ollama.js +173 -6
- package/src/openai.js +258 -0
- package/src/tools.js +151 -35
- package/src/ui/formatting.js +125 -0
- package/src/ui/prompts.js +116 -0
- package/src/ui/spinner.js +40 -0
package/src/tools.js
CHANGED
|
@@ -2,6 +2,7 @@ import { exec } from 'child_process';
|
|
|
2
2
|
import { promisify } from 'util';
|
|
3
3
|
import { mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync, readdirSync, statSync } from 'fs';
|
|
4
4
|
import { resolve, dirname } from 'path';
|
|
5
|
+
import { chatWithTools } from './ollama.js';
|
|
5
6
|
|
|
6
7
|
const execAsync = promisify(exec);
|
|
7
8
|
|
|
@@ -29,23 +30,23 @@ export const RUN_TERMINAL_COMMAND_TOOL = {
|
|
|
29
30
|
};
|
|
30
31
|
|
|
31
32
|
/** Ollama-format tool definition for creating a directory */
|
|
32
|
-
export const CREATE_FOLDER_TOOL = {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
};
|
|
33
|
+
// export const CREATE_FOLDER_TOOL = {
|
|
34
|
+
// type: 'function',
|
|
35
|
+
// function: {
|
|
36
|
+
// name: 'create_folder',
|
|
37
|
+
// description: 'Create a folder (and any necessary parent folders) in the current working directory.',
|
|
38
|
+
// parameters: {
|
|
39
|
+
// type: 'object',
|
|
40
|
+
// required: ['path'],
|
|
41
|
+
// properties: {
|
|
42
|
+
// path: {
|
|
43
|
+
// type: 'string',
|
|
44
|
+
// description: 'Relative path of the folder to create (e.g. "src/components").',
|
|
45
|
+
// },
|
|
46
|
+
// },
|
|
47
|
+
// },
|
|
48
|
+
// },
|
|
49
|
+
// };
|
|
49
50
|
|
|
50
51
|
/** Ollama-format tool definition for reading a file */
|
|
51
52
|
export const READ_FILE_TOOL = {
|
|
@@ -66,7 +67,7 @@ export const READ_FILE_TOOL = {
|
|
|
66
67
|
},
|
|
67
68
|
};
|
|
68
69
|
|
|
69
|
-
|
|
70
|
+
/* WRITE_FILE_TOOL disabled: model uses run_terminal_command for file creation/overwrite
|
|
70
71
|
export const WRITE_FILE_TOOL = {
|
|
71
72
|
type: 'function',
|
|
72
73
|
function: {
|
|
@@ -88,6 +89,7 @@ export const WRITE_FILE_TOOL = {
|
|
|
88
89
|
},
|
|
89
90
|
},
|
|
90
91
|
};
|
|
92
|
+
*/
|
|
91
93
|
|
|
92
94
|
/** Ollama-format tool definition for search-and-replace in a file */
|
|
93
95
|
export const SEARCH_REPLACE_TOOL = {
|
|
@@ -117,37 +119,83 @@ export const SEARCH_REPLACE_TOOL = {
|
|
|
117
119
|
};
|
|
118
120
|
|
|
119
121
|
/** Ollama-format tool definition for deleting a file */
|
|
120
|
-
export const DELETE_FILE_TOOL = {
|
|
122
|
+
// export const DELETE_FILE_TOOL = {
|
|
123
|
+
// type: 'function',
|
|
124
|
+
// function: {
|
|
125
|
+
// name: 'delete_file',
|
|
126
|
+
// description: 'Delete a file. Does not remove directories.',
|
|
127
|
+
// parameters: {
|
|
128
|
+
// type: 'object',
|
|
129
|
+
// required: ['path'],
|
|
130
|
+
// properties: {
|
|
131
|
+
// path: {
|
|
132
|
+
// type: 'string',
|
|
133
|
+
// description: 'Relative path to the file to delete.',
|
|
134
|
+
// },
|
|
135
|
+
// },
|
|
136
|
+
// },
|
|
137
|
+
// },
|
|
138
|
+
// };
|
|
139
|
+
|
|
140
|
+
/** Ollama-format tool definition for listing directory contents */
|
|
141
|
+
// export const LIST_DIR_TOOL = {
|
|
142
|
+
// type: 'function',
|
|
143
|
+
// function: {
|
|
144
|
+
// name: 'list_dir',
|
|
145
|
+
// description: 'List files and folders in a directory. Use to discover paths before reading or editing.',
|
|
146
|
+
// parameters: {
|
|
147
|
+
// type: 'object',
|
|
148
|
+
// required: [],
|
|
149
|
+
// properties: {
|
|
150
|
+
// path: {
|
|
151
|
+
// type: 'string',
|
|
152
|
+
// description: 'Relative path to the directory (default "." for current directory).',
|
|
153
|
+
// },
|
|
154
|
+
// },
|
|
155
|
+
// },
|
|
156
|
+
// },
|
|
157
|
+
// };
|
|
158
|
+
|
|
159
|
+
/** Ollama-format tool definition for web search */
|
|
160
|
+
export const WEB_SEARCH_TOOL = {
|
|
121
161
|
type: 'function',
|
|
122
162
|
function: {
|
|
123
|
-
name: '
|
|
124
|
-
description: '
|
|
163
|
+
name: 'web_search',
|
|
164
|
+
description: 'Search the web for current information. Use when the user asks about recent events, documentation, or facts that may require up-to-date or external sources.',
|
|
125
165
|
parameters: {
|
|
126
166
|
type: 'object',
|
|
127
|
-
required: ['
|
|
167
|
+
required: ['query'],
|
|
128
168
|
properties: {
|
|
129
|
-
|
|
169
|
+
query: {
|
|
130
170
|
type: 'string',
|
|
131
|
-
description: '
|
|
171
|
+
description: 'Search query (e.g. "Node.js 20 release date", "React useEffect docs").',
|
|
172
|
+
},
|
|
173
|
+
num_results: {
|
|
174
|
+
type: 'number',
|
|
175
|
+
description: 'Maximum number of results to return (default 5).',
|
|
132
176
|
},
|
|
133
177
|
},
|
|
134
178
|
},
|
|
135
179
|
},
|
|
136
180
|
};
|
|
137
181
|
|
|
138
|
-
/** Ollama-format tool definition for
|
|
139
|
-
export const
|
|
182
|
+
/** Ollama-format tool definition for delegating a task to a fast 0.8b subagent */
|
|
183
|
+
export const CREATE_SUBAGENT_TOOL = {
|
|
140
184
|
type: 'function',
|
|
141
185
|
function: {
|
|
142
|
-
name: '
|
|
143
|
-
description: '
|
|
186
|
+
name: 'create_subagent',
|
|
187
|
+
description: 'Delegate a focused, self-contained task to a fast 0.8b model. Use for isolated sub-tasks like summarizing text, generating a regex, writing a small helper function, or answering a specific question. The subagent has no tools and no prior conversation context — pass everything it needs in task/context. Its response is returned directly to you.',
|
|
144
188
|
parameters: {
|
|
145
189
|
type: 'object',
|
|
146
|
-
required: [],
|
|
190
|
+
required: ['task'],
|
|
147
191
|
properties: {
|
|
148
|
-
|
|
192
|
+
task: {
|
|
193
|
+
type: 'string',
|
|
194
|
+
description: 'The specific task or question for the subagent to complete (be precise and self-contained).',
|
|
195
|
+
},
|
|
196
|
+
context: {
|
|
149
197
|
type: 'string',
|
|
150
|
-
description: '
|
|
198
|
+
description: 'Optional extra context the subagent needs (e.g. a file snippet, a schema, relevant background). Keep it concise.',
|
|
151
199
|
},
|
|
152
200
|
},
|
|
153
201
|
},
|
|
@@ -157,12 +205,14 @@ export const LIST_DIR_TOOL = {
|
|
|
157
205
|
/** Tools array for agent loop (chatWithTools). */
|
|
158
206
|
export const AGENT_TOOLS = [
|
|
159
207
|
RUN_TERMINAL_COMMAND_TOOL,
|
|
160
|
-
CREATE_FOLDER_TOOL,
|
|
208
|
+
// CREATE_FOLDER_TOOL,
|
|
161
209
|
READ_FILE_TOOL,
|
|
162
|
-
WRITE_FILE_TOOL,
|
|
210
|
+
// WRITE_FILE_TOOL, // disabled: model uses run_terminal_command for file creation/overwrite
|
|
163
211
|
SEARCH_REPLACE_TOOL,
|
|
164
|
-
DELETE_FILE_TOOL,
|
|
165
|
-
LIST_DIR_TOOL,
|
|
212
|
+
// DELETE_FILE_TOOL,
|
|
213
|
+
// LIST_DIR_TOOL,
|
|
214
|
+
WEB_SEARCH_TOOL,
|
|
215
|
+
CREATE_SUBAGENT_TOOL,
|
|
166
216
|
];
|
|
167
217
|
|
|
168
218
|
function getPath(args, opts) {
|
|
@@ -199,6 +249,7 @@ const TOOLS_MAP = {
|
|
|
199
249
|
return { error: err.message };
|
|
200
250
|
}
|
|
201
251
|
},
|
|
252
|
+
/* write_file disabled: model uses run_terminal_command for file creation/overwrite
|
|
202
253
|
write_file: async (args, opts = {}) => {
|
|
203
254
|
const { path: relPath, absPath } = getPath(args, opts);
|
|
204
255
|
const content = typeof args.content === 'string' ? args.content : String(args?.content ?? '');
|
|
@@ -211,6 +262,7 @@ const TOOLS_MAP = {
|
|
|
211
262
|
return { success: false, error: err.message };
|
|
212
263
|
}
|
|
213
264
|
},
|
|
265
|
+
*/
|
|
214
266
|
search_replace: async (args, opts = {}) => {
|
|
215
267
|
const { path: relPath, absPath } = getPath(args, opts);
|
|
216
268
|
const oldStr = typeof args.old_string === 'string' ? args.old_string : String(args?.old_string ?? '');
|
|
@@ -287,6 +339,70 @@ const TOOLS_MAP = {
|
|
|
287
339
|
return { stdout, stderr, exitCode };
|
|
288
340
|
}
|
|
289
341
|
},
|
|
342
|
+
web_search: async (args) => {
|
|
343
|
+
const apiKey = '763399c40c6e5025305aff6f12bbff36152c0830';
|
|
344
|
+
if (!apiKey || !apiKey.trim()) {
|
|
345
|
+
return { error: 'Web search not configured. Set MARKOV_SEARCH_API_KEY.' };
|
|
346
|
+
}
|
|
347
|
+
const query = typeof args.query === 'string' ? args.query.trim() : String(args?.query ?? '').trim();
|
|
348
|
+
if (!query) {
|
|
349
|
+
return { error: 'Query is required for web_search.' };
|
|
350
|
+
}
|
|
351
|
+
const numResults = Math.min(10, Math.max(1, parseInt(args?.num_results, 10) || 5));
|
|
352
|
+
try {
|
|
353
|
+
const res = await fetch('https://google.serper.dev/search', {
|
|
354
|
+
method: 'POST',
|
|
355
|
+
headers: {
|
|
356
|
+
'X-API-KEY': apiKey,
|
|
357
|
+
'Content-Type': 'application/json',
|
|
358
|
+
},
|
|
359
|
+
body: JSON.stringify({ q: query, num: numResults }),
|
|
360
|
+
});
|
|
361
|
+
if (!res.ok) {
|
|
362
|
+
const text = await res.text();
|
|
363
|
+
return { error: `Search API error ${res.status}: ${text.slice(0, 200)}` };
|
|
364
|
+
}
|
|
365
|
+
const data = await res.json();
|
|
366
|
+
const organic = data?.organic ?? [];
|
|
367
|
+
const results = organic.slice(0, numResults).map((item) => ({
|
|
368
|
+
title: item.title ?? '',
|
|
369
|
+
link: item.link ?? item.url ?? '',
|
|
370
|
+
snippet: item.snippet ?? '',
|
|
371
|
+
}));
|
|
372
|
+
return { results, query };
|
|
373
|
+
} catch (err) {
|
|
374
|
+
return { error: err?.message ?? 'Web search request failed.' };
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
create_subagent: async (args) => {
|
|
378
|
+
const task = typeof args.task === 'string' ? args.task.trim() : '';
|
|
379
|
+
const context = typeof args.context === 'string' ? args.context.trim() : '';
|
|
380
|
+
if (!task) return { error: 'task is required for create_subagent' };
|
|
381
|
+
|
|
382
|
+
const messages = [
|
|
383
|
+
{
|
|
384
|
+
role: 'system',
|
|
385
|
+
content: 'You are a focused AI subagent. Complete the assigned task concisely and return only the result. No preamble.',
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
role: 'user',
|
|
389
|
+
content: context ? `Context:\n${context}\n\nTask: ${task}` : task,
|
|
390
|
+
},
|
|
391
|
+
];
|
|
392
|
+
|
|
393
|
+
try {
|
|
394
|
+
const data = await chatWithTools(messages, [RUN_TERMINAL_COMMAND_TOOL], 'qwen3.5:0.8b', null, 'ollama');
|
|
395
|
+
// Backend returns raw JSON; Claude path returns { message: { content } }
|
|
396
|
+
const content =
|
|
397
|
+
data?.message?.content ??
|
|
398
|
+
data?.choices?.[0]?.message?.content ??
|
|
399
|
+
data?.content ??
|
|
400
|
+
'';
|
|
401
|
+
return { result: content, model: 'qwen3.5:0.8b' };
|
|
402
|
+
} catch (err) {
|
|
403
|
+
return { error: err.message ?? 'Subagent call failed' };
|
|
404
|
+
}
|
|
405
|
+
},
|
|
290
406
|
};
|
|
291
407
|
|
|
292
408
|
/**
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
const TERM_WIDTH = 80;
|
|
4
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
5
|
+
const visLen = (s) => s.replace(ANSI_RE, '').length;
|
|
6
|
+
|
|
7
|
+
/** Print token usage when the provider returns it (OpenAI or Anthropic shape). */
|
|
8
|
+
export function printTokenUsage(usage) {
|
|
9
|
+
if (!usage) return;
|
|
10
|
+
// OpenAI: prompt_tokens, completion_tokens, total_tokens
|
|
11
|
+
if (typeof usage.prompt_tokens === 'number' && typeof usage.completion_tokens === 'number') {
|
|
12
|
+
const total = usage.total_tokens ?? usage.prompt_tokens + usage.completion_tokens;
|
|
13
|
+
console.log(chalk.dim(`Tokens: prompt ${usage.prompt_tokens}, completion ${usage.completion_tokens}, total ${total}`));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
// Anthropic: input_tokens, output_tokens
|
|
17
|
+
if (typeof usage.input_tokens === 'number' && typeof usage.output_tokens === 'number') {
|
|
18
|
+
const total = usage.input_tokens + usage.output_tokens;
|
|
19
|
+
console.log(chalk.dim(`Tokens: input ${usage.input_tokens}, output ${usage.output_tokens}, total ${total}`));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Word-wrap plain text to a given column width, preserving existing newlines. */
|
|
24
|
+
export function wrapText(text, width) {
|
|
25
|
+
return text.split('\n').map(line => {
|
|
26
|
+
if (visLen(line) <= width) return line;
|
|
27
|
+
const words = line.split(' ');
|
|
28
|
+
const wrapped = [];
|
|
29
|
+
let current = '';
|
|
30
|
+
for (const word of words) {
|
|
31
|
+
const test = current ? current + ' ' + word : word;
|
|
32
|
+
if (visLen(test) <= width) {
|
|
33
|
+
current = test;
|
|
34
|
+
} else {
|
|
35
|
+
if (current) wrapped.push(current);
|
|
36
|
+
current = word;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (current) wrapped.push(current);
|
|
40
|
+
return wrapped.join('\n');
|
|
41
|
+
}).join('\n');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Parse fenced code blocks (```lang\n...\n```) and render them with plain styling. Non-code segments are wrapped. */
|
|
45
|
+
export function formatResponseWithCodeBlocks(text, width) {
|
|
46
|
+
if (!text || typeof text !== 'string') return '';
|
|
47
|
+
const re = /```(\w*)\n([\s\S]*?)```/g;
|
|
48
|
+
const parts = [];
|
|
49
|
+
let lastIndex = 0;
|
|
50
|
+
let m;
|
|
51
|
+
while ((m = re.exec(text)) !== null) {
|
|
52
|
+
if (m.index > lastIndex) {
|
|
53
|
+
const textSegment = text.slice(lastIndex, m.index);
|
|
54
|
+
if (textSegment) parts.push({ type: 'text', content: textSegment });
|
|
55
|
+
}
|
|
56
|
+
parts.push({ type: 'code', lang: m[1], content: m[2].trim() });
|
|
57
|
+
lastIndex = re.lastIndex;
|
|
58
|
+
}
|
|
59
|
+
if (lastIndex < text.length) {
|
|
60
|
+
const textSegment = text.slice(lastIndex);
|
|
61
|
+
if (textSegment) parts.push({ type: 'text', content: textSegment });
|
|
62
|
+
}
|
|
63
|
+
if (parts.length === 0) return wrapText(text, width);
|
|
64
|
+
return parts.map((p) => {
|
|
65
|
+
if (p.type === 'text') return wrapText(p.content, width);
|
|
66
|
+
const label = p.lang ? p.lang : 'code';
|
|
67
|
+
const header = chalk.dim('─── ') + chalk.cyan(label) + chalk.dim(' ' + '─'.repeat(Math.max(0, width - label.length - 5)));
|
|
68
|
+
const code = p.content.split('\n').map(l => chalk.dim(' ') + l).join('\n');
|
|
69
|
+
return header + '\n' + code + '\n' + chalk.dim('─'.repeat(width));
|
|
70
|
+
}).join('\n\n');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Preview of a file edit for confirmation (search_replace) */
|
|
74
|
+
export function formatFileEditPreview(name, args) {
|
|
75
|
+
const path = args?.path ?? '(no path)';
|
|
76
|
+
if (name === 'search_replace') {
|
|
77
|
+
const oldStr = String(args?.old_string ?? '');
|
|
78
|
+
const newStr = String(args?.new_string ?? '');
|
|
79
|
+
const max = 120;
|
|
80
|
+
const oldPreview = oldStr.length > max ? oldStr.slice(0, max) + '…' : oldStr;
|
|
81
|
+
const newPreview = newStr.length > max ? newStr.slice(0, max) + '…' : newStr;
|
|
82
|
+
return (
|
|
83
|
+
chalk.cyan(path) + '\n' +
|
|
84
|
+
chalk.red(' - ' + (oldPreview || '(empty)').replace(/\n/g, '\n ')) + '\n' +
|
|
85
|
+
chalk.green(' + ' + (newPreview || '(empty)').replace(/\n/g, '\n '))
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return path;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** One-line summary of tool args for display */
|
|
92
|
+
export function formatToolCallSummary(name, args) {
|
|
93
|
+
const a = args ?? {};
|
|
94
|
+
if (name === 'run_terminal_command') return (a.command ?? '').trim() || '(empty)';
|
|
95
|
+
if (name === 'search_replace') return (a.path ?? '') + (a.old_string ? ` "${String(a.old_string).slice(0, 30)}…"` : '');
|
|
96
|
+
if (name === 'read_file' || name === 'delete_file') return a.path ?? '(no path)';
|
|
97
|
+
if (name === 'create_folder') return a.path ?? '(no path)';
|
|
98
|
+
if (name === 'list_dir') return (a.path ?? '.') || '.';
|
|
99
|
+
if (name === 'web_search') return (a.query ?? '').trim() || '(empty query)';
|
|
100
|
+
return JSON.stringify(a).slice(0, 50);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** One-line summary of tool result for display */
|
|
104
|
+
export function formatToolResultSummary(name, resultJson) {
|
|
105
|
+
let obj;
|
|
106
|
+
try {
|
|
107
|
+
obj = typeof resultJson === 'string' ? JSON.parse(resultJson) : resultJson;
|
|
108
|
+
} catch {
|
|
109
|
+
return resultJson?.slice(0, 60) ?? '—';
|
|
110
|
+
}
|
|
111
|
+
if (obj.error) return chalk.red('✗ ' + obj.error);
|
|
112
|
+
if (obj.declined) return chalk.yellow('✗ declined');
|
|
113
|
+
if (name === 'run_terminal_command') {
|
|
114
|
+
const code = obj.exitCode ?? obj.exit_code;
|
|
115
|
+
if (code === 0) return chalk.green('✓ exit 0') + (obj.stdout ? chalk.dim(' ' + String(obj.stdout).trim().slice(0, 80).replace(/\n/g, ' ')) : '');
|
|
116
|
+
return chalk.red(`✗ exit ${code}`) + (obj.stderr ? chalk.dim(' ' + String(obj.stderr).trim().slice(0, 80)) : '');
|
|
117
|
+
}
|
|
118
|
+
if (name === 'search_replace' || name === 'delete_file' || name === 'create_folder') {
|
|
119
|
+
return obj.success !== false ? chalk.green('✓ ' + (obj.path ? obj.path : 'ok')) : chalk.red('✗ ' + (obj.error || 'failed'));
|
|
120
|
+
}
|
|
121
|
+
if (name === 'read_file') return obj.content != null ? chalk.green('✓ ' + (obj.path ?? '') + chalk.dim(` (${String(obj.content).length} chars)`)) : chalk.red('✗ ' + (obj.error || ''));
|
|
122
|
+
if (name === 'list_dir') return obj.entries ? chalk.green('✓ ' + (obj.entries.length ?? 0) + ' entries') : chalk.red('✗ ' + (obj.error || ''));
|
|
123
|
+
if (name === 'web_search') return obj.error ? chalk.red('✗ ' + obj.error) : (obj.results ? chalk.green('✓ ' + (obj.results.length ?? 0) + ' results') : chalk.dim('—'));
|
|
124
|
+
return chalk.dim(JSON.stringify(obj).slice(0, 60));
|
|
125
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
/** Arrow-key selector. Returns the chosen string or null if cancelled. */
|
|
4
|
+
export function selectFrom(options, label) {
|
|
5
|
+
return new Promise((resolve) => {
|
|
6
|
+
let idx = 0;
|
|
7
|
+
|
|
8
|
+
const draw = () => {
|
|
9
|
+
process.stdout.write('\r\x1b[0J');
|
|
10
|
+
process.stdout.write(chalk.dim(label) + '\n');
|
|
11
|
+
options.forEach((o, i) => {
|
|
12
|
+
process.stdout.write(
|
|
13
|
+
i === idx
|
|
14
|
+
? ' ' + chalk.bgCyan.black(` ${o} `) + '\n'
|
|
15
|
+
: ' ' + chalk.dim(o) + '\n'
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
// Move cursor back up to keep it stable
|
|
19
|
+
process.stdout.write(`\x1b[${options.length + 1}A`);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const cleanup = () => {
|
|
23
|
+
process.stdin.removeListener('data', onKey);
|
|
24
|
+
process.stdin.setRawMode(false);
|
|
25
|
+
process.stdin.pause();
|
|
26
|
+
// Clear the drawn lines
|
|
27
|
+
process.stdout.write('\r\x1b[0J');
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const onKey = (data) => {
|
|
31
|
+
const key = data.toString();
|
|
32
|
+
if (key === '\x1b[A') { idx = (idx - 1 + options.length) % options.length; draw(); return; }
|
|
33
|
+
if (key === '\x1b[B') { idx = (idx + 1) % options.length; draw(); return; }
|
|
34
|
+
if (key === '\r' || key === '\n') { cleanup(); resolve(options[idx]); return; }
|
|
35
|
+
if (key === '\x03' || key === '\x11') { cleanup(); resolve(null); return; }
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
process.stdin.setRawMode(true);
|
|
39
|
+
process.stdin.resume();
|
|
40
|
+
process.stdin.setEncoding('utf8');
|
|
41
|
+
process.stdin.on('data', onKey);
|
|
42
|
+
draw();
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Prompt y/n in raw mode, returns true for y/Y. */
|
|
47
|
+
export function confirm(question) {
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
process.stdout.write(question);
|
|
50
|
+
process.stdin.setRawMode(true);
|
|
51
|
+
process.stdin.resume();
|
|
52
|
+
process.stdin.setEncoding('utf8');
|
|
53
|
+
const onKey = (key) => {
|
|
54
|
+
process.stdin.removeListener('data', onKey);
|
|
55
|
+
process.stdin.setRawMode(false);
|
|
56
|
+
process.stdin.pause();
|
|
57
|
+
const answer = key.toLowerCase() === 'y';
|
|
58
|
+
process.stdout.write(answer ? chalk.green('y\n') : chalk.dim('n\n'));
|
|
59
|
+
resolve(answer);
|
|
60
|
+
};
|
|
61
|
+
process.stdin.on('data', onKey);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Read a visible line of input. */
|
|
66
|
+
export function promptLine(label) {
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
process.stdout.write(label);
|
|
69
|
+
let buf = '';
|
|
70
|
+
const onData = (data) => {
|
|
71
|
+
const key = data.toString();
|
|
72
|
+
if (key === '\r' || key === '\n') {
|
|
73
|
+
process.stdin.removeListener('data', onData);
|
|
74
|
+
process.stdin.setRawMode(false);
|
|
75
|
+
process.stdin.pause();
|
|
76
|
+
process.stdout.write('\n');
|
|
77
|
+
resolve(buf);
|
|
78
|
+
} else if (key === '\x7f' || key === '\b') {
|
|
79
|
+
if (buf.length > 0) { buf = buf.slice(0, -1); process.stdout.write('\b \b'); }
|
|
80
|
+
} else if (key >= ' ') {
|
|
81
|
+
buf += key;
|
|
82
|
+
process.stdout.write(key);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
process.stdin.setRawMode(true);
|
|
86
|
+
process.stdin.resume();
|
|
87
|
+
process.stdin.setEncoding('utf8');
|
|
88
|
+
process.stdin.on('data', onData);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Read a line of input without echo (for passwords). */
|
|
93
|
+
export function promptSecret(label) {
|
|
94
|
+
return new Promise((resolve) => {
|
|
95
|
+
process.stdout.write(label);
|
|
96
|
+
let buf = '';
|
|
97
|
+
const onData = (data) => {
|
|
98
|
+
const key = data.toString();
|
|
99
|
+
if (key === '\r' || key === '\n') {
|
|
100
|
+
process.stdin.removeListener('data', onData);
|
|
101
|
+
process.stdin.setRawMode(false);
|
|
102
|
+
process.stdin.pause();
|
|
103
|
+
process.stdout.write('\n');
|
|
104
|
+
resolve(buf);
|
|
105
|
+
} else if (key === '\x7f' || key === '\b') {
|
|
106
|
+
if (buf.length > 0) buf = buf.slice(0, -1);
|
|
107
|
+
} else if (key >= ' ') {
|
|
108
|
+
buf += key;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
process.stdin.setRawMode(true);
|
|
112
|
+
process.stdin.resume();
|
|
113
|
+
process.stdin.setEncoding('utf8');
|
|
114
|
+
process.stdin.on('data', onData);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import gradient from 'gradient-string';
|
|
3
|
+
|
|
4
|
+
const agentGradient = gradient(['#22c55e', '#16a34a', '#4ade80']);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create a spinner with a given label.
|
|
8
|
+
* Returns an object with a stop() method.
|
|
9
|
+
* @param {string} label - The label to display before the spinner
|
|
10
|
+
* @returns {{ stop: () => void }} A spinner handle with a stop() method
|
|
11
|
+
*/
|
|
12
|
+
export function createSpinner(label) {
|
|
13
|
+
const DOTS = ['.', '..', '...'];
|
|
14
|
+
let dotIdx = 0;
|
|
15
|
+
let interval = null;
|
|
16
|
+
const startTime = Date.now();
|
|
17
|
+
|
|
18
|
+
const start = () => {
|
|
19
|
+
if (interval) clearInterval(interval);
|
|
20
|
+
dotIdx = 0;
|
|
21
|
+
process.stdout.write(chalk.dim(`\n${label}`));
|
|
22
|
+
interval = setInterval(() => {
|
|
23
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
24
|
+
process.stdout.write('\r' + chalk.dim(label) + chalk.dim(elapsed + 's ') + agentGradient(DOTS[dotIdx % DOTS.length]) + ' ');
|
|
25
|
+
dotIdx++;
|
|
26
|
+
}, 400);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const stop = () => {
|
|
30
|
+
if (interval) {
|
|
31
|
+
clearInterval(interval);
|
|
32
|
+
interval = null;
|
|
33
|
+
}
|
|
34
|
+
process.stdout.write('\r\x1b[0J');
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
start();
|
|
38
|
+
|
|
39
|
+
return { stop };
|
|
40
|
+
}
|