From 07f6156d8a117ab8080e1fdf5778303474aeec34 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 20 Mar 2026 00:43:32 -0700 Subject: [PATCH] feat: implement --with/--without selective install flags (#679) Add agent: and skill: component families to the install component catalog, enabling fine-grained selective install via CLI flags: ecc install --profile developer --with lang:typescript --without capability:orchestration ecc install --with lang:python --with agent:security-reviewer Changes: - Add agent: family (9 entries) and skill: family (10 entries) to manifests/install-components.json for granular component addressing - Update install-components.schema.json to accept agent: and skill: family prefixes - Register agent and skill family prefixes in COMPONENT_FAMILY_PREFIXES (scripts/lib/install-manifests.js) - Add 41 comprehensive tests covering CLI parsing, request normalization, component catalog validation, plan resolution, target filtering, error handling, and end-to-end install with --with/--without flags Closes #470 --- manifests/install-components.json | 152 ++++++ schemas/install-components.schema.json | 6 +- scripts/lib/install-manifests.js | 2 + tests/lib/selective-install.test.js | 718 +++++++++++++++++++++++++ 4 files changed, 876 insertions(+), 2 deletions(-) create mode 100644 tests/lib/selective-install.test.js diff --git a/manifests/install-components.json b/manifests/install-components.json index 3e58bc1d..ef6ccf01 100644 --- a/manifests/install-components.json +++ b/manifests/install-components.json @@ -250,6 +250,158 @@ "modules": [ "document-processing" ] + }, + { + "id": "agent:architect", + "family": "agent", + "description": "System design and architecture agent.", + "modules": [ + "agents-core" + ] + }, + { + "id": "agent:code-reviewer", + "family": "agent", + "description": "Code review agent for quality and security checks.", + "modules": [ + "agents-core" + ] + }, + { + "id": "agent:security-reviewer", + "family": "agent", + "description": "Security vulnerability analysis agent.", + "modules": [ + "agents-core" + ] + }, + { + "id": "agent:tdd-guide", + "family": "agent", + "description": "Test-driven development guidance agent.", + "modules": [ + "agents-core" + ] + }, + { + "id": "agent:planner", + "family": "agent", + "description": "Feature implementation planning agent.", + "modules": [ + "agents-core" + ] + }, + { + "id": "agent:build-error-resolver", + "family": "agent", + "description": "Build error resolution agent.", + "modules": [ + "agents-core" + ] + }, + { + "id": "agent:e2e-runner", + "family": "agent", + "description": "Playwright E2E testing agent.", + "modules": [ + "agents-core" + ] + }, + { + "id": "agent:refactor-cleaner", + "family": "agent", + "description": "Dead code cleanup and refactoring agent.", + "modules": [ + "agents-core" + ] + }, + { + "id": "agent:doc-updater", + "family": "agent", + "description": "Documentation update agent.", + "modules": [ + "agents-core" + ] + }, + { + "id": "skill:tdd-workflow", + "family": "skill", + "description": "Test-driven development workflow skill.", + "modules": [ + "workflow-quality" + ] + }, + { + "id": "skill:continuous-learning", + "family": "skill", + "description": "Session pattern extraction and continuous learning skill.", + "modules": [ + "workflow-quality" + ] + }, + { + "id": "skill:eval-harness", + "family": "skill", + "description": "Evaluation harness for AI regression testing.", + "modules": [ + "workflow-quality" + ] + }, + { + "id": "skill:verification-loop", + "family": "skill", + "description": "Verification loop for code quality assurance.", + "modules": [ + "workflow-quality" + ] + }, + { + "id": "skill:strategic-compact", + "family": "skill", + "description": "Strategic context compaction for long sessions.", + "modules": [ + "workflow-quality" + ] + }, + { + "id": "skill:coding-standards", + "family": "skill", + "description": "Language-agnostic coding standards and best practices.", + "modules": [ + "framework-language" + ] + }, + { + "id": "skill:frontend-patterns", + "family": "skill", + "description": "React and frontend engineering patterns.", + "modules": [ + "framework-language" + ] + }, + { + "id": "skill:backend-patterns", + "family": "skill", + "description": "API design, database, and backend engineering patterns.", + "modules": [ + "framework-language" + ] + }, + { + "id": "skill:security-review", + "family": "skill", + "description": "Security review checklist and vulnerability analysis.", + "modules": [ + "security" + ] + }, + { + "id": "skill:deep-research", + "family": "skill", + "description": "Deep research and investigation workflows.", + "modules": [ + "research-apis" + ] } ] } diff --git a/schemas/install-components.schema.json b/schemas/install-components.schema.json index c90fefc8..c77878da 100644 --- a/schemas/install-components.schema.json +++ b/schemas/install-components.schema.json @@ -26,7 +26,7 @@ "properties": { "id": { "type": "string", - "pattern": "^(baseline|lang|framework|capability):[a-z0-9-]+$" + "pattern": "^(baseline|lang|framework|capability|agent|skill):[a-z0-9-]+$" }, "family": { "type": "string", @@ -34,7 +34,9 @@ "baseline", "language", "framework", - "capability" + "capability", + "agent", + "skill" ] }, "description": { diff --git a/scripts/lib/install-manifests.js b/scripts/lib/install-manifests.js index 99c4102e..a935ac68 100644 --- a/scripts/lib/install-manifests.js +++ b/scripts/lib/install-manifests.js @@ -10,6 +10,8 @@ const COMPONENT_FAMILY_PREFIXES = { language: 'lang:', framework: 'framework:', capability: 'capability:', + agent: 'agent:', + skill: 'skill:', }; const LEGACY_COMPAT_BASE_MODULE_IDS_BY_TARGET = Object.freeze({ claude: [ diff --git a/tests/lib/selective-install.test.js b/tests/lib/selective-install.test.js new file mode 100644 index 00000000..f15fab33 --- /dev/null +++ b/tests/lib/selective-install.test.js @@ -0,0 +1,718 @@ +/** + * Tests for --with / --without selective install flags (issue #470) + * + * Covers: + * - CLI argument parsing for --with and --without + * - Request normalization with include/exclude component IDs + * - Component-to-module expansion via the manifest catalog + * - End-to-end install plans with --with and --without + * - Validation and error handling for unknown component IDs + * - Combined --profile + --with + --without flows + * - Standalone --with without a profile + * - agent: and skill: component families + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + parseInstallArgs, + normalizeInstallRequest, +} = require('../../scripts/lib/install/request'); + +const { + loadInstallManifests, + listInstallComponents, + resolveInstallPlan, +} = require('../../scripts/lib/install-manifests'); + +function test(name, fn) { + try { + fn(); + console.log(` \u2713 ${name}`); + return true; + } catch (error) { + console.log(` \u2717 ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function runTests() { + console.log('\n=== Testing --with / --without selective install flags ===\n'); + + let passed = 0; + let failed = 0; + + // ─── CLI Argument Parsing ─── + + if (test('parses single --with flag', () => { + const parsed = parseInstallArgs([ + 'node', 'install-apply.js', + '--profile', 'core', + '--with', 'lang:typescript', + ]); + assert.deepStrictEqual(parsed.includeComponentIds, ['lang:typescript']); + assert.deepStrictEqual(parsed.excludeComponentIds, []); + })) passed++; else failed++; + + if (test('parses single --without flag', () => { + const parsed = parseInstallArgs([ + 'node', 'install-apply.js', + '--profile', 'developer', + '--without', 'capability:orchestration', + ]); + assert.deepStrictEqual(parsed.excludeComponentIds, ['capability:orchestration']); + assert.deepStrictEqual(parsed.includeComponentIds, []); + })) passed++; else failed++; + + if (test('parses multiple --with flags', () => { + const parsed = parseInstallArgs([ + 'node', 'install-apply.js', + '--with', 'lang:typescript', + '--with', 'framework:nextjs', + '--with', 'capability:database', + ]); + assert.deepStrictEqual(parsed.includeComponentIds, [ + 'lang:typescript', + 'framework:nextjs', + 'capability:database', + ]); + })) passed++; else failed++; + + if (test('parses multiple --without flags', () => { + const parsed = parseInstallArgs([ + 'node', 'install-apply.js', + '--profile', 'full', + '--without', 'capability:media', + '--without', 'capability:social', + ]); + assert.deepStrictEqual(parsed.excludeComponentIds, [ + 'capability:media', + 'capability:social', + ]); + })) passed++; else failed++; + + if (test('parses combined --with and --without flags', () => { + const parsed = parseInstallArgs([ + 'node', 'install-apply.js', + '--profile', 'developer', + '--with', 'lang:typescript', + '--with', 'framework:nextjs', + '--without', 'capability:orchestration', + ]); + assert.strictEqual(parsed.profileId, 'developer'); + assert.deepStrictEqual(parsed.includeComponentIds, ['lang:typescript', 'framework:nextjs']); + assert.deepStrictEqual(parsed.excludeComponentIds, ['capability:orchestration']); + })) passed++; else failed++; + + if (test('ignores empty --with values', () => { + const parsed = parseInstallArgs([ + 'node', 'install-apply.js', + '--with', '', + '--with', 'lang:python', + ]); + assert.deepStrictEqual(parsed.includeComponentIds, ['lang:python']); + })) passed++; else failed++; + + if (test('ignores empty --without values', () => { + const parsed = parseInstallArgs([ + 'node', 'install-apply.js', + '--profile', 'core', + '--without', '', + '--without', 'capability:media', + ]); + assert.deepStrictEqual(parsed.excludeComponentIds, ['capability:media']); + })) passed++; else failed++; + + // ─── Request Normalization ─── + + if (test('normalizes --with-only request as manifest mode', () => { + const request = normalizeInstallRequest({ + target: 'claude', + profileId: null, + moduleIds: [], + includeComponentIds: ['lang:typescript'], + excludeComponentIds: [], + languages: [], + }); + assert.strictEqual(request.mode, 'manifest'); + assert.deepStrictEqual(request.includeComponentIds, ['lang:typescript']); + assert.deepStrictEqual(request.excludeComponentIds, []); + })) passed++; else failed++; + + if (test('normalizes --profile + --with + --without as manifest mode', () => { + const request = normalizeInstallRequest({ + target: 'cursor', + profileId: 'developer', + moduleIds: [], + includeComponentIds: ['lang:typescript', 'framework:nextjs'], + excludeComponentIds: ['capability:orchestration'], + languages: [], + }); + assert.strictEqual(request.mode, 'manifest'); + assert.strictEqual(request.profileId, 'developer'); + assert.deepStrictEqual(request.includeComponentIds, ['lang:typescript', 'framework:nextjs']); + assert.deepStrictEqual(request.excludeComponentIds, ['capability:orchestration']); + })) passed++; else failed++; + + if (test('rejects --with combined with legacy language arguments', () => { + assert.throws( + () => normalizeInstallRequest({ + target: 'claude', + profileId: null, + moduleIds: [], + includeComponentIds: ['lang:typescript'], + excludeComponentIds: [], + languages: ['python'], + }), + /cannot be combined/ + ); + })) passed++; else failed++; + + if (test('rejects --without combined with legacy language arguments', () => { + assert.throws( + () => normalizeInstallRequest({ + target: 'claude', + profileId: null, + moduleIds: [], + includeComponentIds: [], + excludeComponentIds: ['capability:media'], + languages: ['typescript'], + }), + /cannot be combined/ + ); + })) passed++; else failed++; + + if (test('deduplicates repeated --with component IDs', () => { + const request = normalizeInstallRequest({ + target: 'claude', + profileId: null, + moduleIds: [], + includeComponentIds: ['lang:typescript', 'lang:typescript', 'lang:python'], + excludeComponentIds: [], + languages: [], + }); + assert.deepStrictEqual(request.includeComponentIds, ['lang:typescript', 'lang:python']); + })) passed++; else failed++; + + if (test('deduplicates repeated --without component IDs', () => { + const request = normalizeInstallRequest({ + target: 'claude', + profileId: 'full', + moduleIds: [], + includeComponentIds: [], + excludeComponentIds: ['capability:media', 'capability:media', 'capability:social'], + languages: [], + }); + assert.deepStrictEqual(request.excludeComponentIds, ['capability:media', 'capability:social']); + })) passed++; else failed++; + + // ─── Component Catalog Validation ─── + + if (test('component catalog includes lang: family entries', () => { + const components = listInstallComponents({ family: 'language' }); + assert.ok(components.some(c => c.id === 'lang:typescript'), 'Should have lang:typescript'); + assert.ok(components.some(c => c.id === 'lang:python'), 'Should have lang:python'); + assert.ok(components.some(c => c.id === 'lang:go'), 'Should have lang:go'); + assert.ok(components.some(c => c.id === 'lang:java'), 'Should have lang:java'); + })) passed++; else failed++; + + if (test('component catalog includes framework: family entries', () => { + const components = listInstallComponents({ family: 'framework' }); + assert.ok(components.some(c => c.id === 'framework:react'), 'Should have framework:react'); + assert.ok(components.some(c => c.id === 'framework:nextjs'), 'Should have framework:nextjs'); + assert.ok(components.some(c => c.id === 'framework:django'), 'Should have framework:django'); + assert.ok(components.some(c => c.id === 'framework:springboot'), 'Should have framework:springboot'); + })) passed++; else failed++; + + if (test('component catalog includes capability: family entries', () => { + const components = listInstallComponents({ family: 'capability' }); + assert.ok(components.some(c => c.id === 'capability:database'), 'Should have capability:database'); + assert.ok(components.some(c => c.id === 'capability:security'), 'Should have capability:security'); + assert.ok(components.some(c => c.id === 'capability:orchestration'), 'Should have capability:orchestration'); + })) passed++; else failed++; + + if (test('component catalog includes agent: family entries', () => { + const components = listInstallComponents({ family: 'agent' }); + assert.ok(components.length > 0, 'Should have at least one agent component'); + assert.ok(components.some(c => c.id === 'agent:security-reviewer'), 'Should have agent:security-reviewer'); + })) passed++; else failed++; + + if (test('component catalog includes skill: family entries', () => { + const components = listInstallComponents({ family: 'skill' }); + assert.ok(components.length > 0, 'Should have at least one skill component'); + assert.ok(components.some(c => c.id === 'skill:continuous-learning'), 'Should have skill:continuous-learning'); + })) passed++; else failed++; + + // ─── Install Plan Resolution with --with ─── + + if (test('--with alone resolves component modules and their dependencies', () => { + const plan = resolveInstallPlan({ + includeComponentIds: ['lang:typescript'], + target: 'claude', + }); + assert.ok(plan.selectedModuleIds.includes('framework-language'), + 'Should include the module behind lang:typescript'); + assert.ok(plan.selectedModuleIds.includes('rules-core'), + 'Should include framework-language dependency rules-core'); + assert.ok(plan.selectedModuleIds.includes('platform-configs'), + 'Should include framework-language dependency platform-configs'); + })) passed++; else failed++; + + if (test('--with adds modules on top of a profile', () => { + const plan = resolveInstallPlan({ + profileId: 'core', + includeComponentIds: ['capability:security'], + target: 'claude', + }); + // core profile modules + assert.ok(plan.selectedModuleIds.includes('rules-core')); + assert.ok(plan.selectedModuleIds.includes('workflow-quality')); + // added by --with + assert.ok(plan.selectedModuleIds.includes('security'), + 'Should include security module from --with'); + })) passed++; else failed++; + + if (test('multiple --with flags union their modules', () => { + const plan = resolveInstallPlan({ + includeComponentIds: ['lang:typescript', 'capability:database'], + target: 'claude', + }); + assert.ok(plan.selectedModuleIds.includes('framework-language'), + 'Should include framework-language from lang:typescript'); + assert.ok(plan.selectedModuleIds.includes('database'), + 'Should include database from capability:database'); + })) passed++; else failed++; + + // ─── Install Plan Resolution with --without ─── + + if (test('--without excludes modules from a profile', () => { + const plan = resolveInstallPlan({ + profileId: 'developer', + excludeComponentIds: ['capability:orchestration'], + target: 'claude', + }); + assert.ok(!plan.selectedModuleIds.includes('orchestration'), + 'Should exclude orchestration module'); + assert.ok(plan.excludedModuleIds.includes('orchestration'), + 'Should report orchestration as excluded'); + // rest of developer profile should remain + assert.ok(plan.selectedModuleIds.includes('rules-core')); + assert.ok(plan.selectedModuleIds.includes('framework-language')); + assert.ok(plan.selectedModuleIds.includes('database')); + })) passed++; else failed++; + + if (test('multiple --without flags exclude multiple modules', () => { + const plan = resolveInstallPlan({ + profileId: 'full', + excludeComponentIds: ['capability:media', 'capability:social', 'capability:supply-chain'], + target: 'claude', + }); + assert.ok(!plan.selectedModuleIds.includes('media-generation')); + assert.ok(!plan.selectedModuleIds.includes('social-distribution')); + assert.ok(!plan.selectedModuleIds.includes('supply-chain-domain')); + assert.ok(plan.excludedModuleIds.includes('media-generation')); + assert.ok(plan.excludedModuleIds.includes('social-distribution')); + assert.ok(plan.excludedModuleIds.includes('supply-chain-domain')); + })) passed++; else failed++; + + // ─── Combined --with + --without ─── + + if (test('--with and --without work together on a profile', () => { + const plan = resolveInstallPlan({ + profileId: 'developer', + includeComponentIds: ['capability:security'], + excludeComponentIds: ['capability:orchestration'], + target: 'claude', + }); + assert.ok(plan.selectedModuleIds.includes('security'), + 'Should include security from --with'); + assert.ok(!plan.selectedModuleIds.includes('orchestration'), + 'Should exclude orchestration from --without'); + assert.ok(plan.selectedModuleIds.includes('rules-core'), + 'Should keep profile base modules'); + })) passed++; else failed++; + + if (test('--without on a dependency of --with raises an error', () => { + assert.throws( + () => resolveInstallPlan({ + includeComponentIds: ['capability:social'], + excludeComponentIds: ['capability:content'], + }), + /depends on excluded module/ + ); + })) passed++; else failed++; + + // ─── Validation Errors ─── + + if (test('throws for unknown component ID in --with', () => { + assert.throws( + () => resolveInstallPlan({ + includeComponentIds: ['lang:brainfuck-plus-plus'], + }), + /Unknown install component/ + ); + })) passed++; else failed++; + + if (test('throws for unknown component ID in --without', () => { + assert.throws( + () => resolveInstallPlan({ + profileId: 'core', + excludeComponentIds: ['capability:teleportation'], + }), + /Unknown install component/ + ); + })) passed++; else failed++; + + if (test('throws when all modules are excluded', () => { + assert.throws( + () => resolveInstallPlan({ + profileId: 'core', + excludeComponentIds: [ + 'baseline:rules', + 'baseline:agents', + 'baseline:commands', + 'baseline:hooks', + 'baseline:platform', + 'baseline:workflow', + ], + target: 'claude', + }), + /excludes every requested install module/ + ); + })) passed++; else failed++; + + // ─── Target-Specific Behavior ─── + + if (test('--with respects target compatibility filtering', () => { + const plan = resolveInstallPlan({ + includeComponentIds: ['capability:orchestration'], + target: 'cursor', + }); + // orchestration module only supports claude, codex, opencode + assert.ok(!plan.selectedModuleIds.includes('orchestration'), + 'Should skip orchestration for cursor target'); + assert.ok(plan.skippedModuleIds.includes('orchestration'), + 'Should report orchestration as skipped for cursor'); + })) passed++; else failed++; + + if (test('--without with agent: component excludes the agent module', () => { + const plan = resolveInstallPlan({ + profileId: 'core', + excludeComponentIds: ['agent:security-reviewer'], + target: 'claude', + }); + // agent:security-reviewer maps to agents-core module + // Since core profile includes agents-core and it is excluded, it should be gone + assert.ok(!plan.selectedModuleIds.includes('agents-core'), + 'Should exclude agents-core when agent:security-reviewer is excluded'); + assert.ok(plan.excludedModuleIds.includes('agents-core'), + 'Should report agents-core as excluded'); + })) passed++; else failed++; + + if (test('--with agent: component includes the agents-core module', () => { + const plan = resolveInstallPlan({ + includeComponentIds: ['agent:security-reviewer'], + target: 'claude', + }); + assert.ok(plan.selectedModuleIds.includes('agents-core'), + 'Should include agents-core module from agent:security-reviewer'); + })) passed++; else failed++; + + if (test('--with skill: component includes the parent skill module', () => { + const plan = resolveInstallPlan({ + includeComponentIds: ['skill:continuous-learning'], + target: 'claude', + }); + assert.ok(plan.selectedModuleIds.includes('workflow-quality'), + 'Should include workflow-quality module from skill:continuous-learning'); + })) passed++; else failed++; + + // ─── Help Text ─── + + if (test('help text documents --with and --without flags', () => { + const { execFileSync } = require('child_process'); + const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js'); + const result = execFileSync('node', [scriptPath, '--help'], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + assert.ok(result.includes('--with'), 'Help should mention --with'); + assert.ok(result.includes('--without'), 'Help should mention --without'); + assert.ok(result.includes('component'), 'Help should describe components'); + })) passed++; else failed++; + + // ─── End-to-End Dry-Run ─── + + if (test('end-to-end: --profile developer --with capability:security --without capability:orchestration --dry-run', () => { + const { execFileSync } = require('child_process'); + const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js'); + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'selective-e2e-')); + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'selective-e2e-project-')); + + try { + const result = execFileSync('node', [ + scriptPath, + '--profile', 'developer', + '--with', 'capability:security', + '--without', 'capability:orchestration', + '--dry-run', + ], { + cwd: projectDir, + env: { ...process.env, HOME: homeDir }, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + + assert.ok(result.includes('Mode: manifest'), 'Should be manifest mode'); + assert.ok(result.includes('Profile: developer'), 'Should show developer profile'); + assert.ok(result.includes('capability:security'), 'Should show included component'); + assert.ok(result.includes('capability:orchestration'), 'Should show excluded component'); + assert.ok(result.includes('security'), 'Selected modules should include security'); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + fs.rmSync(projectDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('end-to-end: --with lang:python --with agent:security-reviewer --dry-run', () => { + const { execFileSync } = require('child_process'); + const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js'); + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'selective-e2e-')); + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'selective-e2e-project-')); + + try { + const result = execFileSync('node', [ + scriptPath, + '--with', 'lang:python', + '--with', 'agent:security-reviewer', + '--dry-run', + ], { + cwd: projectDir, + env: { ...process.env, HOME: homeDir }, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + + assert.ok(result.includes('Mode: manifest'), 'Should be manifest mode'); + assert.ok(result.includes('lang:python'), 'Should show lang:python as included'); + assert.ok(result.includes('agent:security-reviewer'), 'Should show agent:security-reviewer as included'); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + fs.rmSync(projectDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('end-to-end: --with with unknown component fails cleanly', () => { + const { execFileSync } = require('child_process'); + const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js'); + + let exitCode = 0; + let stderr = ''; + try { + execFileSync('node', [ + scriptPath, + '--with', 'lang:nonexistent-language', + '--dry-run', + ], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch (error) { + exitCode = error.status || 1; + stderr = error.stderr || ''; + } + + assert.strictEqual(exitCode, 1, 'Should exit with error code 1'); + assert.ok(stderr.includes('Unknown install component'), 'Should report unknown component'); + })) passed++; else failed++; + + if (test('end-to-end: --without with unknown component fails cleanly', () => { + const { execFileSync } = require('child_process'); + const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js'); + + let exitCode = 0; + let stderr = ''; + try { + execFileSync('node', [ + scriptPath, + '--profile', 'core', + '--without', 'capability:nonexistent', + '--dry-run', + ], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch (error) { + exitCode = error.status || 1; + stderr = error.stderr || ''; + } + + assert.strictEqual(exitCode, 1, 'Should exit with error code 1'); + assert.ok(stderr.includes('Unknown install component'), 'Should report unknown component'); + })) passed++; else failed++; + + // ─── End-to-End Actual Install ─── + + if (test('end-to-end: installs --profile core --with capability:security and writes state', () => { + const { execFileSync } = require('child_process'); + const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js'); + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'selective-install-')); + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'selective-install-project-')); + + try { + const result = execFileSync('node', [ + scriptPath, + '--profile', 'core', + '--with', 'capability:security', + ], { + cwd: projectDir, + env: { ...process.env, HOME: homeDir }, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + + const claudeRoot = path.join(homeDir, '.claude'); + // Security skill should be installed (from --with) + assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'security-review', 'SKILL.md')), + 'Should install security-review skill from --with'); + // Core profile modules should be installed + assert.ok(fs.existsSync(path.join(claudeRoot, 'rules', 'common', 'coding-style.md')), + 'Should install core rules'); + + // Install state should record include/exclude + const statePath = path.join(claudeRoot, 'ecc', 'install-state.json'); + const state = JSON.parse(fs.readFileSync(statePath, 'utf8')); + assert.strictEqual(state.request.profile, 'core'); + assert.deepStrictEqual(state.request.includeComponents, ['capability:security']); + assert.deepStrictEqual(state.request.excludeComponents, []); + assert.ok(state.resolution.selectedModules.includes('security')); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + fs.rmSync(projectDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('end-to-end: installs --profile developer --without capability:orchestration and state reflects exclusion', () => { + const { execFileSync } = require('child_process'); + const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js'); + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'selective-install-')); + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'selective-install-project-')); + + try { + execFileSync('node', [ + scriptPath, + '--profile', 'developer', + '--without', 'capability:orchestration', + ], { + cwd: projectDir, + env: { ...process.env, HOME: homeDir }, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + + const claudeRoot = path.join(homeDir, '.claude'); + // Orchestration skills should NOT be installed (from --without) + assert.ok(!fs.existsSync(path.join(claudeRoot, 'skills', 'dmux-workflows', 'SKILL.md')), + 'Should not install orchestration skills'); + // Developer profile base modules should be installed + assert.ok(fs.existsSync(path.join(claudeRoot, 'rules', 'common', 'coding-style.md')), + 'Should install core rules'); + assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'tdd-workflow', 'SKILL.md')), + 'Should install workflow skills'); + + const statePath = path.join(claudeRoot, 'ecc', 'install-state.json'); + const state = JSON.parse(fs.readFileSync(statePath, 'utf8')); + assert.strictEqual(state.request.profile, 'developer'); + assert.deepStrictEqual(state.request.excludeComponents, ['capability:orchestration']); + assert.ok(!state.resolution.selectedModules.includes('orchestration')); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + fs.rmSync(projectDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('end-to-end: --with alone (no profile) installs just the component modules', () => { + const { execFileSync } = require('child_process'); + const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js'); + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'selective-install-')); + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'selective-install-project-')); + + try { + execFileSync('node', [ + scriptPath, + '--with', 'lang:typescript', + ], { + cwd: projectDir, + env: { ...process.env, HOME: homeDir }, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + + const claudeRoot = path.join(homeDir, '.claude'); + // framework-language skill (from lang:typescript) should be installed + assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'coding-standards', 'SKILL.md')), + 'Should install framework-language skills'); + // Its dependencies should be installed + assert.ok(fs.existsSync(path.join(claudeRoot, 'rules', 'common', 'coding-style.md')), + 'Should install dependency rules-core'); + + const statePath = path.join(claudeRoot, 'ecc', 'install-state.json'); + const state = JSON.parse(fs.readFileSync(statePath, 'utf8')); + assert.strictEqual(state.request.profile, null); + assert.deepStrictEqual(state.request.includeComponents, ['lang:typescript']); + assert.ok(state.resolution.selectedModules.includes('framework-language')); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + fs.rmSync(projectDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + // ─── JSON output mode ─── + + if (test('end-to-end: --dry-run --json includes component selections in output', () => { + const { execFileSync } = require('child_process'); + const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js'); + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'selective-e2e-')); + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'selective-e2e-project-')); + + try { + const output = execFileSync('node', [ + scriptPath, + '--profile', 'core', + '--with', 'capability:database', + '--without', 'baseline:hooks', + '--dry-run', + '--json', + ], { + cwd: projectDir, + env: { ...process.env, HOME: homeDir }, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + + const json = JSON.parse(output); + assert.strictEqual(json.dryRun, true); + assert.ok(json.plan, 'Should include plan object'); + assert.ok( + json.plan.includedComponentIds.includes('capability:database'), + 'JSON output should include capability:database in included components' + ); + assert.ok( + json.plan.excludedComponentIds.includes('baseline:hooks'), + 'JSON output should include baseline:hooks in excluded components' + ); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + fs.rmSync(projectDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests();