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

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.
@@ -85,10 +85,10 @@ try {
85
85
  save(session) {
86
86
  try {
87
87
  if (!fs.existsSync(PLEXOR_DIR)) {
88
- fs.mkdirSync(PLEXOR_DIR, { recursive: true });
88
+ fs.mkdirSync(PLEXOR_DIR, { recursive: true, mode: 0o700 });
89
89
  }
90
90
  session.last_activity = Date.now();
91
- fs.writeFileSync(this.sessionPath, JSON.stringify(session, null, 2));
91
+ fs.writeFileSync(this.sessionPath, JSON.stringify(session, null, 2), { mode: 0o600 });
92
92
  } catch {}
93
93
  }
94
94
 
@@ -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
+ }
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Shared config utilities for Plexor slash commands.
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const crypto = require('crypto');
8
+ const os = require('os');
9
+ const { PLEXOR_DIR, CONFIG_PATH } = require('./constants');
10
+
11
+ const DISABLED_HINT_VALUES = new Set(['', 'auto', 'none', 'off']);
12
+ const VALID_ORCHESTRATION_MODES = new Set(['supervised', 'autonomous', 'danger-full-auto']);
13
+ const VALID_ROUTING_MODES = new Set(['eco', 'balanced', 'quality', 'passthrough', 'cost']);
14
+ const MANAGED_HEADER_KEYS = new Set([
15
+ 'x-force-provider',
16
+ 'x-force-model',
17
+ 'x-allow-providers',
18
+ 'x-deny-providers',
19
+ 'x-allow-models',
20
+ 'x-deny-models',
21
+ 'x-plexor-mode',
22
+ 'x-plexor-orchestration-mode'
23
+ ]);
24
+ const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
25
+
26
+ function normalizeForcedProvider(value) {
27
+ if (typeof value !== 'string') {
28
+ return null;
29
+ }
30
+ const normalized = value.trim().toLowerCase();
31
+ if (!normalized || normalized === 'auto') {
32
+ return 'auto';
33
+ }
34
+ return normalized;
35
+ }
36
+
37
+ function normalizeForcedModel(value) {
38
+ if (typeof value !== 'string') {
39
+ return null;
40
+ }
41
+ const normalized = value.trim();
42
+ if (!normalized || DISABLED_HINT_VALUES.has(normalized.toLowerCase())) {
43
+ return null;
44
+ }
45
+ return normalized;
46
+ }
47
+
48
+ function normalizeCsv(value) {
49
+ if (typeof value !== 'string') {
50
+ return null;
51
+ }
52
+ const tokens = value
53
+ .split(',')
54
+ .map((token) => token.trim())
55
+ .filter(Boolean);
56
+ if (!tokens.length) {
57
+ return null;
58
+ }
59
+ return tokens.join(',');
60
+ }
61
+
62
+ function parseCustomHeaders(raw) {
63
+ if (typeof raw !== 'string' || !raw.trim()) {
64
+ return {};
65
+ }
66
+ const trimmedRaw = raw.trim();
67
+
68
+ // Backward compatibility: older plugin versions persisted ANTHROPIC_CUSTOM_HEADERS
69
+ // as a JSON object string. Parse that first so managed key replacement works.
70
+ if (trimmedRaw.startsWith('{')) {
71
+ try {
72
+ const legacy = JSON.parse(trimmedRaw);
73
+ if (legacy && typeof legacy === 'object' && !Array.isArray(legacy)) {
74
+ const out = {};
75
+ for (const [key, value] of Object.entries(legacy)) {
76
+ const normalizedKey = String(key || '').trim().toLowerCase();
77
+ const normalizedValue = String(value ?? '').trim();
78
+ if (!normalizedKey || !normalizedValue) {
79
+ continue;
80
+ }
81
+ out[normalizedKey] = normalizedValue;
82
+ }
83
+ return out;
84
+ }
85
+ } catch {
86
+ // Fall through to line-based parser.
87
+ }
88
+ }
89
+
90
+ const parsed = {};
91
+ for (const line of raw.split('\n')) {
92
+ const trimmed = line.trim();
93
+ if (!trimmed) {
94
+ continue;
95
+ }
96
+ const idx = trimmed.indexOf(':');
97
+ if (idx <= 0) {
98
+ continue;
99
+ }
100
+ const key = trimmed.slice(0, idx).trim().toLowerCase();
101
+ const value = trimmed.slice(idx + 1).trim();
102
+ if (!key || !value) {
103
+ continue;
104
+ }
105
+ parsed[key] = value;
106
+ }
107
+ return parsed;
108
+ }
109
+
110
+ function serializeCustomHeaders(headers) {
111
+ return Object.entries(headers)
112
+ .map(([key, value]) => `${key}: ${value}`)
113
+ .join('\n');
114
+ }
115
+
116
+ function buildManagedAnthropicHeaders(config) {
117
+ const settings = config?.settings || {};
118
+ const headers = {};
119
+
120
+ const modeRaw = String(settings.mode || '')
121
+ .trim()
122
+ .toLowerCase();
123
+ if (VALID_ROUTING_MODES.has(modeRaw)) {
124
+ headers['x-plexor-mode'] = modeRaw === 'cost' ? 'eco' : modeRaw;
125
+ }
126
+
127
+ const orchestrationRaw = String(
128
+ settings.orchestrationMode || settings.orchestration_mode || ''
129
+ )
130
+ .trim()
131
+ .toLowerCase();
132
+ if (VALID_ORCHESTRATION_MODES.has(orchestrationRaw)) {
133
+ headers['x-plexor-orchestration-mode'] = orchestrationRaw;
134
+ }
135
+
136
+ const forceProvider = normalizeForcedProvider(
137
+ settings.preferred_provider ?? settings.preferredProvider ?? 'auto'
138
+ );
139
+ if (forceProvider && forceProvider !== 'auto') {
140
+ headers['x-force-provider'] = forceProvider;
141
+ }
142
+
143
+ const forceModel = normalizeForcedModel(settings.preferred_model ?? settings.preferredModel);
144
+ if (forceModel) {
145
+ headers['x-force-model'] = forceModel;
146
+ }
147
+
148
+ const allowProviders = normalizeCsv(
149
+ settings.provider_allowlist ?? settings.providerAllowlist ?? ''
150
+ );
151
+ if (allowProviders) {
152
+ headers['x-allow-providers'] = allowProviders;
153
+ }
154
+
155
+ const denyProviders = normalizeCsv(settings.provider_denylist ?? settings.providerDenylist ?? '');
156
+ if (denyProviders) {
157
+ headers['x-deny-providers'] = denyProviders;
158
+ }
159
+
160
+ const allowModels = normalizeCsv(settings.model_allowlist ?? settings.modelAllowlist ?? '');
161
+ if (allowModels) {
162
+ headers['x-allow-models'] = allowModels;
163
+ }
164
+
165
+ const denyModels = normalizeCsv(settings.model_denylist ?? settings.modelDenylist ?? '');
166
+ if (denyModels) {
167
+ headers['x-deny-models'] = denyModels;
168
+ }
169
+
170
+ return headers;
171
+ }
172
+
173
+ function syncClaudeCustomHeaders(config) {
174
+ try {
175
+ let settings = {};
176
+ if (fs.existsSync(CLAUDE_SETTINGS_PATH)) {
177
+ const raw = fs.readFileSync(CLAUDE_SETTINGS_PATH, 'utf8');
178
+ settings = raw.trim() ? JSON.parse(raw) : {};
179
+ }
180
+
181
+ if (typeof settings !== 'object' || settings === null || Array.isArray(settings)) {
182
+ settings = {};
183
+ }
184
+ settings.env = settings.env && typeof settings.env === 'object' ? settings.env : {};
185
+
186
+ const existing = parseCustomHeaders(settings.env.ANTHROPIC_CUSTOM_HEADERS);
187
+ for (const key of MANAGED_HEADER_KEYS) {
188
+ delete existing[key];
189
+ }
190
+
191
+ const managed = buildManagedAnthropicHeaders(config);
192
+ const merged = { ...existing, ...managed };
193
+
194
+ if (Object.keys(merged).length) {
195
+ settings.env.ANTHROPIC_CUSTOM_HEADERS = serializeCustomHeaders(merged);
196
+ } else {
197
+ delete settings.env.ANTHROPIC_CUSTOM_HEADERS;
198
+ }
199
+
200
+ const claudeDir = path.dirname(CLAUDE_SETTINGS_PATH);
201
+ if (!fs.existsSync(claudeDir)) {
202
+ fs.mkdirSync(claudeDir, { recursive: true, mode: 0o700 });
203
+ }
204
+ const tempPath = path.join(claudeDir, `.settings.${crypto.randomBytes(8).toString('hex')}.tmp`);
205
+ fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2), { mode: 0o600 });
206
+ fs.renameSync(tempPath, CLAUDE_SETTINGS_PATH);
207
+ return true;
208
+ } catch {
209
+ return false;
210
+ }
211
+ }
212
+
213
+ function hasForcedHintConflict(config) {
214
+ const settings = config?.settings || {};
215
+ const provider = normalizeForcedProvider(
216
+ settings.preferred_provider ?? settings.preferredProvider ?? 'auto'
217
+ );
218
+ const model = normalizeForcedModel(settings.preferred_model ?? settings.preferredModel);
219
+ return Boolean(model) && provider !== 'auto';
220
+ }
221
+
222
+ function validateForcedHintConfig(config) {
223
+ if (!hasForcedHintConflict(config)) {
224
+ return { ok: true };
225
+ }
226
+ return {
227
+ ok: false,
228
+ message:
229
+ 'Invalid Plexor config: set only one force hint. Use preferred_provider OR preferred_model, not both.'
230
+ };
231
+ }
232
+
233
+ function loadConfig() {
234
+ try {
235
+ if (!fs.existsSync(CONFIG_PATH)) {
236
+ return { version: 1, auth: {}, settings: {} };
237
+ }
238
+ const data = fs.readFileSync(CONFIG_PATH, 'utf8');
239
+ if (!data || data.trim() === '') {
240
+ return { version: 1, auth: {}, settings: {} };
241
+ }
242
+ const config = JSON.parse(data);
243
+ if (typeof config !== 'object' || config === null) {
244
+ return { version: 1, auth: {}, settings: {} };
245
+ }
246
+ return config;
247
+ } catch (err) {
248
+ if (err instanceof SyntaxError) {
249
+ try { fs.copyFileSync(CONFIG_PATH, CONFIG_PATH + '.corrupted'); } catch { /* ignore */ }
250
+ }
251
+ return { version: 1, auth: {}, settings: {} };
252
+ }
253
+ }
254
+
255
+ function saveConfig(config) {
256
+ try {
257
+ const validation = validateForcedHintConfig(config);
258
+ if (!validation.ok) {
259
+ console.error(`Error: ${validation.message}`);
260
+ return false;
261
+ }
262
+
263
+ if (!fs.existsSync(PLEXOR_DIR)) {
264
+ fs.mkdirSync(PLEXOR_DIR, { recursive: true, mode: 0o700 });
265
+ }
266
+ const tempId = crypto.randomBytes(8).toString('hex');
267
+ const tempPath = path.join(PLEXOR_DIR, `.config.${tempId}.tmp`);
268
+ fs.writeFileSync(tempPath, JSON.stringify(config, null, 2), { mode: 0o600 });
269
+ fs.renameSync(tempPath, CONFIG_PATH);
270
+ // Best-effort sync to Claude client headers so force hints are respected.
271
+ syncClaudeCustomHeaders(config);
272
+ return true;
273
+ } catch (err) {
274
+ if (err.code === 'EACCES' || err.code === 'EPERM') {
275
+ console.error('Error: Cannot write to ~/.plexor/config.json');
276
+ } else {
277
+ console.error('Failed to save config:', err.message);
278
+ }
279
+ return false;
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Read a setting with env-var → config → default precedence.
285
+ * @param {object} config - Loaded config object
286
+ * @param {string} configKey - Primary key in config.settings (camelCase)
287
+ * @param {string|null} configKeyAlt - Alternative key (snake_case legacy)
288
+ * @param {string} envVar - Environment variable name
289
+ * @param {string[]} validValues - Accepted values
290
+ * @param {string} defaultValue - Fallback
291
+ * @returns {{ value: string, source: string }}
292
+ */
293
+ function readSetting(config, configKey, configKeyAlt, envVar, validValues, defaultValue) {
294
+ const env = process.env[envVar];
295
+ if (env && validValues.includes(env.toLowerCase())) {
296
+ return { value: env.toLowerCase(), source: 'environment' };
297
+ }
298
+ const cfg = config.settings?.[configKey] || (configKeyAlt && config.settings?.[configKeyAlt]);
299
+ if (cfg && validValues.includes(cfg.toLowerCase())) {
300
+ return { value: cfg.toLowerCase(), source: 'config' };
301
+ }
302
+ return { value: defaultValue, source: 'default' };
303
+ }
304
+
305
+ module.exports = {
306
+ loadConfig,
307
+ saveConfig,
308
+ readSetting,
309
+ hasForcedHintConflict,
310
+ validateForcedHintConfig,
311
+ parseCustomHeaders,
312
+ serializeCustomHeaders,
313
+ buildManagedAnthropicHeaders
314
+ };
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() {
@@ -22,7 +23,10 @@ class ConfigManager {
22
23
  timeout: cfg.settings?.timeout || DEFAULT_TIMEOUT,
23
24
  localCacheEnabled: cfg.settings?.localCacheEnabled ?? false,
24
25
  mode: cfg.settings?.mode || 'balanced',
25
- preferredProvider: cfg.settings?.preferred_provider || 'auto'
26
+ preferredProvider: cfg.settings?.preferred_provider || 'auto',
27
+ preferredModel: cfg.settings?.preferredModel || cfg.settings?.preferred_model || null,
28
+ orchestrationMode:
29
+ cfg.settings?.orchestrationMode || cfg.settings?.orchestration_mode || 'autonomous'
26
30
  };
27
31
  } catch {
28
32
  return { enabled: false, apiKey: '', apiUrl: DEFAULT_API_URL };
@@ -52,11 +56,26 @@ class ConfigManager {
52
56
  timeout: config.timeout ?? existing.settings?.timeout,
53
57
  localCacheEnabled: config.localCacheEnabled ?? existing.settings?.localCacheEnabled,
54
58
  mode: config.mode ?? existing.settings?.mode,
55
- preferred_provider: config.preferredProvider ?? existing.settings?.preferred_provider
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,
65
+ orchestrationMode:
66
+ config.orchestrationMode ??
67
+ config.orchestration_mode ??
68
+ existing.settings?.orchestrationMode ??
69
+ existing.settings?.orchestration_mode
56
70
  }
57
71
  };
58
72
 
59
- fs.writeFileSync(this.configPath, JSON.stringify(updated, null, 2));
73
+ const validation = validateForcedHintConfig(updated);
74
+ if (!validation.ok) {
75
+ return false;
76
+ }
77
+
78
+ fs.writeFileSync(this.configPath, JSON.stringify(updated, null, 2), { mode: 0o600 });
60
79
  return true;
61
80
  } catch {
62
81
  return false;