feat(skill): install packaged qmd skill
This commit is contained in:
parent
55f16460d0
commit
b16d77146a
196
src/cli/qmd.ts
196
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<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
22
src/embedded-skills.ts
Normal file
File diff suppressed because one or more lines are too long
@ -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", "."]);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user