@agentionai/fieldwork-cli 0.3.0 → 0.7.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/main.js CHANGED
@@ -1,77 +1,303 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { Command } from 'commander';
4
- import { request } from './client.js';
5
- import { Scope, setupCampaign } from './workspace.js';
6
- const program = new Command().name('fieldwork').description('Fieldwork API client; JSON results on stdout, JSON failures on stderr').version(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version)
7
- .option('--url <url>', 'API server URL (defaults to FIELDWORK_URL, LAB_URL, workspace config, then loopback)');
4
+ import { request, requestAll } from './client.js';
5
+ import { DEFAULT_SERVER, Scope, setupCampaign } from './workspace.js';
6
+ import { filterRows, parseCondition, projectRows, resolve, toTsv } from './select.js';
7
+ import { selectChangelog } from './changelog.js';
8
+ import { credentialsPath, forgetCredential, resolveCredential, storeCredential, } from './credentials.js';
9
+ const version = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
10
+ const program = new Command()
11
+ .name('fieldwork')
12
+ .description('Fieldwork API client; JSON results on stdout, JSON failures on stderr')
13
+ .version(version)
14
+ .option('--url <url>', `API server URL (defaults to FIELDWORK_URL, LAB_URL, workspace config, then ${DEFAULT_SERVER})`)
15
+ .option('--token <token>', 'API credential (defaults to FIELDWORK_TOKEN)')
16
+ .option('--org <id>', 'Organization to act in (defaults to FIELDWORK_ORGANIZATION); unnecessary for a credential bound to one organization');
8
17
  program.exitOverride();
9
18
  program.configureOutput({ writeErr: () => { } });
10
19
  const output = (value) => console.log(JSON.stringify(value, null, 2));
11
- const send = (path, method, body) => request(url(), path, method, body).then(output);
20
+ /** Credentials resolve like --url does: explicit flag, then environment, then the stored
21
+ * credential for this server. Deliberately no prompt fallback -- a secret typed at a
22
+ * prompt ends up in shell history. */
23
+ const credentials = () => {
24
+ const options = program.opts();
25
+ // serverUrl(), not url(): url() builds a Scope that carries credentials, and asking for
26
+ // credentials to resolve credentials recurses until the stack gives out.
27
+ const { token, organization } = resolveCredential(serverUrl(), {
28
+ token: options.token,
29
+ org: options.org,
30
+ });
31
+ return { token, organization };
32
+ };
33
+ const send = (path, method, body) => request(url(), path, method, body, credentials()).then(output);
12
34
  function input(value) {
13
35
  let parsed;
14
36
  try {
15
37
  parsed = JSON.parse(value === '-' ? readFileSync(0, 'utf8') : value);
16
38
  }
17
39
  catch (error) {
18
- throw Object.assign(new Error(`Cannot read JSON input: ${error.message}`), { code: 'INVALID_JSON', hint: 'Supply a valid JSON object with --json, or use --json - to read it from stdin; quote keys and strings with double quotes.' });
40
+ throw Object.assign(new Error(`Cannot read JSON input: ${error.message}`), {
41
+ code: 'INVALID_JSON',
42
+ hint: 'Supply a valid JSON object with --json, or use --json - to read it from stdin; quote keys and strings with double quotes.',
43
+ });
19
44
  }
20
45
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
21
- throw Object.assign(new Error('JSON input must be an object'), { code: 'INVALID_JSON', hint: 'Use a JSON object such as {"name":"Example"}, not null, an array, or a scalar; see the command’s --help for required fields.' });
46
+ throw Object.assign(new Error('JSON input must be an object'), {
47
+ code: 'INVALID_JSON',
48
+ hint: 'Use a JSON object such as {"name":"Example"}, not null, an array, or a scalar; see the command’s --help for required fields.',
49
+ });
22
50
  return parsed;
23
51
  }
24
- function fail2(message) { throw new Error(message); }
25
- const url = () => new Scope(program.opts().url).url;
52
+ function fail(message) {
53
+ throw new Error(message);
54
+ }
55
+ /** Server resolution alone, with no credentials, so credential resolution can use it. */
56
+ const serverUrl = () => new Scope(program.opts().url).url;
57
+ /** One scope per invocation, carrying both the resolved server and the credentials, so no
58
+ * call site can reach the API without them. */
59
+ const apiScope = () => new Scope(program.opts().url, process.cwd(), credentials());
60
+ const url = () => serverUrl();
26
61
  const path = (id) => `/campaigns/${encodeURIComponent(id)}`;
27
62
  const collection = (product) => product ? `/products/${encodeURIComponent(product)}/campaigns` : '/campaigns';
