@plexor-dev/claude-code-plugin-staging 0.1.0-beta.24 → 0.1.0-beta.25

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.
@@ -58,7 +58,7 @@ function getDirectClaudeAuthState() {
58
58
  try {
59
59
  const data = fs.readFileSync(CLAUDE_STATE_PATH, 'utf8');
60
60
  const state = JSON.parse(data);
61
- if (state.primaryApiKey) {
61
+ if (state.primaryApiKey && !state.primaryApiKey.startsWith('plx_')) {
62
62
  return { present: true, source: '/login managed key' };
63
63
  }
64
64
  } catch {
@@ -18,6 +18,9 @@
18
18
  const fs = require('fs');
19
19
  const path = require('path');
20
20
  const os = require('os');
21
+ const { removeManagedStatusLine } = require('../lib/statusline-manager');
22
+ const { removeManagedHooks, cleanupLegacyManagedHooksFile } = require('../lib/hooks-manager');
23
+ const { removeManagedClaudeCustomHeadersFromEnv } = require('../lib/config-utils');
21
24
 
22
25
  // Get home directory, handling sudo case
23
26
  function getHomeDir() {
@@ -36,22 +39,12 @@ const HOME_DIR = getHomeDir();
36
39
  const CLAUDE_DIR = path.join(HOME_DIR, '.claude');
37
40
  const CLAUDE_COMMANDS_DIR = path.join(CLAUDE_DIR, 'commands');
38
41
  const CLAUDE_PLUGINS_DIR = path.join(CLAUDE_DIR, 'plugins', 'plexor');
42
+ const CLAUDE_SETTINGS_PATH = path.join(CLAUDE_DIR, 'settings.json');
43
+ const CLAUDE_HOOKS_PATH = path.join(CLAUDE_DIR, 'settings.json');
44
+ const CLAUDE_LEGACY_HOOKS_PATH = path.join(CLAUDE_DIR, 'hooks.json');
39
45
  const PLEXOR_CONFIG_DIR = path.join(HOME_DIR, '.plexor');
40
46
 
41
47
  // 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
48
  /**
56
49
  * Load settings manager if available
57
50
  */
@@ -115,10 +108,11 @@ function disableRoutingManually() {
115
108
  const hasManagedBaseUrl = isManagedGatewayUrl(settings.env.ANTHROPIC_BASE_URL || '');
116
109
  const hasPlexorAuthToken = isPlexorApiKey(settings.env.ANTHROPIC_AUTH_TOKEN || '');
117
110
  const hasPlexorApiKey = isPlexorApiKey(settings.env.ANTHROPIC_API_KEY || '');
111
+ const removedManagedHeaders = removeManagedClaudeCustomHeadersFromEnv(settings.env);
118
112
 
119
113
  const hasPreviousPrimaryApiKey = Boolean(settings.env[previousPrimaryApiKeyEnv]);
120
114
 
121
- if (!hasManagedBaseUrl && !hasPlexorAuthToken && !hasPlexorApiKey && !hasPreviousPrimaryApiKey) {
115
+ if (!hasManagedBaseUrl && !hasPlexorAuthToken && !hasPlexorApiKey && !hasPreviousPrimaryApiKey && !removedManagedHeaders) {
122
116
  return { success: true, message: 'Plexor routing not active' };
123
117
  }
124
118
 
@@ -191,7 +185,12 @@ function removeSlashCommands() {
191
185
  let removed = 0;
192
186
  let restored = 0;
193
187
 
194
- for (const cmd of PLEXOR_COMMANDS) {
188
+ if (!fs.existsSync(CLAUDE_COMMANDS_DIR)) {
189
+ return { removed, restored };
190
+ }
191
+
192
+ const plexorCommands = fs.readdirSync(CLAUDE_COMMANDS_DIR).filter((entry) => /^plexor-.*\.md$/i.test(entry));
193
+ for (const cmd of plexorCommands) {
195
194
  const cmdPath = path.join(CLAUDE_COMMANDS_DIR, cmd);
196
195
  const backupPath = cmdPath + '.backup';
197
196
 
@@ -229,6 +228,30 @@ function removePluginDirectory() {
229
228
  return false;
230
229
  }
231
230
 
231
+ function removeManagedStatusLineConfig() {
232
+ try {
233
+ return removeManagedStatusLine(CLAUDE_SETTINGS_PATH, HOME_DIR);
234
+ } catch (err) {
235
+ return { changed: false, restored: false, error: err.message };
236
+ }
237
+ }
238
+
239
+ function removeManagedHooksConfig() {
240
+ try {
241
+ return removeManagedHooks(CLAUDE_HOOKS_PATH);
242
+ } catch (err) {
243
+ return { changed: false, removed: 0, error: err.message };
244
+ }
245
+ }
246
+
247
+ function removeLegacyManagedHooksConfig() {
248
+ try {
249
+ return cleanupLegacyManagedHooksFile(CLAUDE_LEGACY_HOOKS_PATH);
250
+ } catch (err) {
251
+ return { changed: false, removed: 0, error: err.message };
252
+ }
253
+ }
254
+
232
255
  /**
233
256
  * Remove config directory
234
257
  */
@@ -255,7 +278,8 @@ function main() {
255
278
  console.log(' Cleans up Plexor integration before npm uninstall.');
256
279
  console.log('');
257
280
  console.log(' Options:');
258
- console.log(' --remove-config, -c Also remove ~/.plexor/ config directory');
281
+ console.log(' --keep-config, -k Preserve ~/.plexor/ config directory');
282
+ console.log(' --remove-config, -c Legacy alias (config is removed by default)');
259
283
  console.log(' --quiet, -q Suppress output messages');
260
284
  console.log(' --help, -h Show this help message');
261
285
  console.log('');
@@ -265,7 +289,7 @@ function main() {
265
289
  process.exit(0);
266
290
  }
267
291
 
268
- const removeConfig = args.includes('--remove-config') || args.includes('-c');
292
+ const removeConfig = !(args.includes('--keep-config') || args.includes('-k'));
269
293
  const quiet = args.includes('--quiet') || args.includes('-q');
270
294
 
271
295
  if (!quiet) {
@@ -295,7 +319,36 @@ function main() {
295
319
  : ` ✗ Failed to remove routing: ${routingResult.message}`);
296
320
  }
297
321
 
298
- // 2. Remove slash command .md files
322
+ // 2. Remove managed Plexor status line
323
+ const statusLineResult = removeManagedStatusLineConfig();
324
+ if (!quiet) {
325
+ if (statusLineResult.error) {
326
+ console.log(` ✗ Failed to clean Plexor status line: ${statusLineResult.error}`);
327
+ } else if (statusLineResult.changed) {
328
+ console.log(' ✓ Restored Claude status line configuration');
329
+ } else {
330
+ console.log(' ○ Claude status line already clean');
331
+ }
332
+ }
333
+
334
+ const hooksResult = removeManagedHooksConfig();
335
+ const legacyHooksResult = removeLegacyManagedHooksConfig();
336
+ if (!quiet) {
337
+ if (hooksResult.error) {
338
+ console.log(` ✗ Failed to clean Plexor hooks: ${hooksResult.error}`);
339
+ } else if (hooksResult.changed) {
340
+ console.log(' ✓ Restored Claude hook configuration');
341
+ } else {
342
+ console.log(' ○ Claude hooks already clean');
343
+ }
344
+ if (legacyHooksResult.error) {
345
+ console.log(` ✗ Failed to clean legacy ~/.claude/hooks.json: ${legacyHooksResult.error}`);
346
+ } else if (legacyHooksResult.changed) {
347
+ console.log(' ✓ Cleaned legacy ~/.claude/hooks.json');
348
+ }
349
+ }
350
+
351
+ // 3. Remove slash command .md files
299
352
  const cmdResult = removeSlashCommands();
300
353
  if (!quiet) {
301
354
  console.log(` ✓ Removed ${cmdResult.removed} slash command files`);
@@ -304,7 +357,7 @@ function main() {
304
357
  }
305
358
  }
306
359
 
307
- // 3. Remove plugin directory
360
+ // 4. Remove plugin directory
308
361
  const pluginRemoved = removePluginDirectory();
309
362
  if (!quiet) {
310
363
  console.log(pluginRemoved
@@ -312,7 +365,7 @@ function main() {
312
365
  : ' ○ Plugin directory not found (already clean)');
313
366
  }
314
367
 
315
- // 4. Optionally remove config directory
368
+ // 5. Remove config directory unless explicitly preserved
316
369
  if (removeConfig) {
317
370
  const configRemoved = removeConfigDirectory();
318
371
  if (!quiet) {
@@ -337,8 +390,7 @@ function main() {
337
390
 
338
391
  if (!removeConfig) {
339
392
  console.log(' Note: ~/.plexor/ config directory was preserved.');
340
- console.log(' To also remove it: plexor-uninstall --remove-config');
341
- console.log(' Or manually: rm -rf ~/.plexor');
393
+ console.log(' To remove it manually: rm -rf ~/.plexor');
342
394
  console.log('');
343
395
  }
344
396
  }
@@ -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();
@@ -113,6 +113,30 @@ function serializeCustomHeaders(headers) {
113
113
  .join('\n');
114
114
  }
115
115
 
116
+ function removeManagedClaudeCustomHeadersFromEnv(env = {}) {
117
+ const existing = parseCustomHeaders(env.ANTHROPIC_CUSTOM_HEADERS);
118
+ let removed = false;
119
+
120
+ for (const key of MANAGED_HEADER_KEYS) {
121
+ if (Object.prototype.hasOwnProperty.call(existing, key)) {
122
+ delete existing[key];
123
+ removed = true;
124
+ }
125
+ }
126
+
127
+ if (!removed) {
128
+ return false;
129
+ }
130
+
131
+ if (Object.keys(existing).length) {
132
+ env.ANTHROPIC_CUSTOM_HEADERS = serializeCustomHeaders(existing);
133
+ } else {
134
+ delete env.ANTHROPIC_CUSTOM_HEADERS;
135
+ }
136
+
137
+ return true;
138
+ }
139
+
116
140
  function buildManagedAnthropicHeaders(config) {
117
141
  const settings = config?.settings || {};
118
142
  const headers = {};
@@ -308,6 +332,7 @@ module.exports = {
308
332
  readSetting,
309
333
  hasForcedHintConflict,
310
334
  validateForcedHintConfig,
335
+ removeManagedClaudeCustomHeadersFromEnv,
311
336
  parseCustomHeaders,
312
337
  serializeCustomHeaders,
313
338
  buildManagedAnthropicHeaders
@@ -0,0 +1,209 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ function quoteForPosixShell(value) {
5
+ return `'${String(value).replace(/'/g, `'\"'\"'`)}'`;
6
+ }
7
+
8
+ function writeJsonAtomically(filePath, value) {
9
+ const dir = path.dirname(filePath);
10
+ fs.mkdirSync(dir, { recursive: true });
11
+ const tempPath = `${filePath}.tmp.${Date.now()}`;
12
+ fs.writeFileSync(tempPath, JSON.stringify(value, null, 2), { mode: 0o600 });
13
+ fs.renameSync(tempPath, filePath);
14
+ }
15
+
16
+ function normalizeSettings(value) {
17
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
18
+ return {};
19
+ }
20
+ return { ...value };
21
+ }
22
+
23
+ function normalizeHookMatchers(value) {
24
+ if (!Array.isArray(value)) {
25
+ return [];
26
+ }
27
+ return value.filter((entry) => entry && typeof entry === 'object' && !Array.isArray(entry));
28
+ }
29
+
30
+ function normalizeHookList(value) {
31
+ if (!Array.isArray(value)) {
32
+ return [];
33
+ }
34
+ return value.filter((entry) => entry && typeof entry === 'object' && !Array.isArray(entry));
35
+ }
36
+
37
+ function readSettings(settingsPath) {
38
+ if (!fs.existsSync(settingsPath)) {
39
+ return { settings: {}, existed: false };
40
+ }
41
+
42
+ try {
43
+ const raw = fs.readFileSync(settingsPath, 'utf8');
44
+ if (!raw || !raw.trim()) {
45
+ return { settings: {}, existed: true };
46
+ }
47
+ return { settings: normalizeSettings(JSON.parse(raw)), existed: true };
48
+ } catch {
49
+ return { settings: {}, existed: true };
50
+ }
51
+ }
52
+
53
+ function getManagedSessionSyncPath(homeDir) {
54
+ return path.join(homeDir, '.claude', 'plugins', 'plexor', 'hooks', 'session-sync.js');
55
+ }
56
+
57
+ function getSafeManagedCommand(scriptPath, args = []) {
58
+ const quotedPath = quoteForPosixShell(scriptPath);
59
+ const quotedArgs = args.map(quoteForPosixShell).join(' ');
60
+ const argSuffix = quotedArgs ? ` ${quotedArgs}` : '';
61
+ return `/bin/sh -lc 'script=$1; shift; [ -x \"$script\" ] || exit 0; exec \"$script\" \"$@\"' -- ${quotedPath}${argSuffix}`;
62
+ }
63
+
64
+ function getManagedSessionSyncCommand(homeDir, mode) {
65
+ return getSafeManagedCommand(getManagedSessionSyncPath(homeDir), [mode]);
66
+ }
67
+
68
+ function getManagedHookMatchers(homeDir) {
69
+ return {
70
+ SessionStart: [{
71
+ matcher: 'startup|resume|clear|compact',
72
+ hooks: [{
73
+ type: 'command',
74
+ command: getManagedSessionSyncCommand(homeDir, 'start'),
75
+ timeout: 5
76
+ }]
77
+ }],
78
+ Stop: [{
79
+ hooks: [{
80
+ type: 'command',
81
+ command: getManagedSessionSyncCommand(homeDir, 'stop'),
82
+ timeout: 5
83
+ }]
84
+ }],
85
+ SessionEnd: [{
86
+ hooks: [{
87
+ type: 'command',
88
+ command: getManagedSessionSyncCommand(homeDir, 'end'),
89
+ timeout: 5
90
+ }]
91
+ }]
92
+ };
93
+ }
94
+
95
+ function isManagedCommandHook(hook) {
96
+ const command = String(hook?.command || '');
97
+ return command.includes('/.claude/plugins/plexor/hooks/session-sync.js');
98
+ }
99
+
100
+ function isManagedMatcher(entry) {
101
+ const hooks = normalizeHookList(entry?.hooks);
102
+ return hooks.some(isManagedCommandHook);
103
+ }
104
+
105
+ function upsertManagedHooks(settingsPath, homeDir) {
106
+ const { settings, existed } = readSettings(settingsPath);
107
+ const currentSettings = normalizeSettings(settings);
108
+ const currentHooks = normalizeSettings(currentSettings.hooks);
109
+ const managedMatchers = getManagedHookMatchers(homeDir);
110
+ const nextHooks = { ...currentHooks };
111
+
112
+ for (const [eventName, managedEntries] of Object.entries(managedMatchers)) {
113
+ const preserved = normalizeHookMatchers(currentHooks[eventName]).filter((entry) => !isManagedMatcher(entry));
114
+ nextHooks[eventName] = [...preserved, ...managedEntries];
115
+ }
116
+
117
+ const nextSettings = { ...currentSettings, hooks: nextHooks };
118
+ const changed = JSON.stringify(currentSettings) !== JSON.stringify(nextSettings);
119
+ if (changed) {
120
+ writeJsonAtomically(settingsPath, nextSettings);
121
+ }
122
+
123
+ return { changed, existed };
124
+ }
125
+
126
+ function removeManagedHooks(settingsPath) {
127
+ const { settings, existed } = readSettings(settingsPath);
128
+ if (!existed) {
129
+ return { changed: false, removed: 0 };
130
+ }
131
+
132
+ const currentSettings = normalizeSettings(settings);
133
+ const currentHooks = normalizeSettings(currentSettings.hooks);
134
+ const nextHooks = { ...currentHooks };
135
+ let removed = 0;
136
+
137
+ for (const [eventName, entries] of Object.entries(currentHooks)) {
138
+ const normalizedEntries = normalizeHookMatchers(entries);
139
+ const filteredEntries = normalizedEntries.filter((entry) => !isManagedMatcher(entry));
140
+ removed += normalizedEntries.length - filteredEntries.length;
141
+ if (filteredEntries.length > 0) {
142
+ nextHooks[eventName] = filteredEntries;
143
+ } else {
144
+ delete nextHooks[eventName];
145
+ }
146
+ }
147
+
148
+ const nextSettings = { ...currentSettings };
149
+ if (Object.keys(nextHooks).length > 0) {
150
+ nextSettings.hooks = nextHooks;
151
+ } else {
152
+ delete nextSettings.hooks;
153
+ }
154
+
155
+ const changed = JSON.stringify(currentSettings) !== JSON.stringify(nextSettings);
156
+ if (changed) {
157
+ writeJsonAtomically(settingsPath, nextSettings);
158
+ }
159
+
160
+ return { changed, removed };
161
+ }
162
+
163
+ function cleanupLegacyManagedHooksFile(legacyHooksPath) {
164
+ if (!fs.existsSync(legacyHooksPath)) {
165
+ return { changed: false, removed: 0 };
166
+ }
167
+
168
+ try {
169
+ const raw = fs.readFileSync(legacyHooksPath, 'utf8');
170
+ if (!raw || !raw.trim()) {
171
+ fs.rmSync(legacyHooksPath, { force: true });
172
+ return { changed: true, removed: 0 };
173
+ }
174
+
175
+ const parsed = JSON.parse(raw);
176
+ const currentHooks = normalizeHookList(parsed.hooks);
177
+ const nextHooks = currentHooks.filter((entry) => {
178
+ const event = String(entry?.event || '');
179
+ const script = String(entry?.script || '');
180
+ const isLegacyManagedEvent = event === 'pre_model_request' || event === 'post_model_response';
181
+ const isLegacyManagedScript = script.includes('/.claude/plugins/plexor/hooks/intercept.js') ||
182
+ script.includes('/.claude/plugins/plexor/hooks/track-response.js');
183
+ return !(isLegacyManagedEvent && isLegacyManagedScript);
184
+ });
185
+ const removed = currentHooks.length - nextHooks.length;
186
+
187
+ if (removed === 0) {
188
+ return { changed: false, removed: 0 };
189
+ }
190
+
191
+ if (nextHooks.length === 0) {
192
+ fs.rmSync(legacyHooksPath, { force: true });
193
+ } else {
194
+ writeJsonAtomically(legacyHooksPath, { ...parsed, hooks: nextHooks });
195
+ }
196
+ return { changed: true, removed };
197
+ } catch {
198
+ return { changed: false, removed: 0 };
199
+ }
200
+ }
201
+
202
+ module.exports = {
203
+ getManagedSessionSyncPath,
204
+ getManagedSessionSyncCommand,
205
+ getManagedHookMatchers,
206
+ upsertManagedHooks,
207
+ removeManagedHooks,
208
+ cleanupLegacyManagedHooksFile
209
+ };
@@ -14,6 +14,7 @@ const fs = require('fs');
14
14
  const path = require('path');
15
15
  const os = require('os');
16
16
  const crypto = require('crypto');
17
+ const { removeManagedClaudeCustomHeadersFromEnv } = require('./config-utils');
17
18
 
18
19
  const CLAUDE_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.claude');
19
20
  const CLAUDE_STATE_PATH = path.join(process.env.HOME || process.env.USERPROFILE || '', '.claude.json');
@@ -92,8 +93,9 @@ function clearPlexorRoutingEnv(env = {}) {
92
93
  const hasManagedBaseUrl = isManagedGatewayUrl(env.ANTHROPIC_BASE_URL || '');
93
94
  const hasPlexorApiKey = isPlexorApiKey(env.ANTHROPIC_API_KEY || '');
94
95
  const hasPlexorAuthToken = isPlexorApiKey(env.ANTHROPIC_AUTH_TOKEN || '');
96
+ const removedManagedHeaders = removeManagedClaudeCustomHeadersFromEnv(env);
95
97
 
96
- if (!hasManagedBaseUrl && !hasPlexorApiKey && !hasPlexorAuthToken) {
98
+ if (!hasManagedBaseUrl && !hasPlexorApiKey && !hasPlexorAuthToken && !removedManagedHeaders) {
97
99
  return false;
98
100
  }
99
101
 
@@ -346,6 +348,7 @@ class ClaudeSettingsManager {
346
348
 
347
349
  try {
348
350
  const settings = this.load();
351
+ const previousSettings = JSON.parse(JSON.stringify(settings));
349
352
  const claudeState = this.loadClaudeState();
350
353
 
351
354
  // Initialize env block if doesn't exist
@@ -365,6 +368,7 @@ class ClaudeSettingsManager {
365
368
  }
366
369
 
367
370
  if (claudeStateChanged && !this.saveClaudeState(claudeState)) {
371
+ this.save(previousSettings);
368
372
  return false;
369
373
  }
370
374
 
@@ -0,0 +1,135 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const PREVIOUS_STATUS_LINE_KEY = 'PLEXOR_PREVIOUS_STATUS_LINE';
5
+
6
+ function quoteForPosixShell(value) {
7
+ return `'${String(value).replace(/'/g, `'\"'\"'`)}'`;
8
+ }
9
+
10
+ function writeJsonAtomically(filePath, value) {
11
+ const dir = path.dirname(filePath);
12
+ fs.mkdirSync(dir, { recursive: true });
13
+ const tempPath = `${filePath}.tmp.${Date.now()}`;
14
+ fs.writeFileSync(tempPath, JSON.stringify(value, null, 2), { mode: 0o600 });
15
+ fs.renameSync(tempPath, filePath);
16
+ }
17
+
18
+ function normalizeSettings(value) {
19
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
20
+ return {};
21
+ }
22
+ return { ...value };
23
+ }
24
+
25
+ function readSettings(settingsPath) {
26
+ if (!fs.existsSync(settingsPath)) {
27
+ return { settings: {}, existed: false };
28
+ }
29
+
30
+ try {
31
+ const raw = fs.readFileSync(settingsPath, 'utf8');
32
+ if (!raw || !raw.trim()) {
33
+ return { settings: {}, existed: true };
34
+ }
35
+ return { settings: normalizeSettings(JSON.parse(raw)), existed: true };
36
+ } catch {
37
+ return { settings: {}, existed: true };
38
+ }
39
+ }
40
+
41
+ function getManagedStatusLinePath(homeDir) {
42
+ return path.join(homeDir, '.claude', 'plugins', 'plexor', 'hooks', 'statusline.js');
43
+ }
44
+
45
+ function getManagedStatusLineCommand(homeDir) {
46
+ const scriptPath = getManagedStatusLinePath(homeDir);
47
+ return `/bin/sh -lc 'script=$1; [ -x \"$script\" ] || exit 0; exec \"$script\"' -- ${quoteForPosixShell(scriptPath)}`;
48
+ }
49
+
50
+ function isManagedStatusLine(statusLine, homeDir) {
51
+ const managedCommand = getManagedStatusLineCommand(homeDir);
52
+ const normalizedManaged = managedCommand.replace(/^"|"$/g, '');
53
+ const command = String(statusLine?.command || '');
54
+
55
+ return (
56
+ statusLine?.type === 'command' &&
57
+ (
58
+ command === managedCommand ||
59
+ command === normalizedManaged ||
60
+ command.includes('/.claude/plugins/plexor/hooks/statusline.js')
61
+ )
62
+ );
63
+ }
64
+
65
+ function getManagedStatusLine(homeDir) {
66
+ return {
67
+ type: 'command',
68
+ command: getManagedStatusLineCommand(homeDir),
69
+ padding: 0
70
+ };
71
+ }
72
+
73
+ function upsertManagedStatusLine(settingsPath, homeDir) {
74
+ const { settings, existed } = readSettings(settingsPath);
75
+ const currentSettings = normalizeSettings(settings);
76
+ const nextSettings = { ...currentSettings };
77
+ const currentStatusLine = currentSettings.statusLine;
78
+
79
+ if (
80
+ currentStatusLine &&
81
+ !isManagedStatusLine(currentStatusLine, homeDir) &&
82
+ !Object.prototype.hasOwnProperty.call(currentSettings, PREVIOUS_STATUS_LINE_KEY)
83
+ ) {
84
+ nextSettings[PREVIOUS_STATUS_LINE_KEY] = currentStatusLine;
85
+ }
86
+
87
+ nextSettings.statusLine = getManagedStatusLine(homeDir);
88
+
89
+ const changed = JSON.stringify(currentSettings) !== JSON.stringify(nextSettings);
90
+ if (changed) {
91
+ writeJsonAtomically(settingsPath, nextSettings);
92
+ }
93
+
94
+ return { changed, existed };
95
+ }
96
+
97
+ function removeManagedStatusLine(settingsPath, homeDir) {
98
+ const { settings, existed } = readSettings(settingsPath);
99
+ if (!existed) {
100
+ return { changed: false, restored: false };
101
+ }
102
+
103
+ const currentSettings = normalizeSettings(settings);
104
+ const nextSettings = { ...currentSettings };
105
+ const managed = isManagedStatusLine(currentSettings.statusLine, homeDir);
106
+ const previousStatusLine = currentSettings[PREVIOUS_STATUS_LINE_KEY];
107
+ let restored = false;
108
+
109
+ if (managed) {
110
+ if (previousStatusLine && typeof previousStatusLine === 'object') {
111
+ nextSettings.statusLine = previousStatusLine;
112
+ restored = true;
113
+ } else {
114
+ delete nextSettings.statusLine;
115
+ }
116
+ }
117
+
118
+ delete nextSettings[PREVIOUS_STATUS_LINE_KEY];
119
+
120
+ const changed = JSON.stringify(currentSettings) !== JSON.stringify(nextSettings);
121
+ if (changed) {
122
+ writeJsonAtomically(settingsPath, nextSettings);
123
+ }
124
+
125
+ return { changed, restored };
126
+ }
127
+
128
+ module.exports = {
129
+ PREVIOUS_STATUS_LINE_KEY,
130
+ getManagedStatusLine,
131
+ getManagedStatusLineCommand,
132
+ isManagedStatusLine,
133
+ upsertManagedStatusLine,
134
+ removeManagedStatusLine
135
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plexor-dev/claude-code-plugin-staging",
3
- "version": "0.1.0-beta.24",
3
+ "version": "0.1.0-beta.25",
4
4
  "description": "STAGING - LLM cost optimization plugin for Claude Code (internal testing)",
5
5
  "main": "lib/constants.js",
6
6
  "bin": {
@@ -11,6 +11,8 @@ const fs = require('fs');
11
11
  const path = require('path');
12
12
  const os = require('os');
13
13
  const { execSync } = require('child_process');
14
+ const { upsertManagedStatusLine } = require('../lib/statusline-manager');
15
+ const { upsertManagedHooks, cleanupLegacyManagedHooksFile } = require('../lib/hooks-manager');
14
16
 
15
17
  /**
16
18
  * Resolve the home directory for a given username by querying /etc/passwd.
@@ -137,12 +139,16 @@ function chownRecursive(dirPath, uid, gid) {
137
139
 
138
140
  const HOME_DIR = getHomeDir();
139
141
  const COMMANDS_SOURCE = path.join(__dirname, '..', 'commands');
142
+ const HOOKS_SOURCE = path.join(__dirname, '..', 'hooks');
140
143
  const LIB_SOURCE = path.join(__dirname, '..', 'lib');
141
144
  const CLAUDE_COMMANDS_DIR = path.join(HOME_DIR, '.claude', 'commands');
142
145
  const PLEXOR_PLUGINS_DIR = path.join(HOME_DIR, '.claude', 'plugins', 'plexor', 'commands');
146
+ const PLEXOR_HOOKS_DIR = path.join(HOME_DIR, '.claude', 'plugins', 'plexor', 'hooks');
143
147
  const PLEXOR_LIB_DIR = path.join(HOME_DIR, '.claude', 'plugins', 'plexor', 'lib');
144
148
  const PLEXOR_CONFIG_DIR = path.join(HOME_DIR, '.plexor');
145
149
  const PLEXOR_CONFIG_FILE = path.join(PLEXOR_CONFIG_DIR, 'config.json');
150
+ const CLAUDE_SETTINGS_FILE = path.join(HOME_DIR, '.claude', 'settings.json');
151
+ const CLAUDE_LEGACY_HOOKS_FILE = path.join(HOME_DIR, '.claude', 'hooks.json');
146
152
 
147
153
  /**
148
154
  * Check if a base URL is a Plexor-managed gateway URL.
@@ -199,33 +205,41 @@ function syncManagedAuthEnv(env, managedAuthKey) {
199
205
  env.ANTHROPIC_AUTH_TOKEN = managedAuthKey;
200
206
  }
201
207
 
208
+ function writeJsonAtomically(filePath, value) {
209
+ const tempPath = `${filePath}.tmp.${Date.now()}`;
210
+ fs.writeFileSync(tempPath, JSON.stringify(value, null, 2), { mode: 0o600 });
211
+ fs.renameSync(tempPath, filePath);
212
+ }
213
+
202
214
  function syncManagedPrimaryApiKey(env, managedAuthKey) {
203
215
  const statePath = path.join(HOME_DIR, '.claude.json');
204
216
  try {
205
217
  if (!fs.existsSync(statePath)) {
206
- return false;
218
+ return { changed: false };
207
219
  }
208
220
 
209
221
  const data = fs.readFileSync(statePath, 'utf8');
210
222
  if (!data || !data.trim()) {
211
- return false;
223
+ return { changed: false };
212
224
  }
213
225
 
214
226
  const claudeState = JSON.parse(data);
215
227
  const primaryApiKey = claudeState.primaryApiKey || '';
216
228
  if (!primaryApiKey || primaryApiKey.startsWith('plx_') || primaryApiKey === managedAuthKey) {
217
- return false;
229
+ return { changed: false };
218
230
  }
219
231
 
220
232
  env[PREVIOUS_PRIMARY_API_KEY_ENV] = primaryApiKey;
221
- delete claudeState.primaryApiKey;
222
-
223
- const tempPath = `${statePath}.tmp.${Date.now()}`;
224
- fs.writeFileSync(tempPath, JSON.stringify(claudeState, null, 2), { mode: 0o600 });
225
- fs.renameSync(tempPath, statePath);
226
- return true;
233
+ const nextClaudeState = { ...claudeState };
234
+ delete nextClaudeState.primaryApiKey;
235
+
236
+ return {
237
+ changed: true,
238
+ statePath,
239
+ claudeState: nextClaudeState
240
+ };
227
241
  } catch {
228
- return false;
242
+ return { changed: false };
229
243
  }
230
244
  }
231
245
 
@@ -240,6 +254,7 @@ function checkOrphanedRouting() {
240
254
  const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
241
255
  const env = settings.env || {};
242
256
  let settingsChanged = false;
257
+ let managedPrimaryApiKeySync = null;
243
258
 
244
259
  const hasPlexorUrl = isManagedGatewayUrl(env.ANTHROPIC_BASE_URL);
245
260
 
@@ -254,10 +269,11 @@ function checkOrphanedRouting() {
254
269
  settingsChanged = true;
255
270
  console.log('\n Synced Plexor auth into ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN');
256
271
  }
257
- if (managedAuthKey && syncManagedPrimaryApiKey(env, managedAuthKey)) {
272
+ const primaryApiKeySync = managedAuthKey ? syncManagedPrimaryApiKey(env, managedAuthKey) : { changed: false };
273
+ if (primaryApiKeySync.changed) {
258
274
  settings.env = env;
259
275
  settingsChanged = true;
260
- console.log('\n Suspended Claude managed API key while Plexor is active');
276
+ managedPrimaryApiKeySync = primaryApiKeySync;
261
277
  }
262
278
  // Check if there's a valid Plexor config
263
279
  let hasValidConfig = false;
@@ -299,6 +315,16 @@ function checkOrphanedRouting() {
299
315
  fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2), { mode: 0o600 });
300
316
  fs.renameSync(tempPath, settingsPath);
301
317
  }
318
+
319
+ if (managedPrimaryApiKeySync?.changed) {
320
+ try {
321
+ writeJsonAtomically(managedPrimaryApiKeySync.statePath, managedPrimaryApiKeySync.claudeState);
322
+ console.log('\n Suspended Claude managed API key while Plexor is active');
323
+ } catch (e) {
324
+ console.log('\n Warning: Saved Claude key backup but could not suspend Claude managed API key');
325
+ console.log(` ${e.message}`);
326
+ }
327
+ }
302
328
  } catch (e) {
303
329
  // Ignore errors in detection - don't break install
304
330
  }
@@ -334,6 +360,9 @@ function main() {
334
360
  // Create ~/.claude/plugins/plexor/commands/ for JS executors
335
361
  fs.mkdirSync(PLEXOR_PLUGINS_DIR, { recursive: true });
336
362
 
363
+ // Create ~/.claude/plugins/plexor/hooks/ for hook scripts
364
+ fs.mkdirSync(PLEXOR_HOOKS_DIR, { recursive: true });
365
+
337
366
  // Create ~/.claude/plugins/plexor/lib/ for shared modules
338
367
  fs.mkdirSync(PLEXOR_LIB_DIR, { recursive: true });
339
368
 
@@ -356,6 +385,9 @@ function main() {
356
385
  .filter(f => f.endsWith('.md'));
357
386
  const jsFiles = fs.readdirSync(COMMANDS_SOURCE)
358
387
  .filter(f => f.endsWith('.js'));
388
+ const hookFiles = fs.existsSync(HOOKS_SOURCE)
389
+ ? fs.readdirSync(HOOKS_SOURCE).filter(f => f.endsWith('.js'))
390
+ : [];
359
391
 
360
392
  if (mdFiles.length === 0) {
361
393
  console.error('No command files found in package. Installation may be corrupt.');
@@ -397,6 +429,16 @@ function main() {
397
429
  jsInstalled.push(file);
398
430
  }
399
431
 
432
+ // Copy hook files to ~/.claude/plugins/plexor/hooks/
433
+ const hooksInstalled = [];
434
+ for (const file of hookFiles) {
435
+ const src = path.join(HOOKS_SOURCE, file);
436
+ const dest = path.join(PLEXOR_HOOKS_DIR, file);
437
+ fs.copyFileSync(src, dest);
438
+ fs.chmodSync(dest, 0o755);
439
+ hooksInstalled.push(file);
440
+ }
441
+
400
442
  // Copy lib files to ~/.claude/plugins/plexor/lib/
401
443
  // CRITICAL: These are required for commands to work
402
444
  const libInstalled = [];
@@ -431,6 +473,10 @@ function main() {
431
473
  console.error('');
432
474
  }
433
475
 
476
+ const statusLineRegistration = upsertManagedStatusLine(CLAUDE_SETTINGS_FILE, HOME_DIR);
477
+ const hooksRegistration = upsertManagedHooks(CLAUDE_SETTINGS_FILE, HOME_DIR);
478
+ const legacyHooksCleanup = cleanupLegacyManagedHooksFile(CLAUDE_LEGACY_HOOKS_FILE);
479
+
434
480
  // Fix file ownership when running with sudo
435
481
  // Files are created as root but should be owned by the original user
436
482
  if (targetUser) {
@@ -457,9 +503,21 @@ function main() {
457
503
  if (jsInstalled.length > 0) {
458
504
  console.log(` ✓ Installed ${jsInstalled.length} executors to ~/.claude/plugins/plexor/commands/`);
459
505
  }
506
+ if (hooksInstalled.length > 0) {
507
+ console.log(` ✓ Installed ${hooksInstalled.length} hook scripts to ~/.claude/plugins/plexor/hooks/`);
508
+ }
460
509
  if (libInstalled.length > 0) {
461
510
  console.log(` ✓ Installed ${libInstalled.length} lib modules to ~/.claude/plugins/plexor/lib/`);
462
511
  }
512
+ if (statusLineRegistration.changed || statusLineRegistration.existed) {
513
+ console.log(' ✓ Registered Plexor status line in ~/.claude/settings.json');
514
+ }
515
+ if (hooksRegistration.changed || hooksRegistration.existed) {
516
+ console.log(' ✓ Registered Plexor hooks in ~/.claude/settings.json');
517
+ }
518
+ if (legacyHooksCleanup.changed) {
519
+ console.log(' ✓ Cleaned up legacy ~/.claude/hooks.json entries');
520
+ }
463
521
  if (targetUser) {
464
522
  console.log(` ✓ Set file ownership to ${targetUser.user}`);
465
523
  }
@@ -474,10 +532,11 @@ function main() {
474
532
  console.log(' 2. Write ~/.plexor/config.json');
475
533
  console.log(' 3. Point Claude at the Plexor staging gateway');
476
534
  console.log(' 4. Preserve prior Claude auth for restore on logout');
477
- console.log(' 5. Verify Claude routing with a deterministic check');
535
+ console.log(' 5. Show Plexor state in Claude footer via status line');
536
+ console.log(' 6. Update session savings in real time via Claude hooks');
478
537
  console.log('');
479
538
  console.log(' ┌─────────────────────────────────────────────────────────────────┐');
480
- console.log(' │ No shell edits or Claude restart required after setup │');
539
+ console.log(' │ After /plexor-setup, restart Claude before first prompt │');
481
540
  console.log(' └─────────────────────────────────────────────────────────────────┘');
482
541
  console.log('');
483
542
  console.log(' Available commands:');
@@ -17,6 +17,9 @@ const fs = require('fs');
17
17
  const path = require('path');
18
18
  const os = require('os');
19
19
  const { execSync } = require('child_process');
20
+ const { removeManagedStatusLine } = require('../lib/statusline-manager');
21
+ const { removeManagedHooks, cleanupLegacyManagedHooksFile } = require('../lib/hooks-manager');
22
+ const { removeManagedClaudeCustomHeadersFromEnv } = require('../lib/config-utils');
20
23
 
21
24
  /**
22
25
  * Get the correct home directory for the process's effective user.
@@ -52,11 +55,16 @@ console.log('');
52
55
 
53
56
  const results = {
54
57
  routing: false,
58
+ statusLine: false,
59
+ hooks: false,
55
60
  commands: [],
56
61
  restored: [],
57
62
  pluginDir: false
58
63
  };
59
64
  const PREVIOUS_PRIMARY_API_KEY_ENV = 'PLEXOR_PREVIOUS_CLAUDE_PRIMARY_API_KEY';
65
+ const CLAUDE_SETTINGS_PATH = path.join(home, '.claude', 'settings.json');
66
+ const CLAUDE_HOOKS_PATH = path.join(home, '.claude', 'settings.json');
67
+ const CLAUDE_LEGACY_HOOKS_PATH = path.join(home, '.claude', 'hooks.json');
60
68
 
61
69
  function isManagedGatewayUrl(baseUrl = '') {
62
70
  return (
@@ -77,8 +85,9 @@ function clearPlexorRoutingEnv(env = {}) {
77
85
  const hasManagedBaseUrl = isManagedGatewayUrl(env.ANTHROPIC_BASE_URL || '');
78
86
  const hasPlexorAuthToken = isPlexorApiKey(env.ANTHROPIC_AUTH_TOKEN || '');
79
87
  const hasPlexorApiKey = isPlexorApiKey(env.ANTHROPIC_API_KEY || '');
88
+ const removedManagedHeaders = removeManagedClaudeCustomHeadersFromEnv(env);
80
89
 
81
- if (!hasManagedBaseUrl && !hasPlexorAuthToken && !hasPlexorApiKey) {
90
+ if (!hasManagedBaseUrl && !hasPlexorAuthToken && !hasPlexorApiKey && !removedManagedHeaders) {
82
91
  return false;
83
92
  }
84
93
 
@@ -105,10 +114,16 @@ function clearPlexorRoutingEnv(env = {}) {
105
114
  return true;
106
115
  }
107
116
 
117
+ function writeJsonAtomically(filePath, value) {
118
+ const tempPath = `${filePath}.tmp.${Date.now()}`;
119
+ fs.writeFileSync(tempPath, JSON.stringify(value, null, 2), { mode: 0o600 });
120
+ fs.renameSync(tempPath, filePath);
121
+ }
122
+
108
123
  function restoreClaudePrimaryApiKey(env = {}) {
109
124
  const previousPrimaryApiKey = env[PREVIOUS_PRIMARY_API_KEY_ENV] || '';
110
125
  if (!previousPrimaryApiKey) {
111
- return false;
126
+ return { restored: false, warning: null };
112
127
  }
113
128
 
114
129
  const statePath = path.join(home, '.claude.json');
@@ -127,12 +142,14 @@ function restoreClaudePrimaryApiKey(env = {}) {
127
142
  if (!claudeState.primaryApiKey) {
128
143
  claudeState.primaryApiKey = previousPrimaryApiKey;
129
144
  }
130
- delete env[PREVIOUS_PRIMARY_API_KEY_ENV];
131
145
 
132
- const tempPath = `${statePath}.tmp.${Date.now()}`;
133
- fs.writeFileSync(tempPath, JSON.stringify(claudeState, null, 2), { mode: 0o600 });
134
- fs.renameSync(tempPath, statePath);
135
- return true;
146
+ try {
147
+ writeJsonAtomically(statePath, claudeState);
148
+ delete env[PREVIOUS_PRIMARY_API_KEY_ENV];
149
+ return { restored: true, warning: null };
150
+ } catch (e) {
151
+ return { restored: false, warning: e.message };
152
+ }
136
153
  }
137
154
 
138
155
  // 1. Remove routing from settings.json
@@ -145,17 +162,20 @@ try {
145
162
  const settings = JSON.parse(data);
146
163
  if (settings.env) {
147
164
  const routingChanged = clearPlexorRoutingEnv(settings.env);
148
- const primaryApiKeyRestored = restoreClaudePrimaryApiKey(settings.env);
165
+ const primaryApiKeyRestore = restoreClaudePrimaryApiKey(settings.env);
149
166
 
150
167
  // Clean up empty env block
151
168
  if (Object.keys(settings.env).length === 0) {
152
169
  delete settings.env;
153
170
  }
154
171
 
155
- if (routingChanged || primaryApiKeyRestored) {
172
+ if (routingChanged || primaryApiKeyRestore.restored) {
156
173
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), { mode: 0o600 });
157
174
  results.routing = true;
158
175
  }
176
+ if (primaryApiKeyRestore.warning) {
177
+ console.log(` Warning: Could not restore Claude managed API key: ${primaryApiKeyRestore.warning}`);
178
+ }
159
179
  }
160
180
  }
161
181
  }
@@ -163,24 +183,34 @@ try {
163
183
  console.log(` Warning: Could not clean settings.json: ${e.message}`);
164
184
  }
165
185
 
166
- // 2. Remove slash command files
167
- // These are the Plexor-specific command files that get installed to ~/.claude/commands/
168
- const plexorCommands = [
169
- 'plexor-config.md',
170
- 'plexor-enabled.md',
171
- 'plexor-login.md',
172
- 'plexor-logout.md',
173
- 'plexor-mode.md',
174
- 'plexor-provider.md',
175
- 'plexor-settings.md',
176
- 'plexor-setup.md',
177
- 'plexor-status.md',
178
- 'plexor-uninstall.md'
179
- ];
186
+ // 2. Remove managed Claude status line
187
+ try {
188
+ const statusLineRemoval = removeManagedStatusLine(CLAUDE_SETTINGS_PATH, home);
189
+ results.statusLine = statusLineRemoval.changed;
190
+ } catch (e) {
191
+ console.log(` Warning: Could not clean Plexor status line: ${e.message}`);
192
+ }
193
+
194
+ // 2b. Remove managed Claude hooks
195
+ try {
196
+ const hooksRemoval = removeManagedHooks(CLAUDE_HOOKS_PATH);
197
+ results.hooks = hooksRemoval.changed;
198
+ } catch (e) {
199
+ console.log(` Warning: Could not clean Plexor hooks: ${e.message}`);
200
+ }
201
+ try {
202
+ const legacyHooksRemoval = cleanupLegacyManagedHooksFile(CLAUDE_LEGACY_HOOKS_PATH);
203
+ results.hooks = results.hooks || legacyHooksRemoval.changed;
204
+ } catch (e) {
205
+ console.log(` Warning: Could not clean legacy Plexor hooks file: ${e.message}`);
206
+ }
180
207
 
208
+ // 3. Remove slash command files
209
+ // These are the Plexor-specific command files that get installed to ~/.claude/commands/
181
210
  try {
182
211
  const commandsDir = path.join(home, '.claude', 'commands');
183
212
  if (fs.existsSync(commandsDir)) {
213
+ const plexorCommands = fs.readdirSync(commandsDir).filter((entry) => /^plexor-.*\.md$/i.test(entry));
184
214
  for (const cmd of plexorCommands) {
185
215
  const cmdPath = path.join(commandsDir, cmd);
186
216
  const backupPath = cmdPath + '.backup';
@@ -201,7 +231,7 @@ try {
201
231
  console.log(` Warning: Could not clean commands: ${e.message}`);
202
232
  }
203
233
 
204
- // 3. Remove plugin directory
234
+ // 4. Remove plugin directory
205
235
  try {
206
236
  const pluginDir = path.join(home, '.claude', 'plugins', 'plexor');
207
237
  if (fs.existsSync(pluginDir)) {
@@ -212,8 +242,19 @@ try {
212
242
  console.log(` Warning: Could not remove plugin directory: ${e.message}`);
213
243
  }
214
244
 
245
+ // 5. Remove config directory
246
+ try {
247
+ const configDir = path.join(home, '.plexor');
248
+ if (fs.existsSync(configDir)) {
249
+ fs.rmSync(configDir, { recursive: true, force: true });
250
+ results.configDir = true;
251
+ }
252
+ } catch (e) {
253
+ console.log(` Warning: Could not remove ~/.plexor config directory: ${e.message}`);
254
+ }
255
+
215
256
  // Output results
216
- if (results.routing || results.commands.length > 0 || results.pluginDir) {
257
+ if (results.routing || results.statusLine || results.hooks || results.commands.length > 0 || results.pluginDir) {
217
258
  console.log(' Plexor plugin uninstalled');
218
259
  console.log('');
219
260
 
@@ -223,6 +264,16 @@ if (results.routing || results.commands.length > 0 || results.pluginDir) {
223
264
  console.log('');
224
265
  }
225
266
 
267
+ if (results.statusLine) {
268
+ console.log(' Removed Plexor Claude status line');
269
+ console.log('');
270
+ }
271
+
272
+ if (results.hooks) {
273
+ console.log(' Removed Plexor Claude hooks');
274
+ console.log('');
275
+ }
276
+
226
277
  if (results.commands.length > 0) {
227
278
  console.log(' Removed commands:');
228
279
  results.commands.forEach(cmd => console.log(` /${cmd}`));
@@ -239,10 +290,10 @@ if (results.routing || results.commands.length > 0 || results.pluginDir) {
239
290
  console.log(' Removed plugin directory');
240
291
  console.log('');
241
292
  }
242
-
243
- console.log(' Note: ~/.plexor/ config directory was preserved.');
244
- console.log(' To remove it: rm -rf ~/.plexor');
245
- console.log('');
293
+ if (results.configDir) {
294
+ console.log(' Removed ~/.plexor config directory');
295
+ console.log('');
296
+ }
246
297
  } else {
247
298
  console.log(' No Plexor components found to clean up.');
248
299
  console.log('');