@output.ai/cli 0.0.4 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/http_client.js +1 -1
- package/dist/commands/agents/init.d.ts +2 -9
- package/dist/commands/agents/init.js +8 -145
- package/dist/commands/agents/init.spec.js +31 -26
- package/dist/commands/workflow/generate.d.ts +5 -5
- package/dist/commands/workflow/generate.js +2 -2
- package/dist/commands/workflow/generate.spec.js +2 -2
- package/dist/commands/workflow/list.d.ts +4 -4
- package/dist/commands/workflow/list.js +3 -3
- package/dist/commands/workflow/output.d.ts +2 -2
- package/dist/commands/workflow/output.js +4 -4
- package/dist/commands/workflow/plan.d.ts +12 -0
- package/dist/commands/workflow/plan.js +65 -0
- package/dist/commands/workflow/plan.spec.d.ts +1 -0
- package/dist/commands/workflow/plan.spec.js +339 -0
- package/dist/commands/workflow/run.d.ts +4 -4
- package/dist/commands/workflow/run.js +5 -5
- package/dist/commands/workflow/start.d.ts +3 -3
- package/dist/commands/workflow/start.js +3 -3
- package/dist/commands/workflow/status.d.ts +2 -2
- package/dist/commands/workflow/status.js +4 -4
- package/dist/commands/workflow/stop.d.ts +1 -1
- package/dist/commands/workflow/stop.js +2 -2
- package/dist/config.d.ts +4 -0
- package/dist/config.js +4 -0
- package/dist/services/claude_client.d.ts +13 -0
- package/dist/services/claude_client.integration.test.d.ts +1 -0
- package/dist/services/claude_client.integration.test.js +43 -0
- package/dist/services/claude_client.js +155 -0
- package/dist/services/claude_client.spec.d.ts +1 -0
- package/dist/services/claude_client.spec.js +141 -0
- package/dist/services/coding_agents.d.ts +43 -0
- package/dist/services/coding_agents.js +230 -0
- package/dist/services/coding_agents.spec.d.ts +1 -0
- package/dist/services/coding_agents.spec.js +254 -0
- package/dist/services/generate_plan_name@v1.prompt +24 -0
- package/dist/services/template_processor.d.ts +1 -1
- package/dist/services/template_processor.js +1 -1
- package/dist/services/workflow_generator.d.ts +1 -1
- package/dist/services/workflow_generator.js +4 -4
- package/dist/services/workflow_planner.d.ts +20 -0
- package/dist/services/workflow_planner.js +83 -0
- package/dist/services/workflow_planner.spec.d.ts +1 -0
- package/dist/services/workflow_planner.spec.js +208 -0
- package/dist/templates/agent_instructions/commands/plan_workflow.md.template +180 -386
- package/dist/test_helpers/mocks.d.ts +37 -0
- package/dist/test_helpers/mocks.js +67 -0
- package/dist/utils/error_handler.js +1 -1
- package/dist/utils/validation.js +1 -1
- package/package.json +13 -3
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { getRequiredFiles, checkAgentConfigDirExists, checkAgentStructure, getAgentConfigDir, prepareTemplateVariables, initializeAgentConfig, AGENT_CONFIGS } from './coding_agents.js';
|
|
3
|
+
import { access } from 'node:fs/promises';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
vi.mock('node:fs/promises');
|
|
6
|
+
vi.mock('../utils/paths.js', () => ({
|
|
7
|
+
getTemplateDir: vi.fn().mockReturnValue('/templates')
|
|
8
|
+
}));
|
|
9
|
+
vi.mock('../utils/template.js', () => ({
|
|
10
|
+
processTemplate: vi.fn().mockImplementation((content) => content)
|
|
11
|
+
}));
|
|
12
|
+
describe('coding_agents service', () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.clearAllMocks();
|
|
15
|
+
});
|
|
16
|
+
describe('getRequiredFiles', () => {
|
|
17
|
+
it('should derive required files from both AGENT_CONFIGS', () => {
|
|
18
|
+
const files = getRequiredFiles();
|
|
19
|
+
expect(files).toContain('.outputai/AGENTS.md');
|
|
20
|
+
expect(files).toContain('.outputai/agents/workflow_planner.md');
|
|
21
|
+
expect(files).toContain('.outputai/commands/plan_workflow.md');
|
|
22
|
+
expect(files).toContain('CLAUDE.md');
|
|
23
|
+
expect(files).toContain('.claude/agents/workflow_planner.md');
|
|
24
|
+
expect(files).toContain('.claude/commands/plan_workflow.md');
|
|
25
|
+
});
|
|
26
|
+
it('should include both outputai and claude-code files', () => {
|
|
27
|
+
const files = getRequiredFiles();
|
|
28
|
+
const expectedCount = AGENT_CONFIGS.outputai.mappings.length +
|
|
29
|
+
AGENT_CONFIGS['claude-code'].mappings.length;
|
|
30
|
+
expect(files.length).toBe(expectedCount);
|
|
31
|
+
expect(files.length).toBe(8);
|
|
32
|
+
});
|
|
33
|
+
it('should have outputai files with .outputai prefix', () => {
|
|
34
|
+
const files = getRequiredFiles();
|
|
35
|
+
const outputaiFiles = files.slice(0, 5);
|
|
36
|
+
outputaiFiles.forEach(file => {
|
|
37
|
+
expect(file).toMatch(/^\.outputai\//);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
describe('getAgentConfigDir', () => {
|
|
42
|
+
it('should return the correct path to .outputai directory', () => {
|
|
43
|
+
const result = getAgentConfigDir('/test/project');
|
|
44
|
+
expect(result).toBe('/test/project/.outputai');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
describe('checkAgentConfigDirExists', () => {
|
|
48
|
+
it('should return true when .outputai directory exists', async () => {
|
|
49
|
+
vi.mocked(access).mockResolvedValue(undefined);
|
|
50
|
+
const result = await checkAgentConfigDirExists('/test/project');
|
|
51
|
+
expect(result).toBe(true);
|
|
52
|
+
expect(access).toHaveBeenCalledWith('/test/project/.outputai');
|
|
53
|
+
});
|
|
54
|
+
it('should return false when .outputai directory does not exist', async () => {
|
|
55
|
+
vi.mocked(access).mockRejectedValue({ code: 'ENOENT' });
|
|
56
|
+
const result = await checkAgentConfigDirExists('/test/project');
|
|
57
|
+
expect(result).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
describe('checkAgentStructure', () => {
|
|
61
|
+
it('should return all files missing when directory does not exist', async () => {
|
|
62
|
+
vi.mocked(access).mockRejectedValue({ code: 'ENOENT' });
|
|
63
|
+
const result = await checkAgentStructure('/test/project');
|
|
64
|
+
expect(result).toEqual({
|
|
65
|
+
dirExists: false,
|
|
66
|
+
missingFiles: [
|
|
67
|
+
'.outputai/AGENTS.md',
|
|
68
|
+
'.outputai/agents/workflow_planner.md',
|
|
69
|
+
'.outputai/commands/plan_workflow.md',
|
|
70
|
+
'.outputai/meta/pre_flight.md',
|
|
71
|
+
'.outputai/meta/post_flight.md',
|
|
72
|
+
'CLAUDE.md',
|
|
73
|
+
'.claude/agents/workflow_planner.md',
|
|
74
|
+
'.claude/commands/plan_workflow.md'
|
|
75
|
+
],
|
|
76
|
+
isComplete: false
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
it('should return complete when all files exist', async () => {
|
|
80
|
+
// Mock directory exists
|
|
81
|
+
vi.mocked(access).mockResolvedValue(undefined);
|
|
82
|
+
const result = await checkAgentStructure('/test/project');
|
|
83
|
+
expect(result).toEqual({
|
|
84
|
+
dirExists: true,
|
|
85
|
+
missingFiles: [],
|
|
86
|
+
isComplete: true
|
|
87
|
+
});
|
|
88
|
+
expect(access).toHaveBeenCalledTimes(9); // dir + 5 outputai + 3 claude-code
|
|
89
|
+
});
|
|
90
|
+
it('should return missing files when some files do not exist', async () => {
|
|
91
|
+
const calls = [];
|
|
92
|
+
vi.mocked(access).mockImplementation(async () => {
|
|
93
|
+
const callNum = calls.length + 1;
|
|
94
|
+
calls.push(callNum);
|
|
95
|
+
// Call 1: directory exists
|
|
96
|
+
if (callNum === 1) {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
// Call 2: .outputai/AGENTS.md exists
|
|
100
|
+
if (callNum === 2) {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
// Call 3: .outputai/agents/workflow_planner.md missing
|
|
104
|
+
if (callNum === 3) {
|
|
105
|
+
throw { code: 'ENOENT' };
|
|
106
|
+
}
|
|
107
|
+
// Call 4: .outputai/commands/plan_workflow.md exists
|
|
108
|
+
if (callNum === 4) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
// Call 5: .outputai/meta/pre_flight.md exists
|
|
112
|
+
if (callNum === 5) {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
// Call 6: .outputai/meta/post_flight.md exists
|
|
116
|
+
if (callNum === 6) {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
// Call 7: CLAUDE.md exists
|
|
120
|
+
if (callNum === 7) {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
// Call 8: .claude/agents/workflow_planner.md missing
|
|
124
|
+
if (callNum === 8) {
|
|
125
|
+
throw { code: 'ENOENT' };
|
|
126
|
+
}
|
|
127
|
+
// Call 9: .claude/commands/plan_workflow.md exists
|
|
128
|
+
return undefined;
|
|
129
|
+
});
|
|
130
|
+
const result = await checkAgentStructure('/test/project');
|
|
131
|
+
expect(result).toEqual({
|
|
132
|
+
dirExists: true,
|
|
133
|
+
missingFiles: [
|
|
134
|
+
'.outputai/agents/workflow_planner.md',
|
|
135
|
+
'.claude/agents/workflow_planner.md'
|
|
136
|
+
],
|
|
137
|
+
isComplete: false
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
it('should check all required files even when directory exists', async () => {
|
|
141
|
+
vi.mocked(access)
|
|
142
|
+
.mockResolvedValueOnce(undefined) // directory
|
|
143
|
+
.mockResolvedValueOnce(undefined) // .outputai/AGENTS.md
|
|
144
|
+
.mockRejectedValueOnce({ code: 'ENOENT' }) // .outputai/agents/workflow_planner.md
|
|
145
|
+
.mockRejectedValueOnce({ code: 'ENOENT' }) // .outputai/commands/plan_workflow.md
|
|
146
|
+
.mockResolvedValueOnce(undefined) // .outputai/meta/pre_flight.md
|
|
147
|
+
.mockResolvedValueOnce(undefined) // .outputai/meta/post_flight.md
|
|
148
|
+
.mockResolvedValueOnce(undefined) // CLAUDE.md
|
|
149
|
+
.mockResolvedValueOnce(undefined) // .claude/agents/workflow_planner.md
|
|
150
|
+
.mockRejectedValueOnce({ code: 'ENOENT' }); // .claude/commands/plan_workflow.md
|
|
151
|
+
const result = await checkAgentStructure('/test/project');
|
|
152
|
+
expect(result.dirExists).toBe(true);
|
|
153
|
+
expect(result.missingFiles).toEqual([
|
|
154
|
+
'.outputai/agents/workflow_planner.md',
|
|
155
|
+
'.outputai/commands/plan_workflow.md',
|
|
156
|
+
'.claude/commands/plan_workflow.md'
|
|
157
|
+
]);
|
|
158
|
+
expect(result.isComplete).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
describe('prepareTemplateVariables', () => {
|
|
162
|
+
it('should return template variables with formatted date', () => {
|
|
163
|
+
const variables = prepareTemplateVariables();
|
|
164
|
+
expect(variables).toHaveProperty('date');
|
|
165
|
+
expect(typeof variables.date).toBe('string');
|
|
166
|
+
// Should be in format like "January 1, 2025"
|
|
167
|
+
expect(variables.date).toMatch(/^[A-Z][a-z]+ \d{1,2}, \d{4}$/);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
describe('initializeAgentConfig', () => {
|
|
171
|
+
beforeEach(() => {
|
|
172
|
+
// Mock fs operations
|
|
173
|
+
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
|
174
|
+
vi.mocked(access).mockRejectedValue({ code: 'ENOENT' }); // Files don't exist
|
|
175
|
+
vi.mocked(fs.readFile).mockResolvedValue('template content');
|
|
176
|
+
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
|
177
|
+
vi.mocked(fs.symlink).mockResolvedValue(undefined);
|
|
178
|
+
});
|
|
179
|
+
it('should process both outputai and provider mappings', async () => {
|
|
180
|
+
await initializeAgentConfig({
|
|
181
|
+
projectRoot: '/test/project',
|
|
182
|
+
force: false,
|
|
183
|
+
agentProvider: 'claude-code'
|
|
184
|
+
});
|
|
185
|
+
// Should create outputai files (3 templates)
|
|
186
|
+
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);
|
|
189
|
+
});
|
|
190
|
+
it('should skip existing files when force is false', async () => {
|
|
191
|
+
// Mock some files exist
|
|
192
|
+
vi.mocked(access).mockImplementation(async (path, _mode) => {
|
|
193
|
+
const pathStr = path.toString();
|
|
194
|
+
if (pathStr.includes('AGENTS.md')) {
|
|
195
|
+
return; // File exists
|
|
196
|
+
}
|
|
197
|
+
throw { code: 'ENOENT' }; // File doesn't exist
|
|
198
|
+
});
|
|
199
|
+
await initializeAgentConfig({
|
|
200
|
+
projectRoot: '/test/project',
|
|
201
|
+
force: false,
|
|
202
|
+
agentProvider: 'claude-code'
|
|
203
|
+
});
|
|
204
|
+
// Should not create files that already exist
|
|
205
|
+
expect(fs.writeFile).toHaveBeenCalled();
|
|
206
|
+
const writeFileCalls = vi.mocked(fs.writeFile).mock.calls;
|
|
207
|
+
const agentsWriteCalls = writeFileCalls.filter(call => call[0].toString().includes('AGENTS.md'));
|
|
208
|
+
expect(agentsWriteCalls).toHaveLength(0);
|
|
209
|
+
});
|
|
210
|
+
it('should overwrite existing files when force is true', async () => {
|
|
211
|
+
// Mock all files exist
|
|
212
|
+
vi.mocked(access).mockResolvedValue(undefined);
|
|
213
|
+
await initializeAgentConfig({
|
|
214
|
+
projectRoot: '/test/project',
|
|
215
|
+
force: true,
|
|
216
|
+
agentProvider: 'claude-code'
|
|
217
|
+
});
|
|
218
|
+
// Should still write files
|
|
219
|
+
expect(fs.writeFile).toHaveBeenCalled();
|
|
220
|
+
});
|
|
221
|
+
it('should create files and output to stdout', async () => {
|
|
222
|
+
await initializeAgentConfig({
|
|
223
|
+
projectRoot: '/test/project',
|
|
224
|
+
force: false,
|
|
225
|
+
agentProvider: 'claude-code'
|
|
226
|
+
});
|
|
227
|
+
// Should create files
|
|
228
|
+
expect(fs.writeFile).toHaveBeenCalled();
|
|
229
|
+
expect(fs.symlink).toHaveBeenCalled();
|
|
230
|
+
});
|
|
231
|
+
it('should create directories for nested files', async () => {
|
|
232
|
+
await initializeAgentConfig({
|
|
233
|
+
projectRoot: '/test/project',
|
|
234
|
+
force: false,
|
|
235
|
+
agentProvider: 'claude-code'
|
|
236
|
+
});
|
|
237
|
+
// Should create directories like .outputai/agents, .outputai/commands
|
|
238
|
+
expect(fs.mkdir).toHaveBeenCalledWith(expect.stringContaining('agents'), expect.objectContaining({ recursive: true }));
|
|
239
|
+
});
|
|
240
|
+
it('should handle symlink errors by falling back to copy', async () => {
|
|
241
|
+
vi.mocked(fs.symlink).mockRejectedValue({ code: 'ENOTSUP' });
|
|
242
|
+
await initializeAgentConfig({
|
|
243
|
+
projectRoot: '/test/project',
|
|
244
|
+
force: false,
|
|
245
|
+
agentProvider: 'claude-code'
|
|
246
|
+
});
|
|
247
|
+
// Should fall back to copying via writeFile
|
|
248
|
+
expect(fs.writeFile).toHaveBeenCalled();
|
|
249
|
+
const writeFileCalls = vi.mocked(fs.writeFile).mock.calls;
|
|
250
|
+
const symlinkFallbackCalls = writeFileCalls.filter(call => call[0].toString().includes('CLAUDE.md') || call[0].toString().includes('.claude/'));
|
|
251
|
+
expect(symlinkFallbackCalls.length).toBeGreaterThan(0);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
provider: anthropic
|
|
3
|
+
model: claude-sonnet-4-20250514
|
|
4
|
+
temperature: 0.3
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<assistant>
|
|
8
|
+
You are a semantic naming assistant. Generate concise, descriptive slugs for workflow plans.
|
|
9
|
+
</assistant>
|
|
10
|
+
|
|
11
|
+
<user>
|
|
12
|
+
Convert this workflow description into a short, semantic slug (2-6 words max):
|
|
13
|
+
|
|
14
|
+
"{{ description }}"
|
|
15
|
+
|
|
16
|
+
Requirements:
|
|
17
|
+
- Use snake_case format
|
|
18
|
+
- Be descriptive but concise
|
|
19
|
+
- Focus on the core action/purpose
|
|
20
|
+
- Use common technical terms
|
|
21
|
+
- Maximum 50 characters
|
|
22
|
+
|
|
23
|
+
Return ONLY the slug, no quotes, no explanation.
|
|
24
|
+
</user>
|
|
@@ -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 '
|
|
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);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import * as fs from 'node:fs/promises';
|
|
2
|
-
import { WorkflowExistsError } from '
|
|
3
|
-
import { createTargetDir, getTemplateDir } from '
|
|
4
|
-
import { validateWorkflowName, validateOutputDirectory } from '
|
|
5
|
-
import { prepareTemplateVariables } from '
|
|
2
|
+
import { WorkflowExistsError } from '#types/errors.js';
|
|
3
|
+
import { createTargetDir, getTemplateDir } from '#utils/paths.js';
|
|
4
|
+
import { validateWorkflowName, validateOutputDirectory } from '#utils/validation.js';
|
|
5
|
+
import { prepareTemplateVariables } from '#utils/template.js';
|
|
6
6
|
import { getTemplateFiles, processAllTemplates } from './template_processor.js';
|
|
7
7
|
/**
|
|
8
8
|
* Validate the generation configuration
|
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
export declare function generatePlanName(description: string, date?: Date): Promise<string>;
|
|
7
|
+
/**
|
|
8
|
+
* Write plan content to PLAN.md file in the plans directory
|
|
9
|
+
* @param planName - Name of the plan (e.g., "2025_10_06_customer_order_processing")
|
|
10
|
+
* @param content - Plan content to write
|
|
11
|
+
* @param projectRoot - Root directory of the project
|
|
12
|
+
* @returns Full path to the created PLAN.md file
|
|
13
|
+
*/
|
|
14
|
+
export declare function writePlanFile(planName: string, content: string, projectRoot: string): Promise<string>;
|
|
15
|
+
/**
|
|
16
|
+
* Update agent templates by invoking agents init with force flag
|
|
17
|
+
* This recreates all agent configuration files, overwriting existing ones
|
|
18
|
+
* @param projectRoot - Root directory of the project
|
|
19
|
+
*/
|
|
20
|
+
export declare function updateAgentTemplates(projectRoot: string): Promise<void>;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { checkAgentStructure, initializeAgentConfig } from './coding_agents.js';
|
|
2
|
+
import { confirm } from '@inquirer/prompts';
|
|
3
|
+
import { generateText } from 'local_llm';
|
|
4
|
+
import { loadPrompt } from 'local_prompt';
|
|
5
|
+
import { format } from 'date-fns';
|
|
6
|
+
import fs from 'node:fs/promises';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { ux } from '@oclif/core';
|
|
9
|
+
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
|
+
export async function generatePlanName(description, date = new Date()) {
|
|
46
|
+
const datePrefix = format(date, 'yyyy_MM_dd');
|
|
47
|
+
const prompt = loadPrompt('generate_plan_name@v1', { description });
|
|
48
|
+
const planNameSlug = await generateText({ prompt });
|
|
49
|
+
const cleanedName = planNameSlug
|
|
50
|
+
.trim()
|
|
51
|
+
.toLowerCase()
|
|
52
|
+
.replace(/[^a-z0-9_]+/g, '_')
|
|
53
|
+
.replace(/_+/g, '_')
|
|
54
|
+
.replace(/^_|_$/g, '')
|
|
55
|
+
.slice(0, 50);
|
|
56
|
+
return `${datePrefix}_${cleanedName}`;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Write plan content to PLAN.md file in the plans directory
|
|
60
|
+
* @param planName - Name of the plan (e.g., "2025_10_06_customer_order_processing")
|
|
61
|
+
* @param content - Plan content to write
|
|
62
|
+
* @param projectRoot - Root directory of the project
|
|
63
|
+
* @returns Full path to the created PLAN.md file
|
|
64
|
+
*/
|
|
65
|
+
export async function writePlanFile(planName, content, projectRoot) {
|
|
66
|
+
const planDir = path.join(projectRoot, AGENT_CONFIG_DIR, 'plans', planName);
|
|
67
|
+
const planFilePath = path.join(planDir, 'PLAN.md');
|
|
68
|
+
await fs.mkdir(planDir, { recursive: true });
|
|
69
|
+
await fs.writeFile(planFilePath, content, 'utf-8');
|
|
70
|
+
return planFilePath;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Update agent templates by invoking agents init with force flag
|
|
74
|
+
* This recreates all agent configuration files, overwriting existing ones
|
|
75
|
+
* @param projectRoot - Root directory of the project
|
|
76
|
+
*/
|
|
77
|
+
export async function updateAgentTemplates(projectRoot) {
|
|
78
|
+
await initializeAgentConfig({
|
|
79
|
+
projectRoot,
|
|
80
|
+
force: true,
|
|
81
|
+
agentProvider: 'claude-code'
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { ensureOutputAIStructure, generatePlanName, writePlanFile, updateAgentTemplates } from './workflow_planner.js';
|
|
3
|
+
import { checkAgentStructure, initializeAgentConfig } from './coding_agents.js';
|
|
4
|
+
import { generateText } from 'local_llm';
|
|
5
|
+
import { loadPrompt } from 'local_prompt';
|
|
6
|
+
import { confirm } from '@inquirer/prompts';
|
|
7
|
+
import fs from 'node:fs/promises';
|
|
8
|
+
vi.mock('./coding_agents.js');
|
|
9
|
+
vi.mock('local_llm');
|
|
10
|
+
vi.mock('local_prompt');
|
|
11
|
+
vi.mock('@inquirer/prompts');
|
|
12
|
+
vi.mock('node:fs/promises');
|
|
13
|
+
describe('workflow-planner service', () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.clearAllMocks();
|
|
16
|
+
});
|
|
17
|
+
describe('ensureOutputAIStructure', () => {
|
|
18
|
+
it('should call agents init when .outputai does not exist', async () => {
|
|
19
|
+
vi.mocked(checkAgentStructure).mockResolvedValue({
|
|
20
|
+
dirExists: false,
|
|
21
|
+
missingFiles: [
|
|
22
|
+
'.outputai/AGENTS.md',
|
|
23
|
+
'.outputai/agents/workflow_planner.md',
|
|
24
|
+
'.outputai/commands/plan_workflow.md',
|
|
25
|
+
'CLAUDE.md',
|
|
26
|
+
'.claude/agents/workflow_planner.md',
|
|
27
|
+
'.claude/commands/plan_workflow.md'
|
|
28
|
+
],
|
|
29
|
+
isComplete: false
|
|
30
|
+
});
|
|
31
|
+
vi.mocked(initializeAgentConfig).mockResolvedValue();
|
|
32
|
+
await ensureOutputAIStructure('/test/project');
|
|
33
|
+
expect(checkAgentStructure).toHaveBeenCalledWith('/test/project');
|
|
34
|
+
expect(initializeAgentConfig).toHaveBeenCalledWith(expect.objectContaining({
|
|
35
|
+
projectRoot: expect.any(String),
|
|
36
|
+
force: false,
|
|
37
|
+
agentProvider: 'claude-code'
|
|
38
|
+
}));
|
|
39
|
+
});
|
|
40
|
+
it('should skip initialization if .outputai exists', async () => {
|
|
41
|
+
vi.mocked(checkAgentStructure).mockResolvedValue({
|
|
42
|
+
dirExists: true,
|
|
43
|
+
missingFiles: [],
|
|
44
|
+
isComplete: true
|
|
45
|
+
});
|
|
46
|
+
await ensureOutputAIStructure('/test/project');
|
|
47
|
+
expect(checkAgentStructure).toHaveBeenCalledWith('/test/project');
|
|
48
|
+
expect(initializeAgentConfig).not.toHaveBeenCalled();
|
|
49
|
+
});
|
|
50
|
+
it('should throw error if agents init fails', async () => {
|
|
51
|
+
vi.mocked(checkAgentStructure).mockResolvedValue({
|
|
52
|
+
dirExists: false,
|
|
53
|
+
missingFiles: [
|
|
54
|
+
'.outputai/AGENTS.md',
|
|
55
|
+
'.outputai/agents/workflow_planner.md',
|
|
56
|
+
'.outputai/commands/plan_workflow.md',
|
|
57
|
+
'CLAUDE.md',
|
|
58
|
+
'.claude/agents/workflow_planner.md',
|
|
59
|
+
'.claude/commands/plan_workflow.md'
|
|
60
|
+
],
|
|
61
|
+
isComplete: false
|
|
62
|
+
});
|
|
63
|
+
vi.mocked(initializeAgentConfig).mockRejectedValue(new Error('Init command failed'));
|
|
64
|
+
await expect(ensureOutputAIStructure('/test/project')).rejects.toThrow('Init command failed');
|
|
65
|
+
});
|
|
66
|
+
it('should prompt user when some files are missing', async () => {
|
|
67
|
+
vi.mocked(checkAgentStructure).mockResolvedValue({
|
|
68
|
+
dirExists: true,
|
|
69
|
+
missingFiles: ['.outputai/agents/workflow_planner.md'],
|
|
70
|
+
isComplete: false
|
|
71
|
+
});
|
|
72
|
+
vi.mocked(confirm).mockResolvedValue(true);
|
|
73
|
+
vi.mocked(initializeAgentConfig).mockResolvedValue();
|
|
74
|
+
await ensureOutputAIStructure('/test/project');
|
|
75
|
+
expect(confirm).toHaveBeenCalled();
|
|
76
|
+
expect(initializeAgentConfig).toHaveBeenCalledWith(expect.objectContaining({
|
|
77
|
+
force: true
|
|
78
|
+
}));
|
|
79
|
+
});
|
|
80
|
+
it('should throw error when user declines partial init', async () => {
|
|
81
|
+
vi.mocked(checkAgentStructure).mockResolvedValue({
|
|
82
|
+
dirExists: true,
|
|
83
|
+
missingFiles: ['.outputai/agents/workflow_planner.md'],
|
|
84
|
+
isComplete: false
|
|
85
|
+
});
|
|
86
|
+
vi.mocked(confirm).mockResolvedValue(false);
|
|
87
|
+
await expect(ensureOutputAIStructure('/test/project')).rejects.toThrow('Agent configuration incomplete');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe('generatePlanName', () => {
|
|
91
|
+
it('should generate plan name with date prefix using LLM', async () => {
|
|
92
|
+
const mockPrompt = {
|
|
93
|
+
config: { model: 'claude-sonnet-4-20250514', provider: 'anthropic' },
|
|
94
|
+
messages: [
|
|
95
|
+
{ role: 'system', content: 'You are a technical writing assistant...' },
|
|
96
|
+
{ role: 'user', content: 'Generate a snake_case plan name...' }
|
|
97
|
+
]
|
|
98
|
+
};
|
|
99
|
+
vi.mocked(loadPrompt).mockReturnValue(mockPrompt);
|
|
100
|
+
vi.mocked(generateText).mockResolvedValue('customer_order_processing');
|
|
101
|
+
const testDate = new Date(2025, 9, 6);
|
|
102
|
+
const planName = await generatePlanName('A workflow that processes customer orders', testDate);
|
|
103
|
+
expect(planName).toMatch(/^2025_10_06_/);
|
|
104
|
+
expect(planName).toBe('2025_10_06_customer_order_processing');
|
|
105
|
+
expect(loadPrompt).toHaveBeenCalledWith('generate_plan_name@v1', { description: 'A workflow that processes customer orders' });
|
|
106
|
+
expect(generateText).toHaveBeenCalledWith({ prompt: mockPrompt });
|
|
107
|
+
});
|
|
108
|
+
it('should clean and validate LLM response', async () => {
|
|
109
|
+
const mockPrompt = { config: { provider: 'anthropic', model: 'claude-sonnet-4-20250514' }, messages: [] };
|
|
110
|
+
vi.mocked(loadPrompt).mockReturnValue(mockPrompt);
|
|
111
|
+
vi.mocked(generateText).mockResolvedValue(' User-Auth & Security!@# ');
|
|
112
|
+
const testDate = new Date(2025, 9, 6);
|
|
113
|
+
const planName = await generatePlanName('User authentication workflow', testDate);
|
|
114
|
+
expect(planName).toBe('2025_10_06_user_auth_security');
|
|
115
|
+
expect(planName).toMatch(/^[0-9_a-z]+$/);
|
|
116
|
+
});
|
|
117
|
+
it('should handle LLM errors gracefully', async () => {
|
|
118
|
+
const mockPrompt = { config: { provider: 'anthropic', model: 'claude-sonnet-4-20250514' }, messages: [] };
|
|
119
|
+
vi.mocked(loadPrompt).mockReturnValue(mockPrompt);
|
|
120
|
+
vi.mocked(generateText).mockRejectedValue(new Error('API rate limit exceeded'));
|
|
121
|
+
await expect(generatePlanName('Test workflow')).rejects.toThrow('API rate limit exceeded');
|
|
122
|
+
});
|
|
123
|
+
it('should limit plan name length to 50 characters', async () => {
|
|
124
|
+
const mockPrompt = { config: { provider: 'anthropic', model: 'claude-sonnet-4-20250514' }, messages: [] };
|
|
125
|
+
vi.mocked(loadPrompt).mockReturnValue(mockPrompt);
|
|
126
|
+
vi.mocked(generateText).mockResolvedValue('this_is_an_extremely_long_plan_name_that_exceeds_the_maximum_allowed_length_for_file_names');
|
|
127
|
+
const testDate = new Date(2025, 9, 6);
|
|
128
|
+
const planName = await generatePlanName('Long workflow description', testDate);
|
|
129
|
+
const namePart = planName.replace(/^2025_10_06_/, '');
|
|
130
|
+
expect(namePart.length).toBeLessThanOrEqual(50);
|
|
131
|
+
});
|
|
132
|
+
it('should handle multiple underscores correctly', async () => {
|
|
133
|
+
const mockPrompt = { config: { provider: 'anthropic', model: 'claude-sonnet-4-20250514' }, messages: [] };
|
|
134
|
+
vi.mocked(loadPrompt).mockReturnValue(mockPrompt);
|
|
135
|
+
vi.mocked(generateText).mockResolvedValue('user___auth___workflow');
|
|
136
|
+
const testDate = new Date(2025, 9, 6);
|
|
137
|
+
const planName = await generatePlanName('Test', testDate);
|
|
138
|
+
expect(planName).toBe('2025_10_06_user_auth_workflow');
|
|
139
|
+
expect(planName).not.toContain('__');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
describe('writePlanFile', () => {
|
|
143
|
+
it('should create plan directory and write PLAN.md', async () => {
|
|
144
|
+
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
|
145
|
+
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
|
146
|
+
const planName = '2025_10_06_test_plan';
|
|
147
|
+
const content = '# Test Plan\n\nThis is a test plan.';
|
|
148
|
+
const projectRoot = '/test/project';
|
|
149
|
+
const planPath = await writePlanFile(planName, content, projectRoot);
|
|
150
|
+
expect(fs.mkdir).toHaveBeenCalledWith('/test/project/.outputai/plans/2025_10_06_test_plan', { recursive: true });
|
|
151
|
+
expect(fs.writeFile).toHaveBeenCalledWith('/test/project/.outputai/plans/2025_10_06_test_plan/PLAN.md', content, 'utf-8');
|
|
152
|
+
expect(planPath).toBe('/test/project/.outputai/plans/2025_10_06_test_plan/PLAN.md');
|
|
153
|
+
});
|
|
154
|
+
it('should return the plan file path', async () => {
|
|
155
|
+
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
|
156
|
+
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
|
157
|
+
const planPath = await writePlanFile('test_plan', 'content', '/root');
|
|
158
|
+
expect(planPath).toBe('/root/.outputai/plans/test_plan/PLAN.md');
|
|
159
|
+
});
|
|
160
|
+
it('should handle nested directory creation', async () => {
|
|
161
|
+
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
|
162
|
+
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
|
163
|
+
await writePlanFile('2025_10_06_nested_plan', 'content', '/deep/nested/path');
|
|
164
|
+
expect(fs.mkdir).toHaveBeenCalledWith('/deep/nested/path/.outputai/plans/2025_10_06_nested_plan', { recursive: true });
|
|
165
|
+
});
|
|
166
|
+
it('should handle UTF-8 content with special characters', async () => {
|
|
167
|
+
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
|
168
|
+
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
|
169
|
+
const content = '# Plan 🎯\n\n中文 • Español • العربية\n\n"Smart quotes" and—dashes';
|
|
170
|
+
await writePlanFile('unicode_test', content, '/test');
|
|
171
|
+
expect(fs.writeFile).toHaveBeenCalledWith(expect.any(String), content, 'utf-8');
|
|
172
|
+
});
|
|
173
|
+
it('should throw error when directory creation fails', async () => {
|
|
174
|
+
vi.mocked(fs.mkdir).mockRejectedValue(new Error('Permission denied'));
|
|
175
|
+
await expect(writePlanFile('test', 'content', '/root'))
|
|
176
|
+
.rejects.toThrow('Permission denied');
|
|
177
|
+
});
|
|
178
|
+
it('should throw error when file writing fails', async () => {
|
|
179
|
+
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
|
180
|
+
vi.mocked(fs.writeFile).mockRejectedValue(new Error('Disk full'));
|
|
181
|
+
await expect(writePlanFile('test', 'content', '/root'))
|
|
182
|
+
.rejects.toThrow('Disk full');
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
describe('updateAgentTemplates', () => {
|
|
186
|
+
it('should invoke agents init with force flag', async () => {
|
|
187
|
+
vi.mocked(initializeAgentConfig).mockResolvedValue();
|
|
188
|
+
await updateAgentTemplates('/test/project');
|
|
189
|
+
expect(initializeAgentConfig).toHaveBeenCalledWith(expect.objectContaining({
|
|
190
|
+
projectRoot: '/test/project',
|
|
191
|
+
force: true,
|
|
192
|
+
agentProvider: 'claude-code'
|
|
193
|
+
}));
|
|
194
|
+
});
|
|
195
|
+
it('should propagate errors from agents init', async () => {
|
|
196
|
+
vi.mocked(initializeAgentConfig).mockRejectedValue(new Error('Failed to write templates'));
|
|
197
|
+
await expect(updateAgentTemplates('/test/project'))
|
|
198
|
+
.rejects.toThrow('Failed to write templates');
|
|
199
|
+
});
|
|
200
|
+
it('should work with different project roots', async () => {
|
|
201
|
+
vi.mocked(initializeAgentConfig).mockResolvedValue();
|
|
202
|
+
await updateAgentTemplates('/different/path');
|
|
203
|
+
expect(initializeAgentConfig).toHaveBeenCalledWith(expect.objectContaining({
|
|
204
|
+
projectRoot: '/different/path'
|
|
205
|
+
}));
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
});
|