@output.ai/cli 0.7.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 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 simple --input '{"question": "who is ada lovelace?"}'
21
+ npx output workflow run example_question --input '{"question": "who is ada lovelace?"}'
22
22
  ```
23
23
 
24
24
  ## Environment Configuration
@@ -202,6 +202,10 @@ export type PostWorkflowIdTerminateBody = {
202
202
  /** Optional reason for termination */
203
203
  reason?: string;
204
204
  };
205
+ export type PostWorkflowIdTerminate200 = {
206
+ terminated?: boolean;
207
+ workflowId?: string;
208
+ };
205
209
  export type GetWorkflowIdResult200 = {
206
210
  /** The workflow execution id */
207
211
  workflowId?: string;
@@ -325,7 +329,7 @@ export declare const patchWorkflowIdStop: (id: string, options?: ApiRequestOptio
325
329
  * @summary Terminate a workflow execution (force stop)
326
330
  */
327
331
  export type postWorkflowIdTerminateResponse200 = {
328
- data: void;
332
+ data: PostWorkflowIdTerminate200;
329
333
  status: 200;
330
334
  };
331
335
  export type postWorkflowIdTerminateResponse404 = {
@@ -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
  };
@@ -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, handleRunInitError } from '#services/project_scaffold.js';
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((msg) => this.log(msg), (msg) => this.warn(msg), flags['skip-env']);
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
- const { errorMessage } = await handleRunInitError(error, (msg) => this.warn(msg));
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 no required arguments', () => {
25
+ it('should have an optional folderName argument', () => {
31
26
  expect(Init.args).toBeDefined();
32
- expect(Object.keys(Init.args)).toHaveLength(0);
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 log, warn callbacks, and skip-env flag', async () => {
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(expect.any(Function), expect.any(Function), false);
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 with error handler', async () => {
73
- const testError = new Error('Test error');
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 from handler to error', async () => {
86
- const testError = new Error('Custom 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(expect.any(Function), expect.any(Function), true);
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
  });
@@ -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
- await executeClaudeCommand(['plugin', 'marketplace', 'add', 'growthxai/output-claude-plugins'], projectRoot, { ignoreFailure: true });
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
- await executeClaudeCommand(['plugin', 'marketplace', 'update', 'outputai'], projectRoot);
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
- await executeClaudeCommand(['plugin', 'install', 'outputai@outputai', '--scope', 'project'], projectRoot);
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, skips the configuration prompt and returns false immediately
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, skips the configuration prompt and returns false immediately
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 return false if skipPrompt is true', async () => {
32
- // Create a minimal .env.example file
33
- await fs.writeFile(testState.envExamplePath, 'KEY=value');
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 simple --input src/simple/scenarios/question_ada_lovelace.json',
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 simple --input \'{"question": "Hello!"}\'')}
324
+ ${formatCommand('npx output workflow run example_question --input \'{"question": "Hello!"}\'')}
325
325
 
326
326
  ${divider}
327
327
 
@@ -1,6 +1,31 @@
1
- export declare function runInit(log: (message: string) => void, _warn: (message: string) => void, skipEnv?: boolean): Promise<void>;
2
- export declare function handleRunInitError(error: unknown, warn: (message: string) => void): Promise<{
3
- shouldCleanup: boolean;
4
- errorMessage: string;
5
- projectPath: string | null;
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, removeDirectory } from '#utils/file_system.js';
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
- const getProjectConfig = async () => {
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 projectName = await input({
17
- message: 'What is your project name?',
18
- default: 'my-outputai-workflows'
19
- }) || 'my-outputai-workflows';
20
- const folderName = await input({
21
- message: 'What folder name should be used?',
22
- default: kebabCase(projectName)
23
- }) || kebabCase(projectName);
24
- const description = await input({
25
- message: 'What is your project description? (optional)',
26
- default: `An Output SDK workflow for ${kebabCase(projectName)}`
27
- }) || `An Output SDK workflow for ${kebabCase(projectName)}`;
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
- function extractProjectPath(error) {
65
- if (error instanceof FolderAlreadyExistsError) {
66
- return error.folderPath;
67
- }
68
- const errorObject = error;
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
- if (errorCode === 'EACCES') {
96
- return {
97
- shouldCleanup: projectPath !== null,
98
- errorMessage: `Permission denied${pathSuffix}`
99
- };
100
- }
101
- if (errorCode === 'ENOSPC') {
102
- return {
103
- shouldCleanup: projectPath !== null,
104
- errorMessage: `Not enough disk space${pathSuffix}`
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
- const originalMessage = error instanceof Error ? error.message : String(error);
120
- return {
121
- shouldCleanup: projectPath !== null,
122
- errorMessage: `Failed to create project${pathSuffix}: ${originalMessage}`
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
- export async function runInit(log, _warn, skipEnv = false) {
126
- const config = await getProjectConfig();
127
- try {
128
- createDirectory(config.projectPath);
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
- log(`✅ Created project folder: ${config.folderName}`);
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
- log(`✅ Created ${filesCreated.length} project files`);
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
- const enrichedError = error;
140
- enrichedError.projectPath = config.projectPath;
141
- throw enrichedError;
212
+ handleRunInitError(error, state.projectPath || null, state.projectFolderCreated);
142
213
  }
143
- const envConfigured = await configureEnvironmentVariables(config.projectPath, skipEnv);
144
- if (envConfigured) {
145
- log('🔐 Environment variables configured in .env');
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 { handleRunInitError } from './project_scaffold.js';
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('handleRunInitError', () => {
29
- it('should handle UserCancelledError', async () => {
30
- const error = new UserCancelledError();
31
- const warn = vi.fn();
32
- const result = await handleRunInitError(error, warn);
33
- expect(result.errorMessage).toBe(error.message);
34
- expect(result.shouldCleanup).toBe(false);
35
- });
36
- it('should return error message for generic errors including original message', async () => {
37
- const error = new Error('Unknown error');
38
- const warn = vi.fn();
39
- const result = await handleRunInitError(error, warn);
40
- expect(result.errorMessage).toBe('Failed to create project: Unknown error');
41
- });
42
- it('should include project path in error message when available', async () => {
43
- const error = new Error('npm install failed');
44
- error.projectPath = '/Users/test/my-project';
45
- const warn = vi.fn();
46
- const result = await handleRunInitError(error, warn);
47
- expect(result.errorMessage).toBe('Failed to create project at /Users/test/my-project: npm install failed');
48
- expect(result.projectPath).toBe('/Users/test/my-project');
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
  });
@@ -44,7 +44,7 @@ This starts:
44
44
  In a new terminal:
45
45
 
46
46
  ```bash
47
- npx output workflow run simple --input '{"question": "who really is ada lovelace?"}'
47
+ npx output workflow run example_question --input '{"question": "who really is ada lovelace?"}'
48
48
  ```
49
49
 
50
50
  ### 5. Stop Services
@@ -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: 'simple',
5
+ name: 'example_question',
6
6
  description: '{{description}}',
7
7
  inputSchema: z.object( {
8
8
  question: z.string().describe( 'A question to answer' )
@@ -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
+ }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@output.ai/cli",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "CLI for Output.ai workflow generation",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",