@textcortex/zenocode 0.1.12 → 0.1.14

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/README.md CHANGED
@@ -4,6 +4,12 @@ Zenocode is the secure coding agent for TextCortex customers. It runs in your te
4
4
 
5
5
  Built for teams that need speed, control, and trust, Zenocode combines a terminal-native coding workflow with workspace routing and enterprise security from day one.
6
6
 
7
+ ## Requirements
8
+
9
+ Zenocode is available on the **pay-as-you-go (PAYG)** plan. Your TextCortex account — or the organization seat assigned to you — must be on PAYG. On any other plan (Free, subscription, Business, Unlimited), `zenocode login` and model discovery return **"Zenocode is only available on the pay-as-you-go plan."** Switch your account (or ask your workspace admin to assign you a PAYG seat) before installing.
10
+
11
+ If you already use pay-as-you-go and just ran out of wallet credits, top up under **Settings → Billing** (`/user/dashboard/settings/billing`). If you are not on pay-as-you-go yet, pick a plan on the **plan selection** page (`/billing/plans`). The in-app Zenocode dialog (sidebar → Zenocode) links you to the right one automatically based on your account.
12
+
7
13
  ## What This Package Does
8
14
 
9
15
  - Launches Zenocode from your terminal with the `zenocode` or `codecortex` command
@@ -31,13 +37,17 @@ The first `zenocode login` launches Zenocode automatically after browser authent
31
37
 
32
38
  If you already have an API key, you can also start Zenocode by setting `TEXTCORTEX_API_KEY` or `TEXTCORTEX_API_TOKEN`.
33
39
 
40
+ ## Models
41
+
42
+ Zenocode uses **GPT-5.6 Luna** by default. You can switch to any other model available to your TextCortex workspace from the model picker inside the agent. Available models are discovered automatically from your account, so the default falls back to the next best coding model (for example Kimi K2.7 Code) when Luna is not available to you.
43
+
34
44
  ## Built For Security And Compliance
35
45
 
36
- Zenocode is built for organizations that treat security, compliance, and data sovereignty as non-negotiable.
46
+ Zenocode is built for organizations that treat security, compliance, and data governance as non-negotiable.
37
47
 
38
- - Hosted in the European Union on GDPR-compliant infrastructure
48
+ - TextCortex-managed routing with workspace-aware model availability
39
49
  - SOC 2 and ISO 27001 certified
40
- - Built for European data sovereignty
50
+ - Model region metadata is surfaced for routing and governance decisions
41
51
  - Zero data training: your data is not used for model training or fine-tuning
42
52
  - Optional services or LLMs hosted outside the EU are covered by the EU-US Data Privacy Framework or GDPR Standard Contractual Clauses
43
53
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@textcortex/zenocode",
3
- "version": "0.1.12",
4
- "description": "Secure, EU-hosted coding agent for TextCortex customers that runs in your terminal, edits files, runs scripts, and more.",
3
+ "version": "0.1.14",
4
+ "description": "Secure, TextCortex-managed coding agent for customers that runs in your terminal, edits files, runs scripts, and more.",
5
5
  "keywords": [
6
6
  "ai",
7
7
  "agent",
@@ -11,12 +11,17 @@
11
11
  "developer-tools",
12
12
  "textcortex",
13
13
  "secure",
14
- "eu-hosted",
14
+ "enterprise",
15
15
  "gdpr"
16
16
  ],
17
17
  "private": false,
18
18
  "license": "UNLICENSED",
19
19
  "type": "module",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/textcortex/platform.git",
23
+ "directory": "apps/zenocode"
24
+ },
20
25
  "publishConfig": {
21
26
  "access": "public"
22
27
  },
@@ -139,21 +139,55 @@ export function patchOpenCodeVersionFooterText(text) {
139
139
  return { patched, text: next };
140
140
  }
141
141
 
142
+ export function patchOpenCodeDisplayNameText(text) {
143
+ const replacements = [
144
+ ['TD("Uninstall OpenCode")', 'TD("Uninstall Zenocode")'],
145
+ ['"name":"OpenCode"', '"name":"Zenocode"'],
146
+ ['"short_name":"OpenCode"', '"short_name":"Zenocode"'],
147
+ ];
148
+
149
+ let patched = false;
150
+ let nextText = text;
151
+ for (const [target, replacement] of replacements) {
152
+ if (!nextText.includes(target)) {
153
+ continue;
154
+ }
155
+ nextText = nextText.replaceAll(target, replacement);
156
+ patched = true;
157
+ }
158
+
159
+ return { patched, text: nextText };
160
+ }
161
+
142
162
  export function patchZenocodeBinaryText(text) {
143
163
  let patched = false;
144
164
  let nextText = text;
165
+ const patches = {
166
+ logo: false,
167
+ footer: false,
168
+ displayName: false,
169
+ };
145
170
 
146
171
  const logoPatch = patchLogoSnippetText(nextText);
147
172
  if (logoPatch.patched) {
148
173
  nextText = logoPatch.text;
149
174
  patched = true;
175
+ patches.logo = true;
150
176
  }
151
177
 
152
178
  const footerPatch = patchOpenCodeVersionFooterText(nextText);
153
179
  if (footerPatch.patched) {
154
180
  nextText = footerPatch.text;
155
181
  patched = true;
182
+ patches.footer = true;
156
183
  }
157
184
 
158
- return { patched, text: nextText };
185
+ const displayNamePatch = patchOpenCodeDisplayNameText(nextText);
186
+ if (displayNamePatch.patched) {
187
+ nextText = displayNamePatch.text;
188
+ patched = true;
189
+ patches.displayName = true;
190
+ }
191
+
192
+ return { patched, patches, text: nextText };
159
193
  }
@@ -1,6 +1,11 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { padBinaryReplacement, patchOpenCodeVersionFooterText } from "./branding-patch.mjs";
3
+ import {
4
+ padBinaryReplacement,
5
+ patchOpenCodeDisplayNameText,
6
+ patchOpenCodeVersionFooterText,
7
+ patchZenocodeBinaryText,
8
+ } from "./branding-patch.mjs";
4
9
 
