@plexor-dev/claude-code-plugin-staging 0.1.0-beta.2 → 0.1.0-beta.20

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.
@@ -12,13 +12,111 @@
12
12
 
13
13
  const fs = require('fs');
14
14
  const path = require('path');
15
+ const os = require('os');
16
+ const crypto = require('crypto');
15
17
 
16
18
  const CLAUDE_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.claude');
17
19
  const SETTINGS_PATH = path.join(CLAUDE_DIR, 'settings.json');
20
+ const LOCK_TIMEOUT_MS = 5000; // 5 second lock timeout
18
21
 
19
22
  // Plexor gateway endpoints
20
23
  const PLEXOR_STAGING_URL = 'https://staging.api.plexor.dev/gateway/anthropic';
21
24
  const PLEXOR_PROD_URL = 'https://api.plexor.dev/gateway/anthropic';
25
+ const PREVIOUS_API_KEY_ENV = 'PLEXOR_PREVIOUS_ANTHROPIC_API_KEY';
26
+ const PREVIOUS_AUTH_TOKEN_ENV = 'PLEXOR_PREVIOUS_ANTHROPIC_AUTH_TOKEN';
27
+
28
+ /**
29
+ * Check if a base URL is a Plexor-managed gateway URL.
30
+ * Detects all variants: production, staging, localhost, tunnels.
31
+ */
32
+ function isManagedGatewayUrl(baseUrl) {
33
+ if (!baseUrl) return false;
34
+ return (
35
+ baseUrl.includes('plexor') ||
36
+ baseUrl.includes('staging.api') ||
37
+ baseUrl.includes('localhost') ||
38
+ baseUrl.includes('127.0.0.1') ||
39
+ baseUrl.includes('ngrok') ||
40
+ baseUrl.includes('localtunnel')
41
+ );
42
+ }
43
+
44
+ function isPlexorApiKey(value = '') {
45
+ return typeof value === 'string' && value.startsWith('plx_');
46
+ }
47
+
48
+ function getPlexorAuthKey(env = {}) {
49
+ const apiKey = env.ANTHROPIC_API_KEY || '';
50
+ const authToken = env.ANTHROPIC_AUTH_TOKEN || '';
51
+
52
+ if (isPlexorApiKey(apiKey)) return apiKey;
53
+ if (isPlexorApiKey(authToken)) return authToken;
54
+ return apiKey || authToken || '';
55
+ }
56
+
57
+ function hasPlexorManagedAuth(env = {}) {
58
+ return (
59
+ isManagedGatewayUrl(env.ANTHROPIC_BASE_URL || '') ||
60
+ isPlexorApiKey(env.ANTHROPIC_API_KEY || '') ||
61
+ isPlexorApiKey(env.ANTHROPIC_AUTH_TOKEN || '')
62
+ );
63
+ }
64
+
65
+ function stashDirectAuthEnv(env, plexorApiKey) {
66
+ const alreadyManaged = hasPlexorManagedAuth(env);
67
+ const currentApiKey = env.ANTHROPIC_API_KEY || '';
68
+ const currentAuthToken = env.ANTHROPIC_AUTH_TOKEN || '';
69
+
70
+ if (currentApiKey && !isPlexorApiKey(currentApiKey) && currentApiKey !== plexorApiKey) {
71
+ env[PREVIOUS_API_KEY_ENV] = currentApiKey;
72
+ } else if (!alreadyManaged) {
73
+ delete env[PREVIOUS_API_KEY_ENV];
74
+ }
75
+
76
+ if (currentAuthToken && !isPlexorApiKey(currentAuthToken) && currentAuthToken !== plexorApiKey) {
77
+ env[PREVIOUS_AUTH_TOKEN_ENV] = currentAuthToken;
78
+ } else if (!alreadyManaged) {
79
+ delete env[PREVIOUS_AUTH_TOKEN_ENV];
80
+ }
81
+ }
82
+
83
+ function setPlexorAuthKey(env, apiKey) {
84
+ stashDirectAuthEnv(env, apiKey);
85
+ env.ANTHROPIC_API_KEY = apiKey;
86
+ env.ANTHROPIC_AUTH_TOKEN = apiKey;
87
+ }
88
+
89
+ function clearPlexorRoutingEnv(env = {}) {
90
+ const hasManagedBaseUrl = isManagedGatewayUrl(env.ANTHROPIC_BASE_URL || '');
91
+ const hasPlexorApiKey = isPlexorApiKey(env.ANTHROPIC_API_KEY || '');
92
+ const hasPlexorAuthToken = isPlexorApiKey(env.ANTHROPIC_AUTH_TOKEN || '');
93
+
94
+ if (!hasManagedBaseUrl && !hasPlexorApiKey && !hasPlexorAuthToken) {
95
+ return false;
96
+ }
97
+
98
+ if (hasManagedBaseUrl) {
99
+ delete env.ANTHROPIC_BASE_URL;
100
+ }
101
+ if (hasPlexorAuthToken) {
102
+ delete env.ANTHROPIC_AUTH_TOKEN;
103
+ }
104
+ if (hasPlexorApiKey) {
105
+ delete env.ANTHROPIC_API_KEY;
106
+ }
107
+
108
+ if (!env.ANTHROPIC_API_KEY && env[PREVIOUS_API_KEY_ENV]) {
109
+ env.ANTHROPIC_API_KEY = env[PREVIOUS_API_KEY_ENV];
110
+ }
111
+ if (!env.ANTHROPIC_AUTH_TOKEN && env[PREVIOUS_AUTH_TOKEN_ENV]) {
112
+ env.ANTHROPIC_AUTH_TOKEN = env[PREVIOUS_AUTH_TOKEN_ENV];
113
+ }
114
+
115
+ delete env[PREVIOUS_API_KEY_ENV];
116
+ delete env[PREVIOUS_AUTH_TOKEN_ENV];
117
+
118
+ return true;
119
+ }
22
120
 
