diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..13474c9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +## 0.1.1 - 2026-05-26 + +- Align Command Code generate requests with CLI `0.27.2` headers and payload shape. +- Support official Command Code CLI auth files using the `command-code` credential key. +- Handle `reasoning-start` and ignore streamed `tool-result` events. +- Cap generated `max_tokens` by the selected model and the Command Code output limit. + +## 0.1.0 - 2026-05-05 + +- Initial public release. diff --git a/README.md b/README.md index cffca47..3622c42 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,17 @@ Create `~/.commandcode/auth.json`: } ``` +The official Command Code CLI auth shape is also supported: + +```json +{ + "command-code": { + "type": "api", + "key": "user_..." + } +} +``` + Or use pi's auth file at `~/.pi/agent/auth.json`: ```json diff --git a/index.ts b/index.ts index 917f268..6049d7b 100644 --- a/index.ts +++ b/index.ts @@ -15,7 +15,7 @@ import { calculateCost, createAssistantMessageEventStream } from "@mariozechner/pi-ai" import type { ExtensionAPI } from "@mariozechner/pi-coding-agent" -import { createStreamCommandCode, DEFAULT_API_BASE } from "./src/core.ts" +import { COMMAND_CODE_CLI_VERSION, createStreamCommandCode, DEFAULT_API_BASE } from "./src/core.ts" import { DEFAULT_MODELS_URL, fetchCommandCodeModels } from "./src/models.ts" import { getApiKey, login, refreshToken } from "./src/oauth.ts" @@ -43,7 +43,7 @@ export default async function (pi: ExtensionAPI) { api: "commandcode-custom", streamSimple: streamCommandCode, headers: { - "x-command-code-version": "0.24.1", + "x-command-code-version": COMMAND_CODE_CLI_VERSION, "x-cli-environment": "production", }, oauth: { diff --git a/package-lock.json b/package-lock.json index 070f3f1..cbcca43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-commandcode-provider", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-commandcode-provider", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "dependencies": { "@mariozechner/pi-ai": "0.72.0" diff --git a/package.json b/package.json index 971b815..93a3d6e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-commandcode-provider", - "version": "0.1.0", + "version": "0.1.1", "description": "pi custom provider for Command Code API (commandcode.ai)", "type": "module", "keywords": [ @@ -22,6 +22,7 @@ "index.ts", "src/", "README.md", + "CHANGELOG.md", "LICENSE" ], "scripts": { diff --git a/src/converters.ts b/src/converters.ts index 8eaaaf1..a9f5242 100644 --- a/src/converters.ts +++ b/src/converters.ts @@ -42,6 +42,16 @@ function defaultAuthPaths(home: string): string[] { return [join(home, ".commandcode", "auth.json"), join(home, ".pi", "agent", "auth.json")] } +function apiKeyFromCredentialRecord(value: unknown): string | undefined { + if (!isRecord(value)) return undefined + + const type = stringValue(value.type) + if (type === "api") return stringValue(value.key) + if (type === "oauth") return stringValue(value.access) + + return stringValue(value.key) ?? stringValue(value.access) +} + export function getApiKey( options: { env?: NodeJS.ProcessEnv @@ -61,18 +71,18 @@ export function getApiKey( const parsed: unknown = JSON.parse(readFileSync(authPath, "utf-8")) if (!isRecord(parsed)) continue - // Legacy: direct apiKey or commandcode field + // Legacy: direct apiKey or commandcode field. const apiKey = stringValue(parsed.apiKey) if (apiKey) return apiKey const commandcode = stringValue(parsed.commandcode) if (commandcode) return commandcode - // OAuth: pi stores OAuth credentials as {"commandcode": {"type":"oauth","access":"...","refresh":"...","expires":...}} - const providerKey = isRecord(parsed.commandcode) ? parsed.commandcode : undefined - if (providerKey && stringValue(providerKey.type) === "oauth") { - const access = stringValue(providerKey.access) - if (access) return access - } + // pi stores OAuth credentials as {"commandcode": {"type":"oauth","access":"..."}}. + // The official Command Code CLI stores API credentials under "command-code". + const providerKey = + apiKeyFromCredentialRecord(parsed.commandcode) ?? + apiKeyFromCredentialRecord(parsed["command-code"]) + if (providerKey) return providerKey } catch { // Ignore malformed or unreadable auth files. } diff --git a/src/core.ts b/src/core.ts index 1373073..901fdb1 100644 --- a/src/core.ts +++ b/src/core.ts @@ -38,6 +38,9 @@ export * from "./converters.ts" export * from "./types.ts" export const DEFAULT_API_BASE = "https://api.commandcode.ai" +export const COMMAND_CODE_CLI_VERSION = "0.27.2" + +const DEFAULT_GENERATE_MAX_TOKENS = 64_000 function defaultUsage(): Usage { return { @@ -77,6 +80,23 @@ function successStopReason(reason: TerminalReason): StopReason { return "stop" } +function generateMaxTokens(model: ModelLike, options?: StreamOptions): number { + return Math.min( + options?.maxTokens ?? model.maxTokens, + model.maxTokens, + DEFAULT_GENERATE_MAX_TOKENS, + ) +} + +export function projectSlugFromPath(pathName: string): string { + const slug = pathName + .toLowerCase() + .replace(/^[a-z]:/i, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + return slug || "project" +} + export function createStreamCommandCode(deps: CoreDependencies) { const apiBase = deps.apiBase ?? DEFAULT_API_BASE const fetchImpl = deps.fetchImpl ?? fetch @@ -235,7 +255,13 @@ export function createStreamCommandCode(deps: CoreDependencies) { break } + case "reasoning-start": { + endTextBlock() + break + } + case "reasoning-delta": { + endTextBlock() thinkingBlock.push(stringValue(event.text) ?? "") break } @@ -245,6 +271,10 @@ export function createStreamCommandCode(deps: CoreDependencies) { break } + case "tool-result": { + break + } + case "tool-call": { endTextBlock() const toolCall: ToolCallContent = { @@ -303,9 +333,12 @@ export function createStreamCommandCode(deps: CoreDependencies) { try { stream.push({ type: "start", partial: output }) + const workingDir = cwd() + const threadId = uuid() + let body: unknown = { config: { - workingDir: cwd(), + workingDir, date: new Date(now()).toISOString().split("T")[0], environment: getEnvironmentInfo(), structure: [], @@ -315,18 +348,19 @@ export function createStreamCommandCode(deps: CoreDependencies) { gitStatus: "", recentCommits: [], }, - memory: "", - taste: "", + memory: null, + taste: null, skills: null, - permissionMode: "standard", params: { model: model.id, messages: messagesToCC(context.messages), tools: toolsToJson(context.tools), system: context.systemPrompt ?? "", - max_tokens: Math.min(options?.maxTokens ?? model.maxTokens, 200_000), + max_tokens: generateMaxTokens(model, options), + temperature: 0.3, stream: true, }, + threadId, } const nextBody = await raceAbort( @@ -341,12 +375,11 @@ export function createStreamCommandCode(deps: CoreDependencies) { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}`, - "x-command-code-version": "0.24.1", + "x-command-code-version": COMMAND_CODE_CLI_VERSION, "x-cli-environment": "production", - "x-project-slug": "pi-cc", - "x-taste-learning": "false", + "x-project-slug": projectSlugFromPath(workingDir), + "x-taste-learning": "true", "x-co-flag": "false", - "x-session-id": uuid(), ...options?.headers, }, body: JSON.stringify(body), diff --git a/tests/test-pure-functions.ts b/tests/test-pure-functions.ts index 751f573..96ac37b 100644 --- a/tests/test-pure-functions.ts +++ b/tests/test-pure-functions.ts @@ -15,6 +15,7 @@ import { mapFinishReason, messagesToCC, parseStreamEventLine, + projectSlugFromPath, textContent, toJsonSchema, toolsToJson, @@ -27,12 +28,13 @@ describe("getApiKey()", () => { assert.equal(getApiKey({ env: { COMMANDCODE_API_KEY: "env-key" }, authPaths: [] }), "env-key") }) - it("reads apiKey, commandcode, and pi OAuth credential fields from explicit auth paths", () => { + it("reads apiKey, commandcode, pi OAuth, and official CLI credential fields", () => { const dir = mkdtempSync(join(tmpdir(), "cc-auth-")) try { const first = join(dir, "first.json") const second = join(dir, "second.json") const oauth = join(dir, "oauth.json") + const official = join(dir, "official.json") writeFileSync(first, JSON.stringify({ apiKey: "file-key" })) writeFileSync(second, JSON.stringify({ commandcode: "fallback-key" })) writeFileSync( @@ -46,9 +48,19 @@ describe("getApiKey()", () => { }, }), ) + writeFileSync( + official, + JSON.stringify({ + "command-code": { + type: "api", + key: "official-cli-key", + }, + }), + ) assert.equal(getApiKey({ env: {}, authPaths: [first, second] }), "file-key") assert.equal(getApiKey({ env: {}, authPaths: [second] }), "fallback-key") assert.equal(getApiKey({ env: {}, authPaths: [oauth] }), "oauth-access-key") + assert.equal(getApiKey({ env: {}, authPaths: [official] }), "official-cli-key") } finally { rmSync(dir, { recursive: true, force: true }) } @@ -78,6 +90,16 @@ describe("getApiKey()", () => { }) }) +describe("projectSlugFromPath()", () => { + it("matches the official CLI-style slug from an absolute working directory", () => { + assert.equal( + projectSlugFromPath("/Users/patwoz/dev/Personal/pi/pi-commandcode-provider"), + "users-patwoz-dev-personal-pi-pi-commandcode-provider", + ) + assert.equal(projectSlugFromPath("/repo"), "repo") + }) +}) + describe("textContent()", () => { it("extracts and joins text blocks", () => { assert.equal( diff --git a/tests/test-stream.ts b/tests/test-stream.ts index 8cebadb..800a972 100644 --- a/tests/test-stream.ts +++ b/tests/test-stream.ts @@ -142,6 +142,7 @@ describe("streamCommandCode — successful streams", () => { server.mockResponse({ type: "success", events: [ + JSON.stringify({ type: "reasoning-start" }), JSON.stringify({ type: "reasoning-delta", text: "think" }), JSON.stringify({ type: "reasoning-end" }), JSON.stringify({ type: "text-delta", text: "Using tool" }), @@ -244,7 +245,13 @@ describe("streamCommandCode — request serialization", () => { assert.equal(objectAt(body, ["params", "model"]), "deepseek/deepseek-v4-flash") assert.equal(objectAt(body, ["params", "stream"]), true) assert.equal(objectAt(body, ["params", "max_tokens"]), 500) + assert.equal(objectAt(body, ["params", "temperature"]), 0.3) assert.equal(objectAt(body, ["params", "system"]), "You are a test assistant.") + assert.equal(objectAt(body, ["memory"]), null) + assert.equal(objectAt(body, ["taste"]), null) + assert.equal(objectAt(body, ["skills"]), null) + assert.equal(objectAt(body, ["permissionMode"]), undefined) + assert.equal(objectAt(body, ["threadId"]), "00000000-0000-4000-8000-000000000000") assert.equal( objectAt(body, ["params", "messages", "1", "content", "0", "text"]), "first response", @@ -253,8 +260,11 @@ describe("streamCommandCode — request serialization", () => { const headers = server.lastRequestHeaders() assert.equal(headers.authorization, "Bearer mock-key") - assert.equal(headers["x-command-code-version"], "0.24.1") - assert.equal(headers["x-session-id"], "00000000-0000-4000-8000-000000000000") + assert.equal(headers["x-command-code-version"], "0.27.2") + assert.equal(headers["x-project-slug"], "repo") + assert.equal(headers["x-taste-learning"], "true") + assert.equal(headers["x-co-flag"], "false") + assert.equal(headers["x-session-id"], undefined) }) it("caps maxTokens and passes custom headers", async () => { @@ -272,10 +282,26 @@ describe("streamCommandCode — request serialization", () => { }), ) - assert.equal(objectAt(server.lastRequestBody(), ["params", "max_tokens"]), 200_000) + assert.equal(objectAt(server.lastRequestBody(), ["params", "max_tokens"]), 64_000) assert.equal(server.lastRequestHeaders()["x-custom"], "value") }) + it("caps default maxTokens by the selected model", async () => { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + await collectEvents( + streamCommandCode(makeModel({ maxTokens: 8_192 }), makeContext(), { + apiKey: "mock-key", + }), + ) + + assert.equal(objectAt(server.lastRequestBody(), ["params", "max_tokens"]), 8_192) + }) + it("runs onPayload and onResponse hooks", async () => { server.mockResponse({ type: "success",