From c18c74a1341a3f8b114ab888fa77f546337d1876 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobi=20L=C3=BCtke?= Date: Sat, 16 May 2026 19:07:22 +0000 Subject: [PATCH] Serve QMD skill instructions from CLI --- CHANGELOG.md | 4 + package.json | 1 + src/cli/qmd.ts | 306 +++++++++++++++++++++++++++++++++++--- src/embedded-skills.ts | 22 --- test/cli.test.ts | 74 ++++++++- test/local-config.test.ts | 11 +- test/package.test.ts | 5 + 7 files changed, 374 insertions(+), 49 deletions(-) delete mode 100644 src/embedded-skills.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d1f26af..16cd7cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changes + +- Agent skills: add `qmd skills list|get|path` to serve version-matched runtime skill instructions from the installed CLI, and make `qmd skill install` write a stable discovery stub so installed agent skills do not go stale after QMD upgrades. + ### Fixes - GPU: add `QMD_FORCE_CPU=1` / `--no-gpu` to bypass CUDA/Vulkan/Metal probing entirely, and route native llama.cpp stdout noise to stderr so JSON output stays parseable during search/query commands. diff --git a/package.json b/package.json index 59a878a..7425650 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "files": [ "bin/", "dist/", + "skills/", "scripts/check-package-grammars.mjs", "LICENSE", "CHANGELOG.md" diff --git a/src/cli/qmd.ts b/src/cli/qmd.ts index a2791d2..25a2a0d 100755 --- a/src/cli/qmd.ts +++ b/src/cli/qmd.ts @@ -3,9 +3,9 @@ import type { Database } from "../db.js"; import fastGlob from "fast-glob"; import { execSync, spawn as nodeSpawn } from "child_process"; import { fileURLToPath } from "url"; -import { dirname, join as pathJoin, relative as relativePath, resolve as pathResolve } from "path"; +import { basename, dirname, join as pathJoin, relative as relativePath, resolve as pathResolve } from "path"; import { parseArgs } from "util"; -import { readFileSync, realpathSync, statSync, existsSync, unlinkSync, writeFileSync, openSync, closeSync, mkdirSync, lstatSync, rmSync, symlinkSync, readlinkSync } from "fs"; +import { readFileSync, readdirSync, realpathSync, statSync, existsSync, unlinkSync, writeFileSync, openSync, closeSync, mkdirSync, lstatSync, rmSync, symlinkSync, readlinkSync, copyFileSync } from "fs"; import { createInterface } from "readline/promises"; import { getPwd, @@ -104,7 +104,6 @@ import { getConfigPath, configExists, } from "../collections.js"; -import { getEmbeddedQmdSkillContent, getEmbeddedQmdSkillFiles } from "../embedded-skills.js"; // NOTE: enableProductionMode() is intentionally NOT called at module scope here. // Importing this module for its exports (e.g. buildEditorUri, termLink from @@ -2742,14 +2741,158 @@ function removePath(path: string): void { } } +type SkillInfo = { + name: string; + description: string; + dir: string; + hidden: boolean; +}; + +const SKILL_DIR = "skills"; + +function findPackageRoot(): string | null { + if (process.env.QMD_SKILLS_DIR) { + return null; + } + + const start = dirname(fileURLToPath(import.meta.url)); + let current = start; + while (true) { + if (existsSync(resolve(current, SKILL_DIR))) { + return current; + } + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + return null; +} + +function getSkillSearchDirs(_runtimeOnly = false): string[] { + if (process.env.QMD_SKILLS_DIR) { + return [process.env.QMD_SKILLS_DIR]; + } + + const root = findPackageRoot(); + if (!root) return []; + + const dir = resolve(root, SKILL_DIR); + return existsSync(dir) ? [dir] : []; +} + +function parseSkillFrontmatter(content: string): { name: string; description: string; hidden: boolean } | null { + const trimmed = content.trimStart(); + if (!trimmed.startsWith("---")) return null; + const end = trimmed.slice(3).indexOf("\n---"); + if (end < 0) return null; + + const frontmatter = trimmed.slice(3, 3 + end); + let name = ""; + let description = ""; + let hidden = false; + const lines = frontmatter.split(/\r?\n/); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + if (line.startsWith("name:")) { + name = line.slice("name:".length).trim(); + } else if (line.startsWith("description:")) { + const parts = [line.slice("description:".length).trim()]; + while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1]!)) { + i++; + parts.push(lines[i]!.trim()); + } + description = parts.join(" "); + } else if (line.startsWith("hidden:")) { + const value = line.slice("hidden:".length).trim().toLowerCase(); + hidden = value === "true" || value === "yes"; + } + } + + if (!name) return null; + return { name, description, hidden }; +} + +function discoverSkills(runtimeOnly = false): SkillInfo[] { + const skills: SkillInfo[] = []; + for (const dir of getSkillSearchDirs(runtimeOnly)) { + let entries: string[] = []; + try { + entries = readdirSync(dir); + } catch { + continue; + } + + for (const entry of entries) { + const skillDir = resolve(dir, entry); + const skillPath = resolve(skillDir, "SKILL.md"); + if (!existsSync(skillPath)) continue; + let content = ""; + try { + content = readFileSync(skillPath, "utf-8"); + } catch { + continue; + } + const parsed = parseSkillFrontmatter(content); + if (!parsed) continue; + skills.push({ ...parsed, dir: skillDir }); + } + } + return skills.sort((a, b) => a.name.localeCompare(b.name)); +} + +function findSkill(name: string, runtimeOnly = false): SkillInfo | null { + return discoverSkills(runtimeOnly).find((skill) => skill.name === name) ?? null; +} + +function readSkillContent(skill: SkillInfo): string { + return readFileSync(resolve(skill.dir, "SKILL.md"), "utf-8"); +} + +function collectSkillFiles(skill: SkillInfo): { relativePath: string; content: string }[] { + const files: { relativePath: string; content: string }[] = []; + for (const subdirName of ["references", "templates", "scripts"]) { + const subdir = resolve(skill.dir, subdirName); + if (!existsSync(subdir)) continue; + for (const entry of readdirSync(subdir).sort()) { + const filePath = resolve(subdir, entry); + try { + if (!statSync(filePath).isFile()) continue; + files.push({ relativePath: `${subdirName}/${basename(filePath)}`, content: readFileSync(filePath, "utf-8") }); + } catch { + // Ignore unreadable supplementary files. + } + } + } + return files; +} + function showSkill(): void { - console.log("QMD Skill (embedded)"); + const skill = findSkill("qmd"); + if (!skill) { + throw new Error("QMD skill not found. Reinstall qmd or set QMD_SKILLS_DIR."); + } + console.log("QMD Skill"); console.log(""); - const content = getEmbeddedQmdSkillContent(); + const content = readSkillContent(skill); process.stdout.write(content.endsWith("\n") ? content : content + "\n"); } -function writeEmbeddedSkill(targetDir: string, force: boolean): void { +function copyDirectoryContents(sourceDir: string, targetDir: string): void { + mkdirSync(targetDir, { recursive: true }); + for (const entry of readdirSync(sourceDir)) { + const sourcePath = resolve(sourceDir, entry); + const targetPath = resolve(targetDir, entry); + const stat = statSync(sourcePath); + if (stat.isDirectory()) { + copyDirectoryContents(sourcePath, targetPath); + } else if (stat.isFile()) { + copyFileSync(sourcePath, targetPath); + } + } +} + +function writeSkillInstall(targetDir: string, force: boolean): void { if (pathExists(targetDir)) { if (!force) { throw new Error(`Skill already exists: ${targetDir} (use --force to replace it)`); @@ -2757,12 +2900,121 @@ function writeEmbeddedSkill(targetDir: string, force: boolean): void { removePath(targetDir); } - mkdirSync(targetDir, { recursive: true }); - for (const file of getEmbeddedQmdSkillFiles()) { - const destination = resolve(targetDir, file.relativePath); - mkdirSync(dirname(destination), { recursive: true }); - writeFileSync(destination, file.content, "utf-8"); + const skill = findSkill("qmd"); + if (!skill) { + throw new Error("QMD skill not found. Reinstall qmd or set QMD_SKILLS_DIR."); } + + copyDirectoryContents(skill.dir, targetDir); +} + +function outputSkillsJson(payload: unknown): void { + console.log(JSON.stringify(payload)); +} + +function runSkillsCommand(args: string[], jsonMode: boolean, fullOption = false, allOption = false): void { + const subcommand = args[0] ?? "list"; + const runtimeSkills = () => discoverSkills(true).filter((skill) => !skill.hidden); + + switch (subcommand) { + case "list": { + const skills = runtimeSkills(); + if (jsonMode) { + outputSkillsJson({ success: true, data: skills.map(({ name, description }) => ({ name, description })) }); + return; + } + if (skills.length === 0) { + console.log("No skills found"); + return; + } + const maxName = Math.max(...skills.map((skill) => skill.name.length)); + for (const skill of skills) { + console.log(` ${skill.name.padEnd(maxName)} ${skill.description}`); + } + return; + } + + case "get": { + const full = fullOption || args.includes("--full"); + const getAll = allOption || args.includes("--all"); + const names = args.slice(1).filter((arg) => arg !== "--full" && arg !== "--all"); + const targets = getAll ? runtimeSkills() : names.map((name) => { + const skill = findSkill(name, true); + if (!skill) { + throw new Error(`Skill not found: ${name}`); + } + return skill; + }); + + if (targets.length === 0) { + throw new Error("No skill name provided. Usage: qmd skills get "); + } + + if (jsonMode) { + outputSkillsJson({ + success: true, + data: targets.map((skill) => ({ + name: skill.name, + content: readSkillContent(skill), + ...(full ? { files: collectSkillFiles(skill).map((file) => ({ path: file.relativePath, content: file.content })) } : {}), + })), + }); + return; + } + + targets.forEach((skill, index) => { + if (index > 0) console.log("\n---\n"); + const content = readSkillContent(skill); + process.stdout.write(content.endsWith("\n") ? content : content + "\n"); + if (full) { + for (const file of collectSkillFiles(skill)) { + console.log(`\n--- ${file.relativePath} ---\n`); + process.stdout.write(file.content.endsWith("\n") ? file.content : file.content + "\n"); + } + } + }); + return; + } + + case "path": { + const name = args[1]; + if (!name) { + const paths = getSkillSearchDirs(true); + if (jsonMode) outputSkillsJson({ success: true, data: { paths } }); + else paths.forEach((path) => console.log(path)); + return; + } + const skill = findSkill(name, true); + if (!skill) { + throw new Error(`Skill not found: ${name}`); + } + if (jsonMode) outputSkillsJson({ success: true, data: { name: skill.name, path: skill.dir } }); + else console.log(skill.dir); + return; + } + + case "help": { + showSkillsHelp(); + return; + } + + default: + throw new Error(`Unknown skills subcommand: ${subcommand}`); + } +} + +function showSkillsHelp(): void { + console.log("Usage: qmd skills [options]"); + console.log(""); + console.log("Commands:"); + console.log(" list List bundled runtime skills"); + console.log(" get Print a bundled runtime skill"); + console.log(" get --full Include references/templates/scripts"); + console.log(" get --all Print all bundled runtime skills"); + console.log(" path [name] Print runtime skill directory path(s)"); + console.log(""); + console.log("Options:"); + console.log(" --json Print structured JSON"); } function ensureClaudeSymlink(linkPath: string, targetDir: string, force: boolean): boolean { @@ -2822,7 +3074,7 @@ async function shouldCreateClaudeSymlink(linkPath: string, autoYes: boolean): Pr async function installSkill(globalInstall: boolean, force: boolean, autoYes: boolean): Promise { const installDir = getSkillInstallDir(globalInstall); - writeEmbeddedSkill(installDir, force); + writeSkillInstall(installDir, force); console.log(`✓ Installed QMD skill to ${installDir}`); const claudeLinkPath = getClaudeSkillLinkPath(globalInstall); @@ -2851,7 +3103,8 @@ function showHelp(): void { console.log(" qmd vsearch - Vector similarity only"); console.log(" qmd get [:line] [-l N] - Show a single document, optional line slice"); console.log(" qmd multi-get - Batch fetch via glob or comma-separated list"); - console.log(" qmd skill show/install - Show or install the packaged QMD skill"); + console.log(" qmd skills list/get/path - List and retrieve bundled runtime skills"); + console.log(" qmd skill show/install - Show or install the QMD skill"); console.log(" qmd mcp - Start the MCP server (stdio transport for AI agents)"); console.log(" qmd bench - Run search quality benchmarks against a fixture file"); console.log(""); @@ -2904,6 +3157,7 @@ function showHelp(): void { console.log(""); console.log("AI agents & integrations:"); console.log(" - Run `qmd mcp` to expose the MCP server (stdio) to agents/IDEs."); + console.log(" - Run `qmd skills get qmd --full` for version-matched agent instructions."); console.log(" - `qmd skill install` installs the QMD skill into ./.agents/skills/qmd."); console.log(" - Use `qmd skill install --global` for ~/.agents/skills/qmd."); console.log(" - `qmd --skill` is kept as an alias for `qmd skill show`."); @@ -2982,8 +3236,8 @@ if (isMain) { console.log("Usage: qmd skill [options]"); console.log(""); console.log("Commands:"); - console.log(" show Print the packaged QMD skill"); - console.log(" install Install into ./.agents/skills/qmd"); + console.log(" show Print the QMD skill"); + console.log(" install Install QMD skill into ./.agents/skills/qmd"); console.log(""); console.log("Options:"); console.log(" --global Install into ~/.agents/skills/qmd"); @@ -3432,6 +3686,24 @@ if (isMain) { break; } + case "skills": { + try { + if (cli.values.help || cli.args[0] === "help") { + showSkillsHelp(); + } else { + runSkillsCommand(cli.args, Boolean(cli.values.json), Boolean(cli.values.full), Boolean(cli.values.all)); + } + } catch (error) { + if (cli.values.json) { + outputSkillsJson({ success: false, error: error instanceof Error ? error.message : String(error) }); + } else { + console.error(error instanceof Error ? error.message : String(error)); + } + process.exit(1); + } + break; + } + case "skill": { const subcommand = cli.args[0]; switch (subcommand) { @@ -3455,8 +3727,8 @@ if (isMain) { console.log("Usage: qmd skill [options]"); console.log(""); console.log("Commands:"); - console.log(" show Print the packaged QMD skill"); - console.log(" install Install into ./.agents/skills/qmd"); + console.log(" show Print the QMD skill"); + console.log(" install Install QMD skill into ./.agents/skills/qmd"); console.log(""); console.log("Options:"); console.log(" --global Install into ~/.agents/skills/qmd"); diff --git a/src/embedded-skills.ts b/src/embedded-skills.ts deleted file mode 100644 index 266ec86..0000000 --- a/src/embedded-skills.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Generated from skills/qmd source files. Keep this in sync when updating the packaged skill. - -export type EmbeddedSkillFile = { - relativePath: string; - content: string; -}; - -const EMBEDDED_QMD_SKILL_BASE64: Record = { - "SKILL.md": "LS0tCm5hbWU6IHFtZApkZXNjcmlwdGlvbjogU2VhcmNoIG1hcmtkb3duIGtub3dsZWRnZSBiYXNlcywgbm90ZXMsIGFuZCBkb2N1bWVudGF0aW9uIHVzaW5nIFFNRC4gVXNlIHdoZW4gdXNlcnMgYXNrIHRvIHNlYXJjaCBub3RlcywgZmluZCBkb2N1bWVudHMsIG9yIGxvb2sgdXAgaW5mb3JtYXRpb24uCmxpY2Vuc2U6IE1JVApjb21wYXRpYmlsaXR5OiBSZXF1aXJlcyBxbWQgQ0xJIG9yIE1DUCBzZXJ2ZXIuIEluc3RhbGwgdmlhIGBucG0gaW5zdGFsbCAtZyBAdG9iaWx1L3FtZGAuCm1ldGFkYXRhOgogIGF1dGhvcjogdG9iaQogIHZlcnNpb246ICIyLjAuMCIKYWxsb3dlZC10b29sczogQmFzaChxbWQ6KiksIG1jcF9fcW1kX18qCi0tLQoKIyBRTUQgLSBRdWljayBNYXJrZG93biBTZWFyY2gKCkxvY2FsIHNlYXJjaCBlbmdpbmUgZm9yIG1hcmtkb3duIGNvbnRlbnQuCgojIyBTdGF0dXMKCiFgcW1kIHN0YXR1cyAyPi9kZXYvbnVsbCB8fCBlY2hvICJOb3QgaW5zdGFsbGVkOiBucG0gaW5zdGFsbCAtZyBAdG9iaWx1L3FtZCJgCgojIyBNQ1A6IGBxdWVyeWAKCmBgYGpzb24KewogICJzZWFyY2hlcyI6IFsKICAgIHsgInR5cGUiOiAibGV4IiwgInF1ZXJ5IjogIkNBUCB0aGVvcmVtIGNvbnNpc3RlbmN5IiB9LAogICAgeyAidHlwZSI6ICJ2ZWMiLCAicXVlcnkiOiAidHJhZGVvZmYgYmV0d2VlbiBjb25zaXN0ZW5jeSBhbmQgYXZhaWxhYmlsaXR5IiB9CiAgXSwKICAiY29sbGVjdGlvbnMiOiBbImRvY3MiXSwKICAibGltaXQiOiAxMAp9CmBgYAoKIyMjIFF1ZXJ5IFR5cGVzCgp8IFR5cGUgfCBNZXRob2QgfCBJbnB1dCB8CnwtLS0tLS18LS0tLS0tLS18LS0tLS0tLXwKfCBgbGV4YCB8IEJNMjUgfCBLZXl3b3JkcyDigJQgZXhhY3QgdGVybXMsIG5hbWVzLCBjb2RlIHwKfCBgdmVjYCB8IFZlY3RvciB8IFF1ZXN0aW9uIOKAlCBuYXR1cmFsIGxhbmd1YWdlIHwKfCBgaHlkZWAgfCBWZWN0b3IgfCBBbnN3ZXIg4oCUIGh5cG90aGV0aWNhbCByZXN1bHQgKDUwLTEwMCB3b3JkcykgfAoKIyMjIFdyaXRpbmcgR29vZCBRdWVyaWVzCgoqKmxleCAoa2V5d29yZCkqKgotIDItNSB0ZXJtcywgbm8gZmlsbGVyIHdvcmRzCi0gRXhhY3QgcGhyYXNlOiBgImNvbm5lY3Rpb24gcG9vbCJgIChxdW90ZWQpCi0gRXhjbHVkZSB0ZXJtczogYHBlcmZvcm1hbmNlIC1zcG9ydHNgIChtaW51cyBwcmVmaXgpCi0gQ29kZSBpZGVudGlmaWVycyB3b3JrOiBgaGFuZGxlRXJyb3IgYXN5bmNgCgoqKnZlYyAoc2VtYW50aWMpKioKLSBGdWxsIG5hdHVyYWwgbGFuZ3VhZ2UgcXVlc3Rpb24KLSBCZSBzcGVjaWZpYzogYCJob3cgZG9lcyB0aGUgcmF0ZSBsaW1pdGVyIGhhbmRsZSBidXJzdCB0cmFmZmljImAKLSBJbmNsdWRlIGNvbnRleHQ6IGAiaW4gdGhlIHBheW1lbnQgc2VydmljZSwgaG93IGFyZSByZWZ1bmRzIHByb2Nlc3NlZCJgCgoqKmh5ZGUgKGh5cG90aGV0aWNhbCBkb2N1bWVudCkqKgotIFdyaXRlIDUwLTEwMCB3b3JkcyBvZiB3aGF0IHRoZSAqYW5zd2VyKiBsb29rcyBsaWtlCi0gVXNlIHRoZSB2b2NhYnVsYXJ5IHlvdSBleHBlY3QgaW4gdGhlIHJlc3VsdAoKKipleHBhbmQgKGF1dG8tZXhwYW5kKSoqCi0gVXNlIGEgc2luZ2xlLWxpbmUgcXVlcnkgKGltcGxpY2l0KSBvciBgZXhwYW5kOiBxdWVzdGlvbmAgb24gaXRzIG93biBsaW5lCi0gTGV0cyB0aGUgbG9jYWwgTExNIGdlbmVyYXRlIGxleC92ZWMvaHlkZSB2YXJpYXRpb25zCi0gRG8gbm90IG1peCBgZXhwYW5kOmAgd2l0aCBvdGhlciB0eXBlZCBsaW5lcyDigJQgaXQncyBlaXRoZXIgYSBzdGFuZGFsb25lIGV4cGFuZCBxdWVyeSBvciBhIGZ1bGwgcXVlcnkgZG9jdW1lbnQKCiMjIyBJbnRlbnQgKERpc2FtYmlndWF0aW9uKQoKV2hlbiBhIHF1ZXJ5IHRlcm0gaXMgYW1iaWd1b3VzLCBhZGQgYGludGVudGAgdG8gc3RlZXIgcmVzdWx0czoKCmBgYGpzb24KewogICJzZWFyY2hlcyI6IFsKICAgIHsgInR5cGUiOiAibGV4IiwgInF1ZXJ5IjogInBlcmZvcm1hbmNlIiB9CiAgXSwKICAiaW50ZW50IjogIndlYiBwYWdlIGxvYWQgdGltZXMgYW5kIENvcmUgV2ViIFZpdGFscyIKfQpgYGAKCkludGVudCBhZmZlY3RzIGV4cGFuc2lvbiwgcmVyYW5raW5nLCBjaHVuayBzZWxlY3Rpb24sIGFuZCBzbmlwcGV0IGV4dHJhY3Rpb24uIEl0IGRvZXMgbm90IHNlYXJjaCBvbiBpdHMgb3duIOKAlCBpdCdzIGEgc3RlZXJpbmcgc2lnbmFsIHRoYXQgZGlzYW1iaWd1YXRlcyBxdWVyaWVzIGxpa2UgInBlcmZvcm1hbmNlIiAod2ViLXBlcmYgdnMgdGVhbSBoZWFsdGggdnMgZml0bmVzcykuCgojIyMgQ29tYmluaW5nIFR5cGVzCgp8IEdvYWwgfCBBcHByb2FjaCB8CnwtLS0tLS18LS0tLS0tLS0tLXwKfCBLbm93IGV4YWN0IHRlcm1zIHwgYGxleGAgb25seSB8CnwgRG9uJ3Qga25vdyB2b2NhYnVsYXJ5IHwgVXNlIGEgc2luZ2xlLWxpbmUgcXVlcnkgKGltcGxpY2l0IGBleHBhbmQ6YCkgb3IgYHZlY2AgfAp8IEJlc3QgcmVjYWxsIHwgYGxleGAgKyBgdmVjYCB8CnwgQ29tcGxleCB0b3BpYyB8IGBsZXhgICsgYHZlY2AgKyBgaHlkZWAgfAp8IEFtYmlndW91cyBxdWVyeSB8IEFkZCBgaW50ZW50YCB0byBhbnkgY29tYmluYXRpb24gYWJvdmUgfAoKRmlyc3QgcXVlcnkgZ2V0cyAyeCB3ZWlnaHQgaW4gZnVzaW9uIOKAlCBwdXQgeW91ciBiZXN0IGd1ZXNzIGZpcnN0LgoKIyMjIExleCBRdWVyeSBTeW50YXgKCnwgU3ludGF4IHwgTWVhbmluZyB8IEV4YW1wbGUgfAp8LS0tLS0tLS18LS0tLS0tLS0tfC0tLS0tLS0tLXwKfCBgdGVybWAgfCBQcmVmaXggbWF0Y2ggfCBgcGVyZmAgbWF0Y2hlcyAicGVyZm9ybWFuY2UiIHwKfCBgInBocmFzZSJgIHwgRXhhY3QgcGhyYXNlIHwgYCJyYXRlIGxpbWl0ZXIiYCB8CnwgYC10ZXJtYCB8IEV4Y2x1ZGUgfCBgcGVyZm9ybWFuY2UgLXNwb3J0c2AgfAoKTm90ZTogYC10ZXJtYCBvbmx5IHdvcmtzIGluIGxleCBxdWVyaWVzLCBub3QgdmVjL2h5ZGUuCgojIyMgQ29sbGVjdGlvbiBGaWx0ZXJpbmcKCmBgYGpzb24KeyAiY29sbGVjdGlvbnMiOiBbImRvY3MiXSB9ICAgICAgICAgICAgICAvLyBTaW5nbGUKeyAiY29sbGVjdGlvbnMiOiBbImRvY3MiLCAibm90ZXMiXSB9ICAgICAvLyBNdWx0aXBsZSAoT1IpCmBgYAoKT21pdCB0byBzZWFyY2ggYWxsIGNvbGxlY3Rpb25zLgoKIyMgT3RoZXIgTUNQIFRvb2xzCgp8IFRvb2wgfCBVc2UgfAp8LS0tLS0tfC0tLS0tfAp8IGBnZXRgIHwgUmV0cmlldmUgZG9jIGJ5IHBhdGggb3IgYCNkb2NpZGAgfAp8IGBtdWx0aV9nZXRgIHwgUmV0cmlldmUgbXVsdGlwbGUgYnkgZ2xvYi9saXN0IHwKfCBgc3RhdHVzYCB8IENvbGxlY3Rpb25zIGFuZCBoZWFsdGggfAoKIyMgQ0xJCgpgYGBiYXNoCnFtZCBxdWVyeSAicXVlc3Rpb24iICAgICAgICAgICAgICAjIEF1dG8tZXhwYW5kICsgcmVyYW5rCnFtZCBxdWVyeSAkJ2xleDogWFxudmVjOiBZJyAgICAgICAjIFN0cnVjdHVyZWQKcW1kIHF1ZXJ5ICQnZXhwYW5kOiBxdWVzdGlvbicgICAgICMgRXhwbGljaXQgZXhwYW5kCnFtZCBxdWVyeSAtLWpzb24gLS1leHBsYWluICJxIiAgICAjIFNob3cgc2NvcmUgdHJhY2VzIChSUkYgKyByZXJhbmsgYmxlbmQpCnFtZCBzZWFyY2ggImtleXdvcmRzIiAgICAgICAgICAgICAjIEJNMjUgb25seSAobm8gTExNKQpxbWQgZ2V0ICIjYWJjMTIzIiAgICAgICAgICAgICAgICAgIyBCeSBkb2NpZApxbWQgbXVsdGktZ2V0ICJqb3VybmFscy8yMDI2LSoubWQiIC1sIDQwICAjIEJhdGNoIHB1bGwgc25pcHBldHMgYnkgZ2xvYgpxbWQgbXVsdGktZ2V0IG5vdGVzL2Zvby5tZCxub3Rlcy9iYXIubWQgICAjIENvbW1hLXNlcGFyYXRlZCBsaXN0LCBwcmVzZXJ2ZXMgb3JkZXIKYGBgCgojIyBIVFRQIEFQSQoKYGBgYmFzaApjdXJsIC1YIFBPU1QgaHR0cDovL2xvY2FsaG9zdDo4MTgxL3F1ZXJ5IFwKICAtSCAiQ29udGVudC1UeXBlOiBhcHBsaWNhdGlvbi9qc29uIiBcCiAgLWQgJ3sic2VhcmNoZXMiOiBbeyJ0eXBlIjogImxleCIsICJxdWVyeSI6ICJ0ZXN0In1dfScKYGBgCgojIyBTZXR1cAoKYGBgYmFzaApucG0gaW5zdGFsbCAtZyBAdG9iaWx1L3FtZApxbWQgY29sbGVjdGlvbiBhZGQgfi9ub3RlcyAtLW5hbWUgbm90ZXMKcW1kIGVtYmVkCmBgYAo=", - "references/mcp-setup.md": "IyBRTUQgTUNQIFNlcnZlciBTZXR1cAoKIyMgSW5zdGFsbAoKYGBgYmFzaApucG0gaW5zdGFsbCAtZyBAdG9iaWx1L3FtZApxbWQgY29sbGVjdGlvbiBhZGQgfi9wYXRoL3RvL21hcmtkb3duIC0tbmFtZSBteWtub3dsZWRnZQpxbWQgZW1iZWQKYGBgCgojIyBDb25maWd1cmUgTUNQIENsaWVudAoKKipDbGF1ZGUgQ29kZSoqIChgfi8uY2xhdWRlL3NldHRpbmdzLmpzb25gKToKYGBganNvbgp7CiAgIm1jcFNlcnZlcnMiOiB7CiAgICAicW1kIjogeyAiY29tbWFuZCI6ICJxbWQiLCAiYXJncyI6IFsibWNwIl0gfQogIH0KfQpgYGAKCioqQ2xhdWRlIERlc2t0b3AqKiAoYH4vTGlicmFyeS9BcHBsaWNhdGlvbiBTdXBwb3J0L0NsYXVkZS9jbGF1ZGVfZGVza3RvcF9jb25maWcuanNvbmApOgpgYGBqc29uCnsKICAibWNwU2VydmVycyI6IHsKICAgICJxbWQiOiB7ICJjb21tYW5kIjogInFtZCIsICJhcmdzIjogWyJtY3AiXSB9CiAgfQp9CmBgYAoKKipPcGVuQ2xhdyoqIChgfi8ub3BlbmNsYXcvb3BlbmNsYXcuanNvbmApOgpgYGBqc29uCnsKICAibWNwIjogewogICAgInNlcnZlcnMiOiB7CiAgICAgICJxbWQiOiB7ICJjb21tYW5kIjogInFtZCIsICJhcmdzIjogWyJtY3AiXSB9CiAgICB9CiAgfQp9CmBgYAoKIyMgSFRUUCBNb2RlCgpgYGBiYXNoCnFtZCBtY3AgLS1odHRwICAgICAgICAgICAgICAjIFBvcnQgODE4MQpxbWQgbWNwIC0taHR0cCAtLWRhZW1vbiAgICAgIyBCYWNrZ3JvdW5kCnFtZCBtY3Agc3RvcCAgICAgICAgICAgICAgICAjIFN0b3AgZGFlbW9uCmBgYAoKIyMgVG9vbHMKCiMjIyBzdHJ1Y3R1cmVkX3NlYXJjaAoKU2VhcmNoIHdpdGggcHJlLWV4cGFuZGVkIHF1ZXJpZXMuCgpgYGBqc29uCnsKICAic2VhcmNoZXMiOiBbCiAgICB7ICJ0eXBlIjogImxleCIsICJxdWVyeSI6ICJrZXl3b3JkIHBocmFzZXMiIH0sCiAgICB7ICJ0eXBlIjogInZlYyIsICJxdWVyeSI6ICJuYXR1cmFsIGxhbmd1YWdlIHF1ZXN0aW9uIiB9LAogICAgeyAidHlwZSI6ICJoeWRlIiwgInF1ZXJ5IjogImh5cG90aGV0aWNhbCBhbnN3ZXIgcGFzc2FnZS4uLiIgfQogIF0sCiAgImxpbWl0IjogMTAsCiAgImNvbGxlY3Rpb24iOiAib3B0aW9uYWwiLAogICJtaW5TY29yZSI6IDAuMAp9CmBgYAoKfCBUeXBlIHwgTWV0aG9kIHwgSW5wdXQgfAp8LS0tLS0tfC0tLS0tLS0tfC0tLS0tLS18CnwgYGxleGAgfCBCTTI1IHwgS2V5d29yZHMgKDItNSB0ZXJtcykgfAp8IGB2ZWNgIHwgVmVjdG9yIHwgUXVlc3Rpb24gfAp8IGBoeWRlYCB8IFZlY3RvciB8IEFuc3dlciBwYXNzYWdlICg1MC0xMDAgd29yZHMpIHwKCiMjIyBnZXQKClJldHJpZXZlIGRvY3VtZW50IGJ5IHBhdGggb3IgYCNkb2NpZGAuCgp8IFBhcmFtIHwgVHlwZSB8IERlc2NyaXB0aW9uIHwKfC0tLS0tLS18LS0tLS0tfC0tLS0tLS0tLS0tLS18CnwgYHBhdGhgIHwgc3RyaW5nIHwgRmlsZSBwYXRoIG9yIGAjZG9jaWRgIHwKfCBgZnVsbGAgfCBib29sPyB8IFJldHVybiBmdWxsIGNvbnRlbnQgfAp8IGBsaW5lTnVtYmVyc2AgfCBib29sPyB8IEFkZCBsaW5lIG51bWJlcnMgfAoKIyMjIG11bHRpX2dldAoKUmV0cmlldmUgbXVsdGlwbGUgZG9jdW1lbnRzLgoKfCBQYXJhbSB8IFR5cGUgfCBEZXNjcmlwdGlvbiB8CnwtLS0tLS0tfC0tLS0tLXwtLS0tLS0tLS0tLS0tfAp8IGBwYXR0ZXJuYCB8IHN0cmluZyB8IEdsb2Igb3IgY29tbWEtc2VwYXJhdGVkIGxpc3QgfAp8IGBtYXhCeXRlc2AgfCBudW1iZXI/IHwgU2tpcCBsYXJnZSBmaWxlcyAoZGVmYXVsdCAxMEtCKSB8CgojIyMgc3RhdHVzCgpJbmRleCBoZWFsdGggYW5kIGNvbGxlY3Rpb25zLiBObyBwYXJhbXMuCgojIyBUcm91Ymxlc2hvb3RpbmcKCi0gKipOb3Qgc3RhcnRpbmcqKjogYHdoaWNoIHFtZGAsIGBxbWQgbWNwYCBtYW51YWxseQotICoqTm8gcmVzdWx0cyoqOiBgcW1kIGNvbGxlY3Rpb24gbGlzdGAsIGBxbWQgZW1iZWRgCi0gKipTbG93IGZpcnN0IHNlYXJjaCoqOiBOb3JtYWwsIG1vZGVscyBsb2FkaW5nICh+M0dCKQo=" -}; - -export function getEmbeddedQmdSkillFiles(): EmbeddedSkillFile[] { - return Object.entries(EMBEDDED_QMD_SKILL_BASE64).map(([relativePath, encoded]) => ({ - relativePath, - content: Buffer.from(encoded, 'base64').toString('utf8'), - })); -} - -export function getEmbeddedQmdSkillContent(): string { - return Buffer.from(EMBEDDED_QMD_SKILL_BASE64["SKILL.md"]!, "base64").toString("utf8"); -} diff --git a/test/cli.test.ts b/test/cli.test.ts index 08fbeb2..1b551f2 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -244,6 +244,69 @@ describe("CLI Help", () => { }); }); + + +describe("CLI Skills", () => { + test("lists bundled runtime skills", async () => { + const { stdout, stderr, exitCode } = await runQmd(["skills", "list"]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + expect(stdout).toContain("qmd"); + expect(stdout).toContain("Search markdown knowledge bases"); + }); + + test("gets version-matched runtime skill content", async () => { + const { stdout, stderr, exitCode } = await runQmd(["skills", "get", "qmd"]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + expect(stdout).toContain("# QMD - Quick Markdown Search"); + expect(stdout).toContain("## MCP: `query`"); + expect(stdout).not.toContain("This file is a discovery stub"); + }); + + test("gets runtime skill with supplementary references", async () => { + const { stdout, stderr, exitCode } = await runQmd(["skills", "get", "qmd", "--full"]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + expect(stdout).toContain("# QMD - Quick Markdown Search"); + expect(stdout).toContain("--- references/mcp-setup.md ---"); + expect(stdout).toContain("# QMD MCP Server Setup"); + }); + + test("prints canonical repository skill path", async () => { + const { stdout, stderr, exitCode } = await runQmd(["skills", "path", "qmd"]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + expect(stdout.trim()).toMatch(/skills\/qmd$/); + }); + + test("legacy skill show prints the canonical skill", async () => { + const { stdout, stderr, exitCode } = await runQmd(["skill", "show"]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + expect(stdout).toContain("# QMD - Quick Markdown Search"); + expect(stdout).toContain("## MCP: `query`"); + expect(stdout).not.toContain("This file is a discovery stub"); + }); + + test("legacy skill install writes the canonical skill", async () => { + const installDir = join(testDir, "skill-install-target"); + await mkdir(installDir, { recursive: true }); + + const { stdout, stderr, exitCode } = await runQmd(["skill", "install", "--yes"], { cwd: installDir }); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + expect(stdout).toContain("Installed QMD skill"); + + const installedSkillDir = join(installDir, ".agents", "skills", "qmd"); + const installed = readFileSync(join(installedSkillDir, "SKILL.md"), "utf8"); + expect(installed).toContain("# QMD - Quick Markdown Search"); + expect(installed).toContain("## MCP: `query`"); + expect(installed).not.toContain("This file is a discovery stub"); + expect(readFileSync(join(installedSkillDir, "references", "mcp-setup.md"), "utf8")).toContain("# QMD MCP Server Setup"); + }); +}); + describe("CLI Embed", () => { test("prefers QMD_EMBED_MODEL for qmd embed", () => { const prev = process.env.QMD_EMBED_MODEL; @@ -286,7 +349,7 @@ describe("CLI Skill Commands", () => { test("shows embedded skill with --skill alias", async () => { const { stdout, exitCode } = await runQmd(["--skill"]); expect(exitCode).toBe(0); - expect(stdout).toContain("QMD Skill (embedded)"); + expect(stdout).toContain("QMD Skill"); expect(stdout).toContain("name: qmd"); expect(stdout).toContain("allowed-tools: Bash(qmd:*), mcp__qmd__*"); }); @@ -307,8 +370,7 @@ describe("CLI Skill Commands", () => { expect(exitCode).toBe(0); const skillDir = join(projectDir, ".agents", "skills", "qmd"); - expect(readFileSync(join(skillDir, "SKILL.md"), "utf-8")).toContain("name: qmd"); - expect(readFileSync(join(skillDir, "references", "mcp-setup.md"), "utf-8")).toContain("Claude Code"); + expect(readFileSync(join(skillDir, "SKILL.md"), "utf-8")).toContain("# QMD - Quick Markdown Search"); expect(existsSync(join(projectDir, ".claude", "skills", "qmd"))).toBe(false); expect(stdout).toContain(`✓ Installed QMD skill to ${skillDir}`); expect(stdout).toContain("Tip: create a Claude symlink manually"); @@ -326,9 +388,9 @@ describe("CLI Skill Commands", () => { const skillDir = join(fakeHome, ".agents", "skills", "qmd"); const claudeLink = join(fakeHome, ".claude", "skills", "qmd"); - expect(readFileSync(join(skillDir, "SKILL.md"), "utf-8")).toContain("name: qmd"); + expect(readFileSync(join(skillDir, "SKILL.md"), "utf-8")).toContain("# QMD - Quick Markdown Search"); expect(lstatSync(claudeLink).isSymbolicLink()).toBe(true); - expect(readFileSync(join(claudeLink, "SKILL.md"), "utf-8")).toContain("name: qmd"); + expect(readFileSync(join(claudeLink, "SKILL.md"), "utf-8")).toContain("# QMD - Quick Markdown Search"); expect(stdout).toContain(`✓ Installed QMD skill to ${skillDir}`); expect(stdout).toContain(`✓ Linked Claude skill at ${claudeLink}`); }); @@ -346,7 +408,7 @@ describe("CLI Skill Commands", () => { const skillDir = join(fakeHome, ".agents", "skills", "qmd"); expect(lstatSync(skillDir).isSymbolicLink()).toBe(false); - expect(readFileSync(join(skillDir, "SKILL.md"), "utf-8")).toContain("name: qmd"); + expect(readFileSync(join(skillDir, "SKILL.md"), "utf-8")).toContain("# QMD - Quick Markdown Search"); expect(stdout).toContain(`✓ Claude already sees the skill via ${join(fakeHome, ".claude", "skills")}`); }); diff --git a/test/local-config.test.ts b/test/local-config.test.ts index 8e99b55..ef9af72 100644 --- a/test/local-config.test.ts +++ b/test/local-config.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -59,7 +59,9 @@ describe("local .qmd project config", () => { writeFileSync(join(root, ".qmd", "index.yaml"), `collections:\n docs:\n path: ${JSON.stringify(join(root, "docs"))}\n pattern: "**/*.md"\n context:\n /: Local test docs\n`); const home = join(root, "home"); - const output = execFileSync("bun", [join(process.cwd(), "src/cli/qmd.ts"), "status"], { + const tsxBin = join(process.cwd(), "node_modules", ".bin", "tsx"); + const runner = existsSync(tsxBin) ? tsxBin : "bun"; + const output = execFileSync(runner, [join(process.cwd(), "src/cli/qmd.ts"), "status"], { cwd: root, encoding: "utf-8", env: { @@ -70,9 +72,10 @@ describe("local .qmd project config", () => { }, }); - expect(output).toContain(`Index: ${join(root, ".qmd", "index.sqlite")}`); + const localIndex = join(root, ".qmd", "index.sqlite"); + expect(output).toContain(`Index: ${realpathSync(localIndex)}`); expect(output).toContain("docs (qmd://docs/)"); - expect(existsSync(join(root, ".qmd", "index.sqlite"))).toBe(true); + expect(existsSync(localIndex)).toBe(true); expect(existsSync(join(home, ".cache", "qmd", "index.sqlite"))).toBe(false); }); }); diff --git a/test/package.test.ts b/test/package.test.ts index 018087d..7ba41ac 100644 --- a/test/package.test.ts +++ b/test/package.test.ts @@ -18,6 +18,11 @@ describe("package grammar distribution", () => { expect(String(pkg.scripts["smoke:package-grammars"])).toContain("check-package-grammars"); expect(pkg.files, "published package files").toContain("scripts/check-package-grammars.mjs"); + expect(pkg.files, "published package files").toContain("skills/"); + const qmdSkill = readFileSync(new URL("skills/qmd/SKILL.md", root), "utf8"); + expect(qmdSkill).toContain("# QMD - Quick Markdown Search"); + expect(qmdSkill).toContain("## MCP: `query`"); + expect(qmdSkill).not.toContain("This file is a discovery stub"); const scriptPath = join(root.pathname, "scripts", "check-package-grammars.mjs"); const script = readFileSync(scriptPath, "utf8");