@desplega.ai/agent-swarm 1.71.2 → 1.72.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 (62) hide show
  1. package/README.md +3 -2
  2. package/openapi.json +994 -62
  3. package/package.json +2 -1
  4. package/src/be/budget-admission.ts +121 -0
  5. package/src/be/budget-refusal-notify.ts +145 -0
  6. package/src/be/db.ts +488 -5
  7. package/src/be/migrations/044_provider_meta.sql +2 -0
  8. package/src/be/migrations/046_budgets_and_pricing.sql +87 -0
  9. package/src/be/migrations/047_session_costs_cost_source.sql +16 -0
  10. package/src/cli.tsx +22 -1
  11. package/src/commands/claude-managed-setup.ts +687 -0
  12. package/src/commands/codex-login.ts +1 -1
  13. package/src/commands/runner.ts +175 -28
  14. package/src/commands/templates.ts +10 -6
  15. package/src/http/budgets.ts +219 -0
  16. package/src/http/index.ts +6 -0
  17. package/src/http/integrations.ts +134 -0
  18. package/src/http/poll.ts +161 -3
  19. package/src/http/pricing.ts +245 -0
  20. package/src/http/session-data.ts +54 -6
  21. package/src/http/tasks.ts +23 -2
  22. package/src/prompts/base-prompt.ts +103 -73
  23. package/src/prompts/session-templates.ts +43 -0
  24. package/src/providers/claude-adapter.ts +3 -1
  25. package/src/providers/claude-managed-adapter.ts +871 -0
  26. package/src/providers/claude-managed-models.ts +117 -0
  27. package/src/providers/claude-managed-swarm-events.ts +77 -0
  28. package/src/providers/codex-adapter.ts +3 -1
  29. package/src/providers/codex-skill-resolver.ts +10 -0
  30. package/src/providers/codex-swarm-events.ts +20 -161
  31. package/src/providers/devin-adapter.ts +894 -0
  32. package/src/providers/devin-api.ts +207 -0
  33. package/src/providers/devin-playbooks.ts +91 -0
  34. package/src/providers/devin-skill-resolver.ts +113 -0
  35. package/src/providers/index.ts +10 -1
  36. package/src/providers/pi-mono-adapter.ts +3 -1
  37. package/src/providers/swarm-events-shared.ts +262 -0
  38. package/src/providers/types.ts +26 -1
  39. package/src/tests/base-prompt.test.ts +199 -0
  40. package/src/tests/budget-admission.test.ts +339 -0
  41. package/src/tests/budget-claim-gate.test.ts +288 -0
  42. package/src/tests/budget-refusal-notification.test.ts +324 -0
  43. package/src/tests/budgets-routes.test.ts +331 -0
  44. package/src/tests/claude-managed-adapter.test.ts +1301 -0
  45. package/src/tests/claude-managed-setup.test.ts +325 -0
  46. package/src/tests/devin-adapter.test.ts +677 -0
  47. package/src/tests/devin-api.test.ts +339 -0
  48. package/src/tests/integrations-http.test.ts +211 -0
  49. package/src/tests/migration-046-budgets.test.ts +327 -0
  50. package/src/tests/pricing-routes.test.ts +315 -0
  51. package/src/tests/prompt-template-remaining.test.ts +4 -0
  52. package/src/tests/prompt-template-session.test.ts +2 -2
  53. package/src/tests/provider-adapter.test.ts +1 -1
  54. package/src/tests/runner-budget-refused.test.ts +271 -0
  55. package/src/tests/session-costs-codex-recompute.test.ts +386 -0
  56. package/src/tools/poll-task.ts +13 -2
  57. package/src/tools/task-action.ts +92 -2
  58. package/src/tools/templates.ts +29 -0
  59. package/src/types.ts +116 -0
  60. package/src/utils/budget-backoff.ts +34 -0
  61. package/src/utils/credentials.ts +4 -0
  62. package/src/utils/provider-metadata.ts +9 -0
