helix-assistant-cli 0.2.1 → 0.2.7
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/dist/index.js +75 -5
- package/dist/skills/user-skill-discovery.d.ts +12 -0
- package/dist/skills/user-skill-discovery.js +52 -0
- package/dist/ui/autocomplete.d.ts +5 -1
- package/dist/ui/autocomplete.js +18 -2
- package/dist/ui/banner.d.ts +3 -12
- package/dist/ui/banner.js +22 -145
- package/dist/ui/markdown.d.ts +5 -0
- package/dist/ui/markdown.js +49 -0
- package/dist/ui/repl.d.ts +2 -0
- package/dist/ui/repl.js +77 -27
- package/package.json +1 -1
- package/src/index.ts +77 -5
- package/src/skills/user-skill-discovery.ts +45 -0
- package/src/ui/autocomplete.ts +21 -2
- package/src/ui/banner.ts +22 -177
- package/src/ui/markdown.ts +51 -0
- package/src/ui/repl.ts +65 -29
- package/tests/cli.test.ts +106 -0
- package/tsconfig.json +2 -1
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Formats Markdown text for optimal display in terminal emulators,
|
|
5
|
+
* resolving visual noise from unparsed asterisks (*, **) and headers.
|
|
6
|
+
*/
|
|
7
|
+
export function formatTerminalMarkdown(content: string, plain = false): string {
|
|
8
|
+
if (!content) return '';
|
|
9
|
+
|
|
10
|
+
const lines = content.split('\n');
|
|
11
|
+
const formattedLines = lines.map((line) => {
|
|
12
|
+
let formatted = line;
|
|
13
|
+
|
|
14
|
+
// Headers (#, ##, ###)
|
|
15
|
+
const headerMatch = formatted.match(/^(#{1,6})\s+(.*)$/);
|
|
16
|
+
if (headerMatch) {
|
|
17
|
+
const title = headerMatch[2].trim();
|
|
18
|
+
if (plain) {
|
|
19
|
+
return title.toUpperCase();
|
|
20
|
+
}
|
|
21
|
+
return chalk.hex('#FF9F1C').bold(title);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Bullet lists (* item, - item)
|
|
25
|
+
if (/^\s*[*+-]\s+/.test(formatted)) {
|
|
26
|
+
formatted = formatted.replace(/^(\s*)[*+-]\s+/, (_m, indent) => {
|
|
27
|
+
return plain ? `${indent}- ` : `${indent}${chalk.hex('#FF8C00')('•')} `;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Inline bold (**text** or __text__)
|
|
32
|
+
formatted = formatted.replace(/(\*\*|__)(.*?)\1/g, (_m, _delim, text) => {
|
|
33
|
+
return plain ? text : chalk.bold(text);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Inline italics (*text* or _text_)
|
|
37
|
+
formatted = formatted.replace(/(?<!\*)\*([^*]+)\*(?!\*)|(?<!_)_([^_]+)_(?!_)/g, (_m, t1, t2) => {
|
|
38
|
+
const text = t1 || t2;
|
|
39
|
+
return plain ? text : chalk.whiteBright(text);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Inline code (`code`)
|
|
43
|
+
formatted = formatted.replace(/`([^`]+)`/g, (_m, code) => {
|
|
44
|
+
return plain ? code : chalk.cyan(code);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return formatted;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return formattedLines.join('\n');
|
|
51
|
+
}
|
package/src/ui/repl.ts
CHANGED
|
@@ -25,10 +25,12 @@ import { handleCreateMcp, handleListMcp } from '../commands/mcp';
|
|
|
25
25
|
import { handleMicrophoneCommand } from '../commands/mic';
|
|
26
26
|
import { handleBrowserCommand } from '../commands/browser';
|
|
27
27
|
import type { ConfirmationRequest } from '../client/types';
|
|
28
|
+
import { formatTerminalMarkdown } from './markdown';
|
|
28
29
|
|
|
29
30
|
export interface ReplOptions {
|
|
30
31
|
wsUrl: string;
|
|
31
32
|
wsToken?: string;
|
|
33
|
+
plain?: boolean;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
export class HelixRepl {
|
|
@@ -43,9 +45,11 @@ export class HelixRepl {
|
|
|
43
45
|
private activeTaskId: string | null = null;
|
|
44
46
|
private activeTasksCount: number = 0;
|
|
45
47
|
private isProcessingPrompt: boolean = false;
|
|
48
|
+
private plain: boolean = false;
|
|
46
49
|
|
|
47
50
|
constructor(private options: ReplOptions) {
|
|
48
51
|
this.conversationId = `cli_conv_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
|
|
52
|
+
this.plain = Boolean(options.plain);
|
|
49
53
|
this.client = new AgentClient({
|
|
50
54
|
wsUrl: options.wsUrl,
|
|
51
55
|
wsToken: options.wsToken,
|
|
@@ -148,7 +152,7 @@ export class HelixRepl {
|
|
|
148
152
|
// Voice Capture indicator
|
|
149
153
|
this.client.on('voice_capture_started', () => {
|
|
150
154
|
this.isCapturingVoice = true;
|
|
151
|
-
process.stdout.write(`\n${chalk.bgRed.white.bold(' 🎙️
|
|
155
|
+
process.stdout.write(`\n${chalk.bgRed.white.bold(' 🎙️ [VOZ DETECTADA - HEY HELIX]: Capturando audio... ')}\n`);
|
|
152
156
|
});
|
|
153
157
|
|
|
154
158
|
this.client.on('voice_capture_stopped', () => {
|
|
@@ -182,8 +186,9 @@ export class HelixRepl {
|
|
|
182
186
|
console.log('');
|
|
183
187
|
console.log(` ${chalk.hex('#FF8C00').bold('⬡ HELIX')}${modelBadge}`);
|
|
184
188
|
|
|
185
|
-
// Content with subtle left border
|
|
186
|
-
const
|
|
189
|
+
// Content with subtle left border and clean Markdown formatting
|
|
190
|
+
const formatted = formatTerminalMarkdown(content, this.plain);
|
|
191
|
+
const contentLines = formatted.trim().split('\n');
|
|
187
192
|
for (const line of contentLines) {
|
|
188
193
|
console.log(` ${chalk.gray('│')} ${line}`);
|
|
189
194
|
}
|
|
@@ -207,7 +212,7 @@ export class HelixRepl {
|
|
|
207
212
|
}
|
|
208
213
|
|
|
209
214
|
private async _handleConfirmationDialog(req: ConfirmationRequest): Promise<void> {
|
|
210
|
-
console.log(`\n${YELLOW_BOLD('⚠️
|
|
215
|
+
console.log(`\n${YELLOW_BOLD('⚠️ CONFIRMACIÓN DE ACCIÓN REQUERIDA')}`);
|
|
211
216
|
console.log(` Acción: ${chalk.white.bold(req.action || req.tool || 'Acción del sistema')}`);
|
|
212
217
|
if (req.description) {
|
|
213
218
|
console.log(` Descripción: ${DIM(req.description)}`);
|
|
@@ -216,31 +221,44 @@ export class HelixRepl {
|
|
|
216
221
|
console.log(` Parámetros: ${GRAY(JSON.stringify(req.arguments))}`);
|
|
217
222
|
}
|
|
218
223
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
224
|
+
this.rl?.pause();
|
|
225
|
+
try {
|
|
226
|
+
const answer = await prompts({
|
|
227
|
+
type: 'confirm',
|
|
228
|
+
name: 'confirmed',
|
|
229
|
+
message: '¿Permitir al agente ejecutar esta acción?',
|
|
230
|
+
initial: true,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const reqId = req.request_id || req.id || '';
|
|
234
|
+
if (answer && answer.confirmed) {
|
|
235
|
+
this.client.confirmAction(reqId);
|
|
236
|
+
console.log(GREEN(' ✔ Acción confirmada.\n'));
|
|
237
|
+
} else {
|
|
238
|
+
this.client.rejectAction(reqId);
|
|
239
|
+
console.log(RED(' ✖ Acción rechazada.\n'));
|
|
240
|
+
}
|
|
241
|
+
} finally {
|
|
242
|
+
if (process.stdin.isTTY && typeof process.stdin.setRawMode === 'function') {
|
|
243
|
+
try {
|
|
244
|
+
process.stdin.setRawMode(false);
|
|
245
|
+
} catch {}
|
|
246
|
+
}
|
|
247
|
+
this.rl?.resume();
|
|
248
|
+
this._promptUser();
|
|
233
249
|
}
|
|
234
|
-
|
|
235
|
-
this._promptUser();
|
|
236
250
|
}
|
|
237
251
|
|
|
238
252
|
private _startReadline(): void {
|
|
253
|
+
if (process.stdin.setEncoding) {
|
|
254
|
+
process.stdin.setEncoding('utf-8');
|
|
255
|
+
}
|
|
256
|
+
const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
239
257
|
const rl = readline.createInterface({
|
|
240
258
|
input: process.stdin,
|
|
241
259
|
output: process.stdout,
|
|
242
260
|
completer: (line: string) => this.autocompleter.completer(line),
|
|
243
|
-
terminal:
|
|
261
|
+
terminal: isInteractive,
|
|
244
262
|
});
|
|
245
263
|
|
|
246
264
|
this.rl = rl;
|
|
@@ -309,8 +327,14 @@ export class HelixRepl {
|
|
|
309
327
|
|
|
310
328
|
case '/config-apikey':
|
|
311
329
|
this.rl?.pause();
|
|
312
|
-
|
|
313
|
-
|
|
330
|
+
try {
|
|
331
|
+
await handleConfigApiKey(this.client);
|
|
332
|
+
} finally {
|
|
333
|
+
if (process.stdin.isTTY && typeof process.stdin.setRawMode === 'function') {
|
|
334
|
+
try { process.stdin.setRawMode(false); } catch {}
|
|
335
|
+
}
|
|
336
|
+
this.rl?.resume();
|
|
337
|
+
}
|
|
314
338
|
break;
|
|
315
339
|
|
|
316
340
|
case '/show-apikey':
|
|
@@ -323,9 +347,15 @@ export class HelixRepl {
|
|
|
323
347
|
|
|
324
348
|
case '/create-skill':
|
|
325
349
|
this.rl?.pause();
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
350
|
+
try {
|
|
351
|
+
await handleCreateSkill(this.client);
|
|
352
|
+
await this.autocompleter.refreshSuggestions();
|
|
353
|
+
} finally {
|
|
354
|
+
if (process.stdin.isTTY && typeof process.stdin.setRawMode === 'function') {
|
|
355
|
+
try { process.stdin.setRawMode(false); } catch {}
|
|
356
|
+
}
|
|
357
|
+
this.rl?.resume();
|
|
358
|
+
}
|
|
329
359
|
break;
|
|
330
360
|
|
|
331
361
|
case '/mcp':
|
|
@@ -334,9 +364,15 @@ export class HelixRepl {
|
|
|
334
364
|
|
|
335
365
|
case '/create-mcp':
|
|
336
366
|
this.rl?.pause();
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
367
|
+
try {
|
|
368
|
+
await handleCreateMcp(this.client);
|
|
369
|
+
await this.autocompleter.refreshSuggestions();
|
|
370
|
+
} finally {
|
|
371
|
+
if (process.stdin.isTTY && typeof process.stdin.setRawMode === 'function') {
|
|
372
|
+
try { process.stdin.setRawMode(false); } catch {}
|
|
373
|
+
}
|
|
374
|
+
this.rl?.resume();
|
|
375
|
+
}
|
|
340
376
|
break;
|
|
341
377
|
|
|
342
378
|
case '/microphone': {
|
package/tests/cli.test.ts
CHANGED
|
@@ -3,6 +3,10 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import { Autocompleter, SLASH_COMMANDS } from '../src/ui/autocomplete';
|
|
4
4
|
import { formatSkill, formatMcp, highlightMentions, ORANGE } from '../src/ui/colors';
|
|
5
5
|
import { getAsciiLogo, getStatusBar, getVoiceIndicator } from '../src/ui/banner';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { discoverUserSkillNames } from '../src/skills/user-skill-discovery';
|
|
6
10
|
|
|
7
11
|
test('Autocompleter suggests slash commands', () => {
|
|
8
12
|
const completer = new Autocompleter();
|
|
@@ -68,6 +72,42 @@ test('Autocompleter handles @skills and #mcps autocompletion', () => {
|
|
|
68
72
|
assert.deepEqual(skillSub, ['/skill web-search']);
|
|
69
73
|
});
|
|
70
74
|
|
|
75
|
+
test('User skill discovery is shallow, dynamic, and metadata-only', () => {
|
|
76
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'helix-cli-skills-'));
|
|
77
|
+
try {
|
|
78
|
+
fs.mkdirSync(path.join(root, 'meeting-notes'));
|
|
79
|
+
fs.writeFileSync(
|
|
80
|
+
path.join(root, 'meeting-notes', 'SKILL.md'),
|
|
81
|
+
'---\nname: meeting-notes\n---\nSECRET_INSTRUCTIONS_SHOULD_NOT_BE_READ',
|
|
82
|
+
'utf8',
|
|
83
|
+
);
|
|
84
|
+
fs.mkdirSync(path.join(root, 'not-a-skill.txt'));
|
|
85
|
+
fs.mkdirSync(path.join(root, 'nested', 'hidden-skill'), { recursive: true });
|
|
86
|
+
fs.writeFileSync(path.join(root, 'nested', 'hidden-skill', 'SKILL.md'), 'secret', 'utf8');
|
|
87
|
+
fs.writeFileSync(path.join(root, 'README.md'), 'not a skill', 'utf8');
|
|
88
|
+
|
|
89
|
+
assert.deepEqual(discoverUserSkillNames(root), ['meeting-notes']);
|
|
90
|
+
} finally {
|
|
91
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('Autocomplete merges default/remote skills with newly-created user skills', () => {
|
|
96
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'helix-cli-refresh-'));
|
|
97
|
+
try {
|
|
98
|
+
const completer = new Autocompleter(undefined, root);
|
|
99
|
+
(completer as any).remoteSkills = ['coding'];
|
|
100
|
+
(completer as any).skills = ['coding'];
|
|
101
|
+
fs.mkdirSync(path.join(root, 'custom-review'));
|
|
102
|
+
fs.writeFileSync(path.join(root, 'custom-review', 'SKILL.md'), '# user skill', 'utf8');
|
|
103
|
+
|
|
104
|
+
const [matches] = completer.completer('@custom');
|
|
105
|
+
assert.deepEqual(matches, ['@custom-review ']);
|
|
106
|
+
} finally {
|
|
107
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
71
111
|
test('Colors: formatSkill applies strong orange formatting', () => {
|
|
72
112
|
const formatted = formatSkill('web-researcher');
|
|
73
113
|
assert.ok(formatted.includes('@web-researcher'));
|
|
@@ -139,3 +179,69 @@ test('Launcher: probePort resolves false on closed random port', async () => {
|
|
|
139
179
|
const pythonExec = findPythonExecutable(process.cwd());
|
|
140
180
|
assert.ok(pythonExec.length > 0);
|
|
141
181
|
});
|
|
182
|
+
|
|
183
|
+
test('CLI: run without arguments prints helpful error and exits with code 1', async () => {
|
|
184
|
+
const { execSync } = await import('node:child_process');
|
|
185
|
+
const path = await import('node:path');
|
|
186
|
+
const binPath = path.resolve(__dirname, '../bin/helix.js');
|
|
187
|
+
try {
|
|
188
|
+
execSync(`node "${binPath}" run`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
189
|
+
assert.fail('Should have exited with code 1');
|
|
190
|
+
} catch (err: any) {
|
|
191
|
+
assert.equal(err.status, 1);
|
|
192
|
+
const stderr = err.stderr.toString('utf-8');
|
|
193
|
+
assert.ok(stderr.includes('Debes proporcionar un prompt'));
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('CLI: run with piped UTF-8 Spanish text with BOM is decoded cleanly', async () => {
|
|
198
|
+
const { spawn } = await import('node:child_process');
|
|
199
|
+
const path = await import('node:path');
|
|
200
|
+
const binPath = path.resolve(__dirname, '../bin/helix.js');
|
|
201
|
+
|
|
202
|
+
const child = spawn('node', [binPath, 'run'], {
|
|
203
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Write UTF-8 BOM + Spanish text with accents
|
|
207
|
+
const payload = Buffer.concat([
|
|
208
|
+
Buffer.from([0xef, 0xbb, 0xbf]),
|
|
209
|
+
Buffer.from('¿Cuál es la configuración actual?', 'utf-8'),
|
|
210
|
+
]);
|
|
211
|
+
|
|
212
|
+
child.stdin.write(payload);
|
|
213
|
+
child.stdin.end();
|
|
214
|
+
|
|
215
|
+
let stdout = '';
|
|
216
|
+
let stderr = '';
|
|
217
|
+
child.stdout.on('data', (d) => { stdout += d.toString('utf-8'); });
|
|
218
|
+
child.stderr.on('data', (d) => { stderr += d.toString('utf-8'); });
|
|
219
|
+
|
|
220
|
+
await new Promise<void>((resolve) => {
|
|
221
|
+
child.on('close', () => {
|
|
222
|
+
assert.ok(stdout.includes('¿Cuál es la configuración actual?'));
|
|
223
|
+
resolve();
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
test('Markdown: formatTerminalMarkdown parses bold, headers and bullets cleanly', async () => {
|
|
231
|
+
const { formatTerminalMarkdown } = await import('../src/ui/markdown');
|
|
232
|
+
const raw = '# Resumen\n\nAquí hay **texto importante** y un *detalle*:\n* Primer punto\n* Segundo punto\n`código`';
|
|
233
|
+
|
|
234
|
+
// Rich format
|
|
235
|
+
const formatted = formatTerminalMarkdown(raw);
|
|
236
|
+
assert.ok(!formatted.includes('**texto importante**'));
|
|
237
|
+
assert.ok(formatted.includes('texto importante'));
|
|
238
|
+
assert.ok(formatted.includes('• Primer punto'));
|
|
239
|
+
|
|
240
|
+
// Plain format
|
|
241
|
+
const plain = formatTerminalMarkdown(raw, true);
|
|
242
|
+
assert.ok(!plain.includes('**'));
|
|
243
|
+
assert.ok(!plain.includes('* Primer punto'));
|
|
244
|
+
assert.ok(plain.includes('- Primer punto'));
|
|
245
|
+
assert.ok(plain.includes('texto importante'));
|
|
246
|
+
assert.ok(plain.includes('RESUMEN'));
|
|
247
|
+
});
|
package/tsconfig.json
CHANGED
|
@@ -9,10 +9,11 @@
|
|
|
9
9
|
"esModuleInterop": true,
|
|
10
10
|
"skipLibCheck": true,
|
|
11
11
|
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
// @ts-ignore - node is deprecated but required for CommonJS compatibility with current dependencies
|
|
12
13
|
"moduleResolution": "node",
|
|
13
14
|
"resolveJsonModule": true,
|
|
14
15
|
"declaration": true
|
|
15
16
|
},
|
|
16
17
|
"include": ["src/**/*"],
|
|
17
18
|
"exclude": ["node_modules", "dist"]
|
|
18
|
-
}
|
|
19
|
+
}
|