@telsoncc/opencode-commandcode 1.0.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/CHANGELOG.md +212 -0
- package/LICENSE +22 -0
- package/README.md +122 -0
- package/_version.txt +1 -0
- package/index.ts +32 -0
- package/manifest.json +58 -0
- package/models.json +2158 -0
- package/package.json +95 -0
- package/plugin-opencode2.ts +103 -0
- package/plugin.ts +197 -0
- package/src/auth.ts +43 -0
- package/src/catalog-break.ts +41 -0
- package/src/catalog-release-notes.ts +171 -0
- package/src/catalog.ts +839 -0
- package/src/convert.ts +242 -0
- package/src/costs-docs.ts +179 -0
- package/src/costs-models-dev.ts +173 -0
- package/src/cut-changelog.ts +40 -0
- package/src/manifest.ts +172 -0
- package/src/model.ts +168 -0
- package/src/opencode2.ts +336 -0
- package/src/publish-policy.ts +32 -0
- package/src/startup.ts +43 -0
- package/src/stream.ts +237 -0
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { writeFileSync } from "fs";
|
|
2
|
+
|
|
3
|
+
export type CatalogStatus = "healthy" | "degraded" | "broken";
|
|
4
|
+
export type CostCatalogBest = "cli" | "docs" | "thirdParty" | "free" | "fallback" | "missing";
|
|
5
|
+
|
|
6
|
+
export type CostSources = {
|
|
7
|
+
cli: number;
|
|
8
|
+
officialDocs: number;
|
|
9
|
+
thirdParty: number;
|
|
10
|
+
free: number;
|
|
11
|
+
fallback: number;
|
|
12
|
+
unmatched: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type UnavailableEntry = {
|
|
16
|
+
id: string;
|
|
17
|
+
reason: "not-listed-by-provider-api";
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type CatalogReview = {
|
|
21
|
+
thirdParty: string[];
|
|
22
|
+
free: string[];
|
|
23
|
+
unmatched: string[];
|
|
24
|
+
unavailable?: UnavailableEntry[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type CatalogManifest = {
|
|
28
|
+
schemaVersion: 1;
|
|
29
|
+
generatedAt: string;
|
|
30
|
+
pluginVersion: string;
|
|
31
|
+
commandCodeVersion: string;
|
|
32
|
+
commandCodeTarball: string;
|
|
33
|
+
modelCount: number;
|
|
34
|
+
reasoningModelCount: number;
|
|
35
|
+
extraction: {
|
|
36
|
+
modelCatalog: "ok" | "failed";
|
|
37
|
+
costCatalog: CostCatalogBest;
|
|
38
|
+
costCatalogError: string | null;
|
|
39
|
+
};
|
|
40
|
+
costSources: CostSources;
|
|
41
|
+
review?: CatalogReview;
|
|
42
|
+
status: CatalogStatus;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type BuildManifestInput = {
|
|
46
|
+
pluginVersion: string;
|
|
47
|
+
commandCodeVersion: string;
|
|
48
|
+
commandCodeTarball: string;
|
|
49
|
+
modelCount: number;
|
|
50
|
+
reasoningModelCount: number;
|
|
51
|
+
modelCatalogOk: boolean;
|
|
52
|
+
costSources: CostSources;
|
|
53
|
+
review?: CatalogReview;
|
|
54
|
+
generatedAt: string;
|
|
55
|
+
costCatalogError?: string | null;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function withUnavailableIds(
|
|
59
|
+
review: CatalogReview | undefined,
|
|
60
|
+
unavailable: string[],
|
|
61
|
+
): CatalogReview | undefined {
|
|
62
|
+
const entries: UnavailableEntry[] = [...unavailable]
|
|
63
|
+
.sort()
|
|
64
|
+
.map((id) => ({ id, reason: "not-listed-by-provider-api" as const }));
|
|
65
|
+
if (entries.length === 0) return review;
|
|
66
|
+
return { ...(review ?? { thirdParty: [], free: [], unmatched: [] }), unavailable: entries };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Update only the embedded plugin version; reject a missing target version. */
|
|
70
|
+
export function withPluginVersion(manifest: CatalogManifest, version: string): CatalogManifest {
|
|
71
|
+
if (!version || typeof version !== "string" || version.trim().length === 0) {
|
|
72
|
+
throw new Error("target plugin version must be a non-empty string");
|
|
73
|
+
}
|
|
74
|
+
return { ...manifest, pluginVersion: version };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function bumpPatch(version: string): string {
|
|
78
|
+
const parts = version.split(".");
|
|
79
|
+
const patch = Number(parts[2] ?? "0");
|
|
80
|
+
return `${parts[0] ?? "0"}.${parts[1] ?? "0"}.${patch + 1}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function meetsModelCountFloor(modelCount: number, lastSuccessful: number | null): boolean {
|
|
84
|
+
const floor = Math.max(20, Math.floor((lastSuccessful ?? 20) * 0.5));
|
|
85
|
+
// ponytail: when there is no prior catalog, lastSuccessful is null and the floor is 20
|
|
86
|
+
return modelCount >= (lastSuccessful === null ? 20 : floor);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function bestCostCatalog(sources: CostSources): CostCatalogBest {
|
|
90
|
+
if (sources.cli > 0) return "cli";
|
|
91
|
+
if (sources.officialDocs > 0) return "docs";
|
|
92
|
+
if (sources.thirdParty > 0) return "thirdParty";
|
|
93
|
+
if (sources.free > 0) return "free";
|
|
94
|
+
if (sources.fallback > 0) return "fallback";
|
|
95
|
+
return "missing";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function catalogStatus(modelCatalogOk: boolean, sources: CostSources): CatalogStatus {
|
|
99
|
+
if (!modelCatalogOk) return "broken";
|
|
100
|
+
if (sources.unmatched > 0) return "degraded";
|
|
101
|
+
return "healthy";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function commandCodeTarballUrl(version: string): string {
|
|
105
|
+
return `https://registry.npmjs.org/command-code/-/command-code-${version}.tgz`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function buildManifest(input: BuildManifestInput): CatalogManifest {
|
|
109
|
+
const status = catalogStatus(input.modelCatalogOk, input.costSources);
|
|
110
|
+
return {
|
|
111
|
+
schemaVersion: 1,
|
|
112
|
+
generatedAt: input.generatedAt,
|
|
113
|
+
pluginVersion: input.pluginVersion,
|
|
114
|
+
commandCodeVersion: input.commandCodeVersion,
|
|
115
|
+
commandCodeTarball: input.commandCodeTarball,
|
|
116
|
+
modelCount: input.modelCount,
|
|
117
|
+
reasoningModelCount: input.reasoningModelCount,
|
|
118
|
+
extraction: {
|
|
119
|
+
modelCatalog: input.modelCatalogOk ? "ok" : "failed",
|
|
120
|
+
costCatalog: bestCostCatalog(input.costSources),
|
|
121
|
+
costCatalogError: input.costCatalogError ?? null,
|
|
122
|
+
},
|
|
123
|
+
costSources: { ...input.costSources },
|
|
124
|
+
...(input.review ? { review: input.review } : {}),
|
|
125
|
+
status,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function writeManifest(path: string, manifest: CatalogManifest): void {
|
|
130
|
+
writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`, "utf-8");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function lastSuccessfulModelCount(manifest: CatalogManifest | null): number | null {
|
|
134
|
+
if (!manifest) return null;
|
|
135
|
+
if (manifest.status === "broken") return null;
|
|
136
|
+
return manifest.modelCount;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function countCostSources(input: {
|
|
140
|
+
modelIds: string[];
|
|
141
|
+
cliIds: Set<string>;
|
|
142
|
+
officialDocIds: Set<string>;
|
|
143
|
+
thirdPartyIds: Set<string>;
|
|
144
|
+
freeIds: Set<string>;
|
|
145
|
+
fallbackIds?: Set<string>;
|
|
146
|
+
}): CostSources {
|
|
147
|
+
const fallbackIds = input.fallbackIds ?? new Set<string>();
|
|
148
|
+
const sources: CostSources = {
|
|
149
|
+
cli: 0,
|
|
150
|
+
officialDocs: 0,
|
|
151
|
+
thirdParty: 0,
|
|
152
|
+
free: 0,
|
|
153
|
+
fallback: 0,
|
|
154
|
+
unmatched: 0,
|
|
155
|
+
};
|
|
156
|
+
for (const id of input.modelIds) {
|
|
157
|
+
if (input.cliIds.has(id)) sources.cli++;
|
|
158
|
+
else if (input.officialDocIds.has(id)) sources.officialDocs++;
|
|
159
|
+
else if (input.thirdPartyIds.has(id)) sources.thirdParty++;
|
|
160
|
+
else if (input.freeIds.has(id)) sources.free++;
|
|
161
|
+
else if (fallbackIds.has(id)) sources.fallback++;
|
|
162
|
+
else sources.unmatched++;
|
|
163
|
+
}
|
|
164
|
+
return sources;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function bumpPackageVersionField(pkgJson: string): { json: string; version: string } {
|
|
168
|
+
const pkg = JSON.parse(pkgJson) as { version: string };
|
|
169
|
+
const version = bumpPatch(pkg.version);
|
|
170
|
+
const json = pkgJson.replace(/("version"\s*:\s*")([^"]+)(")/, `$1${version}$3`);
|
|
171
|
+
return { json, version };
|
|
172
|
+
}
|
package/src/model.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
LanguageModelV3,
|
|
3
|
+
LanguageModelV3CallOptions,
|
|
4
|
+
LanguageModelV3StreamResult,
|
|
5
|
+
LanguageModelV3GenerateResult,
|
|
6
|
+
LanguageModelV3Content,
|
|
7
|
+
LanguageModelV3Usage,
|
|
8
|
+
LanguageModelV3FinishReason,
|
|
9
|
+
} from "@ai-sdk/provider";
|
|
10
|
+
import { buildRequest } from "./convert.js";
|
|
11
|
+
import { parseStreamEvents } from "./stream.js";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_BASE_URL = "https://api.commandcode.ai";
|
|
14
|
+
// x-command-code-version must match the Command Code CLI version for API compatibility
|
|
15
|
+
const CC_VERSION = "0.26.20";
|
|
16
|
+
|
|
17
|
+
export interface CommandCodeModelOptions {
|
|
18
|
+
apiKey: string;
|
|
19
|
+
baseURL?: string;
|
|
20
|
+
headers?: Record<string, string>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class CommandCodeLanguageModel implements LanguageModelV3 {
|
|
24
|
+
readonly specificationVersion = "v3" as const;
|
|
25
|
+
readonly provider = "commandcode";
|
|
26
|
+
readonly modelId: string;
|
|
27
|
+
supportedUrls: Record<string, RegExp[]> = {};
|
|
28
|
+
|
|
29
|
+
private opts: CommandCodeModelOptions;
|
|
30
|
+
|
|
31
|
+
constructor(modelId: string, opts: CommandCodeModelOptions) {
|
|
32
|
+
this.modelId = modelId;
|
|
33
|
+
this.opts = opts;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private get baseURL(): string {
|
|
37
|
+
return this.opts.baseURL ?? DEFAULT_BASE_URL;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private buildHeaders(): Record<string, string> {
|
|
41
|
+
return {
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
Authorization: `Bearer ${this.opts.apiKey}`,
|
|
44
|
+
"x-command-code-version": CC_VERSION,
|
|
45
|
+
"x-cli-environment": "production",
|
|
46
|
+
"x-project-slug": "opencode",
|
|
47
|
+
...this.opts.headers,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async doStream(options: LanguageModelV3CallOptions): Promise<LanguageModelV3StreamResult> {
|
|
52
|
+
const body = buildRequest(this.modelId, options);
|
|
53
|
+
const requestBody = JSON.stringify(body);
|
|
54
|
+
|
|
55
|
+
const controller = new AbortController();
|
|
56
|
+
const timeout = setTimeout(
|
|
57
|
+
() => controller.abort(new Error("Request timed out after 5 minutes")),
|
|
58
|
+
300_000,
|
|
59
|
+
);
|
|
60
|
+
const userSignal = options.abortSignal;
|
|
61
|
+
if (userSignal) {
|
|
62
|
+
const onAbort = () => controller.abort(userSignal.reason);
|
|
63
|
+
userSignal.addEventListener("abort", onAbort, { once: true });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const response = await fetch(`${this.baseURL}/alpha/generate`, {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: this.buildHeaders(),
|
|
70
|
+
body: requestBody,
|
|
71
|
+
signal: controller.signal,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
const errorBody = await response.text().catch(() => "");
|
|
76
|
+
let errorMessage = `Command Code API error: ${response.status} ${response.statusText}`;
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(errorBody);
|
|
79
|
+
if (parsed.error?.message) errorMessage = parsed.error.message;
|
|
80
|
+
else if (parsed.message) errorMessage = parsed.message;
|
|
81
|
+
} catch {
|
|
82
|
+
// intentionally silent: error body is not JSON
|
|
83
|
+
}
|
|
84
|
+
throw new Error(`${errorMessage} [model=${this.modelId}]`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!response.body) {
|
|
88
|
+
throw new Error(`Command Code API returned no body [model=${this.modelId}]`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const responseHeaders: Record<string, string> = {};
|
|
92
|
+
response.headers.forEach((v, k) => {
|
|
93
|
+
responseHeaders[k] = v;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
stream: parseStreamEvents(response.body as ReadableStream<Uint8Array>),
|
|
98
|
+
request: { body: requestBody },
|
|
99
|
+
response: { headers: responseHeaders },
|
|
100
|
+
};
|
|
101
|
+
} finally {
|
|
102
|
+
clearTimeout(timeout);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async doGenerate(options: LanguageModelV3CallOptions): Promise<LanguageModelV3GenerateResult> {
|
|
107
|
+
const { stream } = await this.doStream(options);
|
|
108
|
+
|
|
109
|
+
const textParts: string[] = [];
|
|
110
|
+
const reasoningParts: string[] = [];
|
|
111
|
+
const content: LanguageModelV3Content[] = [];
|
|
112
|
+
let finishReason: LanguageModelV3FinishReason = { unified: "stop", raw: "stop" };
|
|
113
|
+
let usage: LanguageModelV3Usage = {
|
|
114
|
+
inputTokens: {
|
|
115
|
+
total: undefined,
|
|
116
|
+
noCache: undefined,
|
|
117
|
+
cacheRead: undefined,
|
|
118
|
+
cacheWrite: undefined,
|
|
119
|
+
},
|
|
120
|
+
outputTokens: { total: undefined, text: undefined, reasoning: undefined },
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const reader = stream.getReader();
|
|
124
|
+
try {
|
|
125
|
+
while (true) {
|
|
126
|
+
const { done, value } = await reader.read();
|
|
127
|
+
if (done) break;
|
|
128
|
+
|
|
129
|
+
switch (value.type) {
|
|
130
|
+
case "text-delta":
|
|
131
|
+
textParts.push(value.delta);
|
|
132
|
+
break;
|
|
133
|
+
case "reasoning-delta":
|
|
134
|
+
reasoningParts.push(value.delta);
|
|
135
|
+
break;
|
|
136
|
+
case "tool-call":
|
|
137
|
+
content.push({
|
|
138
|
+
type: "tool-call",
|
|
139
|
+
toolCallId: value.toolCallId,
|
|
140
|
+
toolName: value.toolName,
|
|
141
|
+
input: value.input,
|
|
142
|
+
});
|
|
143
|
+
break;
|
|
144
|
+
case "finish":
|
|
145
|
+
finishReason = value.finishReason;
|
|
146
|
+
usage = value.usage;
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} finally {
|
|
151
|
+
reader.releaseLock();
|
|
152
|
+
stream.cancel();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const text = textParts.join("");
|
|
156
|
+
if (text) content.unshift({ type: "text", text });
|
|
157
|
+
|
|
158
|
+
const reasoning = reasoningParts.join("");
|
|
159
|
+
if (reasoning) content.unshift({ type: "reasoning", text: reasoning });
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
content,
|
|
163
|
+
finishReason,
|
|
164
|
+
usage,
|
|
165
|
+
warnings: [],
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
package/src/opencode2.ts
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join, dirname } from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
import { toConfigKey, loadCatalogFromLocalCommandCode, type ModelEntry } from "./catalog.js";
|
|
6
|
+
import {
|
|
7
|
+
readCatalogCache,
|
|
8
|
+
writeCatalogCache,
|
|
9
|
+
writeStartupSummary,
|
|
10
|
+
pluginStateDir,
|
|
11
|
+
type StartupSummary,
|
|
12
|
+
} from "./startup.js";
|
|
13
|
+
import type { CatalogManifest } from "./manifest.js";
|
|
14
|
+
|
|
15
|
+
// OpenCode 2.0 support: in-memory provider inventory + integration.
|
|
16
|
+
//
|
|
17
|
+
// The 2.0 host API is duck-typed on purpose (mirrors
|
|
18
|
+
// oakimov/cursor-opencode-provider `src/opencode2/types.ts`): importing
|
|
19
|
+
// `@opencode/plugin` as a runtime dependency would force a plugin bump on
|
|
20
|
+
// every OpenCode release. These shapes only cover what this plugin publishes
|
|
21
|
+
// or calls; extra host fields are ignored at runtime.
|
|
22
|
+
|
|
23
|
+
export const COMMANDCODE_PROVIDER_ID = "commandcode";
|
|
24
|
+
export const COMMANDCODE_INTEGRATION_ID = "commandcode";
|
|
25
|
+
export const COMMANDCODE_PROVIDER_NAME = "Command Code";
|
|
26
|
+
export const COMMANDCODE_BASE_URL = "https://api.commandcode.ai/provider/v1";
|
|
27
|
+
export const COMMANDCODE_PACKAGE = "@opencode/ai/providers/openai-compatible";
|
|
28
|
+
export const COMMANDCODE_ENV_NAMES = ["COMMANDCODE_API_KEY"];
|
|
29
|
+
|
|
30
|
+
const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
const MODELS_PATH = join(MODULE_DIR, "..", "models.json");
|
|
32
|
+
const VERSION_PATH = join(MODULE_DIR, "..", "_version.txt");
|
|
33
|
+
const MANIFEST_PATH = join(MODULE_DIR, "..", "manifest.json");
|
|
34
|
+
|
|
35
|
+
export type V2Cost = {
|
|
36
|
+
tier?: { type: "context"; size: number };
|
|
37
|
+
input: number;
|
|
38
|
+
output: number;
|
|
39
|
+
cache: { read: number; write: number };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type V2Variant = {
|
|
43
|
+
id: string;
|
|
44
|
+
settings?: Record<string, unknown>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type V2ModelInfo = {
|
|
48
|
+
id: string;
|
|
49
|
+
/** Wire id sent to the provider. Lets one upstream model back one entry. */
|
|
50
|
+
modelID: string;
|
|
51
|
+
providerID: string;
|
|
52
|
+
name: string;
|
|
53
|
+
capabilities: { tools: boolean; input: string[]; output: string[] };
|
|
54
|
+
limit: { context: number; output: number };
|
|
55
|
+
variants: V2Variant[];
|
|
56
|
+
status: "active";
|
|
57
|
+
enabled: true;
|
|
58
|
+
time: { released: number };
|
|
59
|
+
cost: V2Cost[];
|
|
60
|
+
settings?: Record<string, unknown>;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type V2ProviderInfo = {
|
|
64
|
+
id: string;
|
|
65
|
+
name: string;
|
|
66
|
+
activation: "enabled";
|
|
67
|
+
package: string;
|
|
68
|
+
integrationID: string;
|
|
69
|
+
settings: Record<string, unknown>;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export interface V2ProviderEditor {
|
|
73
|
+
add(input: {
|
|
74
|
+
info: V2ProviderInfo;
|
|
75
|
+
models: readonly V2ModelInfo[];
|
|
76
|
+
sourceConnection?: unknown;
|
|
77
|
+
}): void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface V2IntegrationDraft {
|
|
81
|
+
update(id: string, update: (integration: { id: string; name: string }) => void): void;
|
|
82
|
+
readonly method: {
|
|
83
|
+
update(
|
|
84
|
+
input:
|
|
85
|
+
| { readonly integrationID: string; readonly method: { type: "key"; label?: string } }
|
|
86
|
+
| { readonly integrationID: string; readonly method: { type: "env"; names: string[] } },
|
|
87
|
+
): void;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface V2SetupContext {
|
|
92
|
+
readonly provider: {
|
|
93
|
+
readonly transform: (
|
|
94
|
+
callback: (editor: V2ProviderEditor) => void,
|
|
95
|
+
) => Promise<{ dispose: () => Promise<void> }>;
|
|
96
|
+
readonly reload: () => Promise<void>;
|
|
97
|
+
};
|
|
98
|
+
readonly integration: {
|
|
99
|
+
readonly transform: (
|
|
100
|
+
callback: (draft: V2IntegrationDraft) => void,
|
|
101
|
+
) => Promise<{ dispose: () => Promise<void> }>;
|
|
102
|
+
readonly connection: {
|
|
103
|
+
readonly active: (integrationID: string) => Promise<unknown>;
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
readonly event?: {
|
|
107
|
+
readonly subscribe: (...args: unknown[]) => AsyncIterable<{ type?: unknown }>;
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface PluginFileConfig {
|
|
112
|
+
disableModelSync?: boolean;
|
|
113
|
+
commandCodePackagePath?: string;
|
|
114
|
+
debugStartupLogs?: boolean;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function loadPluginFileConfig(): PluginFileConfig {
|
|
118
|
+
const dir = join(homedir(), ".config", "opencode");
|
|
119
|
+
const configPath = [
|
|
120
|
+
join(dir, "opencode-commandcode.json"),
|
|
121
|
+
join(dir, "commandcode-go-opencode-provider.json"),
|
|
122
|
+
].find((p) => existsSync(p));
|
|
123
|
+
if (!configPath) return {};
|
|
124
|
+
try {
|
|
125
|
+
return JSON.parse(readFileSync(configPath, "utf-8"));
|
|
126
|
+
} catch {
|
|
127
|
+
return {};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function modelEntryToV2(entry: ModelEntry): V2ModelInfo {
|
|
132
|
+
const rawInput = entry.modalities?.input?.filter(
|
|
133
|
+
(m): m is string => typeof m === "string" && m.length > 0,
|
|
134
|
+
) ?? ["text"];
|
|
135
|
+
const input = rawInput.length > 0 ? [...rawInput] : ["text"];
|
|
136
|
+
const variants: V2Variant[] = (entry.reasoningEfforts ?? []).map((effort) => ({
|
|
137
|
+
id: effort,
|
|
138
|
+
settings: { reasoningEffort: effort },
|
|
139
|
+
}));
|
|
140
|
+
return {
|
|
141
|
+
id: toConfigKey(entry.id),
|
|
142
|
+
modelID: entry.id,
|
|
143
|
+
providerID: COMMANDCODE_PROVIDER_ID,
|
|
144
|
+
name: entry.name,
|
|
145
|
+
capabilities: { tools: entry.tool_call, input, output: ["text"] },
|
|
146
|
+
limit: { context: entry.limit.context, output: entry.limit.output },
|
|
147
|
+
variants,
|
|
148
|
+
status: "active",
|
|
149
|
+
enabled: true,
|
|
150
|
+
// The bundled catalog carries no release dates; 0 sorts these last in the
|
|
151
|
+
// picker, so filter by provider "Command Code" (same convention as the
|
|
152
|
+
// cursor provider plugin).
|
|
153
|
+
time: { released: 0 },
|
|
154
|
+
cost: [
|
|
155
|
+
{
|
|
156
|
+
input: entry.cost.input,
|
|
157
|
+
output: entry.cost.output,
|
|
158
|
+
cache: { read: entry.cost.cache_read ?? 0, write: entry.cost.cache_write ?? 0 },
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function buildV2Models(entries: ModelEntry[]): V2ModelInfo[] {
|
|
165
|
+
return entries.map(modelEntryToV2);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function buildProviderInfo(): V2ProviderInfo {
|
|
169
|
+
return {
|
|
170
|
+
id: COMMANDCODE_PROVIDER_ID,
|
|
171
|
+
name: COMMANDCODE_PROVIDER_NAME,
|
|
172
|
+
activation: "enabled",
|
|
173
|
+
package: COMMANDCODE_PACKAGE,
|
|
174
|
+
integrationID: COMMANDCODE_INTEGRATION_ID,
|
|
175
|
+
settings: { baseURL: COMMANDCODE_BASE_URL },
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Publish the catalog into the live provider inventory. A no-op while empty
|
|
181
|
+
* so a failed load keeps the last successful inventory (same convention as
|
|
182
|
+
* the cursor provider plugin).
|
|
183
|
+
*/
|
|
184
|
+
export function applyProviderInventory(
|
|
185
|
+
editor: V2ProviderEditor,
|
|
186
|
+
models: ModelEntry[],
|
|
187
|
+
sourceConnection?: unknown,
|
|
188
|
+
): void {
|
|
189
|
+
if (models.length === 0) return;
|
|
190
|
+
editor.add({
|
|
191
|
+
info: buildProviderInfo(),
|
|
192
|
+
models: buildV2Models(models),
|
|
193
|
+
...(sourceConnection === undefined ? {} : { sourceConnection }),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Register the `commandcode` integration: API-key entry plus env fallback. */
|
|
198
|
+
export function applyIntegration(draft: V2IntegrationDraft): void {
|
|
199
|
+
draft.update(COMMANDCODE_INTEGRATION_ID, (integration) => {
|
|
200
|
+
integration.id = COMMANDCODE_INTEGRATION_ID;
|
|
201
|
+
integration.name = COMMANDCODE_PROVIDER_NAME;
|
|
202
|
+
});
|
|
203
|
+
draft.method.update({
|
|
204
|
+
integrationID: COMMANDCODE_INTEGRATION_ID,
|
|
205
|
+
method: { type: "key", label: "API Key" },
|
|
206
|
+
});
|
|
207
|
+
draft.method.update({
|
|
208
|
+
integrationID: COMMANDCODE_INTEGRATION_ID,
|
|
209
|
+
method: { type: "env", names: [...COMMANDCODE_ENV_NAMES] },
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function loadBundledModels(): ModelEntry[] | null {
|
|
214
|
+
try {
|
|
215
|
+
const parsed = JSON.parse(readFileSync(MODELS_PATH, "utf-8")) as unknown;
|
|
216
|
+
if (!Array.isArray(parsed) || parsed.length === 0) return null;
|
|
217
|
+
return parsed as ModelEntry[];
|
|
218
|
+
} catch {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function readBundledManifest(): CatalogManifest | null {
|
|
224
|
+
if (!existsSync(MANIFEST_PATH)) return null;
|
|
225
|
+
try {
|
|
226
|
+
return JSON.parse(readFileSync(MANIFEST_PATH, "utf-8")) as CatalogManifest;
|
|
227
|
+
} catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function readBundledVersion(): string | null {
|
|
233
|
+
const manifest = readBundledManifest();
|
|
234
|
+
if (manifest?.commandCodeVersion) return manifest.commandCodeVersion;
|
|
235
|
+
if (!existsSync(VERSION_PATH)) return null;
|
|
236
|
+
try {
|
|
237
|
+
const first = readFileSync(VERSION_PATH, "utf-8").split("\n")[0]?.trim();
|
|
238
|
+
return first || null;
|
|
239
|
+
} catch {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export type V2CatalogSource = "bundled" | "cache" | "opt-in-local";
|
|
245
|
+
|
|
246
|
+
export interface LoadedV2Catalog {
|
|
247
|
+
models: ModelEntry[];
|
|
248
|
+
source: V2CatalogSource;
|
|
249
|
+
commandCodeVersion: string | null;
|
|
250
|
+
degraded: boolean;
|
|
251
|
+
degradedReason: string | null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Catalog resolution shared with the V1 `config` hook in `plugin.ts`:
|
|
256
|
+
* opt-in local `command-code` scrape first, then the bundled `models.json`,
|
|
257
|
+
* then the last-good cache. Always refreshes the cache and `startup.json`
|
|
258
|
+
* when models resolve. Keep the two in sync when this changes.
|
|
259
|
+
*/
|
|
260
|
+
export function loadCatalogForV2(): LoadedV2Catalog {
|
|
261
|
+
const pluginCfg = loadPluginFileConfig();
|
|
262
|
+
const debug = pluginCfg.debugStartupLogs === true;
|
|
263
|
+
const override =
|
|
264
|
+
pluginCfg.commandCodePackagePath?.trim() || process.env.COMMANDCODE_PACKAGE_PATH?.trim() || "";
|
|
265
|
+
|
|
266
|
+
let models: ModelEntry[] = [];
|
|
267
|
+
// Mirrors the V1 `config` hook: stays "bundled" when nothing resolves.
|
|
268
|
+
let source: V2CatalogSource = "bundled";
|
|
269
|
+
let commandCodeVersion: string | null = null;
|
|
270
|
+
let degraded = false;
|
|
271
|
+
let degradedReason: string | null = null;
|
|
272
|
+
|
|
273
|
+
if (override) {
|
|
274
|
+
const localCatalog = loadCatalogFromLocalCommandCode({ packagePath: override });
|
|
275
|
+
if (localCatalog && localCatalog.models.length > 0) {
|
|
276
|
+
models = localCatalog.models;
|
|
277
|
+
source = "opt-in-local";
|
|
278
|
+
commandCodeVersion = localCatalog.version;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (models.length === 0) {
|
|
283
|
+
const bundled = loadBundledModels();
|
|
284
|
+
if (bundled) {
|
|
285
|
+
models = bundled;
|
|
286
|
+
source = "bundled";
|
|
287
|
+
commandCodeVersion = readBundledVersion();
|
|
288
|
+
const manifest = readBundledManifest();
|
|
289
|
+
if (manifest?.status === "degraded" || manifest?.status === "broken") {
|
|
290
|
+
degraded = true;
|
|
291
|
+
degradedReason =
|
|
292
|
+
manifest.status === "broken"
|
|
293
|
+
? "bundled catalog marked broken"
|
|
294
|
+
: "bundled catalog has models with no listed price";
|
|
295
|
+
}
|
|
296
|
+
} else {
|
|
297
|
+
const cached = readCatalogCache();
|
|
298
|
+
if (cached) {
|
|
299
|
+
models = cached;
|
|
300
|
+
source = "cache";
|
|
301
|
+
degraded = true;
|
|
302
|
+
degradedReason = "bundled models.json unreadable; using last-good cache";
|
|
303
|
+
} else {
|
|
304
|
+
degraded = true;
|
|
305
|
+
degradedReason = "no bundled catalog and no cache";
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (models.length > 0) {
|
|
311
|
+
try {
|
|
312
|
+
writeCatalogCache(pluginStateDir(), models);
|
|
313
|
+
} catch {
|
|
314
|
+
// ignore cache write
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const summary: StartupSummary = {
|
|
319
|
+
catalogSource: source,
|
|
320
|
+
commandCodeVersion,
|
|
321
|
+
modelCount: models.length,
|
|
322
|
+
reasoningModelCount: models.filter((m) => m.reasoning).length,
|
|
323
|
+
degraded,
|
|
324
|
+
degradedReason,
|
|
325
|
+
};
|
|
326
|
+
try {
|
|
327
|
+
writeStartupSummary(pluginStateDir(), summary);
|
|
328
|
+
} catch {
|
|
329
|
+
// ignore
|
|
330
|
+
}
|
|
331
|
+
if (debug) {
|
|
332
|
+
console.warn("[commandcode]", JSON.stringify(summary));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return { models, source, commandCodeVersion, degraded, degradedReason };
|
|
336
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type CatalogSyncDecision = {
|
|
2
|
+
extract: boolean;
|
|
3
|
+
publishRetry: boolean;
|
|
4
|
+
exit: boolean;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type PublishDecision = "publish" | "skip-no-token" | "skip-already-published";
|
|
8
|
+
|
|
9
|
+
export function decideCatalogSync(input: {
|
|
10
|
+
force: boolean;
|
|
11
|
+
latestCommandCodeVersion: string;
|
|
12
|
+
bundledCommandCodeVersion: string | null;
|
|
13
|
+
pluginVersion: string;
|
|
14
|
+
publishedPluginVersions: string[];
|
|
15
|
+
}): CatalogSyncDecision {
|
|
16
|
+
const published = input.publishedPluginVersions.includes(input.pluginVersion);
|
|
17
|
+
if (input.force || input.latestCommandCodeVersion !== input.bundledCommandCodeVersion) {
|
|
18
|
+
return { extract: true, publishRetry: false, exit: false };
|
|
19
|
+
}
|
|
20
|
+
if (!published) return { extract: false, publishRetry: true, exit: false };
|
|
21
|
+
return { extract: false, publishRetry: false, exit: true };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function decidePublish(input: {
|
|
25
|
+
pluginVersion: string;
|
|
26
|
+
publishedPluginVersions: string[];
|
|
27
|
+
npmTokenSet: boolean;
|
|
28
|
+
}): PublishDecision {
|
|
29
|
+
if (input.publishedPluginVersions.includes(input.pluginVersion)) return "skip-already-published";
|
|
30
|
+
if (!input.npmTokenSet) return "skip-no-token";
|
|
31
|
+
return "publish";
|
|
32
|
+
}
|