@@ -0,0 +1,687 @@
1
+ /**
2
+ * `agent-swarm claude-managed-setup` — bootstrap the Anthropic-side managed
3
+ * agent + environment + skills, then persist their IDs to `swarm_config` so
4
+ * deployed workers can fetch them at boot.
5
+ *
6
+ * Mirrors the shape of `codex-login.ts`: non-UI command, plain stdout, exits
7
+ * via `process.exit`. No Ink. Talks to the swarm API exclusively over HTTP
8
+ * (NO direct DB access — the boundary check enforces this).
9
+ *
10
+ * Reference: thoughts/taras/plans/2026-04-28-claude-managed-agents-provider.md
11
+ * Phase 2 §1 — "Setup CLI command (NOT a standalone script)"
12
+ *
13
+ * SDK shape note: the plan's spec referred to `skill_id` / `content_md` field
14
+ * names, but the actual `@anthropic-ai/sdk` `client.beta.skills.create`
15
+ * accepts `{ display_title?, files: Array<Uploadable> }` and returns a
16
+ * response object with `id` (the field used as `skill_id` when later
17
+ * referencing the skill from an agent definition via
18
+ * `BetaManagedAgentsCustomSkillParams`). The MCP-server param shape is
19
+ * `{ name, type: "url", url }` — the SDK does NOT accept `http_headers`
20
+ * here, so MCP auth is configured Anthropic-side via the dashboard / vault.
21
+ */
22
+
23
+ import { readdir, readFile } from "node:fs/promises";
24
+ import path from "node:path";
25
+ import Anthropic, { BadRequestError, ConflictError } from "@anthropic-ai/sdk";
26
+ import type {
27
+ AgentCreateParams,
28
+ BetaManagedAgentsAgent,
29
+ BetaManagedAgentsCustomSkillParams,
30
+ BetaManagedAgentsURLMCPServerParams,
31
+ } from "@anthropic-ai/sdk/resources/beta/agents";
32
+ import type { BetaEnvironment } from "@anthropic-ai/sdk/resources/beta/environments";
33
+ import type { SkillCreateResponse } from "@anthropic-ai/sdk/resources/beta/skills";
34
+ import { toFile } from "@anthropic-ai/sdk/uploads";
35
+
36
+ import { promptHiddenInput } from "./codex-login.js";
37
+
38
+ // ─── Types ───────────────────────────────────────────────────────────────────
39
+
40
+ type ParsedArgs = {
41
+ apiUrl?: string;
42
+ apiKey?: string;
43
+ force: boolean;
44
+ showHelp: boolean;
45
+ };
46
+
47
+ type SwarmConfigEntry = {
48
+ id?: string;
49
+ scope: string;
50
+ scopeId?: string | null;
51
+ key: string;
52
+ value: string;
53
+ isSecret?: boolean;
54
+ };
55
+
56
+ export type ClaudeManagedSetupResult = {
57
+ agentId: string;
58
+ environmentId: string;
59
+ skillIds: string[];
60
+ alreadyConfigured: boolean;
61
+ mcpVaultId?: string | null;
62
+ };
63
+
64
+ // ─── Constants ───────────────────────────────────────────────────────────────
65
+
66
+ const DEFAULT_AGENT_MODEL = "claude-sonnet-4-6";
67
+ const SKILLS_DIR_RELATIVE = "plugin/commands";
68
+ const SKILLS_BETA_HEADER = "skills-2025-10-02";
69
+
70
+ // Config keys persisted to `swarm_config`. The docker-entrypoint hydrates env
71
+ // vars from these on worker boot.
72
+ const CONFIG_KEY_AGENT_ID = "managed_agent_id";
73
+ const CONFIG_KEY_ENVIRONMENT_ID = "managed_environment_id";
74
+ const CONFIG_KEY_API_KEY = "anthropic_api_key";
75
+ const CONFIG_KEY_MCP_VAULT_ID = "managed_mcp_vault_id";
76
+
77
+ // Display name for the swarm-MCP vault. Used as the lookup key on rerun so the
78
+ // upsert is idempotent — there is intentionally only one MCP vault per
79
+ // installation, scoped to the configured `MCP_BASE_URL`.
80
+ const MCP_VAULT_DISPLAY_NAME = "swarm-mcp";
81
+
82
+ // ─── Arg parsing + help ──────────────────────────────────────────────────────
83
+
84
+ function parseArgs(args: string[]): ParsedArgs {
85
+ const parsed: ParsedArgs = { force: false, showHelp: false };
86
+
87
+ for (let i = 0; i < args.length; i++) {
88
+ const arg = args[i];
89
+ if (arg === "--api-url" && args[i + 1]) {
90
+ parsed.apiUrl = args[++i]!;
91
+ } else if (arg === "--api-key" && args[i + 1]) {
92
+ parsed.apiKey = args[++i]!;
93
+ } else if (arg === "--force") {
94
+ parsed.force = true;
95
+ } else if (arg === "--help" || arg === "-h") {
96
+ parsed.showHelp = true;
97
+ }
98
+ }
99
+
100
+ return parsed;
101
+ }
102
+
103
+ function printHelp(): void {
104
+ console.log(`
105
+ agent-swarm claude-managed-setup — Bootstrap Anthropic Managed Agents for the swarm
106
+
107
+ Usage:
108
+ agent-swarm claude-managed-setup [options]
109
+
110
+ Options:
111
+ --api-url <url> Swarm API URL (default: MCP_BASE_URL or http://localhost:3013)
112
+ --api-key <key> Swarm API key (default: API_KEY or 123123)
113
+ --force Recreate agent + environment even if already configured
114
+ -h, --help Show this help
115
+
116
+ This command:
117
+ 1. Creates an Anthropic-side environment (cloud, unrestricted networking).
118
+ 2. Uploads each plugin/commands/*.md as a managed-agents skill (skips on 409).
119
+ 3. Creates a managed-agents agent referencing the uploaded skills + the
120
+ swarm MCP server (MCP_BASE_URL/mcp).
121
+ 4. Persists the resulting IDs to swarm_config (managed_agent_id,
122
+ managed_environment_id, anthropic_api_key) via PUT /api/config.
123
+
124
+ Required environment variables:
125
+ ANTHROPIC_API_KEY Anthropic API key (prompted with masked input if missing).
126
+ MCP_BASE_URL Public HTTPS URL where Anthropic can reach the swarm MCP.
127
+ MUST start with https:// — fail-fast otherwise.
128
+
129
+ Optional:
130
+ MANAGED_AGENT_MODEL Default model for the managed agent (default: ${DEFAULT_AGENT_MODEL}).
131
+
132
+ Re-running the command is idempotent: if managed_agent_id is already set in
133
+ swarm_config it exits with a "already configured" message. Pass --force to
134
+ recreate the Anthropic-side resources.
135
+ `);
136
+ }
137
+
138
+ // ─── Swarm-API helpers (HTTP only — no DB imports) ───────────────────────────
139
+
140
+ function apiHeaders(apiKey: string): Record<string, string> {
141
+ return {
142
+ "Content-Type": "application/json",
143
+ Authorization: `Bearer ${apiKey}`,
144
+ };
145
+ }
146
+
147
+ async function fetchConfigByKey(
148
+ apiUrl: string,
149
+ apiKey: string,
150
+ key: string,
151
+ ): Promise<SwarmConfigEntry | null> {
152
+ // The list endpoint doesn't filter by key server-side, but the resolved
153
+ // endpoint returns merged global+agent+repo entries, which is what the
154
+ // worker entrypoint also uses. We filter client-side.
155
+ const res = await fetch(
156
+ `${apiUrl}/api/config/resolved?includeSecrets=true&key=${encodeURIComponent(key)}`,
157
+ { headers: { Authorization: `Bearer ${apiKey}` } },
158
+ );
159
+ if (!res.ok) return null;
160
+
161
+ const data = (await res.json()) as { configs?: SwarmConfigEntry[] };
162
+ const entry = data.configs?.find((c) => c.key === key);
163
+ return entry ?? null;
164
+ }
165
+
166
+ async function upsertConfig(
167
+ apiUrl: string,
168
+ apiKey: string,
169
+ entry: { key: string; value: string; isSecret?: boolean; description?: string },
170
+ ): Promise<void> {
171
+ const res = await fetch(`${apiUrl}/api/config`, {
172
+ method: "PUT",
173
+ headers: apiHeaders(apiKey),
174
+ body: JSON.stringify({
175
+ scope: "global",
176
+ key: entry.key,
177
+ value: entry.value,
178
+ isSecret: entry.isSecret ?? false,
179
+ description: entry.description ?? null,
180
+ }),
181
+ });
182
+
183
+ if (!res.ok) {
184
+ const text = await res.text().catch(() => "");
185
+ throw new Error(`Failed to upsert ${entry.key}: HTTP ${res.status} ${text}`);
186
+ }
187
+ }
188
+
189
+ // ─── Skills upload helpers ───────────────────────────────────────────────────
190
+
191
+ /**
192
+ * Slug used as the skill's `display_title`. Mirrors the slugs that
193
+ * `bun run build:pi-skills` generates for the worker-side filesystem layout.
194
+ */
195
+ function skillSlugFromFilename(filename: string): string {
196
+ return filename.replace(/\.md$/i, "");
197
+ }
198
+
199
+ async function loadSkillFiles(
200
+ skillsDir: string,
201
+ ): Promise<Array<{ slug: string; absPath: string; content: string }>> {
202
+ const entries = await readdir(skillsDir);
203
+ const mdEntries = entries.filter((f) => f.toLowerCase().endsWith(".md"));
204
+ const out: Array<{ slug: string; absPath: string; content: string }> = [];
205
+ for (const filename of mdEntries) {
206
+ const absPath = path.join(skillsDir, filename);
207
+ const content = await readFile(absPath, "utf8");
208
+ out.push({ slug: skillSlugFromFilename(filename), absPath, content });
209
+ }
210
+ return out;
211
+ }
212
+
213
+ async function uploadSkill(
214
+ client: Anthropic,
215
+ slug: string,
216
+ content: string,
217
+ log: (msg: string) => void,
218
+ existingByTitle?: Map<string, string>,
219
+ ): Promise<string | null> {
220
+ // The SDK's beta.skills.create expects { display_title?, files: Uploadable[] }
221
+ // and the API requires a SKILL.md at the root of the upload's top-level
222
+ // folder. We name the single file "<slug>/SKILL.md" so each
223
+ // plugin/commands/*.md becomes one skill in its own top-level folder.
224
+ if (existingByTitle?.has(slug)) {
225
+ const id = existingByTitle.get(slug)!;
226
+ log(` · skill "${slug}" already exists — reusing id=${id}`);
227
+ return id;
228
+ }
229
+ try {
230
+ const file = await toFile(Buffer.from(content, "utf8"), `${slug}/SKILL.md`, {
231
+ type: "text/markdown",
232
+ });
233
+ const res: SkillCreateResponse = await client.beta.skills.create({
234
+ display_title: slug,
235
+ files: [file],
236
+ betas: [SKILLS_BETA_HEADER],
237
+ });
238
+ log(` + uploaded skill "${slug}" (id=${res.id})`);
239
+ return res.id;
240
+ } catch (err) {
241
+ // The API returns 400 (not 409) when display_title is reused. Both shapes
242
+ // are treated as "already exists" — caller pre-fetched the skill list to
243
+ // recover the id, but we may still race a concurrent upload. Surface the
244
+ // raw error if we can't recover.
245
+ const isDisplayTitleConflict =
246
+ err instanceof BadRequestError &&
247
+ typeof err.message === "string" &&
248
+ err.message.includes("display_title");
249
+ if (err instanceof ConflictError || isDisplayTitleConflict) {
250
+ log(` · skill "${slug}" already exists (server-side) — skipping`);
251
+ return null;
252
+ }
253
+ throw err;
254
+ }
255
+ }
256
+
257
+ // ─── MCP vault upsert ────────────────────────────────────────────────────────
258
+
259
+ /**
260
+ * Find or create the swarm-MCP vault, then ensure a static-bearer credential
261
+ * exists for `mcpServerUrl` with the given `bearerToken`. Returns the vault id.
262
+ *
263
+ * Idempotency rules:
264
+ * - Vault: matched by `display_name === MCP_VAULT_DISPLAY_NAME`. Only one
265
+ * should exist per Anthropic account; if you somehow have multiple, the
266
+ * most recently created one wins (sorted by `created_at`). On rerun, no
267
+ * new vault is created.
268
+ * - Credential: matched by `auth.type === "static_bearer"` AND
269
+ * `auth.mcp_server_url === mcpServerUrl` (the URL is immutable per the
270
+ * SDK type docstring, so a URL change requires a new credential).
271
+ * The token itself is never returned in API responses, so on rerun we
272
+ * update the existing credential's token only when `force=true` (matches
273
+ * the agent-recreate semantics of `--force`).
274
+ *
275
+ * Anthropic's managed sandbox refuses to call our `/mcp` endpoint until a
276
+ * matching credential exists in a vault that the session is bound to via
277
+ * `vault_ids`. Calling this function from the setup CLI is what closes the
278
+ * loop without an out-of-band dashboard step.
279
+ */
280
+ export async function upsertMcpVault(
281
+ client: Anthropic,
282
+ mcpServerUrl: string,
283
+ bearerToken: string,
284
+ force: boolean,
285
+ log: (msg: string) => void,
286
+ ): Promise<string> {
287
+ // 1. Find an existing swarm-MCP vault by display name.
288
+ let vaultId: string | null = null;
289
+ let vaultExisted = false;
290
+ try {
291
+ let mostRecent: { id: string; created_at: string } | null = null;
292
+ for await (const v of client.beta.vaults.list({})) {
293
+ if (v.archived_at) continue;
294
+ if (v.display_name !== MCP_VAULT_DISPLAY_NAME) continue;
295
+ if (!mostRecent || v.created_at > mostRecent.created_at) {
296
+ mostRecent = { id: v.id, created_at: v.created_at };
297
+ }
298
+ }
299
+ if (mostRecent) {
300
+ vaultId = mostRecent.id;
301
+ vaultExisted = true;
302
+ log(` · vault "${MCP_VAULT_DISPLAY_NAME}" already exists — reusing id=${vaultId}`);
303
+ }
304
+ } catch (err) {
305
+ log(` · vault list failed (${(err as Error).message}); proceeding to create`);
306
+ }
307
+
308
+ // 2. Create the vault if it didn't exist.
309
+ if (!vaultId) {
310
+ const created = await client.beta.vaults.create({ display_name: MCP_VAULT_DISPLAY_NAME });
311
+ vaultId = created.id;
312
+ log(` + created vault "${MCP_VAULT_DISPLAY_NAME}" id=${vaultId}`);
313
+ }
314
+
315
+ // 3. Find or create-or-update the static-bearer credential matching mcpServerUrl.
316
+ let existingCredentialId: string | null = null;
317
+ if (vaultExisted) {
318
+ try {
319
+ for await (const c of client.beta.vaults.credentials.list(vaultId)) {
320
+ if (c.archived_at) continue;
321
+ if (
322
+ c.auth?.type === "static_bearer" &&
323
+ (c.auth as { mcp_server_url?: string }).mcp_server_url === mcpServerUrl
324
+ ) {
325
+ existingCredentialId = c.id;
326
+ break;
327
+ }
328
+ }
329
+ } catch (err) {
330
+ log(` · credential list failed (${(err as Error).message}); proceeding to create`);
331
+ }
332
+ }
333
+
334
+ if (existingCredentialId) {
335
+ if (force) {
336
+ await client.beta.vaults.credentials.update(existingCredentialId, {
337
+ vault_id: vaultId,
338
+ auth: { type: "static_bearer", token: bearerToken },
339
+ });
340
+ log(` + rotated credential token (--force) id=${existingCredentialId}`);
341
+ } else {
342
+ log(` · credential for ${mcpServerUrl} already exists — leaving token alone`);
343
+ }
344
+ } else {
345
+ const cred = await client.beta.vaults.credentials.create(vaultId, {
346
+ auth: {
347
+ type: "static_bearer",
348
+ token: bearerToken,
349
+ mcp_server_url: mcpServerUrl,
350
+ },
351
+ });
352
+ log(` + added static-bearer credential id=${cred.id} for ${mcpServerUrl}`);
353
+ }
354
+
355
+ return vaultId;
356
+ }
357
+
358
+ // ─── Validation helpers ──────────────────────────────────────────────────────
359
+
360
+ function validateMcpBaseUrl(mcpBaseUrl: string | undefined): string {
361
+ if (!mcpBaseUrl || mcpBaseUrl.length === 0) {
362
+ throw new Error(
363
+ "MCP_BASE_URL is not set. Anthropic's managed sandboxes need a public HTTPS URL " +
364
+ "to reach the swarm MCP server. Set MCP_BASE_URL=https://… in your .env or shell.",
365
+ );
366
+ }
367
+ if (!mcpBaseUrl.startsWith("https://")) {
368
+ throw new Error(
369
+ `MCP_BASE_URL must start with https:// (got: ${mcpBaseUrl}). ` +
370
+ "Anthropic's managed agents only connect to HTTPS MCP endpoints. ",
371
+ );
372
+ }
373
+ return mcpBaseUrl;
374
+ }
375
+
376
+ // ─── Resolve config (defaults + prompts) ────────────────────────────────────
377
+
378
+ export async function resolveClaudeManagedSetupConfig(
379
+ args: string[],
380
+ deps: {
381
+ env?: Record<string, string | undefined>;
382
+ isInteractive?: boolean;
383
+ promptSecret?: typeof promptHiddenInput;
384
+ } = {},
385
+ ): Promise<{
386
+ apiUrl: string;
387
+ apiKey: string;
388
+ anthropicApiKey: string;
389
+ mcpBaseUrl: string;
390
+ agentModel: string;
391
+ force: boolean;
392
+ disableMcp: boolean;
393
+ }> {
394
+ const env = deps.env ?? process.env;
395
+ const parsed = parseArgs(args);
396
+ const promptSecret = deps.promptSecret ?? promptHiddenInput;
397
+ const isInteractive = deps.isInteractive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
398
+
399
+ const apiUrl = parsed.apiUrl ?? env.MCP_BASE_URL ?? "http://localhost:3013";
400
+ const apiKey = parsed.apiKey ?? env.API_KEY ?? "123123";
401
+
402
+ let anthropicApiKey = env.ANTHROPIC_API_KEY ?? "";
403
+ if (!anthropicApiKey && isInteractive) {
404
+ anthropicApiKey = (
405
+ await promptSecret(
406
+ "Anthropic API key",
407
+ "",
408
+ "Paste your sk-ant-... key (input is hidden). Stored encrypted in swarm_config.",
409
+ )
410
+ ).trim();
411
+ }
412
+ if (!anthropicApiKey) {
413
+ throw new Error(
414
+ "ANTHROPIC_API_KEY is required. Either export it before running, or run interactively.",
415
+ );
416
+ }
417
+
418
+ const mcpBaseUrl = validateMcpBaseUrl(env.MCP_BASE_URL);
419
+ const agentModel = env.MANAGED_AGENT_MODEL ?? DEFAULT_AGENT_MODEL;
420
+ const disableMcp = env.MANAGED_DISABLE_MCP === "1" || env.MANAGED_DISABLE_MCP === "true";
421
+
422
+ return {
423
+ apiUrl,
424
+ apiKey,
425
+ anthropicApiKey,
426
+ mcpBaseUrl,
427
+ agentModel,
428
+ force: parsed.force,
429
+ disableMcp,
430
+ };
431
+ }
432
+
433
+ // ─── Core flow (testable) ────────────────────────────────────────────────────
434
+
435
+ export type RunClaudeManagedSetupDeps = {
436
+ client?: Anthropic;
437
+ fetchConfig?: typeof fetchConfigByKey;
438
+ upsert?: typeof upsertConfig;
439
+ loadSkills?: typeof loadSkillFiles;
440
+ uploadOne?: typeof uploadSkill;
441
+ skillsDir?: string;
442
+ log?: (msg: string) => void;
443
+ };
444
+
445
+ export async function runClaudeManagedSetupFlow(
446
+ config: {
447
+ apiUrl: string;
448
+ apiKey: string;
449
+ anthropicApiKey: string;
450
+ mcpBaseUrl: string;
451
+ agentModel: string;
452
+ force: boolean;
453
+ disableMcp?: boolean;
454
+ },
455
+ deps: RunClaudeManagedSetupDeps = {},
456
+ ): Promise<ClaudeManagedSetupResult> {
457
+ const log = deps.log ?? ((msg: string) => console.log(msg));
458
+ const fetchCfg = deps.fetchConfig ?? fetchConfigByKey;
459
+ const upsert = deps.upsert ?? upsertConfig;
460
+ const loadSkills = deps.loadSkills ?? loadSkillFiles;
461
+ const uploadOne = deps.uploadOne ?? uploadSkill;
462
+ const skillsDir = deps.skillsDir ?? path.resolve(process.cwd(), SKILLS_DIR_RELATIVE);
463
+
464
+ // Idempotency check: if an agent ID is already persisted, short-circuit
465
+ // unless --force was passed.
466
+ if (!config.force) {
467
+ const existing = await fetchCfg(config.apiUrl, config.apiKey, CONFIG_KEY_AGENT_ID);
468
+ if (existing?.value) {
469
+ const existingEnv = await fetchCfg(config.apiUrl, config.apiKey, CONFIG_KEY_ENVIRONMENT_ID);
470
+ log(
471
+ `claude-managed already configured (managed_agent_id=${existing.value}). ` +
472
+ "Re-run with --force to recreate.",
473
+ );
474
+ return {
475
+ agentId: existing.value,
476
+ environmentId: existingEnv?.value ?? "",
477
+ skillIds: [],
478
+ alreadyConfigured: true,
479
+ };
480
+ }
481
+ }
482
+
483
+ const client = deps.client ?? new Anthropic({ apiKey: config.anthropicApiKey });
484
+
485
+ // 1. Create the environment.
486
+ log("Creating Anthropic-side environment (swarm-worker-env)...");
487
+ const env: BetaEnvironment = await client.beta.environments.create({
488
+ name: "swarm-worker-env",
489
+ config: {
490
+ type: "cloud",
491
+ networking: { type: "unrestricted" },
492
+ },
493
+ });
494
+ log(` + environment id=${env.id}`);
495
+
496
+ // 2. Upload skills.
497
+ log(`Uploading skills from ${skillsDir} ...`);
498
+ const skillFiles = await loadSkills(skillsDir);
499
+ log(` found ${skillFiles.length} skill markdown file(s)`);
500
+ // Pre-fetch existing custom skills so we can reuse their IDs on rerun. The
501
+ // API returns 400 (not 409) on display_title collision and skill IDs aren't
502
+ // surfaced from that error, so the only recovery is to look them up.
503
+ const existingByTitle = new Map<string, string>();
504
+ try {
505
+ for await (const sk of client.beta.skills.list({
506
+ source: "custom",
507
+ betas: [SKILLS_BETA_HEADER],
508
+ })) {
509
+ if (sk.display_title) existingByTitle.set(sk.display_title, sk.id);
510
+ }
511
+ } catch (err) {
512
+ log(` · could not list existing skills (${(err as Error).message}); proceeding anyway`);
513
+ }
514
+ const skillIds: string[] = [];
515
+ let reused = 0;
516
+ let created = 0;
517
+ for (const skill of skillFiles) {
518
+ const wasExisting = existingByTitle.has(skill.slug);
519
+ const id = await uploadOne(client, skill.slug, skill.content, log, existingByTitle);
520
+ if (id) {
521
+ skillIds.push(id);
522
+ if (wasExisting) reused++;
523
+ else created++;
524
+ }
525
+ }
526
+ log(` reused ${reused} existing skill(s); uploaded ${created} new skill(s)`);
527
+
528
+ // 3. Create the agent.
529
+ //
530
+ // NOTE: Anthropic's managed-agents `mcp_servers` URL params don't accept
531
+ // inline auth — credentials must live in an Anthropic-side vault keyed by
532
+ // `mcp_server_url`. Until the setup CLI auto-provisions that vault (see
533
+ // follow-up ticket — `MANAGED_DISABLE_MCP=1` is the temporary bypass), the
534
+ // agent must be created WITHOUT mcp_servers / mcp_toolset, otherwise every
535
+ // session errors out with `mcp_authentication_failed_error`.
536
+ const mcpDisabled = config.disableMcp;
537
+ const mcpServer: BetaManagedAgentsURLMCPServerParams | null = mcpDisabled
538
+ ? null
539
+ : {
540
+ name: "agent-swarm",
541
+ type: "url",
542
+ url: `${config.mcpBaseUrl.replace(/\/$/, "")}/mcp`,
543
+ };
544
+ const skillsParam: BetaManagedAgentsCustomSkillParams[] = skillIds.map((id) => ({
545
+ type: "custom",
546
+ skill_id: id,
547
+ }));
548
+ const agentParams: AgentCreateParams = {
549
+ name: "swarm-worker",
550
+ model: config.agentModel,
551
+ description:
552
+ "Agent Swarm worker. Per-task system prompt is delivered in the user.message; the static system field is intentionally minimal.",
553
+ system: mcpServer
554
+ ? "You are an agent-swarm worker. Per-task instructions arrive in the next user message. Use the agent-swarm MCP server for swarm operations."
555
+ : "You are an agent-swarm worker. Per-task instructions arrive in the next user message. (No MCP tools available in this configuration.)",
556
+ tools: mcpServer
557
+ ? [
558
+ { type: "agent_toolset_20260401" },
559
+ { type: "mcp_toolset", mcp_server_name: mcpServer.name },
560
+ ]
561
+ : [{ type: "agent_toolset_20260401" }],
562
+ skills: skillsParam,
563
+ ...(mcpServer ? { mcp_servers: [mcpServer] } : {}),
564
+ };
565
+ if (mcpDisabled) {
566
+ log("MCP disabled (MANAGED_DISABLE_MCP=1) — agent will run without swarm tools.");
567
+ }
568
+
569
+ log(`Creating agent (model=${config.agentModel}) ...`);
570
+ const agent: BetaManagedAgentsAgent = await client.beta.agents.create(agentParams);
571
+ log(` + agent id=${agent.id}`);
572
+
573
+ // 3b. Upsert MCP vault + static-bearer credential when MCP is enabled.
574
+ // Anthropic's managed sandbox refuses to call our `/mcp` endpoint until a
575
+ // matching vault credential exists. Without this step the operator would
576
+ // have to configure the vault manually via the Anthropic dashboard — see
577
+ // DES-279 in the QA report (`thoughts/taras/qa/2026-04-29-…`) for context.
578
+ let mcpVaultId: string | null = null;
579
+ if (mcpServer) {
580
+ log("Upserting Anthropic MCP vault + static-bearer credential ...");
581
+ try {
582
+ mcpVaultId = await upsertMcpVault(client, mcpServer.url, config.apiKey, config.force, log);
583
+ } catch (err) {
584
+ log(` · MCP vault upsert failed: ${(err as Error).message}`);
585
+ log(
586
+ " · The agent was created but MCP tool calls will fail with " +
587
+ "`mcp_authentication_failed_error` until a vault credential exists. " +
588
+ "Re-run with --force, or configure the vault manually via the Anthropic dashboard.",
589
+ );
590
+ }
591
+ }
592
+
593
+ // 4. Persist IDs to swarm_config.
594
+ log("Persisting IDs to swarm_config via PUT /api/config ...");
595
+ await upsert(config.apiUrl, config.apiKey, {
596
+ key: CONFIG_KEY_AGENT_ID,
597
+ value: agent.id,
598
+ isSecret: false,
599
+ description: "Anthropic Managed Agents agent ID (claude-managed-setup)",
600
+ });
601
+ await upsert(config.apiUrl, config.apiKey, {
602
+ key: CONFIG_KEY_ENVIRONMENT_ID,
603
+ value: env.id,
604
+ isSecret: false,
605
+ description: "Anthropic Managed Agents environment ID (claude-managed-setup)",
606
+ });
607
+ await upsert(config.apiUrl, config.apiKey, {
608
+ key: CONFIG_KEY_API_KEY,
609
+ value: config.anthropicApiKey,
610
+ isSecret: true,
611
+ description: "Anthropic API key for claude-managed provider",
612
+ });
613
+ if (mcpVaultId) {
614
+ await upsert(config.apiUrl, config.apiKey, {
615
+ key: CONFIG_KEY_MCP_VAULT_ID,
616
+ value: mcpVaultId,
617
+ isSecret: false,
618
+ description:
619
+ "Anthropic vault id holding the static-bearer credential for the swarm MCP server",
620
+ });
621
+ log(
622
+ ` + persisted managed_agent_id, managed_environment_id, anthropic_api_key, managed_mcp_vault_id`,
623
+ );
624
+ } else {
625
+ log(" + persisted managed_agent_id, managed_environment_id, anthropic_api_key");
626
+ }
627
+
628
+ log("");
629
+ log("Done. Add the following to your .env if you prefer env-based config:");
630
+ log(` HARNESS_PROVIDER=claude-managed`);
631
+ log(` ANTHROPIC_API_KEY=<the key you just provided>`);
632
+ log(` MANAGED_AGENT_ID=${agent.id}`);
633
+ log(` MANAGED_ENVIRONMENT_ID=${env.id}`);
634
+ if (mcpVaultId) log(` MANAGED_MCP_VAULT_ID=${mcpVaultId}`);
635
+ log("");
636
+ log(
637
+ "Or skip the .env entries — deployed workers automatically restore these from " +
638
+ "swarm_config at boot via docker-entrypoint.sh.",
639
+ );
640
+
641
+ return {
642
+ agentId: agent.id,
643
+ environmentId: env.id,
644
+ skillIds,
645
+ alreadyConfigured: false,
646
+ mcpVaultId,
647
+ };
648
+ }
649
+
650
+ // ─── Entry point ─────────────────────────────────────────────────────────────
651
+
652
+ export type RunClaudeManagedSetupDepsRoot = {
653
+ resolveConfig?: typeof resolveClaudeManagedSetupConfig;
654
+ flow?: typeof runClaudeManagedSetupFlow;
655
+ log?: (msg: string) => void;
656
+ error?: (msg: string) => void;
657
+ exit?: (code: number) => void;
658
+ };
659
+
660
+ export async function runClaudeManagedSetup(
661
+ args: string[],
662
+ deps: RunClaudeManagedSetupDepsRoot = {},
663
+ ): Promise<void> {
664
+ const log = deps.log ?? ((msg: string) => console.log(msg));
665
+ const error = deps.error ?? ((msg: string) => console.error(msg));
666
+ const exit = deps.exit ?? ((code: number) => process.exit(code));
667
+ const resolveConfig = deps.resolveConfig ?? resolveClaudeManagedSetupConfig;
668
+ const flow = deps.flow ?? runClaudeManagedSetupFlow;
669
+
670
+ if (parseArgs(args).showHelp) {
671
+ printHelp();
672
+ return;
673
+ }
674
+
675
+ try {
676
+ const config = await resolveConfig(args);
677
+ log(`Target swarm API: ${config.apiUrl}`);
678
+ log(`MCP base URL : ${config.mcpBaseUrl}`);
679
+ log(`Agent model : ${config.agentModel}`);
680
+ log("");
681
+ await flow(config);
682
+ } catch (err) {
683
+ const message = err instanceof Error ? err.message : String(err);
684
+ error(`\n[claude-managed-setup] ${message}`);
685
+ exit(1);
686
+ }
687
+ }
@@ -73,7 +73,7 @@ async function promptTextInput(label: string, defaultValue: string): Promise<str
73
73
  });
74
74
  }
75
75
 
76
- async function promptHiddenInput(
76
+ export async function promptHiddenInput(
77
77
  label: string,
78
78
  _defaultValue: string,
79
79
  helpText?: string,