diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index f3f612805..33c59997c 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -360,6 +360,15 @@ replit: # for Replit Agent-specific parameters (optional; Agent Skills standard) agent-skills: ">=1.0.0" metadata: # (optional) free-form metadata author: rulesync +opencode: # for OpenCode-specific parameters (optional) + license: MIT # (optional) + compatibility: # (optional) free-form compatibility metadata + opencode-version: ">=1.16.0" + metadata: # (optional) free-form metadata + author: rulesync + allowed-tools: # (optional) Anthropic-spec passthrough; OpenCode ignores unknown fields + - "Bash" + - "Read" agentsskills: # for the Agent Skills standard target (optional; supports project + global ~/.agents/skills/) license: MIT # (optional) compatibility: "Requires Python 3.14+ and uv" # (optional) free-form string, 1–500 chars (an object is also accepted for back-compat) diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index f3f612805..33c59997c 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -360,6 +360,15 @@ replit: # for Replit Agent-specific parameters (optional; Agent Skills standard) agent-skills: ">=1.0.0" metadata: # (optional) free-form metadata author: rulesync +opencode: # for OpenCode-specific parameters (optional) + license: MIT # (optional) + compatibility: # (optional) free-form compatibility metadata + opencode-version: ">=1.16.0" + metadata: # (optional) free-form metadata + author: rulesync + allowed-tools: # (optional) Anthropic-spec passthrough; OpenCode ignores unknown fields + - "Bash" + - "Read" agentsskills: # for the Agent Skills standard target (optional; supports project + global ~/.agents/skills/) license: MIT # (optional) compatibility: "Requires Python 3.14+ and uv" # (optional) free-form string, 1–500 chars (an object is also accepted for back-compat) diff --git a/src/features/skills/opencode-skill.test.ts b/src/features/skills/opencode-skill.test.ts index ec2b3d5a6..f01e2282b 100644 --- a/src/features/skills/opencode-skill.test.ts +++ b/src/features/skills/opencode-skill.test.ts @@ -11,7 +11,7 @@ import { OpenCodeSkillFrontmatter, OpenCodeSkillFrontmatterSchema, } from "./opencode-skill.js"; -import { RulesyncSkill } from "./rulesync-skill.js"; +import { RulesyncSkill, RulesyncSkillFrontmatterInput } from "./rulesync-skill.js"; describe("OpenCodeSkill", () => { let testDir: string; @@ -120,6 +120,47 @@ describe("OpenCodeSkill", () => { "allowed-tools": ["Bash", "Read"], }); }); + + it("should carry license/compatibility/metadata into the opencode section", () => { + const skill = new OpenCodeSkill({ + outputRoot: testDir, + dirName: "test-skill", + frontmatter: { + name: "Test Skill", + description: "Test description", + license: "MIT", + compatibility: { opencode: ">=1.0.0" }, + metadata: { author: "rulesync" }, + }, + body: "Test body", + validate: true, + }); + + const rulesyncSkill = skill.toRulesyncSkill(); + + expect(rulesyncSkill.getFrontmatter().opencode).toEqual({ + license: "MIT", + compatibility: { opencode: ">=1.0.0" }, + metadata: { author: "rulesync" }, + }); + }); + + it("should not attach an opencode section when no optional fields exist", () => { + const skill = new OpenCodeSkill({ + outputRoot: testDir, + dirName: "test-skill", + frontmatter: { + name: "Test Skill", + description: "Test description", + }, + body: "Test body", + validate: true, + }); + + const rulesyncSkill = skill.toRulesyncSkill(); + + expect(rulesyncSkill.getFrontmatter().opencode).toBeUndefined(); + }); }); describe("fromRulesyncSkill", () => { @@ -174,6 +215,81 @@ describe("OpenCodeSkill", () => { expect(skill.getRelativeDirPath()).toBe(join(".config", "opencode", "skills")); expect(skill.getFrontmatter()["allowed-tools"]).toEqual(["Bash", "Read"]); }); + + it("should emit license/compatibility/metadata from the opencode section", () => { + const rulesyncSkill = new RulesyncSkill({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: "test-skill", + frontmatter: { + name: "Test Skill", + description: "Test skill description", + opencode: { + license: "Apache-2.0", + compatibility: { opencode: ">=1.0.0" }, + metadata: { author: "rulesync" }, + }, + }, + body: "Test body", + validate: true, + }); + + const skill = OpenCodeSkill.fromRulesyncSkill({ rulesyncSkill, global: false }); + + const frontmatter = skill.getFrontmatter(); + expect(frontmatter.license).toBe("Apache-2.0"); + expect(frontmatter.compatibility).toEqual({ opencode: ">=1.0.0" }); + expect(frontmatter.metadata).toEqual({ author: "rulesync" }); + }); + + it("should fall back to top-level license/compatibility/metadata (issue #1787)", () => { + const rulesyncSkill = new RulesyncSkill({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: "test-skill", + // Top-level license/compatibility/metadata are accepted by the loose + // schema even though they are not part of the typed input. + frontmatter: { + name: "Test Skill", + description: "Test skill description", + license: "MIT", + compatibility: { opencode: ">=2.0.0" }, + metadata: { author: "top-level" }, + } as unknown as RulesyncSkillFrontmatterInput, + body: "Test body", + validate: true, + }); + + const skill = OpenCodeSkill.fromRulesyncSkill({ rulesyncSkill, global: false }); + + const frontmatter = skill.getFrontmatter(); + expect(frontmatter.license).toBe("MIT"); + expect(frontmatter.compatibility).toEqual({ opencode: ">=2.0.0" }); + expect(frontmatter.metadata).toEqual({ author: "top-level" }); + }); + + it("should prefer the opencode section over top-level values", () => { + const rulesyncSkill = new RulesyncSkill({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: "test-skill", + // `license` at the top level is accepted by the loose schema. + frontmatter: { + name: "Test Skill", + description: "Test skill description", + license: "MIT", + opencode: { + license: "Apache-2.0", + }, + } as unknown as RulesyncSkillFrontmatterInput, + body: "Test body", + validate: true, + }); + + const skill = OpenCodeSkill.fromRulesyncSkill({ rulesyncSkill, global: false }); + + expect(skill.getFrontmatter().license).toBe("Apache-2.0"); + }); }); describe("fromDir", () => { @@ -205,6 +321,39 @@ It can be multiline.`; }); expect(skill.getBody()).toBe("This is the body of the opencode skill.\nIt can be multiline."); }); + + it("should round-trip license/compatibility/metadata through fromDir and toRulesyncSkill", async () => { + const skillDir = join(testDir, ".opencode", "skills", "test-skill"); + await ensureDir(skillDir); + const skillContent = `--- +name: Test Skill +description: Test skill description +license: MIT +compatibility: + opencode: ">=1.0.0" +metadata: + author: rulesync +--- + +Body content.`; + await writeFileContent(join(skillDir, SKILL_FILE_NAME), skillContent); + + const skill = await OpenCodeSkill.fromDir({ + outputRoot: testDir, + dirName: "test-skill", + }); + + const frontmatter = skill.getFrontmatter(); + expect(frontmatter.license).toBe("MIT"); + expect(frontmatter.compatibility).toEqual({ opencode: ">=1.0.0" }); + expect(frontmatter.metadata).toEqual({ author: "rulesync" }); + + expect(skill.toRulesyncSkill().getFrontmatter().opencode).toEqual({ + license: "MIT", + compatibility: { opencode: ">=1.0.0" }, + metadata: { author: "rulesync" }, + }); + }); }); describe("isTargetedByRulesyncSkill", () => { diff --git a/src/features/skills/opencode-skill.ts b/src/features/skills/opencode-skill.ts index 6eef48681..baff03b6c 100644 --- a/src/features/skills/opencode-skill.ts +++ b/src/features/skills/opencode-skill.ts @@ -18,6 +18,15 @@ import { export const OpenCodeSkillFrontmatterSchema = z.looseObject({ name: z.string(), description: z.string(), + // OpenCode's SKILL.md parser recognizes exactly five frontmatter fields: + // `name`, `description`, `license`, `compatibility`, and `metadata`. + // See https://opencode.ai/docs/skills.md + license: z.optional(z.string()), + compatibility: z.optional(z.looseObject({})), + metadata: z.optional(z.looseObject({})), + // `allowed-tools` is NOT recognized by OpenCode (it is an Anthropic-spec + // field that OpenCode silently ignores). It is kept as an optional + // passthrough purely for lossless round-trip with the rulesync frontmatter. "allowed-tools": z.optional(z.array(z.string())), }); @@ -111,15 +120,21 @@ export class OpenCodeSkill extends ToolSkill { toRulesyncSkill(): RulesyncSkill { const frontmatter = this.getFrontmatter(); + const opencodeBlock = { + ...(frontmatter["allowed-tools"] !== undefined && { + "allowed-tools": frontmatter["allowed-tools"], + }), + ...(frontmatter.license !== undefined && { license: frontmatter.license }), + ...(frontmatter.compatibility !== undefined && { + compatibility: frontmatter.compatibility, + }), + ...(frontmatter.metadata !== undefined && { metadata: frontmatter.metadata }), + }; const rulesyncFrontmatter: RulesyncSkillFrontmatterInput = { name: frontmatter.name, description: frontmatter.description, targets: ["*"], - ...(frontmatter["allowed-tools"] && { - opencode: { - "allowed-tools": frontmatter["allowed-tools"], - }, - }), + ...(Object.keys(opencodeBlock).length > 0 && { opencode: opencodeBlock }), }; return new RulesyncSkill({ @@ -141,11 +156,38 @@ export class OpenCodeSkill extends ToolSkill { global = false, }: ToolSkillFromRulesyncSkillParams): OpenCodeSkill { const rulesyncFrontmatter = rulesyncSkill.getFrontmatter(); + const opencodeSection = rulesyncFrontmatter.opencode; + + // `RulesyncSkillFrontmatterSchema` is a `looseObject`, so top-level + // `license`/`compatibility`/`metadata` may exist as runtime keys even + // though they are not part of the typed input. Read them safely here. + const looseTopLevel = rulesyncFrontmatter as Record; + const topLevelLicense = + typeof looseTopLevel.license === "string" ? looseTopLevel.license : undefined; + const topLevelCompatibility = + typeof looseTopLevel.compatibility === "object" && looseTopLevel.compatibility !== null + ? (looseTopLevel.compatibility as Record) + : undefined; + const topLevelMetadata = + typeof looseTopLevel.metadata === "object" && looseTopLevel.metadata !== null + ? (looseTopLevel.metadata as Record) + : undefined; + + // Source precedence: the `opencode` section value takes priority, falling + // back to the top-level rulesync frontmatter value when present. + const license = opencodeSection?.license ?? topLevelLicense; + const compatibility = opencodeSection?.compatibility ?? topLevelCompatibility; + const metadata = opencodeSection?.metadata ?? topLevelMetadata; const opencodeFrontmatter: OpenCodeSkillFrontmatter = { name: rulesyncFrontmatter.name, description: rulesyncFrontmatter.description, - "allowed-tools": rulesyncFrontmatter.opencode?.["allowed-tools"], + ...(license !== undefined && { license }), + ...(compatibility !== undefined && { compatibility }), + ...(metadata !== undefined && { metadata }), + ...(opencodeSection?.["allowed-tools"] !== undefined && { + "allowed-tools": opencodeSection["allowed-tools"], + }), }; const settablePaths = OpenCodeSkill.getSettablePaths({ global }); diff --git a/src/features/skills/rulesync-skill.ts b/src/features/skills/rulesync-skill.ts index 4e57ccda9..bf52f3dbb 100644 --- a/src/features/skills/rulesync-skill.ts +++ b/src/features/skills/rulesync-skill.ts @@ -64,6 +64,9 @@ const RulesyncSkillFrontmatterSchemaInternal = z.looseObject({ opencode: z.optional( z.looseObject({ "allowed-tools": z.optional(z.array(z.string())), + license: z.optional(z.string()), + compatibility: z.optional(z.looseObject({})), + metadata: z.optional(z.looseObject({})), }), ), kilo: z.optional( @@ -182,6 +185,9 @@ export type RulesyncSkillFrontmatterInput = { }; opencode?: { "allowed-tools"?: string[]; + license?: string; + compatibility?: Record; + metadata?: Record; }; kilo?: { "allowed-tools"?: string[];