@output.ai/cli 0.0.3 → 0.0.5

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.
@@ -0,0 +1,141 @@
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
2
+ import { invokePlanWorkflow, ClaudeInvocationError } from './claude_client.js';
3
+ // Mock Claude SDK
4
+ vi.mock('@anthropic-ai/claude-agent-sdk', () => ({
5
+ query: vi.fn()
6
+ }));
7
+ describe('invokePlanWorkflow', () => {
8
+ beforeEach(() => {
9
+ vi.clearAllMocks();
10
+ });
11
+ afterEach(() => {
12
+ // Clean up environment variables
13
+ delete process.env.ANTHROPIC_API_KEY;
14
+ });
15
+ it('should invoke /plan_workflow slash command with settingSources', async () => {
16
+ const { query } = await import('@anthropic-ai/claude-agent-sdk');
17
+ process.env.ANTHROPIC_API_KEY = 'test-key';
18
+ async function* mockIterator() {
19
+ yield { type: 'result', subtype: 'success', result: '# Test Plan\n\nTest plan content' };
20
+ }
21
+ vi.mocked(query).mockReturnValue(mockIterator());
22
+ await invokePlanWorkflow('Test workflow');
23
+ const calls = vi.mocked(query).mock.calls;
24
+ expect(calls[0]?.[0]?.prompt).toContain('/plan_workflow Test workflow');
25
+ expect(calls[0]?.[0]?.options?.settingSources).toEqual(['user', 'project', 'local']);
26
+ expect(calls[0]?.[0]?.options?.allowedTools).toEqual(['Read', 'Grep', 'WebSearch', 'WebFetch', 'TodoWrite']);
27
+ });
28
+ it('should pass workflow description to slash command', async () => {
29
+ const { query } = await import('@anthropic-ai/claude-agent-sdk');
30
+ process.env.ANTHROPIC_API_KEY = 'test-key';
31
+ async function* mockIterator() {
32
+ yield { type: 'result', subtype: 'success', result: '# Plan\n\nContent' };
33
+ }
34
+ vi.mocked(query).mockReturnValue(mockIterator());
35
+ const description = 'Build a user authentication system';
36
+ await invokePlanWorkflow(description);
37
+ const calls = vi.mocked(query).mock.calls;
38
+ expect(calls[0]?.[0]?.prompt).toContain(`/plan_workflow ${description}`);
39
+ expect(calls[0]?.[0]?.options?.settingSources).toEqual(['user', 'project', 'local']);
40
+ });
41
+ it('should return plan output from claude-code', async () => {
42
+ const { query } = await import('@anthropic-ai/claude-agent-sdk');
43
+ process.env.ANTHROPIC_API_KEY = 'test-key';
44
+ const expectedOutput = '# Workflow Plan\n\nDetailed plan content here';
45
+ async function* mockIterator() {
46
+ yield { type: 'result', subtype: 'success', result: expectedOutput };
47
+ }
48
+ vi.mocked(query).mockReturnValue(mockIterator());
49
+ const result = await invokePlanWorkflow('Test');
50
+ expect(result).toBe(expectedOutput);
51
+ });
52
+ it('should throw error if API key is missing', async () => {
53
+ delete process.env.ANTHROPIC_API_KEY;
54
+ await expect(invokePlanWorkflow('Test'))
55
+ .rejects.toThrow('ANTHROPIC_API_KEY');
56
+ });
57
+ describe('error handling', () => {
58
+ it('should throw ClaudeInvocationError on API failures', async () => {
59
+ const { query } = await import('@anthropic-ai/claude-agent-sdk');
60
+ process.env.ANTHROPIC_API_KEY = 'test-key';
61
+ const apiError = new Error('API connection failed');
62
+ vi.mocked(query).mockRejectedValue(apiError);
63
+ await expect(invokePlanWorkflow('Test'))
64
+ .rejects.toThrow(ClaudeInvocationError);
65
+ });
66
+ it('should preserve original error in cause property', async () => {
67
+ const { query } = await import('@anthropic-ai/claude-agent-sdk');
68
+ process.env.ANTHROPIC_API_KEY = 'test-key';
69
+ const originalError = new Error('Network timeout');
70
+ // Mock async iterator that throws
71
+ async function* mockIterator() {
72
+ throw originalError;
73
+ }
74
+ vi.mocked(query).mockReturnValue(mockIterator());
75
+ try {
76
+ await invokePlanWorkflow('Test');
77
+ // If we get here, the test should fail
78
+ expect.fail('Should have thrown an error');
79
+ }
80
+ catch (error) {
81
+ expect(error).toBeInstanceOf(ClaudeInvocationError);
82
+ expect(error.cause).toBe(originalError);
83
+ }
84
+ });
85
+ it('should handle rate limit errors', async () => {
86
+ const { query } = await import('@anthropic-ai/claude-agent-sdk');
87
+ process.env.ANTHROPIC_API_KEY = 'test-key';
88
+ const rateLimitError = new Error('Rate limit exceeded');
89
+ rateLimitError.status = 429;
90
+ vi.mocked(query).mockRejectedValue(rateLimitError);
91
+ await expect(invokePlanWorkflow('Test'))
92
+ .rejects.toThrow(ClaudeInvocationError);
93
+ });
94
+ it('should handle authentication errors', async () => {
95
+ const { query } = await import('@anthropic-ai/claude-agent-sdk');
96
+ process.env.ANTHROPIC_API_KEY = 'invalid-key';
97
+ const authError = new Error('Invalid API key');
98
+ authError.status = 401;
99
+ vi.mocked(query).mockRejectedValue(authError);
100
+ await expect(invokePlanWorkflow('Test'))
101
+ .rejects.toThrow(ClaudeInvocationError);
102
+ });
103
+ it('should provide user-friendly error messages', async () => {
104
+ const { query } = await import('@anthropic-ai/claude-agent-sdk');
105
+ process.env.ANTHROPIC_API_KEY = 'test-key';
106
+ vi.mocked(query).mockRejectedValue(new Error('API error'));
107
+ try {
108
+ await invokePlanWorkflow('Test');
109
+ }
110
+ catch (error) {
111
+ expect(error.message).toMatch(/Failed to invoke/i);
112
+ }
113
+ });
114
+ it('should throw error when no result received', async () => {
115
+ const { query } = await import('@anthropic-ai/claude-agent-sdk');
116
+ process.env.ANTHROPIC_API_KEY = 'test-key';
117
+ // Mock iterator that yields no result messages
118
+ async function* mockIterator() {
119
+ yield { type: 'assistant', message: { content: [] } };
120
+ }
121
+ vi.mocked(query).mockReturnValue(mockIterator());
122
+ await expect(invokePlanWorkflow('Test'))
123
+ .rejects.toThrow(ClaudeInvocationError);
124
+ });
125
+ });
126
+ });
127
+ describe('ClaudeInvocationError', () => {
128
+ it('should be instance of Error', () => {
129
+ const error = new ClaudeInvocationError('test message');
130
+ expect(error).toBeInstanceOf(Error);
131
+ });
132
+ it('should have correct name property', () => {
133
+ const error = new ClaudeInvocationError('test message');
134
+ expect(error.name).toBe('ClaudeInvocationError');
135
+ });
136
+ it('should store cause error', () => {
137
+ const causeError = new Error('original error');
138
+ const error = new ClaudeInvocationError('test message', causeError);
139
+ expect(error.cause).toBe(causeError);
140
+ });
141
+ });
@@ -0,0 +1,43 @@
1
+ export interface FileMapping {
2
+ from: string;
3
+ to: string;
4
+ type: 'template' | 'symlink' | 'copy';
5
+ }
6
+ export interface AgentSystemConfig {
7
+ id: string;
8
+ name: string;
9
+ mappings: FileMapping[];
10
+ }
11
+ export interface StructureCheckResult {
12
+ dirExists: boolean;
13
+ missingFiles: string[];
14
+ isComplete: boolean;
15
+ }
16
+ export interface InitOptions {
17
+ projectRoot: string;
18
+ force: boolean;
19
+ agentProvider: string;
20
+ }
21
+ /**
22
+ * Agent configuration mappings for different providers
23
+ */
24
+ export declare const AGENT_CONFIGS: Record<string, AgentSystemConfig>;
25
+ export declare function getRequiredFiles(): string[];
26
+ /**
27
+ * Get the full path to the agent configuration directory
28
+ */
29
+ export declare function getAgentConfigDir(projectRoot: string): string;
30
+ /**
31
+ * Check if .outputai directory exists
32
+ */
33
+ export declare function checkAgentConfigDirExists(projectRoot: string): Promise<boolean>;
34
+ export declare function checkAgentStructure(projectRoot: string): Promise<StructureCheckResult>;
35
+ /**
36
+ * Prepare template variables for file generation
37
+ */
38
+ export declare function prepareTemplateVariables(): Record<string, string>;
39
+ /**
40
+ * Initialize agent configuration files
41
+ * Main entry point for agent initialization logic
42
+ */
43
+ export declare function initializeAgentConfig(options: InitOptions): Promise<void>;
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Coding agent configuration service
3
+ * Handles initialization and validation of agent configuration files
4
+ */
5
+ import fs from 'node:fs/promises';
6
+ import { access } from 'node:fs/promises';
7
+ import path from 'node:path';
8
+ import { join } from 'node:path';
9
+ import { ux } from '@oclif/core';
10
+ import { AGENT_CONFIG_DIR } from '../config.js';
11
+ import { getTemplateDir } from '../utils/paths.js';
12
+ import { processTemplate } from '../utils/template.js';
13
+ /**
14
+ * Agent configuration mappings for different providers
15
+ */
16
+ export const AGENT_CONFIGS = {
17
+ outputai: {
18
+ id: 'outputai',
19
+ name: 'OutputAI Core Files',
20
+ mappings: [
21
+ {
22
+ type: 'template',
23
+ from: 'AGENTS.md.template',
24
+ to: `${AGENT_CONFIG_DIR}/AGENTS.md`
25
+ },
26
+ {
27
+ type: 'template',
28
+ from: 'agents/workflow_planner.md.template',
29
+ to: `${AGENT_CONFIG_DIR}/agents/workflow_planner.md`
30
+ },
31
+ {
32
+ type: 'template',
33
+ from: 'commands/plan_workflow.md.template',
34
+ to: `${AGENT_CONFIG_DIR}/commands/plan_workflow.md`
35
+ },
36
+ {
37
+ type: 'template',
38
+ from: 'meta/pre_flight.md.template',
39
+ to: '.outputai/meta/pre_flight.md'
40
+ },
41
+ {
42
+ type: 'template',
43
+ from: 'meta/post_flight.md.template',
44
+ to: '.outputai/meta/post_flight.md'
45
+ }
46
+ ]
47
+ },
48
+ 'claude-code': {
49
+ id: 'claude-code',
50
+ name: 'Claude Code',
51
+ mappings: [
52
+ {
53
+ type: 'symlink',
54
+ from: `${AGENT_CONFIG_DIR}/AGENTS.md`,
55
+ to: 'CLAUDE.md'
56
+ },
57
+ {
58
+ type: 'symlink',
59
+ from: `${AGENT_CONFIG_DIR}/agents/workflow_planner.md`,
60
+ to: '.claude/agents/workflow_planner.md'
61
+ },
62
+ {
63
+ type: 'symlink',
64
+ from: `${AGENT_CONFIG_DIR}/commands/plan_workflow.md`,
65
+ to: '.claude/commands/plan_workflow.md'
66
+ }
67
+ ]
68
+ }
69
+ };
70
+ export function getRequiredFiles() {
71
+ const outputaiFiles = AGENT_CONFIGS.outputai.mappings.map(m => m.to);
72
+ const claudeCodeFiles = AGENT_CONFIGS['claude-code'].mappings.map(m => m.to);
73
+ return [...outputaiFiles, ...claudeCodeFiles];
74
+ }
75
+ /**
76
+ * Get the full path to the agent configuration directory
77
+ */
78
+ export function getAgentConfigDir(projectRoot) {
79
+ return join(projectRoot, AGENT_CONFIG_DIR);
80
+ }
81
+ /**
82
+ * Check if .outputai directory exists
83
+ */
84
+ export async function checkAgentConfigDirExists(projectRoot) {
85
+ const agentConfigDir = getAgentConfigDir(projectRoot);
86
+ try {
87
+ await access(agentConfigDir);
88
+ return true;
89
+ }
90
+ catch {
91
+ return false;
92
+ }
93
+ }
94
+ async function fileExists(filePath) {
95
+ try {
96
+ await access(filePath);
97
+ return true;
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ }
103
+ export async function checkAgentStructure(projectRoot) {
104
+ const requiredFiles = getRequiredFiles();
105
+ const dirExists = await checkAgentConfigDirExists(projectRoot);
106
+ if (!dirExists) {
107
+ return {
108
+ dirExists: false,
109
+ missingFiles: requiredFiles,
110
+ isComplete: false
111
+ };
112
+ }
113
+ const missingChecks = await Promise.all(requiredFiles.map(async (file) => ({
114
+ file,
115
+ exists: await fileExists(join(projectRoot, file))
116
+ })));
117
+ const missingFiles = missingChecks
118
+ .filter(check => !check.exists)
119
+ .map(check => check.file);
120
+ return {
121
+ dirExists: true,
122
+ missingFiles,
123
+ isComplete: missingFiles.length === 0
124
+ };
125
+ }
126
+ /**
127
+ * Prepare template variables for file generation
128
+ */
129
+ export function prepareTemplateVariables() {
130
+ return {
131
+ date: new Date().toLocaleDateString('en-US', {
132
+ year: 'numeric',
133
+ month: 'long',
134
+ day: 'numeric'
135
+ })
136
+ };
137
+ }
138
+ /**
139
+ * Ensure a directory exists, creating it if necessary
140
+ */
141
+ async function ensureDirectoryExists(dir) {
142
+ try {
143
+ await fs.mkdir(dir, { recursive: true });
144
+ ux.stdout(ux.colorize('gray', `Created directory: ${dir}`));
145
+ }
146
+ catch (error) {
147
+ if (error.code !== 'EEXIST') {
148
+ throw error;
149
+ }
150
+ }
151
+ }
152
+ /**
153
+ * Create a file from a template
154
+ */
155
+ async function createFromTemplate(template, output, variables) {
156
+ const templateDir = getTemplateDir('agent_instructions');
157
+ const templatePath = path.join(templateDir, template);
158
+ const content = await fs.readFile(templatePath, 'utf-8');
159
+ const processed = processTemplate(content, variables);
160
+ await fs.writeFile(output, processed, 'utf-8');
161
+ ux.stdout(ux.colorize('gray', `Created from template: ${output}`));
162
+ }
163
+ /**
164
+ * Create a symlink, falling back to copying if symlinks are not supported
165
+ */
166
+ async function createSymlink(source, target) {
167
+ try {
168
+ if (await fileExists(target)) {
169
+ await fs.unlink(target);
170
+ }
171
+ const relativePath = path.relative(path.dirname(target), source);
172
+ await fs.symlink(relativePath, target);
173
+ ux.stdout(ux.colorize('gray', `Created symlink: ${target} -> ${source}`));
174
+ }
175
+ catch (error) {
176
+ const code = error.code;
177
+ if (code === 'ENOTSUP' || code === 'EPERM') {
178
+ ux.stdout(ux.colorize('gray', `Symlinks not supported, creating copy: ${target}`));
179
+ const content = await fs.readFile(source, 'utf-8');
180
+ await fs.writeFile(target, content, 'utf-8');
181
+ return;
182
+ }
183
+ throw error;
184
+ }
185
+ }
186
+ /**
187
+ * Copy a file from source to target
188
+ */
189
+ async function copyFile(source, target) {
190
+ const content = await fs.readFile(source, 'utf-8');
191
+ await fs.writeFile(target, content, 'utf-8');
192
+ ux.stdout(ux.colorize('gray', `Copied file: ${source} -> ${target}`));
193
+ }
194
+ /**
195
+ * Process file mappings for a specific agent configuration
196
+ */
197
+ async function processMappings(config, variables, force, projectRoot) {
198
+ for (const mapping of config.mappings) {
199
+ const fullPath = path.isAbsolute(mapping.to) ?
200
+ mapping.to :
201
+ path.join(projectRoot, mapping.to);
202
+ const dir = path.dirname(fullPath);
203
+ await ensureDirectoryExists(dir);
204
+ if (!force && await fileExists(fullPath)) {
205
+ ux.warn(`File already exists: ${mapping.to} (use --force to overwrite)`);
206
+ continue;
207
+ }
208
+ switch (mapping.type) {
209
+ case 'template':
210
+ await createFromTemplate(mapping.from, fullPath, variables);
211
+ break;
212
+ case 'symlink':
213
+ await createSymlink(mapping.from, fullPath);
214
+ break;
215
+ case 'copy':
216
+ await copyFile(mapping.from, fullPath);
217
+ break;
218
+ }
219
+ }
220
+ }
221
+ /**
222
+ * Initialize agent configuration files
223
+ * Main entry point for agent initialization logic
224
+ */
225
+ export async function initializeAgentConfig(options) {
226
+ const { projectRoot, force, agentProvider } = options;
227
+ const variables = prepareTemplateVariables();
228
+ await processMappings(AGENT_CONFIGS.outputai, variables, force, projectRoot);
229
+ await processMappings(AGENT_CONFIGS[agentProvider], variables, force, projectRoot);
230
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -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
+ });