diff --git a/src/cli/config-manager/add-plugin-to-opencode-config.ts b/src/cli/config-manager/add-plugin-to-opencode-config.ts index 8cb7d0838..e78a91bae 100644 --- a/src/cli/config-manager/add-plugin-to-opencode-config.ts +++ b/src/cli/config-manager/add-plugin-to-opencode-config.ts @@ -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 { + 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 { 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"), } } } diff --git a/src/cli/config-manager/plugin-detection.test.ts b/src/cli/config-manager/plugin-detection.test.ts index e4ebd1b6e..69e7af95b 100644 --- a/src/cli/config-manager/plugin-detection.test.ts +++ b/src/cli/config-manager/plugin-detection.test.ts @@ -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]) }) }) diff --git a/src/cli/install.test.ts b/src/cli/install.test.ts index d84dc7060..c2c9c5ecf 100644 --- a/src/cli/install.test.ts +++ b/src/cli/install.test.ts @@ -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)