wispy-cli 2.5.1 → 2.6.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/wispy.mjs +110 -3
- package/core/auth.mjs +369 -0
- package/core/config.mjs +19 -0
- package/core/onboarding.mjs +81 -0
- package/core/providers.mjs +38 -1
- package/lib/wispy-tui.mjs +5 -5
- package/package.json +1 -1
package/bin/wispy.mjs
CHANGED
|
@@ -130,8 +130,14 @@ ${_bold("Cron & Automation:")}
|
|
|
130
130
|
${_cyan("wispy cron add")} Add a cron job
|
|
131
131
|
${_cyan("wispy cron start")} Start scheduler
|
|
132
132
|
|
|
133
|
+
${_bold("Auth:")}
|
|
134
|
+
${_cyan("wispy auth")} Show auth status for all providers
|
|
135
|
+
${_cyan("wispy auth github-copilot")} Sign in with GitHub (Copilot OAuth)
|
|
136
|
+
${_cyan("wispy auth refresh <provider>")} Refresh expired OAuth token
|
|
137
|
+
${_cyan("wispy auth revoke <provider>")} Remove saved auth token
|
|
138
|
+
|
|
133
139
|
${_bold("Config & Maintenance:")}
|
|
134
|
-
${_cyan("wispy setup")} Configure wispy interactively ${_dim("(
|
|
140
|
+
${_cyan("wispy setup")} Configure wispy interactively ${_dim("(30 AI providers)")}
|
|
135
141
|
${_cyan("wispy model")} Show / switch AI model
|
|
136
142
|
${_cyan("wispy model list")} List available models per provider
|
|
137
143
|
${_cyan("wispy model set <p:model>")} Switch model (e.g. xai:grok-3)
|
|
@@ -346,6 +352,7 @@ if (args[0] === "completion") {
|
|
|
346
352
|
{ cmd: "status", desc: "Show wispy status & remote connection info" },
|
|
347
353
|
{ cmd: "connect", desc: "Connect to a remote Wispy server" },
|
|
348
354
|
{ cmd: "disconnect", desc: "Disconnect from remote server (go back to local)" },
|
|
355
|
+
{ cmd: "auth", desc: "OAuth auth management (github-copilot, refresh, revoke)" },
|
|
349
356
|
];
|
|
350
357
|
|
|
351
358
|
// Sub-command completions for nested commands
|
|
@@ -359,6 +366,7 @@ if (args[0] === "completion") {
|
|
|
359
366
|
channel: ["setup", "list", "test"],
|
|
360
367
|
node: ["pair", "connect", "list", "status", "remove"],
|
|
361
368
|
sync: ["push", "pull", "status", "auto"],
|
|
369
|
+
auth: ["github-copilot", "refresh", "revoke"],
|
|
362
370
|
server: ["start", "stop", "status"],
|
|
363
371
|
completion: ["bash", "zsh", "fish"],
|
|
364
372
|
help: cmdSpecs.map(c => c.cmd),
|
|
@@ -1797,13 +1805,111 @@ if (args[0] === "channel") {
|
|
|
1797
1805
|
process.exit(0);
|
|
1798
1806
|
}
|
|
1799
1807
|
|
|
1808
|
+
// ── auth sub-command ──────────────────────────────────────────────────────────
|
|
1809
|
+
if (args[0] === "auth") {
|
|
1810
|
+
const { AuthManager } = await import(
|
|
1811
|
+
path.join(__dirname, "..", "core", "auth.mjs")
|
|
1812
|
+
);
|
|
1813
|
+
const { WISPY_DIR } = await import(
|
|
1814
|
+
path.join(__dirname, "..", "core", "config.mjs")
|
|
1815
|
+
);
|
|
1816
|
+
|
|
1817
|
+
const sub = args[1]; // provider name | "refresh" | "revoke" | undefined
|
|
1818
|
+
|
|
1819
|
+
const auth = new AuthManager(WISPY_DIR);
|
|
1820
|
+
|
|
1821
|
+
// wispy auth — show status for all providers
|
|
1822
|
+
if (!sub) {
|
|
1823
|
+
const statuses = await auth.allStatus();
|
|
1824
|
+
if (statuses.length === 0) {
|
|
1825
|
+
console.log(_dim("\n No saved auth tokens. Run: wispy auth <provider>\n"));
|
|
1826
|
+
} else {
|
|
1827
|
+
console.log(`\n${_bold("🔑 Auth Status")}\n`);
|
|
1828
|
+
for (const s of statuses) {
|
|
1829
|
+
const expiryStr = s.expiresAt ? _dim(` (expires ${new Date(s.expiresAt).toLocaleString()})`) : "";
|
|
1830
|
+
const statusIcon = s.expired ? _yellow("⚠️ expired") : _green("✅ valid");
|
|
1831
|
+
console.log(` ${_cyan(s.provider.padEnd(20))} ${s.type.padEnd(8)} ${statusIcon}${expiryStr}`);
|
|
1832
|
+
}
|
|
1833
|
+
console.log("");
|
|
1834
|
+
console.log(_dim(" wispy auth <provider> — re-authenticate"));
|
|
1835
|
+
console.log(_dim(" wispy auth refresh <provider> — refresh token"));
|
|
1836
|
+
console.log(_dim(" wispy auth revoke <provider> — remove saved token"));
|
|
1837
|
+
}
|
|
1838
|
+
console.log("");
|
|
1839
|
+
process.exit(0);
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
// wispy auth refresh <provider>
|
|
1843
|
+
if (sub === "refresh" && args[2]) {
|
|
1844
|
+
const provider = args[2];
|
|
1845
|
+
console.log(`\n🔄 Refreshing token for ${_cyan(provider)}...`);
|
|
1846
|
+
try {
|
|
1847
|
+
await auth.refreshToken(provider);
|
|
1848
|
+
console.log(_green(`✅ Token refreshed for ${provider}\n`));
|
|
1849
|
+
} catch (err) {
|
|
1850
|
+
console.error(_red(`❌ ${err.message}\n`));
|
|
1851
|
+
process.exit(1);
|
|
1852
|
+
}
|
|
1853
|
+
process.exit(0);
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
// wispy auth revoke <provider>
|
|
1857
|
+
if (sub === "revoke" && args[2]) {
|
|
1858
|
+
const provider = args[2];
|
|
1859
|
+
await auth.revokeToken(provider);
|
|
1860
|
+
console.log(_green(`✅ Revoked auth for ${provider}\n`));
|
|
1861
|
+
process.exit(0);
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
// wispy auth <provider> — run OAuth or re-authenticate
|
|
1865
|
+
if (sub && sub !== "refresh" && sub !== "revoke") {
|
|
1866
|
+
const provider = sub;
|
|
1867
|
+
|
|
1868
|
+
if (provider === "github-copilot") {
|
|
1869
|
+
console.log(`\n🔑 ${_bold("GitHub Copilot")} — OAuth sign-in\n`);
|
|
1870
|
+
try {
|
|
1871
|
+
const result = await auth.oauth("github-copilot");
|
|
1872
|
+
if (result.valid) {
|
|
1873
|
+
console.log(_green(`✅ GitHub Copilot authenticated!\n`));
|
|
1874
|
+
console.log(_dim(" Token saved to ~/.wispy/auth/github-copilot.json"));
|
|
1875
|
+
console.log(_dim(" Run: wispy auth to verify status\n"));
|
|
1876
|
+
}
|
|
1877
|
+
} catch (err) {
|
|
1878
|
+
console.error(_red(`\n❌ ${err.message}\n`));
|
|
1879
|
+
process.exit(1);
|
|
1880
|
+
}
|
|
1881
|
+
} else {
|
|
1882
|
+
console.error(_red(`\n❌ Unknown provider for auth: ${provider}`));
|
|
1883
|
+
console.log(_dim(` Currently OAuth is supported for: github-copilot\n`));
|
|
1884
|
+
process.exit(1);
|
|
1885
|
+
}
|
|
1886
|
+
process.exit(0);
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
// Help
|
|
1890
|
+
console.log(`
|
|
1891
|
+
${_bold("🔑 Wispy Auth Commands")}
|
|
1892
|
+
|
|
1893
|
+
${_cyan("wispy auth")} — show auth status for all providers
|
|
1894
|
+
${_cyan("wispy auth github-copilot")} — sign in with GitHub (Copilot OAuth)
|
|
1895
|
+
${_cyan("wispy auth refresh <provider>")} — refresh expired token
|
|
1896
|
+
${_cyan("wispy auth revoke <provider>")} — remove saved auth
|
|
1897
|
+
|
|
1898
|
+
${_bold("Examples:")}
|
|
1899
|
+
wispy auth github-copilot
|
|
1900
|
+
wispy auth refresh github-copilot
|
|
1901
|
+
wispy auth revoke github-copilot
|
|
1902
|
+
`);
|
|
1903
|
+
process.exit(0);
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1800
1906
|
// ── Unknown command detection ─────────────────────────────────────────────────
|
|
1801
1907
|
// Any non-flag argument that wasn't matched above is an unknown command
|
|
1802
1908
|
const _KNOWN_COMMANDS = new Set([
|
|
1803
1909
|
"ws", "trust", "where", "handoff", "skill", "teach", "improve", "dry",
|
|
1804
1910
|
"setup", "init", "update", "status", "connect", "disconnect", "sync",
|
|
1805
1911
|
"deploy", "migrate", "cron", "audit", "log", "server", "node", "channel",
|
|
1806
|
-
"tui", "help", "doctor", "completion", "version",
|
|
1912
|
+
"auth", "tui", "help", "doctor", "completion", "version",
|
|
1807
1913
|
// serve flags (handled below)
|
|
1808
1914
|
"--serve", "--telegram", "--discord", "--slack", "--server",
|
|
1809
1915
|
"--help", "-h", "--version", "-v", "--debug", "--tui",
|
|
@@ -1903,7 +2009,8 @@ const isInteractiveStart = !args.some(a =>
|
|
|
1903
2009
|
["--serve", "--telegram", "--discord", "--slack", "--server",
|
|
1904
2010
|
"status", "setup", "init", "connect", "disconnect", "deploy",
|
|
1905
2011
|
"cron", "audit", "log", "server", "node", "channel", "sync", "tui",
|
|
1906
|
-
"ws", "trust", "where", "handoff", "skill", "teach", "improve", "dry"
|
|
2012
|
+
"ws", "trust", "where", "handoff", "skill", "teach", "improve", "dry",
|
|
2013
|
+
"auth"].includes(a)
|
|
1907
2014
|
);
|
|
1908
2015
|
|
|
1909
2016
|
if (isInteractiveStart) {
|
package/core/auth.mjs
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/auth.mjs — Auth manager for Wispy
|
|
3
|
+
*
|
|
4
|
+
* Handles OAuth device flow, subscription checks, and API key auth.
|
|
5
|
+
* Tokens stored in ~/.wispy/auth/<provider>.json (separate from config.json)
|
|
6
|
+
*
|
|
7
|
+
* class AuthManager:
|
|
8
|
+
* constructor(wispyDir)
|
|
9
|
+
* async apiKey(provider, key) → { valid, token }
|
|
10
|
+
* async oauth(provider) → { valid, token } // OAuth device flow
|
|
11
|
+
* async subscription(provider) → { valid, token } // subscription check
|
|
12
|
+
* saveToken(provider, tokenData)
|
|
13
|
+
* loadToken(provider) → tokenData | null
|
|
14
|
+
* async refreshToken(provider) → tokenData
|
|
15
|
+
* isExpired(provider) → boolean
|
|
16
|
+
* getActiveToken(provider) → string | null
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { mkdir, writeFile, readFile, unlink } from "node:fs/promises";
|
|
22
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
23
|
+
|
|
24
|
+
import { WISPY_DIR } from "./config.mjs";
|
|
25
|
+
|
|
26
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
// Helpers
|
|
28
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
31
|
+
|
|
32
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
33
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
34
|
+
const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
35
|
+
const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
|
|
36
|
+
const yellow= (s) => `\x1b[33m${s}\x1b[0m`;
|
|
37
|
+
const red = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
38
|
+
|
|
39
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
40
|
+
// GitHub Copilot OAuth constants
|
|
41
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
const GITHUB_COPILOT_CLIENT_ID = "Iv1.b507a08c87ecfe98";
|
|
44
|
+
const GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code";
|
|
45
|
+
const GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
|
|
46
|
+
const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token";
|
|
47
|
+
|
|
48
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
49
|
+
// GitHub device flow
|
|
50
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Run GitHub OAuth device flow. Returns { access_token, token_type, scope }.
|
|
54
|
+
*/
|
|
55
|
+
async function githubDeviceFlow() {
|
|
56
|
+
// 1. Request device code
|
|
57
|
+
const resp = await fetch(GITHUB_DEVICE_CODE_URL, {
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers: { "Content-Type": "application/json", "Accept": "application/json" },
|
|
60
|
+
body: JSON.stringify({ client_id: GITHUB_COPILOT_CLIENT_ID, scope: "copilot" }),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (!resp.ok) {
|
|
64
|
+
const err = await resp.text();
|
|
65
|
+
throw new Error(`Failed to get device code: ${resp.status} ${err.slice(0, 200)}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const { device_code, user_code, verification_uri, expires_in, interval: rawInterval } = await resp.json();
|
|
69
|
+
let pollInterval = rawInterval ?? 5;
|
|
70
|
+
|
|
71
|
+
// 2. Show user instructions
|
|
72
|
+
console.log(`\n ${bold("GitHub Copilot Authorization")}`);
|
|
73
|
+
console.log(`\n 1. Open: ${cyan(verification_uri)}`);
|
|
74
|
+
console.log(` 2. Enter code: ${bold(green(user_code))}\n`);
|
|
75
|
+
console.log(` ${dim("Waiting for authorization... (Ctrl+C to cancel)")}`);
|
|
76
|
+
|
|
77
|
+
// 3. Poll for token
|
|
78
|
+
const deadline = Date.now() + (expires_in ?? 900) * 1000;
|
|
79
|
+
while (Date.now() < deadline) {
|
|
80
|
+
await sleep(pollInterval * 1000);
|
|
81
|
+
|
|
82
|
+
const tokenResp = await fetch(GITHUB_ACCESS_TOKEN_URL, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: { "Content-Type": "application/json", "Accept": "application/json" },
|
|
85
|
+
body: JSON.stringify({
|
|
86
|
+
client_id: GITHUB_COPILOT_CLIENT_ID,
|
|
87
|
+
device_code,
|
|
88
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
89
|
+
}),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const result = await tokenResp.json();
|
|
93
|
+
|
|
94
|
+
if (result.access_token) {
|
|
95
|
+
console.log(green("\n ✅ GitHub authorization successful!\n"));
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
if (result.error === "authorization_pending") continue;
|
|
99
|
+
if (result.error === "slow_down") { pollInterval += 5; continue; }
|
|
100
|
+
if (result.error === "expired_token") throw new Error("Authorization code expired. Please try again.");
|
|
101
|
+
if (result.error === "access_denied") throw new Error("Authorization denied by user.");
|
|
102
|
+
throw new Error(result.error_description ?? result.error ?? "Unknown OAuth error");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
throw new Error("Authorization timed out. Please try again.");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Exchange a GitHub OAuth token for a Copilot-specific token.
|
|
110
|
+
* Returns { token, expires_at } — token is the Copilot bearer token.
|
|
111
|
+
*/
|
|
112
|
+
async function getCopilotToken(githubToken) {
|
|
113
|
+
const resp = await fetch(COPILOT_TOKEN_URL, {
|
|
114
|
+
headers: {
|
|
115
|
+
"Authorization": `token ${githubToken}`,
|
|
116
|
+
"Accept": "application/json",
|
|
117
|
+
"User-Agent": "wispy-cli",
|
|
118
|
+
},
|
|
119
|
+
signal: AbortSignal.timeout(10000),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (resp.status === 403) {
|
|
123
|
+
throw new Error("GitHub Copilot not available — you need an active GitHub Copilot subscription.");
|
|
124
|
+
}
|
|
125
|
+
if (!resp.ok) {
|
|
126
|
+
const err = await resp.text();
|
|
127
|
+
throw new Error(`Failed to get Copilot token: ${resp.status} ${err.slice(0, 200)}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return await resp.json(); // { token: "tid=xxx;...", expires_at: 1234567890 }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
134
|
+
// AuthManager class
|
|
135
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
export class AuthManager {
|
|
138
|
+
constructor(wispyDir = WISPY_DIR) {
|
|
139
|
+
this._authDir = path.join(wispyDir, "auth");
|
|
140
|
+
// Cache loaded tokens in memory (keyed by provider)
|
|
141
|
+
this._cache = {};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Token file helpers ─────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
_tokenPath(provider) {
|
|
147
|
+
return path.join(this._authDir, `${provider}.json`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Save token data to ~/.wispy/auth/<provider>.json
|
|
152
|
+
*/
|
|
153
|
+
async saveToken(provider, tokenData) {
|
|
154
|
+
await mkdir(this._authDir, { recursive: true });
|
|
155
|
+
const data = { ...tokenData, provider, savedAt: new Date().toISOString() };
|
|
156
|
+
await writeFile(this._tokenPath(provider), JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
|
|
157
|
+
this._cache[provider] = data;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Load token from disk synchronously. Returns null if not found.
|
|
162
|
+
* Prefer loadTokenAsync when in async context.
|
|
163
|
+
*/
|
|
164
|
+
loadToken(provider) {
|
|
165
|
+
if (this._cache[provider]) return this._cache[provider];
|
|
166
|
+
const p = this._tokenPath(provider);
|
|
167
|
+
if (!existsSync(p)) return null;
|
|
168
|
+
try {
|
|
169
|
+
const data = JSON.parse(readFileSync(p, "utf8"));
|
|
170
|
+
this._cache[provider] = data;
|
|
171
|
+
return data;
|
|
172
|
+
} catch { return null; }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Async version of loadToken (preferred)
|
|
177
|
+
*/
|
|
178
|
+
async loadTokenAsync(provider) {
|
|
179
|
+
if (this._cache[provider]) return this._cache[provider];
|
|
180
|
+
try {
|
|
181
|
+
const raw = await readFile(this._tokenPath(provider), "utf8");
|
|
182
|
+
const data = JSON.parse(raw);
|
|
183
|
+
this._cache[provider] = data;
|
|
184
|
+
return data;
|
|
185
|
+
} catch { return null; }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Remove saved auth token
|
|
190
|
+
*/
|
|
191
|
+
async revokeToken(provider) {
|
|
192
|
+
delete this._cache[provider];
|
|
193
|
+
try { await unlink(this._tokenPath(provider)); } catch { /* ignore */ }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── Token validation ───────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Check if the saved token for a provider is expired.
|
|
200
|
+
*/
|
|
201
|
+
async isExpired(provider) {
|
|
202
|
+
const token = await this.loadTokenAsync(provider);
|
|
203
|
+
if (!token) return true;
|
|
204
|
+
if (!token.expiresAt) return false; // no expiry = never expires
|
|
205
|
+
return new Date(token.expiresAt) <= new Date(Date.now() + 60_000); // 1-min buffer
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Get the active bearer token string for a provider.
|
|
210
|
+
* For github-copilot: returns the Copilot API token (not the GitHub OAuth token).
|
|
211
|
+
*/
|
|
212
|
+
async getActiveToken(provider) {
|
|
213
|
+
const token = await this.loadTokenAsync(provider);
|
|
214
|
+
if (!token) return null;
|
|
215
|
+
|
|
216
|
+
if (provider === "github-copilot") {
|
|
217
|
+
// Copilot token is in token.copilotToken; GitHub OAuth token in token.accessToken
|
|
218
|
+
if (token.copilotToken && !this._isCopilotTokenExpired(token)) {
|
|
219
|
+
return token.copilotToken;
|
|
220
|
+
}
|
|
221
|
+
// Refresh Copilot token from stored GitHub OAuth token
|
|
222
|
+
if (token.accessToken) {
|
|
223
|
+
try {
|
|
224
|
+
const copilotData = await getCopilotToken(token.accessToken);
|
|
225
|
+
const updated = {
|
|
226
|
+
...token,
|
|
227
|
+
copilotToken: copilotData.token,
|
|
228
|
+
copilotExpiresAt: new Date(copilotData.expires_at * 1000).toISOString(),
|
|
229
|
+
};
|
|
230
|
+
await this.saveToken(provider, updated);
|
|
231
|
+
return copilotData.token;
|
|
232
|
+
} catch {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return token.accessToken ?? null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
_isCopilotTokenExpired(token) {
|
|
243
|
+
if (!token.copilotExpiresAt) return true;
|
|
244
|
+
return new Date(token.copilotExpiresAt) <= new Date(Date.now() + 60_000);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── Auth methods ───────────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* API key auth — validate and store.
|
|
251
|
+
*/
|
|
252
|
+
async apiKey(provider, key) {
|
|
253
|
+
if (!key || key.trim() === "") return { valid: false, token: null };
|
|
254
|
+
const tokenData = {
|
|
255
|
+
type: "apikey",
|
|
256
|
+
accessToken: key.trim(),
|
|
257
|
+
provider,
|
|
258
|
+
};
|
|
259
|
+
await this.saveToken(provider, tokenData);
|
|
260
|
+
return { valid: true, token: key.trim() };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* OAuth device flow auth.
|
|
265
|
+
* Currently only supports github-copilot.
|
|
266
|
+
*/
|
|
267
|
+
async oauth(provider) {
|
|
268
|
+
if (provider === "github-copilot") {
|
|
269
|
+
return this._oauthGitHubCopilot();
|
|
270
|
+
}
|
|
271
|
+
throw new Error(`OAuth not implemented for provider: ${provider}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async _oauthGitHubCopilot() {
|
|
275
|
+
// 1. Run device flow to get GitHub OAuth token
|
|
276
|
+
const oauthResult = await githubDeviceFlow();
|
|
277
|
+
const githubToken = oauthResult.access_token;
|
|
278
|
+
|
|
279
|
+
// 2. Exchange for Copilot token
|
|
280
|
+
console.log(dim(" Getting Copilot token..."));
|
|
281
|
+
const copilotData = await getCopilotToken(githubToken);
|
|
282
|
+
|
|
283
|
+
// 3. Save tokens (never log full tokens)
|
|
284
|
+
const tokenData = {
|
|
285
|
+
type: "oauth",
|
|
286
|
+
accessToken: githubToken,
|
|
287
|
+
scope: oauthResult.scope ?? "copilot",
|
|
288
|
+
copilotToken: copilotData.token,
|
|
289
|
+
copilotExpiresAt: new Date(copilotData.expires_at * 1000).toISOString(),
|
|
290
|
+
provider: "github-copilot",
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
await this.saveToken("github-copilot", tokenData);
|
|
294
|
+
|
|
295
|
+
console.log(green(" ✅ GitHub Copilot authenticated!\n"));
|
|
296
|
+
return { valid: true, token: copilotData.token };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Subscription check auth (for providers that use existing subscription tokens).
|
|
301
|
+
*/
|
|
302
|
+
async subscription(provider) {
|
|
303
|
+
throw new Error(`Subscription auth not implemented for: ${provider}`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Refresh an expired OAuth token.
|
|
308
|
+
* For github-copilot: gets a fresh Copilot token using stored GitHub OAuth token.
|
|
309
|
+
*/
|
|
310
|
+
async refreshToken(provider) {
|
|
311
|
+
const token = await this.loadTokenAsync(provider);
|
|
312
|
+
if (!token) throw new Error(`No saved auth for ${provider}. Run: wispy auth ${provider}`);
|
|
313
|
+
|
|
314
|
+
if (provider === "github-copilot") {
|
|
315
|
+
if (!token.accessToken) {
|
|
316
|
+
throw new Error("GitHub OAuth token missing. Re-run: wispy auth github-copilot");
|
|
317
|
+
}
|
|
318
|
+
const copilotData = await getCopilotToken(token.accessToken);
|
|
319
|
+
const updated = {
|
|
320
|
+
...token,
|
|
321
|
+
copilotToken: copilotData.token,
|
|
322
|
+
copilotExpiresAt: new Date(copilotData.expires_at * 1000).toISOString(),
|
|
323
|
+
};
|
|
324
|
+
await this.saveToken(provider, updated);
|
|
325
|
+
return updated;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
throw new Error(`Token refresh not supported for: ${provider}`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ── Status helpers ─────────────────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Get auth status for all providers (for wispy auth status display).
|
|
335
|
+
*/
|
|
336
|
+
async allStatus() {
|
|
337
|
+
const authDir = this._authDir;
|
|
338
|
+
const results = [];
|
|
339
|
+
|
|
340
|
+
let files = [];
|
|
341
|
+
try {
|
|
342
|
+
const { readdir } = await import("node:fs/promises");
|
|
343
|
+
files = (await readdir(authDir)).filter(f => f.endsWith(".json"));
|
|
344
|
+
} catch { /* no auth dir yet */ }
|
|
345
|
+
|
|
346
|
+
for (const file of files) {
|
|
347
|
+
const provider = file.replace(/\.json$/, "");
|
|
348
|
+
const token = await this.loadTokenAsync(provider);
|
|
349
|
+
if (!token) continue;
|
|
350
|
+
|
|
351
|
+
const expired = await this.isExpired(provider);
|
|
352
|
+
results.push({
|
|
353
|
+
provider,
|
|
354
|
+
type: token.type ?? "unknown",
|
|
355
|
+
expired,
|
|
356
|
+
expiresAt: token.expiresAt ?? token.copilotExpiresAt ?? null,
|
|
357
|
+
savedAt: token.savedAt,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return results;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
366
|
+
// Exports for use in providers.mjs and onboarding.mjs
|
|
367
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
export { githubDeviceFlow, getCopilotToken };
|
package/core/config.mjs
CHANGED
|
@@ -52,6 +52,9 @@ export const PROVIDERS = {
|
|
|
52
52
|
dashscope: { envKeys: ["DASHSCOPE_API_KEY"], defaultModel: "qwen-max", label: "DashScope (Alibaba)", signupUrl: "https://dashscope.aliyun.com/" },
|
|
53
53
|
xiaomi: { envKeys: ["XIAOMI_API_KEY"], defaultModel: "MiMo-7B-RL", label: "Xiaomi AI", signupUrl: "https://ai.xiaomi.com/" },
|
|
54
54
|
|
|
55
|
+
// ── OAuth / subscription-based ────────────────────────────────────────────
|
|
56
|
+
"github-copilot": { envKeys: ["GITHUB_COPILOT_TOKEN"], defaultModel: "gpt-4o", label: "GitHub Copilot", signupUrl: "https://github.com/features/copilot", auth: "oauth" },
|
|
57
|
+
|
|
55
58
|
// ── Local / self-hosted ─────────────────────────────────────────────────────
|
|
56
59
|
ollama: { envKeys: [], defaultModel: "llama3.2", label: "Ollama (local)", signupUrl: null, local: true },
|
|
57
60
|
vllm: { envKeys: [], defaultModel: "meta-llama/Llama-3.3-70B-Instruct", label: "vLLM (self-hosted)", signupUrl: "https://docs.vllm.ai/", local: true },
|
|
@@ -164,5 +167,21 @@ export async function detectProvider() {
|
|
|
164
167
|
}
|
|
165
168
|
}
|
|
166
169
|
|
|
170
|
+
// 5. OAuth providers: check auth token file
|
|
171
|
+
const githubCopilotAuthPath = path.join(WISPY_DIR, "auth", "github-copilot.json");
|
|
172
|
+
try {
|
|
173
|
+
const { existsSync } = await import("node:fs");
|
|
174
|
+
if (existsSync(githubCopilotAuthPath)) {
|
|
175
|
+
const tokenData = JSON.parse(await readFile(githubCopilotAuthPath, "utf8"));
|
|
176
|
+
if (tokenData?.accessToken || tokenData?.copilotToken) {
|
|
177
|
+
return {
|
|
178
|
+
provider: "github-copilot",
|
|
179
|
+
key: tokenData.copilotToken ?? tokenData.accessToken,
|
|
180
|
+
model: process.env.WISPY_MODEL ?? PROVIDERS["github-copilot"].defaultModel,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
} catch { /* ignore */ }
|
|
185
|
+
|
|
167
186
|
return null;
|
|
168
187
|
}
|
package/core/onboarding.mjs
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
loadConfig,
|
|
38
38
|
saveConfig,
|
|
39
39
|
} from "./config.mjs";
|
|
40
|
+
import { AuthManager } from "./auth.mjs";
|
|
40
41
|
|
|
41
42
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
42
43
|
// ANSI helpers (no extra deps)
|
|
@@ -55,6 +56,7 @@ const red = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
|
55
56
|
|
|
56
57
|
const PROVIDER_LIST = [
|
|
57
58
|
// ── Popular ─────────────────────────────────────────────────────────────────
|
|
59
|
+
{ key: "github-copilot", label: "🌟 GitHub Copilot — free with GitHub subscription", defaultModel: "gpt-4o", signupUrl: "https://github.com/features/copilot", auth: "oauth", tag: "free with GitHub subscription" },
|
|
58
60
|
{ key: "google", label: "🌟 Google AI (Gemini) — free tier", defaultModel: "gemini-2.5-flash", signupUrl: "https://aistudio.google.com/apikey" },
|
|
59
61
|
{ key: "anthropic", label: "🌟 Anthropic (Claude) — best quality", defaultModel: "claude-sonnet-4-20250514", signupUrl: "https://console.anthropic.com/settings/keys" },
|
|
60
62
|
{ key: "openai", label: "🌟 OpenAI (GPT-4o)", defaultModel: "gpt-4o", signupUrl: "https://platform.openai.com/api-keys" },
|
|
@@ -423,6 +425,85 @@ export class OnboardingWizard {
|
|
|
423
425
|
continue;
|
|
424
426
|
}
|
|
425
427
|
|
|
428
|
+
// ── OAuth providers (e.g. GitHub Copilot) ───────────────────────────
|
|
429
|
+
if (info?.auth === "oauth" && provKey === "github-copilot") {
|
|
430
|
+
console.log("");
|
|
431
|
+
console.log(` ${bold("GitHub Copilot")} — requires a GitHub account with Copilot subscription`);
|
|
432
|
+
console.log(dim(` Get one at: ${info?.signupUrl}`));
|
|
433
|
+
console.log("");
|
|
434
|
+
|
|
435
|
+
let authMethod = "oauth";
|
|
436
|
+
try {
|
|
437
|
+
authMethod = await select({
|
|
438
|
+
message: "GitHub Copilot auth method:",
|
|
439
|
+
choices: [
|
|
440
|
+
{ name: "Sign in with GitHub (recommended)", value: "oauth" },
|
|
441
|
+
{ name: "Paste GitHub token manually", value: "token" },
|
|
442
|
+
],
|
|
443
|
+
});
|
|
444
|
+
} catch {
|
|
445
|
+
console.log(dim(" Skipping github-copilot.\n"));
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const authMgr = new AuthManager(WISPY_DIR);
|
|
450
|
+
|
|
451
|
+
if (authMethod === "oauth") {
|
|
452
|
+
try {
|
|
453
|
+
const result = await authMgr.oauth("github-copilot");
|
|
454
|
+
if (result.valid) {
|
|
455
|
+
providers["github-copilot"] = { model: info?.defaultModel ?? "gpt-4o", auth: "oauth" };
|
|
456
|
+
console.log(green(` ✅ GitHub Copilot connected (${info?.defaultModel ?? "gpt-4o"})`));
|
|
457
|
+
}
|
|
458
|
+
} catch (err) {
|
|
459
|
+
if (err?.name === "ExitPromptError" || err?.code === "ERR_USE_AFTER_CLOSE") {
|
|
460
|
+
console.log(dim(" Skipping github-copilot."));
|
|
461
|
+
} else {
|
|
462
|
+
console.log(red(` ✗ ${err.message}`));
|
|
463
|
+
console.log(yellow(" You can re-run: wispy auth github-copilot"));
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
} else {
|
|
467
|
+
// Manual token paste
|
|
468
|
+
let githubToken = null;
|
|
469
|
+
try {
|
|
470
|
+
githubToken = await password({
|
|
471
|
+
message: " GitHub token (ghp_... or ghu_...):",
|
|
472
|
+
mask: "*",
|
|
473
|
+
});
|
|
474
|
+
} catch { console.log(dim(" Skipping.")); continue; }
|
|
475
|
+
|
|
476
|
+
if (githubToken && githubToken.trim()) {
|
|
477
|
+
try {
|
|
478
|
+
process.stdout.write(" Verifying Copilot access...");
|
|
479
|
+
const { getCopilotToken } = await import("./auth.mjs");
|
|
480
|
+
const copilotData = await getCopilotToken(githubToken.trim());
|
|
481
|
+
await authMgr.saveToken("github-copilot", {
|
|
482
|
+
type: "token",
|
|
483
|
+
accessToken: githubToken.trim(),
|
|
484
|
+
copilotToken: copilotData.token,
|
|
485
|
+
copilotExpiresAt: new Date(copilotData.expires_at * 1000).toISOString(),
|
|
486
|
+
provider: "github-copilot",
|
|
487
|
+
});
|
|
488
|
+
providers["github-copilot"] = { model: info?.defaultModel ?? "gpt-4o", auth: "oauth" };
|
|
489
|
+
console.log(green(` ✅ Copilot access confirmed!`));
|
|
490
|
+
} catch (err) {
|
|
491
|
+
console.log(red(` ✗ ${err.message}`));
|
|
492
|
+
console.log(yellow(" Saving token anyway — check it later with: wispy auth github-copilot"));
|
|
493
|
+
await authMgr.saveToken("github-copilot", {
|
|
494
|
+
type: "token",
|
|
495
|
+
accessToken: githubToken.trim(),
|
|
496
|
+
provider: "github-copilot",
|
|
497
|
+
});
|
|
498
|
+
providers["github-copilot"] = { model: info?.defaultModel ?? "gpt-4o", auth: "oauth" };
|
|
499
|
+
}
|
|
500
|
+
} else {
|
|
501
|
+
console.log(dim(" Skipping github-copilot."));
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
|
|
426
507
|
// ── Special: Cloudflare needs account_id ─────────────────────────────
|
|
427
508
|
if (provKey === "cloudflare") {
|
|
428
509
|
console.log("");
|
package/core/providers.mjs
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* Each provider: { name, label, models, chat(messages, tools, opts) }
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { PROVIDERS, detectProvider } from "./config.mjs";
|
|
14
|
+
import { PROVIDERS, detectProvider, WISPY_DIR } from "./config.mjs";
|
|
15
|
+
import { AuthManager } from "./auth.mjs";
|
|
15
16
|
|
|
16
17
|
const OPENAI_COMPAT_ENDPOINTS = {
|
|
17
18
|
// ── Tier 1: Popular ───────────────────────────────────────────────────────
|
|
@@ -39,6 +40,8 @@ const OPENAI_COMPAT_ENDPOINTS = {
|
|
|
39
40
|
zai: "https://open.bigmodel.cn/api/paas/v4/chat/completions",
|
|
40
41
|
dashscope: "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
|
|
41
42
|
xiaomi: "https://api.api2d.com/v1/chat/completions", // placeholder
|
|
43
|
+
// ── GitHub Copilot ─────────────────────────────────────────────────────────
|
|
44
|
+
"github-copilot": "https://api.githubcopilot.com/chat/completions",
|
|
42
45
|
// ── Local / self-hosted ───────────────────────────────────────────────────
|
|
43
46
|
ollama: null, // set from OLLAMA_HOST
|
|
44
47
|
vllm: null, // set from VLLM_BASE_URL
|
|
@@ -53,6 +56,8 @@ export class ProviderRegistry {
|
|
|
53
56
|
this._apiKey = null;
|
|
54
57
|
this._model = null;
|
|
55
58
|
this._sessionTokens = { input: 0, output: 0 };
|
|
59
|
+
this._auth = new AuthManager(WISPY_DIR);
|
|
60
|
+
this._authType = null; // "apikey" | "oauth"
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
/**
|
|
@@ -67,6 +72,19 @@ export class ProviderRegistry {
|
|
|
67
72
|
this._apiKey = overrides.key ?? detected?.key;
|
|
68
73
|
this._model = overrides.model ?? detected?.model;
|
|
69
74
|
this._detected = detected;
|
|
75
|
+
|
|
76
|
+
// Check provider auth type
|
|
77
|
+
const providerInfo = PROVIDERS[this._provider];
|
|
78
|
+
this._authType = providerInfo?.auth ?? "apikey";
|
|
79
|
+
|
|
80
|
+
// For OAuth providers, try to load token from auth store
|
|
81
|
+
if (this._authType === "oauth") {
|
|
82
|
+
const activeToken = await this._auth.getActiveToken(this._provider);
|
|
83
|
+
if (activeToken) {
|
|
84
|
+
this._apiKey = activeToken;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
70
88
|
return { provider: this._provider, key: this._apiKey, model: this._model };
|
|
71
89
|
}
|
|
72
90
|
|
|
@@ -109,6 +127,20 @@ export class ProviderRegistry {
|
|
|
109
127
|
* Returns: { type: "text"|"tool_calls", text?, calls? }
|
|
110
128
|
*/
|
|
111
129
|
async chat(messages, tools = [], opts = {}) {
|
|
130
|
+
// Auto-refresh expired OAuth tokens before making API calls
|
|
131
|
+
if (this._authType === "oauth" && await this._auth.isExpired(this._provider)) {
|
|
132
|
+
try {
|
|
133
|
+
await this._auth.refreshToken(this._provider);
|
|
134
|
+
const newToken = await this._auth.getActiveToken(this._provider);
|
|
135
|
+
if (newToken) this._apiKey = newToken;
|
|
136
|
+
} catch (err) {
|
|
137
|
+
// Token refresh failed — proceed with existing key and let API return auth error
|
|
138
|
+
if (process.env.WISPY_DEBUG) {
|
|
139
|
+
console.error(`[wispy] Token refresh failed: ${err.message}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
112
144
|
const model = opts.model ?? this._model;
|
|
113
145
|
if (this._provider === "google") {
|
|
114
146
|
return this._chatGemini(messages, tools, opts, model);
|
|
@@ -387,6 +419,11 @@ export class ProviderRegistry {
|
|
|
387
419
|
if (this._apiKey) headers["Authorization"] = `Bearer ${this._apiKey}`;
|
|
388
420
|
if (this._provider === "openrouter") headers["HTTP-Referer"] = "https://wispy.dev";
|
|
389
421
|
if (this._provider === "cloudflare") headers["Authorization"] = `Bearer ${process.env.CF_API_TOKEN ?? this._apiKey}`;
|
|
422
|
+
if (this._provider === "github-copilot") {
|
|
423
|
+
headers["Copilot-Integration-Id"] = "vscode-chat";
|
|
424
|
+
headers["Editor-Version"] = "vscode/1.85.0";
|
|
425
|
+
headers["Editor-Plugin-Version"] = "copilot-chat/0.12.0";
|
|
426
|
+
}
|
|
390
427
|
|
|
391
428
|
const supportsTools = !["ollama", "vllm", "sglang", "minimax", "huggingface"].includes(this._provider);
|
|
392
429
|
const body = { model, messages: openaiMessages, temperature: 0.7, max_tokens: 4096, stream: true };
|
package/lib/wispy-tui.mjs
CHANGED
|
@@ -38,11 +38,11 @@ const SIDEBAR_WIDTH = 16;
|
|
|
38
38
|
const TIMELINE_LINES = 3;
|
|
39
39
|
|
|
40
40
|
const TOOL_ICONS = {
|
|
41
|
-
read_file: "
|
|
42
|
-
git: "
|
|
43
|
-
spawn_subagent: "
|
|
44
|
-
memory_search: "
|
|
45
|
-
node_execute: "
|
|
41
|
+
read_file: "[file]", write_file: "[edit]", file_edit: "[edit]", run_command: "[exec]",
|
|
42
|
+
git: "[git]", web_search: "[search]", web_fetch: "[web]", list_directory: "[dir]",
|
|
43
|
+
spawn_subagent: "[sub-agent]", spawn_agent: "[agent]", memory_save: "[save]",
|
|
44
|
+
memory_search: "[find]", memory_list: "[list]", delete_file: "[delete]",
|
|
45
|
+
node_execute: "[run]", update_work_context: "[update]",
|
|
46
46
|
};
|
|
47
47
|
|
|
48
48
|
// ─── Utilities ───────────────────────────────────────────────────────────────
|