chore: add CI workflow (typecheck + prettier format check), tsconfig, and prettier formatting
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Local HTTP callback server for the Command Code browser auth flow.
|
||||
*
|
||||
* Starts a one-shot server on a random port. The Command Code Studio
|
||||
* website POSTs the user's API key to /callback after they authenticate.
|
||||
*/
|
||||
|
||||
import { createServer, type Server } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
export interface AuthCallback {
|
||||
apiKey: string;
|
||||
state: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
keyName: string;
|
||||
}
|
||||
|
||||
export interface AuthServer {
|
||||
server: Server;
|
||||
port: number;
|
||||
waitForCallback: Promise<AuthCallback>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a local HTTP server that listens for the Command Code Studio
|
||||
* to POST the API key after the user authenticates in their browser.
|
||||
*
|
||||
* The server accepts exactly one valid POST to /callback and then closes.
|
||||
*/
|
||||
export function startAuthServer(): Promise<AuthServer> {
|
||||
let resolveCallback: (value: AuthCallback) => void;
|
||||
let rejectCallback: (error: Error) => void;
|
||||
|
||||
const waitForCallback = new Promise<AuthCallback>((resolve, reject) => {
|
||||
resolveCallback = resolve;
|
||||
rejectCallback = reject;
|
||||
});
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
// CORS: allow requests from Command Code domains and localhost for dev
|
||||
const origin = req.headers.origin || "";
|
||||
const allowedOrigins = [
|
||||
"http://localhost:3000",
|
||||
"https://staging.commandcode.ai",
|
||||
"https://commandcode.ai",
|
||||
];
|
||||
const responseOrigin = allowedOrigins.includes(origin)
|
||||
? origin
|
||||
: allowedOrigins[0];
|
||||
|
||||
res.setHeader("Access-Control-Allow-Origin", responseOrigin);
|
||||
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
|
||||
// Handle CORS preflight
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url !== "/callback") {
|
||||
res.writeHead(404);
|
||||
res.end(JSON.stringify({ success: false, error: "Not found" }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method !== "POST") {
|
||||
res.writeHead(405);
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: "Method not allowed. Use POST.",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let body = "";
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk.toString();
|
||||
if (body.length > 10_000) req.destroy();
|
||||
});
|
||||
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const parsed = JSON.parse(body) as Record<string, unknown>;
|
||||
|
||||
if (parsed.error) {
|
||||
res.writeHead(200);
|
||||
res.end(JSON.stringify({ success: true }));
|
||||
const description =
|
||||
typeof parsed.error_description === "string"
|
||||
? parsed.error_description
|
||||
: String(parsed.error);
|
||||
if (parsed.error === "access_denied") {
|
||||
rejectCallback(
|
||||
new Error(description || "Authorization was denied by the user"),
|
||||
);
|
||||
} else {
|
||||
rejectCallback(new Error(description || String(parsed.error)));
|
||||
}
|
||||
server.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = typeof parsed.apiKey === "string" ? parsed.apiKey : "";
|
||||
const state = typeof parsed.state === "string" ? parsed.state : "";
|
||||
const userId = typeof parsed.userId === "string" ? parsed.userId : "";
|
||||
const userName =
|
||||
typeof parsed.userName === "string" ? parsed.userName : "";
|
||||
const keyName =
|
||||
typeof parsed.keyName === "string" ? parsed.keyName : "";
|
||||
|
||||
if (!apiKey || !state || !userId || !userName || !keyName) {
|
||||
res.writeHead(400);
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: "Missing required fields",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200);
|
||||
res.end(JSON.stringify({ success: true }));
|
||||
|
||||
resolveCallback({ apiKey, state, userId, userName, keyName });
|
||||
server.close();
|
||||
} catch {
|
||||
res.writeHead(400);
|
||||
res.end(JSON.stringify({ success: false, error: "Invalid JSON" }));
|
||||
}
|
||||
});
|
||||
|
||||
req.on("error", () => {
|
||||
res.writeHead(500);
|
||||
res.end(JSON.stringify({ success: false, error: "Request error" }));
|
||||
});
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server.on("error", (err: NodeJS.ErrnoException) => {
|
||||
rejectCallback(new Error(`Failed to start auth server: ${err.message}`));
|
||||
});
|
||||
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address() as AddressInfo;
|
||||
resolve({ server, port: address.port, waitForCallback });
|
||||
});
|
||||
});
|
||||
}
|
||||
+91
-19
@@ -16,17 +16,30 @@ function booleanValue(value: unknown): boolean | undefined {
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
|
||||
export function recordArray(value: unknown): readonly Record<string, unknown>[] {
|
||||
export function recordArray(
|
||||
value: unknown,
|
||||
): readonly Record<string, unknown>[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter(isRecord);
|
||||
}
|
||||
|
||||
export function recordOrEmpty(value: unknown): Record<string, unknown> {
|
||||
return isRecord(value) ? value : {};
|
||||
if (isRecord(value)) return value;
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (isRecord(parsed)) return parsed;
|
||||
} catch {
|
||||
// Some providers stream incomplete JSON argument fragments.
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function numberValue(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function defaultAuthPaths(home: string): string[] {
|
||||
@@ -36,11 +49,13 @@ function defaultAuthPaths(home: string): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
export function getApiKey(options: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
authPaths?: readonly string[];
|
||||
homeDir?: () => string;
|
||||
} = {}): string | undefined {
|
||||
export function getApiKey(
|
||||
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;
|
||||
|
||||
@@ -52,10 +67,21 @@ export function getApiKey(options: {
|
||||
if (!existsSync(authPath)) continue;
|
||||
const parsed: unknown = JSON.parse(readFileSync(authPath, "utf-8"));
|
||||
if (!isRecord(parsed)) continue;
|
||||
|
||||
// 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;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed or unreadable auth files.
|
||||
}
|
||||
@@ -98,23 +124,32 @@ export function toJsonSchema(schema: unknown): unknown {
|
||||
case "Object": {
|
||||
const properties: Record<string, unknown> = {};
|
||||
const inferredRequired: string[] = [];
|
||||
const sourceProperties = isRecord(schema.properties) ? schema.properties : undefined;
|
||||
const sourceProperties = isRecord(schema.properties)
|
||||
? schema.properties
|
||||
: undefined;
|
||||
const optional = Array.isArray(schema.optional)
|
||||
? schema.optional.filter((item): item is string => typeof item === "string")
|
||||
? 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)) {
|
||||
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")
|
||||
? schema.required.filter(
|
||||
(item): item is string => typeof item === "string",
|
||||
)
|
||||
: undefined;
|
||||
const required = explicitRequired ?? inferredRequired;
|
||||
const out: Record<string, unknown> = { type: "object" };
|
||||
@@ -124,7 +159,10 @@ export function toJsonSchema(schema: unknown): unknown {
|
||||
}
|
||||
case "array":
|
||||
case "Array":
|
||||
return { type: "array", items: toJsonSchema(schema.items ?? schema.element) };
|
||||
return {
|
||||
type: "array",
|
||||
items: toJsonSchema(schema.items ?? schema.element),
|
||||
};
|
||||
case "union":
|
||||
case "Union": {
|
||||
const variants = Array.isArray(schema.variants)
|
||||
@@ -134,7 +172,8 @@ export function toJsonSchema(schema: unknown): unknown {
|
||||
: [];
|
||||
for (const variant of variants) {
|
||||
const converted = toJsonSchema(variant);
|
||||
if (isRecord(converted) && Object.keys(converted).length > 0) return converted;
|
||||
if (isRecord(converted) && Object.keys(converted).length > 0)
|
||||
return converted;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -156,13 +195,38 @@ export function toolsToJson(tools?: readonly ToolLike[]): unknown[] {
|
||||
}));
|
||||
}
|
||||
|
||||
function completeToolCallIds(messages?: readonly MessageLike[]): Set<string> {
|
||||
const callIds = new Set<string>();
|
||||
const resultIds = new Set<string>();
|
||||
|
||||
for (const message of messages ?? []) {
|
||||
if (message.role === "assistant") {
|
||||
for (const content of recordArray(message.content)) {
|
||||
if (content.type === "toolCall") {
|
||||
const id = stringValue(content.id);
|
||||
if (id) callIds.add(id);
|
||||
}
|
||||
}
|
||||
} else if (message.role === "toolResult") {
|
||||
if (message.toolCallId) resultIds.add(message.toolCallId);
|
||||
}
|
||||
}
|
||||
|
||||
return new Set([...callIds].filter((id) => resultIds.has(id)));
|
||||
}
|
||||
|
||||
export function messagesToCC(messages?: readonly MessageLike[]): unknown[] {
|
||||
const out: unknown[] = [];
|
||||
const pairedToolCallIds = completeToolCallIds(messages);
|
||||
|
||||
for (const message of messages ?? []) {
|
||||
if (message.role === "user") {
|
||||
out.push({
|
||||
role: "user",
|
||||
content: typeof message.content === "string" ? message.content : message.content,
|
||||
content:
|
||||
typeof message.content === "string"
|
||||
? message.content
|
||||
: message.content,
|
||||
});
|
||||
} else if (message.role === "assistant") {
|
||||
const parts: unknown[] = [];
|
||||
@@ -170,18 +234,25 @@ export function messagesToCC(messages?: readonly MessageLike[]): unknown[] {
|
||||
if (content.type === "text") {
|
||||
parts.push({ type: "text", text: stringValue(content.text) ?? "" });
|
||||
} else if (content.type === "thinking") {
|
||||
parts.push({ type: "reasoning", text: stringValue(content.thinking) ?? "" });
|
||||
parts.push({
|
||||
type: "reasoning",
|
||||
text: stringValue(content.thinking) ?? "",
|
||||
});
|
||||
} else if (content.type === "toolCall") {
|
||||
const toolCallId = stringValue(content.id) ?? "";
|
||||
if (!pairedToolCallIds.has(toolCallId)) continue;
|
||||
parts.push({
|
||||
type: "tool-call",
|
||||
toolCallId: stringValue(content.id) ?? "",
|
||||
toolCallId,
|
||||
toolName: stringValue(content.name) ?? "",
|
||||
input: recordOrEmpty(content.arguments),
|
||||
});
|
||||
}
|
||||
}
|
||||
out.push({ role: "assistant", content: parts });
|
||||
if (parts.length > 0) out.push({ role: "assistant", content: parts });
|
||||
} else if (message.role === "toolResult") {
|
||||
if (!message.toolCallId || !pairedToolCallIds.has(message.toolCallId))
|
||||
continue;
|
||||
out.push({
|
||||
role: "tool",
|
||||
content: [
|
||||
@@ -202,7 +273,8 @@ export function messagesToCC(messages?: readonly MessageLike[]): unknown[] {
|
||||
|
||||
export function parseStreamEventLine(line: string): unknown | undefined {
|
||||
let trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith(":") || trimmed.startsWith("event:")) return undefined;
|
||||
if (!trimmed || trimmed.startsWith(":") || trimmed.startsWith("event:"))
|
||||
return undefined;
|
||||
if (trimmed.startsWith("data:")) trimmed = trimmed.slice(5).trim();
|
||||
if (!trimmed || trimmed === "[DONE]") return undefined;
|
||||
|
||||
|
||||
+112
-32
@@ -50,12 +50,18 @@ function defaultUsage(): Usage {
|
||||
};
|
||||
}
|
||||
|
||||
function commandCodeUsage(event: Record<string, unknown>): Record<string, unknown> | undefined {
|
||||
function commandCodeUsage(
|
||||
event: Record<string, unknown>,
|
||||
): Record<string, unknown> | undefined {
|
||||
return isRecord(event.totalUsage) ? event.totalUsage : undefined;
|
||||
}
|
||||
|
||||
function commandCodeInputTokenDetails(usage: Record<string, unknown>): Record<string, unknown> | undefined {
|
||||
return isRecord(usage.inputTokenDetails) ? usage.inputTokenDetails : undefined;
|
||||
function commandCodeInputTokenDetails(
|
||||
usage: Record<string, unknown>,
|
||||
): Record<string, unknown> | undefined {
|
||||
return isRecord(usage.inputTokenDetails)
|
||||
? usage.inputTokenDetails
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function headersToRecord(headers: Headers): Record<string, string> {
|
||||
@@ -109,11 +115,13 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
||||
const stream = deps.createStream();
|
||||
|
||||
async function run() {
|
||||
const apiKey = options?.apiKey ?? getApiKey({
|
||||
env: deps.env,
|
||||
authPaths: deps.authPaths,
|
||||
homeDir: deps.homeDir,
|
||||
});
|
||||
const apiKey =
|
||||
options?.apiKey ??
|
||||
getApiKey({
|
||||
env: deps.env,
|
||||
authPaths: deps.authPaths,
|
||||
homeDir: deps.homeDir,
|
||||
});
|
||||
|
||||
if (!apiKey) {
|
||||
const msg: AssistantMessageLike = {
|
||||
@@ -125,7 +133,7 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
||||
usage: defaultUsage(),
|
||||
stopReason: "error",
|
||||
errorMessage:
|
||||
"No Command Code API key. Set COMMANDCODE_API_KEY env var or configure ~/.commandcode/auth.json or ~/.pi/agent/auth.json.",
|
||||
"No Command Code API key. Run /login commandcode, set COMMANDCODE_API_KEY env var, or configure ~/.commandcode/auth.json or ~/.pi/agent/auth.json.",
|
||||
timestamp: now(),
|
||||
};
|
||||
stream.push({ type: "error", reason: "error", error: msg });
|
||||
@@ -163,7 +171,9 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
||||
if (options?.signal?.aborted) {
|
||||
abortUpstream();
|
||||
} else {
|
||||
options?.signal?.addEventListener("abort", abortUpstream, { once: true });
|
||||
options?.signal?.addEventListener("abort", abortUpstream, {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
|
||||
const endTextBlock = () => {
|
||||
@@ -184,9 +194,23 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
||||
thinkingBlock = [];
|
||||
output.content.push({ type: "thinking", thinking: thinkingText });
|
||||
const idx = output.content.length - 1;
|
||||
stream.push({ type: "thinking_start", contentIndex: idx, partial: output });
|
||||
stream.push({ type: "thinking_delta", contentIndex: idx, delta: thinkingText, partial: output });
|
||||
stream.push({ type: "thinking_end", contentIndex: idx, content: thinkingText, partial: output });
|
||||
stream.push({
|
||||
type: "thinking_start",
|
||||
contentIndex: idx,
|
||||
partial: output,
|
||||
});
|
||||
stream.push({
|
||||
type: "thinking_delta",
|
||||
contentIndex: idx,
|
||||
delta: thinkingText,
|
||||
partial: output,
|
||||
});
|
||||
stream.push({
|
||||
type: "thinking_end",
|
||||
contentIndex: idx,
|
||||
content: thinkingText,
|
||||
partial: output,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEvent = (event: unknown) => {
|
||||
@@ -198,11 +222,20 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
||||
textBlock = { type: "text", text: "" };
|
||||
output.content.push(textBlock);
|
||||
currentTextIdx = output.content.length - 1;
|
||||
stream.push({ type: "text_start", contentIndex: currentTextIdx, partial: output });
|
||||
stream.push({
|
||||
type: "text_start",
|
||||
contentIndex: currentTextIdx,
|
||||
partial: output,
|
||||
});
|
||||
}
|
||||
const delta = stringValue(event.text) ?? "";
|
||||
textBlock.text += delta;
|
||||
stream.push({ type: "text_delta", contentIndex: currentTextIdx, delta, partial: output });
|
||||
stream.push({
|
||||
type: "text_delta",
|
||||
contentIndex: currentTextIdx,
|
||||
delta,
|
||||
partial: output,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -222,12 +255,23 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
||||
type: "toolCall",
|
||||
id: stringValue(event.toolCallId) ?? "",
|
||||
name: stringValue(event.toolName) ?? "",
|
||||
arguments: recordOrEmpty(event.input ?? event.args),
|
||||
arguments: recordOrEmpty(
|
||||
event.input ?? event.args ?? event.arguments,
|
||||
),
|
||||
};
|
||||
output.content.push(toolCall);
|
||||
const idx = output.content.length - 1;
|
||||
stream.push({ type: "toolcall_start", contentIndex: idx, partial: output });
|
||||
stream.push({ type: "toolcall_end", contentIndex: idx, toolCall, partial: output });
|
||||
stream.push({
|
||||
type: "toolcall_start",
|
||||
contentIndex: idx,
|
||||
partial: output,
|
||||
});
|
||||
stream.push({
|
||||
type: "toolcall_end",
|
||||
contentIndex: idx,
|
||||
toolCall,
|
||||
partial: output,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -237,8 +281,10 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
||||
const details = commandCodeInputTokenDetails(usage);
|
||||
output.usage.input = numberValue(usage.inputTokens) ?? 0;
|
||||
output.usage.output = numberValue(usage.outputTokens) ?? 0;
|
||||
output.usage.cacheRead = numberValue(details?.cacheReadTokens) ?? 0;
|
||||
output.usage.cacheWrite = numberValue(details?.cacheWriteTokens) ?? 0;
|
||||
output.usage.cacheRead =
|
||||
numberValue(details?.cacheReadTokens) ?? 0;
|
||||
output.usage.cacheWrite =
|
||||
numberValue(details?.cacheWriteTokens) ?? 0;
|
||||
output.usage.totalTokens =
|
||||
output.usage.input +
|
||||
output.usage.output +
|
||||
@@ -253,7 +299,10 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
||||
|
||||
case "error": {
|
||||
const errorRecord = isRecord(event.error) ? event.error : undefined;
|
||||
const message = stringValue(errorRecord?.message) ?? stringValue(event.error) ?? "Stream error";
|
||||
const message =
|
||||
stringValue(errorRecord?.message) ??
|
||||
stringValue(event.error) ??
|
||||
"Stream error";
|
||||
output.stopReason = "error";
|
||||
output.errorMessage = message;
|
||||
throw new Error(message);
|
||||
@@ -285,12 +334,18 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
||||
messages: messagesToCC(context.messages),
|
||||
tools: toolsToJson(context.tools),
|
||||
system: context.systemPrompt ?? "",
|
||||
max_tokens: Math.min(options?.maxTokens ?? model.maxTokens, 200_000),
|
||||
max_tokens: Math.min(
|
||||
options?.maxTokens ?? model.maxTokens,
|
||||
200_000,
|
||||
),
|
||||
stream: true,
|
||||
},
|
||||
};
|
||||
|
||||
const nextBody = await raceAbort(Promise.resolve(options?.onPayload?.(body, model)), controller.signal);
|
||||
const nextBody = await raceAbort(
|
||||
Promise.resolve(options?.onPayload?.(body, model)),
|
||||
controller.signal,
|
||||
);
|
||||
if (nextBody !== undefined) body = nextBody;
|
||||
|
||||
const response = await raceAbort(
|
||||
@@ -314,13 +369,26 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
||||
);
|
||||
|
||||
await raceAbort(
|
||||
Promise.resolve(options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model)),
|
||||
Promise.resolve(
|
||||
options?.onResponse?.(
|
||||
{
|
||||
status: response.status,
|
||||
headers: headersToRecord(response.headers),
|
||||
},
|
||||
model,
|
||||
),
|
||||
),
|
||||
controller.signal,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errBody = await raceAbort(response.text().catch(() => ""), controller.signal);
|
||||
throw new Error(`Command Code API error ${response.status}: ${errBody.slice(0, 500)}`);
|
||||
const errBody = await raceAbort(
|
||||
response.text().catch(() => ""),
|
||||
controller.signal,
|
||||
);
|
||||
throw new Error(
|
||||
`Command Code API error ${response.status}: ${errBody.slice(0, 500)}`,
|
||||
);
|
||||
}
|
||||
|
||||
reader = response.body?.getReader();
|
||||
@@ -331,7 +399,10 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
||||
|
||||
readLoop: for (;;) {
|
||||
if (controller.signal.aborted) throw abortError("Aborted");
|
||||
const { done, value } = await raceAbort(reader.read(), controller.signal);
|
||||
const { done, value } = await raceAbort(
|
||||
reader.read(),
|
||||
controller.signal,
|
||||
);
|
||||
if (done) {
|
||||
if (buffer.trim()) handleEvent(parseStreamEventLine(buffer));
|
||||
break;
|
||||
@@ -352,14 +423,23 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
||||
endTextBlock();
|
||||
flushThinkingBlock();
|
||||
|
||||
stream.push({ type: "done", reason: successStopReason(output.stopReason), message: output });
|
||||
stream.push({
|
||||
type: "done",
|
||||
reason: successStopReason(output.stopReason),
|
||||
message: output,
|
||||
});
|
||||
stream.end();
|
||||
} catch (error: unknown) {
|
||||
const reason: ErrorReason = controller.signal.aborted ? "aborted" : "error";
|
||||
const reason: ErrorReason = controller.signal.aborted
|
||||
? "aborted"
|
||||
: "error";
|
||||
output.stopReason = reason;
|
||||
output.errorMessage = reason === "aborted"
|
||||
? "Request aborted"
|
||||
: error instanceof Error ? error.message : String(error);
|
||||
output.errorMessage =
|
||||
reason === "aborted"
|
||||
? "Request aborted"
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
stream.push({ type: "error", reason, error: output });
|
||||
stream.end();
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 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 random 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. 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.
|
||||
*/
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { startAuthServer } from "./auth-server.ts";
|
||||
|
||||
const STUDIO_BASE_URL = "https://commandcode.ai";
|
||||
const TEN_YEARS_MS = 10 * 365 * 24 * 60 * 60 * 1000; // API keys don't expire
|
||||
|
||||
export interface OAuthLoginCallbacks {
|
||||
onAuth(params: { url: string }): void;
|
||||
onPrompt(params: { message: string }): Promise<string>;
|
||||
}
|
||||
|
||||
export interface OAuthCredentials {
|
||||
refresh: string;
|
||||
access: string;
|
||||
expires: number;
|
||||
}
|
||||
|
||||
function generateStateToken(): string {
|
||||
return randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
const authServer = await startAuthServer();
|
||||
const stateToken = generateStateToken();
|
||||
|
||||
const authUrl = `${STUDIO_BASE_URL}/studio/auth/cli?callback=${encodeURIComponent(`http://localhost:${authServer.port}/callback`)}&state=${encodeURIComponent(stateToken)}`;
|
||||
|
||||
// Tell pi to open the browser
|
||||
callbacks.onAuth({ url: authUrl });
|
||||
|
||||
// Wait for the Command Code Studio to POST the API key back
|
||||
let callback: { apiKey: string; state: string };
|
||||
try {
|
||||
callback = await authServer.waitForCallback;
|
||||
} catch (error) {
|
||||
// Clean up server on error
|
||||
authServer.server.close();
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Validate state token to prevent CSRF
|
||||
if (callback.state !== stateToken) {
|
||||
throw new Error(
|
||||
"State token mismatch. Authentication may have been tampered with.",
|
||||
);
|
||||
}
|
||||
|
||||
// Return as OAuth credentials. Since CC API keys don't expire,
|
||||
// we set a far-future expiry and use the API key as both access and refresh.
|
||||
return {
|
||||
refresh: callback.apiKey,
|
||||
access: callback.apiKey,
|
||||
expires: Date.now() + TEN_YEARS_MS,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Command Code API keys don't expire, so "refresh" is a no-op.
|
||||
* Returns the same credentials with an updated far-future expiry.
|
||||
*/
|
||||
export async function refreshToken(
|
||||
credentials: OAuthCredentials,
|
||||
): Promise<OAuthCredentials> {
|
||||
return {
|
||||
refresh: credentials.refresh,
|
||||
access: credentials.access,
|
||||
expires: Date.now() + TEN_YEARS_MS,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the access token (API key) from OAuth credentials.
|
||||
*/
|
||||
export function getApiKey(credentials: OAuthCredentials): string {
|
||||
return credentials.access;
|
||||
}
|
||||
+48
-9
@@ -87,20 +87,59 @@ export interface StreamOptions {
|
||||
signal?: AbortSignal;
|
||||
headers?: Record<string, string>;
|
||||
maxTokens?: number;
|
||||
onPayload?: (payload: unknown, model: ModelLike) => unknown | Promise<unknown>;
|
||||
onResponse?: (response: ProviderResponseInfo, model: ModelLike) => void | Promise<void>;
|
||||
onPayload?: (
|
||||
payload: unknown,
|
||||
model: ModelLike,
|
||||
) => unknown | Promise<unknown>;
|
||||
onResponse?: (
|
||||
response: ProviderResponseInfo,
|
||||
model: ModelLike,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export type AssistantMessageEvent =
|
||||
| { type: "start"; partial: AssistantMessageLike }
|
||||
| { type: "text_start"; contentIndex: number; partial: AssistantMessageLike }
|
||||
| { type: "text_delta"; contentIndex: number; delta: string; partial: AssistantMessageLike }
|
||||
| { type: "text_end"; contentIndex: number; content: string; partial: AssistantMessageLike }
|
||||
| { type: "thinking_start"; contentIndex: number; partial: AssistantMessageLike }
|
||||
| { type: "thinking_delta"; contentIndex: number; delta: string; partial: AssistantMessageLike }
|
||||
| { type: "thinking_end"; contentIndex: number; content: string; partial: AssistantMessageLike }
|
||||
| { type: "toolcall_start"; contentIndex: number; partial: AssistantMessageLike }
|
||||
| { type: "toolcall_end"; contentIndex: number; toolCall: ToolCallContent; partial: AssistantMessageLike }
|
||||
| {
|
||||
type: "text_delta";
|
||||
contentIndex: number;
|
||||
delta: string;
|
||||
partial: AssistantMessageLike;
|
||||
}
|
||||
| {
|
||||
type: "text_end";
|
||||
contentIndex: number;
|
||||
content: string;
|
||||
partial: AssistantMessageLike;
|
||||
}
|
||||
| {
|
||||
type: "thinking_start";
|
||||
contentIndex: number;
|
||||
partial: AssistantMessageLike;
|
||||
}
|
||||
| {
|
||||
type: "thinking_delta";
|
||||
contentIndex: number;
|
||||
delta: string;
|
||||
partial: AssistantMessageLike;
|
||||
}
|
||||
| {
|
||||
type: "thinking_end";
|
||||
contentIndex: number;
|
||||
content: string;
|
||||
partial: AssistantMessageLike;
|
||||
}
|
||||
| {
|
||||
type: "toolcall_start";
|
||||
contentIndex: number;
|
||||
partial: AssistantMessageLike;
|
||||
}
|
||||
| {
|
||||
type: "toolcall_end";
|
||||
contentIndex: number;
|
||||
toolCall: ToolCallContent;
|
||||
partial: AssistantMessageLike;
|
||||
}
|
||||
| { type: "done"; reason: StopReason; message: AssistantMessageLike }
|
||||
| { type: "error"; reason: ErrorReason; error: AssistantMessageLike };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user