@opencode-compat/cli 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.
@@ -0,0 +1,349 @@
1
+ /**
2
+ * Option B — install-tree provider shims for host LanguageModel adoption.
3
+ *
4
+ * After Layer A reify, rewrite custom provider package entries in-place so
5
+ * `create*` → `languageModel()` streams get MiMo/Kilo policy applied without
6
+ * editing upstream plugin sources. Needed because classic plugins often set
7
+ * `npm` to a direct `file://…/dist/index.js` URL (bypasses package exports).
8
+ */
9
+ import { ORIGINAL_SUFFIX, RUNTIME_FILENAME, SHIM_MARKER, SHIM_META_FILENAME, originalBackupPath, providerShimRuntimeSource, relativeImportPath, renderProviderShimSource, renderShimMeta, } from "@opencode-compat/adapter";
10
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
11
+ import { basename, dirname, join, relative, resolve } from "node:path";
12
+ const SKIP_NAME_PREFIXES = [
13
+ "@opencode-compat/",
14
+ "@opencode-ai/",
15
+ "@mimo-ai/",
16
+ "@kilocode/",
17
+ "@ai-sdk/",
18
+ ];
19
+ const SKIP_NAMES = new Set([
20
+ "opencode-compat-overrides",
21
+ "typescript",
22
+ "bun-types",
23
+ ]);
24
+ function readJson(path) {
25
+ if (!existsSync(path))
26
+ return undefined;
27
+ try {
28
+ return JSON.parse(readFileSync(path, "utf8"));
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ }
34
+ function exportConditionPath(value) {
35
+ if (typeof value === "string")
36
+ return value;
37
+ if (!value || typeof value !== "object" || Array.isArray(value))
38
+ return undefined;
39
+ const record = value;
40
+ for (const key of ["import", "module", "default", "require", "node"]) {
41
+ const hit = exportConditionPath(record[key]);
42
+ if (hit)
43
+ return hit;
44
+ }
45
+ return undefined;
46
+ }
47
+ /** Resolve package root entry relative path (POSIX, leading `./`). */
48
+ export function resolvePackageEntryRel(pkg) {
49
+ const exportsField = pkg.exports;
50
+ if (typeof exportsField === "string")
51
+ return normalizeRel(exportsField);
52
+ if (exportsField && typeof exportsField === "object" && !Array.isArray(exportsField)) {
53
+ const root = exportsField["."];
54
+ const fromDot = exportConditionPath(root);
55
+ if (fromDot)
56
+ return normalizeRel(fromDot);
57
+ }
58
+ if (typeof pkg.module === "string" && pkg.module)
59
+ return normalizeRel(pkg.module);
60
+ if (typeof pkg.main === "string" && pkg.main)
61
+ return normalizeRel(pkg.main);
62
+ return undefined;
63
+ }
64
+ function normalizeRel(value) {
65
+ const cleaned = value.replace(/\\/g, "/");
66
+ if (cleaned.startsWith("./"))
67
+ return cleaned;
68
+ if (cleaned.startsWith("/"))
69
+ return `.${cleaned}`;
70
+ return `./${cleaned}`;
71
+ }
72
+ function listScopedPackages(nodeModules) {
73
+ if (!existsSync(nodeModules))
74
+ return [];
75
+ const out = [];
76
+ for (const ent of readdirSync(nodeModules, { withFileTypes: true })) {
77
+ if (!ent.isDirectory() && !ent.isSymbolicLink())
78
+ continue;
79
+ if (ent.name === ".bin")
80
+ continue;
81
+ const full = join(nodeModules, ent.name);
82
+ if (ent.name.startsWith("@")) {
83
+ for (const scoped of readdirSync(full, { withFileTypes: true })) {
84
+ if (!scoped.isDirectory() && !scoped.isSymbolicLink())
85
+ continue;
86
+ out.push(join(full, scoped.name));
87
+ }
88
+ continue;
89
+ }
90
+ out.push(full);
91
+ }
92
+ return out;
93
+ }
94
+ function shouldSkipPackage(name) {
95
+ if (!name)
96
+ return false;
97
+ if (SKIP_NAMES.has(name))
98
+ return true;
99
+ return SKIP_NAME_PREFIXES.some((prefix) => name.startsWith(prefix));
100
+ }
101
+ /** Static scan for `export … create*` / named exports (sync; no module eval). */
102
+ export function discoverExportNames(source) {
103
+ const names = new Set();
104
+ for (const match of source.matchAll(/export\s+(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)/g)) {
105
+ names.add(match[1]);
106
+ }
107
+ for (const match of source.matchAll(/export\s+(?:const|let|var|class)\s+([A-Za-z_$][A-Za-z0-9_$]*)/g)) {
108
+ names.add(match[1]);
109
+ }
110
+ for (const match of source.matchAll(/export\s+\{([^}]+)\}/g)) {
111
+ const body = match[1];
112
+ for (const part of body.split(",")) {
113
+ const token = part.trim();
114
+ if (!token)
115
+ continue;
116
+ const asMatch = token.match(/^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/);
117
+ if (asMatch) {
118
+ names.add(asMatch[2]);
119
+ continue;
120
+ }
121
+ const ident = token.match(/^([A-Za-z_$][\w$]*)$/);
122
+ if (ident)
123
+ names.add(ident[1]);
124
+ }
125
+ }
126
+ return [...names];
127
+ }
128
+ function hasCreateExport(names) {
129
+ return names.some((name) => name.startsWith("create"));
130
+ }
131
+ function looksLikeProviderPackage(pkg, entrySource) {
132
+ if (shouldSkipPackage(pkg.name))
133
+ return false;
134
+ const names = discoverExportNames(entrySource);
135
+ if (!hasCreateExport(names))
136
+ return false;
137
+ // Prefer AI-provider shaped packages; still allow create* + languageModel mention.
138
+ const deps = {
139
+ ...(pkg.dependencies ?? {}),
140
+ ...(pkg.peerDependencies ?? {}),
141
+ };
142
+ if ("@ai-sdk/provider" in deps)
143
+ return true;
144
+ if (entrySource.includes("languageModel"))
145
+ return true;
146
+ if ((pkg.name ?? "").includes("provider"))
147
+ return true;
148
+ return hasCreateExport(names);
149
+ }
150
+ /** Discover provider package directories under a host install tree. */
151
+ export function discoverProviderPackageDirs(installRoot) {
152
+ const root = resolve(installRoot);
153
+ if (!existsSync(root))
154
+ return [];
155
+ const found = new Set();
156
+ const consider = (pkgDir) => {
157
+ const pkgPath = join(pkgDir, "package.json");
158
+ const pkg = readJson(pkgPath);
159
+ if (!pkg)
160
+ return;
161
+ if (shouldSkipPackage(pkg.name))
162
+ return;
163
+ const entryRel = resolvePackageEntryRel(pkg);
164
+ if (!entryRel)
165
+ return;
166
+ const entryAbs = resolve(pkgDir, entryRel);
167
+ // When already shimmed, read the backup for discovery.
168
+ const backupAbs = originalBackupPath(entryAbs);
169
+ const sourcePath = existsSync(backupAbs)
170
+ ? backupAbs
171
+ : existsSync(entryAbs)
172
+ ? entryAbs
173
+ : undefined;
174
+ if (!sourcePath)
175
+ return;
176
+ let source;
177
+ try {
178
+ source = readFileSync(sourcePath, "utf8");
179
+ }
180
+ catch {
181
+ return;
182
+ }
183
+ // If reading the live entry and it is our shim, require backup.
184
+ if (source.includes(SHIM_MARKER) && !existsSync(backupAbs))
185
+ return;
186
+ const probeSource = existsSync(backupAbs)
187
+ ? readFileSync(backupAbs, "utf8")
188
+ : source.includes(SHIM_MARKER)
189
+ ? ""
190
+ : source;
191
+ if (!probeSource || !looksLikeProviderPackage(pkg, probeSource))
192
+ return;
193
+ found.add(pkgDir);
194
+ };
195
+ // Isolated host layout: packages/<name>@<ver>/node_modules/<pkg>
196
+ for (const ent of readdirSync(root, { withFileTypes: true })) {
197
+ if (!ent.isDirectory() || ent.name.startsWith("."))
198
+ continue;
199
+ const child = join(root, ent.name);
200
+ consider(child);
201
+ for (const mod of listScopedPackages(join(child, "node_modules"))) {
202
+ consider(mod);
203
+ }
204
+ }
205
+ // Also scan root node_modules when present
206
+ for (const mod of listScopedPackages(join(root, "node_modules"))) {
207
+ consider(mod);
208
+ }
209
+ return [...found].sort();
210
+ }
211
+ function writeText(path, contents, dryRun) {
212
+ if (dryRun)
213
+ return;
214
+ mkdirSync(dirname(path), { recursive: true });
215
+ writeFileSync(path, contents, "utf8");
216
+ }
217
+ function shimOnePackage(packageDir, options) {
218
+ const pkg = readJson(join(packageDir, "package.json"));
219
+ const packageName = pkg?.name;
220
+ if (!pkg) {
221
+ return {
222
+ packageDir,
223
+ entry: "",
224
+ original: "",
225
+ changed: false,
226
+ skipped: "missing package.json",
227
+ };
228
+ }
229
+ const entryRel = resolvePackageEntryRel(pkg);
230
+ if (!entryRel) {
231
+ return {
232
+ packageDir,
233
+ packageName,
234
+ entry: "",
235
+ original: "",
236
+ changed: false,
237
+ skipped: "no package entry",
238
+ };
239
+ }
240
+ const entryAbs = resolve(packageDir, entryRel);
241
+ const backupAbs = originalBackupPath(entryAbs);
242
+ const dryRun = options.dryRun ?? false;
243
+ let exportSourcePath = entryAbs;
244
+ let changed = false;
245
+ if (existsSync(backupAbs)) {
246
+ exportSourcePath = backupAbs;
247
+ // Refresh shim/runtime even when backup already exists.
248
+ }
249
+ else if (existsSync(entryAbs)) {
250
+ const current = readFileSync(entryAbs, "utf8");
251
+ if (current.includes(SHIM_MARKER)) {
252
+ return {
253
+ packageDir,
254
+ packageName,
255
+ entry: entryRel,
256
+ original: normalizeRel(relative(packageDir, backupAbs) || basename(backupAbs)),
257
+ changed: false,
258
+ skipped: "shim present without backup; refuse to clobber",
259
+ };
260
+ }
261
+ if (!dryRun)
262
+ renameSync(entryAbs, backupAbs);
263
+ changed = true;
264
+ exportSourcePath = backupAbs;
265
+ }
266
+ else {
267
+ return {
268
+ packageDir,
269
+ packageName,
270
+ entry: entryRel,
271
+ original: "",
272
+ changed: false,
273
+ skipped: "entry file missing",
274
+ };
275
+ }
276
+ const originalSource = readFileSync(dryRun && !existsSync(backupAbs) ? entryAbs : exportSourcePath, "utf8");
277
+ const exportNames = discoverExportNames(originalSource).filter((name) => name !== "default");
278
+ if (!hasCreateExport(exportNames)) {
279
+ return {
280
+ packageDir,
281
+ packageName,
282
+ entry: entryRel,
283
+ original: normalizeRel(relative(packageDir, backupAbs)),
284
+ changed: false,
285
+ skipped: "no create* exports",
286
+ };
287
+ }
288
+ const originalRelFromEntry = relativeImportPath(entryRel, normalizeRel(relative(packageDir, backupAbs) || `${entryRel.replace(/\.js$/, "")}${ORIGINAL_SUFFIX}`));
289
+ const meta = {
290
+ original: originalRelFromEntry,
291
+ entry: entryRel,
292
+ exportNames,
293
+ hostHint: options.hostHint,
294
+ strategy: "inplace-entry",
295
+ };
296
+ const runtimePath = join(dirname(entryAbs), RUNTIME_FILENAME);
297
+ const metaPath = join(packageDir, SHIM_META_FILENAME);
298
+ const shimSource = renderProviderShimSource(meta);
299
+ const runtimeSource = providerShimRuntimeSource();
300
+ const metaSource = renderShimMeta(meta);
301
+ const prevShim = existsSync(entryAbs) ? readFileSync(entryAbs, "utf8") : "";
302
+ const prevRuntime = existsSync(runtimePath) ? readFileSync(runtimePath, "utf8") : "";
303
+ const prevMeta = existsSync(metaPath) ? readFileSync(metaPath, "utf8") : "";
304
+ if (prevShim !== shimSource ||
305
+ prevRuntime !== runtimeSource ||
306
+ prevMeta !== metaSource ||
307
+ changed) {
308
+ changed = true;
309
+ }
310
+ writeText(runtimePath, runtimeSource, dryRun);
311
+ writeText(entryAbs, shimSource, dryRun);
312
+ writeText(metaPath, metaSource, dryRun);
313
+ return {
314
+ packageDir,
315
+ packageName,
316
+ entry: entryRel,
317
+ original: meta.original,
318
+ changed,
319
+ };
320
+ }
321
+ /** Apply Option B shims under a host plugin install tree. */
322
+ export function setupProviderShims(options) {
323
+ const dir = resolve(options.dir);
324
+ const dirs = discoverProviderPackageDirs(dir);
325
+ const targets = dirs.map((packageDir) => shimOnePackage(packageDir, {
326
+ hostHint: options.hostHint,
327
+ dryRun: options.dryRun,
328
+ }));
329
+ const changed = targets.filter((t) => t.changed).length;
330
+ const skipped = targets.filter((t) => t.skipped);
331
+ const action = options.dryRun ? "dry-run" : "wrote";
332
+ const message = [
333
+ `provider-shim ${action}: scanned ${targets.length} provider package(s), ${changed} changed`,
334
+ ...targets
335
+ .filter((t) => t.changed || t.skipped)
336
+ .map((t) => t.skipped
337
+ ? ` - ${t.packageName ?? t.packageDir}: skipped (${t.skipped})`
338
+ : ` ~ ${t.packageName ?? t.packageDir}: ${t.entry} → shim (${t.original})`),
339
+ ...(skipped.length === 0
340
+ ? []
341
+ : [`note: ${skipped.length} candidate(s) skipped`]),
342
+ ].join("\n");
343
+ return {
344
+ ok: true,
345
+ targets,
346
+ message,
347
+ };
348
+ }
349
+ //# sourceMappingURL=provider-shim.js.map
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Layer A setup — write `@opencode-ai/{plugin,sdk}` install-tree overrides
3
+ * that resolve to `@opencode-compat/facade-*`, then Option B provider shims.
4
+ */
5
+ import { type DetectOptions, type HostId } from "@opencode-compat/profile";
6
+ import { type ProviderShimResult } from "./provider-shim";
7
+ export type SetupMode = "npm" | "file";
8
+ export type SetupOptions = {
9
+ /** Target install tree (defaults to detected host `pluginInstallDir`). */
10
+ dir?: string;
11
+ /** Force host id (sets OPENCODE_COMPAT_HOST for detect). */
12
+ host?: HostId | string;
13
+ /** Override map version for npm: specs (default 0.1.0). */
14
+ version?: string;
15
+ /** Prefer npm: specs or local file: paths. Auto when omitted. */
16
+ mode?: SetupMode | "auto";
17
+ /** Print actions without writing. */
18
+ dryRun?: boolean;
19
+ /** Also patch immediate child package.json trees (installed plugins). */
20
+ deep?: boolean;
21
+ /**
22
+ * After writing overrides, reify install trees that already have
23
+ * `node_modules` (or always when `"force"`) so facade overrides link.
24
+ * Default: `"auto"`.
25
+ */
26
+ reify?: boolean | "auto" | "force";
27
+ /**
28
+ * After reify, write Option B in-place provider entry shims
29
+ * (`create*` → host-dynamic languageModel adoption). Default: true.
30
+ */
31
+ providerShim?: boolean;
32
+ /** Detect options (tests). */
33
+ detectOptions?: DetectOptions;
34
+ };
35
+ export type SetupTarget = {
36
+ path: string;
37
+ created: boolean;
38
+ changed: boolean;
39
+ reified?: boolean;
40
+ reifyError?: string;
41
+ };
42
+ export type SetupResult = {
43
+ ok: boolean;
44
+ host: HostId;
45
+ source?: string;
46
+ dir: string;
47
+ mode: SetupMode;
48
+ overrides: Record<string, string>;
49
+ targets: SetupTarget[];
50
+ providerShim?: ProviderShimResult;
51
+ message: string;
52
+ };
53
+ /** Apply Layer A overrides into one or more package.json files under an install tree. */
54
+ export declare function setup(options?: SetupOptions): SetupResult;
55
+ export declare function parseSetupArgs(rest: string[]): {
56
+ dir?: string;
57
+ host?: string;
58
+ version?: string;
59
+ mode?: SetupMode | "auto";
60
+ dryRun: boolean;
61
+ deep: boolean;
62
+ reify: boolean | "auto" | "force";
63
+ providerShim: boolean;
64
+ help: boolean;
65
+ };
66
+ //# sourceMappingURL=setup.d.ts.map