Merge pull request #657 from tobi/feat/cli-served-skills

Serve QMD skill instructions from the CLI
This commit is contained in:
Tobias Lütke 2026-05-16 19:40:10 -04:00 committed by GitHub
commit cdf3bc0712
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 374 additions and 49 deletions

View File

@ -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.

View File

@ -17,6 +17,7 @@
"files": [
"bin/",
"dist/",
"skills/",
"scripts/check-package-grammars.mjs",
"LICENSE",
"CHANGELOG.md"

View File

@ -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 <name>");
}
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 <list|get|path> [options]");
console.log("");
console.log("Commands:");
console.log(" list List bundled runtime skills");
console.log(" get <name> Print a bundled runtime skill");
console.log(" get <name> --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<void> {
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 <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 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 <fixture.json> - 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 <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(" 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 <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(" 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");

File diff suppressed because one or more lines are too long

View File

@ -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")}`);
});

View File

@ -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);
});
});

View File

@ -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");