swarmroom 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/swarmroom.js +2 -0
- package/dist/commands/agents.d.ts +2 -0
- package/dist/commands/agents.js +71 -0
- package/dist/commands/daemon.d.ts +2 -0
- package/dist/commands/daemon.js +302 -0
- package/dist/commands/setup.d.ts +2 -0
- package/dist/commands/setup.js +165 -0
- package/dist/commands/start.d.ts +2 -0
- package/dist/commands/start.js +134 -0
- package/dist/commands/status.d.ts +2 -0
- package/dist/commands/status.js +58 -0
- package/dist/configurators/__tests__/configurators.test.d.ts +1 -0
- package/dist/configurators/__tests__/configurators.test.js +102 -0
- package/dist/configurators/claude-code.d.ts +5 -0
- package/dist/configurators/claude-code.js +60 -0
- package/dist/configurators/gemini-cli.d.ts +5 -0
- package/dist/configurators/gemini-cli.js +61 -0
- package/dist/configurators/index.d.ts +18 -0
- package/dist/configurators/index.js +21 -0
- package/dist/configurators/opencode.d.ts +5 -0
- package/dist/configurators/opencode.js +60 -0
- package/dist/daemon/__tests__/config.test.d.ts +1 -0
- package/dist/daemon/__tests__/config.test.js +125 -0
- package/dist/daemon/__tests__/process-detector.test.d.ts +1 -0
- package/dist/daemon/__tests__/process-detector.test.js +77 -0
- package/dist/daemon/__tests__/watcher.test.d.ts +1 -0
- package/dist/daemon/__tests__/watcher.test.js +305 -0
- package/dist/daemon/config.d.ts +26 -0
- package/dist/daemon/config.js +89 -0
- package/dist/daemon/process-detector.d.ts +21 -0
- package/dist/daemon/process-detector.js +59 -0
- package/dist/daemon/watcher.d.ts +38 -0
- package/dist/daemon/watcher.js +225 -0
- package/dist/detectors/__tests__/detectors.test.d.ts +1 -0
- package/dist/detectors/__tests__/detectors.test.js +105 -0
- package/dist/detectors/claude-code.d.ts +7 -0
- package/dist/detectors/claude-code.js +39 -0
- package/dist/detectors/gemini-cli.d.ts +6 -0
- package/dist/detectors/gemini-cli.js +27 -0
- package/dist/detectors/index.d.ts +15 -0
- package/dist/detectors/index.js +12 -0
- package/dist/detectors/opencode.d.ts +6 -0
- package/dist/detectors/opencode.js +26 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +17 -0
- package/dist/utils/display.d.ts +10 -0
- package/dist/utils/display.js +78 -0
- package/package.json +38 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { WS_RECONNECT_DELAY_MS, DAEMON_SPAWN_COOLDOWN_MS, } from '@swarmroom/shared';
|
|
4
|
+
import { loadDaemonConfig } from './config.js';
|
|
5
|
+
import { isAgentProcessRunning } from './process-detector.js';
|
|
6
|
+
const MAX_RECONNECT_DELAY_MS = 30_000;
|
|
7
|
+
export class DaemonWatcher {
|
|
8
|
+
hubUrl;
|
|
9
|
+
workdir;
|
|
10
|
+
verbose;
|
|
11
|
+
config;
|
|
12
|
+
ws = null;
|
|
13
|
+
reconnectTimer = null;
|
|
14
|
+
reconnectAttempts = 0;
|
|
15
|
+
intentionalDisconnect = false;
|
|
16
|
+
// Cooldown tracking: agentType -> timestamp of last spawn
|
|
17
|
+
spawnCooldowns = new Map();
|
|
18
|
+
constructor(options = {}) {
|
|
19
|
+
this.config = loadDaemonConfig();
|
|
20
|
+
this.hubUrl = options.hubUrl ?? this.config.hubUrl;
|
|
21
|
+
this.workdir = options.workdir ?? process.cwd();
|
|
22
|
+
this.verbose = options.verbose ?? false;
|
|
23
|
+
}
|
|
24
|
+
/** Start the daemon — connect to Hub WebSocket */
|
|
25
|
+
start() {
|
|
26
|
+
this.intentionalDisconnect = false;
|
|
27
|
+
this.log('Starting daemon watcher...');
|
|
28
|
+
this.log(`Hub URL: ${this.hubUrl}`);
|
|
29
|
+
this.log(`Working directory: ${this.workdir}`);
|
|
30
|
+
this.connectWebSocket();
|
|
31
|
+
}
|
|
32
|
+
/** Stop the daemon — disconnect and clean up */
|
|
33
|
+
stop() {
|
|
34
|
+
this.intentionalDisconnect = true;
|
|
35
|
+
this.clearReconnectTimer();
|
|
36
|
+
if (this.ws) {
|
|
37
|
+
this.ws.close(1000, 'daemon stopping');
|
|
38
|
+
this.ws = null;
|
|
39
|
+
}
|
|
40
|
+
this.log('Daemon watcher stopped.');
|
|
41
|
+
}
|
|
42
|
+
/** Reload config from disk */
|
|
43
|
+
reloadConfig() {
|
|
44
|
+
this.config = loadDaemonConfig();
|
|
45
|
+
this.log('Config reloaded.');
|
|
46
|
+
}
|
|
47
|
+
connectWebSocket() {
|
|
48
|
+
const wsUrl = this.hubUrl.replace(/^http/, 'ws') + '/ws';
|
|
49
|
+
this.log(`Connecting to ${wsUrl}...`);
|
|
50
|
+
this.ws = new WebSocket(wsUrl);
|
|
51
|
+
this.ws.on('open', () => {
|
|
52
|
+
this.reconnectAttempts = 0;
|
|
53
|
+
this.sendRegister();
|
|
54
|
+
this.log('Connected to Hub. Registered as daemon.');
|
|
55
|
+
});
|
|
56
|
+
this.ws.on('message', (data) => {
|
|
57
|
+
this.handleMessage(String(data));
|
|
58
|
+
});
|
|
59
|
+
this.ws.on('close', () => {
|
|
60
|
+
if (!this.intentionalDisconnect) {
|
|
61
|
+
this.scheduleReconnect();
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
this.ws.on('error', (err) => {
|
|
65
|
+
this.log(`WebSocket error: ${err.message}`);
|
|
66
|
+
// Error is followed by close event, which handles reconnection
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
sendRegister() {
|
|
70
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
|
|
71
|
+
return;
|
|
72
|
+
const msg = {
|
|
73
|
+
type: 'register',
|
|
74
|
+
payload: { clientType: 'daemon' },
|
|
75
|
+
timestamp: new Date().toISOString(),
|
|
76
|
+
};
|
|
77
|
+
this.ws.send(JSON.stringify(msg));
|
|
78
|
+
}
|
|
79
|
+
handleMessage(raw) {
|
|
80
|
+
let msg;
|
|
81
|
+
try {
|
|
82
|
+
msg = JSON.parse(raw);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
switch (msg.type) {
|
|
88
|
+
case 'message_undelivered': {
|
|
89
|
+
const payload = msg.payload;
|
|
90
|
+
this.handleUndeliveredMessage(payload);
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
case 'heartbeat': {
|
|
94
|
+
// Respond to server pings
|
|
95
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
96
|
+
const pong = {
|
|
97
|
+
type: 'heartbeat',
|
|
98
|
+
payload: { pong: true },
|
|
99
|
+
timestamp: new Date().toISOString(),
|
|
100
|
+
};
|
|
101
|
+
this.ws.send(JSON.stringify(pong));
|
|
102
|
+
}
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
case 'register':
|
|
106
|
+
this.log('Registration confirmed by hub.');
|
|
107
|
+
break;
|
|
108
|
+
default:
|
|
109
|
+
// Ignore other message types (agent_online, agent_offline, etc.)
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
handleUndeliveredMessage(payload) {
|
|
114
|
+
const { recipientAgentId, recipientAgentName, message } = payload;
|
|
115
|
+
this.log(`Undelivered message for agent "${recipientAgentName}" (${recipientAgentId})`);
|
|
116
|
+
// Find which agent config matches this agent name
|
|
117
|
+
const agentType = this.findAgentType(recipientAgentName);
|
|
118
|
+
if (!agentType) {
|
|
119
|
+
this.log(`No config found for agent "${recipientAgentName}", ignoring.`);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const agentConfig = this.config.agents[agentType];
|
|
123
|
+
if (!agentConfig) {
|
|
124
|
+
this.log(`No agent config for type "${agentType}", ignoring.`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// Check if headless wakeup is enabled
|
|
128
|
+
if (!agentConfig.headlessWakeup) {
|
|
129
|
+
this.log(`Headless wakeup disabled for "${agentType}", message stored in DB for later retrieval.`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
// Check if the agent process is already running
|
|
133
|
+
if (isAgentProcessRunning(agentType)) {
|
|
134
|
+
this.log(`Agent "${agentType}" process is running but not connected. Message stored in DB.`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
// Check cooldown
|
|
138
|
+
if (this.isOnCooldown(agentType)) {
|
|
139
|
+
this.log(`Agent "${agentType}" is on spawn cooldown, skipping.`);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
// Spawn headless process
|
|
143
|
+
this.spawnHeadless(agentType, agentConfig, message);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Find which agent type (config key) matches the given agent name.
|
|
147
|
+
* The agent name from the hub may be "claude-code", "opencode", "gemini-cli",
|
|
148
|
+
* or a custom name. We try exact match first, then partial matching.
|
|
149
|
+
*/
|
|
150
|
+
findAgentType(agentName) {
|
|
151
|
+
const normalized = agentName.toLowerCase();
|
|
152
|
+
// Exact match against config keys
|
|
153
|
+
if (this.config.agents[normalized]) {
|
|
154
|
+
return normalized;
|
|
155
|
+
}
|
|
156
|
+
// Check if the agent name contains any known agent type
|
|
157
|
+
for (const key of Object.keys(this.config.agents)) {
|
|
158
|
+
if (normalized.includes(key) || key.includes(normalized)) {
|
|
159
|
+
return key;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
isOnCooldown(agentType) {
|
|
165
|
+
const lastSpawn = this.spawnCooldowns.get(agentType);
|
|
166
|
+
if (!lastSpawn)
|
|
167
|
+
return false;
|
|
168
|
+
return Date.now() - lastSpawn < DAEMON_SPAWN_COOLDOWN_MS;
|
|
169
|
+
}
|
|
170
|
+
spawnHeadless(agentType, agentConfig, message) {
|
|
171
|
+
// Set cooldown immediately
|
|
172
|
+
this.spawnCooldowns.set(agentType, Date.now());
|
|
173
|
+
// Build the message content for the prompt
|
|
174
|
+
const messageContent = typeof message === 'object' && message !== null
|
|
175
|
+
? message.content ?? JSON.stringify(message)
|
|
176
|
+
: String(message);
|
|
177
|
+
// Replace {message} template in args
|
|
178
|
+
const args = agentConfig.args.map((arg) => arg.replace('{message}', String(messageContent)));
|
|
179
|
+
const workdir = agentConfig.workdir ?? this.workdir;
|
|
180
|
+
this.log(`Spawning headless: ${agentConfig.command} ${args.join(' ')}`);
|
|
181
|
+
this.log(` Working directory: ${workdir}`);
|
|
182
|
+
try {
|
|
183
|
+
const child = spawn(agentConfig.command, args, {
|
|
184
|
+
cwd: workdir,
|
|
185
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
186
|
+
detached: false, // We want to track the child
|
|
187
|
+
});
|
|
188
|
+
child.stdout?.on('data', (data) => {
|
|
189
|
+
this.log(`[${agentType}:stdout] ${data.toString().trim()}`);
|
|
190
|
+
});
|
|
191
|
+
child.stderr?.on('data', (data) => {
|
|
192
|
+
this.log(`[${agentType}:stderr] ${data.toString().trim()}`);
|
|
193
|
+
});
|
|
194
|
+
child.on('exit', (code) => {
|
|
195
|
+
this.log(`[${agentType}] Process exited with code ${code}`);
|
|
196
|
+
});
|
|
197
|
+
child.on('error', (err) => {
|
|
198
|
+
this.log(`[${agentType}] Failed to spawn: ${err.message}`);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
203
|
+
this.log(`Failed to spawn ${agentType}: ${errMsg}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
scheduleReconnect() {
|
|
207
|
+
this.clearReconnectTimer();
|
|
208
|
+
const delay = Math.min(WS_RECONNECT_DELAY_MS * Math.pow(2, this.reconnectAttempts), MAX_RECONNECT_DELAY_MS);
|
|
209
|
+
this.reconnectAttempts++;
|
|
210
|
+
this.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})...`);
|
|
211
|
+
this.reconnectTimer = setTimeout(() => {
|
|
212
|
+
this.connectWebSocket();
|
|
213
|
+
}, delay);
|
|
214
|
+
}
|
|
215
|
+
clearReconnectTimer() {
|
|
216
|
+
if (this.reconnectTimer) {
|
|
217
|
+
clearTimeout(this.reconnectTimer);
|
|
218
|
+
this.reconnectTimer = null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
log(message) {
|
|
222
|
+
const timestamp = new Date().toISOString();
|
|
223
|
+
console.log(`[daemon ${timestamp}] ${message}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
vi.mock('node:child_process', () => ({
|
|
5
|
+
execSync: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
vi.mock('node:fs', () => ({
|
|
8
|
+
existsSync: vi.fn(),
|
|
9
|
+
readFileSync: vi.fn(),
|
|
10
|
+
writeFileSync: vi.fn(),
|
|
11
|
+
copyFileSync: vi.fn(),
|
|
12
|
+
mkdirSync: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
import { execSync } from 'node:child_process';
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
16
|
+
import { ClaudeCodeDetector } from '../claude-code.js';
|
|
17
|
+
import { OpenCodeDetector } from '../opencode.js';
|
|
18
|
+
import { GeminiCliDetector } from '../gemini-cli.js';
|
|
19
|
+
import { detectAllAgents } from '../index.js';
|
|
20
|
+
const mockedExecSync = vi.mocked(execSync);
|
|
21
|
+
const mockedExistsSync = vi.mocked(existsSync);
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
vi.clearAllMocks();
|
|
24
|
+
});
|
|
25
|
+
describe('ClaudeCodeDetector', () => {
|
|
26
|
+
const detector = new ClaudeCodeDetector();
|
|
27
|
+
it('detects when claude is installed and config exists in cwd', async () => {
|
|
28
|
+
mockedExecSync.mockReturnValue(Buffer.from('/usr/bin/claude'));
|
|
29
|
+
mockedExistsSync.mockReturnValueOnce(true);
|
|
30
|
+
const result = await detector.detect();
|
|
31
|
+
expect(result.installed).toBe(true);
|
|
32
|
+
expect(result.configExists).toBe(true);
|
|
33
|
+
expect(result.configPath).toBe(join(process.cwd(), '.mcp.json'));
|
|
34
|
+
expect(result.name).toBe('Claude Code');
|
|
35
|
+
expect(mockedExecSync).toHaveBeenCalledWith('which claude', { stdio: 'ignore' });
|
|
36
|
+
});
|
|
37
|
+
it('detects when claude is not installed and no config exists', async () => {
|
|
38
|
+
mockedExecSync.mockImplementation(() => {
|
|
39
|
+
throw new Error('not found');
|
|
40
|
+
});
|
|
41
|
+
mockedExistsSync.mockReturnValue(false);
|
|
42
|
+
const result = await detector.detect();
|
|
43
|
+
expect(result.installed).toBe(false);
|
|
44
|
+
expect(result.configExists).toBe(false);
|
|
45
|
+
expect(result.configPath).toBe(join(process.cwd(), '.mcp.json'));
|
|
46
|
+
expect(result.name).toBe('Claude Code');
|
|
47
|
+
});
|
|
48
|
+
it('finds config in home directory when not in cwd', async () => {
|
|
49
|
+
mockedExecSync.mockReturnValue(Buffer.from('/usr/bin/claude'));
|
|
50
|
+
mockedExistsSync.mockReturnValueOnce(false).mockReturnValueOnce(true);
|
|
51
|
+
const result = await detector.detect();
|
|
52
|
+
expect(result.installed).toBe(true);
|
|
53
|
+
expect(result.configExists).toBe(true);
|
|
54
|
+
expect(result.configPath).toBe(join(homedir(), '.mcp.json'));
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
describe('OpenCodeDetector', () => {
|
|
58
|
+
const detector = new OpenCodeDetector();
|
|
59
|
+
it('detects when opencode is installed and config exists', async () => {
|
|
60
|
+
mockedExecSync.mockReturnValue(Buffer.from('/usr/bin/opencode'));
|
|
61
|
+
mockedExistsSync.mockReturnValue(true);
|
|
62
|
+
const result = await detector.detect();
|
|
63
|
+
expect(result.installed).toBe(true);
|
|
64
|
+
expect(result.configExists).toBe(true);
|
|
65
|
+
expect(result.configPath).toBe(join(process.cwd(), 'opencode.json'));
|
|
66
|
+
expect(result.name).toBe('OpenCode');
|
|
67
|
+
});
|
|
68
|
+
it('detects when opencode is not installed', async () => {
|
|
69
|
+
mockedExecSync.mockImplementation(() => {
|
|
70
|
+
throw new Error('not found');
|
|
71
|
+
});
|
|
72
|
+
mockedExistsSync.mockReturnValue(false);
|
|
73
|
+
const result = await detector.detect();
|
|
74
|
+
expect(result.installed).toBe(false);
|
|
75
|
+
expect(result.configExists).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
describe('GeminiCliDetector', () => {
|
|
79
|
+
const detector = new GeminiCliDetector();
|
|
80
|
+
it('detects when gemini is installed and config exists', async () => {
|
|
81
|
+
mockedExecSync.mockReturnValue(Buffer.from('/usr/bin/gemini'));
|
|
82
|
+
mockedExistsSync.mockReturnValue(true);
|
|
83
|
+
const result = await detector.detect();
|
|
84
|
+
expect(result.installed).toBe(true);
|
|
85
|
+
expect(result.configExists).toBe(true);
|
|
86
|
+
expect(result.configPath).toBe(join(homedir(), '.gemini', 'settings.json'));
|
|
87
|
+
expect(result.name).toBe('Gemini CLI');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe('detectAllAgents', () => {
|
|
91
|
+
it('returns results for all 3 agents', async () => {
|
|
92
|
+
mockedExecSync.mockReturnValue(Buffer.from('/usr/bin/agent'));
|
|
93
|
+
mockedExistsSync.mockReturnValue(true);
|
|
94
|
+
const results = await detectAllAgents();
|
|
95
|
+
expect(results).toHaveLength(3);
|
|
96
|
+
const names = results.map((r) => r.name);
|
|
97
|
+
expect(names).toContain('Claude Code');
|
|
98
|
+
expect(names).toContain('OpenCode');
|
|
99
|
+
expect(names).toContain('Gemini CLI');
|
|
100
|
+
results.forEach((r) => {
|
|
101
|
+
expect(r.installed).toBe(true);
|
|
102
|
+
expect(r.configExists).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
export class ClaudeCodeDetector {
|
|
6
|
+
name = 'Claude Code';
|
|
7
|
+
async detect() {
|
|
8
|
+
const installed = this.checkInstalled();
|
|
9
|
+
const { configExists, configPath } = this.findConfig();
|
|
10
|
+
return {
|
|
11
|
+
installed,
|
|
12
|
+
configExists,
|
|
13
|
+
configPath,
|
|
14
|
+
name: this.name,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
checkInstalled() {
|
|
18
|
+
try {
|
|
19
|
+
execSync('which claude', { stdio: 'ignore' });
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
findConfig() {
|
|
27
|
+
// Check cwd first, then home directory
|
|
28
|
+
const cwdPath = join(process.cwd(), '.mcp.json');
|
|
29
|
+
if (existsSync(cwdPath)) {
|
|
30
|
+
return { configExists: true, configPath: cwdPath };
|
|
31
|
+
}
|
|
32
|
+
const homePath = join(homedir(), '.mcp.json');
|
|
33
|
+
if (existsSync(homePath)) {
|
|
34
|
+
return { configExists: true, configPath: homePath };
|
|
35
|
+
}
|
|
36
|
+
// Default to cwd path even if not found
|
|
37
|
+
return { configExists: false, configPath: cwdPath };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
export class GeminiCliDetector {
|
|
6
|
+
name = 'Gemini CLI';
|
|
7
|
+
async detect() {
|
|
8
|
+
const installed = this.checkInstalled();
|
|
9
|
+
const configPath = join(homedir(), '.gemini', 'settings.json');
|
|
10
|
+
const configExists = existsSync(configPath);
|
|
11
|
+
return {
|
|
12
|
+
installed,
|
|
13
|
+
configExists,
|
|
14
|
+
configPath,
|
|
15
|
+
name: this.name,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
checkInstalled() {
|
|
19
|
+
try {
|
|
20
|
+
execSync('which gemini', { stdio: 'ignore' });
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ClaudeCodeDetector } from './claude-code.js';
|
|
2
|
+
import { OpenCodeDetector } from './opencode.js';
|
|
3
|
+
import { GeminiCliDetector } from './gemini-cli.js';
|
|
4
|
+
export interface DetectionResult {
|
|
5
|
+
installed: boolean;
|
|
6
|
+
configExists: boolean;
|
|
7
|
+
configPath: string;
|
|
8
|
+
name: string;
|
|
9
|
+
}
|
|
10
|
+
export interface AgentDetector {
|
|
11
|
+
name: string;
|
|
12
|
+
detect(): Promise<DetectionResult>;
|
|
13
|
+
}
|
|
14
|
+
export declare function detectAllAgents(): Promise<DetectionResult[]>;
|
|
15
|
+
export { ClaudeCodeDetector, OpenCodeDetector, GeminiCliDetector };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ClaudeCodeDetector } from './claude-code.js';
|
|
2
|
+
import { OpenCodeDetector } from './opencode.js';
|
|
3
|
+
import { GeminiCliDetector } from './gemini-cli.js';
|
|
4
|
+
const allDetectors = [
|
|
5
|
+
new ClaudeCodeDetector(),
|
|
6
|
+
new OpenCodeDetector(),
|
|
7
|
+
new GeminiCliDetector(),
|
|
8
|
+
];
|
|
9
|
+
export async function detectAllAgents() {
|
|
10
|
+
return Promise.all(allDetectors.map((d) => d.detect()));
|
|
11
|
+
}
|
|
12
|
+
export { ClaudeCodeDetector, OpenCodeDetector, GeminiCliDetector };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
export class OpenCodeDetector {
|
|
5
|
+
name = 'OpenCode';
|
|
6
|
+
async detect() {
|
|
7
|
+
const installed = this.checkInstalled();
|
|
8
|
+
const configPath = join(process.cwd(), 'opencode.json');
|
|
9
|
+
const configExists = existsSync(configPath);
|
|
10
|
+
return {
|
|
11
|
+
installed,
|
|
12
|
+
configExists,
|
|
13
|
+
configPath,
|
|
14
|
+
name: this.name,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
checkInstalled() {
|
|
18
|
+
try {
|
|
19
|
+
execSync('which opencode', { stdio: 'ignore' });
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { makeSetupCommand } from './commands/setup.js';
|
|
3
|
+
import { makeStatusCommand } from './commands/status.js';
|
|
4
|
+
import { makeAgentsCommand } from './commands/agents.js';
|
|
5
|
+
import { makeDaemonCommand } from './commands/daemon.js';
|
|
6
|
+
import { makeStartCommand } from './commands/start.js';
|
|
7
|
+
const program = new Command();
|
|
8
|
+
program
|
|
9
|
+
.name('swarmroom')
|
|
10
|
+
.description('SwarmRoom CLI — orchestrate AI agents on your local network')
|
|
11
|
+
.version('0.1.0');
|
|
12
|
+
program.addCommand(makeSetupCommand());
|
|
13
|
+
program.addCommand(makeStatusCommand());
|
|
14
|
+
program.addCommand(makeAgentsCommand());
|
|
15
|
+
program.addCommand(makeDaemonCommand());
|
|
16
|
+
program.addCommand(makeStartCommand());
|
|
17
|
+
program.parse();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare function banner(title: string, message?: string): void;
|
|
2
|
+
export declare function successBox(title: string, lines: string[]): void;
|
|
3
|
+
export declare function table(headers: string[], rows: string[][]): void;
|
|
4
|
+
export declare function statusIndicator(status: string): string;
|
|
5
|
+
export declare function formatUptime(seconds: number): string;
|
|
6
|
+
export declare function keyValue(key: string, value: string): void;
|
|
7
|
+
export declare function error(message: string): void;
|
|
8
|
+
export declare function warn(message: string): void;
|
|
9
|
+
export declare function info(message: string): void;
|
|
10
|
+
export declare function success(message: string): void;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
export function banner(title, message) {
|
|
3
|
+
const line = '─'.repeat(Math.max(title.length, message?.length ?? 0) + 4);
|
|
4
|
+
console.log('');
|
|
5
|
+
console.log(chalk.cyan(` ╭${line}╮`));
|
|
6
|
+
console.log(chalk.cyan(` │ ${chalk.bold.white(title.padEnd(line.length - 4))} │`));
|
|
7
|
+
if (message) {
|
|
8
|
+
console.log(chalk.cyan(` │ ${chalk.gray(message.padEnd(line.length - 4))} │`));
|
|
9
|
+
}
|
|
10
|
+
console.log(chalk.cyan(` ╰${line}╯`));
|
|
11
|
+
console.log('');
|
|
12
|
+
}
|
|
13
|
+
export function successBox(title, lines) {
|
|
14
|
+
const maxLen = Math.max(title.length, ...lines.map((l) => l.length));
|
|
15
|
+
const width = maxLen + 4;
|
|
16
|
+
const border = '─'.repeat(width);
|
|
17
|
+
console.log('');
|
|
18
|
+
console.log(chalk.green(` ╭${border}╮`));
|
|
19
|
+
console.log(chalk.green(` │ ${chalk.bold.white(title.padEnd(width - 4))} │`));
|
|
20
|
+
console.log(chalk.green(` │${' '.repeat(width)}│`));
|
|
21
|
+
for (const line of lines) {
|
|
22
|
+
console.log(chalk.green(` │ ${line.padEnd(width - 4)} │`));
|
|
23
|
+
}
|
|
24
|
+
console.log(chalk.green(` ╰${border}╯`));
|
|
25
|
+
console.log('');
|
|
26
|
+
}
|
|
27
|
+
export function table(headers, rows) {
|
|
28
|
+
const colWidths = headers.map((h, i) => {
|
|
29
|
+
const maxRow = rows.reduce((max, row) => Math.max(max, (row[i] ?? '').length), 0);
|
|
30
|
+
return Math.max(h.length, maxRow);
|
|
31
|
+
});
|
|
32
|
+
const headerLine = headers.map((h, i) => chalk.bold.white(h.padEnd(colWidths[i]))).join(' ');
|
|
33
|
+
const separator = colWidths.map((w) => '─'.repeat(w)).join('──');
|
|
34
|
+
console.log(` ${headerLine}`);
|
|
35
|
+
console.log(chalk.gray(` ${separator}`));
|
|
36
|
+
for (const row of rows) {
|
|
37
|
+
const line = row.map((cell, i) => cell.padEnd(colWidths[i])).join(' ');
|
|
38
|
+
console.log(` ${line}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function statusIndicator(status) {
|
|
42
|
+
switch (status) {
|
|
43
|
+
case 'online':
|
|
44
|
+
return chalk.green('●') + ' ' + chalk.green('online');
|
|
45
|
+
case 'busy':
|
|
46
|
+
return chalk.yellow('●') + ' ' + chalk.yellow('busy');
|
|
47
|
+
case 'idle':
|
|
48
|
+
return chalk.blue('●') + ' ' + chalk.blue('idle');
|
|
49
|
+
case 'offline':
|
|
50
|
+
return chalk.gray('●') + ' ' + chalk.gray('offline');
|
|
51
|
+
default:
|
|
52
|
+
return chalk.gray('●') + ' ' + chalk.gray(status);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function formatUptime(seconds) {
|
|
56
|
+
if (seconds < 60)
|
|
57
|
+
return `${seconds}s`;
|
|
58
|
+
if (seconds < 3600)
|
|
59
|
+
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
60
|
+
const hours = Math.floor(seconds / 3600);
|
|
61
|
+
const mins = Math.floor((seconds % 3600) / 60);
|
|
62
|
+
return `${hours}h ${mins}m`;
|
|
63
|
+
}
|
|
64
|
+
export function keyValue(key, value) {
|
|
65
|
+
console.log(` ${chalk.gray(key + ':')} ${value}`);
|
|
66
|
+
}
|
|
67
|
+
export function error(message) {
|
|
68
|
+
console.error(chalk.red(` ✖ ${message}`));
|
|
69
|
+
}
|
|
70
|
+
export function warn(message) {
|
|
71
|
+
console.log(chalk.yellow(` ⚠ ${message}`));
|
|
72
|
+
}
|
|
73
|
+
export function info(message) {
|
|
74
|
+
console.log(chalk.blue(` ℹ ${message}`));
|
|
75
|
+
}
|
|
76
|
+
export function success(message) {
|
|
77
|
+
console.log(chalk.green(` ✔ ${message}`));
|
|
78
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "swarmroom",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "LAN-based agent discovery and communication hub for AI coding agents",
|
|
6
|
+
"keywords": ["swarm", "agent", "mcp", "ai", "multi-agent", "lan", "discovery"],
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/visual-z/swarm.git",
|
|
11
|
+
"directory": "packages/cli"
|
|
12
|
+
},
|
|
13
|
+
"main": "dist/index.js",
|
|
14
|
+
"types": "dist/index.d.ts",
|
|
15
|
+
"bin": {
|
|
16
|
+
"swarmroom": "./bin/swarmroom.js"
|
|
17
|
+
},
|
|
18
|
+
"files": ["dist", "bin", "README.md", "LICENSE"],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc",
|
|
21
|
+
"dev": "tsc --watch",
|
|
22
|
+
"test": "vitest run",
|
|
23
|
+
"prepublishOnly": "npm run build"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@swarmroom/sdk": "^0.1.0",
|
|
27
|
+
"@swarmroom/server": "^0.1.0",
|
|
28
|
+
"@swarmroom/shared": "^0.1.0",
|
|
29
|
+
"chalk": "^5.6.2",
|
|
30
|
+
"commander": "^14.0.3",
|
|
31
|
+
"inquirer": "^13.2.2",
|
|
32
|
+
"ora": "^9.3.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/inquirer": "^9.0.9",
|
|
36
|
+
"vitest": "^4.0.18"
|
|
37
|
+
}
|
|
38
|
+
}
|