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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tal Wayn / SwarmIQ
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# SwarmIQ Init CLI
|
|
2
|
+
|
|
3
|
+
<div dir="rtl">
|
|
4
|
+
|
|
5
|
+
## סקירה
|
|
6
|
+
|
|
7
|
+
`swarmiq-init` הוא כלי שורת פקודה לחיבור ראשוני של המשתמש לפרוקסי SwarmIQ.
|
|
8
|
+
הוא מוביל את המשתמש דרך GitHub OAuth (דרך Clerk), מקבל `api_key`, שומר אותו
|
|
9
|
+
בקובץ קונפיגורציה מקומי, ומדפיס את משתני הסביבה שיש להגדיר ב-Claude Code / VS Code
|
|
10
|
+
כדי שכל הבקשות יעברו דרך הפרוקסי.
|
|
11
|
+
|
|
12
|
+
**פרסום ל-npm** הוא פעולה ידנית של Tal (מוגדרת למועד מאוחר יותר, כמו רכישת Contabo).
|
|
13
|
+
עד אז יש להשתמש בהפעלה מקומית (ראה להלן).
|
|
14
|
+
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Onboarding flow
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
User → npx swarmiq-init → GitHub OAuth (Clerk) → JWT
|
|
23
|
+
│
|
|
24
|
+
▼
|
|
25
|
+
POST /v1/auth/callback
|
|
26
|
+
(proxy creates tenant + api_key)
|
|
27
|
+
│
|
|
28
|
+
▼
|
|
29
|
+
~/.swarmiq/config.json ← key written
|
|
30
|
+
│
|
|
31
|
+
▼
|
|
32
|
+
env-var instructions printed
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Usage (after npm publish — deferred)
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
npx swarmiq-init
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Local dev invocation (no npm publish required)
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
# From the repo root:
|
|
49
|
+
node tools/cli/src/index.mjs
|
|
50
|
+
|
|
51
|
+
# Or from within tools/cli/:
|
|
52
|
+
node src/index.mjs
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Options
|
|
58
|
+
|
|
59
|
+
| Flag | Effect |
|
|
60
|
+
|------|--------|
|
|
61
|
+
| `--help` / `-h` | Show help and exit |
|
|
62
|
+
| `--version` / `-v` | Print version and exit |
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Environment variables
|
|
67
|
+
|
|
68
|
+
| Variable | Default | Description |
|
|
69
|
+
|----------|---------|-------------|
|
|
70
|
+
| `SWARMIQ_CONFIG_HOME` | `~/.swarmiq` | Directory where `config.json` is written |
|
|
71
|
+
| `SWARMIQ_PROXY_URL` | `http://127.0.0.1:8000` | SwarmIQ proxy base URL |
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Output — config.json
|
|
76
|
+
|
|
77
|
+
After a successful run, `$SWARMIQ_CONFIG_HOME/config.json` contains:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"api_key": "<your-swarmiq-api-key>",
|
|
82
|
+
"tenant_id": "tnt_abc123",
|
|
83
|
+
"tier": "free",
|
|
84
|
+
"proxy_base_url": "http://127.0.0.1:8000",
|
|
85
|
+
"updated_at": "2026-05-31T12:00:00.000Z"
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Output — env vars printed to terminal
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
export ANTHROPIC_BASE_URL="http://127.0.0.1:8000"
|
|
95
|
+
export ANTHROPIC_API_KEY="<your-swarmiq-api-key>"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Add these to your `~/.zshrc` / `~/.bashrc` (or Windows equivalent), or configure
|
|
99
|
+
Claude Code with:
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
claude config set apiBaseUrl "http://127.0.0.1:8000"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## Running tests
|
|
108
|
+
|
|
109
|
+
No dependencies required. Uses Node's built-in test runner.
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
cd tools/cli
|
|
113
|
+
node --test test/
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Expected output: all 11 tests pass, `fail 0`.
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Architecture
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
tools/cli/
|
|
124
|
+
├── src/
|
|
125
|
+
│ ├── index.mjs ← CLI entry point (bin: swarmiq-init)
|
|
126
|
+
│ ├── init.mjs ← Core flow, fully injectable (no network in tests)
|
|
127
|
+
│ ├── oauth.mjs ← URL builder + local callback HTTP server
|
|
128
|
+
│ ├── api.mjs ← POST /v1/auth/callback exchange
|
|
129
|
+
│ └── config.mjs ← ~/.swarmiq/config.json read/write
|
|
130
|
+
└── test/
|
|
131
|
+
└── init.test.mjs ← 11 tests, node --test
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**Runtime dependencies: zero.** Only Node built-ins (`node:fs`, `node:http`,
|
|
135
|
+
`node:crypto`, `node:os`, `node:path`, `node:url`, `node:child_process`).
|
|
136
|
+
|
|
137
|
+
**Design choice — plain ESM `.mjs` (not TypeScript):** The project has no
|
|
138
|
+
existing Node build pipeline. TypeScript would require `npm install` + `npx tsc`
|
|
139
|
+
on every change, creating offline-fragile CI. Plain ESM runs with zero build
|
|
140
|
+
steps: `node src/index.mjs` and `node --test test/` both work immediately on
|
|
141
|
+
Node 18+.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Auth contract
|
|
146
|
+
|
|
147
|
+
The CLI implements the onboarding leg of
|
|
148
|
+
`infra/contracts/auth.contract.md` (C3, Wave-0B):
|
|
149
|
+
|
|
150
|
+
- OAuth entry point: `https://clerk.swarmiq.dev/oauth/github`
|
|
151
|
+
- Exchange endpoint: `POST /v1/auth/callback`
|
|
152
|
+
- Response: `{ tenant_id, api_key, tier }`
|
|
153
|
+
- Config written: `{ api_key, tenant_id, tier, proxy_base_url }`
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
<div dir="rtl">
|
|
158
|
+
|
|
159
|
+
## פרסום ל-npm (פעולה ידנית)
|
|
160
|
+
|
|
161
|
+
כאשר מגיע הזמן לפרסם:
|
|
162
|
+
|
|
163
|
+
```sh
|
|
164
|
+
cd tools/cli
|
|
165
|
+
npm publish --access public
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
ברגע שהחבילה פורסמה, המשתמשים יוכלו להריץ:
|
|
169
|
+
|
|
170
|
+
```sh
|
|
171
|
+
npx swarmiq-init
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
עד אז — השתמשו בהפעלה מקומית.
|
|
175
|
+
|
|
176
|
+
</div>
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "swarmiq",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Route Claude Code through SwarmIQ's free-LLM cascade and save your Anthropic Max quota.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.mjs",
|
|
7
|
+
"bin": {
|
|
8
|
+
"swarmiq": "src/index.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src/",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test test/init.test.mjs test/github_auth.test.mjs"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18.0.0"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"claude",
|
|
23
|
+
"claude-code",
|
|
24
|
+
"anthropic",
|
|
25
|
+
"llm",
|
|
26
|
+
"proxy",
|
|
27
|
+
"router",
|
|
28
|
+
"cascade",
|
|
29
|
+
"swarmiq"
|
|
30
|
+
],
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"homepage": "https://swarmiq.dev",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://github.com/TalWayn72/swarmiq-os.git",
|
|
36
|
+
"directory": "tools/cli"
|
|
37
|
+
},
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/TalWayn72/swarmiq-os/issues"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/api.mjs
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* api.mjs — HTTP helpers for talking to the SwarmIQ proxy.
|
|
3
|
+
*
|
|
4
|
+
* All functions accept an injectable `fetch` parameter so tests can replace
|
|
5
|
+
* network calls with fakes without ever hitting the wire.
|
|
6
|
+
*
|
|
7
|
+
* IMPORTANT: Provider API keys are accepted as parameters but NEVER written
|
|
8
|
+
* to disk and NEVER logged. They are sent once over HTTPS and then discarded.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { PROXY_BASE_URL_DEFAULT } from './config.mjs';
|
|
12
|
+
|
|
13
|
+
// ─── BYOK: store a provider key in the Vault ─────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* POST /v1/vault/provider-key
|
|
17
|
+
* Stores a provider API key in the SwarmIQ Vault under the caller's tenant.
|
|
18
|
+
* The key parameter is used exactly once — never cached, never written to disk.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} opts
|
|
21
|
+
* @param {string} opts.provider - e.g. "anthropic", "openai"
|
|
22
|
+
* @param {string} opts.key - the raw API key (not persisted locally)
|
|
23
|
+
* @param {string} opts.accessToken - Bearer token from device flow
|
|
24
|
+
* @param {string} [opts.proxyBaseUrl]
|
|
25
|
+
* @param {Function}[opts.fetch]
|
|
26
|
+
* @returns {Promise<{status:string, provider:string}>}
|
|
27
|
+
*/
|
|
28
|
+
export async function storeProviderKey({
|
|
29
|
+
provider,
|
|
30
|
+
key,
|
|
31
|
+
accessToken,
|
|
32
|
+
proxyBaseUrl = PROXY_BASE_URL_DEFAULT,
|
|
33
|
+
fetch: fetchFn = globalThis.fetch,
|
|
34
|
+
}) {
|
|
35
|
+
const url = `${proxyBaseUrl}/v1/vault/provider-key`;
|
|
36
|
+
const res = await fetchFn(url, {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: {
|
|
39
|
+
'Content-Type': 'application/json',
|
|
40
|
+
Authorization: `Bearer ${accessToken}`,
|
|
41
|
+
},
|
|
42
|
+
body: JSON.stringify({ provider, key }),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
if (!res.ok) {
|
|
46
|
+
const body = await res.text().catch(() => '');
|
|
47
|
+
throw new Error(
|
|
48
|
+
`POST /v1/vault/provider-key failed: HTTP ${res.status} — ${body}`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return res.json();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── Health + Savings summary ─────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* GET /health
|
|
59
|
+
* Returns raw JSON from the health endpoint or null on failure.
|
|
60
|
+
*
|
|
61
|
+
* @param {object} opts
|
|
62
|
+
* @param {string} [opts.proxyBaseUrl]
|
|
63
|
+
* @param {Function}[opts.fetch]
|
|
64
|
+
* @returns {Promise<Record<string,unknown>|null>}
|
|
65
|
+
*/
|
|
66
|
+
export async function fetchHealth({
|
|
67
|
+
proxyBaseUrl = PROXY_BASE_URL_DEFAULT,
|
|
68
|
+
fetch: fetchFn = globalThis.fetch,
|
|
69
|
+
} = {}) {
|
|
70
|
+
try {
|
|
71
|
+
const res = await fetchFn(`${proxyBaseUrl}/health`);
|
|
72
|
+
if (!res.ok) return null;
|
|
73
|
+
return res.json();
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* GET /v1/savings/summary
|
|
81
|
+
* Returns the savings summary or null on failure.
|
|
82
|
+
*
|
|
83
|
+
* @param {object} opts
|
|
84
|
+
* @param {string} [opts.proxyBaseUrl]
|
|
85
|
+
* @param {Function}[opts.fetch]
|
|
86
|
+
* @returns {Promise<Record<string,unknown>|null>}
|
|
87
|
+
*/
|
|
88
|
+
export async function fetchSavings({
|
|
89
|
+
proxyBaseUrl = PROXY_BASE_URL_DEFAULT,
|
|
90
|
+
fetch: fetchFn = globalThis.fetch,
|
|
91
|
+
} = {}) {
|
|
92
|
+
try {
|
|
93
|
+
const res = await fetchFn(`${proxyBaseUrl}/v1/savings/summary`);
|
|
94
|
+
if (!res.ok) return null;
|
|
95
|
+
return res.json();
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config.mjs — Config file read/write for SwarmIQ CLI.
|
|
3
|
+
*
|
|
4
|
+
* Config location: $SWARMIQ_CONFIG_HOME/config.json (default: ~/.swarmiq/config.json)
|
|
5
|
+
* Overridable via env SWARMIQ_CONFIG_HOME for tests.
|
|
6
|
+
*
|
|
7
|
+
* Also handles read-modify-write of ~/.claude/settings.json to inject the
|
|
8
|
+
* SwarmIQ env block (ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY).
|
|
9
|
+
* The "SwarmIQ block" is identified by the presence of ANTHROPIC_BASE_URL under
|
|
10
|
+
* the settings.env key so --logout can cleanly remove exactly those keys.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
14
|
+
import { join, dirname } from 'node:path';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
|
|
17
|
+
// ─── proxy / OAuth base URLs ─────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/** Default proxy base — overridable via SWARMIQ_PROXY_URL env var. */
|
|
20
|
+
export const PROXY_BASE_URL_DEFAULT =
|
|
21
|
+
process.env.SWARMIQ_PROXY_URL ?? 'https://api.swarmiq.dev';
|
|
22
|
+
|
|
23
|
+
// ─── SwarmIQ config.json helpers ─────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the directory that holds config.json.
|
|
27
|
+
* @returns {string} absolute path to config directory
|
|
28
|
+
*/
|
|
29
|
+
export function configDir() {
|
|
30
|
+
if (process.env.SWARMIQ_CONFIG_HOME) {
|
|
31
|
+
return process.env.SWARMIQ_CONFIG_HOME;
|
|
32
|
+
}
|
|
33
|
+
return join(homedir(), '.swarmiq');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the config file path.
|
|
38
|
+
* @returns {string} absolute path to config.json
|
|
39
|
+
*/
|
|
40
|
+
export function configPath() {
|
|
41
|
+
return join(configDir(), 'config.json');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Read config.json; returns {} if it doesn't exist.
|
|
46
|
+
* @returns {Record<string, unknown>}
|
|
47
|
+
*/
|
|
48
|
+
export function readConfig() {
|
|
49
|
+
const p = configPath();
|
|
50
|
+
if (!existsSync(p)) return {};
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(readFileSync(p, 'utf8'));
|
|
53
|
+
} catch {
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Write (merge) fields into config.json, creating the directory if needed.
|
|
60
|
+
* Idempotent: merges over existing keys.
|
|
61
|
+
* Never writes provider keys — callers must strip sensitive fields before
|
|
62
|
+
* passing to this function.
|
|
63
|
+
* @param {Record<string, unknown>} fields
|
|
64
|
+
*/
|
|
65
|
+
export function writeConfig(fields) {
|
|
66
|
+
const dir = configDir();
|
|
67
|
+
if (!existsSync(dir)) {
|
|
68
|
+
mkdirSync(dir, { recursive: true });
|
|
69
|
+
}
|
|
70
|
+
const existing = readConfig();
|
|
71
|
+
const merged = { ...existing, ...fields, updated_at: new Date().toISOString() };
|
|
72
|
+
writeFileSync(configPath(), JSON.stringify(merged, null, 2) + '\n', 'utf8');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── ~/.claude/settings.json helpers ─────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve the Claude Code settings.json path.
|
|
79
|
+
* Overridable via SWARMIQ_CLAUDE_SETTINGS_PATH for tests.
|
|
80
|
+
* @returns {string}
|
|
81
|
+
*/
|
|
82
|
+
export function claudeSettingsPath() {
|
|
83
|
+
if (process.env.SWARMIQ_CLAUDE_SETTINGS_PATH) {
|
|
84
|
+
return process.env.SWARMIQ_CLAUDE_SETTINGS_PATH;
|
|
85
|
+
}
|
|
86
|
+
return join(homedir(), '.claude', 'settings.json');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Read ~/.claude/settings.json; returns {} if absent or unparseable.
|
|
91
|
+
* @returns {Record<string, unknown>}
|
|
92
|
+
*/
|
|
93
|
+
export function readClaudeSettings() {
|
|
94
|
+
const p = claudeSettingsPath();
|
|
95
|
+
if (!existsSync(p)) return {};
|
|
96
|
+
try {
|
|
97
|
+
return JSON.parse(readFileSync(p, 'utf8'));
|
|
98
|
+
} catch {
|
|
99
|
+
return {};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Write the SwarmIQ env block into ~/.claude/settings.json.
|
|
105
|
+
* Merges carefully: existing keys outside of `env` are preserved;
|
|
106
|
+
* within `env`, only the three SwarmIQ keys are added/updated.
|
|
107
|
+
*
|
|
108
|
+
* @param {object} opts
|
|
109
|
+
* @param {string} opts.proxyBaseUrl - written as ANTHROPIC_BASE_URL
|
|
110
|
+
* @param {string} opts.accessToken - written as ANTHROPIC_AUTH_TOKEN
|
|
111
|
+
*/
|
|
112
|
+
export function writeClaudeSettings({ proxyBaseUrl, accessToken }) {
|
|
113
|
+
const p = claudeSettingsPath();
|
|
114
|
+
const dir = dirname(p);
|
|
115
|
+
|
|
116
|
+
if (!existsSync(dir)) {
|
|
117
|
+
mkdirSync(dir, { recursive: true });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const existing = readClaudeSettings();
|
|
121
|
+
const existingEnv = (existing.env && typeof existing.env === 'object')
|
|
122
|
+
? existing.env
|
|
123
|
+
: {};
|
|
124
|
+
|
|
125
|
+
const merged = {
|
|
126
|
+
...existing,
|
|
127
|
+
env: {
|
|
128
|
+
...existingEnv,
|
|
129
|
+
ANTHROPIC_BASE_URL: proxyBaseUrl,
|
|
130
|
+
ANTHROPIC_AUTH_TOKEN: accessToken,
|
|
131
|
+
ANTHROPIC_API_KEY: '',
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
writeFileSync(p, JSON.stringify(merged, null, 2) + '\n', 'utf8');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Remove the SwarmIQ env block from ~/.claude/settings.json.
|
|
140
|
+
* Removes ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY.
|
|
141
|
+
* If `env` becomes empty after removal it is deleted from the settings object.
|
|
142
|
+
* If the file doesn't exist, this is a no-op.
|
|
143
|
+
*/
|
|
144
|
+
export function removeClaudeSettings() {
|
|
145
|
+
const p = claudeSettingsPath();
|
|
146
|
+
if (!existsSync(p)) return;
|
|
147
|
+
|
|
148
|
+
const existing = readClaudeSettings();
|
|
149
|
+
if (!existing.env || typeof existing.env !== 'object') return;
|
|
150
|
+
|
|
151
|
+
const SWARMIQ_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY'];
|
|
152
|
+
const newEnv = { ...existing.env };
|
|
153
|
+
for (const k of SWARMIQ_KEYS) {
|
|
154
|
+
delete newEnv[k];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const updated = { ...existing };
|
|
158
|
+
if (Object.keys(newEnv).length === 0) {
|
|
159
|
+
delete updated.env;
|
|
160
|
+
} else {
|
|
161
|
+
updated.env = newEnv;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
writeFileSync(p, JSON.stringify(updated, null, 2) + '\n', 'utf8');
|
|
165
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* github_auth.mjs — GitHub OAuth Device Flow for SwarmIQ CLI.
|
|
3
|
+
*
|
|
4
|
+
* Implements the GitHub Device Flow (https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow):
|
|
5
|
+
* 1. POST https://github.com/login/device/code
|
|
6
|
+
* body: client_id + scope=read:user user:email
|
|
7
|
+
* → {device_code, user_code, verification_uri, expires_in, interval}
|
|
8
|
+
* 2. Display user_code + verification_uri to user; open browser (caller handles this).
|
|
9
|
+
* 3. Poll POST https://github.com/login/oauth/access_token
|
|
10
|
+
* body: client_id + device_code + grant_type=urn:ietf:params:oauth:grant-type:device_code
|
|
11
|
+
* → {access_token, ...} or {error: "authorization_pending"|"slow_down"|"expired_token"|"access_denied"}
|
|
12
|
+
*
|
|
13
|
+
* All network calls and the sleep function are injectable for testing.
|
|
14
|
+
* The GitHub access token is NEVER written to disk — callers must keep it in
|
|
15
|
+
* memory only and discard it after exchanging for a SwarmIQ token.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const GITHUB_DEVICE_CODE_URL = 'https://github.com/login/device/code';
|
|
19
|
+
const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Start the GitHub device flow: POST to GitHub's device/code endpoint.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} clientId - The GitHub OAuth App client_id
|
|
25
|
+
* @param {Function} [fetchFn] - Injectable fetch (defaults to globalThis.fetch)
|
|
26
|
+
* @returns {Promise<{
|
|
27
|
+
* device_code: string,
|
|
28
|
+
* user_code: string,
|
|
29
|
+
* verification_uri: string,
|
|
30
|
+
* expires_in: number,
|
|
31
|
+
* interval: number,
|
|
32
|
+
* }>}
|
|
33
|
+
*/
|
|
34
|
+
export async function requestGitHubDeviceCode(clientId, fetchFn = globalThis.fetch) {
|
|
35
|
+
const res = await fetchFn(GITHUB_DEVICE_CODE_URL, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: {
|
|
38
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
39
|
+
Accept: 'application/json',
|
|
40
|
+
},
|
|
41
|
+
body: new URLSearchParams({
|
|
42
|
+
client_id: clientId,
|
|
43
|
+
scope: 'read:user user:email',
|
|
44
|
+
}).toString(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (!res.ok) {
|
|
48
|
+
const body = await res.text().catch(() => '');
|
|
49
|
+
throw new Error(
|
|
50
|
+
`GitHub device code request failed: HTTP ${res.status} — ${body}`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const data = await res.json();
|
|
55
|
+
|
|
56
|
+
if (data.error) {
|
|
57
|
+
throw new Error(`GitHub device code error: ${data.error} — ${data.error_description ?? ''}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!data.device_code || !data.user_code || !data.verification_uri) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
'Unexpected response from GitHub device/code: missing required fields'
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
device_code: data.device_code,
|
|
68
|
+
user_code: data.user_code,
|
|
69
|
+
verification_uri: data.verification_uri,
|
|
70
|
+
expires_in: data.expires_in ?? 900,
|
|
71
|
+
interval: data.interval ?? 5,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Poll GitHub's token endpoint until the user completes authorisation.
|
|
77
|
+
*
|
|
78
|
+
* SECURITY: The returned access_token is a GitHub token and must NEVER be
|
|
79
|
+
* written to disk. Callers must exchange it immediately via POST /v1/auth/github
|
|
80
|
+
* and then discard it.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} clientId - The GitHub OAuth App client_id
|
|
83
|
+
* @param {string} deviceCode - device_code from requestGitHubDeviceCode
|
|
84
|
+
* @param {number} interval - Initial polling interval in seconds
|
|
85
|
+
* @param {object} [opts]
|
|
86
|
+
* @param {Function}[opts.fetch] - Injectable fetch (defaults to globalThis.fetch)
|
|
87
|
+
* @param {Function}[opts.sleep] - Injectable sleep (ms:number)=>Promise<void>
|
|
88
|
+
* @param {number} [opts.expiresIn] - Total expiry window in seconds (default 900)
|
|
89
|
+
* @returns {Promise<string>} The GitHub access token (transient — do not persist)
|
|
90
|
+
*/
|
|
91
|
+
export async function pollGitHubToken(
|
|
92
|
+
clientId,
|
|
93
|
+
deviceCode,
|
|
94
|
+
interval,
|
|
95
|
+
{ fetch: fetchFn = globalThis.fetch, sleep = defaultSleep, expiresIn = 900 } = {}
|
|
96
|
+
) {
|
|
97
|
+
const deadline = Date.now() + expiresIn * 1000;
|
|
98
|
+
let currentInterval = interval;
|
|
99
|
+
|
|
100
|
+
while (Date.now() < deadline) {
|
|
101
|
+
await sleep(currentInterval * 1000);
|
|
102
|
+
|
|
103
|
+
const res = await fetchFn(GITHUB_TOKEN_URL, {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers: {
|
|
106
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
107
|
+
Accept: 'application/json',
|
|
108
|
+
},
|
|
109
|
+
body: new URLSearchParams({
|
|
110
|
+
client_id: clientId,
|
|
111
|
+
device_code: deviceCode,
|
|
112
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
113
|
+
}).toString(),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
if (!res.ok) {
|
|
117
|
+
const body = await res.text().catch(() => '');
|
|
118
|
+
throw new Error(
|
|
119
|
+
`GitHub token poll failed: HTTP ${res.status} — ${body}`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const data = await res.json();
|
|
124
|
+
|
|
125
|
+
if (data.access_token) {
|
|
126
|
+
// Success — return token immediately (do NOT persist it)
|
|
127
|
+
return data.access_token;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const error = data.error ?? 'unknown_error';
|
|
131
|
+
|
|
132
|
+
switch (error) {
|
|
133
|
+
case 'authorization_pending':
|
|
134
|
+
// User hasn't approved yet — keep polling
|
|
135
|
+
break;
|
|
136
|
+
|
|
137
|
+
case 'slow_down':
|
|
138
|
+
// GitHub asked us to back off — increase interval by 5s
|
|
139
|
+
currentInterval += 5;
|
|
140
|
+
break;
|
|
141
|
+
|
|
142
|
+
case 'expired_token':
|
|
143
|
+
throw new Error(
|
|
144
|
+
'GitHub device authorisation expired. Please run `swarmiq` again to restart.'
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
case 'access_denied':
|
|
148
|
+
throw new Error(
|
|
149
|
+
'GitHub authorisation was denied. Please run `swarmiq` again and approve access.'
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
default:
|
|
153
|
+
throw new Error(
|
|
154
|
+
`Unexpected error from GitHub token endpoint: ${error} — ${data.error_description ?? ''}`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
throw new Error(
|
|
160
|
+
'GitHub device authorisation timed out. Please run `swarmiq` again to restart.'
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ─── internal helpers ─────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
function defaultSleep(ms) {
|
|
167
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
168
|
+
}
|