@outputai/cli 0.2.1-next.af8a069.0 → 0.2.1-next.b87b58f.0

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.
Files changed (71) hide show
  1. package/bin/run.js +4 -2
  2. package/dist/api/generated/api.d.ts +160 -7
  3. package/dist/api/generated/api.js +33 -1
  4. package/dist/api/http_client.js +24 -19
  5. package/dist/assets/docker/docker-compose-dev.yml +3 -7
  6. package/dist/commands/dev/index.js +12 -1
  7. package/dist/commands/fix.js +1 -1
  8. package/dist/commands/fix.spec.js +2 -2
  9. package/dist/commands/init.d.ts +1 -0
  10. package/dist/commands/init.js +5 -1
  11. package/dist/commands/init.spec.js +10 -5
  12. package/dist/commands/update.js +1 -1
  13. package/dist/commands/update.spec.js +2 -2
  14. package/dist/commands/workflow/plan.js +5 -1
  15. package/dist/commands/workflow/plan.spec.js +3 -2
  16. package/dist/commands/workflow/run.d.ts +1 -1
  17. package/dist/commands/workflow/run.js +8 -5
  18. package/dist/commands/workflow/run.spec.js +3 -3
  19. package/dist/commands/workflow/runs/list.d.ts +1 -0
  20. package/dist/commands/workflow/runs/list.js +7 -0
  21. package/dist/commands/workflow/start.d.ts +1 -1
  22. package/dist/commands/workflow/start.js +8 -5
  23. package/dist/commands/workflow/start.spec.js +1 -1
  24. package/dist/config.d.ts +11 -38
  25. package/dist/config.js +34 -42
  26. package/dist/config.spec.d.ts +1 -0
  27. package/dist/config.spec.js +129 -0
  28. package/dist/generated/framework_version.json +1 -1
  29. package/dist/hooks/init.d.ts +4 -0
  30. package/dist/hooks/init.js +17 -1
  31. package/dist/hooks/init.spec.js +79 -5
  32. package/dist/services/coding_agents.js +5 -1
  33. package/dist/services/coding_agents.spec.js +19 -6
  34. package/dist/services/credentials_configurator.js +1 -1
  35. package/dist/services/docker.js +5 -2
  36. package/dist/services/docker.spec.js +74 -3
  37. package/dist/services/env_configurator.js +1 -1
  38. package/dist/services/env_configurator.spec.js +12 -12
  39. package/dist/services/messages.js +2 -1
  40. package/dist/services/project_scaffold.d.ts +1 -1
  41. package/dist/services/project_scaffold.js +17 -2
  42. package/dist/services/project_scaffold.spec.js +6 -6
  43. package/dist/services/workflow_builder.js +5 -1
  44. package/dist/services/workflow_builder.spec.js +3 -2
  45. package/dist/services/workflow_runs.d.ts +1 -0
  46. package/dist/services/workflow_runs.js +3 -0
  47. package/dist/templates/project/.env.example.template +17 -0
  48. package/dist/utils/credentials_loader.d.ts +1 -0
  49. package/dist/utils/credentials_loader.js +18 -0
  50. package/dist/utils/credentials_loader.spec.d.ts +1 -0
  51. package/dist/utils/credentials_loader.spec.js +84 -0
  52. package/dist/utils/env_loader.js +1 -2
  53. package/dist/utils/error_handler.js +10 -8
  54. package/dist/utils/interactive.d.ts +2 -0
  55. package/dist/utils/interactive.js +5 -0
  56. package/dist/utils/interactive.spec.d.ts +1 -0
  57. package/dist/utils/interactive.spec.js +40 -0
  58. package/dist/utils/prompt.d.ts +17 -0
  59. package/dist/utils/prompt.js +20 -0
  60. package/dist/utils/prompt.spec.d.ts +1 -0
  61. package/dist/utils/prompt.spec.js +70 -0
  62. package/dist/utils/proxy.d.ts +9 -0
  63. package/dist/utils/proxy.js +24 -0
  64. package/dist/utils/proxy.spec.d.ts +1 -0
  65. package/dist/utils/proxy.spec.js +48 -0
  66. package/dist/utils/validation.d.ts +13 -0
  67. package/dist/utils/validation.js +31 -0
  68. package/dist/utils/validation.spec.js +47 -1
  69. package/dist/views/dev.js +3 -3
  70. package/dist/views/workflow/list.js +10 -8
  71. package/package.json +10 -9
