neuro-cli 4.1.0
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/LICENSE +21 -0
- package/README.md +510 -0
- package/dist/advisor/advisor.d.ts +50 -0
- package/dist/advisor/advisor.js +178 -0
- package/dist/agents/base.d.ts +62 -0
- package/dist/agents/base.js +215 -0
- package/dist/agents/orchestrator.d.ts +46 -0
- package/dist/agents/orchestrator.js +192 -0
- package/dist/agents/team.d.ts +51 -0
- package/dist/agents/team.js +210 -0
- package/dist/api/models.d.ts +23 -0
- package/dist/api/models.js +514 -0
- package/dist/api/ollama.d.ts +153 -0
- package/dist/api/ollama.js +751 -0
- package/dist/api/openrouter.d.ts +55 -0
- package/dist/api/openrouter.js +223 -0
- package/dist/commands/commands.d.ts +43 -0
- package/dist/commands/commands.js +308 -0
- package/dist/config/config.d.ts +8 -0
- package/dist/config/config.js +311 -0
- package/dist/context/compaction.d.ts +54 -0
- package/dist/context/compaction.js +251 -0
- package/dist/context/custom-agents.d.ts +107 -0
- package/dist/context/custom-agents.js +397 -0
- package/dist/context/custom-tools.d.ts +120 -0
- package/dist/context/custom-tools.js +564 -0
- package/dist/context/git-checkpoint.d.ts +52 -0
- package/dist/context/git-checkpoint.js +240 -0
- package/dist/context/neuro-md.d.ts +48 -0
- package/dist/context/neuro-md.js +202 -0
- package/dist/context/neuroignore.d.ts +102 -0
- package/dist/context/neuroignore.js +441 -0
- package/dist/context/repo-map.d.ts +38 -0
- package/dist/context/repo-map.js +220 -0
- package/dist/context/skill-standard.d.ts +262 -0
- package/dist/context/skill-standard.js +1156 -0
- package/dist/context/skill-system.d.ts +75 -0
- package/dist/context/skill-system.js +578 -0
- package/dist/context/tree-sitter.d.ts +99 -0
- package/dist/context/tree-sitter.js +1956 -0
- package/dist/core/acp.d.ts +325 -0
- package/dist/core/acp.js +1498 -0
- package/dist/core/api-server.d.ts +143 -0
- package/dist/core/api-server.js +550 -0
- package/dist/core/approval.d.ts +81 -0
- package/dist/core/approval.js +432 -0
- package/dist/core/auto-compact.d.ts +127 -0
- package/dist/core/auto-compact.js +436 -0
- package/dist/core/auto-mode.d.ts +232 -0
- package/dist/core/auto-mode.js +831 -0
- package/dist/core/background-session.d.ts +166 -0
- package/dist/core/background-session.js +696 -0
- package/dist/core/cicd.d.ts +257 -0
- package/dist/core/cicd.js +1443 -0
- package/dist/core/cloud-sync.d.ts +156 -0
- package/dist/core/cloud-sync.js +582 -0
- package/dist/core/code-review.d.ts +132 -0
- package/dist/core/code-review.js +1191 -0
- package/dist/core/completion.d.ts +49 -0
- package/dist/core/completion.js +384 -0
- package/dist/core/context.d.ts +38 -0
- package/dist/core/context.js +144 -0
- package/dist/core/diff-preview.d.ts +35 -0
- package/dist/core/diff-preview.js +173 -0
- package/dist/core/doom-loop.d.ts +51 -0
- package/dist/core/doom-loop.js +179 -0
- package/dist/core/engine.d.ts +183 -0
- package/dist/core/engine.js +942 -0
- package/dist/core/extended-thinking.d.ts +103 -0
- package/dist/core/extended-thinking.js +269 -0
- package/dist/core/fallback.d.ts +54 -0
- package/dist/core/fallback.js +104 -0
- package/dist/core/git-worktree.d.ts +139 -0
- package/dist/core/git-worktree.js +614 -0
- package/dist/core/headless.d.ts +30 -0
- package/dist/core/headless.js +101 -0
- package/dist/core/i18n.d.ts +83 -0
- package/dist/core/i18n.js +739 -0
- package/dist/core/linting.d.ts +129 -0
- package/dist/core/linting.js +699 -0
- package/dist/core/model-router.d.ts +109 -0
- package/dist/core/model-router.js +581 -0
- package/dist/core/multi-model.d.ts +243 -0
- package/dist/core/multi-model.js +1099 -0
- package/dist/core/multi-session.d.ts +144 -0
- package/dist/core/multi-session.js +442 -0
- package/dist/core/multimodal.d.ts +125 -0
- package/dist/core/multimodal.js +286 -0
- package/dist/core/observability.d.ts +93 -0
- package/dist/core/observability.js +737 -0
- package/dist/core/os-sandbox.d.ts +122 -0
- package/dist/core/os-sandbox.js +1193 -0
- package/dist/core/outcome-grading.d.ts +228 -0
- package/dist/core/outcome-grading.js +1123 -0
- package/dist/core/output-styles.d.ts +57 -0
- package/dist/core/output-styles.js +382 -0
- package/dist/core/parallel-agents.d.ts +183 -0
- package/dist/core/parallel-agents.js +563 -0
- package/dist/core/plugin-bundle.d.ts +236 -0
- package/dist/core/plugin-bundle.js +887 -0
- package/dist/core/plugin-sdk.d.ts +139 -0
- package/dist/core/plugin-sdk.js +273 -0
- package/dist/core/prompt-cache.d.ts +163 -0
- package/dist/core/prompt-cache.js +599 -0
- package/dist/core/sandbox.d.ts +127 -0
- package/dist/core/sandbox.js +369 -0
- package/dist/core/scheduled-tasks.d.ts +151 -0
- package/dist/core/scheduled-tasks.js +502 -0
- package/dist/core/security-scanner.d.ts +160 -0
- package/dist/core/security-scanner.js +1494 -0
- package/dist/core/session.d.ts +83 -0
- package/dist/core/session.js +269 -0
- package/dist/core/shell-completion.d.ts +51 -0
- package/dist/core/shell-completion.js +674 -0
- package/dist/core/smart-monitor.d.ts +146 -0
- package/dist/core/smart-monitor.js +1199 -0
- package/dist/core/spec-driven.d.ts +233 -0
- package/dist/core/spec-driven.js +1485 -0
- package/dist/core/spending-warnings.d.ts +123 -0
- package/dist/core/spending-warnings.js +456 -0
- package/dist/core/sub-agent.d.ts +298 -0
- package/dist/core/sub-agent.js +1023 -0
- package/dist/core/telemetry.d.ts +157 -0
- package/dist/core/telemetry.js +412 -0
- package/dist/core/terminal-ux.d.ts +134 -0
- package/dist/core/terminal-ux.js +649 -0
- package/dist/core/testing.d.ts +146 -0
- package/dist/core/testing.js +1200 -0
- package/dist/core/types.d.ts +439 -0
- package/dist/core/types.js +6 -0
- package/dist/core/undo-redo.d.ts +112 -0
- package/dist/core/undo-redo.js +290 -0
- package/dist/core/updater.d.ts +159 -0
- package/dist/core/updater.js +608 -0
- package/dist/core/vim-mode.d.ts +151 -0
- package/dist/core/vim-mode.js +771 -0
- package/dist/core/voice.d.ts +137 -0
- package/dist/core/voice.js +538 -0
- package/dist/core/web-dashboard.d.ts +109 -0
- package/dist/core/web-dashboard.js +484 -0
- package/dist/hooks/hooks.d.ts +74 -0
- package/dist/hooks/hooks.js +160 -0
- package/dist/hooks/llm-evaluator.d.ts +165 -0
- package/dist/hooks/llm-evaluator.js +560 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1186 -0
- package/dist/lsp/lsp-manager.d.ts +63 -0
- package/dist/lsp/lsp-manager.js +351 -0
- package/dist/mcp/client.d.ts +133 -0
- package/dist/mcp/client.js +684 -0
- package/dist/mcp/mcp-apps.d.ts +70 -0
- package/dist/mcp/mcp-apps.js +1007 -0
- package/dist/tools/bash.d.ts +5 -0
- package/dist/tools/bash.js +195 -0
- package/dist/tools/browser.d.ts +92 -0
- package/dist/tools/browser.js +1570 -0
- package/dist/tools/extended.d.ts +6 -0
- package/dist/tools/extended.js +191 -0
- package/dist/tools/file.d.ts +10 -0
- package/dist/tools/file.js +382 -0
- package/dist/tools/github.d.ts +389 -0
- package/dist/tools/github.js +759 -0
- package/dist/tools/index.d.ts +9 -0
- package/dist/tools/index.js +40 -0
- package/dist/tools/memory.d.ts +6 -0
- package/dist/tools/memory.js +197 -0
- package/dist/tools/registry.d.ts +29 -0
- package/dist/tools/registry.js +64 -0
- package/dist/tools/web.d.ts +6 -0
- package/dist/tools/web.js +150 -0
- package/dist/ui/renderer.d.ts +97 -0
- package/dist/ui/renderer.js +279 -0
- package/dist/ui/theme.d.ts +27 -0
- package/dist/ui/theme.js +106 -0
- package/package.json +83 -0
|
@@ -0,0 +1,1156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NeuroCLI — SKILL.md Standard Compliance System
|
|
3
|
+
*
|
|
4
|
+
* Implements the agentskills.io specification for SKILL.md files.
|
|
5
|
+
* Provides auto-discovery, parsing, activation, registry, and
|
|
6
|
+
* cross-tool compatibility with Claude Code, Codex CLI, and GitHub Copilot.
|
|
7
|
+
*
|
|
8
|
+
* Uses only Node.js built-in modules (fs, path, os, crypto, https, http, url).
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from "fs";
|
|
11
|
+
import * as path from "path";
|
|
12
|
+
import * as os from "os";
|
|
13
|
+
import * as crypto from "crypto";
|
|
14
|
+
import * as https from "https";
|
|
15
|
+
import * as http from "http";
|
|
16
|
+
import { URL } from "url";
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Constants
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
/** File name for skill definitions */
|
|
21
|
+
const SKILL_FILENAME = "SKILL.md";
|
|
22
|
+
/** Maximum allowed length for skill names */
|
|
23
|
+
const MAX_NAME_LENGTH = 64;
|
|
24
|
+
/** Regex for valid skill names: lowercase, digits, hyphens; must start with letter */
|
|
25
|
+
const VALID_NAME_REGEX = /^[a-z][a-z0-9-]{0,63}$/;
|
|
26
|
+
/** Default global skills directory relative to home */
|
|
27
|
+
const DEFAULT_GLOBAL_SKILLS_DIR = ".neuro/skills";
|
|
28
|
+
/** Default project-level skills directory */
|
|
29
|
+
const DEFAULT_PROJECT_SKILLS_DIR = ".neuro/skills";
|
|
30
|
+
/** Maximum number of nested directories to traverse during discovery */
|
|
31
|
+
const MAX_DISCOVERY_DEPTH = 5;
|
|
32
|
+
/** Cache TTL in milliseconds (5 minutes) */
|
|
33
|
+
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
34
|
+
/** Neuro skill registry base URL */
|
|
35
|
+
const REGISTRY_BASE_URL = "https://registry.agentskills.io";
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// YAML Frontmatter Parser
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
/**
|
|
40
|
+
* Minimal YAML frontmatter parser that handles the subset of YAML used in
|
|
41
|
+
* SKILL.md files. Supports scalars (strings, numbers, booleans) and
|
|
42
|
+
* inline arrays (["a", "b"]). Does NOT depend on any external library.
|
|
43
|
+
*/
|
|
44
|
+
function parseFrontmatter(raw) {
|
|
45
|
+
const trimmed = raw.trimStart();
|
|
46
|
+
if (!trimmed.startsWith("---")) {
|
|
47
|
+
return { metadata: {}, body: raw };
|
|
48
|
+
}
|
|
49
|
+
// Find the closing ---
|
|
50
|
+
const firstDelimiterEnd = trimmed.indexOf("\n", 0);
|
|
51
|
+
if (firstDelimiterEnd === -1) {
|
|
52
|
+
return { metadata: {}, body: raw };
|
|
53
|
+
}
|
|
54
|
+
const secondDelimiter = trimmed.indexOf("\n---", firstDelimiterEnd);
|
|
55
|
+
if (secondDelimiter === -1) {
|
|
56
|
+
return { metadata: {}, body: raw };
|
|
57
|
+
}
|
|
58
|
+
const frontmatterText = trimmed.slice(firstDelimiterEnd + 1, secondDelimiter);
|
|
59
|
+
const body = trimmed.slice(secondDelimiter + 4).trimStart();
|
|
60
|
+
const metadata = {};
|
|
61
|
+
// Parse line by line — handles top-level keys only (spec compliant)
|
|
62
|
+
const lines = frontmatterText.split("\n");
|
|
63
|
+
for (const line of lines) {
|
|
64
|
+
const colonIdx = line.indexOf(":");
|
|
65
|
+
if (colonIdx === -1)
|
|
66
|
+
continue;
|
|
67
|
+
const key = line.slice(0, colonIdx).trim();
|
|
68
|
+
let value = line.slice(colonIdx + 1).trim();
|
|
69
|
+
if (value === "") {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
// Inline array: ["a", "b", "c"]
|
|
73
|
+
if (typeof value === "string" && value.startsWith("[") && value.endsWith("]")) {
|
|
74
|
+
value = parseInlineArray(value);
|
|
75
|
+
}
|
|
76
|
+
// Quoted string
|
|
77
|
+
else if (typeof value === "string") {
|
|
78
|
+
value = unquote(value);
|
|
79
|
+
}
|
|
80
|
+
// Numeric
|
|
81
|
+
else if (typeof value === "string" && /^\d+(\.\d+)?$/.test(value)) {
|
|
82
|
+
value = Number(value);
|
|
83
|
+
}
|
|
84
|
+
// Boolean
|
|
85
|
+
else if (value === "true") {
|
|
86
|
+
value = true;
|
|
87
|
+
}
|
|
88
|
+
else if (value === "false") {
|
|
89
|
+
value = false;
|
|
90
|
+
}
|
|
91
|
+
metadata[key] = value;
|
|
92
|
+
}
|
|
93
|
+
return { metadata, body };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Parse a YAML inline array like `["react", "nextjs", "frontend"]`.
|
|
97
|
+
*/
|
|
98
|
+
function parseInlineArray(raw) {
|
|
99
|
+
const inner = raw.slice(1, -1).trim();
|
|
100
|
+
if (inner === "")
|
|
101
|
+
return [];
|
|
102
|
+
const items = [];
|
|
103
|
+
let current = "";
|
|
104
|
+
let inQuote = null;
|
|
105
|
+
for (let i = 0; i < inner.length; i++) {
|
|
106
|
+
const ch = inner[i];
|
|
107
|
+
if (inQuote) {
|
|
108
|
+
if (ch === inQuote) {
|
|
109
|
+
inQuote = null;
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
current += ch;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
else if (ch === '"' || ch === "'") {
|
|
116
|
+
inQuote = ch;
|
|
117
|
+
}
|
|
118
|
+
else if (ch === ",") {
|
|
119
|
+
items.push(current.trim());
|
|
120
|
+
current = "";
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
current += ch;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (current.trim()) {
|
|
127
|
+
items.push(current.trim());
|
|
128
|
+
}
|
|
129
|
+
return items.map((s) => unquote(s));
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Remove surrounding quotes from a string value.
|
|
133
|
+
*/
|
|
134
|
+
function unquote(value) {
|
|
135
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
136
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
137
|
+
return value.slice(1, -1);
|
|
138
|
+
}
|
|
139
|
+
return value;
|
|
140
|
+
}
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Cross-Tool Compatibility Adapters
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
/**
|
|
145
|
+
* Adapter for Claude Code skill format.
|
|
146
|
+
* Claude Code uses a CLAUDE.md convention with similar frontmatter.
|
|
147
|
+
*/
|
|
148
|
+
function adaptFromClaudeCode(raw, filePath) {
|
|
149
|
+
const warnings = [];
|
|
150
|
+
const errors = [];
|
|
151
|
+
// Claude Code files may not have structured frontmatter; attempt extraction
|
|
152
|
+
const { metadata, body } = parseFrontmatter(raw);
|
|
153
|
+
const name = metadata.name ||
|
|
154
|
+
path.basename(path.dirname(filePath)).toLowerCase().replace(/\s+/g, "-");
|
|
155
|
+
if (!VALID_NAME_REGEX.test(name)) {
|
|
156
|
+
errors.push(`Invalid skill name "${name}". Must match ${VALID_NAME_REGEX.source}`);
|
|
157
|
+
}
|
|
158
|
+
const description = metadata.description ||
|
|
159
|
+
metadata.trigger ||
|
|
160
|
+
body.split("\n").find((l) => l.trim().length > 0) ||
|
|
161
|
+
"";
|
|
162
|
+
return {
|
|
163
|
+
skill: {
|
|
164
|
+
name,
|
|
165
|
+
description,
|
|
166
|
+
version: metadata.version || undefined,
|
|
167
|
+
author: metadata.author || undefined,
|
|
168
|
+
tags: Array.isArray(metadata.tags) ? metadata.tags : undefined,
|
|
169
|
+
tools: Array.isArray(metadata.tools) ? metadata.tools : undefined,
|
|
170
|
+
priority: typeof metadata.priority === "number" ? metadata.priority : 50,
|
|
171
|
+
invoke: metadata.invoke || "auto",
|
|
172
|
+
load: metadata.load || "on-demand",
|
|
173
|
+
body,
|
|
174
|
+
filePath,
|
|
175
|
+
source: "project",
|
|
176
|
+
},
|
|
177
|
+
warnings,
|
|
178
|
+
errors,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Adapter for Codex CLI skill format.
|
|
183
|
+
* Codex CLI uses codex.md / AGENTS.md files.
|
|
184
|
+
*/
|
|
185
|
+
function adaptFromCodexCli(raw, filePath) {
|
|
186
|
+
const { metadata, body } = parseFrontmatter(raw);
|
|
187
|
+
const warnings = [];
|
|
188
|
+
const errors = [];
|
|
189
|
+
const name = metadata.name ||
|
|
190
|
+
path.basename(path.dirname(filePath)).toLowerCase().replace(/\s+/g, "-");
|
|
191
|
+
if (!VALID_NAME_REGEX.test(name)) {
|
|
192
|
+
errors.push(`Invalid skill name "${name}". Must match ${VALID_NAME_REGEX.source}`);
|
|
193
|
+
}
|
|
194
|
+
// Codex CLI uses "triggers" (plural) or "when" as activation condition
|
|
195
|
+
const description = metadata.description ||
|
|
196
|
+
metadata.triggers ||
|
|
197
|
+
metadata.when ||
|
|
198
|
+
"";
|
|
199
|
+
return {
|
|
200
|
+
skill: {
|
|
201
|
+
name,
|
|
202
|
+
description,
|
|
203
|
+
version: metadata.version || undefined,
|
|
204
|
+
author: metadata.author || undefined,
|
|
205
|
+
tags: Array.isArray(metadata.tags) ? metadata.tags : undefined,
|
|
206
|
+
tools: Array.isArray(metadata.tools) ? metadata.tools : undefined,
|
|
207
|
+
priority: typeof metadata.priority === "number" ? metadata.priority : 50,
|
|
208
|
+
invoke: metadata.invoke || "auto",
|
|
209
|
+
load: metadata.load || "on-demand",
|
|
210
|
+
body,
|
|
211
|
+
filePath,
|
|
212
|
+
source: "project",
|
|
213
|
+
},
|
|
214
|
+
warnings,
|
|
215
|
+
errors,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Adapter for GitHub Copilot skill format.
|
|
220
|
+
* Copilot uses .github/copilot-skills/ with .md files.
|
|
221
|
+
*/
|
|
222
|
+
function adaptFromCopilot(raw, filePath) {
|
|
223
|
+
const { metadata, body } = parseFrontmatter(raw);
|
|
224
|
+
const warnings = [];
|
|
225
|
+
const errors = [];
|
|
226
|
+
const name = metadata.name ||
|
|
227
|
+
path.basename(filePath, ".md").toLowerCase().replace(/\s+/g, "-");
|
|
228
|
+
if (!VALID_NAME_REGEX.test(name)) {
|
|
229
|
+
errors.push(`Invalid skill name "${name}". Must match ${VALID_NAME_REGEX.source}`);
|
|
230
|
+
}
|
|
231
|
+
// Copilot uses "appliesTo" or "whenToSuggest" as activation condition
|
|
232
|
+
const description = metadata.description ||
|
|
233
|
+
metadata.appliesTo ||
|
|
234
|
+
metadata.whenToSuggest ||
|
|
235
|
+
"";
|
|
236
|
+
return {
|
|
237
|
+
skill: {
|
|
238
|
+
name,
|
|
239
|
+
description,
|
|
240
|
+
version: metadata.version || undefined,
|
|
241
|
+
author: metadata.author || undefined,
|
|
242
|
+
tags: Array.isArray(metadata.tags) ? metadata.tags : undefined,
|
|
243
|
+
tools: Array.isArray(metadata.tools) ? metadata.tools : undefined,
|
|
244
|
+
priority: typeof metadata.priority === "number" ? metadata.priority : 50,
|
|
245
|
+
invoke: metadata.invoke || "auto",
|
|
246
|
+
load: metadata.load || "on-demand",
|
|
247
|
+
body,
|
|
248
|
+
filePath,
|
|
249
|
+
source: "project",
|
|
250
|
+
},
|
|
251
|
+
warnings,
|
|
252
|
+
errors,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// Utility helpers
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
/**
|
|
259
|
+
* Walk a directory tree up to `maxDepth` levels, returning all files named
|
|
260
|
+
* `SKILL_FILENAME` or recognized cross-tool skill files.
|
|
261
|
+
*/
|
|
262
|
+
function walkForSkills(dir, maxDepth, currentDepth = 0) {
|
|
263
|
+
const results = [];
|
|
264
|
+
if (currentDepth > maxDepth)
|
|
265
|
+
return results;
|
|
266
|
+
if (!fs.existsSync(dir))
|
|
267
|
+
return results;
|
|
268
|
+
let entries;
|
|
269
|
+
try {
|
|
270
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return results;
|
|
274
|
+
}
|
|
275
|
+
for (const entry of entries) {
|
|
276
|
+
const fullPath = path.join(dir, entry.name);
|
|
277
|
+
if (entry.isFile()) {
|
|
278
|
+
// Standard SKILL.md
|
|
279
|
+
if (entry.name === SKILL_FILENAME) {
|
|
280
|
+
results.push(fullPath);
|
|
281
|
+
}
|
|
282
|
+
// Cross-tool: Claude Code
|
|
283
|
+
else if (entry.name === "CLAUDE.md") {
|
|
284
|
+
results.push(fullPath);
|
|
285
|
+
}
|
|
286
|
+
// Cross-tool: Codex CLI
|
|
287
|
+
else if (entry.name === "codex.md" || entry.name === "AGENTS.md") {
|
|
288
|
+
results.push(fullPath);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
else if (entry.isDirectory()) {
|
|
292
|
+
// Recurse into subdirectories (e.g. nested skill folders)
|
|
293
|
+
if (!entry.name.startsWith(".") && entry.name !== "node_modules") {
|
|
294
|
+
results.push(...walkForSkills(fullPath, maxDepth, currentDepth + 1));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return results;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Determine the source type based on file path.
|
|
302
|
+
*/
|
|
303
|
+
function determineSource(filePath, globalDir, projectDir) {
|
|
304
|
+
const normalized = path.resolve(filePath);
|
|
305
|
+
if (normalized.startsWith(path.resolve(globalDir))) {
|
|
306
|
+
return "global";
|
|
307
|
+
}
|
|
308
|
+
return "project";
|
|
309
|
+
}
|
|
310
|
+
function detectFormat(filePath) {
|
|
311
|
+
const base = path.basename(filePath);
|
|
312
|
+
switch (base) {
|
|
313
|
+
case "CLAUDE.md":
|
|
314
|
+
return "claude";
|
|
315
|
+
case "codex.md":
|
|
316
|
+
case "AGENTS.md":
|
|
317
|
+
return "codex";
|
|
318
|
+
default:
|
|
319
|
+
// Check for GitHub Copilot path pattern
|
|
320
|
+
if (filePath.includes(path.join(".github", "copilot-skills"))) {
|
|
321
|
+
return "copilot";
|
|
322
|
+
}
|
|
323
|
+
return "neuro";
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Compute SHA-256 checksum of a string.
|
|
328
|
+
*/
|
|
329
|
+
function checksum(content) {
|
|
330
|
+
return crypto.createHash("sha256").update(content, "utf8").digest("hex");
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Ensure a directory exists, creating it recursively if needed.
|
|
334
|
+
*/
|
|
335
|
+
function ensureDir(dir) {
|
|
336
|
+
if (!fs.existsSync(dir)) {
|
|
337
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* HTTP/HTTPS GET request that returns the body as a string.
|
|
342
|
+
* Works with both http and https protocols using only built-in modules.
|
|
343
|
+
*/
|
|
344
|
+
function httpGet(urlStr) {
|
|
345
|
+
return new Promise((resolve, reject) => {
|
|
346
|
+
let client;
|
|
347
|
+
try {
|
|
348
|
+
const parsed = new URL(urlStr);
|
|
349
|
+
client = parsed.protocol === "https:" ? https : http;
|
|
350
|
+
}
|
|
351
|
+
catch (err) {
|
|
352
|
+
reject(new Error(`Invalid URL: ${urlStr}`));
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
client
|
|
356
|
+
.get(urlStr, { timeout: 15000 }, (res) => {
|
|
357
|
+
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
358
|
+
// Follow redirect
|
|
359
|
+
httpGet(res.headers.location).then(resolve).catch(reject);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
|
|
363
|
+
reject(new Error(`HTTP ${res.statusCode} for ${urlStr}`));
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const chunks = [];
|
|
367
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
368
|
+
res.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
369
|
+
res.on("error", reject);
|
|
370
|
+
})
|
|
371
|
+
.on("error", reject)
|
|
372
|
+
.on("timeout", function () {
|
|
373
|
+
this.destroy();
|
|
374
|
+
reject(new Error(`Request timeout for ${urlStr}`));
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* HTTP POST request that sends a JSON body and returns the response.
|
|
380
|
+
*/
|
|
381
|
+
function httpPost(urlStr, body) {
|
|
382
|
+
return new Promise((resolve, reject) => {
|
|
383
|
+
let client;
|
|
384
|
+
try {
|
|
385
|
+
const parsed = new URL(urlStr);
|
|
386
|
+
client = parsed.protocol === "https:" ? https : http;
|
|
387
|
+
}
|
|
388
|
+
catch (err) {
|
|
389
|
+
reject(new Error(`Invalid URL: ${urlStr}`));
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const payload = JSON.stringify(body);
|
|
393
|
+
const options = {
|
|
394
|
+
method: "POST",
|
|
395
|
+
headers: {
|
|
396
|
+
"Content-Type": "application/json",
|
|
397
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
398
|
+
},
|
|
399
|
+
timeout: 15000,
|
|
400
|
+
};
|
|
401
|
+
const req = client.request(urlStr, options, (res) => {
|
|
402
|
+
if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
|
|
403
|
+
const chunks = [];
|
|
404
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
405
|
+
res.on("end", () => {
|
|
406
|
+
reject(new Error(`HTTP ${res.statusCode}: ${Buffer.concat(chunks).toString("utf8")}`));
|
|
407
|
+
});
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const chunks = [];
|
|
411
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
412
|
+
res.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
413
|
+
res.on("error", reject);
|
|
414
|
+
});
|
|
415
|
+
req.on("error", reject);
|
|
416
|
+
req.on("timeout", () => {
|
|
417
|
+
req.destroy();
|
|
418
|
+
reject(new Error(`Request timeout for ${urlStr}`));
|
|
419
|
+
});
|
|
420
|
+
req.write(payload);
|
|
421
|
+
req.end();
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
// SkillStandard Class
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
/**
|
|
428
|
+
* Core class implementing the agentskills.io SKILL.md standard compliance
|
|
429
|
+
* system for NeuroCLI.
|
|
430
|
+
*
|
|
431
|
+
* Handles discovery, parsing, activation, installation, search, publishing,
|
|
432
|
+
* and cross-tool compatibility.
|
|
433
|
+
*/
|
|
434
|
+
export class SkillStandard {
|
|
435
|
+
globalSkillsDir;
|
|
436
|
+
projectSkillsDir;
|
|
437
|
+
enableCache;
|
|
438
|
+
cacheTtlMs;
|
|
439
|
+
registryBaseUrl;
|
|
440
|
+
/** In-memory skill definitions, keyed by name */
|
|
441
|
+
skills = new Map();
|
|
442
|
+
/** Compact listing cache for agent activation */
|
|
443
|
+
compactListing = [];
|
|
444
|
+
/** Internal cache */
|
|
445
|
+
cache = null;
|
|
446
|
+
/** Local registry index (file-backed) */
|
|
447
|
+
registryIndex = new Map();
|
|
448
|
+
/** Path to the local registry file */
|
|
449
|
+
registryFilePath;
|
|
450
|
+
/** Whether discovery has been run at least once */
|
|
451
|
+
discovered = false;
|
|
452
|
+
constructor(options = {}) {
|
|
453
|
+
this.globalSkillsDir =
|
|
454
|
+
options.globalSkillsDir ||
|
|
455
|
+
path.join(os.homedir(), DEFAULT_GLOBAL_SKILLS_DIR);
|
|
456
|
+
this.projectSkillsDir =
|
|
457
|
+
options.projectSkillsDir || DEFAULT_PROJECT_SKILLS_DIR;
|
|
458
|
+
this.enableCache = options.enableCache ?? true;
|
|
459
|
+
this.cacheTtlMs = options.cacheTtlMs ?? CACHE_TTL_MS;
|
|
460
|
+
this.registryBaseUrl = options.registryBaseUrl || REGISTRY_BASE_URL;
|
|
461
|
+
this.registryFilePath = path.join(this.globalSkillsDir, "..", "registry.json");
|
|
462
|
+
}
|
|
463
|
+
// -----------------------------------------------------------------------
|
|
464
|
+
// Public API
|
|
465
|
+
// -----------------------------------------------------------------------
|
|
466
|
+
/**
|
|
467
|
+
* Discover all skills from both global and project directories.
|
|
468
|
+
* Reads ONLY the `name` and `description` from each skill's YAML
|
|
469
|
+
* frontmatter for the compact listing, but also caches full definitions.
|
|
470
|
+
*
|
|
471
|
+
* @param projectRoot - The root directory of the current project
|
|
472
|
+
* @returns Array of compact skill entries for agent activation
|
|
473
|
+
*/
|
|
474
|
+
discoverSkills(projectRoot) {
|
|
475
|
+
// Return cached if still valid
|
|
476
|
+
if (this.enableCache && this.cache && Date.now() - this.cache.timestamp < this.cacheTtlMs) {
|
|
477
|
+
return this.cache.compactListing;
|
|
478
|
+
}
|
|
479
|
+
this.skills.clear();
|
|
480
|
+
this.compactListing = [];
|
|
481
|
+
const globalDir = this.globalSkillsDir;
|
|
482
|
+
const localDir = path.resolve(projectRoot, this.projectSkillsDir);
|
|
483
|
+
// Discover from global directory
|
|
484
|
+
const globalFiles = walkForSkills(globalDir, MAX_DISCOVERY_DEPTH);
|
|
485
|
+
for (const file of globalFiles) {
|
|
486
|
+
const result = this.parseSkillMd(file);
|
|
487
|
+
if (result.skill && result.errors.length === 0) {
|
|
488
|
+
result.skill.source = "global";
|
|
489
|
+
this.skills.set(result.skill.name, result.skill);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
// Discover from project directory
|
|
493
|
+
const projectFiles = walkForSkills(localDir, MAX_DISCOVERY_DEPTH);
|
|
494
|
+
for (const file of projectFiles) {
|
|
495
|
+
const result = this.parseSkillMd(file);
|
|
496
|
+
if (result.skill && result.errors.length === 0) {
|
|
497
|
+
// Project skills override global skills with the same name
|
|
498
|
+
result.skill.source = "project";
|
|
499
|
+
this.skills.set(result.skill.name, result.skill);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
// Also check for GitHub Copilot skill files in .github/copilot-skills/
|
|
503
|
+
const copilotDir = path.resolve(projectRoot, ".github/copilot-skills");
|
|
504
|
+
const copilotFiles = walkForSkills(copilotDir, MAX_DISCOVERY_DEPTH);
|
|
505
|
+
for (const file of copilotFiles) {
|
|
506
|
+
const result = this.parseSkillMd(file);
|
|
507
|
+
if (result.skill && result.errors.length === 0) {
|
|
508
|
+
result.skill.source = "project";
|
|
509
|
+
this.skills.set(result.skill.name, result.skill);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
// Build compact listing — sorted by priority descending, then name ascending
|
|
513
|
+
this.compactListing = Array.from(this.skills.values())
|
|
514
|
+
.map((s) => ({
|
|
515
|
+
name: s.name,
|
|
516
|
+
description: s.description,
|
|
517
|
+
priority: s.priority ?? 50,
|
|
518
|
+
invoke: s.invoke ?? "auto",
|
|
519
|
+
load: s.load ?? "on-demand",
|
|
520
|
+
}))
|
|
521
|
+
.sort((a, b) => {
|
|
522
|
+
if (b.priority !== a.priority)
|
|
523
|
+
return b.priority - a.priority;
|
|
524
|
+
return a.name.localeCompare(b.name);
|
|
525
|
+
});
|
|
526
|
+
// Update cache
|
|
527
|
+
if (this.enableCache) {
|
|
528
|
+
this.cache = {
|
|
529
|
+
compactListing: this.compactListing,
|
|
530
|
+
skills: new Map(this.skills),
|
|
531
|
+
timestamp: Date.now(),
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
this.discovered = true;
|
|
535
|
+
return this.compactListing;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Parse a SKILL.md file according to the agentskills.io specification.
|
|
539
|
+
* Supports cross-tool format detection and automatic adaptation.
|
|
540
|
+
*
|
|
541
|
+
* @param filePath - Absolute path to the SKILL.md (or compatible) file
|
|
542
|
+
* @returns Parse result with skill definition, warnings, and errors
|
|
543
|
+
*/
|
|
544
|
+
parseSkillMd(filePath) {
|
|
545
|
+
const warnings = [];
|
|
546
|
+
const errors = [];
|
|
547
|
+
// Read file
|
|
548
|
+
let raw;
|
|
549
|
+
try {
|
|
550
|
+
raw = fs.readFileSync(filePath, "utf8");
|
|
551
|
+
}
|
|
552
|
+
catch (err) {
|
|
553
|
+
return {
|
|
554
|
+
skill: null,
|
|
555
|
+
warnings,
|
|
556
|
+
errors: [
|
|
557
|
+
`Failed to read skill file at ${filePath}: ${err.message}`,
|
|
558
|
+
],
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
// Detect format and adapt
|
|
562
|
+
const format = detectFormat(filePath);
|
|
563
|
+
let result;
|
|
564
|
+
switch (format) {
|
|
565
|
+
case "claude":
|
|
566
|
+
result = adaptFromClaudeCode(raw, filePath);
|
|
567
|
+
warnings.push(`Adapted from Claude Code format (CLAUDE.md). Some fields may need review.`);
|
|
568
|
+
break;
|
|
569
|
+
case "codex":
|
|
570
|
+
result = adaptFromCodexCli(raw, filePath);
|
|
571
|
+
warnings.push(`Adapted from Codex CLI format (${path.basename(filePath)}). Some fields may need review.`);
|
|
572
|
+
break;
|
|
573
|
+
case "copilot":
|
|
574
|
+
result = adaptFromCopilot(raw, filePath);
|
|
575
|
+
warnings.push(`Adapted from GitHub Copilot format. Some fields may need review.`);
|
|
576
|
+
break;
|
|
577
|
+
case "neuro":
|
|
578
|
+
default:
|
|
579
|
+
result = this.parseNeuroFormat(raw, filePath);
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
// Merge warnings
|
|
583
|
+
warnings.push(...result.warnings);
|
|
584
|
+
// Validate required fields
|
|
585
|
+
if (result.skill) {
|
|
586
|
+
// Validate name
|
|
587
|
+
if (!result.skill.name) {
|
|
588
|
+
errors.push("Required field 'name' is missing.");
|
|
589
|
+
result.skill = null;
|
|
590
|
+
}
|
|
591
|
+
else if (!VALID_NAME_REGEX.test(result.skill.name)) {
|
|
592
|
+
errors.push(`Invalid skill name "${result.skill.name}". Must be lowercase, start with a letter, contain only letters, digits, and hyphens, and be at most ${MAX_NAME_LENGTH} characters. Pattern: ${VALID_NAME_REGEX.source}`);
|
|
593
|
+
result.skill = null;
|
|
594
|
+
}
|
|
595
|
+
// Validate description
|
|
596
|
+
if (result.skill && !result.skill.description) {
|
|
597
|
+
errors.push("Required field 'description' is missing.");
|
|
598
|
+
result.skill = null;
|
|
599
|
+
}
|
|
600
|
+
// Validate version format if present
|
|
601
|
+
if (result.skill && result.skill.version) {
|
|
602
|
+
if (!/^\d+\.\d+\.\d+/.test(result.skill.version)) {
|
|
603
|
+
warnings.push(`Version "${result.skill.version}" does not follow semver (major.minor.patch).`);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
// Validate priority range
|
|
607
|
+
if (result.skill && result.skill.priority !== undefined) {
|
|
608
|
+
if (result.skill.priority < 0 || result.skill.priority > 100) {
|
|
609
|
+
warnings.push(`Priority ${result.skill.priority} is outside the recommended 0-100 range. Clamping.`);
|
|
610
|
+
result.skill.priority = Math.max(0, Math.min(100, result.skill.priority));
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
// Set defaults
|
|
614
|
+
if (result.skill) {
|
|
615
|
+
result.skill.priority = result.skill.priority ?? 50;
|
|
616
|
+
result.skill.invoke = result.skill.invoke ?? "auto";
|
|
617
|
+
result.skill.load = result.skill.load ?? "on-demand";
|
|
618
|
+
result.skill.source = determineSource(filePath, this.globalSkillsDir, this.projectSkillsDir);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return {
|
|
622
|
+
skill: result.skill,
|
|
623
|
+
warnings,
|
|
624
|
+
errors: [...errors, ...result.errors],
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Get the compact listing of name+description pairs for agent activation.
|
|
629
|
+
* This is the minimal information the agent sees to decide which skill
|
|
630
|
+
* to activate. Must call discoverSkills() first.
|
|
631
|
+
*
|
|
632
|
+
* @returns Array of compact skill entries
|
|
633
|
+
*/
|
|
634
|
+
getCompactListing() {
|
|
635
|
+
if (!this.discovered) {
|
|
636
|
+
return [];
|
|
637
|
+
}
|
|
638
|
+
return this.compactListing;
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Activate a skill if its description matches the current context or prompt.
|
|
642
|
+
* For "auto" invoke skills, the description is used as a trigger condition.
|
|
643
|
+
* For "explicit" invoke skills, the skill name must be directly referenced.
|
|
644
|
+
*
|
|
645
|
+
* @param name - The skill name to activate
|
|
646
|
+
* @param prompt - The current user prompt or context for matching
|
|
647
|
+
* @returns The full skill definition if activated, or null
|
|
648
|
+
*/
|
|
649
|
+
activateSkill(name, prompt) {
|
|
650
|
+
const skill = this.skills.get(name);
|
|
651
|
+
if (!skill)
|
|
652
|
+
return null;
|
|
653
|
+
// For explicit invoke, only activate if the skill name is directly
|
|
654
|
+
// referenced in the prompt
|
|
655
|
+
if (skill.invoke === "explicit") {
|
|
656
|
+
const namePatterns = [
|
|
657
|
+
name,
|
|
658
|
+
name.replace(/-/g, " "),
|
|
659
|
+
name.replace(/-/g, "_"),
|
|
660
|
+
];
|
|
661
|
+
const promptLower = prompt.toLowerCase();
|
|
662
|
+
const isReferenced = namePatterns.some((p) => promptLower.includes(p.toLowerCase()));
|
|
663
|
+
if (!isReferenced)
|
|
664
|
+
return null;
|
|
665
|
+
}
|
|
666
|
+
// For auto invoke, check if the description matches the prompt context.
|
|
667
|
+
// Use a simple keyword-overlap heuristic with a threshold.
|
|
668
|
+
if (skill.invoke === "auto") {
|
|
669
|
+
const descriptionKeywords = extractKeywords(skill.description);
|
|
670
|
+
const promptKeywords = extractKeywords(prompt);
|
|
671
|
+
if (descriptionKeywords.length > 0) {
|
|
672
|
+
const overlap = descriptionKeywords.filter((k) => promptKeywords.includes(k));
|
|
673
|
+
// Require at least 30% keyword overlap, or at least 1 match for
|
|
674
|
+
// short descriptions
|
|
675
|
+
const threshold = Math.max(1, Math.ceil(descriptionKeywords.length * 0.3));
|
|
676
|
+
if (overlap.length < threshold)
|
|
677
|
+
return null;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
return skill;
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Install a skill from a URL or local path.
|
|
684
|
+
*
|
|
685
|
+
* @param source - URL (http/https) or local file path to the SKILL.md
|
|
686
|
+
* @param options - Installation options
|
|
687
|
+
* @returns The installed skill definition, or null on failure
|
|
688
|
+
*/
|
|
689
|
+
async installSkill(source, options = {}) {
|
|
690
|
+
const errors = [];
|
|
691
|
+
let content;
|
|
692
|
+
// Determine if source is a URL or local path
|
|
693
|
+
if (source.startsWith("http://") || source.startsWith("https://")) {
|
|
694
|
+
try {
|
|
695
|
+
content = await httpGet(source);
|
|
696
|
+
}
|
|
697
|
+
catch (err) {
|
|
698
|
+
return {
|
|
699
|
+
skill: null,
|
|
700
|
+
errors: [
|
|
701
|
+
`Failed to fetch skill from ${source}: ${err.message}`,
|
|
702
|
+
],
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
else {
|
|
707
|
+
// Local path
|
|
708
|
+
const resolvedPath = path.resolve(source);
|
|
709
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
710
|
+
return {
|
|
711
|
+
skill: null,
|
|
712
|
+
errors: [`Local path does not exist: ${resolvedPath}`],
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
try {
|
|
716
|
+
content = fs.readFileSync(resolvedPath, "utf8");
|
|
717
|
+
}
|
|
718
|
+
catch (err) {
|
|
719
|
+
return {
|
|
720
|
+
skill: null,
|
|
721
|
+
errors: [
|
|
722
|
+
`Failed to read local file at ${resolvedPath}: ${err.message}`,
|
|
723
|
+
],
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
// Parse the content
|
|
728
|
+
const tempPath = path.join(os.tmpdir(), `neuro-skill-install-${Date.now()}.md`);
|
|
729
|
+
try {
|
|
730
|
+
fs.writeFileSync(tempPath, content, "utf8");
|
|
731
|
+
const parseResult = this.parseSkillMd(tempPath);
|
|
732
|
+
if (parseResult.errors.length > 0 || !parseResult.skill) {
|
|
733
|
+
return {
|
|
734
|
+
skill: null,
|
|
735
|
+
errors: [...parseResult.errors, "Failed to parse skill content."],
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
const skillName = options.name || parseResult.skill.name;
|
|
739
|
+
if (!VALID_NAME_REGEX.test(skillName)) {
|
|
740
|
+
return {
|
|
741
|
+
skill: null,
|
|
742
|
+
errors: [
|
|
743
|
+
`Invalid skill name "${skillName}". Must match ${VALID_NAME_REGEX.source}`,
|
|
744
|
+
],
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
// Determine target directory
|
|
748
|
+
const targetDir = options.global
|
|
749
|
+
? path.join(this.globalSkillsDir, skillName)
|
|
750
|
+
: path.join(path.resolve(options.projectRoot || process.cwd()), this.projectSkillsDir, skillName);
|
|
751
|
+
ensureDir(targetDir);
|
|
752
|
+
const targetPath = path.join(targetDir, SKILL_FILENAME);
|
|
753
|
+
// If content came from a different format, re-serialize to standard SKILL.md
|
|
754
|
+
const format = detectFormat(source);
|
|
755
|
+
if (format !== "neuro" && parseResult.skill) {
|
|
756
|
+
content = serializeToStandardFormat(parseResult.skill);
|
|
757
|
+
}
|
|
758
|
+
fs.writeFileSync(targetPath, content, "utf8");
|
|
759
|
+
// Re-parse from final location
|
|
760
|
+
const finalResult = this.parseSkillMd(targetPath);
|
|
761
|
+
if (finalResult.skill) {
|
|
762
|
+
finalResult.skill.source = options.global ? "global" : "project";
|
|
763
|
+
this.skills.set(finalResult.skill.name, finalResult.skill);
|
|
764
|
+
this.invalidateCache();
|
|
765
|
+
// Update registry index
|
|
766
|
+
this.updateRegistryEntry(finalResult.skill, source);
|
|
767
|
+
}
|
|
768
|
+
return { skill: finalResult.skill, errors: [] };
|
|
769
|
+
}
|
|
770
|
+
finally {
|
|
771
|
+
// Clean up temp file
|
|
772
|
+
try {
|
|
773
|
+
fs.unlinkSync(tempPath);
|
|
774
|
+
}
|
|
775
|
+
catch {
|
|
776
|
+
// Ignore cleanup errors
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Search available skills from the registry.
|
|
782
|
+
* Searches both the local registry index and the remote registry.
|
|
783
|
+
*
|
|
784
|
+
* @param query - Search query string
|
|
785
|
+
* @returns Array of matching registry entries
|
|
786
|
+
*/
|
|
787
|
+
async searchSkills(query) {
|
|
788
|
+
const results = [];
|
|
789
|
+
const queryLower = query.toLowerCase();
|
|
790
|
+
const queryTerms = queryLower.split(/\s+/);
|
|
791
|
+
// Search local registry first
|
|
792
|
+
this.loadRegistryIndex();
|
|
793
|
+
for (const entry of this.registryIndex.values()) {
|
|
794
|
+
if (this.matchesQuery(entry, queryTerms)) {
|
|
795
|
+
results.push(entry);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
// Search remote registry
|
|
799
|
+
try {
|
|
800
|
+
const remoteResults = await this.searchRemoteRegistry(query);
|
|
801
|
+
for (const entry of remoteResults) {
|
|
802
|
+
// Don't duplicate entries already found locally
|
|
803
|
+
if (!results.some((r) => r.name === entry.name)) {
|
|
804
|
+
// Mark installation status
|
|
805
|
+
entry.installed = this.skills.has(entry.name);
|
|
806
|
+
results.push(entry);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
catch {
|
|
811
|
+
// Remote registry unavailable — return local results only
|
|
812
|
+
}
|
|
813
|
+
// Sort by relevance: installed first, then by name
|
|
814
|
+
results.sort((a, b) => {
|
|
815
|
+
if (a.installed !== b.installed)
|
|
816
|
+
return a.installed ? -1 : 1;
|
|
817
|
+
return a.name.localeCompare(b.name);
|
|
818
|
+
});
|
|
819
|
+
return results;
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Publish a skill to the registry.
|
|
823
|
+
*
|
|
824
|
+
* @param skillPath - Path to the SKILL.md file or the skill directory
|
|
825
|
+
* @returns The registry entry created, or null on failure
|
|
826
|
+
*/
|
|
827
|
+
async publishSkill(skillPath) {
|
|
828
|
+
const errors = [];
|
|
829
|
+
// Resolve path
|
|
830
|
+
const resolved = path.resolve(skillPath);
|
|
831
|
+
let filePath;
|
|
832
|
+
if (fs.statSync(resolved).isDirectory()) {
|
|
833
|
+
filePath = path.join(resolved, SKILL_FILENAME);
|
|
834
|
+
}
|
|
835
|
+
else {
|
|
836
|
+
filePath = resolved;
|
|
837
|
+
}
|
|
838
|
+
if (!fs.existsSync(filePath)) {
|
|
839
|
+
return {
|
|
840
|
+
entry: null,
|
|
841
|
+
errors: [`Skill file not found at ${filePath}`],
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
// Parse the skill
|
|
845
|
+
const parseResult = this.parseSkillMd(filePath);
|
|
846
|
+
if (parseResult.errors.length > 0 || !parseResult.skill) {
|
|
847
|
+
return {
|
|
848
|
+
entry: null,
|
|
849
|
+
errors: [...parseResult.errors, "Cannot publish invalid skill."],
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
const skill = parseResult.skill;
|
|
853
|
+
// Read raw content for checksum
|
|
854
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
855
|
+
const entry = {
|
|
856
|
+
name: skill.name,
|
|
857
|
+
description: skill.description,
|
|
858
|
+
version: skill.version || "1.0.0",
|
|
859
|
+
author: skill.author || "unknown",
|
|
860
|
+
tags: skill.tags || [],
|
|
861
|
+
source: filePath,
|
|
862
|
+
checksum: checksum(content),
|
|
863
|
+
addedAt: new Date().toISOString(),
|
|
864
|
+
installed: true,
|
|
865
|
+
};
|
|
866
|
+
// Attempt remote publish
|
|
867
|
+
try {
|
|
868
|
+
await httpPost(`${this.registryBaseUrl}/api/v1/skills`, {
|
|
869
|
+
name: entry.name,
|
|
870
|
+
description: entry.description,
|
|
871
|
+
version: entry.version,
|
|
872
|
+
author: entry.author,
|
|
873
|
+
tags: entry.tags,
|
|
874
|
+
checksum: entry.checksum,
|
|
875
|
+
content,
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
catch (err) {
|
|
879
|
+
errors.push(`Remote publish failed: ${err.message}. Skill saved to local registry only.`);
|
|
880
|
+
}
|
|
881
|
+
// Update local registry
|
|
882
|
+
this.loadRegistryIndex();
|
|
883
|
+
this.registryIndex.set(entry.name, entry);
|
|
884
|
+
this.saveRegistryIndex();
|
|
885
|
+
return { entry, errors };
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Get the full content of an activated skill.
|
|
889
|
+
* This should be called after activateSkill() returns a match.
|
|
890
|
+
*
|
|
891
|
+
* @param name - The skill name
|
|
892
|
+
* @returns The full skill definition including the Markdown body, or null
|
|
893
|
+
*/
|
|
894
|
+
getSkillContent(name) {
|
|
895
|
+
const skill = this.skills.get(name);
|
|
896
|
+
if (!skill)
|
|
897
|
+
return null;
|
|
898
|
+
// Re-read from disk to ensure we have the latest content
|
|
899
|
+
try {
|
|
900
|
+
const raw = fs.readFileSync(skill.filePath, "utf8");
|
|
901
|
+
const result = this.parseSkillMd(skill.filePath);
|
|
902
|
+
if (result.skill) {
|
|
903
|
+
// Update the cached version
|
|
904
|
+
this.skills.set(name, result.skill);
|
|
905
|
+
return result.skill;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
catch {
|
|
909
|
+
// Fall back to cached version
|
|
910
|
+
}
|
|
911
|
+
return skill;
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* List all installed skills with their current status.
|
|
915
|
+
*
|
|
916
|
+
* @returns Array of skill definitions with status information
|
|
917
|
+
*/
|
|
918
|
+
listInstalled() {
|
|
919
|
+
return Array.from(this.skills.values()).map((skill) => ({
|
|
920
|
+
...skill,
|
|
921
|
+
loaded: true,
|
|
922
|
+
format: detectFormat(skill.filePath),
|
|
923
|
+
}));
|
|
924
|
+
}
|
|
925
|
+
// -----------------------------------------------------------------------
|
|
926
|
+
// Private Methods
|
|
927
|
+
// -----------------------------------------------------------------------
|
|
928
|
+
/**
|
|
929
|
+
* Parse a standard Neuro SKILL.md file.
|
|
930
|
+
*/
|
|
931
|
+
parseNeuroFormat(raw, filePath) {
|
|
932
|
+
const warnings = [];
|
|
933
|
+
const errors = [];
|
|
934
|
+
const { metadata, body } = parseFrontmatter(raw);
|
|
935
|
+
const name = metadata.name;
|
|
936
|
+
const description = metadata.description;
|
|
937
|
+
if (!name) {
|
|
938
|
+
errors.push("Required field 'name' is missing from frontmatter.");
|
|
939
|
+
}
|
|
940
|
+
if (!description) {
|
|
941
|
+
errors.push("Required field 'description' is missing from frontmatter.");
|
|
942
|
+
}
|
|
943
|
+
if (errors.length > 0) {
|
|
944
|
+
return {
|
|
945
|
+
skill: null,
|
|
946
|
+
warnings,
|
|
947
|
+
errors,
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
const skill = {
|
|
951
|
+
name: name,
|
|
952
|
+
description: description,
|
|
953
|
+
version: metadata.version || undefined,
|
|
954
|
+
author: metadata.author || undefined,
|
|
955
|
+
tags: Array.isArray(metadata.tags) ? metadata.tags : undefined,
|
|
956
|
+
tools: Array.isArray(metadata.tools) ? metadata.tools : undefined,
|
|
957
|
+
priority: typeof metadata.priority === "number" ? metadata.priority : undefined,
|
|
958
|
+
invoke: metadata.invoke || undefined,
|
|
959
|
+
load: metadata.load || undefined,
|
|
960
|
+
body,
|
|
961
|
+
filePath,
|
|
962
|
+
source: "project", // Will be overridden by caller
|
|
963
|
+
};
|
|
964
|
+
return { skill, warnings, errors };
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* Invalidate the discovery cache.
|
|
968
|
+
*/
|
|
969
|
+
invalidateCache() {
|
|
970
|
+
this.cache = null;
|
|
971
|
+
this.discovered = false;
|
|
972
|
+
this.compactListing = [];
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Load the local registry index from disk.
|
|
976
|
+
*/
|
|
977
|
+
loadRegistryIndex() {
|
|
978
|
+
this.registryIndex.clear();
|
|
979
|
+
if (!fs.existsSync(this.registryFilePath))
|
|
980
|
+
return;
|
|
981
|
+
try {
|
|
982
|
+
const raw = fs.readFileSync(this.registryFilePath, "utf8");
|
|
983
|
+
const data = JSON.parse(raw);
|
|
984
|
+
for (const entry of data) {
|
|
985
|
+
// Verify installation status
|
|
986
|
+
const skillDir = path.join(this.globalSkillsDir, entry.name);
|
|
987
|
+
entry.installed = fs.existsSync(path.join(skillDir, SKILL_FILENAME));
|
|
988
|
+
this.registryIndex.set(entry.name, entry);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
catch {
|
|
992
|
+
// Corrupted or empty registry — start fresh
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Save the local registry index to disk.
|
|
997
|
+
*/
|
|
998
|
+
saveRegistryIndex() {
|
|
999
|
+
ensureDir(path.dirname(this.registryFilePath));
|
|
1000
|
+
const data = Array.from(this.registryIndex.values());
|
|
1001
|
+
fs.writeFileSync(this.registryFilePath, JSON.stringify(data, null, 2), "utf8");
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Update a single registry entry.
|
|
1005
|
+
*/
|
|
1006
|
+
updateRegistryEntry(skill, source) {
|
|
1007
|
+
this.loadRegistryIndex();
|
|
1008
|
+
try {
|
|
1009
|
+
const content = fs.readFileSync(skill.filePath, "utf8");
|
|
1010
|
+
const entry = {
|
|
1011
|
+
name: skill.name,
|
|
1012
|
+
description: skill.description,
|
|
1013
|
+
version: skill.version || "1.0.0",
|
|
1014
|
+
author: skill.author || "unknown",
|
|
1015
|
+
tags: skill.tags || [],
|
|
1016
|
+
source,
|
|
1017
|
+
checksum: checksum(content),
|
|
1018
|
+
addedAt: new Date().toISOString(),
|
|
1019
|
+
installed: true,
|
|
1020
|
+
};
|
|
1021
|
+
this.registryIndex.set(skill.name, entry);
|
|
1022
|
+
this.saveRegistryIndex();
|
|
1023
|
+
}
|
|
1024
|
+
catch {
|
|
1025
|
+
// Silently fail — non-critical
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Search the remote skill registry.
|
|
1030
|
+
*/
|
|
1031
|
+
async searchRemoteRegistry(query) {
|
|
1032
|
+
const url = `${this.registryBaseUrl}/api/v1/skills/search?q=${encodeURIComponent(query)}`;
|
|
1033
|
+
const raw = await httpGet(url);
|
|
1034
|
+
const data = JSON.parse(raw);
|
|
1035
|
+
if (Array.isArray(data)) {
|
|
1036
|
+
return data;
|
|
1037
|
+
}
|
|
1038
|
+
if (data && Array.isArray(data.results)) {
|
|
1039
|
+
return data.results;
|
|
1040
|
+
}
|
|
1041
|
+
return [];
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* Check if a registry entry matches the given query terms.
|
|
1045
|
+
*/
|
|
1046
|
+
matchesQuery(entry, queryTerms) {
|
|
1047
|
+
const searchable = [
|
|
1048
|
+
entry.name,
|
|
1049
|
+
entry.description,
|
|
1050
|
+
entry.author,
|
|
1051
|
+
...entry.tags,
|
|
1052
|
+
]
|
|
1053
|
+
.join(" ")
|
|
1054
|
+
.toLowerCase();
|
|
1055
|
+
return queryTerms.every((term) => searchable.includes(term));
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
// ---------------------------------------------------------------------------
|
|
1059
|
+
// Standalone utility functions
|
|
1060
|
+
// ---------------------------------------------------------------------------
|
|
1061
|
+
/**
|
|
1062
|
+
* Extract meaningful keywords from a text string.
|
|
1063
|
+
* Removes stop words and normalizes for comparison.
|
|
1064
|
+
*/
|
|
1065
|
+
function extractKeywords(text) {
|
|
1066
|
+
const stopWords = new Set([
|
|
1067
|
+
"a", "an", "and", "are", "as", "at", "be", "by", "do", "for", "from",
|
|
1068
|
+
"has", "have", "how", "i", "if", "in", "is", "it", "its", "just",
|
|
1069
|
+
"me", "my", "no", "not", "of", "on", "or", "our", "out", "own",
|
|
1070
|
+
"say", "she", "so", "than", "that", "the", "their", "them", "then",
|
|
1071
|
+
"there", "these", "they", "this", "to", "up", "us", "very", "was",
|
|
1072
|
+
"we", "what", "when", "which", "who", "will", "with", "would", "you",
|
|
1073
|
+
"your", "can", "could", "should", "shall", "may", "might", "must",
|
|
1074
|
+
]);
|
|
1075
|
+
return text
|
|
1076
|
+
.toLowerCase()
|
|
1077
|
+
.replace(/[^a-z0-9\s-]/g, " ")
|
|
1078
|
+
.split(/[\s-]+/)
|
|
1079
|
+
.filter((word) => word.length > 1 && !stopWords.has(word));
|
|
1080
|
+
}
|
|
1081
|
+
/**
|
|
1082
|
+
* Serialize a StandardSkillDefinition back into a valid SKILL.md format.
|
|
1083
|
+
* Used when converting cross-tool formats to the standard format.
|
|
1084
|
+
*/
|
|
1085
|
+
function serializeToStandardFormat(skill) {
|
|
1086
|
+
const lines = ["---"];
|
|
1087
|
+
lines.push(`name: ${skill.name}`);
|
|
1088
|
+
lines.push(`description: "${skill.description.replace(/"/g, '\\"')}"`);
|
|
1089
|
+
if (skill.version)
|
|
1090
|
+
lines.push(`version: "${skill.version}"`);
|
|
1091
|
+
if (skill.author)
|
|
1092
|
+
lines.push(`author: "${skill.author}"`);
|
|
1093
|
+
if (skill.tags && skill.tags.length > 0) {
|
|
1094
|
+
lines.push(`tags: [${skill.tags.map((t) => `"${t}"`).join(", ")}]`);
|
|
1095
|
+
}
|
|
1096
|
+
if (skill.tools && skill.tools.length > 0) {
|
|
1097
|
+
lines.push(`tools: [${skill.tools.map((t) => `"${t}"`).join(", ")}]`);
|
|
1098
|
+
}
|
|
1099
|
+
if (skill.priority !== undefined)
|
|
1100
|
+
lines.push(`priority: ${skill.priority}`);
|
|
1101
|
+
if (skill.invoke)
|
|
1102
|
+
lines.push(`invoke: ${skill.invoke}`);
|
|
1103
|
+
if (skill.load)
|
|
1104
|
+
lines.push(`load: ${skill.load}`);
|
|
1105
|
+
lines.push("---");
|
|
1106
|
+
lines.push("");
|
|
1107
|
+
if (skill.body) {
|
|
1108
|
+
lines.push(skill.body);
|
|
1109
|
+
}
|
|
1110
|
+
return lines.join("\n");
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Create a new SKILL.md file at the specified path.
|
|
1114
|
+
* Convenience function for bootstrapping new skills.
|
|
1115
|
+
*/
|
|
1116
|
+
export function createSkillFile(dirPath, definition) {
|
|
1117
|
+
ensureDir(dirPath);
|
|
1118
|
+
const skill = {
|
|
1119
|
+
...definition,
|
|
1120
|
+
body: definition.body || `# ${definition.name}\n\nSkill content goes here.\n`,
|
|
1121
|
+
filePath: "",
|
|
1122
|
+
source: "project",
|
|
1123
|
+
};
|
|
1124
|
+
const content = serializeToStandardFormat(skill);
|
|
1125
|
+
const filePath = path.join(dirPath, SKILL_FILENAME);
|
|
1126
|
+
fs.writeFileSync(filePath, content, "utf8");
|
|
1127
|
+
return filePath;
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Validate a skill name against the specification requirements.
|
|
1131
|
+
*/
|
|
1132
|
+
export function validateSkillName(name) {
|
|
1133
|
+
const errors = [];
|
|
1134
|
+
if (!name) {
|
|
1135
|
+
errors.push("Name is required.");
|
|
1136
|
+
}
|
|
1137
|
+
else {
|
|
1138
|
+
if (name.length > MAX_NAME_LENGTH) {
|
|
1139
|
+
errors.push(`Name exceeds maximum length of ${MAX_NAME_LENGTH} characters (got ${name.length}).`);
|
|
1140
|
+
}
|
|
1141
|
+
if (!/^[a-z]/.test(name)) {
|
|
1142
|
+
errors.push("Name must start with a lowercase letter.");
|
|
1143
|
+
}
|
|
1144
|
+
if (/[^a-z0-9-]/.test(name)) {
|
|
1145
|
+
errors.push("Name must contain only lowercase letters, digits, and hyphens.");
|
|
1146
|
+
}
|
|
1147
|
+
if (name.endsWith("-")) {
|
|
1148
|
+
errors.push("Name must not end with a hyphen.");
|
|
1149
|
+
}
|
|
1150
|
+
if (name.includes("--")) {
|
|
1151
|
+
errors.push("Name must not contain consecutive hyphens.");
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
return { valid: errors.length === 0, errors };
|
|
1155
|
+
}
|
|
1156
|
+
//# sourceMappingURL=skill-standard.js.map
|