feat(cli): add --codex installer + lazycodex auto-routing via OMO_INVOCATION_NAME
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
|
import { basename } from "node:path";
|
||||||
import { getPlatformPackageCandidates, getBinaryPath } from "./platform.js";
|
import { getPlatformPackageCandidates, getBinaryPath } from "./platform.js";
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
@@ -80,6 +81,23 @@ function getPackageBaseName() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine which bin name the user invoked us with (oh-my-opencode, oh-my-openagent, omo, lazycodex).
|
||||||
|
* Propagated to the compiled CLI binary via OMO_INVOCATION_NAME so it can route accordingly
|
||||||
|
* (e.g. `lazycodex` defaults to the Codex install flow).
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function getInvocationName() {
|
||||||
|
if (process.env.OMO_INVOCATION_NAME) {
|
||||||
|
return process.env.OMO_INVOCATION_NAME;
|
||||||
|
}
|
||||||
|
const argv1 = process.argv[1] ?? "";
|
||||||
|
if (!argv1) {
|
||||||
|
return "oh-my-opencode";
|
||||||
|
}
|
||||||
|
return basename(argv1, ".js").replace(/\.exe$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
const { platform, arch } = process;
|
const { platform, arch } = process;
|
||||||
const libcFamily = getLibcFamily();
|
const libcFamily = getLibcFamily();
|
||||||
@@ -119,11 +137,18 @@ function main() {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const invocationName = getInvocationName();
|
||||||
|
const childEnv = {
|
||||||
|
...process.env,
|
||||||
|
OMO_INVOCATION_NAME: invocationName,
|
||||||
|
};
|
||||||
|
|
||||||
for (let index = 0; index < resolvedBinaries.length; index += 1) {
|
for (let index = 0; index < resolvedBinaries.length; index += 1) {
|
||||||
const currentBinary = resolvedBinaries[index];
|
const currentBinary = resolvedBinaries[index];
|
||||||
const hasFallback = index < resolvedBinaries.length - 1;
|
const hasFallback = index < resolvedBinaries.length - 1;
|
||||||
const result = spawnSync(currentBinary.binPath, process.argv.slice(2), {
|
const result = spawnSync(currentBinary.binPath, process.argv.slice(2), {
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
|
env: childEnv,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ describe("runCliInstaller telemetry isolation", () => {
|
|||||||
hasOpenAI: false,
|
hasOpenAI: false,
|
||||||
hasGemini: false,
|
hasGemini: false,
|
||||||
hasCopilot: false,
|
hasCopilot: false,
|
||||||
|
hasCodex: false,
|
||||||
hasOpencodeZen: false,
|
hasOpencodeZen: false,
|
||||||
hasZaiCodingPlan: false,
|
hasZaiCodingPlan: false,
|
||||||
hasKimiForCoding: false,
|
hasKimiForCoding: false,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ describe("runCliInstaller", () => {
|
|||||||
hasOpenAI: false,
|
hasOpenAI: false,
|
||||||
hasGemini: false,
|
hasGemini: false,
|
||||||
hasCopilot: false,
|
hasCopilot: false,
|
||||||
|
hasCodex: false,
|
||||||
hasOpencodeZen: false,
|
hasOpencodeZen: false,
|
||||||
hasZaiCodingPlan: false,
|
hasZaiCodingPlan: false,
|
||||||
hasKimiForCoding: false,
|
hasKimiForCoding: false,
|
||||||
@@ -80,6 +81,7 @@ describe("runCliInstaller", () => {
|
|||||||
hasOpenAI: false,
|
hasOpenAI: false,
|
||||||
hasGemini: false,
|
hasGemini: false,
|
||||||
hasCopilot: false,
|
hasCopilot: false,
|
||||||
|
hasCodex: false,
|
||||||
hasOpencodeZen: false,
|
hasOpencodeZen: false,
|
||||||
hasZaiCodingPlan: false,
|
hasZaiCodingPlan: false,
|
||||||
hasKimiForCoding: false,
|
hasKimiForCoding: false,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
validateNonTuiArgs,
|
validateNonTuiArgs,
|
||||||
} from "./install-validators"
|
} from "./install-validators"
|
||||||
import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version"
|
import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version"
|
||||||
|
import { runCodexInstaller } from "./install-codex"
|
||||||
|
|
||||||
export async function runCliInstaller(args: InstallArgs, version: string): Promise<number> {
|
export async function runCliInstaller(args: InstallArgs, version: string): Promise<number> {
|
||||||
const validation = validateNonTuiArgs(args)
|
const validation = validateNonTuiArgs(args)
|
||||||
@@ -115,6 +116,18 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi
|
|||||||
console.log(` Run ${color.cyan("opencode")} to start!`)
|
console.log(` Run ${color.cyan("opencode")} to start!`)
|
||||||
console.log()
|
console.log()
|
||||||
|
|
||||||
|
if (config.hasCodex) {
|
||||||
|
printInfo("Installing Codex harness adapter...")
|
||||||
|
try {
|
||||||
|
const codexResult = await runCodexInstaller()
|
||||||
|
printSuccess(`Codex plugin installed ${SYMBOLS.arrow} ${color.dim(codexResult.configPath)}`)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
printWarning(`Codex install failed (OpenCode install is still complete): ${message}`)
|
||||||
|
}
|
||||||
|
console.log()
|
||||||
|
}
|
||||||
|
|
||||||
printInfo(
|
printInfo(
|
||||||
"Anonymous telemetry is enabled by default. Disable it with OMO_SEND_ANONYMOUS_TELEMETRY=0 or OMO_DISABLE_POSTHOG=1.",
|
"Anonymous telemetry is enabled by default. Disable it with OMO_SEND_ANONYMOUS_TELEMETRY=0 or OMO_DISABLE_POSTHOG=1.",
|
||||||
)
|
)
|
||||||
|
|||||||
+41
-15
@@ -16,6 +16,41 @@ const VERSION = packageJson.version
|
|||||||
|
|
||||||
const program = new Command()
|
const program = new Command()
|
||||||
|
|
||||||
|
type InstallCommandOptions = {
|
||||||
|
readonly tui?: boolean
|
||||||
|
readonly claude?: InstallArgs["claude"]
|
||||||
|
readonly openai?: InstallArgs["openai"]
|
||||||
|
readonly gemini?: InstallArgs["gemini"]
|
||||||
|
readonly copilot?: InstallArgs["copilot"]
|
||||||
|
readonly codex?: InstallArgs["codex"]
|
||||||
|
readonly opencodeZen?: InstallArgs["opencodeZen"]
|
||||||
|
readonly zaiCodingPlan?: InstallArgs["zaiCodingPlan"]
|
||||||
|
readonly kimiForCoding?: InstallArgs["kimiForCoding"]
|
||||||
|
readonly opencodeGo?: InstallArgs["opencodeGo"]
|
||||||
|
readonly vercelAiGateway?: InstallArgs["vercelAiGateway"]
|
||||||
|
readonly skipAuth?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveInstallArgs(
|
||||||
|
options: InstallCommandOptions,
|
||||||
|
invocationName: string | undefined = process.env.OMO_INVOCATION_NAME,
|
||||||
|
): InstallArgs {
|
||||||
|
return {
|
||||||
|
tui: options.tui !== false,
|
||||||
|
claude: options.claude,
|
||||||
|
openai: options.openai,
|
||||||
|
gemini: options.gemini,
|
||||||
|
copilot: options.copilot,
|
||||||
|
codex: options.codex ?? (invocationName === "lazycodex" ? "yes" : undefined),
|
||||||
|
opencodeZen: options.opencodeZen,
|
||||||
|
zaiCodingPlan: options.zaiCodingPlan,
|
||||||
|
kimiForCoding: options.kimiForCoding,
|
||||||
|
opencodeGo: options.opencodeGo,
|
||||||
|
vercelAiGateway: options.vercelAiGateway,
|
||||||
|
skipAuth: options.skipAuth ?? false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
program
|
program
|
||||||
.name("oh-my-opencode")
|
.name("oh-my-opencode")
|
||||||
.description("The ultimate OpenCode plugin - multi-model orchestration, LSP tools, and more")
|
.description("The ultimate OpenCode plugin - multi-model orchestration, LSP tools, and more")
|
||||||
@@ -32,16 +67,19 @@ program
|
|||||||
.option("--openai <value>", "OpenAI/ChatGPT subscription: no, yes (default: no)")
|
.option("--openai <value>", "OpenAI/ChatGPT subscription: no, yes (default: no)")
|
||||||
.option("--gemini <value>", "Gemini integration: no, yes")
|
.option("--gemini <value>", "Gemini integration: no, yes")
|
||||||
.option("--copilot <value>", "GitHub Copilot subscription: no, yes")
|
.option("--copilot <value>", "GitHub Copilot subscription: no, yes")
|
||||||
|
.option("--codex <value>", "Install Codex harness adapter: no, yes (default: no)")
|
||||||
.option("--opencode-zen <value>", "OpenCode Zen access: no, yes (default: no)")
|
.option("--opencode-zen <value>", "OpenCode Zen access: no, yes (default: no)")
|
||||||
.option("--zai-coding-plan <value>", "Z.ai Coding Plan subscription: no, yes (default: no)")
|
.option("--zai-coding-plan <value>", "Z.ai Coding Plan subscription: no, yes (default: no)")
|
||||||
.option("--kimi-for-coding <value>", "Kimi For Coding subscription: no, yes (default: no)")
|
.option("--kimi-for-coding <value>", "Kimi For Coding subscription: no, yes (default: no)")
|
||||||
.option("--opencode-go <value>", "OpenCode Go subscription: no, yes (default: no)")
|
.option("--opencode-go <value>", "OpenCode Go subscription: no, yes (default: no)")
|
||||||
.option("--vercel-ai-gateway <value>", "Vercel AI Gateway: no, yes (default: no)")
|
.option("--vercel-ai-gateway <value>", "Vercel AI Gateway: no, yes (default: no)")
|
||||||
.option("--skip-auth", "Skip authentication setup hints")
|
.option("--skip-auth", "Skip authentication setup hints")
|
||||||
.addHelpText("after", `
|
.addHelpText("after", `
|
||||||
Examples:
|
Examples:
|
||||||
$ bunx oh-my-opencode install
|
$ bunx oh-my-opencode install
|
||||||
$ bunx oh-my-opencode install --no-tui --claude=max20 --openai=yes --gemini=yes --copilot=no
|
$ bunx lazycodex install --no-tui --claude=yes --gemini=no --copilot=no
|
||||||
|
$ bunx oh-my-opencode install --no-tui --claude=max20 --openai=yes --gemini=yes --copilot=no --codex=yes
|
||||||
|
$ omo install --codex=yes
|
||||||
$ bunx oh-my-opencode install --no-tui --claude=no --gemini=no --copilot=yes --opencode-zen=yes
|
$ bunx oh-my-opencode install --no-tui --claude=no --gemini=no --copilot=yes --opencode-zen=yes
|
||||||
|
|
||||||
Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai > Kimi > Vercel):
|
Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai > Kimi > Vercel):
|
||||||
@@ -55,19 +93,7 @@ Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai > Kimi > Verce
|
|||||||
Vercel vercel/ models (universal proxy, always last fallback)
|
Vercel vercel/ models (universal proxy, always last fallback)
|
||||||
`)
|
`)
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
const args: InstallArgs = {
|
const args = resolveInstallArgs(options)
|
||||||
tui: options.tui !== false,
|
|
||||||
claude: options.claude,
|
|
||||||
openai: options.openai,
|
|
||||||
gemini: options.gemini,
|
|
||||||
copilot: options.copilot,
|
|
||||||
opencodeZen: options.opencodeZen,
|
|
||||||
zaiCodingPlan: options.zaiCodingPlan,
|
|
||||||
kimiForCoding: options.kimiForCoding,
|
|
||||||
opencodeGo: options.opencodeGo,
|
|
||||||
vercelAiGateway: options.vercelAiGateway,
|
|
||||||
skipAuth: options.skipAuth ?? false,
|
|
||||||
}
|
|
||||||
const exitCode = await install(args)
|
const exitCode = await install(args)
|
||||||
process.exit(exitCode)
|
process.exit(exitCode)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export function detectCurrentConfig(): DetectedConfig {
|
|||||||
hasOpenAI: true,
|
hasOpenAI: true,
|
||||||
hasGemini: false,
|
hasGemini: false,
|
||||||
hasCopilot: false,
|
hasCopilot: false,
|
||||||
|
hasCodex: false,
|
||||||
hasOpencodeZen: true,
|
hasOpencodeZen: true,
|
||||||
hasZaiCodingPlan: false,
|
hasZaiCodingPlan: false,
|
||||||
hasKimiForCoding: false,
|
hasKimiForCoding: false,
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { mkdir, mkdtemp, readFile, readlink, writeFile } from "node:fs/promises"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import { linkCachedPluginBins, rewriteCachedMcpManifest } from "./codex-cache"
|
||||||
|
|
||||||
|
describe("codex-cache", () => {
|
||||||
|
test("rewrites cached mcp manifest relative args and cwd", async () => {
|
||||||
|
// given
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "omo-codex-cache-"))
|
||||||
|
await writeFile(
|
||||||
|
join(root, ".mcp.json"),
|
||||||
|
JSON.stringify({ mcpServers: { lsp: { cwd: ".", args: ["./components/lsp/dist/cli.js", "mcp"] } } }),
|
||||||
|
)
|
||||||
|
|
||||||
|
// when
|
||||||
|
await rewriteCachedMcpManifest(root)
|
||||||
|
|
||||||
|
// then
|
||||||
|
const rewritten = JSON.parse(await readFile(join(root, ".mcp.json"), "utf8")) as {
|
||||||
|
mcpServers: { lsp: { cwd?: string; args: string[] } }
|
||||||
|
}
|
||||||
|
expect(rewritten.mcpServers.lsp.cwd).toBeUndefined()
|
||||||
|
expect(rewritten.mcpServers.lsp.args[0]).toBe(join(root, "./components/lsp/dist/cli.js"))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("links cached plugin bins and stays idempotent", async () => {
|
||||||
|
// given
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "omo-codex-cache-"))
|
||||||
|
const pluginRoot = join(root, "plugin")
|
||||||
|
const binDir = join(root, "bin")
|
||||||
|
await mkdir(pluginRoot, { recursive: true })
|
||||||
|
await writeFile(join(pluginRoot, "package.json"), JSON.stringify({ name: "@scope/omo", bin: { "omo-hook": "dist/cli.js" } }))
|
||||||
|
await mkdir(join(pluginRoot, "dist"), { recursive: true })
|
||||||
|
await writeFile(join(pluginRoot, "dist", "cli.js"), "#!/usr/bin/env node\n")
|
||||||
|
|
||||||
|
// when
|
||||||
|
const first = await linkCachedPluginBins({ binDir, pluginRoot })
|
||||||
|
const second = await linkCachedPluginBins({ binDir, pluginRoot })
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(first).toHaveLength(1)
|
||||||
|
expect(second).toHaveLength(1)
|
||||||
|
const linkedTarget = await readlink(join(binDir, "omo-hook"))
|
||||||
|
expect(linkedTarget).toBe(join(pluginRoot, "dist", "cli.js"))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, symlink, writeFile } from "node:fs/promises"
|
||||||
|
import { basename, dirname, join, sep } from "node:path"
|
||||||
|
import type { InstalledPlugin, RunCommand } from "./types"
|
||||||
|
|
||||||
|
export async function installCachedPlugin(input: {
|
||||||
|
readonly codexHome: string
|
||||||
|
readonly marketplaceName: string
|
||||||
|
readonly name: string
|
||||||
|
readonly sourcePath: string
|
||||||
|
readonly version: string
|
||||||
|
readonly runCommand: RunCommand
|
||||||
|
}): Promise<InstalledPlugin> {
|
||||||
|
await maybeRunNpmInstall(input.sourcePath, input.runCommand)
|
||||||
|
await maybeRunNpmBuild(input.sourcePath, input.runCommand)
|
||||||
|
|
||||||
|
const targetPath = join(input.codexHome, "plugins", "cache", input.marketplaceName, input.name, input.version)
|
||||||
|
await replaceDirectory(input.sourcePath, targetPath)
|
||||||
|
await maybeRunNpmInstall(targetPath, input.runCommand, ["install", "--omit=dev"])
|
||||||
|
await rewriteCachedMcpManifest(targetPath)
|
||||||
|
return { name: input.name, version: input.version, path: targetPath }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pruneMarketplaceCache(input: {
|
||||||
|
readonly codexHome: string
|
||||||
|
readonly marketplaceName: string
|
||||||
|
readonly keepPluginNames: readonly string[]
|
||||||
|
}): Promise<void> {
|
||||||
|
const cacheRoot = join(input.codexHome, "plugins", "cache", input.marketplaceName)
|
||||||
|
if (!(await exists(cacheRoot))) return
|
||||||
|
const keep = new Set(input.keepPluginNames)
|
||||||
|
const entries = await readdir(cacheRoot, { withFileTypes: true })
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory() || keep.has(entry.name)) continue
|
||||||
|
await rm(join(cacheRoot, entry.name), { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function linkCachedPluginBins(input: {
|
||||||
|
readonly binDir: string
|
||||||
|
readonly pluginRoot: string
|
||||||
|
}): Promise<readonly { name: string; path: string; target: string }[]> {
|
||||||
|
const binLinks = await discoverPackageBins(input.pluginRoot)
|
||||||
|
await mkdir(input.binDir, { recursive: true })
|
||||||
|
const linked: Array<{ name: string; path: string; target: string }> = []
|
||||||
|
for (const link of binLinks) {
|
||||||
|
const linkPath = join(input.binDir, link.name)
|
||||||
|
await replaceSymlink(linkPath, link.target)
|
||||||
|
linked.push({ name: link.name, path: linkPath, target: link.target })
|
||||||
|
}
|
||||||
|
return linked
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rewriteCachedMcpManifest(pluginRoot: string): Promise<void> {
|
||||||
|
const manifestPath = join(pluginRoot, ".mcp.json")
|
||||||
|
if (!(await exists(manifestPath))) return
|
||||||
|
const raw = await readFile(manifestPath, "utf8")
|
||||||
|
const parsed: unknown = JSON.parse(raw)
|
||||||
|
if (!isRecord(parsed) || !isRecord(parsed.mcpServers)) return
|
||||||
|
let changed = false
|
||||||
|
for (const server of Object.values(parsed.mcpServers)) {
|
||||||
|
if (!isRecord(server)) continue
|
||||||
|
if (server.cwd === "." || server.cwd === "./") {
|
||||||
|
delete server.cwd
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
const currentArgs = server.args
|
||||||
|
if (!Array.isArray(currentArgs)) continue
|
||||||
|
const nextArgs = currentArgs.map((arg) => {
|
||||||
|
if (typeof arg !== "string") return arg
|
||||||
|
if (arg.startsWith("./") || arg.startsWith("../")) return join(pluginRoot, arg)
|
||||||
|
return arg
|
||||||
|
})
|
||||||
|
if (nextArgs.some((value, index) => value !== currentArgs[index])) {
|
||||||
|
server.args = nextArgs
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) await writeFile(manifestPath, `${JSON.stringify(parsed, null, "\t")}\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maybeRunNpmInstall(cwd: string, runCommand: RunCommand, args: readonly string[] = ["install"]): Promise<void> {
|
||||||
|
if (!(await exists(join(cwd, "package.json")))) return
|
||||||
|
await runCommand("npm", args, { cwd })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maybeRunNpmBuild(cwd: string, runCommand: RunCommand): Promise<void> {
|
||||||
|
if (!(await exists(join(cwd, "package.json")))) return
|
||||||
|
const packageJson: unknown = JSON.parse(await readFile(join(cwd, "package.json"), "utf8"))
|
||||||
|
if (!isRecord(packageJson)) return
|
||||||
|
const scripts = packageJson.scripts
|
||||||
|
if (!isRecord(scripts) || typeof scripts.build !== "string") return
|
||||||
|
await runCommand("npm", ["run", "build"], { cwd })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceDirectory(sourcePath: string, targetPath: string): Promise<void> {
|
||||||
|
await mkdir(dirname(targetPath), { recursive: true })
|
||||||
|
const tempPath = join(dirname(targetPath), `.tmp-${basename(targetPath)}-${process.pid}-${Date.now()}`)
|
||||||
|
await rm(tempPath, { recursive: true, force: true })
|
||||||
|
await cp(sourcePath, tempPath, { recursive: true, filter: (source) => shouldCopyPluginPath(source, sourcePath) })
|
||||||
|
await rm(targetPath, { recursive: true, force: true })
|
||||||
|
await rename(tempPath, targetPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discoverPackageBins(root: string): Promise<readonly { name: string; target: string }[]> {
|
||||||
|
const links: Array<{ name: string; target: string }> = []
|
||||||
|
await collectPackageBins(root, root, links)
|
||||||
|
return links
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectPackageBins(directory: string, root: string, links: Array<{ name: string; target: string }>): Promise<void> {
|
||||||
|
const entries = await readdir(directory, { withFileTypes: true })
|
||||||
|
if (entries.some((entry) => entry.isFile() && entry.name === "package.json")) {
|
||||||
|
await appendPackageBinLinks(join(directory, "package.json"), directory, links)
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue
|
||||||
|
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") continue
|
||||||
|
const childPath = join(directory, entry.name)
|
||||||
|
if (!childPath.startsWith(root)) continue
|
||||||
|
await collectPackageBins(childPath, root, links)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function appendPackageBinLinks(packageJsonPath: string, packageRoot: string, links: Array<{ name: string; target: string }>): Promise<void> {
|
||||||
|
const packageJson: unknown = JSON.parse(await readFile(packageJsonPath, "utf8"))
|
||||||
|
if (!isRecord(packageJson)) return
|
||||||
|
const packageName = packageJson.name
|
||||||
|
const packageBin = packageJson.bin
|
||||||
|
if (typeof packageBin === "string" && typeof packageName === "string") {
|
||||||
|
links.push({ name: basename(packageName), target: join(packageRoot, packageBin) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isRecord(packageBin)) return
|
||||||
|
for (const [name, target] of Object.entries(packageBin)) {
|
||||||
|
if (typeof target !== "string") continue
|
||||||
|
links.push({ name, target: join(packageRoot, target) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceSymlink(linkPath: string, targetPath: string): Promise<void> {
|
||||||
|
if (await existingNonSymlink(linkPath)) throw new Error(`${linkPath} already exists and is not a symlink`)
|
||||||
|
await rm(linkPath, { force: true })
|
||||||
|
await symlink(targetPath, linkPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function existingNonSymlink(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const stat = await lstat(path)
|
||||||
|
if (!stat.isSymbolicLink()) return true
|
||||||
|
await readlink(path)
|
||||||
|
return false
|
||||||
|
} catch (error) {
|
||||||
|
if (isNodeErrorWithCode(error) && error.code === "ENOENT") return false
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldCopyPluginPath(path: string, root: string): boolean {
|
||||||
|
const relative = path === root ? "" : path.slice(root.length + sep.length)
|
||||||
|
if (relative === "") return true
|
||||||
|
const parts = relative.split(sep)
|
||||||
|
return !parts.some((part) => part === ".git" || part === "node_modules")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exists(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await lstat(path)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNodeErrorWithCode(error: unknown): error is NodeJS.ErrnoException {
|
||||||
|
return typeof error === "object" && error !== null && "code" in error
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { mkdtemp, readFile } from "node:fs/promises"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import { updateCodexConfig } from "./codex-config-toml"
|
||||||
|
|
||||||
|
describe("codex-config-toml", () => {
|
||||||
|
test("writes config blocks and stays idempotent", async () => {
|
||||||
|
// given
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "omo-codex-config-"))
|
||||||
|
const configPath = join(root, "config.toml")
|
||||||
|
|
||||||
|
// when
|
||||||
|
await updateCodexConfig({
|
||||||
|
configPath,
|
||||||
|
repoRoot: "/repo/packages/omo-codex",
|
||||||
|
marketplaceName: "code-yeongyu-codex-plugins",
|
||||||
|
pluginNames: ["omo"],
|
||||||
|
trustedHookStates: [{ key: "omo@code-yeongyu-codex-plugins:hooks/hooks.json:post_tool_use:0:0", trustedHash: "sha256:abc" }],
|
||||||
|
})
|
||||||
|
await updateCodexConfig({
|
||||||
|
configPath,
|
||||||
|
repoRoot: "/repo/packages/omo-codex",
|
||||||
|
marketplaceName: "code-yeongyu-codex-plugins",
|
||||||
|
pluginNames: ["omo"],
|
||||||
|
trustedHookStates: [{ key: "omo@code-yeongyu-codex-plugins:hooks/hooks.json:post_tool_use:0:0", trustedHash: "sha256:abc" }],
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
const content = await readFile(configPath, "utf8")
|
||||||
|
expect(content).toContain("[features]")
|
||||||
|
expect(content).toContain("plugins = true")
|
||||||
|
expect(content).toContain("plugin_hooks = true")
|
||||||
|
expect(content).toContain("[marketplaces.code-yeongyu-codex-plugins]")
|
||||||
|
expect(content).toContain("[plugins.\"omo@code-yeongyu-codex-plugins\"]")
|
||||||
|
expect(content).toContain("[hooks.state.\"omo@code-yeongyu-codex-plugins:hooks/hooks.json:post_tool_use:0:0\"]")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { mkdir, readFile, writeFile } from "node:fs/promises"
|
||||||
|
import { dirname } from "node:path"
|
||||||
|
import type { TrustedHookState } from "./types"
|
||||||
|
|
||||||
|
export async function updateCodexConfig(input: {
|
||||||
|
readonly configPath: string
|
||||||
|
readonly repoRoot: string
|
||||||
|
readonly marketplaceName: string
|
||||||
|
readonly pluginNames: readonly string[]
|
||||||
|
readonly trustedHookStates?: readonly TrustedHookState[]
|
||||||
|
}): Promise<void> {
|
||||||
|
await mkdir(dirname(input.configPath), { recursive: true })
|
||||||
|
let config = ""
|
||||||
|
if (await exists(input.configPath)) config = await readFile(input.configPath, "utf8")
|
||||||
|
|
||||||
|
const pluginSet = new Set(input.pluginNames)
|
||||||
|
config = removeStaleMarketplacePluginBlocks(config, input.marketplaceName, pluginSet)
|
||||||
|
config = removeStaleMarketplaceHookStateBlocks(config, input.marketplaceName, pluginSet)
|
||||||
|
config = ensureFeatureEnabled(config, "plugins")
|
||||||
|
config = ensureFeatureEnabled(config, "plugin_hooks")
|
||||||
|
config = ensureMarketplaceBlock(config, input.marketplaceName, input.repoRoot)
|
||||||
|
for (const pluginName of input.pluginNames) {
|
||||||
|
config = ensurePluginEnabled(config, `${pluginName}@${input.marketplaceName}`)
|
||||||
|
}
|
||||||
|
for (const state of input.trustedHookStates ?? []) {
|
||||||
|
config = ensureHookTrusted(config, state.key, state.trustedHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(input.configPath, `${config.trimEnd()}\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeStaleMarketplacePluginBlocks(config: string, marketplaceName: string, keepPluginNames: Set<string>): string {
|
||||||
|
return removeTomlSections(config, (header) => {
|
||||||
|
const pluginKey = parseQuotedPluginHeader(header)
|
||||||
|
if (pluginKey === null) return false
|
||||||
|
const suffix = `@${marketplaceName}`
|
||||||
|
if (!pluginKey.endsWith(suffix)) return false
|
||||||
|
return !keepPluginNames.has(pluginKey.slice(0, -suffix.length))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeStaleMarketplaceHookStateBlocks(config: string, marketplaceName: string, keepPluginNames: Set<string>): string {
|
||||||
|
return removeTomlSections(config, (header) => {
|
||||||
|
const prefix = "hooks.state."
|
||||||
|
if (!header.startsWith(prefix)) return false
|
||||||
|
const hookKey = parseJsonString(header.slice(prefix.length))
|
||||||
|
if (hookKey === null) return false
|
||||||
|
const separator = hookKey.indexOf(":")
|
||||||
|
if (separator === -1) return false
|
||||||
|
const pluginKey = hookKey.slice(0, separator)
|
||||||
|
const suffix = `@${marketplaceName}`
|
||||||
|
if (!pluginKey.endsWith(suffix)) return false
|
||||||
|
return !keepPluginNames.has(pluginKey.slice(0, -suffix.length))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureFeatureEnabled(config: string, featureName: string): string {
|
||||||
|
const section = findTomlSection(config, "features")
|
||||||
|
if (!section) return appendBlock(config, `[features]\n${featureName} = true\n`)
|
||||||
|
return replaceOrInsertSetting(config, section, featureName, "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureMarketplaceBlock(config: string, marketplaceName: string, repoRoot: string): string {
|
||||||
|
const header = `marketplaces.${marketplaceName}`
|
||||||
|
if (findTomlSection(config, header)) return config
|
||||||
|
return appendBlock(
|
||||||
|
config,
|
||||||
|
[
|
||||||
|
`[${header}]`,
|
||||||
|
`last_updated = "${new Date().toISOString().replace(/\.\d{3}Z$/, "Z")}"`,
|
||||||
|
'source_type = "local"',
|
||||||
|
`source = ${JSON.stringify(repoRoot)}`,
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePluginEnabled(config: string, pluginKey: string): string {
|
||||||
|
const header = `plugins.${JSON.stringify(pluginKey)}`
|
||||||
|
const section = findTomlSection(config, header)
|
||||||
|
if (!section) return appendBlock(config, `[${header}]\nenabled = true\n`)
|
||||||
|
return replaceOrInsertSetting(config, section, "enabled", "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureHookTrusted(config: string, key: string, trustedHash: string): string {
|
||||||
|
const header = `hooks.state.${JSON.stringify(key)}`
|
||||||
|
const section = findTomlSection(config, header)
|
||||||
|
if (!section) return appendBlock(config, `[${header}]\ntrusted_hash = ${JSON.stringify(trustedHash)}\n`)
|
||||||
|
return replaceOrInsertSetting(config, section, "trusted_hash", JSON.stringify(trustedHash))
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTomlSections(config: string, shouldRemove: (header: string) => boolean): string {
|
||||||
|
return splitTomlSections(config)
|
||||||
|
.filter((section) => section.header === null || !shouldRemove(section.header))
|
||||||
|
.map((section) => section.text)
|
||||||
|
.join("")
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTomlSections(config: string): Array<{ header: string | null; text: string }> {
|
||||||
|
const lines = config.match(/[^\n]*\n?|$/g) ?? []
|
||||||
|
const sections: Array<{ header: string | null; text: string }> = []
|
||||||
|
let current: { header: string | null; text: string } = { header: null, text: "" }
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.length === 0) break
|
||||||
|
const header = parseTomlHeader(line)
|
||||||
|
if (header !== null) {
|
||||||
|
if (current.text.length > 0) sections.push(current)
|
||||||
|
current = { header, text: line }
|
||||||
|
} else {
|
||||||
|
current.text += line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current.text.length > 0) sections.push(current)
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTomlSection(config: string, header: string): { start: number; end: number; text: string } | null {
|
||||||
|
const headerLine = `[${header}]`
|
||||||
|
const lines = config.match(/[^\n]*\n?|$/g) ?? []
|
||||||
|
let offset = 0
|
||||||
|
let start = -1
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.length === 0) break
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (start === -1) {
|
||||||
|
if (trimmed === headerLine) start = offset
|
||||||
|
} else if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
||||||
|
return { start, end: offset, text: config.slice(start, offset) }
|
||||||
|
}
|
||||||
|
offset += line.length
|
||||||
|
}
|
||||||
|
if (start === -1) return null
|
||||||
|
return { start, end: config.length, text: config.slice(start) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceOrInsertSetting(config: string, section: { start: number; end: number; text: string }, key: string, value: string): string {
|
||||||
|
const linePattern = new RegExp(`^${escapeRegExp(key)}\\s*=.*$`, "m")
|
||||||
|
const replacement = linePattern.test(section.text)
|
||||||
|
? section.text.replace(linePattern, `${key} = ${value}`)
|
||||||
|
: insertSetting(section.text, key, value)
|
||||||
|
return config.slice(0, section.start) + replacement + config.slice(section.end)
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertSetting(sectionText: string, key: string, value: string): string {
|
||||||
|
const lines = sectionText.split("\n")
|
||||||
|
lines.splice(1, 0, `${key} = ${value}`)
|
||||||
|
return lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTomlHeader(line: string): string | null {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed.startsWith("[") || !trimmed.endsWith("]") || trimmed.startsWith("[[")) return null
|
||||||
|
return trimmed.slice(1, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseQuotedPluginHeader(header: string): string | null {
|
||||||
|
const prefix = "plugins."
|
||||||
|
if (!header.startsWith(prefix)) return null
|
||||||
|
return parseJsonString(header.slice(prefix.length))
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonString(value: string): string | null {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(value)
|
||||||
|
return typeof parsed === "string" ? parsed : null
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) return null
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendBlock(config: string, block: string): string {
|
||||||
|
const prefix = config.trimEnd()
|
||||||
|
return `${prefix}${prefix.length > 0 ? "\n\n" : ""}${block.trimEnd()}\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exists(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await readFile(path, "utf8")
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) return false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import { trustedHookStatesForPlugin } from "./codex-hook-trust"
|
||||||
|
|
||||||
|
describe("codex-hook-trust", () => {
|
||||||
|
test("computes trusted hook hashes for vendored plugin", async () => {
|
||||||
|
// given
|
||||||
|
const pluginRoot = join(
|
||||||
|
"/Users/yeongyu/local-workspaces/omodex",
|
||||||
|
"packages",
|
||||||
|
"omo-codex",
|
||||||
|
"plugin",
|
||||||
|
)
|
||||||
|
|
||||||
|
// when
|
||||||
|
const states = await trustedHookStatesForPlugin({
|
||||||
|
marketplaceName: "code-yeongyu-codex-plugins",
|
||||||
|
pluginName: "omo",
|
||||||
|
pluginRoot,
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(states.length).toBeGreaterThan(0)
|
||||||
|
expect(states[0]?.trustedHash.startsWith("sha256:")).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { createHash } from "node:crypto"
|
||||||
|
import { readFile } from "node:fs/promises"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import type { TrustedHookState } from "./types"
|
||||||
|
|
||||||
|
const EVENT_LABELS = new Map<string, string>([
|
||||||
|
["PreToolUse", "pre_tool_use"],
|
||||||
|
["PermissionRequest", "permission_request"],
|
||||||
|
["PostToolUse", "post_tool_use"],
|
||||||
|
["PreCompact", "pre_compact"],
|
||||||
|
["PostCompact", "post_compact"],
|
||||||
|
["SessionStart", "session_start"],
|
||||||
|
["UserPromptSubmit", "user_prompt_submit"],
|
||||||
|
["SubagentStart", "subagent_start"],
|
||||||
|
["SubagentStop", "subagent_stop"],
|
||||||
|
["Stop", "stop"],
|
||||||
|
])
|
||||||
|
|
||||||
|
export async function trustedHookStatesForPlugin(input: {
|
||||||
|
readonly marketplaceName: string
|
||||||
|
readonly pluginName: string
|
||||||
|
readonly pluginRoot: string
|
||||||
|
}): Promise<readonly TrustedHookState[]> {
|
||||||
|
const manifestPath = join(input.pluginRoot, ".codex-plugin", "plugin.json")
|
||||||
|
if (!(await exists(manifestPath))) return []
|
||||||
|
const manifest: unknown = JSON.parse(await readFile(manifestPath, "utf8"))
|
||||||
|
if (!isRecord(manifest) || typeof manifest.hooks !== "string") return []
|
||||||
|
|
||||||
|
const hooksPath = join(input.pluginRoot, manifest.hooks)
|
||||||
|
if (!(await exists(hooksPath))) return []
|
||||||
|
const parsed: unknown = JSON.parse(await readFile(hooksPath, "utf8"))
|
||||||
|
if (!isRecord(parsed) || !isRecord(parsed.hooks)) return []
|
||||||
|
|
||||||
|
const keySource = `${input.pluginName}@${input.marketplaceName}:${stripDotSlash(manifest.hooks)}`
|
||||||
|
const states: TrustedHookState[] = []
|
||||||
|
for (const [eventName, groups] of Object.entries(parsed.hooks)) {
|
||||||
|
if (!Array.isArray(groups)) continue
|
||||||
|
const eventLabel = EVENT_LABELS.get(eventName)
|
||||||
|
if (eventLabel === undefined) continue
|
||||||
|
for (const [groupIndex, group] of groups.entries()) {
|
||||||
|
if (!isRecord(group) || !Array.isArray(group.hooks)) continue
|
||||||
|
for (const [handlerIndex, handler] of group.hooks.entries()) {
|
||||||
|
if (!isRecord(handler) || handler.type !== "command") continue
|
||||||
|
if (handler.async === true) continue
|
||||||
|
if (typeof handler.command !== "string" || handler.command.trim() === "") continue
|
||||||
|
const key = `${keySource}:${eventLabel}:${groupIndex}:${handlerIndex}`
|
||||||
|
states.push({ key, trustedHash: commandHookHash(eventLabel, group.matcher, handler) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return states
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandHookHash(eventName: string, matcher: unknown, handler: Record<string, unknown>): string {
|
||||||
|
const timeout = Math.max(Number(handler.timeout ?? 600), 1)
|
||||||
|
const normalizedHandler: Record<string, unknown> = {
|
||||||
|
type: "command",
|
||||||
|
command: handler.command,
|
||||||
|
timeout,
|
||||||
|
async: false,
|
||||||
|
}
|
||||||
|
if (typeof handler.statusMessage === "string") normalizedHandler.statusMessage = handler.statusMessage
|
||||||
|
|
||||||
|
const identity: Record<string, unknown> = { event_name: eventName, hooks: [normalizedHandler] }
|
||||||
|
if (typeof matcher === "string") identity.matcher = matcher
|
||||||
|
const canonical = JSON.stringify(canonicalJson(identity))
|
||||||
|
return `sha256:${createHash("sha256").update(canonical).digest("hex")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalJson(value: unknown): unknown {
|
||||||
|
if (Array.isArray(value)) return value.map(canonicalJson)
|
||||||
|
if (!isRecord(value)) return value
|
||||||
|
const result: Record<string, unknown> = {}
|
||||||
|
for (const key of Object.keys(value).sort()) {
|
||||||
|
result[key] = canonicalJson(value[key])
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripDotSlash(value: string): string {
|
||||||
|
return value.startsWith("./") ? value.slice(2) : value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exists(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await readFile(path, "utf8")
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { mkdir, mkdtemp, writeFile } from "node:fs/promises"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import { readMarketplace, readPluginManifest, resolvePluginSource } from "./codex-marketplace"
|
||||||
|
|
||||||
|
describe("codex-marketplace", () => {
|
||||||
|
test("reads marketplace and resolves plugin source with override", async () => {
|
||||||
|
// given
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "omo-codex-marketplace-"))
|
||||||
|
const pkgRoot = join(root, "packages", "omo-codex")
|
||||||
|
const pluginRoot = join(pkgRoot, "plugin", ".codex-plugin")
|
||||||
|
await mkdir(pluginRoot, { recursive: true })
|
||||||
|
await writeFile(join(pkgRoot, "marketplace.json"), JSON.stringify({ name: "code-yeongyu-codex-plugins", plugins: [{ name: "omo", source: "./plugins/omo" }] }))
|
||||||
|
await writeFile(join(pluginRoot, "plugin.json"), JSON.stringify({ name: "omo", version: "0.1.0" }))
|
||||||
|
|
||||||
|
// when
|
||||||
|
const marketplace = await readMarketplace(root)
|
||||||
|
const sourcePath = resolvePluginSource(pkgRoot, marketplace.plugins[0], { pathOverride: "./plugin" })
|
||||||
|
const manifest = await readPluginManifest(sourcePath)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(marketplace.name).toBe("code-yeongyu-codex-plugins")
|
||||||
|
expect(sourcePath).toBe(join(pkgRoot, "plugin"))
|
||||||
|
expect(manifest.name).toBe("omo")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects traversal source path", async () => {
|
||||||
|
// given
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "omo-codex-marketplace-"))
|
||||||
|
const pkgRoot = join(root, "packages", "omo-codex")
|
||||||
|
await mkdir(pkgRoot, { recursive: true })
|
||||||
|
await writeFile(join(pkgRoot, "marketplace.json"), JSON.stringify({ name: "code-yeongyu-codex-plugins", plugins: [{ name: "omo", source: "./../escape" }] }))
|
||||||
|
|
||||||
|
// when
|
||||||
|
const action = readMarketplace(root)
|
||||||
|
|
||||||
|
// then
|
||||||
|
await expect(action).rejects.toThrow("local plugin source path must stay within the marketplace root")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { readFile } from "node:fs/promises"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import type {
|
||||||
|
MarketplaceManifest,
|
||||||
|
MarketplacePluginEntry,
|
||||||
|
MarketplacePluginSourceLocal,
|
||||||
|
PluginManifest,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
|
const DEFAULT_MARKETPLACE_PATH = "packages/omo-codex/marketplace.json"
|
||||||
|
|
||||||
|
export async function readMarketplace(
|
||||||
|
repoRoot: string,
|
||||||
|
options?: { readonly marketplacePath?: string },
|
||||||
|
): Promise<MarketplaceManifest> {
|
||||||
|
const marketplacePath = options?.marketplacePath ?? join(repoRoot, DEFAULT_MARKETPLACE_PATH)
|
||||||
|
const raw = await readFile(marketplacePath, "utf8")
|
||||||
|
const parsed: unknown = JSON.parse(raw)
|
||||||
|
if (!isRecord(parsed)) throw new Error("marketplace.json must be an object")
|
||||||
|
if (typeof parsed.name !== "string" || parsed.name.trim() === "") {
|
||||||
|
throw new Error("marketplace.json name must be a non-empty string")
|
||||||
|
}
|
||||||
|
validatePathSegment(parsed.name, "marketplace name")
|
||||||
|
if (!Array.isArray(parsed.plugins)) throw new Error("marketplace.json plugins must be an array")
|
||||||
|
return {
|
||||||
|
name: parsed.name,
|
||||||
|
plugins: parsed.plugins.map((plugin, index) => normalizeMarketplacePlugin(plugin, index)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePluginSource(
|
||||||
|
repoRoot: string,
|
||||||
|
plugin: MarketplacePluginEntry,
|
||||||
|
options?: { readonly pathOverride?: string },
|
||||||
|
): string {
|
||||||
|
const sourcePath = localSourcePath(options?.pathOverride ?? plugin.source)
|
||||||
|
const relativePath = sourcePath.slice(2)
|
||||||
|
return join(repoRoot, ...relativePath.split(/[\\/]/))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readPluginManifest(pluginRoot: string): Promise<PluginManifest> {
|
||||||
|
const raw = await readFile(join(pluginRoot, ".codex-plugin", "plugin.json"), "utf8")
|
||||||
|
const parsed: unknown = JSON.parse(raw)
|
||||||
|
if (!isRecord(parsed)) throw new Error(`${pluginRoot} plugin.json must be an object`)
|
||||||
|
if (typeof parsed.name !== "string" || parsed.name.trim() === "") {
|
||||||
|
throw new Error(`${pluginRoot} plugin.json name must be a non-empty string`)
|
||||||
|
}
|
||||||
|
if (parsed.version !== undefined && (typeof parsed.version !== "string" || parsed.version.trim() === "")) {
|
||||||
|
throw new Error(`${pluginRoot} plugin.json version must be a non-empty string`)
|
||||||
|
}
|
||||||
|
if (parsed.hooks !== undefined && (typeof parsed.hooks !== "string" || parsed.hooks.trim() === "")) {
|
||||||
|
throw new Error(`${pluginRoot} plugin.json hooks must be a non-empty string`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: parsed.name,
|
||||||
|
version: typeof parsed.version === "string" ? parsed.version.trim() : undefined,
|
||||||
|
hooks: typeof parsed.hooks === "string" ? parsed.hooks.trim() : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePathSegment(value: string, label: string): void {
|
||||||
|
if (!/^[A-Za-z0-9._+-]+$/.test(value)) {
|
||||||
|
throw new Error(`${label} contains unsupported characters: ${value}`)
|
||||||
|
}
|
||||||
|
if (value === "." || value === "..") {
|
||||||
|
throw new Error(`${label} must not be a path traversal segment`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMarketplacePlugin(plugin: unknown, index: number): MarketplacePluginEntry {
|
||||||
|
if (!isRecord(plugin)) throw new Error(`marketplace plugin ${index} must be an object`)
|
||||||
|
if (typeof plugin.name !== "string" || plugin.name.trim() === "") {
|
||||||
|
throw new Error(`marketplace plugin ${index} name must be a non-empty string`)
|
||||||
|
}
|
||||||
|
validatePathSegment(plugin.name, "plugin name")
|
||||||
|
if (plugin.source === undefined || typeof plugin.source === "string") {
|
||||||
|
if (typeof plugin.source === "string") {
|
||||||
|
validateLocalSourcePath(plugin.source)
|
||||||
|
}
|
||||||
|
return { name: plugin.name, source: plugin.source }
|
||||||
|
}
|
||||||
|
if (isRecord(plugin.source) && plugin.source.source === "local" && typeof plugin.source.path === "string") {
|
||||||
|
validateLocalSourcePath(plugin.source.path)
|
||||||
|
const local: MarketplacePluginSourceLocal = { source: "local", path: plugin.source.path }
|
||||||
|
return { name: plugin.name, source: local }
|
||||||
|
}
|
||||||
|
throw new Error("local plugin source must be a string path or { source: \"local\", path } object")
|
||||||
|
}
|
||||||
|
|
||||||
|
function localSourcePath(source: string | MarketplacePluginSourceLocal | undefined): string {
|
||||||
|
if (typeof source === "string") return validateLocalSourcePath(source)
|
||||||
|
if (source?.source === "local") return validateLocalSourcePath(source.path)
|
||||||
|
throw new Error("local plugin source path is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateLocalSourcePath(path: string): string {
|
||||||
|
if (!path.startsWith("./")) throw new Error("local plugin source path must start with ./")
|
||||||
|
const relative = path.slice(2)
|
||||||
|
if (relative.length === 0) throw new Error("local plugin source path must not be empty")
|
||||||
|
for (const part of relative.split(/[\\/]/)) {
|
||||||
|
if (part === "" || part === "." || part === "..") {
|
||||||
|
throw new Error("local plugin source path must stay within the marketplace root")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { spawn } from "../../shared/bun-spawn-shim"
|
||||||
|
import type { RunCommand } from "./types"
|
||||||
|
|
||||||
|
export const defaultRunCommand: RunCommand = async (command, args, options) => {
|
||||||
|
const proc = spawn({
|
||||||
|
cmd: [command, ...args],
|
||||||
|
cwd: options.cwd,
|
||||||
|
stdin: "ignore",
|
||||||
|
stdout: "inherit",
|
||||||
|
stderr: "inherit",
|
||||||
|
})
|
||||||
|
|
||||||
|
const code = await proc.exited
|
||||||
|
if (code !== 0) {
|
||||||
|
throw new Error(`${command} ${args.join(" ")} failed in ${options.cwd} with exit code ${code}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export type {
|
||||||
|
CodexInstallOptions,
|
||||||
|
CodexInstallResult,
|
||||||
|
InstalledPlugin,
|
||||||
|
MarketplaceManifest,
|
||||||
|
PluginManifest,
|
||||||
|
TrustedHookState,
|
||||||
|
} from "./types"
|
||||||
|
export { runCodexInstaller } from "./install-codex"
|
||||||
|
export { readMarketplace, readPluginManifest, resolvePluginSource, validatePathSegment } from "./codex-marketplace"
|
||||||
|
export { installCachedPlugin, linkCachedPluginBins, pruneMarketplaceCache, rewriteCachedMcpManifest } from "./codex-cache"
|
||||||
|
export { updateCodexConfig } from "./codex-config-toml"
|
||||||
|
export { trustedHookStatesForPlugin } from "./codex-hook-trust"
|
||||||
|
export { defaultRunCommand } from "./codex-process"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { mkdtemp, readFile, stat } from "node:fs/promises"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import { runCodexInstaller } from "./install-codex"
|
||||||
|
|
||||||
|
describe("install-codex", () => {
|
||||||
|
test("installs vendored plugin into codex home and stays idempotent", async () => {
|
||||||
|
// given
|
||||||
|
const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-home-"))
|
||||||
|
const binDir = await mkdtemp(join(tmpdir(), "omo-codex-bin-"))
|
||||||
|
const repoRoot = "/Users/yeongyu/local-workspaces/omodex"
|
||||||
|
|
||||||
|
// when
|
||||||
|
const first = await runCodexInstaller({ codexHome, binDir, repoRoot, runCommand: async () => undefined })
|
||||||
|
const second = await runCodexInstaller({ codexHome, binDir, repoRoot, runCommand: async () => undefined })
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(first.marketplaceName).toBe("code-yeongyu-codex-plugins")
|
||||||
|
expect(second.installed.length).toBe(1)
|
||||||
|
const configContent = await readFile(join(codexHome, "config.toml"), "utf8")
|
||||||
|
expect(configContent).toContain("[features]")
|
||||||
|
expect(configContent).toContain("[marketplaces.code-yeongyu-codex-plugins]")
|
||||||
|
expect(configContent).toContain("[plugins.\"omo@code-yeongyu-codex-plugins\"]")
|
||||||
|
expect(configContent).toContain("[hooks.state.")
|
||||||
|
|
||||||
|
const pluginPath = first.installed[0]?.path
|
||||||
|
expect(pluginPath).toBeDefined()
|
||||||
|
const stats = await stat(pluginPath ?? "")
|
||||||
|
expect(stats.isDirectory()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { homedir } from "node:os"
|
||||||
|
import { join, resolve } from "node:path"
|
||||||
|
import { existsSync } from "node:fs"
|
||||||
|
import { installCachedPlugin, linkCachedPluginBins, pruneMarketplaceCache } from "./codex-cache"
|
||||||
|
import { updateCodexConfig } from "./codex-config-toml"
|
||||||
|
import { trustedHookStatesForPlugin } from "./codex-hook-trust"
|
||||||
|
import { readMarketplace, readPluginManifest, resolvePluginSource, validatePathSegment } from "./codex-marketplace"
|
||||||
|
import { defaultRunCommand } from "./codex-process"
|
||||||
|
import type { CodexInstallOptions, CodexInstallResult, InstalledPlugin } from "./types"
|
||||||
|
|
||||||
|
export async function runCodexInstaller(options: CodexInstallOptions = {}): Promise<CodexInstallResult> {
|
||||||
|
const repoRoot = resolve(options.repoRoot ?? findRepoRootFromImporter(import.meta.dir))
|
||||||
|
const codexHome = resolve(options.codexHome ?? process.env.CODEX_HOME ?? join(homedir(), ".codex"))
|
||||||
|
const binDir = resolve(options.binDir ?? process.env.CODEX_LOCAL_BIN_DIR ?? join(homedir(), ".local", "bin"))
|
||||||
|
const runCommand = options.runCommand ?? defaultRunCommand
|
||||||
|
const log = options.log ?? (() => undefined)
|
||||||
|
|
||||||
|
const codexPackageRoot = join(repoRoot, "packages", "omo-codex")
|
||||||
|
const marketplace = await readMarketplace(repoRoot, {
|
||||||
|
marketplacePath: join(codexPackageRoot, "marketplace.json"),
|
||||||
|
})
|
||||||
|
|
||||||
|
const installed: InstalledPlugin[] = []
|
||||||
|
for (const entry of marketplace.plugins) {
|
||||||
|
const sourcePath = resolvePluginSource(codexPackageRoot, entry, { pathOverride: "./plugin" })
|
||||||
|
const manifest = await readPluginManifest(sourcePath)
|
||||||
|
if (manifest.name !== entry.name) {
|
||||||
|
throw new Error(
|
||||||
|
`plugin manifest name ${JSON.stringify(manifest.name)} does not match marketplace name ${JSON.stringify(entry.name)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = manifest.version ?? "local"
|
||||||
|
validatePathSegment(version, "plugin version")
|
||||||
|
log(`Building ${entry.name}@${version}`)
|
||||||
|
|
||||||
|
const plugin = await installCachedPlugin({
|
||||||
|
codexHome,
|
||||||
|
marketplaceName: marketplace.name,
|
||||||
|
name: entry.name,
|
||||||
|
runCommand,
|
||||||
|
sourcePath,
|
||||||
|
version,
|
||||||
|
})
|
||||||
|
|
||||||
|
const links = await linkCachedPluginBins({ binDir, pluginRoot: plugin.path })
|
||||||
|
for (const link of links) {
|
||||||
|
log(`Linked ${link.name} -> ${link.target}`)
|
||||||
|
}
|
||||||
|
installed.push(plugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
const trustedHookStates = (
|
||||||
|
await Promise.all(
|
||||||
|
installed.map((plugin) =>
|
||||||
|
trustedHookStatesForPlugin({
|
||||||
|
marketplaceName: marketplace.name,
|
||||||
|
pluginName: plugin.name,
|
||||||
|
pluginRoot: plugin.path,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
).flat()
|
||||||
|
|
||||||
|
await pruneMarketplaceCache({
|
||||||
|
codexHome,
|
||||||
|
marketplaceName: marketplace.name,
|
||||||
|
keepPluginNames: marketplace.plugins.map((plugin) => plugin.name),
|
||||||
|
})
|
||||||
|
|
||||||
|
const configPath = join(codexHome, "config.toml")
|
||||||
|
await updateCodexConfig({
|
||||||
|
configPath,
|
||||||
|
repoRoot: codexPackageRoot,
|
||||||
|
marketplaceName: marketplace.name,
|
||||||
|
pluginNames: marketplace.plugins.map((plugin) => plugin.name),
|
||||||
|
trustedHookStates,
|
||||||
|
})
|
||||||
|
|
||||||
|
await trackCodexInstallTelemetry()
|
||||||
|
|
||||||
|
return {
|
||||||
|
marketplaceName: marketplace.name,
|
||||||
|
installed,
|
||||||
|
configPath,
|
||||||
|
codexHome,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findRepoRootFromImporter(importerDir: string): string {
|
||||||
|
let current = importerDir
|
||||||
|
for (let depth = 0; depth <= 5; depth += 1) {
|
||||||
|
const pluginManifestPath = join(current, "packages", "omo-codex", "plugin", ".codex-plugin", "plugin.json")
|
||||||
|
if (existsSyncLike(pluginManifestPath)) return current
|
||||||
|
current = resolve(current, "..")
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
"Unable to locate vendored Codex plugin: expected packages/omo-codex/plugin/.codex-plugin/plugin.json within 5 parent levels",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function existsSyncLike(path: string): boolean {
|
||||||
|
return existsSync(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trackCodexInstallTelemetry(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { createInstallPostHog, getPostHogDistinctId } = await import("@oh-my-opencode/omo-codex/telemetry")
|
||||||
|
const posthog = createInstallPostHog()
|
||||||
|
posthog.trackActive(getPostHogDistinctId(), "install_completed")
|
||||||
|
await posthog.shutdown()
|
||||||
|
} catch {
|
||||||
|
// no-excuse-ok: catch
|
||||||
|
// telemetry must never break installs
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
|
import process from "node:process"
|
||||||
|
import { resolveInstallArgs } from "../cli-program"
|
||||||
|
import { argsToConfig } from "../install-validators"
|
||||||
|
|
||||||
|
describe("lazycodex install routing", () => {
|
||||||
|
const originalInvocationName = process.env.OMO_INVOCATION_NAME
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalInvocationName === undefined) {
|
||||||
|
delete process.env.OMO_INVOCATION_NAME
|
||||||
|
return
|
||||||
|
}
|
||||||
|
process.env.OMO_INVOCATION_NAME = originalInvocationName
|
||||||
|
})
|
||||||
|
|
||||||
|
test("defaults codex to yes when invoked as lazycodex and user did not pass --codex", () => {
|
||||||
|
// given
|
||||||
|
process.env.OMO_INVOCATION_NAME = "lazycodex"
|
||||||
|
|
||||||
|
// when
|
||||||
|
const args = resolveInstallArgs({
|
||||||
|
tui: false,
|
||||||
|
claude: "no",
|
||||||
|
gemini: "no",
|
||||||
|
copilot: "no",
|
||||||
|
})
|
||||||
|
const config = argsToConfig(args)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(args.codex).toBe("yes")
|
||||||
|
expect(config.hasCodex).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("respects explicit --codex=no when invoked as lazycodex", () => {
|
||||||
|
// given
|
||||||
|
process.env.OMO_INVOCATION_NAME = "lazycodex"
|
||||||
|
|
||||||
|
// when
|
||||||
|
const args = resolveInstallArgs({
|
||||||
|
tui: false,
|
||||||
|
claude: "no",
|
||||||
|
gemini: "no",
|
||||||
|
copilot: "no",
|
||||||
|
codex: "no",
|
||||||
|
})
|
||||||
|
const config = argsToConfig(args)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(args.codex).toBe("no")
|
||||||
|
expect(config.hasCodex).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps codex disabled by default when invocation name is oh-my-opencode", () => {
|
||||||
|
// given
|
||||||
|
process.env.OMO_INVOCATION_NAME = "oh-my-opencode"
|
||||||
|
|
||||||
|
// when
|
||||||
|
const args = resolveInstallArgs({
|
||||||
|
tui: false,
|
||||||
|
claude: "no",
|
||||||
|
gemini: "no",
|
||||||
|
copilot: "no",
|
||||||
|
})
|
||||||
|
const config = argsToConfig(args)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(args.codex).toBeUndefined()
|
||||||
|
expect(config.hasCodex).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps codex disabled by default when invocation name is unset", () => {
|
||||||
|
// given
|
||||||
|
delete process.env.OMO_INVOCATION_NAME
|
||||||
|
|
||||||
|
// when
|
||||||
|
const args = resolveInstallArgs(
|
||||||
|
{
|
||||||
|
tui: false,
|
||||||
|
claude: "no",
|
||||||
|
gemini: "no",
|
||||||
|
copilot: "no",
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
|
const config = argsToConfig(args)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(args.codex).toBeUndefined()
|
||||||
|
expect(config.hasCodex).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
export interface MarketplacePluginSourceLocal {
|
||||||
|
readonly source: "local"
|
||||||
|
readonly path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketplacePluginEntry {
|
||||||
|
readonly name: string
|
||||||
|
readonly source?: string | MarketplacePluginSourceLocal
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketplaceManifest {
|
||||||
|
readonly name: string
|
||||||
|
readonly plugins: readonly MarketplacePluginEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginManifest {
|
||||||
|
readonly name: string
|
||||||
|
readonly version?: string
|
||||||
|
readonly hooks?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstalledPlugin {
|
||||||
|
readonly name: string
|
||||||
|
readonly version: string
|
||||||
|
readonly path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrustedHookState {
|
||||||
|
readonly key: string
|
||||||
|
readonly trustedHash: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandRunOptions {
|
||||||
|
readonly cwd: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RunCommand = (
|
||||||
|
command: string,
|
||||||
|
args: readonly string[],
|
||||||
|
options: CommandRunOptions,
|
||||||
|
) => Promise<void>
|
||||||
|
|
||||||
|
export interface CodexInstallOptions {
|
||||||
|
readonly codexHome?: string
|
||||||
|
readonly binDir?: string
|
||||||
|
readonly repoRoot?: string
|
||||||
|
readonly runCommand?: RunCommand
|
||||||
|
readonly log?: (message: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexInstallResult {
|
||||||
|
readonly marketplaceName: string
|
||||||
|
readonly installed: readonly InstalledPlugin[]
|
||||||
|
readonly configPath: string
|
||||||
|
readonly codexHome: string
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ function createArgs(overrides: Partial<InstallArgs> = {}): InstallArgs {
|
|||||||
openai: "no",
|
openai: "no",
|
||||||
gemini: "no",
|
gemini: "no",
|
||||||
copilot: "no",
|
copilot: "no",
|
||||||
|
codex: "no",
|
||||||
opencodeZen: "no",
|
opencodeZen: "no",
|
||||||
zaiCodingPlan: "no",
|
zaiCodingPlan: "no",
|
||||||
kimiForCoding: "no",
|
kimiForCoding: "no",
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export function formatConfigSummary(config: InstallConfig): string {
|
|||||||
lines.push(formatProvider("OpenAI/ChatGPT", config.hasOpenAI, "GPT-5.4 for Oracle"))
|
lines.push(formatProvider("OpenAI/ChatGPT", config.hasOpenAI, "GPT-5.4 for Oracle"))
|
||||||
lines.push(formatProvider("Gemini", config.hasGemini))
|
lines.push(formatProvider("Gemini", config.hasGemini))
|
||||||
lines.push(formatProvider("GitHub Copilot", config.hasCopilot, "fallback"))
|
lines.push(formatProvider("GitHub Copilot", config.hasCopilot, "fallback"))
|
||||||
|
lines.push(formatProvider("Codex Harness (oh-my-codex plugin)", config.hasCodex))
|
||||||
lines.push(formatProvider("OpenCode Zen", config.hasOpencodeZen, "opencode/ models"))
|
lines.push(formatProvider("OpenCode Zen", config.hasOpencodeZen, "opencode/ models"))
|
||||||
lines.push(formatProvider("Z.ai Coding Plan", config.hasZaiCodingPlan, "Librarian/Multimodal"))
|
lines.push(formatProvider("Z.ai Coding Plan", config.hasZaiCodingPlan, "Librarian/Multimodal"))
|
||||||
lines.push(formatProvider("Kimi For Coding", config.hasKimiForCoding, "Sisyphus/Prometheus fallback"))
|
lines.push(formatProvider("Kimi For Coding", config.hasKimiForCoding, "Sisyphus/Prometheus fallback"))
|
||||||
@@ -134,6 +135,10 @@ export function validateNonTuiArgs(args: InstallArgs): { valid: boolean; errors:
|
|||||||
errors.push(`Invalid --copilot value: ${args.copilot} (expected: no, yes)`)
|
errors.push(`Invalid --copilot value: ${args.copilot} (expected: no, yes)`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args.codex !== undefined && !["no", "yes"].includes(args.codex)) {
|
||||||
|
errors.push(`Invalid --codex value: ${args.codex} (expected: no, yes)`)
|
||||||
|
}
|
||||||
|
|
||||||
if (args.openai !== undefined && !["no", "yes"].includes(args.openai)) {
|
if (args.openai !== undefined && !["no", "yes"].includes(args.openai)) {
|
||||||
errors.push(`Invalid --openai value: ${args.openai} (expected: no, yes)`)
|
errors.push(`Invalid --openai value: ${args.openai} (expected: no, yes)`)
|
||||||
}
|
}
|
||||||
@@ -168,9 +173,10 @@ export function argsToConfig(args: InstallArgs): InstallConfig {
|
|||||||
hasOpenAI: args.openai === "yes",
|
hasOpenAI: args.openai === "yes",
|
||||||
hasGemini: args.gemini === "yes",
|
hasGemini: args.gemini === "yes",
|
||||||
hasCopilot: args.copilot === "yes",
|
hasCopilot: args.copilot === "yes",
|
||||||
|
hasCodex: args.codex === "yes",
|
||||||
hasOpencodeZen: args.opencodeZen === "yes",
|
hasOpencodeZen: args.opencodeZen === "yes",
|
||||||
hasZaiCodingPlan: args.zaiCodingPlan === "yes",
|
hasZaiCodingPlan: args.zaiCodingPlan === "yes",
|
||||||
hasKimiForCoding: args.kimiForCoding === "yes",
|
hasKimiForCoding: args.kimiForCoding === "yes",
|
||||||
hasOpencodeGo: args.opencodeGo === "yes",
|
hasOpencodeGo: args.opencodeGo === "yes",
|
||||||
hasVercelAiGateway: args.vercelAiGateway === "yes",
|
hasVercelAiGateway: args.vercelAiGateway === "yes",
|
||||||
}
|
}
|
||||||
@@ -181,9 +187,10 @@ export function detectedToInitialValues(detected: DetectedConfig): {
|
|||||||
openai: BooleanArg
|
openai: BooleanArg
|
||||||
gemini: BooleanArg
|
gemini: BooleanArg
|
||||||
copilot: BooleanArg
|
copilot: BooleanArg
|
||||||
|
codex: BooleanArg
|
||||||
opencodeZen: BooleanArg
|
opencodeZen: BooleanArg
|
||||||
zaiCodingPlan: BooleanArg
|
zaiCodingPlan: BooleanArg
|
||||||
kimiForCoding: BooleanArg
|
kimiForCoding: BooleanArg
|
||||||
opencodeGo: BooleanArg
|
opencodeGo: BooleanArg
|
||||||
vercelAiGateway: BooleanArg
|
vercelAiGateway: BooleanArg
|
||||||
} {
|
} {
|
||||||
@@ -197,9 +204,10 @@ kimiForCoding: BooleanArg
|
|||||||
openai: detected.hasOpenAI ? "yes" : "no",
|
openai: detected.hasOpenAI ? "yes" : "no",
|
||||||
gemini: detected.hasGemini ? "yes" : "no",
|
gemini: detected.hasGemini ? "yes" : "no",
|
||||||
copilot: detected.hasCopilot ? "yes" : "no",
|
copilot: detected.hasCopilot ? "yes" : "no",
|
||||||
|
codex: detected.hasCodex ? "yes" : "no",
|
||||||
opencodeZen: detected.hasOpencodeZen ? "yes" : "no",
|
opencodeZen: detected.hasOpencodeZen ? "yes" : "no",
|
||||||
zaiCodingPlan: detected.hasZaiCodingPlan ? "yes" : "no",
|
zaiCodingPlan: detected.hasZaiCodingPlan ? "yes" : "no",
|
||||||
kimiForCoding: detected.hasKimiForCoding ? "yes" : "no",
|
kimiForCoding: detected.hasKimiForCoding ? "yes" : "no",
|
||||||
opencodeGo: detected.hasOpencodeGo ? "yes" : "no",
|
opencodeGo: detected.hasOpencodeGo ? "yes" : "no",
|
||||||
vercelAiGateway: detected.hasVercelAiGateway ? "yes" : "no",
|
vercelAiGateway: detected.hasVercelAiGateway ? "yes" : "no",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,16 @@ export async function promptInstallConfig(detected: DetectedConfig): Promise<Ins
|
|||||||
})
|
})
|
||||||
if (!copilot) return null
|
if (!copilot) return null
|
||||||
|
|
||||||
|
const codex = await selectOrCancel({
|
||||||
|
message: "Install Codex harness adapter into ~/.codex?",
|
||||||
|
options: [
|
||||||
|
{ value: "no", label: "No", hint: "Skip Codex plugin installation" },
|
||||||
|
{ value: "yes", label: "Yes", hint: "Install vendored oh-my-codex plugin" },
|
||||||
|
],
|
||||||
|
initialValue: initial.codex,
|
||||||
|
})
|
||||||
|
if (!codex) return null
|
||||||
|
|
||||||
const opencodeZen = await selectOrCancel({
|
const opencodeZen = await selectOrCancel({
|
||||||
message: "Do you have access to OpenCode Zen (opencode/ models)?",
|
message: "Do you have access to OpenCode Zen (opencode/ models)?",
|
||||||
options: [
|
options: [
|
||||||
@@ -126,6 +136,7 @@ export async function promptInstallConfig(detected: DetectedConfig): Promise<Ins
|
|||||||
hasOpenAI: openai === "yes",
|
hasOpenAI: openai === "yes",
|
||||||
hasGemini: gemini === "yes",
|
hasGemini: gemini === "yes",
|
||||||
hasCopilot: copilot === "yes",
|
hasCopilot: copilot === "yes",
|
||||||
|
hasCodex: codex === "yes",
|
||||||
hasOpencodeZen: opencodeZen === "yes",
|
hasOpencodeZen: opencodeZen === "yes",
|
||||||
hasZaiCodingPlan: zaiCodingPlan === "yes",
|
hasZaiCodingPlan: zaiCodingPlan === "yes",
|
||||||
hasKimiForCoding: kimiForCoding === "yes",
|
hasKimiForCoding: kimiForCoding === "yes",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ describe("runTuiInstaller", () => {
|
|||||||
hasOpenAI: false,
|
hasOpenAI: false,
|
||||||
hasGemini: false,
|
hasGemini: false,
|
||||||
hasCopilot: false,
|
hasCopilot: false,
|
||||||
|
hasCodex: false,
|
||||||
hasOpencodeZen: false,
|
hasOpencodeZen: false,
|
||||||
hasZaiCodingPlan: false,
|
hasZaiCodingPlan: false,
|
||||||
hasKimiForCoding: false,
|
hasKimiForCoding: false,
|
||||||
@@ -89,6 +90,7 @@ describe("runTuiInstaller", () => {
|
|||||||
hasOpenAI: false,
|
hasOpenAI: false,
|
||||||
hasGemini: false,
|
hasGemini: false,
|
||||||
hasCopilot: false,
|
hasCopilot: false,
|
||||||
|
hasCodex: false,
|
||||||
hasOpencodeZen: false,
|
hasOpencodeZen: false,
|
||||||
hasZaiCodingPlan: false,
|
hasZaiCodingPlan: false,
|
||||||
hasKimiForCoding: false,
|
hasKimiForCoding: false,
|
||||||
@@ -103,6 +105,7 @@ describe("runTuiInstaller", () => {
|
|||||||
hasOpenAI: false,
|
hasOpenAI: false,
|
||||||
hasGemini: false,
|
hasGemini: false,
|
||||||
hasCopilot: false,
|
hasCopilot: false,
|
||||||
|
hasCodex: false,
|
||||||
hasOpencodeZen: false,
|
hasOpencodeZen: false,
|
||||||
hasZaiCodingPlan: false,
|
hasZaiCodingPlan: false,
|
||||||
hasKimiForCoding: false,
|
hasKimiForCoding: false,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { detectedToInitialValues, formatConfigSummary, SYMBOLS } from "./install-validators"
|
import { detectedToInitialValues, formatConfigSummary, SYMBOLS } from "./install-validators"
|
||||||
import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version"
|
import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version"
|
||||||
import { promptInstallConfig } from "./tui-install-prompts"
|
import { promptInstallConfig } from "./tui-install-prompts"
|
||||||
|
import { runCodexInstaller } from "./install-codex"
|
||||||
|
|
||||||
export async function runTuiInstaller(args: InstallArgs, version: string): Promise<number> {
|
export async function runTuiInstaller(args: InstallArgs, version: string): Promise<number> {
|
||||||
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
||||||
@@ -83,6 +84,18 @@ export async function runTuiInstaller(args: InstallArgs, version: string): Promi
|
|||||||
|
|
||||||
p.note(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete")
|
p.note(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete")
|
||||||
|
|
||||||
|
if (config.hasCodex) {
|
||||||
|
spinner.start("Installing Codex harness adapter")
|
||||||
|
try {
|
||||||
|
const codexResult = await runCodexInstaller()
|
||||||
|
spinner.stop(`Codex plugin installed to ${color.cyan(codexResult.configPath)}`)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
spinner.stop(`Codex install failed ${color.yellow("[!]")}`)
|
||||||
|
p.log.warn(`Codex install failed (OpenCode install remains successful): ${message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
p.log.success(color.bold(isUpdate ? "Configuration updated!" : "Installation complete!"))
|
p.log.success(color.bold(isUpdate ? "Configuration updated!" : "Installation complete!"))
|
||||||
p.log.message(`Run ${color.cyan("opencode")} to start!`)
|
p.log.message(`Run ${color.cyan("opencode")} to start!`)
|
||||||
p.log.info("Anonymous telemetry is enabled by default. Disable it with OMO_SEND_ANONYMOUS_TELEMETRY=0 or OMO_DISABLE_POSTHOG=1.")
|
p.log.info("Anonymous telemetry is enabled by default. Disable it with OMO_SEND_ANONYMOUS_TELEMETRY=0 or OMO_DISABLE_POSTHOG=1.")
|
||||||
|
|||||||
+4
-1
@@ -7,9 +7,10 @@ export interface InstallArgs {
|
|||||||
openai?: BooleanArg
|
openai?: BooleanArg
|
||||||
gemini?: BooleanArg
|
gemini?: BooleanArg
|
||||||
copilot?: BooleanArg
|
copilot?: BooleanArg
|
||||||
|
codex?: BooleanArg
|
||||||
opencodeZen?: BooleanArg
|
opencodeZen?: BooleanArg
|
||||||
zaiCodingPlan?: BooleanArg
|
zaiCodingPlan?: BooleanArg
|
||||||
kimiForCoding?: BooleanArg
|
kimiForCoding?: BooleanArg
|
||||||
opencodeGo?: BooleanArg
|
opencodeGo?: BooleanArg
|
||||||
vercelAiGateway?: BooleanArg
|
vercelAiGateway?: BooleanArg
|
||||||
skipAuth?: boolean
|
skipAuth?: boolean
|
||||||
@@ -21,6 +22,7 @@ export interface InstallConfig {
|
|||||||
hasOpenAI: boolean
|
hasOpenAI: boolean
|
||||||
hasGemini: boolean
|
hasGemini: boolean
|
||||||
hasCopilot: boolean
|
hasCopilot: boolean
|
||||||
|
hasCodex: boolean
|
||||||
hasOpencodeZen: boolean
|
hasOpencodeZen: boolean
|
||||||
hasZaiCodingPlan: boolean
|
hasZaiCodingPlan: boolean
|
||||||
hasKimiForCoding: boolean
|
hasKimiForCoding: boolean
|
||||||
@@ -42,6 +44,7 @@ export interface DetectedConfig {
|
|||||||
hasOpenAI: boolean
|
hasOpenAI: boolean
|
||||||
hasGemini: boolean
|
hasGemini: boolean
|
||||||
hasCopilot: boolean
|
hasCopilot: boolean
|
||||||
|
hasCodex: boolean
|
||||||
hasOpencodeZen: boolean
|
hasOpencodeZen: boolean
|
||||||
hasZaiCodingPlan: boolean
|
hasZaiCodingPlan: boolean
|
||||||
hasKimiForCoding: boolean
|
hasKimiForCoding: boolean
|
||||||
|
|||||||
Reference in New Issue
Block a user