Procházet zdrojové kódy

feat(skill): install packaged qmd skill

nkkko před 2 měsíci
rodič
revize
b16d77146a
3 změnil soubory, kde provedl 275 přidání a 14 odebrání
  1. 183 13
      src/cli/qmd.ts
  2. 8 0
      src/embedded-skills.ts
  3. 84 1
      test/cli.test.ts

+ 183 - 13
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 showSkill(): void {
-  const scriptDir = dirname(fileURLToPath(import.meta.url));
-  const relativePath = pathJoin("skills", "qmd", "SKILL.md");
-  const skillPath = pathJoin(scriptDir, "..", "..", relativePath);
+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;
+  }
+}
 
-  console.log(`QMD Skill (${relativePath})`);
-  console.log(`Location: ${skillPath}`);
+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 {
+  console.log("QMD Skill (embedded)");
   console.log("");
+  const content = getEmbeddedQmdSkillContent();
+  process.stdout.write(content.endsWith("\n") ? content : content + "\n");
+}
+
+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();
+  }
+}
 
-  if (!existsSync(skillPath)) {
-    console.error("SKILL.md not found. If you built from source, ensure skills/qmd/SKILL.md exists.");
+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();
 

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 8 - 0
src/embedded-skills.ts


+ 84 - 1
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 <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", "."]);

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů