@empirical-gauzza/cli 1.0.38 → 1.0.40

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.
@@ -1,112 +1,112 @@
1
- 'use strict';
2
-
3
- // Resolving "which memory did the user mean" from a --match query, shared by every memory
4
- // subcommand that accepts --match. It lives in its own module so attachmentCommands.js can use
5
- // it without a require cycle back through memoryCommands.js; memoryCommands.js re-exports the
6
- // three names so existing importers and tests are unaffected.
7
-
8
- const { warn } = require('./ui');
9
- const { httpJson } = require('./http');
10
- const { parseOption } = require('./env');
11
- const { buildApiAuthHeaders } = require('./auth');
12
-
13
- async function resolveMemoryByQuery(match, minScore, accessToken, baseUrl, filters) {
14
- const queryUrl = new URL('/api/v1/memories/query', baseUrl).toString();
15
- const body = { query: match, topK: 5 };
16
- if (filters && Object.keys(filters).length) body.filters = filters;
17
- let response;
18
- try {
19
- response = await httpJson(queryUrl, 'POST', body, buildApiAuthHeaders(accessToken));
20
- } catch (error) {
21
- return { found: false, reason: 'error', message: error.message };
22
- }
23
-
24
- const results = (response && response.data && Array.isArray(response.data.results))
25
- ? response.data.results
26
- : (Array.isArray(response) ? response : []);
27
-
28
- if (!results.length) {
29
- return { found: false, reason: 'no_results' };
30
- }
31
-
32
- const scored = results.map((r) => ({
33
- id: r.id,
34
- summary: r.summary,
35
- score: r.rankScore != null ? r.rankScore : (r.similarityScore != null ? r.similarityScore : 0)
36
- }));
37
-
38
- scored.sort((a, b) => b.score - a.score);
39
-
40
- // A caller-supplied filter (e.g. --tags) that narrows the server-side result set to exactly
41
- // one memory is already a stronger disambiguation signal than the semantic score - trust it
42
- // outright rather than falling through to the score check below. This matters because Atlas
43
- // vector search has a real async indexing lag: a memory created moments ago can come back
44
- // with score 0 (unindexed, not "not this one") even when a tag filter already proves it is
45
- // the only candidate. Fixes a real bug found 2026-08-25 where delete --match failed on a
46
- // memory `query` found instantly, because delete had no way to trust a filtered, unambiguous
47
- // result over a stale/zero semantic score.
48
- if (filters && Object.keys(filters).length && results.length === 1) {
49
- const only = scored[0];
50
- return { found: true, memory: { id: only.id, summary: only.summary, score: only.score } };
51
- }
52
-
53
- const top = scored[0];
54
- const second = scored[1];
55
-
56
- if (top.score < minScore) {
57
- return { found: false, reason: 'low_score', candidates: scored.slice(0, 3) };
58
- }
59
-
60
- if (second && (top.score - second.score) <= 0.05) {
61
- return { found: false, reason: 'ambiguous', candidates: scored.slice(0, 3) };
62
- }
63
-
64
- return { found: true, memory: { id: top.id, summary: top.summary, score: top.score } };
65
- }
66
-
67
- // --match resolution depends on Atlas vector search, which has a real async indexing lag:
68
- // a memory created moments ago can be invisible to $vectorSearch even though it is already
69
- // findable by tag. Letting --match commands pass --tags narrows the candidate pool directly
70
- // instead of hoping semantic ranking surfaces the right one - the fix for a real bug found
71
- // 2026-08-25 where delete --match returned unrelated score-0 candidates for a memory that
72
- // `query` found instantly. Deliberately --tags (plural) only, not --category: `delete` already
73
- // uses singular --tag/--category to mean "bulk delete everything in this category", so adding
74
- // --category here would silently reroute a --match delete into a bulk delete instead of
75
- // narrowing it - --tags never collides with that bulk-mode gate.
76
- function parseMatchFilters(args) {
77
- const tagsRaw = parseOption(args, '--tags', '');
78
- const tags = tagsRaw ? tagsRaw.split(',').map((t) => t.trim()).filter(Boolean) : [];
79
- const filters = {};
80
- if (tags.length) filters.tags = tags;
81
- return filters;
82
- }
83
-
84
- function printMemoryResolveError(result) {
85
- if (result.reason === 'no_results') {
86
- warn(JSON.stringify({ error: true, code: 'no_results', message: 'No memories matched the query.' }));
87
- } else if (result.reason === 'low_score') {
88
- warn(JSON.stringify({
89
- error: true,
90
- code: 'low_score',
91
- message: 'Top result score was below --min-score threshold.',
92
- candidates: result.candidates
93
- }));
94
- } else if (result.reason === 'ambiguous') {
95
- warn(JSON.stringify({
96
- error: true,
97
- code: 'ambiguous',
98
- message: 'Multiple memories matched with similar scores. Use --memory-id to target one directly.',
99
- candidates: result.candidates
100
- }));
101
- } else if (result.reason === 'error') {
102
- warn(JSON.stringify({ error: true, code: 'query_error', message: result.message }));
103
- } else {
104
- warn(JSON.stringify({ error: true, code: 'resolve_failed', message: 'Could not resolve memory.' }));
105
- }
106
- }
107
-
108
- module.exports = {
109
- resolveMemoryByQuery,
110
- parseMatchFilters,
111
- printMemoryResolveError
112
- };
1
+ 'use strict';
2
+
3
+ // Resolving "which memory did the user mean" from a --match query, shared by every memory
4
+ // subcommand that accepts --match. It lives in its own module so attachmentCommands.js can use
5
+ // it without a require cycle back through memoryCommands.js; memoryCommands.js re-exports the
6
+ // three names so existing importers and tests are unaffected.
7
+
8
+ const { warn } = require('./ui');
9
+ const { httpJson } = require('./http');
10
+ const { parseOption } = require('./env');
11
+ const { buildApiAuthHeaders } = require('./auth');
12
+
13
+ async function resolveMemoryByQuery(match, minScore, accessToken, baseUrl, filters) {
14
+ const queryUrl = new URL('/api/v1/memories/query', baseUrl).toString();
15
+ const body = { query: match, topK: 5 };
16
+ if (filters && Object.keys(filters).length) body.filters = filters;
17
+ let response;
18
+ try {
19
+ response = await httpJson(queryUrl, 'POST', body, buildApiAuthHeaders(accessToken));
20
+ } catch (error) {
21
+ return { found: false, reason: 'error', message: error.message };
22
+ }
23
+
24
+ const results = (response && response.data && Array.isArray(response.data.results))
25
+ ? response.data.results
26
+ : (Array.isArray(response) ? response : []);
27
+
28
+ if (!results.length) {
29
+ return { found: false, reason: 'no_results' };
30
+ }
31
+
32
+ const scored = results.map((r) => ({
33
+ id: r.id,
34
+ summary: r.summary,
35
+ score: r.rankScore != null ? r.rankScore : (r.similarityScore != null ? r.similarityScore : 0)
36
+ }));
37
+
38
+ scored.sort((a, b) => b.score - a.score);
39
+
40
+ // A caller-supplied filter (e.g. --tags) that narrows the server-side result set to exactly
41
+ // one memory is already a stronger disambiguation signal than the semantic score - trust it
42
+ // outright rather than falling through to the score check below. This matters because Atlas
43
+ // vector search has a real async indexing lag: a memory created moments ago can come back
44
+ // with score 0 (unindexed, not "not this one") even when a tag filter already proves it is
45
+ // the only candidate. Fixes a real bug found 2026-08-25 where delete --match failed on a
46
+ // memory `query` found instantly, because delete had no way to trust a filtered, unambiguous
47
+ // result over a stale/zero semantic score.
48
+ if (filters && Object.keys(filters).length && results.length === 1) {
49
+ const only = scored[0];
50
+ return { found: true, memory: { id: only.id, summary: only.summary, score: only.score } };
51
+ }
52
+
53
+ const top = scored[0];
54
+ const second = scored[1];
55
+
56
+ if (top.score < minScore) {
57
+ return { found: false, reason: 'low_score', candidates: scored.slice(0, 3) };
58
+ }
59
+
60
+ if (second && (top.score - second.score) <= 0.05) {
61
+ return { found: false, reason: 'ambiguous', candidates: scored.slice(0, 3) };
62
+ }
63
+
64
+ return { found: true, memory: { id: top.id, summary: top.summary, score: top.score } };
65
+ }
66
+
67
+ // --match resolution depends on Atlas vector search, which has a real async indexing lag:
68
+ // a memory created moments ago can be invisible to $vectorSearch even though it is already
69
+ // findable by tag. Letting --match commands pass --tags narrows the candidate pool directly
70
+ // instead of hoping semantic ranking surfaces the right one - the fix for a real bug found
71
+ // 2026-08-25 where delete --match returned unrelated score-0 candidates for a memory that
72
+ // `query` found instantly. Deliberately --tags (plural) only, not --category: `delete` already
73
+ // uses singular --tag/--category to mean "bulk delete everything in this category", so adding
74
+ // --category here would silently reroute a --match delete into a bulk delete instead of
75
+ // narrowing it - --tags never collides with that bulk-mode gate.
76
+ function parseMatchFilters(args) {
77
+ const tagsRaw = parseOption(args, '--tags', '');
78
+ const tags = tagsRaw ? tagsRaw.split(',').map((t) => t.trim()).filter(Boolean) : [];
79
+ const filters = {};
80
+ if (tags.length) filters.tags = tags;
81
+ return filters;
82
+ }
83
+
84
+ function printMemoryResolveError(result) {
85
+ if (result.reason === 'no_results') {
86
+ warn(JSON.stringify({ error: true, code: 'no_results', message: 'No memories matched the query.' }));
87
+ } else if (result.reason === 'low_score') {
88
+ warn(JSON.stringify({
89
+ error: true,
90
+ code: 'low_score',
91
+ message: 'Top result score was below --min-score threshold.',
92
+ candidates: result.candidates
93
+ }));
94
+ } else if (result.reason === 'ambiguous') {
95
+ warn(JSON.stringify({
96
+ error: true,
97
+ code: 'ambiguous',
98
+ message: 'Multiple memories matched with similar scores. Use --memory-id to target one directly.',
99
+ candidates: result.candidates
100
+ }));
101
+ } else if (result.reason === 'error') {
102
+ warn(JSON.stringify({ error: true, code: 'query_error', message: result.message }));
103
+ } else {
104
+ warn(JSON.stringify({ error: true, code: 'resolve_failed', message: 'Could not resolve memory.' }));
105
+ }
106
+ }
107
+
108
+ module.exports = {
109
+ resolveMemoryByQuery,
110
+ parseMatchFilters,
111
+ printMemoryResolveError
112
+ };
@@ -0,0 +1,313 @@
1
+ 'use strict';
2
+
3
+ // Editing a workspace's shape by hand, without the interview.
4
+ //
5
+ // empirical workspace shape show --workspace-id <id>
6
+ // empirical workspace shape set --workspace-id <id> --file <shape.json> [--apply]
7
+ // empirical workspace shape edit --workspace-id <id> --file <edit.json> [--apply]
8
+ // empirical workspace shape add-type --workspace-id <id> <label> [--statuses "A,B,C (done)"] ...
9
+ // empirical workspace shape add-status --workspace-id <id> <type-key> <label> [--done]
10
+ // empirical workspace shape add-field --workspace-id <id> <type-key> <label> --kind <kind> ...
11
+ //
12
+ // The interview is charged (100 Empirical Tokens, plus 25 per change to a draft) and takes nine
13
+ // questions, so adding a single status cost more than the status was worth. The server has had the
14
+ // door for this since POST/PATCH /api/v1/team-brains/:id/shape and /shape/preview; this file is the
15
+ // CLI side of it.
16
+ //
17
+ // Every change is a DRY RUN until --apply. A change goes to /shape/preview first, which validates it
18
+ // against the active shape and returns a confirmDigest; --apply sends that digest back, so an edit
19
+ // is only ever applied to the shape that was shown. The commands here only ADD (a type, a status, a
20
+ // field); removing needs a mapping for the stored values, which `edit --mapping-file` accepts.
21
+
22
+ const fs = require('fs');
23
+ const { log, warn } = require('./ui');
24
+ const { httpJson } = require('./http');
25
+ const { parseOption, parseFlag } = require('./env');
26
+
27
+ const SHAPE_ACTIONS = ['show', 'set', 'edit', 'add-type', 'add-status', 'add-field'];
28
+ const FIELD_KINDS = ['text', 'longText', 'number', 'date', 'select', 'multiSelect', 'person', 'link', 'url'];
29
+ const VALUE_OPTIONS = new Set([
30
+ '--workspace-id', '--file', '--mapping-file', '--purpose', '--plural', '--category', '--node-type',
31
+ '--icon', '--color', '--statuses', '--fields', '--nest-under', '--kind', '--options'
32
+ ]);
33
+
34
+ const SHAPE_USAGE = [
35
+ 'Usage: empirical workspace shape <show|set|edit|add-type|add-status|add-field> --workspace-id <id> ...',
36
+ ' show print the workspace\'s item types, statuses and fields',
37
+ ' set --file <shape.json> [--apply] give a workspace with no shape its first shape',
38
+ ' edit --file <edit.json> [--mapping-file <m.json>] [--apply]',
39
+ ' add, update or remove entries (see `empirical doc workspace`)',
40
+ ' add-type <label> [--statuses "A,B,C (done)"] [--fields "Email:text:required;Wave:number"]',
41
+ ' [--plural <label>] [--category <cat>] [--node-type <type>] [--icon <i>] [--color <c>]',
42
+ ' [--nest-under <type-key,...>] [--purpose <text>] (--purpose only when the workspace has no shape yet)',
43
+ ' add-status <type-key> <label> [--done]',
44
+ ' add-field <type-key> <label> [--kind text|longText|number|date|select|multiSelect|person|link|url]',
45
+ ' [--required] [--options "Yes|No|Not asked"]',
46
+ 'Changes are a dry run until you add --apply. Running the interview instead: `empirical workspace shape` with no action.'
47
+ ].join('\n');
48
+
49
+ function isManualShapeAction(args) {
50
+ return SHAPE_ACTIONS.includes(args[2]);
51
+ }
52
+
53
+ // Words after the action that are not options or option values, in order.
54
+ function positionals(args, startIndex = 3) {
55
+ const out = [];
56
+ for (let i = startIndex; i < args.length; i += 1) {
57
+ if (VALUE_OPTIONS.has(args[i])) i += 1;
58
+ else if (!String(args[i]).startsWith('--')) out.push(args[i]);
59
+ }
60
+ return out;
61
+ }
62
+
63
+ // "Not Yet Invited, Invited, Call Completed (done)" -> statuses in order; "(done)" marks the finished one.
64
+ function parseStatusList(text) {
65
+ return String(text || '')
66
+ .split(',')
67
+ .map((part) => part.trim())
68
+ .filter(Boolean)
69
+ .map((part) => {
70
+ const done = /\(\s*done\s*\)\s*$/i.test(part);
71
+ const label = part.replace(/\(\s*done\s*\)\s*$/i, '').trim();
72
+ return done ? { label, isDone: true } : { label };
73
+ });
74
+ }
75
+
76
+ // "Email:text:required; Open to a call:select:Yes|No|Not asked; Wave:number"
77
+ // label[:kind][:required][:option|option...] separated by semicolons (options may contain commas).
78
+ function parseFieldList(text) {
79
+ return String(text || '')
80
+ .split(';')
81
+ .map((part) => part.trim())
82
+ .filter(Boolean)
83
+ .map((part) => {
84
+ const [label, kindRaw, ...rest] = part.split(':').map((s) => s.trim());
85
+ const kind = kindRaw || 'text';
86
+ if (!FIELD_KINDS.includes(kind)) {
87
+ throw new Error(`Field "${label}" has kind "${kind}". Use one of: ${FIELD_KINDS.join(', ')}.`);
88
+ }
89
+ const field = { label, kind };
90
+ for (const piece of rest) {
91
+ if (/^required$/i.test(piece)) field.required = true;
92
+ else if (piece) field.options = piece.split('|').map((o) => o.trim()).filter(Boolean);
93
+ }
94
+ return field;
95
+ });
96
+ }
97
+
98
+ function buildTypeFromFlags(args, label) {
99
+ const type = {
100
+ label,
101
+ baseCategory: parseOption(args, '--category', 'project'),
102
+ baseNodeType: parseOption(args, '--node-type', 'entity')
103
+ };
104
+ const plural = parseOption(args, '--plural', '');
105
+ if (plural) type.labelPlural = plural;
106
+ for (const [flag, key] of [['--icon', 'icon'], ['--color', 'color']]) {
107
+ const value = parseOption(args, flag, '');
108
+ if (value) type[key] = value;
109
+ }
110
+ const nest = parseOption(args, '--nest-under', '');
111
+ if (nest) type.canNestUnder = nest.split(',').map((s) => s.trim()).filter(Boolean);
112
+ const statuses = parseStatusList(parseOption(args, '--statuses', ''));
113
+ if (statuses.length) type.statuses = statuses;
114
+ const fields = parseFieldList(parseOption(args, '--fields', ''));
115
+ if (fields.length) type.fields = fields;
116
+ return type;
117
+ }
118
+
119
+ function buildAddStatusEdit(typeKey, label, isDone) {
120
+ return { itemTypes: { update: [{ key: typeKey, statuses: { add: [isDone ? { label, isDone: true } : { label }] } }] } };
121
+ }
122
+
123
+ function buildAddFieldEdit(typeKey, field) {
124
+ return { itemTypes: { update: [{ key: typeKey, fields: { add: [field] } }] } };
125
+ }
126
+
127
+ function readJsonFile(path, what) {
128
+ let raw;
129
+ try {
130
+ raw = fs.readFileSync(path, 'utf8');
131
+ } catch (error) {
132
+ throw new Error(`Could not read ${what} ${path}: ${error.message}`);
133
+ }
134
+ try {
135
+ return JSON.parse(raw);
136
+ } catch (error) {
137
+ throw new Error(`${what} ${path} is not valid JSON: ${error.message}`);
138
+ }
139
+ }
140
+
141
+ // One readable block per item type. Statuses in order, (done) on the finished ones.
142
+ function formatShape(shape) {
143
+ if (!shape) return ['This workspace has no shape yet.'];
144
+ const lines = [];
145
+ if (shape.purpose) lines.push(`Purpose: ${shape.purpose}`);
146
+ if (shape.version !== undefined) lines.push(`Version ${shape.version}${shape.source ? ` (${shape.source})` : ''}`);
147
+ for (const type of shape.itemTypes || []) {
148
+ lines.push('');
149
+ lines.push(`${type.label} [${type.key}] saved as ${type.baseCategory}/${type.baseNodeType}`);
150
+ const statuses = (type.statuses || []).map((s) => `${s.label} [${s.key}]${s.isDone ? ' (done)' : ''}`);
151
+ lines.push(` statuses: ${statuses.length ? statuses.join(' > ') : 'none'}`);
152
+ for (const f of type.fields || []) {
153
+ const extra = [f.kind, f.required ? 'required' : null, f.options && f.options.length ? f.options.join('|') : null].filter(Boolean);
154
+ lines.push(` field ${f.label} [${f.key}]: ${extra.join(', ')}`);
155
+ }
156
+ if (type.canNestUnder && type.canNestUnder.length) lines.push(` nests under: ${type.canNestUnder.join(', ')}`);
157
+ }
158
+ if ((shape.policy || []).length) {
159
+ lines.push('');
160
+ lines.push('Rules:');
161
+ for (const p of shape.policy) lines.push(` ${p.mode || ''} ${p.key || ''}: ${p.text || ''}`.replace(/\s+/g, ' ').replace(/^ /, ' '));
162
+ }
163
+ return lines;
164
+ }
165
+
166
+ function shapeUrl(baseUrl, workspaceId, suffix = '') {
167
+ return new URL(`/api/v1/team-brains/${encodeURIComponent(workspaceId)}/shape${suffix}`, baseUrl).toString();
168
+ }
169
+
170
+ // An edit goes to /shape/preview, is shown, and only with --apply is sent to PATCH /shape with the
171
+ // digest the preview returned. Returns what happened so a script can act on it.
172
+ async function previewAndMaybeApply({ baseUrl, workspaceId, authHeaders, edit, mapping, apply, asJson }) {
173
+ const preview = (await httpJson(shapeUrl(baseUrl, workspaceId, '/preview'), 'POST', { edit, ...(mapping ? { mapping } : {}) }, authHeaders)).preview;
174
+ if (asJson && !apply) {
175
+ log(JSON.stringify({ applied: false, preview }));
176
+ } else if (!asJson) {
177
+ for (const line of preview.summary || []) log(line);
178
+ for (const line of preview.mappingSummary || []) log(line);
179
+ }
180
+
181
+ if ((preview.mappingProblems || []).length) {
182
+ for (const problem of preview.mappingProblems) warn(`Needs a decision: ${problem}`);
183
+ throw new Error('This change removes or renames something that has stored values. Say where they go with --mapping-file (see `empirical doc workspace`).');
184
+ }
185
+ if (!apply) {
186
+ if (!asJson) log('Dry run: nothing was changed. Add --apply to save this.');
187
+ return { applied: false, preview };
188
+ }
189
+
190
+ const saved = await httpJson(shapeUrl(baseUrl, workspaceId), 'PATCH', {
191
+ edit,
192
+ confirmDigest: preview.confirmDigest,
193
+ ...(mapping ? { mapping } : {})
194
+ }, authHeaders);
195
+ if (asJson) log(JSON.stringify({ applied: true, shape: saved.shape }));
196
+ else log(`Saved: the shape of ${workspaceId} is now version ${saved.shape && saved.shape.version}.`);
197
+ return { applied: true, preview, saved };
198
+ }
199
+
200
+ // Gives a workspace with no shape its first one. The server refuses if it already has one.
201
+ async function createShape({ baseUrl, workspaceId, authHeaders, shape, apply, asJson }) {
202
+ if (!apply) {
203
+ if (asJson) log(JSON.stringify({ applied: false, shape }));
204
+ else {
205
+ log(`Purpose: ${shape.purpose}`);
206
+ for (const t of shape.itemTypes || []) {
207
+ log(`${t.label} saved as ${t.baseCategory}/${t.baseNodeType}`);
208
+ log(` statuses: ${(t.statuses || []).map((st) => st.label + (st.isDone ? ' (done)' : '')).join(' > ') || 'none'}`);
209
+ for (const f of t.fields || []) log(` field ${f.label}: ${[f.kind, f.required ? 'required' : null, (f.options || []).join('|') || null].filter(Boolean).join(', ')}`);
210
+ }
211
+ log('');
212
+ log('Dry run: nothing was created. Add --apply to give this workspace that shape.');
213
+ }
214
+ return { applied: false };
215
+ }
216
+ const created = await httpJson(shapeUrl(baseUrl, workspaceId), 'POST', { shape }, authHeaders);
217
+ if (asJson) log(JSON.stringify({ applied: true, shape: created.shape }));
218
+ else log(`Created: ${workspaceId} now has a shape (version ${created.shape && created.shape.version}).`);
219
+ return { applied: true, created };
220
+ }
221
+
222
+ function requireWorkspaceId(args, { needsExplicit }) {
223
+ const id = parseOption(args, '--workspace-id', '').trim();
224
+ if (!id && needsExplicit) {
225
+ throw new Error('Saving a shape change needs --workspace-id <id>, so a stale directory binding cannot aim it at the wrong workspace.');
226
+ }
227
+ return id;
228
+ }
229
+
230
+ // The entry point. `resolveBound` returns the bound workspace id (or '') for read-only and dry-run use.
231
+ async function runManualShapeCommand({ args, baseUrl, authHeaders, resolveBound = () => '' }) {
232
+ const action = args[2];
233
+ const apply = parseFlag(args, '--apply');
234
+ const asJson = parseFlag(args, '--json');
235
+ const workspaceId = requireWorkspaceId(args, { needsExplicit: apply }) || resolveBound();
236
+ if (!workspaceId) throw new Error(`${SHAPE_USAGE}\n\nNo workspace: pass --workspace-id <id>.`);
237
+ if (!apply && !parseOption(args, '--workspace-id', '')) warn(`Using the workspace bound to this directory: ${workspaceId}`);
238
+
239
+ const current = (await httpJson(shapeUrl(baseUrl, workspaceId), 'GET', null, authHeaders)).shape || null;
240
+
241
+ if (action === 'show') {
242
+ if (asJson) log(JSON.stringify({ shape: current }));
243
+ else for (const line of formatShape(current)) log(line);
244
+ return { shape: current };
245
+ }
246
+
247
+ if (action === 'set') {
248
+ const file = parseOption(args, '--file', '');
249
+ if (!file) throw new Error('Usage: empirical workspace shape set --workspace-id <id> --file <shape.json> [--apply]');
250
+ if (current) throw new Error('This workspace already has a shape. Change it with `shape edit`, `add-type`, `add-status` or `add-field`.');
251
+ const body = readJsonFile(file, 'the shape file');
252
+ return createShape({ baseUrl, workspaceId, authHeaders, shape: body.shape || body, apply, asJson });
253
+ }
254
+
255
+ const mappingFile = parseOption(args, '--mapping-file', '');
256
+ const mapping = mappingFile ? readJsonFile(mappingFile, 'the mapping file') : undefined;
257
+
258
+ if (action === 'edit') {
259
+ const file = parseOption(args, '--file', '');
260
+ if (!file) throw new Error('Usage: empirical workspace shape edit --workspace-id <id> --file <edit.json> [--mapping-file <m.json>] [--apply]');
261
+ if (!current) throw new Error('This workspace has no shape to edit. Give it one first with `shape set` or `shape add-type --purpose "..."`.');
262
+ return previewAndMaybeApply({ baseUrl, workspaceId, authHeaders, edit: readJsonFile(file, 'the edit file'), mapping, apply, asJson });
263
+ }
264
+
265
+ if (action === 'add-type') {
266
+ const [label] = positionals(args);
267
+ if (!label) throw new Error('Usage: empirical workspace shape add-type <label> [--statuses "A,B,C (done)"] [--fields "Email:text:required"] [--purpose <text>] --workspace-id <id>');
268
+ const type = buildTypeFromFlags(args, label);
269
+ if (!current) {
270
+ const purpose = parseOption(args, '--purpose', '');
271
+ if (!purpose) throw new Error('This workspace has no shape yet, so add-type also needs --purpose "what this workspace is for".');
272
+ return createShape({ baseUrl, workspaceId, authHeaders, shape: { purpose, itemTypes: [type], policy: [] }, apply, asJson });
273
+ }
274
+ return previewAndMaybeApply({ baseUrl, workspaceId, authHeaders, edit: { itemTypes: { add: [type] } }, mapping, apply, asJson });
275
+ }
276
+
277
+ if (!current) throw new Error('This workspace has no shape yet. Start with `shape add-type <label> --purpose "..."`.');
278
+
279
+ if (action === 'add-status') {
280
+ const [typeKey, label] = positionals(args);
281
+ if (!typeKey || !label) throw new Error('Usage: empirical workspace shape add-status <type-key> <label> [--done] --workspace-id <id> [--apply]');
282
+ return previewAndMaybeApply({ baseUrl, workspaceId, authHeaders, edit: buildAddStatusEdit(typeKey, label, parseFlag(args, '--done')), mapping, apply, asJson });
283
+ }
284
+
285
+ if (action === 'add-field') {
286
+ const [typeKey, label] = positionals(args);
287
+ if (!typeKey || !label) throw new Error('Usage: empirical workspace shape add-field <type-key> <label> [--kind text] [--required] [--options "A|B"] --workspace-id <id> [--apply]');
288
+ const kind = parseOption(args, '--kind', 'text');
289
+ if (!FIELD_KINDS.includes(kind)) throw new Error(`--kind must be one of: ${FIELD_KINDS.join(', ')}.`);
290
+ const field = { label, kind };
291
+ if (parseFlag(args, '--required')) field.required = true;
292
+ const options = parseOption(args, '--options', '');
293
+ if (options) field.options = options.split('|').map((o) => o.trim()).filter(Boolean);
294
+ return previewAndMaybeApply({ baseUrl, workspaceId, authHeaders, edit: buildAddFieldEdit(typeKey, field), mapping, apply, asJson });
295
+ }
296
+
297
+ throw new Error(SHAPE_USAGE);
298
+ }
299
+
300
+ module.exports = {
301
+ SHAPE_ACTIONS,
302
+ SHAPE_USAGE,
303
+ FIELD_KINDS,
304
+ isManualShapeAction,
305
+ positionals,
306
+ parseStatusList,
307
+ parseFieldList,
308
+ buildTypeFromFlags,
309
+ buildAddStatusEdit,
310
+ buildAddFieldEdit,
311
+ formatShape,
312
+ runManualShapeCommand
313
+ };