pi-clinepass 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +117 -0
- package/package.json +52 -0
- package/src/auth.ts +363 -0
- package/src/catalog.ts +117 -0
- package/src/errors.ts +112 -0
- package/src/headers.ts +80 -0
- package/src/index.ts +109 -0
- package/src/settings.ts +49 -0
- package/src/usage.ts +743 -0
- package/src/workos.ts +231 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 fific
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# pi-clinepass
|
|
4
|
+
|
|
5
|
+
**ClinePass provider for [pi](https://pi.dev) coding agent**
|
|
6
|
+
|
|
7
|
+
Dollar-based limits, live cost tracking, and plan cap reporting.
|
|
8
|
+
|
|
9
|
+
<br>
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<a href="https://www.npmjs.com/package/pi-clinepass"><img src="https://img.shields.io/badge/npm-pi--clinepass-CB3837?style=for-the-badge&logo=npm&logoColor=white" alt="npm package"></a>
|
|
13
|
+
<a href="https://www.typescriptlang.org"><img src="https://img.shields.io/badge/TypeScript-7.0-3178C6?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript 7"></a>
|
|
14
|
+
<a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node.js-%3E%3D22.19-339933?style=for-the-badge&logo=nodedotjs&logoColor=white" alt="Node version"></a>
|
|
15
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-000000?style=for-the-badge" alt="MIT License"></a>
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
<br>
|
|
19
|
+
|
|
20
|
+
<img src="https://raw.githubusercontent.com/fifidayone/pi-clinepass/main/assets/status.png" alt="pi-clinepass status meter" width="680">
|
|
21
|
+
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<br>
|
|
25
|
+
|
|
26
|
+
## Highlights
|
|
27
|
+
|
|
28
|
+
- **16 Models**: 13 ClinePass models on a dollar-based limit system plus 3 free tier models
|
|
29
|
+
- **Live Status Meter**: Per-turn and session cost directly in the pi footer
|
|
30
|
+
- **Plan Utilization**: 5-hour, weekly, and monthly caps tracked via `/clinepass`
|
|
31
|
+
- **Simple Auth**: One-time login via Cline CLI, browser, or API key
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
Requires **Node.js 22.19+** and [pi](https://pi.dev).
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
pi install npm:pi-clinepass
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Quickstart
|
|
46
|
+
|
|
47
|
+
### 1. Authenticate
|
|
48
|
+
|
|
49
|
+
Run the login command inside pi:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
/login
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Select **ClinePass** to choose your sign-in method:
|
|
56
|
+
|
|
57
|
+
<br>
|
|
58
|
+
|
|
59
|
+
<div align="center">
|
|
60
|
+
<img src="https://raw.githubusercontent.com/fifidayone/pi-clinepass/main/assets/login.png" alt="ClinePass login menu" width="620">
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<br>
|
|
64
|
+
|
|
65
|
+
- **Use existing sign-in (Cline CLI)**: keep using your Cline CLI account
|
|
66
|
+
- **Sign in with browser**: log in once in your browser
|
|
67
|
+
- **Enter API key**: paste your ClinePass key
|
|
68
|
+
|
|
69
|
+
### 2. Select a Model
|
|
70
|
+
|
|
71
|
+
Pick any model using the interactive selector:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
/model
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Report
|
|
80
|
+
|
|
81
|
+
View the live rate sheet and plan limit utilization anytime:
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
/clinepass
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
<div align="center">
|
|
88
|
+
<img src="https://raw.githubusercontent.com/fifidayone/pi-clinepass/main/assets/report.png" alt="ClinePass report with pricing and plan limits" width="620">
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Development
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
git clone https://github.com/fifidayone/pi-clinepass.git
|
|
97
|
+
cd pi-clinepass
|
|
98
|
+
npm install
|
|
99
|
+
npm test
|
|
100
|
+
npm run typecheck
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Support
|
|
106
|
+
|
|
107
|
+
pi-clinepass is free and will stay free. If you'd like to help, consider buying me a coffee:
|
|
108
|
+
|
|
109
|
+
<p align="left">
|
|
110
|
+
<a href="https://www.paypal.com/paypalme/fifidayone"><img src="https://img.shields.io/badge/PayPal-fifidayone-00457C?style=for-the-badge&logo=paypal&logoColor=white" alt="Support via PayPal"></a>
|
|
111
|
+
</p>
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
[MIT](LICENSE) © 2026 fifidayone
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-clinepass",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "ClinePass provider for pi — static measured-price catalog, server-truth billing meter, free models",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ai",
|
|
7
|
+
"cline",
|
|
8
|
+
"cline-pass",
|
|
9
|
+
"clinepass",
|
|
10
|
+
"pi",
|
|
11
|
+
"pi-coding-agent",
|
|
12
|
+
"pi-package",
|
|
13
|
+
"pi-extension"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "fifidayone",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/fifidayone/pi-clinepass.git"
|
|
20
|
+
},
|
|
21
|
+
"type": "module",
|
|
22
|
+
"main": "src/index.ts",
|
|
23
|
+
"types": "src/index.ts",
|
|
24
|
+
"files": [
|
|
25
|
+
"src",
|
|
26
|
+
"CHANGELOG.md",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=22"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "vitest run",
|
|
35
|
+
"test:watch": "vitest",
|
|
36
|
+
"typecheck": "tsc --noEmit"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@earendil-works/pi-ai": "*",
|
|
40
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^26.3.0",
|
|
44
|
+
"typescript": "^7.0.2",
|
|
45
|
+
"vitest": "^4.1.11"
|
|
46
|
+
},
|
|
47
|
+
"pi": {
|
|
48
|
+
"extensions": [
|
|
49
|
+
"./src/index.ts"
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,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.
|
|
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
|
+
}
|
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClinePass static model catalog.
|
|
3
|
+
*
|
|
4
|
+
* Prices are the measured billing rates from the Cline `/usages` API
|
|
5
|
+
* (verified against real usage), not the published reference table which
|
|
6
|
+
* drifts from actual billing. `cacheWrite` is kept at 0: pi's cost model
|
|
7
|
+
* requires the field, but we do not display or track cache-write pricing.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
11
|
+
|
|
12
|
+
export type ThinkingLevelMap = Readonly<Record<ThinkingLevel, string | null>>;
|
|
13
|
+
|
|
14
|
+
export interface ClinePassModel {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
reasoning: boolean;
|
|
18
|
+
input: readonly ["text"];
|
|
19
|
+
cost: {
|
|
20
|
+
input: number;
|
|
21
|
+
output: number;
|
|
22
|
+
cacheRead: number;
|
|
23
|
+
cacheWrite: number;
|
|
24
|
+
};
|
|
25
|
+
contextWindow: number;
|
|
26
|
+
maxTokens: number;
|
|
27
|
+
thinkingLevelMap: ThinkingLevelMap;
|
|
28
|
+
compat: {
|
|
29
|
+
supportsDeveloperRole: false;
|
|
30
|
+
cacheControlFormat: "anthropic";
|
|
31
|
+
supportsLongCacheRetention: false;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** All levels supported (off → "none"), minimal unsupported. */
|
|
36
|
+
const FULL_THINKING: ThinkingLevelMap = {
|
|
37
|
+
off: "none",
|
|
38
|
+
minimal: null,
|
|
39
|
+
low: "low",
|
|
40
|
+
medium: "medium",
|
|
41
|
+
high: "high",
|
|
42
|
+
xhigh: "xhigh",
|
|
43
|
+
max: "max",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** ox-alpha requires reasoning upstream: off → null (unsupported). */
|
|
47
|
+
const OX_ALPHA_THINKING: ThinkingLevelMap = {
|
|
48
|
+
...FULL_THINKING,
|
|
49
|
+
off: null,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* ClinePass rejects the `developer` role; the API caps at `max_tokens` /
|
|
54
|
+
* `max_completion_tokens` with reasoning excluded from the completion cap,
|
|
55
|
+
* so pi's default `max_completion_tokens` is correct and left unset.
|
|
56
|
+
* `cacheControlFormat: "anthropic"` makes pi send `{type:"ephemeral"}`
|
|
57
|
+
* (no ttl) — verified to engage real caching on ClinePass.
|
|
58
|
+
* `supportsLongCacheRetention: false` keeps ttl from ever being sent
|
|
59
|
+
* (ttl causes HTTP 500).
|
|
60
|
+
*/
|
|
61
|
+
const COMPAT = {
|
|
62
|
+
supportsDeveloperRole: false as const,
|
|
63
|
+
cacheControlFormat: "anthropic" as const,
|
|
64
|
+
supportsLongCacheRetention: false as const,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function model(
|
|
68
|
+
id: string,
|
|
69
|
+
name: string,
|
|
70
|
+
cost: [input: number, output: number, cacheRead: number],
|
|
71
|
+
contextWindow: number,
|
|
72
|
+
maxTokens: number,
|
|
73
|
+
thinkingLevelMap: ThinkingLevelMap = FULL_THINKING,
|
|
74
|
+
): ClinePassModel {
|
|
75
|
+
return {
|
|
76
|
+
id,
|
|
77
|
+
name,
|
|
78
|
+
reasoning: true,
|
|
79
|
+
input: ["text"],
|
|
80
|
+
cost: { input: cost[0], output: cost[1], cacheRead: cost[2], cacheWrite: 0 },
|
|
81
|
+
contextWindow,
|
|
82
|
+
maxTokens,
|
|
83
|
+
thinkingLevelMap,
|
|
84
|
+
compat: COMPAT,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Measured prices ($/1M tokens: input/output/cacheRead). */
|
|
89
|
+
export const MODELS: readonly ClinePassModel[] = [
|
|
90
|
+
// ── Free models (Cline free tier, cost 0) ──────────────────────────────
|
|
91
|
+
model("deepseek/deepseek-v4-flash", "DeepSeek V4 Flash (Cline Free)", [0, 0, 0], 1_024_000, 384_000),
|
|
92
|
+
model("stealth/ox-alpha", "Ox Alpha (Cline Free)", [0, 0, 0], 1_048_576, 131_072, OX_ALPHA_THINKING),
|
|
93
|
+
model("poolside/laguna-s-2.1:free", "Poolside Laguna S-2.1 (Cline Free)", [0, 0, 0], 262_144, 32_768),
|
|
94
|
+
// ── ClinePass models (measured billing prices) ─────────────────────────
|
|
95
|
+
model("cline-pass/glm-5.3", "GLM-5.3 (ClinePass)", [1.4, 4.4, 0.26], 1_048_576, 131_072),
|
|
96
|
+
model("cline-pass/glm-5.2", "GLM-5.2 (ClinePass)", [1.4, 4.4, 0.26], 1_048_576, 262_144),
|
|
97
|
+
model("cline-pass/kimi-k2.7-code", "Kimi K2.7 Code (ClinePass)", [1.58, 6.67, 0.317], 262_144, 262_144),
|
|
98
|
+
model("cline-pass/kimi-k2.6", "Kimi K2.6 (ClinePass)", [1.58, 6.67, 0.267], 262_144, 262_144),
|
|
99
|
+
model("cline-pass/kimi-k3", "Kimi K3 (ClinePass)", [6.0, 30.0, 0.6], 1_048_576, 1_048_576),
|
|
100
|
+
model("cline-pass/deepseek-v4-pro", "DeepSeek V4 Pro (ClinePass)", [1.65, 4.95, 0.06], 1_024_000, 384_000),
|
|
101
|
+
model("cline-pass/deepseek-v4-flash", "DeepSeek V4 Flash (ClinePass)", [0.44, 1.32, 0.014], 1_024_000, 384_000),
|
|
102
|
+
model("cline-pass/mimo-v2.5", "MiMo-V2.5 (ClinePass)", [0.14, 0.28, 0.0028], 1_048_576, 131_072),
|
|
103
|
+
model("cline-pass/mimo-v2.5-pro", "MiMo-V2.5-Pro (ClinePass)", [0.435, 0.87, 0.0036], 1_048_576, 131_072),
|
|
104
|
+
model("cline-pass/minimax-m3", "MiniMax M3 (ClinePass)", [0.5, 2.0, 0.1], 524_288, 512_000),
|
|
105
|
+
model("cline-pass/qwen3.7-plus", "Qwen3.7 Plus (ClinePass)", [0.67, 2.68, 0.067], 1_000_000, 131_072),
|
|
106
|
+
model("cline-pass/qwen3.7-max", "Qwen3.7 Max (ClinePass)", [4.17, 12.51, 0.83], 1_000_000, 131_072),
|
|
107
|
+
model("cline-pass/qwen3.8-max", "Qwen3.8 Max (ClinePass)", [2.75, 8.25, 0.344], 1_000_000, 131_072),
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
export function modelIds(): string[] {
|
|
111
|
+
return MODELS.map((m) => m.id);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** True for the Cline free-tier models (cost 0). */
|
|
115
|
+
export function isFreeModel(id: string): boolean {
|
|
116
|
+
return id.includes(":free") || id.includes("ox-alpha") || id === "deepseek/deepseek-v4-flash";
|
|
117
|
+
}
|