@callstack/apex 0.3.0 → 0.3.2

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.
Files changed (3) hide show
  1. package/README.md +39 -9
  2. package/dist/cli.js +172 -37
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -148,15 +148,43 @@ their own backup first, so an undo is itself reversible. Apex CLI records each b
148
148
  | OpenCode | `~/.config/opencode/opencode.json` or `.jsonc` | Adds `provider.callstack.ai`, OpenAI-compatible transport, model and environment-key reference; preserves the default model. A config already in OpenCode 2's `providers` format gets the v2 entry instead, and you add the key with `/connect`. |
149
149
  | Codex 0.134.0+ | `~/.codex/callstack_ai.config.toml` | Adds a self-contained Responses API provider/profile; leaves base config and default model untouched. |
150
150
  | Claude Code | `~/.claude/settings.json` | Merges attribution flag only; use `apex run claude` for gateway credentials and model selection. |
151
- | Pi | `~/.pi/agent/models.json` | Adds `providers.callstack` with Chat Completions, Apex and `$CALLSTACK_AUTH_TOKEN`; preserves other models. Requires a Pi version supporting `$VAR` key interpolation. |
151
+ | Pi | `~/.pi/agent/models.json` and `settings.json` | Adds `providers.callstack` with Chat Completions, Apex and `$CALLSTACK_AUTH_TOKEN`; saves `medium` as Apex's per-model thinking default for direct launches. Preserves other models and their defaults. Requires a Pi version supporting `$VAR` key interpolation and `modelThinkingLevels`. |
152
152
  | Cursor | Guided setup | Prints endpoint, API-key and custom-model steps; does not modify private editor storage. |
153
153
  | VS Code / Copilot | Guided setup | Detects VS Code, not whether Copilot is installed; prints custom-endpoint steps and model JSON, retaining the editor-generated secret reference. |
