diff --git a/docker/plugin-setup/codex-auth-relay.js b/docker/plugin-setup/codex-auth-relay.js new file mode 100644 index 000000000..155abf90d --- /dev/null +++ b/docker/plugin-setup/codex-auth-relay.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node +/** + * Codex OAuth login relay for the hardened harness. + * + * `codex login` binds its callback server to 127.0.0.1:1455 inside the + * container. A Docker port publish forwards to the container interface, not + * loopback, so the host browser's redirect to http://localhost:1455 is + * refused. This bridges the published interface to loopback: + * + * host 127.0.0.1:1455 -> container 0.0.0.0:1456 (this relay) -> 127.0.0.1:1455 + * + * Run inside the networked session before `codex login`: + * node /ecc/docker/plugin-setup/codex-auth-relay.js & + * + * Env: ECC_RELAY_LISTEN (default 1456), ECC_RELAY_TARGET (default 1455). + */ + +'use strict'; + +const net = require('net'); + +function portFromEnv(name, fallback) { + const value = process.env[name] === undefined ? fallback : process.env[name]; + const port = Number(value); + if (!/^\d+$/.test(String(value)) || !Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`${name} must be an integer port between 1 and 65535`); + } + return port; +} + +const listenPort = portFromEnv('ECC_RELAY_LISTEN', 1456); +const targetPort = portFromEnv('ECC_RELAY_TARGET', 1455); + +net.createServer(client => { + const upstream = net.connect(targetPort, '127.0.0.1'); + client.pipe(upstream).pipe(client); + const drop = () => { + client.destroy(); + upstream.destroy(); + }; + client.on('error', drop); + upstream.on('error', drop); + client.on('close', drop); + upstream.on('close', drop); + client.setTimeout(30000, drop); + upstream.setTimeout(30000, drop); +}).listen(listenPort, '0.0.0.0', () => { + console.log(`codex-auth-relay: 0.0.0.0:${listenPort} -> 127.0.0.1:${targetPort}`); +}); diff --git a/docker/plugin-setup/compose.yaml b/docker/plugin-setup/compose.yaml index ef19064e5..15678eaa6 100644 --- a/docker/plugin-setup/compose.yaml +++ b/docker/plugin-setup/compose.yaml @@ -76,6 +76,9 @@ services: profiles: - networked network_mode: default + # Codex OAuth callback bridge: host browser -> codex-auth-relay.js -> loopback. + ports: + - "127.0.0.1:1455:1456" image: ecc-plugin-setup:debian real-cli-ubuntu: diff --git a/docs/STATUSLINE.md b/docs/STATUSLINE.md new file mode 100644 index 000000000..86610904a --- /dev/null +++ b/docs/STATUSLINE.md @@ -0,0 +1,32 @@ +# Optional Claude usage display + +ECC's existing statusline can show rate-limit windows, prompt-cache usage, +session cost, and installed plugin information when explicitly enabled. It +reads Claude's statusline input and local ECC metrics; it makes no network +requests and does not register hooks or change shell aliases. + +To try it on macOS or Linux, adapt the existing statusline example in +`examples/statusline.json` and prefix its command with `ECC_STATUSLINE_USAGE=1`: + +```json +{ + "statusLine": { + "type": "command", + "command": "ECC_STATUSLINE_USAGE=1 /absolute/path/to/node /absolute/path/to/ecc/scripts/hooks/ecc-statusline.js" + } +} +``` + +Use the absolute Node executable that works on your machine, and quote paths +containing spaces. Keep any existing custom statusline unless you choose to +replace it. Windows users can set `ECC_STATUSLINE_USAGE=1` in their provider's +environment and use the same script command with quoted absolute paths. + +Removing the environment setting restores the existing compact output. Missing +rate-limit data produces no usage window. `ECC_BAR_GLYPHS=ascii` selects ASCII +symbols, and `ECC_STATUSLINE_COMPACT=1` requests the compact variant inside +the optional display. + +The rendering idea comes from PR #2716 and the MIT-licensed +[claude-code-usage-bar](https://github.com/leeguooooo/claude-code-usage-bar). +Automatic installation and a managed Codex terminal bar remain separate work. diff --git a/scripts/hooks/ecc-statusline.js b/scripts/hooks/ecc-statusline.js index 413dbda49..11006a354 100644 --- a/scripts/hooks/ecc-statusline.js +++ b/scripts/hooks/ecc-statusline.js @@ -85,6 +85,68 @@ function readCurrentTask(sessionId) { } } +function getEccVersion(plugins) { + const ecc = plugins.find(p => p.name === 'ecc'); + if (ecc?.version) return ecc.version; + try { + return fs.readFileSync(path.join(__dirname, '..', '..', 'VERSION'), 'utf8').trim(); + } catch { + return ''; + } +} + +/** + * Assemble statusline output lines from Claude Code stdin data. + * @param {object} data - Parsed statusLine stdin JSON + * @param {object|null} bridge - Session bridge metrics or null + * @param {string} task - Current in-progress task + * @returns {string[]} Non-empty output lines + */ +function buildUsageLines(data, bridge, task) { + const render = require('../lib/statusline-render'); + const remaining = data.context_window?.remaining_percentage; + const cost = data.cost || {}; + const dirname = path.basename(data.workspace?.current_dir || process.cwd()); + + if (process.env.ECC_STATUSLINE_COMPACT === '1') { + const segments = [`\x1b[2m${data.model?.display_name || 'Claude'}\x1b[0m`]; + if (task) segments.push(`\x1b[1;97m${task}\x1b[0m`); + segments.push(`\x1b[2m${dirname}\x1b[0m`); + return [segments.join(render.SEP) + buildContextBar(remaining)]; + } + + const usageLine = render.buildUsageLine( + data.rate_limits, + render.computeCacheStats(data.context_window?.current_usage) + ); + + const durationMs = bridge?.first_timestamp + ? Date.now() - new Date(bridge.first_timestamp).getTime() + : 0; + const sessionLine = render.buildSessionLine({ + model: data.model?.display_name, + effort: data.effort?.level, + fastMode: data.fast_mode === true, + ctxBar: buildContextBar(remaining), + ctxWindowSize: data.context_window?.context_window_size, + costUsd: cost.total_cost_usd ?? bridge?.total_cost_usd ?? 0, + linesAdded: cost.total_lines_added || 0, + linesRemoved: cost.total_lines_removed || 0, + durationMs, + task, + }); + + const plugins = render.readInstalledPlugins(); + const eccLine = render.buildEccLine({ + eccVersion: getEccVersion(plugins), + hooks: render.getHooksSummary(), + plugins, + dirname, + }); + + return [usageLine, sessionLine, eccLine].filter(Boolean); +} + function runStatusline() { let input = ''; const stdinTimeout = setTimeout(() => process.exit(0), 3000); @@ -119,6 +181,11 @@ function runStatusline() { // Current task const task = sessionId ? readCurrentTask(sessionId) : ''; + if (process.env.ECC_STATUSLINE_USAGE === '1') { + process.stdout.write(buildUsageLines(data, bridge, task).join('\n')); + return; + } + // Metrics from bridge let metricsStr = ''; if (bridge) { @@ -163,6 +230,6 @@ function runStatusline() { }); } -module.exports = { formatDuration, buildContextBar, readCurrentTask, MAX_STDIN }; +module.exports = { formatDuration, buildContextBar, readCurrentTask, buildUsageLines, MAX_STDIN }; if (require.main === module) runStatusline(); diff --git a/scripts/install-guided.js b/scripts/install-guided.js index 4fa27525d..08a1a15e6 100644 --- a/scripts/install-guided.js +++ b/scripts/install-guided.js @@ -106,7 +106,11 @@ function parseArgs(argv) { if (options.allHarnesses && options.harnesses.length > 0) { throw new Error('--all-harnesses and --harness are mutually exclusive.'); } - return options; + const dryRunEnv = process.env.ECC_DRY_RUN; + if (dryRunEnv !== undefined && dryRunEnv !== '0' && dryRunEnv !== '1') { + throw new Error('ECC_DRY_RUN must be "1" or "0" when set'); + } + return { ...options, dryRun: options.dryRun || dryRunEnv === '1' }; } function choicesText(values) { diff --git a/scripts/lib/context-selection.js b/scripts/lib/context-selection.js index 6a925c033..5a642d455 100644 --- a/scripts/lib/context-selection.js +++ b/scripts/lib/context-selection.js @@ -44,10 +44,138 @@ function hasSuggestionEvidence(candidate) { const MIN_ANCHOR_TERMS = 1; // v4: an explicit empty proposal (decline) is honored; the tier-2 fallback no // longer overrides declines at the launch/selection call sites. -// v5: implicit admission requires a name or trigger anchor term. -const ROUTING_POLICY_VERSION = 5; +// v5: indirect name citations cannot bypass review through exact or scored admission. +// v6: ordered directives and singular named references share the same admission guard. +// v7: independent directives scope negation and imperatives allow question punctuation. +// v8: only recognized request/rejection scopes can authorize coordinated directives. +// v9: neutral task preambles do not suppress a later explicit skill request. +// v10: illustrative instructions do not supply or withdraw a direct request. +// v11: a contrastive current-task instruction ends the preceding example scope. +// v12: preserve current-main evidence floors and anchor checks. +const ROUTING_POLICY_VERSION = 12; const TASK_KEYS = new Set(['sessionId', 'taskId', 'revision', 'phase', 'query', 'explicitIds', 'proposedIds', 'noWorkflow']); +const DIRECTIVE_VERB = /\b(use|apply|invoke|run|follow|load)\s+(the\s+)?/i; +const DIRECTIVE_REQUEST = /^(?:please )?(?:(?:can|could|would|will) you (?:please )?|do )?(?:use|apply|invoke|run|follow|load)\b/; +const DIRECTIVE_REJECTION = /^(?:please )?(?:(?:(?:do )?not|never) (?:use|apply|invoke|run|follow|load)\b|avoid\b)/; +const DIRECTIVE_NAME_PREFIX = new RegExp(DIRECTIVE_REQUEST.source + '\\s+(?:the\\s+)?(?:skill\\s*)?$'); +const QUESTION_START = /^(?!do not\b)(can|could|would|should|shall|will|may|might|must|ought|do|does|did|is|are|why|how|what|when|where|which)\b/; +const REPORTED_INSTRUCTION = /\b(say|says|said|reads|told|mentions?|quoted?|document|states?|stated|recommends?|recommended|asserts?|asserted)\b/; +const EXAMPLE_PREFIX = /^(?:for (?:example|instance)|as an example|to illustrate)\b/; +const CURRENT_TASK_SUFFIX = /\bfor (?:this|the current) task$/; +// Subject-led statements may describe someone else's instructions or intentions. +const SUBJECT_PREFIX = /^(?:the|a|an|i|you|he|she|it|we|they|my|your|his|her|its|our|their|this|that|these|those)\b/; +// Rejections keep their whole list until another instruction/question begins. +const INSTRUCTION_BOUNDARY = /(?:,\s*(?:(?:and|but)\s+)?|\s+(?:and|but)\s+)(?=(?:please\s+)?(?:do\s+(?:not\s+)?|not\s+|never\s+)?(?:use|apply|invoke|run|follow|load)\b|(?:can|could|would|should|shall|will|may|might|must|ought|do|does|did|is|are|why|how|what|when|where|which)\b)/i; +// Admission additionally checks intervening scopes, including unknown wording. +const ADMISSION_BOUNDARY = /(?:,\s*(?:(?:and|but)\s+)?|\s+(?:and|but)\s+)(?=\S)/i; + +/** Track example spans without discarding the connectors between instructions. + * A contrast inside an example remains illustrative unless its instruction + * explicitly addresses the current task. Earlier non-example scopes stay intact. + * @param {string} text Normalized clause with comma boundaries preserved. + * @param {string[]} names Known candidate aliases whose internal connectors are names. + * @returns {Array<{start: number, text: string, example: boolean}>} Ordered scopes. + */ +function citationScopes(text, names) { + const mentions = names.flatMap(name => [...text.matchAll(new RegExp('\\b' + name + '\\b', 'g'))] + .map(mention => ({ start: mention.index, end: mention.index + name.length }))); + const boundaries = [...text.matchAll(new RegExp(ADMISSION_BOUNDARY.source, 'gi'))] + .filter(boundary => !mentions.some(mention => boundary.index > mention.start && boundary.index < mention.end)); + let example = false; + return [0, ...boundaries.map(boundary => boundary.index + boundary[0].length)].map((start, index) => { + const scope = text.slice(start, boundaries[index]?.index ?? text.length); + const currentTask = CURRENT_TASK_SUFFIX.test(scope) + && (DIRECTIVE_REQUEST.test(scope) || DIRECTIVE_REJECTION.test(scope)); + if (currentTask && /\bbut\b/.test(boundaries[index - 1]?.[0] || '')) example = false; + if (EXAMPLE_PREFIX.test(scope)) example = true; + return { start, text: scope, example }; + }); +} + +/** Normalize citation names while preserving contracted negation as a separate word. + * @param {string} text Raw query text or a registry alias. + * @returns {string} Lowercase alphanumeric words with negation preserved. + */ +const normalizedQueryName = text => text.replace(/n['\u2019]t\b/gi, ' not') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/_/g, ' ') + .toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); + +/** Return canonical/native aliases plus conservative singular forms. Singular + * multiword aliases only guard indirect references; they never create exact admission. + * @param {object} candidate Ranked skill candidate with its canonical ID and exact alias. + * @param {string} nativeName Registry name that may be absent from exact retrieval. + * @returns {Array<{name: string, exact: boolean}>} Normalized names and admission eligibility. + */ +function citationAliases(candidate, nativeName) { + const names = [...new Set([candidate.exactAlias, candidate.id.slice('skill:'.length), nativeName] + .filter(Boolean).map(normalizedQueryName).filter(Boolean))]; + return names.flatMap(name => [{ name, exact: true }, + ...(/\s\w+[^s]s$/.test(name) ? [{ name: name.slice(0, -1), exact: false }] : [])]); +} + +/** Classify a retrieval candidate's references in query order. Genuine later + * directives/rejections replace earlier instructions; questions, quotations and + * reported speech do not withdraw a directive. Returns directive, indirect or none. + * @param {object} candidate Ranked skill candidate with its canonical ID and exact alias. + * @param {string} query Task text to classify without loading skill contents. + * @param {string} nativeName Registry name, including aliases absent from exact retrieval. + * @param {string[]} names All candidate aliases, used to preserve names across scopes. + * @returns {'directive'|'indirect'|'none'} Admission evidence for this skill only. + */ +function citationFor(candidate, query, nativeName, names) { + const aliases = citationAliases(candidate, nativeName); + let citation = 'none'; + // Mask quoted instructions before splitting clauses so punctuation inside a + // quotation cannot look like a separate instruction. Quoted names remain usable. + const unquoted = query.replace(/"[^"]*"|\u201c[^\u201d]*\u201d|(?:^|[\s(:])'[^']*'|\u2018[^\u2019]*\u2019|\x60[^\x60]*\x60/g, part => { + const text = normalizedQueryName(part); + const instruction = DIRECTIVE_VERB.test(text); + // Quoting a task qualifier cannot turn an illustrative instruction into a request. + if (!instruction && !CURRENT_TASK_SUFFIX.test(text)) return part; + if (instruction && aliases.some(({ name }) => new RegExp('\\b' + name + '\\b').test(text))) citation = 'indirect'; + return part.replace(/[^.!?;\n]/g, ' '); + }); + // Keep the illustrative abbreviation together when its periods would split clauses. + const expanded = unquoted.replace(/\be\.g\.(?=\s|[,;:!?]|$)/gi, 'for example'); + for (const clause of expanded.match(/[^.!?;\n]+[.!?;\n]*/g) || []) { + // Keep comma boundaries for directive scope without changing alias normalization. + const text = clause.split(',').map(normalizedQueryName).join(', '); + const clauseScopes = citationScopes(text, names); + const mentions = aliases.flatMap(({ name, exact }) => [...text.matchAll(new RegExp('\\b' + name + '\\b', 'g'))] + .map(mention => ({ index: mention.index, exact }))).sort((a, b) => a.index - b.index); + for (const mention of mentions) { + const prefix = text.slice(0, mention.index); + const precedingScopes = clauseScopes.filter(scope => scope.start <= mention.index); + const example = precedingScopes.at(-1)?.example; + const scopes = precedingScopes.filter(scope => !scope.example) + .map(scope => scope.text.slice(0, mention.index - scope.start)); + const instructionPrefix = prefix.split(INSTRUCTION_BOUNDARY).at(-1); + // A task preamble such as "fix the bug" does not change a later request. + // Only preceding scopes may be neutral: an embedded instruction, question, + // or subject-led statement still needs review. The final scope must remain + // a direct request; neutral text never supplies admission evidence itself. + const instruction = scopes.every((scope, index) => DIRECTIVE_REQUEST.test(scope) || DIRECTIVE_REJECTION.test(scope) + || (index < scopes.length - 1 && !DIRECTIVE_VERB.test(scope) + && !QUESTION_START.test(scope) && !REPORTED_INSTRUCTION.test(scope) && !SUBJECT_PREFIX.test(scope))); + const question = (!DIRECTIVE_REQUEST.test(text) && QUESTION_START.test(text)) + || (!DIRECTIVE_REQUEST.test(instructionPrefix) + && (QUESTION_START.test(instructionPrefix) || clause.includes('?'))); + if (example || question || scopes.some(scope => REPORTED_INSTRUCTION.test(scope)) || /^\s*["'\u201c\u2018\x60]/.test(clause)) { + if (citation === 'none') citation = 'indirect'; + } else if (/\b(do not|never|no|not|avoid)\b/.test(instructionPrefix)) { + citation = 'indirect'; + } else if (!instruction) { + if (citation === 'none') citation = 'indirect'; + } else if (mention.exact && candidate.exact + && DIRECTIVE_NAME_PREFIX.test(scopes.at(-1))) { + citation = 'directive'; + } + } + } + return citation; +} + function validateTask(task) { if (!task || typeof task !== 'object' || Array.isArray(task)) throw new Error('Task must be an object'); for (const key of Object.keys(task)) if (!TASK_KEYS.has(key)) throw new Error(`Unknown task field: ${key}`); @@ -196,7 +324,13 @@ function routingEntries({ repoRoot = DEFAULT_REPO_ROOT, profileId = 'lean@1', ta routingPolicyVersion: ROUTING_POLICY_VERSION, triggersDigest: digestObject(triggers), entries }; } -/** Pure task-scoped resolver. Returned context never invokes a native skill or changes permissions. */ +/** Resolve task-scoped skill context without invoking native skills or changing permissions. + * @param {object} [options] Repository, task, profile, and selection constraints. + * @param {object} options.task Task identity, query, and explicit or proposed skill IDs. + * @param {boolean} [options.load=false] Include verified resource contents in the result. + * @param {object|null} [options.previous=null] Receipt reusable only when its binding matches. + * @returns {object} Selection decision, candidates, verified resources, and a bound receipt. + */ function resolveTaskContext({ repoRoot = DEFAULT_REPO_ROOT, task, profileId = 'lean@1', target = 'codex', selectionMode = 'auto', include = [], exclude = [], load = false, previous = null, expectedDigest = null } = {}) { validateTask(task); @@ -229,28 +363,11 @@ function resolveTaskContext({ repoRoot = DEFAULT_REPO_ROOT, task, profileId = 'l // questions, negations, reported speech, multiple cited names — never admit // implicitly. Everything else keeps the bounded-proposal path so the // primary agent decides ambiguous cases during work it was already doing. - const DIRECTIVE_VERB = /\b(use|apply|invoke|run|follow|load)\s+(the\s+)?/i; - const normalizedQueryName = text => text.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/_/g, ' ') - .toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); - const directiveCitation = candidate => { - if (!candidate || !candidate.exact) return false; - const text = normalizedQueryName(task.query || ''); - const aliases = [...new Set([candidate.exactAlias, - candidate.id.slice('skill:'.length).toLowerCase(), - candidate.id.slice('skill:'.length).toLowerCase().replace(/-/g, ' ')].filter(Boolean))]; - for (const name of aliases) { - const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const pattern = new RegExp(`${DIRECTIVE_VERB.source}(skill\\s*:?\\s*)?${escaped}(\\s+(skill|workflow|guidance))?\\b`, 'i'); - const match = pattern.exec(text); - if (!match) continue; - const window = text.slice(Math.max(0, match.index - 28), match.index); - if (/\b(do not|don't|never|no)\b/.test(window)) return false; - if (/\b(says|said|reads|told|document)\b/i.test(task.query || '')) return false; - return true; - } - return false; - }; - const exactAnchors = candidates.filter(directiveCitation); + const names = [...new Set(candidates.flatMap(candidate => citationAliases(candidate, byId.get(candidate.id).name) + .map(alias => alias.name)))]; + const citations = new Map(candidates.map(candidate => [candidate.id, + citationFor(candidate, task.query || '', byId.get(candidate.id).name, names)])); + const exactAnchors = candidates.filter(candidate => citations.get(candidate.id) === 'directive'); let autoSelection = null; if (!task.noWorkflow && selectionMode === 'auto' && !reused && !explicitIds.length && !proposedIds.length && candidates.length) { if (exactAnchors.length === 1) { @@ -259,7 +376,7 @@ function resolveTaskContext({ repoRoot = DEFAULT_REPO_ROOT, task, profileId = 'l } else if (!exactAnchors.length) { const top = candidates[0]; const second = candidates[1]; - if (top.bm25 >= AUTO_ADMIT_MIN_BM25 && top.matchedTerms.length >= AUTO_ADMIT_MIN_TERMS + if (citations.get(top.id) !== 'indirect' && top.bm25 >= AUTO_ADMIT_MIN_BM25 && top.matchedTerms.length >= AUTO_ADMIT_MIN_TERMS && top.anchorTerms.length >= MIN_ANCHOR_TERMS && (!second || top.bm25 >= AUTO_ADMIT_MARGIN * (second.bm25 || 0))) { autoSelection = { id: top.id, bm25: top.bm25, matchedTerms: top.matchedTerms.length, exact: false }; @@ -271,7 +388,7 @@ function resolveTaskContext({ repoRoot = DEFAULT_REPO_ROOT, task, profileId = 'l && !explicitIds.length && !proposedIds.length && candidates.length && !exactAnchors.length) { const top = candidates[0]; const second = candidates[1]; - if (meetsFallbackBar(top) + if (citations.get(top.id) !== 'indirect' && meetsFallbackBar(top) && top.anchorTerms.length >= MIN_ANCHOR_TERMS && (!second || top.bm25 >= FALLBACK_MARGIN * (second.bm25 || 0))) { fallback = { id: top.id, bm25: top.bm25, matchedTerms: top.matchedTerms.length }; diff --git a/scripts/lib/install/request.js b/scripts/lib/install/request.js index 28fab4acb..9dfbed7e2 100644 --- a/scripts/lib/install/request.js +++ b/scripts/lib/install/request.js @@ -4,6 +4,7 @@ const { validateInstallModuleIds, LOCALE_ALIAS_TO_COMPONENT_ID, listSupportedLoc const { resolveHookConsentFlags } = require('./hook-consent'); const LEGACY_INSTALL_TARGETS = ['claude', 'claude-project', 'cursor', 'antigravity']; +const LOCALE_COMPONENT_IDS = new Set(Object.values(LOCALE_ALIAS_TO_COMPONENT_ID)); function dedupeStrings(values) { return [...new Set((Array.isArray(values) ? values : []).map(value => String(value).trim()).filter(Boolean))]; @@ -103,6 +104,7 @@ function normalizeInstallRequest(options = {}) { : null; const profileId = options.profileId || config?.profileId || null; const target = options.target || config?.target || 'claude'; + const supportsLocale = target === 'claude' || target === 'claude-project'; const moduleIds = validateInstallModuleIds( dedupeStrings([...(config?.moduleIds || []), ...(options.moduleIds || [])]) ); @@ -113,7 +115,7 @@ function normalizeInstallRequest(options = {}) { `Unsupported locale: "${locale}". Supported locales: ${listSupportedLocales().join(', ')}` ); } - if (locale && target !== 'claude' && target !== 'claude-project') { + if (locale && !supportsLocale) { throw new Error('--locale can only be used with --target claude or --target claude-project'); } const requestedIncludeComponentIds = dedupeStrings([ @@ -139,7 +141,8 @@ function normalizeInstallRequest(options = {}) { const hasManifestBaseSelection = Boolean(profileId) || moduleIds.length > 0 || includeComponentIds.length > 0; const hasNonLocaleManifestSelection = Boolean(profileId) || moduleIds.length > 0 - || requestedIncludeComponentIds.length > 0 + // Install-state persists --locale as a component; auto-update replays it via --with. + || requestedIncludeComponentIds.some(id => !supportsLocale || !LOCALE_COMPONENT_IDS.has(id)) || excludeComponentIds.length > 0; const usingManifestMode = hasManifestBaseSelection || excludeComponentIds.length > 0; diff --git a/scripts/lib/statusline-render.js b/scripts/lib/statusline-render.js new file mode 100644 index 000000000..00abcde04 --- /dev/null +++ b/scripts/lib/statusline-render.js @@ -0,0 +1,278 @@ +#!/usr/bin/env node +/** + * Shared rendering helpers for the ECC usage bar statusline. + * + * Inspired by leeguooooo/claude-code-usage-bar (MIT). ECC re-implementation + * with brand colors, hooks/plugin awareness, and no external dependencies. + * + * Colors use 256-color ANSI so macOS Terminal.app renders them correctly. + * Brand palette (from assets/ecc-icon.svg): amber #F59E0B ~ 214, + * terracotta #E07856 ~ 173. + */ + +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { areHooksEnabled, getHookProfile, getDisabledHookIds } = require('./hook-flags'); + +const C = { + amber: '\x1b[38;5;214m', + terracotta: '\x1b[38;5;173m', + yellow: '\x1b[38;5;220m', + orange: '\x1b[38;5;208m', + red: '\x1b[1;38;5;196m', + green: '\x1b[38;5;114m', + dim: '\x1b[2m', + reset: '\x1b[0m', +}; + +const UNICODE_GLYPHS = { + full: '\u2588', empty: '\u2591', sep: '\u2502', bolt: '\u26a1', star: '\u2733', hex: '\u2b22', reset: '\u21bb', dot: '\u00b7', +}; +const ASCII_GLYPHS = { + full: '|', empty: '_', sep: '|', bolt: '*', star: '*', hex: '#', reset: '~', dot: '-', +}; + +/** + * Glyphs degrade to ASCII when the environment cannot be trusted to render + * block/box characters: tmux mangles them to underscores without a UTF-8 + * locale, and fonts such as Terminal.app's default lack \u2b22/\u2733. + * Force either set with ECC_BAR_GLYPHS=ascii|unicode. + */ +function glyphSet(env = process.env) { + const forced = String(env.ECC_BAR_GLYPHS || '').trim().toLowerCase(); + if (forced === 'ascii') return ASCII_GLYPHS; + if (forced === 'unicode') return UNICODE_GLYPHS; + const locale = `${env.LC_ALL || ''} ${env.LC_CTYPE || ''} ${env.LANG || ''}`.toLowerCase(); + return locale.includes('utf') ? UNICODE_GLYPHS : ASCII_GLYPHS; +} + +const G = glyphSet(); +const SEP = ` ${C.dim}${G.sep}${C.reset} `; + +/** + * Severity color for a usage percentage: brand color while healthy, + * warm ramp as the window fills up. + * @param {number} pct - 0..100 used + * @param {string} brandColor - color to use below 60% + */ +function pctColor(pct, brandColor = C.amber) { + if (pct >= 90) return C.red; + if (pct >= 80) return C.orange; + if (pct >= 60) return C.yellow; + return brandColor; +} + +/** + * Block bar for a percentage. Uses the same glyphs as the context bar. + * @param {number} pct - 0..100 used + * @param {number} width - bar width in cells + */ +function buildBar(pct, width = 8) { + const clamped = Math.max(0, Math.min(100, Math.round(pct))); + const filled = Math.round((clamped / 100) * width); + return G.full.repeat(filled) + G.empty.repeat(width - filled); +} + +/** + * Countdown until a unix-seconds timestamp: "42m", "1h42m", "3d". + * @param {number} epochSecs + * @returns {string} countdown or '' when invalid/past + */ +function formatCountdown(epochSecs) { + if (!epochSecs || typeof epochSecs !== 'number') return ''; + const secs = Math.floor(epochSecs - Date.now() / 1000); + if (secs <= 0) return ''; + const mins = Math.ceil(secs / 60); + if (mins < 60) return `${mins}m`; + const hours = Math.floor(mins / 60); + if (hours < 24) { + const rem = mins % 60; + return rem > 0 ? `${hours}h${rem}m` : `${hours}h`; + } + return `${Math.round(hours / 24)}d`; +} + +/** + * Wall-clock duration from milliseconds: "5s", "12m", "3h11m". + * @param {number} ms + */ +function formatMs(ms) { + if (!ms || ms < 0) return ''; + const secs = Math.floor(ms / 1000); + if (secs < 60) return `${secs}s`; + const mins = Math.floor(secs / 60); + if (mins < 60) return `${mins}m`; + const hours = Math.floor(mins / 60); + const rem = mins % 60; + return rem > 0 ? `${hours}h${rem}m` : `${hours}h`; +} + +/** + * Prompt-cache stats from context_window.current_usage. + * @returns {{hitPct: number}|null} null when there is no usage data yet + */ +function computeCacheStats(currentUsage) { + if (!currentUsage || typeof currentUsage !== 'object') return null; + const read = currentUsage.cache_read_input_tokens || 0; + const write = currentUsage.cache_creation_input_tokens || 0; + const fresh = currentUsage.input_tokens || 0; + const total = read + write + fresh; + if (total <= 0) return null; + return { hitPct: Math.round((read / total) * 100) }; +} + +function usageSegment(label, labelColor, window) { + const pct = Math.round(window?.used_percentage ?? -1); + if (pct < 0) return ''; + const color = pctColor(pct, labelColor); + const reset = formatCountdown(window.resets_at); + const resetStr = reset ? ` ${C.dim}${G.reset}${reset}${C.reset}` : ''; + return `${labelColor}${label}${C.reset} ${color}${buildBar(pct)} ${pct}%${C.reset}${resetStr}`; +} + +/** + * Line 1: rate-limit windows + prompt cache. + * Returns '' when no rate limit data is available (e.g. API-key billing). + */ +function buildUsageLine(rateLimits, cacheStats) { + const parts = []; + const fiveHour = usageSegment('5h', C.amber, rateLimits?.five_hour); + const sevenDay = usageSegment('7d', C.terracotta, rateLimits?.seven_day); + if (fiveHour) parts.push(fiveHour); + if (sevenDay) parts.push(sevenDay); + if (cacheStats && parts.length > 0) { + const cacheColor = cacheStats.hitPct >= 50 ? C.green : C.dim; + parts.push(`${C.dim}cache${C.reset} ${cacheColor}${cacheStats.hitPct}%${C.reset}`); + } + if (parts.length === 0) return ''; + return `${C.amber}${G.bolt}${C.reset} ${parts.join(SEP)}`; +} + +/** + * Line 2: model, mode, context bar, cost, diff, duration, current task. + */ +function buildSessionLine(info) { + const parts = []; + let model = `${C.amber}${G.star} ${info.model || 'Claude'}${C.reset}`; + const badges = []; + if (info.effort && info.effort !== 'high') badges.push(info.effort); + if (info.fastMode) badges.push('fast'); + if (badges.length > 0) model += ` ${C.dim}${badges.join(' ')}${C.reset}`; + parts.push(model); + + if (info.ctxBar) { + const size = info.ctxWindowSize >= 1000000 + ? '1M' + : info.ctxWindowSize ? `${Math.round(info.ctxWindowSize / 1000)}K` : ''; + const sizeStr = size ? ` ${C.dim}${size}${C.reset}` : ''; + parts.push(`${C.dim}ctx${C.reset}${info.ctxBar}${sizeStr}`); + } + + const metrics = []; + if (info.costUsd > 0) metrics.push(`$${info.costUsd.toFixed(2)}`); + if (info.linesAdded > 0 || info.linesRemoved > 0) { + metrics.push(`+${info.linesAdded || 0}/-${info.linesRemoved || 0}`); + } + const dur = formatMs(info.durationMs); + if (dur) metrics.push(dur); + if (metrics.length > 0) parts.push(`\x1b[38;5;117m${metrics.join(' ')}${C.reset}`); + + if (info.task) parts.push(`\x1b[1;97m${info.task}${C.reset}`); + return parts.join(SEP); +} + +/** + * Line 3: ECC version, hooks status, enabled plugins with versions, directory. + */ +function buildEccLine(info) { + const parts = []; + const version = info.eccVersion ? ` ${info.eccVersion}` : ''; + parts.push(`${C.terracotta}${G.hex} ECC${version}${C.reset}`); + + if (info.hooks) { + const h = info.hooks; + let hookStr; + if (!h.enabled) hookStr = `${C.red}hooks off${C.reset}`; + else { + const off = h.disabledCount > 0 ? ` ${C.dim}(${h.disabledCount} off)${C.reset}` : ''; + hookStr = `${C.green}hooks ${h.profile}${C.reset}${off}`; + } + parts.push(hookStr); + } + + if (Array.isArray(info.plugins) && info.plugins.length > 0) { + const shown = info.plugins.slice(0, 4) + .map(p => `${p.name}${p.version ? ` ${C.dim}${p.version}${C.reset}` : ''}`); + const more = info.plugins.length > 4 ? ` ${C.dim}+${info.plugins.length - 4}${C.reset}` : ''; + parts.push(`${C.dim}plugins${C.reset} ${shown.join(` ${G.dot} `)}${more}`); + } + + if (info.dirname) parts.push(`${C.dim}${info.dirname}${C.reset}`); + return parts.join(SEP); +} + +/** + * Enabled plugins with versions, ecc first. + * Reads installed_plugins.json (v2) and settings.json enabledPlugins. + * @param {string} [claudeDir] - defaults to CLAUDE_CONFIG_DIR or ~/.claude + * @returns {Array<{name: string, version: string}>} + */ +function readInstalledPlugins(claudeDir) { + try { + const dir = claudeDir || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); + const installed = JSON.parse( + fs.readFileSync(path.join(dir, 'plugins', 'installed_plugins.json'), 'utf8') + ); + let enabled = null; + try { + const settings = JSON.parse(fs.readFileSync(path.join(dir, 'settings.json'), 'utf8')); + if (settings.enabledPlugins && typeof settings.enabledPlugins === 'object') { + enabled = settings.enabledPlugins; + } + } catch { /* no settings \u2014 show all installed */ } + + const plugins = []; + for (const [id, entries] of Object.entries(installed?.plugins || {})) { + if (enabled && enabled[id] === false) continue; + const entry = Array.isArray(entries) ? entries[0] : entries; + plugins.push({ name: id.split('@')[0], version: entry?.version || '' }); + } + plugins.sort((a, b) => (a.name === 'ecc' ? -1 : b.name === 'ecc' ? 1 : a.name.localeCompare(b.name))); + return plugins; + } catch { + return []; + } +} + +/** + * Hooks status summary from ECC hook flags (env + managed config). + */ +function getHooksSummary(env = process.env) { + return { + enabled: areHooksEnabled(env), + profile: getHookProfile(env), + disabledCount: getDisabledHookIds(env).size, + }; +} + +module.exports = { + C, + G, + SEP, + UNICODE_GLYPHS, + ASCII_GLYPHS, + glyphSet, + pctColor, + buildBar, + formatCountdown, + formatMs, + computeCacheStats, + buildUsageLine, + buildSessionLine, + buildEccLine, + readInstalledPlugins, + getHooksSummary, +}; diff --git a/skills/docker-patterns/SKILL.md b/skills/docker-patterns/SKILL.md index e60c1d20f..f5dbb4683 100644 --- a/skills/docker-patterns/SKILL.md +++ b/skills/docker-patterns/SKILL.md @@ -416,6 +416,35 @@ explicit Compose `--env NAME` flag, understand that the value is inspectable and can be exfiltrated for the container lifetime, and remove the exact named container immediately after. +### Authenticate Codex Inside the Networked Session + +`codex login` binds its OAuth callback server to `127.0.0.1:1455` inside the +container. A Docker port publish forwards to the container interface, not +loopback, so the host browser's redirect to `http://localhost:1455` is refused +without a bridge. The `real-cli-networked` service publishes host +`127.0.0.1:1455` to container port `1456`; bridge that port to loopback before +logging in: + +```bash +npm install --global @openai/codex # the harness image does not ship codex +node /ecc/docker/plugin-setup/codex-auth-relay.js & +relay_pid=$! +codex login +kill "$relay_pid" +``` + +Complete the sign-in in the host browser. Each attempt mints a fresh +`state`/`code`, so always follow the newest printed link. The token stays in +the disposable container's `~/.codex/auth.json`; removing the named container +deletes that credential copy. Stop the relay after the login attempt. Only one container can hold host port 1455 at a time +because the OAuth redirect targets a fixed port. + +The host publish is restricted to loopback. Inside the container, the relay +listens on all interfaces so Docker can forward to it; other containers on the +same bridge network can reach it while it runs. Use a dedicated network without +untrusted peers for this login, and remove the exact named container after use. +Idle callback connections close after 30 seconds. + Run the same focused suite natively on the host: ```bash diff --git a/tests/docker/codex-auth-relay.test.js b/tests/docker/codex-auth-relay.test.js new file mode 100644 index 000000000..9c8aa40ea --- /dev/null +++ b/tests/docker/codex-auth-relay.test.js @@ -0,0 +1,62 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const net = require('node:net'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); +const { once } = require('node:events'); +const test = require('node:test'); + +const script = path.resolve(__dirname, '../../docker/plugin-setup/codex-auth-relay.js'); + +async function availablePort() { + const server = net.createServer(); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const port = server.address().port; + await new Promise(resolve => server.close(resolve)); + return port; +} + +test('actual relay forwards callback bytes and closes its upstream when the client leaves', { timeout: 10000 }, async t => { + const upstreamSockets = new Set(); + const upstream = net.createServer(socket => { + upstreamSockets.add(socket); + socket.on('close', () => upstreamSockets.delete(socket)); + socket.on('data', data => socket.write(data)); + }); + upstream.listen(0, '127.0.0.1'); + await once(upstream, 'listening'); + const relayPort = await availablePort(); + const child = spawn(process.execPath, [script], { env: { ...process.env, + ECC_RELAY_LISTEN: String(relayPort), ECC_RELAY_TARGET: String(upstream.address().port) } }); + t.after(async () => { + child.kill(); + for (const socket of upstreamSockets) socket.destroy(); + await new Promise(resolve => upstream.close(resolve)); + }); + const startup = await Promise.race([once(child.stdout, 'data'), once(child, 'exit')]); + assert.ok(Buffer.isBuffer(startup[0]), 'Relay must start before exiting'); + const client = net.connect(relayPort, '127.0.0.1'); + t.after(() => client.destroy()); + await once(client, 'connect'); + client.write('GET /auth/callback?state=fixture&code=fixture HTTP/1.1\r\n\r\n'); + const [bytes] = await once(client, 'data'); + assert.equal(bytes.toString(), 'GET /auth/callback?state=fixture&code=fixture HTTP/1.1\r\n\r\n'); + const socket = [...upstreamSockets][0]; + const closed = once(socket, 'close'); + client.destroy(); + await closed; + assert.equal(child.exitCode, null); +}); + +for (const value of ['0', '-1', '65536', '1.5', 'not-a-port']) { + test(`rejects invalid relay port ${value} before listening`, { timeout: 5000 }, async () => { + const child = spawn(process.execPath, [script], { env: { ...process.env, ECC_RELAY_LISTEN: value } }); + let stderr = ''; + child.stderr.on('data', chunk => { stderr += chunk; }); + const [code] = await once(child, 'exit'); + assert.equal(code, 1); + assert.match(stderr, /ECC_RELAY_LISTEN must be an integer port/); + }); +} diff --git a/tests/hooks/ecc-statusline-usage.test.js b/tests/hooks/ecc-statusline-usage.test.js new file mode 100644 index 000000000..5d045233c --- /dev/null +++ b/tests/hooks/ecc-statusline-usage.test.js @@ -0,0 +1,31 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const test = require('node:test'); + +test('actual statusline usage display is opt-in and leaves provider settings alone', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-statusline-usage-')); + try { + const payload = { model: { display_name: 'Claude fixture' }, context_window: { remaining_percentage: 80 }, + rate_limits: { five_hour: { used_percentage: 20, resets_at: Math.floor(Date.now() / 1000) + 3600 } } }; + const render = value => { + const child = spawnSync(process.execPath, [path.resolve(__dirname, '../../scripts/hooks/ecc-statusline.js')], { + input: JSON.stringify(payload), encoding: 'utf8', timeout: 5000, + env: { ...process.env, HOME: home, USERPROFILE: home, CLAUDE_CONFIG_DIR: home, + ECC_STATUSLINE_USAGE: value, ECC_BAR_GLYPHS: 'ascii' }, + }); + assert.equal(child.status, 0, child.stderr); + return child.stdout; + }; + assert.equal(render('0').split('\n').length, 1); + assert.ok(render('1').split('\n').length >= 2); + assert.match(render('1'), /5h/); + assert.deepEqual(fs.readdirSync(home), []); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); diff --git a/tests/lib/context-profile-launch.test.js b/tests/lib/context-profile-launch.test.js index 59d03ecdc..cc2aac847 100644 --- a/tests/lib/context-profile-launch.test.js +++ b/tests/lib/context-profile-launch.test.js @@ -155,3 +155,183 @@ test('spawn failures and timeout signals remain unsuccessful without a native ex assert.equal(result.taskSuccess, 'unverified'); } })); + +for (const query of ["Don't use the feature skill.", 'Don\u2019t use the feature skill.', + 'Can I use the feature skill?', 'The README says use the feature skill.', 'The docs say use feature.', + 'The docs say do not use feature and use shared guidance.', + 'The docs say do not use feature, use shared guidance, okay?', + 'The spec states do not use feature and use shared guidance.', + 'The spec stated do not use feature, use shared guidance.', + 'The spec recommends we do not use feature and use shared guidance.', + 'The spec asserts we should not use feature and use shared guidance.', + 'Use standard tools, the spec stipulates do not use feature and use shared guidance.', + 'Should we avoid feature and use shared guidance?', + 'Should we avoid feature, use shared guidance?', + 'Use standard tools, should we use feature?', + 'Use standard tools, shall we use feature?', + 'Use standard tools, will we use feature?', + 'Use standard tools, ought we to use feature?', + 'Please use the existing code, can I use feature?', + 'Use standard tools and should we use feature?', + 'Use standard tools but should we use feature?', + 'Fix the bug, shall we use feature?', + 'Fix the bug, the spec states do not use feature and use shared guidance.', + 'The docs say fix the bug and use feature.', + 'Can we fix the bug and use feature?', + 'The spec stipulates: fix the bug and use feature.', + 'Our policy stipulates repair the bug and use feature.', + 'Fix the bug, ought we to repair it and use feature?', + 'Fix the bug, must we repair it and use feature?', + 'Fix the bug, feature.', + 'For example, use feature', + 'For instance, use feature.', + 'As an example, use feature.', + 'To illustrate, use feature.', + 'Fix the bug, for example, use feature.', + 'For example, use feature and use feature.', + 'For example, use feature but use shared guidance.', + "For example, don't use feature but use shared guidance.", + 'For example, use feature and use feature for this task.', + 'For example, use feature, but should we use feature for this task?', + 'For example, use feature, but the docs say use feature for this task.', + 'For example, use feature, but "use feature for this task" is an example.', + 'The docs say for example, use feature, but use feature for this task.', + 'For example, use feature, but use feature "for this task"', + 'Should we, for example, use shared guidance, but use feature for this task?', + 'Our policy stipulates, for example, use shared guidance, but use feature for this task.', + 'Use feature. For example, use shared guidance, but do not use feature for this task.', + 'For example, use shared guidance, but use feature for this task and do not use feature.', + 'e.g., use feature.', + 'E.g. use feature.', + 'Do not use feature and shared guidance.', + "Use feature, don't use feature.", + "Use feature and don't use feature.", + "Use feature but don't use feature."]) { + test('indirect citation uses the proposal path and honors decline: ' + query, () => withFixture(repoRoot => { + const phases = []; + const result = launchTaskContext({ repoRoot, task: { ...input, query, explicitIds: [] }, + execute(command, args, options) { + phases.push(options.phase); + if (options.phase === 'selection') return { status: 0, stdout: '{"selectedIds":[]}' }; + assert.doesNotMatch(options.input, /# feature/); + assert.doesNotMatch(options.input, /# shared/); + return { status: 0, stdout: 'ok' }; + } }); + assert.deepEqual(phases, ['selection', 'task']); + assert.equal(result.routingCalls, 1); + assert.deepEqual(result.selection.selectedIds, []); + assert.deepEqual(result.selection.loadedIds, []); + assert.equal(result.selection.fallback, null); + assert.equal(result.selection.reason, 'agent-declined-selection'); + })); +} + +for (const [query, skill] of [ + ['Fix the bug, use feature', 'feature'], + ['Fix the bug and use feature.', 'feature'], + ['Please fix the bug, please use feature.', 'feature'], + ['Write the regression test, use feature.', 'feature'], + ['Investigate the bug but use feature.', 'feature'], + ['For this task, use feature.', 'feature'], + ["Fix the bug, don't use feature and use shared guidance.", 'shared'], + ["Don't use feature, use shared guidance.", 'shared'], + ["Don't use feature and use shared guidance.", 'shared'], + ["Don't use feature but use shared guidance.", 'shared'], + ["Don't use feature, use shared guidance, okay?", 'shared'], + ["Don't use feature and use shared guidance, okay?", 'shared'], + ["Don't use feature but use shared guidance, okay?", 'shared'], + ['Do not use feature, please use shared guidance?', 'shared'], + ['Do not use feature and please use shared guidance?', 'shared'], + ['Do not use feature but please use shared guidance?', 'shared'], + ["Don't use feature, use feature.", 'feature'], + ['Use feature?', 'feature'], + ['Please use feature?', 'feature'], + ['Use feature to fix the bug, okay?', 'feature'], + ['Use feature. Shall we use feature?', 'feature'], + ['Use feature, shall we use shared guidance?', 'feature'], + ['Shall we use shared guidance? Use feature.', 'feature'], + ['Use feature. The spec states do not use feature and use shared guidance.', 'feature'], + ['The spec states do not use feature and use shared guidance. Use feature.', 'feature'], + ['Use feature. For example, use shared guidance.', 'feature'], + ['For example, use shared guidance. Use feature.', 'feature'], + ["Use feature. For example, don't use feature.", 'feature'], + ['Use feature to build an example.', 'feature'], + ['Fix the example, use feature.', 'feature'], + ['Fix e.g.js, use feature.', 'feature'], + ["Use feature. E.g., don't use feature.", 'feature'], + ['For example, use feature, but use feature for this task', 'feature'], + ['For example, use shared guidance, but please use feature for this task.', 'feature'], + ['For instance, use shared guidance but use feature for this task.', 'feature'], + ['For example use shared guidance, but use feature for this task.', 'feature'], + ['For example, use shared guidance, but use feature for the current task.', 'feature'], + ['For example, use shared guidance, but use feature for this task, for instance, do not use feature.', 'feature'], + ['For example, the docs say use shared guidance, but use feature for this task.', 'feature'], +]) { + test('an unambiguous final directive injects context without a proposal: ' + query, () => withFixture(repoRoot => { + const phases = []; + const result = launchTaskContext({ repoRoot, task: { ...input, query, explicitIds: [] }, + execute(command, args, options) { + phases.push(options.phase); + if (options.phase === 'selection') return { status: 0, stdout: '{"selectedIds":[]}' }; + assert.match(options.input, new RegExp('# ' + skill)); + assert.doesNotMatch(options.input, new RegExp('# ' + (skill === 'shared' ? 'feature' : 'shared'))); + return { status: 0, stdout: 'ok' }; + } }); + assert.deepEqual(phases, ['task']); + assert.equal(result.routingCalls, 0); + assert.deepEqual(result.selection.loadedIds, ['skill:' + skill]); + assert.equal(result.selection.reason, 'auto-selection'); + })); +} + +test('a final directive after a question reaches the task with its context', () => withFixture(repoRoot => { + const phases = []; + const result = launchTaskContext({ repoRoot, + task: { ...input, query: 'Should we use feature? Use feature.', explicitIds: [] }, + execute(command, args, options) { + phases.push(options.phase); + assert.match(options.input, /# feature/); + return { status: 0, stdout: 'ok' }; + } }); + assert.deepEqual(phases, ['task']); + assert.equal(result.routingCalls, 0); + assert.deepEqual(result.selection.loadedIds, ['skill:feature']); +})); + +for (const [suffix, expectedIds] of [['.', ['skill:feature']], [' and use shared guidance.', []]]) { + test('a conjunction inside a native alias preserves launch scope: ' + suffix, () => withFixture(repoRoot => { + fs.writeFileSync(path.join(repoRoot, 'skills/feature/SKILL.md'), + '---\nname: research-and-development\ndescription: Feature workflow\n---\n# feature'); + const query = 'For example, use shared guidance, but use research and development for this task' + suffix; + const phases = []; + const result = launchTaskContext({ repoRoot, task: { ...input, query, explicitIds: [] }, + execute(command, args, options) { + phases.push(options.phase); + if (options.phase === 'selection') return { status: 0, stdout: '{"selectedIds":[]}' }; + if (expectedIds.length) assert.match(options.input, /# feature/); + else assert.doesNotMatch(options.input, /# feature/); + assert.doesNotMatch(options.input, /# shared/); + return { status: 0, stdout: 'ok' }; + } }); + assert.deepEqual(phases, expectedIds.length ? ['task'] : ['selection', 'task']); + assert.deepEqual(result.selection.selectedIds, expectedIds); + assert.deepEqual(result.selection.loadedIds, expectedIds); + assert.equal(result.selection.reason, expectedIds.length ? 'auto-selection' : 'agent-declined-selection'); + })); +} + +test('a singular rejected name cannot inject scored context at launch', () => { + const phases = []; + const query = "Don't use the database migration skill. Review a PostgreSQL migration that adds an indexed nullable column without downtime."; + const result = launchTaskContext({ task: { ...input, query, explicitIds: [] }, + execute(command, args, options) { + phases.push(options.phase); + if (options.phase === 'selection') return { status: 0, stdout: '{"selectedIds":[]}' }; + assert.doesNotMatch(options.input, /# Database Migration Patterns/); + return { status: 0, stdout: 'ok' }; + } }); + assert.deepEqual(phases, ['selection', 'task']); + assert.equal(result.routingCalls, 1); + assert.deepEqual(result.selection.loadedIds, []); + assert.equal(result.selection.fallback, null); +}); diff --git a/tests/lib/context-selection.test.js b/tests/lib/context-selection.test.js index 281f646d5..c04f943d0 100644 --- a/tests/lib/context-selection.test.js +++ b/tests/lib/context-selection.test.js @@ -167,11 +167,13 @@ test('source changes invalidate reuse and source-bound load preview', () => with })); test('bounded search uses canonical IDs and deterministic order', () => withFixture(repoRoot => { - const result = resolve(repoRoot, { query: 'feature' }); + const result = resolve(repoRoot, { query: 'feature' }, { load: true }); assert.equal(result.candidates[0].id, 'skill:feature'); // A bare name mention ranks the skill but is not a directive citation. assert.deepEqual(result.selectedIds, []); + assert.deepEqual(result.loadedIds, []); assert.equal(result.reason, 'agent-selection-required'); + assert.equal(result.fallback, null); assert.ok(result.candidates.length <= 5); })); @@ -201,6 +203,18 @@ test('multiple directive citations defer to an explicit agent proposal', () => w assert.equal(result.reason, 'agent-selection-required'); })); +for (const [suffix, expectedIds] of [['.', ['skill:feature']], [' and use shared guidance.', []]]) { + test('a conjunction inside a native alias preserves task request scope: ' + suffix, () => withFixture(repoRoot => { + write(repoRoot, 'skills/feature/SKILL.md', '---\nname: research-and-development\ndescription: Feature workflow\n---\n# feature'); + const query = 'For example, use shared guidance, but use research and development for this task' + suffix; + const result = resolve(repoRoot, { query }, { load: true }); + assert.deepEqual(result.selectedIds, expectedIds); + assert.deepEqual(result.loadedIds, expectedIds); + assert.equal(result.reason, expectedIds.length ? 'auto-selection' : 'agent-selection-required'); + if (!expectedIds.length) assert.equal(result.fallback, null); + })); +} + test('name anchors require complete word boundaries', () => withFixture(repoRoot => { const result = resolve(repoRoot, { query: 'featurette sharedness' }, { load: true }); assert.deepEqual(result.loadedIds, []); @@ -344,3 +358,372 @@ test('suggestion evidence requires an exact name or the absolute fallback floor' assert.equal(hasSuggestionEvidence(candidate({ exact: true, bm25: 1, matchedTerms: ['feature'] })), true); assert.equal(hasSuggestionEvidence(null), false); }); + +for (const query of [ + "You shouldn't use feature.", + "You can\u2019t use feature.", + "For feature, don't use the feature skill.", + 'For feature, never invoke feature.', + 'The phrase "Use feature" is an example.', + 'The phrase "Use feature." is an example.', + 'The phrase \u201cUse feature\u201d is an example.', + 'The phrase `Use feature` is an example.', + "Don't use the feature skill.", + 'Don\u2019t use the feature skill.', + 'Do not use the feature skill.', + 'Never use the feature skill.', + 'Can I use the feature skill?', + 'Should we use the feature skill?', + 'Why use the feature skill?', + 'The README says to use the feature skill.', + 'The docs say use feature.', + 'The docs say to use feature.', + 'The docs said use feature.', + '"Use the feature skill" is an example.', +]) { + test('indirect name citation needs an agent decision: ' + query, () => withFixture(repoRoot => { + const result = resolve(repoRoot, { query }, { load: true }); + assert.ok(result.candidates.some(candidate => candidate.id === 'skill:feature')); + assert.deepEqual(result.loadedIds, []); + assert.equal(result.reason, 'agent-selection-required'); + assert.equal(result.fallback, null); + })); +} + +test('a negated citation does not suppress a separate affirmative directive', () => withFixture(repoRoot => { + for (const query of [ + "Don't use feature. Use shared guidance.", + 'Don\u2019t use feature; please use shared guidance.', + 'The document says use feature. Use shared guidance.', + ]) { + const result = resolve(repoRoot, { query }, { load: true }); + assert.deepEqual(result.loadedIds, ['skill:shared']); + } +})); + +for (const query of [ + "Don't use feature, use shared guidance.", + "Don't use feature and use shared guidance.", + "Don't use feature but use shared guidance.", + 'Do not use feature, please use shared guidance.', + 'Don\u2019t use feature, and please use shared guidance.', + "Don't use feature, do use shared guidance.", + "Don't use feature, use shared guidance, okay?", + "Don't use feature and use shared guidance, okay?", + "Don't use feature but use shared guidance, okay?", + 'Do not use feature, please use shared guidance?', + 'Do not use feature and please use shared guidance?', + 'Do not use feature but please use shared guidance?', +]) { + test('a new directive scopes an earlier rejection: ' + query, () => withFixture(repoRoot => { + const result = resolve(repoRoot, { query }, { load: true }); + assert.deepEqual(result.selectedIds, ['skill:shared']); + assert.deepEqual(result.loadedIds, ['skill:shared']); + assert.equal(result.reason, 'auto-selection'); + assert.equal(result.receipt.autoSelection.exact, true); + })); +} + +for (const query of [ + 'The docs say do not use feature and use shared guidance.', + 'The docs say do not use feature, use shared guidance.', + 'The docs say do not use feature, use shared guidance, okay?', + 'Use standard tools, the spec states do not use feature and use shared guidance.', + 'The spec states do not use feature and use shared guidance.', + 'The spec stated do not use feature, use shared guidance.', + 'The spec recommends we do not use feature and use shared guidance.', + 'The spec asserts we should not use feature and use shared guidance.', + 'Use standard tools, the spec stipulates do not use feature and use shared guidance.', + 'Use the documentation that says do not use feature and use shared guidance.', + 'Should we avoid feature and use shared guidance?', + 'Should we avoid feature, use shared guidance?', + 'Do not use feature and shared guidance.', + 'Do not use feature, shared guidance.', +]) { + test('a connector preserves indirect or negated context: ' + query, () => withFixture(repoRoot => { + const result = resolve(repoRoot, { query }, { load: true }); + assert.ok(result.candidates.some(candidate => candidate.id === 'skill:shared')); + assert.deepEqual(result.selectedIds, []); + assert.deepEqual(result.loadedIds, []); + assert.equal(result.reason, 'agent-selection-required'); + assert.equal(result.fallback, null); + })); +} + +for (const query of [ + 'Use standard tools, should we use feature?', + 'Use standard tools, shall we use feature?', + 'Use standard tools, will we use feature?', + 'Use standard tools, ought we to use feature?', + 'Please use the existing code, can I use feature?', + 'Use standard tools and should we use feature?', + 'Use standard tools but should we use feature?', +]) { + test('a generic directive does not authorize a later question: ' + query, () => withFixture(repoRoot => { + const result = resolve(repoRoot, { query }, { load: true }); + assert.ok(result.candidates.some(candidate => candidate.id === 'skill:feature')); + assert.deepEqual(result.selectedIds, []); + assert.deepEqual(result.loadedIds, []); + assert.equal(result.reason, 'agent-selection-required'); + assert.equal(result.fallback, null); + })); +} + +for (const query of ['Use feature?', 'Please use feature?', 'Use feature to fix the bug, okay?']) { + test('an imperative with question punctuation remains a directive: ' + query, () => withFixture(repoRoot => { + const result = resolve(repoRoot, { query }, { load: true }); + assert.deepEqual(result.selectedIds, ['skill:feature']); + assert.deepEqual(result.loadedIds, ['skill:feature']); + assert.equal(result.reason, 'auto-selection'); + assert.equal(result.receipt.autoSelection.exact, true); + })); +} + +test('ordinary and polite directives preserve automatic and explicit selection', () => withFixture(repoRoot => { + for (const query of ['Please use the feature skill.', 'Can you use the feature skill?', + 'Use feature without changing the code.', 'Use feature to document the change.', + 'Use "feature".', 'Use `feature`.', 'Do use feature.', 'Please can you use feature?']) { + assert.deepEqual(resolve(repoRoot, { query }, { load: true }).loadedIds, ['skill:feature']); + } + for (const field of ['explicitIds', 'proposedIds']) { + assert.deepEqual(resolve(repoRoot, { query: 'Should we use feature?', [field]: ['skill:feature'] }, + { load: true }).loadedIds, ['skill:feature']); + } +})); + +for (const [query, expectedId] of [ + ['Fix the bug, use feature', 'skill:feature'], + ['Fix the bug and use feature.', 'skill:feature'], + ['Please fix the bug, please use feature.', 'skill:feature'], + ['Write the regression test, use feature.', 'skill:feature'], + ['Investigate the bug but use feature.', 'skill:feature'], + ['For this task, use feature.', 'skill:feature'], + ["Fix the bug, don't use feature and use shared guidance.", 'skill:shared'], +]) { + test('a general task preamble preserves a later directive: ' + query, () => withFixture(repoRoot => { + const result = resolve(repoRoot, { query }, { load: true }); + assert.deepEqual(result.selectedIds, [expectedId]); + assert.deepEqual(result.loadedIds, [expectedId]); + assert.equal(result.reason, 'auto-selection'); + assert.equal(result.receipt.autoSelection.exact, true); + })); +} + +for (const query of [ + 'Fix the bug, shall we use feature?', + 'Fix the bug, the spec states do not use feature and use shared guidance.', + 'The docs say fix the bug and use feature.', + 'Can we fix the bug and use feature?', + 'The spec stipulates: fix the bug and use feature.', + 'Our policy stipulates repair the bug and use feature.', + 'Fix the bug, ought we to repair it and use feature?', + 'Fix the bug, must we repair it and use feature?', + 'Fix the bug, feature.', +]) { + test('a general task preamble cannot promote an indirect citation: ' + query, () => withFixture(repoRoot => { + const result = resolve(repoRoot, { query }, { load: true }); + assert.ok(result.candidates.some(candidate => candidate.id === 'skill:feature')); + assert.deepEqual(result.selectedIds, []); + assert.deepEqual(result.loadedIds, []); + assert.equal(result.reason, 'agent-selection-required'); + assert.equal(result.fallback, null); + })); +} + +for (const prefix of ['database-migrations:', "Don't use database-migrations.", 'Do not use database-migrations.', + 'Should we use database-migrations?', 'The README says use database-migrations.']) { + test('strong retrieval cannot bypass an indirect citation: ' + prefix, () => { + const query = prefix + ' Review a PostgreSQL migration that adds an indexed nullable column without downtime.'; + const result = resolveTaskContext({ task: task({ query }), load: true }); + const candidate = result.candidates.find(value => value.id === 'skill:database-migrations'); + assert.ok(candidate && candidate.bm25 >= 20, 'Exercise the BM25 auto-admission threshold'); + assert.deepEqual(result.loadedIds, []); + assert.equal(result.fallback, null); + }); +} + +for (const query of [ + 'For example, use feature', + 'For instance, use feature.', + 'As an example, use feature.', + 'To illustrate, use feature.', + 'Fix the bug, for example, use feature.', + 'For example, use feature and use feature.', + 'For example, use feature but use shared guidance.', + "For example, don't use feature but use shared guidance.", + 'For example, use feature and use feature for this task.', + 'For example, use feature, but should we use feature for this task?', + 'For example, use feature, but the docs say use feature for this task.', + 'For example, use feature, but "use feature for this task" is an example.', + 'The docs say for example, use feature, but use feature for this task.', + 'For example, use feature, but use feature "for this task"', + 'Should we, for example, use shared guidance, but use feature for this task?', + 'Our policy stipulates, for example, use shared guidance, but use feature for this task.', + 'e.g., use feature.', + 'E.g. use feature.', +]) { + test('an illustrative citation needs an agent decision: ' + query, () => withFixture(repoRoot => { + const result = resolve(repoRoot, { query }, { load: true }); + assert.ok(result.candidates.some(candidate => candidate.id === 'skill:feature')); + assert.deepEqual(result.selectedIds, []); + assert.deepEqual(result.loadedIds, []); + assert.equal(result.reason, 'agent-selection-required'); + assert.equal(result.fallback, null); + })); +} + +for (const [routingPolicyVersion, query] of [ + [7, 'Use standard tools, shall we use feature?'], + [9, 'For example, use feature'], +]) { + test('an old policy ' + routingPolicyVersion + ' selected receipt is invalidated after policy changes', () => withFixture(repoRoot => { + const first = resolve(repoRoot, { query, explicitIds: ['skill:feature'] }); + const { digestObject } = require('../../scripts/lib/context-profile-support'); + const { compileContextProfile } = require('../../scripts/lib/context-profiles'); + const plan = compileContextProfile({ repoRoot, selectionMode: 'auto' }); + const bindingDigest = digestObject({ sessionId: 'session-1', taskId: 'task-1', revision: 1, + phase: 'implement', planDigest: plan.planDigest, routingPolicyVersion, + triggersDigest: digestObject({}), queryDigest: digestObject(query) }); + const { receiptDigest: _receiptDigest, ...receipt } = first.receipt; + const oldReceipt = { ...receipt, bindingDigest, explicitIds: [], + selectionDigest: digestObject({ bindingDigest, selectedIds: ['skill:feature'], explicitIds: [] }) }; + const previous = { ...oldReceipt, receiptDigest: digestObject(oldReceipt) }; + const result = resolve(repoRoot, { query }, { previous, load: true }); + assert.equal(result.reused, false); + assert.notEqual(result.receipt.bindingDigest, previous.bindingDigest); + assert.deepEqual(result.selectedIds, []); + assert.deepEqual(result.loadedIds, []); + assert.equal(result.fallback, null); + })); +} + +for (const [routingPolicyVersion, query, expectedId] of [ + [6, "Don't use feature, use shared guidance.", 'skill:shared'], + [8, 'Fix the bug, use feature', 'skill:feature'], + [10, 'For example, use feature, but use feature for this task', 'skill:feature'], +]) { + test('an old policy ' + routingPolicyVersion + ' empty selection is reconsidered after policy changes', () => withFixture(repoRoot => { + const first = resolve(repoRoot, { query }); + const { digestObject } = require('../../scripts/lib/context-profile-support'); + const { compileContextProfile } = require('../../scripts/lib/context-profiles'); + const plan = compileContextProfile({ repoRoot, selectionMode: 'auto' }); + const bindingDigest = digestObject({ sessionId: 'session-1', taskId: 'task-1', revision: 1, + phase: 'implement', planDigest: plan.planDigest, routingPolicyVersion, + triggersDigest: digestObject({}), queryDigest: digestObject(query) }); + const { receiptDigest: _receiptDigest, autoSelection: _autoSelection, ...receipt } = first.receipt; + const oldReceipt = { ...receipt, bindingDigest, decision: 'none', selectedIds: [], explicitIds: [], + loadedIds: [], resources: [], selectionDigest: digestObject({ bindingDigest, selectedIds: [], explicitIds: [] }) }; + const previous = { ...oldReceipt, receiptDigest: digestObject(oldReceipt) }; + const result = resolve(repoRoot, { query }, { previous, load: true }); + assert.equal(result.reused, false); + assert.notEqual(result.receipt.bindingDigest, previous.bindingDigest); + assert.deepEqual(result.selectedIds, [expectedId]); + assert.deepEqual(result.loadedIds, [expectedId]); + assert.equal(result.reason, 'auto-selection'); + })); +} + +for (const query of [ + 'Should we use feature? Use feature.', + 'Use feature. Should we use feature?', + 'Use feature, should we use feature?', + 'Use feature. Shall we use feature?', + 'Use feature, shall we use shared guidance?', + 'Shall we use shared guidance? Use feature.', + 'The docs say use feature. Use feature.', + 'Use feature. The docs say use feature.', + 'Use feature. The spec states do not use feature and use shared guidance.', + 'Use feature. Use the warning that says never use feature.', + 'The spec states do not use feature and use shared guidance. Use feature.', + 'The phrase "Use feature." is an example. Use feature.', + 'The docs say "Use feature." Use feature.', + 'The docs say "Should we use feature?" Use feature.', + `Use feature. The docs say "don’t use feature."`, + 'Use feature. The phrase "Use feature." is an example.', + "Don't use feature. Use feature.", + "Don't use feature, use feature.", + "Don't use feature and use feature.", + "Don't use feature but use feature.", + 'The docs say never use feature. Use feature.', + 'Use feature. The docs say never use feature.', + 'Use feature to inspect the essay.', + 'Use feature. For example, use shared guidance.', + 'For example, use shared guidance. Use feature.', + "Use feature. For example, don't use feature.", + 'Use feature to build an example.', + 'Fix the example, use feature.', + 'Fix e.g.js, use feature.', + "Use feature. E.g., don't use feature.", + 'For example, use feature, but use feature for this task', + 'For example, use shared guidance, but please use feature for this task.', + 'For instance, use shared guidance but use feature for this task.', + 'For example use shared guidance, but use feature for this task.', + 'For example, use shared guidance, but use feature for the current task.', + 'For example, use shared guidance, but use feature for this task, for instance, do not use feature.', + 'For example, the docs say use shared guidance, but use feature for this task.', +]) { + test('a genuine directive survives surrounding discussion: ' + query, () => withFixture(repoRoot => { + const result = resolve(repoRoot, { query }, { load: true }); + assert.deepEqual(result.loadedIds, ['skill:feature']); + assert.equal(result.reason, 'auto-selection'); + assert.equal(result.receipt.autoSelection.exact, true); + })); +} + +for (const query of [ + "Use feature. Don't use feature.", + 'Use feature. Avoid feature.', + 'Use feature. You should not use feature.', + 'Use feature. You shouldn\u2019t use feature.', + 'Use feature, no feature.', + 'Use feature. For this task, do not use feature.', + "Use feature, don't use feature.", + "Use feature and don't use feature.", + "Use feature but don't use feature.", + 'Use feature. Never use feature.', + "Use feature. Don't use feature. Should we use feature?", + 'Use feature. For example, use shared guidance, but do not use feature for this task.', + 'For example, use shared guidance, but use feature for this task and do not use feature.', +]) { + test('a later rejection withdraws an earlier directive: ' + query, () => withFixture(repoRoot => { + const result = resolve(repoRoot, { query }, { load: true }); + assert.deepEqual(result.loadedIds, []); + assert.equal(result.fallback, null); + })); +} + +test('a later grouped rejection withdraws a previously requested skill', () => withFixture(repoRoot => { + for (const query of ["Use shared. Don't use feature and shared guidance.", + 'Use shared. Do not use feature and our shared guidance.', + 'Use shared. Do not use feature and the existing shared guidance.', + 'Use shared. Do not use feature, manual, and shared guidance.', + "Use shared. Don't use feature and the shared skill."]) { + const result = resolve(repoRoot, { query }, { load: true }); + assert.deepEqual(result.loadedIds, [], query); + assert.equal(result.fallback, null); + } +})); + +for (const prefix of [ + "Don't use the database migration skill.", + 'Don\u2019t use the database migration skill.', + 'Never use database migration.', + 'Should we use the database migration skill?', + 'The docs say use the database migration skill.', +]) { + test('singular named references cannot bypass citation review: ' + prefix, () => { + const query = prefix + ' Review a PostgreSQL migration that adds an indexed nullable column without downtime.'; + const result = resolveTaskContext({ task: task({ query }), load: true }); + const candidate = result.candidates.find(value => value.id === 'skill:database-migrations'); + assert.ok(candidate && candidate.bm25 >= 20, 'Exercise the BM25 auto-admission threshold'); + assert.equal(candidate.exact, false, 'Exercise the non-exact candidate path'); + assert.deepEqual(result.loadedIds, []); + assert.equal(result.fallback, null); + }); +} + +test('singular forms do not create exact directive admission', () => { + const result = resolveTaskContext({ task: task({ query: 'Use the database migration skill.' }), load: true }); + assert.ok(result.candidates.some(candidate => candidate.id === 'skill:database-migrations')); + assert.ok(!result.receipt.autoSelection || !result.receipt.autoSelection.exact); +}); diff --git a/tests/lib/install-request.test.js b/tests/lib/install-request.test.js index 9e580918c..276591f68 100644 --- a/tests/lib/install-request.test.js +++ b/tests/lib/install-request.test.js @@ -143,6 +143,60 @@ function runTests() { assert.deepStrictEqual(request.includeComponentIds, ['locale:ja']); })) passed++; else failed++; + if (test('accepts canonical locale components with legacy languages on Claude targets', () => { + for (const target of ['claude', 'claude-project']) { + const request = normalizeInstallRequest(parseInstallArgs([ + 'node', 'install-apply.js', '--target', target, + '--with', 'locale:zh-cn', '--with', 'locale:ja', + 'typescript', '--no-hooks', + ])); + assert.strictEqual(request.mode, 'legacy-compat'); + assert.deepStrictEqual(request.includeComponentIds, ['locale:zh-cn', 'locale:ja']); + assert.deepStrictEqual(request.legacyLanguages, ['typescript']); + assert.strictEqual(request.hookConsent, 'declined'); + } + })) passed++; else failed++; + + if (test('accepts config-backed locale components with legacy languages', () => { + const request = normalizeInstallRequest({ + languages: ['typescript'], + config: { target: 'claude', includeComponentIds: ['locale:zh-cn'] }, + }); + assert.strictEqual(request.mode, 'legacy-compat'); + assert.deepStrictEqual(request.includeComponentIds, ['locale:zh-cn']); + })) passed++; else failed++; + + if (test('keeps non-locale manifest selections incompatible with legacy languages', () => { + const invalidSelections = [ + { profileId: 'core' }, + { moduleIds: ['rules-core'] }, + { includeComponentIds: ['locale:zh-cn', 'skill:tdd-workflow'] }, + { includeComponentIds: ['locale:fr'] }, + { includeComponentIds: ['locale:zh-CN'] }, + { excludeComponentIds: ['skill:tdd-workflow'] }, + { config: { includeComponentIds: ['skill:tdd-workflow'] } }, + { target: 'cursor' }, + { target: 'antigravity' }, + ]; + for (const selection of invalidSelections) { + assert.throws(() => normalizeInstallRequest({ + target: 'claude', languages: ['typescript'], + includeComponentIds: ['locale:zh-cn'], ...selection, + }), /cannot be combined/, JSON.stringify(selection)); + } + })) passed++; else failed++; + + if (test('locale components without legacy languages retain manifest mode', () => { + for (const profileId of [null, 'core']) { + const request = normalizeInstallRequest({ + target: 'claude', profileId, includeComponentIds: ['locale:zh-cn'], + }); + assert.strictEqual(request.mode, 'manifest'); + assert.strictEqual(request.profileId, profileId); + assert.deepStrictEqual(request.includeComponentIds, ['locale:zh-cn']); + } + })) passed++; else failed++; + if (test('rejects unsupported locale codes', () => { assert.throws( () => normalizeInstallRequest({ diff --git a/tests/lib/statusline-render.test.js b/tests/lib/statusline-render.test.js new file mode 100644 index 000000000..a46fd96f4 --- /dev/null +++ b/tests/lib/statusline-render.test.js @@ -0,0 +1,288 @@ +/** + * Tests for scripts/lib/statusline-render.js + * + * Run with: node tests/lib/statusline-render.test.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + C, + G, + pctColor, + buildBar, + formatCountdown, + formatMs, + computeCacheStats, + buildUsageLine, + buildSessionLine, + buildEccLine, + readInstalledPlugins, + getHooksSummary, +} = require('../../scripts/lib/statusline-render'); + +function test(name, fn) { + try { + fn(); + console.log(` \u2713 ${name}`); + return true; + } catch (err) { + console.log(` \u2717 ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +function runTests() { + console.log('\n=== Testing statusline-render.js ===\n'); + + let passed = 0; + let failed = 0; + const t = (name, fn) => (test(name, fn) ? passed++ : failed++); + + console.log('buildBar:'); + t('0% renders all empty blocks', () => { + assert.strictEqual(buildBar(0, 8), G.empty.repeat(8)); + }); + t('100% renders all full blocks', () => { + assert.strictEqual(buildBar(100, 8), G.full.repeat(8)); + }); + t('50% renders half full', () => { + assert.strictEqual(buildBar(50, 8), G.full.repeat(4) + G.empty.repeat(4)); + }); + t('clamps values beyond 100', () => { + assert.strictEqual(buildBar(250, 4), G.full.repeat(4)); + }); + t('clamps negative values', () => { + assert.strictEqual(buildBar(-10, 4), G.empty.repeat(4)); + }); + + console.log('\nglyphSet:'); + t('non-UTF-8 locale falls back to ASCII glyphs', () => { + const { glyphSet, ASCII_GLYPHS } = require('../../scripts/lib/statusline-render'); + assert.strictEqual(glyphSet({ LANG: 'C' }), ASCII_GLYPHS); + assert.strictEqual(glyphSet({}), ASCII_GLYPHS); + }); + t('UTF-8 locale uses unicode glyphs', () => { + const { glyphSet, UNICODE_GLYPHS } = require('../../scripts/lib/statusline-render'); + assert.strictEqual(glyphSet({ LANG: 'en_US.UTF-8' }), UNICODE_GLYPHS); + assert.strictEqual(glyphSet({ LC_ALL: 'C.utf8' }), UNICODE_GLYPHS); + }); + t('ECC_BAR_GLYPHS forces either set', () => { + const { glyphSet, ASCII_GLYPHS, UNICODE_GLYPHS } = require('../../scripts/lib/statusline-render'); + assert.strictEqual(glyphSet({ LANG: 'en_US.UTF-8', ECC_BAR_GLYPHS: 'ascii' }), ASCII_GLYPHS); + assert.strictEqual(glyphSet({ LANG: 'C', ECC_BAR_GLYPHS: 'unicode' }), UNICODE_GLYPHS); + }); + + console.log('\npctColor:'); + t('below 60 uses brand color', () => { + assert.strictEqual(pctColor(59, C.amber), C.amber); + }); + t('60-79 is yellow', () => { + assert.strictEqual(pctColor(60), C.yellow); + }); + t('80-89 is orange', () => { + assert.strictEqual(pctColor(80), C.orange); + }); + t('90+ is red', () => { + assert.strictEqual(pctColor(95), C.red); + }); + + console.log('\nformatCountdown:'); + t('past timestamp returns empty', () => { + assert.strictEqual(formatCountdown(Date.now() / 1000 - 60), ''); + }); + t('invalid input returns empty', () => { + assert.strictEqual(formatCountdown(null), ''); + assert.strictEqual(formatCountdown('soon'), ''); + }); + t('minutes-scale countdown ends with m', () => { + const result = formatCountdown(Date.now() / 1000 + 42 * 60); + assert.ok(/^\d+m$/.test(result), `got: ${result}`); + }); + t('hours-scale countdown contains h', () => { + const result = formatCountdown(Date.now() / 1000 + 90 * 60); + assert.ok(result.includes('h'), `got: ${result}`); + }); + t('days-scale countdown ends with d', () => { + const result = formatCountdown(Date.now() / 1000 + 3 * 24 * 3600 + 60); + assert.ok(/^\d+d$/.test(result), `got: ${result}`); + }); + + console.log('\nformatMs:'); + t('zero returns empty', () => { + assert.strictEqual(formatMs(0), ''); + }); + t('seconds scale', () => { + assert.strictEqual(formatMs(30 * 1000), '30s'); + }); + t('minutes scale', () => { + assert.strictEqual(formatMs(5 * 60 * 1000), '5m'); + }); + t('hours scale with remainder', () => { + assert.strictEqual(formatMs((3 * 60 + 11) * 60 * 1000), '3h11m'); + }); + + console.log('\ncomputeCacheStats:'); + t('null usage returns null', () => { + assert.strictEqual(computeCacheStats(null), null); + }); + t('zero tokens returns null', () => { + assert.strictEqual(computeCacheStats({ input_tokens: 0 }), null); + }); + t('computes hit percentage from cache reads', () => { + const stats = computeCacheStats({ + input_tokens: 10, + cache_creation_input_tokens: 10, + cache_read_input_tokens: 80, + }); + assert.strictEqual(stats.hitPct, 80); + }); + + console.log('\nbuildUsageLine:'); + t('no rate limits returns empty string', () => { + assert.strictEqual(buildUsageLine(undefined, { hitPct: 90 }), ''); + assert.strictEqual(buildUsageLine({}, null), ''); + }); + t('renders both windows with percentages', () => { + const line = buildUsageLine({ + five_hour: { used_percentage: 71, resets_at: Date.now() / 1000 + 3600 }, + seven_day: { used_percentage: 23, resets_at: Date.now() / 1000 + 86400 * 3 }, + }, null); + assert.ok(line.includes('5h'), 'expected 5h label'); + assert.ok(line.includes('7d'), 'expected 7d label'); + assert.ok(line.includes('71%'), 'expected 5h percentage'); + assert.ok(line.includes('23%'), 'expected 7d percentage'); + assert.ok(line.includes(G.reset), 'expected reset countdown'); + }); + t('renders a single window when only one exists', () => { + const line = buildUsageLine({ five_hour: { used_percentage: 5 } }, null); + assert.ok(line.includes('5h'), 'expected 5h label'); + assert.ok(!line.includes('7d'), 'no 7d label expected'); + }); + t('appends cache hit rate when bars exist', () => { + const line = buildUsageLine({ five_hour: { used_percentage: 5 } }, { hitPct: 98 }); + assert.ok(line.includes('cache'), 'expected cache segment'); + assert.ok(line.includes('98%'), 'expected cache percentage'); + }); + + console.log('\nbuildSessionLine:'); + t('shows model with effort badge', () => { + const line = buildSessionLine({ model: 'Opus 5', effort: 'xhigh' }); + assert.ok(line.includes('Opus 5'), 'expected model name'); + assert.ok(line.includes('xhigh'), 'expected effort badge'); + }); + t('default effort high shows no badge', () => { + const line = buildSessionLine({ model: 'Opus 5', effort: 'high' }); + assert.ok(!line.includes('high'), 'no badge expected for default effort'); + }); + t('shows cost, diff, and task', () => { + const line = buildSessionLine({ + model: 'Opus 5', + costUsd: 1.234, + linesAdded: 11, + linesRemoved: 4, + durationMs: 60 * 1000, + task: 'Fixing auth bug', + }); + assert.ok(line.includes('$1.23'), 'expected cost'); + assert.ok(line.includes('+11/-4'), 'expected diff stats'); + assert.ok(line.includes('1m'), 'expected duration'); + assert.ok(line.includes('Fixing auth bug'), 'expected task'); + }); + t('shows 1M context window size', () => { + const line = buildSessionLine({ model: 'Opus 5', ctxBar: ' bar 10%', ctxWindowSize: 1000000 }); + assert.ok(line.includes('1M'), 'expected 1M badge'); + }); + + console.log('\nbuildEccLine:'); + t('shows version, hooks profile, plugins, dirname', () => { + const line = buildEccLine({ + eccVersion: '2.2.0', + hooks: { enabled: true, profile: 'standard', disabledCount: 0 }, + plugins: [{ name: 'ecc', version: '2.2.0' }, { name: 'swift-lsp', version: '1.0.0' }], + dirname: 'myproject', + }); + assert.ok(line.includes('ECC 2.2.0'), 'expected ECC version'); + assert.ok(line.includes('hooks standard'), 'expected hooks profile'); + assert.ok(line.includes('swift-lsp'), 'expected plugin name'); + assert.ok(line.includes('1.0.0'), 'expected plugin version'); + assert.ok(line.includes('myproject'), 'expected dirname'); + }); + t('disabled hooks render hooks off', () => { + const line = buildEccLine({ hooks: { enabled: false, profile: 'standard', disabledCount: 0 } }); + assert.ok(line.includes('hooks off'), 'expected hooks off'); + }); + t('disabled hook count is shown', () => { + const line = buildEccLine({ hooks: { enabled: true, profile: 'minimal', disabledCount: 2 } }); + assert.ok(line.includes('(2 off)'), 'expected disabled count'); + }); + t('caps plugin list at 4 with overflow count', () => { + const plugins = ['a', 'b', 'c', 'd', 'e', 'f'].map(name => ({ name, version: '1.0.0' })); + const line = buildEccLine({ plugins }); + assert.ok(line.includes('+2'), 'expected +2 overflow'); + }); + + console.log('\nreadInstalledPlugins:'); + t('reads v2 plugin file and filters disabled plugins, ecc first', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-render-test-')); + try { + fs.mkdirSync(path.join(tmpDir, 'plugins'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'plugins', 'installed_plugins.json'), JSON.stringify({ + version: 2, + plugins: { + 'zeta@market': [{ version: '0.5.0', scope: 'user' }], + 'ecc@ecc': [{ version: '2.2.0', scope: 'user' }], + 'off-plugin@market': [{ version: '9.9.9', scope: 'user' }], + }, + })); + fs.writeFileSync(path.join(tmpDir, 'settings.json'), JSON.stringify({ + enabledPlugins: { 'zeta@market': true, 'ecc@ecc': true, 'off-plugin@market': false }, + })); + + const plugins = readInstalledPlugins(tmpDir); + assert.strictEqual(plugins.length, 2); + assert.strictEqual(plugins[0].name, 'ecc'); + assert.strictEqual(plugins[0].version, '2.2.0'); + assert.strictEqual(plugins[1].name, 'zeta'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + t('missing plugin file returns empty array', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-render-test-')); + try { + assert.deepStrictEqual(readInstalledPlugins(tmpDir), []); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + console.log('\ngetHooksSummary:'); + t('defaults to enabled standard profile', () => { + const summary = getHooksSummary({}); + assert.strictEqual(summary.enabled, true); + assert.strictEqual(summary.profile, 'standard'); + assert.strictEqual(summary.disabledCount, 0); + }); + t('respects ECC_HOOKS_ENABLED=false', () => { + assert.strictEqual(getHooksSummary({ ECC_HOOKS_ENABLED: 'false' }).enabled, false); + }); + t('reports profile and disabled hook count', () => { + const summary = getHooksSummary({ + ECC_HOOK_PROFILE: 'minimal', + ECC_DISABLED_HOOKS: 'a,b', + }); + assert.strictEqual(summary.profile, 'minimal'); + assert.strictEqual(summary.disabledCount, 2); + }); + + console.log(`\nResults: ${passed} passed, ${failed} failed\n`); + return { passed, failed }; +} + +const { failed } = runTests(); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/scripts/auto-update.test.js b/tests/scripts/auto-update.test.js index 9533fd67b..98d678902 100644 --- a/tests/scripts/auto-update.test.js +++ b/tests/scripts/auto-update.test.js @@ -6,6 +6,9 @@ const assert = require('assert'); const fs = require('fs'); const os = require('os'); const path = require('path'); +const { execFileSync } = require('child_process'); +const { LOCALE_ALIAS_TO_COMPONENT_ID } = require('../../scripts/lib/install-manifests'); +const { normalizeInstallRequest, parseInstallArgs } = require('../../scripts/lib/install/request'); const { parseArgs, @@ -157,6 +160,76 @@ function runTests() { ]); })) passed += 1; else failed += 1; + if (test('replays every locale alias with legacy languages and hook consent', () => { + for (const target of ['claude', 'claude-project']) { + for (const [alias, componentId] of Object.entries(LOCALE_ALIAS_TO_COMPONENT_ID)) { + const original = normalizeInstallRequest(parseInstallArgs([ + 'node', 'install-apply.js', '--target', target, + '--locale', alias, 'typescript', 'python', '--no-hooks', + ])); + const record = makeRecord({ + repoRoot: '/tmp/ecc', homeDir: '/tmp/home', projectRoot: '/tmp/project', + adapter: { id: target, target, kind: target === 'claude' ? 'home' : 'project' }, + request: { + profile: original.profileId, modules: original.moduleIds, + includeComponents: original.includeComponentIds, + excludeComponents: original.excludeComponentIds, + legacyLanguages: original.legacyLanguages, + legacyMode: true, hookConsent: original.hookConsent, + }, + resolution: { selectedModules: [], skippedModules: [] }, operations: [], + }); + const replay = normalizeInstallRequest(parseInstallArgs([ + 'node', 'install-apply.js', ...buildInstallApplyArgs(record), + ])); + assert.deepStrictEqual(replay, original, `${target}: ${alias} -> ${componentId}`); + } + } + })) passed += 1; else failed += 1; + + for (const target of ['claude', 'claude-project']) { + if (test(`end-to-end: auto-update plans a recorded ${target} locale and language install`, () => { + const fixture = createTempDir('auto-update-locale-'); + const homeDir = path.join(fixture, 'home'); + const projectRoot = path.join(fixture, 'project'); + const repoRoot = path.resolve(__dirname, '..', '..'); + const env = { ...process.env, HOME: homeDir, USERPROFILE: homeDir, ECC_DRY_RUN: '0' }; + fs.mkdirSync(homeDir); + fs.mkdirSync(projectRoot); + try { + const installed = JSON.parse(execFileSync(process.execPath, [ + path.join(repoRoot, 'scripts', 'install-apply.js'), + '--target', target, '--locale', 'zh', 'typescript', '--no-hooks', '--json', + ], { cwd: projectRoot, env, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 })); + assert.strictEqual(installed.result.applied, true); + const statePath = installed.result.installStatePath; + const stateBefore = fs.readFileSync(statePath, 'utf8'); + const state = JSON.parse(stateBefore); + assert.deepStrictEqual(state.request.includeComponents, ['locale:zh-cn']); + assert.deepStrictEqual(state.request.legacyLanguages, ['typescript']); + + const updated = JSON.parse(execFileSync(process.execPath, [ + path.join(repoRoot, 'scripts', 'auto-update.js'), '--target', target, + '--repo-root', repoRoot, '--dry-run', '--json', + ], { cwd: projectRoot, env, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 })); + assert.strictEqual(updated.summary.errorCount, 0); + assert.strictEqual(updated.summary.checkedCount, 1); + assert.strictEqual(updated.results[0].status, 'planned'); + const plan = updated.results[0].payload.plan; + assert.strictEqual(plan.mode, 'legacy-compat'); + assert.deepStrictEqual(plan.legacyLanguages, ['typescript']); + assert.deepStrictEqual(plan.includedComponentIds, ['locale:zh-cn']); + assert.ok(plan.selectedModuleIds.includes('docs-zh-cn')); + assert.ok(plan.selectedModuleIds.includes('framework-language')); + assert.ok(!plan.selectedModuleIds.includes('hooks-runtime')); + assert.strictEqual(plan.statePreview.request.hookConsent, 'declined'); + assert.strictEqual(fs.readFileSync(statePath, 'utf8'), stateBefore); + } finally { + cleanup(fixture); + } + })) passed += 1; else failed += 1; + } + if (test('buildInstallApplyArgs reconstructs manifest installs', () => { const record = { adapter: { target: 'cursor', kind: 'project' }, diff --git a/tests/scripts/install-guided-global-dry-run.test.js b/tests/scripts/install-guided-global-dry-run.test.js new file mode 100644 index 000000000..40e6ae8fa --- /dev/null +++ b/tests/scripts/install-guided-global-dry-run.test.js @@ -0,0 +1,47 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const test = require('node:test'); + +const repoRoot = path.resolve(__dirname, '../..'); + +for (const entry of ['install-guided.js', 'install-apply.js', 'ecc.js']) { + test(`global dry-run protects the actual ${entry} guided workflow`, () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-guided-global-preview-')); + try { + const home = path.join(root, 'home'); + const project = path.join(root, 'project'); + fs.mkdirSync(home); + fs.mkdirSync(project); + const args = entry === 'ecc.js' ? ['--dry-run', 'install', '--guided'] + : entry === 'install-apply.js' ? ['--guided'] : []; + const child = spawnSync(process.execPath, [path.join(repoRoot, 'scripts', entry), + ...args, '--harness', 'kimi', '--profile', 'minimal', '--yes', '--json'], { + cwd: project, encoding: 'utf8', timeout: 30000, maxBuffer: 16 * 1024 * 1024, + env: { ...process.env, HOME: home, USERPROFILE: home, + CLAUDE_CONFIG_DIR: path.join(home, '.claude'), ECC_DRY_RUN: '1' }, + }); + assert.equal(child.status, 0, child.stderr); + const payload = JSON.parse(child.stdout); + assert.equal(payload.dryRun, true); + assert.equal(payload.plan.request.dryRun, true); + assert.deepEqual(fs.readdirSync(home), []); + assert.deepEqual(fs.readdirSync(project), []); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +} + +test('invalid global dry-run input is rejected before guided planning', () => { + const child = spawnSync(process.execPath, [path.join(repoRoot, 'scripts/install-guided.js'), + '--harness', 'kimi', '--yes', '--json'], { + encoding: 'utf8', env: { ...process.env, ECC_DRY_RUN: 'yes' }, + }); + assert.equal(child.status, 1); + assert.match(JSON.parse(child.stderr).error.message, /ECC_DRY_RUN must be "1" or "0"/); +});