minimal-agent 0.1.9 → 0.3.0
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.
- package/README.md +383 -122
- package/package.json +19 -12
- package/plugins/HOW-TO-WRITE-A-PLUGIN.md +186 -0
- package/plugins/ralph-wiggum/commands/ralph-loop.md +6 -16
- package/plugins/ralph-wiggum/plugin.js +205 -0
- package/plugins/ralph-wiggum/src/goalState.js +260 -0
- package/plugins/ralph-wiggum/src/sentinels.js +21 -0
- package/plugins/ralph-wiggum/src/stopHookRunner.js +104 -0
- package/plugins/ralph-wiggum/src/verificationGate.js +202 -0
- package/plugins/workflow-runner/.claude-plugin/plugin.json +5 -0
- package/plugins/workflow-runner/commands/workflow.md +15 -0
- package/plugins/workflow-runner/commands/workflows.md +8 -0
- package/plugins/workflow-runner/plugin.js +36 -0
- package/plugins/workflow-runner/src/expressions.js +369 -0
- package/plugins/workflow-runner/src/index.js +174 -0
- package/plugins/workflow-runner/src/loader.js +183 -0
- package/plugins/workflow-runner/src/runner.js +290 -0
- package/plugins/workflow-runner/src/stepExecutors/assert.js +28 -0
- package/plugins/workflow-runner/src/stepExecutors/llm.js +44 -0
- package/plugins/workflow-runner/src/stepExecutors/skill.js +103 -0
- package/plugins/workflow-runner/src/stepExecutors/tool.js +35 -0
- package/plugins/workflow-runner/src/types.js +59 -0
- package/plugins/workflow-runner/src/workflowState.js +46 -0
- package/skills/image-gen-openrouter/SKILL.md +121 -0
- package/skills/subtitle-srt/SKILL.md +134 -0
- package/skills/tts-zh/SKILL.md +137 -0
- package/skills/video-compose/SKILL.md +139 -0
- package/src/bootstrap/cwdArg.js +22 -0
- package/src/bootstrap/workingDir.js +31 -0
- package/src/cli/configWizard.js +272 -0
- package/src/cli/print.js +192 -0
- package/src/config/configFile.js +78 -0
- package/src/config.js +118 -0
- package/src/context/compact.js +357 -0
- package/src/context/microCompactLite.js +151 -0
- package/src/context/persistContext.js +109 -0
- package/src/context/reactiveCompact.js +121 -0
- package/src/context/sessionPath.js +58 -0
- package/src/context/snipCompact.js +112 -0
- package/src/context/tokenCounter.js +66 -0
- package/src/llm/client.js +182 -0
- package/src/loop.js +230 -0
- package/src/main.js +116 -0
- package/src/plugin-sdk.js +24 -0
- package/src/plugins/commandRouter.js +169 -0
- package/src/plugins/hookEngine.js +258 -0
- package/src/plugins/pluginApi.js +23 -0
- package/src/plugins/pluginLoader.js +71 -0
- package/src/plugins/pluginRunner.js +65 -0
- package/src/plugins/transcript.js +171 -0
- package/src/prompts/projectInstructions.js +48 -0
- package/src/prompts/skillList.js +126 -0
- package/src/prompts/system.js +155 -0
- package/src/session/runTurn.js +41 -0
- package/src/session/sessionState.js +19 -0
- package/src/tools/bash/bash.js +352 -0
- package/src/tools/bash/semantics.js +85 -0
- package/src/tools/bash/warnings.js +98 -0
- package/src/tools/edit/edit.js +253 -0
- package/src/tools/edit/multi-edit.js +155 -0
- package/src/tools/glob/glob.js +97 -0
- package/src/tools/grep/grep.js +185 -0
- package/src/tools/grep/rgPath.js +173 -0
- package/src/tools/index.js +94 -0
- package/src/tools/read/read.js +209 -0
- package/src/tools/shared/fileState.js +61 -0
- package/src/tools/shared/fileUtils.js +281 -0
- package/src/tools/shared/schemas.js +16 -0
- package/src/tools/types.js +21 -0
- package/src/tools/webbrowser/browser.js +55 -0
- package/src/tools/webbrowser/webbrowser.js +194 -0
- package/src/tools/webfetch/preapproved.js +267 -0
- package/src/tools/webfetch/webfetch.js +317 -0
- package/src/tools/websearch/websearch.js +161 -0
- package/src/tools/write/write.js +125 -0
- package/src/types/turndown.d.ts +23 -0
- package/src/types.js +16 -0
- package/src/ui/App.js +37 -0
- package/src/ui/InputBox.js +240 -0
- package/src/ui/MessageList.js +28 -0
- package/src/ui/Root.js +70 -0
- package/src/ui/StatusLine.js +41 -0
- package/src/ui/ToolStatus.js +11 -0
- package/src/ui/hooks/useChat.js +234 -0
- package/src/ui/hooks/usePasteHandler.js +137 -0
- package/src/ui/hooks/useTextBuffer.js +55 -0
- package/src/ui/hooks/useTokenUsage.js +30 -0
- package/src/ui/textBuffer.js +217 -0
- package/src/utils/packageRoot.js +37 -0
- package/src/utils/resourcePaths.js +49 -0
- package/src/utils/zodToJson.js +29 -0
- package/workflows/book-review-short.yaml +99 -0
- package/workflows/e2e-write-greet.yaml +27 -0
- package/workflows/schema.json +74 -0
- package/workflows/youtube-shorts.yaml +171 -0
- package/dist/main.js +0 -5936
- package/plugins/ralph-wiggum/scripts/setup-ralph-loop.sh +0 -203
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/workflows/types.ts —— workflow runner 核心类型
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* ralph-loop 让 LLM 自规划;workflow 反过来 —— 外层确定性 step 列表
|
|
6
|
+
* 驱动执行,LLM 只在 llm: / skill: 节点出场。本文件定义 YAML 解析
|
|
7
|
+
* 后的内存形态(WorkflowDef / StepDef),以及运行时上下文(VarStack /
|
|
8
|
+
* RunContext)和 UI 事件(WorkflowEvent)。
|
|
9
|
+
*
|
|
10
|
+
* 与 src/types.ts 关系:WorkflowEvent 是插件**私有**事件,结构上满足
|
|
11
|
+
* framework 的 PluginEvent 开放契约(type: string + 任意 payload),
|
|
12
|
+
* 经 plugin.ts -> pluginRunner 原样透传给 UI;UI 不识别即静默忽略。
|
|
13
|
+
* src/types.ts 的 LoopEvent union **不**包含 workflow_*,框架零插件耦合。
|
|
14
|
+
* ============================================================
|
|
15
|
+
*/
|
|
16
|
+
// ---------------- 2. 变量栈(loop scope 用) ----------------
|
|
17
|
+
/**
|
|
18
|
+
* 简单的多帧变量栈:
|
|
19
|
+
* - 最外层(frame 0)放 inputs + 全局变量
|
|
20
|
+
* - 进入 loop 时 push() 新帧,存当前迭代的 as / as_idx
|
|
21
|
+
* - 退出 loop 时 pop()
|
|
22
|
+
*
|
|
23
|
+
* get(key) 从栈顶往下找第一个匹配的 frame,类似 JS 的 lexical scope。
|
|
24
|
+
*/
|
|
25
|
+
export class VarStack {
|
|
26
|
+
frames = [{}];
|
|
27
|
+
set(key, val) {
|
|
28
|
+
this.frames[this.frames.length - 1][key] = val;
|
|
29
|
+
}
|
|
30
|
+
/** 写到栈底(root frame),用于 inputs / 全局常量 */
|
|
31
|
+
setGlobal(key, val) {
|
|
32
|
+
this.frames[0][key] = val;
|
|
33
|
+
}
|
|
34
|
+
get(key) {
|
|
35
|
+
for (let i = this.frames.length - 1; i >= 0; i--) {
|
|
36
|
+
if (key in this.frames[i])
|
|
37
|
+
return this.frames[i][key];
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
has(key) {
|
|
42
|
+
for (let i = this.frames.length - 1; i >= 0; i--) {
|
|
43
|
+
if (key in this.frames[i])
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
push() {
|
|
49
|
+
this.frames.push({});
|
|
50
|
+
}
|
|
51
|
+
pop() {
|
|
52
|
+
if (this.frames.length > 1)
|
|
53
|
+
this.frames.pop();
|
|
54
|
+
}
|
|
55
|
+
/** 合并所有帧为一个普通对象(外层覆盖内层),用于 vars 持久化与事件 */
|
|
56
|
+
snapshot() {
|
|
57
|
+
return Object.assign({}, ...this.frames);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/workflows/workflowState.ts —— workflow 运行状态持久化
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 与 GoalState 类似的轻量目录模型,但只用于 debug / 中断恢复(P0 不实现
|
|
6
|
+
* 恢复,只写出现场)。目录结构:
|
|
7
|
+
*
|
|
8
|
+
* <cwd>/.minimal-agent-workflow/
|
|
9
|
+
* ├── current.json # { name, startedAt }
|
|
10
|
+
* ├── inputs.json # 用户传入的 inputs 快照
|
|
11
|
+
* ├── vars.json # 最后一次成功 step 后的 VarStack.snapshot()
|
|
12
|
+
* └── progress.md # 时间戳追加的 step 完成日志
|
|
13
|
+
*
|
|
14
|
+
* init() 进 workflow 前 reset 一次;cleanup() 在 finally 把文件删掉
|
|
15
|
+
* + rmdir(保持 /new 扫描 .minimal-agent-* 子目录的语义一致)。
|
|
16
|
+
*
|
|
17
|
+
* 与 GoalState 的核心区别:workflow 不需要 PHASE / completion / learnings 文件,
|
|
18
|
+
* 因为执行流程是确定性的,没有 LLM 自规划过程。
|
|
19
|
+
* ============================================================
|
|
20
|
+
*/
|
|
21
|
+
import { mkdir, writeFile, appendFile, rm } from 'node:fs/promises';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
const STATE_DIR = '.minimal-agent-workflow';
|
|
24
|
+
export class WorkflowState {
|
|
25
|
+
dir;
|
|
26
|
+
constructor(workingDir) {
|
|
27
|
+
this.dir = join(workingDir, STATE_DIR);
|
|
28
|
+
}
|
|
29
|
+
async init(name, inputs) {
|
|
30
|
+
await rm(this.dir, { recursive: true, force: true });
|
|
31
|
+
await mkdir(this.dir, { recursive: true });
|
|
32
|
+
await writeFile(join(this.dir, 'current.json'), JSON.stringify({ name, startedAt: new Date().toISOString() }, null, 2), 'utf8');
|
|
33
|
+
await writeFile(join(this.dir, 'inputs.json'), JSON.stringify(inputs, null, 2), 'utf8');
|
|
34
|
+
await writeFile(join(this.dir, 'progress.md'), `# ${name}\n\n`, 'utf8');
|
|
35
|
+
}
|
|
36
|
+
async appendProgress(line) {
|
|
37
|
+
const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
|
38
|
+
await appendFile(join(this.dir, 'progress.md'), `- ${ts} ${line}\n`, 'utf8').catch(() => { });
|
|
39
|
+
}
|
|
40
|
+
async writeVars(snapshot) {
|
|
41
|
+
await writeFile(join(this.dir, 'vars.json'), JSON.stringify(snapshot, null, 2), 'utf8').catch(() => { });
|
|
42
|
+
}
|
|
43
|
+
async cleanup() {
|
|
44
|
+
await rm(this.dir, { recursive: true, force: true }).catch(() => { });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: image-gen-openrouter
|
|
3
|
+
description: 当用户说"生成图片"、"画一张图"、"配图"、"OpenRouter 生图"、"nano banana"、"image-gen-openrouter",或在 workflow 里要按提示词生成图片时使用。调用 OpenRouter 的 Chat Completions 接口(OpenAI 兼容 + image modalities),按提示词生成任意比例图片并落盘。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# image-gen-openrouter —— OpenRouter 文生图
|
|
7
|
+
|
|
8
|
+
> 这是一个**通用 skill**,不绑定特定场景。任何需要文生图的 workflow / 用户对话都能调它。
|
|
9
|
+
|
|
10
|
+
## 输入约定
|
|
11
|
+
|
|
12
|
+
调用方传一行 `key=value` 字段串,**字段顺序无要求**,例:
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
prompt=A cat reading a book in cyberpunk neon city output=./assets/img_0.png aspect=9:16 size=4K model=google/gemini-3-pro-image-preview key=sk-or-...
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
字段定义:
|
|
19
|
+
- **prompt**(必填):图片提示词(建议英文,效果更好)。可包含空格 —— 用"剩余串"策略解析(先把其它已知字段抠出去,剩下的就是 prompt)
|
|
20
|
+
- **output**(必填):目标图片路径(相对 cwd 或绝对)
|
|
21
|
+
- **aspect**(可选,默认 `1:1`):宽高比;OpenRouter 支持 `1:1` / `2:3` / `3:2` / `3:4` / `4:3` / `4:5` / `5:4` / `9:16` / `16:9` / `21:9` 等
|
|
22
|
+
- **size**(可选,默认 `1K`):分辨率;可选 `0.5K` / `1K` / `2K` / `4K`(部分模型不支持 4K,限流时自动降级)
|
|
23
|
+
- **model**(可选,默认 `google/gemini-3-pro-image-preview`)—— Nano Banana Pro,质量最好;备选 `google/gemini-3.1-flash-image-preview`(便宜)、`google/gemini-2.5-flash-image`(兜底)
|
|
24
|
+
- **key**(可选):API key;不传则用环境变量 `OPENROUTER_API_KEY`
|
|
25
|
+
|
|
26
|
+
## 标准执行流程
|
|
27
|
+
|
|
28
|
+
### 1. 解析输入
|
|
29
|
+
|
|
30
|
+
仔细从 step.input 里提取 4 个字段。注意 prompt 可能包含空格,所以按"先取 output= / model= / key= 之外的部分作为 prompt"的策略最稳妥。
|
|
31
|
+
|
|
32
|
+
如果你拿不到 prompt 或 output,立刻报错并返回。
|
|
33
|
+
|
|
34
|
+
### 2. 取 API key
|
|
35
|
+
|
|
36
|
+
按优先级:
|
|
37
|
+
1. step.input 里的 `key=` 字段
|
|
38
|
+
2. 用 Bash 跑 `echo "$OPENROUTER_API_KEY"` 看环境变量
|
|
39
|
+
|
|
40
|
+
两者都空 → 报错 "OpenRouter API key 未配置(既不在 step.input 也不在 OPENROUTER_API_KEY 环境变量)" 并退出。
|
|
41
|
+
|
|
42
|
+
### 3. 调 OpenRouter API
|
|
43
|
+
|
|
44
|
+
用 Bash 跑 curl(**注意把变量替换成实际值**):
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
curl -sS https://openrouter.ai/api/v1/chat/completions \
|
|
48
|
+
-H "Authorization: Bearer <YOUR_KEY>" \
|
|
49
|
+
-H "Content-Type: application/json" \
|
|
50
|
+
-d '{
|
|
51
|
+
"model": "<MODEL>",
|
|
52
|
+
"messages": [{"role": "user", "content": "<PROMPT>"}],
|
|
53
|
+
"modalities": ["image", "text"],
|
|
54
|
+
"image_config": {"aspect_ratio": "<ASPECT>", "image_size": "<SIZE>"}
|
|
55
|
+
}' \
|
|
56
|
+
-o /tmp/openrouter_resp.json
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
> 💡 prompt 里若含双引号/特殊字符,用 jq 拼 JSON(**推荐**):
|
|
60
|
+
> ```bash
|
|
61
|
+
> jq -n --arg p "$PROMPT" --arg m "$MODEL" --arg a "$ASPECT" --arg s "$SIZE" \
|
|
62
|
+
> '{model:$m, messages:[{role:"user", content:$p}], modalities:["image","text"], image_config:{aspect_ratio:$a, image_size:$s}}' \
|
|
63
|
+
> | curl -sS https://openrouter.ai/api/v1/chat/completions \
|
|
64
|
+
> -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
|
|
65
|
+
> --data-binary @- -o /tmp/openrouter_resp.json
|
|
66
|
+
> ```
|
|
67
|
+
|
|
68
|
+
### 4. 提取 base64 并解码到 PNG
|
|
69
|
+
|
|
70
|
+
OpenRouter 返回结构(关键字段):
|
|
71
|
+
```
|
|
72
|
+
.choices[0].message.images[0].image_url.url → "data:image/png;base64,iVBORw0KG..."
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Linux/macOS/git-bash on Windows:**
|
|
76
|
+
```bash
|
|
77
|
+
mkdir -p "$(dirname <OUTPUT>)"
|
|
78
|
+
jq -r '.choices[0].message.images[0].image_url.url' /tmp/openrouter_resp.json \
|
|
79
|
+
| sed 's|^data:image/[a-z]*;base64,||' \
|
|
80
|
+
| base64 -d > <OUTPUT>
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Windows PowerShell(base64 -d 不存在时):**
|
|
84
|
+
```powershell
|
|
85
|
+
powershell -NoProfile -Command "$resp = Get-Content /tmp/openrouter_resp.json -Raw | ConvertFrom-Json; $url = $resp.choices[0].message.images[0].image_url.url; $b64 = $url -replace '^data:image/[a-z]+;base64,',''; [IO.File]::WriteAllBytes('<OUTPUT>', [Convert]::FromBase64String($b64))"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### 5. 校验产物
|
|
89
|
+
|
|
90
|
+
用 Read 工具读 `<OUTPUT>`(offset=0 limit=1)— 触发文件类型嗅探;如果工具报"二进制"或文件 size > 1KB 就视为成功,否则失败。
|
|
91
|
+
|
|
92
|
+
或者更直接:用 Bash 跑 `ls -la <OUTPUT> | awk '{print $5}'`,size > 10000 字节算成功。
|
|
93
|
+
|
|
94
|
+
### 6. 处理错误
|
|
95
|
+
|
|
96
|
+
如果 curl 失败 / OpenRouter 返回 error / base64 解码失败 / 产物太小:
|
|
97
|
+
- 打印 `/tmp/openrouter_resp.json` 头 500 字节(用 Bash `head -c 500`)帮助 debug
|
|
98
|
+
- 如果是 429 限流,sleep 5s 重试一次
|
|
99
|
+
- 仍然失败 → 输出 "image-gen-openrouter 失败:<原因>" 并返回(不要继续往下走)
|
|
100
|
+
|
|
101
|
+
### 7. 收尾输出
|
|
102
|
+
|
|
103
|
+
成功后输出**一行** summary(机器友好,方便上游 capture / regex 提取):
|
|
104
|
+
```
|
|
105
|
+
saved: <OUTPUT> (model=<MODEL>, aspect=<ASPECT>, size=<BYTES>)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## 模型选型快速参考
|
|
109
|
+
|
|
110
|
+
| 模型 ID | 代号 | 单图成本 | 备注 |
|
|
111
|
+
|---|---|---|---|
|
|
112
|
+
| `google/gemini-3-pro-image-preview` | Nano Banana Pro | 高 | 4K,质量最好,默认 |
|
|
113
|
+
| `google/gemini-3.1-flash-image-preview` | Nano Banana 2 | 中 | 平衡,限流时降级 |
|
|
114
|
+
| `google/gemini-2.5-flash-image` | Nano Banana | 低 | 兜底 |
|
|
115
|
+
|
|
116
|
+
## 注意事项
|
|
117
|
+
|
|
118
|
+
- **不要把 API key 打到日志或最终输出里**,curl 错误信息也要小心 grep 掉
|
|
119
|
+
- **不要重试超过 1 次** — 429 / 5xx 各最多 1 次重试,避免烧钱
|
|
120
|
+
- 中文提示词模型可以理解,但效果不如英文 —— 如果调用方给的是中文 prompt,可以保留,但**不要主动翻译**(让上游负责)
|
|
121
|
+
- 调用方决定 `aspect` 和 `size`;本 skill 不做任何场景假设(不绑定竖屏/横屏)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: subtitle-srt
|
|
3
|
+
description: 当用户说"生成字幕"、"做 SRT"、"字幕文件"、"subtitle-srt",或在 workflow 里要把分段文本 + 时长信息转成标准 SRT 字幕文件时使用。给定 cue 列表(每条带文本和起止时间或时长),生成符合 SRT 规范的字幕文件。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# subtitle-srt —— SRT 字幕生成
|
|
7
|
+
|
|
8
|
+
> 通用 skill,不绑定语种 / 不绑定视频比例 / 不绑定字体(字体是视频合成阶段的事)。
|
|
9
|
+
|
|
10
|
+
## 输入约定
|
|
11
|
+
|
|
12
|
+
支持两种输入形态:
|
|
13
|
+
|
|
14
|
+
### 形态 A:cues_json(一次性给所有 cue)
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
cues_json=<JSON 数组> output=./subs.srt [encoding=utf-8]
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`cues_json` 是合法 JSON 数组,每个元素至少有 `text`,时间可以两种方式给:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
[
|
|
24
|
+
{"text": "开场白", "start": 0.0, "end": 3.5},
|
|
25
|
+
{"text": "第二幕", "start": 3.5, "end": 7.7}
|
|
26
|
+
]
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
或者只给时长,由本 skill 自动累加:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
[
|
|
33
|
+
{"text": "开场白", "duration": 3.5},
|
|
34
|
+
{"text": "第二幕", "duration": 4.2}
|
|
35
|
+
]
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### 形态 B:texts + durations(两个并列数组)
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
texts=<JSON 字符串数组> durations=<逗号分隔秒数> output=./subs.srt
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
例:
|
|
45
|
+
```
|
|
46
|
+
texts=["开场白","第二幕","第三幕"] durations=3.5,4.2,3.0 output=./subs.srt
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
> 形态 B 比形态 A 易写,适合 workflow 里用 capture 拼出的简单场景。
|
|
50
|
+
|
|
51
|
+
## 标准执行流程
|
|
52
|
+
|
|
53
|
+
### 1. 解析输入
|
|
54
|
+
|
|
55
|
+
判断是哪种形态:含 `cues_json=` 字段走 A,含 `texts=` + `durations=` 走 B。两者都不满足 → 报错。
|
|
56
|
+
|
|
57
|
+
### 2. 计算每条 cue 的起止时间戳
|
|
58
|
+
|
|
59
|
+
如果给了 `start` + `end`(形态 A 第一种),直接用。
|
|
60
|
+
|
|
61
|
+
否则按 `duration` 累加:
|
|
62
|
+
- cue 1: start=0.0, end=duration[0]
|
|
63
|
+
- cue 2: start=end[0], end=start[1]+duration[1]
|
|
64
|
+
- ...
|
|
65
|
+
|
|
66
|
+
### 3. 时间戳格式转换
|
|
67
|
+
|
|
68
|
+
SRT 时间戳格式:`HH:MM:SS,mmm`(毫秒前**用逗号不用点**)。
|
|
69
|
+
|
|
70
|
+
公式:
|
|
71
|
+
```
|
|
72
|
+
total_ms = round(seconds * 1000)
|
|
73
|
+
hh = total_ms // 3600000
|
|
74
|
+
mm = (total_ms % 3600000) // 60000
|
|
75
|
+
ss = (total_ms % 60000) // 1000
|
|
76
|
+
ms = total_ms % 1000
|
|
77
|
+
formatted = f"{hh:02d}:{mm:02d}:{ss:02d},{ms:03d}"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### 4. 拼 SRT 内容
|
|
81
|
+
|
|
82
|
+
每条 cue 4 行(序号 / 时间戳 / 文本 / 空行):
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
1
|
|
86
|
+
00:00:00,000 --> 00:00:03,500
|
|
87
|
+
开场白
|
|
88
|
+
|
|
89
|
+
2
|
|
90
|
+
00:00:03,500 --> 00:00:07,700
|
|
91
|
+
第二幕
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
> ⚠️ 最后一条 cue 末尾**也要有空行**,否则部分播放器会吞掉最后一句。
|
|
96
|
+
|
|
97
|
+
### 5. 写入文件
|
|
98
|
+
|
|
99
|
+
用 Write 工具:
|
|
100
|
+
- `file_path = OUTPUT`
|
|
101
|
+
- `content = <上面拼好的 SRT 字符串>`
|
|
102
|
+
|
|
103
|
+
或者用 Bash + heredoc(注意编码,Windows 上要保证 UTF-8 无 BOM):
|
|
104
|
+
```bash
|
|
105
|
+
mkdir -p "$(dirname "$OUTPUT")"
|
|
106
|
+
cat > "$OUTPUT" <<'EOF'
|
|
107
|
+
1
|
|
108
|
+
00:00:00,000 --> 00:00:03,500
|
|
109
|
+
开场白
|
|
110
|
+
|
|
111
|
+
EOF
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### 6. 自检(可选)
|
|
115
|
+
|
|
116
|
+
如果机器有 ffprobe,跑一下确认字幕可读:
|
|
117
|
+
```bash
|
|
118
|
+
ffprobe -v error -loglevel error -i "$OUTPUT" 2>&1 || echo "ffprobe 验证未通过:$?"
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
ffprobe 失败不算硬错(部分版本对 SRT 解析比较宽松),但要打印 warning。
|
|
122
|
+
|
|
123
|
+
### 7. 收尾输出
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
saved: <OUTPUT> (cues=<N>, duration=<TOTAL_SEC>s)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## 注意事项
|
|
130
|
+
|
|
131
|
+
- 长文本自动换行:单 cue 文本超过 ~30 字时,在自然标点处插入换行(`,` / `。` / `?` / `!`)。SRT 单条多行用真实换行符即可
|
|
132
|
+
- **不打印用户文本内容到日志**(隐私)
|
|
133
|
+
- 字符编码强制 UTF-8 无 BOM;Windows PowerShell 写文件时显式 `-Encoding utf8`
|
|
134
|
+
- 不假设语种 —— 中英日韩都按字符长度判断换行(中文 1 字符 ≈ 英文 2 字符的视觉宽度,可按 30 字符切)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tts-zh
|
|
3
|
+
description: 当用户说"中文配音"、"中文 TTS"、"语音合成"、"文字转语音"、"tts-zh",或在 workflow 里要把中文文本转成 mp3 时使用。优先用 MiniMax T2A API,失败时 fallback 到 edge-tts 离线方案,输出 mp3 到指定路径。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# tts-zh —— 中文 TTS(minimax 主 + edge-tts 兜底)
|
|
7
|
+
|
|
8
|
+
## 输入约定
|
|
9
|
+
|
|
10
|
+
调用方传一行 `key=value` 字段串:
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
text=今天天气真好 output=./assets/audio_0.mp3 voice=female-shaonv speed=1.0
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
字段:
|
|
17
|
+
- **text**(必填):要合成的中文文本(短句,≤200 字)
|
|
18
|
+
- **output**(必填):目标 mp3 路径
|
|
19
|
+
- **voice**(可选,默认 `female-shaonv`):minimax 嗓音 id;edge-tts 走 `zh-CN-XiaoxiaoNeural` 不受 voice 字段影响
|
|
20
|
+
- **speed**(可选,默认 `1.0`):语速 0.5–2.0
|
|
21
|
+
|
|
22
|
+
## 标准执行流程
|
|
23
|
+
|
|
24
|
+
### 1. 解析输入
|
|
25
|
+
|
|
26
|
+
仔细取 text / output / voice / speed。text 可能包含空格甚至标点,按"剩余串"策略提取。
|
|
27
|
+
|
|
28
|
+
### 2. 优先尝试 MiniMax T2A
|
|
29
|
+
|
|
30
|
+
#### 2.1 取 key + group id
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
KEY="$MINIMAX_API_KEY"
|
|
34
|
+
GROUP="$MINIMAX_GROUP_ID"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
如果两个都不空,进入 2.2;任一为空,**直接跳到第 3 步(edge-tts fallback)**,不算错误。
|
|
38
|
+
|
|
39
|
+
#### 2.2 调 API
|
|
40
|
+
|
|
41
|
+
minimax T2A v2 接口:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
jq -n --arg t "$TEXT" --arg v "$VOICE" --argjson s "$SPEED" \
|
|
45
|
+
'{model:"speech-01", text:$t, voice_setting:{voice_id:$v, speed:$s}, audio_setting:{format:"mp3"}}' \
|
|
46
|
+
| curl -sS "https://api.minimax.chat/v1/t2a_v2?GroupId=$GROUP" \
|
|
47
|
+
-H "Authorization: Bearer $KEY" \
|
|
48
|
+
-H "Content-Type: application/json" \
|
|
49
|
+
--data-binary @- \
|
|
50
|
+
-o /tmp/minimax_resp.json
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
> ⚠️ MINIMAX_BASE_URL 可能不是 `api.minimax.chat`(用户也许配了自定义网关)。如果 env 里有 `MINIMAX_BASE_URL`,用它替换 `https://api.minimax.chat`,否则用默认。
|
|
54
|
+
|
|
55
|
+
#### 2.3 解析响应
|
|
56
|
+
|
|
57
|
+
minimax 返回里音频是 hex 字符串:
|
|
58
|
+
```
|
|
59
|
+
.data.audio → "ff f3 e4 ..."(hex 编码的 mp3 字节流)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
解码到文件:
|
|
63
|
+
```bash
|
|
64
|
+
mkdir -p "$(dirname "$OUTPUT")"
|
|
65
|
+
jq -r '.data.audio' /tmp/minimax_resp.json | xxd -r -p > "$OUTPUT"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
> 如果 `xxd` 不可用(部分 Windows),用 PowerShell:
|
|
69
|
+
> ```powershell
|
|
70
|
+
> $resp = Get-Content /tmp/minimax_resp.json -Raw | ConvertFrom-Json
|
|
71
|
+
> $hex = $resp.data.audio
|
|
72
|
+
> $bytes = New-Object byte[] ($hex.Length / 2)
|
|
73
|
+
> for ($i = 0; $i -lt $bytes.Length; $i++) { $bytes[$i] = [Convert]::ToByte($hex.Substring($i*2, 2), 16) }
|
|
74
|
+
> [IO.File]::WriteAllBytes('<OUTPUT>', $bytes)
|
|
75
|
+
> ```
|
|
76
|
+
|
|
77
|
+
#### 2.4 校验
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
SIZE=$(stat -c%s "$OUTPUT" 2>/dev/null || stat -f%z "$OUTPUT")
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
- `SIZE > 1000` → 成功,输出 `saved: <OUTPUT> (provider=minimax, size=<SIZE>)`,结束
|
|
84
|
+
- `SIZE < 1000` 或文件不存在 → 视为失败,**进入第 3 步 edge-tts fallback**
|
|
85
|
+
|
|
86
|
+
### 3. Fallback 到 edge-tts
|
|
87
|
+
|
|
88
|
+
#### 3.1 确认 edge-tts 可用
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
pip show edge-tts >/dev/null 2>&1 || pip install --user edge-tts
|
|
92
|
+
which edge-tts || python -m edge_tts --help >/dev/null 2>&1 || true
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
如果 pip / python 都没有,**报错退出**:"tts-zh 失败:minimax 不可用且本机无 Python,无法 fallback 到 edge-tts。请配置 MINIMAX_API_KEY + MINIMAX_GROUP_ID 或安装 Python + edge-tts"
|
|
96
|
+
|
|
97
|
+
#### 3.2 跑 edge-tts
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
edge-tts --voice zh-CN-XiaoxiaoNeural --rate "+0%" --text "$TEXT" --write-media "$OUTPUT"
|
|
101
|
+
# 或:
|
|
102
|
+
python -m edge_tts --voice zh-CN-XiaoxiaoNeural --text "$TEXT" --write-media "$OUTPUT"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
> 语速换算:minimax 的 speed=1.0 ↔ edge-tts 的 `--rate "+0%"`;speed=1.2 ↔ `--rate "+20%"`;speed=0.8 ↔ `--rate "-20%"`。
|
|
106
|
+
> 公式:`rate = ((speed - 1.0) * 100)` 取整,前面加 `+` 或 `-`。
|
|
107
|
+
|
|
108
|
+
#### 3.3 校验
|
|
109
|
+
|
|
110
|
+
同 2.4 步骤;size > 1000 → 成功,输出 `saved: <OUTPUT> (provider=edge-tts, size=<SIZE>)`。
|
|
111
|
+
|
|
112
|
+
仍然失败 → 报错 "tts-zh 失败:minimax 和 edge-tts 都不工作"
|
|
113
|
+
|
|
114
|
+
## 嗓音速查
|
|
115
|
+
|
|
116
|
+
### MiniMax 常用 voice_id
|
|
117
|
+
|
|
118
|
+
| voice_id | 描述 |
|
|
119
|
+
|---|---|
|
|
120
|
+
| `female-shaonv` | 少女音(默认,自然清亮) |
|
|
121
|
+
| `male-qn-jingying` | 精英男声(沉稳) |
|
|
122
|
+
| `female-tianmei` | 甜美女声 |
|
|
123
|
+
| `audiobook_male_1` | 有声书男声 |
|
|
124
|
+
|
|
125
|
+
### edge-tts(兜底,固定)
|
|
126
|
+
|
|
127
|
+
| voice | 描述 |
|
|
128
|
+
|---|---|
|
|
129
|
+
| `zh-CN-XiaoxiaoNeural` | 默认女声(自然) |
|
|
130
|
+
| `zh-CN-YunxiNeural` | 男声 |
|
|
131
|
+
|
|
132
|
+
## 注意事项
|
|
133
|
+
|
|
134
|
+
- **不打印 API key / GroupId 到任何日志**
|
|
135
|
+
- text 含特殊 shell 字符(`$ " '` 等)时务必走 jq 拼 JSON,**不要直接拼字符串到 curl 命令里**
|
|
136
|
+
- 输出 mp3 时长会因为文本长度而变 —— 不要硬假设时长,下游 ffprobe 实际测量
|
|
137
|
+
- 一次只合成一句;若调用方传超过 200 字,让它分多次调用
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: video-compose
|
|
3
|
+
description: 当用户说"合成视频"、"做 mp4"、"图片转视频"、"ffmpeg 拼视频"、"video-compose",或在 workflow 里要把图片序列 + 可选音轨 + 可选字幕合成成 mp4 时使用。本 skill 是 ffmpeg 的 declarative 封装,所有参数(分辨率、字体、码率、转场)都可配置,不绑定特定视频格式。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# video-compose —— 通用 ffmpeg 视频合成
|
|
7
|
+
|
|
8
|
+
> 通用 skill,可被任何 workflow 调用。无场景假设(不绑定竖屏/横屏,不绑定语言)。
|
|
9
|
+
|
|
10
|
+
## 输入约定
|
|
11
|
+
|
|
12
|
+
调用方传一行 `key=value` 字段串,必填项 + 可选项混合:
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
images_glob=./assets/img_*.png audio=./assets/audio.mp3 srt=./assets/subs.srt output=./out/video.mp4 width=1080 height=1920 per_image_duration=3 fps=30 font=Microsoft YaHei font_size=42
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
### 必填
|
|
19
|
+
|
|
20
|
+
- **images_glob**:图片 glob 表达式(用 Glob 工具枚举,按文件名排序)
|
|
21
|
+
- **output**:目标 mp4 路径
|
|
22
|
+
|
|
23
|
+
### 可选
|
|
24
|
+
|
|
25
|
+
| 字段 | 默认 | 说明 |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| **audio** | (无) | 音轨 mp3/wav;不传则纯视频无声 |
|
|
28
|
+
| **srt** | (无) | 字幕文件;不传则不烧入字幕 |
|
|
29
|
+
| **width** × **height** | `1080` × `1920` | 输出分辨率(默认竖屏;横屏传 `1920` × `1080`) |
|
|
30
|
+
| **per_image_duration** | `3` | 每张图片显示秒数(如果给 audio,可能会被覆盖 —— 见下方"对齐音轨") |
|
|
31
|
+
| **fps** | `30` | 帧率 |
|
|
32
|
+
| **font** | `Microsoft YaHei`(Win)/ `PingFang SC`(Mac)/ `Noto Sans CJK SC`(Linux) | 字幕字体 |
|
|
33
|
+
| **font_size** | `42` | 字幕字号 |
|
|
34
|
+
| **font_color** | `FFFFFF` | 字幕颜色(RGB hex) |
|
|
35
|
+
| **align_audio** | `auto` | `auto`=按音轨长度等分图片时长 / `fixed`=固定 `per_image_duration` / `cut`=超出部分截断 |
|
|
36
|
+
| **crf** | `23` | x264 质量(18–28,越低越清) |
|
|
37
|
+
|
|
38
|
+
## 标准执行流程
|
|
39
|
+
|
|
40
|
+
### 1. 前置检查
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
which ffmpeg >/dev/null || { echo "缺少 ffmpeg,请安装:winget install Gyan.FFmpeg / brew install ffmpeg / apt install ffmpeg"; exit 1; }
|
|
44
|
+
which ffprobe >/dev/null || { echo "缺少 ffprobe(通常和 ffmpeg 一起装)"; exit 1; }
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### 2. 收集图片列表
|
|
48
|
+
|
|
49
|
+
用 Glob 工具按 `images_glob` 找文件 → 按文件名 ASCII 排序。如果只有 0 或 1 张图,**报错退出**(视频至少要 2 帧才有意义)。
|
|
50
|
+
|
|
51
|
+
### 3. 决定每张图时长
|
|
52
|
+
|
|
53
|
+
- 如果**有 audio** 且 `align_audio=auto`(默认):
|
|
54
|
+
- 用 `ffprobe -v error -show_entries format=duration -of csv=p=0 <audio>` 拿音轨时长 `T`
|
|
55
|
+
- 单图时长 = `T / 图片数`
|
|
56
|
+
- 否则:每张图固定 `per_image_duration` 秒
|
|
57
|
+
|
|
58
|
+
### 4. 生成 concat 列表
|
|
59
|
+
|
|
60
|
+
ffmpeg concat demuxer 用法:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
mkdir -p "$(dirname "$OUTPUT")"
|
|
64
|
+
LIST=/tmp/video_compose_list.txt
|
|
65
|
+
> "$LIST"
|
|
66
|
+
for f in $(ls $IMAGES_GLOB | sort); do
|
|
67
|
+
echo "file '$f'" >> "$LIST"
|
|
68
|
+
echo "duration $PER_IMG" >> "$LIST"
|
|
69
|
+
done
|
|
70
|
+
# ffmpeg quirk:最后一张图片要再列一次(不带 duration)才会被显示完整时长
|
|
71
|
+
LAST=$(ls $IMAGES_GLOB | sort | tail -n1)
|
|
72
|
+
echo "file '$LAST'" >> "$LIST"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### 5. 合成视频流(可选烧字幕)
|
|
76
|
+
|
|
77
|
+
无字幕版本:
|
|
78
|
+
```bash
|
|
79
|
+
ffmpeg -y -f concat -safe 0 -i "$LIST" \
|
|
80
|
+
-vf "scale=${WIDTH}:${HEIGHT}:force_original_aspect_ratio=increase,crop=${WIDTH}:${HEIGHT},fps=${FPS}" \
|
|
81
|
+
-c:v libx264 -crf ${CRF} -pix_fmt yuv420p /tmp/video_only.mp4
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
带字幕版本(filter 链尾加 subtitles 滤镜):
|
|
85
|
+
```bash
|
|
86
|
+
ffmpeg -y -f concat -safe 0 -i "$LIST" \
|
|
87
|
+
-vf "scale=${WIDTH}:${HEIGHT}:force_original_aspect_ratio=increase,crop=${WIDTH}:${HEIGHT},fps=${FPS},subtitles=${SRT}:force_style='Fontname=${FONT},Fontsize=${FONT_SIZE},PrimaryColour=&H00${FONT_COLOR}&,Outline=2,BorderStyle=1'" \
|
|
88
|
+
-c:v libx264 -crf ${CRF} -pix_fmt yuv420p /tmp/video_only.mp4
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
> ⚠️ Windows 路径里反斜杠 `\` 会被 ffmpeg subtitles 滤镜当转义字符吃掉。**字幕路径强制用正斜杠 `/`**,或者 `\\:` 转义盘符冒号。
|
|
92
|
+
|
|
93
|
+
### 6. 合入音轨(如果有)
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
if [ -n "$AUDIO" ]; then
|
|
97
|
+
ffmpeg -y -i /tmp/video_only.mp4 -i "$AUDIO" \
|
|
98
|
+
-c:v copy -c:a aac -b:a 192k \
|
|
99
|
+
-shortest "$OUTPUT"
|
|
100
|
+
else
|
|
101
|
+
cp /tmp/video_only.mp4 "$OUTPUT"
|
|
102
|
+
fi
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### 7. 校验产物
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
ffprobe -v error -show_entries stream=codec_type,duration -of default=noprint_wrappers=1 "$OUTPUT"
|
|
109
|
+
SIZE=$(stat -c%s "$OUTPUT" 2>/dev/null || stat -f%z "$OUTPUT")
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
- 文件存在且 size > 100KB
|
|
113
|
+
- 至少有 1 个 video 流;如果传了 audio 还应该有 1 个 audio 流
|
|
114
|
+
- 总时长 > 1 秒
|
|
115
|
+
|
|
116
|
+
任一项不满足 → 报错并打印 ffprobe 输出供 debug。
|
|
117
|
+
|
|
118
|
+
### 8. 收尾输出
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
saved: <OUTPUT> (resolution=<WIDTH>x<HEIGHT>, duration=<DUR>s, size=<MB>MB)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## 字体路径速查
|
|
125
|
+
|
|
126
|
+
| OS | 字体名(filter 用) | 实际文件 |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| Windows | `Microsoft YaHei` 或 `SimHei` | `C:\Windows\Fonts\msyh.ttc` |
|
|
129
|
+
| macOS | `PingFang SC` | `/System/Library/Fonts/PingFang.ttc` |
|
|
130
|
+
| Linux | `Noto Sans CJK SC` | `/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc` |
|
|
131
|
+
|
|
132
|
+
如果 force_style 找不到字体,会 fallback 到 Arial(中文会变方块),用 `fc-list :lang=zh-cn` 看本机有哪些中文字体。
|
|
133
|
+
|
|
134
|
+
## 注意事项
|
|
135
|
+
|
|
136
|
+
- **ffmpeg 滤镜里的引号嵌套很难写**:双引号包围整个 `-vf` 值,里面用单引号;force_style 的 key=value 用 `=` 不要用空格
|
|
137
|
+
- subtitles 滤镜路径必须**用正斜杠**,盘符冒号转义成 `\\:`(Win 上 `C:/path/x.srt` → `C\\:/path/x.srt`)
|
|
138
|
+
- 不接受**纯静态图片**作输入 —— 至少 2 张才合理;如果调用方只有 1 张图,让它用 ffmpeg `-loop 1` 单独处理(不走本 skill)
|
|
139
|
+
- 不假设视频用途(短视频 / 长视频 / 横屏 / 竖屏 / 方形)—— 全部走参数
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/bootstrap/cwdArg.ts —— `-d` / `--cwd` 参数解析
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 抽出来是为了让 main.tsx 之外能 import 这个纯函数做单测,
|
|
6
|
+
* 而不会触发 main.tsx 顶层的 main().catch(...) 副作用。
|
|
7
|
+
*
|
|
8
|
+
* 使用约定:在 initWorkingDir() 之前调一次。匹配到则建目录 + chdir。
|
|
9
|
+
* ============================================================
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* 扫 argv 找 `-d <dir>` / `--cwd <dir>`;只取第一个匹配。
|
|
13
|
+
* 找不到返回 null。
|
|
14
|
+
*/
|
|
15
|
+
export function extractCwdArg(argv) {
|
|
16
|
+
for (let i = 0; i < argv.length; i++) {
|
|
17
|
+
if (argv[i] === '-d' || argv[i] === '--cwd') {
|
|
18
|
+
return argv[i + 1] ?? null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|