Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions src/api-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { existsSync, readFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}

function stringValue(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined
}

function defaultAuthPaths(home: string): string[] {
return [join(home, ".commandcode", "auth.json"), join(home, ".pi", "agent", "auth.json")]
}

export function getConfiguredApiKey(
options: {
env?: NodeJS.ProcessEnv
authPaths?: readonly string[]
homeDir?: () => string
} = {},
): string | undefined {
const env = options.env ?? process.env
if (env.COMMANDCODE_API_KEY) return env.COMMANDCODE_API_KEY

const home = options.homeDir?.() ?? homedir()
const authPaths = options.authPaths ?? defaultAuthPaths(home)

for (const authPath of authPaths) {
try {
if (!existsSync(authPath)) continue
const parsed: unknown = JSON.parse(readFileSync(authPath, "utf-8"))
if (!isRecord(parsed)) continue

const apiKey = stringValue(parsed.apiKey)
if (apiKey) return apiKey

const commandcode = stringValue(parsed.commandcode)
if (commandcode) return commandcode

const providerKey = isRecord(parsed.commandcode) ? parsed.commandcode : undefined
if (providerKey && stringValue(providerKey.type) === "oauth") {
const access = stringValue(providerKey.access)
if (access) return access
}
} catch {
// Ignore malformed or unreadable auth files.
}
}

return undefined
}
18 changes: 17 additions & 1 deletion src/models.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
export const DEFAULT_MODELS_URL = "https://api.commandcode.ai/provider/v1/models"
import type { Api } from "@mariozechner/pi-ai"

export const DEFAULT_PROVIDER_API_BASE = "https://api.commandcode.ai/provider/v1"
export const DEFAULT_MODELS_URL = `${DEFAULT_PROVIDER_API_BASE}/models`

const DEFAULT_MAX_OUTPUT_TOKENS = 65_536

Expand All @@ -11,6 +14,7 @@ interface ApiModel {
export interface CommandCodeModel {
id: string
name: string
api: Api
reasoning: boolean
contextWindow: number
maxTokens: number
Expand Down Expand Up @@ -47,6 +51,17 @@ function parseApiModel(value: unknown): ApiModel {
}
}

export function apiForModelId(id: string): Api {
if (id.startsWith("claude-")) return "anthropic-messages"
return "openai-completions"
}

export function baseUrlForModel(apiBase: string, api: Api): string {
const normalized = apiBase.replace(/\/+$/g, "")
if (api !== "anthropic-messages") return normalized
return normalized.endsWith("/v1") ? normalized.slice(0, -3) : normalized
}

export function commandCodeModelsFromApiResponse(value: unknown): readonly CommandCodeModel[] {
if (!isRecord(value)) throw new Error("Expected models response to be an object")
if (value.object !== "list") throw new Error("Expected models response object to be 'list'")
Expand All @@ -57,6 +72,7 @@ export function commandCodeModelsFromApiResponse(value: unknown): readonly Comma
return data.map(parseApiModel).map((model) => ({
id: model.id,
name: `${model.name} (CC)`,
api: apiForModelId(model.id),
reasoning: true,
contextWindow: model.contextLength,
maxTokens: Math.min(model.contextLength, DEFAULT_MAX_OUTPUT_TOKENS),
Expand Down
67 changes: 53 additions & 14 deletions src/oauth.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
/**
* Command Code OAuth provider for pi's /login flow.
*
* Implements a browser-assisted API key retrieval flow:
* 1. Starts a local HTTP server on a Command Code CLI-compatible port
* 2. Opens the Command Code Studio auth page in the browser
* 3. The user authenticates on the Command Code website
* 4. The website POSTs the API key back to the local server
* 5. If browser transfer fails, the user can paste the API key manually
* 6. The API key is stored in pi's auth.json as OAuth credentials
* Implements two API key retrieval flows:
* 1. Browser-assisted login: opens Command Code Studio and waits for the
* website to POST the API key back to a local callback server.
* 2. Direct API key login: prompts the user to paste a Command Code Studio API key.
*
* If browser transfer fails, the user can still paste the API key manually.
* The API key is stored in pi's auth.json as OAuth credentials.
*
* Since Command Code API keys don't expire, we store them as
* OAuth credentials with a far-future expiry.
Expand Down Expand Up @@ -101,13 +101,35 @@ async function promptForApiKey(callbacks: OAuthLoginCallbacks, message: string)
return credentialsFromApiKey(apiKey)
}

/**
* Starts the browser-based login flow for Command Code.
*
* Returns OAuth credentials where access == refresh == the user's API key.
* The keys don't expire, so we set a far-future expiry.
*/
export async function login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
type LoginChoice = { type: "browser" } | { type: "prompt" } | { type: "apiKey"; apiKey: string }

async function chooseLoginFlow(callbacks: OAuthLoginCallbacks): Promise<LoginChoice> {
const input = sanitizeApiKey(
await callbacks.onPrompt({
message:
"Command Code login: press Enter for browser login, type 'key' to paste an API key, or paste the API key directly:",
}),
)
const normalized = input.toLowerCase()

if (!input || normalized === "1" || normalized === "b" || normalized === "browser") {
return { type: "browser" }
}

if (
normalized === "2" ||
normalized === "k" ||
normalized === "key" ||
normalized === "api" ||
normalized === "paste"
) {
return { type: "prompt" }
}

return { type: "apiKey", apiKey: input }
}

async function browserLogin(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
let authServer
try {
authServer = await startAuthServer()
Expand Down Expand Up @@ -151,6 +173,23 @@ export async function login(callbacks: OAuthLoginCallbacks): Promise<OAuthCreden
return credentialsFromApiKey(callback.apiKey)
}

/**
* Starts the login flow for Command Code.
*
* Returns OAuth credentials where access == refresh == the user's API key.
* The keys don't expire, so we set a far-future expiry.
*/
export async function login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
const choice = await chooseLoginFlow(callbacks)

if (choice.type === "apiKey") return credentialsFromApiKey(choice.apiKey)
if (choice.type === "prompt") {
return promptForApiKey(callbacks, "Paste your Command Code API key:")
}

return browserLogin(callbacks)
}

/**
* Command Code API keys don't expire, so "refresh" is a no-op.
* Returns the same credentials with an updated far-future expiry.
Expand Down
156 changes: 0 additions & 156 deletions src/types.ts

This file was deleted.

Loading
Loading