@@ -6,6 +6,7 @@ export default class WorkflowRunsList extends Command {
6
6
  workflowName: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
7
7
  };
8
8
  static flags: {
9
+ catalog: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
10
  limit: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
10
11
  format: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
12
  };
@@ -55,6 +55,7 @@ export default class WorkflowRunsList extends Command {
55
55
  '<%= config.bin %> <%= command.id %>',
56
56
  '<%= config.bin %> <%= command.id %> simple',
57
57
  '<%= config.bin %> <%= command.id %> simple --limit 10',
58
+ '<%= config.bin %> <%= command.id %> --catalog my-catalog',
58
59
  '<%= config.bin %> <%= command.id %> --format json',
59
60
  '<%= config.bin %> <%= command.id %> --format table'
60
61
  ];
@@ -65,6 +66,11 @@ export default class WorkflowRunsList extends Command {
65
66
  })
66
67
  };
67
68
  static flags = {
69
+ catalog: Flags.string({
70
+ char: 'c',
71
+ description: 'Filter runs by catalog (defaults to OUTPUT_CATALOG_ID)',
72
+ env: 'OUTPUT_CATALOG_ID'
73
+ }),
68
74
  limit: Flags.integer({
69
75
  char: 'l',
70
76
  description: 'Maximum number of runs to return',
@@ -81,6 +87,7 @@ export default class WorkflowRunsList extends Command {
81
87
  const { args, flags } = await this.parse(WorkflowRunsList);
82
88
  const { runs, count } = await fetchWorkflowRuns({
83
89
  workflowType: args.workflowName,
90
+ catalog: flags.catalog,
84
91
  limit: flags.limit
85
92
  });
86
93
  if (runs.length === 0) {
@@ -8,7 +8,7 @@ export default class WorkflowStart extends Command {
8
8
  };
9
9
  static flags: {
10
10
  input: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
- 'task-queue': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ catalog: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
12
  };
13
13
  run(): Promise<void>;
14
14
  catch(error: Error): Promise<void>;
@@ -8,7 +8,7 @@ export default class WorkflowStart extends Command {
8
8
  '<%= config.bin %> <%= command.id %> simple basic_input',
9
9
  '<%= config.bin %> <%= command.id %> simple --input \'{"values":[1,2,3]}\'',
10
10
  '<%= config.bin %> <%= command.id %> simple --input input.json',
11
- '<%= config.bin %> <%= command.id %> simple --input \'{"key":"value"}\' --task-queue my-queue'
11
+ '<%= config.bin %> <%= command.id %> simple --input \'{"key":"value"}\' --catalog my-catalog'
12
12
  ];
13
13
  static args = {
14
14
  workflowName: Args.string({
@@ -26,9 +26,12 @@ export default class WorkflowStart extends Command {
26
26
  description: 'Workflow input as JSON string or file path (overrides scenario)',
27
27
  required: false
28
28
  }),
29
- 'task-queue': Flags.string({
30
- char: 'q',
31
- description: 'Task queue name for workflow execution (defaults to OUTPUT_CATALOG_ID)',
29
+ catalog: Flags.string({
30
+ char: 'c',
31
+ aliases: ['task-queue'],
32
+ charAliases: ['q'],
33
+ deprecateAliases: true,
34
+ description: 'Catalog name for workflow execution (defaults to OUTPUT_CATALOG_ID)',
32
35
  env: 'OUTPUT_CATALOG_ID'
33
36
  })
34
37
  };
@@ -39,7 +42,7 @@ export default class WorkflowStart extends Command {
39
42
  const response = await postWorkflowStart({
40
43
  workflowName: args.workflowName,
41
44
  input,
42
- taskQueue: flags['task-queue']
45
+ catalog: flags.catalog
43
46
  });
44
47
  if (!response || !response.data) {
45
48
  this.error('API returned invalid response', { exit: 1 });
@@ -14,7 +14,7 @@ describe('workflow start command', () => {
14
14
  expect(WorkflowStart.description).toContain('Start a workflow');
15
15
  expect(WorkflowStart.args).toHaveProperty('workflowName');
16
16
  expect(WorkflowStart.flags).toHaveProperty('input');
17
- expect(WorkflowStart.flags).toHaveProperty('task-queue');
17
+ expect(WorkflowStart.flags).toHaveProperty('catalog');
18
18
  });
19
19
  it('should have correct flag configuration', async () => {
20
20
  const WorkflowStart = (await import('./start.js')).default;
package/dist/config.d.ts CHANGED
@@ -1,44 +1,17 @@
1
- /**
2
- * CLI configuration
3
- */
4
1
  export declare const config: {
5
- /**
6
- * Base URL for the Output.ai API server
7
- * Can be overridden with OUTPUT_API_URL environment variable
8
- */
9
- apiUrl: string;
10
- /**
11
- * API authentication token
12
- * Set via OUTPUT_API_AUTH_TOKEN environment variable
13
- */
14
- apiToken: string | undefined;
15
- /**
16
- * Default timeout for API requests (in milliseconds)
17
- */
2
+ readonly apiUrl: string;
3
+ readonly ports: {
4
+ temporalUi: number;
5
+ api: number;
6
+ };
7
+ readonly temporalUiUrl: string;
8
+ readonly apiToken: string | undefined;
18
9
  requestTimeout: number;
19
- /**
20
- * Docker Compose project name
21
- * Can be overridden with DOCKER_SERVICE_NAME environment variable
22
- */
23
- dockerServiceName: string;
24
- /**
25
- * Set the debug mode
26
- */
27
- debugMode: boolean;
28
- /**
29
- * Where the env vars are stored, defaults to `.env`
30
- */
31
- envFile: string;
32
- /**
33
- * Agent configuration directory name
34
- */
10
+ readonly dockerServiceName: string;
11
+ readonly debugMode: boolean;
12
+ readonly envFile: string;
35
13
  agentConfigDir: string;
36
- /**
37
- * S3 configuration for remote trace storage
38
- * Set via OUTPUT_TRACE_REMOTE_S3_BUCKET, OUTPUT_AWS_REGION,
39
- * OUTPUT_AWS_ACCESS_KEY_ID, and OUTPUT_AWS_SECRET_ACCESS_KEY environment variables
40
- */
41
- s3: {
14
+ readonly s3: {
42
15
  bucket: string | undefined;
43
16
  region: string | undefined;
44
17
  accessKeyId: string | undefined;
package/dist/config.js CHANGED
@@ -1,47 +1,39 @@
1
- /**
2
- * CLI configuration
3
- */
1
+ import { parsePort } from '#utils/validation.js';
2
+ const DEFAULT_API_PORT = 3001;
3
+ const DEFAULT_TEMPORAL_UI_PORT = 8080;
4
4
  export const config = {
5
- /**
6
- * Base URL for the Output.ai API server
7
- * Can be overridden with OUTPUT_API_URL environment variable
8
- */
9
- apiUrl: process.env.OUTPUT_API_URL || 'http://localhost:3001',
10
- /**
11
- * API authentication token
12
- * Set via OUTPUT_API_AUTH_TOKEN environment variable
13
- */
14
- apiToken: process.env.OUTPUT_API_AUTH_TOKEN,
15
- /**
16
- * Default timeout for API requests (in milliseconds)
17
- */
5
+ get apiUrl() {
6
+ return process.env.OUTPUT_API_URL || `http://localhost:${this.ports.api}`;
7
+ },
8
+ get ports() {
9
+ return {
10
+ temporalUi: parsePort(process.env.OUTPUT_TEMPORAL_UI_HOST_PORT, DEFAULT_TEMPORAL_UI_PORT, 'OUTPUT_TEMPORAL_UI_HOST_PORT'),
11
+ api: parsePort(process.env.OUTPUT_API_HOST_PORT, DEFAULT_API_PORT, 'OUTPUT_API_HOST_PORT')
12
+ };
13
+ },
14
+ get temporalUiUrl() {
15
+ return `http://localhost:${this.ports.temporalUi}`;
16
+ },
17
+ get apiToken() {
18
+ return process.env.OUTPUT_API_AUTH_TOKEN;
19
+ },
18
20
  requestTimeout: 30000,
19
- /**
20
- * Docker Compose project name
21
- * Can be overridden with DOCKER_SERVICE_NAME environment variable
22
- */
23
- dockerServiceName: process.env.DOCKER_SERVICE_NAME || 'output-sdk',
24
- /**
25
- * Set the debug mode
26
- */
27
- debugMode: process.env.OUTPUT_DEBUG === 'true',
28
- /**
29
- * Where the env vars are stored, defaults to `.env`
30
- */
31
- envFile: process.env.OUTPUT_CLI_ENV || '.env',
32
- /**
33
- * Agent configuration directory name
34
- */
21
+ get dockerServiceName() {
22
+ return process.env.DOCKER_SERVICE_NAME || 'output-sdk';
23
+ },
24
+ get debugMode() {
25
+ return process.env.OUTPUT_DEBUG === 'true';
26
+ },
27
+ get envFile() {
28
+ return process.env.OUTPUT_CLI_ENV || '.env';
29
+ },
35
30
  agentConfigDir: '.outputai',
36
- /**
37
- * S3 configuration for remote trace storage
38
- * Set via OUTPUT_TRACE_REMOTE_S3_BUCKET, OUTPUT_AWS_REGION,
39
- * OUTPUT_AWS_ACCESS_KEY_ID, and OUTPUT_AWS_SECRET_ACCESS_KEY environment variables
40
- */
41
- s3: {
42
- bucket: process.env.OUTPUT_TRACE_REMOTE_S3_BUCKET,
43
- region: process.env.OUTPUT_AWS_REGION,
44
- accessKeyId: process.env.OUTPUT_AWS_ACCESS_KEY_ID,
45
- secretAccessKey: process.env.OUTPUT_AWS_SECRET_ACCESS_KEY
31
+ get s3() {
32
+ return {
33
+ bucket: process.env.OUTPUT_TRACE_REMOTE_S3_BUCKET,
34
+ region: process.env.OUTPUT_AWS_REGION,
35
+ accessKeyId: process.env.OUTPUT_AWS_ACCESS_KEY_ID,
36
+ secretAccessKey: process.env.OUTPUT_AWS_SECRET_ACCESS_KEY
37
+ };
46
38
  }
47
39
  };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { config } from '#config.js';
3
+ import { InvalidPortError } from '#utils/validation.js';
4
+ describe('config', () => {
5
+ const envVars = [
6
+ 'OUTPUT_API_URL',
7
+ 'OUTPUT_API_HOST_PORT',
8
+ 'OUTPUT_TEMPORAL_UI_HOST_PORT',
9
+ 'OUTPUT_API_AUTH_TOKEN',
10
+ 'DOCKER_SERVICE_NAME',
11
+ 'OUTPUT_DEBUG',
12
+ 'OUTPUT_CLI_ENV',
13
+ 'OUTPUT_TRACE_REMOTE_S3_BUCKET',
14
+ 'OUTPUT_AWS_REGION',
15
+ 'OUTPUT_AWS_ACCESS_KEY_ID',
16
+ 'OUTPUT_AWS_SECRET_ACCESS_KEY'
17
+ ];
18
+ const saved = {};
19
+ beforeEach(() => {
20
+ for (const key of envVars) {
21
+ saved[key] = process.env[key];
22
+ }
23
+ });
24
+ afterEach(() => {
25
+ for (const key of envVars) {
26
+ if (saved[key] === undefined) {
27
+ delete process.env[key];
28
+ }
29
+ else {
30
+ process.env[key] = saved[key];
31
+ }
32
+ }
33
+ });
34
+ it('reads env vars lazily, not at module evaluation time', () => {
35
+ process.env.OUTPUT_API_URL = 'https://lazy-test.example.com';
36
+ expect(config.apiUrl).toBe('https://lazy-test.example.com');
37
+ process.env.OUTPUT_API_URL = 'https://changed.example.com';
38
+ expect(config.apiUrl).toBe('https://changed.example.com');
39
+ });
40
+ it('falls back to defaults when env vars are unset', () => {
41
+ delete process.env.OUTPUT_API_URL;
42
+ delete process.env.OUTPUT_API_HOST_PORT;
43
+ delete process.env.OUTPUT_TEMPORAL_UI_HOST_PORT;
44
+ delete process.env.DOCKER_SERVICE_NAME;
45
+ delete process.env.OUTPUT_DEBUG;
46
+ delete process.env.OUTPUT_CLI_ENV;
47
+ expect(config.apiUrl).toBe('http://localhost:3001');
48
+ expect(config.ports).toEqual({ temporalUi: 8080, api: 3001 });
49
+ expect(config.temporalUiUrl).toBe('http://localhost:8080');
50
+ expect(config.dockerServiceName).toBe('output-sdk');
51
+ expect(config.debugMode).toBe(false);
52
+ expect(config.envFile).toBe('.env');
53
+ });
54
+ it('derives apiUrl from OUTPUT_API_HOST_PORT when OUTPUT_API_URL is unset', () => {
55
+ delete process.env.OUTPUT_API_URL;
56
+ process.env.OUTPUT_API_HOST_PORT = '3002';
57
+ expect(config.apiUrl).toBe('http://localhost:3002');
58
+ });
59
+ it('OUTPUT_API_URL takes precedence over OUTPUT_API_HOST_PORT', () => {
60
+ process.env.OUTPUT_API_URL = 'https://api.example.com';
61
+ process.env.OUTPUT_API_HOST_PORT = '3002';
62
+ expect(config.apiUrl).toBe('https://api.example.com');
63
+ });
64
+ it('empty-string OUTPUT_API_URL falls through to OUTPUT_API_HOST_PORT (|| not ??)', () => {
65
+ process.env.OUTPUT_API_URL = '';
66
+ process.env.OUTPUT_API_HOST_PORT = '3002';
67
+ expect(config.apiUrl).toBe('http://localhost:3002');
68
+ });
69
+ it('reads port overrides from env vars', () => {
70
+ process.env.OUTPUT_TEMPORAL_UI_HOST_PORT = '8081';
71
+ process.env.OUTPUT_API_HOST_PORT = '3002';
72
+ expect(config.ports).toEqual({ temporalUi: 8081, api: 3002 });
73
+ expect(config.temporalUiUrl).toBe('http://localhost:8081');
74
+ });
75
+ it('treats empty-string port env vars as unset (matches Compose semantics)', () => {
76
+ delete process.env.OUTPUT_API_URL;
77
+ process.env.OUTPUT_API_HOST_PORT = '';
78
+ process.env.OUTPUT_TEMPORAL_UI_HOST_PORT = '';
79
+ expect(config.apiUrl).toBe('http://localhost:3001');
80
+ expect(config.ports).toEqual({ temporalUi: 8080, api: 3001 });
81
+ expect(config.temporalUiUrl).toBe('http://localhost:8080');
82
+ });
83
+ it('throws InvalidPortError on non-numeric port values', () => {
84
+ delete process.env.OUTPUT_API_URL;
85
+ process.env.OUTPUT_API_HOST_PORT = 'abc';
86
+ expect(() => config.ports).toThrow(InvalidPortError);
87
+ expect(() => config.apiUrl).toThrow(InvalidPortError);
88
+ });
89
+ it('throws InvalidPortError on out-of-range port values', () => {
90
+ process.env.OUTPUT_API_HOST_PORT = '99999';
91
+ expect(() => config.ports).toThrow(InvalidPortError);
92
+ });
93
+ it('throws InvalidPortError on trailing-junk port values', () => {
94
+ process.env.OUTPUT_TEMPORAL_UI_HOST_PORT = '8080abc';
95
+ expect(() => config.ports).toThrow(InvalidPortError);
96
+ });
97
+ it('throws InvalidPortError on port 0 (Compose treats 0 as ephemeral - prevents CLI/Docker desync)', () => {
98
+ process.env.OUTPUT_API_HOST_PORT = '0';
99
+ expect(() => config.ports).toThrow(InvalidPortError);
100
+ });
101
+ it('reads apiToken from env', () => {
102
+ process.env.OUTPUT_API_AUTH_TOKEN = 'test-token-123';
103
+ expect(config.apiToken).toBe('test-token-123');
104
+ delete process.env.OUTPUT_API_AUTH_TOKEN;
105
+ expect(config.apiToken).toBeUndefined();
106
+ });
107
+ it('reads debugMode as boolean', () => {
108
+ process.env.OUTPUT_DEBUG = 'true';
109
+ expect(config.debugMode).toBe(true);
110
+ process.env.OUTPUT_DEBUG = 'false';
111
+ expect(config.debugMode).toBe(false);
112
+ });
113
+ it('reads s3 config lazily', () => {
114
+ process.env.OUTPUT_TRACE_REMOTE_S3_BUCKET = 'my-bucket';
115
+ process.env.OUTPUT_AWS_REGION = 'us-west-2';
116
+ process.env.OUTPUT_AWS_ACCESS_KEY_ID = 'AKIA123';
117
+ process.env.OUTPUT_AWS_SECRET_ACCESS_KEY = 'secret123';
118
+ expect(config.s3).toEqual({
119
+ bucket: 'my-bucket',
120
+ region: 'us-west-2',
121
+ accessKeyId: 'AKIA123',
122
+ secretAccessKey: 'secret123'
123
+ });
124
+ });
125
+ it('has static properties that are not env-derived', () => {
126
+ expect(config.requestTimeout).toBe(30000);
127
+ expect(config.agentConfigDir).toBe('.outputai');
128
+ });
129
+ });
@@ -1,3 +1,3 @@
1
1
  {
2
- "framework": "0.2.1-next.af8a069.0"
2
+ "framework": "0.2.1-next.b87b58f.0"
3
3
  }
@@ -1,3 +1,7 @@
1
1
  import { Hook } from '@oclif/core';
2
+ export declare const INTERACTIVE_FLAGS: string[];
3
+ export declare const GLOBAL_FLAGS: Set<string>;
4
+ export declare const hasInteractiveFlag: (argv: string[]) => boolean;
5
+ export declare const stripGlobalFlags: (argv: string[]) => void;
2
6
  declare const hook: Hook<'init'>;
3
7
  export default hook;
@@ -1,6 +1,22 @@
1
1
  import { ux } from '@oclif/core';
2
2
  import { checkForUpdate } from '#services/version_check.js';
3
- const hook = async function () {
3
+ import { setNonInteractive } from '#utils/interactive.js';
4
+ export const INTERACTIVE_FLAGS = ['--yes', '--non-interactive'];
5
+ export const GLOBAL_FLAGS = new Set(INTERACTIVE_FLAGS);
6
+ export const hasInteractiveFlag = (argv) => argv.some(arg => INTERACTIVE_FLAGS.includes(arg));
7
+ export const stripGlobalFlags = (argv) => {
8
+ const kept = argv.filter(arg => !GLOBAL_FLAGS.has(arg));
9
+ if (kept.length !== argv.length) {
10
+ argv.splice(0, argv.length, ...kept);
11
+ }
12
+ };
13
+ const hook = async function (opts) {
14
+ const interactive = hasInteractiveFlag(opts.argv) || hasInteractiveFlag(process.argv);
15
+ stripGlobalFlags(opts.argv);
16
+ stripGlobalFlags(process.argv);
17
+ if (interactive) {
18
+ setNonInteractive(true);
19
+ }
4
20
  try {
5
21
  const result = await checkForUpdate(this.config.version, this.config.cacheDir);
6
22
  if (!result.updateAvailable) {
@@ -1,9 +1,13 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
3
3
  import { checkForUpdate } from '#services/version_check.js';
4
+ import { setNonInteractive } from '#utils/interactive.js';
4
5
  vi.mock('#services/version_check.js', () => ({
5
6
  checkForUpdate: vi.fn()
6
7
  }));
8
+ vi.mock('#utils/interactive.js', () => ({
9
+ setNonInteractive: vi.fn()
10
+ }));
7
11
  vi.mock('@oclif/core', () => ({
8
12
  ux: {
9
13
  stdout: vi.fn(),
@@ -11,7 +15,7 @@ vi.mock('@oclif/core', () => ({
11
15
  }
12
16
  }));
13
17
  import { ux } from '@oclif/core';
14
- import hook from './init.js';
18
+ import hook, { hasInteractiveFlag, stripGlobalFlags } from './init.js';
15
19
  describe('init hook', () => {
16
20
  beforeEach(() => {
17
21
  vi.clearAllMocks();
@@ -26,7 +30,7 @@ describe('init hook', () => {
26
30
  latestVersion: '1.0.0'
27
31
  });
28
32
  const ctx = createHookContext();
29
- await hook.call(ctx, {});
33
+ await hook.call(ctx, { argv: [], id: undefined });
30
34
  expect(checkForUpdate).toHaveBeenCalledWith('0.8.4', '/tmp/test-cache');
31
35
  expect(ux.stdout).toHaveBeenCalled();
32
36
  const output = vi.mocked(ux.stdout).mock.calls.map(c => c[0]).join('\n');
@@ -42,13 +46,83 @@ describe('init hook', () => {
42
46
  latestVersion: '0.8.4'
43
47
  });
44
48
  const ctx = createHookContext();
45
- await hook.call(ctx, {});
49
+ await hook.call(ctx, { argv: [], id: undefined });
46
50
  expect(ux.stdout).not.toHaveBeenCalled();
47
51
  });
48
52
  it('should silently handle errors', async () => {
49
53
  vi.mocked(checkForUpdate).mockRejectedValue(new Error('network failure'));
50
54
  const ctx = createHookContext();
51
- await hook.call(ctx, {});
55
+ await hook.call(ctx, { argv: [], id: undefined });
52
56
  expect(ux.stdout).not.toHaveBeenCalled();
53
57
  });
58
+ describe('global interactive flags', () => {
59
+ const originalArgv = process.argv;
60
+ beforeEach(() => {
61
+ vi.mocked(checkForUpdate).mockResolvedValue({
62
+ updateAvailable: false,
63
+ currentVersion: '0.8.4',
64
+ latestVersion: '0.8.4'
65
+ });
66
+ });
67
+ afterEach(() => {
68
+ process.argv = originalArgv;
69
+ });
70
+ it('should mutate opts.argv in place to strip --yes', async () => {
71
+ process.argv = ['node', 'run.js', 'init', '--yes', 'my-project'];
72
+ const optsArgv = ['--yes', 'my-project'];
73
+ const argvRef = optsArgv;
74
+ const ctx = createHookContext();
75
+ await hook.call(ctx, { argv: optsArgv, id: 'init' });
76
+ expect(setNonInteractive).toHaveBeenCalledWith(true);
77
+ expect(optsArgv).toBe(argvRef);
78
+ expect(optsArgv).toEqual(['my-project']);
79
+ expect(process.argv).toEqual(['node', 'run.js', 'init', 'my-project']);
80
+ });
81
+ it('should mutate opts.argv in place to strip --non-interactive', async () => {
82
+ process.argv = ['node', 'run.js', 'init', '--non-interactive'];
83
+ const optsArgv = ['--non-interactive'];
84
+ const ctx = createHookContext();
85
+ await hook.call(ctx, { argv: optsArgv, id: 'init' });
86
+ expect(setNonInteractive).toHaveBeenCalledWith(true);
87
+ expect(optsArgv).toEqual([]);
88
+ expect(process.argv).toEqual(['node', 'run.js', 'init']);
89
+ });
90
+ it('should leave argv untouched when no global flag is present', async () => {
91
+ process.argv = ['node', 'run.js', 'init', '--skip-env'];
92
+ const optsArgv = ['--skip-env'];
93
+ const ctx = createHookContext();
94
+ await hook.call(ctx, { argv: optsArgv, id: 'init' });
95
+ expect(setNonInteractive).not.toHaveBeenCalled();
96
+ expect(optsArgv).toEqual(['--skip-env']);
97
+ expect(process.argv).toEqual(['node', 'run.js', 'init', '--skip-env']);
98
+ });
99
+ });
100
+ describe('hasInteractiveFlag', () => {
101
+ it('returns true when --yes is present', () => {
102
+ expect(hasInteractiveFlag(['init', '--yes', 'foo'])).toBe(true);
103
+ });
104
+ it('returns true when --non-interactive is present', () => {
105
+ expect(hasInteractiveFlag(['--non-interactive'])).toBe(true);
106
+ });
107
+ it('returns false for unrelated flags', () => {
108
+ expect(hasInteractiveFlag(['init', '--skip-env', '--skip-git'])).toBe(false);
109
+ });
110
+ it('returns false for an empty argv', () => {
111
+ expect(hasInteractiveFlag([])).toBe(false);
112
+ });
113
+ });
114
+ describe('stripGlobalFlags', () => {
115
+ it('mutates argv in place to remove global flags', () => {
116
+ const argv = ['init', '--yes', 'foo', '--non-interactive'];
117
+ const ref = argv;
118
+ stripGlobalFlags(argv);
119
+ expect(argv).toBe(ref);
120
+ expect(argv).toEqual(['init', 'foo']);
121
+ });
122
+ it('leaves argv untouched when no global flag is present', () => {
123
+ const argv = ['init', '--skip-env'];
124
+ stripGlobalFlags(argv);
125
+ expect(argv).toEqual(['init', '--skip-env']);
126
+ });
127
+ });
54
128
  });
@@ -7,7 +7,8 @@ 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
+ import { confirm } from '#utils/prompt.js';
11
+ import { isInteractive } from '#utils/interactive.js';
11
12
  import debugFactory from 'debug';
12
13
  import { getTemplateDir } from '#utils/paths.js';
13
14
  import { executeClaudeCommand } from '#utils/claude.js';
@@ -142,6 +143,9 @@ async function handlePluginError(error, commandName, silent = false) {
142
143
  debug('Plugin error: %s', pluginError.message);
143
144
  throw error;
144
145
  }
146
+ if (!isInteractive()) {
147
+ throw pluginError;
148
+ }
145
149
  ux.warn(pluginError.message);
146
150
  try {
147
151
  const shouldProceed = await confirm({
@@ -19,9 +19,12 @@ vi.mock('@oclif/core', () => ({
19
19
  colorize: vi.fn().mockImplementation((_color, text) => text)
20
20
  }
21
21
  }));
22
- vi.mock('@inquirer/prompts', () => ({
22
+ vi.mock('#utils/prompt.js', () => ({
23
23
  confirm: vi.fn()
24
24
  }));
25
+ vi.mock('#utils/interactive.js', () => ({
26
+ isInteractive: vi.fn(() => true)
27
+ }));
25
28
  describe('coding_agents service', () => {
26
29
  beforeEach(() => {
27
30
  vi.clearAllMocks();
@@ -157,7 +160,7 @@ describe('coding_agents service', () => {
157
160
  });
158
161
  it('should show error and prompt user when plugin commands fail', async () => {
159
162
  const { executeClaudeCommand } = await import('../utils/claude.js');
160
- const { confirm } = await import('@inquirer/prompts');
163
+ const { confirm } = await import('#utils/prompt.js');
161
164
  vi.mocked(executeClaudeCommand)
162
165
  .mockResolvedValueOnce(undefined) // marketplace add
163
166
  .mockRejectedValueOnce(new Error('Plugin update failed')); // marketplace update
@@ -169,7 +172,7 @@ describe('coding_agents service', () => {
169
172
  });
170
173
  it('should allow user to proceed without plugin setup if they confirm', async () => {
171
174
  const { executeClaudeCommand } = await import('../utils/claude.js');
172
- const { confirm } = await import('@inquirer/prompts');
175
+ const { confirm } = await import('#utils/prompt.js');
173
176
  vi.mocked(executeClaudeCommand)
174
177
  .mockRejectedValue(new Error('All plugin commands fail'));
175
178
  vi.mocked(confirm).mockResolvedValue(true);
@@ -219,7 +222,7 @@ describe('coding_agents service', () => {
219
222
  });
220
223
  it('should show error and prompt user when registerPluginMarketplace fails', async () => {
221
224
  const { executeClaudeCommand } = await import('../utils/claude.js');
222
- const { confirm } = await import('@inquirer/prompts');
225
+ const { confirm } = await import('#utils/prompt.js');
223
226
  vi.mocked(executeClaudeCommand)
224
227
  .mockResolvedValueOnce(undefined) // marketplace add
225
228
  .mockRejectedValueOnce(new Error('Plugin update failed')); // marketplace update
@@ -231,7 +234,7 @@ describe('coding_agents service', () => {
231
234
  });
232
235
  it('should show error and prompt user when installOutputAIPlugin fails', async () => {
233
236
  const { executeClaudeCommand } = await import('../utils/claude.js');
234
- const { confirm } = await import('@inquirer/prompts');
237
+ const { confirm } = await import('#utils/prompt.js');
235
238
  vi.mocked(executeClaudeCommand)
236
239
  .mockResolvedValueOnce(undefined) // marketplace add
237
240
  .mockResolvedValueOnce(undefined) // marketplace update
@@ -244,7 +247,7 @@ describe('coding_agents service', () => {
244
247
  });
245
248
  it('should allow user to proceed without plugin setup if they confirm', async () => {
246
249
  const { executeClaudeCommand } = await import('../utils/claude.js');
247
- const { confirm } = await import('@inquirer/prompts');
250
+ const { confirm } = await import('#utils/prompt.js');
248
251
  vi.mocked(executeClaudeCommand)
249
252
  .mockRejectedValue(new Error('All plugin commands fail'));
250
253
  vi.mocked(confirm).mockResolvedValue(true);
@@ -252,5 +255,15 @@ describe('coding_agents service', () => {
252
255
  // File operations should still complete
253
256
  expect(fs.mkdir).toHaveBeenCalled();
254
257
  });
258
+ it('should rethrow plugin error in non-interactive mode without prompting', async () => {
259
+ const { executeClaudeCommand } = await import('../utils/claude.js');
260
+ const { confirm } = await import('#utils/prompt.js');
261
+ const { isInteractive } = await import('#utils/interactive.js');
262
+ vi.mocked(isInteractive).mockReturnValueOnce(false);
263
+ vi.mocked(executeClaudeCommand)
264
+ .mockRejectedValueOnce(new Error('Plugin marketplace add failed'));
265
+ await expect(initializeAgentConfig({ projectRoot: '/test/project', force: true })).rejects.toThrow(/plugin marketplace add/i);
266
+ expect(confirm).not.toHaveBeenCalled();
267
+ });
255
268
  });
256
269
  });
@@ -1,4 +1,4 @@
1
- import { password, confirm } from '@inquirer/prompts';
1
+ import { password, confirm } from '#utils/prompt.js';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { ux } from '@oclif/core';