diff --git a/src/cli/qmd.ts b/src/cli/qmd.ts index 7ee82cc..52a076d 100755 --- a/src/cli/qmd.ts +++ b/src/cli/qmd.ts @@ -3,9 +3,10 @@ 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 } from "path"; +import { dirname, join as pathJoin, relative as relativePath } from "path"; import { parseArgs } from "util"; -import { readFileSync, realpathSync, statSync, existsSync, unlinkSync, writeFileSync, openSync, closeSync, mkdirSync } from "fs"; +import { readFileSync, realpathSync, statSync, existsSync, unlinkSync, writeFileSync, openSync, closeSync, mkdirSync, lstatSync, rmSync, symlinkSync, readlinkSync } from "fs"; +import { createInterface } from "readline/promises"; import { getPwd, getRealPath, @@ -95,6 +96,7 @@ import { setConfigIndexName, loadConfig, } from "../collections.js"; +import { getEmbeddedQmdSkillContent, getEmbeddedQmdSkillFiles } from "../embedded-skills.js"; // Enable production mode - allows using default database path // Tests must set INDEX_PATH or use createStore() with explicit path @@ -2313,6 +2315,8 @@ function parseCLI() { help: { type: "boolean", short: "h" }, version: { type: "boolean", short: "v" }, skill: { type: "boolean" }, + global: { type: "boolean" }, + yes: { type: "boolean" }, // Search options n: { type: "string" }, "min-score": { type: "string" }, @@ -2392,22 +2396,130 @@ function parseCLI() { }; } +function getSkillInstallDir(globalInstall: boolean): string { + return globalInstall + ? resolve(homedir(), ".agents", "skills", "qmd") + : resolve(getPwd(), ".agents", "skills", "qmd"); +} + +function getClaudeSkillLinkPath(globalInstall: boolean): string { + return globalInstall + ? resolve(homedir(), ".claude", "skills", "qmd") + : resolve(getPwd(), ".claude", "skills", "qmd"); +} + +function pathExists(path: string): boolean { + try { + lstatSync(path); + return true; + } catch { + return false; + } +} + +function removePath(path: string): void { + const stat = lstatSync(path); + if (stat.isDirectory() && !stat.isSymbolicLink()) { + rmSync(path, { recursive: true, force: true }); + } else { + unlinkSync(path); + } +} + function showSkill(): void { - const scriptDir = dirname(fileURLToPath(import.meta.url)); - const relativePath = pathJoin("skills", "qmd", "SKILL.md"); - const skillPath = pathJoin(scriptDir, "..", "..", relativePath); - - console.log(`QMD Skill (${relativePath})`); - console.log(`Location: ${skillPath}`); + console.log("QMD Skill (embedded)"); console.log(""); + const content = getEmbeddedQmdSkillContent(); + process.stdout.write(content.endsWith("\n") ? content : content + "\n"); +} - if (!existsSync(skillPath)) { - console.error("SKILL.md not found. If you built from source, ensure skills/qmd/SKILL.md exists."); +function writeEmbeddedSkill(targetDir: string, force: boolean): void { + if (pathExists(targetDir)) { + if (!force) { + throw new Error(`Skill already exists: ${targetDir} (use --force to replace it)`); + } + 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"); + } +} + +function ensureClaudeSymlink(linkPath: string, targetDir: string, force: boolean): boolean { + const parentDir = dirname(linkPath); + if (pathExists(parentDir)) { + const resolvedTargetDir = realpathSync(dirname(targetDir)); + const resolvedLinkParent = realpathSync(parentDir); + + // If .claude/skills already resolves to the same directory as .agents/skills, + // the skill is already visible to Claude and creating qmd -> qmd would loop. + if (resolvedTargetDir === resolvedLinkParent) { + return false; + } + } + + const linkTarget = relativePath(parentDir, targetDir) || "."; + + mkdirSync(parentDir, { recursive: true }); + + if (pathExists(linkPath)) { + const stat = lstatSync(linkPath); + if (stat.isSymbolicLink() && readlinkSync(linkPath) === linkTarget) { + return true; + } + if (!force) { + throw new Error(`Claude skill path already exists: ${linkPath} (use --force to replace it)`); + } + removePath(linkPath); + } + + symlinkSync(linkTarget, linkPath, "dir"); + return true; +} + +async function shouldCreateClaudeSymlink(linkPath: string, autoYes: boolean): Promise { + if (autoYes) { + return true; + } + if (!process.stdin.isTTY || !process.stdout.isTTY) { + console.log(`Tip: create a Claude symlink manually at ${linkPath}`); + return false; + } + + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + + try { + const answer = await rl.question(`Create a symlink in ${linkPath}? [y/N] `); + const normalized = answer.trim().toLowerCase(); + return normalized === "y" || normalized === "yes"; + } finally { + rl.close(); + } +} + +async function installSkill(globalInstall: boolean, force: boolean, autoYes: boolean): Promise { + const installDir = getSkillInstallDir(globalInstall); + writeEmbeddedSkill(installDir, force); + console.log(`✓ Installed QMD skill to ${installDir}`); + + const claudeLinkPath = getClaudeSkillLinkPath(globalInstall); + if (!(await shouldCreateClaudeSymlink(claudeLinkPath, autoYes))) { return; } - const content = readFileSync(skillPath, "utf-8"); - process.stdout.write(content.endsWith("\n") ? content : content + "\n"); + const linked = ensureClaudeSymlink(claudeLinkPath, installDir, force); + if (linked) { + console.log(`✓ Linked Claude skill at ${claudeLinkPath}`); + } else { + console.log(`✓ Claude already sees the skill via ${dirname(claudeLinkPath)}`); + } } function showHelp(): void { @@ -2423,6 +2535,7 @@ 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 mcp - Start the MCP server (stdio transport for AI agents)"); console.log(""); console.log("Collections & context:"); @@ -2472,7 +2585,9 @@ 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(" - `qmd --skill` prints the packaged skills/qmd/SKILL.md (path + contents)."); + 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`."); console.log(" - Advanced: `qmd mcp --http ...` and `qmd mcp --http --daemon` are optional for custom transports."); console.log(""); console.log("Global options:"); @@ -2533,6 +2648,20 @@ if (isMain) { process.exit(0); } + if (cli.values.help && cli.command === "skill") { + 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(""); + console.log("Options:"); + console.log(" --global Install into ~/.agents/skills/qmd"); + console.log(" --yes Also create the .claude/skills/qmd symlink"); + console.log(" -f, --force Replace existing install or symlink"); + process.exit(0); + } + if (!cli.command || cli.values.help) { showHelp(); process.exit(cli.values.help ? 0 : 1); @@ -2933,6 +3062,47 @@ if (isMain) { break; } + case "skill": { + const subcommand = cli.args[0]; + switch (subcommand) { + case "show": { + showSkill(); + break; + } + + case "install": { + try { + await installSkill(Boolean(cli.values.global), Boolean(cli.values.force), Boolean(cli.values.yes)); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } + break; + } + + case "help": + case undefined: { + 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(""); + console.log("Options:"); + console.log(" --global Install into ~/.agents/skills/qmd"); + console.log(" --yes Also create the .claude/skills/qmd symlink"); + console.log(" -f, --force Replace existing install or symlink"); + process.exit(0); + } + + default: + console.error(`Unknown subcommand: ${subcommand}`); + console.error("Run 'qmd skill help' for usage"); + process.exit(1); + } + break; + } + case "cleanup": { const db = getDb(); diff --git a/src/embedded-skills.ts b/src/embedded-skills.ts new file mode 100644 index 0000000..266ec86 --- /dev/null +++ b/src/embedded-skills.ts @@ -0,0 +1,22 @@ +// 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 d6ee5b8..834ac18 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -7,7 +7,7 @@ import { describe, test, expect, beforeAll, afterAll, beforeEach } from "vitest"; import { mkdtemp, rm, writeFile, mkdir } from "fs/promises"; -import { existsSync, readFileSync, writeFileSync, unlinkSync } from "fs"; +import { existsSync, lstatSync, readFileSync, symlinkSync, writeFileSync, unlinkSync } from "fs"; import { tmpdir } from "os"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; @@ -231,6 +231,7 @@ describe("CLI Help", () => { expect(stdout).toContain("Usage:"); expect(stdout).toContain("qmd collection add"); expect(stdout).toContain("qmd search"); + expect(stdout).toContain("qmd skill show/install"); }); test("shows help with no arguments", async () => { @@ -240,6 +241,88 @@ describe("CLI Help", () => { }); }); +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("name: qmd"); + expect(stdout).toContain("allowed-tools: Bash(qmd:*), mcp__qmd__*"); + }); + + test("shows skill help with -h", async () => { + const { stdout, exitCode } = await runQmd(["skill", "-h"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Usage: qmd skill [options]"); + expect(stdout).toContain("install"); + expect(stdout).toContain("--global"); + }); + + test("installs the skill into the current project", async () => { + const projectDir = join(testDir, "skill-project"); + await mkdir(projectDir, { recursive: true }); + + const { stdout, exitCode } = await runQmd(["skill", "install"], { cwd: projectDir }); + 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(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"); + }); + + test("installs globally and creates the Claude symlink with --yes", async () => { + const fakeHome = join(testDir, "skill-home"); + await mkdir(fakeHome, { recursive: true }); + + const { stdout, exitCode } = await runQmd(["skill", "install", "--global", "--yes"], { + env: { HOME: fakeHome }, + }); + expect(exitCode).toBe(0); + + 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(lstatSync(claudeLink).isSymbolicLink()).toBe(true); + expect(readFileSync(join(claudeLink, "SKILL.md"), "utf-8")).toContain("name: qmd"); + expect(stdout).toContain(`✓ Installed QMD skill to ${skillDir}`); + expect(stdout).toContain(`✓ Linked Claude skill at ${claudeLink}`); + }); + + test("skips Claude qmd symlink when .claude/skills already points to .agents/skills", async () => { + const fakeHome = join(testDir, "skill-home-shared"); + await mkdir(join(fakeHome, ".agents"), { recursive: true }); + await mkdir(join(fakeHome, ".claude"), { recursive: true }); + symlinkSync(join(fakeHome, ".agents", "skills"), join(fakeHome, ".claude", "skills"), "dir"); + + const { stdout, exitCode } = await runQmd(["skill", "install", "--global", "--yes"], { + env: { HOME: fakeHome }, + }); + expect(exitCode).toBe(0); + + 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(stdout).toContain(`✓ Claude already sees the skill via ${join(fakeHome, ".claude", "skills")}`); + }); + + test("refuses to overwrite an existing install without --force", async () => { + const projectDir = join(testDir, "skill-project-force"); + await mkdir(projectDir, { recursive: true }); + + const first = await runQmd(["skill", "install"], { cwd: projectDir }); + expect(first.exitCode).toBe(0); + + const second = await runQmd(["skill", "install"], { cwd: projectDir }); + expect(second.exitCode).toBe(1); + expect(second.stderr).toContain("Skill already exists"); + expect(second.stderr).toContain("--force"); + }); +}); + describe("CLI Add Command", () => { test("adds files from current directory", async () => { const { stdout, exitCode } = await runQmd(["collection", "add", "."]);