@outputai/cli 0.1.12 → 0.1.13-dev.01b8dea.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 (66) hide show
  1. package/bin/run.js +2 -0
  2. package/dist/api/generated/api.d.ts +4 -0
  3. package/dist/assets/docker/docker-compose-dev.yml +1 -1
  4. package/dist/commands/fix.js +1 -1
  5. package/dist/commands/fix.spec.js +2 -2
  6. package/dist/commands/update.js +1 -1
  7. package/dist/commands/update.spec.js +2 -2
  8. package/dist/commands/workflow/generate.js +3 -1
  9. package/dist/commands/workflow/generate.spec.js +12 -0
  10. package/dist/commands/workflow/list.d.ts +1 -0
  11. package/dist/commands/workflow/list.js +12 -6
  12. package/dist/commands/workflow/list.spec.js +21 -0
  13. package/dist/commands/workflow/plan.js +5 -1
  14. package/dist/commands/workflow/plan.spec.js +3 -2
  15. package/dist/commands/workflow/run.js +2 -1
  16. package/dist/commands/workflow/run.spec.js +1 -0
  17. package/dist/commands/workflow/start.js +2 -1
  18. package/dist/commands/workflow/start.spec.js +1 -0
  19. package/dist/components/command_footer.d.ts +8 -0
  20. package/dist/components/command_footer.js +4 -0
  21. package/dist/components/status_icon.d.ts +11 -0
  22. package/dist/components/status_icon.js +25 -0
  23. package/dist/components/workflow_summary.d.ts +10 -0
  24. package/dist/components/workflow_summary.js +4 -0
  25. package/dist/generated/framework_version.json +1 -1
  26. package/dist/hooks/init.js +4 -0
  27. package/dist/services/claude_client.js +4 -1
  28. package/dist/services/coding_agents.js +1 -1
  29. package/dist/services/coding_agents.spec.js +6 -6
  30. package/dist/services/credentials_configurator.js +1 -1
  31. package/dist/services/docker.d.ts +1 -3
  32. package/dist/services/docker.js +38 -13
  33. package/dist/services/env_configurator.js +1 -1
  34. package/dist/services/env_configurator.spec.js +12 -12
  35. package/dist/services/messages.d.ts +1 -1
  36. package/dist/services/messages.js +2 -2
  37. package/dist/services/project_scaffold.js +2 -2
  38. package/dist/services/project_scaffold.spec.js +6 -6
  39. package/dist/services/workflow_builder.js +5 -1
  40. package/dist/services/workflow_builder.spec.js +3 -2
  41. package/dist/templates/agent_instructions/CLAUDE.md.template +36 -2
  42. package/dist/templates/agent_instructions/dotclaude/settings.json.template +2 -2
  43. package/dist/utils/date_formatter.d.ts +11 -1
  44. package/dist/utils/date_formatter.js +26 -1
  45. package/dist/utils/interactive.d.ts +2 -0
  46. package/dist/utils/interactive.js +5 -0
  47. package/dist/utils/interactive.spec.d.ts +1 -0
  48. package/dist/utils/interactive.spec.js +40 -0
  49. package/dist/utils/open_url.d.ts +1 -0
  50. package/dist/utils/open_url.js +12 -0
  51. package/dist/utils/prompt.d.ts +17 -0
  52. package/dist/utils/prompt.js +20 -0
  53. package/dist/utils/prompt.spec.d.ts +1 -0
  54. package/dist/utils/prompt.spec.js +74 -0
  55. package/dist/utils/proxy.d.ts +1 -0
  56. package/dist/utils/proxy.js +9 -0
  57. package/dist/utils/proxy.spec.d.ts +1 -0
  58. package/dist/utils/proxy.spec.js +39 -0
  59. package/dist/utils/workflow_dir_parser.d.ts +5 -0
  60. package/dist/utils/workflow_dir_parser.js +39 -0
  61. package/dist/utils/workflow_dir_parser.spec.d.ts +1 -0
  62. package/dist/utils/workflow_dir_parser.spec.js +74 -0
  63. package/dist/views/dev.js +62 -26
  64. package/dist/views/workflow/list.d.ts +6 -0
  65. package/dist/views/workflow/list.js +127 -0
  66. package/package.json +12 -11
@@ -3,7 +3,7 @@ import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { configureEnvironmentVariables } from './env_configurator.js';
5
5
  // Mock inquirer prompts
