@swarp/cli 0.0.4 → 0.1.0-rc.34

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.
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "swarp",
3
+ "version": "0.1.0",
4
+ "description": "Agent orchestration platform — deploy remote Claude agents",
5
+ "mcpServers": "./.mcp.json",
6
+ "skills": "./skills/"
7
+ }
package/.mcp.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "swarp": {
4
+ "command": "node",
5
+ "args": ["${CLAUDE_PLUGIN_ROOT}/src/mcp-server/index.mjs"]
6
+ }
7
+ }
8
+ }
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ INPUT=$(cat)
5
+ TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
6
+
7
+ if [ -z "$TOOL_NAME" ]; then
8
+ jq -n '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"block","permissionDecisionReason":"Could not determine tool name."}}' >&2
9
+ exit 1
10
+ fi
11
+
12
+ make_decision() {
13
+ local decision="$1"
14
+ local reason="$2"
15
+ jq -n \
16
+ --arg d "$decision" \
17
+ --arg r "$reason" \
18
+ '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":$d,"permissionDecisionReason":$r}}'
19
+ }
20
+
21
+ case "$TOOL_NAME" in
22
+ *swarp_deploy_router)
23
+ COST_INFO="Fly Machine (shared-cpu-1x, 256MB): ~\$1.94/mo + 1GB volume: ~\$0.15/mo + shared IPv4: \$0/mo. Total: ~\$2.09/mo."
24
+ make_decision "ask" "This will create a Fly Machine + volume. $COST_INFO Billed to your Fly.io account."
25
+ ;;
26
+
27
+ *swarp_deploy)
28
+ AGENT=$(echo "$INPUT" | jq -r '.tool_input.agent // "unknown"')
29
+ make_decision "ask" "This will create a Fly Sprite for agent '$AGENT'. Sprites cost ~\$0.007/min while running (paused when idle). A typical 30-min session costs ~\$0.21. Billed to your Fly.io account."
30
+ ;;
31
+
32
+ *swarp_destroy)
33
+ AGENT=$(echo "$INPUT" | jq -r '.tool_input.agent // "unknown"')
34
+ make_decision "ask" "This will PERMANENTLY destroy the sprite for agent '$AGENT'. Lost: runner process state, session files on sprite. Agent config (agent.yaml) is preserved locally. This cannot be undone."
35
+ ;;
36
+
37
+ *)
38
+ make_decision "ask" "Unknown destructive action: $TOOL_NAME. Proceed with caution."
39
+ ;;
40
+ esac
@@ -0,0 +1,18 @@
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "matcher": "mcp__swarp__swarp_deploy_router",
6
+ "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/confirm-cost.sh" }]
7
+ },
8
+ {
9
+ "matcher": "mcp__swarp__swarp_deploy",
10
+ "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/confirm-cost.sh" }]
11
+ },
12
+ {
13
+ "matcher": "mcp__swarp__swarp_destroy",
14
+ "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/confirm-cost.sh" }]
15
+ }
16
+ ]
17
+ }
18
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarp/cli",
3
- "version": "0.0.4",
3
+ "version": "0.1.0-rc.34",
4
4
  "description": "SWARP agent orchestration platform — CLI, MCP server, generator",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,12 +15,14 @@
15
15
  "./runtimes/fly-sprites": "./src/runtimes/fly-sprites.mjs"
16
16
  },
17
17
  "files": [
18
+ ".claude-plugin/",
19
+ ".mcp.json",
20
+ "hooks/",
21
+ "skills/",
18
22
  "bin/",
19
- "src/",
20
- "proto/"
23
+ "src/"
21
24
  ],
22
25
  "scripts": {
23
- "prepack": "mkdir -p proto/swarp/v1 && cp ../proto/swarp/v1/swarp.proto proto/swarp/v1/",
24
26
  "test": "vitest run",
25
27
  "test:watch": "vitest"
26
28
  },
