@optima-chat/dev-skills 0.16.6 → 0.16.8

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.
Files changed (40) hide show
  1. package/.claude/commands/logs.md +1 -1
  2. package/.claude/commands/query-db.md +6 -1
  3. package/.claude/skills/discount-codes/SKILL.md +4 -4
  4. package/.claude/skills/generate-test-token/SKILL.md +8 -4
  5. package/.claude/skills/logs/SKILL.md +5 -3
  6. package/.claude/skills/show-env/SKILL.md +1 -1
  7. package/.claude/skills/yzsgo-e2e/SKILL.md +19 -0
  8. package/.claude/skills/yzsgo-e2e/SYNC.md +26 -2
  9. package/.claude/skills/yzsgo-e2e/chat_driver.py +221 -47
  10. package/.claude/skills/yzsgo-e2e/pull_wire.py +39 -8
  11. package/.claude/skills/yzsgo-e2e/run_e2e.py +17 -2
  12. package/.codex/skills/generate-test-token/SKILL.md +3 -2
  13. package/.codex/skills/logs/SKILL.md +4 -2
  14. package/.codex/skills/show-env/SKILL.md +4 -2
  15. package/.codex/skills/yzsgo-e2e/SKILL.md +19 -0
  16. package/.codex/skills/yzsgo-e2e/SYNC.md +26 -2
  17. package/.codex/skills/yzsgo-e2e/chat_driver.py +221 -47
  18. package/.codex/skills/yzsgo-e2e/pull_wire.py +39 -8
  19. package/.codex/skills/yzsgo-e2e/run_e2e.py +17 -2
  20. package/AGENTS.md +1 -1
  21. package/bin/cli.js +3 -3
  22. package/bin/helpers/billing-http.ts +12 -3
  23. package/bin/helpers/db-utils.ts +8 -5
  24. package/bin/helpers/entitlement/grant.ts +5 -0
  25. package/bin/helpers/entitlement/revoke.ts +5 -0
  26. package/bin/helpers/generate-test-token.ts +40 -3
  27. package/bin/helpers/grant-credits.ts +8 -2
  28. package/bin/helpers/grant-subscription.ts +8 -4
  29. package/bin/helpers/operator.ts +9 -0
  30. package/bin/helpers/verify-health.ts +77 -29
  31. package/dist/bin/helpers/billing-http.js +9 -1
  32. package/dist/bin/helpers/db-utils.js +8 -2
  33. package/dist/bin/helpers/entitlement/grant.js +7 -0
  34. package/dist/bin/helpers/entitlement/revoke.js +7 -0
  35. package/dist/bin/helpers/generate-test-token.js +40 -3
  36. package/dist/bin/helpers/grant-credits.js +9 -2
  37. package/dist/bin/helpers/grant-subscription.js +9 -3
  38. package/dist/bin/helpers/operator.js +11 -0
  39. package/dist/bin/helpers/verify-health.js +83 -27
  40. package/package.json +1 -1
@@ -169,6 +169,19 @@ def render_conversation(conv, resps, deref, idx):
169
169
  res_ids.add(b.get("tool_use_id"))
170
170
  dangling = [(u, n) for u, n in use_ids.items() if u not in res_ids]
171
171
 
172
+ # 末 response 状态:审查靠它判「最终回复能不能核」。缺失时**必须显式标注**,
173
+ # 否则 transcript 静默停在末条 user 消息(常是超长 tool_result 截断处),
174
+ # 会被误读成「响应被截断吞了」(上游 #102 真根因:不是截断,是末 req 无 response 记录)。
175
+ lastresp = resps.get(last["callId"])
176
+ if not lastresp:
177
+ last_state = "⚠️ 末 request 无 response 记录(生成中断/未落盘/末轮是 continuation)——最终回复不可核"
178
+ elif lastresp.get("kind") == "error":
179
+ last_state = "⚠️ 末 response 是 error:" + json.dumps(deref(lastresp.get("error") or {}), ensure_ascii=False)[:160]
180
+ elif (lastresp.get("finalMessage") or {}).get("stopReason") == "max_tokens":
181
+ last_state = "⚠️ 末 response stop_reason=max_tokens(回复被截断,可能未说完)"
182
+ else:
183
+ last_state = "有(正常 response)"
184
+
172
185
  L = [f"# 对话 #{idx} {ts0}",
173
186
  "", f"**prompt**: {prompt[:200]}", "",
174
187
  "## 事实卡(代码算的确定信息)",
@@ -176,9 +189,9 @@ def render_conversation(conv, resps, deref, idx):
176
189
  f"- error 响应 {len(errs)}" + (f":{[json.dumps(deref(e.get('error') or {}),ensure_ascii=False)[:120] for e in errs]}" if errs else ""),
177
190
  f"- stop_reason=max_tokens 的响应 {maxtok}",
178
191
  f"- 悬空 tool_use(无匹配 result){len(dangling)}: {dangling[:5]}",
192
+ f"- 末 response: {last_state}",
179
193
  "", "## 完整 transcript(末 req 全历史 + 末 response)", ""]
