acdev 1.0.8 → 1.0.10
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/.acdev/.env.example +0 -3
- package/README.md +11 -21
- package/bin/acdev.js +3 -13
- package/package.json +1 -2
- package/public/app.js +71 -388
- package/public/index.html +19 -33
- package/public/styles.css +32 -31
- package/src/agent.js +64 -127
- package/src/config.js +45 -151
- package/src/git.js +62 -0
- package/src/models.js +35 -256
- package/src/server.js +72 -52
- package/src/store.js +2 -0
- package/src/openrouter-auth.js +0 -37
package/src/config.js
CHANGED
|
@@ -6,20 +6,15 @@ import { checkGhAuth, githubTokenFromEnv, originRemoteInfo } from './gh-auth.js'
|
|
|
6
6
|
import { normalizeJiraBaseUrl } from './jira.js';
|
|
7
7
|
import {
|
|
8
8
|
DEFAULT_MODEL,
|
|
9
|
-
DEFAULT_OPENROUTER_MODEL,
|
|
10
|
-
LLM_PROVIDERS,
|
|
11
9
|
NO_MODEL,
|
|
12
10
|
isValidModelId,
|
|
13
11
|
isNoModel,
|
|
14
|
-
|
|
15
|
-
defaultModelForProvider,
|
|
16
|
-
isModelIdForProvider,
|
|
12
|
+
isClaudeCatalogId,
|
|
17
13
|
curatedModelOptions,
|
|
18
14
|
} from './models.js';
|
|
19
|
-
import { checkOpenRouterAuth } from './openrouter-auth.js';
|
|
20
15
|
import { dataDir } from './paths.js';
|
|
21
16
|
|
|
22
|
-
export { DEFAULT_MODEL,
|
|
17
|
+
export { DEFAULT_MODEL, CLAUDE_MODEL_OPTIONS, MODEL_OPTIONS, NO_MODEL } from './models.js';
|
|
23
18
|
|
|
24
19
|
/** Tool names the agent may be granted via config. */
|
|
25
20
|
export const KNOWN_TOOLS = ['Read', 'Glob', 'Grep', 'Edit', 'Write', 'Bash'];
|
|
@@ -69,10 +64,7 @@ const DEFAULTS = {
|
|
|
69
64
|
maxAgentTurns: 30,
|
|
70
65
|
allowedTools: [...KNOWN_TOOLS],
|
|
71
66
|
agentTimeoutMs: 900_000,
|
|
72
|
-
llmProvider: /** @type {'claude' | 'openrouter'} */ ('claude'),
|
|
73
67
|
model: DEFAULT_MODEL,
|
|
74
|
-
/** Last valid model selection per LLM provider (for restore on provider switch). */
|
|
75
|
-
lastModelsByProvider: /** @type {Record<string, string>} */ ({}),
|
|
76
68
|
ticketSource: /** @type {'github' | 'jira'} */ ('github'),
|
|
77
69
|
jiraBaseUrl: '',
|
|
78
70
|
/** PR body phrase for Jira tickets, e.g. "Relates to PROJ-123". */
|
|
@@ -82,76 +74,29 @@ const DEFAULTS = {
|
|
|
82
74
|
};
|
|
83
75
|
|
|
84
76
|
/**
|
|
77
|
+
* Migrate legacy OpenRouter model ids to a Claude model.
|
|
85
78
|
* @param {unknown} raw
|
|
86
|
-
* @
|
|
87
|
-
*/
|
|
88
|
-
function normalizeLastModelsByProvider(raw) {
|
|
89
|
-
/** @type {Record<string, string>} */
|
|
90
|
-
const out = {};
|
|
91
|
-
if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
92
|
-
return out;
|
|
93
|
-
}
|
|
94
|
-
for (const provider of LLM_PROVIDERS) {
|
|
95
|
-
const value = /** @type {Record<string, unknown>} */ (raw)[provider];
|
|
96
|
-
if (typeof value !== 'string' || isNoModel(value) || !isValidModelId(value)) {
|
|
97
|
-
continue;
|
|
98
|
-
}
|
|
99
|
-
const id = value.trim();
|
|
100
|
-
if (isModelIdForProvider(id, provider)) {
|
|
101
|
-
out[provider] = id;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
return out;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* @param {object} config
|
|
109
|
-
* @param {'claude' | 'openrouter'} provider
|
|
110
|
-
* @param {string} model
|
|
111
|
-
*/
|
|
112
|
-
function rememberModelForProvider(config, provider, model) {
|
|
113
|
-
if (!isValidLlmProvider(provider)) return;
|
|
114
|
-
if (isNoModel(model) || !isValidModelId(model)) return;
|
|
115
|
-
const id = String(model).trim();
|
|
116
|
-
if (!isModelIdForProvider(id, provider)) return;
|
|
117
|
-
if (
|
|
118
|
-
!config.lastModelsByProvider ||
|
|
119
|
-
typeof config.lastModelsByProvider !== 'object' ||
|
|
120
|
-
config.lastModelsByProvider === DEFAULTS.lastModelsByProvider
|
|
121
|
-
) {
|
|
122
|
-
config.lastModelsByProvider = {
|
|
123
|
-
...(config.lastModelsByProvider &&
|
|
124
|
-
typeof config.lastModelsByProvider === 'object' &&
|
|
125
|
-
config.lastModelsByProvider !== DEFAULTS.lastModelsByProvider
|
|
126
|
-
? config.lastModelsByProvider
|
|
127
|
-
: {}),
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
config.lastModelsByProvider[provider] = id;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Pick model for a provider: preferred if in catalog, else last stored, else no selection.
|
|
135
|
-
* @param {object} config
|
|
136
|
-
* @param {'claude' | 'openrouter'} provider
|
|
137
|
-
* @param {string} [preferredModel]
|
|
79
|
+
* @param {string} currentModel
|
|
138
80
|
* @returns {string}
|
|
139
81
|
*/
|
|
140
|
-
function
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
typeof
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
82
|
+
function migrateLegacyModel(raw, currentModel) {
|
|
83
|
+
if (isNoModel(currentModel)) return NO_MODEL;
|
|
84
|
+
if (isClaudeCatalogId(currentModel)) return String(currentModel).trim();
|
|
85
|
+
|
|
86
|
+
const stored =
|
|
87
|
+
raw != null &&
|
|
88
|
+
typeof raw === 'object' &&
|
|
89
|
+
!Array.isArray(raw) &&
|
|
90
|
+
typeof /** @type {Record<string, unknown>} */ (raw).lastModelsByProvider === 'object'
|
|
91
|
+
? /** @type {Record<string, unknown>} */ (
|
|
92
|
+
/** @type {Record<string, unknown>} */ (raw).lastModelsByProvider
|
|
93
|
+
).claude
|
|
94
|
+
: null;
|
|
95
|
+
|
|
96
|
+
if (typeof stored === 'string' && isClaudeCatalogId(stored)) {
|
|
152
97
|
return stored.trim();
|
|
153
98
|
}
|
|
154
|
-
return
|
|
99
|
+
return DEFAULT_MODEL;
|
|
155
100
|
}
|
|
156
101
|
|
|
157
102
|
/**
|
|
@@ -294,9 +239,7 @@ function persistable(config) {
|
|
|
294
239
|
maxAgentTurns: config.maxAgentTurns,
|
|
295
240
|
allowedTools: config.allowedTools,
|
|
296
241
|
agentTimeoutMs: config.agentTimeoutMs,
|
|
297
|
-
llmProvider: isValidLlmProvider(config.llmProvider) ? config.llmProvider : 'claude',
|
|
298
242
|
model: config.model,
|
|
299
|
-
lastModelsByProvider: normalizeLastModelsByProvider(config.lastModelsByProvider),
|
|
300
243
|
ticketSource: config.ticketSource,
|
|
301
244
|
jiraBaseUrl: config.jiraBaseUrl || '',
|
|
302
245
|
jiraPrLinkPhrase: config.jiraPrLinkPhrase || 'Relates to',
|
|
@@ -336,11 +279,9 @@ export function loadConfig(repoRoot) {
|
|
|
336
279
|
const initial = {
|
|
337
280
|
...DEFAULTS,
|
|
338
281
|
allowedTools: [...KNOWN_TOOLS],
|
|
339
|
-
lastModelsByProvider: {},
|
|
340
282
|
jiraRules: structuredClone(DEFAULT_JIRA_RULES),
|
|
341
283
|
githubRules: structuredClone(DEFAULT_GITHUB_RULES),
|
|
342
284
|
};
|
|
343
|
-
rememberModelForProvider(initial, 'claude', initial.model);
|
|
344
285
|
fs.writeFileSync(configPath, JSON.stringify(persistable(initial), null, 2) + '\n', 'utf8');
|
|
345
286
|
return initial;
|
|
346
287
|
}
|
|
@@ -350,29 +291,34 @@ export function loadConfig(repoRoot) {
|
|
|
350
291
|
...DEFAULTS,
|
|
351
292
|
...raw,
|
|
352
293
|
allowedTools: raw.allowedTools ?? [...KNOWN_TOOLS],
|
|
353
|
-
lastModelsByProvider: normalizeLastModelsByProvider(raw.lastModelsByProvider),
|
|
354
294
|
jiraRules: normalizeJiraRules(raw.jiraRules),
|
|
355
295
|
githubRules: normalizeGithubRules(raw.githubRules),
|
|
356
296
|
};
|
|
357
297
|
|
|
298
|
+
let needsSave = false;
|
|
299
|
+
|
|
358
300
|
if (isNoModel(config.model)) {
|
|
359
301
|
if (String(config.model).trim() !== NO_MODEL) {
|
|
360
302
|
config.model = NO_MODEL;
|
|
361
|
-
|
|
303
|
+
needsSave = true;
|
|
362
304
|
} else {
|
|
363
305
|
config.model = NO_MODEL;
|
|
364
306
|
}
|
|
365
307
|
} else if (!isValidModelId(config.model)) {
|
|
366
|
-
config.model =
|
|
367
|
-
|
|
368
|
-
);
|
|
369
|
-
saveConfig(repoRoot, persistable(config));
|
|
308
|
+
config.model = DEFAULT_MODEL;
|
|
309
|
+
needsSave = true;
|
|
370
310
|
} else {
|
|
371
|
-
|
|
311
|
+
const migrated = migrateLegacyModel(raw, config.model);
|
|
312
|
+
if (migrated !== String(config.model).trim()) {
|
|
313
|
+
config.model = migrated;
|
|
314
|
+
needsSave = true;
|
|
315
|
+
} else {
|
|
316
|
+
config.model = String(config.model).trim();
|
|
317
|
+
}
|
|
372
318
|
}
|
|
373
319
|
|
|
374
|
-
if (
|
|
375
|
-
|
|
320
|
+
if (raw.llmProvider === 'openrouter' || raw.lastModelsByProvider != null) {
|
|
321
|
+
needsSave = true;
|
|
376
322
|
}
|
|
377
323
|
|
|
378
324
|
if (!ALLOWED_TICKET_SOURCES.has(config.ticketSource)) {
|
|
@@ -387,10 +333,9 @@ export function loadConfig(repoRoot) {
|
|
|
387
333
|
config.jiraPrLinkPhrase = 'Relates to';
|
|
388
334
|
}
|
|
389
335
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
rememberModelForProvider(config, loadedProvider, config.model);
|
|
336
|
+
if (needsSave) {
|
|
337
|
+
saveConfig(repoRoot, persistable(config));
|
|
338
|
+
}
|
|
394
339
|
|
|
395
340
|
return config;
|
|
396
341
|
}
|
|
@@ -404,23 +349,6 @@ export function loadConfig(repoRoot) {
|
|
|
404
349
|
* @returns {typeof DEFAULTS}
|
|
405
350
|
*/
|
|
406
351
|
export function updateConfig(repoRoot, config, patch) {
|
|
407
|
-
if (!config.lastModelsByProvider || typeof config.lastModelsByProvider !== 'object') {
|
|
408
|
-
config.lastModelsByProvider = normalizeLastModelsByProvider(config.lastModelsByProvider);
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
const prevProvider = isValidLlmProvider(config.llmProvider)
|
|
412
|
-
? config.llmProvider
|
|
413
|
-
: 'claude';
|
|
414
|
-
let providerChanged = false;
|
|
415
|
-
const willChangeProvider =
|
|
416
|
-
patch.llmProvider !== undefined &&
|
|
417
|
-
isValidLlmProvider(patch.llmProvider) &&
|
|
418
|
-
patch.llmProvider !== prevProvider;
|
|
419
|
-
|
|
420
|
-
if (willChangeProvider) {
|
|
421
|
-
rememberModelForProvider(config, prevProvider, config.model);
|
|
422
|
-
}
|
|
423
|
-
|
|
424
352
|
if (patch.model != null) {
|
|
425
353
|
if (isNoModel(patch.model)) {
|
|
426
354
|
config.model = NO_MODEL;
|
|
@@ -428,33 +356,13 @@ export function updateConfig(repoRoot, config, patch) {
|
|
|
428
356
|
throw new Error(
|
|
429
357
|
`Invalid model "${patch.model}". Expected a non-empty model id or "${NO_MODEL}" for no selection`
|
|
430
358
|
);
|
|
431
|
-
} else {
|
|
432
|
-
config.model = String(patch.model).trim();
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
if (patch.llmProvider !== undefined) {
|
|
437
|
-
if (!isValidLlmProvider(patch.llmProvider)) {
|
|
359
|
+
} else if (!isClaudeCatalogId(String(patch.model).trim())) {
|
|
438
360
|
throw new Error(
|
|
439
|
-
`Invalid
|
|
361
|
+
`Invalid model "${patch.model}". Expected a Claude model id (no provider/ prefix)`
|
|
440
362
|
);
|
|
363
|
+
} else {
|
|
364
|
+
config.model = String(patch.model).trim();
|
|
441
365
|
}
|
|
442
|
-
providerChanged = willChangeProvider;
|
|
443
|
-
config.llmProvider = patch.llmProvider;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
const currentProvider = isValidLlmProvider(config.llmProvider)
|
|
447
|
-
? config.llmProvider
|
|
448
|
-
: 'claude';
|
|
449
|
-
|
|
450
|
-
if (providerChanged) {
|
|
451
|
-
config.model = resolveModelForProvider(config, currentProvider, config.model);
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
if (patch.model != null && !providerChanged) {
|
|
455
|
-
rememberModelForProvider(config, currentProvider, config.model);
|
|
456
|
-
} else if (providerChanged && !isNoModel(config.model)) {
|
|
457
|
-
rememberModelForProvider(config, currentProvider, config.model);
|
|
458
366
|
}
|
|
459
367
|
|
|
460
368
|
if (patch.baseBranch !== undefined) {
|
|
@@ -545,18 +453,14 @@ export function updateConfig(repoRoot, config, patch) {
|
|
|
545
453
|
* stubAgent?: boolean,
|
|
546
454
|
* ghAuth?: import('./gh-auth.js').GhAuthResult,
|
|
547
455
|
* claudeAuth?: import('./claude-auth.js').ClaudeAuthResult,
|
|
548
|
-
* openrouterAuth?: import('./openrouter-auth.js').OpenRouterAuthResult,
|
|
549
456
|
* }} [opts]
|
|
550
457
|
*/
|
|
551
458
|
export function publicConfig(config, opts = {}) {
|
|
552
|
-
const llmProvider = isValidLlmProvider(config.llmProvider)
|
|
553
|
-
? config.llmProvider
|
|
554
|
-
: 'claude';
|
|
555
459
|
const model = isNoModel(config.model)
|
|
556
460
|
? NO_MODEL
|
|
557
|
-
: isValidModelId(config.model)
|
|
461
|
+
: isValidModelId(config.model) && isClaudeCatalogId(config.model)
|
|
558
462
|
? String(config.model).trim()
|
|
559
|
-
:
|
|
463
|
+
: DEFAULT_MODEL;
|
|
560
464
|
const repoName = opts.repoRoot ? path.basename(opts.repoRoot) : undefined;
|
|
561
465
|
const ticketSource = ALLOWED_TICKET_SOURCES.has(config.ticketSource)
|
|
562
466
|
? config.ticketSource
|
|
@@ -581,19 +485,15 @@ export function publicConfig(config, opts = {}) {
|
|
|
581
485
|
|
|
582
486
|
const ghAuth = opts.ghAuth ?? checkGhAuth();
|
|
583
487
|
const claudeAuth = opts.claudeAuth ?? checkClaudeAuth();
|
|
584
|
-
const openrouterAuth = opts.openrouterAuth ?? checkOpenRouterAuth();
|
|
585
488
|
const origin = originRemoteInfo(opts.repoRoot);
|
|
586
489
|
const ghTokenMask = maskSecret(githubTokenFromEnv());
|
|
587
490
|
const anthropicMask = maskSecret(process.env.ANTHROPIC_API_KEY);
|
|
588
491
|
const claudeOauthMask = maskSecret(process.env.CLAUDE_CODE_OAUTH_TOKEN);
|
|
589
|
-
const openrouterMask = maskSecret(process.env.OPENROUTER_API_KEY);
|
|
590
492
|
|
|
591
|
-
const curatedModels = curatedModelOptions(
|
|
493
|
+
const curatedModels = curatedModelOptions();
|
|
592
494
|
|
|
593
495
|
return {
|
|
594
|
-
llmProvider,
|
|
595
496
|
model,
|
|
596
|
-
lastModelsByProvider: normalizeLastModelsByProvider(config.lastModelsByProvider),
|
|
597
497
|
baseBranch: config.baseBranch,
|
|
598
498
|
testCommand: config.testCommand ?? null,
|
|
599
499
|
maxAgentTurns: config.maxAgentTurns,
|
|
@@ -625,13 +525,7 @@ export function publicConfig(config, opts = {}) {
|
|
|
625
525
|
anthropicApiKeyMasked: anthropicMask.masked,
|
|
626
526
|
claudeOauthTokenSet: claudeOauthMask.set,
|
|
627
527
|
claudeOauthTokenMasked: claudeOauthMask.masked,
|
|
628
|
-
|
|
629
|
-
openrouterApiKeySet: openrouterMask.set,
|
|
630
|
-
openrouterApiKeyMasked: openrouterMask.masked,
|
|
631
|
-
llmAuthOk:
|
|
632
|
-
llmProvider === 'openrouter'
|
|
633
|
-
? openrouterAuth.ok === true
|
|
634
|
-
: claudeAuth.ok === true,
|
|
528
|
+
llmAuthOk: claudeAuth.ok === true,
|
|
635
529
|
stubAgent: opts.stubAgent === true,
|
|
636
530
|
...(repoName ? { repoName } : {}),
|
|
637
531
|
};
|
package/src/git.js
CHANGED
|
@@ -103,6 +103,68 @@ export function buildBranchName(type, title) {
|
|
|
103
103
|
return `${type}/${slugifyTitle(title)}`;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
const PREFERRED_BRANCH_MAX = 100;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Sanitize a user-supplied git branch name. Preserves case (e.g. NC-2133).
|
|
110
|
+
* Empty / whitespace-only input returns ''.
|
|
111
|
+
* @param {unknown} raw
|
|
112
|
+
*/
|
|
113
|
+
export function normalizePreferredBranchName(raw) {
|
|
114
|
+
if (raw == null) return '';
|
|
115
|
+
let name = String(raw).trim().replace(/\s+/g, '-');
|
|
116
|
+
if (!name) return '';
|
|
117
|
+
|
|
118
|
+
name = name
|
|
119
|
+
.replace(/[~^:?*\[\\]+/g, '-')
|
|
120
|
+
.replace(/@{/g, '-at-')
|
|
121
|
+
.replace(/[^A-Za-z0-9._/-]+/g, '-')
|
|
122
|
+
.replace(/\/+/g, '/')
|
|
123
|
+
.replace(/\.{2,}/g, '.')
|
|
124
|
+
.replace(/-{2,}/g, '-')
|
|
125
|
+
.replace(/^[-./]+|[-./]+$/g, '');
|
|
126
|
+
|
|
127
|
+
if (/\.lock$/i.test(name)) {
|
|
128
|
+
name = name.replace(/\.lock$/i, '').replace(/[-./]+$/g, '');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
name = name.slice(0, PREFERRED_BRANCH_MAX).replace(/[-./]+$/g, '');
|
|
132
|
+
if (!name || name === '@' || /^HEAD$/i.test(name)) return '';
|
|
133
|
+
return name;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Parse optional branchName from an API body.
|
|
138
|
+
* Omitted / blank → no preference (caller uses feat/fix slug).
|
|
139
|
+
* Provided but invalid after sanitize → error.
|
|
140
|
+
* @param {unknown} raw
|
|
141
|
+
* @returns {{ ok: true, value?: string } | { ok: false, error: string }}
|
|
142
|
+
*/
|
|
143
|
+
export function parsePreferredBranchName(raw) {
|
|
144
|
+
if (raw == null) return { ok: true };
|
|
145
|
+
if (typeof raw !== 'string') {
|
|
146
|
+
return { ok: false, error: 'branchName must be a string' };
|
|
147
|
+
}
|
|
148
|
+
if (!raw.trim()) return { ok: true };
|
|
149
|
+
const value = normalizePreferredBranchName(raw);
|
|
150
|
+
if (!value) {
|
|
151
|
+
return { ok: false, error: 'branchName is not a valid git branch name' };
|
|
152
|
+
}
|
|
153
|
+
return { ok: true, value };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Prefer a user-supplied branch name; otherwise feat/fix slug from the ticket.
|
|
158
|
+
* @param {string | undefined | null} preferred
|
|
159
|
+
* @param {'feat' | 'fix'} issueType
|
|
160
|
+
* @param {string} issueTitle
|
|
161
|
+
*/
|
|
162
|
+
export function resolveDesiredBranchName(preferred, issueType, issueTitle) {
|
|
163
|
+
const custom = normalizePreferredBranchName(preferred);
|
|
164
|
+
if (custom) return custom;
|
|
165
|
+
return buildBranchName(issueType, issueTitle);
|
|
166
|
+
}
|
|
167
|
+
|
|
106
168
|
/**
|
|
107
169
|
* @param {string} repoRoot
|
|
108
170
|
* @param {string | number} worktreeId GitHub issue number or Jira key
|