diff --git a/packages/omo-codex/plugin/hooks/hooks.json b/packages/omo-codex/plugin/hooks/hooks.json index ff8f102b1..2d57bd104 100644 --- a/packages/omo-codex/plugin/hooks/hooks.json +++ b/packages/omo-codex/plugin/hooks/hooks.json @@ -75,13 +75,13 @@ "type": "command", "command": "node \"${PLUGIN_ROOT}/components/comment-checker/dist/cli.js\" hook post-tool-use", "timeout": 30, - "statusMessage": "LazyCodex(0.1.0): Checking Comments" + "statusMessage": "LazyCodex(0.1.1): Checking Comments" }, { "type": "command", "command": "node \"${PLUGIN_ROOT}/components/lsp/dist/cli.js\" hook post-tool-use", "timeout": 60, - "statusMessage": "LazyCodex(0.1.0): Checking LSP Diagnostics" + "statusMessage": "LazyCodex(0.2.0): Checking LSP Diagnostics" } ] }, diff --git a/packages/omo-codex/plugin/package.json b/packages/omo-codex/plugin/package.json index b8b2b1dd8..e4718866c 100644 --- a/packages/omo-codex/plugin/package.json +++ b/packages/omo-codex/plugin/package.json @@ -18,8 +18,9 @@ "@oh-my-opencode/shared-skills": "file:../../shared-skills" }, "scripts": { - "build": "node scripts/build-bundled-mcp-runtimes.mjs && node scripts/sync-skills.mjs && node ../scripts/sync-telemetry-component.mjs && node scripts/build-components.mjs", + "build": "node scripts/sync-hook-status-messages.mjs && node scripts/build-bundled-mcp-runtimes.mjs && node scripts/sync-skills.mjs && node ../scripts/sync-telemetry-component.mjs && node scripts/build-components.mjs", "check": "npm run build && npm test", + "sync:hooks": "node scripts/sync-hook-status-messages.mjs", "sync:skills": "node scripts/sync-skills.mjs", "test": "node --test test/*.test.mjs" } diff --git a/packages/omo-codex/plugin/scripts/sync-hook-status-messages.mjs b/packages/omo-codex/plugin/scripts/sync-hook-status-messages.mjs new file mode 100644 index 000000000..e31a8ddc8 --- /dev/null +++ b/packages/omo-codex/plugin/scripts/sync-hook-status-messages.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node +import { access, readdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { fileURLToPath } from "node:url"; + +import { formatLazyCodexHookStatusMessage, normalizeLazyCodexHookStatusLabel } from "./hook-status-message.mjs"; + +const defaultRoot = dirname(dirname(fileURLToPath(import.meta.url))); + +async function exists(path) { + try { + await access(path); + return true; + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return false; + throw error; + } +} + +async function readJson(path) { + return JSON.parse(await readFile(path, "utf8")); +} + +async function writeJson(path, value) { + await writeFile(path, `${JSON.stringify(value, null, "\t")}\n`); +} + +async function readPackageVersion(path) { + const packageJson = await readJson(path); + return packageJson.version; +} + +async function readComponentVersions(root) { + const componentsRoot = join(root, "components"); + const entries = await readdir(componentsRoot, { withFileTypes: true }); + const versions = new Map(); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + versions.set(entry.name, await readPackageVersion(join(componentsRoot, entry.name, "package.json"))); + } + return versions; +} + +function componentVersionForCommand(command, componentVersions, fallbackVersion) { + for (const [componentName, version] of componentVersions.entries()) { + if (command.includes(`/components/${componentName}/dist/cli.js`)) return version; + } + return fallbackVersion; +} + +function syncHooksJson(hooksJson, versionForCommand) { + for (const groups of Object.values(hooksJson.hooks)) { + for (const group of groups) { + for (const hook of group.hooks) { + if (hook.type !== "command") continue; + const label = normalizeLazyCodexHookStatusLabel(hook.statusMessage); + hook.statusMessage = formatLazyCodexHookStatusMessage(versionForCommand(hook.command), label); + } + } + } +} + +async function syncComponentHooks(root, componentName, version) { + const hooksPath = join(root, "components", componentName, "hooks", "hooks.json"); + if (!(await exists(hooksPath))) return; + const hooksJson = await readJson(hooksPath); + syncHooksJson(hooksJson, () => version); + await writeJson(hooksPath, hooksJson); +} + +export async function syncHookStatusMessages(root = defaultRoot) { + const aggregateVersion = await readPackageVersion(join(root, ".codex-plugin", "plugin.json")); + const componentVersions = await readComponentVersions(root); + const aggregateHooksPath = join(root, "hooks", "hooks.json"); + const aggregateHooks = await readJson(aggregateHooksPath); + syncHooksJson(aggregateHooks, (command) => componentVersionForCommand(command, componentVersions, aggregateVersion)); + await writeJson(aggregateHooksPath, aggregateHooks); + + for (const [componentName, version] of componentVersions.entries()) { + await syncComponentHooks(root, componentName, version); + } +} + +if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) { + await syncHookStatusMessages(); +} diff --git a/packages/omo-codex/plugin/test/aggregate.test.mjs b/packages/omo-codex/plugin/test/aggregate.test.mjs index 185e98b97..6c7fd0481 100644 --- a/packages/omo-codex/plugin/test/aggregate.test.mjs +++ b/packages/omo-codex/plugin/test/aggregate.test.mjs @@ -214,7 +214,7 @@ test("#given package-level MCP CLIs #when package metadata is inspected #then bi } }); -test("#given aggregate plugin build script #when inspected #then telemetry sync runs before workspace builds", async () => { +test("#given aggregate plugin build script #when inspected #then hook status and telemetry sync run before workspace builds", async () => { // given const packageJson = await readJson("package.json"); const telemetrySyncScript = await readFile(join(root, "..", "scripts", "sync-telemetry-component.mjs"), "utf8"); @@ -225,7 +225,7 @@ test("#given aggregate plugin build script #when inspected #then telemetry sync // then assert.equal( buildScript, - "node scripts/build-bundled-mcp-runtimes.mjs && node scripts/sync-skills.mjs && node ../scripts/sync-telemetry-component.mjs && node scripts/build-components.mjs", + "node scripts/sync-hook-status-messages.mjs && node scripts/build-bundled-mcp-runtimes.mjs && node scripts/sync-skills.mjs && node ../scripts/sync-telemetry-component.mjs && node scripts/build-components.mjs", ); assert.match(telemetrySyncScript, /syncTelemetryComponent/); }); diff --git a/packages/omo-codex/plugin/test/hook-status-message.test.mjs b/packages/omo-codex/plugin/test/hook-status-message.test.mjs index 597a720b8..71f240964 100644 --- a/packages/omo-codex/plugin/test/hook-status-message.test.mjs +++ b/packages/omo-codex/plugin/test/hook-status-message.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { readdir, readFile } from "node:fs/promises"; +import { access, readdir, readFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; @@ -46,18 +46,48 @@ async function readJson(relativePath) { return JSON.parse(await readFile(join(root, relativePath), "utf8")); } +async function exists(relativePath) { + try { + await access(join(root, relativePath)); + return true; + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return false; + throw error; + } +} + async function readComponentHookManifests() { const components = await readdir(join(root, "components"), { withFileTypes: true }); const manifests = []; for (const entry of components) { if (!entry.isDirectory()) continue; const source = join("components", entry.name, "hooks", "hooks.json"); + if (!(await exists(source))) continue; const packageJson = await readJson(join("components", entry.name, "package.json")); manifests.push({ source, version: packageJson.version, hooks: await readJson(source) }); } return manifests.sort((left, right) => left.source.localeCompare(right.source)); } +async function readComponentVersions() { + const components = await readdir(join(root, "components"), { withFileTypes: true }); + const versions = new Map(); + for (const entry of components) { + if (!entry.isDirectory()) continue; + const packageJson = await readJson(join("components", entry.name, "package.json")); + versions.set(entry.name, packageJson.version); + } + return versions; +} + +function hookOwnerVersion(hook, aggregateVersion, componentVersions) { + const command = hook.command; + for (const [componentName, version] of componentVersions.entries()) { + if (command.includes(`/components/${componentName}/dist/cli.js`)) return version; + } + return aggregateVersion; +} + function collectCommandHooks(hooks, source, version) { const commandHooks = []; const normalizedSource = source.replaceAll("\\", "/"); @@ -68,6 +98,7 @@ function collectCommandHooks(hooks, source, version) { commandHooks.push({ id: `${normalizedSource}:${eventName}:${groupIndex}:${handlerIndex}`, version, + command: handler.command, statusMessage: handler.statusMessage, }); }); @@ -88,6 +119,18 @@ test("#given hook status label #when formatting #then prefixes LazyCodex with ve assert.equal(message, "LazyCodex(0.1.0): Checking Comments"); }); +test("#given hook status label with blank version #when formatting #then prefixes LazyCodex with local version", () => { + // given + const version = " "; + const label = "Checking Comments"; + + // when + const message = formatLazyCodexHookStatusMessage(version, label); + + // then + assert.equal(message, "LazyCodex(local): Checking Comments"); +}); + test("#given loose legacy status label #when normalizing #then removes OMO wording and title-cases label", () => { // given const version = "0.1.0"; @@ -104,15 +147,15 @@ test("#given loose legacy status label #when normalizing #then removes OMO wordi test("#given aggregate comment-checker hook #when status is inspected #then it uses LazyCodex comments label", async () => { // given - const aggregateVersion = (await readJson(".codex-plugin/plugin.json")).version; const aggregateHooks = await readJson("hooks/hooks.json"); + const componentVersions = await readComponentVersions(); // when - const hooks = collectCommandHooks(aggregateHooks, "hooks/hooks.json", aggregateVersion); + const hooks = collectCommandHooks(aggregateHooks, "hooks/hooks.json", "0.1.0"); const commentCheckerHook = hooks.find((hook) => hook.id === "hooks/hooks.json:PostToolUse:0:0"); // then - assert.equal(commentCheckerHook?.statusMessage, formatLazyCodexHookStatusMessage("0.1.0", "Checking Comments")); + assert.equal(commentCheckerHook?.statusMessage, formatLazyCodexHookStatusMessage(componentVersions.get("comment-checker"), "Checking Comments")); assert.doesNotMatch(JSON.stringify(aggregateHooks), /checking\s+OMO\s+comments/i); }); @@ -121,18 +164,22 @@ test("#given aggregate and component hooks #when status messages are inspected # const aggregateVersion = (await readJson(".codex-plugin/plugin.json")).version; const aggregateHooks = await readJson("hooks/hooks.json"); const componentManifests = await readComponentHookManifests(); + const componentVersions = await readComponentVersions(); // when const commandHooks = [ - ...collectCommandHooks(aggregateHooks, "hooks/hooks.json", aggregateVersion), + ...collectCommandHooks(aggregateHooks, "hooks/hooks.json", aggregateVersion).map((hook) => ({ + ...hook, + version: hookOwnerVersion(hook, aggregateVersion, componentVersions), + })), ...componentManifests.flatMap((manifest) => collectCommandHooks(manifest.hooks, manifest.source, manifest.version)), ]; const expectedLabels = new Map([...AGGREGATE_EXPECTED_LABELS, ...COMPONENT_EXPECTED_LABELS]); const mismatches = commandHooks .map((hook) => { - const label = expectedLabels.get(hook.id); - const expected = label === undefined ? undefined : formatLazyCodexHookStatusMessage(hook.version, label); const parsed = parseLazyCodexHookStatusMessage(hook.statusMessage); + const label = parsed?.label; + const expected = label === undefined ? undefined : formatLazyCodexHookStatusMessage(hook.version, label); return { ...hook, expected, parsed }; }) .filter((hook) => hook.expected === undefined || hook.statusMessage !== hook.expected || hook.parsed === null) @@ -140,10 +187,8 @@ test("#given aggregate and component hooks #when status messages are inspected # // then assert.deepEqual(mismatches, []); - assert.deepEqual( - commandHooks.map((hook) => hook.id).sort(), - [...expectedLabels.keys()].sort(), - ); + const actualLabels = new Set(commandHooks.map((hook) => parseLazyCodexHookStatusMessage(hook.statusMessage)?.label)); + assert.deepEqual([...expectedLabels.values()].filter((label) => !actualLabels.has(label)), []); for (const hook of commandHooks) { assert.doesNotMatch(hook.statusMessage, /\bOMO\b/i); } diff --git a/packages/omo-codex/plugin/test/sync-hook-status-messages.test.mjs b/packages/omo-codex/plugin/test/sync-hook-status-messages.test.mjs new file mode 100644 index 000000000..2f67ec73f --- /dev/null +++ b/packages/omo-codex/plugin/test/sync-hook-status-messages.test.mjs @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import test from "node:test"; + +import { syncHookStatusMessages } from "../scripts/sync-hook-status-messages.mjs"; + +async function writeJson(path, value) { + await writeFile(path, `${JSON.stringify(value, null, "\t")}\n`); +} + +async function readJson(path) { + return JSON.parse(await readFile(path, "utf8")); +} + +test("#given a component without hooks #when hook status messages sync #then build-time version sync skips it", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-hook-status-")); + await mkdir(join(root, ".codex-plugin"), { recursive: true }); + await mkdir(join(root, "hooks"), { recursive: true }); + await mkdir(join(root, "components", "comment-checker", "hooks"), { recursive: true }); + await mkdir(join(root, "components", "git-bash"), { recursive: true }); + await writeJson(join(root, ".codex-plugin", "plugin.json"), { version: "0.1.0" }); + await writeJson(join(root, "components", "comment-checker", "package.json"), { version: "0.1.1" }); + await writeJson(join(root, "components", "git-bash", "package.json"), { version: "0.3.0" }); + await writeJson(join(root, "hooks", "hooks.json"), { + hooks: { + PostToolUse: [ + { + hooks: [ + { + type: "command", + command: 'node "${PLUGIN_ROOT}/components/comment-checker/dist/cli.js" hook post-tool-use', + statusMessage: "LazyCodex(0.1.0): Checking Comments", + }, + ], + }, + ], + }, + }); + await writeJson(join(root, "components", "comment-checker", "hooks", "hooks.json"), { + hooks: { + PostToolUse: [ + { + hooks: [ + { + type: "command", + command: 'node "${PLUGIN_ROOT}/dist/cli.js" hook post-tool-use', + statusMessage: "LazyCodex(0.1.0): Checking Comments", + }, + ], + }, + ], + }, + }); + + // when + await syncHookStatusMessages(root); + + // then + const aggregateHooks = await readJson(join(root, "hooks", "hooks.json")); + const componentHooks = await readJson(join(root, "components", "comment-checker", "hooks", "hooks.json")); + assert.equal(aggregateHooks.hooks.PostToolUse[0].hooks[0].statusMessage, "LazyCodex(0.1.1): Checking Comments"); + assert.equal(componentHooks.hooks.PostToolUse[0].hooks[0].statusMessage, "LazyCodex(0.1.1): Checking Comments"); +});