@b3labs/b3iq-cli 0.0.0-stage → 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.
- package/AGENTS.md +319 -0
- package/ERROR_PATHS.md +63 -0
- package/LICENSE +202 -0
- package/README.md +207 -2
- package/dist/commands/claude-desktop.js +323 -0
- package/dist/commands/codex-desktop.js +118 -0
- package/dist/commands/commands.js +39 -0
- package/dist/commands/logs.js +41 -0
- package/dist/commands/machines.js +104 -0
- package/dist/commands/media-mcp.js +19 -0
- package/dist/commands/misc.js +257 -0
- package/dist/commands/models.js +141 -0
- package/dist/commands/native-agents.js +291 -0
- package/dist/commands/opencode.js +161 -0
- package/dist/commands/plugins.js +67 -0
- package/dist/commands/runtimes.js +76 -0
- package/dist/commands/ssh.js +156 -0
- package/dist/index.js +194 -0
- package/dist/lib/api.js +149 -0
- package/dist/lib/args.js +35 -0
- package/dist/lib/commands-wait.js +41 -0
- package/dist/lib/config.js +85 -0
- package/dist/lib/desktop-server-lifecycle.js +19 -0
- package/dist/lib/diagnosis.js +678 -0
- package/dist/lib/inference.js +272 -0
- package/dist/lib/machines.js +120 -0
- package/dist/lib/management-probe.js +36 -0
- package/dist/lib/media-download.js +50 -0
- package/dist/lib/output.js +151 -0
- package/dist/lib/ssh-session.js +280 -0
- package/dist/lib/ssh-target.js +28 -0
- package/dist/lib/stdin.js +16 -0
- package/dist/lib/version.js +1 -0
- package/package.json +58 -4
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdir, writeFile, readdir, lstat, readFile, symlink, readlink, unlink, realpath } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { configDir } from "../lib/config.js";
|
|
8
|
+
import { explainModelNotServed, requireContext } from "../lib/diagnosis.js";
|
|
9
|
+
import { probeManagement } from "../lib/management-probe.js";
|
|
10
|
+
import { CliError, isJSON, isQuiet, log, success } from "../lib/output.js";
|
|
11
|
+
import { DESKTOP_SERVER_LIFECYCLE_INSTRUCTIONS } from "../lib/desktop-server-lifecycle.js";
|
|
12
|
+
import { InferenceGateway, gatewayKey, savedGatewayKey, nativeInputModalities } from "../lib/inference.js";
|
|
13
|
+
import { openCodeConfig } from "./opencode.js";
|
|
14
|
+
import { claudeGatewayBase, claudeDesktopConnection, claudeDesktopReference } from "./claude-desktop.js";
|
|
15
|
+
const claudeCredentialOverrides = ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY"];
|
|
16
|
+
export function nativeSetupResult(client, prepared) {
|
|
17
|
+
const activation = client === "codex" ? prepared.codexDesktop ? ["open", prepared.codexDesktop.installed_launcher ?? prepared.codexDesktop.launcher] : prepared.profile ? ["codex", "--profile", prepared.profile] : undefined
|
|
18
|
+
: client === "claude" && !prepared.desktop ? ["env", ...claudeCredentialOverrides.flatMap(key => ["-u", key]), "claude", ...prepared.args]
|
|
19
|
+
: undefined;
|
|
20
|
+
const scope = client === "codex" ? prepared.desktop ? "isolated_desktop_instance" : "opt_in_profile"
|
|
21
|
+
: client === "claude" && prepared.desktop ? prepared.claudeDesktop?.activated ? "activated_desktop_configuration" : prepared.claudeDesktop?.saved ? "saved_inactive_configuration" : "desktop_connection_export"
|
|
22
|
+
: client === "opencode" && prepared.desktop ? "provider_registration" : "selected_session";
|
|
23
|
+
return { client, launch_surface: prepared.desktop ? "desktop" : "terminal",
|
|
24
|
+
desktop_configuration_changed: client === "codex" ? Boolean(prepared.codexDesktop) : client === "claude" ? Boolean(prepared.claudeDesktop?.saved?.changed || prepared.claudeDesktop?.activated) : client === "opencode" && Boolean(prepared.installedConfig),
|
|
25
|
+
defaults_changed: Boolean(prepared.claudeDesktop?.activated),
|
|
26
|
+
configuration_scope: scope,
|
|
27
|
+
directory: prepared.dir, config: prepared.config,
|
|
28
|
+
...(prepared.setupName ? { setup_name: prepared.setupName } : {}),
|
|
29
|
+
...(activation ? { activation_argv: activation,
|
|
30
|
+
activation_command: activation.map(arg => /^[a-zA-Z0-9_./:@=-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'"'"'`)}'`).join(" ") } : {}),
|
|
31
|
+
...(prepared.profile ? { profile: prepared.profile } : {}),
|
|
32
|
+
...(prepared.selection ? { model_selection: prepared.selection } : {}),
|
|
33
|
+
instructions: client === "codex" && prepared.desktop ? "Open the launcher for a separate B3IQ desktop instance. Normal desktop and CLI defaults remain unchanged while it runs. Its settings and history are separate; select your local project in this instance. This integration is experimental. No OpenAI account credentials are copied."
|
|
34
|
+
: client === "codex" ? "Use activation_argv to select B3IQ. Plain codex and the desktop retain their normal defaults. Do not merge this profile into config.toml."
|
|
35
|
+
: client === "claude" && prepared.desktop ? "Use desktop_setup for exact configuration and activation status. Setup preserves defaults unless --activate was explicitly supplied. Restore the original selection with b3iq inference restore claude --desktop, with Claude closed."
|
|
36
|
+
: client === "claude" ? "Use activation_argv for a B3IQ terminal session (POSIX). Plain claude and desktop retain their normal defaults. Do not merge this file into settings.json or change shell exports."
|
|
37
|
+
: "Select B3IQ and its exact model ID in the native picker. Registration preserves existing defaults; do not merge selected-session model or agent overrides into global settings.",
|
|
38
|
+
...(prepared.codexDesktop ? { desktop_setup: prepared.codexDesktop } : {}),
|
|
39
|
+
...(prepared.claudeDesktop ? { desktop_setup: claudeDesktopReference(prepared.claudeDesktop) } : {}),
|
|
40
|
+
...(prepared.modelCatalog ? { model_catalog: prepared.modelCatalog } : {}),
|
|
41
|
+
...(prepared.mediaConfig ? { media_mcp_config: prepared.mediaConfig } : {}),
|
|
42
|
+
...(prepared.skillsPlugin ? { shared_skills_plugin: prepared.skillsPlugin } : {}),
|
|
43
|
+
...(prepared.openCodeVersion ? { client_major_version: prepared.openCodeVersion } : {}),
|
|
44
|
+
...(prepared.installedConfig ? { installed_config: prepared.installedConfig } : {}),
|
|
45
|
+
...(prepared.backup ? { backup: prepared.backup } : {}),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export function printNativeSetupResult(client, prepared, activated = false) {
|
|
49
|
+
const receipt = nativeSetupResult(client, prepared);
|
|
50
|
+
if (client === "codex" && prepared.codexDesktop && !isJSON() && !isQuiet()) {
|
|
51
|
+
success(activated ? "B3IQ Codex desktop launch requested." : "B3IQ Codex desktop setup saved. The app has not been opened.");
|
|
52
|
+
log("Normal Codex desktop and CLI defaults are unchanged.");
|
|
53
|
+
process.stderr.write(`\n${activated ? "Open it again later" : "Open it now"}:\n ${receipt.activation_command}\n\n`);
|
|
54
|
+
if (!activated)
|
|
55
|
+
log("To save and open in one step, rerun your setup command with --activate.");
|
|
56
|
+
log("Select your local project in the separate B3IQ window. This integration is experimental.");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
process.stdout.write(JSON.stringify(receipt) + "\n");
|
|
60
|
+
}
|
|
61
|
+
export async function installCodexProfile(prepared, directory = process.env.CODEX_HOME ?? join(homedir(), ".codex")) {
|
|
62
|
+
const content = await readFile(prepared.config, "utf8");
|
|
63
|
+
const profile = `b3iq-${createHash("sha256").update(content).digest("hex").slice(0, 16)}`;
|
|
64
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
65
|
+
const path = join(directory, `${profile}.config.toml`);
|
|
66
|
+
try {
|
|
67
|
+
await writeFile(path, content, { flag: "wx", mode: 0o600 });
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error.code !== "EEXIST")
|
|
71
|
+
throw error;
|
|
72
|
+
const info = await lstat(path);
|
|
73
|
+
if (!info.isFile() || info.isSymbolicLink() || await readFile(path, "utf8") !== content) {
|
|
74
|
+
throw new CliError("The B3IQ Codex profile already contains different settings. Inspect that profile before retrying; the normal Codex configuration was not changed.", "usage");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
prepared.profile = profile;
|
|
78
|
+
prepared.installedConfig = path;
|
|
79
|
+
prepared.args = ["--profile", profile];
|
|
80
|
+
}
|
|
81
|
+
export function codexCatalog(model, context, desktop = false) {
|
|
82
|
+
return { models: [{
|
|
83
|
+
slug: model.id, display_name: desktop ? `B3IQ · ${model.id}` : model.id, description: "Owned B3IQ inference",
|
|
84
|
+
base_instructions: `Runtime model identity: the selected served model ID is ${JSON.stringify(model.id)}. Your client application is Codex and your inference provider is B3IQ. If asked which model you are using, report that exact configured model ID and provider, separately from the client application. Do not infer model identity from learned persona claims, tool names or old conversation text. This metadata does not establish training provenance or capabilities beyond the serving profile.\n\n` +
|
|
85
|
+
"You are a coding agent working with the user in their local project. Complete authorized coding tasks using the available client tools. Read and follow applicable AGENTS.md and project instructions before editing. When a task uses a skill, read its SKILL.md and any required references before acting. Keep changes within the authorized project or disposable worktree, preserve unrelated work, and verify changed behavior with appropriate checks. Keep the human's Git authorship. Report actual outcomes and remaining limitations; do not claim a tool ran or a test passed without its result. Ask only for missing information or actions that need additional authorization." +
|
|
86
|
+
(desktop ? `\n\n${DESKTOP_SERVER_LIFECYCLE_INSTRUCTIONS}` : ""),
|
|
87
|
+
supported_reasoning_levels: [], shell_type: "unified_exec", visibility: "list", supported_in_api: true,
|
|
88
|
+
priority: 1, availability_nux: null, upgrade: null, support_verbosity: false, default_verbosity: null,
|
|
89
|
+
apply_patch_tool_type: "freeform", truncation_policy: { mode: "tokens", limit: Math.min(10000, Math.floor(context / 4)) },
|
|
90
|
+
context_window: context, max_context_window: context, effective_context_window_percent: 90,
|
|
91
|
+
input_modalities: nativeInputModalities(model), experimental_supported_tools: [], tool_mode: "direct",
|
|
92
|
+
supports_parallel_tool_calls: true, supports_search_tool: true, prefer_websockets: false, use_responses_lite: false,
|
|
93
|
+
include_skills_usage_instructions: true, include_plugin_usage_instructions: true, include_apps_usage_instructions: false,
|
|
94
|
+
supports_reasoning_summary_parameter: false, default_reasoning_summary: "none", node_repl_disabled: true,
|
|
95
|
+
}] };
|
|
96
|
+
}
|
|
97
|
+
export async function requireNativeContext(model, client, machine, desktop) {
|
|
98
|
+
try {
|
|
99
|
+
return requireContext(model, { client, machine, desktop });
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (!(error instanceof CliError))
|
|
103
|
+
throw error;
|
|
104
|
+
const diagnostic = error.diagnosis?.diagnostic;
|
|
105
|
+
if (diagnostic !== "context_window_insufficient" && diagnostic !== "context_metadata_missing")
|
|
106
|
+
throw error;
|
|
107
|
+
return requireContext(model, { client, machine, desktop, management: await probeManagement(machine) });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function mcpConfig(options, entrypoint) {
|
|
111
|
+
return { command: process.execPath, args: [entrypoint, "mcp", "media", "--gateway-url", new InferenceGateway(options).base.toString(),
|
|
112
|
+
...(options.machine ? ["--machine", options.machine] : []), ...(options.outputDir ? ["--output-dir", options.outputDir] : [])] };
|
|
113
|
+
}
|
|
114
|
+
function claudeEnvironment(model, gateway, context, machine) {
|
|
115
|
+
const base = claudeGatewayBase(gateway.base);
|
|
116
|
+
const aliases = Object.fromEntries(["OPUS", "FABLE", "SONNET", "HAIKU"].flatMap(family => [
|
|
117
|
+
[`ANTHROPIC_DEFAULT_${family}_MODEL_NAME`, `B3IQ · ${model}`],
|
|
118
|
+
[`ANTHROPIC_DEFAULT_${family}_MODEL_DESCRIPTION`, "Uses the same selected owned B3IQ model."],
|
|
119
|
+
]));
|
|
120
|
+
return {
|
|
121
|
+
...aliases,
|
|
122
|
+
ANTHROPIC_BASE_URL: base, ANTHROPIC_MODEL: model,
|
|
123
|
+
ANTHROPIC_DEFAULT_OPUS_MODEL: model, ANTHROPIC_DEFAULT_SONNET_MODEL: model, ANTHROPIC_DEFAULT_HAIKU_MODEL: model,
|
|
124
|
+
ANTHROPIC_DEFAULT_FABLE_MODEL: model, ANTHROPIC_SMALL_FAST_MODEL: model,
|
|
125
|
+
CLAUDE_CODE_SUBAGENT_MODEL: model, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
|
|
126
|
+
CLAUDE_CODE_MAX_OUTPUT_TOKENS: "8192", CLAUDE_CODE_AUTO_COMPACT_WINDOW: String(context),
|
|
127
|
+
...(context < 1000000 ? { CLAUDE_CODE_DISABLE_1M_CONTEXT: "1" } : {}),
|
|
128
|
+
...(machine ? { ANTHROPIC_CUSTOM_HEADERS: `x-b3iq-node-id: ${machine}` } : {}),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
export async function nativeEntrypoint(own, invoked = process.argv[1]) {
|
|
132
|
+
if (invoked) {
|
|
133
|
+
const path = resolve(invoked);
|
|
134
|
+
const [actual, expected] = await Promise.all([realpath(path).catch(() => ""), realpath(own).catch(() => "")]);
|
|
135
|
+
if (actual && actual === expected)
|
|
136
|
+
return path;
|
|
137
|
+
}
|
|
138
|
+
return own;
|
|
139
|
+
}
|
|
140
|
+
export async function prepareNativeAgent(client, modelID, options, root, openCode = {}) {
|
|
141
|
+
if (openCode.setupName !== undefined && (client !== "codex" || !openCode.desktop || !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(openCode.setupName)))
|
|
142
|
+
throw new CliError("--setup-name requires Codex desktop setup and 1–64 lowercase letters, digits, underscores or hyphens, starting with a letter or digit.", "usage");
|
|
143
|
+
if ((client === "claude" || client === "codex") && openCode.desktop && !options.key)
|
|
144
|
+
options = { ...options, key: await savedGatewayKey() };
|
|
145
|
+
const gateway = new InferenceGateway(options), models = await gateway.models();
|
|
146
|
+
const model = models.find(row => row.id === modelID);
|
|
147
|
+
if (!model)
|
|
148
|
+
throw explainModelNotServed(modelID, models.length, options.machine);
|
|
149
|
+
const context = await requireNativeContext(model, client, options.machine, Boolean(openCode.desktop));
|
|
150
|
+
const fingerprint = createHash("sha256").update(JSON.stringify([client, model.id, gateway.base.href, options.machine,
|
|
151
|
+
...(client === "opencode" ? [openCode.version ?? 2, Boolean(openCode.desktop)] : client === "codex" && openCode.desktop ? ["isolated-desktop-v1", ...(openCode.setupName ? ["setup-name", openCode.setupName] : [])] : [])])).digest("hex").slice(0, 16);
|
|
152
|
+
const dir = root ?? join(configDir(), "native-agents", fingerprint);
|
|
153
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
154
|
+
const extension = import.meta.url.endsWith(".ts") ? "ts" : "js";
|
|
155
|
+
const entrypoint = await nativeEntrypoint(fileURLToPath(new URL(`../index.${extension}`, import.meta.url)));
|
|
156
|
+
const media = mcpConfig(options, entrypoint);
|
|
157
|
+
if (client === "opencode") {
|
|
158
|
+
const value = openCodeConfig(model, context, gateway.base.toString(), media, options.machine, openCode);
|
|
159
|
+
const content = JSON.stringify(value, null, 2), config = join(dir, "opencode.json");
|
|
160
|
+
await writeFile(config, content + "\n", { mode: 0o600 });
|
|
161
|
+
return { dir, config, selection: `b3iq/${model.id}`, openCodeVersion: openCode.version ?? 2, desktop: Boolean(openCode.desktop), args: [],
|
|
162
|
+
env: { B3IQ_GATEWAY_API_KEY: options.key ?? await gatewayKey(), OPENCODE_CONFIG_CONTENT: content } };
|
|
163
|
+
}
|
|
164
|
+
if (client === "codex") {
|
|
165
|
+
const catalog = join(dir, "models.json");
|
|
166
|
+
await writeFile(catalog, JSON.stringify(codexCatalog(model, context, Boolean(openCode.desktop)), null, 2) + "\n", { mode: 0o600 });
|
|
167
|
+
const bindings = {
|
|
168
|
+
model: model.id, model_provider: "b3iq", model_catalog_json: catalog, web_search: "disabled",
|
|
169
|
+
model_context_window: context, model_auto_compact_token_limit: Math.floor(context * 0.8),
|
|
170
|
+
"model_providers.b3iq.name": "B3IQ", "model_providers.b3iq.base_url": gateway.base.toString(),
|
|
171
|
+
"model_providers.b3iq.wire_api": "responses",
|
|
172
|
+
"model_providers.b3iq.auth.command": process.execPath,
|
|
173
|
+
"model_providers.b3iq.auth.args": [entrypoint, "inference", "credential", ...(openCode.desktop ? ["--saved"] : [])],
|
|
174
|
+
"model_providers.b3iq.auth.timeout_ms": 5000, "model_providers.b3iq.auth.refresh_interval_ms": 300000,
|
|
175
|
+
"model_providers.b3iq.supports_websockets": false,
|
|
176
|
+
...(openCode.desktop ? { "model_providers.b3iq.requires_openai_auth": false, approval_policy: "on-request", sandbox_mode: "workspace-write" } : {}),
|
|
177
|
+
"model_providers.b3iq.request_max_retries": 1, "model_providers.b3iq.stream_max_retries": 1,
|
|
178
|
+
"features.unbounded_connection_retries": false,
|
|
179
|
+
...(options.machine ? { "model_providers.b3iq.http_headers": { "x-b3iq-node-id": options.machine } } : {}),
|
|
180
|
+
"mcp_servers.b3iq_media.command": media.command, "mcp_servers.b3iq_media.args": [...media.args, ...(openCode.desktop ? ["--saved-key"] : [])],
|
|
181
|
+
...(!openCode.desktop ? { "mcp_servers.b3iq_media.env_vars": ["B3IQ_GATEWAY_API_KEY"] } : {}),
|
|
182
|
+
};
|
|
183
|
+
const config = join(dir, "codex.toml");
|
|
184
|
+
await writeFile(config, Object.entries(bindings).map(([key, value]) => `${key} = ${toml(value)}`).join("\n") + "\n", { mode: 0o600 });
|
|
185
|
+
return { dir, config, modelCatalog: catalog, desktop: Boolean(openCode.desktop), ...(openCode.setupName ? { setupName: openCode.setupName } : {}), args: Object.entries(bindings).flatMap(([key, value]) => ["-c", `${key}=${toml(value)}`]), env: { B3IQ_GATEWAY_API_KEY: options.key ?? await gatewayKey() } };
|
|
186
|
+
}
|
|
187
|
+
const connection = claudeDesktopConnection(gateway.base, process.execPath, entrypoint, options.machine, model.id);
|
|
188
|
+
if (openCode.desktop) {
|
|
189
|
+
const desktopGateway = new InferenceGateway({ ...options, gateway: connection.inferenceGatewayBaseUrl + "/v1" });
|
|
190
|
+
const discovery = await desktopGateway.json("/models");
|
|
191
|
+
const rows = discovery.data;
|
|
192
|
+
if (!Array.isArray(rows) || !rows.some(row => row.id === connection.inferenceModels?.[0].name && row.b3iq?.served_model === model.id && row.b3iq.compatibility === "claude-desktop-v1")) {
|
|
193
|
+
throw new CliError("The gateway has not qualified this desktop compatibility connection. Deploy the matching gateway and verify the selected model and machine before saving or activation.", "api");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const desktopFile = join(dir, "claude-desktop-connection.json");
|
|
197
|
+
await writeFile(desktopFile, JSON.stringify(connection, null, 2) + "\n", { mode: 0o600 });
|
|
198
|
+
const claudeDesktop = { config_file: desktopFile, connection, selected_model: model.id };
|
|
199
|
+
if (openCode.desktop)
|
|
200
|
+
return { dir, config: desktopFile, desktop: true, claudeDesktop, args: [], env: {} };
|
|
201
|
+
const env = claudeEnvironment(model.id, gateway, context, options.machine);
|
|
202
|
+
const settings = join(dir, "claude-settings.json"), mcp = join(dir, "mcp.json");
|
|
203
|
+
const shellQuote = (value) => `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
204
|
+
await writeFile(settings, JSON.stringify({ model: model.id, availableModels: [model.id], enforceAvailableModels: true,
|
|
205
|
+
alwaysThinkingEnabled: false, env, apiKeyHelper: `${shellQuote(process.execPath)} ${shellQuote(entrypoint)} inference credential` }, null, 2) + "\n", { mode: 0o600 });
|
|
206
|
+
await writeFile(mcp, JSON.stringify({ mcpServers: { b3iq_media: media } }, null, 2) + "\n", { mode: 0o600 });
|
|
207
|
+
const plugin = await sharedSkillsPlugin(dir);
|
|
208
|
+
const key = options.key ?? await gatewayKey();
|
|
209
|
+
return { dir, config: settings, claudeDesktop, mediaConfig: mcp, ...(plugin ? { skillsPlugin: plugin } : {}), args: ["--settings", settings, "--mcp-config", mcp, ...(plugin ? ["--plugin-dir", plugin] : [])], env: { ...env, B3IQ_GATEWAY_API_KEY: key } };
|
|
210
|
+
}
|
|
211
|
+
async function sharedSkillsPlugin(dir) {
|
|
212
|
+
const configured = process.env.AGENTS_ROOT?.trim();
|
|
213
|
+
const source = resolve(configured ? configured.replace(/^~(?=\/|$)/, homedir()) : join(homedir(), ".agents", "skills"));
|
|
214
|
+
let entries;
|
|
215
|
+
try {
|
|
216
|
+
entries = await readdir(source, { withFileTypes: true });
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
if (error.code === "ENOENT")
|
|
220
|
+
return null;
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
const plugin = join(dir, "shared-skills"), skills = join(plugin, "skills");
|
|
224
|
+
await mkdir(join(plugin, ".claude-plugin"), { recursive: true, mode: 0o700 });
|
|
225
|
+
await mkdir(skills, { recursive: true, mode: 0o700 });
|
|
226
|
+
await writeFile(join(plugin, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "b3iq-shared-skills", version: "0.1.0", description: "Skills from your shared agents directory" }), { mode: 0o600 });
|
|
227
|
+
const selected = new Set();
|
|
228
|
+
for (const entry of entries) {
|
|
229
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink())
|
|
230
|
+
continue;
|
|
231
|
+
const target = join(source, entry.name);
|
|
232
|
+
try {
|
|
233
|
+
await readFile(join(target, "SKILL.md"), "utf8");
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
selected.add(entry.name);
|
|
239
|
+
const destination = join(skills, entry.name);
|
|
240
|
+
let existing;
|
|
241
|
+
try {
|
|
242
|
+
existing = await lstat(destination);
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
if (error.code !== "ENOENT")
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
if (existing) {
|
|
249
|
+
if (!existing.isSymbolicLink())
|
|
250
|
+
throw new CliError("A generated skill destination contains an unexpected file. Preserve it and inspect the B3IQ setup directory.", "usage");
|
|
251
|
+
if (await readlink(destination) === target)
|
|
252
|
+
continue;
|
|
253
|
+
await unlink(destination);
|
|
254
|
+
}
|
|
255
|
+
await symlink(target, destination, "dir");
|
|
256
|
+
}
|
|
257
|
+
for (const entry of await readdir(skills, { withFileTypes: true })) {
|
|
258
|
+
if (!selected.has(entry.name) && entry.isSymbolicLink())
|
|
259
|
+
await unlink(join(skills, entry.name));
|
|
260
|
+
}
|
|
261
|
+
return plugin;
|
|
262
|
+
}
|
|
263
|
+
function toml(value) {
|
|
264
|
+
if (Array.isArray(value))
|
|
265
|
+
return `[${value.map(toml).join(", ")}]`;
|
|
266
|
+
if (value !== null && typeof value === "object")
|
|
267
|
+
return `{ ${Object.entries(value).map(([key, item]) => `${JSON.stringify(key)} = ${toml(item)}`).join(", ")} }`;
|
|
268
|
+
const encoded = JSON.stringify(value);
|
|
269
|
+
if (encoded === undefined)
|
|
270
|
+
throw new CliError("Unsupported configuration value.", "usage");
|
|
271
|
+
return encoded;
|
|
272
|
+
}
|
|
273
|
+
export async function launchNativeAgent(client, prepared, args) {
|
|
274
|
+
if (client === "codex")
|
|
275
|
+
await installCodexProfile(prepared);
|
|
276
|
+
const env = { ...process.env, ...prepared.env };
|
|
277
|
+
if (client === "opencode")
|
|
278
|
+
env.PWD = process.cwd();
|
|
279
|
+
if (client === "claude") {
|
|
280
|
+
for (const key of claudeCredentialOverrides)
|
|
281
|
+
delete env[key];
|
|
282
|
+
}
|
|
283
|
+
if (client === "opencode" && prepared.openCodeVersion === 2 && args.some(arg => arg === "--server" || arg.startsWith("--server="))) {
|
|
284
|
+
throw new CliError("B3IQ OpenCode launches use a private server. Configure a remote OpenCode server separately instead of passing --server.", "usage");
|
|
285
|
+
}
|
|
286
|
+
await new Promise((resolvePromise, reject) => {
|
|
287
|
+
const child = spawn(client, [...args, ...prepared.args, ...(client === "opencode" && prepared.openCodeVersion === 2 ? ["--standalone"] : [])], { env, stdio: "inherit" });
|
|
288
|
+
child.once("error", () => reject(new CliError(`Install ${client} before launching it through B3IQ.`, "usage")));
|
|
289
|
+
child.once("exit", (code, signal) => { process.exitCode = code ?? (signal ? 130 : 1); resolvePromise(); });
|
|
290
|
+
});
|
|
291
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile, rename, unlink, lstat, link, rmdir } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { parse, modify, applyEdits } from "jsonc-parser";
|
|
7
|
+
import { CliError } from "../lib/output.js";
|
|
8
|
+
import { inferenceKeyPath, nativeInputModalities } from "../lib/inference.js";
|
|
9
|
+
const agentIDs = ["build", "plan", "general", "explore", "title", "summary", "compaction"];
|
|
10
|
+
export async function detectOpenCodeVersion() {
|
|
11
|
+
return new Promise((resolvePromise, reject) => {
|
|
12
|
+
execFile("opencode", ["--version"], { timeout: 5000, maxBuffer: 4096 }, (error, stdout) => {
|
|
13
|
+
const version = stdout?.trim().match(/^(?:opencode\s+v?)?([12])\.\d+\.\d+(?:[-+][\w.-]+)?$/)?.[1];
|
|
14
|
+
if (error || !version)
|
|
15
|
+
return reject(new CliError("Install OpenCode on PATH and check opencode --version, or specify --client-version 1|2 for a supported client.", "usage"));
|
|
16
|
+
resolvePromise(version === "1" ? 1 : 2);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
export function openCodeConfig(model, context, baseURL, media, machine, setup = {}) {
|
|
21
|
+
if (model.id.includes("#"))
|
|
22
|
+
throw new CliError("OpenCode model IDs cannot contain a variant separator (#).", "usage");
|
|
23
|
+
const version = setup.version ?? 2, selection = `b3iq/${model.id}`;
|
|
24
|
+
const apiKey = setup.desktop ? `{file:${inferenceKeyPath()}}` : "{env:B3IQ_GATEWAY_API_KEY}";
|
|
25
|
+
const configuredSkills = process.env.AGENTS_ROOT?.trim();
|
|
26
|
+
const skills = configuredSkills ? [resolve(configuredSkills.replace(/^~(?=\/|$)/, homedir()))] : [];
|
|
27
|
+
const limits = { context, output: 8192 };
|
|
28
|
+
const agents = Object.fromEntries(agentIDs.map(id => [id, { model: selection }]));
|
|
29
|
+
const mcp = { type: "local", command: [media.command, ...media.args],
|
|
30
|
+
...(setup.desktop ? { environment: { B3IQ_GATEWAY_API_KEY: apiKey } } : {}) };
|
|
31
|
+
if (version === 1)
|
|
32
|
+
return {
|
|
33
|
+
$schema: "https://opencode.ai/config.json",
|
|
34
|
+
...(!setup.desktop ? { model: selection, small_model: selection, agent: agents } : {}),
|
|
35
|
+
provider: { b3iq: { npm: "@ai-sdk/openai", name: "B3IQ", options: {
|
|
36
|
+
baseURL, apiKey, ...(machine ? { headers: { "x-b3iq-node-id": machine } } : {}),
|
|
37
|
+
}, models: { [model.id]: { name: model.id, tool_call: true, reasoning: false,
|
|
38
|
+
modalities: { input: nativeInputModalities(model), output: ["text"] }, limit: limits } } } },
|
|
39
|
+
mcp: { b3iq_media: { ...mcp, enabled: true } },
|
|
40
|
+
...(skills.length ? { skills: { paths: skills } } : {}),
|
|
41
|
+
};
|
|
42
|
+
return {
|
|
43
|
+
$schema: "https://opencode.ai/config.json",
|
|
44
|
+
...(!setup.desktop ? { model: selection, agents } : {}),
|
|
45
|
+
providers: { b3iq: { package: "@opencode/ai/providers/openai-compatible/responses", name: "B3IQ",
|
|
46
|
+
settings: { baseURL, apiKey, encoding: "none" },
|
|
47
|
+
...(machine ? { headers: { "x-b3iq-node-id": machine } } : {}),
|
|
48
|
+
models: { [model.id]: { modelID: model.id, name: model.id,
|
|
49
|
+
capabilities: { tools: true, input: nativeInputModalities(model), output: ["text"] }, limit: limits } },
|
|
50
|
+
} }, mcp: { servers: { b3iq_media: { ...mcp, codemode: false } } },
|
|
51
|
+
...(skills.length ? { skills } : {}),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export function openCodeConfigDirectory() {
|
|
55
|
+
return process.env.OPENCODE_CONFIG_DIR ?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "opencode");
|
|
56
|
+
}
|
|
57
|
+
export async function installOpenCodeConfig(config, version, expectedKey, directory = openCodeConfigDirectory(), keyFile = inferenceKeyPath()) {
|
|
58
|
+
let saved;
|
|
59
|
+
try {
|
|
60
|
+
saved = (await readFile(keyFile, "utf8")).trim();
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw new CliError("Save the dashboard key with b3iq inference key-save before installing desktop settings.", "auth");
|
|
64
|
+
}
|
|
65
|
+
if (saved !== expectedKey)
|
|
66
|
+
throw new CliError("The saved dashboard key differs from the discovery key. Save the current key before installing desktop settings.", "auth");
|
|
67
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
68
|
+
const lock = join(directory, ".b3iq-config.lock");
|
|
69
|
+
try {
|
|
70
|
+
await mkdir(lock, { mode: 0o700 });
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
throw new CliError("Another B3IQ setup is using this OpenCode config directory. Inspect its .b3iq-config.lock before retrying.", "usage");
|
|
74
|
+
}
|
|
75
|
+
let temp;
|
|
76
|
+
try {
|
|
77
|
+
const existing = [];
|
|
78
|
+
for (const name of ["opencode.json", "opencode.jsonc"]) {
|
|
79
|
+
const path = join(directory, name);
|
|
80
|
+
try {
|
|
81
|
+
const info = await lstat(path);
|
|
82
|
+
if (!info.isFile() || info.isSymbolicLink())
|
|
83
|
+
throw new CliError("OpenCode config is not a regular file. Inspect it before applying B3IQ settings.", "usage");
|
|
84
|
+
existing.push(path);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (error.code !== "ENOENT")
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (existing.length > 1)
|
|
92
|
+
throw new CliError("Both OpenCode JSON and JSONC configs exist. Merge the generated settings manually to preserve their precedence.", "usage");
|
|
93
|
+
const path = existing[0] ?? join(directory, "opencode.jsonc");
|
|
94
|
+
const original = existing.length ? await readFile(path, "utf8") : "{}\n";
|
|
95
|
+
const errors = [];
|
|
96
|
+
const value = parse(original, errors, { allowTrailingComma: true });
|
|
97
|
+
if (errors.length || !value || typeof value !== "object" || Array.isArray(value))
|
|
98
|
+
throw new CliError("OpenCode config is not a valid JSONC object. Fix it or merge the generated settings manually.", "usage");
|
|
99
|
+
const current = value;
|
|
100
|
+
if ((version === 2 && ("provider" in current || "agent" in current || "small_model" in current)) ||
|
|
101
|
+
(version === 1 && ("providers" in current || "agents" in current)))
|
|
102
|
+
throw new CliError("Existing OpenCode config uses another major version. Migrate it with OpenCode before installing B3IQ settings.", "usage");
|
|
103
|
+
const object = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
104
|
+
const providers = version === 2 ? "providers" : "provider";
|
|
105
|
+
if (current[providers] !== undefined && !object(current[providers]) ||
|
|
106
|
+
current.mcp !== undefined && !object(current.mcp) ||
|
|
107
|
+
version === 2 && object(current.mcp) && current.mcp.servers !== undefined && !object(current.mcp.servers)) {
|
|
108
|
+
throw new CliError("Inspect existing OpenCode provider and MCP objects before adding B3IQ settings.", "usage");
|
|
109
|
+
}
|
|
110
|
+
let updated = original;
|
|
111
|
+
const set = (path, value) => {
|
|
112
|
+
updated = applyEdits(updated, modify(updated, path, value, { formattingOptions: { insertSpaces: true, tabSize: 2 } }));
|
|
113
|
+
};
|
|
114
|
+
const add = (path, existing, proposed) => {
|
|
115
|
+
if (existing === undefined) {
|
|
116
|
+
set(path, proposed);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (object(existing) && object(proposed)) {
|
|
120
|
+
for (const [key, value] of Object.entries(proposed))
|
|
121
|
+
add([...path, key], existing[key], value);
|
|
122
|
+
}
|
|
123
|
+
else if (JSON.stringify(existing) !== JSON.stringify(proposed)) {
|
|
124
|
+
throw new CliError(`Existing OpenCode setting ${path.join(".")} conflicts with B3IQ registration. Inspect it before retrying; no client settings were changed.`, "usage");
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
add([providers, "b3iq"], current[providers]?.b3iq, config[providers].b3iq);
|
|
128
|
+
const oldMcp = version === 2 ? current.mcp?.servers : current.mcp;
|
|
129
|
+
const proposedMcp = version === 2 ? config.mcp.servers : config.mcp;
|
|
130
|
+
add(version === 2 ? ["mcp", "servers", "b3iq_media"] : ["mcp", "b3iq_media"], oldMcp?.b3iq_media, proposedMcp.b3iq_media);
|
|
131
|
+
if (config.skills) {
|
|
132
|
+
const old = version === 2 ? current.skills : current.skills?.paths;
|
|
133
|
+
const added = version === 2 ? config.skills : config.skills.paths;
|
|
134
|
+
if (old !== undefined && (!Array.isArray(old) || old.some(x => typeof x !== "string")))
|
|
135
|
+
throw new CliError("Inspect existing OpenCode skill paths before merging B3IQ settings.", "usage");
|
|
136
|
+
const merged = [...new Set([...(old ?? []), ...added])];
|
|
137
|
+
if (JSON.stringify(old) !== JSON.stringify(merged))
|
|
138
|
+
set(version === 2 ? ["skills"] : ["skills", "paths"], merged);
|
|
139
|
+
}
|
|
140
|
+
if (updated === original)
|
|
141
|
+
return { config: path };
|
|
142
|
+
const backup = existing.length ? `${path}.b3iq-backup-${randomUUID()}` : undefined;
|
|
143
|
+
if (backup)
|
|
144
|
+
await writeFile(backup, original, { flag: "wx", mode: 0o600 });
|
|
145
|
+
temp = `${path}.b3iq-${randomUUID()}`;
|
|
146
|
+
await writeFile(temp, updated.endsWith("\n") ? updated : updated + "\n", { flag: "wx", mode: 0o600 });
|
|
147
|
+
if (existing.length && await readFile(path, "utf8") !== original)
|
|
148
|
+
throw new CliError("OpenCode config changed during setup. Retry after inspecting the backup.", "usage");
|
|
149
|
+
if (!existing.length) {
|
|
150
|
+
await link(temp, path);
|
|
151
|
+
}
|
|
152
|
+
else
|
|
153
|
+
await rename(temp, path);
|
|
154
|
+
return { config: path, ...(backup ? { backup } : {}) };
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
if (temp)
|
|
158
|
+
await unlink(temp).catch(() => { });
|
|
159
|
+
await rmdir(lock);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { api } from "../lib/api.js";
|
|
2
|
+
import { resolveMachine, machineLabel, noteReportedOffline } from "../lib/machines.js";
|
|
3
|
+
import { queuedThenWait } from "../lib/commands-wait.js";
|
|
4
|
+
import { CliError, emit, emitIds, isQuiet, success, table } from "../lib/output.js";
|
|
5
|
+
export async function pluginsList(machine) {
|
|
6
|
+
if (machine)
|
|
7
|
+
return machinePlugins(machine);
|
|
8
|
+
const { plugins } = await api.plugins();
|
|
9
|
+
if (isQuiet()) {
|
|
10
|
+
emitIds(plugins.map((p) => p.name));
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
emit(plugins, () => {
|
|
14
|
+
table(["PLUGIN", "WHAT IT ADDS"], plugins.map((p) => [p.name, p.description]));
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
async function machinePlugins(machine) {
|
|
18
|
+
const node = await resolveMachine(machine);
|
|
19
|
+
const plugins = (await api.node(node.node_id)).plugins ?? [];
|
|
20
|
+
if (isQuiet()) {
|
|
21
|
+
emitIds(plugins.map((p) => p.name));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
emit(plugins, () => {
|
|
25
|
+
if (plugins.length === 0) {
|
|
26
|
+
process.stderr.write(`${machineLabel(node)} has no plugins. See what it can install: \`b3iq plugins list\`.\n`);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
table(["PLUGIN", "VERSION", "STATE"], plugins.map((p) => [p.name, p.version, p.reason ? `${p.state} (${p.reason})` : p.state]));
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
const INSTALL_TIMEOUT_MS = 25 * 60 * 1000;
|
|
33
|
+
const UNINSTALL_TIMEOUT_MS = 10 * 60 * 1000;
|
|
34
|
+
export async function pluginsInstall(name, machine, wait) {
|
|
35
|
+
if (!name)
|
|
36
|
+
throw new CliError("Usage: b3iq plugins install <plugin> --machine <m>", "usage", 1);
|
|
37
|
+
const node = await resolveMachine(machine);
|
|
38
|
+
noteReportedOffline(node);
|
|
39
|
+
const row = await queuedThenWait(node.node_id, await api.installPlugin(node.node_id, name), {
|
|
40
|
+
wait,
|
|
41
|
+
timeoutMs: INSTALL_TIMEOUT_MS,
|
|
42
|
+
describe: `install ${name} on ${machineLabel(node)}`,
|
|
43
|
+
});
|
|
44
|
+
emit(row, () => {
|
|
45
|
+
if (row.status === "completed")
|
|
46
|
+
success(`${name} installed on ${machineLabel(node)}.`);
|
|
47
|
+
else
|
|
48
|
+
process.stdout.write(`${row.command_id}\n`);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
export async function pluginsRemove(name, machine, wait) {
|
|
52
|
+
if (!name)
|
|
53
|
+
throw new CliError("Usage: b3iq plugins remove <plugin> --machine <m>", "usage", 1);
|
|
54
|
+
const node = await resolveMachine(machine);
|
|
55
|
+
noteReportedOffline(node);
|
|
56
|
+
const row = await queuedThenWait(node.node_id, await api.uninstallPlugin(node.node_id, name), {
|
|
57
|
+
wait,
|
|
58
|
+
timeoutMs: UNINSTALL_TIMEOUT_MS,
|
|
59
|
+
describe: `remove ${name} from ${machineLabel(node)}`,
|
|
60
|
+
});
|
|
61
|
+
emit(row, () => {
|
|
62
|
+
if (row.status === "completed")
|
|
63
|
+
success(`${name} removed from ${machineLabel(node)}.`);
|
|
64
|
+
else
|
|
65
|
+
process.stdout.write(`${row.command_id}\n`);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { api } from "../lib/api.js";
|
|
2
|
+
import { explainRuntimeMissing } from "../lib/diagnosis.js";
|
|
3
|
+
import { resolveMachine, machineLabel, noteReportedOffline } from "../lib/machines.js";
|
|
4
|
+
import { enqueueAndWait } from "../lib/commands-wait.js";
|
|
5
|
+
import { CliError, emit, emitIds, isQuiet, success, table } from "../lib/output.js";
|
|
6
|
+
export async function runtimesCheck(machine) {
|
|
7
|
+
const node = await resolveMachine(machine);
|
|
8
|
+
const row = await enqueueAndWait(node.node_id, "model.catalog", {}, { wait: true, timeoutMs: 120_000, describe: "check runtime updates" });
|
|
9
|
+
const detail = await api.nodeCommand(node.node_id, row.command_id);
|
|
10
|
+
const catalog = detail.command.result;
|
|
11
|
+
const entries = catalog?.runtime_catalog ?? [];
|
|
12
|
+
emit(entries, () => table(["RUNTIME", "INSTALLED", "PINNED TARGET", "UPDATE", "POLICY"], entries.filter(r => r.update).map(r => [r.runtime_id ?? r.kind, r.update.installed_version ?? "unknown", r.update.target_version, r.update.state, r.update.policy.mode === "scheduled" ? `daily ${r.update.policy.hour_utc}:00 UTC` : "manual"])));
|
|
13
|
+
}
|
|
14
|
+
export async function runtimesUpgrade(selector, machine, target, wait) {
|
|
15
|
+
if (!selector || !target || !/^\d+\.\d+\.\d+$/.test(target))
|
|
16
|
+
throw new CliError("Usage: b3iq runtimes upgrade <runtime-id> --machine <m> --target-version <pinned-version> [--no-wait]. Get the target with b3iq runtimes check --machine <m>.", "usage", 1);
|
|
17
|
+
const node = await resolveMachine(machine);
|
|
18
|
+
noteReportedOffline(node);
|
|
19
|
+
const row = await enqueueAndWait(node.node_id, "runtime.upgrade", { runtime_id: selector, target_version: target }, { wait, timeoutMs: 65 * 60_000, describe: `upgrade ${selector}; waits for active inference, then briefly restarts` });
|
|
20
|
+
emit(row, () => process.stdout.write(`${row.command_id}: ${row.status}\n`));
|
|
21
|
+
}
|
|
22
|
+
export async function runtimesPolicy(selector, machine, mode, hour) {
|
|
23
|
+
if (!selector || !["manual", "scheduled"].includes(mode ?? "") || (mode === "scheduled" && (!hour || !/^(?:[01]?\d|2[0-3])$/.test(hour))))
|
|
24
|
+
throw new CliError("Usage: b3iq runtimes policy <runtime-id> --machine <m> --mode manual|scheduled [--hour-utc 0..23]. Scheduled upgrades use a daily one-hour window and only the plugin's pinned release.", "usage", 1);
|
|
25
|
+
const node = await resolveMachine(machine);
|
|
26
|
+
const row = await enqueueAndWait(node.node_id, "runtime.update_policy", { runtime_id: selector, mode, ...(mode === "scheduled" ? { hour_utc: Number(hour) } : {}) }, { wait: true, timeoutMs: 120_000, describe: `save ${selector} update policy` });
|
|
27
|
+
emit(row, () => success("Runtime update policy saved."));
|
|
28
|
+
}
|
|
29
|
+
export async function runtimesList(machine) {
|
|
30
|
+
const node = await resolveMachine(machine);
|
|
31
|
+
const runtimes = (await api.node(node.node_id)).runtimes ?? [];
|
|
32
|
+
const rid = (r) => r.runtime_id ?? r.id ?? r.runtime_kind ?? r.kind ?? "";
|
|
33
|
+
if (isQuiet()) {
|
|
34
|
+
emitIds(runtimes.map(rid));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
emit(runtimes, () => {
|
|
38
|
+
if (runtimes.length === 0) {
|
|
39
|
+
process.stderr.write(`${machineLabel(node)} reports no runtimes (agent offline?).\n`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
table(["RUNTIME", "VERSION", "STATUS", "ENDPOINT", "SELF-HEAL"], runtimes.map((r) => [
|
|
43
|
+
rid(r),
|
|
44
|
+
r.runtime_version ?? r.version ?? "—",
|
|
45
|
+
r.status ?? (r.available === false ? "unreachable" : "available"),
|
|
46
|
+
(r.base_url ?? "").replace(/^https?:\/\//, ""),
|
|
47
|
+
r.auto_restart === "uncovered" ? "no" : r.auto_restart === "covered" ? "yes" : "—",
|
|
48
|
+
]));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
const RESTART_TIMEOUT_MS = 5 * 60 * 1000;
|
|
52
|
+
export async function runtimesRestart(selector, machine, wait) {
|
|
53
|
+
if (!selector)
|
|
54
|
+
throw new CliError("Usage: b3iq runtimes restart <runtime> --machine <m>", "usage", 1);
|
|
55
|
+
const node = await resolveMachine(machine);
|
|
56
|
+
noteReportedOffline(node);
|
|
57
|
+
const runtimes = (await api.node(node.node_id)).runtimes ?? [];
|
|
58
|
+
const lower = selector.trim().toLowerCase();
|
|
59
|
+
const matches = runtimes.filter((r) => [r.runtime_id, r.id, r.runtime_kind, r.kind].some((v) => v?.toLowerCase() === lower));
|
|
60
|
+
if (matches.length === 0) {
|
|
61
|
+
throw explainRuntimeMissing(selector, node.node_id, machineLabel(node));
|
|
62
|
+
}
|
|
63
|
+
const target = matches[0];
|
|
64
|
+
const runtimeId = target.runtime_id ?? target.id ?? selector;
|
|
65
|
+
const row = await enqueueAndWait(node.node_id, "runtime.restart", { runtime_id: runtimeId }, {
|
|
66
|
+
wait,
|
|
67
|
+
timeoutMs: RESTART_TIMEOUT_MS,
|
|
68
|
+
describe: `restart ${runtimeId} on ${machineLabel(node)}`,
|
|
69
|
+
});
|
|
70
|
+
emit(row, () => {
|
|
71
|
+
if (row.status === "completed")
|
|
72
|
+
success(`${runtimeId} restarted on ${machineLabel(node)}.`);
|
|
73
|
+
else
|
|
74
|
+
process.stdout.write(`${row.command_id}\n`);
|
|
75
|
+
});
|
|
76
|
+
}
|