@empirical-gauzza/cli 1.0.34 → 1.0.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/auth.js +38 -9
- package/lib/constants.js +13 -0
- package/lib/fsUtils.js +44 -2
- package/lib/help.js +4 -2
- package/lib/installers/nativePlugin.js +2 -2
- package/lib/memoryCommands.js +71 -1
- package/lib/workspaceCommands.js +59 -7
- package/lib/workspaceContext.js +176 -76
- package/package.json +1 -1
package/lib/auth.js
CHANGED
|
@@ -6,7 +6,7 @@ const os = require('os');
|
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const { spawnSync } = require('child_process');
|
|
8
8
|
const { log, warn, uiQuestion, uiDim, uiLink, ask } = require('./ui');
|
|
9
|
-
const { readJsonIfExists, writeFile, ensureDir, quotePowerShellArg, runResolvedCommand } = require('./fsUtils');
|
|
9
|
+
const { readJsonIfExists, writeFile, ensureDir, quotePowerShellArg, runResolvedCommand, runPowerShell } = require('./fsUtils');
|
|
10
10
|
const { httpGet, httpJson, httpForm } = require('./http');
|
|
11
11
|
const { getEnv, parseFlag, parseOption } = require('./env');
|
|
12
12
|
const {
|
|
@@ -81,17 +81,26 @@ async function oauthPreflight(baseMcpUrl) {
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/** Best-effort browser launch. Returns true when the launcher ran, false when it could not. */
|
|
84
85
|
function openBrowser(url) {
|
|
86
|
+
let result;
|
|
85
87
|
if (process.platform === 'win32') {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
+
// runPowerShell falls back from pwsh to powershell.exe; the old hardcoded `pwsh` silently
|
|
89
|
+
// did nothing on a stock Windows install (2026-09-23, real user report).
|
|
90
|
+
result = runPowerShell(`Start-Process ${quotePowerShellArg(url)}`, { stdio: 'ignore' });
|
|
88
91
|
} else if (process.platform === 'darwin') {
|
|
89
|
-
spawnSync('open', [url], { stdio: 'ignore' });
|
|
92
|
+
result = spawnSync('open', [url], { stdio: 'ignore' });
|
|
90
93
|
} else {
|
|
91
|
-
spawnSync('xdg-open', [url], { stdio: 'ignore' });
|
|
94
|
+
result = spawnSync('xdg-open', [url], { stdio: 'ignore' });
|
|
92
95
|
}
|
|
96
|
+
return Boolean(result) && !result.error && result.status === 0;
|
|
93
97
|
}
|
|
94
98
|
|
|
99
|
+
// How long the loopback listener waits for the browser to come back. Was 5 minutes; a user who
|
|
100
|
+
// has to find the printed link by hand, log in, and pick a workspace can take longer than that,
|
|
101
|
+
// and once the listener closes the browser lands on a dead 127.0.0.1 port with no explanation.
|
|
102
|
+
const OAUTH_CALLBACK_TIMEOUT_MS = 10 * 60 * 1000;
|
|
103
|
+
|
|
95
104
|
async function runMcpOAuthFlow(baseUrl) {
|
|
96
105
|
return new Promise((resolve, reject) => {
|
|
97
106
|
const { codeVerifier, codeChallenge } = generatePKCE();
|
|
@@ -103,9 +112,9 @@ async function runMcpOAuthFlow(baseUrl) {
|
|
|
103
112
|
if (server) server.close();
|
|
104
113
|
if (!resolved) {
|
|
105
114
|
resolved = true;
|
|
106
|
-
reject(new Error(
|
|
115
|
+
reject(new Error(`OAuth callback timeout (${OAUTH_CALLBACK_TIMEOUT_MS / 60000} minutes). The login link has expired; run the command again to get a fresh one.`));
|
|
107
116
|
}
|
|
108
|
-
},
|
|
117
|
+
}, OAUTH_CALLBACK_TIMEOUT_MS);
|
|
109
118
|
|
|
110
119
|
server = http.createServer((req, res) => {
|
|
111
120
|
const url = new URL(`http://localhost${req.url}`);
|
|
@@ -162,8 +171,14 @@ async function runMcpOAuthFlow(baseUrl) {
|
|
|
162
171
|
authUrl.searchParams.set('scope', 'memory:read memory:write memory:delete offline_access');
|
|
163
172
|
|
|
164
173
|
log(uiQuestion('Opening browser for Empirical MCP authentication...'));
|
|
165
|
-
openBrowser(authUrl.toString());
|
|
166
|
-
|
|
174
|
+
const opened = openBrowser(authUrl.toString());
|
|
175
|
+
if (opened) {
|
|
176
|
+
log(uiDim(`If browser doesn't open, visit: ${uiLink(authUrl.toString())}`));
|
|
177
|
+
} else {
|
|
178
|
+
warn('Could not open a browser automatically. Open this link yourself:');
|
|
179
|
+
log(uiLink(authUrl.toString()));
|
|
180
|
+
}
|
|
181
|
+
log(uiDim(`Waiting up to ${OAUTH_CALLBACK_TIMEOUT_MS / 60000} minutes for the browser to finish...`));
|
|
167
182
|
});
|
|
168
183
|
|
|
169
184
|
server.on('error', (error) => {
|
|
@@ -370,6 +385,19 @@ async function gatherAuthStatus() {
|
|
|
370
385
|
}
|
|
371
386
|
}
|
|
372
387
|
|
|
388
|
+
// What the CLI itself will scope the next memory command to (env, project binding, or the
|
|
389
|
+
// global selection), beside the server-reported account workspace above. The two can differ,
|
|
390
|
+
// and when they do this is the field that explains a "[]" recall. Lazy require: workspaceContext
|
|
391
|
+
// depends on this module.
|
|
392
|
+
let cliWorkspace = null;
|
|
393
|
+
try {
|
|
394
|
+
const { resolveLocalWorkspaceSelection } = require('./workspaceContext');
|
|
395
|
+
const local = resolveLocalWorkspaceSelection();
|
|
396
|
+
if (local) cliWorkspace = { workspaceId: local.workspaceId, label: local.label || null, source: local.source, path: local.path };
|
|
397
|
+
} catch (_error) {
|
|
398
|
+
cliWorkspace = null;
|
|
399
|
+
}
|
|
400
|
+
|
|
373
401
|
return {
|
|
374
402
|
authenticated: Boolean(token) && !error && tokenInfo.expired !== true,
|
|
375
403
|
tokenPresent: tokenInfo.present,
|
|
@@ -378,6 +406,7 @@ async function gatherAuthStatus() {
|
|
|
378
406
|
tokenExpired: tokenInfo.expired,
|
|
379
407
|
user,
|
|
380
408
|
workspaceId,
|
|
409
|
+
cliWorkspace,
|
|
381
410
|
error
|
|
382
411
|
};
|
|
383
412
|
}
|
package/lib/constants.js
CHANGED
|
@@ -298,9 +298,22 @@ Creates a team workspace using your existing plan quota and prints its workspace
|
|
|
298
298
|
### Use a workspace
|
|
299
299
|
|
|
300
300
|
empirical workspace use <number|name|id>
|
|
301
|
+
empirical workspace use <number|name|id> --global
|
|
302
|
+
empirical workspace unbind
|
|
301
303
|
|
|
302
304
|
Friendly alias for \`workspace select\`. It accepts the list number, exact case-insensitive name, or workspace ID.
|
|
303
305
|
|
|
306
|
+
Inside a git repository, \`use\` binds the workspace to THAT project by writing \`<repo>/.empirical/workspace.json\`; every command run inside the repo uses it, and other repositories are unaffected. \`--global\` sets the machine-wide default instead (the previous behavior). \`--project\` forces a project binding in the current directory even without git. \`unbind\` removes the project binding.
|
|
307
|
+
|
|
308
|
+
Resolution order for every memory command, first match wins: the \`EMPIRICAL_WORKSPACE_ID\` environment variable, the nearest project binding walking up from the current directory, the global selection, then the server's per-client selection. \`workspace current\` and \`whoami\` print which source applied.
|
|
309
|
+
|
|
310
|
+
### Query across several workspaces
|
|
311
|
+
|
|
312
|
+
empirical memory query --match "<query>" --workspaces "Default Workspace","Project Atlas"
|
|
313
|
+
empirical memory query --match "<query>" --all-workspaces
|
|
314
|
+
|
|
315
|
+
\`--workspaces\` accepts list numbers, names, or workspace IDs (comma-separated) and returns one merged, ranked result set with each row's \`workspaceId\`. Paging (\`--cursor\`, offsets) is not available across several workspaces. Without the flag a query is scoped to the single active workspace exactly as before.
|
|
316
|
+
|
|
304
317
|
### Share an active workspace
|
|
305
318
|
|
|
306
319
|
empirical workspace invite create --role writer --expires-in-days 7 --max-uses 3
|
package/lib/fsUtils.js
CHANGED
|
@@ -4,11 +4,51 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const { spawnSync } = require('child_process');
|
|
6
6
|
|
|
7
|
+
// 2026-09-23: every Windows shell-out used to hardcode `pwsh` (PowerShell 7). Windows ships only
|
|
8
|
+
// Windows PowerShell 5.1 (`powershell.exe`); PowerShell 7 is a separate install most people do
|
|
9
|
+
// not have. A real user on plain Windows hit `spawnSync pwsh ENOENT` on every plugin install AND
|
|
10
|
+
// the browser never opened for OAuth (openBrowser also went through pwsh), so they found the
|
|
11
|
+
// login link late, after the loopback listener had closed. Resolve once per process: prefer
|
|
12
|
+
// pwsh when present, fall back to powershell.exe, and surface a clear error if neither works.
|
|
13
|
+
let resolvedPowerShell;
|
|
14
|
+
function resolvePowerShell() {
|
|
15
|
+
if (resolvedPowerShell !== undefined) return resolvedPowerShell;
|
|
16
|
+
const override = String(process.env.EMPIRICAL_POWERSHELL || '').trim();
|
|
17
|
+
const candidates = override ? [override] : ['pwsh', 'powershell'];
|
|
18
|
+
for (const candidate of candidates) {
|
|
19
|
+
const probe = spawnSync(candidate, ['-NoProfile', '-Command', 'exit 0'], { stdio: 'ignore', timeout: 15000 });
|
|
20
|
+
if (!probe.error && probe.status === 0) {
|
|
21
|
+
resolvedPowerShell = candidate;
|
|
22
|
+
return candidate;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
resolvedPowerShell = null;
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Runs a PowerShell script on Windows through whichever PowerShell exists. Returns a spawnSync-
|
|
31
|
+
* shaped result; when no PowerShell can be found the result carries an `error` (never throws),
|
|
32
|
+
* the same shape spawnSync itself returns for ENOENT, so callers' existing error paths apply.
|
|
33
|
+
*/
|
|
34
|
+
function runPowerShell(script, options = {}) {
|
|
35
|
+
const shell = resolvePowerShell();
|
|
36
|
+
if (!shell) {
|
|
37
|
+
return {
|
|
38
|
+
status: null,
|
|
39
|
+
stdout: '',
|
|
40
|
+
stderr: '',
|
|
41
|
+
error: new Error('No PowerShell found (tried pwsh and powershell). Install PowerShell 7 or set EMPIRICAL_POWERSHELL to your PowerShell executable.')
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return spawnSync(shell, ['-NoProfile', '-Command', script], options);
|
|
45
|
+
}
|
|
46
|
+
|
|
7
47
|
function commandExists(cmd) {
|
|
8
48
|
if (process.platform === 'win32') {
|
|
9
49
|
const escaped = cmd.replace(/'/g, "''");
|
|
10
50
|
const script = `if (Get-Command '${escaped}' -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 }`;
|
|
11
|
-
const result =
|
|
51
|
+
const result = runPowerShell(script, { stdio: 'ignore' });
|
|
12
52
|
return result.status === 0;
|
|
13
53
|
}
|
|
14
54
|
const result = spawnSync('which', [cmd], { stdio: 'ignore' });
|
|
@@ -57,7 +97,7 @@ function runResolvedCommand(command, args, options = {}) {
|
|
|
57
97
|
}
|
|
58
98
|
|
|
59
99
|
const script = `& ${quotePowerShellArg(command)} ${args.map(quotePowerShellArg).join(' ')}`;
|
|
60
|
-
return
|
|
100
|
+
return runPowerShell(script, {
|
|
61
101
|
encoding: 'utf8',
|
|
62
102
|
...(input !== undefined ? { input } : { stdio })
|
|
63
103
|
});
|
|
@@ -79,6 +119,8 @@ function summarizeProcessOutput(result) {
|
|
|
79
119
|
}
|
|
80
120
|
|
|
81
121
|
module.exports = {
|
|
122
|
+
resolvePowerShell,
|
|
123
|
+
runPowerShell,
|
|
82
124
|
commandExists,
|
|
83
125
|
ensureDir,
|
|
84
126
|
readUtf8IfExists,
|
package/lib/help.js
CHANGED
|
@@ -38,9 +38,11 @@ function showHelp() {
|
|
|
38
38
|
log(' empirical oauth client show');
|
|
39
39
|
log(' empirical oauth client clear');
|
|
40
40
|
log(' empirical workspace list List workspaces you can select, and which one is active');
|
|
41
|
-
log(' empirical workspace current Show the
|
|
41
|
+
log(' empirical workspace current Show the active workspace and where it comes from (env, project, global)');
|
|
42
42
|
log(' empirical workspace select [<number|name>] Set the active workspace (prompts if omitted)');
|
|
43
|
-
log(' empirical workspace use <number|name|id> Alias for `workspace select
|
|
43
|
+
log(' empirical workspace use <number|name|id> Alias for `workspace select`. Inside a git repo binds THIS project;');
|
|
44
|
+
log(' --global sets the machine-wide default, --project forces a binding');
|
|
45
|
+
log(' empirical workspace unbind Remove this project\'s workspace binding');
|
|
44
46
|
log(' empirical workspace create <name> [--description <text>]');
|
|
45
47
|
log(' Create a new shared team workspace');
|
|
46
48
|
log(' empirical workspace invite create [--role reader|writer|manager] [--expires-in-days <n>] [--max-uses <n>]');
|
|
@@ -77,9 +77,9 @@ function detectLockHolders(agentId) {
|
|
|
77
77
|
const names = LOCK_HOLDER_PROCESS_NAMES[agentId] || [];
|
|
78
78
|
if (!names.length) return [];
|
|
79
79
|
try {
|
|
80
|
-
const {
|
|
80
|
+
const { runPowerShell } = require('../fsUtils');
|
|
81
81
|
const script = `Get-Process -Name ${names.join(',')} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty ProcessName -Unique`;
|
|
82
|
-
const probe =
|
|
82
|
+
const probe = runPowerShell(script, { encoding: 'utf8', timeout: 5000 });
|
|
83
83
|
return String(probe.stdout || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
|
84
84
|
} catch (_error) {
|
|
85
85
|
return [];
|
package/lib/memoryCommands.js
CHANGED
|
@@ -59,7 +59,7 @@ const REMIND_STATES = ['pending', 'notified', 'acknowledged', 'cancelled'];
|
|
|
59
59
|
// the 90-line global help wall bin/empirical.js otherwise short-circuits every --help to.
|
|
60
60
|
|
|
61
61
|
const MEMORY_SUBCOMMAND_USAGE = {
|
|
62
|
-
query: 'Usage: empirical memory query --match <query> [--top-k 3] [--category <cat>] [--tags <t1,t2>] [--tags-all <t1,t2>] [--status <s1,s2>] [--due-before <ISO date>] [--due-after <ISO date>] [--remind-state pending,notified] [--path semantic|list|graph|hebbian] [--hops 1|2] [--pretty] [--format table]',
|
|
62
|
+
query: 'Usage: empirical memory query --match <query> [--top-k 3] [--category <cat>] [--tags <t1,t2>] [--tags-all <t1,t2>] [--status <s1,s2>] [--due-before <ISO date>] [--due-after <ISO date>] [--remind-state pending,notified] [--path semantic|list|graph|hebbian] [--hops 1|2] [--workspaces <n|name|id,...>] [--all-workspaces] [--pretty] [--format table]',
|
|
63
63
|
get: 'Usage: empirical memory get --memory-id <id> [--pretty]',
|
|
64
64
|
list: 'Usage: empirical memory list [--category <cat>] [--tags <t1,t2>] [--start-date <ISO>] [--end-date <ISO>] [--limit 20] [--cursor <cursor>] [--oldest] [--pretty] [--format table]',
|
|
65
65
|
record: 'Usage: empirical memory record --category <cat> (--summary <text> | --summary - | --summary-file <path>) [--tags t1,t2] [--node-type observation] [--json-file <path>] [--no-dedupe] [--no-auto-link] [--author-type user|agent]',
|
|
@@ -192,6 +192,56 @@ function printMemoryResolveError(result) {
|
|
|
192
192
|
|
|
193
193
|
// ─── Memory subcommands ───────────────────────────────────────────────────────
|
|
194
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Turn `--workspaces` selectors (list numbers, names, or ids) into workspace ids using the
|
|
197
|
+
* per-user listing (/api/v1/users/me/workspaces, the same list `workspace list` prints; number 1
|
|
198
|
+
* is always the Default Workspace). An id (ws_...) passes through without a lookup. An unknown
|
|
199
|
+
* name is fatal at the terminal rather than a 403 or an empty answer after the round trip.
|
|
200
|
+
*/
|
|
201
|
+
async function resolveWorkspaceIdsForQuery({ baseUrl, accessToken, selectors = [], all = false }) {
|
|
202
|
+
const needsListing = all || selectors.some((s) => !/^ws_/.test(s));
|
|
203
|
+
let options = [];
|
|
204
|
+
if (needsListing) {
|
|
205
|
+
const listUrl = new URL('/api/v1/users/me/workspaces', baseUrl).toString();
|
|
206
|
+
let payload;
|
|
207
|
+
try {
|
|
208
|
+
payload = await httpJson(listUrl, 'GET', undefined, buildApiAuthHeaders(accessToken));
|
|
209
|
+
} catch (error) {
|
|
210
|
+
printApiError(error);
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
options = [
|
|
214
|
+
...(payload?.defaultOption ? [payload.defaultOption] : []),
|
|
215
|
+
...(Array.isArray(payload?.workspaces) ? payload.workspaces : [])
|
|
216
|
+
].filter((o) => o && o.workspaceId);
|
|
217
|
+
}
|
|
218
|
+
if (all) return [...new Set(options.map((o) => o.workspaceId))];
|
|
219
|
+
|
|
220
|
+
const ids = [];
|
|
221
|
+
for (const selector of selectors) {
|
|
222
|
+
if (/^ws_/.test(selector)) { ids.push(selector); continue; }
|
|
223
|
+
const asNumber = Number(selector);
|
|
224
|
+
let match = null;
|
|
225
|
+
if (Number.isInteger(asNumber) && asNumber >= 1 && asNumber <= options.length) {
|
|
226
|
+
match = options[asNumber - 1];
|
|
227
|
+
} else {
|
|
228
|
+
const lower = selector.toLowerCase();
|
|
229
|
+
const byLabel = options.filter((o) => String(o.label || '').toLowerCase() === lower || String(o.key || '').toLowerCase() === lower);
|
|
230
|
+
if (byLabel.length > 1) {
|
|
231
|
+
warn(`Ambiguous workspace "${selector}". Use its list number or workspace id.`);
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
match = byLabel[0] || null;
|
|
235
|
+
}
|
|
236
|
+
if (!match) {
|
|
237
|
+
warn(`Workspace not found: "${selector}". Run \`empirical workspace list\` to see names and numbers.`);
|
|
238
|
+
process.exit(1);
|
|
239
|
+
}
|
|
240
|
+
ids.push(match.workspaceId);
|
|
241
|
+
}
|
|
242
|
+
return [...new Set(ids)];
|
|
243
|
+
}
|
|
244
|
+
|
|
195
245
|
async function runMemoryQuery(args) {
|
|
196
246
|
const match = parseOption(args, '--match', '');
|
|
197
247
|
if (!match) {
|
|
@@ -208,6 +258,17 @@ async function runMemoryQuery(args) {
|
|
|
208
258
|
// --status filters data.status. Both are optional and additive to --tags/--category.
|
|
209
259
|
const tagsAllRaw = parseOption(args, '--tags-all', '');
|
|
210
260
|
const tagsAll = tagsAllRaw ? tagsAllRaw.split(',').map((t) => t.trim()).filter(Boolean) : [];
|
|
261
|
+
// Multi-workspace recall (2026-09-24). --workspaces takes list numbers, names, or ids; names
|
|
262
|
+
// and numbers are resolved against the same listing `workspace list` prints, so the server
|
|
263
|
+
// contract stays ids-only. --all-workspaces sends every selectable workspace. Without either
|
|
264
|
+
// flag the query is scoped to the single active workspace exactly as before.
|
|
265
|
+
const workspacesRaw = parseOption(args, '--workspaces', '');
|
|
266
|
+
const workspaceSelectors = workspacesRaw ? workspacesRaw.split(',').map((w) => w.trim()).filter(Boolean) : [];
|
|
267
|
+
const allWorkspaces = parseFlag(args, '--all-workspaces');
|
|
268
|
+
if (workspaceSelectors.length && allWorkspaces) {
|
|
269
|
+
warn('Pass either --workspaces or --all-workspaces, not both.');
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
211
272
|
const statusRaw = parseOption(args, '--status', '');
|
|
212
273
|
const status = statusRaw ? statusRaw.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
|
213
274
|
const pretty = parseFlag(args, '--pretty');
|
|
@@ -278,6 +339,14 @@ async function runMemoryQuery(args) {
|
|
|
278
339
|
if (category) body.category = category;
|
|
279
340
|
if (tags.length) body.tags = tags;
|
|
280
341
|
if (tagsAll.length) body.tagsAll = tagsAll;
|
|
342
|
+
if (workspaceSelectors.length || allWorkspaces) {
|
|
343
|
+
body.workspaceIds = await resolveWorkspaceIdsForQuery({
|
|
344
|
+
baseUrl,
|
|
345
|
+
accessToken,
|
|
346
|
+
selectors: workspaceSelectors,
|
|
347
|
+
all: allWorkspaces
|
|
348
|
+
});
|
|
349
|
+
}
|
|
281
350
|
if (status.length) body.status = status;
|
|
282
351
|
if (dueFilters.dueBefore) body.dueBefore = dueFilters.dueBefore;
|
|
283
352
|
if (dueFilters.dueAfter) body.dueAfter = dueFilters.dueAfter;
|
|
@@ -1130,6 +1199,7 @@ module.exports = {
|
|
|
1130
1199
|
withCliAnalyticsContext,
|
|
1131
1200
|
resolveMemoryByQuery,
|
|
1132
1201
|
printMemoryResolveError,
|
|
1202
|
+
resolveWorkspaceIdsForQuery,
|
|
1133
1203
|
runMemoryQuery,
|
|
1134
1204
|
runMemoryGet,
|
|
1135
1205
|
runMemoryList,
|
package/lib/workspaceCommands.js
CHANGED
|
@@ -7,14 +7,31 @@ const { ensureHeadlessToken, resolveAccessToken, buildApiAuthHeaders } = require
|
|
|
7
7
|
const { readStoredOAuthClient } = require('./oauthClient');
|
|
8
8
|
const { deriveBaseUrlFromMcpUrl } = require('./constants');
|
|
9
9
|
const { withCliAnalyticsContext } = require('./memoryCommands');
|
|
10
|
-
const {
|
|
10
|
+
const {
|
|
11
|
+
writeSelectedWorkspace,
|
|
12
|
+
clearSelectedWorkspace,
|
|
13
|
+
resolveLocalWorkspaceSelection,
|
|
14
|
+
findGitRoot,
|
|
15
|
+
writeProjectWorkspace,
|
|
16
|
+
clearProjectWorkspace,
|
|
17
|
+
projectWorkspacePathFor,
|
|
18
|
+
WORKSPACE_ENV_VAR
|
|
19
|
+
} = require('./workspaceContext');
|
|
11
20
|
|
|
12
21
|
function isWorkspaceCommand(args) {
|
|
13
22
|
if (args[0] !== 'workspace') return false;
|
|
14
|
-
if (['list', 'current', 'select', 'use', 'create', 'delete'].includes(args[1])) return true;
|
|
23
|
+
if (['list', 'current', 'select', 'use', 'unbind', 'create', 'delete'].includes(args[1])) return true;
|
|
15
24
|
return args[1] === 'invite' && ['create', 'list', 'revoke'].includes(args[2]);
|
|
16
25
|
}
|
|
17
26
|
|
|
27
|
+
function describeSelectionSource(selection) {
|
|
28
|
+
if (!selection) return '';
|
|
29
|
+
if (selection.source === 'env') return ` (from ${WORKSPACE_ENV_VAR})`;
|
|
30
|
+
if (selection.source === 'project') return ` (bound to this project: ${selection.path})`;
|
|
31
|
+
if (selection.source === 'global') return ' (global CLI selection)';
|
|
32
|
+
return '';
|
|
33
|
+
}
|
|
34
|
+
|
|
18
35
|
function workspaceOptions(payload) {
|
|
19
36
|
return [
|
|
20
37
|
...(payload?.defaultOption ? [{ ...payload.defaultOption, isDefault: true }] : []),
|
|
@@ -164,9 +181,11 @@ async function runWorkspaceCommand(args) {
|
|
|
164
181
|
}
|
|
165
182
|
|
|
166
183
|
if (action === 'current') {
|
|
167
|
-
|
|
184
|
+
// Same resolver every memory command uses (env, project binding, global file), so what
|
|
185
|
+
// this prints is what the next `memory query` will actually be scoped to.
|
|
186
|
+
const locallySelected = resolveLocalWorkspaceSelection();
|
|
168
187
|
if (locallySelected) {
|
|
169
|
-
log(`Current workspace: ${locallySelected.label || locallySelected.workspaceId}`);
|
|
188
|
+
log(`Current workspace: ${locallySelected.label || locallySelected.workspaceId}${describeSelectionSource(locallySelected)}`);
|
|
170
189
|
return;
|
|
171
190
|
}
|
|
172
191
|
if (!clientId) {
|
|
@@ -185,19 +204,40 @@ async function runWorkspaceCommand(args) {
|
|
|
185
204
|
return;
|
|
186
205
|
}
|
|
187
206
|
|
|
207
|
+
if (action === 'unbind') {
|
|
208
|
+
const gitRoot = findGitRoot();
|
|
209
|
+
if (!gitRoot) throw new Error('Not inside a git repository; there is no project binding here to remove.');
|
|
210
|
+
const removed = clearProjectWorkspace(gitRoot);
|
|
211
|
+
log(removed
|
|
212
|
+
? uiSuccess(`Removed project workspace binding: ${removed}`)
|
|
213
|
+
: `No project workspace binding at ${projectWorkspacePathFor(gitRoot)}.`);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
188
217
|
if (action === 'select' || action === 'use') {
|
|
189
|
-
|
|
218
|
+
// Where the choice lands (2026-09-24): inside a git repo the default is a PROJECT binding
|
|
219
|
+
// (<repo>/.empirical/workspace.json, local only, never the server's per-client selection),
|
|
220
|
+
// so one repo's session cannot re-scope another's. --global keeps the old machine-wide
|
|
221
|
+
// behavior; --project forces a binding even when git is missing (uses cwd).
|
|
222
|
+
const wantGlobal = parseFlag(args, '--global');
|
|
223
|
+
const wantProject = parseFlag(args, '--project');
|
|
224
|
+
if (wantGlobal && wantProject) throw new Error('Pass either --global or --project, not both.');
|
|
225
|
+
const gitRoot = wantGlobal ? null : findGitRoot();
|
|
226
|
+
const projectRoot = wantProject ? (gitRoot || process.cwd()) : gitRoot;
|
|
227
|
+
const bindToProject = !wantGlobal && Boolean(projectRoot);
|
|
228
|
+
|
|
229
|
+
if (!bindToProject) requireClientId(clientId);
|
|
190
230
|
const [payload, teamPayload] = await Promise.all([
|
|
191
231
|
httpJson(listUrl, 'GET', null, authHeaders),
|
|
192
232
|
httpJson(teamBrainsUrl, 'GET', null, authHeaders),
|
|
193
233
|
]);
|
|
194
|
-
if (payload.locked) {
|
|
234
|
+
if (payload.locked && !bindToProject) {
|
|
195
235
|
throw new Error('Workspace is admin-locked for this OAuth client and cannot be changed.');
|
|
196
236
|
}
|
|
197
237
|
|
|
198
238
|
const options = workspaceOptions(payload);
|
|
199
239
|
|
|
200
|
-
let requested = args.slice(2).join(' ').trim();
|
|
240
|
+
let requested = args.slice(2).filter((arg) => !['--global', '--project'].includes(arg)).join(' ').trim();
|
|
201
241
|
if (!requested && process.stdin.isTTY) {
|
|
202
242
|
log('Select workspace:');
|
|
203
243
|
options.forEach((item, idx) => log(` ${idx + 1}. ${item.label}`));
|
|
@@ -213,6 +253,18 @@ async function runWorkspaceCommand(args) {
|
|
|
213
253
|
if (target?.ambiguous) throw new Error(`Ambiguous workspace: ${requested}. Use its list number or workspace ID.`);
|
|
214
254
|
if (!target?.selectionKey) throw new Error(`Workspace not found: ${requested}`);
|
|
215
255
|
|
|
256
|
+
if (bindToProject) {
|
|
257
|
+
// The Default Workspace is written explicitly (its personal id), never as "clear": an
|
|
258
|
+
// explicit binding is the whole point when a sibling session may have changed the global.
|
|
259
|
+
const workspaceId = target.workspaceId || payload?.defaultOption?.workspaceId || null;
|
|
260
|
+
if (!workspaceId) throw new Error(`Could not resolve a workspace id for ${target.label || requested}.`);
|
|
261
|
+
const written = writeProjectWorkspace(projectRoot, { workspaceId, key: target.key || null, label: target.label || null });
|
|
262
|
+
log(uiSuccess(`Workspace bound to this project: ${target.label || workspaceId}`));
|
|
263
|
+
log(` ${written}`);
|
|
264
|
+
log(' Commands run inside this repository use it. Use --global to change the machine-wide default instead.');
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
216
268
|
const updateUrl = new URL(`/oauth/clients/${encodedClientId}/workspace-selection`, baseUrl).toString();
|
|
217
269
|
const updated = await httpJson(updateUrl, 'POST', { key: target.selectionKey }, authHeaders);
|
|
218
270
|
if (target.isDefault) {
|
package/lib/workspaceContext.js
CHANGED
|
@@ -1,76 +1,176 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const os = require('os');
|
|
4
|
-
const path = require('path');
|
|
5
|
-
const
|
|
6
|
-
const {
|
|
7
|
-
const {
|
|
8
|
-
const {
|
|
9
|
-
const {
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const { execFileSync } = require('child_process');
|
|
7
|
+
const { httpJson } = require('./http');
|
|
8
|
+
const { getEnv } = require('./env');
|
|
9
|
+
const { resolveAccessToken, resolveApiBaseUrl, buildApiAuthHeaders } = require('./auth');
|
|
10
|
+
const { readStoredOAuthClient } = require('./oauthClient');
|
|
11
|
+
const { readJsonIfExists, writeFile, ensureDir } = require('./fsUtils');
|
|
12
|
+
|
|
13
|
+
const WORKSPACE_CONTEXT_TIMEOUT_MS = 2000;
|
|
14
|
+
const SELECTED_WORKSPACE_PATH = path.join(os.homedir(), '.empirical', 'workspace-selection.json');
|
|
15
|
+
|
|
16
|
+
// Per-project binding (2026-09-24). The global selection above is one file per machine, so any
|
|
17
|
+
// session in any repo flips it for every other session: a WOB session left the CLI on the WOB team
|
|
18
|
+
// workspace and every tag-scoped recall from an empirical_v2 session returned [] while the memories
|
|
19
|
+
// existed. The unit that actually varies is the project directory, like git config, so a project
|
|
20
|
+
// can pin its workspace in <repo>/.empirical/workspace.json and `use` writes there by default when
|
|
21
|
+
// run inside a git repo. Resolution order, first hit wins: env, project, global, then the server's
|
|
22
|
+
// per-client selection exactly as before. No project file and no env means today's behavior.
|
|
23
|
+
const PROJECT_WORKSPACE_DIRNAME = '.empirical';
|
|
24
|
+
const PROJECT_WORKSPACE_FILENAME = 'workspace.json';
|
|
25
|
+
const WORKSPACE_ENV_VAR = 'EMPIRICAL_WORKSPACE_ID';
|
|
26
|
+
|
|
27
|
+
function readSelectedWorkspace() {
|
|
28
|
+
const value = readJsonIfExists(SELECTED_WORKSPACE_PATH);
|
|
29
|
+
return value && typeof value.workspaceId === 'string' ? value : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function writeSelectedWorkspace(workspace) {
|
|
33
|
+
ensureDir(path.dirname(SELECTED_WORKSPACE_PATH));
|
|
34
|
+
writeFile(SELECTED_WORKSPACE_PATH, `${JSON.stringify(workspace, null, 2)}\n`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function clearSelectedWorkspace() {
|
|
38
|
+
if (fs.existsSync(SELECTED_WORKSPACE_PATH)) fs.unlinkSync(SELECTED_WORKSPACE_PATH);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Nearest <dir>/.empirical/workspace.json walking up from `startDir` to the filesystem root.
|
|
43
|
+
* Returns { path, value } or null. A malformed file is ignored rather than fatal: a broken
|
|
44
|
+
* binding must not take the CLI down, it just falls through to the next source.
|
|
45
|
+
*/
|
|
46
|
+
function findProjectWorkspaceFile(startDir = process.cwd()) {
|
|
47
|
+
let dir = path.resolve(startDir);
|
|
48
|
+
for (;;) {
|
|
49
|
+
const candidate = path.join(dir, PROJECT_WORKSPACE_DIRNAME, PROJECT_WORKSPACE_FILENAME);
|
|
50
|
+
const value = readJsonIfExists(candidate);
|
|
51
|
+
if (value && typeof value.workspaceId === 'string' && value.workspaceId.trim()) {
|
|
52
|
+
return { path: candidate, value };
|
|
53
|
+
}
|
|
54
|
+
const parent = path.dirname(dir);
|
|
55
|
+
if (parent === dir) return null;
|
|
56
|
+
dir = parent;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The git toplevel for `cwd`, or null outside a repo (or without git). Used to decide where
|
|
62
|
+
* `workspace use` writes by default and where the project binding lives.
|
|
63
|
+
*/
|
|
64
|
+
function findGitRoot(cwd = process.cwd()) {
|
|
65
|
+
try {
|
|
66
|
+
const out = execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
67
|
+
cwd,
|
|
68
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
69
|
+
encoding: 'utf8'
|
|
70
|
+
});
|
|
71
|
+
const root = String(out || '').trim();
|
|
72
|
+
return root ? path.resolve(root) : null;
|
|
73
|
+
} catch (_error) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function projectWorkspacePathFor(rootDir) {
|
|
79
|
+
return path.join(rootDir, PROJECT_WORKSPACE_DIRNAME, PROJECT_WORKSPACE_FILENAME);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function writeProjectWorkspace(rootDir, workspace) {
|
|
83
|
+
const filePath = projectWorkspacePathFor(rootDir);
|
|
84
|
+
ensureDir(path.dirname(filePath));
|
|
85
|
+
writeFile(filePath, `${JSON.stringify({ ...workspace, boundAt: new Date().toISOString() }, null, 2)}\n`);
|
|
86
|
+
return filePath;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function clearProjectWorkspace(rootDir) {
|
|
90
|
+
const filePath = projectWorkspacePathFor(rootDir);
|
|
91
|
+
if (fs.existsSync(filePath)) {
|
|
92
|
+
fs.unlinkSync(filePath);
|
|
93
|
+
return filePath;
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The single answer to "which workspace does this CLI invocation act on", before asking the
|
|
100
|
+
* server. Every memory command and `workspace current` read this, so reads and writes can never
|
|
101
|
+
* disagree again. Returns { workspaceId, label, key, source, path } or null when nothing local
|
|
102
|
+
* is set (the caller then falls back to the server's per-client selection).
|
|
103
|
+
*/
|
|
104
|
+
function resolveLocalWorkspaceSelection({ cwd = process.cwd(), env = process.env } = {}) {
|
|
105
|
+
const fromEnv = String(env[WORKSPACE_ENV_VAR] || '').trim();
|
|
106
|
+
if (fromEnv) {
|
|
107
|
+
return { workspaceId: fromEnv, label: null, key: null, source: 'env', path: WORKSPACE_ENV_VAR };
|
|
108
|
+
}
|
|
109
|
+
const project = findProjectWorkspaceFile(cwd);
|
|
110
|
+
if (project) {
|
|
111
|
+
const { workspaceId, label = null, key = null } = project.value;
|
|
112
|
+
return { workspaceId: workspaceId.trim(), label, key, source: 'project', path: project.path };
|
|
113
|
+
}
|
|
114
|
+
const global = readSelectedWorkspace();
|
|
115
|
+
if (global) {
|
|
116
|
+
return { workspaceId: global.workspaceId, label: global.label || null, key: global.key || null, source: 'global', path: SELECTED_WORKSPACE_PATH };
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function withSelectedWorkspaceContext(fn) {
|
|
122
|
+
const local = resolveLocalWorkspaceSelection();
|
|
123
|
+
if (local?.workspaceId) {
|
|
124
|
+
const previousWorkspaceId = globalThis.__empiricalCliWorkspaceId || null;
|
|
125
|
+
globalThis.__empiricalCliWorkspaceId = local.workspaceId;
|
|
126
|
+
try {
|
|
127
|
+
return await fn();
|
|
128
|
+
} finally {
|
|
129
|
+
globalThis.__empiricalCliWorkspaceId = previousWorkspaceId;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const accessToken = resolveAccessToken();
|
|
134
|
+
const clientId = getEnv('EMPIRICAL_CLIENT_ID') || readStoredOAuthClient()?.clientId;
|
|
135
|
+
if (!accessToken || !clientId) return fn();
|
|
136
|
+
|
|
137
|
+
const previousWorkspaceId = globalThis.__empiricalCliWorkspaceId || null;
|
|
138
|
+
globalThis.__empiricalCliWorkspaceId = null;
|
|
139
|
+
try {
|
|
140
|
+
const baseUrl = resolveApiBaseUrl();
|
|
141
|
+
const currentUrl = new URL(
|
|
142
|
+
`/oauth/clients/${encodeURIComponent(clientId)}/workspace-selection`,
|
|
143
|
+
baseUrl
|
|
144
|
+
).toString();
|
|
145
|
+
try {
|
|
146
|
+
const payload = await httpJson(
|
|
147
|
+
currentUrl,
|
|
148
|
+
'GET',
|
|
149
|
+
null,
|
|
150
|
+
buildApiAuthHeaders(accessToken),
|
|
151
|
+
WORKSPACE_CONTEXT_TIMEOUT_MS
|
|
152
|
+
);
|
|
153
|
+
globalThis.__empiricalCliWorkspaceId = payload?.current?.workspaceId || null;
|
|
154
|
+
} catch (_error) {
|
|
155
|
+
globalThis.__empiricalCliWorkspaceId = null;
|
|
156
|
+
}
|
|
157
|
+
return await fn();
|
|
158
|
+
} finally {
|
|
159
|
+
globalThis.__empiricalCliWorkspaceId = previousWorkspaceId;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = {
|
|
164
|
+
withSelectedWorkspaceContext,
|
|
165
|
+
resolveLocalWorkspaceSelection,
|
|
166
|
+
readSelectedWorkspace,
|
|
167
|
+
writeSelectedWorkspace,
|
|
168
|
+
clearSelectedWorkspace,
|
|
169
|
+
findProjectWorkspaceFile,
|
|
170
|
+
findGitRoot,
|
|
171
|
+
projectWorkspacePathFor,
|
|
172
|
+
writeProjectWorkspace,
|
|
173
|
+
clearProjectWorkspace,
|
|
174
|
+
WORKSPACE_ENV_VAR,
|
|
175
|
+
SELECTED_WORKSPACE_PATH
|
|
176
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@empirical-gauzza/cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.36",
|
|
4
4
|
"description": "Persistent cross-tool memory for AI agents. Give Claude Code, Codex, Copilot, Cursor, and ChatGPT one shared memory: save project context once, recall it everywhere via CLI or MCP.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"memory",
|