feat(install): install converted rules for the copilot target

The copilot project target installed only skills and agents, dropping the
entire rules/ tree even though Copilot CLI and VS Code read path-specific
instructions from .github/instructions/**/*.instructions.md.

Plan each rules/**/*.md file into .github/instructions/ecc/ as an
.instructions.md file and add a copilot-rule-frontmatter transform that maps
the Claude-style `paths:` list onto Copilot's `applyTo:` glob list. Rules
without `paths:` become applyTo: "**", matching their always-on semantics in
Claude Code. rules/README.md documents the rule set and is not installed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
manuel-albanese_ssgiaandCopilot authored and Kepler9870u0987 committed 2026-10-28 01:45:40 +01:00
1 parent 25278e7d7b
commit 6425693e4b
6 files changed
+231 -13

No files matched your search

+6 -3
View File
@@ -1328,7 +1328,7 @@ Treat `stable`, `beta`, `experimental`, and `instruction-only` below as capabili
| Cursor | Beta project adapter | Selective installer into `.cursor/` | Agent discovery varies by Cursor build; hook events use profile-gated dispatchers, but full Claude feature parity is not claimed. |
| OpenCode | Beta built plugin | Build plugin, then selective installer | ECC ships a subset of the catalog; connect a provider and select a model in OpenCode ([#2617](https://github.com/affaan-m/ECC/issues/2617)). |
| GitHub Copilot Chat | Instruction-only | Checked-in instructions and prompt files | ECC hooks are not adapted. |
| GitHub Copilot CLI | Beta project adapter | `--target copilot` places skills and adapted agents in `.github/` | ECC hooks, rules, and command shims are not installed by this target. |
| GitHub Copilot CLI | Beta project adapter | `--target copilot` places skills, adapted agents, and converted rules in `.github/` | ECC hooks and command shims are not installed by this target. |
| Gemini, Zed, Antigravity, Qwen, Hermes, OpenClaw, Kimi, CodeBuddy, JoyCode | Experimental/minimal adapters | Harness-specific selective target | File placement and instruction portability are tested; full Claude feature parity is not claimed. |
<details>
@@ -1801,8 +1801,10 @@ inventory to see what this checkout installs; a count is not a capability grant.
Claude-only agent frontmatter (`model`, `tools`, `color`) is dropped during the
copy: Copilot resolves the model from your own configuration, and it governs
tool access per session through `--allow-tool`/`--deny-tool` rather than through
agent frontmatter. ECC's rules, commands, and hooks are not installed by this
target.
agent frontmatter. Rules are installed under `.github/instructions/ecc/` as
`.instructions.md` files, with the Claude-style `paths:` frontmatter converted
to Copilot's `applyTo:` glob list (rules without `paths` become `applyTo:
"**"`). ECC's commands and hooks are not installed by this target.
#### Feature coverage
@@ -1818,6 +1820,7 @@ target.
| Commit message format | Per-task instruction in `settings.json` |
| Skills | Native discovery in Copilot CLI (`.github/skills/`, `.agents/skills/`) |
| Agents / delegation | Copilot CLI custom agents (`.github/agents/`, `--agent`) |
| Rules | Path-specific instructions (`.github/instructions/ecc/`, `applyTo:` globs) |
| Hooks / automation | ECC's hooks are not ported (Copilot CLI has its own plugin hook system) |
#### Limitations
+48 -8
View File
@@ -1,17 +1,23 @@
const fs = require('fs');
const path = require('path');
const {
createInstallTargetAdapter,
createManagedOperation,
listRelativeFiles,
normalizeRelativePath,
} = require('./helpers');
// GitHub Copilot CLI discovers skills from `.github/skills/` and custom agents
// from `.github/agents/`. Explicitly declared package-manager helpers live in
// `.github/ecc/scripts/`, outside Copilot's discovery roots. ECC's hooks target
// Claude Code's event model, and commands rely on slash-command argument
// substitution Copilot does not provide, so neither is installed here.
const SUPPORTED_SOURCE_PREFIXES = ['agents', 'skills'];
// GitHub Copilot CLI discovers skills from `.github/skills/`, custom agents
// from `.github/agents/`, and path-specific instructions from
// `.github/instructions/**/*.instructions.md`. Rules are installed under
// `instructions/ecc/` with their Claude-style `paths:` frontmatter converted
// to Copilot's `applyTo:` glob list. Explicitly declared package-manager
// helpers live in `.github/ecc/scripts/`, outside Copilot's discovery roots.
// ECC's hooks target Claude Code's event model, and commands rely on
// slash-command argument substitution Copilot does not provide, so neither is
// installed here.
const SUPPORTED_SOURCE_PREFIXES = ['agents', 'skills', 'rules'];
const COPILOT_HELPER_PATHS = new Set([
'scripts/setup-package-manager.js',
'scripts/lib/package-manager.js',
@@ -35,7 +41,36 @@ function stripPrefix(normalizedPath, prefix) {
return normalizedPath === prefix ? '' : normalizedPath.slice(`${prefix}/`.length);
}
function planSourcePathOperations(module, sourceRelativePath, targetRoot) {
// Rules expand to per-file operations because the destination renames each
// Markdown file to `.instructions.md`, which a directory-level copy cannot
// express. `rules/README.md` documents the rule set and is not installed.
function planCopilotRuleOperations(module, normalizedSourcePath, targetRoot, repoRoot) {
const sourceRoot = path.join(repoRoot || '', normalizedSourcePath);
if (!repoRoot || !fs.existsSync(sourceRoot)) {
return [];
}
const prefix = stripPrefix(normalizedSourcePath, 'rules');
const sourceFiles = fs.statSync(sourceRoot).isDirectory()
? listRelativeFiles(sourceRoot).map(file => ({ source: `${normalizedSourcePath}/${file}`, relative: prefix ? `${prefix}/${file}` : file }))
: [{ source: normalizedSourcePath, relative: path.basename(normalizedSourcePath) }];
return sourceFiles
.filter(({ relative }) => /\.md$/i.test(relative) && !/(^|\/)README\.md$/i.test(relative))
.map(({ source, relative }) => createManagedOperation({
moduleId: module.id,
sourceRelativePath: source,
destinationPath: path.join(
targetRoot,
'instructions',
'ecc',
relative.replace(/\.md$/i, '.instructions.md')
),
contentTransform: 'copilot-rule-frontmatter',
}));
}
function planSourcePathOperations(module, sourceRelativePath, targetRoot, repoRoot) {
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
if (hasPrefix(normalizedSourcePath, 'agents')) {
@@ -65,6 +100,10 @@ function planSourcePathOperations(module, sourceRelativePath, targetRoot) {
];
}
if (hasPrefix(normalizedSourcePath, 'rules')) {
return planCopilotRuleOperations(module, normalizedSourcePath, targetRoot, repoRoot);
}
if (COPILOT_HELPER_PATHS.has(normalizedSourcePath)) {
return [createManagedOperation({
moduleId: module.id,
@@ -105,7 +144,8 @@ module.exports = createInstallTargetAdapter({
.flatMap(sourceRelativePath => planSourcePathOperations(
module,
sourceRelativePath,
targetRoot
targetRoot,
repoRoot
));
});
},
+1
View File
@@ -488,6 +488,7 @@ module.exports = {
createNamespacedFlatRuleOperations,
createRemappedOperation,
isForeignPlatformPath,
listRelativeFiles,
normalizeRelativePath,
planClaudeHooksOperations,
isWSL,
+4
View File
@@ -3,6 +3,7 @@
const { stripPluginNamespace } = require('./plugin-namespace');
const { adaptAntigravityAgent } = require('./antigravity-agent');
const { adaptCopilotAgent } = require('./copilot-agent');
const { adaptCopilotRule } = require('./copilot-rule');
const { disableOpenCodeHookPluginRegistration, getDisabledOpenCodePluginContent } = require('./hook-consent');
function rewriteCopilotWorkflowPaths(content, sourceRelativePath) {
@@ -32,6 +33,9 @@ function transformInstallContent(operation, content) {
if (operation.contentTransform === 'copilot-workflow-paths') {
return rewriteCopilotWorkflowPaths(content, operation.sourceRelativePath);
}
if (operation.contentTransform === 'copilot-rule-frontmatter') {
return adaptCopilotRule(content, operation.sourceRelativePath);
}
if (operation.contentTransform === 'opencode-disable-ecc-hooks') {
return transformInstallContent({ ...operation, contentTransform: 'opencode-home-skills-path' },
disableOpenCodeHookPluginRegistration(content, operation.sourceRelativePath));
+66
View File
@@ -0,0 +1,66 @@
'use strict';
// GitHub Copilot loads path-specific instructions from
// `.github/instructions/**/*.instructions.md`, where the `applyTo` frontmatter
// key carries a comma-separated glob list. ECC rules express the same scoping
// with the Claude Code convention, a YAML `paths:` list, and rules without
// frontmatter apply to every prompt. The transform maps one model onto the
// other: `paths:` becomes `applyTo:`, and a rule without `paths` becomes
// `applyTo: "**"` so it keeps applying globally.
//
// The transform is an allowlist: only `paths` is read, so frontmatter keys
// added to ECC rules later cannot silently leak into the Copilot copy.
const GLOBAL_APPLY_TO = '**';
function splitFrontmatter(source, label) {
const match = String(source || '').match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
if (!match) {
return null;
}
// Keep YAML loading behind the transform boundary. Public help commands load
// the installer graph without executing a transform, including in hermetic
// packed-artifact checks where runtime dependencies are intentionally absent.
const frontmatter = require('js-yaml').load(match[1]);
if (!frontmatter || typeof frontmatter !== 'object' || Array.isArray(frontmatter)) {
throw new Error(`Cannot adapt Copilot rule ${label}: frontmatter must be an object`);
}
return {
frontmatter,
body: String(source).slice(match[0].length),
};
}
function serializeApplyTo(globs) {
return require('js-yaml')
.dump({ applyTo: globs }, { lineWidth: -1, noRefs: true })
.trimEnd();
}
function adaptCopilotRule(source, label = '<unknown>') {
const parsed = splitFrontmatter(source, label);
if (!parsed) {
return `---\napplyTo: "${GLOBAL_APPLY_TO}"\n---\n${String(source || '')}`;
}
const { frontmatter, body } = parsed;
const paths = frontmatter.paths;
if (paths === undefined) {
return `---\napplyTo: "${GLOBAL_APPLY_TO}"\n---\n${body}`;
}
if (!Array.isArray(paths) || paths.some(glob => typeof glob !== 'string' || glob.trim() === '')) {
throw new Error(`Cannot adapt Copilot rule ${label}: "paths" must be a list of glob strings`);
}
return `---\n${serializeApplyTo(paths.map(glob => glob.trim()).join(','))}\n---\n${body}`;
}
module.exports = {
adaptCopilotRule,
GLOBAL_APPLY_TO,
};
+106 -2
View File
@@ -13,6 +13,7 @@ const path = require('path');
const REPO_ROOT = path.resolve(__dirname, '..', '..');
const { adaptCopilotAgent } = require(path.join(REPO_ROOT, 'scripts', 'lib', 'install', 'copilot-agent'));
const { adaptCopilotRule } = require(path.join(REPO_ROOT, 'scripts', 'lib', 'install', 'copilot-rule'));
const copilotAdapter = require(path.join(REPO_ROOT, 'scripts', 'lib', 'install-targets', 'copilot-project'));
const { getInstallTargetAdapter } = require(path.join(REPO_ROOT, 'scripts', 'lib', 'install-targets', 'registry'));
const { SUPPORTED_INSTALL_TARGETS } = require(path.join(REPO_ROOT, 'scripts', 'lib', 'install-manifests'));
@@ -56,6 +57,15 @@ function planFor(modules) {
});
}
function planForRepo(modules) {
return copilotAdapter.planOperations({
repoRoot: REPO_ROOT,
projectRoot: '/project',
homeDir: '/home/user',
modules,
});
}
console.log('=== Testing Copilot install target ===\n');
test('registry resolves the copilot target and adapter id', () => {
@@ -127,9 +137,77 @@ test('skills are planned into .github/skills with installed helper references',
);
});
test('rule transform converts paths frontmatter to applyTo', () => {
const source = [
'---',
'paths:',
' - "**/*.component.ts"',
' - "**/*.service.ts"',
'---',
'',
'# Angular Coding Style',
'',
].join('\n');
const adapted = adaptCopilotRule(source, 'rules/angular/coding-style.md');
assert.match(adapted, /^---\n/);
assert.match(adapted, /applyTo: ["']\*\*\/\*\.component\.ts,\*\*\/\*\.service\.ts["']\n/);
assert.doesNotMatch(adapted, /^paths:/m);
assert.ok(adapted.includes('# Angular Coding Style'));
});
test('rule transform makes a rule without frontmatter global', () => {
const adapted = adaptCopilotRule('# Security\n\nAlways validate input.\n', 'rules/common/security.md');
assert.match(adapted, /^---\napplyTo: ["']\*\*["']\n---\n/);
assert.ok(adapted.endsWith('# Security\n\nAlways validate input.\n'));
});
test('rule transform makes a rule without paths global', () => {
const adapted = adaptCopilotRule('---\ndescription: General guidance\n---\nbody\n', 'rules/common/other.md');
assert.match(adapted, /^---\napplyTo: ["']\*\*["']\n---\nbody\n/);
});
test('rule transform rejects frontmatter that is not an object', () => {
assert.throws(
() => adaptCopilotRule('---\n- a\n- b\n---\nbody\n', 'rules/broken.md'),
/frontmatter must be an object/
);
});
test('rule transform rejects a non-list paths value', () => {
assert.throws(
() => adaptCopilotRule('---\npaths: not-a-list\n---\nbody\n', 'rules/broken.md'),
/"paths" must be a list of glob strings/
);
});
test('rules are planned into .github/instructions/ecc as renamed instructions files', () => {
const operations = planForRepo([{ id: 'rules-core', paths: ['rules'] }]);
assert.ok(operations.length > 100);
const root = path.join('/project', '.github', 'instructions', 'ecc');
for (const operation of operations) {
assert.strictEqual(operation.contentTransform, 'copilot-rule-frontmatter');
assert.ok(operation.destinationPath.endsWith('.instructions.md'), operation.destinationPath);
const relative = path.relative(root, operation.destinationPath);
assert.ok(relative && !relative.startsWith('..') && !path.isAbsolute(relative), operation.destinationPath);
assert.ok(!/(^|[\\/])README\.instructions\.md$/i.test(operation.destinationPath), operation.destinationPath);
}
assert.ok(operations.some(operation => (
operation.destinationPath === path.join(root, 'angular', 'coding-style.instructions.md')
&& operation.sourceRelativePath === 'rules/angular/coding-style.md'
)));
});
test('a rules subdirectory keeps its namespace under instructions/ecc', () => {
const operations = planForRepo([{ id: 'rules-core', paths: ['rules/angular'] }]);
assert.ok(operations.length > 0);
for (const operation of operations) {
const relative = path.relative(path.join('/project', '.github', 'instructions', 'ecc', 'angular'), operation.destinationPath);
assert.ok(relative && !relative.startsWith('..') && !path.isAbsolute(relative), operation.destinationPath);
}
});
test('unsupported source paths produce no operations', () => {
const operations = planFor([
{ id: 'rules-core', paths: ['rules'] },
{ id: 'commands-core', paths: ['commands', 'scripts/harness-audit.js'] },
{ id: 'hooks-runtime', paths: ['hooks', 'scripts/hooks'] },
{ id: 'platform-configs', paths: ['.claude-plugin', '.codex', 'mcp-configs'] },
@@ -168,7 +246,7 @@ test('AGENTS.md and .agents are not installed by this target', () => {
});
test('traversal segments produce no planned operations', () => {
for (const value of ['agents/../../escape.md', 'skills/../escape.md', 'agents\\..\\..\\escape.md']) {
for (const value of ['agents/../../escape.md', 'skills/../escape.md', 'rules/../../escape.md', 'agents\\..\\..\\escape.md']) {
assert.deepStrictEqual(planFor([{ id: 'agents-core', paths: [value] }]), []);
}
});
@@ -324,5 +402,31 @@ test('Copilot installs non-Markdown agent assets byte-for-byte while adapting Ma
}
});
test('Copilot installs rules as instructions and uninstall removes them', () => {
const fs = require('fs');
const os = require('os');
const { createManifestInstallPlan, applyInstallPlan } = require('../../scripts/lib/install-executor');
const { uninstallInstalledStates } = require('../../scripts/lib/install-lifecycle');
const project = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-copilot-rules-'));
try {
const homeDir = path.join(project, 'home');
const plan = createManifestInstallPlan({ target: 'copilot', moduleIds: ['rules-core'], projectRoot: project, homeDir, env: {} });
applyInstallPlan(plan);
const instructionsRoot = path.join(project, '.github', 'instructions', 'ecc');
const angular = fs.readFileSync(path.join(instructionsRoot, 'angular', 'coding-style.instructions.md'), 'utf8');
assert.match(angular, /^---\napplyTo: ["']\*\*\/\*\.component\.ts/);
assert.doesNotMatch(angular, /^paths:/m);
const security = fs.readFileSync(path.join(instructionsRoot, 'common', 'security.instructions.md'), 'utf8');
assert.match(security, /^---\napplyTo: ["']\*\*["']\n---\n/);
assert.ok(!fs.existsSync(path.join(instructionsRoot, 'README.instructions.md')));
const options = { repoRoot: REPO_ROOT, projectRoot: project, homeDir, targets: ['copilot'], env: {} };
const uninstall = uninstallInstalledStates(options);
assert.strictEqual(uninstall.summary.errorCount, 0, JSON.stringify(uninstall));
assert.ok(!fs.existsSync(path.join(instructionsRoot, 'angular', 'coding-style.instructions.md')));
} finally {
fs.rmSync(project, { recursive: true, force: true });
}
});
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed === 0 ? 0 : 1);