@output.ai/cli 0.6.0 → 0.7.1
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 +2 -1
- package/dist/api/generated/api.d.ts +29 -0
- package/dist/api/generated/api.js +11 -0
- package/dist/commands/init.d.ts +3 -1
- package/dist/commands/init.js +14 -7
- package/dist/commands/init.spec.js +18 -27
- package/dist/commands/workflow/terminate.d.ts +13 -0
- package/dist/commands/workflow/terminate.js +39 -0
- package/dist/services/coding_agents.js +49 -3
- package/dist/services/coding_agents.spec.js +47 -0
- package/dist/services/env_configurator.d.ts +1 -1
- package/dist/services/env_configurator.js +19 -21
- package/dist/services/env_configurator.spec.js +9 -13
- package/dist/services/messages.js +2 -2
- package/dist/services/project_scaffold.d.ts +31 -6
- package/dist/services/project_scaffold.js +153 -95
- package/dist/services/project_scaffold.spec.js +105 -23
- package/dist/services/workflow_planner.js +4 -1
- package/dist/templates/project/README.md.template +1 -1
- package/dist/templates/project/src/{simple → workflows/example_question}/workflow.ts.template +1 -1
- package/dist/types/errors.d.ts +12 -0
- package/dist/types/errors.js +17 -0
- package/dist/types/errors.spec.d.ts +1 -0
- package/dist/types/errors.spec.js +18 -0
- package/package.json +1 -1
- /package/dist/templates/project/src/{simple → workflows/example_question}/prompts/answer_question@v1.prompt.template +0 -0
- /package/dist/templates/project/src/{simple → workflows/example_question}/scenarios/question_ada_lovelace.json.template +0 -0
- /package/dist/templates/project/src/{simple → workflows/example_question}/steps.ts.template +0 -0
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ cd <project-name>
|
|
|
18
18
|
npx output dev
|
|
19
19
|
|
|
20
20
|
# Run a workflow
|
|
21
|
-
npx output workflow run
|
|
21
|
+
npx output workflow run example_question --input '{"question": "who is ada lovelace?"}'
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
## Environment Configuration
|
|
@@ -54,6 +54,7 @@ OUTPUT_CLI_ENV=/etc/output/production.env npx output workflow status wf-123
|
|
|
54
54
|
| `output workflow status` | Get workflow execution status |
|
|
55
55
|
| `output workflow result` | Get workflow execution result |
|
|
56
56
|
| `output workflow stop` | Stop a workflow execution |
|
|
57
|
+
| `output workflow terminate` | Terminate a workflow execution (force stop) |
|
|
57
58
|
| `output workflow debug` | Display workflow execution trace |
|
|
58
59
|
|
|
59
60
|
## Development Services
|
|
@@ -198,6 +198,14 @@ export type GetWorkflowIdStatus200 = {
|
|
|
198
198
|
/** An epoch timestamp representing when the workflow ended */
|
|
199
199
|
completedAt?: number;
|
|
200
200
|
};
|
|
201
|
+
export type PostWorkflowIdTerminateBody = {
|
|
202
|
+
/** Optional reason for termination */
|
|
203
|
+
reason?: string;
|
|
204
|
+
};
|
|
205
|
+
export type PostWorkflowIdTerminate200 = {
|
|
206
|
+
terminated?: boolean;
|
|
207
|
+
workflowId?: string;
|
|
208
|
+
};
|
|
201
209
|
export type GetWorkflowIdResult200 = {
|
|
202
210
|
/** The workflow execution id */
|
|
203
211
|
workflowId?: string;
|
|
@@ -316,6 +324,27 @@ export type patchWorkflowIdStopResponseError = (patchWorkflowIdStopResponse404)
|
|
|
316
324
|
export type patchWorkflowIdStopResponse = (patchWorkflowIdStopResponseSuccess | patchWorkflowIdStopResponseError);
|
|
317
325
|
export declare const getPatchWorkflowIdStopUrl: (id: string) => string;
|
|
318
326
|
export declare const patchWorkflowIdStop: (id: string, options?: ApiRequestOptions) => Promise<patchWorkflowIdStopResponse>;
|
|
327
|
+
/**
|
|
328
|
+
* Force terminates a workflow. Unlike stop/cancel, terminate immediately stops the workflow without allowing cleanup.
|
|
329
|
+
* @summary Terminate a workflow execution (force stop)
|
|
330
|
+
*/
|
|
331
|
+
export type postWorkflowIdTerminateResponse200 = {
|
|
332
|
+
data: PostWorkflowIdTerminate200;
|
|
333
|
+
status: 200;
|
|
334
|
+
};
|
|
335
|
+
export type postWorkflowIdTerminateResponse404 = {
|
|
336
|
+
data: void;
|
|
337
|
+
status: 404;
|
|
338
|
+
};
|
|
339
|
+
export type postWorkflowIdTerminateResponseSuccess = (postWorkflowIdTerminateResponse200) & {
|
|
340
|
+
headers: Headers;
|
|
341
|
+
};
|
|
342
|
+
export type postWorkflowIdTerminateResponseError = (postWorkflowIdTerminateResponse404) & {
|
|
343
|
+
headers: Headers;
|
|
344
|
+
};
|
|
345
|
+
export type postWorkflowIdTerminateResponse = (postWorkflowIdTerminateResponseSuccess | postWorkflowIdTerminateResponseError);
|
|
346
|
+
export declare const getPostWorkflowIdTerminateUrl: (id: string) => string;
|
|
347
|
+
export declare const postWorkflowIdTerminate: (id: string, postWorkflowIdTerminateBody: PostWorkflowIdTerminateBody, options?: ApiRequestOptions) => Promise<postWorkflowIdTerminateResponse>;
|
|
319
348
|
/**
|
|
320
349
|
* @summary Return the result of a workflow
|
|
321
350
|
*/
|
|
@@ -87,6 +87,17 @@ export const patchWorkflowIdStop = async (id, options) => {
|
|
|
87
87
|
method: 'PATCH'
|
|
88
88
|
});
|
|
89
89
|
};
|
|
90
|
+
export const getPostWorkflowIdTerminateUrl = (id) => {
|
|
91
|
+
return `/workflow/${id}/terminate`;
|
|
92
|
+
};
|
|
93
|
+
export const postWorkflowIdTerminate = async (id, postWorkflowIdTerminateBody, options) => {
|
|
94
|
+
return customFetchInstance(getPostWorkflowIdTerminateUrl(id), {
|
|
95
|
+
...options,
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
|
98
|
+
body: JSON.stringify(postWorkflowIdTerminateBody)
|
|
99
|
+
});
|
|
100
|
+
};
|
|
90
101
|
export const getGetWorkflowIdResultUrl = (id) => {
|
|
91
102
|
return `/workflow/${id}/result`;
|
|
92
103
|
};
|
package/dist/commands/init.d.ts
CHANGED
|
@@ -2,7 +2,9 @@ import { Command } from '@oclif/core';
|
|
|
2
2
|
export default class Init extends Command {
|
|
3
3
|
static description: string;
|
|
4
4
|
static examples: string[];
|
|
5
|
-
static args: {
|
|
5
|
+
static args: {
|
|
6
|
+
folderName: import("@oclif/core/lib/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
7
|
+
};
|
|
6
8
|
static flags: {
|
|
7
9
|
'skip-env': import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
|
|
8
10
|
};
|
package/dist/commands/init.js
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
|
-
import { Command, Flags } from '@oclif/core';
|
|
1
|
+
import { Args, Command, Flags } from '@oclif/core';
|
|
2
2
|
import { UserCancelledError } from '#types/errors.js';
|
|
3
|
-
import { runInit
|
|
3
|
+
import { runInit } from '#services/project_scaffold.js';
|
|
4
4
|
export default class Init extends Command {
|
|
5
5
|
static description = 'Initialize a new Output SDK workflow project by scaffolding the complete project structure';
|
|
6
6
|
static examples = [
|
|
7
|
-
'<%= config.bin %> <%= command.id %>'
|
|
7
|
+
'<%= config.bin %> <%= command.id %>',
|
|
8
|
+
'<%= config.bin %> <%= command.id %> my-workflow-project'
|
|
8
9
|
];
|
|
9
|
-
static args = {
|
|
10
|
+
static args = {
|
|
11
|
+
folderName: Args.string({
|
|
12
|
+
description: 'Optional folder name for the project (skips folder name prompt)',
|
|
13
|
+
required: false
|
|
14
|
+
})
|
|
15
|
+
};
|
|
10
16
|
static flags = {
|
|
11
17
|
'skip-env': Flags.boolean({
|
|
12
18
|
description: 'Skip interactive environment variable configuration',
|
|
@@ -15,15 +21,16 @@ export default class Init extends Command {
|
|
|
15
21
|
};
|
|
16
22
|
async run() {
|
|
17
23
|
try {
|
|
18
|
-
const { flags } = await this.parse(Init);
|
|
19
|
-
await runInit(
|
|
24
|
+
const { args, flags } = await this.parse(Init);
|
|
25
|
+
await runInit(flags['skip-env'], args.folderName);
|
|
20
26
|
}
|
|
21
27
|
catch (error) {
|
|
22
28
|
if (error instanceof UserCancelledError) {
|
|
23
29
|
this.log(error.message);
|
|
24
30
|
return;
|
|
25
31
|
}
|
|
26
|
-
|
|
32
|
+
// runInit handles cleanup internally and throws Error with message
|
|
33
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
27
34
|
this.error(errorMessage);
|
|
28
35
|
}
|
|
29
36
|
}
|
|
@@ -8,11 +8,6 @@ describe('init command', () => {
|
|
|
8
8
|
beforeEach(() => {
|
|
9
9
|
vi.clearAllMocks();
|
|
10
10
|
vi.mocked(projectScaffold.runInit).mockResolvedValue(undefined);
|
|
11
|
-
vi.mocked(projectScaffold.handleRunInitError).mockResolvedValue({
|
|
12
|
-
shouldCleanup: false,
|
|
13
|
-
errorMessage: 'Failed to create project',
|
|
14
|
-
projectPath: null
|
|
15
|
-
});
|
|
16
11
|
});
|
|
17
12
|
afterEach(() => {
|
|
18
13
|
vi.restoreAllMocks();
|
|
@@ -27,20 +22,21 @@ describe('init command', () => {
|
|
|
27
22
|
expect(Array.isArray(Init.examples)).toBe(true);
|
|
28
23
|
expect(Init.examples.length).toBeGreaterThan(0);
|
|
29
24
|
});
|
|
30
|
-
it('should have
|
|
25
|
+
it('should have an optional folderName argument', () => {
|
|
31
26
|
expect(Init.args).toBeDefined();
|
|
32
|
-
expect(
|
|
27
|
+
expect(Init.args.folderName).toBeDefined();
|
|
28
|
+
expect(Init.args.folderName.required).toBe(false);
|
|
33
29
|
});
|
|
34
30
|
});
|
|
35
31
|
describe('command execution', () => {
|
|
36
|
-
const createTestCommand = (args = [], flags = {}) => {
|
|
32
|
+
const createTestCommand = (args = [], flags = {}, parsedArgs = {}) => {
|
|
37
33
|
const cmd = new Init(args, {});
|
|
38
34
|
cmd.log = vi.fn();
|
|
39
35
|
cmd.warn = vi.fn();
|
|
40
36
|
cmd.error = vi.fn();
|
|
41
37
|
Object.defineProperty(cmd, 'parse', {
|
|
42
38
|
value: vi.fn().mockResolvedValue({
|
|
43
|
-
args: {},
|
|
39
|
+
args: { folderName: undefined, ...parsedArgs },
|
|
44
40
|
flags: { 'skip-env': false, ...flags }
|
|
45
41
|
}),
|
|
46
42
|
configurable: true
|
|
@@ -56,10 +52,10 @@ describe('init command', () => {
|
|
|
56
52
|
expect(cmd.run).toBeDefined();
|
|
57
53
|
expect(typeof cmd.run).toBe('function');
|
|
58
54
|
});
|
|
59
|
-
it('should call runInit with
|
|
55
|
+
it('should call runInit with skip-env flag and folder name', async () => {
|
|
60
56
|
const cmd = createTestCommand();
|
|
61
57
|
await cmd.run();
|
|
62
|
-
expect(projectScaffold.runInit).toHaveBeenCalledWith(
|
|
58
|
+
expect(projectScaffold.runInit).toHaveBeenCalledWith(false, undefined);
|
|
63
59
|
});
|
|
64
60
|
it('should handle UserCancelledError', async () => {
|
|
65
61
|
const error = new UserCancelledError();
|
|
@@ -69,27 +65,17 @@ describe('init command', () => {
|
|
|
69
65
|
expect(cmd.log).toHaveBeenCalledWith('Init cancelled by user.');
|
|
70
66
|
expect(cmd.error).not.toHaveBeenCalled();
|
|
71
67
|
});
|
|
72
|
-
it('should handle other errors
|
|
73
|
-
|
|
68
|
+
it('should handle other errors by passing error message', async () => {
|
|
69
|
+
// runInit now handles cleanup internally and throws Error with message
|
|
70
|
+
const testError = new Error('Failed to create project');
|
|
74
71
|
vi.mocked(projectScaffold.runInit).mockRejectedValue(testError);
|
|
75
|
-
vi.mocked(projectScaffold.handleRunInitError).mockResolvedValue({
|
|
76
|
-
shouldCleanup: true,
|
|
77
|
-
errorMessage: 'Failed to create project',
|
|
78
|
-
projectPath: '/some/path'
|
|
79
|
-
});
|
|
80
72
|
const cmd = createTestCommand();
|
|
81
73
|
await cmd.run();
|
|
82
|
-
expect(projectScaffold.handleRunInitError).toHaveBeenCalledWith(testError, expect.any(Function));
|
|
83
74
|
expect(cmd.error).toHaveBeenCalledWith('Failed to create project');
|
|
84
75
|
});
|
|
85
|
-
it('should pass error message
|
|
86
|
-
const testError = new Error('
|
|
76
|
+
it('should pass error message to this.error', async () => {
|
|
77
|
+
const testError = new Error('Folder already exists');
|
|
87
78
|
vi.mocked(projectScaffold.runInit).mockRejectedValue(testError);
|
|
88
|
-
vi.mocked(projectScaffold.handleRunInitError).mockResolvedValue({
|
|
89
|
-
shouldCleanup: false,
|
|
90
|
-
errorMessage: 'Folder already exists',
|
|
91
|
-
projectPath: null
|
|
92
|
-
});
|
|
93
79
|
const cmd = createTestCommand();
|
|
94
80
|
await cmd.run();
|
|
95
81
|
expect(cmd.error).toHaveBeenCalledWith('Folder already exists');
|
|
@@ -103,7 +89,12 @@ describe('init command', () => {
|
|
|
103
89
|
it('should pass skip-env flag to runInit when --skip-env is provided', async () => {
|
|
104
90
|
const cmd = createTestCommand([], { 'skip-env': true });
|
|
105
91
|
await cmd.run();
|
|
106
|
-
expect(projectScaffold.runInit).toHaveBeenCalledWith(
|
|
92
|
+
expect(projectScaffold.runInit).toHaveBeenCalledWith(true, undefined);
|
|
93
|
+
});
|
|
94
|
+
it('should pass folder name to runInit when provided', async () => {
|
|
95
|
+
const cmd = createTestCommand([], {}, { folderName: 'my-project' });
|
|
96
|
+
await cmd.run();
|
|
97
|
+
expect(projectScaffold.runInit).toHaveBeenCalledWith(false, 'my-project');
|
|
107
98
|
});
|
|
108
99
|
});
|
|
109
100
|
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
export default class WorkflowTerminate extends Command {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static args: {
|
|
6
|
+
workflowId: import("@oclif/core/lib/interfaces").Arg<string, Record<string, unknown>>;
|
|
7
|
+
};
|
|
8
|
+
static flags: {
|
|
9
|
+
reason: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
|
|
10
|
+
};
|
|
11
|
+
run(): Promise<void>;
|
|
12
|
+
catch(error: Error): Promise<void>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Args, Command, Flags } from '@oclif/core';
|
|
2
|
+
import { postWorkflowIdTerminate } from '#api/generated/api.js';
|
|
3
|
+
import { handleApiError } from '#utils/error_handler.js';
|
|
4
|
+
export default class WorkflowTerminate extends Command {
|
|
5
|
+
static description = 'Terminate a workflow execution (force stop)';
|
|
6
|
+
static examples = [
|
|
7
|
+
'<%= config.bin %> <%= command.id %> wf-12345',
|
|
8
|
+
'<%= config.bin %> <%= command.id %> wf-12345 --reason "Cleaning up old workflows"'
|
|
9
|
+
];
|
|
10
|
+
static args = {
|
|
11
|
+
workflowId: Args.string({
|
|
12
|
+
description: 'The workflow ID to terminate',
|
|
13
|
+
required: true
|
|
14
|
+
})
|
|
15
|
+
};
|
|
16
|
+
static flags = {
|
|
17
|
+
reason: Flags.string({
|
|
18
|
+
char: 'r',
|
|
19
|
+
description: 'Reason for termination'
|
|
20
|
+
})
|
|
21
|
+
};
|
|
22
|
+
async run() {
|
|
23
|
+
const { args, flags } = await this.parse(WorkflowTerminate);
|
|
24
|
+
this.log(`Terminating workflow: ${args.workflowId}...`);
|
|
25
|
+
await postWorkflowIdTerminate(args.workflowId, { reason: flags.reason });
|
|
26
|
+
const output = [
|
|
27
|
+
'Workflow terminated successfully',
|
|
28
|
+
'',
|
|
29
|
+
`Workflow ID: ${args.workflowId}`,
|
|
30
|
+
flags.reason ? `Reason: ${flags.reason}` : ''
|
|
31
|
+
].filter(Boolean).join('\n');
|
|
32
|
+
this.log(`\n${output}`);
|
|
33
|
+
}
|
|
34
|
+
async catch(error) {
|
|
35
|
+
return handleApiError(error, (...args) => this.error(...args), {
|
|
36
|
+
404: 'Workflow not found. Check the workflow ID.'
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -7,10 +7,12 @@ import { access } from 'node:fs/promises';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { ux } from '@oclif/core';
|
|
10
|
+
import { confirm } from '@inquirer/prompts';
|
|
10
11
|
import { AGENT_CONFIG_DIR } from '#config.js';
|
|
11
12
|
import { getTemplateDir } from '#utils/paths.js';
|
|
12
13
|
import { executeClaudeCommand } from '#utils/claude.js';
|
|
13
14
|
import { processTemplate } from '#utils/template.js';
|
|
15
|
+
import { ClaudePluginError, UserCancelledError } from '#types/errors.js';
|
|
14
16
|
const EXPECTED_MARKETPLACE_REPO = 'growthxai/output-claude-plugins';
|
|
15
17
|
/**
|
|
16
18
|
* Get the full path to the agent configuration directory
|
|
@@ -192,21 +194,65 @@ async function createClaudeMdSymlink(projectRoot, force) {
|
|
|
192
194
|
ux.warn('File already exists: CLAUDE.md (use --force to overwrite)');
|
|
193
195
|
}
|
|
194
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* Handle Claude plugin command errors with user confirmation to proceed
|
|
199
|
+
* @param error - The error that occurred
|
|
200
|
+
* @param commandName - Name of the command that failed
|
|
201
|
+
* @throws UserCancelledError if user declines to proceed or presses Ctrl+C
|
|
202
|
+
*/
|
|
203
|
+
async function handlePluginError(error, commandName) {
|
|
204
|
+
const pluginError = new ClaudePluginError(commandName, error instanceof Error ? error : undefined);
|
|
205
|
+
ux.warn(pluginError.message);
|
|
206
|
+
try {
|
|
207
|
+
const shouldProceed = await confirm({
|
|
208
|
+
message: 'Claude plugin setup failed.\n\nThis means your project will be without Output.ai-specific commands, skills, and subagents.' +
|
|
209
|
+
' You will not be able to use our AI-assisted workflow planning and building functionality.\n\n' +
|
|
210
|
+
'Would you like to proceed without the Claude plugin setup?',
|
|
211
|
+
default: false
|
|
212
|
+
});
|
|
213
|
+
if (!shouldProceed) {
|
|
214
|
+
throw new UserCancelledError();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch (promptError) {
|
|
218
|
+
if (promptError instanceof UserCancelledError) {
|
|
219
|
+
throw promptError;
|
|
220
|
+
}
|
|
221
|
+
// Ctrl+C throws ExitPromptError - convert to UserCancelledError
|
|
222
|
+
throw new UserCancelledError();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
195
225
|
/**
|
|
196
226
|
* Register and update the OutputAI plugin marketplace
|
|
197
227
|
*/
|
|
198
228
|
async function registerPluginMarketplace(projectRoot) {
|
|
199
229
|
ux.stdout(ux.colorize('gray', 'Registering plugin marketplace...'));
|
|
200
|
-
|
|
230
|
+
try {
|
|
231
|
+
await executeClaudeCommand(['plugin', 'marketplace', 'add', 'growthxai/output-claude-plugins'], projectRoot, { ignoreFailure: true });
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
await handlePluginError(error, 'plugin marketplace add');
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
201
237
|
ux.stdout(ux.colorize('gray', 'Updating plugin marketplace...'));
|
|
202
|
-
|
|
238
|
+
try {
|
|
239
|
+
await executeClaudeCommand(['plugin', 'marketplace', 'update', 'outputai'], projectRoot);
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
await handlePluginError(error, 'plugin marketplace update outputai');
|
|
243
|
+
}
|
|
203
244
|
}
|
|
204
245
|
/**
|
|
205
246
|
* Install the OutputAI plugin
|
|
206
247
|
*/
|
|
207
248
|
async function installOutputAIPlugin(projectRoot) {
|
|
208
249
|
ux.stdout(ux.colorize('gray', 'Installing OutputAI plugin...'));
|
|
209
|
-
|
|
250
|
+
try {
|
|
251
|
+
await executeClaudeCommand(['plugin', 'install', 'outputai@outputai', '--scope', 'project'], projectRoot);
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
await handlePluginError(error, 'plugin install outputai@outputai');
|
|
255
|
+
}
|
|
210
256
|
}
|
|
211
257
|
/**
|
|
212
258
|
* Initialize agent configuration files and register Claude Code plugin
|
|
@@ -19,6 +19,9 @@ vi.mock('@oclif/core', () => ({
|
|
|
19
19
|
colorize: vi.fn().mockImplementation((_color, text) => text)
|
|
20
20
|
}
|
|
21
21
|
}));
|
|
22
|
+
vi.mock('@inquirer/prompts', () => ({
|
|
23
|
+
confirm: vi.fn()
|
|
24
|
+
}));
|
|
22
25
|
describe('coding_agents service', () => {
|
|
23
26
|
beforeEach(() => {
|
|
24
27
|
vi.clearAllMocks();
|
|
@@ -222,4 +225,48 @@ describe('coding_agents service', () => {
|
|
|
222
225
|
expect(fs.mkdir).toHaveBeenCalled();
|
|
223
226
|
});
|
|
224
227
|
});
|
|
228
|
+
describe('Claude plugin error handling', () => {
|
|
229
|
+
beforeEach(() => {
|
|
230
|
+
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
|
231
|
+
vi.mocked(access).mockRejectedValue({ code: 'ENOENT' });
|
|
232
|
+
vi.mocked(fs.readFile).mockResolvedValue('template content');
|
|
233
|
+
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
|
234
|
+
vi.mocked(fs.symlink).mockResolvedValue(undefined);
|
|
235
|
+
});
|
|
236
|
+
it('should show error and prompt user when registerPluginMarketplace fails', async () => {
|
|
237
|
+
const { executeClaudeCommand } = await import('../utils/claude.js');
|
|
238
|
+
const { confirm } = await import('@inquirer/prompts');
|
|
239
|
+
vi.mocked(executeClaudeCommand)
|
|
240
|
+
.mockResolvedValueOnce(undefined) // marketplace add
|
|
241
|
+
.mockRejectedValueOnce(new Error('Plugin update failed')); // marketplace update
|
|
242
|
+
vi.mocked(confirm).mockResolvedValue(true);
|
|
243
|
+
await expect(initializeAgentConfig({ projectRoot: '/test/project', force: true })).resolves.not.toThrow();
|
|
244
|
+
expect(confirm).toHaveBeenCalledWith(expect.objectContaining({
|
|
245
|
+
message: expect.stringContaining('proceed')
|
|
246
|
+
}));
|
|
247
|
+
});
|
|
248
|
+
it('should show error and prompt user when installOutputAIPlugin fails', async () => {
|
|
249
|
+
const { executeClaudeCommand } = await import('../utils/claude.js');
|
|
250
|
+
const { confirm } = await import('@inquirer/prompts');
|
|
251
|
+
vi.mocked(executeClaudeCommand)
|
|
252
|
+
.mockResolvedValueOnce(undefined) // marketplace add
|
|
253
|
+
.mockResolvedValueOnce(undefined) // marketplace update
|
|
254
|
+
.mockRejectedValueOnce(new Error('Plugin install failed')); // plugin install
|
|
255
|
+
vi.mocked(confirm).mockResolvedValue(true);
|
|
256
|
+
await expect(initializeAgentConfig({ projectRoot: '/test/project', force: true })).resolves.not.toThrow();
|
|
257
|
+
expect(confirm).toHaveBeenCalledWith(expect.objectContaining({
|
|
258
|
+
message: expect.stringContaining('proceed')
|
|
259
|
+
}));
|
|
260
|
+
});
|
|
261
|
+
it('should allow user to proceed without plugin setup if they confirm', async () => {
|
|
262
|
+
const { executeClaudeCommand } = await import('../utils/claude.js');
|
|
263
|
+
const { confirm } = await import('@inquirer/prompts');
|
|
264
|
+
vi.mocked(executeClaudeCommand)
|
|
265
|
+
.mockRejectedValue(new Error('All plugin commands fail'));
|
|
266
|
+
vi.mocked(confirm).mockResolvedValue(true);
|
|
267
|
+
await expect(initializeAgentConfig({ projectRoot: '/test/project', force: true })).resolves.not.toThrow();
|
|
268
|
+
// File operations should still complete
|
|
269
|
+
expect(fs.mkdir).toHaveBeenCalled();
|
|
270
|
+
});
|
|
271
|
+
});
|
|
225
272
|
});
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* unchanged as a template for other developers.
|
|
8
8
|
*
|
|
9
9
|
* @param projectPath - The absolute path to the project directory containing the .env.example file
|
|
10
|
-
* @param skipPrompt - If true,
|
|
10
|
+
* @param skipPrompt - If true, copies .env.example to .env without interactive prompts and returns false
|
|
11
11
|
* @returns A promise that resolves to true if environment variables were successfully configured,
|
|
12
12
|
* false if configuration was skipped (no .env.example file, user declined, no variables to configure,
|
|
13
13
|
* or an error occurred)
|
|
@@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { ux } from '@oclif/core';
|
|
5
5
|
import { getErrorMessage } from '#utils/error_utils.js';
|
|
6
|
+
import { UserCancelledError } from '#types/errors.js';
|
|
6
7
|
const COMMENT_LINE = /^\s*#/;
|
|
7
8
|
const COMMENTED_VAR = /^\s*#\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/;
|
|
8
9
|
const ACTIVE_VAR = /^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/;
|
|
@@ -120,35 +121,28 @@ async function writeEnvFile(filePath, variables) {
|
|
|
120
121
|
* unchanged as a template for other developers.
|
|
121
122
|
*
|
|
122
123
|
* @param projectPath - The absolute path to the project directory containing the .env.example file
|
|
123
|
-
* @param skipPrompt - If true,
|
|
124
|
+
* @param skipPrompt - If true, copies .env.example to .env without interactive prompts and returns false
|
|
124
125
|
* @returns A promise that resolves to true if environment variables were successfully configured,
|
|
125
126
|
* false if configuration was skipped (no .env.example file, user declined, no variables to configure,
|
|
126
127
|
* or an error occurred)
|
|
127
128
|
*/
|
|
128
129
|
export async function configureEnvironmentVariables(projectPath, skipPrompt = false) {
|
|
129
|
-
if (skipPrompt) {
|
|
130
|
-
return false;
|
|
131
|
-
}
|
|
132
|
-
ux.stdout('configuring environment variables...');
|
|
133
|
-
const envExamplePath = path.join(projectPath, '.env.example');
|
|
134
|
-
const envPath = path.join(projectPath, '.env');
|
|
135
|
-
try {
|
|
136
|
-
await fs.access(envExamplePath);
|
|
137
|
-
}
|
|
138
|
-
catch {
|
|
139
|
-
ux.stdout(ux.colorize('red', '.env.example file does not exist, nothing to configure'));
|
|
140
|
-
return false;
|
|
141
|
-
}
|
|
142
|
-
const shouldConfigure = await confirm({
|
|
143
|
-
message: 'Would you like to configure environment variables now?',
|
|
144
|
-
default: true
|
|
145
|
-
});
|
|
146
|
-
if (!shouldConfigure) {
|
|
147
|
-
return false;
|
|
148
|
-
}
|
|
149
130
|
try {
|
|
131
|
+
ux.stdout('configuring environment variables...');
|
|
132
|
+
const envExamplePath = path.join(projectPath, '.env.example');
|
|
133
|
+
const envPath = path.join(projectPath, '.env');
|
|
150
134
|
// Copy .env.example to .env before configuring
|
|
151
135
|
await fs.copyFile(envExamplePath, envPath);
|
|
136
|
+
if (skipPrompt) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
const shouldConfigure = await confirm({
|
|
140
|
+
message: 'Would you like to configure environment variables now?',
|
|
141
|
+
default: true
|
|
142
|
+
});
|
|
143
|
+
if (!shouldConfigure) {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
152
146
|
const variables = await parseEnvFile(envPath);
|
|
153
147
|
const variablesToConfigure = variables.filter(v => isEmpty(v.value) || v.isSecret);
|
|
154
148
|
if (variablesToConfigure.length === 0) {
|
|
@@ -159,6 +153,10 @@ export async function configureEnvironmentVariables(projectPath, skipPrompt = fa
|
|
|
159
153
|
return true;
|
|
160
154
|
}
|
|
161
155
|
catch (error) {
|
|
156
|
+
// Ctrl+C in @inquirer/prompts throws ExitPromptError - propagate as UserCancelledError
|
|
157
|
+
if (error instanceof Error && error.name === 'ExitPromptError') {
|
|
158
|
+
throw new UserCancelledError();
|
|
159
|
+
}
|
|
162
160
|
ux.warn(`Failed to configure environment variables: ${getErrorMessage(error)}`);
|
|
163
161
|
return false;
|
|
164
162
|
}
|
|
@@ -28,16 +28,22 @@ describe('configureEnvironmentVariables', () => {
|
|
|
28
28
|
// Ignore cleanup errors
|
|
29
29
|
}
|
|
30
30
|
});
|
|
31
|
-
it('should
|
|
32
|
-
|
|
33
|
-
await fs.writeFile(testState.envExamplePath,
|
|
31
|
+
it('should copy .env.example to .env when skipPrompt is true', async () => {
|
|
32
|
+
const envExampleContent = 'API_KEY=<SECRET>\nDATABASE_URL=localhost';
|
|
33
|
+
await fs.writeFile(testState.envExamplePath, envExampleContent);
|
|
34
34
|
const result = await configureEnvironmentVariables(testState.tempDir, true);
|
|
35
35
|
expect(result).toBe(false);
|
|
36
|
+
const envContent = await fs.readFile(testState.envPath, 'utf-8');
|
|
37
|
+
expect(envContent).toBe(envExampleContent);
|
|
36
38
|
});
|
|
37
39
|
it('should return false if .env.example file does not exist', async () => {
|
|
38
40
|
const result = await configureEnvironmentVariables(testState.tempDir, false);
|
|
39
41
|
expect(result).toBe(false);
|
|
40
42
|
});
|
|
43
|
+
it('should handle missing .env.example gracefully when skipPrompt is true', async () => {
|
|
44
|
+
const result = await configureEnvironmentVariables(testState.tempDir, true);
|
|
45
|
+
expect(result).toBe(false);
|
|
46
|
+
});
|
|
41
47
|
it('should return false if user declines configuration', async () => {
|
|
42
48
|
const { confirm } = await import('@inquirer/prompts');
|
|
43
49
|
vi.mocked(confirm).mockResolvedValue(false);
|
|
@@ -46,16 +52,6 @@ describe('configureEnvironmentVariables', () => {
|
|
|
46
52
|
expect(result).toBe(false);
|
|
47
53
|
expect(vi.mocked(confirm)).toHaveBeenCalled();
|
|
48
54
|
});
|
|
49
|
-
it('should NOT create .env when user declines configuration', async () => {
|
|
50
|
-
const { confirm } = await import('@inquirer/prompts');
|
|
51
|
-
vi.mocked(confirm).mockResolvedValue(false);
|
|
52
|
-
await fs.writeFile(testState.envExamplePath, '# API key\nAPIKEY=');
|
|
53
|
-
await configureEnvironmentVariables(testState.tempDir, false);
|
|
54
|
-
// .env should NOT exist when user declines
|
|
55
|
-
await expect(fs.access(testState.envPath)).rejects.toThrow();
|
|
56
|
-
// .env.example should still exist
|
|
57
|
-
await expect(fs.access(testState.envExamplePath)).resolves.toBeUndefined();
|
|
58
|
-
});
|
|
59
55
|
it('should return false if no empty variables exist', async () => {
|
|
60
56
|
const { confirm } = await import('@inquirer/prompts');
|
|
61
57
|
vi.mocked(confirm).mockResolvedValue(true);
|
|
@@ -177,7 +177,7 @@ export const getProjectSuccessMessage = (folderName, installSuccess, envConfigur
|
|
|
177
177
|
note: 'Launches Temporal, Redis, PostgreSQL, API, Worker, and UI'
|
|
178
178
|
}, {
|
|
179
179
|
step: 'Run example workflow',
|
|
180
|
-
command: 'npx output workflow run
|
|
180
|
+
command: 'npx output workflow run example_question --input src/workflows/example_question/scenarios/question_ada_lovelace.json',
|
|
181
181
|
note: 'Execute in a new terminal after services are running'
|
|
182
182
|
}, {
|
|
183
183
|
step: 'Monitor workflows',
|
|
@@ -321,7 +321,7 @@ ${createSectionHeader('RUN A WORKFLOW', '🚀')}
|
|
|
321
321
|
|
|
322
322
|
${ux.colorize('white', 'In a new terminal, execute:')}
|
|
323
323
|
|
|
324
|
-
${formatCommand('npx output workflow run
|
|
324
|
+
${formatCommand('npx output workflow run example_question --input \'{"question": "Hello!"}\'')}
|
|
325
325
|
|
|
326
326
|
${divider}
|
|
327
327
|
|
|
@@ -1,6 +1,31 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
1
|
+
interface ProjectConfig {
|
|
2
|
+
projectName: string;
|
|
3
|
+
folderName: string;
|
|
4
|
+
projectPath: string;
|
|
5
|
+
description: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Check for required dependencies (Docker and Claude CLI)
|
|
9
|
+
* Prompts user to continue if dependencies are missing
|
|
10
|
+
* @throws UserCancelledError if user declines to proceed without dependencies
|
|
11
|
+
*/
|
|
12
|
+
export declare function checkDependencies(): Promise<void>;
|
|
13
|
+
/**
|
|
14
|
+
* Get project configuration from user input
|
|
15
|
+
* @param userFolderNameArg - Optional folder name to skip folder name prompt
|
|
16
|
+
*/
|
|
17
|
+
export declare const getProjectConfig: (userFolderNameArg?: string) => Promise<ProjectConfig>;
|
|
18
|
+
/**
|
|
19
|
+
* Create a SIGINT handler for cleanup during init
|
|
20
|
+
* Exits immediately without prompting to avoid race conditions
|
|
21
|
+
* @param projectPath - Path to the project folder
|
|
22
|
+
* @param folderCreated - Whether the folder has been created
|
|
23
|
+
*/
|
|
24
|
+
export declare function createSigintHandler(projectPath: string, folderCreated: boolean): () => void;
|
|
25
|
+
/**
|
|
26
|
+
* Run the init command workflow
|
|
27
|
+
* @param skipEnv - Whether to skip environment configuration prompts
|
|
28
|
+
* @param folderName - Optional folder name to skip folder name prompt
|
|
29
|
+
*/
|
|
30
|
+
export declare function runInit(skipEnv?: boolean, folderName?: string): Promise<void>;
|
|
31
|
+
export {};
|
|
@@ -1,30 +1,86 @@
|
|
|
1
|
-
import { input } from '@inquirer/prompts';
|
|
1
|
+
import { input, confirm } from '@inquirer/prompts';
|
|
2
|
+
import { ux } from '@oclif/core';
|
|
2
3
|
import { kebabCase, pascalCase } from 'change-case';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
5
6
|
import { FolderAlreadyExistsError, UserCancelledError, DirectoryCreationError } from '#types/errors.js';
|
|
6
|
-
import { createDirectory
|
|
7
|
+
import { createDirectory } from '#utils/file_system.js';
|
|
7
8
|
import { executeCommand, executeCommandWithMessages } from '#utils/process.js';
|
|
8
9
|
import { getSDKVersions } from '#utils/sdk_versions.js';
|
|
9
10
|
import { getErrorMessage, getErrorCode } from '#utils/error_utils.js';
|
|
11
|
+
import { isDockerInstalled } from '#services/docker.js';
|
|
12
|
+
import { isClaudeCliAvailable } from '#utils/claude.js';
|
|
10
13
|
import { configureEnvironmentVariables } from './env_configurator.js';
|
|
11
14
|
import { getTemplateFiles, processTemplateFile } from './template_processor.js';
|
|
12
15
|
import { ensureOutputAISystem } from './coding_agents.js';
|
|
13
16
|
import { getProjectSuccessMessage } from './messages.js';
|
|
14
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Check for required dependencies (Docker and Claude CLI)
|
|
19
|
+
* Prompts user to continue if dependencies are missing
|
|
20
|
+
* @throws UserCancelledError if user declines to proceed without dependencies
|
|
21
|
+
*/
|
|
22
|
+
export async function checkDependencies() {
|
|
23
|
+
const dockerInstalled = isDockerInstalled();
|
|
24
|
+
const claudeAvailable = isClaudeCliAvailable();
|
|
25
|
+
if (dockerInstalled && claudeAvailable) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const missingDeps = [];
|
|
29
|
+
if (!dockerInstalled) {
|
|
30
|
+
missingDeps.push('Docker (https://docs.docker.com/)');
|
|
31
|
+
}
|
|
32
|
+
if (!claudeAvailable) {
|
|
33
|
+
missingDeps.push('Claude CLI (https://code.claude.com/)');
|
|
34
|
+
}
|
|
35
|
+
const depList = missingDeps.join('\n - ');
|
|
36
|
+
const message = `The following dependencies are missing:\n - ${depList}\n\n` +
|
|
37
|
+
'Some features may not work correctly without these dependencies.';
|
|
38
|
+
ux.warn(message);
|
|
15
39
|
try {
|
|
16
|
-
const
|
|
17
|
-
message: '
|
|
18
|
-
default:
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
40
|
+
const shouldProceed = await confirm({
|
|
41
|
+
message: 'Would you like to proceed anyway?',
|
|
42
|
+
default: false
|
|
43
|
+
});
|
|
44
|
+
if (!shouldProceed) {
|
|
45
|
+
throw new UserCancelledError();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
throw new UserCancelledError();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const promptForFolderName = async (projectName) => {
|
|
53
|
+
return await input({
|
|
54
|
+
message: 'What folder name should be used?',
|
|
55
|
+
default: kebabCase(projectName)
|
|
56
|
+
}) || kebabCase(projectName);
|
|
57
|
+
};
|
|
58
|
+
const promptForProjectName = async (defaultProjectName) => {
|
|
59
|
+
return await input({
|
|
60
|
+
message: 'What is your project name?',
|
|
61
|
+
default: defaultProjectName
|
|
62
|
+
}) || defaultProjectName;
|
|
63
|
+
};
|
|
64
|
+
const promptForProjectDescription = async (projectName) => {
|
|
65
|
+
return await input({
|
|
66
|
+
message: 'What is your project description? (optional)',
|
|
67
|
+
default: `An Output SDK workflow for ${kebabCase(projectName)}`
|
|
68
|
+
}) || `An Output SDK workflow for ${kebabCase(projectName)}`;
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Get project configuration from user input
|
|
72
|
+
* @param userFolderNameArg - Optional folder name to skip folder name prompt
|
|
73
|
+
*/
|
|
74
|
+
export const getProjectConfig = async (userFolderNameArg) => {
|
|
75
|
+
const defaultProjectName = 'my-outputai-workflows';
|
|
76
|
+
try {
|
|
77
|
+
const projectName = userFolderNameArg ?
|
|
78
|
+
userFolderNameArg :
|
|
79
|
+
await promptForProjectName(defaultProjectName);
|
|
80
|
+
const folderName = userFolderNameArg ?
|
|
81
|
+
userFolderNameArg :
|
|
82
|
+
await promptForFolderName(projectName);
|
|
83
|
+
const description = await promptForProjectDescription(projectName);
|
|
28
84
|
return {
|
|
29
85
|
projectName,
|
|
30
86
|
folderName,
|
|
@@ -61,100 +117,102 @@ async function executeNpmInstall(projectPath) {
|
|
|
61
117
|
async function initializeAgents(projectPath) {
|
|
62
118
|
await ensureOutputAISystem(projectPath);
|
|
63
119
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (errorObject.projectPath) {
|
|
70
|
-
return errorObject.projectPath;
|
|
71
|
-
}
|
|
72
|
-
return null;
|
|
73
|
-
}
|
|
74
|
-
function handleInitError(error, projectPath) {
|
|
120
|
+
/**
|
|
121
|
+
* Format error message for init errors
|
|
122
|
+
* Single responsibility: only format error messages, no cleanup logic
|
|
123
|
+
*/
|
|
124
|
+
function formatInitError(error, projectPath) {
|
|
75
125
|
if (error instanceof UserCancelledError) {
|
|
76
|
-
return
|
|
77
|
-
shouldCleanup: false,
|
|
78
|
-
errorMessage: error.message
|
|
79
|
-
};
|
|
126
|
+
return error.message;
|
|
80
127
|
}
|
|
81
128
|
if (error instanceof FolderAlreadyExistsError) {
|
|
82
|
-
return
|
|
83
|
-
shouldCleanup: false,
|
|
84
|
-
errorMessage: error.message
|
|
85
|
-
};
|
|
129
|
+
return error.message;
|
|
86
130
|
}
|
|
87
131
|
const errorCode = getErrorCode(error);
|
|
88
|
-
if (errorCode === 'EEXIST') {
|
|
89
|
-
return {
|
|
90
|
-
shouldCleanup: false,
|
|
91
|
-
errorMessage: 'Folder already exists'
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
132
|
const pathSuffix = projectPath ? ` at ${projectPath}` : '';
|
|
95
|
-
|
|
96
|
-
return
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
if (errorCode === 'EPERM') {
|
|
108
|
-
return {
|
|
109
|
-
shouldCleanup: projectPath !== null,
|
|
110
|
-
errorMessage: `Operation not permitted${pathSuffix}`
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
if (errorCode === 'ENOENT') {
|
|
114
|
-
return {
|
|
115
|
-
shouldCleanup: projectPath !== null,
|
|
116
|
-
errorMessage: `Required file or directory not found${pathSuffix}`
|
|
117
|
-
};
|
|
133
|
+
switch (errorCode) {
|
|
134
|
+
case 'EEXIST': return 'Folder already exists';
|
|
135
|
+
case 'EACCES': return `Permission denied${pathSuffix}`;
|
|
136
|
+
case 'ENOSPC': return `Not enough disk space${pathSuffix}`;
|
|
137
|
+
case 'EPERM': return `Operation not permitted${pathSuffix}`;
|
|
138
|
+
case 'ENOENT': return `Required file or directory not found${pathSuffix}`;
|
|
139
|
+
default: {
|
|
140
|
+
const originalMessage = error instanceof Error ? error.message : String(error);
|
|
141
|
+
return `Failed to create project${pathSuffix}: ${originalMessage}`;
|
|
142
|
+
}
|
|
118
143
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Create a SIGINT handler for cleanup during init
|
|
147
|
+
* Exits immediately without prompting to avoid race conditions
|
|
148
|
+
* @param projectPath - Path to the project folder
|
|
149
|
+
* @param folderCreated - Whether the folder has been created
|
|
150
|
+
*/
|
|
151
|
+
export function createSigintHandler(projectPath, folderCreated) {
|
|
152
|
+
return () => {
|
|
153
|
+
ux.stdout('\n');
|
|
154
|
+
if (folderCreated) {
|
|
155
|
+
ux.warn(`Incomplete project folder may exist at: ${projectPath}`);
|
|
156
|
+
ux.warn(`Run: rm -rf "${projectPath}" to clean up`);
|
|
157
|
+
}
|
|
158
|
+
process.exit(130);
|
|
123
159
|
};
|
|
124
160
|
}
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
catch (error) {
|
|
131
|
-
throw new DirectoryCreationError(getErrorMessage(error), config.projectPath);
|
|
161
|
+
function handleRunInitError(error, projectPath, projectFolderCreated) {
|
|
162
|
+
const errorMessage = formatInitError(error, projectPath);
|
|
163
|
+
if (projectFolderCreated && projectPath) {
|
|
164
|
+
ux.warn(`Incomplete project folder may exist at: ${projectPath}`);
|
|
165
|
+
ux.warn(`Run: rm -rf "${projectPath}" to clean up`);
|
|
132
166
|
}
|
|
133
|
-
|
|
167
|
+
throw new Error(errorMessage);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Run the init command workflow
|
|
171
|
+
* @param skipEnv - Whether to skip environment configuration prompts
|
|
172
|
+
* @param folderName - Optional folder name to skip folder name prompt
|
|
173
|
+
*/
|
|
174
|
+
export async function runInit(skipEnv = false, folderName) {
|
|
175
|
+
// Track state for SIGINT cleanup using an object to avoid let
|
|
176
|
+
const state = {
|
|
177
|
+
projectFolderCreated: false,
|
|
178
|
+
projectPath: ''
|
|
179
|
+
};
|
|
180
|
+
// Create and register SIGINT handler
|
|
181
|
+
const sigintHandler = () => {
|
|
182
|
+
const handler = createSigintHandler(state.projectPath, state.projectFolderCreated);
|
|
183
|
+
handler();
|
|
184
|
+
};
|
|
185
|
+
process.on('SIGINT', sigintHandler);
|
|
134
186
|
try {
|
|
187
|
+
// Check dependencies first
|
|
188
|
+
await checkDependencies();
|
|
189
|
+
const config = await getProjectConfig(folderName);
|
|
190
|
+
state.projectPath = config.projectPath;
|
|
191
|
+
try {
|
|
192
|
+
createDirectory(config.projectPath);
|
|
193
|
+
state.projectFolderCreated = true;
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
throw new DirectoryCreationError(getErrorMessage(error), config.projectPath);
|
|
197
|
+
}
|
|
198
|
+
ux.stdout(`Created project folder: ${config.folderName}`);
|
|
135
199
|
const filesCreated = await scaffoldProjectFiles(config.projectPath, config.projectName, config.description);
|
|
136
|
-
|
|
200
|
+
ux.stdout(`Created ${filesCreated.length} project files`);
|
|
201
|
+
const envConfigured = await configureEnvironmentVariables(config.projectPath, skipEnv);
|
|
202
|
+
if (envConfigured) {
|
|
203
|
+
ux.stdout('Environment variables configured in .env');
|
|
204
|
+
}
|
|
205
|
+
await executeCommandWithMessages(() => initializeAgents(config.projectPath), 'Initializing agent system...', 'Agent system initialized');
|
|
206
|
+
const installSuccess = await executeCommandWithMessages(() => executeNpmInstall(config.projectPath), 'Installing dependencies...', 'Dependencies installed');
|
|
207
|
+
const nextSteps = getProjectSuccessMessage(config.folderName, installSuccess, envConfigured);
|
|
208
|
+
ux.stdout('Project created successfully!');
|
|
209
|
+
ux.stdout(nextSteps);
|
|
137
210
|
}
|
|
138
211
|
catch (error) {
|
|
139
|
-
|
|
140
|
-
enrichedError.projectPath = config.projectPath;
|
|
141
|
-
throw enrichedError;
|
|
212
|
+
handleRunInitError(error, state.projectPath || null, state.projectFolderCreated);
|
|
142
213
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
await executeCommandWithMessages(() => initializeAgents(config.projectPath), '🤖 Initializing agent system...', '✅ Agent system initialized');
|
|
148
|
-
const installSuccess = await executeCommandWithMessages(() => executeNpmInstall(config.projectPath), '📦 Installing dependencies...', '✅ Dependencies installed');
|
|
149
|
-
const nextSteps = getProjectSuccessMessage(config.folderName, installSuccess, envConfigured);
|
|
150
|
-
log('✅ Project created successfully!');
|
|
151
|
-
log(nextSteps);
|
|
152
|
-
}
|
|
153
|
-
export async function handleRunInitError(error, warn) {
|
|
154
|
-
const projectPath = extractProjectPath(error);
|
|
155
|
-
const { shouldCleanup, errorMessage } = handleInitError(error, projectPath);
|
|
156
|
-
if (shouldCleanup && projectPath) {
|
|
157
|
-
await removeDirectory(projectPath, warn);
|
|
214
|
+
finally {
|
|
215
|
+
// Remove SIGINT handler on completion (success or error)
|
|
216
|
+
process.removeListener('SIGINT', sigintHandler);
|
|
158
217
|
}
|
|
159
|
-
return { shouldCleanup, errorMessage, projectPath };
|
|
160
218
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import { getProjectConfig, checkDependencies, createSigintHandler } from './project_scaffold.js';
|
|
3
3
|
import { UserCancelledError } from '#types/errors.js';
|
|
4
4
|
// Mock the SDK versions utility
|
|
5
5
|
vi.mock('#utils/sdk_versions.js', () => ({
|
|
@@ -12,7 +12,8 @@ vi.mock('#utils/sdk_versions.js', () => ({
|
|
|
12
12
|
}));
|
|
13
13
|
// Mock other dependencies
|
|
14
14
|
vi.mock('@inquirer/prompts', () => ({
|
|
15
|
-
input: vi.fn()
|
|
15
|
+
input: vi.fn(),
|
|
16
|
+
confirm: vi.fn()
|
|
16
17
|
}));
|
|
17
18
|
vi.mock('#utils/file_system.js');
|
|
18
19
|
vi.mock('#utils/process.js');
|
|
@@ -21,31 +22,112 @@ vi.mock('./env_configurator.js', () => ({
|
|
|
21
22
|
}));
|
|
22
23
|
vi.mock('./template_processor.js');
|
|
23
24
|
vi.mock('./coding_agents.js');
|
|
25
|
+
vi.mock('#services/docker.js', () => ({
|
|
26
|
+
isDockerInstalled: vi.fn().mockReturnValue(true)
|
|
27
|
+
}));
|
|
28
|
+
vi.mock('#utils/claude.js', () => ({
|
|
29
|
+
isClaudeCliAvailable: vi.fn().mockReturnValue(true)
|
|
30
|
+
}));
|
|
31
|
+
vi.mock('@oclif/core', () => ({
|
|
32
|
+
ux: {
|
|
33
|
+
stdout: vi.fn(),
|
|
34
|
+
warn: vi.fn()
|
|
35
|
+
}
|
|
36
|
+
}));
|
|
24
37
|
describe('project_scaffold', () => {
|
|
25
38
|
beforeEach(() => {
|
|
26
39
|
vi.clearAllMocks();
|
|
27
40
|
});
|
|
28
|
-
describe('
|
|
29
|
-
it('should
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
expect(
|
|
34
|
-
expect(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
41
|
+
describe('getProjectConfig', () => {
|
|
42
|
+
it('should skip project name and folder name prompts when folderName is provided', async () => {
|
|
43
|
+
const { input } = await import('@inquirer/prompts');
|
|
44
|
+
vi.mocked(input).mockResolvedValueOnce('Test description');
|
|
45
|
+
const config = await getProjectConfig('my-project');
|
|
46
|
+
expect(config.folderName).toBe('my-project');
|
|
47
|
+
expect(config.projectName).toBe('my-project');
|
|
48
|
+
// Should only prompt for description when folderName is provided
|
|
49
|
+
expect(input).toHaveBeenCalledTimes(1);
|
|
50
|
+
});
|
|
51
|
+
it('should still prompt for description when folderName provided', async () => {
|
|
52
|
+
const { input } = await import('@inquirer/prompts');
|
|
53
|
+
vi.mocked(input).mockResolvedValueOnce('My custom description');
|
|
54
|
+
const config = await getProjectConfig('test-folder');
|
|
55
|
+
expect(config.description).toBe('My custom description');
|
|
56
|
+
expect(input).toHaveBeenCalledWith(expect.objectContaining({
|
|
57
|
+
message: expect.stringContaining('description')
|
|
58
|
+
}));
|
|
59
|
+
});
|
|
60
|
+
it('should prompt for all fields when folderName is not provided', async () => {
|
|
61
|
+
const { input } = await import('@inquirer/prompts');
|
|
62
|
+
vi.mocked(input)
|
|
63
|
+
.mockResolvedValueOnce('Test Project')
|
|
64
|
+
.mockResolvedValueOnce('test-project')
|
|
65
|
+
.mockResolvedValueOnce('A test project');
|
|
66
|
+
const config = await getProjectConfig();
|
|
67
|
+
expect(config.projectName).toBe('Test Project');
|
|
68
|
+
expect(config.folderName).toBe('test-project');
|
|
69
|
+
expect(config.description).toBe('A test project');
|
|
70
|
+
expect(input).toHaveBeenCalledTimes(3);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
describe('checkDependencies', () => {
|
|
74
|
+
it('should not prompt when all dependencies are available', async () => {
|
|
75
|
+
const { isDockerInstalled } = await import('#services/docker.js');
|
|
76
|
+
const { isClaudeCliAvailable } = await import('#utils/claude.js');
|
|
77
|
+
const { confirm } = await import('@inquirer/prompts');
|
|
78
|
+
vi.mocked(isDockerInstalled).mockReturnValue(true);
|
|
79
|
+
vi.mocked(isClaudeCliAvailable).mockReturnValue(true);
|
|
80
|
+
await checkDependencies();
|
|
81
|
+
expect(confirm).not.toHaveBeenCalled();
|
|
82
|
+
});
|
|
83
|
+
it('should prompt user when docker is missing', async () => {
|
|
84
|
+
const { isDockerInstalled } = await import('#services/docker.js');
|
|
85
|
+
const { isClaudeCliAvailable } = await import('#utils/claude.js');
|
|
86
|
+
const { confirm } = await import('@inquirer/prompts');
|
|
87
|
+
vi.mocked(isDockerInstalled).mockReturnValue(false);
|
|
88
|
+
vi.mocked(isClaudeCliAvailable).mockReturnValue(true);
|
|
89
|
+
vi.mocked(confirm).mockResolvedValue(true);
|
|
90
|
+
await checkDependencies();
|
|
91
|
+
expect(confirm).toHaveBeenCalledWith(expect.objectContaining({
|
|
92
|
+
message: expect.stringContaining('proceed')
|
|
93
|
+
}));
|
|
94
|
+
});
|
|
95
|
+
it('should throw UserCancelledError when user declines to proceed', async () => {
|
|
96
|
+
const { isDockerInstalled } = await import('#services/docker.js');
|
|
97
|
+
const { isClaudeCliAvailable } = await import('#utils/claude.js');
|
|
98
|
+
const { confirm } = await import('@inquirer/prompts');
|
|
99
|
+
vi.mocked(isDockerInstalled).mockReturnValue(false);
|
|
100
|
+
vi.mocked(isClaudeCliAvailable).mockReturnValue(true);
|
|
101
|
+
vi.mocked(confirm).mockResolvedValue(false);
|
|
102
|
+
await expect(checkDependencies()).rejects.toThrow(UserCancelledError);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
describe('SIGINT handler', () => {
|
|
106
|
+
it('should show cleanup message when project folder was created', async () => {
|
|
107
|
+
const { ux } = await import('@oclif/core');
|
|
108
|
+
const handler = createSigintHandler('/test/project', true);
|
|
109
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined);
|
|
110
|
+
handler();
|
|
111
|
+
expect(ux.warn).toHaveBeenCalledWith(expect.stringContaining('/test/project'));
|
|
112
|
+
expect(ux.warn).toHaveBeenCalledWith(expect.stringContaining('rm -rf'));
|
|
113
|
+
expect(exitSpy).toHaveBeenCalledWith(130);
|
|
114
|
+
exitSpy.mockRestore();
|
|
115
|
+
});
|
|
116
|
+
it('should exit immediately without warning when folder not created', async () => {
|
|
117
|
+
const { ux } = await import('@oclif/core');
|
|
118
|
+
const handler = createSigintHandler('/nonexistent', false);
|
|
119
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined);
|
|
120
|
+
handler();
|
|
121
|
+
expect(ux.warn).not.toHaveBeenCalled();
|
|
122
|
+
expect(exitSpy).toHaveBeenCalledWith(130);
|
|
123
|
+
exitSpy.mockRestore();
|
|
124
|
+
});
|
|
125
|
+
it('should exit with code 130 (SIGINT convention)', async () => {
|
|
126
|
+
const handler = createSigintHandler('/test/project', true);
|
|
127
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined);
|
|
128
|
+
handler();
|
|
129
|
+
expect(exitSpy).toHaveBeenCalledWith(130);
|
|
130
|
+
exitSpy.mockRestore();
|
|
49
131
|
});
|
|
50
132
|
});
|
|
51
133
|
});
|
|
@@ -4,7 +4,10 @@ import fs from 'node:fs/promises';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { AGENT_CONFIG_DIR } from '#config.js';
|
|
6
6
|
export async function generatePlanName(description, date = new Date()) {
|
|
7
|
-
const
|
|
7
|
+
const year = date.getFullYear();
|
|
8
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
9
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
10
|
+
const datePrefix = `${year}_${month}_${day}`;
|
|
8
11
|
const planNameSlug = await generateText({
|
|
9
12
|
prompt: 'generate_plan_name@v1',
|
|
10
13
|
variables: { description }
|
|
@@ -44,7 +44,7 @@ This starts:
|
|
|
44
44
|
In a new terminal:
|
|
45
45
|
|
|
46
46
|
```bash
|
|
47
|
-
npx output workflow run
|
|
47
|
+
npx output workflow run example_question --input '{"question": "who really is ada lovelace?"}'
|
|
48
48
|
```
|
|
49
49
|
|
|
50
50
|
### 5. Stop Services
|
package/dist/templates/project/src/{simple → workflows/example_question}/workflow.ts.template
RENAMED
|
@@ -2,7 +2,7 @@ import { workflow, z } from '@output.ai/core';
|
|
|
2
2
|
import { answerQuestion } from './steps.js';
|
|
3
3
|
|
|
4
4
|
export default workflow( {
|
|
5
|
-
name: '
|
|
5
|
+
name: 'example_question',
|
|
6
6
|
description: '{{description}}',
|
|
7
7
|
inputSchema: z.object( {
|
|
8
8
|
question: z.string().describe( 'A question to answer' )
|
package/dist/types/errors.d.ts
CHANGED
|
@@ -54,3 +54,15 @@ export declare class DirectoryCreationError extends Error {
|
|
|
54
54
|
*/
|
|
55
55
|
constructor(message: string, projectPath: string);
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Error thrown when a Claude CLI plugin command fails
|
|
59
|
+
*/
|
|
60
|
+
export declare class ClaudePluginError extends Error {
|
|
61
|
+
readonly commandName: string;
|
|
62
|
+
readonly originalError?: Error | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* @param commandName - The Claude command that failed (e.g., 'plugin update outputai')
|
|
65
|
+
* @param originalError - The underlying error from the command execution
|
|
66
|
+
*/
|
|
67
|
+
constructor(commandName: string, originalError?: Error | undefined);
|
|
68
|
+
}
|
package/dist/types/errors.js
CHANGED
|
@@ -81,3 +81,20 @@ export class DirectoryCreationError extends Error {
|
|
|
81
81
|
this.projectPath = projectPath;
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Error thrown when a Claude CLI plugin command fails
|
|
86
|
+
*/
|
|
87
|
+
export class ClaudePluginError extends Error {
|
|
88
|
+
commandName;
|
|
89
|
+
originalError;
|
|
90
|
+
/**
|
|
91
|
+
* @param commandName - The Claude command that failed (e.g., 'plugin update outputai')
|
|
92
|
+
* @param originalError - The underlying error from the command execution
|
|
93
|
+
*/
|
|
94
|
+
constructor(commandName, originalError) {
|
|
95
|
+
const originalMessage = originalError?.message || 'Unknown error';
|
|
96
|
+
super(`Claude command '${commandName}' failed: ${originalMessage}`);
|
|
97
|
+
this.commandName = commandName;
|
|
98
|
+
this.originalError = originalError;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ClaudePluginError } from './errors.js';
|
|
3
|
+
describe('ClaudePluginError', () => {
|
|
4
|
+
it('should instantiate with command name and original error', () => {
|
|
5
|
+
const originalError = new Error('Connection failed');
|
|
6
|
+
const error = new ClaudePluginError('plugin update outputai', originalError);
|
|
7
|
+
expect(error).toBeInstanceOf(Error);
|
|
8
|
+
expect(error).toBeInstanceOf(ClaudePluginError);
|
|
9
|
+
expect(error.commandName).toBe('plugin update outputai');
|
|
10
|
+
expect(error.originalError).toBe(originalError);
|
|
11
|
+
});
|
|
12
|
+
it('should format error message to include command context', () => {
|
|
13
|
+
const originalError = new Error('Command not found');
|
|
14
|
+
const error = new ClaudePluginError('plugin install outputai@outputai', originalError);
|
|
15
|
+
expect(error.message).toContain('plugin install outputai@outputai');
|
|
16
|
+
expect(error.message).toContain('Command not found');
|
|
17
|
+
});
|
|
18
|
+
});
|
package/package.json
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|