acdev 1.0.2 → 1.0.4

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/jira.js CHANGED
@@ -416,3 +416,174 @@ export async function transitionJiraIssue(key, targetStatusName, creds) {
416
416
  statusName: match.to?.name || want,
417
417
  };
418
418
  }
419
+
420
+ /** Status names commonly used for “done / closed” when category is unavailable. */
421
+ const CLOSE_STATUS_NAME_HINTS = new Set([
422
+ 'done',
423
+ 'closed',
424
+ 'resolved',
425
+ 'complete',
426
+ 'completed',
427
+ ]);
428
+
429
+ /**
430
+ * Prefer a transition whose target statusCategory.key is `done`, else a
431
+ * well-known Done/Closed/Resolved-like status name (case-insensitive).
432
+ * @param {Array<{
433
+ * id?: string,
434
+ * name?: string,
435
+ * to?: { name?: string, statusCategory?: { key?: string, name?: string } },
436
+ * }>} transitions
437
+ * @returns {{ id: string, name?: string, to?: { name?: string } } | null}
438
+ */
439
+ export function findCloseTransition(transitions) {
440
+ const list = Array.isArray(transitions) ? transitions : [];
441
+ for (const t of list) {
442
+ if (!t || t.id == null) continue;
443
+ const catKey = String(t.to?.statusCategory?.key || '')
444
+ .trim()
445
+ .toLowerCase();
446
+ if (catKey === 'done') {
447
+ return /** @type {{ id: string, name?: string, to?: { name?: string } }} */ (t);
448
+ }
449
+ }
450
+ for (const t of list) {
451
+ if (!t || t.id == null) continue;
452
+ const toName = String(t.to?.name || '')
453
+ .trim()
454
+ .toLowerCase();
455
+ if (toName && CLOSE_STATUS_NAME_HINTS.has(toName)) {
456
+ return /** @type {{ id: string, name?: string, to?: { name?: string } }} */ (t);
457
+ }
458
+ }
459
+ return null;
460
+ }
461
+
462
+ /**
463
+ * Add a label to a Jira issue (REST update `labels` add op).
464
+ * @param {string} key
465
+ * @param {string} label
466
+ * @param {{
467
+ * baseUrl: string,
468
+ * email: string,
469
+ * apiToken: string,
470
+ * fetchFn?: typeof fetch,
471
+ * }} creds
472
+ * @returns {Promise<{ ok: true, key: string, label: string }>}
473
+ */
474
+ export async function addJiraIssueLabel(key, label, creds) {
475
+ const fetchFn = creds.fetchFn || fetch;
476
+ const base = normalizeJiraBaseUrl(creds.baseUrl);
477
+ const normalizedKey = String(key).toUpperCase();
478
+ const name = String(label || '').trim();
479
+ if (!name) {
480
+ throw new Error('Jira label name is required');
481
+ }
482
+
483
+ const headers = {
484
+ Authorization: jiraAuthHeader({
485
+ email: creds.email,
486
+ apiToken: creds.apiToken,
487
+ }),
488
+ Accept: 'application/json',
489
+ 'Content-Type': 'application/json',
490
+ };
491
+
492
+ const url = `${base}/rest/api/3/issue/${encodeURIComponent(normalizedKey)}`;
493
+ const res = await fetchFn(url, {
494
+ method: 'PUT',
495
+ headers,
496
+ body: JSON.stringify({
497
+ update: {
498
+ labels: [{ add: name }],
499
+ },
500
+ }),
501
+ });
502
+
503
+ if (!res.ok) {
504
+ const body = await res.text().catch(() => '');
505
+ const detail = body ? `: ${body.slice(0, 300)}` : '';
506
+ throw new Error(
507
+ `Failed to add Jira label "${name}" on ${normalizedKey} (${res.status} ${res.statusText})${detail}`
508
+ );
509
+ }
510
+
511
+ return { ok: true, key: normalizedKey, label: name };
512
+ }
513
+
514
+ /**
515
+ * Close a Jira issue by transitioning to a Done-category (or Done/Closed-like) status.
516
+ * There is no dedicated “close” REST endpoint; this reuses the transitions API.
517
+ * @param {string} key
518
+ * @param {{
519
+ * baseUrl: string,
520
+ * email: string,
521
+ * apiToken: string,
522
+ * fetchFn?: typeof fetch,
523
+ * }} creds
524
+ * @returns {Promise<{
525
+ * ok: true,
526
+ * key: string,
527
+ * transitionId: string,
528
+ * statusName: string,
529
+ * }>}
530
+ */
531
+ export async function closeJiraIssue(key, creds) {
532
+ const fetchFn = creds.fetchFn || fetch;
533
+ const base = normalizeJiraBaseUrl(creds.baseUrl);
534
+ const normalizedKey = String(key).toUpperCase();
535
+
536
+ const headers = {
537
+ Authorization: jiraAuthHeader({
538
+ email: creds.email,
539
+ apiToken: creds.apiToken,
540
+ }),
541
+ Accept: 'application/json',
542
+ 'Content-Type': 'application/json',
543
+ };
544
+
545
+ const listUrl = `${base}/rest/api/3/issue/${encodeURIComponent(normalizedKey)}/transitions`;
546
+ const listRes = await fetchFn(listUrl, { method: 'GET', headers });
547
+ if (!listRes.ok) {
548
+ const body = await listRes.text().catch(() => '');
549
+ const detail = body ? `: ${body.slice(0, 300)}` : '';
550
+ throw new Error(
551
+ `Failed to list Jira transitions for ${normalizedKey} (${listRes.status} ${listRes.statusText})${detail}`
552
+ );
553
+ }
554
+
555
+ const listData = await listRes.json();
556
+ const match = findCloseTransition(listData.transitions || []);
557
+ if (!match) {
558
+ const available = (listData.transitions || [])
559
+ .map((t) => t?.to?.name)
560
+ .filter(Boolean)
561
+ .join(', ');
562
+ throw new Error(
563
+ `No Jira close transition (Done/Closed-like status) for ${normalizedKey}` +
564
+ (available ? ` (available: ${available})` : '')
565
+ );
566
+ }
567
+
568
+ const postRes = await fetchFn(listUrl, {
569
+ method: 'POST',
570
+ headers,
571
+ body: JSON.stringify({ transition: { id: String(match.id) } }),
572
+ });
573
+
574
+ if (!postRes.ok) {
575
+ const body = await postRes.text().catch(() => '');
576
+ const detail = body ? `: ${body.slice(0, 300)}` : '';
577
+ const statusName = match.to?.name || 'Done';
578
+ throw new Error(
579
+ `Failed to close Jira issue ${normalizedKey} via "${statusName}" (${postRes.status} ${postRes.statusText})${detail}`
580
+ );
581
+ }
582
+
583
+ return {
584
+ ok: true,
585
+ key: normalizedKey,
586
+ transitionId: String(match.id),
587
+ statusName: match.to?.name || 'Done',
588
+ };
589
+ }
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
+ }