@empirical-gauzza/cli 1.0.37 → 1.0.39
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/lib/attachmentCommands.js +388 -0
- package/lib/constants.js +65 -0
- package/lib/empiricalTokensFormat.js +6 -4
- package/lib/help.js +15 -0
- package/lib/http.js +160 -0
- package/lib/installers/hooks.js +1312 -1312
- package/lib/installers/skill-variants/SKILL.both.md +20 -0
- package/lib/installers/skill-variants/SKILL.cli.md +10 -0
- package/lib/installers/skill-variants/SKILL.mcp.md +18 -0
- package/lib/memoryCommands.js +142 -97
- package/lib/memoryResolve.js +112 -0
- package/lib/setupCommands.js +191 -14
- package/lib/shapeCommands.js +313 -0
- package/lib/workspaceCommands.js +80 -5
- package/lib/workspaceContext.js +176 -176
- package/package.json +33 -33
package/lib/setupCommands.js
CHANGED
|
@@ -55,9 +55,20 @@ function recoveryHints(error, sessionId, step = '') {
|
|
|
55
55
|
switch (error && error.code) {
|
|
56
56
|
case 'GUIDED_SETUP_VOICE_NOT_WAITING':
|
|
57
57
|
case 'GUIDED_SETUP_VOICE_STALE':
|
|
58
|
+
case 'GUIDED_SETUP_WORKSPACE_NOT_WAITING':
|
|
58
59
|
return [`See the current question and its key with: ${resume}`];
|
|
59
60
|
case 'GUIDED_SETUP_VOICE_BUSY':
|
|
61
|
+
case 'GUIDED_SETUP_WORKSPACE_BUSY':
|
|
60
62
|
return [`Wait a moment, then pick it up with: ${resume}`];
|
|
63
|
+
// The workspace interview's own refusals. The server's message already carries the price of a new
|
|
64
|
+
// interview (it is the effective one), so these lines name no number. An abandoned interview is
|
|
65
|
+
// not refunded, which is said before the person is pointed at starting again.
|
|
66
|
+
case 'GUIDED_SETUP_WORKSPACE_SESSION_LIMIT':
|
|
67
|
+
return [`If a draft was already shown, you can read it with ${resume} and confirm it; the limit can be reached before there is one. Starting a new interview is charged again, and stopping this one with empirical setup abandon ${sid} does not return what it cost.`];
|
|
68
|
+
case 'GUIDED_SETUP_WORKSPACE_GENERATION_FAILED':
|
|
69
|
+
return [`Your answers are saved. Run ${resume} to see where the setup is, then answer again.`];
|
|
70
|
+
case 'GUIDED_SETUP_WORKSPACE_UNAVAILABLE':
|
|
71
|
+
return ['Nothing was started or charged. Run the command again in a few minutes.'];
|
|
61
72
|
case 'EMPIRICAL_TOKENS_ATTEMPT_CLOSED':
|
|
62
73
|
return ['This attempt is already closed, so it cannot be continued. Start a new one with: empirical setup run <setupId>'];
|
|
63
74
|
case 'EMPIRICAL_TOKEN_KEY_OWNER_MISMATCH':
|
|
@@ -120,7 +131,23 @@ function refundLine(error) {
|
|
|
120
131
|
return null;
|
|
121
132
|
}
|
|
122
133
|
|
|
134
|
+
// The server words its errors for every client, and one clause tells an AGENT to call a tool. At a terminal the
|
|
135
|
+
// same step is a command, so that clause (and only that clause) becomes the real command.
|
|
136
|
+
function cliConfirmWording(text, sessionId) {
|
|
137
|
+
return String(text === undefined || text === null ? '' : text).replace(
|
|
138
|
+
/\b([Cc])all confirm_guided_setup with this session's sessionId and the draftHash shown with the draft/g,
|
|
139
|
+
(_m, c) => `${c === 'C' ? 'Run' : 'run'} empirical setup confirm ${sessionId || '<sessionId>'} <draftHash>`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
function forTerminal(error, sessionId) {
|
|
143
|
+
if (!error) return error;
|
|
144
|
+
if (typeof error.message === 'string') error.message = cliConfirmWording(error.message, sessionId);
|
|
145
|
+
if (typeof error.serverMessage === 'string') error.serverMessage = cliConfirmWording(error.serverMessage, sessionId);
|
|
146
|
+
return error;
|
|
147
|
+
}
|
|
148
|
+
|
|
123
149
|
function failWith(error, pathSuffix, body) {
|
|
150
|
+
forTerminal(error, sessionIdFromPath(pathSuffix));
|
|
124
151
|
printApiError(error);
|
|
125
152
|
for (const line of recoveryHints(error, sessionIdFromPath(pathSuffix), stepOf(pathSuffix, body))) warn(line);
|
|
126
153
|
const refund = refundLine(error);
|
|
@@ -219,7 +246,9 @@ function formatQuestionLines(question, progress = {}) {
|
|
|
219
246
|
// records whatever is typed, so the end-early hint belongs to the follow-up loop alone.
|
|
220
247
|
if (p.meterLine && question.kind === 'text' && /^g\d+$/.test(key) && !question.focus) lines.push(loopHint(question, p));
|
|
221
248
|
}
|
|
222
|
-
|
|
249
|
+
// The workspace interview words its own examples (built from the options on offer, and in the 'add Name'
|
|
250
|
+
// form its parser reads); this Writing Voice line would show "2,5" for a list that may have two options.
|
|
251
|
+
if (question.kind === 'pick_and_edit' && !isWorkspaceQuestion(question)) lines.push('Reply with the numbers that fit, like 2,5, and optionally a | and your own changes.');
|
|
223
252
|
if (question.kind === 'pick_one') lines.push('Reply with one number.');
|
|
224
253
|
if (question.kind === 'yes_no') lines.push('Reply yes or no.');
|
|
225
254
|
if (staleTopic) {
|
|
@@ -238,7 +267,46 @@ function workspacePlace(name) {
|
|
|
238
267
|
return /\bworkspace$/i.test(n) ? n : `${n} workspace`;
|
|
239
268
|
}
|
|
240
269
|
|
|
270
|
+
// A workspace draft is a list of plain sentences (no hub or rules), the people to be invited, and
|
|
271
|
+
// anything that stops it being confirmed. The Writing Voice branch below would print "Hub: undefined".
|
|
272
|
+
function isWorkspaceDraft(question) {
|
|
273
|
+
return Boolean(question && question.draft && Array.isArray(question.draft.lines));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function formatWorkspaceDraftLines(question) {
|
|
277
|
+
const t = sanitizeForTerminal;
|
|
278
|
+
const confirm = question.confirm || {};
|
|
279
|
+
const lines = [];
|
|
280
|
+
for (const l of [...question.draft.lines, ...(Array.isArray(question.draft.statusSummary) ? question.draft.statusSummary : [])]) lines.push(`- ${t(l)}`);
|
|
281
|
+
if (Array.isArray(confirm.invites)) {
|
|
282
|
+
lines.push(confirm.invites.length
|
|
283
|
+
? `Invites: ${confirm.invites.map((i) => `${t(i.email)} (${t(i.role)})`).join(', ')}`
|
|
284
|
+
: 'Invites: nobody yet.');
|
|
285
|
+
}
|
|
286
|
+
if (confirm.inviteNote) lines.push(t(confirm.inviteNote));
|
|
287
|
+
if (confirm.canConfirm === false) {
|
|
288
|
+
lines.push('This draft cannot be confirmed as it is:');
|
|
289
|
+
for (const b of Array.isArray(confirm.blockers) ? confirm.blockers : []) lines.push(`- ${t(b)}`);
|
|
290
|
+
lines.push('Type a change to fix it. Nothing has been created or changed.');
|
|
291
|
+
} else if (question.prompt) {
|
|
292
|
+
// The server's own review wording: what the person can do next (next, confirm, a change, a rename).
|
|
293
|
+
lines.push(workspaceReviewPrompt(question.prompt));
|
|
294
|
+
}
|
|
295
|
+
return lines;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// The server words the review for every client, and its one tool-call clause (how an AGENT confirms) means
|
|
299
|
+
// nothing at a terminal, where the person types the word that is asked for below. Everything else is the
|
|
300
|
+
// server's own text, unchanged, so every example in it works as typed.
|
|
301
|
+
function workspaceReviewPrompt(prompt) {
|
|
302
|
+
return sanitizeForTerminal(prompt)
|
|
303
|
+
.replace(/\s*\(call confirm_guided_setup[^)]*\)/, '')
|
|
304
|
+
.replace(/To (?:create the workspace|save these changes), call confirm_guided_setup[^.]*\.\s*/, '')
|
|
305
|
+
.trim();
|
|
306
|
+
}
|
|
307
|
+
|
|
241
308
|
function formatDraftLines(question, progress = {}) {
|
|
309
|
+
if (isWorkspaceDraft(question)) return formatWorkspaceDraftLines(question);
|
|
242
310
|
const t = sanitizeForTerminal;
|
|
243
311
|
const draft = question.draft || {};
|
|
244
312
|
const confirm = question.confirm || {};
|
|
@@ -302,6 +370,10 @@ function printCurrentQuestion(result) {
|
|
|
302
370
|
}
|
|
303
371
|
printDraft(question, result.progress);
|
|
304
372
|
const confirm = question.confirm || {};
|
|
373
|
+
if (isWorkspaceDraft(question) && confirm.canConfirm === false) {
|
|
374
|
+
warn(`Ask for a change with: empirical setup answer ${sid} ${key} <the change you want>`);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
305
377
|
if (confirm.stale) {
|
|
306
378
|
warn(`Rebuild it with: empirical setup answer ${sid} ${key} rebuild`);
|
|
307
379
|
} else if (confirm.draftHash) {
|
|
@@ -378,6 +450,14 @@ async function offerVoicePluginInstall({ prompter, env = process.env, install =
|
|
|
378
450
|
// offer: { prompter } when a person at a terminal ran the interview; absent for scripted runs.
|
|
379
451
|
async function reportConfirmed(result, offer) {
|
|
380
452
|
if (result && result.message) log(sanitizeForTerminal(result.message));
|
|
453
|
+
// A confirmed workspace interview has no install job: it names the workspace and what to do next.
|
|
454
|
+
if (result && result.workspaceId && !(result && result.installId)) {
|
|
455
|
+
const id = sanitizeForTerminal(result.workspaceId);
|
|
456
|
+
log(`Workspace id: ${id}`);
|
|
457
|
+
log(`Switch to it with: ${WORKSPACE_USE_LINE(id)}`);
|
|
458
|
+
log(`Change its shape later with: ${WORKSPACE_SHAPE_LINE(id)}`);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
381
461
|
const installId = result && result.installId;
|
|
382
462
|
if (!installId) return;
|
|
383
463
|
const status = await fetchInstallStatus(installId);
|
|
@@ -419,11 +499,26 @@ async function reportConfirmed(result, offer) {
|
|
|
419
499
|
// Spec 7.3: a priced setup returns a cost preview first. Acceptance is sent only from the explicit
|
|
420
500
|
// --accept-cost flag; a first call without it stops at the cost and starts nothing. A person at a
|
|
421
501
|
// terminal is asked instead, and a non-interactive run stops.
|
|
422
|
-
|
|
423
|
-
|
|
502
|
+
//
|
|
503
|
+
// start carries any extra start fields. For the workspace interview that is targetWorkspaceId, the one
|
|
504
|
+
// explicit way to ask for a re-shape (no field means a new workspace). Its start is also where a setup
|
|
505
|
+
// that is switched off for the person answers 404, which is explained instead of left as a bare error.
|
|
506
|
+
async function startWithCost(setupId, { acceptCost, prompter, start = {} }) {
|
|
507
|
+
const first = await startCall({ setupId, ...start, ...(acceptCost ? { acceptEmpiricalTokenCost: true } : {}) });
|
|
424
508
|
if (!isCostPreview(first)) return reportTokensUsed(first);
|
|
425
509
|
await confirmCostOrExit(first, { prompter, warn, sanitize: sanitizeForTerminal });
|
|
426
|
-
return reportTokensUsed(await
|
|
510
|
+
return reportTokensUsed(await startCall({ setupId, ...start, acceptEmpiricalTokenCost: true }));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async function startCall(body) {
|
|
514
|
+
const { data, error } = await tryApi('POST', '/start', body);
|
|
515
|
+
if (!error) return data;
|
|
516
|
+
if (body.setupId === WORKSPACE_SETUP_ID && error.statusCode === 404) {
|
|
517
|
+
printApiError(error);
|
|
518
|
+
warn('The workspace interview is not switched on for your account, so nothing was started or charged.');
|
|
519
|
+
process.exit(1);
|
|
520
|
+
}
|
|
521
|
+
return failWith(error, '/start', body);
|
|
427
522
|
}
|
|
428
523
|
|
|
429
524
|
// After a paid start, say what it cost, but only when the response carries the amount. stderr, so
|
|
@@ -434,20 +529,35 @@ function reportTokensUsed(result) {
|
|
|
434
529
|
return result;
|
|
435
530
|
}
|
|
436
531
|
|
|
437
|
-
async function runNonInteractive(setupId, answersFilePath, acceptCost) {
|
|
532
|
+
async function runNonInteractive(setupId, answersFilePath, acceptCost, start) {
|
|
438
533
|
const raw = fs.readFileSync(path.resolve(answersFilePath), 'utf8');
|
|
439
534
|
const answersByQuestionKey = JSON.parse(raw);
|
|
440
535
|
|
|
441
|
-
let current = await startWithCost(setupId, { acceptCost, prompter: null });
|
|
536
|
+
let current = await startWithCost(setupId, { acceptCost, prompter: null, start });
|
|
537
|
+
hintAbandon(current);
|
|
442
538
|
let confirmed = false;
|
|
539
|
+
const reviewTurns = {};
|
|
443
540
|
|
|
444
541
|
while (current.question && current.status !== 'completed' && current.status !== 'finished_early') {
|
|
445
542
|
const question = current.question;
|
|
446
543
|
const questionKey = question.questionKey;
|
|
544
|
+
if (question.kind === 'confirm' && isFirstWorkspaceReview(question) && Object.prototype.hasOwnProperty.call(answersByQuestionKey, questionKey) && answersByQuestionKey[questionKey] !== 'confirm') {
|
|
545
|
+
// The first review of a workspace: the file's value is the answer, so a script can reach the invites.
|
|
546
|
+
// A list is taken in order (a rename, then next); a single string is sent once.
|
|
547
|
+
const wanted = answersByQuestionKey[questionKey];
|
|
548
|
+
const queue = Array.isArray(wanted) ? wanted : [wanted];
|
|
549
|
+
const turn = (reviewTurns[questionKey] = (reviewTurns[questionKey] || 0) + 1) - 1;
|
|
550
|
+
if (turn < queue.length) {
|
|
551
|
+
current = await callApi('POST', `/${current.sessionId}/continue`, answerBody(question, queue[turn]));
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
447
555
|
if (question.kind === 'confirm') {
|
|
448
556
|
// Confirming is its own step: only an explicit "confirm" for q5_confirm installs, never a stale draft.
|
|
449
557
|
const stale = Boolean(question.confirm && question.confirm.stale);
|
|
450
|
-
|
|
558
|
+
// A workspace draft with blockers has no hash to confirm; the file's "confirm" is not sent for it.
|
|
559
|
+
const confirmable = !(question.confirm && question.confirm.canConfirm === false);
|
|
560
|
+
if (answersByQuestionKey[questionKey] === 'confirm' && !stale && confirmable) {
|
|
451
561
|
current = await callApi('POST', `/${current.sessionId}/confirm`, { draftHash: question.confirm.draftHash });
|
|
452
562
|
confirmed = true;
|
|
453
563
|
} else if (stale) {
|
|
@@ -517,34 +627,44 @@ async function sendInteractiveAnswer(current, answer) {
|
|
|
517
627
|
if (!error) return data;
|
|
518
628
|
if (error.statusCode !== 400 && error.statusCode !== 409) failWith(error, pathSuffix, body);
|
|
519
629
|
if (error.code === 'GUIDED_SETUP_CONFIRM_REQUIRED') warn('To install this draft, type yes.');
|
|
520
|
-
else warn(sanitizeForTerminal(error.serverMessage || error.message));
|
|
630
|
+
else warn(sanitizeForTerminal(cliConfirmWording(error.serverMessage || error.message, current.sessionId)));
|
|
521
631
|
return callApi('POST', pathSuffix, {});
|
|
522
632
|
}
|
|
523
633
|
|
|
524
|
-
async function runInteractive(setupId, acceptCost) {
|
|
634
|
+
async function runInteractive(setupId, acceptCost, start) {
|
|
525
635
|
const prompter = createPrompter(process.stderr);
|
|
526
636
|
let abortMessage = null;
|
|
527
637
|
let finalResult = null;
|
|
528
638
|
let confirmed = false;
|
|
529
639
|
try {
|
|
530
|
-
let current = await startWithCost(setupId, { acceptCost, prompter });
|
|
640
|
+
let current = await startWithCost(setupId, { acceptCost, prompter, start });
|
|
531
641
|
if (!current) return;
|
|
642
|
+
hintAbandon(current);
|
|
532
643
|
let emptyReplies = 0;
|
|
533
644
|
while (current.question && current.status !== 'completed' && current.status !== 'finished_early') {
|
|
534
645
|
const question = current.question;
|
|
535
646
|
if (question.kind === 'confirm') {
|
|
536
647
|
printDraft(question, current.progress);
|
|
537
|
-
const reply = (await prompter.askText(
|
|
648
|
+
const reply = (await prompter.askText(confirmPromptFor(question))).trim();
|
|
538
649
|
if (!reply) {
|
|
539
650
|
const sessionId = current.sessionId;
|
|
540
651
|
abortMessage = () => printNothingInstalled(sessionId, question.confirm && question.confirm.draftHash);
|
|
541
652
|
break;
|
|
542
653
|
}
|
|
543
|
-
|
|
654
|
+
const firstReview = isFirstWorkspaceReview(question);
|
|
655
|
+
if (firstReview && /^(y|yes)$/i.test(reply)) {
|
|
656
|
+
warn('Yes is not one of the choices here. Type next to choose who to invite, or confirm to finish without inviting anyone.');
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
if (firstReview ? /^confirm$/i.test(reply) : /^(y|yes|confirm)$/i.test(reply)) {
|
|
544
660
|
if (question.confirm && question.confirm.stale) {
|
|
545
661
|
warn('This draft is out of date. Type rebuild to write a new one first.');
|
|
546
662
|
continue;
|
|
547
663
|
}
|
|
664
|
+
if (isWorkspaceDraft(question) && (question.confirm.canConfirm === false || !question.confirm.draftHash)) {
|
|
665
|
+
warn('This draft cannot be confirmed as it is. Type a change to fix what is listed above.');
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
548
668
|
current = await callApi('POST', `/${current.sessionId}/confirm`, { draftHash: question.confirm.draftHash });
|
|
549
669
|
confirmed = true;
|
|
550
670
|
break;
|
|
@@ -589,6 +709,50 @@ async function runInteractive(setupId, acceptCost) {
|
|
|
589
709
|
printJson(finalResult);
|
|
590
710
|
}
|
|
591
711
|
|
|
712
|
+
const WORKSPACE_SETUP_ID = 'setup_workspace';
|
|
713
|
+
// The commands printed after a workspace is made, built from the real id so they work as typed.
|
|
714
|
+
const WORKSPACE_USE_LINE = (id) => `empirical workspace use ${id}`;
|
|
715
|
+
const WORKSPACE_SHAPE_LINE = (id) => `empirical workspace shape --workspace-id ${id}`;
|
|
716
|
+
|
|
717
|
+
// The question at the review step. The workspace interview creates or changes a workspace, not an install.
|
|
718
|
+
// The first review (questionKey draft) is BEFORE the invitations, so a bare yes is not a choice there: the
|
|
719
|
+
// person picks next (to choose who to invite) or confirm (finish with nobody invited). The final review
|
|
720
|
+
// (after the invitations) is the one a yes confirms.
|
|
721
|
+
function isFirstWorkspaceReview(question) {
|
|
722
|
+
return isWorkspaceDraft(question) && question.questionKey === 'draft';
|
|
723
|
+
}
|
|
724
|
+
function confirmPromptFor(question) {
|
|
725
|
+
if (!isWorkspaceDraft(question)) return 'Install this? (type yes to install, or type a change you want)';
|
|
726
|
+
const reshape = question.confirm && question.confirm.mode === 'reshape';
|
|
727
|
+
if (question.confirm && question.confirm.canConfirm === false) return 'What next? (type a change you want)';
|
|
728
|
+
if (isFirstWorkspaceReview(question)) return `What next? (type next to choose who to invite, confirm to ${reshape ? 'save these changes' : 'create the workspace'} without inviting anyone, or type a change you want)`;
|
|
729
|
+
return `${reshape ? 'Apply this to the workspace?' : 'Create this workspace?'} (type yes to confirm, or type a change you want)`;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Printed once after a workspace interview starts. An abandoned interview is NOT refunded, and the
|
|
733
|
+
// line says so, so nobody stops one expecting their Empirical Tokens back.
|
|
734
|
+
function hintAbandon(result) {
|
|
735
|
+
if (!result || !result.sessionId || !result.question) return;
|
|
736
|
+
if (!isWorkspaceQuestion(result.question)) return;
|
|
737
|
+
const sid = sanitizeForTerminal(result.sessionId);
|
|
738
|
+
warn(`If you want to stop this interview, run: empirical setup abandon ${sid} (what it cost is not returned).`);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Workspace questions are the only ones with these keys; the Writing Voice engine has none of them.
|
|
742
|
+
function isWorkspaceQuestion(question) {
|
|
743
|
+
const key = String((question && question.questionKey) || '');
|
|
744
|
+
return key === 'purpose' || key === 'types' || key === 'nesting' || key === 'policy' || key === 'invites'
|
|
745
|
+
|| key === 'draft' || /^(statuses|fields):/.test(key);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// The workspace interview from the workspace commands. targetWorkspaceId present means re-shape that
|
|
749
|
+
// workspace, absent means create a new one; nothing else decides it.
|
|
750
|
+
async function runWorkspaceInterview({ targetWorkspaceId, answersFile = '', acceptCost = false } = {}) {
|
|
751
|
+
const start = targetWorkspaceId ? { targetWorkspaceId } : {};
|
|
752
|
+
if (answersFile) return runNonInteractive(WORKSPACE_SETUP_ID, answersFile, acceptCost, start);
|
|
753
|
+
return runInteractive(WORKSPACE_SETUP_ID, acceptCost, start);
|
|
754
|
+
}
|
|
755
|
+
|
|
592
756
|
async function runRun(args) {
|
|
593
757
|
const setupId = args[0] && !args[0].startsWith('--') ? args[0] : '';
|
|
594
758
|
if (!setupId) {
|
|
@@ -627,6 +791,18 @@ async function runFinish(args) {
|
|
|
627
791
|
printJson(result);
|
|
628
792
|
}
|
|
629
793
|
|
|
794
|
+
async function runAbandon(args) {
|
|
795
|
+
const [sessionId] = args;
|
|
796
|
+
if (!sessionId) {
|
|
797
|
+
warn('Usage: empirical setup abandon <sessionId>');
|
|
798
|
+
process.exit(1);
|
|
799
|
+
}
|
|
800
|
+
const result = await callApi('POST', `/${encodeURIComponent(sessionId)}/abandon`, {});
|
|
801
|
+
log(result && result.status === 'abandoned'
|
|
802
|
+
? 'The interview was stopped. Nothing was created or changed, and what it cost was not returned.'
|
|
803
|
+
: 'That interview was already finished or stopped. Nothing was changed, and no Empirical Tokens were returned.');
|
|
804
|
+
}
|
|
805
|
+
|
|
630
806
|
async function runResume(args) {
|
|
631
807
|
const [sessionId] = args;
|
|
632
808
|
if (!sessionId) {
|
|
@@ -656,11 +832,12 @@ async function runSetupCommand(args) {
|
|
|
656
832
|
if (sub === 'finish') return runFinish(rest);
|
|
657
833
|
if (sub === 'resume') return runResume(rest);
|
|
658
834
|
if (sub === 'confirm') return runConfirm(rest);
|
|
659
|
-
|
|
835
|
+
if (sub === 'abandon') return runAbandon(rest);
|
|
836
|
+
warn('Usage: empirical setup <list|run|answer|finish|resume|confirm|abandon> ...');
|
|
660
837
|
process.exit(1);
|
|
661
838
|
}
|
|
662
839
|
|
|
663
840
|
module.exports = {
|
|
664
|
-
refundLine, runSetupCommand, formatQuestionLines, formatDraftLines, sanitizeForTerminal,
|
|
841
|
+
refundLine, cliConfirmWording, runSetupCommand, runWorkspaceInterview, WORKSPACE_SETUP_ID, recoveryHints, formatQuestionLines, formatDraftLines, sanitizeForTerminal,
|
|
665
842
|
offerVoicePluginInstall, VOICE_PLUGIN_COMMANDS, VOICE_STANDING_INSTRUCTION
|
|
666
843
|
};
|
|
@@ -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
|
+
};
|