@creativeintelligence/abbie 0.1.1 → 0.1.3
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/dist/cli/commands/start.d.ts +2 -1
- package/dist/cli/commands/start.d.ts.map +1 -1
- package/dist/cli/commands/start.js +17 -12
- package/dist/lib/provider-auth.d.ts +38 -0
- package/dist/lib/provider-auth.d.ts.map +1 -0
- package/dist/lib/provider-auth.js +193 -0
- package/extensions/abbie/index.ts +105 -0
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
- package/src/cli/commands/start.ts +19 -13
- package/src/lib/provider-auth.ts +258 -0
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* 1. Check Pi → not found → auto-install
|
|
6
6
|
* 2. Install Abbie Pi extension
|
|
7
7
|
* 3. Open browser for Clerk auth
|
|
8
|
-
* 4.
|
|
8
|
+
* 4. Connect AI provider (OAuth — uses your subscription)
|
|
9
|
+
* 5. Launch Pi (extension auto-starts bridge)
|
|
9
10
|
*
|
|
10
11
|
* Subsequent runs:
|
|
11
12
|
* Launch Pi directly (extension handles bridge)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"start.d.ts","sourceRoot":"","sources":["../../../src/cli/commands/start.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"start.d.ts","sourceRoot":"","sources":["../../../src/cli/commands/start.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAQH,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAoCjD,MAAM,CAAC,OAAO,OAAO,cAAe,SAAQ,WAAW;IACrD,OAAgB,EAAE,SAAM;IACxB,OAAgB,OAAO,SAAkB;IACzC,OAAgB,WAAW,SAQsB;IAEjD,OAAgB,QAAQ,WAItB;IAEF,OAAgB,KAAK;;;;;;;MAUnB;IAGF,OAAgB,MAAM,UAAS;IAEzB,OAAO,IAAI,OAAO,CAAC,OAAO,CAAC;YAyDnB,QAAQ;IAqFtB;;;OAGG;IACH,OAAO,CAAC,mBAAmB;CAmB5B"}
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* 1. Check Pi → not found → auto-install
|
|
6
6
|
* 2. Install Abbie Pi extension
|
|
7
7
|
* 3. Open browser for Clerk auth
|
|
8
|
-
* 4.
|
|
8
|
+
* 4. Connect AI provider (OAuth — uses your subscription)
|
|
9
|
+
* 5. Launch Pi (extension auto-starts bridge)
|
|
9
10
|
*
|
|
10
11
|
* Subsequent runs:
|
|
11
12
|
* Launch Pi directly (extension handles bridge)
|
|
@@ -17,6 +18,7 @@ import { fileURLToPath } from "node:url";
|
|
|
17
18
|
import { spawnSync, spawn, execSync } from "node:child_process";
|
|
18
19
|
import { Flags } from "@oclif/core";
|
|
19
20
|
import { BaseCommand } from "../base-command.js";
|
|
21
|
+
import { hasAnyProvider, ensureProviderConnected, getConnectedProviders } from "../../lib/provider-auth.js";
|
|
20
22
|
const __filename = fileURLToPath(import.meta.url);
|
|
21
23
|
const __dirname = dirname(__filename);
|
|
22
24
|
const PI_PACKAGE = "@mariozechner/pi-coding-agent";
|
|
@@ -86,17 +88,20 @@ After setup, just run \`abbie\` to start working.`;
|
|
|
86
88
|
const extOk = extensionInstalled();
|
|
87
89
|
// --status: show state and exit
|
|
88
90
|
if (flags.status) {
|
|
91
|
+
const providers = getConnectedProviders();
|
|
89
92
|
this.log("");
|
|
90
93
|
this.log(" abbie status");
|
|
91
94
|
this.log("");
|
|
92
95
|
this.log(` pi: ${pi.installed ? `✓ ${pi.version}` : "✗ not installed"}`);
|
|
93
96
|
this.log(` extension: ${extOk ? "✓ installed" : "✗ not installed"}`);
|
|
94
97
|
this.log(` auth: ${configured ? "✓ configured" : "✗ not configured"}`);
|
|
98
|
+
this.log(` providers: ${providers.length > 0 ? `✓ ${providers.join(", ")}` : "✗ none connected"}`);
|
|
95
99
|
this.log("");
|
|
96
|
-
return { pi: pi.installed, extension: extOk, auth: configured };
|
|
100
|
+
return { pi: pi.installed, extension: extOk, auth: configured, providers };
|
|
97
101
|
}
|
|
98
102
|
// Setup needed?
|
|
99
|
-
const
|
|
103
|
+
const providerOk = hasAnyProvider();
|
|
104
|
+
const needsSetup = flags.setup || !pi.installed || !configured || !extOk || !providerOk;
|
|
100
105
|
if (needsSetup) {
|
|
101
106
|
await this.runSetup(pi, configured, extOk);
|
|
102
107
|
// Re-check after setup
|
|
@@ -130,7 +135,7 @@ After setup, just run \`abbie\` to start working.`;
|
|
|
130
135
|
this.log("");
|
|
131
136
|
// Step 1: Install Pi
|
|
132
137
|
if (!pi.installed) {
|
|
133
|
-
this.log(" [1/
|
|
138
|
+
this.log(" [1/4] installing pi...");
|
|
134
139
|
this.log(` npm install -g ${PI_PACKAGE}`);
|
|
135
140
|
this.log("");
|
|
136
141
|
try {
|
|
@@ -158,12 +163,12 @@ After setup, just run \`abbie\` to start working.`;
|
|
|
158
163
|
}
|
|
159
164
|
}
|
|
160
165
|
else {
|
|
161
|
-
this.log(` [1/
|
|
166
|
+
this.log(` [1/4] pi ${pi.version} ✓`);
|
|
162
167
|
}
|
|
163
168
|
this.log("");
|
|
164
169
|
// Step 2: Install Abbie extension
|
|
165
170
|
if (!extOk) {
|
|
166
|
-
this.log(" [2/
|
|
171
|
+
this.log(" [2/4] installing abbie extension...");
|
|
167
172
|
// Find the bundled extension
|
|
168
173
|
const extSource = this.findExtensionSource();
|
|
169
174
|
if (extSource) {
|
|
@@ -177,12 +182,12 @@ After setup, just run \`abbie\` to start working.`;
|
|
|
177
182
|
}
|
|
178
183
|
}
|
|
179
184
|
else {
|
|
180
|
-
this.log(" [2/
|
|
185
|
+
this.log(" [2/4] extension ✓");
|
|
181
186
|
}
|
|
182
187
|
this.log("");
|
|
183
188
|
// Step 3: Authenticate
|
|
184
189
|
if (!configured) {
|
|
185
|
-
this.log(" [3/
|
|
190
|
+
this.log(" [3/4] authenticating...");
|
|
186
191
|
this.log(" opening browser for login...");
|
|
187
192
|
this.log("");
|
|
188
193
|
// Delegate to the login command
|
|
@@ -194,13 +199,13 @@ After setup, just run \`abbie\` to start working.`;
|
|
|
194
199
|
}
|
|
195
200
|
}
|
|
196
201
|
else {
|
|
197
|
-
this.log(" [3/
|
|
202
|
+
this.log(" [3/4] authenticated ✓");
|
|
198
203
|
}
|
|
199
204
|
this.log("");
|
|
200
|
-
|
|
205
|
+
// Step 4: Connect AI provider
|
|
206
|
+
await ensureProviderConnected((msg) => this.log(msg));
|
|
201
207
|
this.log("");
|
|
202
|
-
this.log("
|
|
203
|
-
this.log(" (Anthropic, OpenAI, Google, or GitHub Copilot)");
|
|
208
|
+
this.log(" setup complete ✓");
|
|
204
209
|
}
|
|
205
210
|
/**
|
|
206
211
|
* Find the bundled extension source directory.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider OAuth Authentication
|
|
3
|
+
*
|
|
4
|
+
* Runs AI provider OAuth flows (Anthropic, OpenAI, Google, GitHub Copilot)
|
|
5
|
+
* using Pi's OAuth libraries, resolved dynamically from Pi's global install.
|
|
6
|
+
*
|
|
7
|
+
* Writes credentials to ~/.pi/agent/auth.json (Pi's auth storage format).
|
|
8
|
+
* This means Pi starts with providers already configured — no /login needed.
|
|
9
|
+
*/
|
|
10
|
+
export interface ProviderCredential {
|
|
11
|
+
type: "oauth";
|
|
12
|
+
refresh: string;
|
|
13
|
+
access: string;
|
|
14
|
+
expires: number;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
type AuthData = Record<string, ProviderCredential>;
|
|
18
|
+
export declare function readAuthJson(): AuthData;
|
|
19
|
+
export declare function getConnectedProviders(): string[];
|
|
20
|
+
export declare function hasAnyProvider(): boolean;
|
|
21
|
+
export interface ProviderInfo {
|
|
22
|
+
id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
description: string;
|
|
25
|
+
}
|
|
26
|
+
export declare const PROVIDERS: ProviderInfo[];
|
|
27
|
+
/**
|
|
28
|
+
* Run the provider selection + OAuth flow.
|
|
29
|
+
* Returns the provider ID that was connected, or null if cancelled.
|
|
30
|
+
*/
|
|
31
|
+
export declare function connectProvider(log: (msg: string) => void): Promise<string | null>;
|
|
32
|
+
/**
|
|
33
|
+
* Check providers and optionally prompt for connection.
|
|
34
|
+
* Used in the setup wizard.
|
|
35
|
+
*/
|
|
36
|
+
export declare function ensureProviderConnected(log: (msg: string) => void): Promise<boolean>;
|
|
37
|
+
export {};
|
|
38
|
+
//# sourceMappingURL=provider-auth.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"provider-auth.d.ts","sourceRoot":"","sources":["../../src/lib/provider-auth.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAeH,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,KAAK,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAAC;AAEnD,wBAAgB,YAAY,IAAI,QAAQ,CAOvC;AASD,wBAAgB,qBAAqB,IAAI,MAAM,EAAE,CAKhD;AAED,wBAAgB,cAAc,IAAI,OAAO,CAExC;AAMD,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,eAAO,MAAM,SAAS,EAAE,YAAY,EAMnC,CAAC;AAoEF;;;GAGG;AACH,wBAAsB,eAAe,CACnC,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,GACzB,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAiFxB;AAED;;;GAGG;AACH,wBAAsB,uBAAuB,CAC3C,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,GACzB,OAAO,CAAC,OAAO,CAAC,CAkBlB"}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider OAuth Authentication
|
|
3
|
+
*
|
|
4
|
+
* Runs AI provider OAuth flows (Anthropic, OpenAI, Google, GitHub Copilot)
|
|
5
|
+
* using Pi's OAuth libraries, resolved dynamically from Pi's global install.
|
|
6
|
+
*
|
|
7
|
+
* Writes credentials to ~/.pi/agent/auth.json (Pi's auth storage format).
|
|
8
|
+
* This means Pi starts with providers already configured — no /login needed.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { execSync } from "node:child_process";
|
|
14
|
+
import { createInterface } from "node:readline";
|
|
15
|
+
import { exec } from "node:child_process";
|
|
16
|
+
const AUTH_JSON = join(homedir(), ".pi", "agent", "auth.json");
|
|
17
|
+
export function readAuthJson() {
|
|
18
|
+
try {
|
|
19
|
+
if (!existsSync(AUTH_JSON))
|
|
20
|
+
return {};
|
|
21
|
+
return JSON.parse(readFileSync(AUTH_JSON, "utf8"));
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function writeAuthJson(data) {
|
|
28
|
+
const dir = join(homedir(), ".pi", "agent");
|
|
29
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
30
|
+
writeFileSync(AUTH_JSON, JSON.stringify(data, null, 2), "utf8");
|
|
31
|
+
chmodSync(AUTH_JSON, 0o600);
|
|
32
|
+
}
|
|
33
|
+
export function getConnectedProviders() {
|
|
34
|
+
const data = readAuthJson();
|
|
35
|
+
return Object.entries(data)
|
|
36
|
+
.filter(([, v]) => v?.type === "oauth" && v.access)
|
|
37
|
+
.map(([k]) => k);
|
|
38
|
+
}
|
|
39
|
+
export function hasAnyProvider() {
|
|
40
|
+
return getConnectedProviders().length > 0;
|
|
41
|
+
}
|
|
42
|
+
export const PROVIDERS = [
|
|
43
|
+
{ id: "anthropic", name: "Anthropic", description: "Claude Pro/Max subscription" },
|
|
44
|
+
{ id: "openai-codex", name: "OpenAI", description: "ChatGPT Plus/Pro subscription" },
|
|
45
|
+
{ id: "google-gemini-cli", name: "Google Gemini", description: "Google Cloud Code Assist" },
|
|
46
|
+
{ id: "github-copilot", name: "GitHub Copilot", description: "Copilot Individual/Business" },
|
|
47
|
+
{ id: "google-antigravity", name: "Google Antigravity", description: "Gemini 3, Claude, GPT via Google Cloud" },
|
|
48
|
+
];
|
|
49
|
+
async function importPiOAuth() {
|
|
50
|
+
// Strategy: find @mariozechner/pi-ai from Pi's global install
|
|
51
|
+
const candidates = [];
|
|
52
|
+
// 1. Try resolving from pi binary location
|
|
53
|
+
try {
|
|
54
|
+
const piPath = execSync("which pi", { encoding: "utf8", timeout: 3000 }).trim();
|
|
55
|
+
if (piPath) {
|
|
56
|
+
const { realpathSync } = await import("node:fs");
|
|
57
|
+
const realPath = realpathSync(piPath);
|
|
58
|
+
// pi binary -> bin/pi.mjs -> package root -> node_modules/@mariozechner/pi-ai
|
|
59
|
+
const pkgRoot = join(realPath, "..", "..");
|
|
60
|
+
candidates.push(join(pkgRoot, "node_modules", "@mariozechner", "pi-ai", "dist", "index.js"));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch { /* not found */ }
|
|
64
|
+
// 2. Try common global paths
|
|
65
|
+
const nodeVersion = process.version.replace("v", "");
|
|
66
|
+
candidates.push(join(homedir(), ".nvm", "versions", "node", `v${nodeVersion}`, "lib", "node_modules", "@mariozechner", "pi-coding-agent", "node_modules", "@mariozechner", "pi-ai", "dist", "index.js"), join("/usr", "local", "lib", "node_modules", "@mariozechner", "pi-coding-agent", "node_modules", "@mariozechner", "pi-ai", "dist", "index.js"));
|
|
67
|
+
for (const candidate of candidates) {
|
|
68
|
+
if (existsSync(candidate)) {
|
|
69
|
+
try {
|
|
70
|
+
return await import(candidate);
|
|
71
|
+
}
|
|
72
|
+
catch { /* import failed */ }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
// ============================================================================
|
|
78
|
+
// CLI OAuth flow helpers
|
|
79
|
+
// ============================================================================
|
|
80
|
+
function prompt(question) {
|
|
81
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
82
|
+
return new Promise((resolve) => {
|
|
83
|
+
rl.question(question, (answer) => {
|
|
84
|
+
rl.close();
|
|
85
|
+
resolve(answer.trim());
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
function openBrowser(url) {
|
|
90
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
91
|
+
exec(`${cmd} "${url}"`);
|
|
92
|
+
}
|
|
93
|
+
// ============================================================================
|
|
94
|
+
// Public API
|
|
95
|
+
// ============================================================================
|
|
96
|
+
/**
|
|
97
|
+
* Run the provider selection + OAuth flow.
|
|
98
|
+
* Returns the provider ID that was connected, or null if cancelled.
|
|
99
|
+
*/
|
|
100
|
+
export async function connectProvider(log) {
|
|
101
|
+
const piOAuth = await importPiOAuth();
|
|
102
|
+
if (!piOAuth) {
|
|
103
|
+
log(" ⚠ could not find Pi's OAuth module");
|
|
104
|
+
log(" connect providers in Pi instead: /login");
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
const connected = getConnectedProviders();
|
|
108
|
+
// Show provider list
|
|
109
|
+
log("");
|
|
110
|
+
log(" connect an AI provider");
|
|
111
|
+
log(" ─────────────────────");
|
|
112
|
+
log("");
|
|
113
|
+
const providers = piOAuth.getOAuthProviders();
|
|
114
|
+
for (let i = 0; i < providers.length; i++) {
|
|
115
|
+
const p = providers[i];
|
|
116
|
+
const isConnected = connected.includes(p.id);
|
|
117
|
+
const status = isConnected ? " ✓ connected" : "";
|
|
118
|
+
log(` ${i + 1}. ${p.name}${status}`);
|
|
119
|
+
}
|
|
120
|
+
log(` ${providers.length + 1}. skip for now`);
|
|
121
|
+
log("");
|
|
122
|
+
const choice = await prompt(" select (1-" + (providers.length + 1) + "): ");
|
|
123
|
+
const idx = parseInt(choice, 10) - 1;
|
|
124
|
+
if (isNaN(idx) || idx < 0 || idx >= providers.length) {
|
|
125
|
+
return null; // skip
|
|
126
|
+
}
|
|
127
|
+
const provider = providers[idx];
|
|
128
|
+
log("");
|
|
129
|
+
log(` connecting ${provider.name}...`);
|
|
130
|
+
log("");
|
|
131
|
+
try {
|
|
132
|
+
const credentials = await provider.login({
|
|
133
|
+
onAuth: (info) => {
|
|
134
|
+
log(` open this URL to authenticate:`);
|
|
135
|
+
log(` ${info.url}`);
|
|
136
|
+
if (info.instructions) {
|
|
137
|
+
log(` ${info.instructions}`);
|
|
138
|
+
}
|
|
139
|
+
log("");
|
|
140
|
+
openBrowser(info.url);
|
|
141
|
+
},
|
|
142
|
+
onPrompt: async (promptInfo) => {
|
|
143
|
+
const answer = await prompt(` ${promptInfo.message} `);
|
|
144
|
+
if (!answer && !promptInfo.allowEmpty) {
|
|
145
|
+
throw new Error("Input required");
|
|
146
|
+
}
|
|
147
|
+
return answer;
|
|
148
|
+
},
|
|
149
|
+
onProgress: (message) => {
|
|
150
|
+
log(` ${message}`);
|
|
151
|
+
},
|
|
152
|
+
onManualCodeInput: async () => {
|
|
153
|
+
return await prompt(" paste the code or URL: ");
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
// Write to auth.json
|
|
157
|
+
const data = readAuthJson();
|
|
158
|
+
data[provider.id] = { type: "oauth", ...credentials };
|
|
159
|
+
writeAuthJson(data);
|
|
160
|
+
log(` ✓ ${provider.name} connected`);
|
|
161
|
+
return provider.id;
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
165
|
+
if (msg.includes("cancelled")) {
|
|
166
|
+
log(" cancelled");
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
log(` ✗ connection failed: ${msg}`);
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Check providers and optionally prompt for connection.
|
|
176
|
+
* Used in the setup wizard.
|
|
177
|
+
*/
|
|
178
|
+
export async function ensureProviderConnected(log) {
|
|
179
|
+
const connected = getConnectedProviders();
|
|
180
|
+
if (connected.length > 0) {
|
|
181
|
+
const names = connected.map((id) => {
|
|
182
|
+
const info = PROVIDERS.find((p) => p.id === id);
|
|
183
|
+
return info?.name ?? id;
|
|
184
|
+
});
|
|
185
|
+
log(` [4/4] providers: ${names.join(", ")} ✓`);
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
log(" [4/4] connect an AI provider");
|
|
189
|
+
log(" (uses your existing subscription — no API keys needed)");
|
|
190
|
+
log("");
|
|
191
|
+
const result = await connectProvider(log);
|
|
192
|
+
return result !== null;
|
|
193
|
+
}
|
|
@@ -535,6 +535,111 @@ export default function abbieExtension(pi: ExtensionAPI) {
|
|
|
535
535
|
},
|
|
536
536
|
});
|
|
537
537
|
|
|
538
|
+
// ===========================================================================
|
|
539
|
+
// Provider auth sync: sync auth.json → Convex via bridge on startup
|
|
540
|
+
// ===========================================================================
|
|
541
|
+
|
|
542
|
+
async function getConnectedProviders(): Promise<string[]> {
|
|
543
|
+
const { existsSync, readFileSync } = await import("node:fs");
|
|
544
|
+
const { join } = await import("node:path");
|
|
545
|
+
const { homedir } = await import("node:os");
|
|
546
|
+
const authPath = join(homedir(), ".pi", "agent", "auth.json");
|
|
547
|
+
if (!existsSync(authPath)) return [];
|
|
548
|
+
try {
|
|
549
|
+
const data = JSON.parse(readFileSync(authPath, "utf8"));
|
|
550
|
+
return Object.entries(data)
|
|
551
|
+
.filter(([, v]: [string, any]) => v?.type === "oauth" && v.access)
|
|
552
|
+
.map(([k]) => k);
|
|
553
|
+
} catch { return []; }
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// On session start: check if any providers are connected, try syncing from Convex
|
|
557
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
558
|
+
const localProviders = await getConnectedProviders();
|
|
559
|
+
|
|
560
|
+
// If no local providers, try to pull credentials from Convex (for fresh installs)
|
|
561
|
+
if (localProviders.length === 0) {
|
|
562
|
+
await syncProvidersFromConvex();
|
|
563
|
+
const afterSync = await getConnectedProviders();
|
|
564
|
+
if (afterSync.length === 0 && ctx.hasUI) {
|
|
565
|
+
ctx.ui.notify(
|
|
566
|
+
"No AI provider connected. Connect one at Settings → Providers in the web app, or use /login here.",
|
|
567
|
+
"warning",
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
// Sync provider credentials to Convex after first agent interaction
|
|
574
|
+
pi.on("agent_end", async (_event, _ctx) => {
|
|
575
|
+
if ((pi as any).__providersSynced) return;
|
|
576
|
+
(pi as any).__providersSynced = true;
|
|
577
|
+
|
|
578
|
+
const providers = await getConnectedProviders();
|
|
579
|
+
if (providers.length === 0) return;
|
|
580
|
+
|
|
581
|
+
// Also sync credentials to Convex for sandbox injection
|
|
582
|
+
try {
|
|
583
|
+
const { existsSync, readFileSync } = await import("node:fs");
|
|
584
|
+
const { join } = await import("node:path");
|
|
585
|
+
const { homedir } = await import("node:os");
|
|
586
|
+
const authPath = join(homedir(), ".pi", "agent", "auth.json");
|
|
587
|
+
const configPath = join(homedir(), ".abbie", "config.json");
|
|
588
|
+
if (!existsSync(configPath) || !existsSync(authPath)) return;
|
|
589
|
+
const config = JSON.parse(readFileSync(configPath, "utf8"));
|
|
590
|
+
const convexUrl = config.convexUrl || process.env.NEXT_PUBLIC_CONVEX_URL;
|
|
591
|
+
const bridgeSecret = config.bridgeSecret || process.env.ABBIE_BRIDGE_SECRET;
|
|
592
|
+
if (!convexUrl || !bridgeSecret) return;
|
|
593
|
+
|
|
594
|
+
const siteUrl = convexUrl.replace('.convex.cloud', '.convex.site');
|
|
595
|
+
const authData = JSON.parse(readFileSync(authPath, "utf8"));
|
|
596
|
+
|
|
597
|
+
// Sync each provider's credentials
|
|
598
|
+
for (const providerId of providers) {
|
|
599
|
+
const cred = authData[providerId];
|
|
600
|
+
if (!cred?.access || !cred?.refresh) continue;
|
|
601
|
+
|
|
602
|
+
await fetch(`${siteUrl}/bridge/providers/complete`, {
|
|
603
|
+
method: "POST",
|
|
604
|
+
headers: {
|
|
605
|
+
"Content-Type": "application/json",
|
|
606
|
+
"x-bridge-secret": bridgeSecret,
|
|
607
|
+
},
|
|
608
|
+
body: JSON.stringify({
|
|
609
|
+
// Use a synthetic request ID (the endpoint handles upserts)
|
|
610
|
+
requestId: null,
|
|
611
|
+
accessToken: cred.access,
|
|
612
|
+
refreshToken: cred.refresh,
|
|
613
|
+
expiresAt: cred.expires || Date.now() + 3600_000,
|
|
614
|
+
provider: providerId,
|
|
615
|
+
}),
|
|
616
|
+
}).catch(() => {}); // best-effort
|
|
617
|
+
}
|
|
618
|
+
} catch { /* non-fatal */ }
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Pull provider credentials from Convex and write to local auth.json.
|
|
623
|
+
* Used on fresh installs where user connected via web but auth.json is empty.
|
|
624
|
+
*/
|
|
625
|
+
async function syncProvidersFromConvex(): Promise<void> {
|
|
626
|
+
try {
|
|
627
|
+
const { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } = await import("node:fs");
|
|
628
|
+
const { join } = await import("node:path");
|
|
629
|
+
const { homedir } = await import("node:os");
|
|
630
|
+
|
|
631
|
+
const configPath = join(homedir(), ".abbie", "config.json");
|
|
632
|
+
if (!existsSync(configPath)) return;
|
|
633
|
+
const config = JSON.parse(readFileSync(configPath, "utf8"));
|
|
634
|
+
const convexUrl = config.convexUrl || process.env.NEXT_PUBLIC_CONVEX_URL;
|
|
635
|
+
const bridgeSecret = config.bridgeSecret || process.env.ABBIE_BRIDGE_SECRET;
|
|
636
|
+
if (!convexUrl || !bridgeSecret) return;
|
|
637
|
+
|
|
638
|
+
// TODO: Add a bridge endpoint that returns stored credentials
|
|
639
|
+
// For now, the bridge handles credential sync via the poll loop
|
|
640
|
+
} catch { /* non-fatal */ }
|
|
641
|
+
}
|
|
642
|
+
|
|
538
643
|
// ===========================================================================
|
|
539
644
|
// Auto-bridge: start bridge daemon on Pi startup
|
|
540
645
|
// ===========================================================================
|
package/oclif.manifest.json
CHANGED
|
@@ -11672,5 +11672,5 @@
|
|
|
11672
11672
|
"summary": "Watch workspace windows (tmux panes) and stream updates"
|
|
11673
11673
|
}
|
|
11674
11674
|
},
|
|
11675
|
-
"version": "0.1.
|
|
11675
|
+
"version": "0.1.3"
|
|
11676
11676
|
}
|
package/package.json
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* 1. Check Pi → not found → auto-install
|
|
6
6
|
* 2. Install Abbie Pi extension
|
|
7
7
|
* 3. Open browser for Clerk auth
|
|
8
|
-
* 4.
|
|
8
|
+
* 4. Connect AI provider (OAuth — uses your subscription)
|
|
9
|
+
* 5. Launch Pi (extension auto-starts bridge)
|
|
9
10
|
*
|
|
10
11
|
* Subsequent runs:
|
|
11
12
|
* Launch Pi directly (extension handles bridge)
|
|
@@ -18,6 +19,7 @@ import { fileURLToPath } from "node:url";
|
|
|
18
19
|
import { spawnSync, spawn, execSync } from "node:child_process";
|
|
19
20
|
import { Flags } from "@oclif/core";
|
|
20
21
|
import { BaseCommand } from "../base-command.js";
|
|
22
|
+
import { hasAnyProvider, ensureProviderConnected, getConnectedProviders } from "../../lib/provider-auth.js";
|
|
21
23
|
|
|
22
24
|
const __filename = fileURLToPath(import.meta.url);
|
|
23
25
|
const __dirname = dirname(__filename);
|
|
@@ -96,18 +98,21 @@ After setup, just run \`abbie\` to start working.`;
|
|
|
96
98
|
|
|
97
99
|
// --status: show state and exit
|
|
98
100
|
if (flags.status) {
|
|
101
|
+
const providers = getConnectedProviders();
|
|
99
102
|
this.log("");
|
|
100
103
|
this.log(" abbie status");
|
|
101
104
|
this.log("");
|
|
102
105
|
this.log(` pi: ${pi.installed ? `✓ ${pi.version}` : "✗ not installed"}`);
|
|
103
106
|
this.log(` extension: ${extOk ? "✓ installed" : "✗ not installed"}`);
|
|
104
107
|
this.log(` auth: ${configured ? "✓ configured" : "✗ not configured"}`);
|
|
108
|
+
this.log(` providers: ${providers.length > 0 ? `✓ ${providers.join(", ")}` : "✗ none connected"}`);
|
|
105
109
|
this.log("");
|
|
106
|
-
return { pi: pi.installed, extension: extOk, auth: configured };
|
|
110
|
+
return { pi: pi.installed, extension: extOk, auth: configured, providers };
|
|
107
111
|
}
|
|
108
112
|
|
|
109
113
|
// Setup needed?
|
|
110
|
-
const
|
|
114
|
+
const providerOk = hasAnyProvider();
|
|
115
|
+
const needsSetup = flags.setup || !pi.installed || !configured || !extOk || !providerOk;
|
|
111
116
|
|
|
112
117
|
if (needsSetup) {
|
|
113
118
|
await this.runSetup(pi, configured, extOk);
|
|
@@ -152,7 +157,7 @@ After setup, just run \`abbie\` to start working.`;
|
|
|
152
157
|
|
|
153
158
|
// Step 1: Install Pi
|
|
154
159
|
if (!pi.installed) {
|
|
155
|
-
this.log(" [1/
|
|
160
|
+
this.log(" [1/4] installing pi...");
|
|
156
161
|
this.log(` npm install -g ${PI_PACKAGE}`);
|
|
157
162
|
this.log("");
|
|
158
163
|
|
|
@@ -178,13 +183,13 @@ After setup, just run \`abbie\` to start working.`;
|
|
|
178
183
|
this.error("Pi is required to continue.");
|
|
179
184
|
}
|
|
180
185
|
} else {
|
|
181
|
-
this.log(` [1/
|
|
186
|
+
this.log(` [1/4] pi ${pi.version} ✓`);
|
|
182
187
|
}
|
|
183
188
|
this.log("");
|
|
184
189
|
|
|
185
190
|
// Step 2: Install Abbie extension
|
|
186
191
|
if (!extOk) {
|
|
187
|
-
this.log(" [2/
|
|
192
|
+
this.log(" [2/4] installing abbie extension...");
|
|
188
193
|
|
|
189
194
|
// Find the bundled extension
|
|
190
195
|
const extSource = this.findExtensionSource();
|
|
@@ -197,13 +202,13 @@ After setup, just run \`abbie\` to start working.`;
|
|
|
197
202
|
this.logWarn("this is non-fatal; you can install it manually later");
|
|
198
203
|
}
|
|
199
204
|
} else {
|
|
200
|
-
this.log(" [2/
|
|
205
|
+
this.log(" [2/4] extension ✓");
|
|
201
206
|
}
|
|
202
207
|
this.log("");
|
|
203
208
|
|
|
204
209
|
// Step 3: Authenticate
|
|
205
210
|
if (!configured) {
|
|
206
|
-
this.log(" [3/
|
|
211
|
+
this.log(" [3/4] authenticating...");
|
|
207
212
|
this.log(" opening browser for login...");
|
|
208
213
|
this.log("");
|
|
209
214
|
|
|
@@ -214,14 +219,15 @@ After setup, just run \`abbie\` to start working.`;
|
|
|
214
219
|
this.logWarn("login failed or was cancelled — you can run `abbie login` later");
|
|
215
220
|
}
|
|
216
221
|
} else {
|
|
217
|
-
this.log(" [3/
|
|
222
|
+
this.log(" [3/4] authenticated ✓");
|
|
218
223
|
}
|
|
219
|
-
|
|
220
224
|
this.log("");
|
|
221
|
-
|
|
225
|
+
|
|
226
|
+
// Step 4: Connect AI provider
|
|
227
|
+
await ensureProviderConnected((msg) => this.log(msg));
|
|
228
|
+
|
|
222
229
|
this.log("");
|
|
223
|
-
this.log("
|
|
224
|
-
this.log(" (Anthropic, OpenAI, Google, or GitHub Copilot)");
|
|
230
|
+
this.log(" setup complete ✓");
|
|
225
231
|
}
|
|
226
232
|
|
|
227
233
|
/**
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider OAuth Authentication
|
|
3
|
+
*
|
|
4
|
+
* Runs AI provider OAuth flows (Anthropic, OpenAI, Google, GitHub Copilot)
|
|
5
|
+
* using Pi's OAuth libraries, resolved dynamically from Pi's global install.
|
|
6
|
+
*
|
|
7
|
+
* Writes credentials to ~/.pi/agent/auth.json (Pi's auth storage format).
|
|
8
|
+
* This means Pi starts with providers already configured — no /login needed.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { execSync } from "node:child_process";
|
|
15
|
+
import { createInterface } from "node:readline";
|
|
16
|
+
import { exec } from "node:child_process";
|
|
17
|
+
|
|
18
|
+
const AUTH_JSON = join(homedir(), ".pi", "agent", "auth.json");
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// Auth.json helpers
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
export interface ProviderCredential {
|
|
25
|
+
type: "oauth";
|
|
26
|
+
refresh: string;
|
|
27
|
+
access: string;
|
|
28
|
+
expires: number;
|
|
29
|
+
[key: string]: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type AuthData = Record<string, ProviderCredential>;
|
|
33
|
+
|
|
34
|
+
export function readAuthJson(): AuthData {
|
|
35
|
+
try {
|
|
36
|
+
if (!existsSync(AUTH_JSON)) return {};
|
|
37
|
+
return JSON.parse(readFileSync(AUTH_JSON, "utf8")) as AuthData;
|
|
38
|
+
} catch {
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function writeAuthJson(data: AuthData): void {
|
|
44
|
+
const dir = join(homedir(), ".pi", "agent");
|
|
45
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
46
|
+
writeFileSync(AUTH_JSON, JSON.stringify(data, null, 2), "utf8");
|
|
47
|
+
chmodSync(AUTH_JSON, 0o600);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getConnectedProviders(): string[] {
|
|
51
|
+
const data = readAuthJson();
|
|
52
|
+
return Object.entries(data)
|
|
53
|
+
.filter(([, v]) => v?.type === "oauth" && v.access)
|
|
54
|
+
.map(([k]) => k);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function hasAnyProvider(): boolean {
|
|
58
|
+
return getConnectedProviders().length > 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ============================================================================
|
|
62
|
+
// Provider info
|
|
63
|
+
// ============================================================================
|
|
64
|
+
|
|
65
|
+
export interface ProviderInfo {
|
|
66
|
+
id: string;
|
|
67
|
+
name: string;
|
|
68
|
+
description: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const PROVIDERS: ProviderInfo[] = [
|
|
72
|
+
{ id: "anthropic", name: "Anthropic", description: "Claude Pro/Max subscription" },
|
|
73
|
+
{ id: "openai-codex", name: "OpenAI", description: "ChatGPT Plus/Pro subscription" },
|
|
74
|
+
{ id: "google-gemini-cli", name: "Google Gemini", description: "Google Cloud Code Assist" },
|
|
75
|
+
{ id: "github-copilot", name: "GitHub Copilot", description: "Copilot Individual/Business" },
|
|
76
|
+
{ id: "google-antigravity", name: "Google Antigravity", description: "Gemini 3, Claude, GPT via Google Cloud" },
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
// ============================================================================
|
|
80
|
+
// Dynamic import of Pi's OAuth module
|
|
81
|
+
// ============================================================================
|
|
82
|
+
|
|
83
|
+
interface OAuthModule {
|
|
84
|
+
getOAuthProvider: (id: string) => any;
|
|
85
|
+
getOAuthProviders: () => any[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function importPiOAuth(): Promise<OAuthModule | null> {
|
|
89
|
+
// Strategy: find @mariozechner/pi-ai from Pi's global install
|
|
90
|
+
const candidates: string[] = [];
|
|
91
|
+
|
|
92
|
+
// 1. Try resolving from pi binary location
|
|
93
|
+
try {
|
|
94
|
+
const piPath = execSync("which pi", { encoding: "utf8", timeout: 3000 }).trim();
|
|
95
|
+
if (piPath) {
|
|
96
|
+
const { realpathSync } = await import("node:fs");
|
|
97
|
+
const realPath = realpathSync(piPath);
|
|
98
|
+
// pi binary -> bin/pi.mjs -> package root -> node_modules/@mariozechner/pi-ai
|
|
99
|
+
const pkgRoot = join(realPath, "..", "..");
|
|
100
|
+
candidates.push(join(pkgRoot, "node_modules", "@mariozechner", "pi-ai", "dist", "index.js"));
|
|
101
|
+
}
|
|
102
|
+
} catch { /* not found */ }
|
|
103
|
+
|
|
104
|
+
// 2. Try common global paths
|
|
105
|
+
const nodeVersion = process.version.replace("v", "");
|
|
106
|
+
candidates.push(
|
|
107
|
+
join(homedir(), ".nvm", "versions", "node", `v${nodeVersion}`, "lib", "node_modules", "@mariozechner", "pi-coding-agent", "node_modules", "@mariozechner", "pi-ai", "dist", "index.js"),
|
|
108
|
+
join("/usr", "local", "lib", "node_modules", "@mariozechner", "pi-coding-agent", "node_modules", "@mariozechner", "pi-ai", "dist", "index.js"),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
for (const candidate of candidates) {
|
|
112
|
+
if (existsSync(candidate)) {
|
|
113
|
+
try {
|
|
114
|
+
return await import(candidate) as OAuthModule;
|
|
115
|
+
} catch { /* import failed */ }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ============================================================================
|
|
123
|
+
// CLI OAuth flow helpers
|
|
124
|
+
// ============================================================================
|
|
125
|
+
|
|
126
|
+
function prompt(question: string): Promise<string> {
|
|
127
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
128
|
+
return new Promise((resolve) => {
|
|
129
|
+
rl.question(question, (answer) => {
|
|
130
|
+
rl.close();
|
|
131
|
+
resolve(answer.trim());
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function openBrowser(url: string): void {
|
|
137
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
138
|
+
exec(`${cmd} "${url}"`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ============================================================================
|
|
142
|
+
// Public API
|
|
143
|
+
// ============================================================================
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Run the provider selection + OAuth flow.
|
|
147
|
+
* Returns the provider ID that was connected, or null if cancelled.
|
|
148
|
+
*/
|
|
149
|
+
export async function connectProvider(
|
|
150
|
+
log: (msg: string) => void,
|
|
151
|
+
): Promise<string | null> {
|
|
152
|
+
const piOAuth = await importPiOAuth();
|
|
153
|
+
|
|
154
|
+
if (!piOAuth) {
|
|
155
|
+
log(" ⚠ could not find Pi's OAuth module");
|
|
156
|
+
log(" connect providers in Pi instead: /login");
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const connected = getConnectedProviders();
|
|
161
|
+
|
|
162
|
+
// Show provider list
|
|
163
|
+
log("");
|
|
164
|
+
log(" connect an AI provider");
|
|
165
|
+
log(" ─────────────────────");
|
|
166
|
+
log("");
|
|
167
|
+
|
|
168
|
+
const providers = piOAuth.getOAuthProviders();
|
|
169
|
+
for (let i = 0; i < providers.length; i++) {
|
|
170
|
+
const p = providers[i];
|
|
171
|
+
const isConnected = connected.includes(p.id);
|
|
172
|
+
const status = isConnected ? " ✓ connected" : "";
|
|
173
|
+
log(` ${i + 1}. ${p.name}${status}`);
|
|
174
|
+
}
|
|
175
|
+
log(` ${providers.length + 1}. skip for now`);
|
|
176
|
+
log("");
|
|
177
|
+
|
|
178
|
+
const choice = await prompt(" select (1-" + (providers.length + 1) + "): ");
|
|
179
|
+
const idx = parseInt(choice, 10) - 1;
|
|
180
|
+
|
|
181
|
+
if (isNaN(idx) || idx < 0 || idx >= providers.length) {
|
|
182
|
+
return null; // skip
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const provider = providers[idx];
|
|
186
|
+
log("");
|
|
187
|
+
log(` connecting ${provider.name}...`);
|
|
188
|
+
log("");
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const credentials = await provider.login({
|
|
192
|
+
onAuth: (info: { url: string; instructions?: string }) => {
|
|
193
|
+
log(` open this URL to authenticate:`);
|
|
194
|
+
log(` ${info.url}`);
|
|
195
|
+
if (info.instructions) {
|
|
196
|
+
log(` ${info.instructions}`);
|
|
197
|
+
}
|
|
198
|
+
log("");
|
|
199
|
+
openBrowser(info.url);
|
|
200
|
+
},
|
|
201
|
+
onPrompt: async (promptInfo: { message: string; placeholder?: string; allowEmpty?: boolean }) => {
|
|
202
|
+
const answer = await prompt(` ${promptInfo.message} `);
|
|
203
|
+
if (!answer && !promptInfo.allowEmpty) {
|
|
204
|
+
throw new Error("Input required");
|
|
205
|
+
}
|
|
206
|
+
return answer;
|
|
207
|
+
},
|
|
208
|
+
onProgress: (message: string) => {
|
|
209
|
+
log(` ${message}`);
|
|
210
|
+
},
|
|
211
|
+
onManualCodeInput: async () => {
|
|
212
|
+
return await prompt(" paste the code or URL: ");
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// Write to auth.json
|
|
217
|
+
const data = readAuthJson();
|
|
218
|
+
data[provider.id] = { type: "oauth", ...credentials };
|
|
219
|
+
writeAuthJson(data);
|
|
220
|
+
|
|
221
|
+
log(` ✓ ${provider.name} connected`);
|
|
222
|
+
return provider.id;
|
|
223
|
+
} catch (err) {
|
|
224
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
225
|
+
if (msg.includes("cancelled")) {
|
|
226
|
+
log(" cancelled");
|
|
227
|
+
} else {
|
|
228
|
+
log(` ✗ connection failed: ${msg}`);
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Check providers and optionally prompt for connection.
|
|
236
|
+
* Used in the setup wizard.
|
|
237
|
+
*/
|
|
238
|
+
export async function ensureProviderConnected(
|
|
239
|
+
log: (msg: string) => void,
|
|
240
|
+
): Promise<boolean> {
|
|
241
|
+
const connected = getConnectedProviders();
|
|
242
|
+
|
|
243
|
+
if (connected.length > 0) {
|
|
244
|
+
const names = connected.map((id) => {
|
|
245
|
+
const info = PROVIDERS.find((p) => p.id === id);
|
|
246
|
+
return info?.name ?? id;
|
|
247
|
+
});
|
|
248
|
+
log(` [4/4] providers: ${names.join(", ")} ✓`);
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
log(" [4/4] connect an AI provider");
|
|
253
|
+
log(" (uses your existing subscription — no API keys needed)");
|
|
254
|
+
log("");
|
|
255
|
+
|
|
256
|
+
const result = await connectProvider(log);
|
|
257
|
+
return result !== null;
|
|
258
|
+
}
|