@plexor-dev/claude-code-plugin-staging 0.1.0-beta.24 → 0.1.0-beta.26
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/commands/plexor-logout.js +3 -3
- package/commands/plexor-setup.js +14 -2
- package/commands/plexor-setup.md +4 -4
- package/commands/plexor-status.js +6 -2
- package/commands/plexor-uninstall.js +86 -28
- package/hooks/session-sync.js +194 -0
- package/hooks/statusline.js +130 -0
- package/lib/config-utils.js +83 -13
- package/lib/hooks-manager.js +209 -0
- package/lib/settings-manager.js +52 -47
- package/lib/statusline-manager.js +135 -0
- package/package.json +1 -1
- package/scripts/plexor-cli.sh +6 -1
- package/scripts/postinstall.js +132 -55
- package/scripts/uninstall.js +89 -30
|
@@ -115,9 +115,9 @@ function main() {
|
|
|
115
115
|
process.exit(1);
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
//
|
|
119
|
-
// This removes
|
|
120
|
-
// no longer
|
|
118
|
+
// Disable Claude Code routing in ~/.claude/settings.json.
|
|
119
|
+
// This removes the Plexor gateway URL and managed Plexor headers so Claude
|
|
120
|
+
// no longer routes through Plexor with removed credentials.
|
|
121
121
|
const routingDisabled = settingsManager.disablePlexorRouting();
|
|
122
122
|
|
|
123
123
|
// Clear session
|
package/commands/plexor-setup.js
CHANGED
|
@@ -93,7 +93,7 @@ function getGatewayLabel(apiUrl) {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
function isRunningInsideClaudeSession(env = process.env) {
|
|
96
|
-
return Boolean(env.CLAUDECODE);
|
|
96
|
+
return Boolean(env.CLAUDECODE || env.PLEXOR_SETUP_IN_CLAUDE);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
function createSkipVerifyResult() {
|
|
@@ -143,6 +143,14 @@ function printUsage() {
|
|
|
143
143
|
console.log(' PLEXOR_API_KEY=plx_... /plexor-setup');
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
function printRestartBanner() {
|
|
147
|
+
const BRIGHT_YELLOW = '\x1b[93m';
|
|
148
|
+
const BOLD = '\x1b[1m';
|
|
149
|
+
const RESET = '\x1b[0m';
|
|
150
|
+
console.log('');
|
|
151
|
+
console.log(`${BOLD}${BRIGHT_YELLOW}RESTART CLAUDE NOW.${RESET}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
146
154
|
function printReceipt({ user, gateway, previousAuthPreserved, verifyResult }) {
|
|
147
155
|
const line = (content) => `│ ${String(content).slice(0, 43).padEnd(43)}│`;
|
|
148
156
|
|
|
@@ -169,7 +177,11 @@ function printReceipt({ user, gateway, previousAuthPreserved, verifyResult }) {
|
|
|
169
177
|
}
|
|
170
178
|
console.log('└─────────────────────────────────────────────┘');
|
|
171
179
|
|
|
172
|
-
if (
|
|
180
|
+
if (verifyResult.pendingRestart) {
|
|
181
|
+
printRestartBanner();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!verifyResult.ok && !verifyResult.pendingRestart) {
|
|
173
185
|
console.log('');
|
|
174
186
|
console.log(`Verification failed: ${verifyResult.reason}`);
|
|
175
187
|
}
|
package/commands/plexor-setup.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
description: Guided first-run setup for Plexor with Claude Code (user)
|
|
2
2
|
---
|
|
3
3
|
|
|
4
|
-
**RULE: Execute this workflow EXACTLY ONCE. After the Bash tool returns output, your ONLY action is to present that output to the user. DO NOT restart the workflow.**
|
|
4
|
+
**RULE: Execute this workflow EXACTLY ONCE. After the Bash tool returns output, your ONLY action is to present that output to the user. If the output says restart is required, add one standalone line exactly `RESTART CLAUDE NOW.` after the tool output. DO NOT restart the workflow.**
|
|
5
5
|
|
|
6
6
|
Plexor setup is the primary human setup flow.
|
|
7
7
|
|
|
8
8
|
If `$ARGUMENTS` already contains a Plexor API key (`plx_...`), run:
|
|
9
9
|
|
|
10
10
|
```bash
|
|
11
|
-
node ~/.claude/plugins/plexor/commands/plexor-setup.js $ARGUMENTS
|
|
11
|
+
PLEXOR_SETUP_IN_CLAUDE=1 node ~/.claude/plugins/plexor/commands/plexor-setup.js $ARGUMENTS
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
If the user did not provide a key yet, ask them:
|
|
@@ -18,11 +18,11 @@ If the user did not provide a key yet, ask them:
|
|
|
18
18
|
After the user replies with the key, run:
|
|
19
19
|
|
|
20
20
|
```bash
|
|
21
|
-
node ~/.claude/plugins/plexor/commands/plexor-setup.js <user_key>
|
|
21
|
+
PLEXOR_SETUP_IN_CLAUDE=1 node ~/.claude/plugins/plexor/commands/plexor-setup.js <user_key>
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
This command:
|
|
25
25
|
- saves the Plexor key
|
|
26
26
|
- routes Claude through the Plexor staging gateway
|
|
27
|
-
-
|
|
27
|
+
- keeps Claude's existing direct auth intact and adds Plexor auth via managed headers
|
|
28
28
|
- runs a deterministic Claude verification step
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
const fs = require('fs');
|
|
9
9
|
const path = require('path');
|
|
10
10
|
const https = require('https');
|
|
11
|
+
const { parseCustomHeaders } = require('../lib/config-utils');
|
|
11
12
|
|
|
12
13
|
// Import centralized constants with HOME directory validation
|
|
13
14
|
const { HOME_DIR, CONFIG_PATH, SESSION_PATH, SESSION_TIMEOUT_MS } = require('../lib/constants');
|
|
@@ -26,12 +27,15 @@ function isManagedGatewayUrl(baseUrl = '') {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
function getPlexorAuthKey(env = {}) {
|
|
30
|
+
const headers = parseCustomHeaders(env.ANTHROPIC_CUSTOM_HEADERS);
|
|
31
|
+
const headerApiKey = headers['x-plexor-key'] || '';
|
|
29
32
|
const apiKey = env.ANTHROPIC_API_KEY || '';
|
|
30
33
|
const authToken = env.ANTHROPIC_AUTH_TOKEN || '';
|
|
31
34
|
|
|
35
|
+
if (headerApiKey.startsWith('plx_')) return headerApiKey;
|
|
32
36
|
if (apiKey.startsWith('plx_')) return apiKey;
|
|
33
37
|
if (authToken.startsWith('plx_')) return authToken;
|
|
34
|
-
return
|
|
38
|
+
return '';
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
function getDirectClaudeAuthState() {
|
|
@@ -58,7 +62,7 @@ function getDirectClaudeAuthState() {
|
|
|
58
62
|
try {
|
|
59
63
|
const data = fs.readFileSync(CLAUDE_STATE_PATH, 'utf8');
|
|
60
64
|
const state = JSON.parse(data);
|
|
61
|
-
if (state.primaryApiKey) {
|
|
65
|
+
if (state.primaryApiKey && !state.primaryApiKey.startsWith('plx_')) {
|
|
62
66
|
return { present: true, source: '/login managed key' };
|
|
63
67
|
}
|
|
64
68
|
} catch {
|
|
@@ -18,15 +18,24 @@
|
|
|
18
18
|
const fs = require('fs');
|
|
19
19
|
const path = require('path');
|
|
20
20
|
const os = require('os');
|
|
21
|
+
const { execSync } = require('child_process');
|
|
22
|
+
const { removeManagedStatusLine } = require('../lib/statusline-manager');
|
|
23
|
+
const { removeManagedHooks, cleanupLegacyManagedHooksFile } = require('../lib/hooks-manager');
|
|
24
|
+
const { removeManagedClaudeCustomHeadersFromEnv } = require('../lib/config-utils');
|
|
21
25
|
|
|
22
26
|
// Get home directory, handling sudo case
|
|
23
27
|
function getHomeDir() {
|
|
24
|
-
if (process.env.SUDO_USER) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
if (os.platform() !== 'win32' && process.env.SUDO_USER) {
|
|
29
|
+
try {
|
|
30
|
+
if (typeof process.getuid === 'function' && process.getuid() === 0) {
|
|
31
|
+
const entry = execSync(`getent passwd ${process.env.SUDO_USER}`, { encoding: 'utf8' }).trim();
|
|
32
|
+
const fields = entry.split(':');
|
|
33
|
+
if (fields.length >= 6 && fields[5]) {
|
|
34
|
+
return fields[5];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
// Fall through to HOME/os.homedir below.
|
|
30
39
|
}
|
|
31
40
|
}
|
|
32
41
|
return process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
@@ -36,22 +45,12 @@ const HOME_DIR = getHomeDir();
|
|
|
36
45
|
const CLAUDE_DIR = path.join(HOME_DIR, '.claude');
|
|
37
46
|
const CLAUDE_COMMANDS_DIR = path.join(CLAUDE_DIR, 'commands');
|
|
38
47
|
const CLAUDE_PLUGINS_DIR = path.join(CLAUDE_DIR, 'plugins', 'plexor');
|
|
48
|
+
const CLAUDE_SETTINGS_PATH = path.join(CLAUDE_DIR, 'settings.json');
|
|
49
|
+
const CLAUDE_HOOKS_PATH = path.join(CLAUDE_DIR, 'settings.json');
|
|
50
|
+
const CLAUDE_LEGACY_HOOKS_PATH = path.join(CLAUDE_DIR, 'hooks.json');
|
|
39
51
|
const PLEXOR_CONFIG_DIR = path.join(HOME_DIR, '.plexor');
|
|
40
52
|
|
|
41
53
|
// All Plexor slash command files
|
|
42
|
-
const PLEXOR_COMMANDS = [
|
|
43
|
-
'plexor-enabled.md',
|
|
44
|
-
'plexor-login.md',
|
|
45
|
-
'plexor-logout.md',
|
|
46
|
-
'plexor-setup.md',
|
|
47
|
-
'plexor-status.md',
|
|
48
|
-
'plexor-uninstall.md',
|
|
49
|
-
'plexor-mode.md',
|
|
50
|
-
'plexor-provider.md',
|
|
51
|
-
'plexor-settings.md',
|
|
52
|
-
'plexor-config.md'
|
|
53
|
-
];
|
|
54
|
-
|
|
55
54
|
/**
|
|
56
55
|
* Load settings manager if available
|
|
57
56
|
*/
|
|
@@ -115,10 +114,11 @@ function disableRoutingManually() {
|
|
|
115
114
|
const hasManagedBaseUrl = isManagedGatewayUrl(settings.env.ANTHROPIC_BASE_URL || '');
|
|
116
115
|
const hasPlexorAuthToken = isPlexorApiKey(settings.env.ANTHROPIC_AUTH_TOKEN || '');
|
|
117
116
|
const hasPlexorApiKey = isPlexorApiKey(settings.env.ANTHROPIC_API_KEY || '');
|
|
117
|
+
const removedManagedHeaders = removeManagedClaudeCustomHeadersFromEnv(settings.env);
|
|
118
118
|
|
|
119
119
|
const hasPreviousPrimaryApiKey = Boolean(settings.env[previousPrimaryApiKeyEnv]);
|
|
120
120
|
|
|
121
|
-
if (!hasManagedBaseUrl && !hasPlexorAuthToken && !hasPlexorApiKey && !hasPreviousPrimaryApiKey) {
|
|
121
|
+
if (!hasManagedBaseUrl && !hasPlexorAuthToken && !hasPlexorApiKey && !hasPreviousPrimaryApiKey && !removedManagedHeaders) {
|
|
122
122
|
return { success: true, message: 'Plexor routing not active' };
|
|
123
123
|
}
|
|
124
124
|
|
|
@@ -191,7 +191,12 @@ function removeSlashCommands() {
|
|
|
191
191
|
let removed = 0;
|
|
192
192
|
let restored = 0;
|
|
193
193
|
|
|
194
|
-
|
|
194
|
+
if (!fs.existsSync(CLAUDE_COMMANDS_DIR)) {
|
|
195
|
+
return { removed, restored };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const plexorCommands = fs.readdirSync(CLAUDE_COMMANDS_DIR).filter((entry) => /^plexor-.*\.md$/i.test(entry));
|
|
199
|
+
for (const cmd of plexorCommands) {
|
|
195
200
|
const cmdPath = path.join(CLAUDE_COMMANDS_DIR, cmd);
|
|
196
201
|
const backupPath = cmdPath + '.backup';
|
|
197
202
|
|
|
@@ -229,6 +234,30 @@ function removePluginDirectory() {
|
|
|
229
234
|
return false;
|
|
230
235
|
}
|
|
231
236
|
|
|
237
|
+
function removeManagedStatusLineConfig() {
|
|
238
|
+
try {
|
|
239
|
+
return removeManagedStatusLine(CLAUDE_SETTINGS_PATH, HOME_DIR);
|
|
240
|
+
} catch (err) {
|
|
241
|
+
return { changed: false, restored: false, error: err.message };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function removeManagedHooksConfig() {
|
|
246
|
+
try {
|
|
247
|
+
return removeManagedHooks(CLAUDE_HOOKS_PATH);
|
|
248
|
+
} catch (err) {
|
|
249
|
+
return { changed: false, removed: 0, error: err.message };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function removeLegacyManagedHooksConfig() {
|
|
254
|
+
try {
|
|
255
|
+
return cleanupLegacyManagedHooksFile(CLAUDE_LEGACY_HOOKS_PATH);
|
|
256
|
+
} catch (err) {
|
|
257
|
+
return { changed: false, removed: 0, error: err.message };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
232
261
|
/**
|
|
233
262
|
* Remove config directory
|
|
234
263
|
*/
|
|
@@ -255,7 +284,8 @@ function main() {
|
|
|
255
284
|
console.log(' Cleans up Plexor integration before npm uninstall.');
|
|
256
285
|
console.log('');
|
|
257
286
|
console.log(' Options:');
|
|
258
|
-
console.log(' --
|
|
287
|
+
console.log(' --keep-config, -k Preserve ~/.plexor/ config directory');
|
|
288
|
+
console.log(' --remove-config, -c Legacy alias (config is removed by default)');
|
|
259
289
|
console.log(' --quiet, -q Suppress output messages');
|
|
260
290
|
console.log(' --help, -h Show this help message');
|
|
261
291
|
console.log('');
|
|
@@ -265,7 +295,7 @@ function main() {
|
|
|
265
295
|
process.exit(0);
|
|
266
296
|
}
|
|
267
297
|
|
|
268
|
-
const removeConfig = args.includes('--
|
|
298
|
+
const removeConfig = !(args.includes('--keep-config') || args.includes('-k'));
|
|
269
299
|
const quiet = args.includes('--quiet') || args.includes('-q');
|
|
270
300
|
|
|
271
301
|
if (!quiet) {
|
|
@@ -295,7 +325,36 @@ function main() {
|
|
|
295
325
|
: ` ✗ Failed to remove routing: ${routingResult.message}`);
|
|
296
326
|
}
|
|
297
327
|
|
|
298
|
-
// 2. Remove
|
|
328
|
+
// 2. Remove managed Plexor status line
|
|
329
|
+
const statusLineResult = removeManagedStatusLineConfig();
|
|
330
|
+
if (!quiet) {
|
|
331
|
+
if (statusLineResult.error) {
|
|
332
|
+
console.log(` ✗ Failed to clean Plexor status line: ${statusLineResult.error}`);
|
|
333
|
+
} else if (statusLineResult.changed) {
|
|
334
|
+
console.log(' ✓ Restored Claude status line configuration');
|
|
335
|
+
} else {
|
|
336
|
+
console.log(' ○ Claude status line already clean');
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const hooksResult = removeManagedHooksConfig();
|
|
341
|
+
const legacyHooksResult = removeLegacyManagedHooksConfig();
|
|
342
|
+
if (!quiet) {
|
|
343
|
+
if (hooksResult.error) {
|
|
344
|
+
console.log(` ✗ Failed to clean Plexor hooks: ${hooksResult.error}`);
|
|
345
|
+
} else if (hooksResult.changed) {
|
|
346
|
+
console.log(' ✓ Restored Claude hook configuration');
|
|
347
|
+
} else {
|
|
348
|
+
console.log(' ○ Claude hooks already clean');
|
|
349
|
+
}
|
|
350
|
+
if (legacyHooksResult.error) {
|
|
351
|
+
console.log(` ✗ Failed to clean legacy ~/.claude/hooks.json: ${legacyHooksResult.error}`);
|
|
352
|
+
} else if (legacyHooksResult.changed) {
|
|
353
|
+
console.log(' ✓ Cleaned legacy ~/.claude/hooks.json');
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// 3. Remove slash command .md files
|
|
299
358
|
const cmdResult = removeSlashCommands();
|
|
300
359
|
if (!quiet) {
|
|
301
360
|
console.log(` ✓ Removed ${cmdResult.removed} slash command files`);
|
|
@@ -304,7 +363,7 @@ function main() {
|
|
|
304
363
|
}
|
|
305
364
|
}
|
|
306
365
|
|
|
307
|
-
//
|
|
366
|
+
// 4. Remove plugin directory
|
|
308
367
|
const pluginRemoved = removePluginDirectory();
|
|
309
368
|
if (!quiet) {
|
|
310
369
|
console.log(pluginRemoved
|
|
@@ -312,7 +371,7 @@ function main() {
|
|
|
312
371
|
: ' ○ Plugin directory not found (already clean)');
|
|
313
372
|
}
|
|
314
373
|
|
|
315
|
-
//
|
|
374
|
+
// 5. Remove config directory unless explicitly preserved
|
|
316
375
|
if (removeConfig) {
|
|
317
376
|
const configRemoved = removeConfigDirectory();
|
|
318
377
|
if (!quiet) {
|
|
@@ -337,8 +396,7 @@ function main() {
|
|
|
337
396
|
|
|
338
397
|
if (!removeConfig) {
|
|
339
398
|
console.log(' Note: ~/.plexor/ config directory was preserved.');
|
|
340
|
-
console.log(' To
|
|
341
|
-
console.log(' Or manually: rm -rf ~/.plexor');
|
|
399
|
+
console.log(' To remove it manually: rm -rf ~/.plexor');
|
|
342
400
|
console.log('');
|
|
343
401
|
}
|
|
344
402
|
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const https = require('https');
|
|
6
|
+
const http = require('http');
|
|
7
|
+
const { CONFIG_PATH, SESSION_PATH, PLEXOR_DIR } = require('../lib/constants');
|
|
8
|
+
|
|
9
|
+
function readJson(filePath) {
|
|
10
|
+
try {
|
|
11
|
+
if (!fs.existsSync(filePath)) return null;
|
|
12
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
13
|
+
return raw && raw.trim() ? JSON.parse(raw) : null;
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function writeJsonAtomically(filePath, value) {
|
|
20
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
21
|
+
const tempPath = `${filePath}.tmp.${Date.now()}`;
|
|
22
|
+
fs.writeFileSync(tempPath, JSON.stringify(value, null, 2), { mode: 0o600 });
|
|
23
|
+
fs.renameSync(tempPath, filePath);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readStdin() {
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
let data = '';
|
|
29
|
+
process.stdin.setEncoding('utf8');
|
|
30
|
+
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
31
|
+
process.stdin.on('end', () => resolve(data));
|
|
32
|
+
process.stdin.resume();
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function clampNonNegative(value) {
|
|
37
|
+
return Number.isFinite(value) ? Math.max(0, value) : 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function finiteOrZero(value) {
|
|
41
|
+
return Number.isFinite(value) ? value : 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parseStats(payload) {
|
|
45
|
+
const summary = payload?.summary || payload || {};
|
|
46
|
+
return {
|
|
47
|
+
total_requests: Number(summary.total_requests ?? payload?.total_requests ?? 0),
|
|
48
|
+
total_optimizations: Number(summary.total_optimizations ?? payload?.total_optimizations ?? 0),
|
|
49
|
+
original_tokens: Number(summary.original_tokens ?? payload?.original_tokens ?? 0),
|
|
50
|
+
optimized_tokens: Number(summary.optimized_tokens ?? payload?.optimized_tokens ?? 0),
|
|
51
|
+
tokens_saved: Number(summary.tokens_saved ?? payload?.tokens_saved ?? 0),
|
|
52
|
+
total_cost: Number(summary.total_cost ?? payload?.total_cost ?? 0),
|
|
53
|
+
baseline_cost: Number(summary.baseline_cost ?? payload?.baseline_cost ?? payload?.total_baseline_cost ?? 0),
|
|
54
|
+
cost_saved: Number(summary.cost_saved ?? payload?.cost_saved ?? payload?.total_savings ?? 0)
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function fetchJson(apiUrl, endpoint, apiKey) {
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
const url = new URL(endpoint, apiUrl.replace(/\/$/, '') + '/');
|
|
61
|
+
const client = url.protocol === 'https:' ? https : http;
|
|
62
|
+
const req = client.request({
|
|
63
|
+
hostname: url.hostname,
|
|
64
|
+
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
|
65
|
+
path: `${url.pathname}${url.search}`,
|
|
66
|
+
method: 'GET',
|
|
67
|
+
headers: {
|
|
68
|
+
'X-API-Key': apiKey,
|
|
69
|
+
'X-Plexor-Key': apiKey,
|
|
70
|
+
'User-Agent': 'plexor-session-sync/0.1.0-beta.24'
|
|
71
|
+
},
|
|
72
|
+
timeout: 5000
|
|
73
|
+
}, (res) => {
|
|
74
|
+
let body = '';
|
|
75
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
76
|
+
res.on('end', () => {
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(body);
|
|
79
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
80
|
+
resolve(parsed);
|
|
81
|
+
} else {
|
|
82
|
+
reject(new Error(parsed.message || `HTTP ${res.statusCode}`));
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
reject(new Error(`Invalid JSON response: ${body.slice(0, 120)}`));
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
req.on('error', reject);
|
|
90
|
+
req.on('timeout', () => {
|
|
91
|
+
req.destroy(new Error('Request timeout'));
|
|
92
|
+
});
|
|
93
|
+
req.end();
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function loadConfig() {
|
|
98
|
+
const config = readJson(CONFIG_PATH);
|
|
99
|
+
const apiKey = config?.auth?.api_key || '';
|
|
100
|
+
const apiUrl = config?.settings?.apiUrl || 'https://staging.api.plexor.dev';
|
|
101
|
+
const enabled = config?.settings?.enabled !== false;
|
|
102
|
+
if (!enabled || !apiKey.startsWith('plx_')) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
return { config, apiKey, apiUrl };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function buildSessionRecord(sessionId, baseline, nowIso) {
|
|
109
|
+
return {
|
|
110
|
+
session_id: sessionId || `session_${Date.now()}`,
|
|
111
|
+
started_at: nowIso,
|
|
112
|
+
last_activity: Date.now(),
|
|
113
|
+
requests: 0,
|
|
114
|
+
optimizations: 0,
|
|
115
|
+
cache_hits: 0,
|
|
116
|
+
passthroughs: 0,
|
|
117
|
+
original_tokens: 0,
|
|
118
|
+
optimized_tokens: 0,
|
|
119
|
+
tokens_saved: 0,
|
|
120
|
+
output_tokens: 0,
|
|
121
|
+
baseline_cost: 0,
|
|
122
|
+
actual_cost: 0,
|
|
123
|
+
cost_delta: 0,
|
|
124
|
+
cost_saved: 0,
|
|
125
|
+
_baseline: baseline
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function main() {
|
|
130
|
+
const mode = process.argv[2] || 'stop';
|
|
131
|
+
const inputRaw = await readStdin();
|
|
132
|
+
let input = {};
|
|
133
|
+
try {
|
|
134
|
+
input = inputRaw && inputRaw.trim() ? JSON.parse(inputRaw) : {};
|
|
135
|
+
} catch {
|
|
136
|
+
input = {};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (mode === 'end') {
|
|
140
|
+
try {
|
|
141
|
+
fs.rmSync(SESSION_PATH, { force: true });
|
|
142
|
+
} catch {
|
|
143
|
+
// Session cleanup must not interrupt Claude shutdown.
|
|
144
|
+
}
|
|
145
|
+
process.exit(0);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const loaded = loadConfig();
|
|
149
|
+
if (!loaded) {
|
|
150
|
+
process.exit(0);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const statsPayload = await fetchJson(loaded.apiUrl, '/v1/stats', loaded.apiKey);
|
|
155
|
+
const current = parseStats(statsPayload);
|
|
156
|
+
const nowIso = new Date().toISOString();
|
|
157
|
+
const sessionId = input.session_id || input.sessionId || null;
|
|
158
|
+
const existing = readJson(SESSION_PATH);
|
|
159
|
+
|
|
160
|
+
if (mode === 'start' || !existing || !existing._baseline || (sessionId && existing.session_id !== sessionId)) {
|
|
161
|
+
const baseline = { ...current };
|
|
162
|
+
writeJsonAtomically(SESSION_PATH, buildSessionRecord(sessionId, baseline, nowIso));
|
|
163
|
+
process.exit(0);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const baseline = existing._baseline || {};
|
|
167
|
+
const baselineCost = clampNonNegative(current.baseline_cost - Number(baseline.baseline_cost || 0));
|
|
168
|
+
const actualCost = clampNonNegative(current.total_cost - Number(baseline.total_cost || 0));
|
|
169
|
+
const next = {
|
|
170
|
+
...existing,
|
|
171
|
+
session_id: sessionId || existing.session_id,
|
|
172
|
+
last_activity: Date.now(),
|
|
173
|
+
requests: clampNonNegative(current.total_requests - Number(baseline.total_requests || 0)),
|
|
174
|
+
optimizations: clampNonNegative(current.total_optimizations - Number(baseline.total_optimizations || 0)),
|
|
175
|
+
original_tokens: clampNonNegative(current.original_tokens - Number(baseline.original_tokens || 0)),
|
|
176
|
+
optimized_tokens: clampNonNegative(current.optimized_tokens - Number(baseline.optimized_tokens || 0)),
|
|
177
|
+
tokens_saved: clampNonNegative(current.tokens_saved - Number(baseline.tokens_saved || 0)),
|
|
178
|
+
baseline_cost: baselineCost,
|
|
179
|
+
actual_cost: actualCost,
|
|
180
|
+
cost_delta: finiteOrZero(baselineCost - actualCost),
|
|
181
|
+
cost_saved: clampNonNegative(current.cost_saved - Number(baseline.cost_saved || 0)),
|
|
182
|
+
_baseline: baseline
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
if (!fs.existsSync(PLEXOR_DIR)) {
|
|
186
|
+
fs.mkdirSync(PLEXOR_DIR, { recursive: true, mode: 0o700 });
|
|
187
|
+
}
|
|
188
|
+
writeJsonAtomically(SESSION_PATH, next);
|
|
189
|
+
} catch {
|
|
190
|
+
// Hook failures must not interrupt Claude.
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
main();
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { execSync } = require('child_process');
|
|
7
|
+
const { PREVIOUS_STATUS_LINE_KEY, getManagedStatusLineCommand } = require('../lib/statusline-manager');
|
|
8
|
+
|
|
9
|
+
const ANSI_BLUE = '\x1b[38;5;153m';
|
|
10
|
+
const ANSI_GREEN = '\x1b[32m';
|
|
11
|
+
const ANSI_RESET = '\x1b[0m';
|
|
12
|
+
|
|
13
|
+
function readJson(filePath) {
|
|
14
|
+
try {
|
|
15
|
+
if (!fs.existsSync(filePath)) return null;
|
|
16
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
17
|
+
return raw && raw.trim() ? JSON.parse(raw) : null;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isManagedGatewayUrl(baseUrl = '') {
|
|
24
|
+
return (
|
|
25
|
+
baseUrl.includes('plexor') ||
|
|
26
|
+
baseUrl.includes('staging.api') ||
|
|
27
|
+
baseUrl.includes('localhost') ||
|
|
28
|
+
baseUrl.includes('127.0.0.1') ||
|
|
29
|
+
baseUrl.includes('ngrok') ||
|
|
30
|
+
baseUrl.includes('localtunnel')
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getGatewayLabel(baseUrl = '') {
|
|
35
|
+
if (baseUrl.includes('staging.api')) return 'staging';
|
|
36
|
+
if (baseUrl.includes('localhost') || baseUrl.includes('127.0.0.1')) return 'localhost';
|
|
37
|
+
return 'prod';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function formatUsd(value) {
|
|
41
|
+
if (!Number.isFinite(value)) {
|
|
42
|
+
return '';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (Math.abs(value) === 0) {
|
|
46
|
+
return '$0.00';
|
|
47
|
+
}
|
|
48
|
+
if (Math.abs(value) >= 1) {
|
|
49
|
+
return `$${value.toFixed(2)}`;
|
|
50
|
+
}
|
|
51
|
+
if (Math.abs(value) >= 0.01) {
|
|
52
|
+
return `$${value.toFixed(3)}`;
|
|
53
|
+
}
|
|
54
|
+
return `$${value.toFixed(4)}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function runPreviousStatusLine(statusLine, homeDir) {
|
|
58
|
+
if (!statusLine || typeof statusLine !== 'object' || statusLine.type !== 'command' || !statusLine.command) {
|
|
59
|
+
return '';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const managedCommand = getManagedStatusLineCommand(homeDir);
|
|
63
|
+
if (String(statusLine.command) === managedCommand) {
|
|
64
|
+
return '';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const output = execSync(String(statusLine.command), {
|
|
69
|
+
cwd: homeDir,
|
|
70
|
+
encoding: 'utf8',
|
|
71
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
72
|
+
timeout: 1000
|
|
73
|
+
}).trim();
|
|
74
|
+
return output.split('\n')[0] || '';
|
|
75
|
+
} catch {
|
|
76
|
+
return '';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildPlexorSegment(config, settings) {
|
|
81
|
+
const authKey = config?.auth?.api_key || '';
|
|
82
|
+
const enabled = config?.settings?.enabled !== false;
|
|
83
|
+
const baseUrl = settings?.env?.ANTHROPIC_BASE_URL || '';
|
|
84
|
+
if (!enabled || !authKey.startsWith('plx_') || !isManagedGatewayUrl(baseUrl)) {
|
|
85
|
+
return '';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const gateway = getGatewayLabel(baseUrl);
|
|
89
|
+
const mode = config?.settings?.mode || 'balanced';
|
|
90
|
+
const provider = config?.settings?.preferred_provider || 'auto';
|
|
91
|
+
return `PLEXOR ${gateway} ${mode} ${provider}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function buildSavingsSegment(session) {
|
|
95
|
+
const explicitDelta = Number(session?.cost_delta);
|
|
96
|
+
const costSaved = Number(session?.cost_saved);
|
|
97
|
+
const rawCostDelta = Number.isFinite(explicitDelta)
|
|
98
|
+
? explicitDelta
|
|
99
|
+
: ((Number.isFinite(costSaved) && costSaved !== 0)
|
|
100
|
+
? costSaved
|
|
101
|
+
: (Number(session?.baseline_cost) - Number(session?.actual_cost)));
|
|
102
|
+
const costDelta = Number.isFinite(rawCostDelta) ? rawCostDelta : 0;
|
|
103
|
+
return `cost-delta ${costDelta < 0 ? '-' : ''}${formatUsd(Math.abs(costDelta))}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function main() {
|
|
107
|
+
const homeDir = os.homedir();
|
|
108
|
+
const config = readJson(path.join(homeDir, '.plexor', 'config.json')) || {};
|
|
109
|
+
const settings = readJson(path.join(homeDir, '.claude', 'settings.json')) || {};
|
|
110
|
+
const session = readJson(path.join(homeDir, '.plexor', 'session.json')) || {};
|
|
111
|
+
|
|
112
|
+
const plexorSegment = buildPlexorSegment(config, settings);
|
|
113
|
+
const savingsSegment = plexorSegment ? buildSavingsSegment(session) : '';
|
|
114
|
+
const previous = runPreviousStatusLine(settings[PREVIOUS_STATUS_LINE_KEY], homeDir);
|
|
115
|
+
|
|
116
|
+
const coloredPlexor = plexorSegment ? `${ANSI_BLUE}${plexorSegment}${ANSI_RESET}` : '';
|
|
117
|
+
const coloredSavings = savingsSegment ? `${ANSI_GREEN}${savingsSegment}${ANSI_RESET}` : '';
|
|
118
|
+
|
|
119
|
+
if (!coloredPlexor && previous) {
|
|
120
|
+
process.stdout.write(previous);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const line = [coloredPlexor, coloredSavings, previous].filter(Boolean).join(' · ');
|
|
125
|
+
if (line) {
|
|
126
|
+
process.stdout.write(line);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
main();
|