@gururea/opencode-commandcode-provider 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Pat Woz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # opencode-commandcode
2
+
3
+ An [opencode](https://opencode.ai) plugin that adds the [Command Code](https://commandcode.ai) provider to the interface and its authentication to the `/connect` dialog.
4
+
5
+ > **Disclaimer:** This is an unofficial, community-maintained integration. It is not affiliated with, endorsed by, or supported by Command Code. You need your own Command Code account, API key, and a plan with Provider API access.
6
+
7
+ ## Install
8
+
9
+ Add the plugin to your `opencode.json`:
10
+
11
+ ```json
12
+ {
13
+ "$schema": "https://opencode.ai/config.json",
14
+ "plugin": ["opencode-commandcode"]
15
+ }
16
+ ```
17
+
18
+ Or drop `src/index.ts` into `.opencode/plugins/` for local development.
19
+
20
+ Restart opencode, then:
21
+
22
+ 1. Run `/connect` and select **Command Code**.
23
+ 2. Choose **Login with Command Code (browser)** (opens your browser and transfers the API key automatically) or **Paste an API key**.
24
+ 3. Run `/models` and pick a `commandcode/*` model.
25
+
26
+ ## What the plugin does
27
+
28
+ - **Registers the provider.** The plugin injects a `commandcode` custom provider into the opencode config (`@ai-sdk/openai-compatible`, base URL `https://api.commandcode.ai/provider/v1`). The model catalog is fetched live from the Provider API at startup; if the endpoint is unreachable, a built-in fallback catalog is used so the provider still appears.
29
+ - **Adds authentication.** The `/connect` dialog gets two methods via the plugin `auth` hook:
30
+ - **Browser login:** opens `commandcode.ai/studio/auth/cli` and receives the API key through a local callback server (fall back to pasting the key if the transfer fails).
31
+ - **API key:** the standard key-paste dialog.
32
+ - Command Code API keys (`user_...`) do not expire, so they are stored once and reused.
33
+
34
+ ## Environment variables
35
+
36
+ | Variable | Purpose |
37
+ | --- | --- |
38
+ | `COMMAND_CODE_API_KEY` / `COMMANDCODE_API_KEY` | API key used as a fallback when no auth is stored via `/connect` |
39
+ | `COMMANDCODE_API_BASE` | Override the provider API base URL (default `https://api.commandcode.ai/provider/v1`) |
40
+ | `COMMANDCODE_MODELS_URL` | Override the model catalog URL (default `<api base>/models`) |
41
+ | `CMD_ZDR=1` / `COMMANDCODE_ZDR=1` | Send the `x-cmd-zdr: 1` zero-data-retention header |
42
+
43
+ ## Scope
44
+
45
+ Claude models (`claude-*`) are excluded: they are served through Command Code's Anthropic-compatible endpoint, which is outside the OpenAI-compatible scope of this MVP.
46
+
47
+ ## Development
48
+
49
+ ```sh
50
+ npm install
51
+ npm test # unit + integration tests
52
+ npm run typecheck
53
+ npm run build # emits dist/index.js + dist/index.d.ts via tsup
54
+ ```
55
+
56
+ ## License
57
+
58
+ MIT
@@ -0,0 +1,16 @@
1
+ import { Plugin } from '@opencode-ai/plugin';
2
+
3
+ /**
4
+ * opencode-commandcode
5
+ *
6
+ * Adds the Command Code (commandcode.ai) provider to opencode and registers
7
+ * its authentication (browser login or API key) in the /connect dialog.
8
+ *
9
+ * The plugin registers a custom provider in the opencode config (the
10
+ * `provider` plugin hook only works for providers already in the models.dev
11
+ * catalog) and an `auth` hook that the /connect command picks up.
12
+ */
13
+
14
+ declare const CommandCodePlugin: Plugin;
15
+
16
+ export { CommandCodePlugin, CommandCodePlugin as default };
package/dist/index.js ADDED
@@ -0,0 +1,545 @@
1
+ // src/auth.ts
2
+ import { randomBytes } from "crypto";
3
+
4
+ // src/auth-server.ts
5
+ import { createServer } from "http";
6
+ var DEFAULT_PORT = 5959;
7
+ var DEFAULT_PORT_RANGE = 10;
8
+ function listenOnAvailablePort(server, startPort = DEFAULT_PORT, range = DEFAULT_PORT_RANGE) {
9
+ return new Promise((resolve, reject) => {
10
+ let offset = 0;
11
+ const tryListen = () => {
12
+ const useFallbackPort = startPort === 0 || offset >= range;
13
+ const port = useFallbackPort ? 0 : startPort + offset;
14
+ const onError = (err) => {
15
+ server.off("listening", onListening);
16
+ if (err.code === "EADDRINUSE" && !useFallbackPort) {
17
+ offset += 1;
18
+ tryListen();
19
+ return;
20
+ }
21
+ reject(err);
22
+ };
23
+ const onListening = () => {
24
+ server.off("error", onError);
25
+ const address = server.address();
26
+ resolve(address.port);
27
+ };
28
+ server.once("error", onError);
29
+ server.once("listening", onListening);
30
+ server.listen(port, "127.0.0.1");
31
+ };
32
+ tryListen();
33
+ });
34
+ }
35
+ function closeServer(server) {
36
+ server.close((err) => {
37
+ if (err && err.code !== "ERR_SERVER_NOT_RUNNING") {
38
+ }
39
+ });
40
+ }
41
+ async function startAuthServer(options = {}) {
42
+ let resolveCallback;
43
+ let rejectCallback;
44
+ const waitForCallback = new Promise((resolve, reject) => {
45
+ resolveCallback = resolve;
46
+ rejectCallback = reject;
47
+ });
48
+ const server = createServer((req, res) => {
49
+ const origin = req.headers.origin || "";
50
+ const allowedOrigins = [
51
+ "http://localhost:3000",
52
+ "https://staging.commandcode.ai",
53
+ "https://commandcode.ai"
54
+ ];
55
+ const responseOrigin = allowedOrigins.includes(origin) ? origin : allowedOrigins[0];
56
+ const requestedHeaders = req.headers["access-control-request-headers"];
57
+ res.setHeader("Access-Control-Allow-Origin", responseOrigin);
58
+ res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
59
+ res.setHeader(
60
+ "Access-Control-Allow-Headers",
61
+ typeof requestedHeaders === "string" && requestedHeaders.length > 0 ? requestedHeaders : "Content-Type"
62
+ );
63
+ res.setHeader("Access-Control-Allow-Private-Network", "true");
64
+ res.setHeader("Content-Type", "application/json");
65
+ if (req.method === "OPTIONS") {
66
+ res.writeHead(204);
67
+ res.end();
68
+ return;
69
+ }
70
+ if (req.url !== "/callback") {
71
+ res.writeHead(404);
72
+ res.end(JSON.stringify({ success: false, error: "Not found" }));
73
+ return;
74
+ }
75
+ if (req.method !== "POST") {
76
+ res.writeHead(405);
77
+ res.end(
78
+ JSON.stringify({
79
+ success: false,
80
+ error: "Method not allowed. Use POST."
81
+ })
82
+ );
83
+ return;
84
+ }
85
+ let body = "";
86
+ req.on("data", (chunk) => {
87
+ body += chunk.toString();
88
+ if (body.length > 1e4) req.destroy();
89
+ });
90
+ req.on("end", () => {
91
+ try {
92
+ const parsed = JSON.parse(body);
93
+ if (parsed.error) {
94
+ res.writeHead(200);
95
+ res.end(JSON.stringify({ success: true }));
96
+ const description = typeof parsed.error_description === "string" ? parsed.error_description : String(parsed.error);
97
+ if (parsed.error === "access_denied") {
98
+ rejectCallback(new Error(description || "Authorization was denied by the user"));
99
+ } else {
100
+ rejectCallback(new Error(description || String(parsed.error)));
101
+ }
102
+ closeServer(server);
103
+ return;
104
+ }
105
+ const apiKey = typeof parsed.apiKey === "string" ? parsed.apiKey : "";
106
+ const state = typeof parsed.state === "string" ? parsed.state : "";
107
+ const userId = typeof parsed.userId === "string" ? parsed.userId : "";
108
+ const userName = typeof parsed.userName === "string" ? parsed.userName : "";
109
+ const keyName = typeof parsed.keyName === "string" ? parsed.keyName : "";
110
+ if (!apiKey || !state || !userId || !userName || !keyName) {
111
+ res.writeHead(400);
112
+ res.end(
113
+ JSON.stringify({
114
+ success: false,
115
+ error: "Missing required fields"
116
+ })
117
+ );
118
+ return;
119
+ }
120
+ if (options.expectedState !== void 0 && state !== options.expectedState) {
121
+ res.writeHead(403);
122
+ res.end(JSON.stringify({ success: false, error: "Invalid state token" }));
123
+ return;
124
+ }
125
+ res.writeHead(200);
126
+ res.end(JSON.stringify({ success: true }));
127
+ resolveCallback({ apiKey, state, userId, userName, keyName });
128
+ closeServer(server);
129
+ } catch {
130
+ res.writeHead(400);
131
+ res.end(JSON.stringify({ success: false, error: "Invalid JSON" }));
132
+ }
133
+ });
134
+ req.on("error", () => {
135
+ res.writeHead(500);
136
+ res.end(JSON.stringify({ success: false, error: "Request error" }));
137
+ });
138
+ });
139
+ try {
140
+ const port = await listenOnAvailablePort(
141
+ server,
142
+ options.startPort ?? DEFAULT_PORT,
143
+ options.portRange ?? DEFAULT_PORT_RANGE
144
+ );
145
+ return { server, port, waitForCallback };
146
+ } catch (err) {
147
+ const message = err instanceof Error ? err.message : String(err);
148
+ const error = new Error(`Failed to start auth server: ${message}`);
149
+ rejectCallback(error);
150
+ throw error;
151
+ }
152
+ }
153
+
154
+ // src/util.ts
155
+ function withTimeout(promise, timeoutMs) {
156
+ return new Promise((resolve, reject) => {
157
+ const timer = setTimeout(
158
+ () => reject(new Error(`The operation timed out after ${timeoutMs}ms`)),
159
+ timeoutMs
160
+ );
161
+ promise.then(
162
+ (value) => {
163
+ clearTimeout(timer);
164
+ resolve(value);
165
+ },
166
+ (error) => {
167
+ clearTimeout(timer);
168
+ reject(error);
169
+ }
170
+ );
171
+ });
172
+ }
173
+ function sanitizeApiKey(input) {
174
+ const esc = String.fromCharCode(27);
175
+ return Array.from(
176
+ input.replaceAll(`${esc}[200~`, "").replaceAll(`${esc}[201~`, "").replaceAll("[200~", "").replaceAll("[201~", "")
177
+ ).filter((char) => {
178
+ const code = char.charCodeAt(0);
179
+ return code > 31 && code !== 127;
180
+ }).join("").trim();
181
+ }
182
+
183
+ // src/auth.ts
184
+ var STUDIO_BASE_URL = "https://commandcode.ai";
185
+ var DEFAULT_AUTH_TIMEOUT_MS = 12e4;
186
+ function createAuthHook(options = {}) {
187
+ const studioBaseUrl = options.studioBaseUrl ?? STUDIO_BASE_URL;
188
+ const authTimeoutMs = options.authTimeoutMs ?? DEFAULT_AUTH_TIMEOUT_MS;
189
+ return {
190
+ provider: "commandcode",
191
+ async loader(getAuth) {
192
+ const auth = await getAuth();
193
+ if (auth?.type === "api" && auth.key) return { apiKey: auth.key };
194
+ if (auth?.type === "oauth" && auth.access) return { apiKey: auth.access };
195
+ return {};
196
+ },
197
+ methods: [
198
+ {
199
+ type: "oauth",
200
+ label: "Login with Command Code (browser)",
201
+ authorize: async () => {
202
+ const state = randomBytes(32).toString("base64url");
203
+ let authServer;
204
+ try {
205
+ authServer = await startAuthServer({ expectedState: state });
206
+ } catch {
207
+ throw new Error(
208
+ "Could not start the local callback server. Use the API key method instead."
209
+ );
210
+ }
211
+ const callbackUrl = `http://localhost:${authServer.port}/callback`;
212
+ const url = `${studioBaseUrl}/studio/auth/cli?callback=${encodeURIComponent(callbackUrl)}&state=${encodeURIComponent(state)}`;
213
+ return {
214
+ url,
215
+ instructions: "Open the link in your browser and sign in to Command Code. Your API key is transferred automatically. If that fails, copy the key and use the API key method instead.",
216
+ method: "auto",
217
+ callback: async () => {
218
+ try {
219
+ const result = await withTimeout(authServer.waitForCallback, authTimeoutMs);
220
+ return { type: "success", key: sanitizeApiKey(result.apiKey) };
221
+ } catch {
222
+ return { type: "failed" };
223
+ }
224
+ }
225
+ };
226
+ }
227
+ },
228
+ {
229
+ type: "api",
230
+ label: "Paste an API key"
231
+ }
232
+ ]
233
+ };
234
+ }
235
+
236
+ // src/models.ts
237
+ var DEFAULT_PROVIDER_API_BASE = "https://api.commandcode.ai/provider/v1";
238
+ var DEFAULT_MODELS_URL = `${DEFAULT_PROVIDER_API_BASE}/models`;
239
+ var DEFAULT_MODELS_TIMEOUT_MS = 1e4;
240
+ var DEFAULT_MAX_OUTPUT_TOKENS = 65536;
241
+ var ZERO_MODEL_COST = {
242
+ input: 0,
243
+ output: 0,
244
+ cacheRead: 0,
245
+ cacheWrite: 0
246
+ };
247
+ var MODEL_COSTS = {
248
+ "deepseek/deepseek-v4-pro": { input: 0.66, output: 1.98, cacheRead: 0.022, cacheWrite: 0 },
249
+ "deepseek/deepseek-v4-flash": { input: 0.22, output: 0.66, cacheRead: 7e-3, cacheWrite: 0 },
250
+ "deepseek/deepseek-v4-flash-vision-exp": {
251
+ input: 0.22,
252
+ output: 0.66,
253
+ cacheRead: 7e-3,
254
+ cacheWrite: 0
255
+ },
256
+ "Qwen/Qwen3.8-Max": { input: 2, output: 6, cacheRead: 0.25, cacheWrite: 2.5 },
257
+ "Qwen/Qwen3.8-27B": { input: 0.4, output: 3, cacheRead: 0.04, cacheWrite: 0 },
258
+ "Qwen/Qwen3.7-Max": { input: 2.5, output: 7.5, cacheRead: 0.5, cacheWrite: 3.13 },
259
+ "Qwen/Qwen3.7-Plus": { input: 0.4, output: 1.6, cacheRead: 0.08, cacheWrite: 0.5 },
260
+ "Qwen/Qwen3.7-Flash": { input: 0.03, output: 0.13, cacheRead: 6e-3, cacheWrite: 0.038 },
261
+ "Qwen/Qwen3.6-Max-Preview": { input: 1.3, output: 7.8, cacheRead: 0.26, cacheWrite: 1.63 },
262
+ "Qwen/Qwen3.6-Plus": { input: 0.5, output: 3, cacheRead: 0.1, cacheWrite: 0 },
263
+ "moonshotai/Kimi-K3": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 },
264
+ "moonshotai/Kimi-K2.7-Code": { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 },
265
+ "moonshotai/Kimi-K2.7-Code-Highspeed": {
266
+ input: 1.9,
267
+ output: 8,
268
+ cacheRead: 0.38,
269
+ cacheWrite: 0
270
+ },
271
+ "moonshotai/Kimi-K2.6": { input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0 },
272
+ "moonshotai/Kimi-K2.5": { input: 0.6, output: 3, cacheRead: 0.1, cacheWrite: 0 },
273
+ "zai-org/GLM-5.3": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 },
274
+ "zai-org/GLM-5.2": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 },
275
+ "zai-org/GLM-5.2-Fast": { input: 3, output: 10.25, cacheRead: 0.5, cacheWrite: 0 },
276
+ "zai-org/GLM-5.1": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 },
277
+ "zai-org/GLM-5": { input: 1, output: 3.2, cacheRead: 0.2, cacheWrite: 0 },
278
+ "MiniMaxAI/MiniMax-M3": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 },
279
+ "MiniMaxAI/MiniMax-M2.7": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 },
280
+ "MiniMaxAI/MiniMax-M2.5": { input: 0.3, output: 1.2, cacheRead: 0.03, cacheWrite: 0 },
281
+ "stepfun/Step-3.7-Flash": { input: 0.2, output: 1.15, cacheRead: 0.04, cacheWrite: 0 },
282
+ "stepfun/Step-3.5-Flash": { input: 0.1, output: 0.3, cacheRead: 0.02, cacheWrite: 0 },
283
+ "xiaomi/mimo-v2.5-pro": { input: 0.435, output: 0.87, cacheRead: 36e-4, cacheWrite: 0 },
284
+ "xiaomi/mimo-v2.5": { input: 0.14, output: 0.28, cacheRead: 28e-4, cacheWrite: 0 },
285
+ "nvidia/nemotron-3-ultra-550b-a55b": {
286
+ input: 0.6,
287
+ output: 2.4,
288
+ cacheRead: 0.12,
289
+ cacheWrite: 0
290
+ },
291
+ "sakana/fugu-ultra": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 },
292
+ "thinkingmachines/inkling": { input: 1, output: 4.05, cacheRead: 0.17, cacheWrite: 0 },
293
+ "thinkingmachines/inkling-small": { input: 0.5, output: 1.2, cacheRead: 0.1, cacheWrite: 0 },
294
+ "meta/muse-spark-1.1": { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 },
295
+ "meta/muse-spark-1.2": { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 },
296
+ "meta/muse-spark-1.2-contributor": {
297
+ input: 0.1,
298
+ output: 0.2,
299
+ cacheRead: 2e-3,
300
+ cacheWrite: 0
301
+ },
302
+ "gpt-5.6-sol": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 },
303
+ "gpt-5.6-terra": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5 },
304
+ "gpt-5.6-luna": { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 },
305
+ "gpt-5.5": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 },
306
+ "gpt-5.4": { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
307
+ "gpt-5.3-codex": { input: 2, output: 8, cacheRead: 0.5, cacheWrite: 0 },
308
+ "gpt-5.4-mini": { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 },
309
+ "google/gemini-3.7-flash": {
310
+ input: 0.75,
311
+ output: 3.75,
312
+ cacheRead: 0.075,
313
+ cacheWrite: 0.04167
314
+ },
315
+ "google/gemini-3.6-flash": { input: 1.5, output: 7.5, cacheRead: 0.15, cacheWrite: 0 },
316
+ "google/gemini-3.5-flash": { input: 1.5, output: 9, cacheRead: 0.15, cacheWrite: 0 },
317
+ "google/gemini-3.5-flash-lite": { input: 0.3, output: 2.5, cacheRead: 0.03, cacheWrite: 0 },
318
+ "google/gemini-3.1-flash-lite": {
319
+ input: 0.25,
320
+ output: 1.5,
321
+ cacheRead: 0.03,
322
+ cacheWrite: 0
323
+ },
324
+ "xai/grok-4.5": { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 },
325
+ "xai/grok-4.6": { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 }
326
+ };
327
+ function modelCost(id) {
328
+ return MODEL_COSTS[id] ?? ZERO_MODEL_COST;
329
+ }
330
+ function isOpenAiCompatibleModel(id) {
331
+ return !id.startsWith("claude-");
332
+ }
333
+ function isRecord(value) {
334
+ return typeof value === "object" && value !== null && !Array.isArray(value);
335
+ }
336
+ function stringField(record, key) {
337
+ const value = record[key];
338
+ if (typeof value !== "string" || value.length === 0) {
339
+ throw new Error(`Expected ${key} to be a non-empty string`);
340
+ }
341
+ return value;
342
+ }
343
+ function positiveNumberField(record, key) {
344
+ const value = record[key];
345
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
346
+ throw new Error(`Expected ${key} to be a positive number`);
347
+ }
348
+ return value;
349
+ }
350
+ function commandCodeModelsFromApiResponse(value) {
351
+ if (!isRecord(value)) throw new Error("Expected models response to be an object");
352
+ if (value.object !== "list") throw new Error("Expected models response object to be 'list'");
353
+ if (!Array.isArray(value.data)) throw new Error("Expected models response data to be an array");
354
+ const models = value.data.map((entry) => {
355
+ if (!isRecord(entry)) throw new Error("Expected model entry to be an object");
356
+ return {
357
+ id: stringField(entry, "id"),
358
+ name: stringField(entry, "name"),
359
+ contextLength: positiveNumberField(entry, "context_length")
360
+ };
361
+ });
362
+ if (models.length === 0) throw new Error("Command Code returned an empty model catalog");
363
+ return models;
364
+ }
365
+ function outputLimitForModel(id, contextLength) {
366
+ return Math.min(contextLength, DEFAULT_MAX_OUTPUT_TOKENS);
367
+ }
368
+ function toProviderModelMap(models) {
369
+ const map = {};
370
+ for (const model of models) {
371
+ if (!isOpenAiCompatibleModel(model.id)) continue;
372
+ const cost = modelCost(model.id);
373
+ map[model.id] = {
374
+ name: model.name,
375
+ limit: {
376
+ context: model.contextLength,
377
+ output: outputLimitForModel(model.id, model.contextLength)
378
+ },
379
+ cost: {
380
+ input: cost.input,
381
+ output: cost.output,
382
+ cache_read: cost.cacheRead,
383
+ cache_write: cost.cacheWrite
384
+ }
385
+ };
386
+ }
387
+ return map;
388
+ }
389
+ async function runWithTimeout(operation, timeoutMs, externalSignal) {
390
+ const controller = new AbortController();
391
+ let timer;
392
+ let onExternalAbort;
393
+ const cleanup = () => {
394
+ if (timer !== void 0) clearTimeout(timer);
395
+ if (externalSignal && onExternalAbort) {
396
+ externalSignal.removeEventListener("abort", onExternalAbort);
397
+ }
398
+ };
399
+ if (externalSignal?.aborted) {
400
+ throw externalSignal.reason ?? new Error("The operation was aborted");
401
+ }
402
+ onExternalAbort = () => controller.abort(externalSignal?.reason);
403
+ if (externalSignal && onExternalAbort) {
404
+ externalSignal.addEventListener("abort", onExternalAbort, { once: true });
405
+ }
406
+ timer = setTimeout(() => controller.abort(new Error(`Model discovery timed out after ${timeoutMs}ms`)), timeoutMs);
407
+ try {
408
+ return await operation(controller.signal);
409
+ } finally {
410
+ cleanup();
411
+ }
412
+ }
413
+ async function fetchCommandCodeModels(options = {}) {
414
+ const url = options.url ?? DEFAULT_MODELS_URL;
415
+ const fetchImpl = options.fetchImpl ?? fetch;
416
+ const timeoutMs = options.timeoutMs !== void 0 && Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : DEFAULT_MODELS_TIMEOUT_MS;
417
+ const body = await runWithTimeout(
418
+ async (signal) => {
419
+ const response = await fetchImpl(url, {
420
+ headers: { accept: "application/json" },
421
+ signal
422
+ });
423
+ if (!response.ok) {
424
+ throw new Error(
425
+ `Failed to fetch Command Code models: ${response.status} ${response.statusText}`
426
+ );
427
+ }
428
+ return await response.json();
429
+ },
430
+ timeoutMs,
431
+ options.signal
432
+ );
433
+ return commandCodeModelsFromApiResponse(body);
434
+ }
435
+
436
+ // src/index.ts
437
+ var PROVIDER_ID = "commandcode";
438
+ var FALLBACK_MODELS = [
439
+ { id: "gpt-5.6-sol", name: "GPT-5.6 Sol", contextLength: 105e4 },
440
+ { id: "gpt-5.6-terra", name: "GPT-5.6 Terra", contextLength: 105e4 },
441
+ { id: "gpt-5.6-luna", name: "GPT-5.6 Luna", contextLength: 105e4 },
442
+ { id: "gpt-5.5", name: "GPT-5.5", contextLength: 4e5 },
443
+ { id: "gpt-5.4", name: "GPT-5.4", contextLength: 4e5 },
444
+ { id: "gpt-5.3-codex", name: "GPT-5.3 Codex", contextLength: 4e5 },
445
+ { id: "gpt-5.4-mini", name: "GPT-5.4 Mini", contextLength: 4e5 },
446
+ { id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro (latest)", contextLength: 1e6 },
447
+ { id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash (latest)", contextLength: 1e6 },
448
+ { id: "deepseek/deepseek-v4-flash-vision-exp", name: "DeepSeek V4 Flash Vision (exp)", contextLength: 1e6 },
449
+ { id: "moonshotai/Kimi-K3", name: "Kimi K3", contextLength: 1e6 },
450
+ { id: "moonshotai/Kimi-K2.7-Code", name: "Kimi K2.7 Code", contextLength: 256e3 },
451
+ { id: "moonshotai/Kimi-K2.7-Code-Highspeed", name: "Kimi K2.7 Code HighSpeed", contextLength: 262e3 },
452
+ { id: "moonshotai/Kimi-K2.6", name: "Kimi K2.6", contextLength: 256e3 },
453
+ { id: "moonshotai/Kimi-K2.5", name: "Kimi K2.5", contextLength: 256e3 },
454
+ { id: "z-ai/glm-5.3-flash", name: "GLM-5.3 Flash", contextLength: 1048576 },
455
+ { id: "zai-org/GLM-5.3", name: "GLM-5.3", contextLength: 1e6 },
456
+ { id: "zai-org/GLM-5.2", name: "GLM-5.2", contextLength: 1e6 },
457
+ { id: "zai-org/GLM-5.2-Fast", name: "GLM-5.2 Fast", contextLength: 1e6 },
458
+ { id: "zai-org/GLM-5.1", name: "GLM-5.1", contextLength: 2e5 },
459
+ { id: "zai-org/GLM-5", name: "GLM-5", contextLength: 2e5 },
460
+ { id: "MiniMaxAI/MiniMax-M3", name: "MiniMax M3", contextLength: 1e6 },
461
+ { id: "MiniMaxAI/MiniMax-M2.7", name: "MiniMax M2.7", contextLength: 2e5 },
462
+ { id: "minimax/minimax-m3-free", name: "MiniMax M3", contextLength: 1e6 },
463
+ { id: "minimax/minimax-m2.7-free", name: "MiniMax M2.7", contextLength: 197e3 },
464
+ { id: "MiniMaxAI/MiniMax-M2.5", name: "MiniMax M2.5", contextLength: 2e5 },
465
+ { id: "xiaomi/mimo-v2.5-pro", name: "MiMo V2.5 Pro", contextLength: 1e6 },
466
+ { id: "xiaomi/mimo-v2.5", name: "MiMo V2.5", contextLength: 1e6 },
467
+ { id: "Qwen/Qwen3.8-Max", name: "Qwen 3.8 Max", contextLength: 1e6 },
468
+ { id: "Qwen/Qwen3.8-27B", name: "Qwen 3.8 27B", contextLength: 262144 },
469
+ { id: "Qwen/Qwen3.8-Flash", name: "Qwen 3.8 Flash", contextLength: 1e6 },
470
+ { id: "Qwen/Qwen3.7-Max", name: "Qwen 3.7 Max", contextLength: 1e6 },
471
+ { id: "Qwen/Qwen3.7-Plus", name: "Qwen 3.7 Plus", contextLength: 1e6 },
472
+ { id: "Qwen/Qwen3.7-Flash", name: "Qwen 3.7 Flash", contextLength: 1e6 },
473
+ { id: "Qwen/Qwen3.6-Max-Preview", name: "Qwen 3.6 Max Preview", contextLength: 2e5 },
474
+ { id: "Qwen/Qwen3.6-Plus", name: "Qwen 3.6 Plus", contextLength: 2e5 },
475
+ { id: "stepfun/Step-3.7-Flash", name: "Step 3.7 Flash", contextLength: 256e3 },
476
+ { id: "stepfun/Step-3.5-Flash", name: "Step 3.5 Flash", contextLength: 1e6 },
477
+ { id: "tencent/hy3-paid", name: "Tencent Hy3", contextLength: 262144 },
478
+ { id: "tencent/hy4-preview", name: "Tencent Hy4 Preview", contextLength: 1048576 },
479
+ { id: "google/gemini-3.7-flash", name: "Gemini 3.7 Flash", contextLength: 1048576 },
480
+ { id: "google/gemini-3.6-flash", name: "Gemini 3.6 Flash", contextLength: 1e6 },
481
+ { id: "google/gemini-3.5-flash", name: "Gemini 3.5 Flash", contextLength: 1e6 },
482
+ { id: "google/gemini-3.5-flash-lite", name: "Gemini 3.5 Flash Lite", contextLength: 1e6 },
483
+ { id: "google/gemini-3.1-flash-lite", name: "Gemini 3.1 Flash Lite", contextLength: 1e6 },
484
+ { id: "sakana/fugu-ultra", name: "Fugu Ultra", contextLength: 1e6 },
485
+ { id: "nvidia/nemotron-3-ultra-550b-a55b", name: "Nemotron 3 Ultra", contextLength: 1e6 },
486
+ { id: "thinkingmachines/inkling", name: "Inkling", contextLength: 256e3 },
487
+ { id: "thinkingmachines/inkling-small", name: "Inkling Small", contextLength: 1e6 },
488
+ { id: "poolside/laguna-s-2.1-free", name: "Laguna S 2.1", contextLength: 256e3 },
489
+ { id: "meta/muse-spark-1.1", name: "Muse Spark 1.1", contextLength: 1048576 },
490
+ { id: "meta/muse-spark-1.2", name: "Muse Spark 1.2", contextLength: 1048576 },
491
+ { id: "meta/muse-spark-1.2-contributor", name: "Muse Spark 1.2 Contributor", contextLength: 1048576 },
492
+ { id: "xai/grok-4.5", name: "Grok 4.5", contextLength: 5e5 },
493
+ { id: "xai/grok-4.6", name: "Grok 4.6", contextLength: 5e5 }
494
+ ];
495
+ function zeroDataRetentionHeaders() {
496
+ if (process.env.CMD_ZDR === "1" || process.env.COMMANDCODE_ZDR === "1") {
497
+ return { "x-cmd-zdr": "1" };
498
+ }
499
+ return void 0;
500
+ }
501
+ async function loadProviderModels(modelsUrl, log) {
502
+ try {
503
+ const models = await fetchCommandCodeModels({ url: modelsUrl });
504
+ return toProviderModelMap(models);
505
+ } catch (error) {
506
+ const fallback = toProviderModelMap(FALLBACK_MODELS);
507
+ const message = error instanceof Error ? error.message : String(error);
508
+ log(`Could not fetch the Command Code model catalog (${message}). Using the built-in fallback catalog.`);
509
+ return fallback;
510
+ }
511
+ }
512
+ function providerBaseUrl() {
513
+ const configured = process.env.COMMANDCODE_API_BASE;
514
+ if (configured && configured.length > 0) return configured;
515
+ return DEFAULT_PROVIDER_API_BASE;
516
+ }
517
+ var CommandCodePlugin = async ({ client }) => {
518
+ const apiBase = providerBaseUrl();
519
+ const modelsUrl = process.env.COMMANDCODE_MODELS_URL ?? `${apiBase}/models`;
520
+ const models = await loadProviderModels(modelsUrl, (message) => {
521
+ client.app.log({ body: { service: "opencode-commandcode", level: "warn", message } }).catch(() => {
522
+ });
523
+ });
524
+ return {
525
+ config: async (cfg) => {
526
+ cfg.provider ??= {};
527
+ cfg.provider[PROVIDER_ID] = {
528
+ npm: "@ai-sdk/openai-compatible",
529
+ name: "Command Code",
530
+ env: ["COMMAND_CODE_API_KEY", "COMMANDCODE_API_KEY"],
531
+ options: {
532
+ baseURL: apiBase,
533
+ ...zeroDataRetentionHeaders() ? { headers: zeroDataRetentionHeaders() } : {}
534
+ },
535
+ models
536
+ };
537
+ },
538
+ auth: createAuthHook()
539
+ };
540
+ };
541
+ var index_default = CommandCodePlugin;
542
+ export {
543
+ CommandCodePlugin,
544
+ index_default as default
545
+ };
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@gururea/opencode-commandcode-provider",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode plugin that adds the Command Code (commandcode.ai) provider and its authentication to the interface.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "scripts": {
20
+ "build": "tsup src/index.ts --format esm --dts --clean --out-dir dist",
21
+ "test": "node --import tsx --test tests/*.test.ts",
22
+ "typecheck": "tsc --noEmit",
23
+ "prepare": "npm run build"
24
+ },
25
+ "keywords": [
26
+ "opencode",
27
+ "plugin",
28
+ "commandcode",
29
+ "command-code",
30
+ "provider"
31
+ ],
32
+ "license": "MIT",
33
+ "author": "Jonathan Narvaez <jonathan@narvaez.com.co>",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/jnarvaezp/opencode-commandcode-provider.git"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "devDependencies": {
42
+ "@opencode-ai/plugin": "latest",
43
+ "@types/node": "^24.0.0",
44
+ "tsx": "^4.20.0",
45
+ "tsup": "^8.0.0",
46
+ "typescript": "^5.7.0"
47
+ }
48
+ }