Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/features/permissions/opencode-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
16 changes: 15 additions & 1 deletion src/features/permissions/opencode-permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof OpencodePermissionsConfigSchema>;
Expand Down Expand Up @@ -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,
Expand Down
71 changes: 71 additions & 0 deletions src/features/skills/opencode-skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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", () => {
Expand Down
26 changes: 21 additions & 5 deletions src/features/skills/opencode-skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -38,6 +42,21 @@ export const OpenCodeSkillFrontmatterSchema = z.looseObject({

export type OpenCodeSkillFrontmatter = z.infer<typeof OpenCodeSkillFrontmatterSchema>;

/**
* 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<string, unknown> | undefined {
if (typeof value === "string") {
return value;
}
if (typeof value === "object" && value !== null) {
return value as Record<string, unknown>;
}
return undefined;
}

export type OpenCodeSkillParams = {
outputRoot?: string;
relativeDirPath?: string;
Expand Down Expand Up @@ -168,10 +187,7 @@ export class OpenCodeSkill extends ToolSkill {
const looseTopLevel = rulesyncFrontmatter as Record<string, unknown>;
const topLevelLicense =
typeof looseTopLevel.license === "string" ? looseTopLevel.license : undefined;
const topLevelCompatibility =
typeof looseTopLevel.compatibility === "object" && looseTopLevel.compatibility !== null
? (looseTopLevel.compatibility as Record<string, unknown>)
: undefined;
const topLevelCompatibility = readTopLevelCompatibility(looseTopLevel.compatibility);
const topLevelMetadata =
typeof looseTopLevel.metadata === "object" && looseTopLevel.metadata !== null
? (looseTopLevel.metadata as Record<string, unknown>)
Expand Down
6 changes: 4 additions & 2 deletions src/features/skills/rulesync-skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({})),
}),
),
Expand Down Expand Up @@ -247,7 +249,7 @@ export type RulesyncSkillFrontmatterInput = {
opencode?: {
"allowed-tools"?: string[];
license?: string;
compatibility?: Record<string, unknown>;
compatibility?: string | Record<string, unknown>;
metadata?: Record<string, unknown>;
};
kilo?: {
Expand Down
Loading