@in-the-loop-labs/pair-review 2.4.4 → 2.6.0

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.
@@ -205,10 +205,13 @@ async function runTask(
205
205
  onUpdate: OnUpdate | undefined,
206
206
  makeDetails: (results: TaskResult[]) => TaskDetails,
207
207
  ): Promise<TaskResult> {
208
- // Build args: full tool access, JSON output, no session persistence
208
+ // Build args: full tool access, JSON output, no session persistence.
209
+ // Skills and extensions are left enabled so subtasks have access to the
210
+ // user's configured environment. --no-prompt-templates is kept because
211
+ // prompt templates can't be triggered in -p mode.
209
212
  const args: string[] = [
210
213
  "--mode", "json", "-p", "--no-session",
211
- "--no-extensions", "--no-skills", "--no-prompt-templates",
214
+ "--no-prompt-templates",
212
215
  "-e", EXTENSION_DIR,
213
216
  ];
214
217
  if (model) {
@@ -0,0 +1 @@
1
+ {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@in-the-loop-labs/pair-review",
3
- "version": "2.4.4",
3
+ "version": "2.6.0",
4
4
  "description": "Your AI-powered code review partner - Close the feedback loop with AI coding agents",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -14,6 +14,7 @@
14
14
  "public/",
15
15
  "plugin/",
16
16
  "plugin-code-critic/",
17
+ "config.managed.json",
17
18
  "README.md",
18
19
  "LICENSE"
19
20
  ],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pair-review",
3
- "version": "2.4.4",
3
+ "version": "2.6.0",
4
4
  "description": "pair-review app integration — Open PRs and local changes in the pair-review web UI, run server-side AI analysis, and address review feedback. Requires the pair-review MCP server.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-critic",
3
- "version": "2.4.4",
3
+ "version": "2.6.0",
4
4
  "description": "AI-powered code review analysis — Run three-level AI analysis and implement-review-fix loops directly in your coding agent. Works standalone, no server required.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
package/public/css/pr.css CHANGED
@@ -11066,6 +11066,39 @@ body.resizing * {
11066
11066
  background: linear-gradient(to bottom, var(--chat-subtle), transparent);
11067
11067
  flex-shrink: 0;
11068
11068
  }
11069
+
11070
+ /* Status flash pill — transient notification between header and content */
11071
+ .chat-panel__status-flash {
11072
+ display: flex;
11073
+ justify-content: center;
11074
+ padding: 6px 16px;
11075
+ flex-shrink: 0;
11076
+ opacity: 0;
11077
+ transition: opacity 0.3s ease;
11078
+ }
11079
+
11080
+ .chat-panel__status-flash--visible {
11081
+ opacity: 1;
11082
+ }
11083
+
11084
+ .chat-panel__status-flash-text {
11085
+ display: inline-block;
11086
+ padding: 3px 12px;
11087
+ font-size: 11px;
11088
+ font-weight: 600;
11089
+ letter-spacing: 0.02em;
11090
+ color: #92400e;
11091
+ background: #fef3c7;
11092
+ border: 1px solid #fcd34d;
11093
+ border-radius: 999px;
11094
+ }
11095
+
11096
+ [data-theme="dark"] .chat-panel__status-flash-text {
11097
+ color: #fbbf24;
11098
+ background: rgba(251, 191, 36, 0.12);
11099
+ border-color: rgba(251, 191, 36, 0.3);
11100
+ }
11101
+
11069
11102
  /* Provider picker */
11070
11103
  .chat-panel__provider-picker {
11071
11104
  position: relative;
@@ -32,6 +32,7 @@ class ChatPanel {
32
32
  this._analysisContextRemoved = false;
33
33
  this._sessionAnalysisRunId = null; // tracks which AI run ID's context is loaded in the current session
34
34
  this._openPromise = null; // concurrency guard for open()
35
+ this._sessionWarm = false; // true once the session has been used in this page load
35
36
  this._activeProvider = window.__pairReview?.chatProvider || 'pi';
36
37
  this._chatProviders = window.__pairReview?.chatProviders || [];
37
38
 
@@ -84,6 +85,9 @@ class ChatPanel {
84
85
  </button>
85
86
  </div>
86
87
  </div>
88
+ <div class="chat-panel__status-flash" style="display:none">
89
+ <span class="chat-panel__status-flash-text">Starting Agent Client Protocol</span>
90
+ </div>
87
91
  <div class="chat-panel__messages-wrapper">
88
92
  <div class="chat-panel__messages" id="chat-messages">
89
93
  <div class="chat-panel__empty">
@@ -170,6 +174,7 @@ class ChatPanel {
170
174
  this.historyBtn = this.container.querySelector('.chat-panel__history-btn');
171
175
  this.titleTextEl = this.container.querySelector('.chat-panel__title-text');
172
176
  this.newContentPill = this.container.querySelector('.chat-panel__new-content-pill');
177
+ this.statusFlash = this.container.querySelector('.chat-panel__status-flash');
173
178
  }
174
179
 
175
180
  /**
@@ -407,6 +412,53 @@ class ChatPanel {
407
412
  return providerId.charAt(0).toUpperCase() + providerId.slice(1);
408
413
  }
409
414
 
415
+ /**
416
+ * Check if the active provider uses ACP (Agent Client Protocol).
417
+ * @returns {boolean}
418
+ */
419
+ _isAcpProvider() {
420
+ const entry = this._chatProviders.find(p => p.id === this._activeProvider);
421
+ return entry?.type === 'acp';
422
+ }
423
+
424
+ /**
425
+ * Show a transient status flash pill (e.g. "Starting Agent Client Protocol").
426
+ * Auto-hides after the given timeout.
427
+ * @param {string} text - Text to display
428
+ * @param {number} [timeout=5000] - Max display time in ms
429
+ */
430
+ _showStatusFlash(text, timeout = 5000) {
431
+ if (!this.statusFlash) return;
432
+ if (this._hideAnimationTimeout) {
433
+ clearTimeout(this._hideAnimationTimeout);
434
+ this._hideAnimationTimeout = null;
435
+ }
436
+ const textEl = this.statusFlash.querySelector('.chat-panel__status-flash-text');
437
+ if (textEl) textEl.textContent = text;
438
+ this.statusFlash.style.display = '';
439
+ // Force reflow to ensure the fade-in animation triggers
440
+ void this.statusFlash.offsetHeight;
441
+ this.statusFlash.classList.add('chat-panel__status-flash--visible');
442
+ this._statusFlashTimeout = setTimeout(() => this._hideStatusFlash(), timeout);
443
+ }
444
+
445
+ /**
446
+ * Hide the status flash pill with a fade-out animation.
447
+ */
448
+ _hideStatusFlash() {
449
+ if (this._statusFlashTimeout) {
450
+ clearTimeout(this._statusFlashTimeout);
451
+ this._statusFlashTimeout = null;
452
+ }
453
+ if (!this.statusFlash) return;
454
+ this.statusFlash.classList.remove('chat-panel__status-flash--visible');
455
+ // Hide after transition completes
456
+ this._hideAnimationTimeout = setTimeout(() => {
457
+ if (this.statusFlash) this.statusFlash.style.display = 'none';
458
+ this._hideAnimationTimeout = null;
459
+ }, 300);
460
+ }
461
+
410
462
  /**
411
463
  * Open the chat panel
412
464
  * @param {Object} options - Optional context
@@ -666,6 +718,7 @@ class ChatPanel {
666
718
 
667
719
  const mru = sessions[0];
668
720
  this.currentSessionId = mru.id;
721
+ this._sessionWarm = false;
669
722
  this._resubscribeChat();
670
723
  console.debug('[ChatPanel] Loaded MRU session:', mru.id, 'messages:', mru.message_count);
671
724
 
@@ -944,6 +997,7 @@ class ChatPanel {
944
997
 
945
998
  // 2. Reset state
946
999
  this.currentSessionId = sessionId;
1000
+ this._sessionWarm = false;
947
1001
  this._resubscribeChat();
948
1002
  this.messages = [];
949
1003
  this._streamingContent = '';
@@ -1081,6 +1135,11 @@ class ChatPanel {
1081
1135
  return null;
1082
1136
  }
1083
1137
 
1138
+ const isAcp = this._isAcpProvider();
1139
+ if (isAcp) {
1140
+ this._showStatusFlash('Starting Agent Client Protocol');
1141
+ }
1142
+
1084
1143
  try {
1085
1144
  const body = {
1086
1145
  provider: this._activeProvider,
@@ -1100,6 +1159,8 @@ class ChatPanel {
1100
1159
  body: JSON.stringify(body)
1101
1160
  });
1102
1161
 
1162
+ if (isAcp) this._hideStatusFlash();
1163
+
1103
1164
  if (!response.ok) {
1104
1165
  const err = await response.json().catch(() => ({}));
1105
1166
  throw new Error(err.error || 'Failed to create chat session');
@@ -1107,10 +1168,12 @@ class ChatPanel {
1107
1168
 
1108
1169
  const result = await response.json();
1109
1170
  this.currentSessionId = result.data.id;
1171
+ this._sessionWarm = true;
1110
1172
  this._resubscribeChat();
1111
1173
  console.debug('[ChatPanel] Session created:', this.currentSessionId);
1112
1174
  return result.data;
1113
1175
  } catch (error) {
1176
+ if (isAcp) this._hideStatusFlash();
1114
1177
  console.error('[ChatPanel] Error creating session:', error);
1115
1178
  this._showError('Failed to start chat session. ' + error.message);
1116
1179
  return null;
@@ -1197,6 +1260,12 @@ class ChatPanel {
1197
1260
  this._pendingActionContext = null;
1198
1261
  }
1199
1262
 
1263
+ // Show ACP resume flash when the session may need server-side auto-resume
1264
+ const acpResuming = this._isAcpProvider() && !this._sessionWarm;
1265
+ if (acpResuming) {
1266
+ this._showStatusFlash('Resuming Agent Client Protocol');
1267
+ }
1268
+
1200
1269
  // Send to API
1201
1270
  try {
1202
1271
  console.debug('[ChatPanel] Sending message to session', this.currentSessionId);
@@ -1206,7 +1275,15 @@ class ChatPanel {
1206
1275
  body: JSON.stringify(payload)
1207
1276
  });
1208
1277
 
1209
- // Handle 410 Gone: session is not resumable — transparently create a new one and retry once
1278
+ if (acpResuming) {
1279
+ this._hideStatusFlash();
1280
+ this._sessionWarm = true;
1281
+ }
1282
+
1283
+ // Handle 410 Gone: session is not resumable — transparently create a new one and retry once.
1284
+ // Note: we do NOT call _hideStatusFlash() here. createSession() will call
1285
+ // _showStatusFlash() which overwrites the pill text directly, avoiding a
1286
+ // visible hide/show flicker during the transparent retry.
1210
1287
  if (response.status === 410) {
1211
1288
  console.debug('[ChatPanel] Session not resumable (410), creating new session and retrying');
1212
1289
  this.currentSessionId = null;
@@ -1230,6 +1307,7 @@ class ChatPanel {
1230
1307
  }
1231
1308
  console.debug('[ChatPanel] Message accepted, waiting for WebSocket events');
1232
1309
  } catch (error) {
1310
+ if (acpResuming) this._hideStatusFlash();
1233
1311
  // Restore pending context so it's not lost
1234
1312
  this._pendingContext = savedContext;
1235
1313
  this._pendingContextData = savedContextData;
@@ -174,7 +174,14 @@ class ClaudeProvider extends AIProvider {
174
174
  ].join(',');
175
175
  permissionArgs = ['--allowedTools', allowedTools];
176
176
  }
177
- const baseArgs = ['-p', '--verbose', ...cliModelArgs, '--output-format', 'stream-json', ...permissionArgs];
177
+ // Suppress user hooks in analysis subprocesses to avoid side-effects
178
+ // (notifications, confirmations, etc.) firing on every tool call during review.
179
+ // Uses --settings '{"disableAllHooks":true}' since Claude has no --no-hooks flag.
180
+ // Skills and extensions are left enabled so the subprocess has access to the
181
+ // user's configured environment. To disable skills, add --disable-slash-commands
182
+ // to extra_args in provider/model config.
183
+ const hooksArgs = ['--settings', '{"disableAllHooks":true}'];
184
+ const baseArgs = ['-p', '--verbose', ...cliModelArgs, '--output-format', 'stream-json', ...hooksArgs, ...permissionArgs];
178
185
  if (maxBudget) {
179
186
  const budgetNum = parseFloat(maxBudget);
180
187
  if (isNaN(budgetNum) || budgetNum <= 0) {
@@ -23,7 +23,8 @@ const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
23
23
  * Based on OpenAI Codex Models guide (developers.openai.com/codex/models)
24
24
  * - gpt-5.1-codex-mini: Smaller, cost-effective variant for quick scans
25
25
  * - gpt-5.2-codex: Advanced coding model for everyday reviews, good reasoning/cost balance
26
- * - gpt-5.3-codex: Most capable agentic coding model with frontier performance and reasoning
26
+ * - gpt-5.3-codex: Capable agentic coding model with frontier performance and reasoning
27
+ * - gpt-5.4: Latest generation with enhanced reasoning depth
27
28
  */
28
29
  const CODEX_MODELS = [
29
30
  {
@@ -50,7 +51,16 @@ const CODEX_MODELS = [
50
51
  name: 'GPT-5.3 Codex',
51
52
  tier: 'thorough',
52
53
  tagline: 'Deep Review',
53
- description: 'Most capable agentic coding model—combines frontier coding performance with stronger reasoning for deep cross-file analysis.',
54
+ description: 'Capable agentic coding model—combines frontier coding performance with strong reasoning for cross-file analysis.',
55
+ badge: 'Thorough',
56
+ badgeClass: 'badge-power'
57
+ },
58
+ {
59
+ id: 'gpt-5.4',
60
+ name: 'GPT-5.4',
61
+ tier: 'thorough',
62
+ tagline: 'Latest Gen',
63
+ description: 'Latest generation model with enhanced reasoning depth for complex architectural reviews.',
54
64
  badge: 'Most Thorough',
55
65
  badgeClass: 'badge-power'
56
66
  }
@@ -143,6 +143,24 @@ const CURSOR_AGENT_MODELS = [
143
143
  description: 'Deep analysis with extended thinking—Cursor default for maximum review quality',
144
144
  badge: 'Most Thorough',
145
145
  badgeClass: 'badge-power'
146
+ },
147
+ {
148
+ id: 'gpt-5.4-high',
149
+ name: 'GPT-5.4 High',
150
+ tier: 'thorough',
151
+ tagline: 'Latest OpenAI',
152
+ description: 'Latest generation with high reasoning effort—strong for complex architectural reviews',
153
+ badge: 'Latest Gen',
154
+ badgeClass: 'badge-power'
155
+ },
156
+ {
157
+ id: 'gpt-5.4-medium',
158
+ name: 'GPT-5.4',
159
+ tier: 'thorough',
160
+ tagline: 'Latest Gen',
161
+ description: 'Latest generation at medium reasoning depth',
162
+ badge: 'Latest',
163
+ badgeClass: 'badge-power'
146
164
  }
147
165
  ];
148
166
 
@@ -189,24 +189,23 @@ class PiProvider extends AIProvider {
189
189
  // --no-session: Each pi invocation is an ephemeral analysis — there's no need to
190
190
  // persist session state between runs. Set PAIR_REVIEW_PI_SESSION=1
191
191
  // to enable session saving for debugging (sessions saved to ~/.pi/sessions/).
192
- // --no-skills: Skills are disabled by default to keep runs deterministic. A skill can
193
- // still be loaded via `--skill` in model-specific `extra_args` if needed.
194
-
195
192
  // Build args: base args + built-in extra_args + provider extra_args + model extra_args
196
193
  // In yolo mode, omit --tools entirely to allow all tools (including edit, write)
197
194
  // The task extension is loaded to give the model a subagent tool for delegating
198
195
  // work to isolated subprocesses, preserving the main context window.
199
- // --no-extensions prevents auto-discovery of other extensions.
200
- // --no-skills and --no-prompt-templates keep the subprocess focused.
196
+ // --no-prompt-templates: prompt templates can't be triggered in -p mode, so suppress
197
+ // them to avoid wasting context. Skills and extensions are left enabled so the
198
+ // subprocess has access to the user's configured environment. To disable them,
199
+ // add --no-skills or --no-extensions to extra_args in provider/model config.
201
200
  const sessionArgs = process.env.PAIR_REVIEW_PI_SESSION ? [] : ['--no-session'];
202
201
  let baseArgs;
203
202
  if (configOverrides.yolo) {
204
203
  baseArgs = ['-p', '--mode', 'json', ...cliModelArgs, ...sessionArgs,
205
- '--no-extensions', '--no-skills', '--no-prompt-templates',
204
+ '--no-prompt-templates',
206
205
  '-e', TASK_EXTENSION_DIR];
207
206
  } else {
208
207
  baseArgs = ['-p', '--mode', 'json', ...cliModelArgs, '--tools', 'read,bash,grep,find,ls', ...sessionArgs,
209
- '--no-extensions', '--no-skills', '--no-prompt-templates',
208
+ '--no-prompt-templates',
210
209
  '-e', TASK_EXTENSION_DIR];
211
210
  }
212
211
  const builtInArgs = builtIn?.extra_args || [];
@@ -96,17 +96,40 @@ function applyConfigOverrides(providersConfig) {
96
96
 
97
97
  /**
98
98
  * Get a chat provider definition with config overrides merged.
99
- * @param {string} id - Provider ID (e.g. 'copilot-acp')
99
+ * Supports both built-in providers and dynamic providers defined entirely in config.
100
+ * @param {string} id - Provider ID (e.g. 'copilot-acp', or a custom ID like 'river')
100
101
  * @returns {Object|null} Provider definition or null if unknown
101
102
  */
102
103
  function getChatProvider(id) {
103
104
  const base = CHAT_PROVIDERS[id];
104
- if (!base) return null;
105
-
106
105
  const overrides = _configOverrides[id];
106
+
107
+ if (!base && !overrides) return null;
108
+
109
+ // Dynamic provider defined entirely in config
110
+ if (!base) {
111
+ const provider = {
112
+ id,
113
+ name: overrides.name || overrides.label || id,
114
+ type: overrides.type || 'acp',
115
+ command: overrides.command || id,
116
+ args: overrides.args || [],
117
+ env: overrides.env || {},
118
+ };
119
+ if (overrides.model) provider.model = overrides.model;
120
+ if (overrides.extra_args && Array.isArray(overrides.extra_args)) {
121
+ provider.args = [...provider.args, ...overrides.extra_args];
122
+ }
123
+ if (provider.command.includes(' ')) {
124
+ provider.useShell = true;
125
+ }
126
+ return provider;
127
+ }
128
+
107
129
  if (!overrides) return { ...base };
108
130
 
109
131
  const merged = { ...base };
132
+ if (overrides.name || overrides.label) merged.name = overrides.name || overrides.label;
110
133
  if (overrides.command) merged.command = overrides.command;
111
134
  if (overrides.model) merged.model = overrides.model;
112
135
  if (overrides.env) merged.env = { ...merged.env, ...overrides.env };
@@ -125,11 +148,15 @@ function getChatProvider(id) {
125
148
  }
126
149
 
127
150
  /**
128
- * Get all chat provider definitions (with overrides applied).
151
+ * Get all chat provider definitions (built-in + dynamic from config).
129
152
  * @returns {Array<Object>}
130
153
  */
131
154
  function getAllChatProviders() {
132
- return Object.keys(CHAT_PROVIDERS).map(id => getChatProvider(id));
155
+ const ids = new Set([
156
+ ...Object.keys(CHAT_PROVIDERS),
157
+ ...Object.keys(_configOverrides),
158
+ ]);
159
+ return [...ids].map(id => getChatProvider(id)).filter(Boolean);
133
160
  }
134
161
 
135
162
  /**
@@ -138,7 +165,7 @@ function getAllChatProviders() {
138
165
  * @returns {boolean}
139
166
  */
140
167
  function isAcpProvider(id) {
141
- const provider = CHAT_PROVIDERS[id];
168
+ const provider = getChatProvider(id);
142
169
  return provider?.type === 'acp';
143
170
  }
144
171
 
@@ -148,7 +175,7 @@ function isAcpProvider(id) {
148
175
  * @returns {boolean}
149
176
  */
150
177
  function isClaudeCodeProvider(id) {
151
- const provider = CHAT_PROVIDERS[id];
178
+ const provider = getChatProvider(id);
152
179
  return provider?.type === 'claude';
153
180
  }
154
181
 
@@ -158,7 +185,7 @@ function isClaudeCodeProvider(id) {
158
185
  * @returns {boolean}
159
186
  */
160
187
  function isCodexProvider(id) {
161
- const provider = CHAT_PROVIDERS[id];
188
+ const provider = getChatProvider(id);
162
189
  return provider?.type === 'codex';
163
190
  }
164
191
 
@@ -223,7 +250,7 @@ async function checkChatProviderAvailability(id, _deps) {
223
250
  * @returns {Promise<void>}
224
251
  */
225
252
  async function checkAllChatProviders(_deps) {
226
- const ids = Object.keys(CHAT_PROVIDERS);
253
+ const ids = [...new Set([...Object.keys(CHAT_PROVIDERS), ...Object.keys(_configOverrides)])];
227
254
  const results = await Promise.all(
228
255
  ids.map(async (id) => {
229
256
  const result = await checkChatProviderAvailability(id, _deps);
@@ -273,12 +273,9 @@ class PiBridge extends EventEmitter {
273
273
  }
274
274
 
275
275
  // Load extensions via -e (e.g., task extension for subagent delegation).
276
- // --no-extensions prevents auto-discovery; only explicitly listed ones load.
277
- if (this.extensions.length > 0) {
278
- args.push('--no-extensions');
279
- for (const ext of this.extensions) {
280
- args.push('-e', ext);
281
- }
276
+ // These are additive — the user's auto-discovered extensions remain available.
277
+ for (const ext of this.extensions) {
278
+ args.push('-e', ext);
282
279
  }
283
280
 
284
281
  return args;
package/src/config.js CHANGED
@@ -2,17 +2,22 @@
2
2
  const fs = require('fs').promises;
3
3
  const path = require('path');
4
4
  const os = require('os');
5
+ const childProcess = require('child_process');
5
6
  const logger = require('./utils/logger');
6
7
 
8
+ let _cachedCommandToken = null;
9
+
7
10
  const CONFIG_DIR = path.join(os.homedir(), '.pair-review');
8
11
  const DEFAULT_CHECKOUT_TIMEOUT_MS = 300000;
9
12
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
10
13
  const CONFIG_LOCAL_FILE = path.join(CONFIG_DIR, 'config.local.json');
11
14
  const CONFIG_EXAMPLE_FILE = path.join(CONFIG_DIR, 'config.example.json');
12
15
  const PACKAGE_ROOT = path.join(__dirname, '..');
16
+ const MANAGED_CONFIG_FILE = path.join(PACKAGE_ROOT, 'config.managed.json');
13
17
 
14
18
  const DEFAULT_CONFIG = {
15
19
  github_token: "",
20
+ github_token_command: "gh auth token", // Shell command whose stdout is used as the GitHub token
16
21
  port: 7247,
17
22
  theme: "light",
18
23
  default_provider: "claude", // AI provider: 'claude', 'gemini', 'codex', 'copilot', 'opencode', 'cursor-agent', 'pi'
@@ -171,6 +176,7 @@ async function loadConfig() {
171
176
 
172
177
  const localDir = path.join(process.cwd(), '.pair-review');
173
178
  const sources = [
179
+ { path: MANAGED_CONFIG_FILE, label: 'managed config', required: false },
174
180
  { path: CONFIG_FILE, label: 'global config', required: true },
175
181
  { path: CONFIG_LOCAL_FILE, label: 'global local config', required: false },
176
182
  { path: path.join(localDir, 'config.json'), label: 'project config', required: false },
@@ -247,17 +253,54 @@ function getConfigDir() {
247
253
  * Priority:
248
254
  * 1. GITHUB_TOKEN environment variable (highest priority)
249
255
  * 2. config.github_token from ~/.pair-review/config.json
256
+ * 3. config.github_token_command — execute shell command, use stdout (cached on success)
257
+ * 4. Empty string (no token)
250
258
  *
251
259
  * @param {Object} config - Configuration object from loadConfig()
252
260
  * @returns {string} - GitHub token or empty string if not configured
253
261
  */
254
262
  function getGitHubToken(config) {
255
- // Environment variable takes precedence
256
263
  if (process.env.GITHUB_TOKEN) {
264
+ logger.debug('Using GitHub token from GITHUB_TOKEN environment variable');
257
265
  return process.env.GITHUB_TOKEN;
258
266
  }
259
- // Fall back to config file
260
- return config.github_token || '';
267
+ if (config.github_token) {
268
+ logger.debug('Using GitHub token from config.github_token');
269
+ return config.github_token;
270
+ }
271
+ if (config.github_token_command) {
272
+ if (_cachedCommandToken !== null) {
273
+ logger.debug('Using GitHub token from github_token_command (cached)');
274
+ return _cachedCommandToken;
275
+ }
276
+ logger.debug(`Attempting GitHub token from command: ${config.github_token_command}`);
277
+ try {
278
+ const result = childProcess.execSync(config.github_token_command, {
279
+ encoding: 'utf8',
280
+ timeout: 5000,
281
+ stdio: ['pipe', 'pipe', 'ignore']
282
+ }).trim();
283
+ if (!result) {
284
+ logger.warn(`github_token_command did not produce a token (command: ${config.github_token_command})`);
285
+ return '';
286
+ }
287
+ logger.debug('Using GitHub token from github_token_command');
288
+ _cachedCommandToken = result;
289
+ return result;
290
+ } catch (error) {
291
+ logger.warn(`github_token_command failed (command: ${config.github_token_command}): ${error.message}`);
292
+ return '';
293
+ }
294
+ }
295
+ logger.debug('No GitHub token configured');
296
+ return '';
297
+ }
298
+
299
+ /**
300
+ * Resets the cached command token. Exported for testing only.
301
+ */
302
+ function _resetTokenCache() {
303
+ _cachedCommandToken = null;
261
304
  }
262
305
 
263
306
  /**
@@ -465,5 +508,6 @@ module.exports = {
465
508
  resolveMonorepoOptions,
466
509
  resolveDbName,
467
510
  warnIfDevModeWithoutDbName,
511
+ _resetTokenCache,
468
512
  DEFAULT_CHECKOUT_TIMEOUT_MS
469
513
  };
package/src/main.js CHANGED
@@ -318,7 +318,7 @@ CONFIG FILE:
318
318
 
319
319
  Example config:
320
320
  {
321
- "github_token": "ghp_your_token_here",
321
+ "github_token_command": "gh auth token",
322
322
  "port": 7247,
323
323
  "theme": "light",
324
324
  "debug_stream": false,
@@ -327,7 +327,10 @@ CONFIG FILE:
327
327
  }
328
328
 
329
329
  GITHUB TOKEN:
330
- Create a Personal Access Token at:
330
+ If you have the GitHub CLI (gh) installed and authenticated,
331
+ you're all set — the default github_token_command handles it.
332
+
333
+ Otherwise, create a Personal Access Token at:
331
334
  https://github.com/settings/tokens/new
332
335
 
333
336
  Required scopes:
@@ -336,7 +339,9 @@ GITHUB TOKEN:
336
339
 
337
340
  You can provide the token via:
338
341
  1. GITHUB_TOKEN environment variable (takes precedence)
339
- 2. github_token field in config file
342
+ 2. github_token field in config file (**deprecated**)
343
+ 3. github_token_command in config file (**preferred** for security, default: "gh auth token")
344
+ No secret stored in plain text. Works with gh CLI, 1Password CLI, pass, etc.
340
345
 
341
346
  ENVIRONMENT VARIABLES:
342
347
  GITHUB_TOKEN GitHub Personal Access Token (takes precedence over config file)
@@ -504,7 +509,7 @@ async function handlePullRequest(args, config, db, flags = {}) {
504
509
  // Get GitHub token (env var takes precedence over config)
505
510
  const githubToken = getGitHubToken(config);
506
511
  if (!githubToken) {
507
- throw new Error('GitHub token not found. Set GITHUB_TOKEN environment variable or run: npx pair-review --configure');
512
+ throw new Error('GitHub token not found. Set GITHUB_TOKEN env var, add github_token to config, or set github_token_command (e.g., "gh auth token"). Run: npx pair-review --configure');
508
513
  }
509
514
 
510
515
  // Parse PR arguments
@@ -600,7 +605,7 @@ async function performHeadlessReview(args, config, db, flags, options) {
600
605
  // Get GitHub token (env var takes precedence over config)
601
606
  const githubToken = getGitHubToken(config);
602
607
  if (!githubToken) {
603
- throw new Error('GitHub token not found. Set GITHUB_TOKEN environment variable or run: npx pair-review --configure');
608
+ throw new Error('GitHub token not found. Set GITHUB_TOKEN env var, add github_token to config, or set github_token_command (e.g., "gh auth token"). Run: npx pair-review --configure');
604
609
  }
605
610
 
606
611
  // Parse PR arguments
@@ -37,7 +37,7 @@ router.get('/api/config', (req, res) => {
37
37
  // Build chat_providers array with availability
38
38
  const chatAvailability = getAllCachedChatAvailability();
39
39
  const chatProviders = getAllChatProviders().map(p => ({
40
- id: p.id, name: p.name, available: chatAvailability[p.id]?.available || false
40
+ id: p.id, name: p.name, type: p.type, available: chatAvailability[p.id]?.available || false
41
41
  }));
42
42
 
43
43
  // Only return safe configuration values (not secrets like github_token)
package/src/routes/pr.js CHANGED
@@ -300,7 +300,11 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
300
300
  }
301
301
 
302
302
  // Fetch fresh PR data from GitHub
303
- const githubClient = new GitHubClient(config.github_token);
303
+ const githubToken = getGitHubToken(config);
304
+ if (!githubToken) {
305
+ return res.status(401).json({ error: 'GitHub token not configured' });
306
+ }
307
+ const githubClient = new GitHubClient(githubToken);
304
308
  const prData = await githubClient.fetchPullRequest(owner, repo, prNumber);
305
309
 
306
310
  // Update worktree with latest changes
@@ -467,7 +471,11 @@ router.get('/api/pr/:owner/:repo/:number/check-stale', async (req, res) => {
467
471
  }
468
472
 
469
473
  // Fetch current PR from GitHub
470
- const githubClient = new GitHubClient(config.github_token);
474
+ const githubToken = getGitHubToken(config);
475
+ if (!githubToken) {
476
+ return res.json({ isStale: null, error: 'GitHub token not configured' });
477
+ }
478
+ const githubClient = new GitHubClient(githubToken);
471
479
  const remotePrData = await githubClient.fetchPullRequest(owner, repo, prNumber);
472
480
 
473
481
  const remoteHeadSha = remotePrData.head_sha;