acdev 1.0.12 → 1.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acdev",
3
- "version": "1.0.12",
3
+ "version": "1.0.13",
4
4
  "description": "Local CLI + web UI for running AI agents on GitHub issues via git worktrees",
5
5
  "type": "module",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -344,6 +344,7 @@ const els = {
344
344
  settingsOpenrouterKeyClear: document.getElementById('settings-openrouter-key-clear'),
345
345
  settingsOpenrouterKeyHint: document.getElementById('settings-openrouter-key-hint'),
346
346
  settingsLlmProvider: document.getElementById('settings-llm-provider'),
347
+ settingsLlmProviderHint: document.getElementById('settings-llm-provider-hint'),
347
348
  overviewLlmProvider: document.getElementById('overview-llm-provider'),
348
349
  sidebarAgentLabel: document.getElementById('sidebar-agent-label'),
349
350
  settingsTabs: document.getElementById('settings-tabs'),
@@ -3973,7 +3974,7 @@ function fillSettingsForm(cfg) {
3973
3974
  fillAuthSettings(cfg);
3974
3975
 
3975
3976
  updateTicketSourceUI(cfg.ticketSource === 'jira' ? 'jira' : 'github');
3976
- updateLlmProviderUI(cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude');
3977
+ updateLlmProviderUI(cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude', cfg);
3977
3978
 
3978
3979
  if (els.settingsJiraBaseUrl) {
3979
3980
  els.settingsJiraBaseUrl.value = cfg.jiraBaseUrl || '';
@@ -4202,18 +4203,74 @@ function updateTicketSourceUI(source) {
4202
4203
 
4203
4204
  /**
4204
4205
  * @param {'claude' | 'openrouter'} provider
4206
+ * @param {typeof appConfig} [cfg]
4205
4207
  */
4206
- function updateLlmProviderUI(provider) {
4208
+ function llmProviderReady(provider, cfg = appConfig) {
4209
+ if (cfg?.stubAgent) return true;
4210
+ if (provider === 'openrouter') return cfg?.openrouterAuthOk === true;
4211
+ return cfg?.claudeAuthOk === true;
4212
+ }
4213
+
4214
+ /**
4215
+ * @param {'claude' | 'openrouter'} provider
4216
+ */
4217
+ function llmProviderUnavailableReason(provider) {
4218
+ if (provider === 'openrouter') {
4219
+ return 'OpenRouter is not configured. Add an API key in Settings → Authentication first.';
4220
+ }
4221
+ return 'Claude is not configured. Add an API key or OAuth token in Settings → Authentication, or run claude auth login.';
4222
+ }
4223
+
4224
+ const LLM_PROVIDER_HINT_DEFAULT =
4225
+ 'Claude uses the Claude Agent SDK. OpenRouter uses <code>@openrouter/agent</code> with the same coding tools (any catalog model). A provider stays disabled until it is configured and authenticated.';
4226
+
4227
+ /**
4228
+ * @param {typeof appConfig} [cfg]
4229
+ */
4230
+ function updateLlmProviderHint(cfg = appConfig) {
4231
+ if (!els.settingsLlmProviderHint) return;
4232
+ if (cfg?.stubAgent) {
4233
+ els.settingsLlmProviderHint.innerHTML = LLM_PROVIDER_HINT_DEFAULT;
4234
+ return;
4235
+ }
4236
+ const missing = [];
4237
+ if (!llmProviderReady('claude', cfg)) {
4238
+ missing.push('Claude (API key, OAuth token, or <code>claude auth login</code>)');
4239
+ }
4240
+ if (!llmProviderReady('openrouter', cfg)) {
4241
+ missing.push('OpenRouter (API key in Authentication)');
4242
+ }
4243
+ if (!missing.length) {
4244
+ els.settingsLlmProviderHint.innerHTML = LLM_PROVIDER_HINT_DEFAULT;
4245
+ return;
4246
+ }
4247
+ els.settingsLlmProviderHint.innerHTML = `Cannot enable a provider until it is configured and authenticated. Missing: ${missing.join('; ')}.`;
4248
+ }
4249
+
4250
+ /**
4251
+ * @param {'claude' | 'openrouter'} provider
4252
+ * @param {typeof appConfig} [cfg]
4253
+ */
4254
+ function updateLlmProviderUI(provider, cfg = appConfig) {
4207
4255
  llmProvider = provider === 'openrouter' ? 'openrouter' : 'claude';
4208
4256
 
4209
4257
  for (const toggle of [els.settingsLlmProvider, els.overviewLlmProvider]) {
4210
4258
  if (!toggle) continue;
4211
4259
  toggle.querySelectorAll('.source-btn').forEach((btn) => {
4212
- const active = btn.dataset.provider === llmProvider;
4260
+ const id = btn.dataset.provider === 'openrouter' ? 'openrouter' : 'claude';
4261
+ const active = id === llmProvider;
4262
+ const ready = llmProviderReady(id, cfg);
4213
4263
  btn.classList.toggle('active', active);
4214
4264
  btn.setAttribute('aria-pressed', active ? 'true' : 'false');
4265
+ btn.disabled = !ready;
4266
+ if (!ready) {
4267
+ btn.title = llmProviderUnavailableReason(id);
4268
+ } else {
4269
+ btn.removeAttribute('title');
4270
+ }
4215
4271
  });
4216
4272
  }
4273
+ updateLlmProviderHint(cfg);
4217
4274
  updateModelLabel();
4218
4275
  }
4219
4276
 
@@ -4240,6 +4297,13 @@ async function saveTicketSource(next) {
4240
4297
 
4241
4298
  async function saveLlmProvider(next) {
4242
4299
  const provider = next === 'openrouter' ? 'openrouter' : 'claude';
4300
+ if (!llmProviderReady(provider)) {
4301
+ const msg = llmProviderUnavailableReason(provider);
4302
+ setOverviewLlmFeedback(msg, 'error');
4303
+ setSettingsFeedback(msg, 'error');
4304
+ return;
4305
+ }
4306
+ const prev = llmProvider;
4243
4307
  updateLlmProviderUI(provider);
4244
4308
  clearAvailableModels();
4245
4309
  try {
@@ -4249,11 +4313,18 @@ async function saveLlmProvider(next) {
4249
4313
  body: JSON.stringify({ llmProvider: provider }),
4250
4314
  });
4251
4315
  const data = await readJson(res);
4252
- if (res.ok) {
4253
- applyConfigSnapshot({ ...appConfig, ...data });
4316
+ if (!res.ok) {
4317
+ updateLlmProviderUI(prev);
4318
+ const msg = data.error || `Update failed (HTTP ${res.status})`;
4319
+ setOverviewLlmFeedback(msg, 'error');
4320
+ setSettingsFeedback(msg, 'error');
4254
4321
  await fetchModels({ refresh: true, reconcile: true });
4322
+ return;
4255
4323
  }
4324
+ applyConfigSnapshot({ ...appConfig, ...data });
4325
+ await fetchModels({ refresh: true, reconcile: true });
4256
4326
  } catch {
4327
+ updateLlmProviderUI(prev);
4257
4328
  void fetchModels({ refresh: true, reconcile: true });
4258
4329
  }
4259
4330
  }
@@ -4285,7 +4356,7 @@ function setOverviewLlmFeedback(message, kind = 'ok') {
4285
4356
  */
4286
4357
  function fillOverviewLlmControls(cfg) {
4287
4358
  if (!cfg) return;
4288
- updateLlmProviderUI(cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude');
4359
+ updateLlmProviderUI(cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude', cfg);
4289
4360
  syncAllModelComboboxValues(currentModel);
4290
4361
  }
4291
4362
 
@@ -4333,7 +4404,10 @@ function applyConfigSnapshot(data) {
4333
4404
  updateModelLabel();
4334
4405
  fillOverviewLlmControls(data);
4335
4406
  updateTicketSourceUI(data?.ticketSource === 'jira' ? 'jira' : 'github');
4336
- updateLlmProviderUI(data?.llmProvider === 'openrouter' ? 'openrouter' : 'claude');
4407
+ updateLlmProviderUI(
4408
+ data?.llmProvider === 'openrouter' ? 'openrouter' : 'claude',
4409
+ data || appConfig
4410
+ );
4337
4411
  if (els.repoName) {
4338
4412
  els.repoName.textContent = data?.repoName || 'local repo';
4339
4413
  }
@@ -4730,6 +4804,12 @@ function handleLlmProviderToggleClick(e) {
4730
4804
  const next = btn.dataset.provider === 'openrouter' ? 'openrouter' : 'claude';
4731
4805
  if (next === llmProvider) return;
4732
4806
  e.stopPropagation();
4807
+ if (btn.disabled || !llmProviderReady(next)) {
4808
+ const msg = llmProviderUnavailableReason(next);
4809
+ setOverviewLlmFeedback(msg, 'error');
4810
+ setSettingsFeedback(msg, 'error');
4811
+ return;
4812
+ }
4733
4813
  void saveLlmProvider(next);
4734
4814
  }
4735
4815
 
package/public/index.html CHANGED
@@ -669,7 +669,7 @@
669
669
  <button type="button" class="source-btn active" data-provider="claude">Claude</button>
670
670
  <button type="button" class="source-btn" data-provider="openrouter">OpenRouter</button>
671
671
  </div>
672
- <p class="field-hint">Claude uses the Claude Agent SDK. OpenRouter uses <code>@openrouter/agent</code> with the same coding tools (any catalog model).</p>
672
+ <p class="field-hint" id="settings-llm-provider-hint">Claude uses the Claude Agent SDK. OpenRouter uses <code>@openrouter/agent</code> with the same coding tools (any catalog model). A provider stays disabled until it is configured and authenticated.</p>
673
673
  </div>
674
674
  <div class="settings-grid">
675
675
  <div class="settings-field">
package/public/styles.css CHANGED
@@ -2475,7 +2475,7 @@ body.diff-fs-open {
2475
2475
  letter-spacing: 0.02em;
2476
2476
  }
2477
2477
 
2478
- .source-btn:hover:not(.active) {
2478
+ .source-btn:hover:not(.active):not(:disabled) {
2479
2479
  color: var(--text);
2480
2480
  background: color-mix(in srgb, var(--text) 4%, transparent);
2481
2481
  }
@@ -2487,6 +2487,16 @@ body.diff-fs-open {
2487
2487
  box-shadow: var(--shadow-sm);
2488
2488
  }
2489
2489
 
2490
+ .source-btn:disabled {
2491
+ opacity: 0.45;
2492
+ cursor: not-allowed;
2493
+ }
2494
+
2495
+ .source-btn.active:disabled {
2496
+ opacity: 0.7;
2497
+ cursor: default;
2498
+ }
2499
+
2490
2500
  .enqueue-jira-hint {
2491
2501
  margin: 0;
2492
2502
  font-size: 12px;
package/src/config.js CHANGED
@@ -91,6 +91,42 @@ export function normalizeLlmProvider(value) {
91
91
  return value === 'openrouter' ? 'openrouter' : 'claude';
92
92
  }
93
93
 
94
+ /**
95
+ * Block enabling an LLM provider that is not authenticated.
96
+ * Stub-agent skips the check (UI-only testing without credentials).
97
+ * @param {unknown} nextProvider
98
+ * @param {{
99
+ * stubAgent?: boolean,
100
+ * claudeAuth?: import('./claude-auth.js').ClaudeAuthResult,
101
+ * openrouterAuth?: import('./openrouter-auth.js').OpenRouterAuthResult,
102
+ * }} [opts]
103
+ * @returns {{ error: string, code: string } | null}
104
+ */
105
+ export function llmProviderAuthGate(nextProvider, opts = {}) {
106
+ if (opts.stubAgent === true) return null;
107
+ const provider = normalizeLlmProvider(nextProvider);
108
+ if (provider === 'openrouter') {
109
+ const or = opts.openrouterAuth ?? checkOpenRouterAuth();
110
+ if (!or.ok) {
111
+ return {
112
+ error:
113
+ 'OpenRouter is not authenticated. Add an API key in Settings → Authentication before enabling OpenRouter.',
114
+ code: 'openrouter_auth_required',
115
+ };
116
+ }
117
+ return null;
118
+ }
119
+ const claude = opts.claudeAuth ?? checkClaudeAuth();
120
+ if (!claude.ok) {
121
+ return {
122
+ error:
123
+ 'Claude is not authenticated. Add an API key or OAuth token in Settings → Authentication, or run claude auth login, before enabling Claude.',
124
+ code: 'claude_auth_required',
125
+ };
126
+ }
127
+ return null;
128
+ }
129
+
94
130
  /**
95
131
  * @param {unknown} raw
96
132
  * @returns {{ claude?: string, openrouter?: string }}
package/src/server.js CHANGED
@@ -33,7 +33,12 @@ import {
33
33
  runAgentOnReviewFeedback,
34
34
  stripAiAttribution,
35
35
  } from './agent.js';
36
- import { publicConfig, updateConfig, normalizeLlmProvider } from './config.js';
36
+ import {
37
+ publicConfig,
38
+ updateConfig,
39
+ normalizeLlmProvider,
40
+ llmProviderAuthGate,
41
+ } from './config.js';
37
42
  import { upsertEnvVars } from './env.js';
38
43
  import { listModels } from './models.js';
39
44
  import { splitIssueUrls } from './urls.js';
@@ -682,6 +687,21 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
682
687
  openrouterApiKey: _or,
683
688
  ...configPatch
684
689
  } = patch;
690
+
691
+ if (configPatch.llmProvider === 'claude' || configPatch.llmProvider === 'openrouter') {
692
+ const next = configPatch.llmProvider;
693
+ if (next !== normalizeLlmProvider(config.llmProvider)) {
694
+ const gate = llmProviderAuthGate(next, {
695
+ stubAgent: useStubAgent,
696
+ claudeAuth: doCheckClaudeAuth(),
697
+ openrouterAuth: checkOpenRouterAuth(),
698
+ });
699
+ if (gate) {
700
+ return res.status(400).json(gate);
701
+ }
702
+ }
703
+ }
704
+
685
705
  updateConfig(repoRoot, config, configPatch);
686
706
  res.json(publicConfigPayload());
687
707
  } catch (err) {