diff --git a/.changeset/fix-ghost-skill-frontmatter.md b/.changeset/fix-ghost-skill-frontmatter.md new file mode 100644 index 00000000..c9c39aab --- /dev/null +++ b/.changeset/fix-ghost-skill-frontmatter.md @@ -0,0 +1,5 @@ +--- +"@design-intelligence/ghost": patch +--- + +Fix the installed ghost skill metadata so goose can discover it. diff --git a/packages/ghost/src/skill-bundle/SKILL.md b/packages/ghost/src/skill-bundle/SKILL.md index 7c834d29..0901ae0b 100644 --- a/packages/ghost/src/skill-bundle/SKILL.md +++ b/packages/ghost/src/skill-bundle/SKILL.md @@ -1,6 +1,10 @@ --- name: ghost -description: Author, validate, consume, and review against a repo-local ghost package: the medium-agnostic articulation of a product's brand. Use when the user wants to set up a .ghost package, write or update guidance nodes, gather brand context before generation, or assemble a review packet from ghost checks. +description: >- + Author, validate, consume, and review against a repo-local ghost package: the + medium-agnostic articulation of a product's brand. Use when the user wants to + set up a .ghost package, write or update guidance nodes, gather brand context + before generation, or assemble a review packet from ghost checks. license: Apache-2.0 metadata: homepage: https://github.com/block/ghost diff --git a/packages/ghost/src/skill-bundle/references/schema.md b/packages/ghost/src/skill-bundle/references/schema.md index 0580a12b..ff7bd3e7 100644 --- a/packages/ghost/src/skill-bundle/references/schema.md +++ b/packages/ghost/src/skill-bundle/references/schema.md @@ -1,6 +1,8 @@ --- name: schema -description: The deterministic ghost package contract: layout, manifest, nodes, materials, Skeletons, checks, and command behavior. +description: >- + The deterministic ghost package contract: layout, manifest, nodes, materials, + Skeletons, checks, and command behavior. --- # ghost Package Reference diff --git a/packages/ghost/test/goosed-skill-discovery.test.ts b/packages/ghost/test/goosed-skill-discovery.test.ts new file mode 100644 index 00000000..4adecd90 --- /dev/null +++ b/packages/ghost/test/goosed-skill-discovery.test.ts @@ -0,0 +1,127 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { describe, expect, it } from "vitest"; + +const execFileAsync = promisify(execFile); +const GHOST_BIN = resolve("packages/ghost/dist/bin.js"); + +const BAD_SKILL = `--- +name: ghost +description: Author, validate, consume, and review against a repo-local ghost package: the medium-agnostic articulation of a product's brand. Use when the user wants to set up a .ghost package. +--- + +# Bad ghost skill +`; + +function hasGhostSkill(output: string): boolean { + return output.split(/\r?\n/).some((line) => /^ghost\s+\|/.test(line)); +} + +async function runGoosedSkillsList( + gooseBin: string, + cwd: string, + envRoot: string, +): Promise<{ stdout: string; stderr: string; code: number }> { + try { + const { stdout, stderr } = await execFileAsync( + gooseBin, + ["skills", "list"], + { + cwd, + env: { + ...process.env, + HOME: join(envRoot, "home"), + XDG_CONFIG_HOME: join(envRoot, "home", ".config"), + GOOSE_PATH_ROOT: join(envRoot, "goose-root"), + }, + timeout: 20_000, + }, + ); + return { stdout, stderr, code: 0 }; + } catch (error) { + const err = error as NodeJS.ErrnoException & { + stdout?: string; + stderr?: string; + code?: number; + }; + return { + stdout: err.stdout ?? "", + stderr: err.stderr ?? err.message, + code: typeof err.code === "number" ? err.code : 1, + }; + } +} + +describe("goosed skill discovery", () => { + it.skipIf(!process.env.GOOSE_BIN)( + "lists the installed ghost skill only when SKILL.md frontmatter is valid (set GOOSE_BIN to opt in; skipped when unavailable)", + async () => { + const gooseBin = process.env.GOOSE_BIN; + expect( + gooseBin, + "set GOOSE_BIN to the goosed binary to run this test", + ).toBeTruthy(); + + const root = await mkdtemp(join(tmpdir(), "ghost-goosed-skill-")); + try { + const cwd = join(root, "work"); + await mkdir(join(cwd, ".agents", "skills", "ghost"), { + recursive: true, + }); + await mkdir(join(root, "home"), { recursive: true }); + await mkdir(join(root, "goose-root"), { recursive: true }); + + await writeFile( + join(cwd, ".agents", "skills", "ghost", "SKILL.md"), + BAD_SKILL, + ); + const malformed = await runGoosedSkillsList( + gooseBin as string, + cwd, + root, + ); + expect( + malformed.code !== 0 || !hasGhostSkill(malformed.stdout), + `malformed skill should be rejected or omitted; stdout:\n${malformed.stdout}\nstderr:\n${malformed.stderr}`, + ).toBe(true); + + await rm(join(cwd, ".agents", "skills", "ghost"), { + recursive: true, + force: true, + }); + await execFileAsync( + process.execPath, + [GHOST_BIN, "skill", "install", "--dest", ".agents/skills/ghost"], + { + cwd, + env: { + ...process.env, + HOME: join(root, "home"), + XDG_CONFIG_HOME: join(root, "home", ".config"), + GOOSE_PATH_ROOT: join(root, "goose-root"), + }, + timeout: 20_000, + }, + ); + + const installedSkill = await readFile( + join(cwd, ".agents", "skills", "ghost", "SKILL.md"), + "utf-8", + ); + expect(installedSkill).toContain("description: >-"); + + const fixed = await runGoosedSkillsList(gooseBin as string, cwd, root); + expect(fixed.code, fixed.stderr).toBe(0); + expect(fixed.stdout).toContain("ghost"); + expect(fixed.stdout).toContain("Author, validate, consume"); + expect(hasGhostSkill(fixed.stdout), fixed.stdout).toBe(true); + } finally { + await rm(root, { recursive: true, force: true }); + } + }, + 60_000, + ); +}); diff --git a/packages/ghost/test/skill-bundle.test.ts b/packages/ghost/test/skill-bundle.test.ts new file mode 100644 index 00000000..1ddfdc35 --- /dev/null +++ b/packages/ghost/test/skill-bundle.test.ts @@ -0,0 +1,112 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { parse as parseYaml } from "yaml"; +import { runCli } from "./cli-test-utils.js"; + +interface ParsedSkillMarkdown { + frontmatter: Record; + body: string; +} + +const OLD_MALFORMED_SKILL_HEADER = `--- +name: ghost +description: Author, validate, consume, and review against a repo-local ghost package: the medium-agnostic articulation of a product's brand. Use when the user wants to set up a .ghost package, write or update guidance nodes, gather brand context before generation, or assemble a review packet from ghost checks. +license: Apache-2.0 +metadata: + homepage: https://github.com/block/ghost + cli: ghost +--- + +# ghost: Brand Guidance Packages +`; + +function parseSkillMarkdown(raw: string): ParsedSkillMarkdown { + const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); + if (!match) throw new Error("SKILL.md is missing YAML frontmatter"); + const frontmatter = parseYaml(match[1] ?? ""); + if ( + !frontmatter || + typeof frontmatter !== "object" || + Array.isArray(frontmatter) + ) { + throw new Error("SKILL.md frontmatter must be a YAML mapping"); + } + return { + frontmatter: frontmatter as Record, + body: match[2] ?? "", + }; +} + +describe("ghost skill bundle frontmatter", () => { + it("documents the old malformed description as a YAML parse failure", () => { + expect(() => parseSkillMarkdown(OLD_MALFORMED_SKILL_HEADER)).toThrow( + /Nested mappings are not allowed in compact mappings/, + ); + }); + + it("installs a SKILL.md whose frontmatter is valid YAML metadata", async () => { + const dir = await mkdtemp(join(tmpdir(), "ghost-skill-bundle-")); + try { + const install = await runCli( + ["skill", "install", "--dest", "skills/ghost"], + dir, + ); + expect(install.code).toBe(0); + + const raw = await readFile( + join(dir, "skills", "ghost", "SKILL.md"), + "utf-8", + ); + const parsed = parseSkillMarkdown(raw); + + expect(parsed.frontmatter).toMatchObject({ + name: "ghost", + description: + "Author, validate, consume, and review against a repo-local ghost package: the medium-agnostic articulation of a product's brand. Use when the user wants to set up a .ghost package, write or update guidance nodes, gather brand context before generation, or assemble a review packet from ghost checks.", + license: "Apache-2.0", + metadata: { + homepage: "https://github.com/block/ghost", + cli: "ghost", + }, + }); + expect(typeof parsed.frontmatter.description).toBe("string"); + expect(parsed.body).toContain("# ghost: Brand Guidance Packages"); + expect(parsed.body).toContain("When the package is silent"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("keeps frontmatter valid for every bundled markdown file", async () => { + const dir = await mkdtemp(join(tmpdir(), "ghost-skill-bundle-all-")); + try { + const install = await runCli( + ["skill", "install", "--dest", "skills/ghost"], + dir, + ); + expect(install.code).toBe(0); + + const skillRoot = join(dir, "skills", "ghost"); + const files = [ + "SKILL.md", + "references/authoring.md", + "references/ground.md", + "references/making.md", + "references/materials.md", + "references/nodes.md", + "references/schema.md", + "references/steering-audit.md", + ]; + + for (const file of files) { + const raw = await readFile(join(skillRoot, file), "utf-8"); + if (!raw.startsWith("---\n")) continue; + expect(() => parseSkillMarkdown(raw), file).not.toThrow(); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +});