@remixmate/cli 0.9.15 → 0.9.17

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 (48) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dist/capabilities.d.ts +5 -1
  4. package/dist/cli.js +12 -0
  5. package/dist/doctor.js +21 -0
  6. package/dist/manifest.json +13 -13
  7. package/dist/project/commands.d.ts +19 -0
  8. package/dist/project/commands.js +156 -0
  9. package/dist/project/host.d.ts +77 -0
  10. package/dist/project/host.js +106 -0
  11. package/dist/project/resolve.d.ts +43 -0
  12. package/dist/project/resolve.js +65 -0
  13. package/dist/project/store.d.ts +31 -0
  14. package/dist/project/store.js +97 -0
  15. package/dist/project/take.d.ts +46 -0
  16. package/dist/project/take.js +102 -0
  17. package/dist/registry.d.ts +4 -0
  18. package/dist/registry.js +2 -0
  19. package/dist/runner.js +59 -0
  20. package/dist/skill-schema.d.ts +14 -0
  21. package/dist/skill-schema.js +15 -0
  22. package/package.json +1 -1
  23. package/skills/export-jianying/version.json +1 -1
  24. package/skills/gen-digital-human/skill.json +78 -11
  25. package/skills/gen-digital-human/version.json +1 -1
  26. package/skills/gen-image/SKILL.md +40 -18
  27. package/skills/gen-image/skill.json +71 -12
  28. package/skills/gen-image/version.json +1 -1
  29. package/skills/gen-script/SKILL.md +17 -21
  30. package/skills/gen-script/version.json +1 -1
  31. package/skills/gen-video/SKILL.md +5 -3
  32. package/skills/gen-video/skill.json +77 -13
  33. package/skills/gen-video/version.json +1 -1
  34. package/skills/gen-voice/SKILL.md +1 -1
  35. package/skills/gen-voice/skill.json +34 -8
  36. package/skills/gen-voice/version.json +1 -1
  37. package/skills/prepare-video-assets/skill.json +43 -9
  38. package/skills/prepare-video-assets/version.json +1 -1
  39. package/skills/render-video/skill.json +43 -8
  40. package/skills/render-video/version.json +1 -1
  41. package/skills/template-registry/version.json +1 -1
  42. package/skills/video-parser/skill.json +29 -7
  43. package/skills/video-parser/version.json +1 -1
  44. package/skills/web-record/SKILL.md +131 -133
  45. package/skills/web-record/skill.json +137 -33
  46. package/skills/web-screenshot/SKILL.md +93 -96
  47. package/skills/web-screenshot/skill.json +66 -16
  48. package/skills/web-screenshot/version.json +1 -1
package/README.md CHANGED
@@ -296,7 +296,7 @@ remixmate template-registry --list-templates
296
296
  ### Quick start
297
297
 
298
298
  ```
299
- @skills/gen-image/SKILL.md Generate an image of a panda, 9:16, using gemini 3.1, Chinese-painting style + follow this doc strictly
299
+ @skills/gen-image/SKILL.md Generate an image of a panda, 9:16, using gemini, Chinese-painting style + follow this doc strictly
300
300
  @skills/gen-video/SKILL.md Generate a video of a panda running in a bamboo forest, 9:16, 6 seconds, using veo + follow this doc strictly
301
301
  @skills/gen-voice/SKILL.md Generate a voiceover introducing panda habits, around 100 words + follow this doc strictly
302
302
  @skills/gen-digital-human/SKILL.md Get the digital human list + follow this doc strictly
package/README.zh-CN.md CHANGED
@@ -232,7 +232,7 @@ remixmate template-registry --list-templates
232
232
  ### 快速体验
233
233
 
234
234
  ```
235
- @skills/gen-image/SKILL.md 生成一张熊猫的图片,9:16,调用gemini 3.1,国画风 + 严格按该文档执行
235
+ @skills/gen-image/SKILL.md 生成一张熊猫的图片,9:16,调用gemini,国画风 + 严格按该文档执行
236
236
  @skills/gen-video/SKILL.md 生成一段熊猫在竹林奔跑的视频,9:16,长度6秒,调用veo + 严格按该文档执行
237
237
  @skills/gen-voice/SKILL.md 生成一段语音,介绍熊猫的习性,大概100字左右 + 严格按该文档执行
238
238
  @skills/gen-digital-human/SKILL.md 获取数字人列表 + 严格按该文档执行
@@ -19,8 +19,12 @@ export interface ModelConstraints {
19
19
  durationDefault?: number;
20
20
  refImageMax?: number;
21
21
  sizes?: string[];
22
- /** aspect-ratio → [width, height] pixel preset (Seedream). */
22
+ /** aspect-ratio → [width, height] pixel preset (Seedream). Per model: the same
23
+ * ratio maps to different pixels on different Seedream variants. */
23
24
  sizePresets?: Record<string, [number, number]>;
25
+ /** total-pixel bounds (width × height); the backend rescales sizes outside them. */
26
+ pixelMin?: number;
27
+ pixelMax?: number;
24
28
  }
