Merge pull request #657 from tobi/feat/cli-served-skills
Serve QMD skill instructions from the CLI
This commit is contained in:
commit
cdf3bc0712
@ -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.
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
"files": [
|
||||
"bin/",
|
||||
"dist/",
|
||||
"skills/",
|
||||
"scripts/check-package-grammars.mjs",
|
||||
"LICENSE",
|
||||
"CHANGELOG.md"
|
||||
|
||||
306
src/cli/qmd.ts
306
src/cli/qmd.ts
@ -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
@ -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")}`);
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user