@output.ai/cli 0.0.4 → 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.
- package/dist/commands/agents/init.d.ts +0 -7
- package/dist/commands/agents/init.js +8 -145
- package/dist/commands/agents/init.spec.js +29 -24
- 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/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 +124 -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/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/package.json +9 -3
|
@@ -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
|
+
});
|