@kairyou/agent-tools 0.7.2 → 0.8.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
@@ -75,8 +75,8 @@ npx -y @kairyou/agent-tools@latest <capability> -a <agent...>
75
75
 
76
76
  `--dry-run` previews, `--uninstall` unwires the integration from the agent, and
77
77
  re-running the install command updates. The installer only touches config
78
- entries it wrote itself; shared files under `~/.agent-tools` are kept on
79
- uninstall.
78
+ entries it wrote itself, and `config.jsonc` updates only add missing default
79
+ keys without touching your edits or comments.
80
80
 
81
81
  | Capability | Claude Code | Codex | OpenCode |
82
82
  | --- | --- | --- | --- |
@@ -90,24 +90,20 @@ uninstall.
90
90
  npx -y @kairyou/agent-tools@latest statusline -a claude
91
91
  ```
92
92
 
93
- The installer writes `statusLine` to `~/.claude/settings.json`. The default
94
- output is:
93
+ The installer writes `statusLine` to `~/.claude/settings.json`. Example output:
95
94
 
96
95
  ```text
96
+ # Pick and order the fields via statusline.fields in ~/.agent-tools/config.jsonc:
97
97
  ⎇ main | Opus 4.8 | 5h 7% ⟳2h54m | w 41% ⟳3d1h
98
98
 
99
99
  # With a compatible API relay, its quota info is shown instead, e.g.:
100
- ⎇ main | Opus 4.8 | API | balance $362 | today $61.7 | 30d $566
100
+ ⎇ main | Opus 4.8 | balance $362 | today $61.7 | 30d $566
101
101
  ```
102
102
 
103
103
  Here `5h` and `w` are Claude's rolling usage windows and `⟳` is the reset
104
104
  countdown; see [Provider usage](#provider-usage) below for relay quota
105
105
  compatibility and configuration.
106
106
 
107
- To choose what appears, edit `statusline.fields` in
108
- `~/.agent-tools/config.jsonc`. Installer updates only add missing default keys
109
- and never touch your edits or comments.
110
-
111
107
  ### Provider usage
112
108
 
113
109
  For API relay / gateway setups: shows the relay's balance / quota inside the
@@ -157,10 +153,10 @@ Output examples:
157
153
 
158
154
  ```text
159
155
  # Relay plan quota.
160
- API | D $0.0/$100 | W $0.0/$300 | Exp 07-08
156
+ D $0.0/$100 | W $0.0/$300 | Exp 07-08
161
157
 
162
158
  # Wallet balance.
163
- API | balance $362 | today $61.7 | 30d $566
159
+ balance $362 | today $61.7 | 30d $566
164
160
  ```
165
161
 
166
162
  Fields: `D/W/M` are daily/weekly/monthly spend against plan limits; `Exp` is
@@ -207,7 +203,7 @@ export async function run(context, { requestJson, agentConfig }) {
207
203
  const me = await requestJson(`${context.baseUrl}/api/user/self`, {
208
204
  headers: { authorization: `Bearer ${session?.data?.accessToken}` },
209
205
  });
210
- return { text: `API | balance ¥${me?.data?.balance}` };
206
+ return { text: `balance ¥${me?.data?.balance}` };
211
207
  }
212
208
  ```
213
209
 
package/README.zh-CN.md CHANGED
@@ -72,7 +72,8 @@ npx -y @kairyou/agent-tools@latest <capability> -a <agent...>
72
72
  ```
73
73
 
74
74
  `--dry-run` 预览, `--uninstall` 解除对应集成, 重新执行安装命令即为更新.
75
- 安装器只改动自己写入的配置项; 卸载会保留 `~/.agent-tools` 下的共享文件.
75
+ 安装器只改动自己写入的配置项, 更新 `config.jsonc` 时只补充缺失的默认键,
76
+ 不会动你的修改和注释.
76
77
 
77
78
  | Capability | Claude Code | Codex | OpenCode |
78
79
  | --- | --- | --- | --- |
@@ -86,21 +87,19 @@ npx -y @kairyou/agent-tools@latest <capability> -a <agent...>
86
87
  npx -y @kairyou/agent-tools@latest statusline -a claude
87
88
  ```
88
89
 
89
- 安装器会把 `statusLine` 写入 `~/.claude/settings.json`. 默认显示:
90
+ 安装器会把 `statusLine` 写入 `~/.claude/settings.json`. 输出示例:
90
91
 
91
92
  ```text
93
+ # 显示项与顺序由 ~/.agent-tools/config.jsonc 的 statusline.fields 控制:
92
94
  ⎇ main | Opus 4.8 | 5h 7% ⟳2h54m | w 41% ⟳3d1h
93
95
 
94
96
  # 使用兼容的 API 中转时, 显示中转的额度信息, 比如:
95
- ⎇ main | Opus 4.8 | API | balance $362 | today $61.7 | 30d $566
97
+ ⎇ main | Opus 4.8 | balance $362 | today $61.7 | 30d $566
96
98
  ```
97
99
 
98
100
  其中 `5h` / `w` 是 Claude 的滚动用量窗口, `⟳` 后面是重置倒计时;
