@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.
- package/.claude-plugin/plugin.json +7 -0
- package/.mcp.json +8 -0
- package/hooks/confirm-cost.sh +40 -0
- package/hooks/hooks.json +18 -0
- package/package.json +6 -4
- package/skills/swarp/SKILL.md +88 -0
- package/src/init/index.mjs +11 -142
- package/src/init/index.test.mjs +21 -143
- package/src/mcp-server/index.mjs +23 -11
- package/src/mcp-server/onboard.mjs +291 -0
- package/proto/swarp/v1/swarp.proto +0 -157
- package/src/init/wizard.mjs +0 -38
- package/src/skill/generate.mjs +0 -141
- package/src/templates/agent.yaml +0 -132
- package/src/templates/agent.yaml.example.hbs +0 -137
- package/src/templates/agent.yaml.hbs +0 -40
- package/src/templates/router.yaml.hbs +0 -11
- package/src/templates/workflow.yml +0 -20
- package/src/templates/workflow.yml.hbs +0 -16
package/.mcp.json
ADDED
|
@@ -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
|
package/hooks/hooks.json
ADDED
|
@@ -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.
|
|
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.
|
package/src/init/index.mjs
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
|
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(`
|
|
35
|
+
console.log(` create ${mcpPath} — added swarp MCP server`);
|
|
132
36
|
}
|
|
133
37
|
|
|
134
|
-
|
|
135
38
|
// ── Main entry point ──────────────────────────────────────────────────────────
|
|
136
39
|
|
|
137
40
|
/**
|
|
138
|
-
*
|
|
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()
|
|
147
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/init/index.test.mjs
CHANGED
|
@@ -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
|
|
63
|
-
await runInit({ cwd: tmpDir
|
|
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.
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
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
|
});
|
package/src/mcp-server/index.mjs
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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);
|