28
- const setup = program.command('setup').description('Attach the current directory to an existing campaign');
29
- setup.command('campaign').description('Create local config and workspace directories; use --create to add a missing campaign')
63
+ const auth = program.command('auth').description('Manage the API credential for this machine');
64
+ auth
65
+ .command('login')
66
+ .description('Store a credential for this server')
67
+ .action(async () => {
68
+ const options = program.opts();
69
+ // --token and --org are global, so they are spelled the same here as everywhere else.
70
+ // Declaring them again on this subcommand shadowed the global ones and made login the
71
+ // one command that could not see its own credential.
72
+ if (!options.token)
73
+ throw Object.assign(new Error('auth login requires --token'), {
74
+ code: 'MISSING_TOKEN',
75
+ hint: 'Pass the credential with --token. It is stored for this server only, under your config directory.',
76
+ });
77
+ const server = url();
78
+ // Verified before it is written. Storing an unusable credential turns a typo into a
79
+ // failure on some later command, far from the paste that caused it.
80
+ const organizations = (await request(server, '/organizations', 'GET', undefined, {
81
+ token: options.token,
82
+ organization: options.org,
83
+ }));
84
+ const chosen = options.org ?? (organizations.length === 1 ? organizations[0]?.id : undefined);
85
+ const path = storeCredential(server, {
86
+ token: options.token,
87
+ ...(chosen ? { organization: chosen } : {}),
88
+ });
89
+ output({
90
+ server,
91
+ storedAt: path,
92
+ organizations,
93
+ organization: chosen ?? null,
94
+ // Several organizations and no --org means later commands need one; say so now
95
+ // rather than letting every request fail with a permission error.
96
+ ...(chosen
97
+ ? {}
98
+ : {
99
+ note: 'Several organizations are available; pass --org, or set FIELDWORK_ORGANIZATION.',
100
+ }),
101
+ });
102
+ });
103
+ auth
104
+ .command('status')
105
+ .description('Show which credential this server would use, and whether it works')
106
+ .action(async () => {
107
+ const server = url();
108
+ const resolved = resolveCredential(server, program.opts());
109
+ if (!resolved.token)
110
+ return output({
111
+ server,
112
+ source: 'none',
113
+ credentialsFile: credentialsPath(),
114
+ note: 'No credential. Use fieldwork auth login --token, or set FIELDWORK_TOKEN.',
115
+ });
116
+ const organizations = (await request(server, '/organizations', 'GET', undefined, resolved));
117
+ output({
118
+ server,
119
+ // Which source is in play matters: a stale FIELDWORK_TOKEN silently shadowing a
120
+ // fresh login is the usual confusion.
121
+ source: resolved.source,
122
+ credentialsFile: credentialsPath(),
123
+ organization: resolved.organization ?? null,
124
+ organizations,
125
+ });
126
+ });
127
+ auth
128
+ .command('logout')
129
+ .description('Remove the stored credential for this server')
130
+ .action(() => {
131
+ const server = url();
132
+ // Reports what it did rather than failing when there was nothing stored: logging out
133
+ // twice is not an error, and an environment variable is not ours to unset.
134
+ output({ server, removed: forgetCredential(server), credentialsFile: credentialsPath() });
135
+ });
136
+ const setup = program
137
+ .command('setup')
138
+ .description('Attach the current directory to an existing campaign');
139
+ setup
140
+ .command('campaign')
141
+ .description('Create local config and workspace directories; use --create to add a missing campaign')
30
142
  .requiredOption('--campaign <ref>', 'Campaign stub or ID')
31
143
  .option('--product <ref>', 'Product stub or ID; validates parentage')
32
144
  .option('--create', 'Create the campaign when not found; requires --goal')
33
145
  .option('--goal <text>', 'Goal for --create')
34
146
  .action(async (options) => {
35
- const result = await setupCampaign(url(), { ...options, campaign: options.campaign }, process.cwd());
36
- output({ workspaceRoot: result.root, created: result.created, product: result.config.product, campaign: result.config.campaign });
147
+ const result = await setupCampaign(url(), { ...options, campaign: options.campaign }, process.cwd(), credentials());
148
+ output({
149
+ workspaceRoot: result.root,
150
+ created: result.created,
151
+ product: result.config.product,
152
+ campaign: result.config.campaign,
153
+ });
37
154
  });
38
- program.command('context').description('Effective server, workspace scope, and live inherited research context (setup campaign required when no scope is given)')
155
+ program
156
+ .command('context')
157
+ .description('Effective server, workspace scope, and live inherited research context (setup campaign required when no scope is given)')
39
158
  .option('--campaign <ref>', 'Inspect without a local workspace')
