@agentionai/fieldwork-cli 0.4.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/select.js ADDED
@@ -0,0 +1,124 @@
1
+ const operators = ['!=', '>=', '<=', '=', '>', '<'];
2
+ const sectionNames = [
3
+ 'parameters',
4
+ 'observations',
5
+ 'comparisonContext',
6
+ 'extras',
7
+ 'inputRefs',
8
+ 'environment',
9
+ ];
10
+ const searched = ['parameters', 'observations', 'comparisonContext', 'extras'];
11
+ export function fail(message, hint) {
12
+ throw Object.assign(new Error(message), { code: 'INVALID_SELECTION', hint });
13
+ }
14
+ function object(value) {
15
+ return value && typeof value === 'object' && !Array.isArray(value)
16
+ ? value
17
+ : {};
18
+ }
19
+ // Schema sections are named the same everywhere; a run stores its parameters under `config`. Servers
20
+ // older than the top-level echo only nest the other sections under `research`.
21
+ function section(kind, row, name) {
22
+ if (name === 'parameters' && kind === 'runs')
23
+ return object(row['config']);
24
+ return object(row[name] === undefined ? object(row['research'])[name] : row[name]);
25
+ }
26
+ function walk(value, path) {
27
+ return path.reduce((current, key) => current && typeof current === 'object'
28
+ ? current[key]
29
+ : undefined, value);
30
+ }
31
+ export function resolve(kind, row, path) {
32
+ const parts = path.split('.');
33
+ const head = parts[0];
34
+ if (parts.length > 1 && sectionNames.includes(head))
35
+ return walk(section(kind, row, head), parts.slice(1));
36
+ if (Object.hasOwn(row, head))
37
+ return walk(row, parts);
38
+ const research = object(row['research']);
39
+ if (Object.hasOwn(research, head))
40
+ return walk(research, parts);
41
+ if (parts.length > 1)
42
+ return undefined;
43
+ const found = searched.filter((name) => Object.hasOwn(section(kind, row, name), head));
44
+ if (found.length > 1)
45
+ fail(`Ambiguous field '${head}': ${found.map((name) => `${name}.${head}`).join(', ')}`, 'Qualify the field with its section, for example parameters.variant.');
46
+ return found.length ? section(kind, row, found[0])[head] : undefined;
47
+ }
48
+ export function parseCondition(expression) {
49
+ for (let index = 0; index < expression.length; index += 1) {
50
+ const operator = operators.find((candidate) => expression.startsWith(candidate, index));
51
+ if (!operator)
52
+ continue;
53
+ const path = expression.slice(0, index).trim();
54
+ if (!path)
55
+ break;
56
+ return { path, operator, operand: expression.slice(index + operator.length).trim() };
57
+ }
58
+ return fail(`Cannot read --where '${expression}'`, "Use FIELD=VALUE, or one of != >= <= > <, for example --where 'parameters.n>=4'.");
59
+ }
60
+ function equals(value, operand) {
61
+ if (value === undefined)
62
+ return operand === '';
63
+ if (typeof value === 'number')
64
+ return Number(operand) === value && operand.trim() !== '';
65
+ if (typeof value === 'boolean')
66
+ return String(value) === operand;
67
+ if (typeof value === 'string')
68
+ return value === operand;
69
+ if (value === null)
70
+ return operand === 'null';
71
+ return JSON.stringify(value) === operand;
72
+ }
73
+ export function matches(value, condition) {
74
+ if (condition.operator === '=')
75
+ return equals(value, condition.operand);
76
+ if (condition.operator === '!=')
77
+ return !equals(value, condition.operand);
78
+ if (value === undefined || value === null)
79
+ return false;
80
+ const numeric = typeof value === 'number' &&
81
+ condition.operand.trim() !== '' &&
82
+ Number.isFinite(Number(condition.operand));
83
+ const [left, right] = numeric
84
+ ? [value, Number(condition.operand)]
85
+ : [String(value), condition.operand];
86
+ return condition.operator === '>'
87
+ ? left > right
88
+ : condition.operator === '>='
89
+ ? left >= right
90
+ : condition.operator === '<'
91
+ ? left < right
92
+ : left <= right;
93
+ }
94
+ // A path that resolves nowhere in a nonempty result is a typo, not an empty column: say so instead of
95
+ // printing a silent blank that a reader would take for a measured absence.
96
+ function requirePresent(kind, rows, paths, flag) {
97
+ for (const path of paths) {
98
+ if (rows.length && rows.every((row) => resolve(kind, row, path) === undefined))
99
+ fail(`No record has ${flag} field '${path}'`, 'Check the spelling and section, or list one record in full to see the available fields.');
100
+ }
101
+ }
102
+ export function filterRows(kind, rows, conditions) {
103
+ requirePresent(kind, rows, conditions.map((condition) => condition.path), '--where');
104
+ return rows.filter((row) => conditions.every((condition) => matches(resolve(kind, row, condition.path), condition)));
105
+ }
106
+ export function projectRows(kind, rows, fields) {
107
+ requirePresent(kind, rows, fields, '--fields');
108
+ return rows.map((row) => Object.fromEntries(fields.map((path) => [path, resolve(kind, row, path)])));
109
+ }
110
+ function cell(value) {
111
+ if (value === undefined)
112
+ return '';
113
+ const text = typeof value === 'string' ? value : (JSON.stringify(value) ?? '');
114
+ return text.replace(/\\/g, '\\\\').replace(/\t/g, '\\t').replace(/\r?\n/g, '\\n');
115
+ }
116
+ export function toTsv(rows, fields) {
117
+ const columns = fields.length
118
+ ? [...fields]
119
+ : [...new Set(rows.flatMap((row) => Object.keys(row)))];
120
+ return [
121
+ columns.join('\t'),
122
+ ...rows.map((row) => columns.map((column) => cell(row[column])).join('\t')),
123
+ ].join('\n');
124
+ }
package/dist/workspace.js CHANGED
@@ -1,10 +1,17 @@
1
- import { lstatSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, unlinkSync, rmdirSync } from 'node:fs';
1
+ import { lstatSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, unlinkSync, rmdirSync, } from 'node:fs';
2
2
  import { dirname, join, resolve } from 'node:path';
