mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-17 21:15:40 +02:00
* feat(install): add guided Claude plugin setup * fix: support Claude command shims on Windows * feat: support safe Claude plugin scope migration * fix(install): preserve interactive setup terminal * fix(install): auto-migrate setup scope changes * feat(install): add guided multi-harness installer * fix(install): sync Yarn binary metadata * fix(install): handle wizard EOF on Node 18 * ci: allow installer matrix tests to finish * test(install): allow slower PowerShell delegation * fix(install): harden guided provider reconciliation * test(install): harden packaged and local compatibility * chore: prepare guided installer release 2.2.0 * fix(install): report refreshed Codex marketplace state * fix(install): verify managed content provenance * test(install): allow empty Yarn smoke fixture * test(install): invoke Windows package shims safely * fix(install): close cross-platform release gaps * fix(install): require trusted GitHub origins * fix(install): preserve hook profile precedence * refactor(install): centralize trusted GitHub origins * ci: retrigger workflow run after merge of main Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
339 lines
12 KiB
JavaScript
339 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
const readline = require('readline/promises');
|
|
|
|
const {
|
|
getHarnessCapability,
|
|
listGuidedHarnesses,
|
|
normalizeHarnessSelection,
|
|
} = require('./lib/harness-capabilities');
|
|
const {
|
|
VALID_CLAUDE_HOOKS,
|
|
VALID_CLAUDE_SCOPES,
|
|
VALID_PROFILES,
|
|
applyMultiHarnessPlan,
|
|
createMultiHarnessPlan,
|
|
normalizeGuidedInstallRequest,
|
|
} = require('./lib/multi-harness-setup');
|
|
const { startTerminalSpinner } = require('./lib/terminal-spinner');
|
|
const { showTerminalWelcome } = require('./lib/terminal-welcome');
|
|
const { stripAnsi } = require('./lib/utils');
|
|
|
|
const ADVANCED_HARNESSES = 'Cursor, Antigravity, Gemini CLI, OpenCode, CodeBuddy, JoyCode, Qwen Code, Zed, Hermes, and OpenClaw';
|
|
|
|
function showHelp(output = process.stdout) {
|
|
output.write(`
|
|
ECC guided multi-harness install
|
|
|
|
Usage:
|
|
ecc install --guided
|
|
ecc install --guided --harness claude --harness codex --harness kimi [options]
|
|
|
|
Guided harnesses:
|
|
claude Native Claude Code plugin; choose user, project, or local scope and an ECC hook profile.
|
|
codex Native Codex plugin and Codex-owned hook review/trust.
|
|
kimi Managed project install under ./.kimi-code; ECC hooks are not configured.
|
|
|
|
Options:
|
|
--harness <id[,id...]> Repeatable; accepts Claude, Codex, Kimi, or all
|
|
--all-harnesses Select all three guided harnesses
|
|
--claude-scope <user|project|local>
|
|
--claude-hooks <off|minimal|standard|strict>
|
|
--profile <minimal|core|developer|security|research|full>
|
|
Kimi managed-project content profile
|
|
--yes, -y Apply without confirmation
|
|
--dry-run Preflight and preview without changing files
|
|
--json Emit machine-readable output
|
|
--help, -h Show this help
|
|
|
|
Advanced managed adapters remain available through explicit ecc install --target commands:
|
|
${ADVANCED_HARNESSES}
|
|
|
|
This command configures ECC. It does not install or authenticate provider CLIs.
|
|
`);
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
let options = {
|
|
allHarnesses: false,
|
|
claudeHooks: undefined,
|
|
claudeScope: undefined,
|
|
dryRun: false,
|
|
harnesses: [],
|
|
help: false,
|
|
json: false,
|
|
profile: undefined,
|
|
yes: false,
|
|
};
|
|
const valueFlags = new Map([
|
|
['--harness', 'harnesses'],
|
|
['--claude-scope', 'claudeScope'],
|
|
['--claude-hooks', 'claudeHooks'],
|
|
['--profile', 'profile'],
|
|
]);
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const argument = argv[index];
|
|
if (valueFlags.has(argument)) {
|
|
const value = argv[index + 1];
|
|
if (!value || value.startsWith('--')) {
|
|
throw new Error(`Missing value for ${argument}`);
|
|
}
|
|
if (value.length > 256) {
|
|
throw new Error(`Value for ${argument} is too long.`);
|
|
}
|
|
const key = valueFlags.get(argument);
|
|
options = key === 'harnesses'
|
|
? { ...options, harnesses: [...options.harnesses, value] }
|
|
: { ...options, [key]: value };
|
|
index += 1;
|
|
} else if (argument === '--all-harnesses') {
|
|
options = { ...options, allHarnesses: true };
|
|
} else if (argument === '--yes' || argument === '-y') {
|
|
options = { ...options, yes: true };
|
|
} else if (argument === '--dry-run') {
|
|
options = { ...options, dryRun: true };
|
|
} else if (argument === '--json') {
|
|
options = { ...options, json: true };
|
|
} else if (argument === '--help' || argument === '-h') {
|
|
options = { ...options, help: true };
|
|
} else {
|
|
throw new Error('Unknown argument. Run guided install with --help to see valid options.');
|
|
}
|
|
}
|
|
if (options.allHarnesses && options.harnesses.length > 0) {
|
|
throw new Error('--all-harnesses and --harness are mutually exclusive.');
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function choicesText(values) {
|
|
return values.join('|');
|
|
}
|
|
|
|
async function askChoice(terminal, output, prompt, values, defaultValue) {
|
|
output.write(`\n${prompt}\n`);
|
|
values.forEach((value, index) => output.write(` ${index + 1}. ${value}\n`));
|
|
while (true) {
|
|
const question = defaultValue
|
|
? `Choose [Recommended: ${defaultValue}] (one option only): `
|
|
: 'Choose one option: ';
|
|
const answer = (await terminal.question(question)).trim().toLowerCase();
|
|
if (!answer && defaultValue) return defaultValue;
|
|
const numeric = /^\d+$/.test(answer) ? values[Number(answer) - 1] : undefined;
|
|
const selected = numeric || values.find(value => value === answer);
|
|
if (selected) return selected;
|
|
output.write(`Please choose ${choicesText(values)}.\n`);
|
|
}
|
|
}
|
|
|
|
async function askHarnesses(terminal, output) {
|
|
const guided = listGuidedHarnesses();
|
|
output.write('\nWhich coding agents should ECC configure?\n');
|
|
guided.forEach((harness, index) => {
|
|
output.write(` ${index + 1}. ${harness.label} — ${harness.destination}\n`);
|
|
});
|
|
output.write(' all. All three guided harnesses\n');
|
|
output.write(`\nAdvanced adapters (use ecc install --target): ${ADVANCED_HARNESSES}.\n\n`);
|
|
while (true) {
|
|
const answer = await terminal.question('Choose one or more (for example 1,3 or all): ');
|
|
if (answer.length > 1024) {
|
|
output.write('Please choose Claude, Codex, Kimi, or all.\n');
|
|
continue;
|
|
}
|
|
try {
|
|
return normalizeHarnessSelection(answer);
|
|
} catch (_error) {
|
|
output.write('Please choose Claude, Codex, Kimi, or all.\n');
|
|
}
|
|
}
|
|
}
|
|
|
|
async function collectInteractiveOptions(options, dependencies = {}) {
|
|
const terminal = dependencies.terminal;
|
|
const output = dependencies.output || process.stdout;
|
|
let harnesses = options.allHarnesses ? ['all'] : options.harnesses;
|
|
if (harnesses.length === 0) harnesses = await askHarnesses(terminal, output);
|
|
const normalizedHarnesses = normalizeHarnessSelection(harnesses);
|
|
const includesClaude = normalizedHarnesses.includes('claude');
|
|
const includesKimi = normalizedHarnesses.includes('kimi');
|
|
const claudeScope = includesClaude && !options.claudeScope
|
|
? await askChoice(terminal, output, 'Where should Claude enable ecc@ecc?', [...VALID_CLAUDE_SCOPES], 'user')
|
|
: options.claudeScope;
|
|
const claudeHooks = includesClaude && !options.claudeHooks
|
|
? await askChoice(terminal, output, 'How should ECC hooks run in Claude?', [...VALID_CLAUDE_HOOKS], 'standard')
|
|
: options.claudeHooks;
|
|
const profile = includesKimi && !options.profile
|
|
? await askChoice(terminal, output, 'Which ECC content profile should Kimi receive?', [...VALID_PROFILES], 'core')
|
|
: options.profile;
|
|
return {
|
|
...options,
|
|
harnesses: normalizedHarnesses,
|
|
claudeScope,
|
|
claudeHooks,
|
|
profile,
|
|
};
|
|
}
|
|
|
|
function selectedHarnessIds(options) {
|
|
if (options.allHarnesses) return normalizeHarnessSelection(['all']);
|
|
if (options.harnesses.length === 0) return [];
|
|
return normalizeHarnessSelection(options.harnesses);
|
|
}
|
|
|
|
function validateExecutionMode(options, interactive) {
|
|
const harnesses = selectedHarnessIds(options);
|
|
if (!interactive && harnesses.length === 0) {
|
|
throw new Error('Non-interactive guided install requires at least one --harness.');
|
|
}
|
|
const requiresExplicit = !interactive || options.json;
|
|
if (requiresExplicit && harnesses.includes('claude') && (!options.claudeScope || !options.claudeHooks)) {
|
|
throw new Error('Claude requires explicit --claude-scope and --claude-hooks choices in this mode.');
|
|
}
|
|
if (requiresExplicit && harnesses.includes('kimi') && !options.profile) {
|
|
throw new Error('Kimi requires an explicit --profile choice in this mode.');
|
|
}
|
|
if ((!interactive || options.json) && !options.yes && !options.dryRun) {
|
|
throw new Error('Non-interactive and JSON mutations require --yes.');
|
|
}
|
|
}
|
|
|
|
function printPlan(plan, output) {
|
|
output.write('\nECC guided install preview\n\n');
|
|
output.write('Harness Channel Destination\n');
|
|
for (const entry of plan.harnesses) {
|
|
const harness = getHarnessCapability(entry.id);
|
|
output.write(`${harness.label.padEnd(13)} ${entry.channel.padEnd(17)} ${harness.destination}\n`);
|
|
}
|
|
if (plan.request.harnesses.includes('kimi')) {
|
|
output.write('\nKimi note: ECC hooks are not configured; model, provider, and authentication settings are unchanged.\n');
|
|
}
|
|
}
|
|
|
|
async function confirmPlan(terminal, output) {
|
|
output.write('\n');
|
|
const answer = await terminal.question('Apply ECC to these harnesses? [y/N]: ');
|
|
return /^y(es)?$/i.test(answer.trim());
|
|
}
|
|
|
|
function sanitizeTerminalText(value) {
|
|
return stripAnsi(String(value || '')).replace(/[^\x20-\x7E]/g, '?');
|
|
}
|
|
|
|
function buildRetryArguments(plan, retryHarnesses) {
|
|
const harnesses = [...retryHarnesses];
|
|
const harnessArguments = harnesses.flatMap(id => ['--harness', id]);
|
|
const claudeArguments = harnesses.includes('claude')
|
|
? ['--claude-scope', plan.request.claudeScope, '--claude-hooks', plan.request.claudeHooks]
|
|
: [];
|
|
const kimiArguments = harnesses.includes('kimi')
|
|
? ['--profile', plan.request.profile]
|
|
: [];
|
|
return [...harnessArguments, ...claudeArguments, ...kimiArguments].join(' ');
|
|
}
|
|
|
|
async function main(argv = process.argv.slice(2), injected = {}) {
|
|
const output = injected.output || process.stdout;
|
|
const errorOutput = injected.errorOutput || process.stderr;
|
|
const interactive = injected.interactive !== undefined
|
|
? injected.interactive
|
|
: Boolean(process.stdin.isTTY && output.isTTY);
|
|
const createPlan = injected.createPlan || createMultiHarnessPlan;
|
|
const applyPlan = injected.applyPlan || applyMultiHarnessPlan;
|
|
const renderWelcome = injected.showWelcome || showTerminalWelcome;
|
|
const makeSpinner = injected.startSpinner || startTerminalSpinner;
|
|
let terminal = injected.terminal;
|
|
let ownsTerminal = false;
|
|
|
|
try {
|
|
let options = parseArgs(argv);
|
|
if (options.help) {
|
|
showHelp(output);
|
|
return 0;
|
|
}
|
|
validateExecutionMode(options, interactive);
|
|
const needsChoices = selectedHarnessIds(options).length === 0
|
|
|| (selectedHarnessIds(options).includes('claude') && (!options.claudeScope || !options.claudeHooks))
|
|
|| (selectedHarnessIds(options).includes('kimi') && !options.profile);
|
|
if (interactive && needsChoices) {
|
|
if (!terminal) {
|
|
terminal = readline.createInterface({ input: process.stdin, output });
|
|
ownsTerminal = true;
|
|
}
|
|
options = await collectInteractiveOptions(options, { output, terminal });
|
|
}
|
|
const request = normalizeGuidedInstallRequest({
|
|
...options,
|
|
harnesses: options.allHarnesses ? ['all'] : options.harnesses,
|
|
});
|
|
const plan = await createPlan(request);
|
|
|
|
if (options.json && options.dryRun) {
|
|
output.write(`${JSON.stringify({ dryRun: true, plan }, null, 2)}\n`);
|
|
return 0;
|
|
}
|
|
if (!options.json) printPlan(plan, output);
|
|
if (options.dryRun) {
|
|
output.write('\nDry run complete. No changes were made.\n');
|
|
return 0;
|
|
}
|
|
if (!options.yes) {
|
|
if (!terminal) {
|
|
terminal = readline.createInterface({ input: process.stdin, output });
|
|
ownsTerminal = true;
|
|
}
|
|
if (!await confirmPlan(terminal, output)) {
|
|
output.write('\nECC install cancelled. No changes were made.\n');
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
const spinner = interactive && !options.json
|
|
? makeSpinner('Applying ECC to selected harnesses...')
|
|
: undefined;
|
|
let result;
|
|
try {
|
|
result = await applyPlan(plan);
|
|
} finally {
|
|
spinner?.stop();
|
|
}
|
|
if (options.json) {
|
|
output.write(`${JSON.stringify({ dryRun: false, result }, null, 2)}\n`);
|
|
} else if (result.status === 'complete') {
|
|
output.write(`\nECC configured for ${result.completed.map(item => getHarnessCapability(item.id).label).join(', ')}.\n`);
|
|
renderWelcome({ action: 'installed', interactive, json: false, output });
|
|
} else {
|
|
const retry = buildRetryArguments(plan, result.retryHarnesses);
|
|
errorOutput.write(
|
|
`ECC stopped at ${sanitizeTerminalText(result.failure.id)}: `
|
|
+ `${sanitizeTerminalText(result.failure.message)}\n`
|
|
+ `Retry with: ecc-universal install --guided ${retry}\n`
|
|
);
|
|
}
|
|
return result.status === 'complete' ? 0 : 1;
|
|
} catch (error) {
|
|
const payload = { error: { code: 'GUIDED_INSTALL_FAILED', message: error.message } };
|
|
if (argv.includes('--json')) errorOutput.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
else errorOutput.write(`Error: ${sanitizeTerminalText(error.message)}\n`);
|
|
return 1;
|
|
} finally {
|
|
if (ownsTerminal) terminal?.close();
|
|
}
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main().then(code => {
|
|
process.exitCode = code;
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
collectInteractiveOptions,
|
|
main,
|
|
parseArgs,
|
|
printPlan,
|
|
showHelp,
|
|
validateExecutionMode,
|
|
};
|