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>
505 lines
14 KiB
JavaScript
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,
|
|
};
|