6
- vi.mock('@inquirer/prompts', () => ({
6
+ vi.mock('#utils/prompt.js', () => ({
7
7
  input: vi.fn(),
8
8
  confirm: vi.fn(),
9
9
  password: vi.fn()
@@ -45,7 +45,7 @@ describe('configureEnvironmentVariables', () => {
45
45
  expect(result).toBe(false);
46
46
  });
47
47
  it('should return false if user declines configuration', async () => {
48
- const { confirm } = await import('@inquirer/prompts');
48
+ const { confirm } = await import('#utils/prompt.js');
49
49
  vi.mocked(confirm).mockResolvedValue(false);
50
50
  await fs.writeFile(testState.envExamplePath, '# API key\nAPIKEY=');
51
51
  const result = await configureEnvironmentVariables(testState.tempDir, false);
@@ -53,14 +53,14 @@ describe('configureEnvironmentVariables', () => {
53
53
  expect(vi.mocked(confirm)).toHaveBeenCalled();
54
54
  });
55
55
  it('should return false if no empty variables exist', async () => {
56
- const { confirm } = await import('@inquirer/prompts');
56
+ const { confirm } = await import('#utils/prompt.js');
57
57
  vi.mocked(confirm).mockResolvedValue(true);
58
58
  await fs.writeFile(testState.envExamplePath, 'APIKEY=my-secret-key');
59
59
  const result = await configureEnvironmentVariables(testState.tempDir, false);
60
60
  expect(result).toBe(false);
61
61
  });
62
62
  it('should copy .env.example to .env when user confirms configuration', async () => {
63
- const { input, confirm } = await import('@inquirer/prompts');
63
+ const { input, confirm } = await import('#utils/prompt.js');
64
64
  vi.mocked(confirm).mockResolvedValue(true);
65
65
  vi.mocked(input).mockResolvedValueOnce('sk-proj-123');
66
66
  const originalContent = `# API key
@@ -72,7 +72,7 @@ APIKEY=`;
72
72
  await expect(fs.access(testState.envPath)).resolves.toBeUndefined();
73
73
  });
74
74
  it('should write configured values to .env while leaving .env.example unchanged', async () => {
75
- const { input, confirm } = await import('@inquirer/prompts');
75
+ const { input, confirm } = await import('#utils/prompt.js');
76
76
  vi.mocked(confirm).mockResolvedValue(true);
77
77
  vi.mocked(input).mockResolvedValueOnce('sk-proj-123');
78
78
  const originalContent = `# API key
@@ -88,7 +88,7 @@ APIKEY=`;
88
88
  expect(envExampleContent).toBe(originalContent);
89
89
  });
90
90
  it('should prompt for empty variables and update .env', async () => {
91
- const { input, confirm } = await import('@inquirer/prompts');
91
+ const { input, confirm } = await import('#utils/prompt.js');
92
92
  vi.mocked(confirm).mockResolvedValue(true);
93
93
  vi.mocked(input).mockResolvedValueOnce('sk-proj-123');
94
94
  vi.mocked(input).mockResolvedValueOnce('');
@@ -105,7 +105,7 @@ OPENAI_API_KEY=`);
105
105
  expect(content).toContain('OPENAI_API_KEY=');
106
106
  });
107
107
  it('should preserve comments in .env file', async () => {
108
- const { input, confirm } = await import('@inquirer/prompts');
108
+ const { input, confirm } = await import('#utils/prompt.js');
109
109
  vi.mocked(confirm).mockResolvedValue(true);
110
110
  vi.mocked(input).mockResolvedValueOnce('test-key');
111
111
  const originalContent = `# This is a comment
@@ -123,7 +123,7 @@ OTHER=value`;
123
123
  expect(content).toContain('OTHER=value');
124
124
  });
125
125
  it('should skip placeholder values and only prompt for truly empty variables', async () => {
126
- const { input, confirm } = await import('@inquirer/prompts');
126
+ const { input, confirm } = await import('#utils/prompt.js');
127
127
  vi.mocked(confirm).mockResolvedValue(true);
128
128
  vi.mocked(input).mockResolvedValueOnce('new-key');
129
129
  await fs.writeFile(testState.envExamplePath, `APIKEY=your_api_key_here
@@ -136,7 +136,7 @@ EMPTY_KEY=`);
136
136
  }));
137
137
  });
138
138
  it('should skip variables with existing values', async () => {
139
- const { input, confirm } = await import('@inquirer/prompts');
139
+ const { input, confirm } = await import('#utils/prompt.js');
140
140
  vi.mocked(confirm).mockResolvedValue(true);
141
141
  vi.mocked(input).mockResolvedValueOnce('new-key');
142
142
  await fs.writeFile(testState.envExamplePath, `EXISTING_KEY=existing-value
@@ -147,7 +147,7 @@ EMPTY_KEY=`);
147
147
  expect(vi.mocked(input)).toHaveBeenCalledTimes(1);
148
148
  });
149
149
  it('should handle case where .env already exists (overwrite with copy)', async () => {
150
- const { input, confirm } = await import('@inquirer/prompts');
150
+ const { input, confirm } = await import('#utils/prompt.js');
151
151
  vi.mocked(confirm).mockResolvedValue(true);
152
152
  vi.mocked(input).mockResolvedValueOnce('new-configured-value');
153
153
  // Create existing .env with old content
@@ -162,7 +162,7 @@ EMPTY_KEY=`);
162
162
  expect(envContent).not.toContain('OLD_KEY');
163
163
  });
164
164
  it('should return false if an error occurs during parsing', async () => {
165
- const { confirm } = await import('@inquirer/prompts');
165
+ const { confirm } = await import('#utils/prompt.js');
166
166
  vi.mocked(confirm).mockResolvedValue(true);
167
167
  await fs.writeFile(testState.envExamplePath, 'KEY=');
168
168
  // Delete the .env.example file after access check but before parsing would happen
@@ -178,7 +178,7 @@ EMPTY_KEY=`);
178
178
  vi.mocked(fs.copyFile).mockImplementation(originalCopyFile);
179
179
  });
180
180
  it('should prompt for SECRET marker values with password input', async () => {
181
- const { password, confirm } = await import('@inquirer/prompts');
181
+ const { password, confirm } = await import('#utils/prompt.js');
182
182
  vi.mocked(confirm).mockResolvedValue(true);
183
183
  vi.mocked(password).mockResolvedValueOnce('my-secret-api-key');
184
184
  await fs.writeFile(testState.envExamplePath, `# API Key
@@ -3,4 +3,4 @@
3
3
  */
4
4
  export declare const getEjectSuccessMessage: (destPath: string, outputFile: string, binName: string) => string;
5
5
  export declare const getProjectSuccessMessage: (folderName: string, installSuccess: boolean, credentialsConfigured?: boolean) => string;
6
- export declare const getWorkflowGenerateSuccessMessage: (workflowName: string, targetDir: string, filesCreated: string[]) => string;
6
+ export declare const getWorkflowGenerateSuccessMessage: (workflowName: string, workflowId: string, scenarioName: string | undefined, targetDir: string, filesCreated: string[]) => string;
@@ -234,7 +234,7 @@ ${ux.colorize('dim', ' to manage your project secrets.')}
234
234
  ${ux.colorize('green', ux.colorize('bold', 'Happy building with Output! 🚀'))}
235
235
  `;
236
236
  };
237
- export const getWorkflowGenerateSuccessMessage = (workflowName, targetDir, filesCreated) => {
237
+ export const getWorkflowGenerateSuccessMessage = (workflowName, workflowId, scenarioName, targetDir, filesCreated) => {
238
238
  const divider = ux.colorize('dim', '─'.repeat(80));
239
239
  const bulletPoint = ux.colorize('green', '▸');
240
240
  const formattedFiles = filesCreated.map(file => {
@@ -256,7 +256,7 @@ export const getWorkflowGenerateSuccessMessage = (workflowName, targetDir, files
256
256
  },
257
257
  {
258
258
  step: 'Test your workflow',
259
- command: `npx output workflow run ${workflowName} test_input`,
259
+ command: `npx output workflow run ${workflowId}${scenarioName ? ` ${scenarioName}` : ''}`,
260
260
  note: 'Run after starting services with "npx output dev"'
261
261
  }
262
262
  ];
@@ -1,4 +1,4 @@
1
- import { input, confirm } from '@inquirer/prompts';
1
+ import { input, confirm } from '#utils/prompt.js';
2
2
  import { ux } from '@oclif/core';
3
3
  import { kebabCase, pascalCase } from 'change-case';
4
4
  import fs from 'node:fs/promises';
@@ -41,7 +41,7 @@ export async function checkDependencies() {
41
41
  try {
42
42
  const shouldProceed = await confirm({
43
43
  message: 'Would you like to proceed anyway?',
44
- default: false
44
+ default: true
45
45
  });
46
46
  if (!shouldProceed) {
47
47
  throw new UserCancelledError();
@@ -8,7 +8,7 @@ vi.mock('#utils/framework_version.js', () => ({
8
8
  })
9
9
  }));
10
10
  // Mock other dependencies
11
- vi.mock('@inquirer/prompts', () => ({
11
+ vi.mock('#utils/prompt.js', () => ({
12
12
  input: vi.fn(),
13
13
  confirm: vi.fn()
14
14
  }));
@@ -47,7 +47,7 @@ describe('project_scaffold', () => {
47
47
  });
48
48
  describe('getProjectConfig', () => {
49
49
  it('should skip all prompts when folderName is provided', async () => {
50
- const { input } = await import('@inquirer/prompts');
50
+ const { input } = await import('#utils/prompt.js');
51
51
  const config = await getProjectConfig('my-project');
52
52
  expect(config.folderName).toBe('my-project');
53
53
  expect(config.projectName).toBe('my-project');
@@ -58,7 +58,7 @@ describe('project_scaffold', () => {
58
58
  expect(config.description).toBe('AI Agents & Workflows built with Output.ai for test-folder');
59
59
  });
60
60
  it('should prompt for project name and folder name when not provided', async () => {
61
- const { input } = await import('@inquirer/prompts');
61
+ const { input } = await import('#utils/prompt.js');
62
62
  vi.mocked(input)
63
63
  .mockResolvedValueOnce('Test Project')
64
64
  .mockResolvedValueOnce('test-project');
@@ -73,7 +73,7 @@ describe('project_scaffold', () => {
73
73
  it('should not prompt when all dependencies are available', async () => {
74
74
  const { isDockerInstalled } = await import('#services/docker.js');
75
75
  const { isClaudeCliAvailable } = await import('#utils/claude.js');
76
- const { confirm } = await import('@inquirer/prompts');
76
+ const { confirm } = await import('#utils/prompt.js');
77
77
  vi.mocked(isDockerInstalled).mockReturnValue(true);
78
78
  vi.mocked(isClaudeCliAvailable).mockReturnValue(true);
79
79
  await checkDependencies();
@@ -82,7 +82,7 @@ describe('project_scaffold', () => {
82
82
  it('should prompt user when docker is missing', async () => {
83
83
  const { isDockerInstalled } = await import('#services/docker.js');
84
84
  const { isClaudeCliAvailable } = await import('#utils/claude.js');
85
- const { confirm } = await import('@inquirer/prompts');
85
+ const { confirm } = await import('#utils/prompt.js');
86
86
  vi.mocked(isDockerInstalled).mockReturnValue(false);
87
87
  vi.mocked(isClaudeCliAvailable).mockReturnValue(true);
88
88
  vi.mocked(confirm).mockResolvedValue(true);
@@ -94,7 +94,7 @@ describe('project_scaffold', () => {
94
94
  it('should throw UserCancelledError when user declines to proceed', async () => {
95
95
  const { isDockerInstalled } = await import('#services/docker.js');
96
96
  const { isClaudeCliAvailable } = await import('#utils/claude.js');
97
- const { confirm } = await import('@inquirer/prompts');
97
+ const { confirm } = await import('#utils/prompt.js');
98
98
  vi.mocked(isDockerInstalled).mockReturnValue(false);
99
99
  vi.mocked(isClaudeCliAvailable).mockReturnValue(true);
100
100
  vi.mocked(confirm).mockResolvedValue(false);
@@ -2,7 +2,8 @@
2
2
  * Workflow builder service for implementing workflows from plan files
3
3
  */
4
4
  import { ADDITIONAL_INSTRUCTIONS, BUILD_COMMAND_OPTIONS, invokeBuildWorkflow as invokeBuildWorkflowFromClient, replyToClaude } from './claude_client.js';
5
- import { input } from '@inquirer/prompts';
5
+ import { input } from '#utils/prompt.js';
6
+ import { isInteractive } from '#utils/interactive.js';
6
7
  import { ux } from '@oclif/core';
7
8
  import fs from 'node:fs/promises';
8
9
  import path from 'node:path';
@@ -70,6 +71,9 @@ async function processModification(modification, currentOutput) {
70
71
  }
71
72
  }
72
73
  async function interactiveRefinementLoop(currentOutput) {
74
+ if (!isInteractive()) {
75
+ return currentOutput;
76
+ }
73
77
  const modification = await promptForModification();
74
78
  if (isAcceptCommand(modification)) {
75
79
  return currentOutput;
@@ -1,11 +1,12 @@
1
1
  import { describe, it, expect, beforeEach, vi } from 'vitest';
2
2
  import { buildWorkflow, buildWorkflowInteractiveLoop } from './workflow_builder.js';
3
3
  import { ADDITIONAL_INSTRUCTIONS, BUILD_COMMAND_OPTIONS, invokeBuildWorkflow, replyToClaude } from './claude_client.js';
4
- import { input } from '@inquirer/prompts';
4
+ import { input } from '#utils/prompt.js';
5
5
  import { ux } from '@oclif/core';
6
6
  import fs from 'node:fs/promises';
7
7
  vi.mock('./claude_client.js');
8
- vi.mock('@inquirer/prompts');
8
+ vi.mock('#utils/prompt.js');
9
+ vi.mock('#utils/interactive.js', () => ({ isInteractive: () => true }));
9
10
  vi.mock('@oclif/core', () => ({
10
11
  ux: {
11
12
  stdout: vi.fn(),
@@ -4,16 +4,50 @@ This is an **Output.ai** project - a framework for building reliable, production
4
4
 
5
5
  ## Getting Started
6
6
 
7
- For full framework documentation, commands, and AI-assisted workflow development, install our Claude Code plugins:
7
+ Install Claude Code plugins for full framework documentation and AI-assisted development:
8
8
 
9
9
  ```bash
10
10
  claude plugin marketplace add growthxai/output
11
11
  claude plugin install outputai@outputai --scope project
12
12
  ```
13
13
 
14
+ ## Commands
15
+
16
+ ```bash
17
+ npm run output:dev # Start dev environment (worker + Temporal)
18
+ npm run output:worker:build # Build TypeScript to dist/
19
+ npm run output:worker:watch # Build + restart on file changes
20
+ npm run output:worker # Install, build, and start worker
21
+ ```
22
+
23
+ ## Project Structure
24
+
25
+ ```
26
+ src/
27
+ workflows/ # Each subfolder is one workflow
28
+ <name>/
29
+ workflow.ts # Workflow orchestration (must be deterministic - no I/O)
30
+ steps.ts # Step functions (all I/O: HTTP, LLM, DB)
31
+ evaluators.ts # Evaluator functions (LLM-based quality assessment)
32
+ types.ts # Zod schemas and TypeScript types
33
+ prompts/ # .prompt files (Liquid.js templates with YAML frontmatter)
34
+ scenarios/ # Test scenario JSON files
35
+ clients/ # Shared HTTP clients (use @outputai/http, not fetch/axios)
36
+ shared/ # Shared utilities across workflows
37
+ config/
38
+ costs.yml # Token/API pricing overrides
39
+ credentials.yml.enc # Encrypted secrets (edit via: output credentials edit)
40
+ credentials.yml.template # Credential structure reference
41
+ ```
42
+
43
+ ## Key Conventions
44
+
45
+ - **Workflows are deterministic**: No I/O, no `Date.now()`, no `Math.random()` in `workflow.ts`. All side effects go in steps or evaluators.
46
+ - **HTTP clients**: Always use `httpClient` from `@outputai/http` -- never raw `fetch` or `axios`. This enables automatic tracing and cost tracking.
47
+ - **LLM calls**: Use `generateText` from `@outputai/llm` with `.prompt` files. Never call LLM APIs directly.
48
+
14
49
  ---
15
50
 
16
51
  ## Project-Specific Instructions
17
52
 
18
53
  <!-- Add your project-specific instructions below -->
19
-
@@ -4,8 +4,8 @@
4
4
  "WebFetch",
5
5
  "Bash(npx output:*)",
6
6
  "Bash(npm run output:*)",
7
- "Skills(output*)",
8
- "Skills(flow*)"
7
+ "Skill(output*)",
8
+ "Skill(outputai:*)"
9
9
  ]
10
10
  },
11
11
  "enabledPlugins": {
@@ -14,7 +14,17 @@ export declare function formatDuration(ms: number): string;
14
14
  */
15
15
  export declare function formatDate(isoString: string | null | undefined): string;
16
16
  /**
17
- * Format a duration between two ISO timestamps
17
+ * Calculate elapsed milliseconds between two ISO timestamps.
18
+ * If completedAt is null/undefined, uses current time (for in-progress durations).
19
+ */
20
+ export declare function elapsedMs(startedAt: string, completedAt?: string | null): number;
21
+ /**
22
+ * Format a duration in milliseconds to a compact string that fits in narrow columns.
23
+ * Always returns a short single-token string (e.g., "150ms", "7.56s", "24.2m", "1.3h").
24
+ */
25
+ export declare function formatDurationCompact(ms: number): string;
26
+ /**
27
+ * Format a duration between two ISO timestamps.
18
28
  *
19
29
  * @param startedAt - ISO 8601 start timestamp
20
30
  * @param completedAt - ISO 8601 end timestamp (or null if still running)
@@ -33,7 +33,32 @@ export function formatDate(isoString) {
33
33
  return format(parseISO(isoString), 'MMM d, yyyy h:mm a');
34
34
  }
35
35
  /**
36
- * Format a duration between two ISO timestamps
36
+ * Calculate elapsed milliseconds between two ISO timestamps.
37
+ * If completedAt is null/undefined, uses current time (for in-progress durations).
38
+ */
39
+ export function elapsedMs(startedAt, completedAt) {
40
+ const start = parseISO(startedAt).getTime();
41
+ const end = completedAt ? parseISO(completedAt).getTime() : Date.now();
42
+ return end - start;
43
+ }
44
+ /**
45
+ * Format a duration in milliseconds to a compact string that fits in narrow columns.
46
+ * Always returns a short single-token string (e.g., "150ms", "7.56s", "24.2m", "1.3h").
47
+ */
48
+ export function formatDurationCompact(ms) {
49
+ if (ms < 1000) {
50
+ return `${ms}ms`;
51
+ }
52
+ if (ms < 60_000) {
53
+ return `${(ms / 1000).toFixed(2)}s`;
54
+ }
55
+ if (ms < 3_600_000) {
56
+ return `${(ms / 60_000).toFixed(1)}m`;
57
+ }
58
+ return `${(ms / 3_600_000).toFixed(1)}h`;
59
+ }
60
+ /**
61
+ * Format a duration between two ISO timestamps.
37
62
  *
38
63
  * @param startedAt - ISO 8601 start timestamp
39
64
  * @param completedAt - ISO 8601 end timestamp (or null if still running)
@@ -0,0 +1,2 @@
1
+ export declare const setNonInteractive: (value: boolean) => void;
2
+ export declare const isInteractive: () => boolean;
@@ -0,0 +1,5 @@
1
+ const state = { nonInteractive: false };
2
+ export const setNonInteractive = (value) => {
3
+ state.nonInteractive = value;
4
+ };
5
+ export const isInteractive = () => !state.nonInteractive && !!process.stdin.isTTY;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ describe('interactive', () => {
3
+ beforeEach(async () => {
4
+ // Re-import to reset singleton state
5
+ const mod = await import('./interactive.js');
6
+ mod.setNonInteractive(false);
7
+ });
8
+ it('isInteractive returns true by default when TTY is available', async () => {
9
+ const originalIsTTY = process.stdin.isTTY;
10
+ Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
11
+ const { isInteractive } = await import('./interactive.js');
12
+ expect(isInteractive()).toBe(true);
13
+ Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true });
14
+ });
15
+ it('isInteractive returns false when no TTY', async () => {
16
+ const originalIsTTY = process.stdin.isTTY;
17
+ Object.defineProperty(process.stdin, 'isTTY', { value: undefined, configurable: true });
18
+ const { isInteractive } = await import('./interactive.js');
19
+ expect(isInteractive()).toBe(false);
20
+ Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true });
21
+ });
22
+ it('isInteractive returns false after setNonInteractive(true)', async () => {
23
+ const originalIsTTY = process.stdin.isTTY;
24
+ Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
25
+ const { isInteractive, setNonInteractive } = await import('./interactive.js');
26
+ setNonInteractive(true);
27
+ expect(isInteractive()).toBe(false);
28
+ Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true });
29
+ });
30
+ it('setNonInteractive(false) restores interactive mode', async () => {
31
+ const originalIsTTY = process.stdin.isTTY;
32
+ Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
33
+ const { isInteractive, setNonInteractive } = await import('./interactive.js');
34
+ setNonInteractive(true);
35
+ expect(isInteractive()).toBe(false);
36
+ setNonInteractive(false);
37
+ expect(isInteractive()).toBe(true);
38
+ Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true });
39
+ });
40
+ });
@@ -0,0 +1 @@
1
+ export declare const openUrl: (url: string) => void;
@@ -0,0 +1,12 @@
1
+ import { execFile } from 'node:child_process';
2
+ export const openUrl = (url) => {
3
+ if (process.platform === 'darwin') {
4
+ execFile('open', [url]);
5
+ }
6
+ else if (process.platform === 'win32') {
7
+ execFile('cmd', ['/c', 'start', url]);
8
+ }
9
+ else {
10
+ execFile('xdg-open', [url]);
11
+ }
12
+ };
@@ -0,0 +1,17 @@
1
+ type ConfirmOptions = {
2
+ message: string;
3
+ default?: boolean;
4
+ };
5
+ type InputOptions = {
6
+ message: string;
7
+ default?: string;
8
+ validate?: (value: string) => boolean | string;
9
+ };
10
+ type PasswordOptions = {
11
+ message: string;
12
+ mask?: boolean;
13
+ };
14
+ export declare const confirm: (options: ConfirmOptions) => Promise<boolean>;
15
+ export declare const input: (options: InputOptions) => Promise<string>;
16
+ export declare const password: (options: PasswordOptions) => Promise<string>;
17
+ export {};
@@ -0,0 +1,20 @@
1
+ import { confirm as inquirerConfirm, input as inquirerInput, password as inquirerPassword } from '@inquirer/prompts';
2
+ import { isInteractive } from './interactive.js';
3
+ export const confirm = async (options) => {
4
+ if (!isInteractive()) {
5
+ return options.default ?? true;
6
+ }
7
+ return inquirerConfirm(options);
8
+ };
9
+ export const input = async (options) => {
10
+ if (!isInteractive()) {
11
+ return options.default ?? '';
12
+ }
13
+ return inquirerInput(options);
14
+ };
15
+ export const password = async (options) => {
16
+ if (!isInteractive()) {
17
+ return '';
18
+ }
19
+ return inquirerPassword(options);
20
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { confirm as inquirerConfirm, input as inquirerInput, password as inquirerPassword } from '@inquirer/prompts';
3
+ vi.mock('@inquirer/prompts', () => ({
4
+ confirm: vi.fn(),
5
+ input: vi.fn(),
6
+ password: vi.fn()
7
+ }));
8
+ vi.mock('./interactive.js', () => ({
9
+ isInteractive: vi.fn()
10
+ }));
11
+ describe('prompt wrapper', () => {
12
+ beforeEach(() => {
13
+ vi.clearAllMocks();
14
+ });
15
+ describe('when interactive', () => {
16
+ beforeEach(async () => {
17
+ const { isInteractive } = await import('./interactive.js');
18
+ vi.mocked(isInteractive).mockReturnValue(true);
19
+ });
20
+ it('confirm delegates to inquirer', async () => {
21
+ vi.mocked(inquirerConfirm).mockResolvedValue(false);
22
+ const { confirm } = await import('./prompt.js');
23
+ const result = await confirm({ message: 'Continue?', default: true });
24
+ expect(inquirerConfirm).toHaveBeenCalledWith({ message: 'Continue?', default: true });
25
+ expect(result).toBe(false);
26
+ });
27
+ it('input delegates to inquirer', async () => {
28
+ vi.mocked(inquirerInput).mockResolvedValue('user input');
29
+ const { input } = await import('./prompt.js');
30
+ const result = await input({ message: 'Name?', default: 'default' });
31
+ expect(inquirerInput).toHaveBeenCalledWith({ message: 'Name?', default: 'default' });
32
+ expect(result).toBe('user input');
33
+ });
34
+ it('password delegates to inquirer', async () => {
35
+ vi.mocked(inquirerPassword).mockResolvedValue('secret');
36
+ const { password } = await import('./prompt.js');
37
+ const result = await password({ message: 'Token?' });
38
+ expect(inquirerPassword).toHaveBeenCalledWith({ message: 'Token?' });
39
+ expect(result).toBe('secret');
40
+ });
41
+ });
42
+ describe('when non-interactive', () => {
43
+ beforeEach(async () => {
44
+ const { isInteractive } = await import('./interactive.js');
45
+ vi.mocked(isInteractive).mockReturnValue(false);
46
+ });
47
+ it('confirm returns default value', async () => {
48
+ const { confirm } = await import('./prompt.js');
49
+ expect(await confirm({ message: 'Continue?', default: false })).toBe(false);
50
+ expect(await confirm({ message: 'Continue?', default: true })).toBe(true);
51
+ expect(inquirerConfirm).not.toHaveBeenCalled();
52
+ });
53
+ it('confirm defaults to true when no default specified', async () => {
54
+ const { confirm } = await import('./prompt.js');
55
+ expect(await confirm({ message: 'Continue?' })).toBe(true);
56
+ expect(inquirerConfirm).not.toHaveBeenCalled();
57
+ });
58
+ it('input returns default value', async () => {
59
+ const { input } = await import('./prompt.js');
60
+ expect(await input({ message: 'Name?', default: 'fallback' })).toBe('fallback');
61
+ expect(inquirerInput).not.toHaveBeenCalled();
62
+ });
63
+ it('input returns empty string when no default', async () => {
64
+ const { input } = await import('./prompt.js');
65
+ expect(await input({ message: 'Name?' })).toBe('');
66
+ expect(inquirerInput).not.toHaveBeenCalled();
67
+ });
68
+ it('password returns empty string', async () => {
69
+ const { password } = await import('./prompt.js');
70
+ expect(await password({ message: 'Token?' })).toBe('');
71
+ expect(inquirerPassword).not.toHaveBeenCalled();
72
+ });
73
+ });
74
+ });
@@ -0,0 +1 @@
1
+ export declare const bootstrapProxy: () => void;
@@ -0,0 +1,9 @@
1
+ import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';
2
+ export const bootstrapProxy = () => {
3
+ const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy ||
4
+ process.env.HTTP_PROXY || process.env.http_proxy;
5
+ if (!proxyUrl) {
6
+ return;
7
+ }
8
+ setGlobalDispatcher(new EnvHttpProxyAgent());
9
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ const mockSetGlobalDispatcher = vi.fn();
3
+ const MockEnvHttpProxyAgent = vi.fn();
4
+ vi.mock('undici', () => ({
5
+ EnvHttpProxyAgent: MockEnvHttpProxyAgent,
6
+ setGlobalDispatcher: mockSetGlobalDispatcher
7
+ }));
8
+ describe('proxy bootstrap', () => {
9
+ const originalEnv = { ...process.env };
10
+ beforeEach(() => {
11
+ vi.clearAllMocks();
12
+ delete process.env.HTTPS_PROXY;
13
+ delete process.env.https_proxy;
14
+ delete process.env.HTTP_PROXY;
15
+ delete process.env.http_proxy;
16
+ });
17
+ afterEach(() => {
18
+ process.env = { ...originalEnv };
19
+ });
20
+ it('does nothing when no proxy env vars are set', async () => {
21
+ const { bootstrapProxy } = await import('./proxy.js');
22
+ bootstrapProxy();
23
+ expect(mockSetGlobalDispatcher).not.toHaveBeenCalled();
24
+ });
25
+ it('sets global dispatcher when HTTPS_PROXY is set', async () => {
26
+ process.env.HTTPS_PROXY = 'http://proxy:8080';
27
+ const { bootstrapProxy } = await import('./proxy.js');
28
+ bootstrapProxy();
29
+ expect(MockEnvHttpProxyAgent).toHaveBeenCalled();
30
+ expect(mockSetGlobalDispatcher).toHaveBeenCalledTimes(1);
31
+ });
32
+ it('sets global dispatcher when HTTP_PROXY is set', async () => {
33
+ process.env.HTTP_PROXY = 'http://proxy:8080';
34
+ const { bootstrapProxy } = await import('./proxy.js');
35
+ bootstrapProxy();
36
+ expect(MockEnvHttpProxyAgent).toHaveBeenCalled();
37
+ expect(mockSetGlobalDispatcher).toHaveBeenCalledTimes(1);
38
+ });
39
+ });
@@ -0,0 +1,5 @@
1
+ export interface WorkflowDirInfo {
2
+ workflowId: string | undefined;
3
+ scenarioNames: string[];
4
+ }
5
+ export declare function parseWorkflowDir(targetDir: string): WorkflowDirInfo;
@@ -0,0 +1,39 @@
1
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ const WORKFLOW_FILE_NAMES = ['workflow.ts', 'workflow.js'];
4
+ const SCENARIOS_DIR = 'scenarios';
5
+ const WORKFLOW_NAME_PATTERN = /name:\s*['"]([^'"]+)['"]/;
6
+ function safeReadFile(filePath) {
7
+ try {
8
+ return readFileSync(filePath, 'utf-8');
9
+ }
10
+ catch {
11
+ return '';
12
+ }
13
+ }
14
+ function safeReadDir(dirPath) {
15
+ try {
16
+ return readdirSync(dirPath);
17
+ }
18
+ catch {
19
+ return [];
20
+ }
21
+ }
22
+ function parseWorkflowId(targetDir) {
23
+ const workflowFile = WORKFLOW_FILE_NAMES
24
+ .map(name => join(targetDir, name))
25
+ .find(existsSync);
26
+ const content = workflowFile ? safeReadFile(workflowFile) : '';
27
+ return WORKFLOW_NAME_PATTERN.exec(content)?.[1];
28
+ }
29
+ function listScenarioNames(targetDir) {
30
+ return safeReadDir(join(targetDir, SCENARIOS_DIR))
31
+ .filter(f => f.endsWith('.json'))
32
+ .map(f => f.replace(/\.json$/, ''));
33
+ }
34
+ export function parseWorkflowDir(targetDir) {
35
+ return {
36
+ workflowId: parseWorkflowId(targetDir),
37
+ scenarioNames: listScenarioNames(targetDir)
38
+ };
39
+ }