@empirical-gauzza/cli 1.0.9 → 1.0.11
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 +1 -1
- package/lib/constants.js +4 -3
- package/lib/installWizard.js +127 -113
- package/lib/installers/claude.js +8 -4
- package/lib/installers/codex.js +10 -0
- package/lib/installers/copilot.js +10 -0
- package/lib/installers/hooks.js +442 -20
- package/lib/installers/promptBlocks.js +15 -3
- package/lib/workspaceCommands.js +50 -27
- package/package.json +2 -7
package/bin/empirical.js
CHANGED
package/lib/constants.js
CHANGED
|
@@ -121,9 +121,10 @@ Runs the interactive installer to connect Empirical memory to your AI coding CLI
|
|
|
121
121
|
instruction files are left unchanged
|
|
122
122
|
5. Offers optional browser OAuth login for CLI use, optional headless/API-key bootstrap for
|
|
123
123
|
automation, containers, and external runtimes, and optional work-history/scar-tissue guidance
|
|
124
|
-
6.
|
|
125
|
-
|
|
126
|
-
|
|
124
|
+
6. Installs a \`SessionStart\` hook for each selected client that calls the \`empirical\` CLI
|
|
125
|
+
directly once per session — deterministic baseline context that doesn't depend on the model
|
|
126
|
+
deciding to call a tool. Claude: \`~/.claude/settings.json\`. Codex: \`<CODEX_HOME>/hooks.json\`
|
|
127
|
+
(defaults to \`~/.codex\`). Copilot: \`~/.copilot/hooks/empirical-session-start.json\`.
|
|
127
128
|
7. Installs the native \`empirical-memory\` plugin for each selected client. The plugin owns
|
|
128
129
|
the Empirical MCP connection and all agent-facing Skills; the CLI does not generate duplicate
|
|
129
130
|
\`SKILL.md\` or \`EMPIRICAL_SKILL.md\` files.
|
package/lib/installWizard.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { log, warn, uiHeader, uiQuestion, uiSuccess, uiWarn, uiDim, uiLink, ask, askYesNo, parseSelection, formatHeadlessErrorHint } = require('./ui');
|
|
4
|
-
const { getEnv } = require('./env');
|
|
3
|
+
const { log, warn, uiHeader, uiQuestion, uiSuccess, uiWarn, uiDim, uiLink, ask, askYesNo, parseSelection, formatHeadlessErrorHint } = require('./ui');
|
|
4
|
+
const { getEnv } = require('./env');
|
|
5
5
|
const { summarizeProcessOutput } = require('./fsUtils');
|
|
6
6
|
const {
|
|
7
7
|
EMPIRICAL_MCP_NAME, EMPIRICAL_MCP_URL,
|
|
8
|
-
getCliVersion, deriveBaseUrlFromMcpUrl
|
|
8
|
+
getCliVersion, deriveBaseUrlFromMcpUrl
|
|
9
9
|
} = require('./constants');
|
|
10
10
|
const {
|
|
11
11
|
readStoredOAuthClient, writeStoredOAuthClient, getLocalOAuthClientPath,
|
|
@@ -16,22 +16,22 @@ const {
|
|
|
16
16
|
runMcpOAuthFlow, updateClaudeMcpServerAuth, resolveAccessToken
|
|
17
17
|
} = require('./auth');
|
|
18
18
|
const { performSync, printSyncSummary } = require('./skillSync');
|
|
19
|
-
const {
|
|
20
|
-
INSTALL_PROMPT_OPTIONS_DEFAULTS, collectInstallerPromptOptions,
|
|
21
|
-
} = require('./installers/promptBlocks');
|
|
19
|
+
const {
|
|
20
|
+
INSTALL_PROMPT_OPTIONS_DEFAULTS, collectInstallerPromptOptions,
|
|
21
|
+
} = require('./installers/promptBlocks');
|
|
22
22
|
const { detectInstallTargets } = require('./installers/detect');
|
|
23
|
-
const { installClaudeHooks } = require('./installers/hooks');
|
|
24
|
-
const { runCodex } = require('./installers/codex');
|
|
25
|
-
const { installNativePlugin, OPTIONAL_PLUGIN_NAMES } = require('./installers/nativePlugin');
|
|
26
|
-
const { policyFromInstallerOptions, savePolicy: persistPolicy } = require('./policyCommands');
|
|
27
|
-
|
|
28
|
-
function pluginNamesForInstallerOptions(options = {}) {
|
|
29
|
-
const names = ['empirical-memory'];
|
|
30
|
-
if (options.recordWorkHistory !== false) names.push('empirical-work-history');
|
|
31
|
-
if (options.recordScarTissue !== false) names.push('empirical-scar-tissue');
|
|
32
|
-
if (options.recordTodos === true) names.push('empirical-todo');
|
|
33
|
-
return names.filter((name) => name === 'empirical-memory' || OPTIONAL_PLUGIN_NAMES.includes(name));
|
|
34
|
-
}
|
|
23
|
+
const { installClaudeHooks, installCodexHooks, installCopilotHooks } = require('./installers/hooks');
|
|
24
|
+
const { runCodex } = require('./installers/codex');
|
|
25
|
+
const { installNativePlugin, OPTIONAL_PLUGIN_NAMES } = require('./installers/nativePlugin');
|
|
26
|
+
const { policyFromInstallerOptions, savePolicy: persistPolicy } = require('./policyCommands');
|
|
27
|
+
|
|
28
|
+
function pluginNamesForInstallerOptions(options = {}) {
|
|
29
|
+
const names = ['empirical-memory'];
|
|
30
|
+
if (options.recordWorkHistory !== false) names.push('empirical-work-history');
|
|
31
|
+
if (options.recordScarTissue !== false) names.push('empirical-scar-tissue');
|
|
32
|
+
if (options.recordTodos === true) names.push('empirical-todo');
|
|
33
|
+
return names.filter((name) => name === 'empirical-memory' || OPTIONAL_PLUGIN_NAMES.includes(name));
|
|
34
|
+
}
|
|
35
35
|
|
|
36
36
|
async function runInstallFlow() {
|
|
37
37
|
log(uiHeader(`Empirical installer v${getCliVersion()}`));
|
|
@@ -74,17 +74,17 @@ async function runInstallFlow() {
|
|
|
74
74
|
log('');
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
let promptOptions;
|
|
78
|
-
let savePolicyEnabled;
|
|
77
|
+
let promptOptions;
|
|
78
|
+
let savePolicyEnabled;
|
|
79
79
|
|
|
80
80
|
if (integrationMode === 2) {
|
|
81
|
-
// CLI-direct mode: skip MCP-specific installer questions
|
|
82
|
-
promptOptions = { ...INSTALL_PROMPT_OPTIONS_DEFAULTS };
|
|
83
|
-
savePolicyEnabled = true;
|
|
84
|
-
} else {
|
|
85
|
-
const promptSetup = await collectInstallerPromptOptions();
|
|
86
|
-
promptOptions = promptSetup.promptOptions;
|
|
87
|
-
savePolicyEnabled = promptSetup.savePolicy !== false;
|
|
81
|
+
// CLI-direct mode: skip MCP-specific installer questions
|
|
82
|
+
promptOptions = { ...INSTALL_PROMPT_OPTIONS_DEFAULTS };
|
|
83
|
+
savePolicyEnabled = true;
|
|
84
|
+
} else {
|
|
85
|
+
const promptSetup = await collectInstallerPromptOptions();
|
|
86
|
+
promptOptions = promptSetup.promptOptions;
|
|
87
|
+
savePolicyEnabled = promptSetup.savePolicy !== false;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
log(uiHeader('Installer auth setup'));
|
|
@@ -95,8 +95,8 @@ async function runInstallFlow() {
|
|
|
95
95
|
}
|
|
96
96
|
const useHeadlessDefault = getEnv('EMPIRICAL_HEADLESS_DEFAULT', 'true').toLowerCase() !== 'false';
|
|
97
97
|
const settingsUrl = `${installerBaseUrl.replace(/\/$/, '')}/app/settings`;
|
|
98
|
-
let headlessToken = null;
|
|
99
|
-
let policyAccessToken = null;
|
|
98
|
+
let headlessToken = null;
|
|
99
|
+
let policyAccessToken = null;
|
|
100
100
|
try {
|
|
101
101
|
const existingClient = readStoredOAuthClient();
|
|
102
102
|
if (getEnv('EMPIRICAL_CLIENT_ID')) {
|
|
@@ -138,12 +138,12 @@ async function runInstallFlow() {
|
|
|
138
138
|
}
|
|
139
139
|
log('');
|
|
140
140
|
|
|
141
|
-
let shouldBootstrapHeadless = useHeadlessDefault;
|
|
142
|
-
if (process.stdin.isTTY) {
|
|
143
|
-
log('Optional credential setup:');
|
|
144
|
-
log(' Browser OAuth is the normal choice for interactive CLI use.');
|
|
145
|
-
log(' Headless bootstrap creates reusable credentials for CLI use, automation, containers, and external runtimes.');
|
|
146
|
-
log(' Headless bootstrap uses Settings client credentials (client ID + client secret).');
|
|
141
|
+
let shouldBootstrapHeadless = useHeadlessDefault;
|
|
142
|
+
if (process.stdin.isTTY) {
|
|
143
|
+
log('Optional credential setup:');
|
|
144
|
+
log(' Browser OAuth is the normal choice for interactive CLI use.');
|
|
145
|
+
log(' Headless bootstrap creates reusable credentials for CLI use, automation, containers, and external runtimes.');
|
|
146
|
+
log(' Headless bootstrap uses Settings client credentials (client ID + client secret).');
|
|
147
147
|
log(' Client credentials are loaded from env/saved oauth-client.json, with prompt fallback.');
|
|
148
148
|
log(` Settings: ${uiLink(settingsUrl)}`);
|
|
149
149
|
shouldBootstrapHeadless = await askYesNo('Bootstrap headless token now?', useHeadlessDefault);
|
|
@@ -192,31 +192,31 @@ async function runInstallFlow() {
|
|
|
192
192
|
}
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
|
-
} else {
|
|
196
|
-
log('Headless auth: skipped by user choice. Interactive OAuth will be used when required.');
|
|
197
|
-
log('');
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if ((integrationMode === 2 || integrationMode === 3) && !resolveAccessToken()) {
|
|
201
|
-
try {
|
|
202
|
-
log('CLI auth: no reusable credential found. Opening browser OAuth login...');
|
|
203
|
-
await runMcpOAuthFlow(installerBaseUrl);
|
|
204
|
-
log(uiSuccess('CLI OAuth session saved. Empirical CLI commands are ready to use.'));
|
|
205
|
-
log('');
|
|
206
|
-
} catch (error) {
|
|
207
|
-
warn(uiWarn(`CLI OAuth login failed: ${error.message}`));
|
|
208
|
-
warn(' CLI commands will need a credential later: run "empirical auth login".');
|
|
209
|
-
log('');
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
if (integrationMode !== 2) {
|
|
214
|
-
const preflight = await oauthPreflight(EMPIRICAL_MCP_URL);
|
|
215
|
-
if (!preflight.ok) {
|
|
216
|
-
warn(`OAuth preflight warning: ${preflight.message}`);
|
|
217
|
-
warn('Codex/Claude setup may fail until the hosted OAuth login page is updated.');
|
|
218
|
-
log('');
|
|
219
|
-
}
|
|
195
|
+
} else {
|
|
196
|
+
log('Headless auth: skipped by user choice. Interactive OAuth will be used when required.');
|
|
197
|
+
log('');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if ((integrationMode === 2 || integrationMode === 3) && !resolveAccessToken()) {
|
|
201
|
+
try {
|
|
202
|
+
log('CLI auth: no reusable credential found. Opening browser OAuth login...');
|
|
203
|
+
await runMcpOAuthFlow(installerBaseUrl);
|
|
204
|
+
log(uiSuccess('CLI OAuth session saved. Empirical CLI commands are ready to use.'));
|
|
205
|
+
log('');
|
|
206
|
+
} catch (error) {
|
|
207
|
+
warn(uiWarn(`CLI OAuth login failed: ${error.message}`));
|
|
208
|
+
warn(' CLI commands will need a credential later: run "empirical auth login".');
|
|
209
|
+
log('');
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (integrationMode !== 2) {
|
|
214
|
+
const preflight = await oauthPreflight(EMPIRICAL_MCP_URL);
|
|
215
|
+
if (!preflight.ok) {
|
|
216
|
+
warn(`OAuth preflight warning: ${preflight.message}`);
|
|
217
|
+
warn('Codex/Claude setup may fail until the hosted OAuth login page is updated.');
|
|
218
|
+
log('');
|
|
219
|
+
}
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
let failures = 0;
|
|
@@ -228,26 +228,40 @@ async function runInstallFlow() {
|
|
|
228
228
|
const target = targets[index];
|
|
229
229
|
log(uiHeader(`Step 2/2: Installing for ${target.label}...`));
|
|
230
230
|
try {
|
|
231
|
-
if (integrationMode === 2) {
|
|
232
|
-
// CLI-direct: skip MCP registration; the native plugin owns guidance.
|
|
233
|
-
const pluginNames = pluginNamesForInstallerOptions(promptOptions);
|
|
234
|
-
installNativePlugin(target.id, pluginNames);
|
|
235
|
-
log(` - ${target.label} native plugins installed: ${pluginNames.join(', ')}.`);
|
|
236
|
-
if (target.id === 'claude') {
|
|
237
|
-
try {
|
|
238
|
-
installClaudeHooks();
|
|
231
|
+
if (integrationMode === 2) {
|
|
232
|
+
// CLI-direct: skip MCP registration; the native plugin owns guidance.
|
|
233
|
+
const pluginNames = pluginNamesForInstallerOptions(promptOptions);
|
|
234
|
+
installNativePlugin(target.id, pluginNames);
|
|
235
|
+
log(` - ${target.label} native plugins installed: ${pluginNames.join(', ')}.`);
|
|
236
|
+
if (target.id === 'claude') {
|
|
237
|
+
try {
|
|
238
|
+
installClaudeHooks(pluginNames);
|
|
239
239
|
} catch (error) {
|
|
240
240
|
warn(` - Could not install Claude hooks for deterministic memory lookups: ${error.message}`);
|
|
241
241
|
}
|
|
242
|
-
}
|
|
243
|
-
|
|
242
|
+
}
|
|
243
|
+
if (target.id === 'codex') {
|
|
244
|
+
try {
|
|
245
|
+
installCodexHooks(pluginNames);
|
|
246
|
+
} catch (error) {
|
|
247
|
+
warn(` - Could not install Codex hooks for deterministic memory lookups: ${error.message}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (target.id === 'copilot') {
|
|
251
|
+
try {
|
|
252
|
+
installCopilotHooks(pluginNames);
|
|
253
|
+
} catch (error) {
|
|
254
|
+
warn(` - Could not install Copilot hooks for deterministic memory lookups: ${error.message}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
log(uiSuccess(`Done: ${target.label} (CLI-direct mode)`));
|
|
244
258
|
} else {
|
|
245
259
|
// Mode 1 or 3: run MCP install normally
|
|
246
|
-
const pluginNames = pluginNamesForInstallerOptions(promptOptions);
|
|
247
|
-
const installResult = target.install(headlessToken, promptOptions, {
|
|
248
|
-
writeGlobalInstructions: false,
|
|
249
|
-
pluginNames
|
|
250
|
-
});
|
|
260
|
+
const pluginNames = pluginNamesForInstallerOptions(promptOptions);
|
|
261
|
+
const installResult = target.install(headlessToken, promptOptions, {
|
|
262
|
+
writeGlobalInstructions: false,
|
|
263
|
+
pluginNames
|
|
264
|
+
});
|
|
251
265
|
log(uiSuccess(`Done: ${target.label}`));
|
|
252
266
|
if (target.id === 'codex') {
|
|
253
267
|
codexInstalled = true;
|
|
@@ -256,7 +270,7 @@ async function runInstallFlow() {
|
|
|
256
270
|
if (target.id === 'claude') {
|
|
257
271
|
claudeInstalled = true;
|
|
258
272
|
}
|
|
259
|
-
}
|
|
273
|
+
}
|
|
260
274
|
} catch (error) {
|
|
261
275
|
failures += 1;
|
|
262
276
|
warn(uiWarn(`Failed: ${target.label} - ${error.message}`));
|
|
@@ -282,11 +296,11 @@ async function runInstallFlow() {
|
|
|
282
296
|
} else {
|
|
283
297
|
const detail = summarizeProcessOutput(loginResult);
|
|
284
298
|
warn(uiWarn(`Codex MCP authentication returned a non-zero status${detail ? `: ${detail}` : '.'}`));
|
|
285
|
-
warn('Re-run `empirical install` to retry automatic authentication.');
|
|
299
|
+
warn('Re-run `empirical install` to retry automatic authentication.');
|
|
286
300
|
}
|
|
287
301
|
} catch (error) {
|
|
288
302
|
warn(uiWarn(`Codex MCP authentication failed: ${error.message}`));
|
|
289
|
-
warn('Re-run `empirical install` to retry automatic authentication.');
|
|
303
|
+
warn('Re-run `empirical install` to retry automatic authentication.');
|
|
290
304
|
}
|
|
291
305
|
log('');
|
|
292
306
|
}
|
|
@@ -294,7 +308,7 @@ async function runInstallFlow() {
|
|
|
294
308
|
// Offer to authenticate Empirical MCP after Claude installation.
|
|
295
309
|
// Prefer the permanent API key from the headless bootstrap (never expires)
|
|
296
310
|
// over a short-lived JWT from the browser OAuth flow.
|
|
297
|
-
if (claudeInstalled && process.stdin.isTTY) {
|
|
311
|
+
if (claudeInstalled && process.stdin.isTTY) {
|
|
298
312
|
log(uiHeader('Empirical MCP authentication'));
|
|
299
313
|
const permanentToken = headlessToken?.apiKey || '';
|
|
300
314
|
if (permanentToken) {
|
|
@@ -310,45 +324,45 @@ async function runInstallFlow() {
|
|
|
310
324
|
try {
|
|
311
325
|
log('');
|
|
312
326
|
log(uiQuestion('Authenticating Empirical MCP...'));
|
|
313
|
-
const mcpToken = await runMcpOAuthFlow(installerBaseUrl);
|
|
314
|
-
policyAccessToken = mcpToken.accessToken;
|
|
315
|
-
updateClaudeMcpServerAuth(EMPIRICAL_MCP_NAME, mcpToken.accessToken);
|
|
327
|
+
const mcpToken = await runMcpOAuthFlow(installerBaseUrl);
|
|
328
|
+
policyAccessToken = mcpToken.accessToken;
|
|
329
|
+
updateClaudeMcpServerAuth(EMPIRICAL_MCP_NAME, mcpToken.accessToken);
|
|
316
330
|
log(uiSuccess('Empirical MCP authenticated successfully in Claude!'));
|
|
317
|
-
log(' Note: this OAuth token expires in ~30 days. Re-run the installer with');
|
|
318
|
-
log(' your Settings client credentials to get a permanent credential instead.');
|
|
331
|
+
log(' Note: this OAuth token expires in ~30 days. Re-run the installer with');
|
|
332
|
+
log(' your Settings client credentials to get a permanent credential instead.');
|
|
319
333
|
} catch (error) {
|
|
320
334
|
warn(uiWarn(`MCP authentication failed: ${error.message}`));
|
|
321
335
|
warn('You can authenticate later from Claude Code by using the /mcp command.');
|
|
322
336
|
}
|
|
323
337
|
}
|
|
324
|
-
log('');
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// Persist the selected behavior after the installer has had a chance to complete OAuth/MCP
|
|
328
|
-
// authentication. In MCP-only mode, authentication happens after native installation.
|
|
329
|
-
try {
|
|
330
|
-
const accessToken = resolveAccessToken() || policyAccessToken;
|
|
331
|
-
if (accessToken && savePolicyEnabled) {
|
|
332
|
-
await persistPolicy(policyFromInstallerOptions(promptOptions), accessToken);
|
|
333
|
-
log(uiSuccess('Empirical behavior policy saved for native skills.'));
|
|
334
|
-
} else if (accessToken) {
|
|
335
|
-
log('Empirical behavior policy not saved by user choice; native skills will use safe defaults.');
|
|
336
|
-
} else {
|
|
337
|
-
warn('Empirical behavior policy was not saved: no authenticated CLI credential is available yet.');
|
|
338
|
-
}
|
|
339
|
-
log('');
|
|
340
|
-
} catch (error) {
|
|
341
|
-
warn(uiWarn(`Empirical behavior policy could not be saved: ${error.message}`));
|
|
342
|
-
log('');
|
|
343
|
-
}
|
|
338
|
+
log('');
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Persist the selected behavior after the installer has had a chance to complete OAuth/MCP
|
|
342
|
+
// authentication. In MCP-only mode, authentication happens after native installation.
|
|
343
|
+
try {
|
|
344
|
+
const accessToken = resolveAccessToken() || policyAccessToken;
|
|
345
|
+
if (accessToken && savePolicyEnabled) {
|
|
346
|
+
await persistPolicy(policyFromInstallerOptions(promptOptions), accessToken);
|
|
347
|
+
log(uiSuccess('Empirical behavior policy saved for native skills.'));
|
|
348
|
+
} else if (accessToken) {
|
|
349
|
+
log('Empirical behavior policy not saved by user choice; native skills will use safe defaults.');
|
|
350
|
+
} else {
|
|
351
|
+
warn('Empirical behavior policy was not saved: no authenticated CLI credential is available yet.');
|
|
352
|
+
}
|
|
353
|
+
log('');
|
|
354
|
+
} catch (error) {
|
|
355
|
+
warn(uiWarn(`Empirical behavior policy could not be saved: ${error.message}`));
|
|
356
|
+
log('');
|
|
357
|
+
}
|
|
344
358
|
|
|
345
359
|
// US-011: independent of whether any Kit exists or `kit use` has ever been run, also sync
|
|
346
360
|
// the active device's resolved (builtin ∪ Default Kit ∪ active named Kit) Skill set into
|
|
347
361
|
// every detected agent's skill directory now, so a brand-new device gets the Default Kit's
|
|
348
362
|
// authored Skills (if any exist yet — likely none for a fresh account, which is fine; the
|
|
349
363
|
// sync just writes zero non-builtin Skills) immediately. This does NOT touch the
|
|
350
|
-
// Native plugin installation above owns the empirical-memory Skill. The generalized Kit
|
|
351
|
-
// writer below must continue to exclude the builtin placeholder with the same name.
|
|
364
|
+
// Native plugin installation above owns the empirical-memory Skill. The generalized Kit
|
|
365
|
+
// writer below must continue to exclude the builtin placeholder with the same name.
|
|
352
366
|
try {
|
|
353
367
|
const syncAccessToken = resolveAccessToken();
|
|
354
368
|
if (syncAccessToken) {
|
|
@@ -370,13 +384,13 @@ async function runInstallFlow() {
|
|
|
370
384
|
log('');
|
|
371
385
|
}
|
|
372
386
|
|
|
373
|
-
const completionMessage = integrationMode === 2
|
|
374
|
-
? 'Install complete. Next time you open the selected CLI(s), Empirical CLI commands should be available.'
|
|
375
|
-
: integrationMode === 3
|
|
376
|
-
? 'Install complete. Next time you open the selected CLI(s), Empirical MCP and CLI commands should be available.'
|
|
377
|
-
: 'Install complete. Next time you open the selected CLI(s), Empirical MCP should be available.';
|
|
378
|
-
log(uiSuccess(completionMessage));
|
|
379
|
-
}
|
|
387
|
+
const completionMessage = integrationMode === 2
|
|
388
|
+
? 'Install complete. Next time you open the selected CLI(s), Empirical CLI commands should be available.'
|
|
389
|
+
: integrationMode === 3
|
|
390
|
+
? 'Install complete. Next time you open the selected CLI(s), Empirical MCP and CLI commands should be available.'
|
|
391
|
+
: 'Install complete. Next time you open the selected CLI(s), Empirical MCP should be available.';
|
|
392
|
+
log(uiSuccess(completionMessage));
|
|
393
|
+
}
|
|
380
394
|
|
|
381
395
|
module.exports = {
|
|
382
396
|
runInstallFlow
|
package/lib/installers/claude.js
CHANGED
|
@@ -22,15 +22,19 @@ function isClaudeServerConfigured(name) {
|
|
|
22
22
|
function installClaude(headlessToken, promptOptions = INSTALL_PROMPT_OPTIONS_DEFAULTS, installOptions = {}) {
|
|
23
23
|
log(' - Claude global guidance file unchanged; native plugin guidance is authoritative.');
|
|
24
24
|
|
|
25
|
+
// installNativePlugin throws if the plugin install/update genuinely fails (see
|
|
26
|
+
// assertPluginInstallSucceeded in nativePlugin.js) - hooks are only installed AFTER this
|
|
27
|
+
// succeeds, never before, so a failed/skipped plugin install never leaves an orphaned hook
|
|
28
|
+
// pointing at a plugin that isn't actually present.
|
|
29
|
+
installNativePlugin('claude', installOptions.pluginNames);
|
|
30
|
+
log(` - Claude native plugins installed: ${(installOptions.pluginNames || ['empirical-memory']).join(', ')}.`);
|
|
31
|
+
|
|
25
32
|
try {
|
|
26
|
-
installClaudeHooks();
|
|
33
|
+
installClaudeHooks(installOptions.pluginNames);
|
|
27
34
|
} catch (error) {
|
|
28
35
|
warn(` - Could not install Claude hooks for deterministic memory lookups: ${error.message}`);
|
|
29
36
|
}
|
|
30
37
|
|
|
31
|
-
installNativePlugin('claude', installOptions.pluginNames);
|
|
32
|
-
log(` - Claude native plugins installed: ${(installOptions.pluginNames || ['empirical-memory']).join(', ')}.`);
|
|
33
|
-
|
|
34
38
|
if (!commandExists('claude')) {
|
|
35
39
|
warn(' - Claude CLI not found on PATH. Skipped Claude MCP registration.');
|
|
36
40
|
return;
|
package/lib/installers/codex.js
CHANGED
|
@@ -7,6 +7,7 @@ const {
|
|
|
7
7
|
EMPIRICAL_MCP_NAME, EMPIRICAL_MCP_LEGACY_NAMES, EMPIRICAL_MCP_URL
|
|
8
8
|
} = require('../constants');
|
|
9
9
|
const { INSTALL_PROMPT_OPTIONS_DEFAULTS } = require('./promptBlocks');
|
|
10
|
+
const { installCodexHooks } = require('./hooks');
|
|
10
11
|
const { installNativePlugin } = require('./nativePlugin');
|
|
11
12
|
|
|
12
13
|
function runCodex(args, options = {}) {
|
|
@@ -21,9 +22,18 @@ function isCodexServerConfigured(name) {
|
|
|
21
22
|
function installCodex(headlessToken, promptOptions = INSTALL_PROMPT_OPTIONS_DEFAULTS, installOptions = {}) {
|
|
22
23
|
log(' - Codex global AGENTS instructions unchanged; native plugin guidance is authoritative.');
|
|
23
24
|
|
|
25
|
+
// installNativePlugin throws on genuine failure (see assertPluginInstallSucceeded in
|
|
26
|
+
// nativePlugin.js) - hooks install only AFTER this succeeds, never before, so a failed/skipped
|
|
27
|
+
// plugin install never leaves an orphaned hook pointing at a plugin that isn't present.
|
|
24
28
|
installNativePlugin('codex', installOptions.pluginNames);
|
|
25
29
|
log(` - Codex native plugins installed: ${(installOptions.pluginNames || ['empirical-memory']).join(', ')}.`);
|
|
26
30
|
|
|
31
|
+
try {
|
|
32
|
+
installCodexHooks(installOptions.pluginNames);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
warn(` - Could not install Codex hooks for deterministic memory lookups: ${error.message}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
27
37
|
if (!commandExists('codex')) {
|
|
28
38
|
warn(' - Codex CLI not found on PATH. Skipped Codex MCP registration.');
|
|
29
39
|
return;
|
|
@@ -10,6 +10,7 @@ const {
|
|
|
10
10
|
EMPIRICAL_MCP_NAME, EMPIRICAL_MCP_LEGACY_NAMES, EMPIRICAL_MCP_URL, COPILOT_OAUTH_CLIENT_ID
|
|
11
11
|
} = require('../constants');
|
|
12
12
|
const { INSTALL_PROMPT_OPTIONS_DEFAULTS } = require('./promptBlocks');
|
|
13
|
+
const { installCopilotHooks } = require('./hooks');
|
|
13
14
|
const { installNativePlugin } = require('./nativePlugin');
|
|
14
15
|
|
|
15
16
|
function installCopilot(headlessToken, promptOptions = INSTALL_PROMPT_OPTIONS_DEFAULTS, installOptions = {}) {
|
|
@@ -62,9 +63,18 @@ function installCopilot(headlessToken, promptOptions = INSTALL_PROMPT_OPTIONS_DE
|
|
|
62
63
|
|
|
63
64
|
log(' - Copilot global instructions unchanged; native plugin guidance is authoritative.');
|
|
64
65
|
|
|
66
|
+
// installNativePlugin throws on genuine failure (see assertPluginInstallSucceeded in
|
|
67
|
+
// nativePlugin.js) - hooks install only AFTER this succeeds, never before, so a failed/skipped
|
|
68
|
+
// plugin install never leaves an orphaned hook pointing at a plugin that isn't present.
|
|
65
69
|
installNativePlugin('copilot', installOptions.pluginNames);
|
|
66
70
|
log(` - Copilot native plugins installed: ${(installOptions.pluginNames || ['empirical-memory']).join(', ')}.`);
|
|
67
71
|
|
|
72
|
+
try {
|
|
73
|
+
installCopilotHooks(installOptions.pluginNames);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
warn(` - Could not install Copilot hooks for deterministic memory lookups: ${error.message}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
68
78
|
log(' - Copilot MCP config updated.');
|
|
69
79
|
if (storedClient?.clientId) {
|
|
70
80
|
log(` - Copilot OAuth client loaded from ${getLocalOAuthClientPath()}.`);
|
package/lib/installers/hooks.js
CHANGED
|
@@ -4,11 +4,73 @@ const os = require('os');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const { log } = require('../ui');
|
|
6
6
|
const { ensureDir, writeFile, readJsonIfExists } = require('../fsUtils');
|
|
7
|
+
const { normalizePluginNames } = require('./nativePlugin');
|
|
7
8
|
|
|
8
9
|
const CLAUDE_HOOK_SESSION_START_SOURCE = `#!/usr/bin/env node
|
|
9
10
|
'use strict';
|
|
10
11
|
|
|
11
12
|
var spawnSync = require('child_process').spawnSync;
|
|
13
|
+
var fs = require('fs');
|
|
14
|
+
var os = require('os');
|
|
15
|
+
var path = require('path');
|
|
16
|
+
|
|
17
|
+
// Diagnostic breadcrumb log: this hook's own stdout is only visible to Claude Code's SessionStart
|
|
18
|
+
// machinery, not to a human debugging "did this actually fire" after the fact. Every run appends
|
|
19
|
+
// one line here (bounded — see trimLogIfLarge) regardless of success/failure, so firing can be
|
|
20
|
+
// verified by checking file growth/timestamps after starting or resuming a session, without
|
|
21
|
+
// depending on server-side analytics that don't currently tag hook-originated calls.
|
|
22
|
+
var LOG_PATH = path.join(os.homedir(), '.empirical', 'hooks', 'session-start.log');
|
|
23
|
+
var MAX_LOG_BYTES = 200 * 1024;
|
|
24
|
+
|
|
25
|
+
function appendLog(line) {
|
|
26
|
+
try {
|
|
27
|
+
fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
|
|
28
|
+
fs.appendFileSync(LOG_PATH, '[' + new Date().toISOString() + '] ' + line + '\\n');
|
|
29
|
+
var stat = fs.statSync(LOG_PATH);
|
|
30
|
+
if (stat.size > MAX_LOG_BYTES) {
|
|
31
|
+
var tail = fs.readFileSync(LOG_PATH, 'utf8').slice(-MAX_LOG_BYTES / 2);
|
|
32
|
+
fs.writeFileSync(LOG_PATH, tail);
|
|
33
|
+
}
|
|
34
|
+
} catch (e) {
|
|
35
|
+
// Diagnostics must never break session start.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// On Windows, npm's global "empirical" command is a .cmd shim, and .cmd/.bat files can only be
|
|
40
|
+
// executed via the OS shell (cmd.exe), never as a native process — this is an OS constraint, not
|
|
41
|
+
// a Node quirk. But spawnSync's shell:true path joins argv into a single command-line string for
|
|
42
|
+
// cmd.exe to reparse, and Node does not escape special shell characters (spaces, commas, etc.) in
|
|
43
|
+
// that join for you. A --match value like "project context, conventions, recent decisions, user
|
|
44
|
+
// preferences" gets mangled by that reparse, silently truncating the search query and returning
|
|
45
|
+
// far fewer/worse matches than the same query run without a shell in between. The fix: resolve
|
|
46
|
+
// the shim's REAL target (a plain .js file) and invoke that directly via node.exe with shell:false
|
|
47
|
+
// — node.exe is a true native executable, so no shell/reparsing step exists to mangle args.
|
|
48
|
+
function resolveEmpiricalEntryPoint() {
|
|
49
|
+
if (process.platform !== 'win32') {
|
|
50
|
+
// POSIX global installs are usually a symlink straight to the JS entry (or a shebang script)
|
|
51
|
+
// — spawnSync already runs these directly without a shell, so no mangling risk exists there.
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
// Parse the .cmd shim itself rather than hardcoding an npm package scope: the published
|
|
56
|
+
// package has already been renamed once (was the empirical scope, now a different scope
|
|
57
|
+
// entirely - the old one is 404 on the registry), so hardcoding either name is a guaranteed
|
|
58
|
+
// future break. npm-generated shims all reference their target relative to dp0 (the shim's
|
|
59
|
+
// own directory) as a node_modules-rooted path ending in bin then a .js file - extracting
|
|
60
|
+
// that pattern works regardless of what the package is scoped or renamed to.
|
|
61
|
+
var where = spawnSync('where', ['empirical.cmd'], { encoding: 'utf8', timeout: 5000, shell: true });
|
|
62
|
+
if (where.status !== 0 || !where.stdout) return null;
|
|
63
|
+
var cmdPath = where.stdout.split(/\\r?\\n/).map(function (s) { return s.trim(); }).filter(Boolean)[0];
|
|
64
|
+
if (!cmdPath || !fs.existsSync(cmdPath)) return null;
|
|
65
|
+
var cmdContent = fs.readFileSync(cmdPath, 'utf8');
|
|
66
|
+
var match = cmdContent.match(/"%dp0%\\\\(node_modules\\\\.+?\\.js)"/);
|
|
67
|
+
if (!match) return null;
|
|
68
|
+
var candidate = path.join(path.dirname(cmdPath), match[1]);
|
|
69
|
+
return fs.existsSync(candidate) ? candidate : null;
|
|
70
|
+
} catch (e) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
12
74
|
|
|
13
75
|
function commandExists(cmd) {
|
|
14
76
|
var probe = process.platform === 'win32'
|
|
@@ -17,22 +79,45 @@ function commandExists(cmd) {
|
|
|
17
79
|
return probe.status === 0;
|
|
18
80
|
}
|
|
19
81
|
|
|
82
|
+
function runMemoryQuery(args) {
|
|
83
|
+
var entryPoint = resolveEmpiricalEntryPoint();
|
|
84
|
+
if (entryPoint) {
|
|
85
|
+
appendLog('resolved direct entry point: ' + entryPoint);
|
|
86
|
+
return spawnSync(process.execPath, [entryPoint].concat(args), {
|
|
87
|
+
encoding: 'utf8',
|
|
88
|
+
timeout: 10000,
|
|
89
|
+
shell: false
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Fallback: could not resolve a direct entry point (non-Windows, or resolution failed) — run
|
|
94
|
+
// the "empirical" command as before. On POSIX this is shell:false and safe. On Windows this is
|
|
95
|
+
// shell:true and CAN still mangle args as described above; it only triggers if the direct-entry
|
|
96
|
+
// resolution above failed, so degraded (not absent) results are the acceptable worst case here.
|
|
97
|
+
appendLog('direct entry point not resolved, falling back to shell invocation (shell=' + (process.platform === 'win32') + ')');
|
|
98
|
+
return spawnSync('empirical', args, {
|
|
99
|
+
encoding: 'utf8',
|
|
100
|
+
timeout: 10000,
|
|
101
|
+
shell: process.platform === 'win32'
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
20
105
|
function main() {
|
|
106
|
+
appendLog('hook fired (pid=' + process.pid + ', platform=' + process.platform + ')');
|
|
107
|
+
|
|
21
108
|
if (!commandExists('empirical')) {
|
|
109
|
+
appendLog('empirical CLI not found on PATH, exiting');
|
|
22
110
|
process.exit(0);
|
|
23
111
|
}
|
|
24
112
|
|
|
25
|
-
var result =
|
|
113
|
+
var result = runMemoryQuery([
|
|
26
114
|
'memory', 'query',
|
|
27
115
|
'--match', 'project context, conventions, recent decisions, user preferences',
|
|
28
116
|
'--top-k', '8'
|
|
29
|
-
]
|
|
30
|
-
encoding: 'utf8',
|
|
31
|
-
timeout: 10000,
|
|
32
|
-
shell: process.platform === 'win32'
|
|
33
|
-
});
|
|
117
|
+
]);
|
|
34
118
|
|
|
35
119
|
if (!result || result.status !== 0 || !result.stdout) {
|
|
120
|
+
appendLog('memory query failed or returned no stdout (status=' + (result && result.status) + ', stderr=' + (result && result.stderr ? String(result.stderr).slice(0, 300) : '') + ')');
|
|
36
121
|
process.exit(0);
|
|
37
122
|
}
|
|
38
123
|
|
|
@@ -40,13 +125,17 @@ function main() {
|
|
|
40
125
|
try {
|
|
41
126
|
results = JSON.parse(result.stdout);
|
|
42
127
|
} catch (e) {
|
|
128
|
+
appendLog('failed to parse memory query stdout as JSON: ' + e.message);
|
|
43
129
|
process.exit(0);
|
|
44
130
|
}
|
|
45
131
|
|
|
46
132
|
if (!Array.isArray(results) || results.length === 0) {
|
|
133
|
+
appendLog('memory query returned zero results');
|
|
47
134
|
process.exit(0);
|
|
48
135
|
}
|
|
49
136
|
|
|
137
|
+
appendLog('memory query returned ' + results.length + ' result(s), injecting into session context');
|
|
138
|
+
|
|
50
139
|
var lines = results.slice(0, 8).map(function (r) {
|
|
51
140
|
var category = r && r.category ? r.category : 'memory';
|
|
52
141
|
var summary = r && r.summary ? r.summary : '';
|
|
@@ -60,6 +149,204 @@ function main() {
|
|
|
60
149
|
main();
|
|
61
150
|
`;
|
|
62
151
|
|
|
152
|
+
// EMPIRICAL_HOOK_STOP_SOURCE: fires once per assistant turn (Claude: Stop; Codex: Stop, turn-
|
|
153
|
+
// scoped per codex-rs/hooks/src/engine/dispatcher.rs; Copilot: agentStop, "the main agent
|
|
154
|
+
// finishes a turn" per GitHub's own hooks reference). This is the write-side counterpart to the
|
|
155
|
+
// SessionStart hook above (which only covers recall, once per session boundary) - deliberately
|
|
156
|
+
// simple: it does NOT parse the transcript to detect whether memory-worthy activity happened
|
|
157
|
+
// this turn (transcript formats differ across all three platforms and getting that heuristic
|
|
158
|
+
// wrong silently - the exact class of bug this session spent hours finding - is worse than an
|
|
159
|
+
// unconditional reminder). It never calls the empirical CLI and makes no network call, so the
|
|
160
|
+
// "wasteful per-turn hook" concern that killed the old per-turn UserPromptSubmit hook (see
|
|
161
|
+
// installClaudeHooks below) does not apply here - this is a single static stdout write.
|
|
162
|
+
// ALWAYS exits 0: per Claude Code's documented Stop hook contract, exit 2 forces the agent to
|
|
163
|
+
// keep responding (a blocking decision) - a bug in a hook that's supposed to be an informational
|
|
164
|
+
// nudge must never be able to trap a session in a forced-continuation loop, so this is
|
|
165
|
+
// unconditional regardless of any error encountered.
|
|
166
|
+
const EMPIRICAL_HOOK_STOP_SOURCE = `#!/usr/bin/env node
|
|
167
|
+
'use strict';
|
|
168
|
+
|
|
169
|
+
var fs = require('fs');
|
|
170
|
+
var os = require('os');
|
|
171
|
+
var path = require('path');
|
|
172
|
+
|
|
173
|
+
var LOG_PATH = path.join(os.homedir(), '.empirical', 'hooks', 'stop.log');
|
|
174
|
+
var MAX_LOG_BYTES = 200 * 1024;
|
|
175
|
+
|
|
176
|
+
function appendLog(line) {
|
|
177
|
+
try {
|
|
178
|
+
fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
|
|
179
|
+
fs.appendFileSync(LOG_PATH, '[' + new Date().toISOString() + '] ' + line + '\\n');
|
|
180
|
+
var stat = fs.statSync(LOG_PATH);
|
|
181
|
+
if (stat.size > MAX_LOG_BYTES) {
|
|
182
|
+
var tail = fs.readFileSync(LOG_PATH, 'utf8').slice(-MAX_LOG_BYTES / 2);
|
|
183
|
+
fs.writeFileSync(LOG_PATH, tail);
|
|
184
|
+
}
|
|
185
|
+
} catch (e) {
|
|
186
|
+
// Diagnostics must never break the hook.
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function readStdinPayload() {
|
|
191
|
+
try {
|
|
192
|
+
var raw = fs.readFileSync(0, 'utf8');
|
|
193
|
+
return raw ? JSON.parse(raw) : {};
|
|
194
|
+
} catch (e) {
|
|
195
|
+
return {};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function main() {
|
|
200
|
+
var payload = readStdinPayload();
|
|
201
|
+
|
|
202
|
+
// Defensive loop guard: if a Stop hook's own forced-continuation output somehow triggers
|
|
203
|
+
// another Stop event before the agent produces new output, stop_hook_active (Claude's documented
|
|
204
|
+
// field name for this) prevents a reminder-print-loop. This hook never sets decision:block
|
|
205
|
+
// itself, but checking costs nothing and protects against any future change that does.
|
|
206
|
+
if (payload && payload.stop_hook_active === true) {
|
|
207
|
+
appendLog('stop_hook_active=true, skipping reminder');
|
|
208
|
+
process.exit(0);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
appendLog('stop hook fired (pid=' + process.pid + ', platform=' + process.platform + ')');
|
|
212
|
+
|
|
213
|
+
console.log('[Empirical reminder] Before finishing: if this turn included a completed decision, a corrected mistake, or meaningful work worth remembering, record it now via the empirical-memory skill or \`empirical memory record\` - do not wait to be asked.');
|
|
214
|
+
process.exit(0);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
main();
|
|
218
|
+
`;
|
|
219
|
+
|
|
220
|
+
// Claude-only variant of the Stop reminder above. Claude Code's Stop hook contract is the one
|
|
221
|
+
// platform of the three we've actually confirmed supports safe forced-continuation: exit code 2
|
|
222
|
+
// feeds stderr back to Claude as the reason and blocks the stop, and the stop_hook_active field on
|
|
223
|
+
// the next invocation's stdin payload tells us a forced-continuation already happened for this
|
|
224
|
+
// turn (Claude sets it specifically so a hook can avoid re-blocking and trapping the session in a
|
|
225
|
+
// loop). EMPIRICAL_HOOK_STOP_SOURCE above already reads stop_hook_active defensively but never
|
|
226
|
+
// blocks, so on Claude the reminder was silently logged and never reached the model - this variant
|
|
227
|
+
// is the same script with the loop guard actually load-bearing: block once per turn, never twice.
|
|
228
|
+
// Codex/Copilot keep using the silent EMPIRICAL_HOOK_STOP_SOURCE until their own Stop/agentStop
|
|
229
|
+
// blocking semantics are confirmed against source the way Claude's was here.
|
|
230
|
+
const CLAUDE_HOOK_STOP_SOURCE = `#!/usr/bin/env node
|
|
231
|
+
'use strict';
|
|
232
|
+
|
|
233
|
+
var fs = require('fs');
|
|
234
|
+
var os = require('os');
|
|
235
|
+
var path = require('path');
|
|
236
|
+
|
|
237
|
+
var LOG_PATH = path.join(os.homedir(), '.empirical', 'hooks', 'stop.log');
|
|
238
|
+
var MAX_LOG_BYTES = 200 * 1024;
|
|
239
|
+
|
|
240
|
+
function appendLog(line) {
|
|
241
|
+
try {
|
|
242
|
+
fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
|
|
243
|
+
fs.appendFileSync(LOG_PATH, '[' + new Date().toISOString() + '] ' + line + '\\n');
|
|
244
|
+
var stat = fs.statSync(LOG_PATH);
|
|
245
|
+
if (stat.size > MAX_LOG_BYTES) {
|
|
246
|
+
var tail = fs.readFileSync(LOG_PATH, 'utf8').slice(-MAX_LOG_BYTES / 2);
|
|
247
|
+
fs.writeFileSync(LOG_PATH, tail);
|
|
248
|
+
}
|
|
249
|
+
} catch (e) {
|
|
250
|
+
// Diagnostics must never break the hook.
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function readStdinPayload() {
|
|
255
|
+
try {
|
|
256
|
+
var raw = fs.readFileSync(0, 'utf8');
|
|
257
|
+
return raw ? JSON.parse(raw) : {};
|
|
258
|
+
} catch (e) {
|
|
259
|
+
return {};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function main() {
|
|
264
|
+
var payload = readStdinPayload();
|
|
265
|
+
|
|
266
|
+
// stop_hook_active === true means Claude is re-invoking this hook because a PRIOR Stop hook
|
|
267
|
+
// (possibly this same one) already forced a continuation this turn. Blocking again here would
|
|
268
|
+
// trap the session in a forced-continuation loop, so this is the one case that must always exit
|
|
269
|
+
// 0 with no reminder, unconditionally.
|
|
270
|
+
if (payload && payload.stop_hook_active === true) {
|
|
271
|
+
appendLog('stop_hook_active=true, skipping reminder (already blocked once this turn)');
|
|
272
|
+
process.exit(0);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
appendLog('stop hook fired (pid=' + process.pid + ', platform=' + process.platform + '), blocking once to surface reminder');
|
|
276
|
+
|
|
277
|
+
// Exit 2 is Claude Code's documented Stop-hook blocking signal: stderr is fed back to Claude as
|
|
278
|
+
// context and the turn is forced to continue instead of ending, which is what actually gets this
|
|
279
|
+
// reminder read (a plain stdout console.log + exit 0 is only logged, never seen by the model -
|
|
280
|
+
// this was this hook's exact original bug).
|
|
281
|
+
process.stderr.write('[Empirical reminder] Before finishing: if this turn included a completed decision, a corrected mistake, or meaningful work worth remembering, record it now via the empirical-memory skill or \`empirical memory record\` - do not wait to be asked.\\n');
|
|
282
|
+
process.exit(2);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
main();
|
|
286
|
+
`;
|
|
287
|
+
|
|
288
|
+
// UserPromptSubmit: fires before each turn is processed (Claude, Codex, and Copilot all support
|
|
289
|
+
// this event under the same name per each platform's docs/source). This is the recall-side
|
|
290
|
+
// counterpart to Stop's write-side reminder - deliberately a STATIC message, not a live
|
|
291
|
+
// `empirical memory query` call: a prior per-turn hook in this codebase was removed for making an
|
|
292
|
+
// unconditional network call on every single prompt (see the comment above installClaudeHooks),
|
|
293
|
+
// and re-adding that cost here on three platforms at once was an explicit no per product decision.
|
|
294
|
+
// The message itself is generated per-install from the actual plugin set being installed, not one
|
|
295
|
+
// fixed string for everyone - e.g. someone who declined empirical-scar-tissue at install time
|
|
296
|
+
// never sees a scar-tissue reminder, since that capability isn't actually available to them.
|
|
297
|
+
function buildPromptReminderMessage(pluginNames) {
|
|
298
|
+
const names = normalizePluginNames(pluginNames);
|
|
299
|
+
const lines = [
|
|
300
|
+
'Before responding: if this task needs prior context (research, debugging, a spec or status update, a handoff), query Empirical memory first via the empirical-memory skill or empirical memory query - do not assume you already have enough context.'
|
|
301
|
+
];
|
|
302
|
+
if (names.includes('empirical-work-history')) {
|
|
303
|
+
lines.push('If you are picking up previously completed work, check for a recent work-history entry before assuming there is none.');
|
|
304
|
+
}
|
|
305
|
+
if (names.includes('empirical-scar-tissue')) {
|
|
306
|
+
lines.push('Check for scar tissue before repeating a past mistake in this area.');
|
|
307
|
+
}
|
|
308
|
+
if (names.includes('empirical-todo')) {
|
|
309
|
+
lines.push('If there is an open todo for this project, check it before starting new work.');
|
|
310
|
+
}
|
|
311
|
+
return '[Empirical reminder] ' + lines.join(' ');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Builds the full UserPromptSubmit hook script source with the tailored message baked in at
|
|
315
|
+
// generation time (via JSON.stringify, so the message is embedded as a single already-escaped
|
|
316
|
+
// string literal - substituted through `${}`, not written as raw template-literal source text,
|
|
317
|
+
// which is what caused the earlier comment-escaping bug in this same file. Never do the latter).
|
|
318
|
+
function buildUserPromptSubmitHookSource(pluginNames) {
|
|
319
|
+
const messageLiteral = JSON.stringify(buildPromptReminderMessage(pluginNames));
|
|
320
|
+
return `#!/usr/bin/env node
|
|
321
|
+
'use strict';
|
|
322
|
+
|
|
323
|
+
var fs = require('fs');
|
|
324
|
+
var os = require('os');
|
|
325
|
+
var path = require('path');
|
|
326
|
+
|
|
327
|
+
var LOG_PATH = path.join(os.homedir(), '.empirical', 'hooks', 'user-prompt-submit.log');
|
|
328
|
+
var MAX_LOG_BYTES = 200 * 1024;
|
|
329
|
+
|
|
330
|
+
function appendLog(line) {
|
|
331
|
+
try {
|
|
332
|
+
fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
|
|
333
|
+
fs.appendFileSync(LOG_PATH, '[' + new Date().toISOString() + '] ' + line + '\\n');
|
|
334
|
+
var stat = fs.statSync(LOG_PATH);
|
|
335
|
+
if (stat.size > MAX_LOG_BYTES) {
|
|
336
|
+
var tail = fs.readFileSync(LOG_PATH, 'utf8').slice(-MAX_LOG_BYTES / 2);
|
|
337
|
+
fs.writeFileSync(LOG_PATH, tail);
|
|
338
|
+
}
|
|
339
|
+
} catch (e) {
|
|
340
|
+
// Diagnostics must never break the hook.
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
appendLog('user-prompt-submit hook fired (pid=' + process.pid + ', platform=' + process.platform + ')');
|
|
345
|
+
console.log(${messageLiteral});
|
|
346
|
+
process.exit(0);
|
|
347
|
+
`;
|
|
348
|
+
}
|
|
349
|
+
|
|
63
350
|
function getEmpiricalHooksDir() {
|
|
64
351
|
return path.join(os.homedir(), '.empirical', 'hooks');
|
|
65
352
|
}
|
|
@@ -81,17 +368,21 @@ function removeEmpiricalHookEntries(entries, scriptPath) {
|
|
|
81
368
|
.filter((entry) => entry && (!Array.isArray(entry.hooks) || entry.hooks.length > 0));
|
|
82
369
|
}
|
|
83
370
|
|
|
84
|
-
function installClaudeHooks() {
|
|
371
|
+
function installClaudeHooks(pluginNames) {
|
|
85
372
|
const hooksDir = getEmpiricalHooksDir();
|
|
86
373
|
ensureDir(hooksDir);
|
|
87
374
|
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
// the
|
|
91
|
-
//
|
|
375
|
+
// A prior version of this hook made an unconditional `empirical memory query` network call on
|
|
376
|
+
// every single prompt and was removed for being wasteful (see git history). This is a
|
|
377
|
+
// deliberate reintroduction at the SAME script path, but with fundamentally different content:
|
|
378
|
+
// a static, install-time-generated reminder with no network call at all - the cost concern that
|
|
379
|
+
// killed the old version does not apply to a plain stdout write.
|
|
92
380
|
const userPromptScriptPath = path.join(hooksDir, 'claude-user-prompt-submit.js');
|
|
93
381
|
const sessionStartScriptPath = path.join(hooksDir, 'claude-session-start.js');
|
|
382
|
+
const stopScriptPath = path.join(hooksDir, 'claude-stop.js');
|
|
94
383
|
writeFile(sessionStartScriptPath, CLAUDE_HOOK_SESSION_START_SOURCE);
|
|
384
|
+
writeFile(stopScriptPath, CLAUDE_HOOK_STOP_SOURCE);
|
|
385
|
+
writeFile(userPromptScriptPath, buildUserPromptSubmitHookSource(pluginNames));
|
|
95
386
|
|
|
96
387
|
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
|
97
388
|
const settings = readJsonIfExists(settingsPath) || {};
|
|
@@ -99,13 +390,10 @@ function installClaudeHooks() {
|
|
|
99
390
|
settings.hooks = {};
|
|
100
391
|
}
|
|
101
392
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
delete settings.hooks.UserPromptSubmit;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
393
|
+
settings.hooks.UserPromptSubmit = removeEmpiricalHookEntries(settings.hooks.UserPromptSubmit, userPromptScriptPath);
|
|
394
|
+
settings.hooks.UserPromptSubmit.push({
|
|
395
|
+
hooks: [{ type: 'command', command: buildHookCommand(userPromptScriptPath), timeout: 10 }]
|
|
396
|
+
});
|
|
109
397
|
|
|
110
398
|
settings.hooks.SessionStart = removeEmpiricalHookEntries(settings.hooks.SessionStart, sessionStartScriptPath);
|
|
111
399
|
for (const matcher of ['startup', 'resume', 'compact']) {
|
|
@@ -115,14 +403,148 @@ function installClaudeHooks() {
|
|
|
115
403
|
});
|
|
116
404
|
}
|
|
117
405
|
|
|
406
|
+
settings.hooks.Stop = removeEmpiricalHookEntries(settings.hooks.Stop, stopScriptPath);
|
|
407
|
+
settings.hooks.Stop.push({
|
|
408
|
+
hooks: [{ type: 'command', command: buildHookCommand(stopScriptPath), timeout: 10 }]
|
|
409
|
+
});
|
|
410
|
+
|
|
118
411
|
writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
119
|
-
log(' - Claude SessionStart
|
|
412
|
+
log(' - Claude SessionStart + UserPromptSubmit + Stop hooks installed for deterministic memory lookups and reminders.');
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Codex CLI ships a real hooks system (confirmed against openai/codex source — codex-rs/hooks,
|
|
416
|
+
// codex-rs/config/src/hook_config.rs — not assumed from docs alone, since third-party writeups
|
|
417
|
+
// disagreed on Windows support). It reads <CODEX_HOME>/hooks.json (JSON, not the config.toml
|
|
418
|
+
// used for MCP/model settings — a separate file, so this never touches config.toml) shaped as
|
|
419
|
+
// { hooks: { SessionStart: [{ matcher, hooks: [{ type: 'command', command, commandWindows,
|
|
420
|
+
// timeout }] }], ... } }. CODEX_HOME defaults to ~/.codex when unset, same convention as
|
|
421
|
+
// ~/.claude for Claude Code.
|
|
422
|
+
function getCodexHomeDir() {
|
|
423
|
+
return process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function removeEmpiricalHooksJsonEntries(entries, scriptPath) {
|
|
427
|
+
if (!Array.isArray(entries)) return [];
|
|
428
|
+
return entries
|
|
429
|
+
.map((entry) => {
|
|
430
|
+
if (!entry || !Array.isArray(entry.hooks)) return entry;
|
|
431
|
+
const filteredHooks = entry.hooks.filter(
|
|
432
|
+
(h) => !(h && typeof h.command === 'string' && h.command.includes(scriptPath))
|
|
433
|
+
);
|
|
434
|
+
return { ...entry, hooks: filteredHooks };
|
|
435
|
+
})
|
|
436
|
+
.filter((entry) => entry && (!Array.isArray(entry.hooks) || entry.hooks.length > 0));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function installCodexHooks(pluginNames) {
|
|
440
|
+
const hooksDir = getEmpiricalHooksDir();
|
|
441
|
+
ensureDir(hooksDir);
|
|
442
|
+
|
|
443
|
+
const sessionStartScriptPath = path.join(hooksDir, 'codex-session-start.js');
|
|
444
|
+
const stopScriptPath = path.join(hooksDir, 'codex-stop.js');
|
|
445
|
+
const userPromptScriptPath = path.join(hooksDir, 'codex-user-prompt-submit.js');
|
|
446
|
+
writeFile(sessionStartScriptPath, CLAUDE_HOOK_SESSION_START_SOURCE);
|
|
447
|
+
writeFile(stopScriptPath, EMPIRICAL_HOOK_STOP_SOURCE);
|
|
448
|
+
writeFile(userPromptScriptPath, buildUserPromptSubmitHookSource(pluginNames));
|
|
449
|
+
|
|
450
|
+
const hooksJsonPath = path.join(getCodexHomeDir(), 'hooks.json');
|
|
451
|
+
const hooksFile = readJsonIfExists(hooksJsonPath) || {};
|
|
452
|
+
if (!hooksFile.hooks || typeof hooksFile.hooks !== 'object') {
|
|
453
|
+
hooksFile.hooks = {};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
hooksFile.hooks.SessionStart = removeEmpiricalHooksJsonEntries(hooksFile.hooks.SessionStart, sessionStartScriptPath);
|
|
457
|
+
const sessionStartCommand = buildHookCommand(sessionStartScriptPath);
|
|
458
|
+
for (const matcher of ['startup', 'resume']) {
|
|
459
|
+
hooksFile.hooks.SessionStart.push({
|
|
460
|
+
matcher,
|
|
461
|
+
hooks: [{ type: 'command', command: sessionStartCommand, commandWindows: sessionStartCommand, timeout: 15 }]
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
hooksFile.hooks.Stop = removeEmpiricalHooksJsonEntries(hooksFile.hooks.Stop, stopScriptPath);
|
|
466
|
+
const stopCommand = buildHookCommand(stopScriptPath);
|
|
467
|
+
hooksFile.hooks.Stop.push({
|
|
468
|
+
hooks: [{ type: 'command', command: stopCommand, commandWindows: stopCommand, timeout: 10 }]
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// Confirmed against codex-rs/config/src/hook_config.rs's HookEventsToml serde renames: the
|
|
472
|
+
// config-file key is PascalCase "UserPromptSubmit", same casing convention as SessionStart/Stop.
|
|
473
|
+
hooksFile.hooks.UserPromptSubmit = removeEmpiricalHooksJsonEntries(hooksFile.hooks.UserPromptSubmit, userPromptScriptPath);
|
|
474
|
+
const userPromptCommand = buildHookCommand(userPromptScriptPath);
|
|
475
|
+
hooksFile.hooks.UserPromptSubmit.push({
|
|
476
|
+
hooks: [{ type: 'command', command: userPromptCommand, commandWindows: userPromptCommand, timeout: 10 }]
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
writeFile(hooksJsonPath, `${JSON.stringify(hooksFile, null, 2)}\n`);
|
|
480
|
+
log(' - Codex SessionStart + UserPromptSubmit + Stop hooks installed for deterministic memory lookups and reminders.');
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// GitHub Copilot CLI's hook config is one JSON file per hook under ~/.copilot/hooks/ (any
|
|
484
|
+
// filename — confirmed via GitHub's own docs at docs.github.com/en/copilot/reference/hooks-
|
|
485
|
+
// reference), so unlike Claude/Codex this never needs to merge into a shared file: Empirical
|
|
486
|
+
// owns this file outright and can just overwrite it. Event key for turn-end is "agentStop"
|
|
487
|
+
// ("the main agent finishes a turn" per the same docs) — not "stop"/"Stop" like Claude/Codex.
|
|
488
|
+
// Schema: { version: 1, hooks: { sessionStart: [...], agentStop: [...] } }.
|
|
489
|
+
function installCopilotHooks(pluginNames) {
|
|
490
|
+
const hooksDir = getEmpiricalHooksDir();
|
|
491
|
+
ensureDir(hooksDir);
|
|
492
|
+
|
|
493
|
+
const sessionStartScriptPath = path.join(hooksDir, 'copilot-session-start.js');
|
|
494
|
+
const stopScriptPath = path.join(hooksDir, 'copilot-stop.js');
|
|
495
|
+
const userPromptScriptPath = path.join(hooksDir, 'copilot-user-prompt-submit.js');
|
|
496
|
+
writeFile(sessionStartScriptPath, CLAUDE_HOOK_SESSION_START_SOURCE);
|
|
497
|
+
writeFile(stopScriptPath, EMPIRICAL_HOOK_STOP_SOURCE);
|
|
498
|
+
writeFile(userPromptScriptPath, buildUserPromptSubmitHookSource(pluginNames));
|
|
499
|
+
|
|
500
|
+
const copilotHooksDir = path.join(os.homedir(), '.copilot', 'hooks');
|
|
501
|
+
ensureDir(copilotHooksDir);
|
|
502
|
+
|
|
503
|
+
// Prior versions of this installer wrote a single-purpose "empirical-session-start.json" —
|
|
504
|
+
// remove it now that both events live in one unified file, so a stale duplicate registration
|
|
505
|
+
// never lingers alongside the new one.
|
|
506
|
+
const legacyHookFilePath = path.join(copilotHooksDir, 'empirical-session-start.json');
|
|
507
|
+
try {
|
|
508
|
+
const fs = require('fs');
|
|
509
|
+
if (fs.existsSync(legacyHookFilePath)) fs.unlinkSync(legacyHookFilePath);
|
|
510
|
+
} catch (_error) {
|
|
511
|
+
// best-effort cleanup only
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const hookFilePath = path.join(copilotHooksDir, 'empirical-hooks.json');
|
|
515
|
+
const hookFileContent = {
|
|
516
|
+
version: 1,
|
|
517
|
+
hooks: {
|
|
518
|
+
sessionStart: [
|
|
519
|
+
{ type: 'command', command: buildHookCommand(sessionStartScriptPath), timeoutSec: 15 }
|
|
520
|
+
],
|
|
521
|
+
// "userPromptSubmitted" per GitHub's hooks reference ("The user submits a prompt") - not
|
|
522
|
+
// "userPromptSubmit" (that camelCase spelling is Claude/Codex's convention, not Copilot's).
|
|
523
|
+
userPromptSubmitted: [
|
|
524
|
+
{ type: 'command', command: buildHookCommand(userPromptScriptPath), timeoutSec: 10 }
|
|
525
|
+
],
|
|
526
|
+
agentStop: [
|
|
527
|
+
{ type: 'command', command: buildHookCommand(stopScriptPath), timeoutSec: 10 }
|
|
528
|
+
]
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
writeFile(hookFilePath, `${JSON.stringify(hookFileContent, null, 2)}\n`);
|
|
533
|
+
log(' - Copilot SessionStart + userPromptSubmitted + agentStop hooks installed for deterministic memory lookups and reminders.');
|
|
120
534
|
}
|
|
121
535
|
|
|
122
536
|
module.exports = {
|
|
123
537
|
CLAUDE_HOOK_SESSION_START_SOURCE,
|
|
538
|
+
EMPIRICAL_HOOK_STOP_SOURCE,
|
|
539
|
+
CLAUDE_HOOK_STOP_SOURCE,
|
|
540
|
+
buildPromptReminderMessage,
|
|
541
|
+
buildUserPromptSubmitHookSource,
|
|
124
542
|
getEmpiricalHooksDir,
|
|
125
543
|
buildHookCommand,
|
|
126
544
|
removeEmpiricalHookEntries,
|
|
127
|
-
installClaudeHooks
|
|
545
|
+
installClaudeHooks,
|
|
546
|
+
getCodexHomeDir,
|
|
547
|
+
removeEmpiricalHooksJsonEntries,
|
|
548
|
+
installCodexHooks,
|
|
549
|
+
installCopilotHooks
|
|
128
550
|
};
|
|
@@ -159,16 +159,28 @@ function buildEmpiricalSkillFrontmatter(options = {}) {
|
|
|
159
159
|
name: 'empirical-memory',
|
|
160
160
|
// Keep frontmatter deliberately simple: Copilot's YAML parser rejects an
|
|
161
161
|
// unquoted colon in a plain scalar, and does not support when_to_use.
|
|
162
|
-
|
|
162
|
+
// Covers both triggers this skill owns: durable project memory recall/save,
|
|
163
|
+
// and "how do I use Empirical" questions (CLI commands, install, auth,
|
|
164
|
+
// workspaces, kits, skills) - a single skill so there's no ambiguity about
|
|
165
|
+
// which one to check for an Empirical-shaped question.
|
|
166
|
+
description: 'Use Empirical to recall/save durable project context across sessions, and to look up how to use Empirical itself - CLI commands, install/auth/workspace/kit/skill setup, docs.'
|
|
163
167
|
};
|
|
164
168
|
}
|
|
165
169
|
|
|
166
170
|
function buildEmpiricalSkillBody(options = {}) {
|
|
167
171
|
const policyLines = buildSharedEmpiricalPolicyLines(options);
|
|
168
172
|
const workflows = workflowLabelsSummary(options);
|
|
169
|
-
return `## Empirical
|
|
173
|
+
return `## Empirical - Use It Like grep/view
|
|
170
174
|
|
|
171
|
-
|
|
175
|
+
This skill covers two distinct triggers. Check both, not just the first one that seems to fit:
|
|
176
|
+
|
|
177
|
+
1. **Durable memory recall/save** - for ${workflows}, whenever prior context may matter, and
|
|
178
|
+
whenever the user asks to remember, recall, update, or forget information.
|
|
179
|
+
2. **"How do I use Empirical" questions** - CLI command syntax, install/auth/workspace/kit/skill
|
|
180
|
+
setup, or any question about what Empirical can do or how it's configured. Don't answer these
|
|
181
|
+
from general knowledge or by grepping the empirical_v2 source - run \`empirical doc\` (lists
|
|
182
|
+
topics) or \`empirical doc <topic>\` via the available shell for the current, agent-readable
|
|
183
|
+
answer before guessing.
|
|
172
184
|
|
|
173
185
|
Prefer calling the \`empirical\` CLI directly via the available shell over the MCP tools when both are
|
|
174
186
|
available: \`empirical memory query|get|list|record|note|update|delete\`. The CLI doesn't require an
|
package/lib/workspaceCommands.js
CHANGED
|
@@ -6,8 +6,8 @@ const { getEnv, parseFlag, parseOption } = require('./env');
|
|
|
6
6
|
const { ensureHeadlessToken, resolveAccessToken, buildApiAuthHeaders } = require('./auth');
|
|
7
7
|
const { readStoredOAuthClient } = require('./oauthClient');
|
|
8
8
|
const { deriveBaseUrlFromMcpUrl } = require('./constants');
|
|
9
|
-
const { withCliAnalyticsContext } = require('./memoryCommands');
|
|
10
|
-
const { readSelectedWorkspace, writeSelectedWorkspace, clearSelectedWorkspace } = require('./workspaceContext');
|
|
9
|
+
const { withCliAnalyticsContext } = require('./memoryCommands');
|
|
10
|
+
const { readSelectedWorkspace, writeSelectedWorkspace, clearSelectedWorkspace } = require('./workspaceContext');
|
|
11
11
|
|
|
12
12
|
function isWorkspaceCommand(args) {
|
|
13
13
|
if (args[0] !== 'workspace') return false;
|
|
@@ -43,7 +43,7 @@ function targetFromOption(option, teamBrain) {
|
|
|
43
43
|
return {
|
|
44
44
|
...(option || {}),
|
|
45
45
|
key: option?.key || teamBrain?.workspaceId,
|
|
46
|
-
selectionKey: option?.key || teamBrain?.workspaceId || option?.workspaceId,
|
|
46
|
+
selectionKey: option?.key || teamBrain?.workspaceId || option?.workspaceId,
|
|
47
47
|
label: option?.label || teamBrain?.name,
|
|
48
48
|
isDefault: Boolean(option?.isDefault),
|
|
49
49
|
workspaceId: teamBrain?.workspaceId || option?.workspaceId || null,
|
|
@@ -118,12 +118,20 @@ async function getWorkspaceCommandAuth() {
|
|
|
118
118
|
if (!accessToken) {
|
|
119
119
|
throw new Error('No access token available. Run `empirical oauth bootstrap headless --prompt-credentials` first.');
|
|
120
120
|
}
|
|
121
|
-
|
|
121
|
+
// clientId is genuinely optional here now -- `list`/`current` fall back to a per-user endpoint
|
|
122
|
+
// (GET /api/v1/users/me/workspaces) when it's absent, since most CLI usage is a plain API key
|
|
123
|
+
// with no OAuth client_credentials flow at all (see `doctor`'s token_type check). Actions that
|
|
124
|
+
// mutate a client's workspace selection (select/use/create/delete/invite) still require one --
|
|
125
|
+
// there's no per-user equivalent of "which workspace is this client currently using."
|
|
126
|
+
const clientId = tokenPayload?.clientId || getEnv('EMPIRICAL_CLIENT_ID') || readStoredOAuthClient()?.clientId || null;
|
|
127
|
+
const baseUrl = getEnv('EMPIRICAL_BASE_URL', deriveBaseUrlFromMcpUrl() || 'https://empirical.gauzza.com');
|
|
128
|
+
return { baseUrl, clientId, accessToken };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function requireClientId(clientId) {
|
|
122
132
|
if (!clientId) {
|
|
123
133
|
throw new Error('No OAuth client ID found. Run `empirical oauth client set --client-id <id>` first.');
|
|
124
134
|
}
|
|
125
|
-
const baseUrl = getEnv('EMPIRICAL_BASE_URL', deriveBaseUrlFromMcpUrl() || 'https://empirical.gauzza.com');
|
|
126
|
-
return { baseUrl, clientId, accessToken };
|
|
127
135
|
}
|
|
128
136
|
|
|
129
137
|
async function runWorkspaceCommand(args) {
|
|
@@ -132,9 +140,13 @@ async function runWorkspaceCommand(args) {
|
|
|
132
140
|
async () => {
|
|
133
141
|
const action = args[1];
|
|
134
142
|
const { baseUrl, clientId, accessToken } = await getWorkspaceCommandAuth();
|
|
135
|
-
const encodedClientId = encodeURIComponent(clientId);
|
|
136
|
-
const listUrl =
|
|
137
|
-
|
|
143
|
+
const encodedClientId = clientId ? encodeURIComponent(clientId) : null;
|
|
144
|
+
const listUrl = clientId
|
|
145
|
+
? new URL(`/oauth/clients/${encodedClientId}/workspaces`, baseUrl).toString()
|
|
146
|
+
: new URL('/api/v1/users/me/workspaces', baseUrl).toString();
|
|
147
|
+
const currentUrl = clientId
|
|
148
|
+
? new URL(`/oauth/clients/${encodedClientId}/workspace-selection`, baseUrl).toString()
|
|
149
|
+
: null; // no per-client selection endpoint to hit -- 'current' derives it from listUrl's `selected` flags instead
|
|
138
150
|
const teamBrainsUrl = new URL('/api/v1/team-brains', baseUrl).toString();
|
|
139
151
|
const authHeaders = buildApiAuthHeaders(accessToken);
|
|
140
152
|
|
|
@@ -151,13 +163,21 @@ async function runWorkspaceCommand(args) {
|
|
|
151
163
|
return;
|
|
152
164
|
}
|
|
153
165
|
|
|
154
|
-
if (action === 'current') {
|
|
155
|
-
const locallySelected = readSelectedWorkspace();
|
|
156
|
-
if (locallySelected) {
|
|
157
|
-
log(`Current workspace: ${locallySelected.label || locallySelected.workspaceId}`);
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
|
|
166
|
+
if (action === 'current') {
|
|
167
|
+
const locallySelected = readSelectedWorkspace();
|
|
168
|
+
if (locallySelected) {
|
|
169
|
+
log(`Current workspace: ${locallySelected.label || locallySelected.workspaceId}`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (!clientId) {
|
|
173
|
+
const payload = await httpJson(listUrl, 'GET', null, authHeaders);
|
|
174
|
+
const selected = payload.defaultOption.selected
|
|
175
|
+
? payload.defaultOption
|
|
176
|
+
: (payload.workspaces || []).find((ws) => ws.selected) || payload.defaultOption;
|
|
177
|
+
log(`Current workspace: ${selected.label || 'Default Brain'}`);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const payload = await httpJson(currentUrl, 'GET', null, authHeaders);
|
|
161
181
|
log(`Current workspace: ${payload?.current?.label || 'Default Brain'}`);
|
|
162
182
|
if (payload?.locked) {
|
|
163
183
|
log('This OAuth client is admin-locked to a workspace.');
|
|
@@ -166,6 +186,7 @@ async function runWorkspaceCommand(args) {
|
|
|
166
186
|
}
|
|
167
187
|
|
|
168
188
|
if (action === 'select' || action === 'use') {
|
|
189
|
+
requireClientId(clientId);
|
|
169
190
|
const [payload, teamPayload] = await Promise.all([
|
|
170
191
|
httpJson(listUrl, 'GET', null, authHeaders),
|
|
171
192
|
httpJson(teamBrainsUrl, 'GET', null, authHeaders),
|
|
@@ -193,13 +214,13 @@ async function runWorkspaceCommand(args) {
|
|
|
193
214
|
if (!target?.selectionKey) throw new Error(`Workspace not found: ${requested}`);
|
|
194
215
|
|
|
195
216
|
const updateUrl = new URL(`/oauth/clients/${encodedClientId}/workspace-selection`, baseUrl).toString();
|
|
196
|
-
const updated = await httpJson(updateUrl, 'POST', { key: target.selectionKey }, authHeaders);
|
|
197
|
-
if (target.isDefault) {
|
|
198
|
-
clearSelectedWorkspace();
|
|
199
|
-
} else if (target.workspaceId) {
|
|
200
|
-
writeSelectedWorkspace({ workspaceId: target.workspaceId, key: target.key, label: target.label });
|
|
201
|
-
}
|
|
202
|
-
log(uiSuccess(`Workspace selected: ${updated?.current?.label || target.label || target.selectionKey}`));
|
|
217
|
+
const updated = await httpJson(updateUrl, 'POST', { key: target.selectionKey }, authHeaders);
|
|
218
|
+
if (target.isDefault) {
|
|
219
|
+
clearSelectedWorkspace();
|
|
220
|
+
} else if (target.workspaceId) {
|
|
221
|
+
writeSelectedWorkspace({ workspaceId: target.workspaceId, key: target.key, label: target.label });
|
|
222
|
+
}
|
|
223
|
+
log(uiSuccess(`Workspace selected: ${updated?.current?.label || target.label || target.selectionKey}`));
|
|
203
224
|
return;
|
|
204
225
|
}
|
|
205
226
|
|
|
@@ -241,18 +262,20 @@ async function runWorkspaceCommand(args) {
|
|
|
241
262
|
|
|
242
263
|
log(`Soft-deleting workspace ${targetDescription}...`);
|
|
243
264
|
await httpJson(new URL(`/api/v1/team-brains/${encodeURIComponent(target.workspaceId)}`, baseUrl).toString(), 'DELETE', null, authHeaders);
|
|
244
|
-
if (target.selected) {
|
|
265
|
+
if (target.selected && clientId) {
|
|
245
266
|
const updateUrl = new URL(`/oauth/clients/${encodedClientId}/workspace-selection`, baseUrl).toString();
|
|
246
267
|
const updated = await httpJson(updateUrl, 'POST', { key: 'default_brain' }, authHeaders);
|
|
247
|
-
log(uiSuccess(`Soft-deleted workspace ${targetDescription}. Active workspace reset to ${updated?.current?.label || 'Default Brain'}.`));
|
|
248
|
-
clearSelectedWorkspace();
|
|
249
|
-
} else {
|
|
268
|
+
log(uiSuccess(`Soft-deleted workspace ${targetDescription}. Active workspace reset to ${updated?.current?.label || 'Default Brain'}.`));
|
|
269
|
+
clearSelectedWorkspace();
|
|
270
|
+
} else {
|
|
250
271
|
log(uiSuccess(`Soft-deleted workspace ${targetDescription}.`));
|
|
272
|
+
clearSelectedWorkspace();
|
|
251
273
|
}
|
|
252
274
|
return;
|
|
253
275
|
}
|
|
254
276
|
|
|
255
277
|
if (action === 'invite') {
|
|
278
|
+
requireClientId(clientId);
|
|
256
279
|
const inviteAction = args[2];
|
|
257
280
|
const currentPayload = await httpJson(currentUrl, 'GET', null, authHeaders);
|
|
258
281
|
const current = currentPayload?.current;
|
package/package.json
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "@empirical-gauzza/cli",
|
|
3
|
-
"version": "1.0.
|
|
2
|
+
"name": "@empirical-gauzza/cli",
|
|
3
|
+
"version": "1.0.11",
|
|
4
4
|
"description": "Empirical CLI installer and local MCP wiring helper",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"license": "MIT",
|
|
7
|
-
"repository": {
|
|
8
|
-
"type": "git",
|
|
9
|
-
"url": "git+https://github.com/mattgauzza/empirical.git",
|
|
10
|
-
"directory": "packages/cli"
|
|
11
|
-
},
|
|
12
7
|
"homepage": "https://empirical.gauzza.com/docs/cli-details",
|
|
13
8
|
"publishConfig": {
|
|
14
9
|
"access": "public"
|