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
26 changes: 20 additions & 6 deletions docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ cursor: # cursor specific parameters
copilot: # copilot specific parameters (non-root `*.instructions.md` files only)
name: "TypeScript Style" # (optional) display name shown in the VS Code UI; defaults to the file name
excludeAgent: "code-review" # (optional) "code-review" or "cloud-agent": skip this file for that agent
# Any other frontmatter key found in a hand-written `*.instructions.md` is imported into this
# section and written back out, so a field Rulesync does not model is not lost on regeneration.
# `description` and `applyTo` are the exception: they have canonical homes (`description` and
# `globs`), so a value written for them in this section is overwritten by the canonical one.
antigravity: # antigravity specific parameters
trigger: "always_on" # always_on, glob, manual, or model_decision
globs: ["**/*"] # (optional) file patterns to match when trigger is "glob"
Expand Down Expand Up @@ -597,13 +601,14 @@ name: example-skill # skill name
description: >- # skill description
A sample skill that demonstrates the skill format
targets: ["*"] # * = all, or specific tools
# (optional) shared default for tools that support the flag — claudecode, cursor,
# zed, pi, qwencode, grokcli, and factorydroid. Any of those tool sections can
# override it by setting their own `disable-model-invocation` value below.
# (optional) shared default for tools that support the flag — claudecode, copilot,
# copilotcli, cursor, zed, pi, qwencode, grokcli, and factorydroid. Any of those
# tool sections can override it by setting their own `disable-model-invocation`
# value below.
disable-model-invocation: true
# (optional) shared default for tools that support the flag — claudecode, qwencode,
# vibe, grokcli, and factorydroid. Any of those tool sections can override it by
# setting their own `user-invocable` value below.
# (optional) shared default for tools that support the flag — claudecode, copilot,
# copilotcli, qwencode, vibe, grokcli, and factorydroid. Any of those tool sections
# can override it by setting their own `user-invocable` value below.
user-invocable: false
claudecode: # for claudecode-specific parameters
model: sonnet # opus, sonnet, haiku, or any string
Expand Down Expand Up @@ -713,6 +718,15 @@ agentsskills: # for the Agent Skills standard target (optional; supports project
copilot: # for GitHub Copilot-specific parameters (optional; project .github/skills/, global ~/.copilot/skills/)
license: MIT # (optional)
allowed-tools: "shell" # (optional) tools pre-approved without per-use confirmation
argument-hint: "[message]" # (optional) hint shown for the skill's expected arguments
user-invocable: true # (optional, default true) whether users can run it with /SKILL-NAME
disable-model-invocation: false # (optional, default false) stop the agent from invoking it on its own
context: fork # (optional, experimental) run the skill in a forked session (VS Code 1.118+)
# `copilot` and `copilotcli` write the same SKILL.md path at both scopes, so with both targets
# enabled the one generated last wins — and that is the order the targets are listed in, so which
# section decides the file is not fixed. Set the value in both sections (or, for the two invocation
# gates, in the shared top-level fields) whenever you generate for both. `context` has no
# `copilotcli` counterpart, so it survives only when `copilot` is generated last.
copilotcli: # for GitHub Copilot CLI-specific parameters (optional; project .github/skills/, global ~/.copilot/skills/)
license: MIT # (optional)
allowed-tools: "shell" # (optional) tools pre-approved without per-use confirmation
Expand Down
42 changes: 38 additions & 4 deletions src/features/rules/copilot-rule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1151,11 +1151,11 @@ description: "Test trimming"
expect(result.success).toBe(false);
});

it("should allow frontmatter with extra fields (zod/mini doesn't have strict mode)", () => {
it("should keep frontmatter fields beyond the schema (looseObject)", () => {
const extraFieldsFrontmatter = {
description: "Valid description",
applyTo: "*.js",
extraField: "allowed in zod/mini",
extraField: "kept by looseObject",
};

const result = CopilotRuleFrontmatterSchema.safeParse(extraFieldsFrontmatter);
Expand All @@ -1164,8 +1164,9 @@ description: "Test trimming"
if (result.success) {
expect(result.data.description).toBe("Valid description");
expect(result.data.applyTo).toBe("*.js");
// Extra field is not included in the parsed data
expect((result.data as any).extraField).toBeUndefined();
// A field rulesync does not model survives parsing, so importing a
// hand-written instructions file does not lose it.
expect(result.data.extraField).toBe("kept by looseObject");
}
});
});
Expand Down Expand Up @@ -1340,5 +1341,38 @@ description: "Test trimming"
excludeAgent: "code-review",
});
});