99
101
  中转额度信息的兼容性与配置见下方 [Provider usage](#provider-usage).
100
102
 
101
- 如需控制显示项, 修改 `~/.agent-tools/config.jsonc` 里的
102
- `statusline.fields`. 安装器更新时只会补充缺失的默认键, 不会动你的修改和注释.
103
-
104
103
  ### Provider usage
105
104
 
106
105
  面向使用 API 中转的场景: 在 agent 内直接显示中转网关的余额/额度, 按量付费或
@@ -145,10 +144,10 @@ provider 的 `base_url` 和密钥; Claude Code: 读取 `ANTHROPIC_BASE_URL` 与
145
144
 
146
145
  ```text
147
146
  # 中转套餐额度.
148
- API | D $0.0/$100 | W $0.0/$300 | Exp 07-08
147
+ D $0.0/$100 | W $0.0/$300 | Exp 07-08
149
148
 
150
149
  # 钱包余额.
151
- API | balance $362 | today $61.7 | 30d $566
150
+ balance $362 | today $61.7 | 30d $566
152
151
  ```
153
152
 
154
153
  字段含义: `D/W/M` 是日/周/月套餐消耗与上限, `Exp` 是套餐到期日,
@@ -193,7 +192,7 @@ export async function run(context, { requestJson, agentConfig }) {
193
192
  const me = await requestJson(`${context.baseUrl}/api/user/self`, {
194
193
  headers: { authorization: `Bearer ${session?.data?.accessToken}` },
195
194
  });
196
- return { text: `API | balance ¥${me?.data?.balance}` };
195
+ return { text: `balance ¥${me?.data?.balance}` };
197
196
  }
198
197
  ```
199
198
 
File without changes
File without changes
File without changes
@@ -1276,9 +1276,6 @@ function formatMaybeMoney(value, unit = "USD") {
1276
1276
  if (unit === "USD" || unit === "$") return formatMoney(value);
1277
1277
  return `${value.toLocaleString("en-US", { maximumFractionDigits: 1 })} ${unit}`;
1278
1278
  }
1279
- function usageParts() {
1280
- return ["API"];
1281
- }
1282
1279
  async function formatNewApiQuota(value) {
1283
1280
  const scale = await newApiQuotaScale();
1284
1281
  if (Number.isFinite(scale) && scale > 0) return formatMoney(value / scale);
@@ -1328,26 +1325,26 @@ function hasV1UsageFields(root) {
1328
1325
  "usage"
1329
1326
  ]) !== void 0 || pickNumber(root?.quota, ["limit", "quota", "used", "quota_used", "remaining"]) !== void 0 || pickNumber(root?.usage?.today, ["actual_cost", "cost"]) !== void 0 || Array.isArray(root?.daily_usage) && root.daily_usage.length > 0;
1330
1327
  }
1331
- async function formatQuota(label, data) {
1328
+ async function formatQuota(data) {
1332
1329
  const root = usageRoot(data);
1333
1330
  const unit = root?.unit || "USD";
1334
1331
  const remaining = pickNumber(root, ["remaining"]);
1335
1332
  const hardLimit = pickNumber(root, ["hard_limit_usd", "hard_limit", "total_granted", "quota"]);
1336
1333
  const used = pickNumber(root, ["total_usage", "used", "usage"]);
1337
1334
  const balance = pickNumber(root, ["balance", "remaining", "remain", "available"]);
1338
- if (isQuotaLimitedUsage(root)) return formatQuotaLimitedLine(label, root);
1339
- if (isSubscriptionUsage(root)) return formatUsageLine(label, root);
1340
- if (isWalletUsage(root)) return await formatWalletLine(label, root);
1341
- if (remaining !== void 0) return formatUsageLine(label, root);
1342
- if (balance !== void 0) return `API | balance ${formatMaybeMoney(balance, unit)}`;
1335
+ if (isQuotaLimitedUsage(root)) return formatQuotaLimitedLine(root);
1336
+ if (isSubscriptionUsage(root)) return formatUsageLine(root);
1337
+ if (isWalletUsage(root)) return await formatWalletLine(root);
1338
+ if (remaining !== void 0) return formatUsageLine(root);
1339
+ if (balance !== void 0) return `balance ${formatMaybeMoney(balance, unit)}`;
1343
1340
  if (hardLimit !== void 0 && used !== void 0) {
1344
- return `API | remaining ${formatMaybeMoney(Math.max(0, hardLimit - used), unit)}`;
1341
+ return `remaining ${formatMaybeMoney(Math.max(0, hardLimit - used), unit)}`;
1345
1342
  }
1346
- if (hardLimit !== void 0) return `API | total ${formatMaybeMoney(hardLimit, unit)}`;
1343
+ if (hardLimit !== void 0) return `total ${formatMaybeMoney(hardLimit, unit)}`;
1347
1344
  const keys = Object.keys(root || {}).slice(0, 4).join(", ");
1348
- return keys ? `API | received (${keys})` : `API | checked ${unit}`;
1345
+ return keys ? `received (${keys})` : `checked ${unit}`;
1349
1346
  }
1350
- async function formatNewApiTokenLine(label, data) {
1347
+ async function formatNewApiTokenLine(data) {
1351
1348
  const root = usageRoot(data);
1352
1349
  const unlimited = root?.unlimited_quota === true || root?.unlimitedQuota === true;
1353
1350
  const quota = pickNumber(root, ["quota", "limit", "total_quota", "totalQuota"]);
@@ -1359,7 +1356,7 @@ async function formatNewApiTokenLine(label, data) {
1359
1356
  if (!unlimited && quota === void 0 && used === void 0 && remaining === void 0) {
1360
1357
  throw new Error("NewAPI token usage payload has no quota fields");
1361
1358
  }
1362
- const parts = usageParts();
1359
+ const parts = [];
1363
1360
  if (unlimited) parts.push("unlimited");
1364
1361
  if (remaining !== void 0) parts.push(`balance ${await formatNewApiQuota(remaining)}`);
1365
1362
  if (used !== void 0 && quota !== void 0) {
@@ -1369,13 +1366,13 @@ async function formatNewApiTokenLine(label, data) {
1369
1366
  }
1370
1367
  return parts.join(" | ");
1371
1368
  }
1372
- function formatOpenRouterLine(label, data) {
1369
+ function formatOpenRouterLine(data) {
1373
1370
  const root = usageRoot(data);
1374
1371
  const limit = pickNumber(root, ["limit", "limit_remaining", "total_credits"]);
1375
1372
  const remaining = pickNumber(root, ["limit_remaining", "remaining_credits"]);
1376
1373
  const used = pickNumber(root, ["usage", "total_usage", "spend"]);
1377
1374
  const reset = root?.limit_reset || root?.reset_at ? shortDate(root.limit_reset || root.reset_at) : "";
1378
- const parts = usageParts();
1375
+ const parts = [];
1379
1376
  if (remaining !== void 0) parts.push(`balance ${formatMoney(remaining)}`);
1380
1377
  if (used !== void 0 && limit !== void 0 && limit !== remaining) {
1381
1378
  parts.push(`used ${formatMoney(used)}/${formatMoney(limit)}`);
@@ -1383,18 +1380,18 @@ function formatOpenRouterLine(label, data) {
1383
1380
  parts.push(`used ${formatMoney(used)}`);
1384
1381
  }
1385
1382
  if (reset) parts.push(`Reset ${reset}`);
1386
- if (parts.length === 1) throw new Error("OpenRouter payload has no usage fields");
1383
+ if (parts.length === 0) throw new Error("OpenRouter payload has no usage fields");
1387
1384
  return parts.join(" | ");
1388
1385
  }
1389
1386
  function formatOneApiBillingLine(limit, used) {
1390
- return `API | balance ${formatMoney(Math.max(0, limit - used))} | used ${formatMoney(used)}/${formatMoney(limit)}`;
1387
+ return `balance ${formatMoney(Math.max(0, limit - used))} | used ${formatMoney(used)}/${formatMoney(limit)}`;
1391
1388
  }
1392
- function formatQuotaLimitedLine(label, root) {
1389
+ function formatQuotaLimitedLine(root) {
1393
1390
  const quota = root?.quota || {};
1394
1391
  const limit = pickNumber(quota, ["limit", "quota"]);
1395
1392
  const used = pickNumber(quota, ["used", "quota_used"]);
1396
1393
  const remaining = pickNumber(quota, ["remaining"]) ?? pickNumber(root, ["remaining"]);
1397
- const parts = usageParts();
1394
+ const parts = [];
1398
1395
  if (limit !== void 0 && used !== void 0) {
1399
1396
  parts.push(`Q ${formatMoney(used)}/${formatMoney(limit)}`);
1400
1397
  } else if (remaining !== void 0) {
@@ -1411,11 +1408,11 @@ function formatQuotaLimitedLine(label, root) {
1411
1408
  }
1412
1409
  return parts.join(" | ");
1413
1410
  }
1414
- async function formatWalletLine(label, root) {
1411
+ async function formatWalletLine(root) {
1415
1412
  const balance = pickNumber(root, ["balance", "remaining", "remain", "available"]);
1416
1413
  const todayCost = pickNumber(root?.usage?.today, ["actual_cost", "cost"]);
1417
1414
  const recentUsage = Array.isArray(root?.daily_usage) ? root.daily_usage.reduce((sum, day) => sum + (pickNumber(day, ["actual_cost", "cost"]) || 0), 0) : void 0;
1418
- const parts = usageParts();
1415
+ const parts = [];
1419
1416
  if (balance !== void 0) parts.push(`balance ${formatMoney(balance)}`);
1420
1417
  if (todayCost !== void 0) parts.push(`today ${formatMoney(todayCost)}`);
1421
1418
  if (recentUsage !== void 0 && root.daily_usage.length > 0) {
@@ -1423,7 +1420,7 @@ async function formatWalletLine(label, root) {
1423
1420
  }
1424
1421
  return parts.join(" | ");
1425
1422
  }
1426
- function formatUsageLine(label, root) {
1423
+ function formatUsageLine(root) {
1427
1424
  const sub = root?.subscription || {};
1428
1425
  const dailyLimit = pickNumber(sub, ["daily_limit_usd"]);
1429
1426
  const dailyUsage = pickNumber(sub, ["daily_usage_usd"]);
@@ -1432,7 +1429,7 @@ function formatUsageLine(label, root) {
1432
1429
  const monthlyLimit = pickNumber(sub, ["monthly_limit_usd"]);
1433
1430
  const monthlyUsage = pickNumber(sub, ["monthly_usage_usd"]);
1434
1431
  const expires = shortDate(sub.expires_at);
1435
- const parts = usageParts();
1432
+ const parts = [];
1436
1433
  if (dailyLimit > 0 && dailyUsage !== void 0) parts.push(`D ${formatMoney(dailyUsage)}/${formatMoney(dailyLimit)}`);
1437
1434
  if (weeklyLimit > 0 && weeklyUsage !== void 0) parts.push(`W ${formatMoney(weeklyUsage)}/${formatMoney(weeklyLimit)}`);
1438
1435
  if (monthlyLimit > 0 && monthlyUsage !== void 0) parts.push(`M ${formatMoney(monthlyUsage)}/${formatMoney(monthlyLimit)}`);
@@ -1462,7 +1459,7 @@ async function fetchV1Usage(context) {
1462
1459
  name: "v1 usage"
1463
1460
  });
1464
1461
  if (!hasV1UsageFields(usageRoot(json))) throw new Error("v1 usage payload has no usage fields");
1465
- return usageResult(context, "v1-usage", await formatQuota(context.label, json), json);
1462
+ return usageResult(context, "v1-usage", await formatQuota(json), json);
1466
1463
  }
1467
1464
  async function fetchNewApiTokenUsage(context) {
1468
1465
  const json = await requestJson(joinUrl(serviceRoot(context.baseUrl), "/api/usage/token/"), {
@@ -1491,7 +1488,7 @@ async function fetchNewApiTokenUsage(context) {
1491
1488
  source: "newapi-token",
1492
1489
  raw: json
1493
1490
  };
1494
- return usageResult(context, "newapi-token", await formatNewApiTokenLine(context.label, json), normalized);
1491
+ return usageResult(context, "newapi-token", await formatNewApiTokenLine(json), normalized);
1495
1492
  }
1496
1493
  async function fetchOneApiBillingUsage(context) {
1497
1494
  const base = serviceRoot(context.baseUrl);
@@ -1537,7 +1534,7 @@ async function fetchOpenRouterUsage(context) {
1537
1534
  for (const endpoint of endpoints) {
1538
1535
  try {
1539
1536
  const json = await requestJson(endpoint.url, { key: context.key, name: endpoint.source });
1540
- return usageResult(context, endpoint.source, formatOpenRouterLine("OpenRouter", json), json);
1537
+ return usageResult(context, endpoint.source, formatOpenRouterLine(json), json);
1541
1538
  } catch (error) {
1542
1539
  lastError = error;
1543
1540
  await debugLog({ source: endpoint.source, error: error.message });
@@ -1,9 +1,6 @@
1
1
  // integrations/usage/opencode-plugin.mjs
2
2
  import { queryProviderUsage } from "./core.mjs";
3
3
  var DEFAULT_REFRESH_MS = 6e4;
4
- function toastMessage(text) {
5
- return text.replace(/^API \| /, "");
6
- }
7
4
  function firstString(value, keys) {
8
5
  for (const key of keys) {
9
6
  if (typeof value?.[key] === "string" && value[key].trim()) return value[key].trim();
@@ -38,7 +35,7 @@ var AgentToolsUsage = async ({ client }, options = {}) => {
38
35
  await client.tui.showToast({
39
36
  body: {
40
37
  title: "Provider usage",
41
- message: toastMessage(text),
38
+ message: text,
42
39
  variant: "info",
43
40
  duration: 8e3
44
41
  }
@@ -4,9 +4,6 @@ import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  var AGENT_TOOLS_HOME = process.env.AGENT_TOOLS_HOME || join(homedir(), ".agent-tools");
6
6
  var SNAPSHOT_PATH = join(AGENT_TOOLS_HOME, "cache", "usage-snapshot.json");
7
- function toastMessage(text) {
8
- return text.replace(/^API \| /, "");
9
- }
10
7
  function latestUsage() {
11
8
  try {
12
9
  const data = JSON.parse(readFileSync(SNAPSHOT_PATH, "utf8"));
@@ -28,7 +25,7 @@ var tui = async (api) => {
28
25
  const message = latestUsage();
29
26
  api.ui.toast({
30
27
  title: "Provider usage",
31
- message: message ? toastMessage(message) : "Provider usage is not available yet",
28
+ message: message || "Provider usage is not available yet",
32
29
  variant: message ? "info" : "warning",
33
30
  duration: 8e3
34
31
  });
File without changes
@@ -1,4 +1,6 @@
1
1
  // Turns gateway payloads into the compact one-line usage message.
2
+ // Lines carry no branding prefix; each display surface adds its own context
3
+ // (hook name, statusline position, toast title) if it needs any.
2
4
 
3
5
  import { newApiQuotaScale, providerUsageDays } from "./config.mjs";
4
6
 
@@ -20,10 +22,6 @@ function formatMaybeMoney(value, unit = "USD") {
20
22
  return `${value.toLocaleString("en-US", { maximumFractionDigits: 1 })} ${unit}`;
21
23
  }
22
24
 
23
- function usageParts() {
24
- return ["API"];
25
- }
26
-
27
25
  async function formatNewApiQuota(value) {
28
26
  const scale = await newApiQuotaScale();
29
27
  if (Number.isFinite(scale) && scale > 0) return formatMoney(value / scale);
@@ -92,7 +90,7 @@ export function hasV1UsageFields(root) {
92
90
  );
93
91
  }
94
92
 
95
- export async function formatQuota(label, data) {
93
+ export async function formatQuota(data) {
96
94
  const root = usageRoot(data);
97
95
  const unit = root?.unit || "USD";
98
96
  const remaining = pickNumber(root, ["remaining"]);
@@ -100,21 +98,21 @@ export async function formatQuota(label, data) {
100
98
  const used = pickNumber(root, ["total_usage", "used", "usage"]);
101
99
  const balance = pickNumber(root, ["balance", "remaining", "remain", "available"]);
102
100
 
103
- if (isQuotaLimitedUsage(root)) return formatQuotaLimitedLine(label, root);
104
- if (isSubscriptionUsage(root)) return formatUsageLine(label, root);
105
- if (isWalletUsage(root)) return await formatWalletLine(label, root);
106
- if (remaining !== undefined) return formatUsageLine(label, root);
107
- if (balance !== undefined) return `API | balance ${formatMaybeMoney(balance, unit)}`;
101
+ if (isQuotaLimitedUsage(root)) return formatQuotaLimitedLine(root);
102
+ if (isSubscriptionUsage(root)) return formatUsageLine(root);
103
+ if (isWalletUsage(root)) return await formatWalletLine(root);
104
+ if (remaining !== undefined) return formatUsageLine(root);
105
+ if (balance !== undefined) return `balance ${formatMaybeMoney(balance, unit)}`;
108
106
  if (hardLimit !== undefined && used !== undefined) {
109
- return `API | remaining ${formatMaybeMoney(Math.max(0, hardLimit - used), unit)}`;
107
+ return `remaining ${formatMaybeMoney(Math.max(0, hardLimit - used), unit)}`;
110
108
  }
111
- if (hardLimit !== undefined) return `API | total ${formatMaybeMoney(hardLimit, unit)}`;
109
+ if (hardLimit !== undefined) return `total ${formatMaybeMoney(hardLimit, unit)}`;
112
110
 
113
111
  const keys = Object.keys(root || {}).slice(0, 4).join(", ");
114
- return keys ? `API | received (${keys})` : `API | checked ${unit}`;
112
+ return keys ? `received (${keys})` : `checked ${unit}`;
115
113
  }
116
114
 
117
- export async function formatNewApiTokenLine(label, data) {
115
+ export async function formatNewApiTokenLine(data) {
118
116
  const root = usageRoot(data);
119
117
  const unlimited = root?.unlimited_quota === true || root?.unlimitedQuota === true;
120
118
  const quota = pickNumber(root, ["quota", "limit", "total_quota", "totalQuota"]);
@@ -128,7 +126,7 @@ export async function formatNewApiTokenLine(label, data) {
128
126
  throw new Error("NewAPI token usage payload has no quota fields");
129
127
  }
130
128
 
131
- const parts = usageParts();
129
+ const parts = [];
132
130
  if (unlimited) parts.push("unlimited");
133
131
  if (remaining !== undefined) parts.push(`balance ${await formatNewApiQuota(remaining)}`);
134
132
  if (used !== undefined && quota !== undefined) {
@@ -139,13 +137,13 @@ export async function formatNewApiTokenLine(label, data) {
139
137
  return parts.join(" | ");
140
138
  }
141
139
 
142
- export function formatOpenRouterLine(label, data) {
140
+ export function formatOpenRouterLine(data) {
143
141
  const root = usageRoot(data);
144
142
  const limit = pickNumber(root, ["limit", "limit_remaining", "total_credits"]);
145
143
  const remaining = pickNumber(root, ["limit_remaining", "remaining_credits"]);
146
144
  const used = pickNumber(root, ["usage", "total_usage", "spend"]);
147
145
  const reset = root?.limit_reset || root?.reset_at ? shortDate(root.limit_reset || root.reset_at) : "";
148
- const parts = usageParts();
146
+ const parts = [];
149
147
 
150
148
  if (remaining !== undefined) parts.push(`balance ${formatMoney(remaining)}`);
151
149
  if (used !== undefined && limit !== undefined && limit !== remaining) {
@@ -155,20 +153,20 @@ export function formatOpenRouterLine(label, data) {
155
153
  }
156
154
  if (reset) parts.push(`Reset ${reset}`);
157
155
 
158
- if (parts.length === 1) throw new Error("OpenRouter payload has no usage fields");
156
+ if (parts.length === 0) throw new Error("OpenRouter payload has no usage fields");
159
157
  return parts.join(" | ");
160
158
  }
161
159
 
162
160
  export function formatOneApiBillingLine(limit, used) {
163
- return `API | balance ${formatMoney(Math.max(0, limit - used))} | used ${formatMoney(used)}/${formatMoney(limit)}`;
161
+ return `balance ${formatMoney(Math.max(0, limit - used))} | used ${formatMoney(used)}/${formatMoney(limit)}`;
164
162
  }
165
163
 
166
- function formatQuotaLimitedLine(label, root) {
164
+ function formatQuotaLimitedLine(root) {
167
165
  const quota = root?.quota || {};
168
166
  const limit = pickNumber(quota, ["limit", "quota"]);
169
167
  const used = pickNumber(quota, ["used", "quota_used"]);
170
168
  const remaining = pickNumber(quota, ["remaining"]) ?? pickNumber(root, ["remaining"]);
171
- const parts = usageParts();
169
+ const parts = [];
172
170
 
173
171
  if (limit !== undefined && used !== undefined) {
174
172
  parts.push(`Q ${formatMoney(used)}/${formatMoney(limit)}`);
@@ -193,14 +191,14 @@ function formatQuotaLimitedLine(label, root) {
193
191
  return parts.join(" | ");
194
192
  }
195
193
 
196
- async function formatWalletLine(label, root) {
194
+ async function formatWalletLine(root) {
197
195
  const balance = pickNumber(root, ["balance", "remaining", "remain", "available"]);
198
196
  const todayCost = pickNumber(root?.usage?.today, ["actual_cost", "cost"]);
199
197
  const recentUsage = Array.isArray(root?.daily_usage)
200
198
  ? root.daily_usage.reduce((sum, day) => sum + (pickNumber(day, ["actual_cost", "cost"]) || 0), 0)
201
199
  : undefined;
202
200
 
203
- const parts = usageParts();
201
+ const parts = [];
204
202
  if (balance !== undefined) parts.push(`balance ${formatMoney(balance)}`);
205
203
  if (todayCost !== undefined) parts.push(`today ${formatMoney(todayCost)}`);
206
204
  if (recentUsage !== undefined && root.daily_usage.length > 0) {
@@ -209,7 +207,7 @@ async function formatWalletLine(label, root) {
209
207
  return parts.join(" | ");
210
208
  }
211
209
 
212
- function formatUsageLine(label, root) {
210
+ function formatUsageLine(root) {
213
211
  const sub = root?.subscription || {};
214
212
  const dailyLimit = pickNumber(sub, ["daily_limit_usd"]);
215
213
  const dailyUsage = pickNumber(sub, ["daily_usage_usd"]);
@@ -219,7 +217,7 @@ function formatUsageLine(label, root) {
219
217
  const monthlyUsage = pickNumber(sub, ["monthly_usage_usd"]);
220
218
  const expires = shortDate(sub.expires_at);
221
219
 
222
- const parts = usageParts();
220
+ const parts = [];
223
221
  if (dailyLimit > 0 && dailyUsage !== undefined) parts.push(`D ${formatMoney(dailyUsage)}/${formatMoney(dailyLimit)}`);
224
222
  if (weeklyLimit > 0 && weeklyUsage !== undefined) parts.push(`W ${formatMoney(weeklyUsage)}/${formatMoney(weeklyLimit)}`);
225
223
  if (monthlyLimit > 0 && monthlyUsage !== undefined) parts.push(`M ${formatMoney(monthlyUsage)}/${formatMoney(monthlyLimit)}`);
@@ -56,7 +56,7 @@ async function fetchV1Usage(context) {
56
56
  name: "v1 usage",
57
57
  });
58
58
  if (!hasV1UsageFields(usageRoot(json))) throw new Error("v1 usage payload has no usage fields");
59
- return usageResult(context, "v1-usage", await formatQuota(context.label, json), json);
59
+ return usageResult(context, "v1-usage", await formatQuota(json), json);
60
60
  }
61
61
 
62
62
  // New API exposes a read-only usage endpoint authenticated by the same relay
@@ -88,7 +88,7 @@ async function fetchNewApiTokenUsage(context) {
88
88
  source: "newapi-token",
89
89
  raw: json,
90
90
  };
91
- return usageResult(context, "newapi-token", await formatNewApiTokenLine(context.label, json), normalized);
91
+ return usageResult(context, "newapi-token", await formatNewApiTokenLine(json), normalized);
92
92
  }
93
93
 
94
94
  // One API's legacy OpenAI billing endpoints use the same relay API key as
@@ -143,7 +143,7 @@ async function fetchOpenRouterUsage(context) {
143
143
  for (const endpoint of endpoints) {
144
144
  try {
145
145
  const json = await requestJson(endpoint.url, { key: context.key, name: endpoint.source });
146
- return usageResult(context, endpoint.source, formatOpenRouterLine("OpenRouter", json), json);
146
+ return usageResult(context, endpoint.source, formatOpenRouterLine(json), json);
147
147
  } catch (error) {
148
148
  lastError = error;
149
149
  await debugLog({ source: endpoint.source, error: error.message });
@@ -2,10 +2,6 @@ import { queryProviderUsage } from "./core.mjs";
2
2
 
3
3
  const DEFAULT_REFRESH_MS = 60_000;
4
4
 
5
- function toastMessage(text) {
6
- return text.replace(/^API \| /, "");
7
- }
8
-
9
5
  function firstString(value, keys) {
10
6
  for (const key of keys) {
11
7
  if (typeof value?.[key] === "string" && value[key].trim()) return value[key].trim();
@@ -49,7 +45,7 @@ export const AgentToolsUsage = async ({ client }, options = {}) => {
49
45
  await client.tui.showToast({
50
46
  body: {
51
47
  title: "Provider usage",
52
- message: toastMessage(text),
48
+ message: text,
53
49
  variant: "info",
54
50
  duration: 8000,
55
51
  },
@@ -5,10 +5,6 @@ import { join } from "node:path";
5
5
  const AGENT_TOOLS_HOME = process.env.AGENT_TOOLS_HOME || join(homedir(), ".agent-tools");
6
6
  const SNAPSHOT_PATH = join(AGENT_TOOLS_HOME, "cache", "usage-snapshot.json");
7
7
 
8
- function toastMessage(text) {
9
- return text.replace(/^API \| /, "");
10
- }
11
-
12
8
  function latestUsage() {
13
9
  try {
14
10
  const data = JSON.parse(readFileSync(SNAPSHOT_PATH, "utf8"));
@@ -33,7 +29,7 @@ const tui = async (api) => {
33
29
  const message = latestUsage();
34
30
  api.ui.toast({
35
31
  title: "Provider usage",
36
- message: message ? toastMessage(message) : "Provider usage is not available yet",
32
+ message: message || "Provider usage is not available yet",
37
33
  variant: message ? "info" : "warning",
38
34
  duration: 8000,
39
35
  });
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "@kairyou/agent-tools",
3
- "version": "0.7.2",
3
+ "version": "0.8.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
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/kairyou/agent-tools.git"
9
+ },
6
10
  "engines": {
7
11
  "node": ">=22"
8
12
  },
@@ -28,7 +32,8 @@
28
32
  "build": "node scripts/build.mjs",
29
33
  "prepare": "npm run build",
30
34
  "test": "node --test tests/*.test.mjs",
31
- "release": "node scripts/release.mjs"
35
+ "release": "node scripts/release.mjs --target=registry",
36
+ "release:github": "node scripts/release.mjs --target=github"
32
37
  },
33
38
  "bin": {
34
39
  "agent-tools": "./scripts/install.mjs"
@@ -45,6 +45,7 @@
45
45
  // -h, --help Show this help.
46
46
 
47
47
  import { spawnSync } from "node:child_process";
48
+ import { createHash } from "node:crypto";
48
49
  import fs from "node:fs";
49
50
  import os from "node:os";
50
51
  import path from "node:path";
@@ -93,6 +94,7 @@ const USAGE_SKILL_NAME = "at-usage";
93
94
  const USAGE_SKILL_SRC = path.join(REPO_ROOT, "integrations", "usage", "skills", USAGE_SKILL_NAME);
94
95
  const VISION_BUNDLED_MCP_SERVER = path.join(REPO_ROOT, "dist", "vision", "mcp-server.mjs");
95
96
  const VISION_BUNDLED_CLI = path.join(REPO_ROOT, "dist", "vision", "cli.mjs");
97
+ const INSTALL_STATE_PATH = path.join(INSTALL_ROOT, "install-state.json");
96
98
 
97
99
  function fwd(p) {
98
100
  return p.replace(/\\/g, "/");
@@ -445,13 +447,12 @@ const VISION_RATE_LIMIT_STATE = path.join(INSTALL_ROOT, "cache", "vision-rate-li
445
447
  const VISION_DIST_DIR = path.join(REPO_ROOT, "dist", "vision");
446
448
  const VISION_RUNTIME_SERVER = path.join(VISION_RUNTIME_DIR, "mcp-server.mjs");
447
449
  const VISION_RUNTIME_CLI = path.join(VISION_RUNTIME_DIR, "cli.mjs");
448
- const SKILL_MARKER = ".agent-tools-managed.json";
449
- const VISION_SKILL_MARKER_DATA = Object.freeze({
450
+ const VISION_SKILL_IDENTITY = Object.freeze({
450
451
  owner: "@kairyou/agent-tools",
451
452
  capability: "vision",
452
453
  artifact: "skill",
453
454
  });
454
- const USAGE_SKILL_MARKER_DATA = Object.freeze({
455
+ const USAGE_SKILL_IDENTITY = Object.freeze({
455
456
  owner: "@kairyou/agent-tools",
456
457
  capability: "usage",
457
458
  artifact: "skill",
@@ -558,26 +559,110 @@ function installVisionRuntime(opts) {
558
559
  }
559
560
  }
560
561
 
561
- function isManagedSkillDir(dest, markerData) {
562
- const marker = path.join(dest, SKILL_MARKER);
563
- if (!fs.existsSync(marker)) return false;
562
+ function skillManifestKey(dest) {
563
+ const resolved = fwd(path.resolve(dest));
564
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
565
+ }
566
+
567
+ function skillHash(skillFile) {
568
+ if (!fs.existsSync(skillFile)) return "";
569
+ return createHash("sha256").update(fs.readFileSync(skillFile)).digest("hex");
570
+ }
571
+
572
+ function matchesIdentity(value, identity) {
573
+ return Object.entries(identity).every(([key, expected]) => value?.[key] === expected);
574
+ }
575
+
576
+ function readInstallState() {
577
+ const value = readJson(INSTALL_STATE_PATH);
578
+ if (Object.keys(value).length === 0) return { version: 1, artifacts: {} };
579
+ if (
580
+ value.version !== 1 ||
581
+ !value.artifacts ||
582
+ typeof value.artifacts !== "object" ||
583
+ Array.isArray(value.artifacts)
584
+ ) {
585
+ throw new Error(`Cannot parse ${INSTALL_STATE_PATH}: unsupported install state schema`);
586
+ }
587
+ return value;
588
+ }
589
+
590
+ function writeInstallState(state) {
591
+ if (Object.keys(state.artifacts).length === 0) {
592
+ fs.rmSync(INSTALL_STATE_PATH, { force: true });
593
+ return;
594
+ }
595
+ const text = JSON.stringify(state, null, 2) + "\n";
596
+ const suffix = `${process.pid}-${Date.now()}`;
597
+ const stage = `${INSTALL_STATE_PATH}.stage-${suffix}`;
598
+ const backup = `${INSTALL_STATE_PATH}.backup-${suffix}`;
599
+ let movedCurrent = false;
600
+ fs.mkdirSync(path.dirname(INSTALL_STATE_PATH), { recursive: true });
564
601
  try {
565
- const value = JSON.parse(fs.readFileSync(marker, "utf8"));
566
- return Object.entries(markerData).every(([key, expected]) => value?.[key] === expected);
567
- } catch {
568
- return false;
602
+ fs.writeFileSync(stage, text);
603
+ if (fs.existsSync(INSTALL_STATE_PATH)) {
604
+ fs.renameSync(INSTALL_STATE_PATH, backup);
605
+ movedCurrent = true;
606
+ }
607
+ fs.renameSync(stage, INSTALL_STATE_PATH);
608
+ fs.rmSync(backup, { force: true });
609
+ } catch (error) {
610
+ fs.rmSync(stage, { force: true });
611
+ if (movedCurrent && !fs.existsSync(INSTALL_STATE_PATH) && fs.existsSync(backup)) {
612
+ fs.renameSync(backup, INSTALL_STATE_PATH);
613
+ }
614
+ throw error;
615
+ } finally {
616
+ fs.rmSync(stage, { force: true });
617
+ if (fs.existsSync(INSTALL_STATE_PATH)) fs.rmSync(backup, { force: true });
569
618
  }
619
+ console.log(` wrote ${INSTALL_STATE_PATH}`);
620
+ }
621
+
622
+ function managedSkillStatus(dest, identity) {
623
+ const entry = readInstallState().artifacts[skillManifestKey(dest)];
624
+ if (entry) {
625
+ if (!matchesIdentity(entry, identity)) return "other";
626
+ const currentHash = skillHash(path.join(dest, "SKILL.md"));
627
+ return currentHash && currentHash === entry.sha256 ? "managed" : "modified";
628
+ }
629
+ return "unmanaged";
630
+ }
631
+
632
+ function recordManagedSkill(dest, identity) {
633
+ const state = readInstallState();
634
+ state.artifacts[skillManifestKey(dest)] = {
635
+ path: fwd(path.resolve(dest)),
636
+ ...identity,
637
+ sha256: skillHash(path.join(dest, "SKILL.md")),
638
+ };
639
+ writeInstallState(state);
640
+ }
641
+
642
+ function forgetManagedSkill(dest) {
643
+ const state = readInstallState();
644
+ if (!state.artifacts[skillManifestKey(dest)]) return;
645
+ delete state.artifacts[skillManifestKey(dest)];
646
+ writeInstallState(state);
570
647
  }
571
648
 
572
649
  function installManagedSkillDir(
573
650
  skillsRoot,
574
- { name, source, markerData, replacements, remove, dryRun }
651
+ { name, source, identity, replacements, remove, dryRun }
575
652
  ) {
576
653
  const dest = path.join(skillsRoot, name);
577
- const isManaged = () => isManagedSkillDir(dest, markerData);
654
+ const status = fs.existsSync(dest) ? managedSkillStatus(dest, identity) : "missing";
578
655
  if (remove) {
579
- if (!fs.existsSync(dest)) return;
580
- if (!isManaged()) {
656
+ if (!fs.existsSync(dest)) {
657
+ if (!dryRun) forgetManagedSkill(dest);
658
+ return;
659
+ }
660
+ if (status === "modified") {
661
+ if (!dryRun) forgetManagedSkill(dest);
662
+ console.log(` kept modified skill and released ownership ${dest}`);
663
+ return;
664
+ }
665
+ if (status !== "managed") {
581
666
  console.log(` kept unmanaged ${dest}`);
582
667
  return;
583
668
  }
@@ -586,10 +671,18 @@ function installManagedSkillDir(
586
671
  return;
587
672
  }
588
673
  fs.rmSync(dest, { recursive: true, force: true });
674
+ forgetManagedSkill(dest);
589
675
  console.log(` removed ${dest}`);
590
676
  return;
591
677
  }
592
- if (fs.existsSync(dest) && !isManaged()) {
678
+ if (status === "modified") {
679
+ console.error(
680
+ ` Refusing to overwrite modified managed skill directory ${dest}. ` +
681
+ `Move or restore it, then re-run the install.`
682
+ );
683
+ process.exit(1);
684
+ }
685
+ if (fs.existsSync(dest) && status !== "managed") {
593
686
  console.error(
594
687
  ` Refusing to overwrite existing unowned skill directory ${dest}. ` +
595
688
  `Move or remove it, then re-run the install.`
@@ -612,10 +705,7 @@ function installManagedSkillDir(
612
705
  skill = skill.replaceAll(token, value);
613
706
  }
614
707
  fs.writeFileSync(skillFile, skill);
615
- fs.writeFileSync(
616
- path.join(dest, SKILL_MARKER),
617
- JSON.stringify(markerData, null, 2) + "\n"
618
- );
708
+ recordManagedSkill(dest, identity);
619
709
  console.log(` wrote ${dest}`);
620
710
  }
621
711
 
@@ -623,7 +713,7 @@ function installVisionSkillDir(skillsRoot, { remove, dryRun }) {
623
713
  installManagedSkillDir(skillsRoot, {
624
714
  name: VISION_SKILL_NAME,
625
715
  source: VISION_SKILL_SRC,
626
- markerData: VISION_SKILL_MARKER_DATA,
716
+ identity: VISION_SKILL_IDENTITY,
627
717
  replacements: { "{{VISION_CLI_PATH}}": fwd(VISION_RUNTIME_CLI) },
628
718
  remove,
629
719
  dryRun,
@@ -634,7 +724,7 @@ function installUsageSkillDir(skillsRoot, agent, { remove, dryRun }) {
634
724
  installManagedSkillDir(skillsRoot, {
635
725
  name: USAGE_SKILL_NAME,
636
726
  source: USAGE_SKILL_SRC,
637
- markerData: USAGE_SKILL_MARKER_DATA,
727
+ identity: USAGE_SKILL_IDENTITY,
638
728
  replacements: {
639
729
  "{{USAGE_CLI_PATH}}": fwd(RUNTIME.usageCli),
640
730
  "{{USAGE_AGENT}}": agent,
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env node
2
+ // Internal registry publisher used by release.mjs.
3
+ // Publishes to npmjs or an npm-compatible private registry without changing the version.
4
+ // Credentials persist in ~/.agent-tools/publish.npmrc, leaving ~/.npmrc untouched.
5
+
6
+ import { spawnSync } from "node:child_process";
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ const ROOT = fileURLToPath(new URL("..", import.meta.url));
13
+ const packageJson = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
14
+ const PACKAGE_NAME = packageJson.name;
15
+ const PUBLISH_NPMRC = path.join(
16
+ process.env.AGENT_TOOLS_HOME || path.join(os.homedir(), ".agent-tools"),
17
+ "publish.npmrc"
18
+ );
19
+
20
+ let registryOverride;
21
+ let authType;
22
+ let dryRun = false;
23
+ let authOnly = false;
24
+ for (const arg of process.argv.slice(2)) {
25
+ if (arg === "--dry-run") dryRun = true;
26
+ else if (arg === "--auth-only") authOnly = true;
27
+ else if (arg.startsWith("--registry=")) registryOverride = arg.slice("--registry=".length);
28
+ else if (arg.startsWith("--auth-type=")) authType = arg.slice("--auth-type=".length);
29
+ else {
30
+ console.error(
31
+ `Unknown argument: ${arg} ` +
32
+ `(expected --registry=<url>, --auth-type=<type>, --auth-only, or --dry-run)`
33
+ );
34
+ process.exit(2);
35
+ }
36
+ }
37
+
38
+ if (typeof PACKAGE_NAME !== "string" || PACKAGE_NAME.trim() === "") {
39
+ console.error("package.json must define a non-empty name before publishing.");
40
+ process.exit(2);
41
+ }
42
+
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";
46
+ const commandArgs = npmExecPath ? [npmExecPath, ...args] : args;
47
+ const result = spawnSync(command, commandArgs, {
48
+ cwd: ROOT,
49
+ encoding: capture ? "utf8" : undefined,
50
+ stdio: capture ? "pipe" : "inherit",
51
+ });
52
+ if (result.error) throw result.error;
53
+ if (!allowFailure && result.status !== 0) process.exit(result.status ?? 1);
54
+ return result;
55
+ }
56
+
57
+ function readNpmConfig(key) {
58
+ const result = runNpm(["config", "get", key], { capture: true });
59
+ const value = result.stdout.trim();
60
+ return value && value !== "undefined" && value !== "null" ? value : undefined;
61
+ }
62
+
63
+ const packageScope = PACKAGE_NAME.match(/^(@[^/]+)\//)?.[1];
64
+ const registry =
65
+ registryOverride ||
66
+ packageJson.publishConfig?.registry ||
67
+ (packageScope && readNpmConfig(`${packageScope}:registry`)) ||
68
+ readNpmConfig("registry");
69
+
70
+ try {
71
+ const url = new URL(registry);
72
+ if (!["http:", "https:"].includes(url.protocol)) throw new Error("unsupported protocol");
73
+ } catch {
74
+ console.error("The resolved npm registry must be a valid HTTP(S) URL.");
75
+ process.exit(2);
76
+ }
77
+
78
+ console.log(`Package: ${PACKAGE_NAME}@${packageJson.version}`);
79
+ console.log(`Registry: ${registry}`);
80
+ if (new URL(registry).hostname === "registry.npmjs.org") {
81
+ console.log("Note: npmjs releases normally go through `npm run release:github` (OIDC).");
82
+ }
83
+
84
+ if (dryRun) {
85
+ runNpm(["pack", "--dry-run"]);
86
+ process.exit(0);
87
+ }
88
+
89
+ fs.mkdirSync(path.dirname(PUBLISH_NPMRC), { recursive: true, mode: 0o700 });
90
+ if (!fs.existsSync(PUBLISH_NPMRC)) fs.writeFileSync(PUBLISH_NPMRC, "", { mode: 0o600 });
91
+
92
+ const npmConfigArgs = [`--registry=${registry}`, `--userconfig=${PUBLISH_NPMRC}`];
93
+ const whoami = runNpm(["whoami", ...npmConfigArgs], { capture: true, allowFailure: true });
94
+ if (whoami.status !== 0) {
95
+ console.log(`Authentication required. Credentials will be saved to ${PUBLISH_NPMRC}`);
96
+ const loginArgs = ["login", ...npmConfigArgs];
97
+ if (authType) loginArgs.push(`--auth-type=${authType}`);
98
+ runNpm(loginArgs);
99
+ runNpm(["whoami", ...npmConfigArgs]);
100
+ }
101
+
102
+ if (authOnly) {
103
+ console.log(`Authenticated for ${registry}`);
104
+ process.exit(0);
105
+ }
106
+
107
+ runNpm(["publish", ...npmConfigArgs]);
108
+ console.log(`\nPublished ${PACKAGE_NAME}@${packageJson.version} to ${registry}`);
@@ -2,125 +2,153 @@
2
2
  // Release helper for this package.
3
3
  //
4
4
  // Usage:
5
- // npm run release [-- minor|major] # default: patch
6
- // npm run release -- --dry-run # npm test + preview tarball, no bump/publish
7
- //
8
- // Steps: clean-tree check -> npm auth check -> npm test -> npm version -> npm publish -> git push
9
- //
10
- // Release when scripts/ integrations/ config.default.jsonc or
11
- // package.json deps change — npx users only get these from the published package.
12
- // (skills/ ships from GitHub via `npx skills add`; no release needed.)
5
+ // npm run release [-- patch|minor|major]
6
+ // Test, bump, publish to the configured npm registry, then push the commit and v* tag.
7
+ // npm run release:github [-- patch|minor|major]
8
+ // Test, bump, and push the commit and v* tag; GitHub Actions publishes the package.
9
+ // npm run release -- --publish-only [--registry=<url>] [--auth-type=legacy]
10
+ // Publish the current version only; do not test, bump, commit, tag, or push.
11
+ // npm run release -- --dry-run
12
+ // Test and preview the package; do not authenticate, bump, publish, or push.
13
13
 
14
14
  import { spawnSync } from "node:child_process";
15
- import { readFileSync } from "node:fs";
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { fileURLToPath } from "node:url";
16
18
 
19
+ const ROOT = fileURLToPath(new URL("..", import.meta.url));
20
+ const PUBLISH_SCRIPT = path.join(ROOT, "scripts", "publish.mjs");
17
21
  const BUMPS = ["patch", "minor", "major"];
18
- const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
19
- const PACKAGE_NAME = packageJson.name;
20
-
21
- if (typeof PACKAGE_NAME !== "string" || PACKAGE_NAME.trim() === "") {
22
- console.error("package.json must define a non-empty name before releasing.");
23
- process.exit(2);
24
- }
25
-
26
- function readNpmConfig(key) {
27
- const npmExecPath = process.env.npm_execpath;
28
- const result = npmExecPath
29
- ? spawnSync(process.execPath, [npmExecPath, "config", "get", key], { encoding: "utf8" })
30
- : spawnSync("npm", ["config", "get", key], { encoding: "utf8", shell: true });
31
-
32
- if (result.status !== 0) {
33
- const detail = result.stderr?.trim() || result.error?.message || "npm config exited unexpectedly";
34
- console.error(`Unable to read npm config ${key}:\n${detail}`);
35
- process.exit(result.status ?? 2);
36
- }
37
-
38
- const value = result.stdout?.trim();
39
- return value && value !== "undefined" && value !== "null" ? value : undefined;
40
- }
41
-
42
- const packageScope = PACKAGE_NAME.match(/^(@[^/]+)\//)?.[1];
43
- const REGISTRY = (packageScope && readNpmConfig(`${packageScope}:registry`)) || readNpmConfig("registry");
44
-
45
- try {
46
- const registryUrl = new URL(REGISTRY);
47
- if (!["http:", "https:"].includes(registryUrl.protocol)) throw new Error("unsupported protocol");
48
- } catch {
49
- console.error("npm registry config must be a valid HTTP(S) URL before releasing.");
50
- process.exit(2);
51
- }
52
22
 
23
+ let target = "registry";
53
24
  let bump = "patch";
25
+ let bumpSpecified = false;
54
26
  let dryRun = false;
27
+ let publishOnly = false;
28
+ const publishArgs = [];
55
29
  for (const arg of process.argv.slice(2)) {
56
30
  if (arg === "--dry-run") dryRun = true;
57
- else if (BUMPS.includes(arg)) bump = arg;
58
- else {
59
- console.error(`Unknown argument: ${arg} (expected ${BUMPS.join("|")} or --dry-run)`);
31
+ else if (arg === "--publish-only") publishOnly = true;
32
+ else if (arg.startsWith("--target=")) target = arg.slice("--target=".length);
33
+ else if (arg.startsWith("--registry=") || arg.startsWith("--auth-type=")) {
34
+ publishArgs.push(arg);
35
+ } else if (BUMPS.includes(arg)) {
36
+ bump = arg;
37
+ bumpSpecified = true;
38
+ } else {
39
+ console.error(
40
+ `Unknown argument: ${arg} ` +
41
+ `(expected ${BUMPS.join("|")}, --publish-only, --dry-run, ` +
42
+ `--registry=<url>, or --auth-type=<type>)`
43
+ );
60
44
  process.exit(2);
61
45
  }
62
46
  }
63
47
 
64
- function run(command, opts = {}) {
65
- const { onFailure, ...spawnOptions } = opts;
66
- console.log(`\n> ${command}`);
67
- const result = spawnSync(command, { stdio: "inherit", shell: true, ...spawnOptions });
48
+ if (!["registry", "github"].includes(target)) {
49
+ console.error(`Unknown release target: ${target} (expected registry or github)`);
50
+ process.exit(2);
51
+ }
52
+ if (target === "github" && publishArgs.length > 0) {
53
+ console.error("--registry and --auth-type only apply to registry releases.");
54
+ process.exit(2);
55
+ }
56
+ if (target === "github" && publishOnly) {
57
+ console.error("--publish-only only applies to registry releases.");
58
+ process.exit(2);
59
+ }
60
+ if (publishOnly && (dryRun || bumpSpecified)) {
61
+ console.error("--publish-only cannot be combined with a version bump or --dry-run.");
62
+ process.exit(2);
63
+ }
64
+
65
+ function run(command, args, { label, onFailure } = {}) {
66
+ console.log(`\n> ${label || [command, ...args].join(" ")}`);
67
+ const result = spawnSync(command, args, { cwd: ROOT, stdio: "inherit" });
68
+ if (result.error) {
69
+ console.error(`\nAborted: ${result.error.message}`);
70
+ onFailure?.();
71
+ process.exit(1);
72
+ }
68
73
  if (result.status !== 0) {
69
- console.error(`\nAborted: \`${command}\` exited with ${result.status}.`);
74
+ console.error(`\nAborted: command exited with ${result.status}.`);
70
75
  onFailure?.();
