From 3a97e3bbf25a6900d9c72336623ed0a19303f028 Mon Sep 17 00:00:00 2001 From: dyoshikawa Date: Tue, 30 Jun 2026 15:49:42 -0700 Subject: [PATCH] fix(opencode): accept documented string forms for skill compatibility and top-level permission (#2066) OpenCode documents SKILL.md `compatibility` as a free-form string (e.g. `compatibility: opencode`), but the schema modeled it as object-only, so importing the documented form threw `Invalid frontmatter`. Widen OpenCodeSkillFrontmatterSchema and the rulesync opencode-section schema to a string|object union and propagate it through toRulesyncSkill/fromRulesyncSkill. OpenCode also documents the top-level `permission` config as accepting a bare uniform string (`"permission": "allow"`) applying to all tools, but the schema typed it as object-only. Widen OpencodePermissionsConfigSchema to a string|record union and map the uniform string into the canonical model as the wildcard tool/glob `{ "*": { "*": value } }`, matching OpenCode's own `*` all-tools convention. The object forms remain fully backward compatible. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../permissions/opencode-permissions.test.ts | 11 +++ .../permissions/opencode-permissions.ts | 16 ++++- src/features/skills/opencode-skill.test.ts | 71 +++++++++++++++++++ src/features/skills/opencode-skill.ts | 26 +++++-- src/features/skills/rulesync-skill.ts | 6 +- 5 files changed, 122 insertions(+), 8 deletions(-) diff --git a/src/features/permissions/opencode-permissions.test.ts b/src/features/permissions/opencode-permissions.test.ts index 31db5830f..237afe357 100644 --- a/src/features/permissions/opencode-permissions.test.ts +++ b/src/features/permissions/opencode-permissions.test.ts @@ -70,6 +70,17 @@ describe("OpencodePermissions", () => { expect(json.permission.bash["git *"]).toBe("allow"); }); + it("should import the top-level uniform string permission form (issue #2066)", async () => { + await writeFileContent(join(testDir, "opencode.json"), JSON.stringify({ permission: "allow" })); + + const instance = await OpencodePermissions.fromFile({ outputRoot: testDir }); + + expect(instance.getJson().permission).toBe("allow"); + + const rulesync = instance.toRulesyncPermissions().getJson(); + expect(rulesync.permission).toEqual({ "*": { "*": "allow" } }); + }); + it("should support global mode file resolution", async () => { await ensureDir(join(testDir, ".config", "opencode")); await writeFileContent( diff --git a/src/features/permissions/opencode-permissions.ts b/src/features/permissions/opencode-permissions.ts index 781228b17..01e20dde7 100644 --- a/src/features/permissions/opencode-permissions.ts +++ b/src/features/permissions/opencode-permissions.ts @@ -28,7 +28,13 @@ const OpencodePermissionSchema = z.union([ ]); const OpencodePermissionsConfigSchema = z.looseObject({ - permission: z.optional(z.record(z.string(), OpencodePermissionSchema)), + // OpenCode accepts either a per-tool object OR a bare top-level string that + // applies uniformly to every tool (e.g. `"permission": "allow"`). + // See https://opencode.ai/docs/permissions/ ("You can also set all + // permissions at once"). + permission: z.optional( + z.union([z.enum(["allow", "ask", "deny"]), z.record(z.string(), OpencodePermissionSchema)]), + ), }); type OpencodePermissionsConfig = z.infer; @@ -170,6 +176,14 @@ export class OpencodePermissions extends ToolPermissions { return {}; } + // Top-level uniform string form (`"permission": "allow"`): OpenCode applies + // it to every tool. The canonical rulesync model represents "all tools / + // all inputs" with the wildcard tool key `"*"` and the wildcard glob `"*"`, + // matching how OpenCode's own object syntax uses `"*"` as the all-tools key. + if (typeof permission === "string") { + return { "*": { "*": permission } }; + } + return Object.fromEntries( Object.entries(permission).map(([tool, value]) => [ tool, diff --git a/src/features/skills/opencode-skill.test.ts b/src/features/skills/opencode-skill.test.ts index f01e2282b..e1316fbec 100644 --- a/src/features/skills/opencode-skill.test.ts +++ b/src/features/skills/opencode-skill.test.ts @@ -145,6 +145,26 @@ describe("OpenCodeSkill", () => { }); }); + it("should carry a string compatibility into the opencode section (issue #2066)", () => { + const skill = new OpenCodeSkill({ + outputRoot: testDir, + dirName: "test-skill", + frontmatter: { + name: "Test Skill", + description: "Test description", + compatibility: "opencode", + }, + body: "Test body", + validate: true, + }); + + const rulesyncSkill = skill.toRulesyncSkill(); + + expect(rulesyncSkill.getFrontmatter().opencode).toEqual({ + compatibility: "opencode", + }); + }); + it("should not attach an opencode section when no optional fields exist", () => { const skill = new OpenCodeSkill({ outputRoot: testDir, @@ -268,6 +288,27 @@ describe("OpenCodeSkill", () => { expect(frontmatter.metadata).toEqual({ author: "top-level" }); }); + it("should emit a string compatibility from the opencode section (issue #2066)", () => { + const rulesyncSkill = new RulesyncSkill({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: "test-skill", + frontmatter: { + name: "Test Skill", + description: "Test skill description", + opencode: { + compatibility: "opencode", + }, + }, + body: "Test body", + validate: true, + }); + + const skill = OpenCodeSkill.fromRulesyncSkill({ rulesyncSkill, global: false }); + + expect(skill.getFrontmatter().compatibility).toBe("opencode"); + }); + it("should prefer the opencode section over top-level values", () => { const rulesyncSkill = new RulesyncSkill({ outputRoot: testDir, @@ -354,6 +395,36 @@ Body content.`; metadata: { author: "rulesync" }, }); }); + + it("should import the documented `compatibility: opencode` string form (issue #2066)", async () => { + const skillDir = join(testDir, ".opencode", "skills", "git-release"); + await ensureDir(skillDir); + const skillContent = `--- +name: git-release +description: Create consistent releases and changelogs +license: MIT +compatibility: opencode +metadata: + audience: maintainers +--- + +Body content.`; + await writeFileContent(join(skillDir, SKILL_FILE_NAME), skillContent); + + const skill = await OpenCodeSkill.fromDir({ + outputRoot: testDir, + dirName: "git-release", + }); + + const frontmatter = skill.getFrontmatter(); + expect(frontmatter.compatibility).toBe("opencode"); + + expect(skill.toRulesyncSkill().getFrontmatter().opencode).toEqual({ + license: "MIT", + compatibility: "opencode", + metadata: { audience: "maintainers" }, + }); + }); }); describe("isTargetedByRulesyncSkill", () => { diff --git a/src/features/skills/opencode-skill.ts b/src/features/skills/opencode-skill.ts index 32ecc8794..beeedd791 100644 --- a/src/features/skills/opencode-skill.ts +++ b/src/features/skills/opencode-skill.ts @@ -28,7 +28,11 @@ export const OpenCodeSkillFrontmatterSchema = z.looseObject({ // `name`, `description`, `license`, `compatibility`, and `metadata`. // See https://opencode.ai/docs/skills.md license: z.optional(z.string()), - compatibility: z.optional(z.looseObject({})), + // OpenCode documents `compatibility` as a free-form string (e.g. + // `compatibility: opencode`, see https://opencode.ai/docs/skills/ ). The + // object form is also tolerated for backward compatibility with skills that + // model it as a per-tool version-constraint map. + compatibility: z.optional(z.union([z.string(), 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 @@ -38,6 +42,21 @@ export const OpenCodeSkillFrontmatterSchema = z.looseObject({ export type OpenCodeSkillFrontmatter = z.infer; +/** + * Reads a top-level `compatibility` value from rulesync frontmatter, accepting + * both the documented string form (e.g. `compatibility: opencode`) and the + * legacy object form. Returns `undefined` for any other shape. + */ +function readTopLevelCompatibility(value: unknown): string | Record | undefined { + if (typeof value === "string") { + return value; + } + if (typeof value === "object" && value !== null) { + return value as Record; + } + return undefined; +} + export type OpenCodeSkillParams = { outputRoot?: string; relativeDirPath?: string; @@ -168,10 +187,7 @@ export class OpenCodeSkill extends ToolSkill { 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 topLevelCompatibility = readTopLevelCompatibility(looseTopLevel.compatibility); const topLevelMetadata = typeof looseTopLevel.metadata === "object" && looseTopLevel.metadata !== null ? (looseTopLevel.metadata as Record) diff --git a/src/features/skills/rulesync-skill.ts b/src/features/skills/rulesync-skill.ts index 02419b727..ce4502b82 100644 --- a/src/features/skills/rulesync-skill.ts +++ b/src/features/skills/rulesync-skill.ts @@ -80,7 +80,9 @@ const RulesyncSkillFrontmatterSchemaInternal = z.looseObject({ z.looseObject({ "allowed-tools": z.optional(z.array(z.string())), license: z.optional(z.string()), - compatibility: z.optional(z.looseObject({})), + // OpenCode documents `compatibility` as a free-form string; the object + // form stays accepted for back-compat. See https://opencode.ai/docs/skills/ + compatibility: z.optional(z.union([z.string(), z.looseObject({})])), metadata: z.optional(z.looseObject({})), }), ), @@ -247,7 +249,7 @@ export type RulesyncSkillFrontmatterInput = { opencode?: { "allowed-tools"?: string[]; license?: string; - compatibility?: Record; + compatibility?: string | Record; metadata?: Record; }; kilo?: {