@swarp/cli 0.0.1-rc.17

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,259 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import {
4
+ CallToolRequestSchema,
5
+ ListToolsRequestSchema,
6
+ } from '@modelcontextprotocol/sdk/types.js';
7
+ import { DispatchClient } from './dispatch.mjs';
8
+ import { loadConfig } from '../config.mjs';
9
+ import { auditConfigs } from '../generator/audit.mjs';
10
+ import { generateRunnerConfig } from '../generator/generate.mjs';
11
+
12
+ export async function startMcpServer() {
13
+ const config = loadConfig('.swarp.json');
14
+ const client = new DispatchClient(config.router_url);
15
+
16
+ const server = new Server(
17
+ { name: 'swarp', version: '1.0.0' },
18
+ { capabilities: { tools: {} } },
19
+ );
20
+
21
+ const agentTools = [];
22
+ let agentList = [];
23
+
24
+ try {
25
+ const { agents } = await client.listAgents();
26
+ agentList = agents ?? [];
27
+ for (const agent of agentList) {
28
+ agentTools.push(buildAgentTool(agent));
29
+ }
30
+ } catch (err) {
31
+ console.error(`[swarp] Warning: could not reach router at ${config.router_url}: ${err.message}`);
32
+ }
33
+
34
+ const localTools = buildLocalTools(config);
35
+
36
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
37
+ return { tools: [...agentTools, ...localTools] };
38
+ });
39
+
40
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
41
+ const { name, arguments: toolArgs } = req.params;
42
+
43
+ const agent = agentList.find((a) => a.name === name);
44
+ if (agent) {
45
+ return handleAgentDispatch(client, agent.name, toolArgs);
46
+ }
47
+
48
+ if (name === 'swarm_audit') return handleAudit(config, toolArgs);
49
+ if (name === 'swarm_generate') return handleGenerate(config, toolArgs);
50
+ if (name === 'swarm_status') return handleStatus(client, toolArgs);
51
+ if (name === 'swarm_deploy') return handleDeploy(config, toolArgs);
52
+
53
+ return {
54
+ content: [{ type: 'text', text: `Unknown tool: ${name}` }],
55
+ isError: true,
56
+ };
57
+ });
58
+
59
+ const transport = new StdioServerTransport();
60
+ await server.connect(transport);
61
+ }
62
+
63
+ function buildAgentTool(agent) {
64
+ const modeNames = (agent.modes ?? []).map((m) => m.name);
65
+ return {
66
+ name: agent.name,
67
+ description: `Dispatch a task to the ${agent.name} agent (${agent.role ?? 'agent'}). Available modes: ${modeNames.join(', ') || 'any'}`,
68
+ inputSchema: {
69
+ type: 'object',
70
+ properties: {
71
+ mode: {
72
+ type: 'string',
73
+ description: `Mode to run. One of: ${modeNames.join(', ') || 'any'}`,
74
+ },
75
+ params: {
76
+ type: 'object',
77
+ description: 'Mode-specific parameters as key-value pairs',
78
+ additionalProperties: { type: 'string' },
79
+ },
80
+ session_id: {
81
+ type: 'string',
82
+ description: 'Optional session ID to continue an existing conversation',
83
+ },
84
+ },
85
+ required: ['mode'],
86
+ },
87
+ };
88
+ }
89
+
90
+ function buildLocalTools(config) {
91
+ return [
92
+ {
93
+ name: 'swarm_audit',
94
+ description: 'Audit all agent.yaml files for schema errors and warnings',
95
+ inputSchema: {
96
+ type: 'object',
97
+ properties: {
98
+ agents_dir: {
99
+ type: 'string',
100
+ description: 'Directory containing agent subdirectories (default: config agents_dir)',
101
+ },
102
+ },
103
+ },
104
+ },
105
+ {
106
+ name: 'swarm_generate',
107
+ description: 'Generate runner configs from agent.yaml files',
108
+ inputSchema: {
109
+ type: 'object',
110
+ properties: {
111
+ agents_dir: {
112
+ type: 'string',
113
+ description: 'Directory containing agent subdirectories (default: config agents_dir)',
114
+ },
115
+ },
116
+ },
117
+ },
118
+ {
119
+ name: 'swarm_status',
120
+ description: 'Get live status of an agent or all agents via gRPC',
121
+ inputSchema: {
122
+ type: 'object',
123
+ properties: {
124
+ agent: {
125
+ type: 'string',
126
+ description: 'Agent name (omit for all agents)',
127
+ },
128
+ },
129
+ },
130
+ },
131
+ {
132
+ name: 'swarm_deploy',
133
+ description: 'Deploy an agent sprite via the Sprites API. Requires SWARP_SPRITES_TOKEN.',
134
+ inputSchema: {
135
+ type: 'object',
136
+ properties: {
137
+ agent: {
138
+ type: 'string',
139
+ description: 'Agent name to deploy',
140
+ },
141
+ },
142
+ required: ['agent'],
143
+ },
144
+ annotations: {
145
+ destructiveHint: true,
146
+ },
147
+ },
148
+ ];
149
+ }
150
+
151
+ async function handleAgentDispatch(client, agentName, toolArgs) {
152
+ const { mode, params = {}, session_id: sessionId = '' } = toolArgs ?? {};
153
+ const events = [];
154
+
155
+ try {
156
+ for await (const event of client.dispatchTask(
157
+ agentName,
158
+ { structured: { mode, params } },
159
+ sessionId,
160
+ )) {
161
+ events.push(event);
162
+ }
163
+ } catch (err) {
164
+ return {
165
+ content: [{ type: 'text', text: `Dispatch error: ${err.message}` }],
166
+ isError: true,
167
+ };
168
+ }
169
+
170
+ const last = events.at(-1);
171
+ const text = last?.result?.summary ?? last?.text ?? '';
172
+ return { content: [{ type: 'text', text }] };
173
+ }
174
+
175
+ async function handleAudit(config, toolArgs) {
176
+ const agentsDir = toolArgs?.agents_dir ?? config.agents_dir ?? 'agents';
177
+ try {
178
+ const results = await auditConfigs(agentsDir);
179
+ const lines = [];
180
+ for (const result of results) {
181
+ lines.push(`=== ${result.agent} ===`);
182
+ for (const c of result.checks) {
183
+ const icon = c.status === 'pass' ? '\u2713' : c.status === 'fail' ? '\u2717' : '\u25cb';
184
+ lines.push(` ${icon} [${c.severity.toUpperCase().slice(0, 3)}] ${c.name}: ${c.message}`);
185
+ }
186
+ }
187
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
188
+ } catch (err) {
189
+ return { content: [{ type: 'text', text: `Audit error: ${err.message}` }], isError: true };
190
+ }
191
+ }
192
+
193
+ async function handleGenerate(config, toolArgs) {
194
+ const agentsDir = toolArgs?.agents_dir ?? config.agents_dir ?? 'agents';
195
+ try {
196
+ await generateRunnerConfig(agentsDir);
197
+ return { content: [{ type: 'text', text: `Runner configs generated for agents in ${agentsDir}` }] };
198
+ } catch (err) {
199
+ return { content: [{ type: 'text', text: `Generate error: ${err.message}` }], isError: true };
200
+ }
201
+ }
202
+
203
+ async function handleStatus(client, toolArgs) {
204
+ const { agent } = toolArgs ?? {};
205
+ try {
206
+ if (agent) {
207
+ const status = await client.getAgentStatus(agent);
208
+ return { content: [{ type: 'text', text: JSON.stringify(status, null, 2) }] };
209
+ }
210
+ const { agents } = await client.listAgents();
211
+ const lines = (agents ?? []).map(
212
+ (a) => `${a.name} (${a.online ? 'online' : 'offline'}) — ${a.modes?.length ?? 0} mode(s)`,
213
+ );
214
+ return { content: [{ type: 'text', text: lines.join('\n') || 'No agents registered' }] };
215
+ } catch (err) {
216
+ return { content: [{ type: 'text', text: `Status error: ${err.message}` }], isError: true };
217
+ }
218
+ }
219
+
220
+ async function handleDeploy(config, toolArgs) {
221
+ const token = process.env.SWARP_SPRITES_TOKEN;
222
+ if (!token) {
223
+ return {
224
+ content: [{ type: 'text', text: 'Error: SWARP_SPRITES_TOKEN env var is required for deploy' }],
225
+ isError: true,
226
+ };
227
+ }
228
+
229
+ const { agent: agentName } = toolArgs ?? {};
230
+ if (!agentName) {
231
+ return {
232
+ content: [{ type: 'text', text: 'Error: agent name is required' }],
233
+ isError: true,
234
+ };
235
+ }
236
+
237
+ try {
238
+ const { readFileSync, existsSync } = await import('node:fs');
239
+ const { resolve, join } = await import('node:path');
240
+ const yaml = (await import('js-yaml')).default;
241
+ const { FlySpriteAdapter } = await import('../runtimes/fly-sprites.mjs');
242
+
243
+ const agentsDir = resolve(config.agents_dir ?? 'agents');
244
+ const yamlPath = join(agentsDir, agentName, 'agent.yaml');
245
+ if (!existsSync(yamlPath)) {
246
+ return {
247
+ content: [{ type: 'text', text: `Error: agent.yaml not found at ${yamlPath}` }],
248
+ isError: true,
249
+ };
250
+ }
251
+
252
+ const agentConfig = yaml.load(readFileSync(yamlPath, 'utf-8'));
253
+ const adapter = new FlySpriteAdapter(token);
254
+ await adapter.createAgent(agentName, agentConfig);
255
+ return { content: [{ type: 'text', text: `Deployed ${agentName}` }] };
256
+ } catch (err) {
257
+ return { content: [{ type: 'text', text: `Deploy error: ${err.message}` }], isError: true };
258
+ }
259
+ }
@@ -0,0 +1,74 @@
1
+ import { RuntimeAdapter } from './interface.mjs';
2
+
3
+ const SPRITES_API_BASE = 'https://sprites.fly.io/v1';
4
+
5
+ export class FlySpriteAdapter extends RuntimeAdapter {
6
+ constructor(token) {
7
+ if (!token) {
8
+ throw new Error('SWARP_SPRITES_TOKEN is required — never load it from .env');
9
+ }
10
+ super(token);
11
+ }
12
+
13
+ async createAgent(name, config) {
14
+ const res = await fetch(`${SPRITES_API_BASE}/sprites`, {
15
+ method: 'POST',
16
+ headers: {
17
+ Authorization: `Bearer ${this.token}`,
18
+ 'Content-Type': 'application/json',
19
+ },
20
+ body: JSON.stringify({
21
+ name,
22
+ config: {
23
+ name: config.name,
24
+ version: config.version,
25
+ grpc_port: config.grpc_port ?? 50052,
26
+ router_url: config.router_url,
27
+ region: config.region,
28
+ },
29
+ }),
30
+ });
31
+
32
+ if (!res.ok) {
33
+ const body = await res.text();
34
+ throw new Error(`Sprites API error creating ${name}: ${res.status} ${body}`);
35
+ }
36
+
37
+ return res.json();
38
+ }
39
+
40
+ async destroyAgent(name) {
41
+ const res = await fetch(`${SPRITES_API_BASE}/sprites/${encodeURIComponent(name)}`, {
42
+ method: 'DELETE',
43
+ headers: {
44
+ Authorization: `Bearer ${this.token}`,
45
+ },
46
+ });
47
+
48
+ if (!res.ok && res.status !== 404) {
49
+ const body = await res.text();
50
+ throw new Error(`Sprites API error destroying ${name}: ${res.status} ${body}`);
51
+ }
52
+
53
+ return { destroyed: true, name };
54
+ }
55
+
56
+ async getAgent(name) {
57
+ const res = await fetch(`${SPRITES_API_BASE}/sprites/${encodeURIComponent(name)}`, {
58
+ headers: {
59
+ Authorization: `Bearer ${this.token}`,
60
+ },
61
+ });
62
+
63
+ if (res.status === 404) {
64
+ return null;
65
+ }
66
+
67
+ if (!res.ok) {
68
+ const body = await res.text();
69
+ throw new Error(`Sprites API error getting ${name}: ${res.status} ${body}`);
70
+ }
71
+
72
+ return res.json();
73
+ }
74
+ }
@@ -0,0 +1,20 @@
1
+ export class RuntimeAdapter {
2
+ constructor(token) {
3
+ if (new.target === RuntimeAdapter) {
4
+ throw new Error('RuntimeAdapter is abstract — extend it with a concrete implementation');
5
+ }
6
+ this.token = token;
7
+ }
8
+
9
+ async createAgent(name, config) {
10
+ throw new Error('createAgent() must be implemented by subclass');
11
+ }
12
+
13
+ async destroyAgent(name) {
14
+ throw new Error('destroyAgent() must be implemented by subclass');
15
+ }
16
+
17
+ async getAgent(name) {
18
+ throw new Error('getAgent() must be implemented by subclass');
19
+ }
20
+ }
@@ -0,0 +1,141 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import yaml from 'js-yaml';
4
+
5
+ /**
6
+ * Scans the agents directory, reads each agent.yaml, and generates
7
+ * a SKILL.md tailored to the user's actual agents and modes.
8
+ *
9
+ * @param {object} opts
10
+ * @param {string} opts.agentsDir - Absolute path to agents directory
11
+ * @param {string} opts.routerUrl - Router gRPC address
12
+ * @returns {string} Rendered SKILL.md content
13
+ */
14
+ export function generateSkill({ agentsDir, routerUrl }) {
15
+ const agents = scanAgents(agentsDir);
16
+
17
+ let md = `# /swarp
18
+
19
+ Use this skill when dispatching tasks to SWARP agents, checking agent status, or managing deployments.
20
+
21
+ ## Available Agents
22
+
23
+ `;
24
+
25
+ if (agents.length === 0) {
26
+ md += `No agents found in \`${agentsDir}\`. Run \`npx @swarp/cli init\` to create one.\n`;
27
+ } else {
28
+ for (const agent of agents) {
29
+ md += `### \`${agent.name}\`\n\n`;
30
+ if (agent.modes.length > 0) {
31
+ md += '| Mode | Description | Model | Timeout |\n';
32
+ md += '|------|-------------|-------|--------|\n';
33
+ for (const mode of agent.modes) {
34
+ const desc = mode.description || '—';
35
+ const model = mode.model || 'default';
36
+ const timeout = mode.timeout_minutes ? `${mode.timeout_minutes}m` : '30m';
37
+ md += `| \`${mode.name}\` | ${desc} | ${model} | ${timeout} |\n`;
38
+ }
39
+ md += '\n';
40
+ }
41
+ }
42
+ }
43
+
44
+ md += `## MCP Tools
45
+
46
+ The SWARP MCP server registers one tool per agent, plus management tools.
47
+
48
+ ### Agent tools (one per agent above)
49
+
50
+ Each agent tool accepts:
51
+ - \`mode\` (required): one of the modes listed above
52
+ - \`params\` (optional): key-value pairs interpolated into the mode prompt
53
+ - \`session_id\` (optional): resume an existing session
54
+
55
+ Example:
56
+ \`\`\`
57
+ `;
58
+
59
+ if (agents.length > 0) {
60
+ const first = agents[0];
61
+ const firstMode = first.modes[0]?.name || 'implement';
62
+ md += `Use the \`${first.name}\` tool with mode "${firstMode}" and params.task = "Add error handling to the API"\n`;
63
+ } else {
64
+ md += `Use the \`agent-name\` tool with mode "implement" and params.task = "..."\n`;
65
+ }
66
+
67
+ md += `\`\`\`
68
+
69
+ ### Management tools
70
+
71
+ | Tool | Description |
72
+ |------|-------------|
73
+ | \`swarm_status\` | Check if an agent is online |
74
+ | \`swarm_audit\` | Validate all agent.yaml files |
75
+ | \`swarm_generate\` | Regenerate runner configs |
76
+ | \`swarm_deploy\` | Deploy an agent (destructive — confirm first) |
77
+
78
+ ## CLI
79
+
80
+ \`\`\`bash
81
+ npx @swarp/cli status # All agents
82
+ npx @swarp/cli status <agent> # Specific agent
83
+ npx @swarp/cli audit # Validate configs
84
+ npx @swarp/cli deploy <agent> # Deploy one agent
85
+ npx @swarp/cli deploy --all # Deploy all
86
+ npx @swarp/cli certs generate # Generate mTLS keypairs
87
+ \`\`\`
88
+
89
+ ## Configuration
90
+
91
+ Router: \`${routerUrl}\`
92
+ Agents directory: \`${path.relative(process.cwd(), agentsDir) || agentsDir}\`
93
+
94
+ ## Environment Variables
95
+
96
+ | Variable | Required | Description |
97
+ |----------|----------|-------------|
98
+ | \`SWARP_SPRITES_TOKEN\` | For deploy | Fly Sprites API token |
99
+ | \`SWARP_ROUTER_URL\` | For gRPC | Router address (overrides .swarp.json) |
100
+
101
+ **Security:** \`SWARP_SPRITES_TOKEN\` must be set in the environment — never loaded from .env files.
102
+ `;
103
+
104
+ return md;
105
+ }
106
+
107
+ /**
108
+ * Reads all agent.yaml files from the agents directory.
109
+ *
110
+ * @param {string} agentsDir
111
+ * @returns {Array<{name: string, modes: Array<{name: string, description?: string, model?: string, timeout_minutes?: number}>}>}
112
+ */
113
+ function scanAgents(agentsDir) {
114
+ if (!fs.existsSync(agentsDir)) return [];
115
+
116
+ const entries = fs.readdirSync(agentsDir, { withFileTypes: true });
117
+ const agents = [];
118
+
119
+ for (const entry of entries) {
120
+ if (!entry.isDirectory()) continue;
121
+ const yamlPath = path.join(agentsDir, entry.name, 'agent.yaml');
122
+ if (!fs.existsSync(yamlPath)) continue;
123
+
124
+ try {
125
+ const config = yaml.load(fs.readFileSync(yamlPath, 'utf8'));
126
+ agents.push({
127
+ name: config.name || entry.name,
128
+ modes: (config.modes || []).map((m) => ({
129
+ name: m.name,
130
+ description: m.description,
131
+ model: m.model,
132
+ timeout_minutes: m.timeout_minutes,
133
+ })),
134
+ });
135
+ } catch {
136
+ // Skip malformed configs — swarm_audit will catch them
137
+ }
138
+ }
139
+
140
+ return agents.sort((a, b) => a.name.localeCompare(b.name));
141
+ }
@@ -0,0 +1,132 @@
1
+ # agents/example/agent.yaml
2
+ #
3
+ # SWARP agent configuration — all fields shown with explanations.
4
+ # Required fields: name, version, grpc_port, router_url, modes
5
+ # All other fields are optional.
6
+
7
+ # ── Identity ──────────────────────────────────────────────────────────────────
8
+
9
+ # Unique agent name. Used as the key in the router registry and as the MCP tool
10
+ # name exposed to Claude Code.
11
+ name: example
12
+
13
+ # Semantic version of this agent's configuration. The runner reports this
14
+ # version to the router on registration and includes it in AgentEvent metadata.
15
+ version: '1.0.0'
16
+
17
+ # ── Network ───────────────────────────────────────────────────────────────────
18
+
19
+ # gRPC port this runner listens on. Each agent on the same Fly Machine must
20
+ # use a unique port. The router connects to this port for Dispatch calls.
21
+ grpc_port: 50052
22
+
23
+ # Address of the SWARP router. Use an env var reference so the value is not
24
+ # hardcoded and can differ between local dev and production.
25
+ router_url: '${SWARP_ROUTER_URL}'
26
+
27
+ # ── Persona ───────────────────────────────────────────────────────────────────
28
+
29
+ # persona sets the character of this agent. It is prepended to every prompt
30
+ # as a system-level instruction before any mode-specific content.
31
+ persona: |
32
+ You are example, a focused software engineer.
33
+ You write clean, tested, idiomatic code.
34
+ You ask clarifying questions before starting large tasks.
35
+ You never make up facts.
36
+
37
+ # ── Preamble ──────────────────────────────────────────────────────────────────
38
+
39
+ # preamble is injected after the persona and before the user's task. Use it
40
+ # for project-specific conventions that every mode should know about.
41
+ preamble: |
42
+ Project: monorepo (Bun + React + Supabase)
43
+ Branch policy: never push to main; always use feature branches.
44
+ Test runner: bun test
45
+ Linter: bun run lint
46
+
47
+ # ── Modes ─────────────────────────────────────────────────────────────────────
48
+
49
+ # A mode is a named capability of this agent. The router's classifier selects
50
+ # a mode based on keywords in the incoming task description.
51
+ #
52
+ # Required per mode: name
53
+ # Optional: description, model, max_turns, timeout_minutes, tools, skills, hooks, env
54
+ modes:
55
+ - name: implement
56
+ description: 'Implement a feature or fix a bug from a spec'
57
+ # Model to use for this mode. Defaults to the runner's configured default.
58
+ model: claude-opus-4-5
59
+ # Maximum number of agentic turns before the runner halts the session.
60
+ max_turns: 40
61
+ # Session wall-clock timeout in minutes. The runner cancels the claude
62
+ # process when this expires.
63
+ timeout_minutes: 30
64
+ # tools lists MCP tool names this mode is allowed to call.
65
+ tools:
66
+ - Read
67
+ - Edit
68
+ - Write
69
+ - Bash
70
+ - Glob
71
+ - Grep
72
+ # skills lists slash-command skill names this mode loads on startup.
73
+ skills:
74
+ - superpowers:test-driven-development
75
+ - superpowers:systematic-debugging
76
+ # hooks are shell commands run before or after the session.
77
+ hooks:
78
+ pre_session:
79
+ - 'git fetch origin'
80
+ post_session:
81
+ - 'bun run lint'
82
+ - 'bun test'
83
+ # env injects key=value pairs into the runner's process environment.
84
+ # Secrets should use ${SECRET_NAME} references.
85
+ env:
86
+ NODE_ENV: development
87
+
88
+ - name: review
89
+ description: 'Review code and provide structured feedback'
90
+ model: claude-sonnet-4-5
91
+ max_turns: 20
92
+ timeout_minutes: 15
93
+ tools:
94
+ - Read
95
+ - Glob
96
+ - Grep
97
+ skills:
98
+ - superpowers:requesting-code-review
99
+
100
+ - name: debug
101
+ description: 'Diagnose and fix a failing test or runtime error'
102
+ model: claude-opus-4-5
103
+ max_turns: 30
104
+ timeout_minutes: 25
105
+ tools:
106
+ - Read
107
+ - Edit
108
+ - Write
109
+ - Bash
110
+ - Glob
111
+ - Grep
112
+ skills:
113
+ - superpowers:systematic-debugging
114
+ - superpowers:test-driven-development
115
+
116
+ - name: plan
117
+ description: 'Write an implementation plan for a feature or refactor'
118
+ model: claude-sonnet-4-5
119
+ max_turns: 15
120
+ timeout_minutes: 15
121
+ tools:
122
+ - Read
123
+ - Glob
124
+ - Grep
125
+ skills:
126
+ - superpowers:writing-plans
127
+
128
+ # ── Env file ──────────────────────────────────────────────────────────────────
129
+
130
+ # Path to a .env file loaded before any session starts. Relative to the agent
131
+ # config file. Use this for secrets that differ between environments.
132
+ # env_file: .env