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
7 changes: 7 additions & 0 deletions docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,11 @@ copilot: # for GitHub Copilot-specific parameters (optional; project .github/ski
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+)
# Any other frontmatter key found in a hand-written SKILL.md is imported into this section and
# written back out, so a field Rulesync does not model is not lost on regeneration. `name` and
# `description` are the exception: they have canonical homes at the top level. Like the modeled
# fields below, such a key rides one section only, so the shared-path caveat that follows applies
# to it too.
# `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
Expand All @@ -733,6 +738,8 @@ copilotcli: # for GitHub Copilot CLI-specific parameters (optional; project .git
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
# As in the `copilot` section, any other frontmatter key found in a hand-written SKILL.md is
# imported here and written back out.
rovodev: # for Rovo Dev CLI-specific parameters (optional; Agent Skills standard)
allowed-tools: "grep bash" # (optional) space-separated string (a YAML list is also accepted)
license: MIT # (optional)
Expand Down
14 changes: 7 additions & 7 deletions src/features/hooks/copilotcli-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,13 @@ import {
* under `.github/hooks/` is picked up automatically when the CLI is
* invoked from the project root.
*
* - **Global scope**: `~/.copilot/hooks/copilot-hooks.json` — chosen for
* consistency with the existing global Copilot CLI config layout (e.g.
* `~/.copilot/mcp-config.json` produced by `copilotcli-mcp.ts`). The
* official docs do not currently document a global hooks location, so
* this is a rulesync convention pending official documentation; we keep
* all rulesync-managed Copilot CLI files under the single `~/.copilot/`
* root and will revisit if the spec later mandates an alternate layout.
* - **Global scope**: `~/.copilot/hooks/copilot-hooks.json` — the directory is
* the documented user-level hooks location ("`*.json` files in the
* user-level hooks directory. By default this is `~/.copilot/hooks/` on
* macOS and Linux, or `%USERPROFILE%\.copilot\hooks\` on Windows"). Every
* `*.json` in it is loaded, so the filename remains rulesync's choice, as it
* is for project scope. `COPILOT_HOME` relocates the directory upstream
* (`$COPILOT_HOME/hooks/`); rulesync does not read that variable yet.
*
* Hook entries on the six matcher-aware events (see
* {@link COPILOTCLI_MATCHER_EVENTS}) may carry an optional `matcher` regex; it
Expand Down
54 changes: 54 additions & 0 deletions src/features/skills/copilot-skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,60 @@ Skill content goes here.`,
});
});

it("should round-trip a frontmatter field beyond the schema", () => {
const skill = new CopilotSkill({
dirName: "future",
frontmatter: {
name: "future",
description: "Uses a field rulesync does not model",
// Not modeled by rulesync: it used to be dropped on import and then
// erased from the SKILL.md on the next generate.
futureCopilotField: "keep-me",
},
body: "body",
});

const rulesyncSkill = skill.toRulesyncSkill();
expect(rulesyncSkill.getFrontmatter().copilot).toEqual({ futureCopilotField: "keep-me" });

const roundTripped = CopilotSkill.fromRulesyncSkill({ rulesyncSkill });
expect(roundTripped.getFrontmatter()).toEqual({
name: "future",
description: "Uses a field rulesync does not model",
futureCopilotField: "keep-me",
});
});

it("lets the canonical name and description win over the section", () => {
// Assigned through a variable: the section type does not model these two
// keys, which is exactly what makes them worth pinning here.
const shadowingSection = {
license: "MIT",
name: "section-name",
description: "Section description",
};
const rulesyncSkill = new RulesyncSkill({
outputRoot: testDir,
relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH,
dirName: "shadowed",
frontmatter: {
name: "shadowed",
description: "Canonical description",
targets: ["*"],
// A section is written before the canonical fields, so keys that
// have a canonical home must not be shadowed by it.
copilot: shadowingSection,
},
body: "body",
});

expect(CopilotSkill.fromRulesyncSkill({ rulesyncSkill }).getFrontmatter()).toEqual({
name: "shadowed",
description: "Canonical description",
license: "MIT",
});
});

