@output.ai/cli 0.0.0 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -2
- package/dist/api/generated/api.d.ts +272 -0
- package/dist/api/generated/api.js +131 -0
- package/dist/api/http_client.d.ts +6 -0
- package/dist/api/http_client.js +41 -0
- package/dist/api/orval_post_process.d.ts +10 -0
- package/dist/api/orval_post_process.js +33 -0
- package/dist/api/parser.d.ts +17 -0
- package/dist/api/parser.js +71 -0
- package/dist/commands/agents/init.d.ts +18 -0
- package/dist/commands/agents/init.js +175 -0
- package/dist/commands/agents/init.spec.d.ts +1 -0
- package/dist/commands/agents/init.spec.js +227 -0
- package/dist/commands/workflow/generate.js +1 -1
- package/dist/commands/workflow/list.d.ts +21 -0
- package/dist/commands/workflow/list.js +169 -0
- package/dist/commands/workflow/list.test.d.ts +1 -0
- package/dist/commands/workflow/list.test.js +83 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.js +19 -0
- package/dist/templates/agent_instructions/AGENTS.md.template +30 -0
- package/dist/templates/agent_instructions/agents/workflow_planner.md.template +104 -0
- package/dist/templates/agent_instructions/commands/plan_workflow.md.template +466 -0
- package/dist/templates/agent_instructions/meta/post_flight.md.template +94 -0
- package/dist/templates/agent_instructions/meta/pre_flight.md.template +60 -0
- package/dist/templates/workflow/README.md.template +6 -6
- package/dist/templates/workflow/workflow.ts.template +2 -2
- package/dist/utils/paths.d.ts +5 -0
- package/dist/utils/paths.js +8 -1
- package/package.json +19 -4
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
export default class Init extends Command {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static args: {};
|
|
6
|
+
static flags: {
|
|
7
|
+
'agent-provider': import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
8
|
+
force: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
|
|
9
|
+
};
|
|
10
|
+
run(): Promise<void>;
|
|
11
|
+
private prepareTemplateVariables;
|
|
12
|
+
private processMappings;
|
|
13
|
+
private ensureDirectoryExists;
|
|
14
|
+
private fileExists;
|
|
15
|
+
private createFromTemplate;
|
|
16
|
+
private createSymlink;
|
|
17
|
+
private copyFile;
|
|
18
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { Command, Flags } from '@oclif/core';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { getTemplateDir } from '../../utils/paths.js';
|
|
5
|
+
import { processTemplate } from '../../utils/template.js';
|
|
6
|
+
const AGENT_CONFIGS = {
|
|
7
|
+
outputai: {
|
|
8
|
+
id: 'outputai',
|
|
9
|
+
name: 'OutputAI Core Files',
|
|
10
|
+
mappings: [
|
|
11
|
+
{
|
|
12
|
+
type: 'template',
|
|
13
|
+
from: 'AGENTS.md.template',
|
|
14
|
+
to: '.outputai/AGENTS.md'
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
type: 'template',
|
|
18
|
+
from: 'agents/workflow_planner.md.template',
|
|
19
|
+
to: '.outputai/agents/workflow_planner.md'
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
type: 'template',
|
|
23
|
+
from: 'commands/plan_workflow.md.template',
|
|
24
|
+
to: '.outputai/commands/plan_workflow.md'
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
'claude-code': {
|
|
29
|
+
id: 'claude-code',
|
|
30
|
+
name: 'Claude Code',
|
|
31
|
+
mappings: [
|
|
32
|
+
{
|
|
33
|
+
type: 'symlink',
|
|
34
|
+
from: '.outputai/AGENTS.md',
|
|
35
|
+
to: 'CLAUDE.md'
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
type: 'symlink',
|
|
39
|
+
from: '.outputai/agents/workflow_planner.md',
|
|
40
|
+
to: '.claude/agents/workflow_planner.md'
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
type: 'symlink',
|
|
44
|
+
from: '.outputai/commands/plan_workflow.md',
|
|
45
|
+
to: '.claude/commands/plan_workflow.md'
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
export default class Init extends Command {
|
|
51
|
+
static description = 'Initialize agent configuration files for AI assistant integration';
|
|
52
|
+
static examples = [
|
|
53
|
+
'<%= config.bin %> <%= command.id %>',
|
|
54
|
+
'<%= config.bin %> <%= command.id %> --agent-provider claude-code',
|
|
55
|
+
'<%= config.bin %> <%= command.id %> --force'
|
|
56
|
+
];
|
|
57
|
+
static args = {};
|
|
58
|
+
static flags = {
|
|
59
|
+
'agent-provider': Flags.string({
|
|
60
|
+
description: 'Specify the coding agent provider',
|
|
61
|
+
default: AGENT_CONFIGS['claude-code'].id,
|
|
62
|
+
options: [AGENT_CONFIGS['claude-code'].id]
|
|
63
|
+
}),
|
|
64
|
+
force: Flags.boolean({
|
|
65
|
+
char: 'f',
|
|
66
|
+
description: 'Overwrite existing files',
|
|
67
|
+
default: false
|
|
68
|
+
})
|
|
69
|
+
};
|
|
70
|
+
async run() {
|
|
71
|
+
const { flags } = await this.parse(Init);
|
|
72
|
+
this.log('Initializing agent configuration for Claude Code...');
|
|
73
|
+
try {
|
|
74
|
+
const variables = this.prepareTemplateVariables();
|
|
75
|
+
await this.processMappings(AGENT_CONFIGS.outputai, variables, flags.force);
|
|
76
|
+
await this.processMappings(AGENT_CONFIGS[flags['agent-provider']], variables, flags.force);
|
|
77
|
+
this.log('✅ Agent configuration initialized successfully!');
|
|
78
|
+
this.log('');
|
|
79
|
+
this.log('Created:');
|
|
80
|
+
this.log(' • .outputai/ directory with agent and command configurations');
|
|
81
|
+
this.log(' • .claude/ directory with symlinks for Claude Code integration');
|
|
82
|
+
this.log('');
|
|
83
|
+
this.log('Claude Code will automatically detect and use these configurations.');
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (error.code === 'EACCES') {
|
|
87
|
+
this.error('Permission denied. Please check file permissions and try again.');
|
|
88
|
+
}
|
|
89
|
+
this.error(`Failed to initialize agent configuration: ${error.message}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
prepareTemplateVariables() {
|
|
93
|
+
return {
|
|
94
|
+
date: new Date().toLocaleDateString('en-US', {
|
|
95
|
+
year: 'numeric',
|
|
96
|
+
month: 'long',
|
|
97
|
+
day: 'numeric'
|
|
98
|
+
})
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
async processMappings(config, variables, force) {
|
|
102
|
+
for (const mapping of config.mappings) {
|
|
103
|
+
const dir = path.dirname(mapping.to);
|
|
104
|
+
await this.ensureDirectoryExists(dir);
|
|
105
|
+
if (!force && await this.fileExists(mapping.to)) {
|
|
106
|
+
this.warn(`File already exists: ${mapping.to} (use --force to overwrite)`);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
switch (mapping.type) {
|
|
110
|
+
case 'template':
|
|
111
|
+
await this.createFromTemplate(mapping.from, mapping.to, variables);
|
|
112
|
+
break;
|
|
113
|
+
case 'symlink':
|
|
114
|
+
await this.createSymlink(mapping.from, mapping.to);
|
|
115
|
+
break;
|
|
116
|
+
case 'copy':
|
|
117
|
+
await this.copyFile(mapping.from, mapping.to);
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async ensureDirectoryExists(dir) {
|
|
123
|
+
try {
|
|
124
|
+
await fs.mkdir(dir, { recursive: true });
|
|
125
|
+
this.debug(`Created directory: ${dir}`);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (error.code !== 'EEXIST') {
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async fileExists(filePath) {
|
|
134
|
+
try {
|
|
135
|
+
await fs.stat(filePath);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async createFromTemplate(template, output, variables) {
|
|
143
|
+
const templateDir = getTemplateDir('agent_instructions');
|
|
144
|
+
const templatePath = path.join(templateDir, template);
|
|
145
|
+
const content = await fs.readFile(templatePath, 'utf-8');
|
|
146
|
+
const processed = processTemplate(content, variables);
|
|
147
|
+
await fs.writeFile(output, processed, 'utf-8');
|
|
148
|
+
this.debug(`Created from template: ${output}`);
|
|
149
|
+
}
|
|
150
|
+
async createSymlink(source, target) {
|
|
151
|
+
try {
|
|
152
|
+
if (await this.fileExists(target)) {
|
|
153
|
+
await fs.unlink(target);
|
|
154
|
+
}
|
|
155
|
+
const relativePath = path.relative(path.dirname(target), source);
|
|
156
|
+
await fs.symlink(relativePath, target);
|
|
157
|
+
this.debug(`Created symlink: ${target} -> ${source}`);
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
const code = error.code;
|
|
161
|
+
if (code === 'ENOTSUP' || code === 'EPERM') {
|
|
162
|
+
this.debug(`Symlinks not supported, creating copy: ${target}`);
|
|
163
|
+
const content = await fs.readFile(source, 'utf-8');
|
|
164
|
+
await fs.writeFile(target, content, 'utf-8');
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async copyFile(source, target) {
|
|
171
|
+
const content = await fs.readFile(source, 'utf-8');
|
|
172
|
+
await fs.writeFile(target, content, 'utf-8');
|
|
173
|
+
this.debug(`Copied file: ${source} -> ${target}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/* eslint-disable no-restricted-syntax, @typescript-eslint/no-explicit-any, init-declarations */
|
|
2
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import Init from './init.js';
|
|
6
|
+
import { getTemplateDir } from '../../utils/paths.js';
|
|
7
|
+
import { processTemplate } from '../../utils/template.js';
|
|
8
|
+
vi.mock('node:fs/promises');
|
|
9
|
+
vi.mock('node:path', async () => {
|
|
10
|
+
const actual = await vi.importActual('node:path');
|
|
11
|
+
return {
|
|
12
|
+
...actual,
|
|
13
|
+
join: vi.fn((...args) => args.join('/')),
|
|
14
|
+
dirname: vi.fn(p => p.split('/').slice(0, -1).join('/') || '.'),
|
|
15
|
+
relative: vi.fn((from, to) => {
|
|
16
|
+
if (to === 'CLAUDE.md' && from === '.') {
|
|
17
|
+
return '.outputai/AGENTS.md';
|
|
18
|
+
}
|
|
19
|
+
if (to.includes('.outputai/agents')) {
|
|
20
|
+
return `../../.outputai/agents/${path.basename(to)}`;
|
|
21
|
+
}
|
|
22
|
+
if (to.includes('.outputai/commands')) {
|
|
23
|
+
return `../../.outputai/commands/${path.basename(to)}`;
|
|
24
|
+
}
|
|
25
|
+
return to;
|
|
26
|
+
})
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
vi.mock('../../utils/paths.js');
|
|
30
|
+
vi.mock('../../utils/template.js');
|
|
31
|
+
describe('agents init', () => {
|
|
32
|
+
let mockGetTemplateDir;
|
|
33
|
+
let mockProcessTemplate;
|
|
34
|
+
const createTestCommand = (args = []) => {
|
|
35
|
+
const cmd = new Init(args, {});
|
|
36
|
+
cmd.log = vi.fn();
|
|
37
|
+
cmd.warn = vi.fn();
|
|
38
|
+
cmd.error = vi.fn();
|
|
39
|
+
cmd.debug = vi.fn();
|
|
40
|
+
cmd.parse = vi.fn();
|
|
41
|
+
return cmd;
|
|
42
|
+
};
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
vi.clearAllMocks();
|
|
45
|
+
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
|
46
|
+
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
|
47
|
+
vi.mocked(fs.symlink).mockResolvedValue(undefined);
|
|
48
|
+
vi.mocked(fs.stat).mockRejectedValue({ code: 'ENOENT' });
|
|
49
|
+
vi.mocked(fs.unlink).mockResolvedValue(undefined);
|
|
50
|
+
vi.mocked(fs.readFile).mockResolvedValue('Template content with {{date}}');
|
|
51
|
+
mockGetTemplateDir = vi.mocked(getTemplateDir);
|
|
52
|
+
mockGetTemplateDir.mockReturnValue('/templates/agent_instructions');
|
|
53
|
+
mockProcessTemplate = vi.mocked(processTemplate);
|
|
54
|
+
mockProcessTemplate.mockImplementation((content, variables) => {
|
|
55
|
+
return content.replace(/\{\{(\w+)\}\}/g, (_match, key) => variables[key] || _match);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
vi.restoreAllMocks();
|
|
60
|
+
});
|
|
61
|
+
describe('command structure', () => {
|
|
62
|
+
it('should have correct description', () => {
|
|
63
|
+
expect(Init.description).toBeDefined();
|
|
64
|
+
expect(Init.description).toContain('agent configuration');
|
|
65
|
+
});
|
|
66
|
+
it('should have correct examples', () => {
|
|
67
|
+
expect(Init.examples).toBeDefined();
|
|
68
|
+
expect(Array.isArray(Init.examples)).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
it('should have no required arguments', () => {
|
|
71
|
+
expect(Init.args).toBeDefined();
|
|
72
|
+
expect(Object.keys(Init.args)).toHaveLength(0);
|
|
73
|
+
});
|
|
74
|
+
it('should have appropriate flags', () => {
|
|
75
|
+
expect(Init.flags).toBeDefined();
|
|
76
|
+
expect(Init.flags.force).toBeDefined();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
describe('directory creation', () => {
|
|
80
|
+
it('should create .outputai directory structure', async () => {
|
|
81
|
+
const mockMkdir = vi.mocked(fs.mkdir);
|
|
82
|
+
mockMkdir.mockResolvedValue(undefined);
|
|
83
|
+
const cmd = createTestCommand();
|
|
84
|
+
cmd.parse.mockResolvedValue({ flags: { 'agent-provider': 'claude-code', force: false }, args: {} });
|
|
85
|
+
await cmd.run();
|
|
86
|
+
expect(mockMkdir).toHaveBeenCalledWith(expect.stringContaining('.outputai'), expect.objectContaining({ recursive: true }));
|
|
87
|
+
expect(mockMkdir).toHaveBeenCalledWith(expect.stringContaining('.outputai/agents'), expect.objectContaining({ recursive: true }));
|
|
88
|
+
expect(mockMkdir).toHaveBeenCalledWith(expect.stringContaining('.outputai/commands'), expect.objectContaining({ recursive: true }));
|
|
89
|
+
});
|
|
90
|
+
it('should create .claude directory structure', async () => {
|
|
91
|
+
const mockMkdir = vi.mocked(fs.mkdir);
|
|
92
|
+
mockMkdir.mockResolvedValue(undefined);
|
|
93
|
+
const cmd = createTestCommand();
|
|
94
|
+
cmd.parse.mockResolvedValue({ flags: { 'agent-provider': 'claude-code', force: false }, args: {} });
|
|
95
|
+
await cmd.run();
|
|
96
|
+
expect(mockMkdir).toHaveBeenCalledWith(expect.stringContaining('.claude'), expect.objectContaining({ recursive: true }));
|
|
97
|
+
expect(mockMkdir).toHaveBeenCalledWith(expect.stringContaining('.claude/agents'), expect.objectContaining({ recursive: true }));
|
|
98
|
+
expect(mockMkdir).toHaveBeenCalledWith(expect.stringContaining('.claude/commands'), expect.objectContaining({ recursive: true }));
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
describe('file creation from templates', () => {
|
|
102
|
+
it('should create AGENTS.md file from template', async () => {
|
|
103
|
+
const mockWriteFile = vi.mocked(fs.writeFile);
|
|
104
|
+
const mockReadFile = vi.mocked(fs.readFile);
|
|
105
|
+
mockReadFile.mockResolvedValue('Agent instructions {{date}}');
|
|
106
|
+
const cmd = createTestCommand();
|
|
107
|
+
cmd.parse.mockResolvedValue({ flags: { 'agent-provider': 'claude-code', force: false }, args: {} });
|
|
108
|
+
await cmd.run();
|
|
109
|
+
expect(mockReadFile).toHaveBeenCalledWith(expect.stringContaining('AGENTS.md.template'), 'utf-8');
|
|
110
|
+
expect(mockWriteFile).toHaveBeenCalledWith('.outputai/AGENTS.md', expect.stringContaining('Agent instructions'), 'utf-8');
|
|
111
|
+
});
|
|
112
|
+
it('should create all agent configuration files', async () => {
|
|
113
|
+
const mockWriteFile = vi.mocked(fs.writeFile);
|
|
114
|
+
const cmd = createTestCommand();
|
|
115
|
+
cmd.parse.mockResolvedValue({ flags: { 'agent-provider': 'claude-code', force: false }, args: {} });
|
|
116
|
+
await cmd.run();
|
|
117
|
+
const agentFiles = [
|
|
118
|
+
'.outputai/agents/workflow_planner.md'
|
|
119
|
+
];
|
|
120
|
+
for (const file of agentFiles) {
|
|
121
|
+
expect(mockWriteFile).toHaveBeenCalledWith(file, expect.any(String), 'utf-8');
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
it('should create all command configuration files', async () => {
|
|
125
|
+
const mockWriteFile = vi.mocked(fs.writeFile);
|
|
126
|
+
const cmd = createTestCommand();
|
|
127
|
+
cmd.parse.mockResolvedValue({ flags: { 'agent-provider': 'claude-code', force: false }, args: {} });
|
|
128
|
+
await cmd.run();
|
|
129
|
+
const commandFiles = [
|
|
130
|
+
'.outputai/commands/plan_workflow.md'
|
|
131
|
+
];
|
|
132
|
+
for (const file of commandFiles) {
|
|
133
|
+
expect(mockWriteFile).toHaveBeenCalledWith(file, expect.any(String), 'utf-8');
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
describe('symlink creation', () => {
|
|
138
|
+
it('should create symlink from CLAUDE.md to .outputai/AGENTS.md', async () => {
|
|
139
|
+
const mockSymlink = vi.mocked(fs.symlink);
|
|
140
|
+
const mockStat = vi.mocked(fs.stat);
|
|
141
|
+
mockStat.mockImplementation(async (path) => {
|
|
142
|
+
if (typeof path === 'string' && path.startsWith('.outputai')) {
|
|
143
|
+
return { isFile: () => true };
|
|
144
|
+
}
|
|
145
|
+
throw { code: 'ENOENT' };
|
|
146
|
+
});
|
|
147
|
+
const cmd = createTestCommand();
|
|
148
|
+
cmd.parse.mockResolvedValue({ flags: { 'agent-provider': 'claude-code', force: false }, args: {} });
|
|
149
|
+
await cmd.run();
|
|
150
|
+
expect(mockSymlink).toHaveBeenCalledWith('.outputai/AGENTS.md', 'CLAUDE.md');
|
|
151
|
+
});
|
|
152
|
+
it('should create symlinks for all agent files', async () => {
|
|
153
|
+
const mockSymlink = vi.mocked(fs.symlink);
|
|
154
|
+
const cmd = createTestCommand();
|
|
155
|
+
cmd.parse.mockResolvedValue({ flags: { 'agent-provider': 'claude-code', force: false }, args: {} });
|
|
156
|
+
await cmd.run();
|
|
157
|
+
expect(mockSymlink).toHaveBeenCalledWith(expect.stringContaining('.outputai/agents/workflow_planner.md'), '.claude/agents/workflow_planner.md');
|
|
158
|
+
});
|
|
159
|
+
it('should create symlinks for all command files', async () => {
|
|
160
|
+
const mockSymlink = vi.mocked(fs.symlink);
|
|
161
|
+
const cmd = createTestCommand();
|
|
162
|
+
cmd.parse.mockResolvedValue({ flags: { 'agent-provider': 'claude-code', force: false }, args: {} });
|
|
163
|
+
await cmd.run();
|
|
164
|
+
expect(mockSymlink).toHaveBeenCalledWith(expect.stringContaining('.outputai/commands/plan_workflow.md'), '.claude/commands/plan_workflow.md');
|
|
165
|
+
});
|
|
166
|
+
it('should handle Windows by copying files when symlinks are not supported', async () => {
|
|
167
|
+
const mockSymlink = vi.mocked(fs.symlink);
|
|
168
|
+
const mockWriteFile = vi.mocked(fs.writeFile);
|
|
169
|
+
const mockReadFile = vi.mocked(fs.readFile);
|
|
170
|
+
mockSymlink.mockRejectedValue({ code: 'ENOTSUP' });
|
|
171
|
+
mockReadFile.mockResolvedValue('File content');
|
|
172
|
+
const cmd = createTestCommand();
|
|
173
|
+
cmd.parse.mockResolvedValue({ flags: { 'agent-provider': 'claude-code', force: false }, args: {} });
|
|
174
|
+
await cmd.run();
|
|
175
|
+
expect(mockReadFile).toHaveBeenCalledWith('.outputai/AGENTS.md', 'utf-8');
|
|
176
|
+
expect(mockWriteFile).toHaveBeenCalledWith('CLAUDE.md', 'File content', 'utf-8');
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
describe('error handling', () => {
|
|
180
|
+
it('should handle existing directory gracefully', async () => {
|
|
181
|
+
const mockMkdir = vi.mocked(fs.mkdir);
|
|
182
|
+
mockMkdir.mockRejectedValue({ code: 'EEXIST' });
|
|
183
|
+
const cmd = createTestCommand();
|
|
184
|
+
cmd.parse.mockResolvedValue({ flags: { 'agent-provider': 'claude-code', force: false }, args: {} });
|
|
185
|
+
await cmd.run();
|
|
186
|
+
expect(cmd.error).not.toHaveBeenCalled();
|
|
187
|
+
});
|
|
188
|
+
it('should handle permission errors', async () => {
|
|
189
|
+
const mockMkdir = vi.mocked(fs.mkdir);
|
|
190
|
+
mockMkdir.mockRejectedValue({ code: 'EACCES' });
|
|
191
|
+
const cmd = createTestCommand();
|
|
192
|
+
cmd.parse.mockResolvedValue({ flags: { 'agent-provider': 'claude-code', force: false }, args: {} });
|
|
193
|
+
await cmd.run();
|
|
194
|
+
expect(cmd.error).toHaveBeenCalledWith(expect.stringContaining('Permission denied'));
|
|
195
|
+
});
|
|
196
|
+
it('should be idempotent (safe to run multiple times)', async () => {
|
|
197
|
+
const mockWriteFile = vi.mocked(fs.writeFile);
|
|
198
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
199
|
+
const cmd = createTestCommand();
|
|
200
|
+
cmd.parse.mockResolvedValue({ flags: { 'agent-provider': 'claude-code', force: false }, args: {} });
|
|
201
|
+
await cmd.run();
|
|
202
|
+
await cmd.run();
|
|
203
|
+
expect(cmd.error).not.toHaveBeenCalled();
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
describe('force flag', () => {
|
|
207
|
+
it('should overwrite existing files when force flag is set', async () => {
|
|
208
|
+
const mockWriteFile = vi.mocked(fs.writeFile);
|
|
209
|
+
const mockStat = vi.mocked(fs.stat);
|
|
210
|
+
mockStat.mockResolvedValue({ isFile: () => true });
|
|
211
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
212
|
+
const cmd = createTestCommand(['--force']);
|
|
213
|
+
cmd.parse.mockResolvedValue({ flags: { 'agent-provider': 'claude-code', force: true }, args: {} });
|
|
214
|
+
await cmd.run();
|
|
215
|
+
expect(mockWriteFile).toHaveBeenCalled();
|
|
216
|
+
expect(cmd.warn).not.toHaveBeenCalled();
|
|
217
|
+
});
|
|
218
|
+
it('should warn about existing files without force flag', async () => {
|
|
219
|
+
const mockStat = vi.mocked(fs.stat);
|
|
220
|
+
mockStat.mockResolvedValue({ isFile: () => true });
|
|
221
|
+
const cmd = createTestCommand();
|
|
222
|
+
cmd.parse.mockResolvedValue({ flags: { 'agent-provider': 'claude-code', force: false }, args: {} });
|
|
223
|
+
await cmd.run();
|
|
224
|
+
expect(cmd.warn).toHaveBeenCalledWith(expect.stringContaining('already exists'));
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -2,7 +2,7 @@ import { Args, Command, Flags } from '@oclif/core';
|
|
|
2
2
|
import { generateWorkflow } from '../../services/workflow_generator.js';
|
|
3
3
|
import { DEFAULT_OUTPUT_DIRS } from '../../utils/paths.js';
|
|
4
4
|
export default class Generate extends Command {
|
|
5
|
-
static description = 'Generate a new
|
|
5
|
+
static description = 'Generate a new Output SDK workflow';
|
|
6
6
|
static examples = [
|
|
7
7
|
'<%= config.bin %> <%= command.id %> my-workflow',
|
|
8
8
|
'<%= config.bin %> <%= command.id %> my-workflow --skeleton',
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
import { type Workflow } from '../../api/generated/api.js';
|
|
3
|
+
interface WorkflowDisplay {
|
|
4
|
+
name: string;
|
|
5
|
+
description: string;
|
|
6
|
+
inputs: string;
|
|
7
|
+
outputs: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function parseWorkflowForDisplay(workflow: Workflow): WorkflowDisplay;
|
|
10
|
+
export default class WorkflowList extends Command {
|
|
11
|
+
static description: string;
|
|
12
|
+
static examples: string[];
|
|
13
|
+
static flags: {
|
|
14
|
+
format: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
15
|
+
detailed: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
|
|
16
|
+
filter: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
17
|
+
};
|
|
18
|
+
run(): Promise<void>;
|
|
19
|
+
private handleError;
|
|
20
|
+
}
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { Command, Flags } from '@oclif/core';
|
|
2
|
+
import Table from 'cli-table3';
|
|
3
|
+
import { getWorkflowCatalog } from '../../api/generated/api.js';
|
|
4
|
+
import { parseWorkflowDefinition, formatParameters } from '../../api/parser.js';
|
|
5
|
+
import { config } from '../../config.js';
|
|
6
|
+
const OUTPUT_FORMAT = {
|
|
7
|
+
LIST: 'list',
|
|
8
|
+
TABLE: 'table',
|
|
9
|
+
JSON: 'json'
|
|
10
|
+
};
|
|
11
|
+
export function parseWorkflowForDisplay(workflow) {
|
|
12
|
+
const parsed = parseWorkflowDefinition(workflow);
|
|
13
|
+
return {
|
|
14
|
+
name: parsed.name,
|
|
15
|
+
description: parsed.description || 'No description',
|
|
16
|
+
inputs: formatParameters(parsed.inputs),
|
|
17
|
+
outputs: formatParameters(parsed.outputs)
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function caseInsensitiveIncludes(str, filter) {
|
|
21
|
+
return str.toLowerCase().includes(filter.toLowerCase());
|
|
22
|
+
}
|
|
23
|
+
function matchName(filterString) {
|
|
24
|
+
return workflow => {
|
|
25
|
+
const name = workflow.name || '';
|
|
26
|
+
return caseInsensitiveIncludes(name, filterString);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function sortWorkflowsByName(workflows) {
|
|
30
|
+
return [...workflows].sort((a, b) => {
|
|
31
|
+
const nameA = (a.name || '').toLowerCase();
|
|
32
|
+
const nameB = (b.name || '').toLowerCase();
|
|
33
|
+
return nameA.localeCompare(nameB);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function createWorkflowTable(workflows, detailed) {
|
|
37
|
+
const table = new Table({
|
|
38
|
+
head: ['Name', 'Description', 'Inputs', 'Outputs'],
|
|
39
|
+
colWidths: detailed ? [25, 40, 40, 40] : [20, 30, 25, 25],
|
|
40
|
+
wordWrap: true,
|
|
41
|
+
style: {
|
|
42
|
+
head: ['cyan']
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
const sortedWorkflows = sortWorkflowsByName(workflows);
|
|
46
|
+
sortedWorkflows.forEach(workflow => {
|
|
47
|
+
const display = parseWorkflowForDisplay(workflow);
|
|
48
|
+
if (detailed) {
|
|
49
|
+
const inputs = display.inputs.split(', ').join('\n');
|
|
50
|
+
const outputs = display.outputs.split(', ').join('\n');
|
|
51
|
+
table.push([display.name, display.description, inputs, outputs]);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
const maxLen = 22;
|
|
55
|
+
const inputs = display.inputs.length > maxLen ?
|
|
56
|
+
display.inputs.substring(0, maxLen) + '...' :
|
|
57
|
+
display.inputs;
|
|
58
|
+
const outputs = display.outputs.length > maxLen ?
|
|
59
|
+
display.outputs.substring(0, maxLen) + '...' :
|
|
60
|
+
display.outputs;
|
|
61
|
+
table.push([display.name, display.description, inputs, outputs]);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
return table.toString();
|
|
65
|
+
}
|
|
66
|
+
function formatWorkflowsAsList(workflows) {
|
|
67
|
+
const sortedWorkflows = sortWorkflowsByName(workflows);
|
|
68
|
+
const names = sortedWorkflows.map(w => parseWorkflowForDisplay(w).name);
|
|
69
|
+
return `\nWorkflows:\n\n${names.map(name => `- ${name}`).join('\n')}`;
|
|
70
|
+
}
|
|
71
|
+
function formatWorkflowsAsJson(workflows) {
|
|
72
|
+
const output = {
|
|
73
|
+
workflows: workflows.map(w => {
|
|
74
|
+
const display = parseWorkflowForDisplay(w);
|
|
75
|
+
return {
|
|
76
|
+
name: display.name,
|
|
77
|
+
description: display.description,
|
|
78
|
+
inputs: display.inputs.split(', '),
|
|
79
|
+
outputs: display.outputs.split(', '),
|
|
80
|
+
raw: w
|
|
81
|
+
};
|
|
82
|
+
})
|
|
83
|
+
};
|
|
84
|
+
return JSON.stringify(output, null, 2);
|
|
85
|
+
}
|
|
86
|
+
function formatWorkflows(workflows, format, detailed) {
|
|
87
|
+
if (format === OUTPUT_FORMAT.JSON) {
|
|
88
|
+
return formatWorkflowsAsJson(workflows);
|
|
89
|
+
}
|
|
90
|
+
if (format === OUTPUT_FORMAT.TABLE) {
|
|
91
|
+
return createWorkflowTable(workflows, detailed);
|
|
92
|
+
}
|
|
93
|
+
return formatWorkflowsAsList(workflows);
|
|
94
|
+
}
|
|
95
|
+
export default class WorkflowList extends Command {
|
|
96
|
+
static description = 'List available workflows from the catalog';
|
|
97
|
+
static examples = [
|
|
98
|
+
'<%= config.bin %> <%= command.id %>',
|
|
99
|
+
'<%= config.bin %> <%= command.id %> --format table',
|
|
100
|
+
'<%= config.bin %> <%= command.id %> --format json',
|
|
101
|
+
'<%= config.bin %> <%= command.id %> --detailed',
|
|
102
|
+
'<%= config.bin %> <%= command.id %> --filter simple'
|
|
103
|
+
];
|
|
104
|
+
static flags = {
|
|
105
|
+
format: Flags.string({
|
|
106
|
+
char: 'f',
|
|
107
|
+
description: 'Output format',
|
|
108
|
+
options: [OUTPUT_FORMAT.LIST, OUTPUT_FORMAT.TABLE, OUTPUT_FORMAT.JSON],
|
|
109
|
+
default: OUTPUT_FORMAT.LIST
|
|
110
|
+
}),
|
|
111
|
+
detailed: Flags.boolean({
|
|
112
|
+
char: 'd',
|
|
113
|
+
description: 'Show detailed parameter information',
|
|
114
|
+
default: false
|
|
115
|
+
}),
|
|
116
|
+
filter: Flags.string({
|
|
117
|
+
description: 'Filter workflows by name'
|
|
118
|
+
})
|
|
119
|
+
};
|
|
120
|
+
async run() {
|
|
121
|
+
const { flags } = await this.parse(WorkflowList);
|
|
122
|
+
try {
|
|
123
|
+
this.log('Fetching workflow catalog...');
|
|
124
|
+
const response = await getWorkflowCatalog();
|
|
125
|
+
if (!response) {
|
|
126
|
+
this.error('Failed to connect to API server. Is it running?', { exit: 1 });
|
|
127
|
+
}
|
|
128
|
+
if (!response.data) {
|
|
129
|
+
this.error('API returned invalid response (missing data)', { exit: 1 });
|
|
130
|
+
}
|
|
131
|
+
if (!response.data.workflows) {
|
|
132
|
+
this.error('API returned invalid response (missing workflows)', { exit: 1 });
|
|
133
|
+
}
|
|
134
|
+
if (response.data.workflows.length === 0) {
|
|
135
|
+
this.log('No workflows found in catalog.');
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const workflows = flags.filter ?
|
|
139
|
+
response.data.workflows.filter(matchName(flags.filter)) :
|
|
140
|
+
response.data.workflows;
|
|
141
|
+
if (workflows.length === 0 && flags.filter) {
|
|
142
|
+
this.log(`No workflows matching filter: ${flags.filter}`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const output = formatWorkflows(workflows, flags.format, flags.detailed);
|
|
146
|
+
this.log(output);
|
|
147
|
+
this.log(`\nFound ${workflows.length} workflow(s)`);
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
this.handleError(error);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
handleError(error) {
|
|
154
|
+
const apiError = error;
|
|
155
|
+
if (apiError.code === 'ECONNREFUSED') {
|
|
156
|
+
this.error(`Connection refused to ${config.apiUrl}`, { exit: 1 });
|
|
157
|
+
}
|
|
158
|
+
if (apiError.response?.status === 401) {
|
|
159
|
+
this.error('Authentication failed.', { exit: 1 });
|
|
160
|
+
}
|
|
161
|
+
if (apiError.response?.status === 404) {
|
|
162
|
+
this.error('Not found.', { exit: 1 });
|
|
163
|
+
}
|
|
164
|
+
if (apiError.message) {
|
|
165
|
+
this.error(`Failed to fetch workflow catalog: ${apiError.message}`, { exit: 1 });
|
|
166
|
+
}
|
|
167
|
+
this.error('Failed to fetch workflow catalog: Unknown error', { exit: 1 });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|