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.
@@ -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(' 🎙️ [VOZ DETECTADA - HEY HELIX]: Capturando audio... ')}\n`);
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 contentLines = content.trim().split('\n');
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('⚠️ CONFIRMACIÓN DE ACCIÓN REQUERIDA')}`);
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
- const answer = await prompts({
220
- type: 'confirm',
221
- name: 'confirmed',
222
- message: '¿Permitir al agente ejecutar esta acción?',
223
- initial: true,
224
- });
225
-
226
- const reqId = req.request_id || req.id || '';
227
- if (answer.confirmed) {
228
- this.client.confirmAction(reqId);
229
- console.log(GREEN(' ✔ Acción confirmada.\n'));
230
- } else {
231
- this.client.rejectAction(reqId);
232
- console.log(RED(' ✖ Acción rechazada.\n'));
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: true,
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
- await handleConfigApiKey(this.client);
313
- this.rl?.resume();
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
- await handleCreateSkill(this.client);
327
- await this.autocompleter.refreshSuggestions();
328
- this.rl?.resume();
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
- await handleCreateMcp(this.client);
338
- await this.autocompleter.refreshSuggestions();
339
- this.rl?.resume();
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
+ }