helix-assistant-cli 0.2.4 → 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 CHANGED
@@ -70,14 +70,16 @@ const program = new commander_1.Command();
70
70
  program
71
71
  .name('helix')
72
72
  .description('HELIX - Interactive Terminal CLI for Windows AI Desktop Agent')
73
- .version('0.1.0')
73
+ .version('0.2.7')
74
74
  .option('-u, --ws-url <url>', 'URL del servidor WebSocket del agente', (0, env_1.getWsUrl)())
75
75
  .option('-t, --token <token>', 'Token de autenticación WebSocket de HELIX', (0, env_1.getWsToken)())
76
+ .option('--plain', 'Desactivar formato enriquecido de Markdown y mostrar texto plano sin asteriscos')
76
77
  .action(async (options) => {
77
78
  // Default action: start interactive chat REPL
78
79
  const repl = new repl_1.HelixRepl({
79
80
  wsUrl: options.wsUrl || (0, env_1.getWsUrl)(),
80
81
  wsToken: options.token || (0, env_1.getWsToken)(),
82
+ plain: Boolean(options.plain),
81
83
  });
82
84
  await repl.start();
83
85
  });
@@ -89,6 +91,7 @@ program
89
91
  const repl = new repl_1.HelixRepl({
90
92
  wsUrl: opts.wsUrl || (0, env_1.getWsUrl)(),
91
93
  wsToken: opts.token || (0, env_1.getWsToken)(),
94
+ plain: Boolean(opts.plain),
92
95
  });
93
96
  await repl.start();
94
97
  });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Resolves the only user-controlled directory used by the CLI skill catalog.
