@mthanh/vespercli 1.7.0 → 1.8.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/README.md +46 -0
- package/assets/claude/agents/clickup-agent.md +1 -1
- package/assets/claude/agents/context7.md +1 -1
- package/assets/claude/agents/history-logger.md +2 -2
- package/assets/claude/agents/modernization.md +1 -0
- package/assets/claude/commands/compact-knowledge.md +1 -1
- package/assets/claude/skills/java/SKILL.md +1 -1
- package/assets/claude/skills/nextjs-tailwind/SKILL.md +1 -1
- package/assets/claude/skills/reactjs/SKILL.md +1 -1
- package/assets/claude/skills/self-explanatory-code-commenting/SKILL.md +1 -1
- package/assets/claude/skills/tailwind-v4-vite/SKILL.md +1 -1
- package/assets/claude/skills/task-implementation/SKILL.md +1 -1
- package/assets/claude/skills/vesper-catalog-editing-safety/SKILL.md +2 -1
- package/assets/claude/vesper-orchestrator.md +2 -0
- package/assets/core/agents/history-logger.agent.md +2 -2
- package/assets/core/prompts/compact-knowledge.prompt.md +1 -1
- package/assets/instructions/flutter/dart-n-flutter.instructions.md +447 -0
- package/assets/instructions/self-explanatory-code-commenting/self-explanatory-code-commenting.instructions.md +1 -1
- package/assets/skills/vesper-catalog-editing-safety/SKILL.md +2 -1
- package/assets/vendor/i-have-adhd/LICENSE +21 -0
- package/assets/vendor/i-have-adhd/SKILL.md +142 -0
- package/assets/vendor/i-have-adhd/VENDOR.md +56 -0
- package/assets/vendor/ponytail/LICENSE +21 -0
- package/assets/vendor/ponytail/SKILL.md +120 -0
- package/assets/vendor/ponytail/VENDOR.md +115 -0
- package/dist/cli.min.js +62 -26
- package/package.json +1 -1
- package/scripts/generate-claude-assets.js +273 -20
- package/scripts/generate-claude-assets.test.js +228 -0
- package/scripts/postinstall.js +15 -4
- package/src/mcp/vesper-indexing/dist/index.js +17 -1
- package/src/mcp/vesper-indexing/dist/index.js.map +1 -1
- package/src/mcp/vesper-indexing/package-lock.json +2 -2
- package/src/mcp/vesper-indexing/package.json +1 -1
|
@@ -13,12 +13,20 @@
|
|
|
13
13
|
* anything at install time. The 12 core agents + the orchestrator playbook are hand-authored
|
|
14
14
|
* separately in assets/claude/ and are never touched by this script.
|
|
15
15
|
*
|
|
16
|
+
* NOT scanned or written by this script (by design): `assets/vendor/` — reviewed,
|
|
17
|
+
* pinned snapshots of third-party assets (e.g. `assets/vendor/i-have-adhd/`). It is
|
|
18
|
+
* outside the four scan roots (agents/prompts/skills/instructions) and the three write
|
|
19
|
+
* targets (assets/claude/{agents,commands,skills}), so the generator can never read,
|
|
20
|
+
* transform, or clobber it. Vendored assets are installed project-level at init time
|
|
21
|
+
* (see installAdhdProjectAsset in src/cli/utils/claude-target.js), never generated here.
|
|
22
|
+
*
|
|
16
23
|
* Run: node scripts/generate-claude-assets.js
|
|
17
24
|
*/
|
|
18
25
|
|
|
19
26
|
import { readdir, readFile, writeFile, mkdir, cp, stat } from 'node:fs/promises';
|
|
20
27
|
import { join, dirname } from 'node:path';
|
|
21
|
-
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
29
|
+
import { MCP_SERVERS } from '../src/cli/utils/settings.js';
|
|
22
30
|
|
|
23
31
|
const __filename = fileURLToPath(import.meta.url);
|
|
24
32
|
const __dirname = dirname(__filename);
|
|
@@ -50,13 +58,20 @@ const TOOL_MAP = {
|
|
|
50
58
|
'search': 'Grep, Glob',
|
|
51
59
|
'todo': 'TodoWrite',
|
|
52
60
|
'agent': 'Agent',
|
|
61
|
+
'agent/runSubagent': 'Agent', // namespaced Copilot spelling of the subagent-invocation capability; equivalent to bare `agent`
|
|
53
62
|
'vscode/askQuestions': 'AskUserQuestion',
|
|
54
63
|
'web/fetch': 'WebFetch',
|
|
55
64
|
'web': 'WebFetch, WebSearch',
|
|
56
65
|
};
|
|
57
66
|
|
|
58
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* Copilot / VS Code-only tokens with no Claude Code equivalent — INTENTIONALLY dropped, not mapped.
|
|
69
|
+
* Every entry here is reviewable, greppable policy: a token in this set is dropped silently and does
|
|
70
|
+
* NOT trip the unknown-token warning below. A token that is neither here, in TOOL_MAP, nor in
|
|
71
|
+
* MCP_PASSTHROUGH is treated as a hazard and warned about (see mapToolTokens).
|
|
72
|
+
*/
|
|
59
73
|
const DROP_TOKENS = new Set([
|
|
74
|
+
// VS Code workspace/editor capabilities with no Claude Code analogue.
|
|
60
75
|
'vscode/getProjectSetupInfo',
|
|
61
76
|
'vscode/installExtension',
|
|
62
77
|
'vscode/newWorkspace',
|
|
@@ -65,10 +80,25 @@ const DROP_TOKENS = new Set([
|
|
|
65
80
|
'vscode/vscodeAPI',
|
|
66
81
|
'vscode/extensions',
|
|
67
82
|
'vscode',
|
|
83
|
+
// Copilot-only task/terminal/diagnostic capabilities — no Claude Code equivalent.
|
|
84
|
+
'read/problems', // VS Code Problems panel; Claude has no diagnostics feed
|
|
85
|
+
'execute/runTask', // VS Code task runner; Claude uses Bash directly
|
|
86
|
+
'execute/getTaskOutput', // paired with runTask; N/A in Claude
|
|
87
|
+
'read/getTaskOutput', // Copilot task-runner readback (read a VS Code task's output); sibling of execute/getTaskOutput. No Claude equivalent.
|
|
88
|
+
'read/terminalLastCommand', // VS Code terminal introspection
|
|
89
|
+
'read/terminalSelection', // VS Code terminal introspection
|
|
90
|
+
// Copilot-only search/context capabilities with no Claude Code analogue.
|
|
91
|
+
'web/githubRepo', // Copilot GitHub repo context provider
|
|
92
|
+
'findTestFiles', // Copilot test-file locator; use Glob/Grep in Claude
|
|
93
|
+
'search/searchResults', // Copilot search-results context provider
|
|
94
|
+
'search/usages', // Copilot symbol-usages provider
|
|
95
|
+
// Copilot-only MCP/context providers with no Claude equivalent in this catalog.
|
|
96
|
+
'microsoft.docs.mcp', // Copilot-bundled Microsoft Docs MCP
|
|
97
|
+
'github', // Copilot GitHub context provider
|
|
68
98
|
]);
|
|
69
99
|
|
|
70
100
|
/** MCP-namespaced tokens pass through as mcp__<server>__* wildcards (kept as a note, not expanded) */
|
|
71
|
-
const MCP_PASSTHROUGH = ['vesper-indexing/*', 'memory/*', 'sequential-thinking/*', 'context7/*'];
|
|
101
|
+
const MCP_PASSTHROUGH = ['vesper-indexing/*', 'memory/*', 'sequential-thinking/*', 'context7/*', 'clickup/*'];
|
|
72
102
|
|
|
73
103
|
// Pinned model IDs, not floating aliases (`opus`/`sonnet`/`haiku`) — aliases
|
|
74
104
|
// silently track the newest release of each family, which means unreviewed
|
|
@@ -82,25 +112,83 @@ const MODEL_MAP = {
|
|
|
82
112
|
};
|
|
83
113
|
|
|
84
114
|
/**
|
|
85
|
-
*
|
|
115
|
+
* Normalize a single tool item: trim, then strip a matching pair of surrounding quotes.
|
|
116
|
+
* @param {string} raw
|
|
117
|
+
* @returns {string}
|
|
118
|
+
*/
|
|
119
|
+
function normalizeToolItem(raw) {
|
|
120
|
+
return raw.trim().replace(/^['"]|['"]$/g, '');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Parse a `tools:` frontmatter value into a token list. Accepts BOTH forms:
|
|
125
|
+
* - bracket array: `tools: [a, b, c]` (single or multi-line)
|
|
126
|
+
* - YAML dash-list: `tools:` followed by ` - a` / ` - b` lines
|
|
127
|
+
*
|
|
128
|
+
* The dash-list branch is DEFECT 1's fix: without it, a `tools:` written as a
|
|
129
|
+
* YAML sequence matches no branch and the generated agent ships with no `tools:`
|
|
130
|
+
* key at all — which, per Claude Code docs, silently inherits ALL tools (including
|
|
131
|
+
* every live MCP tool). Parser-only fix: authored dash-list sources are NOT rewritten.
|
|
86
132
|
* @param {string} frontmatter
|
|
87
133
|
* @returns {string[]}
|
|
88
134
|
*/
|
|
89
135
|
function extractToolTokens(frontmatter) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
136
|
+
// Bracket-array form first (unchanged behavior).
|
|
137
|
+
const bracket = frontmatter.match(/tools:\s*\[([\s\S]*?)\]/);
|
|
138
|
+
if (bracket) {
|
|
139
|
+
return bracket[1]
|
|
140
|
+
.split(',')
|
|
141
|
+
.map(normalizeToolItem)
|
|
142
|
+
.filter(Boolean);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// YAML dash-list form: a bare `tools:` line (optionally carrying a trailing
|
|
146
|
+
// `# comment`, tolerated for the same reason per-item comments are), then one
|
|
147
|
+
// or more `- <token>` items. Tolerating the header comment avoids a silent
|
|
148
|
+
// fall-through to `[]` that would be indistinguishable from a genuinely empty
|
|
149
|
+
// list — the exact silent-token-loss shape this whole change exists to close.
|
|
150
|
+
const lines = frontmatter.split('\n');
|
|
151
|
+
const startIdx = lines.findIndex(l => /^\s*tools:\s*(#.*)?$/.test(l));
|
|
152
|
+
if (startIdx === -1) return [];
|
|
153
|
+
|
|
154
|
+
const tokens = [];
|
|
155
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
156
|
+
const line = lines[i];
|
|
157
|
+
const dash = line.match(/^\s*-\s+(.*)$/); // tolerate mixed indentation
|
|
158
|
+
if (dash) {
|
|
159
|
+
// Strip a trailing inline `# comment`, then normalize (trim + unquote).
|
|
160
|
+
const item = normalizeToolItem(dash[1].replace(/\s+#.*$/, ''));
|
|
161
|
+
if (item) tokens.push(item);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (line.trim() === '') continue; // tolerate blank lines within the block
|
|
165
|
+
break; // next non-indented, non-dash key ends the block (empty list -> [])
|
|
166
|
+
}
|
|
167
|
+
return tokens;
|
|
96
168
|
}
|
|
97
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Tracks whether ANY unknown tool token was seen during this process's generation run.
|
|
172
|
+
* Consulted by main() to fail the build under CI / --strict. Warn-and-continue means a
|
|
173
|
+
* single stray token never aborts generation mid-run and corrupts every other agent's output.
|
|
174
|
+
*/
|
|
175
|
+
let sawUnknownToken = false;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Tracks whether ANY `applyTo` glob could not be translated to natural language during this run.
|
|
179
|
+
* Consulted by main() to fail the build under CI / --strict, mirroring `sawUnknownToken`. Emitting
|
|
180
|
+
* raw glob syntax into a semantically-matched Claude skill description is a regression we refuse to
|
|
181
|
+
* ship silently — an unmappable token warns loudly and hard-fails strict runs.
|
|
182
|
+
*/
|
|
183
|
+
let sawUnmappableGlob = false;
|
|
184
|
+
|
|
98
185
|
/**
|
|
99
186
|
* Convert a Copilot tool-token list into a Claude Code `tools:` frontmatter value.
|
|
100
187
|
* @param {string[]} tokens
|
|
188
|
+
* @param {string} [agentName] - source agent, threaded through for useful warnings
|
|
101
189
|
* @returns {string}
|
|
102
190
|
*/
|
|
103
|
-
function mapToolTokens(tokens) {
|
|
191
|
+
function mapToolTokens(tokens, agentName) {
|
|
104
192
|
const claudeTools = new Set();
|
|
105
193
|
const mcpNotes = new Set();
|
|
106
194
|
|
|
@@ -113,15 +201,33 @@ function mapToolTokens(tokens) {
|
|
|
113
201
|
const mapped = TOOL_MAP[token];
|
|
114
202
|
if (mapped) {
|
|
115
203
|
mapped.split(',').forEach(t => claudeTools.add(t.trim()));
|
|
204
|
+
continue;
|
|
116
205
|
}
|
|
206
|
+
// Unknown token: not mapped, not an MCP passthrough, not an intentional drop.
|
|
207
|
+
// This is exactly the diagnostic that would have surfaced DEFECT 2 (`clickup/*`)
|
|
208
|
+
// instead of letting it vanish. Warn and continue; CI/--strict escalates in main().
|
|
209
|
+
sawUnknownToken = true;
|
|
210
|
+
const where = agentName ? ` (in agent "${agentName}")` : '';
|
|
211
|
+
console.warn(
|
|
212
|
+
` [unknown-tool-token] "${token}"${where} matches no TOOL_MAP, MCP_PASSTHROUGH, or DROP_TOKENS entry — dropped. ` +
|
|
213
|
+
`Map it, add it to MCP_PASSTHROUGH, or add it to DROP_TOKENS if intentional.`
|
|
214
|
+
);
|
|
117
215
|
}
|
|
118
216
|
|
|
119
217
|
// Expand known MCP servers to their concrete tool names (best-effort; unknown servers noted as-is).
|
|
218
|
+
//
|
|
219
|
+
// The four entries below are a DELIBERATELY CURATED read-mostly SUBSET — NOT a mirror of
|
|
220
|
+
// MCP_SERVERS (which is the full auto-approval superset: memory has 9 tools incl. delete_*/
|
|
221
|
+
// read_graph, context7 has 3). Deriving these from MCP_SERVERS by rule would grant destructive
|
|
222
|
+
// tools to every agent declaring `memory/*`, re-creating the fail-open class this file fixes.
|
|
223
|
+
// Leave them literal. `clickup` is the sole exception: it has no curated subset, so its full
|
|
224
|
+
// set is sourced from the single source of truth in settings.js (MCP_SERVERS.clickup.tools).
|
|
120
225
|
const MCP_TOOL_NAMES = {
|
|
121
226
|
'vesper-indexing': ['mcp__vesper-indexing__vesper_search', 'mcp__vesper-indexing__vesper_symbols', 'mcp__vesper-indexing__vesper_status', 'mcp__vesper-indexing__vesper_reindex'],
|
|
122
227
|
'memory': ['mcp__memory__create_entities', 'mcp__memory__create_relations', 'mcp__memory__add_observations', 'mcp__memory__search_nodes', 'mcp__memory__open_nodes'],
|
|
123
228
|
'sequential-thinking': ['mcp__sequential-thinking__sequentialthinking'],
|
|
124
229
|
'context7': ['mcp__context7__resolve-library-id', 'mcp__context7__get-library-docs'],
|
|
230
|
+
'clickup': MCP_SERVERS.clickup.tools.map(t => `mcp__clickup__${t}`),
|
|
125
231
|
};
|
|
126
232
|
for (const server of mcpNotes) {
|
|
127
233
|
(MCP_TOOL_NAMES[server] || []).forEach(t => claudeTools.add(t));
|
|
@@ -146,7 +252,7 @@ function convertAgent(content, kebabName) {
|
|
|
146
252
|
|
|
147
253
|
const description = descriptionMatch ? descriptionMatch[1] : `${kebabName} specialist agent`;
|
|
148
254
|
const model = modelMatch ? (MODEL_MAP[modelMatch[1]] || 'claude-sonnet-5') : 'claude-sonnet-5';
|
|
149
|
-
const tools = mapToolTokens(extractToolTokens(frontmatter));
|
|
255
|
+
const tools = mapToolTokens(extractToolTokens(frontmatter), kebabName);
|
|
150
256
|
|
|
151
257
|
const newFrontmatter = [
|
|
152
258
|
`name: ${kebabName}`,
|
|
@@ -183,6 +289,128 @@ function convertPrompt(content) {
|
|
|
183
289
|
return newFrontmatter ? `---\n${newFrontmatter}\n---\n${cleanedBody}` : cleanedBody;
|
|
184
290
|
}
|
|
185
291
|
|
|
292
|
+
/**
|
|
293
|
+
* Map a bare file extension (no dot) to a human-readable language/format name. Deliberately literal
|
|
294
|
+
* and reviewable — it covers exactly the extensions the six in-tree instruction sources reference,
|
|
295
|
+
* plus a small margin of common ones. Do NOT grow this speculatively: an unmapped extension trips the
|
|
296
|
+
* LOUD `sawUnmappableGlob` path in describeApplyTo(), which is the signal to extend this map on demand.
|
|
297
|
+
*/
|
|
298
|
+
const EXT_LANG = {
|
|
299
|
+
py: 'Python',
|
|
300
|
+
js: 'JavaScript',
|
|
301
|
+
ts: 'TypeScript',
|
|
302
|
+
tsx: 'TSX',
|
|
303
|
+
jsx: 'JSX',
|
|
304
|
+
java: 'Java',
|
|
305
|
+
cs: 'C#',
|
|
306
|
+
cpp: 'C++',
|
|
307
|
+
c: 'C',
|
|
308
|
+
go: 'Go',
|
|
309
|
+
rb: 'Ruby',
|
|
310
|
+
css: 'CSS',
|
|
311
|
+
scss: 'SCSS',
|
|
312
|
+
md: 'Markdown',
|
|
313
|
+
json: 'JSON',
|
|
314
|
+
html: 'HTML',
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Curated natural-language phrases for path-scoped applyTo globs — tokens that carry an embedded
|
|
319
|
+
* directory path (beyond a leading globstar) and therefore have no honest trailing-extension translation.
|
|
320
|
+
* A naive "trailing .md -> Markdown files" guess for the task-implementation entry would wrongly
|
|
321
|
+
* re-introduce over-breadth, so path-scoped globs are looked up here or fail LOUD. A stale/unused key
|
|
322
|
+
* is harmless residue (it simply never matches); prune only when a source stops using it.
|
|
323
|
+
*/
|
|
324
|
+
const GLOB_OVERRIDES = {
|
|
325
|
+
'**/.vesper/.tracking/changes/*.md': 'task change-record files under .vesper/.tracking/changes/',
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Render a list as an Oxford-style human phrase: "A", "A or B", "A, B, or C".
|
|
330
|
+
* @param {string[]} items
|
|
331
|
+
* @returns {string}
|
|
332
|
+
*/
|
|
333
|
+
function oxfordList(items) {
|
|
334
|
+
if (items.length === 0) return '';
|
|
335
|
+
if (items.length === 1) return items[0];
|
|
336
|
+
if (items.length === 2) return `${items[0]} or ${items[1]}`;
|
|
337
|
+
return `${items.slice(0, -1).join(', ')}, or ${items[items.length - 1]}`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Translate an `applyTo` glob list into a natural-language scope clause for a Claude skill
|
|
342
|
+
* description. Pure and exported for unit testing — no I/O, no writes.
|
|
343
|
+
*
|
|
344
|
+
* Contract:
|
|
345
|
+
* - Extension-mappable tokens (no embedded directory path — a leading globstar is allowed — ending in
|
|
346
|
+
* a dotted extension, incl. bare filenames like `vite.config.ts`) resolve via EXT_LANG to a name.
|
|
347
|
+
* - Path-scoped tokens (an embedded slash beyond a leading globstar, e.g. the task-implementation
|
|
348
|
+
* tracking-changes glob) resolve via GLOB_OVERRIDES to a curated phrase; a miss is
|
|
349
|
+
* pushed to `unmapped` — we NEVER emit raw glob syntax and NEVER naively guess the trailing ext.
|
|
350
|
+
* - Any unmappable token warns (naming skill + token + remediation), sets the module-level
|
|
351
|
+
* `sawUnmappableGlob` flag (which main() escalates under CI/--strict), and is omitted from the
|
|
352
|
+
* clause — degrade loudly, never silently.
|
|
353
|
+
*
|
|
354
|
+
* @param {string} applyTo - raw comma-separated applyTo value ('' when absent)
|
|
355
|
+
* @param {string} [skillName] - source skill name, threaded through for useful warnings
|
|
356
|
+
* @returns {{ clause: string, unmapped: string[] }} clause includes a leading space (or '' when empty)
|
|
357
|
+
*/
|
|
358
|
+
function describeApplyTo(applyTo, skillName) {
|
|
359
|
+
const unmapped = [];
|
|
360
|
+
if (!applyTo || !applyTo.trim()) return { clause: '', unmapped };
|
|
361
|
+
|
|
362
|
+
const tokens = applyTo.split(',').map(t => t.trim()).filter(Boolean);
|
|
363
|
+
const languages = [];
|
|
364
|
+
const phrases = [];
|
|
365
|
+
|
|
366
|
+
for (const token of tokens) {
|
|
367
|
+
// A single leading `**/` is a "match anywhere" prefix, not an embedded path — strip it before
|
|
368
|
+
// deciding whether the remainder is path-scoped.
|
|
369
|
+
const stripped = token.replace(/^\*\*\//, '');
|
|
370
|
+
|
|
371
|
+
if (stripped.includes('/')) {
|
|
372
|
+
// Path-scoped: only a curated override is acceptable; a miss is LOUD, never a raw-glob leak.
|
|
373
|
+
const override = GLOB_OVERRIDES[token];
|
|
374
|
+
if (override) {
|
|
375
|
+
if (!phrases.includes(override)) phrases.push(override);
|
|
376
|
+
} else {
|
|
377
|
+
unmapped.push(token);
|
|
378
|
+
}
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const extMatch = stripped.match(/\.([A-Za-z0-9]+)$/);
|
|
383
|
+
if (extMatch) {
|
|
384
|
+
const lang = EXT_LANG[extMatch[1].toLowerCase()];
|
|
385
|
+
if (lang) {
|
|
386
|
+
if (!languages.includes(lang)) languages.push(lang);
|
|
387
|
+
} else {
|
|
388
|
+
unmapped.push(token);
|
|
389
|
+
}
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Neither path-scoped nor a recognizable `.<ext>` token.
|
|
394
|
+
unmapped.push(token);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
for (const token of unmapped) {
|
|
398
|
+
sawUnmappableGlob = true;
|
|
399
|
+
const where = skillName ? ` (in skill "${skillName}")` : '';
|
|
400
|
+
console.warn(
|
|
401
|
+
` [unmappable-applyto-glob] "${token}"${where} could not be translated to natural language — omitted. ` +
|
|
402
|
+
`Extend EXT_LANG (for a new extension) or GLOB_OVERRIDES (for a path-scoped glob) to map it.`
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const pieces = [];
|
|
407
|
+
if (languages.length) pieces.push(`${oxfordList(languages)} files`);
|
|
408
|
+
for (const phrase of phrases) pieces.push(phrase);
|
|
409
|
+
|
|
410
|
+
const clause = pieces.length ? ` Use when working with ${oxfordList(pieces)}.` : '';
|
|
411
|
+
return { clause, unmapped };
|
|
412
|
+
}
|
|
413
|
+
|
|
186
414
|
/**
|
|
187
415
|
* Convert an `.instructions.md` file into a new Skill (SKILL.md).
|
|
188
416
|
* @param {string} content
|
|
@@ -197,11 +425,15 @@ function convertInstructionToSkill(content, kebabName) {
|
|
|
197
425
|
const descriptionMatch = frontmatter.match(/description:\s*['"]?(.*?)['"]?\s*(\n|$)/);
|
|
198
426
|
const applyToMatch = frontmatter.match(/applyTo:\s*['"]?(.*?)['"]?\s*(\n|$)/);
|
|
199
427
|
|
|
200
|
-
const
|
|
428
|
+
const rawBaseDescription = descriptionMatch ? descriptionMatch[1] : `${kebabName} conventions`;
|
|
201
429
|
const applyTo = applyToMatch ? applyToMatch[1] : '';
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
430
|
+
const { clause } = describeApplyTo(applyTo, kebabName);
|
|
431
|
+
// Normalize the base description before appending the scope clause: trim trailing whitespace and
|
|
432
|
+
// ensure it ends in terminal punctuation, so the clause's leading space doesn't run the two
|
|
433
|
+
// sentences together (e.g. "…Java base applications Use when…"). Only append when there IS a clause.
|
|
434
|
+
const trimmedBase = rawBaseDescription.trimEnd();
|
|
435
|
+
const normalizedBase = clause && !/[.!?]$/.test(trimmedBase) ? `${trimmedBase}.` : trimmedBase;
|
|
436
|
+
const description = `${normalizedBase}${clause}`;
|
|
205
437
|
|
|
206
438
|
const newFrontmatter = [`name: ${kebabName}`, `description: ${description}`].join('\n');
|
|
207
439
|
const cleanedBody = applyBodyTokenReplacements(body);
|
|
@@ -375,9 +607,30 @@ async function main() {
|
|
|
375
607
|
console.log(`✓ instructions: ${instructionSkillCount} converted to skills -> assets/claude/skills/`);
|
|
376
608
|
|
|
377
609
|
console.log('\nDone. Hand-authored content (assets/claude/agents/vesper-*.md, vesper-orchestrator.md, *.tpl, commands/vesper-*.md) is untouched by this script.');
|
|
610
|
+
|
|
611
|
+
// Escalate unknown tokens in CI (or when --strict is passed): an unmapped token means a
|
|
612
|
+
// mis-permissioned agent would ship. Locally we warn-and-continue; CI must hard-fail.
|
|
613
|
+
const strict = process.argv.includes('--strict') || Boolean(process.env.CI);
|
|
614
|
+
if (sawUnknownToken && strict) {
|
|
615
|
+
console.error('\n✗ Unknown tool token(s) encountered (see warnings above). Failing build (CI/--strict).');
|
|
616
|
+
process.exitCode = 1;
|
|
617
|
+
}
|
|
618
|
+
if (sawUnmappableGlob && strict) {
|
|
619
|
+
console.error('\n✗ Unmappable applyTo glob(s) encountered (see warnings above). Failing build (CI/--strict).');
|
|
620
|
+
process.exitCode = 1;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Only run generation when executed directly as a script — importing the module
|
|
625
|
+
// (e.g. from vitest) must NOT trigger a full catalog regeneration. Guard on
|
|
626
|
+
// `process.argv[1]` first: when unset (e.g. `node --input-type=module -e`),
|
|
627
|
+
// `pathToFileURL(undefined)` throws ERR_INVALID_ARG_TYPE, so the import-safety
|
|
628
|
+
// guarantee must not depend on argv[1] being populated.
|
|
629
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
630
|
+
main().catch((error) => {
|
|
631
|
+
console.error('Generation failed:', error);
|
|
632
|
+
process.exit(1);
|
|
633
|
+
});
|
|
378
634
|
}
|
|
379
635
|
|
|
380
|
-
|
|
381
|
-
console.error('Generation failed:', error);
|
|
382
|
-
process.exit(1);
|
|
383
|
-
});
|
|
636
|
+
export { extractToolTokens, mapToolTokens, convertAgent, describeApplyTo, convertInstructionToSkill };
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { extractToolTokens, mapToolTokens, convertAgent, describeApplyTo, convertInstructionToSkill } from './generate-claude-assets.js';
|
|
8
|
+
import { MCP_SERVERS } from '../src/cli/utils/settings.js';
|
|
9
|
+
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const readInstructionFixture = (name) =>
|
|
12
|
+
readFileSync(join(__dirname, '..', 'assets', 'instructions', name, `${name}.instructions.md`), 'utf-8');
|
|
13
|
+
const descriptionLineOf = (skillMd) =>
|
|
14
|
+
skillMd.split('\n').find((l) => l.startsWith('description:'));
|
|
15
|
+
|
|
16
|
+
describe('extractToolTokens', () => {
|
|
17
|
+
it('parses a bracket-array tools list (unchanged behavior)', () => {
|
|
18
|
+
const fm = `name: x\ntools: ['clickup/*', 'search', "read", 'memory/*']\nagents: []`;
|
|
19
|
+
expect(extractToolTokens(fm)).toEqual(['clickup/*', 'search', 'read', 'memory/*']);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('parses a YAML dash-list tools block equivalently to the bracket form (DEFECT 1)', () => {
|
|
23
|
+
const dashList = [
|
|
24
|
+
'name: x',
|
|
25
|
+
'tools:',
|
|
26
|
+
' - search',
|
|
27
|
+
' - read',
|
|
28
|
+
' - edit',
|
|
29
|
+
' - execute',
|
|
30
|
+
' - agent',
|
|
31
|
+
' - todo',
|
|
32
|
+
" - 'vesper-indexing/*'",
|
|
33
|
+
" - 'memory/*'",
|
|
34
|
+
'agents: []',
|
|
35
|
+
].join('\n');
|
|
36
|
+
|
|
37
|
+
const bracket = "name: x\ntools: [search, read, edit, execute, agent, todo, 'vesper-indexing/*', 'memory/*']\nagents: []";
|
|
38
|
+
|
|
39
|
+
expect(extractToolTokens(dashList)).toEqual(extractToolTokens(bracket));
|
|
40
|
+
expect(extractToolTokens(dashList)).toContain('vesper-indexing/*');
|
|
41
|
+
expect(extractToolTokens(dashList)).toContain('memory/*');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('strips surrounding quotes on dash items', () => {
|
|
45
|
+
const fm = `tools:\n - 'memory/*'\n - "search"\nagents: []`;
|
|
46
|
+
expect(extractToolTokens(fm)).toEqual(['memory/*', 'search']);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('strips trailing inline # comments on dash items', () => {
|
|
50
|
+
const fm = `tools:\n - read # legacy\n - search # keep\nagents: []`;
|
|
51
|
+
expect(extractToolTokens(fm)).toEqual(['read', 'search']);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('tolerates mixed indentation across dash items', () => {
|
|
55
|
+
const fm = `tools:\n - read\n - search\n - edit\nnext: []`;
|
|
56
|
+
expect(extractToolTokens(fm)).toEqual(['read', 'search', 'edit']);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('returns [] for an empty tools list without consuming the next key', () => {
|
|
60
|
+
const fm = `tools:\nagents: []`;
|
|
61
|
+
expect(extractToolTokens(fm)).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('tolerates a trailing inline comment on the tools: header line itself', () => {
|
|
65
|
+
const fm = `tools: # see below\n - read\n - search\nagents: []`;
|
|
66
|
+
expect(extractToolTokens(fm)).toEqual(['read', 'search']);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('convertAgent (end-to-end frontmatter emission — locks DEFECT 1 at the layer that broke)', () => {
|
|
71
|
+
it('emits a real tools: line for a dash-list source with real tokens (the actual defect)', () => {
|
|
72
|
+
const src = [
|
|
73
|
+
'---',
|
|
74
|
+
"description: 'demo'",
|
|
75
|
+
"model: 'Claude Haiku 4.5'",
|
|
76
|
+
'name: Demo',
|
|
77
|
+
'tools:',
|
|
78
|
+
' - read',
|
|
79
|
+
' - edit',
|
|
80
|
+
" - 'memory/*'",
|
|
81
|
+
'agents: []',
|
|
82
|
+
'---',
|
|
83
|
+
'Body prose.',
|
|
84
|
+
].join('\n');
|
|
85
|
+
|
|
86
|
+
const out = convertAgent(src, 'demo');
|
|
87
|
+
// The regression lived in convertAgent's `tools ? ... : null` ternary: a dropped
|
|
88
|
+
// key here is exactly what produced inherit-all. Assert the key is present and populated.
|
|
89
|
+
const toolsLine = out.split('\n').find(l => l.startsWith('tools:'));
|
|
90
|
+
expect(toolsLine).toBeDefined();
|
|
91
|
+
expect(toolsLine).toContain('Read');
|
|
92
|
+
expect(toolsLine).toContain('Edit');
|
|
93
|
+
expect(toolsLine).toContain('mcp__memory__create_entities');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('omits the tools: key entirely when the source declares genuinely no tools', () => {
|
|
97
|
+
const src = [
|
|
98
|
+
'---',
|
|
99
|
+
"description: 'no tools'",
|
|
100
|
+
"model: 'Claude Haiku 4.5'",
|
|
101
|
+
'name: Bare',
|
|
102
|
+
'agents: []',
|
|
103
|
+
'---',
|
|
104
|
+
'Body prose.',
|
|
105
|
+
].join('\n');
|
|
106
|
+
|
|
107
|
+
const out = convertAgent(src, 'bare');
|
|
108
|
+
expect(out.split('\n').some(l => l.startsWith('tools:'))).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('mapToolTokens', () => {
|
|
113
|
+
it('expands clickup/* to all mcp__clickup__* names from the settings.js source of truth (DEFECT 2)', () => {
|
|
114
|
+
const expected = MCP_SERVERS.clickup.tools.map(t => `mcp__clickup__${t}`);
|
|
115
|
+
const result = mapToolTokens(['clickup/*']).split(', ');
|
|
116
|
+
expect(result).toEqual(expected);
|
|
117
|
+
expect(expected).toHaveLength(32);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('maps the namespaced agent/runSubagent token to the Agent tool (fail-closed capability restore)', () => {
|
|
121
|
+
expect(mapToolTokens(['agent/runSubagent']).split(', ')).toContain('Agent');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('does not warn on intentional DROP_TOKENS entries', () => {
|
|
125
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
126
|
+
const drops = ['read/problems', 'execute/runTask', 'web/githubRepo', 'findTestFiles', 'github'];
|
|
127
|
+
const result = mapToolTokens(drops);
|
|
128
|
+
expect(result).toBe('');
|
|
129
|
+
expect(warn).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('drops read/getTaskOutput silently (intentional Copilot-only drop, no warning)', () => {
|
|
133
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
134
|
+
const result = mapToolTokens(['read/getTaskOutput']);
|
|
135
|
+
expect(result).toBe('');
|
|
136
|
+
expect(warn).not.toHaveBeenCalled();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('warns (naming token + agent) on an unrecognized token', () => {
|
|
140
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
141
|
+
mapToolTokens(['totally/madeUpTool'], 'demo-agent');
|
|
142
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
143
|
+
const msg = warn.mock.calls[0][0];
|
|
144
|
+
expect(msg).toContain('totally/madeUpTool');
|
|
145
|
+
expect(msg).toContain('demo-agent');
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('describeApplyTo', () => {
|
|
150
|
+
it('translates a single extension glob to a language clause', () => {
|
|
151
|
+
expect(describeApplyTo('**/*.java', 'java')).toEqual({
|
|
152
|
+
clause: ' Use when working with Java files.',
|
|
153
|
+
unmapped: [],
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('dedupes and preserves first-seen order across a multi-extension list (reactjs)', () => {
|
|
158
|
+
expect(describeApplyTo('**/*.jsx, **/*.tsx, **/*.js, **/*.ts, **/*.css, **/*.scss', 'reactjs')).toEqual({
|
|
159
|
+
clause: ' Use when working with JSX, TSX, JavaScript, TypeScript, CSS, or SCSS files.',
|
|
160
|
+
unmapped: [],
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('maps a bare filename via its extension (no path component)', () => {
|
|
165
|
+
expect(describeApplyTo('vite.config.ts', 'tailwind-v4-vite')).toEqual({
|
|
166
|
+
clause: ' Use when working with TypeScript files.',
|
|
167
|
+
unmapped: [],
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('returns an empty clause for empty applyTo', () => {
|
|
172
|
+
expect(describeApplyTo('', 'anything')).toEqual({ clause: '', unmapped: [] });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('uses the GLOB_OVERRIDES phrase for a path-scoped hit', () => {
|
|
176
|
+
expect(describeApplyTo('**/.vesper/.tracking/changes/*.md', 'task-implementation')).toEqual({
|
|
177
|
+
clause: ' Use when working with task change-record files under .vesper/.tracking/changes/.',
|
|
178
|
+
unmapped: [],
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('reports a path-scoped miss as unmapped (LOUD, not silent) and omits it from the clause', () => {
|
|
183
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
184
|
+
const result = describeApplyTo('src/weird/**/*.md', 'fabricated');
|
|
185
|
+
expect(result.unmapped).toContain('src/weird/**/*.md');
|
|
186
|
+
expect(result.clause).toBe('');
|
|
187
|
+
expect(warn).toHaveBeenCalled();
|
|
188
|
+
expect(warn.mock.calls[0][0]).toContain('src/weird/**/*.md');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('reports an unknown extension as unmapped', () => {
|
|
192
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
193
|
+
const result = describeApplyTo('**/*.zig', 'fabricated');
|
|
194
|
+
expect(result.unmapped).toContain('**/*.zig');
|
|
195
|
+
expect(result.clause).toBe('');
|
|
196
|
+
expect(warn).toHaveBeenCalled();
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Caller-layer coverage: asserts on convertInstructionToSkill's ACTUAL emitted description, not just
|
|
201
|
+
// the helper in isolation. A regression that bypasses describeApplyTo and reintroduces a raw-glob paste
|
|
202
|
+
// directly in the caller (the helper-vs-caller gap that hid the tool-token defect) would pass every
|
|
203
|
+
// describeApplyTo unit test but fail here.
|
|
204
|
+
describe('convertInstructionToSkill (caller-layer output over real instruction fixtures)', () => {
|
|
205
|
+
it('emits a translated language clause and NO raw glob for an extension-scoped instruction (reactjs)', () => {
|
|
206
|
+
const out = convertInstructionToSkill(readInstructionFixture('reactjs'), 'reactjs');
|
|
207
|
+
const desc = descriptionLineOf(out);
|
|
208
|
+
expect(desc).toContain('Use when working with JSX, TSX, JavaScript, TypeScript, CSS, or SCSS files.');
|
|
209
|
+
expect(desc).not.toMatch(/\*\*\//); // no globstar
|
|
210
|
+
expect(desc).not.toMatch(/\*\./); // no `*.ext` raw glob
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('emits the GLOB_OVERRIDES phrase and NO raw glob for the path-scoped instruction (task-implementation)', () => {
|
|
214
|
+
const out = convertInstructionToSkill(readInstructionFixture('task-implementation'), 'task-implementation');
|
|
215
|
+
const desc = descriptionLineOf(out);
|
|
216
|
+
expect(desc).toContain('Use when working with task change-record files under .vesper/.tracking/changes/.');
|
|
217
|
+
expect(desc).not.toMatch(/\*\*\//);
|
|
218
|
+
expect(desc).not.toMatch(/\*\./);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('normalizes a base description lacking terminal punctuation so the clause does not run on (java)', () => {
|
|
222
|
+
const out = convertInstructionToSkill(readInstructionFixture('java'), 'java');
|
|
223
|
+
const desc = descriptionLineOf(out);
|
|
224
|
+
// Base is "Guidelines for building Java base applications" (no period) — must gain one at the seam.
|
|
225
|
+
expect(desc).toContain('Java base applications. Use when working with Java files.');
|
|
226
|
+
expect(desc).not.toContain('applications Use when'); // the run-on the fix removes
|
|
227
|
+
});
|
|
228
|
+
});
|
package/scripts/postinstall.js
CHANGED
|
@@ -21,7 +21,16 @@ const isDevEnvironment = existsSync(join(packageRoot, '.git'));
|
|
|
21
21
|
|
|
22
22
|
// Check if MCP server is already built
|
|
23
23
|
const mcpDistExists = existsSync(join(mcpDir, 'dist', 'index.js'));
|
|
24
|
-
|
|
24
|
+
|
|
25
|
+
// Integrity canary: presence of `node_modules` alone does NOT prove a healthy
|
|
26
|
+
// install — a corrupted tree missing a critical dep manifest crashes the server
|
|
27
|
+
// with ERR_MODULE_NOT_FOUND yet still has the directory. Check the manifests of
|
|
28
|
+
// the two direct runtime deps (`@modelcontextprotocol/sdk`, `zod`) instead, so a
|
|
29
|
+
// corrupt tree falls through to the `npm install` self-heal below. Same canary set
|
|
30
|
+
// as getMcpServerState() in src/cli/commands/mcp.js — keep them in sync.
|
|
31
|
+
const sdkManifest = join(mcpDir, 'node_modules', '@modelcontextprotocol', 'sdk', 'package.json');
|
|
32
|
+
const zodManifest = join(mcpDir, 'node_modules', 'zod', 'package.json');
|
|
33
|
+
const mcpDepsHealthy = existsSync(sdkManifest) && existsSync(zodManifest);
|
|
25
34
|
|
|
26
35
|
async function runCommand(command, args, cwd) {
|
|
27
36
|
return new Promise((resolve, reject) => {
|
|
@@ -45,7 +54,7 @@ async function runCommand(command, args, cwd) {
|
|
|
45
54
|
|
|
46
55
|
async function main() {
|
|
47
56
|
// Skip if already built (common during npm pack/publish)
|
|
48
|
-
if (mcpDistExists &&
|
|
57
|
+
if (mcpDistExists && mcpDepsHealthy) {
|
|
49
58
|
console.log('✓ vesper-indexing MCP server already built');
|
|
50
59
|
return;
|
|
51
60
|
}
|
|
@@ -63,8 +72,10 @@ async function main() {
|
|
|
63
72
|
try {
|
|
64
73
|
// Install dependencies. When prebuilt dist is shipped (the published
|
|
65
74
|
// package), skip devDependencies — no TypeScript build is needed, only the
|
|
66
|
-
// runtime deps
|
|
67
|
-
|
|
75
|
+
// runtime deps. The store uses the built-in node:sqlite module (migrated off
|
|
76
|
+
// better-sqlite3), so there is no native addon to compile here. `npm install`
|
|
77
|
+
// over a partially-corrupt tree self-heals by reinstalling missing packages.
|
|
78
|
+
if (!mcpDepsHealthy) {
|
|
68
79
|
console.log('→ Installing dependencies...');
|
|
69
80
|
const installArgs = ['install', '--no-audit', '--no-fund'];
|
|
70
81
|
if (mcpDistExists) installArgs.push('--omit=dev');
|