@plexor-dev/claude-code-plugin-staging 0.1.0-beta.15 → 0.1.0-beta.17

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.
@@ -2,10 +2,35 @@
2
2
  description: Set Plexor agent autonomy — supervised, autonomous, or danger-full-auto (user)
3
3
  ---
4
4
 
5
- **RULE: Execute the bash command below EXACTLY ONCE. After the Bash tool returns output, your ONLY action is to present that output to the user. DO NOT re-execute the command. DO NOT call any other tools.**
5
+ **Step 1: Check if arguments were provided.**
6
6
 
7
- Show current agent mode (no args) or set it (supervised, autonomous, danger-full-auto).
7
+ If `$ARGUMENTS` contains a value (e.g., `/plexor-agent autonomous`), apply it directly:
8
8
 
9
9
  ```bash
10
10
  node ~/.claude/plugins/plexor/commands/plexor-agent.js $ARGUMENTS
11
11
  ```
12
+
13
+ Present the output to the user and STOP. Do NOT call any other tools.
14
+
15
+ **Step 2: If NO arguments were provided** (just `/plexor-agent`), first show the current state:
16
+
17
+ ```bash
18
+ node ~/.claude/plugins/plexor/commands/plexor-agent.js
19
+ ```
20
+
21
+ Then immediately use **AskUserQuestion** to let the user pick a mode:
22
+
23
+ - **Question**: "Which agent autonomy mode?"
24
+ - **Header**: "Agent mode"
25
+ - **Options** (exactly 3):
26
+ 1. **supervised** — "Confirm before every action"
27
+ 2. **autonomous (Recommended)** — "Acts within safe bounds, default mode"
28
+ 3. **danger-full-auto** — "Full autonomy, no guardrails"
29
+
30
+ **Step 3: After the user selects**, apply their choice:
31
+
32
+ ```bash
33
+ node ~/.claude/plugins/plexor/commands/plexor-agent.js <selected_value>
34
+ ```
35
+
36
+ Present the output and STOP. Do NOT re-execute commands or call other tools.
@@ -74,9 +74,16 @@ function main() {
74
74
 
75
75
  config.settings = config.settings || {};
76
76
  config.settings.preferred_provider = requested;
77
+ if (requested !== 'auto') {
78
+ delete config.settings.preferred_model;
79
+ delete config.settings.preferredModel;
80
+ }
77
81
  if (!saveConfig(config)) { process.exit(1); }
78
82
 
79
83
  console.log(`Provider: ${current.value} → ${requested}`);
84
+ if (requested !== 'auto') {
85
+ console.log(' Cleared preferred_model to keep provider/model force hints mutually exclusive.');
86
+ }
80
87
  console.log(` ${DESC[requested]}`);
81
88
  console.log(' Takes effect on next request.');
82
89
  }
@@ -2,10 +2,36 @@
2
2
  description: Set Plexor LLM provider — auto, anthropic, openai, deepseek, gemini, etc. (user)
3
3
  ---
4
4
 
5
- **RULE: Execute the bash command below EXACTLY ONCE. After the Bash tool returns output, your ONLY action is to present that output to the user. DO NOT re-execute the command. DO NOT call any other tools.**
5
+ **Step 1: Check if arguments were provided.**
6
6
 
7
- Show current provider (no args) or set it (auto, anthropic, openai, deepseek, mistral, gemini, grok, cohere).
7
+ If `$ARGUMENTS` contains a value (e.g., `/plexor-provider anthropic`), apply it directly:
8
8
 
9
9
  ```bash
10
10
  node ~/.claude/plugins/plexor/commands/plexor-provider.js $ARGUMENTS
11
11
  ```
12
+
13
+ Present the output to the user and STOP. Do NOT call any other tools.
14
+
15
+ **Step 2: If NO arguments were provided** (just `/plexor-provider`), first show the current state:
16
+
17
+ ```bash
18
+ node ~/.claude/plugins/plexor/commands/plexor-provider.js
19
+ ```
20
+
21
+ Then immediately use **AskUserQuestion** to let the user pick a provider:
22
+
23
+ - **Question**: "Which LLM provider?"
24
+ - **Header**: "Provider"
25
+ - **Options** (exactly 4 — "Other" auto-covers mistral, gemini, grok, cohere):
26
+ 1. **auto (Recommended)** — "Let Plexor choose the best provider automatically"
27
+ 2. **anthropic** — "Claude models (Opus, Sonnet, Haiku)"
28
+ 3. **openai** — "GPT models (GPT-4o, GPT-4.1, o1)"
29
+ 4. **deepseek** — "DeepSeek models (Chat, Reasoner)"
30
+
31
+ **Step 3: After the user selects**, apply their choice. If the user picks "Other", they will type the provider name (mistral, gemini, grok, cohere):
32
+
33
+ ```bash
34
+ node ~/.claude/plugins/plexor/commands/plexor-provider.js <selected_value>
35
+ ```
36
+
37
+ Present the output and STOP. Do NOT re-execute commands or call other tools.
@@ -2,10 +2,36 @@
2
2
  description: Set Plexor routing mode — eco, balanced, quality, or passthrough (user)
3
3
  ---
4
4
 