3
+ * HELIX_SKILLS_DIR is explicit; otherwise it follows the agent's settings path.
4
+ */
5
+ export declare function getUserSkillsDirectory(): string;
6
+ /**
7
+ * Returns names only. The file contents are deliberately never read: skill
8
+ * instructions can contain secrets and are not needed for autocomplete.
9
+ * Discovery is shallow and rejects symlinked entries to avoid following paths
10
+ * outside the user's skills directory.
11
+ */
12
+ export declare function discoverUserSkillNames(skillsDirectory?: string): string[];
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getUserSkillsDirectory = getUserSkillsDirectory;
7
+ exports.discoverUserSkillNames = discoverUserSkillNames;
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const path_1 = __importDefault(require("path"));
10
+ const env_1 = require("../config/env");
11
+ const SKILL_NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/;
12
+ /**
13
+ * Resolves the only user-controlled directory used by the CLI skill catalog.
14
+ * HELIX_SKILLS_DIR is explicit; otherwise it follows the agent's settings path.
15
+ */
16
+ function getUserSkillsDirectory() {
17
+ const configured = process.env.HELIX_SKILLS_DIR?.trim();
18
+ if (configured)
19
+ return path_1.default.resolve(configured);
20
+ return path_1.default.join(path_1.default.dirname((0, env_1.getSettingsFilePath)()), 'skills');
21
+ }
22
+ /**
23
+ * Returns names only. The file contents are deliberately never read: skill
24
+ * instructions can contain secrets and are not needed for autocomplete.
25
+ * Discovery is shallow and rejects symlinked entries to avoid following paths
26
+ * outside the user's skills directory.
27
+ */
28
+ function discoverUserSkillNames(skillsDirectory = getUserSkillsDirectory()) {
29
+ const root = path_1.default.resolve(skillsDirectory);
30
+ let entries;
31
+ try {
32
+ entries = fs_1.default.readdirSync(root, { withFileTypes: true });
33
+ }
34
+ catch {
35
+ return [];
36
+ }
37
+ const names = [];
38
+ for (const entry of entries) {
39
+ if (!entry.isDirectory() || entry.isSymbolicLink() || !SKILL_NAME.test(entry.name))
40
+ continue;
41
+ const skillFile = path_1.default.join(root, entry.name, 'SKILL.md');
42
+ try {
43
+ const file = fs_1.default.lstatSync(skillFile);
44
+ if (file.isFile() && !file.isSymbolicLink())
45
+ names.push(entry.name);
46
+ }
47
+ catch {
48
+ // Ignore incomplete, inaccessible, or malformed user entries.
49
+ }
50
+ }
51
+ return names.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
52
+ }
@@ -8,8 +8,12 @@ export declare const SLASH_COMMANDS: CommandDefinition[];
8
8
  export declare class Autocompleter {
9
9
  private client?;
10
10
  private skills;
11
+ private remoteSkills;
12
+ private localSkills;
11
13
  private mcpTools;
12
- constructor(client?: AgentClient | undefined);
14
+ private readonly userSkillsDirectory;
15
+ constructor(client?: AgentClient | undefined, userSkillsDirectory?: string);
16
+ private refreshLocalSkills;
13
17
  refreshSuggestions(): Promise<void>;
14
18
  getSkills(): string[];
15
19
  getMcpTools(): string[];
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.Autocompleter = exports.SLASH_COMMANDS = void 0;
7
7
  const chalk_1 = __importDefault(require("chalk"));
8
8
  const colors_1 = require("./colors");
9
+ const user_skill_discovery_1 = require("../skills/user-skill-discovery");
9
10
  exports.SLASH_COMMANDS = [
10
11
  { command: '/create-skill', description: 'Crear una nueva skill interactiva para HELIX' },
11
12
  { command: '/skill', description: 'Listar o inspeccionar skills registradas', usage: '/skill [nombre]' },
@@ -26,9 +27,20 @@ exports.SLASH_COMMANDS = [
26
27
  class Autocompleter {
27
28
  client;
28
29
  skills = [];
30
+ remoteSkills = [];
31
+ localSkills = [];
29
32
  mcpTools = [];
30
- constructor(client) {
33
+ userSkillsDirectory;
34
+ constructor(client, userSkillsDirectory = (0, user_skill_discovery_1.getUserSkillsDirectory)()) {
31
35
  this.client = client;
36
+ this.userSkillsDirectory = userSkillsDirectory;
37
+ this.refreshLocalSkills();
38
+ }
39
+ refreshLocalSkills() {
40
+ this.localSkills = (0, user_skill_discovery_1.discoverUserSkillNames)(this.userSkillsDirectory);
41
+ const baseSkills = this.client ? this.remoteSkills : this.skills;
42
+ this.skills = [...new Set([...baseSkills, ...this.localSkills])]
43
+ .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
32
44
  }
33
45
  async refreshSuggestions() {
34
46
  if (!this.client || !this.client.connected)
@@ -36,7 +48,7 @@ class Autocompleter {
36
48
  try {
37
49
  const skills = await this.client.getSkills();
38
50
  if (skills) {
39
- this.skills = skills.map((s) => s.name);
51
+ this.remoteSkills = skills.map((s) => s.name);
40
52
  }
41
53
  }
42
54
  catch {
@@ -60,6 +72,7 @@ class Autocompleter {
60
72
  catch {
61
73
  // Ignored
62
74
  }
75
+ this.refreshLocalSkills();
63
76
  }
64
77
  getSkills() {
65
78
  return this.skills;
@@ -72,6 +85,9 @@ class Autocompleter {
72
85
  * (line: string) => [string[], string]
73
86
  */
74
87
  completer(line) {
88
+ // A skill may be created by another HELIX surface while the REPL is open.
89
+ // Keep Tab completion current without exposing the skill file contents.
90
+ this.refreshLocalSkills();
75
91
  const trimmed = line.trimStart();
76
92
  // 1. Skill sub-arguments for /skill <name>
77
93
  if (trimmed.startsWith('/skill ')) {
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Formats Markdown text for optimal display in terminal emulators,
3
+ * resolving visual noise from unparsed asterisks (*, **) and headers.
4
+ */
5
+ export declare function formatTerminalMarkdown(content: string, plain?: boolean): string;
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.formatTerminalMarkdown = formatTerminalMarkdown;
7
+ const chalk_1 = __importDefault(require("chalk"));
8
+ /**
9
+ * Formats Markdown text for optimal display in terminal emulators,
10
+ * resolving visual noise from unparsed asterisks (*, **) and headers.
11
+ */
12
+ function formatTerminalMarkdown(content, plain = false) {
13
+ if (!content)
14
+ return '';
15
+ const lines = content.split('\n');
16
+ const formattedLines = lines.map((line) => {
17
+ let formatted = line;
18
+ // Headers (#, ##, ###)
19
+ const headerMatch = formatted.match(/^(#{1,6})\s+(.*)$/);
20
+ if (headerMatch) {
21
+ const title = headerMatch[2].trim();
22
+ if (plain) {
23
+ return title.toUpperCase();
24
+ }
25
+ return chalk_1.default.hex('#FF9F1C').bold(title);
26
+ }
27
+ // Bullet lists (* item, - item)
28
+ if (/^\s*[*+-]\s+/.test(formatted)) {
29
+ formatted = formatted.replace(/^(\s*)[*+-]\s+/, (_m, indent) => {
30
+ return plain ? `${indent}- ` : `${indent}${chalk_1.default.hex('#FF8C00')('•')} `;
31
+ });
32
+ }
33
+ // Inline bold (**text** or __text__)
34
+ formatted = formatted.replace(/(\*\*|__)(.*?)\1/g, (_m, _delim, text) => {
35
+ return plain ? text : chalk_1.default.bold(text);
36
+ });
37
+ // Inline italics (*text* or _text_)
38
+ formatted = formatted.replace(/(?<!\*)\*([^*]+)\*(?!\*)|(?<!_)_([^_]+)_(?!_)/g, (_m, t1, t2) => {
39
+ const text = t1 || t2;
40
+ return plain ? text : chalk_1.default.whiteBright(text);
41
+ });
42
+ // Inline code (`code`)
43
+ formatted = formatted.replace(/`([^`]+)`/g, (_m, code) => {
44
+ return plain ? code : chalk_1.default.cyan(code);
45
+ });
46
+ return formatted;
47
+ });
48
+ return formattedLines.join('\n');
49
+ }
package/dist/ui/repl.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export interface ReplOptions {
2
2
  wsUrl: string;
3
3
  wsToken?: string;
4
+ plain?: boolean;
4
5
  }
5
6
  export declare class HelixRepl {
6
7
  private options;
@@ -15,6 +16,7 @@ export declare class HelixRepl {
15
16
  private activeTaskId;
16
17
  private activeTasksCount;
17
18
  private isProcessingPrompt;
19
+ private plain;
18
20
  constructor(options: ReplOptions);
19
21
  start(): Promise<void>;
20
22
  private _syncSettings;
package/dist/ui/repl.js CHANGED
@@ -16,6 +16,7 @@ const skills_1 = require("../commands/skills");
16
16
  const mcp_1 = require("../commands/mcp");
17
17
  const mic_1 = require("../commands/mic");
18
18
  const browser_1 = require("../commands/browser");
19
+ const markdown_1 = require("./markdown");
19
20
  class HelixRepl {
20
21
  options;
21
22
  rl = null;
@@ -29,9 +30,11 @@ class HelixRepl {
29
30
  activeTaskId = null;
30
31
  activeTasksCount = 0;
31
32
  isProcessingPrompt = false;
33
+ plain = false;
32
34
  constructor(options) {
33
35
  this.options = options;
34
36
  this.conversationId = `cli_conv_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
37
+ this.plain = Boolean(options.plain);
35
38
  this.client = new agent_client_1.AgentClient({
36
39
  wsUrl: options.wsUrl,
37
40
  wsToken: options.wsToken,
@@ -150,8 +153,9 @@ class HelixRepl {
150
153
  // Branded header
151
154
  console.log('');
152
155
  console.log(` ${chalk_1.default.hex('#FF8C00').bold('⬡ HELIX')}${modelBadge}`);
153
- // Content with subtle left border
154
- const contentLines = content.trim().split('\n');
156
+ // Content with subtle left border and clean Markdown formatting
157
+ const formatted = (0, markdown_1.formatTerminalMarkdown)(content, this.plain);
158
+ const contentLines = formatted.trim().split('\n');
155
159
  for (const line of contentLines) {
156
160
  console.log(` ${chalk_1.default.gray('│')} ${line}`);
157
161
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "helix-assistant-cli",
3
- "version": "0.2.4",
3
+ "version": "0.2.7",
4
4
  "description": "HELIX - Interactive Terminal CLI for Windows AI Desktop Agent",
5
5
  "private": false,
6
6
  "publishConfig": {
@@ -30,4 +30,4 @@
30
30
  "@types/ws": "^8.5.10",
31
31
  "typescript": "^5.3.0"
32
32
  }
33
- }
33
+ }
package/src/index.ts CHANGED
@@ -73,14 +73,16 @@ const program = new Command();
73
73
  program
74
74
  .name('helix')
75
75
  .description('HELIX - Interactive Terminal CLI for Windows AI Desktop Agent')
76
- .version('0.1.0')
76
+ .version('0.2.7')
77
77
  .option('-u, --ws-url <url>', 'URL del servidor WebSocket del agente', getWsUrl())
78
78
  .option('-t, --token <token>', 'Token de autenticación WebSocket de HELIX', getWsToken())
79
+ .option('--plain', 'Desactivar formato enriquecido de Markdown y mostrar texto plano sin asteriscos')
79
80
  .action(async (options) => {
80
81
  // Default action: start interactive chat REPL
81
82
  const repl = new HelixRepl({
82
83
  wsUrl: options.wsUrl || getWsUrl(),
83
84
  wsToken: options.token || getWsToken(),
85
+ plain: Boolean(options.plain),
84
86
  });
85
87
  await repl.start();
86
88
  });
@@ -93,6 +95,7 @@ program
93
95
  const repl = new HelixRepl({
94
96
  wsUrl: opts.wsUrl || getWsUrl(),
95
97
  wsToken: opts.token || getWsToken(),
98
+ plain: Boolean(opts.plain),
96
99
  });
97
100
  await repl.start();
98
101
  });
@@ -0,0 +1,45 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { getSettingsFilePath } from '../config/env';
4
+
5
+ const SKILL_NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/;
6
+
7
+ /**
8
+ * Resolves the only user-controlled directory used by the CLI skill catalog.
9
+ * HELIX_SKILLS_DIR is explicit; otherwise it follows the agent's settings path.
10
+ */
11
+ export function getUserSkillsDirectory(): string {
12
+ const configured = process.env.HELIX_SKILLS_DIR?.trim();
13
+ if (configured) return path.resolve(configured);
14
+ return path.join(path.dirname(getSettingsFilePath()), 'skills');
15
+ }
16
+
17
+ /**
18
+ * Returns names only. The file contents are deliberately never read: skill
19
+ * instructions can contain secrets and are not needed for autocomplete.
20
+ * Discovery is shallow and rejects symlinked entries to avoid following paths
21
+ * outside the user's skills directory.
22
+ */
23
+ export function discoverUserSkillNames(skillsDirectory = getUserSkillsDirectory()): string[] {
24
+ const root = path.resolve(skillsDirectory);
25
+ let entries: fs.Dirent[];
26
+ try {
27
+ entries = fs.readdirSync(root, { withFileTypes: true });
28
+ } catch {
29
+ return [];
30
+ }
31
+
32
+ const names: string[] = [];
33
+ for (const entry of entries) {
34
+ if (!entry.isDirectory() || entry.isSymbolicLink() || !SKILL_NAME.test(entry.name)) continue;
35
+
36
+ const skillFile = path.join(root, entry.name, 'SKILL.md');
37
+ try {
38
+ const file = fs.lstatSync(skillFile);
39
+ if (file.isFile() && !file.isSymbolicLink()) names.push(entry.name);
40
+ } catch {
41
+ // Ignore incomplete, inaccessible, or malformed user entries.
42
+ }
43
+ }
44
+ return names.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
45
+ }
@@ -1,6 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import type { AgentClient } from '../client/agent-client';
3
3
  import { formatSkill, formatMcp, ORANGE, CYAN, MAGENTA, DIM } from './colors';
4
+ import { discoverUserSkillNames, getUserSkillsDirectory } from '../skills/user-skill-discovery';
4
5
 
5
6
  export interface CommandDefinition {
6
7
  command: string;
@@ -28,16 +29,29 @@ export const SLASH_COMMANDS: CommandDefinition[] = [
28
29
 
29
30
  export class Autocompleter {
30
31
  private skills: string[] = [];
32
+ private remoteSkills: string[] = [];
33
+ private localSkills: string[] = [];
31
34
  private mcpTools: string[] = [];
35
+ private readonly userSkillsDirectory: string;
32
36
 
33
- constructor(private client?: AgentClient) {}
37
+ constructor(private client?: AgentClient, userSkillsDirectory = getUserSkillsDirectory()) {
38
+ this.userSkillsDirectory = userSkillsDirectory;
39
+ this.refreshLocalSkills();
40
+ }
41
+
42
+ private refreshLocalSkills(): void {
43
+ this.localSkills = discoverUserSkillNames(this.userSkillsDirectory);
44
+ const baseSkills = this.client ? this.remoteSkills : this.skills;
45
+ this.skills = [...new Set([...baseSkills, ...this.localSkills])]
46
+ .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
47
+ }
34
48
 
35
49
  public async refreshSuggestions(): Promise<void> {
36
50
  if (!this.client || !this.client.connected) return;
37
51
  try {
38
52
  const skills = await this.client.getSkills();
39
53
  if (skills) {
40
- this.skills = skills.map((s) => s.name);
54
+ this.remoteSkills = skills.map((s) => s.name);
41
55
  }
42
56
  } catch {
43
57
  // Ignored if client is offline
@@ -60,6 +74,8 @@ export class Autocompleter {
60
74
  } catch {
61
75
  // Ignored
62
76
  }
77
+
78
+ this.refreshLocalSkills();
63
79
  }
64
80
 
65
81
  public getSkills(): string[] {
@@ -75,6 +91,9 @@ export class Autocompleter {
75
91
  * (line: string) => [string[], string]
76
92
  */
77
93
  public completer(line: string): [string[], string] {
94
+ // A skill may be created by another HELIX surface while the REPL is open.
95
+ // Keep Tab completion current without exposing the skill file contents.
96
+ this.refreshLocalSkills();
78
97
  const trimmed = line.trimStart();
79
98
 
80
99
  // 1. Skill sub-arguments for /skill <name>
@@ -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,
@@ -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
  }
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'));
@@ -185,3 +225,23 @@ test('CLI: run with piped UTF-8 Spanish text with BOM is decoded cleanly', async
185
225
  });
186
226
  });
187
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
+ });