it("carries a frontmatter field beyond the schema in both directions", async () => {
const instructionsDir = join(testDir, ".github", "instructions");
await ensureDir(instructionsDir);
await writeFileContent(
join(instructionsDir, "style.instructions.md"),
[
"---",
"description: Style rules",
"applyTo: '**/*.ts'",
// Not modeled by rulesync: it used to be dropped on import and then
// erased from the file on the next generate.
"futureCopilotField: keep-me",
"---",
"",
"Body",
"",
].join("\n"),
);

const copilotRule = await CopilotRule.fromFile({
outputRoot: testDir,
relativeFilePath: "style.instructions.md",
});
const rulesyncRule = copilotRule.toRulesyncRule();

expect(rulesyncRule.getFrontmatter().copilot).toEqual({
futureCopilotField: "keep-me",
});
expect(
CopilotRule.fromRulesyncRule({ outputRoot: testDir, rulesyncRule }).getFileContent(),
).toContain("futureCopilotField: keep-me");
});
});
});
24 changes: 14 additions & 10 deletions src/features/rules/copilot-rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ import {
buildToolPath,
} from "./tool-rule.js";

export const CopilotRuleFrontmatterSchema = z.object({
// looseObject preserves unknown keys during parsing, so a hand-written
// `*.instructions.md` carrying a field rulesync does not model imports with it
// intact instead of losing it on the next generate.
export const CopilotRuleFrontmatterSchema = z.looseObject({
description: z.optional(z.string()),
applyTo: z.optional(z.string()),
// Display name shown in the UI; defaults to the file name when absent.
Expand Down Expand Up @@ -150,17 +153,17 @@ export class CopilotRule extends ToolRule {
globs = this.frontmatter.applyTo.split(",").map((g) => g.trim());
}

// `description` and `applyTo` have canonical homes; everything else —
// `name`, `excludeAgent`, and any field beyond the schema — rides the
// tool-scoped `copilot` section so it survives the round-trip.
const { description, applyTo: _applyTo, ...copilotFields } = this.frontmatter;

const rulesyncFrontmatter: RulesyncRuleFrontmatter = {
targets: ["*"],
root: this.isRoot(),
description: this.frontmatter.description,
description,
globs,
...((this.frontmatter.excludeAgent || this.frontmatter.name) && {
copilot: {
...(this.frontmatter.excludeAgent && { excludeAgent: this.frontmatter.excludeAgent }),
...(this.frontmatter.name && { name: this.frontmatter.name }),
},
}),
...(Object.keys(copilotFields).length > 0 && { copilot: copilotFields }),
};

// Strip .instructions.md extension and normalize to .md
Expand Down Expand Up @@ -188,10 +191,11 @@ export class CopilotRule extends ToolRule {
const paths = this.getSettablePaths({ global });

const copilotFrontmatter: CopilotRuleFrontmatter = {
// The `copilot` section is written first so the canonical `description`
// and `globs` still own their two keys.
...rulesyncFrontmatter.copilot,
description: rulesyncFrontmatter.description,
applyTo: rulesyncFrontmatter.globs?.length ? rulesyncFrontmatter.globs.join(",") : undefined,
excludeAgent: rulesyncFrontmatter.copilot?.excludeAgent,
name: rulesyncFrontmatter.copilot?.name,
};

// Generate proper file content with Copilot specific frontmatter
Expand Down
57 changes: 57 additions & 0 deletions src/features/skills/copilot-skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,63 @@ Skill content goes here.`,
const roundTripped = CopilotSkill.fromRulesyncSkill({ rulesyncSkill });
expect(roundTripped.getFrontmatter()["allowed-tools"]).toBe("shell");
});

it("should round-trip argument-hint, both invocation gates and context", () => {
const skill = new CopilotSkill({
dirName: "release",
frontmatter: {
name: "release",
description: "Cut a release",
"argument-hint": "<version>",
"user-invocable": true,
"disable-model-invocation": true,
context: "fork",
},
body: "body",
});

const rulesyncSkill = skill.toRulesyncSkill();
expect(rulesyncSkill.getFrontmatter().copilot).toEqual({
"argument-hint": "<version>",
"user-invocable": true,
"disable-model-invocation": true,
context: "fork",
});

const roundTripped = CopilotSkill.fromRulesyncSkill({ rulesyncSkill });
expect(roundTripped.getFrontmatter()).toEqual({
name: "release",
description: "Cut a release",
"argument-hint": "<version>",
"user-invocable": true,
"disable-model-invocation": true,
context: "fork",
});
});

it("should take the invocation gates from the top-level defaults, section wins", () => {
const rulesyncSkill = new RulesyncSkill({
outputRoot: testDir,
relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH,
dirName: "gated",
frontmatter: {
name: "gated",
description: "Gated skill",
targets: ["*"],
// A `false` in the section must win over a `true` default rather
// than reading as absent.
"user-invocable": true,
"disable-model-invocation": true,
copilot: { "user-invocable": false },
},
body: "body",
});

const copilotSkill = CopilotSkill.fromRulesyncSkill({ rulesyncSkill });

expect(copilotSkill.getFrontmatter()["user-invocable"]).toBe(false);
expect(copilotSkill.getFrontmatter()["disable-model-invocation"]).toBe(true);
});
});

describe("isTargetedByRulesyncSkill", () => {
Expand Down
49 changes: 45 additions & 4 deletions src/features/skills/copilot-skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { RULESYNC_SKILLS_RELATIVE_DIR_PATH } from "../../constants/rulesync-path
import { ValidationResult } from "../../types/ai-dir.js";
import { formatError } from "../../utils/error.js";
import { RulesyncSkill, RulesyncSkillFrontmatterInput, SkillFile } from "./rulesync-skill.js";
import { resolveDisableModelInvocation, resolveUserInvocable } from "./skills-utils.js";
import {
ToolSkill,
ToolSkillForDeletionParams,
Expand All @@ -26,6 +27,19 @@ export const CopilotSkillFrontmatterSchema = z.looseObject({
// Pre-approved tools the agent may run without per-use confirmation.
// https://docs.github.com/en/copilot/how-tos/copilot-on-github/customize-copilot/customize-cloud-agent/add-skills
"allowed-tools": z.optional(z.union([z.string(), z.array(z.string())])),
// Hint shown for the skill's expected arguments.
// https://code.visualstudio.com/docs/agent-customization/agent-skills
"argument-hint": z.optional(z.string()),
// The two invocation gates: `user-invocable` (default true) controls
// `/SKILL-NAME`, `disable-model-invocation` (default false) stops the agent
// from picking the skill up on its own.
"user-invocable": z.optional(z.boolean()),
"disable-model-invocation": z.optional(z.boolean()),
// Experimental execution context, `fork` to run the skill in a forked
// session. Added in VS Code 1.118. Typed as a free string rather than the one
// documented literal so a value added later still round-trips.
// https://code.visualstudio.com/updates/v1_118
context: z.optional(z.string()),
});

export type CopilotSkillFrontmatter = z.infer<typeof CopilotSkillFrontmatterSchema>;
Expand Down Expand Up @@ -129,6 +143,16 @@ export class CopilotSkill extends ToolSkill {
...(frontmatter["allowed-tools"] !== undefined && {
"allowed-tools": frontmatter["allowed-tools"],
}),
...(frontmatter["argument-hint"] !== undefined && {
"argument-hint": frontmatter["argument-hint"],
}),
...(frontmatter["user-invocable"] !== undefined && {
"user-invocable": frontmatter["user-invocable"],
}),
...(frontmatter["disable-model-invocation"] !== undefined && {
"disable-model-invocation": frontmatter["disable-model-invocation"],
}),
...(frontmatter.context !== undefined && { context: frontmatter.context }),
};
const rulesyncFrontmatter: RulesyncSkillFrontmatterInput = {
name: frontmatter.name,
Expand Down Expand Up @@ -157,16 +181,33 @@ export class CopilotSkill extends ToolSkill {
}: ToolSkillFromRulesyncSkillParams): CopilotSkill {
const settablePaths = CopilotSkill.getSettablePaths({ global });
const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
const copilotSection = rulesyncFrontmatter.copilot;
const resolvedUserInvocable = resolveUserInvocable({
rootFrontmatter: rulesyncFrontmatter,
section: copilotSection,
});
const resolvedDisableModelInvocation = resolveDisableModelInvocation({
rootFrontmatter: rulesyncFrontmatter,
section: copilotSection,
});

const copilotFrontmatter: CopilotSkillFrontmatter = {
name: rulesyncFrontmatter.name,
description: rulesyncFrontmatter.description,
...(rulesyncFrontmatter.copilot?.license !== undefined && {
license: rulesyncFrontmatter.copilot.license,
...(copilotSection?.license !== undefined && {
license: copilotSection.license,
}),
...(copilotSection?.["allowed-tools"] !== undefined && {
"allowed-tools": copilotSection["allowed-tools"],
}),
...(copilotSection?.["argument-hint"] !== undefined && {
"argument-hint": copilotSection["argument-hint"],
}),
...(rulesyncFrontmatter.copilot?.["allowed-tools"] !== undefined && {
"allowed-tools": rulesyncFrontmatter.copilot["allowed-tools"],
...(resolvedUserInvocable !== undefined && { "user-invocable": resolvedUserInvocable }),
...(resolvedDisableModelInvocation !== undefined && {
"disable-model-invocation": resolvedDisableModelInvocation,
}),
...(copilotSection?.context !== undefined && { context: copilotSection.context }),
};

return new CopilotSkill({
Expand Down
24 changes: 24 additions & 0 deletions src/features/skills/copilotcli-skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,30 @@ Skill content goes here.`,
CopilotcliSkill.fromRulesyncSkill({ rulesyncSkill }).getFrontmatter()["user-invocable"],
).toBe(false);
});

it("should take the invocation gates from the top-level defaults, section wins", () => {
const rulesyncSkill = new RulesyncSkill({
outputRoot: testDir,
relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH,
dirName: "gated",
frontmatter: {
name: "gated",
description: "Gated skill",
targets: ["*"],
"user-invocable": true,
"disable-model-invocation": true,
// A `false` in the section must win over a `true` default rather
// than reading as absent.
copilotcli: { "user-invocable": false },
},
body: "body",
});

const skill = CopilotcliSkill.fromRulesyncSkill({ rulesyncSkill });

expect(skill.getFrontmatter()["user-invocable"]).toBe(false);
expect(skill.getFrontmatter()["disable-model-invocation"]).toBe(true);
});
});

