From b4296c70959b4db95b9c006b4eace6d6a91d815b Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Tue, 24 Mar 2026 03:53:45 -0700 Subject: [PATCH] feat: add install catalog and project config autodetection --- scripts/catalog.js | 186 ++++++++++++++++++++++++++++ scripts/ecc.js | 8 ++ scripts/install-apply.js | 10 +- scripts/install-plan.js | 18 ++- scripts/lib/install-manifests.js | 40 ++++++ scripts/lib/install/config.js | 7 ++ tests/lib/install-config.test.js | 27 ++++ tests/scripts/catalog.test.js | 104 ++++++++++++++++ tests/scripts/ecc.test.js | 13 ++ tests/scripts/install-apply.test.js | 61 +++++++++ tests/scripts/install-plan.test.js | 28 ++++- 11 files changed, 496 insertions(+), 6 deletions(-) create mode 100644 scripts/catalog.js create mode 100644 tests/scripts/catalog.test.js diff --git a/scripts/catalog.js b/scripts/catalog.js new file mode 100644 index 00000000..2ee4d697 --- /dev/null +++ b/scripts/catalog.js @@ -0,0 +1,186 @@ +#!/usr/bin/env node + +const { + getInstallComponent, + listInstallComponents, + listInstallProfiles, +} = require('./lib/install-manifests'); + +const FAMILY_ALIASES = Object.freeze({ + baseline: 'baseline', + baselines: 'baseline', + language: 'language', + languages: 'language', + lang: 'language', + framework: 'framework', + frameworks: 'framework', + capability: 'capability', + capabilities: 'capability', + agent: 'agent', + agents: 'agent', + skill: 'skill', + skills: 'skill', +}); + +function showHelp(exitCode = 0) { + console.log(` +Discover ECC install components and profiles + +Usage: + node scripts/catalog.js profiles [--json] + node scripts/catalog.js components [--family ] [--target ] [--json] + node scripts/catalog.js show [--json] + +Examples: + node scripts/catalog.js profiles + node scripts/catalog.js components --family language + node scripts/catalog.js show framework:nextjs +`); + + process.exit(exitCode); +} + +function normalizeFamily(value) { + if (!value) { + return null; + } + + const normalized = String(value).trim().toLowerCase(); + return FAMILY_ALIASES[normalized] || normalized; +} + +function parseArgs(argv) { + const args = argv.slice(2); + const parsed = { + command: null, + componentId: null, + family: null, + target: null, + json: false, + help: false, + }; + + if (args.length === 0 || args[0] === '--help' || args[0] === '-h') { + parsed.help = true; + return parsed; + } + + parsed.command = args[0]; + + for (let index = 1; index < args.length; index += 1) { + const arg = args[index]; + + if (arg === '--help' || arg === '-h') { + parsed.help = true; + } else if (arg === '--json') { + parsed.json = true; + } else if (arg === '--family') { + if (!args[index + 1]) { + throw new Error('Missing value for --family'); + } + parsed.family = normalizeFamily(args[index + 1]); + index += 1; + } else if (arg === '--target') { + if (!args[index + 1]) { + throw new Error('Missing value for --target'); + } + parsed.target = args[index + 1]; + index += 1; + } else if (parsed.command === 'show' && !parsed.componentId) { + parsed.componentId = arg; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + return parsed; +} + +function printProfiles(profiles) { + console.log('Install profiles:\n'); + for (const profile of profiles) { + console.log(`- ${profile.id} (${profile.moduleCount} modules)`); + console.log(` ${profile.description}`); + } +} + +function printComponents(components) { + console.log('Install components:\n'); + for (const component of components) { + console.log(`- ${component.id} [${component.family}]`); + console.log(` targets=${component.targets.join(', ')} modules=${component.moduleIds.join(', ')}`); + console.log(` ${component.description}`); + } +} + +function printComponent(component) { + console.log(`Install component: ${component.id}\n`); + console.log(`Family: ${component.family}`); + console.log(`Targets: ${component.targets.join(', ')}`); + console.log(`Modules: ${component.moduleIds.join(', ')}`); + console.log(`Description: ${component.description}`); + + if (component.modules.length > 0) { + console.log('\nResolved modules:'); + for (const module of component.modules) { + console.log(`- ${module.id} [${module.kind}]`); + console.log( + ` targets=${module.targets.join(', ')} default=${module.defaultInstall} cost=${module.cost} stability=${module.stability}` + ); + console.log(` ${module.description}`); + } + } +} + +function main() { + try { + const options = parseArgs(process.argv); + + if (options.help) { + showHelp(0); + } + + if (options.command === 'profiles') { + const profiles = listInstallProfiles(); + if (options.json) { + console.log(JSON.stringify({ profiles }, null, 2)); + } else { + printProfiles(profiles); + } + return; + } + + if (options.command === 'components') { + const components = listInstallComponents({ + family: options.family, + target: options.target, + }); + if (options.json) { + console.log(JSON.stringify({ components }, null, 2)); + } else { + printComponents(components); + } + return; + } + + if (options.command === 'show') { + if (!options.componentId) { + throw new Error('Catalog show requires an install component ID'); + } + const component = getInstallComponent(options.componentId); + if (options.json) { + console.log(JSON.stringify(component, null, 2)); + } else { + printComponent(component); + } + return; + } + + throw new Error(`Unknown catalog command: ${options.command}`); + } catch (error) { + console.error(`Error: ${error.message}`); + process.exit(1); + } +} + +main(); diff --git a/scripts/ecc.js b/scripts/ecc.js index 472ba616..50f46908 100644 --- a/scripts/ecc.js +++ b/scripts/ecc.js @@ -13,6 +13,10 @@ const COMMANDS = { script: 'install-plan.js', description: 'Inspect selective-install manifests and resolved plans', }, + catalog: { + script: 'catalog.js', + description: 'Discover install profiles and component IDs', + }, 'install-plan': { script: 'install-plan.js', description: 'Alias for plan', @@ -50,6 +54,7 @@ const COMMANDS = { const PRIMARY_COMMANDS = [ 'install', 'plan', + 'catalog', 'list-installed', 'doctor', 'repair', @@ -79,6 +84,9 @@ Examples: ecc typescript ecc install --profile developer --target claude ecc plan --profile core --target cursor + ecc catalog profiles + ecc catalog components --family language + ecc catalog show framework:nextjs ecc list-installed --json ecc doctor --target cursor ecc repair --dry-run diff --git a/scripts/install-apply.js b/scripts/install-apply.js index c9104511..593cf58d 100644 --- a/scripts/install-apply.js +++ b/scripts/install-apply.js @@ -100,12 +100,18 @@ function main() { showHelp(0); } - const { loadInstallConfig } = require('./lib/install/config'); + const { + findDefaultInstallConfigPath, + loadInstallConfig, + } = require('./lib/install/config'); const { applyInstallPlan } = require('./lib/install-executor'); const { createInstallPlanFromRequest } = require('./lib/install/runtime'); + const defaultConfigPath = options.configPath || options.languages.length > 0 + ? null + : findDefaultInstallConfigPath({ cwd: process.cwd() }); const config = options.configPath ? loadInstallConfig(options.configPath, { cwd: process.cwd() }) - : null; + : (defaultConfigPath ? loadInstallConfig(defaultConfigPath, { cwd: process.cwd() }) : null); const request = normalizeInstallRequest({ ...options, config, diff --git a/scripts/install-plan.js b/scripts/install-plan.js index dfe02ee5..24ffd91b 100644 --- a/scripts/install-plan.js +++ b/scripts/install-plan.js @@ -9,7 +9,10 @@ const { listInstallProfiles, resolveInstallPlan, } = require('./lib/install-manifests'); -const { loadInstallConfig } = require('./lib/install/config'); +const { + findDefaultInstallConfigPath, + loadInstallConfig, +} = require('./lib/install/config'); const { normalizeInstallRequest } = require('./lib/install/request'); function showHelp() { @@ -186,7 +189,7 @@ function main() { try { const options = parseArgs(process.argv); - if (options.help || process.argv.length <= 2) { + if (options.help) { showHelp(); process.exit(0); } @@ -224,9 +227,18 @@ function main() { return; } + const defaultConfigPath = options.configPath + ? null + : findDefaultInstallConfigPath({ cwd: process.cwd() }); const config = options.configPath ? loadInstallConfig(options.configPath, { cwd: process.cwd() }) - : null; + : (defaultConfigPath ? loadInstallConfig(defaultConfigPath, { cwd: process.cwd() }) : null); + + if (process.argv.length <= 2 && !config) { + showHelp(); + process.exit(0); + } + const request = normalizeInstallRequest({ ...options, languages: [], diff --git a/scripts/lib/install-manifests.js b/scripts/lib/install-manifests.js index 16b1fbc1..ee159bcd 100644 --- a/scripts/lib/install-manifests.js +++ b/scripts/lib/install-manifests.js @@ -216,6 +216,45 @@ function listInstallComponents(options = {}) { .filter(component => !target || component.targets.includes(target)); } +function getInstallComponent(componentId, options = {}) { + const manifests = loadInstallManifests(options); + const normalizedComponentId = String(componentId || '').trim(); + + if (!normalizedComponentId) { + throw new Error('An install component ID is required'); + } + + const component = manifests.componentsById.get(normalizedComponentId); + if (!component) { + throw new Error(`Unknown install component: ${normalizedComponentId}`); + } + + const moduleIds = dedupeStrings(component.modules); + const modules = moduleIds + .map(moduleId => manifests.modulesById.get(moduleId)) + .filter(Boolean) + .map(module => ({ + id: module.id, + kind: module.kind, + description: module.description, + targets: module.targets, + defaultInstall: module.defaultInstall, + cost: module.cost, + stability: module.stability, + dependencies: dedupeStrings(module.dependencies), + })); + + return { + id: component.id, + family: component.family, + description: component.description, + moduleIds, + moduleCount: moduleIds.length, + targets: intersectTargets(modules), + modules, + }; +} + function expandComponentIdsToModuleIds(componentIds, manifests) { const expandedModuleIds = []; @@ -438,6 +477,7 @@ module.exports = { SUPPORTED_INSTALL_TARGETS, getManifestPaths, loadInstallManifests, + getInstallComponent, listInstallComponents, listLegacyCompatibilityLanguages, listInstallModules, diff --git a/scripts/lib/install/config.js b/scripts/lib/install/config.js index 2ff8f8a1..2ba01226 100644 --- a/scripts/lib/install/config.js +++ b/scripts/lib/install/config.js @@ -47,6 +47,12 @@ function resolveInstallConfigPath(configPath, options = {}) { : path.normalize(path.join(cwd, configPath)); } +function findDefaultInstallConfigPath(options = {}) { + const cwd = options.cwd || process.cwd(); + const candidatePath = path.join(cwd, DEFAULT_INSTALL_CONFIG); + return fs.existsSync(candidatePath) ? candidatePath : null; +} + function loadInstallConfig(configPath, options = {}) { const resolvedPath = resolveInstallConfigPath(configPath, options); @@ -77,6 +83,7 @@ function loadInstallConfig(configPath, options = {}) { module.exports = { DEFAULT_INSTALL_CONFIG, + findDefaultInstallConfigPath, loadInstallConfig, resolveInstallConfigPath, }; diff --git a/tests/lib/install-config.test.js b/tests/lib/install-config.test.js index 9dc1b5c8..7ff45520 100644 --- a/tests/lib/install-config.test.js +++ b/tests/lib/install-config.test.js @@ -8,6 +8,7 @@ const os = require('os'); const path = require('path'); const { + findDefaultInstallConfigPath, loadInstallConfig, resolveInstallConfigPath, } = require('../../scripts/lib/install/config'); @@ -49,6 +50,32 @@ function runTests() { assert.strictEqual(resolved, path.join(cwd, 'configs', 'ecc-install.json')); })) passed++; else failed++; + if (test('finds the default project install config in the provided cwd', () => { + const cwd = createTempDir('install-config-'); + + try { + const configPath = path.join(cwd, 'ecc-install.json'); + writeJson(configPath, { + version: 1, + profile: 'core', + }); + + assert.strictEqual(findDefaultInstallConfigPath({ cwd }), configPath); + } finally { + cleanup(cwd); + } + })) passed++; else failed++; + + if (test('returns null when no default project install config exists', () => { + const cwd = createTempDir('install-config-'); + + try { + assert.strictEqual(findDefaultInstallConfigPath({ cwd }), null); + } finally { + cleanup(cwd); + } + })) passed++; else failed++; + if (test('loads and normalizes a valid install config', () => { const cwd = createTempDir('install-config-'); diff --git a/tests/scripts/catalog.test.js b/tests/scripts/catalog.test.js new file mode 100644 index 00000000..1836b477 --- /dev/null +++ b/tests/scripts/catalog.test.js @@ -0,0 +1,104 @@ +/** + * Tests for scripts/catalog.js + */ + +const assert = require('assert'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'catalog.js'); + +function run(args = []) { + try { + const stdout = execFileSync('node', [SCRIPT, ...args], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 10000, + }); + return { code: 0, stdout, stderr: '' }; + } catch (error) { + return { + code: error.status || 1, + stdout: error.stdout || '', + stderr: error.stderr || '', + }; + } +} + +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 catalog.js ===\n'); + + let passed = 0; + let failed = 0; + + if (test('shows help with no arguments', () => { + const result = run(); + assert.strictEqual(result.code, 0); + assert.ok(result.stdout.includes('Discover ECC install components and profiles')); + })) passed++; else failed++; + + if (test('shows help with an explicit help flag', () => { + const result = run(['--help']); + assert.strictEqual(result.code, 0); + assert.ok(result.stdout.includes('Usage:')); + assert.ok(result.stdout.includes('node scripts/catalog.js show ')); + })) passed++; else failed++; + + if (test('lists install profiles', () => { + const result = run(['profiles']); + assert.strictEqual(result.code, 0); + assert.ok(result.stdout.includes('Install profiles')); + assert.ok(result.stdout.includes('core')); + })) passed++; else failed++; + + if (test('filters components by family and emits JSON', () => { + const result = run(['components', '--family', 'language', '--json']); + assert.strictEqual(result.code, 0, result.stderr); + const parsed = JSON.parse(result.stdout); + assert.ok(Array.isArray(parsed.components)); + assert.ok(parsed.components.length > 0); + assert.ok(parsed.components.every(component => component.family === 'language')); + assert.ok(parsed.components.some(component => component.id === 'lang:typescript')); + assert.ok(parsed.components.every(component => component.id !== 'framework:nextjs')); + })) passed++; else failed++; + + if (test('shows a resolved component payload', () => { + const result = run(['show', 'framework:nextjs', '--json']); + assert.strictEqual(result.code, 0, result.stderr); + const parsed = JSON.parse(result.stdout); + assert.strictEqual(parsed.id, 'framework:nextjs'); + assert.strictEqual(parsed.family, 'framework'); + assert.deepStrictEqual(parsed.moduleIds, ['framework-language']); + assert.ok(Array.isArray(parsed.modules)); + assert.strictEqual(parsed.modules[0].id, 'framework-language'); + })) passed++; else failed++; + + if (test('fails on unknown subcommands', () => { + const result = run(['bogus']); + assert.strictEqual(result.code, 1); + assert.ok(result.stderr.includes('Unknown catalog command')); + })) passed++; else failed++; + + if (test('fails on unknown component ids', () => { + const result = run(['show', 'framework:not-real']); + assert.strictEqual(result.code, 1); + assert.ok(result.stderr.includes('Unknown install component')); + })) passed++; else failed++; + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/scripts/ecc.test.js b/tests/scripts/ecc.test.js index 85cb6dc5..33817d1c 100644 --- a/tests/scripts/ecc.test.js +++ b/tests/scripts/ecc.test.js @@ -65,6 +65,7 @@ function main() { const result = runCli(['--help']); assert.strictEqual(result.status, 0); assert.match(result.stdout, /ECC selective-install CLI/); + assert.match(result.stdout, /catalog/); assert.match(result.stdout, /list-installed/); assert.match(result.stdout, /doctor/); }], @@ -93,6 +94,13 @@ function main() { assert.ok(Array.isArray(payload.profiles)); assert.ok(payload.profiles.length > 0); }], + ['delegates catalog command', () => { + const result = runCli(['catalog', 'show', 'framework:nextjs', '--json']); + assert.strictEqual(result.status, 0, result.stderr); + const payload = parseJson(result.stdout); + assert.strictEqual(payload.id, 'framework:nextjs'); + assert.deepStrictEqual(payload.moduleIds, ['framework-language']); + }], ['delegates lifecycle commands', () => { const homeDir = createTempDir('ecc-cli-home-'); const projectRoot = createTempDir('ecc-cli-project-'); @@ -127,6 +135,11 @@ function main() { assert.strictEqual(result.status, 0, result.stderr); assert.match(result.stdout, /Usage: node scripts\/repair\.js/); }], + ['supports help for the catalog subcommand', () => { + const result = runCli(['help', 'catalog']); + assert.strictEqual(result.status, 0, result.stderr); + assert.match(result.stdout, /node scripts\/catalog\.js show /); + }], ['fails on unknown commands instead of treating them as installs', () => { const result = runCli(['bogus']); assert.strictEqual(result.status, 1); diff --git a/tests/scripts/install-apply.test.js b/tests/scripts/install-apply.test.js index 811ebe2b..39743bfc 100644 --- a/tests/scripts/install-apply.test.js +++ b/tests/scripts/install-apply.test.js @@ -358,6 +358,67 @@ function runTests() { } })) passed++; else failed++; + if (test('auto-detects ecc-install.json from the project root', () => { + const homeDir = createTempDir('install-apply-home-'); + const projectDir = createTempDir('install-apply-project-'); + const configPath = path.join(projectDir, 'ecc-install.json'); + + try { + fs.writeFileSync(configPath, JSON.stringify({ + version: 1, + target: 'claude', + profile: 'developer', + include: ['capability:security'], + exclude: ['capability:orchestration'], + }, null, 2)); + + const result = run([], { cwd: projectDir, homeDir }); + assert.strictEqual(result.code, 0, result.stderr); + + assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'skills', 'security-review', 'SKILL.md'))); + assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'skills', 'dmux-workflows', 'SKILL.md'))); + + const state = readJson(path.join(homeDir, '.claude', 'ecc', 'install-state.json')); + assert.strictEqual(state.request.profile, 'developer'); + assert.deepStrictEqual(state.request.includeComponents, ['capability:security']); + assert.deepStrictEqual(state.request.excludeComponents, ['capability:orchestration']); + assert.ok(state.resolution.selectedModules.includes('security')); + assert.ok(!state.resolution.selectedModules.includes('orchestration')); + } finally { + cleanup(homeDir); + cleanup(projectDir); + } + })) passed++; else failed++; + + if (test('preserves legacy language installs when a project config is present', () => { + const homeDir = createTempDir('install-apply-home-'); + const projectDir = createTempDir('install-apply-project-'); + const configPath = path.join(projectDir, 'ecc-install.json'); + + try { + fs.writeFileSync(configPath, JSON.stringify({ + version: 1, + target: 'claude', + profile: 'developer', + include: ['capability:security'], + }, null, 2)); + + const result = run(['typescript'], { cwd: projectDir, homeDir }); + assert.strictEqual(result.code, 0, result.stderr); + + const state = readJson(path.join(homeDir, '.claude', 'ecc', 'install-state.json')); + assert.strictEqual(state.request.legacyMode, true); + assert.deepStrictEqual(state.request.legacyLanguages, ['typescript']); + assert.strictEqual(state.request.profile, null); + assert.deepStrictEqual(state.request.includeComponents, []); + assert.ok(state.resolution.selectedModules.includes('framework-language')); + assert.ok(!state.resolution.selectedModules.includes('security')); + } finally { + cleanup(homeDir); + cleanup(projectDir); + } + })) passed++; else failed++; + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); } diff --git a/tests/scripts/install-plan.test.js b/tests/scripts/install-plan.test.js index ad9341a5..c0d14034 100644 --- a/tests/scripts/install-plan.test.js +++ b/tests/scripts/install-plan.test.js @@ -8,11 +8,12 @@ const { execFileSync } = require('child_process'); const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'install-plan.js'); -function run(args = []) { +function run(args = [], options = {}) { try { const stdout = execFileSync('node', [SCRIPT, ...args], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], + cwd: options.cwd, timeout: 10000, }); return { code: 0, stdout, stderr: '' }; @@ -135,6 +136,31 @@ function runTests() { } })) passed++; else failed++; + if (test('auto-detects planning intent from project ecc-install.json', () => { + const configDir = path.join(__dirname, '..', 'fixtures', 'tmp-install-plan-autodetect'); + const configPath = path.join(configDir, 'ecc-install.json'); + + try { + require('fs').mkdirSync(configDir, { recursive: true }); + require('fs').writeFileSync(configPath, JSON.stringify({ + version: 1, + target: 'cursor', + profile: 'core', + include: ['capability:security'], + }, null, 2)); + + const result = run(['--json'], { cwd: configDir }); + assert.strictEqual(result.code, 0, result.stderr); + const parsed = JSON.parse(result.stdout); + assert.strictEqual(parsed.target, 'cursor'); + assert.strictEqual(parsed.profileId, 'core'); + assert.deepStrictEqual(parsed.includedComponentIds, ['capability:security']); + assert.ok(parsed.selectedModuleIds.includes('security')); + } finally { + require('fs').rmSync(configDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + if (test('fails on unknown arguments', () => { const result = run(['--unknown-flag']); assert.strictEqual(result.code, 1);