@outputai/cli 0.1.13-dev.af42b51.0 → 0.1.13-dev.e3e3291.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.13-dev.af42b51.0}
84
+ image: outputai/api:${OUTPUT_API_VERSION:-0.1.13-dev.e3e3291.0}
85
85
  init: true
86
86
  networks:
87
87
  - main
@@ -114,7 +114,7 @@ export default class WorkflowTest extends Command {
114
114
  }
115
115
  };
116
116
  if (save) {
117
- const filePath = join(dir, `${dataset.name}.yml`);
117
+ const filePath = dataset._source ?? join(dir, `${dataset.name}.yml`);
118
118
  await writeDataset(updated, filePath);
119
119
  this.log(` Saved output to ${filePath}`);
120
120
  }
@@ -137,7 +137,7 @@ export default class WorkflowTest extends Command {
137
137
  date: now
138
138
  }
139
139
  };
140
- const filePath = join(dir, `${dataset.name}.yml`);
140
+ const filePath = dataset._source ?? join(dir, `${dataset.name}.yml`);
141
141
  await writeDataset(updated, filePath);
142
142
  this.log(` Saved eval result to ${filePath}`);
143
143
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "framework": "0.1.13-dev.af42b51.0"
2
+ "framework": "0.1.13-dev.e3e3291.0"
3
3
  }
@@ -8,7 +8,7 @@ export interface DatasetInfo {
8
8
  }
9
9
  export declare function resolveDatasetsDir(workflowName: string, basePath?: string): string | null;
10
10
  export declare function resolveDefaultDatasetsDir(workflowName: string, basePath?: string): string;