40
- .action(async (options) => output(await new Scope(url()).context(options.campaign)));
41
- const campaigns = program.command('campaigns').description('Manage research goals and inspect inherited product context');
42
- campaigns.command('list').option('--product <ref>', 'Scope to product (stub or ID) via /products/:id/campaigns').description('List campaigns; omit product for the whole workspace').action(async (options) => send(collection(options.product && (await new Scope(url()).product(options.product)).id)));
43
- campaigns.command('get').argument('<ref>').description('Get campaign by stub or ID, with live parent context and runs').action(async (reference) => send(path((await new Scope(url()).campaign(reference)).id)));
44
- campaigns.command('context').argument('<ref>').description('Get campaign goal, criteria, constraints and inherited context (same complete response as get)').action(async (reference) => send(path((await new Scope(url()).campaign(reference)).id)));
45
- campaigns.command('create').option('--product <ref>', 'Product (stub or ID) in the URL; omit for independent campaigns or a productId in JSON').requiredOption('--json <json|->', 'JSON object, or - to read stdin; requires name and goal')
46
- .action(async (options) => send(collection(options.product && (await new Scope(url()).product(options.product)).id), 'POST', input(options.json)));
47
- campaigns.command('update').argument('<ref>').requiredOption('--json <json|->', 'Patch including expected revision').description('Patch by stub or ID; JSON includes expected revision').action(async (reference, options) => send(path((await new Scope(url()).campaign(reference)).id), 'PATCH', input(options.json)));
48
- campaigns.command('delete').argument('<ref>').requiredOption('--revision <number>', 'Expected revision; deletion is blocked by experiments or runs')
159
+ .action(async (options) => output(await apiScope().context(options.campaign)));
160
+ program
161
+ .command('changelog')
162
+ .description('Recent releases of this CLI, offline; each change states whether it needs an updated API')
163
+ .option('--since <version>', 'Only releases after this version')
164
+ .option('--release <version>', 'Only this release')
165
+ .addHelpText('after', '\nThe package carries recent releases only; see the repository changelog for the full history.\nrequiresApi records what a change needs from the server. The CLI cannot verify what an API\nprovides, and installing a newer client never upgrades a server.')
166
+ .action((options) => output(selectChangelog(version, options)));
167
+ const campaigns = program
168
+ .command('campaigns')
169
+ .description('Manage research goals and inspect inherited product context');
170
+ campaigns
171
+ .command('list')
172
+ .option('--product <ref>', 'Scope to product (stub or ID) via /products/:id/campaigns')
173
+ .description('List campaigns; omit product for the whole workspace')
174
+ .action(async (options) => send(collection(options.product && (await apiScope().product(options.product)).id)));
175
+ campaigns
176
+ .command('get')
177
+ .argument('<ref>')
178
+ .description('Get campaign by stub or ID, with live parent context and runs')
179
+ .action(async (reference) => send(path((await apiScope().campaign(reference)).id)));
180
+ campaigns
181
+ .command('context')
182
+ .argument('<ref>')
183
+ .description('Get campaign goal, criteria, constraints and inherited context (same complete response as get)')
184
+ .action(async (reference) => send(path((await apiScope().campaign(reference)).id)));
185
+ campaigns
186
+ .command('create')
187
+ .option('--product <ref>', 'Product (stub or ID) in the URL; omit for independent campaigns or a productId in JSON')
188
+ .requiredOption('--json <json|->', 'JSON object, or - to read stdin; requires name and goal')
189
+ .action(async (options) => send(collection(options.product && (await apiScope().product(options.product)).id), 'POST', input(options.json)));
190
+ campaigns
191
+ .command('update')
192
+ .argument('<ref>')
193
+ .requiredOption('--json <json|->', 'Patch including expected revision')
194
+ .description('Patch by stub or ID; JSON includes expected revision')
195
+ .action(async (reference, options) => send(path((await apiScope().campaign(reference)).id), 'PATCH', input(options.json)));
196
+ campaigns
197
+ .command('delete')
198
+ .argument('<ref>')
199
+ .requiredOption('--revision <number>', 'Expected revision; deletion is blocked by experiments or runs')
49
200
  .action(async (reference, options) => {
50
201
  const revision = Number(options.revision);
51
202
  if (!Number.isSafeInteger(revision) || revision < 1)
52
203
  throw new Error('Revision must be a positive integer');
53
- return send(path((await new Scope(url()).campaign(reference)).id), 'DELETE', { revision });
204
+ return send(path((await apiScope().campaign(reference)).id), 'DELETE', { revision });
205
+ });
206
+ const artifacts = program
207
+ .command('artifacts')
208
+ .description('Product-scoped recipes and file metadata; permanently immutable after reference');
209
+ const artifactProduct = async (ref) => (await apiScope().product(ref)).id;
210
+ async function artifactRef(ref, product) {
211
+ if (/^[0-9a-f-]{36}$/i.test(ref) && !product)
212
+ return ref;
213
+ if (!product)
214
+ fail('Artifact stubs require --product REF; IDs can be used directly');
215
+ const values = (await request(url(), `/products/${encodeURIComponent(await artifactProduct(product))}/artifacts`, 'GET', undefined, credentials()));
216
+ const value = values.find((v) => v.id === ref || v.stub === ref);
217
+ if (!value)
218
+ fail('Artifact not found in product');
219
+ return value.id;
220
+ }
221
+ artifacts
222
+ .command('list')
223
+ .requiredOption('--product <ref>', 'Product stub or ID')
224
+ .action(async (o) => send(`/products/${encodeURIComponent(await artifactProduct(o.product))}/artifacts`));
225
+ artifacts
226
+ .command('create')
227
+ .requiredOption('--product <ref>', 'Product stub or ID')
228
+ .requiredOption('--json <json|->', 'Requires stub/name; optional kind, description, definition object, files [{role,uri,sha256?}], derivedFrom ID')
229
+ .action(async (o) => send(`/products/${encodeURIComponent(await artifactProduct(o.product))}/artifacts`, 'POST', input(o.json)));
230
+ artifacts
231
+ .command('get')
232
+ .argument('<ref>')
233
+ .option('--product <ref>', 'Required for artifact stubs')
234
+ .action(async (ref, o) => send(`/artifacts/${encodeURIComponent(await artifactRef(ref, o.product))}`));
235
+ artifacts
236
+ .command('update')
237
+ .argument('<ref>')
238
+ .option('--product <ref>', 'Required for artifact stubs')
239
+ .requiredOption('--json <json|->', 'Patch with current revision; definition/files replace whole fields; frozen artifacts reject edits')
240
+ .action(async (ref, o) => send(`/artifacts/${encodeURIComponent(await artifactRef(ref, o.product))}`, 'PATCH', input(o.json)));
241
+ artifacts
242
+ .command('delete')
243
+ .argument('<ref>')
244
+ .option('--product <ref>', 'Required for artifact stubs')
245
+ .requiredOption('--revision <number>', 'Expected revision; referenced artifacts cannot be deleted')
246
+ .action(async (ref, o) => {
247
+ const revision = Number(o.revision);
248
+ if (!Number.isSafeInteger(revision) || revision < 1)
249
+ fail('Revision must be a positive integer');
250
+ return send(`/artifacts/${encodeURIComponent(await artifactRef(ref, o.product))}`, 'DELETE', {
251
+ revision,
252
+ });
54
253
  });
55
- const resolveSchema = async (ref) => { if (!/^[0-9a-f-]{36}$/i.test(ref))
56
- fail2('Schema references are version IDs; use schemas list --product REF to obtain one'); return ref; };
57
- const schemas = program.command('schemas').description('Manage typed research schemas: publish immutable versions, inspect templates, and validate payloads before writing records');
254
+ artifacts
255
+ .command('diff')
256
+ .argument('<from>')
257
+ .argument('<to>')
258
+ .option('--product <ref>', 'Required for artifact stubs')
259
+ .description('Compare recipe definitions/files within one product; paths use JSON Pointer')
260
+ .action(async (from, to, o) => send(`/artifacts/${encodeURIComponent(await artifactRef(from, o.product))}/diff/${encodeURIComponent(await artifactRef(to, o.product))}`));
261
+ const resolveSchema = async (ref) => {
262
+ if (!/^[0-9a-f-]{36}$/i.test(ref))
263
+ fail('Schema references are version IDs; use schemas list --product REF to obtain one');
264
+ return ref;
265
+ };
266
+ const schemas = program
267
+ .command('schemas')
268
+ .description('Manage typed research schemas: publish immutable versions, inspect templates, and validate payloads before writing records');
58
269
  async function schemaScope(options) {
59
- const scope = new Scope(url());
270
+ const scope = apiScope();
60
271
  if (options.product) {
61
272
  if (options.campaign || options.experiment)
62
- fail2('--product cannot be combined with --campaign or --experiment for schemas');
273
+ fail('--product cannot be combined with --campaign or --experiment for schemas');
63
274
  return `/products/${encodeURIComponent((await scope.product(options.product)).id)}`;
64
275
  }
65
276
  const campaign = await scope.campaign(options.campaign);
66
- return options.experiment ? `/experiments/${encodeURIComponent(await scope.work('experiments', options.experiment, campaign.id))}` : path(campaign.id);
277
+ return options.experiment
278
+ ? `/experiments/${encodeURIComponent(await scope.work('experiments', options.experiment, campaign.id))}`
279
+ : path(campaign.id);
67
280
  }
