@empirical-gauzza/cli 1.0.19 → 1.0.20

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/bin/empirical.js CHANGED
@@ -32,7 +32,12 @@ async function main() {
32
32
  return;
33
33
  }
34
34
 
35
- if (args.includes('-h') || args.includes('--help')) {
35
+ // `-h`/`--help` short-circuits to the global help wall UNLESS a subcommand that knows how to
36
+ // print its own scoped usage was named first (currently just `memory`, per C-005 item 5) --
37
+ // otherwise `empirical memory --help` / `empirical memory query --help` printed the 90-line
38
+ // global wall instead of anything about `memory`.
39
+ const scopedHelpCommands = new Set(['memory']);
40
+ if ((args.includes('-h') || args.includes('--help')) && !scopedHelpCommands.has(args[0])) {
36
41
  showHelp();
37
42
  return;
38
43
  }
@@ -42,8 +47,11 @@ async function main() {
42
47
  return;
43
48
  }
44
49
 
45
- const updateCheckExcluded = new Set(['install', 'plugin', 'auth', 'oauth', 'policy', 'doctor']);
46
- if (process.stdin.isTTY && !updateCheckExcluded.has(args[0])) {
50
+ const updateCheckExcluded = new Set(['install', 'plugin', 'auth', 'oauth', 'policy', 'doctor', 'whoami']);
51
+ // Both stdin and stdout must be a real TTY: stdin.isTTY alone let this run ahead of e.g.
52
+ // `empirical memory query --match x | jq`, where stdin stays a TTY but stdout is a pipe --
53
+ // the header/prompt then leaked into the piped stream ahead of the JSON payload.
54
+ if (process.stdin.isTTY && process.stdout.isTTY && !updateCheckExcluded.has(args[0])) {
47
55
  try {
48
56
  await checkNativePluginUpdates({ interactive: true });
49
57
  } catch (_error) {
package/lib/auth.js CHANGED
@@ -18,12 +18,12 @@ const {
18
18
  generateRandomString,
19
19
  generatePKCE,
20
20
  isJwtToken,
21
- isJwtExpired,
22
- readHeadlessToken,
23
- readOAuthSession,
24
- writeOAuthSession,
25
- clearOAuthSession,
26
- hasFreshAccessToken,
21
+ isJwtExpired,
22
+ readHeadlessToken,
23
+ readOAuthSession,
24
+ writeOAuthSession,
25
+ clearOAuthSession,
26
+ hasFreshAccessToken,
27
27
  writeHeadlessEnvFile
28
28
  } = require('./oauthClient');
29
29
  const { deriveBaseUrlFromMcpUrl } = require('./constants');
@@ -124,11 +124,11 @@ async function runMcpOAuthFlow(baseUrl) {
124
124
 
125
125
  if (!resolved) {
126
126
  resolved = true;
127
- resolveOAuthCode(code, codeVerifier, baseUrl)
128
- .then((token) => {
129
- writeOAuthSession(token);
130
- resolve(token);
131
- })
127
+ resolveOAuthCode(code, codeVerifier, baseUrl)
128
+ .then((token) => {
129
+ writeOAuthSession(token);
130
+ resolve(token);
131
+ })
132
132
  .catch(reject);
133
133
  }
134
134
  } else {
@@ -191,9 +191,9 @@ async function resolveOAuthCode(code, codeVerifier, baseUrl) {
191
191
  throw new Error('Token exchange succeeded but no access_token was returned.');
192
192
  }
193
193
 
194
- return {
195
- baseUrl,
196
- accessToken: token.access_token,
194
+ return {
195
+ baseUrl,
196
+ accessToken: token.access_token,
197
197
  refreshToken: token.refresh_token || null,
198
198
  expiresIn: token.expires_in || null,
199
199
  tokenType: token.token_type || 'Bearer',
@@ -240,7 +240,7 @@ function resolveAccessToken() {
240
240
  path.join(os.homedir(), '.empirical', 'headless-token.json')
241
241
  ].filter(Boolean);
242
242
 
243
- for (const candidate of tokenFileCandidates) {
243
+ for (const candidate of tokenFileCandidates) {
244
244
  if (!fs.existsSync(candidate)) continue;
245
245
  try {
246
246
  const parsed = JSON.parse(fs.readFileSync(candidate, 'utf8'));
@@ -253,12 +253,12 @@ function resolveAccessToken() {
253
253
  } catch (_error) {
254
254
  // ignore parse errors and try next
255
255
  }
256
- }
257
-
258
- const session = readOAuthSession();
259
- if (session?.accessToken && !(isJwtToken(session.accessToken) && isJwtExpired(session.accessToken))) {
260
- return session.accessToken;
261
- }
256
+ }
257
+
258
+ const session = readOAuthSession();
259
+ if (session?.accessToken && !(isJwtToken(session.accessToken) && isJwtExpired(session.accessToken))) {
260
+ return session.accessToken;
261
+ }
262
262
 
263
263
  // Priority 4: EMPIRICAL_API_KEY / EMPIRICAL_CLIENT_SECRET env vars, used
264
264
  // directly as a permanent Bearer credential. In a client_credentials setup
@@ -305,30 +305,111 @@ function resolveApiBaseUrl() {
305
305
  return 'https://empirical.gauzza.com';
306
306
  }
307
307
 
308
- function requireAccessToken() {
308
+ function requireAccessToken() {
309
309
  const token = resolveAccessToken();
310
310
  if (!token) {
311
- warn('No access token found. Run: empirical auth login, or configure headless credentials with empirical oauth bootstrap headless.');
311
+ warn('No access token found. Run: empirical auth login, or configure headless credentials with empirical oauth bootstrap headless.');
312
312
  process.exit(1);
313
313
  }
314
314
  return token;
315
- }
316
-
317
- function isAuthCommand(args) {
318
- return args[0] === 'auth' && ['login', 'logout'].includes(args[1]);
319
- }
320
-
321
- async function runAuthCommand(args) {
322
- const action = args[1];
323
- if (action === 'login') {
324
- const baseUrl = getEnv('EMPIRICAL_BASE_URL', 'https://empirical.gauzza.com');
325
- await runMcpOAuthFlow(baseUrl);
326
- log('Empirical OAuth login complete. CLI session saved.');
327
- return;
328
- }
329
- clearOAuthSession();
330
- log('Empirical OAuth session cleared.');
331
- }
315
+ }
316
+
317
+ function isAuthCommand(args) {
318
+ if (args[0] === 'whoami') return true;
319
+ return args[0] === 'auth' && ['login', 'logout', 'status', 'whoami'].includes(args[1]);
320
+ }
321
+
322
+ // Shared token introspection, used by both `auth status`/`whoami` here and by `doctor`
323
+ // (doctor.js's token_type/token_expiry checks call this instead of decoding the JWT payload
324
+ // a second time) so the two commands can never silently drift on what "expired" means.
325
+ function inspectToken(token) {
326
+ if (!token) {
327
+ return { present: false, tokenType: null, expiresAt: null, expired: null };
328
+ }
329
+ const parts = token.split('.');
330
+ if (parts.length !== 3) {
331
+ return { present: true, tokenType: 'apikey', expiresAt: null, expired: false };
332
+ }
333
+ try {
334
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
335
+ if (payload && typeof payload.exp === 'number') {
336
+ const expiresAt = new Date(payload.exp * 1000).toISOString();
337
+ const expired = payload.exp < Math.floor(Date.now() / 1000);
338
+ return { present: true, tokenType: 'jwt', expiresAt, expired };
339
+ }
340
+ return { present: true, tokenType: 'jwt', expiresAt: null, expired: null };
341
+ } catch (_error) {
342
+ return { present: true, tokenType: 'jwt', expiresAt: null, expired: null };
343
+ }
344
+ }
345
+
346
+ // Gathers the same identity/workspace facts `doctor` reports (token presence/type/expiry, plus
347
+ // user + workspace from /api/v1/users/me), formatted for `auth status` / `whoami` as a single
348
+ // JSON object instead of doctor's multi-check report.
349
+ async function gatherAuthStatus() {
350
+ const token = resolveAccessToken();
351
+ const tokenInfo = inspectToken(token);
352
+
353
+ let user = null;
354
+ let workspaceId = null;
355
+ let error = null;
356
+
357
+ if (token) {
358
+ try {
359
+ const baseUrl = resolveApiBaseUrl();
360
+ const meUrl = new URL('/api/v1/users/me', baseUrl).toString();
361
+ const response = await httpJson(meUrl, 'GET', null, buildApiAuthHeaders(token));
362
+ const userData = (response && response.data) ? response.data : response;
363
+ user = {
364
+ id: userData.userId || userData.id || null,
365
+ email: userData.email || null
366
+ };
367
+ workspaceId = userData.workspaceId || userData.workspaceName || null;
368
+ } catch (apiError) {
369
+ error = apiError.message;
370
+ }
371
+ }
372
+
373
+ return {
374
+ authenticated: Boolean(token) && !error && tokenInfo.expired !== true,
375
+ tokenPresent: tokenInfo.present,
376
+ tokenType: tokenInfo.tokenType,
377
+ tokenExpiresAt: tokenInfo.expiresAt,
378
+ tokenExpired: tokenInfo.expired,
379
+ user,
380
+ workspaceId,
381
+ error
382
+ };
383
+ }
384
+
385
+ async function runAuthCommand(args) {
386
+ const action = args[0] === 'whoami' ? 'status' : args[1];
387
+
388
+ if (action === 'login') {
389
+ const baseUrl = getEnv('EMPIRICAL_BASE_URL', 'https://empirical.gauzza.com');
390
+ await runMcpOAuthFlow(baseUrl);
391
+ log('Empirical OAuth login complete. CLI session saved.');
392
+ return;
393
+ }
394
+
395
+ if (action === 'status' || action === 'whoami') {
396
+ const status = await gatherAuthStatus();
397
+ log(JSON.stringify(status));
398
+ if (!status.authenticated) {
399
+ process.exitCode = 1;
400
+ }
401
+ return;
402
+ }
403
+
404
+ if (action === 'logout') {
405
+ clearOAuthSession();
406
+ log('Empirical OAuth session cleared.');
407
+ return;
408
+ }
409
+
410
+ warn('Usage: empirical auth <login|logout|status> (or: empirical whoami)');
411
+ process.exitCode = 1;
412
+ }
332
413
 
333
414
  function buildApiAuthHeaders(token) {
334
415
  const context = globalThis.__empiricalCliAnalyticsContext || {};
@@ -780,9 +861,11 @@ async function runHeadlessRefresh(args) {
780
861
  module.exports = {
781
862
  oauthPreflight,
782
863
  openBrowser,
783
- runMcpOAuthFlow,
784
- isAuthCommand,
785
- runAuthCommand,
864
+ runMcpOAuthFlow,
865
+ isAuthCommand,
866
+ runAuthCommand,
867
+ inspectToken,
868
+ gatherAuthStatus,
786
869
  resolveOAuthCode,
787
870
  updateClaudeMcpServerAuth,
788
871
  resolveAccessToken,
package/lib/constants.js CHANGED
@@ -26,7 +26,7 @@ Errors are written to stderr as JSON. Successful output is written to stdout as
26
26
  ### query
27
27
  Search memories by natural language.
28
28
 
29
- empirical memory query --match "coding style preferences" [--top-k 3] [--category build] [--tags ts,node] [--pretty]
29
+ empirical memory query --match "coding style preferences" [--top-k 3] [--category build] [--tags ts,node] [--pretty] [--format table]
30
30
 
31
31
  Flags:
32
32
  - \`--match\` (required): natural language search string
@@ -34,6 +34,7 @@ Flags:
34
34
  - \`--category\`: filter by category
35
35
  - \`--tags\`: comma-separated tags filter
36
36
  - \`--pretty\`: pretty-print JSON output
37
+ - \`--format table\`: render results as a table instead of JSON (JSON stays the default)
37
38
 
38
39
  ### get
39
40
  Retrieve one memory by exact ID.
@@ -43,7 +44,7 @@ Retrieve one memory by exact ID.
43
44
  ### list
44
45
  Browse memories deterministically. Use \`--cursor\` from the previous response while \`pagination.hasMore\` is true.
45
46
 
46
- empirical memory list [--category project] [--tags canonical,launch] [--start-date <ISO>] [--end-date <ISO>] [--limit 20] [--cursor <cursor>] [--oldest] [--pretty]
47
+ empirical memory list [--category project] [--tags canonical,launch] [--start-date <ISO>] [--end-date <ISO>] [--limit 20] [--cursor <cursor>] [--oldest] [--pretty] [--format table]
47
48
 
48
49
  Flags:
49
50
  - \`--category\`, \`--tags\`: optional filters
@@ -52,6 +53,7 @@ Flags:
52
53
  - \`--cursor\`: continuation cursor from a previous page
53
54
  - \`--oldest\`: sort oldest first; default is newest first
54
55
  - \`--pretty\`: pretty-print JSON output
56
+ - \`--format table\`: render results as a table instead of JSON (JSON stays the default)
55
57
 
56
58
  ### record
57
59
  Save a new memory.
@@ -99,6 +101,43 @@ Flags:
99
101
  - \`--memory-id\`: use exact memory ID
100
102
  - \`--min-score\`: minimum similarity score, default 0.35
101
103
 
104
+ Bulk delete by tag, category, or everything (exactly one selector):
105
+
106
+ empirical memory delete --tag old-preference --confirm
107
+ empirical memory delete --category recipe --confirm
108
+ empirical memory delete --all --confirm
109
+
110
+ Without \`--confirm\`, always a dry-run that prints the count that would be deleted; no writes
111
+ happen. Re-run with \`--confirm\` to actually delete.
112
+
113
+ ### export / import
114
+ Export memories to a versioned, round-trippable JSON document and re-import them later.
115
+
116
+ empirical memory export --scope <personal|workspace> [--out <path>] [--workspace-id <id>]
117
+ empirical memory import <file> [--dry-run]
118
+
119
+ Export writes \`{ schemaVersion: 1, exportedAt, memories: [...] }\`. Import reads that same
120
+ shape and POSTs each row through the normal create endpoint, so imported memories get the same
121
+ dedupe and auto-link treatment as any other write. \`--dry-run\` on import prints the count that
122
+ would be imported without writing anything.
123
+
124
+ ### stats
125
+ Aggregated counts, scoped to the caller.
126
+
127
+ empirical memory stats [--format table]
128
+
129
+ Returns total count, counts by category, counts by tag, and oldest/newest memory timestamps.
130
+
131
+ ### compact
132
+ On-demand near-duplicate scan (the same logic the nightly compaction job runs), scoped to the
133
+ caller only. Dry-run by default, matching a terraform plan/apply model.
134
+
135
+ empirical memory compact [--category <cat>]
136
+ empirical memory compact --apply [--category <cat>]
137
+
138
+ Without \`--apply\`, reports proposed merge/link candidate pairs and counts with no writes.
139
+ With \`--apply\`, executes the merges and links for real and reports what was applied.
140
+
102
141
  ## Error output contract
103
142
  - Errors → stderr as JSON: \`{ "error": true, "code": "...", "message": "..." }\`
104
143
  - Success → stdout as JSON (compact by default)
@@ -207,18 +246,35 @@ Deletion dry-runs by default. \`--confirm\` calls the existing soft-delete endpo
207
246
 
208
247
  doctor: `# Empirical Doctor
209
248
 
210
- empirical doctor [--output json]
249
+ empirical doctor [--output json] [--deep]
211
250
 
212
- Checks the health of your Empirical CLI setup. Useful for diagnosing auth and connectivity issues.
251
+ Checks the health of your Empirical CLI setup, including why MCP tools might not be showing up
252
+ in a host CLI (the most common failure report).
213
253
 
214
- ## Checks performed (in order)
254
+ ## Checks performed
215
255
 
216
256
  1. **Token presence** — verifies a token exists via EMPIRICAL_ACCESS_TOKEN env, apiKey field,
217
257
  or accessToken field in the token file
218
258
  2. **Token type** — detects API key (long alphanumeric, no dots) vs JWT (3 dot-separated parts)
219
259
  3. **Token expiry** — if JWT, decodes the payload and checks the \`exp\` field; warns if no \`exp\`
220
- 4. **API connectivity** — calls GET /api/users/me; 200 = OK, anything else = FAIL
221
- 5. **Workspace** — reads workspaceId from the /api/users/me response; warns if null
260
+ 4. **API connectivity** — calls GET /api/v1/users/me; 200 = OK, anything else = FAIL
261
+ 5. **Workspace** — reads workspaceId from the /api/v1/users/me response; warns if null
262
+ 6. **MCP hosts detected** — which of codex/claude/copilot were found on this machine
263
+ 7. **MCP config registration (per detected host)** — reads that host's own config file
264
+ (\`~/.claude.json\`, \`<CODEX_HOME>/config.toml\`, \`~/.copilot/mcp-config.json\`) and confirms
265
+ the Empirical MCP server is actually registered there with the current URL
266
+ 8. **MCP reachability** — POSTs a real MCP \`initialize\` request to the MCP endpoint with the
267
+ resolved access token and confirms a valid JSON-RPC response comes back
268
+ 9. **Native plugin presence/version (per detected host)** — runs \`<host> plugin list\` and
269
+ reports the installed empirical-memory (and optional) plugin versions
270
+ 10. **Hooks presence (per detected host)** — confirms the SessionStart/UserPromptSubmit/Stop
271
+ hook scripts exist on disk and are registered in that host's hook manifest
272
+
273
+ ## --deep
274
+
275
+ Adds a live write/query/delete round trip: records a \`doctor-probe-<timestamp>\` memory, queries
276
+ it back, deletes it, and reports elapsed time for each leg. Requires a valid token; writes and
277
+ deletes exactly one throwaway memory tagged \`doctor-probe\`.
222
278
 
223
279
  ## Output format
224
280
 
@@ -422,9 +478,17 @@ The browser OAuth session is stored separately at \`~/.empirical/oauth-session.j
422
478
 
423
479
  ## Verify auth status
424
480
 
481
+ empirical auth status
482
+ empirical whoami
483
+
484
+ Prints a single JSON object: \`{ authenticated, tokenPresent, tokenType, tokenExpiresAt,
485
+ tokenExpired, user: { id, email } | null, workspaceId, error }\`. \`whoami\` is an alias for
486
+ \`auth status\`. Exits 1 if not authenticated.
487
+
425
488
  empirical doctor
426
489
 
427
- Checks token presence, type, expiry, and API connectivity.
490
+ Checks token presence, type, expiry, and API connectivity across a broader set of checks
491
+ (host MCP config registration, hooks, plugin versions).
428
492
  `,
429
493
 
430
494
  user: `# Empirical User Identity
@@ -20,20 +20,37 @@ function readDeviceConfig() {
20
20
  return readJsonIfExists(getDeviceConfigPath()) || {};
21
21
  }
22
22
 
23
- function writeDeviceConfig(config) {
24
- writeFile(getDeviceConfigPath(), `${JSON.stringify(config, null, 2)}\n`);
25
- }
26
-
27
- function readPolicyCache() {
28
- const config = readDeviceConfig();
29
- return config.empiricalPolicy || null;
30
- }
31
-
32
- function writePolicyCache(policyCache) {
33
- const config = readDeviceConfig();
34
- config.empiricalPolicy = policyCache;
35
- writeDeviceConfig(config);
36
- }
23
+ function writeDeviceConfig(config) {
24
+ writeFile(getDeviceConfigPath(), `${JSON.stringify(config, null, 2)}\n`);
25
+ }
26
+
27
+ // C-001: the installer's chosen integration mode (1 = MCP, 2 = CLI-direct, 3 = both), persisted
28
+ // so later, unattended refreshes of the empirical-memory Skill -- a plugin auto-update
29
+ // (pluginUpdateCheck.js) or a `kit sync`/`kit use` run (skillSync.js) that happens long after the
30
+ // interactive installer finished -- still pick the mode the user actually chose, not a hardcoded
31
+ // default.
32
+ function readIntegrationMode() {
33
+ const config = readDeviceConfig();
34
+ const mode = config.integrationMode;
35
+ return mode === 2 || mode === 3 ? mode : 1;
36
+ }
37
+
38
+ function writeIntegrationMode(integrationMode) {
39
+ const config = readDeviceConfig();
40
+ config.integrationMode = integrationMode === 2 || integrationMode === 3 ? integrationMode : 1;
41
+ writeDeviceConfig(config);
42
+ }
43
+
44
+ function readPolicyCache() {
45
+ const config = readDeviceConfig();
46
+ return config.empiricalPolicy || null;
47
+ }
48
+
49
+ function writePolicyCache(policyCache) {
50
+ const config = readDeviceConfig();
51
+ config.empiricalPolicy = policyCache;
52
+ writeDeviceConfig(config);
53
+ }
37
54
 
38
55
  // Ensures a deviceId exists in the local config, generating+persisting one on first use.
39
56
  // Reused across all future CLI invocations on this machine.
@@ -80,10 +97,12 @@ function writePendingReview(pendingReview) {
80
97
  module.exports = {
81
98
  getEmpiricalDir,
82
99
  getDeviceConfigPath,
83
- readDeviceConfig,
84
- writeDeviceConfig,
85
- readPolicyCache,
86
- writePolicyCache,
100
+ readDeviceConfig,
101
+ writeDeviceConfig,
102
+ readIntegrationMode,
103
+ writeIntegrationMode,
104
+ readPolicyCache,
105
+ writePolicyCache,
87
106
  ensureDeviceId,
88
107
  getSyncManifestPath,
89
108
  readSyncManifest,