fix: resolve four bug reports (#2290, #2282, #2276, #2272)

- #2290 suggest-compact: honor ECC_CONTEXT_WINDOW_TOKENS / CLAUDE_CODE_AUTO_COMPACT_WINDOW
  so 400k-window models (Opus 4.x) no longer report ~double context usage; add
  override + isolation tests in transcript-context.test.js.
- #2282 install: bare-language syntax is legacy-only by design, but the error
  now distinguishes a supported-but-wrong-mode target (gemini/codex/…) from a
  genuinely unknown one and points to --profile/--modules/--skills.
- #2276 cost-report: the command + cost-tracking skill targeted a SQLite DB no
  tracker writes. Repoint both at the real ~/.claude/metrics/costs.jsonl (JSONL,
  estimated_cost_usd), reduce cumulative-per-session snapshots to latest-per-session,
  and use node instead of sqlite3 for cross-platform support.
- #2272 gateguard: make the 'confirm no existing file' checklist item
  tool-agnostic (Glob/Grep or find/grep via Bash) so hosts without a Glob tool
  don't get a dead tool call.

Full suite 2839/2839; lint green.
This commit is contained in:
Affaan Mustafa 2026-06-18 16:49:58 -04:00
parent 51184b692e
commit b3268fef80
7 changed files with 325 additions and 424 deletions

View File

@ -1,107 +1,81 @@
--- ---
description: Generate a local Claude Code cost report from a cost-tracker SQLite database. description: Generate a local Claude Code cost report from the ECC cost-tracker metrics log.
argument-hint: [csv] argument-hint: [csv]
--- ---
# Cost Report # Cost Report
Query the local cost-tracking database and present a spending report by day, Summarize local Claude Code spend by day, model, and session from the metrics
project, tool, and session. This command assumes a cost-tracking hook or plugin log that ECC's `stop:cost-tracker` hook writes.
is already writing usage rows to `~/.claude-cost-tracker/usage.db`.
## What This Command Does ## Where the data lives
1. Check that `sqlite3` is available. The tracker appends one JSON object per session-stop to
2. Check that `~/.claude-cost-tracker/usage.db` exists. `~/.claude/metrics/costs.jsonl`. Each row is a **cumulative snapshot for that
3. Run aggregate queries against the `usage` table. session**, so the report takes the **latest row per `session_id`** and sums
4. Present a compact report, or export recent rows as CSV when the argument is across sessions (summing every row would multiply-count).
`csv`.
## Prerequisites Row schema:
`{ timestamp, session_id, transcript_path, model, input_tokens, output_tokens, cache_write_tokens, cache_read_tokens, estimated_cost_usd }`
The database must be populated by a local cost tracker. If the file is missing, ## What this command does
tell the user the tracker is not set up and suggest installing or enabling a
trusted Claude Code cost-tracking hook/plugin first. 1. Check that `~/.claude/metrics/costs.jsonl` exists. If it does not, tell the
user the tracker is not set up yet (it populates after the first session ends
with the `stop:cost-tracker` hook enabled).
2. Reduce rows to the latest snapshot per session and aggregate.
3. Present a compact report, or export recent rows as CSV when the argument is `csv`.
`node` is used instead of `sqlite3`/`jq` so this works identically on macOS,
Linux, and Windows.
## Report
```bash ```bash
test -f ~/.claude-cost-tracker/usage.db && echo "Database found" || echo "Database not found" node -e '
const fs=require("fs"),os=require("os"),path=require("path");
const f=path.join(os.homedir(),".claude","metrics","costs.jsonl");
if(!fs.existsSync(f)){console.log("Cost tracker not set up: "+f+" not found. Enable the stop:cost-tracker hook and finish a session first.");process.exit(0);}
const rows=fs.readFileSync(f,"utf8").split(/\r?\n/).filter(Boolean).map(l=>{try{return JSON.parse(l)}catch{return null}}).filter(Boolean);
const bySession=new Map();
for(const r of rows){const k=r.session_id||r.transcript_path||r.timestamp;const p=bySession.get(k);if(!p||String(r.timestamp)>String(p.timestamp))bySession.set(k,r);}
const latest=[...bySession.values()];
const cost=r=>Number(r.estimated_cost_usd)||0;
const day=r=>String(r.timestamp||"").slice(0,10);
const today=new Date().toISOString().slice(0,10);
const d=new Date(Date.now()-864e5).toISOString().slice(0,10);
const sum=a=>a.reduce((s,r)=>s+cost(r),0);
const f4=n=>"$"+n.toFixed(4);
console.log("=== Cost summary ===");
console.log("today: "+f4(sum(latest.filter(r=>day(r)===today))));
console.log("yesterday: "+f4(sum(latest.filter(r=>day(r)===d))));
console.log("total: "+f4(sum(latest))+" ("+latest.length+" sessions)");
const by=(key)=>{const m=new Map();for(const r of latest){const k=key(r)||"(unknown)";m.set(k,(m.get(k)||0)+cost(r));}return [...m.entries()].sort((a,b)=>b[1]-a[1]);};
console.log("\n=== By model ===");for(const [k,v] of by(r=>r.model))console.log(f4(v).padStart(12)+" "+k);
console.log("\n=== Last 7 days ===");
const days=new Map();for(const r of latest){const k=day(r);days.set(k,(days.get(k)||0)+cost(r));}
[...days.entries()].sort((a,b)=>b[0]<a[0]?-1:1).slice(0,7).forEach(([k,v])=>console.log(k+" "+f4(v)));
'
``` ```
## Summary Query ## CSV export (`/cost-report csv`)
```bash ```bash
sqlite3 -header -column ~/.claude-cost-tracker/usage.db " node -e '
SELECT const fs=require("fs"),os=require("os"),path=require("path");
ROUND(COALESCE(SUM(CASE WHEN date(timestamp) = date('now') THEN cost_usd END), 0), 4) AS today_cost, const f=path.join(os.homedir(),".claude","metrics","costs.jsonl");
ROUND(COALESCE(SUM(CASE WHEN date(timestamp) = date('now', '-1 day') THEN cost_usd END), 0), 4) AS yesterday_cost, if(!fs.existsSync(f)){console.error("no data");process.exit(0);}
ROUND(COALESCE(SUM(cost_usd), 0), 4) AS total_cost, const rows=fs.readFileSync(f,"utf8").split(/\r?\n/).filter(Boolean).map(l=>{try{return JSON.parse(l)}catch{return null}}).filter(Boolean).slice(-100);
COUNT(*) AS total_calls, console.log("timestamp,session_id,model,input_tokens,output_tokens,cache_write_tokens,cache_read_tokens,estimated_cost_usd");
COUNT(DISTINCT session_id) AS sessions for(const r of rows)console.log([r.timestamp,r.session_id,r.model,r.input_tokens,r.output_tokens,r.cache_write_tokens,r.cache_read_tokens,r.estimated_cost_usd].join(","));
FROM usage; '
"
``` ```
## Project Breakdown ## Report format
```bash 1. Summary: today, yesterday, total, session count.
sqlite3 -header -column ~/.claude-cost-tracker/usage.db " 2. By model: models ranked by total cost.
SELECT project, ROUND(SUM(cost_usd), 4) AS cost, COUNT(*) AS calls 3. Last seven days: date and cost.
FROM usage
GROUP BY project
ORDER BY cost DESC;
"
```
## Tool Breakdown Rely on the precomputed `estimated_cost_usd` values written by the tracker; do
not re-estimate pricing from raw tokens here.
```bash
sqlite3 -header -column ~/.claude-cost-tracker/usage.db "
SELECT tool_name, ROUND(SUM(cost_usd), 4) AS cost, COUNT(*) AS calls
FROM usage
GROUP BY tool_name
ORDER BY cost DESC;
"
```
## Last Seven Days
```bash
sqlite3 -header -column ~/.claude-cost-tracker/usage.db "
SELECT date(timestamp) AS date, ROUND(SUM(cost_usd), 4) AS cost, COUNT(*) AS calls
FROM usage
GROUP BY date(timestamp)
ORDER BY date DESC
LIMIT 7;
"
```
## CSV Export
If the user asks for `/cost-report csv`, export the most recent usage rows with
an explicit column list:
```bash
sqlite3 -csv -header ~/.claude-cost-tracker/usage.db "
SELECT timestamp, project, tool_name, input_tokens, output_tokens, cost_usd, session_id, model
FROM usage
ORDER BY timestamp DESC
LIMIT 100;
"
```
## Report Format
Format the response as:
1. Summary: today, yesterday, total, calls, sessions.
2. By project: projects ranked by total cost.
3. By tool: tools ranked by total cost.
4. Last seven days: date, cost, call count.
Use four decimal places for sub-dollar amounts. Do not estimate pricing from raw
tokens in this command; rely on the precomputed `cost_usd` values written by the
tracker.
## Source
Salvaged from stale community PR #1304 by `MayurBhavsar`.

View File

@ -49,7 +49,7 @@
}, },
{ {
"command": "cost-report", "command": "cost-report",
"description": "Generate a local Claude Code cost report from a cost-tracker SQLite database.", "description": "Generate a local Claude Code cost report from the ECC cost-tracker metrics log.",
"type": "testing", "type": "testing",
"primaryAgents": [], "primaryAgents": [],
"allAgents": [], "allAgents": [],

View File

@ -25,11 +25,7 @@
const crypto = require('crypto'); const crypto = require('crypto');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { const { extractCommandSubstitutions, extractSubshellGroups, extractBraceGroups } = require('../lib/shell-substitution');
extractCommandSubstitutions,
extractSubshellGroups,
extractBraceGroups
} = require('../lib/shell-substitution');
// Session state — scoped per session to avoid cross-session races. // Session state — scoped per session to avoid cross-session races.
const STATE_DIR = process.env.GATEGUARD_STATE_DIR || path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.gateguard'); const STATE_DIR = process.env.GATEGUARD_STATE_DIR || path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.gateguard');
@ -88,10 +84,10 @@ function getExtraDestructiveRegex() {
extraDestructiveCacheRegex = null; extraDestructiveCacheRegex = null;
if (!extraDestructiveWarnLogged) { if (!extraDestructiveWarnLogged) {
try { try {
process.stderr.write( process.stderr.write(`[gateguard-fact-force] ignoring invalid GATEGUARD_BASH_EXTRA_DESTRUCTIVE regex: ${err.message}\n`);
`[gateguard-fact-force] ignoring invalid GATEGUARD_BASH_EXTRA_DESTRUCTIVE regex: ${err.message}\n` } catch (_) {
); /* stderr write failure is non-fatal */
} catch (_) { /* stderr write failure is non-fatal */ } }
extraDestructiveWarnLogged = true; extraDestructiveWarnLogged = true;
} }
} }
@ -112,9 +108,7 @@ function isRoutineBashGateDisabled() {
* @returns {string} * @returns {string}
*/ */
function stripQuotedStrings(input) { function stripQuotedStrings(input) {
return input return input.replace(/'(?:[^'\\]|\\.)*'/g, "''").replace(/"(?:[^"\\]|\\.)*"/g, '""');
.replace(/'(?:[^'\\]|\\.)*'/g, "''")
.replace(/"(?:[^"\\]|\\.)*"/g, '""');
} }
/** /**
@ -168,7 +162,6 @@ function tokenize(segment) {
return segment.split(/\s+/).filter(Boolean); return segment.split(/\s+/).filter(Boolean);
} }
/** /**
* Tokenize a short allowlisted shell command while preserving quoted * Tokenize a short allowlisted shell command while preserving quoted
* arguments. This is intentionally smaller than a full shell parser: the * arguments. This is intentionally smaller than a full shell parser: the
@ -236,7 +229,10 @@ function tokenizeAllowlistedShellWords(input) {
*/ */
function commandBasename(token) { function commandBasename(token) {
if (!token) return ''; if (!token) return '';
return token.replace(/^.*[\\/]/, '').replace(/\.exe$/i, '').toLowerCase(); return token
.replace(/^.*[\\/]/, '')
.replace(/\.exe$/i, '')
.toLowerCase();
} }
/** /**
@ -553,7 +549,10 @@ function isDestructiveBash(command) {
// that false-negative while also catching `&&`, `;`, `|`, and `||` compound forms. // that false-negative while also catching `&&`, `;`, `|`, and `||` compound forms.
const bodies = collectExecutableBodies(raw); const bodies = collectExecutableBodies(raw);
for (const body of bodies) { for (const body of bodies) {
for (const rawSeg of body.split(/[;|&]+/).map(s => s.trim()).filter(Boolean)) { for (const rawSeg of body
.split(/[;|&]+/)
.map(s => s.trim())
.filter(Boolean)) {
if (isDestructiveFindExec(rawSeg)) return true; if (isDestructiveFindExec(rawSeg)) return true;
} }
} }
@ -573,7 +572,9 @@ function isDestructiveBash(command) {
// --- State management (per-session, atomic writes, bounded) --- // --- State management (per-session, atomic writes, bounded) ---
function normalizeEnvValue(value) { function normalizeEnvValue(value) {
return String(value || '').trim().toLowerCase(); return String(value || '')
.trim()
.toLowerCase();
} }
function isGateGuardDisabled() { function isGateGuardDisabled() {
@ -886,8 +887,7 @@ function isReadOnlyGitIntrospection(command) {
if (args.length === 2) { if (args.length === 2) {
const [first, second] = args; const [first, second] = args;
// ref + flag // ref + flag
if (!first.startsWith('--') && /^[a-zA-Z0-9._:/ -]+$/.test(first) && if (!first.startsWith('--') && /^[a-zA-Z0-9._:/ -]+$/.test(first) && (second === '--stat' || second === '--name-only')) {
(second === '--stat' || second === '--name-only')) {
return true; return true;
} }
return false; return false;
@ -932,7 +932,7 @@ function writeGateMsg(filePath) {
`Before creating ${safe}, present these facts:`, `Before creating ${safe}, present these facts:`,
'', '',
'1. Name the file(s) and line(s) that will call this new file', '1. Name the file(s) and line(s) that will call this new file',
'2. Confirm no existing file serves the same purpose (use Glob)', '2. Confirm no existing file serves the same purpose (search the tree — Glob/Grep, or find/grep via Bash)',
'3. If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)', '3. If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)',
"4. Quote the user's current instruction verbatim", "4. Quote the user's current instruction verbatim",
'', '',
@ -983,11 +983,7 @@ function routineBashMsg() {
function withRecoveryHint(message, hookIds = [EDIT_WRITE_HOOK_ID]) { function withRecoveryHint(message, hookIds = [EDIT_WRITE_HOOK_ID]) {
const disableTargets = hookIds.map(hookId => `\`${hookId}\``).join(' or '); const disableTargets = hookIds.map(hookId => `\`${hookId}\``).join(' or ');
return [ return [message, '', `Recovery: if GateGuard is blocking setup or repair work, run this session with \`ECC_GATEGUARD=off\` or add ${disableTargets} to \`ECC_DISABLED_HOOKS\`.`].join('\n');
message,
'',
`Recovery: if GateGuard is blocking setup or repair work, run this session with \`ECC_GATEGUARD=off\` or add ${disableTargets} to \`ECC_DISABLED_HOOKS\`.`
].join('\n');
} }
function isSubagentInvocation(data) { function isSubagentInvocation(data) {
@ -995,12 +991,7 @@ function isSubagentInvocation(data) {
return false; return false;
} }
const candidates = [ const candidates = [data.agent_id, data.agentId, data.parent_tool_use_id, data.parentToolUseId];
data.agent_id,
data.agentId,
data.parent_tool_use_id,
data.parentToolUseId
];
return candidates.some(candidate => typeof candidate === 'string' && candidate.trim()); return candidates.some(candidate => typeof candidate === 'string' && candidate.trim());
} }

View File

@ -5,20 +5,12 @@ const { execFileSync } = require('child_process');
const { toCursorAgentRelativePath } = require('./cursor-agent-names'); const { toCursorAgentRelativePath } = require('./cursor-agent-names');
const { LEGACY_INSTALL_TARGETS, parseInstallArgs } = require('./install/request'); const { LEGACY_INSTALL_TARGETS, parseInstallArgs } = require('./install/request');
const { const { SUPPORTED_INSTALL_TARGETS, listLegacyCompatibilityLanguages, resolveLegacyCompatibilitySelection, resolveInstallPlan } = require('./install-manifests');
SUPPORTED_INSTALL_TARGETS,
listLegacyCompatibilityLanguages,
resolveLegacyCompatibilitySelection,
resolveInstallPlan,
} = require('./install-manifests');
const { getInstallTargetAdapter } = require('./install-targets/registry'); const { getInstallTargetAdapter } = require('./install-targets/registry');
const LANGUAGE_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/; const LANGUAGE_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
const CLAUDE_ECC_NAMESPACE = 'ecc'; const CLAUDE_ECC_NAMESPACE = 'ecc';
const EXCLUDED_GENERATED_SOURCE_SUFFIXES = [ const EXCLUDED_GENERATED_SOURCE_SUFFIXES = ['/ecc-install-state.json', '/ecc/install-state.json'];
'/ecc-install-state.json',
'/ecc/install-state.json',
];
function getSourceRoot() { function getSourceRoot() {
return path.join(__dirname, '../..'); return path.join(__dirname, '../..');
@ -26,9 +18,7 @@ function getSourceRoot() {
function getPackageVersion(sourceRoot) { function getPackageVersion(sourceRoot) {
try { try {
const packageJson = JSON.parse( const packageJson = JSON.parse(fs.readFileSync(path.join(sourceRoot, 'package.json'), 'utf8'));
fs.readFileSync(path.join(sourceRoot, 'package.json'), 'utf8')
);
return packageJson.version || null; return packageJson.version || null;
} catch (_error) { } catch (_error) {
return null; return null;
@ -37,9 +27,7 @@ function getPackageVersion(sourceRoot) {
function getManifestVersion(sourceRoot) { function getManifestVersion(sourceRoot) {
try { try {
const modulesManifest = JSON.parse( const modulesManifest = JSON.parse(fs.readFileSync(path.join(sourceRoot, 'manifests', 'install-modules.json'), 'utf8'));
fs.readFileSync(path.join(sourceRoot, 'manifests', 'install-modules.json'), 'utf8')
);
return modulesManifest.version || 1; return modulesManifest.version || 1;
} catch (_error) { } catch (_error) {
return 1; return 1;
@ -52,7 +40,7 @@ function getRepoCommit(sourceRoot) {
cwd: sourceRoot, cwd: sourceRoot,
encoding: 'utf8', encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'], stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000, timeout: 5000
}).trim(); }).trim();
} catch (_error) { } catch (_error) {
return null; return null;
@ -64,32 +52,35 @@ function readDirectoryNames(dirPath) {
return []; return [];
} }
return fs.readdirSync(dirPath, { withFileTypes: true }) return fs
.readdirSync(dirPath, { withFileTypes: true })
.filter(entry => entry.isDirectory()) .filter(entry => entry.isDirectory())
.map(entry => entry.name) .map(entry => entry.name)
.sort(); .sort();
} }
function listAvailableLanguages(sourceRoot = getSourceRoot()) { function listAvailableLanguages(sourceRoot = getSourceRoot()) {
return [...new Set([ return [...new Set([...listLegacyCompatibilityLanguages(), ...readDirectoryNames(path.join(sourceRoot, 'rules')).filter(name => name !== 'common')])].sort();
...listLegacyCompatibilityLanguages(),
...readDirectoryNames(path.join(sourceRoot, 'rules'))
.filter(name => name !== 'common'),
])].sort();
} }
function validateLegacyTarget(target) { function validateLegacyTarget(target) {
if (!LEGACY_INSTALL_TARGETS.includes(target)) { if (LEGACY_INSTALL_TARGETS.includes(target)) {
return;
}
// A target can be fully supported yet not installable via the bare-language
// positional syntax (which is legacy-only). Guide the user to the right mode
// instead of implying the target is unknown (#2282).
if (SUPPORTED_INSTALL_TARGETS.includes(target)) {
throw new Error( throw new Error(
`Unknown install target: ${target}. Expected one of ${LEGACY_INSTALL_TARGETS.join(', ')}` `Target '${target}' is supported, but the bare-language install syntax only accepts ${LEGACY_INSTALL_TARGETS.join(', ')}. ` +
`Install '${target}' with a component selection instead, e.g. \`install.sh --target ${target} --profile full\` ` +
`(or --modules <id,...> / --skills <id,...>).`
); );
} }
throw new Error(`Unknown install target: ${target}. Expected one of ${SUPPORTED_INSTALL_TARGETS.join(', ')}`);
} }
const IGNORED_DIRECTORY_NAMES = new Set([ const IGNORED_DIRECTORY_NAMES = new Set(['node_modules', '.git']);
'node_modules',
'.git',
]);
function listFilesRecursive(dirPath) { function listFilesRecursive(dirPath) {
if (!fs.existsSync(dirPath)) { if (!fs.existsSync(dirPath)) {
@ -141,7 +132,7 @@ function buildCopyFileOperation({ moduleId, sourcePath, sourceRelativePath, dest
destinationPath, destinationPath,
strategy, strategy,
ownership: 'managed', ownership: 'managed',
scaffoldOnly: false, scaffoldOnly: false
}; };
} }
@ -156,20 +147,20 @@ function addRecursiveCopyOperations(operations, options) {
for (const relativeFile of relativeFiles) { for (const relativeFile of relativeFiles) {
const sourceRelativePath = path.join(options.sourceRelativeDir, relativeFile); const sourceRelativePath = path.join(options.sourceRelativeDir, relativeFile);
const sourcePath = path.join(options.sourceRoot, sourceRelativePath); const sourcePath = path.join(options.sourceRoot, sourceRelativePath);
const destinationRelativePath = typeof options.destinationRelativePathTransform === 'function' const destinationRelativePath = typeof options.destinationRelativePathTransform === 'function' ? options.destinationRelativePathTransform(relativeFile, sourceRelativePath) : relativeFile;
? options.destinationRelativePathTransform(relativeFile, sourceRelativePath)
: relativeFile;
if (!destinationRelativePath) { if (!destinationRelativePath) {
continue; continue;
} }
const destinationPath = path.join(options.destinationDir, destinationRelativePath); const destinationPath = path.join(options.destinationDir, destinationRelativePath);
operations.push(buildCopyFileOperation({ operations.push(
moduleId: options.moduleId, buildCopyFileOperation({
sourcePath, moduleId: options.moduleId,
sourceRelativePath, sourcePath,
destinationPath, sourceRelativePath,
strategy: options.strategy || 'preserve-relative-path', destinationPath,
})); strategy: options.strategy || 'preserve-relative-path'
})
);
} }
return relativeFiles.length; return relativeFiles.length;
@ -181,13 +172,15 @@ function addFileCopyOperation(operations, options) {
return false; return false;
} }
operations.push(buildCopyFileOperation({ operations.push(
moduleId: options.moduleId, buildCopyFileOperation({
sourcePath, moduleId: options.moduleId,
sourceRelativePath: options.sourceRelativePath, sourcePath,
destinationPath: options.destinationPath, sourceRelativePath: options.sourceRelativePath,
strategy: options.strategy || 'preserve-relative-path', destinationPath: options.destinationPath,
})); strategy: options.strategy || 'preserve-relative-path'
})
);
return true; return true;
} }
@ -218,7 +211,7 @@ function addCursorAgentDataScaffoldOperations(operations, options) {
sourceRoot: options.sourceRoot, sourceRoot: options.sourceRoot,
sourceRelativePath: path.join('scaffolds', 'cursor', 'ecc-agent-data.json'), sourceRelativePath: path.join('scaffolds', 'cursor', 'ecc-agent-data.json'),
destinationPath: path.join(options.targetRoot, 'ecc-agent-data.json'), destinationPath: path.join(options.targetRoot, 'ecc-agent-data.json'),
strategy: 'preserve-relative-path', strategy: 'preserve-relative-path'
}); });
addFileCopyOperation(operations, { addFileCopyOperation(operations, {
@ -226,21 +219,17 @@ function addCursorAgentDataScaffoldOperations(operations, options) {
sourceRoot: options.sourceRoot, sourceRoot: options.sourceRoot,
sourceRelativePath: path.join('scaffolds', 'cursor', 'rules', 'ecc-agent-data-home.mdc'), sourceRelativePath: path.join('scaffolds', 'cursor', 'rules', 'ecc-agent-data-home.mdc'),
destinationPath: path.join(options.targetRoot, 'rules', 'ecc-agent-data-home.mdc'), destinationPath: path.join(options.targetRoot, 'rules', 'ecc-agent-data-home.mdc'),
strategy: 'preserve-relative-path', strategy: 'preserve-relative-path'
}); });
addJsonMergeOperation(operations, { addJsonMergeOperation(operations, {
moduleId: options.moduleId, moduleId: options.moduleId,
sourceRoot: options.sourceRoot, sourceRoot: options.sourceRoot,
sourceRelativePath: path.join('scaffolds', 'cursor', 'hooks.json'), sourceRelativePath: path.join('scaffolds', 'cursor', 'hooks.json'),
destinationPath: path.join(options.targetRoot, 'hooks.json'), destinationPath: path.join(options.targetRoot, 'hooks.json')
}); });
const cursorSessionHookDeps = [ const cursorSessionHookDeps = [path.join('scripts', 'hooks', 'cursor-session-env.js'), path.join('scripts', 'lib', 'agent-data-home.js'), path.join('scripts', 'lib', 'utils.js')];
path.join('scripts', 'hooks', 'cursor-session-env.js'),
path.join('scripts', 'lib', 'agent-data-home.js'),
path.join('scripts', 'lib', 'utils.js'),
];
for (const sourceRelativePath of cursorSessionHookDeps) { for (const sourceRelativePath of cursorSessionHookDeps) {
addFileCopyOperation(operations, { addFileCopyOperation(operations, {
@ -248,7 +237,7 @@ function addCursorAgentDataScaffoldOperations(operations, options) {
sourceRoot: options.sourceRoot, sourceRoot: options.sourceRoot,
sourceRelativePath, sourceRelativePath,
destinationPath: path.join(options.targetRoot, sourceRelativePath), destinationPath: path.join(options.targetRoot, sourceRelativePath),
strategy: 'preserve-relative-path', strategy: 'preserve-relative-path'
}); });
} }
} }
@ -267,7 +256,7 @@ function addJsonMergeOperation(operations, options) {
strategy: 'merge-json', strategy: 'merge-json',
ownership: 'managed', ownership: 'managed',
scaffoldOnly: false, scaffoldOnly: false,
mergePayload: readJsonObject(sourcePath, options.sourceRelativePath), mergePayload: readJsonObject(sourcePath, options.sourceRelativePath)
}); });
return true; return true;
@ -279,7 +268,8 @@ function addMatchingRuleOperations(operations, options) {
return 0; return 0;
} }
const files = fs.readdirSync(sourceDir, { withFileTypes: true }) const files = fs
.readdirSync(sourceDir, { withFileTypes: true })
.filter(entry => entry.isFile() && options.matcher(entry.name)) .filter(entry => entry.isFile() && options.matcher(entry.name))
.map(entry => entry.name) .map(entry => entry.name)
.sort(); .sort();
@ -287,18 +277,17 @@ function addMatchingRuleOperations(operations, options) {
for (const fileName of files) { for (const fileName of files) {
const sourceRelativePath = path.join(options.sourceRelativeDir, fileName); const sourceRelativePath = path.join(options.sourceRelativeDir, fileName);
const sourcePath = path.join(options.sourceRoot, sourceRelativePath); const sourcePath = path.join(options.sourceRoot, sourceRelativePath);
const destinationPath = path.join( const destinationPath = path.join(options.destinationDir, options.rename ? options.rename(fileName) : fileName);
options.destinationDir,
options.rename ? options.rename(fileName) : fileName
);
operations.push(buildCopyFileOperation({ operations.push(
moduleId: options.moduleId, buildCopyFileOperation({
sourcePath, moduleId: options.moduleId,
sourceRelativePath, sourcePath,
destinationPath, sourceRelativePath,
strategy: options.strategy || 'flatten-copy', destinationPath,
})); strategy: options.strategy || 'flatten-copy'
})
);
} }
return files.length; return files.length;
@ -324,7 +313,7 @@ function planClaudeStyleLegacyInstall(context, { adapterId, adapterRootInput, ru
moduleId: 'legacy-claude-rules', moduleId: 'legacy-claude-rules',
sourceRoot: context.sourceRoot, sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('rules', 'common'), sourceRelativeDir: path.join('rules', 'common'),
destinationDir: path.join(rulesDir, 'common'), destinationDir: path.join(rulesDir, 'common')
}); });
for (const language of context.languages) { for (const language of context.languages) {
@ -343,7 +332,7 @@ function planClaudeStyleLegacyInstall(context, { adapterId, adapterRootInput, ru
moduleId: 'legacy-claude-rules', moduleId: 'legacy-claude-rules',
sourceRoot: context.sourceRoot, sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('rules', language), sourceRelativeDir: path.join('rules', language),
destinationDir: path.join(rulesDir, language), destinationDir: path.join(rulesDir, language)
}); });
} }
@ -356,7 +345,7 @@ function planClaudeStyleLegacyInstall(context, { adapterId, adapterRootInput, ru
installStatePath, installStatePath,
operations, operations,
warnings, warnings,
selectedModules: ['legacy-claude-rules'], selectedModules: ['legacy-claude-rules']
}; };
} }
@ -364,7 +353,7 @@ function planClaudeLegacyInstall(context) {
return planClaudeStyleLegacyInstall(context, { return planClaudeStyleLegacyInstall(context, {
adapterId: 'claude', adapterId: 'claude',
adapterRootInput: { homeDir: context.homeDir }, adapterRootInput: { homeDir: context.homeDir },
rulesDir: context.claudeRulesDir || null, rulesDir: context.claudeRulesDir || null
}); });
} }
@ -372,7 +361,7 @@ function planClaudeProjectLegacyInstall(context) {
return planClaudeStyleLegacyInstall(context, { return planClaudeStyleLegacyInstall(context, {
adapterId: 'claude-project', adapterId: 'claude-project',
adapterRootInput: { repoRoot: context.projectRoot }, adapterRootInput: { repoRoot: context.projectRoot },
rulesDir: null, rulesDir: null
}); });
} }
@ -388,14 +377,12 @@ function planCursorLegacyInstall(context) {
sourceRoot: context.sourceRoot, sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('.cursor', 'rules'), sourceRelativeDir: path.join('.cursor', 'rules'),
destinationDir: path.join(targetRoot, 'rules'), destinationDir: path.join(targetRoot, 'rules'),
matcher: fileName => /^common-.*\.md$/.test(fileName), matcher: fileName => /^common-.*\.md$/.test(fileName)
}); });
for (const language of context.languages) { for (const language of context.languages) {
if (!LANGUAGE_NAME_PATTERN.test(language)) { if (!LANGUAGE_NAME_PATTERN.test(language)) {
warnings.push( warnings.push(`Invalid language name '${language}'. Only alphanumeric, dash, and underscore are allowed`);
`Invalid language name '${language}'. Only alphanumeric, dash, and underscore are allowed`
);
continue; continue;
} }
@ -404,7 +391,7 @@ function planCursorLegacyInstall(context) {
sourceRoot: context.sourceRoot, sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('.cursor', 'rules'), sourceRelativeDir: path.join('.cursor', 'rules'),
destinationDir: path.join(targetRoot, 'rules'), destinationDir: path.join(targetRoot, 'rules'),
matcher: fileName => fileName.startsWith(`${language}-`) && fileName.endsWith('.md'), matcher: fileName => fileName.startsWith(`${language}-`) && fileName.endsWith('.md')
}); });
if (matches === 0) { if (matches === 0) {
@ -417,44 +404,44 @@ function planCursorLegacyInstall(context) {
sourceRoot: context.sourceRoot, sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('.cursor', 'agents'), sourceRelativeDir: path.join('.cursor', 'agents'),
destinationDir: path.join(targetRoot, 'agents'), destinationDir: path.join(targetRoot, 'agents'),
destinationRelativePathTransform: toCursorAgentRelativePath, destinationRelativePathTransform: toCursorAgentRelativePath
}); });
addRecursiveCopyOperations(operations, { addRecursiveCopyOperations(operations, {
moduleId: 'legacy-cursor-install', moduleId: 'legacy-cursor-install',
sourceRoot: context.sourceRoot, sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('.cursor', 'skills'), sourceRelativeDir: path.join('.cursor', 'skills'),
destinationDir: path.join(targetRoot, 'skills'), destinationDir: path.join(targetRoot, 'skills')
}); });
addRecursiveCopyOperations(operations, { addRecursiveCopyOperations(operations, {
moduleId: 'legacy-cursor-install', moduleId: 'legacy-cursor-install',
sourceRoot: context.sourceRoot, sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('.cursor', 'commands'), sourceRelativeDir: path.join('.cursor', 'commands'),
destinationDir: path.join(targetRoot, 'commands'), destinationDir: path.join(targetRoot, 'commands')
}); });
addRecursiveCopyOperations(operations, { addRecursiveCopyOperations(operations, {
moduleId: 'legacy-cursor-install', moduleId: 'legacy-cursor-install',
sourceRoot: context.sourceRoot, sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('.cursor', 'hooks'), sourceRelativeDir: path.join('.cursor', 'hooks'),
destinationDir: path.join(targetRoot, 'hooks'), destinationDir: path.join(targetRoot, 'hooks')
}); });
addFileCopyOperation(operations, { addFileCopyOperation(operations, {
moduleId: 'legacy-cursor-install', moduleId: 'legacy-cursor-install',
sourceRoot: context.sourceRoot, sourceRoot: context.sourceRoot,
sourceRelativePath: path.join('.cursor', 'hooks.json'), sourceRelativePath: path.join('.cursor', 'hooks.json'),
destinationPath: path.join(targetRoot, 'hooks.json'), destinationPath: path.join(targetRoot, 'hooks.json')
}); });
addJsonMergeOperation(operations, { addJsonMergeOperation(operations, {
moduleId: 'legacy-cursor-install', moduleId: 'legacy-cursor-install',
sourceRoot: context.sourceRoot, sourceRoot: context.sourceRoot,
sourceRelativePath: '.mcp.json', sourceRelativePath: '.mcp.json',
destinationPath: path.join(targetRoot, 'mcp.json'), destinationPath: path.join(targetRoot, 'mcp.json')
}); });
addCursorAgentDataScaffoldOperations(operations, { addCursorAgentDataScaffoldOperations(operations, {
moduleId: 'legacy-cursor-install', moduleId: 'legacy-cursor-install',
sourceRoot: context.sourceRoot, sourceRoot: context.sourceRoot,
targetRoot, targetRoot
}); });
return { return {
@ -466,7 +453,7 @@ function planCursorLegacyInstall(context) {
installStatePath, installStatePath,
operations, operations,
warnings, warnings,
selectedModules: ['legacy-cursor-install'], selectedModules: ['legacy-cursor-install']
}; };
} }
@ -478,9 +465,7 @@ function planAntigravityLegacyInstall(context) {
const warnings = []; const warnings = [];
if (isDirectoryNonEmpty(path.join(targetRoot, 'rules'))) { if (isDirectoryNonEmpty(path.join(targetRoot, 'rules'))) {
warnings.push( warnings.push(`Destination ${path.join(targetRoot, 'rules')}/ already exists and files may be overwritten`);
`Destination ${path.join(targetRoot, 'rules')}/ already exists and files may be overwritten`
);
} }
addMatchingRuleOperations(operations, { addMatchingRuleOperations(operations, {
@ -489,14 +474,12 @@ function planAntigravityLegacyInstall(context) {
sourceRelativeDir: path.join('rules', 'common'), sourceRelativeDir: path.join('rules', 'common'),
destinationDir: path.join(targetRoot, 'rules'), destinationDir: path.join(targetRoot, 'rules'),
matcher: fileName => fileName.endsWith('.md'), matcher: fileName => fileName.endsWith('.md'),
rename: fileName => `common-${fileName}`, rename: fileName => `common-${fileName}`
}); });
for (const language of context.languages) { for (const language of context.languages) {
if (!LANGUAGE_NAME_PATTERN.test(language)) { if (!LANGUAGE_NAME_PATTERN.test(language)) {
warnings.push( warnings.push(`Invalid language name '${language}'. Only alphanumeric, dash, and underscore are allowed`);
`Invalid language name '${language}'. Only alphanumeric, dash, and underscore are allowed`
);
continue; continue;
} }
@ -512,7 +495,7 @@ function planAntigravityLegacyInstall(context) {
sourceRelativeDir: path.join('rules', language), sourceRelativeDir: path.join('rules', language),
destinationDir: path.join(targetRoot, 'rules'), destinationDir: path.join(targetRoot, 'rules'),
matcher: fileName => fileName.endsWith('.md'), matcher: fileName => fileName.endsWith('.md'),
rename: fileName => `${language}-${fileName}`, rename: fileName => `${language}-${fileName}`
}); });
} }
@ -520,19 +503,19 @@ function planAntigravityLegacyInstall(context) {
moduleId: 'legacy-antigravity-install', moduleId: 'legacy-antigravity-install',
sourceRoot: context.sourceRoot, sourceRoot: context.sourceRoot,
sourceRelativeDir: 'commands', sourceRelativeDir: 'commands',
destinationDir: path.join(targetRoot, 'workflows'), destinationDir: path.join(targetRoot, 'workflows')
}); });
addRecursiveCopyOperations(operations, { addRecursiveCopyOperations(operations, {
moduleId: 'legacy-antigravity-install', moduleId: 'legacy-antigravity-install',
sourceRoot: context.sourceRoot, sourceRoot: context.sourceRoot,
sourceRelativeDir: 'agents', sourceRelativeDir: 'agents',
destinationDir: path.join(targetRoot, 'skills'), destinationDir: path.join(targetRoot, 'skills')
}); });
addRecursiveCopyOperations(operations, { addRecursiveCopyOperations(operations, {
moduleId: 'legacy-antigravity-install', moduleId: 'legacy-antigravity-install',
sourceRoot: context.sourceRoot, sourceRoot: context.sourceRoot,
sourceRelativeDir: 'skills', sourceRelativeDir: 'skills',
destinationDir: path.join(targetRoot, 'skills'), destinationDir: path.join(targetRoot, 'skills')
}); });
return { return {
@ -544,7 +527,7 @@ function planAntigravityLegacyInstall(context) {
installStatePath, installStatePath,
operations, operations,
warnings, warnings,
selectedModules: ['legacy-antigravity-install'], selectedModules: ['legacy-antigravity-install']
}; };
} }
@ -561,7 +544,7 @@ function createLegacyInstallPlan(options = {}) {
projectRoot, projectRoot,
homeDir, homeDir,
languages: Array.isArray(options.languages) ? options.languages : [], languages: Array.isArray(options.languages) ? options.languages : [],
claudeRulesDir: options.claudeRulesDir || process.env.CLAUDE_RULES_DIR || null, claudeRulesDir: options.claudeRulesDir || process.env.CLAUDE_RULES_DIR || null
}; };
let plan; let plan;
@ -578,7 +561,7 @@ function createLegacyInstallPlan(options = {}) {
const source = { const source = {
repoVersion: getPackageVersion(sourceRoot), repoVersion: getPackageVersion(sourceRoot),
repoCommit: getRepoCommit(sourceRoot), repoCommit: getRepoCommit(sourceRoot),
manifestVersion: getManifestVersion(sourceRoot), manifestVersion: getManifestVersion(sourceRoot)
}; };
const statePreview = createStatePreview({ const statePreview = createStatePreview({
@ -589,14 +572,14 @@ function createLegacyInstallPlan(options = {}) {
profile: null, profile: null,
modules: [], modules: [],
legacyLanguages: context.languages, legacyLanguages: context.languages,
legacyMode: true, legacyMode: true
}, },
resolution: { resolution: {
selectedModules: plan.selectedModules, selectedModules: plan.selectedModules,
skippedModules: [], skippedModules: []
}, },
operations: plan.operations, operations: plan.operations,
source, source
}); });
return { return {
@ -605,7 +588,7 @@ function createLegacyInstallPlan(options = {}) {
adapter: { adapter: {
id: plan.adapter.id, id: plan.adapter.id,
target: plan.adapter.target, target: plan.adapter.target,
kind: plan.adapter.kind, kind: plan.adapter.kind
}, },
targetRoot: plan.targetRoot, targetRoot: plan.targetRoot,
installRoot: plan.installRoot, installRoot: plan.installRoot,
@ -613,7 +596,7 @@ function createLegacyInstallPlan(options = {}) {
warnings: plan.warnings, warnings: plan.warnings,
languages: context.languages, languages: context.languages,
operations: plan.operations, operations: plan.operations,
statePreview, statePreview
}; };
} }
@ -621,19 +604,15 @@ function createLegacyCompatInstallPlan(options = {}) {
const sourceRoot = options.sourceRoot || getSourceRoot(); const sourceRoot = options.sourceRoot || getSourceRoot();
const projectRoot = options.projectRoot || process.cwd(); const projectRoot = options.projectRoot || process.cwd();
const target = options.target || 'claude'; const target = options.target || 'claude';
const includeComponentIds = Array.isArray(options.includeComponentIds) const includeComponentIds = Array.isArray(options.includeComponentIds) ? [...options.includeComponentIds] : [];
? [...options.includeComponentIds] const excludeComponentIds = Array.isArray(options.excludeComponentIds) ? [...options.excludeComponentIds] : [];
: [];
const excludeComponentIds = Array.isArray(options.excludeComponentIds)
? [...options.excludeComponentIds]
: [];
validateLegacyTarget(target); validateLegacyTarget(target);
const selection = resolveLegacyCompatibilitySelection({ const selection = resolveLegacyCompatibilitySelection({
repoRoot: sourceRoot, repoRoot: sourceRoot,
target, target,
legacyLanguages: options.legacyLanguages || [], legacyLanguages: options.legacyLanguages || []
}); });
return createManifestInstallPlan({ return createManifestInstallPlan({
@ -651,25 +630,24 @@ function createLegacyCompatInstallPlan(options = {}) {
requestModuleIds: [], requestModuleIds: [],
requestIncludeComponentIds: includeComponentIds, requestIncludeComponentIds: includeComponentIds,
requestExcludeComponentIds: excludeComponentIds, requestExcludeComponentIds: excludeComponentIds,
mode: 'legacy-compat', mode: 'legacy-compat'
}); });
} }
function materializeScaffoldOperation(sourceRoot, operation) { function materializeScaffoldOperation(sourceRoot, operation) {
if (operation.kind === 'merge-json') { if (operation.kind === 'merge-json') {
return [{ return [
kind: 'merge-json', {
moduleId: operation.moduleId, kind: 'merge-json',
sourceRelativePath: operation.sourceRelativePath, moduleId: operation.moduleId,
destinationPath: operation.destinationPath, sourceRelativePath: operation.sourceRelativePath,
strategy: operation.strategy || 'merge-json', destinationPath: operation.destinationPath,
ownership: operation.ownership || 'managed', strategy: operation.strategy || 'merge-json',
scaffoldOnly: Object.hasOwn(operation, 'scaffoldOnly') ? operation.scaffoldOnly : false, ownership: operation.ownership || 'managed',
mergePayload: readJsonObject( scaffoldOnly: Object.hasOwn(operation, 'scaffoldOnly') ? operation.scaffoldOnly : false,
path.join(sourceRoot, operation.sourceRelativePath), mergePayload: readJsonObject(path.join(sourceRoot, operation.sourceRelativePath), operation.sourceRelativePath)
operation.sourceRelativePath }
), ];
}];
} }
const sourcePath = path.join(sourceRoot, operation.sourceRelativePath); const sourcePath = path.join(sourceRoot, operation.sourceRelativePath);
@ -683,13 +661,15 @@ function materializeScaffoldOperation(sourceRoot, operation) {
const stat = fs.statSync(sourcePath); const stat = fs.statSync(sourcePath);
if (stat.isFile()) { if (stat.isFile()) {
return [buildCopyFileOperation({ return [
moduleId: operation.moduleId, buildCopyFileOperation({
sourcePath, moduleId: operation.moduleId,
sourceRelativePath: operation.sourceRelativePath, sourcePath,
destinationPath: operation.destinationPath, sourceRelativePath: operation.sourceRelativePath,
strategy: operation.strategy, destinationPath: operation.destinationPath,
})]; strategy: operation.strategy
})
];
} }
const relativeFiles = listFilesRecursive(sourcePath).filter(relativeFile => { const relativeFiles = listFilesRecursive(sourcePath).filter(relativeFile => {
@ -703,7 +683,7 @@ function materializeScaffoldOperation(sourceRoot, operation) {
sourcePath: path.join(sourcePath, relativeFile), sourcePath: path.join(sourcePath, relativeFile),
sourceRelativePath, sourceRelativePath,
destinationPath: path.join(operation.destinationPath, relativeFile), destinationPath: path.join(operation.destinationPath, relativeFile),
strategy: operation.strategy, strategy: operation.strategy
}); });
}); });
} }
@ -712,21 +692,19 @@ function createManifestInstallPlan(options = {}) {
const sourceRoot = options.sourceRoot || getSourceRoot(); const sourceRoot = options.sourceRoot || getSourceRoot();
const projectRoot = options.projectRoot || process.cwd(); const projectRoot = options.projectRoot || process.cwd();
const target = options.target || 'claude'; const target = options.target || 'claude';
const legacyLanguages = Array.isArray(options.legacyLanguages) const legacyLanguages = Array.isArray(options.legacyLanguages) ? [...options.legacyLanguages] : [];
? [...options.legacyLanguages] const requestProfileId = Object.hasOwn(options, 'requestProfileId') ? options.requestProfileId : options.profileId || null;
: []; const requestModuleIds = Object.hasOwn(options, 'requestModuleIds') ? [...options.requestModuleIds] : Array.isArray(options.moduleIds) ? [...options.moduleIds] : [];
const requestProfileId = Object.hasOwn(options, 'requestProfileId')
? options.requestProfileId
: (options.profileId || null);
const requestModuleIds = Object.hasOwn(options, 'requestModuleIds')
? [...options.requestModuleIds]
: (Array.isArray(options.moduleIds) ? [...options.moduleIds] : []);
const requestIncludeComponentIds = Object.hasOwn(options, 'requestIncludeComponentIds') const requestIncludeComponentIds = Object.hasOwn(options, 'requestIncludeComponentIds')
? [...options.requestIncludeComponentIds] ? [...options.requestIncludeComponentIds]
: (Array.isArray(options.includeComponentIds) ? [...options.includeComponentIds] : []); : Array.isArray(options.includeComponentIds)
? [...options.includeComponentIds]
: [];
const requestExcludeComponentIds = Object.hasOwn(options, 'requestExcludeComponentIds') const requestExcludeComponentIds = Object.hasOwn(options, 'requestExcludeComponentIds')
? [...options.requestExcludeComponentIds] ? [...options.requestExcludeComponentIds]
: (Array.isArray(options.excludeComponentIds) ? [...options.excludeComponentIds] : []); : Array.isArray(options.excludeComponentIds)
? [...options.excludeComponentIds]
: [];
const plan = resolveInstallPlan({ const plan = resolveInstallPlan({
repoRoot: sourceRoot, repoRoot: sourceRoot,
projectRoot, projectRoot,
@ -735,14 +713,14 @@ function createManifestInstallPlan(options = {}) {
moduleIds: options.moduleIds || [], moduleIds: options.moduleIds || [],
includeComponentIds: options.includeComponentIds || [], includeComponentIds: options.includeComponentIds || [],
excludeComponentIds: options.excludeComponentIds || [], excludeComponentIds: options.excludeComponentIds || [],
target, target
}); });
const adapter = getInstallTargetAdapter(target); const adapter = getInstallTargetAdapter(target);
const operations = plan.operations.flatMap(operation => materializeScaffoldOperation(sourceRoot, operation)); const operations = plan.operations.flatMap(operation => materializeScaffoldOperation(sourceRoot, operation));
const source = { const source = {
repoVersion: getPackageVersion(sourceRoot), repoVersion: getPackageVersion(sourceRoot),
repoCommit: getRepoCommit(sourceRoot), repoCommit: getRepoCommit(sourceRoot),
manifestVersion: getManifestVersion(sourceRoot), manifestVersion: getManifestVersion(sourceRoot)
}; };
const statePreview = createStatePreview({ const statePreview = createStatePreview({
adapter, adapter,
@ -754,14 +732,14 @@ function createManifestInstallPlan(options = {}) {
includeComponents: requestIncludeComponentIds, includeComponents: requestIncludeComponentIds,
excludeComponents: requestExcludeComponentIds, excludeComponents: requestExcludeComponentIds,
legacyLanguages, legacyLanguages,
legacyMode: Boolean(options.legacyMode), legacyMode: Boolean(options.legacyMode)
}, },
resolution: { resolution: {
selectedModules: plan.selectedModuleIds, selectedModules: plan.selectedModuleIds,
skippedModules: plan.skippedModuleIds, skippedModules: plan.skippedModuleIds
}, },
operations, operations,
source, source
}); });
return { return {
@ -770,7 +748,7 @@ function createManifestInstallPlan(options = {}) {
adapter: { adapter: {
id: adapter.id, id: adapter.id,
target: adapter.target, target: adapter.target,
kind: adapter.kind, kind: adapter.kind
}, },
targetRoot: plan.targetRoot, targetRoot: plan.targetRoot,
installRoot: plan.targetRoot, installRoot: plan.targetRoot,
@ -787,7 +765,7 @@ function createManifestInstallPlan(options = {}) {
skippedModuleIds: plan.skippedModuleIds, skippedModuleIds: plan.skippedModuleIds,
excludedModuleIds: plan.excludedModuleIds, excludedModuleIds: plan.excludedModuleIds,
operations, operations,
statePreview, statePreview
}; };
} }
@ -800,5 +778,5 @@ module.exports = {
createLegacyInstallPlan, createLegacyInstallPlan,
getSourceRoot, getSourceRoot,
listAvailableLanguages, listAvailableLanguages,
parseInstallArgs, parseInstallArgs
}; };

View File

@ -98,9 +98,7 @@ function readLatestContextTokens(transcriptPath, options = {}) {
return null; return null;
} }
const tailBytes = Number.isInteger(options.tailBytes) && options.tailBytes > 0 const tailBytes = Number.isInteger(options.tailBytes) && options.tailBytes > 0 ? options.tailBytes : DEFAULT_TRANSCRIPT_TAIL_BYTES;
? options.tailBytes
: DEFAULT_TRANSCRIPT_TAIL_BYTES;
const tail = readFileTail(transcriptPath, tailBytes); const tail = readFileTail(transcriptPath, tailBytes);
if (!tail) { if (!tail) {
@ -124,9 +122,7 @@ function readLatestContextTokens(transcriptPath, options = {}) {
const tokens = extractUsageTokens(record); const tokens = extractUsageTokens(record);
if (tokens > 0) { if (tokens > 0) {
const model = record.message && typeof record.message.model === 'string' const model = record.message && typeof record.message.model === 'string' ? record.message.model : '';
? record.message.model
: '';
return { tokens, model }; return { tokens, model };
} }
} }
@ -141,6 +137,15 @@ function readLatestContextTokens(transcriptPath, options = {}) {
* suffix); otherwise the standard 200k window. * suffix); otherwise the standard 200k window.
*/ */
function resolveContextWindowTokens(tokens, model) { function resolveContextWindowTokens(tokens, model) {
// Explicit window override wins: 400k models (e.g. Opus 4.x) match neither the
// 200k default nor the 1M marker and would otherwise report ~double usage (#2290).
// Honor ECC's own knob and Claude Code's native CLAUDE_CODE_AUTO_COMPACT_WINDOW.
const env = (typeof process !== 'undefined' && process.env) || {};
const envWindow = Number.parseInt(env.ECC_CONTEXT_WINDOW_TOKENS || env.CLAUDE_CODE_AUTO_COMPACT_WINDOW || '', 10);
if (Number.isInteger(envWindow) && envWindow > 0) {
return envWindow;
}
if (typeof model === 'string' && model.includes(LARGE_WINDOW_MODEL_MARKER)) { if (typeof model === 'string' && model.includes(LARGE_WINDOW_MODEL_MARKER)) {
return LARGE_CONTEXT_WINDOW_TOKENS; return LARGE_CONTEXT_WINDOW_TOKENS;
} }
@ -169,9 +174,7 @@ function resolveContextThreshold(env, windowTokens) {
} }
} }
return windowTokens >= LARGE_CONTEXT_WINDOW_TOKENS return windowTokens >= LARGE_CONTEXT_WINDOW_TOKENS ? DEFAULT_CONTEXT_THRESHOLD_LARGE : DEFAULT_CONTEXT_THRESHOLD_STANDARD;
? DEFAULT_CONTEXT_THRESHOLD_LARGE
: DEFAULT_CONTEXT_THRESHOLD_STANDARD;
} }
/** /**
@ -181,9 +184,7 @@ function resolveContextThreshold(env, windowTokens) {
function resolveContextInterval(env) { function resolveContextInterval(env) {
const raw = env && env.COMPACT_CONTEXT_INTERVAL; const raw = env && env.COMPACT_CONTEXT_INTERVAL;
const parsed = Number.parseInt(raw, 10); const parsed = Number.parseInt(raw, 10);
return Number.isInteger(parsed) && parsed > 0 && parsed <= MAX_TOKEN_SETTING return Number.isInteger(parsed) && parsed > 0 && parsed <= MAX_TOKEN_SETTING ? parsed : DEFAULT_CONTEXT_INTERVAL_TOKENS;
? parsed
: DEFAULT_CONTEXT_INTERVAL_TOKENS;
} }
/** /**
@ -205,9 +206,7 @@ function computeContextBucket(tokens, threshold, interval) {
* Human-readable label for a context window size (e.g. "200k", "1M"). * Human-readable label for a context window size (e.g. "200k", "1M").
*/ */
function formatWindowLabel(windowTokens) { function formatWindowLabel(windowTokens) {
return windowTokens >= LARGE_CONTEXT_WINDOW_TOKENS return windowTokens >= LARGE_CONTEXT_WINDOW_TOKENS ? '1M' : `${Math.round(windowTokens / 1000)}k`;
? '1M'
: `${Math.round(windowTokens / 1000)}k`;
} }
module.exports = { module.exports = {

View File

@ -1,148 +1,97 @@
--- ---
name: cost-tracking name: cost-tracking
description: Track and report Claude Code token usage, spending, and budgets from a local cost-tracking database. Use when the user asks about costs, spending, usage, tokens, budgets, or cost breakdowns by project, tool, session, or date. description: Track and report Claude Code token usage, spending, and budgets from the local ECC cost-tracker metrics log. Use when the user asks about costs, spending, usage, tokens, budgets, or cost breakdowns by model, session, or date.
metadata: metadata:
origin: community origin: community
--- ---
# Cost Tracking # Cost Tracking
Use this skill to analyze Claude Code cost and usage history from a local SQLite Use this skill to analyze Claude Code cost and usage history from the metrics log
database. It is intended for users who already have a cost-tracking hook or that ECC's `stop:cost-tracker` hook writes.
plugin writing usage rows to `~/.claude-cost-tracker/usage.db`.
Source: salvaged from stale community PR #1304 by `MayurBhavsar`. ## Where the data lives
The tracker appends one JSON object per session-stop to
`~/.claude/metrics/costs.jsonl`. Each row is a **cumulative snapshot for that
session**, so to total spend you take the **latest row per `session_id`** and
sum across sessions — summing every row multiply-counts.
Row schema:
| Field | Meaning |
| --- | --- |
| `timestamp` | ISO timestamp of the snapshot |
| `session_id` | Claude Code session identifier |
| `transcript_path` | Path to the session transcript |
| `model` | Model used |
| `input_tokens` / `output_tokens` | Token counts |
| `cache_write_tokens` / `cache_read_tokens` | Prompt-cache token counts |
| `estimated_cost_usd` | Precomputed cumulative cost in USD for the session |
Prefer `estimated_cost_usd` over hand-calculating pricing — model and cache
prices change, and the tracker is the source of truth.
## When to Use ## When to Use
- The user asks "how much have I spent?", "what did this session cost?", or - The user asks "how much have I spent?", "what did this session cost?", or
"what is my token usage?" "what is my token usage?"
- The user mentions budgets, spending limits, overruns, or cost controls. - The user mentions budgets, spending limits, overruns, or cost controls.
- The user wants a cost breakdown by project, tool, session, model, or date. - The user wants a cost breakdown by model, session, or date, or a CSV export.
- The user wants to compare today against yesterday or inspect a recent trend.
- The user asks for a CSV export of recent usage records.
## How It Works ## How It Works
First verify prerequisites: First verify the log exists (use `node`, not `sqlite3` — the tracker writes
JSONL, and `node` is cross-platform):
```bash ```bash
command -v sqlite3 >/dev/null && echo "sqlite3 available" || echo "sqlite3 missing" node -e 'const fs=require("fs"),os=require("os"),p=require("path");const f=p.join(os.homedir(),".claude","metrics","costs.jsonl");console.log(fs.existsSync(f)?"cost log found":"cost log not found: "+f)'
test -f ~/.claude-cost-tracker/usage.db && echo "Database found" || echo "Database not found"
``` ```
If the database is missing, do not fabricate usage data. Tell the user that cost If the log is missing, do not fabricate usage data. Tell the user that cost
tracking is not configured and suggest installing or enabling a trusted local tracking populates after the first session ends with the `stop:cost-tracker`
cost-tracking hook/plugin. hook enabled.
The expected `usage` table usually contains one row per tool call or model ## Example — summary, by model, last 7 days
interaction. Column names vary by tracker, but the examples below assume:
| Column | Meaning |
| --- | --- |
| `timestamp` | ISO timestamp for the usage event |
| `project` | Project or repository name |
| `tool_name` | Tool or event name |
| `input_tokens` | Input token count, when recorded |
| `output_tokens` | Output token count, when recorded |
| `cost_usd` | Precomputed cost in USD |
| `session_id` | Claude Code session identifier |
| `model` | Model used for the event |
Prefer `cost_usd` over hand-calculating pricing. Model prices and cache pricing
change over time, and the tracker should be the source of truth for how each row
was priced.
## Examples
### Quick Summary
```bash ```bash
sqlite3 ~/.claude-cost-tracker/usage.db " node -e '
SELECT const fs=require("fs"),os=require("os"),path=require("path");
'Today: $' || ROUND(COALESCE(SUM(CASE WHEN date(timestamp) = date('now') THEN cost_usd END), 0), 4) || const f=path.join(os.homedir(),".claude","metrics","costs.jsonl");
' | Total: $' || ROUND(COALESCE(SUM(cost_usd), 0), 4) || if(!fs.existsSync(f)){console.log("cost log not found: "+f);process.exit(0);}
' | Calls: ' || COUNT(*) || const rows=fs.readFileSync(f,"utf8").split(/\r?\n/).filter(Boolean).map(l=>{try{return JSON.parse(l)}catch{return null}}).filter(Boolean);
' | Sessions: ' || COUNT(DISTINCT session_id) const bySession=new Map();
FROM usage; for(const r of rows){const k=r.session_id||r.transcript_path||r.timestamp;const p=bySession.get(k);if(!p||String(r.timestamp)>String(p.timestamp))bySession.set(k,r);}
" const latest=[...bySession.values()];
const cost=r=>Number(r.estimated_cost_usd)||0, day=r=>String(r.timestamp||"").slice(0,10), sum=a=>a.reduce((s,r)=>s+cost(r),0), f4=n=>"$"+n.toFixed(4);
const today=new Date().toISOString().slice(0,10), yest=new Date(Date.now()-864e5).toISOString().slice(0,10);
console.log("today: "+f4(sum(latest.filter(r=>day(r)===today)))+" | yesterday: "+f4(sum(latest.filter(r=>day(r)===yest)))+" | total: "+f4(sum(latest))+" ("+latest.length+" sessions)");
const m=new Map();for(const r of latest){const k=r.model||"(unknown)";m.set(k,(m.get(k)||0)+cost(r));}
console.log("by model:");[...m.entries()].sort((a,b)=>b[1]-a[1]).forEach(([k,v])=>console.log(" "+f4(v)+" "+k));
'
``` ```
### Cost By Project For a session drilldown or CSV export, iterate the same `latest` set (or the raw
rows for CSV) and print the fields you need.
```bash
sqlite3 -header -column ~/.claude-cost-tracker/usage.db "
SELECT project, ROUND(SUM(cost_usd), 4) AS cost, COUNT(*) AS calls
FROM usage
GROUP BY project
ORDER BY cost DESC;
"
```
### Cost By Tool
```bash
sqlite3 -header -column ~/.claude-cost-tracker/usage.db "
SELECT tool_name, ROUND(SUM(cost_usd), 4) AS cost, COUNT(*) AS calls
FROM usage
GROUP BY tool_name
ORDER BY cost DESC;
"
```
### Last Seven Days
```bash
sqlite3 -header -column ~/.claude-cost-tracker/usage.db "
SELECT date(timestamp) AS date, ROUND(SUM(cost_usd), 4) AS cost, COUNT(*) AS calls
FROM usage
GROUP BY date(timestamp)
ORDER BY date DESC
LIMIT 7;
"
```
### Session Drilldown
```bash
sqlite3 -header -column ~/.claude-cost-tracker/usage.db "
SELECT session_id,
MIN(timestamp) AS started,
MAX(timestamp) AS ended,
ROUND(SUM(cost_usd), 4) AS cost,
COUNT(*) AS calls
FROM usage
GROUP BY session_id
ORDER BY started DESC
LIMIT 10;
"
```
## Reporting Guidance ## Reporting Guidance
When presenting cost data, include: When presenting cost data, include today's spend vs yesterday, total across all
sessions, a by-model breakdown, and session count. Format sub-dollar amounts
1. Today's spend and yesterday comparison. with four decimals, larger amounts with two.
2. Total spend across the tracked database.
3. Top projects ranked by cost.
4. Top tools ranked by cost.
5. Session count and average cost per session when enough data exists.
For small amounts, format currency with four decimal places. For larger amounts,
two decimals are enough.
## Anti-Patterns ## Anti-Patterns
- Do not estimate costs from raw token counts when `cost_usd` is present. - Do not sum every row — they are cumulative per session; reduce to the latest
- Do not assume the database exists without checking. row per `session_id` first.
- Do not run unbounded `SELECT *` exports on large databases. - Do not estimate costs from raw token counts when `estimated_cost_usd` is present.
- Do not assume the log exists without checking.
- Do not hard-code current model pricing in user-facing answers. - Do not hard-code current model pricing in user-facing answers.
- Do not recommend installing unreviewed hooks or plugins that execute arbitrary - Do not recommend installing unreviewed hooks or plugins that execute arbitrary code.
code.
## Related ## Related
- `/cost-report` - Command-form report using the same database. - `/cost-report` - Command-form report over the same metrics log.
- `cost-aware-llm-pipeline` - Model-routing and budget-design patterns. - `cost-aware-llm-pipeline` - Model-routing and budget-design patterns.
- `token-budget-advisor` - Context and token-budget planning. - `token-budget-advisor` - Context and token-budget planning.
- `strategic-compact` - Context compaction to reduce repeated token spend. - `strategic-compact` - Context compaction to reduce repeated token spend.

View File

@ -78,47 +78,32 @@ function tracked(filePath) {
console.log('readLatestContextTokens:'); console.log('readLatestContextTokens:');
test('sums input + cache_read + cache_creation from the latest usage record', () => { test('sums input + cache_read + cache_creation from the latest usage record', () => {
const file = tracked(writeTranscript([ const file = tracked(writeTranscript([usageRecord({ input: 10, cacheRead: 20, cacheCreation: 5 }), usageRecord({ input: 100, cacheRead: 150000, cacheCreation: 7000 })]));
usageRecord({ input: 10, cacheRead: 20, cacheCreation: 5 }),
usageRecord({ input: 100, cacheRead: 150000, cacheCreation: 7000 })
]));
const result = readLatestContextTokens(file); const result = readLatestContextTokens(file);
assert.ok(result, 'Expected a usage result'); assert.ok(result, 'Expected a usage result');
assert.strictEqual(result.tokens, 157100); assert.strictEqual(result.tokens, 157100);
}); });
test('returns the model id alongside the token count', () => { test('returns the model id alongside the token count', () => {
const file = tracked(writeTranscript([ const file = tracked(writeTranscript([usageRecord({ input: 1000 }, 'claude-opus-4-5[1m]')]));
usageRecord({ input: 1000 }, 'claude-opus-4-5[1m]')
]));
const result = readLatestContextTokens(file); const result = readLatestContextTokens(file);
assert.strictEqual(result.model, 'claude-opus-4-5[1m]'); assert.strictEqual(result.model, 'claude-opus-4-5[1m]');
}); });
test('skips trailing records without usage (e.g. tool results)', () => { test('skips trailing records without usage (e.g. tool results)', () => {
const file = tracked(writeTranscript([ const file = tracked(writeTranscript([usageRecord({ input: 5000 }), JSON.stringify({ type: 'user', message: { content: 'tool result' } }), JSON.stringify({ type: 'system', subtype: 'info' })]));
usageRecord({ input: 5000 }),
JSON.stringify({ type: 'user', message: { content: 'tool result' } }),
JSON.stringify({ type: 'system', subtype: 'info' })
]));
const result = readLatestContextTokens(file); const result = readLatestContextTokens(file);
assert.strictEqual(result.tokens, 5000); assert.strictEqual(result.tokens, 5000);
}); });
test('skips malformed JSONL lines without throwing', () => { test('skips malformed JSONL lines without throwing', () => {
const file = tracked(writeTranscript([ const file = tracked(writeTranscript([usageRecord({ input: 4200 }), '{not json at all', '']));
usageRecord({ input: 4200 }),
'{not json at all',
''
]));
const result = readLatestContextTokens(file); const result = readLatestContextTokens(file);
assert.strictEqual(result.tokens, 4200); assert.strictEqual(result.tokens, 4200);
}); });
test('returns null for a transcript with no usage records', () => { test('returns null for a transcript with no usage records', () => {
const file = tracked(writeTranscript([ const file = tracked(writeTranscript([JSON.stringify({ type: 'user', message: { content: 'hello' } })]));
JSON.stringify({ type: 'user', message: { content: 'hello' } })
]));
assert.strictEqual(readLatestContextTokens(file), null); assert.strictEqual(readLatestContextTokens(file), null);
}); });
@ -132,10 +117,7 @@ test('returns null for empty or non-string paths', () => {
}); });
test('ignores zero-token usage records', () => { test('ignores zero-token usage records', () => {
const file = tracked(writeTranscript([ const file = tracked(writeTranscript([usageRecord({ input: 999 }), usageRecord({ input: 0 })]));
usageRecord({ input: 999 }),
usageRecord({ input: 0 })
]));
const result = readLatestContextTokens(file); const result = readLatestContextTokens(file);
assert.strictEqual(result.tokens, 999); assert.strictEqual(result.tokens, 999);
}); });
@ -154,10 +136,42 @@ test('only scans the transcript tail (latest records win on large files)', () =>
// ── resolveContextWindowTokens ── // ── resolveContextWindowTokens ──
console.log('\nresolveContextWindowTokens:'); console.log('\nresolveContextWindowTokens:');
// Isolation: an env-set window override (either knob) otherwise leaks into the
// default-window assertions below and fails them (#2290).
delete process.env.ECC_CONTEXT_WINDOW_TOKENS;
delete process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW;
test('defaults to the standard 200k window', () => { test('defaults to the standard 200k window', () => {
assert.strictEqual(resolveContextWindowTokens(50000, 'claude-sonnet-4-6'), STANDARD_CONTEXT_WINDOW_TOKENS); assert.strictEqual(resolveContextWindowTokens(50000, 'claude-sonnet-4-6'), STANDARD_CONTEXT_WINDOW_TOKENS);
}); });
test('honors an explicit ECC_CONTEXT_WINDOW_TOKENS override (e.g. 400k models, #2290)', () => {
process.env.ECC_CONTEXT_WINDOW_TOKENS = '400000';
try {
assert.strictEqual(resolveContextWindowTokens(50000, 'claude-opus-4-x'), 400000);
} finally {
delete process.env.ECC_CONTEXT_WINDOW_TOKENS;
}
});
test('honors Claude Code native CLAUDE_CODE_AUTO_COMPACT_WINDOW override', () => {
process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = '400000';
try {
assert.strictEqual(resolveContextWindowTokens(50000, 'claude-opus-4-x'), 400000);
} finally {
delete process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW;
}
});
test('ignores a non-positive / invalid window override', () => {
process.env.ECC_CONTEXT_WINDOW_TOKENS = 'not-a-number';
try {
assert.strictEqual(resolveContextWindowTokens(50000, 'claude-sonnet-4-6'), STANDARD_CONTEXT_WINDOW_TOKENS);
} finally {
delete process.env.ECC_CONTEXT_WINDOW_TOKENS;
}
});
test('detects a 1M window from the [1m] model marker', () => { test('detects a 1M window from the [1m] model marker', () => {
assert.strictEqual(resolveContextWindowTokens(50000, 'claude-opus-4-5[1m]'), LARGE_CONTEXT_WINDOW_TOKENS); assert.strictEqual(resolveContextWindowTokens(50000, 'claude-opus-4-5[1m]'), LARGE_CONTEXT_WINDOW_TOKENS);
}); });
@ -191,11 +205,7 @@ test('COMPACT_CONTEXT_THRESHOLD=0 disables the signal', () => {
test('invalid COMPACT_CONTEXT_THRESHOLD falls back to the default', () => { test('invalid COMPACT_CONTEXT_THRESHOLD falls back to the default', () => {
for (const bad of ['-5', 'abc', '99999999999']) { for (const bad of ['-5', 'abc', '99999999999']) {
assert.strictEqual( assert.strictEqual(resolveContextThreshold({ COMPACT_CONTEXT_THRESHOLD: bad }, STANDARD_CONTEXT_WINDOW_TOKENS), DEFAULT_CONTEXT_THRESHOLD_STANDARD, `Expected fallback for ${bad}`);
resolveContextThreshold({ COMPACT_CONTEXT_THRESHOLD: bad }, STANDARD_CONTEXT_WINDOW_TOKENS),
DEFAULT_CONTEXT_THRESHOLD_STANDARD,
`Expected fallback for ${bad}`
);
} }
}); });