@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/README.md +51 -17
- package/dist/changelog.js +137 -0
- package/dist/client.js +80 -7
- package/dist/credentials.js +78 -0
- package/dist/fieldwork-skill.md +121 -21
- package/dist/main.js +479 -66
- package/dist/select.js +124 -0
- package/dist/workspace.js +71 -21
- package/package.json +27 -7
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) {
|
|
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) ||
|
|
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' &&
|
|
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 ||
|
|
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
|
-
|
|
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', {
|
|
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 = {
|
|
134
|
-
|
|
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 {
|
|
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
|
-
|
|
236
|
+
credentials;
|
|
237
|
+
constructor(url, cwd = process.cwd(), credentials = {}) {
|
|
238
|
+
this.credentials = credentials;
|
|
198
239
|
this.workspace = discover(cwd);
|
|
199
|
-
this.url = origin(url ??
|
|
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}
|
|
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 {
|
|
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.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Agention Fieldwork CLI for research campaigns, experiments and run records",
|
|
5
|
-
"files": [
|
|
6
|
-
|
|
7
|
-
|
|
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": {
|
|
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": {
|
|
16
|
-
|
|
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
|
}
|