@zhangferry-dev/tokendash 1.6.2 → 1.7.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.
package/README.md CHANGED
@@ -1,6 +1,11 @@
1
1
  <div align="center">
2
- # TokenDash
3
-
2
+
3
+ <p align="center">
4
+ <img src="resources/icon.png" width="132" alt="TokenDash app icon">
5
+ </p>
6
+
7
+ # TokenDash
8
+
4
9
  **Your local command center for AI coding usage.**
5
10
 
6
11
  Track tokens, costs, models, projects, and coding-plan limits from the macOS menu bar, with a detailed local dashboard when you need to dig deeper.
@@ -13,10 +18,6 @@
13
18
  [Download for macOS](https://github.com/zhangferry/tokendash/releases/latest) · [Run with npx](#run-the-web-dashboard) · [Features](#what-you-get) · [Development](#development)
14
19
  </div>
15
20
 
16
- <p align="center">
17
- <img src="resources/icon.png" width="132" alt="TokenDash app icon">
18
- </p>
19
-
20
21
  ![TokenDash menu bar app and web dashboard](resources/readme-hero.png)
21
22
 
22
23
  ## Why TokenDash?
package/dist/daemon.cjs CHANGED
@@ -2247,6 +2247,32 @@ var QuotaService = class {
2247
2247
  }
2248
2248
  return this.fetchAll();
2249
2249
  }