5
10
  test("padBinaryReplacement keeps binary length stable", () => {
6
11
  const padded = padBinaryReplacement("abcdef", "abc");
@@ -54,3 +59,31 @@ test("patchOpenCodeVersionFooterText is a no-op when already branded", () => {
54
59
  assert.equal(result.patched, false);
55
60
  assert.equal(result.text, branded);
56
61
  });
62
+
63
+ test("patchOpenCodeDisplayNameText rewrites current compiled display names", () => {
64
+ const text = [
65
+ 'TD("Uninstall OpenCode");"name":"OpenCode","short_name":"OpenCode"',
66
+ '"Powered by OpenCode"',
67
+ ].join(";");
68
+
69
+ const result = patchOpenCodeDisplayNameText(text);
70
+
71
+ assert.equal(result.patched, true);
72
+ assert.equal(result.text.length, text.length);
73
+ assert.equal(
74
+ result.text,
75
+ 'TD("Uninstall Zenocode");"name":"Zenocode","short_name":"Zenocode";"Powered by OpenCode"',
76
+ );
77
+ });
78
+
79
+ test("patchZenocodeBinaryText reports display-name-only branding separately", () => {
80
+ const result = patchZenocodeBinaryText('"name":"OpenCode"');
81
+
82
+ assert.equal(result.patched, true);
83
+ assert.deepEqual(result.patches, {
84
+ logo: false,
85
+ footer: false,
86
+ displayName: true,
87
+ });
88
+ assert.equal(result.text, '"name":"Zenocode"');
89
+ });
@@ -7,17 +7,31 @@ import path from "node:path";
7
7
  import process from "node:process";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { patchZenocodeBinaryText } from "./branding-patch.mjs";
10
+ import { openCodeRef, openCodeVersion } from "./opencode-version.mjs";
10
11
 
11
12
  const currentFilePath = realpathSync(fileURLToPath(import.meta.url));
12
13
  const __dirname = path.dirname(currentFilePath);
13
14
  const appRoot = path.resolve(__dirname, "..");
14
15
  const defaultOutputDir = path.join(appRoot, ".zenocode", "brand-build");
16
+ export const defaultOpenCodeForkUrl = "https://github.com/sst/opencode";
17
+ export const defaultOpenCodeRef = openCodeRef;
18
+ export const defaultOpenCodeVersion = openCodeVersion;
19
+ export const defaultOpenCodeBuildArgs = ["--skip-install"];
20
+ export const trustedPublishingRepository = {
21
+ type: "git",
22
+ url: "git+https://github.com/textcortex/platform.git",
23
+ };
24
+ const displayNameOnlyBrandingRefs = new Set([defaultOpenCodeRef]);
15
25
 
16
26
  const forkUrl =
17
27
  process.env.ZENOCODE_OPENCODE_FORK_URL ||
18
28
  process.env.CODECORTEX_OPENCODE_FORK_URL ||
19
- "https://github.com/anomalyco/opencode";
20
- const forkRef = (process.env.ZENOCODE_OPENCODE_REF || process.env.CODECORTEX_OPENCODE_REF || "").trim();
29
+ defaultOpenCodeForkUrl;
30
+ const forkRef = (
31
+ process.env.ZENOCODE_OPENCODE_REF ||
32
+ process.env.CODECORTEX_OPENCODE_REF ||
33
+ defaultOpenCodeRef
34
+ ).trim();
21
35
  const outputDir =
22
36
  process.env.ZENOCODE_BRANDED_OUTPUT_DIR ||
23
37
  process.env.CODECORTEX_BRANDED_OUTPUT_DIR ||
@@ -36,16 +50,27 @@ const publishTag =
36
50
  process.env.ZENOCODE_PUBLISH_TAG ||
37
51
  process.env.CODECORTEX_PUBLISH_TAG ||
38
52
  "latest";
39
- const buildArgs = (
53
+ const publishProvenance =
54
+ (process.env.ZENOCODE_NPM_PROVENANCE || process.env.CODECORTEX_NPM_PROVENANCE) === "1";
55
+ const configuredBuildArgs = (
40
56
  process.env.ZENOCODE_OPENCODE_BUILD_ARGS ||
41
57
  process.env.CODECORTEX_OPENCODE_BUILD_ARGS ||
42
58
  ""
43
- )
44
- .trim()
45
- .split(/\s+/)
46
- .filter(Boolean);
59
+ ).trim();
60
+ const buildArgs = configuredBuildArgs
61
+ ? configuredBuildArgs.split(/\s+/).filter(Boolean)
62
+ : defaultOpenCodeBuildArgs;
47
63
  const cliArgs = process.argv.slice(2);
48
64
 
65
+ export function deriveOpenCodeVersionFromRef(ref, fallbackVersion = defaultOpenCodeVersion) {
66
+ const match = ref.match(/^v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?:\+.*)?$/);
67
+ return match?.[1] || fallbackVersion;
68
+ }
69
+
70
+ export function allowsDisplayNameOnlyBranding(ref) {
71
+ return displayNameOnlyBrandingRefs.has(ref);
72
+ }
73
+
49
74
  function _command(name) {
50
75
  if (process.platform === "win32" && ["npm", "pnpm", "npx"].includes(name)) {
51
76
  return `${name}.cmd`;
@@ -207,6 +232,20 @@ async function writeJson(filePath, payload) {
207
232
  await fs.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf-8");
208
233
  }
209
234
 
235
+ export function selectLatestTarball(entries) {
236
+ const tarballs = entries.filter((entry) => entry.endsWith(".tgz")).sort((left, right) => {
237
+ const leftVersion = left.match(/-(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?:\+.*)?\.tgz$/)?.[1];
238
+ const rightVersion = right.match(/-(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?:\+.*)?\.tgz$/)?.[1];
239
+ if (leftVersion && rightVersion) {
240
+ return compareSemver(leftVersion, rightVersion);
241
+ }
242
+ if (leftVersion) return 1;
243
+ if (rightVersion) return -1;
244
+ return left.localeCompare(right);
245
+ });
246
+ return tarballs[tarballs.length - 1] || null;
247
+ }
248
+
210
249
  async function maybePublishedVersion(packageName) {
211
250
  const npmCommand = _command("npm");
212
251
  try {
@@ -230,15 +269,18 @@ async function maybePublishedVersion(packageName) {
230
269
  async function packPackage(packageDir) {
231
270
  const npmCommand = _command("npm");
232
271
  await run(npmCommand, ["pack"], { cwd: packageDir });
233
- const tarballs = (await fs.readdir(packageDir)).filter((entry) => entry.endsWith(".tgz")).sort();
234
- if (!tarballs.length) {
272
+ const tarball = selectLatestTarball(await fs.readdir(packageDir));
273
+ if (!tarball) {
235
274
  throw new Error(`npm pack did not produce a tarball in ${packageDir}`);
236
275
  }
237
- return path.join(packageDir, tarballs[tarballs.length - 1]);
276
+ return path.join(packageDir, tarball);
238
277
  }
239
278
 
240
- export function buildPublishCommandArgs(tarballPath, { tag } = {}) {
279
+ export function buildPublishCommandArgs(tarballPath, { tag, provenance = false } = {}) {
241
280
  const args = ["publish", tarballPath, "--access", "public"];
281
+ if (provenance) {
282
+ args.push("--provenance");
283
+ }
242
284
  if (tag) {
243
285
  args.push("--tag", tag);
244
286
  }
@@ -262,14 +304,79 @@ async function publishTarballIfNeeded(packageName, version, tarballPath, cwd) {
262
304
  }
263
305
 
264
306
  const npmCommand = _command("npm");
265
- await run(npmCommand, buildPublishCommandArgs(tarballPath, { tag: publishTag }), { cwd });
307
+ await run(npmCommand, buildPublishCommandArgs(tarballPath, { tag: publishTag, provenance: publishProvenance }), { cwd });
266
308
  return { published: true, skipped: false };
267
309
  }
268
310
 
311
+ async function readPublishTarget(packageDir) {
312
+ const pkg = await readJson(path.join(packageDir, "package.json"));
313
+ if (typeof pkg?.name !== "string" || typeof pkg?.version !== "string") {
314
+ throw new Error(`Invalid package metadata in ${packageDir}`);
315
+ }
316
+ const tarball = selectLatestTarball(await fs.readdir(packageDir));
317
+ if (!tarball) {
318
+ throw new Error(`Missing npm tarball in ${packageDir}`);
319
+ }
320
+ return {
321
+ packageName: pkg.name,
322
+ version: pkg.version,
323
+ tarballPath: path.join(packageDir, tarball),
324
+ cwd: packageDir,
325
+ };
326
+ }
327
+
328
+ async function publishExistingPackages(artifactDir) {
329
+ const packageRoot = path.join(artifactDir, "npm-binaries");
330
+ const entries = await fs.readdir(packageRoot, { withFileTypes: true });
331
+ const binaryPackageDirs = entries
332
+ .filter((entry) => entry.isDirectory())
333
+ .map((entry) => path.join(packageRoot, entry.name))
334
+ .sort();
335
+ if (!binaryPackageDirs.length) {
336
+ throw new Error(`No existing binary packages found under ${packageRoot}`);
337
+ }
338
+
339
+ const targets = [];
340
+ for (const packageDir of binaryPackageDirs) {
341
+ targets.push(await readPublishTarget(packageDir));
342
+ }
343
+ targets.push(await readPublishTarget(path.join(artifactDir, "npm-package")));
344
+
345
+ const published = [];
346
+ const skipped = [];
347
+ for (const target of targets) {
348
+ const result = await publishTarballIfNeeded(
349
+ target.packageName,
350
+ target.version,
351
+ target.tarballPath,
352
+ target.cwd,
353
+ );
354
+ if (result.published) {
355
+ published.push(target.packageName);
356
+ } else if (result.skipped) {
357
+ skipped.push(target.packageName);
358
+ }
359
+ }
360
+
361
+ if (published.length) {
362
+ console.log(`Published packages: ${published.join(", ")}`);
363
+ }
364
+ if (skipped.length) {
365
+ console.log(`Skipped already-published packages: ${skipped.join(", ")}`);
366
+ }
367
+ }
368
+
269
369
  function _binaryFilename() {
270
370
  return process.platform === "win32" ? "opencode.exe" : "opencode";
271
371
  }
272
372
 
373
+ async function adHocSignBinary(binaryPath) {
374
+ if (process.platform !== "darwin") return;
375
+ await run("codesign", ["--force", "--sign", "-", binaryPath], {
376
+ stdio: "ignore",
377
+ });
378
+ }
379
+
273
380
  async function patchBinaryAtPath(binaryPath) {
274
381
  const buffer = await fs.readFile(binaryPath);
275
382
  const originalLength = buffer.length;
@@ -277,6 +384,12 @@ async function patchBinaryAtPath(binaryPath) {
277
384
  if (!patch.patched) {
278
385
  throw new Error(`Branding patch did not match binary ${binaryPath}`);
279
386
  }
387
+ const patchedMandatoryBranding = patch.patches.logo || patch.patches.footer;
388
+ if (!patchedMandatoryBranding && !allowsDisplayNameOnlyBranding(forkRef)) {
389
+ throw new Error(
390
+ `Branding patch only matched display names for ${binaryPath}; update the logo/footer patch signatures before building ${forkRef}.`,
391
+ );
392
+ }
280
393
  const nextBuffer = Buffer.from(patch.text, "latin1");
281
394
  if (nextBuffer.length !== originalLength) {
282
395
  throw new Error(`Branding patch changed binary length for ${binaryPath}`);
@@ -285,6 +398,7 @@ async function patchBinaryAtPath(binaryPath) {
285
398
  if (process.platform !== "win32") {
286
399
  await fs.chmod(binaryPath, 0o755);
287
400
  }
401
+ await adHocSignBinary(binaryPath);
288
402
  }
289
403
 
290
404
  function _buildWrapperExecutable({ runtimePackageName, binName }) {
@@ -501,6 +615,7 @@ async function buildWrapperPackage({
501
615
  version,
502
616
  license: "MIT",
503
617
  type: "commonjs",
618
+ repository: trustedPublishingRepository,
504
619
  bin: buildWrapperBinMap(packageName, binName),
505
620
  optionalDependencies: Object.fromEntries(
506
621
  binaryPackages.map((pkg) => [pkg.brandedName, pkg.version]),
@@ -555,6 +670,7 @@ async function prepareBinaryPackages({ distDir, artifactDir, runtimePackageName
555
670
  const targetDir = path.join(packageRoot, brandedName.replace("/", "__"));
556
671
  await fs.cp(sourceDir, targetDir, { recursive: true });
557
672
  pkg.name = brandedName;
673
+ pkg.repository = trustedPublishingRepository;
558
674
  await writeJson(path.join(targetDir, "package.json"), pkg);
559
675
  await patchBinaryAtPath(path.join(targetDir, "bin", _binaryFilename()));
560
676
 
@@ -608,21 +724,29 @@ async function main() {
608
724
  console.log(" ZENOCODE_OPENCODE_BUILD_ARGS Additional args for upstream build script");
609
725
  console.log(" ZENOCODE_PUBLISH=1 Publish packages to npm");
610
726
  console.log(` ZENOCODE_PUBLISH_TAG npm tag (default: ${publishTag})`);
727
+ console.log(" --publish-existing Publish tarballs from the output directory without rebuilding");
611
728
  console.log(" Legacy CODECORTEX_* env names are still accepted for compatibility.");
612
729
  return;
613
730
  }
614
731
 
732
+ const npmCommand = _command("npm");
733
+ await requireCommand(npmCommand);
734
+
735
+ const artifactDir = path.resolve(outputDir);
736
+
737
+ if (cliArgs.includes("--publish-existing")) {
738
+ await publishExistingPackages(artifactDir);
739
+ return;
740
+ }
741
+
615
742
  const gitCommand = _command("git");
616
743
  const bunCommand = _command("bun");
617
- const npmCommand = _command("npm");
618
744
  await requireCommand(gitCommand);
619
745
  await requireCommand(bunCommand);
620
- await requireCommand(npmCommand);
621
746
 
622
747
  const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "zenocode-opencode-"));
623
748
  const checkoutDir = path.join(tempRoot, "opencode");
624
749
  const distDir = path.join(checkoutDir, "packages", "opencode", "dist");
625
- const artifactDir = path.resolve(outputDir);
626
750
 
627
751
  try {
628
752
  const cloneArgs = ["clone", "--depth=1"];
@@ -636,7 +760,16 @@ async function main() {
636
760
  await run(
637
761
  bunCommand,
638
762
  ["./packages/opencode/script/build.ts", "--single", ...buildArgs],
639
- { cwd: checkoutDir },
763
+ {
764
+ cwd: checkoutDir,
765
+ env: {
766
+ ...process.env,
767
+ OPENCODE_CHANNEL: process.env.OPENCODE_CHANNEL || "latest",
768
+ OPENCODE_VERSION:
769
+ process.env.OPENCODE_VERSION ||
770
+ deriveOpenCodeVersionFromRef(forkRef, defaultOpenCodeVersion),
771
+ },
772
+ },
640
773
  );
641
774
 
642
775
  await fs.mkdir(artifactDir, { recursive: true });
@@ -3,9 +3,35 @@ import test from "node:test";
3
3
  import {
4
4
  buildPublishCommandArgs,
5
5
  buildWrapperBinMap,
6
+ allowsDisplayNameOnlyBranding,
7
+ defaultOpenCodeBuildArgs,
8
+ defaultOpenCodeForkUrl,
9
+ defaultOpenCodeRef,
10
+ defaultOpenCodeVersion,
11
+ deriveOpenCodeVersionFromRef,
6
12
  mapBrandedBinaryPackageName,
13
+ selectLatestTarball,
14
+ trustedPublishingRepository,
7
15
  } from "./build-branded-opencode.mjs";
8
16
 
17
+ test("branded OpenCode build defaults to the latest stable upstream release", () => {
18
+ assert.equal(defaultOpenCodeForkUrl, "https://github.com/sst/opencode");
19
+ assert.equal(defaultOpenCodeRef, "v1.17.6");
20
+ assert.equal(defaultOpenCodeVersion, "1.17.6");
21
+ assert.deepEqual(defaultOpenCodeBuildArgs, ["--skip-install"]);
22
+ });
23
+
24
+ test("deriveOpenCodeVersionFromRef follows explicit tag overrides", () => {
25
+ assert.equal(deriveOpenCodeVersionFromRef("v1.17.5"), "1.17.5");
26
+ assert.equal(deriveOpenCodeVersionFromRef("1.18.0-beta.1"), "1.18.0-beta.1");
27
+ assert.equal(deriveOpenCodeVersionFromRef("main", "1.17.4"), "1.17.4");
28
+ });
29
+
30
+ test("display-name-only branding is limited to reviewed upstream refs", () => {
31
+ assert.equal(allowsDisplayNameOnlyBranding(defaultOpenCodeRef), true);
32
+ assert.equal(allowsDisplayNameOnlyBranding("v1.17.7"), false);
33
+ });
34
+
9
35
  test("mapBrandedBinaryPackageName scopes runtime binaries under zenocode", () => {
10
36
  assert.equal(
11
37
  mapBrandedBinaryPackageName("opencode-darwin-arm64", "@textcortex/zenocode-ai"),
@@ -25,7 +51,26 @@ test("buildWrapperBinMap includes package, opencode, and zenocode entrypoints",
25
51
  });
26
52
  });
27
53
 
28
- test("buildPublishCommandArgs omits npm provenance for runtime tarball publishing", () => {
54
+ test("generated runtime packages point trusted publishing at this repository", () => {
55
+ assert.deepEqual(trustedPublishingRepository, {
56
+ type: "git",
57
+ url: "git+https://github.com/textcortex/platform.git",
58
+ });
59
+ });
60
+
61
+ test("buildPublishCommandArgs enables npm provenance for trusted publishing", () => {
62
+ assert.deepEqual(buildPublishCommandArgs("package.tgz", { tag: "latest", provenance: true }), [
63
+ "publish",
64
+ "package.tgz",
65
+ "--access",
66
+ "public",
67
+ "--provenance",
68
+ "--tag",
69
+ "latest",
70
+ ]);
71
+ });
72
+
73
+ test("buildPublishCommandArgs omits npm provenance unless requested", () => {
29
74
  assert.deepEqual(buildPublishCommandArgs("package.tgz", { tag: "latest" }), [
30
75
  "publish",
31
76
  "package.tgz",
@@ -44,3 +89,15 @@ test("buildPublishCommandArgs omits tag arguments when no tag is provided", () =
44
89
  "public",
45
90
  ]);
46
91
  });
92
+
93
+ test("selectLatestTarball returns the sorted newest tarball", () => {
94
+ assert.equal(
95
+ selectLatestTarball([
96
+ "README.md",
97
+ "textcortex-zenocode-linux-x64-1.9.0.tgz",
98
+ "textcortex-zenocode-linux-x64-1.10.0.tgz",
99
+ ]),
100
+ "textcortex-zenocode-linux-x64-1.10.0.tgz",
101
+ );
102
+ assert.equal(selectLatestTarball(["README.md"]), null);
103
+ });
@@ -0,0 +1,4 @@
1
+ export const openCodePackageName = "opencode-ai";
2
+ export const openCodeVersion = "1.17.6";
3
+ export const openCodeRef = `v${openCodeVersion}`;
4
+ export const openCodePackageSpec = `${openCodePackageName}@${openCodeVersion}`;
@@ -11,6 +11,10 @@ import {
11
11
  patchZenocodeBinaryText,
12
12
  zenocodeLogo,
13
13
  } from "./branding-patch.mjs";
14
+ import {
15
+ openCodePackageName,
16
+ openCodePackageSpec,
17
+ } from "./opencode-version.mjs";
14
18
 
15
19
  const currentFilePath = realpathSync(fileURLToPath(import.meta.url));
16
20
  const __dirname = path.dirname(currentFilePath);
@@ -40,19 +44,29 @@ const configuredOpencodePackage =
40
44
  process.env.CODECORTEX_OPENCODE_PACKAGE ||
41
45
  process.env.OPENCODE_PACKAGE ||
42
46
  null;
43
- const defaultBrandedOpencodePackage = "@textcortex/zenocode-ai";
47
+ const defaultBrandedOpencodePackage = openCodePackageSpec;
44
48
  const legacyBrandedOpencodePackage = "@textcortex/opencode-ai";
45
- const fallbackOpencodePackage = "opencode-ai";
49
+ const fallbackOpencodePackage = openCodePackageName;
46
50
  const opencodePackage = configuredOpencodePackage || defaultBrandedOpencodePackage;
47
51
  const opencodeBinaryPath =
48
52
  process.env.ZENOCODE_OPENCODE_BIN_PATH ||
49
53
  process.env.CODECORTEX_OPENCODE_BIN_PATH ||
50
54
  "";
51
- const oauthInitiatePath = "/internal/v1/fastapi/zenocode/oauth2/initiate";
52
- const oauthTokenPath = "/internal/v1/fastapi/zenocode/oauth2/token";
55
+ const oauthInitiatePath = "/internal/v2/fastapi/zenocode/oauth2/initiate";
56
+ const oauthTokenPath = "/internal/v2/fastapi/zenocode/oauth2/token";
53
57
  const defaultOrder = [
58
+ "gpt-5-6-luna",
59
+ "kimi-k2p7-code-non-thinking",
60
+ "kimi-k2p7-code-thinking",
61
+ "minimax-m3-thinking",
62
+ "kimi-k2-6-non-thinking",
63
+ "kimi-k2-6-thinking",
64
+ "kimi-k2-5",
54
65
  "kimi-k2-5-thinking",
55
- "glm-5",
66
+ "glm-5-2-non-thinking",
67
+ "glm-5-2-thinking",
68
+ "glm-5-1-non-thinking",
69
+ "glm-5-1-thinking",
56
70
  "gpt-5-2",
57
71
  "gpt-5-1",
58
72
  "gpt-5",
@@ -387,7 +401,8 @@ export function chooseDefaults(models) {
387
401
  const ids = Object.keys(models);
388
402
  if (!ids.length) throw new Error("No models were returned by the backend.");
389
403
  const model = defaultOrder.find((id) => ids.includes(id)) || ids[0];
390
- const smallModel = ids.find((id) => id.includes("mini") || id.includes("haiku")) || model;
404
+ const smallModel =
405
+ ids.find((id) => id.includes("-mini") || id.includes("haiku")) || model;
391
406
  return { model, smallModel };
392
407
  }
393
408
 
@@ -403,7 +418,7 @@ export function buildOpenCodeConfig({ baseUrl, providerID, model, smallModel })
403
418
  [providerID]: {
404
419
  name: "Zenocode",
405
420
  options: {
406
- baseURL: new URL("/internal/v1/fastapi/zenocode/v1", baseUrl).toString(),
421
+ baseURL: new URL("/internal/v2/fastapi/zenocode/v1", baseUrl).toString(),
407
422
  },
408
423
  },
409
424
  // Older fallback opencode-ai builds can load the Codex auth plugin when
@@ -464,7 +479,7 @@ async function requestJson(url, init) {
464
479
  }
465
480
 
466
481
  async function prepareRuntime(baseUrl, token) {
467
- const modelsUrl = new URL("/internal/v1/fastapi/zenocode/models/api.json", baseUrl).toString();
482
+ const modelsUrl = new URL("/internal/v2/fastapi/zenocode/models/api.json", baseUrl).toString();
468
483
  const { response, payload, text } = await requestJson(modelsUrl, {
469
484
  headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
470
485
  });
@@ -954,11 +969,26 @@ function _runtimeBrandedBinaryPath() {
954
969
  return path.join(runtimeDir, "bin", binaryName);
955
970
  }
956
971
 
972
+ export function packageNameFromSpecifier(packageSpecifier) {
973
+ if (!packageSpecifier.includes("@")) {
974
+ return packageSpecifier;
975
+ }
976
+ if (!packageSpecifier.startsWith("@")) {
977
+ return packageSpecifier.split("@")[0];
978
+ }
979
+
980
+ const versionSeparator = packageSpecifier.lastIndexOf("@");
981
+ return versionSeparator > packageSpecifier.indexOf("/")
982
+ ? packageSpecifier.slice(0, versionSeparator)
983
+ : packageSpecifier;
984
+ }
985
+
957
986
  function shouldPatchOpencodeRuntimePackage(packageName) {
987
+ const runtimePackageName = packageNameFromSpecifier(packageName);
958
988
  return (
959
- packageName.endsWith("/opencode-ai") ||
960
- packageName.endsWith("/zenocode-ai") ||
961
- packageName === "opencode-ai"
989
+ runtimePackageName.endsWith("/opencode-ai") ||
990
+ runtimePackageName.endsWith("/zenocode-ai") ||
991
+ runtimePackageName === openCodePackageName
962
992
  );
963
993
  }
964
994
 
@@ -999,6 +1029,88 @@ async function _collectPnpmDlxPnpmDirs(rootDir) {
999
1029
  return pnpmDirs;
1000
1030
  }
1001
1031
 
1032
+ async function _collectNodeModulesDirs(rootDir) {
1033
+ const queue = [{ dir: rootDir, depth: 0 }];
1034
+ const nodeModulesDirs = [];
1035
+
1036
+ while (queue.length) {
1037
+ const current = queue.shift();
1038
+ const nodeModulesPath = path.join(current.dir, "node_modules");
1039
+ if (await _pathExists(nodeModulesPath)) {
1040
+ nodeModulesDirs.push(nodeModulesPath);
1041
+ }
1042
+
1043
+ if (current.depth >= 3) continue;
1044
+ let entries = [];
1045
+ try {
1046
+ entries = await fs.readdir(current.dir, { withFileTypes: true });
1047
+ } catch {
1048
+ continue;
1049
+ }
1050
+ for (const entry of entries) {
1051
+ if (!entry.isDirectory()) continue;
1052
+ if (entry.name === "node_modules") continue;
1053
+ queue.push({ dir: path.join(current.dir, entry.name), depth: current.depth + 1 });
1054
+ }
1055
+ }
1056
+
1057
+ return nodeModulesDirs;
1058
+ }
1059
+
1060
+ async function _collectOpencodeBinariesFromNodeModules(nodeModulesPath) {
1061
+ const binaryName = process.platform === "win32" ? "opencode.exe" : "opencode";
1062
+ const hiddenCachedBinaryName = ".opencode";
1063
+ const candidates = [];
1064
+
1065
+ let topLevel = [];
1066
+ try {
1067
+ topLevel = await fs.readdir(nodeModulesPath, { withFileTypes: true });
1068
+ } catch {
1069
+ return candidates;
1070
+ }
1071
+
1072
+ for (const moduleEntry of topLevel) {
1073
+ if (!moduleEntry.isDirectory()) continue;
1074
+
1075
+ if (moduleEntry.name === openCodePackageName) {
1076
+ const cachedPath = path.join(nodeModulesPath, openCodePackageName, "bin", hiddenCachedBinaryName);
1077
+ if (await _pathExists(cachedPath)) candidates.push(cachedPath);
1078
+ continue;
1079
+ }
1080
+
1081
+ if (moduleEntry.name.startsWith("opencode-")) {
1082
+ const binaryPath = path.join(nodeModulesPath, moduleEntry.name, "bin", binaryName);
1083
+ if (await _pathExists(binaryPath)) candidates.push(binaryPath);
1084
+ continue;
1085
+ }
1086
+
1087
+ if (!moduleEntry.name.startsWith("@")) continue;
1088
+ const scopePath = path.join(nodeModulesPath, moduleEntry.name);
1089
+ let scopedPackages = [];
1090
+ try {
1091
+ scopedPackages = await fs.readdir(scopePath, { withFileTypes: true });
1092
+ } catch {
1093
+ continue;
1094
+ }
1095
+ for (const scopedPackage of scopedPackages) {
1096
+ if (!scopedPackage.isDirectory()) continue;
1097
+ const scopedNodeModulesPath = path.join(scopePath, scopedPackage.name);
1098
+
1099
+ if (scopedPackage.name === "opencode-ai") {
1100
+ const cachedPath = path.join(scopedNodeModulesPath, "bin", hiddenCachedBinaryName);
1101
+ if (await _pathExists(cachedPath)) candidates.push(cachedPath);
1102
+ continue;
1103
+ }
1104
+
1105
+ if (!scopedPackage.name.startsWith("opencode-")) continue;
1106
+ const binaryPath = path.join(scopedNodeModulesPath, "bin", binaryName);
1107
+ if (await _pathExists(binaryPath)) candidates.push(binaryPath);
1108
+ }
1109
+ }
1110
+
1111
+ return candidates;
1112
+ }
1113
+
1002
1114
  async function _collectPnpmDlxOpencodeBinaries() {
1003
1115
  const dlxRoots = [
1004
1116
  path.join(os.homedir(), "Library", "Caches", "pnpm", "dlx"),
@@ -1006,8 +1118,6 @@ async function _collectPnpmDlxOpencodeBinaries() {
1006
1118
  path.join(process.env.LOCALAPPDATA || "", "pnpm", "dlx"),
1007
1119
  ].filter(Boolean);
1008
1120
 
1009
- const binaryName = process.platform === "win32" ? "opencode.exe" : "opencode";
1010
- const hiddenCachedBinaryName = ".opencode";
1011
1121
  const candidates = [];
1012
1122
 
1013
1123
  for (const root of dlxRoots) {
@@ -1026,51 +1136,7 @@ async function _collectPnpmDlxOpencodeBinaries() {
1026
1136
  const nodeModulesPath = path.join(pnpmDir, entry.name, "node_modules");
1027
1137
  if (!(await _pathExists(nodeModulesPath))) continue;
1028
1138
 
1029
- let topLevel = [];
1030
- try {
1031
- topLevel = await fs.readdir(nodeModulesPath, { withFileTypes: true });
1032
- } catch {
1033
- continue;
1034
- }
1035
-
1036
- for (const moduleEntry of topLevel) {
1037
- if (!moduleEntry.isDirectory()) continue;
1038
-
1039
- if (moduleEntry.name === "opencode-ai") {
1040
- const cachedPath = path.join(nodeModulesPath, "opencode-ai", "bin", hiddenCachedBinaryName);
1041
- if (await _pathExists(cachedPath)) candidates.push(cachedPath);
1042
- continue;
1043
- }
1044
-
1045
- if (moduleEntry.name.startsWith("opencode-")) {
1046
- const binaryPath = path.join(nodeModulesPath, moduleEntry.name, "bin", binaryName);
1047
- if (await _pathExists(binaryPath)) candidates.push(binaryPath);
1048
- continue;
1049
- }
1050
-
1051
- if (!moduleEntry.name.startsWith("@")) continue;
1052
- const scopePath = path.join(nodeModulesPath, moduleEntry.name);
1053
- let scopedPackages = [];
1054
- try {
1055
- scopedPackages = await fs.readdir(scopePath, { withFileTypes: true });
1056
- } catch {
1057
- continue;
1058
- }
1059
- for (const scopedPackage of scopedPackages) {
1060
- if (!scopedPackage.isDirectory()) continue;
1061
- const scopedNodeModulesPath = path.join(scopePath, scopedPackage.name);
1062
-
1063
- if (scopedPackage.name === "opencode-ai") {
1064
- const cachedPath = path.join(scopedNodeModulesPath, "bin", hiddenCachedBinaryName);
1065
- if (await _pathExists(cachedPath)) candidates.push(cachedPath);
1066
- continue;
1067
- }
1068
-
1069
- if (!scopedPackage.name.startsWith("opencode-")) continue;
1070
- const binaryPath = path.join(scopedNodeModulesPath, "bin", binaryName);
1071
- if (await _pathExists(binaryPath)) candidates.push(binaryPath);
1072
- }
1073
- }
1139
+ candidates.push(...await _collectOpencodeBinariesFromNodeModules(nodeModulesPath));
1074
1140
  }
1075
1141
  }
1076
1142
  }
@@ -1078,6 +1144,44 @@ async function _collectPnpmDlxOpencodeBinaries() {
1078
1144
  return [...new Set(candidates)];
1079
1145
  }
1080
1146
 
1147
+ async function _collectNpxOpencodeBinaries() {
1148
+ const npxRoots = [
1149
+ path.join(os.homedir(), ".npm", "_npx"),
1150
+ path.join(process.env.LOCALAPPDATA || "", "npm-cache", "_npx"),
1151
+ ].filter(Boolean);
1152
+ const candidates = [];
1153
+
1154
+ for (const root of npxRoots) {
1155
+ if (!(await _pathExists(root))) continue;
1156
+ const nodeModulesDirs = await _collectNodeModulesDirs(root);
1157
+ for (const nodeModulesDir of nodeModulesDirs) {
1158
+ candidates.push(...await _collectOpencodeBinariesFromNodeModules(nodeModulesDir));
1159
+ }
1160
+ }
1161
+
1162
+ return [...new Set(candidates)];
1163
+ }
1164
+
1165
+ function _buildPackageWarmupArgs(packageName, runner) {
1166
+ if (runner.command === _runnerCommand("pnpm")) {
1167
+ return ["dlx", packageName, "--version"];
1168
+ }
1169
+ if (runner.command === _runnerCommand("npx")) {
1170
+ return ["--yes", packageName, "--version"];
1171
+ }
1172
+ return null;
1173
+ }
1174
+
1175
+ async function _collectRunnerOpencodeBinaries(runner) {
1176
+ if (runner.command === _runnerCommand("pnpm")) {
1177
+ return _collectPnpmDlxOpencodeBinaries();
1178
+ }
1179
+ if (runner.command === _runnerCommand("npx")) {
1180
+ return _collectNpxOpencodeBinaries();
1181
+ }
1182
+ return [];
1183
+ }
1184
+
1081
1185
  async function _adHocSignBinary(binaryPath) {
1082
1186
  if (process.platform !== "darwin") return;
1083
1187
  try {
@@ -1143,9 +1247,6 @@ async function _ensurePatchedOpencodeDlxBinaries(packageName, runner, options) {
1143
1247
  if (!shouldPatchOpencodeRuntimePackage(packageName)) {
1144
1248
  return null;
1145
1249
  }
1146
- if (runner.command !== _runnerCommand("pnpm")) {
1147
- return null;
1148
- }
1149
1250
  if (
1150
1251
  process.env.ZENOCODE_DISABLE_OPENCODE_LOGO_PATCH === "1" ||
1151
1252
  process.env.CODECORTEX_DISABLE_OPENCODE_LOGO_PATCH === "1"
@@ -1153,19 +1254,18 @@ async function _ensurePatchedOpencodeDlxBinaries(packageName, runner, options) {
1153
1254
  return null;
1154
1255
  }
1155
1256
 
1156
- let binaryCandidates = await _collectPnpmDlxOpencodeBinaries();
1157
- if (!binaryCandidates.length) {
1158
- // Warm cache so we have a concrete binary to patch.
1159
- try {
1160
- await runChild(runner.command, ["dlx", packageName, "--version"], {
1257
+ const warmupArgs = _buildPackageWarmupArgs(packageName, runner);
1258
+ try {
1259
+ if (warmupArgs) {
1260
+ await runChild(runner.command, warmupArgs, {
1161
1261
  ...options,
1162
1262
  stdio: "ignore",
1163
1263
  });
1164
- } catch {
1165
- // ignore warm-up failures and continue with best effort patching
1166
1264
  }
1167
- binaryCandidates = await _collectPnpmDlxOpencodeBinaries();
1265
+ } catch {
1266
+ // ignore warm-up failures and continue with best effort patching
1168
1267
  }
1268
+ const binaryCandidates = await _collectRunnerOpencodeBinaries(runner);
1169
1269
 
1170
1270
  if (!binaryCandidates.length) {
1171
1271
  return null;
@@ -1198,6 +1298,22 @@ export function buildPackageLauncherChildOptions(options, pinnedRuntimePath) {
1198
1298
  };
1199
1299
  }
1200
1300
 
1301
+ export function buildPackageLauncherInvocation({ runner, args, pinnedRuntimePath }) {
1302
+ if (pinnedRuntimePath) {
1303
+ return {
1304
+ command: pinnedRuntimePath,
1305
+ args,
1306
+ usePinnedRuntime: true,
1307
+ };
1308
+ }
1309
+
1310
+ return {
1311
+ command: runner.command,
1312
+ args: runner.args,
1313
+ usePinnedRuntime: false,
1314
+ };
1315
+ }
1316
+
1201
1317
  async function runPackageLauncher(packageName, args, options) {
1202
1318
  const runners = [
1203
1319
  { command: _runnerCommand("pnpm"), args: ["dlx", packageName, ...args] },
@@ -1215,9 +1331,17 @@ async function runPackageLauncher(packageName, args, options) {
1215
1331
  }
1216
1332
  }
1217
1333
 
1218
- const childOptions = buildPackageLauncherChildOptions(options, pinnedRuntimePath);
1334
+ const invocation = buildPackageLauncherInvocation({
1335
+ runner,
1336
+ args,
1337
+ pinnedRuntimePath,
1338
+ });
1339
+ const childOptions = buildPackageLauncherChildOptions(
1340
+ options,
1341
+ invocation.usePinnedRuntime ? null : pinnedRuntimePath,
1342
+ );
1219
1343
 
1220
- const result = await runChild(runner.command, runner.args, childOptions);
1344
+ const result = await runChild(invocation.command, invocation.args, childOptions);
1221
1345
  if (result.signal) {
1222
1346
  process.kill(process.pid, result.signal);
1223
1347
  return;
@@ -1363,10 +1487,11 @@ export async function runRuntimeWithSessionRecovery({
1363
1487
  }
1364
1488
 
1365
1489
  async function packageExistsOnNpm(packageName) {
1490
+ const packageSpecName = packageNameFromSpecifier(packageName);
1366
1491
  const controller = new AbortController();
1367
1492
  const timeout = setTimeout(() => controller.abort(), 4_000);
1368
1493
  try {
1369
- const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}`, {
1494
+ const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageSpecName)}`, {
1370
1495
  method: "GET",
1371
1496
  headers: { Accept: "application/json" },
1372
1497
  signal: controller.signal,
@@ -1424,6 +1549,11 @@ function shouldRenderBanner(args) {
1424
1549
  return !args.some((arg) => suppressFlags.has(arg));
1425
1550
  }
1426
1551
 
1552
+ export function shouldBypassZenocodePreparation(args) {
1553
+ const metadataFlags = new Set(["--help", "-h", "--version", "-v", "completion"]);
1554
+ return metadataFlags.has(args[0]);
1555
+ }
1556
+
1427
1557
  function maybeRenderBanner(args) {
1428
1558
  if (!shouldRenderBanner(args)) {
1429
1559
  return;
@@ -1617,6 +1747,15 @@ async function main() {
1617
1747
  }
1618
1748
 
1619
1749
  maybeRenderBanner(runtimeArgs);
1750
+ if (shouldBypassZenocodePreparation(runtimeArgs)) {
1751
+ const launchPackage = await resolveLaunchPackage();
1752
+ await runPackageLauncher(launchPackage, runtimeArgs, {
1753
+ cwd: process.cwd(),
1754
+ env: { ...process.env },
1755
+ });
1756
+ return;
1757
+ }
1758
+
1620
1759
  const tokenResolution = await resolveTokenWithAutoLogin(baseUrl, runtimeArgs, {
1621
1760
  preferLocalhost,
1622
1761
  });
@@ -11,32 +11,99 @@ import {
11
11
  } from "./branding-patch.mjs";
12
12
  import {
13
13
  buildOpenCodeConfig,
14
+ buildPackageLauncherInvocation,
14
15
  buildPackageLauncherChildOptions,
15
16
  canRecoverRuntimeSessionFromTranscript,
16
17
  buildZenocodeBanner,
17
18
  chooseDefaults,
18
19
  hasLocalBaseUrlFlag,
20
+ packageNameFromSpecifier,
19
21
  resolveLoginBaseUrl,
20
22
  resolveLoginSuccessIdentifier,
21
23
  resolveTextCortexBaseUrl,
22
24
  runRuntimeWithSessionRecovery,
25
+ shouldBypassZenocodePreparation,
23
26
  shouldFallbackLoginToCloud,
24
27
  writePrivateJsonFile,
25
28
  } from "./run-zenocode.mjs";
26
29
 
27
- test("chooseDefaults prefers kimi k2.5 thinking for Zenocode", () => {
30
+ test("chooseDefaults prefers GPT-5.6 Luna as the Zenocode default", () => {
28
31
  const defaults = chooseDefaults({
29
- "glm-5": {},
30
- "kimi-k2-5-thinking": {},
32
+ "kimi-k2p7-code-non-thinking": {},
33
+ "gpt-5-6-luna": {},
34
+ "glm-5-2-non-thinking": {},
35
+ "minimax-m3-thinking": {},
31
36
  "gpt-5-2": {},
32
37
  });
33
38
 
34
39
  assert.deepEqual(defaults, {
35
- model: "kimi-k2-5-thinking",
36
- smallModel: "kimi-k2-5-thinking",
40
+ model: "gpt-5-6-luna",
41
+ smallModel: "gpt-5-6-luna",
37
42
  });
38
43
  });
39
44
 
45
+ test("chooseDefaults falls back to Kimi K2.7 Code when GPT-5.6 Luna is unavailable", () => {
46
+ const defaults = chooseDefaults({
47
+ "kimi-k2p7-code-non-thinking": {},
48
+ "glm-5-2-non-thinking": {},
49
+ "glm-5-1-non-thinking": {},
50
+ "kimi-k2-6-non-thinking": {},
51
+ "minimax-m3-thinking": {},
52
+ "gpt-5-2": {},
53
+ });
54
+
55
+ assert.deepEqual(defaults, {
56
+ model: "kimi-k2p7-code-non-thinking",
57
+ smallModel: "kimi-k2p7-code-non-thinking",
58
+ });
59
+ });
60
+
61
+ test("chooseDefaults prefers GLM 5.2 over GLM 5.1 for Zenocode fallback", () => {
62
+ const defaults = chooseDefaults({
63
+ "glm-5-1-non-thinking": {},
64
+ "glm-5-2-non-thinking": {},
65
+ "gpt-5-mini": {},
66
+ });
67
+
68
+ assert.deepEqual(defaults, {
69
+ model: "glm-5-2-non-thinking",
70
+ smallModel: "gpt-5-mini",
71
+ });
72
+ });
73
+
74
+ test("chooseDefaults falls back to explicit thinking variants when base models are absent", () => {
75
+ const defaults = chooseDefaults({
76
+ "kimi-k2p7-code-thinking": {},
77
+ "glm-5-2-non-thinking": {},
78
+ "kimi-k2-6-thinking": {},
79
+ });
80
+
81
+ assert.deepEqual(defaults, {
82
+ model: "kimi-k2p7-code-thinking",
83
+ smallModel: "kimi-k2p7-code-thinking",
84
+ });
85
+ });
86
+
87
+ test("packageNameFromSpecifier strips npm versions without breaking scopes", () => {
88
+ assert.equal(packageNameFromSpecifier("opencode-ai@1.17.6"), "opencode-ai");
89
+ assert.equal(
90
+ packageNameFromSpecifier("@textcortex/zenocode-ai@1.17.6"),
91
+ "@textcortex/zenocode-ai",
92
+ );
93
+ assert.equal(
94
+ packageNameFromSpecifier("@textcortex/zenocode-ai"),
95
+ "@textcortex/zenocode-ai",
96
+ );
97
+ });
98
+
99
+ test("shouldBypassZenocodePreparation skips auth for runtime metadata commands", () => {
100
+ assert.equal(shouldBypassZenocodePreparation(["--version"]), true);
101
+ assert.equal(shouldBypassZenocodePreparation(["completion", "zsh"]), true);
102
+ assert.equal(shouldBypassZenocodePreparation(["run"]), false);
103
+ assert.equal(shouldBypassZenocodePreparation(["run", "implement", "completion"]), false);
104
+ assert.equal(shouldBypassZenocodePreparation(["run", "--help"]), false);
105
+ });
106
+
40
107
  test("resolveTextCortexBaseUrl defaults to the cloud API for packaged usage", () => {
41
108
  assert.equal(
42
109
  resolveTextCortexBaseUrl({
@@ -128,12 +195,13 @@ test("buildOpenCodeConfig includes an openai stub for fallback runtime auth plug
128
195
  const config = buildOpenCodeConfig({
129
196
  baseUrl: "http://127.0.0.1:8080",
130
197
  providerID: "textcortex",
131
- model: "kimi-k2-5-thinking",
132
- smallModel: "kimi-k2-5-thinking",
198
+ model: "minimax-m3-thinking",
199
+ smallModel: "gpt-5-2",
133
200
  });
134
201
 
135
202
  assert.deepEqual(config.enabled_providers, ["textcortex"]);
136
- assert.equal(config.model, "textcortex/kimi-k2-5-thinking");
203
+ assert.equal(config.model, "textcortex/minimax-m3-thinking");
204
+ assert.equal(config.small_model, "textcortex/gpt-5-2");
137
205
  assert.equal(config.theme, "system");
138
206
  assert.ok(config.provider.openai);
139
207
  assert.deepEqual(config.provider.openai.models, {});
@@ -156,6 +224,40 @@ test("buildPackageLauncherChildOptions keeps fallback package launchers attached
156
224
  });
157
225
  });
158
226
 
227
+ test("buildPackageLauncherInvocation runs pinned runtimes directly", () => {
228
+ const invocation = buildPackageLauncherInvocation({
229
+ runner: {
230
+ command: "npx",
231
+ args: ["--yes", "opencode-ai@1.17.6", "--version"],
232
+ },
233
+ args: ["--version"],
234
+ pinnedRuntimePath: "/tmp/zenocode-runtime",
235
+ });
236
+
237
+ assert.deepEqual(invocation, {
238
+ command: "/tmp/zenocode-runtime",
239
+ args: ["--version"],
240
+ usePinnedRuntime: true,
241
+ });
242
+ });
243
+
244
+ test("buildPackageLauncherInvocation uses package runners without a pinned runtime", () => {
245
+ const invocation = buildPackageLauncherInvocation({
246
+ runner: {
247
+ command: "pnpm",
248
+ args: ["dlx", "opencode-ai@1.17.6", "--version"],
249
+ },
250
+ args: ["--version"],
251
+ pinnedRuntimePath: null,
252
+ });
253
+
254
+ assert.deepEqual(invocation, {
255
+ command: "pnpm",
256
+ args: ["dlx", "opencode-ai@1.17.6", "--version"],
257
+ usePinnedRuntime: false,
258
+ });
259
+ });
260
+
159
261
  test("buildZenocodeBanner renders block logo art instead of plain text", () => {
160
262
  const banner = buildZenocodeBanner();
161
263
 
@@ -510,7 +612,7 @@ test("prepare-only refreshes stored Zenocode credentials when the access token h
510
612
 
511
613
  if (
512
614
  req.method === "GET" &&
513
- req.url === "/internal/v1/fastapi/zenocode/models/api.json"
615
+ req.url === "/internal/v2/fastapi/zenocode/models/api.json"
514
616
  ) {
515
617
  modelAuthHeader = req.headers.authorization || null;
516
618
  if (modelAuthHeader === "Bearer expired-access") {
@@ -524,8 +626,15 @@ test("prepare-only refreshes stored Zenocode credentials when the access token h
524
626
  JSON.stringify({
525
627
  textcortex: {
526
628
  models: {
629
+ "minimax-m3-thinking": {},
630
+ "kimi-k2-6-non-thinking": {},
631
+ "kimi-k2-6-thinking": {},
632
+ "kimi-k2-5": {},
527
633
  "kimi-k2-5-thinking": {},
528
- "glm-5": {},
634
+ "glm-5-2-non-thinking": {},
635
+ "glm-5-2-thinking": {},
636
+ "glm-5-1-non-thinking": {},
637
+ "glm-5-1-thinking": {},
529
638
  },
530
639
  },
531
640
  }),
@@ -580,6 +689,16 @@ test("prepare-only refreshes stored Zenocode credentials when the access token h
580
689
  const savedCredentials = JSON.parse(await fs.readFile(credentialsPath, "utf-8"));
581
690
  assert.equal(savedCredentials.access_token, "fresh-access");
582
691
  assert.equal(savedCredentials.refresh_token, "fresh-refresh");
692
+
693
+ const savedConfig = JSON.parse(
694
+ await fs.readFile(path.join(zenocodeHome, "opencode.jsonc"), "utf-8"),
695
+ );
696
+ assert.equal(savedConfig.model, "textcortex/minimax-m3-thinking");
697
+
698
+ const savedModels = JSON.parse(
699
+ await fs.readFile(path.join(zenocodeHome, "models.json"), "utf-8"),
700
+ );
701
+ assert.ok(savedModels.textcortex.models["glm-5-2-non-thinking"]);
583
702
  });
584
703
 
585
704
  test("login launches the runtime immediately with system TUI theming", async (t) => {
@@ -623,7 +742,7 @@ main().catch((error) => {
623
742
  const server = http.createServer((req, res) => {
624
743
  if (
625
744
  req.method === "POST" &&
626
- req.url === "/internal/v1/fastapi/zenocode/oauth2/initiate"
745
+ req.url === "/internal/v2/fastapi/zenocode/oauth2/initiate"
627
746
  ) {
628
747
  res.writeHead(200, { "Content-Type": "application/json" });
629
748
  res.end(
@@ -642,7 +761,7 @@ main().catch((error) => {
642
761
 
643
762
  if (
644
763
  req.method === "POST" &&
645
- req.url === "/internal/v1/fastapi/zenocode/oauth2/token"
764
+ req.url === "/internal/v2/fastapi/zenocode/oauth2/token"
646
765
  ) {
647
766
  res.writeHead(200, { "Content-Type": "application/json" });
648
767
  res.end(
@@ -659,15 +778,22 @@ main().catch((error) => {
659
778
 
660
779
  if (
661
780
  req.method === "GET" &&
662
- req.url === "/internal/v1/fastapi/zenocode/models/api.json"
781
+ req.url === "/internal/v2/fastapi/zenocode/models/api.json"
663
782
  ) {
664
783
  res.writeHead(200, { "Content-Type": "application/json" });
665
784
  res.end(
666
785
  JSON.stringify({
667
786
  textcortex: {
668
787
  models: {
788
+ "minimax-m3-thinking": {},
789
+ "kimi-k2-6-non-thinking": {},
790
+ "kimi-k2-6-thinking": {},
791
+ "kimi-k2-5": {},
669
792
  "kimi-k2-5-thinking": {},
670
- "glm-5": {},
793
+ "glm-5-2-non-thinking": {},
794
+ "glm-5-2-thinking": {},
795
+ "glm-5-1-non-thinking": {},
796
+ "glm-5-1-thinking": {},
671
797
  },
672
798
  },
673
799
  }),
@@ -736,6 +862,12 @@ main().catch((error) => {
736
862
  await fs.readFile(runtimeInvocation.env.OPENCODE_CONFIG, "utf-8"),
737
863
  );
738
864
  assert.equal(opencodeConfig.theme, "system");
865
+ assert.equal(opencodeConfig.model, "textcortex/minimax-m3-thinking");
866
+
867
+ const modelsConfig = JSON.parse(
868
+ await fs.readFile(path.join(zenocodeHome, "models.json"), "utf-8"),
869
+ );
870
+ assert.ok(modelsConfig.textcortex.models["glm-5-2-non-thinking"]);
739
871
 
740
872
  const tuiConfig = JSON.parse(
741
873
  await fs.readFile(runtimeInvocation.env.OPENCODE_TUI_CONFIG, "utf-8"),