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
13 changes: 5 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ opencode plugin file://$PWD

## Authenticate

Requires the plugin to be installed (see above). Preferred: **Command Code Go plan ($1/mo)** browser login (same as `cmd login`). You can also paste an API key / session token.
Requires the plugin to be installed (see above). Preferred: **Command Code Go plan ($1/mo)** browser login — same as `cmd login` (open URL → Authorize → paste the key Studio shows).

```bash
# Option A — sync from Command Code CLI (recommended)
Expand All @@ -74,13 +74,10 @@ cmd login # browser → Studio CLI auth (Go plan)
opencode auth login --provider command-code
# pick "Use existing cmd login session"

# Option B — browser OAuth (Go $1)
# Option B — browser login inside OpenCode (Go $1)
opencode auth login --provider command-code
# pick "Login with Command Code (Go $1)"

# Option C — paste API key / session token
opencode auth login --provider command-code
# pick "Enter Command Code API key" and paste into the prompt field
# open the URL, Authorize, then paste the API key (or ok if the browser already finished)
```

Then start OpenCode, pick provider **command-code**, and choose any model from the live catalog (`cmd --list-models`). Laguna S 2.1 free is the default when available:
Expand All @@ -98,7 +95,7 @@ Laguna S 2.1 free requires an active Go (or higher) account with credits on file
| **Gateway proxy** | Talks to `api.commandcode.ai/alpha/generate` — same transport as the `cmd` CLI (`mode=agent`). |
| **Live model catalog** | Discovered from `cmd --list-models` (enriched by the installed CLI’s models.md) — not hardcoded. |
| **Laguna S 2.1 free** | Default when present — 256k context, $0 while the deal lasts. |
| **Auth options** | Go-plan browser OAuth, sync from `cmd login`, or paste an API key / session token. |
| **Auth options** | Go-plan browser login (URL → paste key, same as `cmd login`) or sync an existing CLI session. |
| **Agent-grade tools** | OpenCode tool calls park and resume; MCP-prefixed tools are tracked separately. |
| **Attachments** | Images, PDFs, text/binary files from OpenCode. Text-only models get image placeholders (Command Code behavior). |
| **Auto-compact** | Context-fraction tips + tiered client compact before the 256k window overflows. |
Expand Down Expand Up @@ -162,7 +159,7 @@ Local pin refresh after a release:
## FAQ

**Do I need a Studio API key?**
No. Prefer Go-plan browser login or sync from `cmd login`. You can also use **Enter Command Code API key** if you already have a session token.
No. Use Go-plan browser login or sync from `cmd login`. After you Authorize in the browser, Studio may show a one-time key to paste into the OpenCode prompt — that key is part of the login flow, not a separate product API key.

**Are only Laguna models available?**
No. The plugin loads the full live catalog from `cmd --list-models`. Laguna is just the default free model used for testing.
Expand Down
123 changes: 111 additions & 12 deletions src/auth-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
*
* Browser flow matches `cmd login`:
* https://commandcode.ai/studio/auth/cli?callback=http://localhost:PORT/callback&state=STATE
* Studio POSTs { apiKey, userId, userName, keyName, state } to the local callback.
* That session credential is what the $1 Go plan uses — no Studio API key needed.
* Studio POSTs { apiKey, userId, userName, keyName, state } to the local callback when
* reachable. If the POST fails, Studio shows the key to copy — paste it into OpenCode
* (`method: "code"`), same as the CLI's "Paste your API key..." field.
*/
import { createServer, type Server } from "node:http";
import { randomBytes } from "node:crypto";
Expand Down Expand Up @@ -58,12 +59,14 @@ const ALLOWED_CORS = [
"http://localhost:3000",
"https://staging.commandcode.ai",
"https://commandcode.ai",
"https://www.commandcode.ai",
];

let pending: PendingCommandLogin | null = null;
let authServer: Server | null = null;
let waitForCallback: Promise<BrowserCallbackPayload> | null = null;
let rejectCallback: ((err: Error) => void) | null = null;
let receivedPayload: BrowserCallbackPayload | null = null;

