@output.ai/cli 0.0.5 → 0.0.7

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 (49) hide show
  1. package/README.md +2 -3
  2. package/dist/api/http_client.js +1 -1
  3. package/dist/commands/agents/init.d.ts +2 -2
  4. package/dist/commands/agents/init.js +2 -2
  5. package/dist/commands/agents/init.spec.js +2 -2
  6. package/dist/commands/workflow/generate.d.ts +6 -5
  7. package/dist/commands/workflow/generate.js +25 -6
  8. package/dist/commands/workflow/generate.spec.js +2 -2
  9. package/dist/commands/workflow/list.d.ts +4 -4
  10. package/dist/commands/workflow/list.js +3 -3
  11. package/dist/commands/workflow/output.d.ts +2 -2
  12. package/dist/commands/workflow/output.js +4 -4
  13. package/dist/commands/workflow/plan.d.ts +2 -2
  14. package/dist/commands/workflow/plan.js +4 -3
  15. package/dist/commands/workflow/plan.spec.js +6 -4
  16. package/dist/commands/workflow/run.d.ts +4 -4
  17. package/dist/commands/workflow/run.js +5 -5
  18. package/dist/commands/workflow/start.d.ts +3 -3
  19. package/dist/commands/workflow/start.js +3 -3
  20. package/dist/commands/workflow/status.d.ts +2 -2
  21. package/dist/commands/workflow/status.js +4 -4
  22. package/dist/commands/workflow/stop.d.ts +1 -1
  23. package/dist/commands/workflow/stop.js +2 -2
  24. package/dist/services/claude_client.d.ts +18 -1
  25. package/dist/services/claude_client.js +108 -31
  26. package/dist/services/coding_agents.d.ts +7 -0
  27. package/dist/services/coding_agents.js +67 -10
  28. package/dist/services/coding_agents.spec.js +155 -14
  29. package/dist/services/template_processor.d.ts +1 -1
  30. package/dist/services/template_processor.js +1 -1
  31. package/dist/services/workflow_builder.d.ts +16 -0
  32. package/dist/services/workflow_builder.js +85 -0
  33. package/dist/services/workflow_builder.spec.d.ts +1 -0
  34. package/dist/services/workflow_builder.spec.js +165 -0
  35. package/dist/services/workflow_generator.d.ts +1 -1
  36. package/dist/services/workflow_generator.js +4 -4
  37. package/dist/services/workflow_planner.d.ts +0 -5
  38. package/dist/services/workflow_planner.js +2 -39
  39. package/dist/services/workflow_planner.spec.js +2 -77
  40. package/dist/templates/agent_instructions/commands/build_workflow.md.template +246 -0
  41. package/dist/templates/workflow/steps.ts.template +25 -54
  42. package/dist/templates/workflow/workflow.ts.template +23 -49
  43. package/dist/types/domain.d.ts +20 -0
  44. package/dist/types/domain.js +4 -0
  45. package/dist/utils/error_handler.js +1 -1
  46. package/dist/utils/paths.d.ts +1 -1
  47. package/dist/utils/paths.js +1 -1
  48. package/dist/utils/validation.js +1 -1
  49. package/package.json +7 -3
@@ -7,9 +7,10 @@ import { access } from 'node:fs/promises';
7
7
  import path from 'node:path';
8
8
  import { join } from 'node:path';
9
9
  import { ux } from '@oclif/core';
10
- import { AGENT_CONFIG_DIR } from '../config.js';
11
- import { getTemplateDir } from '../utils/paths.js';
12
- import { processTemplate } from '../utils/template.js';
10
+ import { confirm } from '@inquirer/prompts';
11
+ import { AGENT_CONFIG_DIR } from '#config.js';
12
+ import { getTemplateDir } from '#utils/paths.js';
13
+ import { processTemplate } from '#utils/template.js';
13
14
  /**
14
15
  * Agent configuration mappings for different providers
15
16
  */
@@ -33,6 +34,11 @@ export const AGENT_CONFIGS = {
33
34
  from: 'commands/plan_workflow.md.template',
34
35
  to: `${AGENT_CONFIG_DIR}/commands/plan_workflow.md`
35
36
  },
37
+ {
38
+ type: 'template',
39
+ from: 'commands/build_workflow.md.template',
40
+ to: `${AGENT_CONFIG_DIR}/commands/build_workflow.md`
41
+ },
36
42
  {
37
43
  type: 'template',
38
44
  from: 'meta/pre_flight.md.template',
@@ -63,6 +69,11 @@ export const AGENT_CONFIGS = {
63
69
  type: 'symlink',
64
70
  from: `${AGENT_CONFIG_DIR}/commands/plan_workflow.md`,
65
71
  to: '.claude/commands/plan_workflow.md'
72
+ },
73
+ {
74
+ type: 'symlink',
75
+ from: `${AGENT_CONFIG_DIR}/commands/build_workflow.md`,
76
+ to: '.claude/commands/build_workflow.md'
66
77
  }
67
78
  ]
