@outputai/cli 0.1.12 → 0.1.13-next.11cbe40.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.
@@ -81,7 +81,7 @@ services:
81
81
  condition: service_healthy
82
82
  worker:
83
83
  condition: service_healthy
84
- image: outputai/api:${OUTPUT_API_VERSION:-0.1.12}
84
+ image: outputai/api:${OUTPUT_API_VERSION:-0.1.13-next.11cbe40.0}
85
85
  init: true
86
86
  networks:
87
87
  - main
@@ -4,6 +4,7 @@ import { buildWorkflow, buildWorkflowInteractiveLoop } from '#services/workflow_
4
4
  import { ensureOutputAISystem } from '#services/coding_agents.js';
5
5
  import { getWorkflowGenerateSuccessMessage } from '#services/messages.js';
6
6
  import { DEFAULT_OUTPUT_DIRS } from '#utils/paths.js';
7
+ import { parseWorkflowDir } from '#utils/workflow_dir_parser.js';
7
8
  import path from 'node:path';
8
9
  import * as fsSync from 'node:fs';
9
10
  import { getErrorMessage } from '#utils/error_utils.js';
@@ -83,7 +84,8 @@ export default class Generate extends Command {
83
84
  this.displaySuccess(result);
84
85
  }
85
86
  displaySuccess(result) {
86
- const message = getWorkflowGenerateSuccessMessage(result.workflowName, result.targetDir, result.filesCreated);
87
+ const dirInfo = parseWorkflowDir(result.targetDir);
88
+ const message = getWorkflowGenerateSuccessMessage(result.workflowName, dirInfo.workflowId ?? result.workflowName, dirInfo.scenarioNames[0], result.targetDir, result.filesCreated);
87
89
  this.log(message);
88
90
  }
89
91
  }
@@ -2,10 +2,13 @@
2
2
  import { describe, it, expect, beforeEach, vi } from 'vitest';
3
3
  import Generate from './generate.js';
4
4
  import { generateWorkflow } from '#services/workflow_generator.js';
5
+ import { parseWorkflowDir } from '#utils/workflow_dir_parser.js';
5
6
  import { InvalidNameError, WorkflowExistsError } from '#types/errors.js';
6
7
  vi.mock('../../services/workflow_generator.js');
8
+ vi.mock('../../utils/workflow_dir_parser.js');
7
9
  describe('Generate Command', () => {
8
10
  let mockGenerateWorkflow;
11
+ let mockParseWorkflowDir;
9
12
  let logSpy;
10
13
  const createCommand = () => {
11
14
  const cmd = new Generate([], {});
@@ -20,6 +23,7 @@ describe('Generate Command', () => {
20
23
  beforeEach(() => {
21
24
  vi.clearAllMocks();
22
25
  mockGenerateWorkflow = vi.mocked(generateWorkflow);
26
+ mockParseWorkflowDir = vi.mocked(parseWorkflowDir);
23
27
  });
24
28
  describe('successful workflow generation', () => {
25
29
  it('should generate workflow with skeleton flag', async () => {
@@ -38,6 +42,10 @@ describe('Generate Command', () => {
38
42
  targetDir: '/tmp/test-workflow',
39
43
  filesCreated: ['index.ts', 'steps.ts', 'types.ts']
40
44
  });
45
+ mockParseWorkflowDir.mockReturnValue({
46
+ workflowId: 'testWorkflow',
47
+ scenarioNames: ['test_input']
48
+ });
41
49
  await cmd.run();
42
50
  expect(mockGenerateWorkflow).toHaveBeenCalledWith({
43
51
  name: 'test-workflow',
@@ -105,6 +113,10 @@ describe('Generate Command', () => {
105
113
  targetDir: '/custom/path/my-workflow',
106
114
  filesCreated: ['index.ts', 'steps.ts', 'types.ts']
107
115
  });
116
+ mockParseWorkflowDir.mockReturnValue({
117
+ workflowId: 'myWorkflow',
118
+ scenarioNames: ['test_input']
119
+ });
108
120
  await cmd.run();
109
121
  expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('SUCCESS!'));
110
122
  expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('my-workflow'));
@@ -0,0 +1,8 @@
1
+ import React from 'react';
2
+ export interface CommandHint {
3
+ key: string;
4
+ label: string;
5
+ }
6
+ export declare const CommandFooter: React.FC<{
7
+ hints: CommandHint[];
8
+ }>;
@@ -0,0 +1,4 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { Box, Text } from 'ink';
4
+ export const CommandFooter = ({ hints }) => (_jsx(Box, { marginTop: 1, children: hints.map((hint, i) => (_jsxs(React.Fragment, { children: [i > 0 && _jsx(Text, { dimColor: true, children: ' | ' }), _jsx(Text, { dimColor: true, children: '(' }), _jsx(Text, { dimColor: true, bold: true, children: hint.key }), _jsx(Text, { dimColor: true, children: ')' }), _jsx(Text, { dimColor: true, children: ` ${hint.label}` })] }, hint.key))) }));
@@ -0,0 +1,11 @@
1
+ import React from 'react';
2
+ interface StatusDisplay {
3
+ icon: string;
4
+ color: string;
5
+ }
6
+ export declare const resolveStatus: (status: string) => StatusDisplay;
7
+ export declare const statusColor: (status: string) => string;
8
+ export declare const StatusIcon: React.FC<{
9
+ status: string;
10
+ }>;
11
+ export {};
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Text } from 'ink';
3
+ const STATUS_MAP = {
4
+ // Docker service health
5
+ healthy: { icon: '●', color: 'green' },
6
+ unhealthy: { icon: '○', color: 'red' },
7
+ starting: { icon: '◐', color: 'yellow' },
8
+ none: { icon: '●', color: 'blue' },
9
+ exited: { icon: '✗', color: 'red' },
10
+ // Workflow run status
11
+ running: { icon: '●', color: 'blue' },
12
+ completed: { icon: '●', color: 'green' },
13
+ failed: { icon: '✗', color: 'red' },
14
+ canceled: { icon: '○', color: 'gray' },
15
+ terminated: { icon: '✗', color: 'red' },
16
+ timed_out: { icon: '✗', color: 'red' },
17
+ continued: { icon: '↻', color: 'blue' }
18
+ };
19
+ const DEFAULT_DISPLAY = { icon: '?', color: 'white' };
20
+ export const resolveStatus = (status) => STATUS_MAP[status] ?? DEFAULT_DISPLAY;
21
+ export const statusColor = (status) => resolveStatus(status).color;
22
+ export const StatusIcon = ({ status }) => {
23
+ const { icon, color } = resolveStatus(status);
24
+ return _jsx(Text, { color: color, children: icon });
25
+ };
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+ export interface WorkflowSummary {
3
+ running: number;
4
+ completed: number;
5
+ failed: number;
6
+ total: number;
7
+ }
8
+ export declare const WorkflowSummarySection: React.FC<{
9
+ summary: WorkflowSummary;
10
+ }>;
@@ -0,0 +1,4 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { statusColor } from '#components/status_icon.js';
4
+ export const WorkflowSummarySection = ({ summary }) => (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, children: "\uD83D\uDCCB Workflows" }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: statusColor('running'), children: [summary.running, " running"] }), _jsx(Text, { children: ", " }), _jsxs(Text, { color: statusColor('failed'), children: [summary.failed, " failed"] }), _jsx(Text, { children: ", " }), _jsxs(Text, { color: statusColor('completed'), children: [summary.completed, " complete"] })] })] }));
@@ -1,3 +1,3 @@
1
1
  {
2
- "framework": "0.1.12"
2
+ "framework": "0.1.13-next.11cbe40.0"
3
3
  }