function authJsonPath(): string {
const xdg = process.env.XDG_DATA_HOME;
Expand Down Expand Up @@ -218,6 +221,23 @@ export function resetPendingCommandLogin(): void {
pending = null;
waitForCallback = null;
rejectCallback = null;
receivedPayload = null;
}

function payloadToTokens(payload: BrowserCallbackPayload): CommandCodeAuthTokens {
return {
access: payload.apiKey,
key: payload.apiKey,
refresh: "browser-oauth",
expires: Date.now() + 365 * 24 * 60 * 60 * 1000,
userId: payload.userId,
userName: payload.userName,
keyName: payload.keyName,
};
}

function sanitizeApiKeyInput(value: string): string {
return value.trim().replace(/^["']|["']$/g, "");
}

function stopAuthServer(): void {
Expand Down Expand Up @@ -307,10 +327,14 @@ export async function startCommandBrowserLogin(): Promise<PendingCommandLogin> {
const allow =
typeof origin === "string" && ALLOWED_CORS.includes(origin)
? origin
: ALLOWED_CORS[0];
: origin && origin.endsWith("commandcode.ai")
? origin
: ALLOWED_CORS[2]; // https://commandcode.ai
res.setHeader("Access-Control-Allow-Origin", allow);
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
// Chrome Private Network Access: public site → localhost callback.
res.setHeader("Access-Control-Allow-Private-Network", "true");
res.setHeader("Content-Type", "application/json");

if (req.method === "OPTIONS") {
Expand Down Expand Up @@ -375,6 +399,8 @@ export async function startCommandBrowserLogin(): Promise<PendingCommandLogin> {
}
res.writeHead(200);
res.end(JSON.stringify({ success: true }));
receivedPayload = parsed;
if (pending) pending.completed = true;
resolveCb(parsed);
stopAuthServer();
} catch {
Expand Down Expand Up @@ -411,15 +437,8 @@ export async function completeCommandBrowserLogin(): Promise<CommandCodeAuthToke

try {
const payload = await Promise.race([waitForCallback, timeout]);
const tokens: CommandCodeAuthTokens = {
access: payload.apiKey,
key: payload.apiKey,
refresh: "browser-oauth",
expires: Date.now() + 365 * 24 * 60 * 60 * 1000,
userId: payload.userId,
userName: payload.userName,
keyName: payload.keyName,
};
receivedPayload = payload;
const tokens = payloadToTokens(payload);
writeCommandCodeAuth(tokens);
if (pending) pending.completed = true;
return tokens;
Expand All @@ -431,3 +450,83 @@ export async function completeCommandBrowserLogin(): Promise<CommandCodeAuthToke
throw err;
}
}

/**
* Finish login the way `cmd login` does after Studio confirms:
* prefer a key already POSTed to the local callback; otherwise accept the
* API key the user pasted from Studio's fallback / copy page.
*/
export async function completeCommandLoginWithCode(
code: string,
): Promise<CommandCodeAuthTokens> {
if (receivedPayload?.apiKey) {
const tokens = payloadToTokens(receivedPayload);
writeCommandCodeAuth(tokens);
if (pending) pending.completed = true;
stopAuthServer();
waitForCallback = null;
rejectCallback = null;
return tokens;
}

// Tiny grace period: Studio may still be POSTing while the user pastes "ok".
if (waitForCallback) {
const raced = await Promise.race([
waitForCallback.then((payload) => ({ ok: true as const, payload })),
new Promise<{ ok: false }>((resolve) =>
setTimeout(() => resolve({ ok: false }), 750),
),
]);
if (raced.ok) {
receivedPayload = raced.payload;
const tokens = payloadToTokens(raced.payload);
writeCommandCodeAuth(tokens);
if (pending) pending.completed = true;
stopAuthServer();
waitForCallback = null;
rejectCallback = null;
return tokens;
}
}

const key = sanitizeApiKeyInput(code);
// OpenCode "code" flow: if the browser already succeeded, user may paste "ok".
if (!key || /^(ok|done|success|yes)$/i.test(key)) {
if (receivedPayload?.apiKey) {
const tokens = payloadToTokens(receivedPayload);
writeCommandCodeAuth(tokens);
resetPendingCommandLogin();
return tokens;
}
if (!key) {
throw new Error(
"Paste the API key from Command Code Studio (shown after Authorize).",
);
}
throw new Error(
"No session received yet. Paste the API key shown on the Studio page.",
);
}

const check = await validateCommandApiKey(key);
if (!check.valid) {
throw new Error(
check.error === "invalid_key"
? "Invalid API key — copy the key from Studio and try again."
: `Could not validate API key (${check.error ?? "unknown"}).`,
);
}

const tokens: CommandCodeAuthTokens = {
access: key,
key,
refresh: "browser-oauth-paste",
expires: Date.now() + 365 * 24 * 60 * 60 * 1000,
userName: check.userName,
keyName: "opencode-oauth",
};
writeCommandCodeAuth(tokens);
if (pending) pending.completed = true;
resetPendingCommandLogin();
return tokens;
}
56 changes: 9 additions & 47 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* OpenCode Command Code Auth Plugin
*
* Enables Command Code models inside OpenCode via:
* 1. Go-plan browser OAuth, CLI session sync, or pasted API key
* 1. Go-plan browser login (`cmd login` style: open URL, then paste the key)
* 2. Local OpenAI-compatible proxy → api.commandcode.ai /alpha/generate
* 3. Dynamic model catalog from `cmd --list-models`
* 4. Tools/MCP park-resume, attachments, compact, and usage accounting
Expand All @@ -12,13 +12,11 @@
*/
import type { Hooks, Plugin, PluginInput } from "@opencode-ai/plugin";
import {
completeCommandBrowserLogin,
completeCommandLoginWithCode,
getPendingCommandLogin,
resetPendingCommandLogin,
startCommandBrowserLogin,
syncCommandCodeCredentialsToOpenCode,
validateCommandApiKey,
writeCommandCodeAuth,
} from "./auth-login.js";
import {
DEFAULT_MODEL_ID,
Expand Down Expand Up @@ -397,7 +395,7 @@ export const CommandCodePlugin: Plugin = async (
return {
url: "https://commandcode.ai/docs/plans/go",
instructions:
"Command Code Go-plan session found (from `cmd login`). Click Complete — no API key needed.",
"Command Code Go-plan session found (from `cmd login`). Click Complete — no paste needed.",
method: "auto" as const,
async callback() {
return {
Expand All @@ -418,11 +416,12 @@ export const CommandCodePlugin: Plugin = async (
return {
url: current.url,
instructions:
"Opens Command Code Studio CLI login (Go plan, $1/mo). Approve in the browser — session is posted back automatically. Live models load from `cmd --list-models` after login.",
method: "auto" as const,
async callback() {
"Open the URL, click Authorize, then paste the API key Studio shows (or paste ok if the browser says you're all set). Same flow as `cmd login`.",
method: "code" as const,
async callback(code: string) {
try {
const tokens = await completeCommandBrowserLogin();
const tokens = await completeCommandLoginWithCode(code);
refreshCommandModels();
return {
type: "success" as const,
refresh: tokens.refresh,
Expand All @@ -432,7 +431,7 @@ export const CommandCodePlugin: Plugin = async (
} catch (err) {
resetPendingCommandLogin();
log.error(
"[opencode-commandcode] Go-plan OAuth failed",
"[opencode-commandcode] Go-plan login failed",
err instanceof Error ? err.message : err,
);
return { type: "failed" as const };
Expand Down Expand Up @@ -463,43 +462,6 @@ export const CommandCodePlugin: Plugin = async (
};
},
},
{
type: "api",
label: "Enter Command Code API key",
prompts: [
{
type: "text",
key: "apiKey",
message: "Paste your Command Code API key / session token",
placeholder: "user_… or Studio key",
validate: (value) => {
if (!value || !value.trim()) return "API key is required";
if (value.trim().length < 16) return "Key looks too short";
return undefined;
},
},
],
async authorize(inputs) {
const key = inputs?.apiKey?.trim() || inputs?.key?.trim() || "";
if (!key) return { type: "failed" as const };
const check = await validateCommandApiKey(key);
if (!check.valid) {
log.warn(
"[opencode-commandcode] API key validation failed",
check.error,
);
return { type: "failed" as const };
}
writeCommandCodeAuth({
key,
access: key,
refresh: "api-key",
expires: Date.now() + 1000 * 60 * 60 * 24 * 365,
});
refreshCommandModels();
return { type: "success" as const, key };
},
},
],
},
};
Expand Down
Loading