@studio-foundation/cli 0.3.0-beta.1 → 0.3.0-beta.6

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 (88) hide show
  1. package/package.json +9 -5
  2. package/ARCHITECTURE.md +0 -40
  3. package/src/commands/api.ts +0 -126
  4. package/src/commands/config.ts +0 -433
  5. package/src/commands/init.ts +0 -879
  6. package/src/commands/install.ts +0 -23
  7. package/src/commands/integrations.ts +0 -332
  8. package/src/commands/list.ts +0 -197
  9. package/src/commands/logs.ts +0 -119
  10. package/src/commands/ollama.ts +0 -168
  11. package/src/commands/project.ts +0 -167
  12. package/src/commands/registry/audit.ts +0 -87
  13. package/src/commands/registry/index.ts +0 -63
  14. package/src/commands/registry/install.ts +0 -222
  15. package/src/commands/registry/publish.ts +0 -112
  16. package/src/commands/registry/remove.ts +0 -89
  17. package/src/commands/registry/search.ts +0 -93
  18. package/src/commands/registry/sync.ts +0 -24
  19. package/src/commands/registry/update.ts +0 -63
  20. package/src/commands/replay.ts +0 -559
  21. package/src/commands/run.ts +0 -454
  22. package/src/commands/status.ts +0 -163
  23. package/src/commands/template/index.ts +0 -59
  24. package/src/commands/template/validate.ts +0 -175
  25. package/src/commands/templates.ts +0 -99
  26. package/src/commands/tools.ts +0 -227
  27. package/src/commands/users.ts +0 -127
  28. package/src/commands/validate.ts +0 -46
  29. package/src/config.ts +0 -101
  30. package/src/index.ts +0 -201
  31. package/src/models-cache.ts +0 -127
  32. package/src/output/file-changes.ts +0 -97
  33. package/src/output/formatter.ts +0 -85
  34. package/src/output/formatters.ts +0 -303
  35. package/src/output/logger.ts +0 -18
  36. package/src/output/parallel-progress.ts +0 -103
  37. package/src/output/progress.ts +0 -414
  38. package/src/provider-validator.ts +0 -109
  39. package/src/registry/cache.ts +0 -46
  40. package/src/registry/client.ts +0 -84
  41. package/src/registry/lockfile.ts +0 -68
  42. package/src/registry/resolver.ts +0 -113
  43. package/src/registry/types.ts +0 -66
  44. package/src/run-logger.ts +0 -66
  45. package/src/run-store-factory.ts +0 -30
  46. package/src/studio-dir.ts +0 -28
  47. package/src/utils/input-wizard.ts +0 -73
  48. package/src/utils/placeholders.ts +0 -10
  49. package/tests/__stubs__/studio-runner.ts +0 -104
  50. package/tests/commands/api.test.ts +0 -110
  51. package/tests/commands/config.test.ts +0 -221
  52. package/tests/commands/init.test.ts +0 -919
  53. package/tests/commands/install.test.ts +0 -52
  54. package/tests/commands/integrations.test.ts +0 -158
  55. package/tests/commands/ollama.test.ts +0 -139
  56. package/tests/commands/project.test.ts +0 -179
  57. package/tests/commands/registry/audit.test.ts +0 -56
  58. package/tests/commands/registry/install.test.ts +0 -200
  59. package/tests/commands/registry/publish.test.ts +0 -56
  60. package/tests/commands/registry/remove.test.ts +0 -103
  61. package/tests/commands/registry/search.test.ts +0 -44
  62. package/tests/commands/registry/sync.test.ts +0 -37
  63. package/tests/commands/registry/update.test.ts +0 -62
  64. package/tests/commands/replay.test.ts +0 -283
  65. package/tests/commands/template/validate.test.ts +0 -150
  66. package/tests/commands/templates.test.ts +0 -42
  67. package/tests/commands/tools.test.ts +0 -106
  68. package/tests/config.test.ts +0 -142
  69. package/tests/formatter.test.ts +0 -158
  70. package/tests/integration/sigint.test.ts +0 -188
  71. package/tests/models-cache.test.ts +0 -250
  72. package/tests/output/file-changes.test.ts +0 -178
  73. package/tests/output/formatters.test.ts +0 -448
  74. package/tests/output/progress-spinner.test.ts +0 -232
  75. package/tests/output/progress-timer.test.ts +0 -230
  76. package/tests/provider-validator.test.ts +0 -182
  77. package/tests/registry/cache.test.ts +0 -66
  78. package/tests/registry/client.test.ts +0 -70
  79. package/tests/registry/lockfile.test.ts +0 -87
  80. package/tests/registry/resolver.test.ts +0 -122
  81. package/tests/run-logger-events.test.ts +0 -326
  82. package/tests/run-logger.test.ts +0 -64
  83. package/tests/run-store-factory.test.ts +0 -51
  84. package/tests/studio-dir.test.ts +0 -31
  85. package/tests/utils/input-wizard.test.ts +0 -153
  86. package/tests/utils/placeholders.test.ts +0 -36
  87. package/tsconfig.json +0 -24
  88. package/vitest.config.ts +0 -20