describe("isTargetedByRulesyncSkill", () => {
Expand Down
30 changes: 19 additions & 11 deletions src/features/skills/copilotcli-skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { RULESYNC_SKILLS_RELATIVE_DIR_PATH } from "../../constants/rulesync-path
import { ValidationResult } from "../../types/ai-dir.js";
import { formatError } from "../../utils/error.js";
import { RulesyncSkill, RulesyncSkillFrontmatterInput, SkillFile } from "./rulesync-skill.js";
import { resolveDisableModelInvocation, resolveUserInvocable } from "./skills-utils.js";
import {
ToolSkill,
ToolSkillForDeletionParams,
Expand Down Expand Up @@ -176,24 +177,31 @@ export class CopilotcliSkill extends ToolSkill {
}: ToolSkillFromRulesyncSkillParams): CopilotcliSkill {
const settablePaths = CopilotcliSkill.getSettablePaths({ global });
const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
const copilotcliSection = rulesyncFrontmatter.copilotcli;
const resolvedUserInvocable = resolveUserInvocable({
rootFrontmatter: rulesyncFrontmatter,
section: copilotcliSection,
});
const resolvedDisableModelInvocation = resolveDisableModelInvocation({
rootFrontmatter: rulesyncFrontmatter,
section: copilotcliSection,
});

const copilotcliFrontmatter: CopilotcliSkillFrontmatter = {
name: rulesyncFrontmatter.name,
description: rulesyncFrontmatter.description,
...(rulesyncFrontmatter.copilotcli?.license !== undefined && {
license: rulesyncFrontmatter.copilotcli.license,
}),
...(rulesyncFrontmatter.copilotcli?.["allowed-tools"] !== undefined && {
"allowed-tools": rulesyncFrontmatter.copilotcli["allowed-tools"],
...(copilotcliSection?.license !== undefined && {
license: copilotcliSection.license,
}),
...(rulesyncFrontmatter.copilotcli?.["argument-hint"] !== undefined && {
"argument-hint": rulesyncFrontmatter.copilotcli["argument-hint"],
...(copilotcliSection?.["allowed-tools"] !== undefined && {
"allowed-tools": copilotcliSection["allowed-tools"],
}),
...(rulesyncFrontmatter.copilotcli?.["user-invocable"] !== undefined && {
"user-invocable": rulesyncFrontmatter.copilotcli["user-invocable"],
...(copilotcliSection?.["argument-hint"] !== undefined && {
"argument-hint": copilotcliSection["argument-hint"],
}),
...(rulesyncFrontmatter.copilotcli?.["disable-model-invocation"] !== undefined && {
"disable-model-invocation": rulesyncFrontmatter.copilotcli["disable-model-invocation"],
...(resolvedUserInvocable !== undefined && { "user-invocable": resolvedUserInvocable }),
...(resolvedDisableModelInvocation !== undefined && {
"disable-model-invocation": resolvedDisableModelInvocation,
}),
};

Expand Down
Loading
Loading