@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.
Files changed (50) hide show
  1. package/dist/api/http_client.js +1 -1
  2. package/dist/commands/agents/init.d.ts +2 -9
  3. package/dist/commands/agents/init.js +8 -145
  4. package/dist/commands/agents/init.spec.js +31 -26
  5. package/dist/commands/workflow/generate.d.ts +5 -5
  6. package/dist/commands/workflow/generate.js +2 -2
  7. package/dist/commands/workflow/generate.spec.js +2 -2
  8. package/dist/commands/workflow/list.d.ts +4 -4
  9. package/dist/commands/workflow/list.js +3 -3
  10. package/dist/commands/workflow/output.d.ts +2 -2
  11. package/dist/commands/workflow/output.js +4 -4
  12. package/dist/commands/workflow/plan.d.ts +12 -0
  13. package/dist/commands/workflow/plan.js +65 -0
  14. package/dist/commands/workflow/plan.spec.d.ts +1 -0
  15. package/dist/commands/workflow/plan.spec.js +339 -0
  16. package/dist/commands/workflow/run.d.ts +4 -4
  17. package/dist/commands/workflow/run.js +5 -5
  18. package/dist/commands/workflow/start.d.ts +3 -3
  19. package/dist/commands/workflow/start.js +3 -3
  20. package/dist/commands/workflow/status.d.ts +2 -2
  21. package/dist/commands/workflow/status.js +4 -4
  22. package/dist/commands/workflow/stop.d.ts +1 -1
  23. package/dist/commands/workflow/stop.js +2 -2
  24. package/dist/config.d.ts +4 -0
  25. package/dist/config.js +4 -0
  26. package/dist/services/claude_client.d.ts +13 -0
  27. package/dist/services/claude_client.integration.test.d.ts +1 -0
  28. package/dist/services/claude_client.integration.test.js +43 -0
  29. package/dist/services/claude_client.js +155 -0
  30. package/dist/services/claude_client.spec.d.ts +1 -0
  31. package/dist/services/claude_client.spec.js +141 -0
  32. package/dist/services/coding_agents.d.ts +43 -0
  33. package/dist/services/coding_agents.js +230 -0
  34. package/dist/services/coding_agents.spec.d.ts +1 -0
  35. package/dist/services/coding_agents.spec.js +254 -0
  36. package/dist/services/generate_plan_name@v1.prompt +24 -0
  37. package/dist/services/template_processor.d.ts +1 -1
  38. package/dist/services/template_processor.js +1 -1
  39. package/dist/services/workflow_generator.d.ts +1 -1
  40. package/dist/services/workflow_generator.js +4 -4
  41. package/dist/services/workflow_planner.d.ts +20 -0
  42. package/dist/services/workflow_planner.js +83 -0
  43. package/dist/services/workflow_planner.spec.d.ts +1 -0
  44. package/dist/services/workflow_planner.spec.js +208 -0
  45. package/dist/templates/agent_instructions/commands/plan_workflow.md.template +180 -386
  46. package/dist/test_helpers/mocks.d.ts +37 -0
  47. package/dist/test_helpers/mocks.js +67 -0
  48. package/dist/utils/error_handler.js +1 -1
  49. package/dist/utils/validation.js +1 -1
  50. 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,4 +1,4 @@
1
- import type { TemplateFile } from '../types/generator.js';
1
+ import type { TemplateFile } from '#types/generator.js';
2
2
  /**
3
3
  * Get list of template files from a directory
4
4
  * Automatically discovers all .template files and derives output names
@@ -1,6 +1,6 @@
1
1
  import * as fs from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
- import { processTemplate } from '../utils/template.js';
3
+ import { processTemplate } from '#utils/template.js';
4
4
  const TEMPLATE_EXTENSION = '.template';
5
5
  function isTemplateFile(file) {
6
6
  return file.endsWith(TEMPLATE_EXTENSION);
@@ -1,4 +1,4 @@
1
- import type { WorkflowGenerationConfig, WorkflowGenerationResult } from '../types/generator.js';
1
+ import type { WorkflowGenerationConfig, WorkflowGenerationResult } from '#types/generator.js';
2
2
  /**
3
3
  * Generate a new workflow
4
4
  */
@@ -1,8 +1,8 @@
1
1
  import * as fs from 'node:fs/promises';
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';
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
+ });