mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-19 19:30:29 +08:00
- #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:
parent
51184b692e
commit
b3268fef80
@ -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`.
|
|
||||||
|
|||||||
@ -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": [],
|
||||||
|
|||||||
@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
buildCopyFileOperation({
|
||||||
moduleId: options.moduleId,
|
moduleId: options.moduleId,
|
||||||
sourcePath,
|
sourcePath,
|
||||||
sourceRelativePath,
|
sourceRelativePath,
|
||||||
destinationPath,
|
destinationPath,
|
||||||
strategy: options.strategy || 'preserve-relative-path',
|
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(
|
||||||
|
buildCopyFileOperation({
|
||||||
moduleId: options.moduleId,
|
moduleId: options.moduleId,
|
||||||
sourcePath,
|
sourcePath,
|
||||||
sourceRelativePath: options.sourceRelativePath,
|
sourceRelativePath: options.sourceRelativePath,
|
||||||
destinationPath: options.destinationPath,
|
destinationPath: options.destinationPath,
|
||||||
strategy: options.strategy || 'preserve-relative-path',
|
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(
|
||||||
|
buildCopyFileOperation({
|
||||||
moduleId: options.moduleId,
|
moduleId: options.moduleId,
|
||||||
sourcePath,
|
sourcePath,
|
||||||
sourceRelativePath,
|
sourceRelativePath,
|
||||||
destinationPath,
|
destinationPath,
|
||||||
strategy: options.strategy || 'flatten-copy',
|
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,13 +630,14 @@ 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',
|
kind: 'merge-json',
|
||||||
moduleId: operation.moduleId,
|
moduleId: operation.moduleId,
|
||||||
sourceRelativePath: operation.sourceRelativePath,
|
sourceRelativePath: operation.sourceRelativePath,
|
||||||
@ -665,11 +645,9 @@ function materializeScaffoldOperation(sourceRoot, operation) {
|
|||||||
strategy: operation.strategy || 'merge-json',
|
strategy: operation.strategy || 'merge-json',
|
||||||
ownership: operation.ownership || 'managed',
|
ownership: operation.ownership || 'managed',
|
||||||
scaffoldOnly: Object.hasOwn(operation, 'scaffoldOnly') ? operation.scaffoldOnly : false,
|
scaffoldOnly: Object.hasOwn(operation, 'scaffoldOnly') ? operation.scaffoldOnly : false,
|
||||||
mergePayload: readJsonObject(
|
mergePayload: readJsonObject(path.join(sourceRoot, operation.sourceRelativePath), operation.sourceRelativePath)
|
||||||
path.join(sourceRoot, 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 [
|
||||||
|
buildCopyFileOperation({
|
||||||
moduleId: operation.moduleId,
|
moduleId: operation.moduleId,
|
||||||
sourcePath,
|
sourcePath,
|
||||||
sourceRelativePath: operation.sourceRelativePath,
|
sourceRelativePath: operation.sourceRelativePath,
|
||||||
destinationPath: operation.destinationPath,
|
destinationPath: operation.destinationPath,
|
||||||
strategy: operation.strategy,
|
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
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user