@desplega.ai/agent-swarm 1.0.2 → 1.2.1

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.
@@ -1,579 +1,608 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Spinner, TextInput } from "@inkjs/ui";
3
- import { Box, Text, useApp, useInput } from "ink";
4
- import { useEffect, useState } from "react";
3
+ import { Box, Text, useApp } from "ink";
4
+ import { useCallback, useEffect, useRef, useState } from "react";
5
5
  import pkg from "../../package.json";
6
6
 
7
- // @ts-ignore
8
- const SERVER_NAME = pkg.config?.name ?? "agent-swarm";
7
+ const SERVER_NAME = (pkg as { config?: { name?: string } }).config?.name ?? "agent-swarm";
9
8
  const PKG_NAME = pkg.name;
10
9
  const DEFAULT_MCP_BASE_URL = "https://agent-swarm-mcp.desplega.sh";
11
10
 
12
- const UUID_REGEX =
13
- /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
11
+ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
14
12
 
15
13
  const isValidUUID = (value: string): boolean => UUID_REGEX.test(value);
16
14
 
17
15
  type SetupStep =
18
- | "check_dirs"
19
- | "restoring"
20
- | "input_token"
21
- | "input_agent_id"
22
- | "updating"
23
- | "done"
24
- | "error";
16
+ | "check_dirs"
17
+ | "restoring"
18
+ | "input_token"
19
+ | "input_agent_id"
20
+ | "updating"
21
+ | "done"
22
+ | "error";
25
23
 
26
24
  interface SetupProps {
27
- dryRun?: boolean;
28
- restore?: boolean;
25
+ dryRun?: boolean;
26
+ restore?: boolean;
27
+ yes?: boolean;
29
28
  }
30
29
 
31
- const BACKUP_FILES = [
32
- ".claude/settings.local.json",
33
- ".mcp.json",
34
- ".gitignore",
35
- ];
30
+ const BACKUP_FILES = [".claude/settings.local.json", ".mcp.json", ".gitignore"];
36
31
 
37
32
  interface SetupState {
38
- step: SetupStep;
39
- token: string;
40
- agentId: string;
41
- existingToken: string;
42
- existingAgentId: string;
43
- error: string | null;
44
- logs: string[];
45
- isGitRepo: boolean;
33
+ step: SetupStep;
34
+ token: string;
35
+ agentId: string;
36
+ existingToken: string;
37
+ existingAgentId: string;
38
+ error: string | null;
39
+ logs: string[];
40
+ isGitRepo: boolean;
46
41
  }
47
42
 
48
43
  const createDefaultSettingsLocal = () => ({
49
- permissions: {
50
- allow: [],
51
- },
52
- enableAllProjectMcpServers: false,
53
- enabledMcpjsonServers: [],
54
- hooks: {},
44
+ permissions: {
45
+ allow: [],
46
+ },
47
+ enableAllProjectMcpServers: false,
48
+ enabledMcpjsonServers: [],
49
+ hooks: {},
55
50
  });
56
51
 
57
52
  const createDefaultMcpJson = () => ({
58
- mcpServers: {},
53
+ mcpServers: {},
59
54
  });
60
55
 
61
56
  const createHooksConfig = () => {
62
- const hookCommand = `bunx ${PKG_NAME}@latest hook`;
63
- const hookEntry = {
64
- matcher: "*",
65
- hooks: [
66
- {
67
- type: "command",
68
- command: hookCommand,
69
- },
70
- ],
71
- };
72
-
73
- return {
74
- SessionStart: [hookEntry],
75
- UserPromptSubmit: [hookEntry],
76
- PreToolUse: [hookEntry],
77
- PostToolUse: [hookEntry],
78
- PreCompact: [hookEntry],
79
- Stop: [hookEntry],
80
- };
57
+ const hookCommand = `bunx ${PKG_NAME}@latest hook`;
58
+ const hookEntry = {
59
+ matcher: "*",
60
+ hooks: [
61
+ {
62
+ type: "command",
63
+ command: hookCommand,
64
+ },
65
+ ],
66
+ };
67
+
68
+ return {
69
+ SessionStart: [hookEntry],
70
+ UserPromptSubmit: [hookEntry],
71
+ PreToolUse: [hookEntry],
72
+ PostToolUse: [hookEntry],
73
+ PreCompact: [hookEntry],
74
+ Stop: [hookEntry],
75
+ };
81
76
  };
82
77
 
