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 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("(29 AI providers)")}
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"].includes(a)
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
  }
@@ -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("");
@@ -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: "📖", write_file: "✏️", file_edit: "✏️", run_command: "⚙️",
42
- git: "🌿", web_search: "🔍", web_fetch: "🌐", list_directory: "📁",
43
- spawn_subagent: "🤖", spawn_agent: "🤖", memory_save: "💾",
44
- memory_search: "🔍", memory_list: "📝", delete_file: "🗑️",
45
- node_execute: "🖥️", update_work_context: "📝",
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 ───────────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wispy-cli",
3
- "version": "2.5.1",
3
+ "version": "2.6.0",
4
4
  "description": "🌿 Wispy — AI workspace assistant with trustworthy execution (harness, receipts, approvals, diffs)",
5
5
  "license": "MIT",
6
6
  "author": "Minseo & Poropo",