Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-ghost-skill-frontmatter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@design-intelligence/ghost": patch
---

Fix the installed ghost skill metadata so goose can discover it.
6 changes: 5 additions & 1 deletion packages/ghost/src/skill-bundle/SKILL.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 3 additions & 1 deletion packages/ghost/src/skill-bundle/references/schema.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
127 changes: 127 additions & 0 deletions packages/ghost/test/goosed-skill-discovery.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
112 changes: 112 additions & 0 deletions packages/ghost/test/skill-bundle.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, unknown>,
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 });
}
});
});
Loading