180
- # 末 response 拼到历史尾
181
- lastresp = resps.get(last["callId"])
194
+ # 末 response 拼到历史尾(仅 response 类才有 finalMessage 内容可拼)
182
195
  if lastresp and lastresp.get("kind") == "response":
183
196
  fm = lastresp.get("finalMessage") or {}
184
197
  msgs.append({"role": "assistant", "content": fm.get("content", [])})
@@ -190,8 +203,15 @@ def render_conversation(conv, resps, deref, idx):
190
203
  L.append(" " + _clip(c, CAP_TEXT))
191
204
  elif isinstance(c, list):
192
205
  for b in c:
193
- L.append(render_block(b, deref))
194
- return prompt, ts0, len(errs), len(dangling), "\n".join(L)
206
+ try:
207
+ L.append(render_block(b, deref))
208
+ except Exception as e: # 单个块渲染失败不该中断后续消息(上游 #102 加固)
209
+ L.append(f" [渲染失败 {type(e).__name__}: {str(e)[:80]}]")
210
+ # transcript 结尾显式收口——末 response 非正常时补一行,让审查者绝不把静默结束当完整
211
+ resp_ok = bool(lastresp and lastresp.get("kind") == "response")
212
+ if not resp_ok:
213
+ L.append(f"\n--- [transcript 结束] {last_state} ---")
214
+ return prompt, ts0, len(errs), len(dangling), resp_ok, "\n".join(L)
195
215
 
196
216
 
197
217
  def render_all(wire_root, out):
@@ -214,10 +234,10 @@ def render_all(wire_root, out):
214
234
  index.append(f"\n## session `{sid}` — {len(convs)} 对话 / {len(reqs)} req\n")
215
235
  for conv in convs:
216
236
  gidx += 1
217
- prompt, ts0, n_err, n_dang, md = render_conversation(conv, resps, deref, gidx)
237
+ prompt, ts0, n_err, n_dang, resp_ok, md = render_conversation(conv, resps, deref, gidx)
218
238
  fn = f"{gidx:03d}.md"
219
239
  open(os.path.join(conv_dir, fn), "w", encoding="utf-8").write(md)
220
- flag = (" ⚠️err" if n_err else "") + (" ⚠️dangling" if n_dang else "")
240
+ flag = (" ⚠️err" if n_err else "") + (" ⚠️dangling" if n_dang else "") + ("" if resp_ok else " ⚠️无末response")
221
241
  index.append(f"- [{fn}](conversations/{fn}) [{len(conv):>2} req] {prompt[:64]}{flag}")
222
242
  idxpath = os.path.join(out, "index.md")
223
243
  open(idxpath, "w", encoding="utf-8").write("\n".join(index))
@@ -278,9 +298,20 @@ def _ts_key(ts):
278
298
  return None
279
299
 
280
300
 
