@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.
Files changed (33) hide show
  1. package/README.md +2 -1
  2. package/package.json +1 -1
  3. package/src/agents/agent-adapter.ts +6 -1
  4. package/src/agents/build-agent-adapters.ts +32 -0
  5. package/src/agents/build-claude-config-seed.ts +7 -4
  6. package/src/agents/build-claude-mcp-config.ts +27 -0
  7. package/src/agents/claude-adapter.ts +157 -66
  8. package/src/agents/codex-adapter.ts +17 -11
  9. package/src/agents/gateway-adapter.ts +17 -1
  10. package/src/agents/grok-adapter.ts +15 -9
  11. package/src/build-migrated-config.ts +196 -0
  12. package/src/cli.ts +49 -17
  13. package/src/client/collect-agent-picks.ts +3 -10
  14. package/src/client/index.ts +1 -1
  15. package/src/client/resolve-agent-metadata.ts +4 -10
  16. package/src/client/spawn-picker.ts +1 -2
  17. package/src/daemon/build-auth-binding.ts +2 -1
  18. package/src/daemon/daemon-connection.ts +1 -1
  19. package/src/daemon/daemon.ts +12 -3
  20. package/src/daemon/sessions.ts +8 -2
  21. package/src/mcp/build-spawn-descriptions.ts +2 -2
  22. package/src/run-config-migrate.ts +85 -0
  23. package/src/shared/check-gateway-auth.ts +173 -0
  24. package/src/shared/collect-agents.ts +349 -0
  25. package/src/shared/collect-claude-auth.ts +125 -10
  26. package/src/shared/collect-gateways.ts +2 -168
  27. package/src/shared/collect-legacy-agents.ts +80 -0
  28. package/src/shared/config.ts +70 -60
  29. package/src/shared/format-mixed-agent-keys.ts +10 -0
  30. package/src/shared/is-subscription-override-variable.ts +14 -0
  31. package/src/shared/legacy-agent-keys.ts +15 -0
  32. package/src/shared/outranking-variables.ts +17 -0
  33. 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
- export interface ClaudeAuth {
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 the `claudeAuth` entry against the auth profiles. An entry that
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: ['claudeAuth must be an object with a non-empty profiles array'],
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
- `claudeAuth.${extra} cannot be set: atc fixes the endpoint and the placeholder of a Claude subscription session`,
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: [`claudeAuth: ${resolution.problem.message}`] };
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
- `claudeAuth needs a profile that sets a bearer authorization header for ${ANTHROPIC_API_HOST}, where Claude Code sends its subscription token`,
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
- return { auth: { profiles: selected }, errors: [] };
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
+ }
@@ -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
- claudeBin: string;
28
- claudeArgs: string[];
29
-
30
- // The auth profiles stock Claude signs in through on a target that
31
- // reaches impd's broker, null when it keeps the host's own sign-in there,
32
- // and the problems that left an entry out.
33
- claudeAuth: ClaudeAuth | null;
34
- claudeAuthErrors: readonly string[];
35
- grokBin: string;
36
- grokArgs: string[];
37
- codexBin: string;
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 a gateway's auth selects from, by profile
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
- claudeBin: 'claude',
91
- claudeArgs: [],
92
- claudeAuth: null,
93
- claudeAuthErrors: [],
94
- grokBin: 'grok',
95
- grokArgs: [],
96
- codexBin: 'codex',
97
- codexArgs: [],
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. It leaves out the targets and
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
- claudeAuth: _claudeAuth,
248
- claudeAuthErrors: _claudeAuthErrors,
249
- gatewayErrors: _gatewayErrors,
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
- { ...written, workspaces: { ...rest, targets: Object.fromEntries(targetRoots) } },
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 claudeBin = parsed.data.claudeBin ?? DEFAULTS.claudeBin;
300
- const claudeArgs = parsed.data.claudeArgs ?? DEFAULTS.claudeArgs;
301
- const grokBin = parsed.data.grokBin ?? DEFAULTS.grokBin;
302
- const grokArgs = parsed.data.grokArgs ?? DEFAULTS.grokArgs;
303
- const codexBin = parsed.data.codexBin ?? DEFAULTS.codexBin;
304
- const codexArgs = parsed.data.codexArgs ?? DEFAULTS.codexArgs;
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 gateways = collectGateways(
311
- parsed.data.gateways,
312
- claudeBin,
313
- claudeArgs,
314
- authProfiles.profiles,
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
- claudeBin,
326
- claudeArgs,
327
- claudeAuth: claudeAuth.auth,
328
- claudeAuthErrors: claudeAuth.errors,
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
+ }