minimal-agent 0.1.9 → 0.2.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.
Files changed (43) hide show
  1. package/README.md +405 -122
  2. package/dist/main.js +117 -738
  3. package/package.json +4 -2
  4. package/plugins/HOW-TO-WRITE-A-PLUGIN.md +186 -0
  5. package/plugins/ralph-wiggum/commands/ralph-loop.md +6 -16
  6. package/plugins/ralph-wiggum/plugin.ts +275 -0
  7. package/plugins/ralph-wiggum/src/goalState.ts +310 -0
  8. package/plugins/ralph-wiggum/src/sentinels.ts +24 -0
  9. package/plugins/ralph-wiggum/src/stopHookRunner.ts +136 -0
  10. package/plugins/ralph-wiggum/src/verificationGate.ts +252 -0
  11. package/plugins/ralph-wiggum/test/goalState.test.ts +410 -0
  12. package/plugins/ralph-wiggum/test/verificationGate.test.ts +122 -0
  13. package/plugins/workflow-runner/.claude-plugin/plugin.json +5 -0
  14. package/plugins/workflow-runner/commands/workflow.md +15 -0
  15. package/plugins/workflow-runner/commands/workflows.md +8 -0
  16. package/plugins/workflow-runner/plugin.ts +42 -0
  17. package/plugins/workflow-runner/src/expressions.ts +371 -0
  18. package/plugins/workflow-runner/src/index.ts +194 -0
  19. package/plugins/workflow-runner/src/loader.ts +193 -0
  20. package/plugins/workflow-runner/src/runner.ts +313 -0
  21. package/plugins/workflow-runner/src/stepExecutors/assert.ts +30 -0
  22. package/plugins/workflow-runner/src/stepExecutors/llm.ts +54 -0
  23. package/plugins/workflow-runner/src/stepExecutors/skill.ts +115 -0
  24. package/plugins/workflow-runner/src/stepExecutors/tool.ts +41 -0
  25. package/plugins/workflow-runner/src/types.ts +183 -0
  26. package/plugins/workflow-runner/src/workflowState.ts +65 -0
  27. package/plugins/workflow-runner/test/cli.e2e.test.ts +114 -0
  28. package/plugins/workflow-runner/test/e2e.test.ts +268 -0
  29. package/plugins/workflow-runner/test/expressions.test.ts +140 -0
  30. package/plugins/workflow-runner/test/fixtures/cli-e2e.yaml +27 -0
  31. package/plugins/workflow-runner/test/fixtures/hello-workflow.yaml +49 -0
  32. package/plugins/workflow-runner/test/graceful.test.ts +139 -0
  33. package/plugins/workflow-runner/test/loader.test.ts +216 -0
  34. package/plugins/workflow-runner/test/pluginRunner.isolation.test.ts +230 -0
  35. package/plugins/workflow-runner/test/runner.test.ts +511 -0
  36. package/skills/image-gen-openrouter/SKILL.md +121 -0
  37. package/skills/subtitle-srt/SKILL.md +134 -0
  38. package/skills/tts-zh/SKILL.md +137 -0
  39. package/skills/video-compose/SKILL.md +139 -0
  40. package/workflows/book-review-short.yaml +99 -0
  41. package/workflows/e2e-write-greet.yaml +27 -0
  42. package/workflows/schema.json +74 -0
  43. package/workflows/youtube-shorts.yaml +171 -0
