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
9 changes: 9 additions & 0 deletions docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions skills/rulesync/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
151 changes: 150 additions & 1 deletion src/features/skills/opencode-skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
54 changes: 48 additions & 6 deletions src/features/skills/opencode-skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())),
});

Expand Down Expand Up @@ -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({
Expand All @@ -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<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 topLevelMetadata =
typeof looseTopLevel.metadata === "object" && looseTopLevel.metadata !== null
? (looseTopLevel.metadata as Record<string, unknown>)
: 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 });
Expand Down
6 changes: 6 additions & 0 deletions src/features/skills/rulesync-skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -182,6 +185,9 @@ export type RulesyncSkillFrontmatterInput = {
};
opencode?: {
"allowed-tools"?: string[];
license?: string;
compatibility?: Record<string, unknown>;
metadata?: Record<string, unknown>;
};
kilo?: {
"allowed-tools"?: string[];
Expand Down
Loading