@@ -0,0 +1,88 @@
1
+ ---
2
+ name: swarp
3
+ description: Agent orchestration — setup, create agents, deploy, manage. Use when user says /swarp, asks about agents, wants to deploy or manage remote Claude agents.
4
+ ---
5
+
6
+ # /swarp
7
+
8
+ SWARP deploys remote Claude agents on Fly Sprites, orchestrated through a central gRPC router.
9
+
10
+ ## First Run
11
+
12
+ If the user runs `/swarp` or asks about setting up agents, call the `swarp_onboard` tool with no arguments. It will tell you the current setup status and what to do next. Follow its instructions exactly.
13
+
14
+ The onboard tool manages a 4-phase workflow:
15
+
16
+ 1. **Prerequisites** — checks flyctl, sprite, gh CLIs are installed
17
+ 2. **Router** — deploys the SWARP router to Fly.io (requires user cost approval)
18
+ 3. **Secrets** — guides setting GitHub repo secrets for CI
19
+ 4. **First Agent** — creates and deploys the first agent
20
+
21
+ Each phase must complete before the next can start. The tool enforces this — do not try to skip phases.
22
+
23
+ ## Commands
24
+
25
+ ### `/swarp` (no arguments)
26
+
27
+ Call `swarp_onboard` with action `status`. Show the result to the user.
28
+
29
+ ### `/swarp create <name>`
30
+
31
+ Help the user create an agent. Ask:
32
+
33
+ 1. What should this agent do? (natural language description)
34
+ 2. What model should it use? (suggest Haiku for fast/cheap, Sonnet for balanced, Opus for complex)
35
+ 3. Any specific tools it should have access to?
36
+
37
+ Generate `agents/<name>/agent.yaml` with appropriate modes based on the answers. Use the Write tool to create the file.
38
+
39
+ ### `/swarp deploy <name>`
40
+
41
+ Call the `swarp_deploy` MCP tool with the agent name. This triggers a cost confirmation prompt — the user must approve before infrastructure is created.
42
+
43
+ ### `/swarp deploy --all`
44
+
45
+ Deploy all agents found in the agents directory. Call `swarp_deploy` for each one. Each triggers its own cost confirmation.
46
+
47
+ ### `/swarp destroy <name>`
48
+
49
+ Call the `swarp_destroy` MCP tool with the agent name. This triggers a confirmation prompt warning about data loss.
50
+
51
+ ### `/swarp status`
52
+
53
+ Call `swarm_status` MCP tool (no arguments for all agents, or with agent name for one).
54
+
55
+ ## Agent YAML Format
56
+
57
+ When creating agents, generate YAML like this:
58
+
59
+ ```yaml
60
+ name: <agent-name>
61
+ version: '1.0.0'
62
+ grpc_port: 50052
63
+ router_url: '${SWARP_ROUTER_URL}'
64
+
65
+ preamble: |
66
+ <describe what the agent does and its personality>
67
+
68
+ modes:
69
+ - name: <primary-mode>
70
+ description: '<what this mode does>'
71
+ model: <claude-sonnet-4-6 or claude-haiku-4-5-20251001>
72
+ max_turns: <10-40 depending on complexity>
73
+ timeout_minutes: <5-30>
74
+ prompt: '{{ task }}'
75
+ allowed_tools:
76
+ - Read
77
+ - Edit
78
+ - Write
79
+ - Bash
80
+ - Glob
81
+ - Grep
82
+ ```
83
+
84
+ ## Important Notes
85
+
86
+ - **Cost gates**: `swarp_deploy`, `swarp_deploy_router`, and `swarp_destroy` all trigger cost confirmation hooks. The user WILL be prompted by Claude Code before these execute. Set their expectations: "This will create infrastructure — you'll see a cost estimate and need to approve."
87
+ - **WireGuard**: Agent sprites connect to the router via WireGuard tunnels over Fly's private network. This is handled automatically during deploy.
88
+ - **OAuth**: External clients (like this Claude Code session) authenticate to the router via Supabase OAuth at swarp.dev. The MCP server handles the token automatically.
@@ -1,108 +1,12 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { execFileSync } from 'node:child_process';
4
- import { fileURLToPath } from 'node:url';
5
- import { runWizard } from './wizard.mjs';
6
- import { generateSkill } from '../skill/generate.mjs';
7
-
8
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
- const TEMPLATES_DIR = path.resolve(__dirname, '../templates');
10
-
11
- // ── Prerequisite check ────────────────────────────────────────────────────────
12
-
13
- const REQUIRED_TOOLS = ['flyctl', 'gh', 'sprite'];
14
-
15
- /**
16
- * Checks whether a binary exists on PATH via `which`.
17
- * Returns true if found, false if not.
18
- *
19
- * @param {string} tool
20
- * @returns {boolean}
21
- */
22
- function toolExists(tool) {
23
- try {
24
- execFileSync('which', [tool], { stdio: 'ignore' });
25
- return true;
26
- } catch {
27
- return false;
28
- }
29
- }
30
-
31
- /**
32
- * Checks all required tools and prints warnings for missing ones.
33
- * Missing tools are non-fatal — init continues.
34
- *
35
- * @param {string[]} tools
36
- * @returns {string[]} names of missing tools
37
- */
38
- function checkPrerequisites(tools = REQUIRED_TOOLS) {
39
- const missing = tools.filter((t) => !toolExists(t));
40
- for (const tool of missing) {
41
- console.warn(`Warning: '${tool}' not found on PATH. Some features will not work until it is installed.`);
42
- }
43
- return missing;
44
- }
45
-
46
- // ── File writers ──────────────────────────────────────────────────────────────
47
-
48
- /**
49
- * Writes a file only if it does not already exist.
50
- * Prints a skip or create message.
51
- *
52
- * @param {string} filePath - Absolute path
53
- * @param {string} content
54
- */
55
- function writeIfAbsent(filePath, content) {
56
- if (fs.existsSync(filePath)) {
57
- console.log(` skip ${filePath} (already exists)`);
58
- return;
59
- }
60
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
61
- fs.writeFileSync(filePath, content, 'utf8');
62
- console.log(` create ${filePath}`);
63
- }
64
-
65
- /**
66
- * Reads a template file and replaces {{key}} placeholders.
67
- *
68
- * @param {string} templateName - File name inside src/templates/
69
- * @param {Record<string, string>} vars
70
- * @returns {string}
71
- */
72
- function renderTemplate(templateName, vars) {
73
- const tplPath = path.join(TEMPLATES_DIR, templateName);
74
- let content = fs.readFileSync(tplPath, 'utf8');
75
- for (const [key, value] of Object.entries(vars)) {
76
- content = content.replaceAll(`{{${key}}}`, value);
77
- }
78
- return content;
79
- }
80
-
81
- // ── .swarp.json ───────────────────────────────────────────────────────────────
82
-
83
- function buildSwarpJson(routerUrl, agentsDir, flyApp) {
84
- return JSON.stringify({ router_url: routerUrl, agents_dir: agentsDir, fly_app: flyApp }, null, 2) + '\n';
85
- }
86
-
87
- // ── router.yaml ───────────────────────────────────────────────────────────────
88
-
89
- function buildRouterYaml() {
90
- return `router:
91
- grpc_port: 50051
92
-
93
- registration:
94
- ttl_minutes: 30
95
- persist_path: /data/registry.json
96
- `;
97
- }
98
3
 
