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 +4 -1
- 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/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 +6 -2
- package/package.json +2 -2
- package/src/index.ts +4 -1
- package/src/skills/user-skill-discovery.ts +45 -0
- package/src/ui/autocomplete.ts +21 -2
- package/src/ui/markdown.ts +51 -0
- package/src/ui/repl.ts +7 -2
- package/tests/cli.test.ts +60 -0
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.
|
|
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
|
-
|
|
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[];
|
package/dist/ui/autocomplete.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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,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
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/src/ui/autocomplete.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
+
});
|