acdev 1.0.1 → 1.0.3

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/src/models.js ADDED
@@ -0,0 +1,366 @@
1
+ /**
2
+ * Resolve Claude model options for the Settings UI.
3
+ *
4
+ * Prefer Anthropic Models API (`GET /v1/models`) when an API key, auth token,
5
+ * or Claude Code OAuth credential is available. Claude Agent SDK and Claude CLI
6
+ * do not expose a list endpoint. Subscription-only login without a readable
7
+ * credential falls back to a curated Claude Code / Agent SDK list.
8
+ */
9
+
10
+ import { execFileSync } from 'node:child_process';
11
+ import { existsSync, readFileSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { join } from 'node:path';
14
+
15
+ /**
16
+ * Curated Claude Agent SDK / Claude Code model ids used as defaults + fallback.
17
+ * Prefer documented Code aliases and Anthropic API ids (not invented snapshots).
18
+ */
19
+ export const MODEL_OPTIONS = [
20
+ { id: 'claude-sonnet-5', label: 'Sonnet 5' },
21
+ { id: 'claude-opus-5', label: 'Opus 5' },
22
+ { id: 'claude-fable-5', label: 'Fable 5' },
23
+ { id: 'claude-haiku-4-5', label: 'Haiku 4.5' },
24
+ { id: 'sonnet', label: 'sonnet (latest)' },
25
+ { id: 'opus', label: 'opus (latest)' },
26
+ { id: 'haiku', label: 'haiku (latest)' },
27
+ { id: 'fable', label: 'fable (latest)' },
28
+ { id: 'best', label: 'best' },
29
+ { id: 'opusplan', label: 'opusplan' },
30
+ { id: 'claude-sonnet-4-6', label: 'Sonnet 4.6' },
31
+ { id: 'claude-opus-4-8', label: 'Opus 4.8' },
32
+ { id: 'claude-opus-4-7', label: 'Opus 4.7' },
33
+ { id: 'claude-opus-4-6', label: 'Opus 4.6' },
34
+ { id: 'claude-sonnet-4-5', label: 'Sonnet 4.5' },
35
+ { id: 'claude-opus-4-5', label: 'Opus 4.5' },
36
+ { id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5 (20251001)' },
37
+ { id: 'claude-sonnet-4-5-20250929', label: 'Sonnet 4.5 (20250929)' },
38
+ { id: 'claude-opus-4-5-20251101', label: 'Opus 4.5 (20251101)' },
39
+ ];
40
+
41
+ export const DEFAULT_MODEL = 'claude-sonnet-5';
42
+
43
+ /** Loose model id shape accepted by config (API ids + Claude Code aliases). */
44
+ export const MODEL_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/;
45
+
46
+ const ANTHROPIC_MODELS_URL = 'https://api.anthropic.com/v1/models';
47
+ const ANTHROPIC_VERSION = '2023-06-01';
48
+ const CACHE_TTL_MS = 5 * 60_000;
49
+ const KEYCHAIN_SERVICE = 'Claude Code-credentials';
50
+
51
+ /** @typedef {{ id: string, name?: string, label?: string }} ModelOption */
52
+ /** @typedef {'anthropic' | 'fallback'} ModelsSource */
53
+ /** @typedef {{ models: ModelOption[], selected: string, source: ModelsSource }} ModelsListResult */
54
+
55
+ /** @type {{ expiresAt: number, result: Omit<ModelsListResult, 'selected'> } | null} */
56
+ let cache = null;
57
+
58
+ /** @type {typeof fetch | null} */
59
+ let fetchImpl = null;
60
+
61
+ /** @type {() => NodeJS.ProcessEnv} */
62
+ let envResolver = () => process.env;
63
+
64
+ /** @type {() => string | null} */
65
+ let credentialsTokenResolver = defaultCredentialsTokenResolver;
66
+
67
+ /** @param {typeof fetch} fn */
68
+ export function _setFetchImpl(fn) {
69
+ fetchImpl = fn;
70
+ }
71
+
72
+ export function _resetFetchImpl() {
73
+ fetchImpl = null;
74
+ }
75
+
76
+ /** @param {() => NodeJS.ProcessEnv} fn */
77
+ export function _setEnvResolver(fn) {
78
+ envResolver = fn;
79
+ }
80
+
81
+ export function _resetEnvResolver() {
82
+ envResolver = () => process.env;
83
+ }
84
+
85
+ /** @param {() => string | null} fn */
86
+ export function _setCredentialsTokenResolver(fn) {
87
+ credentialsTokenResolver = fn;
88
+ }
89
+
90
+ export function _resetCredentialsTokenResolver() {
91
+ credentialsTokenResolver = defaultCredentialsTokenResolver;
92
+ }
93
+
94
+ export function _resetModelsCache() {
95
+ cache = null;
96
+ }
97
+
98
+ /**
99
+ * @param {unknown} value
100
+ * @returns {value is string}
101
+ */
102
+ export function isValidModelId(value) {
103
+ return typeof value === 'string' && MODEL_ID_RE.test(value.trim());
104
+ }
105
+
106
+ /**
107
+ * @param {string} id
108
+ * @param {string} [displayName]
109
+ * @returns {ModelOption}
110
+ */
111
+ function toOption(id, displayName) {
112
+ const name = (displayName || '').trim();
113
+ return name ? { id, name, label: name } : { id, label: id };
114
+ }
115
+
116
+ /**
117
+ * Merge curated options (stable order / aliases) with live Anthropic rows.
118
+ * Same ids keep curated position but prefer live display names.
119
+ * @param {ModelOption[]} curated
120
+ * @param {ModelOption[]} live
121
+ * @returns {ModelOption[]}
122
+ */
123
+ function mergeModelLists(curated, live) {
124
+ const liveById = new Map();
125
+ for (const m of live) {
126
+ if (m?.id) liveById.set(m.id, m);
127
+ }
128
+ const seen = new Set();
129
+ /** @type {ModelOption[]} */
130
+ const out = [];
131
+ for (const m of curated) {
132
+ if (!m?.id || seen.has(m.id)) continue;
133
+ seen.add(m.id);
134
+ const liveHit = liveById.get(m.id);
135
+ if (liveHit) {
136
+ const label = liveHit.label || liveHit.name || m.label || m.id;
137
+ const name = liveHit.name || liveHit.label || m.name;
138
+ out.push(name ? { id: m.id, name, label } : { id: m.id, label });
139
+ } else {
140
+ out.push({ ...m });
141
+ }
142
+ }
143
+ for (const m of live) {
144
+ if (!m?.id || seen.has(m.id)) continue;
145
+ seen.add(m.id);
146
+ out.push({ ...m });
147
+ }
148
+ return out;
149
+ }
150
+
151
+ /**
152
+ * @param {ModelOption[]} models
153
+ * @param {string} selected
154
+ * @returns {ModelOption[]}
155
+ */
156
+ function ensureSelected(models, selected) {
157
+ if (!selected || models.some((m) => m.id === selected)) return models;
158
+ return [toOption(selected), ...models];
159
+ }
160
+
161
+ /**
162
+ * Extract Claude Code OAuth access token from a credentials JSON blob.
163
+ * @param {string} raw
164
+ * @returns {string | null}
165
+ */
166
+ export function parseClaudeCodeOauthAccessToken(raw) {
167
+ try {
168
+ const parsed = JSON.parse(String(raw || '').trim());
169
+ const tok = parsed?.claudeAiOauth?.accessToken;
170
+ if (typeof tok === 'string' && tok.trim()) return tok.trim();
171
+ } catch {
172
+ // ignore
173
+ }
174
+ return null;
175
+ }
176
+
177
+ /**
178
+ * Read plaintext Claude Code credentials file (`~/.claude/.credentials.json`
179
+ * or `$CLAUDE_CONFIG_DIR/.credentials.json`).
180
+ * @returns {string | null}
181
+ */
182
+ function readClaudeCodeCredentialsFile() {
183
+ const env = envResolver();
184
+ /** @type {string[]} */
185
+ const dirs = [];
186
+ const configDir = (env.CLAUDE_CONFIG_DIR || '').trim();
187
+ if (configDir) dirs.push(configDir);
188
+ dirs.push(join(homedir(), '.claude'));
189
+
190
+ for (const dir of dirs) {
191
+ const path = join(dir, '.credentials.json');
192
+ try {
193
+ if (!existsSync(path)) continue;
194
+ const tok = parseClaudeCodeOauthAccessToken(readFileSync(path, 'utf8'));
195
+ if (tok) return tok;
196
+ } catch {
197
+ // ignore unreadable paths
198
+ }
199
+ }
200
+ return null;
201
+ }
202
+
203
+ /**
204
+ * Read Claude Code OAuth token from macOS Keychain (browser `claude auth login`).
205
+ * @returns {string | null}
206
+ */
207
+ function readClaudeCodeKeychainToken() {
208
+ if (process.platform !== 'darwin') return null;
209
+ try {
210
+ const raw = execFileSync(
211
+ 'security',
212
+ ['find-generic-password', '-s', KEYCHAIN_SERVICE, '-w'],
213
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
214
+ );
215
+ return parseClaudeCodeOauthAccessToken(raw);
216
+ } catch {
217
+ return null;
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Claude Code subscription OAuth token from local credential stores.
223
+ * @returns {string | null}
224
+ */
225
+ function defaultCredentialsTokenResolver() {
226
+ return readClaudeCodeCredentialsFile() || readClaudeCodeKeychainToken();
227
+ }
228
+
229
+ /**
230
+ * Build auth headers for Anthropic Models API from env or Claude Code login.
231
+ * @returns {{ headers: Record<string, string>, auth: string } | null}
232
+ */
233
+ function resolveAnthropicAuth() {
234
+ const env = envResolver();
235
+ const apiKey = (env.ANTHROPIC_API_KEY || '').trim();
236
+ if (apiKey) {
237
+ return {
238
+ auth: 'api-key',
239
+ headers: {
240
+ 'x-api-key': apiKey,
241
+ 'anthropic-version': ANTHROPIC_VERSION,
242
+ },
243
+ };
244
+ }
245
+ const bearer = (
246
+ env.ANTHROPIC_AUTH_TOKEN ||
247
+ env.CLAUDE_CODE_OAUTH_TOKEN ||
248
+ ''
249
+ ).trim();
250
+ if (bearer) {
251
+ return {
252
+ auth: 'bearer',
253
+ headers: {
254
+ Authorization: `Bearer ${bearer}`,
255
+ 'anthropic-version': ANTHROPIC_VERSION,
256
+ },
257
+ };
258
+ }
259
+
260
+ const stored = (credentialsTokenResolver() || '').trim();
261
+ if (stored) {
262
+ return {
263
+ auth: 'claude-code-login',
264
+ headers: {
265
+ Authorization: `Bearer ${stored}`,
266
+ 'anthropic-version': ANTHROPIC_VERSION,
267
+ },
268
+ };
269
+ }
270
+ return null;
271
+ }
272
+
273
+ /**
274
+ * @param {Response} res
275
+ * @returns {Promise<ModelOption[]>}
276
+ */
277
+ async function parseModelsResponse(res) {
278
+ if (!res.ok) {
279
+ throw new Error(`Anthropic Models API HTTP ${res.status}`);
280
+ }
281
+ const body = await res.json();
282
+ const rows = Array.isArray(body?.data) ? body.data : [];
283
+ /** @type {ModelOption[]} */
284
+ const models = [];
285
+ for (const row of rows) {
286
+ const id = typeof row?.id === 'string' ? row.id.trim() : '';
287
+ if (!isValidModelId(id)) continue;
288
+ const display =
289
+ typeof row.display_name === 'string'
290
+ ? row.display_name
291
+ : typeof row.name === 'string'
292
+ ? row.name
293
+ : '';
294
+ models.push(toOption(id, display));
295
+ }
296
+ if (models.length === 0) {
297
+ throw new Error('Anthropic Models API returned no models');
298
+ }
299
+ return models;
300
+ }
301
+
302
+ /**
303
+ * Fetch live models from Anthropic (no cache).
304
+ * @returns {Promise<ModelOption[]>}
305
+ */
306
+ export async function fetchAnthropicModels() {
307
+ const auth = resolveAnthropicAuth();
308
+ if (!auth) {
309
+ throw new Error('No Anthropic API credentials for models list');
310
+ }
311
+ const doFetch = fetchImpl || globalThis.fetch;
312
+ if (typeof doFetch !== 'function') {
313
+ throw new Error('fetch is not available');
314
+ }
315
+ const res = await doFetch(ANTHROPIC_MODELS_URL, {
316
+ method: 'GET',
317
+ headers: auth.headers,
318
+ });
319
+ return parseModelsResponse(res);
320
+ }
321
+
322
+ /**
323
+ * List models for the UI.
324
+ * @param {{ selected?: string, force?: boolean }} [opts]
325
+ * @returns {Promise<ModelsListResult>}
326
+ */
327
+ export async function listModels(opts = {}) {
328
+ const selectedRaw = opts.selected;
329
+ const selected = isValidModelId(selectedRaw)
330
+ ? String(selectedRaw).trim()
331
+ : DEFAULT_MODEL;
332
+ const force = opts.force === true;
333
+ const now = Date.now();
334
+
335
+ if (!force && cache && cache.expiresAt > now) {
336
+ return {
337
+ ...cache.result,
338
+ models: ensureSelected(cache.result.models, selected),
339
+ selected,
340
+ };
341
+ }
342
+
343
+ try {
344
+ const live = await fetchAnthropicModels();
345
+ // Keep curated Claude Code aliases available alongside API ids.
346
+ const models = ensureSelected(
347
+ mergeModelLists(
348
+ MODEL_OPTIONS.map((m) => ({ ...m })),
349
+ live
350
+ ),
351
+ selected
352
+ );
353
+ const result = { models, source: /** @type {ModelsSource} */ ('anthropic') };
354
+ cache = { expiresAt: now + CACHE_TTL_MS, result };
355
+ return { ...result, selected };
356
+ } catch {
357
+ const models = ensureSelected(
358
+ MODEL_OPTIONS.map((m) => ({ ...m })),
359
+ selected
360
+ );
361
+ const result = { models, source: /** @type {ModelsSource} */ ('fallback') };
362
+ // Short cache on fallback so we retry Anthropic soon after auth is saved.
363
+ cache = { expiresAt: now + 30_000, result };
364
+ return { ...result, selected };
365
+ }
366
+ }
package/src/server.js CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  } from './agent.js';
34
34
  import { publicConfig, updateConfig } from './config.js';
35
35
  import { upsertEnvVars } from './env.js';
36
+ import { listModels } from './models.js';
36
37
  import { splitIssueUrls } from './urls.js';
37
38
  import { usageFromLogs, withJobUsage } from './usage.js';
38
39
 
@@ -76,6 +77,23 @@ function jobDedupeKey(job) {
76
77
  }
77
78
  return `github:${job.issueUrl}`;
78
79
  }
80
+
81
+ /**
82
+ * Empty string = leave the existing secret unchanged; null = clear.
83
+ * @param {Record<string, string | null>} envPatch
84
+ * @param {string} envKey
85
+ * @param {unknown} value
86
+ */
87
+ function applySecretField(envPatch, envKey, value) {
88
+ if (value === undefined) return;
89
+ if (value === null) {
90
+ envPatch[envKey] = null;
91
+ return;
92
+ }
93
+ if (typeof value === 'string' && value.trim()) {
94
+ envPatch[envKey] = value.trim();
95
+ }
96
+ }
79
97
  /**
80
98
  * Normalize + validate POST /api/jobs/:id/review body.
81
99
  * @param {unknown} body
@@ -146,6 +164,8 @@ export function normalizeReviewComments(body) {
146
164
  * listChangedFiles?: typeof listChangedFiles,
147
165
  * applyFileExclusions?: typeof applyFileExclusions,
148
166
  * transitionJiraIssue?: Function,
167
+ * addJiraIssueLabel?: Function,
168
+ * closeJiraIssue?: Function,
149
169
  * addIssueLabel?: Function,
150
170
  * closeIssue?: Function,
151
171
  * resolveJiraCredentials?: Function,
@@ -447,11 +467,25 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
447
467
  }
448
468
  });
449
469
 
470
+ app.get('/api/models', async (req, res) => {
471
+ try {
472
+ const force =
473
+ req.query.refresh === '1' ||
474
+ req.query.refresh === 'true' ||
475
+ req.query.force === '1';
476
+ const result = await listModels({ selected: config.model, force });
477
+ res.json(result);
478
+ } catch (err) {
479
+ res.status(500).json({ error: err.message });
480
+ }
481
+ });
482
+
450
483
  app.patch('/api/config', (req, res) => {
451
484
  try {
452
485
  const patch = req.body || {};
453
486
 
454
- // Jira secrets → .env (never persisted in config.json)
487
+ // Secrets → .acdev/.env (never persisted in config.json).
488
+ // Empty string = leave unchanged; null = clear.
455
489
  /** @type {Record<string, string | null>} */
456
490
  const envPatch = {};
457
491
  if (patch.jiraEmail !== undefined) {
@@ -459,16 +493,10 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
459
493
  typeof patch.jiraEmail === 'string' ? patch.jiraEmail.trim() : '';
460
494
  envPatch.JIRA_EMAIL = email || null;
461
495
  }
462
- if (patch.jiraApiToken !== undefined) {
463
- const token =
464
- typeof patch.jiraApiToken === 'string' ? patch.jiraApiToken.trim() : '';
465
- // Empty string means "leave unchanged" when token already set — only clear if null
466
- if (patch.jiraApiToken === null) {
467
- envPatch.JIRA_API_TOKEN = null;
468
- } else if (token) {
469
- envPatch.JIRA_API_TOKEN = token;
470
- }
471
- }
496
+ applySecretField(envPatch, 'JIRA_API_TOKEN', patch.jiraApiToken);
497
+ applySecretField(envPatch, 'GH_TOKEN', patch.ghToken);
498
+ applySecretField(envPatch, 'ANTHROPIC_API_KEY', patch.anthropicApiKey);
499
+ applySecretField(envPatch, 'CLAUDE_CODE_OAUTH_TOKEN', patch.claudeOauthToken);
472
500
  if (patch.jiraBaseUrl !== undefined && typeof patch.jiraBaseUrl === 'string') {
473
501
  // Also mirror base URL into env for convenience when set via Settings
474
502
  const trimmed = patch.jiraBaseUrl.trim();
@@ -483,6 +511,9 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
483
511
  const {
484
512
  jiraEmail: _e,
485
513
  jiraApiToken: _t,
514
+ ghToken: _gh,
515
+ anthropicApiKey: _ak,
516
+ claudeOauthToken: _oa,
486
517
  ...configPatch
487
518
  } = patch;
488
519
  updateConfig(repoRoot, config, configPatch);
@@ -814,6 +845,8 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
814
845
  appendLog: (j, type, payload) => appendLog(j, type, payload),
815
846
  deps: {
816
847
  transitionJiraIssue: deps.transitionJiraIssue,
848
+ addJiraIssueLabel: deps.addJiraIssueLabel,
849
+ closeJiraIssue: deps.closeJiraIssue,
817
850
  addIssueLabel: deps.addIssueLabel,
818
851
  closeIssue: deps.closeIssue,
819
852
  resolveJiraCredentials: deps.resolveJiraCredentials,