3
- import { request } from './client.js';
4
- export function fail(message) { throw new Error(message); }
3
+ import { request, requestAll } from './client.js';
4
+ export function fail(message) {
5
+ throw new Error(message);
6
+ }
5
7
  export function origin(value) {
6
8
  const url = new URL(value);
7
- if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || url.pathname !== '/')
9
+ if (!['http:', 'https:'].includes(url.protocol) ||
10
+ url.username ||
11
+ url.password ||
12
+ url.search ||
13
+ url.hash ||
14
+ url.pathname !== '/')
8
15
  fail('Server URL must be an HTTP(S) origin without credentials or a resource path');
9
16
  return url.origin;
10
17
  }
@@ -22,7 +29,10 @@ function ref(value) {
22
29
  if (!value || typeof value !== 'object')
23
30
  return false;
24
31
  const r = value;
25
- return typeof r.id === 'string' && /^[0-9a-f-]{36}$/i.test(r.id) && typeof r.stub === 'string' && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(r.stub);
32
+ return (typeof r.id === 'string' &&
33
+ /^[0-9a-f-]{36}$/i.test(r.id) &&
34
+ typeof r.stub === 'string' &&
35
+ /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(r.stub));
26
36
  }
27
37
  function configDirectory(root) {
28
38
  if (stat(join(root, '.fieldwork')))
@@ -41,7 +51,11 @@ export function readWorkspace(root) {
41
51
  if (!info.isFile() || info.isSymbolicLink())
42
52
  fail('Workspace config must be a regular file');
43
53
  const data = JSON.parse(readFileSync(path, 'utf8'));
44
- if (!data || data.version !== 1 || !ref(data.campaign) || !(data.product === null || ref(data.product)) || typeof data.serverUrl !== 'string')
54
+ if (!data ||
55
+ data.version !== 1 ||
56
+ !ref(data.campaign) ||
57
+ !(data.product === null || ref(data.product)) ||
58
+ typeof data.serverUrl !== 'string')
45
59
  fail('Invalid workspace config');
46
60
  origin(data.serverUrl);
47
61
  return data;
@@ -58,8 +72,10 @@ export function discover(cwd = process.cwd()) {
58
72
  root = parent;
59
73
  }
60
74
  }
61
- export async function records(url, path) {
62
- const data = await request(url, path);
75
+ export async function records(url, path, credentials = {}) {
76
+ // Paged or not: a collection that answers with a page is walked, because resolving a
77
+ // stub against the first fifty rows would fail on the fifty-first record someone made.
78
+ const data = await requestAll(url, path, credentials);
63
79
  if (!Array.isArray(data) || !data.every(ref))
64
80
  fail('Invalid record collection returned by server');
65
81
  return data;
@@ -70,7 +86,7 @@ export function select(items, reference) {
70
86
  fail(matches.length ? `Ambiguous reference: ${reference}` : `Record not found: ${reference}`);
71
87
  return matches[0];
72
88
  }
73
- export async function setupCampaign(url, options, cwd = process.cwd()) {
89
+ export async function setupCampaign(url, options, cwd = process.cwd(), credentials = {}) {
74
90
  url = origin(url);
75
91
  const root = resolve(cwd);
76
92
  const directory = configDirectory(root);
@@ -104,9 +120,9 @@ export async function setupCampaign(url, options, cwd = process.cwd()) {
104
120
  fail('Already inside a campaign workspace; setup must run at its root');
105
121
  if (existing && existing.serverUrl !== url)
106
122
  fail('Workspace is bound to another server; refusing to rebind');
107
- const products = await records(url, '/products');
123
+ const products = await records(url, '/products', credentials);
108
124
  const product = options.product ? select(products, options.product) : undefined;
109
- const campaigns = await records(url, '/campaigns');
125
+ const campaigns = await records(url, '/campaigns', credentials);
110
126
  let campaign;
111
127
  if (existing && [existing.campaign.id, existing.campaign.stub].includes(options.campaign)) {
112
128
  campaign = select(campaigns, existing.campaign.id);
@@ -120,7 +136,12 @@ export async function setupCampaign(url, options, cwd = process.cwd()) {
120
136
  fail('--create requires --goal');
121
137
  if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(options.campaign))
122
138
  fail('A new campaign requires a valid stub');
123
- campaign = await request(url, '/campaigns', 'POST', { name: options.campaign, stub: options.campaign, goal: options.goal, ...(product ? { productId: product.id } : {}) });
139
+ campaign = (await request(url, '/campaigns', 'POST', {
140
+ name: options.campaign,
141
+ stub: options.campaign,
142
+ goal: options.goal,
143
+ ...(product ? { productId: product.id } : {}),
144
+ }, credentials));
124
145
  if (!ref(campaign))
125
146
  fail('Invalid campaign returned by server');
126
147
  }
@@ -130,8 +151,14 @@ export async function setupCampaign(url, options, cwd = process.cwd()) {
130
151
  if (product && campaign.productId !== product.id)
131
152
  fail('Campaign does not belong to the selected product');
132
153
  const parent = campaign.productId ? select(products, campaign.productId) : null;
133
- const config = { version: 1, serverUrl: url, product: parent ? { id: parent.id, stub: parent.stub } : null, campaign: { id: campaign.id, stub: campaign.stub } };
134
- if (existing && (existing.campaign.id !== campaign.id || existing.product?.id !== config.product?.id))
154
+ const config = {
155
+ version: 1,
156
+ serverUrl: url,
157
+ product: parent ? { id: parent.id, stub: parent.stub } : null,
158
+ campaign: { id: campaign.id, stub: campaign.stub },
159
+ };
160
+ if (existing &&
161
+ (existing.campaign.id !== campaign.id || existing.product?.id !== config.product?.id))
135
162
  fail('Workspace is already bound; refusing to rebind');
136
163
  const createdDirs = [];
137
164
  const createdFiles = [];
@@ -185,18 +212,36 @@ export async function setupCampaign(url, options, cwd = process.cwd()) {
185
212
  try {
186
213
  rmdirSync(path);
187
214
  }
188
- catch { /* Leave directories containing concurrent user files. */ }
215
+ catch {
216
+ /* Leave directories containing concurrent user files. */
217
+ }
189
218
  }
190
219
  throw error;
191
220
  }
192
221
  return { root, config: existing ?? config, created: !existing };
193
222
  }
223
+ /** Resolving a stub or an id to a record, against one server.
224
+ *
225
+ * Every lookup here sends the caller's credentials. It is the same collection the command
226
+ * itself would read, so omitting them made every ref-taking command -- update, delete,
227
+ * record, get -- fail with 401 against a server that authenticates, while the same
228
+ * credential worked for a plain list.
229
+ */
230
+ /** The hosted service. A local server is the exception now, and says so with --url,
231
+ * FIELDWORK_URL, or the workspace it was set up against. */
232
+ export const DEFAULT_SERVER = 'https://app.fieldworkledger.com';
194
233
  export class Scope {
195
234
  workspace;
196
235
  url;
197
- constructor(url, cwd = process.cwd()) {
236
+ credentials;
237
+ constructor(url, cwd = process.cwd(), credentials = {}) {
238
+ this.credentials = credentials;
198
239
  this.workspace = discover(cwd);
199
- this.url = origin(url ?? process.env['FIELDWORK_URL'] ?? process.env['LAB_URL'] ?? this.workspace?.config.serverUrl ?? 'http://127.0.0.1:4310');
240
+ this.url = origin(url ??
241
+ process.env['FIELDWORK_URL'] ??
242
+ process.env['LAB_URL'] ??
243
+ this.workspace?.config.serverUrl ??
244
+ DEFAULT_SERVER);
200
245
  }
201
246
  local() {
202
247
  if (this.workspace && this.url !== this.workspace.config.serverUrl)
@@ -204,13 +249,13 @@ export class Scope {
204
249
  return this.workspace?.config;
205
250
  }
206
251
  async product(reference) {
207
- return select(await records(this.url, '/products'), reference);
252
+ return select(await records(this.url, '/products', this.credentials), reference);
208
253
  }
209
254
  async campaign(reference) {
210
255
  const target = reference ?? this.local()?.campaign.id;
211
256
  if (!target)
212
257
  fail('Supply --campaign or run setup campaign first');
213
- const campaign = select(await records(this.url, '/campaigns'), target);
258
+ const campaign = select(await records(this.url, '/campaigns', this.credentials), target);
214
259
  if (!reference && campaign.productId !== this.local()?.product?.id)
215
260
  fail('Campaign parent changed; inspect workspace binding');
216
261
  return campaign;
@@ -219,10 +264,15 @@ export class Scope {
219
264
  if (!campaignRef && !this.workspace && /^[0-9a-f-]{36}$/i.test(reference))
220
265
  return reference;
221
266
  const campaign = await this.campaign(campaignRef);
222
- return select(await records(this.url, `/campaigns/${campaign.id}/${kind}`), reference).id;
267
+ return select(await records(this.url, `/campaigns/${campaign.id}/${kind}`, this.credentials), reference).id;
223
268
  }
224
269
  async context(reference) {
225
270
  const campaign = await this.campaign(reference);
226
- return { root: this.workspace?.root, serverUrl: this.url, campaign, detail: await request(this.url, `/campaigns/${campaign.id}`) };
271
+ return {
272
+ root: this.workspace?.root,
273
+ serverUrl: this.url,
274
+ campaign,
275
+ detail: await request(this.url, `/campaigns/${campaign.id}`, 'GET', undefined, this.credentials),
276
+ };
227
277
  }
228
278
  }
package/package.json CHANGED
@@ -1,17 +1,37 @@
1
1
  {
2
2
  "name": "@agentionai/fieldwork-cli",
3
- "version": "0.4.0",
3
+ "version": "0.7.0",
4
4
  "description": "Agention Fieldwork CLI for research campaigns, experiments and run records",
5
- "files": ["dist/main.js", "dist/client.js", "dist/workspace.js", "dist/fieldwork-skill.md"],
6
- "engines": { "node": ">=22" },
7
- "publishConfig": { "access": "public" },
5
+ "files": [
6
+ "dist/main.js",
7
+ "dist/client.js",
8
+ "dist/credentials.js",
9
+ "dist/workspace.js",
10
+ "dist/select.js",
11
+ "dist/changelog.js",
12
+ "dist/fieldwork-skill.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=22"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
8
20
  "type": "module",
9
- "bin": { "fieldwork": "dist/main.js" },
21
+ "bin": {
22
+ "fieldwork": "dist/main.js"
23
+ },
10
24
  "scripts": {
11
25
  "build": "tsc -b tsconfig.json --force && node scripts/copy-skill.mjs",
12
26
  "prepack": "npm run build",
13
27
  "dev": "tsx src/main.ts"
14
28
  },
15
- "dependencies": { "commander": "^13.1.0" },
16
- "devDependencies": { "tsx": "^4.19.2" }
29
+ "dependencies": {
30
+ "commander": "^13.1.0"
31
+ },
32
+ "devDependencies": {
33
+ "@types/better-sqlite3": "^7.6.12",
34
+ "better-sqlite3": "^11.7.0",
35
+ "tsx": "^4.19.2"
36
+ }
17
37
  }