@plexor-dev/claude-code-plugin-staging 0.1.0-beta.14 → 0.1.0-beta.16

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/lib/logger.js CHANGED
@@ -2,35 +2,94 @@
2
2
  * Plexor Logger
3
3
  *
4
4
  * Simple logger that outputs to stderr to avoid interfering with stdout JSON.
5
+ * Uses ANSI-colored badges for branded Plexor messages.
5
6
  */
6
7
 
8
+ // ANSI color codes for branded badge output
9
+ const RESET = '\x1b[0m';
10
+ const BOLD = '\x1b[1m';
11
+ const DIM = '\x1b[2m';
12
+ const WHITE = '\x1b[37m';
13
+ const YELLOW_FG = '\x1b[33m';
14
+ const RED_FG = '\x1b[31m';
15
+ const CYAN_FG = '\x1b[36m';
16
+ const BLUE_BG = '\x1b[44m';
17
+ const YELLOW_BG = '\x1b[43m';
18
+ const RED_BG = '\x1b[41m';
19
+
20
+ // Pre-built badge strings
21
+ const BADGE_INFO = `${BOLD}${WHITE}${BLUE_BG} PLEXOR ${RESET}`;
22
+ const BADGE_WARN = `${BOLD}${WHITE}${YELLOW_BG} PLEXOR ${RESET}`;
23
+ const BADGE_ERROR = `${BOLD}${WHITE}${RED_BG} PLEXOR ${RESET}`;
24
+
25
+ function parseBooleanEnv(name, defaultValue) {
26
+ const raw = process.env[name];
27
+ if (raw === undefined || raw === null || raw === '') {
28
+ return defaultValue;
29
+ }
30
+ const normalized = String(raw).trim().toLowerCase();
31
+ if (['1', 'true', 'yes', 'on'].includes(normalized)) {
32
+ return true;
33
+ }
34
+ if (['0', 'false', 'no', 'off'].includes(normalized)) {
35
+ return false;
36
+ }
37
+ return defaultValue;
38
+ }
39
+
40
+ function formatUxMessage(message) {
41
+ const text = String(message || '').trim();
42
+ if (!text) {
43
+ return '[PLEXOR: message]';
44
+ }
45
+ if (text.startsWith('[PLEXOR:')) {
46
+ return text;
47
+ }
48
+ if (text.startsWith('[PLEXOR') && text.endsWith(']')) {
49
+ return text.replace(/^\[PLEXOR[^\]]*\]\s*/i, '[PLEXOR: ');
50
+ }
51
+ return `[PLEXOR: ${text}]`;
52
+ }
53
+
7
54
  class Logger {
8
55
  constructor(component = 'plexor') {
9
56
  this.component = component;
10
- this.debug_enabled = process.env.PLEXOR_DEBUG === '1' || process.env.PLEXOR_DEBUG === 'true';
57
+ this.debug_enabled = parseBooleanEnv('PLEXOR_DEBUG', false);
58
+ this.ux_messages_enabled =
59
+ parseBooleanEnv('PLEXOR_UX_MESSAGES', true) &&
60
+ parseBooleanEnv('PLEXOR_UX_DEBUG_MESSAGES', true);
11
61
  }
12
62
 
13
63
  debug(msg, data = null) {
14
64
  if (this.debug_enabled) {
15
- const output = data ? `[DEBUG][${this.component}] ${msg} ${JSON.stringify(data)}` : `[DEBUG][${this.component}] ${msg}`;
65
+ const output = data ? `${DIM}[${this.component}]${RESET} ${msg} ${JSON.stringify(data)}` : `${DIM}[${this.component}]${RESET} ${msg}`;
16
66
  console.error(output);
17
67
  }
18
68
  }
19
69
 
20
70
  info(msg, data = null) {
21
- const output = data ? `${msg} ${JSON.stringify(data)}` : msg;
71
+ const output = data ? `${BADGE_INFO} ${msg} ${JSON.stringify(data)}` : `${BADGE_INFO} ${msg}`;
22
72
  console.error(output);
23
73
  }
24
74
 
25
75
  warn(msg, data = null) {
26
- const output = data ? `[WARN][${this.component}] ${msg} ${JSON.stringify(data)}` : `[WARN][${this.component}] ${msg}`;
76
+ const output = data ? `${BADGE_WARN} ${YELLOW_FG}${msg} ${JSON.stringify(data)}${RESET}` : `${BADGE_WARN} ${YELLOW_FG}${msg}${RESET}`;
27
77
  console.error(output);
28
78
  }
29
79
 
30
80
  error(msg, data = null) {
31
- const output = data ? `[ERROR][${this.component}] ${msg} ${JSON.stringify(data)}` : `[ERROR][${this.component}] ${msg}`;
81
+ const output = data ? `${BADGE_ERROR} ${RED_FG}${msg} ${JSON.stringify(data)}${RESET}` : `${BADGE_ERROR} ${RED_FG}${msg}${RESET}`;
32
82
  console.error(output);
33
83
  }
84
+
85
+ ux(msg, data = null) {
86
+ if (!this.ux_messages_enabled) {
87
+ return;
88
+ }
89
+ const prefixed = formatUxMessage(msg);
90
+ const output = data ? `${prefixed} ${JSON.stringify(data)}` : prefixed;
91
+ console.error(`${CYAN_FG}${output}${RESET}`);
92
+ }
34
93
  }
