226 lines
8.3 KiB
TypeScript
226 lines
8.3 KiB
TypeScript
|
|
import { homedir } from "node:os"
|
||
|
|
import { join, resolve } from "node:path"
|
||
|
|
import { existsSync } from "node:fs"
|
||
|
|
import { mkdir, writeFile } from "node:fs/promises"
|
||
|
|
import { installCachedPlugin, linkCachedPluginBins, pruneMarketplaceCache, pruneMarketplacePluginCaches } from "./codex-cache"
|
||
|
|
import { updateCodexConfig } from "./codex-config-toml"
|
||
|
|
import { trustedHookStatesForPlugin } from "./codex-hook-trust"
|
||
|
|
import { linkCachedPluginAgents } from "./link-cached-plugin-agents"
|
||
|
|
import { readMarketplace, readPluginManifest, resolvePluginSource, validatePathSegment } from "./codex-marketplace"
|
||
|
|
import { writeInstalledMarketplaceSnapshot, type MarketplaceSnapshotPluginSource } from "./codex-marketplace-snapshot"
|
||
|
|
import { defaultRunCommand } from "./codex-process"
|
||
|
|
import type { CodexInstallOptions, CodexInstallResult, CodexMarketplaceSource, InstalledPlugin, MarketplaceManifest } from "./types"
|
||
|
|
|
||
|
|
const SISYPHUS_LEGACY_CACHE_MARKETPLACES = ["lazycodex", "code-yeongyu-codex-plugins"] as const
|
||
|
|
|
||
|
|
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 = resolveCodexInstallerBinDir({ binDir: options.binDir, codexHome, env: process.env })
|
||
|
|
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[] = []
|
||
|
|
const pluginSources: MarketplaceSnapshotPluginSource[] = []
|
||
|
|
const agentConfigs = new Map<string, { readonly name: string; readonly configFile: string }>()
|
||
|
|
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}`)
|
||
|
|
}
|
||
|
|
pluginSources.push({ name: entry.name, sourcePath })
|
||
|
|
installed.push(plugin)
|
||
|
|
}
|
||
|
|
|
||
|
|
const agentSourceRoots = await agentSourceRootsForInstall({
|
||
|
|
codexHome,
|
||
|
|
marketplace,
|
||
|
|
installed,
|
||
|
|
pluginSources,
|
||
|
|
})
|
||
|
|
for (const plugin of installed) {
|
||
|
|
const pluginRoot = agentSourceRoots.get(plugin.name) ?? plugin.path
|
||
|
|
const agentLinks = await linkCachedPluginAgents({ codexHome, pluginRoot })
|
||
|
|
for (const link of agentLinks) {
|
||
|
|
log(`Linked agent ${link.name} -> ${link.target}`)
|
||
|
|
const agentName = agentNameFromToml(link.name)
|
||
|
|
agentConfigs.set(agentName, { name: agentName, configFile: `./agents/${link.name}` })
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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),
|
||
|
|
})
|
||
|
|
for (const legacyMarketplaceName of legacyCacheMarketplaces(marketplace.name)) {
|
||
|
|
await pruneMarketplacePluginCaches({
|
||
|
|
codexHome,
|
||
|
|
marketplaceName: legacyMarketplaceName,
|
||
|
|
pluginNames: marketplace.plugins.map((plugin) => plugin.name),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
const marketplaceRoot = join(codexHome, "plugins", "cache", marketplace.name)
|
||
|
|
await writeCachedMarketplaceManifest({
|
||
|
|
marketplaceName: marketplace.name,
|
||
|
|
marketplaceRoot,
|
||
|
|
plugins: installed,
|
||
|
|
})
|
||
|
|
|
||
|
|
const configPath = join(codexHome, "config.toml")
|
||
|
|
await updateCodexConfig({
|
||
|
|
configPath,
|
||
|
|
repoRoot: codexPackageRoot,
|
||
|
|
marketplaceName: marketplace.name,
|
||
|
|
marketplaceSource: codexMarketplaceSource(marketplaceRoot),
|
||
|
|
pluginNames: marketplace.plugins.map((plugin) => plugin.name),
|
||
|
|
trustedHookStates,
|
||
|
|
agentConfigs: [...agentConfigs.values()].sort((left, right) => left.name.localeCompare(right.name)),
|
||
|
|
autonomousPermissions: options.autonomousPermissions === true,
|
||
|
|
})
|
||
|
|
|
||
|
|
await trackCodexInstallTelemetry()
|
||
|
|
|
||
|
|
return {
|
||
|
|
marketplaceName: marketplace.name,
|
||
|
|
installed,
|
||
|
|
configPath,
|
||
|
|
codexHome,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export function resolveCodexInstallerBinDir(input: {
|
||
|
|
readonly binDir?: string
|
||
|
|
readonly codexHome: string
|
||
|
|
readonly env?: { readonly [key: string]: string | undefined }
|
||
|
|
readonly homeDir?: string
|
||
|
|
}): string {
|
||
|
|
const explicitBinDir = input.binDir ?? input.env?.CODEX_LOCAL_BIN_DIR
|
||
|
|
if (explicitBinDir !== undefined && explicitBinDir.trim().length > 0) return resolve(explicitBinDir)
|
||
|
|
|
||
|
|
const homeDir = input.homeDir ?? homedir()
|
||
|
|
const defaultCodexHome = resolve(homeDir, ".codex")
|
||
|
|
const resolvedCodexHome = resolve(input.codexHome)
|
||
|
|
if (resolvedCodexHome !== defaultCodexHome) return join(resolvedCodexHome, "bin")
|
||
|
|
return resolve(homeDir, ".local", "bin")
|
||
|
|
}
|
||
|
|
|
||
|
|
function agentNameFromToml(fileName: string): string {
|
||
|
|
return fileName.endsWith(".toml") ? fileName.slice(0, -".toml".length) : fileName
|
||
|
|
}
|
||
|
|
|
||
|
|
async function agentSourceRootsForInstall(input: {
|
||
|
|
readonly codexHome: string
|
||
|
|
readonly marketplace: MarketplaceManifest
|
||
|
|
readonly installed: readonly InstalledPlugin[]
|
||
|
|
readonly pluginSources: readonly MarketplaceSnapshotPluginSource[]
|
||
|
|
}): Promise<ReadonlyMap<string, string>> {
|
||
|
|
if (input.marketplace.name !== "sisyphuslabs") {
|
||
|
|
return new Map(input.installed.map((plugin) => [plugin.name, plugin.path]))
|
||
|
|
}
|
||
|
|
const snapshotPlugins = await writeInstalledMarketplaceSnapshot({
|
||
|
|
codexHome: input.codexHome,
|
||
|
|
marketplace: input.marketplace,
|
||
|
|
plugins: input.pluginSources,
|
||
|
|
})
|
||
|
|
return new Map(snapshotPlugins.map((plugin) => [plugin.name, plugin.path]))
|
||
|
|
}
|
||
|
|
|
||
|
|
async function writeCachedMarketplaceManifest(input: {
|
||
|
|
readonly marketplaceName: string
|
||
|
|
readonly marketplaceRoot: string
|
||
|
|
readonly plugins: readonly InstalledPlugin[]
|
||
|
|
}): Promise<void> {
|
||
|
|
const marketplaceDir = join(input.marketplaceRoot, ".agents", "plugins")
|
||
|
|
await mkdir(marketplaceDir, { recursive: true })
|
||
|
|
await writeFile(
|
||
|
|
join(marketplaceDir, "marketplace.json"),
|
||
|
|
`${JSON.stringify(
|
||
|
|
{
|
||
|
|
name: input.marketplaceName,
|
||
|
|
plugins: input.plugins.map((plugin) => ({
|
||
|
|
name: plugin.name,
|
||
|
|
source: { source: "local", path: `./${plugin.name}/${plugin.version}` },
|
||
|
|
})),
|
||
|
|
},
|
||
|
|
null,
|
||
|
|
"\t",
|
||
|
|
)}\n`,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
function legacyCacheMarketplaces(marketplaceName: string): readonly string[] {
|
||
|
|
return marketplaceName === "sisyphuslabs" ? SISYPHUS_LEGACY_CACHE_MARKETPLACES : []
|
||
|
|
}
|
||
|
|
|
||
|
|
function codexMarketplaceSource(marketplaceRoot: string): CodexMarketplaceSource {
|
||
|
|
return { sourceType: "local", source: marketplaceRoot }
|
||
|
|
}
|
||
|
|
|
||
|
|
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 (error) {
|
||
|
|
if (error instanceof Error) return
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|