@empirical-gauzza/cli 1.0.9
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 +125 -0
- package/lib/auth.js +804 -0
- package/lib/constants.js +491 -0
- package/lib/deviceConfig.js +94 -0
- package/lib/doc.js +50 -0
- package/lib/doctor.js +116 -0
- package/lib/env.js +49 -0
- package/lib/fsUtils.js +90 -0
- package/lib/help.js +113 -0
- package/lib/http.js +179 -0
- package/lib/installWizard.js +383 -0
- package/lib/installers/claude.js +89 -0
- package/lib/installers/codex.js +78 -0
- package/lib/installers/copilot.js +77 -0
- package/lib/installers/detect.js +47 -0
- package/lib/installers/hooks.js +128 -0
- package/lib/installers/nativePlugin.js +87 -0
- package/lib/installers/promptBlocks.js +403 -0
- package/lib/kitCommands.js +622 -0
- package/lib/memoryCommands.js +528 -0
- package/lib/oauthClient.js +222 -0
- package/lib/pluginCommands.js +81 -0
- package/lib/pluginUpdateCheck.js +143 -0
- package/lib/policyCommands.js +144 -0
- package/lib/scheduledSync.js +331 -0
- package/lib/skillSync.js +508 -0
- package/lib/ui.js +280 -0
- package/lib/workspaceCommands.js +323 -0
- package/lib/workspaceContext.js +76 -0
- package/package.json +25 -0
package/lib/ui.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const readline = require('readline');
|
|
4
|
+
|
|
5
|
+
function log(msg) {
|
|
6
|
+
process.stdout.write(`${msg}\n`);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function warn(msg) {
|
|
10
|
+
process.stderr.write(`${msg}\n`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const ANSI = {
|
|
14
|
+
reset: '\x1b[0m',
|
|
15
|
+
bold: '\x1b[1m',
|
|
16
|
+
underline: '\x1b[4m',
|
|
17
|
+
dim: '\x1b[2m',
|
|
18
|
+
red: '\x1b[31m',
|
|
19
|
+
green: '\x1b[32m',
|
|
20
|
+
yellow: '\x1b[33m',
|
|
21
|
+
blue: '\x1b[34m',
|
|
22
|
+
cyan: '\x1b[36m'
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function supportsColor() {
|
|
26
|
+
return process.stdout.isTTY && !process.env.NO_COLOR;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function colorize(code, text) {
|
|
30
|
+
if (!supportsColor()) return String(text);
|
|
31
|
+
return `${code}${text}${ANSI.reset}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function uiHeader(text) {
|
|
35
|
+
return colorize(`${ANSI.bold}${ANSI.blue}`, text);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function uiQuestion(text) {
|
|
39
|
+
return `${colorize(ANSI.cyan, '?')} ${text}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function uiSuccess(text) {
|
|
43
|
+
return `${colorize(ANSI.green, '✓')} ${text}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function uiWarn(text) {
|
|
47
|
+
return `${colorize(ANSI.yellow, '!')} ${text}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function uiDim(text) {
|
|
51
|
+
return colorize(ANSI.dim, text);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function uiLink(text) {
|
|
55
|
+
return colorize(`${ANSI.bold}${ANSI.underline}${ANSI.cyan}`, text);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function formatHeadlessErrorHint(message = '') {
|
|
59
|
+
const raw = String(message || '').toLowerCase();
|
|
60
|
+
if (raw.includes('user not found') || raw.includes('inactive') || raw.includes('access_denied')) {
|
|
61
|
+
return 'Verify Settings client credentials (client ID + client secret) and account status.';
|
|
62
|
+
}
|
|
63
|
+
if (raw.includes('invalid_client')) {
|
|
64
|
+
return 'Client credentials are invalid. Use Settings -> API Tool Credentials and retry.';
|
|
65
|
+
}
|
|
66
|
+
if (raw.includes('invalid_grant') || raw.includes('invalid token')) {
|
|
67
|
+
return 'Auth grant/token was rejected. Re-enter credentials and retry.';
|
|
68
|
+
}
|
|
69
|
+
return '';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseSelection(input, max) {
|
|
73
|
+
const raw = (input || '').trim();
|
|
74
|
+
if (!raw) {
|
|
75
|
+
return Array.from({ length: max }, (_v, i) => i);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const indexes = raw
|
|
79
|
+
.split(',')
|
|
80
|
+
.map((part) => part.trim())
|
|
81
|
+
.filter(Boolean)
|
|
82
|
+
.map((part) => Number(part) - 1)
|
|
83
|
+
.filter((idx) => Number.isInteger(idx) && idx >= 0 && idx < max);
|
|
84
|
+
|
|
85
|
+
return Array.from(new Set(indexes));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// `output` defaults to stdout (the original behavior, unchanged for every existing caller).
|
|
89
|
+
// skillSync.js's review-gate prompts pass `process.stderr` instead — `kit sync`/`kit use`'s
|
|
90
|
+
// stdout contract is a single machine-parsed JSON summary line, so prompt text (and any human-
|
|
91
|
+
// readable pre-answer output) must never land there.
|
|
92
|
+
async function ask(question, output = process.stdout) {
|
|
93
|
+
const rl = readline.createInterface({
|
|
94
|
+
input: process.stdin,
|
|
95
|
+
output
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const answer = await new Promise((resolve) => {
|
|
99
|
+
rl.question(question, resolve);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
rl.close();
|
|
103
|
+
return answer;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function askYesNo(question, defaultYes = true, output = process.stdout) {
|
|
107
|
+
const suffix = defaultYes ? 'Y/n' : 'y/N';
|
|
108
|
+
const answer = (await ask(uiQuestion(`${question} ${uiDim(`(${suffix})`)} `), output)).trim().toLowerCase();
|
|
109
|
+
if (!answer) return defaultYes;
|
|
110
|
+
if (['y', 'yes'].includes(answer)) return true;
|
|
111
|
+
if (['n', 'no'].includes(answer)) return false;
|
|
112
|
+
return defaultYes;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// A prompter for asking MULTIPLE sequential y/n questions in one process run (e.g.
|
|
116
|
+
// skillSync.js's US-016 review gate, which may need to ask about several skills in one
|
|
117
|
+
// `kit sync` invocation). Deliberately does NOT use readline: readline.createInterface()
|
|
118
|
+
// auto-closes itself as soon as its underlying input stream fires 'end' — and against a piped,
|
|
119
|
+
// non-TTY stdin (as in CLI-subprocess tests, or any script feeding all answers up front and
|
|
120
|
+
// then closing stdin) that 'end' can arrive well before every question has been asked, even
|
|
121
|
+
// with a single shared interface. Once closed, any further rl.question() call throws
|
|
122
|
+
// ERR_USE_AFTER_CLOSE instead of resolving. Reading stdin manually and buffering it ourselves
|
|
123
|
+
// sidesteps this: all piped lines are captured as they arrive (or immediately, if already
|
|
124
|
+
// buffered before we start reading), regardless of how many questions we've asked so far.
|
|
125
|
+
function createPrompter(output = process.stdout) {
|
|
126
|
+
let buffer = '';
|
|
127
|
+
let ended = false;
|
|
128
|
+
const waiters = [];
|
|
129
|
+
|
|
130
|
+
function flush() {
|
|
131
|
+
while (waiters.length > 0) {
|
|
132
|
+
const newlineIndex = buffer.indexOf('\n');
|
|
133
|
+
if (newlineIndex === -1) {
|
|
134
|
+
if (!ended) break;
|
|
135
|
+
const resolve = waiters.shift();
|
|
136
|
+
const rest = buffer;
|
|
137
|
+
buffer = '';
|
|
138
|
+
resolve(rest);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const line = buffer.slice(0, newlineIndex).replace(/\r$/, '');
|
|
142
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
143
|
+
waiters.shift()(line);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function onData(chunk) {
|
|
148
|
+
buffer += chunk;
|
|
149
|
+
flush();
|
|
150
|
+
}
|
|
151
|
+
function onEnd() {
|
|
152
|
+
ended = true;
|
|
153
|
+
flush();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
process.stdin.setEncoding('utf8');
|
|
157
|
+
process.stdin.on('data', onData);
|
|
158
|
+
process.stdin.on('end', onEnd);
|
|
159
|
+
|
|
160
|
+
function readLine() {
|
|
161
|
+
return new Promise((resolve) => {
|
|
162
|
+
waiters.push(resolve);
|
|
163
|
+
flush();
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function promptYesNo(question, defaultYes = true) {
|
|
168
|
+
const suffix = defaultYes ? 'Y/n' : 'y/N';
|
|
169
|
+
output.write(uiQuestion(`${question} ${uiDim(`(${suffix})`)} `) + '\n');
|
|
170
|
+
const answer = (await readLine()).trim().toLowerCase();
|
|
171
|
+
if (!answer) return defaultYes;
|
|
172
|
+
if (['y', 'yes'].includes(answer)) return true;
|
|
173
|
+
if (['n', 'no'].includes(answer)) return false;
|
|
174
|
+
return defaultYes;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function close() {
|
|
178
|
+
process.stdin.removeListener('data', onData);
|
|
179
|
+
process.stdin.removeListener('end', onEnd);
|
|
180
|
+
// Removing the 'data' listener alone isn't enough to let the process exit: once a stream
|
|
181
|
+
// has been put into flowing mode it keeps its underlying handle referenced/active until
|
|
182
|
+
// explicitly paused, which would otherwise hang every interactive `kit sync` run forever
|
|
183
|
+
// (even ones where zero skills actually triggered a prompt).
|
|
184
|
+
process.stdin.pause();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { askYesNo: promptYesNo, close };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function parseSelectionList(answer, max) {
|
|
191
|
+
const selected = new Set();
|
|
192
|
+
const parts = String(answer || '')
|
|
193
|
+
.split(',')
|
|
194
|
+
.map((item) => item.trim())
|
|
195
|
+
.filter(Boolean);
|
|
196
|
+
|
|
197
|
+
for (const part of parts) {
|
|
198
|
+
const rangeMatch = part.match(/^(\d+)\s*-\s*(\d+)$/);
|
|
199
|
+
if (rangeMatch) {
|
|
200
|
+
const start = Number(rangeMatch[1]);
|
|
201
|
+
const end = Number(rangeMatch[2]);
|
|
202
|
+
if (
|
|
203
|
+
Number.isInteger(start) &&
|
|
204
|
+
Number.isInteger(end) &&
|
|
205
|
+
start >= 1 &&
|
|
206
|
+
end >= start &&
|
|
207
|
+
end <= max
|
|
208
|
+
) {
|
|
209
|
+
for (let i = start; i <= end; i += 1) selected.add(i);
|
|
210
|
+
}
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const value = Number(part);
|
|
214
|
+
if (Number.isInteger(value) && value >= 1 && value <= max) {
|
|
215
|
+
selected.add(value);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return selected;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function askWorkflowMultiSelect(question, options, defaults) {
|
|
223
|
+
const optionCount = options.length;
|
|
224
|
+
const selected = new Set(defaults);
|
|
225
|
+
|
|
226
|
+
while (true) {
|
|
227
|
+
log(uiQuestion(question));
|
|
228
|
+
for (let index = 0; index < options.length; index += 1) {
|
|
229
|
+
const item = options[index];
|
|
230
|
+
const checked = selected.has(item.id) ? '[x]' : '[ ]';
|
|
231
|
+
log(` ${index + 1}. ${checked} ${item.label}`);
|
|
232
|
+
}
|
|
233
|
+
const answer = (await ask(
|
|
234
|
+
uiDim('Press Enter to continue, or type numbers/ranges to toggle (example: 2,4-5): ')
|
|
235
|
+
)).trim();
|
|
236
|
+
|
|
237
|
+
if (!answer) {
|
|
238
|
+
if (!selected.size) {
|
|
239
|
+
warn(uiWarn('At least one workflow must remain selected.'));
|
|
240
|
+
log('');
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
return options.filter((item) => selected.has(item.id)).map((item) => item.id);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const indices = parseSelectionList(answer, optionCount);
|
|
247
|
+
if (!indices.size) {
|
|
248
|
+
warn(uiWarn('No valid selections were provided. Use numbers like 1,3 or ranges like 2-4.'));
|
|
249
|
+
log('');
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
for (const idx of indices) {
|
|
253
|
+
const id = options[idx - 1].id;
|
|
254
|
+
if (selected.has(id)) selected.delete(id);
|
|
255
|
+
else selected.add(id);
|
|
256
|
+
}
|
|
257
|
+
log('');
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
module.exports = {
|
|
262
|
+
log,
|
|
263
|
+
warn,
|
|
264
|
+
ANSI,
|
|
265
|
+
supportsColor,
|
|
266
|
+
colorize,
|
|
267
|
+
uiHeader,
|
|
268
|
+
uiQuestion,
|
|
269
|
+
uiSuccess,
|
|
270
|
+
uiWarn,
|
|
271
|
+
uiDim,
|
|
272
|
+
uiLink,
|
|
273
|
+
formatHeadlessErrorHint,
|
|
274
|
+
parseSelection,
|
|
275
|
+
ask,
|
|
276
|
+
askYesNo,
|
|
277
|
+
createPrompter,
|
|
278
|
+
parseSelectionList,
|
|
279
|
+
askWorkflowMultiSelect
|
|
280
|
+
};
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { log, ask, uiSuccess } = require('./ui');
|
|
4
|
+
const { httpJson } = require('./http');
|
|
5
|
+
const { getEnv, parseFlag, parseOption } = require('./env');
|
|
6
|
+
const { ensureHeadlessToken, resolveAccessToken, buildApiAuthHeaders } = require('./auth');
|
|
7
|
+
const { readStoredOAuthClient } = require('./oauthClient');
|
|
8
|
+
const { deriveBaseUrlFromMcpUrl } = require('./constants');
|
|
9
|
+
const { withCliAnalyticsContext } = require('./memoryCommands');
|
|
10
|
+
const { readSelectedWorkspace, writeSelectedWorkspace, clearSelectedWorkspace } = require('./workspaceContext');
|
|
11
|
+
|
|
12
|
+
function isWorkspaceCommand(args) {
|
|
13
|
+
if (args[0] !== 'workspace') return false;
|
|
14
|
+
if (['list', 'current', 'select', 'use', 'create', 'delete'].includes(args[1])) return true;
|
|
15
|
+
return args[1] === 'invite' && ['create', 'list', 'revoke'].includes(args[2]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function workspaceOptions(payload) {
|
|
19
|
+
return [
|
|
20
|
+
...(payload?.defaultOption ? [{ ...payload.defaultOption, isDefault: true }] : []),
|
|
21
|
+
...((payload?.workspaces || []).map((workspace) => ({ ...workspace, isDefault: false })))
|
|
22
|
+
];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function positionalArgs(args, startIndex) {
|
|
26
|
+
const valueOptions = new Set(['--description', '--role', '--expires-in-days', '--max-uses']);
|
|
27
|
+
const positional = [];
|
|
28
|
+
for (let index = startIndex; index < args.length; index += 1) {
|
|
29
|
+
if (valueOptions.has(args[index])) {
|
|
30
|
+
index += 1;
|
|
31
|
+
} else if (!args[index].startsWith('--')) {
|
|
32
|
+
positional.push(args[index]);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return positional;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getTeamBrains(payload) {
|
|
39
|
+
return Array.isArray(payload?.teamBrains) ? payload.teamBrains : [];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function targetFromOption(option, teamBrain) {
|
|
43
|
+
return {
|
|
44
|
+
...(option || {}),
|
|
45
|
+
key: option?.key || teamBrain?.workspaceId,
|
|
46
|
+
selectionKey: option?.key || teamBrain?.workspaceId || option?.workspaceId,
|
|
47
|
+
label: option?.label || teamBrain?.name,
|
|
48
|
+
isDefault: Boolean(option?.isDefault),
|
|
49
|
+
workspaceId: teamBrain?.workspaceId || option?.workspaceId || null,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function ambiguousWorkspaceTarget() {
|
|
54
|
+
return { ambiguous: true };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function resolveWorkspaceTarget(requested, selectablePayload, teamBrainsPayload) {
|
|
58
|
+
const value = String(requested || '').trim();
|
|
59
|
+
const options = workspaceOptions(selectablePayload);
|
|
60
|
+
const teamBrains = getTeamBrains(teamBrainsPayload);
|
|
61
|
+
if (!value) return null;
|
|
62
|
+
|
|
63
|
+
const byNumber = Number(value);
|
|
64
|
+
if (Number.isInteger(byNumber) && byNumber >= 1 && byNumber <= options.length) {
|
|
65
|
+
const option = options[byNumber - 1];
|
|
66
|
+
if (option.isDefault) return targetFromOption(option, null);
|
|
67
|
+
const teamMatches = teamBrains.filter((brain) => brain.workspaceId === option.workspaceId
|
|
68
|
+
|| (!option.workspaceId && String(brain.name || '').toLowerCase() === String(option.label || '').toLowerCase()));
|
|
69
|
+
if (teamMatches.length > 1) return ambiguousWorkspaceTarget();
|
|
70
|
+
return targetFromOption(option, teamMatches[0]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const lowerValue = value.toLowerCase();
|
|
74
|
+
const byId = teamBrains.filter((brain) => String(brain.workspaceId || '').toLowerCase() === lowerValue);
|
|
75
|
+
if (byId.length > 1) return ambiguousWorkspaceTarget();
|
|
76
|
+
if (byId.length === 1) {
|
|
77
|
+
const option = options.find((item) => item.workspaceId === byId[0].workspaceId
|
|
78
|
+
|| String(item.label || '').toLowerCase() === String(byId[0].name || '').toLowerCase());
|
|
79
|
+
return targetFromOption(option, byId[0]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const byKey = options.filter((item) => String(item.key || '').toLowerCase() === lowerValue);
|
|
83
|
+
if (byKey.length > 1) return ambiguousWorkspaceTarget();
|
|
84
|
+
if (byKey.length === 1) {
|
|
85
|
+
const teamMatches = teamBrains.filter((brain) => brain.workspaceId === byKey[0].workspaceId
|
|
86
|
+
|| (!byKey[0].workspaceId && String(brain.name || '').toLowerCase() === String(byKey[0].label || '').toLowerCase()));
|
|
87
|
+
if (teamMatches.length > 1) return ambiguousWorkspaceTarget();
|
|
88
|
+
return targetFromOption(byKey[0], teamMatches[0]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const byName = teamBrains.filter((brain) => String(brain.name || '').toLowerCase() === lowerValue);
|
|
92
|
+
if (byName.length > 1) return ambiguousWorkspaceTarget();
|
|
93
|
+
if (byName.length === 1) {
|
|
94
|
+
const option = options.find((item) => item.workspaceId === byName[0].workspaceId
|
|
95
|
+
|| String(item.label || '').toLowerCase() === lowerValue);
|
|
96
|
+
return targetFromOption(option, byName[0]);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const byLabel = options.filter((item) => String(item.label || '').toLowerCase() === lowerValue);
|
|
100
|
+
if (byLabel.length > 1) return ambiguousWorkspaceTarget();
|
|
101
|
+
return byLabel.length === 1 ? targetFromOption(byLabel[0], null) : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function positiveIntegerOption(args, optionName) {
|
|
105
|
+
const raw = parseOption(args, optionName, '');
|
|
106
|
+
if (!raw) return undefined;
|
|
107
|
+
const value = Number(raw);
|
|
108
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
109
|
+
throw new Error(`${optionName} must be a positive integer.`);
|
|
110
|
+
}
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function getWorkspaceCommandAuth() {
|
|
115
|
+
const configuredAccessToken = resolveAccessToken();
|
|
116
|
+
const tokenPayload = configuredAccessToken ? null : await ensureHeadlessToken({ promptCredentials: true });
|
|
117
|
+
const accessToken = configuredAccessToken || tokenPayload?.apiKey || tokenPayload?.accessToken;
|
|
118
|
+
if (!accessToken) {
|
|
119
|
+
throw new Error('No access token available. Run `empirical oauth bootstrap headless --prompt-credentials` first.');
|
|
120
|
+
}
|
|
121
|
+
const clientId = tokenPayload?.clientId || getEnv('EMPIRICAL_CLIENT_ID') || readStoredOAuthClient()?.clientId;
|
|
122
|
+
if (!clientId) {
|
|
123
|
+
throw new Error('No OAuth client ID found. Run `empirical oauth client set --client-id <id>` first.');
|
|
124
|
+
}
|
|
125
|
+
const baseUrl = getEnv('EMPIRICAL_BASE_URL', deriveBaseUrlFromMcpUrl() || 'https://empirical.gauzza.com');
|
|
126
|
+
return { baseUrl, clientId, accessToken };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function runWorkspaceCommand(args) {
|
|
130
|
+
return withCliAnalyticsContext(
|
|
131
|
+
{ command: `workspace.${args[1] || 'unknown'}`, subcommand: args[1] || 'unknown' },
|
|
132
|
+
async () => {
|
|
133
|
+
const action = args[1];
|
|
134
|
+
const { baseUrl, clientId, accessToken } = await getWorkspaceCommandAuth();
|
|
135
|
+
const encodedClientId = encodeURIComponent(clientId);
|
|
136
|
+
const listUrl = new URL(`/oauth/clients/${encodedClientId}/workspaces`, baseUrl).toString();
|
|
137
|
+
const currentUrl = new URL(`/oauth/clients/${encodedClientId}/workspace-selection`, baseUrl).toString();
|
|
138
|
+
const teamBrainsUrl = new URL('/api/v1/team-brains', baseUrl).toString();
|
|
139
|
+
const authHeaders = buildApiAuthHeaders(accessToken);
|
|
140
|
+
|
|
141
|
+
if (action === 'list') {
|
|
142
|
+
const payload = await httpJson(listUrl, 'GET', null, authHeaders);
|
|
143
|
+
log('Selectable workspaces:');
|
|
144
|
+
log(` 1. ${payload.defaultOption.label}${payload.defaultOption.selected ? ' (current)' : ''}`);
|
|
145
|
+
(payload.workspaces || []).forEach((ws, index) => {
|
|
146
|
+
log(` ${index + 2}. ${ws.label}${ws.selected ? ' (current)' : ''}`);
|
|
147
|
+
});
|
|
148
|
+
if (payload.locked) {
|
|
149
|
+
log(' (workspace selection is admin-locked for this client)');
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (action === 'current') {
|
|
155
|
+
const locallySelected = readSelectedWorkspace();
|
|
156
|
+
if (locallySelected) {
|
|
157
|
+
log(`Current workspace: ${locallySelected.label || locallySelected.workspaceId}`);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const payload = await httpJson(currentUrl, 'GET', null, authHeaders);
|
|
161
|
+
log(`Current workspace: ${payload?.current?.label || 'Default Brain'}`);
|
|
162
|
+
if (payload?.locked) {
|
|
163
|
+
log('This OAuth client is admin-locked to a workspace.');
|
|
164
|
+
}
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (action === 'select' || action === 'use') {
|
|
169
|
+
const [payload, teamPayload] = await Promise.all([
|
|
170
|
+
httpJson(listUrl, 'GET', null, authHeaders),
|
|
171
|
+
httpJson(teamBrainsUrl, 'GET', null, authHeaders),
|
|
172
|
+
]);
|
|
173
|
+
if (payload.locked) {
|
|
174
|
+
throw new Error('Workspace is admin-locked for this OAuth client and cannot be changed.');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const options = workspaceOptions(payload);
|
|
178
|
+
|
|
179
|
+
let requested = args.slice(2).join(' ').trim();
|
|
180
|
+
if (!requested && process.stdin.isTTY) {
|
|
181
|
+
log('Select workspace:');
|
|
182
|
+
options.forEach((item, idx) => log(` ${idx + 1}. ${item.label}`));
|
|
183
|
+
const answer = (await ask('Enter number: ')).trim();
|
|
184
|
+
requested = answer;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!requested) {
|
|
188
|
+
throw new Error('Please provide a workspace selection, e.g. `empirical workspace use 1` or `empirical workspace use "Default Brain"`.');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const target = resolveWorkspaceTarget(requested, payload, teamPayload);
|
|
192
|
+
if (target?.ambiguous) throw new Error(`Ambiguous workspace: ${requested}. Use its list number or workspace ID.`);
|
|
193
|
+
if (!target?.selectionKey) throw new Error(`Workspace not found: ${requested}`);
|
|
194
|
+
|
|
195
|
+
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}`));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (action === 'create') {
|
|
207
|
+
const name = positionalArgs(args, 2).join(' ').trim();
|
|
208
|
+
if (!name) throw new Error('Usage: empirical workspace create <name> [--description <text>]');
|
|
209
|
+
const description = parseOption(args, '--description', '');
|
|
210
|
+
const created = await httpJson(
|
|
211
|
+
teamBrainsUrl,
|
|
212
|
+
'POST',
|
|
213
|
+
{ name, ...(description ? { description } : {}) },
|
|
214
|
+
authHeaders
|
|
215
|
+
);
|
|
216
|
+
const workspace = created?.teamBrain || created?.workspace || created;
|
|
217
|
+
log(uiSuccess(`Workspace created: ${workspace?.name || name} (${workspace?.workspaceId || workspace?.id || 'unknown'})`));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (action === 'delete') {
|
|
222
|
+
const requested = positionalArgs(args, 2).join(' ').trim();
|
|
223
|
+
if (!requested) throw new Error('Usage: empirical workspace delete <number|name|id> [--confirm]');
|
|
224
|
+
const [selectablePayload, teamPayload] = await Promise.all([
|
|
225
|
+
httpJson(listUrl, 'GET', null, authHeaders),
|
|
226
|
+
httpJson(teamBrainsUrl, 'GET', null, authHeaders),
|
|
227
|
+
]);
|
|
228
|
+
const target = resolveWorkspaceTarget(requested, selectablePayload, teamPayload);
|
|
229
|
+
if (target?.ambiguous) throw new Error(`Ambiguous workspace: ${requested}. Use its list number or workspace ID.`);
|
|
230
|
+
if (!target) throw new Error(`Workspace not found: ${requested}`);
|
|
231
|
+
if (target.isDefault || target.key === 'default_brain') {
|
|
232
|
+
throw new Error('The Default Brain cannot be deleted.');
|
|
233
|
+
}
|
|
234
|
+
if (!target.workspaceId) throw new Error(`Workspace is not a deletable team brain: ${target.label}`);
|
|
235
|
+
|
|
236
|
+
const targetDescription = `${target.label} (${target.workspaceId})`;
|
|
237
|
+
if (!parseFlag(args, '--confirm')) {
|
|
238
|
+
log(`Dry run: would soft-delete workspace ${targetDescription}. This is recoverable by data retention but cannot be undone in the UI. Re-run with --confirm to proceed.`);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
log(`Soft-deleting workspace ${targetDescription}...`);
|
|
243
|
+
await httpJson(new URL(`/api/v1/team-brains/${encodeURIComponent(target.workspaceId)}`, baseUrl).toString(), 'DELETE', null, authHeaders);
|
|
244
|
+
if (target.selected) {
|
|
245
|
+
const updateUrl = new URL(`/oauth/clients/${encodedClientId}/workspace-selection`, baseUrl).toString();
|
|
246
|
+
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 {
|
|
250
|
+
log(uiSuccess(`Soft-deleted workspace ${targetDescription}.`));
|
|
251
|
+
}
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (action === 'invite') {
|
|
256
|
+
const inviteAction = args[2];
|
|
257
|
+
const currentPayload = await httpJson(currentUrl, 'GET', null, authHeaders);
|
|
258
|
+
const current = currentPayload?.current;
|
|
259
|
+
if (!current?.key || current.key === 'default_brain') {
|
|
260
|
+
throw new Error('Workspace invites require an active team workspace. Use `empirical workspace use <workspace>` first.');
|
|
261
|
+
}
|
|
262
|
+
const teamPayload = await httpJson(teamBrainsUrl, 'GET', null, authHeaders);
|
|
263
|
+
const target = resolveWorkspaceTarget(
|
|
264
|
+
current.workspaceId || current.key,
|
|
265
|
+
{ workspaces: [{ key: current.key, label: current.label, workspaceId: current.workspaceId }] },
|
|
266
|
+
teamPayload
|
|
267
|
+
);
|
|
268
|
+
if (!target?.workspaceId) {
|
|
269
|
+
throw new Error('The active workspace is not a team workspace available for invites.');
|
|
270
|
+
}
|
|
271
|
+
const inviteUrl = new URL(`/api/v1/team-brains/${encodeURIComponent(target.workspaceId)}/invites`, baseUrl).toString();
|
|
272
|
+
|
|
273
|
+
if (inviteAction === 'create') {
|
|
274
|
+
const role = parseOption(args, '--role', 'reader');
|
|
275
|
+
if (!['reader', 'writer', 'manager'].includes(role)) {
|
|
276
|
+
throw new Error('--role must be reader, writer, or manager.');
|
|
277
|
+
}
|
|
278
|
+
const expiresInDays = positiveIntegerOption(args, '--expires-in-days');
|
|
279
|
+
const maxUses = positiveIntegerOption(args, '--max-uses');
|
|
280
|
+
const payload = await httpJson(inviteUrl, 'POST', {
|
|
281
|
+
accessRole: role,
|
|
282
|
+
...(expiresInDays !== undefined ? { expiresInDays } : {}),
|
|
283
|
+
...(maxUses !== undefined ? { maxUses } : {}),
|
|
284
|
+
}, authHeaders);
|
|
285
|
+
const token = payload?.invite?.token || payload?.token;
|
|
286
|
+
if (!token) throw new Error('Invite creation did not return a token.');
|
|
287
|
+
log(uiSuccess(`Invite created: ${baseUrl.replace(/\/$/, '')}/invite/${token}`));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (inviteAction === 'list') {
|
|
292
|
+
const payload = await httpJson(inviteUrl, 'GET', null, authHeaders);
|
|
293
|
+
const invites = Array.isArray(payload?.invites) ? payload.invites : [];
|
|
294
|
+
if (!invites.length) {
|
|
295
|
+
log(`No active invites for ${target.label}.`);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
log(`Active invites for ${target.label}:`);
|
|
299
|
+
invites.forEach((invite) => {
|
|
300
|
+
log(` ${invite.token} (${invite.accessRole || 'reader'}) ${baseUrl.replace(/\/$/, '')}/invite/${invite.token}`);
|
|
301
|
+
});
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const token = positionalArgs(args, 3).join(' ').trim();
|
|
306
|
+
if (!token) throw new Error('Usage: empirical workspace invite revoke <token>');
|
|
307
|
+
if (!parseFlag(args, '--confirm')) {
|
|
308
|
+
log(`Dry run: would revoke invite ${token} from ${target.label}. Re-run with --confirm to proceed.`);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
await httpJson(`${inviteUrl}/${encodeURIComponent(token)}`, 'DELETE', null, authHeaders);
|
|
312
|
+
log(uiSuccess(`Invite revoked: ${token}`));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
module.exports = {
|
|
319
|
+
isWorkspaceCommand,
|
|
320
|
+
getWorkspaceCommandAuth,
|
|
321
|
+
resolveWorkspaceTarget,
|
|
322
|
+
runWorkspaceCommand
|
|
323
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { httpJson } = require('./http');
|
|
6
|
+
const { getEnv } = require('./env');
|
|
7
|
+
const { resolveAccessToken, resolveApiBaseUrl, buildApiAuthHeaders } = require('./auth');
|
|
8
|
+
const { readStoredOAuthClient } = require('./oauthClient');
|
|
9
|
+
const { readJsonIfExists, writeFile, ensureDir } = require('./fsUtils');
|
|
10
|
+
|
|
11
|
+
const WORKSPACE_CONTEXT_TIMEOUT_MS = 2000;
|
|
12
|
+
const SELECTED_WORKSPACE_PATH = path.join(os.homedir(), '.empirical', 'workspace-selection.json');
|
|
13
|
+
|
|
14
|
+
function readSelectedWorkspace() {
|
|
15
|
+
const value = readJsonIfExists(SELECTED_WORKSPACE_PATH);
|
|
16
|
+
return value && typeof value.workspaceId === 'string' ? value : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function writeSelectedWorkspace(workspace) {
|
|
20
|
+
ensureDir(path.dirname(SELECTED_WORKSPACE_PATH));
|
|
21
|
+
writeFile(SELECTED_WORKSPACE_PATH, `${JSON.stringify(workspace, null, 2)}\n`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function clearSelectedWorkspace() {
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
if (fs.existsSync(SELECTED_WORKSPACE_PATH)) fs.unlinkSync(SELECTED_WORKSPACE_PATH);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function withSelectedWorkspaceContext(fn) {
|
|
30
|
+
const stored = readSelectedWorkspace();
|
|
31
|
+
if (stored?.workspaceId) {
|
|
32
|
+
const previousWorkspaceId = globalThis.__empiricalCliWorkspaceId || null;
|
|
33
|
+
globalThis.__empiricalCliWorkspaceId = stored.workspaceId;
|
|
34
|
+
try {
|
|
35
|
+
return await fn();
|
|
36
|
+
} finally {
|
|
37
|
+
globalThis.__empiricalCliWorkspaceId = previousWorkspaceId;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const accessToken = resolveAccessToken();
|
|
42
|
+
const clientId = getEnv('EMPIRICAL_CLIENT_ID') || readStoredOAuthClient()?.clientId;
|
|
43
|
+
if (!accessToken || !clientId) return fn();
|
|
44
|
+
|
|
45
|
+
const previousWorkspaceId = globalThis.__empiricalCliWorkspaceId || null;
|
|
46
|
+
globalThis.__empiricalCliWorkspaceId = null;
|
|
47
|
+
try {
|
|
48
|
+
const baseUrl = resolveApiBaseUrl();
|
|
49
|
+
const currentUrl = new URL(
|
|
50
|
+
`/oauth/clients/${encodeURIComponent(clientId)}/workspace-selection`,
|
|
51
|
+
baseUrl
|
|
52
|
+
).toString();
|
|
53
|
+
try {
|
|
54
|
+
const payload = await httpJson(
|
|
55
|
+
currentUrl,
|
|
56
|
+
'GET',
|
|
57
|
+
null,
|
|
58
|
+
buildApiAuthHeaders(accessToken),
|
|
59
|
+
WORKSPACE_CONTEXT_TIMEOUT_MS
|
|
60
|
+
);
|
|
61
|
+
globalThis.__empiricalCliWorkspaceId = payload?.current?.workspaceId || null;
|
|
62
|
+
} catch (_error) {
|
|
63
|
+
globalThis.__empiricalCliWorkspaceId = null;
|
|
64
|
+
}
|
|
65
|
+
return await fn();
|
|
66
|
+
} finally {
|
|
67
|
+
globalThis.__empiricalCliWorkspaceId = previousWorkspaceId;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = {
|
|
72
|
+
withSelectedWorkspaceContext,
|
|
73
|
+
readSelectedWorkspace,
|
|
74
|
+
writeSelectedWorkspace,
|
|
75
|
+
clearSelectedWorkspace
|
|
76
|
+
};
|