|
|
@@ -1,6 +1,6 @@
|
|
|
import { Log } from "../util/log"
|
|
|
import path from "path"
|
|
|
-import { pathToFileURL, fileURLToPath } from "url"
|
|
|
+import { pathToFileURL } from "url"
|
|
|
import { createRequire } from "module"
|
|
|
import os from "os"
|
|
|
import z from "zod"
|
|
|
@@ -38,6 +38,11 @@ import { Filesystem } from "@/util/filesystem"
|
|
|
|
|
|
export namespace Config {
|
|
|
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
|
|
|
+ const PluginOptions = z.record(z.string(), z.unknown())
|
|
|
+ export const PluginSpec = z.union([z.string(), z.tuple([z.string(), PluginOptions])])
|
|
|
+
|
|
|
+ export type PluginOptions = z.infer<typeof PluginOptions>
|
|
|
+ export type PluginSpec = z.infer<typeof PluginSpec>
|
|
|
|
|
|
const log = Log.create({ service: "config" })
|
|
|
|
|
|
@@ -449,7 +454,7 @@ export namespace Config {
|
|
|
}
|
|
|
|
|
|
async function loadPlugin(dir: string) {
|
|
|
- const plugins: string[] = []
|
|
|
+ const plugins: PluginSpec[] = []
|
|
|
|
|
|
for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", {
|
|
|
cwd: dir,
|
|
|
@@ -462,6 +467,32 @@ export namespace Config {
|
|
|
return plugins
|
|
|
}
|
|
|
|
|
|
+ export function pluginSpecifier(plugin: PluginSpec): string {
|
|
|
+ return Array.isArray(plugin) ? plugin[0] : plugin
|
|
|
+ }
|
|
|
+
|
|
|
+ export function pluginOptions(plugin: PluginSpec): PluginOptions | undefined {
|
|
|
+ return Array.isArray(plugin) ? plugin[1] : undefined
|
|
|
+ }
|
|
|
+
|
|
|
+ export function resolvePluginSpec(plugin: PluginSpec, configFilepath: string): PluginSpec {
|
|
|
+ const spec = pluginSpecifier(plugin)
|
|
|
+ try {
|
|
|
+ const resolved = import.meta.resolve!(spec, configFilepath)
|
|
|
+ if (Array.isArray(plugin)) return [resolved, plugin[1]]
|
|
|
+ return resolved
|
|
|
+ } catch {
|
|
|
+ try {
|
|
|
+ const require = createRequire(configFilepath)
|
|
|
+ const resolved = pathToFileURL(require.resolve(spec)).href
|
|
|
+ if (Array.isArray(plugin)) return [resolved, plugin[1]]
|
|
|
+ return resolved
|
|
|
+ } catch {
|
|
|
+ return plugin
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* Extracts a canonical plugin name from a plugin specifier.
|
|
|
* - For file:// URLs: extracts filename without extension
|
|
|
@@ -472,15 +503,16 @@ export namespace Config {
|
|
|
* getPluginName("[email protected]") // "oh-my-opencode"
|
|
|
* getPluginName("@scope/[email protected]") // "@scope/pkg"
|
|
|
*/
|
|
|
- export function getPluginName(plugin: string): string {
|
|
|
- if (plugin.startsWith("file://")) {
|
|
|
- return path.parse(new URL(plugin).pathname).name
|
|
|
+ export function getPluginName(plugin: PluginSpec): string {
|
|
|
+ const spec = pluginSpecifier(plugin)
|
|
|
+ if (spec.startsWith("file://")) {
|
|
|
+ return path.parse(new URL(spec).pathname).name
|
|
|
}
|
|
|
- const lastAt = plugin.lastIndexOf("@")
|
|
|
+ const lastAt = spec.lastIndexOf("@")
|
|
|
if (lastAt > 0) {
|
|
|
- return plugin.substring(0, lastAt)
|
|
|
+ return spec.substring(0, lastAt)
|
|
|
}
|
|
|
- return plugin
|
|
|
+ return spec
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -494,14 +526,14 @@ export namespace Config {
|
|
|
* Since plugins are added in low-to-high priority order,
|
|
|
* we reverse, deduplicate (keeping first occurrence), then restore order.
|
|
|
*/
|
|
|
- export function deduplicatePlugins(plugins: string[]): string[] {
|
|
|
+ export function deduplicatePlugins(plugins: PluginSpec[]): PluginSpec[] {
|
|
|
// seenNames: canonical plugin names for duplicate detection
|
|
|
// e.g., "oh-my-opencode", "@scope/pkg"
|
|
|
const seenNames = new Set<string>()
|
|
|
|
|
|
// uniqueSpecifiers: full plugin specifiers to return
|
|
|
- // e.g., "[email protected]", "file:///path/to/plugin.js"
|
|
|
- const uniqueSpecifiers: string[] = []
|
|
|
+ // e.g., "[email protected]", ["file:///path/to/plugin.js", { ... }]
|
|
|
+ const uniqueSpecifiers: PluginSpec[] = []
|
|
|
|
|
|
for (const specifier of plugins.toReversed()) {
|
|
|
const name = getPluginName(specifier)
|
|
|
@@ -997,7 +1029,7 @@ export namespace Config {
|
|
|
ignore: z.array(z.string()).optional(),
|
|
|
})
|
|
|
.optional(),
|
|
|
- plugin: z.string().array().optional(),
|
|
|
+ plugin: PluginSpec.array().optional(),
|
|
|
snapshot: z.boolean().optional(),
|
|
|
share: z
|
|
|
.enum(["manual", "auto", "disabled"])
|
|
|
@@ -1245,19 +1277,7 @@ export namespace Config {
|
|
|
const data = parsed.data
|
|
|
if (data.plugin && isFile) {
|
|
|
for (let i = 0; i < data.plugin.length; i++) {
|
|
|
- const plugin = data.plugin[i]
|
|
|
- try {
|
|
|
- data.plugin[i] = import.meta.resolve!(plugin, options.path)
|
|
|
- } catch (e) {
|
|
|
- try {
|
|
|
- // import.meta.resolve sometimes fails with newly created node_modules
|
|
|
- const require = createRequire(options.path)
|
|
|
- const resolvedPath = require.resolve(plugin)
|
|
|
- data.plugin[i] = pathToFileURL(resolvedPath).href
|
|
|
- } catch {
|
|
|
- // Ignore, plugin might be a generic string identifier like "mcp-server"
|
|
|
- }
|
|
|
- }
|
|
|
+ data.plugin[i] = resolvePluginSpec(data.plugin[i], options.path)
|
|
|
}
|
|
|
}
|
|
|
return data
|