pi-clinepass 0.1.0 → 0.1.2

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/src/auth.ts CHANGED
@@ -1,363 +1,375 @@
1
- /**
2
- * ClinePass authentication: credential resolution and the /login flow.
3
- *
4
- * Login presents a chooser (reuse existing credentials / device flow /
5
- * paste API key); the user picks explicitly every time.
6
- */
7
-
8
- import type { OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai";
9
- import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
- import { homedir } from "node:os";
11
- import { join } from "node:path";
12
- import {
13
- DEFAULT_API_BASE,
14
- WORKOS_REFRESH_MARGIN_MS,
15
- isWorkosToken,
16
- pollDeviceAuthorization,
17
- refreshWorkosToken,
18
- registerWorkOSTokens,
19
- startDeviceAuthorization,
20
- type ClineAuthCredentials,
21
- } from "./workos.js";
22
-
23
- export const DASHBOARD_URL = "https://app.cline.bot/dashboard/account?tab=api-keys";
24
- export const TEN_YEARS_MS = 10 * 365 * 24 * 60 * 60 * 1000;
25
-
26
- export interface AuthOptions {
27
- homeDir?: () => string;
28
- readFile?: (path: string) => string;
29
- fileExists?: (path: string) => boolean;
30
- writeFile?: (path: string, data: string) => void;
31
- fetch?: typeof globalThis.fetch;
32
- apiBase?: string;
33
- }
34
-
35
- function defaultHomeDir(): string {
36
- return homedir();
37
- }
38
-
39
- function resolveOptions(
40
- options: AuthOptions,
41
- ): Required<Pick<AuthOptions, "homeDir" | "readFile" | "fileExists" | "writeFile">> {
42
- return {
43
- homeDir: options.homeDir ?? defaultHomeDir,
44
- readFile: options.readFile ?? ((p) => readFileSync(p, "utf8")),
45
- fileExists: options.fileExists ?? ((p) => existsSync(p)),
46
- writeFile: options.writeFile ?? defaultWriteFile,
47
- };
48
- }
49
-
50
- /** Atomic-ish write matching pi's auth.json conventions (JSON, 0600 on create). */
51
- function defaultWriteFile(path: string, data: string): void {
52
- const tmp = `${path}.tmp`;
53
- writeFileSync(tmp, data, { encoding: "utf8", mode: 0o600 });
54
- try {
55
- // rename replaces atomically on POSIX; on Windows it may fail if the
56
- // target is open — fall back to a direct write in that case.
57
- renameSync(tmp, path);
58
- } catch {
59
- writeFileSync(path, data, { encoding: "utf8", mode: 0o600 });
60
- }
61
- }
62
-
63
- function isRecord(value: unknown): value is Record<string, unknown> {
64
- return typeof value === "object" && value !== null && !Array.isArray(value);
65
- }
66
-
67
- function stringValue(value: unknown): string | undefined {
68
- return typeof value === "string" ? value : undefined;
69
- }
70
-
71
- function numberValue(value: unknown): number | undefined {
72
- if (typeof value === "number" && Number.isFinite(value)) return value;
73
- return undefined;
74
- }
75
-
76
- function isMissingFileError(error: unknown): boolean {
77
- if (typeof error === "object" && error !== null && "code" in error) {
78
- if ((error as { code?: unknown }).code === "ENOENT") return true;
79
- }
80
- const msg = error instanceof Error ? error.message : String(error);
81
- return msg.includes("ENOENT") || msg.includes("not found");
82
- }
83
-
84
- function parseJson(path: string, options: AuthOptions): Record<string, unknown> | undefined {
85
- const { readFile, fileExists } = resolveOptions(options);
86
- try {
87
- if (!fileExists(path)) return undefined;
88
- const parsed: unknown = JSON.parse(readFile(path));
89
- return isRecord(parsed) ? parsed : undefined;
90
- } catch (error) {
91
- if (!isMissingFileError(error)) {
92
- console.warn(`[pi-clinepass] failed to read ${path}: ${error instanceof Error ? error.message : String(error)}`);
93
- }
94
- return undefined;
95
- }
96
- }
97
-
98
- // ─── Credential extraction ────────────────────────────────────────────────
99
-
100
- function extractWorkosAuth(auth: Record<string, unknown>): ClineAuthCredentials | undefined {
101
- const accessToken = stringValue(auth.accessToken);
102
- const refreshToken = stringValue(auth.refreshToken);
103
- if (!accessToken || !refreshToken || !isWorkosToken(accessToken)) return undefined;
104
- return {
105
- accessToken,
106
- refreshToken,
107
- expiresAt: numberValue(auth.expiresAt) ?? 0,
108
- };
109
- }
110
-
111
- /**
112
- * Read credentials a Cline CLI login left on this machine
113
- * (`~/.cline/data/settings/providers.json`) — offered as the "reuse"
114
- * option in /login.
115
- */
116
- export function resolveClineCliCredential(options: AuthOptions = {}): ClineAuthCredentials | undefined {
117
- const home = resolveOptions(options).homeDir();
118
- const parsed = parseJson(join(home, ".cline", "data", "settings", "providers.json"), options);
119
- if (!parsed) return undefined;
120
-
121
- const providers = isRecord(parsed.providers) ? parsed.providers : undefined;
122
- const candidates: ClineAuthCredentials[] = [];
123
- for (const key of ["cline-pass", "cline"] as const) {
124
- const provider = isRecord(providers?.[key]) ? providers[key] : undefined;
125
- const settings = isRecord(provider?.settings) ? provider.settings : undefined;
126
- const auth = isRecord(settings?.auth) ? settings.auth : undefined;
127
- if (auth) {
128
- const cred = extractWorkosAuth(auth);
129
- if (cred) candidates.push(cred);
130
- }
131
- }
132
- if (candidates.length > 0) {
133
- return candidates.reduce((best, current) => (current.expiresAt > best.expiresAt ? current : best));
134
- }
135
- return undefined;
136
- }
137
-
138
- /**
139
- * Read pi's OWN stored credential for the `clinepass` provider
140
- * (`~/.pi/agent/auth.json` → `clinepass` field). This is the identity pi
141
- * itself uses for chat requests after /login — the meter must match it.
142
- * Handles: plain string key, `{type:"api_key", key}`, and OAuth objects.
143
- */
144
- export function resolvePiStoredCredential(options: AuthOptions = {}): ClineAuthCredentials | undefined {
145
- const home = resolveOptions(options).homeDir();
146
- const parsed = parseJson(join(home, ".pi", "agent", "auth.json"), options);
147
- if (!parsed) return undefined;
148
-
149
- const cp = parsed.clinepass;
150
- if (typeof cp === "string" && cp.trim()) {
151
- return { accessToken: cp.trim(), refreshToken: cp.trim(), expiresAt: Number.MAX_SAFE_INTEGER };
152
- }
153
- if (!isRecord(cp)) return undefined;
154
-
155
- if (cp.type === "api_key") {
156
- const key = stringValue(cp.key);
157
- return key ? { accessToken: key, refreshToken: key, expiresAt: Number.MAX_SAFE_INTEGER } : undefined;
158
- }
159
-
160
- const access = stringValue(cp.access);
161
- const refresh = stringValue(cp.refresh);
162
- if (!access) return undefined;
163
- const isOAuth = isWorkosToken(access);
164
- return {
165
- accessToken: access,
166
- refreshToken: refresh ?? access,
167
- // Bare non-prefixed access from pi's store is a static key; WorkOS
168
- // tokens with no expiry are treated as stale so they get refreshed.
169
- expiresAt: numberValue(cp.expires) ?? (isOAuth ? 0 : Number.MAX_SAFE_INTEGER),
170
- };
171
- }
172
-
173
- // ─── Credential persistence ─────────────────────────────────────
174
-
175
- /**
176
- * Persist an OAuth credential for the `clinepass` provider back into pi's
177
- * auth.json (`~/.pi/agent/auth.json`). Used after a meter-triggered refresh
178
- * produced rotated tokens — if the server rotates single-use refresh
179
- * tokens, discarding the new one would leave pi's stored credential dead
180
- * and force a re-login. Waits for pi's lock file (`<auth.json>.lock`, held
181
- * by proper-lockfile while pi writes) to clear before merging.
182
- */
183
- export async function persistOAuthCredential(
184
- credential: OAuthCredentials,
185
- options: AuthOptions = {},
186
- ): Promise<void> {
187
- const { homeDir, readFile, fileExists, writeFile } = resolveOptions(options);
188
- const authPath = join(homeDir(), ".pi", "agent", "auth.json");
189
- const lockPath = `${authPath}.lock`;
190
-
191
- for (let attempt = 0; attempt < 20 && fileExists(lockPath); attempt++) {
192
- await new Promise((resolve) => setTimeout(resolve, 100));
193
- }
194
-
195
- const current = fileExists(authPath) ? readFile(authPath) : "{}";
196
- const parsed: unknown = current.trim() ? JSON.parse(current) : {};
197
- if (!isRecord(parsed)) {
198
- throw new Error("auth.json is not a JSON object");
199
- }
200
- const next = {
201
- ...parsed,
202
- clinepass: {
203
- type: "oauth",
204
- access: credential.access,
205
- refresh: credential.refresh,
206
- expires: credential.expires,
207
- },
208
- };
209
- writeFile(authPath, JSON.stringify(next, null, 2));
210
- }
211
-
212
- // ─── Login chain ──────────────────────────────────────────────────────────
213
-
214
- function credentialsFromApiKey(apiKey: string): OAuthCredentials {
215
- return { refresh: apiKey, access: apiKey, expires: Date.now() + TEN_YEARS_MS };
216
- }
217
-
218
- async function loginWithWorkosCredentials(
219
- clineAuth: ClineAuthCredentials,
220
- options: AuthOptions,
221
- ): Promise<OAuthCredentials> {
222
- if (clineAuth.expiresAt <= Date.now() + WORKOS_REFRESH_MARGIN_MS) {
223
- return refreshWorkosToken(
224
- { access: clineAuth.accessToken, refresh: clineAuth.refreshToken, expires: clineAuth.expiresAt },
225
- { fetch: options.fetch, apiBase: options.apiBase ?? DEFAULT_API_BASE },
226
- );
227
- }
228
- return {
229
- access: clineAuth.accessToken,
230
- refresh: clineAuth.refreshToken,
231
- expires: clineAuth.expiresAt,
232
- };
233
- }
234
-
235
- async function loginWithDeviceFlow(
236
- callbacks: OAuthLoginCallbacks,
237
- options: AuthOptions,
238
- ): Promise<OAuthCredentials> {
239
- const device = await startDeviceAuthorization({ fetch: options.fetch });
240
- callbacks.onDeviceCode({
241
- userCode: device.userCode,
242
- verificationUri: device.verificationUri,
243
- intervalSeconds: device.intervalSeconds,
244
- expiresInSeconds: device.expiresInSeconds,
245
- });
246
- callbacks.onAuth({ url: device.verificationUriComplete ?? device.verificationUri });
247
- const tokens = await pollDeviceAuthorization(
248
- {
249
- deviceCode: device.deviceCode,
250
- expiresInSeconds: device.expiresInSeconds,
251
- intervalSeconds: device.intervalSeconds,
252
- },
253
- { fetch: options.fetch },
254
- );
255
- return registerWorkOSTokens(tokens, { fetch: options.fetch, apiBase: options.apiBase ?? DEFAULT_API_BASE });
256
- }
257
-
258
- async function loginWithManualApiKey(
259
- callbacks: OAuthLoginCallbacks,
260
- options: AuthOptions,
261
- reason?: string,
262
- ): Promise<OAuthCredentials> {
263
- const apiKey = sanitizeApiKey(
264
- await callbacks.onPrompt({
265
- message:
266
- (reason ? `${reason} ` : "") +
267
- `Enter your ClinePass API key (get one at ${DASHBOARD_URL}):`,
268
- }),
269
- );
270
- if (!apiKey) throw new Error("No ClinePass API key provided");
271
- if (apiKey.length < 20) {
272
- console.warn(
273
- `[pi-clinepass] Warning: API key looks unusually short (${apiKey.length} chars). ` +
274
- "Verify you copied the full key from app.cline.bot.",
275
- );
276
- }
277
- return credentialsFromApiKey(apiKey);
278
- }
279
-
280
- /** Strip terminal paste wrappers and control chars from pasted key input. */
281
- export function sanitizeApiKey(input: string): string {
282
- const esc = "\x1b";
283
- return input
284
- .replaceAll(`${esc}[200~`, "")
285
- .replaceAll(`${esc}[201~`, "")
286
- .replaceAll("[200~", "")
287
- .replaceAll("[201~", "")
288
- .replace(/[\u0000-\u001f\u007f]/g, "")
289
- .trim();
290
- }
291
-
292
- /**
293
- * Run the ClinePass login flow: present a chooser (reuse the Cline CLI
294
- * login when one exists on this machine, device flow, or enter an API
295
- * key). The user picks explicitly every time.
296
- */
297
- export async function login(callbacks: OAuthLoginCallbacks, options: AuthOptions = {}): Promise<OAuthCredentials> {
298
- // Reuse candidate comes from a Cline CLI login only — never pi's own
299
- // stored credential.
300
- const reusable = resolveClineCliCredential(options);
301
-
302
- const optionsList: { id: string; label: string }[] = [
303
- { id: "device", label: "Sign in with browser" },
304
- { id: "paste", label: "Enter API key" },
305
- ];
306
- if (reusable) {
307
- optionsList.unshift({ id: "reuse", label: "Use existing sign-in (Cline CLI)" });
308
- }
309
-
310
- const selected = await callbacks.onSelect({
311
- message: "Choose how to sign in to ClinePass",
312
- options: optionsList,
313
- });
314
- if (!selected) throw new Error("ClinePass login cancelled");
315
-
316
- switch (selected) {
317
- case "reuse": {
318
- if (!reusable) throw new Error("ClinePass login cancelled");
319
- try {
320
- return await loginWithWorkosCredentials(reusable, options);
321
- } catch (err) {
322
- console.warn(
323
- `[pi-clinepass] Cline CLI sign-in failed: ${err instanceof Error ? err.message : String(err)}`,
324
- );
325
- return loginWithManualApiKey(callbacks, options, "Cline CLI sign-in failed (refresh token may be expired).");
326
- }
327
- }
328
- case "paste":
329
- return loginWithManualApiKey(callbacks, options);
330
- case "device":
331
- return loginWithDeviceFlow(callbacks, options).catch((err) => {
332
- console.warn(
333
- `[pi-clinepass] Device login failed: ${err instanceof Error ? err.message : String(err)}`,
334
- );
335
- return loginWithManualApiKey(callbacks, options, "Device login failed or was cancelled.");
336
- });
337
- default:
338
- throw new Error("ClinePass login cancelled");
339
- }
340
- }
341
-
342
- /**
343
- * Refresh ClinePass credentials. WorkOS tokens (workos: prefix) go through
344
- * Cline's server endpoint; static API keys never expire and pass through.
345
- */
346
- export async function refreshToken(
347
- credentials: OAuthCredentials,
348
- _signal?: AbortSignal,
349
- options: AuthOptions = {},
350
- ): Promise<OAuthCredentials> {
351
- if (isWorkosToken(credentials.access)) {
352
- return refreshWorkosToken(credentials, { fetch: options.fetch, apiBase: options.apiBase ?? DEFAULT_API_BASE });
353
- }
354
- return credentialsFromApiKey(credentials.refresh);
355
- }
356
-
357
- /**
358
- * Return the bearer token. OAuth flows already store the `workos:` prefix
359
- * (refresh/register ensure it); static API keys are used as-is.
360
- */
361
- export function getApiKey(credentials: OAuthCredentials): string {
362
- return credentials.access;
363
- }
1
+ /**
2
+ * ClinePass authentication: credential resolution and the /login flow.
3
+ *
4
+ * Login presents a chooser (reuse existing credentials / device flow /
5
+ * paste API key); the user picks explicitly every time.
6
+ */
7
+
8
+ import type { OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai";
9
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ import {
13
+ DEFAULT_API_BASE,
14
+ WORKOS_REFRESH_MARGIN_MS,
15
+ isWorkosToken,
16
+ pollDeviceAuthorization,
17
+ refreshWorkosToken,
18
+ registerWorkOSTokens,
19
+ startDeviceAuthorization,
20
+ type ClineAuthCredentials,
21
+ } from "./workos.js";
22
+
23
+ export const DASHBOARD_URL = "https://app.cline.bot/dashboard/account?tab=api-keys";
24
+ export const TEN_YEARS_MS = 10 * 365 * 24 * 60 * 60 * 1000;
25
+
26
+ export interface AuthOptions {
27
+ homeDir?: () => string;
28
+ readFile?: (path: string) => string;
29
+ fileExists?: (path: string) => boolean;
30
+ writeFile?: (path: string, data: string) => void;
31
+ fetch?: typeof globalThis.fetch;
32
+ apiBase?: string;
33
+ }
34
+
35
+ function defaultHomeDir(): string {
36
+ return homedir();
37
+ }
38
+
39
+ function resolveOptions(
40
+ options: AuthOptions,
41
+ ): Required<Pick<AuthOptions, "homeDir" | "readFile" | "fileExists" | "writeFile">> {
42
+ return {
43
+ homeDir: options.homeDir ?? defaultHomeDir,
44
+ readFile: options.readFile ?? ((p) => readFileSync(p, "utf8")),
45
+ fileExists: options.fileExists ?? ((p) => existsSync(p)),
46
+ writeFile: options.writeFile ?? defaultWriteFile,
47
+ };
48
+ }
49
+
50
+ /** Atomic-ish write matching pi's auth.json conventions (JSON, 0600 on create). */
51
+ function defaultWriteFile(path: string, data: string): void {
52
+ const tmp = `${path}.tmp`;
53
+ writeFileSync(tmp, data, { encoding: "utf8", mode: 0o600 });
54
+ try {
55
+ // rename replaces atomically on POSIX; on Windows it may fail if the
56
+ // target is open — fall back to a direct write in that case.
57
+ renameSync(tmp, path);
58
+ } catch {
59
+ writeFileSync(path, data, { encoding: "utf8", mode: 0o600 });
60
+ }
61
+ }
62
+
63
+ function isRecord(value: unknown): value is Record<string, unknown> {
64
+ return typeof value === "object" && value !== null && !Array.isArray(value);
65
+ }
66
+
67
+ function stringValue(value: unknown): string | undefined {
68
+ return typeof value === "string" ? value : undefined;
69
+ }
70
+
71
+ function numberValue(value: unknown): number | undefined {
72
+ if (typeof value === "number" && Number.isFinite(value)) return value;
73
+ return undefined;
74
+ }
75
+
76
+ function isMissingFileError(error: unknown): boolean {
77
+ if (typeof error === "object" && error !== null && "code" in error) {
78
+ if ((error as { code?: unknown }).code === "ENOENT") return true;
79
+ }
80
+ const msg = error instanceof Error ? error.message : String(error);
81
+ return msg.includes("ENOENT") || msg.includes("not found");
82
+ }
83
+
84
+ function parseJson(path: string, options: AuthOptions): Record<string, unknown> | undefined {
85
+ const { readFile, fileExists } = resolveOptions(options);
86
+ try {
87
+ if (!fileExists(path)) return undefined;
88
+ const parsed: unknown = JSON.parse(readFile(path));
89
+ return isRecord(parsed) ? parsed : undefined;
90
+ } catch (error) {
91
+ if (!isMissingFileError(error)) {
92
+ console.warn(`[pi-clinepass] failed to read ${path}: ${error instanceof Error ? error.message : String(error)}`);
93
+ }
94
+ return undefined;
95
+ }
96
+ }
97
+
98
+ // ─── Credential extraction ────────────────────────────────────────────────
99
+
100
+ function extractWorkosAuth(auth: Record<string, unknown>): ClineAuthCredentials | undefined {
101
+ const accessToken = stringValue(auth.accessToken);
102
+ const refreshToken = stringValue(auth.refreshToken);
103
+ if (!accessToken || !refreshToken || !isWorkosToken(accessToken)) return undefined;
104
+ return {
105
+ accessToken,
106
+ refreshToken,
107
+ expiresAt: numberValue(auth.expiresAt) ?? 0,
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Read credentials a Cline CLI login left on this machine
113
+ * (`~/.cline/data/settings/providers.json`) — offered as the "reuse"
114
+ * option in /login.
115
+ */
116
+ export function resolveClineCliCredential(options: AuthOptions = {}): ClineAuthCredentials | undefined {
117
+ const home = resolveOptions(options).homeDir();
118
+ const parsed = parseJson(join(home, ".cline", "data", "settings", "providers.json"), options);
119
+ if (!parsed) return undefined;
120
+
121
+ const providers = isRecord(parsed.providers) ? parsed.providers : undefined;
122
+ const candidates: ClineAuthCredentials[] = [];
123
+ for (const key of ["cline-pass", "cline"] as const) {
124
+ const provider = isRecord(providers?.[key]) ? providers[key] : undefined;
125
+ const settings = isRecord(provider?.settings) ? provider.settings : undefined;
126
+ const auth = isRecord(settings?.auth) ? settings.auth : undefined;
127
+ if (auth) {
128
+ const cred = extractWorkosAuth(auth);
129
+ if (cred) candidates.push(cred);
130
+ }
131
+ }
132
+ if (candidates.length > 0) {
133
+ return candidates.reduce((best, current) => (current.expiresAt > best.expiresAt ? current : best));
134
+ }
135
+ return undefined;
136
+ }
137
+
138
+ /**
139
+ * Read pi's OWN stored credential for the `clinepass` provider
140
+ * (`~/.pi/agent/auth.json` → `clinepass` field). This is the identity pi
141
+ * itself uses for chat requests after /login — the meter must match it.
142
+ * Handles: plain string key, `{type:"api_key", key}`, and OAuth objects.
143
+ */
144
+ export function resolvePiStoredCredential(options: AuthOptions = {}): ClineAuthCredentials | undefined {
145
+ const home = resolveOptions(options).homeDir();
146
+ const parsed = parseJson(join(home, ".pi", "agent", "auth.json"), options);
147
+ if (!parsed) return undefined;
148
+
149
+ const cp = parsed.clinepass;
150
+ if (typeof cp === "string" && cp.trim()) {
151
+ return { accessToken: cp.trim(), refreshToken: cp.trim(), expiresAt: Number.MAX_SAFE_INTEGER };
152
+ }
153
+ if (!isRecord(cp)) return undefined;
154
+
155
+ if (cp.type === "api_key") {
156
+ const key = stringValue(cp.key);
157
+ return key ? { accessToken: key, refreshToken: key, expiresAt: Number.MAX_SAFE_INTEGER } : undefined;
158
+ }
159
+
160
+ const access = stringValue(cp.access);
161
+ const refresh = stringValue(cp.refresh);
162
+ if (!access) return undefined;
163
+ const isOAuth = isWorkosToken(access);
164
+ return {
165
+ accessToken: access,
166
+ refreshToken: refresh ?? access,
167
+ // Bare non-prefixed access from pi's store is a static key; WorkOS
168
+ // tokens with no expiry are treated as stale so they get refreshed.
169
+ expiresAt: numberValue(cp.expires) ?? (isOAuth ? 0 : Number.MAX_SAFE_INTEGER),
170
+ };
171
+ }
172
+
173
+ // ─── Credential persistence ─────────────────────────────────────
174
+
175
+ /**
176
+ * Persist an OAuth credential for the `clinepass` provider back into pi's
177
+ * auth.json (`~/.pi/agent/auth.json`). Used after a meter-triggered refresh
178
+ * produced rotated tokens — if the server rotates single-use refresh
179
+ * tokens, discarding the new one would leave pi's stored credential dead
180
+ * and force a re-login. Waits for pi's lock file (`<auth.json>.lock`, held
181
+ * by proper-lockfile while pi writes) to clear before merging, then verifies
182
+ * after writing: the meter refresh and pi's own chat-path refresh can fire at
183
+ * the same near-expiry moment, so if pi rewrote the file in between, the
184
+ * merge is redone once on top of the newer content instead of dropping it.
185
+ */
186
+ export async function persistOAuthCredential(
187
+ credential: OAuthCredentials,
188
+ options: AuthOptions = {},
189
+ ): Promise<void> {
190
+ const { homeDir, readFile, fileExists, writeFile } = resolveOptions(options);
191
+ const authPath = join(homeDir(), ".pi", "agent", "auth.json");
192
+ const lockPath = `${authPath}.lock`;
193
+ const entry = {
194
+ type: "oauth",
195
+ access: credential.access,
196
+ refresh: credential.refresh,
197
+ expires: credential.expires,
198
+ };
199
+
200
+ for (let pass = 0; pass < 2; pass++) {
201
+ for (let attempt = 0; attempt < 20 && fileExists(lockPath); attempt++) {
202
+ await new Promise((resolve) => setTimeout(resolve, 100));
203
+ }
204
+
205
+ const current = fileExists(authPath) ? readFile(authPath) : "{}";
206
+ const parsed: unknown = current.trim() ? JSON.parse(current) : {};
207
+ if (!isRecord(parsed)) {
208
+ throw new Error("auth.json is not a JSON object");
209
+ }
210
+ const serialized = JSON.stringify({ ...parsed, clinepass: entry }, null, 2);
211
+ writeFile(authPath, serialized);
212
+
213
+ let contended = false;
214
+ for (let attempt = 0; attempt < 20 && fileExists(lockPath); attempt++) {
215
+ contended = true;
216
+ await new Promise((resolve) => setTimeout(resolve, 100));
217
+ }
218
+ // Done only when pi never contended and the file still holds exactly what
219
+ // we wrote; otherwise loop once more and merge over the newer content.
220
+ if (!contended && (!fileExists(authPath) || readFile(authPath) === serialized)) return;
221
+ }
222
+ }
223
+
224
+ // ─── Login chain ──────────────────────────────────────────────────────────
225
+
226
+ function credentialsFromApiKey(apiKey: string): OAuthCredentials {
227
+ return { refresh: apiKey, access: apiKey, expires: Date.now() + TEN_YEARS_MS };
228
+ }
229
+
230
+ async function loginWithWorkosCredentials(
231
+ clineAuth: ClineAuthCredentials,
232
+ options: AuthOptions,
233
+ ): Promise<OAuthCredentials> {
234
+ if (clineAuth.expiresAt <= Date.now() + WORKOS_REFRESH_MARGIN_MS) {
235
+ return refreshWorkosToken(
236
+ { access: clineAuth.accessToken, refresh: clineAuth.refreshToken, expires: clineAuth.expiresAt },
237
+ { fetch: options.fetch, apiBase: options.apiBase ?? DEFAULT_API_BASE },
238
+ );
239
+ }
240
+ return {
241
+ access: clineAuth.accessToken,
242
+ refresh: clineAuth.refreshToken,
243
+ expires: clineAuth.expiresAt,
244
+ };
245
+ }
246
+
247
+ async function loginWithDeviceFlow(
248
+ callbacks: OAuthLoginCallbacks,
249
+ options: AuthOptions,
250
+ ): Promise<OAuthCredentials> {
251
+ const device = await startDeviceAuthorization({ fetch: options.fetch });
252
+ callbacks.onDeviceCode({
253
+ userCode: device.userCode,
254
+ verificationUri: device.verificationUri,
255
+ intervalSeconds: device.intervalSeconds,
256
+ expiresInSeconds: device.expiresInSeconds,
257
+ });
258
+ callbacks.onAuth({ url: device.verificationUriComplete ?? device.verificationUri });
259
+ const tokens = await pollDeviceAuthorization(
260
+ {
261
+ deviceCode: device.deviceCode,
262
+ expiresInSeconds: device.expiresInSeconds,
263
+ intervalSeconds: device.intervalSeconds,
264
+ },
265
+ { fetch: options.fetch },
266
+ );
267
+ return registerWorkOSTokens(tokens, { fetch: options.fetch, apiBase: options.apiBase ?? DEFAULT_API_BASE });
268
+ }
269
+
270
+ async function loginWithManualApiKey(
271
+ callbacks: OAuthLoginCallbacks,
272
+ options: AuthOptions,
273
+ reason?: string,
274
+ ): Promise<OAuthCredentials> {
275
+ const apiKey = sanitizeApiKey(
276
+ await callbacks.onPrompt({
277
+ message:
278
+ (reason ? `${reason} ` : "") +
279
+ `Enter your ClinePass API key (get one at ${DASHBOARD_URL}):`,
280
+ }),
281
+ );
282
+ if (!apiKey) throw new Error("No ClinePass API key provided");
283
+ if (apiKey.length < 20) {
284
+ console.warn(
285
+ `[pi-clinepass] Warning: API key looks unusually short (${apiKey.length} chars). ` +
286
+ "Verify you copied the full key from app.cline.bot.",
287
+ );
288
+ }
289
+ return credentialsFromApiKey(apiKey);
290
+ }
291
+
292
+ /** Strip terminal paste wrappers and control chars from pasted key input. */
293
+ export function sanitizeApiKey(input: string): string {
294
+ const esc = "\x1b";
295
+ return input
296
+ .replaceAll(`${esc}[200~`, "")
297
+ .replaceAll(`${esc}[201~`, "")
298
+ .replaceAll("[200~", "")
299
+ .replaceAll("[201~", "")
300
+ .replace(/[\u0000-\u001f\u007f]/g, "")
301
+ .trim();
302
+ }
303
+
304
+ /**
305
+ * Run the ClinePass login flow: present a chooser (reuse the Cline CLI
306
+ * login when one exists on this machine, device flow, or enter an API
307
+ * key). The user picks explicitly every time.
308
+ */
309
+ export async function login(callbacks: OAuthLoginCallbacks, options: AuthOptions = {}): Promise<OAuthCredentials> {
310
+ // Reuse candidate comes from a Cline CLI login only — never pi's own
311
+ // stored credential.
312
+ const reusable = resolveClineCliCredential(options);
313
+
314
+ const optionsList: { id: string; label: string }[] = [
315
+ { id: "device", label: "Sign in with browser" },
316
+ { id: "paste", label: "Enter API key" },
317
+ ];
318
+ if (reusable) {
319
+ optionsList.unshift({ id: "reuse", label: "Use existing sign-in (Cline CLI)" });
320
+ }
321
+
322
+ const selected = await callbacks.onSelect({
323
+ message: "Choose how to sign in to ClinePass",
324
+ options: optionsList,
325
+ });
326
+ if (!selected) throw new Error("ClinePass login cancelled");
327
+
328
+ switch (selected) {
329
+ case "reuse": {
330
+ if (!reusable) throw new Error("ClinePass login cancelled");
331
+ try {
332
+ return await loginWithWorkosCredentials(reusable, options);
333
+ } catch (err) {
334
+ console.warn(
335
+ `[pi-clinepass] Cline CLI sign-in failed: ${err instanceof Error ? err.message : String(err)}`,
336
+ );
337
+ return loginWithManualApiKey(callbacks, options, "Cline CLI sign-in failed (refresh token may be expired).");
338
+ }
339
+ }
340
+ case "paste":
341
+ return loginWithManualApiKey(callbacks, options);
342
+ case "device":
343
+ return loginWithDeviceFlow(callbacks, options).catch((err) => {
344
+ console.warn(
345
+ `[pi-clinepass] Device login failed: ${err instanceof Error ? err.message : String(err)}`,
346
+ );
347
+ return loginWithManualApiKey(callbacks, options, "Device login failed or was cancelled.");
348
+ });
349
+ default:
350
+ throw new Error("ClinePass login cancelled");
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Refresh ClinePass credentials. WorkOS tokens (workos: prefix) go through
356
+ * Cline's server endpoint; static API keys never expire and pass through.
357
+ */
358
+ export async function refreshToken(
359
+ credentials: OAuthCredentials,
360
+ _signal?: AbortSignal,
361
+ options: AuthOptions = {},
362
+ ): Promise<OAuthCredentials> {
363
+ if (isWorkosToken(credentials.access)) {
364
+ return refreshWorkosToken(credentials, { fetch: options.fetch, apiBase: options.apiBase ?? DEFAULT_API_BASE });
365
+ }
366
+ return credentialsFromApiKey(credentials.refresh);
367
+ }
368
+
369
+ /**
370
+ * Return the bearer token. OAuth flows already store the `workos:` prefix
371
+ * (refresh/register ensure it); static API keys are used as-is.
372
+ */
373
+ export function getApiKey(credentials: OAuthCredentials): string {
374
+ return credentials.access;
375
+ }