@@ -1,283 +0,0 @@
1
- import { describe, it, expect, afterEach, beforeEach } from 'vitest';
2
- import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
3
- import { resolve } from 'node:path';
4
- import { findJsonlFile, mapJsonlLineToEvent, parseJsonlForResume, resolveStageFromPipeline } from '../../src/commands/replay.js';
5
- import type { PipelineDefinition } from '@studio-foundation/contracts';
6
-
7
- const TMP = resolve('/tmp', '.studio-replay-test');
8
- const RUNS_DIR = resolve(TMP, '.studio/runs');
9
-
10
- beforeEach(() => {
11
- mkdirSync(RUNS_DIR, { recursive: true });
12
- });
13
-
14
- afterEach(() => {
15
- rmSync(TMP, { recursive: true, force: true });
16
- });
17
-
18
- describe('findJsonlFile', () => {
19
- it('finds a JSONL file matching full 8-char run-id', () => {
20
- const filename = '2026-02-22T14h35m-feature-builder-abc12345.jsonl';
21
- writeFileSync(resolve(RUNS_DIR, filename), '');
22
- const result = findJsonlFile(RUNS_DIR, 'abc12345');
23
- expect(result).toBe(resolve(RUNS_DIR, filename));
24
- });
25
-
26
- it('finds a JSONL file matching partial run-id (4 chars)', () => {
27
- const filename = '2026-02-22T14h35m-feature-builder-abc12345.jsonl';
28
- writeFileSync(resolve(RUNS_DIR, filename), '');
29
- const result = findJsonlFile(RUNS_DIR, 'abc1');
30
- expect(result).toBe(resolve(RUNS_DIR, filename));
31
- });
32
-
33
- it('throws if no matching file found', () => {
34
- writeFileSync(resolve(RUNS_DIR, '2026-02-22T14h35m-pipe-zzz99999.jsonl'), '');
35
- expect(() => findJsonlFile(RUNS_DIR, 'abc1')).toThrow(/No run log found/);
36
- });
37
-
38
- it('throws if multiple files match an ambiguous prefix', () => {
39
- writeFileSync(resolve(RUNS_DIR, '2026-02-22T14h35m-pipe1-abc12345.jsonl'), '');
40
- writeFileSync(resolve(RUNS_DIR, '2026-02-22T15h00m-pipe2-abc12399.jsonl'), '');
41
- expect(() => findJsonlFile(RUNS_DIR, 'abc12')).toThrow(/Multiple/);
42
- });
43
-
44
- it('strips dashes from UUID-style run-id before matching', () => {
45
- const filename = '2026-02-22T14h35m-feature-builder-abc12345.jsonl';
46
- writeFileSync(resolve(RUNS_DIR, filename), '');
47
- const result = findJsonlFile(RUNS_DIR, 'abc1-2345');
48
- expect(result).toBe(resolve(RUNS_DIR, filename));
49
- });
50
- });
51
-
52
- describe('mapJsonlLineToEvent', () => {
53
- it('maps pipeline_start', () => {
54
- const line = { event: 'pipeline_start', pipeline: 'feature-builder', run_id: 'abc12345' };
55
- const result = mapJsonlLineToEvent(line);
56
- expect(result).toEqual({
57
- handler: 'onPipelineStart',
58
- payload: { pipeline_name: 'feature-builder', run_id: 'abc12345' },
59
- });
60
- });
61
-
62
- it('maps pipeline_complete', () => {
63
- const line = {
64
- event: 'pipeline_complete', pipeline_name: 'feature-builder', run_id: 'abc12345',
65
- status: 'success', duration_ms: 5000, total_tokens: 1000, total_tool_calls: 3,
66
- };
67
- const result = mapJsonlLineToEvent(line);
68
- expect(result).toEqual({
69
- handler: 'onPipelineComplete',
70
- payload: {
71
- pipeline_name: 'feature-builder', run_id: 'abc12345',
72
- status: 'success', duration_ms: 5000, total_tokens: 1000, total_tool_calls: 3,
73
- },
74
- });
75
- });
76
-
77
- it('maps stage_start (stage → stage_name)', () => {
78
- const line = { event: 'stage_start', stage: 'code-generation', stage_index: 0, total_stages: 3 };
79
- const result = mapJsonlLineToEvent(line);
80
- expect(result).toEqual({
81
- handler: 'onStageStart',
82
- payload: { stage_name: 'code-generation', stage_index: 0, total_stages: 3 },
83
- });
84
- });
85
-
86
- it('maps stage_complete with token remapping', () => {
87
- const line = {
88
- event: 'stage_complete', stage: 'code-generation', status: 'success',
89
- stage_index: 0, total_stages: 3,
90
- attempts: 1, duration_ms: 2000,
91
- tokens: { prompt: 500, completion: 200, total: 700 },
92
- tool_calls: [{ name: 'repo_manager-write_file', arguments_summary: 'path=src/foo.ts' }],
93
- output: { summary: 'done' },
94
- };
95
- const result = mapJsonlLineToEvent(line);
96
- expect(result?.payload).toMatchObject({
97
- stage_name: 'code-generation',
98
- token_usage: { prompt_tokens: 500, completion_tokens: 200, total_tokens: 700 },
99
- });
100
- });
101
-
102
- it('maps stage_retry', () => {
103
- const line = {
104
- event: 'stage_retry', stage: 'code-generation', attempt: 2, max_attempts: 3,
105
- failures: ['missing field: summary'], tool_calls_count: 1,
106
- };
107
- const result = mapJsonlLineToEvent(line);
108
- expect(result).toEqual({
109
- handler: 'onTaskRetry',
110
- payload: {
111
- stage: 'code-generation', attempt: 2, max_attempts: 3,
112
- failures: ['missing field: summary'], tool_calls_count: 1,
113
- },
114
- });
115
- });
116
-
117
- it('maps group events (group → group_name)', () => {
118
- const line = { event: 'group_start', group: 'impl-review', max_iterations: 3 };
119
- const result = mapJsonlLineToEvent(line);
120
- expect(result).toEqual({
121
- handler: 'onGroupStart',
122
- payload: { group_name: 'impl-review', max_iterations: 3 },
123
- });
124
- });
125
-
126
- it('maps tool_call_start', () => {
127
- const line = { event: 'tool_call_start', tool: 'repo_manager-write_file', params: { path: 'src/foo.ts' } };
128
- const result = mapJsonlLineToEvent(line);
129
- expect(result).toEqual({
130
- handler: 'onToolCallStart',
131
- payload: { tool: 'repo_manager-write_file', params: { path: 'src/foo.ts' }, timestamp: 0 },
132
- });
133
- });
134
-
135
- it('maps tool_call_complete', () => {
136
- const line = { event: 'tool_call_complete', tool: 'repo_manager-write_file', result: { written: true }, duration_ms: 50 };
137
- const result = mapJsonlLineToEvent(line);
138
- expect(result).toEqual({
139
- handler: 'onToolCallComplete',
140
- payload: { tool: 'repo_manager-write_file', result: { written: true }, duration_ms: 50, timestamp: 0 },
141
- });
142
- });
143
-
144
- it('returns null for unknown event types', () => {
145
- const line = { event: 'unknown_thing', data: 123 };
146
- const result = mapJsonlLineToEvent(line);
147
- expect(result).toBeNull();
148
- });
149
- });
150
-
151
- describe('parseJsonlForResume', () => {
152
- it('extracts input and stage outputs from JSONL', () => {
153
- const lines = [
154
- JSON.stringify({ event: 'pipeline_start', pipeline: 'my-pipe', run_id: 'abc12345', input: { x: 1 } }),
155
- JSON.stringify({ event: 'stage_complete', stage: 'stage-a', run_id: 'abc12345', status: 'success', attempts: 1, duration_ms: 100, output: { result: 'a-result' }, tool_calls: [] }),
156
- JSON.stringify({ event: 'stage_complete', stage: 'stage-b', run_id: 'abc12345', status: 'success', attempts: 1, duration_ms: 200, output: { result: 'b-result' }, tool_calls: [{ id: '1', name: 'repo_manager-read_file', arguments: { path: 'foo.ts' } }] }),
157
- ].join('\n');
158
-
159
- const result = parseJsonlForResume(lines);
160
-
161
- expect(result.pipelineInput).toEqual({ x: 1 });
162
- expect(result.stageOutputs.get('stage-a')).toEqual({ result: 'a-result' });
163
- expect(result.stageOutputs.get('stage-b')).toEqual({ result: 'b-result' });
164
- expect(result.stageToolResults.get('stage-b')).toHaveLength(1);
165
- expect(result.stageToolResults.get('stage-b')![0]!.name).toBe('repo_manager-read_file');
166
- });
167
-
168
- it('returns empty maps if no stage_complete events', () => {
169
- const lines = JSON.stringify({ event: 'pipeline_start', pipeline: 'x', run_id: 'abc', input: {} });
170
- const result = parseJsonlForResume(lines);
171
- expect(result.stageOutputs.size).toBe(0);
172
- expect(result.stageToolResults.size).toBe(0);
173
- });
174
-
175
- it('skips stages with no output', () => {
176
- const lines = JSON.stringify({ event: 'stage_complete', stage: 'stage-a', run_id: 'abc', status: 'success', attempts: 1, duration_ms: 100 });
177
- const result = parseJsonlForResume(lines);
178
- expect(result.stageOutputs.has('stage-a')).toBe(false);
179
- });
180
- });
181
-
182
- describe('resolveStageFromPipeline', () => {
183
- const pipeline: PipelineDefinition = {
184
- name: 'test',
185
- description: 'test',
186
- version: 1,
187
- stages: [
188
- { name: 'stage-a', executor: 'script', script: 'x.py', runtime: 'shell' },
189
- {
190
- group: 'my-group',
191
- max_iterations: 3,
192
- stages: [
193
- { name: 'stage-b', executor: 'script', script: 'x.py', runtime: 'shell' },
194
- { name: 'stage-c', executor: 'script', script: 'x.py', runtime: 'shell' },
195
- ],
196
- },
197
- ],
198
- };
199
-
200
- it('resolves a stage name by exact match', () => {
201
- expect(resolveStageFromPipeline('stage-b', pipeline)).toBe('stage-b');
202
- });
203
-
204
- it('resolves a stage by 0-based leaf index', () => {
205
- // leaf order: stage-a(0), stage-b(1), stage-c(2)
206
- expect(resolveStageFromPipeline('0', pipeline)).toBe('stage-a');
207
- expect(resolveStageFromPipeline('1', pipeline)).toBe('stage-b');
208
- expect(resolveStageFromPipeline('2', pipeline)).toBe('stage-c');
209
- });
210
-
211
- it('throws if name not found', () => {
212
- expect(() => resolveStageFromPipeline('nonexistent', pipeline)).toThrow(/not found/i);
213
- });
214
-
215
- it('throws if index out of bounds', () => {
216
- expect(() => resolveStageFromPipeline('5', pipeline)).toThrow(/out of bounds/i);
217
- });
218
-
219
- it('index 0 returns the first stage name', () => {
220
- expect(resolveStageFromPipeline('0', pipeline)).toBe('stage-a');
221
- });
222
- });
223
-
224
- describe('replay integration — full JSONL file', () => {
225
- it('maps a complete pipeline run through all events without errors', () => {
226
- const jsonlLines = [
227
- { event: 'pipeline_start', pipeline: 'test-pipe', run_id: 'aabb1122', ts: '2026-02-22T14:00:00Z' },
228
- { event: 'stage_start', stage: 'analysis', stage_index: 0, total_stages: 2, ts: '2026-02-22T14:00:01Z' },
229
- { event: 'tool_call_start', tool: 'repo_manager-read_file', params: { path: 'README.md' }, ts: '2026-02-22T14:00:02Z' },
230
- { event: 'tool_call_complete', tool: 'repo_manager-read_file', result: { content: '# Hello' }, duration_ms: 100, ts: '2026-02-22T14:00:02Z' },
231
- { event: 'stage_complete', stage: 'analysis', stage_index: 0, total_stages: 2, status: 'success', attempts: 1, duration_ms: 2000, tokens: { prompt: 500, completion: 200, total: 700 }, output: { summary: 'analyzed' }, ts: '2026-02-22T14:00:03Z' },
232
- { event: 'stage_start', stage: 'code-generation', stage_index: 1, total_stages: 2, ts: '2026-02-22T14:00:04Z' },
233
- { event: 'stage_retry', stage: 'code-generation', attempt: 2, max_attempts: 3, failures: ['missing field'], ts: '2026-02-22T14:00:05Z' },
234
- { event: 'stage_complete', stage: 'code-generation', stage_index: 1, total_stages: 2, status: 'success', attempts: 2, duration_ms: 4000, tokens: { prompt: 800, completion: 400, total: 1200 }, output: { summary: 'generated' }, ts: '2026-02-22T14:00:08Z' },
235
- { event: 'pipeline_complete', pipeline_name: 'test-pipe', run_id: 'aabb1122', status: 'success', duration_ms: 8000, total_tokens: 1900, total_tool_calls: 1, ts: '2026-02-22T14:00:08Z' },
236
- ];
237
-
238
- const mapped = jsonlLines.map((line) => mapJsonlLineToEvent(line));
239
- expect(mapped.every((m) => m !== null)).toBe(true);
240
- expect(mapped.map((m) => m!.handler)).toEqual([
241
- 'onPipelineStart',
242
- 'onStageStart',
243
- 'onToolCallStart',
244
- 'onToolCallComplete',
245
- 'onStageComplete',
246
- 'onStageStart',
247
- 'onTaskRetry',
248
- 'onStageComplete',
249
- 'onPipelineComplete',
250
- ]);
251
- });
252
-
253
- it('maps a rejected group run', () => {
254
- const jsonlLines = [
255
- { event: 'pipeline_start', pipeline: 'test-pipe', run_id: 'ccdd3344', ts: '2026-02-22T14:00:00Z' },
256
- { event: 'group_start', group: 'impl-review', max_iterations: 3, ts: '2026-02-22T14:00:01Z' },
257
- { event: 'group_iteration', group: 'impl-review', iteration: 1, max_iterations: 3, ts: '2026-02-22T14:00:02Z' },
258
- { event: 'stage_start', stage: 'code-gen', stage_index: 0, total_stages: 2, ts: '2026-02-22T14:00:03Z' },
259
- { event: 'stage_complete', stage: 'code-gen', stage_index: 0, total_stages: 2, status: 'success', attempts: 1, duration_ms: 2000, ts: '2026-02-22T14:00:05Z' },
260
- { event: 'stage_start', stage: 'qa-review', stage_index: 1, total_stages: 2, ts: '2026-02-22T14:00:06Z' },
261
- { event: 'stage_complete', stage: 'qa-review', stage_index: 1, total_stages: 2, status: 'rejected', attempts: 1, duration_ms: 1500, rejection_reason: 'code incomplete', rejection_details: ['missing error handling'], ts: '2026-02-22T14:00:07Z' },
262
- { event: 'group_feedback', group: 'impl-review', iteration: 1, rejection_reason: 'code incomplete', rejection_details: ['missing error handling'], ts: '2026-02-22T14:00:07Z' },
263
- { event: 'group_complete', group: 'impl-review', iterations: 1, status: 'rejected', ts: '2026-02-22T14:00:07Z' },
264
- { event: 'pipeline_complete', pipeline_name: 'test-pipe', run_id: 'ccdd3344', status: 'rejected', duration_ms: 7000, total_tokens: 2000, total_tool_calls: 0, ts: '2026-02-22T14:00:07Z' },
265
- ];
266
-
267
- const mapped = jsonlLines.map((line) => mapJsonlLineToEvent(line));
268
- expect(mapped.every((m) => m !== null)).toBe(true);
269
-
270
- const stageComplete = mapped.find((m) => m!.handler === 'onStageComplete' && m!.payload.status === 'rejected');
271
- expect(stageComplete!.payload.rejection_reason).toBe('code incomplete');
272
- expect(stageComplete!.payload.rejection_details).toEqual(['missing error handling']);
273
- });
274
-
275
- it('skips corrupt JSONL lines gracefully', () => {
276
- const validLine = { event: 'pipeline_start', pipeline: 'test', run_id: 'xxxx' };
277
- const mapped = mapJsonlLineToEvent(validLine);
278
- expect(mapped).not.toBeNull();
279
-
280
- const unknownLine = { event: 'totally_unknown', data: 123 };
281
- expect(mapJsonlLineToEvent(unknownLine)).toBeNull();
282
- });
283
- });
@@ -1,150 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import { mkdir, rm, writeFile } from 'node:fs/promises';
3
- import { resolve, join } from 'node:path';
4
- import { validateTemplateDir } from '../../../src/commands/template/validate.js';
5
-
6
- // Use /tmp — never a subdirectory of the Studio repo
7
- const TMP = resolve('/tmp', '.studio-template-validate-test');
8
-
9
- async function makeTemplate(overrides: {
10
- metadata?: Record<string, unknown> | null;
11
- pipelines?: Record<string, string>;
12
- agents?: Record<string, string>;
13
- contracts?: Record<string, string>;
14
- } = {}): Promise<string> {
15
- const dir = join(TMP, String(Date.now()));
16
- await mkdir(join(dir, 'pipelines'), { recursive: true });
17
- await mkdir(join(dir, 'agents'), { recursive: true });
18
- await mkdir(join(dir, 'contracts'), { recursive: true });
19
- await mkdir(join(dir, 'tools'), { recursive: true });
20
- await mkdir(join(dir, 'inputs'), { recursive: true });
21
-
22
- // metadata.json
23
- if (overrides.metadata !== null) {
24
- const meta = overrides.metadata ?? { name: 'test', version: '1.0.0', description: 'Test template' };
25
- await writeFile(join(dir, 'metadata.json'), JSON.stringify(meta));
26
- }
27
-
28
- // pipelines — default: 2 valid pipelines
29
- const pipelines = overrides.pipelines ?? {
30
- 'pipe-one': 'name: pipe-one\nstages:\n - name: s1\n kind: analysis\n agent: analyst\n contract: output\n',
31
- 'pipe-two': 'name: pipe-two\nstages:\n - name: s2\n kind: analysis\n agent: analyst\n contract: output\n',
32
- };
33
- for (const [name, content] of Object.entries(pipelines)) {
34
- await writeFile(join(dir, 'pipelines', `${name}.pipeline.yaml`), content);
35
- }
36
-
37
- // agents — default: 1 valid agent
38
- const agents = overrides.agents ?? {
39
- analyst: 'name: analyst\nprovider: anthropic\nmodel: claude-haiku-4-20250514\n',
40
- };
41
- for (const [name, content] of Object.entries(agents)) {
42
- await writeFile(join(dir, 'agents', `${name}.agent.yaml`), content);
43
- }
44
-
45
- // contracts — default: 1 valid contract
46
- const contracts = overrides.contracts ?? {
47
- output: 'name: output\nversion: 1\nschema:\n required_fields:\n - summary\n',
48
- };
49
- for (const [name, content] of Object.entries(contracts)) {
50
- await writeFile(join(dir, 'contracts', `${name}.contract.yaml`), content);
51
- }
52
-
53
- return dir;
54
- }
55
-
56
- beforeEach(async () => { await mkdir(TMP, { recursive: true }); });
57
- afterEach(async () => { await rm(TMP, { recursive: true, force: true }); });
58
-
59
- describe('validateTemplateDir — Level 1: Structural', () => {
60
- it('returns valid for a well-formed minimal template', async () => {
61
- const dir = await makeTemplate();
62
- const result = await validateTemplateDir(dir);
63
- expect(result.valid).toBe(true);
64
- expect(result.structuralErrors).toHaveLength(0);
65
- });
66
-
67
- it('errors when directory does not exist', async () => {
68
- const result = await validateTemplateDir('/tmp/does-not-exist-xyz');
69
- expect(result.valid).toBe(false);
70
- expect(result.structuralErrors.some(e => e.includes('not found') || e.includes('does not exist'))).toBe(true);
71
- });
72
-
73
- it('errors when metadata.json is missing', async () => {
74
- const dir = await makeTemplate({ metadata: null });
75
- const result = await validateTemplateDir(dir);
76
- expect(result.valid).toBe(false);
77
- expect(result.structuralErrors.some(e => e.includes('metadata.json'))).toBe(true);
78
- });
79
-
80
- it('errors when metadata.json is missing required field', async () => {
81
- const dir = await makeTemplate({ metadata: { name: 'test' } });
82
- const result = await validateTemplateDir(dir);
83
- expect(result.valid).toBe(false);
84
- expect(result.structuralErrors.some(e => e.includes('version') || e.includes('description'))).toBe(true);
85
- });
86
-
87
- it('accepts a template with a single pipeline', async () => {
88
- const dir = await makeTemplate({
89
- pipelines: { 'only-one': 'name: only-one\nstages:\n - name: s1\n kind: analysis\n agent: analyst\n contract: output\n' },
90
- });
91
- const result = await validateTemplateDir(dir);
92
- expect(result.valid).toBe(true);
93
- });
94
-
95
- it('accepts a template with no pipelines or agents (e.g. blank template)', async () => {
96
- const dir = await makeTemplate({ pipelines: {}, agents: {}, contracts: {} });
97
- const result = await validateTemplateDir(dir);
98
- expect(result.valid).toBe(true);
99
- });
100
- });
101
-
102
- describe('validateTemplateDir — Level 2: Semantic', () => {
103
- it('errors on invalid YAML in a pipeline file', async () => {
104
- const dir = await makeTemplate({
105
- pipelines: {
106
- 'pipe-one': 'name: pipe-one\nstages:\n - name: s1\n kind: analysis\n agent: analyst\n contract: output\n',
107
- 'pipe-bad': ': invalid: yaml: [\n',
108
- },
109
- });
110
- const result = await validateTemplateDir(dir);
111
- expect(result.valid).toBe(false);
112
- expect(result.semanticErrors.some(e => e.includes('pipe-bad'))).toBe(true);
113
- });
114
-
115
- it('errors when pipeline stage references a contract that does not exist', async () => {
116
- const dir = await makeTemplate({
117
- pipelines: {
118
- 'pipe-one': 'name: pipe-one\nstages:\n - name: s1\n kind: analysis\n agent: analyst\n contract: output\n',
119
- 'pipe-two': 'name: pipe-two\nstages:\n - name: s2\n kind: analysis\n agent: analyst\n contract: missing-contract\n',
120
- },
121
- });
122
- const result = await validateTemplateDir(dir);
123
- expect(result.valid).toBe(false);
124
- expect(result.semanticErrors.some(e => e.includes('missing-contract'))).toBe(true);
125
- });
126
-
127
- it('errors when pipeline stage references an agent that does not exist', async () => {
128
- const dir = await makeTemplate({
129
- pipelines: {
130
- 'pipe-one': 'name: pipe-one\nstages:\n - name: s1\n kind: analysis\n agent: analyst\n contract: output\n',
131
- 'pipe-two': 'name: pipe-two\nstages:\n - name: s2\n kind: analysis\n agent: missing-agent\n contract: output\n',
132
- },
133
- });
134
- const result = await validateTemplateDir(dir);
135
- expect(result.valid).toBe(false);
136
- expect(result.semanticErrors.some(e => e.includes('missing-agent'))).toBe(true);
137
- });
138
-
139
- it('validates stages inside groups', async () => {
140
- const dir = await makeTemplate({
141
- pipelines: {
142
- 'pipe-one': 'name: pipe-one\nstages:\n - name: s1\n kind: analysis\n agent: analyst\n contract: output\n',
143
- 'pipe-group': `name: pipe-group\nstages:\n - group: my-group\n max_iterations: 3\n stages:\n - name: s1\n kind: analysis\n agent: analyst\n contract: output\n - name: s2\n kind: qa\n agent: analyst\n contract: ghost-contract\n`,
144
- },
145
- });
146
- const result = await validateTemplateDir(dir);
147
- expect(result.valid).toBe(false);
148
- expect(result.semanticErrors.some(e => e.includes('ghost-contract'))).toBe(true);
149
- });
150
- });
@@ -1,42 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import { listTemplates } from '../../src/commands/templates.js';
3
-
4
- // Mock registry so tests only exercise local (bundled) templates
5
- vi.mock('../../src/commands/registry/sync.js', () => ({
6
- syncRegistry: vi.fn().mockResolvedValue(undefined),
7
- }));
8
- vi.mock('../../src/registry/cache.js', () => {
9
- class RegistryCache {
10
- read() { return Promise.resolve(null); }
11
- write() { return Promise.resolve(undefined); }
12
- isFresh() { return Promise.resolve(false); }
13
- }
14
- return { RegistryCache };
15
- });
16
-
17
- describe('listTemplates', () => {
18
- it('returns blank as the only built-in template (others are in the registry)', async () => {
19
- const templates = await listTemplates();
20
- const names = templates.map((t) => t.name);
21
- expect(names).toContain('blank');
22
- expect(names).not.toContain('software');
23
- expect(names).not.toContain('software-full');
24
- expect(names).not.toContain('content');
25
- expect(names).not.toContain('document-analysis');
26
- });
27
-
28
- it('each template has name, version, description', async () => {
29
- const templates = await listTemplates();
30
- for (const t of templates) {
31
- expect(t.name).toBeTruthy();
32
- expect(t.version).toBeTruthy();
33
- expect(t.description).toBeTruthy();
34
- }
35
- });
36
-
37
- it('templates are sorted alphabetically', async () => {
38
- const templates = await listTemplates();
39
- const names = templates.map((t) => t.name);
40
- expect(names).toEqual([...names].sort());
41
- });
42
- });
@@ -1,106 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import { mkdir, rm, writeFile, access } from 'node:fs/promises';
3
- import { resolve } from 'node:path';
4
- import { listTools, getToolsDir, listAvailableTools, toolsAddDirect } from '../../src/commands/tools.js';
5
-
6
- const TMP = resolve(import.meta.dirname, '.tmp-tools-test');
7
- const STUDIO_DIR = resolve(TMP, '.studio');
8
-
9
- beforeEach(async () => { await mkdir(STUDIO_DIR, { recursive: true }); });
10
- afterEach(async () => { await rm(TMP, { recursive: true, force: true }); });
11
-
12
- describe('listTools', () => {
13
- it('returns empty array when no tools dir', async () => {
14
- const result = await listTools(resolve(STUDIO_DIR, 'tools'));
15
- expect(result).toEqual([]);
16
- });
17
-
18
- it('finds .tool.yaml files', async () => {
19
- const toolsDir = resolve(STUDIO_DIR, 'tools');
20
- await mkdir(toolsDir, { recursive: true });
21
- await writeFile(resolve(toolsDir, 'git.tool.yaml'), 'name: git\n');
22
- await writeFile(resolve(toolsDir, 'other.txt'), 'ignored\n');
23
- const result = await listTools(toolsDir);
24
- expect(result).toEqual(['git']);
25
- });
26
- });
27
-
28
- describe('getToolsDir', () => {
29
- it('resolves tools dir from studioDir', () => {
30
- const dir = getToolsDir(STUDIO_DIR);
31
- expect(dir).toBe(resolve(STUDIO_DIR, 'tools'));
32
- });
33
- });
34
-
35
- const TOOLS_TMP = resolve('/tmp', '.studio-tools-add-test-' + Math.floor(Date.now() / 1000));
36
- const TOOLS_STUDIO_DIR = resolve(TOOLS_TMP, '.studio');
37
-
38
- async function fileExists(p: string): Promise<boolean> {
39
- try { await access(p); return true; } catch { return false; }
40
- }
41
-
42
- describe('toolsAddDirect', () => {
43
- beforeEach(async () => {
44
- await mkdir(TOOLS_STUDIO_DIR, { recursive: true });
45
- });
46
- afterEach(async () => {
47
- await rm(TOOLS_TMP, { recursive: true, force: true });
48
- });
49
-
50
- it('installs a single valid tool', async () => {
51
- const result = await toolsAddDirect(TOOLS_STUDIO_DIR, ['git']);
52
- expect(result.installed).toEqual(['git']);
53
- expect(result.skipped).toEqual([]);
54
- const toolPath = resolve(TOOLS_STUDIO_DIR, 'tools', 'git.tool.yaml');
55
- expect(await fileExists(toolPath)).toBe(true);
56
- });
57
-
58
- it('installs multiple tools in one call', async () => {
59
- const result = await toolsAddDirect(TOOLS_STUDIO_DIR, ['git', 'shell']);
60
- expect(result.installed).toContain('git');
61
- expect(result.installed).toContain('shell');
62
- expect(result.skipped).toEqual([]);
63
- });
64
-
65
- it('skips already-installed tool and returns it in skipped list', async () => {
66
- await toolsAddDirect(TOOLS_STUDIO_DIR, ['git']);
67
- const result = await toolsAddDirect(TOOLS_STUDIO_DIR, ['git', 'shell']);
68
- expect(result.installed).toEqual(['shell']);
69
- expect(result.skipped).toEqual(['git']);
70
- });
71
-
72
- it('throws on unknown tool name', async () => {
73
- await expect(toolsAddDirect(TOOLS_STUDIO_DIR, ['nonexistent'])).rejects.toThrow("Unknown tool 'nonexistent'");
74
- });
75
-
76
- it('creates tools dir if it does not exist', async () => {
77
- const result = await toolsAddDirect(TOOLS_STUDIO_DIR, ['search']);
78
- expect(result.installed).toEqual(['search']);
79
- expect(await fileExists(resolve(TOOLS_STUDIO_DIR, 'tools', 'search.tool.yaml'))).toBe(true);
80
- });
81
- });
82
-
83
- describe('listAvailableTools', () => {
84
- it('returns all available tool templates', async () => {
85
- const tools = await listAvailableTools();
86
- const names = tools.map((t) => t.name);
87
- expect(names).toContain('git');
88
- expect(names).toContain('repo-manager');
89
- expect(names).toContain('shell');
90
- expect(names).toContain('search');
91
- });
92
-
93
- it('returns description for each tool', async () => {
94
- const tools = await listAvailableTools();
95
- for (const t of tools) {
96
- expect(typeof t.description).toBe('string');
97
- expect(t.description.length).toBeGreaterThan(0);
98
- }
99
- });
100
-
101
- it('returns tools sorted by name', async () => {
102
- const tools = await listAvailableTools();
103
- const names = tools.map((t) => t.name);
104
- expect(names).toEqual([...names].sort());
105
- });
106
- });