mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-08 07:37:48 +02:00
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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
623f2c020f
commit
28e53a0bc1
@@ -0,0 +1,504 @@
|
||||
#!/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,
|
||||
};
|
||||
Reference in New Issue
Block a user