5
- **RULE: Execute the bash command below EXACTLY ONCE. After the Bash tool returns output, your ONLY action is to present that output to the user. DO NOT re-execute the command. DO NOT call any other tools.**
5
+ **Step 1: Check if arguments were provided.**
6
6
 
7
- Show current routing mode (no args) or set it (eco, balanced, quality, passthrough).
7
+ If `$ARGUMENTS` contains a value (e.g., `/plexor-routing eco`), apply it directly:
8
8
 
9
9
  ```bash
10
10
  node ~/.claude/plugins/plexor/commands/plexor-routing.js $ARGUMENTS
11
11
  ```
12
+
13
+ Present the output to the user and STOP. Do NOT call any other tools.
14
+
15
+ **Step 2: If NO arguments were provided** (just `/plexor-routing`), first show the current state:
16
+
17
+ ```bash
18
+ node ~/.claude/plugins/plexor/commands/plexor-routing.js
19
+ ```
20
+
21
+ Then immediately use **AskUserQuestion** to let the user pick a mode:
22
+
23
+ - **Question**: "Which routing mode?"
24
+ - **Header**: "Routing"
25
+ - **Options** (exactly 4):
26
+ 1. **eco (Recommended)** — "Cheapest models first, best cost efficiency"
27
+ 2. **balanced** — "Balance cost and quality, smart model selection"
28
+ 3. **quality** — "Premium models, best output quality"
29
+ 4. **passthrough** — "Direct provider access, no routing logic"
30
+
31
+ **Step 3: After the user selects**, apply their choice:
32
+
33
+ ```bash
34
+ node ~/.claude/plugins/plexor/commands/plexor-routing.js <selected_value>
35
+ ```
36
+
37
+ Present the output and STOP. Do NOT re-execute commands or call other tools.
@@ -127,6 +127,10 @@ function setDimension(dimension, value, config) {
127
127
 
128
128
  config.settings = config.settings || {};
129
129
  config.settings[configKey] = resolved;
130
+ if (configKey === 'preferred_provider' && resolved !== 'auto') {
131
+ delete config.settings.preferred_model;
132
+ delete config.settings.preferredModel;
133
+ }
130
134
  if (configKeyAlt && config.settings[configKeyAlt]) {
131
135
  delete config.settings[configKeyAlt];
132
136
  }
@@ -134,6 +138,9 @@ function setDimension(dimension, value, config) {
134
138
  if (!saveConfig(config)) { process.exit(1); }
135
139
 
136
140
  console.log(`${label} updated: ${current.value} → ${resolved}`);
141
+ if (configKey === 'preferred_provider' && resolved !== 'auto') {
142
+ console.log(' Cleared preferred_model to keep provider/model force hints mutually exclusive.');
143
+ }
137
144
  console.log(` ${descMap[resolved]}`);
138
145
  console.log(` Takes effect on next request.`);
139
146
  }
@@ -2,14 +2,51 @@
2
2
  description: View and change Plexor settings — routing mode, agent autonomy, LLM provider (user)
3
3
  ---
4
4
 
5
- **RULE: Execute the bash command below EXACTLY ONCE. After the Bash tool returns output, your ONLY action is to present that output to the user. DO NOT re-execute the command. DO NOT call any other tools.**
5
+ **Step 1: Check if arguments were provided.**
6
6
 
7
- Show all settings (no args), or set a specific dimension:
8
- - `/plexor-settings` — dashboard
9
- - `/plexor-settings routing eco` — set routing mode
10
- - `/plexor-settings agent danger-full-auto` — set agent autonomy
11
- - `/plexor-settings provider anthropic` — set LLM provider
7
+ If `$ARGUMENTS` contains both a dimension AND a value (e.g., `/plexor-settings routing eco`), apply it directly:
12
8
 
13
9
  ```bash
14
10
  node ~/.claude/plugins/plexor/commands/plexor-settings.js $ARGUMENTS
15
11
  ```
12
+
13
+ Present the output to the user and STOP. Do NOT call any other tools.
14
+
15
+ **Step 2: If only a dimension was provided** (e.g., `/plexor-settings routing`), show current state then present a picker for that dimension:
16
+
17
+ ```bash
18
+ node ~/.claude/plugins/plexor/commands/plexor-settings.js $ARGUMENTS
19
+ ```
20
+
21
+ Then use **AskUserQuestion** based on which dimension:
22
+
23
+ **If dimension is "routing":**
24
+ - **Question**: "Which routing mode?"
25
+ - **Header**: "Routing"
26
+ - **Options**: eco (Recommended) — "Cheapest first", balanced — "Cost/quality balance", quality — "Premium models", passthrough — "Direct access"
27
+
28
+ **If dimension is "agent":**
29
+ - **Question**: "Which agent autonomy mode?"
30
+ - **Header**: "Agent mode"
31
+ - **Options**: supervised — "Confirm every action", autonomous (Recommended) — "Safe bounds", danger-full-auto — "No guardrails"
32
+
33
+ **If dimension is "provider":**
34
+ - **Question**: "Which LLM provider?"
35
+ - **Header**: "Provider"
36
+ - **Options**: auto (Recommended) — "Auto-select", anthropic — "Claude models", openai — "GPT models", deepseek — "DeepSeek models"
37
+
38
+ After the user selects, apply:
39
+
40
+ ```bash
41
+ node ~/.claude/plugins/plexor/commands/plexor-settings.js <dimension> <selected_value>
42
+ ```
43
+
44
+ Present the output and STOP.
45
+
46
+ **Step 3: If NO arguments were provided** (just `/plexor-settings`), show the dashboard:
47
+
48
+ ```bash
49
+ node ~/.claude/plugins/plexor/commands/plexor-settings.js
50
+ ```
51
+
52
+ Present the output to the user and STOP. Do NOT call any other tools.
@@ -54,6 +54,8 @@ const VALID_PROVIDER_HINTS = new Set([
54
54
  'cohere'
55
55
  ]);
56
56
 
57
+ const DISABLED_MODEL_HINTS = new Set(['auto', 'none', 'off']);
58
+
57
59
  function normalizeOrchestrationMode(mode) {
58
60
  if (typeof mode !== 'string') {
59
61
  return null;
@@ -62,8 +64,6 @@ function normalizeOrchestrationMode(mode) {
62
64
  if (!VALID_ORCHESTRATION_MODES.has(normalized)) {
63
65
  return null;
64
66
  }
65
- // Accept legacy 'full-auto' as alias for 'danger-full-auto'
66
- if (normalized === 'full-auto') return 'danger-full-auto';
67
67
  return normalized;
68
68
  }
69
69
 
@@ -122,6 +122,42 @@ function resolvePreferredProvider(settings) {
122
122
  return cfgProvider || 'auto';
123
123
  }
124
124
 
125
+ function normalizePreferredModel(model) {
126
+ if (typeof model !== 'string') {
127
+ return null;
128
+ }
129
+ const trimmed = model.trim();
130
+ if (!trimmed) {
131
+ return null;
132
+ }
133
+ if (DISABLED_MODEL_HINTS.has(trimmed.toLowerCase())) {
134
+ return null;
135
+ }
136
+ return trimmed;
137
+ }
138
+
139
+ function resolvePreferredModel(settings) {
140
+ const envModel = normalizePreferredModel(
141
+ process.env.PLEXOR_MODEL || process.env.PLEXOR_PREFERRED_MODEL
142
+ );
143
+ if (envModel) {
144
+ return envModel;
145
+ }
146
+ const cfgModel = normalizePreferredModel(settings?.preferredModel || settings?.preferred_model);
147
+ return cfgModel || null;
148
+ }
149
+
150
+ function validateForceHintSelection(preferredProvider, preferredModel) {
151
+ if (!preferredModel || preferredProvider === 'auto') {
152
+ return;
153
+ }
154
+ const error = new Error(
155
+ 'Invalid Plexor config: force only one hint. Set preferred_provider OR preferred_model, not both.'
156
+ );
157
+ error.code = 'PLEXOR_CONFIG_CONFLICT';
158
+ throw error;
159
+ }
160
+
125
161
  // Try to load lib modules, fall back to inline implementations
126
162
  let ConfigManager, SessionManager, LocalCache, Logger, PlexorClient;
127
163
  let config, session, cache, logger;
@@ -143,10 +179,25 @@ try {
143
179
  const SESSION_PATH = path.join(process.env.HOME || '', '.plexor', 'session.json');
144
180
  const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
145
181
 
182
+ const uxMessagesEnabled = !/^(0|false|no|off)$/i.test(
183
+ String(process.env.PLEXOR_UX_MESSAGES ?? process.env.PLEXOR_UX_DEBUG_MESSAGES ?? 'true')
184
+ );
185
+ const CYAN = '\x1b[36m';
186
+ const RESET = '\x1b[0m';
187
+ const formatUx = (msg) => {
188
+ const text = String(msg || '').trim();
189
+ if (!text) return '[PLEXOR: message]';
190
+ return text.startsWith('[PLEXOR:') ? text : `[PLEXOR: ${text}]`;
191
+ };
192
+
146
193
  logger = {
147
194
  debug: (msg) => process.env.PLEXOR_DEBUG && console.error(`[DEBUG] ${msg}`),
148
195
  info: (msg) => console.error(msg),
149
- error: (msg) => console.error(`[ERROR] ${msg}`)
196
+ error: (msg) => console.error(`[ERROR] ${msg}`),
197
+ ux: (msg) => {
198
+ if (!uxMessagesEnabled) return;
199
+ console.error(`${CYAN}${formatUx(msg)}${RESET}`);
200
+ }
150
201
  };
151
202
 
152
203
  config = {
@@ -162,6 +213,7 @@ try {
162
213
  localCacheEnabled: cfg.settings?.localCacheEnabled ?? false,
163
214
  mode: cfg.settings?.mode || 'balanced',
164
215
  preferredProvider: cfg.settings?.preferred_provider || 'auto',
216
+ preferredModel: cfg.settings?.preferredModel || cfg.settings?.preferred_model || null,
165
217
  orchestrationMode:
166
218
  cfg.settings?.orchestrationMode || cfg.settings?.orchestration_mode || 'autonomous'
167
219
  };
@@ -277,6 +329,7 @@ async function main() {
277
329
  // not API requests and should not pollute session analytics
278
330
  if (isSlashCommand(request)) {
279
331
  logger.debug('Slash command detected, clean passthrough (no metadata)');
332
+ logger.ux('Slash command passthrough (no optimization applied)');
280
333
  return output(request); // Completely clean — no metadata added
281
334
  }
282
335
 
@@ -284,6 +337,8 @@ async function main() {
284
337
  const orchestrationMode = resolveOrchestrationMode(settings);
285
338
  const gatewayMode = resolveGatewayMode(settings);
286
339
  const preferredProvider = resolvePreferredProvider(settings);
340
+ const preferredModel = resolvePreferredModel(settings);
341
+ validateForceHintSelection(preferredProvider, preferredModel);
287
342
 
288
343
  // Phase 3 Hypervisor Mode Detection
289
344
  // When ANTHROPIC_BASE_URL points to Plexor, all intelligence is server-side
@@ -295,10 +350,14 @@ async function main() {
295
350
  // HYPERVISOR MODE: Server handles everything
296
351
  // Just pass through with minimal metadata for session tracking
297
352
  logger.debug('Hypervisor mode active - server handles all optimization');
353
+ logger.ux(
354
+ `Hypervisor mode active (${gatewayMode}/${orchestrationMode}); routing handled server-side`
355
+ );
298
356
 
299
357
  // Add session tracking metadata (server will use this for analytics)
300
358
  return output({
301
359
  ...request,
360
+ ...(preferredModel ? { model: preferredModel } : {}),
302
361
  plexor_mode: gatewayMode,
303
362
  ...(preferredProvider !== 'auto' ? { plexor_provider: preferredProvider } : {}),
304
363
  plexor_orchestration_mode: orchestrationMode,
@@ -309,6 +368,7 @@ async function main() {
309
368
  orchestration_mode: orchestrationMode,
310
369
  plexor_mode: gatewayMode,
311
370
  preferred_provider: preferredProvider,
371
+ preferred_model: preferredModel,
312
372
  cwd: process.cwd(),
313
373
  timestamp: Date.now(),
314
374
  }
@@ -319,6 +379,7 @@ async function main() {
319
379
  // Azure CLI, AWS CLI, kubectl, etc. need tools to be preserved
320
380
  if (requiresToolExecution(request)) {
321
381
  logger.debug('CLI tool execution detected, passing through unchanged');
382
+ logger.ux('Tool-execution request detected; preserving tools via passthrough');
322
383
  session.recordPassthrough();
323
384
  return output({
324
385
  ...request,
@@ -337,6 +398,7 @@ async function main() {
337
398
  // Modifying messages breaks the agent loop and causes infinite loops
338
399
  if (isAgenticRequest(request)) {
339
400
  logger.debug('Agentic request detected, passing through unchanged');
401
+ logger.ux('Agentic/tool-use request detected; passthrough to avoid loop breakage');
340
402
  session.recordPassthrough();
341
403
  return output({
342
404
  ...request,
@@ -353,6 +415,7 @@ async function main() {
353
415
 
354
416
  if (!settings.enabled) {
355
417
  logger.debug('Plexor disabled, passing through');
418
+ logger.ux('Plexor plugin is disabled; forwarding request unchanged');
356
419
  session.recordPassthrough();
357
420
  return output({
358
421
  ...request,
@@ -367,6 +430,7 @@ async function main() {
367
430
 
368
431
  if (!settings.apiKey) {
369
432
  logger.info('Not authenticated. Run /plexor-login to enable optimization.');
433
+ logger.ux('Not authenticated. Run /plexor-login to enable Plexor routing');
370
434
  session.recordPassthrough();
371
435
  return output({
372
436
  ...request,
@@ -394,6 +458,7 @@ async function main() {
394
458
 
395
459
  if (cachedResponse && settings.localCacheEnabled) {
396
460
  logger.info('Local cache hit');
461
+ logger.ux('Local cache hit');
397
462
  session.recordCacheHit();
398
463
  return output({
399
464
  ...request,
@@ -422,9 +487,15 @@ async function main() {
422
487
  const savingsPercent = ((result.original_tokens - result.optimized_tokens) / result.original_tokens * 100).toFixed(1);
423
488
 
424
489
  logger.info(`Optimized: ${result.original_tokens} → ${result.optimized_tokens} tokens (${savingsPercent}% saved)`);
490
+ logger.ux(
491
+ `Optimized ${result.original_tokens} -> ${result.optimized_tokens} tokens (${savingsPercent}% saved)`
492
+ );
425
493
 
426
494
  if (result.recommended_provider !== 'anthropic') {
427
495
  logger.info(`Recommended: ${result.recommended_provider} (~$${result.estimated_cost.toFixed(4)})`);
496
+ logger.ux(
497
+ `Provider recommendation: ${result.recommended_provider} (~$${result.estimated_cost.toFixed(4)})`
498
+ );
428
499
  }
429
500
 
430
501
  const optimizedRequest = {
@@ -460,7 +531,15 @@ async function main() {
460
531
  return output(optimizedRequest);
461
532
 
462
533
  } catch (error) {
534
+ if (error?.code === 'PLEXOR_CONFIG_CONFLICT') {
535
+ logger.error(`Configuration error: ${error.message}`);
536
+ logger.ux(error.message);
537
+ process.stderr.write(`\n[Plexor] ${error.message}\n`);
538
+ process.exit(1);
539
+ }
540
+
463
541
  logger.error(`Error: ${error.message}`);
542
+ logger.ux(`Optimization hook error: ${error.message}`);
464
543
  logger.debug(error.stack);
465
544
 
466
545
  const errorRequestId = generateRequestId('error'); // Issue #701: Add request_id for tracking
@@ -133,6 +133,17 @@ try {
133
133
  async getMetadata() { return null; }
134
134
  };
135
135
 
136
+ const uxMessagesEnabled = !/^(0|false|no|off)$/i.test(
137
+ String(process.env.PLEXOR_UX_MESSAGES ?? process.env.PLEXOR_UX_DEBUG_MESSAGES ?? 'true')
138
+ );
139
+ const CYAN = '\x1b[36m';
140
+ const RESET = '\x1b[0m';
141
+ const formatUx = (msg) => {
142
+ const text = String(msg || '').trim();
143
+ if (!text) return '[PLEXOR: message]';
144
+ return text.startsWith('[PLEXOR:') ? text : `[PLEXOR: ${text}]`;
145
+ };
146
+
136
147
  Logger = class {
137
148
  constructor(name) { this.name = name; }
138
149
  info(msg, data) {
@@ -143,6 +154,10 @@ try {
143
154
  }
144
155
  }
145
156
  error(msg) { console.error(`[${this.name}] [ERROR] ${msg}`); }
157
+ ux(msg) {
158
+ if (!uxMessagesEnabled) return;
159
+ console.error(`${CYAN}${formatUx(msg)}${RESET}`);
160
+ }
146
161
  debug(msg) {
147
162
  if (process.env.PLEXOR_DEBUG) {
148
163
  console.error(`[${this.name}] [DEBUG] ${msg}`);
@@ -194,11 +209,17 @@ async function main() {
194
209
  input = await readStdin();
195
210
  response = JSON.parse(input);
196
211
 
212
+ // Normalize known Plexor recovery text into explicit [PLEXOR: ...] format
213
+ // and mirror high-signal UX hints to stderr in cyan.
214
+ response = normalizePlexorResponseMessages(response);
215
+ emitPlexorUxMessages(response);
216
+
197
217
  // Calculate output tokens for ALL responses (Issue #701)
198
218
  const outputTokens = estimateOutputTokens(response);
199
219
 
200
220
  // Get Plexor metadata if present
201
221
  const plexorMeta = response._plexor;
222
+ emitPlexorOutcomeSummary(response, plexorMeta, outputTokens);
202
223
 
203
224
  // Issue #701: Track ALL responses, not just when enabled
204
225
  // This ensures session stats are always accurate
@@ -374,3 +395,282 @@ main().catch((error) => {
374
395
  console.error(`[Plexor] Fatal error: ${error.message}`);
375
396
  process.exit(1);
376
397
  });
398
+
399
+ function normalizePlexorText(text) {
400
+ if (typeof text !== 'string' || !text) {
401
+ return text;
402
+ }
403
+
404
+ let normalized = text;
405
+
406
+ if (
407
+ normalized.includes("I've been repeating the same operation without making progress.") &&
408
+ !normalized.includes("[PLEXOR: I've been repeating the same operation without making progress.]")
409
+ ) {
410
+ normalized = normalized.replace(
411
+ "I've been repeating the same operation without making progress.",
412
+ "[PLEXOR: I've been repeating the same operation without making progress.]"
413
+ );
414
+ }
415
+
416
+ normalized = normalized.replace(
417
+ /\[The repeated operation has been blocked\.[^\]]*\]/g,
418
+ (match) => `[PLEXOR: ${match.slice(1, -1)}]`
419
+ );
420
+ normalized = normalized.replace(
421
+ /\[All tasks have been completed\. Session ending\.\]/g,
422
+ '[PLEXOR: All tasks have been completed. Session ending.]'
423
+ );
424
+ normalized = normalized.replace(
425
+ /\[Blocked destructive cleanup command while recovering from prior errors\.[^\]]*\]/g,
426
+ (match) => `[PLEXOR: ${match.slice(1, -1)}]`
427
+ );
428
+
429
+ return normalized;
430
+ }
431
+
432
+ function normalizePlexorResponseMessages(response) {
433
+ if (!response || typeof response !== 'object') {
434
+ return response;
435
+ }
436
+
437
+ // Anthropic format
438
+ if (Array.isArray(response.content)) {
439
+ response.content = response.content.map((block) => {
440
+ if (!block || block.type !== 'text' || typeof block.text !== 'string') {
441
+ return block;
442
+ }
443
+ return { ...block, text: normalizePlexorText(block.text) };
444
+ });
445
+ return response;
446
+ }
447
+
448
+ // String content format
449
+ if (typeof response.content === 'string') {
450
+ response.content = normalizePlexorText(response.content);
451
+ return response;
452
+ }
453
+
454
+ // OpenAI choices format
455
+ if (Array.isArray(response.choices)) {
456
+ response.choices = response.choices.map((choice) => {
457
+ if (!choice || typeof choice !== 'object') return choice;
458
+ const updated = { ...choice };
459
+ if (typeof updated.text === 'string') {
460
+ updated.text = normalizePlexorText(updated.text);
461
+ }
462
+ if (updated.message && typeof updated.message.content === 'string') {
463
+ updated.message = {
464
+ ...updated.message,
465
+ content: normalizePlexorText(updated.message.content)
466
+ };
467
+ }
468
+ return updated;
469
+ });
470
+ }
471
+
472
+ return response;
473
+ }
474
+
475
+ function collectResponseText(response) {
476
+ const texts = [];
477
+ if (!response || typeof response !== 'object') {
478
+ return texts;
479
+ }
480
+
481
+ if (Array.isArray(response.content)) {
482
+ for (const block of response.content) {
483
+ if (block?.type === 'text' && typeof block.text === 'string') {
484
+ texts.push(block.text);
485
+ }
486
+ }
487
+ } else if (typeof response.content === 'string') {
488
+ texts.push(response.content);
489
+ }
490
+
491
+ if (Array.isArray(response.choices)) {
492
+ for (const choice of response.choices) {
493
+ if (typeof choice?.text === 'string') {
494
+ texts.push(choice.text);
495
+ }
496
+ if (typeof choice?.message?.content === 'string') {
497
+ texts.push(choice.message.content);
498
+ }
499
+ }
500
+ }
501
+
502
+ return texts;
503
+ }
504
+
505
+ function emitPlexorUxMessages(response) {
506
+ if (!logger || typeof logger.ux !== 'function') {
507
+ return;
508
+ }
509
+
510
+ const lines = collectResponseText(response).join('\n');
511
+ if (!lines) {
512
+ return;
513
+ }
514
+
515
+ const signals = new Set();
516
+
517
+ if (lines.includes("I've been repeating the same operation without making progress.")) {
518
+ signals.add("[PLEXOR: I've been repeating the same operation without making progress.]");
519
+ }
520
+ if (/repeated operation has been blocked/i.test(lines)) {
521
+ signals.add('[PLEXOR: The repeated operation has been blocked; switching approach.]');
522
+ }
523
+ if (/circuit breaker has fired/i.test(lines)) {
524
+ signals.add('[PLEXOR: Circuit breaker fired; recovery guidance active.]');
525
+ }
526
+ if (/blocked destructive cleanup command/i.test(lines)) {
527
+ signals.add('[PLEXOR: Blocked destructive cleanup command during recovery.]');
528
+ }
529
+ if (/all tasks have been completed\. session ending\./i.test(lines)) {
530
+ signals.add('[PLEXOR: All tasks completed; session ending.]');
531
+ }
532
+
533
+ const explicitPlexorMessages = lines.match(/\[PLEXOR:[^\]]+\]/g) || [];
534
+ for (const explicit of explicitPlexorMessages) {
535
+ signals.add(explicit);
536
+ }
537
+
538
+ for (const signal of signals) {
539
+ logger.ux(signal);
540
+ }
541
+ }
542
+
543
+ function toNumber(value) {
544
+ if (value === null || value === undefined || value === '') {
545
+ return null;
546
+ }
547
+ const parsed = typeof value === 'number' ? value : Number(value);
548
+ return Number.isFinite(parsed) ? parsed : null;
549
+ }
550
+
551
+ function toBoolean(value) {
552
+ if (typeof value === 'boolean') return value;
553
+ if (typeof value === 'string') {
554
+ const normalized = value.trim().toLowerCase();
555
+ if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
556
+ if (['false', '0', 'no', 'off'].includes(normalized)) return false;
557
+ }
558
+ return null;
559
+ }
560
+
561
+ function formatUsd(value) {
562
+ if (value === null || value === undefined || !Number.isFinite(value)) {
563
+ return null;
564
+ }
565
+ return `$${value.toFixed(6)}`;
566
+ }
567
+
568
+ function extractGatewayOutcome(response, plexorMeta, outputTokens) {
569
+ const provider =
570
+ response?.plexor_provider_used ||
571
+ response?.plexor?.provider_used ||
572
+ plexorMeta?.recommended_provider ||
573
+ null;
574
+ const selectedModel =
575
+ response?.plexor_selected_model ||
576
+ response?.plexor?.selected_model ||
577
+ plexorMeta?.recommended_model ||
578
+ null;
579
+ const requestedModel = response?.model || null;
580
+ const routingSource = response?.plexor_routing_source || response?.plexor?.routing_source || null;
581
+ const authTier = response?.plexor_auth_tier || response?.plexor?.auth_tier || null;
582
+ const stopReason = response?.stop_reason || response?.choices?.[0]?.finish_reason || null;
583
+ const agentHalt = toBoolean(response?.plexor_agent_halt);
584
+ const fallbackUsed =
585
+ toBoolean(response?.plexor_fallback_used) ??
586
+ toBoolean(response?.fallback_used) ??
587
+ toBoolean(response?.plexor?.fallback_used);
588
+ const costUsd =
589
+ toNumber(response?.plexor_cost_usd) ??
590
+ toNumber(response?.cost_usd) ??
591
+ toNumber(plexorMeta?.estimated_cost) ??
592
+ null;
593
+ const savingsUsd =
594
+ toNumber(response?.plexor_savings_usd) ??
595
+ toNumber(response?.savings_usd) ??
596
+ null;
597
+ const inputTokens =
598
+ toNumber(response?.usage?.input_tokens) ??
599
+ toNumber(response?.usage?.prompt_tokens) ??
600
+ toNumber(plexorMeta?.optimized_tokens) ??
601
+ null;
602
+ const emittedOutputTokens =
603
+ toNumber(response?.usage?.output_tokens) ??
604
+ toNumber(response?.usage?.completion_tokens) ??
605
+ toNumber(outputTokens);
606
+
607
+ return {
608
+ provider,
609
+ selectedModel,
610
+ requestedModel,
611
+ routingSource,
612
+ authTier,
613
+ stopReason,
614
+ agentHalt,
615
+ fallbackUsed,
616
+ costUsd,
617
+ savingsUsd,
618
+ inputTokens,
619
+ emittedOutputTokens,
620
+ };
621
+ }
622
+
623
+ function emitPlexorOutcomeSummary(response, plexorMeta, outputTokens) {
624
+ if (!logger || typeof logger.ux !== 'function') {
625
+ return;
626
+ }
627
+
628
+ const outcome = extractGatewayOutcome(response, plexorMeta, outputTokens);
629
+ const messages = [];
630
+
631
+ if (outcome.provider || outcome.selectedModel || outcome.requestedModel) {
632
+ const parts = [];
633
+ if (outcome.provider) parts.push(`provider=${outcome.provider}`);
634
+ if (outcome.selectedModel) parts.push(`selected_model=${outcome.selectedModel}`);
635
+ if (!outcome.selectedModel && outcome.requestedModel) {
636
+ parts.push(`requested_model=${outcome.requestedModel}`);
637
+ }
638
+ if (outcome.routingSource) parts.push(`routing=${outcome.routingSource}`);
639
+ if (outcome.authTier) parts.push(`auth_tier=${outcome.authTier}`);
640
+ messages.push(`Routing summary: ${parts.join(' ')}`);
641
+ }
642
+
643
+ if (outcome.stopReason || outcome.agentHalt !== null || outcome.fallbackUsed !== null) {
644
+ const parts = [];
645
+ if (outcome.stopReason) parts.push(`stop_reason=${outcome.stopReason}`);
646
+ if (outcome.agentHalt === true) parts.push('agent_halt=auto_continue');
647
+ if (outcome.fallbackUsed !== null) parts.push(`fallback_used=${outcome.fallbackUsed}`);
648
+ if (parts.length > 0) {
649
+ messages.push(`Execution status: ${parts.join(' ')}`);
650
+ }
651
+ }
652
+
653
+ if (
654
+ outcome.costUsd !== null ||
655
+ outcome.savingsUsd !== null ||
656
+ outcome.inputTokens !== null ||
657
+ outcome.emittedOutputTokens !== null
658
+ ) {
659
+ const parts = [];
660
+ const costStr = formatUsd(outcome.costUsd);
661
+ const savingsStr = formatUsd(outcome.savingsUsd);
662
+ if (costStr) parts.push(`cost=${costStr}`);
663
+ if (savingsStr) parts.push(`savings=${savingsStr}`);
664
+ if (outcome.inputTokens !== null) parts.push(`input_tokens=${Math.round(outcome.inputTokens)}`);
665
+ if (outcome.emittedOutputTokens !== null) {
666
+ parts.push(`output_tokens=${Math.round(outcome.emittedOutputTokens)}`);
667
+ }
668
+ if (parts.length > 0) {
669
+ messages.push(`Usage summary: ${parts.join(' ')}`);
670
+ }
671
+ }
672
+
673
+ for (const msg of messages) {
674
+ logger.ux(msg);
675
+ }
676
+ }
@@ -7,6 +7,50 @@ const path = require('path');
7
7
  const crypto = require('crypto');
8
8
  const { PLEXOR_DIR, CONFIG_PATH } = require('./constants');
9
9
 
10
+ const DISABLED_HINT_VALUES = new Set(['', 'auto', 'none', 'off']);
11
+
12
+ function normalizeForcedProvider(value) {
13
+ if (typeof value !== 'string') {
14
+ return null;
15
+ }
16
+ const normalized = value.trim().toLowerCase();
17
+ if (!normalized || normalized === 'auto') {
18
+ return 'auto';
19
+ }
20
+ return normalized;
21
+ }
22
+
23
+ function normalizeForcedModel(value) {
24
+ if (typeof value !== 'string') {
25
+ return null;
26
+ }
27
+ const normalized = value.trim();
28
+ if (!normalized || DISABLED_HINT_VALUES.has(normalized.toLowerCase())) {
29
+ return null;
30
+ }
31
+ return normalized;
32
+ }
33
+
34
+ function hasForcedHintConflict(config) {
35
+ const settings = config?.settings || {};
36
+ const provider = normalizeForcedProvider(
37
+ settings.preferred_provider ?? settings.preferredProvider ?? 'auto'
38
+ );
39
+ const model = normalizeForcedModel(settings.preferred_model ?? settings.preferredModel);
40
+ return Boolean(model) && provider !== 'auto';
41
+ }
42
+
43
+ function validateForcedHintConfig(config) {
44
+ if (!hasForcedHintConflict(config)) {
45
+ return { ok: true };
46
+ }
47
+ return {
48
+ ok: false,
49
+ message:
50
+ 'Invalid Plexor config: set only one force hint. Use preferred_provider OR preferred_model, not both.'
51
+ };
52
+ }
53
+
10
54
  function loadConfig() {
11
55
  try {
12
56
  if (!fs.existsSync(CONFIG_PATH)) {
@@ -31,6 +75,12 @@ function loadConfig() {
31
75
 
32
76
  function saveConfig(config) {
33
77
  try {
78
+ const validation = validateForcedHintConfig(config);
79
+ if (!validation.ok) {
80
+ console.error(`Error: ${validation.message}`);
81
+ return false;
82
+ }
83
+
34
84
  if (!fs.existsSync(PLEXOR_DIR)) {
35
85
  fs.mkdirSync(PLEXOR_DIR, { recursive: true, mode: 0o700 });
36
86
  }
@@ -71,4 +121,4 @@ function readSetting(config, configKey, configKeyAlt, envVar, validValues, defau
71
121
  return { value: defaultValue, source: 'default' };
72
122
  }
73
123
 
74
- module.exports = { loadConfig, saveConfig, readSetting };
124
+ module.exports = { loadConfig, saveConfig, readSetting, hasForcedHintConflict, validateForcedHintConfig };
package/lib/config.js CHANGED
@@ -5,6 +5,7 @@
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
7
  const { CONFIG_PATH, PLEXOR_DIR, DEFAULT_API_URL, DEFAULT_TIMEOUT } = require('./constants');
8
+ const { validateForcedHintConfig } = require('./config-utils');
8
9
 
9
10
  class ConfigManager {
10
11
  constructor() {
@@ -23,6 +24,7 @@ class ConfigManager {
23
24
  localCacheEnabled: cfg.settings?.localCacheEnabled ?? false,
24
25
  mode: cfg.settings?.mode || 'balanced',
25
26
  preferredProvider: cfg.settings?.preferred_provider || 'auto',
27
+ preferredModel: cfg.settings?.preferredModel || cfg.settings?.preferred_model || null,
26
28
  orchestrationMode:
27
29
  cfg.settings?.orchestrationMode || cfg.settings?.orchestration_mode || 'autonomous'
28
30
  };
@@ -55,6 +57,11 @@ class ConfigManager {
55
57
  localCacheEnabled: config.localCacheEnabled ?? existing.settings?.localCacheEnabled,
56
58
  mode: config.mode ?? existing.settings?.mode,
57
59
  preferred_provider: config.preferredProvider ?? existing.settings?.preferred_provider,
60
+ preferred_model:
61
+ config.preferredModel ??
62
+ config.preferred_model ??
63
+ existing.settings?.preferredModel ??
64
+ existing.settings?.preferred_model,
58
65
  orchestrationMode:
59
66
  config.orchestrationMode ??
60
67
  config.orchestration_mode ??
@@ -63,6 +70,11 @@ class ConfigManager {
63
70
  }
64
71
  };
65
72
 
73
+ const validation = validateForcedHintConfig(updated);
74
+ if (!validation.ok) {
75
+ return false;
76
+ }
77
+
66
78
  fs.writeFileSync(this.configPath, JSON.stringify(updated, null, 2), { mode: 0o600 });
67
79
  return true;
68
80
  } catch {
package/lib/logger.js CHANGED
@@ -12,6 +12,7 @@ const DIM = '\x1b[2m';
12
12
  const WHITE = '\x1b[37m';
13
13
  const YELLOW_FG = '\x1b[33m';
14
14
  const RED_FG = '\x1b[31m';
15
+ const CYAN_FG = '\x1b[36m';
15
16
  const BLUE_BG = '\x1b[44m';
16
17
  const YELLOW_BG = '\x1b[43m';
17
18
  const RED_BG = '\x1b[41m';
@@ -21,10 +22,42 @@ const BADGE_INFO = `${BOLD}${WHITE}${BLUE_BG} PLEXOR ${RESET}`;
21
22
  const BADGE_WARN = `${BOLD}${WHITE}${YELLOW_BG} PLEXOR ${RESET}`;
22
23
  const BADGE_ERROR = `${BOLD}${WHITE}${RED_BG} PLEXOR ${RESET}`;
23
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
+
24
54
  class Logger {
25
55
  constructor(component = 'plexor') {
26
56
  this.component = component;
27
- 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);
28
61
  }
29
62
 
30
63
  debug(msg, data = null) {
@@ -48,6 +81,15 @@ class Logger {
48
81
  const output = data ? `${BADGE_ERROR} ${RED_FG}${msg} ${JSON.stringify(data)}${RESET}` : `${BADGE_ERROR} ${RED_FG}${msg}${RESET}`;
49
82
  console.error(output);
50
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
+ }
51
93
  }
52
94
 
53
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.15",
3
+ "version": "0.1.0-beta.17",
4
4
  "description": "STAGING - LLM cost optimization plugin for Claude Code (internal testing)",
5
5
  "main": "lib/constants.js",
6
6
  "bin": {
@@ -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