claude-nexus 0.32.1 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@
7
7
  {
8
8
  "name": "claude-nexus",
9
9
  "description": "Claude Code plugin for nexus-core agent orchestration",
10
- "version": "0.32.1",
10
+ "version": "0.33.0",
11
11
  "author": {
12
12
  "name": "kih"
13
13
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nexus",
3
- "version": "0.32.1",
3
+ "version": "0.33.0",
4
4
  "description": "Claude Code plugin for nexus-core agent orchestration",
5
5
  "author": {
6
6
  "name": "kih"
package/README.en.md CHANGED
@@ -51,7 +51,12 @@ With the plugin enabled, each new Claude Code session runs the `lead` agent as t
51
51
 
52
52
  ## Optional: statusline
53
53
 
54
- The plugin ships a two-line statusline script. Line one shows `◆Nexus vX.Y.Z`, the model, the project, and the git branch with staged/unstaged counts. Line two shows context-window usage plus 5-hour and 7-day Claude usage gauges with the time until each resets. The usage gauges require a Claude Pro or Max OAuth session; `~/.claude/.usage_cache` is shared across local sessions so concurrent Claude Code windows never re-fetch.
54
+ The plugin ships a two-line statusline script. Line one shows `◆Nexus vX.Y.Z`, the model, the project, and the git branch with staged/unstaged counts. Line two shows context-window usage plus mode-specific information:
55
+
56
+ - **OAuth session (Claude Pro / Max)** — 5-hour and 7-day Claude usage gauges with the time until each resets. `$CLAUDE_CONFIG_DIR/.usage_cache` (defaults to `~/.claude/.usage_cache`) is shared across local sessions so concurrent Claude Code windows never re-fetch.
57
+ - **API mode (`ANTHROPIC_API_KEY` set)** — `API $X.XX today` showing the cost incurred today (UTC midnight boundary). Claude Code's local jsonl session logs are scanned directly to sum tokens per model and convert to USD using Anthropic's published pricing — no admin key setup required.
58
+
59
+ When you use `CLAUDE_CONFIG_DIR` to separate multiple OAuth accounts, the statusline's cache path, keychain query, and cost scan all branch automatically using the same algorithm as Claude Code itself.
55
60
 
56
61
  Claude Code does not let a plugin auto-configure the user's `statusLine`, so register the `claude-nexus-statusline` CLI (shipped with the same npm package) from your own `~/.claude/settings.json`.
57
62
 
package/README.md CHANGED
@@ -51,7 +51,12 @@ Claude Code 안에서 플러그인 마켓플레이스로 설치한다.
51
51
 
52
52
  ## 선택: statusline
53
53
 
54
- 플러그인은 2줄 statusline 스크립트를 함께 배포한다. 첫 줄은 `◆Nexus vX.Y.Z`·모델·프로젝트·git 브랜치(staged/unstaged), 둘째 줄은 컨텍스트 사용률과 5h/7d 사용 한도 게이지(리셋까지 남은 시간). Claude Pro·Max OAuth 세션에서만 5h/7d가 표시되며, 로컬의 여러 Claude 세션이 `~/.claude/.usage_cache`를 공유하므로 API 중복 호출 없이 경합이 방지된다.
54
+ 플러그인은 2줄 statusline 스크립트를 함께 배포한다. 첫 줄은 `◆Nexus vX.Y.Z`·모델·프로젝트·git 브랜치(staged/unstaged), 둘째 줄은 컨텍스트 사용률과 모드별 사용량 정보:
55
+
56
+ - **OAuth 세션 (Claude Pro·Max)** — 5h/7d 사용 한도 게이지(리셋까지 남은 시간). 로컬의 여러 Claude 세션이 `$CLAUDE_CONFIG_DIR/.usage_cache`(미설정 시 `~/.claude/.usage_cache`)를 공유하므로 API 중복 호출 없이 경합이 방지된다.
57
+ - **API 모드 (`ANTHROPIC_API_KEY` 설정)** — `API $X.XX today` 형식으로 오늘(UTC 자정 기준) 발생한 비용을 표시. Claude Code가 기록하는 로컬 jsonl 세션 로그를 직접 스캔해 모델별 토큰을 합산하고 Anthropic 공식 가격표 기반으로 USD 환산하므로, 별도 admin key 셋업이 불필요하다.
58
+
59
+ `CLAUDE_CONFIG_DIR` 환경변수로 다중 OAuth 계정을 분리해 쓰는 경우, statusline의 캐시·키체인 조회·비용 스캔이 모두 본체와 동일한 알고리즘으로 자동 분기된다.
55
60
 
56
61
  Claude Code는 플러그인이 사용자 `statusLine`을 자동 등록하는 걸 허용하지 않으므로, 별도 CLI로 배포된 `claude-nexus`를 본인의 `~/.claude/settings.json`에 등록한다.
57
62
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nexus",
3
- "version": "0.32.1",
3
+ "version": "0.33.0",
4
4
  "type": "module",
5
5
  "description": "Claude Code plugin for nexus-core agent orchestration",
6
6
  "author": "kih",
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/statusline/statusline.ts
4
- import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { existsSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
5
5
  import { basename, dirname, join, resolve } from "node:path";
6
6
  import { execSync, spawn } from "node:child_process";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { homedir } from "node:os";
9
+ import { createHash } from "node:crypto";
9
10
  var stdinRaw = "";
10
11
  try {
11
12
  stdinRaw = readFileSync(0, "utf-8");
@@ -29,7 +30,16 @@ function findProjectRoot(start) {
29
30
  }
30
31
  var PROJECT_ROOT = findProjectRoot(getVal("cwd") || process.cwd());
31
32
  var HOME = homedir();
33
+ var CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR || join(HOME, ".claude");
32
34
  var PLUGIN_ROOT = process.env.CLAUDE_PLUGIN_ROOT || "";
35
+ var KEYCHAIN_SERVICE = (() => {
36
+ const envDir = process.env.CLAUDE_CONFIG_DIR;
37
+ if (!envDir)
38
+ return "Claude Code-credentials";
39
+ const normalized = envDir.normalize("NFC");
40
+ const suffix = createHash("sha256").update(normalized).digest("hex").slice(0, 8);
41
+ return `Claude Code-credentials-${suffix}`;
42
+ })();
33
43
  function getPluginVersion() {
34
44
  if (PLUGIN_ROOT) {
35
45
  try {
@@ -72,7 +82,7 @@ function makeBar(pct, width) {
72
82
  function meter(label, pct, width) {
73
83
  return `${DIM}${label}${RESET} ${pctColor(pct)}${makeBar(pct, width)} ${Math.round(pct)}%${RESET}`;
74
84
  }
75
- var VERSION_CACHE_PATH = join(HOME, ".claude", ".nexus_version_cache");
85
+ var VERSION_CACHE_PATH = join(CLAUDE_CONFIG_DIR, ".nexus_version_cache");
76
86
  var VERSION_CACHE_TTL = 86400;
77
87
  function updateAvailable(current) {
78
88
  if (!current)
@@ -142,7 +152,7 @@ function buildLine1() {
142
152
  const nexusTag = `\x1B[38;5;141m◆Nexus${versionStr}${RESET}${updateTag}`;
143
153
  return `${nexusTag} ${SEP} ${modelColor}${BOLD}${model}${RESET} ${SEP} \x1B[36m${project}${RESET} ${SEP} ${gitPart}`;
144
154
  }
145
- var USAGE_CACHE_PATH = join(HOME, ".claude", ".usage_cache");
155
+ var USAGE_CACHE_PATH = join(CLAUDE_CONFIG_DIR, ".usage_cache");
146
156
  var CACHE_TTL_DEFAULT = 60;
147
157
  var FETCH_BACKOFF = 300;
148
158
  var STALE_THRESHOLD = 300;
@@ -166,9 +176,9 @@ ${cachedData}`);
166
176
  try {
167
177
  let tokenCmd = "";
168
178
  if (process.platform === "darwin") {
169
- tokenCmd = `TOKEN=$(security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null | grep -o '"accessToken":"[^"]*"' | sed 's/"accessToken":"//;s/"//')`;
179
+ tokenCmd = `TOKEN=$(security find-generic-password -s "${KEYCHAIN_SERVICE}" -w 2>/dev/null | grep -o '"accessToken":"[^"]*"' | sed 's/"accessToken":"//;s/"//')`;
170
180
  } else {
171
- const credFile = join(HOME, ".claude", ".credentials.json");
181
+ const credFile = join(CLAUDE_CONFIG_DIR, ".credentials.json");
172
182
  tokenCmd = `TOKEN=$(grep -o '"accessToken":"[^"]*"' "${credFile}" 2>/dev/null | sed 's/"accessToken":"//;s/"//')`;
173
183
  }
174
184
  const script = `
@@ -218,12 +228,12 @@ function readUsage() {
218
228
  try {
219
229
  let credJson = "";
220
230
  if (process.platform === "darwin") {
221
- credJson = execSync('security find-generic-password -s "Claude Code-credentials" -w', {
231
+ credJson = execSync(`security find-generic-password -s "${KEYCHAIN_SERVICE}" -w`, {
222
232
  encoding: "utf-8",
223
233
  stdio: ["pipe", "pipe", "pipe"]
224
234
  }).trim();
225
235
  } else {
226
- const credFile = join(HOME, ".claude", ".credentials.json");
236
+ const credFile = join(CLAUDE_CONFIG_DIR, ".credentials.json");
227
237
  if (existsSync(credFile))
228
238
  credJson = readFileSync(credFile, "utf-8");
229
239
  }
@@ -274,27 +284,177 @@ function resetRemain(parsed, section) {
274
284
  function isApiMode() {
275
285
  return !!process.env.ANTHROPIC_API_KEY;
276
286
  }
277
- function fetchApiCost(adminKey) {
287
+ var COST_CACHE_PATH = join(CLAUDE_CONFIG_DIR, ".api_cost_cache");
288
+ var COST_CACHE_TTL = 60;
289
+ var COST_STALE_THRESHOLD = 300;
290
+ function priceFor(model) {
291
+ const m = model.toLowerCase();
292
+ const TABLE = [
293
+ [/opus-4-[5-9]/, 5, 25],
294
+ [/opus-(?:4-[01]|4)(?:[-_]|$)/, 15, 75],
295
+ [/opus-3/, 15, 75],
296
+ [/sonnet-4(?:-\d+)?(?:[-_]|$)|4-sonnet/, 3, 15],
297
+ [/sonnet-3-7|3-7-sonnet/, 3, 15],
298
+ [/sonnet-3-5|3-5-sonnet/, 3, 15],
299
+ [/haiku-4-5/, 1, 5],
300
+ [/haiku-3-5|3-5-haiku/, 0.8, 4],
301
+ [/haiku-3/, 0.25, 1.25]
302
+ ];
303
+ for (const [re, inp, out] of TABLE) {
304
+ if (re.test(m)) {
305
+ const inputPerToken = inp / 1e6;
306
+ return {
307
+ input: inputPerToken,
308
+ output: out / 1e6,
309
+ cache5m: inputPerToken * 1.25,
310
+ cache1h: inputPerToken * 2,
311
+ cacheRead: inputPerToken * 0.1
312
+ };
313
+ }
314
+ }
315
+ return null;
316
+ }
317
+ function turnCostUsd(model, usage) {
318
+ const rates = priceFor(model);
319
+ if (!rates)
320
+ return 0;
321
+ const inp = usage.input_tokens ?? 0;
322
+ const out = usage.output_tokens ?? 0;
323
+ const cacheRead = usage.cache_read_input_tokens ?? 0;
324
+ const c5m = usage.cache_creation?.ephemeral_5m_input_tokens;
325
+ const c1h = usage.cache_creation?.ephemeral_1h_input_tokens;
326
+ let cacheWriteCost = 0;
327
+ if (c5m !== undefined || c1h !== undefined) {
328
+ cacheWriteCost = (c5m ?? 0) * rates.cache5m + (c1h ?? 0) * rates.cache1h;
329
+ } else {
330
+ cacheWriteCost = (usage.cache_creation_input_tokens ?? 0) * rates.cache5m;
331
+ }
332
+ return inp * rates.input + out * rates.output + cacheRead * rates.cacheRead + cacheWriteCost;
333
+ }
334
+ function scanLocalCostUsd() {
335
+ const projectsRoot = join(CLAUDE_CONFIG_DIR, "projects");
336
+ if (!existsSync(projectsRoot))
337
+ return null;
338
+ const todayStart = new Date;
339
+ todayStart.setUTCHours(0, 0, 0, 0);
340
+ const todayStartMs = todayStart.getTime();
341
+ let total = 0;
342
+ let projectDirs;
278
343
  try {
279
- const today = new Date().toISOString().slice(0, 10);
280
- const resp = execSync(`curl -s --max-time 3 "https://api.anthropic.com/v1/organizations/cost_report?start_date=${today}&end_date=${today}" -H "x-api-key: ${adminKey}" -H "anthropic-version: 2023-06-01"`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
281
- const m = resp.match(/"total_cost"\s*:\s*([0-9.]+)/);
282
- return m ? parseFloat(m[1]) : null;
344
+ projectDirs = readdirSync(projectsRoot);
283
345
  } catch {
284
346
  return null;
285
347
  }
348
+ for (const proj of projectDirs) {
349
+ const projPath = join(projectsRoot, proj);
350
+ let entries;
351
+ try {
352
+ entries = readdirSync(projPath);
353
+ } catch {
354
+ continue;
355
+ }
356
+ for (const file of entries) {
357
+ if (!file.endsWith(".jsonl"))
358
+ continue;
359
+ const fp = join(projPath, file);
360
+ try {
361
+ const st = statSync(fp);
362
+ if (st.mtimeMs < todayStartMs)
363
+ continue;
364
+ } catch {
365
+ continue;
366
+ }
367
+ let raw;
368
+ try {
369
+ raw = readFileSync(fp, "utf-8");
370
+ } catch {
371
+ continue;
372
+ }
373
+ const lines = raw.split(`
374
+ `);
375
+ for (const line of lines) {
376
+ if (!line || !line.includes('"assistant"'))
377
+ continue;
378
+ let entry;
379
+ try {
380
+ entry = JSON.parse(line);
381
+ } catch {
382
+ continue;
383
+ }
384
+ if (entry.type !== "assistant")
385
+ continue;
386
+ const ts = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
387
+ if (!Number.isFinite(ts) || ts < todayStartMs)
388
+ continue;
389
+ const model = entry.message?.model;
390
+ const usage = entry.message?.usage;
391
+ if (!model || !usage)
392
+ continue;
393
+ total += turnCostUsd(model, usage);
394
+ }
395
+ }
396
+ }
397
+ return total;
398
+ }
399
+ function writeCostCacheAtomic(content) {
400
+ try {
401
+ writeFileSync(COST_CACHE_PATH + ".tmp", content);
402
+ renameSync(COST_CACHE_PATH + ".tmp", COST_CACHE_PATH);
403
+ } catch {
404
+ try {
405
+ unlinkSync(COST_CACHE_PATH + ".tmp");
406
+ } catch {}
407
+ }
408
+ }
409
+ function readApiCost() {
410
+ const now = Math.floor(Date.now() / 1000);
411
+ let dataTimestamp = 0;
412
+ let nextRescanAfter = 0;
413
+ let cachedValue = "";
414
+ if (existsSync(COST_CACHE_PATH)) {
415
+ try {
416
+ const lines = readFileSync(COST_CACHE_PATH, "utf-8").split(`
417
+ `);
418
+ dataTimestamp = parseInt(lines[0]) || 0;
419
+ nextRescanAfter = parseInt(lines[1]) || 0;
420
+ cachedValue = (lines[2] || "").trim();
421
+ } catch {}
422
+ }
423
+ const age = dataTimestamp > 0 ? now - dataTimestamp : 0;
424
+ const parseCached = () => {
425
+ if (!cachedValue)
426
+ return null;
427
+ const n = parseFloat(cachedValue);
428
+ return Number.isFinite(n) ? n : null;
429
+ };
430
+ if (cachedValue && now < nextRescanAfter) {
431
+ return { cost: parseCached(), stale: age >= COST_STALE_THRESHOLD, ageSeconds: age };
432
+ }
433
+ const cost = scanLocalCostUsd();
434
+ if (cost === null) {
435
+ return { cost: null, stale: false, ageSeconds: 0 };
436
+ }
437
+ writeCostCacheAtomic(`${now}
438
+ ${now + COST_CACHE_TTL}
439
+ ${cost}`);
440
+ return { cost, stale: false, ageSeconds: 0 };
286
441
  }
287
442
  function buildLine2() {
288
443
  const BAR_WIDTH = 6;
289
444
  const ctxPct = Math.round(getNum("used_percentage"));
290
445
  const ctx = meter("ctx", ctxPct, BAR_WIDTH);
291
446
  if (isApiMode()) {
292
- const adminKey = process.env.ANTHROPIC_ADMIN_KEY;
293
- if (adminKey) {
294
- const cost = fetchApiCost(adminKey);
295
- if (cost !== null) {
296
- return `${ctx} ${SEP} ${DIM}API${RESET} ${pctColor(0)}$${cost.toFixed(2)} today${RESET}`;
447
+ const { cost, stale, ageSeconds } = readApiCost();
448
+ if (cost !== null) {
449
+ let stalePart2 = "";
450
+ if (stale) {
451
+ const ageMin = Math.floor(ageSeconds / 60);
452
+ const hh = Math.floor(ageMin / 60);
453
+ const mm = ageMin % 60;
454
+ const ageStr = hh > 0 ? `${hh}h${mm}m` : `${mm}m`;
455
+ stalePart2 = ` ${SEP} \x1B[33m${ageStr} ago\x1B[0m`;
297
456
  }
457
+ return `${ctx} ${SEP} ${DIM}API${RESET} ${pctColor(0)}$${cost.toFixed(2)} today${RESET}${stalePart2}`;
298
458
  }
299
459
  return `${ctx} ${SEP} ${DIM}API mode${RESET}`;
300
460
  }