68
79
  }
@@ -100,6 +111,15 @@ async function fileExists(filePath) {
100
111
  return false;
101
112
  }
102
113
  }
114
+ async function findMissingFiles(files, projectRoot) {
115
+ const checks = await Promise.all(files.map(async (file) => ({
116
+ file,
117
+ missing: !await fileExists(join(projectRoot, file))
118
+ })));
119
+ return checks
120
+ .filter(check => check.missing)
121
+ .map(check => check.file);
122
+ }
103
123
  export async function checkAgentStructure(projectRoot) {
104
124
  const requiredFiles = getRequiredFiles();
105
125
  const dirExists = await checkAgentConfigDirExists(projectRoot);
@@ -110,13 +130,7 @@ export async function checkAgentStructure(projectRoot) {
110
130
  isComplete: false
111
131
  };
112
132
  }
113
- const missingChecks = await Promise.all(requiredFiles.map(async (file) => ({
114
- file,
115
- exists: await fileExists(join(projectRoot, file))
116
- })));
117
- const missingFiles = missingChecks
118
- .filter(check => !check.exists)
119
- .map(check => check.file);
133
+ const missingFiles = await findMissingFiles(requiredFiles, projectRoot);
120
134
  return {
121
135
  dirExists: true,
122
136
  missingFiles,
@@ -228,3 +242,46 @@ export async function initializeAgentConfig(options) {
228
242
  await processMappings(AGENT_CONFIGS.outputai, variables, force, projectRoot);
229
243
  await processMappings(AGENT_CONFIGS[agentProvider], variables, force, projectRoot);
230
244
  }
245
+ function formatMissingFilesList(files) {
246
+ return files.map(f => ` • ${f}`).join('\n');
247
+ }
248
+ async function promptForReinitialize() {
249
+ return confirm({
250
+ message: 'Would you like to run "agents init --force" to recreate missing files?',
251
+ default: true
252
+ });
253
+ }
254
+ async function initializeAgentStructure(projectRoot, force) {
255
+ await initializeAgentConfig({
256
+ projectRoot,
257
+ force,
258
+ agentProvider: 'claude-code'
259
+ });
260
+ }
261
+ function createIncompleteConfigError(missingFiles) {
262
+ return new Error(`Agent configuration incomplete. Missing files:\n${missingFiles.join('\n')}\n\n` +
263
+ 'Run "output-cli agents init --force" to recreate them.');
264
+ }
265
+ /**
266
+ * Ensure .outputai directory structure exists by invoking agents init if needed
267
+ * Displays warnings for missing files and prompts for reinitialization
268
+ * @param projectRoot - Root directory of the project
269
+ * @throws Error if user declines to initialize or if initialization fails
270
+ */
271
+ export async function ensureOutputAIStructure(projectRoot) {
272
+ const structureCheck = await checkAgentStructure(projectRoot);
273
+ if (structureCheck.isComplete) {
274
+ return;
275
+ }
276
+ if (!structureCheck.dirExists) {
277
+ await initializeAgentStructure(projectRoot, false);
278
+ return;
279
+ }
280
+ const missingList = formatMissingFilesList(structureCheck.missingFiles);
281
+ ux.warn(`\n⚠️ Agent configuration is incomplete. Missing files:\n${missingList}\n`);
282
+ const shouldReinit = await promptForReinitialize();
283
+ if (!shouldReinit) {
284
+ throw createIncompleteConfigError(structureCheck.missingFiles);
285
+ }
286
+ await initializeAgentStructure(projectRoot, true);
287
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeEach, vi } from 'vitest';
2
- import { getRequiredFiles, checkAgentConfigDirExists, checkAgentStructure, getAgentConfigDir, prepareTemplateVariables, initializeAgentConfig, AGENT_CONFIGS } from './coding_agents.js';
2
+ import { getRequiredFiles, checkAgentConfigDirExists, checkAgentStructure, getAgentConfigDir, prepareTemplateVariables, initializeAgentConfig, ensureOutputAIStructure, AGENT_CONFIGS } from './coding_agents.js';
3
3
  import { access } from 'node:fs/promises';
4
4
  import fs from 'node:fs/promises';
5
5
  vi.mock('node:fs/promises');
@@ -9,6 +9,16 @@ vi.mock('../utils/paths.js', () => ({
9
9
  vi.mock('../utils/template.js', () => ({
10
10
  processTemplate: vi.fn().mockImplementation((content) => content)
11
11
  }));
12
+ vi.mock('@inquirer/prompts', () => ({
13
+ confirm: vi.fn()
14
+ }));
15
+ vi.mock('@oclif/core', () => ({
16
+ ux: {
17
+ warn: vi.fn(),
18
+ stdout: vi.fn(),
19
+ colorize: vi.fn().mockImplementation((_color, text) => text)
20
+ }
21
+ }));
12
22
  describe('coding_agents service', () => {
13
23
  beforeEach(() => {
14
24
  vi.clearAllMocks();
@@ -28,11 +38,11 @@ describe('coding_agents service', () => {
28
38
  const expectedCount = AGENT_CONFIGS.outputai.mappings.length +
29
39
  AGENT_CONFIGS['claude-code'].mappings.length;
30
40
  expect(files.length).toBe(expectedCount);
31
- expect(files.length).toBe(8);
41
+ expect(files.length).toBe(10);
32
42
  });
33
43
  it('should have outputai files with .outputai prefix', () => {
34
44
  const files = getRequiredFiles();
35
- const outputaiFiles = files.slice(0, 5);
45
+ const outputaiFiles = files.slice(0, 6);
36
46
  outputaiFiles.forEach(file => {
37
47
  expect(file).toMatch(/^\.outputai\//);
38
48
  });
@@ -67,11 +77,13 @@ describe('coding_agents service', () => {
67
77
  '.outputai/AGENTS.md',
68
78
  '.outputai/agents/workflow_planner.md',
69
79
  '.outputai/commands/plan_workflow.md',
80
+ '.outputai/commands/build_workflow.md',
70
81
  '.outputai/meta/pre_flight.md',
71
82
  '.outputai/meta/post_flight.md',
72
83
  'CLAUDE.md',
73
84
  '.claude/agents/workflow_planner.md',
74
- '.claude/commands/plan_workflow.md'
85
+ '.claude/commands/plan_workflow.md',
86
+ '.claude/commands/build_workflow.md'
75
87
  ],
76
88
  isComplete: false
77
89
  });
@@ -85,7 +97,7 @@ describe('coding_agents service', () => {
85
97
  missingFiles: [],
86
98
  isComplete: true
87
99
  });
88
- expect(access).toHaveBeenCalledTimes(9); // dir + 5 outputai + 3 claude-code
100
+ expect(access).toHaveBeenCalledTimes(11); // dir + 6 outputai + 4 claude-code
89
101
  });
90
102
  it('should return missing files when some files do not exist', async () => {
91
103
  const calls = [];
@@ -108,23 +120,31 @@ describe('coding_agents service', () => {
108
120
  if (callNum === 4) {
109
121
  return undefined;
110
122
  }
111
- // Call 5: .outputai/meta/pre_flight.md exists
123
+ // Call 5: .outputai/commands/build_workflow.md exists
112
124
  if (callNum === 5) {
113
125
  return undefined;
114
126
  }
115
- // Call 6: .outputai/meta/post_flight.md exists
127
+ // Call 6: .outputai/meta/pre_flight.md exists
116
128
  if (callNum === 6) {
117
129
  return undefined;
118
130
  }
119
- // Call 7: CLAUDE.md exists
131
+ // Call 7: .outputai/meta/post_flight.md exists
120
132
  if (callNum === 7) {
121
133
  return undefined;
122
134
  }
123
- // Call 8: .claude/agents/workflow_planner.md missing
135
+ // Call 8: CLAUDE.md exists
124
136
  if (callNum === 8) {
137
+ return undefined;
138
+ }
139
+ // Call 9: .claude/agents/workflow_planner.md missing
140
+ if (callNum === 9) {
125
141
  throw { code: 'ENOENT' };
126
142
  }
127
- // Call 9: .claude/commands/plan_workflow.md exists
143
+ // Call 10: .claude/commands/plan_workflow.md exists
144
+ if (callNum === 10) {
145
+ return undefined;
146
+ }
147
+ // Call 11: .claude/commands/build_workflow.md exists
128
148
  return undefined;
129
149
  });
130
150
  const result = await checkAgentStructure('/test/project');
@@ -143,11 +163,13 @@ describe('coding_agents service', () => {
143
163
  .mockResolvedValueOnce(undefined) // .outputai/AGENTS.md
144
164
  .mockRejectedValueOnce({ code: 'ENOENT' }) // .outputai/agents/workflow_planner.md
145
165
  .mockRejectedValueOnce({ code: 'ENOENT' }) // .outputai/commands/plan_workflow.md
166
+ .mockResolvedValueOnce(undefined) // .outputai/commands/build_workflow.md
146
167
  .mockResolvedValueOnce(undefined) // .outputai/meta/pre_flight.md
147
168
  .mockResolvedValueOnce(undefined) // .outputai/meta/post_flight.md
148
169
  .mockResolvedValueOnce(undefined) // CLAUDE.md
149
170
  .mockResolvedValueOnce(undefined) // .claude/agents/workflow_planner.md
150
- .mockRejectedValueOnce({ code: 'ENOENT' }); // .claude/commands/plan_workflow.md
171
+ .mockRejectedValueOnce({ code: 'ENOENT' }) // .claude/commands/plan_workflow.md
172
+ .mockResolvedValueOnce(undefined); // .claude/commands/build_workflow.md
151
173
  const result = await checkAgentStructure('/test/project');
152
174
  expect(result.dirExists).toBe(true);
153
175
  expect(result.missingFiles).toEqual([
@@ -182,10 +204,10 @@ describe('coding_agents service', () => {
182
204
  force: false,
183
205
  agentProvider: 'claude-code'
184
206
  });
185
- // Should create outputai files (3 templates)
207
+ // Should create outputai files (6 templates)
186
208
  expect(fs.writeFile).toHaveBeenCalledWith(expect.stringContaining('AGENTS.md'), expect.any(String), 'utf-8');
187
- // Should create symlinks (3 symlinks for claude-code)
188
- expect(fs.symlink).toHaveBeenCalledTimes(3);
209
+ // Should create symlinks (4 symlinks for claude-code)
210
+ expect(fs.symlink).toHaveBeenCalledTimes(4);
189
211
  });
190
212
  it('should skip existing files when force is false', async () => {
191
213
  // Mock some files exist
@@ -251,4 +273,123 @@ describe('coding_agents service', () => {
251
273
  expect(symlinkFallbackCalls.length).toBeGreaterThan(0);
252
274
  });
253
275
  });
276
+ describe('ensureOutputAIStructure', () => {
277
+ beforeEach(() => {
278
+ // Mock fs operations
279
+ vi.mocked(fs.mkdir).mockResolvedValue(undefined);
280
+ vi.mocked(access).mockRejectedValue({ code: 'ENOENT' });
281
+ vi.mocked(fs.readFile).mockResolvedValue('template content');
282
+ vi.mocked(fs.writeFile).mockResolvedValue(undefined);
283
+ vi.mocked(fs.symlink).mockResolvedValue(undefined);
284
+ });
285
+ it('should return immediately when agent structure is complete', async () => {
286
+ // Mock complete structure
287
+ vi.mocked(access).mockResolvedValue(undefined);
288
+ await ensureOutputAIStructure('/test/project');
289
+ // Should not call init functions
290
+ expect(fs.mkdir).not.toHaveBeenCalled();
291
+ expect(fs.writeFile).not.toHaveBeenCalled();
292
+ });
293
+ it('should auto-initialize when directory does not exist', async () => {
294
+ // Mock directory doesn't exist
295
+ vi.mocked(access).mockRejectedValue({ code: 'ENOENT' });
296
+ await ensureOutputAIStructure('/test/project');
297
+ // Should call init functions to create structure
298
+ expect(fs.mkdir).toHaveBeenCalled();
299
+ expect(fs.writeFile).toHaveBeenCalled();
300
+ });
301
+ it('should prompt user when some files are missing', async () => {
302
+ const { confirm } = await import('@inquirer/prompts');
303
+ const { ux } = await import('@oclif/core');
304
+ // Mock directory exists but some files missing
305
+ const calls = [];
306
+ vi.mocked(access).mockImplementation(async () => {
307
+ const callNum = calls.length + 1;
308
+ calls.push(callNum);
309
+ if (callNum === 1) {
310
+ return undefined; // Directory exists
311
+ }
312
+ if (callNum === 2) {
313
+ return undefined; // First file exists
314
+ }
315
+ throw { code: 'ENOENT' }; // Rest are missing
316
+ });
317
+ vi.mocked(confirm).mockResolvedValue(true);
318
+ await ensureOutputAIStructure('/test/project');
319
+ // Should warn about missing files
320
+ expect(ux.warn).toHaveBeenCalledWith(expect.stringContaining('Agent configuration is incomplete'));
321
+ // Should prompt user
322
+ expect(confirm).toHaveBeenCalledWith({
323
+ message: 'Would you like to run "agents init --force" to recreate missing files?',
324
+ default: true
325
+ });
326
+ // Should reinitialize with force=true
327
+ expect(fs.writeFile).toHaveBeenCalled();
328
+ });
329
+ it('should throw error when user declines to reinitialize', async () => {
330
+ const { confirm } = await import('@inquirer/prompts');
331
+ // Mock directory exists but files missing
332
+ const calls = [];
333
+ vi.mocked(access).mockImplementation(async () => {
334
+ const callNum = calls.length + 1;
335
+ calls.push(callNum);
336
+ if (callNum === 1) {
337
+ return undefined; // Directory exists
338
+ }
339
+ throw { code: 'ENOENT' }; // Files are missing
340
+ });
341
+ vi.mocked(confirm).mockResolvedValue(false);
342
+ await expect(ensureOutputAIStructure('/test/project')).rejects.toThrow('Agent configuration incomplete');
343
+ // Should not call init functions
344
+ expect(fs.mkdir).not.toHaveBeenCalled();
345
+ expect(fs.writeFile).not.toHaveBeenCalled();
346
+ });
347
+ it('should list all missing files in error message when user declines', async () => {
348
+ const { confirm } = await import('@inquirer/prompts');
349
+ // Mock directory exists but all files missing
350
+ const calls = [];
351
+ vi.mocked(access).mockImplementation(async () => {
352
+ const callNum = calls.length + 1;
353
+ calls.push(callNum);
354
+ if (callNum === 1) {
355
+ return undefined; // Directory exists
356
+ }
357
+ throw { code: 'ENOENT' }; // All files missing
358
+ });
359
+ vi.mocked(confirm).mockResolvedValue(false);
360
+ try {
361
+ await ensureOutputAIStructure('/test/project');
362
+ expect.fail('Should have thrown an error');
363
+ }
364
+ catch (error) {
365
+ const message = error.message;
366
+ expect(message).toContain('.outputai/AGENTS.md');
367
+ expect(message).toContain('.outputai/agents/workflow_planner.md');
368
+ expect(message).toContain('Run "output-cli agents init --force"');
369
+ }
370
+ });
371
+ it('should call initializeAgentConfig with force=false when dir does not exist', async () => {
372
+ vi.mocked(access).mockRejectedValue({ code: 'ENOENT' });
373
+ await ensureOutputAIStructure('/test/project');
374
+ // Should have called mkdir (part of init with force=false)
375
+ expect(fs.mkdir).toHaveBeenCalled();
376
+ });
377
+ it('should call initializeAgentConfig with force=true after user confirmation', async () => {
378
+ const { confirm } = await import('@inquirer/prompts');
379
+ // Mock directory exists but files missing
380
+ const calls = [];
381
+ vi.mocked(access).mockImplementation(async () => {
382
+ const callNum = calls.length + 1;
383
+ calls.push(callNum);
384
+ if (callNum === 1) {
385
+ return undefined; // Directory exists
386
+ }
387
+ throw { code: 'ENOENT' }; // Files missing
388
+ });
389
+ vi.mocked(confirm).mockResolvedValue(true);
390
+ await ensureOutputAIStructure('/test/project');
391
+ // Should have called writeFile (part of init with force=true)
392
+ expect(fs.writeFile).toHaveBeenCalled();
393
+ });
394
+ });
254
395
  });
@@ -1,4 +1,4 @@
1
- import type { TemplateFile } from '../types/generator.js';
1
+ import type { TemplateFile } from '#types/generator.js';
2
2
  /**
3
3
  * Get list of template files from a directory
4
4
  * Automatically discovers all .template files and derives output names
@@ -1,6 +1,6 @@
1
1
  import * as fs from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
- import { processTemplate } from '../utils/template.js';
3
+ import { processTemplate } from '#utils/template.js';
4
4
  const TEMPLATE_EXTENSION = '.template';
5
5
  function isTemplateFile(file) {
6
6
  return file.endsWith(TEMPLATE_EXTENSION);
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Build a workflow from a plan file using the /build_workflow slash command
3
+ * @param planFilePath - Absolute path to the plan file
4
+ * @param workflowDir - Absolute path to the workflow directory
5
+ * @param workflowName - Name of the workflow
6
+ * @param additionalInstructions - Optional additional instructions
7
+ * @returns Implementation output from claude-code
8
+ */
9
+ export declare function buildWorkflow(planFilePath: string, workflowDir: string, workflowName: string, additionalInstructions?: string): Promise<string>;
10
+ /**
11
+ * Interactive loop for refining workflow implementation
12
+ * Similar to the plan modification loop pattern
13
+ * @param originalOutput - Initial implementation output from claude-code
14
+ * @returns Final accepted output
15
+ */
16
+ export declare function buildWorkflowInteractiveLoop(originalOutput: string): Promise<string>;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Workflow builder service for implementing workflows from plan files
3
+ */
4
+ import { BUILD_COMMAND_OPTIONS, invokeBuildWorkflow as invokeBuildWorkflowFromClient, replyToClaude } from './claude_client.js';
5
+ import { input } from '@inquirer/prompts';
6
+ import { ux } from '@oclif/core';
7
+ import fs from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ const ACCEPT_KEY = 'ACCEPT';
10
+ const SEPARATOR_LINE = '─'.repeat(80);
11
+ function displayImplementationOutput(output, message) {
12
+ ux.stdout('\n');
13
+ ux.stdout(ux.colorize('green', message));
14
+ ux.stdout('\n');
15
+ ux.stdout(ux.colorize('dim', SEPARATOR_LINE));
16
+ ux.stdout(output);
17
+ ux.stdout(ux.colorize('dim', SEPARATOR_LINE));
18
+ ux.stdout('\n');
19
+ }
20
+ async function promptForModification() {
21
+ return input({
22
+ message: `Review the implementation. Type "${ACCEPT_KEY}" to accept, or describe modifications:`,
23
+ default: ACCEPT_KEY
24
+ });
25
+ }
26
+ function isAcceptCommand(modification) {
27
+ return modification.trim().toUpperCase() === ACCEPT_KEY;
28
+ }
29
+ function isEmpty(modification) {
30
+ return modification.trim() === '';
31
+ }
32
+ /**
33
+ * Build a workflow from a plan file using the /build_workflow slash command
34
+ * @param planFilePath - Absolute path to the plan file
35
+ * @param workflowDir - Absolute path to the workflow directory
36
+ * @param workflowName - Name of the workflow
37
+ * @param additionalInstructions - Optional additional instructions
38
+ * @returns Implementation output from claude-code
39
+ */
40
+ export async function buildWorkflow(planFilePath, workflowDir, workflowName, additionalInstructions) {
41
+ try {
42
+ await fs.access(planFilePath);
43
+ }
44
+ catch {
45
+ throw new Error(`Plan file not found: ${planFilePath}`);
46
+ }
47
+ await fs.mkdir(workflowDir, { recursive: true });
48
+ const absolutePlanPath = path.resolve(planFilePath);
49
+ const absoluteWorkflowDir = path.resolve(workflowDir);
50
+ return invokeBuildWorkflowFromClient(absolutePlanPath, absoluteWorkflowDir, workflowName, additionalInstructions);
51
+ }
52
+ async function processModification(modification, currentOutput) {
53
+ if (isEmpty(modification)) {
54
+ ux.stdout(ux.colorize('yellow', 'Please provide modification instructions or type ACCEPT to continue.'));
55
+ return currentOutput;
56
+ }
57
+ try {
58
+ const updatedOutput = await replyToClaude(modification, BUILD_COMMAND_OPTIONS);
59
+ displayImplementationOutput(updatedOutput, '✓ Implementation updated!');
60
+ return updatedOutput;
61
+ }
62
+ catch (error) {
63
+ ux.error(`Failed to apply modifications: ${error.message}`);
64
+ ux.stdout('Continuing with previous version...\n');
65
+ return currentOutput;
66
+ }
67
+ }
68
+ async function interactiveRefinementLoop(currentOutput) {
69
+ const modification = await promptForModification();
70
+ if (isAcceptCommand(modification)) {
71
+ return currentOutput;
72
+ }
73
+ const updatedOutput = await processModification(modification, currentOutput);
74
+ return interactiveRefinementLoop(updatedOutput);
75
+ }
76
+ /**
77
+ * Interactive loop for refining workflow implementation
78
+ * Similar to the plan modification loop pattern
79
+ * @param originalOutput - Initial implementation output from claude-code
80
+ * @returns Final accepted output
81
+ */
82
+ export async function buildWorkflowInteractiveLoop(originalOutput) {
83
+ displayImplementationOutput(originalOutput, '✓ Workflow implementation complete!');
84
+ return interactiveRefinementLoop(originalOutput);
85
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,165 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { buildWorkflow, buildWorkflowInteractiveLoop } from './workflow_builder.js';
3
+ import { BUILD_COMMAND_OPTIONS, invokeBuildWorkflow, replyToClaude } from './claude_client.js';
4
+ import { input } from '@inquirer/prompts';
5
+ import { ux } from '@oclif/core';
6
+ import fs from 'node:fs/promises';
7
+ vi.mock('./claude_client.js');
8
+ vi.mock('@inquirer/prompts');
9
+ vi.mock('@oclif/core', () => ({
10
+ ux: {
11
+ stdout: vi.fn(),
12
+ error: vi.fn(),
13
+ colorize: vi.fn((_color, text) => text)
14
+ }
15
+ }));
16
+ vi.mock('node:fs/promises');
17
+ describe('workflow-builder service', () => {
18
+ beforeEach(() => {
19
+ vi.clearAllMocks();
20
+ });
21
+ describe('buildWorkflow', () => {
22
+ it('should build workflow from plan file', async () => {
23
+ vi.mocked(fs.access).mockResolvedValue(undefined);
24
+ vi.mocked(fs.mkdir).mockResolvedValue(undefined);
25
+ vi.mocked(invokeBuildWorkflow).mockResolvedValue('Implementation complete!');
26
+ const result = await buildWorkflow('/path/to/plan.md', '/path/to/workflows/test_workflow', 'test_workflow');
27
+ expect(fs.access).toHaveBeenCalledWith('/path/to/plan.md');
28
+ expect(fs.mkdir).toHaveBeenCalledWith(expect.stringContaining('test_workflow'), { recursive: true });
29
+ expect(invokeBuildWorkflow).toHaveBeenCalledWith(expect.stringContaining('plan.md'), expect.stringContaining('test_workflow'), 'test_workflow', undefined);
30
+ expect(result).toBe('Implementation complete!');
31
+ });
32
+ it('should pass additional instructions to claude-code', async () => {
33
+ vi.mocked(fs.access).mockResolvedValue(undefined);
34
+ vi.mocked(fs.mkdir).mockResolvedValue(undefined);
35
+ vi.mocked(invokeBuildWorkflow).mockResolvedValue('Done!');
36
+ await buildWorkflow('/plan.md', '/workflows', 'test', 'Use TypeScript only');
37
+ expect(invokeBuildWorkflow).toHaveBeenCalledWith(expect.any(String), expect.any(String), 'test', 'Use TypeScript only');
38
+ });
39
+ it('should throw error if plan file does not exist', async () => {
40
+ vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT'));
41
+ await expect(buildWorkflow('/nonexistent/plan.md', '/workflows', 'test')).rejects.toThrow('Plan file not found: /nonexistent/plan.md');
42
+ expect(fs.mkdir).not.toHaveBeenCalled();
43
+ expect(invokeBuildWorkflow).not.toHaveBeenCalled();
44
+ });
45
+ it('should create workflow directory if it does not exist', async () => {
46
+ vi.mocked(fs.access).mockResolvedValue(undefined);
47
+ vi.mocked(fs.mkdir).mockResolvedValue(undefined);
48
+ vi.mocked(invokeBuildWorkflow).mockResolvedValue('Done');
49
+ await buildWorkflow('/plan.md', '/new/workflows/test', 'test');
50
+ expect(fs.mkdir).toHaveBeenCalledWith(expect.stringContaining('test'), { recursive: true });
51
+ });
52
+ it('should resolve paths to absolute paths', async () => {
53
+ vi.mocked(fs.access).mockResolvedValue(undefined);
54
+ vi.mocked(fs.mkdir).mockResolvedValue(undefined);
55
+ vi.mocked(invokeBuildWorkflow).mockResolvedValue('Done');
56
+ await buildWorkflow('relative/plan.md', 'relative/workflows', 'test');
57
+ // Should call with absolute paths
58
+ const calls = vi.mocked(invokeBuildWorkflow).mock.calls[0];
59
+ expect(calls[0]).toMatch(/^[/\\]/); // Absolute path starts with / or \
60
+ expect(calls[1]).toMatch(/^[/\\]/); // Absolute path starts with / or \
61
+ });
62
+ it('should propagate errors from claude-code invocation', async () => {
63
+ vi.mocked(fs.access).mockResolvedValue(undefined);
64
+ vi.mocked(fs.mkdir).mockResolvedValue(undefined);
65
+ vi.mocked(invokeBuildWorkflow).mockRejectedValue(new Error('Claude API timeout'));
66
+ await expect(buildWorkflow('/plan.md', '/workflows', 'test')).rejects.toThrow('Claude API timeout');
67
+ });
68
+ it('should handle directory creation failures', async () => {
69
+ vi.mocked(fs.access).mockResolvedValue(undefined);
70
+ vi.mocked(fs.mkdir).mockRejectedValue(new Error('Permission denied'));
71
+ await expect(buildWorkflow('/plan.md', '/workflows', 'test')).rejects.toThrow('Permission denied');
72
+ });
73
+ });
74
+ describe('buildWorkflowInteractiveLoop', () => {
75
+ it('should accept implementation immediately when user types ACCEPT', async () => {
76
+ vi.mocked(input).mockResolvedValue('ACCEPT');
77
+ const result = await buildWorkflowInteractiveLoop('Initial implementation');
78
+ expect(result).toBe('Initial implementation');
79
+ expect(input).toHaveBeenCalledOnce();
80
+ expect(replyToClaude).not.toHaveBeenCalled();
81
+ });
82
+ it('should accept implementation when user types lowercase accept', async () => {
83
+ vi.mocked(input).mockResolvedValue('accept');
84
+ const result = await buildWorkflowInteractiveLoop('Initial implementation');
85
+ expect(result).toBe('Initial implementation');
86
+ expect(replyToClaude).not.toHaveBeenCalled();
87
+ });
88
+ it('should accept implementation with extra whitespace', async () => {
89
+ vi.mocked(input).mockResolvedValue(' ACCEPT ');
90
+ const result = await buildWorkflowInteractiveLoop('Initial implementation');
91
+ expect(result).toBe('Initial implementation');
92
+ expect(replyToClaude).not.toHaveBeenCalled();
93
+ });
94
+ it('should apply modifications and return updated implementation', async () => {
95
+ vi.mocked(input)
96
+ .mockResolvedValueOnce('Add error handling')
97
+ .mockResolvedValueOnce('ACCEPT');
98
+ vi.mocked(replyToClaude).mockResolvedValue('Updated implementation with error handling');
99
+ const result = await buildWorkflowInteractiveLoop('Initial implementation');
100
+ expect(replyToClaude).toHaveBeenCalledWith('Add error handling', BUILD_COMMAND_OPTIONS);
101
+ expect(result).toBe('Updated implementation with error handling');
102
+ expect(input).toHaveBeenCalledTimes(2);
103
+ });
104
+ it('should handle multiple modification rounds', async () => {
105
+ vi.mocked(input)
106
+ .mockResolvedValueOnce('Add logging')
107
+ .mockResolvedValueOnce('Add validation')
108
+ .mockResolvedValueOnce('ACCEPT');
109
+ vi.mocked(replyToClaude)
110
+ .mockResolvedValueOnce('Implementation with logging')
111
+ .mockResolvedValueOnce('Implementation with logging and validation');
112
+ const result = await buildWorkflowInteractiveLoop('Initial implementation');
113
+ expect(replyToClaude).toHaveBeenCalledTimes(2);
114
+ expect(replyToClaude).toHaveBeenNthCalledWith(1, 'Add logging', BUILD_COMMAND_OPTIONS);
115
+ expect(replyToClaude).toHaveBeenNthCalledWith(2, 'Add validation', BUILD_COMMAND_OPTIONS);
116
+ expect(result).toBe('Implementation with logging and validation');
117
+ });
118
+ it('should prompt again when user provides empty input', async () => {
119
+ vi.mocked(input)
120
+ .mockResolvedValueOnce('')
121
+ .mockResolvedValueOnce(' ')
122
+ .mockResolvedValueOnce('ACCEPT');
123
+ const result = await buildWorkflowInteractiveLoop('Initial implementation');
124
+ expect(result).toBe('Initial implementation');
125
+ expect(input).toHaveBeenCalledTimes(3);
126
+ expect(ux.stdout).toHaveBeenCalledWith(expect.stringContaining('provide modification instructions'));
127
+ });
128
+ it('should display implementation output to user', async () => {
129
+ vi.mocked(input).mockResolvedValue('ACCEPT');
130
+ await buildWorkflowInteractiveLoop('Implementation summary');
131
+ expect(ux.stdout).toHaveBeenCalledWith(expect.stringContaining('Implementation summary'));
132
+ });
133
+ it('should display updated implementation after modifications', async () => {
134
+ vi.mocked(input)
135
+ .mockResolvedValueOnce('Improve performance')
136
+ .mockResolvedValueOnce('ACCEPT');
137
+ vi.mocked(replyToClaude).mockResolvedValue('Optimized implementation');
138
+ await buildWorkflowInteractiveLoop('Initial');
139
+ expect(ux.stdout).toHaveBeenCalledWith(expect.stringContaining('Optimized implementation'));
140
+ });
141
+ it('should handle errors from replyToClaude gracefully', async () => {
142
+ vi.mocked(input)
143
+ .mockResolvedValueOnce('Invalid modification')
144
+ .mockResolvedValueOnce('ACCEPT');
145
+ vi.mocked(replyToClaude).mockRejectedValue(new Error('API error'));
146
+ const result = await buildWorkflowInteractiveLoop('Original implementation');
147
+ // Should return original implementation after error
148
+ expect(result).toBe('Original implementation');
149
+ expect(ux.error).toHaveBeenCalledWith(expect.stringContaining('Failed to apply modifications'));
150
+ expect(ux.stdout).toHaveBeenCalledWith(expect.stringContaining('Continuing with previous version'));
151
+ });
152
+ it('should continue looping after handling error', async () => {
153
+ vi.mocked(input)
154
+ .mockResolvedValueOnce('Bad request')
155
+ .mockResolvedValueOnce('Good request')
156
+ .mockResolvedValueOnce('ACCEPT');
157
+ vi.mocked(replyToClaude)
158
+ .mockRejectedValueOnce(new Error('API error'))
159
+ .mockResolvedValueOnce('Fixed implementation');
160
+ const result = await buildWorkflowInteractiveLoop('Initial');
161
+ expect(result).toBe('Fixed implementation');
162
+ expect(replyToClaude).toHaveBeenCalledTimes(2);
163
+ });
164
+ });
165
+ });
@@ -1,4 +1,4 @@
1
- import type { WorkflowGenerationConfig, WorkflowGenerationResult } from '../types/generator.js';
1
+ import type { WorkflowGenerationConfig, WorkflowGenerationResult } from '#types/generator.js';
2
2
  /**
3
3
  * Generate a new workflow
4
4
  */