83
- export function Setup({ dryRun = false, restore = false }: SetupProps) {
84
- const { exit } = useApp();
85
- const [state, setState] = useState<SetupState>({
86
- step: restore ? "restoring" : "check_dirs",
87
- token: "",
88
- agentId: "",
89
- existingToken: "",
90
- existingAgentId: "",
91
- error: null,
92
- logs: [],
93
- isGitRepo: false,
94
- });
95
-
96
- const addLog = (log: string, isDryRunAction = false) => {
97
- const prefix = isDryRunAction && dryRun ? "[DRY-RUN] Would: " : "";
98
- setState((s) => ({ ...s, logs: [...s.logs, `${prefix}${log}`] }));
99
- };
100
-
101
- // Helper to create backup
102
- const createBackup = async (filePath: string): Promise<boolean> => {
103
- const file = Bun.file(filePath);
104
- if (await file.exists()) {
105
- const backupPath = `${filePath}.bak`;
106
- if (!dryRun) {
107
- const content = await file.text();
108
- await Bun.write(backupPath, content);
109
- }
110
- addLog(`Backup: ${filePath} -> ${filePath}.bak`, true);
111
- return true;
112
- }
113
- return false;
114
- };
115
-
116
- // Handle restore mode
117
- useEffect(() => {
118
- if (state.step !== "restoring") return;
119
-
120
- const restoreFiles = async () => {
121
- const cwd = process.cwd();
122
- let restoredCount = 0;
123
-
124
- for (const relativePath of BACKUP_FILES) {
125
- const backupPath = `${cwd}/${relativePath}.bak`;
126
- const originalPath = `${cwd}/${relativePath}`;
127
- const backupFile = Bun.file(backupPath);
128
-
129
- if (await backupFile.exists()) {
130
- if (!dryRun) {
131
- const content = await backupFile.text();
132
- await Bun.write(originalPath, content);
133
- await Bun.$`rm ${backupPath}`;
134
- }
135
- addLog(`Restore: ${relativePath}.bak -> ${relativePath}`, true);
136
- restoredCount++;
137
- } else {
138
- addLog(`No backup found: ${relativePath}.bak`);
139
- }
140
- }
141
-
142
- if (restoredCount === 0) {
143
- setState((s) => ({
144
- ...s,
145
- step: "error",
146
- error: "No backup files found to restore",
147
- }));
148
- } else {
149
- setState((s) => ({ ...s, step: "done" }));
150
- }
151
- };
152
-
153
- restoreFiles().catch((err) => {
154
- setState((s) => ({ ...s, step: "error", error: err.message }));
155
- });
156
- }, [state.step, dryRun]);
157
-
158
- // Step 1: Check and create directories/files
159
- useEffect(() => {
160
- if (state.step !== "check_dirs") return;
161
-
162
- const checkDirs = async () => {
163
- const cwd = process.cwd();
164
-
165
- // Check if .claude dir exists
166
- const claudeDir = Bun.file(`${cwd}/.claude`);
167
- if (!(await claudeDir.exists())) {
168
- if (!dryRun) {
169
- await Bun.$`mkdir -p ${cwd}/.claude`;
170
- }
171
- addLog("Create .claude directory", true);
172
- } else {
173
- addLog(".claude directory exists");
174
- }
175
-
176
- // Check if .claude/settings.local.json exists
177
- const settingsFile = Bun.file(`${cwd}/.claude/settings.local.json`);
178
- if (!(await settingsFile.exists())) {
179
- if (!dryRun) {
180
- await Bun.write(
181
- settingsFile,
182
- JSON.stringify(createDefaultSettingsLocal(), null, 2),
183
- );
184
- }
185
- addLog("Create .claude/settings.local.json", true);
186
- } else {
187
- addLog(".claude/settings.local.json exists");
188
- }
189
-
190
- // Check if .mcp.json exists
191
- const mcpFile = Bun.file(`${cwd}/.mcp.json`);
192
- if (!(await mcpFile.exists())) {
193
- if (!dryRun) {
194
- await Bun.write(mcpFile, JSON.stringify(createDefaultMcpJson(), null, 2));
195
- }
196
- addLog("Create .mcp.json", true);
197
- } else {
198
- addLog(".mcp.json exists");
199
- }
200
-
201
- // Check if it's a git repo by finding the git root
202
- let isGitRepo = false;
203
- let gitRoot = "";
204
- try {
205
- const result = await Bun.$`git -C ${cwd} rev-parse --show-toplevel`.quiet();
206
- gitRoot = result.text().trim();
207
- isGitRepo = result.exitCode === 0 && gitRoot.length > 0;
208
- } catch {
209
- isGitRepo = false;
210
- }
211
-
212
- if (isGitRepo) {
213
- addLog(`Git repository detected (root: ${gitRoot})`);
214
-
215
- // Check .gitignore at git root
216
- const gitignoreFile = Bun.file(`${gitRoot}/.gitignore`);
217
- let gitignoreContent = "";
218
-
219
- if (await gitignoreFile.exists()) {
220
- gitignoreContent = await gitignoreFile.text();
221
- }
222
-
223
- const entriesToAdd: string[] = [];
224
- if (!gitignoreContent.includes(".claude")) {
225
- entriesToAdd.push(".claude");
226
- }
227
- if (!gitignoreContent.includes(".mcp.json")) {
228
- entriesToAdd.push(".mcp.json");
229
- }
230
-
231
- if (entriesToAdd.length > 0) {
232
- // Backup .gitignore before modifying
233
- await createBackup(`${gitRoot}/.gitignore`);
234
- if (!dryRun) {
235
- const newEntries = `# Added by ${SERVER_NAME} setup\n${entriesToAdd.join("\n")}\n\n`;
236
- await Bun.write(gitignoreFile, newEntries + gitignoreContent);
237
- }
238
- addLog(`Add to .gitignore: ${entriesToAdd.join(", ")}`, true);
239
- } else {
240
- addLog(".gitignore already contains required entries");
241
- }
242
- } else {
243
- addLog("Not a git repository (skipping .gitignore update)");
244
- }
245
-
246
- // Try to read existing values from .mcp.json
247
- let existingToken = "";
248
- let existingAgentId = "";
249
- try {
250
- const mcpFile = Bun.file(`${cwd}/.mcp.json`);
251
- if (await mcpFile.exists()) {
252
- const mcpConfig = await mcpFile.json();
253
- const serverConfig = mcpConfig?.mcpServers?.[SERVER_NAME];
254
- if (serverConfig?.headers) {
255
- const authHeader = serverConfig.headers.Authorization || "";
256
- if (authHeader.startsWith("Bearer ")) {
257
- existingToken = authHeader.slice(7);
258
- }
259
- existingAgentId = serverConfig.headers["X-Agent-ID"] || "";
260
- }
261
- if (existingToken || existingAgentId) {
262
- addLog("Found existing configuration values");
263
- }
264
- }
265
- } catch {
266
- // Ignore errors reading existing config
267
- }
268
-
269
- setState((s) => ({
270
- ...s,
271
- step: "input_token",
272
- isGitRepo,
273
- existingToken,
274
- existingAgentId,
275
- }));
276
- };
277
-
278
- checkDirs().catch((err) => {
279
- setState((s) => ({ ...s, step: "error", error: err.message }));
280
- });
281
- }, [state.step, dryRun]);
282
-
283
- // Handle final update step
284
- useEffect(() => {
285
- if (state.step !== "updating") return;
286
-
287
- const updateFiles = async () => {
288
- const cwd = process.cwd();
289
- const mcpBaseUrl =
290
- process.env.MCP_BASE_URL || DEFAULT_MCP_BASE_URL;
291
-
292
- // For dry-run, show what would be written
293
- const generatedAgentId = state.agentId || crypto.randomUUID();
294
-
295
- // Create backups before modifying
296
- await createBackup(`${cwd}/.claude/settings.local.json`);
297
- await createBackup(`${cwd}/.mcp.json`);
298
-
299
- // Update .claude/settings.local.json
300
- const settingsFile = Bun.file(`${cwd}/.claude/settings.local.json`);
301
- let settings: Record<string, unknown>;
302
-
303
- if (dryRun && !(await settingsFile.exists())) {
304
- settings = createDefaultSettingsLocal();
305
- } else {
306
- settings = await settingsFile.json();
307
- }
308
-
309
- // Ensure permissions.allow exists and add mcp__agent-swarm__*
310
- if (!settings.permissions) {
311
- settings.permissions = { allow: [] };
312
- }
313
- const permissions = settings.permissions as { allow: string[] };
314
- if (!permissions.allow) {
315
- permissions.allow = [];
316
- }
317
- const permissionEntry = `mcp__${SERVER_NAME}__*`;
318
- if (!permissions.allow.includes(permissionEntry)) {
319
- permissions.allow.push(permissionEntry);
320
- addLog(`Add "${permissionEntry}" to permissions.allow`, true);
321
- }
322
-
323
- // Ensure enabledMcpjsonServers exists and add agent-swarm
324
- if (!settings.enabledMcpjsonServers) {
325
- settings.enabledMcpjsonServers = [];
326
- }
327
- const enabledServers = settings.enabledMcpjsonServers as string[];
328
- if (!enabledServers.includes(SERVER_NAME)) {
329
- enabledServers.push(SERVER_NAME);
330
- addLog(`Add "${SERVER_NAME}" to enabledMcpjsonServers`, true);
331
- }
332
-
333
- // Add hooks
334
- const newHooks = createHooksConfig();
335
- settings.hooks = { ...(settings.hooks as object || {}), ...newHooks };
336
- addLog("Add hooks configuration", true);
337
-
338
- if (!dryRun) {
339
- await Bun.write(settingsFile, JSON.stringify(settings, null, 2));
340
- }
341
- addLog("Update .claude/settings.local.json", true);
342
-
343
- // Update .mcp.json
344
- const mcpFile = Bun.file(`${cwd}/.mcp.json`);
345
- let mcpConfig: Record<string, unknown>;
346
-
347
- if (dryRun && !(await mcpFile.exists())) {
348
- mcpConfig = createDefaultMcpJson();
349
- } else {
350
- mcpConfig = await mcpFile.json();
351
- }
352
-
353
- if (!mcpConfig.mcpServers) {
354
- mcpConfig.mcpServers = {};
355
- }
356
-
357
- const mcpServers = mcpConfig.mcpServers as Record<string, unknown>;
358
- mcpServers[SERVER_NAME] = {
359
- type: "http",
360
- url: `${mcpBaseUrl}/mcp`,
361
- headers: {
362
- Authorization: `Bearer ${state.token}`,
363
- "X-Agent-ID": generatedAgentId,
364
- },
365
- };
366
-
367
- if (!dryRun) {
368
- await Bun.write(mcpFile, JSON.stringify(mcpConfig, null, 2));
369
- }
370
- addLog("Update .mcp.json with server configuration", true);
371
-
372
- if (dryRun) {
373
- addLog("");
374
- addLog(`Agent ID that would be used: ${generatedAgentId}`);
375
- }
376
-
377
- setState((s) => ({ ...s, step: "done" }));
378
- };
379
-
380
- updateFiles().catch((err) => {
381
- setState((s) => ({ ...s, step: "error", error: err.message }));
382
- });
383
- }, [state.step, state.token, state.agentId, dryRun]);
384
-
385
- // Exit on done
386
- useEffect(() => {
387
- if (state.step === "done" || state.step === "error") {
388
- const timer = setTimeout(() => exit(), 500);
389
- return () => clearTimeout(timer);
390
- }
391
- }, [state.step, exit]);
392
-
393
- if (state.step === "error") {
394
- return (
395
- <Box flexDirection="column" padding={1}>
396
- <Text color="red">Setup failed: {state.error}</Text>
397
- </Box>
398
- );
399
- }
400
-
401
- if (state.step === "restoring") {
402
- return (
403
- <Box flexDirection="column" padding={1}>
404
- <Box flexDirection="column" marginBottom={1}>
405
- {state.logs.map((log, i) => (
406
- <Text key={i} dimColor>
407
- {log}
408
- </Text>
409
- ))}
410
- </Box>
411
- <Spinner label="Restoring from backups..." />
412
- </Box>
413
- );
414
- }
415
-
416
- if (state.step === "check_dirs") {
417
- return (
418
- <Box padding={1}>
419
- <Spinner label="Checking directories and files..." />
420
- </Box>
421
- );
422
- }
423
-
424
- if (state.step === "input_token") {
425
- return (
426
- <Box flexDirection="column" padding={1}>
427
- <Box flexDirection="column" marginBottom={1}>
428
- {state.logs.map((log, i) => (
429
- <Text key={i} dimColor>
430
- {log}
431
- </Text>
432
- ))}
433
- </Box>
434
- <Box flexDirection="column">
435
- <Box>
436
- <Text bold>Enter your API token</Text>
437
- {state.existingToken && (
438
- <Text dimColor> (current: {state.existingToken.slice(0, 8)}...)</Text>
439
- )}
440
- <Text bold>: </Text>
441
- </Box>
442
- <TextInput
443
- key="token-input"
444
- defaultValue={state.existingToken}
445
- placeholder="your-api-token"
446
- onSubmit={(value) => {
447
- if (!value.trim()) {
448
- setState((s) => ({
449
- ...s,
450
- step: "error",
451
- error: "API token is required",
452
- }));
453
- return;
454
- }
455
- setState((s) => ({ ...s, token: value, step: "input_agent_id" }));
456
- }}
457
- />
458
- </Box>
459
- </Box>
460
- );
461
- }
462
-
463
- if (state.step === "input_agent_id") {
464
- return (
465
- <Box flexDirection="column" padding={1}>
466
- <Box flexDirection="column" marginBottom={1}>
467
- {state.logs.map((log, i) => (
468
- <Text key={i} dimColor>
469
- {log}
470
- </Text>
471
- ))}
472
- </Box>
473
- <Box flexDirection="column">
474
- <Box>
475
- <Text bold>Enter your Agent ID</Text>
476
- {state.existingAgentId && (
477
- <Text dimColor> (current: {state.existingAgentId})</Text>
478
- )}
479
- </Box>
480
- <Text dimColor>(optional, press Enter to generate a new one): </Text>
481
- <TextInput
482
- key="agent-id-input"
483
- defaultValue={state.existingAgentId}
484
- placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
485
- onSubmit={(value) => {
486
- const trimmed = value.trim();
487
- if (trimmed && !isValidUUID(trimmed)) {
488
- setState((s) => ({
489
- ...s,
490
- step: "error",
491
- error: "Invalid UUID format. Expected: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
492
- }));
493
- return;
494
- }
495
- setState((s) => ({ ...s, agentId: trimmed, step: "updating" }));
496
- }}
497
- />
498
- </Box>
499
- </Box>
500
- );
501
- }
502
-
503
- if (state.step === "updating") {
504
- return (
505
- <Box flexDirection="column" padding={1}>
506
- <Box flexDirection="column" marginBottom={1}>
507
- {state.logs.map((log, i) => (
508
- <Text key={i} dimColor>
509
- {log}
510
- </Text>
511
- ))}
512
- </Box>
513
- <Spinner label="Updating configuration files..." />
514
- </Box>
515
- );
516
- }
517
-
518
- if (state.step === "done") {
519
- const mcpBaseUrl = process.env.MCP_BASE_URL || DEFAULT_MCP_BASE_URL;
520
-
521
- const getDoneMessage = () => {
522
- if (dryRun && restore) return "Dry-run restore complete!";
523
- if (dryRun) return "Dry-run complete!";
524
- if (restore) return "Restore complete!";
525
- return "Setup complete!";
526
- };
527
-
528
- return (
529
- <Box flexDirection="column" padding={1}>
530
- {dryRun && (
531
- <Box marginBottom={1}>
532
- <Text color="yellow" bold>
533
- DRY-RUN MODE - No changes were made
534
- </Text>
535
- </Box>
536
- )}
537
- <Box flexDirection="column" marginBottom={1}>
538
- {state.logs.map((log, i) => (
539
- <Text key={i} dimColor>
540
- {log}
541
- </Text>
542
- ))}
543
- </Box>
544
- <Box marginTop={1}>
545
- <Text color="green">{getDoneMessage()}</Text>
546
- </Box>
547
- {!dryRun && !restore && (
548
- <Box flexDirection="column" marginTop={1}>
549
- <Text bold>Next steps:</Text>
550
- <Text>
551
- 1. Set the <Text color="cyan">MCP_BASE_URL</Text> environment variable
552
- in your .env file
553
- </Text>
554
- <Text dimColor> (Default: {DEFAULT_MCP_BASE_URL})</Text>
555
- <Text dimColor> (Current: {mcpBaseUrl})</Text>
556
- <Text>2. Restart Claude Code to apply the changes</Text>
557
- </Box>
558
- )}
559
- {!dryRun && restore && (
560
- <Box flexDirection="column" marginTop={1}>
561
- <Text>Files restored from backups. Restart Claude Code to apply.</Text>
562
- </Box>
563
- )}
564
- {dryRun && !restore && (
565
- <Box flexDirection="column" marginTop={1}>
566
- <Text>Run without --dry-run to apply these changes.</Text>
567
- </Box>
568
- )}
569
- {dryRun && restore && (
570
- <Box flexDirection="column" marginTop={1}>
571
- <Text>Run without --dry-run to restore from backups.</Text>
572
- </Box>
573
- )}
574
- </Box>
575
- );
576
- }
577
-
578
- return null;
78
+ export function Setup({ dryRun = false, restore = false, yes = false }: SetupProps) {
79
+ const { exit } = useApp();
80
+ const [state, setState] = useState<SetupState>({
81
+ step: restore ? "restoring" : "check_dirs",
82
+ token: yes ? process.env.API_KEY || "" : "",
83
+ agentId: yes ? process.env.AGENT_ID || "" : "",
84
+ existingToken: "",
85
+ existingAgentId: "",
86
+ error: null,
87
+ logs: [],
88
+ isGitRepo: false,
89
+ });
90
+
91
+ // Track which steps have been executed to prevent duplicates
92
+ const executedSteps = useRef<Set<SetupStep>>(new Set());
93
+
94
+ const addLog = useCallback(
95
+ (log: string, isDryRunAction = false) => {
96
+ const prefix = isDryRunAction && dryRun ? "[DRY-RUN] Would: " : "";
97
+ setState((s) => ({ ...s, logs: [...s.logs, `${prefix}${log}`] }));
98
+ },
99
+ [dryRun],
100
+ );
101
+
102
+ // Helper to create backup
103
+ const createBackup = useCallback(
104
+ async (filePath: string): Promise<boolean> => {
105
+ const file = Bun.file(filePath);
106
+ if (await file.exists()) {
107
+ const backupPath = `${filePath}.bak`;
108
+ if (!dryRun) {
109
+ const content = await file.text();
110
+ await Bun.write(backupPath, content);
111
+ }
112
+ addLog(`Backup: ${filePath} -> ${filePath}.bak`, true);
113
+ return true;
114
+ }
115
+ return false;
116
+ },
117
+ [dryRun, addLog],
118
+ );
119
+
120
+ // Handle restore mode
121
+ useEffect(() => {
122
+ if (state.step !== "restoring") return;
123
+ if (executedSteps.current.has("restoring")) return;
124
+ executedSteps.current.add("restoring");
125
+
126
+ const restoreFiles = async () => {
127
+ const cwd = process.cwd();
128
+ let restoredCount = 0;
129
+
130
+ for (const relativePath of BACKUP_FILES) {
131
+ const backupPath = `${cwd}/${relativePath}.bak`;
132
+ const originalPath = `${cwd}/${relativePath}`;
133
+ const backupFile = Bun.file(backupPath);
134
+
135
+ if (await backupFile.exists()) {
136
+ if (!dryRun) {
137
+ const content = await backupFile.text();
138
+ await Bun.write(originalPath, content);
139
+ await Bun.$`rm ${backupPath}`;
140
+ }
141
+ addLog(`Restore: ${relativePath}.bak -> ${relativePath}`, true);
142
+ restoredCount++;
143
+ } else {
144
+ addLog(`No backup found: ${relativePath}.bak`);
145
+ }
146
+ }
147
+
148
+ if (restoredCount === 0) {
149
+ setState((s) => ({
150
+ ...s,
151
+ step: "error",
152
+ error: "No backup files found to restore",
153
+ }));
154
+ } else {
155
+ setState((s) => ({ ...s, step: "done" }));
156
+ }
157
+ };
158
+
159
+ restoreFiles().catch((err) => {
160
+ setState((s) => ({ ...s, step: "error", error: err.message }));
161
+ });
162
+ }, [state.step, dryRun, addLog]);
163
+
164
+ // Step 1: Check and create directories/files
165
+ useEffect(() => {
166
+ if (state.step !== "check_dirs") return;
167
+ if (executedSteps.current.has("check_dirs")) return;
168
+ executedSteps.current.add("check_dirs");
169
+
170
+ const checkDirs = async () => {
171
+ const cwd = process.cwd();
172
+
173
+ // Check if .claude dir exists
174
+ const claudeDir = Bun.file(`${cwd}/.claude`);
175
+ if (!(await claudeDir.exists())) {
176
+ if (!dryRun) {
177
+ await Bun.$`mkdir -p ${cwd}/.claude`;
178
+ }
179
+ addLog("Create .claude directory", true);
180
+ } else {
181
+ addLog(".claude directory exists");
182
+ }
183
+
184
+ // Check if .claude/settings.local.json exists
185
+ const settingsFile = Bun.file(`${cwd}/.claude/settings.local.json`);
186
+ if (!(await settingsFile.exists())) {
187
+ if (!dryRun) {
188
+ await Bun.write(settingsFile, JSON.stringify(createDefaultSettingsLocal(), null, 2));
189
+ }
190
+ addLog("Create .claude/settings.local.json", true);
191
+ } else {
192
+ addLog(".claude/settings.local.json exists");
193
+ }
194
+
195
+ // Check if .mcp.json exists
196
+ const mcpFile = Bun.file(`${cwd}/.mcp.json`);
197
+ if (!(await mcpFile.exists())) {
198
+ if (!dryRun) {
199
+ await Bun.write(mcpFile, JSON.stringify(createDefaultMcpJson(), null, 2));
200
+ }
201
+ addLog("Create .mcp.json", true);
202
+ } else {
203
+ addLog(".mcp.json exists");
204
+ }
205
+
206
+ // Check if it's a git repo by finding the git root
207
+ let isGitRepo = false;
208
+ let gitRoot = "";
209
+ try {
210
+ const result = await Bun.$`git -C ${cwd} rev-parse --show-toplevel`.quiet();
211
+ gitRoot = result.text().trim();
212
+ isGitRepo = result.exitCode === 0 && gitRoot.length > 0;
213
+ } catch {
214
+ isGitRepo = false;
215
+ }
216
+
217
+ if (isGitRepo) {
218
+ addLog(`Git repository detected (root: ${gitRoot})`);
219
+
220
+ // Check .gitignore at git root
221
+ const gitignoreFile = Bun.file(`${gitRoot}/.gitignore`);
222
+ let gitignoreContent = "";
223
+
224
+ if (await gitignoreFile.exists()) {
225
+ gitignoreContent = await gitignoreFile.text();
226
+ }
227
+
228
+ const entriesToAdd: string[] = [];
229
+ if (!gitignoreContent.includes(".claude")) {
230
+ entriesToAdd.push(".claude");
231
+ }
232
+ if (!gitignoreContent.includes(".mcp.json")) {
233
+ entriesToAdd.push(".mcp.json");
234
+ }
235
+
236
+ if (entriesToAdd.length > 0) {
237
+ // Backup .gitignore before modifying
238
+ await createBackup(`${gitRoot}/.gitignore`);
239
+ if (!dryRun) {
240
+ const newEntries = `# Added by ${SERVER_NAME} setup\n${entriesToAdd.join("\n")}\n\n`;
241
+ await Bun.write(gitignoreFile, newEntries + gitignoreContent);
242
+ }
243
+ addLog(`Add to .gitignore: ${entriesToAdd.join(", ")}`, true);
244
+ } else {
245
+ addLog(".gitignore already contains required entries");
246
+ }
247
+ } else {
248
+ addLog("Not a git repository (skipping .gitignore update)");
249
+ }
250
+
251
+ // Try to read existing values from .mcp.json
252
+ let existingToken = "";
253
+ let existingAgentId = "";
254
+ try {
255
+ const mcpFile = Bun.file(`${cwd}/.mcp.json`);
256
+ if (await mcpFile.exists()) {
257
+ const mcpConfig = await mcpFile.json();
258
+ const serverConfig = mcpConfig?.mcpServers?.[SERVER_NAME];
259
+ if (serverConfig?.headers) {
260
+ const authHeader = serverConfig.headers.Authorization || "";
261
+ if (authHeader.startsWith("Bearer ")) {
262
+ existingToken = authHeader.slice(7);
263
+ }
264
+ existingAgentId = serverConfig.headers["X-Agent-ID"] || "";
265
+ }
266
+ if (existingToken || existingAgentId) {
267
+ addLog("Found existing configuration values");
268
+ }
269
+ }
270
+ } catch {
271
+ // Ignore errors reading existing config
272
+ }
273
+
274
+ // In non-interactive mode (yes=true), skip prompts and go directly to updating
275
+ if (yes) {
276
+ const token = process.env.API_KEY;
277
+ const agentId = process.env.AGENT_ID;
278
+
279
+ if (!token) {
280
+ setState((s) => ({
281
+ ...s,
282
+ step: "error",
283
+ error: "API_KEY environment variable is required in non-interactive mode (-y/--yes)",
284
+ }));
285
+ return;
286
+ }
287
+
288
+ addLog("Non-interactive mode: using environment variables");
289
+ setState((s) => ({
290
+ ...s,
291
+ step: "updating",
292
+ isGitRepo,
293
+ token,
294
+ agentId: agentId || "",
295
+ }));
296
+ return;
297
+ }
298
+
299
+ setState((s) => ({
300
+ ...s,
301
+ step: "input_token",
302
+ isGitRepo,
303
+ existingToken,
304
+ existingAgentId,
305
+ }));
306
+ };
307
+
308
+ checkDirs().catch((err) => {
309
+ setState((s) => ({ ...s, step: "error", error: err.message }));
310
+ });
311
+ }, [state.step, dryRun, yes, addLog, createBackup]);
312
+
313
+ // Handle final update step
314
+ useEffect(() => {
315
+ if (state.step !== "updating") return;
316
+ if (executedSteps.current.has("updating")) return;
317
+ executedSteps.current.add("updating");
318
+
319
+ const updateFiles = async () => {
320
+ const cwd = process.cwd();
321
+ const mcpBaseUrl = process.env.MCP_BASE_URL || DEFAULT_MCP_BASE_URL;
322
+
323
+ // For dry-run, show what would be written
324
+ const generatedAgentId = state.agentId || crypto.randomUUID();
325
+
326
+ // Create backups before modifying
327
+ await createBackup(`${cwd}/.claude/settings.local.json`);
328
+ await createBackup(`${cwd}/.mcp.json`);
329
+
330
+ // Update .claude/settings.local.json
331
+ const settingsFile = Bun.file(`${cwd}/.claude/settings.local.json`);
332
+ let settings: Record<string, unknown>;
333
+
334
+ if (dryRun && !(await settingsFile.exists())) {
335
+ settings = createDefaultSettingsLocal();
336
+ } else {
337
+ settings = await settingsFile.json();
338
+ }
339
+
340
+ // Ensure permissions.allow exists and add mcp__agent-swarm__*
341
+ if (!settings.permissions) {
342
+ settings.permissions = { allow: [] };
343
+ }
344
+ const permissions = settings.permissions as { allow: string[] };
345
+ if (!permissions.allow) {
346
+ permissions.allow = [];
347
+ }
348
+ const permissionEntry = `mcp__${SERVER_NAME}__*`;
349
+ if (!permissions.allow.includes(permissionEntry)) {
350
+ permissions.allow.push(permissionEntry);
351
+ addLog(`Add "${permissionEntry}" to permissions.allow`, true);
352
+ }
353
+
354
+ // Ensure enabledMcpjsonServers exists and add agent-swarm
355
+ if (!settings.enabledMcpjsonServers) {
356
+ settings.enabledMcpjsonServers = [];
357
+ }
358
+ const enabledServers = settings.enabledMcpjsonServers as string[];
359
+ if (!enabledServers.includes(SERVER_NAME)) {
360
+ enabledServers.push(SERVER_NAME);
361
+ addLog(`Add "${SERVER_NAME}" to enabledMcpjsonServers`, true);
362
+ }
363
+
364
+ // Add hooks
365
+ const newHooks = createHooksConfig();
366
+ settings.hooks = { ...((settings.hooks as object) || {}), ...newHooks };
367
+ addLog("Add hooks configuration", true);
368
+
369
+ if (!dryRun) {
370
+ await Bun.write(settingsFile, JSON.stringify(settings, null, 2));
371
+ }
372
+ addLog("Update .claude/settings.local.json", true);
373
+
374
+ // Update .mcp.json
375
+ const mcpFile = Bun.file(`${cwd}/.mcp.json`);
376
+ let mcpConfig: Record<string, unknown>;
377
+
378
+ if (dryRun && !(await mcpFile.exists())) {
379
+ mcpConfig = createDefaultMcpJson();
380
+ } else {
381
+ mcpConfig = await mcpFile.json();
382
+ }
383
+
384
+ if (!mcpConfig.mcpServers) {
385
+ mcpConfig.mcpServers = {};
386
+ }
387
+
388
+ const mcpServers = mcpConfig.mcpServers as Record<string, unknown>;
389
+ mcpServers[SERVER_NAME] = {
390
+ type: "http",
391
+ url: `${mcpBaseUrl}/mcp`,
392
+ headers: {
393
+ Authorization: `Bearer ${state.token}`,
394
+ "X-Agent-ID": generatedAgentId,
395
+ },
396
+ };
397
+
398
+ if (!dryRun) {
399
+ await Bun.write(mcpFile, JSON.stringify(mcpConfig, null, 2));
400
+ }
401
+ addLog("Update .mcp.json with server configuration", true);
402
+
403
+ if (dryRun) {
404
+ addLog("");
405
+ addLog(`Agent ID that would be used: ${generatedAgentId}`);
406
+ }
407
+
408
+ setState((s) => ({ ...s, step: "done" }));
409
+ };
410
+
411
+ updateFiles().catch((err) => {
412
+ setState((s) => ({ ...s, step: "error", error: err.message }));
413
+ });
414
+ }, [state.step, state.token, state.agentId, dryRun, addLog, createBackup]);
415
+
416
+ // Exit on done
417
+ useEffect(() => {
418
+ if (state.step === "done" || state.step === "error") {
419
+ const timer = setTimeout(() => exit(), 500);
420
+ return () => clearTimeout(timer);
421
+ }
422
+ }, [state.step, exit]);
423
+
424
+ if (state.step === "error") {
425
+ return (
426
+ <Box flexDirection="column" padding={1}>
427
+ <Text color="red">Setup failed: {state.error}</Text>
428
+ </Box>
429
+ );
430
+ }
431
+
432
+ if (state.step === "restoring") {
433
+ return (
434
+ <Box flexDirection="column" padding={1}>
435
+ <Box flexDirection="column" marginBottom={1}>
436
+ {state.logs.map((log, i) => (
437
+ <Text key={`log-${i}-${log.slice(0, 20)}`} dimColor>
438
+ {log}
439
+ </Text>
440
+ ))}
441
+ </Box>
442
+ <Spinner label="Restoring from backups..." />
443
+ </Box>
444
+ );
445
+ }
446
+
447
+ if (state.step === "check_dirs") {
448
+ return (
449
+ <Box padding={1}>
450
+ <Spinner label="Checking directories and files..." />
451
+ </Box>
452
+ );
453
+ }
454
+
455
+ if (state.step === "input_token") {
456
+ return (
457
+ <Box flexDirection="column" padding={1}>
458
+ <Box flexDirection="column" marginBottom={1}>
459
+ {state.logs.map((log, i) => (
460
+ <Text key={`log-${i}-${log.slice(0, 20)}`} dimColor>
461
+ {log}
462
+ </Text>
463
+ ))}
464
+ </Box>
465
+ <Box flexDirection="column">
466
+ <Box>
467
+ <Text bold>Enter your API token</Text>
468
+ {state.existingToken && (
469
+ <Text dimColor> (current: {state.existingToken.slice(0, 8)}...)</Text>
470
+ )}
471
+ <Text bold>: </Text>
472
+ </Box>
473
+ <TextInput
474
+ key="token-input"
475
+ defaultValue={state.existingToken}
476
+ placeholder="your-api-token"
477
+ onSubmit={(value) => {
478
+ if (!value.trim()) {
479
+ setState((s) => ({
480
+ ...s,
481
+ step: "error",
482
+ error: "API token is required",
483
+ }));
484
+ return;
485
+ }
486
+ setState((s) => ({ ...s, token: value, step: "input_agent_id" }));
487
+ }}
488
+ />
489
+ </Box>
490
+ </Box>
491
+ );
492
+ }
493
+
494
+ if (state.step === "input_agent_id") {
495
+ return (
496
+ <Box flexDirection="column" padding={1}>
497
+ <Box flexDirection="column" marginBottom={1}>
498
+ {state.logs.map((log, i) => (
499
+ <Text key={`log-${i}-${log.slice(0, 20)}`} dimColor>
500
+ {log}
501
+ </Text>
502
+ ))}
503
+ </Box>
504
+ <Box flexDirection="column">
505
+ <Box>
506
+ <Text bold>Enter your Agent ID</Text>
507
+ {state.existingAgentId && <Text dimColor> (current: {state.existingAgentId})</Text>}
508
+ </Box>
509
+ <Text dimColor>(optional, press Enter to generate a new one): </Text>
510
+ <TextInput
511
+ key="agent-id-input"
512
+ defaultValue={state.existingAgentId}
513
+ placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
514
+ onSubmit={(value) => {
515
+ const trimmed = value.trim();
516
+ if (trimmed && !isValidUUID(trimmed)) {
517
+ setState((s) => ({
518
+ ...s,
519
+ step: "error",
520
+ error: "Invalid UUID format. Expected: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
521
+ }));
522
+ return;
523
+ }
524
+ setState((s) => ({ ...s, agentId: trimmed, step: "updating" }));
525
+ }}
526
+ />
527
+ </Box>
528
+ </Box>
529
+ );
530
+ }
531
+
532
+ if (state.step === "updating") {
533
+ return (
534
+ <Box flexDirection="column" padding={1}>
535
+ <Box flexDirection="column" marginBottom={1}>
536
+ {state.logs.map((log, i) => (
537
+ <Text key={`log-${i}-${log.slice(0, 20)}`} dimColor>
538
+ {log}
539
+ </Text>
540
+ ))}
541
+ </Box>
542
+ <Spinner label="Updating configuration files..." />
543
+ </Box>
544
+ );
545
+ }
546
+
547
+ if (state.step === "done") {
548
+ const mcpBaseUrl = process.env.MCP_BASE_URL || DEFAULT_MCP_BASE_URL;
549
+
550
+ const getDoneMessage = () => {
551
+ if (dryRun && restore) return "Dry-run restore complete!";
552
+ if (dryRun) return "Dry-run complete!";
553
+ if (restore) return "Restore complete!";
554
+ return "Setup complete!";
555
+ };
556
+
557
+ return (
558
+ <Box flexDirection="column" padding={1}>
559
+ {dryRun && (
560
+ <Box marginBottom={1}>
561
+ <Text color="yellow" bold>
562
+ DRY-RUN MODE - No changes were made
563
+ </Text>
564
+ </Box>
565
+ )}
566
+ <Box flexDirection="column" marginBottom={1}>
567
+ {state.logs.map((log, i) => (
568
+ <Text key={`log-${i}-${log.slice(0, 20)}`} dimColor>
569
+ {log}
570
+ </Text>
571
+ ))}
572
+ </Box>
573
+ <Box marginTop={1}>
574
+ <Text color="green">{getDoneMessage()}</Text>
575
+ </Box>
576
+ {!dryRun && !restore && (
577
+ <Box flexDirection="column" marginTop={1}>
578
+ <Text bold>Next steps:</Text>
579
+ <Text>
580
+ 1. Set the <Text color="cyan">MCP_BASE_URL</Text> environment variable in your .env
581
+ file
582
+ </Text>
583
+ <Text dimColor> (Default: {DEFAULT_MCP_BASE_URL})</Text>
584
+ <Text dimColor> (Current: {mcpBaseUrl})</Text>
585
+ <Text>2. Restart Claude Code to apply the changes</Text>
586
+ </Box>
587
+ )}
588
+ {!dryRun && restore && (
589
+ <Box flexDirection="column" marginTop={1}>
590
+ <Text>Files restored from backups. Restart Claude Code to apply.</Text>
591
+ </Box>
592
+ )}
593
+ {dryRun && !restore && (
594
+ <Box flexDirection="column" marginTop={1}>
595
+ <Text>Run without --dry-run to apply these changes.</Text>
596
+ </Box>
597
+ )}
598
+ {dryRun && restore && (
599
+ <Box flexDirection="column" marginTop={1}>
600
+ <Text>Run without --dry-run to restore from backups.</Text>
601
+ </Box>
602
+ )}
603
+ </Box>
604
+ );
605
+ }
606
+
607
+ return null;
579
608
  }