diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 6aa79e3090dc..17563692bfa4 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -477,6 +477,17 @@ export namespace Config { }) export type Skills = z.infer + export const LegacySkill = z + .object({ + name: z.string(), + description: z.string(), + command: z.string(), + }) + .catchall(z.unknown()) + export const LegacySkills = z.array(LegacySkill) + export type LegacySkill = z.infer + export type LegacySkills = z.infer + export const Agent = z .object({ model: ModelId.optional(), @@ -871,7 +882,10 @@ export namespace Config { .record(z.string(), Command) .optional() .describe("Command configuration, see https://opencode.ai/docs/commands"), - skills: Skills.optional().describe("Additional skill folder paths"), + skills: z + .union([Skills, LegacySkills]) + .optional() + .describe("Additional skill folder paths, remote skill URLs, or legacy inline skills"), watcher: z .object({ ignore: z.array(z.string()).optional(), diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 6c4f290a08d8..fc5de1bc1882 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -164,25 +164,56 @@ export namespace Skill { } const cfg = yield* config.get() - for (const item of cfg.skills?.paths ?? []) { - const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item - const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded) - if (!(yield* fsys.isDir(dir))) { - log.warn("skill path not found", { path: dir }) - continue - } + if (Array.isArray(cfg.skills)) { + for (const item of cfg.skills) { + if (state.skills[item.name]) { + log.warn("duplicate skill name", { + name: item.name, + existing: state.skills[item.name].location, + duplicate: path.join(directory, "opencode.json"), + }) + } - yield* scan(state, bus, dir, SKILL_PATTERN) + state.skills[item.name] = { + name: item.name, + description: item.description, + location: path.join(directory, "opencode.json"), + content: [ + "# Legacy Inline Skill", + "", + "This skill was loaded from the legacy `skills` array in `opencode.json`.", + "", + "```bash", + item.command, + "```", + ].join("\n"), + } + } } - for (const url of cfg.skills?.urls ?? []) { - const pulledDirs = yield* discovery.pull(url) - for (const dir of pulledDirs) { - state.dirs.add(dir) + if (!Array.isArray(cfg.skills)) { + for (const item of cfg.skills?.paths ?? []) { + const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item + const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded) + if (!(yield* fsys.isDir(dir))) { + log.warn("skill path not found", { path: dir }) + continue + } + yield* scan(state, bus, dir, SKILL_PATTERN) } } + if (!Array.isArray(cfg.skills)) { + for (const url of cfg.skills?.urls ?? []) { + const pulledDirs = yield* discovery.pull(url) + for (const dir of pulledDirs) { + state.dirs.add(dir) + yield* scan(state, bus, dir, SKILL_PATTERN) + } + } + } + log.info("init", { count: Object.keys(state.skills).length }) }) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index ce3566a0c58d..4669caf17c10 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -514,6 +514,41 @@ test("handles command configuration", async () => { }) }) +test("accepts legacy skills array in opencode.json", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + skills: [ + { + name: "test", + description: "Test skill", + command: "echo test", + }, + ], + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(Array.isArray(config.skills)).toBe(true) + expect(config.skills).toEqual([ + { + name: "test", + description: "Test skill", + command: "echo test", + }, + ]) + }, + }) +}) + test("migrates autoshare to share field", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index 21c6c7e65137..13b79880c65a 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -11,11 +11,11 @@ const node = CrossSpawnSpawner.defaultLayer const it = testEffect(Layer.mergeAll(Skill.defaultLayer, node)) -async function createGlobalSkill(homeDir: string) { - const skillDir = path.join(homeDir, ".claude", "skills", "global-test-skill") - await fs.mkdir(skillDir, { recursive: true }) +async function createGlobalSkill(home: string) { + const dir = path.join(home, ".claude", "skills", "global-test-skill") + await fs.mkdir(dir, { recursive: true }) await Bun.write( - path.join(skillDir, "SKILL.md"), + path.join(dir, "SKILL.md"), `--- name: global-test-skill description: A global skill from ~/.claude/skills for testing. @@ -226,6 +226,38 @@ description: A skill in the .claude/skills directory. ), ) + it.live("loads legacy inline skills from opencode.json", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + skills: [ + { + name: "test", + description: "Test skill", + command: "echo test", + }, + ], + }), + ), + ) + + const skill = yield* Skill.Service + const list = yield* skill.all() + expect(list.length).toBe(1) + expect(list[0].name).toBe("test") + expect(list[0].description).toBe("Test skill") + expect(list[0].location).toBe(path.join(dir, "opencode.json")) + expect(list[0].content).toContain("echo test") + }), + { git: true }, + ), + ) + it.live("discovers skills from .agents/skills/ directory", () => provideTmpdirInstance( (dir) => @@ -264,11 +296,11 @@ description: A skill in the .agents/skills directory. yield* withHome( tmp.path, Effect.gen(function* () { - const skillDir = path.join(tmp.path, ".agents", "skills", "global-agent-skill") - yield* Effect.promise(() => fs.mkdir(skillDir, { recursive: true })) + const dir = path.join(tmp.path, ".agents", "skills", "global-agent-skill") + yield* Effect.promise(() => fs.mkdir(dir, { recursive: true })) yield* Effect.promise(() => Bun.write( - path.join(skillDir, "SKILL.md"), + path.join(dir, "SKILL.md"), `--- name: global-agent-skill description: A global skill from ~/.agents/skills for testing.