bullswarm 0.1.4
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/LICENSE +21 -0
- package/README.md +67 -0
- package/bin/bullswarm.js +12 -0
- package/connectors/_schema.json +31 -0
- package/connectors/claude-code.json +17 -0
- package/connectors/codex.json +41 -0
- package/connectors/command-code.json +39 -0
- package/connectors/echo-worker.mjs +38 -0
- package/connectors/echo.json +16 -0
- package/connectors/grok.json +38 -0
- package/connectors/opencode2.json +18 -0
- package/mcp/server.mjs +138 -0
- package/package.json +48 -0
- package/skill/SKILL.md +53 -0
- package/src/cli.js +307 -0
- package/src/lib/config.js +107 -0
- package/src/lib/release.js +55 -0
- package/src/lib/route.js +133 -0
- package/src/lib/state.js +105 -0
- package/src/lib/verify.js +126 -0
- package/src/lib/version.js +17 -0
- package/src/lib/watch.js +167 -0
- package/src/meters/claude.js +126 -0
- package/src/meters/codex.js +264 -0
- package/src/meters/command-code.js +218 -0
- package/src/meters/framework.js +128 -0
- package/src/meters/grok.js +200 -0
- package/src/meters/registry.js +85 -0
- package/src/setup.js +272 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// bullswarm grok meter — Grok Build weekly credit pool via the billing
|
|
2
|
+
// endpoint the Grok CLI itself uses. Weekly-only: five_hour stays null.
|
|
3
|
+
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
const CREDITS_URL = 'https://cli-chat-proxy.grok.com/v1/billing?format=credits';
|
|
9
|
+
const REFRESH_URL = 'https://auth.x.ai/oauth2/token';
|
|
10
|
+
const TOKEN_AUTH = 'xai-grok-cli';
|
|
11
|
+
const DEFAULT_CLIENT_ID = 'b1a00492-073a-47ea-816f-4c329264a828';
|
|
12
|
+
const WEEKLY_PERIOD = 'USAGE_PERIOD_TYPE_WEEKLY';
|
|
13
|
+
const REFRESH_BUFFER_MS = 5 * 60_000;
|
|
14
|
+
|
|
15
|
+
export class GrokMeterError extends Error {
|
|
16
|
+
constructor(message, code) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.code = code; // no_auth | http | parse | network | not_weekly
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function authPath() {
|
|
23
|
+
const home = process.env.GROK_HOME?.trim() || path.join(os.homedir(), '.grok');
|
|
24
|
+
return path.join(home, 'auth.json');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function loadAuthEntry() {
|
|
28
|
+
const p = authPath();
|
|
29
|
+
if (!existsSync(p)) {
|
|
30
|
+
throw new GrokMeterError('Grok not logged in. Run `grok login`.', 'no_auth');
|
|
31
|
+
}
|
|
32
|
+
let raw;
|
|
33
|
+
try {
|
|
34
|
+
raw = JSON.parse(readFileSync(p, 'utf8'));
|
|
35
|
+
} catch {
|
|
36
|
+
throw new GrokMeterError('Grok auth.json unreadable. Re-run `grok login`.', 'no_auth');
|
|
37
|
+
}
|
|
38
|
+
for (const [entryKey, entry] of Object.entries(raw)) {
|
|
39
|
+
const token = typeof entry?.key === 'string' ? entry.key.trim() : '';
|
|
40
|
+
if (token) return { token, entry, entryKey };
|
|
41
|
+
}
|
|
42
|
+
throw new GrokMeterError('Grok auth has no access token.', 'no_auth');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function needsRefresh(entry) {
|
|
46
|
+
if (!entry.expires_at) return false;
|
|
47
|
+
const ms = Date.parse(entry.expires_at);
|
|
48
|
+
return Number.isFinite(ms) && Date.now() >= ms - REFRESH_BUFFER_MS;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function refreshAccessToken(entry) {
|
|
52
|
+
const refresh = entry.refresh_token?.trim();
|
|
53
|
+
if (!refresh) return null;
|
|
54
|
+
const clientId = entry.oidc_client_id?.trim() || DEFAULT_CLIENT_ID;
|
|
55
|
+
let res;
|
|
56
|
+
try {
|
|
57
|
+
res = await fetch(REFRESH_URL, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
60
|
+
body: new URLSearchParams({
|
|
61
|
+
grant_type: 'refresh_token',
|
|
62
|
+
client_id: clientId,
|
|
63
|
+
refresh_token: refresh,
|
|
64
|
+
}).toString(),
|
|
65
|
+
});
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
if (!res.ok) return null;
|
|
70
|
+
try {
|
|
71
|
+
const j = await res.json();
|
|
72
|
+
if (typeof j.access_token !== 'string' || !j.access_token) return null;
|
|
73
|
+
try {
|
|
74
|
+
const p = authPath();
|
|
75
|
+
const raw = JSON.parse(readFileSync(p, 'utf8'));
|
|
76
|
+
for (const key of Object.keys(raw)) {
|
|
77
|
+
if (raw[key]?.key === entry.key || raw[key]?.refresh_token === refresh) {
|
|
78
|
+
raw[key] = {
|
|
79
|
+
...raw[key],
|
|
80
|
+
key: j.access_token,
|
|
81
|
+
refresh_token: j.refresh_token ?? raw[key].refresh_token,
|
|
82
|
+
expires_at:
|
|
83
|
+
typeof j.expires_in === 'number'
|
|
84
|
+
? new Date(Date.now() + j.expires_in * 1000).toISOString()
|
|
85
|
+
: raw[key].expires_at,
|
|
86
|
+
};
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
writeFileSync(p, JSON.stringify(raw, null, 2));
|
|
91
|
+
} catch {
|
|
92
|
+
/* in-memory token still works */
|
|
93
|
+
}
|
|
94
|
+
return j.access_token;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --- pure decoder ---------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
export function parseGrokCreditsConfig(body) {
|
|
103
|
+
if (!body || typeof body !== 'object') {
|
|
104
|
+
throw new GrokMeterError('Grok billing response missing body', 'parse');
|
|
105
|
+
}
|
|
106
|
+
const config = body.config;
|
|
107
|
+
if (!config || typeof config !== 'object') {
|
|
108
|
+
throw new GrokMeterError('Grok billing response missing config', 'parse');
|
|
109
|
+
}
|
|
110
|
+
const period = config.currentPeriod;
|
|
111
|
+
if (!period || typeof period !== 'object') {
|
|
112
|
+
throw new GrokMeterError('Grok billing response missing currentPeriod', 'parse');
|
|
113
|
+
}
|
|
114
|
+
const periodType = typeof period.type === 'string' ? period.type.trim() : '';
|
|
115
|
+
if (!periodType) {
|
|
116
|
+
throw new GrokMeterError('Grok billing period type missing', 'parse');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let utilization = 0;
|
|
120
|
+
if (config.creditUsagePercent !== undefined && config.creditUsagePercent !== null) {
|
|
121
|
+
const n = Number(config.creditUsagePercent);
|
|
122
|
+
if (!Number.isFinite(n)) {
|
|
123
|
+
throw new GrokMeterError('creditUsagePercent is not a number', 'parse');
|
|
124
|
+
}
|
|
125
|
+
utilization = Math.min(100, Math.max(0, n));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const end =
|
|
129
|
+
typeof period.end === 'string' && period.end ? new Date(period.end).toISOString() : null;
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
utilization,
|
|
133
|
+
resets_at: end && !Number.isNaN(Date.parse(end)) ? end : null,
|
|
134
|
+
period_type: periodType,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function fetchJson(url, token) {
|
|
139
|
+
let res;
|
|
140
|
+
try {
|
|
141
|
+
res = await fetch(url, {
|
|
142
|
+
headers: {
|
|
143
|
+
Authorization: `Bearer ${token.trim()}`,
|
|
144
|
+
'X-XAI-Token-Auth': TOKEN_AUTH,
|
|
145
|
+
Accept: 'application/json',
|
|
146
|
+
'User-Agent': 'bullswarm',
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
} catch (err) {
|
|
150
|
+
throw new GrokMeterError(`Network error reaching Grok billing: ${err.message}`, 'network');
|
|
151
|
+
}
|
|
152
|
+
let body = null;
|
|
153
|
+
try {
|
|
154
|
+
body = await res.json();
|
|
155
|
+
} catch {
|
|
156
|
+
/* leave null */
|
|
157
|
+
}
|
|
158
|
+
return { status: res.status, body };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function fetchGrokUsage() {
|
|
162
|
+
const loaded = loadAuthEntry();
|
|
163
|
+
let token = loaded.token;
|
|
164
|
+
if (needsRefresh(loaded.entry)) {
|
|
165
|
+
const refreshed = await refreshAccessToken(loaded.entry);
|
|
166
|
+
if (refreshed) token = refreshed;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let { status, body } = await fetchJson(CREDITS_URL, token);
|
|
170
|
+
if (status === 401 || status === 403) {
|
|
171
|
+
const refreshed = await refreshAccessToken(loaded.entry);
|
|
172
|
+
if (refreshed) {
|
|
173
|
+
token = refreshed;
|
|
174
|
+
({ status, body } = await fetchJson(CREDITS_URL, token));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (status < 200 || status >= 300) {
|
|
178
|
+
throw new GrokMeterError(
|
|
179
|
+
`Grok billing returned HTTP ${status}`,
|
|
180
|
+
status === 401 || status === 403 ? 'no_auth' : 'http',
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const credits = parseGrokCreditsConfig(body);
|
|
185
|
+
if (credits.period_type !== WEEKLY_PERIOD) {
|
|
186
|
+
throw new GrokMeterError(
|
|
187
|
+
`Grok period is ${credits.period_type}, not weekly — refusing to mislabel`,
|
|
188
|
+
'not_weekly',
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
captured_at: new Date().toISOString(),
|
|
194
|
+
pool: 'grok',
|
|
195
|
+
five_hour: { utilization: null, resets_at: null },
|
|
196
|
+
seven_day: { utilization: credits.utilization, resets_at: credits.resets_at },
|
|
197
|
+
monthly: null,
|
|
198
|
+
plan_type: null,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// bullswarm meter registry — pool name → live reader, cache-first.
|
|
2
|
+
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { MeterCache, paceSnapshot, FRESH_MS, STALE_MS } from './framework.js';
|
|
6
|
+
import { fetchCodexUsage, CodexMeterError } from './codex.js';
|
|
7
|
+
import { fetchGrokUsage, GrokMeterError } from './grok.js';
|
|
8
|
+
import { fetchCommandCodeUsage, CommandCodeMeterError } from './command-code.js';
|
|
9
|
+
import { fetchClaudeUsage, ClaudeMeterError } from './claude.js';
|
|
10
|
+
|
|
11
|
+
export const METERS_DIR = () =>
|
|
12
|
+
process.env.BULLSWARM_HOME?.trim() || join(homedir(), '.bullswarm');
|
|
13
|
+
|
|
14
|
+
const READERS = {
|
|
15
|
+
codex: fetchCodexUsage,
|
|
16
|
+
grok: fetchGrokUsage,
|
|
17
|
+
'command-code': fetchCommandCodeUsage,
|
|
18
|
+
'claude-code': fetchClaudeUsage,
|
|
19
|
+
claude: fetchClaudeUsage,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function readerFor(pool) {
|
|
23
|
+
return READERS[pool] ?? null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get a usable meter reading for a pool:
|
|
28
|
+
* 1. fresh cache hit (<= FRESH_MS old) → use it
|
|
29
|
+
* 2. live poll → cache + use
|
|
30
|
+
* 3. poll failed → stale cache labeled stale, else the error
|
|
31
|
+
* Never fabricates numbers.
|
|
32
|
+
*/
|
|
33
|
+
export async function getMeterReading(pool, opts = {}) {
|
|
34
|
+
const { force = false, nowMs = Date.now() } = opts;
|
|
35
|
+
const cache = new MeterCache(join(METERS_DIR(), 'meters'));
|
|
36
|
+
const cached = cache.get(pool);
|
|
37
|
+
|
|
38
|
+
if (!force && cached && nowMs - Date.parse(cached.captured_at) <= FRESH_MS) {
|
|
39
|
+
return { snapshot: cached, source: 'cache', ...paceSnapshot(cached, nowMs) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const reader = readerFor(pool);
|
|
43
|
+
if (!reader) {
|
|
44
|
+
// No programmatic reader for this pool — declared meters (state.json)
|
|
45
|
+
// are the fallback and are handled by config.js. Signal that here.
|
|
46
|
+
return { snapshot: null, source: 'none', pacing: null, burstGate: false, windows: {} };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const snapshot = await reader();
|
|
51
|
+
cache.put(pool, snapshot);
|
|
52
|
+
return { snapshot, source: 'live', ...paceSnapshot(snapshot, nowMs) };
|
|
53
|
+
} catch (err) {
|
|
54
|
+
if (cached) {
|
|
55
|
+
const ageMs = nowMs - Date.parse(cached.captured_at);
|
|
56
|
+
if (ageMs <= STALE_MS) {
|
|
57
|
+
return {
|
|
58
|
+
snapshot: cached,
|
|
59
|
+
source: 'stale',
|
|
60
|
+
error: err,
|
|
61
|
+
ageMs,
|
|
62
|
+
...paceSnapshot(cached, nowMs),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Best-effort reading for all pools that have readers; never throws. */
|
|
71
|
+
export async function getAllMeterReadings(poolNames, opts = {}) {
|
|
72
|
+
const out = {};
|
|
73
|
+
await Promise.all(
|
|
74
|
+
poolNames.map(async (p) => {
|
|
75
|
+
try {
|
|
76
|
+
out[p] = await getMeterReading(p, opts);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
out[p] = { snapshot: null, source: 'error', error: err, pacing: null, burstGate: false, windows: {} };
|
|
79
|
+
}
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export { CodexMeterError, GrokMeterError, CommandCodeMeterError, ClaudeMeterError, FRESH_MS, STALE_MS };
|
package/src/setup.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// bullswarm setup wizard — the front door.
|
|
2
|
+
//
|
|
3
|
+
// Doctrine:
|
|
4
|
+
// U1. Discovery = binary on PATH + config dir present + (never) credential
|
|
5
|
+
// entry. Burn rate starts EMPTY and is labeled "learning".
|
|
6
|
+
// U2. The wizard suggests a routing table as an EDITABLE ARTIFACT, never a
|
|
7
|
+
// questionnaire.
|
|
8
|
+
// U3. CLAUDE.md / AGENTS.md integration is a DIFF with explicit approval
|
|
9
|
+
// before any write, delimited by versioned bullswarm:begin/end
|
|
10
|
+
// markers, idempotent on re-run.
|
|
11
|
+
// U4. `bullswarm setup` on a configured machine reports state and repairs
|
|
12
|
+
// broken connector files.
|
|
13
|
+
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import {
|
|
16
|
+
existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, copyFileSync,
|
|
17
|
+
} from 'node:fs';
|
|
18
|
+
import { join, dirname } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { stdin as input } from 'node:process';
|
|
21
|
+
import { loadState, saveState } from './lib/state.js';
|
|
22
|
+
|
|
23
|
+
const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
24
|
+
const MARKER_BEGIN = '<!-- bullswarm:begin v1 -->';
|
|
25
|
+
const MARKER_END = '<!-- bullswarm:end -->';
|
|
26
|
+
|
|
27
|
+
// --- prompting ------------------------------------------------------------
|
|
28
|
+
// Sequential prompts that work identically on a TTY and with piped answers.
|
|
29
|
+
// (readline/promises question() drops lines when stdin is a pipe: the second
|
|
30
|
+
// question re-arms after buffered data was already consumed. Preload pipes;
|
|
31
|
+
// readline only per-question on a real TTY.)
|
|
32
|
+
class Prompter {
|
|
33
|
+
#lines = [];
|
|
34
|
+
#preloaded = false;
|
|
35
|
+
|
|
36
|
+
async #preload() {
|
|
37
|
+
if (this.#preloaded) return;
|
|
38
|
+
this.#preloaded = true;
|
|
39
|
+
if (!input.isTTY) {
|
|
40
|
+
input.setEncoding?.('utf8');
|
|
41
|
+
let data = '';
|
|
42
|
+
for await (const chunk of input) data += chunk;
|
|
43
|
+
this.#lines = data.split('\n').filter((x) => x.length > 0);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async question(prompt) {
|
|
48
|
+
await this.#preload();
|
|
49
|
+
if (input.isTTY) {
|
|
50
|
+
const { createInterface } = await import('node:readline/promises');
|
|
51
|
+
const rl = createInterface({ input, output: process.stderr });
|
|
52
|
+
const answer = (await rl.question(prompt)).trim();
|
|
53
|
+
rl.close();
|
|
54
|
+
return answer;
|
|
55
|
+
}
|
|
56
|
+
return this.#lines.shift() ?? '';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --- discovery ---------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
function onPath(bin) {
|
|
63
|
+
try {
|
|
64
|
+
execFileSync('which', [bin], { stdio: 'pipe' });
|
|
65
|
+
return true;
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function expandHome(p) {
|
|
72
|
+
return p.startsWith('~') ? join(process.env.HOME ?? '', p.slice(1)) : p;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function discoverConnectors() {
|
|
76
|
+
const dir = join(REPO_ROOT, 'connectors');
|
|
77
|
+
const found = [];
|
|
78
|
+
for (const f of readdirSync(dir).sort()) {
|
|
79
|
+
if (!f.endsWith('.json') || f.startsWith('_')) continue;
|
|
80
|
+
let conn;
|
|
81
|
+
try {
|
|
82
|
+
conn = JSON.parse(readFileSync(join(dir, f), 'utf8'));
|
|
83
|
+
} catch {
|
|
84
|
+
found.push({ file: f, broken: true });
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const binFound = onPath(conn.bin);
|
|
88
|
+
const cfgFound = (conn.configDirs ?? []).some((d) => existsSync(expandHome(d)));
|
|
89
|
+
found.push({
|
|
90
|
+
file: f,
|
|
91
|
+
name: conn.name,
|
|
92
|
+
bin: conn.bin,
|
|
93
|
+
binFound,
|
|
94
|
+
cfgFound,
|
|
95
|
+
discovered: binFound || cfgFound,
|
|
96
|
+
meter: conn.meter?.type ?? 'none',
|
|
97
|
+
costRank: conn.costRank,
|
|
98
|
+
lanes: conn.lanes,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return found;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- routing suggestion --------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
export function suggestRoutingTable(enabledPools) {
|
|
107
|
+
const byLane = { analyze: [], build: [], chore: [] };
|
|
108
|
+
for (const p of enabledPools) {
|
|
109
|
+
for (const lane of p.lanes ?? ['analyze', 'build', 'chore']) {
|
|
110
|
+
byLane[lane]?.push(p.name);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Suggest: cheapest pool as default per lane; caller as final fallback.
|
|
114
|
+
const suggestion = {};
|
|
115
|
+
for (const [lane, names] of Object.entries(byLane)) {
|
|
116
|
+
suggestion[lane] = { order: names, fallback: 'caller' };
|
|
117
|
+
}
|
|
118
|
+
return suggestion;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// --- integration block ------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
export function integrationBlock() {
|
|
124
|
+
return `${MARKER_BEGIN}
|
|
125
|
+
## bullswarm offload policy
|
|
126
|
+
|
|
127
|
+
When a task fits a bounded lane, prefer offloading it:
|
|
128
|
+
|
|
129
|
+
bullswarm run --lane <analyze|build|chore> --add-dir <repo-dir> --task-file <file> --json
|
|
130
|
+
|
|
131
|
+
Read the verdict JSON: ok:true -> use outFile; keepOnClaude:true -> do it in-session;
|
|
132
|
+
ok:false -> the why field names the failed gate. Delegate output is INPUT you verify,
|
|
133
|
+
never the answer. Final synthesis, architecture decisions, and live-context work stay
|
|
134
|
+
with you. Run \`bullswarm health\` after every offload round.
|
|
135
|
+
${MARKER_END}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function applyIntegrationBlock(filePath, { approved }) {
|
|
139
|
+
if (!approved) return { changed: false, reason: 'not approved' };
|
|
140
|
+
let existing = '';
|
|
141
|
+
try {
|
|
142
|
+
existing = readFileSync(filePath, 'utf8');
|
|
143
|
+
} catch {
|
|
144
|
+
/* new file */
|
|
145
|
+
}
|
|
146
|
+
const stripped = existing
|
|
147
|
+
.replace(new RegExp(`${MARKER_BEGIN}[\\s\\S]*?${MARKER_END}\n?`), '')
|
|
148
|
+
.trimEnd();
|
|
149
|
+
const next = stripped
|
|
150
|
+
? `${stripped}\n\n${integrationBlock()}\n`
|
|
151
|
+
: `${integrationBlock()}\n`;
|
|
152
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
153
|
+
writeFileSync(filePath, next);
|
|
154
|
+
return { changed: true };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function integrationBlockPresent(filePath) {
|
|
158
|
+
try {
|
|
159
|
+
return readFileSync(filePath, 'utf8').includes(MARKER_BEGIN);
|
|
160
|
+
} catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// --- repair ---------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
export function repairConnectors(bullswarmDir) {
|
|
168
|
+
const target = join(bullswarmDir, 'connectors');
|
|
169
|
+
mkdirSync(target, { recursive: true });
|
|
170
|
+
const repaired = [];
|
|
171
|
+
for (const f of readdirSync(join(REPO_ROOT, 'connectors'))) {
|
|
172
|
+
if (!f.endsWith('.json') || f.startsWith('_')) continue;
|
|
173
|
+
const dst = join(target, f);
|
|
174
|
+
let broken = false;
|
|
175
|
+
try {
|
|
176
|
+
JSON.parse(readFileSync(dst, 'utf8'));
|
|
177
|
+
} catch {
|
|
178
|
+
broken = true;
|
|
179
|
+
}
|
|
180
|
+
if (!existsSync(dst) || broken) {
|
|
181
|
+
copyFileSync(join(REPO_ROOT, 'connectors', f), dst);
|
|
182
|
+
repaired.push(f);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return repaired;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// --- wizard -------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
export async function runWizard(bullswarmDir, opts = {}) {
|
|
191
|
+
const state = loadState(bullswarmDir);
|
|
192
|
+
const discovered = discoverConnectors();
|
|
193
|
+
|
|
194
|
+
if (opts.json) {
|
|
195
|
+
console.log(JSON.stringify({ discovered, state: !!state.pools }, null, 2));
|
|
196
|
+
return 0;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const rl = new Prompter();
|
|
200
|
+
console.log('bullswarm setup\n');
|
|
201
|
+
|
|
202
|
+
// 1. Discovery table
|
|
203
|
+
console.log('Discovered agent CLIs:');
|
|
204
|
+
for (const d of discovered) {
|
|
205
|
+
if (d.broken) {
|
|
206
|
+
console.log(` ${d.file.padEnd(22)} BROKEN (will repair)`);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const meter =
|
|
210
|
+
d.meter === 'none'
|
|
211
|
+
? 'quota: unmetered'
|
|
212
|
+
: `quota: ${d.meter} window (burn rate: learning)`;
|
|
213
|
+
console.log(
|
|
214
|
+
` ${d.name.padEnd(14)} ${d.discovered ? 'found' : 'not found'} ${meter}`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
console.log('');
|
|
218
|
+
|
|
219
|
+
// 2. Toggle pools
|
|
220
|
+
const enabled = [];
|
|
221
|
+
for (const d of discovered.filter((x) => !x.broken && x.discovered)) {
|
|
222
|
+
const ans = (await rl.question(`enable ${d.name}? [Y/n] `)).trim().toLowerCase();
|
|
223
|
+
if (ans !== 'n') enabled.push(d.name);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (enabled.length === 0) {
|
|
227
|
+
console.log('\nNo pools enabled — bullswarm will keep every task in-session.');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 3. Routing suggestion (editable artifact)
|
|
231
|
+
const chosen = discovered.filter((d) => enabled.includes(d.name));
|
|
232
|
+
const table = suggestRoutingTable(chosen);
|
|
233
|
+
console.log('\nSuggested routing table (edit ~/.bullswarm/routing.json to change):');
|
|
234
|
+
console.log(JSON.stringify(table, null, 2));
|
|
235
|
+
|
|
236
|
+
// 4. Write config
|
|
237
|
+
mkdirSync(join(bullswarmDir, 'connectors'), { recursive: true });
|
|
238
|
+
const repaired = repairConnectors(bullswarmDir);
|
|
239
|
+
state.pools ??= {};
|
|
240
|
+
for (const d of discovered.filter((x) => !x.broken)) {
|
|
241
|
+
state.pools[d.name] ??= {};
|
|
242
|
+
state.pools[d.name].enabled = enabled.includes(d.name);
|
|
243
|
+
}
|
|
244
|
+
saveState(bullswarmDir, state);
|
|
245
|
+
writeFileSync(
|
|
246
|
+
join(bullswarmDir, 'routing.json'),
|
|
247
|
+
`${JSON.stringify(table, null, 2)}\n`,
|
|
248
|
+
);
|
|
249
|
+
console.log(`\nWrote ${bullswarmDir}/state.json and routing.json`);
|
|
250
|
+
if (repaired.length) console.log(`Repaired connector files: ${repaired.join(', ')}`);
|
|
251
|
+
|
|
252
|
+
// 5. Integration blocks — diff + approval
|
|
253
|
+
for (const [label, path] of [
|
|
254
|
+
['CLAUDE.md', join(process.env.HOME ?? '', '.claude', 'CLAUDE.md')],
|
|
255
|
+
['AGENTS.md', join(process.cwd(), 'AGENTS.md')],
|
|
256
|
+
]) {
|
|
257
|
+
const present = integrationBlockPresent(path);
|
|
258
|
+
const preview = present
|
|
259
|
+
? 'block already present (idempotent re-run)'
|
|
260
|
+
: `will append to ${path}:\n\n${integrationBlock()}\n`;
|
|
261
|
+
console.log(`\n${label}: ${preview}`);
|
|
262
|
+
const ans = (
|
|
263
|
+
await rl.question(`write ${label} integration block? [y/N] `)
|
|
264
|
+
).trim().toLowerCase();
|
|
265
|
+
const result = applyIntegrationBlock(path, { approved: ans === 'y' && !present });
|
|
266
|
+
console.log(result.changed ? ` wrote ${path}` : ` skipped ${label}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
rl.close();
|
|
270
|
+
console.log('\nSetup complete. Try: bullswarm pools');
|
|
271
|
+
return 0;
|
|
272
|
+
}
|