154
- | Vercel AI SDK / Eve | Guided setup | Detected from the `package.json` in the directory you run Apex CLI from (`ai`, `@ai-sdk/openai` or `eve`); prints the connector snippet, which reads the key from `CALLSTACK_AUTH_TOKEN`, and the Eve context-window setting. Nothing is written into the project. |
155
-
156
- OpenCode, Pi, Codex and the Copilot model JSON also declare what Apex can do, in each tool's own
157
- fields: a 262,144-token context window, 16,384 output tokens, tool calling, image input, and the
158
- `none`/`low`/`medium`/`xhigh` reasoning efforts (default `medium`). Cursor and Claude Code have no
159
- such fields for a custom model.
154
+ | Vercel AI SDK / Eve | Guided setup | Detected from the `package.json` in the directory you run Apex CLI from (`ai`, `@ai-sdk/openai` or `eve`); prints the connector snippet, which reads the key from `CALLSTACK_AUTH_TOKEN`. Nothing is written into the project. |
155
+
156
+ Apex configs declare tool calling, image input, and the supported
157
+ `none`/`low`/`medium`/`xhigh` reasoning efforts (default `medium`) where supported.
158
+ OpenCode, Pi, Copilot, Claude's launcher, and the AI SDK guidance retain a 32,768-token output
159
+ budget. OpenCode, Pi, Codex, Copilot, and Claude's launcher declare the model's native
160
+ 262,144-token context window using each app's documented fields. Input limits and compaction
161
+ thresholds use the harness defaults; Apex CLI does not set a separate input budget or compaction
162
+ reserve.
163
+ Pi's per-model default applies to fresh direct launches and model selection; `apex run pi` also
164
+ selects `medium` explicitly. Resumed Pi sessions retain their saved thinking level.
165
+
166
+ The gateway model ID, authentication, `medium` default, and `off` → `none` translation remain
167
+ Callstack-specific.
168
+
169
+ | Harness | Context field | Output field | Official reference |
170
+ | --- | --- | --- | --- |
171
+ | OpenCode | `limit.context` | `limit.output` | [Custom providers](https://opencode.ai/docs/providers/) |
172
+ | Pi | `contextWindow` | `maxTokens` | [Custom models](https://pi.dev/docs/latest/models) |
173
+ | Codex | `model_context_window` | No output-limit config key | [OpenAI configuration reference](https://developers.openai.com/codex/config-reference/) |
174
+ | Claude Code launcher | `CLAUDE_CODE_MAX_CONTEXT_TOKENS` | `CLAUDE_CODE_MAX_OUTPUT_TOKENS` | [Environment variables](https://code.claude.com/docs/en/env-vars) |
175
+ | VS Code / Copilot | `contextWindow` | `maxOutputTokens` | [Custom endpoint models](https://code.visualstudio.com/docs/agent-customization/language-models) |
176
+
177
+ The Claude launcher sets context/output limits only in the child process and does not set compaction
178
+ window or percentage controls. Pi retains its native compaction settings. The AI SDK
179
+ snippet sets the output budget without inventing a context-window option.
180
+
181
+ Run `apex init` again after updating the CLI to preview upgrades to an existing Apex setup; pass
182
+ `--apply` to save them. Apex context/output metadata and reasoning mappings are updated in place. Older OpenCode input
183
+ limits and Codex compaction overrides are removed so each harness uses its own compaction defaults.
184
+ Codex's obsolete `model_max_output_tokens` key from older Apex versions is removed, and its dedicated
185
+ profile becomes self-contained. Unrelated settings, models, credentials and comments are preserved;
186
+ the ordinary backups and `apex undo` also cover upgrades. `none` remains supported: the gateway
187
+ translates it to thinking off.
160
188
 
161
189
  Respects `XDG_CONFIG_HOME`, `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `PI_CODING_AGENT_DIR`,
162
190
  `XDG_STATE_HOME`/`APEX_STATE_DIR`, and Windows `APPDATA`. Project-specific settings and custom
@@ -201,9 +229,11 @@ Apex CLI will not break what you already have.
201
229
  How that works underneath:
202
230
 
203
231
  - Previews print paths and key/value changes, never secrets.
204
- - All selected configurations are parsed before any write. Malformed files, conflicting Codex
205
- profile keys and symlinked files or directories are refused, and one bad assistant blocks the
232
+ - All selected configurations are parsed before any write. Malformed files and symlinked files or
233
+ directories are refused, and one bad assistant blocks the
206
234
  whole batch instead of half-applying it.
235
+ - The dedicated Codex Apex profile's owned keys are upgraded rather than rejected as conflicts;
236
+ the base `config.toml` and other profiles remain untouched.
207
237
  - JSON/JSONC edits preserve comments and unrelated keys. The Callstack endpoint is set to the
208
238
  documented one. A key the assistant already has (in OpenCode's own key store, or in Pi's
209
239
  `models.json`, where Apex CLI 0.2 put them) is kept, so upgrading never leaves an assistant
package/dist/cli.js CHANGED
@@ -2212,26 +2212,123 @@ function editJson(text, changes, path) {
2212
2212
  parseJson(next, path);
2213
2213
  return next;
2214
2214
  }
2215
- function addToml(text, desired, path) {
2216
- let existing;
2215
+ function updateToml(text, desired, path, obsolete = []) {
2216
+ const before = text ?? "";
2217
2217
  try {
2218
- existing = parse$1(text ?? "");
2218
+ parse$1(before);
2219
2219
  } catch {
2220
2220
  throw new Error(`Invalid TOML in ${path}; nothing overwritten.`);
2221
2221
  }
2222
- const missing = {};
2223
- for (const [key, value] of Object.entries(desired)) if (!Object.hasOwn(existing, key)) missing[key] = value;
2224
- else if (!isDeepStrictEqual(existing[key], value)) throw new Error(`Conflicting ${key} in ${path}. Review it manually; nothing overwritten.`);
2225
- if (!Object.keys(missing).length) return text ?? "";
2226
- const scalars = Object.fromEntries(Object.entries(missing).filter(([, value]) => !object(value)));
2227
- const tables = Object.fromEntries(Object.entries(missing).filter(([, value]) => object(value)));
2228
- const parts = [];
2229
- if (Object.keys(scalars).length) parts.push(stringify(scalars));
2230
- if ((text ?? "").trim()) parts.push(text);
2231
- if (Object.keys(tables).length) parts.push(stringify(tables));
2232
- const next = `${parts.map((part) => part.replace(/\s+$/, "")).join("\n\n")}\n`;
2233
- parse$1(next);
2234
- return next;
2222
+ const eol = before.includes("\r\n") ? "\r\n" : "\n";
2223
+ const keyText = (key) => /^[\w-]+$/.test(key) ? key : JSON.stringify(key);
2224
+ const lines = before.split(/\r?\n/);
2225
+ const valueAt = (value, keys) => keys.reduce((parent, key) => parent?.[key], value);
2226
+ const merge = (old, next) => object(next) ? Object.fromEntries(Object.entries({
2227
+ ...object(old) ? old : {},
2228
+ ...next
2229
+ }).map(([key, value]) => [key, Object.hasOwn(next, key) ? merge(old?.[key], next[key]) : value])) : next;
2230
+ const inline = (value) => value instanceof Date ? stringify({ value }).trim().replace(/^value\s*=\s*/, "") : object(value) ? `{ ${Object.entries(value).map(([key, item]) => `${JSON.stringify(key)} = ${inline(item)}`).join(", ")} }` : Array.isArray(value) ? `[${value.map(inline).join(", ")}]` : stringify({ value }).trim().replace(/^value\s*=\s*/, "");
2231
+ const statements = [];
2232
+ let section = [];
2233
+ for (let i = 0; i < lines.length; i++) {
2234
+ if (!lines[i].trim() || lines[i].trimStart().startsWith("#")) {
2235
+ statements.push({
2236
+ text: lines[i],
2237
+ section
2238
+ });
2239
+ continue;
2240
+ }
2241
+ let block = lines[i], parsed;
2242
+ for (;;) try {
2243
+ parsed = parse$1(block);
2244
+ break;
2245
+ } catch {
2246
+ if (++i >= lines.length) throw new Error(`Cannot safely update TOML in ${path}.`);
2247
+ block += eol + lines[i];
2248
+ }
2249
+ if (block.trimStart().startsWith("[")) {
2250
+ section = [];
2251
+ let cursor = parsed;
2252
+ while (object(cursor) && Object.keys(cursor).length === 1) {
2253
+ const key = Object.keys(cursor)[0];
2254
+ section.push(key);
2255
+ cursor = cursor[key];
2256
+ }
2257
+ statements.push({
2258
+ text: block,
2259
+ section,
2260
+ header: true,
2261
+ array: Array.isArray(cursor)
2262
+ });
2263
+ continue;
2264
+ }
2265
+ let equal = 0, keyQuote = null;
2266
+ for (; equal < block.length; equal++) {
2267
+ const char = block[equal];
2268
+ if (keyQuote === "\"" && char === "\\") {
2269
+ equal++;
2270
+ continue;
2271
+ }
2272
+ if (keyQuote) {
2273
+ if (char === keyQuote) keyQuote = null;
2274
+ } else if (char === "\"" || char === "'") keyQuote = char;
2275
+ else if (char === "=") break;
2276
+ }
2277
+ const rawKey = block.slice(0, equal).trim();
2278
+ let cursor = parse$1(`${rawKey} = 0`), keys = [];
2279
+ while (object(cursor)) {
2280
+ const key = Object.keys(cursor)[0];
2281
+ keys.push(key);
2282
+ cursor = cursor[key];
2283
+ }
2284
+ const full = [...section, ...keys];
2285
+ if (obsolete.includes(full.join("."))) continue;
2286
+ const next = valueAt(desired, full);
2287
+ if (next !== void 0 && !isDeepStrictEqual(valueAt(parsed, keys), merge(valueAt(parsed, keys), next))) {
2288
+ let comment = "", quote = null;
2289
+ if (!block.includes(eol)) for (let j = equal + 1; j < block.length; j++) {
2290
+ const char = block[j];
2291
+ if (quote === "\"" && char === "\\") {
2292
+ j++;
2293
+ continue;
2294
+ }
2295
+ if (quote) {
2296
+ if (char === quote) quote = null;
2297
+ } else if (char === "\"" || char === "'") quote = char;
2298
+ else if (char === "#") {
2299
+ comment = ` ${block.slice(j)}`;
2300
+ break;
2301
+ }
2302
+ }
2303
+ block = `${block.slice(0, equal + 1)} ${inline(merge(valueAt(parsed, keys), next))}${comment}`;
2304
+ }
2305
+ statements.push({
2306
+ text: block,
2307
+ section
2308
+ });
2309
+ }
2310
+ const leaves = (value, prefix = []) => Object.entries(value).flatMap(([key, item]) => object(item) ? leaves(item, [...prefix, key]) : [[[...prefix, key], item]]);
2311
+ for (const [keys, value] of leaves(desired)) {
2312
+ const current = parse$1(statements.map((item) => item.text).join(eol));
2313
+ if (isDeepStrictEqual(valueAt(current, keys), value)) continue;
2314
+ let header = -1, prefix = [];
2315
+ statements.forEach((item, index) => {
2316
+ if (item.header && !item.array && item.section.length < keys.length && item.section.every((key, i) => key === keys[i]) && item.section.length > prefix.length) {
2317
+ header = index;
2318
+ prefix = item.section;
2319
+ }
2320
+ });
2321
+ let end = statements.findIndex((item, index) => index > header && item.header);
2322
+ if (end === -1) end = statements.length;
2323
+ statements.splice(end, 0, {
2324
+ text: `${keys.slice(prefix.length).map(keyText).join(".")} = ${inline(value)}`,
2325
+ section: prefix
2326
+ });
2327
+ }
2328
+ const after = statements.map((item) => item.text).join(eol);
2329
+ const result = parse$1(after);
2330
+ for (const [keys, value] of leaves(desired)) if (!isDeepStrictEqual(valueAt(result, keys), value)) throw new Error(`Cannot safely update ${keys.join(".")} in ${path}.`);
2331
+ return after === before ? before : `${after.replace(/\s+$/, "")}${eol}`;
2235
2332
  }
2236
2333
  function sha256(text) {
2237
2334
  return createHash("sha256").update(text, "utf8").digest("hex");
@@ -2311,8 +2408,7 @@ const RUNNABLE = [
2311
2408
  "pi"
2312
2409
  ];
2313
2410
  const CONTEXT_WINDOW = 262144;
2314
- const MAX_INPUT = 24e4;
2315
- const MAX_OUTPUT = 16384;
2411
+ const MAX_OUTPUT = 32768;
2316
2412
  const EFFORTS = [
2317
2413
  "none",
2318
2414
  "low",
@@ -2330,7 +2426,6 @@ const OPENCODE_MODEL = {
2330
2426
  },
2331
2427
  limit: {
2332
2428
  context: CONTEXT_WINDOW,
2333
- input: MAX_INPUT,
2334
2429
  output: MAX_OUTPUT
2335
2430
  },
2336
2431
  options: { reasoningEffort: "medium" },
@@ -2349,7 +2444,6 @@ const OPENCODE_V2_PROVIDER = {
2349
2444
  },
2350
2445
  limit: {
2351
2446
  context: CONTEXT_WINDOW,
2352
- input: MAX_INPUT,
2353
2447
  output: MAX_OUTPUT
2354
2448
  },
2355
2449
  settings: { reasoningEffort: "medium" },
@@ -2372,7 +2466,8 @@ const PI_MODEL = {
2372
2466
  xhigh: "xhigh",
2373
2467
  max: null
2374
2468
  },
2375
- contextWindow: CONTEXT_WINDOW
2469
+ contextWindow: CONTEXT_WINDOW,
2470
+ maxTokens: MAX_OUTPUT
2376
2471
  };
2377
2472
  async function exists(path) {
2378
2473
  try {
@@ -2497,7 +2592,34 @@ async function planAssistant(assistant) {
2497
2592
  const storedKey = await openCodeHasKey(assistant.authFile);
2498
2593
  await json(hasJsonc ? "opencode.jsonc" : "opencode.json", (existing) => {
2499
2594
  v2 = Object.hasOwn(existing, "providers");
2500
- if (v2) return [[["providers", "callstack.ai"], OPENCODE_V2_PROVIDER]];
2595
+ if (v2) return [
2596
+ [[
2597
+ "providers",
2598
+ "callstack.ai",
2599
+ "name"
2600
+ ], OPENCODE_V2_PROVIDER.name],
2601
+ [[
2602
+ "providers",
2603
+ "callstack.ai",
2604
+ "package"
2605
+ ], OPENCODE_V2_PROVIDER.package],
2606
+ [[
2607
+ "providers",
2608
+ "callstack.ai",
2609
+ "settings",
2610
+ "baseURL"
2611
+ ], BASE_URL],
2612
+ ...Object.entries(OPENCODE_V2_PROVIDER.models[MODEL]).map(([key, value]) => [[
2613
+ "providers",
2614
+ "callstack.ai",
2615
+ "models",
2616
+ MODEL,
2617
+ key
2618
+ ], key === "settings" ? {
2619
+ ...existing.providers?.["callstack.ai"]?.models?.[MODEL]?.settings,
2620
+ ...value
2621
+ } : value])
2622
+ ];
2501
2623
  return [
2502
2624
  [[
2503
2625
  "provider",
@@ -2515,18 +2637,22 @@ async function planAssistant(assistant) {
2515
2637
  "options",
2516
2638
  "baseURL"
2517
2639
  ], BASE_URL],
2518
- ...storedKey ? [] : [[[
2640
+ ...storedKey || existing.provider?.["callstack.ai"]?.options?.apiKey ? [] : [[[
2519
2641
  "provider",
2520
2642
  "callstack.ai",
2521
2643
  "options",
2522
2644
  "apiKey"
2523
2645
  ], "{env:CALLSTACK_AUTH_TOKEN}"]],
2524
- [[
2646
+ ...Object.entries(OPENCODE_MODEL).map(([key, value]) => [[
2525
2647
  "provider",
2526
2648
  "callstack.ai",
2527
2649
  "models",
2528
- MODEL
2529
- ], OPENCODE_MODEL]
2650
+ MODEL,
2651
+ key
2652
+ ], key === "options" ? {
2653
+ ...existing.provider?.["callstack.ai"]?.models?.[MODEL]?.options,
2654
+ ...value
2655
+ } : value])
2530
2656
  ];
2531
2657
  });
2532
2658
  if (v2) changes[0].steps = ["OpenCode 2 stores provider keys itself: run /connect in OpenCode, pick callstack.ai → \"Manually enter API Key\", and paste your Callstack key."];
@@ -2557,9 +2683,14 @@ async function planAssistant(assistant) {
2557
2683
  "providers",
2558
2684
  "callstack",
2559
2685
  "models"
2560
- ], models.some((model) => model.id === "callstack/Apex") ? models.map((model) => model.id === "callstack/Apex" ? PI_MODEL : model) : [...models, PI_MODEL]]
2686
+ ], models.some((model) => model.id === "callstack/Apex") ? models.map((model) => model.id === "callstack/Apex" ? {
2687
+ ...model,
2688
+ ...PI_MODEL,
2689
+ ...model.compat ? { compat: Object.fromEntries(Object.entries(model.compat).filter(([key]) => key !== "reasoningEffortMap")) } : {}
2690
+ } : model) : [...models, PI_MODEL]]
2561
2691
  ];
2562
2692
  });
2693
+ await json("settings.json", [[["modelThinkingLevels", `callstack/${MODEL}`], "medium"]]);
2563
2694
  break;
2564
2695
  case "claude":
2565
2696
  await json("settings.json", [[["env", "CLAUDE_CODE_ATTRIBUTION_HEADER"], "0"]]);
@@ -2567,7 +2698,7 @@ async function planAssistant(assistant) {
2567
2698
  case "codex": {
2568
2699
  const path = join(assistant.directory, "callstack_ai.config.toml");
2569
2700
  const before = await readConfig(path);
2570
- const after = addToml(before, {
2701
+ const after = updateToml(before, {
2571
2702
  model_provider: "callstack_ai",
2572
2703
  model: MODEL,
2573
2704
  model_context_window: CONTEXT_WINDOW,
@@ -2579,7 +2710,7 @@ async function planAssistant(assistant) {
2579
2710
  wire_api: "responses",
2580
2711
  requires_openai_auth: false
2581
2712
  } }
2582
- }, path);
2713
+ }, path, ["model_max_output_tokens", "model_auto_compact_token_limit"]);
2583
2714
  changes.push({
2584
2715
  path,
2585
2716
  before,
@@ -2609,7 +2740,9 @@ function launchOptions(id, env = process.env) {
2609
2740
  "--provider",
2610
2741
  "callstack",
2611
2742
  "--model",
2612
- MODEL
2743
+ MODEL,
2744
+ "--thinking",
2745
+ "medium"
2613
2746
  ],
2614
2747
  env: childEnv
2615
2748
  };
@@ -2629,7 +2762,9 @@ function launchOptions(id, env = process.env) {
2629
2762
  ANTHROPIC_DEFAULT_OPUS_MODEL: MODEL,
2630
2763
  ANTHROPIC_DEFAULT_SONNET_MODEL: MODEL,
2631
2764
  ANTHROPIC_DEFAULT_HAIKU_MODEL: MODEL,
2632
- CLAUDE_CODE_SUBAGENT_MODEL: MODEL
2765
+ CLAUDE_CODE_SUBAGENT_MODEL: MODEL,
2766
+ CLAUDE_CODE_MAX_CONTEXT_TOKENS: String(CONTEXT_WINDOW),
2767
+ CLAUDE_CODE_MAX_OUTPUT_TOKENS: String(MAX_OUTPUT)
2633
2768
  });
2634
2769
  return {
2635
2770
  args: ["--model", MODEL],
@@ -2663,7 +2798,11 @@ const AI_SDK_SNIPPET = [
2663
2798
  ` baseURL: '${BASE_URL}',`,
2664
2799
  " apiKey: process.env.CALLSTACK_AUTH_TOKEN,",
2665
2800
  "});",
2666
- `// apex('${MODEL}') with generateText / streamText.`,
2801
+ "const requestOptions = {",
2802
+ ` model: apex('${MODEL}'),`,
2803
+ ` maxOutputTokens: ${MAX_OUTPUT},`,
2804
+ "};",
2805
+ "// Pass requestOptions with your prompt to generateText / streamText.",
2667
2806
  `// Reasoning effort (${EFFORTS.join(", ")}):`,
2668
2807
  "// providerOptions: { callstack: { reasoningEffort: 'medium' } }"
2669
2808
  ];
@@ -2674,11 +2813,7 @@ const MANUAL = {
2674
2813
  "Keep the generated apiKey secret reference; add this object to its models array:",
2675
2814
  ...COPILOT_MODEL.split("\n")
2676
2815
  ],
2677
- "ai-sdk": [
2678
- "Apex CLI never writes your key into a project. Keep it in CALLSTACK_AUTH_TOKEN (for example in a .env that git ignores) and create the provider with:",
2679
- ...AI_SDK_SNIPPET,
2680
- `Eve: set modelContextWindowTokens: ${CONTEXT_WINDOW} on defineAgent, or compaction will fail to compile.`
2681
- ]
2816
+ "ai-sdk": ["Apex CLI never writes your key into a project. Keep it in CALLSTACK_AUTH_TOKEN (for example in a .env that git ignores) and create the provider with:", ...AI_SDK_SNIPPET]
2682
2817
  };
2683
2818
  const MANUAL_IDS = Object.keys(MANUAL);
2684
2819
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@callstack/apex",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Detects the AI coding assistants installed on your machine and configures them to use Callstack Apex.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -24,6 +24,7 @@
24
24
  "build": "tsdown",
25
25
  "pretest": "npm run build",
26
26
  "prepack": "npm run build",
27
+ "prepublishOnly": "npm test",
27
28
  "test": "node --test",
28
29
  "check": "for f in src/*.js bin/apex.js; do node --check \"$f\" || exit 1; done && npm run build"
29
30
  },