@empirical-gauzza/cli 1.0.21 → 1.0.22
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/README.md +63 -63
- package/bin/empirical.js +12 -0
- package/lib/auth.js +83 -2
- package/lib/blueprintCommands.js +128 -0
- package/lib/constants.js +27 -0
- package/lib/doc.js +1 -1
- package/lib/env.js +49 -49
- package/lib/help.js +6 -1
- package/lib/installers/detect.js +47 -47
- package/lib/installers/hooks.js +12 -12
- package/lib/memoryCommands.js +50 -0
- package/lib/oauthClient.js +222 -222
- package/lib/policyCommands.js +144 -144
- package/lib/setupCommands.js +123 -0
- package/lib/ui.js +7 -1
- package/lib/workspaceContext.js +47 -47
- package/package.json +1 -1
- package/tests/ui.test.js +42 -0
package/lib/installers/hooks.js
CHANGED
|
@@ -643,20 +643,20 @@ try {
|
|
|
643
643
|
// The message itself is generated per-install from the actual plugin set being installed, not one
|
|
644
644
|
// fixed string for everyone - e.g. someone who declined empirical-scar-tissue at install time
|
|
645
645
|
// never sees a scar-tissue reminder, since that capability isn't actually available to them.
|
|
646
|
-
function buildPromptReminderMessage(pluginNames, platform) {
|
|
646
|
+
function buildPromptReminderMessage(pluginNames, platform) {
|
|
647
647
|
const names = normalizePluginNames(pluginNames);
|
|
648
648
|
const lines = [
|
|
649
649
|
'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.'
|
|
650
650
|
];
|
|
651
|
-
if (names.includes('empirical-work-history')) {
|
|
652
|
-
lines.push('If you are picking up previously completed work, check for a recent work-history entry before assuming there is none.');
|
|
653
|
-
// Codex Stop runs after its final response has already been emitted, so it can only be an
|
|
654
|
-
// advisory reminder. Put the completion gate into the pre-response context instead, where
|
|
655
|
-
// Codex can still act on it. Keep this Codex-specific: Claude and Copilot have their own
|
|
656
|
-
// stop-hook control flows and should not receive a changed prompt contract here.
|
|
657
|
-
if (platform === 'codex') {
|
|
658
|
-
lines.push('If this turn results in meaningful completed work, BEFORE sending your final response you MUST use the empirical-work-history skill or record it via Empirical. The entry must carry tag work-history and mass 1; verify the create receipt. CLI fallback must use --json-file with data.session and data.git; do not use the shorthand record command for coding work. Do not wait for the Stop hook.');
|
|
659
|
-
}
|
|
651
|
+
if (names.includes('empirical-work-history')) {
|
|
652
|
+
lines.push('If you are picking up previously completed work, check for a recent work-history entry before assuming there is none.');
|
|
653
|
+
// Codex Stop runs after its final response has already been emitted, so it can only be an
|
|
654
|
+
// advisory reminder. Put the completion gate into the pre-response context instead, where
|
|
655
|
+
// Codex can still act on it. Keep this Codex-specific: Claude and Copilot have their own
|
|
656
|
+
// stop-hook control flows and should not receive a changed prompt contract here.
|
|
657
|
+
if (platform === 'codex') {
|
|
658
|
+
lines.push('If this turn results in meaningful completed work, BEFORE sending your final response you MUST use the empirical-work-history skill or record it via Empirical. The entry must carry tag work-history and mass 1; verify the create receipt. CLI fallback must use --json-file with data.session and data.git; do not use the shorthand record command for coding work. Do not wait for the Stop hook.');
|
|
659
|
+
}
|
|
660
660
|
}
|
|
661
661
|
if (names.includes('empirical-scar-tissue')) {
|
|
662
662
|
lines.push('Check for scar tissue before repeating a past mistake in this area.');
|
|
@@ -685,8 +685,8 @@ function buildPromptReminderMessage(pluginNames, platform) {
|
|
|
685
685
|
// - default (Claude/Copilot's plain console.log was the old assumption for both - WRONG for
|
|
686
686
|
// Copilot, which requires valid JSON on stdout for every event, confirmed via the same fetch
|
|
687
687
|
// above): plain console.log, confirmed correct for Claude only.
|
|
688
|
-
function buildUserPromptSubmitHookSource(pluginNames, platform) {
|
|
689
|
-
const messageLiteral = JSON.stringify(buildPromptReminderMessage(pluginNames, platform));
|
|
688
|
+
function buildUserPromptSubmitHookSource(pluginNames, platform) {
|
|
689
|
+
const messageLiteral = JSON.stringify(buildPromptReminderMessage(pluginNames, platform));
|
|
690
690
|
|
|
691
691
|
let stdinHelper = '';
|
|
692
692
|
let outputStatement;
|
package/lib/memoryCommands.js
CHANGED
|
@@ -55,6 +55,7 @@ const MEMORY_SUBCOMMAND_USAGE = {
|
|
|
55
55
|
merge: 'Usage: empirical memory merge --canonical-id <id> --duplicate-id <id> [--no-merge-tags] [--no-merge-relationships]',
|
|
56
56
|
export: 'Usage: empirical memory export --scope <personal|workspace> [--out <path>] [--workspace-id <id>]',
|
|
57
57
|
import: 'Usage: empirical memory import <file> [--dry-run]',
|
|
58
|
+
'bulk-import': 'Usage: empirical memory bulk-import <file> [--dry-run]\nFile format: { "memories": [{ "localKey": "...", "category": "...", "nodeType": "...", "summary": "...", "data": {...} }], "relationships": [{ "fromLocalKey": "...", "toLocalKey": "...", "type": "..." }] }',
|
|
58
59
|
stats: 'Usage: empirical memory stats [--format table]',
|
|
59
60
|
compact: 'Usage: empirical memory compact [--apply] [--category <cat>]'
|
|
60
61
|
};
|
|
@@ -762,6 +763,53 @@ async function runMemoryImport(args) {
|
|
|
762
763
|
log(JSON.stringify({ imported, failed, total: memories.length, errors: errors.slice(0, 20) }));
|
|
763
764
|
}
|
|
764
765
|
|
|
766
|
+
async function runMemoryBulkImport(args) {
|
|
767
|
+
const filePath = args.find((a) => a && !a.startsWith('--')) || '';
|
|
768
|
+
const dryRun = parseFlag(args, '--dry-run');
|
|
769
|
+
|
|
770
|
+
if (!filePath) {
|
|
771
|
+
warn('Usage: empirical memory bulk-import <file> [--dry-run]');
|
|
772
|
+
process.exit(1);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
let plan;
|
|
776
|
+
try {
|
|
777
|
+
plan = JSON.parse(fs.readFileSync(path.resolve(filePath), 'utf8'));
|
|
778
|
+
} catch (error) {
|
|
779
|
+
warn(JSON.stringify({ error: true, code: 'json_file_error', message: error.message }));
|
|
780
|
+
process.exit(1);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const memories = Array.isArray(plan && plan.memories) ? plan.memories : null;
|
|
784
|
+
if (!memories) {
|
|
785
|
+
warn(JSON.stringify({
|
|
786
|
+
error: true,
|
|
787
|
+
code: 'invalid_format',
|
|
788
|
+
message: 'File must have a top-level "memories" array. See `empirical memory bulk-import --help`.'
|
|
789
|
+
}));
|
|
790
|
+
process.exit(1);
|
|
791
|
+
}
|
|
792
|
+
const relationships = Array.isArray(plan.relationships) ? plan.relationships : [];
|
|
793
|
+
|
|
794
|
+
if (dryRun) {
|
|
795
|
+
log(JSON.stringify({ dryRun: true, wouldImportMemories: memories.length, wouldImportRelationships: relationships.length }));
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const accessToken = requireAccessToken();
|
|
800
|
+
const baseUrl = resolveApiBaseUrl();
|
|
801
|
+
const bulkUrl = new URL('/api/v1/memories/bulk', baseUrl).toString();
|
|
802
|
+
|
|
803
|
+
try {
|
|
804
|
+
const response = await httpJson(bulkUrl, 'POST', { memories, relationships }, buildApiAuthHeaders(accessToken));
|
|
805
|
+
const data = (response && response.data) ? response.data : response;
|
|
806
|
+
log(JSON.stringify(data));
|
|
807
|
+
} catch (error) {
|
|
808
|
+
printApiError(error);
|
|
809
|
+
process.exit(1);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
765
813
|
async function runMemoryStats(args) {
|
|
766
814
|
const format = parseOption(args, '--format', 'json');
|
|
767
815
|
|
|
@@ -842,6 +890,7 @@ async function runMemoryCommand(subArgs) {
|
|
|
842
890
|
if (sub === 'merge') return runMemoryMerge(rest);
|
|
843
891
|
if (sub === 'export') return runMemoryExport(rest);
|
|
844
892
|
if (sub === 'import') return runMemoryImport(rest);
|
|
893
|
+
if (sub === 'bulk-import') return runMemoryBulkImport(rest);
|
|
845
894
|
if (sub === 'stats') return runMemoryStats(rest);
|
|
846
895
|
if (sub === 'compact') return runMemoryCompact(rest);
|
|
847
896
|
|
|
@@ -867,6 +916,7 @@ module.exports = {
|
|
|
867
916
|
runMemoryMerge,
|
|
868
917
|
runMemoryExport,
|
|
869
918
|
runMemoryImport,
|
|
919
|
+
runMemoryBulkImport,
|
|
870
920
|
runMemoryStats,
|
|
871
921
|
runMemoryCompact,
|
|
872
922
|
runMemoryDeleteBulk,
|
package/lib/oauthClient.js
CHANGED
|
@@ -1,222 +1,222 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const fs = require('fs');
|
|
4
|
-
const os = require('os');
|
|
5
|
-
const path = require('path');
|
|
6
|
-
const { readJsonIfExists, writeFile, ensureDir } = require('./fsUtils');
|
|
7
|
-
const { getEnv } = require('./env');
|
|
8
|
-
const { runResolvedCommand } = require('./fsUtils');
|
|
9
|
-
|
|
10
|
-
function getLocalOAuthClientPath() {
|
|
11
|
-
return path.join(os.homedir(), '.empirical', 'oauth-client.json');
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function readStoredOAuthClient() {
|
|
15
|
-
const stored = readJsonIfExists(getLocalOAuthClientPath());
|
|
16
|
-
if (!stored || typeof stored !== 'object') return null;
|
|
17
|
-
const clientId = String(stored.clientId || '').trim();
|
|
18
|
-
if (!clientId) return null;
|
|
19
|
-
return {
|
|
20
|
-
clientId,
|
|
21
|
-
clientSecret: String(stored.clientSecret || '')
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function writeStoredOAuthClient(clientId, clientSecret) {
|
|
26
|
-
const payload = {
|
|
27
|
-
clientId: String(clientId || '').trim(),
|
|
28
|
-
clientSecret: String(clientSecret || '').trim(),
|
|
29
|
-
updatedAt: new Date().toISOString()
|
|
30
|
-
};
|
|
31
|
-
writeFile(getLocalOAuthClientPath(), `${JSON.stringify(payload, null, 2)}\n`);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function clearStoredOAuthClient() {
|
|
35
|
-
const storePath = getLocalOAuthClientPath();
|
|
36
|
-
if (fs.existsSync(storePath)) {
|
|
37
|
-
fs.unlinkSync(storePath);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function maskSecret(value) {
|
|
42
|
-
const raw = String(value || '');
|
|
43
|
-
if (!raw) return '(not set)';
|
|
44
|
-
if (raw.length <= 8) return '*'.repeat(raw.length);
|
|
45
|
-
return `${raw.slice(0, 4)}...${raw.slice(-4)}`;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function generateRandomString(length = 32) {
|
|
49
|
-
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
|
50
|
-
let result = '';
|
|
51
|
-
for (let i = 0; i < length; i += 1) {
|
|
52
|
-
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
53
|
-
}
|
|
54
|
-
return result;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function base64url(buf) {
|
|
58
|
-
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function generatePKCE() {
|
|
62
|
-
const crypto = require('crypto');
|
|
63
|
-
const codeVerifier = generateRandomString(128);
|
|
64
|
-
const codeChallenge = base64url(crypto.createHash('sha256').update(codeVerifier).digest());
|
|
65
|
-
return { codeVerifier, codeChallenge };
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function decodeJwtPayload(token) {
|
|
69
|
-
try {
|
|
70
|
-
const parts = String(token).split('.');
|
|
71
|
-
if (parts.length !== 3) return null;
|
|
72
|
-
return JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
73
|
-
} catch (_error) {
|
|
74
|
-
return null;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function isJwtToken(token) {
|
|
79
|
-
return typeof token === 'string' && token.split('.').length === 3;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function isJwtExpired(token, skewSeconds = 30) {
|
|
83
|
-
const payload = decodeJwtPayload(token);
|
|
84
|
-
if (!payload || typeof payload.exp !== 'number') return true;
|
|
85
|
-
return payload.exp < Math.floor(Date.now() / 1000) + skewSeconds;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function getHeadlessTokenPath() {
|
|
89
|
-
return path.resolve(getEnv('EMPIRICAL_TOKEN_OUTPUT', '.empirical/headless-token.json'));
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function getOAuthSessionPath() {
|
|
93
|
-
return path.join(os.homedir(), '.empirical', 'oauth-session.json');
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function readOAuthSession() {
|
|
97
|
-
const session = readJsonIfExists(getOAuthSessionPath());
|
|
98
|
-
if (!session || typeof session !== 'object') return null;
|
|
99
|
-
return session;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function writeOAuthSession(session) {
|
|
103
|
-
if (!session?.accessToken) return;
|
|
104
|
-
writeFile(getOAuthSessionPath(), `${JSON.stringify(session, null, 2)}\n`);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function clearOAuthSession() {
|
|
108
|
-
const sessionPath = getOAuthSessionPath();
|
|
109
|
-
if (fs.existsSync(sessionPath)) fs.unlinkSync(sessionPath);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function readHeadlessToken() {
|
|
113
|
-
const tokenPath = getHeadlessTokenPath();
|
|
114
|
-
if (!fs.existsSync(tokenPath)) return null;
|
|
115
|
-
try {
|
|
116
|
-
const parsed = JSON.parse(fs.readFileSync(tokenPath, 'utf8'));
|
|
117
|
-
if (!parsed || typeof parsed !== 'object') {
|
|
118
|
-
return null;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const normalized = { ...parsed };
|
|
122
|
-
if (!normalized.clientId && normalized.authClientId) {
|
|
123
|
-
normalized.clientId = normalized.authClientId;
|
|
124
|
-
}
|
|
125
|
-
if (!normalized.clientSecret && normalized.authClientSecret) {
|
|
126
|
-
normalized.clientSecret = normalized.authClientSecret;
|
|
127
|
-
}
|
|
128
|
-
if (!normalized.apiKey) {
|
|
129
|
-
normalized.apiKey = normalized.clientSecret || normalized.authClientSecret || null;
|
|
130
|
-
}
|
|
131
|
-
return normalized;
|
|
132
|
-
} catch (_error) {
|
|
133
|
-
return null;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function getAccessTokenExpiryMs(token) {
|
|
138
|
-
if (!token || !token.accessToken) return 0;
|
|
139
|
-
const obtainedAtMs = Date.parse(String(token.obtainedAt || ''));
|
|
140
|
-
const expiresInSeconds = Number(token.expiresIn || 0);
|
|
141
|
-
if (!Number.isFinite(obtainedAtMs) || !Number.isFinite(expiresInSeconds) || expiresInSeconds <= 0) {
|
|
142
|
-
return 0;
|
|
143
|
-
}
|
|
144
|
-
return obtainedAtMs + (expiresInSeconds * 1000);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function hasFreshAccessToken(token, skewSeconds = 120) {
|
|
148
|
-
const expiryMs = getAccessTokenExpiryMs(token);
|
|
149
|
-
if (!expiryMs) return false;
|
|
150
|
-
return expiryMs > (Date.now() + (skewSeconds * 1000));
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function persistAccessToken(accessToken) {
|
|
154
|
-
if (!accessToken) return;
|
|
155
|
-
process.env.EMPIRICAL_ACCESS_TOKEN = accessToken;
|
|
156
|
-
if (process.platform !== 'win32') {
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
// `setx` writes permanently into the user's Windows environment, surviving
|
|
160
|
-
// every future shell/process. Only do that for a genuinely permanent
|
|
161
|
-
// credential (a plain apiKey) — a short-lived session JWT written here
|
|
162
|
-
// would silently outlive its own expiry and shadow a valid apiKey in the
|
|
163
|
-
// token file for good, forcing a manual env-var reset roughly every hour.
|
|
164
|
-
if (isJwtToken(accessToken)) {
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
const escaped = String(accessToken).replace(/\"/g, '""');
|
|
168
|
-
runResolvedCommand('setx', ['EMPIRICAL_ACCESS_TOKEN', escaped], { stdio: 'ignore' });
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function toEnvSafe(value) {
|
|
172
|
-
return String(value ?? '')
|
|
173
|
-
.replace(/\\/g, '\\\\')
|
|
174
|
-
.replace(/\n/g, '\\n')
|
|
175
|
-
.replace(/\r/g, '\\r')
|
|
176
|
-
.replace(/"/g, '\\"');
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function writeHeadlessEnvFile(result, outputPath) {
|
|
180
|
-
ensureDir(path.dirname(path.resolve(outputPath)));
|
|
181
|
-
// Prefer the API key as the access token — it never expires and works as a
|
|
182
|
-
// permanent Bearer credential with the MCP endpoint.
|
|
183
|
-
const permanentToken = result.apiKey || result.accessToken;
|
|
184
|
-
const lines = [
|
|
185
|
-
'# Generated by empirical cli',
|
|
186
|
-
`EMPIRICAL_BASE_URL="${toEnvSafe(result.baseUrl)}"`,
|
|
187
|
-
`EMPIRICAL_ACCESS_TOKEN="${toEnvSafe(permanentToken)}"`,
|
|
188
|
-
`EMPIRICAL_API_KEY="${toEnvSafe(result.apiKey || '')}"`,
|
|
189
|
-
`EMPIRICAL_REFRESH_TOKEN="${toEnvSafe(result.refreshToken || '')}"`,
|
|
190
|
-
`EMPIRICAL_CLIENT_ID="${toEnvSafe(result.clientId || '')}"`,
|
|
191
|
-
`EMPIRICAL_CLIENT_SECRET="${toEnvSafe(result.clientSecret || '')}"`,
|
|
192
|
-
`EMPIRICAL_TOKEN_TYPE="${toEnvSafe(result.tokenType || 'Bearer')}"`,
|
|
193
|
-
`EMPIRICAL_TOKEN_OBTAINED_AT="${toEnvSafe(result.obtainedAt || '')}"`,
|
|
194
|
-
`EMPIRICAL_TOKEN_EXPIRES_IN="${toEnvSafe(result.expiresIn || '')}"`
|
|
195
|
-
];
|
|
196
|
-
fs.writeFileSync(path.resolve(outputPath), `${lines.join('\n')}\n`, 'utf8');
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
module.exports = {
|
|
200
|
-
getLocalOAuthClientPath,
|
|
201
|
-
readStoredOAuthClient,
|
|
202
|
-
writeStoredOAuthClient,
|
|
203
|
-
clearStoredOAuthClient,
|
|
204
|
-
maskSecret,
|
|
205
|
-
generateRandomString,
|
|
206
|
-
base64url,
|
|
207
|
-
generatePKCE,
|
|
208
|
-
decodeJwtPayload,
|
|
209
|
-
isJwtToken,
|
|
210
|
-
isJwtExpired,
|
|
211
|
-
getHeadlessTokenPath,
|
|
212
|
-
getOAuthSessionPath,
|
|
213
|
-
readOAuthSession,
|
|
214
|
-
writeOAuthSession,
|
|
215
|
-
clearOAuthSession,
|
|
216
|
-
readHeadlessToken,
|
|
217
|
-
getAccessTokenExpiryMs,
|
|
218
|
-
hasFreshAccessToken,
|
|
219
|
-
persistAccessToken,
|
|
220
|
-
toEnvSafe,
|
|
221
|
-
writeHeadlessEnvFile
|
|
222
|
-
};
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { readJsonIfExists, writeFile, ensureDir } = require('./fsUtils');
|
|
7
|
+
const { getEnv } = require('./env');
|
|
8
|
+
const { runResolvedCommand } = require('./fsUtils');
|
|
9
|
+
|
|
10
|
+
function getLocalOAuthClientPath() {
|
|
11
|
+
return path.join(os.homedir(), '.empirical', 'oauth-client.json');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function readStoredOAuthClient() {
|
|
15
|
+
const stored = readJsonIfExists(getLocalOAuthClientPath());
|
|
16
|
+
if (!stored || typeof stored !== 'object') return null;
|
|
17
|
+
const clientId = String(stored.clientId || '').trim();
|
|
18
|
+
if (!clientId) return null;
|
|
19
|
+
return {
|
|
20
|
+
clientId,
|
|
21
|
+
clientSecret: String(stored.clientSecret || '')
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function writeStoredOAuthClient(clientId, clientSecret) {
|
|
26
|
+
const payload = {
|
|
27
|
+
clientId: String(clientId || '').trim(),
|
|
28
|
+
clientSecret: String(clientSecret || '').trim(),
|
|
29
|
+
updatedAt: new Date().toISOString()
|
|
30
|
+
};
|
|
31
|
+
writeFile(getLocalOAuthClientPath(), `${JSON.stringify(payload, null, 2)}\n`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function clearStoredOAuthClient() {
|
|
35
|
+
const storePath = getLocalOAuthClientPath();
|
|
36
|
+
if (fs.existsSync(storePath)) {
|
|
37
|
+
fs.unlinkSync(storePath);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function maskSecret(value) {
|
|
42
|
+
const raw = String(value || '');
|
|
43
|
+
if (!raw) return '(not set)';
|
|
44
|
+
if (raw.length <= 8) return '*'.repeat(raw.length);
|
|
45
|
+
return `${raw.slice(0, 4)}...${raw.slice(-4)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function generateRandomString(length = 32) {
|
|
49
|
+
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
|
50
|
+
let result = '';
|
|
51
|
+
for (let i = 0; i < length; i += 1) {
|
|
52
|
+
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function base64url(buf) {
|
|
58
|
+
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function generatePKCE() {
|
|
62
|
+
const crypto = require('crypto');
|
|
63
|
+
const codeVerifier = generateRandomString(128);
|
|
64
|
+
const codeChallenge = base64url(crypto.createHash('sha256').update(codeVerifier).digest());
|
|
65
|
+
return { codeVerifier, codeChallenge };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function decodeJwtPayload(token) {
|
|
69
|
+
try {
|
|
70
|
+
const parts = String(token).split('.');
|
|
71
|
+
if (parts.length !== 3) return null;
|
|
72
|
+
return JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
73
|
+
} catch (_error) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isJwtToken(token) {
|
|
79
|
+
return typeof token === 'string' && token.split('.').length === 3;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isJwtExpired(token, skewSeconds = 30) {
|
|
83
|
+
const payload = decodeJwtPayload(token);
|
|
84
|
+
if (!payload || typeof payload.exp !== 'number') return true;
|
|
85
|
+
return payload.exp < Math.floor(Date.now() / 1000) + skewSeconds;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function getHeadlessTokenPath() {
|
|
89
|
+
return path.resolve(getEnv('EMPIRICAL_TOKEN_OUTPUT', '.empirical/headless-token.json'));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getOAuthSessionPath() {
|
|
93
|
+
return path.join(os.homedir(), '.empirical', 'oauth-session.json');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readOAuthSession() {
|
|
97
|
+
const session = readJsonIfExists(getOAuthSessionPath());
|
|
98
|
+
if (!session || typeof session !== 'object') return null;
|
|
99
|
+
return session;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function writeOAuthSession(session) {
|
|
103
|
+
if (!session?.accessToken) return;
|
|
104
|
+
writeFile(getOAuthSessionPath(), `${JSON.stringify(session, null, 2)}\n`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function clearOAuthSession() {
|
|
108
|
+
const sessionPath = getOAuthSessionPath();
|
|
109
|
+
if (fs.existsSync(sessionPath)) fs.unlinkSync(sessionPath);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function readHeadlessToken() {
|
|
113
|
+
const tokenPath = getHeadlessTokenPath();
|
|
114
|
+
if (!fs.existsSync(tokenPath)) return null;
|
|
115
|
+
try {
|
|
116
|
+
const parsed = JSON.parse(fs.readFileSync(tokenPath, 'utf8'));
|
|
117
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const normalized = { ...parsed };
|
|
122
|
+
if (!normalized.clientId && normalized.authClientId) {
|
|
123
|
+
normalized.clientId = normalized.authClientId;
|
|
124
|
+
}
|
|
125
|
+
if (!normalized.clientSecret && normalized.authClientSecret) {
|
|
126
|
+
normalized.clientSecret = normalized.authClientSecret;
|
|
127
|
+
}
|
|
128
|
+
if (!normalized.apiKey) {
|
|
129
|
+
normalized.apiKey = normalized.clientSecret || normalized.authClientSecret || null;
|
|
130
|
+
}
|
|
131
|
+
return normalized;
|
|
132
|
+
} catch (_error) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function getAccessTokenExpiryMs(token) {
|
|
138
|
+
if (!token || !token.accessToken) return 0;
|
|
139
|
+
const obtainedAtMs = Date.parse(String(token.obtainedAt || ''));
|
|
140
|
+
const expiresInSeconds = Number(token.expiresIn || 0);
|
|
141
|
+
if (!Number.isFinite(obtainedAtMs) || !Number.isFinite(expiresInSeconds) || expiresInSeconds <= 0) {
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
return obtainedAtMs + (expiresInSeconds * 1000);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function hasFreshAccessToken(token, skewSeconds = 120) {
|
|
148
|
+
const expiryMs = getAccessTokenExpiryMs(token);
|
|
149
|
+
if (!expiryMs) return false;
|
|
150
|
+
return expiryMs > (Date.now() + (skewSeconds * 1000));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function persistAccessToken(accessToken) {
|
|
154
|
+
if (!accessToken) return;
|
|
155
|
+
process.env.EMPIRICAL_ACCESS_TOKEN = accessToken;
|
|
156
|
+
if (process.platform !== 'win32') {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// `setx` writes permanently into the user's Windows environment, surviving
|
|
160
|
+
// every future shell/process. Only do that for a genuinely permanent
|
|
161
|
+
// credential (a plain apiKey) — a short-lived session JWT written here
|
|
162
|
+
// would silently outlive its own expiry and shadow a valid apiKey in the
|
|
163
|
+
// token file for good, forcing a manual env-var reset roughly every hour.
|
|
164
|
+
if (isJwtToken(accessToken)) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const escaped = String(accessToken).replace(/\"/g, '""');
|
|
168
|
+
runResolvedCommand('setx', ['EMPIRICAL_ACCESS_TOKEN', escaped], { stdio: 'ignore' });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function toEnvSafe(value) {
|
|
172
|
+
return String(value ?? '')
|
|
173
|
+
.replace(/\\/g, '\\\\')
|
|
174
|
+
.replace(/\n/g, '\\n')
|
|
175
|
+
.replace(/\r/g, '\\r')
|
|
176
|
+
.replace(/"/g, '\\"');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function writeHeadlessEnvFile(result, outputPath) {
|
|
180
|
+
ensureDir(path.dirname(path.resolve(outputPath)));
|
|
181
|
+
// Prefer the API key as the access token — it never expires and works as a
|
|
182
|
+
// permanent Bearer credential with the MCP endpoint.
|
|
183
|
+
const permanentToken = result.apiKey || result.accessToken;
|
|
184
|
+
const lines = [
|
|
185
|
+
'# Generated by empirical cli',
|
|
186
|
+
`EMPIRICAL_BASE_URL="${toEnvSafe(result.baseUrl)}"`,
|
|
187
|
+
`EMPIRICAL_ACCESS_TOKEN="${toEnvSafe(permanentToken)}"`,
|
|
188
|
+
`EMPIRICAL_API_KEY="${toEnvSafe(result.apiKey || '')}"`,
|
|
189
|
+
`EMPIRICAL_REFRESH_TOKEN="${toEnvSafe(result.refreshToken || '')}"`,
|
|
190
|
+
`EMPIRICAL_CLIENT_ID="${toEnvSafe(result.clientId || '')}"`,
|
|
191
|
+
`EMPIRICAL_CLIENT_SECRET="${toEnvSafe(result.clientSecret || '')}"`,
|
|
192
|
+
`EMPIRICAL_TOKEN_TYPE="${toEnvSafe(result.tokenType || 'Bearer')}"`,
|
|
193
|
+
`EMPIRICAL_TOKEN_OBTAINED_AT="${toEnvSafe(result.obtainedAt || '')}"`,
|
|
194
|
+
`EMPIRICAL_TOKEN_EXPIRES_IN="${toEnvSafe(result.expiresIn || '')}"`
|
|
195
|
+
];
|
|
196
|
+
fs.writeFileSync(path.resolve(outputPath), `${lines.join('\n')}\n`, 'utf8');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
module.exports = {
|
|
200
|
+
getLocalOAuthClientPath,
|
|
201
|
+
readStoredOAuthClient,
|
|
202
|
+
writeStoredOAuthClient,
|
|
203
|
+
clearStoredOAuthClient,
|
|
204
|
+
maskSecret,
|
|
205
|
+
generateRandomString,
|
|
206
|
+
base64url,
|
|
207
|
+
generatePKCE,
|
|
208
|
+
decodeJwtPayload,
|
|
209
|
+
isJwtToken,
|
|
210
|
+
isJwtExpired,
|
|
211
|
+
getHeadlessTokenPath,
|
|
212
|
+
getOAuthSessionPath,
|
|
213
|
+
readOAuthSession,
|
|
214
|
+
writeOAuthSession,
|
|
215
|
+
clearOAuthSession,
|
|
216
|
+
readHeadlessToken,
|
|
217
|
+
getAccessTokenExpiryMs,
|
|
218
|
+
hasFreshAccessToken,
|
|
219
|
+
persistAccessToken,
|
|
220
|
+
toEnvSafe,
|
|
221
|
+
writeHeadlessEnvFile
|
|
222
|
+
};
|