mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-31 21:05:20 +00:00
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:
1 parent
25278e7d7b
commit
6425693e4b
6 files changed
+231
-13
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
));
|
||||
});
|
||||
},
|
||||
|
||||
@@ -488,6 +488,7 @@ module.exports = {
|
||||
createNamespacedFlatRuleOperations,
|
||||
createRemappedOperation,
|
||||
isForeignPlatformPath,
|
||||
listRelativeFiles,
|
||||
normalizeRelativePath,
|
||||
planClaudeHooksOperations,
|
||||
isWSL,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user