pi-commandcode-provider 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/src/core.ts ADDED
@@ -0,0 +1,458 @@
1
+ /**
2
+ * Testable Command Code provider core.
3
+ *
4
+ * The runtime imports live in index.ts; this module takes injected stream/cost
5
+ * dependencies so tests can exercise the real serialization and stream parser.
6
+ */
7
+
8
+ import { randomUUID } from "node:crypto"
9
+
10
+ import {
11
+ getApiKey,
12
+ getEnvironmentInfo,
13
+ isRecord,
14
+ mapFinishReason,
15
+ messagesToCC,
16
+ numberValue,
17
+ parseStreamEventLine,
18
+ recordOrEmpty,
19
+ stringValue,
20
+ toolsToJson,
21
+ } from "./converters.ts"
22
+ import type {
23
+ AssistantMessageEventStreamLike,
24
+ AssistantMessageLike,
25
+ ContextLike,
26
+ CoreDependencies,
27
+ ErrorReason,
28
+ ModelLike,
29
+ StopReason,
30
+ StreamOptions,
31
+ TerminalReason,
32
+ TextContent,
33
+ ToolCallContent,
34
+ Usage,
35
+ } from "./types.ts"
36
+
37
+ export * from "./converters.ts"
38
+ export * from "./types.ts"
39
+
40
+ export const DEFAULT_API_BASE = "https://api.commandcode.ai"
41
+
42
+ function defaultUsage(): Usage {
43
+ return {
44
+ input: 0,
45
+ output: 0,
46
+ cacheRead: 0,
47
+ cacheWrite: 0,
48
+ totalTokens: 0,
49
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
50
+ }
51
+ }
52
+
53
+ function commandCodeUsage(event: Record<string, unknown>): Record<string, unknown> | undefined {
54
+ return isRecord(event.totalUsage) ? event.totalUsage : undefined
55
+ }
56
+
57
+ function commandCodeInputTokenDetails(
58
+ usage: Record<string, unknown>,
59
+ ): Record<string, unknown> | undefined {
60
+ return isRecord(usage.inputTokenDetails) ? usage.inputTokenDetails : undefined
61
+ }
62
+
63
+ function headersToRecord(headers: Headers): Record<string, string> {
64
+ const out: Record<string, string> = {}
65
+ headers.forEach((value, key) => {
66
+ out[key] = value
67
+ })
68
+ return out
69
+ }
70
+
71
+ function abortError(message = "The operation was aborted"): DOMException {
72
+ return new DOMException(message, "AbortError")
73
+ }
74
+
75
+ function successStopReason(reason: TerminalReason): StopReason {
76
+ if (reason === "length" || reason === "toolUse") return reason
77
+ return "stop"
78
+ }
79
+
80
+ export function createStreamCommandCode(deps: CoreDependencies) {
81
+ const apiBase = deps.apiBase ?? DEFAULT_API_BASE
82
+ const fetchImpl = deps.fetchImpl ?? fetch
83
+ const cwd = deps.cwd ?? (() => process.cwd())
84
+ const now = deps.now ?? (() => Date.now())
85
+ const uuid = deps.uuid ?? (() => randomUUID())
86
+
87
+ function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
88
+ if (signal.aborted) return Promise.reject(abortError())
89
+
90
+ return new Promise<T>((resolve, reject) => {
91
+ const onAbort = () => reject(abortError())
92
+ signal.addEventListener("abort", onAbort, { once: true })
93
+ promise.then(
94
+ (value) => {
95
+ signal.removeEventListener("abort", onAbort)
96
+ resolve(value)
97
+ },
98
+ (error: unknown) => {
99
+ signal.removeEventListener("abort", onAbort)
100
+ reject(error)
101
+ },
102
+ )
103
+ })
104
+ }
105
+
106
+ return function streamCommandCode(
107
+ model: ModelLike,
108
+ context: ContextLike,
109
+ options?: StreamOptions,
110
+ ): AssistantMessageEventStreamLike {
111
+ const stream = deps.createStream()
112
+
113
+ async function run() {
114
+ const apiKey =
115
+ options?.apiKey ??
116
+ getApiKey({
117
+ env: deps.env,
118
+ authPaths: deps.authPaths,
119
+ homeDir: deps.homeDir,
120
+ })
121
+
122
+ if (!apiKey) {
123
+ const msg: AssistantMessageLike = {
124
+ role: "assistant",
125
+ content: [],
126
+ api: model.api,
127
+ provider: model.provider,
128
+ model: model.id,
129
+ usage: defaultUsage(),
130
+ stopReason: "error",
131
+ errorMessage:
132
+ "No Command Code API key. Run /login and select Command Code, set COMMANDCODE_API_KEY env var, or configure ~/.commandcode/auth.json or ~/.pi/agent/auth.json.",
133
+ timestamp: now(),
134
+ }
135
+ stream.push({ type: "error", reason: "error", error: msg })
136
+ stream.end()
137
+ return
138
+ }
139
+
140
+ const output: AssistantMessageLike = {
141
+ role: "assistant",
142
+ content: [],
143
+ api: model.api,
144
+ provider: model.provider,
145
+ model: model.id,
146
+ usage: defaultUsage(),
147
+ stopReason: "stop",
148
+ timestamp: now(),
149
+ }
150
+
151
+ const controller = new AbortController()
152
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
153
+ let textBlock: TextContent | undefined
154
+ let currentTextIdx = -1
155
+ let thinkingBlock: string[] = []
156
+ let finished = false
157
+
158
+ const abortUpstream = () => {
159
+ if (!controller.signal.aborted) controller.abort()
160
+ try {
161
+ reader?.cancel().catch(() => undefined)
162
+ } catch {
163
+ // Reader cancellation is best-effort.
164
+ }
165
+ }
166
+
167
+ if (options?.signal?.aborted) {
168
+ abortUpstream()
169
+ } else {
170
+ options?.signal?.addEventListener("abort", abortUpstream, {
171
+ once: true,
172
+ })
173
+ }
174
+
175
+ const endTextBlock = () => {
176
+ if (!textBlock) return
177
+ stream.push({
178
+ type: "text_end",
179
+ contentIndex: currentTextIdx,
180
+ content: textBlock.text,
181
+ partial: output,
182
+ })
183
+ textBlock = undefined
184
+ currentTextIdx = -1
185
+ }
186
+
187
+ const flushThinkingBlock = () => {
188
+ if (thinkingBlock.length === 0) return
189
+ const thinkingText = thinkingBlock.join("")
190
+ thinkingBlock = []
191
+ output.content.push({ type: "thinking", thinking: thinkingText })
192
+ const idx = output.content.length - 1
193
+ stream.push({
194
+ type: "thinking_start",
195
+ contentIndex: idx,
196
+ partial: output,
197
+ })
198
+ stream.push({
199
+ type: "thinking_delta",
200
+ contentIndex: idx,
201
+ delta: thinkingText,
202
+ partial: output,
203
+ })
204
+ stream.push({
205
+ type: "thinking_end",
206
+ contentIndex: idx,
207
+ content: thinkingText,
208
+ partial: output,
209
+ })
210
+ }
211
+
212
+ const handleEvent = (event: unknown) => {
213
+ if (!isRecord(event)) return
214
+
215
+ switch (event.type) {
216
+ case "text-delta": {
217
+ if (!textBlock) {
218
+ textBlock = { type: "text", text: "" }
219
+ output.content.push(textBlock)
220
+ currentTextIdx = output.content.length - 1
221
+ stream.push({
222
+ type: "text_start",
223
+ contentIndex: currentTextIdx,
224
+ partial: output,
225
+ })
226
+ }
227
+ const delta = stringValue(event.text) ?? ""
228
+ textBlock.text += delta
229
+ stream.push({
230
+ type: "text_delta",
231
+ contentIndex: currentTextIdx,
232
+ delta,
233
+ partial: output,
234
+ })
235
+ break
236
+ }
237
+
238
+ case "reasoning-delta": {
239
+ thinkingBlock.push(stringValue(event.text) ?? "")
240
+ break
241
+ }
242
+
243
+ case "reasoning-end": {
244
+ flushThinkingBlock()
245
+ break
246
+ }
247
+
248
+ case "tool-call": {
249
+ endTextBlock()
250
+ const toolCall: ToolCallContent = {
251
+ type: "toolCall",
252
+ id: stringValue(event.toolCallId) ?? "",
253
+ name: stringValue(event.toolName) ?? "",
254
+ arguments: recordOrEmpty(event.input ?? event.args ?? event.arguments),
255
+ }
256
+ output.content.push(toolCall)
257
+ const idx = output.content.length - 1
258
+ stream.push({
259
+ type: "toolcall_start",
260
+ contentIndex: idx,
261
+ partial: output,
262
+ })
263
+ stream.push({
264
+ type: "toolcall_end",
265
+ contentIndex: idx,
266
+ toolCall,
267
+ partial: output,
268
+ })
269
+ break
270
+ }
271
+
272
+ case "finish": {
273
+ const usage = commandCodeUsage(event)
274
+ if (usage) {
275
+ const details = commandCodeInputTokenDetails(usage)
276
+ output.usage.input = numberValue(usage.inputTokens) ?? 0
277
+ output.usage.output = numberValue(usage.outputTokens) ?? 0
278
+ output.usage.cacheRead = numberValue(details?.cacheReadTokens) ?? 0
279
+ output.usage.cacheWrite = numberValue(details?.cacheWriteTokens) ?? 0
280
+ output.usage.totalTokens =
281
+ output.usage.input +
282
+ output.usage.output +
283
+ output.usage.cacheRead +
284
+ output.usage.cacheWrite
285
+ deps.calculateCost(model, output.usage)
286
+ }
287
+ output.stopReason = mapFinishReason(event.finishReason)
288
+ finished = true
289
+ break
290
+ }
291
+
292
+ case "error": {
293
+ const errorRecord = isRecord(event.error) ? event.error : undefined
294
+ const message =
295
+ stringValue(errorRecord?.message) ?? stringValue(event.error) ?? "Stream error"
296
+ output.stopReason = "error"
297
+ output.errorMessage = message
298
+ throw new Error(message)
299
+ }
300
+ }
301
+ }
302
+
303
+ try {
304
+ stream.push({ type: "start", partial: output })
305
+
306
+ let body: unknown = {
307
+ config: {
308
+ workingDir: cwd(),
309
+ date: new Date(now()).toISOString().split("T")[0],
310
+ environment: getEnvironmentInfo(),
311
+ structure: [],
312
+ isGitRepo: false,
313
+ currentBranch: "",
314
+ mainBranch: "",
315
+ gitStatus: "",
316
+ recentCommits: [],
317
+ },
318
+ memory: "",
319
+ taste: "",
320
+ skills: null,
321
+ permissionMode: "standard",
322
+ params: {
323
+ model: model.id,
324
+ messages: messagesToCC(context.messages),
325
+ tools: toolsToJson(context.tools),
326
+ system: context.systemPrompt ?? "",
327
+ max_tokens: Math.min(options?.maxTokens ?? model.maxTokens, 200_000),
328
+ stream: true,
329
+ },
330
+ }
331
+
332
+ const nextBody = await raceAbort(
333
+ Promise.resolve(options?.onPayload?.(body, model)),
334
+ controller.signal,
335
+ )
336
+ if (nextBody !== undefined) body = nextBody
337
+
338
+ const response = await raceAbort(
339
+ fetchImpl(`${apiBase}/alpha/generate`, {
340
+ method: "POST",
341
+ headers: {
342
+ "Content-Type": "application/json",
343
+ Authorization: `Bearer ${apiKey}`,
344
+ "x-command-code-version": "0.24.1",
345
+ "x-cli-environment": "production",
346
+ "x-project-slug": "pi-cc",
347
+ "x-taste-learning": "false",
348
+ "x-co-flag": "false",
349
+ "x-session-id": uuid(),
350
+ ...options?.headers,
351
+ },
352
+ body: JSON.stringify(body),
353
+ signal: controller.signal,
354
+ }),
355
+ controller.signal,
356
+ )
357
+
358
+ await raceAbort(
359
+ Promise.resolve(
360
+ options?.onResponse?.(
361
+ {
362
+ status: response.status,
363
+ headers: headersToRecord(response.headers),
364
+ },
365
+ model,
366
+ ),
367
+ ),
368
+ controller.signal,
369
+ )
370
+
371
+ if (!response.ok) {
372
+ const errBody = await raceAbort(
373
+ response.text().catch(() => ""),
374
+ controller.signal,
375
+ )
376
+ throw new Error(`Command Code API error ${response.status}: ${errBody.slice(0, 500)}`)
377
+ }
378
+
379
+ reader = response.body?.getReader()
380
+ if (!reader) throw new Error("No response body")
381
+
382
+ const decoder = new TextDecoder()
383
+ let buffer = ""
384
+
385
+ readLoop: for (;;) {
386
+ if (controller.signal.aborted) throw abortError("Aborted")
387
+ const { done, value } = await raceAbort(reader.read(), controller.signal)
388
+ if (done) {
389
+ if (buffer.trim()) handleEvent(parseStreamEventLine(buffer))
390
+ break
391
+ }
392
+ if (controller.signal.aborted) throw abortError("Aborted")
393
+
394
+ buffer += decoder.decode(value, { stream: true })
395
+ const lines = buffer.split("\n")
396
+ buffer = lines.pop() ?? ""
397
+
398
+ for (const line of lines) {
399
+ if (controller.signal.aborted) throw abortError("Aborted")
400
+ handleEvent(parseStreamEventLine(line))
401
+ if (finished) break readLoop
402
+ }
403
+ }
404
+
405
+ endTextBlock()
406
+ flushThinkingBlock()
407
+
408
+ stream.push({
409
+ type: "done",
410
+ reason: successStopReason(output.stopReason),
411
+ message: output,
412
+ })
413
+ stream.end()
414
+ } catch (error: unknown) {
415
+ const reason: ErrorReason = controller.signal.aborted ? "aborted" : "error"
416
+ output.stopReason = reason
417
+ output.errorMessage =
418
+ reason === "aborted"
419
+ ? "Request aborted"
420
+ : error instanceof Error
421
+ ? error.message
422
+ : String(error)
423
+ stream.push({ type: "error", reason, error: output })
424
+ stream.end()
425
+ } finally {
426
+ options?.signal?.removeEventListener("abort", abortUpstream)
427
+ try {
428
+ await reader?.cancel()
429
+ } catch {
430
+ // Reader may already be closed/cancelled.
431
+ }
432
+ try {
433
+ reader?.releaseLock()
434
+ } catch {
435
+ // Reader may already be released/cancelled by the abort path.
436
+ }
437
+ }
438
+ }
439
+
440
+ run().catch((error: unknown) => {
441
+ const msg: AssistantMessageLike = {
442
+ role: "assistant",
443
+ content: [],
444
+ api: model.api,
445
+ provider: model.provider,
446
+ model: model.id,
447
+ usage: defaultUsage(),
448
+ stopReason: "error",
449
+ errorMessage: error instanceof Error ? error.message : String(error),
450
+ timestamp: now(),
451
+ }
452
+ stream.push({ type: "error", reason: "error", error: msg })
453
+ stream.end()
454
+ })
455
+
456
+ return stream
457
+ }
458
+ }
package/src/oauth.ts ADDED
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Command Code OAuth provider for pi's /login flow.
3
+ *
4
+ * Implements a browser-assisted API key retrieval flow:
5
+ * 1. Starts a local HTTP server on a Command Code CLI-compatible port
6
+ * 2. Opens the Command Code Studio auth page in the browser
7
+ * 3. The user authenticates on the Command Code website
8
+ * 4. The website POSTs the API key back to the local server
9
+ * 5. If browser transfer fails, the user can paste the API key manually
10
+ * 6. The API key is stored in pi's auth.json as OAuth credentials
11
+ *
12
+ * Since Command Code API keys don't expire, we store them as
13
+ * OAuth credentials with a far-future expiry.
14
+ */
15
+
16
+ import { randomBytes } from "node:crypto"
17
+ import { startAuthServer } from "./auth-server.ts"
18
+
19
+ const STUDIO_BASE_URL = "https://commandcode.ai"
20
+ const TEN_YEARS_MS = 10 * 365 * 24 * 60 * 60 * 1000 // API keys don't expire
21
+ const DEFAULT_AUTH_TIMEOUT_MS = 15_000
22
+
23
+ export interface OAuthLoginCallbacks {
24
+ onAuth(params: { url: string }): void
25
+ onPrompt(params: { message: string }): Promise<string>
26
+ }
27
+
28
+ export interface OAuthCredentials {
29
+ refresh: string
30
+ access: string
31
+ expires: number
32
+ }
33
+
34
+ class AuthTimeoutError extends Error {
35
+ constructor() {
36
+ super("Browser authentication timed out")
37
+ this.name = "AuthTimeoutError"
38
+ }
39
+ }
40
+
41
+ function generateStateToken(): string {
42
+ return randomBytes(32).toString("base64url")
43
+ }
44
+
45
+ function getAuthTimeoutMs(): number {
46
+ const raw = process.env.COMMANDCODE_AUTH_TIMEOUT_MS
47
+ if (!raw) return DEFAULT_AUTH_TIMEOUT_MS
48
+
49
+ const parsed = Number(raw)
50
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_AUTH_TIMEOUT_MS
51
+ }
52
+
53
+ function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
54
+ return new Promise((resolve, reject) => {
55
+ const timer = setTimeout(() => reject(new AuthTimeoutError()), timeoutMs)
56
+
57
+ promise.then(
58
+ (value) => {
59
+ clearTimeout(timer)
60
+ resolve(value)
61
+ },
62
+ (error) => {
63
+ clearTimeout(timer)
64
+ reject(error)
65
+ },
66
+ )
67
+ })
68
+ }
69
+
70
+ function credentialsFromApiKey(apiKey: string): OAuthCredentials {
71
+ return {
72
+ refresh: apiKey,
73
+ access: apiKey,
74
+ expires: Date.now() + TEN_YEARS_MS,
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Remove common terminal paste wrappers/control chars and surrounding whitespace.
80
+ */
81
+ export function sanitizeApiKey(input: string): string {
82
+ const esc = String.fromCharCode(27)
83
+ return Array.from(
84
+ input
85
+ .replaceAll(`${esc}[200~`, "")
86
+ .replaceAll(`${esc}[201~`, "")
87
+ .replaceAll("[200~", "")
88
+ .replaceAll("[201~", ""),
89
+ )
90
+ .filter((char) => {
91
+ const code = char.charCodeAt(0)
92
+ return code > 31 && code !== 127
93
+ })
94
+ .join("")
95
+ .trim()
96
+ }
97
+
98
+ async function promptForApiKey(callbacks: OAuthLoginCallbacks, message: string) {
99
+ const apiKey = sanitizeApiKey(await callbacks.onPrompt({ message }))
100
+ if (!apiKey) throw new Error("No Command Code API key provided")
101
+ return credentialsFromApiKey(apiKey)
102
+ }
103
+
104
+ /**
105
+ * Starts the browser-based login flow for Command Code.
106
+ *
107
+ * Returns OAuth credentials where access == refresh == the user's API key.
108
+ * The keys don't expire, so we set a far-future expiry.
109
+ */
110
+ export async function login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
111
+ let authServer
112
+ try {
113
+ authServer = await startAuthServer()
114
+ } catch {
115
+ return promptForApiKey(
116
+ callbacks,
117
+ "Could not start browser auth. Paste your Command Code API key:",
118
+ )
119
+ }
120
+
121
+ const stateToken = generateStateToken()
122
+ const callbackUrl = `http://localhost:${authServer.port}/callback`
123
+ const authUrl = `${STUDIO_BASE_URL}/studio/auth/cli?callback=${encodeURIComponent(callbackUrl)}&state=${encodeURIComponent(stateToken)}`
124
+
125
+ // Tell pi to open the browser.
126
+ callbacks.onAuth({ url: authUrl })
127
+
128
+ // Wait for the Command Code Studio to POST the API key back. If the browser
129
+ // cannot reach localhost (Command Code shows "Copy your API key"), fall back
130
+ // to pi's prompt so the user can paste the key from the browser.
131
+ let callback: { apiKey: string; state: string }
132
+ try {
133
+ callback = await withTimeout(authServer.waitForCallback, getAuthTimeoutMs())
134
+ } catch (error) {
135
+ authServer.server.close()
136
+ if (error instanceof AuthTimeoutError) {
137
+ return promptForApiKey(
138
+ callbacks,
139
+ "Automatic transfer failed or timed out. Paste your Command Code API key:",
140
+ )
141
+ }
142
+ throw error
143
+ }
144
+
145
+ // Validate state token to prevent CSRF.
146
+ if (callback.state !== stateToken) {
147
+ authServer.server.close()
148
+ throw new Error("State token mismatch. Authentication may have been tampered with.")
149
+ }
150
+
151
+ return credentialsFromApiKey(callback.apiKey)
152
+ }
153
+
154
+ /**
155
+ * Command Code API keys don't expire, so "refresh" is a no-op.
156
+ * Returns the same credentials with an updated far-future expiry.
157
+ */
158
+ export async function refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
159
+ return credentialsFromApiKey(credentials.refresh)
160
+ }
161
+
162
+ /**
163
+ * Returns the access token (API key) from OAuth credentials.
164
+ */
165
+ export function getApiKey(credentials: OAuthCredentials): string {
166
+ return credentials.access
167
+ }