Files
neuron/packages/opencode/src/cli/cmd/plug.ts
T
Dax Raad 9031b8f108 core: move Global module to @opencode-ai/core for centralized path management
Move the Global module from packages/opencode/src/global to packages/core/src/global
to provide a unified location for managing XDG directories and application paths.
This eliminates duplicate path definitions across packages and ensures consistent
access to data, config, cache, state, log, and bin directories throughout the codebase.
2026-04-25 13:52:32 -04:00

234 lines
6.8 KiB
TypeScript

import { intro, log, outro, spinner } from "@clack/prompts"
import type { Argv } from "yargs"
import { ConfigPaths } from "../../config"
import { Global } from "@opencode-ai/core/global"
import { installPlugin, patchPluginConfig, readPluginManifest } from "../../plugin/install"
import { resolvePluginTarget } from "../../plugin/shared"
import { Instance } from "../../project/instance"
import { errorMessage } from "../../util/error"
import { Filesystem } from "../../util"
import { Process } from "../../util"
import { UI } from "../ui"
import { cmd } from "./cmd"
type Spin = {
start: (msg: string) => void
stop: (msg: string, code?: number) => void
}
export type PlugDeps = {
spinner: () => Spin
log: {
error: (msg: string) => void
info: (msg: string) => void
success: (msg: string) => void
}
resolve: (spec: string) => Promise<string>
readText: (file: string) => Promise<string>
write: (file: string, text: string) => Promise<void>
exists: (file: string) => Promise<boolean>
files: (dir: string, name: "opencode" | "tui") => string[]
global: string
}
export type PlugInput = {
mod: string
global?: boolean
force?: boolean
}
export type PlugCtx = {
vcs?: string
worktree: string
directory: string
}
const defaultPlugDeps: PlugDeps = {
spinner: () => spinner(),
log: {
error: (msg) => log.error(msg),
info: (msg) => log.info(msg),
success: (msg) => log.success(msg),
},
resolve: (spec) => resolvePluginTarget(spec),
readText: (file) => Filesystem.readText(file),
write: async (file, text) => {
await Filesystem.write(file, text)
},
exists: (file) => Filesystem.exists(file),
files: (dir, name) => ConfigPaths.fileInDirectory(dir, name),
global: Global.Path.config,
}
function cause(err: unknown) {
if (!err || typeof err !== "object") return
if (!("cause" in err)) return
return (err as { cause?: unknown }).cause
}
export function createPlugTask(input: PlugInput, dep: PlugDeps = defaultPlugDeps) {
const mod = input.mod
const force = Boolean(input.force)
const global = Boolean(input.global)
return async (ctx: PlugCtx) => {
const install = dep.spinner()
install.start("Installing plugin package...")
const target = await installPlugin(mod, dep)
if (!target.ok) {
install.stop("Install failed", 1)
dep.log.error(`Could not install "${mod}"`)
const hit = cause(target.error) ?? target.error
if (hit instanceof Process.RunFailedError) {
const lines = hit.stderr
.toString()
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
const errs = lines.filter((line) => line.startsWith("error:")).map((line) => line.replace(/^error:\s*/, ""))
const detail = errs[0] ?? lines.at(-1)
if (detail) dep.log.error(detail)
if (lines.some((line) => line.includes("No version matching"))) {
dep.log.info("This package depends on a version that is not available in your npm registry.")
dep.log.info("Check npm registry/auth settings and try again.")
}
}
if (!(hit instanceof Process.RunFailedError)) {
dep.log.error(errorMessage(hit))
}
return false
}
install.stop("Plugin package ready")
const inspect = dep.spinner()
inspect.start("Reading plugin manifest...")
const manifest = await readPluginManifest(target.target)
if (!manifest.ok) {
if (manifest.code === "manifest_read_failed") {
inspect.stop("Manifest read failed", 1)
dep.log.error(`Installed "${mod}" but failed to read ${manifest.file}`)
dep.log.error(errorMessage(cause(manifest.error) ?? manifest.error))
return false
}
if (manifest.code === "manifest_no_targets") {
inspect.stop("No plugin targets found", 1)
dep.log.error(`"${mod}" does not expose plugin entrypoints in package.json`)
dep.log.info(
'Expected one of: exports["./tui"], exports["./server"], package.json main for server, or package.json["oc-themes"] for tui themes.',
)
return false
}
inspect.stop("Manifest read failed", 1)
return false
}
inspect.stop(
`Detected ${manifest.targets.map((item) => item.kind).join(" + ")} target${manifest.targets.length === 1 ? "" : "s"}`,
)
const patch = dep.spinner()
patch.start("Updating plugin config...")
const out = await patchPluginConfig(
{
spec: mod,
targets: manifest.targets,
force,
global,
vcs: ctx.vcs,
worktree: ctx.worktree,
directory: ctx.directory,
config: dep.global,
},
dep,
)
if (!out.ok) {
if (out.code === "invalid_json") {
patch.stop(`Failed updating ${out.kind} config`, 1)
dep.log.error(`Invalid JSON in ${out.file} (${out.parse} at line ${out.line}, column ${out.col})`)
dep.log.info("Fix the config file and run the command again.")
return false
}
patch.stop("Failed updating plugin config", 1)
dep.log.error(errorMessage(out.error))
return false
}
patch.stop("Plugin config updated")
for (const item of out.items) {
if (item.mode === "noop") {
dep.log.info(`Already configured in ${item.file}`)
continue
}
if (item.mode === "replace") {
dep.log.info(`Replaced in ${item.file}`)
continue
}
dep.log.info(`Added to ${item.file}`)
}
dep.log.success(`Installed ${mod}`)
dep.log.info(global ? `Scope: global (${out.dir})` : `Scope: local (${out.dir})`)
return true
}
}
export const PluginCommand = cmd({
command: "plugin <module>",
aliases: ["plug"],
describe: "install plugin and update config",
builder: (yargs: Argv) => {
return yargs
.positional("module", {
type: "string",
describe: "npm module name",
})
.option("global", {
alias: ["g"],
type: "boolean",
default: false,
describe: "install in global config",
})
.option("force", {
alias: ["f"],
type: "boolean",
default: false,
describe: "replace existing plugin version",
})
},
handler: async (args) => {
const mod = String(args.module ?? "").trim()
if (!mod) {
UI.error("module is required")
process.exitCode = 1
return
}
UI.empty()
intro(`Install plugin ${mod}`)
const run = createPlugTask({
mod,
global: Boolean(args.global),
force: Boolean(args.force),
})
let ok = true
await Instance.provide({
directory: process.cwd(),
fn: async () => {
ok = await run({
vcs: Instance.project.vcs,
worktree: Instance.worktree,
directory: Instance.directory,
})
},
})
outro("Done")
if (!ok) process.exitCode = 1
},
})