@@ -105,7 +105,10 @@ function getTodoWriteMessage(message) {
105
105
  if (message.type !== 'assistant') {
106
106
  return null;
107
107
  }
108
- const todoWriteMessage = message.message.content.find((c) => c?.type === 'tool_use' && c.name === 'TodoWrite');
108
+ const todoWriteMessage = message.message.content.find((c) => {
109
+ const block = c;
110
+ return block.type === 'tool_use' && block.name === 'TodoWrite';
111
+ });
109
112
  return todoWriteMessage ?? null;
110
113
  }
111
114
  function applyInstructions(message, instructions) {
@@ -21,8 +21,6 @@ export declare class DockerComposeConfigNotFoundError extends Error {
21
21
  constructor(dockerComposePath: string);
22
22
  }
23
23
  declare const isDockerInstalled: () => boolean;
24
- declare const isDockerComposeAvailable: () => boolean;
25
- declare const isDockerDaemonRunning: () => boolean;
26
24
  export declare function validateDockerEnvironment(): void;
27
25
  export declare function getDefaultDockerComposePath(): string;
28
26
  export declare function parseServiceStatus(jsonOutput: string): ServiceStatus[];
@@ -37,4 +35,4 @@ export type PullPolicy = 'always' | 'missing' | 'never';
37
35
  export declare function startDockerCompose(dockerComposePath: string, pullPolicy?: PullPolicy): Promise<DockerComposeProcess>;
38
36
  export declare function startDockerComposeDetached(dockerComposePath: string, pullPolicy?: PullPolicy): void;
39
37
  export declare function stopDockerCompose(dockerComposePath: string): Promise<void>;
40
- export { isDockerInstalled, isDockerComposeAvailable, isDockerDaemonRunning, DockerValidationError };
38
+ export { isDockerInstalled, DockerValidationError };
@@ -2,6 +2,7 @@ import { execFileSync, execSync, spawn } from 'node:child_process';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { ux } from '@oclif/core';
5
+ import semver from 'semver';
5
6
  const DEFAULT_COMPOSE_PATH = '../assets/docker/docker-compose-dev.yml';
6
7
  export const SERVICE_HEALTH = {
7
8
  HEALTHY: 'healthy',
@@ -30,27 +31,51 @@ const checkDockerCommand = (command) => {
30
31
  return false;
31
32
  }
32
33
  };
34
+ const getCommandVersion = (command, pattern = /(\d+\.\d+\.\d+)/) => {
35
+ try {
36
+ const output = execSync(command, { stdio: 'pipe', encoding: 'utf-8' }).trim();
37
+ const match = output.match(pattern);
38
+ return match ? match[1] : null;
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ };
33
44
  const isDockerInstalled = () => checkDockerCommand('docker --version');
34
- const isDockerComposeAvailable = () => checkDockerCommand('docker compose version');
35
- const isDockerDaemonRunning = () => checkDockerCommand('docker ps');
36
- const DOCKER_VALIDATIONS = [
45
+ const PREREQUISITES = [
37
46
  {
38
- check: isDockerInstalled,
39
- error: 'Docker is not installed. Please install Docker to use the dev command.\nVisit: https://docs.docker.com/get-docker/'
47
+ name: 'Docker',
48
+ semverRange: '>=20.0.0',
49
+ getVersion: () => getCommandVersion('docker --version'),
50
+ errorMessage: (current, required) => current === null ?
51
+ 'Docker is not installed. Please install Docker to use the dev command.\nVisit: https://docs.docker.com/get-docker/' :
52
+ `Docker version ${required} is required (found v${current}).\nVisit: https://docs.docker.com/get-docker/`
40
53
  },
41
54
  {
42
- check: isDockerComposeAvailable,
43
- error: 'Docker Compose is not installed. Please install Docker Compose to use the dev command.\nVisit: https://docs.docker.com/compose/install/'
55
+ name: 'Docker Compose',
56
+ semverRange: '>=2.24.0',
57
+ getVersion: () => getCommandVersion('docker compose version --short'),
58
+ errorMessage: (current, required) => current === null ?
59
+ 'Docker Compose is not installed. Please install Docker Compose to use the dev command.\nVisit: https://docs.docker.com/compose/install/' :
60
+ `Docker Compose ${required} is required (found v${current}).\nPlease update Docker Compose: https://docs.docker.com/compose/install/`
44
61
  },
45
62
  {
46
- check: isDockerDaemonRunning,
47
- error: 'Docker daemon is not running. Please start Docker and try again.'
63
+ name: 'Docker Daemon',
64
+ semverRange: '*',
65
+ getVersion: () => checkDockerCommand('docker ps') ? '0.0.0' : null,
66
+ errorMessage: () => 'Docker daemon is not running. Please start Docker and try again.'
48
67
  }
49
68
  ];
50
69
  export function validateDockerEnvironment() {
51
- const failedValidation = DOCKER_VALIDATIONS.find(v => !v.check());
52
- if (failedValidation) {
53
- throw new DockerValidationError(failedValidation.error);
70
+ for (const prereq of PREREQUISITES) {
71
+ const raw = prereq.getVersion();
72
+ const version = raw ? semver.valid(semver.coerce(raw)) : null;
73
+ if (!version) {
74
+ throw new DockerValidationError(prereq.errorMessage(null, prereq.semverRange));
75
+ }
76
+ if (!semver.satisfies(version, prereq.semverRange)) {
77
+ throw new DockerValidationError(prereq.errorMessage(version, prereq.semverRange));
78
+ }
54
79
  }
55
80
  }
56
81
  export function getDefaultDockerComposePath() {
@@ -129,4 +154,4 @@ export async function stopDockerCompose(dockerComposePath) {
129
154
  ux.stdout('⏹️ Stopping services...\n');
130
155
  execFileSync('docker', ['compose', '-f', dockerComposePath, 'down'], { stdio: 'inherit', cwd: process.cwd() });
131
156
  }
132
- export { isDockerInstalled, isDockerComposeAvailable, isDockerDaemonRunning, DockerValidationError };
157
+ export { isDockerInstalled, DockerValidationError };
@@ -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
  ];
@@ -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 @@
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,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
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,74 @@
1
+ /* eslint-disable no-restricted-syntax, init-declarations */
2
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
3
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { parseWorkflowDir } from './workflow_dir_parser.js';
7
+ describe('parseWorkflowDir', () => {
8
+ let tempDir;
9
+ beforeEach(() => {
10
+ tempDir = mkdtempSync(join(tmpdir(), 'workflow-dir-parser-'));
11
+ });
12
+ afterEach(() => {
13
+ rmSync(tempDir, { recursive: true, force: true });
14
+ });
15
+ it('should extract workflow ID from workflow.ts', () => {
16
+ writeFileSync(join(tempDir, 'workflow.ts'), 'export default workflow( { name: \'myWorkflow\', description: \'test\' } );');
17
+ const result = parseWorkflowDir(tempDir);
18
+ expect(result.workflowId).toBe('myWorkflow');
19
+ });
20
+ it('should extract workflow ID from workflow.js', () => {
21
+ writeFileSync(join(tempDir, 'workflow.js'), 'export default workflow( { name: "blogGenerator", description: "test" } );');
22
+ const result = parseWorkflowDir(tempDir);
23
+ expect(result.workflowId).toBe('blogGenerator');
24
+ });
25
+ it('should prefer workflow.ts over workflow.js', () => {
26
+ writeFileSync(join(tempDir, 'workflow.ts'), 'export default workflow( { name: \'fromTs\' } );');
27
+ writeFileSync(join(tempDir, 'workflow.js'), 'export default workflow( { name: \'fromJs\' } );');
28
+ const result = parseWorkflowDir(tempDir);
29
+ expect(result.workflowId).toBe('fromTs');
30
+ });
31
+ it('should return undefined workflowId when no workflow file exists', () => {
32
+ const result = parseWorkflowDir(tempDir);
33
+ expect(result.workflowId).toBeUndefined();
34
+ });
35
+ it('should return undefined workflowId when workflow file has no name property', () => {
36
+ writeFileSync(join(tempDir, 'workflow.ts'), 'export default workflow( { description: \'no name here\' } );');
37
+ const result = parseWorkflowDir(tempDir);
38
+ expect(result.workflowId).toBeUndefined();
39
+ });
40
+ it('should list scenario names from scenarios directory', () => {
41
+ const scenariosDir = join(tempDir, 'scenarios');
42
+ mkdirSync(scenariosDir);
43
+ writeFileSync(join(scenariosDir, 'test_input.json'), '{}');
44
+ writeFileSync(join(scenariosDir, 'edge_case.json'), '{}');
45
+ const result = parseWorkflowDir(tempDir);
46
+ expect(result.scenarioNames).toEqual(expect.arrayContaining(['test_input', 'edge_case']));
47
+ expect(result.scenarioNames).toHaveLength(2);
48
+ });
49
+ it('should return empty scenarioNames when no scenarios directory exists', () => {
50
+ const result = parseWorkflowDir(tempDir);
51
+ expect(result.scenarioNames).toEqual([]);
52
+ });
53
+ it('should ignore non-json files in scenarios directory', () => {
54
+ const scenariosDir = join(tempDir, 'scenarios');
55
+ mkdirSync(scenariosDir);
56
+ writeFileSync(join(scenariosDir, 'test_input.json'), '{}');
57
+ writeFileSync(join(scenariosDir, 'README.md'), '# Scenarios');
58
+ const result = parseWorkflowDir(tempDir);
59
+ expect(result.scenarioNames).toEqual(['test_input']);
60
+ });
61
+ it('should handle multiline workflow files', () => {
62
+ writeFileSync(join(tempDir, 'workflow.ts'), `import { workflow } from '@outputai/core';
63
+
64
+ export default workflow( {
65
+ name: 'complexWorkflow',
66
+ description: 'A complex workflow',
67
+ fn: async ( input ) => {
68
+ return input;
69
+ }
70
+ } );`);
71
+ const result = parseWorkflowDir(tempDir);
72
+ expect(result.workflowId).toBe('complexWorkflow');
73
+ });
74
+ });
package/dist/views/dev.js CHANGED
@@ -1,31 +1,18 @@
1
- import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useState, useEffect, useRef } from 'react';
3
3
  import { Box, Text, Static, useApp, useInput } from 'ink';
4
4
  import Spinner from 'ink-spinner';
5
- import { getServiceStatus, isServiceHealthy, isServiceFailed, SERVICE_HEALTH, SERVICE_STATE } from '#services/docker.js';
5
+ import { getServiceStatus, isServiceHealthy, isServiceFailed, SERVICE_HEALTH } from '#services/docker.js';
6
6
  import { config } from '#config.js';
7
+ import { fetchWorkflowRuns } from '#services/workflow_runs.js';
8
+ import { openUrl } from '#utils/open_url.js';
9
+ import { StatusIcon } from '#components/status_icon.js';
10
+ import { WorkflowSummarySection } from '#components/workflow_summary.js';
11
+ import { CommandFooter } from '#components/command_footer.js';
12
+ import { WorkflowListView } from '#views/workflow/list.js';
7
13
  const POLL_INTERVAL_MS = 2000;
8
14
  const HEALTH_TIMEOUT_MS = 120_000;
9
- const STATUS_ICONS = {
10
- [SERVICE_HEALTH.HEALTHY]: '●',
11
- [SERVICE_HEALTH.UNHEALTHY]: '○',
12
- [SERVICE_HEALTH.STARTING]: '◐',
13
- [SERVICE_HEALTH.NONE]: '●',
14
- [SERVICE_STATE.RUNNING]: '●',
15
- [SERVICE_STATE.EXITED]: '✗'
16
- };
17
- const STATUS_COLORS = {
18
- [SERVICE_HEALTH.HEALTHY]: 'green',
19
- [SERVICE_HEALTH.UNHEALTHY]: 'red',
20
- [SERVICE_HEALTH.STARTING]: 'yellow',
21
- [SERVICE_HEALTH.NONE]: 'blue',
22
- [SERVICE_STATE.RUNNING]: 'blue',
23
- [SERVICE_STATE.EXITED]: 'red'
24
- };
25
- const resolveServiceDisplay = (service) => {
26
- const key = service.health === SERVICE_HEALTH.NONE ? service.state : service.health;
27
- return { icon: STATUS_ICONS[key] ?? '?', color: STATUS_COLORS[key] ?? 'white', status: key };
28
- };
15
+ const resolveServiceStatus = (service) => service.health === SERVICE_HEALTH.NONE ? service.state : service.health;
29
16
  const fetchServices = async (dockerComposePath) => {
30
17
  try {
31
18
  return await getServiceStatus(dockerComposePath);
@@ -97,6 +84,32 @@ const useStatusRefresh = (dockerComposePath, enabled, onServices) => {
97
84
  return 'continue';
98
85
  });
99
86
  };
87
+ const useWorkflowPolling = (enabled, onRuns) => {
88
+ const onRunsRef = useRef(onRuns);
89
+ onRunsRef.current = onRuns;
90
+ usePoll(enabled, async () => {
91
+ try {
92
+ const { runs } = await fetchWorkflowRuns({ limit: 100 });
93
+ onRunsRef.current(runs);
94
+ }
95
+ catch {
96
+ // API may not be ready yet
97
+ }
98
+ return 'continue';
99
+ });
100
+ };
101
+ const useMainViewInput = (isActive, callbacks) => {
102
+ const callbacksRef = useRef(callbacks);
103
+ callbacksRef.current = callbacks;
104
+ useInput(input => {
105
+ if (input === 'o') {
106
+ callbacksRef.current.onOpenTemporal();
107
+ }
108
+ if (input === 'w') {
109
+ callbacksRef.current.onOpenWorkflows();
110
+ }
111
+ }, { isActive });
112
+ };
100
113
  const useCtrlC = (onCleanup) => {
101
114
  const { exit } = useApp();
102
115
  const isExitingRef = useRef(false);
@@ -108,9 +121,9 @@ const useCtrlC = (onCleanup) => {
108
121
  });
109
122
  };
110
123
  const ServiceRow = ({ service }) => {
111
- const { icon, color, status } = resolveServiceDisplay(service);
124
+ const status = resolveServiceStatus(service);
112
125
  const ports = service.ports.length ? service.ports.join(', ') : '-';
113
- return (_jsxs(Box, { children: [_jsxs(Text, { color: color, children: [icon, " "] }), _jsx(Box, { width: 16, children: _jsx(Text, { children: service.name }) }), _jsx(Text, { dimColor: true, children: status.padEnd(10) }), _jsx(Text, { dimColor: true, children: ports })] }));
126
+ return (_jsxs(Box, { children: [_jsx(Box, { width: 3, children: _jsx(StatusIcon, { status: status }) }), _jsx(Box, { width: 16, children: _jsx(Text, { children: service.name }) }), _jsx(Text, { dimColor: true, children: status.padEnd(10) }), _jsx(Text, { dimColor: true, children: ports })] }));
114
127
  };
115
128
  const FailureWarning = ({ services }) => {
116
129
  const failed = services.filter(isServiceFailed);
@@ -128,12 +141,24 @@ const DevSuccessMessage = ({ services }) => {
128
141
  return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { dimColor: true, children: divider }) }), _jsxs(Box, { children: [_jsx(Text, { color: "green", bold: true, children: '✅ SUCCESS! ' }), _jsx(Text, { bold: true, children: "Development services are running" })] }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { dimColor: true, children: divider }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "\uD83D\uDC33 SERVICES" }) }), _jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsxs(Box, { children: [_jsx(Text, { color: "white", children: 'Temporal: ' }), _jsx(Text, { color: "yellow", children: "localhost:7233" })] }), _jsxs(Box, { children: [_jsx(Text, { color: "white", children: 'Temporal UI: ' }), _jsx(Text, { color: "cyan", children: "http://localhost:8080" })] }), _jsxs(Box, { children: [_jsx(Text, { color: "white", children: 'API Server: ' }), _jsx(Text, { color: "yellow", children: "localhost:3001" })] }), _jsxs(Box, { children: [_jsx(Text, { color: "white", children: 'Redis: ' }), _jsx(Text, { color: "yellow", children: "localhost:6379" })] })] }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { dimColor: true, children: divider }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "\uD83D\uDE80 RUN A WORKFLOW" }) }), _jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsx(Text, { color: "white", children: "In a new terminal, execute:" }), _jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: "cyan", children: "npx output workflow run blog_evaluator paulgraham_hwh" }) })] }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { dimColor: true, children: divider }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "\u26A1 USEFUL COMMANDS" }) }), _jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsxs(Box, { children: [_jsx(Text, { color: "white", children: 'Open Temporal UI: ' }), _jsx(Text, { color: "cyan", children: "open http://localhost:8080" })] }), _jsxs(Box, { children: [_jsx(Text, { color: "white", children: 'View logs: ' }), _jsx(Text, { color: "cyan", children: logsCommand })] }), _jsxs(Box, { children: [_jsx(Text, { color: "white", children: 'Stop services: ' }), _jsx(Text, { color: "cyan", children: "Press Ctrl+C" })] })] }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { dimColor: true, children: divider }) }), _jsx(Text, { dimColor: true, children: "\uD83D\uDCA1 Tip: The Temporal UI lets you monitor workflow executions in real-time" })] }));
