@output.ai/cli 0.0.6 → 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.
@@ -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
  });
@@ -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,8 +1,3 @@
1
- /**
2
- * Ensure .outputai directory structure exists by invoking agents init if needed
3
- * @throws Error if user declines to initialize or if initialization fails
4
- */
5
- export declare function ensureOutputAIStructure(projectRoot: string): Promise<void>;
6
1
  export declare function generatePlanName(description: string, date?: Date): Promise<string>;
7
2
  /**
8
3
  * Write plan content to PLAN.md file in the plans directory
@@ -1,47 +1,10 @@
1
- import { checkAgentStructure, initializeAgentConfig } from './coding_agents.js';
2
- import { confirm } from '@inquirer/prompts';
1
+ import { initializeAgentConfig } from './coding_agents.js';
3
2
  import { generateText } from 'local_llm';
4
3
  import { loadPrompt } from 'local_prompt';
5
4
  import { format } from 'date-fns';
6
5
  import fs from 'node:fs/promises';
7
6
  import path from 'node:path';
8
- import { ux } from '@oclif/core';
9
7
  import { AGENT_CONFIG_DIR } from '#config.js';
10
- /**
11
- * Invoke agents init programmatically
12
- */
13
- async function invokeAgentsInit(projectRoot, force) {
14
- await initializeAgentConfig({
15
- projectRoot,
16
- force,
17
- agentProvider: 'claude-code'
18
- });
19
- }
20
- /**
21
- * Ensure .outputai directory structure exists by invoking agents init if needed
22
- * @throws Error if user declines to initialize or if initialization fails
23
- */
24
- export async function ensureOutputAIStructure(projectRoot) {
25
- const structureCheck = await checkAgentStructure(projectRoot);
26
- if (structureCheck.isComplete) {
27
- return;
28
- }
29
- if (!structureCheck.dirExists) {
30
- await invokeAgentsInit(projectRoot, false);
31
- return;
32
- }
33
- const missingList = structureCheck.missingFiles.map(f => ` • ${f}`).join('\n');
34
- ux.warn(`\n⚠️ Agent configuration is incomplete. Missing files:\n${missingList}\n`);
35
- const shouldReinit = await confirm({
36
- message: 'Would you like to run "agents init --force" to recreate missing files?',
37
- default: true
38
- });
39
- if (!shouldReinit) {
40
- throw new Error(`Agent configuration incomplete. Missing files:\n${structureCheck.missingFiles.join('\n')}\n\n` +
41
- 'Run "output-cli agents init --force" to recreate them.');
42
- }
43
- await invokeAgentsInit(projectRoot, true);
44
- }
45
8
  export async function generatePlanName(description, date = new Date()) {
46
9
  const datePrefix = format(date, 'yyyy_MM_dd');
47
10
  const prompt = loadPrompt('generate_plan_name@v1', { description });