@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.
@@ -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. Launch Pi (extension auto-starts bridge)
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;;;;;;;;;;;GAWG;AAQH,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAmCjD,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;YAsDnB,QAAQ;IAoFtB;;;OAGG;IACH,OAAO,CAAC,mBAAmB;CAmB5B"}
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. Launch Pi (extension auto-starts bridge)
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 needsSetup = flags.setup || !pi.installed || !configured || !extOk;
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/3] installing pi...");
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/3] pi ${pi.version} ✓`);
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/3] installing abbie extension...");
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/3] extension ✓");
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/3] authenticating...");
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/3] authenticated ✓");
202
+ this.log(" [3/4] authenticated ✓");
198
203
  }
199
204
  this.log("");
200
- this.log(" setup complete");
205
+ // Step 4: Connect AI provider
206
+ await ensureProviderConnected((msg) => this.log(msg));
201
207
  this.log("");
202
- this.log(" tip: open Pi and type /login to connect an AI provider");
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
  // ===========================================================================
@@ -11672,5 +11672,5 @@
11672
11672
  "summary": "Watch workspace windows (tmux panes) and stream updates"
11673
11673
  }
11674
11674
  },
11675
- "version": "0.1.0"
11675
+ "version": "0.1.3"
11676
11676
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@creativeintelligence/abbie",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Abbie — agent orchestration CLI",
@@ -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. Launch Pi (extension auto-starts bridge)
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 needsSetup = flags.setup || !pi.installed || !configured || !extOk;
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/3] installing pi...");
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/3] pi ${pi.version} ✓`);
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/3] installing abbie extension...");
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/3] extension ✓");
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/3] authenticating...");
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/3] authenticated ✓");
222
+ this.log(" [3/4] authenticated ✓");
218
223
  }
219
-
220
224
  this.log("");
221
- this.log(" setup complete");
225
+
226
+ // Step 4: Connect AI provider
227
+ await ensureProviderConnected((msg) => this.log(msg));
228
+
222
229
  this.log("");
223
- this.log(" tip: open Pi and type /login to connect an AI provider");
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
+ }