codex-slot 0.1.19 → 0.1.21

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/dist/status.js CHANGED
@@ -8,6 +8,14 @@ const config_1 = require("./config");
8
8
  const account_store_1 = require("./account-store");
9
9
  const state_1 = require("./state");
10
10
  const text_1 = require("./text");
11
+ const TABLE_ANSI = {
12
+ reset: "\x1b[0m",
13
+ dim: "\x1b[2m",
14
+ green: "\x1b[32m",
15
+ yellow: "\x1b[33m",
16
+ red: "\x1b[31m",
17
+ cyan: "\x1b[36m"
18
+ };
11
19
  function computeLeftPercent(usedPercent) {
12
20
  if (usedPercent === null || usedPercent === undefined || Number.isNaN(usedPercent)) {
13
21
  return null;
@@ -30,23 +38,214 @@ function formatReset(unixSeconds) {
30
38
  return (0, text_1.formatLocalDateTime)(unixSeconds);
31
39
  }
32
40
  /**
33
- * 按给定最大宽度截断单元格文本,优先保证表格整体不换行。
41
+ * 移除 ANSI 控制序列,获得真实可见文本。
42
+ *
43
+ * 业务含义:
44
+ * 1. 交互界面会对状态列做轻量着色。
45
+ * 2. 表格宽度计算必须忽略颜色控制符,否则列宽会被错误拉大。
46
+ *
47
+ * @param value 可能包含 ANSI 样式的文本。
48
+ * @returns 去除 ANSI 控制序列后的可见文本。
49
+ * @throws 无显式抛出。
50
+ */
51
+ function stripAnsi(value) {
52
+ return value.replace(/\x1b\[[0-9;]*m/g, "");
53
+ }
54
+ /**
55
+ * 按需给文本添加 ANSI 样式。
34
56
  *
35
57
  * @param value 原始文本。
36
- * @param maxWidth 最大宽度。
58
+ * @param color ANSI 颜色或样式控制符。
59
+ * @param styled 是否启用样式。
60
+ * @returns 启用样式时返回带 ANSI 控制符的文本,否则返回原文。
61
+ * @throws 无显式抛出。
62
+ */
63
+ function styleCell(value, color, styled) {
64
+ if (!styled) {
65
+ return value;
66
+ }
67
+ return `${color}${value}${TABLE_ANSI.reset}`;
68
+ }
69
+ /**
70
+ * 判断字符是否应按双列宽展示。
71
+ *
72
+ * 业务含义:
73
+ * 1. 终端中中文、全角符号与多数 CJK 字符通常占两个显示列。
74
+ * 2. 表格列宽若只按字符串长度计算,会导致包含中文括号的账号名错位或过早截断。
75
+ *
76
+ * @param codePoint Unicode code point;必须来自单个字符迭代结果。
77
+ * @returns `true` 表示该字符应按双列宽计算;其他字符返回 `false`。
78
+ * @throws 无显式抛出。
79
+ */
80
+ function isWideCodePoint(codePoint) {
81
+ return (codePoint >= 0x1100 &&
82
+ (codePoint <= 0x115f ||
83
+ codePoint === 0x2329 ||
84
+ codePoint === 0x232a ||
85
+ (codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) ||
86
+ (codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
87
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) ||
88
+ (codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
89
+ (codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
90
+ (codePoint >= 0xff00 && codePoint <= 0xff60) ||
91
+ (codePoint >= 0xffe0 && codePoint <= 0xffe6)));
92
+ }
93
+ /**
94
+ * 计算文本在常见等宽终端中的显示列宽。
95
+ *
96
+ * 业务含义:
97
+ * 1. 状态表需要根据终端列数分配可用空间。
98
+ * 2. 账号名可能包含中文日期括号,必须按显示宽度而不是 UTF-16 长度计算。
99
+ *
100
+ * @param value 待展示文本;允许为空字符串。
101
+ * @returns 文本占用的终端显示列数。
102
+ * @throws 无显式抛出。
103
+ */
104
+ function getDisplayWidth(value) {
105
+ const visibleValue = stripAnsi(value);
106
+ let width = 0;
107
+ for (const char of visibleValue) {
108
+ const codePoint = char.codePointAt(0) ?? 0;
109
+ width += isWideCodePoint(codePoint) ? 2 : 1;
110
+ }
111
+ return width;
112
+ }
113
+ /**
114
+ * 按终端显示列宽截断文本。
115
+ *
116
+ * 业务含义:
117
+ * 1. 优先保证表格整体不换行。
118
+ * 2. 截断时保留省略号,让用户能看出内容未完全展示。
119
+ *
120
+ * @param value 原始文本。
121
+ * @param maxWidth 最大显示列宽;小于等于 0 时返回空字符串。
37
122
  * @returns 截断后的文本;宽度过小时退化为最短可读形式。
123
+ * @throws 无显式抛出。
38
124
  */
39
125
  function truncateCell(value, maxWidth) {
40
126
  if (maxWidth <= 0) {
41
127
  return "";
42
128
  }
43
- if (value.length <= maxWidth) {
129
+ if (getDisplayWidth(value) <= maxWidth) {
44
130
  return value;
45
131
  }
46
132
  if (maxWidth <= 2) {
47
- return value.slice(0, maxWidth);
133
+ let output = "";
134
+ let width = 0;
135
+ for (const char of value) {
136
+ const charWidth = getDisplayWidth(char);
137
+ if (width + charWidth > maxWidth) {
138
+ break;
139
+ }
140
+ output += char;
141
+ width += charWidth;
142
+ }
143
+ return output;
144
+ }
145
+ const ellipsis = "…";
146
+ const targetWidth = maxWidth - getDisplayWidth(ellipsis);
147
+ let output = "";
148
+ let width = 0;
149
+ for (const char of value) {
150
+ const charWidth = getDisplayWidth(char);
151
+ if (width + charWidth > targetWidth) {
152
+ break;
153
+ }
154
+ output += char;
155
+ width += charWidth;
156
+ }
157
+ return `${output}${ellipsis}`;
158
+ }
159
+ /**
160
+ * 按终端显示列宽补齐单元格。
161
+ *
162
+ * @param value 已完成截断的单元格文本。
163
+ * @param width 目标显示列宽。
164
+ * @returns 右侧补空格后的单元格文本。
165
+ * @throws 无显式抛出。
166
+ */
167
+ function padCell(value, width) {
168
+ return `${value}${" ".repeat(Math.max(0, width - getDisplayWidth(value)))}`;
169
+ }
170
+ /**
171
+ * 根据账号状态给状态标签选择终端样式。
172
+ *
173
+ * 业务含义:
174
+ * 1. `available` 是调度可用状态,使用绿色强调。
175
+ * 2. 额度限制和短时熔断需要提醒但不一定是错误,使用黄色。
176
+ * 3. 工作空间损坏、账号缺失等不可用异常使用红色。
177
+ * 4. 禁用账号使用弱化样式,减少对可用账号的视觉干扰。
178
+ *
179
+ * @param status 已归一化的状态标签。
180
+ * @param item 单个账号状态。
181
+ * @param styled 是否启用 ANSI 样式。
182
+ * @returns 应用于表格状态列的文本。
183
+ * @throws 无显式抛出。
184
+ */
185
+ function styleStatusCell(status, item, styled) {
186
+ if (!styled) {
187
+ return status;
188
+ }
189
+ if (item.isAvailable) {
190
+ return styleCell(status, TABLE_ANSI.green, styled);
191
+ }
192
+ if (!item.enabled) {
193
+ return styleCell(status, TABLE_ANSI.dim, styled);
194
+ }
195
+ if (item.refreshErrorCode || !item.exists) {
196
+ return styleCell(status, TABLE_ANSI.red, styled);
48
197
  }
49
- return `${value.slice(0, maxWidth - 1)}…`;
198
+ if (item.isFiveHourLimited || item.isWeeklyLimited || item.localBlockUntil) {
199
+ return styleCell(status, TABLE_ANSI.yellow, styled);
200
+ }
201
+ return status;
202
+ }
203
+ /**
204
+ * 对当前自动选中账号的名称做轻量强调。
205
+ *
206
+ * @param name 账号展示名称。
207
+ * @param styled 是否启用 ANSI 样式。
208
+ * @returns 表格名称列展示文本。
209
+ * @throws 无显式抛出。
210
+ */
211
+ function styleNameCell(name, styled) {
212
+ if (!styled || !name.endsWith("*")) {
213
+ return name;
214
+ }
215
+ return styleCell(name, TABLE_ANSI.cyan, styled);
216
+ }
217
+ /**
218
+ * 计算紧凑状态表中账号名称列可使用的显示宽度。
219
+ *
220
+ * 业务含义:
221
+ * 1. 窄终端下保留最小可读名称。
222
+ * 2. 终端变宽时优先把新增空间分配给账号名称,避免固定 12 列导致名字仍被截断。
223
+ *
224
+ * @param statuses 待展示账号状态。
225
+ * @param hasSelector 是否展示选择/启用状态列。
226
+ * @param maxWidth 当前终端最大显示列宽;无穷大表示不限制。
227
+ * @param headerWidth 当前账号列标题宽度。
228
+ * @param planWidth plan 列宽。
229
+ * @param statusWidth 状态列宽。
230
+ * @returns 账号名称列的目标显示宽度。
231
+ * @throws 无显式抛出。
232
+ */
233
+ function resolveCompactSlotWidth(statuses, hasSelector, maxWidth, headerWidth, planWidth, statusWidth) {
234
+ const longestNameWidth = Math.max(headerWidth, ...statuses.map((item) => getDisplayWidth(item.name)));
235
+ if (!Number.isFinite(maxWidth)) {
236
+ return longestNameWidth;
237
+ }
238
+ const fixedColumnWidths = [
239
+ ...(hasSelector ? [4] : []),
240
+ planWidth,
241
+ 3,
242
+ 4,
243
+ statusWidth
244
+ ];
245
+ const separatorWidth = 2 * fixedColumnWidths.length;
246
+ const availableWidth = Math.floor(maxWidth) - fixedColumnWidths.reduce((sum, width) => sum + width, 0) - separatorWidth;
247
+ const minWidth = maxWidth < 56 ? 8 : 12;
248
+ return Math.max(Math.min(longestNameWidth, availableWidth), Math.min(minWidth, Math.max(4, availableWidth)));
50
249
  }
51
250
  /**
52
251
  * 生成固定标签宽度的详情行,超出终端宽度时自动截断值部分。
@@ -263,15 +462,17 @@ function renderStatusTable(statuses, options) {
263
462
  const selectorColumn = options?.selectorColumn;
264
463
  const compact = options?.compact ?? false;
265
464
  const maxWidth = options?.maxWidth ?? Number.POSITIVE_INFINITY;
465
+ const styled = options?.styled ?? false;
266
466
  const compactHeader = maxWidth < 68;
267
- const compactSlotWidth = maxWidth < 56 ? 8 : 12;
268
467
  const compactPlanWidth = maxWidth < 56 ? 4 : 6;
269
468
  const compactStatusWidth = maxWidth < 56 ? 12 : 18;
469
+ const compactSlotHeader = compactHeader ? "ID" : "SLOT";
470
+ const compactSlotWidth = resolveCompactSlotWidth(statuses, Boolean(selectorColumn), maxWidth, getDisplayWidth(compactSlotHeader), compactPlanWidth, compactStatusWidth);
270
471
  const rows = [
271
472
  compact
272
473
  ? [
273
474
  ...(selectorColumn ? [" "] : []),
274
- compactHeader ? "ID" : "SLOT",
475
+ compactSlotHeader,
275
476
  compactHeader ? "P" : "PLAN",
276
477
  "5H",
277
478
  compactHeader ? "WK" : "WEEK",
@@ -297,27 +498,27 @@ function renderStatusTable(statuses, options) {
297
498
  rows.push(compact
298
499
  ? [
299
500
  ...(selectorCell ? [selectorCell] : []),
300
- truncateCell(item.name, compactSlotWidth),
501
+ styleNameCell(truncateCell(item.name, compactSlotWidth), styled),
301
502
  truncateCell(item.plan, compactPlanWidth),
302
503
  formatPercent(item.fiveHourLeftPercent),
303
504
  formatPercent(item.weeklyLeftPercent),
304
- truncateCell(status, compactStatusWidth)
505
+ styleStatusCell(truncateCell(status, compactStatusWidth), item, styled)
305
506
  ]
306
507
  : [
307
508
  ...(selectorCell ? [selectorCell] : []),
308
- item.name,
509
+ styleNameCell(item.name, styled),
309
510
  item.email ?? "-",
310
511
  item.plan,
311
512
  formatPercent(item.fiveHourLeftPercent),
312
513
  formatReset(item.fiveHourResetsAt),
313
514
  formatPercent(item.weeklyLeftPercent),
314
515
  formatReset(item.weeklyResetsAt),
315
- status
516
+ styleStatusCell(status, item, styled)
316
517
  ]);
317
518
  }
318
- const widths = rows[0].map((_, columnIndex) => Math.max(...rows.map((row) => row[columnIndex].length)));
519
+ const widths = rows[0].map((_, columnIndex) => Math.max(...rows.map((row) => getDisplayWidth(row[columnIndex]))));
319
520
  return rows
320
- .map((row) => row.map((cell, index) => cell.padEnd(widths[index])).join(" "))
521
+ .map((row) => row.map((cell, index) => padCell(cell, widths[index])).join(" "))
321
522
  .join("\n");
322
523
  }
323
524
  /**
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildUpstreamHeaders = buildUpstreamHeaders;
4
+ exports.sendCodexResponsesRequest = sendCodexResponsesRequest;
5
+ const undici_1 = require("undici");
6
+ /**
7
+ * 构造发往上游的请求头,并移除仅属于本地代理链路的头信息。
8
+ *
9
+ * 业务含义:
10
+ * 1. 本地服务使用独立 api_key 鉴权,转发时必须替换为真实上游 access token。
11
+ * 2. body 会在本地先读取成 Buffer 以支持失败后切换账号重试,因此这里重算 content-length。
12
+ *
13
+ * @param requestHeaders 客户端发到本地服务的原始请求头。
14
+ * @param accessToken 当前候选账号可用的上游访问令牌。
15
+ * @param bodyLength 请求体字节长度。
16
+ * @param accountIdHeader 可选的 ChatGPT 账号标识头。
17
+ * @returns 可直接传给上游请求的请求头对象。
18
+ * @throws 无显式抛出。
19
+ */
20
+ function buildUpstreamHeaders(requestHeaders, accessToken, bodyLength, accountIdHeader) {
21
+ const headers = {};
22
+ for (const [headerName, headerValue] of Object.entries(requestHeaders)) {
23
+ const normalizedName = headerName.toLowerCase();
24
+ if (headerValue == null ||
25
+ normalizedName === "authorization" ||
26
+ normalizedName === "host" ||
27
+ normalizedName === "connection" ||
28
+ normalizedName === "content-length") {
29
+ continue;
30
+ }
31
+ headers[normalizedName] = Array.isArray(headerValue)
32
+ ? headerValue.join(", ")
33
+ : headerValue;
34
+ }
35
+ headers.authorization = `Bearer ${accessToken}`;
36
+ if (!headers.accept) {
37
+ headers.accept = "text/event-stream, application/json";
38
+ }
39
+ headers["content-length"] = String(bodyLength);
40
+ headers["user-agent"] = "codex-slot/0.1.1";
41
+ if (accountIdHeader) {
42
+ headers["chatgpt-account-id"] = accountIdHeader;
43
+ }
44
+ return headers;
45
+ }
46
+ /**
47
+ * 向 Codex responses 上游发送一次请求。
48
+ *
49
+ * 业务含义:
50
+ * 1. 该 Adapter 隔离 undici 与上游 URL 细节,代理重试服务不直接依赖 HTTP 客户端实现。
51
+ * 2. 调用方负责决定 access token、账号切换与失败策略。
52
+ *
53
+ * @param options 上游请求参数。
54
+ * @returns undici 上游响应对象。
55
+ * @throws 当网络层或 undici 请求失败时透传底层异常。
56
+ */
57
+ async function sendCodexResponsesRequest(options) {
58
+ return await (0, undici_1.request)(`${options.codexBaseUrl}/responses`, {
59
+ method: "POST",
60
+ headers: buildUpstreamHeaders(options.requestHeaders, options.accessToken, options.body.length, options.accountIdHeader),
61
+ body: options.body
62
+ });
63
+ }
@@ -0,0 +1,114 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveBlockWindow = resolveBlockWindow;
4
+ exports.extractErrorCode = extractErrorCode;
5
+ exports.isNetworkUnavailableError = isNetworkUnavailableError;
6
+ exports.buildNetworkUnavailablePayload = buildNetworkUnavailablePayload;
7
+ exports.isUsageLimitErrorText = isUsageLimitErrorText;
8
+ const text_1 = require("./text");
9
+ /**
10
+ * 根据错误文本与当前账号状态,决定本地禁用时长。
11
+ *
12
+ * 业务规则:
13
+ * 1. 周限制优先,直到周窗口重置时间。
14
+ * 2. 5 小时额度限制次之,直到 5 小时窗口重置时间。
15
+ * 3. 未能明确识别时,按 5 分钟临时熔断处理。
16
+ *
17
+ * @param picked 当前被选中的账号及状态。
18
+ * @param errorText 上游返回的错误文本。
19
+ * @returns 本地禁用窗口与原因。
20
+ * @throws 无显式抛出。
21
+ */
22
+ function resolveBlockWindow(picked, errorText) {
23
+ const lowerText = errorText.toLowerCase();
24
+ if (lowerText.includes("weekly") ||
25
+ lowerText.includes("7 day") ||
26
+ lowerText.includes("7-day") ||
27
+ picked.status.isWeeklyLimited) {
28
+ return {
29
+ until: picked.status.weeklyResetsAt ?? Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60,
30
+ reason: "weekly_limited"
31
+ };
32
+ }
33
+ if (lowerText.includes("5 hour") ||
34
+ lowerText.includes("5-hour") ||
35
+ lowerText.includes("5h") ||
36
+ lowerText.includes("usage limit") ||
37
+ picked.status.isFiveHourLimited) {
38
+ return {
39
+ until: picked.status.fiveHourResetsAt ?? Math.floor(Date.now() / 1000) + 5 * 60,
40
+ reason: "5h_limited"
41
+ };
42
+ }
43
+ return {
44
+ until: Math.floor(Date.now() / 1000) + 5 * 60,
45
+ reason: "temporary_5m_limit"
46
+ };
47
+ }
48
+ /**
49
+ * 提取错误对象中最接近底层网络层的错误码。
50
+ *
51
+ * @param error 捕获到的异常对象。
52
+ * @returns 错误码;若无法识别则返回 `null`。
53
+ * @throws 无显式抛出。
54
+ */
55
+ function extractErrorCode(error) {
56
+ if (!error || typeof error !== "object") {
57
+ return null;
58
+ }
59
+ const errnoError = error;
60
+ if (typeof errnoError.code === "string" && errnoError.code.length > 0) {
61
+ return errnoError.code;
62
+ }
63
+ return extractErrorCode(errnoError.cause);
64
+ }
65
+ /**
66
+ * 判断一次请求失败是否属于本机到上游之间的网络不可达场景。
67
+ *
68
+ * @param error 捕获到的异常对象。
69
+ * @returns `true` 表示网络层异常,不应写入账号熔断;否则返回 `false`。
70
+ * @throws 无显式抛出。
71
+ */
72
+ function isNetworkUnavailableError(error) {
73
+ const errorCode = extractErrorCode(error);
74
+ return [
75
+ "ECONNREFUSED",
76
+ "ECONNRESET",
77
+ "EHOSTUNREACH",
78
+ "ENETDOWN",
79
+ "ENETUNREACH",
80
+ "ENOTFOUND",
81
+ "EAI_AGAIN",
82
+ "ETIMEDOUT",
83
+ "UND_ERR_CONNECT_TIMEOUT",
84
+ "UND_ERR_SOCKET"
85
+ ].includes(errorCode ?? "");
86
+ }
87
+ /**
88
+ * 将网络层异常转换为统一的响应体,避免误导成“当前没有可用账号”。
89
+ *
90
+ * @param accountId 当前尝试的账号标识。
91
+ * @param error 捕获到的异常对象。
92
+ * @returns 统一的网络异常响应体。
93
+ * @throws 无显式抛出。
94
+ */
95
+ function buildNetworkUnavailablePayload(accountId, error) {
96
+ const message = error instanceof Error ? error.message : String(error);
97
+ return {
98
+ error: {
99
+ message: (0, text_1.bi)(`网络不可用,账号 ${accountId} 无法连接上游: ${message}`, `Network unavailable. Account ${accountId} cannot reach upstream: ${message}`),
100
+ type: "network_unavailable"
101
+ }
102
+ };
103
+ }
104
+ /**
105
+ * 判断错误响应文本是否表示上游额度限制。
106
+ *
107
+ * @param errorText 上游返回的错误文本。
108
+ * @returns `true` 表示可按额度限制处理并切换账号。
109
+ * @throws 无显式抛出。
110
+ */
111
+ function isUsageLimitErrorText(errorText) {
112
+ const lowerText = errorText.toLowerCase();
113
+ return lowerText.includes("usage limit") || lowerText.includes("try again later");
114
+ }
@@ -12,6 +12,12 @@ const state_1 = require("./state");
12
12
  const text_1 = require("./text");
13
13
  const USAGE_CACHE_TTL_MS = 60 * 1000;
14
14
  const inflightUsageRefreshes = new Map();
15
+ const SHORT_LIVED_ACCOUNT_BLOCK_REASONS = new Set([
16
+ "request_failed",
17
+ "upstream_5xx",
18
+ "temporary_5m_limit",
19
+ "token_refresh_failed"
20
+ ]);
15
21
  function normalizeResetAt(value, resetAfterSeconds) {
16
22
  if (typeof value === "number" && Number.isFinite(value)) {
17
23
  return value;
@@ -21,6 +27,21 @@ function normalizeResetAt(value, resetAfterSeconds) {
21
27
  }
22
28
  return null;
23
29
  }
30
+ /**
31
+ * 当账号已经成功完成鉴权或额度刷新时,清理与瞬时异常相关的本地熔断。
32
+ *
33
+ * 只会移除短期失败类熔断,不会误清理 5 小时或周额度限制。
34
+ *
35
+ * @param accountId 账号标识。
36
+ * @returns 无返回值。
37
+ */
38
+ function clearShortLivedAccountBlock(accountId) {
39
+ const block = (0, state_1.getAccountBlock)(accountId);
40
+ if (!block || !SHORT_LIVED_ACCOUNT_BLOCK_REASONS.has(block.reason)) {
41
+ return;
42
+ }
43
+ (0, state_1.clearAccountBlock)(accountId);
44
+ }
24
45
  /**
25
46
  * 将额度刷新异常归类为可直接展示在 `status` 表格中的状态码。
26
47
  *
@@ -96,6 +117,7 @@ async function refreshAccountTokens(accountId) {
96
117
  last_refresh: new Date().toISOString()
97
118
  };
98
119
  (0, account_store_1.writeAuthFile)(account.codex_home, nextAuth);
120
+ clearShortLivedAccountBlock(accountId);
99
121
  return nextAuth;
100
122
  }
101
123
  /**
@@ -149,6 +171,7 @@ async function refreshAccountUsage(accountId) {
149
171
  };
150
172
  (0, state_1.setUsageCache)(result);
151
173
  (0, state_1.clearUsageRefreshError)(accountId);
174
+ clearShortLivedAccountBlock(accountId);
152
175
  return result;
153
176
  }
154
177
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-slot",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "本地 Codex 多账号切换与状态管理工具",
5
5
  "type": "commonjs",
6
6
  "main": "dist/cli.js",
@@ -12,8 +12,8 @@
12
12
  "dist"
13
13
  ],
14
14
  "scripts": {
15
- "clean": "rm -rf dist",
16
- "build": "npm run clean && tsc -p tsconfig.json && chmod +x dist/cli.js dist/serve.js",
15
+ "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
16
+ "build": "npm run clean && tsc -p tsconfig.json && node -e \"const fs=require('node:fs'); for (const file of ['dist/cli.js','dist/serve.js']) { try { fs.chmodSync(file, 0o755); } catch {} }\"",
17
17
  "prepublishOnly": "npm run build",
18
18
  "dev": "tsx src/cli.ts",
19
19
  "check": "tsc --noEmit -p tsconfig.json",