129
142
  };
130
143
  const WaitingView = ({ services }) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Waiting for services to become healthy..." })] }), services.length > 0 && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: services.map(s => _jsx(ServiceRow, { service: s }, s.name)) }))] }));
131
- const RunningView = ({ services }) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "\uD83D\uDCCA Service Status" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: services.map(s => _jsx(ServiceRow, { service: s }, s.name)) }), _jsx(FailureWarning, { services: services }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: '🌐 Temporal UI: ' }), _jsx(Text, { bold: true, children: "http://localhost:8080" })] }), _jsx(Text, { dimColor: true, children: "Press Ctrl+C to stop services" })] }));
144
+ const RunningView = ({ services, workflowSummary }) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "\uD83D\uDCCA Service Status" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: services.map(s => _jsx(ServiceRow, { service: s }, s.name)) }), _jsx(FailureWarning, { services: services }), workflowSummary && _jsx(WorkflowSummarySection, { summary: workflowSummary }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: '🌐 Temporal UI: ' }), _jsx(Text, { bold: true, children: "http://localhost:8080" })] }), _jsx(CommandFooter, { hints: [
145
+ { key: 'o', label: 'open ui' },
146
+ { key: 'w', label: 'view workflow runs' },
147
+ { key: 'ctrl+c', label: 'stop' }
148
+ ] })] }));
149
+ const MainDevView = ({ phase, services, workflowSummary }) => {
150
+ if (phase === 'waiting') {
151
+ return _jsx(WaitingView, { services: services });
152
+ }
153
+ return _jsx(RunningView, { services: services, workflowSummary: workflowSummary });
154
+ };
132
155
  export const DevApp = ({ dockerComposePath, onCleanup }) => {
133
156
  const { exit } = useApp();
134
157
  const [phase, setPhase] = useState('waiting');
135
158
  const [services, setServices] = useState([]);
136
159
  const [successItems, setSuccessItems] = useState([]);
160
+ const [activeView, setActiveView] = useState('main');
161
+ const [workflowRuns, setWorkflowRuns] = useState([]);
137
162
  useHealthPolling(dockerComposePath, phase === 'waiting', {
138
163
  onServices: setServices,
139
164
  onAllHealthy: svcs => {
@@ -146,6 +171,17 @@ export const DevApp = ({ dockerComposePath, onCleanup }) => {
146
171
  onTimeout: () => exit(new Error('Timeout waiting for services to become healthy'))
147
172
  });
148
173
  useStatusRefresh(dockerComposePath, phase === 'running', setServices);
174
+ useWorkflowPolling(phase === 'running' || phase === 'failed', setWorkflowRuns);
175
+ useMainViewInput(activeView === 'main' && phase !== 'waiting', {
176
+ onOpenTemporal: () => openUrl('http://localhost:8080'),
177
+ onOpenWorkflows: () => setActiveView('workflows')
178
+ });
149
179
  useCtrlC(onCleanup);
150
- return (_jsxs(_Fragment, { children: [_jsx(Static, { items: successItems, children: item => _jsx(DevSuccessMessage, { services: item.services }, item.id) }), phase === 'waiting' && _jsx(WaitingView, { services: services }), phase === 'running' && _jsx(RunningView, { services: services }), phase === 'failed' && _jsx(RunningView, { services: services })] }));
180
+ const workflowSummary = workflowRuns.length > 0 ? {
181
+ running: workflowRuns.filter(r => r.status === 'running').length,
182
+ completed: workflowRuns.filter(r => r.status === 'completed').length,
183
+ failed: workflowRuns.filter(r => r.status === 'failed').length,
184
+ total: workflowRuns.length
185
+ } : null;
186
+ return (_jsxs(_Fragment, { children: [_jsx(Static, { items: successItems, children: item => _jsx(DevSuccessMessage, { services: item.services }, item.id) }), _jsxs(Box, { flexDirection: "column", children: [activeView === 'main' && (_jsx(MainDevView, { phase: phase, services: services, workflowSummary: workflowSummary })), activeView === 'workflows' && (_jsx(WorkflowListView, { runs: workflowRuns, onBack: () => setActiveView('main') }))] })] }));
151
187
  };
@@ -0,0 +1,6 @@
1
+ import React from 'react';
2
+ import type { WorkflowRun } from '#services/workflow_runs.js';
3
+ export declare const WorkflowListView: React.FC<{
4
+ runs: WorkflowRun[];
5
+ onBack: () => void;
6
+ }>;
@@ -0,0 +1,127 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useEffect, useRef, useMemo } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import Spinner from 'ink-spinner';
5
+ import { getWorkflowIdResult } from '#api/generated/api.js';
6
+ import { StatusIcon, statusColor } from '#components/status_icon.js';
7
+ import { elapsedMs, formatDurationCompact } from '#utils/date_formatter.js';
8
+ import { CommandFooter } from '#components/command_footer.js';
9
+ import { openUrl } from '#utils/open_url.js';
10
+ const TEMPORAL_UI_BASE = 'http://localhost:8080';
11
+ const VISIBLE_ROWS = 15;
12
+ const STATUS_ORDER = {
13
+ running: 0,
14
+ failed: 1,
15
+ timed_out: 2,
16
+ terminated: 3,
17
+ canceled: 4,
18
+ continued: 5,
19
+ completed: 6
20
+ };
21
+ const sortRuns = (runs) => [...runs].sort((a, b) => {
22
+ const statusDiff = (STATUS_ORDER[a.status ?? ''] ?? Infinity) - (STATUS_ORDER[b.status ?? ''] ?? Infinity);
23
+ if (statusDiff !== 0) {
24
+ return statusDiff;
25
+ }
26
+ const aTime = a.startedAt ? new Date(a.startedAt).getTime() : 0;
27
+ const bTime = b.startedAt ? new Date(b.startedAt).getTime() : 0;
28
+ return bTime - aTime;
29
+ });
30
+ const truncate = (str, max) => str.length > max ? str.slice(0, max - 1) + '…' : str;
31
+ const COL = {
32
+ indicator: 2,
33
+ icon: 3,
34
+ status: 12,
35
+ type: 20,
36
+ id: 32,
37
+ duration: 10
38
+ };
39
+ const WorkflowRow = ({ run, selected }) => {
40
+ const status = run.status ?? 'running';
41
+ const color = statusColor(status);
42
+ const duration = run.startedAt ? formatDurationCompact(elapsedMs(run.startedAt, run.completedAt)) : '-';
43
+ return (_jsxs(Box, { children: [_jsx(Box, { width: COL.indicator, children: _jsx(Text, { color: selected ? 'cyan' : undefined, bold: selected, children: selected ? '▸' : ' ' }) }), _jsx(Box, { width: COL.icon, children: _jsx(StatusIcon, { status: status }) }), _jsx(Box, { width: COL.status, children: _jsx(Text, { color: color, children: status }) }), _jsx(Box, { width: COL.type, children: _jsx(Text, { bold: selected, children: truncate(run.workflowType ?? '-', COL.type - 2) }) }), _jsx(Box, { width: COL.id, children: _jsx(Text, { dimColor: !selected, children: truncate(run.workflowId ?? '-', COL.id - 2) }) }), _jsx(Box, { width: COL.duration, justifyContent: "flex-end", children: _jsx(Text, { dimColor: true, children: duration }) })] }));
44
+ };
45
+ const HeaderRow = () => (_jsxs(Box, { children: [_jsx(Box, { width: COL.indicator, children: _jsx(Text, { children: " " }) }), _jsx(Box, { width: COL.icon, children: _jsx(Text, { children: " " }) }), _jsx(Box, { width: COL.status, children: _jsx(Text, { dimColor: true, bold: true, children: "STATUS" }) }), _jsx(Box, { width: COL.type, children: _jsx(Text, { dimColor: true, bold: true, children: "TYPE" }) }), _jsx(Box, { width: COL.id, children: _jsx(Text, { dimColor: true, bold: true, children: "WORKFLOW ID" }) }), _jsx(Box, { width: COL.duration, justifyContent: "flex-end", children: _jsx(Text, { dimColor: true, bold: true, children: "DURATION" }) })] }));
46
+ const WorkflowDetailPane = ({ detail, loading }) => {
47
+ if (loading) {
48
+ return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Loading details..." })] }));
49
+ }
50
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingLeft: 2, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Status: " }), _jsx(Text, { color: statusColor(detail.status ?? ''), children: detail.status })] }), detail.error && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, color: "red", children: "Error:" }), _jsx(Text, { color: "red", children: truncate(detail.error, 300) })] })), detail.output !== undefined && detail.output !== null && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Output:" }), _jsx(Text, { children: truncate(JSON.stringify(detail.output, null, 2), 400) })] }))] }));
51
+ };
52
+ export const WorkflowListView = ({ runs, onBack }) => {
53
+ const [selectedIndex, setSelectedIndex] = useState(0);
54
+ const [detail, setDetail] = useState(null);
55
+ const [detailLoading, setDetailLoading] = useState(false);
56
+ const cacheRef = useRef(new Map());
57
+ const fetchIdRef = useRef(0);
58
+ const sortedRuns = useMemo(() => sortRuns(runs), [runs]);
59
+ const clampedIndex = Math.min(selectedIndex, Math.max(0, sortedRuns.length - 1));
60
+ const selectedRun = sortedRuns[clampedIndex];
61
+ const selectedWorkflowId = selectedRun?.workflowId;
62
+ useEffect(() => {
63
+ if (clampedIndex !== selectedIndex) {
64
+ setSelectedIndex(clampedIndex);
65
+ }
66
+ }, [clampedIndex, selectedIndex]);
67
+ useEffect(() => {
68
+ if (!selectedWorkflowId) {
69
+ setDetail(null);
70
+ return;
71
+ }
72
+ const cached = cacheRef.current.get(selectedWorkflowId);
73
+ if (cached) {
74
+ setDetail(cached);
75
+ setDetailLoading(false);
76
+ return;
77
+ }
78
+ const currentFetchId = ++fetchIdRef.current;
79
+ setDetailLoading(true);
80
+ getWorkflowIdResult(selectedWorkflowId)
81
+ .then(response => {
82
+ if (fetchIdRef.current !== currentFetchId) {
83
+ return;
84
+ }
85
+ const data = response.data;
86
+ cacheRef.current.set(selectedWorkflowId, data);
87
+ setDetail(data);
88
+ setDetailLoading(false);
89
+ })
90
+ .catch(() => {
91
+ if (fetchIdRef.current !== currentFetchId) {
92
+ return;
93
+ }
94
+ setDetail(null);
95
+ setDetailLoading(false);
96
+ });
97
+ }, [selectedWorkflowId]);
98
+ useInput((input, key) => {
99
+ if (key.upArrow) {
100
+ setSelectedIndex(i => Math.max(0, i - 1));
101
+ }
102
+ else if (key.downArrow) {
103
+ setSelectedIndex(i => Math.min(sortedRuns.length - 1, i + 1));
104
+ }
105
+ else if (key.escape || input === 'q') {
106
+ onBack();
107
+ }
108
+ else if (input === 'o' && selectedWorkflowId) {
109
+ openUrl(`${TEMPORAL_UI_BASE}/namespaces/default/workflows/${selectedWorkflowId}`);
110
+ }
111
+ });
112
+ const windowStart = useMemo(() => {
113
+ const half = Math.floor(VISIBLE_ROWS / 2);
114
+ const start = Math.max(0, clampedIndex - half);
115
+ const maxStart = Math.max(0, sortedRuns.length - VISIBLE_ROWS);
116
+ return Math.min(start, maxStart);
117
+ }, [clampedIndex, sortedRuns.length]);
118
+ const visibleRuns = sortedRuns.slice(windowStart, windowStart + VISIBLE_ROWS);
119
+ if (sortedRuns.length === 0) {
120
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Workflow Runs" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "No workflow runs found." }) }), _jsx(CommandFooter, { hints: [{ key: 'q', label: 'back' }] })] }));
121
+ }
122
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, children: ["Workflow Runs (", sortedRuns.length, ")"] }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(HeaderRow, {}), windowStart > 0 && _jsxs(Text, { dimColor: true, children: [" \u2191 ", windowStart, " more above"] }), visibleRuns.map((run, i) => (_jsx(WorkflowRow, { run: run, selected: windowStart + i === clampedIndex }, `${run.workflowId}-${run.startedAt}-${windowStart + i}`))), windowStart + VISIBLE_ROWS < sortedRuns.length && (_jsxs(Text, { dimColor: true, children: [" \u2193 ", sortedRuns.length - windowStart - VISIBLE_ROWS, " more below"] }))] }), detail && _jsx(WorkflowDetailPane, { detail: detail, loading: detailLoading }), detailLoading && !detail && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Loading details..." })] })), _jsx(CommandFooter, { hints: [
123
+ { key: '↑/↓', label: 'navigate' },
124
+ { key: 'o', label: 'open in temporal' },
125
+ { key: 'q', label: 'back' }
126
+ ] })] }));
127
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@outputai/cli",
3
- "version": "0.1.12",
3
+ "version": "0.1.13-next.11cbe40.0",
4
4
  "description": "CLI for Output.ai workflow generation",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -14,30 +14,30 @@
