feat(skill): install packaged qmd skill

This commit is contained in:
nkkko 2026-03-10 23:18:15 +01:00
parent 55f16460d0
commit b16d77146a
3 changed files with 289 additions and 14 deletions

View File

@ -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<boolean> {
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<void> {
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 <query> - Vector similarity only");
console.log(" qmd get <file>[:line] [-l N] - Show a single document, optional line slice");
console.log(" qmd multi-get <pattern> - 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 <show|install> [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 <show|install> [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();

22
src/embedded-skills.ts Normal file

File diff suppressed because one or more lines are too long

View File

@ -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 <show|install> [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", "."]);