99
4
  // ── .mcp.json ─────────────────────────────────────────────────────────────────
100
5
 
101
6
  /**
102
7
  * Adds a `swarp` entry to .mcp.json if one does not exist.
103
- * If .mcp.json does not exist, creates it from scratch.
104
- *
105
- * @param {string} cwd - Project root
8
+ * This is for standalone (non-plugin) usage only.
9
+ * Plugin users get the MCP server automatically.
106
10
  */
107
11
  function updateMcpJson(cwd) {
108
12
  const mcpPath = path.join(cwd, '.mcp.json');
@@ -112,11 +16,11 @@ function updateMcpJson(cwd) {
112
16
  try {
113
17
  config = JSON.parse(fs.readFileSync(mcpPath, 'utf8'));
114
18
  } catch {
115
- console.warn(' warn .mcp.json is not valid JSON — skipping update');
19
+ console.warn(' warn .mcp.json is not valid JSON — skipping');
116
20
  return;
117
21
  }
118
22
  if (config.mcpServers?.swarp) {
119
- console.log(` skip ${mcpPath} swarp entry (already exists)`);
23
+ console.log(` skip ${mcpPath} (swarp entry already exists)`);
120
24
  return;
121
25
  }
122
26
  }
@@ -128,58 +32,23 @@ function updateMcpJson(cwd) {
128
32
  };
129
33
 
130
34
  fs.writeFileSync(mcpPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
131
- console.log(` update ${mcpPath} — added swarp entry`);
35
+ console.log(` create ${mcpPath} — added swarp MCP server`);
132
36
  }
133
37
 
134
-
135
38
  // ── Main entry point ──────────────────────────────────────────────────────────
136
39
 
137
40
  /**
138
- * Runs the SWARP init command.
41
+ * Minimal init for standalone (non-plugin) usage.
42
+ * Creates .mcp.json entry so Claude Code can find the MCP server.
43
+ * All other setup happens via the /swarp skill in Claude Code.
139
44
  *
140
45
  * @param {object} [opts]
141
46
  * @param {string} [opts.cwd] - Project root directory (default: process.cwd())
142
- * @param {object} [opts.wizardAnswers] - Pre-filled answers (skips interactive prompts, for testing)
143
- * @param {NodeJS.ReadableStream} [opts.input] - Wizard stdin override
144
- * @param {NodeJS.WritableStream} [opts.output] - Wizard stdout override
145
47
  */
146
- export async function runInit({ cwd = process.cwd(), wizardAnswers, input, output } = {}) {
147
- // 1. Prerequisites
148
- checkPrerequisites();
149
-
150
- // 2. Wizard
151
- const answers = wizardAnswers ?? (await runWizard({ input, output }));
152
- const { agentsDir, flyOrg, firstAgentName } = answers;
153
-
154
- const flyApp = flyOrg ? `${flyOrg}-swarp-router` : 'my-org-swarp-router';
155
- const routerUrl = flyOrg ? `${flyOrg}-swarp-router.fly.dev:50051` : 'your-router.fly.dev:50051';
156
-
157
- console.log('\nGenerating files...\n');
48
+ export async function runInit({ cwd = process.cwd() } = {}) {
49
+ console.log('\nSWARP init\n');
158
50
 
159
- // 3. agents/<name>/agent.yaml — fully commented sample
160
- const agentYamlContent = renderTemplate('agent.yaml.example.hbs', { firstAgentName });
161
- writeIfAbsent(path.join(cwd, agentsDir, firstAgentName, 'agent.yaml'), agentYamlContent);
162
-
163
- // 4. .swarp.json
164
- writeIfAbsent(path.join(cwd, '.swarp.json'), buildSwarpJson(routerUrl, agentsDir, flyApp));
165
-
166
- // 5. router.yaml
167
- writeIfAbsent(path.join(cwd, 'router.yaml'), buildRouterYaml());
168
-
169
- // 6. .github/workflows/deploy-agents.yml
170
- const workflowContent = fs.readFileSync(path.join(TEMPLATES_DIR, 'workflow.yml'), 'utf8');
171
- writeIfAbsent(path.join(cwd, '.github', 'workflows', 'deploy-agents.yml'), workflowContent);
172
-
173
- // 7. .mcp.json
174
51
  updateMcpJson(cwd);
175
52
 
176
- // 8. .claude/skills/swarp/SKILL.md generated from agent configs
177
- const skillContent = generateSkill({
178
- agentsDir: path.resolve(cwd, agentsDir),
179
- routerUrl,
180
- });
181
- writeIfAbsent(path.join(cwd, '.claude', 'skills', 'swarp', 'SKILL.md'), skillContent);
182
-
183
- // 9. Done
184
- console.log('\nNext steps: run /swarp in Claude Code to dispatch tasks to your agents\n');
53
+ console.log('\n Open Claude Code and run /swarp to get started.\n');
185
54
  }
@@ -4,26 +4,6 @@ import path from 'node:path';
4
4
  import os from 'node:os';
5
5
  import { runInit } from './index.mjs';
6
6
 
7
- // Track calls made to execFileSync by the module under test.
8
- // We cannot vi.spyOn a CJS binding in ESM, so we use a shared call log
9
- // populated via vi.mock instead.
10
- const execFileSyncCalls = [];
11
- let execFileSyncImpl = null;
12
-
13
- vi.mock('node:child_process', async (importOriginal) => {
14
- const original = await importOriginal();
15
- return {
16
- ...original,
17
- execFileSync: (...args) => {
18
- execFileSyncCalls.push(args);
19
- if (execFileSyncImpl) return execFileSyncImpl(...args);
20
- return original.execFileSync(...args);
21
- },
22
- };
23
- });
24
-
25
- // ── Helpers ───────────────────────────────────────────────────────────────────
26
-
27
7
  function makeTmpDir() {
28
8
  return fs.mkdtempSync(path.join(os.tmpdir(), 'swarp-init-test-'));
29
9
  }
@@ -32,159 +12,57 @@ function cleanDir(dir) {
32
12
  fs.rmSync(dir, { recursive: true, force: true });
33
13
  }
34
14
 
35
- // Pre-filled wizard answers used by most tests
36
- const DEFAULT_ANSWERS = {
37
- agentsDir: 'agents/',
38
- flyOrg: 'myorg',
39
- firstAgentName: 'example',
40
- };
41
-
42
- // ── Tests ─────────────────────────────────────────────────────────────────────
43
-
44
15
  describe('runInit', () => {
45
16
  let tmpDir;
46
17
 
47
18
  beforeEach(() => {
48
19
  tmpDir = makeTmpDir();
49
- execFileSyncCalls.length = 0;
50
- execFileSyncImpl = null;
51
- // Silence stdout/stderr during tests
52
20
  vi.spyOn(console, 'log').mockImplementation(() => {});
53
21
  vi.spyOn(console, 'warn').mockImplementation(() => {});
54
22
  });
55
23
 
56
24
  afterEach(() => {
57
25
  cleanDir(tmpDir);
58
- execFileSyncImpl = null;
59
26
  vi.restoreAllMocks();
60
27
  });
61
28
 
62
- it('creates agents/<name>/agent.yaml', async () => {
63
- await runInit({ cwd: tmpDir, wizardAnswers: DEFAULT_ANSWERS });
64
- const p = path.join(tmpDir, 'agents', 'example', 'agent.yaml');
65
- expect(fs.existsSync(p)).toBe(true);
66
- const content = fs.readFileSync(p, 'utf8');
67
- expect(content).toContain('name: example');
68
- });
69
-
70
- it('substitutes firstAgentName in agent.yaml template', async () => {
71
- await runInit({ cwd: tmpDir, wizardAnswers: { ...DEFAULT_ANSWERS, firstAgentName: 'dominic' } });
72
- const content = fs.readFileSync(
73
- path.join(tmpDir, 'agents', 'dominic', 'agent.yaml'),
74
- 'utf8',
75
- );
76
- expect(content).toContain('name: dominic');
77
- expect(content).not.toContain('{{firstAgentName}}');
78
- });
79
-
80
- it('creates .swarp.json with correct fields', async () => {
81
- await runInit({ cwd: tmpDir, wizardAnswers: DEFAULT_ANSWERS });
82
- const p = path.join(tmpDir, '.swarp.json');
83
- expect(fs.existsSync(p)).toBe(true);
84
- const config = JSON.parse(fs.readFileSync(p, 'utf8'));
85
- expect(config.router_url).toBe('myorg-swarp-router.fly.dev:50051');
86
- expect(config.agents_dir).toBe('agents/');
87
- expect(config.fly_app).toBe('myorg-swarp-router');
88
- });
89
-
90
- it('creates router.yaml', async () => {
91
- await runInit({ cwd: tmpDir, wizardAnswers: DEFAULT_ANSWERS });
92
- const p = path.join(tmpDir, 'router.yaml');
93
- expect(fs.existsSync(p)).toBe(true);
94
- const content = fs.readFileSync(p, 'utf8');
95
- expect(content).toContain('grpc_port: 50051');
96
- expect(content).toContain('grpc_port: 50051');
97
- });
98
-
99
- it('creates .github/workflows/deploy-agents.yml', async () => {
100
- await runInit({ cwd: tmpDir, wizardAnswers: DEFAULT_ANSWERS });
101
- const p = path.join(tmpDir, '.github', 'workflows', 'deploy-agents.yml');
102
- expect(fs.existsSync(p)).toBe(true);
103
- const content = fs.readFileSync(p, 'utf8');
104
- expect(content).toContain('dl3consulting/swarp-actions');
105
- });
106
-
107
- it('adds swarp entry to .mcp.json when none exists', async () => {
108
- await runInit({ cwd: tmpDir, wizardAnswers: DEFAULT_ANSWERS });
109
- const config = JSON.parse(fs.readFileSync(path.join(tmpDir, '.mcp.json'), 'utf8'));
110
- expect(config.mcpServers.swarp).toBeDefined();
111
- expect(config.mcpServers.swarp.command).toBe('npx');
112
- });
113
-
114
- it('merges swarp entry into existing .mcp.json without clobbering other entries', async () => {
29
+ it('creates .mcp.json with swarp entry', async () => {
30
+ await runInit({ cwd: tmpDir });
115
31
  const mcpPath = path.join(tmpDir, '.mcp.json');
116
- fs.writeFileSync(
117
- mcpPath,
118
- JSON.stringify({ mcpServers: { dl3: { command: 'bun', args: [] } } }, null, 2),
119
- 'utf8',
120
- );
121
- await runInit({ cwd: tmpDir, wizardAnswers: DEFAULT_ANSWERS });
32
+ expect(fs.existsSync(mcpPath)).toBe(true);
122
33
  const config = JSON.parse(fs.readFileSync(mcpPath, 'utf8'));
123
- expect(config.mcpServers.dl3).toBeDefined();
124
34
  expect(config.mcpServers.swarp).toBeDefined();
35
+ expect(config.mcpServers.swarp.command).toBe('npx');
36
+ expect(config.mcpServers.swarp.args).toContain('@swarp/cli');
125
37
  });
126
38
 
127
- describe('idempotency', () => {
128
- it('skips files that already exist on re-run', async () => {
129
- await runInit({ cwd: tmpDir, wizardAnswers: DEFAULT_ANSWERS });
130
-
131
- // Overwrite agent.yaml with sentinel content
132
- const agentYaml = path.join(tmpDir, 'agents', 'example', 'agent.yaml');
133
- fs.writeFileSync(agentYaml, 'SENTINEL', 'utf8');
134
-
135
- // Second run must not overwrite
136
- await runInit({ cwd: tmpDir, wizardAnswers: DEFAULT_ANSWERS });
137
- expect(fs.readFileSync(agentYaml, 'utf8')).toBe('SENTINEL');
138
- });
39
+ it('does not overwrite existing swarp entry in .mcp.json', async () => {
40
+ const mcpPath = path.join(tmpDir, '.mcp.json');
41
+ const existing = { mcpServers: { swarp: { command: 'custom', args: [] } } };
42
+ fs.writeFileSync(mcpPath, JSON.stringify(existing), 'utf8');
139
43
 
140
- it('skips .swarp.json on re-run', async () => {
141
- await runInit({ cwd: tmpDir, wizardAnswers: DEFAULT_ANSWERS });
142
- const p = path.join(tmpDir, '.swarp.json');
143
- fs.writeFileSync(p, '{"sentinel":true}', 'utf8');
144
- await runInit({ cwd: tmpDir, wizardAnswers: DEFAULT_ANSWERS });
145
- expect(JSON.parse(fs.readFileSync(p, 'utf8')).sentinel).toBe(true);
146
- });
44
+ await runInit({ cwd: tmpDir });
147
45
 
148
- it('skips swarp entry in .mcp.json on re-run', async () => {
149
- await runInit({ cwd: tmpDir, wizardAnswers: DEFAULT_ANSWERS });
150
- const mcpPath = path.join(tmpDir, '.mcp.json');
151
- const before = fs.readFileSync(mcpPath, 'utf8');
152
- await runInit({ cwd: tmpDir, wizardAnswers: DEFAULT_ANSWERS });
153
- expect(fs.readFileSync(mcpPath, 'utf8')).toBe(before);
154
- });
46
+ const config = JSON.parse(fs.readFileSync(mcpPath, 'utf8'));
47
+ expect(config.mcpServers.swarp.command).toBe('custom');
155
48
  });
156
49
 
157
- describe('missing tools', () => {
158
- it('prints warning for missing tool and continues', async () => {
159
- // Mock execFileSync to simulate flyctl missing by importing the module and
160
- // checking that a missing tool does not throw.
161
- // We verify by observing console.warn was called and files were still created.
162
- const warnSpy = vi.spyOn(console, 'warn');
50
+ it('preserves other MCP entries in .mcp.json', async () => {
51
+ const mcpPath = path.join(tmpDir, '.mcp.json');
52
+ const existing = { mcpServers: { other: { command: 'other-tool', args: [] } } };
53
+ fs.writeFileSync(mcpPath, JSON.stringify(existing), 'utf8');
163
54
 
164
- // Force toolExists to return false for flyctl by patching child_process at module
165
- // level is complex in ESM; instead we verify the warning path via a direct import
166
- // of checkPrerequisites internals by calling runInit with a cwd where all tools
167
- // are absent from the PATH simulation. Since we cannot mock execFileSync in ESM
168
- // without additional setup, we verify the critical contract: runInit completes
169
- // even when a tool check would warn.
170
- await runInit({ cwd: tmpDir, wizardAnswers: DEFAULT_ANSWERS });
171
- // Files are created regardless of tool presence
172
- expect(fs.existsSync(path.join(tmpDir, '.swarp.json'))).toBe(true);
173
- });
55
+ await runInit({ cwd: tmpDir });
174
56
 
175
- it('uses placeholder router URL when flyOrg is empty', async () => {
176
- await runInit({ cwd: tmpDir, wizardAnswers: { ...DEFAULT_ANSWERS, flyOrg: '' } });
177
- const config = JSON.parse(fs.readFileSync(path.join(tmpDir, '.swarp.json'), 'utf8'));
178
- expect(config.router_url).toBe('your-router.fly.dev:50051');
179
- expect(config.fly_app).toBe('my-org-swarp-router');
180
- });
57
+ const config = JSON.parse(fs.readFileSync(mcpPath, 'utf8'));
58
+ expect(config.mcpServers.other).toBeDefined();
59
+ expect(config.mcpServers.swarp).toBeDefined();
181
60
  });
182
61
 
183
62
  it('prints next-steps message', async () => {
184
63
  const logSpy = vi.spyOn(console, 'log');
185
- await runInit({ cwd: tmpDir, wizardAnswers: DEFAULT_ANSWERS });
64
+ await runInit({ cwd: tmpDir });
186
65
  const messages = logSpy.mock.calls.map((c) => c[0]).join('\n');
187
66
  expect(messages).toContain('/swarp');
188
67
  });
189
-
190
68
  });
@@ -8,10 +8,19 @@ import { DispatchClient } from './dispatch.mjs';
8
8
  import { loadConfig } from '../config.mjs';
9
9
  import { auditConfigs } from '../generator/audit.mjs';
10
10
  import { generateRunnerConfig } from '../generator/generate.mjs';
11
+ import { handleOnboard, onboardToolDef } from './onboard.mjs';
11
12
 
12
13
  export async function startMcpServer() {
13
- const config = loadConfig('.swarp.json');
14
- const client = new DispatchClient(config.router_url);
14
+ let config = {};
15
+ let client = null;
16
+
17
+ try {
18
+ config = loadConfig('.swarp.json');
19
+ client = new DispatchClient(config.router_url);
20
+ } catch {
21
+ // No config yet — onboarding mode. Only swarp_onboard tool is available.
22
+ console.error('[swarp] No .swarp.json found — running in onboarding mode');
23
+ }
15
24
 
16
25
  const server = new Server(
17
26
  { name: 'swarp', version: '1.0.0' },
@@ -21,20 +30,22 @@ export async function startMcpServer() {
21
30
  const agentTools = [];
22
31
  let agentList = [];
23
32
 
24
- try {
25
- const { agents } = await client.listAgents();
26
- agentList = agents ?? [];
27
- for (const agent of agentList) {
28
- agentTools.push(buildAgentTool(agent));
33
+ if (client) {
34
+ try {
35
+ const { agents } = await client.listAgents();
36
+ agentList = agents ?? [];
37
+ for (const agent of agentList) {
38
+ agentTools.push(buildAgentTool(agent));
39
+ }
40
+ } catch (err) {
41
+ console.error(`[swarp] Warning: could not reach router at ${config.router_url}: ${err.message}`);
29
42
  }
30
- } catch (err) {
31
- console.error(`[swarp] Warning: could not reach router at ${config.router_url}: ${err.message}`);
32
43
  }
33
44
 
34
- const localTools = buildLocalTools(config);
45
+ const localTools = client ? buildLocalTools(config) : [];
35
46
 
36
47
  server.setRequestHandler(ListToolsRequestSchema, async () => {
37
- return { tools: [...agentTools, ...localTools] };
48
+ return { tools: [onboardToolDef, ...agentTools, ...localTools] };
38
49
  });
39
50
 
40
51
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
@@ -45,6 +56,7 @@ export async function startMcpServer() {
45
56
  return handleAgentDispatch(client, agent.name, toolArgs);
46
57
  }
47
58
 
59
+ if (name === 'swarp_onboard') return handleOnboard(toolArgs);
48
60
  if (name === 'swarm_audit') return handleAudit(config, toolArgs);
49
61
  if (name === 'swarm_generate') return handleGenerate(config, toolArgs);
50
62
  if (name === 'swarm_status') return handleStatus(client, toolArgs);