@remixmate/cli 0.9.12 → 0.9.14

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 (56) 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/cli.js +75 -8
  17. package/dist/doctor.d.ts +22 -0
  18. package/dist/doctor.js +142 -0
  19. package/dist/errors.d.ts +29 -0
  20. package/dist/errors.js +33 -0
  21. package/dist/exec.d.ts +18 -0
  22. package/dist/exec.js +47 -0
  23. package/dist/handlers/gen-digital-human.js +1 -0
  24. package/dist/handlers/gen-image.js +1 -0
  25. package/dist/handlers/gen-video.js +1 -0
  26. package/dist/handlers/gen-voice.js +2 -0
  27. package/dist/handlers/index.d.ts +7 -0
  28. package/dist/http.d.ts +17 -6
  29. package/dist/http.js +39 -15
  30. package/dist/manifest.json +2 -2
  31. package/dist/registry.d.ts +4 -2
  32. package/dist/registry.js +2 -1
  33. package/dist/runner.d.ts +4 -0
  34. package/dist/runner.js +27 -12
  35. package/dist/skill-schema.d.ts +19 -0
  36. package/dist/skill-schema.js +18 -0
  37. package/dist/text.d.ts +8 -0
  38. package/dist/text.js +15 -0
  39. package/package.json +2 -2
  40. package/skills/export-jianying/skill.json +1 -0
  41. package/skills/gen-digital-human/skill.json +1 -0
  42. package/skills/gen-image/skill.json +1 -0
  43. package/skills/gen-script/SKILL.md +20 -1
  44. package/skills/gen-script/skill.json +1 -0
  45. package/skills/gen-video/skill.json +1 -0
  46. package/skills/gen-voice/skill.json +1 -0
  47. package/skills/prepare-video-assets/SKILL.md +17 -1
  48. package/skills/prepare-video-assets/skill.json +1 -0
  49. package/skills/render-video/SKILL.md +13 -0
  50. package/skills/render-video/skill.json +1 -0
  51. package/skills/template-registry/scripts/list_templates.py +16 -1
  52. package/skills/template-registry/scripts/registry_loader.py +63 -63
  53. package/skills/template-registry/skill.json +1 -0
  54. package/skills/video-parser/skill.json +1 -0
  55. package/skills/web-record/skill.json +1 -0
  56. package/skills/web-screenshot/skill.json +1 -0
@@ -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,18 @@
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
+ export { SkillError, EXIT } from './errors.js';
22
+ /**
23
+ * Resolve the ab-api base URL the same way for authenticated and device-flow calls.
24
+ *
25
+ * `MM_BACKEND_API_URL` is honored because several Python skills already fall back
26
+ * to it (registry_loader, render_job_client, _vod_polling). Since runner.ts now
27
+ * injects the resolved base URL into those child processes, leaving it out here
28
+ * would let a prod default silently override an ab-agent-injected backend.
19
29
  */
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
30
  export declare function resolveApiBaseUrl(flag?: string): string;
