@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 +23 -2
- package/package.json +1 -1
- package/src/config.mjs +10 -0
- package/src/http.mjs +54 -5
- package/src/opencode.mjs +31 -12
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
|
|
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
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
|
|
29
|
-
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
106
|
-
if (
|
|
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...");
|