Files
ECC/scripts/setup.js
28e53a0bc1 feat(install): add guided multi-harness installer (#2649)
* 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>
2026-08-06 15:39:49 -04:00

505 lines
14 KiB
JavaScript

#!/usr/bin/env node
'use strict';
const path = require('path');
const readline = require('readline/promises');
const {
ClaudeSetupError,
VALID_HOOK_MODES,
VALID_SCOPES,
deriveHookMode,
readSettings,
setupClaudePlugin,
} = require('./lib/claude-plugin-setup');
const {
migrateClaudePluginScope,
} = require('./lib/claude-scope-migration');
const { resolveClaudePaths } = require('./lib/install/inventory');
const { startTerminalSpinner } = require('./lib/terminal-spinner');
const { showTerminalWelcome } = require('./lib/terminal-welcome');
const MODE = 'claude-plugin';
const AUTO_MIGRATION_CODES = new Set([
'MULTIPLE_PLUGIN_SCOPES',
'SCOPE_MOVE_REQUIRED',
]);
function showHelp() {
process.stdout.write(`
ECC guided setup
Usage:
ecc setup
ecc setup --mode claude-plugin --scope user|project|local [options]
ecc setup --mode claude-plugin --scope project --move-scope [options]
Install scopes:
user Global for this user; ECC is available in every project.
project Shared project configuration; the repository can enable ECC for collaborators.
local Private project configuration; ECC is enabled here without committing the choice.
Hook preferences:
--hooks off|minimal|standard|strict
Save a personal hook preference in Claude user settings.
Options:
--mode claude-plugin
--scope <scope>
--hooks <preference>
--move-scope Explicitly request migration (normally auto-detected).
--yes, -y Skip the confirmation prompt.
--dry-run Inspect and report without changing anything.
--json Emit machine-readable JSON.
--help, -h Show this help.
Re-running setup updates an existing ecc@ecc installation at its detected scope.
Choosing another scope automatically migrates the existing installation.
Migration installs and verifies the destination before removing the source scope.
`);
}
function parseArgs(argv) {
const options = {
dryRun: false,
help: false,
hooks: undefined,
json: false,
mode: undefined,
moveScope: false,
scope: undefined,
yes: false,
};
const valueFlags = new Map([
['--mode', 'mode'],
['--scope', 'scope'],
['--hooks', 'hooks'],
]);
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}`);
}
options[valueFlags.get(argument)] = value;
index += 1;
} else if (argument === '--yes' || argument === '-y') {
options.yes = true;
} else if (argument === '--dry-run') {
options.dryRun = true;
} else if (argument === '--move-scope') {
options.moveScope = true;
} else if (argument === '--json') {
options.json = true;
} else if (argument === '--help' || argument === '-h') {
options.help = true;
} else {
throw new Error(`Unknown argument: ${argument}`);
}
}
if (options.mode !== undefined && options.mode !== MODE) {
throw new Error(`Invalid setup mode: ${options.mode}. This command currently supports ${MODE}.`);
}
if (options.scope !== undefined && !VALID_SCOPES.has(options.scope)) {
throw new Error(`Invalid --scope value: ${options.scope}`);
}
if (options.hooks !== undefined && !VALID_HOOK_MODES.has(options.hooks)) {
throw new Error(`Invalid --hooks value: ${options.hooks}`);
}
if (options.moveScope && options.scope === undefined) {
throw new Error('--move-scope requires an explicit --scope destination.');
}
return options;
}
function questionWithCancellation(terminal, prompt) {
return new Promise((resolve, reject) => {
let settled = false;
const finish = callback => value => {
if (settled) return;
settled = true;
terminal.removeListener('close', onClose);
callback(value);
};
const onClose = finish(() => {
const error = new Error('Readline was closed before an answer was received.');
error.code = 'ABORT_ERR';
reject(error);
});
const resolveAnswer = finish(resolve);
const rejectQuestion = finish(reject);
terminal.once('close', onClose);
Promise.resolve(terminal.question(prompt)).then(resolveAnswer, rejectQuestion);
});
}
async function askChoice(terminal, prompt, choices, defaultIndex) {
process.stdout.write(`\n${prompt}\n`);
choices.forEach((choice, index) => {
process.stdout.write(` ${index + 1}. ${choice.label}${choice.description}\n`);
});
const choiceNumbers = choices.map((_, index) => String(index + 1));
const validChoices = choiceNumbers.length === 1
? choiceNumbers[0]
: `${choiceNumbers.slice(0, -1).join(', ')}, or ${choiceNumbers.at(-1)}`;
while (true) {
const hasDefault = Number.isInteger(defaultIndex);
const answer = await questionWithCancellation(
terminal,
hasDefault ? `Choose [${defaultIndex + 1}]: ` : 'Choose: '
);
const normalized = answer.trim().toLowerCase();
if (normalized === '' && hasDefault) return choices[defaultIndex].value;
const namedChoice = choices.find(choice => choice.value === normalized);
if (namedChoice) return namedChoice.value;
if (/^\d+$/.test(normalized)) {
const index = Number(normalized) - 1;
if (index >= 0 && index < choices.length) return choices[index].value;
}
process.stdout.write(`Please choose ${validChoices}.\n`);
}
}
function resolveInteractiveDefaults() {
try {
const result = setupClaudePlugin({ dryRun: true });
return {
hooks: result.hooks,
installed: result.action === 'would-update',
scope: result.scope,
};
} catch (error) {
if (!(error instanceof ClaudeSetupError)) throw error;
if (error.code === 'SCOPE_REQUIRED') {
return {
hooks: 'standard',
installed: false,
scope: 'user',
};
}
if (error.code === 'MULTIPLE_PLUGIN_SCOPES') {
const paths = resolveClaudePaths();
return {
hooks: deriveHookMode(readSettings(path.join(paths.configDir, 'settings.json'))),
installed: true,
multipleScopes: true,
scope: undefined,
};
}
throw error;
}
}
async function collectInteractiveOptions(options, defaults = {}, providedTerminal) {
const terminal = providedTerminal || readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const ownsTerminal = !providedTerminal;
try {
const scopeChoices = [
{
value: 'user',
label: 'Global user',
description: 'Available in every project for this user.',
},
{
value: 'project',
label: 'Shared project',
description: 'Stored in repository settings for collaborators.',
},
{
value: 'local',
label: 'Private project',
description: 'Enabled only here without committing the choice.',
},
];
const detectedScopeDefault = scopeChoices.findIndex(
choice => choice.value === defaults.scope
);
const scopeDefaultIndex = detectedScopeDefault === -1
? undefined
: detectedScopeDefault;
const scope = options.scope || await askChoice(
terminal,
'Where should Claude enable ecc@ecc?',
scopeChoices,
scopeDefaultIndex
);
const hookChoices = [
{
value: 'off',
label: 'Off',
description: 'Keep skills and commands without local hook automation.',
},
{
value: 'minimal',
label: 'Minimal',
description: 'Run only the lightest lifecycle and safety automation.',
},
{
value: 'standard',
label: 'Standard',
description: 'Balanced quality and safety automation.',
},
{
value: 'strict',
label: 'Strict',
description: 'Use the strongest checks and reminders.',
},
];
const detectedHookDefault = hookChoices.findIndex(
choice => choice.value === defaults.hooks
);
const hookDefaultIndex = detectedHookDefault === -1 ? 2 : detectedHookDefault;
const hooks = options.hooks || await askChoice(
terminal,
'How should ECC hooks run?',
hookChoices,
hookDefaultIndex
);
return {
...options,
hooks,
mode: MODE,
scope,
};
} finally {
if (ownsTerminal) terminal.close();
}
}
async function confirm(options, providedTerminal) {
const terminal = providedTerminal || readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const ownsTerminal = !providedTerminal;
try {
const operation = options.moveScope
? 'Migrate'
: (options.confirmationAction || 'Apply');
const scopeLabel = options.scope || 'the detected';
const answer = await questionWithCancellation(
terminal,
`${operation} ${MODE} setup at ${scopeLabel} scope`
+ ` with hooks=${options.hooks || 'standard'}? [y/N] `
);
return /^y(es)?$/i.test(answer.trim());
} finally {
if (ownsTerminal) terminal.close();
}
}
function printResult(result, json) {
if (json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return;
}
process.stdout.write(`\nECC ${result.action} ${result.pluginId} at ${result.scope} scope.\n`);
if (result.sourceScope) {
process.stdout.write(`Previous scope: ${result.sourceScope}\n`);
}
process.stdout.write(`Hook preference: ${result.hooks}\n`);
if (result.restartRequired) {
process.stdout.write('Restart Claude Code or run /reload-plugins to load the updated plugin.\n');
}
}
function printError(error, json) {
if (json) {
const payload = error instanceof ClaudeSetupError
? error.toJSON()
: {
error: {
code: 'SETUP_FAILED',
message: error.message,
phase: 'cli',
observedScopes: [],
recovery: [],
},
};
process.stderr.write(`${JSON.stringify(payload, null, 2)}\n`);
return;
}
process.stderr.write(`Error: ${error.message}\n`);
}
function isInteractiveCancellation(error) {
return Boolean(error && (
error.code === 'ABORT_ERR'
|| /aborted with ctrl\+d|readline was closed/i.test(error.message || '')
));
}
function needsInteractiveChoices(options) {
return (
options.mode === undefined
|| options.scope === undefined
|| options.hooks === undefined
);
}
function validateInteractiveJsonOptions(options, interactive) {
if (!interactive || !options.json) return;
if (needsInteractiveChoices(options)) {
throw new Error(
'Interactive --json requires explicit --mode, --scope, and --hooks values.'
);
}
if (!options.yes && !options.dryRun) {
throw new Error('Interactive --json mutations require --yes.');
}
}
function reconcileClaudePlugin(options) {
const setupOptions = {
dryRun: options.dryRun,
hooks: options.hooks,
scope: options.scope,
};
if (options.moveScope) {
return migrateClaudePluginScope(setupOptions);
}
try {
return setupClaudePlugin(setupOptions);
} catch (error) {
const canAutoMigrate = (
error instanceof ClaudeSetupError
&& AUTO_MIGRATION_CODES.has(error.code)
&& options.scope !== undefined
);
if (!canAutoMigrate) throw error;
return migrateClaudePluginScope(setupOptions);
}
}
function applyClaudePlugin(options, interactive) {
const spinner = interactive && !options.dryRun && !options.json
? startTerminalSpinner('Applying ECC setup...')
: undefined;
try {
return reconcileClaudePlugin(options);
} finally {
spinner?.stop();
}
}
async function main(argv = process.argv.slice(2)) {
let options;
let terminal;
try {
options = parseArgs(argv);
if (options.help) {
showHelp();
return;
}
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
validateInteractiveJsonOptions(options, interactive);
const shouldCollectInteractiveChoices = needsInteractiveChoices(options);
const needsConfirmation = !options.yes && !options.dryRun;
const interactiveDefaults = interactive
&& (shouldCollectInteractiveChoices || needsConfirmation)
? resolveInteractiveDefaults()
: undefined;
if (interactive && shouldCollectInteractiveChoices) {
terminal = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
options = await collectInteractiveOptions(
options,
interactiveDefaults,
terminal
);
} else if (!options.mode) {
if (!interactive) {
throw new Error(
'Interactive setup requires a terminal. Pass --mode claude-plugin and the required flags.'
);
}
}
if (interactiveDefaults) {
const confirmationAction = interactiveDefaults.multipleScopes
? 'Resume migration'
: (
interactiveDefaults.installed && interactiveDefaults.scope !== options.scope
? 'Migrate'
: 'Apply'
);
options = {
...options,
confirmationAction,
};
}
if (needsConfirmation) {
if (!interactive) {
throw new Error('Non-interactive setup requires --yes.');
}
if (!terminal) {
terminal = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
}
if (!await confirm(options, terminal)) {
printResult({
action: 'cancelled',
hooks: options.hooks || 'standard',
pluginId: 'ecc@ecc',
scope: options.scope || 'detected',
}, options.json);
return;
}
}
const result = applyClaudePlugin(options, interactive);
printResult(result, options.json);
showTerminalWelcome({
action: result.action,
dryRun: options.dryRun,
interactive,
json: options.json,
});
} catch (error) {
if (isInteractiveCancellation(error)) {
process.stdout.write('\nECC setup cancelled. No changes were made.\n');
return;
}
printError(error, options?.json);
process.exitCode = 1;
} finally {
terminal?.close();
}
}
if (require.main === module) {
main();
}
module.exports = {
collectInteractiveOptions,
applyClaudePlugin,
main,
parseArgs,
printError,
printResult,
questionWithCancellation,
reconcileClaudePlugin,
resolveInteractiveDefaults,
isInteractiveCancellation,
validateInteractiveJsonOptions,
showHelp,
};