@remixmate/cli 0.9.14 → 0.9.15

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.
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Billing capture — makes credit consumption visible to whoever ran the skill.
3
+ *
4
+ * Credits are deducted server-side and used to be invisible here: a run printed
5
+ * its image/video URL and nothing else, so the first time a user noticed the
6
+ * credit system at all was the `insufficient_credits` error after the balance
7
+ * had already run out. ab-api now attaches a `billing` object to the envelope of
8
+ * every response that charged something (see core.Success / credits_billing.go);
9
+ * this module accumulates those across all the calls a single skill run makes —
10
+ * a gen-image run polls, a gen-video run polls, and each may charge once — and
11
+ * renders one footer at the end.
12
+ *
13
+ * Two outputs, two audiences, both on stdout:
14
+ * - a human-readable footer, which is what the agent relays to the user.
15
+ * - one `__progress__` line (phase `billing`) — the existing machine protocol,
16
+ * already parsed by ab-agent's executor and skipped by the line-scanning
17
+ * parsers in render_video.py, so a nested pipeline caller can aggregate a
18
+ * whole run's spend without a new stdout contract.
19
+ *
20
+ * Both go to stdout rather than stderr (where the auth footer lives) because
21
+ * ab-agent hands the LLM `result.stdout || result.stderr` (mcp-tools.ts) — a
22
+ * stderr-only footer would be invisible in the hosted agent, visible only when
23
+ * a local host like codex shows both streams. Adding stdout lines is safe:
24
+ * emitProgress already writes NDJSON there, so no consumer can be treating
25
+ * stdout as a single JSON document.
26
+ */
27
+ export interface BillingItem {
28
+ credits: number;
29
+ bizType?: string;
30
+ detail?: string;
31
+ }
32
+ /** The `billing` object ab-api attaches to a charged response. */
33
+ export interface Billing {
34
+ credits: number;
35
+ balance: number;
36
+ items?: BillingItem[];
37
+ }
38
+ /** Accumulate one response's billing object; no-ops on responses that charged nothing. */
39
+ export declare function recordBilling(billing: Billing | undefined | null): void;
40
+ /** Everything charged so far in this process, or null when nothing was. */
41
+ export declare function billingSummary(): Billing | null;
42
+ export declare function emitBillingFooter(): void;
43
+ /** Test seam — resets the accumulator between runs in-process. */
44
+ export declare function resetBilling(): void;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Billing capture — makes credit consumption visible to whoever ran the skill.
3
+ *
4
+ * Credits are deducted server-side and used to be invisible here: a run printed
5
+ * its image/video URL and nothing else, so the first time a user noticed the
6
+ * credit system at all was the `insufficient_credits` error after the balance
7
+ * had already run out. ab-api now attaches a `billing` object to the envelope of
8
+ * every response that charged something (see core.Success / credits_billing.go);
9
+ * this module accumulates those across all the calls a single skill run makes —
10
+ * a gen-image run polls, a gen-video run polls, and each may charge once — and
11
+ * renders one footer at the end.
12
+ *
13
+ * Two outputs, two audiences, both on stdout:
14
+ * - a human-readable footer, which is what the agent relays to the user.
15
+ * - one `__progress__` line (phase `billing`) — the existing machine protocol,
16
+ * already parsed by ab-agent's executor and skipped by the line-scanning
17
+ * parsers in render_video.py, so a nested pipeline caller can aggregate a
18
+ * whole run's spend without a new stdout contract.
19
+ *
20
+ * Both go to stdout rather than stderr (where the auth footer lives) because
21
+ * ab-agent hands the LLM `result.stdout || result.stderr` (mcp-tools.ts) — a
22
+ * stderr-only footer would be invisible in the hosted agent, visible only when
23
+ * a local host like codex shows both streams. Adding stdout lines is safe:
24
+ * emitProgress already writes NDJSON there, so no consumer can be treating
25
+ * stdout as a single JSON document.
26
+ */
27
+ import { emitProgress } from './progress.js';
28
+ let creditsCharged = 0;
29
+ let latestBalance = null;
30
+ const chargedItems = [];
31
+ /** Accumulate one response's billing object; no-ops on responses that charged nothing. */
32
+ export function recordBilling(billing) {
33
+ if (!billing || typeof billing.credits !== 'number' || billing.credits <= 0)
34
+ return;
35
+ creditsCharged += billing.credits;
36
+ if (typeof billing.balance === 'number')
37
+ latestBalance = billing.balance;
38
+ for (const item of billing.items ?? []) {
39
+ if (item && typeof item.credits === 'number' && item.credits > 0)
40
+ chargedItems.push(item);
41
+ }
42
+ }
43
+ /** Everything charged so far in this process, or null when nothing was. */
44
+ export function billingSummary() {
45
+ if (creditsCharged <= 0)
46
+ return null;
47
+ return { credits: creditsCharged, balance: latestBalance ?? 0, items: [...chargedItems] };
48
+ }
49
+ /**
50
+ * Emit the footer for what this process charged. Safe to call on the error path
51
+ * too: a run can fail after a successful (already billed) generation step, and
52
+ * that spend still needs to be reported.
53
+ *
54
+ * Idempotent — only the first call prints, so a handler and the dispatcher can
55
+ * both reach for it without doubling the number the user sees.
56
+ */
57
+ let emitted = false;
58
+ export function emitBillingFooter() {
59
+ if (emitted)
60
+ return;
61
+ const summary = billingSummary();
62
+ if (!summary)
63
+ return;
64
+ emitted = true;
65
+ emitProgress({ phase: 'billing', credits: summary.credits, balance: summary.balance, items: summary.items });
66
+ const balance = summary.balance.toLocaleString('en-US');
67
+ const credits = summary.credits.toLocaleString('en-US');
68
+ process.stdout.write(`\n💳 Charged ${credits} credits · balance ${balance}\n`);
69
+ }
70
+ /** Test seam — resets the accumulator between runs in-process. */
71
+ export function resetBilling() {
72
+ creditsCharged = 0;
73
+ latestBalance = null;
74
+ chargedItems.length = 0;
75
+ emitted = false;
76
+ }
package/dist/http.d.ts CHANGED
@@ -18,6 +18,7 @@
18
18
  * 3. process.env.MM_BACKEND_API_URL — ab-agent's convention (back-compat)