25
29
  export interface ModelDescriptor {
26
30
  id: string;
package/dist/cli.js CHANGED
@@ -15,6 +15,7 @@ import { loadSkills, SKILLS_DIR } from './registry.js';
15
15
  import { parseArgv } from './argv.js';
16
16
  import { runSkill } from './runner.js';
17
17
  import { runAuthCommand } from './auth/commands.js';
18
+ import { HOST_VERB, PROJECT_VERB, runHostCommand, runProjectCommand } from './project/commands.js';
18
19
  import { runExec } from './exec.js';
19
20
  import { authStatusLine, runDoctor } from './doctor.js';
20
21
  import { padDisplay } from './text.js';
@@ -56,6 +57,10 @@ function printUsage() {
56
57
  process.stdout.write(' remixmate logout Remove stored credentials\n');
57
58
  process.stdout.write(' remixmate whoami Show the current identity\n');
58
59
  process.stdout.write(' remixmate exec -- <cmd> [args...] Run a command with credentials injected\n');
60
+ process.stdout.write(' remixmate project list List your projects\n');
61
+ process.stdout.write(' remixmate project use <prj> [--global] Bind output to a project (this dir, or globally)\n');
62
+ process.stdout.write(' remixmate project show Show the current project binding\n');
63
+ process.stdout.write(' remixmate host probe Diagnose host detection and take grouping\n');
59
64
  process.stdout.write(' remixmate <name> [--flag value ...] Invoke a skill\n');
60
65
  process.stdout.write(' remixmate <name> --help Show skill-specific help\n');
61
66
  process.stdout.write('\nAvailable skills:\n');
@@ -132,6 +137,13 @@ async function main() {
132
137
  const code = await runAuthCommand(argv[0], argv.slice(1));
133
138
  process.exit(code);
134
139
  }
140
+ // Project / host verbs, like the auth verbs, are resolved before skill dispatch.
141
+ if (argv[0] === PROJECT_VERB) {
142
+ process.exit(await runProjectCommand(argv.slice(1)));
143
+ }
144
+ if (argv[0] === HOST_VERB) {
145
+ process.exit(await runHostCommand(argv.slice(1)));
146
+ }
135
147
  if (argv[0] === 'exec') {
136
148
  // Raw argv (not parsed) — everything after `--` belongs to the child command.
137
149
  const code = await runExec(argv.slice(1));
package/dist/doctor.js CHANGED
@@ -17,6 +17,8 @@ import { resolveApiBaseUrl } from './http.js';
17
17
  import { EXIT } from './errors.js';
18
18
  import { resolvePrivToken } from './auth/resolve.js';
19
19
  import { listCredentials } from './auth/credential-store.js';
20
+ import { resolveTakeKey } from './project/host.js';
21
+ import { readBinding } from './project/store.js';
20
22
  import { canAutoAuth, isHeadless } from './auth/environment.js';
21
23
  import { padDisplay } from './text.js';
22
24
  /**
@@ -98,6 +100,25 @@ export async function runDoctor(opts = {}) {
98
100
  detail: playwright ?? '当前解释器下未安装 —— 仅 web-screenshot / web-record 需要,首次运行会自动装',
99
101
  });
100
102
  checks.push({ name: '后端地址', status: 'ok', detail: apiBaseUrl });
103
+ // Where will this machine's renders land, and will repeated renders group?
104
+ // Both were previously invisible — the first sign of trouble was a finished
105
+ // video that appeared nowhere in the web app.
106
+ const binding = await readBinding(apiBaseUrl);
107
+ checks.push({
108
+ name: '项目归属',
109
+ status: 'ok',
110
+ detail: binding
111
+ ? `${binding.projectId}(${binding.scope === 'path' ? '目录绑定' : '全局绑定'})`
112
+ : '未绑定 → 产物落进默认项目「未分类」(remixmate project use <prj> 可改)',
113
+ });
114
+ const take = resolveTakeKey(undefined);
115
+ checks.push({
116
+ name: 'take 分组',
117
+ status: take.source === 'none' ? 'warn' : 'ok',
118
+ detail: take.source === 'none'
119
+ ? '宿主未提供会话 ID —— 每次渲染各自成为一个 take。可 export REMIXMATE_TAKE_KEY 手动分组,详见 remixmate host probe'
120
+ : `${take.key}(宿主:${take.host || 'unknown'},来源:${take.source})`,
121
+ });
101
122
  const resolved = await resolvePrivToken({ apiBaseUrl });
102
123
  const stored = await listCredentials();
103
124
  if (resolved) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.9.15",
4
- "generatedAt": "2026-08-14T08:23:17.130Z",
3
+ "version": "0.9.17",
4
+ "generatedAt": "2026-08-29T15:20:08.872Z",
5
5
  "skills": [
6
6
  {
7
7
  "id": "export-jianying",
@@ -54,7 +54,7 @@
54
54
  "tier": "atomic",
55
55
  "category": "asset",
56
56
  "title": "AI Image Generation",
57
- "summary": "AI image generation: produce an image from a text prompt. Supports Seedream and Gemini models, plus image-to-image with reference images.",
57
+ "summary": "AI image generation: produce an image from a text prompt. Supports the Seedream family (including a high-fidelity 'pro' variant) and Gemini, plus image-to-image with reference images.",
58
58
  "triggers": [
59
59
  "AI image generation, text-to-image, \"draw me ...\", \"generate an image of ...\"",
60
60
  "Image-to-image, reference image, style transfer, image variation",
@@ -247,11 +247,11 @@
247
247
  "title": "Web Page Recording",
248
248
  "summary": "Drive a headless browser (Playwright Python) to RECORD any URL to a video, then (by default) transcode to mp4, grab a cover frame, upload to VOD and return a playable CDN URL. Modes: fixed-duration recording, condition-triggered stop (element appears / disappears), auto-scroll from top to bottom, custom storyboards, and parameterized templates. Storyboard scenes: highlight / focus / zoom / scroll / virtual-cursor click / type / hover / caption / title-card / arrow / numbered sequence / redact / code-line highlight. For still images (png/jpg) use the web_screenshot tool instead. Use this tool whenever the user wants a video / recording / screencast of a web page: record a page, scroll-through video, page-from-top-to-bottom clip, demo of clicks/typing/hover, storyboard / multi-scene intro video, or a template-based clip.",
249
249
  "triggers": [
250
- "录屏、网页录制、录制视频、录一段操作、生成 webmscreencast",
251
- "滚动录屏、页面从头划到尾的视频",
252
- "多场景视频、分镜视频、storyboard、按时间线编排",
253
- "演示点击 / 输入 / 悬停(虚拟鼠标 + 涟漪 / 打字机 / 触发 tooltip)",
254
- "模板视频一行出片、按顺序编号高亮多个区域并录制"
250
+ "Screen recording, record a web page, record a video, capture an interaction, produce a webm, screencast",
251
+ "Scroll recording, a video that pans the page from top to bottom",
252
+ "Multi-scene video, storyboard video, timeline-sequenced clips",
253
+ "Animated demos of clicks / typing / hover (virtual cursor + ripple / typewriter / triggering tooltips)",
254
+ "One-command template clips, numbering and highlighting several regions in sequence and recording it"
255
255
  ],
256
256
  "entry": {
257
257
  "type": "python",
@@ -275,11 +275,11 @@
275
275
  "title": "Web Page Screenshot",
276
276
  "summary": "Drive a headless browser (Playwright Python) to capture any URL to a local STILL IMAGE (png/jpg): full-page / viewport / element / region screenshots, with device emulation, waiting, hide/mask/redact, and static annotations (highlight / arrow / caption / numbered sequence / redact). Images only — for video / recording / scroll-through / storyboard clips (webm) use the web_record tool instead.",
277
277
  "triggers": [
278
- "网页截图、网页截屏、整页截图、长截图、full page screenshot",
279
- "截某个元素 / 区域、局部截屏、focus 某个区域",
280
- "截图前隐藏元素 / 涂盖打码、给静态截图加注释(红框 / 箭头 / 标签)",
281
- "高亮某文件的 L5-L20 代码行并截图",
282
- "移动端 / 设备模拟截图、带 cookie / 登录态截图"
278
+ "Web page screenshot, screen capture, full-page screenshot, long screenshot",
279
+ "Capture a specific element / region, partial screenshot, focus on an area",
280
+ "Hide or mask elements before capturing, annotate a still screenshot (highlight box / arrow / label)",
281
+ "Highlight lines L5-L20 of a file and screenshot it",
282
+ "Mobile / device-emulated screenshot, capture with cookies / a logged-in session"
283
283
  ],
284
284
  "entry": {
285
285
  "type": "python",
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `remixmate project ...` and `remixmate host probe`.
3
+ *
4
+ * `project use` is the low-friction way to keep CLI output out of 「未分类」:
5
+ * say it once per repo (or once globally) instead of passing --project on every
6
+ * call — an agent won't pass a flag it wasn't told about.
7
+ *
8
+ * `host probe` exists so onboarding an unknown agent shell is a minute's work
9
+ * rather than a reverse-engineering session: run it inside the new host, read
10
+ * off the session variable, add one row to HOST_PROVIDERS.
11
+ */
12
+ export declare const PROJECT_VERB = "project";
13
+ export declare const HOST_VERB = "host";
14
+ export declare function runProjectCommand(argv: string[]): Promise<number>;
15
+ /**
16
+ * Report what the CLI can see about its host. Prints variable NAMES only —
17
+ * an environment dump is a routine way to leak tokens into a chat transcript.
18
+ */
19
+ export declare function runHostCommand(argv: string[]): Promise<number>;
@@ -0,0 +1,156 @@
1
+ /**
2
+ * `remixmate project ...` and `remixmate host probe`.
3
+ *
4
+ * `project use` is the low-friction way to keep CLI output out of 「未分类」:
5
+ * say it once per repo (or once globally) instead of passing --project on every
6
+ * call — an agent won't pass a flag it wasn't told about.
7
+ *
8
+ * `host probe` exists so onboarding an unknown agent shell is a minute's work
9
+ * rather than a reverse-engineering session: run it inside the new host, read
10
+ * off the session variable, add one row to HOST_PROVIDERS.
11
+ */
12
+ import { EXIT, SkillError } from '../errors.js';
13
+ import { resolveApiBaseUrl, resolveHttpContext } from '../http.js';
14
+ import { detectHostLabel, probeSessionVarNames, resolveTakeKey } from './host.js';
15
+ import { fetchDefaultProject, listProjects } from './resolve.js';
16
+ import { clearBinding, readBinding, writeBinding, PROJECTS_FILE } from './store.js';
17
+ export const PROJECT_VERB = 'project';
18
+ export const HOST_VERB = 'host';
19
+ function projectLabel(p) {
20
+ return p.displayName || p.fullName || p.sourceUrl || '(未命名)';
21
+ }
22
+ async function ctxFor(sub) {
23
+ return await resolveHttpContext(`project-${sub}`);
24
+ }
25
+ async function cmdList() {
26
+ const ctx = await ctxFor('list');
27
+ const projects = await listProjects(ctx);
28
+ if (projects.length === 0) {
29
+ process.stdout.write('(还没有项目。渲染一次即可自动创建「未分类」)\n');
30
+ return EXIT.OK;
31
+ }
32
+ const bound = await readBinding(ctx.apiBaseUrl);
33
+ for (const p of projects) {
34
+ const marks = [p.isDefault ? '默认' : '', bound?.projectId === p.id ? '← 当前绑定' : '']
35
+ .filter(Boolean)
36
+ .join(' ');
37
+ process.stdout.write(` ${p.id.padEnd(20)} ${projectLabel(p)}${marks ? ` ${marks}` : ''}\n`);
38
+ }
39
+ return EXIT.OK;
40
+ }
41
+ async function cmdUse(args) {
42
+ const global = args.includes('--global');
43
+ const projectId = args.find((a) => !a.startsWith('-'));
44
+ if (!projectId) {
45
+ process.stderr.write('用法: remixmate project use <prj_xxx> [--global]\n');
46
+ return EXIT.USAGE;
47
+ }
48
+ const ctx = await ctxFor('use');
49
+ // Verify before persisting: a typo'd uid would otherwise sit in the config and
50
+ // silently send every later render to the default project.
51
+ const projects = await listProjects(ctx);
52
+ const match = projects.find((p) => p.id === projectId);
53
+ if (!match) {
54
+ process.stderr.write(`❌ 项目不存在或不属于当前账号: ${projectId}\n 用 remixmate project list 查看可用项目。\n`);
55
+ return EXIT.USAGE;
56
+ }
57
+ await writeBinding(ctx.apiBaseUrl, projectId, global ? 'default' : 'path');
58
+ const scopeText = global ? '全局默认' : `当前目录 (${process.cwd()})`;
59
+ process.stdout.write(`✅ 已绑定到 ${projectLabel(match)} [${projectId}]\n 作用域: ${scopeText}\n`);
60
+ if (!global) {
61
+ // Not a hypothetical: WorkBuddy spawns ~/WorkBuddy/<timestamp> per conversation,
62
+ // so a path binding made there is gone by the next conversation.
63
+ process.stdout.write(' 提示: 若宿主每次会话都换工作目录,请改用 --global。\n');
64
+ }
65
+ return EXIT.OK;
66
+ }
67
+ async function cmdUnuse(args) {
68
+ const global = args.includes('--global');
69
+ const apiBaseUrl = resolveApiBaseUrl();
70
+ const removed = await clearBinding(apiBaseUrl, global ? 'default' : 'path');
71
+ process.stdout.write(removed ? '✅ 绑定已清除\n' : 'ℹ️ 没有可清除的绑定\n');
72
+ return EXIT.OK;
73
+ }
74
+ async function cmdShow() {
75
+ const apiBaseUrl = resolveApiBaseUrl();
76
+ const bound = await readBinding(apiBaseUrl);
77
+ process.stdout.write(`后端: ${apiBaseUrl}\n`);
78
+ process.stdout.write(`配置文件: ${PROJECTS_FILE}\n`);
79
+ if (bound) {
80
+ const scope = bound.scope === 'path' ? `目录绑定 (${process.cwd()})` : '全局默认绑定';
81
+ process.stdout.write(`绑定项目: ${bound.projectId} [${scope}]\n`);
82
+ }
83
+ else {
84
+ process.stdout.write('绑定项目: (无) — 产物会落进默认项目「未分类」\n');
85
+ }
86
+ const envOverride = (process.env.REMIXMATE_PROJECT_ID ?? '').trim();
87
+ if (envOverride) {
88
+ process.stdout.write(`⚠️ REMIXMATE_PROJECT_ID=${envOverride} 会覆盖上面的绑定\n`);
89
+ }
90
+ return EXIT.OK;
91
+ }
92
+ async function cmdDefault() {
93
+ const ctx = await ctxFor('default');
94
+ const p = await fetchDefaultProject(ctx);
95
+ process.stdout.write(`${p.id} ${projectLabel(p)}\n`);
96
+ return EXIT.OK;
97
+ }
98
+ export async function runProjectCommand(argv) {
99
+ const sub = argv[0] ?? 'show';
100
+ try {
101
+ switch (sub) {
102
+ case 'list':
103
+ return await cmdList();
104
+ case 'use':
105
+ return await cmdUse(argv.slice(1));
106
+ case 'unuse':
107
+ return await cmdUnuse(argv.slice(1));
108
+ case 'show':
109
+ return await cmdShow();
110
+ case 'default':
111
+ return await cmdDefault();
112
+ default:
113
+ process.stderr.write(`❌ 未知子命令: ${sub}\n用法: remixmate project <list|use|unuse|show|default>\n`);
114
+ return EXIT.USAGE;
115
+ }
116
+ }
117
+ catch (err) {
118
+ if (err instanceof SkillError) {
119
+ process.stderr.write(err.message + '\n');
120
+ return err.exitCode;
121
+ }
122
+ process.stderr.write(`❌ ${err.message}\n`);
123
+ return EXIT.ERROR;
124
+ }
125
+ }
126
+ /**
127
+ * Report what the CLI can see about its host. Prints variable NAMES only —
128
+ * an environment dump is a routine way to leak tokens into a chat transcript.
129
+ */
130
+ export async function runHostCommand(argv) {
131
+ const sub = argv[0] ?? 'probe';
132
+ if (sub !== 'probe') {
133
+ process.stderr.write(`❌ 未知子命令: ${sub}\n用法: remixmate host probe\n`);
134
+ return EXIT.USAGE;
135
+ }
136
+ const host = detectHostLabel();
137
+ const take = resolveTakeKey(undefined);
138
+ process.stdout.write(`识别宿主: ${host || '(未识别)'}\n`);
139
+ process.stdout.write(`分组键: ${take.key || '(无 — 每次渲染会各自成为一个 take)'}\n`);
140
+ process.stdout.write(`来源: ${take.source}\n`);
141
+ const names = probeSessionVarNames();
142
+ process.stdout.write('\n环境里疑似会话标识的变量(仅名称,不含取值):\n');
143
+ if (names.length === 0) {
144
+ process.stdout.write(' (无)\n');
145
+ }
146
+ else {
147
+ for (const n of names)
148
+ process.stdout.write(` ${n}\n`);
149
+ }
150
+ if (!host && names.length > 0) {
151
+ process.stdout.write('\n宿主未被识别。若上面某个变量在整个会话期间保持不变,可以:\n' +
152
+ ' 1) 临时方案: export REMIXMATE_TAKE_KEY="$该变量"\n' +
153
+ ' 2) 长期方案: 在 src/project/host.ts 的 HOST_PROVIDERS 里加一行\n');
154
+ }
155
+ return EXIT.OK;
156
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Host detection — which agent shell is this CLI running inside, and what is
3
+ * its conversation id?
4
+ *
5
+ * The id is used as the *grouping key* for takes: every render fired from the
6
+ * same host conversation lands in one VideoProject as successive takes. The
7
+ * creative loop ("generate → watch → shorten the intro → re-render") spans
8
+ * multiple skill invocations and multiple render jobs, so the conversation —
9
+ * not the render job — is its natural boundary.
10
+ *
11
+ * Everything here reads the **child process environment only**. We deliberately
12
+ * do not speak any host's private IPC (Codex exposes its thread id through an
13
+ * App Server JSON-RPC channel, and finding "the current thread" there means
14
+ * matching on cwd + status + recency — a heuristic, not an identity lookup,
15
+ * which would silently group takes wrong). A host that hands us nothing simply
16
+ * degrades one rung; see resolveTakeKey().
17
+ *
18
+ * See docs/cli-project-binding-design.md §5.1 and appendix A.
19
+ */
20
+ export interface HostSessionProvider {
21
+ /** Host identifier; becomes the namespace of the grouping key. */
22
+ host: string;
23
+ /** First provider whose detect() passes wins — ORDER MATTERS, see below. */
24
+ detect: (env: NodeJS.ProcessEnv) => boolean;
25
+ /** Session-level id candidates, most-preferred first. */
26
+ sessionVars: string[];
27
+ }
28
+ /**
29
+ * Adding a host is a data-only change: append a row, touch nothing else.
30
+ * Run `remixmate host probe` inside the new host to discover its variable names.
31
+ *
32
+ * ⚠️ ORDER IS LOAD-BEARING. CodeBuddy also sets `CLAUDE_SESSION_ID` (same value
33
+ * as its own id), while real Claude Code uses the longer `CLAUDE_CODE_*` prefix.
34
+ * Detecting Claude Code first — or matching on a bare `CLAUDE_` prefix — would
35
+ * mislabel every CodeBuddy session.
36
+ *
37
+ * ⚠️ Prefer the ROOT session over a child/sub-agent one. Claude Code exposes
38
+ * both (`CLAUDE_CODE_HOST_SESSION_ID` alongside a per-subagent
39
+ * `CLAUDE_CODE_SESSION_ID`); grouping must follow the root so a render fired
40
+ * from a subagent still joins the main take.
41
+ */
42
+ export declare const HOST_PROVIDERS: HostSessionProvider[];
43
+ export interface HostSession {
44
+ host: string;
45
+ sessionId: string;
46
+ }
47
+ /** Identify the host and its root session id, or null when unrecognised. */
48
+ export declare function detectHostSession(env?: NodeJS.ProcessEnv): HostSession | null;
49
+ /** Host label for provenance, even when no session id could be read. */
50
+ export declare function detectHostLabel(env?: NodeJS.ProcessEnv): string;
51
+ export interface TakeKey {
52
+ /** `<host>:<sessionId>`, or '' when nothing groups this invocation. */
53
+ key: string;
54
+ host: string;
55
+ /** Which rung produced the key — surfaced by `doctor` / `host probe`. */
56
+ source: 'host' | 'env-override' | 'render-job' | 'none';
57
+ }
58
+ /**
59
+ * Resolve the take grouping key.
60
+ *
61
+ * 1. a recognised host's root session id → "<host>:<id>"
62
+ * 2. REMIXMATE_TAKE_KEY → "custom:<value>"
63
+ * 3. the render job id → "job:<id>"
64
+ *
65
+ * Rung 2 is the escape hatch that matters: an unsupported host (or a user on
66
+ * Codex) can export one variable and get correct grouping without waiting for a
67
+ * CLI release. Rung 3 keeps the feature working everywhere, at the cost of
68
+ * splitting one video's iterations across takes — acceptable as a floor, not as
69
+ * a default.
70
+ */
71
+ export declare function resolveTakeKey(jobId: number | undefined, env?: NodeJS.ProcessEnv): TakeKey;
72
+ /**
73
+ * Environment variable names that look like a session/thread identifier.
74
+ * Used by `remixmate host probe` to onboard an unknown host in a minute.
75
+ * Returns NAMES ONLY — values may carry tokens and are never printed.
76
+ */
77
+ export declare function probeSessionVarNames(env?: NodeJS.ProcessEnv): string[];
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Host detection — which agent shell is this CLI running inside, and what is
3
+ * its conversation id?
4
+ *
5
+ * The id is used as the *grouping key* for takes: every render fired from the
6
+ * same host conversation lands in one VideoProject as successive takes. The
7
+ * creative loop ("generate → watch → shorten the intro → re-render") spans
8
+ * multiple skill invocations and multiple render jobs, so the conversation —
9
+ * not the render job — is its natural boundary.
10
+ *
11
+ * Everything here reads the **child process environment only**. We deliberately
12
+ * do not speak any host's private IPC (Codex exposes its thread id through an
13
+ * App Server JSON-RPC channel, and finding "the current thread" there means
14
+ * matching on cwd + status + recency — a heuristic, not an identity lookup,
15
+ * which would silently group takes wrong). A host that hands us nothing simply
16
+ * degrades one rung; see resolveTakeKey().
17
+ *
18
+ * See docs/cli-project-binding-design.md §5.1 and appendix A.
19
+ */
20
+ /**
21
+ * Adding a host is a data-only change: append a row, touch nothing else.
22
+ * Run `remixmate host probe` inside the new host to discover its variable names.
23
+ *
24
+ * ⚠️ ORDER IS LOAD-BEARING. CodeBuddy also sets `CLAUDE_SESSION_ID` (same value
25
+ * as its own id), while real Claude Code uses the longer `CLAUDE_CODE_*` prefix.
26
+ * Detecting Claude Code first — or matching on a bare `CLAUDE_` prefix — would
27
+ * mislabel every CodeBuddy session.
28
+ *
29
+ * ⚠️ Prefer the ROOT session over a child/sub-agent one. Claude Code exposes
30
+ * both (`CLAUDE_CODE_HOST_SESSION_ID` alongside a per-subagent
31
+ * `CLAUDE_CODE_SESSION_ID`); grouping must follow the root so a render fired
32
+ * from a subagent still joins the main take.
33
+ */
34
+ export const HOST_PROVIDERS = [
35
+ {
36
+ host: 'codebuddy',
37
+ detect: (e) => !!e.CODEBUDDY_SESSION_ID || e.CODEBUDDY_HOST != null,
38
+ sessionVars: ['CODEBUDDY_SESSION_ID'],
39
+ },
40
+ {
41
+ host: 'claude-code',
42
+ detect: (e) => e.CLAUDECODE === '1' || !!e.CLAUDE_CODE_SESSION_ID,
43
+ sessionVars: ['CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_SESSION_ID'],
44
+ },
45
+ // Untested placeholders — verify with `remixmate host probe` before trusting.
46
+ { host: 'qoder', detect: (e) => !!e.QODER_SESSION_ID, sessionVars: ['QODER_SESSION_ID'] },
47
+ { host: 'trae', detect: (e) => !!e.TRAE_SESSION_ID, sessionVars: ['TRAE_SESSION_ID'] },
48
+ ];
49
+ /** Identify the host and its root session id, or null when unrecognised. */
50
+ export function detectHostSession(env = process.env) {
51
+ for (const provider of HOST_PROVIDERS) {
52
+ if (!provider.detect(env))
53
+ continue;
54
+ for (const name of provider.sessionVars) {
55
+ const value = (env[name] ?? '').trim();
56
+ if (value)
57
+ return { host: provider.host, sessionId: value };
58
+ }
59
+ // Recognised the host but it gave us no id — don't fall through to a later
60
+ // provider, that would mislabel. Report the host, leave grouping to a lower rung.
61
+ return null;
62
+ }
63
+ return null;
64
+ }
65
+ /** Host label for provenance, even when no session id could be read. */
66
+ export function detectHostLabel(env = process.env) {
67
+ return HOST_PROVIDERS.find((p) => p.detect(env))?.host ?? '';
68
+ }
69
+ /**
70
+ * Resolve the take grouping key.
71
+ *
72
+ * 1. a recognised host's root session id → "<host>:<id>"
73
+ * 2. REMIXMATE_TAKE_KEY → "custom:<value>"
74
+ * 3. the render job id → "job:<id>"
75
+ *
76
+ * Rung 2 is the escape hatch that matters: an unsupported host (or a user on
77
+ * Codex) can export one variable and get correct grouping without waiting for a
78
+ * CLI release. Rung 3 keeps the feature working everywhere, at the cost of
79
+ * splitting one video's iterations across takes — acceptable as a floor, not as
80
+ * a default.
81
+ */
82
+ export function resolveTakeKey(jobId, env = process.env) {
83
+ const host = detectHostLabel(env);
84
+ const detected = detectHostSession(env);
85
+ if (detected) {
86
+ return { key: `${detected.host}:${detected.sessionId}`, host: detected.host, source: 'host' };
87
+ }
88
+ const override = (env.REMIXMATE_TAKE_KEY ?? '').trim();
89
+ if (override)
90
+ return { key: `custom:${override}`, host, source: 'env-override' };
91
+ if (jobId && jobId > 0)
92
+ return { key: `job:${jobId}`, host, source: 'render-job' };
93
+ return { key: '', host, source: 'none' };
94
+ }
95
+ /**
96
+ * Environment variable names that look like a session/thread identifier.
97
+ * Used by `remixmate host probe` to onboard an unknown host in a minute.
98
+ * Returns NAMES ONLY — values may carry tokens and are never printed.
99
+ */
100
+ export function probeSessionVarNames(env = process.env) {
101
+ const interesting = /(SESSION|THREAD|CONVERSATION|CHAT|TASK)/i;
102
+ const secretish = /(TOKEN|KEY|SECRET|AUTH|PASSWORD|COOKIE|CREDENTIAL)/i;
103
+ return Object.keys(env)
104
+ .filter((name) => interesting.test(name) && !secretish.test(name))
105
+ .sort();
106
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Project resolution ladder — which Project does this invocation's output
3
+ * belong to? First rung that matches wins.
4
+ *
5
+ * 0. CONVERSATION_ID already set → do nothing at all (cloud ab-agent path)
6
+ * 1. --project prj_xxx → explicit flag
7
+ * 2. REMIXMATE_PROJECT_ID → explicit env
8
+ * 3. ~/.config/remixmate/projects.json → `project use` binding (byPath → default)
9
+ * 4. the default project 「未分类」 → lazily created server-side
10
+ *
11
+ * Only "explicit" and "fallback" — there is deliberately no inference step in
12
+ * between. Deriving a project from the git remote reads well until you notice
13
+ * it *creates* one on every unfamiliar repo (`/project/create` is an upsert),
14
+ * and that its rules have to keep growing for private remotes, non-GitHub
15
+ * remotes, monorepos and repos with no origin — each misfire silently filing
16
+ * output under a project the user never made.
17
+ *
18
+ * See docs/cli-project-binding-design.md §3.
19
+ */
20
+ import { type HttpContext } from '../http.js';
21
+ export interface ResolvedProject {
22
+ projectId: string;
23
+ source: 'flag' | 'env' | 'binding-path' | 'binding-default' | 'default-project';
24
+ }
25
+ /** Minimal shape of ab-api's ProjectVO — only what the ladder needs. */
26
+ export interface ProjectSummary {
27
+ id: string;
28
+ displayName?: string;
29
+ fullName?: string;
30
+ sourceUrl?: string;
31
+ isDefault?: boolean;
32
+ }
33
+ /** POST /project/default — get-or-lazily-create 「未分类」. */
34
+ export declare function fetchDefaultProject(ctx: HttpContext): Promise<ProjectSummary>;
35
+ /** POST /project/list — for `remixmate project list`. */
36
+ export declare function listProjects(ctx: HttpContext): Promise<ProjectSummary[]>;
37
+ export declare function resolveProject(ctx: HttpContext, opts?: {
38
+ flagProjectId?: string;
39
+ env?: NodeJS.ProcessEnv;
40
+ cwd?: string;
41
+ }): Promise<ResolvedProject>;
42
+ /** Human-readable label for `doctor` / `project show`. */
43
+ export declare function describeProjectSource(source: ResolvedProject['source']): string;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Project resolution ladder — which Project does this invocation's output
3
+ * belong to? First rung that matches wins.
4
+ *
5
+ * 0. CONVERSATION_ID already set → do nothing at all (cloud ab-agent path)
6
+ * 1. --project prj_xxx → explicit flag
7
+ * 2. REMIXMATE_PROJECT_ID → explicit env
8
+ * 3. ~/.config/remixmate/projects.json → `project use` binding (byPath → default)
9
+ * 4. the default project 「未分类」 → lazily created server-side
10
+ *
11
+ * Only "explicit" and "fallback" — there is deliberately no inference step in
12
+ * between. Deriving a project from the git remote reads well until you notice
13
+ * it *creates* one on every unfamiliar repo (`/project/create` is an upsert),
14
+ * and that its rules have to keep growing for private remotes, non-GitHub
15
+ * remotes, monorepos and repos with no origin — each misfire silently filing
16
+ * output under a project the user never made.
17
+ *
18
+ * See docs/cli-project-binding-design.md §3.
19
+ */
20
+ import { mmPost } from '../http.js';
21
+ import { readBinding } from './store.js';
22
+ /** POST /project/default — get-or-lazily-create 「未分类」. */
23
+ export async function fetchDefaultProject(ctx) {
24
+ return await mmPost(ctx, '/project/default', {});
25
+ }
26
+ /** POST /project/list — for `remixmate project list`. */
27
+ export async function listProjects(ctx) {
28
+ const data = await mmPost(ctx, '/project/list', {});
29
+ if (Array.isArray(data))
30
+ return data;
31
+ return data?.list ?? [];
32
+ }
33
+ export async function resolveProject(ctx, opts = {}) {
34
+ const env = opts.env ?? process.env;
35
+ const flag = (opts.flagProjectId ?? '').trim();
36
+ if (flag)
37
+ return { projectId: flag, source: 'flag' };
38
+ const fromEnv = (env.REMIXMATE_PROJECT_ID ?? '').trim();
39
+ if (fromEnv)
40
+ return { projectId: fromEnv, source: 'env' };
41
+ const bound = await readBinding(ctx.apiBaseUrl, opts.cwd);
42
+ if (bound) {
43
+ return {
44
+ projectId: bound.projectId,
45
+ source: bound.scope === 'path' ? 'binding-path' : 'binding-default',
46
+ };
47
+ }
48
+ const fallback = await fetchDefaultProject(ctx);
49
+ return { projectId: fallback.id, source: 'default-project' };
50
+ }
51
+ /** Human-readable label for `doctor` / `project show`. */
52
+ export function describeProjectSource(source) {
53
+ switch (source) {
54
+ case 'flag':
55
+ return '--project 参数';
56
+ case 'env':
57
+ return 'REMIXMATE_PROJECT_ID 环境变量';
58
+ case 'binding-path':
59
+ return `${process.cwd()} 的目录绑定`;
60
+ case 'binding-default':
61
+ return '全局默认绑定';
62
+ case 'default-project':
63
+ return '默认项目「未分类」';
64
+ }
65
+ }