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,134 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { DaemonWatcher } from '../daemon/watcher.js';
|
|
7
|
+
import { banner, keyValue, info, error } from '../utils/display.js';
|
|
8
|
+
const HUB_STARTUP_DELAY_MS = 2000;
|
|
9
|
+
function resolveServerEntry() {
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
return require.resolve('@swarmroom/server/dist/index.js');
|
|
12
|
+
}
|
|
13
|
+
function prefixLines(prefix, data) {
|
|
14
|
+
const text = data.toString().trim();
|
|
15
|
+
if (!text)
|
|
16
|
+
return;
|
|
17
|
+
for (const line of text.split('\n')) {
|
|
18
|
+
console.log(`${prefix} ${line}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function startHubProcess(port) {
|
|
22
|
+
const serverEntry = resolveServerEntry();
|
|
23
|
+
if (!existsSync(serverEntry)) {
|
|
24
|
+
error(`Server entry not found at: ${serverEntry}`);
|
|
25
|
+
error('Make sure the server is built: npm run build -w packages/server');
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
const hubProcess = spawn('node', [serverEntry], {
|
|
29
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
30
|
+
env: { ...process.env, PORT: String(port) },
|
|
31
|
+
});
|
|
32
|
+
const hubPrefix = chalk.blue('[hub]');
|
|
33
|
+
const hubErrPrefix = chalk.red('[hub]');
|
|
34
|
+
hubProcess.stdout?.on('data', (data) => {
|
|
35
|
+
prefixLines(hubPrefix, data);
|
|
36
|
+
});
|
|
37
|
+
hubProcess.stderr?.on('data', (data) => {
|
|
38
|
+
prefixLines(hubErrPrefix, data);
|
|
39
|
+
});
|
|
40
|
+
hubProcess.on('exit', (code) => {
|
|
41
|
+
if (code !== null && code !== 0) {
|
|
42
|
+
error(`Hub process exited with code ${code}`);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
hubProcess.on('error', (err) => {
|
|
46
|
+
error(`Failed to start hub: ${err.message}`);
|
|
47
|
+
});
|
|
48
|
+
return hubProcess;
|
|
49
|
+
}
|
|
50
|
+
export function makeStartCommand() {
|
|
51
|
+
const cmd = new Command('start')
|
|
52
|
+
.description('Start SwarmRoom (hub + daemon)')
|
|
53
|
+
.option('--hub-only', 'Start only the hub server')
|
|
54
|
+
.option('--daemon-only', 'Start only the daemon watcher')
|
|
55
|
+
.option('--hub-url <url>', 'Hub URL for daemon connection')
|
|
56
|
+
.option('--port <port>', 'Server port (default: 3000)', '3000')
|
|
57
|
+
.option('--verbose', 'Enable verbose logging')
|
|
58
|
+
.action(async (options) => {
|
|
59
|
+
const port = parseInt(options.port, 10);
|
|
60
|
+
const verbose = options.verbose ?? false;
|
|
61
|
+
if (options.hubOnly && options.daemonOnly) {
|
|
62
|
+
error('Cannot use --hub-only and --daemon-only together.');
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
if (options.hubOnly) {
|
|
66
|
+
banner('SwarmRoom Hub');
|
|
67
|
+
info('Starting hub server only...');
|
|
68
|
+
keyValue('Port', chalk.cyan(String(port)));
|
|
69
|
+
console.log('');
|
|
70
|
+
const hubProcess = startHubProcess(port);
|
|
71
|
+
const shutdown = () => {
|
|
72
|
+
console.log('');
|
|
73
|
+
info('Shutting down hub...');
|
|
74
|
+
hubProcess.kill('SIGTERM');
|
|
75
|
+
process.exit(0);
|
|
76
|
+
};
|
|
77
|
+
process.on('SIGINT', shutdown);
|
|
78
|
+
process.on('SIGTERM', shutdown);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (options.daemonOnly) {
|
|
82
|
+
const hubUrl = options.hubUrl ?? `http://localhost:${port}`;
|
|
83
|
+
banner('SwarmRoom Daemon');
|
|
84
|
+
info('Starting daemon watcher only...');
|
|
85
|
+
keyValue('Hub URL', chalk.cyan(hubUrl));
|
|
86
|
+
console.log('');
|
|
87
|
+
const watcher = new DaemonWatcher({
|
|
88
|
+
hubUrl,
|
|
89
|
+
workdir: process.cwd(),
|
|
90
|
+
verbose,
|
|
91
|
+
});
|
|
92
|
+
watcher.start();
|
|
93
|
+
const shutdown = () => {
|
|
94
|
+
console.log('');
|
|
95
|
+
info('Shutting down daemon...');
|
|
96
|
+
watcher.stop();
|
|
97
|
+
process.exit(0);
|
|
98
|
+
};
|
|
99
|
+
process.on('SIGINT', shutdown);
|
|
100
|
+
process.on('SIGTERM', shutdown);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const hubUrl = options.hubUrl ?? `http://localhost:${port}`;
|
|
104
|
+
banner('SwarmRoom', 'Hub + Daemon');
|
|
105
|
+
info('Starting hub server and daemon watcher...');
|
|
106
|
+
keyValue('Port', chalk.cyan(String(port)));
|
|
107
|
+
keyValue('Hub URL', chalk.cyan(hubUrl));
|
|
108
|
+
if (verbose) {
|
|
109
|
+
keyValue('Verbose', chalk.yellow('enabled'));
|
|
110
|
+
}
|
|
111
|
+
console.log('');
|
|
112
|
+
const hubProcess = startHubProcess(port);
|
|
113
|
+
const watcher = new DaemonWatcher({
|
|
114
|
+
hubUrl,
|
|
115
|
+
workdir: process.cwd(),
|
|
116
|
+
verbose,
|
|
117
|
+
});
|
|
118
|
+
const daemonTimer = setTimeout(() => {
|
|
119
|
+
info('Starting daemon watcher...');
|
|
120
|
+
watcher.start();
|
|
121
|
+
}, HUB_STARTUP_DELAY_MS);
|
|
122
|
+
const shutdown = () => {
|
|
123
|
+
console.log('');
|
|
124
|
+
info('Shutting down SwarmRoom...');
|
|
125
|
+
clearTimeout(daemonTimer);
|
|
126
|
+
watcher.stop();
|
|
127
|
+
hubProcess.kill('SIGTERM');
|
|
128
|
+
process.exit(0);
|
|
129
|
+
};
|
|
130
|
+
process.on('SIGINT', shutdown);
|
|
131
|
+
process.on('SIGTERM', shutdown);
|
|
132
|
+
});
|
|
133
|
+
return cmd;
|
|
134
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import { DEFAULT_PORT } from '@swarmroom/shared';
|
|
5
|
+
import { banner, keyValue, error, formatUptime, statusIndicator, } from '../utils/display.js';
|
|
6
|
+
async function resolveHubUrl(providedUrl) {
|
|
7
|
+
if (providedUrl)
|
|
8
|
+
return providedUrl;
|
|
9
|
+
try {
|
|
10
|
+
const { discoverHub } = await import('@swarmroom/sdk');
|
|
11
|
+
const discovered = await discoverHub(3000);
|
|
12
|
+
if (discovered)
|
|
13
|
+
return discovered;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
}
|
|
17
|
+
return `http://localhost:${DEFAULT_PORT}`;
|
|
18
|
+
}
|
|
19
|
+
export function makeStatusCommand() {
|
|
20
|
+
const cmd = new Command('status')
|
|
21
|
+
.description('Show SwarmRoom Hub status')
|
|
22
|
+
.option('--hub-url <url>', 'Hub URL (or discover via mDNS)')
|
|
23
|
+
.action(async (options) => {
|
|
24
|
+
banner('SwarmRoom Status');
|
|
25
|
+
const hubUrl = await resolveHubUrl(options.hubUrl);
|
|
26
|
+
const spinner = ora(`Connecting to ${chalk.cyan(hubUrl)}...`).start();
|
|
27
|
+
try {
|
|
28
|
+
const response = await fetch(`${hubUrl}/health`);
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
spinner.fail(`Hub returned HTTP ${response.status}`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const health = (await response.json());
|
|
34
|
+
spinner.stop();
|
|
35
|
+
console.log('');
|
|
36
|
+
keyValue('Hub URL', chalk.cyan(hubUrl));
|
|
37
|
+
keyValue('Status', statusIndicator(health.status === 'ok' ? 'online' : 'offline'));
|
|
38
|
+
keyValue('Version', chalk.white(health.version));
|
|
39
|
+
keyValue('Uptime', chalk.white(formatUptime(health.uptime)));
|
|
40
|
+
keyValue('Agents', chalk.white(String(health.agentCount)));
|
|
41
|
+
console.log('');
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
spinner.fail(`Cannot connect to Hub at ${chalk.cyan(hubUrl)}`);
|
|
45
|
+
console.log('');
|
|
46
|
+
error('Hub is offline or unreachable.');
|
|
47
|
+
console.log('');
|
|
48
|
+
keyValue('Hub URL', chalk.cyan(hubUrl));
|
|
49
|
+
keyValue('Status', statusIndicator('offline'));
|
|
50
|
+
console.log('');
|
|
51
|
+
console.log(chalk.gray(' Tip: Start the hub with ') +
|
|
52
|
+
chalk.cyan('npm run dev -w packages/server') +
|
|
53
|
+
chalk.gray(' or check the URL.'));
|
|
54
|
+
console.log('');
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
return cmd;
|
|
58
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { ClaudeCodeConfigurator } from '../claude-code.js';
|
|
6
|
+
import { OpenCodeConfigurator } from '../opencode.js';
|
|
7
|
+
import { GeminiCliConfigurator } from '../gemini-cli.js';
|
|
8
|
+
import { configureAgent } from '../index.js';
|
|
9
|
+
let tempDir;
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
if (tempDir) {
|
|
12
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
function createTempDir() {
|
|
16
|
+
tempDir = mkdtempSync(join(tmpdir(), 'swarmroom-test-'));
|
|
17
|
+
return tempDir;
|
|
18
|
+
}
|
|
19
|
+
describe('ClaudeCodeConfigurator', () => {
|
|
20
|
+
const configurator = new ClaudeCodeConfigurator();
|
|
21
|
+
const hubUrl = 'http://localhost:3000';
|
|
22
|
+
it('writes correct JSON to new config file', async () => {
|
|
23
|
+
const dir = createTempDir();
|
|
24
|
+
const configPath = join(dir, '.mcp.json');
|
|
25
|
+
const result = await configurator.configure(hubUrl, configPath, false);
|
|
26
|
+
expect(result.success).toBe(true);
|
|
27
|
+
expect(result.backedUp).toBe(false);
|
|
28
|
+
const written = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
29
|
+
expect(written.mcpServers.swarmroom).toEqual({
|
|
30
|
+
type: 'http',
|
|
31
|
+
url: 'http://localhost:3000/mcp',
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
it('merges with existing config and creates backup', async () => {
|
|
35
|
+
const dir = createTempDir();
|
|
36
|
+
const configPath = join(dir, '.mcp.json');
|
|
37
|
+
const existingConfig = {
|
|
38
|
+
mcpServers: {
|
|
39
|
+
other: { type: 'stdio', command: 'some-tool' },
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
writeFileSync(configPath, JSON.stringify(existingConfig, null, 2), 'utf-8');
|
|
43
|
+
const result = await configurator.configure(hubUrl, configPath, false);
|
|
44
|
+
expect(result.success).toBe(true);
|
|
45
|
+
expect(result.backedUp).toBe(true);
|
|
46
|
+
expect(result.backupPath).toBe(configPath + '.bak');
|
|
47
|
+
expect(existsSync(configPath + '.bak')).toBe(true);
|
|
48
|
+
const merged = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
49
|
+
expect(merged.mcpServers.other).toEqual({ type: 'stdio', command: 'some-tool' });
|
|
50
|
+
expect(merged.mcpServers.swarmroom).toEqual({
|
|
51
|
+
type: 'http',
|
|
52
|
+
url: 'http://localhost:3000/mcp',
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
it('does not write files in dry-run mode', async () => {
|
|
56
|
+
const dir = createTempDir();
|
|
57
|
+
const configPath = join(dir, '.mcp.json');
|
|
58
|
+
const result = await configurator.configure(hubUrl, configPath, true);
|
|
59
|
+
expect(result.success).toBe(true);
|
|
60
|
+
expect(result.backedUp).toBe(false);
|
|
61
|
+
expect(result.before).toBe('{}');
|
|
62
|
+
expect(result.after).toContain('swarmroom');
|
|
63
|
+
expect(existsSync(configPath)).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
describe('OpenCodeConfigurator', () => {
|
|
67
|
+
const configurator = new OpenCodeConfigurator();
|
|
68
|
+
const hubUrl = 'http://localhost:3000';
|
|
69
|
+
it('writes config with mcp key (not mcpServers)', async () => {
|
|
70
|
+
const dir = createTempDir();
|
|
71
|
+
const configPath = join(dir, 'opencode.json');
|
|
72
|
+
const result = await configurator.configure(hubUrl, configPath, false);
|
|
73
|
+
expect(result.success).toBe(true);
|
|
74
|
+
const written = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
75
|
+
expect(written.mcp.swarmroom).toEqual({
|
|
76
|
+
type: 'remote',
|
|
77
|
+
url: 'http://localhost:3000/mcp',
|
|
78
|
+
});
|
|
79
|
+
expect(written.mcpServers).toBeUndefined();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
describe('GeminiCliConfigurator', () => {
|
|
83
|
+
const configurator = new GeminiCliConfigurator();
|
|
84
|
+
const hubUrl = 'http://localhost:3000';
|
|
85
|
+
it('creates parent directories and writes httpUrl format', async () => {
|
|
86
|
+
const dir = createTempDir();
|
|
87
|
+
const configPath = join(dir, '.gemini', 'settings.json');
|
|
88
|
+
const result = await configurator.configure(hubUrl, configPath, false);
|
|
89
|
+
expect(result.success).toBe(true);
|
|
90
|
+
const written = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
91
|
+
expect(written.mcpServers.swarmroom).toEqual({
|
|
92
|
+
httpUrl: 'http://localhost:3000/mcp',
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
describe('configureAgent', () => {
|
|
97
|
+
it('returns error for unknown agent name', async () => {
|
|
98
|
+
const result = await configureAgent('Unknown Agent', 'http://localhost:3000', '/tmp/fake', false);
|
|
99
|
+
expect(result.success).toBe(false);
|
|
100
|
+
expect(result.error).toContain('No configurator found');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { AgentConfigurator, ConfiguratorResult } from './index.js';
|
|
2
|
+
export declare class ClaudeCodeConfigurator implements AgentConfigurator {
|
|
3
|
+
readonly name = "Claude Code";
|
|
4
|
+
configure(hubUrl: string, configPath: string, dryRun: boolean): Promise<ConfiguratorResult>;
|
|
5
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, copyFileSync, existsSync } from 'node:fs';
|
|
2
|
+
export class ClaudeCodeConfigurator {
|
|
3
|
+
name = 'Claude Code';
|
|
4
|
+
async configure(hubUrl, configPath, dryRun) {
|
|
5
|
+
try {
|
|
6
|
+
let existing = {};
|
|
7
|
+
let before = '{}';
|
|
8
|
+
if (existsSync(configPath)) {
|
|
9
|
+
const raw = readFileSync(configPath, 'utf-8');
|
|
10
|
+
before = raw;
|
|
11
|
+
existing = JSON.parse(raw);
|
|
12
|
+
}
|
|
13
|
+
const mcpServers = (existing.mcpServers ?? {});
|
|
14
|
+
const merged = {
|
|
15
|
+
...existing,
|
|
16
|
+
mcpServers: {
|
|
17
|
+
...mcpServers,
|
|
18
|
+
swarmroom: {
|
|
19
|
+
type: 'http',
|
|
20
|
+
url: `${hubUrl}/mcp`,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
const after = JSON.stringify(merged, null, 2) + '\n';
|
|
25
|
+
if (dryRun) {
|
|
26
|
+
return {
|
|
27
|
+
success: true,
|
|
28
|
+
configPath,
|
|
29
|
+
backedUp: false,
|
|
30
|
+
before,
|
|
31
|
+
after,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
let backedUp = false;
|
|
35
|
+
let backupPath;
|
|
36
|
+
if (existsSync(configPath)) {
|
|
37
|
+
backupPath = configPath + '.bak';
|
|
38
|
+
copyFileSync(configPath, backupPath);
|
|
39
|
+
backedUp = true;
|
|
40
|
+
}
|
|
41
|
+
writeFileSync(configPath, after, 'utf-8');
|
|
42
|
+
return {
|
|
43
|
+
success: true,
|
|
44
|
+
configPath,
|
|
45
|
+
backedUp,
|
|
46
|
+
backupPath,
|
|
47
|
+
before,
|
|
48
|
+
after,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
return {
|
|
53
|
+
success: false,
|
|
54
|
+
configPath,
|
|
55
|
+
backedUp: false,
|
|
56
|
+
error: err instanceof Error ? err.message : String(err),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { AgentConfigurator, ConfiguratorResult } from './index.js';
|
|
2
|
+
export declare class GeminiCliConfigurator implements AgentConfigurator {
|
|
3
|
+
readonly name = "Gemini CLI";
|
|
4
|
+
configure(hubUrl: string, configPath: string, dryRun: boolean): Promise<ConfiguratorResult>;
|
|
5
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, copyFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
export class GeminiCliConfigurator {
|
|
4
|
+
name = 'Gemini CLI';
|
|
5
|
+
async configure(hubUrl, configPath, dryRun) {
|
|
6
|
+
try {
|
|
7
|
+
let existing = {};
|
|
8
|
+
let before = '{}';
|
|
9
|
+
if (existsSync(configPath)) {
|
|
10
|
+
const raw = readFileSync(configPath, 'utf-8');
|
|
11
|
+
before = raw;
|
|
12
|
+
existing = JSON.parse(raw);
|
|
13
|
+
}
|
|
14
|
+
const mcpServers = (existing.mcpServers ?? {});
|
|
15
|
+
const merged = {
|
|
16
|
+
...existing,
|
|
17
|
+
mcpServers: {
|
|
18
|
+
...mcpServers,
|
|
19
|
+
swarmroom: {
|
|
20
|
+
httpUrl: `${hubUrl}/mcp`,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
const after = JSON.stringify(merged, null, 2) + '\n';
|
|
25
|
+
if (dryRun) {
|
|
26
|
+
return {
|
|
27
|
+
success: true,
|
|
28
|
+
configPath,
|
|
29
|
+
backedUp: false,
|
|
30
|
+
before,
|
|
31
|
+
after,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
35
|
+
let backedUp = false;
|
|
36
|
+
let backupPath;
|
|
37
|
+
if (existsSync(configPath)) {
|
|
38
|
+
backupPath = configPath + '.bak';
|
|
39
|
+
copyFileSync(configPath, backupPath);
|
|
40
|
+
backedUp = true;
|
|
41
|
+
}
|
|
42
|
+
writeFileSync(configPath, after, 'utf-8');
|
|
43
|
+
return {
|
|
44
|
+
success: true,
|
|
45
|
+
configPath,
|
|
46
|
+
backedUp,
|
|
47
|
+
backupPath,
|
|
48
|
+
before,
|
|
49
|
+
after,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
return {
|
|
54
|
+
success: false,
|
|
55
|
+
configPath,
|
|
56
|
+
backedUp: false,
|
|
57
|
+
error: err instanceof Error ? err.message : String(err),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { ClaudeCodeConfigurator } from './claude-code.js';
|
|
2
|
+
import { OpenCodeConfigurator } from './opencode.js';
|
|
3
|
+
import { GeminiCliConfigurator } from './gemini-cli.js';
|
|
4
|
+
export interface ConfiguratorResult {
|
|
5
|
+
success: boolean;
|
|
6
|
+
configPath: string;
|
|
7
|
+
backedUp: boolean;
|
|
8
|
+
backupPath?: string;
|
|
9
|
+
error?: string;
|
|
10
|
+
before?: string;
|
|
11
|
+
after?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface AgentConfigurator {
|
|
14
|
+
name: string;
|
|
15
|
+
configure(hubUrl: string, configPath: string, dryRun: boolean): Promise<ConfiguratorResult>;
|
|
16
|
+
}
|
|
17
|
+
export declare function configureAgent(agentName: string, hubUrl: string, configPath: string, dryRun: boolean): Promise<ConfiguratorResult>;
|
|
18
|
+
export { ClaudeCodeConfigurator, OpenCodeConfigurator, GeminiCliConfigurator };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { ClaudeCodeConfigurator } from './claude-code.js';
|
|
2
|
+
import { OpenCodeConfigurator } from './opencode.js';
|
|
3
|
+
import { GeminiCliConfigurator } from './gemini-cli.js';
|
|
4
|
+
const configurators = {
|
|
5
|
+
'Claude Code': new ClaudeCodeConfigurator(),
|
|
6
|
+
'OpenCode': new OpenCodeConfigurator(),
|
|
7
|
+
'Gemini CLI': new GeminiCliConfigurator(),
|
|
8
|
+
};
|
|
9
|
+
export async function configureAgent(agentName, hubUrl, configPath, dryRun) {
|
|
10
|
+
const configurator = configurators[agentName];
|
|
11
|
+
if (!configurator) {
|
|
12
|
+
return {
|
|
13
|
+
success: false,
|
|
14
|
+
configPath,
|
|
15
|
+
backedUp: false,
|
|
16
|
+
error: `No configurator found for agent: ${agentName}`,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return configurator.configure(hubUrl, configPath, dryRun);
|
|
20
|
+
}
|
|
21
|
+
export { ClaudeCodeConfigurator, OpenCodeConfigurator, GeminiCliConfigurator };
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { AgentConfigurator, ConfiguratorResult } from './index.js';
|
|
2
|
+
export declare class OpenCodeConfigurator implements AgentConfigurator {
|
|
3
|
+
readonly name = "OpenCode";
|
|
4
|
+
configure(hubUrl: string, configPath: string, dryRun: boolean): Promise<ConfiguratorResult>;
|
|
5
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, copyFileSync, existsSync } from 'node:fs';
|
|
2
|
+
export class OpenCodeConfigurator {
|
|
3
|
+
name = 'OpenCode';
|
|
4
|
+
async configure(hubUrl, configPath, dryRun) {
|
|
5
|
+
try {
|
|
6
|
+
let existing = {};
|
|
7
|
+
let before = '{}';
|
|
8
|
+
if (existsSync(configPath)) {
|
|
9
|
+
const raw = readFileSync(configPath, 'utf-8');
|
|
10
|
+
before = raw;
|
|
11
|
+
existing = JSON.parse(raw);
|
|
12
|
+
}
|
|
13
|
+
const mcp = (existing.mcp ?? {});
|
|
14
|
+
const merged = {
|
|
15
|
+
...existing,
|
|
16
|
+
mcp: {
|
|
17
|
+
...mcp,
|
|
18
|
+
swarmroom: {
|
|
19
|
+
type: 'remote',
|
|
20
|
+
url: `${hubUrl}/mcp`,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
const after = JSON.stringify(merged, null, 2) + '\n';
|
|
25
|
+
if (dryRun) {
|
|
26
|
+
return {
|
|
27
|
+
success: true,
|
|
28
|
+
configPath,
|
|
29
|
+
backedUp: false,
|
|
30
|
+
before,
|
|
31
|
+
after,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
let backedUp = false;
|
|
35
|
+
let backupPath;
|
|
36
|
+
if (existsSync(configPath)) {
|
|
37
|
+
backupPath = configPath + '.bak';
|
|
38
|
+
copyFileSync(configPath, backupPath);
|
|
39
|
+
backedUp = true;
|
|
40
|
+
}
|
|
41
|
+
writeFileSync(configPath, after, 'utf-8');
|
|
42
|
+
return {
|
|
43
|
+
success: true,
|
|
44
|
+
configPath,
|
|
45
|
+
backedUp,
|
|
46
|
+
backupPath,
|
|
47
|
+
before,
|
|
48
|
+
after,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
return {
|
|
53
|
+
success: false,
|
|
54
|
+
configPath,
|
|
55
|
+
backedUp: false,
|
|
56
|
+
error: err instanceof Error ? err.message : String(err),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,125 @@
|
|
|
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:fs', () => ({
|
|
5
|
+
existsSync: vi.fn(),
|
|
6
|
+
readFileSync: vi.fn(),
|
|
7
|
+
writeFileSync: vi.fn(),
|
|
8
|
+
mkdirSync: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
11
|
+
import { getDefaultConfig, loadDaemonConfig, saveDaemonConfig, ensureConfig, getConfigPath, } from '../config.js';
|
|
12
|
+
const mockedExistsSync = vi.mocked(existsSync);
|
|
13
|
+
const mockedReadFileSync = vi.mocked(readFileSync);
|
|
14
|
+
const mockedWriteFileSync = vi.mocked(writeFileSync);
|
|
15
|
+
const mockedMkdirSync = vi.mocked(mkdirSync);
|
|
16
|
+
const CONFIG_DIR = join(homedir(), '.swarmroom');
|
|
17
|
+
const CONFIG_PATH = join(CONFIG_DIR, 'daemon.json');
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
vi.clearAllMocks();
|
|
20
|
+
});
|
|
21
|
+
describe('getConfigPath', () => {
|
|
22
|
+
it('returns the correct path under ~/.swarmroom/', () => {
|
|
23
|
+
const path = getConfigPath();
|
|
24
|
+
expect(path).toBe(CONFIG_PATH);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
describe('getDefaultConfig', () => {
|
|
28
|
+
it('returns config with hubUrl and three agents', () => {
|
|
29
|
+
const config = getDefaultConfig();
|
|
30
|
+
expect(config.hubUrl).toBe('http://localhost:3000');
|
|
31
|
+
expect(Object.keys(config.agents)).toHaveLength(3);
|
|
32
|
+
expect(config.agents).toHaveProperty('claude-code');
|
|
33
|
+
expect(config.agents).toHaveProperty('opencode');
|
|
34
|
+
expect(config.agents).toHaveProperty('gemini-cli');
|
|
35
|
+
});
|
|
36
|
+
it('has all agents with headlessWakeup disabled by default', () => {
|
|
37
|
+
const config = getDefaultConfig();
|
|
38
|
+
for (const agent of Object.values(config.agents)) {
|
|
39
|
+
expect(agent.headlessWakeup).toBe(false);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
it('has correct command for each agent', () => {
|
|
43
|
+
const config = getDefaultConfig();
|
|
44
|
+
expect(config.agents['claude-code'].command).toBe('claude');
|
|
45
|
+
expect(config.agents['opencode'].command).toBe('opencode');
|
|
46
|
+
expect(config.agents['gemini-cli'].command).toBe('gemini');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe('loadDaemonConfig', () => {
|
|
50
|
+
it('returns defaults when config file does not exist', () => {
|
|
51
|
+
mockedExistsSync.mockReturnValue(false);
|
|
52
|
+
const config = loadDaemonConfig();
|
|
53
|
+
expect(config).toEqual(getDefaultConfig());
|
|
54
|
+
expect(mockedReadFileSync).not.toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
it('parses valid config from file and merges with defaults', () => {
|
|
57
|
+
mockedExistsSync.mockReturnValue(true);
|
|
58
|
+
mockedReadFileSync.mockReturnValue(JSON.stringify({
|
|
59
|
+
hubUrl: 'http://myhost:4000',
|
|
60
|
+
agents: {
|
|
61
|
+
'claude-code': {
|
|
62
|
+
headlessWakeup: true,
|
|
63
|
+
command: 'claude',
|
|
64
|
+
args: ['-p', '{message}'],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
}));
|
|
68
|
+
const config = loadDaemonConfig();
|
|
69
|
+
expect(config.hubUrl).toBe('http://myhost:4000');
|
|
70
|
+
expect(config.agents['claude-code'].headlessWakeup).toBe(true);
|
|
71
|
+
expect(config.agents['opencode']).toBeDefined();
|
|
72
|
+
expect(config.agents['gemini-cli']).toBeDefined();
|
|
73
|
+
});
|
|
74
|
+
it('handles invalid JSON gracefully and returns defaults', () => {
|
|
75
|
+
mockedExistsSync.mockReturnValue(true);
|
|
76
|
+
mockedReadFileSync.mockReturnValue('not valid json {{{');
|
|
77
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
78
|
+
const config = loadDaemonConfig();
|
|
79
|
+
expect(config).toEqual(getDefaultConfig());
|
|
80
|
+
warnSpy.mockRestore();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
describe('saveDaemonConfig', () => {
|
|
84
|
+
it('writes JSON to the correct path', () => {
|
|
85
|
+
mockedExistsSync.mockReturnValue(true);
|
|
86
|
+
const config = getDefaultConfig();
|
|
87
|
+
saveDaemonConfig(config);
|
|
88
|
+
expect(mockedWriteFileSync).toHaveBeenCalledWith(CONFIG_PATH, expect.stringContaining('"hubUrl"'), 'utf-8');
|
|
89
|
+
});
|
|
90
|
+
it('creates directory if it does not exist', () => {
|
|
91
|
+
mockedExistsSync.mockReturnValue(false);
|
|
92
|
+
saveDaemonConfig(getDefaultConfig());
|
|
93
|
+
expect(mockedMkdirSync).toHaveBeenCalledWith(CONFIG_DIR, { recursive: true });
|
|
94
|
+
expect(mockedWriteFileSync).toHaveBeenCalled();
|
|
95
|
+
});
|
|
96
|
+
it('does not create directory if it already exists', () => {
|
|
97
|
+
mockedExistsSync.mockReturnValue(true);
|
|
98
|
+
saveDaemonConfig(getDefaultConfig());
|
|
99
|
+
expect(mockedMkdirSync).not.toHaveBeenCalled();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
describe('ensureConfig', () => {
|
|
103
|
+
it('creates file with defaults when config does not exist', () => {
|
|
104
|
+
mockedExistsSync.mockReturnValue(false);
|
|
105
|
+
const config = ensureConfig();
|
|
106
|
+
expect(config).toEqual(getDefaultConfig());
|
|
107
|
+
expect(mockedWriteFileSync).toHaveBeenCalled();
|
|
108
|
+
});
|
|
109
|
+
it('loads existing config when file exists', () => {
|
|
110
|
+
mockedExistsSync.mockReturnValue(true);
|
|
111
|
+
mockedReadFileSync.mockReturnValue(JSON.stringify({
|
|
112
|
+
hubUrl: 'http://custom:5000',
|
|
113
|
+
agents: {
|
|
114
|
+
'claude-code': {
|
|
115
|
+
headlessWakeup: true,
|
|
116
|
+
command: 'claude',
|
|
117
|
+
args: ['-p', '{message}'],
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
}));
|
|
121
|
+
const config = ensureConfig();
|
|
122
|
+
expect(config.hubUrl).toBe('http://custom:5000');
|
|
123
|
+
expect(mockedWriteFileSync).not.toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|