diff --git a/packages/omo-codex/src/index.ts b/packages/omo-codex/src/index.ts new file mode 100644 index 000000000..fecc03d28 --- /dev/null +++ b/packages/omo-codex/src/index.ts @@ -0,0 +1,2 @@ +export * from "./telemetry" +export * from "./install" diff --git a/packages/omo-codex/src/python-migration-cross-platform.test.ts b/packages/omo-codex/src/python-migration-cross-platform.test.ts new file mode 100644 index 000000000..0af58c6bd --- /dev/null +++ b/packages/omo-codex/src/python-migration-cross-platform.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" + +import { runUserPromptSubmitHook } from "../plugin/components/ultrawork/src/codex-hook" + +const repoRoot = join(import.meta.dir, "..", "..", "..") + +describe("omo-codex Python migration cross-platform behavior", () => { + it("handles empty inventory, malformed input, and Windows paths without Python", () => { + // given + const aggregateHooks = readJson(join(repoRoot, "packages/omo-codex/plugin/hooks/hooks.json")) + const componentHooks = readJson(join(repoRoot, "packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json")) + const hookCommands = collectHookCommands([aggregateHooks, componentHooks]) + + // when + const outputs = [ + runUserPromptSubmitHook(undefined), + runUserPromptSubmitHook({ hook_event_name: "UserPromptSubmit", prompt: "" }), + runUserPromptSubmitHook({ hook_event_name: "UserPromptSubmit", prompt: "refactor ulw_helper.ts" }), + runUserPromptSubmitHook({ + cwd: "C:\\Users\\codex\\project", + hook_event_name: "UserPromptSubmit", + model: "gpt-5.5", + permission_mode: "default", + prompt: "please ulw this", + session_id: "s", + transcript_path: null, + turn_id: "t", + }), + ] + const ultraworkOutput = parseHookOutput(outputs[3]) + + // then + expect(hookCommands).not.toContainEqual(expect.stringMatching(/\bpython3?\b/i)) + expect(hookCommands).toContain('node "${PLUGIN_ROOT}/components/ultrawork/dist/cli.js" hook user-prompt-submit') + expect(hookCommands).toContain('node "${PLUGIN_ROOT}/dist/cli.js" hook user-prompt-submit') + expect(outputs[0]).toBe("") + expect(outputs[1]).toBe("") + expect(outputs[2]).toBe("") + expect(ultraworkOutput.hookSpecificOutput.hookEventName).toBe("UserPromptSubmit") + expect(ultraworkOutput.hookSpecificOutput.additionalContext).toStartWith("") + }) +}) + +function readJson(path: string): unknown { + return JSON.parse(readFileSync(path, "utf8")) +} + +function collectHookCommands(values: readonly unknown[]): readonly string[] { + return values.flatMap(collectHookCommandsFromValue) +} + +function collectHookCommandsFromValue(value: unknown): readonly string[] { + if (typeof value === "string") return [] + if (Array.isArray(value)) return value.flatMap(collectHookCommandsFromValue) + if (!isRecord(value)) return [] + const ownCommand = typeof value["command"] === "string" ? [value["command"]] : [] + return [...ownCommand, ...Object.values(value).flatMap(collectHookCommandsFromValue)] +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +interface UserPromptSubmitHookOutput { + readonly hookSpecificOutput: { + readonly hookEventName: "UserPromptSubmit" + readonly additionalContext: string + } +} + +function parseHookOutput(output: string): UserPromptSubmitHookOutput { + const parsed: unknown = JSON.parse(output) + if (!isUserPromptSubmitHookOutput(parsed)) throw new TypeError("Expected UserPromptSubmit hook output") + return parsed +} + +function isUserPromptSubmitHookOutput(value: unknown): value is UserPromptSubmitHookOutput { + if (!isRecord(value)) return false + const hookSpecificOutput = value["hookSpecificOutput"] + return ( + isRecord(hookSpecificOutput) && + hookSpecificOutput["hookEventName"] === "UserPromptSubmit" && + typeof hookSpecificOutput["additionalContext"] === "string" + ) +} diff --git a/packages/omo-codex/src/python-migration-inventory.test.ts b/packages/omo-codex/src/python-migration-inventory.test.ts new file mode 100644 index 000000000..353938225 --- /dev/null +++ b/packages/omo-codex/src/python-migration-inventory.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "bun:test" +import { readdirSync, statSync } from "node:fs" +import { join, relative, sep } from "node:path" + +const repoRoot = join(import.meta.dir, "..", "..", "..") +const packageRoot = join(repoRoot, "packages", "omo-codex") + +const retainedPythonFiles = [ + "packages/omo-codex/plugin/components/lsp/test/fixtures/broken.py", + "packages/omo-codex/plugin/skills/programming/scripts/go/new-project.py", + "packages/omo-codex/plugin/skills/programming/scripts/python/check-no-excuse-rules.py", + "packages/omo-codex/plugin/skills/programming/scripts/python/new-project.py", + "packages/omo-codex/plugin/skills/programming/scripts/python/new-script.py", + "packages/omo-codex/plugin/skills/programming/scripts/rust/check-no-excuse-rules.py", + "packages/omo-codex/plugin/skills/programming/scripts/rust/new-project.py", +] as const +const retainedPythonFileSet = new Set(retainedPythonFiles) + +describe("omo-codex Python migration inventory", () => { + it("classifies every Python file under packages/omo-codex", () => { + // given + const pythonFiles = listPythonFiles(packageRoot) + + // when + const unclassified = pythonFiles.filter((path) => !retainedPythonFileSet.has(path)) + + // then + expect(unclassified).toEqual([]) + expect(pythonFiles).toEqual([...retainedPythonFiles].sort()) + }) +}) + +function listPythonFiles(root: string): readonly string[] { + const files: string[] = [] + collectPythonFiles(root, files) + return files.sort() +} + +function collectPythonFiles(directory: string, files: string[]): void { + for (const entry of readdirSync(directory)) { + if (entry === "node_modules" || entry === "dist") continue + + const absolutePath = join(directory, entry) + const stats = statSync(absolutePath) + if (stats.isDirectory()) { + collectPythonFiles(absolutePath, files) + continue + } + + if (entry.endsWith(".py") || entry.endsWith(".pyi")) { + files.push(relative(repoRoot, absolutePath).split(sep).join("/")) + } + } +} diff --git a/packages/omo-codex/src/python-migration-regression.test.ts b/packages/omo-codex/src/python-migration-regression.test.ts new file mode 100644 index 000000000..6e474e60a --- /dev/null +++ b/packages/omo-codex/src/python-migration-regression.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" + +const repoRoot = join(import.meta.dir, "..", "..", "..") +const ultraworkRoot = join(repoRoot, "packages/omo-codex/plugin/components/ultrawork") + +describe("omo-codex Python migration regression", () => { + it("keeps package scripts and plugin packaging Python-free", () => { + // given + const componentPackage = readJson(join(ultraworkRoot, "package.json")) + const aggregateHooks = readFileSync(join(repoRoot, "packages/omo-codex/plugin/hooks/hooks.json"), "utf8") + const componentHooks = readFileSync(join(ultraworkRoot, "hooks/hooks.json"), "utf8") + const aggregateTest = readFileSync(join(repoRoot, "packages/omo-codex/plugin/test/aggregate.test.mjs"), "utf8") + + // when + const packagedFiles = isRecord(componentPackage) && Array.isArray(componentPackage["files"]) + ? componentPackage["files"] + : [] + const scripts = isRecord(componentPackage) && isRecord(componentPackage["scripts"]) ? componentPackage["scripts"] : {} + const bin = isRecord(componentPackage) && isRecord(componentPackage["bin"]) ? componentPackage["bin"] : {} + + // then + expect(scripts["build"]).toBe("tsc -p tsconfig.build.json") + expect(scripts["test"]).toBe("vitest --run") + expect(bin["omo-ultrawork"]).toBe("./dist/cli.js") + expect(packagedFiles).toContain("dist") + expect(packagedFiles).not.toContain("hooks/ultrawork-detector.py") + expect(`${aggregateHooks}\n${componentHooks}\n${aggregateTest}`).not.toMatch(/\bpython3?\b|ultrawork-detector\.py/) + }) +}) + +function readJson(path: string): unknown { + return JSON.parse(readFileSync(path, "utf8")) +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +}