@recur-tw/cli 0.1.0 → 0.1.2
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 +82 -22
- package/dist/cli.mjs +80 -13
- package/dist/index.mjs +76 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ npx @recur-tw/cli products list
|
|
|
21
21
|
前往 [Recur Dashboard](https://app.recur.tw) 取得 Secret Key(`sk_test_*` 或 `sk_live_*`),然後執行:
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
|
-
recur
|
|
24
|
+
recur login
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
API Key 會安全存儲在 `~/.recur/credentials.json`(檔案權限 `0600`)。
|
|
@@ -58,7 +58,7 @@ recur <資源> <動作> [選項]
|
|
|
58
58
|
| `--key <sk_*>` | 指定 API Secret Key |
|
|
59
59
|
| `--profile <name>` | 使用指定的 profile |
|
|
60
60
|
| `--base-url <url>` | API 位址覆寫 |
|
|
61
|
-
| `--output <format>` | 輸出格式:`json`、`table`(預設)、`csv` |
|
|
61
|
+
| `--output <format>` | 輸出格式:`json`、`table`(預設)、`csv`、`ndjson` |
|
|
62
62
|
| `--fields <fields>` | 逗號分隔的欄位篩選 |
|
|
63
63
|
| `--dry-run` | 僅本地驗證,不發送 API 請求 |
|
|
64
64
|
| `--json <payload>` | 直接傳入 JSON 作為 API request body |
|
|
@@ -66,9 +66,8 @@ recur <資源> <動作> [選項]
|
|
|
66
66
|
### 驗證管理
|
|
67
67
|
|
|
68
68
|
```bash
|
|
69
|
-
recur login # 互動式設定 API Key
|
|
70
|
-
recur
|
|
71
|
-
recur whoami # 顯示目前 profile
|
|
69
|
+
recur login # 互動式設定 API Key(輸入時遮蔽顯示)
|
|
70
|
+
recur whoami # 顯示目前 profile 與 masked key
|
|
72
71
|
recur profiles # 列出所有 profile
|
|
73
72
|
recur use <profile> # 切換 profile
|
|
74
73
|
recur logout [profile] # 移除 profile
|
|
@@ -95,7 +94,7 @@ recur --profile sandbox products list
|
|
|
95
94
|
|
|
96
95
|
```bash
|
|
97
96
|
recur products list # 列出所有產品
|
|
98
|
-
recur products list --status
|
|
97
|
+
recur products list --status archived # 列出已封存產品
|
|
99
98
|
recur products get <id> # 以 ID 查詢
|
|
100
99
|
recur products get <slug> # 以 slug 查詢
|
|
101
100
|
recur products create --name "方案" --price 299 --interval monthly --type SUBSCRIPTION
|
|
@@ -109,7 +108,8 @@ recur products archive <id>
|
|
|
109
108
|
```bash
|
|
110
109
|
recur customers list # 列出所有客戶
|
|
111
110
|
recur customers list --email user@example.com # 以 email 篩選
|
|
112
|
-
recur customers list --
|
|
111
|
+
recur customers list --starting-after <id> --limit 50 # 游標分頁
|
|
112
|
+
recur customers list --page-all # 自動分頁(輸出 NDJSON)
|
|
113
113
|
recur customers get <id> # 查詢客戶詳情
|
|
114
114
|
recur customers update <id> --name "新名稱"
|
|
115
115
|
```
|
|
@@ -119,8 +119,8 @@ recur customers update <id> --name "新名稱"
|
|
|
119
119
|
```bash
|
|
120
120
|
recur subscriptions list # 列出所有訂閱
|
|
121
121
|
recur subscriptions list --status active # 篩選狀態
|
|
122
|
-
recur subscriptions list --customer-id
|
|
123
|
-
recur subscriptions list --
|
|
122
|
+
recur subscriptions list --customer-id <id> # 以客戶篩選
|
|
123
|
+
recur subscriptions list --page-all # 自動分頁
|
|
124
124
|
recur subscriptions get <id> # 查詢訂閱詳情
|
|
125
125
|
recur subscriptions cancel <id> # 期末取消
|
|
126
126
|
recur subscriptions cancel <id> --immediately # 立即取消
|
|
@@ -131,7 +131,7 @@ recur subscriptions cancel <id> --immediately # 立即取消
|
|
|
131
131
|
```bash
|
|
132
132
|
recur orders list # 列出所有訂單
|
|
133
133
|
recur orders list --status paid # 篩選狀態
|
|
134
|
-
recur orders list --customer-id
|
|
134
|
+
recur orders list --customer-id <id> # 以客戶篩選
|
|
135
135
|
recur orders get <id> # 查詢訂單詳情
|
|
136
136
|
```
|
|
137
137
|
|
|
@@ -139,8 +139,8 @@ recur orders get <id> # 查詢訂單詳情
|
|
|
139
139
|
|
|
140
140
|
```bash
|
|
141
141
|
recur invoices list # 列出所有帳單
|
|
142
|
-
recur invoices list --subscription-id
|
|
143
|
-
recur invoices list --customer-id
|
|
142
|
+
recur invoices list --subscription-id <id> # 以訂閱篩選
|
|
143
|
+
recur invoices list --customer-id <id> # 以客戶篩選
|
|
144
144
|
recur invoices list --status paid # 篩選狀態
|
|
145
145
|
recur invoices get <id> # 查詢帳單詳情
|
|
146
146
|
```
|
|
@@ -159,7 +159,7 @@ recur webhooks listen http://localhost:3000/api/webhooks # 即時轉發到本
|
|
|
159
159
|
|
|
160
160
|
### 本地 Webhook 開發(`webhooks listen`)
|
|
161
161
|
|
|
162
|
-
|
|
162
|
+
`recur webhooks listen` 讓你在本地開發時即時接收 webhook 事件,不需要公開 URL。
|
|
163
163
|
|
|
164
164
|
```bash
|
|
165
165
|
# 基本用法:轉發所有事件到本地伺服器
|
|
@@ -211,8 +211,8 @@ recur webhooks listen http://localhost:3000/api/webhooks --events checkout.compl
|
|
|
211
211
|
### Checkout Sessions
|
|
212
212
|
|
|
213
213
|
```bash
|
|
214
|
-
recur checkouts create --product-id
|
|
215
|
-
recur checkouts create --json '{"productId":"
|
|
214
|
+
recur checkouts create --product-id <id> --customer-email user@example.com
|
|
215
|
+
recur checkouts create --json '{"productId":"<id>","successUrl":"https://..."}'
|
|
216
216
|
recur checkouts get <id> # 查詢 session 狀態
|
|
217
217
|
```
|
|
218
218
|
|
|
@@ -237,7 +237,7 @@ name price type
|
|
|
237
237
|
$ recur products list --output json
|
|
238
238
|
|
|
239
239
|
[
|
|
240
|
-
{ "id": "
|
|
240
|
+
{ "id": "k672i1kd6zgrw5b6w39xwpx3", "name": "大師方案", "price": 799 },
|
|
241
241
|
...
|
|
242
242
|
]
|
|
243
243
|
```
|
|
@@ -249,6 +249,13 @@ recur products list --output json | jq '.[].name'
|
|
|
249
249
|
recur subscriptions list --output json | jq '[.[] | select(.status == "ACTIVE")]'
|
|
250
250
|
```
|
|
251
251
|
|
|
252
|
+
### NDJSON(適合串流處理)
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
# --page-all 自動輸出 NDJSON(一行一筆)
|
|
256
|
+
recur customers list --page-all --fields id,email
|
|
257
|
+
```
|
|
258
|
+
|
|
252
259
|
### CSV(適合匯出與試算表)
|
|
253
260
|
|
|
254
261
|
```bash
|
|
@@ -264,7 +271,58 @@ recur products list --fields id,name,price
|
|
|
264
271
|
recur customers list --fields email,name --output json
|
|
265
272
|
```
|
|
266
273
|
|
|
267
|
-
## AI Agent
|
|
274
|
+
## MCP Server(AI Agent 整合)
|
|
275
|
+
|
|
276
|
+
`recur mcp` 啟動一個本地 MCP(Model Context Protocol)server,讓 AI 助理直接操作 Recur API。
|
|
277
|
+
|
|
278
|
+
### Claude Desktop
|
|
279
|
+
|
|
280
|
+
在 `claude_desktop_config.json` 中加入:
|
|
281
|
+
|
|
282
|
+
```json
|
|
283
|
+
{
|
|
284
|
+
"mcpServers": {
|
|
285
|
+
"recur": {
|
|
286
|
+
"command": "npx",
|
|
287
|
+
"args": ["@recur-tw/cli", "mcp"],
|
|
288
|
+
"env": { "RECUR_SECRET_KEY": "sk_test_xxx" }
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### Claude Code
|
|
295
|
+
|
|
296
|
+
在專案根目錄的 `.mcp.json` 中加入:
|
|
297
|
+
|
|
298
|
+
```json
|
|
299
|
+
{
|
|
300
|
+
"mcpServers": {
|
|
301
|
+
"recur": {
|
|
302
|
+
"command": "npx",
|
|
303
|
+
"args": ["@recur-tw/cli", "mcp"],
|
|
304
|
+
"env": { "RECUR_SECRET_KEY": "sk_test_xxx" }
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
### 提供的 Tools(22 個)
|
|
311
|
+
|
|
312
|
+
| 類別 | Tools | 類型 |
|
|
313
|
+
|------|-------|------|
|
|
314
|
+
| Schema | `get_schema` | 唯讀 |
|
|
315
|
+
| Products | `list_products`, `get_product`, `create_product`, `update_product`, `archive_product` | 讀寫 |
|
|
316
|
+
| Customers | `list_customers`, `get_customer`, `update_customer` | 讀寫 |
|
|
317
|
+
| Subscriptions | `list_subscriptions`, `get_subscription`, `cancel_subscription` | 讀寫 |
|
|
318
|
+
| Orders | `list_orders`, `get_order` | 唯讀 |
|
|
319
|
+
| Invoices | `list_invoices`, `get_invoice` | 唯讀 |
|
|
320
|
+
| Webhooks | `list_webhooks`, `create_webhook`, `test_webhook`, `delete_webhook` | 讀寫 |
|
|
321
|
+
| Checkouts | `create_checkout`, `get_checkout` | 寫入 |
|
|
322
|
+
|
|
323
|
+
所有 tools 帶有 MCP Tool Annotations(`readOnlyHint`、`destructiveHint`),讓 agent 能判斷操作風險。
|
|
324
|
+
|
|
325
|
+
## Agent-Optimized 設計
|
|
268
326
|
|
|
269
327
|
此 CLI 遵循 [Agent-Optimized CLI](https://justin.poehnelt.com/posts/rewrite-your-cli-for-ai-agents/) 設計原則:
|
|
270
328
|
|
|
@@ -305,12 +363,12 @@ recur products create --dry-run --json '{"name":"Test","price":299}'
|
|
|
305
363
|
|
|
306
364
|
### Input 驗證(防止 AI 幻覺)
|
|
307
365
|
|
|
308
|
-
CLI 內建多層輸入驗證,防止 Agent
|
|
366
|
+
CLI 內建多層輸入驗證,防止 Agent 送出錯誤或惡意輸入:
|
|
309
367
|
|
|
310
|
-
- Resource ID 前綴驗證(`prod_*`、`cus_*`、`sub_*` 等)
|
|
311
368
|
- 路徑穿越偵測(`../`、`/`、`\`)
|
|
312
|
-
-
|
|
313
|
-
-
|
|
369
|
+
- Query injection 阻擋(`?`、`#`、`%`)
|
|
370
|
+
- 控制字元與不可見 Unicode 過濾
|
|
371
|
+
- API 回應 sanitization(ANSI escape、零寬字元)
|
|
314
372
|
|
|
315
373
|
## 驗證方式
|
|
316
374
|
|
|
@@ -331,10 +389,12 @@ CLI 僅接受 Secret Key。Publishable Key(`pk_*`)僅供前端 SDK 使用。
|
|
|
331
389
|
|
|
332
390
|
### 安全性
|
|
333
391
|
|
|
392
|
+
- Login 輸入 key 時以 `*` 遮蔽顯示
|
|
334
393
|
- API Key 以 `0600` 權限存儲在 `~/.recur/credentials.json`
|
|
335
394
|
- CLI 不直接連接資料庫,所有操作經由 API server 驗證
|
|
336
395
|
- 每個 API Key 綁定一個 Organization,資料完全隔離
|
|
337
396
|
- Key 以 SHA-256 hash 存儲在 server 端
|
|
397
|
+
- API 回應自動 sanitize 以防止 prompt injection
|
|
338
398
|
|
|
339
399
|
## 程式化使用
|
|
340
400
|
|
|
@@ -353,7 +413,7 @@ const products = await client.get('/v1/products')
|
|
|
353
413
|
|
|
354
414
|
## 系統需求
|
|
355
415
|
|
|
356
|
-
- Node.js >=
|
|
416
|
+
- Node.js >= 22
|
|
357
417
|
- 作業系統:macOS、Linux、Windows
|
|
358
418
|
|
|
359
419
|
## 授權
|
package/dist/cli.mjs
CHANGED
|
@@ -920,7 +920,7 @@ var RecurClient = class {
|
|
|
920
920
|
}
|
|
921
921
|
const headers = {
|
|
922
922
|
Authorization: `Bearer ${this.secretKey}`,
|
|
923
|
-
"User-Agent": `@recur-tw/cli/0.1.
|
|
923
|
+
"User-Agent": `@recur-tw/cli/0.1.2`
|
|
924
924
|
};
|
|
925
925
|
const hasBody = opts?.body !== void 0;
|
|
926
926
|
if (hasBody) headers["Content-Type"] = "application/json";
|
|
@@ -988,6 +988,16 @@ function sanitizeResponse(data) {
|
|
|
988
988
|
}
|
|
989
989
|
//#endregion
|
|
990
990
|
//#region src/output.ts
|
|
991
|
+
/** Fields to hide in table mode unless explicitly requested via --fields */
|
|
992
|
+
const TABLE_HIDDEN_FIELDS = new Set([
|
|
993
|
+
"object",
|
|
994
|
+
"metadata",
|
|
995
|
+
"display_order",
|
|
996
|
+
"product_family",
|
|
997
|
+
"livemode",
|
|
998
|
+
"created_at",
|
|
999
|
+
"updated_at"
|
|
1000
|
+
]);
|
|
991
1001
|
/**
|
|
992
1002
|
* Render data in the specified format.
|
|
993
1003
|
* Agent-optimized: --output json produces clean, parseable JSON to stdout.
|
|
@@ -1037,32 +1047,88 @@ function renderCsv(data, opts) {
|
|
|
1037
1047
|
console.log(values.join(","));
|
|
1038
1048
|
}
|
|
1039
1049
|
}
|
|
1050
|
+
/**
|
|
1051
|
+
* Calculate display width of a string in terminal columns.
|
|
1052
|
+
* CJK characters (Chinese/Japanese/Korean) take 2 columns.
|
|
1053
|
+
* ANSI escape sequences take 0 columns.
|
|
1054
|
+
*/
|
|
1055
|
+
function displayWidth(str) {
|
|
1056
|
+
const stripped = str.replace(/\x1B\[[0-9;]*[A-Za-z]/g, "");
|
|
1057
|
+
let width = 0;
|
|
1058
|
+
for (const char of stripped) if (isFullWidth(char.codePointAt(0))) width += 2;
|
|
1059
|
+
else width += 1;
|
|
1060
|
+
return width;
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* Check if a Unicode code point is full-width (2 terminal columns).
|
|
1064
|
+
* Covers CJK Unified Ideographs, CJK Compatibility, Hangul, Fullwidth Forms, etc.
|
|
1065
|
+
*/
|
|
1066
|
+
function isFullWidth(code) {
|
|
1067
|
+
return code >= 4352 && code <= 4447 || code >= 11904 && code <= 12350 || code >= 12352 && code <= 13247 || code >= 13312 && code <= 19903 || code >= 19968 && code <= 42191 || code >= 43360 && code <= 43388 || code >= 44032 && code <= 55203 || code >= 63744 && code <= 64255 || code >= 65072 && code <= 65131 || code >= 65281 && code <= 65376 || code >= 65504 && code <= 65510 || code >= 131072 && code <= 196605 || code >= 196608 && code <= 262141;
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* Pad a string to target display width (CJK-aware).
|
|
1071
|
+
*/
|
|
1072
|
+
function padEndDisplay(str, targetWidth) {
|
|
1073
|
+
const currentWidth = displayWidth(str);
|
|
1074
|
+
if (currentWidth >= targetWidth) return str;
|
|
1075
|
+
return str + " ".repeat(targetWidth - currentWidth);
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* Slice a string to fit within maxWidth display columns (CJK-aware).
|
|
1079
|
+
* Adds '…' if truncated.
|
|
1080
|
+
*/
|
|
1081
|
+
function sliceDisplay(str, maxWidth) {
|
|
1082
|
+
if (maxWidth <= 1) return "…";
|
|
1083
|
+
const stripped = str.replace(/\x1B\[[0-9;]*[A-Za-z]/g, "");
|
|
1084
|
+
let width = 0;
|
|
1085
|
+
let result = "";
|
|
1086
|
+
for (const char of stripped) {
|
|
1087
|
+
const charWidth = isFullWidth(char.codePointAt(0)) ? 2 : 1;
|
|
1088
|
+
if (width + charWidth > maxWidth - 1) return result + "…";
|
|
1089
|
+
result += char;
|
|
1090
|
+
width += charWidth;
|
|
1091
|
+
}
|
|
1092
|
+
return str;
|
|
1093
|
+
}
|
|
1040
1094
|
function renderTable(data, opts) {
|
|
1041
1095
|
const rows = Array.isArray(data) ? data : [data];
|
|
1042
1096
|
if (rows.length === 0) {
|
|
1043
1097
|
console.log(pc.dim("No results"));
|
|
1044
1098
|
return;
|
|
1045
1099
|
}
|
|
1046
|
-
const
|
|
1100
|
+
const allFields = Object.keys(rows[0]);
|
|
1101
|
+
const fields = opts.fields ?? allFields.filter((f) => !TABLE_HIDDEN_FIELDS.has(f));
|
|
1102
|
+
const termWidth = process.stdout.columns || 120;
|
|
1103
|
+
const maxColWidth = 40;
|
|
1047
1104
|
const widths = {};
|
|
1048
|
-
for (const field of fields) widths[field] = field
|
|
1105
|
+
for (const field of fields) widths[field] = displayWidth(field);
|
|
1049
1106
|
for (const row of rows) {
|
|
1050
1107
|
const record = row;
|
|
1051
1108
|
for (const field of fields) {
|
|
1052
|
-
const
|
|
1053
|
-
widths[field] = Math.min(Math.max(widths[field] ?? 0,
|
|
1109
|
+
const w = displayWidth(formatValue(record[field]));
|
|
1110
|
+
widths[field] = Math.min(Math.max(widths[field] ?? 0, w), maxColWidth);
|
|
1054
1111
|
}
|
|
1055
1112
|
}
|
|
1056
|
-
const
|
|
1113
|
+
const totalGap = 2 * (fields.length - 1);
|
|
1114
|
+
let totalWidth = fields.reduce((sum, f) => sum + (widths[f] ?? 0), 0) + totalGap;
|
|
1115
|
+
while (totalWidth > termWidth && fields.length > 1) {
|
|
1116
|
+
let widest = fields[0];
|
|
1117
|
+
for (const f of fields) if ((widths[f] ?? 0) > (widths[widest] ?? 0)) widest = f;
|
|
1118
|
+
if ((widths[widest] ?? 0) <= 6) break;
|
|
1119
|
+
widths[widest] = (widths[widest] ?? 0) - 1;
|
|
1120
|
+
totalWidth--;
|
|
1121
|
+
}
|
|
1122
|
+
const header = fields.map((f) => pc.bold(padEndDisplay(f, widths[f] ?? 0))).join(" ");
|
|
1057
1123
|
console.log(header);
|
|
1058
1124
|
console.log(fields.map((f) => "─".repeat(widths[f] ?? 0)).join(" "));
|
|
1059
1125
|
for (const row of rows) {
|
|
1060
1126
|
const record = row;
|
|
1061
1127
|
const line = fields.map((f) => {
|
|
1062
1128
|
const val = formatValue(record[f]);
|
|
1063
|
-
const
|
|
1064
|
-
|
|
1065
|
-
return
|
|
1129
|
+
const maxW = widths[f] ?? maxColWidth;
|
|
1130
|
+
if (displayWidth(val) > maxW) return sliceDisplay(val, maxW);
|
|
1131
|
+
return padEndDisplay(val, maxW);
|
|
1066
1132
|
}).join(" ");
|
|
1067
1133
|
console.log(line);
|
|
1068
1134
|
}
|
|
@@ -1071,7 +1137,8 @@ function renderTable(data, opts) {
|
|
|
1071
1137
|
function formatValue(val) {
|
|
1072
1138
|
if (val === null || val === void 0) return pc.dim("—");
|
|
1073
1139
|
if (typeof val === "boolean") return val ? pc.green("true") : pc.dim("false");
|
|
1074
|
-
if (
|
|
1140
|
+
if (Array.isArray(val)) return pc.dim(`[${val.length} items]`);
|
|
1141
|
+
if (typeof val === "object") return pc.dim("{…}");
|
|
1075
1142
|
return sanitizeString(String(val));
|
|
1076
1143
|
}
|
|
1077
1144
|
/**
|
|
@@ -1676,7 +1743,7 @@ async function parseSSEStream(body, handlers, signal) {
|
|
|
1676
1743
|
}
|
|
1677
1744
|
}
|
|
1678
1745
|
function registerListenSubcommand(webhooksCmd) {
|
|
1679
|
-
const listenCmd = webhooksCmd.command("listen").description("Forward webhook events from Recur to your local server in real-time
|
|
1746
|
+
const listenCmd = webhooksCmd.command("listen").description("Forward webhook events from Recur to your local server in real-time").argument("<url>", "Local URL to forward events to (e.g. http://localhost:3000/api/webhooks)").option("--events <types>", "Comma-separated event types to filter (e.g. checkout.completed,order.paid)").option("--relay-url <url>", "Override relay server URL (for development)");
|
|
1680
1747
|
listenCmd.addHelpText("after", `
|
|
1681
1748
|
Connects to Recur's SSE relay and forwards webhook events to your local server.
|
|
1682
1749
|
Each session gets a unique signing secret (whsec_*) for verifying event signatures.
|
|
@@ -2489,7 +2556,7 @@ Requires a Secret Key (sk_test_* or sk_live_*) via --key, RECUR_SECRET_KEY, or r
|
|
|
2489
2556
|
});
|
|
2490
2557
|
const server = new McpServer({
|
|
2491
2558
|
name: "recur",
|
|
2492
|
-
version: "0.1.
|
|
2559
|
+
version: "0.1.2"
|
|
2493
2560
|
}, { capabilities: { tools: {} } });
|
|
2494
2561
|
registerTools(server, client);
|
|
2495
2562
|
const transport = new StdioServerTransport();
|
|
@@ -2505,7 +2572,7 @@ Requires a Secret Key (sk_test_* or sk_live_*) via --key, RECUR_SECRET_KEY, or r
|
|
|
2505
2572
|
//#endregion
|
|
2506
2573
|
//#region src/cli.ts
|
|
2507
2574
|
const program = new Command();
|
|
2508
|
-
program.name("recur").description("Recur CLI — Taiwan subscription payment platform.\nManage products, customers, subscriptions, webhooks, and more.\nAll commands require a Secret Key (sk_test_* or sk_live_*).").version("0.1.
|
|
2575
|
+
program.name("recur").description("Recur CLI — Taiwan subscription payment platform.\nManage products, customers, subscriptions, webhooks, and more.\nAll commands require a Secret Key (sk_test_* or sk_live_*).").version("0.1.2").option("--key <secret-key>", "API secret key (sk_test_* or sk_live_*)").option("--profile <name>", "Use a named profile from ~/.recur/credentials.json").option("--base-url <url>", "API base URL (default: https://api.recur.tw)").option("--output <format>", "Output format: json, table, csv, ndjson (default: json when piped, table otherwise)").hook("preAction", (thisCommand) => {
|
|
2509
2576
|
const opts = thisCommand.opts();
|
|
2510
2577
|
if (!opts.output) opts.output = process.stdout.isTTY ? "table" : "json";
|
|
2511
2578
|
if (![
|
package/dist/index.mjs
CHANGED
|
@@ -187,6 +187,16 @@ function resolveBaseUrl(opts) {
|
|
|
187
187
|
}
|
|
188
188
|
//#endregion
|
|
189
189
|
//#region src/output.ts
|
|
190
|
+
/** Fields to hide in table mode unless explicitly requested via --fields */
|
|
191
|
+
const TABLE_HIDDEN_FIELDS = new Set([
|
|
192
|
+
"object",
|
|
193
|
+
"metadata",
|
|
194
|
+
"display_order",
|
|
195
|
+
"product_family",
|
|
196
|
+
"livemode",
|
|
197
|
+
"created_at",
|
|
198
|
+
"updated_at"
|
|
199
|
+
]);
|
|
190
200
|
/**
|
|
191
201
|
* Render data in the specified format.
|
|
192
202
|
* Agent-optimized: --output json produces clean, parseable JSON to stdout.
|
|
@@ -236,32 +246,88 @@ function renderCsv(data, opts) {
|
|
|
236
246
|
console.log(values.join(","));
|
|
237
247
|
}
|
|
238
248
|
}
|
|
249
|
+
/**
|
|
250
|
+
* Calculate display width of a string in terminal columns.
|
|
251
|
+
* CJK characters (Chinese/Japanese/Korean) take 2 columns.
|
|
252
|
+
* ANSI escape sequences take 0 columns.
|
|
253
|
+
*/
|
|
254
|
+
function displayWidth(str) {
|
|
255
|
+
const stripped = str.replace(/\x1B\[[0-9;]*[A-Za-z]/g, "");
|
|
256
|
+
let width = 0;
|
|
257
|
+
for (const char of stripped) if (isFullWidth(char.codePointAt(0))) width += 2;
|
|
258
|
+
else width += 1;
|
|
259
|
+
return width;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Check if a Unicode code point is full-width (2 terminal columns).
|
|
263
|
+
* Covers CJK Unified Ideographs, CJK Compatibility, Hangul, Fullwidth Forms, etc.
|
|
264
|
+
*/
|
|
265
|
+
function isFullWidth(code) {
|
|
266
|
+
return code >= 4352 && code <= 4447 || code >= 11904 && code <= 12350 || code >= 12352 && code <= 13247 || code >= 13312 && code <= 19903 || code >= 19968 && code <= 42191 || code >= 43360 && code <= 43388 || code >= 44032 && code <= 55203 || code >= 63744 && code <= 64255 || code >= 65072 && code <= 65131 || code >= 65281 && code <= 65376 || code >= 65504 && code <= 65510 || code >= 131072 && code <= 196605 || code >= 196608 && code <= 262141;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Pad a string to target display width (CJK-aware).
|
|
270
|
+
*/
|
|
271
|
+
function padEndDisplay(str, targetWidth) {
|
|
272
|
+
const currentWidth = displayWidth(str);
|
|
273
|
+
if (currentWidth >= targetWidth) return str;
|
|
274
|
+
return str + " ".repeat(targetWidth - currentWidth);
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Slice a string to fit within maxWidth display columns (CJK-aware).
|
|
278
|
+
* Adds '…' if truncated.
|
|
279
|
+
*/
|
|
280
|
+
function sliceDisplay(str, maxWidth) {
|
|
281
|
+
if (maxWidth <= 1) return "…";
|
|
282
|
+
const stripped = str.replace(/\x1B\[[0-9;]*[A-Za-z]/g, "");
|
|
283
|
+
let width = 0;
|
|
284
|
+
let result = "";
|
|
285
|
+
for (const char of stripped) {
|
|
286
|
+
const charWidth = isFullWidth(char.codePointAt(0)) ? 2 : 1;
|
|
287
|
+
if (width + charWidth > maxWidth - 1) return result + "…";
|
|
288
|
+
result += char;
|
|
289
|
+
width += charWidth;
|
|
290
|
+
}
|
|
291
|
+
return str;
|
|
292
|
+
}
|
|
239
293
|
function renderTable(data, opts) {
|
|
240
294
|
const rows = Array.isArray(data) ? data : [data];
|
|
241
295
|
if (rows.length === 0) {
|
|
242
296
|
console.log(pc.dim("No results"));
|
|
243
297
|
return;
|
|
244
298
|
}
|
|
245
|
-
const
|
|
299
|
+
const allFields = Object.keys(rows[0]);
|
|
300
|
+
const fields = opts.fields ?? allFields.filter((f) => !TABLE_HIDDEN_FIELDS.has(f));
|
|
301
|
+
const termWidth = process.stdout.columns || 120;
|
|
302
|
+
const maxColWidth = 40;
|
|
246
303
|
const widths = {};
|
|
247
|
-
for (const field of fields) widths[field] = field
|
|
304
|
+
for (const field of fields) widths[field] = displayWidth(field);
|
|
248
305
|
for (const row of rows) {
|
|
249
306
|
const record = row;
|
|
250
307
|
for (const field of fields) {
|
|
251
|
-
const
|
|
252
|
-
widths[field] = Math.min(Math.max(widths[field] ?? 0,
|
|
308
|
+
const w = displayWidth(formatValue(record[field]));
|
|
309
|
+
widths[field] = Math.min(Math.max(widths[field] ?? 0, w), maxColWidth);
|
|
253
310
|
}
|
|
254
311
|
}
|
|
255
|
-
const
|
|
312
|
+
const totalGap = 2 * (fields.length - 1);
|
|
313
|
+
let totalWidth = fields.reduce((sum, f) => sum + (widths[f] ?? 0), 0) + totalGap;
|
|
314
|
+
while (totalWidth > termWidth && fields.length > 1) {
|
|
315
|
+
let widest = fields[0];
|
|
316
|
+
for (const f of fields) if ((widths[f] ?? 0) > (widths[widest] ?? 0)) widest = f;
|
|
317
|
+
if ((widths[widest] ?? 0) <= 6) break;
|
|
318
|
+
widths[widest] = (widths[widest] ?? 0) - 1;
|
|
319
|
+
totalWidth--;
|
|
320
|
+
}
|
|
321
|
+
const header = fields.map((f) => pc.bold(padEndDisplay(f, widths[f] ?? 0))).join(" ");
|
|
256
322
|
console.log(header);
|
|
257
323
|
console.log(fields.map((f) => "─".repeat(widths[f] ?? 0)).join(" "));
|
|
258
324
|
for (const row of rows) {
|
|
259
325
|
const record = row;
|
|
260
326
|
const line = fields.map((f) => {
|
|
261
327
|
const val = formatValue(record[f]);
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
return
|
|
328
|
+
const maxW = widths[f] ?? maxColWidth;
|
|
329
|
+
if (displayWidth(val) > maxW) return sliceDisplay(val, maxW);
|
|
330
|
+
return padEndDisplay(val, maxW);
|
|
265
331
|
}).join(" ");
|
|
266
332
|
console.log(line);
|
|
267
333
|
}
|
|
@@ -270,7 +336,8 @@ function renderTable(data, opts) {
|
|
|
270
336
|
function formatValue(val) {
|
|
271
337
|
if (val === null || val === void 0) return pc.dim("—");
|
|
272
338
|
if (typeof val === "boolean") return val ? pc.green("true") : pc.dim("false");
|
|
273
|
-
if (
|
|
339
|
+
if (Array.isArray(val)) return pc.dim(`[${val.length} items]`);
|
|
340
|
+
if (typeof val === "object") return pc.dim("{…}");
|
|
274
341
|
return sanitizeString(String(val));
|
|
275
342
|
}
|
|
276
343
|
/**
|