@crewhaus/spec 0.1.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/package.json +43 -0
- package/src/index.test.ts +1011 -0
- package/src/index.ts +1035 -0
|
@@ -0,0 +1,1011 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Spec, SpecParseError, parseSpec } from "./index";
|
|
3
|
+
|
|
4
|
+
describe("parseSpec", () => {
|
|
5
|
+
test("parses a minimal valid CLI spec", () => {
|
|
6
|
+
const spec = parseSpec(`
|
|
7
|
+
name: hello
|
|
8
|
+
target: cli
|
|
9
|
+
agent:
|
|
10
|
+
model: claude-sonnet-4-6
|
|
11
|
+
instructions: be helpful
|
|
12
|
+
`);
|
|
13
|
+
expect(spec.name).toBe("hello");
|
|
14
|
+
expect(spec.target).toBe("cli");
|
|
15
|
+
if (spec.target !== "cli") expect.unreachable();
|
|
16
|
+
expect(spec.agent.model).toBe("claude-sonnet-4-6");
|
|
17
|
+
expect(spec.agent.instructions).toBe("be helpful");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("preserves multi-line block-scalar instructions", () => {
|
|
21
|
+
const spec = parseSpec(`
|
|
22
|
+
name: hello
|
|
23
|
+
target: cli
|
|
24
|
+
agent:
|
|
25
|
+
model: m
|
|
26
|
+
instructions: |
|
|
27
|
+
line one.
|
|
28
|
+
line two.
|
|
29
|
+
`);
|
|
30
|
+
if (spec.target !== "cli") expect.unreachable();
|
|
31
|
+
expect(spec.agent.instructions).toBe("line one.\nline two.\n");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("rejects spec with missing required fields", () => {
|
|
35
|
+
expect(() => parseSpec("name: hello")).toThrow(SpecParseError);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("rejects spec with unknown top-level fields (strict mode)", () => {
|
|
39
|
+
expect(() =>
|
|
40
|
+
parseSpec(`
|
|
41
|
+
name: hello
|
|
42
|
+
target: cli
|
|
43
|
+
agent:
|
|
44
|
+
model: m
|
|
45
|
+
instructions: i
|
|
46
|
+
extra: nope
|
|
47
|
+
`),
|
|
48
|
+
).toThrow(SpecParseError);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("rejects an unsupported target", () => {
|
|
52
|
+
expect(() =>
|
|
53
|
+
parseSpec(`
|
|
54
|
+
name: hello
|
|
55
|
+
target: voice
|
|
56
|
+
agent:
|
|
57
|
+
model: m
|
|
58
|
+
instructions: i
|
|
59
|
+
`),
|
|
60
|
+
).toThrow(SpecParseError);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("rejects malformed YAML", () => {
|
|
64
|
+
expect(() => parseSpec("{[\nname: oops")).toThrow(SpecParseError);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("error message points at the failing path", () => {
|
|
68
|
+
try {
|
|
69
|
+
parseSpec(`
|
|
70
|
+
name: hello
|
|
71
|
+
target: cli
|
|
72
|
+
agent:
|
|
73
|
+
model: ""
|
|
74
|
+
instructions: ok
|
|
75
|
+
`);
|
|
76
|
+
expect.unreachable();
|
|
77
|
+
} catch (err) {
|
|
78
|
+
expect(err).toBeInstanceOf(SpecParseError);
|
|
79
|
+
expect((err as Error).message).toContain("agent.model");
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("parseSpec tools field", () => {
|
|
85
|
+
test("parses a CLI spec with a tools array", () => {
|
|
86
|
+
const spec = parseSpec(`
|
|
87
|
+
name: hello
|
|
88
|
+
target: cli
|
|
89
|
+
agent:
|
|
90
|
+
model: m
|
|
91
|
+
instructions: i
|
|
92
|
+
tools:
|
|
93
|
+
- read
|
|
94
|
+
- write
|
|
95
|
+
`);
|
|
96
|
+
if (spec.target !== "cli") expect.unreachable();
|
|
97
|
+
expect(spec.tools).toEqual(["read", "write"]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("tools field is optional (omitted means undefined)", () => {
|
|
101
|
+
const spec = parseSpec(`
|
|
102
|
+
name: hello
|
|
103
|
+
target: cli
|
|
104
|
+
agent:
|
|
105
|
+
model: m
|
|
106
|
+
instructions: i
|
|
107
|
+
`);
|
|
108
|
+
if (spec.target !== "cli") expect.unreachable();
|
|
109
|
+
expect(spec.tools).toBeUndefined();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("rejects non-string tool entries", () => {
|
|
113
|
+
expect(() =>
|
|
114
|
+
parseSpec(`
|
|
115
|
+
name: hello
|
|
116
|
+
target: cli
|
|
117
|
+
agent:
|
|
118
|
+
model: m
|
|
119
|
+
instructions: i
|
|
120
|
+
tools:
|
|
121
|
+
- 123
|
|
122
|
+
`),
|
|
123
|
+
).toThrow(SpecParseError);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("rejects empty-string tool names", () => {
|
|
127
|
+
expect(() =>
|
|
128
|
+
parseSpec(`
|
|
129
|
+
name: hello
|
|
130
|
+
target: cli
|
|
131
|
+
agent:
|
|
132
|
+
model: m
|
|
133
|
+
instructions: i
|
|
134
|
+
tools:
|
|
135
|
+
- ""
|
|
136
|
+
`),
|
|
137
|
+
).toThrow(SpecParseError);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("Spec schema", () => {
|
|
142
|
+
test("schema is exported as a runtime value (Zod)", () => {
|
|
143
|
+
expect(typeof Spec.safeParse).toBe("function");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("parseSpec workflow target", () => {
|
|
148
|
+
test("parses a minimal valid workflow spec", () => {
|
|
149
|
+
const spec = parseSpec(`
|
|
150
|
+
name: hello-workflow
|
|
151
|
+
target: workflow
|
|
152
|
+
model: claude-sonnet-4-6
|
|
153
|
+
steps:
|
|
154
|
+
- name: only-step
|
|
155
|
+
instructions: do the thing
|
|
156
|
+
`);
|
|
157
|
+
expect(spec.target).toBe("workflow");
|
|
158
|
+
if (spec.target !== "workflow") expect.unreachable();
|
|
159
|
+
expect(spec.name).toBe("hello-workflow");
|
|
160
|
+
expect(spec.model).toBe("claude-sonnet-4-6");
|
|
161
|
+
expect(spec.steps).toHaveLength(1);
|
|
162
|
+
expect(spec.steps[0]?.name).toBe("only-step");
|
|
163
|
+
expect(spec.steps[0]?.instructions).toBe("do the thing");
|
|
164
|
+
expect(spec.steps[0]?.model).toBeUndefined();
|
|
165
|
+
expect(spec.steps[0]?.tools).toBeUndefined();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("parses a workflow spec with multiple steps and per-step tools", () => {
|
|
169
|
+
const spec = parseSpec(`
|
|
170
|
+
name: w
|
|
171
|
+
target: workflow
|
|
172
|
+
model: m
|
|
173
|
+
steps:
|
|
174
|
+
- name: a
|
|
175
|
+
instructions: ai
|
|
176
|
+
tools:
|
|
177
|
+
- bash
|
|
178
|
+
- name: b
|
|
179
|
+
instructions: bi
|
|
180
|
+
`);
|
|
181
|
+
if (spec.target !== "workflow") expect.unreachable();
|
|
182
|
+
expect(spec.steps).toHaveLength(2);
|
|
183
|
+
expect(spec.steps[0]?.tools).toEqual(["bash"]);
|
|
184
|
+
expect(spec.steps[1]?.tools).toBeUndefined();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("parses a workflow spec with per-step model override", () => {
|
|
188
|
+
const spec = parseSpec(`
|
|
189
|
+
name: w
|
|
190
|
+
target: workflow
|
|
191
|
+
model: default-model
|
|
192
|
+
steps:
|
|
193
|
+
- name: a
|
|
194
|
+
instructions: ai
|
|
195
|
+
model: override-model
|
|
196
|
+
- name: b
|
|
197
|
+
instructions: bi
|
|
198
|
+
`);
|
|
199
|
+
if (spec.target !== "workflow") expect.unreachable();
|
|
200
|
+
expect(spec.steps[0]?.model).toBe("override-model");
|
|
201
|
+
expect(spec.steps[1]?.model).toBeUndefined();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("rejects a workflow spec with no steps", () => {
|
|
205
|
+
expect(() =>
|
|
206
|
+
parseSpec(`
|
|
207
|
+
name: w
|
|
208
|
+
target: workflow
|
|
209
|
+
model: m
|
|
210
|
+
steps: []
|
|
211
|
+
`),
|
|
212
|
+
).toThrow(SpecParseError);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("rejects a workflow step with empty instructions", () => {
|
|
216
|
+
expect(() =>
|
|
217
|
+
parseSpec(`
|
|
218
|
+
name: w
|
|
219
|
+
target: workflow
|
|
220
|
+
model: m
|
|
221
|
+
steps:
|
|
222
|
+
- name: a
|
|
223
|
+
instructions: ""
|
|
224
|
+
`),
|
|
225
|
+
).toThrow(SpecParseError);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("rejects a workflow step with empty name", () => {
|
|
229
|
+
expect(() =>
|
|
230
|
+
parseSpec(`
|
|
231
|
+
name: w
|
|
232
|
+
target: workflow
|
|
233
|
+
model: m
|
|
234
|
+
steps:
|
|
235
|
+
- name: ""
|
|
236
|
+
instructions: ai
|
|
237
|
+
`),
|
|
238
|
+
).toThrow(SpecParseError);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("rejects a workflow spec missing top-level model", () => {
|
|
242
|
+
expect(() =>
|
|
243
|
+
parseSpec(`
|
|
244
|
+
name: w
|
|
245
|
+
target: workflow
|
|
246
|
+
steps:
|
|
247
|
+
- name: a
|
|
248
|
+
instructions: ai
|
|
249
|
+
`),
|
|
250
|
+
).toThrow(SpecParseError);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("rejects a workflow spec with extra top-level field (strict)", () => {
|
|
254
|
+
expect(() =>
|
|
255
|
+
parseSpec(`
|
|
256
|
+
name: w
|
|
257
|
+
target: workflow
|
|
258
|
+
model: m
|
|
259
|
+
extra: nope
|
|
260
|
+
steps:
|
|
261
|
+
- name: a
|
|
262
|
+
instructions: ai
|
|
263
|
+
`),
|
|
264
|
+
).toThrow(SpecParseError);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("rejects a workflow step with unknown field (strict)", () => {
|
|
268
|
+
expect(() =>
|
|
269
|
+
parseSpec(`
|
|
270
|
+
name: w
|
|
271
|
+
target: workflow
|
|
272
|
+
model: m
|
|
273
|
+
steps:
|
|
274
|
+
- name: a
|
|
275
|
+
instructions: ai
|
|
276
|
+
bogus: 1
|
|
277
|
+
`),
|
|
278
|
+
).toThrow(SpecParseError);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe("permissions block", () => {
|
|
282
|
+
test("accepts a cli spec with permissions: mode + rules", () => {
|
|
283
|
+
const spec = parseSpec(`
|
|
284
|
+
name: hello
|
|
285
|
+
target: cli
|
|
286
|
+
agent:
|
|
287
|
+
model: m
|
|
288
|
+
instructions: i
|
|
289
|
+
permissions:
|
|
290
|
+
mode: auto
|
|
291
|
+
rules:
|
|
292
|
+
- type: alwaysAllow
|
|
293
|
+
pattern: Read
|
|
294
|
+
- type: alwaysDeny
|
|
295
|
+
pattern: Bash(rm**)
|
|
296
|
+
`);
|
|
297
|
+
expect(spec.target).toBe("cli");
|
|
298
|
+
if (spec.target !== "cli") return;
|
|
299
|
+
expect(spec.permissions?.mode).toBe("auto");
|
|
300
|
+
expect(spec.permissions?.rules).toHaveLength(2);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("accepts a workflow spec with permissions block", () => {
|
|
304
|
+
const spec = parseSpec(`
|
|
305
|
+
name: w
|
|
306
|
+
target: workflow
|
|
307
|
+
model: m
|
|
308
|
+
steps:
|
|
309
|
+
- name: a
|
|
310
|
+
instructions: ai
|
|
311
|
+
permissions:
|
|
312
|
+
mode: plan
|
|
313
|
+
`);
|
|
314
|
+
expect(spec.target).toBe("workflow");
|
|
315
|
+
if (spec.target !== "workflow") return;
|
|
316
|
+
expect(spec.permissions?.mode).toBe("plan");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("rejects mode: bypass in cli spec with a friendly security message", () => {
|
|
320
|
+
expect(() =>
|
|
321
|
+
parseSpec(`
|
|
322
|
+
name: hello
|
|
323
|
+
target: cli
|
|
324
|
+
agent:
|
|
325
|
+
model: m
|
|
326
|
+
instructions: i
|
|
327
|
+
permissions:
|
|
328
|
+
mode: bypass
|
|
329
|
+
`),
|
|
330
|
+
).toThrow(SpecParseError);
|
|
331
|
+
expect(() =>
|
|
332
|
+
parseSpec(`
|
|
333
|
+
name: hello
|
|
334
|
+
target: cli
|
|
335
|
+
agent:
|
|
336
|
+
model: m
|
|
337
|
+
instructions: i
|
|
338
|
+
permissions:
|
|
339
|
+
mode: bypass
|
|
340
|
+
`),
|
|
341
|
+
).toThrow(/bypass mode is only available via the --permission-mode CLI flag/);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("rejects mode: bypass in workflow spec", () => {
|
|
345
|
+
expect(() =>
|
|
346
|
+
parseSpec(`
|
|
347
|
+
name: w
|
|
348
|
+
target: workflow
|
|
349
|
+
model: m
|
|
350
|
+
steps:
|
|
351
|
+
- name: a
|
|
352
|
+
instructions: ai
|
|
353
|
+
permissions:
|
|
354
|
+
mode: bypass
|
|
355
|
+
`),
|
|
356
|
+
).toThrow(SpecParseError);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("rejects unknown rule type", () => {
|
|
360
|
+
expect(() =>
|
|
361
|
+
parseSpec(`
|
|
362
|
+
name: hello
|
|
363
|
+
target: cli
|
|
364
|
+
agent:
|
|
365
|
+
model: m
|
|
366
|
+
instructions: i
|
|
367
|
+
permissions:
|
|
368
|
+
rules:
|
|
369
|
+
- type: neverAllow
|
|
370
|
+
pattern: Read
|
|
371
|
+
`),
|
|
372
|
+
).toThrow(SpecParseError);
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
describe("parseSpec channel target (Section 12)", () => {
|
|
378
|
+
test("parses a minimal valid channel spec", () => {
|
|
379
|
+
const spec = parseSpec(`
|
|
380
|
+
name: hello-channel
|
|
381
|
+
target: channel
|
|
382
|
+
agent:
|
|
383
|
+
model: claude-sonnet-4-6
|
|
384
|
+
instructions: be a good bot
|
|
385
|
+
channels:
|
|
386
|
+
slack:
|
|
387
|
+
botToken: xoxb-test
|
|
388
|
+
signingSecret: shh
|
|
389
|
+
routing:
|
|
390
|
+
sessionKey: thread
|
|
391
|
+
`);
|
|
392
|
+
expect(spec.target).toBe("channel");
|
|
393
|
+
if (spec.target !== "channel") expect.unreachable();
|
|
394
|
+
expect(spec.agent.model).toBe("claude-sonnet-4-6");
|
|
395
|
+
expect(spec.channels.slack?.botToken).toBe("xoxb-test");
|
|
396
|
+
expect(spec.routing.sessionKey).toBe("thread");
|
|
397
|
+
expect(spec.agent.tools).toBeUndefined();
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("parses a channel spec with agent.tools and permissions", () => {
|
|
401
|
+
const spec = parseSpec(`
|
|
402
|
+
name: hello-channel
|
|
403
|
+
target: channel
|
|
404
|
+
agent:
|
|
405
|
+
model: m
|
|
406
|
+
instructions: i
|
|
407
|
+
tools:
|
|
408
|
+
- read
|
|
409
|
+
- bash
|
|
410
|
+
channels:
|
|
411
|
+
slack:
|
|
412
|
+
botToken: $SLACK_BOT_TOKEN
|
|
413
|
+
signingSecret: $SLACK_SIGNING_SECRET
|
|
414
|
+
appToken: $SLACK_APP_TOKEN
|
|
415
|
+
routing:
|
|
416
|
+
sessionKey: user
|
|
417
|
+
permissions:
|
|
418
|
+
rules:
|
|
419
|
+
- type: alwaysAllow
|
|
420
|
+
pattern: Read
|
|
421
|
+
`);
|
|
422
|
+
if (spec.target !== "channel") expect.unreachable();
|
|
423
|
+
expect(spec.agent.tools).toEqual(["read", "bash"]);
|
|
424
|
+
expect(spec.channels.slack?.appToken).toBe("$SLACK_APP_TOKEN");
|
|
425
|
+
expect(spec.routing.sessionKey).toBe("user");
|
|
426
|
+
expect(spec.permissions?.rules).toHaveLength(1);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("rejects a channel spec missing the channels block", () => {
|
|
430
|
+
expect(() =>
|
|
431
|
+
parseSpec(`
|
|
432
|
+
name: hello-channel
|
|
433
|
+
target: channel
|
|
434
|
+
agent:
|
|
435
|
+
model: m
|
|
436
|
+
instructions: i
|
|
437
|
+
routing:
|
|
438
|
+
sessionKey: thread
|
|
439
|
+
`),
|
|
440
|
+
).toThrow(SpecParseError);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("rejects a channel spec with empty channels block (no slack)", () => {
|
|
444
|
+
expect(() =>
|
|
445
|
+
parseSpec(`
|
|
446
|
+
name: hello-channel
|
|
447
|
+
target: channel
|
|
448
|
+
agent:
|
|
449
|
+
model: m
|
|
450
|
+
instructions: i
|
|
451
|
+
channels: {}
|
|
452
|
+
routing:
|
|
453
|
+
sessionKey: thread
|
|
454
|
+
`),
|
|
455
|
+
).toThrow(/at least one channel/);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("rejects a channel spec missing routing", () => {
|
|
459
|
+
expect(() =>
|
|
460
|
+
parseSpec(`
|
|
461
|
+
name: hello-channel
|
|
462
|
+
target: channel
|
|
463
|
+
agent:
|
|
464
|
+
model: m
|
|
465
|
+
instructions: i
|
|
466
|
+
channels:
|
|
467
|
+
slack:
|
|
468
|
+
botToken: x
|
|
469
|
+
signingSecret: y
|
|
470
|
+
`),
|
|
471
|
+
).toThrow(SpecParseError);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("rejects an invalid sessionKey", () => {
|
|
475
|
+
expect(() =>
|
|
476
|
+
parseSpec(`
|
|
477
|
+
name: hello-channel
|
|
478
|
+
target: channel
|
|
479
|
+
agent:
|
|
480
|
+
model: m
|
|
481
|
+
instructions: i
|
|
482
|
+
channels:
|
|
483
|
+
slack:
|
|
484
|
+
botToken: x
|
|
485
|
+
signingSecret: y
|
|
486
|
+
routing:
|
|
487
|
+
sessionKey: workspace
|
|
488
|
+
`),
|
|
489
|
+
).toThrow(SpecParseError);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("rejects an unknown channel adapter (strict)", () => {
|
|
493
|
+
expect(() =>
|
|
494
|
+
parseSpec(`
|
|
495
|
+
name: hello-channel
|
|
496
|
+
target: channel
|
|
497
|
+
agent:
|
|
498
|
+
model: m
|
|
499
|
+
instructions: i
|
|
500
|
+
channels:
|
|
501
|
+
telegram:
|
|
502
|
+
botToken: x
|
|
503
|
+
routing:
|
|
504
|
+
sessionKey: thread
|
|
505
|
+
`),
|
|
506
|
+
).toThrow(SpecParseError);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test("rejects mode: bypass in channel spec", () => {
|
|
510
|
+
expect(() =>
|
|
511
|
+
parseSpec(`
|
|
512
|
+
name: hello-channel
|
|
513
|
+
target: channel
|
|
514
|
+
agent:
|
|
515
|
+
model: m
|
|
516
|
+
instructions: i
|
|
517
|
+
channels:
|
|
518
|
+
slack:
|
|
519
|
+
botToken: x
|
|
520
|
+
signingSecret: y
|
|
521
|
+
routing:
|
|
522
|
+
sessionKey: thread
|
|
523
|
+
permissions:
|
|
524
|
+
mode: bypass
|
|
525
|
+
`),
|
|
526
|
+
).toThrow(SpecParseError);
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
describe("parseSpec mcp_servers block (Section 9)", () => {
|
|
531
|
+
test("parses a CLI spec with a stdio MCP server", () => {
|
|
532
|
+
const spec = parseSpec(`
|
|
533
|
+
name: hello
|
|
534
|
+
target: cli
|
|
535
|
+
agent:
|
|
536
|
+
model: m
|
|
537
|
+
instructions: i
|
|
538
|
+
mcp_servers:
|
|
539
|
+
fs:
|
|
540
|
+
transport: stdio
|
|
541
|
+
command: npx
|
|
542
|
+
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
|
543
|
+
env:
|
|
544
|
+
DEBUG: "1"
|
|
545
|
+
`);
|
|
546
|
+
if (spec.target !== "cli") expect.unreachable();
|
|
547
|
+
expect(spec.mcp_servers).toBeDefined();
|
|
548
|
+
const fs = spec.mcp_servers?.["fs"];
|
|
549
|
+
expect(fs?.transport).toBe("stdio");
|
|
550
|
+
if (fs?.transport !== "stdio") expect.unreachable();
|
|
551
|
+
expect(fs.command).toBe("npx");
|
|
552
|
+
expect(fs.args).toEqual(["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]);
|
|
553
|
+
expect(fs.env).toEqual({ DEBUG: "1" });
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test("parses a CLI spec with an SSE MCP server", () => {
|
|
557
|
+
const spec = parseSpec(`
|
|
558
|
+
name: hello
|
|
559
|
+
target: cli
|
|
560
|
+
agent:
|
|
561
|
+
model: m
|
|
562
|
+
instructions: i
|
|
563
|
+
mcp_servers:
|
|
564
|
+
remote:
|
|
565
|
+
transport: sse
|
|
566
|
+
url: https://example.com/sse
|
|
567
|
+
headers:
|
|
568
|
+
Authorization: "Bearer x"
|
|
569
|
+
`);
|
|
570
|
+
if (spec.target !== "cli") expect.unreachable();
|
|
571
|
+
const remote = spec.mcp_servers?.["remote"];
|
|
572
|
+
expect(remote?.transport).toBe("sse");
|
|
573
|
+
if (remote?.transport !== "sse") expect.unreachable();
|
|
574
|
+
expect(remote.url).toBe("https://example.com/sse");
|
|
575
|
+
expect(remote.headers).toEqual({ Authorization: "Bearer x" });
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test("parses a workflow spec with mcp_servers", () => {
|
|
579
|
+
const spec = parseSpec(`
|
|
580
|
+
name: w
|
|
581
|
+
target: workflow
|
|
582
|
+
model: m
|
|
583
|
+
mcp_servers:
|
|
584
|
+
fs:
|
|
585
|
+
transport: stdio
|
|
586
|
+
command: foo
|
|
587
|
+
steps:
|
|
588
|
+
- name: a
|
|
589
|
+
instructions: ai
|
|
590
|
+
`);
|
|
591
|
+
if (spec.target !== "workflow") expect.unreachable();
|
|
592
|
+
expect(spec.mcp_servers?.["fs"]).toBeDefined();
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
test("mcp_servers field is optional", () => {
|
|
596
|
+
const spec = parseSpec(`
|
|
597
|
+
name: hello
|
|
598
|
+
target: cli
|
|
599
|
+
agent:
|
|
600
|
+
model: m
|
|
601
|
+
instructions: i
|
|
602
|
+
`);
|
|
603
|
+
if (spec.target !== "cli") expect.unreachable();
|
|
604
|
+
expect(spec.mcp_servers).toBeUndefined();
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
test("rejects an MCP config missing the discriminator", () => {
|
|
608
|
+
expect(() =>
|
|
609
|
+
parseSpec(`
|
|
610
|
+
name: hello
|
|
611
|
+
target: cli
|
|
612
|
+
agent:
|
|
613
|
+
model: m
|
|
614
|
+
instructions: i
|
|
615
|
+
mcp_servers:
|
|
616
|
+
fs:
|
|
617
|
+
command: npx
|
|
618
|
+
`),
|
|
619
|
+
).toThrow(SpecParseError);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
test("rejects an unknown transport value", () => {
|
|
623
|
+
expect(() =>
|
|
624
|
+
parseSpec(`
|
|
625
|
+
name: hello
|
|
626
|
+
target: cli
|
|
627
|
+
agent:
|
|
628
|
+
model: m
|
|
629
|
+
instructions: i
|
|
630
|
+
mcp_servers:
|
|
631
|
+
fs:
|
|
632
|
+
transport: ftp
|
|
633
|
+
command: x
|
|
634
|
+
`),
|
|
635
|
+
).toThrow(SpecParseError);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
test("rejects an stdio config with stray sse fields (strict mode)", () => {
|
|
639
|
+
expect(() =>
|
|
640
|
+
parseSpec(`
|
|
641
|
+
name: hello
|
|
642
|
+
target: cli
|
|
643
|
+
agent:
|
|
644
|
+
model: m
|
|
645
|
+
instructions: i
|
|
646
|
+
mcp_servers:
|
|
647
|
+
fs:
|
|
648
|
+
transport: stdio
|
|
649
|
+
command: x
|
|
650
|
+
url: https://nope
|
|
651
|
+
`),
|
|
652
|
+
).toThrow(SpecParseError);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
test("rejects an SSE config with non-URL url", () => {
|
|
656
|
+
expect(() =>
|
|
657
|
+
parseSpec(`
|
|
658
|
+
name: hello
|
|
659
|
+
target: cli
|
|
660
|
+
agent:
|
|
661
|
+
model: m
|
|
662
|
+
instructions: i
|
|
663
|
+
mcp_servers:
|
|
664
|
+
fs:
|
|
665
|
+
transport: sse
|
|
666
|
+
url: not-a-url
|
|
667
|
+
`),
|
|
668
|
+
).toThrow(SpecParseError);
|
|
669
|
+
});
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
describe("parseSpec — CLI banner (Phase 3 §3.3)", () => {
|
|
673
|
+
test("accepts a banner block with taglineMode and taglines", () => {
|
|
674
|
+
const spec = parseSpec(`
|
|
675
|
+
name: hello
|
|
676
|
+
target: cli
|
|
677
|
+
agent:
|
|
678
|
+
model: claude-sonnet-4-6
|
|
679
|
+
instructions: be helpful
|
|
680
|
+
cli:
|
|
681
|
+
banner:
|
|
682
|
+
taglineMode: random
|
|
683
|
+
taglines:
|
|
684
|
+
- "🦞 first"
|
|
685
|
+
- "🦞 second"
|
|
686
|
+
`);
|
|
687
|
+
if (spec.target !== "cli") throw new Error("unexpected target");
|
|
688
|
+
expect(spec.cli?.banner?.taglineMode).toBe("random");
|
|
689
|
+
expect(spec.cli?.banner?.taglines).toEqual(["🦞 first", "🦞 second"]);
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
test("defaults taglineMode to 'static' when omitted", () => {
|
|
693
|
+
const spec = parseSpec(`
|
|
694
|
+
name: hello
|
|
695
|
+
target: cli
|
|
696
|
+
agent:
|
|
697
|
+
model: claude-sonnet-4-6
|
|
698
|
+
instructions: be helpful
|
|
699
|
+
cli:
|
|
700
|
+
banner:
|
|
701
|
+
taglines: ["only one"]
|
|
702
|
+
`);
|
|
703
|
+
if (spec.target !== "cli") throw new Error("unexpected target");
|
|
704
|
+
expect(spec.cli?.banner?.taglineMode).toBe("static");
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test("rejects empty taglines array", () => {
|
|
708
|
+
expect(() =>
|
|
709
|
+
parseSpec(`
|
|
710
|
+
name: hello
|
|
711
|
+
target: cli
|
|
712
|
+
agent:
|
|
713
|
+
model: claude-sonnet-4-6
|
|
714
|
+
instructions: be helpful
|
|
715
|
+
cli:
|
|
716
|
+
banner:
|
|
717
|
+
taglines: []
|
|
718
|
+
`),
|
|
719
|
+
).toThrow(SpecParseError);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
test("rejects invalid taglineMode", () => {
|
|
723
|
+
expect(() =>
|
|
724
|
+
parseSpec(`
|
|
725
|
+
name: hello
|
|
726
|
+
target: cli
|
|
727
|
+
agent:
|
|
728
|
+
model: claude-sonnet-4-6
|
|
729
|
+
instructions: be helpful
|
|
730
|
+
cli:
|
|
731
|
+
banner:
|
|
732
|
+
taglineMode: invalid
|
|
733
|
+
taglines: ["t"]
|
|
734
|
+
`),
|
|
735
|
+
).toThrow(SpecParseError);
|
|
736
|
+
});
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
describe("parseSpec — gateway (Phase 3 §3.4)", () => {
|
|
740
|
+
test("accepts a gateway block with port + ui", () => {
|
|
741
|
+
const spec = parseSpec(`
|
|
742
|
+
name: hello
|
|
743
|
+
target: channel
|
|
744
|
+
agent:
|
|
745
|
+
model: claude-sonnet-4-6
|
|
746
|
+
instructions: be helpful
|
|
747
|
+
channels:
|
|
748
|
+
slack:
|
|
749
|
+
botToken: $SLACK_BOT_TOKEN
|
|
750
|
+
signingSecret: $SLACK_SIGNING_SECRET
|
|
751
|
+
routing:
|
|
752
|
+
sessionKey: thread
|
|
753
|
+
gateway:
|
|
754
|
+
port: 19001
|
|
755
|
+
ui: true
|
|
756
|
+
`);
|
|
757
|
+
if (spec.target !== "channel") throw new Error("unexpected target");
|
|
758
|
+
expect(spec.gateway?.port).toBe(19001);
|
|
759
|
+
expect(spec.gateway?.ui).toBe(true);
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
test("ui defaults to false when omitted", () => {
|
|
763
|
+
const spec = parseSpec(`
|
|
764
|
+
name: hello
|
|
765
|
+
target: channel
|
|
766
|
+
agent:
|
|
767
|
+
model: claude-sonnet-4-6
|
|
768
|
+
instructions: be helpful
|
|
769
|
+
channels:
|
|
770
|
+
slack:
|
|
771
|
+
botToken: $SLACK_BOT_TOKEN
|
|
772
|
+
signingSecret: $SLACK_SIGNING_SECRET
|
|
773
|
+
routing:
|
|
774
|
+
sessionKey: thread
|
|
775
|
+
gateway:
|
|
776
|
+
port: 8080
|
|
777
|
+
`);
|
|
778
|
+
if (spec.target !== "channel") throw new Error("unexpected target");
|
|
779
|
+
expect(spec.gateway?.ui).toBe(false);
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
test("rejects invalid port (out of range)", () => {
|
|
783
|
+
expect(() =>
|
|
784
|
+
parseSpec(`
|
|
785
|
+
name: hello
|
|
786
|
+
target: channel
|
|
787
|
+
agent:
|
|
788
|
+
model: claude-sonnet-4-6
|
|
789
|
+
instructions: be helpful
|
|
790
|
+
channels:
|
|
791
|
+
slack:
|
|
792
|
+
botToken: $SLACK_BOT_TOKEN
|
|
793
|
+
signingSecret: $SLACK_SIGNING_SECRET
|
|
794
|
+
routing:
|
|
795
|
+
sessionKey: thread
|
|
796
|
+
gateway:
|
|
797
|
+
port: 99999
|
|
798
|
+
`),
|
|
799
|
+
).toThrow(SpecParseError);
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
test("gateway is optional", () => {
|
|
803
|
+
const spec = parseSpec(`
|
|
804
|
+
name: hello
|
|
805
|
+
target: channel
|
|
806
|
+
agent:
|
|
807
|
+
model: claude-sonnet-4-6
|
|
808
|
+
instructions: be helpful
|
|
809
|
+
channels:
|
|
810
|
+
slack:
|
|
811
|
+
botToken: $SLACK_BOT_TOKEN
|
|
812
|
+
signingSecret: $SLACK_SIGNING_SECRET
|
|
813
|
+
routing:
|
|
814
|
+
sessionKey: thread
|
|
815
|
+
`);
|
|
816
|
+
if (spec.target !== "channel") throw new Error("unexpected target");
|
|
817
|
+
expect(spec.gateway).toBeUndefined();
|
|
818
|
+
});
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
describe("parseSpec — heartbeat (Phase 3 §3.1)", () => {
|
|
822
|
+
test("accepts a heartbeat block with duration and instructions", () => {
|
|
823
|
+
const spec = parseSpec(`
|
|
824
|
+
name: hello
|
|
825
|
+
target: channel
|
|
826
|
+
agent:
|
|
827
|
+
model: claude-sonnet-4-6
|
|
828
|
+
instructions: be helpful
|
|
829
|
+
channels:
|
|
830
|
+
slack:
|
|
831
|
+
botToken: $SLACK_BOT_TOKEN
|
|
832
|
+
signingSecret: $SLACK_SIGNING_SECRET
|
|
833
|
+
routing:
|
|
834
|
+
sessionKey: thread
|
|
835
|
+
heartbeat:
|
|
836
|
+
every: 2h
|
|
837
|
+
instructions: wake and decide
|
|
838
|
+
`);
|
|
839
|
+
if (spec.target !== "channel") throw new Error("unexpected target");
|
|
840
|
+
expect(spec.heartbeat?.every).toBe("2h");
|
|
841
|
+
expect(spec.heartbeat?.instructions).toBe("wake and decide");
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
test.each(["2h", "30m", "60s", "500ms"])("accepts duration string %s", (every: string) => {
|
|
845
|
+
const spec = parseSpec(`
|
|
846
|
+
name: hello
|
|
847
|
+
target: channel
|
|
848
|
+
agent:
|
|
849
|
+
model: claude-sonnet-4-6
|
|
850
|
+
instructions: be helpful
|
|
851
|
+
channels:
|
|
852
|
+
slack:
|
|
853
|
+
botToken: $SLACK_BOT_TOKEN
|
|
854
|
+
signingSecret: $SLACK_SIGNING_SECRET
|
|
855
|
+
routing:
|
|
856
|
+
sessionKey: thread
|
|
857
|
+
heartbeat:
|
|
858
|
+
every: ${every}
|
|
859
|
+
instructions: tick
|
|
860
|
+
`);
|
|
861
|
+
if (spec.target !== "channel") throw new Error("unexpected target");
|
|
862
|
+
expect(spec.heartbeat?.every).toBe(every);
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
test("rejects invalid duration format", () => {
|
|
866
|
+
expect(() =>
|
|
867
|
+
parseSpec(`
|
|
868
|
+
name: hello
|
|
869
|
+
target: channel
|
|
870
|
+
agent:
|
|
871
|
+
model: claude-sonnet-4-6
|
|
872
|
+
instructions: be helpful
|
|
873
|
+
channels:
|
|
874
|
+
slack:
|
|
875
|
+
botToken: $SLACK_BOT_TOKEN
|
|
876
|
+
signingSecret: $SLACK_SIGNING_SECRET
|
|
877
|
+
routing:
|
|
878
|
+
sessionKey: thread
|
|
879
|
+
heartbeat:
|
|
880
|
+
every: "2 hours"
|
|
881
|
+
instructions: tick
|
|
882
|
+
`),
|
|
883
|
+
).toThrow(SpecParseError);
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
test("heartbeat is optional", () => {
|
|
887
|
+
const spec = parseSpec(`
|
|
888
|
+
name: hello
|
|
889
|
+
target: channel
|
|
890
|
+
agent:
|
|
891
|
+
model: claude-sonnet-4-6
|
|
892
|
+
instructions: be helpful
|
|
893
|
+
channels:
|
|
894
|
+
slack:
|
|
895
|
+
botToken: $SLACK_BOT_TOKEN
|
|
896
|
+
signingSecret: $SLACK_SIGNING_SECRET
|
|
897
|
+
routing:
|
|
898
|
+
sessionKey: thread
|
|
899
|
+
`);
|
|
900
|
+
if (spec.target !== "channel") throw new Error("unexpected target");
|
|
901
|
+
expect(spec.heartbeat).toBeUndefined();
|
|
902
|
+
});
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
describe("parseSpec — compaction block (Section 17 + Pillar 2 curator)", () => {
|
|
906
|
+
test("accepts the curator opt-in + tuning knobs", () => {
|
|
907
|
+
const spec = parseSpec(`
|
|
908
|
+
name: hello
|
|
909
|
+
target: cli
|
|
910
|
+
agent:
|
|
911
|
+
model: claude-sonnet-4-6
|
|
912
|
+
instructions: be helpful
|
|
913
|
+
compaction:
|
|
914
|
+
model: claude-haiku-4
|
|
915
|
+
curate: true
|
|
916
|
+
dedupeThreshold: 0.88
|
|
917
|
+
relevanceTopK: 5
|
|
918
|
+
`);
|
|
919
|
+
if (spec.target !== "cli") throw new Error("unexpected target");
|
|
920
|
+
expect(spec.compaction).toEqual({
|
|
921
|
+
model: "claude-haiku-4",
|
|
922
|
+
curate: true,
|
|
923
|
+
dedupeThreshold: 0.88,
|
|
924
|
+
relevanceTopK: 5,
|
|
925
|
+
});
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
test("each curator field is independently optional", () => {
|
|
929
|
+
const spec = parseSpec(`
|
|
930
|
+
name: hello
|
|
931
|
+
target: cli
|
|
932
|
+
agent:
|
|
933
|
+
model: m
|
|
934
|
+
instructions: i
|
|
935
|
+
compaction:
|
|
936
|
+
curate: true
|
|
937
|
+
`);
|
|
938
|
+
if (spec.target !== "cli") throw new Error("unexpected target");
|
|
939
|
+
expect(spec.compaction).toEqual({ curate: true });
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
test("rejects dedupeThreshold > 1 (cosine outputs cap at 1)", () => {
|
|
943
|
+
expect(() =>
|
|
944
|
+
parseSpec(`
|
|
945
|
+
name: hello
|
|
946
|
+
target: cli
|
|
947
|
+
agent:
|
|
948
|
+
model: m
|
|
949
|
+
instructions: i
|
|
950
|
+
compaction:
|
|
951
|
+
dedupeThreshold: 1.5
|
|
952
|
+
`),
|
|
953
|
+
).toThrow(SpecParseError);
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
test("rejects dedupeThreshold <= 0", () => {
|
|
957
|
+
expect(() =>
|
|
958
|
+
parseSpec(`
|
|
959
|
+
name: hello
|
|
960
|
+
target: cli
|
|
961
|
+
agent:
|
|
962
|
+
model: m
|
|
963
|
+
instructions: i
|
|
964
|
+
compaction:
|
|
965
|
+
dedupeThreshold: 0
|
|
966
|
+
`),
|
|
967
|
+
).toThrow(SpecParseError);
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
test("rejects non-integer relevanceTopK", () => {
|
|
971
|
+
expect(() =>
|
|
972
|
+
parseSpec(`
|
|
973
|
+
name: hello
|
|
974
|
+
target: cli
|
|
975
|
+
agent:
|
|
976
|
+
model: m
|
|
977
|
+
instructions: i
|
|
978
|
+
compaction:
|
|
979
|
+
relevanceTopK: 3.5
|
|
980
|
+
`),
|
|
981
|
+
).toThrow(SpecParseError);
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
test("rejects relevanceTopK <= 0", () => {
|
|
985
|
+
expect(() =>
|
|
986
|
+
parseSpec(`
|
|
987
|
+
name: hello
|
|
988
|
+
target: cli
|
|
989
|
+
agent:
|
|
990
|
+
model: m
|
|
991
|
+
instructions: i
|
|
992
|
+
compaction:
|
|
993
|
+
relevanceTopK: 0
|
|
994
|
+
`),
|
|
995
|
+
).toThrow(SpecParseError);
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
test("rejects unknown keys inside the compaction block (strict)", () => {
|
|
999
|
+
expect(() =>
|
|
1000
|
+
parseSpec(`
|
|
1001
|
+
name: hello
|
|
1002
|
+
target: cli
|
|
1003
|
+
agent:
|
|
1004
|
+
model: m
|
|
1005
|
+
instructions: i
|
|
1006
|
+
compaction:
|
|
1007
|
+
enableMagic: true
|
|
1008
|
+
`),
|
|
1009
|
+
).toThrow(SpecParseError);
|
|
1010
|
+
});
|
|
1011
|
+
});
|