23
121
  class ClaudeSettingsManager {
24
122
  constructor() {
@@ -27,8 +125,8 @@ class ClaudeSettingsManager {
27
125
  }
28
126
 
29
127
  /**
30
- * Load current Claude settings
31
- * @returns {Object} settings object or empty object if not found
128
+ * Load current Claude settings with integrity checking
129
+ * @returns {Object} settings object or empty object if not found/corrupted
32
130
  */
33
131
  load() {
34
132
  try {
@@ -36,15 +134,84 @@ class ClaudeSettingsManager {
36
134
  return {};
37
135
  }
38
136
  const data = fs.readFileSync(this.settingsPath, 'utf8');
39
- return JSON.parse(data);
137
+
138
+ // Check for empty file
139
+ if (!data || data.trim() === '') {
140
+ return {};
141
+ }
142
+
143
+ const parsed = JSON.parse(data);
144
+
145
+ // Basic schema validation - must be an object
146
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
147
+ const backupPath = this._backupCorruptedFile();
148
+ console.warn('');
149
+ console.warn('WARNING: Claude settings file has invalid format!');
150
+ if (backupPath) {
151
+ console.warn(` Corrupted file backed up to: ${backupPath}`);
152
+ }
153
+ console.warn(' Using default settings. Your previous settings may need manual recovery.');
154
+ console.warn('');
155
+ return {};
156
+ }
157
+
158
+ return parsed;
40
159
  } catch (err) {
41
- // Return empty object if file doesn't exist or parse error
160
+ if (err.code === 'ENOENT') {
161
+ return {};
162
+ }
163
+ // JSON parse error or corrupted file
164
+ if (err instanceof SyntaxError) {
165
+ const backupPath = this._backupCorruptedFile();
166
+ console.warn('');
167
+ console.warn('WARNING: Claude settings file is corrupted (invalid JSON)!');
168
+ if (backupPath) {
169
+ console.warn(` Corrupted file backed up to: ${backupPath}`);
170
+ }
171
+ console.warn(' Using default settings. Your previous settings may need manual recovery.');
172
+ console.warn('');
173
+ return {};
174
+ }
175
+ // Permission error
176
+ if (err.code === 'EACCES' || err.code === 'EPERM') {
177
+ console.warn(`Warning: Cannot read ${this.settingsPath} (permission denied)`);
178
+ return {};
179
+ }
180
+ console.warn('Warning: Failed to load Claude settings:', err.message);
42
181
  return {};
43
182
  }
44
183
  }
45
184
 
46
185
  /**
47
- * Save Claude settings
186
+ * Backup a corrupted settings file with numbered suffix for debugging
187
+ * Creates settings.json.corrupted.1, .corrupted.2, etc. to preserve history
188
+ * @returns {string|null} path to backup file, or null if backup failed
189
+ */
190
+ _backupCorruptedFile() {
191
+ try {
192
+ if (fs.existsSync(this.settingsPath)) {
193
+ // Find next available numbered backup
194
+ let backupNum = 1;
195
+ let backupPath;
196
+ while (true) {
197
+ backupPath = `${this.settingsPath}.corrupted.${backupNum}`;
198
+ if (!fs.existsSync(backupPath)) {
199
+ break;
200
+ }
201
+ backupNum++;
202
+ }
203
+ fs.copyFileSync(this.settingsPath, backupPath);
204
+ return backupPath;
205
+ }
206
+ } catch {
207
+ // Ignore backup errors silently
208
+ }
209
+ return null;
210
+ }
211
+
212
+ /**
213
+ * Save Claude settings using atomic write pattern
214
+ * Prevents race conditions by writing to temp file then renaming
48
215
  * @param {Object} settings - settings object to save
49
216
  * @returns {boolean} success status
50
217
  */
@@ -55,10 +222,27 @@ class ClaudeSettingsManager {
55
222
  fs.mkdirSync(this.claudeDir, { recursive: true });
56
223
  }
57
224
 
58
- fs.writeFileSync(this.settingsPath, JSON.stringify(settings, null, 2));
225
+ // Atomic write: write to temp file, then rename
226
+ // This prevents race conditions where concurrent writes corrupt the file
227
+ const tempId = crypto.randomBytes(8).toString('hex');
228
+ const tempPath = path.join(this.claudeDir, `.settings.${tempId}.tmp`);
229
+
230
+ // Write to temp file
231
+ const content = JSON.stringify(settings, null, 2);
232
+ fs.writeFileSync(tempPath, content, { mode: 0o600 });
233
+
234
+ // Atomic rename (on POSIX systems, rename is atomic)
235
+ fs.renameSync(tempPath, this.settingsPath);
236
+
59
237
  return true;
60
238
  } catch (err) {
61
- console.error('Failed to save Claude settings:', err.message);
239
+ // Clean error message for permission errors
240
+ if (err.code === 'EACCES' || err.code === 'EPERM') {
241
+ console.error(`Error: Cannot write to ${this.settingsPath}`);
242
+ console.error(' Check file permissions or run with appropriate access.');
243
+ } else {
244
+ console.error('Failed to save Claude settings:', err.message);
245
+ }
62
246
  return false;
63
247
  }
