Files
ECC/scripts/lib/harness-capabilities.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

361 lines
11 KiB
JavaScript

const path = require('path');
const { SUPPORTED_INSTALL_TARGETS } = require('./install-manifests');
const { listInstallTargetAdapters } = require('./install-targets/registry');
function deepFreeze(value) {
if (!value || typeof value !== 'object' || Object.isFrozen(value)) {
return value;
}
for (const child of Object.values(value)) {
deepFreeze(child);
}
return Object.freeze(value);
}
function scope(id, targetId, root) {
return { id, targetId, root };
}
function hooks(mode, eccConfigured, note) {
return {
mode,
eccConfigured,
note,
summary: note,
};
}
const HARNESS_CAPABILITIES = deepFreeze([
{
id: 'claude',
label: 'Claude Code',
targetIds: ['claude', 'claude-project'],
channel: 'native-plugin',
installMode: 'native-plugin',
guidedReady: true,
availability: 'guided',
destination: 'Selected Claude plugin scope: ~/.claude or ./.claude',
scopes: [
scope('user', 'claude', '~/.claude'),
scope('project', 'claude-project', './.claude'),
scope('local', 'claude-project', './.claude'),
],
hooks: hooks(
'profile-selection',
true,
'ECC hooks are configured through the selected off, minimal, standard, or strict profile.'
),
aliases: ['claude-code'],
},
{
id: 'codex',
label: 'Codex',
targetIds: ['codex'],
channel: 'native-plugin',
installMode: 'native-plugin',
guidedReady: true,
availability: 'guided',
destination: '~/.codex through the Codex native plugin lifecycle',
scopes: [scope('native', 'codex', '~/.codex')],
hooks: hooks(
'native-trust',
true,
'ECC hooks use Codex native plugin discovery and remain subject to Codex review and trust.'
),
aliases: ['openai-codex'],
},
{
id: 'kimi',
label: 'Kimi Code',
targetIds: ['kimi'],
channel: 'managed-project',
installMode: 'managed-project',
guidedReady: true,
availability: 'guided',
destination: './.kimi-code',
scopes: [scope('project', 'kimi', './.kimi-code')],
hooks: hooks(
'not-configured',
false,
'ECC hooks are not configured for the Kimi managed-project install.'
),
aliases: ['kimi-code'],
},
{
id: 'cursor',
label: 'Cursor',
targetIds: ['cursor'],
channel: 'managed-project',
installMode: 'managed-project',
guidedReady: false,
availability: 'advanced',
destination: './.cursor',
scopes: [scope('project', 'cursor', './.cursor')],
hooks: hooks(
'adapter-configured',
true,
'ECC hooks use the Cursor project adapter and Cursor event configuration.'
),
aliases: [],
},
{
id: 'antigravity',
label: 'Antigravity',
targetIds: ['antigravity'],
channel: 'managed-project',
installMode: 'managed-project',
guidedReady: false,
availability: 'advanced',
destination: './.agent',
scopes: [scope('project', 'antigravity', './.agent')],
hooks: hooks('not-configured', false, 'ECC hooks are not configured by this adapter.'),
aliases: ['google-antigravity'],
},
{
id: 'gemini',
label: 'Gemini CLI',
targetIds: ['gemini'],
channel: 'managed-project',
installMode: 'managed-project',
guidedReady: false,
availability: 'advanced',
destination: './.gemini',
scopes: [scope('project', 'gemini', './.gemini')],
hooks: hooks('not-configured', false, 'ECC hooks are not configured by this adapter.'),
aliases: ['gemini-cli'],
},
{
id: 'opencode',
label: 'OpenCode',
targetIds: ['opencode'],
channel: 'managed-home',
installMode: 'managed-home',
guidedReady: false,
availability: 'advanced',
destination: '~/.opencode',
scopes: [scope('home', 'opencode', '~/.opencode')],
hooks: hooks(
'adapter-opt-in',
false,
'ECC hook runtime support is available through the OpenCode adapter but is not installed by default.'
),
aliases: ['open-code'],
},
{
id: 'codebuddy',
label: 'CodeBuddy',
targetIds: ['codebuddy'],
channel: 'managed-project',
installMode: 'managed-project',
guidedReady: false,
availability: 'advanced',
destination: './.codebuddy',
scopes: [scope('project', 'codebuddy', './.codebuddy')],
hooks: hooks(
'managed-files',
true,
'ECC hook runtime files are installed through the CodeBuddy project adapter.'
),
aliases: ['code-buddy'],
},
{
id: 'joycode',
label: 'JoyCode',
targetIds: ['joycode'],
channel: 'managed-project',
installMode: 'managed-project',
guidedReady: false,
availability: 'advanced',
destination: './.joycode',
scopes: [scope('project', 'joycode', './.joycode')],
hooks: hooks('not-configured', false, 'ECC hooks are not configured by this adapter.'),
aliases: ['joy-code'],
},
{
id: 'qwen',
label: 'Qwen Code',
targetIds: ['qwen'],
channel: 'managed-home',
installMode: 'managed-home',
guidedReady: false,
availability: 'advanced',
destination: '~/.qwen',
scopes: [scope('home', 'qwen', '~/.qwen')],
hooks: hooks('not-configured', false, 'ECC hooks are not configured by this adapter.'),
aliases: ['qwen-code'],
},
{
id: 'zed',
label: 'Zed',
targetIds: ['zed'],
channel: 'managed-project',
installMode: 'managed-project',
guidedReady: false,
availability: 'advanced',
destination: './.zed',
scopes: [scope('project', 'zed', './.zed')],
hooks: hooks('not-configured', false, 'ECC hooks are not configured by this adapter.'),
aliases: [],
},
{
id: 'hermes',
label: 'Hermes',
targetIds: ['hermes'],
channel: 'managed-home',
installMode: 'managed-home',
guidedReady: false,
availability: 'advanced',
destination: '~/.hermes',
scopes: [scope('home', 'hermes', '~/.hermes')],
hooks: hooks('not-configured', false, 'ECC hooks are not configured by this adapter.'),
aliases: ['hermes-agent'],
},
{
id: 'openclaw',
label: 'OpenClaw',
targetIds: ['openclaw'],
channel: 'managed-home',
installMode: 'managed-home',
guidedReady: false,
availability: 'advanced',
destination: '~/.openclaw',
scopes: [scope('home', 'openclaw', '~/.openclaw')],
hooks: hooks('not-configured', false, 'ECC hooks are not configured by this adapter.'),
aliases: ['open-claw'],
},
]);
const GUIDED_HARNESS_IDS = deepFreeze(
HARNESS_CAPABILITIES
.filter(harness => harness.guidedReady)
.map(harness => harness.id)
);
function normalizeLookupToken(value) {
return String(value).trim().toLowerCase().replace(/[\s_]+/g, '-');
}
const LOOKUP = new Map();
for (const harness of HARNESS_CAPABILITIES) {
const keys = [harness.id, harness.label, ...harness.targetIds, ...harness.aliases];
for (const key of keys) {
LOOKUP.set(normalizeLookupToken(key), harness);
}
}
function expectedRootForAdapter(adapter) {
const homeDir = path.resolve('/__ecc_catalog_home__');
const projectRoot = path.resolve('/__ecc_catalog_project__');
const absoluteRoot = adapter.resolveRoot({ homeDir, projectRoot });
const baseRoot = adapter.kind === 'home' ? homeDir : projectRoot;
const prefix = adapter.kind === 'home' ? '~/' : './';
return `${prefix}${path.relative(baseRoot, absoluteRoot).replace(/\\/g, '/')}`;
}
function validateCatalog() {
const adapters = listInstallTargetAdapters();
const adapterByTarget = new Map(adapters.map(adapter => [adapter.target, adapter]));
const catalogTargetIds = HARNESS_CAPABILITIES.flatMap(harness => harness.targetIds);
if (new Set(catalogTargetIds).size !== catalogTargetIds.length) {
throw new Error('Harness capability catalog contains duplicate install target ids');
}
const supported = [...SUPPORTED_INSTALL_TARGETS].sort();
const registered = adapters.map(adapter => adapter.target).sort();
const catalogued = [...catalogTargetIds].sort();
if (
JSON.stringify(catalogued) !== JSON.stringify(supported)
|| JSON.stringify(catalogued) !== JSON.stringify(registered)
) {
throw new Error('Harness capability catalog is out of sync with install targets');
}
for (const harness of HARNESS_CAPABILITIES) {
for (const declaredScope of harness.scopes) {
const adapter = adapterByTarget.get(declaredScope.targetId);
if (!adapter || expectedRootForAdapter(adapter) !== declaredScope.root) {
throw new Error(
`Harness capability root is out of sync for target ${declaredScope.targetId}`
);
}
}
}
}
validateCatalog();
function listHarnessCapabilities() {
return HARNESS_CAPABILITIES.slice();
}
function listGuidedHarnesses() {
return GUIDED_HARNESS_IDS.map(id => LOOKUP.get(id));
}
function getHarnessCapability(value) {
if (typeof value !== 'string' || value.trim() === '') {
return null;
}
return LOOKUP.get(normalizeLookupToken(value)) || null;
}
function tokenizeSelection(selection) {
const values = Array.isArray(selection) ? selection : [selection];
return values.flatMap(value => (
typeof value === 'string' ? value.split(',') : []
)).map(value => value.trim()).filter(Boolean);
}
function normalizeHarnessSelection(selection) {
const tokens = tokenizeSelection(selection);
if (tokens.length === 0 || tokens.every(token => normalizeLookupToken(token) === 'none')) {
throw new Error('At least one guided harness must be selected');
}
const allTokens = tokens.filter(token => ['all', '*'].includes(normalizeLookupToken(token)));
const explicitTokens = tokens.filter(token => !['all', '*'].includes(normalizeLookupToken(token)));
if (allTokens.length > 0 && explicitTokens.length > 0) {
throw new Error('The all/* harness selection cannot be combined with other selections');
}
if (allTokens.length > 0) {
return GUIDED_HARNESS_IDS.slice();
}
const selected = new Set();
for (const token of tokens) {
const normalizedToken = normalizeLookupToken(token);
const menuIndex = /^\d+$/.test(normalizedToken) ? Number(normalizedToken) - 1 : -1;
const harness = menuIndex >= 0
? listGuidedHarnesses()[menuIndex] || null
: getHarnessCapability(token);
if (!harness) {
throw new Error(`Unknown guided harness selection: ${token}`);
}
if (!harness.guidedReady) {
throw new Error(`${harness.label} is an advanced harness and is not guided-ready`);
}
selected.add(harness.id);
}
if (selected.size === 0) {
throw new Error('At least one guided harness must be selected');
}
return GUIDED_HARNESS_IDS.filter(id => selected.has(id));
}
module.exports = {
GUIDED_HARNESS_IDS,
HARNESS_CAPABILITIES,
getHarnessCapability,
listGuidedHarnesses,
listHarnessCapabilities,
normalizeHarnessSelection,
};