11
- export declare function readDataset(filePath: string): Promise<Dataset>;
11
+ export declare function readDatasetFile(filePath: string): Promise<Dataset[]>;
12
12
  export declare function readAllDatasets(workflowName: string, filterNames?: string[], basePath?: string): Promise<{
13
13
  datasets: Dataset[];
14
14
  dir: string;
@@ -24,10 +24,19 @@ export function resolveDefaultDatasetsDir(workflowName, basePath = process.cwd()
24
24
  // Default to first workflows path
25
25
  return resolve(basePath, WORKFLOWS_PATHS[0], workflowName, DATASETS_DIR);
26
26
  }
27
- export async function readDataset(filePath) {
28
- const content = await readFile(filePath, 'utf-8');
29
- const raw = yaml.load(content);
30
- return DatasetSchema.parse(raw);
27
+ export async function readDatasetFile(filePath) {
28
+ const raw = yaml.load(await readFile(filePath, 'utf-8'));
29
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
30
+ throw new Error(`Invalid dataset file: ${filePath}`);
31
+ }
32
+ return Object.entries(raw).map(([name, body]) => {
33
+ if (!body || typeof body !== 'object' || !('input' in body)) {
34
+ throw new Error(`Dataset case "${name}" in ${filePath} is missing required "input" field`);
35
+ }
36
+ const dataset = DatasetSchema.parse({ name, ...body });
37
+ dataset._source = filePath;
38
+ return dataset;
39
+ });
31
40
  }
32
41
  export async function readAllDatasets(workflowName, filterNames, basePath) {
33
42
  const dir = resolveDatasetsDir(workflowName, basePath);
@@ -36,42 +45,35 @@ export async function readAllDatasets(workflowName, filterNames, basePath) {
36
45
  }
37
46
  const files = await readdir(dir);
38
47
  const ymlFiles = files.filter(f => f.endsWith('.yml') || f.endsWith('.yaml'));
48
+ const seen = new Set();
39
49
  const datasets = [];
40
50
  for (const file of ymlFiles) {
41
- const dataset = await readDataset(join(dir, file));
42
- if (filterNames && !filterNames.includes(dataset.name)) {
43
- continue;
51
+ const cases = await readDatasetFile(join(dir, file));
52
+ for (const dataset of cases) {
53
+ if (seen.has(dataset.name)) {
54
+ throw new Error(`Duplicate dataset case name "${dataset.name}" found in ${file}`);
55
+ }
56
+ seen.add(dataset.name);
57
+ if (filterNames && !filterNames.includes(dataset.name)) {
58
+ continue;
59
+ }
60
+ datasets.push(dataset);
44
61
  }
45
- datasets.push(dataset);
46
62
  }
47
63
  return { datasets, dir };
48
64
  }
49
- async function mergeWithExisting(dataset, filePath) {
50
- if (!existsSync(filePath)) {
51
- return dataset;
52
- }
53
- try {
54
- const existing = await readDataset(filePath);
55
- return {
56
- ...existing,
57
- ...dataset,
58
- ground_truth: dataset.ground_truth ?? existing.ground_truth,
59
- last_output: dataset.last_output ?? existing.last_output,
60
- last_eval: dataset.last_eval ?? existing.last_eval
61
- };
62
- }
63
- catch {
64
- return dataset;
65
- }
66
- }
67
65
  export async function writeDataset(dataset, filePath) {
68
- const merged = await mergeWithExisting(dataset, filePath);
69
66
  const dir = resolve(filePath, '..');
70
67
  if (!existsSync(dir)) {
71
68
  await mkdir(dir, { recursive: true });
72
69
  }
73
- const content = yaml.dump(merged, { lineWidth: 120, noRefs: true, sortKeys: false });
74
- await writeFile(filePath, content, 'utf-8');
70
+ const loaded = existsSync(filePath) ? yaml.load(await readFile(filePath, 'utf-8')) : null;
71
+ const fileObj = (loaded && typeof loaded === 'object' && !Array.isArray(loaded)) ?
72
+ loaded :
73
+ {};
74
+ const { name, _source, ...caseBody } = dataset;
75
+ fileObj[name] = { ...fileObj[name], ...caseBody };
76
+ await writeFile(filePath, yaml.dump(fileObj, { lineWidth: 120, noRefs: true, sortKeys: false }), 'utf-8');
75
77
  }
76
78
  export async function listDatasets(workflowName, basePath) {
77
79
  const dir = resolveDatasetsDir(workflowName, basePath);
@@ -84,14 +86,16 @@ export async function listDatasets(workflowName, basePath) {
84
86
  for (const file of ymlFiles) {
85
87
  const filePath = join(dir, file);
86
88
  try {
87
- const dataset = await readDataset(filePath);
88
- results.push({
89
- name: dataset.name,
90
- path: filePath,
91
- hasLastOutput: dataset.last_output?.output !== undefined,
92
- lastOutputDate: dataset.last_output?.date,
93
- lastEvalDate: dataset.last_eval?.date
94
- });
89
+ const cases = await readDatasetFile(filePath);
90
+ for (const dataset of cases) {
91
+ results.push({
92
+ name: dataset.name,
93
+ path: filePath,
94
+ hasLastOutput: dataset.last_output?.output !== undefined,
95
+ lastOutputDate: dataset.last_output?.date,
96
+ lastEvalDate: dataset.last_eval?.date
97
+ });
98
+ }
95
99
  }
96
100
  catch {
97
101
  results.push({
@@ -0,0 +1 @@
1
+ export {};
@@ -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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@outputai/cli",
3
- "version": "0.1.13-dev.af42b51.0",
3
+ "version": "0.1.13-dev.e3e3291.0",
4
4
  "description": "CLI for Output.ai workflow generation",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -35,9 +35,9 @@
35
35
  "react": "19.2.4",
36
36
  "semver": "7.7.4",
37
37
  "yaml": "^2.8.3",
38
- "@outputai/credentials": "0.1.13-dev.af42b51.0",
39
- "@outputai/evals": "0.1.13-dev.af42b51.0",
40
- "@outputai/llm": "0.1.13-dev.af42b51.0"
38
+ "@outputai/evals": "0.1.13-dev.e3e3291.0",
39
+ "@outputai/credentials": "0.1.13-dev.e3e3291.0",
40
+ "@outputai/llm": "0.1.13-dev.e3e3291.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/cli-progress": "3.11.6",