64
248
  }
@@ -66,8 +250,9 @@ class ClaudeSettingsManager {
66
250
  /**
67
251
  * Enable Plexor routing by setting env vars in settings.json
68
252
  *
69
- * This is the KEY mechanism: setting ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN
70
- * in the env block redirects ALL Claude Code sessions to Plexor automatically.
253
+ * This is the KEY mechanism: setting ANTHROPIC_BASE_URL plus both Claude auth
254
+ * env vars in settings.json redirects ALL Claude Code sessions to Plexor
255
+ * automatically, even when the user previously configured direct API auth.
71
256
  *
72
257
  * @param {string} apiKey - Plexor API key (plx_*)
73
258
  * @param {Object} options - { useStaging: boolean }
@@ -86,10 +271,10 @@ class ClaudeSettingsManager {
86
271
  settings.env = {};
87
272
  }
88
273
 
89
- // Set the magic environment variables
90
- // ANTHROPIC_AUTH_TOKEN has higher precedence than ANTHROPIC_API_KEY
274
+ // Mirror the Plexor key into both Claude auth env vars so every Claude
275
+ // runtime path uses the gateway instead of any previously saved direct key.
91
276
  settings.env.ANTHROPIC_BASE_URL = apiUrl;
92
- settings.env.ANTHROPIC_AUTH_TOKEN = apiKey;
277
+ setPlexorAuthKey(settings.env, apiKey);
93
278
 
94
279
  const success = this.save(settings);
95
280
 
@@ -124,9 +309,10 @@ class ClaudeSettingsManager {
124
309
  return true;
125
310
  }
126
311
 
127
- // Remove Plexor-specific env vars
128
- delete settings.env.ANTHROPIC_BASE_URL;
129
- delete settings.env.ANTHROPIC_AUTH_TOKEN;
312
+ if (!clearPlexorRoutingEnv(settings.env)) {
313
+ console.log('Plexor routing is not currently enabled.');
314
+ return true;
315
+ }
130
316
 
131
317
  // Clean up empty env block
132
318
  if (Object.keys(settings.env).length === 0) {
@@ -157,26 +343,48 @@ class ClaudeSettingsManager {
157
343
  const settings = this.load();
158
344
 
159
345
  const baseUrl = settings.env?.ANTHROPIC_BASE_URL || null;
160
- const hasToken = !!settings.env?.ANTHROPIC_AUTH_TOKEN;
346
+ const authKey = getPlexorAuthKey(settings.env);
347
+ const hasToken = !!authKey;
161
348
 
162
- // Check if routing to Plexor
163
- const isPlexorRouting = baseUrl && (
164
- baseUrl.includes('plexor') ||
165
- baseUrl.includes('staging.api')
166
- );
349
+ // Check if routing to Plexor (any variant: prod, staging, localhost, tunnel)
350
+ const isPlexorRouting = isManagedGatewayUrl(baseUrl);
167
351
 
168
352
  return {
169
353
  enabled: isPlexorRouting,
170
354
  baseUrl,
171
355
  hasToken,
172
356
  isStaging: baseUrl?.includes('staging') || false,
173
- tokenPreview: hasToken ? settings.env.ANTHROPIC_AUTH_TOKEN.substring(0, 12) + '...' : null
357
+ tokenPreview: hasToken ? authKey.substring(0, 12) + '...' : null
174
358
  };
175
359
  } catch {
176
360
  return { enabled: false, baseUrl: null, hasToken: false };
177
361
  }
178
362
  }
179
363
 
364
+ /**
365
+ * Detect partial routing state where URL points to Plexor but auth is missing/invalid
366
+ * This can cause confusing auth errors for users
367
+ * @returns {Object} { partial: boolean, issue: string|null }
368
+ */
369
+ detectPartialState() {
370
+ try {
371
+ const settings = this.load();
372
+ const baseUrl = settings.env?.ANTHROPIC_BASE_URL || '';
373
+ const authKey = getPlexorAuthKey(settings.env);
374
+ const isPlexorUrl = isManagedGatewayUrl(baseUrl);
375
+
376
+ if (isPlexorUrl && !authKey) {
377
+ return { partial: true, issue: 'Plexor URL set but no Plexor auth key' };
378
+ }
379
+ if (isPlexorUrl && !isPlexorApiKey(authKey)) {
380
+ return { partial: true, issue: 'Plexor URL set but auth key is not a Plexor key' };
381
+ }
382
+ return { partial: false, issue: null };
383
+ } catch {
384
+ return { partial: false, issue: null };
385
+ }
386
+ }
387
+
180
388
  /**
181
389
  * Update just the API key without changing other settings
182
390
  * @param {string} apiKey - new Plexor API key
@@ -190,7 +398,7 @@ class ClaudeSettingsManager {
190
398
  settings.env = {};
191
399
  }
192
400
 
193
- settings.env.ANTHROPIC_AUTH_TOKEN = apiKey;
401
+ setPlexorAuthKey(settings.env, apiKey);
194
402
  return this.save(settings);
195
403
  } catch (err) {
196
404
  console.error('Failed to update API key:', err.message);
@@ -208,7 +416,7 @@ class ClaudeSettingsManager {
208
416
  const settings = this.load();
209
417
 
210
418
  if (!settings.env?.ANTHROPIC_BASE_URL) {
211
- console.log('Plexor routing is not enabled. Run /plexor-login first.');
419
+ console.log('Plexor routing is not enabled. Run /plexor-setup first.');
212
420
  return false;
213
421
  }
214
422
 
@@ -232,6 +440,7 @@ const settingsManager = new ClaudeSettingsManager();
232
440
  module.exports = {
233
441
  ClaudeSettingsManager,
234
442
  settingsManager,
443
+ isManagedGatewayUrl,
235
444
  CLAUDE_DIR,
236
445
  SETTINGS_PATH,
237
446
  PLEXOR_STAGING_URL,
@@ -0,0 +1,77 @@
1
+ const { spawnSync } = require('child_process');
2
+ const { HOME_DIR } = require('./constants');
3
+
4
+ const VERIFY_PROMPT = 'Reply with exactly PONG. Do not use tools.';
5
+ const EXPECTED_VERIFY_RESPONSE = 'PONG';
6
+
7
+ function normalizeVerifyOutput(output = '') {
8
+ return String(output).trim();
9
+ }
10
+
11
+ function interpretVerifyResult(result = {}) {
12
+ const stdout = normalizeVerifyOutput(result.stdout);
13
+ const stderr = normalizeVerifyOutput(result.stderr);
14
+
15
+ if (result.error) {
16
+ return {
17
+ ok: false,
18
+ stdout,
19
+ stderr,
20
+ code: null,
21
+ reason: result.error.message || 'Claude verification failed to start'
22
+ };
23
+ }
24
+
25
+ if (result.status !== 0) {
26
+ return {
27
+ ok: false,
28
+ stdout,
29
+ stderr,
30
+ code: result.status,
31
+ reason: stderr || stdout || `Claude exited with status ${result.status}`
32
+ };
33
+ }
34
+
35
+ if (stdout !== EXPECTED_VERIFY_RESPONSE) {
36
+ return {
37
+ ok: false,
38
+ stdout,
39
+ stderr,
40
+ code: result.status,
41
+ reason: `Expected "${EXPECTED_VERIFY_RESPONSE}" but received "${stdout || '(empty)'}"`
42
+ };
43
+ }
44
+
45
+ return {
46
+ ok: true,
47
+ stdout,
48
+ stderr,
49
+ code: result.status,
50
+ reason: ''
51
+ };
52
+ }
53
+
54
+ function runClaudeRouteVerification(options = {}, runCommand = spawnSync) {
55
+ const result = runCommand(options.claudeCommand || 'claude', [
56
+ '-p',
57
+ '--tools', '',
58
+ '--output-format', 'text',
59
+ VERIFY_PROMPT
60
+ ], {
61
+ cwd: options.cwd || HOME_DIR,
62
+ env: options.env || process.env,
63
+ encoding: 'utf8',
64
+ timeout: options.timeoutMs || 30000,
65
+ maxBuffer: 1024 * 1024
66
+ });
67
+
68
+ return interpretVerifyResult(result);
69
+ }
70
+
71
+ module.exports = {
72
+ EXPECTED_VERIFY_RESPONSE,
73
+ VERIFY_PROMPT,
74
+ interpretVerifyResult,
75
+ normalizeVerifyOutput,
76
+ runClaudeRouteVerification
77
+ };
package/package.json CHANGED
@@ -1,17 +1,19 @@
1
1
  {
2
2
  "name": "@plexor-dev/claude-code-plugin-staging",
3
- "version": "0.1.0-beta.2",
3
+ "version": "0.1.0-beta.20",
4
4
  "description": "STAGING - LLM cost optimization plugin for Claude Code (internal testing)",
5
5
  "main": "lib/constants.js",
6
6
  "bin": {
7
7
  "plexor-status": "./commands/plexor-status.js",
8
- "plexor-mode": "./commands/plexor-mode.js",
8
+ "plexor-setup": "./commands/plexor-setup.js",
9
9
  "plexor-enabled": "./commands/plexor-enabled.js",
10
- "plexor-provider": "./commands/plexor-provider.js",
11
10
  "plexor-login": "./commands/plexor-login.js",
12
11
  "plexor-logout": "./commands/plexor-logout.js",
12
+ "plexor-uninstall": "./commands/plexor-uninstall.js",
13
13
  "plexor-settings": "./commands/plexor-settings.js",
14
- "plexor-config": "./commands/plexor-config.js"
14
+ "plexor-routing": "./commands/plexor-routing.js",
15
+ "plexor-agent": "./commands/plexor-agent.js",
16
+ "plexor-provider": "./commands/plexor-provider.js"
15
17
  },
16
18
  "scripts": {
17
19
  "postinstall": "node scripts/postinstall.js",