Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,29 @@ else
fi

# ── 2. Run tests — commit is blocked unless all pass ────────────────────────
echo "🧪 Running backend tests..."
npm run test:server
# Each suite is retried once on failure. The full run executes dozens of test
# files concurrently, each starting its own server (plus the CLI suite's
# spawned child processes with hard kill timeouts), so a loaded machine can
# produce a one-off timing failure unrelated to the commit. A real regression
# is deterministic: it fails both runs and still blocks the commit. This keeps
# the gate strict without making commits a dice roll on machine load.
run_suite() {
# $1 = human label, $2 = npm script
echo "🧪 Running $1 tests..."
if npm run "$2"; then
return 0
fi
echo "⚠️ $1 suite failed — retrying once to rule out machine-load flakiness..."
if npm run "$2"; then
echo "✅ $1 suite passed on retry — treating the first failure as a load flake."
echo " If this keeps happening, the flaky test above deserves a real fix."
return 0
fi
echo "❌ $1 suite failed twice — genuine failure, aborting the commit."
return 1
}

echo "🧪 Running frontend tests..."
npm run test:client
run_suite backend test:server
run_suite frontend test:client

echo "✅ Formatting applied and all tests passed — proceeding with commit."
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
## Repo map
- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes optional git upstream checks and `routes/updates.js`, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks).
- `client/`: React + Vite UI.
- `scripts/`: hook installer/handler, import, seed, cleanup utilities, `self-update-restart.js` (pull → setup → restart).
- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`; the dashboard never restarts itself — users run the printed command, surfaced in the UI and by `ccam update-check`.)
- `mcp/`: local MCP server exposing dashboard operations as tools.

## Non-negotiable engineering rules
Expand Down
6 changes: 5 additions & 1 deletion README-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,7 @@ ccam analytics # token 总量、常用工具、agent 类型
ccam workflows [--session id] # 工作流智能统计与模式
ccam runs [--session id] # 动态 Workflow 工具运行
ccam cost # 按模型细分的总预估成本
# (对有用量但无定价规则的模型发出警告)

# 告警 & webhook
ccam alerts [--unacked] # 触发告警流
Expand All @@ -643,8 +644,10 @@ ccam webhooks # webhook 目标列表
ccam webhooks test <id> # 发送合成测试告警

# 价格
ccam pricing # 定价规则列表
ccam pricing # 定价规则列表(含 fast-mode 与 intro 列)
ccam pricing set <pattern> --input N --output N [--cache-read N --cache-write N]
[--cache-write-1h N] [--fast-input N --fast-output N]
[--intro-input N --intro-output N … --intro-until YYYY-MM-DD]
ccam pricing delete <pattern>
ccam pricing reset

Expand All @@ -658,6 +661,7 @@ ccam info # 原始系统信息 JSON
ccam export [file.json] # 导出全部数据为 JSON
ccam cleanup --hours N --days M # 放弃滞留会话 / 清理旧会话
ccam reinstall-hooks # 重新安装 Claude Code hook
ccam update-check # 检出是否落后于 upstream?(打印更新命令)
ccam clear-data --yes # 删除全部数据(必须 --yes)
ccam open # 在浏览器中打开仪表盘
ccam version # 打印 CLI 版本(也可用 --version / -v)
Expand Down
6 changes: 5 additions & 1 deletion README-KO.md
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,7 @@ ccam analytics # 토큰 총계, 상위 도구, 에이전트
ccam workflows [--session id] # 워크플로 인텔리전스 통계 및 패턴
ccam runs [--session id] # 동적 Workflow 도구 실행
ccam cost # 모델별 내역이 포함된 총 예상 비용
# (사용량은 있지만 가격 책정 규칙이 없는 모델에 대해 경고)

# 알림 및 웹훅
ccam alerts [--unacked] # 발생한 알림 피드
Expand All @@ -650,8 +651,10 @@ ccam webhooks # 웹훅 대상 나열
ccam webhooks test <id> # 합성 테스트 알림 전송

# 가격 책정
ccam pricing # 모델 가격 책정 규칙 나열
ccam pricing # 모델 가격 책정 규칙 나열(fast-mode 및 intro 컬럼 포함)
ccam pricing set <pattern> --input N --output N [--cache-read N --cache-write N]
[--cache-write-1h N] [--fast-input N --fast-output N]
[--intro-input N --intro-output N … --intro-until YYYY-MM-DD]
ccam pricing delete <pattern>
ccam pricing reset

Expand All @@ -665,6 +668,7 @@ ccam info # 원시 시스템 정보 JSON
ccam export [file.json] # 전체 JSON 데이터 내보내기
ccam cleanup --hours N --days M # 오래된 세션 abandon 처리 / 이전 세션 삭제
ccam reinstall-hooks # Claude Code Hook 재설치
ccam update-check # 체크아웃이 업스트림보다 뒤처져 있는가?(업데이트 명령어 출력)
ccam clear-data --yes # 모든 데이터 삭제(--yes 필요)
ccam open # 브라우저에서 대시보드 열기
ccam version # CLI 버전 출력(--version / -v도 가능)
Expand Down
6 changes: 5 additions & 1 deletion README-VN.md
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,7 @@ ccam analytics # tổng token, công cụ hàng đầu, loạ
ccam workflows [--session id] # thống kê workflow-intelligence và các mẫu
ccam runs [--session id] # các lần chạy Workflow động
ccam cost # tổng chi phí ước tính theo model
# (cảnh báo model có mức dùng nhưng chưa có quy tắc định giá)

# Cảnh báo & webhook
ccam alerts [--unacked] # luồng cảnh báo đã kích hoạt
Expand All @@ -641,8 +642,10 @@ ccam webhooks # danh sách đích webhook
ccam webhooks test <id> # gửi cảnh báo thử tổng hợp

# Giá
ccam pricing # danh sách quy tắc định giá
ccam pricing # danh sách quy tắc định giá (gồm cột fast-mode & intro)
ccam pricing set <pattern> --input N --output N [--cache-read N --cache-write N]
[--cache-write-1h N] [--fast-input N --fast-output N]
[--intro-input N --intro-output N … --intro-until YYYY-MM-DD]
ccam pricing delete <pattern>
ccam pricing reset

Expand All @@ -656,6 +659,7 @@ ccam info # JSON thông tin hệ thống thô
ccam export [file.json] # xuất toàn bộ dữ liệu dạng JSON
ccam cleanup --hours N --days M # bỏ phiên treo / dọn phiên cũ
ccam reinstall-hooks # cài lại hook Claude Code
ccam update-check # bản checkout có chậm hơn upstream? (in lệnh cập nhật)
ccam clear-data --yes # xóa TOÀN BỘ dữ liệu (bắt buộc --yes)
ccam open # mở dashboard trong trình duyệt
ccam version # in phiên bản CLI (cũng có --version / -v)
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,7 @@ ccam analytics # token totals, top tools, agent types
ccam workflows [--session id] # workflow intelligence stats and patterns
ccam runs [--session id] # dynamic Workflow-tool runs
ccam cost # total estimated cost with per-model breakdown
# (warns about models with usage but no pricing rule)

# Alerts & webhooks
ccam alerts [--unacked] # fired-alert feed
Expand All @@ -650,8 +651,10 @@ ccam webhooks # list webhook targets
ccam webhooks test <id> # send a synthetic test alert

# Pricing
ccam pricing # list model pricing rules
ccam pricing # list model pricing rules (incl. fast-mode & intro columns)
ccam pricing set <pattern> --input N --output N [--cache-read N --cache-write N]
[--cache-write-1h N] [--fast-input N --fast-output N]
[--intro-input N --intro-output N … --intro-until YYYY-MM-DD]
ccam pricing delete <pattern>
ccam pricing reset

Expand All @@ -665,6 +668,7 @@ ccam info # raw system info JSON
ccam export [file.json] # full JSON data export
ccam cleanup --hours N --days M # abandon stale / purge old sessions
ccam reinstall-hooks # reinstall Claude Code hooks
ccam update-check # is the checkout behind upstream? (prints the update command)
ccam clear-data --yes # delete ALL data (requires --yes)
ccam open # open the dashboard in your browser
ccam version # print the CLI version (also --version / -v)
Expand Down
141 changes: 119 additions & 22 deletions bin/ccam.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
* Pricing pricing · pricing set/delete/reset
* Import import rescan · import path <dir>
* Administration doctor · info · export · cleanup · reinstall-hooks ·
* clear-data --yes · open · version
* update-check · clear-data --yes · open · version
*
* The REPL (`ccam repl`, aliases `shell` / `i`) runs each entered line as a
* short-lived child `ccam` process, so its behavior is identical to the
Expand Down Expand Up @@ -898,6 +898,27 @@ async function cmdCost() {
);
}
}
// The API prices unmatched models at $0 and reports them in unpriced_models
// so the total stays honest — surface that here instead of silently showing
// an under-reported number (e.g. right after a brand-new model id ships).
const unpriced = cost.unpriced_models || [];
if (unpriced.length) {
console.log();
console.log(
c.yellow(
`⚠ ${unpriced.length} model(s) have usage but no pricing rule — excluded from the total:`
)
);
for (const u of unpriced) {
const tokens =
(u.input_tokens || 0) +
(u.output_tokens || 0) +
(u.cache_read_tokens || 0) +
(u.cache_write_tokens || 0);
console.log(` ${c.bold(u.model)} ${c.dim(`${fmtTokens(tokens)} tokens`)}`);
}
console.log(c.dim(" Add a rule with: ccam pricing set <pattern> --input N --output N"));
}
}

// ── Alerts & webhooks ───────────────────────────────────────────────────────
Expand Down Expand Up @@ -966,6 +987,31 @@ async function cmdWebhooks(flags, positional) {

// ── Pricing ─────────────────────────────────────────────────────────────────

/** Render pricing rules — shared by the online list and the offline fallback
* so both show the full rate surface (standard, fast-mode, intro promo). */
function renderPricingTable(rules) {
const fastCol = (p) =>
(p.fast_input_per_mtok || 0) > 0 ? `$${p.fast_input_per_mtok}/$${p.fast_output_per_mtok}` : "-";
const introCol = (p) =>
p.intro_until
? `$${p.intro_input_per_mtok}/$${p.intro_output_per_mtok} ≤${p.intro_until}`
: "-";
const rows = (rules || []).map((p) => [
p.model_pattern,
(p.display_name || "").slice(0, 24),
`$${p.input_per_mtok}`,
`$${p.output_per_mtok}`,
`$${p.cache_read_per_mtok}`,
`$${p.cache_write_per_mtok}`,
fastCol(p),
introCol(p),
]);
table(
["Pattern", "Name", "In/M", "Out/M", "CacheR/M", "CacheW/M", "Fast In/Out", "Intro In/Out"],
rows
);
}

async function cmdPricing(flags, positional) {
const sub = positional[0];
if (sub === "set" && positional[1]) {
Expand All @@ -977,6 +1023,30 @@ async function cmdPricing(flags, positional) {
cache_read_per_mtok: Number(flags["cache-read"] ?? 0),
cache_write_per_mtok: Number(flags["cache-write"] ?? 0),
};
if (flags["cache-write-1h"] !== undefined)
body.cache_write_1h_per_mtok = Number(flags["cache-write-1h"]);
if (flags["fast-input"] !== undefined) body.fast_input_per_mtok = Number(flags["fast-input"]);
if (flags["fast-output"] !== undefined)
body.fast_output_per_mtok = Number(flags["fast-output"]);
// The intro block is only sent when at least one --intro-* flag is present:
// per the API contract, a PUT that omits every intro field preserves an
// existing promo, so a plain rate edit can never clobber one.
const INTRO_FLAGS = {
"intro-input": "intro_input_per_mtok",
"intro-output": "intro_output_per_mtok",
"intro-cache-read": "intro_cache_read_per_mtok",
"intro-cache-write": "intro_cache_write_per_mtok",
"intro-cache-write-1h": "intro_cache_write_1h_per_mtok",
};
const introProvided =
flags["intro-until"] !== undefined ||
Object.keys(INTRO_FLAGS).some((f) => flags[f] !== undefined);
if (introProvided) {
for (const [flag, field] of Object.entries(INTRO_FLAGS))
body[field] = Number(flags[flag] ?? 0);
// A bare --intro-until (no date) clears the promo, mirroring the API.
body.intro_until = typeof flags["intro-until"] === "string" ? flags["intro-until"] : "";
}
await api("PUT", "/api/pricing", body);
console.log(`${c.green("✔")} Pricing rule saved for ${c.bold(positional[1])}`);
return;
Expand All @@ -992,15 +1062,35 @@ async function cmdPricing(flags, positional) {
return;
}
const data = await get("/api/pricing");
const rows = (data.pricing || data.rules || []).map((p) => [
p.model_pattern,
(p.display_name || "").slice(0, 24),
`$${p.input_per_mtok}`,
`$${p.output_per_mtok}`,
`$${p.cache_read_per_mtok}`,
`$${p.cache_write_per_mtok}`,
]);
table(["Pattern", "Name", "In/M", "Out/M", "CacheR/M", "CacheW/M"], rows);
renderPricingTable(data.pricing || data.rules || []);
}

// ── Updates ─────────────────────────────────────────────────────────────────

async function cmdUpdateCheck() {
console.log(c.dim("Checking the canonical remote — this can take a few seconds…"));
// POST /check (rather than GET /status) so a dashboard open in the browser
// sees the same fresh result via the update_status websocket broadcast.
const s = await post("/api/updates/check");
heading("Dashboard updates", s.repo_root || baseUrl());
if (!s.git_repo) {
console.log(`${c.yellow("○")} ${s.message}`);
return;
}
if (s.fetch_error) {
console.log(`${c.yellow("!")} ${s.message} ${c.dim(`(${s.fetch_error})`)}`);
return;
}
if (s.update_available) {
console.log(`${c.yellow("⬆")} ${s.message}`);
if (s.situation_note) console.log(c.dim(` ${s.situation_note}`));
if (s.manual_command) {
console.log(`\n ${c.bold("To update, run:")}`);
console.log(` ${c.cyan(s.manual_command)}`);
}
} else {
console.log(`${c.green("✔")} ${s.message || "Your checkout is up to date."}`);
}
}

// ── Import ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -1255,7 +1345,11 @@ const COMMAND_GROUPS = [
"Pricing",
[
["pricing", "", "List model pricing rules"],
["pricing set <pattern>", "", "--input N --output N [--cache-read N --cache-write N]"],
[
"pricing set <pattern>",
"",
"--input/--output N, plus --cache-*, --fast-*, --intro-* --intro-until YYYY-MM-DD",
],
["pricing delete <pattern>", "", "Delete a pricing rule"],
["pricing reset", "", "Reset pricing rules to defaults"],
],
Expand All @@ -1275,6 +1369,7 @@ const COMMAND_GROUPS = [
["export", "[file.json]", "Export all data as JSON"],
["cleanup", "--hours N --days M", "Abandon stale / purge old sessions"],
["reinstall-hooks", "", "Reinstall Claude Code hooks"],
["update-check", "", "Check whether the dashboard checkout is behind upstream"],
["clear-data", "--yes", "Delete ALL data (requires --yes)"],
["open", "", "Open the dashboard in your browser"],
["version", "", "Print the ccam version (also: --version, -v)"],
Expand Down Expand Up @@ -1419,17 +1514,7 @@ const OFFLINE_HANDLERS = {
);
}
const db = requireDb();
const rows = db
.all("SELECT * FROM model_pricing ORDER BY LENGTH(model_pattern) DESC")
.map((p) => [
p.model_pattern,
(p.display_name || "").slice(0, 24),
`$${p.input_per_mtok}`,
`$${p.output_per_mtok}`,
`$${p.cache_read_per_mtok}`,
`$${p.cache_write_per_mtok}`,
]);
table(["Pattern", "Name", "In/M", "Out/M", "CacheR/M", "CacheW/M"], rows);
renderPricingTable(db.all("SELECT * FROM model_pricing ORDER BY LENGTH(model_pattern) DESC"));
},
async alerts(flags, positional) {
if (positional[0]) {
Expand Down Expand Up @@ -1538,6 +1623,7 @@ const SERVER_ONLY_REASONS = {
cleanup: "cleanup is a server-side mutation",
"clear-data": "data wipes must go through the server",
"reinstall-hooks": "hook installation is performed by the server",
"update-check": "the update check runs server-side (git fetch against the canonical remote)",
info: "system info (uptime, memory, WS connections) only exists on a running server",
health: "health is, by definition, a check against the running server",
};
Expand Down Expand Up @@ -1587,6 +1673,15 @@ const REPL_FLAGS = [
"--output",
"--cache-read",
"--cache-write",
"--cache-write-1h",
"--fast-input",
"--fast-output",
"--intro-input",
"--intro-output",
"--intro-cache-read",
"--intro-cache-write",
"--intro-cache-write-1h",
"--intro-until",
"--name",
"--no-color",
];
Expand Down Expand Up @@ -2028,6 +2123,8 @@ async function runCommand(argv) {
return cmdClearData(flags);
case "reinstall-hooks":
return cmdReinstallHooks();
case "update-check":
return cmdUpdateCheck();
case "open":
return cmdOpen();
case "version":
Expand Down
2 changes: 2 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,8 @@ Upsert a pricing rule, keyed by `model_pattern`. The same call creates a new rul

The intro block is **optional and backward-compatible**: a request that omits every `intro_*`/`intro_until` field leaves any existing promo untouched, so older clients that send only the standard rates never clobber a promo.

**Validation:** every `*_per_mtok` rate present in the body must be a **non-negative finite number** (numeric strings are coerced); a `NaN`, non-numeric, or negative value is rejected with `400 INVALID_INPUT` naming the offending field, and nothing is written. `intro_until` must be a `YYYY-MM-DD` date (or empty/`null` to clear the promo).

**Example Request:**

```bash
Expand Down
Loading
Loading