mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-18 09:06:53 +03:00
Ship native subagent definitions for GitHub Copilot and Cursor
The github and cursor providers previously received only the generated
degraded/ inline fallbacks. Both harnesses support real custom subagents,
so the build now emits them from the same skill/agents/ source:
- GitHub Copilot: .github/agents/impeccable-<role>.agent.md with portable
frontmatter only (name + description; omitting tools grants all tools,
and Copilot has no documented model/effort/max-turns equivalents).
- Cursor: .cursor/agents/impeccable-<role>.md with name, description,
model: inherit, is_background: false, and readonly derived from the
agent's tool list (true only for the finish reviewer, which declares
neither Write nor Edit). effort/max-turns are skipped because Cursor's
effort option requires an explicit model id.
Agent bodies now also resolve {{scripts_path}} and strip rule markers in
the shared agentFormat pipeline, which fixes the previously unresolved
placeholder in the emitted Claude asset-producer agent.
The CLI installer places agents per scope: project installs write
<repo>/.github/agents/ and <repo>/.cursor/agents/; user-level installs
write ~/.copilot/agents/ (Copilot's user dir, not ~/.github/) and
~/.cursor/agents/, overwriting stale impeccable-* copies. Because
Copilot lets user-level agents shadow same-named project ones, a project
install warns when shadowing copies exist; Cursor gives project agents
precedence, so no warning there.
new-work.md and visualize.md extend their harness-naming clauses with
the Cursor and Copilot invocations. The degraded/ fallbacks keep
shipping for surfaces where the model still fails to delegate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
dedb8a1df2
commit
fa1177ed9c
+166
-1
@@ -162,7 +162,7 @@ This is a test skill body.`;
|
||||
expect(fs.existsSync(path.join(DIST_DIR, 'codex/.codex/skills/test-skill/SKILL.md'))).toBe(true);
|
||||
});
|
||||
|
||||
test('integration: emits native subagent files for Codex and Claude Code', () => {
|
||||
test('integration: emits native subagent files for Codex, Claude Code, GitHub Copilot, and Cursor', () => {
|
||||
const skillContent = `---
|
||||
name: test-skill
|
||||
description: A test skill
|
||||
@@ -195,14 +195,22 @@ Do not redesign the approved crop.`;
|
||||
|
||||
transformers.transformClaudeCode(skills, DIST_DIR, patterns);
|
||||
transformers.transformCodex(skills, DIST_DIR, patterns);
|
||||
transformers.transformGitHub(skills, DIST_DIR, patterns);
|
||||
transformers.transformCursor(skills, DIST_DIR, patterns);
|
||||
|
||||
const claudeAgentPath = path.join(DIST_DIR, 'claude-code/.claude/agents/asset-producer.md');
|
||||
// Codex auto-discovers agents nested inside an installed skill, so the .toml
|
||||
// ships in the skill's own agents/ folder rather than a top-level .codex/agents/.
|
||||
const codexAgentPath = path.join(DIST_DIR, 'codex/.codex/skills/test-skill/agents/asset_producer.toml');
|
||||
// GitHub Copilot discovers repo-level custom agents at .github/agents/<name>.agent.md.
|
||||
const copilotAgentPath = path.join(DIST_DIR, 'github/.github/agents/asset-producer.agent.md');
|
||||
// Cursor discovers repo-level subagents at .cursor/agents/<name>.md.
|
||||
const cursorAgentPath = path.join(DIST_DIR, 'cursor/.cursor/agents/asset-producer.md');
|
||||
|
||||
expect(fs.existsSync(claudeAgentPath)).toBe(true);
|
||||
expect(fs.existsSync(codexAgentPath)).toBe(true);
|
||||
expect(fs.existsSync(copilotAgentPath)).toBe(true);
|
||||
expect(fs.existsSync(cursorAgentPath)).toBe(true);
|
||||
|
||||
const claudeAgent = fs.readFileSync(claudeAgentPath, 'utf-8');
|
||||
expect(claudeAgent).toContain('name: asset-producer');
|
||||
@@ -214,6 +222,31 @@ Do not redesign the approved crop.`;
|
||||
expect(codexAgent).toContain('model_reasoning_effort = "medium"');
|
||||
expect(codexAgent).toContain('nickname_candidates = ["Asset Plate"]');
|
||||
expect(codexAgent).toContain('developer_instructions =');
|
||||
|
||||
// Copilot's portable frontmatter is name + description only: omitting
|
||||
// `tools` grants access to all tools, and there are no documented
|
||||
// model/effort/max-turns equivalents.
|
||||
const copilotAgent = fs.readFileSync(copilotAgentPath, 'utf-8');
|
||||
expect(copilotAgent).toContain('name: asset-producer');
|
||||
expect(copilotAgent).toContain('description: Produces assets from approved crops');
|
||||
expect(copilotAgent).toContain('Do not redesign the approved crop.');
|
||||
expect(copilotAgent).not.toContain('tools:');
|
||||
expect(copilotAgent).not.toContain('model:');
|
||||
expect(copilotAgent).not.toContain('effort:');
|
||||
expect(copilotAgent).not.toContain('maxTurns:');
|
||||
|
||||
// Cursor keeps model (inherit maps directly) and derives readonly from the
|
||||
// tool list; this agent carries Write, so no readonly field is emitted.
|
||||
const cursorAgent = fs.readFileSync(cursorAgentPath, 'utf-8');
|
||||
expect(cursorAgent).toContain('name: asset-producer');
|
||||
expect(cursorAgent).toContain('description: Produces assets from approved crops');
|
||||
expect(cursorAgent).toContain('model: inherit');
|
||||
expect(cursorAgent).toContain('is_background: false');
|
||||
expect(cursorAgent).toContain('Do not redesign the approved crop.');
|
||||
expect(cursorAgent).not.toContain('readonly:');
|
||||
expect(cursorAgent).not.toContain('tools:');
|
||||
expect(cursorAgent).not.toContain('effort:');
|
||||
expect(cursorAgent).not.toContain('maxTurns:');
|
||||
});
|
||||
|
||||
test('integration: verify transformations are correct', () => {
|
||||
@@ -466,3 +499,135 @@ describe('degraded-mode fallback reference generation', () => {
|
||||
expect(fs.existsSync(path.join(ROOT, 'skill', 'reference', 'degraded'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitHub Copilot custom agent generation', () => {
|
||||
const ROOT = process.cwd();
|
||||
const COPILOT_TEST_DIR = path.join(ROOT, 'test-tmp-copilot-agents');
|
||||
const DIST = path.join(COPILOT_TEST_DIR, 'dist');
|
||||
const AGENTS_DIR = path.join(DIST, 'github', '.github', 'agents');
|
||||
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(COPILOT_TEST_DIR)) fs.rmSync(COPILOT_TEST_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(COPILOT_TEST_DIR, { recursive: true });
|
||||
const { skills } = utils.readSourceFiles(ROOT);
|
||||
transformers.transformGitHub(skills, DIST);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(COPILOT_TEST_DIR)) fs.rmSync(COPILOT_TEST_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('emits .github/agents/<name>.agent.md for every shipped agent', () => {
|
||||
const files = fs.readdirSync(AGENTS_DIR).sort();
|
||||
expect(files).toEqual([
|
||||
'impeccable-asset-producer.agent.md',
|
||||
'impeccable-documenter.agent.md',
|
||||
'impeccable-finish-reviewer.agent.md',
|
||||
'impeccable-manual-edit-applier.agent.md',
|
||||
]);
|
||||
});
|
||||
|
||||
test('frontmatter carries only name and description, description verbatim from the source', () => {
|
||||
const source = fs.readFileSync(path.join(ROOT, 'skill', 'agents', 'impeccable-finish-reviewer.md'), 'utf-8');
|
||||
const sourceDescription = source.match(/^description:\s*(.+)$/m)[1].trim();
|
||||
|
||||
const content = fs.readFileSync(path.join(AGENTS_DIR, 'impeccable-finish-reviewer.agent.md'), 'utf-8');
|
||||
const frontmatter = content.split('---')[1];
|
||||
expect(frontmatter).toContain('name: impeccable-finish-reviewer');
|
||||
expect(frontmatter).toContain(`description: ${sourceDescription}`);
|
||||
// Copilot has no documented equivalents for these, and omitting `tools`
|
||||
// grants access to all tools; only portable fields are emitted.
|
||||
expect(frontmatter).not.toContain('tools:');
|
||||
expect(frontmatter).not.toContain('model:');
|
||||
expect(frontmatter).not.toContain('effort:');
|
||||
expect(frontmatter).not.toContain('maxTurns:');
|
||||
expect(frontmatter).not.toContain('nickname');
|
||||
});
|
||||
|
||||
test('bodies are compiled: placeholders resolved, rule markers stripped', () => {
|
||||
for (const name of fs.readdirSync(AGENTS_DIR)) {
|
||||
const content = fs.readFileSync(path.join(AGENTS_DIR, name), 'utf-8');
|
||||
expect(content).not.toContain('{{');
|
||||
expect(content).not.toMatch(/<!--\s*rule:/);
|
||||
}
|
||||
// The asset producer's body references the skill's scripts dir; the
|
||||
// placeholder resolves to the provider-aware path.
|
||||
const assetProducer = fs.readFileSync(path.join(AGENTS_DIR, 'impeccable-asset-producer.agent.md'), 'utf-8');
|
||||
expect(assetProducer).toContain('.github/skills/impeccable/scripts');
|
||||
// A distinctive body phrase proves the agent body itself was inlined.
|
||||
const reviewer = fs.readFileSync(path.join(AGENTS_DIR, 'impeccable-finish-reviewer.agent.md'), 'utf-8');
|
||||
expect(reviewer).toContain('material_fixes');
|
||||
});
|
||||
|
||||
test('degraded fallbacks still ship for the github provider alongside the real agents', () => {
|
||||
const degradedDir = path.join(DIST, 'github', '.github', 'skills', 'impeccable', 'reference', 'degraded');
|
||||
const files = fs.readdirSync(degradedDir).sort();
|
||||
expect(files).toEqual([
|
||||
'asset-producer.md',
|
||||
'documenter.md',
|
||||
'finish-reviewer.md',
|
||||
'manual-edit-applier.md',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cursor subagent generation', () => {
|
||||
const ROOT = process.cwd();
|
||||
const CURSOR_TEST_DIR = path.join(ROOT, 'test-tmp-cursor-agents');
|
||||
const DIST = path.join(CURSOR_TEST_DIR, 'dist');
|
||||
const AGENTS_DIR = path.join(DIST, 'cursor', '.cursor', 'agents');
|
||||
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(CURSOR_TEST_DIR)) fs.rmSync(CURSOR_TEST_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(CURSOR_TEST_DIR, { recursive: true });
|
||||
const { skills } = utils.readSourceFiles(ROOT);
|
||||
transformers.transformCursor(skills, DIST);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(CURSOR_TEST_DIR)) fs.rmSync(CURSOR_TEST_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('emits .cursor/agents/<name>.md for every shipped agent', () => {
|
||||
const files = fs.readdirSync(AGENTS_DIR).sort();
|
||||
expect(files).toEqual([
|
||||
'impeccable-asset-producer.md',
|
||||
'impeccable-documenter.md',
|
||||
'impeccable-finish-reviewer.md',
|
||||
'impeccable-manual-edit-applier.md',
|
||||
]);
|
||||
});
|
||||
|
||||
test('frontmatter maps name, description, model inherit, is_background false; readonly only on the reviewer', () => {
|
||||
for (const name of fs.readdirSync(AGENTS_DIR)) {
|
||||
const content = fs.readFileSync(path.join(AGENTS_DIR, name), 'utf-8');
|
||||
const frontmatter = content.split('---')[1];
|
||||
expect(frontmatter).toContain(`name: ${name.replace(/\.md$/, '')}`);
|
||||
expect(frontmatter).toContain('description: ');
|
||||
expect(frontmatter).toContain('model: inherit');
|
||||
expect(frontmatter).toContain('is_background: false');
|
||||
// Cursor's effort option requires an explicit model id, incompatible
|
||||
// with inherit, and our tool names are not Cursor's vocabulary.
|
||||
expect(frontmatter).not.toContain('tools:');
|
||||
expect(frontmatter).not.toContain('effort:');
|
||||
expect(frontmatter).not.toContain('maxTurns:');
|
||||
// The finish reviewer is the only role whose tool list has no Write or
|
||||
// Edit; it reviews, the other three write.
|
||||
if (name === 'impeccable-finish-reviewer.md') {
|
||||
expect(frontmatter).toContain('readonly: true');
|
||||
} else {
|
||||
expect(frontmatter).not.toContain('readonly:');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('bodies are compiled: placeholders resolved, rule markers stripped', () => {
|
||||
for (const name of fs.readdirSync(AGENTS_DIR)) {
|
||||
const content = fs.readFileSync(path.join(AGENTS_DIR, name), 'utf-8');
|
||||
expect(content).not.toContain('{{');
|
||||
expect(content).not.toMatch(/<!--\s*rule:/);
|
||||
}
|
||||
const assetProducer = fs.readFileSync(path.join(AGENTS_DIR, 'impeccable-asset-producer.md'), 'utf-8');
|
||||
expect(assetProducer).toContain('.cursor/skills/impeccable/scripts');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { mkdtempSync, existsSync, readdirSync, readFileSync, mkdirSync, writeFil
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import {
|
||||
copyProviderAgents,
|
||||
copyProviderHooks,
|
||||
copyProviderSkills,
|
||||
decideHookInstall,
|
||||
@@ -105,6 +106,19 @@ function createFakeUniversalBundle(root, providers = ['.claude', '.agents', '.cu
|
||||
hooks: { PostToolUse: [{ matcher: 'apply_patch', hooks: [{ type: 'command', command: 'node ".codex/skills/impeccable/scripts/hook.mjs"' }] }] },
|
||||
}, null, 2));
|
||||
}
|
||||
// Native subagent definitions, mirroring the build's provider agents output.
|
||||
if (providers.includes('.github')) {
|
||||
mkdirSync(join(bundleRoot, '.github', 'agents'), { recursive: true });
|
||||
writeFileSync(join(bundleRoot, '.github', 'agents', 'impeccable-finish-reviewer.agent.md'),
|
||||
'---\nname: impeccable-finish-reviewer\ndescription: Reviews a finished build.\n---\nCopilot reviewer body.\n');
|
||||
writeFileSync(join(bundleRoot, '.github', 'agents', 'impeccable-asset-producer.agent.md'),
|
||||
'---\nname: impeccable-asset-producer\ndescription: Produces assets.\n---\nCopilot producer body.\n');
|
||||
}
|
||||
if (providers.includes('.cursor')) {
|
||||
mkdirSync(join(bundleRoot, '.cursor', 'agents'), { recursive: true });
|
||||
writeFileSync(join(bundleRoot, '.cursor', 'agents', 'impeccable-finish-reviewer.md'),
|
||||
'---\nname: impeccable-finish-reviewer\ndescription: Reviews a finished build.\nmodel: inherit\nreadonly: true\nis_background: false\n---\nCursor reviewer body.\n');
|
||||
}
|
||||
return bundleRoot;
|
||||
}
|
||||
|
||||
@@ -214,6 +228,84 @@ describe('copyProviderSkills: symlink handling', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyProviderAgents: Copilot and Cursor subagents', () => {
|
||||
test('project scope places agents at .github/agents/ and .cursor/agents/', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-agents-project-'));
|
||||
const bundle = createFakeUniversalBundle(tmp, ['.github', '.cursor']);
|
||||
|
||||
const results = copyProviderAgents(bundle, tmp, ['.github', '.cursor'], { scope: 'project' });
|
||||
|
||||
expect(existsSync(join(tmp, '.github', 'agents', 'impeccable-finish-reviewer.agent.md'))).toBe(true);
|
||||
expect(existsSync(join(tmp, '.github', 'agents', 'impeccable-asset-producer.agent.md'))).toBe(true);
|
||||
expect(existsSync(join(tmp, '.cursor', 'agents', 'impeccable-finish-reviewer.md'))).toBe(true);
|
||||
expect(results.map(r => r.provider).sort()).toEqual(['.cursor', '.github']);
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('user scope places Copilot agents at ~/.copilot/agents (not ~/.github) and Cursor agents at ~/.cursor/agents, overwriting stale copies', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-agents-user-'));
|
||||
const home = mkdtempSync(join(tmpdir(), 'imp-agents-user-home-'));
|
||||
const bundle = createFakeUniversalBundle(tmp, ['.github', '.cursor']);
|
||||
// A stale user-level copy from an older release must be overwritten.
|
||||
mkdirSync(join(home, '.copilot', 'agents'), { recursive: true });
|
||||
writeFileSync(join(home, '.copilot', 'agents', 'impeccable-finish-reviewer.agent.md'), 'stale copy\n');
|
||||
|
||||
copyProviderAgents(bundle, home, ['.github', '.cursor'], { scope: 'user' });
|
||||
|
||||
const copilotAgent = readFileSync(join(home, '.copilot', 'agents', 'impeccable-finish-reviewer.agent.md'), 'utf8');
|
||||
expect(copilotAgent).toContain('Copilot reviewer body.');
|
||||
expect(existsSync(join(home, '.cursor', 'agents', 'impeccable-finish-reviewer.md'))).toBe(true);
|
||||
expect(existsSync(join(home, '.github', 'agents'))).toBe(false);
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('project scope reports user-level Copilot agents that shadow the installed ones; Cursor never does (project wins there)', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-agents-shadow-'));
|
||||
const home = mkdtempSync(join(tmpdir(), 'imp-agents-shadow-home-'));
|
||||
const bundle = createFakeUniversalBundle(tmp, ['.github', '.cursor']);
|
||||
mkdirSync(join(home, '.copilot', 'agents'), { recursive: true });
|
||||
writeFileSync(join(home, '.copilot', 'agents', 'impeccable-finish-reviewer.agent.md'), 'user-level copy\n');
|
||||
mkdirSync(join(home, '.cursor', 'agents'), { recursive: true });
|
||||
writeFileSync(join(home, '.cursor', 'agents', 'impeccable-finish-reviewer.md'), 'user-level copy\n');
|
||||
|
||||
const results = copyProviderAgents(bundle, tmp, ['.github', '.cursor'], { scope: 'project', home });
|
||||
|
||||
const github = results.find(r => r.provider === '.github');
|
||||
const cursor = results.find(r => r.provider === '.cursor');
|
||||
expect(github.shadowed).toEqual(['impeccable-finish-reviewer.agent.md']);
|
||||
expect(cursor.shadowed).toEqual([]);
|
||||
// The project copies still land; the shadow report is a warning, not a block.
|
||||
expect(existsSync(join(tmp, '.github', 'agents', 'impeccable-finish-reviewer.agent.md'))).toBe(true);
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('fresh install lays agents down alongside skills and reports them', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-agents-install-'));
|
||||
const home = mkdtempSync(join(tmpdir(), 'imp-agents-install-home-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
const bundleRoot = createFakeUniversalBundle(tmp, ['.github', '.cursor']);
|
||||
|
||||
const output = run('skills install -y --no-hooks --providers=github,cursor', {
|
||||
cwd: tmp,
|
||||
env: { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
||||
});
|
||||
|
||||
expect(output).toContain('Installed impeccable into: .github, .cursor (project)');
|
||||
expect(output).toContain('Installed GitHub Copilot agents into:');
|
||||
expect(output).toContain('Installed Cursor agents into:');
|
||||
expect(existsSync(join(tmp, '.github', 'agents', 'impeccable-finish-reviewer.agent.md'))).toBe(true);
|
||||
expect(existsSync(join(tmp, '.cursor', 'agents', 'impeccable-finish-reviewer.md'))).toBe(true);
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
describe('skills install: already-installed detection', () => {
|
||||
test('detects impeccable sentinel and bails', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-'));
|
||||
|
||||
Reference in New Issue
Block a user