mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-17 21:15:40 +02:00
* fix: disable Claude co-author attribution by default * fix: harden default co-author opt-out and correct the docs Follow-up on the co-author default in this PR. - Remove the existsSync/writeFileSync race in the installer settings write (CodeQL js/file-system-race, high). A single guarded read now covers the fresh-install case, and unreadable or non-object settings are left untouched. - Respect `attribution` as an explicit user choice. It supersedes `includeCoAuthoredBy` in Claude Code 2.1.x, so a user who configured it would otherwise have had a dead key written into their settings. - Share one opt-out rule via scripts/lib/claude-commit-attribution.js instead of duplicating it across the installer and plugin setup. - Update the git-workflow rule and its nine mirrors and translations, which still told users ECC does not ship this setting. We keep writing the deprecated `includeCoAuthoredBy` key rather than `attribution`: unknown keys fail Claude Code settings validation, so writing `attribution` would break users on older versions.
677 lines
20 KiB
JavaScript
677 lines
20 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { spawnSync } = require('child_process');
|
|
|
|
const { writeFileAtomic } = require('./atomic-write');
|
|
const {
|
|
hasExplicitCommitAttributionPreference,
|
|
withCommitAttributionDisabled,
|
|
} = require('./claude-commit-attribution');
|
|
const { normalizeGitHubGitOrigin } = require('./github-origin');
|
|
const {
|
|
CURRENT_PLUGIN_ID,
|
|
LEGACY_PLUGIN_IDS,
|
|
findManagedClaudeInstalls,
|
|
findManualClaudePlugin,
|
|
resolveClaudePaths,
|
|
} = require('./install/inventory');
|
|
|
|
const OFFICIAL_MARKETPLACE_NAME = 'ecc';
|
|
const OFFICIAL_MARKETPLACE_REPO = 'affaan-m/ecc';
|
|
const OFFICIAL_MARKETPLACE_URL = 'https://github.com/affaan-m/ECC';
|
|
const PROVIDER_COMMAND_TIMEOUT_MS = 120 * 1000;
|
|
const VALID_SCOPES = new Set(['user', 'project', 'local']);
|
|
const VALID_HOOK_MODES = new Set(['off', 'minimal', 'standard', 'strict']);
|
|
|
|
class ClaudeSetupError extends Error {
|
|
constructor(code, message, details = {}) {
|
|
super(message);
|
|
this.name = 'ClaudeSetupError';
|
|
this.code = code;
|
|
this.phase = details.phase || 'preflight';
|
|
this.observedScopes = [...(details.observedScopes || [])];
|
|
this.recovery = [...(details.recovery || [])];
|
|
}
|
|
|
|
toJSON() {
|
|
return {
|
|
error: {
|
|
code: this.code,
|
|
message: this.message,
|
|
phase: this.phase,
|
|
observedScopes: [...this.observedScopes],
|
|
recovery: [...this.recovery],
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
function fail(code, message, details) {
|
|
throw new ClaudeSetupError(code, message, details);
|
|
}
|
|
|
|
function normalizeGitHubRepository(value) {
|
|
if (typeof value !== 'string') return null;
|
|
const normalized = value.trim().replace(/\.git$/i, '').replace(/\/+$/, '');
|
|
const match = normalized.match(/^([^/]+\/[^/]+)$/);
|
|
return match ? match[1].toLowerCase() : null;
|
|
}
|
|
|
|
function normalizeMarketplaceRepository(marketplace) {
|
|
return marketplace?.source === 'github'
|
|
? normalizeGitHubRepository(marketplace.repo)
|
|
: normalizeGitHubGitOrigin(marketplace?.url);
|
|
}
|
|
|
|
function isOfficialMarketplace(marketplace) {
|
|
if (!marketplace || marketplace.name !== OFFICIAL_MARKETPLACE_NAME) return false;
|
|
return normalizeMarketplaceRepository(marketplace) === OFFICIAL_MARKETPLACE_REPO;
|
|
}
|
|
|
|
function parseJsonArray(stdout, label) {
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(String(stdout || ''));
|
|
} catch (error) {
|
|
fail(
|
|
`INVALID_${label.toUpperCase()}_INVENTORY`,
|
|
`Claude ${label} inventory returned invalid JSON: ${error.message}`
|
|
);
|
|
}
|
|
if (!Array.isArray(parsed)) {
|
|
fail(
|
|
`INVALID_${label.toUpperCase()}_INVENTORY`,
|
|
`Claude ${label} inventory is invalid: expected a JSON array`
|
|
);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function parsePluginList(stdout) {
|
|
const plugins = parseJsonArray(stdout, 'plugin');
|
|
for (const plugin of plugins) {
|
|
const isRelevant = plugin && (
|
|
plugin.id === CURRENT_PLUGIN_ID
|
|
|| String(plugin.id || '').startsWith('ecc@')
|
|
|| LEGACY_PLUGIN_IDS.has(plugin.id)
|
|
|| String(plugin.id || '').startsWith('everything-claude-code@')
|
|
);
|
|
if (!isRelevant) continue;
|
|
if (
|
|
typeof plugin.id !== 'string'
|
|
|| !VALID_SCOPES.has(plugin.scope)
|
|
|| typeof plugin.enabled !== 'boolean'
|
|
) {
|
|
fail(
|
|
'INVALID_PLUGIN_INVENTORY',
|
|
'Claude plugin inventory contains an invalid ECC plugin entry'
|
|
);
|
|
}
|
|
}
|
|
return plugins;
|
|
}
|
|
|
|
function parseMarketplaceList(stdout) {
|
|
const marketplaces = parseJsonArray(stdout, 'marketplace');
|
|
for (const marketplace of marketplaces) {
|
|
if (!marketplace || marketplace.name !== OFFICIAL_MARKETPLACE_NAME) continue;
|
|
if (
|
|
typeof marketplace.name !== 'string'
|
|
|| typeof marketplace.source !== 'string'
|
|
|| !['github', 'git'].includes(marketplace.source)
|
|
|| !normalizeMarketplaceRepository(marketplace)
|
|
) {
|
|
fail(
|
|
'INVALID_MARKETPLACE_INVENTORY',
|
|
'Claude marketplace inventory contains an invalid `ecc` entry'
|
|
);
|
|
}
|
|
}
|
|
return marketplaces;
|
|
}
|
|
|
|
const UNSAFE_WINDOWS_SHELL_CHARS = /[\r\n&|<>^%!]/;
|
|
|
|
function quoteWindowsCommandToken(value) {
|
|
const token = String(value);
|
|
if (UNSAFE_WINDOWS_SHELL_CHARS.test(token)) {
|
|
throw new Error('Claude Code command contains characters that are unsafe for cmd.exe');
|
|
}
|
|
if (token === '') return '""';
|
|
if (!/[\s"]/.test(token)) return token;
|
|
return `"${token.replace(/"/g, '""')}"`;
|
|
}
|
|
|
|
function buildWindowsCommandLine(command, args) {
|
|
return [command, ...args].map(quoteWindowsCommandToken).join(' ');
|
|
}
|
|
|
|
function resolveWindowsCmdShim(command, env) {
|
|
if (typeof command !== 'string' || command.length === 0) return null;
|
|
if (/\.(cmd|bat)$/i.test(command)) return command;
|
|
if (path.extname(command)) return null;
|
|
|
|
const isPathLike = path.isAbsolute(command)
|
|
|| command.includes('/')
|
|
|| command.includes('\\');
|
|
if (isPathLike) {
|
|
const candidate = `${command}.cmd`;
|
|
return fs.existsSync(candidate) ? candidate : null;
|
|
}
|
|
|
|
const lookup = spawnSync('where.exe', [`${command}.cmd`], {
|
|
env,
|
|
encoding: 'utf8',
|
|
windowsHide: true,
|
|
});
|
|
if (lookup.error || lookup.status !== 0) return null;
|
|
return String(lookup.stdout || '')
|
|
.split(/\r?\n/)
|
|
.map(line => line.trim())
|
|
.find(Boolean) || null;
|
|
}
|
|
|
|
function runClaude(args, options = {}, dependencies = {}) {
|
|
const command = options.command || 'claude';
|
|
const spawn = dependencies.spawnSync || spawnSync;
|
|
const timeoutMs = options.timeoutMs ?? PROVIDER_COMMAND_TIMEOUT_MS;
|
|
const spawnOptions = {
|
|
cwd: options.cwd || process.cwd(),
|
|
env: options.env || process.env,
|
|
encoding: 'utf8',
|
|
maxBuffer: 10 * 1024 * 1024,
|
|
killSignal: 'SIGKILL',
|
|
timeout: timeoutMs,
|
|
windowsHide: true,
|
|
};
|
|
let result = spawn(command, args, spawnOptions);
|
|
|
|
if (process.platform === 'win32' && result.error) {
|
|
const shim = resolveWindowsCmdShim(command, spawnOptions.env);
|
|
if (shim) {
|
|
let commandLine;
|
|
try {
|
|
commandLine = buildWindowsCommandLine(shim, args);
|
|
} catch (error) {
|
|
fail(
|
|
'CLAUDE_COMMAND_FAILED',
|
|
`Could not run Claude Code: ${error.message}`,
|
|
{ phase: options.phase || 'provider' }
|
|
);
|
|
}
|
|
result = spawn(commandLine, {
|
|
...spawnOptions,
|
|
shell: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
const timedOut = (
|
|
result.error?.code === 'ETIMEDOUT'
|
|
|| (result.error?.killed === true && result.error?.signal === spawnOptions.killSignal)
|
|
);
|
|
if (timedOut) {
|
|
fail(
|
|
'CLAUDE_COMMAND_FAILED',
|
|
`Claude Code command timed out after ${timeoutMs} ms`,
|
|
{ phase: options.phase || 'provider' }
|
|
);
|
|
}
|
|
if (result.error) {
|
|
if (result.error.code === 'ENOENT') {
|
|
fail(
|
|
'CLAUDE_NOT_FOUND',
|
|
'Claude Code is not installed or `claude` is not on PATH. Install Claude Code, then rerun ECC setup.',
|
|
{ phase: options.phase || 'inventory' }
|
|
);
|
|
}
|
|
fail(
|
|
'CLAUDE_COMMAND_FAILED',
|
|
`Could not run Claude Code: ${result.error.message}`,
|
|
{ phase: options.phase || 'provider' }
|
|
);
|
|
}
|
|
if (result.status !== 0) {
|
|
const detail = String(result.stderr || result.stdout || '').trim();
|
|
fail(
|
|
'CLAUDE_COMMAND_FAILED',
|
|
`Claude Code command failed${detail ? `: ${detail}` : ''}`,
|
|
{ phase: options.phase || 'provider' }
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function readSettings(settingsPath) {
|
|
if (!fs.existsSync(settingsPath)) return {};
|
|
let settings;
|
|
try {
|
|
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
} catch (error) {
|
|
fail(
|
|
'INVALID_CLAUDE_SETTINGS',
|
|
`Claude user settings are invalid at ${settingsPath}: ${error.message}`,
|
|
{ phase: 'preflight' }
|
|
);
|
|
}
|
|
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
|
|
fail(
|
|
'INVALID_CLAUDE_SETTINGS',
|
|
`Claude user settings are invalid at ${settingsPath}: expected a JSON object`,
|
|
{ phase: 'preflight' }
|
|
);
|
|
}
|
|
const pluginConfigs = settings.pluginConfigs;
|
|
if (pluginConfigs !== undefined && (
|
|
!pluginConfigs
|
|
|| typeof pluginConfigs !== 'object'
|
|
|| Array.isArray(pluginConfigs)
|
|
)) {
|
|
fail(
|
|
'INVALID_CLAUDE_SETTINGS',
|
|
`Claude user settings are invalid at ${settingsPath}: pluginConfigs must be an object`,
|
|
{ phase: 'preflight' }
|
|
);
|
|
}
|
|
const eccConfig = pluginConfigs?.[CURRENT_PLUGIN_ID];
|
|
if (eccConfig !== undefined && (
|
|
!eccConfig
|
|
|| typeof eccConfig !== 'object'
|
|
|| Array.isArray(eccConfig)
|
|
)) {
|
|
fail(
|
|
'INVALID_CLAUDE_SETTINGS',
|
|
`Claude user settings are invalid at ${settingsPath}: ${CURRENT_PLUGIN_ID} config must be an object`,
|
|
{ phase: 'preflight' }
|
|
);
|
|
}
|
|
if (eccConfig?.options !== undefined && (
|
|
!eccConfig.options
|
|
|| typeof eccConfig.options !== 'object'
|
|
|| Array.isArray(eccConfig.options)
|
|
)) {
|
|
fail(
|
|
'INVALID_CLAUDE_SETTINGS',
|
|
`Claude user settings are invalid at ${settingsPath}: ${CURRENT_PLUGIN_ID} options must be an object`,
|
|
{ phase: 'preflight' }
|
|
);
|
|
}
|
|
return settings;
|
|
}
|
|
|
|
function hookOptions(hooks) {
|
|
return {
|
|
hooks_enabled: hooks !== 'off',
|
|
hook_profile: hooks === 'off' ? 'standard' : hooks,
|
|
};
|
|
}
|
|
|
|
function readStoredHookOptions(settings) {
|
|
const options = settings.pluginConfigs?.[CURRENT_PLUGIN_ID]?.options || {};
|
|
return {
|
|
hooks_enabled: options.hooks_enabled !== false,
|
|
hook_profile: VALID_HOOK_MODES.has(options.hook_profile)
|
|
&& options.hook_profile !== 'off'
|
|
? options.hook_profile
|
|
: 'standard',
|
|
};
|
|
}
|
|
|
|
function deriveHookMode(settings) {
|
|
const options = readStoredHookOptions(settings);
|
|
return options.hooks_enabled ? options.hook_profile : 'off';
|
|
}
|
|
|
|
function withClaudeCommitAttributionPreference(settings) {
|
|
return withCommitAttributionDisabled(settings);
|
|
}
|
|
|
|
function needsClaudeCommitAttributionPreferenceWrite(settings) {
|
|
return !hasExplicitCommitAttributionPreference(settings);
|
|
}
|
|
|
|
function writeClaudePluginOptions(settingsPath, hooks) {
|
|
const settings = readSettings(settingsPath);
|
|
const pluginConfigs = settings.pluginConfigs || {};
|
|
const eccConfig = pluginConfigs[CURRENT_PLUGIN_ID] || {};
|
|
const options = eccConfig.options || {};
|
|
const nextOptions = hooks === undefined
|
|
? { ...options }
|
|
: {
|
|
...options,
|
|
...hookOptions(hooks),
|
|
};
|
|
const nextSettings = {
|
|
...withClaudeCommitAttributionPreference(settings),
|
|
pluginConfigs: {
|
|
...pluginConfigs,
|
|
[CURRENT_PLUGIN_ID]: {
|
|
...eccConfig,
|
|
options: nextOptions,
|
|
},
|
|
},
|
|
};
|
|
writeFileAtomic(settingsPath, `${JSON.stringify(nextSettings, null, 2)}\n`);
|
|
return settingsPath;
|
|
}
|
|
|
|
function currentEccPlugins(plugins) {
|
|
return plugins.filter(plugin => plugin?.id === CURRENT_PLUGIN_ID);
|
|
}
|
|
|
|
function assertNoConflictingEccPlugins(plugins) {
|
|
const legacy = plugins.find(plugin => (
|
|
LEGACY_PLUGIN_IDS.has(plugin?.id)
|
|
|| String(plugin?.id || '').startsWith('everything-claude-code@')
|
|
));
|
|
if (legacy) {
|
|
fail(
|
|
'LEGACY_PLUGIN_INSTALLED',
|
|
`Legacy plugin ${legacy.id} is installed. Uninstall it before setting up ${CURRENT_PLUGIN_ID}.`,
|
|
{
|
|
observedScopes: [legacy.scope],
|
|
recovery: [`claude plugin uninstall ${legacy.id} --scope ${legacy.scope} --keep-data`],
|
|
}
|
|
);
|
|
}
|
|
|
|
const conflictingEcc = plugins.find(plugin => (
|
|
typeof plugin?.id === 'string'
|
|
&& plugin.id.startsWith('ecc@')
|
|
&& plugin.id !== CURRENT_PLUGIN_ID
|
|
));
|
|
if (conflictingEcc) {
|
|
fail(
|
|
'DUPLICATE_ECC_PLUGIN',
|
|
`${conflictingEcc.id} is already installed and would duplicate ECC surfaces. Uninstall it before setting up ${CURRENT_PLUGIN_ID}.`,
|
|
{
|
|
observedScopes: [conflictingEcc.scope],
|
|
recovery: [
|
|
`claude plugin uninstall ${conflictingEcc.id} --scope ${conflictingEcc.scope} --keep-data`,
|
|
],
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
function inspectPluginInventory(plugins, requestedScope) {
|
|
assertNoConflictingEccPlugins(plugins);
|
|
const installed = currentEccPlugins(plugins);
|
|
const observedScopes = installed.map(plugin => plugin.scope);
|
|
if (installed.length > 1 || new Set(observedScopes).size !== observedScopes.length) {
|
|
fail(
|
|
'MULTIPLE_PLUGIN_SCOPES',
|
|
`${CURRENT_PLUGIN_ID} is installed in multiple scopes. Resolve the duplicate scopes before setup.`,
|
|
{ observedScopes }
|
|
);
|
|
}
|
|
|
|
if (!requestedScope && installed.length === 0) {
|
|
fail(
|
|
'SCOPE_REQUIRED',
|
|
'A fresh install requires --scope user, project, or local.'
|
|
);
|
|
}
|
|
|
|
const scope = requestedScope || installed[0].scope;
|
|
if (!VALID_SCOPES.has(scope)) {
|
|
fail('INVALID_SCOPE', `Invalid plugin scope: ${scope}`);
|
|
}
|
|
if (installed.length === 1 && installed[0].scope !== scope) {
|
|
fail(
|
|
'SCOPE_MOVE_REQUIRED',
|
|
`${CURRENT_PLUGIN_ID} is already installed at ${installed[0].scope} scope. Use the scope migration workflow to move it to ${scope}.`,
|
|
{
|
|
observedScopes,
|
|
recovery: [
|
|
`ecc setup --mode claude-plugin --scope ${scope} --move-scope --yes`,
|
|
],
|
|
}
|
|
);
|
|
}
|
|
|
|
return {
|
|
installed: installed[0] || null,
|
|
observedScopes,
|
|
scope,
|
|
};
|
|
}
|
|
|
|
function assertSafeLocalInventory(options) {
|
|
const manual = findManualClaudePlugin(options);
|
|
if (manual) {
|
|
fail(
|
|
'MANUAL_PLUGIN_INSTALL',
|
|
`A manual ECC plugin layout exists at ${manual.manifestPath}. Remove or migrate the manual install before setup.`
|
|
);
|
|
}
|
|
let managedInstalls;
|
|
try {
|
|
managedInstalls = findManagedClaudeInstalls(options);
|
|
} catch (error) {
|
|
fail('INVALID_MANAGED_STATE', error.message);
|
|
}
|
|
const overlap = managedInstalls.find(install => install.overlapsPlugin);
|
|
if (overlap) {
|
|
fail(
|
|
'MANAGED_INSTALL_OVERLAP',
|
|
`Managed ECC content at ${overlap.statePath} overlaps the Claude plugin. Remove that managed overlap before setup.`
|
|
);
|
|
}
|
|
return managedInstalls;
|
|
}
|
|
|
|
function ensureOfficialMarketplace(options) {
|
|
const run = options.run || runClaude;
|
|
const existing = options.marketplaces.find(entry => entry?.name === OFFICIAL_MARKETPLACE_NAME);
|
|
if (existing && !isOfficialMarketplace(existing)) {
|
|
fail(
|
|
'MARKETPLACE_COLLISION',
|
|
'Refusing the `ecc` marketplace collision because it is not the official affaan-m/ECC source.'
|
|
);
|
|
}
|
|
|
|
if (existing) {
|
|
run(
|
|
['plugin', 'marketplace', 'update', OFFICIAL_MARKETPLACE_NAME],
|
|
{ cwd: options.projectRoot, phase: 'marketplace' }
|
|
);
|
|
} else {
|
|
run(
|
|
[
|
|
'plugin', 'marketplace', 'add',
|
|
OFFICIAL_MARKETPLACE_URL,
|
|
'--scope', options.scope,
|
|
],
|
|
{ cwd: options.projectRoot, phase: 'marketplace' }
|
|
);
|
|
}
|
|
|
|
const verified = parseMarketplaceList(
|
|
run(
|
|
['plugin', 'marketplace', 'list', '--json'],
|
|
{ cwd: options.projectRoot, phase: 'marketplace-verification' }
|
|
).stdout
|
|
).find(entry => entry?.name === OFFICIAL_MARKETPLACE_NAME);
|
|
if (!verified || !isOfficialMarketplace(verified)) {
|
|
fail(
|
|
'MARKETPLACE_VERIFICATION_FAILED',
|
|
'Could not verify the official ECC marketplace after the marketplace change.',
|
|
{ phase: 'marketplace-verification' }
|
|
);
|
|
}
|
|
return verified;
|
|
}
|
|
|
|
function verifyPluginAtScope(options) {
|
|
const run = options.run || runClaude;
|
|
const plugins = parsePluginList(
|
|
run(
|
|
['plugin', 'list', '--json'],
|
|
{ cwd: options.projectRoot, phase: options.phase || 'plugin-verification' }
|
|
).stdout
|
|
);
|
|
const installed = currentEccPlugins(plugins);
|
|
const valid = (
|
|
installed.length === 1
|
|
&& installed[0].scope === options.scope
|
|
&& installed[0].enabled === true
|
|
);
|
|
if (!valid) {
|
|
fail(
|
|
'PLUGIN_VERIFICATION_FAILED',
|
|
`Could not verify ${CURRENT_PLUGIN_ID} as enabled only at ${options.scope} scope.`,
|
|
{
|
|
phase: options.phase || 'plugin-verification',
|
|
observedScopes: installed.map(plugin => plugin.scope),
|
|
}
|
|
);
|
|
}
|
|
return installed[0];
|
|
}
|
|
|
|
function ensurePluginAtScope(options) {
|
|
const run = options.run || runClaude;
|
|
const configuredHooks = options.hookConfiguration || hookOptions(options.hooks);
|
|
if (options.installed) {
|
|
run(
|
|
['plugin', 'update', CURRENT_PLUGIN_ID, '--scope', options.scope],
|
|
{ cwd: options.projectRoot, phase: 'plugin-update' }
|
|
);
|
|
return 'updated';
|
|
}
|
|
run(
|
|
[
|
|
'plugin', 'install', CURRENT_PLUGIN_ID,
|
|
'--scope', options.scope,
|
|
'--config', `hooks_enabled=${configuredHooks.hooks_enabled}`,
|
|
'--config', `hook_profile=${configuredHooks.hook_profile}`,
|
|
],
|
|
{ cwd: options.projectRoot, phase: 'plugin-install' }
|
|
);
|
|
return 'installed';
|
|
}
|
|
|
|
function setupClaudePlugin(options = {}, dependencies = {}) {
|
|
const paths = resolveClaudePaths(options);
|
|
if (options.hooks !== undefined && !VALID_HOOK_MODES.has(options.hooks)) {
|
|
fail('INVALID_HOOK_MODE', `Invalid hook mode: ${options.hooks}`);
|
|
}
|
|
if (options.scope !== undefined && !VALID_SCOPES.has(options.scope)) {
|
|
fail('INVALID_SCOPE', `Invalid plugin scope: ${options.scope}`);
|
|
}
|
|
|
|
const settingsPath = path.join(paths.configDir, 'settings.json');
|
|
const initialSettings = readSettings(settingsPath);
|
|
assertSafeLocalInventory(paths);
|
|
|
|
const run = dependencies.runClaude || runClaude;
|
|
const plugins = parsePluginList(
|
|
run(
|
|
['plugin', 'list', '--json'],
|
|
{ cwd: paths.projectRoot, phase: 'inventory' }
|
|
).stdout
|
|
);
|
|
const inventory = inspectPluginInventory(plugins, options.scope);
|
|
const hooks = options.hooks === undefined && inventory.installed
|
|
? deriveHookMode(initialSettings)
|
|
: (options.hooks || 'standard');
|
|
const marketplaces = parseMarketplaceList(
|
|
run(
|
|
['plugin', 'marketplace', 'list', '--json'],
|
|
{ cwd: paths.projectRoot, phase: 'marketplace-inventory' }
|
|
).stdout
|
|
);
|
|
const namedMarketplace = marketplaces.find(entry => (
|
|
entry?.name === OFFICIAL_MARKETPLACE_NAME
|
|
));
|
|
if (namedMarketplace && !isOfficialMarketplace(namedMarketplace)) {
|
|
fail(
|
|
'MARKETPLACE_COLLISION',
|
|
'Refusing the `ecc` marketplace collision because it is not the official affaan-m/ECC source.'
|
|
);
|
|
}
|
|
|
|
if (options.dryRun) {
|
|
return {
|
|
action: inventory.installed ? 'would-update' : 'would-install',
|
|
dryRun: true,
|
|
hooks,
|
|
marketplaceAction: namedMarketplace ? 'would-update' : 'would-add',
|
|
pluginId: CURRENT_PLUGIN_ID,
|
|
scope: inventory.scope,
|
|
};
|
|
}
|
|
|
|
ensureOfficialMarketplace({
|
|
marketplaces,
|
|
projectRoot: paths.projectRoot,
|
|
run,
|
|
scope: inventory.scope,
|
|
});
|
|
const action = ensurePluginAtScope({
|
|
hooks,
|
|
installed: inventory.installed,
|
|
projectRoot: paths.projectRoot,
|
|
run,
|
|
scope: inventory.scope,
|
|
});
|
|
verifyPluginAtScope({
|
|
phase: 'plugin-verification',
|
|
projectRoot: paths.projectRoot,
|
|
run,
|
|
scope: inventory.scope,
|
|
});
|
|
const hooksToPersist = options.hooks !== undefined || !inventory.installed
|
|
? hooks
|
|
: undefined;
|
|
if (
|
|
options.hooks !== undefined
|
|
|| !inventory.installed
|
|
|| needsClaudeCommitAttributionPreferenceWrite(initialSettings)
|
|
) {
|
|
writeClaudePluginOptions(settingsPath, hooksToPersist);
|
|
}
|
|
|
|
return {
|
|
action,
|
|
hooks,
|
|
pluginId: CURRENT_PLUGIN_ID,
|
|
restartRequired: true,
|
|
scope: inventory.scope,
|
|
settingsPath,
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
ClaudeSetupError,
|
|
CURRENT_PLUGIN_ID,
|
|
OFFICIAL_MARKETPLACE_NAME,
|
|
OFFICIAL_MARKETPLACE_URL,
|
|
PROVIDER_COMMAND_TIMEOUT_MS,
|
|
VALID_HOOK_MODES,
|
|
VALID_SCOPES,
|
|
buildWindowsCommandLine,
|
|
assertNoConflictingEccPlugins,
|
|
assertSafeLocalInventory,
|
|
currentEccPlugins,
|
|
deriveHookMode,
|
|
ensureOfficialMarketplace,
|
|
ensurePluginAtScope,
|
|
hookOptions,
|
|
inspectPluginInventory,
|
|
isOfficialMarketplace,
|
|
parseMarketplaceList,
|
|
parsePluginList,
|
|
readStoredHookOptions,
|
|
readSettings,
|
|
runClaude,
|
|
setupClaudePlugin,
|
|
verifyPluginAtScope,
|
|
needsClaudeCommitAttributionPreferenceWrite,
|
|
withClaudeCommitAttributionPreference,
|
|
writeClaudePluginOptions,
|
|
};
|