@kairyou/agent-tools 0.8.0 → 0.10.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.
@@ -4,6 +4,8 @@
4
4
 
5
5
  import { newApiQuotaScale, providerUsageDays } from "./config.mjs";
6
6
 
7
+ const ONE_API_HARD_LIMIT_SENTINEL_USD = 1_000_000;
8
+
7
9
  export function pickNumber(obj, keys) {
8
10
  for (const key of keys) {
9
11
  const value = obj?.[key];
@@ -112,15 +114,34 @@ export async function formatQuota(data) {
112
114
  return keys ? `received (${keys})` : `checked ${unit}`;
113
115
  }
114
116
 
115
- export async function formatNewApiTokenLine(data) {
117
+ export function newApiTokenQuota(data) {
116
118
  const root = usageRoot(data);
117
119
  const unlimited = root?.unlimited_quota === true || root?.unlimitedQuota === true;
118
- const quota = pickNumber(root, ["quota", "limit", "total_quota", "totalQuota"]);
119
- const used = pickNumber(root, ["used_quota", "usedQuota", "used"]);
120
- let remaining = pickNumber(root, ["remain_quota", "remainQuota", "remaining", "balance"]);
120
+ const quota = pickNumber(root, [
121
+ "quota",
122
+ "limit",
123
+ "total_quota",
124
+ "totalQuota",
125
+ "total_granted",
126
+ "totalGranted",
127
+ ]);
128
+ const used = pickNumber(root, ["used_quota", "usedQuota", "used", "total_used", "totalUsed"]);
129
+ let remaining = pickNumber(root, [
130
+ "remain_quota",
131
+ "remainQuota",
132
+ "remaining",
133
+ "balance",
134
+ "total_available",
135
+ "totalAvailable",
136
+ ]);
121
137
  if (remaining === undefined && quota !== undefined && used !== undefined) {
122
138
  remaining = Math.max(0, quota - used);
123
139
  }
140
+ return { unlimited, quota, used, remaining };
141
+ }
142
+
143
+ export async function formatNewApiTokenLine(data) {
144
+ const { unlimited, quota, used, remaining } = newApiTokenQuota(data);
124
145
 
125
146
  if (!unlimited && quota === undefined && used === undefined && remaining === undefined) {
126
147
  throw new Error("NewAPI token usage payload has no quota fields");
@@ -128,7 +149,7 @@ export async function formatNewApiTokenLine(data) {
128
149
 
129
150
  const parts = [];
130
151
  if (unlimited) parts.push("unlimited");
131
- if (remaining !== undefined) parts.push(`balance ${await formatNewApiQuota(remaining)}`);
152
+ if (!unlimited && remaining !== undefined) parts.push(`balance ${await formatNewApiQuota(remaining)}`);
132
153
  if (used !== undefined && quota !== undefined) {
133
154
  parts.push(`used ${await formatNewApiQuota(used)}/${await formatNewApiQuota(quota)}`);
134
155
  } else if (used !== undefined) {
@@ -158,9 +179,62 @@ export function formatOpenRouterLine(data) {
158
179
  }
159
180
 
160
181
  export function formatOneApiBillingLine(limit, used) {
182
+ if (!hasSpendableOneApiLimit(limit)) return `used ${formatMoney(used)}`;
161
183
  return `balance ${formatMoney(Math.max(0, limit - used))} | used ${formatMoney(used)}/${formatMoney(limit)}`;
162
184
  }
163
185
 
186
+ export function hasSpendableOneApiLimit(limit) {
187
+ return Number.isFinite(limit) && limit >= 0 && limit < ONE_API_HARD_LIMIT_SENTINEL_USD;
188
+ }
189
+
190
+ function effectiveClaudeCodeHubWindow(root, suffix) {
191
+ const keyLimit = pickNumber(root, [`keyLimit${suffix}Usd`]);
192
+ if (keyLimit !== undefined && keyLimit > 0) {
193
+ return { limit: keyLimit, used: pickNumber(root, [`keyCurrent${suffix}Usd`]) };
194
+ }
195
+ const userLimit = pickNumber(root, [`userLimit${suffix}Usd`]);
196
+ return {
197
+ limit: userLimit !== undefined && userLimit > 0 ? userLimit : undefined,
198
+ used: pickNumber(root, [`userCurrent${suffix}Usd`]),
199
+ };
200
+ }
201
+
202
+ function earliestDate(...values) {
203
+ return values
204
+ .map((value) => ({ value, time: Date.parse(value) }))
205
+ .filter((entry) => entry.value && Number.isFinite(entry.time))
206
+ .sort((left, right) => left.time - right.time)[0]?.value;
207
+ }
208
+
209
+ export function formatClaudeCodeHubLine(data) {
210
+ const root = usageRoot(data);
211
+ const windows = [
212
+ ["5h", "5h"],
213
+ ["D", "Daily"],
214
+ ["W", "Weekly"],
215
+ ["M", "Monthly"],
216
+ ["T", "Total"],
217
+ ];
218
+ const parts = [];
219
+
220
+ for (const [label, suffix] of windows) {
221
+ const { limit, used } = effectiveClaudeCodeHubWindow(root, suffix);
222
+ if (limit !== undefined && used !== undefined) {
223
+ parts.push(`${label} ${formatMoney(used)}/${formatMoney(limit)}`);
224
+ }
225
+ }
226
+
227
+ if (parts.length === 0) {
228
+ const totalUsed = pickNumber(root, ["keyCurrentTotalUsd", "userCurrentTotalUsd"]);
229
+ if (totalUsed !== undefined) parts.push(`used ${formatMoney(totalUsed)}`);
230
+ }
231
+
232
+ const expires = shortDate(earliestDate(root?.expiresAt, root?.userExpiresAt));
233
+ if (expires) parts.push(`Exp ${expires}`);
234
+ if (parts.length === 0) throw new Error("Claude Code Hub quota payload has no quota fields");
235
+ return parts.join(" | ");
236
+ }
237
+
164
238
  function formatQuotaLimitedLine(root) {
165
239
  const quota = root?.quota || {};
166
240
  const limit = pickNumber(quota, ["limit", "quota"]);
@@ -4,7 +4,10 @@
4
4
  import { createContext, runInContext } from "node:vm";
5
5
  import { debugLog } from "./config.mjs";
6
6
 
7
- const REQUEST_TIMEOUT_MS = 5000;
7
+ // No user waits on this request: the hook reads a snapshot and refreshes in a
8
+ // detached process, and skill/CLI queries are explicit. Custom routes can pass
9
+ // their own timeoutMs.
10
+ const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
8
11
  const SHIELD_USER_AGENT =
9
12
  "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
10
13
  "(KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36";
@@ -126,7 +129,7 @@ function mergeSetCookiePairs(cookieHeader, setCookieHeaders) {
126
129
  }
127
130
 
128
131
  export async function requestJson(url, options = {}) {
129
- const { key = "", headers = {}, name = "usage", timeoutMs = REQUEST_TIMEOUT_MS } = options;
132
+ const { key = "", headers = {}, name = "usage", timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS } = options;
130
133
  let cookieHeader = "";
131
134
  for (let attempt = 0; attempt < 2; attempt += 1) {
132
135
  const controller = new AbortController();
@@ -24,9 +24,12 @@ import {
24
24
  usageRoot,
25
25
  hasV1UsageFields,
26
26
  formatQuota,
27
+ newApiTokenQuota,
27
28
  formatNewApiTokenLine,
28
29
  formatOneApiBillingLine,
30
+ hasSpendableOneApiLimit,
29
31
  formatOpenRouterLine,
32
+ formatClaudeCodeHubLine,
30
33
  } from "./format.mjs";
31
34
  import { readRouteCache } from "./cache.mjs";
32
35
 
@@ -66,13 +69,7 @@ async function fetchNewApiTokenUsage(context) {
66
69
  key: context.key,
67
70
  name: "New API token usage",
68
71
  });
69
- const root = usageRoot(json);
70
- const quota = pickNumber(root, ["quota", "limit", "total_quota", "totalQuota"]);
71
- const used = pickNumber(root, ["used_quota", "usedQuota", "used"]);
72
- let remaining = pickNumber(root, ["remain_quota", "remainQuota", "remaining", "balance"]);
73
- if (remaining === undefined && quota !== undefined && used !== undefined) {
74
- remaining = Math.max(0, quota - used);
75
- }
72
+ const { quota, used, remaining } = newApiTokenQuota(json);
76
73
  const scale = await newApiQuotaScale();
77
74
  const quotaForWarning = scale ? quota / scale : quota;
78
75
  const usedForWarning = scale ? used / scale : used;
@@ -109,13 +106,16 @@ async function fetchOneApiBillingUsage(context) {
109
106
  throw new Error("One API billing payload has no quota fields");
110
107
  }
111
108
  const used = usageCents / 100;
109
+ const spendableLimit = hasSpendableOneApiLimit(limit);
112
110
  const normalized = {
113
- mode: "quota_limited",
114
- quota: {
115
- limit,
116
- used,
117
- remaining: Math.max(0, limit - used),
118
- },
111
+ mode: spendableLimit ? "quota_limited" : "unrestricted",
112
+ ...(spendableLimit ? {
113
+ quota: {
114
+ limit,
115
+ used,
116
+ remaining: Math.max(0, limit - used),
117
+ },
118
+ } : { used }),
119
119
  unit: "USD",
120
120
  source: "oneapi-billing",
121
121
  raw: { subscription, usage },
@@ -128,6 +128,24 @@ async function fetchOneApiBillingUsage(context) {
128
128
  );
129
129
  }
130
130
 
131
+ // Claude Code Hub exposes self-scoped quota windows to the same API key used
132
+ // for model requests. The endpoint accepts the existing Bearer authentication.
133
+ async function fetchClaudeCodeHubUsage(context) {
134
+ const base = cleanBaseUrl(context.baseUrl)
135
+ .replace(/\/api\/v1$/i, "")
136
+ .replace(/\/v1$/i, "");
137
+ const json = await requestJson(joinUrl(base, "/api/v1/me/quota"), {
138
+ key: context.key,
139
+ name: "Claude Code Hub quota",
140
+ });
141
+ return usageResult(
142
+ context,
143
+ "claude-code-hub",
144
+ formatClaudeCodeHubLine(json),
145
+ json
146
+ );
147
+ }
148
+
131
149
  // OpenRouter exposes normal API-key usage at /api/v1/key. Some accounts also
132
150
  // expose credits at /api/v1/credits; keep this route isolated because
133
151
  // OpenRouter's base URL already includes /api/v1, unlike New API.
@@ -173,6 +191,11 @@ const USAGE_ROUTES = {
173
191
  path: "/api/v1/key",
174
192
  run: fetchOpenRouterUsage,
175
193
  },
194
+ "claude-code-hub": {
195
+ id: "claude-code-hub",
196
+ path: "/api/v1/me/quota",
197
+ run: fetchClaudeCodeHubUsage,
198
+ },
176
199
  };
177
200
 
178
201
  // User-authored gateway routes, declared in config.jsonc:
@@ -256,7 +279,8 @@ async function routeRegistry() {
256
279
  return registry;
257
280
  }
258
281
 
259
- // Presets select API-key usage protocols, not hosted gateway brands.
282
+ // Presets primarily select API-key usage protocols. Brand aliases map to a
283
+ // shared protocol when the upstream gateway exposes the same endpoints.
260
284
  async function usageRouteIds(context) {
261
285
  const preset = await usagePreset();
262
286
  const routes = {
@@ -264,7 +288,10 @@ async function usageRouteIds(context) {
264
288
  "openai-compatible": ["v1-usage"],
265
289
  "new-api": ["newapi-token"],
266
290
  "one-api": ["oneapi-billing"],
291
+ "one-hub": ["oneapi-billing"],
292
+ "done-hub": ["oneapi-billing"],
267
293
  "openrouter": ["openrouter"],
294
+ "claude-code-hub": ["claude-code-hub"],
268
295
  };
269
296
  if (routes[preset]) return routes[preset];
270
297
 
@@ -275,7 +302,7 @@ async function usageRouteIds(context) {
275
302
  const customIds = (await customRoutes()).map((route) => route.id);
276
303
  const builtinIds = hostIncludes(context.baseUrl, "openrouter.ai")
277
304
  ? ["openrouter"]
278
- : ["v1-usage", "newapi-token", "oneapi-billing"];
305
+ : ["v1-usage", "newapi-token", "oneapi-billing", "claude-code-hub"];
279
306
  return [...new Set([...customIds, ...builtinIds])];
280
307
  }
281
308
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kairyou/agent-tools",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Reusable Agent Skills and installable integrations (statusline, provider usage, vision) for Codex, Claude Code, and opencode.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -15,6 +15,7 @@
15
15
  },
16
16
  "files": [
17
17
  "config.default.jsonc",
18
+ "docs/",
18
19
  "dist/",
19
20
  "integrations/",
20
21
  "scripts/",
@@ -57,6 +57,15 @@ const INSTALL_ROOT =
57
57
  process.env.AGENT_TOOLS_HOME || path.join(os.homedir(), ".agent-tools");
58
58
  const META_KEY = "_agentTools";
59
59
  const META_VERSION = 1;
60
+ // Stamped into install-state.json so a later install can tell which release
61
+ // wrote the current layout. Purely informational today.
62
+ const PACKAGE_VERSION = (() => {
63
+ try {
64
+ return JSON.parse(fs.readFileSync(path.join(REPO_ROOT, "package.json"), "utf8")).version || "";
65
+ } catch {
66
+ return "";
67
+ }
68
+ })();
60
69
  // Everything copied into ~/.agent-tools is built output from dist/ (see
61
70
  // scripts/build.mjs); integrations/ holds the sources.
62
71
  const SOURCE = {
@@ -395,14 +404,13 @@ function removeFile(file, dryRun) {
395
404
  console.log(` removed ${file}`);
396
405
  }
397
406
 
398
- function usageEntry() {
407
+ function usageEntry({ silent = false } = {}) {
399
408
  return {
400
409
  hooks: [
401
410
  {
402
411
  type: "command",
403
- command: nodeCmd(RUNTIME.codexUsageHook),
412
+ command: `${nodeCmd(RUNTIME.codexUsageHook)}${silent ? " --silent" : ""}`,
404
413
  timeout: 5,
405
- statusMessage: "Refreshing API usage",
406
414
  },
407
415
  ],
408
416
  };
@@ -433,7 +441,7 @@ function applyProviderUsage(cfg, { remove }) {
433
441
  }
434
442
  cfg.hooks[event] = cfg.hooks[event] || [];
435
443
  cfg.hooks[event] = cfg.hooks[event].filter((entry) => !isOurProviderUsageEntry(entry));
436
- cfg.hooks[event].push(usageEntry());
444
+ cfg.hooks[event].push(usageEntry({ silent: event === "UserPromptSubmit" }));
437
445
  }
438
446
  if (Object.keys(cfg.hooks).length === 0) delete cfg.hooks;
439
447
  }
@@ -631,6 +639,7 @@ function managedSkillStatus(dest, identity) {
631
639
 
632
640
  function recordManagedSkill(dest, identity) {
633
641
  const state = readInstallState();
642
+ state.packageVersion = PACKAGE_VERSION;
634
643
  state.artifacts[skillManifestKey(dest)] = {
635
644
  path: fwd(path.resolve(dest)),
636
645
  ...identity,
@@ -41,8 +41,12 @@ if (typeof PACKAGE_NAME !== "string" || PACKAGE_NAME.trim() === "") {
41
41
  }
42
42
 
43
43
  function runNpm(args, { capture = false, allowFailure = false } = {}) {
44
- const npmExecPath = process.env.npm_execpath;
45
- const command = npmExecPath ? process.execPath : process.platform === "win32" ? "npm.cmd" : "npm";
44
+ // npm run sets npm_execpath. When invoked directly (`node scripts/...`), find
45
+ // the bundled npm-cli.js next to node spawning npm.cmd fails on Node 22+.
46
+ // Version-manager layouts may not have it there, so fall back to PATH.
47
+ const bundledNpm = path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js");
48
+ const npmExecPath = process.env.npm_execpath || (fs.existsSync(bundledNpm) ? bundledNpm : undefined);
49
+ const command = npmExecPath ? process.execPath : "npm";
46
50
  const commandArgs = npmExecPath ? [npmExecPath, ...args] : args;
47
51
  const result = spawnSync(command, commandArgs, {
48
52
  cwd: ROOT,
@@ -78,12 +78,14 @@ function run(command, args, { label, onFailure } = {}) {
78
78
  }
79
79
 
80
80
  function runNpm(args) {
81
- const npmExecPath = process.env.npm_execpath;
82
- if (npmExecPath) {
83
- run(process.execPath, [npmExecPath, ...args], { label: `npm ${args.join(" ")}` });
84
- } else {
85
- run(process.platform === "win32" ? "npm.cmd" : "npm", args);
86
- }
81
+ // npm run sets npm_execpath. When invoked directly (`node scripts/...`), find
82
+ // the bundled npm-cli.js next to node — spawning npm.cmd fails on Node 22+.
83
+ // Version-manager layouts may not have it there, so fall back to PATH.
84
+ const bundledNpm = path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js");
85
+ const npmExecPath = process.env.npm_execpath || (fs.existsSync(bundledNpm) ? bundledNpm : undefined);
86
+ const command = npmExecPath ? process.execPath : "npm";
87
+ const commandArgs = npmExecPath ? [npmExecPath, ...args] : args;
88
+ run(command, commandArgs, { label: `npm ${args.join(" ")}` });
87
89
  }
88
90
 
89
91
  function printRecovery(commands) {