@empirical-gauzza/cli 1.0.36 → 1.0.38

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.
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+
3
+ // Empirical Tokens wording and the cost gate shared by every CLI command that can start a priced
4
+ // action (`setup run`, `blueprint install`) or print a reset date (`usage`, `whoami`, error hints).
5
+
6
+ // '<Month> <D> at <h:mm AM/PM> <TZ>' in this machine's time zone, e.g. 'October 31 at 8:00 PM EDT'.
7
+ // The reset instant is 00:00 UTC on the 1st, so the local date can be the day before: date AND time
8
+ // are always shown. This is a standalone copy of formatResetInstant in packages/shared (the CLI is
9
+ // published on its own and does not import shared); both are pinned to the same fixtures in tests.
10
+ // timeZone defaults to the machine's own; an invalid or empty one reads as UTC and says so. Returns
11
+ // null for a missing or unreadable date so callers can fall back to neutral wording.
12
+ function resetLabel(resetsAt, timeZone) {
13
+ if (resetsAt === undefined || resetsAt === null || resetsAt === '') return null;
14
+ const date = new Date(resetsAt);
15
+ if (Number.isNaN(date.getTime())) return null;
16
+ const machineZone = (() => {
17
+ try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch (_error) { return null; }
18
+ })();
19
+ const requested = timeZone === undefined ? machineZone : timeZone;
20
+ const zones = [typeof requested === 'string' && requested.trim() ? requested.trim() : null, 'UTC'].filter(Boolean);
21
+ for (const zone of zones) {
22
+ try {
23
+ const parts = new Intl.DateTimeFormat('en-US', {
24
+ timeZone: zone, month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'short'
25
+ }).formatToParts(date);
26
+ const get = (type) => (parts.find((p) => p.type === type) || {}).value || '';
27
+ return `${get('month')} ${get('day')} at ${get('hour')}:${get('minute')} ${get('dayPeriod')} ${get('timeZoneName')}`;
28
+ } catch (_error) {
29
+ // Invalid time zone: try UTC.
30
+ }
31
+ }
32
+ return null;
33
+ }
34
+
35
+ // A charge is offered only to a real terminal; piped input never accepts one. EMPIRICAL_TEST_ASSUME_TTY=1
36
+ // exists only so tests can drive the prompt through a pipe, and it applies ONLY when NODE_ENV is 'test':
37
+ // a stray copy of it in a real shell cannot switch off the non-interactive refusals (a paid re-shape from
38
+ // a binding, a cost accepted with no one at the keyboard). It is not documented in --help on purpose and
39
+ // nothing in the product sets it.
40
+ function isInteractiveTerminal() {
41
+ if (process.stdin.isTTY) return true;
42
+ return process.env.NODE_ENV === 'test' && process.env.EMPIRICAL_TEST_ASSUME_TTY === '1';
43
+ }
44
+
45
+ // A priced start answered 200 with status 'cost_confirmation_required' and started nothing. This
46
+ // resolves only when a person at a terminal typed y or yes; every other path (no prompter, no terminal,
47
+ // any other reply) prints why nothing started and exits 1. The cost is never accepted on the person's
48
+ // behalf: acceptance reaches the server only from --accept-cost or from this prompt.
49
+ // preview the server's preview payload (message, empiricalTokenCost)
50
+ // prompter createPrompter(...) result, or null for a non-interactive run
51
+ // warn the CLI's stderr printer
52
+ // sanitize terminal sanitiser for server text
53
+ async function confirmCostOrExit(preview, { prompter, warn, sanitize = String }) {
54
+ warn(sanitize(preview.message || `This uses ${preview.empiricalTokenCost} Empirical Tokens.`));
55
+ if (!prompter || !isInteractiveTerminal()) {
56
+ warn('Nothing was started. Re-run with --accept-cost to start.');
57
+ process.exit(1);
58
+ }
59
+ const reply = (await prompter.askText('Start? (y/N)')).trim();
60
+ if (!/^(y|yes)$/i.test(reply)) {
61
+ warn('Nothing was started.');
62
+ process.exit(1);
63
+ }
64
+ }
65
+
66
+ const isCostPreview = (value) => Boolean(value) && value.status === 'cost_confirmation_required';
67
+
68
+ module.exports = { resetLabel, isInteractiveTerminal, confirmCostOrExit, isCostPreview };
package/lib/help.js CHANGED
@@ -13,7 +13,7 @@ function showHelp() {
13
13
  log('');
14
14
  log('Usage:');
15
15
  log(' empirical install');
16
- log(' empirical plugin install <codex|copilot|claude|all> [--all-optional]');
16
+ log(' empirical plugin install <codex|copilot|claude|all> [--all-optional|--voice]');
17
17
  log(' empirical plugin check [--force]');
18
18
  log(' empirical policy show [--refresh]');
19
19
  log(' empirical policy set --json <policy>');
@@ -25,6 +25,7 @@ function showHelp() {
25
25
  log(' Mint a new API key (--replace-all wipes the existing ones)');
26
26
  log(' empirical auth revoke-key (--key-id <id> | --all)');
27
27
  log(' Revoke one API key, or every key on the account');
28
+ log(' empirical usage [--json] Memory, installs and Empirical Tokens this month');
28
29
  log(' empirical oauth bootstrap headless [--print-access-token]');
29
30
  log(' First-time token setup for CI/headless/API-key use');
30
31
  log(' empirical oauth bootstrap headless --write-env [--env-output <path>]');
@@ -45,6 +46,13 @@ function showHelp() {
45
46
  log(' empirical workspace unbind Remove this project\'s workspace binding');
46
47
  log(' empirical workspace create <name> [--description <text>]');
47
48
  log(' Create a new shared team workspace');
49
+ log(' empirical workspace create --interview [--answers <file.json>] [--accept-cost]');
50
+ log(' Set up a new shared workspace by interview: it offers options, shows a');
51
+ log(' draft, and creates the workspace and invites only when you say yes');
52
+ log(' empirical workspace shape [--workspace-id <id>] [--answers <file.json>] [--accept-cost]');
53
+ log(' Re-shape a team workspace you manage by interview. Without --workspace-id it');
54
+ log(' confirms the workspace bound to this directory with you, at a terminal only;');
55
+ log(' in a script or with --accept-cost it needs --workspace-id. A new interview is charged');
48
56
  log(' empirical workspace invite create [--role reader|writer|manager] [--expires-in-days <n>] [--max-uses <n>]');
49
57
  log(' Create a shareable invite link for the active team workspace');
50
58
  log(' empirical workspace invite list List active invites for the active team workspace');
@@ -52,6 +60,16 @@ function showHelp() {
52
60
  log(' empirical workspace delete <number|name|id> [--confirm]');
53
61
  log(' Soft-delete a team workspace (dry-run without --confirm)');
54
62
  log('');
63
+ log('Guided setups:');
64
+ log(' empirical setup list List guided setups');
65
+ log(' empirical setup run <setupId> [--accept-cost] Run one interactively');
66
+ log(' empirical setup run <setupId> --answers <f> Run from a JSON file of answers');
67
+ log(' empirical setup resume <sessionId> Show the current question');
68
+ log(' empirical setup answer <sessionId> <key> <v> Answer a question');
69
+ log(' empirical setup confirm <sessionId> <hash> Confirm and install a reviewed draft');
70
+ log(' empirical setup finish <sessionId> Finish a scripted setup early');
71
+ log(' empirical setup abandon <sessionId> Stop a workspace interview (what it cost is not returned)');
72
+ log('');
55
73
  log('Options:');
56
74
  log(' install Interactive installer for Codex/Copilot/Claude CLI integration');
57
75
  log(' plugin install <target> Install core plugin; add --all-optional or capability flags');
@@ -79,6 +97,10 @@ function showHelp() {
79
97
  log(' empirical memory note Add a note to an existing memory');
80
98
  log(' empirical memory update Update an existing memory');
81
99
  log(' empirical memory delete Delete an existing memory');
100
+ log(' empirical memory attach Attach a file to a memory [--file <path>]');
101
+ log(' empirical memory attachments List the files on a memory [--include-deleted] [--format table]');
102
+ log(' empirical memory attachment Full file commands: list, add, get, delete, restore');
103
+ log(' empirical memory tree One item plus everything nested under it, as a document');
82
104
  log(' empirical memory duplicates List duplicate candidates for a memory');
83
105
  log(' empirical memory merge Merge a duplicate memory into a canonical one');
84
106
  log(' empirical memory export Export memories as portable JSON [--scope <personal|workspace>] [--out <path>]');
package/lib/http.js CHANGED
@@ -52,8 +52,10 @@ function httpErrorFromResponse(method, urlString, statusCode, data) {
52
52
 
53
53
  const error = new Error(parts.join(' '));
54
54
  error.statusCode = statusCode;
55
+ error.serverMessage = baseMsg;
55
56
  error.code = data.code || null;
56
57
  error.upgradeUrl = upgradeUrl;
58
+ error.details = data.details && typeof data.details === 'object' ? data.details : null;
57
59
  error.retryAfterSeconds = retryAfterSeconds;
58
60
  return error;
59
61
  }
@@ -61,14 +63,19 @@ function httpErrorFromResponse(method, urlString, statusCode, data) {
61
63
  // Prints a structured JSON error line to stderr so scripts/agents parsing CLI
62
64
  // output can branch on `code` (e.g. RATE_LIMITED, PLAN_LIMIT_MEMORY_REACHED)
63
65
  // and act on `upgradeUrl`/`retryAfterSeconds` without string-parsing `message`.
64
- function printApiError(error) {
65
- warn(JSON.stringify({
66
+ // JSON.stringify escapes C0 controls but not C1 (0x80 to 0x9f), which some terminals act on.
67
+ function formatApiError(error) {
68
+ return JSON.stringify({
66
69
  error: true,
67
70
  code: error.code || 'api_error',
68
71
  message: error.message,
69
72
  ...(error.retryAfterSeconds != null && { retryAfterSeconds: error.retryAfterSeconds }),
70
73
  ...(error.upgradeUrl && { upgradeUrl: error.upgradeUrl })
71
- }));
74
+ }).replace(/[\x80-\x9f]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
75
+ }
76
+
77
+ function printApiError(error) {
78
+ warn(formatApiError(error));
72
79
  }
73
80
 
74
81
  function httpJson(urlString, method, payload, headers = {}, timeoutMs = 0) {
@@ -131,6 +138,163 @@ function httpJson(urlString, method, payload, headers = {}, timeoutMs = 0) {
131
138
  });
132
139
  }
133
140
 
141
+ // A filename travels to the server inside a Content-Disposition header we build by hand. Local
142
+ // filenames are arbitrary bytes: a quote ends the parameter early and a CR/LF splits the part
143
+ // header, so either one lets a filename forge headers or a whole extra part. multer/busboy also
144
+ // has no portable unescaping here, so these characters are replaced rather than escaped.
145
+ function sanitizeMultipartFilename(value) {
146
+ const cleaned = String(value || '')
147
+ .replace(/[\r\n"\\]/g, '_')
148
+ // C0 controls would be sent verbatim into a header line.
149
+ .replace(/[\x00-\x1f\x7f]/g, '_')
150
+ .trim();
151
+ return cleaned || 'attachment';
152
+ }
153
+
154
+ function buildMultipartBody(boundary, fields, file) {
155
+ const parts = [];
156
+ for (const [name, value] of Object.entries(fields || {})) {
157
+ if (value === undefined || value === null || value === '') continue;
158
+ parts.push(Buffer.from(
159
+ `--${boundary}\r\nContent-Disposition: form-data; name="${sanitizeMultipartFilename(name)}"\r\n\r\n${String(value)}\r\n`,
160
+ 'utf8'
161
+ ));
162
+ }
163
+ parts.push(Buffer.from(
164
+ `--${boundary}\r\n`
165
+ + `Content-Disposition: form-data; name="${sanitizeMultipartFilename(file.field || 'file')}";`
166
+ + ` filename="${sanitizeMultipartFilename(file.filename)}"\r\n`
167
+ + `Content-Type: ${sanitizeMultipartFilename(file.contentType || 'application/octet-stream')}\r\n\r\n`,
168
+ 'utf8'
169
+ ));
170
+ parts.push(file.buffer);
171
+ parts.push(Buffer.from(`\r\n--${boundary}--\r\n`, 'utf8'));
172
+ return Buffer.concat(parts);
173
+ }
174
+
175
+ /**
176
+ * multipart/form-data with exactly one file part, for attachment uploads. The CLI ships with one
177
+ * dependency and no form-data library, and the body is already fully buffered (the per-file cap
178
+ * is 25MB), so the body is assembled here rather than streamed.
179
+ *
180
+ * Resolves/rejects on the same contract as httpJson, so callers can use printApiError unchanged.
181
+ */
182
+ function httpMultipart(urlString, method, { fields = {}, file }, headers = {}, timeoutMs = 0) {
183
+ return new Promise((resolve, reject) => {
184
+ if (!file || !Buffer.isBuffer(file.buffer)) {
185
+ reject(new Error('httpMultipart requires file.buffer'));
186
+ return;
187
+ }
188
+ const parsed = new URL(urlString);
189
+ const lib = parsed.protocol === 'https:' ? https : http;
190
+ const boundary = `----empiricalcli${Date.now().toString(16)}${Math.random().toString(16).slice(2)}`;
191
+ const body = buildMultipartBody(boundary, fields, file);
192
+
193
+ const req = lib.request(
194
+ parsed,
195
+ {
196
+ method,
197
+ headers: {
198
+ Accept: 'application/json',
199
+ // The server infers source 'cli' from this (see @empirical/core memoryAuthorship).
200
+ 'User-Agent': 'empirical-cli/1.0',
201
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
202
+ 'Content-Length': body.length,
203
+ ...headers
204
+ }
205
+ },
206
+ (res) => {
207
+ let raw = '';
208
+ res.setEncoding('utf8');
209
+ res.on('data', (chunk) => {
210
+ raw += chunk;
211
+ });
212
+ res.on('end', () => {
213
+ let data = {};
214
+ try {
215
+ data = raw ? JSON.parse(raw) : {};
216
+ } catch (_error) {
217
+ data = { raw };
218
+ }
219
+
220
+ if (data && Array.isArray(data.alerts) && data.alerts.length) {
221
+ process.stderr.write(renderCliAlertBanner(data.alerts));
222
+ }
223
+
224
+ const statusCode = res.statusCode || 0;
225
+ if (statusCode < 200 || statusCode >= 300) {
226
+ reject(httpErrorFromResponse(method, urlString, statusCode, data));
227
+ return;
228
+ }
229
+ resolve(data);
230
+ });
231
+ }
232
+ );
233
+
234
+ req.on('error', reject);
235
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
236
+ req.setTimeout(timeoutMs, () => {
237
+ req.destroy(new Error(`request timeout after ${timeoutMs}ms`));
238
+ });
239
+ }
240
+ req.write(body);
241
+ req.end();
242
+ });
243
+ }
244
+
245
+ /**
246
+ * GET that keeps the response as BYTES. httpJson would decode the body as utf8 and corrupt any
247
+ * binary, so downloads cannot reuse it. A non-2xx body is still JSON here (the attachment
248
+ * download route answers 404/410 as JSON), so failures are parsed and rejected on the same
249
+ * contract as httpJson.
250
+ *
251
+ * @returns {Promise<{ statusCode: number, headers: object, buffer: Buffer }>}
252
+ */
253
+ function httpDownload(urlString, headers = {}, timeoutMs = 0) {
254
+ return new Promise((resolve, reject) => {
255
+ const parsed = new URL(urlString);
256
+ const lib = parsed.protocol === 'https:' ? https : http;
257
+
258
+ const req = lib.request(
259
+ parsed,
260
+ {
261
+ method: 'GET',
262
+ headers: {
263
+ 'User-Agent': 'empirical-cli/1.0',
264
+ ...headers
265
+ }
266
+ },
267
+ (res) => {
268
+ const chunks = [];
269
+ res.on('data', (chunk) => chunks.push(chunk));
270
+ res.on('end', () => {
271
+ const buffer = Buffer.concat(chunks);
272
+ const statusCode = res.statusCode || 0;
273
+ if (statusCode < 200 || statusCode >= 300) {
274
+ let data = {};
275
+ try {
276
+ data = JSON.parse(buffer.toString('utf8'));
277
+ } catch (_error) {
278
+ data = { raw: buffer.toString('utf8').slice(0, 500) };
279
+ }
280
+ reject(httpErrorFromResponse('GET', urlString, statusCode, data));
281
+ return;
282
+ }
283
+ resolve({ statusCode, headers: res.headers || {}, buffer });
284
+ });
285
+ }
286
+ );
287
+
288
+ req.on('error', reject);
289
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
290
+ req.setTimeout(timeoutMs, () => {
291
+ req.destroy(new Error(`request timeout after ${timeoutMs}ms`));
292
+ });
293
+ }
294
+ req.end();
295
+ });
296
+ }
297
+
134
298
  function httpForm(urlString, method, params, headers = {}) {
135
299
  return new Promise((resolve, reject) => {
136
300
  const parsed = new URL(urlString);
@@ -186,7 +350,11 @@ function httpForm(urlString, method, params, headers = {}) {
186
350
  module.exports = {
187
351
  httpGet,
188
352
  httpErrorFromResponse,
353
+ formatApiError,
189
354
  printApiError,
190
355
  httpJson,
356
+ httpMultipart,
357
+ httpDownload,
358
+ sanitizeMultipartFilename,
191
359
  httpForm
192
360
  };
@@ -32,6 +32,7 @@ function pluginNamesForInstallerOptions(options = {}) {
32
32
  if (options.recordWorkHistory !== false) names.push('empirical-work-history');
33
33
  if (options.recordScarTissue !== false) names.push('empirical-scar-tissue');
34
34
  if (options.recordTodos === true) names.push('empirical-todo');
35
+ if (options.installVoicePlugin === true) names.push('empirical-voice');
35
36
  return names.filter((name) => name === 'empirical-memory' || OPTIONAL_PLUGIN_NAMES.includes(name));
36
37
  }
37
38
 
@@ -405,5 +406,6 @@ async function runInstallFlow() {
405
406
  }
406
407
 
407
408
  module.exports = {
408
- runInstallFlow
409
+ runInstallFlow,
410
+ pluginNamesForInstallerOptions
409
411
  };
@@ -23,7 +23,8 @@ const PLUGIN_DESCRIPTIONS = {
23
23
  [CORE_PLUGIN_NAME]: 'query and save durable project/user context - the core memory skill',
24
24
  'empirical-work-history': 'record concise summaries of completed work for future agents',
25
25
  'empirical-scar-tissue': 'record recurring failure patterns and prevention guidance',
26
- 'empirical-todo': 'track actionable follow-ups by project'
26
+ 'empirical-todo': 'track actionable follow-ups by project',
27
+ 'empirical-voice': "write in the person's saved voice whenever you write as them"
27
28
  };
28
29
 
29
30
  function describePlugin(name) {