2250
+ /**
2251
+ * Validate a credential without caching it or writing it to disk. This keeps
2252
+ * the settings form transactional: only credentials accepted upstream are
2253
+ * persisted by the native app.
2254
+ */
2255
+ async validateCredential(provider, credential) {
2256
+ const adapter = this.registry.get(provider);
2257
+ if (!adapter) {
2258
+ return {
2259
+ provider,
2260
+ valid: false,
2261
+ status: { state: "not_configured", message: "Unsupported provider" }
2262
+ };
2263
+ }
2264
+ try {
2265
+ const snapshot = await withTimeout(
2266
+ adapter.fetch({ credential }),
2267
+ this.fetchTimeoutMs,
2268
+ provider
2269
+ );
2270
+ const validated = validateQuotaSnapshot(snapshot);
2271
+ return { provider, valid: validated.status.state === "ok", status: validated.status };
2272
+ } catch (err) {
2273
+ return { provider, valid: false, status: statusForError(err, this.fetchTimeoutMs) };
2274
+ }
2275
+ }
2250
2276
  async fetchWithTimeout(adapter) {
2251
2277
  try {
2252
2278
  const snapshot = await withTimeout(adapter.fetch(), this.fetchTimeoutMs, adapter.provider);
@@ -2258,14 +2284,7 @@ var QuotaService = class {
2258
2284
  }
2259
2285
  }
2260
2286
  handleFailure(adapter, err) {
2261
- let status;
2262
- if (err instanceof QuotaError) {
2263
- status = err.status;
2264
- } else if (err instanceof TimeoutError) {
2265
- status = { state: "timed_out", message: `upstream did not respond within ${this.fetchTimeoutMs}ms` };
2266
- } else {
2267
- status = { state: "error", message: redact(err), category: "unexpected" };
2268
- }
2287
+ const status = statusForError(err, this.fetchTimeoutMs);
2269
2288
  const stale = this.cache.getStale(adapter.provider);
2270
2289
  if (stale) {
2271
2290
  return { ...stale, freshness: "stale", status };
@@ -2280,6 +2299,13 @@ var QuotaService = class {
2280
2299
  };
2281
2300
  }
2282
2301
  };
2302
+ function statusForError(err, timeoutMs) {
2303
+ if (err instanceof QuotaError) return err.status;
2304
+ if (err instanceof TimeoutError) {
2305
+ return { state: "timed_out", message: `upstream did not respond within ${timeoutMs}ms` };
2306
+ }
2307
+ return { state: "error", message: redact(err), category: "unexpected" };
2308
+ }
2283
2309
  var TimeoutError = class extends Error {
2284
2310
  constructor(provider) {
2285
2311
  super(`quota fetch timed out: ${provider}`);
@@ -2404,8 +2430,7 @@ var codexAdapter = {
2404
2430
  provider: "codex",
2405
2431
  displayName: "OpenAI Codex",
2406
2432
  async isConfigured() {
2407
- if ((0, import_node_fs8.existsSync)((0, import_node_path8.join)(codexHome(), "auth.json"))) return true;
2408
- return await codexBinaryAvailable();
2433
+ return (0, import_node_fs8.existsSync)((0, import_node_path8.join)(codexHome(), "auth.json")) && resolveCodexBinary() !== null;
2409
2434
  },
2410
2435
  async fetch() {
2411
2436
  const result = await queryRateLimits();
@@ -2448,10 +2473,16 @@ function capitalize(s) {
2448
2473
  return s.charAt(0).toUpperCase() + s.slice(1);
2449
2474
  }
2450
2475
  async function queryRateLimits() {
2451
- if (!await codexBinaryAvailable()) {
2452
- throw new QuotaError({ state: "not_configured", message: "codex CLI not found on PATH" });
2453
- }
2454
- const proc = (0, import_node_child_process2.spawn)("codex", ["app-server"], { stdio: ["pipe", "pipe", "pipe"] });
2476
+ const codexBinary = resolveCodexBinary();
2477
+ if (!codexBinary) {
2478
+ throw new QuotaError({ state: "not_configured", message: "official Codex CLI not found" });
2479
+ }
2480
+ const binaryDir = (0, import_node_path8.dirname)(codexBinary);
2481
+ const childPath = [binaryDir, process.env.PATH].filter(Boolean).join(":");
2482
+ const proc = (0, import_node_child_process2.spawn)(codexBinary, ["app-server"], {
2483
+ stdio: ["pipe", "pipe", "pipe"],
2484
+ env: { ...process.env, PATH: childPath }
2485
+ });
2455
2486
  const client = new JsonRpcClient(proc);
2456
2487
  try {
2457
2488
  await client.request("initialize", {
@@ -2540,24 +2571,44 @@ var JsonRpcClient = class {
2540
2571
  this.pending.clear();
2541
2572
  }
2542
2573
  };
2543
- var cachedCodexPath;
2544
- async function codexBinaryAvailable() {
2545
- if (cachedCodexPath !== void 0) return cachedCodexPath !== null;
2546
- return new Promise((resolve2) => {
2547
- const proc = (0, import_node_child_process2.spawn)("which", ["codex"], { stdio: ["ignore", "pipe", "ignore"] });
2548
- let out = "";
2549
- proc.stdout.on("data", (c) => {
2550
- out += c;
2551
- });
2552
- proc.on("close", () => {
2553
- cachedCodexPath = out.trim() || null;
2554
- resolve2(cachedCodexPath !== null);
2555
- });
2556
- proc.on("error", () => {
2557
- cachedCodexPath = null;
2558
- resolve2(false);
2559
- });
2560
- });
2574
+ function resolveCodexBinary(options = {}) {
2575
+ const home = options.home ?? (0, import_node_os8.homedir)();
2576
+ const path = options.path ?? process.env.PATH ?? "";
2577
+ const explicitBinary = options.explicitBinary ?? process.env.CODEX_BIN;
2578
+ const isExecutable = options.isExecutable ?? defaultIsExecutable;
2579
+ const nvmRoot = (0, import_node_path8.join)(home, ".nvm", "versions", "node");
2580
+ const nvmVersions = options.nvmVersions ?? readDirectoryNames(nvmRoot);
2581
+ const candidates = [
2582
+ explicitBinary,
2583
+ "/Applications/Codex.app/Contents/Resources/codex",
2584
+ (0, import_node_path8.join)(home, "Applications", "Codex.app", "Contents", "Resources", "codex"),
2585
+ "/opt/homebrew/bin/codex",
2586
+ "/usr/local/bin/codex",
2587
+ (0, import_node_path8.join)(home, ".local", "bin", "codex"),
2588
+ (0, import_node_path8.join)(home, ".volta", "bin", "codex"),
2589
+ (0, import_node_path8.join)(home, ".bun", "bin", "codex"),
2590
+ ...nvmVersions.sort((a, b) => b.localeCompare(a, void 0, { numeric: true })).map((version) => (0, import_node_path8.join)(nvmRoot, version, "bin", "codex")),
2591
+ ...path.split(":").filter(Boolean).map((directory) => (0, import_node_path8.join)(directory, "codex"))
2592
+ ];
2593
+ for (const candidate of candidates) {
2594
+ if (candidate && isExecutable(candidate)) return candidate;
2595
+ }
2596
+ return null;
2597
+ }
2598
+ function defaultIsExecutable(candidate) {
2599
+ try {
2600
+ (0, import_node_fs8.accessSync)(candidate, import_node_fs8.constants.X_OK);
2601
+ return true;
2602
+ } catch {
2603
+ return false;
2604
+ }
2605
+ }
2606
+ function readDirectoryNames(directory) {
2607
+ try {
2608
+ return (0, import_node_fs8.readdirSync)(directory, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
2609
+ } catch {
2610
+ return [];
2611
+ }
2561
2612
  }
2562
2613
 
2563
2614
  // src/server/quota/adapters/claude.ts
@@ -2565,6 +2616,7 @@ var import_node_fs9 = require("node:fs");
2565
2616
  var import_node_path9 = require("node:path");
2566
2617
  var import_node_os9 = require("node:os");
2567
2618
  var import_node_child_process3 = require("node:child_process");
2619
+ var import_node_crypto = require("node:crypto");
2568
2620
  var claudeAdapter = {
2569
2621
  provider: "claude",
2570
2622
  displayName: "Claude Code",
@@ -2633,7 +2685,7 @@ function readClaudeToken() {
2633
2685
  if ((0, import_node_fs9.existsSync)(credPath)) {
2634
2686
  try {
2635
2687
  const parsed = JSON.parse((0, import_node_fs9.readFileSync)(credPath, "utf8"));
2636
- return parsed?.claudeAiOauth?.accessToken ?? null;
2688
+ return extractClaudeAccessToken(parsed);
2637
2689
  } catch {
2638
2690
  return null;
2639
2691
  }
@@ -2641,33 +2693,55 @@ function readClaudeToken() {
2641
2693
  return null;
2642
2694
  }
2643
2695
  function readFromKeychain() {
2644
- const candidates = ["Claude Code-credentials"];
2696
+ const candidates = claudeKeychainServiceNames(process.env.CLAUDE_CONFIG_DIR);
2645
2697
  try {
2646
2698
  const list = (0, import_node_child_process3.execFileSync)("security", ["dump-keychain"], { stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" });
2647
- for (const m of list.matchAll(/"srvname"<blob>="([^"]*Claude Code-credentials[^"]*)"/g)) {
2699
+ for (const m of list.matchAll(/"svce"<blob>="([^"]*Claude Code-credentials[^"]*)"/g)) {
2648
2700
  if (m[1] && !candidates.includes(m[1])) candidates.push(m[1]);
2649
2701
  }
2650
2702
  } catch {
2651
2703
  }
2704
+ const accounts = [safeUsername(), void 0];
2652
2705
  for (const name of candidates) {
2653
- try {
2654
- const raw = (0, import_node_child_process3.execFileSync)("security", ["find-generic-password", "-s", name, "-w"], {
2655
- stdio: ["ignore", "pipe", "ignore"],
2656
- encoding: "utf8"
2657
- }).trim();
2658
- if (!raw) continue;
2706
+ for (const account of accounts) {
2659
2707
  try {
2660
- const parsed = JSON.parse(raw);
2661
- return parsed?.claudeAiOauth?.accessToken ?? null;
2708
+ const args = ["find-generic-password", "-s", name];
2709
+ if (account) args.push("-a", account);
2710
+ args.push("-w");
2711
+ const raw = (0, import_node_child_process3.execFileSync)("/usr/bin/security", args, {
2712
+ stdio: ["ignore", "pipe", "ignore"],
2713
+ encoding: "utf8",
2714
+ timeout: 2e3
2715
+ }).trim();
2716
+ if (!raw) continue;
2717
+ const token = extractClaudeAccessToken(JSON.parse(raw));
2718
+ if (token) return token;
2662
2719
  } catch {
2663
- return raw;
2720
+ continue;
2664
2721
  }
2665
- } catch {
2666
- continue;
2667
2722
  }
2668
2723
  }
2669
2724
  return null;
2670
2725
  }
2726
+ function claudeKeychainServiceNames(configDir) {
2727
+ if (!configDir) return ["Claude Code-credentials"];
2728
+ const hash = (0, import_node_crypto.createHash)("sha256").update(configDir).digest("hex").slice(0, 8);
2729
+ return [`Claude Code-credentials-${hash}`, "Claude Code-credentials"];
2730
+ }
2731
+ function extractClaudeAccessToken(value) {
2732
+ if (!value || typeof value !== "object") return null;
2733
+ const root = value;
2734
+ const nested = root.claudeAiOauth;
2735
+ const credentials = nested && typeof nested === "object" ? nested : root;
2736
+ return typeof credentials.accessToken === "string" && credentials.accessToken ? credentials.accessToken : null;
2737
+ }
2738
+ function safeUsername() {
2739
+ try {
2740
+ return (0, import_node_os9.userInfo)().username?.trim() || void 0;
2741
+ } catch {
2742
+ return void 0;
2743
+ }
2744
+ }
2671
2745
 
2672
2746
  // src/server/quota/adapters/glm.ts
2673
2747
  var import_node_fs11 = require("node:fs");
@@ -2703,8 +2777,8 @@ var glmAdapter = {
2703
2777
  async isConfigured() {
2704
2778
  return !!resolveCredential();
2705
2779
  },
2706
- async fetch() {
2707
- const cred = resolveCredential();
2780
+ async fetch(options) {
2781
+ const cred = resolveCredential(options?.credential);
2708
2782
  if (!cred) {
2709
2783
  throw new QuotaError({ state: "not_configured", message: "set ZAI_API_KEY or ZHIPU_API_KEY" });
2710
2784
  }
@@ -2760,7 +2834,13 @@ function classifyFetchError2(err) {
2760
2834
  const msg = err instanceof Error ? err.message : String(err);
2761
2835
  return new QuotaError({ state: "upstream_unavailable", message: msg.slice(0, 200) });
2762
2836
  }
2763
- function resolveCredential() {
2837
+ function resolveCredential(proposed) {
2838
+ if (proposed?.apiKey) {
2839
+ return {
2840
+ key: proposed.apiKey,
2841
+ base: proposed.baseUrl || "https://open.bigmodel.cn"
2842
+ };
2843
+ }
2764
2844
  const stored = readStoredCredential("glm");
2765
2845
  if (stored) return { key: stored.apiKey, base: stored.baseUrl || "https://open.bigmodel.cn" };
2766
2846
  const zai = envOrConfig("ZAI_API_KEY");
@@ -2815,8 +2895,8 @@ var minimaxAdapter = {
2815
2895
  async isConfigured() {
2816
2896
  return !!resolveCredential2();
2817
2897
  },
2818
- async fetch() {
2819
- const cred = resolveCredential2();
2898
+ async fetch(options) {
2899
+ const cred = resolveCredential2(options?.credential);
2820
2900
  if (!cred) {
2821
2901
  throw new QuotaError({ state: "not_configured", message: "set MINIMAX_API_KEY (Subscription Key)" });
2822
2902
  }
@@ -2875,7 +2955,12 @@ function classifyFetchError3(err) {
2875
2955
  const msg = err instanceof Error ? err.message : String(err);
2876
2956
  return new QuotaError({ state: "upstream_unavailable", message: msg.slice(0, 200) });
2877
2957
  }
2878
- function resolveCredential2() {
2958
+ function resolveCredential2(proposed) {
2959
+ if (proposed?.apiKey) {
2960
+ const region2 = (process.env.MINIMAX_REGION || "").toLowerCase();
2961
+ const base2 = proposed.baseUrl || (region2 === "cn" ? "https://www.minimaxi.com" : "https://www.minimax.io");
2962
+ return { key: proposed.apiKey, base: base2 };
2963
+ }
2879
2964
  const stored = readStoredCredential("minimax");
2880
2965
  if (stored) {
2881
2966
  const region2 = (process.env.MINIMAX_REGION || "").toLowerCase();
@@ -2903,9 +2988,9 @@ var kimiAdapter = {
2903
2988
  const cred = readCredentials();
2904
2989
  return !!cred && !!cred.access_token;
2905
2990
  },
2906
- async fetch() {
2991
+ async fetch(options) {
2907
2992
  const credPath = credentialsPath();
2908
- let cred = readCredentials();
2993
+ let cred = options?.credential?.apiKey ? { access_token: options.credential.apiKey, token_type: "Bearer" } : readCredentials();
2909
2994
  if (!cred || !cred.access_token) {
2910
2995
  throw new QuotaError({ state: "not_configured", message: "run `kimi` to log in first" });
2911
2996
  }
@@ -3057,6 +3142,24 @@ async function getQuota(_req, res) {
3057
3142
  res.status(500).json({ error: "Failed to fetch quota", hint: message });
3058
3143
  }
3059
3144
  }
3145
+ var editableQuotaProviders = /* @__PURE__ */ new Set(["glm", "kimi", "minimax"]);
3146
+ async function validateQuotaCredential(req, res) {
3147
+ const provider = req.body?.provider;
3148
+ const apiKey = typeof req.body?.apiKey === "string" ? req.body.apiKey.trim() : "";
3149
+ const baseUrl = typeof req.body?.baseUrl === "string" ? req.body.baseUrl.trim() : void 0;
3150
+ if (!editableQuotaProviders.has(provider) || !apiKey) {
3151
+ res.status(400).json({
3152
+ error: "Invalid credential request",
3153
+ hint: "A supported provider and non-empty token are required."
3154
+ });
3155
+ return;
3156
+ }
3157
+ const result = await quotaService.validateCredential(provider, {
3158
+ apiKey,
3159
+ baseUrl: baseUrl || void 0
3160
+ });
3161
+ res.status(result.valid ? 200 : 422).json(result);
3162
+ }
3060
3163
  function getAgents(_req, res) {
3061
3164
  try {
3062
3165
  const agents = detectAvailableAgents();
@@ -3090,6 +3193,7 @@ function registerApiRoutes(router, appInfo) {
3090
3193
  router.get("/blocks", getBlocks);
3091
3194
  router.get("/analytics", getAnalytics);
3092
3195
  router.get("/quota", getQuota);
3196
+ router.post("/quota/validate", validateQuotaCredential);
3093
3197
  }
3094
3198
 
3095
3199
  // src/server/index.ts
@@ -3133,6 +3237,7 @@ function resolveStaticAssetBaseDir(moduleUrl = __esbuild_import_meta_url, baseDi
3133
3237
  function createApp(_port, baseDir) {
3134
3238
  const app = (0, import_express.default)();
3135
3239
  const router = import_express.default.Router();
3240
+ app.use(import_express.default.json({ limit: "16kb" }));
3136
3241
  registerApiRoutes(router, {
3137
3242
  packageName: PACKAGE_NAME,
3138
3243
  version: getPackageVersion(),