@remixmate/cli 0.9.63 → 0.9.65

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 (35) hide show
  1. package/dist/cli.js +13 -0
  2. package/dist/handlers/index.js +2 -0
  3. package/dist/handlers/template-init.d.ts +10 -0
  4. package/dist/handlers/template-init.js +21 -0
  5. package/dist/manifest.json +53 -3
  6. package/dist/template/args.d.ts +25 -0
  7. package/dist/template/args.js +69 -0
  8. package/dist/template/check.d.ts +23 -0
  9. package/dist/template/check.js +25 -0
  10. package/dist/template/commands.d.ts +25 -0
  11. package/dist/template/commands.js +146 -0
  12. package/dist/template/constants.d.ts +25 -0
  13. package/dist/template/constants.js +32 -0
  14. package/dist/template/init.d.ts +22 -0
  15. package/dist/template/init.js +115 -0
  16. package/dist/template/list.d.ts +23 -0
  17. package/dist/template/list.js +46 -0
  18. package/dist/template/preview.d.ts +60 -0
  19. package/dist/template/preview.js +318 -0
  20. package/dist/template/proc.d.ts +26 -0
  21. package/dist/template/proc.js +43 -0
  22. package/dist/template/project.d.ts +37 -0
  23. package/dist/template/project.js +110 -0
  24. package/dist/template/publish.d.ts +43 -0
  25. package/dist/template/publish.js +182 -0
  26. package/dist/template/render-client.d.ts +39 -0
  27. package/dist/template/render-client.js +79 -0
  28. package/package.json +1 -1
  29. package/skills/template-authoring/SKILL.md +69 -0
  30. package/skills/template-authoring/skill.json +32 -0
  31. package/skills/template-authoring/version.json +7 -0
  32. package/skills/template-registry/SKILL.md +2 -2
  33. package/skills/template-registry/scripts/list_templates.py +46 -13
  34. package/skills/template-registry/scripts/registry_loader.py +17 -0
  35. package/skills/template-registry/skill.json +1 -1
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Starter-project discovery, template identity (K4) and the shared output helper.
3
+ */
4
+ import { randomBytes } from 'node:crypto';
5
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import path from 'node:path';
7
+ import { EXIT, SkillError } from '../errors.js';
8
+ import { CHECK_ENTRY, IDENTITY_FILE } from './constants.js';
9
+ import { usageError } from './args.js';
10
+ // ── project root ─────────────────────────────────────────────────────────────
11
+ /** Walk up from `start` to the directory holding `.remixmate/check/index.mjs`. */
12
+ export function findProjectRoot(start = process.cwd()) {
13
+ let dir = path.resolve(start);
14
+ for (;;) {
15
+ if (existsSync(path.join(dir, CHECK_ENTRY)))
16
+ return dir;
17
+ const parent = path.dirname(dir);
18
+ if (parent === dir)
19
+ return null;
20
+ dir = parent;
21
+ }
22
+ }
23
+ export function requireProjectRoot(start) {
24
+ const root = findProjectRoot(start);
25
+ if (!root) {
26
+ throw new SkillError(`❌ 当前目录不在模板工程内(找不到 ${CHECK_ENTRY})。\n` +
27
+ ' 先运行 remixmate template init <dir>,再进入该目录执行。', EXIT.USAGE);
28
+ }
29
+ return root;
30
+ }
31
+ /** A template name is a single directory under templates/ — never a path. */
32
+ export function validateTemplateName(name) {
33
+ if (!name)
34
+ throw usageError('缺少模板名:remixmate template <command> <name>');
35
+ if (name === '.' || name === '..' || /[\\/]/.test(name) || name.startsWith('-')) {
36
+ throw usageError(`模板名不合法: ${name}(应为 templates/ 下的目录名)`);
37
+ }
38
+ return name;
39
+ }
40
+ export function requireTemplateDir(root, name) {
41
+ const dir = path.join(root, 'templates', name);
42
+ if (!existsSync(path.join(dir, 'template.json'))) {
43
+ throw new SkillError(`❌ 找不到 templates/${name}/template.json`, EXIT.USAGE);
44
+ }
45
+ return dir;
46
+ }
47
+ export function readJsonFile(file) {
48
+ try {
49
+ return JSON.parse(readFileSync(file, 'utf8'));
50
+ }
51
+ catch (err) {
52
+ throw new SkillError(`❌ ${path.basename(file)} 解析失败: ${err.message}`);
53
+ }
54
+ }
55
+ /** kebab-case slug of a directory name; falls back to `template` when nothing survives. */
56
+ export function kebabSlug(input) {
57
+ const slug = input
58
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
59
+ .toLowerCase()
60
+ .replace(/[^a-z0-9]+/g, '-')
61
+ .replace(/^-+|-+$/g, '');
62
+ return slug || 'template';
63
+ }
64
+ /** First-publish key: `my-<slug>-<6 hex>`. */
65
+ export function generateTemplateKey(dirName, hex = randomBytes(3).toString('hex')) {
66
+ return `my-${kebabSlug(dirName)}-${hex}`;
67
+ }
68
+ export function readIdentity(templateDir) {
69
+ const file = path.join(templateDir, IDENTITY_FILE);
70
+ if (!existsSync(file))
71
+ return null;
72
+ const raw = readJsonFile(file);
73
+ if (typeof raw.templateKey !== 'string' || !raw.templateKey) {
74
+ throw new SkillError(`❌ ${IDENTITY_FILE} 缺少 templateKey,请修复或删除该文件后重试`);
75
+ }
76
+ return { templateKey: raw.templateKey, version: typeof raw.version === 'number' ? raw.version : 0 };
77
+ }
78
+ /** Stable key: the identity file wins, so renaming the directory never forks a template. */
79
+ export function resolveTemplateKey(templateDir) {
80
+ const id = readIdentity(templateDir);
81
+ if (id)
82
+ return { templateKey: id.templateKey, existing: true };
83
+ return { templateKey: generateTemplateKey(path.basename(templateDir)), existing: false };
84
+ }
85
+ export function writeIdentity(templateDir, identity) {
86
+ writeFileSync(path.join(templateDir, IDENTITY_FILE), JSON.stringify(identity, null, 2) + '\n', 'utf8');
87
+ }
88
+ // ── output ───────────────────────────────────────────────────────────────────
89
+ /**
90
+ * Human text goes to stdout by default. With --json it moves to stderr so the
91
+ * last stdout line is the JSON result (progress lines still go to stdout —
92
+ * hosts parse them by their `__progress__` marker).
93
+ */
94
+ export class Output {
95
+ json;
96
+ constructor(json) {
97
+ this.json = json;
98
+ }
99
+ info(line = '') {
100
+ ;
101
+ (this.json ? process.stderr : process.stdout).write(line + '\n');
102
+ }
103
+ warn(line) {
104
+ process.stderr.write(line + '\n');
105
+ }
106
+ result(obj) {
107
+ if (this.json)
108
+ process.stdout.write(JSON.stringify(obj) + '\n');
109
+ }
110
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `remixmate template publish <name> [--name <displayName>] [--dry-run]`
3
+ *
4
+ * Ported from template-library `scripts/publish-private.mjs` — same ab-api
5
+ * contract (prepareSave → presignUpload → PUT → save, isBuiltin=false), same
6
+ * tar excludes. Differences, all from the spec:
7
+ * - the key is stable per template directory (K4 `.remixmate.json`), so a
8
+ * re-publish updates instead of forking, and renaming the dir is harmless;
9
+ * - the starter's own `check` runs first (requirement 6.2);
10
+ * - the project files stay read-only except for `.remixmate.json` — server
11
+ * normalization only touches a staging copy.
12
+ *
13
+ * Never printed: the token and the presigned upload URL.
14
+ */
15
+ import { type CoreSurface } from './render-client.js';
16
+ export interface PublishOptions {
17
+ name: string;
18
+ displayName?: string;
19
+ dryRun?: boolean;
20
+ json?: boolean;
21
+ token?: string;
22
+ apiBaseUrl?: string;
23
+ cwd?: string;
24
+ }
25
+ export interface PublishResult {
26
+ ok: true;
27
+ name: string;
28
+ templateKey: string;
29
+ version: number | null;
30
+ dryRun: boolean;
31
+ created: boolean;
32
+ }
33
+ type Json = Record<string, any>;
34
+ /** tar argv with the shared exclude list — exported for tests. */
35
+ export declare function tarArgs(srcDir: string, outFile: string): string[];
36
+ /**
37
+ * Core preflight: the template's declared coreExports must all exist on the
38
+ * target ab-render (the tar never carries core). Returns a block reason or null.
39
+ * "Couldn't check" is a block, not a pass.
40
+ */
41
+ export declare function evaluateCoreSurface(manifest: Json, surface: CoreSurface | null, warn?: (m: string) => void): string | null;
42
+ export declare function runPublish(opts: PublishOptions): Promise<PublishResult>;
43
+ export {};
@@ -0,0 +1,182 @@
1
+ /**
2
+ * `remixmate template publish <name> [--name <displayName>] [--dry-run]`
3
+ *
4
+ * Ported from template-library `scripts/publish-private.mjs` — same ab-api
5
+ * contract (prepareSave → presignUpload → PUT → save, isBuiltin=false), same
6
+ * tar excludes. Differences, all from the spec:
7
+ * - the key is stable per template directory (K4 `.remixmate.json`), so a
8
+ * re-publish updates instead of forking, and renaming the dir is harmless;
9
+ * - the starter's own `check` runs first (requirement 6.2);
10
+ * - the project files stay read-only except for `.remixmate.json` — server
11
+ * normalization only touches a staging copy.
12
+ *
13
+ * Never printed: the token and the presigned upload URL.
14
+ */
15
+ import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ import { EXIT, SkillError } from '../errors.js';
19
+ import { mmPost, resolveHttpContext } from '../http.js';
20
+ import { emitProgress } from '../progress.js';
21
+ import { IDENTITY_FILE, TAR_EXCLUDES } from './constants.js';
22
+ import { runCheck } from './check.js';
23
+ import { Output, readJsonFile, requireProjectRoot, requireTemplateDir, resolveTemplateKey, validateTemplateName, writeIdentity, } from './project.js';
24
+ import { runProcess } from './proc.js';
25
+ import { fetchCoreSurface, resolveRenderBaseUrl } from './render-client.js';
26
+ /** tar argv with the shared exclude list — exported for tests. */
27
+ export function tarArgs(srcDir, outFile) {
28
+ const args = ['-czf', outFile];
29
+ for (const ex of TAR_EXCLUDES)
30
+ args.push(`--exclude=${ex}`);
31
+ args.push('-C', srcDir, '.');
32
+ return args;
33
+ }
34
+ /**
35
+ * Core preflight: the template's declared coreExports must all exist on the
36
+ * target ab-render (the tar never carries core). Returns a block reason or null.
37
+ * "Couldn't check" is a block, not a pass.
38
+ */
39
+ export function evaluateCoreSurface(manifest, surface, warn = () => { }) {
40
+ if (!surface)
41
+ return 'ab-render /health 未返回 coreSurface —— 该实例版本过旧,无法校验 core 契约';
42
+ if (!surface.available)
43
+ return `ab-render 无法读取 template-core:${surface.error ?? '未知原因'}`;
44
+ if (!Array.isArray(manifest.coreExports)) {
45
+ return 'template.json 缺少 coreExports —— 先运行 remixmate template check 生成';
46
+ }
47
+ const have = new Set(surface.exports ?? []);
48
+ const missing = manifest.coreExports.filter((n) => !have.has(n));
49
+ const declared = typeof manifest.coreVersion === 'string' ? manifest.coreVersion : null;
50
+ // `versions` is a K5 addition; older instances only expose packageVersion.
51
+ const offered = Array.isArray(surface.versions) && surface.versions.length > 0
52
+ ? surface.versions
53
+ : surface.packageVersion ? [surface.packageVersion] : [];
54
+ if (missing.length > 0) {
55
+ return (`目标 ab-render 的 template-core 缺少导出:${missing.join(', ')}。` +
56
+ `模板声明需要 core@${declared ?? '(未声明)'},平台当前提供 ${offered.length ? offered.map((v) => `core@${v}`).join(', ') : '(未知版本)'}。` +
57
+ '请改用平台已有的导出,或等平台升级 core 后再发布。');
58
+ }
59
+ if (declared && offered.length > 0 && !offered.includes(declared)) {
60
+ warn(` ⚠ 模板声明 core@${declared},平台提供 ${offered.join(', ')} —— 导出齐全可以渲染,但效果未必与本地完全一致`);
61
+ }
62
+ return null;
63
+ }
64
+ function tarball(srcDir, outFile) {
65
+ return runProcess('tar', tarArgs(srcDir, outFile), { stdio: 'pipe' }).then((r) => {
66
+ if (r.code !== 0)
67
+ throw new SkillError(`❌ 打包失败(tar 退出码 ${r.code}): ${r.stderr.trim().slice(0, 300)}`);
68
+ });
69
+ }
70
+ async function putToOss(uploadUrl, bytes) {
71
+ let res;
72
+ try {
73
+ // Content-Type must equal what ab-api signed, or OSS answers SignatureDoesNotMatch.
74
+ res = await fetch(uploadUrl, { method: 'PUT', headers: { 'Content-Type': 'application/gzip' }, body: new Uint8Array(bytes) });
75
+ }
76
+ catch (err) {
77
+ throw new SkillError(`❌ 上传模板包失败(网络错误): ${err.message}`, EXIT.BACKEND_UNREACHABLE);
78
+ }
79
+ if (res.status >= 300) {
80
+ // Body/URL intentionally omitted: the URL is presigned.
81
+ throw new SkillError(`❌ 上传模板包失败(OSS HTTP ${res.status})`, res.status >= 500 ? EXIT.BACKEND_UNREACHABLE : EXIT.ERROR);
82
+ }
83
+ }
84
+ export async function runPublish(opts) {
85
+ const out = new Output(Boolean(opts.json));
86
+ const name = validateTemplateName(opts.name);
87
+ const root = requireProjectRoot(opts.cwd);
88
+ const tplDir = requireTemplateDir(root, name);
89
+ emitProgress({ phase: 'template-publish:check', progress: 0.05 });
90
+ const checkCode = await runCheck({ name, cwd: root, stdio: opts.json ? 'stderr' : 'inherit' });
91
+ if (checkCode !== 0) {
92
+ throw new SkillError(`❌ 本地校验未通过(check 退出码 ${checkCode}),已中止发布。`, checkCode === 2 ? EXIT.USAGE : EXIT.ERROR);
93
+ }
94
+ // Re-read after check: it refreshes coreVersion / coreExports.
95
+ const templateJson = readJsonFile(path.join(tplDir, 'template.json'));
96
+ const { templateKey, existing } = resolveTemplateKey(tplDir);
97
+ const displayName = opts.displayName ?? (typeof templateJson.name === 'string' && templateJson.name ? templateJson.name : name);
98
+ const coreVersion = typeof templateJson.coreVersion === 'string' ? templateJson.coreVersion : '';
99
+ // Credentials before any network call, so a missing login is exit 4 rather
100
+ // than a confusing preflight failure. Dry-run needs none.
101
+ let ctx = null;
102
+ if (!opts.dryRun) {
103
+ ctx = await resolveHttpContext('template-publish', { privateToken: opts.token, apiBaseUrl: opts.apiBaseUrl });
104
+ }
105
+ const renderBase = resolveRenderBaseUrl();
106
+ out.info(`模板 ${name}`);
107
+ out.info(`templateKey ${templateKey}${existing ? '' : '(首次发布,新生成)'}`);
108
+ out.info(`名称 ${displayName}`);
109
+ out.info(`coreVersion ${coreVersion || '(未声明)'}`);
110
+ emitProgress({ phase: 'template-publish:preflight', progress: 0.15 });
111
+ const surface = await fetchCoreSurface(renderBase);
112
+ const blocked = evaluateCoreSurface(templateJson, surface, (m) => out.warn(m));
113
+ if (blocked)
114
+ throw new SkillError(`❌ core 预检未通过:${blocked}`);
115
+ out.info(`core 预检 通过(${surface?.exports.length ?? 0} 个导出)`);
116
+ if (opts.dryRun) {
117
+ out.info('\n[dry-run] 将依次调用 prepareSave → presignUpload → 上传 → save(isBuiltin=false)');
118
+ out.info(`[dry-run] 打包排除:${TAR_EXCLUDES.join(', ')}(保留 dsl-example.json)`);
119
+ const result = { ok: true, name, templateKey, version: null, dryRun: true, created: !existing };
120
+ out.result({ ...result });
121
+ return result;
122
+ }
123
+ const http = ctx;
124
+ let staging;
125
+ try {
126
+ emitProgress({ phase: 'template-publish:prepare', progress: 0.3 });
127
+ const prepared = await mmPost(http, '/remotionTemplate/prepareSave', {
128
+ templateJson,
129
+ name: displayName,
130
+ templateKey,
131
+ ...(coreVersion ? { coreVersion } : {}),
132
+ });
133
+ const resolvedKey = prepared?.templateKey || templateKey;
134
+ const normalized = prepared?.templateJson || templateJson;
135
+ // Staging copy: server normalization never touches the project files.
136
+ staging = mkdtempSync(path.join(os.tmpdir(), 'remixmate-publish-'));
137
+ const pkgDir = path.join(staging, 'pkg');
138
+ cpSync(tplDir, pkgDir, { recursive: true });
139
+ rmSync(path.join(pkgDir, IDENTITY_FILE), { force: true });
140
+ writeFileSync(path.join(pkgDir, 'template.json'), JSON.stringify(normalized, null, 2) + '\n', 'utf8');
141
+ if (JSON.stringify(normalized) !== JSON.stringify(templateJson)) {
142
+ out.warn(' ⚠ 服务端规范化后的 template.json 与本地不同;本次上传使用规范化版本,本地文件未改动。');
143
+ }
144
+ emitProgress({ phase: 'template-publish:pack', progress: 0.45 });
145
+ const tarPath = path.join(staging, `${resolvedKey}.tar.gz`);
146
+ await tarball(pkgDir, tarPath);
147
+ const bytes = readFileSync(tarPath);
148
+ out.info(`打包 ${(bytes.length / 1024).toFixed(0)} KB`);
149
+ emitProgress({ phase: 'template-publish:presign', progress: 0.55 });
150
+ const grant = await mmPost(http, '/remotionTemplate/presignUpload', { templateKey: resolvedKey });
151
+ if (!grant?.uploadUrl)
152
+ throw new SkillError('❌ presignUpload 未返回 uploadUrl');
153
+ emitProgress({ phase: 'template-publish:upload', progress: 0.7 });
154
+ await putToOss(String(grant.uploadUrl), bytes);
155
+ emitProgress({ phase: 'template-publish:save', progress: 0.85 });
156
+ await mmPost(http, '/remotionTemplate/save', {
157
+ templateKey: resolvedKey,
158
+ name: displayName,
159
+ isBuiltin: false,
160
+ sourceOssKey: grant.sourceOssKey,
161
+ version: grant.version,
162
+ templateJson: normalized,
163
+ ...(coreVersion ? { coreVersion } : {}),
164
+ });
165
+ const version = typeof grant.version === 'number' ? grant.version : Number(grant.version) || 0;
166
+ writeIdentity(tplDir, { templateKey: resolvedKey, version });
167
+ emitProgress({ phase: 'template-publish:done', progress: 1, templateKey: resolvedKey });
168
+ out.info(`\n✅ 已发布为你的私有模板`);
169
+ out.info(` templateKey ${resolvedKey}`);
170
+ out.info(` version ${version}`);
171
+ out.info(` (已写入 templates/${name}/${IDENTITY_FILE},再次发布会更新同一个模板)`);
172
+ out.info('\n出片:');
173
+ out.info(` remixmate render-video --template-id ${resolvedKey} …`);
174
+ const result = { ok: true, name, templateKey: resolvedKey, version, dryRun: false, created: !existing };
175
+ out.result({ ...result });
176
+ return result;
177
+ }
178
+ finally {
179
+ if (staging)
180
+ rmSync(staging, { recursive: true, force: true });
181
+ }
182
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Minimal ab-render client for preview (/renderDraft) and publish preflight (/health).
3
+ *
4
+ * ab-render is a different origin from ab-api but verifies the same PrivToken,
5
+ * so the token is resolved against the ab-api base (where `remixmate login`
6
+ * stored it) and sent to ab-render as X-Priv-Token. All routes are POST.
7
+ */
8
+ import { SkillError } from '../errors.js';
9
+ export declare function resolveRenderBaseUrl(): string;
10
+ /** Business error from ab-render (`code != 0`), payload kept for issue rendering. */
11
+ export declare class RenderBusinessError extends SkillError {
12
+ readonly code: number;
13
+ readonly payload: {
14
+ code?: number;
15
+ msg?: string;
16
+ data?: unknown;
17
+ };
18
+ constructor(message: string, code: number, payload: {
19
+ code?: number;
20
+ msg?: string;
21
+ data?: unknown;
22
+ });
23
+ }
24
+ export declare function renderPost<T = Record<string, unknown>>(base: string, route: string, body: unknown, opts: {
25
+ token?: string;
26
+ skillName: string;
27
+ timeoutMs?: number;
28
+ }): Promise<T>;
29
+ export interface CoreSurface {
30
+ available: boolean;
31
+ exports: string[];
32
+ digest?: string;
33
+ packageVersion?: string | null;
34
+ /** K5 addition; older ab-render instances omit it. */
35
+ versions?: string[];
36
+ error?: string | null;
37
+ }
38
+ /** /health → coreSurface, or null when the instance predates the core contract. */
39
+ export declare function fetchCoreSurface(base: string): Promise<CoreSurface | null>;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Minimal ab-render client for preview (/renderDraft) and publish preflight (/health).
3
+ *
4
+ * ab-render is a different origin from ab-api but verifies the same PrivToken,
5
+ * so the token is resolved against the ab-api base (where `remixmate login`
6
+ * stored it) and sent to ab-render as X-Priv-Token. All routes are POST.
7
+ */
8
+ import { traceHeaders } from '../diagnostics.js';
9
+ import { NOT_AUTHENTICATED_HINT } from '../auth/resolve.js';
10
+ import { EXIT, SkillError } from '../errors.js';
11
+ import { DEFAULT_RENDER_BASE_URL } from './constants.js';
12
+ export function resolveRenderBaseUrl() {
13
+ return ((process.env.AB_RENDER_URL ?? '').trim() || DEFAULT_RENDER_BASE_URL).replace(/\/+$/, '');
14
+ }
15
+ /** Business error from ab-render (`code != 0`), payload kept for issue rendering. */
16
+ export class RenderBusinessError extends SkillError {
17
+ code;
18
+ payload;
19
+ constructor(message, code, payload) {
20
+ super(message, EXIT.ERROR);
21
+ this.code = code;
22
+ this.payload = payload;
23
+ }
24
+ }
25
+ export async function renderPost(base, route, body, opts) {
26
+ const controller = new AbortController();
27
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 60_000);
28
+ const headers = {
29
+ 'Content-Type': 'application/json',
30
+ 'x-invoke-skill': opts.skillName,
31
+ ...traceHeaders(),
32
+ };
33
+ if (opts.token)
34
+ headers['X-Priv-Token'] = opts.token;
35
+ let res;
36
+ try {
37
+ res = await fetch(`${base}${route}`, {
38
+ method: 'POST',
39
+ headers,
40
+ body: JSON.stringify(body ?? {}),
41
+ signal: controller.signal,
42
+ });
43
+ }
44
+ catch (err) {
45
+ const why = err.name === 'AbortError' ? '请求超时' : err.message;
46
+ throw new SkillError(`❌ ab-render 不可达(${base}${route}):${why}`, EXIT.BACKEND_UNREACHABLE);
47
+ }
48
+ finally {
49
+ clearTimeout(timer);
50
+ }
51
+ const text = await res.text();
52
+ if (res.status === 401)
53
+ throw new SkillError(NOT_AUTHENTICATED_HINT, EXIT.NOT_AUTHENTICATED);
54
+ if (res.status >= 500) {
55
+ throw new SkillError(`❌ ab-render ${route} HTTP ${res.status}`, EXIT.BACKEND_UNREACHABLE);
56
+ }
57
+ let json;
58
+ try {
59
+ json = JSON.parse(text);
60
+ }
61
+ catch {
62
+ throw new SkillError(`❌ ab-render ${route} 返回非 JSON(HTTP ${res.status})`);
63
+ }
64
+ if (json.code !== undefined && json.code !== 0) {
65
+ if (json.code === 401) {
66
+ throw new SkillError(`${NOT_AUTHENTICATED_HINT}\n (ab-render: ${json.msg ?? 'code=401'})`, EXIT.NOT_AUTHENTICATED);
67
+ }
68
+ throw new RenderBusinessError(`❌ ab-render ${route} code=${json.code}: ${json.msg ?? 'unknown'}`, json.code, json);
69
+ }
70
+ return (json.data ?? json);
71
+ }
72
+ /** /health → coreSurface, or null when the instance predates the core contract. */
73
+ export async function fetchCoreSurface(base) {
74
+ const data = await renderPost(base, '/health', {}, {
75
+ skillName: 'template-publish',
76
+ timeoutMs: 15_000,
77
+ });
78
+ return data?.coreSurface ?? null;
79
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remixmate/cli",
3
- "version": "0.9.63",
3
+ "version": "0.9.65",
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",
@@ -0,0 +1,69 @@
1
+ ---
2
+ name: template-authoring
3
+ description: |
4
+ Create your own reusable video template locally and publish it as a private template on RemixMate.
5
+ Scaffolds a starter project (`remixmate template init <dir>`) with Remotion Studio preview, local validation,
6
+ remote draft preview and one-command publishing. Purely local until preview/publish; no login needed to start.
7
+ Use this skill whenever the user wants to:
8
+ - 做视频模板 / 自定义视频模板 / 创建模板 / 自己写一个模板 / 把这个画面做成模板
9
+ - template authoring, create a video template, build a custom Remotion template
10
+ - preview or publish a template they are developing locally
11
+ triggers:
12
+ - 做视频模板
13
+ - 自定义视频模板
14
+ - 创建模板 / 新建模板 / 自己写一个视频模板
15
+ - 预览或发布我本地开发的模板
16
+ - template authoring
17
+ - create video template / build a custom video template
18
+ - publish my own Remotion template
19
+ ---
20
+
21
+ # Template Authoring
22
+
23
+ Guides a local agent (Codex, Claude Code, WorkBuddy, TraeWork) through building a video template
24
+ on the RemixMate template SDK and publishing it as the user's **private** template.
25
+
26
+ The project itself carries the authoritative guide: after `init`, read the project's `AGENTS.md`
27
+ (Claude Code: `CLAUDE.md`, same content). It documents the template contract, the template-core
28
+ public API and the Remotion rules. This skill only describes the workflow — do not invent template
29
+ fields; take them from that guide and from the bundled examples.
30
+
31
+ ## Workflow
32
+
33
+ 1. **Create the project** (no login):
34
+ ```bash
35
+ remixmate template init <dir>
36
+ ```
37
+ Refuses a non-empty directory unless `--force`. Needs only Node ≥ 18 and npm.
38
+ As a skill call: `remixmate template-authoring --dir <dir> [--force]`.
39
+ 2. **Open the project and read `AGENTS.md`** before writing any code. Examples live in
40
+ `examples/` (read-only — copy one into `templates/<name>/` and edit the copy).
41
+ 3. **Develop with live preview**: `npm run dev` opens Remotion Studio.
42
+ 4. **Validate locally** (no login):
43
+ ```bash
44
+ remixmate template check <name> # add --json for a machine-readable issue list
45
+ ```
46
+ Fix every reported issue (`file` / `rule` / `message` / `fix`). `check` also refreshes the
47
+ template's core contract fields — never hand-edit those.
48
+ 5. **Remote draft preview** (login required): renders on the real platform renderer.
49
+ ```bash
50
+ remixmate template preview <name>
51
+ ```
52
+ Draft limits: long edge ≤ 1280 px, short edge ≤ 720 px, ≤ 60 s.
53
+ 6. **Publish** (login required):
54
+ ```bash
55
+ remixmate template publish <name> [--name "<display name>"]
56
+ ```
57
+ The first publish writes `templates/<name>/.remixmate.json` with the template key; publishing
58
+ again updates the same template. Keep that file.
59
+ 7. **Render with it**:
60
+ ```bash
61
+ remixmate render-video --template-id <templateKey> ...
62
+ ```
63
+ `remixmate template list` shows the user's private templates.
64
+
65
+ ## Notes
66
+
67
+ - Not logged in → exit code 4: run `remixmate login`. Backend unreachable → exit code 5.
68
+ - Quota errors from publish (template count / package size / file count) are shown verbatim; relay them.
69
+ - TraeWork only reads project-level skills: inside the project run `remixmate install --project` once.
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "template-authoring",
3
+ "toolName": "template_authoring",
4
+ "tier": "tool",
5
+ "category": "meta",
6
+ "title": "Video Template Authoring",
7
+ "description": "Start authoring a custom video template locally: scaffolds a starter project (Remotion Studio preview, local validation, remote draft preview, publishing as the user's private template) into the given directory. After this, follow the project's AGENTS.md and use `remixmate template check|preview|publish`. Local-only; requires Node >= 18 and npm.",
8
+ "auth": "none",
9
+ "envVars": [
10
+ "REMIXMATE_STARTER_TARBALL"
11
+ ],
12
+ "entry": {
13
+ "type": "builtin",
14
+ "handler": "template-init"
15
+ },
16
+ "parameters": {
17
+ "type": "object",
18
+ "properties": {
19
+ "dir": {
20
+ "type": "string",
21
+ "description": "Target directory for the new template project (must be empty unless force is set)"
22
+ },
23
+ "force": {
24
+ "type": "boolean",
25
+ "description": "Allow scaffolding into a non-empty directory (existing files with the same name are overwritten)"
26
+ }
27
+ },
28
+ "required": [
29
+ "dir"
30
+ ]
31
+ }
32
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "skillName": "template-authoring",
3
+ "repoName": "agent-skill-media-maker",
4
+ "skillId": "pending-template-authoring",
5
+ "version": "V1",
6
+ "skillDescription": "Local video-template authoring skill: scaffolds a RemixMate template starter project (`remixmate template init <dir>`), then guides the agent through the project's AGENTS.md, `npm run dev`, `remixmate template check`, `remixmate template preview`, `remixmate template publish`, and rendering with `remixmate render-video --template-id <templateKey>`.\n\nUse this skill whenever the user asks for any of:\n- 做视频模板 / 自定义视频模板 / 创建模板\n- template authoring, create a video template\n- preview or publish a template they develop locally"
7
+ }
@@ -78,7 +78,7 @@ remixmate template-registry --list-templates
78
78
 
79
79
  ### Show templates to the user
80
80
 
81
- When the user asks to see / browse / pick templates, they decide by *watching* samples, not by reading definitions. The list adds, per template, the number of samples and a link to its web detail page (playable samples, layouts, a copy-ready prompt) whenever the backend serves them (`↳ samples: N · <url>` in the table; `showcases.count` / `showcases.pageUrl` in JSON).
81
+ When the user asks to see / browse / pick templates, they decide by *watching* samples, not by reading definitions. The list adds, per template, a link to its web detail page (playable samples, layouts, a copy-ready prompt) and the number of samples whenever the backend serves them — table: `↳ detail: <url>` and `↳ samples: N`; JSON: each summary's `pageUrl` and `showcases.count`; `--template-id`: top-level `pageUrls`.
82
82
 
83
83
  - Present each template as: name, aspect ratios, sample count, and its `pageUrl` as a clickable Markdown link. Keep the URL exactly as printed.
84
84
  - When the user wants to see what one template looks like, give its link, or run `remixmate template-registry --open <templateId>` (prints the URL; opens the browser only from an interactive terminal — relay the printed URL either way).
@@ -111,7 +111,7 @@ print(json.dumps(binding, ensure_ascii=False, indent=2))
111
111
  | `--filter-aspect` | Keep only templates declaring this aspect ratio (e.g. `9:16`). | none |
112
112
  | `--filter-language` | Keep only templates whose `contentLanguage` includes this code (`zh`/`en`); language-agnostic templates always show. | none |
113
113
  | `--include-beta` | Also show `status: beta` templates (same effect as `ENABLE_BETA_TEMPLATES=1`). | off |
114
- | `--json-output` | List mode: emit `{ "templates": [...] }` summaries (or full definitions with `--full`) instead of the table. Detail mode (`--template-id`) is always compact JSON. Summaries carry `showcases: { count, pageUrl, items }` and detail mode a top-level `showcases` map when the backend serves samples (absent otherwise). | off |
114
+ | `--json-output` | List mode: emit `{ "templates": [...] }` summaries (or full definitions with `--full`) instead of the table. Detail mode (`--template-id`) is always compact JSON. Summaries carry `pageUrl` (web detail page) and `showcases: { count, pageUrl, items }`; detail mode carries top-level `pageUrls` and `showcases` maps. Each is absent when the backend does not serve it. | off |
115
115
  | `--open <id>` | Print the template's web detail page URL (samples, layouts) and open the browser when run from an interactive terminal. Exits 1 when the backend has no link for it. Cannot be combined with `--template-id`. | — |
116
116
 
117
117
  ### List vs. detail