68
281
  for (const operation of ['list', 'publish', 'default', 'set-default']) {
69
- const command = schemas.command(operation).option('--product <ref>', 'Product scope; cannot combine with campaign/experiment').option('--campaign <ref>', 'Campaign scope; defaults to workspace').option('--experiment <ref>', 'Experiment within campaign');
282
+ const command = schemas
283
+ .command(operation)
284
+ .option('--product <ref>', 'Product scope; cannot combine with campaign/experiment')
285
+ .option('--campaign <ref>', 'Campaign scope; defaults to workspace')
286
+ .option('--experiment <ref>', 'Experiment within campaign');
70
287
  if (operation === 'list')
71
288
  command.option('--inherited', 'Include schemas owned by ancestors');
72
289
  if (operation === 'publish' || operation === 'set-default')
73
- command.requiredOption('--json <json|->', operation === 'publish' ? 'JSON with stub, version, definition; - reads stdin' : 'JSON with schemaVersionId and current default revision; null clears product/campaign default');
74
- command.description(operation === 'default' ? 'Inspect local and effective schema default; no changes to existing records' : operation === 'set-default' ? 'Set a product/campaign default or pin an experiment (immutable once runs exist)' : operation === 'list' ? 'List local schemas; --inherited includes ancestors' : 'Publish an immutable schema owned by product, campaign or experiment')
290
+ command.requiredOption('--json <json|->', operation === 'publish'
291
+ ? 'JSON with stub, version, definition; - reads stdin'
292
+ : 'JSON with schemaVersionId and current default revision; null clears product/campaign default');
293
+ command
294
+ .description(operation === 'default'
295
+ ? 'Inspect local and effective schema default; no changes to existing records'
296
+ : operation === 'set-default'
297
+ ? 'Set a product/campaign default or pin an experiment (immutable once runs exist)'
298
+ : operation === 'list'
299
+ ? 'List local schemas; --inherited includes ancestors'
300
+ : 'Publish an immutable schema owned by product, campaign or experiment')
75
301
  .action(async (options) => {
76
302
  const target = await schemaScope(options);
77
303
  if (operation === 'set-default')
@@ -80,69 +306,229 @@ for (const operation of ['list', 'publish', 'default', 'set-default']) {
80
306
  });
81
307
  }
82
308
  for (const operation of ['get', 'template'])
83
- schemas.command(operation).argument('<ref>').description(operation === 'template' ? 'Illustrative experiment and observation placeholders; replace, never record as measured values' : 'Published schema definition').action(async (ref) => send(`/schemas/${encodeURIComponent(await resolveSchema(ref))}${operation === 'template' ? '/template' : ''}`));
84
- schemas.command('validate').argument('<ref>').requiredOption('--json <json|->', 'Payload with parameters/observations/comparisonContext').option('--ready', 'Treat required parameters as blocking rather than advisory').description('Check values without saving; exit code 1 with VALIDATION_FAILED when the payload does not satisfy the schema')
309
+ schemas
310
+ .command(operation)
311
+ .argument('<ref>')
312
+ .description(operation === 'template'
313
+ ? 'Illustrative experiment and observation placeholders; replace, never record as measured values'
314
+ : 'Published schema definition')
315
+ .action(async (ref) => send(`/schemas/${encodeURIComponent(await resolveSchema(ref))}${operation === 'template' ? '/template' : ''}`));
316
+ schemas
317
+ .command('extend')
318
+ .argument('<ref>')
319
+ .requiredOption('--json <json|->', 'Complete replacement definition: {definition:{parameters,observations,comparisonContext}}; optional backfill:{field:{RUN_ID:value}} for fields a run predates')
320
+ .option('--dry-run', 'Validate and preview affected records without saving')
321
+ .description('Publish a compatible successor and atomically re-pin matching experiments, runs, defaults and charts; old versions and snapshots remain intact')
322
+ .addHelpText('after', '\nbackfill fills in parameter or context fields a run predates -- added to the schema\nafter the run was recorded -- on runs this extension re-pins, finished runs included, e.g.\n {"definition":{...},"backfill":{"packager":{"RUN_ID":"unsloth"}}}\nName a field parameters.<field> or comparisonContext.<field> if the bare name is\nambiguous. Never over a recorded value, never a field the run was recorded with; values are\nvalidated with the extension. Runs note them in research.backfilled, and a backfilled\nvalue stays correctable with runs update. Adding enum values is an ordinary extension.')
323
+ .action(async (ref, options) => {
324
+ const data = input(options.json);
325
+ if (options.dryRun)
326
+ data.dryRun = true;
327
+ return send(`/schemas/${encodeURIComponent(await resolveSchema(ref))}/extend`, 'POST', data);
328
+ });
329
+ schemas
330
+ .command('validate')
331
+ .argument('<ref>')
332
+ .requiredOption('--json <json|->', 'Payload with parameters/observations/comparisonContext')
333
+ .option('--ready', 'Treat required parameters as blocking rather than advisory')
334
+ .description('Check values without saving; exit code 1 with VALIDATION_FAILED when the payload does not satisfy the schema')
85
335
  .action(async (ref, options) => {
86
336
  const data = input(options.json);
87
337
  if (!data || typeof data !== 'object' || Array.isArray(data))
88
- fail2('Validation input must be a JSON object');
338
+ fail('Validation input must be a JSON object');
89
339
  if (options.ready)
90
340
  data.ready = true;
91
- const result = await request(url(), `/schemas/${encodeURIComponent(await resolveSchema(ref))}/validate`, 'POST', data);
341
+ const result = (await request(url(), `/schemas/${encodeURIComponent(await resolveSchema(ref))}/validate`, 'POST', data, credentials()));
92
342
  output(result);
93
343
  if (result.valid === false) {
94
- console.error(JSON.stringify({ code: 'VALIDATION_FAILED', message: 'Payload does not satisfy the schema; see stdout for the full report', hint: 'Repair stdout issues using their paths, expected values and hints, then rerun validation before writing.' }));
344
+ console.error(JSON.stringify({
345
+ code: 'VALIDATION_FAILED',
346
+ message: 'Payload does not satisfy the schema; see stdout for the full report',
347
+ hint: 'Repair stdout issues using their paths, expected values and hints, then rerun validation before writing.',
348
+ }));
95
349
  process.exitCode = 1;
96
350
  }
97
351
  });
98
- const charts = program.command('charts').description('Create saved typed bar, line and scatter charts for campaigns or experiments');
352
+ const charts = program
353
+ .command('charts')
354
+ .description('Create saved typed bar, line and scatter charts for campaigns or experiments');
99
355
  for (const operation of ['list', 'fields', 'create']) {
100
- const command = charts.command(operation).option('--campaign <ref>', 'Campaign stub or ID; defaults to workspace').option('--experiment <ref>', 'Experiment stub or ID within campaign');
356
+ const command = charts
357
+ .command(operation)
358
+ .option('--campaign <ref>', 'Campaign stub or ID; defaults to workspace')
359
+ .option('--experiment <ref>', 'Experiment stub or ID within campaign');
101
360
  if (operation === 'create')
102
361
  command.requiredOption('--json <json|->', 'Object with title, type (bar/line/scatter), schemaVersionId, x and y: {section,field,label?}; - reads stdin');
103
- command.description(operation === 'fields' ? 'Discover scoped schema versions, typed fields and supported chart types' : operation === 'list' ? 'List saved chart definitions' : 'Save an immutable chart definition; data is live, successful runs only, without aggregation')
362
+ if (operation === 'create')
363
+ command.addHelpText('after', '\nOptional aggregation: {groupBy:[{section,field}],metric:"mean"|"stdev",spread:"none"|"whiskers"|"band"}. Groups retain X and context boundaries; sample SD uses n-1 (unavailable for n<2). Spread requires mean; bands require line. No arbitrary expressions.');
364
+ command
365
+ .description(operation === 'fields'
366
+ ? 'Discover scoped schema versions, typed fields and supported chart types'
367
+ : operation === 'list'
368
+ ? 'List saved chart definitions'
369
+ : 'Save an immutable chart definition; live successful runs, optionally grouped with mean or sample stdev')
104
370
  .action(async (options) => {
105
- const scope = new Scope(url());
371
+ const scope = apiScope();
106
372
  const campaign = await scope.campaign(options.campaign);
107
- const target = options.experiment ? `/experiments/${encodeURIComponent(await scope.work('experiments', options.experiment, campaign.id))}` : path(campaign.id);
373
+ const target = options.experiment
374
+ ? `/experiments/${encodeURIComponent(await scope.work('experiments', options.experiment, campaign.id))}`
375
+ : path(campaign.id);
108
376
  return send(`${target}/charts${operation === 'fields' ? '/fields' : ''}`, operation === 'create' ? 'POST' : 'GET', operation === 'create' ? input(options.json) : undefined);
109
377
  });
110
378
  }
111
379
  for (const operation of ['get', 'data', 'delete'])
112
- charts.command(operation).argument('<id>', 'Chart ID returned by create/list').description(operation === 'data' ? 'Live points with source run IDs/revisions, context series, exclusions and timestamp' : operation === 'delete' ? 'Delete a saved chart definition, never its source runs' : 'Get a saved chart definition').action((id) => send(`/charts/${encodeURIComponent(id)}${operation === 'data' ? '/data' : ''}`, operation === 'delete' ? 'DELETE' : 'GET'));
380
+ charts
381
+ .command(operation)
382
+ .argument('<id>', 'Chart ID returned by create/list')
383
+ .description(operation === 'data'
384
+ ? 'Live points with source run IDs/revisions, context series, exclusions and timestamp'
385
+ : operation === 'delete'
386
+ ? 'Delete a saved chart definition, never its source runs'
387
+ : 'Get a saved chart definition')
388
+ .action((id) => send(`/charts/${encodeURIComponent(id)}${operation === 'data' ? '/data' : ''}`, operation === 'delete' ? 'DELETE' : 'GET'));
389
+ charts
390
+ .command('series')
391
+ .argument('<id>', 'Chart ID returned by create/list')
392
+ .requiredOption('--json <json|->', 'Object with series: {by:"field",section,field}, {by:"groups",groups:[{label,runIds}],otherLabel?}, or null')
393
+ .description("Choose what a raw chart's colours mean: a typed field, groups of runs you define, or null for comparison context")
394
+ .addHelpText('after', '\nBy field colours each point by a parameter or comparison-context value, e.g.\n {"series":{"by":"field","section":"parameters","field":"recipe_family"}}\nBy groups names the split no single field records, e.g. ours against as shipped:\n {"series":{"by":"groups","groups":[{"label":"Ours","runIds":["RUN_ID"]}],"otherLabel":"Shipped"}}\nOnly runs the chart plots, each in one group; the rest join otherLabel. Each point\nstill reports its comparison context. Not for aggregated charts. Also accepted by create.')
395
+ .action((id, options) => send(`/charts/${encodeURIComponent(id)}/series`, 'PUT', input(options.json)));
396
+ charts
397
+ .command('frontier')
398
+ .argument('<id>', 'Chart ID returned by create/list')
399
+ .requiredOption('--json <json|->', 'Object with runIds: the run IDs to join, replacing any earlier choice; [] clears it')
400
+ .description('Choose the runs a scatter chart joins as its frontier; only runs it plots, never computed')
401
+ .addHelpText('after', '\nA frontier is a judgement, not a calculation: pick the runs that are comparable and\nrepresent the trade-off. Scatter charts without aggregation only. The line is drawn\nthrough the chosen runs in X order.')
402
+ .action((id, options) => send(`/charts/${encodeURIComponent(id)}/frontier`, 'PUT', input(options.json)));
403
+ program
404
+ .command('whoami')
405
+ .description('Who this credential is, the organization it acts in, and the permissions it currently holds')
406
+ .addHelpText('after', '\nPermissions are derived per request, so this is what a write would actually be allowed\nto do -- not a profile name or a role. An agent credential also reports itself, since\nnothing else tells it which agent it is or whose work it will be attributed to.')
407
+ .action(() => send('/me'));
113
408
  const products = program.command('products').description('Manage products');
114
409
  products.command('list').action(() => send('/products'));
115
- products.command('get').argument('<ref>').description('Get a product by stub or ID').action(async (reference) => send(`/products/${encodeURIComponent((await new Scope(url()).product(reference)).id)}`));
116
- products.command('create').requiredOption('--json <json|->', 'JSON with name; - reads stdin').action((options) => send('/products', 'POST', input(options.json)));
117
- products.command('update').argument('<ref>').requiredOption('--json <json|->', 'Patch with expected revision').action(async (reference, options) => send(`/products/${encodeURIComponent((await new Scope(url()).product(reference)).id)}`, 'PATCH', input(options.json)));
118
- products.command('delete').argument('<ref>').description('Delete an empty product by stub or ID; existing product API does not require a revision').action(async (reference) => send(`/products/${encodeURIComponent((await new Scope(url()).product(reference)).id)}`, 'DELETE'));
410
+ products
411
+ .command('get')
412
+ .argument('<ref>')
413
+ .description('Get a product by stub or ID')
414
+ .action(async (reference) => send(`/products/${encodeURIComponent((await apiScope().product(reference)).id)}`));
415
+ products
416
+ .command('create')
417
+ .requiredOption('--json <json|->', 'JSON with name; - reads stdin')
418
+ .action((options) => send('/products', 'POST', input(options.json)));
419
+ products
420
+ .command('update')
421
+ .argument('<ref>')
422
+ .requiredOption('--json <json|->', 'Patch with expected revision')
423
+ .action(async (reference, options) => send(`/products/${encodeURIComponent((await apiScope().product(reference)).id)}`, 'PATCH', input(options.json)));
424
+ products
425
+ .command('delete')
426
+ .argument('<ref>')
427
+ .description('Delete an empty product by stub or ID; existing product API does not require a revision')
428
+ .action(async (reference) => send(`/products/${encodeURIComponent((await apiScope().product(reference)).id)}`, 'DELETE'));
119
429
  for (const kind of ['experiments', 'runs']) {
120
- const group = program.command(kind).description(`Manage ${kind}; status updates record state, never launch or stop jobs`);
121
- const resolveRef = (reference, campaignRef) => new Scope(url()).work(kind, reference, campaignRef);
122
- group.command('list').option('--campaign <ref>', 'Defaults to the local workspace campaign').action(async (options) => send(`${path((await new Scope(url()).campaign(options.campaign)).id)}/${kind}`));
430
+ const group = program
431
+ .command(kind)
432
+ .description(`Manage ${kind}; status updates record state, never launch or stop jobs`);
433
+ const resolveRef = (reference, campaignRef) => apiScope().work(kind, reference, campaignRef);
434
+ const list = group
435
+ .command('list')
436
+ .option('--campaign <ref>', 'Defaults to the local workspace campaign')
437
+ .option('--where <expression>', 'Keep records matching FIELD=VALUE, or != >= <= > <; repeatable and combined with AND', (value, previous) => [...previous, value], [])
438
+ .option('--fields <paths>', 'Comma-separated paths to print instead of whole records, such as parameters.n,observations.accuracy')
439
+ .option('--format <format>', 'json (default) or tsv', 'json')
440
+ .option('--include-superseded', 'Include records whose extras.superseded_by names a replacement')
441
+ .option('--include-abandoned', kind === 'runs' ? 'Include runs of abandoned experiments' : 'Include abandoned experiments');
442
+ if (kind === 'runs')
443
+ list.option('--experiment <ref>', 'Only runs of this experiment, by stub or ID');
444
+ list
445
+ .description(`List ${kind}; superseded records and abandoned work are excluded unless asked for`)
446
+ .action(async (options) => {
447
+ if (!['json', 'tsv'].includes(options.format))
448
+ fail('--format must be json or tsv');
449
+ const scope = apiScope();
450
+ const campaign = await scope.campaign(options.campaign);
451
+ let rows = await requestAll(url(), `${path(campaign.id)}/${kind}`, credentials());
452
+ if (options.experiment) {
453
+ const experimentId = await scope.work('experiments', options.experiment, campaign.id);
454
+ rows = rows.filter((row) => row['experimentId'] === experimentId);
455
+ }
456
+ if (!options.includeSuperseded)
457
+ rows = rows.filter((row) => resolve(kind, row, 'extras.superseded_by') == null);
458
+ if (!options.includeAbandoned) {
459
+ if (kind === 'experiments')
460
+ rows = rows.filter((row) => row['status'] !== 'abandoned');
461
+ else {
462
+ const abandoned = new Set((await requestAll(url(), `${path(campaign.id)}/experiments`, credentials()))
463
+ .filter((e) => e['status'] === 'abandoned')
464
+ .map((e) => e['id']));
465
+ rows = rows.filter((row) => !abandoned.has(row['experimentId']));
466
+ }
467
+ }
468
+ rows = filterRows(kind, rows, options.where.map(parseCondition));
469
+ const fields = options.fields
470
+ ? options.fields
471
+ .split(',')
472
+ .map((field) => field.trim())
473
+ .filter(Boolean)
474
+ : [];
475
+ if (fields.length)
476
+ rows = projectRows(kind, rows, fields);
477
+ return options.format === 'tsv' ? console.log(toTsv(rows, fields)) : output(rows);
478
+ });
479
+ if (kind === 'runs')
480
+ group
481
+ .command('record')
482
+ .argument('<ref>')
483
+ .option('--campaign <ref>', 'Defaults to the local workspace campaign')
484
+ .requiredOption('--json <json|->', 'Object with observations and/or a status; optional errorSummary, extras, extrasMode (replace|merge), comparisonContext, allowIncompleteComparisonContext, artifacts, startedAt, finishedAt, revision')
485
+ .description('Record an outcome in one call: the server reads the current revision, and a result may be recorded directly from planned')
486
+ .action(async (reference, options) => send(`/runs/${encodeURIComponent(await resolveRef(reference, options.campaign))}/record`, 'POST', input(options.json)));
123
487
  for (const operation of ['get', 'context'])
124
- group.command(operation).argument('<ref>').option('--campaign <ref>', 'Defaults to the local workspace campaign').description('Record by stub or ID with live parent context' + (kind === 'runs' ? ' and creation-time snapshot' : ' and child runs')).action(async (reference, options) => send(`/${kind}/${encodeURIComponent(await resolveRef(reference, options.campaign))}`));
125
- const create = group.command('create').option('--campaign <ref>', 'Campaign stub or ID; defaults to the local workspace campaign');
488
+ group
489
+ .command(operation)
490
+ .argument('<ref>')
491
+ .option('--campaign <ref>', 'Defaults to the local workspace campaign')
492
+ .description('Record by stub or ID with live parent context' +
493
+ (kind === 'runs' ? ' and creation-time snapshot' : ' and child runs'))
494
+ .action(async (reference, options) => send(`/${kind}/${encodeURIComponent(await resolveRef(reference, options.campaign))}`));
495
+ const create = group
496
+ .command('create')
497
+ .option('--campaign <ref>', 'Campaign stub or ID; defaults to the local workspace campaign');
126
498
  if (kind === 'runs')
127
499
  create.option('--experiment <ref>', 'Experiment stub or ID in campaign scope; cannot combine with JSON experimentId');
128
- create.description(kind === 'runs' ? 'Create a planned run record; this does not execute a job' : 'Create an experiment with a hypothesis and optional schema version').requiredOption('--json <json|->', kind === 'runs' ? 'Object requiring title; optional config, observations, comparisonContext, extras; - reads stdin' : 'Object requiring name and hypothesis; optional parameters, schemaVersionId, varying, comparisonContext, extras; - reads stdin')
500
+ create
501
+ .description(kind === 'runs'
502
+ ? 'Create a run record, optionally already terminal with its results; this does not execute a job'
503
+ : 'Create an experiment with a hypothesis and optional schema version')
504
+ .requiredOption('--json <json|->', kind === 'runs'
505
+ ? 'Object requiring title; optional stub, status, config, observations, comparisonContext, extras, startedAt, finishedAt, errorSummary; - reads stdin'
506
+ : 'Object requiring name and hypothesis; optional parameters, schemaVersionId, varying, comparisonContext, extras; - reads stdin')
129
507
  .action(async (options) => {
130
508
  const payload = input(options.json);
131
509
  if (!payload || typeof payload !== 'object' || Array.isArray(payload))
132
- fail2('JSON input must be an object');
133
- const campaign = await new Scope(url()).campaign(options.campaign);
510
+ fail('JSON input must be an object');
511
+ const campaign = await apiScope().campaign(options.campaign);
134
512
  if (options.experiment) {
135
513
  if (kind !== 'runs')
136
- fail2('--experiment is only supported for runs');
514
+ fail('--experiment is only supported for runs');
137
515
  if ('experimentId' in payload)
138
- fail2('Provide the experiment via --experiment or experimentId, not both');
139
- payload.experimentId = await new Scope(url()).work('experiments', options.experiment, campaign.id);
516
+ fail('Provide the experiment via --experiment or experimentId, not both');
517
+ payload.experimentId = await apiScope().work('experiments', options.experiment, campaign.id);
140
518
  }
141
519
  return send(`${path(campaign.id)}/${kind}`, 'POST', payload);
142
520
  });
143
- group.command('update').argument('<ref>').option('--campaign <ref>', 'Defaults to the local workspace campaign').requiredOption('--json <json|->', 'Patch with expected revision')
521
+ group
522
+ .command('update')
523
+ .argument('<ref>')
524
+ .option('--campaign <ref>', 'Defaults to the local workspace campaign')
525
+ .requiredOption('--json <json|->', 'Patch with expected revision')
144
526
  .action(async (reference, options) => send(`/${kind}/${encodeURIComponent(await resolveRef(reference, options.campaign))}`, 'PATCH', input(options.json)));
145
- group.command('delete').argument('<ref>').option('--campaign <ref>', 'Defaults to the local workspace campaign').requiredOption('--revision <number>', 'Expected revision; executed runs are retained')
527
+ group
528
+ .command('delete')
529
+ .argument('<ref>')
530
+ .option('--campaign <ref>', 'Defaults to the local workspace campaign')
531
+ .requiredOption('--revision <number>', 'Expected revision; executed runs are retained')
146
532
  .action(async (reference, options) => {
147
533
  const revision = Number(options.revision);
148
534
  if (!Number.isSafeInteger(revision) || revision < 1)
@@ -155,21 +541,48 @@ for (const group of program.commands) {
155
541
  for (const command of group.commands) {
156
542
  if (!command.description())
157
543
  command.description(`${command.name()} ${group.name()} records`);
158
- command.addHelpText('after', '\nServer: --url overrides FIELDWORK_URL, LAB_URL, workspace config, then http://127.0.0.1:4310.\nUse --json - for stdin where supported. IDs and stubs are accepted for records;\nschemas require immutable version IDs. Commands never launch or stop jobs.');
544
+ command.addHelpText('after', `\nServer: --url overrides FIELDWORK_URL, LAB_URL, workspace config, then ${DEFAULT_SERVER}.\nA local server needs --url http://127.0.0.1:4310 or FIELDWORK_URL.\nUse --json - for stdin where supported. IDs and stubs are accepted for records;\nschemas require immutable version IDs. Commands never launch or stop jobs.`);
545
+ if (['experiments', 'runs'].includes(group.name()) &&
546
+ ['create', 'update'].includes(command.name()))
547
+ command.addHelpText('after', '\ncomparisonContext, extras, and ' +
548
+ (group.name() === 'runs' ? 'config and observations' : 'parameters') +
549
+ ' must be JSON objects, not strings.');
550
+ if (group.name() === 'experiments' && ['create', 'update'].includes(command.name()))
551
+ command.addHelpText('after', '\nExperiment text limits: name 120 characters; hypothesis, objective, method,\nand conclusion (update only) 4000 characters each. Keep longer procedures\nin a referenced source file; include the exact extraction command in method.');
159
552
  if (command.name() === 'update')
160
553
  command.addHelpText('after', '\nUpdates require the current revision in JSON. Re-read on a conflict; do not\nblindly increment it. Config/parameter/observation objects replace the whole field.');
161
554
  }
162
555
  }
163
- campaigns.commands.find((c) => c.name() === 'create').addHelpText('after', '\nExample: fieldwork campaigns create --product model-a --json \'{"name":"Memory study","goal":"Fit in 24 GiB"}\'\nOmit --product and JSON productId for an independent campaign.');
164
- schemas.commands.find((c) => c.name() === 'publish').addHelpText('after', '\nExample definition:\n {"stub":"study","version":1,"definition":{"parameters":{"bits":{"type":"integer","values":[4,8],"required":true}}}}\nField names use snake_case. Sections: parameters, observations, comparisonContext.\nTypes: number, integer, string, boolean, enum. Published versions cannot be changed.');
165
- schemas.commands.find((c) => c.name() === 'validate').addHelpText('after', '\nReads parameters (not run config), observations and comparisonContext.\nNo records are saved. Invalid reports: stdout JSON + stderr VALIDATION_FAILED, exit 1.\nValid reports, including missing-observation warnings: exit 0. --ready checks\nrequired execution fields, but does not start a run or check experiment varying fields.');
556
+ campaigns.commands
557
+ .find((c) => c.name() === 'create')
558
+ .addHelpText('after', '\nExample: fieldwork campaigns create --product model-a --json \'{"name":"Memory study","goal":"Fit in 24 GiB"}\'\nOmit --product and JSON productId for an independent campaign.');
559
+ schemas.commands
560
+ .find((c) => c.name() === 'publish')
561
+ .addHelpText('after', '\nExample definition:\n {"stub":"study","version":1,"definition":{"parameters":{"bits":{"type":"integer","values":[4,8],"required":true}}}}\nField names use snake_case. Sections: parameters, observations, comparisonContext.\nTypes: number, integer, string, boolean, enum. Published versions cannot be changed.');
562
+ schemas.commands
563
+ .find((c) => c.name() === 'validate')
564
+ .addHelpText('after', '\nReads parameters (not run config), observations and comparisonContext.\nNo records are saved. Invalid reports: stdout JSON + stderr VALIDATION_FAILED, exit 1.\nValid reports, including missing-observation warnings: exit 0. --ready checks\nrequired execution fields, but does not start a run or check experiment varying fields.');
166
565
  try {
167
566
  await program.parseAsync();
168
567
  }
169
568
  catch (error) {
170
569
  const failure = error;
171
570
  if (failure.exitCode !== 0) {
172
- console.error(JSON.stringify({ code: failure.code ?? 'CLIENT_ERROR', message: failure.message ?? 'Request failed', hint: failure.hint ?? (failure.code?.startsWith('commander.') ? 'Run fieldwork --help or fieldwork <group> <command> --help for supported commands, arguments and required options.' : failure.code === 'REVISION_CONFLICT' ? 'Get the latest record and reconcile your patch using its current revision; do not blindly retry.' : failure.message === 'fetch failed' ? 'Check the server is running and verify --url, FIELDWORK_URL and workspace configuration; inspect state before retrying a write.' : 'Inspect any details.issues for field paths and fixes; use the command’s --help and fieldwork context to verify input and scope.'), ...(failure.status ? { status: failure.status } : {}), ...(failure.details !== undefined ? { details: failure.details } : {}) }));
571
+ console.error(JSON.stringify({
572
+ code: failure.code ?? 'CLIENT_ERROR',
573
+ message: failure.message ?? 'Request failed',
574
+ hint: failure.hint ??
575
+ (failure.code?.startsWith('commander.')
576
+ ? 'Run fieldwork --help or fieldwork <group> <command> --help for supported commands, arguments and required options.'
577
+ : failure.code === 'REVISION_CONFLICT'
578
+ ? 'Get the latest record and reconcile your patch using its current revision; do not blindly retry.'
579
+ : failure.message === 'fetch failed'
580
+ ? 'Check the server is running and verify --url, FIELDWORK_URL and workspace configuration; inspect state before retrying a write.'
581
+ : 'Inspect any details.issues for field paths and fixes; use the command’s --help and fieldwork context to verify input and scope.'),
582
+ ...(failure.status ? { status: failure.status } : {}),
583
+ ...(failure.requestId ? { requestId: failure.requestId } : {}),
584
+ ...(failure.details !== undefined ? { details: failure.details } : {}),
585
+ }));
173
586
  process.exitCode = 1;
174
587
  }
175
588
  }