71
76
  process.exit(result.status ?? 1);
72
77
  }
73
78
  }
74
79
 
75
- function printCommands(message, commands) {
76
- console.error(`\n${message}\n`);
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
+ }
87
+ }
88
+
89
+ function printRecovery(commands) {
90
+ console.error("\nResume without creating another version:");
77
91
  for (const command of commands) console.error(` ${command}`);
78
92
  }
79
93
 
80
- const status = spawnSync("git status --porcelain", { shell: true, encoding: "utf8" });
94
+ const status = spawnSync("git", ["status", "--porcelain"], { cwd: ROOT, encoding: "utf8" });
95
+ if (status.error || status.status !== 0) {
96
+ console.error(status.error?.message || status.stderr || "Unable to read git status.");
97
+ process.exit(status.status ?? 1);
98
+ }
81
99
  if (!dryRun && status.stdout.trim() !== "") {
82
- console.error("Working tree is not clean commit or stash first:\n" + status.stdout);
100
+ console.error("Working tree is not clean - commit or stash first:\n" + status.stdout);
83
101
  process.exit(1);
84
102
  }
85
103
 
86
- if (!dryRun) {
87
- // Fail before npm version creates a commit and tag when the saved token is stale.
88
- run(`npm whoami --registry=${REGISTRY}`, {
89
- onFailure() {
90
- printCommands("npm authentication is missing or expired. Log in, verify the account, then retry:", [
91
- `npm login --registry=${REGISTRY}`,
92
- `npm whoami --registry=${REGISTRY}`,
93
- `npm run release -- ${bump}`,
94
- ]);
95
- },
104
+ if (publishOnly) {
105
+ run(process.execPath, [PUBLISH_SCRIPT, ...publishArgs], {
106
+ label: `node scripts/publish.mjs ${publishArgs.join(" ")}`.trimEnd(),
96
107
  });
108
+ process.exit(0);
97
109
  }
98
110
 
99
- run("npm test");
111
+ if (!dryRun && target === "registry") {
112
+ run(process.execPath, [PUBLISH_SCRIPT, "--auth-only", ...publishArgs], {
113
+ label: `node scripts/publish.mjs --auth-only ${publishArgs.join(" ")}`.trimEnd(),
114
+ });
115
+ }
116
+ runNpm(["test"]);
100
117
 
101
118
  if (dryRun) {
102
- // Shows exactly which files would ship; verify nothing is missing/extra.
103
- run("npm pack --dry-run");
104
- console.log("\nDry run complete. No version bump, nothing published.");
119
+ runNpm(["pack", "--dry-run"]);
120
+ console.log("\nDry run complete. No authentication, version bump, publish, or push.");
105
121
  process.exit(0);
106
122
  }
107
123
 
108
- run(`npm version ${bump}`);
109
- run("npm publish", {
110
- onFailure() {
111
- printCommands(
112
- "npm publish failed after the version commit and tag were created. Do not run the release command again. Fix authentication/permissions, then resume:",
113
- [
114
- `npm login --registry=${REGISTRY}`,
115
- `npm whoami --registry=${REGISTRY}`,
116
- `npm owner ls ${PACKAGE_NAME}`,
117
- "npm publish",
118
- `npm view ${PACKAGE_NAME} version`,
119
- "git push --follow-tags",
120
- ],
121
- );
122
- },
123
- });
124
- run("git push --follow-tags");
124
+ const versionArgs = ["version", bump, "--tag-version-prefix=v"];
125
+ if (target === "registry") versionArgs.push("--message=%s [skip ci]");
126
+ runNpm(versionArgs);
127
+ const version = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version;
128
+ const tag = `v${version}`;
125
129
 
126
- console.log("\nRelease complete.");
130
+ if (target === "registry") {
131
+ run(process.execPath, [PUBLISH_SCRIPT, ...publishArgs], {
132
+ label: `node scripts/publish.mjs ${publishArgs.join(" ")}`.trimEnd(),
133
+ onFailure() {
134
+ const suffix = publishArgs.length > 0 ? ` ${publishArgs.join(" ")}` : "";
135
+ printRecovery([
136
+ `npm run release -- --publish-only${suffix}`,
137
+ "git push --follow-tags",
138
+ ]);
139
+ },
140
+ });
141
+ run("git", ["push", "--follow-tags"], {
142
+ onFailure() {
143
+ printRecovery(["git push --follow-tags"]);
144
+ },
145
+ });
146
+ console.log(`\nReleased ${tag} to the configured npm registry.`);
147
+ } else {
148
+ run("git", ["push", "--follow-tags"], {
149
+ onFailure() {
150
+ printRecovery(["git push --follow-tags"]);
151
+ },
152
+ });
153
+ console.log(`\nPushed ${tag}. GitHub Actions will publish it.`);
154
+ }