swarmiq 0.2.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/LICENSE +21 -0
- package/README.md +176 -0
- package/package.json +41 -0
- package/src/api.mjs +99 -0
- package/src/config.mjs +165 -0
- package/src/github_auth.mjs +168 -0
- package/src/index.mjs +100 -0
- package/src/init.mjs +501 -0
- package/src/oauth.mjs +159 -0
package/src/index.mjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* index.mjs — CLI entry point for `npx swarmiq`.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* npx swarmiq # GitHub device-flow login (default)
|
|
7
|
+
* npx swarmiq --auth swarmiq # Force SwarmIQ/Clerk device-flow
|
|
8
|
+
* npx swarmiq --auth clerk # Alias for --auth swarmiq
|
|
9
|
+
* npx swarmiq --logout # Remove SwarmIQ env block from ~/.claude/settings.json
|
|
10
|
+
* npx swarmiq --help # Show help
|
|
11
|
+
* npx swarmiq --version # Show version
|
|
12
|
+
*
|
|
13
|
+
* Dev invocation (no npm publish needed):
|
|
14
|
+
* node tools/cli/src/index.mjs
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { runInit } from './init.mjs';
|
|
18
|
+
import { removeClaudeSettings, claudeSettingsPath } from './config.mjs';
|
|
19
|
+
|
|
20
|
+
const args = process.argv.slice(2);
|
|
21
|
+
|
|
22
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
23
|
+
console.log(`
|
|
24
|
+
swarmiq — SwarmIQ connect CLI
|
|
25
|
+
|
|
26
|
+
Usage:
|
|
27
|
+
npx swarmiq Authenticate via GitHub (default) and configure Claude Code
|
|
28
|
+
npx swarmiq --auth swarmiq Force SwarmIQ/Clerk device-flow instead of GitHub
|
|
29
|
+
npx swarmiq --auth clerk Alias for --auth swarmiq
|
|
30
|
+
npx swarmiq --logout Remove SwarmIQ settings from ~/.claude/settings.json
|
|
31
|
+
|
|
32
|
+
Environment variables:
|
|
33
|
+
SWARMIQ_PROXY_URL Override proxy base URL (default: https://api.swarmiq.dev)
|
|
34
|
+
SWARMIQ_OAUTH_URL Override OAuth base URL (default: https://api.swarmiq.dev)
|
|
35
|
+
SWARMIQ_CONFIG_HOME Override config directory (default: ~/.swarmiq)
|
|
36
|
+
SWARMIQ_CLAUDE_SETTINGS_PATH Override Claude settings path (default: ~/.claude/settings.json)
|
|
37
|
+
|
|
38
|
+
What it does (GitHub flow — default):
|
|
39
|
+
1. Fetches /v1/auth/config from the SwarmIQ proxy to get the GitHub OAuth client_id.
|
|
40
|
+
2. Starts GitHub Device Flow: prints a short code and opens https://github.com/login/device.
|
|
41
|
+
3. Polls GitHub until you approve; the GitHub token is kept in memory only (never saved).
|
|
42
|
+
4. Exchanges the GitHub token for a SwarmIQ access token via POST /v1/auth/github.
|
|
43
|
+
5. Writes an env block to ~/.claude/settings.json so Claude Code routes through SwarmIQ.
|
|
44
|
+
6. Optionally stores a provider API key in the SwarmIQ Vault (BYOK).
|
|
45
|
+
7. Verifies the connection and shows 7-day savings if available.
|
|
46
|
+
|
|
47
|
+
What it does (SwarmIQ flow — --auth swarmiq):
|
|
48
|
+
1. Requests a device code from the SwarmIQ auth server.
|
|
49
|
+
2. Prints a short code and opens the verification URL in your browser.
|
|
50
|
+
3. Polls until you approve access (RFC 8628 device flow).
|
|
51
|
+
4-7. Same as above.
|
|
52
|
+
`);
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (args.includes('--version') || args.includes('-v')) {
|
|
57
|
+
const { readFileSync } = await import('node:fs');
|
|
58
|
+
const { fileURLToPath } = await import('node:url');
|
|
59
|
+
const { join, dirname } = await import('node:path');
|
|
60
|
+
const __dir = dirname(fileURLToPath(import.meta.url));
|
|
61
|
+
const pkg = JSON.parse(readFileSync(join(__dir, '..', 'package.json'), 'utf8'));
|
|
62
|
+
console.log(pkg.version);
|
|
63
|
+
process.exit(0);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (args.includes('--logout')) {
|
|
67
|
+
try {
|
|
68
|
+
removeClaudeSettings();
|
|
69
|
+
console.log(`SwarmIQ env block removed from ${claudeSettingsPath()}`);
|
|
70
|
+
console.log('Claude Code will now use its default settings.');
|
|
71
|
+
} catch (err) {
|
|
72
|
+
console.error(`swarmiq --logout failed: ${err.message}`);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
process.exit(0);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Parse --auth <value>
|
|
79
|
+
let authMode = null;
|
|
80
|
+
const authIdx = args.indexOf('--auth');
|
|
81
|
+
if (authIdx !== -1) {
|
|
82
|
+
const authValue = args[authIdx + 1];
|
|
83
|
+
if (authValue === 'swarmiq' || authValue === 'clerk') {
|
|
84
|
+
authMode = authValue;
|
|
85
|
+
} else if (!authValue || authValue.startsWith('--')) {
|
|
86
|
+
console.error(`swarmiq: --auth requires a value: swarmiq | clerk`);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
} else {
|
|
89
|
+
console.error(`swarmiq: unknown --auth value "${authValue}". Use: swarmiq | clerk`);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Main connect flow
|
|
95
|
+
try {
|
|
96
|
+
await runInit({ authMode });
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.error(`\nswarmiq failed: ${err.message}`);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
package/src/init.mjs
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* init.mjs — Core connect flow, fully injectable for testing.
|
|
3
|
+
*
|
|
4
|
+
* Default flow (GitHub Device Flow):
|
|
5
|
+
* 0. GET {proxyBase}/v1/auth/config → {github_client_id, github_oauth_enabled}
|
|
6
|
+
* 1a. If github_client_id present AND github_oauth_enabled=true (AND --auth != swarmiq/clerk):
|
|
7
|
+
* → GitHub Device Flow (requestGitHubDeviceCode / pollGitHubToken)
|
|
8
|
+
* → Display user_code + verification_uri; open browser
|
|
9
|
+
* → POST {proxyBase}/v1/auth/github with {github_access_token} [token is memory-only]
|
|
10
|
+
* → Receive SwarmIQ access_token + tenant_id + tier
|
|
11
|
+
* 1b. Otherwise (no client_id, github_oauth_enabled=false, or --auth swarmiq/clerk flag):
|
|
12
|
+
* → SwarmIQ Device Flow (existing requestDeviceCode / pollDeviceToken)
|
|
13
|
+
*
|
|
14
|
+
* 2. Write ~/.claude/settings.json env block (writeClaudeSettings)
|
|
15
|
+
* 3. Offer BYOK: prompt y/N → masked key input → POST /v1/vault/provider-key
|
|
16
|
+
* 4. POST-CONNECT VERIFY: GET /health + /v1/savings/summary → print status
|
|
17
|
+
*
|
|
18
|
+
* All I/O is injected: fetch, browser opener, readline, print.
|
|
19
|
+
* Tests can run the entire flow without network calls or FS side effects outside
|
|
20
|
+
* a temp dir.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { requestDeviceCode, pollDeviceToken, OAUTH_BASE_URL } from './oauth.mjs';
|
|
24
|
+
import { requestGitHubDeviceCode, pollGitHubToken } from './github_auth.mjs';
|
|
25
|
+
import { storeProviderKey, fetchHealth, fetchSavings } from './api.mjs';
|
|
26
|
+
import {
|
|
27
|
+
writeConfig,
|
|
28
|
+
configPath,
|
|
29
|
+
writeClaudeSettings,
|
|
30
|
+
claudeSettingsPath,
|
|
31
|
+
PROXY_BASE_URL_DEFAULT,
|
|
32
|
+
} from './config.mjs';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @typedef {object} ConnectDeps
|
|
36
|
+
* @property {Function} openBrowser - (url:string) => void | Promise<void>
|
|
37
|
+
* @property {Function} requestCode - same sig as requestDeviceCode (SwarmIQ flow)
|
|
38
|
+
* @property {Function} pollToken - same sig as pollDeviceToken (SwarmIQ flow)
|
|
39
|
+
* @property {Function} requestGHCode - same sig as requestGitHubDeviceCode
|
|
40
|
+
* @property {Function} pollGHToken - same sig as pollGitHubToken
|
|
41
|
+
* @property {Function} exchangeGHToken - (github_access_token, proxyBaseUrl, fetch) => {access_token, tenant_id, tier}
|
|
42
|
+
* @property {Function} fetchAuthConfig - (proxyBaseUrl, fetch) => {github_client_id, github_oauth_enabled}
|
|
43
|
+
* @property {Function} storeKey - same sig as storeProviderKey
|
|
44
|
+
* @property {Function} getHealth - same sig as fetchHealth
|
|
45
|
+
* @property {Function} getSavings - same sig as fetchSavings
|
|
46
|
+
* @property {Function} writeCfg - (fields) => void
|
|
47
|
+
* @property {Function} writeSettings - (opts) => void
|
|
48
|
+
* @property {Function} print - (msg:string) => void
|
|
49
|
+
* @property {Function} prompt - (msg:string) => Promise<string> (y/N)
|
|
50
|
+
* @property {Function} promptMasked - (msg:string) => Promise<string> (no echo)
|
|
51
|
+
* @property {Function} promptShellAlias - () => Promise<boolean> (y/N)
|
|
52
|
+
* @property {string} [proxyBaseUrl]
|
|
53
|
+
* @property {string} [oauthBaseUrl]
|
|
54
|
+
* @property {string} [authMode] - 'github' | 'swarmiq' | 'clerk' — overrides auto-detect
|
|
55
|
+
* @property {Function} [fetchFn] - injectable fetch for internal calls
|
|
56
|
+
* @property {Function} [sleep] - injectable sleep for GitHub poll
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Run the SwarmIQ connect flow end-to-end.
|
|
61
|
+
*
|
|
62
|
+
* @param {Partial<ConnectDeps>} [deps] - inject for testing
|
|
63
|
+
* @returns {Promise<{access_token:string, tenant_id:string, tier:string}>}
|
|
64
|
+
*/
|
|
65
|
+
export async function runInit(deps = {}) {
|
|
66
|
+
const {
|
|
67
|
+
openBrowser = defaultOpenBrowser,
|
|
68
|
+
requestCode = requestDeviceCode,
|
|
69
|
+
pollToken = pollDeviceToken,
|
|
70
|
+
requestGHCode = requestGitHubDeviceCode,
|
|
71
|
+
pollGHToken = pollGitHubToken,
|
|
72
|
+
exchangeGHToken = defaultExchangeGHToken,
|
|
73
|
+
fetchAuthConfig = defaultFetchAuthConfig,
|
|
74
|
+
storeKey = storeProviderKey,
|
|
75
|
+
getHealth = fetchHealth,
|
|
76
|
+
getSavings = fetchSavings,
|
|
77
|
+
writeCfg = writeConfig,
|
|
78
|
+
writeSettings = writeClaudeSettings,
|
|
79
|
+
print = defaultPrint,
|
|
80
|
+
prompt = defaultPrompt,
|
|
81
|
+
promptMasked = defaultPromptMasked,
|
|
82
|
+
promptShellAlias = defaultPromptShellAlias,
|
|
83
|
+
proxyBaseUrl = PROXY_BASE_URL_DEFAULT,
|
|
84
|
+
oauthBaseUrl = OAUTH_BASE_URL,
|
|
85
|
+
authMode = null,
|
|
86
|
+
fetchFn = globalThis.fetch,
|
|
87
|
+
sleep = defaultSleep,
|
|
88
|
+
} = deps;
|
|
89
|
+
|
|
90
|
+
print('');
|
|
91
|
+
print('SwarmIQ — connect');
|
|
92
|
+
print('─────────────────────────────────────────');
|
|
93
|
+
|
|
94
|
+
// ── Step 0: determine auth mode ──────────────────────────────────────────────
|
|
95
|
+
// Forced SwarmIQ/Clerk path → skip config fetch entirely.
|
|
96
|
+
const forcedSwarmiq = authMode === 'swarmiq' || authMode === 'clerk';
|
|
97
|
+
|
|
98
|
+
let useGitHub = false;
|
|
99
|
+
let githubClientId = null;
|
|
100
|
+
|
|
101
|
+
if (!forcedSwarmiq) {
|
|
102
|
+
// Probe /v1/auth/config — errors are non-fatal; fall back to SwarmIQ flow.
|
|
103
|
+
try {
|
|
104
|
+
const cfg = await fetchAuthConfig(proxyBaseUrl, fetchFn);
|
|
105
|
+
if (cfg && cfg.github_oauth_enabled && cfg.github_client_id) {
|
|
106
|
+
useGitHub = true;
|
|
107
|
+
githubClientId = cfg.github_client_id;
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
// Config endpoint unreachable or returned unexpected shape — fall back.
|
|
111
|
+
useGitHub = false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Auth path split ───────────────────────────────────────────────────────────
|
|
116
|
+
let access_token, tenant_id, tier;
|
|
117
|
+
|
|
118
|
+
if (useGitHub) {
|
|
119
|
+
// ── GitHub Device Flow ──────────────────────────────────────────────────────
|
|
120
|
+
print('');
|
|
121
|
+
print(' Authenticating via GitHub (default)...');
|
|
122
|
+
print('');
|
|
123
|
+
|
|
124
|
+
const ghCode = await requestGHCode(githubClientId, fetchFn);
|
|
125
|
+
|
|
126
|
+
// Show user_code prominently
|
|
127
|
+
print(' Open the following URL in your browser to authenticate with GitHub:');
|
|
128
|
+
print('');
|
|
129
|
+
print(` ${ghCode.verification_uri}`);
|
|
130
|
+
print('');
|
|
131
|
+
print(' Then enter this code when prompted:');
|
|
132
|
+
print('');
|
|
133
|
+
print(` ╔══════════════════╗`);
|
|
134
|
+
print(` ║ ${ghCode.user_code.padEnd(16)} ║`);
|
|
135
|
+
print(` ╚══════════════════╝`);
|
|
136
|
+
print('');
|
|
137
|
+
print(` (Expires in ${Math.round(ghCode.expires_in / 60)} minutes)`);
|
|
138
|
+
print('');
|
|
139
|
+
|
|
140
|
+
// Best-effort browser open
|
|
141
|
+
await openBrowser(ghCode.verification_uri).catch(() => {});
|
|
142
|
+
|
|
143
|
+
print('Waiting for GitHub authorisation...');
|
|
144
|
+
|
|
145
|
+
// Poll GitHub — token is transient, never persisted
|
|
146
|
+
const githubAccessToken = await pollGHToken(
|
|
147
|
+
githubClientId,
|
|
148
|
+
ghCode.device_code,
|
|
149
|
+
ghCode.interval,
|
|
150
|
+
{ fetch: fetchFn, sleep, expiresIn: ghCode.expires_in }
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
// Exchange GitHub token for SwarmIQ token — GitHub token discarded after this call
|
|
154
|
+
const swarmiqResult = await exchangeGHToken(githubAccessToken, proxyBaseUrl, fetchFn);
|
|
155
|
+
|
|
156
|
+
access_token = swarmiqResult.access_token;
|
|
157
|
+
tenant_id = swarmiqResult.tenant_id;
|
|
158
|
+
tier = swarmiqResult.tier ?? 'free';
|
|
159
|
+
|
|
160
|
+
} else {
|
|
161
|
+
// ── SwarmIQ / Clerk Device Flow (existing path) ──────────────────────────────
|
|
162
|
+
print('');
|
|
163
|
+
print(' Authenticating via SwarmIQ device flow...');
|
|
164
|
+
print('');
|
|
165
|
+
|
|
166
|
+
const {
|
|
167
|
+
device_code,
|
|
168
|
+
user_code,
|
|
169
|
+
verification_uri,
|
|
170
|
+
verification_uri_complete,
|
|
171
|
+
expires_in,
|
|
172
|
+
interval,
|
|
173
|
+
} = await requestCode({ oauthBaseUrl });
|
|
174
|
+
|
|
175
|
+
print(' Open the following URL in your browser to authenticate:');
|
|
176
|
+
print('');
|
|
177
|
+
print(` ${verification_uri_complete}`);
|
|
178
|
+
print('');
|
|
179
|
+
print(' Then enter this code when prompted:');
|
|
180
|
+
print('');
|
|
181
|
+
print(` ╔══════════════════╗`);
|
|
182
|
+
print(` ║ ${user_code.padEnd(16)} ║`);
|
|
183
|
+
print(` ╚══════════════════╝`);
|
|
184
|
+
print('');
|
|
185
|
+
print(` (Expires in ${Math.round(expires_in / 60)} minutes)`);
|
|
186
|
+
print('');
|
|
187
|
+
|
|
188
|
+
// Best-effort browser open
|
|
189
|
+
await openBrowser(verification_uri_complete).catch(() => {});
|
|
190
|
+
|
|
191
|
+
print('Waiting for authorisation in browser...');
|
|
192
|
+
|
|
193
|
+
const tokenResult = await pollToken({
|
|
194
|
+
device_code,
|
|
195
|
+
interval,
|
|
196
|
+
expires_in,
|
|
197
|
+
oauthBaseUrl,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
access_token = tokenResult.access_token;
|
|
201
|
+
tenant_id = tokenResult.tenant_id;
|
|
202
|
+
tier = tokenResult.tier ?? 'free';
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
print('');
|
|
206
|
+
print(`Authorised. Tenant: ${tenant_id} | Tier: ${tier}`);
|
|
207
|
+
print('');
|
|
208
|
+
|
|
209
|
+
// ── Write ~/.claude/settings.json ────────────────────────────────────────────
|
|
210
|
+
writeSettings({ proxyBaseUrl, accessToken: access_token });
|
|
211
|
+
// Also persist non-sensitive session info to ~/.swarmiq/config.json
|
|
212
|
+
writeCfg({ tenant_id, tier, proxy_base_url: proxyBaseUrl });
|
|
213
|
+
|
|
214
|
+
print(`Claude Code settings updated: ${claudeSettingsPath()}`);
|
|
215
|
+
print(' ANTHROPIC_BASE_URL set to proxy');
|
|
216
|
+
print(' ANTHROPIC_AUTH_TOKEN set to your access token');
|
|
217
|
+
print(' ANTHROPIC_API_KEY cleared (SwarmIQ handles auth)');
|
|
218
|
+
print('');
|
|
219
|
+
|
|
220
|
+
// ── Offer shell rc alias (optional) ─────────────────────────────────────────
|
|
221
|
+
const wantAlias = await promptShellAlias();
|
|
222
|
+
if (wantAlias) {
|
|
223
|
+
await appendShellAlias({ proxyBaseUrl, print });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── BYOK prompt ──────────────────────────────────────────────────────────────
|
|
227
|
+
const byokAnswer = await prompt(
|
|
228
|
+
'Add a provider API key so SwarmIQ can route on your behalf? [y/N] '
|
|
229
|
+
);
|
|
230
|
+
if (byokAnswer.trim().toLowerCase() === 'y') {
|
|
231
|
+
const providerRaw = await prompt('Provider (default: anthropic): ');
|
|
232
|
+
const provider = providerRaw.trim() || 'anthropic';
|
|
233
|
+
const key = await promptMasked('API key (input hidden): ');
|
|
234
|
+
if (!key.trim()) {
|
|
235
|
+
print('No key entered — skipping.');
|
|
236
|
+
} else {
|
|
237
|
+
await storeKey({ provider, key: key.trim(), accessToken: access_token, proxyBaseUrl });
|
|
238
|
+
// Key is now in the Vault. Discard from local scope.
|
|
239
|
+
print(` Provider key for "${provider}" stored in SwarmIQ Vault.`);
|
|
240
|
+
print(' The key was transmitted over HTTPS and is NOT saved locally.');
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
print('');
|
|
245
|
+
|
|
246
|
+
// ── Post-connect verify ───────────────────────────────────────────────────────
|
|
247
|
+
print('Verifying connection...');
|
|
248
|
+
const health = await getHealth({ proxyBaseUrl });
|
|
249
|
+
const savings = await getSavings({ proxyBaseUrl });
|
|
250
|
+
|
|
251
|
+
if (health) {
|
|
252
|
+
const savingsLine = buildSavingsLine(savings);
|
|
253
|
+
print(` Connected — routing active${savingsLine}`);
|
|
254
|
+
} else {
|
|
255
|
+
print(' Warning: proxy health check did not respond. Check that the proxy');
|
|
256
|
+
print(` is reachable at ${proxyBaseUrl}`);
|
|
257
|
+
}
|
|
258
|
+
print('');
|
|
259
|
+
|
|
260
|
+
return { access_token, tenant_id, tier };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Build a human-readable savings suffix line from the savings summary.
|
|
267
|
+
* Prefers true_savings_vs_max_plan_7d_usd; falls back to total_savings_usd.
|
|
268
|
+
* @param {Record<string,unknown>|null} savings
|
|
269
|
+
* @returns {string}
|
|
270
|
+
*/
|
|
271
|
+
function buildSavingsLine(savings) {
|
|
272
|
+
if (!savings) return '';
|
|
273
|
+
const val =
|
|
274
|
+
savings.true_savings_vs_max_plan_7d_usd ??
|
|
275
|
+
savings.total_savings_usd ??
|
|
276
|
+
null;
|
|
277
|
+
if (val == null) return '';
|
|
278
|
+
const formatted = typeof val === 'number' ? `$${val.toFixed(2)}` : String(val);
|
|
279
|
+
return ` | 7d savings: ${formatted}`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* GET /v1/auth/config — probe whether GitHub OAuth is enabled and obtain client_id.
|
|
284
|
+
* Returns null (not throws) on any network/parse failure so callers can fall back.
|
|
285
|
+
*
|
|
286
|
+
* @param {string} proxyBaseUrl
|
|
287
|
+
* @param {Function} fetchFn
|
|
288
|
+
* @returns {Promise<{github_client_id:string|null, github_oauth_enabled:boolean}|null>}
|
|
289
|
+
*/
|
|
290
|
+
async function defaultFetchAuthConfig(proxyBaseUrl, fetchFn) {
|
|
291
|
+
try {
|
|
292
|
+
const res = await fetchFn(`${proxyBaseUrl}/v1/auth/config`);
|
|
293
|
+
if (!res.ok) return null;
|
|
294
|
+
return await res.json();
|
|
295
|
+
} catch {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* POST /v1/auth/github — exchange a transient GitHub access token for a SwarmIQ token.
|
|
302
|
+
* The github_access_token is sent once over HTTPS and then discarded by the caller.
|
|
303
|
+
*
|
|
304
|
+
* @param {string} githubAccessToken - TRANSIENT — never write to disk
|
|
305
|
+
* @param {string} proxyBaseUrl
|
|
306
|
+
* @param {Function} fetchFn
|
|
307
|
+
* @returns {Promise<{access_token:string, token_type:string, tenant_id:string, tier:string}>}
|
|
308
|
+
*/
|
|
309
|
+
async function defaultExchangeGHToken(githubAccessToken, proxyBaseUrl, fetchFn) {
|
|
310
|
+
const url = `${proxyBaseUrl}/v1/auth/github`;
|
|
311
|
+
const res = await fetchFn(url, {
|
|
312
|
+
method: 'POST',
|
|
313
|
+
headers: { 'Content-Type': 'application/json' },
|
|
314
|
+
body: JSON.stringify({ github_access_token: githubAccessToken }),
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
if (!res.ok) {
|
|
318
|
+
let detail = '';
|
|
319
|
+
try {
|
|
320
|
+
const body = await res.json();
|
|
321
|
+
detail = body?.detail ?? body?.error ?? '';
|
|
322
|
+
} catch {
|
|
323
|
+
detail = await res.text().catch(() => '');
|
|
324
|
+
}
|
|
325
|
+
const msg = detail ? ` — ${detail}` : '';
|
|
326
|
+
throw new Error(
|
|
327
|
+
`GitHub token exchange failed: HTTP ${res.status}${msg}`
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const data = await res.json();
|
|
332
|
+
|
|
333
|
+
if (!data.access_token || !data.tenant_id) {
|
|
334
|
+
throw new Error(
|
|
335
|
+
'Unexpected response from /v1/auth/github: missing access_token or tenant_id'
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
access_token: data.access_token,
|
|
341
|
+
token_type: data.token_type ?? 'bearer',
|
|
342
|
+
tenant_id: data.tenant_id,
|
|
343
|
+
tier: data.tier ?? 'free',
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Append a `claude-swarmiq` alias to the user's shell rc file.
|
|
349
|
+
* Best-effort only — errors are printed, not thrown.
|
|
350
|
+
* @param {object} opts
|
|
351
|
+
* @param {string} opts.proxyBaseUrl
|
|
352
|
+
* @param {Function} opts.print
|
|
353
|
+
*/
|
|
354
|
+
async function appendShellAlias({ proxyBaseUrl, print }) {
|
|
355
|
+
const { homedir } = await import('node:os');
|
|
356
|
+
const { appendFileSync, existsSync } = await import('node:fs');
|
|
357
|
+
const { join } = await import('node:path');
|
|
358
|
+
|
|
359
|
+
const home = homedir();
|
|
360
|
+
const rcCandidates = [
|
|
361
|
+
join(home, '.zshrc'),
|
|
362
|
+
join(home, '.bashrc'),
|
|
363
|
+
join(home, '.bash_profile'),
|
|
364
|
+
];
|
|
365
|
+
|
|
366
|
+
const rcFile = rcCandidates.find((f) => existsSync(f));
|
|
367
|
+
if (!rcFile) {
|
|
368
|
+
print(' Could not locate a shell rc file — alias not added.');
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const alias = `\n# SwarmIQ — added by npx swarmiq\nalias claude-swarmiq='ANTHROPIC_BASE_URL="${proxyBaseUrl}" claude'\n`;
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
appendFileSync(rcFile, alias, 'utf8');
|
|
376
|
+
print(` Alias claude-swarmiq added to ${rcFile}`);
|
|
377
|
+
print(' Reload your shell or run: source ' + rcFile);
|
|
378
|
+
} catch (err) {
|
|
379
|
+
print(` Could not write to ${rcFile}: ${err.message}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ─── production I/O implementations ──────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
async function defaultOpenBrowser(url) {
|
|
386
|
+
const { exec } = await import('node:child_process');
|
|
387
|
+
const cmd =
|
|
388
|
+
process.platform === 'win32'
|
|
389
|
+
? `start "" "${url}"`
|
|
390
|
+
: process.platform === 'darwin'
|
|
391
|
+
? `open "${url}"`
|
|
392
|
+
: `xdg-open "${url}"`;
|
|
393
|
+
exec(cmd, () => {});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function defaultPrint(msg) {
|
|
397
|
+
process.stdout.write(msg + '\n');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Read a single line of stdin (plain, echoed).
|
|
402
|
+
* @param {string} question
|
|
403
|
+
* @returns {Promise<string>}
|
|
404
|
+
*/
|
|
405
|
+
function defaultPrompt(question) {
|
|
406
|
+
return new Promise((resolve) => {
|
|
407
|
+
process.stdout.write(question);
|
|
408
|
+
let buf = '';
|
|
409
|
+
const onData = (chunk) => {
|
|
410
|
+
buf += chunk;
|
|
411
|
+
const nl = buf.indexOf('\n');
|
|
412
|
+
if (nl !== -1) {
|
|
413
|
+
process.stdin.off('data', onData);
|
|
414
|
+
process.stdin.pause();
|
|
415
|
+
resolve(buf.slice(0, nl).replace(/\r$/, ''));
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
process.stdin.setEncoding('utf8');
|
|
419
|
+
process.stdin.resume();
|
|
420
|
+
process.stdin.on('data', onData);
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Read a line from stdin with echo disabled (for API keys).
|
|
426
|
+
* Falls back to plain readline if raw mode is unavailable.
|
|
427
|
+
* @param {string} question
|
|
428
|
+
* @returns {Promise<string>}
|
|
429
|
+
*/
|
|
430
|
+
function defaultPromptMasked(question) {
|
|
431
|
+
return new Promise((resolve) => {
|
|
432
|
+
process.stdout.write(question);
|
|
433
|
+
let buf = '';
|
|
434
|
+
|
|
435
|
+
const stdin = process.stdin;
|
|
436
|
+
const wasRaw = stdin.isRaw;
|
|
437
|
+
let rawModeSet = false;
|
|
438
|
+
|
|
439
|
+
try {
|
|
440
|
+
if (typeof stdin.setRawMode === 'function') {
|
|
441
|
+
stdin.setRawMode(true);
|
|
442
|
+
rawModeSet = true;
|
|
443
|
+
}
|
|
444
|
+
} catch {
|
|
445
|
+
// raw mode unavailable (piped stdin in tests) — fall through to plain
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
stdin.setEncoding('utf8');
|
|
449
|
+
stdin.resume();
|
|
450
|
+
|
|
451
|
+
const onData = (chunk) => {
|
|
452
|
+
for (const ch of chunk) {
|
|
453
|
+
if (ch === '\r' || ch === '\n') {
|
|
454
|
+
// Enter pressed
|
|
455
|
+
stdin.off('data', onData);
|
|
456
|
+
if (rawModeSet) {
|
|
457
|
+
try { stdin.setRawMode(wasRaw); } catch { /* ignore */ }
|
|
458
|
+
}
|
|
459
|
+
stdin.pause();
|
|
460
|
+
process.stdout.write('\n');
|
|
461
|
+
resolve(buf);
|
|
462
|
+
return;
|
|
463
|
+
} else if (ch === '') {
|
|
464
|
+
// Ctrl-C
|
|
465
|
+
stdin.off('data', onData);
|
|
466
|
+
if (rawModeSet) {
|
|
467
|
+
try { stdin.setRawMode(wasRaw); } catch { /* ignore */ }
|
|
468
|
+
}
|
|
469
|
+
process.stdout.write('\n');
|
|
470
|
+
process.exit(130);
|
|
471
|
+
} else if (ch === '' || ch === '\b') {
|
|
472
|
+
// Backspace
|
|
473
|
+
if (buf.length > 0) buf = buf.slice(0, -1);
|
|
474
|
+
} else {
|
|
475
|
+
buf += ch;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
stdin.on('data', onData);
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Ask the user whether to add the claude-swarmiq alias.
|
|
486
|
+
* @returns {Promise<boolean>}
|
|
487
|
+
*/
|
|
488
|
+
async function defaultPromptShellAlias() {
|
|
489
|
+
if (process.platform === 'win32') {
|
|
490
|
+
// Shell aliases not applicable on Windows
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
const ans = await defaultPrompt(
|
|
494
|
+
'Append a `claude-swarmiq` alias to your shell rc? [y/N] '
|
|
495
|
+
);
|
|
496
|
+
return ans.trim().toLowerCase() === 'y';
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function defaultSleep(ms) {
|
|
500
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
501
|
+
}
|