@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.
- package/README.md +2 -3
- package/dist/commands/workflow/generate.d.ts +1 -0
- package/dist/commands/workflow/generate.js +23 -4
- package/dist/commands/workflow/plan.js +4 -3
- package/dist/commands/workflow/plan.spec.js +5 -3
- package/dist/services/claude_client.d.ts +18 -1
- package/dist/services/claude_client.js +67 -21
- package/dist/services/coding_agents.d.ts +7 -0
- package/dist/services/coding_agents.js +64 -7
- package/dist/services/coding_agents.spec.js +155 -14
- package/dist/services/workflow_builder.d.ts +16 -0
- package/dist/services/workflow_builder.js +85 -0
- package/dist/services/workflow_builder.spec.d.ts +1 -0
- package/dist/services/workflow_builder.spec.js +165 -0
- package/dist/services/workflow_planner.d.ts +0 -5
- package/dist/services/workflow_planner.js +1 -38
- package/dist/services/workflow_planner.spec.js +2 -77
- package/dist/templates/agent_instructions/commands/build_workflow.md.template +246 -0
- package/dist/templates/workflow/steps.ts.template +25 -54
- package/dist/templates/workflow/workflow.ts.template +23 -49
- package/dist/types/domain.d.ts +20 -0
- package/dist/types/domain.js +4 -0
- package/dist/utils/paths.d.ts +1 -1
- package/dist/utils/paths.js +1 -1
- package/package.json +1 -1
|
@@ -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(
|
|
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,
|
|
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(
|
|
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/
|
|
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/
|
|
127
|
+
// Call 6: .outputai/meta/pre_flight.md exists
|
|
116
128
|
if (callNum === 6) {
|
|
117
129
|
return undefined;
|
|
118
130
|
}
|
|
119
|
-
// Call 7:
|
|
131
|
+
// Call 7: .outputai/meta/post_flight.md exists
|
|
120
132
|
if (callNum === 7) {
|
|
121
133
|
return undefined;
|
|
122
134
|
}
|
|
123
|
-
// Call 8: .
|
|
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
|
|
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' })
|
|
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 (
|
|
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 (
|
|
188
|
-
expect(fs.symlink).toHaveBeenCalledTimes(
|
|
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 {
|
|
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 });
|