@4yi/cli 0.1.5 → 0.1.7

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/bin/4yi.mjs CHANGED
@@ -5,10 +5,30 @@ import { runCode } from "../src/opencode.mjs";
5
5
  const command = process.argv[2] || "help";
6
6
 
7
7
  if (command === "help" || command === "--help" || command === "-h") {
8
- console.log("Usage: 4yi <login|whoami|logout|code>");
8
+ console.log("Usage: 4yi <login|whoami|logout|code [--model <id>]>");
9
+ console.log(" 4yi code launch OpenCode; switch models live with Tab / /models");
10
+ console.log(" 4yi code --model X pin model X as the default for future sessions");
9
11
  process.exit(0);
10
12
  }
11
13
 
14
+ /** Split out `--model <id>` / `--model=<id>` / `-m <id>`; the rest pass through to OpenCode. */
15
+ function parseCodeArgs(args) {
16
+ let preferredModel = null;
17
+ const passthrough = [];
18
+ for (let i = 0; i < args.length; i += 1) {
19
+ const arg = args[i];
20
+ if (arg === "--model" || arg === "-m") {
21
+ preferredModel = args[i + 1] ?? null;
22
+ i += 1;
23
+ } else if (arg.startsWith("--model=")) {
24
+ preferredModel = arg.slice("--model=".length);
25
+ } else {
26
+ passthrough.push(arg);
27
+ }
28
+ }
29
+ return { preferredModel, passthrough };
30
+ }
31
+
12
32
  if (command === "--version" || command === "-v") {
13
33
  const pkg = await import("../package.json", { with: { type: "json" } });
14
34
  console.log(pkg.default.version);
@@ -27,7 +47,8 @@ try {
27
47
  console.log("Signed out.");
28
48
  } else if (command === "code") {
29
49
  const session = loadSession();
30
- const code = await runCode({ session, argv: process.argv.slice(3) });
50
+ const { preferredModel, passthrough } = parseCodeArgs(process.argv.slice(3));
51
+ const code = await runCode({ session, argv: passthrough, preferredModel });
31
52
  process.exit(Number(code || 0));
32
53
  } else {
33
54
  console.error(`Unknown command: ${command}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@4yi/cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "4YI command-line launcher for OAuth login and OpenCode runtime",
5
5
  "type": "module",
6
6
  "bin": {
package/src/config.mjs CHANGED
@@ -48,3 +48,13 @@ export function writeConfig(config, home = os.homedir()) {
48
48
  ensureDir(paths.homeDir);
49
49
  fs.writeFileSync(paths.configFile, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
50
50
  }
51
+
52
+ /** The user's pinned default model id for `4yi code`, or null if unset. */
53
+ export function getPreferredModel(home = os.homedir()) {
54
+ return readConfig(home).preferred_model || null;
55
+ }
56
+
57
+ /** Persist the pinned default model id, preserving the rest of the config (e.g. session). */
58
+ export function setPreferredModel(model, home = os.homedir()) {
59
+ writeConfig({ ...readConfig(home), preferred_model: model }, home);
60
+ }
package/src/http.mjs CHANGED
@@ -1,12 +1,17 @@
1
1
  export async function requestJson(baseUrl, path, options = {}) {
2
2
  const url = `${baseUrl}${path}`;
3
+ const optionHeaders = normalizeHeaders(options.headers);
4
+ const headers = {
5
+ "content-type": "application/json",
6
+ ...(options.token ? { authorization: `Bearer ${options.token}` } : {}),
7
+ ...optionHeaders,
8
+ };
9
+ if (shouldGenerateIdempotencyKey(path, options, headers)) {
10
+ headers["Idempotency-Key"] = `cli-${randomId()}`;
11
+ }
3
12
  const res = await fetch(url, {
4
13
  ...options,
5
- headers: {
6
- "content-type": "application/json",
7
- ...(options.token ? { authorization: `Bearer ${options.token}` } : {}),
8
- ...(options.headers || {}),
9
- },
14
+ headers,
10
15
  });
11
16
  const text = await res.text();
12
17
  let body = null;
@@ -27,3 +32,47 @@ export async function requestJson(baseUrl, path, options = {}) {
27
32
  }
28
33
  return body;
29
34
  }
35
+
36
+ function normalizeHeaders(headers) {
37
+ if (!headers) return {};
38
+ if (headers instanceof Headers) return Object.fromEntries(headers.entries());
39
+ if (Array.isArray(headers)) return Object.fromEntries(headers);
40
+ return { ...headers };
41
+ }
42
+
43
+ function hasHeader(headers, name) {
44
+ const expected = name.toLowerCase();
45
+ return Object.keys(headers).some((key) => key.toLowerCase() === expected);
46
+ }
47
+
48
+ function shouldGenerateIdempotencyKey(path, options, headers) {
49
+ const method = String(options.method || "GET").toUpperCase();
50
+ if (method !== "POST") return false;
51
+ if (!path.endsWith("/chat/completions")) return false;
52
+ if (hasHeader(headers, "Idempotency-Key") || hasHeader(headers, "X-Idempotency-Key")) return false;
53
+
54
+ const body = parseJsonBody(options.body);
55
+ return body && body.stream !== true;
56
+ }
57
+
58
+ function parseJsonBody(body) {
59
+ if (!body) return null;
60
+ if (typeof body === "string") {
61
+ try {
62
+ return JSON.parse(body);
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+ if (body && typeof body === "object" && !(body instanceof ArrayBuffer)) return body;
68
+ return null;
69
+ }
70
+
71
+ function randomId() {
72
+ if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
73
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (ch) => {
74
+ const value = Math.floor(Math.random() * 16);
75
+ const nibble = ch === "x" ? value : (value & 0x3) | 0x8;
76
+ return nibble.toString(16);
77
+ });
78
+ }
package/src/opencode.mjs CHANGED
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { spawn, spawnSync } from "node:child_process";
5
- import { pathsForHome, ensureDir } from "./config.mjs";
5
+ import { pathsForHome, ensureDir, getPreferredModel, setPreferredModel } from "./config.mjs";
6
6
  import { requestJson } from "./http.mjs";
7
7
 
8
8
  const DEFAULT_OPENCODE_PACKAGE = "opencode-ai";
@@ -23,21 +23,31 @@ function modelOutputLimit(model) {
23
23
  return model.output_limit || model.max_output_tokens || model.max_tokens || DEFAULT_OUTPUT_LIMIT;
24
24
  }
25
25
 
26
- export function buildOpenCodeConfig({ modelConfig, tokenEnv = "FOURYI_CLI_TOKEN", orgEnv = "FOURYI_ORG_ID" }) {
26
+ export function buildOpenCodeConfig({ modelConfig, preferredModel = null, tokenEnv = "FOURYI_CLI_TOKEN", orgEnv = "FOURYI_ORG_ID" }) {
27
+ // Register EVERY model the server returned so OpenCode's native switcher
28
+ // (`Tab` / `/models`) can move between them. Claude stays the default.
29
+ const list = modelConfig.models || [];
27
30
  const models = {};
28
- const claudeModels = (modelConfig.models || []).filter(isClaudeModel);
29
- for (const model of claudeModels) {
30
- models[model.id] = {
31
+ for (const model of list) {
32
+ const entry = {
31
33
  name: model.display_name || model.id,
32
34
  limit: {
33
35
  context: modelContextLimit(model),
34
36
  output: modelOutputLimit(model),
35
37
  },
36
38
  };
39
+ if (typeof model.attachment === "boolean") entry.attachment = model.attachment;
40
+ if (model.modalities && typeof model.modalities === "object") entry.modalities = model.modalities;
41
+ models[model.id] = entry;
37
42
  }
38
- const defaultModel = claudeModels.some((model) => model.id === modelConfig.default_model)
39
- ? modelConfig.default_model
40
- : claudeModels[0]?.id;
43
+ const has = (id) => !!id && list.some((model) => model.id === id);
44
+ const firstClaude = list.find(isClaudeModel)?.id;
45
+ // Precedence: an explicit/persisted preferred model → server default → first Claude → first model.
46
+ const defaultModel =
47
+ (has(preferredModel) && preferredModel) ||
48
+ (has(modelConfig.default_model) && modelConfig.default_model) ||
49
+ firstClaude ||
50
+ list[0]?.id;
41
51
 
42
52
  return {
43
53
  "$schema": "https://opencode.ai/config.json",
@@ -99,18 +109,27 @@ export function ensureOpenCodeRuntime({ home = os.homedir(), stdout = console.lo
99
109
  return bin;
100
110
  }
101
111
 
102
- export async function runCode({ session, home = os.homedir(), argv = [], stdout = console.log } = {}) {
112
+ export async function runCode({ session, home = os.homedir(), argv = [], stdout = console.log, preferredModel = null } = {}) {
103
113
  if (!session?.token) throw new Error("Not signed in. Run `4yi login`.");
104
114
  const modelConfig = await requestJson(session.baseUrl, "/api/cli/models", { token: session.token });
105
- if (!modelConfig.default_model) throw new Error("No chat models available for this organization.");
106
- if (!(modelConfig.models || []).some(isClaudeModel)) throw new Error("No Claude models available for this organization.");
115
+ const list = modelConfig.models || [];
116
+ if (list.length === 0) throw new Error("No chat models available for this organization.");
117
+
118
+ // Resolve the model to launch with: an explicit `--model` (validated + persisted)
119
+ // takes precedence over a previously persisted preference; both must be entitled.
120
+ const valid = (id) => !!id && list.some((model) => model.id === id);
121
+ const explicit = valid(preferredModel) ? preferredModel : null;
122
+ const persisted = !explicit && valid(getPreferredModel(home)) ? getPreferredModel(home) : null;
123
+ const effectivePreferred = explicit || persisted || null;
124
+ if (explicit) setPreferredModel(explicit, home);
125
+ if (preferredModel && !explicit) stdout(`Model "${preferredModel}" is not available; using the default instead.`);
107
126
 
108
127
  const paths = pathsForHome(home);
109
128
  ensureDir(paths.opencodeConfigDir);
110
129
  for (const dir of ["config", "data", "cache", "state"]) {
111
130
  ensureDir(path.join(paths.opencodeConfigDir, "xdg", dir));
112
131
  }
113
- fs.writeFileSync(paths.opencodeConfigFile, `${JSON.stringify(buildOpenCodeConfig({ modelConfig }), null, 2)}\n`, { mode: 0o600 });
132
+ fs.writeFileSync(paths.opencodeConfigFile, `${JSON.stringify(buildOpenCodeConfig({ modelConfig, preferredModel: effectivePreferred }), null, 2)}\n`, { mode: 0o600 });
114
133
 
115
134
  const bin = ensureOpenCodeRuntime({ home, stdout });
116
135
  stdout("Launching OpenCode with 4YI models...");