281
- def locate_conversation(index, started_ts, first_message):
301
+ def locate_conversation(index, started_ts, first_message, session_id=None):
282
302
  """按 (started_ts, first_message) 定位本次对话;禁用 ls -t。规则见 plan Task 3 Interfaces。
283
- 时间比较先把两侧 ISO(Z / +00:00、小数位宽不同)归一成 datetime,避免依赖字典序=数值序的脆弱假设。"""
303
+ 时间比较先把两侧 ISO(Z / +00:00、小数位宽不同)归一成 datetime,避免依赖字典序=数值序的脆弱假设。
304
+
305
+ `session_id`(2026-09-09 起,鸭嘴兽支持并发任务后):驱动侧现在知道自己跑在**哪个 gateway
306
+ session** 上(一 tab 一 session,wire 目录名就是 sessionId),传进来就先把候选缩到该 session。
307
+ 这是**确定性**定位,比 (时间, 首句) 的启发式可靠得多 —— 同一句 prompt 重跑多次时,
308
+ 启发式只能靠时间戳挑,而并发跑的多个会话时间戳本来就交叠。
309
+ 传了但该 session 在索引里一条都没有(wire 还没落盘/TTL 过期/拉的账号不对)→ **不回退**到
310
+ 全局启发式:那样会安静地定位到别的 session 的对话,比返回 None 更糟。"""
311
+ if session_id:
312
+ index = [it for it in index if it.get("sid") == session_id]
313
+ if not index:
314
+ return None
284
315
  key = (first_message or "").strip()[:40]
285
316
  cands = []
286
317
  for it in index:
@@ -85,7 +85,15 @@ def main():
85
85
 
86
86
  started_ts = _utc_now()
87
87
  answers = _parse_answers(args.answer)
88
+ # attach() 默认就自己开 tab 独占一个 gateway session("auto" 档)。两个好处——
89
+ # ① 不去抢用户已经开着的 chat tab(抢了会互相串台,见 chat_driver 模块 docstring);
90
+ # ② 拿到确定的 sessionId,后面按它**确定性**定位 wire 对话,不靠 (时间,首句) 猜。
91
+ # 该环境 multi-tab 没开(flag 默认关,cn-stage 未验)时 auto 档自己会降级复用已有 tab
92
+ # 并打 warn —— 单 driver 复用是安全的,功能不减,只是 wire 定位退回启发式。
88
93
  d = chat_driver.ChatDriver().attach()
94
+ session_id = d.session_id # attach 后立刻取:放在 try 里的话,中途抛异常会留下未绑定名
95
+ if not d.tab_isolated:
96
+ print("[warn] 未能独占 tab(该环境 multi-tab 未开)—— wire 定位退回 (时间,首句) 启发式")
89
97
  try:
90
98
  d.new_conversation()
91
99
  turns = []
@@ -99,13 +107,17 @@ def main():
99
107
  wire_root = pull_wire.pull(args.user, since_days=args.since, out=args.out)
100
108
  meta = {"env": args.env, "started_ts": started_ts, "first_message": args.message[0],
101
109
  "expect": args.expect, "issue_repo": args.issue_repo, "turns": turns,
110
+ "session_id": session_id, # 本轮跑在哪个 gateway session(= wire 目录名)
102
111
  "located": None, "located_reason": None}
103
112
  wrote_prepped = False
104
113
  if not wire_root:
105
114
  meta["located_reason"] = "no_wire_session"
106
115
  if wire_root:
107
116
  index = pull_wire.emit_conversation_index(wire_root)
108
- hit = pull_wire.locate_conversation(index, started_ts, args.message[0])
117
+ hit = pull_wire.locate_conversation(index, started_ts, args.message[0],
118
+ session_id=session_id)
119
+ if session_id and not hit:
120
+ meta["located_reason"] = f"session {session_id} 在 wire 索引里没有对话(未落盘/TTL 过期/账号不对)"
109
121
  meta["located"] = hit
110
122
  if hit:
111
123
  sdir = os.path.join(wire_root, hit["sid"])
@@ -117,7 +129,10 @@ def main():
117
129
  convs = pull_wire.segment(reqs, deref)
118
130
  conv = select_conversation_in_session(convs, deref, hit)
119
131
  if conv is not None:
120
- _, _, _, _, wire_md = pull_wire.render_conversation(conv, resps, deref, hit["gidx"])
132
+ # 6 元返回(上游 #102 起多了 resp_ok):末 response 不正常时 wire_md 结尾已显式收口,
133
+ # 但 meta 也记一份,报告层不必去 grep transcript 才知道「最终回复不可核」。
134
+ _, _, _, _, resp_ok, wire_md = pull_wire.render_conversation(conv, resps, deref, hit["gidx"])
135
+ meta["wire_last_response_ok"] = resp_ok
121
136
  prepped = prep_conversation.merge_browser_evidence(wire_md, turns)
122
137
  with open(os.path.join(args.out, "prepped.md"), "w", encoding="utf-8") as f:
123
138
  f.write(prepped)
package/AGENTS.md CHANGED
@@ -7,7 +7,7 @@ This repository provides shared development skills and CLI helpers for Optima en
7
7
  Prefer the installed CLI tools over reimplementing long shell workflows:
8
8
 
9
9
  - `optima-query-db <service> "<sql>" [environment]`
10
- - `optima-show-env <service> <stage|prod> [options]`
10
+ - `optima-show-env <service> <stage|prod|cn-prod|cn-stage> [options]`
11
11
  - `optima-generate-test-token [options]`
12
12
  - `optima-grant-subscription <email> [options]`
13
13
  - `optima-grant-credits <email|phone|userId> --credits <n> [options]`
package/bin/cli.js CHANGED
@@ -32,8 +32,8 @@ switch (command) {
32
32
  log('Available Commands:', 'yellow');
33
33
  log(' optima-query-db <service> "<sql>" [env] Query database', 'cyan');
34
34
  log(' optima-show-env <service> [env] Show service env vars', 'cyan');
35
- log(' optima-verify-health <service> [--env cn|prod|all] Probe L1-L5 上线健康', 'cyan');
36
- log(' optima-generate-test-token [--env production] Generate test token', 'cyan');
35
+ log(' optima-verify-health <service> [--env stage|prod|cn-prod|cn-stage|all] Probe L1-L5 上线健康', 'cyan');
36
+ log(' optima-generate-test-token [--env ci|stage|prod|cn-prod|cn-stage] Generate test token', 'cyan');
37
37
  log(' optima-grant-credits <email|phone|userId> --credits <n> [--env] Grant credits (bonus, 30d)', 'cyan');
38
38
  log(' optima-grant-subscription <email|phone|userId> --plan <p> [--env] Grant subscription', 'cyan');
39
39
  log(' optima-logs <service> [--env] [--since] [--grep] [-n] View logs (cn=SLS 直连/aws=CloudWatch)', 'cyan');
@@ -41,7 +41,7 @@ switch (command) {
41
41
  log(' /restart-ecs <service> [env] Restart ECS service (skill)', 'cyan');
42
42
 
43
43
  log('\nSupported Services:', 'yellow');
44
- log(' commerce-backend user-auth mcp-host agentic-chat optima-logistics', 'cyan');
44
+ log(' commerce-backend user-auth agentic-chat optima-logistics', 'cyan');
45
45
  log(' session-gateway optima-scout billing browser-backend optima-generation', 'cyan');
46
46
 
47
47
  log('\nEnvironments:', 'yellow');
@@ -1,4 +1,4 @@
1
- import { execSync } from 'child_process';
1
+ import { execFileSync } from 'child_process';
2
2
  import { fetchInfisicalSecret } from './infisical-secrets';
3
3
  import { getInfisicalConfig, getInfisicalToken, getCnInfisicalToken, getCnSecrets, resolveUserId } from './db-utils';
4
4
 
@@ -149,8 +149,17 @@ export function getServiceToken(env: string, scope?: string): string {
149
149
  const effectiveScope = scope ?? (isCn ? CN_PROD_TOKEN_SCOPE : undefined);
150
150
  const scopeParam = effectiveScope ? `&scope=${encodeURIComponent(effectiveScope)}` : '';
151
151
  const body = `grant_type=client_credentials&client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}${scopeParam}`;
152
- const response = execSync(
153
- `curl -s -X POST '${authUrl}/api/v1/oauth/token' -H 'Content-Type: application/x-www-form-urlencoded' -d '${body}'`,
152
+ // execFileSync + 参数数组(不经 shell):Windows cmd.exe 不认单引号,shell 拼出的
153
+ // `-d '${body}'` 会被拆碎、curl 收到垃圾参数直接退出(#92)。数组传参绕开 shell、
154
+ // 跨平台一致;函数保持同步。
155
+ const response = execFileSync(
156
+ 'curl',
157
+ [
158
+ '-s', '-X', 'POST',
159
+ `${authUrl}/api/v1/oauth/token`,
160
+ '-H', 'Content-Type: application/x-www-form-urlencoded',
161
+ '-d', body,
162
+ ],
154
163
  { encoding: 'utf-8' },
155
164
  );
156
165
 
@@ -81,11 +81,14 @@ export function getInfisicalConfig(): InfisicalConfig {
81
81
  }
82
82
 
83
83
  export function getInfisicalToken(config: InfisicalConfig): string {
84
- const response = execSync(
85
- `curl -s -X POST "${config.url}/api/v1/auth/universal-auth/login" -H "Content-Type: application/json" -d '{"clientId": "${config.clientId}", "clientSecret": "${config.clientSecret}"}'`,
86
- { encoding: 'utf-8' }
87
- );
88
- return JSON.parse(response).accessToken;
84
+ // 复用 curlJson(execFileSync + 参数数组、不经 shell):Windows cmd.exe 不认单引号,
85
+ // 原来 shell 拼的 `-d '{...}'` JSON body 会被拆碎、curl 收垃圾参数直接退出(#92)。
86
+ // cn 路径早已用 curlJson,这里对齐;函数保持同步(调用方无需改 async)。
87
+ return curlJson([
88
+ '-X', 'POST', `${config.url}/api/v1/auth/universal-auth/login`,
89
+ '-H', 'Content-Type: application/json',
90
+ '-d', JSON.stringify({ clientId: config.clientId, clientSecret: config.clientSecret }),
91
+ ]).accessToken;
89
92
  }
90
93
 
91
94
  export function getInfisicalSecrets(config: InfisicalConfig, token: string, environment: string, secretPath: string): Record<string, string> {
@@ -1,12 +1,14 @@
1
1
  import { callBilling, validateEnvCnProd } from '../billing-http';
2
2
  import { confirmIfProd } from '../confirm-prompt';
3
3
  import { resolveTargetUser } from '../grant-subscription';
4
+ import { operatorActorId } from '../operator';
4
5
 
5
6
  interface GrantArgs {
6
7
  identifier: string;
7
8
  productKey: string;
8
9
  justification: string;
9
10
  yes: boolean;
11
+ operator?: string;
10
12
  env: string;
11
13
  }
12
14
 
@@ -21,6 +23,7 @@ Required:
21
23
 
22
24
  Optional:
23
25
  --yes Skip prod/cn-prod confirmation prompt (no-op on stage / cn-stage)
26
+ --operator <name> Operator self-report for billing audit (default: local username)
24
27
  --env stage|prod|cn-prod|cn-stage (default: stage)
25
28
 
26
29
  Hardcoded server-side: source=ADMIN_GRANT, priceCents=0, currency=USD, grantedBy=<clientId>.`);
@@ -35,6 +38,7 @@ Hardcoded server-side: source=ADMIN_GRANT, priceCents=0, currency=USD, grantedBy
35
38
  case '--product-key': out.productKey = next; i++; break;
36
39
  case '--justification': out.justification = next; i++; break;
37
40
  case '--yes': out.yes = true; break;
41
+ case '--operator': out.operator = next; i++; break;
38
42
  case '--env': out.env = next; i++; break;
39
43
  default:
40
44
  if (a.startsWith('--')) throw new Error(`Unknown arg: ${a}`);
@@ -67,6 +71,7 @@ export async function runGrant(argv: string[]): Promise<void> {
67
71
  userId,
68
72
  productKey: args.productKey,
69
73
  justification: args.justification,
74
+ actorUserId: operatorActorId(args.operator ?? null),
70
75
  });
71
76
  console.log(`✓ Granted entitlement (HTTP ${res.status}):`);
72
77
  console.log(JSON.stringify(res.body, null, 2));
@@ -1,12 +1,14 @@
1
1
  import { callBilling, validateEnvCnProd } from '../billing-http';
2
2
  import { confirmIfProd } from '../confirm-prompt';
3
3
  import { resolveTargetUser } from '../grant-subscription';
4
+ import { operatorActorId } from '../operator';
4
5
 
5
6
  interface RevokeArgs {
6
7
  identifier: string;
7
8
  productKey: string;
8
9
  reason: string;
9
10
  yes: boolean;
11
+ operator?: string;
10
12
  env: string;
11
13
  }
12
14
 
@@ -28,6 +30,7 @@ Required:
28
30
 
29
31
  Optional:
30
32
  --yes Skip prod/cn-prod confirmation prompt (no-op on stage / cn-stage)
33
+ --operator <name> Operator self-report for billing audit (default: local username)
31
34
  --env stage|prod|cn-prod|cn-stage (default: stage)
32
35
 
33
36
  Refuses non-ADMIN_GRANT sources (PAYMENT, PARTNER) with source-specific
@@ -43,6 +46,7 @@ error pointing to the right reversal flow.`);
43
46
  case '--product-key': out.productKey = next; i++; break;
44
47
  case '--reason': out.reason = next; i++; break;
45
48
  case '--yes': out.yes = true; break;
49
+ case '--operator': out.operator = next; i++; break;
46
50
  case '--env': out.env = next; i++; break;
47
51
  default:
48
52
  if (a.startsWith('--')) throw new Error(`Unknown arg: ${a}`);
@@ -111,6 +115,7 @@ export async function runRevoke(argv: string[]): Promise<void> {
111
115
  entitlementId: target.id,
112
116
  refundReason: args.reason,
113
117
  refundAmountCents: 0,
118
+ actorUserId: operatorActorId(args.operator ?? null),
114
119
  });
115
120
  console.log(`✓ Revoked entitlement (HTTP ${res.status}):`);
116
121
  console.log(JSON.stringify(res.body, null, 2));
@@ -88,6 +88,41 @@ async function httpRequest<T>(url: string, options: RequestInit = {}): Promise<T
88
88
  return response.json() as Promise<T>;
89
89
  }
90
90
 
91
+ /**
92
+ * 判定「账号/商户已存在」——吃 httpRequest 抛出的 `HTTP <status>: <body>` message。
93
+ *
94
+ * 两条腿各自对应一个真实来源(user-auth / commerce-backend 是同一套代码跑在 AWS 与
95
+ * cn 两侧,响应形态一致;下列均为 2026-08-13 直查 origin/main 坐实):
96
+ *
97
+ * - **文案腿** `^HTTP 4xx` 且 `already (exists|registered)`
98
+ * · user-auth 注册已注册邮箱 → 400 "Email already registered"
99
+ * (user-auth `app/services/user.py`)。**这就是 #78 的病灶**:旧判定只认
100
+ * "already exists",措辞对不上 ⇒ cn 侧 `--email` 复用已有账号必挂。
101
+ * 实测 cn-prod 响应体:{"error":"Email already registered","status_code":400}
102
+ * · commerce-backend 重复建 merchant profile → 400 "Merchant profile already
103
+ * exists. Use PUT ..."(commerce-backend `src/api/merchants.py`)
104
+ * · 🔴 必须同时是 4xx:5xx 是服务端故障,body 里恰好带着这些词也不能吞 —— 否则
105
+ * `POST /api/merchants/me` 的 502/503 会被报成 ℹ already exists,一路打印到
106
+ * 「✅ Test token generated successfully!」+ Merchant ID: N/A、exit 0。
107
+ * - **状态码腿** `^HTTP 409`
108
+ * · 本工具只调 `POST /api/v1/auth/register/merchant` 与 `POST /api/merchants/me`
109
+ * 两个端点。**register/merchant 这条路径上唯一的 409** 是企业席位:
110
+ * account_type == ENTERPRISE_SEAT → 409 detail "email_bound_to_seat"。
111
+ * 🔴 **该文案既不含 exists 也不含 registered,所以这条腿不是历史遗留、
112
+ * 是席位场景的唯一命中路径,别删。**
113
+ * ⚠️ 「这条路径上唯一」的限定不是废话:user-auth 全仓有 20+ 处 409
114
+ * (referral / internal-sms / teams / admin),措辞各异且语义完全不同
115
+ * (如 `claim_in_progress` 是并发冲突)。给本工具接新端点时别想当然沿用本判定,
116
+ * 否则会把并发冲突静默吞成「已存在」。
117
+ *
118
+ * 锚 `^HTTP 409` 而不是 issue 建议的 `\b(409|400)\b`:后者会把 body 里夹带的数字当
119
+ * 状态码,把真故障(500 而 body 里提到 409)静默吞成「已存在」;而 400 本身太泛
120
+ * (参数校验失败也是 400),单凭它放行等于吞掉一切请求错误 —— 400 一律交给文案腿判。
121
+ */
122
+ export function isAlreadyExistsError(message: string): boolean {
123
+ return /^HTTP 409\b/.test(message) || (/^HTTP 4\d\d\b/.test(message) && /already (exists|registered)/i.test(message));
124
+ }
125
+
91
126
  async function registerMerchant(
92
127
  email: string,
93
128
  password: string,
@@ -112,7 +147,7 @@ async function registerMerchant(
112
147
  console.log(`✓ Merchant registered successfully (ID: ${result.user_id ?? (result as any).id})`);
113
148
  return result;
114
149
  } catch (error: any) {
115
- if (error.message.includes('409') || error.message.includes('already exists')) {
150
+ if (isAlreadyExistsError(error.message)) {
116
151
  console.log(`ℹ Merchant already exists, proceeding to login...`);
117
152
  return { email, user_id: '', role: 'merchant', is_active: true, created_at: '', updated_at: '' };
118
153
  }
@@ -167,7 +202,7 @@ async function setupMerchantProfile(token: string, businessName: string, config:
167
202
  console.log(`✓ Merchant profile setup complete (ID: ${result.merchant_id})`);
168
203
  return result;
169
204
  } catch (error: any) {
170
- if (error.message.includes('409') || error.message.includes('already exists')) {
205
+ if (isAlreadyExistsError(error.message)) {
171
206
  console.log(`ℹ Merchant profile already exists`);
172
207
  return { merchant_id: '', business_name: businessName, user_id: '' };
173
208
  }
@@ -311,4 +346,6 @@ Example:
311
346
  }
312
347
  }
313
348
 
314
- main();
349
+ if (require.main === module) {
350
+ main();
351
+ }
@@ -10,6 +10,7 @@
10
10
  import { basename } from 'path';
11
11
  import { randomUUID } from 'crypto';
12
12
  import { callBilling, validateEnvCnProd } from './billing-http';
13
+ import { operatorActorId } from './operator';
13
14
  import { resolveTargetUser } from './grant-subscription';
14
15
 
15
16
  const CREDITS_PER_USD = 700;
@@ -19,6 +20,7 @@ interface Parsed {
19
20
  credits: number | null;
20
21
  amountUsd: number | null;
21
22
  description: string | null;
23
+ operator: string | null;
22
24
  env: string;
23
25
  }
24
26
 
@@ -36,6 +38,7 @@ Options:
36
38
  --credits <n> Credits to grant (integer >= 1). Primary unit.
37
39
  --amount <usd> Alt: grant by USD ($1 = ${CREDITS_PER_USD} credits). Provide exactly one of --credits / --amount.
38
40
  --description <text> Description for audit trail (optional)
41
+ --operator <name> Operator self-report for billing audit (default: local username)
39
42
  --env <env> Environment: stage, prod, cn-prod, cn-stage (default: stage)
40
43
  -h, --help Show this help
41
44
 
@@ -50,12 +53,14 @@ Examples:
50
53
  let credits: number | null = null;
51
54
  let amountUsd: number | null = null;
52
55
  let description: string | null = null;
56
+ let operator: string | null = null;
53
57
  let env = 'stage';
54
58
 
55
59
  for (let i = 1; i < args.length; i++) {
56
60
  if (args[i] === '--credits' && args[i + 1]) { credits = parseInt(args[++i], 10); }
57
61
  else if (args[i] === '--amount' && args[i + 1]) { amountUsd = parseFloat(args[++i]); }
58
62
  else if (args[i] === '--description' && args[i + 1]) { description = args[++i]; }
63
+ else if (args[i] === '--operator' && args[i + 1]) { operator = args[++i]; }
59
64
  else if (args[i] === '--env' && args[i + 1]) { env = args[++i]; }
60
65
  }
61
66
 
@@ -74,7 +79,7 @@ Examples:
74
79
  }
75
80
  validateEnvCnProd(env);
76
81
 
77
- return { identifier, credits, amountUsd, description, env };
82
+ return { identifier, credits, amountUsd, description, operator, env };
78
83
  }
79
84
 
80
85
  async function main() {
@@ -83,7 +88,7 @@ async function main() {
83
88
  console.warn('⚠️ optima-grant-balance 已更名为 optima-grant-credits(P15 钱包退役后回归积分)。请改用 `optima-grant-credits --credits <n>`;本别名仍可用,后续弃用。\n');
84
89
  }
85
90
 
86
- const { identifier, credits, amountUsd, description, env } = parseArgs(process.argv.slice(2));
91
+ const { identifier, credits, amountUsd, description, operator, env } = parseArgs(process.argv.slice(2));
87
92
 
88
93
  const creditsDisplay = credits ?? Math.round((amountUsd as number) * CREDITS_PER_USD);
89
94
  console.log(`\n🎁 Granting ${creditsDisplay} credits${amountUsd !== null ? ` ($${amountUsd.toFixed(2)})` : ''} to ${identifier} [${env.toUpperCase()}]\n`);
@@ -105,6 +110,7 @@ async function main() {
105
110
  userId,
106
111
  ...amountField,
107
112
  description: description ?? undefined,
113
+ actorUserId: operatorActorId(operator),
108
114
  idempotencyKey: `dev-skills-grant:${randomUUID()}`,
109
115
  },
110
116
  );
@@ -11,6 +11,7 @@ import {
11
11
  getUserById,
12
12
  validateEnvCnProd,
13
13
  } from './billing-http';
14
+ import { operatorActorId } from './operator';
14
15
 
15
16
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
16
17
 
@@ -76,7 +77,7 @@ const PLANS_BY_ENV: Record<string, string[]> = {
76
77
  'cn-stage': ['trial', 'starter', 'pro', 'enterprise', 'free'],
77
78
  };
78
79
 
79
- function parseArgs(args: string[]): { identifier: string; plan: string; months: number; env: string } {
80
+ function parseArgs(args: string[]): { identifier: string; plan: string; months: number; operator: string | null; env: string } {
80
81
  if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
81
82
  console.log(`Usage: optima-grant-subscription <email|phone|userId> [options]
82
83
 
@@ -85,6 +86,7 @@ Options:
85
86
  cn-prod/cn-stage additionally allow: free
86
87
  (legacy *-cn ids are accepted and normalized to canonical)
87
88
  --months <n> Duration in months (default: 1)
89
+ --operator <name> Operator self-report for billing audit (default: local username)
88
90
  --env <env> Environment: stage, prod, cn-prod, cn-stage (default: stage)
89
91
  -h, --help Show this help`);
90
92
  process.exit(0);
@@ -93,11 +95,13 @@ Options:
93
95
  const identifier = args[0];
94
96
  let plan: string | null = null;
95
97
  let months = 1;
98
+ let operator: string | null = null;
96
99
  let env = 'stage';
97
100
 
98
101
  for (let i = 1; i < args.length; i++) {
99
102
  if (args[i] === '--plan' && args[i + 1]) { plan = args[++i]; }
100
103
  else if (args[i] === '--months' && args[i + 1]) { months = parseInt(args[++i], 10); }
104
+ else if (args[i] === '--operator' && args[i + 1]) { operator = args[++i]; }
101
105
  else if (args[i] === '--env' && args[i + 1]) { env = args[++i]; }
102
106
  }
103
107
 
@@ -113,7 +117,7 @@ Options:
113
117
  }
114
118
  if (months < 1) { console.error('Months must be >= 1'); process.exit(1); }
115
119
 
116
- return { identifier, plan, months, env };
120
+ return { identifier, plan, months, operator, env };
117
121
  }
118
122
 
119
123
  /**
@@ -181,7 +185,7 @@ export async function resolveTargetUser(
181
185
  }
182
186
 
183
187
  async function main() {
184
- const { identifier, plan, months, env } = parseArgs(process.argv.slice(2));
188
+ const { identifier, plan, months, operator, env } = parseArgs(process.argv.slice(2));
185
189
 
186
190
  console.log(`\n🎁 Granting ${plan} subscription to ${identifier} (${classifyIdentifier(identifier)}) for ${months} month(s) [${env.toUpperCase()}]\n`);
187
191
 
@@ -198,7 +202,7 @@ async function main() {
198
202
  weeklyTokenLimit: number;
199
203
  expiresAt: string;
200
204
  warning?: string;
201
- }>(env, 'POST', '/api/billing/admin/grant-subscription', { userId, planId: plan, months });
205
+ }>(env, 'POST', '/api/billing/admin/grant-subscription', { userId, planId: plan, months, actorUserId: operatorActorId(operator) });
202
206
 
203
207
  console.log(`✓ Subscription ${body.subscriptionId} (${body.planId})`);
204
208
  console.log(`✓ Credits: ${body.credits.toLocaleString()} (expires ${body.expiresAt})`);
@@ -0,0 +1,9 @@
1
+ import { userInfo } from 'os';
2
+
3
+ // #429(Owner 拍板 3):CLI 发放族自报操作者——informational,billing 信任 allowlist client
4
+ // 自述(actor 不参与授权);不带 --operator 时回退本机用户名。缺省链最终兜底在 billing
5
+ // 侧(channel=client_id)。格式:dev-skills:<name>。
6
+ export function operatorActorId(operatorFlag?: string | null): string {
7
+ const name = (operatorFlag ?? '').trim() || userInfo().username;
8
+ return `dev-skills:${name}`;
9
+ }