it("should take the invocation gates from the top-level defaults, section wins", () => {
const rulesyncSkill = new RulesyncSkill({
outputRoot: testDir,
Expand Down
46 changes: 18 additions & 28 deletions src/features/skills/copilot-skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,25 +138,14 @@ export class CopilotSkill extends ToolSkill {

toRulesyncSkill(): RulesyncSkill {
const frontmatter = this.getFrontmatter();
const copilotSection = {
...(frontmatter.license !== undefined && { license: frontmatter.license }),
...(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 }),
};
// `name` and `description` have canonical homes; everything else — the
// documented Copilot fields and any key beyond the schema a hand-written
// SKILL.md carries — rides the tool-scoped `copilot` section so it survives
// the round-trip instead of being erased on the next generate.
const { name, description, ...copilotSection } = frontmatter;
const rulesyncFrontmatter: RulesyncSkillFrontmatterInput = {
name: frontmatter.name,
description: frontmatter.description,
name,
description,
targets: ["*"],
...(Object.keys(copilotSection).length > 0 && { copilot: copilotSection }),
};
Expand Down Expand Up @@ -191,23 +180,24 @@ export class CopilotSkill extends ToolSkill {
section: copilotSection,
});

// The two invocation gates are resolved against the shared top-level
// fields, so they are dropped here and written back from the resolvers.
const {
"user-invocable": _userInvocable,
"disable-model-invocation": _disableModelInvocation,
...copilotFields
} = copilotSection ?? {};

const copilotFrontmatter: CopilotSkillFrontmatter = {
// The section is written first so the canonical `name`/`description` and
// the resolved gates still own their keys.
...copilotFields,
name: rulesyncFrontmatter.name,
description: rulesyncFrontmatter.description,
...(copilotSection?.license !== undefined && {
license: copilotSection.license,
}),
...(copilotSection?.["allowed-tools"] !== undefined && {
"allowed-tools": copilotSection["allowed-tools"],
}),
...(copilotSection?.["argument-hint"] !== undefined && {
"argument-hint": copilotSection["argument-hint"],
}),
...(resolvedUserInvocable !== undefined && { "user-invocable": resolvedUserInvocable }),
...(resolvedDisableModelInvocation !== undefined && {
"disable-model-invocation": resolvedDisableModelInvocation,
}),
...(copilotSection?.context !== undefined && { context: copilotSection.context }),
};

return new CopilotSkill({
Expand Down
54 changes: 54 additions & 0 deletions src/features/skills/copilotcli-skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,60 @@ Skill content goes here.`,
expect(roundTripped.getFrontmatter()["disable-model-invocation"]).toBe(true);
});

it("should round-trip a frontmatter field beyond the schema", () => {
const skill = new CopilotcliSkill({
dirName: "future",
frontmatter: {
name: "future",
description: "Uses a field rulesync does not model",
futureCopilotCliField: "keep-me",
},
body: "body",
});

const rulesyncSkill = skill.toRulesyncSkill();
expect(rulesyncSkill.getFrontmatter().copilotcli).toEqual({
futureCopilotCliField: "keep-me",
});

const roundTripped = CopilotcliSkill.fromRulesyncSkill({ rulesyncSkill });
expect(roundTripped.getFrontmatter()).toEqual({
name: "future",
description: "Uses a field rulesync does not model",
futureCopilotCliField: "keep-me",
});
});

it("lets the canonical name and description win over the section", () => {
// Assigned through a variable: the section type does not model these two
// keys, which is exactly what makes them worth pinning here.
const shadowingSection = {
license: "MIT",
name: "section-name",
description: "Section description",
};
const rulesyncSkill = new RulesyncSkill({
outputRoot: testDir,
relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH,
dirName: "shadowed",
frontmatter: {
name: "shadowed",
description: "Canonical description",
targets: ["*"],
// A section is written before the canonical fields, so keys that
// have a canonical home must not be shadowed by it.
copilotcli: shadowingSection,
},
body: "body",
});

expect(CopilotcliSkill.fromRulesyncSkill({ rulesyncSkill }).getFrontmatter()).toEqual({
name: "shadowed",
description: "Canonical description",
license: "MIT",
});
});

it("keeps a false invocation flag rather than treating it as absent", () => {
const skill = new CopilotcliSkill({
dirName: "hidden-skill",
Expand Down
45 changes: 19 additions & 26 deletions src/features/skills/copilotcli-skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,24 +135,15 @@ export class CopilotcliSkill extends ToolSkill {

toRulesyncSkill(): RulesyncSkill {
const frontmatter = this.getFrontmatter();
const copilotcliSection = {
...(frontmatter.license !== undefined && { license: frontmatter.license }),
...(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"],
}),
};
// `name` and `description` have canonical homes; everything else — the
// documented Copilot CLI fields and any key beyond the schema a
// hand-written SKILL.md carries — rides the tool-scoped `copilotcli`
// section so it survives the round-trip instead of being erased on the
// next generate.
const { name, description, ...copilotcliSection } = frontmatter;
const rulesyncFrontmatter: RulesyncSkillFrontmatterInput = {
name: frontmatter.name,
description: frontmatter.description,
name,
description,
targets: ["*"],
...(Object.keys(copilotcliSection).length > 0 && { copilotcli: copilotcliSection }),
};
Expand Down Expand Up @@ -187,18 +178,20 @@ export class CopilotcliSkill extends ToolSkill {
section: copilotcliSection,
});

// The two invocation gates are resolved against the shared top-level
// fields, so they are dropped here and written back from the resolvers.
const {
"user-invocable": _userInvocable,
"disable-model-invocation": _disableModelInvocation,
...copilotcliFields
} = copilotcliSection ?? {};

const copilotcliFrontmatter: CopilotcliSkillFrontmatter = {
// The section is written first so the canonical `name`/`description` and
// the resolved gates still own their keys.
...copilotcliFields,
name: rulesyncFrontmatter.name,
description: rulesyncFrontmatter.description,
...(copilotcliSection?.license !== undefined && {
license: copilotcliSection.license,
}),
...(copilotcliSection?.["allowed-tools"] !== undefined && {
"allowed-tools": copilotcliSection["allowed-tools"],
}),
...(copilotcliSection?.["argument-hint"] !== undefined && {
"argument-hint": copilotcliSection["argument-hint"],
}),
...(resolvedUserInvocable !== undefined && { "user-invocable": resolvedUserInvocable }),
...(resolvedDisableModelInvocation !== undefined && {
"disable-model-invocation": resolvedDisableModelInvocation,
Expand Down
2 changes: 1 addition & 1 deletion src/generated/docs-content.ts

Large diffs are not rendered by default.

Loading