Skip to content
Merged
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
61 changes: 40 additions & 21 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,8 @@
* Command Code provider for pi.
*
* Connects pi to Command Code's API (https://api.commandcode.ai/alpha/generate).
*
* Authentication (pick one):
* 1. Run `/login`, then select Command Code — opens browser to commandcode.ai, auto-stores API key
* 2. Set COMMANDCODE_API_KEY environment variable
* 3. Place API key in `~/.commandcode/auth.json` or `~/.pi/agent/auth.json`
* as {"apiKey": "user_..."} or {"commandcode": "user_..."}
*
* Models are fetched from Command Code's Provider API at startup.
* The provider uses pi's legacy extension registration surface because the
* current pi host exposes `registerProvider(name, config)`, including OMP.
*/

import { AssistantMessageEventStream } from "@earendil-works/pi-ai"
Expand All @@ -21,8 +15,12 @@ import {
} from "@earendil-works/pi-coding-agent"
import { join } from "node:path"

import { getApiKey as getStoredApiKey } from "./src/converters.ts"
import { COMMAND_CODE_CLI_VERSION, createStreamCommandCode, DEFAULT_API_BASE } from "./src/core.ts"
import {
COMMAND_CODE_CLI_VERSION,
COMMAND_CODE_INPUT_TYPES,
createStreamCommandCode,
DEFAULT_API_BASE,
} from "./src/core.ts"
import { calculateCommandCodeCost } from "./src/cost.ts"
import {
DEFAULT_MODELS_URL,
Expand All @@ -34,6 +32,7 @@ import {
import { getApiKey as getOAuthApiKey, login, refreshToken } from "./src/oauth.ts"
import { MODEL_COSTS, ZERO_MODEL_COST } from "./src/pricing.ts"
import { createCommandCodeRuntime } from "./src/runtime.ts"
import { normalizeCommandCodeMessage } from "./src/overflow.ts"

function createProviderConfig(
models: readonly CommandCodeModel[],
Expand All @@ -43,32 +42,46 @@ function createProviderConfig(
return {
name: "Command Code",
baseUrl: apiBase,
// Keep environment authentication dynamic. OAuth credentials are resolved
// by pi's oauth registration, while the custom stream retains its own
// request-time legacy-file fallback for older compatible hosts.
apiKey: "$COMMANDCODE_API_KEY",
authHeader: true,
api: "commandcode-custom",
streamSimple: streamCommandCode,
headers: {
"x-commandcode-version": COMMAND_CODE_CLI_VERSION,
"x-command-code-version": COMMAND_CODE_CLI_VERSION,
"x-cli-environment": "production",
},
oauth: {
name: "Command Code",
login,
refreshToken,
getApiKey: getOAuthApiKey,
},
models: models.map((model) => ({
id: model.id,
name: model.name,
reasoning: model.reasoning,
...(thinkingMetadataForModel(model.id) ?? {}),
input: ["text"] as const,
cost: MODEL_COSTS[model.id] ?? ZERO_MODEL_COST,
contextWindow: model.contextWindow,
maxTokens: model.maxTokens,
})),
models: models.map(createProviderModel),
}
}

function createProviderModel(model: {
id: string
name: string
reasoning: boolean
contextWindow: number
maxTokens: number
}) {
return {
id: model.id,
name: model.name,
reasoning: model.reasoning,
...(thinkingMetadataForModel(model.id) ?? {}),
input: COMMAND_CODE_INPUT_TYPES,
cost: MODEL_COSTS[model.id] ?? ZERO_MODEL_COST,
contextWindow: model.contextWindow,
maxTokens: model.maxTokens,
} as const
}

export default async function (pi: ExtensionAPI) {
const apiBase = process.env.COMMANDCODE_API_BASE ?? DEFAULT_API_BASE
const modelsUrl = process.env.COMMANDCODE_MODELS_URL ?? DEFAULT_MODELS_URL
Expand All @@ -81,6 +94,12 @@ export default async function (pi: ExtensionAPI) {
apiBase,
})

pi.on("message_end", async (event, ctx) => {
if (event.message.role !== "assistant") return
const normalized = normalizeCommandCodeMessage(event.message, ctx.model?.provider)
return normalized ? { message: normalized.message } : undefined
})

const runtime = createCommandCodeRuntime<ProviderConfig, ExtensionCommandContext>(pi, {
endpoint: modelsUrl,
cachePath: modelsCachePath,
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,14 @@
"LICENSE"
],
"scripts": {
"test": "npm run typecheck && tsx tests/test-package-manifest.ts && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-pricing.ts && tsx tests/test-cost.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-stream.ts && tsx tests/test-retry.ts && node tests/test-pi-isolated.mjs && node tests/test-pi-authenticated.mjs && node tests/test-pi-local.mjs && node tests/test-omp-compat.mjs",
"test": "npm run typecheck && tsx tests/test-package-manifest.ts && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-pricing.ts && tsx tests/test-cost.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-overflow.ts && tsx tests/test-stream.ts && tsx tests/test-retry.ts && node tests/test-pi-isolated.mjs && node tests/test-pi-authenticated.mjs && node tests/test-pi-local.mjs && node tests/test-omp-compat.mjs",
"typecheck": "tsc --noEmit",
"format:check": "prettier --check '**/*.{ts,mjs,json,md}'",
"format": "prettier --write '**/*.{ts,mjs,json,md}'",
"pi:isolated": "node scripts/pi-isolated.mjs",
"pi:authenticated": "node scripts/pi-authenticated.mjs",
"test:unit": "tsx tests/test-pure-functions.ts",
"test:overflow": "tsx tests/test-overflow.ts",
"test:models": "tsx tests/test-models.ts",
"test:pricing": "tsx tests/test-pricing.ts",
"test:oauth": "tsx tests/test-oauth.ts",
Expand Down
105 changes: 27 additions & 78 deletions src/converters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { homedir } from "node:os"
import { join } from "node:path"

import type { MessageLike, StopReason, ToolLike } from "./types.ts"
import { toJsonSchema } from "./json-schema.ts"

export { toJsonSchema } from "./json-schema.ts"

export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
Expand All @@ -12,10 +15,6 @@ export function stringValue(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined
}

function booleanValue(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined
}

export function recordArray(value: unknown): readonly Record<string, unknown>[] {
if (!Array.isArray(value)) return []
return value.filter(isRecord)
Expand Down Expand Up @@ -56,6 +55,26 @@ function apiKeyFromCredentialRecord(value: unknown): string | undefined {
return stringValue(value.key) ?? stringValue(value.access)
}

function hasImageContent(value: unknown): boolean {
if (isRecord(value)) return value.type === "image"
return recordArray(value).some((part) => part.type === "image")
}

function imageContentError(role: string): Error {
return new Error(
`Command Code does not support image content in ${role}; refusing to send it to avoid lossy handling`,
)
}

export function assertTextOnlyMessages(messages?: readonly MessageLike[]): void {
for (const message of messages ?? []) {
if (hasImageContent(message.content)) {
const role = message.role === "toolResult" ? "tool results" : `${message.role} messages`
throw imageContentError(role)
}
}
}

export function getApiKey(
options: {
env?: NodeJS.ProcessEnv
Expand Down Expand Up @@ -96,6 +115,8 @@ export function getApiKey(
}

export function textContent(message: { content?: unknown }): string {
if (hasImageContent(message.content)) throw imageContentError("tool results")

return recordArray(message.content)
.filter((part) => part.type === "text")
.map((part) => stringValue(part.text) ?? "")
Expand All @@ -106,80 +127,6 @@ export function getEnvironmentInfo(): string {
return `${process.platform}-${process.arch}, Node.js ${process.version}`
}

export function toJsonSchema(schema: unknown): unknown {
if (!isRecord(schema)) return {}

const kind = stringValue(schema.kind) ?? stringValue(schema.type)
const enumValues = Array.isArray(schema.enum) ? schema.enum : undefined
if (enumValues) {
return { type: typeof enumValues[0], enum: enumValues }
}

switch (kind) {
case "string":
case "String":
return { type: "string" }
case "number":
case "Number":
return { type: "number" }
case "boolean":
case "Boolean":
return { type: "boolean" }
case "object":
case "Object": {
const properties: Record<string, unknown> = {}
const inferredRequired: string[] = []
const sourceProperties = isRecord(schema.properties) ? schema.properties : undefined
const optional = Array.isArray(schema.optional)
? schema.optional.filter((item): item is string => typeof item === "string")
: []

if (sourceProperties) {
for (const [key, value] of Object.entries(sourceProperties)) {
properties[key] = toJsonSchema(value)
const valueRecord = isRecord(value) ? value : undefined
if (booleanValue(valueRecord?.optional) !== true && !optional.includes(key)) {
inferredRequired.push(key)
}
}
}

const explicitRequired = Array.isArray(schema.required)
? schema.required.filter((item): item is string => typeof item === "string")
: undefined
const required = explicitRequired ?? inferredRequired
const out: Record<string, unknown> = { type: "object" }
if (Object.keys(properties).length > 0) out.properties = properties
if (required.length > 0) out.required = required
return out
}
case "array":
case "Array":
return {
type: "array",
items: toJsonSchema(schema.items ?? schema.element),
}
case "union":
case "Union": {
const variants = Array.isArray(schema.variants)
? schema.variants
: Array.isArray(schema.anyOf)
? schema.anyOf
: []
for (const variant of variants) {
const converted = toJsonSchema(variant)
if (isRecord(converted) && Object.keys(converted).length > 0) return converted
}
return {}
}
case "optional":
case "Optional":
return toJsonSchema(schema.wrapped ?? schema.inner)
default:
return {}
}
}

export function toolsToJson(tools?: readonly ToolLike[]): unknown[] {
if (!tools) return []
return tools.map((tool) => ({
Expand Down Expand Up @@ -211,6 +158,8 @@ function completeToolCallIds(messages?: readonly MessageLike[]): Set<string> {
}

export function messagesToCC(messages?: readonly MessageLike[]): unknown[] {
assertTextOnlyMessages(messages)

const out: unknown[] = []
const pairedToolCallIds = completeToolCallIds(messages)

Expand Down
Loading
Loading