From 6425693e4b58bc1e58d8aefcb79f30635ea07435 Mon Sep 17 00:00:00 2001 From: manuel-albanese_ssgia Date: Wed, 28 Oct 2026 00:45:38 +0100 Subject: [PATCH] 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> --- README.md | 9 +- .../lib/install-targets/copilot-project.js | 56 +++++++-- scripts/lib/install-targets/helpers.js | 1 + scripts/lib/install/content-transform.js | 4 + scripts/lib/install/copilot-rule.js | 66 +++++++++++ tests/lib/copilot-install-target.test.js | 108 +++++++++++++++++- 6 files changed, 231 insertions(+), 13 deletions(-) create mode 100644 scripts/lib/install/copilot-rule.js diff --git a/README.md b/README.md index 6583ca8c1..df867de18 100644 --- a/README.md +++ b/README.md @@ -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. |
@@ -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 diff --git a/scripts/lib/install-targets/copilot-project.js b/scripts/lib/install-targets/copilot-project.js index f65327730..2d92691a8 100644 --- a/scripts/lib/install-targets/copilot-project.js +++ b/scripts/lib/install-targets/copilot-project.js @@ -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 )); }); }, diff --git a/scripts/lib/install-targets/helpers.js b/scripts/lib/install-targets/helpers.js index 61d17a767..835c314be 100644 --- a/scripts/lib/install-targets/helpers.js +++ b/scripts/lib/install-targets/helpers.js @@ -488,6 +488,7 @@ module.exports = { createNamespacedFlatRuleOperations, createRemappedOperation, isForeignPlatformPath, + listRelativeFiles, normalizeRelativePath, planClaudeHooksOperations, isWSL, diff --git a/scripts/lib/install/content-transform.js b/scripts/lib/install/content-transform.js index b2f6e1fe1..9a9c28fbe 100644 --- a/scripts/lib/install/content-transform.js +++ b/scripts/lib/install/content-transform.js @@ -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)); diff --git a/scripts/lib/install/copilot-rule.js b/scripts/lib/install/copilot-rule.js new file mode 100644 index 000000000..0e1b14e19 --- /dev/null +++ b/scripts/lib/install/copilot-rule.js @@ -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 = '') { + 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, +}; diff --git a/tests/lib/copilot-install-target.test.js b/tests/lib/copilot-install-target.test.js index 553d8eafd..f37b85fcf 100644 --- a/tests/lib/copilot-install-target.test.js +++ b/tests/lib/copilot-install-target.test.js @@ -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);