14
14
  "output": "./bin/run.js"
15
15
  },
16
16
  "dependencies": {
17
- "@anthropic-ai/claude-agent-sdk": "0.2.85",
18
- "@aws-sdk/client-s3": "3.1018.0",
17
+ "@anthropic-ai/claude-agent-sdk": "0.2.92",
18
+ "@aws-sdk/client-s3": "3.1024.0",
19
19
  "@hackylabs/deep-redact": "3.0.5",
20
20
  "@inquirer/prompts": "8.3.2",
21
- "@oclif/core": "4.10.3",
22
- "@oclif/plugin-help": "6.2.40",
21
+ "@oclif/core": "4.10.5",
22
+ "@oclif/plugin-help": "6.2.42",
23
23
  "change-case": "5.4.4",
24
24
  "cli-progress": "3.12.0",
25
25
  "cli-table3": "0.6.5",
26
26
  "date-fns": "4.1.0",
27
27
  "debug": "4.4.3",
28
- "dotenv": "17.3.1",
28
+ "dotenv": "17.4.0",
29
29
  "handlebars": "4.7.9",
30
- "js-yaml": "4.1.1",
31
30
  "ink": "6.8.0",
32
31
  "ink-spinner": "5.0.0",
33
- "json-schema-library": "11.0.5",
32
+ "js-yaml": "4.1.1",
33
+ "json-schema-library": "11.1.0",
34
34
  "ky": "1.14.3",
35
35
  "react": "19.2.4",
36
36
  "semver": "7.7.4",
37
37
  "yaml": "^2.8.3",
38
- "@outputai/credentials": "0.1.12",
39
- "@outputai/evals": "0.1.12",
40
- "@outputai/llm": "0.1.12"
38
+ "@outputai/credentials": "0.1.13-next.11cbe40.0",
39
+ "@outputai/llm": "0.1.13-next.11cbe40.0",
40
+ "@outputai/evals": "0.1.13-next.11cbe40.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/cli-progress": "3.11.6",