pi-free 2.0.1 → 2.0.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.
@@ -1,298 +1,284 @@
1
- /**
2
- * Cline Provider Extension
3
- *
4
- * Provides access to Cline's free models (via their OpenRouter gateway).
5
- * Free model list is fetched from Cline's GitHub source — no account needed to browse.
6
- * Run /login cline to authenticate and make API calls.
7
- *
8
- * Auth flow based on pi-cline's proven implementation.
9
- *
10
- * Responds to global free-only filter (though Cline only provides free models without auth).
11
- *
12
- * Usage:
13
- * pi install git:github.com/apmantza/pi-free
14
- * # Models appear immediately; run /login cline to start chatting
15
- */
16
-
17
- import type { OAuthCredentials } from "@mariozechner/pi-ai";
18
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
19
- import { BASE_URL_CLINE, PROVIDER_CLINE } from "../../constants.ts";
20
- import { registerWithGlobalToggle } from "../../lib/registry.ts";
21
- import { logWarning } from "../../lib/util.ts";
22
- import { enhanceWithCI } from "../../provider-helper.ts";
23
- import { loginCline, refreshClineToken } from "./cline-auth.ts";
24
- import { fetchClineModels } from "./cline-models.ts";
25
-
26
- // =============================================================================
27
- // Cline API headers (must match real Cline VS Code extension exactly)
28
- // =============================================================================
29
-
30
- const VS_CODE_VERSION = "1.109.3";
31
- const CLINE_EXTENSION_VERSION = "3.76.0";
32
- let _currentTaskId = generateUlid();
33
-
34
- function generateUlid(): string {
35
- const CHARS = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
36
- const now = Date.now();
37
- let ts = "";
38
- let t = now;
39
- for (let i = 0; i < 10; i++) {
40
- ts = CHARS[t % 32] + ts;
41
- t = Math.floor(t / 32);
42
- }
43
- const rand = new Uint8Array(16);
44
- crypto.getRandomValues(rand);
45
- let r = "";
46
- for (let i = 0; i < 16; i++) r += CHARS[rand[i] % 32];
47
- return ts + r;
48
- }
49
-
50
- function buildClineHeaders(): Record<string, string> {
51
- return {
52
- "HTTP-Referer": "https://cline.bot",
53
- "X-Title": "Cline",
54
- "X-Task-ID": _currentTaskId,
55
- "X-PLATFORM": "Visual Studio Code",
56
- "X-PLATFORM-VERSION": VS_CODE_VERSION,
57
- "X-CLIENT-TYPE": "VSCode Extension",
58
- "X-CLIENT-VERSION": CLINE_EXTENSION_VERSION,
59
- "X-CORE-VERSION": CLINE_EXTENSION_VERSION,
60
- "X-Is-Multiroot": "false",
61
- };
62
- }
63
-
64
- function toApiKey(credentials: OAuthCredentials): string {
65
- const token = credentials.access;
66
- return token.startsWith("workos:") ? token : `workos:${token}`;
67
- }
68
-
69
- // =============================================================================
70
- // Context shaping — Cline's API requires a specific message envelope
71
- // =============================================================================
72
-
73
- const TASK_PROGRESS_BLOCK = `
74
- # task_progress List (Optional - Plan Mode)
75
-
76
- While in PLAN MODE, if you've outlined concrete steps or requirements for the user, you may include a preliminary todo list using the task_progress parameter.
77
-
78
- 1. To create or update a todo list, include the task_progress parameter in the next tool call
79
- 2. Review each item and update its status:
80
- - Mark completed items with: - [x]
81
- - Keep incomplete items as: - [ ]
82
- 3. Modify the list as needed
83
- 4. Ensure the list accurately reflects the current state`;
84
-
85
- function buildEnvironmentDetails(): string {
86
- const cwd = process.cwd();
87
- return `<environmentDetails>
88
- # Visual Studio Code Visible Files
89
- (No visible files)
90
-
91
- # Visual Studio Code Open Tabs
92
- (No open tabs)
93
-
94
- # Current Working Directory (${cwd}) Files
95
- (No files)
96
-
97
- # Context Window Usage
98
- 0 / 204.8K tokens used (0%)
99
-
100
- # Current Mode
101
- PLAN MODE
102
- </environmentDetails>`;
103
- }
104
-
105
- function extractText(content: unknown): string {
106
- if (typeof content === "string") return content.trim();
107
- if (Array.isArray(content)) {
108
- return (content as any[])
109
- .filter((p: any) => p?.type === "text" && typeof p?.text === "string")
110
- .map((p: any) => p.text)
111
- .join("\n\n")
112
- .trim();
113
- }
114
- return "";
115
- }
116
-
117
- function isClineWrapped(content: unknown): boolean {
118
- if (!Array.isArray(content)) return false;
119
- const texts = (content as any[])
120
- .filter((p: any) => p?.type === "text" && typeof p?.text === "string")
121
- .map((p: any) => p.text as string);
122
- return (
123
- texts.some((t) => /<task>[\s\S]*<\/task>/.test(t)) &&
124
- texts.some((t) => t.includes("task_progress List")) &&
125
- texts.some((t) => t.includes("<environmentDetails>"))
126
- );
127
- }
128
-
129
- function extractTaskBody(content: unknown): string {
130
- if (!Array.isArray(content)) return "";
131
- for (const p of content as any[]) {
132
- if (p?.type !== "text" || typeof p?.text !== "string") continue;
133
- const m = p.text.match(/<task>\s*([\s\S]*?)\s*<\/task>/);
134
- if (m?.[1]) return m[1].trim();
135
- }
136
- return "";
137
- }
138
-
139
- function shapeMessagesForCline(messages: any[]): any[] {
140
- let lastWrappedIdx = -1;
141
- let baseTranscript = "";
142
- for (let i = messages.length - 1; i >= 0; i--) {
143
- if (messages[i]?.role !== "user") continue;
144
- if (!isClineWrapped(messages[i]?.content)) continue;
145
- lastWrappedIdx = i;
146
- baseTranscript = extractTaskBody(messages[i].content);
147
- break;
148
- }
149
-
150
- const parts: string[] = baseTranscript ? [baseTranscript] : [];
151
- const startIdx = lastWrappedIdx >= 0 ? lastWrappedIdx + 1 : 0;
152
-
153
- for (let i = startIdx; i < messages.length; i++) {
154
- const msg = messages[i];
155
- const role = msg?.role ?? "user";
156
- if (role === "system") continue;
157
- if (role === "user" && isClineWrapped(msg?.content)) continue;
158
- const text = extractText(msg?.content).trim();
159
- if (!text) continue;
160
-
161
- if (role === "tool") {
162
- parts.push(`<tool_result>\n${text}\n</tool_result>`);
163
- } else if (role !== "assistant") {
164
- parts.push(`[${role}]\n${text}`);
165
- }
166
- }
167
-
168
- const transcript = parts.join("\n\n").trim() || "(no conversation yet)";
169
- const envDetails = buildEnvironmentDetails();
170
-
171
- const collapsed: any[] = [];
172
- const systemMsg = messages.find((m: any) => m?.role === "system");
173
- if (systemMsg) {
174
- const systemText = extractText(systemMsg.content);
175
- if (systemText) collapsed.push({ role: "system", content: systemText });
176
- }
177
-
178
- collapsed.push({
179
- role: "user",
180
- content: [
181
- { type: "text", text: `<task>\n${transcript}\n</task>` },
182
- { type: "text", text: TASK_PROGRESS_BLOCK },
183
- { type: "text", text: envDetails },
184
- ],
185
- });
186
-
187
- return collapsed;
188
- }
189
-
190
- // =============================================================================
191
- // Extension entry point
192
- // =============================================================================
193
-
194
- export default async function (pi: ExtensionAPI) {
195
- // Fetch ALL models from OpenRouter (free and paid)
196
- // The global free-only filter will filter based on cost.input
197
- let allModels = await fetchClineModels(false).catch((err) => {
198
- logWarning("cline", "Failed to fetch models at startup", err);
199
- return [];
200
- });
201
-
202
- // Also fetch free-only list for the global free-only filter
203
- let freeModels = allModels.filter((m) => m.cost.input === 0);
204
-
205
- // Create re-register function for global toggle
206
- const reRegister = (m: typeof allModels) => {
207
- pi.registerProvider(PROVIDER_CLINE, {
208
- baseUrl: BASE_URL_CLINE,
209
- api: "openai-completions" as const,
210
- authHeader: false,
211
- headers: buildClineHeaders(),
212
- models: enhanceWithCI(m),
213
- oauth: {
214
- name: "Cline",
215
- login: loginCline,
216
- refreshToken: refreshClineToken,
217
- getApiKey: toApiKey,
218
- },
219
- });
220
- };
221
-
222
- // Register with global toggle (separate free and all lists)
223
- registerWithGlobalToggle(
224
- PROVIDER_CLINE,
225
- { free: freeModels, all: allModels },
226
- (m) => reRegister(m),
227
- false, // no key until OAuth
228
- );
229
-
230
- // Initial registration with all models
231
- reRegister(allModels);
232
-
233
- // Per-provider toggle command
234
- let showPaidModels = false;
235
- pi.registerCommand("toggle-cline", {
236
- description: "Toggle between free and all Cline models",
237
- handler: async (_args, ctx) => {
238
- showPaidModels = !showPaidModels;
239
-
240
- // Determine which models to show
241
- const modelsToShow =
242
- showPaidModels && allModels.length > 0 ? allModels : freeModels;
243
-
244
- reRegister(modelsToShow);
245
-
246
- const freeCount = freeModels.length;
247
- const paidCount = allModels.length - freeCount;
248
-
249
- if (showPaidModels && allModels.length > 0) {
250
- ctx.ui.notify(
251
- `cline: showing all ${allModels.length} models (${freeCount} free, ${paidCount} paid)`,
252
- "info",
253
- );
254
- } else {
255
- ctx.ui.notify(
256
- `cline: showing ${freeCount} free models (${paidCount} paid hidden)`,
257
- "info",
258
- );
259
- }
260
- },
261
- });
262
-
263
- // Cline-specific: refresh task ID and re-register headers before each agent run
264
- pi.on("before_agent_start", async (_event, ctx) => {
265
- if (ctx.model?.provider !== PROVIDER_CLINE) return;
266
- _currentTaskId = generateUlid();
267
- reRegister(freeModels);
268
- });
269
-
270
- // Cline-specific: shape messages to Cline's expected envelope format
271
- pi.on("context", async (event, ctx) => {
272
- if (ctx.model?.provider !== PROVIDER_CLINE) return;
273
- const sourceMessages = Array.isArray(event.messages) ? event.messages : [];
274
- return { messages: shapeMessagesForCline(sourceMessages) };
275
- });
276
-
277
- // Cline-specific: refresh model list at session start
278
- pi.on("session_start", async (_event, ctx) => {
279
- try {
280
- const fresh = await fetchClineModels(false);
281
- if (fresh.length > 0) {
282
- allModels = fresh;
283
- freeModels = allModels.filter((m) => m.cost.input === 0);
284
- reRegister(allModels);
285
- if (ctx.model?.provider === PROVIDER_CLINE) {
286
- const freeCount = freeModels.length;
287
- const paidCount = allModels.length - freeCount;
288
- ctx.ui.notify(
289
- `Cline: ${freeCount} free, ${paidCount} paid models available`,
290
- "info",
291
- );
292
- }
293
- }
294
- } catch (err) {
295
- logWarning("cline", "Failed to refresh models at session start", err);
296
- }
297
- });
298
- }
1
+ /**
2
+ * Cline Provider Extension
3
+ *
4
+ * Provides access to Cline's free models (via their OpenRouter gateway).
5
+ * Free model list is fetched from Cline's GitHub source — no account needed to browse.
6
+ * Run /login cline to authenticate and make API calls.
7
+ *
8
+ * Auth flow based on pi-cline's proven implementation.
9
+ *
10
+ * Responds to global free-only filter (though Cline only provides free models without auth).
11
+ *
12
+ * Usage:
13
+ * pi install git:github.com/apmantza/pi-free
14
+ * # Models appear immediately; run /login cline to start chatting
15
+ */
16
+
17
+ import type { OAuthCredentials } from "@mariozechner/pi-ai";
18
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
19
+ import { getClineShowPaid } from "../../config.ts";
20
+ import { BASE_URL_CLINE, PROVIDER_CLINE } from "../../constants.ts";
21
+ import { registerWithGlobalToggle } from "../../lib/registry.ts";
22
+ import { createToggleState } from "../../lib/toggle-state.ts";
23
+ import { logWarning } from "../../lib/util.ts";
24
+ import { enhanceWithCI } from "../../provider-helper.ts";
25
+ import { loginCline, refreshClineToken } from "./cline-auth.ts";
26
+ import { fetchClineModels } from "./cline-models.ts";
27
+
28
+ // =============================================================================
29
+ // Cline API headers (must match real Cline VS Code extension exactly)
30
+ // =============================================================================
31
+
32
+ const VS_CODE_VERSION = "1.109.3";
33
+ const CLINE_EXTENSION_VERSION = "3.76.0";
34
+ let _currentTaskId = generateUlid();
35
+
36
+ function generateUlid(): string {
37
+ const CHARS = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
38
+ const now = Date.now();
39
+ let ts = "";
40
+ let t = now;
41
+ for (let i = 0; i < 10; i++) {
42
+ ts = CHARS[t % 32] + ts;
43
+ t = Math.floor(t / 32);
44
+ }
45
+ const rand = new Uint8Array(16);
46
+ crypto.getRandomValues(rand);
47
+ let r = "";
48
+ for (let i = 0; i < 16; i++) r += CHARS[rand[i] % 32];
49
+ return ts + r;
50
+ }
51
+
52
+ function buildClineHeaders(): Record<string, string> {
53
+ return {
54
+ "HTTP-Referer": "https://cline.bot",
55
+ "X-Title": "Cline",
56
+ "X-Task-ID": _currentTaskId,
57
+ "X-PLATFORM": "Visual Studio Code",
58
+ "X-PLATFORM-VERSION": VS_CODE_VERSION,
59
+ "X-CLIENT-TYPE": "VSCode Extension",
60
+ "X-CLIENT-VERSION": CLINE_EXTENSION_VERSION,
61
+ "X-CORE-VERSION": CLINE_EXTENSION_VERSION,
62
+ "X-Is-Multiroot": "false",
63
+ };
64
+ }
65
+
66
+ function toApiKey(credentials: OAuthCredentials): string {
67
+ const token = credentials.access;
68
+ return token.startsWith("workos:") ? token : `workos:${token}`;
69
+ }
70
+
71
+ // =============================================================================
72
+ // Context shaping — Cline's API requires a specific message envelope
73
+ // =============================================================================
74
+
75
+ const TASK_PROGRESS_BLOCK = `
76
+ # task_progress List (Optional - Plan Mode)
77
+
78
+ While in PLAN MODE, if you've outlined concrete steps or requirements for the user, you may include a preliminary todo list using the task_progress parameter.
79
+
80
+ 1. To create or update a todo list, include the task_progress parameter in the next tool call
81
+ 2. Review each item and update its status:
82
+ - Mark completed items with: - [x]
83
+ - Keep incomplete items as: - [ ]
84
+ 3. Modify the list as needed
85
+ 4. Ensure the list accurately reflects the current state`;
86
+
87
+ function buildEnvironmentDetails(): string {
88
+ const cwd = process.cwd();
89
+ return `<environmentDetails>
90
+ # Visual Studio Code Visible Files
91
+ (No visible files)
92
+
93
+ # Visual Studio Code Open Tabs
94
+ (No open tabs)
95
+
96
+ # Current Working Directory (${cwd}) Files
97
+ (No files)
98
+
99
+ # Context Window Usage
100
+ 0 / 204.8K tokens used (0%)
101
+
102
+ # Current Mode
103
+ PLAN MODE
104
+ </environmentDetails>`;
105
+ }
106
+
107
+ function extractText(content: unknown): string {
108
+ if (typeof content === "string") return content.trim();
109
+ if (Array.isArray(content)) {
110
+ return (content as any[])
111
+ .filter((p: any) => p?.type === "text" && typeof p?.text === "string")
112
+ .map((p: any) => p.text)
113
+ .join("\n\n")
114
+ .trim();
115
+ }
116
+ return "";
117
+ }
118
+
119
+ function isClineWrapped(content: unknown): boolean {
120
+ if (!Array.isArray(content)) return false;
121
+ const texts = (content as any[])
122
+ .filter((p: any) => p?.type === "text" && typeof p?.text === "string")
123
+ .map((p: any) => p.text as string);
124
+ return (
125
+ texts.some((t) => /<task>[\s\S]*<\/task>/.test(t)) &&
126
+ texts.some((t) => t.includes("task_progress List")) &&
127
+ texts.some((t) => t.includes("<environmentDetails>"))
128
+ );
129
+ }
130
+
131
+ function extractTaskBody(content: unknown): string {
132
+ if (!Array.isArray(content)) return "";
133
+ for (const p of content as any[]) {
134
+ if (p?.type !== "text" || typeof p?.text !== "string") continue;
135
+ const m = p.text.match(/<task>\s*([\s\S]*?)\s*<\/task>/);
136
+ if (m?.[1]) return m[1].trim();
137
+ }
138
+ return "";
139
+ }
140
+
141
+ function shapeMessagesForCline(messages: any[]): any[] {
142
+ let lastWrappedIdx = -1;
143
+ let baseTranscript = "";
144
+ for (let i = messages.length - 1; i >= 0; i--) {
145
+ if (messages[i]?.role !== "user") continue;
146
+ if (!isClineWrapped(messages[i]?.content)) continue;
147
+ lastWrappedIdx = i;
148
+ baseTranscript = extractTaskBody(messages[i].content);
149
+ break;
150
+ }
151
+
152
+ const parts: string[] = baseTranscript ? [baseTranscript] : [];
153
+ const startIdx = lastWrappedIdx >= 0 ? lastWrappedIdx + 1 : 0;
154
+
155
+ for (let i = startIdx; i < messages.length; i++) {
156
+ const msg = messages[i];
157
+ const role = msg?.role ?? "user";
158
+ if (role === "system") continue;
159
+ if (role === "user" && isClineWrapped(msg?.content)) continue;
160
+ const text = extractText(msg?.content).trim();
161
+ if (!text) continue;
162
+
163
+ if (role === "tool") {
164
+ parts.push(`<tool_result>\n${text}\n</tool_result>`);
165
+ } else if (role !== "assistant") {
166
+ parts.push(`[${role}]\n${text}`);
167
+ }
168
+ }
169
+
170
+ const transcript = parts.join("\n\n").trim() || "(no conversation yet)";
171
+ const envDetails = buildEnvironmentDetails();
172
+
173
+ const collapsed: any[] = [];
174
+ const systemMsg = messages.find((m: any) => m?.role === "system");
175
+ if (systemMsg) {
176
+ const systemText = extractText(systemMsg.content);
177
+ if (systemText) collapsed.push({ role: "system", content: systemText });
178
+ }
179
+
180
+ collapsed.push({
181
+ role: "user",
182
+ content: [
183
+ { type: "text", text: `<task>\n${transcript}\n</task>` },
184
+ { type: "text", text: TASK_PROGRESS_BLOCK },
185
+ { type: "text", text: envDetails },
186
+ ],
187
+ });
188
+
189
+ return collapsed;
190
+ }
191
+
192
+ // =============================================================================
193
+ // Extension entry point
194
+ // =============================================================================
195
+
196
+ export default async function (pi: ExtensionAPI) {
197
+ let allModels = await fetchClineModels(false).catch((err) => {
198
+ logWarning("cline", "Failed to fetch models at startup", err);
199
+ return [];
200
+ });
201
+ let freeModels = allModels.filter((m) => m.cost.input === 0);
202
+ const stored = { free: freeModels, all: allModels };
203
+ const toggleState = createToggleState({
204
+ providerId: PROVIDER_CLINE,
205
+ initialShowPaid: getClineShowPaid(),
206
+ initialModels: stored,
207
+ });
208
+
209
+ const reRegister = (m: typeof allModels) => {
210
+ pi.registerProvider(PROVIDER_CLINE, {
211
+ baseUrl: BASE_URL_CLINE,
212
+ api: "openai-completions" as const,
213
+ authHeader: false,
214
+ headers: buildClineHeaders(),
215
+ models: enhanceWithCI(m),
216
+ oauth: {
217
+ name: "Cline",
218
+ login: loginCline,
219
+ refreshToken: refreshClineToken,
220
+ getApiKey: toApiKey,
221
+ },
222
+ });
223
+ };
224
+
225
+ registerWithGlobalToggle(PROVIDER_CLINE, stored, (m) => reRegister(m), false);
226
+ toggleState.applyCurrent(reRegister);
227
+
228
+ pi.registerCommand("toggle-cline", {
229
+ description: "Toggle between free and all Cline models",
230
+ handler: async (_args, ctx) => {
231
+ const applied = toggleState.toggle(reRegister);
232
+ const freeCount = stored.free.length;
233
+ const paidCount = stored.all.length - freeCount;
234
+
235
+ if (applied.mode === "all") {
236
+ ctx.ui.notify(
237
+ `cline: showing all ${stored.all.length} models (${freeCount} free, ${paidCount} paid)`,
238
+ "info",
239
+ );
240
+ } else {
241
+ ctx.ui.notify(
242
+ `cline: showing ${freeCount} free models (${paidCount} paid hidden)`,
243
+ "info",
244
+ );
245
+ }
246
+ },
247
+ });
248
+
249
+ pi.on("before_agent_start", async (_event, ctx) => {
250
+ if (ctx.model?.provider !== PROVIDER_CLINE) return;
251
+ _currentTaskId = generateUlid();
252
+ toggleState.applyCurrent(reRegister);
253
+ });
254
+
255
+ pi.on("context", async (event, ctx) => {
256
+ if (ctx.model?.provider !== PROVIDER_CLINE) return;
257
+ const sourceMessages = Array.isArray(event.messages) ? event.messages : [];
258
+ return { messages: shapeMessagesForCline(sourceMessages) };
259
+ });
260
+
261
+ pi.on("session_start", async (_event, ctx) => {
262
+ try {
263
+ const fresh = await fetchClineModels(false);
264
+ if (fresh.length > 0) {
265
+ allModels = fresh;
266
+ freeModels = allModels.filter((m) => m.cost.input === 0);
267
+ stored.all = allModels;
268
+ stored.free = freeModels;
269
+ toggleState.setModels(stored);
270
+ toggleState.applyCurrent(reRegister);
271
+ if (ctx.model?.provider === PROVIDER_CLINE) {
272
+ const freeCount = stored.free.length;
273
+ const paidCount = stored.all.length - freeCount;
274
+ ctx.ui.notify(
275
+ `Cline: ${freeCount} free, ${paidCount} paid models available`,
276
+ "info",
277
+ );
278
+ }
279
+ }
280
+ } catch (err) {
281
+ logWarning("cline", "Failed to refresh models at session start", err);
282
+ }
283
+ });
284
+ }