diff --git a/script/sync-lazyclaudecode-marketplace.test.ts b/script/sync-lazyclaudecode-marketplace.test.ts new file mode 100644 index 000000000..3ff599e2c --- /dev/null +++ b/script/sync-lazyclaudecode-marketplace.test.ts @@ -0,0 +1,170 @@ +/// + +import { describe, expect, test } from "bun:test" +import { mkdir, mkdtemp, readFile, stat, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { dirname, join } from "node:path" +import { syncLazyclaudecodeMarketplace } from "./sync-lazyclaudecode-marketplace" + +async function writeJson(path: string, value: unknown): Promise { + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`) +} + +async function writeText(path: string, text: string): Promise { + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, text) +} + +interface PluginFixtureOptions { + readonly marketplaceName?: string + readonly pluginName?: string +} + +async function writePluginFixture(sourceRoot: string, options: PluginFixtureOptions = {}): Promise { + const marketplaceName = options.marketplaceName ?? "sisyphuslabs" + const pluginName = options.pluginName ?? "omo" + const pluginRoot = join(sourceRoot, "packages", "omo-claude", "plugin") + + await writeJson(join(sourceRoot, "packages", "omo-claude", "marketplace.json"), { + name: marketplaceName, + owner: { name: "Yeongyu Kim", url: "https://github.com/code-yeongyu" }, + plugins: [{ name: pluginName, source: "./plugins/omo" }], + }) + await writeJson(join(pluginRoot, ".claude-plugin", "plugin.json"), { + name: pluginName, + version: "1.2.3", + }) + + // Runtime artifacts that MUST be shipped. + await writeText(join(pluginRoot, "README.md"), "omo\n") + await writeJson(join(pluginRoot, ".mcp.json"), { mcpServers: {} }) + await writeText(join(pluginRoot, "hooks", "hooks.json"), "{}\n") + await writeText(join(pluginRoot, "skills", "demo", "SKILL.md"), "# demo\n") + await writeText(join(pluginRoot, "agents", "planner.md"), "# planner\n") + await writeText(join(pluginRoot, "components", "rules", "dist", "cli.js"), "// built\n") + await writeText(join(pluginRoot, "mcp", "ast-grep", "cli.js"), "// mcp\n") + + // Entries that MUST be excluded by the copy filter. + await writeText(join(pluginRoot, "node_modules", "ignored", "file.txt"), "ignored\n") + await writeText(join(pluginRoot, "components", "rules", "src", "cli.ts"), "// source\n") + await writeText(join(pluginRoot, "components", "rules", "tsconfig.build.json"), "{}\n") + await writeText(join(pluginRoot, "components", "rules", "test", "cli.test.ts"), "// test\n") + await writeText(join(pluginRoot, "src", "index.ts"), "// source\n") + await writeText(join(pluginRoot, "test", "smoke.test.ts"), "// test\n") + await writeText(join(pluginRoot, "tsconfig.json"), "{}\n") + await writeText(join(pluginRoot, ".omo", "ultragoal", "state.json"), "{}\n") +} + +async function exists(path: string): Promise { + try { + await stat(path) + return true + } catch { + return false + } +} + +describe("sync-lazyclaudecode-marketplace", () => { + test("reads .claude-plugin/plugin.json and writes .claude-plugin/marketplace.json into the dest", async () => { + // given + const sourceRoot = await mkdtemp(join(tmpdir(), "omo-claude-sync-source-")) + const lazyclaudecodeRoot = await mkdtemp(join(tmpdir(), "omo-claude-sync-dest-")) + await writePluginFixture(sourceRoot) + + // when + await syncLazyclaudecodeMarketplace({ sourceRoot, lazyclaudecodeRoot }) + + // then — destination marketplace at the CC path + const marketplace = JSON.parse( + await readFile(join(lazyclaudecodeRoot, ".claude-plugin", "marketplace.json"), "utf8"), + ) + expect(marketplace.name).toBe("sisyphuslabs") + expect(marketplace.plugins[0].source).toBe("./plugins/omo") + + // the plugin manifest came from .claude-plugin/plugin.json and landed under plugins/omo + const manifest = JSON.parse( + await readFile(join(lazyclaudecodeRoot, "plugins", "omo", ".claude-plugin", "plugin.json"), "utf8"), + ) + expect(manifest).toMatchObject({ name: "omo", version: "1.2.3" }) + + // NOT the codex dest path + expect(await exists(join(lazyclaudecodeRoot, ".agents", "plugins", "marketplace.json"))).toBe(false) + }) + + test("ships runtime artifacts into plugins/omo", async () => { + // given + const sourceRoot = await mkdtemp(join(tmpdir(), "omo-claude-sync-source-")) + const lazyclaudecodeRoot = await mkdtemp(join(tmpdir(), "omo-claude-sync-dest-")) + await writePluginFixture(sourceRoot) + + // when + await syncLazyclaudecodeMarketplace({ sourceRoot, lazyclaudecodeRoot }) + + // then + const pluginDest = join(lazyclaudecodeRoot, "plugins", "omo") + expect(await exists(join(pluginDest, ".mcp.json"))).toBe(true) + expect(await exists(join(pluginDest, "hooks", "hooks.json"))).toBe(true) + expect(await exists(join(pluginDest, "skills", "demo", "SKILL.md"))).toBe(true) + expect(await exists(join(pluginDest, "agents", "planner.md"))).toBe(true) + expect(await exists(join(pluginDest, "components", "rules", "dist", "cli.js"))).toBe(true) + expect(await exists(join(pluginDest, "mcp", "ast-grep", "cli.js"))).toBe(true) + }) + + test("copy filter excludes node_modules, src/, test/, tsconfig*.json, and .omo/", async () => { + // given + const sourceRoot = await mkdtemp(join(tmpdir(), "omo-claude-sync-source-")) + const lazyclaudecodeRoot = await mkdtemp(join(tmpdir(), "omo-claude-sync-dest-")) + await writePluginFixture(sourceRoot) + + // when + await syncLazyclaudecodeMarketplace({ sourceRoot, lazyclaudecodeRoot }) + + // then + const pluginDest = join(lazyclaudecodeRoot, "plugins", "omo") + expect(await exists(join(pluginDest, "node_modules"))).toBe(false) + expect(await exists(join(pluginDest, "src"))).toBe(false) + expect(await exists(join(pluginDest, "test"))).toBe(false) + expect(await exists(join(pluginDest, "tsconfig.json"))).toBe(false) + expect(await exists(join(pluginDest, ".omo"))).toBe(false) + // nested src/test/tsconfig under a component are excluded too + expect(await exists(join(pluginDest, "components", "rules", "src"))).toBe(false) + expect(await exists(join(pluginDest, "components", "rules", "test"))).toBe(false) + expect(await exists(join(pluginDest, "components", "rules", "tsconfig.build.json"))).toBe(false) + }) + + test("throws when the marketplace name is not sisyphuslabs", async () => { + // given + const sourceRoot = await mkdtemp(join(tmpdir(), "omo-claude-sync-source-")) + const lazyclaudecodeRoot = await mkdtemp(join(tmpdir(), "omo-claude-sync-dest-")) + await writePluginFixture(sourceRoot, { marketplaceName: "wrong" }) + + // when / then + await expect(syncLazyclaudecodeMarketplace({ sourceRoot, lazyclaudecodeRoot })).rejects.toThrow("sisyphuslabs") + }) + + test("throws when the plugin name is not omo", async () => { + // given + const sourceRoot = await mkdtemp(join(tmpdir(), "omo-claude-sync-source-")) + const lazyclaudecodeRoot = await mkdtemp(join(tmpdir(), "omo-claude-sync-dest-")) + await writePluginFixture(sourceRoot, { pluginName: "not-omo" }) + + // when / then + await expect(syncLazyclaudecodeMarketplace({ sourceRoot, lazyclaudecodeRoot })).rejects.toThrow("omo") + }) + + test("throws when the .claude-plugin/plugin.json manifest is missing", async () => { + // given + const sourceRoot = await mkdtemp(join(tmpdir(), "omo-claude-sync-source-")) + const lazyclaudecodeRoot = await mkdtemp(join(tmpdir(), "omo-claude-sync-dest-")) + await writeJson(join(sourceRoot, "packages", "omo-claude", "marketplace.json"), { + name: "sisyphuslabs", + plugins: [{ name: "omo", source: "./plugins/omo" }], + }) + + // when / then + await expect(syncLazyclaudecodeMarketplace({ sourceRoot, lazyclaudecodeRoot })).rejects.toThrow( + ".claude-plugin/plugin.json", + ) + }) +}) diff --git a/script/sync-lazyclaudecode-marketplace.ts b/script/sync-lazyclaudecode-marketplace.ts new file mode 100644 index 000000000..3f917a064 --- /dev/null +++ b/script/sync-lazyclaudecode-marketplace.ts @@ -0,0 +1,112 @@ +import { cp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises" +import { dirname, join, resolve, sep } from "node:path" + +const MARKETPLACE_SOURCE_PATH = join("packages", "omo-claude", "marketplace.json") +const PLUGIN_SOURCE_PATH = join("packages", "omo-claude", "plugin") +const MARKETPLACE_DESTINATION_PATH = join(".claude-plugin", "marketplace.json") +const PLUGIN_DESTINATION_PATH = join("plugins", "omo") + +export interface SyncLazyclaudecodeMarketplaceInput { + readonly sourceRoot: string + readonly lazyclaudecodeRoot: string +} + +interface MarketplaceManifest { + readonly name: string +} + +interface PluginManifest { + readonly name: string + readonly version?: string +} + +export async function syncLazyclaudecodeMarketplace(input: SyncLazyclaudecodeMarketplaceInput): Promise { + const sourceRoot = resolve(input.sourceRoot) + const lazyclaudecodeRoot = resolve(input.lazyclaudecodeRoot) + const marketplacePath = join(sourceRoot, MARKETPLACE_SOURCE_PATH) + const pluginRoot = join(sourceRoot, PLUGIN_SOURCE_PATH) + const pluginManifestPath = join(pluginRoot, ".claude-plugin", "plugin.json") + + const marketplace = await readMarketplaceManifest(marketplacePath) + if (marketplace.name !== "sisyphuslabs") { + throw new Error(`Sisyphus Labs marketplace manifest must be named sisyphuslabs, got ${marketplace.name}`) + } + + const pluginManifest = await readPluginManifest(pluginManifestPath) + if (pluginManifest.name !== "omo") { + throw new Error(`Sisyphus Labs plugin manifest must be named omo, got ${pluginManifest.name}`) + } + + const destinationMarketplacePath = join(lazyclaudecodeRoot, MARKETPLACE_DESTINATION_PATH) + await mkdir(dirname(destinationMarketplacePath), { recursive: true }) + await writeFile(destinationMarketplacePath, await readFile(marketplacePath, "utf8")) + + const destinationPluginRoot = join(lazyclaudecodeRoot, PLUGIN_DESTINATION_PATH) + await rm(destinationPluginRoot, { recursive: true, force: true }) + await mkdir(dirname(destinationPluginRoot), { recursive: true }) + await cp(pluginRoot, destinationPluginRoot, { + recursive: true, + filter: (path) => shouldCopyPluginPath(path, pluginRoot), + }) +} + +async function readMarketplaceManifest(path: string): Promise { + const parsed = JSON.parse(await readFile(path, "utf8")) + if (isRecord(parsed) && typeof parsed.name === "string") { + return { name: parsed.name } + } + throw new Error("invalid Sisyphus Labs marketplace manifest") +} + +async function readPluginManifest(path: string): Promise { + if (!(await isFile(path))) { + throw new Error(`missing Claude Code plugin manifest at .claude-plugin/plugin.json (${path})`) + } + const parsed = JSON.parse(await readFile(path, "utf8")) + if (isRecord(parsed) && typeof parsed.name === "string") { + return { + name: parsed.name, + version: typeof parsed.version === "string" ? parsed.version : undefined, + } + } + throw new Error("invalid Claude Code plugin manifest") +} + +async function isFile(path: string): Promise { + try { + return (await stat(path)).isFile() + } catch (error) { + if (error instanceof Error) return false + return false + } +} + +function shouldCopyPluginPath(path: string, root: string): boolean { + const relative = path === root ? "" : path.slice(root.length + sep.length) + if (relative.length === 0) return true + return !relative.split(sep).some(isExcludedPathPart) +} + +function isExcludedPathPart(part: string): boolean { + if (part === ".git" || part === "node_modules") return true + if (part === "src" || part === "test") return true + if (part === ".omo") return true + return isTsconfigFile(part) +} + +function isTsconfigFile(part: string): boolean { + return part.startsWith("tsconfig") && part.endsWith(".json") +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +if (import.meta.main) { + const sourceRoot = process.argv[2] ?? process.cwd() + const lazyclaudecodeRoot = process.argv[3] + if (lazyclaudecodeRoot === undefined) { + throw new Error("Usage: bun run script/sync-lazyclaudecode-marketplace.ts ") + } + await syncLazyclaudecodeMarketplace({ sourceRoot, lazyclaudecodeRoot }) +} diff --git a/script/tsconfig.json b/script/tsconfig.json index 4c78bbbfb..7f607b856 100644 --- a/script/tsconfig.json +++ b/script/tsconfig.json @@ -11,5 +11,5 @@ "allowImportingTsExtensions": true, "noEmit": true }, - "include": ["./publish-workflow.test.ts", "./package-layout.test.ts", "./sync-lazycodex-marketplace.ts", "./sync-lazycodex-marketplace.test.ts"] + "include": ["./publish-workflow.test.ts", "./package-layout.test.ts", "./sync-lazycodex-marketplace.ts", "./sync-lazycodex-marketplace.test.ts", "./sync-lazyclaudecode-marketplace.ts", "./sync-lazyclaudecode-marketplace.test.ts"] }