@remixmate/cli 0.9.13 → 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.
Files changed (63) hide show
  1. package/README.md +54 -1
  2. package/README.zh-CN.md +37 -1
  3. package/dist/auth/auto-login.js +10 -1
  4. package/dist/auth/commands.js +181 -21
  5. package/dist/auth/credential-store.d.ts +16 -0
  6. package/dist/auth/credential-store.js +17 -0
  7. package/dist/auth/device-flow-runner.d.ts +15 -0
  8. package/dist/auth/device-flow-runner.js +60 -12
  9. package/dist/auth/device-flow.js +24 -7
  10. package/dist/auth/ensure.d.ts +55 -0
  11. package/dist/auth/ensure.js +62 -0
  12. package/dist/auth/environment.d.ts +13 -0
  13. package/dist/auth/environment.js +21 -0
  14. package/dist/auth/pending-store.d.ts +33 -0
  15. package/dist/auth/pending-store.js +45 -0
  16. package/dist/billing.d.ts +44 -0
  17. package/dist/billing.js +76 -0
  18. package/dist/cli.js +75 -8
  19. package/dist/doctor.d.ts +22 -0
  20. package/dist/doctor.js +142 -0
  21. package/dist/errors.d.ts +29 -0
  22. package/dist/errors.js +33 -0
  23. package/dist/exec.d.ts +18 -0
  24. package/dist/exec.js +47 -0
  25. package/dist/handlers/gen-digital-human.js +1 -0
  26. package/dist/handlers/gen-image.js +1 -0
  27. package/dist/handlers/gen-video.js +1 -0
  28. package/dist/handlers/gen-voice.js +2 -0
  29. package/dist/handlers/index.d.ts +7 -0
  30. package/dist/http.d.ts +20 -6
  31. package/dist/http.js +43 -15
  32. package/dist/manifest.json +2 -2
  33. package/dist/registry.d.ts +4 -2
  34. package/dist/registry.js +2 -1
  35. package/dist/runner.d.ts +4 -0
  36. package/dist/runner.js +36 -12
  37. package/dist/skill-schema.d.ts +19 -0
  38. package/dist/skill-schema.js +18 -0
  39. package/dist/text.d.ts +8 -0
  40. package/dist/text.js +15 -0
  41. package/package.json +2 -2
  42. package/skills/export-jianying/skill.json +1 -0
  43. package/skills/gen-digital-human/SKILL.md +12 -0
  44. package/skills/gen-digital-human/skill.json +1 -0
  45. package/skills/gen-image/SKILL.md +12 -0
  46. package/skills/gen-image/skill.json +1 -0
  47. package/skills/gen-script/skill.json +1 -0
  48. package/skills/gen-video/SKILL.md +12 -0
  49. package/skills/gen-video/skill.json +1 -0
  50. package/skills/gen-voice/SKILL.md +12 -0
  51. package/skills/gen-voice/skill.json +1 -0
  52. package/skills/prepare-video-assets/SKILL.md +12 -0
  53. package/skills/prepare-video-assets/skill.json +1 -0
  54. package/skills/render-video/SKILL.md +12 -0
  55. package/skills/render-video/scripts/render_video.py +63 -0
  56. package/skills/render-video/skill.json +1 -0
  57. package/skills/template-registry/scripts/list_templates.py +16 -1
  58. package/skills/template-registry/scripts/registry_loader.py +63 -63
  59. package/skills/template-registry/scripts/render_job_client.py +27 -0
  60. package/skills/template-registry/skill.json +1 -0
  61. package/skills/video-parser/skill.json +1 -0
  62. package/skills/web-record/skill.json +1 -0
  63. package/skills/web-screenshot/skill.json +1 -0
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Process-wide error type and exit-code contract.
3
+ *
4
+ * Extracted from http.ts so the auth gateway (auth/ensure.ts) can throw the same
5
+ * error type without creating an import cycle (http.ts → ensure.ts → http.ts).
6
+ * `SkillError` is still re-exported from http.ts, so existing imports are unchanged.
7
+ *
8
+ * Exit codes are a CONTRACT with hosts (Claude Code / Codex / ab-agent): an agent
9
+ * reads the code to decide whether to guide the user through authorization, retry
10
+ * later, or surface a hard failure. Never reuse a code for a different meaning.
11
+ */
12
+ export declare const EXIT: {
13
+ /** Success. */
14
+ readonly OK: 0;
15
+ /** Generic runtime failure. */
16
+ readonly ERROR: 1;
17
+ /** Usage error — unknown skill / unknown option. */
18
+ readonly USAGE: 2;
19
+ /** No usable credential, or the credential was rejected. Host should offer to authorize. */
20
+ readonly NOT_AUTHENTICATED: 4;
21
+ /** Backend unreachable / timed out. Distinct from 4 so "log in" isn't suggested for an outage. */
22
+ readonly BACKEND_UNREACHABLE: 5;
23
+ /** Failed to spawn the underlying interpreter. */
24
+ readonly SPAWN_FAILED: 127;
25
+ };
26
+ export declare class SkillError extends Error {
27
+ readonly exitCode: number;
28
+ constructor(message: string, exitCode?: number);
29
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Process-wide error type and exit-code contract.
3
+ *
4
+ * Extracted from http.ts so the auth gateway (auth/ensure.ts) can throw the same
5
+ * error type without creating an import cycle (http.ts → ensure.ts → http.ts).
6
+ * `SkillError` is still re-exported from http.ts, so existing imports are unchanged.
7
+ *
8
+ * Exit codes are a CONTRACT with hosts (Claude Code / Codex / ab-agent): an agent
9
+ * reads the code to decide whether to guide the user through authorization, retry
10
+ * later, or surface a hard failure. Never reuse a code for a different meaning.
11
+ */
12
+ export const EXIT = {
13
+ /** Success. */
14
+ OK: 0,
15
+ /** Generic runtime failure. */
16
+ ERROR: 1,
17
+ /** Usage error — unknown skill / unknown option. */
18
+ USAGE: 2,
19
+ /** No usable credential, or the credential was rejected. Host should offer to authorize. */
20
+ NOT_AUTHENTICATED: 4,
21
+ /** Backend unreachable / timed out. Distinct from 4 so "log in" isn't suggested for an outage. */
22
+ BACKEND_UNREACHABLE: 5,
23
+ /** Failed to spawn the underlying interpreter. */
24
+ SPAWN_FAILED: 127,
25
+ };
26
+ export class SkillError extends Error {
27
+ exitCode;
28
+ constructor(message, exitCode = EXIT.ERROR) {
29
+ super(message);
30
+ this.exitCode = exitCode;
31
+ this.name = 'SkillError';
32
+ }
33
+ }
package/dist/exec.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `remixmate exec -- <command> [args...]`
3
+ *
4
+ * Runs an arbitrary command with the resolved credential injected into its
5
+ * environment (`PRIV_TOKEN` + `MM_API_BASE_URL`), using the same gateway that
6
+ * skill dispatch uses.
7
+ *
8
+ * This is the general form of the invariant the auth rework establishes: Node
9
+ * is the only thing that reads the credential store / keychain / device flow,
10
+ * and every child process receives the result through its environment. Without
11
+ * it, maintenance scripts that import `registry_loader` directly (e.g.
12
+ * scripts/test-template-pipeline.py) would have to re-implement credential
13
+ * lookup — which is exactly the duplication, and the cross-origin token leak,
14
+ * that was just removed.
15
+ *
16
+ * The token is never printed; it only ever reaches the child's environment.
17
+ */
18
+ export declare function runExec(argv: string[]): Promise<number>;
package/dist/exec.js ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * `remixmate exec -- <command> [args...]`
3
+ *
4
+ * Runs an arbitrary command with the resolved credential injected into its
5
+ * environment (`PRIV_TOKEN` + `MM_API_BASE_URL`), using the same gateway that
6
+ * skill dispatch uses.
7
+ *
8
+ * This is the general form of the invariant the auth rework establishes: Node
9
+ * is the only thing that reads the credential store / keychain / device flow,
10
+ * and every child process receives the result through its environment. Without
11
+ * it, maintenance scripts that import `registry_loader` directly (e.g.
12
+ * scripts/test-template-pipeline.py) would have to re-implement credential
13
+ * lookup — which is exactly the duplication, and the cross-origin token leak,
14
+ * that was just removed.
15
+ *
16
+ * The token is never printed; it only ever reaches the child's environment.
17
+ */
18
+ import { spawn } from 'node:child_process';
19
+ import { parseArgv } from './argv.js';
20
+ import { EXIT } from './errors.js';
21
+ import { authChildEnv, ensureAuth } from './auth/ensure.js';
22
+ const USAGE = 'Usage: remixmate exec [--api-base-url URL] [--token TOKEN] -- <command> [args...]\n';
23
+ export async function runExec(argv) {
24
+ const separator = argv.indexOf('--');
25
+ if (separator === -1 || separator === argv.length - 1) {
26
+ process.stderr.write(`❌ exec 需要一个 \`--\` 分隔符与要执行的命令。\n${USAGE}`);
27
+ return EXIT.USAGE;
28
+ }
29
+ const flags = parseArgv(argv.slice(0, separator));
30
+ const [command, ...args] = argv.slice(separator + 1);
31
+ const auth = await ensureAuth({
32
+ mode: 'required',
33
+ apiBaseUrl: typeof flags.api_base_url === 'string' ? flags.api_base_url : undefined,
34
+ flagToken: typeof flags.token === 'string' ? flags.token : undefined,
35
+ });
36
+ return await new Promise((resolve) => {
37
+ const proc = spawn(command, args, {
38
+ stdio: 'inherit',
39
+ env: { ...process.env, ...authChildEnv(auth) },
40
+ });
41
+ proc.on('exit', (code, signal) => resolve(signal ? EXIT.ERROR : code ?? EXIT.ERROR));
42
+ proc.on('error', (err) => {
43
+ process.stderr.write(`❌ failed to spawn ${command}: ${err.message}\n`);
44
+ resolve(EXIT.SPAWN_FAILED);
45
+ });
46
+ });
47
+ }
@@ -91,6 +91,7 @@ export async function genDigitalHuman(input, ctxIn) {
91
91
  const ctx = await resolveHttpContext(ctxIn.skillName, {
92
92
  apiBaseUrl: input.api_base_url,
93
93
  privateToken: input.priv_token,
94
+ preflightToken: ctxIn.auth?.token,
94
95
  });
95
96
  // ── Mode 1: list avatars ────────────────────────────────────────────────
96
97
  if (isTrue(input.list_avatars)) {
@@ -90,6 +90,7 @@ export async function genImage(input, ctxIn) {
90
90
  const ctx = await resolveHttpContext(ctxIn.skillName, {
91
91
  apiBaseUrl,
92
92
  privateToken: input.priv_token,
93
+ preflightToken: ctxIn.auth?.token,
93
94
  });
94
95
  emitProgress({ phase: 'gen-image:request', model, size, n });
95
96
  const data = await mmPost(ctx, '/model/genImg', payload, { timeoutMs: 120_000 });
@@ -85,6 +85,7 @@ export async function genVideo(input, ctxIn) {
85
85
  const ctx = await resolveHttpContext(ctxIn.skillName, {
86
86
  apiBaseUrl,
87
87
  privateToken: input.priv_token,
88
+ preflightToken: ctxIn.auth?.token,
88
89
  });
89
90
  const payload = {
90
91
  model,
@@ -56,6 +56,7 @@ export async function genVoice(input, ctxIn) {
56
56
  const ctx = await resolveHttpContext(ctxIn.skillName, {
57
57
  apiBaseUrl: input.api_base_url,
58
58
  privateToken: input.priv_token,
59
+ preflightToken: ctxIn.auth?.token,
59
60
  });
60
61
  return listVoicesRemote(ctx);
61
62
  }
@@ -74,6 +75,7 @@ export async function genVoice(input, ctxIn) {
74
75
  const ctx = await resolveHttpContext(ctxIn.skillName, {
75
76
  apiBaseUrl,
76
77
  privateToken: input.priv_token,
78
+ preflightToken: ctxIn.auth?.token,
77
79
  });
78
80
  emitProgress({ phase: 'gen-voice:request', voiceId, speed });
79
81
  const data = await mmPost(ctx, '/tool/tts', { provider: voice.provider, text, voiceSetting: { voiceId, speed }, outputFormat: 'url' }, { timeoutMs: 120_000 });
@@ -4,10 +4,17 @@
4
4
  * Only `entry.type` in ('http' | 'builtin') uses this registry; python
5
5
  * skills go through spawn() in runner.ts and bypass handlers entirely.
6
6
  */
7
+ import type { AuthPreflight } from '../auth/ensure.js';
7
8
  export type HandlerInput = Record<string, unknown>;
8
9
  export interface HandlerContext {
9
10
  /** ISO-style skill name from the registry (e.g. `gen-image`). Used for x-invoke-skill. */
10
11
  skillName: string;
12
+ /**
13
+ * Credential resolved by the dispatcher's preflight (runner.ts). Handlers pass
14
+ * `auth.token` into resolveHttpContext so the device flow can't run twice for
15
+ * a single invocation.
16
+ */
17
+ auth?: AuthPreflight;
11
18
  }
12
19
  export type SkillHandler = (input: HandlerInput, ctx: HandlerContext) => Promise<void>;
13
20
  export declare const HANDLERS: Record<string, SkillHandler>;
package/dist/http.d.ts CHANGED
@@ -15,13 +15,19 @@
15
15
  * Base URL resolution:
16
16
  * 1. opts.apiBaseUrl — caller override
17
17
  * 2. process.env.MM_API_BASE_URL — process env (point at local/staging here)
18
- * 3. https://api.remixmate.com/api production default (zero-config)
18
+ * 3. process.env.MM_BACKEND_API_URL ab-agent's convention (back-compat)
19
+ * 4. https://api.remixmate.com/api — production default (zero-config)
20
+ */
21
+ import { type Billing } from './billing.js';
22
+ export { SkillError, EXIT } from './errors.js';
23
+ /**
24
+ * Resolve the ab-api base URL the same way for authenticated and device-flow calls.
25
+ *
26
+ * `MM_BACKEND_API_URL` is honored because several Python skills already fall back
27
+ * to it (registry_loader, render_job_client, _vod_polling). Since runner.ts now
28
+ * injects the resolved base URL into those child processes, leaving it out here
29
+ * would let a prod default silently override an ab-agent-injected backend.
19
30
  */
20
- export declare class SkillError extends Error {
21
- readonly exitCode: number;
22
- constructor(message: string, exitCode?: number);
23
- }
24
- /** Resolve the ab-api base URL the same way for authenticated and device-flow calls. */
25
31
  export declare function resolveApiBaseUrl(flag?: string): string;
26
32
  export interface HttpContext {
27
33
  apiBaseUrl: string;
@@ -33,11 +39,19 @@ export interface HttpContext {
33
39
  export declare function resolveHttpContext(skillName: string, opts?: {
34
40
  apiBaseUrl?: string;
35
41
  privateToken?: string;
42
+ /**
43
+ * Token already resolved by the dispatcher's auth preflight (runner.ts).
44
+ * Reusing it avoids a second credential-store read — and, more importantly,
45
+ * a second device-flow attempt — per skill invocation.
46
+ */
47
+ preflightToken?: string;
36
48
  }): Promise<HttpContext>;
37
49
  export interface MmResponse<T = unknown> {
38
50
  code: number;
39
51
  msg?: string;
40
52
  data?: T;
53
+ /** Present only on responses that charged credits — see billing.ts. */
54
+ billing?: Billing;
41
55
  }
42
56
  /**
43
57
  * POST JSON to ab-api and return the parsed business payload.
package/dist/http.js CHANGED
@@ -15,26 +15,37 @@
15
15
  * Base URL resolution:
16
16
  * 1. opts.apiBaseUrl — caller override
17
17
  * 2. process.env.MM_API_BASE_URL — process env (point at local/staging here)
18
- * 3. https://api.remixmate.com/api production default (zero-config)
18
+ * 3. process.env.MM_BACKEND_API_URL ab-agent's convention (back-compat)
19
+ * 4. https://api.remixmate.com/api — production default (zero-config)
19
20
  */
21
+ import { recordBilling } from './billing.js';
20
22
  import { resolvePrivToken, NOT_AUTHENTICATED_HINT } from './auth/resolve.js';
21
23
  import { attemptAutoLogin } from './auth/auto-login.js';
24
+ import { EXIT, SkillError } from './errors.js';
22
25
  const DEFAULT_API_BASE_URL = 'https://api.remixmate.com/api';
23
- export class SkillError extends Error {
24
- exitCode;
25
- constructor(message, exitCode = 1) {
26
- super(message);
27
- this.exitCode = exitCode;
28
- this.name = 'SkillError';
29
- }
30
- }
31
- /** Resolve the ab-api base URL the same way for authenticated and device-flow calls. */
26
+ // Re-exported so the many `import { SkillError } from '../http.js'` call sites
27
+ // stay valid; the definition moved to errors.ts to break an import cycle with
28
+ // the auth gateway (see errors.ts).
29
+ export { SkillError, EXIT } from './errors.js';
30
+ /**
31
+ * Resolve the ab-api base URL the same way for authenticated and device-flow calls.
32
+ *
33
+ * `MM_BACKEND_API_URL` is honored because several Python skills already fall back
34
+ * to it (registry_loader, render_job_client, _vod_polling). Since runner.ts now
35
+ * injects the resolved base URL into those child processes, leaving it out here
36
+ * would let a prod default silently override an ab-agent-injected backend.
37
+ */
32
38
  export function resolveApiBaseUrl(flag) {
33
- return (flag ?? process.env.MM_API_BASE_URL ?? DEFAULT_API_BASE_URL).replace(/\/+$/, '');
39
+ const fromEnv = (process.env.MM_API_BASE_URL ?? '').trim() || (process.env.MM_BACKEND_API_URL ?? '').trim();
40
+ return ((flag ?? '').trim() || fromEnv || DEFAULT_API_BASE_URL).replace(/\/+$/, '');
34
41
  }
35
42
  export async function resolveHttpContext(skillName, opts = {}) {
36
43
  const apiBaseUrl = resolveApiBaseUrl(opts.apiBaseUrl);
37
- let resolved = await resolvePrivToken({ flag: opts.privateToken, apiBaseUrl });
44
+ // An explicit --token flag still wins over the preflight result.
45
+ let resolved = await resolvePrivToken({
46
+ flag: opts.privateToken ?? opts.preflightToken,
47
+ apiBaseUrl,
48
+ });
38
49
  // Only when flag/env/store are all empty do we try automatic browser auth.
39
50
  // attemptAutoLogin self-gates (cloud env / headless / disabled → returns null
40
51
  // immediately), so the env-injected path never reaches it (zero regression).
@@ -42,7 +53,7 @@ export async function resolveHttpContext(skillName, opts = {}) {
42
53
  resolved = await attemptAutoLogin(apiBaseUrl);
43
54
  }
44
55
  if (!resolved) {
45
- throw new SkillError(NOT_AUTHENTICATED_HINT);
56
+ throw new SkillError(NOT_AUTHENTICATED_HINT, EXIT.NOT_AUTHENTICATED);
46
57
  }
47
58
  return {
48
59
  apiBaseUrl,
@@ -83,15 +94,24 @@ export async function mmPost(ctx, pathOrUrl, body, opts = {}) {
83
94
  }
84
95
  catch (err) {
85
96
  if (err instanceof Error && err.name === 'AbortError') {
86
- throw new SkillError(`❌ request timed out: ${url}`);
97
+ throw new SkillError(`❌ request timed out: ${url}`, EXIT.BACKEND_UNREACHABLE);
87
98
  }
88
- throw new SkillError(`❌ network error: ${err.message}`);
99
+ throw new SkillError(`❌ network error: ${err.message}`, EXIT.BACKEND_UNREACHABLE);
89
100
  }
90
101
  finally {
91
102
  clearTimeout(timeout);
92
103
  }
93
104
  const text = await resp.text();
94
105
  if (!resp.ok) {
106
+ // 401/403 is not a generic API failure — it means the credential is missing,
107
+ // expired or revoked. Surfacing it as such (with exit code 4) is what lets a
108
+ // host offer to re-authorize instead of reporting an opaque backend error.
109
+ if (resp.status === 401 || resp.status === 403) {
110
+ throw new SkillError(`${NOT_AUTHENTICATED_HINT}\n (后端返回 HTTP ${resp.status})`, EXIT.NOT_AUTHENTICATED);
111
+ }
112
+ if (resp.status >= 500) {
113
+ throw new SkillError(`❌ API request failed (HTTP ${resp.status}): ${text}`, EXIT.BACKEND_UNREACHABLE);
114
+ }
95
115
  throw new SkillError(`❌ API request failed (HTTP ${resp.status}): ${text}`);
96
116
  }
97
117
  let parsed;
@@ -101,7 +121,15 @@ export async function mmPost(ctx, pathOrUrl, body, opts = {}) {
101
121
  catch {
102
122
  throw new SkillError(`❌ failed to parse response, body is not JSON: ${text.slice(0, 200)}`);
103
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);
104
127
  if (parsed.code !== 0) {
128
+ // ab-api reports auth failures in the envelope (HTTP 200 + code=401), so the
129
+ // business-code path needs the same 401 → "re-authorize" mapping as above.
130
+ if (parsed.code === 401 || parsed.code === 403) {
131
+ throw new SkillError(`${NOT_AUTHENTICATED_HINT}\n (后端返回 code=${parsed.code}${parsed.msg ? `: ${parsed.msg}` : ''})`, EXIT.NOT_AUTHENTICATED);
132
+ }
105
133
  throw new SkillError(`❌ API returned a business error: ${parsed.msg ?? 'unknown error'} (code=${parsed.code})`);
106
134
  }
107
135
  return parsed.data ?? undefined;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.9.13",
4
- "generatedAt": "2026-08-12T07:37:17.101Z",
3
+ "version": "0.9.15",
4
+ "generatedAt": "2026-08-14T08:23:17.130Z",
5
5
  "skills": [
6
6
  {
7
7
  "id": "export-jianying",
@@ -5,15 +5,17 @@
5
5
  * the build-time manifest generator and smoke test). A record that fails
6
6
  * validation is skipped with a warning rather than crashing the whole CLI.
7
7
  */
8
- import { type SkillEntry } from './skill-schema.js';
8
+ import { type SkillAuthMode, type SkillEntry } from './skill-schema.js';
9
9
  export declare const SKILLS_DIR: string;
10
- export type { SkillEntry } from './skill-schema.js';
10
+ export type { SkillEntry, SkillAuthMode } from './skill-schema.js';
11
11
  export interface SkillDef {
12
12
  name: string;
13
13
  toolName: string;
14
14
  description: string;
15
15
  parameters: Record<string, unknown>;
16
16
  entry: SkillEntry;
17
+ /** Authorization requirement enforced by the dispatcher before invocation. */
18
+ auth: SkillAuthMode;
17
19
  skillDir: string;
18
20
  /** Absolute path to the python entry script (only when entry.type === 'python'). */
19
21
  scriptAbsolutePath?: string;
package/dist/registry.js CHANGED
@@ -8,7 +8,7 @@
8
8
  import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
- import { normalizeEntry, validateSkillJson, } from './skill-schema.js';
11
+ import { normalizeEntry, resolveAuthMode, validateSkillJson, } from './skill-schema.js';
12
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
13
  export const SKILLS_DIR = path.resolve(__dirname, '..', 'skills');
14
14
  function loadOne(skillDir) {
@@ -37,6 +37,7 @@ function loadOne(skillDir) {
37
37
  description: raw.description,
38
38
  parameters: raw.parameters ?? {},
39
39
  entry,
40
+ auth: resolveAuthMode(raw),
40
41
  skillDir,
41
42
  };
42
43
  if (entry.type === 'python') {
package/dist/runner.d.ts CHANGED
@@ -3,6 +3,10 @@
3
3
  * - spawn `python3 <scriptAbsolutePath> --flag value ...` (entry.type === 'python')
4
4
  * - call a TS handler from handlers/ (entry.type === 'http' | 'builtin')
5
5
  *
6
+ * Authorization happens HERE, once, before either path — see auth/ensure.ts for
7
+ * why. Python children receive the resolved credential through their environment
8
+ * and never read the credential store themselves.
9
+ *
6
10
  * Resolves to the child process's exit code so cli.ts can propagate it.
7
11
  */
8
12
  import type { ParsedArgs } from './argv.js';
package/dist/runner.js CHANGED
@@ -3,26 +3,44 @@
3
3
  * - spawn `python3 <scriptAbsolutePath> --flag value ...` (entry.type === 'python')
4
4
  * - call a TS handler from handlers/ (entry.type === 'http' | 'builtin')
5
5
  *
6
+ * Authorization happens HERE, once, before either path — see auth/ensure.ts for
7
+ * why. Python children receive the resolved credential through their environment
8
+ * and never read the credential store themselves.
9
+ *
6
10
  * Resolves to the child process's exit code so cli.ts can propagate it.
7
11
  */
8
12
  import { spawn } from 'node:child_process';
9
13
  import path from 'node:path';
10
14
  import { findSkill, SKILLS_DIR } from './registry.js';
11
15
  import { HANDLERS } from './handlers/index.js';
12
- import { SkillError } from './http.js';
16
+ import { EXIT, SkillError } from './errors.js';
17
+ import { authChildEnv, ensureAuth } from './auth/ensure.js';
18
+ import { emitBillingFooter } from './billing.js';
19
+ /** Read the token override accepted by both the TS handlers and the Python skills. */
20
+ function tokenFlag(args) {
21
+ const value = args.token ?? args.priv_token;
22
+ return typeof value === 'string' ? value : undefined;
23
+ }
13
24
  export async function runSkill(skillName, opts) {
14
25
  const skill = findSkill(skillName, opts.baseDir ?? SKILLS_DIR);
15
26
  if (!skill) {
16
27
  process.stderr.write(`❌ skill not found: ${skillName}\n`);
17
- return 2;
28
+ return EXIT.USAGE;
18
29
  }
19
30
  try {
31
+ const auth = await ensureAuth({
32
+ mode: skill.auth,
33
+ apiBaseUrl: typeof opts.parsedArgs.api_base_url === 'string' ? opts.parsedArgs.api_base_url : undefined,
34
+ flagToken: tokenFlag(opts.parsedArgs),
35
+ });
20
36
  switch (skill.entry.type) {
21
37
  case 'python':
22
- return await runPython(skill, opts.rawArgs);
38
+ // Python children talk to ab-api themselves and print their own billing
39
+ // footer; nothing was charged through this process.
40
+ return await runPython(skill, opts.rawArgs, auth);
23
41
  case 'http':
24
42
  case 'builtin':
25
- return await runHandler(skill, opts.parsedArgs);
43
+ return await runHandler(skill, opts.parsedArgs, auth);
26
44
  }
27
45
  }
28
46
  catch (err) {
@@ -31,10 +49,16 @@ export async function runSkill(skillName, opts) {
31
49
  return err.exitCode;
32
50
  }
33
51
  process.stderr.write(`❌ unexpected error: ${err.message}\n`);
34
- return 1;
52
+ return EXIT.ERROR;
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();
35
59
  }
36
60
  }
37
- async function runPython(skill, rawArgs) {
61
+ async function runPython(skill, rawArgs, auth) {
38
62
  if (!skill.scriptAbsolutePath) {
39
63
  throw new SkillError(`skill ${skill.name} has entry.type=python but no scriptAbsolutePath`);
40
64
  }
@@ -43,21 +67,21 @@ async function runPython(skill, rawArgs) {
43
67
  const proc = spawn('python3', [skill.scriptAbsolutePath, ...rawArgs], {
44
68
  cwd,
45
69
  stdio: 'inherit',
46
- env: { ...process.env, PYTHONUNBUFFERED: '1' },
70
+ env: { ...process.env, ...authChildEnv(auth), PYTHONUNBUFFERED: '1' },
47
71
  });
48
- proc.on('exit', (code) => resolve(code ?? 1));
72
+ proc.on('exit', (code) => resolve(code ?? EXIT.ERROR));
49
73
  proc.on('error', (err) => {
50
74
  process.stderr.write(`❌ failed to spawn python3: ${err.message}\n`);
51
- resolve(127);
75
+ resolve(EXIT.SPAWN_FAILED);
52
76
  });
53
77
  });
54
78
  }
55
- async function runHandler(skill, args) {
79
+ async function runHandler(skill, args, auth) {
56
80
  const handlerKey = skill.entry.handler;
57
81
  const handler = HANDLERS[handlerKey];
58
82
  if (!handler) {
59
83
  throw new SkillError(`handler not registered: ${handlerKey} (skill ${skill.name})`);
60
84
  }
61
- await handler(args, { skillName: skill.name });
62
- return 0;
85
+ await handler(args, { skillName: skill.name, auth });
86
+ return EXIT.OK;
63
87
  }
@@ -38,6 +38,14 @@ export type SkillEntry = {
38
38
  */
39
39
  export type SkillCategory = 'authoring' | 'consuming' | 'asset' | 'meta';
40
40
  export declare const CATEGORY_VALUES: readonly ["authoring", "consuming", "asset", "meta"];
41
+ /**
42
+ * Authorization requirement — consumed by the dispatcher's auth preflight.
43
+ * Semantics are documented on `AuthMode` in src/auth/ensure.ts (kept there so
44
+ * the runtime behavior and its description live together); this file only owns
45
+ * the literal values and their validation.
46
+ */
47
+ export type SkillAuthMode = 'required' | 'optional' | 'none';
48
+ export declare const AUTH_VALUES: readonly ["required", "optional", "none"];
41
49
  export interface RawSkillJson {
42
50
  name: string;
43
51
  toolName: string;
@@ -45,11 +53,22 @@ export interface RawSkillJson {
45
53
  title?: string;
46
54
  tier?: string;
47
55
  category?: SkillCategory;
56
+ auth?: SkillAuthMode;
48
57
  parameters?: Record<string, unknown>;
49
58
  scriptPath?: string;
50
59
  entry?: SkillEntry;
51
60
  envVars?: string[];
52
61
  }
62
+ /**
63
+ * Resolve a skill's auth mode, defaulting for records that predate the field.
64
+ *
65
+ * The default is derived from `envVars` rather than hardcoded to `'required'`:
66
+ * a skill that never declared PRIV_TOKEN cannot possibly need a credential, and
67
+ * defaulting it to `required` would make a purely local skill (web-screenshot)
68
+ * pop a browser. Every skill shipped in this package declares `auth` explicitly,
69
+ * so this only covers externally supplied skill dirs (SKILL_BASE_DIR).
70
+ */
71
+ export declare function resolveAuthMode(raw: Pick<RawSkillJson, 'auth' | 'envVars'>): SkillAuthMode;
53
72
  export declare const TIER_VALUES: readonly ["atomic", "orchestration", "tool"];
54
73
  export declare const REQUIRED_SKILL_JSON_FIELDS: readonly ["name", "tier", "title", "description"];
55
74
  /**
@@ -12,6 +12,21 @@
12
12
  * the build treats them as fatal.
13
13
  */
14
14
  export const CATEGORY_VALUES = ['authoring', 'consuming', 'asset', 'meta'];
15
+ export const AUTH_VALUES = ['required', 'optional', 'none'];
16
+ /**
17
+ * Resolve a skill's auth mode, defaulting for records that predate the field.
18
+ *
19
+ * The default is derived from `envVars` rather than hardcoded to `'required'`:
20
+ * a skill that never declared PRIV_TOKEN cannot possibly need a credential, and
21
+ * defaulting it to `required` would make a purely local skill (web-screenshot)
22
+ * pop a browser. Every skill shipped in this package declares `auth` explicitly,
23
+ * so this only covers externally supplied skill dirs (SKILL_BASE_DIR).
24
+ */
25
+ export function resolveAuthMode(raw) {
26
+ if (raw.auth)
27
+ return raw.auth;
28
+ return raw.envVars?.includes('PRIV_TOKEN') ? 'required' : 'none';
29
+ }
15
30
  export const TIER_VALUES = ['atomic', 'orchestration', 'tool'];
16
31
  export const REQUIRED_SKILL_JSON_FIELDS = ['name', 'tier', 'title', 'description'];
17
32
  /**
@@ -43,6 +58,9 @@ export function validateSkillJson(raw, skillId) {
43
58
  if (raw.category != null && !CATEGORY_VALUES.includes(raw.category)) {
44
59
  errors.push(`skill.json.category must be one of ${CATEGORY_VALUES.join(' | ')}, got '${raw.category}'`);
45
60
  }
61
+ if (raw.auth != null && !AUTH_VALUES.includes(raw.auth)) {
62
+ errors.push(`skill.json.auth must be one of ${AUTH_VALUES.join(' | ')}, got '${raw.auth}'`);
63
+ }
46
64
  if (raw.name && raw.name !== skillId) {
47
65
  errors.push(`skill.json.name='${raw.name}' does not match directory name '${skillId}'`);
48
66
  }
package/dist/text.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Terminal text helpers shared by the human-facing commands.
3
+ */
4
+ /**
5
+ * Pad to a target terminal width. CJK characters occupy two columns but count as
6
+ * one code unit, so String.padEnd misaligns any column that contains them.
7
+ */
8
+ export declare function padDisplay(text: string, width: number): string;
package/dist/text.js ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Terminal text helpers shared by the human-facing commands.
3
+ */
4
+ // Ranges covering the CJK / fullwidth blocks the CLI's Chinese output uses.
5
+ const WIDE = /[ᄀ-ᅟ⺀-䳿一-鿿가-힣豈-﫿︰-﹏＀-⦆¢-₩]/;
6
+ /**
7
+ * Pad to a target terminal width. CJK characters occupy two columns but count as
8
+ * one code unit, so String.padEnd misaligns any column that contains them.
9
+ */
10
+ export function padDisplay(text, width) {
11
+ let cols = 0;
12
+ for (const ch of text)
13
+ cols += WIDE.test(ch) ? 2 : 1;
14
+ return text + ' '.repeat(Math.max(1, width - cols));
15
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remixmate/cli",
3
- "version": "0.9.13",
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",
@@ -21,7 +21,7 @@
21
21
  "smoke": "node scripts/smoke.mjs",
22
22
  "test:cli": "npm run build && node --test test/*.test.mjs",
23
23
  "test:validators": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-validators.py",
24
- "test:template-pipeline": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-template-pipeline.py",
24
+ "test:template-pipeline": "PYTHONDONTWRITEBYTECODE=1 node dist/cli.js exec -- python3 scripts/test-template-pipeline.py",
25
25
  "test:props-contract": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-props-contract.py",
26
26
  "test:render-plan": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-render-plan-snapshot.py",
27
27
  "test:render-plan:update": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-render-plan-snapshot.py --update",
@@ -5,6 +5,7 @@
5
5
  "category": "consuming",
6
6
  "title": "Jianying (CapCut) Draft Export",
7
7
  "description": "Jianying (CapCut) draft export: package asset URLs into a draft ZIP that Jianying can import. Supports automatic conversion from a RenderPlan.",
8
+ "auth": "required",
8
9
  "envVars": ["PRIV_TOKEN", "MM_API_BASE_URL", "AGENT_NAME"],
9
10
  "scriptPath": "scripts/gen_jianying_draft.py",
10
11
  "parameters": {
@@ -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`.