From 9a3f72712b21fafc5004d89322a1fdfe64facf9d Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 30 Apr 2026 06:57:53 -0400 Subject: [PATCH] feat: add ECC consult command --- README.md | 10 + package.json | 1 + scripts/consult.js | 441 +++++++++++++++++++ scripts/ecc.js | 6 + tests/scripts/consult.test.js | 115 +++++ tests/scripts/ecc.test.js | 13 + tests/scripts/install-readme-clarity.test.js | 15 + tests/scripts/npm-publish-surface.test.js | 2 + 8 files changed, 603 insertions(+) create mode 100644 scripts/consult.js create mode 100644 tests/scripts/consult.test.js diff --git a/README.md b/README.md index 3978ad8d..6d89fc49 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,16 @@ Add hooks later only if you want runtime enforcement: ./install.sh --target claude --modules hooks-runtime ``` +### Find the right components first + +If you are not sure which ECC profile or component to install, ask the packaged advisor from any project: + +```bash +npx ecc consult "security reviews" --target claude +``` + +It returns matching components, related profiles, and preview/install commands. Use the preview command before installing if you want to inspect the exact file plan. + ### Step 1: Install the Plugin (Recommended) > NOTE: The plugin is convenient, but the OSS installer below is still the most reliable path if your Claude Code build has trouble resolving self-hosted marketplace entries. diff --git a/package.json b/package.json index 6dd8e336..87605f8c 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "rules/", "schemas/", "scripts/catalog.js", + "scripts/consult.js", "scripts/auto-update.js", "scripts/claw.js", "scripts/codex/merge-codex-config.js", diff --git a/scripts/consult.js b/scripts/consult.js new file mode 100644 index 00000000..a560cc3c --- /dev/null +++ b/scripts/consult.js @@ -0,0 +1,441 @@ +#!/usr/bin/env node + +const { + SUPPORTED_INSTALL_TARGETS, + listInstallComponents, + listInstallProfiles, + loadInstallManifests, +} = require('./lib/install-manifests'); + +const DEFAULT_TARGET = 'claude'; +const DEFAULT_LIMIT = 5; +const MAX_LIMIT = 20; +const SCHEMA_VERSION = 'ecc.consult.v1'; + +const STOP_WORDS = new Set([ + 'a', + 'an', + 'and', + 'app', + 'are', + 'for', + 'from', + 'i', + 'in', + 'into', + 'me', + 'need', + 'of', + 'on', + 'please', + 'skill', + 'skills', + 'the', + 'to', + 'want', + 'with', +]); + +const COMPONENT_ALIASES = Object.freeze({ + 'capability:security': [ + 'appsec', + 'auth', + 'authorization', + 'checklist', + 'hardening', + 'pentest', + 'secret', + 'secrets', + 'threat', + 'vulnerability', + 'vulnerabilities', + ], + 'capability:database': ['db', 'migration', 'migrations', 'postgres', 'postgresql', 'schema', 'sql'], + 'capability:research': ['api', 'apis', 'exa', 'external', 'investigation', 'search'], + 'capability:content': ['article', 'brand', 'business', 'copy', 'linkedin', 'writing'], + 'capability:operators': ['automation', 'billing', 'connected', 'ops', 'operator', 'workspace'], + 'capability:social': ['distribution', 'post', 'posting', 'publish', 'publishing', 'twitter', 'x'], + 'capability:media': ['editing', 'image', 'remotion', 'slides', 'video'], + 'capability:orchestration': ['dmux', 'parallel', 'tmux', 'worktree', 'worktrees'], + 'framework:nextjs': ['next', 'next.js', 'nextjs'], + 'framework:react': ['react', 'tsx'], + 'framework:django': ['django'], + 'framework:springboot': ['spring', 'springboot'], + 'lang:typescript': ['javascript', 'js', 'node', 'nodejs', 'ts'], + 'lang:python': ['py'], + 'lang:go': ['golang'], +}); + +const PROFILE_ALIASES = Object.freeze({ + minimal: ['low-context', 'lean', 'no-hooks', 'base', 'lightweight'], + core: ['baseline', 'default', 'starter'], + developer: ['app', 'code', 'coding', 'engineering', 'software'], + security: ['appsec', 'audit', 'hardening', 'review', 'threat', 'vulnerability'], + research: ['content', 'investigation', 'publishing', 'synthesis'], + full: ['all', 'complete', 'everything'], +}); + +function showHelp(exitCode = 0) { + console.log(` +Consult ECC install components and profiles from any project + +Usage: + node scripts/consult.js "security reviews" [--target ] [--limit ] [--json] + node scripts/consult.js security reviews --target codex + +Options: + --target Install target to include in suggested commands. Default: ${DEFAULT_TARGET} + --limit Maximum component recommendations to return. Default: ${DEFAULT_LIMIT} + --json Emit machine-readable consultation JSON + --help Show this help text + +Examples: + node scripts/consult.js "security reviews" + node scripts/consult.js "Next.js React app" --target cursor + node scripts/consult.js "operator workflows" --target codex --json +`); + + process.exit(exitCode); +} + +function normalizeToken(value) { + return String(value || '') + .toLowerCase() + .replace(/\.js\b/g, 'js') + .replace(/[^a-z0-9:+-]+/g, ' ') + .trim(); +} + +function expandToken(token) { + const values = new Set([token]); + + if (token.endsWith('ies') && token.length > 4) { + values.add(`${token.slice(0, -3)}y`); + } + if (token.endsWith('es') && token.length > 4 && !token.endsWith('js')) { + values.add(token.slice(0, -2)); + } + if (token.endsWith('s') && token.length > 4 && !token.endsWith('js')) { + values.add(token.slice(0, -1)); + } + if (token.endsWith('ing') && token.length > 6) { + values.add(token.slice(0, -3)); + } + + return [...values].filter(Boolean); +} + +function tokenize(value) { + const normalized = normalizeToken(value); + if (!normalized) { + return []; + } + + const tokens = []; + for (const token of normalized.split(/\s+/)) { + if (!token || STOP_WORDS.has(token)) { + continue; + } + tokens.push(...expandToken(token)); + } + return [...new Set(tokens)]; +} + +function parsePositiveInteger(value, label) { + if (!/^[1-9]\d*$/.test(String(value || ''))) { + throw new Error(`${label} must be a positive integer`); + } + return Number(value); +} + +function parseArgs(argv) { + const args = argv.slice(2); + const parsed = { + queryParts: [], + target: DEFAULT_TARGET, + limit: DEFAULT_LIMIT, + json: false, + help: false, + }; + + for (let index = 0; 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 === '--target') { + if (!args[index + 1]) { + throw new Error('Missing value for --target'); + } + parsed.target = args[index + 1]; + index += 1; + } else if (arg === '--limit') { + if (!args[index + 1]) { + throw new Error('Missing value for --limit'); + } + parsed.limit = Math.min(parsePositiveInteger(args[index + 1], '--limit'), MAX_LIMIT); + index += 1; + } else if (arg.startsWith('-')) { + throw new Error(`Unknown argument: ${arg}`); + } else { + parsed.queryParts.push(arg); + } + } + + if (!SUPPORTED_INSTALL_TARGETS.includes(parsed.target)) { + throw new Error( + `Unknown install target: ${parsed.target}. Expected one of ${SUPPORTED_INSTALL_TARGETS.join(', ')}` + ); + } + + parsed.query = parsed.queryParts.join(' ').trim(); + return parsed; +} + +function commandFor(kind, id, target) { + if (kind === 'profile') { + return `npx ecc install --profile ${id} --target ${target}`; + } + + return `npx ecc install --profile minimal --target ${target} --with ${id}`; +} + +function planCommandFor(componentId, target) { + return `npx ecc plan --profile minimal --target ${target} --with ${componentId}`; +} + +function buildSearchCorpus(parts) { + return tokenize(parts.filter(Boolean).join(' ')); +} + +function scoreAgainstQuery(queryTokens, corpusTokens, options = {}) { + const corpus = new Set(corpusTokens); + const reasons = []; + let score = 0; + + queryTokens.forEach((token, index) => { + if (corpus.has(token)) { + score += index === 0 ? 5 : 4; + reasons.push(`matched "${token}"`); + return; + } + + if ( + token.length >= 4 + && [...corpus].some(corpusToken => ( + corpusToken.length >= 4 + && (corpusToken.includes(token) || token.includes(corpusToken)) + )) + ) { + score += 1; + reasons.push(`fuzzy matched "${token}"`); + } + }); + + if (options.preferred && reasons.length > 0) { + score += options.preferred; + } + + return { score, reasons: [...new Set(reasons)] }; +} + +function preferredComponentBonus(component, queryTokens) { + let bonus = 0; + const suffix = component.id.split(':')[1]; + + if (queryTokens[0] === suffix) { + bonus += 5; + } + + if (component.family === 'capability') { + bonus += 3; + } + + if (component.id === 'capability:security' && queryTokens.some(token => ['audit', 'review', 'security'].includes(token))) { + bonus += 4; + } + + return bonus; +} + +function rankComponents({ queryTokens, target, limit }) { + return listInstallComponents({ target }) + .map(component => { + const aliases = COMPONENT_ALIASES[component.id] || []; + const corpusTokens = buildSearchCorpus([ + component.id.replace(':', ' '), + component.family, + component.description, + component.moduleIds.join(' '), + aliases.join(' '), + ]); + const { score, reasons } = scoreAgainstQuery(queryTokens, corpusTokens, { + preferred: preferredComponentBonus(component, queryTokens), + }); + + return { + component, + score, + reasons, + }; + }) + .filter(result => result.score > 0) + .sort((left, right) => ( + right.score - left.score + || left.component.family.localeCompare(right.component.family) + || left.component.id.localeCompare(right.component.id) + )) + .slice(0, limit) + .map(result => ({ + componentId: result.component.id, + family: result.component.family, + description: result.component.description, + moduleIds: result.component.moduleIds, + targets: result.component.targets, + score: result.score, + reasons: result.reasons.length > 0 ? result.reasons : ['related install component'], + installCommand: commandFor('component', result.component.id, target), + planCommand: planCommandFor(result.component.id, target), + })); +} + +function rankProfiles({ queryTokens, target, limit }) { + const manifests = loadInstallManifests(); + return listInstallProfiles() + .map(profile => { + const profileDefinition = manifests.profiles[profile.id] || {}; + const aliases = PROFILE_ALIASES[profile.id] || []; + const corpusTokens = buildSearchCorpus([ + profile.id, + profile.description, + (profileDefinition.modules || []).join(' '), + aliases.join(' '), + ]); + const preferred = queryTokens.includes(profile.id) ? 4 : 0; + const { score, reasons } = scoreAgainstQuery(queryTokens, corpusTokens, { preferred }); + + return { + profile, + score, + reasons, + }; + }) + .filter(result => result.score > 0) + .sort((left, right) => right.score - left.score || left.profile.id.localeCompare(right.profile.id)) + .slice(0, Math.min(3, limit)) + .map(result => ({ + id: result.profile.id, + description: result.profile.description, + moduleCount: result.profile.moduleCount, + score: result.score, + reasons: result.reasons.length > 0 ? result.reasons : ['related install profile'], + installCommand: commandFor('profile', result.profile.id, target), + })); +} + +function buildConsultation(options) { + const queryTokens = tokenize(options.query); + if (queryTokens.length === 0) { + throw new Error('Consult requires a natural language query, for example: security reviews'); + } + + const matches = rankComponents({ + queryTokens, + target: options.target, + limit: options.limit, + }); + const profiles = rankProfiles({ + queryTokens, + target: options.target, + limit: options.limit, + }); + + return { + schemaVersion: SCHEMA_VERSION, + query: options.query, + target: options.target, + generatedAt: new Date().toISOString(), + matches, + profiles, + nextSteps: matches.length > 0 + ? [ + `Preview the top component: ${matches[0].planCommand}`, + `Install it: ${matches[0].installCommand}`, + ] + : [ + 'Run `npx ecc catalog components` to browse all components.', + 'Try a more specific query such as "security review", "Next.js", or "operator workflows".', + ], + }; +} + +function formatText(payload) { + const lines = [ + `ECC consult (${payload.generatedAt})`, + `Query: ${payload.query}`, + `Target: ${payload.target}`, + '', + ]; + + if (payload.matches.length === 0) { + lines.push('No strong component matches found.'); + lines.push('Try: npx ecc catalog components'); + } else { + lines.push('Recommended components:'); + payload.matches.forEach((match, index) => { + lines.push(`${index + 1}. ${match.componentId} [${match.family}]`); + lines.push(` ${match.description}`); + lines.push(` Install: ${match.installCommand}`); + lines.push(` Preview: ${match.planCommand}`); + lines.push(` Why: ${match.reasons.join('; ')}`); + }); + } + + if (payload.profiles.length > 0) { + lines.push(''); + lines.push('Related profiles:'); + payload.profiles.forEach(profile => { + lines.push(`- ${profile.id}: ${profile.description}`); + lines.push(` Install: ${profile.installCommand}`); + }); + } + + lines.push(''); + lines.push('Next steps:'); + payload.nextSteps.forEach(step => lines.push(`- ${step}`)); + + return `${lines.join('\n')}\n`; +} + +function main() { + try { + const options = parseArgs(process.argv); + + if (options.help) { + showHelp(0); + } + + const payload = buildConsultation(options); + if (options.json) { + console.log(JSON.stringify(payload, null, 2)); + } else { + process.stdout.write(formatText(payload)); + } + } catch (error) { + console.error(`Error: ${error.message}`); + process.exit(1); + } +} + +if (require.main === module) { + main(); +} + +module.exports = { + buildConsultation, + formatText, + parseArgs, + tokenize, +}; diff --git a/scripts/ecc.js b/scripts/ecc.js index a7c745f4..18a8240d 100755 --- a/scripts/ecc.js +++ b/scripts/ecc.js @@ -17,6 +17,10 @@ const COMMANDS = { script: 'catalog.js', description: 'Discover install profiles and component IDs', }, + consult: { + script: 'consult.js', + description: 'Recommend ECC components and profiles from a natural language query', + }, 'install-plan': { script: 'install-plan.js', description: 'Alias for plan', @@ -63,6 +67,7 @@ const PRIMARY_COMMANDS = [ 'install', 'plan', 'catalog', + 'consult', 'list-installed', 'doctor', 'repair', @@ -97,6 +102,7 @@ Examples: ecc catalog profiles ecc catalog components --family language ecc catalog show framework:nextjs + ecc consult "security reviews" ecc list-installed --json ecc doctor --target cursor ecc repair --dry-run diff --git a/tests/scripts/consult.test.js b/tests/scripts/consult.test.js new file mode 100644 index 00000000..3eea8c3a --- /dev/null +++ b/tests/scripts/consult.test.js @@ -0,0 +1,115 @@ +/** + * Tests for scripts/consult.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'consult.js'); + +function run(args = [], options = {}) { + return spawnSync(process.execPath, [SCRIPT, ...args], { + cwd: options.cwd || process.cwd(), + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + }); +} + +function parseJson(stdout) { + return JSON.parse(stdout.trim()); +} + +function test(name, fn) { + try { + fn(); + console.log(` PASS ${name}`); + return true; + } catch (error) { + console.log(` FAIL ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function runTests() { + console.log('\n=== Testing consult.js ===\n'); + + let passed = 0; + let failed = 0; + + if (test('shows help with an explicit help flag', () => { + const result = run(['--help']); + + assert.strictEqual(result.status, 0, result.stderr); + assert.match(result.stdout, /Consult ECC install components/); + assert.match(result.stdout, /node scripts\/consult\.js "security reviews"/); + })) passed++; else failed++; + + if (test('recommends security components and profile for a natural language query', () => { + const result = run(['security', 'reviews', '--json']); + + assert.strictEqual(result.status, 0, result.stderr); + const payload = parseJson(result.stdout); + assert.strictEqual(payload.schemaVersion, 'ecc.consult.v1'); + assert.strictEqual(payload.query, 'security reviews'); + assert.strictEqual(payload.target, 'claude'); + assert.strictEqual(payload.matches[0].componentId, 'capability:security'); + assert.ok(payload.matches[0].reasons.some(reason => reason.includes('security'))); + assert.strictEqual( + payload.matches[0].installCommand, + 'npx ecc install --profile minimal --target claude --with capability:security' + ); + assert.ok(payload.profiles.some(profile => profile.id === 'security')); + assert.ok(payload.profiles.find(profile => profile.id === 'security').installCommand.includes('--profile security')); + })) passed++; else failed++; + + if (test('prints text recommendations with install and plan commands', () => { + const result = run(['I', 'want', 'a', 'skill', 'for', 'security', 'reviews']); + + assert.strictEqual(result.status, 0, result.stderr); + assert.match(result.stdout, /ECC consult/); + assert.match(result.stdout, /capability:security/); + assert.match(result.stdout, /npx ecc install --profile minimal --target claude --with capability:security/); + assert.match(result.stdout, /npx ecc plan --profile minimal --target claude --with capability:security/); + })) passed++; else failed++; + + if (test('works from outside the ECC repository', () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-consult-project-')); + try { + const result = run(['nextjs', 'react', '--json'], { cwd: projectDir }); + + assert.strictEqual(result.status, 0, result.stderr); + const payload = parseJson(result.stdout); + assert.strictEqual(payload.matches[0].componentId, 'framework:nextjs'); + assert.ok(payload.matches.some(match => match.componentId === 'framework:react')); + } finally { + fs.rmSync(projectDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('filters recommendations by target and limit', () => { + const result = run(['operator', 'workflows', '--target', 'codex', '--limit', '1', '--json']); + + assert.strictEqual(result.status, 0, result.stderr); + const payload = parseJson(result.stdout); + assert.strictEqual(payload.target, 'codex'); + assert.strictEqual(payload.matches.length, 1); + assert.ok(payload.matches[0].targets.includes('codex')); + assert.ok(payload.matches[0].installCommand.includes('--target codex')); + })) passed++; else failed++; + + if (test('rejects unknown targets', () => { + const result = run(['security', '--target', 'not-a-target']); + + assert.strictEqual(result.status, 1); + assert.match(result.stderr, /Unknown install target/); + })) 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 f64d1299..2691f83a 100644 --- a/tests/scripts/ecc.test.js +++ b/tests/scripts/ecc.test.js @@ -69,6 +69,7 @@ function main() { assert.match(result.stdout, /list-installed/); assert.match(result.stdout, /doctor/); assert.match(result.stdout, /auto-update/); + assert.match(result.stdout, /consult/); assert.match(result.stdout, /loop-status/); }], ['delegates explicit install command', () => { @@ -103,6 +104,13 @@ function main() { assert.strictEqual(payload.id, 'framework:nextjs'); assert.deepStrictEqual(payload.moduleIds, ['framework-language']); }], + ['delegates consult command', () => { + const result = runCli(['consult', 'security', 'reviews', '--json']); + assert.strictEqual(result.status, 0, result.stderr); + const payload = parseJson(result.stdout); + assert.strictEqual(payload.schemaVersion, 'ecc.consult.v1'); + assert.strictEqual(payload.matches[0].componentId, 'capability:security'); + }], ['delegates lifecycle commands', () => { const homeDir = createTempDir('ecc-cli-home-'); const projectRoot = createTempDir('ecc-cli-project-'); @@ -188,6 +196,11 @@ function main() { assert.strictEqual(result.status, 0, result.stderr); assert.match(result.stdout, /node scripts\/catalog\.js show /); }], + ['supports help for the consult subcommand', () => { + const result = runCli(['help', 'consult']); + assert.strictEqual(result.status, 0, result.stderr); + assert.match(result.stdout, /node scripts\/consult\.js "security reviews"/); + }], ['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-readme-clarity.test.js b/tests/scripts/install-readme-clarity.test.js index 7affd11f..3a0725db 100644 --- a/tests/scripts/install-readme-clarity.test.js +++ b/tests/scripts/install-readme-clarity.test.js @@ -93,6 +93,21 @@ function runTests() { ); })) passed++; else failed++; + if (test('README documents consult-based component discovery', () => { + assert.ok( + readme.includes('### Find the right components first'), + 'README should surface component discovery before install steps' + ); + assert.ok( + readme.includes('npx ecc consult "security reviews" --target claude'), + 'README should document the packaged consult command' + ); + assert.ok( + readme.includes('It returns matching components, related profiles, and preview/install commands.'), + 'README should explain what consult returns' + ); + })) passed++; else failed++; + if (test('README documents Cursor agent namespace and loading caveat', () => { assert.ok( readme.includes('`.cursor/agents/ecc-*.md`'), diff --git a/tests/scripts/npm-publish-surface.test.js b/tests/scripts/npm-publish-surface.test.js index 530f6cf9..ae1ed738 100644 --- a/tests/scripts/npm-publish-surface.test.js +++ b/tests/scripts/npm-publish-surface.test.js @@ -43,6 +43,7 @@ function buildExpectedPublishPaths(repoRoot) { "manifests", "scripts/ecc.js", "scripts/catalog.js", + "scripts/consult.js", "scripts/claw.js", "scripts/doctor.js", "scripts/status.js", @@ -108,6 +109,7 @@ function main() { for (const requiredPath of [ "scripts/catalog.js", + "scripts/consult.js", ".gemini/GEMINI.md", ".claude-plugin/plugin.json", ".codex-plugin/plugin.json",