35
94
 
36
95
  module.exports = Logger;
@@ -23,6 +23,22 @@ const LOCK_TIMEOUT_MS = 5000; // 5 second lock timeout
23
23
  const PLEXOR_STAGING_URL = 'https://staging.api.plexor.dev/gateway/anthropic';
24
24
  const PLEXOR_PROD_URL = 'https://api.plexor.dev/gateway/anthropic';
25
25
 
26
+ /**
27
+ * Check if a base URL is a Plexor-managed gateway URL.
28
+ * Detects all variants: production, staging, localhost, tunnels.
29
+ */
30
+ function isManagedGatewayUrl(baseUrl) {
31
+ if (!baseUrl) return false;
32
+ return (
33
+ baseUrl.includes('plexor') ||
34
+ baseUrl.includes('staging.api') ||
35
+ baseUrl.includes('localhost') ||
36
+ baseUrl.includes('127.0.0.1') ||
37
+ baseUrl.includes('ngrok') ||
38
+ baseUrl.includes('localtunnel')
39
+ );
40
+ }
41
+
26
42
  class ClaudeSettingsManager {
27
43
  constructor() {
28
44
  this.settingsPath = SETTINGS_PATH;
@@ -248,11 +264,8 @@ class ClaudeSettingsManager {
248
264
  const baseUrl = settings.env?.ANTHROPIC_BASE_URL || null;
249
265
  const hasToken = !!settings.env?.ANTHROPIC_AUTH_TOKEN;
250
266
 
251
- // Check if routing to Plexor
252
- const isPlexorRouting = baseUrl && (
253
- baseUrl.includes('plexor') ||
254
- baseUrl.includes('staging.api')
255
- );
267
+ // Check if routing to Plexor (any variant: prod, staging, localhost, tunnel)
268
+ const isPlexorRouting = isManagedGatewayUrl(baseUrl);
256
269
 
257
270
  return {
258
271
  enabled: isPlexorRouting,
@@ -276,7 +289,7 @@ class ClaudeSettingsManager {
276
289
  const settings = this.load();
277
290
  const baseUrl = settings.env?.ANTHROPIC_BASE_URL || '';
278
291
  const authToken = settings.env?.ANTHROPIC_AUTH_TOKEN || '';
279
- const isPlexorUrl = baseUrl.includes('plexor') || baseUrl.includes('staging.api');
292
+ const isPlexorUrl = isManagedGatewayUrl(baseUrl);
280
293
 
281
294
  if (isPlexorUrl && !authToken) {
282
295
  return { partial: true, issue: 'Plexor URL set but no auth token' };
@@ -345,6 +358,7 @@ const settingsManager = new ClaudeSettingsManager();
345
358
  module.exports = {
346
359
  ClaudeSettingsManager,
347
360
  settingsManager,
361
+ isManagedGatewayUrl,
348
362
  CLAUDE_DIR,
349
363
  SETTINGS_PATH,
350
364
  PLEXOR_STAGING_URL,
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.14",
3
+ "version": "0.1.0-beta.16",
4
4
  "description": "STAGING - LLM cost optimization plugin for Claude Code (internal testing)",
5
5
  "main": "lib/constants.js",
6
6
  "bin": {
@@ -8,7 +8,11 @@
8
8
  "plexor-enabled": "./commands/plexor-enabled.js",
9
9
  "plexor-login": "./commands/plexor-login.js",
10
10
  "plexor-logout": "./commands/plexor-logout.js",
11
- "plexor-uninstall": "./commands/plexor-uninstall.js"
11
+ "plexor-uninstall": "./commands/plexor-uninstall.js",
12
+ "plexor-settings": "./commands/plexor-settings.js",
13
+ "plexor-routing": "./commands/plexor-routing.js",
14
+ "plexor-agent": "./commands/plexor-agent.js",
15
+ "plexor-provider": "./commands/plexor-provider.js"
12
16
  },
13
17
  "scripts": {
14
18
  "postinstall": "node scripts/postinstall.js",
@@ -13,42 +13,109 @@ const os = require('os');
13
13
  const { execSync } = require('child_process');
14
14
 
15
15
  /**
16
- * Get the correct home directory, accounting for sudo.
17
- * When running with sudo, os.homedir() returns /root, but we want
18
- * the actual user's home directory.
16
+ * Resolve the home directory for a given username by querying /etc/passwd.
17
+ * This is the authoritative source and handles non-standard home paths
18
+ * (e.g., /root, /opt/users/foo, NIS/LDAP users, etc.).
19
+ * Returns null if lookup fails (Windows, missing getent, etc.).
20
+ */
21
+ function getHomeDirFromPasswd(username) {
22
+ try {
23
+ const entry = execSync(`getent passwd ${username}`, { encoding: 'utf8' }).trim();
24
+ // Format: username:x:uid:gid:gecos:homedir:shell
25
+ const fields = entry.split(':');
26
+ if (fields.length >= 6 && fields[5]) {
27
+ return fields[5];
28
+ }
29
+ } catch {
30
+ // getent not available or user not found
31
+ }
32
+ return null;
33
+ }
34
+
35
+ /**
36
+ * Get the correct home directory for the process's effective user.
37
+ *
38
+ * Handles three scenarios:
39
+ * 1. Normal execution: HOME is correct, os.homedir() is correct.
40
+ * 2. `sudo npm install`: SUDO_USER is set, os.homedir() returns /root,
41
+ * but we want the SUDO_USER's home.
42
+ * 3. `sudo -u target npm install`:
43
+ * HOME may still be the *caller's* home (e.g.,
44
+ * /home/azureuser), SUDO_USER is the *caller*
45
+ * (not the target), but process.getuid() returns
46
+ * the *target* UID. We must resolve home from
47
+ * /etc/passwd by UID.
48
+ *
49
+ * Resolution order (most authoritative first):
50
+ * a) Look up the effective UID in /etc/passwd via getent (handles sudo -u)
51
+ * b) Fall back to os.homedir() (works for normal execution)
52
+ * c) Fall back to HOME / USERPROFILE env vars (last resort)
19
53
  */
20
54
  function getHomeDir() {
21
- // Check if running with sudo - SUDO_USER contains the original username
22
- if (process.env.SUDO_USER) {
23
- // On Linux/Mac, home directories are typically /home/<user> or /Users/<user>
24
- const platform = os.platform();
25
- if (platform === 'darwin') {
26
- return path.join('/Users', process.env.SUDO_USER);
27
- } else if (platform === 'linux') {
28
- return path.join('/home', process.env.SUDO_USER);
55
+ // On non-Windows, resolve via the effective UID's passwd entry.
56
+ // This is the most reliable method and correctly handles both
57
+ // `sudo` and `sudo -u <target>` scenarios.
58
+ if (os.platform() !== 'win32') {
59
+ try {
60
+ const uid = process.getuid();
61
+ const entry = execSync(`getent passwd ${uid}`, { encoding: 'utf8' }).trim();
62
+ const fields = entry.split(':');
63
+ if (fields.length >= 6 && fields[5]) {
64
+ return fields[5];
65
+ }
66
+ } catch {
67
+ // Fall through to other methods
29
68
  }
30
69
  }
31
- return os.homedir();
70
+
71
+ // Fallback: os.homedir() (reads HOME env var, then passwd on Unix)
72
+ const home = os.homedir();
73
+ if (home) return home;
74
+
75
+ // Last resort: environment variables
76
+ return process.env.HOME || process.env.USERPROFILE || '/tmp';
32
77
  }
33
78
 
34
79
  /**
35
- * Get uid/gid for the target user (handles sudo case).
36
- * Returns null if not running with sudo or on Windows.
80
+ * Get uid/gid for the effective user running this process.
81
+ * Under `sudo`, the effective user is root but we want to chown to the
82
+ * original (SUDO_USER) or target (`sudo -u target`) user.
83
+ * Under `sudo -u target`, process.getuid() IS the target, so we use that.
84
+ * Returns null on Windows or if no privilege elevation detected.
37
85
  */
38
86
  function getTargetUserIds() {
39
- const sudoUser = process.env.SUDO_USER;
40
- if (!sudoUser || os.platform() === 'win32') {
87
+ if (os.platform() === 'win32') {
41
88
  return null;
42
89
  }
43
90
 
44
91
  try {
45
- // Get uid and gid for the sudo user
46
- const uid = parseInt(execSync(`id -u ${sudoUser}`, { encoding: 'utf8' }).trim(), 10);
47
- const gid = parseInt(execSync(`id -g ${sudoUser}`, { encoding: 'utf8' }).trim(), 10);
48
- return { uid, gid, user: sudoUser };
92
+ const effectiveUid = process.getuid();
93
+
94
+ // If we're running as root (uid 0), we were likely invoked via `sudo`.
95
+ // Chown files to SUDO_USER (the human who ran sudo).
96
+ if (effectiveUid === 0 && process.env.SUDO_USER) {
97
+ const uid = parseInt(execSync(`id -u ${process.env.SUDO_USER}`, { encoding: 'utf8' }).trim(), 10);
98
+ const gid = parseInt(execSync(`id -g ${process.env.SUDO_USER}`, { encoding: 'utf8' }).trim(), 10);
99
+ return { uid, gid, user: process.env.SUDO_USER };
100
+ }
101
+
102
+ // If we're NOT root but SUDO_USER is set, we were invoked via `sudo -u target`.
103
+ // The effective UID is already the target user. Chown to that user.
104
+ if (effectiveUid !== 0 && process.env.SUDO_USER) {
105
+ const entry = execSync(`getent passwd ${effectiveUid}`, { encoding: 'utf8' }).trim();
106
+ const fields = entry.split(':');
107
+ if (fields.length >= 4) {
108
+ const username = fields[0];
109
+ const uid = parseInt(fields[2], 10);
110
+ const gid = parseInt(fields[3], 10);
111
+ return { uid, gid, user: username };
112
+ }
113
+ }
49
114
  } catch {
50
- return null;
115
+ // Fall through
51
116
  }
117
+
118
+ return null;
52
119
  }
53
120
 
54
121
  /**
@@ -77,33 +144,71 @@ const PLEXOR_LIB_DIR = path.join(HOME_DIR, '.claude', 'plugins', 'plexor', 'lib'
77
144
  const PLEXOR_CONFIG_DIR = path.join(HOME_DIR, '.plexor');
78
145
  const PLEXOR_CONFIG_FILE = path.join(PLEXOR_CONFIG_DIR, 'config.json');
79
146
 
147
+ /**
148
+ * Check if a base URL is a Plexor-managed gateway URL.
149
+ * Detects all variants: production, staging, localhost, tunnels.
150
+ */
151
+ function isManagedGatewayUrl(baseUrl) {
152
+ if (!baseUrl) return false;
153
+ return (
154
+ baseUrl.includes('plexor') ||
155
+ baseUrl.includes('staging.api') ||
156
+ baseUrl.includes('localhost') ||
157
+ baseUrl.includes('127.0.0.1') ||
158
+ baseUrl.includes('ngrok') ||
159
+ baseUrl.includes('localtunnel')
160
+ );
161
+ }
162
+
163
+ /**
164
+ * The expected base URL for THIS plugin variant.
165
+ * Used to detect when a different variant was previously installed.
166
+ */
167
+ const THIS_VARIANT_URL = 'https://staging.api.plexor.dev/gateway/anthropic';
168
+
80
169
  /**
81
170
  * Check for orphaned Plexor routing in settings.json without valid config.
82
- * This can happen if a previous uninstall was incomplete.
171
+ * Also detects variant mismatch (e.g., localhost plugin was installed, now
172
+ * installing staging plugin) and migrates ANTHROPIC_BASE_URL + fixes
173
+ * ANTHROPIC_API_KEY → ANTHROPIC_AUTH_TOKEN (#2174).
83
174
  */
84
175
  function checkOrphanedRouting() {
85
- const home = process.env.HOME || process.env.USERPROFILE;
86
- if (!home) return;
87
-
88
- const settingsPath = path.join(home, '.claude', 'settings.json');
89
- const configPath = path.join(home, '.plexor', 'config.json');
176
+ // Use the resolved HOME_DIR (not process.env.HOME which may be wrong under sudo -u)
177
+ const settingsPath = path.join(HOME_DIR, '.claude', 'settings.json');
178
+ const configPath = path.join(HOME_DIR, '.plexor', 'config.json');
90
179
 
91
180
  try {
92
181
  if (!fs.existsSync(settingsPath)) return;
93
182
 
94
183
  const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
95
184
  const env = settings.env || {};
185
+ let settingsChanged = false;
96
186
 
97
- const hasPlexorUrl = env.ANTHROPIC_BASE_URL &&
98
- env.ANTHROPIC_BASE_URL.includes('plexor');
187
+ const hasPlexorUrl = isManagedGatewayUrl(env.ANTHROPIC_BASE_URL);
99
188
 
100
189
  if (hasPlexorUrl) {
190
+ // Fix #2174: Only migrate ANTHROPIC_API_KEY → ANTHROPIC_AUTH_TOKEN
191
+ // when routing through a Plexor-managed URL. Non-Plexor setups
192
+ // (direct Anthropic, etc.) should not have their auth mutated.
193
+ if (env.ANTHROPIC_API_KEY && !env.ANTHROPIC_AUTH_TOKEN) {
194
+ env.ANTHROPIC_AUTH_TOKEN = env.ANTHROPIC_API_KEY;
195
+ delete env.ANTHROPIC_API_KEY;
196
+ settings.env = env;
197
+ settingsChanged = true;
198
+ console.log('\n Migrated ANTHROPIC_API_KEY → ANTHROPIC_AUTH_TOKEN (fix #2174)');
199
+ } else if (env.ANTHROPIC_API_KEY && env.ANTHROPIC_AUTH_TOKEN) {
200
+ // Both exist — remove the lower-precedence one to avoid confusion
201
+ delete env.ANTHROPIC_API_KEY;
202
+ settings.env = env;
203
+ settingsChanged = true;
204
+ console.log('\n Removed redundant ANTHROPIC_API_KEY (ANTHROPIC_AUTH_TOKEN takes precedence)');
205
+ }
101
206
  // Check if there's a valid Plexor config
102
207
  let hasValidConfig = false;
103
208
  try {
104
209
  if (fs.existsSync(configPath)) {
105
210
  const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
106
- hasValidConfig = config.apiKey && config.apiKey.startsWith('plx_');
211
+ hasValidConfig = (config.auth?.api_key || config.apiKey || '').startsWith('plx_');
107
212
  }
108
213
  } catch (e) {}
109
214
 
@@ -113,10 +218,31 @@ function checkOrphanedRouting() {
113
218
  console.log(' Run /plexor-login to reconfigure, or');
114
219
  console.log(' Run /plexor-uninstall to clean up\n');
115
220
  } else {
116
- console.log('\n Existing Plexor configuration detected');
117
- console.log(' Your previous settings have been preserved.\n');
221
+ // Fix #2176: Detect variant mismatch and migrate URL
222
+ const currentUrl = env.ANTHROPIC_BASE_URL;
223
+ if (currentUrl !== THIS_VARIANT_URL) {
224
+ env.ANTHROPIC_BASE_URL = THIS_VARIANT_URL;
225
+ settings.env = env;
226
+ settingsChanged = true;
227
+ console.log(`\n Migrated ANTHROPIC_BASE_URL to this variant's gateway:`);
228
+ console.log(` Old: ${currentUrl}`);
229
+ console.log(` New: ${THIS_VARIANT_URL}\n`);
230
+ } else {
231
+ console.log('\n Existing Plexor configuration detected');
232
+ console.log(' Your previous settings have been preserved.\n');
233
+ }
118
234
  }
119
235
  }
236
+
237
+ // Write back settings if any migration was applied
238
+ if (settingsChanged) {
239
+ const crypto = require('crypto');
240
+ const claudeDir = path.join(HOME_DIR, '.claude');
241
+ const tempId = crypto.randomBytes(8).toString('hex');
242
+ const tempPath = path.join(claudeDir, `.settings.${tempId}.tmp`);
243
+ fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2), { mode: 0o600 });
244
+ fs.renameSync(tempPath, settingsPath);
245
+ }
120
246
  } catch (e) {
121
247
  // Ignore errors in detection - don't break install
122
248
  }
@@ -295,13 +421,13 @@ function main() {
295
421
  console.log('');
296
422
  console.log(' For Claude MAX users (OAuth):');
297
423
  console.log('');
298
- console.log(` echo 'export ANTHROPIC_BASE_URL="https://api.plexor.dev/gateway/anthropic"' >> ${shellRc}`);
424
+ console.log(` echo 'export ANTHROPIC_BASE_URL="https://staging.api.plexor.dev/gateway/anthropic"' >> ${shellRc}`);
299
425
  console.log(` source ${shellRc}`);
300
426
  console.log('');
301
427
  console.log(' For API key users (get key at https://plexor.dev/dashboard):');
302
428
  console.log('');
303
- console.log(` echo 'export ANTHROPIC_BASE_URL="https://api.plexor.dev/gateway/anthropic"' >> ${shellRc}`);
304
- console.log(` echo 'export ANTHROPIC_API_KEY="plx_your_key_here"' >> ${shellRc}`);
429
+ console.log(` echo 'export ANTHROPIC_BASE_URL="https://staging.api.plexor.dev/gateway/anthropic"' >> ${shellRc}`);
430
+ console.log(` echo 'export ANTHROPIC_AUTH_TOKEN="plx_your_key_here"' >> ${shellRc}`);
305
431
  console.log(` source ${shellRc}`);
306
432
  console.log('');
307
433
  console.log(' ┌─────────────────────────────────────────────────────────────────┐');
@@ -15,11 +15,34 @@
15
15
 
16
16
  const fs = require('fs');
17
17
  const path = require('path');
18
+ const os = require('os');
19
+ const { execSync } = require('child_process');
18
20
 
19
- // Get home directory - support both Unix and Windows
20
- const home = process.env.HOME || process.env.USERPROFILE;
21
+ /**
22
+ * Get the correct home directory for the process's effective user.
23
+ * Resolves via /etc/passwd to handle sudo and sudo -u correctly.
24
+ */
25
+ function getHomeDir() {
26
+ if (os.platform() !== 'win32') {
27
+ try {
28
+ const uid = process.getuid();
29
+ const entry = execSync(`getent passwd ${uid}`, { encoding: 'utf8' }).trim();
30
+ const fields = entry.split(':');
31
+ if (fields.length >= 6 && fields[5]) {
32
+ return fields[5];
33
+ }
34
+ } catch {
35
+ // Fall through
36
+ }
37
+ }
38
+ const h = os.homedir();
39
+ if (h) return h;
40
+ return process.env.HOME || process.env.USERPROFILE || null;
41
+ }
42
+
43
+ const home = getHomeDir();
21
44
  if (!home) {
22
- console.log('Warning: HOME not set, skipping cleanup');
45
+ console.log('Warning: Could not determine home directory, skipping cleanup');
23
46
  process.exit(0);
24
47
  }
25
48