@@ -0,0 +1,122 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import {
3
+ parseVerifyArgs,
4
+ verifyFileExists,
5
+ verifyFileContains,
6
+ runVerification,
7
+ } from '../src/verificationGate.ts';
8
+ import type { Check } from '../src/verificationGate.ts';
9
+
10
+ describe('verificationGate', () => {
11
+ describe('parseVerifyArgs', () => {
12
+ it('解析 --verify shell:xxx', () => {
13
+ const checks = parseVerifyArgs('--verify "shell:npm test"');
14
+ expect(checks).toHaveLength(1);
15
+ expect(checks[0].type).toBe('shell');
16
+ expect(checks[0].command).toBe('npm test');
17
+ });
18
+
19
+ it('解析多个 --verify', () => {
20
+ const checks = parseVerifyArgs(
21
+ '--verify "shell:npm test" --verify "file_exists:src/index.ts" --verify "file_contains:README.md:minimal-agent"',
22
+ );
23
+ expect(checks).toHaveLength(3);
24
+ expect(checks[0].type).toBe('shell');
25
+ expect(checks[1].type).toBe('file_exists');
26
+ expect(checks[2].type).toBe('file_contains');
27
+ });
28
+
29
+ it('无 --verify 返回空数组', () => {
30
+ const checks = parseVerifyArgs('--max-iterations 50 "some task"');
31
+ expect(checks).toHaveLength(0);
32
+ });
33
+
34
+ it('空字符串返回空数组', () => {
35
+ const checks = parseVerifyArgs('');
36
+ expect(checks).toHaveLength(0);
37
+ });
38
+
39
+ it('忽略无效类型', () => {
40
+ const checks = parseVerifyArgs('--verify "invalid:value"');
41
+ expect(checks).toHaveLength(0);
42
+ });
43
+
44
+ it('解析 test_count', () => {
45
+ const checks = parseVerifyArgs('--verify "test_count:10"');
46
+ expect(checks).toHaveLength(1);
47
+ expect(checks[0].type).toBe('test_count');
48
+ expect(checks[0].minCount).toBe(10);
49
+ });
50
+ });
51
+
52
+ describe('verifyFileExists', () => {
53
+ it('存在的文件通过', () => {
54
+ const result = verifyFileExists('package.json');
55
+ expect(result.passed).toBe(true);
56
+ expect(result.output).toContain('存在');
57
+ });
58
+
59
+ it('不存在的文件不通过', () => {
60
+ const result = verifyFileExists('__nonexistent_file_xyz__');
61
+ expect(result.passed).toBe(false);
62
+ expect(result.output).toContain('不存在');
63
+ });
64
+ });
65
+
66
+ describe('verifyFileContains', () => {
67
+ it('文件包含模式串通过', () => {
68
+ const result = verifyFileContains('package.json', '"name"');
69
+ expect(result.passed).toBe(true);
70
+ });
71
+
72
+ it('文件不包含模式串不通过', () => {
73
+ const result = verifyFileContains('package.json', '__xyz_pattern_not_exist__');
74
+ expect(result.passed).toBe(false);
75
+ });
76
+
77
+ it('不存在的文件不通过', () => {
78
+ const result = verifyFileContains('__nonexistent__', 'pattern');
79
+ expect(result.passed).toBe(false);
80
+ expect(result.output).toContain('无法读取');
81
+ });
82
+ });
83
+
84
+ describe('runVerification', () => {
85
+ it('无验证项直接通过', async () => {
86
+ const result = await runVerification([]);
87
+ expect(result.passed).toBe(true);
88
+ expect(result.summary).toContain('无验证项');
89
+ });
90
+
91
+ it('全部通过的检查返回 passed=true', async () => {
92
+ const checks: Check[] = [
93
+ { type: 'file_exists', file: 'package.json' },
94
+ { type: 'file_contains', file: 'package.json', pattern: '"name"' },
95
+ ];
96
+ const result = await runVerification(checks);
97
+ expect(result.passed).toBe(true);
98
+ expect(result.details).toHaveLength(2);
99
+ expect(result.details.every((d) => d.passed)).toBe(true);
100
+ });
101
+
102
+ it('有失败的检查返回 passed=false', async () => {
103
+ const checks: Check[] = [
104
+ { type: 'file_exists', file: '__nonexistent__' },
105
+ ];
106
+ const result = await runVerification(checks);
107
+ expect(result.passed).toBe(false);
108
+ expect(result.summary).toContain('❌');
109
+ });
110
+
111
+ it('混合结果返回 passed=false', async () => {
112
+ const checks: Check[] = [
113
+ { type: 'file_exists', file: 'package.json' },
114
+ { type: 'file_exists', file: '__nonexistent__' },
115
+ ];
116
+ const result = await runVerification(checks);
117
+ expect(result.passed).toBe(false);
118
+ expect(result.details[0].passed).toBe(true);
119
+ expect(result.details[1].passed).toBe(false);
120
+ });
121
+ });
122
+ });
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "workflow-runner",
3
+ "version": "0.1.0",
4
+ "description": "YAML workflow DAG executor for minimal-agent. Provides /workflow and /workflows commands."
5
+ }
@@ -0,0 +1,15 @@
1
+ ---
2
+ description: 运行 YAML 工作流(DAG 执行器),用 --input k=v 传参
3
+ argument-hint: <name> [--input key=value ...]
4
+ ---
5
+
6
+ # /workflow
7
+
8
+ 本命令由 workflow-runner 插件接管(plugin.ts.runCommand),不会把 prompt 喂给 LLM。
9
+ 直接调用插件内置的 YAML DAG 执行器,按 steps 顺序运行 tool / llm / assert / branch / loop 等节点。
10
+
11
+ 用法示例:
12
+
13
+ /workflow hello-workflow --input name=alice --input lang=zh
14
+
15
+ 输入 /workflows 查看可用工作流列表。
@@ -0,0 +1,8 @@
1
+ ---
2
+ description: 列出当前 cwd 与插件包内可用的 YAML 工作流
3
+ ---
4
+
5
+ # /workflows
6
+
7
+ 由 workflow-runner 插件接管(plugin.ts.runCommand),扫描 cwd/workflows/ 与
8
+ plugin 自带 examples/ 下的 *.yaml,把名称 + 一行描述列出来。
@@ -0,0 +1,42 @@
1
+ /**
2
+ * ============================================================
3
+ * plugins/workflow-runner/plugin.ts
4
+ * ------------------------------------------------------------
5
+ * workflow-runner 插件入口。接管 /workflow 与 /workflows 两个命令的执行:
6
+ * 不走声明式 markdown→LLM 通道,而是直接调插件内置的 YAML DAG 执行器。
7
+ *
8
+ * 执行器与全部依赖都住在本插件目录 ./src/,外部 src/ 一行不改。
9
+ * 插件只通过 src/plugin-sdk.ts 取框架运行时(runQuery / chat / executeTool 等)。
10
+ *
11
+ * 契约:default export 一个 PluginApi 对象。框架 pluginLoader 会按 pluginRoot
12
+ * 缓存这次 import,所以 cold start 后 dynamic import 只跑一次。
13
+ * ============================================================
14
+ */
15
+
16
+ import type { PluginApi } from '../../src/plugin-sdk.ts';
17
+ import { runWorkflowFromCommand, runWorkflowsList } from './src/index.ts';
18
+
19
+ const api: PluginApi = {
20
+ async *runCommand(commandName, args, ctx) {
21
+ if (commandName === 'workflows') {
22
+ yield* runWorkflowsList();
23
+ return;
24
+ }
25
+
26
+ if (commandName === 'workflow') {
27
+ yield* runWorkflowFromCommand(args, {
28
+ provider: ctx.provider,
29
+ history: ctx.history,
30
+ signal: ctx.signal,
31
+ });
32
+ return;
33
+ }
34
+
35
+ yield {
36
+ type: 'error',
37
+ error: `workflow-runner: 未知命令 /${commandName}`,
38
+ };
39
+ },
40
+ };
41
+
42
+ export default api;
@@ -0,0 +1,371 @@
1
+ /**
2
+ * ============================================================
3
+ * src/workflows/expressions.ts —— workflow 表达式引擎(mini)
4
+ * ------------------------------------------------------------
5
+ * WHY 不直接 eval / Function?
6
+ * Workflow 来源不可信(用户编辑器生成 / 别人贡献的 yaml),允许任意
7
+ * JS 执行 = 任意远程代码执行。所以我们手写一个**只支持必要语法**的
8
+ * 递归下降解析器:常量、变量引用、点路径、字符串/数字字面量、数组、
9
+ * 比较、布尔运算、白名单内置函数。
10
+ *
11
+ * 支持的语法:
12
+ * expr := or
13
+ * or := and ( '||' and )*
14
+ * and := not ( '&&' not )*
15
+ * not := '!' not | comparison
16
+ * comparison := primary ( ('==' | '!=' | '>=' | '<=' | '>' | '<') primary )?
17
+ * primary := number | string | array | call | path | '(' expr ')'
18
+ * array := '[' expr (',' expr)* ']'
19
+ * call := IDENT '(' expr (',' expr)* ')' // 白名单
20
+ * path := IDENT ('.' IDENT)* // 变量 + 点路径
21
+ *
22
+ * 白名单函数:fileExists / length / lower / upper
23
+ *
24
+ * 插值:${EXPR} —— 把 EXPR 求值后 toString 替换;对象/数组用 JSON.stringify。
25
+ * interpolateDeep 递归处理对象/数组/字符串。
26
+ * ============================================================
27
+ */
28
+
29
+ import { existsSync } from 'node:fs';
30
+ import { join, isAbsolute } from 'node:path';
31
+
32
+ import { getWorkingDir } from '../../../src/plugin-sdk.ts';
33
+ import type { VarStack } from './types.ts';
34
+
35
+ // ---------------- Tokenizer ----------------
36
+
37
+ type Token =
38
+ | { kind: 'num'; value: number }
39
+ | { kind: 'str'; value: string }
40
+ | { kind: 'ident'; value: string }
41
+ | { kind: 'punct'; value: string };
42
+
43
+ const PUNCT2 = new Set(['==', '!=', '>=', '<=', '&&', '||']);
44
+ const PUNCT1 = new Set(['(', ')', '[', ']', ',', '.', '!', '>', '<']);
45
+
46
+ function tokenize(src: string): Token[] {
47
+ const out: Token[] = [];
48
+ let i = 0;
49
+ while (i < src.length) {
50
+ const c = src[i];
51
+ if (c === ' ' || c === '\t' || c === '\n' || c === '\r') {
52
+ i++;
53
+ continue;
54
+ }
55
+ // 字符串
56
+ if (c === '"' || c === "'") {
57
+ const quote = c;
58
+ let s = '';
59
+ i++;
60
+ while (i < src.length && src[i] !== quote) {
61
+ if (src[i] === '\\' && i + 1 < src.length) {
62
+ const nx = src[i + 1];
63
+ s += nx === 'n' ? '\n' : nx === 't' ? '\t' : nx;
64
+ i += 2;
65
+ } else {
66
+ s += src[i++];
67
+ }
68
+ }
69
+ if (src[i] !== quote) throw new Error(`未闭合的字符串:${src}`);
70
+ i++;
71
+ out.push({ kind: 'str', value: s });
72
+ continue;
73
+ }
74
+ // 数字
75
+ if ((c >= '0' && c <= '9') || (c === '-' && src[i + 1] >= '0' && src[i + 1] <= '9')) {
76
+ let j = i;
77
+ if (src[j] === '-') j++;
78
+ while (j < src.length && ((src[j] >= '0' && src[j] <= '9') || src[j] === '.')) j++;
79
+ out.push({ kind: 'num', value: parseFloat(src.slice(i, j)) });
80
+ i = j;
81
+ continue;
82
+ }
83
+ // 双字符标点
84
+ const two = src.slice(i, i + 2);
85
+ if (PUNCT2.has(two)) {
86
+ out.push({ kind: 'punct', value: two });
87
+ i += 2;
88
+ continue;
89
+ }
90
+ // 单字符标点
91
+ if (PUNCT1.has(c)) {
92
+ out.push({ kind: 'punct', value: c });
93
+ i++;
94
+ continue;
95
+ }
96
+ // 标识符
97
+ if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_') {
98
+ let j = i;
99
+ while (
100
+ j < src.length &&
101
+ ((src[j] >= 'a' && src[j] <= 'z') ||
102
+ (src[j] >= 'A' && src[j] <= 'Z') ||
103
+ (src[j] >= '0' && src[j] <= '9') ||
104
+ src[j] === '_')
105
+ ) {
106
+ j++;
107
+ }
108
+ out.push({ kind: 'ident', value: src.slice(i, j) });
109
+ i = j;
110
+ continue;
111
+ }
112
+ throw new Error(`非法字符 '${c}' 在表达式 "${src}"`);
113
+ }
114
+ return out;
115
+ }
116
+
117
+ // ---------------- Parser → AST → eval(一次性递归下降,零 AST 对象) ----------------
118
+
119
+ interface Cursor {
120
+ toks: Token[];
121
+ pos: number;
122
+ }
123
+
124
+ function peek(c: Cursor): Token | null {
125
+ return c.pos < c.toks.length ? c.toks[c.pos] : null;
126
+ }
127
+ function eat(c: Cursor): Token {
128
+ return c.toks[c.pos++];
129
+ }
130
+ function isPunct(c: Cursor, v: string): boolean {
131
+ const t = peek(c);
132
+ return !!t && t.kind === 'punct' && t.value === v;
133
+ }
134
+
135
+ const BUILTINS: Record<string, (args: unknown[]) => unknown> = {
136
+ fileExists(args) {
137
+ const p = String(args[0] ?? '');
138
+ if (!p) return false;
139
+ const abs = isAbsolute(p) ? p : join(getWorkingDir(), p);
140
+ return existsSync(abs);
141
+ },
142
+ length(args) {
143
+ const v = args[0];
144
+ if (typeof v === 'string' || Array.isArray(v)) return v.length;
145
+ if (v && typeof v === 'object') return Object.keys(v).length;
146
+ return 0;
147
+ },
148
+ lower(args) {
149
+ return String(args[0] ?? '').toLowerCase();
150
+ },
151
+ upper(args) {
152
+ return String(args[0] ?? '').toUpperCase();
153
+ },
154
+ };
155
+
156
+ function parseOr(c: Cursor, vars: VarStack): unknown {
157
+ let left = parseAnd(c, vars);
158
+ while (isPunct(c, '||')) {
159
+ eat(c);
160
+ const right = parseAnd(c, vars);
161
+ left = truthy(left) || truthy(right);
162
+ }
163
+ return left;
164
+ }
165
+ function parseAnd(c: Cursor, vars: VarStack): unknown {
166
+ let left = parseNot(c, vars);
167
+ while (isPunct(c, '&&')) {
168
+ eat(c);
169
+ const right = parseNot(c, vars);
170
+ left = truthy(left) && truthy(right);
171
+ }
172
+ return left;
173
+ }
174
+ function parseNot(c: Cursor, vars: VarStack): unknown {
175
+ if (isPunct(c, '!')) {
176
+ eat(c);
177
+ return !truthy(parseNot(c, vars));
178
+ }
179
+ return parseComparison(c, vars);
180
+ }
181
+ function parseComparison(c: Cursor, vars: VarStack): unknown {
182
+ const left = parsePrimary(c, vars);
183
+ const t = peek(c);
184
+ if (t && t.kind === 'punct' && ['==', '!=', '>=', '<=', '>', '<'].includes(t.value)) {
185
+ eat(c);
186
+ const right = parsePrimary(c, vars);
187
+ switch (t.value) {
188
+ case '==':
189
+ // eslint-disable-next-line eqeqeq
190
+ return left == right;
191
+ case '!=':
192
+ // eslint-disable-next-line eqeqeq
193
+ return left != right;
194
+ case '>':
195
+ return (left as number) > (right as number);
196
+ case '<':
197
+ return (left as number) < (right as number);
198
+ case '>=':
199
+ return (left as number) >= (right as number);
200
+ case '<=':
201
+ return (left as number) <= (right as number);
202
+ }
203
+ }
204
+ return left;
205
+ }
206
+ function parsePrimary(c: Cursor, vars: VarStack): unknown {
207
+ const t = peek(c);
208
+ if (!t) throw new Error('表达式意外结束');
209
+
210
+ if (t.kind === 'punct' && t.value === '(') {
211
+ eat(c);
212
+ const v = parseOr(c, vars);
213
+ if (!isPunct(c, ')')) throw new Error("缺少 ')'");
214
+ eat(c);
215
+ return v;
216
+ }
217
+ if (t.kind === 'punct' && t.value === '[') {
218
+ eat(c);
219
+ const arr: unknown[] = [];
220
+ if (!isPunct(c, ']')) {
221
+ arr.push(parseOr(c, vars));
222
+ while (isPunct(c, ',')) {
223
+ eat(c);
224
+ arr.push(parseOr(c, vars));
225
+ }
226
+ }
227
+ if (!isPunct(c, ']')) throw new Error("缺少 ']'");
228
+ eat(c);
229
+ return arr;
230
+ }
231
+ if (t.kind === 'num') {
232
+ eat(c);
233
+ return t.value;
234
+ }
235
+ if (t.kind === 'str') {
236
+ eat(c);
237
+ return t.value;
238
+ }
239
+ if (t.kind === 'ident') {
240
+ eat(c);
241
+ // 函数调用?
242
+ if (isPunct(c, '(')) {
243
+ eat(c);
244
+ const argv: unknown[] = [];
245
+ if (!isPunct(c, ')')) {
246
+ argv.push(parseOr(c, vars));
247
+ while (isPunct(c, ',')) {
248
+ eat(c);
249
+ argv.push(parseOr(c, vars));
250
+ }
251
+ }
252
+ if (!isPunct(c, ')')) throw new Error("缺少 ')'");
253
+ eat(c);
254
+ const fn = BUILTINS[t.value];
255
+ if (!fn) throw new Error(`未知函数 "${t.value}"。白名单:${Object.keys(BUILTINS).join(', ')}`);
256
+ return fn(argv);
257
+ }
258
+ // 字面常量
259
+ if (t.value === 'true') return true;
260
+ if (t.value === 'false') return false;
261
+ if (t.value === 'null') return null;
262
+ // 变量 + 点路径
263
+ let cur: unknown = vars.get(t.value);
264
+ while (isPunct(c, '.')) {
265
+ eat(c);
266
+ const next = eat(c);
267
+ if (next.kind !== 'ident') throw new Error('点号后必须是标识符');
268
+ cur = cur != null && typeof cur === 'object' ? (cur as Record<string, unknown>)[next.value] : undefined;
269
+ }
270
+ return cur;
271
+ }
272
+ throw new Error(`无法解析 token: ${JSON.stringify(t)}`);
273
+ }
274
+
275
+ function truthy(v: unknown): boolean {
276
+ if (v === null || v === undefined) return false;
277
+ if (typeof v === 'boolean') return v;
278
+ if (typeof v === 'number') return v !== 0 && !Number.isNaN(v);
279
+ if (typeof v === 'string') return v.length > 0;
280
+ if (Array.isArray(v)) return v.length > 0;
281
+ if (typeof v === 'object') return Object.keys(v).length > 0;
282
+ return true;
283
+ }
284
+
285
+ // ---------------- 对外 API ----------------
286
+
287
+ /** 计算表达式(裸的,不带 ${} 包裹)。失败抛错。 */
288
+ export function evalExpr(expr: string, vars: VarStack): unknown {
289
+ const toks = tokenize(expr);
290
+ if (toks.length === 0) return undefined;
291
+ const cursor: Cursor = { toks, pos: 0 };
292
+ const v = parseOr(cursor, vars);
293
+ if (cursor.pos !== toks.length) {
294
+ throw new Error(`表达式多余 token: "${expr}"`);
295
+ }
296
+ return v;
297
+ }
298
+
299
+ /**
300
+ * 字符串插值:把 "${EXPR}" 替换成 EXPR 求值结果。
301
+ * 多个 ${} 串联也支持。括号配对识别 —— "${ ${nested} }" 会被当成一个外层
302
+ * EXPR(内部 ${nested} 文本原样,但表达式里不允许 ${} 语法,所以会报错)。
303
+ *
304
+ * 转义:`\$` → 字面 $,跳过插值。
305
+ */
306
+ export function interpolate(template: string, vars: VarStack): string {
307
+ let out = '';
308
+ let i = 0;
309
+ while (i < template.length) {
310
+ const c = template[i];
311
+ if (c === '\\' && template[i + 1] === '$') {
312
+ out += '$';
313
+ i += 2;
314
+ continue;
315
+ }
316
+ if (c === '$' && template[i + 1] === '{') {
317
+ // 找匹配的 '}'
318
+ let depth = 1;
319
+ let j = i + 2;
320
+ while (j < template.length && depth > 0) {
321
+ if (template[j] === '{') depth++;
322
+ else if (template[j] === '}') depth--;
323
+ if (depth > 0) j++;
324
+ }
325
+ if (depth !== 0) {
326
+ // 不匹配;原样输出
327
+ out += template.slice(i);
328
+ return out;
329
+ }
330
+ const expr = template.slice(i + 2, j);
331
+ const v = evalExpr(expr, vars);
332
+ out += stringify(v);
333
+ i = j + 1;
334
+ continue;
335
+ }
336
+ out += c;
337
+ i++;
338
+ }
339
+ return out;
340
+ }
341
+
342
+ function stringify(v: unknown): string {
343
+ if (v === null || v === undefined) return '';
344
+ if (typeof v === 'string') return v;
345
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v);
346
+ try {
347
+ return JSON.stringify(v);
348
+ } catch {
349
+ return String(v);
350
+ }
351
+ }
352
+
353
+ /** 递归对对象/数组/字符串做插值;其它原样返回。 */
354
+ export function interpolateDeep<T>(value: T, vars: VarStack): T {
355
+ if (typeof value === 'string') {
356
+ return interpolate(value, vars) as unknown as T;
357
+ }
358
+ if (Array.isArray(value)) {
359
+ return value.map((v) => interpolateDeep(v, vars)) as unknown as T;
360
+ }
361
+ if (value && typeof value === 'object') {
362
+ const out: Record<string, unknown> = {};
363
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
364
+ // 注意:key 也插值(loop 里 `hook_${idx}` 这种),但只对字符串 key 有意义
365
+ const newKey = interpolate(k, vars);
366
+ out[newKey] = interpolateDeep(v, vars);
367
+ }
368
+ return out as unknown as T;
369
+ }
370
+ return value;
371
+ }