@outputai/cli 0.1.12 → 0.1.13-dev.2f0a972.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.
- package/dist/api/generated/api.d.ts +322 -84
- package/dist/api/generated/api.js +67 -9
- package/dist/assets/docker/docker-compose-dev.yml +1 -1
- package/dist/commands/workflow/generate.js +3 -1
- package/dist/commands/workflow/generate.spec.js +12 -0
- package/dist/commands/workflow/list.d.ts +1 -0
- package/dist/commands/workflow/list.js +12 -6
- package/dist/commands/workflow/list.spec.js +21 -0
- package/dist/commands/workflow/run.js +2 -1
- package/dist/commands/workflow/run.spec.js +1 -0
- package/dist/commands/workflow/start.js +2 -1
- package/dist/commands/workflow/start.spec.js +1 -0
- package/dist/commands/workflow/test_eval.js +2 -2
- package/dist/components/command_footer.d.ts +8 -0
- package/dist/components/command_footer.js +4 -0
- package/dist/components/status_icon.d.ts +11 -0
- package/dist/components/status_icon.js +25 -0
- package/dist/components/workflow_summary.d.ts +10 -0
- package/dist/components/workflow_summary.js +4 -0
- package/dist/generated/framework_version.json +1 -1
- package/dist/services/claude_client.js +4 -1
- package/dist/services/datasets.d.ts +1 -1
- package/dist/services/datasets.js +41 -37
- package/dist/services/datasets.test.d.ts +1 -0
- package/dist/services/datasets.test.js +202 -0
- package/dist/services/docker.d.ts +1 -3
- package/dist/services/docker.js +38 -13
- package/dist/services/messages.d.ts +1 -1
- package/dist/services/messages.js +2 -2
- package/dist/templates/agent_instructions/CLAUDE.md.template +36 -2
- package/dist/templates/agent_instructions/dotclaude/settings.json.template +2 -2
- package/dist/utils/date_formatter.d.ts +11 -1
- package/dist/utils/date_formatter.js +26 -1
- package/dist/utils/format_workflow_result.d.ts +3 -3
- package/dist/utils/open_url.d.ts +1 -0
- package/dist/utils/open_url.js +12 -0
- package/dist/utils/workflow_dir_parser.d.ts +5 -0
- package/dist/utils/workflow_dir_parser.js +39 -0
- package/dist/utils/workflow_dir_parser.spec.d.ts +1 -0
- package/dist/utils/workflow_dir_parser.spec.js +74 -0
- package/dist/views/dev.js +62 -26
- package/dist/views/workflow/list.d.ts +6 -0
- package/dist/views/workflow/list.js +127 -0
- package/package.json +14 -14
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtemp, rm, writeFile, readFile, mkdir } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import yaml from 'js-yaml';
|
|
6
|
+
import { readDatasetFile, readAllDatasets, writeDataset, listDatasets } from './datasets.js';
|
|
7
|
+
const ctx = { tmpDir: '' };
|
|
8
|
+
beforeEach(async () => {
|
|
9
|
+
ctx.tmpDir = await mkdtemp(join(tmpdir(), 'output-datasets-test-'));
|
|
10
|
+
});
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
await rm(ctx.tmpDir, { recursive: true, force: true });
|
|
13
|
+
});
|
|
14
|
+
function writeYaml(filePath, obj) {
|
|
15
|
+
return writeFile(filePath, yaml.dump(obj, { lineWidth: 120, noRefs: true, sortKeys: false }), 'utf-8');
|
|
16
|
+
}
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// readDatasetFile
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
describe('readDatasetFile', () => {
|
|
21
|
+
it('parses a multi-case file and returns all cases', async () => {
|
|
22
|
+
const filePath = join(ctx.tmpDir, 'cases.yml');
|
|
23
|
+
await writeYaml(filePath, {
|
|
24
|
+
case_a: { input: { query: 'foo' }, ground_truth: { expected: 1 } },
|
|
25
|
+
case_b: { input: { query: 'bar' } },
|
|
26
|
+
case_c: { input: { query: 'baz' }, ground_truth: { expected: 3 } }
|
|
27
|
+
});
|
|
28
|
+
const datasets = await readDatasetFile(filePath);
|
|
29
|
+
expect(datasets).toHaveLength(3);
|
|
30
|
+
expect(datasets.map(d => d.name)).toEqual(['case_a', 'case_b', 'case_c']);
|
|
31
|
+
expect(datasets[0].input).toEqual({ query: 'foo' });
|
|
32
|
+
expect(datasets[0].ground_truth).toEqual({ expected: 1 });
|
|
33
|
+
});
|
|
34
|
+
it('attaches _source with the absolute file path to each dataset', async () => {
|
|
35
|
+
const filePath = join(ctx.tmpDir, 'cases.yml');
|
|
36
|
+
await writeYaml(filePath, {
|
|
37
|
+
my_case: { input: { x: 1 } }
|
|
38
|
+
});
|
|
39
|
+
const [dataset] = await readDatasetFile(filePath);
|
|
40
|
+
expect(dataset._source).toBe(filePath);
|
|
41
|
+
});
|
|
42
|
+
it('throws when a case is missing input', async () => {
|
|
43
|
+
const filePath = join(ctx.tmpDir, 'bad.yml');
|
|
44
|
+
await writeYaml(filePath, {
|
|
45
|
+
good_case: { input: { x: 1 } },
|
|
46
|
+
bad_case: { ground_truth: { expected: 42 } }
|
|
47
|
+
});
|
|
48
|
+
await expect(readDatasetFile(filePath)).rejects.toThrow('Dataset case "bad_case" in');
|
|
49
|
+
});
|
|
50
|
+
it('throws when file content is not an object', async () => {
|
|
51
|
+
const filePath = join(ctx.tmpDir, 'bad.yml');
|
|
52
|
+
await writeFile(filePath, 'just a string', 'utf-8');
|
|
53
|
+
await expect(readDatasetFile(filePath)).rejects.toThrow('Invalid dataset file');
|
|
54
|
+
});
|
|
55
|
+
it('throws with clear message when file content is a YAML array', async () => {
|
|
56
|
+
const filePath = join(ctx.tmpDir, 'bad.yml');
|
|
57
|
+
await writeFile(filePath, '- foo\n- bar\n', 'utf-8');
|
|
58
|
+
await expect(readDatasetFile(filePath)).rejects.toThrow('Invalid dataset file');
|
|
59
|
+
});
|
|
60
|
+
it('preserves last_output and last_eval fields', async () => {
|
|
61
|
+
const filePath = join(ctx.tmpDir, 'cases.yml');
|
|
62
|
+
await writeYaml(filePath, {
|
|
63
|
+
cached_case: {
|
|
64
|
+
input: { q: 'hello' },
|
|
65
|
+
last_output: { output: { result: 42 }, executionTimeMs: 100, date: '2026-01-01T00:00:00.000Z' }
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
const [dataset] = await readDatasetFile(filePath);
|
|
69
|
+
expect(dataset.last_output?.output).toEqual({ result: 42 });
|
|
70
|
+
expect(dataset.last_output?.executionTimeMs).toBe(100);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// readAllDatasets
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
describe('readAllDatasets', () => {
|
|
77
|
+
it('flattens cases from multiple files', async () => {
|
|
78
|
+
const datasetsDir = join(ctx.tmpDir, 'src', 'workflows', 'my_workflow', 'tests', 'datasets');
|
|
79
|
+
await mkdir(datasetsDir, { recursive: true });
|
|
80
|
+
await writeYaml(join(datasetsDir, 'group_a.yml'), {
|
|
81
|
+
case_1: { input: { x: 1 } },
|
|
82
|
+
case_2: { input: { x: 2 } }
|
|
83
|
+
});
|
|
84
|
+
await writeYaml(join(datasetsDir, 'group_b.yml'), {
|
|
85
|
+
case_3: { input: { x: 3 } }
|
|
86
|
+
});
|
|
87
|
+
const { datasets } = await readAllDatasets('my_workflow', undefined, ctx.tmpDir);
|
|
88
|
+
expect(datasets).toHaveLength(3);
|
|
89
|
+
expect(datasets.map(d => d.name).sort()).toEqual(['case_1', 'case_2', 'case_3']);
|
|
90
|
+
});
|
|
91
|
+
it('filters by case name across files', async () => {
|
|
92
|
+
const datasetsDir = join(ctx.tmpDir, 'src', 'workflows', 'my_workflow', 'tests', 'datasets');
|
|
93
|
+
await mkdir(datasetsDir, { recursive: true });
|
|
94
|
+
await writeYaml(join(datasetsDir, 'group_a.yml'), {
|
|
95
|
+
case_1: { input: { x: 1 } },
|
|
96
|
+
case_2: { input: { x: 2 } }
|
|
97
|
+
});
|
|
98
|
+
await writeYaml(join(datasetsDir, 'group_b.yml'), {
|
|
99
|
+
case_3: { input: { x: 3 } }
|
|
100
|
+
});
|
|
101
|
+
const { datasets } = await readAllDatasets('my_workflow', ['case_2', 'case_3'], ctx.tmpDir);
|
|
102
|
+
expect(datasets).toHaveLength(2);
|
|
103
|
+
expect(datasets.map(d => d.name).sort()).toEqual(['case_2', 'case_3']);
|
|
104
|
+
});
|
|
105
|
+
it('returns empty datasets and a default dir when workflow has no datasets dir', async () => {
|
|
106
|
+
const { datasets, dir } = await readAllDatasets('nonexistent_workflow', undefined, ctx.tmpDir);
|
|
107
|
+
expect(datasets).toHaveLength(0);
|
|
108
|
+
expect(dir).toContain('nonexistent_workflow');
|
|
109
|
+
});
|
|
110
|
+
it('throws when the same case name appears in two different files', async () => {
|
|
111
|
+
const datasetsDir = join(ctx.tmpDir, 'src', 'workflows', 'my_workflow', 'tests', 'datasets');
|
|
112
|
+
await mkdir(datasetsDir, { recursive: true });
|
|
113
|
+
await writeYaml(join(datasetsDir, 'group_a.yml'), { case_1: { input: { x: 1 } } });
|
|
114
|
+
await writeYaml(join(datasetsDir, 'group_b.yml'), { case_1: { input: { x: 2 } } });
|
|
115
|
+
await expect(readAllDatasets('my_workflow', undefined, ctx.tmpDir)).rejects.toThrow('Duplicate dataset case name "case_1"');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// writeDataset
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
describe('writeDataset', () => {
|
|
122
|
+
it('creates a new file with one case keyed by name', async () => {
|
|
123
|
+
const filePath = join(ctx.tmpDir, 'cases.yml');
|
|
124
|
+
const dataset = { name: 'new_case', input: { q: 'hello' } };
|
|
125
|
+
await writeDataset(dataset, filePath);
|
|
126
|
+
const raw = yaml.load(await readFile(filePath, 'utf-8'));
|
|
127
|
+
expect(raw).toHaveProperty('new_case');
|
|
128
|
+
expect(raw.new_case.input).toEqual({ q: 'hello' });
|
|
129
|
+
expect(raw.new_case).not.toHaveProperty('name');
|
|
130
|
+
});
|
|
131
|
+
it('does not write _source into the file', async () => {
|
|
132
|
+
const filePath = join(ctx.tmpDir, 'cases.yml');
|
|
133
|
+
const dataset = { name: 'my_case', input: { q: 'x' }, _source: '/some/path.yml' };
|
|
134
|
+
await writeDataset(dataset, filePath);
|
|
135
|
+
const raw = yaml.load(await readFile(filePath, 'utf-8'));
|
|
136
|
+
expect(raw.my_case).not.toHaveProperty('_source');
|
|
137
|
+
});
|
|
138
|
+
it('updates only the target case, leaving other cases untouched', async () => {
|
|
139
|
+
const filePath = join(ctx.tmpDir, 'cases.yml');
|
|
140
|
+
await writeYaml(filePath, {
|
|
141
|
+
case_a: { input: { x: 1 }, ground_truth: { expected: 1 } },
|
|
142
|
+
case_b: { input: { x: 2 }, ground_truth: { expected: 2 } }
|
|
143
|
+
});
|
|
144
|
+
await writeDataset({ name: 'case_a', input: { x: 1 }, last_output: { output: { result: 1 }, date: '2026-01-01T00:00:00.000Z' } }, filePath);
|
|
145
|
+
const raw = yaml.load(await readFile(filePath, 'utf-8'));
|
|
146
|
+
expect(raw).toHaveProperty('case_b');
|
|
147
|
+
expect(raw.case_b.ground_truth).toEqual({ expected: 2 });
|
|
148
|
+
});
|
|
149
|
+
it('preserves existing fields when writing last_output then last_eval', async () => {
|
|
150
|
+
const filePath = join(ctx.tmpDir, 'cases.yml');
|
|
151
|
+
await writeYaml(filePath, {
|
|
152
|
+
my_case: { input: { q: 'hello' }, ground_truth: { expected: 42 } }
|
|
153
|
+
});
|
|
154
|
+
await writeDataset({ name: 'my_case', input: { q: 'hello' }, last_output: { output: { result: 42 }, executionTimeMs: 50, date: '2026-01-01T00:00:00.000Z' } }, filePath);
|
|
155
|
+
await writeDataset({
|
|
156
|
+
name: 'my_case', input: { q: 'hello' },
|
|
157
|
+
last_eval: { output: { datasetName: 'my_case', verdict: 'pass', evaluators: [] }, date: '2026-01-01T00:01:00.000Z' }
|
|
158
|
+
}, filePath);
|
|
159
|
+
const raw = yaml.load(await readFile(filePath, 'utf-8'));
|
|
160
|
+
const caseObj = raw.my_case;
|
|
161
|
+
expect(caseObj).toHaveProperty('last_output');
|
|
162
|
+
expect(caseObj).toHaveProperty('last_eval');
|
|
163
|
+
expect(caseObj).toHaveProperty('ground_truth');
|
|
164
|
+
});
|
|
165
|
+
it('creates parent directories if they do not exist', async () => {
|
|
166
|
+
const filePath = join(ctx.tmpDir, 'deep', 'nested', 'cases.yml');
|
|
167
|
+
await writeDataset({ name: 'my_case', input: { q: 'x' } }, filePath);
|
|
168
|
+
const raw = yaml.load(await readFile(filePath, 'utf-8'));
|
|
169
|
+
expect(raw).toHaveProperty('my_case');
|
|
170
|
+
});
|
|
171
|
+
it('recovers gracefully when existing file contains non-object YAML', async () => {
|
|
172
|
+
const filePath = join(ctx.tmpDir, 'cases.yml');
|
|
173
|
+
await writeFile(filePath, 'just a string', 'utf-8');
|
|
174
|
+
await writeDataset({ name: 'my_case', input: { q: 'x' } }, filePath);
|
|
175
|
+
const raw = yaml.load(await readFile(filePath, 'utf-8'));
|
|
176
|
+
expect(raw).toHaveProperty('my_case');
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// listDatasets
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
describe('listDatasets', () => {
|
|
183
|
+
it('returns one DatasetInfo per case across all files', async () => {
|
|
184
|
+
const datasetsDir = join(ctx.tmpDir, 'src', 'workflows', 'my_workflow', 'tests', 'datasets');
|
|
185
|
+
await mkdir(datasetsDir, { recursive: true });
|
|
186
|
+
await writeYaml(join(datasetsDir, 'core.yml'), {
|
|
187
|
+
case_1: { input: { x: 1 }, last_output: { output: { r: 1 }, date: '2026-01-01T00:00:00.000Z' } },
|
|
188
|
+
case_2: { input: { x: 2 } }
|
|
189
|
+
});
|
|
190
|
+
const infos = await listDatasets('my_workflow', ctx.tmpDir);
|
|
191
|
+
expect(infos).toHaveLength(2);
|
|
192
|
+
const case1 = infos.find(i => i.name === 'case_1');
|
|
193
|
+
expect(case1.hasLastOutput).toBe(true);
|
|
194
|
+
expect(case1.path).toContain('core.yml');
|
|
195
|
+
const case2 = infos.find(i => i.name === 'case_2');
|
|
196
|
+
expect(case2.hasLastOutput).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
it('returns empty array when no datasets directory exists', async () => {
|
|
199
|
+
const infos = await listDatasets('nonexistent_workflow', ctx.tmpDir);
|
|
200
|
+
expect(infos).toHaveLength(0);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -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,
|
|
38
|
+
export { isDockerInstalled, DockerValidationError };
|
package/dist/services/docker.js
CHANGED
|
@@ -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
|
|
35
|
-
const isDockerDaemonRunning = () => checkDockerCommand('docker ps');
|
|
36
|
-
const DOCKER_VALIDATIONS = [
|
|
45
|
+
const PREREQUISITES = [
|
|
37
46
|
{
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
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,
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
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)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
type WorkflowResult = Pick<
|
|
3
|
-
export declare const ERROR_STATUSES: ReadonlySet<
|
|
1
|
+
import type { WorkflowResultResponse, WorkflowResultResponseStatus } from '../api/generated/api.js';
|
|
2
|
+
type WorkflowResult = Pick<WorkflowResultResponse, 'workflowId' | 'output' | 'status' | 'error'>;
|
|
3
|
+
export declare const ERROR_STATUSES: ReadonlySet<WorkflowResultResponseStatus | undefined>;
|
|
4
4
|
export declare function formatWorkflowResult(result: WorkflowResult): string;
|
|
5
5
|
export {};
|
|
@@ -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,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
|
+
});
|