fix(install): register bundled skill paths

This commit is contained in:
YeonGyu-Kim
2026-05-30 20:03:11 +09:00
parent 855d191bf3
commit 48baa8e277
3 changed files with 141 additions and 15 deletions
@@ -1,6 +1,7 @@
import { readFileSync, writeFileSync } from "node:fs"
import { applyEdits, modify } from "jsonc-parser"
import type { ConfigMergeResult } from "../types"
import { PLUGIN_NAME, LEGACY_PLUGIN_NAME } from "../../shared"
import { PLUGIN_NAME, LEGACY_PLUGIN_NAME, PUBLISHED_PACKAGE_NAME } from "../../shared"
import { backupConfigFile } from "./backup-config"
import { getConfigDir } from "./config-context"
import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists"
@@ -10,14 +11,85 @@ import { parseOpenCodeConfigFileWithError, type OpenCodeConfig } from "./parse-o
import { getPluginNameWithVersion } from "./plugin-name-with-version"
import { checkVersionCompatibility, extractVersionFromPluginEntry } from "./version-compatibility"
const BUNDLED_SKILL_PATHS = [
`./node_modules/${PUBLISHED_PACKAGE_NAME}/.agents/skills`,
`./node_modules/${PLUGIN_NAME}/.agents/skills`,
] as const
interface OpenCodeSkillsConfig {
paths?: string[]
urls?: string[]
[key: string]: unknown
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function toStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter((item): item is string => typeof item === "string")
}
function toSkillsConfig(value: unknown): OpenCodeSkillsConfig {
if (!isRecord(value)) return {}
return {
...value,
paths: toStringArray(value.paths),
urls: toStringArray(value.urls),
}
}
function ensureBundledSkillPaths(config: OpenCodeConfig): void {
const skills = toSkillsConfig(config.skills)
const existingPaths = skills.paths ?? []
const pathSet = new Set(existingPaths)
const mergedPaths = [...existingPaths]
for (const skillPath of BUNDLED_SKILL_PATHS) {
if (!pathSet.has(skillPath)) {
mergedPaths.push(skillPath)
pathSet.add(skillPath)
}
}
config.skills = {
...skills,
paths: mergedPaths,
}
}
function getConfiguredSkillPaths(config: OpenCodeConfig): string[] {
return toSkillsConfig(config.skills).paths ?? [...BUNDLED_SKILL_PATHS]
}
function updateJsoncField(content: string, path: (string | number)[], value: unknown): string {
const edits = modify(content, path, value, {
formattingOptions: {
insertSpaces: true,
tabSize: 2,
eol: "\n",
},
})
return edits.length === 0 ? content : applyEdits(content, edits)
}
function updateJsoncConfig(content: string, pluginEntries: string[], skillPaths: string[]): string {
const withPlugin = updateJsoncField(content, ["plugin"], pluginEntries)
return updateJsoncField(withPlugin, ["skills", "paths"], skillPaths)
}
export async function addPluginToOpenCodeConfig(currentVersion: string): Promise<ConfigMergeResult> {
try {
ensureConfigDirectoryExists()
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err))
return {
success: false,
configPath: getConfigDir(),
error: formatErrorWithSuggestion(err, "create config directory"),
error: formatErrorWithSuggestion(error, "create config directory"),
}
}
@@ -27,6 +99,7 @@ export async function addPluginToOpenCodeConfig(currentVersion: string): Promise
try {
if (format === "none") {
const config: OpenCodeConfig = { plugin: [pluginEntry] }
ensureBundledSkillPaths(config)
writeFileSync(path, JSON.stringify(config, null, 2) + "\n")
return { success: true, configPath: path }
}
@@ -82,30 +155,22 @@ export async function addPluginToOpenCodeConfig(currentVersion: string): Promise
normalizedPlugins.push(pluginEntry)
config.plugin = normalizedPlugins
ensureBundledSkillPaths(config)
if (format === "jsonc") {
const content = readFileSync(path, "utf-8")
const pluginArrayRegex = /((?:"plugin"|plugin)\s*:\s*)\[([\s\S]*?)\]/
const match = content.match(pluginArrayRegex)
if (match) {
const formattedPlugins = normalizedPlugins.map((p) => `"${p}"`).join(",\n ")
const newContent = content.replace(pluginArrayRegex, `$1[\n ${formattedPlugins}\n ]`)
writeFileSync(path, newContent)
} else {
const newContent = content.replace(/(\{)/, `$1\n "plugin": ["${pluginEntry}"],`)
writeFileSync(path, newContent)
}
writeFileSync(path, updateJsoncConfig(content, normalizedPlugins, getConfiguredSkillPaths(config)))
} else {
writeFileSync(path, JSON.stringify(config, null, 2) + "\n")
}
return { success: true, configPath: path }
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err))
return {
success: false,
configPath: path,
error: formatErrorWithSuggestion(err, "update opencode config"),
error: formatErrorWithSuggestion(error, "update opencode config"),
}
}
}
@@ -4,10 +4,16 @@ import { tmpdir } from "node:os"
import { join } from "node:path"
import { resetConfigContext } from "./config-context"
import { parseJsonc } from "../../shared"
import { detectCurrentConfig } from "./detect-current-config"
import { addPluginToOpenCodeConfig } from "./add-plugin-to-opencode-config"
import * as pluginNameWithVersion from "./plugin-name-with-version"
const bundledSkillPaths = [
"./node_modules/oh-my-opencode/.agents/skills",
"./node_modules/oh-my-openagent/.agents/skills",
] as const
describe("detectCurrentConfig - single package detection", () => {
let testConfigDir = ""
let testConfigPath = ""
@@ -95,6 +101,54 @@ describe("addPluginToOpenCodeConfig - single package writes", () => {
expect(result.success).toBe(true)
const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8"))
expect(savedConfig.plugin).toEqual(["oh-my-openagent"])
expect(savedConfig.skills?.paths).toEqual([...bundledSkillPaths])
})
it("preserves existing skill paths while registering bundled skills", async () => {
// given
writeFileSync(
testConfigPath,
JSON.stringify({
plugin: ["other-plugin"],
skills: {
paths: ["./custom-skills"],
urls: ["https://example.com/skill.md"],
},
}, null, 2) + "\n",
"utf-8",
)
// when
const result = await addPluginToOpenCodeConfig("3.11.0")
// then
expect(result.success).toBe(true)
const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8"))
expect(savedConfig.plugin).toEqual(["other-plugin", "oh-my-openagent"])
expect(savedConfig.skills.paths).toEqual(["./custom-skills", ...bundledSkillPaths])
expect(savedConfig.skills.urls).toEqual(["https://example.com/skill.md"])
})
it("does not duplicate bundled skill paths on reinstall", async () => {
// given
writeFileSync(
testConfigPath,
JSON.stringify({
plugin: ["oh-my-openagent"],
skills: {
paths: [...bundledSkillPaths],
},
}, null, 2) + "\n",
"utf-8",
)
// when
const result = await addPluginToOpenCodeConfig("3.11.0")
// then
expect(result.success).toBe(true)
const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8"))
expect(savedConfig.skills.paths).toEqual([...bundledSkillPaths])
})
it("upgrades a bare legacy plugin entry to canonical", async () => {
@@ -181,7 +235,9 @@ describe("addPluginToOpenCodeConfig - single package writes", () => {
// then
expect(result.success).toBe(true)
const savedContent = readFileSync(testConfigPath, "utf-8")
const savedConfig = parseJsonc<{ plugin?: string[]; skills?: { paths?: string[] } }>(savedContent)
expect(savedContent.includes('"plugin": [\n "oh-my-openagent"\n ]')).toBe(true)
expect(savedContent.includes("oh-my-opencode")).toBe(false)
expect(savedConfig.plugin).toEqual(["oh-my-openagent"])
expect(savedConfig.skills?.paths).toEqual([...bundledSkillPaths])
})
})
+5
View File
@@ -10,6 +10,10 @@ import { unsafeTestValue } from "../../test-support/unsafe-test-value"
// Mock console methods to capture output
const mockConsoleLog = mock(() => {})
const mockConsoleError = mock(() => {})
const bundledSkillPaths = [
"./node_modules/oh-my-opencode/.agents/skills",
"./node_modules/oh-my-openagent/.agents/skills",
] as const
describe("install CLI - binary check behavior", () => {
let tempDir: string
@@ -121,6 +125,7 @@ describe("install CLI - binary check behavior", () => {
expect(config.plugin).toBeDefined()
expect(config.plugin.some((p: string) => p.includes("oh-my-openagent"))).toBe(true)
expect(config.plugin.some((p: string) => p.includes("oh-my-opencode"))).toBe(false)
expect(config.skills?.paths).toEqual([...bundledSkillPaths])
// then exit code should be 0 (success)
expect(exitCode).toBe(0)