26
31
  export interface HttpContext {
27
32
  apiBaseUrl: string;
@@ -33,6 +38,12 @@ export interface HttpContext {
33
38
  export declare function resolveHttpContext(skillName: string, opts?: {
34
39
  apiBaseUrl?: string;
35
40
  privateToken?: string;
41
+ /**
42
+ * Token already resolved by the dispatcher's auth preflight (runner.ts).
43
+ * Reusing it avoids a second credential-store read — and, more importantly,
44
+ * a second device-flow attempt — per skill invocation.
45
+ */
46
+ preflightToken?: string;
36
47
  }): Promise<HttpContext>;
37
48
  export interface MmResponse<T = unknown> {
38
49
  code: number;
package/dist/http.js CHANGED
@@ -15,26 +15,36 @@
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
  */
20
21
  import { resolvePrivToken, NOT_AUTHENTICATED_HINT } from './auth/resolve.js';
21
22
  import { attemptAutoLogin } from './auth/auto-login.js';
23
+ import { EXIT, SkillError } from './errors.js';
22
24
  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. */
25
+ // Re-exported so the many `import { SkillError } from '../http.js'` call sites
26
+ // stay valid; the definition moved to errors.ts to break an import cycle with
27
+ // the auth gateway (see errors.ts).
28
+ export { SkillError, EXIT } from './errors.js';
29
+ /**
30
+ * Resolve the ab-api base URL the same way for authenticated and device-flow calls.
31
+ *
32
+ * `MM_BACKEND_API_URL` is honored because several Python skills already fall back
33
+ * to it (registry_loader, render_job_client, _vod_polling). Since runner.ts now
34
+ * injects the resolved base URL into those child processes, leaving it out here
35
+ * would let a prod default silently override an ab-agent-injected backend.
36
+ */
32
37
  export function resolveApiBaseUrl(flag) {
33
- return (flag ?? process.env.MM_API_BASE_URL ?? DEFAULT_API_BASE_URL).replace(/\/+$/, '');
38
+ const fromEnv = (process.env.MM_API_BASE_URL ?? '').trim() || (process.env.MM_BACKEND_API_URL ?? '').trim();
39
+ return ((flag ?? '').trim() || fromEnv || DEFAULT_API_BASE_URL).replace(/\/+$/, '');
34
40
  }
35
41
  export async function resolveHttpContext(skillName, opts = {}) {
36
42
  const apiBaseUrl = resolveApiBaseUrl(opts.apiBaseUrl);
37
- let resolved = await resolvePrivToken({ flag: opts.privateToken, apiBaseUrl });
43
+ // An explicit --token flag still wins over the preflight result.
44
+ let resolved = await resolvePrivToken({
45
+ flag: opts.privateToken ?? opts.preflightToken,
46
+ apiBaseUrl,
47
+ });
38
48
  // Only when flag/env/store are all empty do we try automatic browser auth.
39
49
  // attemptAutoLogin self-gates (cloud env / headless / disabled → returns null
40
50
  // immediately), so the env-injected path never reaches it (zero regression).
@@ -42,7 +52,7 @@ export async function resolveHttpContext(skillName, opts = {}) {
42
52
  resolved = await attemptAutoLogin(apiBaseUrl);
43
53
  }
44
54
  if (!resolved) {
45
- throw new SkillError(NOT_AUTHENTICATED_HINT);
55
+ throw new SkillError(NOT_AUTHENTICATED_HINT, EXIT.NOT_AUTHENTICATED);
46
56
  }
47
57
  return {
48
58
  apiBaseUrl,
@@ -83,15 +93,24 @@ export async function mmPost(ctx, pathOrUrl, body, opts = {}) {
83
93
  }
84
94
  catch (err) {
85
95
  if (err instanceof Error && err.name === 'AbortError') {
86
- throw new SkillError(`❌ request timed out: ${url}`);
96
+ throw new SkillError(`❌ request timed out: ${url}`, EXIT.BACKEND_UNREACHABLE);
87
97
  }
88
- throw new SkillError(`❌ network error: ${err.message}`);
98
+ throw new SkillError(`❌ network error: ${err.message}`, EXIT.BACKEND_UNREACHABLE);
89
99
  }
90
100
  finally {
91
101
  clearTimeout(timeout);
92
102
  }
93
103
  const text = await resp.text();
94
104
  if (!resp.ok) {
105
+ // 401/403 is not a generic API failure — it means the credential is missing,
106
+ // expired or revoked. Surfacing it as such (with exit code 4) is what lets a
107
+ // host offer to re-authorize instead of reporting an opaque backend error.
108
+ if (resp.status === 401 || resp.status === 403) {
109
+ throw new SkillError(`${NOT_AUTHENTICATED_HINT}\n (后端返回 HTTP ${resp.status})`, EXIT.NOT_AUTHENTICATED);
110
+ }
111
+ if (resp.status >= 500) {
112
+ throw new SkillError(`❌ API request failed (HTTP ${resp.status}): ${text}`, EXIT.BACKEND_UNREACHABLE);
113
+ }
95
114
  throw new SkillError(`❌ API request failed (HTTP ${resp.status}): ${text}`);
96
115
  }
97
116
  let parsed;
@@ -102,6 +121,11 @@ export async function mmPost(ctx, pathOrUrl, body, opts = {}) {
102
121
  throw new SkillError(`❌ failed to parse response, body is not JSON: ${text.slice(0, 200)}`);
103
122
  }
104
123
  if (parsed.code !== 0) {
124
+ // ab-api reports auth failures in the envelope (HTTP 200 + code=401), so the
125
+ // business-code path needs the same 401 → "re-authorize" mapping as above.
126
+ if (parsed.code === 401 || parsed.code === 403) {
127
+ throw new SkillError(`${NOT_AUTHENTICATED_HINT}\n (后端返回 code=${parsed.code}${parsed.msg ? `: ${parsed.msg}` : ''})`, EXIT.NOT_AUTHENTICATED);
128
+ }
105
129
  throw new SkillError(`❌ API returned a business error: ${parsed.msg ?? 'unknown error'} (code=${parsed.code})`);
106
130
  }
107
131
  return parsed.data ?? undefined;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.9.12",
4
- "generatedAt": "2026-08-12T01:53:36.480Z",
3
+ "version": "0.9.14",
4
+ "generatedAt": "2026-08-12T07:37:21.011Z",
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,41 @@
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
+ /** Read the token override accepted by both the TS handlers and the Python skills. */
19
+ function tokenFlag(args) {
20
+ const value = args.token ?? args.priv_token;
21
+ return typeof value === 'string' ? value : undefined;
22
+ }
13
23
  export async function runSkill(skillName, opts) {
14
24
  const skill = findSkill(skillName, opts.baseDir ?? SKILLS_DIR);
15
25
  if (!skill) {
16
26
  process.stderr.write(`❌ skill not found: ${skillName}\n`);
17
- return 2;
27
+ return EXIT.USAGE;
18
28
  }
19
29
  try {
30
+ const auth = await ensureAuth({
31
+ mode: skill.auth,
32
+ apiBaseUrl: typeof opts.parsedArgs.api_base_url === 'string' ? opts.parsedArgs.api_base_url : undefined,
33
+ flagToken: tokenFlag(opts.parsedArgs),
34
+ });
20
35
  switch (skill.entry.type) {
21
36
  case 'python':
22
- return await runPython(skill, opts.rawArgs);
37
+ return await runPython(skill, opts.rawArgs, auth);
23
38
  case 'http':
24
39
  case 'builtin':
25
- return await runHandler(skill, opts.parsedArgs);
40
+ return await runHandler(skill, opts.parsedArgs, auth);
26
41
  }
27
42
  }
28
43
  catch (err) {
@@ -31,10 +46,10 @@ export async function runSkill(skillName, opts) {
31
46
  return err.exitCode;
32
47
  }
33
48
  process.stderr.write(`❌ unexpected error: ${err.message}\n`);
34
- return 1;
49
+ return EXIT.ERROR;
35
50
  }
36
51
  }
37
- async function runPython(skill, rawArgs) {
52
+ async function runPython(skill, rawArgs, auth) {
38
53
  if (!skill.scriptAbsolutePath) {
39
54
  throw new SkillError(`skill ${skill.name} has entry.type=python but no scriptAbsolutePath`);
40
55
  }
@@ -43,21 +58,21 @@ async function runPython(skill, rawArgs) {
43
58
  const proc = spawn('python3', [skill.scriptAbsolutePath, ...rawArgs], {
44
59
  cwd,
45
60
  stdio: 'inherit',
46
- env: { ...process.env, PYTHONUNBUFFERED: '1' },
61
+ env: { ...process.env, ...authChildEnv(auth), PYTHONUNBUFFERED: '1' },
47
62
  });
48
- proc.on('exit', (code) => resolve(code ?? 1));
63
+ proc.on('exit', (code) => resolve(code ?? EXIT.ERROR));
49
64
  proc.on('error', (err) => {
50
65
  process.stderr.write(`❌ failed to spawn python3: ${err.message}\n`);
51
- resolve(127);
66
+ resolve(EXIT.SPAWN_FAILED);
52
67
  });
53
68
  });
54
69
  }
55
- async function runHandler(skill, args) {
70
+ async function runHandler(skill, args, auth) {
56
71
  const handlerKey = skill.entry.handler;
57
72
  const handler = HANDLERS[handlerKey];
58
73
  if (!handler) {
59
74
  throw new SkillError(`handler not registered: ${handlerKey} (skill ${skill.name})`);
60
75
  }
61
- await handler(args, { skillName: skill.name });
62
- return 0;
76
+ await handler(args, { skillName: skill.name, auth });
77
+ return EXIT.OK;
63
78
  }
@@ -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.12",
3
+ "version": "0.9.14",
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": {
@@ -5,6 +5,7 @@
5
5
  "category": "asset",
6
6
  "title": "Digital-Human Talking-Head",
7
7
  "description": "Digital-human video: list available avatars; produce a talking-head video from text via TTS, or drive an avatar from an existing audio URL.",
8
+ "auth": "required",
8
9
  "envVars": ["PRIV_TOKEN", "MM_API_BASE_URL", "AGENT_NAME"],
9
10
  "entry": { "type": "http", "handler": "gen-digital-human" },
10
11
  "parameters": {
@@ -5,6 +5,7 @@
5
5
  "category": "asset",
6
6
  "title": "AI Image Generation",
7
7
  "description": "AI image generation: produce an image from a text prompt. Supports Seedream and Gemini models, plus image-to-image with reference images.",
8
+ "auth": "required",
8
9
  "envVars": ["PRIV_TOKEN", "MM_API_BASE_URL", "AGENT_NAME", "MM_IMAGE_MODEL"],
9
10
  "entry": { "type": "http", "handler": "gen-image" },
10
11
  "parameters": {
@@ -9,6 +9,8 @@ description: |
9
9
  - Turn a topic into a video structure / video content plan
10
10
 
11
11
  Even when the user does not say "generate the DSL", use this skill whenever they want to turn a topic into a structured video plan.
12
+
13
+ ⚠️ Stop-and-confirm gate: after this skill returns a DSL, show the full script and wait for the user's explicit confirmation. Never call `prepare_video_assets` in the same turn.
12
14
  triggers:
13
15
  - Write a video script, generate a video script, plan a video, write storyboards
14
16
  - Create a short video, plan video content, generate a Video DSL
@@ -19,6 +21,13 @@ triggers:
19
21
 
20
22
  Turns a user-supplied **topic** into a **Video DSL v1alpha1** JSON describing what the final video should look like (scene structure, asset requirements, narration text, visual layout). It never produces any asset itself.
21
23
 
24
+ > ⚠️ **Stop-and-confirm gate (mandatory, do not skip).**
25
+ > This skill produces a *draft* for the user to review, not an input to chain onward automatically.
26
+ > After it returns, show the full script and **stop your turn** — wait for the user to explicitly confirm.
27
+ > **Never** call `prepare_video_assets` in the same turn as `gen_script`: asset generation spends
28
+ > image and TTS credits that cannot be refunded, and a wrong narration line means paying for the
29
+ > whole batch twice. See **"Agent behavior: script confirmation"** below for the required summary format.
30
+
22
31
  ## Core concepts
23
32
 
24
33
  - **Video DSL**: a declarative description language for videos — describes the final structure, not the orchestration calls.
@@ -84,9 +93,19 @@ This skill does not hit any external API; no token required. The script only doe
84
93
  ### Confirmation flow
85
94
 
86
95
  1. After the agent generates or modifies the DSL, **show the script summary first; do not pass it downstream yet**.
87
- 2. Wait for the user to explicitly confirm (e.g. "OK", "looks good", "continue with template binding") before handing it to `prepare-video-assets` (or `template-registry` for a list-only lookup).
96
+ 2. **End your turn there and wait** for the user to explicitly confirm (e.g. "OK", "looks good", "continue with template binding") before handing it to `prepare-video-assets` (or `template-registry` for a list-only lookup).
88
97
  3. If the user asks for changes (adjust narration, add or remove scenes, change durations), the agent updates the DSL and shows the summary again, then waits for confirmation once more.
89
98
 
99
+ > ⚠️ **Never** call `prepare_video_assets` before the user has confirmed — not even when the original
100
+ > request was a single end-to-end instruction like "make me a video about X". That request authorizes
101
+ > the *pipeline*, not the skipping of its review steps. "Finish the task in one go" does **not** apply
102
+ > here: stopping for confirmation **is** the correct completion of this step.
103
+
104
+ ### Showing the script
105
+
106
+ Show **every scene in full** — do not collapse them with phrases like "scenes 2–6 same as above".
107
+ The user is reviewing the narration word by word; a summary they cannot proofread defeats the gate.
108
+
90
109
  ### Summary content
91
110
 
92
111
  The agent should show the following in clear Markdown:
@@ -5,6 +5,7 @@
5
5
  "category": "authoring",
6
6
  "title": "Video Script Generation",
7
7
  "description": "Video script generation: turn a topic into a structured Video DSL (JSON) that describes the full video — scene structure, asset requirements, and narrative flow.",
8
+ "auth": "none",
8
9
  "envVars": ["DEFAULT_IMAGE_MODEL", "DEFAULT_VIDEO_MODEL", "STUB_IMAGE_URL", "STUB_VIDEO_URL"],
9
10
  "scriptPath": "scripts/gen_script.py",
10
11
  "parameters": {
@@ -5,6 +5,7 @@
5
5
  "category": "asset",
6
6
  "title": "AI Video Generation",
7
7
  "description": "AI video generation: produce a short video clip from a text prompt. Supports Seedance and Veo models, plus first/last frame and reference images.",
8
+ "auth": "required",
8
9
  "envVars": ["PRIV_TOKEN", "MM_API_BASE_URL", "AGENT_NAME", "MM_VIDEO_MODEL"],
9
10
  "entry": { "type": "http", "handler": "gen-video" },
10
11
  "parameters": {
@@ -5,6 +5,7 @@
5
5
  "category": "asset",
6
6
  "title": "Text-to-Speech (Minimax)",
7
7
  "description": "Text-to-speech (TTS): synthesize narration audio from text via the Minimax TTS model. Returns the persisted audio URL — no download needed.",
8
+ "auth": "required",
8
9
  "envVars": ["PRIV_TOKEN", "MM_API_BASE_URL", "AGENT_NAME"],
9
10
  "entry": { "type": "http", "handler": "gen-voice" },
10
11
  "parameters": {
@@ -9,6 +9,8 @@ description: |
9
9
  - Regenerate one asset (image / audio) for a specific scene
10
10
 
11
11
  Next step: after the user confirms the resolved assets, call `render_video` with the `job_id` returned by this skill.
12
+
13
+ ⚠️ Stop-and-confirm gate: this skill runs only after the user has confirmed the script, and after it returns you must show the resolved assets and wait for the user's explicit confirmation. Never call `render_video` in the same turn.
12
14
  triggers:
13
15
  - Generate / prepare video assets
14
16
  - Resolve missing assets in a DSL
@@ -20,6 +22,14 @@ triggers:
20
22
 
21
23
  Phase 1 of the two-phase video pipeline. Takes a Video DSL plus a template binding, walks every `AssetRef` declared in the DSL, calls the matching atomic skills to fill in missing assets (`gen-image`, `gen-voice`, `gen-video`, `gen-digital-human`), persists the resulting RenderPlan to ab-api, and prints a `job_id` for the user-confirmation step.
22
24
 
25
+ > ⚠️ **Stop-and-confirm gate (mandatory, do not skip) — both sides of this skill.**
26
+ > **Before**: only run this skill after the user has confirmed the script produced by `gen_script`.
27
+ > **After**: show the resolved assets (images inline, audio as links) and **stop your turn** — wait for
28
+ > the user to explicitly confirm before rendering. **Never** call `render_video` in the same turn as
29
+ > `prepare_video_assets`. This is the last checkpoint where a bad image or a wrong TTS take can be
30
+ > fixed for the price of one asset instead of a whole re-render.
31
+ > See **"Pre-render user confirmation (Phase 2)"** below for the required summary format.
32
+
23
33
  > **Next step**: after the user reviews the assets and confirms, call **`render_video`** with the `job_id` returned by this skill.
24
34
 
25
35
  ## Pipeline (this skill's part only)
@@ -94,7 +104,13 @@ python3 <SkillDir>/scripts/prepare_video_assets.py \
94
104
 
95
105
  ## Pre-render user confirmation (Phase 2)
96
106
 
97
- This skill is the **first half** of a two-phase user-confirmation flow. After it returns, the agent must show the user the resolved assets and wait for confirmation before calling `render_video`. The recommended summary format:
107
+ This skill is the **first half** of a two-phase user-confirmation flow. After it returns, the agent **must** show the user the resolved assets, then **end its turn and wait** for confirmation before calling `render_video`.
108
+
109
+ > ⚠️ **Never** start the final render before the user confirms. An end-to-end request ("make me a video
110
+ > about X") authorizes the pipeline, not the skipping of its review steps — stopping here **is** the
111
+ > correct completion of this step, not an unfinished task.
112
+
113
+ Read the `job_id` from the stdout line `📦 render job jobId: N` and remember it; asset URLs come from the `🔊 TTS audio:` and `🖼 Image assets:` sections. Show images inline (`![name](https://...)`) and audio as links (`[🔊 listen](http://cdn.../x.mp3)`) so the user can actually review them. The recommended summary format:
98
114
 
99
115
  ```markdown
100
116
  ## Render confirmation
@@ -5,6 +5,7 @@
5
5
  "category": "authoring",
6
6
  "title": "Video Asset Preparation",
7
7
  "description": "Resolves and generates every asset (image / audio / video) referenced by a Video DSL, persists a RenderPlan to the database, and returns a job_id for the subsequent render_video call. This is Phase 1 of the two-phase video pipeline; Phase 3 (Remotion render) lives in render_video.",
8
+ "auth": "required",
8
9
  "envVars": ["PRIV_TOKEN", "MM_API_BASE_URL", "MM_BACKEND_API_URL", "AGENT_NAME", "REMOTION_RENDER_API_URL", "REMOTION_RENDER_MODE", "REMOTION_OUTPUT_DIR", "ASSET_CACHE_DIR"],
9
10
  "scriptPath": "scripts/prepare_video_assets.py",
10
11
  "parameters": {
@@ -9,6 +9,8 @@ description: |
9
9
  - Render with Remotion
10
10
 
11
11
  Prerequisite: assets must already be generated via `prepare_video_assets`. This skill never resolves or regenerates assets — pass it a `job_id` from a previous `prepare_video_assets` call.
12
+
13
+ ⚠️ Stop-and-confirm gate: never call this skill until the user has explicitly confirmed the assets prepared by `prepare_video_assets`. If those assets were prepared in the current turn and the user has not replied since, stop and ask instead of rendering.
12
14
  triggers:
13
15
  - Render the video, composite the video, export the video
14
16
  - Turn the prepared assets into the final clip
@@ -22,6 +24,17 @@ Loads a persisted RenderPlan from the database (or a local file fallback), compi
22
24
 
23
25
  > **Prerequisite**: assets must already be generated via `prepare_video_assets`. That skill returns a `job_id` integer; pass it here.
24
26
 
27
+ > ⚠️ **Stop-and-confirm gate (mandatory, do not skip).**
28
+ > **Never** call this skill until the user has explicitly confirmed the assets shown after
29
+ > `prepare_video_assets`. If those assets were prepared in the current turn and the user has not
30
+ > replied since, **stop and ask** — do not render. This step spends render credits and uploads the
31
+ > result; it is the one action in the pipeline that cannot be walked back cheaply.
32
+ >
33
+ > "Confirmed" means the user said so **in a message of their own** (e.g. "confirm", "go ahead",
34
+ > "looks good", "continue"). An end-to-end instruction from earlier in the conversation
35
+ > ("make me a video about X") is **not** confirmation — it authorizes the pipeline, not the
36
+ > skipping of its review steps. Stopping to ask **is** the correct completion of this step.
37
+
25
38
  ## Render pipeline (this skill's part only)
26
39
 
27
40
  ```