@zgeoff/atc 2.36.1 → 2.38.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.
- package/README.md +2 -1
- package/package.json +1 -1
- package/src/agents/agent-adapter.ts +6 -1
- package/src/agents/build-agent-adapters.ts +32 -0
- package/src/agents/build-claude-config-seed.ts +7 -4
- package/src/agents/build-claude-mcp-config.ts +27 -0
- package/src/agents/claude-adapter.ts +157 -66
- package/src/agents/codex-adapter.ts +17 -11
- package/src/agents/gateway-adapter.ts +17 -1
- package/src/agents/grok-adapter.ts +15 -9
- package/src/build-migrated-config.ts +196 -0
- package/src/cli.ts +49 -17
- package/src/client/collect-agent-picks.ts +3 -10
- package/src/client/index.ts +1 -1
- package/src/client/resolve-agent-metadata.ts +4 -10
- package/src/client/spawn-picker.ts +1 -2
- package/src/daemon/build-auth-binding.ts +2 -1
- package/src/daemon/daemon-connection.ts +1 -1
- package/src/daemon/daemon.ts +12 -3
- package/src/daemon/sessions.ts +8 -2
- package/src/mcp/build-spawn-descriptions.ts +2 -2
- package/src/run-config-migrate.ts +85 -0
- package/src/shared/check-gateway-auth.ts +173 -0
- package/src/shared/collect-agents.ts +349 -0
- package/src/shared/collect-claude-auth.ts +125 -10
- package/src/shared/collect-gateways.ts +2 -168
- package/src/shared/collect-legacy-agents.ts +80 -0
- package/src/shared/config.ts +70 -60
- package/src/shared/format-mixed-agent-keys.ts +10 -0
- package/src/shared/is-subscription-override-variable.ts +14 -0
- package/src/shared/legacy-agent-keys.ts +15 -0
- package/src/shared/outranking-variables.ts +17 -0
- package/src/shared/pick-default-agent.ts +12 -0
|
@@ -2,14 +2,28 @@ import type { AuthProfile } from './collect-auth-profiles';
|
|
|
2
2
|
import { isRecord } from './report';
|
|
3
3
|
import { resolveAuthProfiles } from './resolve-auth-profiles';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* An MCP server a stock Claude session on a target that reaches impd's
|
|
7
|
+
* broker reaches over HTTP, with the header that one of the session's
|
|
8
|
+
* profiles sets for the server's host. The session sends a placeholder in
|
|
9
|
+
* that header, and impd swaps the credential in on the host's side.
|
|
10
|
+
*/
|
|
11
|
+
export interface ClaudeMCPServer {
|
|
12
|
+
readonly name: string;
|
|
13
|
+
readonly url: string;
|
|
14
|
+
readonly profile: string;
|
|
15
|
+
readonly header: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
5
18
|
/**
|
|
6
19
|
* The profiles a stock Claude session on a target that reaches impd's
|
|
7
20
|
* broker signs in through: one of them sends the subscription's setup
|
|
8
21
|
* token to the Anthropic API as a bearer authorization header, and the
|
|
9
|
-
* rest bind beside it, such as a GitHub token.
|
|
22
|
+
* rest bind beside it, such as a GitHub token or an MCP server's key.
|
|
10
23
|
*/
|
|
11
|
-
|
|
24
|
+
interface ClaudeAuth {
|
|
12
25
|
readonly profiles: readonly string[];
|
|
26
|
+
readonly mcpServers: readonly ClaudeMCPServer[];
|
|
13
27
|
}
|
|
14
28
|
|
|
15
29
|
interface CollectedClaudeAuth {
|
|
@@ -18,15 +32,18 @@ interface CollectedClaudeAuth {
|
|
|
18
32
|
}
|
|
19
33
|
|
|
20
34
|
/**
|
|
21
|
-
* Reads
|
|
35
|
+
* Reads a Claude subscription auth entry against the auth profiles, `field`
|
|
36
|
+
* being the key it was set under in error text. An entry that
|
|
22
37
|
* does not resolve, or whose profiles send no bearer authorization header
|
|
23
38
|
* to the Anthropic API, is left out with every problem that refused it, so
|
|
24
39
|
* stock Claude keeps the sign-in of the host it runs on rather than binding
|
|
25
|
-
* a credential the CLI would never send.
|
|
40
|
+
* a credential the CLI would never send. An MCP server that breaks a rule
|
|
41
|
+
* is left out alone, with an error, and the sign-in stays.
|
|
26
42
|
*/
|
|
27
43
|
export function collectClaudeAuth(
|
|
28
44
|
raw: unknown,
|
|
29
45
|
authProfiles: ReadonlyMap<string, AuthProfile>,
|
|
46
|
+
field = 'claudeAuth',
|
|
30
47
|
): CollectedClaudeAuth {
|
|
31
48
|
if (raw === undefined) {
|
|
32
49
|
return { auth: null, errors: [] };
|
|
@@ -42,17 +59,17 @@ export function collectClaudeAuth(
|
|
|
42
59
|
) {
|
|
43
60
|
return {
|
|
44
61
|
auth: null,
|
|
45
|
-
errors: [
|
|
62
|
+
errors: [`${field} must be an object with a non-empty profiles array`],
|
|
46
63
|
};
|
|
47
64
|
}
|
|
48
65
|
|
|
49
|
-
const extra = Object.keys(raw).find((key) => key !== 'profiles');
|
|
66
|
+
const extra = Object.keys(raw).find((key) => key !== 'profiles' && key !== 'mcpServers');
|
|
50
67
|
|
|
51
68
|
if (extra !== undefined) {
|
|
52
69
|
return {
|
|
53
70
|
auth: null,
|
|
54
71
|
errors: [
|
|
55
|
-
|
|
72
|
+
`${field}.${extra} cannot be set: atc fixes the endpoint and the placeholder of a Claude subscription session`,
|
|
56
73
|
],
|
|
57
74
|
};
|
|
58
75
|
}
|
|
@@ -61,7 +78,7 @@ export function collectClaudeAuth(
|
|
|
61
78
|
const resolution = resolveAuthProfiles(authProfiles, selected);
|
|
62
79
|
|
|
63
80
|
if ('problem' in resolution) {
|
|
64
|
-
return { auth: null, errors: [
|
|
81
|
+
return { auth: null, errors: [`${field}: ${resolution.problem.message}`] };
|
|
65
82
|
}
|
|
66
83
|
|
|
67
84
|
const rule = resolution.resolved.secrets
|
|
@@ -72,13 +89,111 @@ export function collectClaudeAuth(
|
|
|
72
89
|
return {
|
|
73
90
|
auth: null,
|
|
74
91
|
errors: [
|
|
75
|
-
|
|
92
|
+
`${field} needs a profile that sets a bearer authorization header for ${ANTHROPIC_API_HOST}, where Claude Code sends its subscription token`,
|
|
76
93
|
],
|
|
77
94
|
};
|
|
78
95
|
}
|
|
79
96
|
|
|
80
|
-
|
|
97
|
+
const servers = collectMCPServers(raw['mcpServers'], field, selected, authProfiles);
|
|
98
|
+
|
|
99
|
+
return { auth: { profiles: selected, mcpServers: servers.servers }, errors: servers.errors };
|
|
81
100
|
}
|
|
82
101
|
|
|
83
102
|
// The one host Claude Code sends a subscription token to.
|
|
84
103
|
const ANTHROPIC_API_HOST = 'api.anthropic.com';
|
|
104
|
+
|
|
105
|
+
interface CollectedMCPServers {
|
|
106
|
+
readonly servers: readonly ClaudeMCPServer[];
|
|
107
|
+
readonly errors: readonly string[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Reads the `mcpServers` map, server names to the server's URL and the
|
|
111
|
+
// selected profile that carries its credential.
|
|
112
|
+
function collectMCPServers(
|
|
113
|
+
raw: unknown,
|
|
114
|
+
field: string,
|
|
115
|
+
selected: readonly string[],
|
|
116
|
+
authProfiles: ReadonlyMap<string, AuthProfile>,
|
|
117
|
+
): CollectedMCPServers {
|
|
118
|
+
if (raw === undefined) {
|
|
119
|
+
return { servers: [], errors: [] };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!isRecord(raw) || Array.isArray(raw)) {
|
|
123
|
+
return { servers: [], errors: [`${field}.mcpServers must be an object of named servers`] };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const servers: ClaudeMCPServer[] = [];
|
|
127
|
+
const errors: string[] = [];
|
|
128
|
+
|
|
129
|
+
for (const [name, entry] of Object.entries(raw)) {
|
|
130
|
+
const parsed = parseMCPServer(name, entry, field, selected, authProfiles);
|
|
131
|
+
|
|
132
|
+
if (typeof parsed === 'string') {
|
|
133
|
+
errors.push(`${field}.mcpServers.${name}: ${parsed}`);
|
|
134
|
+
} else {
|
|
135
|
+
servers.push(parsed);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { servers, errors };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Claude Code's rule for an MCP server's name.
|
|
143
|
+
const MCP_SERVER_NAME = /^[\w-]{1,64}$/u;
|
|
144
|
+
|
|
145
|
+
// The server an entry holds, or the first rule it breaks. The header comes
|
|
146
|
+
// from the profile, so the header the session sends and the one impd swaps
|
|
147
|
+
// the credential into never differ.
|
|
148
|
+
function parseMCPServer(
|
|
149
|
+
name: string,
|
|
150
|
+
entry: unknown,
|
|
151
|
+
field: string,
|
|
152
|
+
selected: readonly string[],
|
|
153
|
+
authProfiles: ReadonlyMap<string, AuthProfile>,
|
|
154
|
+
): ClaudeMCPServer | string {
|
|
155
|
+
if (!MCP_SERVER_NAME.test(name)) {
|
|
156
|
+
return 'a server name must be letters, digits, underscores or hyphens';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!isRecord(entry) || Array.isArray(entry)) {
|
|
160
|
+
return 'a server must be an object with url and profile';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const extra = Object.keys(entry).find((key) => key !== 'url' && key !== 'profile');
|
|
164
|
+
|
|
165
|
+
if (extra !== undefined) {
|
|
166
|
+
return `${extra} cannot be set: atc fixes the transport and the placeholder header`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const url = entry['url'];
|
|
170
|
+
const profileName = entry['profile'];
|
|
171
|
+
|
|
172
|
+
if (typeof profileName !== 'string' || !selected.includes(profileName)) {
|
|
173
|
+
return `profile must be one of ${field}.profiles`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const profile = authProfiles.get(profileName);
|
|
177
|
+
|
|
178
|
+
if (profile?.kind !== 'custom') {
|
|
179
|
+
return 'profile must be a custom profile, which sets one header for one host';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const parsedURL = typeof url === 'string' && URL.canParse(url) ? new URL(url) : null;
|
|
183
|
+
|
|
184
|
+
if (
|
|
185
|
+
parsedURL === null ||
|
|
186
|
+
parsedURL.protocol !== 'https:' ||
|
|
187
|
+
parsedURL.port !== '' ||
|
|
188
|
+
parsedURL.username !== '' ||
|
|
189
|
+
parsedURL.password !== ''
|
|
190
|
+
) {
|
|
191
|
+
return 'url must be an https URL with no port and no user info';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (parsedURL.hostname !== profile.host) {
|
|
195
|
+
return `url must be on ${profile.host}, the host profile ${profileName} sends its credential to`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { name, url: parsedURL.href, profile: profileName, header: profile.header };
|
|
199
|
+
}
|
|
@@ -1,20 +1,10 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { AgentID } from './agent-id';
|
|
3
3
|
import { buildOptionalStringArray } from './build-optional-string-array';
|
|
4
|
+
import { checkGatewayAuth } from './check-gateway-auth';
|
|
5
|
+
import type { GatewayAuth } from './check-gateway-auth';
|
|
4
6
|
import type { AuthProfile } from './collect-auth-profiles';
|
|
5
|
-
import { isBrokerVariable } from './is-broker-variable';
|
|
6
7
|
import { isRecord } from './report';
|
|
7
|
-
import { resolveAuthProfiles } from './resolve-auth-profiles';
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* The auth profiles a gateway's sessions are bound to through impd's
|
|
11
|
-
* broker, and the variables each session gets in place of a credential,
|
|
12
|
-
* every value the fixed placeholder.
|
|
13
|
-
*/
|
|
14
|
-
export interface GatewayAuth {
|
|
15
|
-
readonly profiles: readonly string[];
|
|
16
|
-
readonly placeholderEnv: Readonly<Record<string, string>>;
|
|
17
|
-
}
|
|
18
8
|
|
|
19
9
|
/**
|
|
20
10
|
* A Claude-compatible backend the Claude CLI is pointed at: its own agent id,
|
|
@@ -125,162 +115,6 @@ export function collectGateways(
|
|
|
125
115
|
return { gateways, errors };
|
|
126
116
|
}
|
|
127
117
|
|
|
128
|
-
interface GatewayEntry {
|
|
129
|
-
readonly baseURL?: string | undefined;
|
|
130
|
-
readonly apiKeyHelper?: string | undefined;
|
|
131
|
-
readonly env: Readonly<Record<string, string>>;
|
|
132
|
-
readonly settings?: Readonly<Record<string, unknown>> | undefined;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// The gateway's auth, or every problem that refuses it. The checks cover
|
|
136
|
-
// each place a session's environment comes from: the gateway env, the
|
|
137
|
-
// settings env, and the placeholders, and which of them would win.
|
|
138
|
-
function checkGatewayAuth(
|
|
139
|
-
raw: unknown,
|
|
140
|
-
entry: GatewayEntry,
|
|
141
|
-
authProfiles: ReadonlyMap<string, AuthProfile>,
|
|
142
|
-
): GatewayAuth | string[] {
|
|
143
|
-
const auth = parseGatewayAuth(raw);
|
|
144
|
-
|
|
145
|
-
if (typeof auth === 'string') {
|
|
146
|
-
return [auth];
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const problems: string[] = [];
|
|
150
|
-
|
|
151
|
-
if (entry.apiKeyHelper !== undefined) {
|
|
152
|
-
problems.push(
|
|
153
|
-
'apiKeyHelper cannot be set together with auth, which supplies the credential through the broker',
|
|
154
|
-
);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
if (entry.settings?.['apiKeyHelper'] !== undefined) {
|
|
158
|
-
problems.push(
|
|
159
|
-
'settings.apiKeyHelper cannot be set together with auth, which supplies the credential through the broker',
|
|
160
|
-
);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const settingsEnv = toEnvKeys(entry.settings?.['env']);
|
|
164
|
-
|
|
165
|
-
problems.push(
|
|
166
|
-
...collectEnvProblems('env', Object.keys(entry.env)),
|
|
167
|
-
...collectEnvProblems('settings.env', settingsEnv),
|
|
168
|
-
...collectPlaceholderProblems(auth.placeholderEnv),
|
|
169
|
-
);
|
|
170
|
-
|
|
171
|
-
for (const key of Object.keys(auth.placeholderEnv)) {
|
|
172
|
-
for (const [source, keys] of [
|
|
173
|
-
['env', Object.keys(entry.env)],
|
|
174
|
-
['settings.env', settingsEnv],
|
|
175
|
-
] as const) {
|
|
176
|
-
if (keys.includes(key)) {
|
|
177
|
-
problems.push(`placeholderEnv.${key} is also set in ${source}, which would override it`);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const resolution = resolveAuthProfiles(authProfiles, auth.profiles);
|
|
183
|
-
|
|
184
|
-
if ('problem' in resolution) {
|
|
185
|
-
problems.push(resolution.problem.message);
|
|
186
|
-
} else {
|
|
187
|
-
const hostProblem = findBaseURLProblem(entry.baseURL ?? '', resolution.resolved.hosts);
|
|
188
|
-
|
|
189
|
-
if (hostProblem !== null) {
|
|
190
|
-
problems.push(hostProblem);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
return problems.length > 0 ? problems : auth;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// The value every placeholder variable holds; impd's broker swaps the real
|
|
198
|
-
// credential in on the host's side.
|
|
199
|
-
const PLACEHOLDER = 'imp-broker-placeholder';
|
|
200
|
-
|
|
201
|
-
function parseGatewayAuth(raw: unknown): GatewayAuth | string {
|
|
202
|
-
const profiles = isRecord(raw) ? raw['profiles'] : undefined;
|
|
203
|
-
|
|
204
|
-
if (
|
|
205
|
-
!isRecord(raw) ||
|
|
206
|
-
!Array.isArray(profiles) ||
|
|
207
|
-
profiles.length === 0 ||
|
|
208
|
-
!profiles.every((name) => typeof name === 'string')
|
|
209
|
-
) {
|
|
210
|
-
return 'auth must be an object with a non-empty profiles array';
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
const placeholderEnv = raw['placeholderEnv'] ?? {};
|
|
214
|
-
|
|
215
|
-
if (!isRecord(placeholderEnv) || Array.isArray(placeholderEnv)) {
|
|
216
|
-
return 'placeholderEnv must be an object of variable names';
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
const env: Record<string, string> = {};
|
|
220
|
-
|
|
221
|
-
for (const [key, value] of Object.entries(placeholderEnv)) {
|
|
222
|
-
if (value !== PLACEHOLDER) {
|
|
223
|
-
return `placeholderEnv.${key} must be ${PLACEHOLDER}`;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
env[key] = value;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
return { profiles: profiles.map(String), placeholderEnv: env };
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// A settings env's variable names; anything but an object sets none.
|
|
233
|
-
function toEnvKeys(value: unknown): string[] {
|
|
234
|
-
return isRecord(value) && !Array.isArray(value) ? Object.keys(value) : [];
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// Variables Claude reads its endpoint and credential from. Only the
|
|
238
|
-
// placeholders may set a credential variable, and the base URL comes from
|
|
239
|
-
// `baseURL` alone, whose host is checked against the profiles.
|
|
240
|
-
const CREDENTIAL_VARIABLES: ReadonlySet<string> = new Set([
|
|
241
|
-
'ANTHROPIC_AUTH_TOKEN',
|
|
242
|
-
'ANTHROPIC_API_KEY',
|
|
243
|
-
]);
|
|
244
|
-
|
|
245
|
-
const BASE_URL_VARIABLE = 'ANTHROPIC_BASE_URL';
|
|
246
|
-
|
|
247
|
-
function collectEnvProblems(source: string, keys: readonly string[]): string[] {
|
|
248
|
-
return keys
|
|
249
|
-
.filter(
|
|
250
|
-
(key) => isBrokerVariable(key) || CREDENTIAL_VARIABLES.has(key) || key === BASE_URL_VARIABLE,
|
|
251
|
-
)
|
|
252
|
-
.map((key) => `${source} must not set ${key}`);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
function collectPlaceholderProblems(env: Readonly<Record<string, string>>): string[] {
|
|
256
|
-
return Object.keys(env)
|
|
257
|
-
.filter((key) => isBrokerVariable(key) || key === BASE_URL_VARIABLE)
|
|
258
|
-
.map((key) => `placeholderEnv must not set ${key}`);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// The broker matches a request by the exact host it connects to on the
|
|
262
|
-
// default https port, so the base URL must be https on that port, with a
|
|
263
|
-
// host one of the selected profiles covers.
|
|
264
|
-
function findBaseURLProblem(baseURL: string, hosts: readonly string[]): string | null {
|
|
265
|
-
let url: URL;
|
|
266
|
-
|
|
267
|
-
try {
|
|
268
|
-
url = new URL(baseURL);
|
|
269
|
-
} catch {
|
|
270
|
-
return 'baseURL must be an https URL with no port or user info';
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
if (url.protocol !== 'https:' || url.port !== '' || url.username !== '' || url.password !== '') {
|
|
274
|
-
return 'baseURL must be an https URL with no port or user info';
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
if (!hosts.includes(url.hostname)) {
|
|
278
|
-
return `baseURL host ${url.hostname} is not a host of the selected profiles (${hosts.join(', ')})`;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
return null;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
118
|
// Claude Code settings the sessions of this gateway are started with, on top of
|
|
285
119
|
// the ones atc writes itself. Anything but an object is no extra settings.
|
|
286
120
|
function buildOptionalSettings() {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { AgentEntry } from './collect-agents';
|
|
2
|
+
import type { AuthProfile } from './collect-auth-profiles';
|
|
3
|
+
import { collectClaudeAuth } from './collect-claude-auth';
|
|
4
|
+
import { collectGateways } from './collect-gateways';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The agent keys of the old config.json shape, already defaulted: a binary and arguments per harness, and the raw `claudeAuth`
|
|
8
|
+
* and `gateways` values.
|
|
9
|
+
*/
|
|
10
|
+
interface LegacyAgentKeys {
|
|
11
|
+
readonly claudeBin: string;
|
|
12
|
+
readonly claudeArgs: readonly string[];
|
|
13
|
+
readonly claudeAuth: unknown;
|
|
14
|
+
readonly grokBin: string;
|
|
15
|
+
readonly grokArgs: readonly string[];
|
|
16
|
+
readonly codexBin: string;
|
|
17
|
+
readonly codexArgs: readonly string[];
|
|
18
|
+
readonly gateways: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface LegacyAgents {
|
|
22
|
+
readonly agents: AgentEntry[];
|
|
23
|
+
|
|
24
|
+
// The problems that left a Claude auth or a gateway out, in the text the
|
|
25
|
+
// old keys always reported.
|
|
26
|
+
readonly errors: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Translates the old agent keys to registry entries: `claude`, `grok`, and
|
|
31
|
+
* `codex` in that order, then each gateway the old parse accepts, in file
|
|
32
|
+
* order. The parse is the old lenient one, so an absent or wrong-typed field
|
|
33
|
+
* takes its default.
|
|
34
|
+
*/
|
|
35
|
+
export function collectLegacyAgents(
|
|
36
|
+
keys: LegacyAgentKeys,
|
|
37
|
+
authProfiles: ReadonlyMap<string, AuthProfile>,
|
|
38
|
+
): LegacyAgents {
|
|
39
|
+
const claudeAuth = collectClaudeAuth(keys.claudeAuth, authProfiles);
|
|
40
|
+
const gateways = collectGateways(keys.gateways, keys.claudeBin, keys.claudeArgs, authProfiles);
|
|
41
|
+
|
|
42
|
+
const agents: AgentEntry[] = [
|
|
43
|
+
{
|
|
44
|
+
id: 'claude',
|
|
45
|
+
kind: 'claude',
|
|
46
|
+
label: 'Claude',
|
|
47
|
+
mark: 'c',
|
|
48
|
+
bin: keys.claudeBin,
|
|
49
|
+
args: keys.claudeArgs,
|
|
50
|
+
env: {},
|
|
51
|
+
...(claudeAuth.auth === null
|
|
52
|
+
? {}
|
|
53
|
+
: { auth: { profiles: claudeAuth.auth.profiles, placeholderEnv: {} } }),
|
|
54
|
+
...(claudeAuth.auth === null || claudeAuth.auth.mcpServers.length === 0
|
|
55
|
+
? {}
|
|
56
|
+
: { mcpServers: claudeAuth.auth.mcpServers }),
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: 'grok',
|
|
60
|
+
kind: 'grok',
|
|
61
|
+
label: 'Grok',
|
|
62
|
+
mark: 'g',
|
|
63
|
+
bin: keys.grokBin,
|
|
64
|
+
args: keys.grokArgs,
|
|
65
|
+
env: {},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: 'codex',
|
|
69
|
+
kind: 'codex',
|
|
70
|
+
label: 'Codex',
|
|
71
|
+
mark: 'c',
|
|
72
|
+
bin: keys.codexBin,
|
|
73
|
+
args: keys.codexArgs,
|
|
74
|
+
env: {},
|
|
75
|
+
},
|
|
76
|
+
...gateways.gateways.map((gateway): AgentEntry => ({ ...gateway, kind: 'claude' })),
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
return { agents, errors: [...claudeAuth.errors, ...gateways.errors] };
|
|
80
|
+
}
|
package/src/shared/config.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
+
import type { AgentID } from './agent-id';
|
|
4
5
|
import { buildOptionalBoolean } from './build-optional-boolean';
|
|
5
6
|
import { buildOptionalString } from './build-optional-string';
|
|
6
7
|
import { buildOptionalStringArray } from './build-optional-string-array';
|
|
8
|
+
import { collectAgents } from './collect-agents';
|
|
9
|
+
import type { AgentEntry } from './collect-agents';
|
|
7
10
|
import { collectAuthProfiles } from './collect-auth-profiles';
|
|
8
11
|
import type { AuthProfile } from './collect-auth-profiles';
|
|
9
|
-
import { collectClaudeAuth } from './collect-claude-auth';
|
|
10
|
-
import type { ClaudeAuth } from './collect-claude-auth';
|
|
11
12
|
import { collectDirRoots } from './collect-dir-roots';
|
|
12
|
-
import { collectGateways } from './collect-gateways';
|
|
13
|
-
import type { GatewayConfig } from './collect-gateways';
|
|
14
13
|
import { collectHooks } from './collect-hooks';
|
|
15
14
|
import type { HooksConfig } from './collect-hooks';
|
|
15
|
+
import { collectLegacyAgents } from './collect-legacy-agents';
|
|
16
16
|
import { collectPrincipals } from './collect-principals';
|
|
17
17
|
import { collectTargets } from './collect-targets';
|
|
18
18
|
import type { TargetConfig, TargetConfigError } from './collect-targets';
|
|
@@ -20,30 +20,28 @@ import { collectWorkspacesConfig } from './collect-workspaces-config';
|
|
|
20
20
|
import type { WorkspacesConfig } from './collect-workspaces-config';
|
|
21
21
|
import { DEFAULT_GIT_TRANSPORTS } from './default-git-transports';
|
|
22
22
|
import { formatJSONKind } from './format-json-kind';
|
|
23
|
+
import { formatMixedAgentKeys } from './format-mixed-agent-keys';
|
|
24
|
+
import { LEGACY_AGENT_KEYS } from './legacy-agent-keys';
|
|
25
|
+
import { pickDefaultAgent } from './pick-default-agent';
|
|
23
26
|
import { isRecord } from './report';
|
|
24
27
|
import { resolveHomeDir } from './resolve-home-dir';
|
|
25
28
|
|
|
26
29
|
export interface Config {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
codexArgs: string[];
|
|
30
|
+
// The agents atc offers, in menu order, and the problems that left an
|
|
31
|
+
// entry out.
|
|
32
|
+
agents: readonly AgentEntry[];
|
|
33
|
+
agentErrors: readonly string[];
|
|
34
|
+
|
|
35
|
+
// The old agent keys a file without `agents` sets, in file order. Empty
|
|
36
|
+
// for a file that uses `agents` or sets none of them.
|
|
37
|
+
legacyAgentKeys: readonly string[];
|
|
38
|
+
|
|
39
|
+
// The agent a spawn without one runs.
|
|
40
|
+
defaultAgent: AgentID;
|
|
39
41
|
dirs: DirsConfig;
|
|
40
42
|
workspaces: WorkspacesConfig;
|
|
41
|
-
gateways: GatewayConfig[];
|
|
42
|
-
|
|
43
|
-
// The problems that kept a gateway with auth out of the gateways.
|
|
44
|
-
gatewayErrors: readonly string[];
|
|
45
43
|
|
|
46
|
-
// The credential references
|
|
44
|
+
// The credential references an agent's auth selects from, by profile
|
|
47
45
|
// name, and the problems that kept a profile out.
|
|
48
46
|
authProfiles: ReadonlyMap<string, AuthProfile>;
|
|
49
47
|
authProfileErrors: readonly string[];
|
|
@@ -87,14 +85,20 @@ interface LeaderKey {
|
|
|
87
85
|
}
|
|
88
86
|
|
|
89
87
|
const DEFAULTS: Config = {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
88
|
+
agents: [
|
|
89
|
+
{
|
|
90
|
+
id: 'claude',
|
|
91
|
+
kind: 'claude',
|
|
92
|
+
label: 'Claude',
|
|
93
|
+
mark: 'c',
|
|
94
|
+
bin: 'claude',
|
|
95
|
+
args: [],
|
|
96
|
+
env: {},
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
agentErrors: [],
|
|
100
|
+
legacyAgentKeys: [],
|
|
101
|
+
defaultAgent: 'claude',
|
|
98
102
|
dirs: { roots: [] },
|
|
99
103
|
workspaces: {
|
|
100
104
|
githubOwner: null,
|
|
@@ -103,8 +107,6 @@ const DEFAULTS: Config = {
|
|
|
103
107
|
root: null,
|
|
104
108
|
targetRoots: new Map(),
|
|
105
109
|
},
|
|
106
|
-
gateways: [],
|
|
107
|
-
gatewayErrors: [],
|
|
108
110
|
authProfiles: new Map(),
|
|
109
111
|
authProfileErrors: [],
|
|
110
112
|
hooks: {},
|
|
@@ -139,6 +141,7 @@ export const daemonRecordFile = join(stateDir, 'daemon.json');
|
|
|
139
141
|
// field parses to undefined rather than failing the file, so a bad config
|
|
140
142
|
// falls back to a default instead of refusing to start atc.
|
|
141
143
|
const CONFIG_SCHEMA = z.object({
|
|
144
|
+
agents: z.unknown().optional(),
|
|
142
145
|
claudeBin: buildOptionalString(),
|
|
143
146
|
claudeArgs: buildOptionalStringArray(),
|
|
144
147
|
claudeAuth: z.unknown().optional(),
|
|
@@ -237,16 +240,17 @@ function tryWriteDefaultConfig(file: string): void {
|
|
|
237
240
|
}
|
|
238
241
|
|
|
239
242
|
/**
|
|
240
|
-
* The config.json text a first run writes
|
|
241
|
-
* principals, so the file holds the one implicit `local` target and no
|
|
243
|
+
* The config.json text a first run writes: the `claude` agent alone. It
|
|
244
|
+
* leaves out the targets and principals, so the file holds the one implicit `local` target and no
|
|
242
245
|
* principals until the user sets their own, no auth profiles, and the
|
|
243
246
|
* errors a parse reports, which belong to no file.
|
|
244
247
|
*/
|
|
245
248
|
export function renderDefaultConfig(): string {
|
|
246
249
|
const {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
+
agents: _agents,
|
|
251
|
+
agentErrors: _agentErrors,
|
|
252
|
+
legacyAgentKeys: _legacyAgentKeys,
|
|
253
|
+
defaultAgent: _defaultAgent,
|
|
250
254
|
authProfiles: _authProfiles,
|
|
251
255
|
authProfileErrors: _authProfileErrors,
|
|
252
256
|
targets: _targets,
|
|
@@ -264,7 +268,11 @@ export function renderDefaultConfig(): string {
|
|
|
264
268
|
const { targetRoots, ...rest } = workspaces;
|
|
265
269
|
|
|
266
270
|
return `${JSON.stringify(
|
|
267
|
-
{
|
|
271
|
+
{
|
|
272
|
+
agents: { claude: {} },
|
|
273
|
+
...written,
|
|
274
|
+
workspaces: { ...rest, targets: Object.fromEntries(targetRoots) },
|
|
275
|
+
},
|
|
268
276
|
null,
|
|
269
277
|
2,
|
|
270
278
|
)}\n`;
|
|
@@ -296,23 +304,31 @@ export function parseConfig(raw: unknown, file: string = configFile): Config {
|
|
|
296
304
|
);
|
|
297
305
|
}
|
|
298
306
|
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
307
|
+
const present = Object.keys(raw).filter((key) => LEGACY_AGENT_KEYS.includes(key));
|
|
308
|
+
|
|
309
|
+
if (Object.hasOwn(raw, 'agents') && present.length > 0) {
|
|
310
|
+
return buildUnusableConfig('config_malformed', file, formatMixedAgentKeys(present));
|
|
311
|
+
}
|
|
312
|
+
|
|
305
313
|
const dirs = { roots: collectDirRoots(parsed.data.dirs) };
|
|
306
314
|
const workspaces = collectWorkspacesConfig(parsed.data.workspaces);
|
|
307
315
|
const authProfiles = collectAuthProfiles(parsed.data.authProfiles);
|
|
308
|
-
const claudeAuth = collectClaudeAuth(parsed.data.claudeAuth, authProfiles.profiles);
|
|
309
316
|
|
|
310
|
-
const
|
|
311
|
-
parsed.data.
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
317
|
+
const registry = Object.hasOwn(raw, 'agents')
|
|
318
|
+
? collectAgents(parsed.data.agents, authProfiles.profiles)
|
|
319
|
+
: collectLegacyAgents(
|
|
320
|
+
{
|
|
321
|
+
claudeBin: parsed.data.claudeBin ?? 'claude',
|
|
322
|
+
claudeArgs: parsed.data.claudeArgs ?? [],
|
|
323
|
+
claudeAuth: parsed.data.claudeAuth,
|
|
324
|
+
grokBin: parsed.data.grokBin ?? 'grok',
|
|
325
|
+
grokArgs: parsed.data.grokArgs ?? [],
|
|
326
|
+
codexBin: parsed.data.codexBin ?? 'codex',
|
|
327
|
+
codexArgs: parsed.data.codexArgs ?? [],
|
|
328
|
+
gateways: parsed.data.gateways,
|
|
329
|
+
},
|
|
330
|
+
authProfiles.profiles,
|
|
331
|
+
);
|
|
316
332
|
|
|
317
333
|
const hooks = collectHooks(parsed.data.hooks);
|
|
318
334
|
const targets = collectTargets(parsed.data.targets, parsed.data.defaultTarget);
|
|
@@ -322,18 +338,12 @@ export function parseConfig(raw: unknown, file: string = configFile): Config {
|
|
|
322
338
|
(parsed.data.leader === undefined ? null : decodeLeader(parsed.data.leader)) ?? DEFAULTS.leader;
|
|
323
339
|
|
|
324
340
|
return {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
grokBin,
|
|
330
|
-
grokArgs,
|
|
331
|
-
codexBin,
|
|
332
|
-
codexArgs,
|
|
341
|
+
agents: registry.agents,
|
|
342
|
+
agentErrors: registry.errors,
|
|
343
|
+
legacyAgentKeys: present,
|
|
344
|
+
defaultAgent: pickDefaultAgent(registry.agents),
|
|
333
345
|
dirs,
|
|
334
346
|
workspaces: workspaces.workspaces,
|
|
335
|
-
gateways: gateways.gateways,
|
|
336
|
-
gatewayErrors: gateways.errors,
|
|
337
347
|
authProfiles: authProfiles.profiles,
|
|
338
348
|
authProfileErrors: authProfiles.errors,
|
|
339
349
|
hooks,
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The problem a config.json that sets both `agents` and old agent keys
|
|
3
|
+
* reports: the keys, as "a", "a and b", or "a, b and c", and where they go.
|
|
4
|
+
*/
|
|
5
|
+
export function formatMixedAgentKeys(keys: readonly string[]): string {
|
|
6
|
+
const last = keys.at(-1);
|
|
7
|
+
const list = keys.length < 2 ? keys.join('') : `${keys.slice(0, -1).join(', ')} and ${last}`;
|
|
8
|
+
|
|
9
|
+
return `${list} cannot be set together with agents; move them into agents or run 'atc config migrate'`;
|
|
10
|
+
}
|