19
19
  * 4. https://api.remixmate.com/api — production default (zero-config)
20
20
  */
21
+ import { type Billing } from './billing.js';
21
22
  export { SkillError, EXIT } from './errors.js';
22
23
  /**
23
24
  * Resolve the ab-api base URL the same way for authenticated and device-flow calls.
@@ -49,6 +50,8 @@ export interface MmResponse<T = unknown> {
49
50
  code: number;
50
51
  msg?: string;
51
52
  data?: T;
53
+ /** Present only on responses that charged credits — see billing.ts. */
54
+ billing?: Billing;
52
55
  }
53
56
  /**
54
57
  * POST JSON to ab-api and return the parsed business payload.
package/dist/http.js CHANGED
@@ -18,6 +18,7 @@
18
18
  * 3. process.env.MM_BACKEND_API_URL — ab-agent's convention (back-compat)
19
19
  * 4. https://api.remixmate.com/api — production default (zero-config)
20
20
  */
21
+ import { recordBilling } from './billing.js';
21
22
  import { resolvePrivToken, NOT_AUTHENTICATED_HINT } from './auth/resolve.js';
22
23
  import { attemptAutoLogin } from './auth/auto-login.js';
23
24
  import { EXIT, SkillError } from './errors.js';
@@ -120,6 +121,9 @@ export async function mmPost(ctx, pathOrUrl, body, opts = {}) {
120
121
  catch {
121
122
  throw new SkillError(`❌ failed to parse response, body is not JSON: ${text.slice(0, 200)}`);
122
123
  }
124
+ // Before the code check: a charged response is always code=0 today, but a
125
+ // partial-failure envelope that still billed must not lose its billing line.
126
+ recordBilling(parsed.billing);
123
127
  if (parsed.code !== 0) {
124
128
  // ab-api reports auth failures in the envelope (HTTP 200 + code=401), so the
125
129
  // business-code path needs the same 401 → "re-authorize" mapping as above.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.9.14",
4
- "generatedAt": "2026-08-12T07:37:21.011Z",
3
+ "version": "0.9.15",
4
+ "generatedAt": "2026-08-14T08:23:17.130Z",
5
5
  "skills": [
6
6
  {
7
7
  "id": "export-jianying",
package/dist/runner.js CHANGED
@@ -15,6 +15,7 @@ import { findSkill, SKILLS_DIR } from './registry.js';
15
15
  import { HANDLERS } from './handlers/index.js';
16
16
  import { EXIT, SkillError } from './errors.js';
17
17
  import { authChildEnv, ensureAuth } from './auth/ensure.js';
18
+ import { emitBillingFooter } from './billing.js';
18
19
  /** Read the token override accepted by both the TS handlers and the Python skills. */
19
20
  function tokenFlag(args) {
20
21
  const value = args.token ?? args.priv_token;
@@ -34,6 +35,8 @@ export async function runSkill(skillName, opts) {
34
35
  });
35
36
  switch (skill.entry.type) {
36
37
  case 'python':
38
+ // Python children talk to ab-api themselves and print their own billing
39
+ // footer; nothing was charged through this process.
37
40
  return await runPython(skill, opts.rawArgs, auth);
38
41
  case 'http':
39
42
  case 'builtin':
@@ -48,6 +51,12 @@ export async function runSkill(skillName, opts) {
48
51
  process.stderr.write(`❌ unexpected error: ${err.message}\n`);
49
52
  return EXIT.ERROR;
50
53
  }
54
+ finally {
55
+ // In `finally` because a run can fail *after* a billed step (e.g. the image
56
+ // generated and was charged, then the upload timed out) — spend gets
57
+ // reported either way. No-ops when nothing was charged.
58
+ emitBillingFooter();
59
+ }
51
60
  }
52
61
  async function runPython(skill, rawArgs, auth) {
53
62
  if (!skill.scriptAbsolutePath) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remixmate/cli",
3
- "version": "0.9.14",
3
+ "version": "0.9.15",
4
4
  "description": "AI media generation skills for Claude Code / Codex — 12 skills covering image, video, voice, digital human, web screenshot, web recording, script, template registry, rendering, Jianying export, and video deconstruction.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -175,6 +175,18 @@ remixmate gen-digital-human --check-status --generation-id 123
175
175
  - Keep individual jobs under ~500 characters.
176
176
  - Tone and style of the script affect the perceived voice.
177
177
 
178
+ ## Credits
179
+
180
+ Every run charges credits. The CLI prints a footer on stdout when it does:
181
+
182
+ ```
183
+ 💳 Charged 31 credits · balance 1,240
184
+ ```
185
+
186
+ Relay it to the user whenever it appears — it is the only signal they get about what a
187
+ generation cost, and the balance is the only warning before a run fails with
188
+ `insufficient_credits`. Do not drop it from your summary.
189
+
178
190
  ## Error handling
179
191
 
180
192
  - **401** / **token missing** (non-OpenClaw): set `PRIV_TOKEN`.
@@ -122,6 +122,18 @@ remixmate gen-image \
122
122
  | `--api-base-url` | Override API root | see above |
123
123
  | `--priv-token` | Override token | see above |
124
124
 
125
+ ## Credits
126
+
127
+ Every run charges credits. The CLI prints a footer on stdout when it does:
128
+
129
+ ```
130
+ 💳 Charged 31 credits · balance 1,240
131
+ ```
132
+
133
+ Relay it to the user whenever it appears — it is the only signal they get about what a
134
+ generation cost, and the balance is the only warning before a run fails with
135
+ `insufficient_credits`. Do not drop it from your summary.
136
+
125
137
  ## Error handling
126
138
 
127
139
  - **401** / **token missing** (non-OpenClaw): set `PRIV_TOKEN`.
@@ -150,6 +150,18 @@ remixmate gen-video \
150
150
  - Describe motion explicitly.
151
151
  - Example: `"At sunrise, an aerial drone shot of a futuristic city, golden light on glass facades, mist swirling, 4K ultra-clear"`.
152
152
 
153
+ ## Credits
154
+
155
+ Every run charges credits. The CLI prints a footer on stdout when it does:
156
+
157
+ ```
158
+ 💳 Charged 31 credits · balance 1,240
159
+ ```
160
+
161
+ Relay it to the user whenever it appears — it is the only signal they get about what a
162
+ generation cost, and the balance is the only warning before a run fails with
163
+ `insufficient_credits`. Do not drop it from your summary.
164
+
153
165
  ## Error handling
154
166
 
155
167
  - **401** / **token missing** (non-OpenClaw): set `PRIV_TOKEN`.
@@ -95,6 +95,18 @@ remixmate gen-voice --text "<text-to-synthesize>" --json-output
95
95
  | Voice-over / narration | 1.0–1.2 |
96
96
  | Fast announcement | 1.2–1.5 |
97
97
 
98
+ ## Credits
99
+
100
+ Every run charges credits. The CLI prints a footer on stdout when it does:
101
+
102
+ ```
103
+ 💳 Charged 31 credits · balance 1,240
104
+ ```
105
+
106
+ Relay it to the user whenever it appears — it is the only signal they get about what a
107
+ generation cost, and the balance is the only warning before a run fails with
108
+ `insufficient_credits`. Do not drop it from your summary.
109
+
98
110
  ## Error handling
99
111
 
100
112
  - **401** / **token missing** (non-OpenClaw): set `PRIV_TOKEN`.
@@ -195,6 +195,18 @@ The Asset Resolver handles each `AssetRef` in this order:
195
195
  4. **Parallel generation**: assets of the same type are generated in parallel; different types are sequenced by dependency.
196
196
  5. **Cache reuse**: assets with the same payload are checked against `asset-cache-dir` to avoid duplicate generation.
197
197
 
198
+ ## Credits
199
+
200
+ Every run charges credits. The CLI prints a footer on stdout when it does:
201
+
202
+ ```
203
+ 💳 Charged 31 credits · balance 1,240
204
+ ```
205
+
206
+ Relay it to the user whenever it appears — it is the only signal they get about what a
207
+ generation cost, and the balance is the only warning before a run fails with
208
+ `insufficient_credits`. Do not drop it from your summary.
209
+
198
210
  ## Error handling
199
211
 
200
212
  - **DSL validation failed**: pre-validate with `gen-script --validate`.
@@ -210,6 +210,18 @@ The Remotion render project lives at the monorepo root under `<monorepo-root>/re
210
210
  - Production (default): the client uses `https://api-render.remixmate.com` when `REMOTION_RENDER_API_URL` is unset — no config needed.
211
211
  - Local debugging: in the renderer directory run `npm install && npm run server:dev`, then point the client at it with `REMOTION_RENDER_API_URL=http://localhost:3000`.
212
212
 
213
+ ## Credits
214
+
215
+ Every run charges credits. The CLI prints a footer on stdout when it does:
216
+
217
+ ```
218
+ 💳 Charged 31 credits · balance 1,240
219
+ ```
220
+
221
+ Relay it to the user whenever it appears — it is the only signal they get about what a
222
+ generation cost, and the balance is the only warning before a run fails with
223
+ `insufficient_credits`. Do not drop it from your summary.
224
+
213
225
  ## Error handling
214
226
 
215
227
  - **`job_id` missing or not assets-ready**: the script exits non-zero with a message like "render job N is not in assets-ready state". The agent should call `prepare_video_assets` again with the updated DSL to regenerate, then re-call this skill with the new `job_id`.
@@ -24,6 +24,7 @@
24
24
  """
25
25
 
26
26
  import argparse
27
+ import atexit
27
28
  import builtins
28
29
  import json
29
30
  import os
@@ -356,6 +357,62 @@ def LogPrint(*args, sep=" ", end="\n", file=None, flush=False):
356
357
  builtins.print(f"[{stamp}] {message}", end=end, file=file, flush=flush)
357
358
 
358
359
 
360
+ # ─── 扣费汇总 ────────────────────────────────────────────────────────────────
361
+ # 一条视频管线的花费分散在多处:每个 gen-image / gen-voice / gen-video /
362
+ # gen-digital-human 子进程各扣一次,渲染再通过 saveManifest 扣一次。子进程是
363
+ # capture_output 起的,它们自己打的 💳 页脚会被吞掉,所以这里把子进程 stdout 里的
364
+ # billing 事件抓出来累加,最后统一报一次总账——否则用户跑完一整条管线,只知道视频
365
+ # 好了,不知道花了多少积分。
366
+ _BILLING: dict = {"credits": 0, "balance": None}
367
+
368
+
369
+ def absorb_child_billing(stdout: str) -> None:
370
+ """从子 skill 的 stdout 里收集 `__progress__` billing 事件(见 CLI src/billing.ts)。"""
371
+ for line in (stdout or "").splitlines():
372
+ line = line.strip()
373
+ if not line.startswith("{") or "__progress__" not in line:
374
+ continue
375
+ try:
376
+ event = json.loads(line)
377
+ except json.JSONDecodeError:
378
+ continue
379
+ if not isinstance(event, dict) or event.get("phase") != "billing":
380
+ continue
381
+ credits = event.get("credits")
382
+ if not isinstance(credits, (int, float)) or credits <= 0:
383
+ continue
384
+ _BILLING["credits"] += int(credits)
385
+ balance = event.get("balance")
386
+ if isinstance(balance, (int, float)):
387
+ _BILLING["balance"] = int(balance)
388
+
389
+
390
+ def print_billing_footer() -> None:
391
+ """收尾时报一次总账。通过 atexit 注册,所以 sys.exit / 中途失败也会打印
392
+ ——已经发生的扣费不该因为后面某一步失败就不告知用户。
393
+
394
+ 走 stdout 而不是 stderr:ab-agent 交给 LLM 的是 `result.stdout || result.stderr`
395
+ (mcp-tools.ts),只写 stderr 在托管 agent 里等于不可见。
396
+ """
397
+ credits = _BILLING["credits"]
398
+ balance = _BILLING["balance"]
399
+ try:
400
+ import render_job_client # type: ignore
401
+
402
+ job_billing = render_job_client.billing_summary()
403
+ if job_billing.get("credits"):
404
+ credits += int(job_billing["credits"])
405
+ # saveManifest(渲染扣费)在管线里排最后,它带回的余额最新。
406
+ if job_billing.get("balance") is not None:
407
+ balance = int(job_billing["balance"])
408
+ except Exception: # noqa: BLE001 — 报账失败绝不能影响已完成的渲染
409
+ pass
410
+ if credits <= 0:
411
+ return
412
+ suffix = f" · balance {balance:,}" if balance is not None else ""
413
+ builtins.print(f"\n💳 Charged {credits:,} credits{suffix}", flush=True)
414
+
415
+
359
416
  def sync_chrome_headless_vendor(renderer_dir: str, render_plan: dict) -> None:
360
417
  """Chrome Headless vendor 同步——实现已抽到独立模块 ``_chrome_vendor``。
361
418
 
@@ -758,6 +815,7 @@ def resolve_asset_image(asset: dict, private_token: str, timeout: int) -> dict:
758
815
 
759
816
  try:
760
817
  result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, stdin=subprocess.DEVNULL)
818
+ absorb_child_billing(result.stdout)
761
819
  if result.returncode == 0:
762
820
  for line in reversed(result.stdout.strip().split("\n")):
763
821
  line = line.strip()
@@ -809,6 +867,7 @@ def resolve_asset_audio(asset: dict, private_token: str, timeout: int) -> dict:
809
867
 
810
868
  try:
811
869
  result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, stdin=subprocess.DEVNULL)
870
+ absorb_child_billing(result.stdout)
812
871
  if result.returncode == 0:
813
872
  for line in reversed(result.stdout.strip().split("\n")):
814
873
  line = line.strip()
@@ -854,6 +913,7 @@ def resolve_asset_video(asset: dict, private_token: str, timeout: int) -> dict:
854
913
 
855
914
  try:
856
915
  result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, stdin=subprocess.DEVNULL)
916
+ absorb_child_billing(result.stdout)
857
917
  if result.returncode == 0:
858
918
  for line in reversed(result.stdout.strip().split("\n")):
859
919
  line = line.strip()
@@ -893,6 +953,7 @@ def resolve_asset_digital_human(asset: dict, private_token: str, timeout: int) -
893
953
  dh_timeout = max(timeout, 660)
894
954
  try:
895
955
  result = subprocess.run(cmd, capture_output=True, text=True, timeout=dh_timeout, stdin=subprocess.DEVNULL)
956
+ absorb_child_billing(result.stdout)
896
957
  if result.returncode == 0:
897
958
  for line in reversed(result.stdout.strip().split("\n")):
898
959
  line = line.strip()
@@ -1892,6 +1953,8 @@ def _log_render_plan_summary(render_plan: dict) -> None:
1892
1953
 
1893
1954
 
1894
1955
  def main():
1956
+ # 无论正常结束、sys.exit 还是中途失败,都在最后报一次积分账(无扣费时静默)。
1957
+ atexit.register(print_billing_footer)
1895
1958
  parser = argparse.ArgumentParser(
1896
1959
  description="Video render tool — DSL + TemplateBinding → Remotion video.",
1897
1960
  formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -37,6 +37,31 @@ def _make_headers(priv_token: str) -> dict:
37
37
  return headers
38
38
 
39
39
 
40
+ # ─── 扣费回传 ────────────────────────────────────────────────────────────────
41
+ # ab-api 会在扣了积分的成功响应上挂 billing 字段(见 core.Success)。渲染积分是在
42
+ # /renderJob/saveManifest 里扣的,所以这里是渲染花费唯一的可见入口——累加起来交给
43
+ # render_video.py 在收尾时统一告知用户。
44
+ _BILLING: dict = {"credits": 0, "balance": None}
45
+
46
+
47
+ def _record_billing(result: dict) -> None:
48
+ billing = result.get("billing")
49
+ if not isinstance(billing, dict):
50
+ return
51
+ credits = billing.get("credits")
52
+ if not isinstance(credits, (int, float)) or credits <= 0:
53
+ return
54
+ _BILLING["credits"] += int(credits)
55
+ balance = billing.get("balance")
56
+ if isinstance(balance, (int, float)):
57
+ _BILLING["balance"] = int(balance)
58
+
59
+
60
+ def billing_summary() -> dict:
61
+ """本进程通过本模块产生的累计扣费:{"credits": int, "balance": int|None}。"""
62
+ return dict(_BILLING)
63
+
64
+
40
65
  def _post(path: str, payload: dict, priv_token: str, timeout: int = 30) -> dict:
41
66
  """发送 POST 请求并返回响应 data 字段;失败时抛出 RuntimeError。"""
42
67
  url = f"{_api_base()}{path}"
@@ -50,6 +75,8 @@ def _post(path: str, payload: dict, priv_token: str, timeout: int = 30) -> dict:
50
75
  except urllib.error.URLError as e:
51
76
  raise RuntimeError(f"network request failed ({path}): {e.reason}") from e
52
77
 
78
+ _record_billing(result)
79
+
53
80
  if result.get("code") != 0:
54
81
  msg = result.get("msg") or result.get("message") or "unknown error"
55
82
  raise RuntimeError(f"API returned an error [{path}]: {msg}")