@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
package/README.md
CHANGED
|
@@ -1,3 +1,208 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">b3iq CLI</h1>
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<p align="center">Manage your B3IQ GPU machines from the terminal, a script, or an AI agent.</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://www.npmjs.com/package/@b3labs/b3iq-cli"><img alt="npm version" src="https://img.shields.io/npm/v/@b3labs/b3iq-cli?label=npm&color=0a7cff"></a>
|
|
7
|
+
<a href="./LICENSE"><img alt="License: Apache-2.0" src="https://img.shields.io/badge/license-Apache--2.0-blue"></a>
|
|
8
|
+
<img alt="Node 22.23.4+ or 24.22.1+" src="https://img.shields.io/badge/node-22.23.4%2B%20%7C%2024.22.1%2B-339933">
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
List your machines, see what they serve, install models and plugins, open an SSH
|
|
12
|
+
session without writing a `ProxyCommand`, and run Codex, Claude Code or OpenCode on
|
|
13
|
+
models you own. Every command speaks JSON when piped, so the same CLI drives scripts
|
|
14
|
+
and agents.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install -g @b3labs/b3iq-cli # or: pnpm add -g @b3labs/b3iq-cli
|
|
20
|
+
b3iq --version
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Requires Node.js 22.23.4 or newer on 22.x, or 24.22.1 or newer on 24.x. The package
|
|
24
|
+
ships compiled JavaScript; there is no build step. `b3iq ssh` also needs an OpenSSH
|
|
25
|
+
client and, for tunnelled machines,
|
|
26
|
+
[`cloudflared`](https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/).
|
|
27
|
+
`b3iq doctor` checks both.
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
b3iq login # opens your browser; pick your personal account or an organization
|
|
33
|
+
b3iq status # machines online, models servable
|
|
34
|
+
b3iq machines list
|
|
35
|
+
b3iq ssh my-box # a shell on the machine
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`b3iq login` saves an API key that expires after 90 days. The CLI warns during the
|
|
39
|
+
last 14; run `b3iq login` again to renew. For a script or CI job, create an API key
|
|
40
|
+
with **Machines & SSH** access under
|
|
41
|
+
[Dashboard → Keys → API keys](https://b3iq.org/dashboard/keys?tab=api) and set it
|
|
42
|
+
as `B3IQ_TOKEN`.
|
|
43
|
+
|
|
44
|
+
## Commands
|
|
45
|
+
|
|
46
|
+
### Account
|
|
47
|
+
|
|
48
|
+
| Command | What it does |
|
|
49
|
+
| --- | --- |
|
|
50
|
+
| `login [--token-stdin]` | Sign in through the browser and choose your personal account or an organization. `--token-stdin` saves an API key read from a pipe or file instead; `B3IQ_TOKEN` skips login altogether. An organization login's key reaches that organization's machines as far as your role allows |
|
|
51
|
+
| `logout [--revoke]` | Forget the saved key; `--revoke` also revokes it server-side |
|
|
52
|
+
| `whoami` | The signed-in account and, for an organization login, its organization |
|
|
53
|
+
| `status` | Fleet overview: machines online and servable models (models need a personal login) |
|
|
54
|
+
| `doctor` | Check config, auth, API reachability and `cloudflared` |
|
|
55
|
+
| `config [default-machine <id>]` | View config, or set the machine used when `<machine>` is omitted |
|
|
56
|
+
|
|
57
|
+
### Machines
|
|
58
|
+
|
|
59
|
+
| Command | What it does |
|
|
60
|
+
| --- | --- |
|
|
61
|
+
| `machines list` | Your machines (`--quiet` prints node ids only) |
|
|
62
|
+
| `machines get <machine>` | One machine in detail |
|
|
63
|
+
| `machines qualify <machine>` | Re-run hardware qualification (`--no-wait` returns at once) |
|
|
64
|
+
| `machines guests <machine>` | A KVM host's guests and cards: every guest for its owner, your own guest on a host you rent (`--quiet` prints guest ids) |
|
|
65
|
+
| `commands list --machine <id> [--limit <n>]` | Recent commands sent to a machine, newest first |
|
|
66
|
+
| `commands get <command-id> --machine <id>` | One command's status, progress and result |
|
|
67
|
+
| `logs [--limit <n>] [--follow]` | Recent gateway request logs (personal login); `--follow` streams new events, as NDJSON when piped |
|
|
68
|
+
|
|
69
|
+
### Plugins, models and runtimes
|
|
70
|
+
|
|
71
|
+
| Command | What it does |
|
|
72
|
+
| --- | --- |
|
|
73
|
+
| `plugins list [--machine <id>]` | Plugins a machine can install, or the ones it has |
|
|
74
|
+
| `plugins install <plugin> --machine <id>` | Install a plugin (`--no-wait`) |
|
|
75
|
+
| `plugins remove <plugin> --machine <id>` | Remove a plugin and everything it installed (`--no-wait`) |
|
|
76
|
+
| `models list [--machine <id>]` | Servable models across the fleet (personal login), or one machine's full catalog |
|
|
77
|
+
| `models install <model> --machine <id>` | Install a model (`--no-wait`) |
|
|
78
|
+
| `models remove <model> --machine <id>` | Uninstall a model (`--no-wait`) |
|
|
79
|
+
| `models context <model> --machine <id> [--tokens <n>\|--reset]` | Allocate, verify and save an Ollama serving window (`--no-wait`) |
|
|
80
|
+
| `runtimes list [--machine <id>]` | A machine's runtimes, with status and self-heal coverage |
|
|
81
|
+
| `runtimes restart <runtime> --machine <id>` | Restart a runtime (`--no-wait`) |
|
|
82
|
+
| `runtimes check --machine <id>` | Refresh installed and pinned Ollama versions; does not upgrade |
|
|
83
|
+
| `runtimes upgrade <runtime-id> --machine <id> --target-version <v>` | Stage a pinned upgrade: wait for idle, verify, roll back on failure |
|
|
84
|
+
| `runtimes policy <runtime-id> --machine <id> --mode manual\|scheduled [--hour-utc <0..23>]` | Keep upgrades manual (the default), or allow a daily one-hour window when idle |
|
|
85
|
+
|
|
86
|
+
### SSH
|
|
87
|
+
|
|
88
|
+
| Command | What it does |
|
|
89
|
+
| --- | --- |
|
|
90
|
+
| `ssh [<machine>] [-- <command>]` | Open a shell, or run one command, on a machine. With no machine it asks which one. Prints the `ssh` invocation instead when piped |
|
|
91
|
+
|
|
92
|
+
### Inference and native coding clients
|
|
93
|
+
|
|
94
|
+
| Command | What it does |
|
|
95
|
+
| --- | --- |
|
|
96
|
+
| `inference models [--machine <id>]` | Exact served model ids and their verified profile metadata |
|
|
97
|
+
| `inference setup <codex\|claude\|opencode> --model <id>` | Write private client configuration without touching the client's existing settings |
|
|
98
|
+
| `inference <codex\|claude\|opencode> --model <id> [-- <client args>]` | Launch the installed client on your own models, with the media tools attached |
|
|
99
|
+
| `inference setup codex --desktop [--install] [--activate] --model <id>` | Save a separate Codex desktop instance on macOS; normal defaults stay unchanged |
|
|
100
|
+
| `inference setup claude --desktop [--install] [--activate] --model <id>` | Save an inactive Claude desktop connection; it switches over only with `--activate` |
|
|
101
|
+
| `inference restore claude --desktop` | Restore the provider Claude desktop used before activation |
|
|
102
|
+
| `inference setup opencode --desktop --install --model <id>` | Merge OpenCode's persistent settings, keeping unrelated config and taking a backup |
|
|
103
|
+
| `inference key-save` / `inference key-clear` | Save an API key from stdin to `~/.b3iq/gateway-key` (mode `0600`), or remove it |
|
|
104
|
+
| `mcp media` | Run the B3IQ media MCP server over stdio |
|
|
105
|
+
|
|
106
|
+
**Machine selectors.** `<machine>` accepts a node id, an id prefix, or a
|
|
107
|
+
case-insensitive substring of the machine's name. An exact id or name wins; an
|
|
108
|
+
ambiguous selector exits `1` and lists the candidates. A KVM host you rent stays in
|
|
109
|
+
its owner's list, so `ssh` and `machines guests` also look in your rentals.
|
|
110
|
+
|
|
111
|
+
**Waiting.** Commands that change a machine wait for the result by default: up to
|
|
112
|
+
45 minutes for a model install, 25 for a plugin install, 10 for a plugin removal or
|
|
113
|
+
a qualification, 6 for `models context`, 5 for a model removal or a runtime
|
|
114
|
+
restart. `--no-wait` returns the command id at once; read it back with
|
|
115
|
+
`commands get`.
|
|
116
|
+
|
|
117
|
+
## SSH
|
|
118
|
+
|
|
119
|
+
`b3iq ssh` reaches a machine over its Cloudflare Tunnel (this needs `cloudflared`)
|
|
120
|
+
or a bastion jump host, whichever the machine advertises, and reconnects a shell
|
|
121
|
+
that drops on a network failure (`--no-reconnect` turns that off). Accounts with
|
|
122
|
+
the `cli_direct_ssh` feature go straight to the machine's own address when its sshd
|
|
123
|
+
answers and fall back to the tunnel otherwise; `--direct` and `--tunnel` force one
|
|
124
|
+
path. That feature is not yet available to a `b3iq login`. Passing `-v`, `-E`,
|
|
125
|
+
`-q`, `-y` or `-S` to ssh turns the fallback and the reconnecting off for that run.
|
|
126
|
+
On a KVM host you rent, `ssh` opens your guest as its own user.
|
|
127
|
+
|
|
128
|
+
The SSH lane is off until you enable it on the machine's dashboard page, and your
|
|
129
|
+
public key must be listed under **Keys → SSH keys**.
|
|
130
|
+
|
|
131
|
+
## Scripts and agents
|
|
132
|
+
|
|
133
|
+
- **stdout is data, stderr is logs.** Progress lines and hints never touch stdout.
|
|
134
|
+
- **JSON is automatic when piped.** `--json` forces it, `--pretty` forces the
|
|
135
|
+
table, `--quiet` prints only ids, one per line.
|
|
136
|
+
- **Exit codes are stable:** `0` ok, `1` usage, `2` api, `3` auth,
|
|
137
|
+
`4` rate-limited. Branch on these, not on message text.
|
|
138
|
+
- **Errors are JSON too.** In JSON mode a failure writes one object to stderr with
|
|
139
|
+
`error`, `code`, `request_id`, `diagnostic`, `cause`, `observed`, `recovery` and
|
|
140
|
+
sometimes `limitation`. Keys and bearer values are redacted.
|
|
141
|
+
|
|
142
|
+
[AGENTS.md](./AGENTS.md) is the guide for a coding agent that drives the CLI.
|
|
143
|
+
[ERROR_PATHS.md](./ERROR_PATHS.md) lists every `diagnostic` code with what was
|
|
144
|
+
checked and the next step.
|
|
145
|
+
|
|
146
|
+
## Configuration
|
|
147
|
+
|
|
148
|
+
| Setting | Meaning |
|
|
149
|
+
| --- | --- |
|
|
150
|
+
| `~/.b3iq/config.json` | The saved login (mode `0600`): the key, its `expires_at`, and for an organization login its `org_id` and `org_name` |
|
|
151
|
+
| `B3IQ_TOKEN` | API key for machine-management commands; overrides the saved login |
|
|
152
|
+
| `B3IQ_API_URL` | Control plane origin. Default `https://control-plane.b3iq.org` |
|
|
153
|
+
| `B3IQ_ORG_ID` | Organization to act for. Refused with a personal key; an organization login does not need it |
|
|
154
|
+
| `B3IQ_DASHBOARD_URL` | Dashboard origin for the `b3iq login` approval link. Defaults to the dashboard paired with `B3IQ_API_URL` |
|
|
155
|
+
| `B3IQ_CONFIG_DIR` | Replaces `~/.b3iq` (config, saved API key, media output), so a test login does not touch your real one |
|
|
156
|
+
| `B3IQ_GATEWAY_API_KEY` | API key for inference and media. Separate from `B3IQ_TOKEN`; `inference key-save` stores it privately instead |
|
|
157
|
+
|
|
158
|
+
Environment variables override the config file. With `B3IQ_TOKEN` set, the saved
|
|
159
|
+
`org_id` is not sent.
|
|
160
|
+
|
|
161
|
+
## Native coding clients
|
|
162
|
+
|
|
163
|
+
Run Codex, Claude Code or OpenCode on models served from your own machines. The CLI
|
|
164
|
+
launches the client you already have installed, points it at the B3IQ gateway with
|
|
165
|
+
a private configuration, and attaches the media MCP server. Your normal client
|
|
166
|
+
settings, authentication and defaults are left alone.
|
|
167
|
+
|
|
168
|
+
1. Create an API key under
|
|
169
|
+
[Dashboard → Keys → API keys](https://b3iq.org/dashboard/keys?tab=api) and
|
|
170
|
+
supply it as `B3IQ_GATEWAY_API_KEY`, or pipe it into `b3iq inference key-save`.
|
|
171
|
+
Keep it out of command arguments, committed files and tickets.
|
|
172
|
+
2. Discover exact served ids with `b3iq inference models`. The model must be online,
|
|
173
|
+
serve text with `tool_calling`, and have a verified context window of at least
|
|
174
|
+
32K for Codex and OpenCode or 100K for Claude Code. `b3iq models context` sets the
|
|
175
|
+
window.
|
|
176
|
+
3. From your project directory:
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
b3iq inference codex --model <exact-served-id>
|
|
180
|
+
b3iq inference claude --model <exact-served-id>
|
|
181
|
+
b3iq inference opencode --model <exact-served-id>
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Add `--machine <node-id>` to pin one machine; otherwise routing balances across the
|
|
185
|
+
key's permitted machines. The clients keep their own coding tools, permissions,
|
|
186
|
+
saved sessions and project instructions. Desktop setups (`--desktop`) save a
|
|
187
|
+
separate instance or an inactive connection and are experimental on macOS. The full
|
|
188
|
+
walkthrough, including desktop setup and rollback, is at
|
|
189
|
+
[docs.b3iq.org/developers/cli](https://docs.b3iq.org/developers/cli).
|
|
190
|
+
|
|
191
|
+
Anthropic does not officially support non-Claude gateway models.
|
|
192
|
+
|
|
193
|
+
## Media MCP
|
|
194
|
+
|
|
195
|
+
`b3iq mcp media` exposes model discovery, image generation, video submission and
|
|
196
|
+
video status as MCP tools over stdio, using the same API key. Outputs go under
|
|
197
|
+
`~/.b3iq/media` (`--output-dir` changes that). A request is refused when no served
|
|
198
|
+
model matches its modality; a text or video model never stands in for an image
|
|
199
|
+
model. Video submission returns a job id to poll; do not resubmit the same request.
|
|
200
|
+
|
|
201
|
+
## Support
|
|
202
|
+
|
|
203
|
+
Questions and bug reports: [hello@b3iq.org](mailto:hello@b3iq.org).
|
|
204
|
+
Documentation: [docs.b3iq.org/developers/cli](https://docs.b3iq.org/developers/cli).
|
|
205
|
+
|
|
206
|
+
## License
|
|
207
|
+
|
|
208
|
+
[Apache-2.0](./LICENSE)
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { lstat, readFile, writeFile, rename, unlink } from "node:fs/promises";
|
|
5
|
+
import { homedir, userInfo } from "node:os";
|
|
6
|
+
import { join, dirname } from "node:path";
|
|
7
|
+
import { configDir } from "../lib/config.js";
|
|
8
|
+
import { CliError } from "../lib/output.js";
|
|
9
|
+
export function claudeGatewayBase(base) {
|
|
10
|
+
const url = new URL(base);
|
|
11
|
+
url.pathname = url.pathname.replace(/\/v1(?:\/api)?$/, "");
|
|
12
|
+
return url.toString().replace(/\/$/, "");
|
|
13
|
+
}
|
|
14
|
+
export function claudeDesktopAlias(model) {
|
|
15
|
+
return "claude-" + createHash("sha256").update(`b3iq-desktop-v1:${model}`).digest("hex").slice(0, 24);
|
|
16
|
+
}
|
|
17
|
+
export function claudeDesktopConnection(base, executable, entrypoint, machine, model) {
|
|
18
|
+
if ([executable, entrypoint].some(value => /["%\x00-\x1f]/.test(value)))
|
|
19
|
+
throw new CliError("Claude desktop helper paths cannot contain double quotes, percent signs or control characters. Install Node and the CLI at a compatible path.", "usage");
|
|
20
|
+
return {
|
|
21
|
+
inferenceProvider: "gateway", inferenceCredentialKind: "helper-script",
|
|
22
|
+
inferenceGatewayBaseUrl: claudeGatewayBase(base) + (model ? `/v1/claude-desktop/${Buffer.from(model, "utf8").toString("base64url")}` : ""), inferenceGatewayAuthScheme: "bearer",
|
|
23
|
+
inferenceCredentialHelper: executable,
|
|
24
|
+
inferenceCredentialHelperArgs: [entrypoint, "inference", "credential", "--saved"],
|
|
25
|
+
...(model ? { inferenceModels: [{ name: claudeDesktopAlias(model), labelOverride: `B3IQ · ${model} (experimental)`, supports1m: false, prefer1m: false, maxEffort: "low" }], modelPrefer1mContext: false, defaultModelEffort: "low" } : {}),
|
|
26
|
+
...(machine ? { inferenceCustomHeaders: { "x-b3iq-node-id": machine } } : {}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function claudeDesktopReference(setup) {
|
|
30
|
+
const c = setup.connection;
|
|
31
|
+
return {
|
|
32
|
+
status: setup.activated ? "activated" : setup.saved ? "saved_inactive" : "exported_connection",
|
|
33
|
+
activated: Boolean(setup.activated), restart_required: Boolean(setup.activated), activation_supported: Boolean(c.inferenceModels), model_compatibility: "experimental",
|
|
34
|
+
compatibility_note: "Experimental non-Claude model compatibility. The dedicated gateway maps one opaque alias to the real B3IQ model. Anthropic does not support non-Claude models. Code file tools run locally with native permissions; Cowork is not qualified.",
|
|
35
|
+
discovery_note: "Requires the desktop compatibility gateway deployment. Verify Test model discovery and Test inference before coding. The UI may assume 200K despite a smaller served window; compact early on long sessions. Token counts are conservative estimates.",
|
|
36
|
+
restore_command: "b3iq inference restore claude --desktop",
|
|
37
|
+
export_note: "Claude classifies custom headers, including the machine pin, as sensitive. This connection contains no API key; exports include routing IDs and helper paths.",
|
|
38
|
+
selected_model: setup.selected_model, config_file: setup.config_file,
|
|
39
|
+
configuration_id: setup.saved?.id, configuration_name: setup.saved?.name,
|
|
40
|
+
installed_config: setup.saved?.path, backup: setup.saved?.backup,
|
|
41
|
+
ui_fields: {
|
|
42
|
+
"Provider": c.inferenceProvider, "Credential kind": c.inferenceCredentialKind,
|
|
43
|
+
"Gateway base URL": c.inferenceGatewayBaseUrl, "Gateway auth scheme": c.inferenceGatewayAuthScheme,
|
|
44
|
+
"Helper script": c.inferenceCredentialHelper,
|
|
45
|
+
"Helper script arguments": c.inferenceCredentialHelperArgs,
|
|
46
|
+
"Helper script arguments (paste JSON)": JSON.stringify(c.inferenceCredentialHelperArgs),
|
|
47
|
+
"Custom inference headers": c.inferenceCustomHeaders ?? {},
|
|
48
|
+
"Models": c.inferenceModels ?? [], "Use 1M wherever available": false, "Default model effort": c.defaultModelEffort,
|
|
49
|
+
"Gateway API key": "Leave blank; the helper supplies the saved API key.",
|
|
50
|
+
"Artifact preview iframe origin": "Leave blank.", "Stream idle timeout": "Leave at the default 300 seconds.",
|
|
51
|
+
},
|
|
52
|
+
credential: { source: "saved_gateway_key", path: join(configDir(), "gateway-key"), secret_included: false,
|
|
53
|
+
save_command: 'printf \'%s\' "$B3IQ_GATEWAY_API_KEY" | b3iq inference key-save',
|
|
54
|
+
instructions: "Create the API key at https://b3iq.org/dashboard/keys?tab=api. Save it privately before using Test script. Do not run b3iq inference credential in a captured terminal." },
|
|
55
|
+
instructions: setup.activated
|
|
56
|
+
? "Reopen Claude normally and start a fresh Code conversation in your local project. To restore the original provider, quit Claude and run b3iq inference restore claude --desktop."
|
|
57
|
+
: setup.saved
|
|
58
|
+
? "Open Claude, then Developer → Configure Third-Party Inference and select the named B3IQ configuration from the top-right saved-configuration dropdown beside Export (initially labelled Default). Keep Provider set to Gateway. Test script checks the helper. Saving leaves the previously applied configuration unchanged. To activate, quit Claude and rerun setup with --desktop --install --activate; reopen Claude afterward. Use b3iq inference restore claude --desktop with Claude closed to restore the original selection."
|
|
59
|
+
: "Use the generated connection JSON or copy ui_fields into Developer → Configure Third-Party Inference. To save it without typing fields, use b3iq inference setup claude --desktop --install with the same model and machine. Activation is separate and explicit: add --activate only when you intend to switch the desktop provider.",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
63
|
+
async function regular(path) {
|
|
64
|
+
const info = await lstat(path);
|
|
65
|
+
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1)
|
|
66
|
+
throw new CliError("Claude configuration contains a symlink, hard link or non-file. No existing settings were changed.", "usage");
|
|
67
|
+
return info;
|
|
68
|
+
}
|
|
69
|
+
function libraryMeta(text) {
|
|
70
|
+
let value;
|
|
71
|
+
try {
|
|
72
|
+
value = JSON.parse(text);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
throw new CliError("Claude configuration metadata is invalid JSON. No existing settings were changed.", "usage");
|
|
76
|
+
}
|
|
77
|
+
if (!value || typeof value !== "object" || !Array.isArray(value.entries) || !value.entries.length
|
|
78
|
+
|| !value.entries.every(e => e && uuid.test(e.id) && typeof e.name === "string")
|
|
79
|
+
|| new Set(value.entries.map(e => e.id)).size !== value.entries.length
|
|
80
|
+
|| typeof value.appliedId !== "string" || !value.entries.some(e => e.id === value.appliedId)) {
|
|
81
|
+
throw new CliError("Claude configuration metadata is not a recognized initialized library. No existing settings were changed.", "usage");
|
|
82
|
+
}
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
export async function saveClaudeDesktopConnection(setup, directory) {
|
|
86
|
+
return withLibrary(directory, root => saveInLibrary(setup, root));
|
|
87
|
+
}
|
|
88
|
+
async function withLibrary(directory, operation) {
|
|
89
|
+
if (!directory && process.platform === "darwin") {
|
|
90
|
+
try {
|
|
91
|
+
await promisify(execFile)("/usr/bin/pgrep", ["-x", "Claude"]);
|
|
92
|
+
throw new CliError("Quit Claude desktop before saving its configuration library, then rerun. No desktop configuration was saved or activated.", "usage");
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (error instanceof CliError)
|
|
96
|
+
throw error;
|
|
97
|
+
if (error.code !== 1)
|
|
98
|
+
throw new CliError("Could not verify that Claude desktop is closed. Close the app before saving; use the generated JSON and ui_fields until then.", "usage");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (!directory && process.platform !== "darwin")
|
|
102
|
+
throw new CliError("Inactive Claude desktop saving is qualified on macOS only. Use the generated connection JSON and ui_fields on other systems.", "usage");
|
|
103
|
+
const root = directory ?? join(homedir(), "Library", "Application Support", "Claude-3p", "configLibrary");
|
|
104
|
+
let dir;
|
|
105
|
+
try {
|
|
106
|
+
dir = await lstat(root);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (error.code === "ENOENT")
|
|
110
|
+
throw new CliError("Open Claude's Developer → Configure Third-Party Inference once to initialize its library, quit Claude, then rerun. No desktop configuration was created or activated.", "usage");
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
if (!dir.isDirectory() || dir.isSymbolicLink())
|
|
114
|
+
throw new CliError("Claude's configuration library is not a regular directory. No existing settings were changed.", "usage");
|
|
115
|
+
const lock = join(root, ".b3iq-save.lock");
|
|
116
|
+
try {
|
|
117
|
+
await writeFile(lock, String(process.pid), { flag: "wx", mode: 0o600 });
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (error.code === "EEXIST")
|
|
121
|
+
throw new CliError("Another B3IQ desktop save is in progress, or its lock remains after interruption. Review .b3iq-save.lock before retrying; no configuration was changed.", "usage");
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
return await operation(root);
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
await unlink(lock);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async function saveInLibrary(setup, root) {
|
|
132
|
+
const metaPath = join(root, "_meta.json");
|
|
133
|
+
let original, metaInfo;
|
|
134
|
+
try {
|
|
135
|
+
metaInfo = await regular(metaPath);
|
|
136
|
+
original = await readFile(metaPath, "utf8");
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (error.code === "ENOENT")
|
|
140
|
+
throw new CliError("Open Claude's third-party configuration window once to initialize its library, quit Claude, then rerun. No desktop configuration was activated.", "usage");
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
const meta = libraryMeta(original);
|
|
144
|
+
await regular(join(root, `${meta.appliedId}.json`));
|
|
145
|
+
const content = JSON.stringify(setup.connection, null, 2) + "\n";
|
|
146
|
+
const digest = createHash("sha256").update(JSON.stringify([setup.selected_model, content])).digest().subarray(0, 16);
|
|
147
|
+
digest[6] = (digest[6] & 15) | 80;
|
|
148
|
+
digest[8] = (digest[8] & 63) | 128;
|
|
149
|
+
const hex = digest.toString("hex"), id = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
150
|
+
if (meta.appliedId === id)
|
|
151
|
+
throw new CliError("This B3IQ configuration is already applied. This command only saves inactive configurations; review the active choice in Claude before rerunning.", "usage");
|
|
152
|
+
const path = join(root, `${id}.json`), entry = meta.entries.find(e => e.id === id);
|
|
153
|
+
let created = false, metadataInstalled = false;
|
|
154
|
+
let backupCreated;
|
|
155
|
+
try {
|
|
156
|
+
try {
|
|
157
|
+
await writeFile(path, content, { flag: "wx", mode: 0o600 });
|
|
158
|
+
created = true;
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
if (error.code !== "EEXIST")
|
|
162
|
+
throw error;
|
|
163
|
+
await regular(path);
|
|
164
|
+
if (await readFile(path, "utf8") !== content)
|
|
165
|
+
throw new CliError("The saved B3IQ configuration contains user edits. No existing configuration was overwritten.", "usage");
|
|
166
|
+
}
|
|
167
|
+
if (entry)
|
|
168
|
+
return { id, name: entry.name, path, changed: created };
|
|
169
|
+
const name = `B3IQ · ${setup.selected_model}`;
|
|
170
|
+
const backup = join(root, `_meta.json.before-b3iq-${randomUUID()}`), temp = join(root, `._meta-b3iq-${randomUUID()}.tmp`);
|
|
171
|
+
await writeFile(backup, original, { flag: "wx", mode: 0o600 });
|
|
172
|
+
backupCreated = backup;
|
|
173
|
+
try {
|
|
174
|
+
await writeFile(temp, JSON.stringify({ ...meta, entries: [...meta.entries, { id, name }] }, null, 2) + "\n", { flag: "wx", mode: metaInfo.mode & 0o777 });
|
|
175
|
+
const current = await regular(metaPath);
|
|
176
|
+
if (current.ino !== metaInfo.ino || current.dev !== metaInfo.dev || await readFile(metaPath, "utf8") !== original) {
|
|
177
|
+
throw new CliError("Claude's configuration library changed during setup. Close its configuration window and retry; the concurrent settings were preserved.", "usage");
|
|
178
|
+
}
|
|
179
|
+
await rename(temp, metaPath);
|
|
180
|
+
metadataInstalled = true;
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
await unlink(temp).catch(error => { if (error.code !== "ENOENT")
|
|
184
|
+
throw error; });
|
|
185
|
+
}
|
|
186
|
+
return { id, name, path, backup, changed: true };
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
if (!metadataInstalled) {
|
|
190
|
+
if (created && await readFile(path, "utf8").catch(() => "") === content)
|
|
191
|
+
await unlink(path).catch(() => { });
|
|
192
|
+
if (backupCreated)
|
|
193
|
+
await unlink(backupCreated).catch(() => { });
|
|
194
|
+
}
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const journalName = ".b3iq-activation.json";
|
|
199
|
+
const hash = (text) => createHash("sha256").update(text).digest("hex");
|
|
200
|
+
async function readObject(path, optional = false) {
|
|
201
|
+
try {
|
|
202
|
+
await regular(path);
|
|
203
|
+
const original = await readFile(path, "utf8"), value = JSON.parse(original);
|
|
204
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
205
|
+
throw new Error("object required");
|
|
206
|
+
return { original, value: value };
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
if (optional && error.code === "ENOENT")
|
|
210
|
+
return { original: null, value: {} };
|
|
211
|
+
throw new CliError("Claude configuration is not a recognized regular JSON object. Existing settings were preserved.", "usage");
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
async function replaceObject(path, original, value) {
|
|
215
|
+
const current = await readObject(path, original === null);
|
|
216
|
+
if (current.original !== original)
|
|
217
|
+
throw new CliError("Claude configuration changed concurrently. Close Claude and retry; no concurrent edits were overwritten.", "usage");
|
|
218
|
+
const temp = `${path}.b3iq-${randomUUID()}.tmp`;
|
|
219
|
+
try {
|
|
220
|
+
await writeFile(temp, JSON.stringify(value, null, 2) + "\n", { flag: "wx", mode: 0o600 });
|
|
221
|
+
if ((await readObject(path, original === null)).original !== original)
|
|
222
|
+
throw new CliError("Claude configuration changed concurrently; settings were preserved.", "usage");
|
|
223
|
+
await rename(temp, path);
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
await unlink(temp).catch(error => { if (error.code !== "ENOENT")
|
|
227
|
+
throw error; });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function deploymentMode(value) {
|
|
231
|
+
if (!Object.hasOwn(value, "deploymentMode"))
|
|
232
|
+
return { present: false };
|
|
233
|
+
if (value.deploymentMode !== "1p" && value.deploymentMode !== "3p")
|
|
234
|
+
throw new CliError("Claude has an unrecognized deployment mode; activate through its configuration window instead.", "usage");
|
|
235
|
+
return { present: true, value: value.deploymentMode };
|
|
236
|
+
}
|
|
237
|
+
async function refuseManagedActivation() {
|
|
238
|
+
for (const path of [join("/Library/Managed Preferences", userInfo().username, "com.anthropic.claudefordesktop.plist"), "/Library/Managed Preferences/com.anthropic.claudefordesktop.plist"]) {
|
|
239
|
+
try {
|
|
240
|
+
await lstat(path);
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
if (error.code === "ENOENT")
|
|
244
|
+
continue;
|
|
245
|
+
throw error;
|
|
246
|
+
}
|
|
247
|
+
throw new CliError("Claude has managed preferences. Local activation cannot override organizational policy; use the configuration window with your administrator.", "usage");
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
export async function activateClaudeDesktopConnection(setup, directory) {
|
|
251
|
+
if (!setup.saved || !setup.connection.inferenceModels)
|
|
252
|
+
throw new CliError("Save the complete B3IQ desktop connection before activation.", "usage");
|
|
253
|
+
if (!directory)
|
|
254
|
+
await refuseManagedActivation();
|
|
255
|
+
return withLibrary(directory, async (root) => {
|
|
256
|
+
const metaPath = join(root, "_meta.json"), modePath = join(dirname(root), "claude_desktop_config.json"), journalPath = join(root, journalName);
|
|
257
|
+
const m = await readObject(metaPath), meta = libraryMeta(m.original);
|
|
258
|
+
if (!meta.entries.some(e => e.id === setup.saved.id))
|
|
259
|
+
throw new CliError("Saved B3IQ configuration is missing from Claude's library.", "usage");
|
|
260
|
+
if (meta.appliedId === setup.saved.id)
|
|
261
|
+
throw new CliError("B3IQ is already active. Restore the prior connection before activating another configuration.", "usage");
|
|
262
|
+
const target = join(root, `${setup.saved.id}.json`);
|
|
263
|
+
await regular(target);
|
|
264
|
+
if (await readFile(target, "utf8") !== JSON.stringify(setup.connection, null, 2) + "\n")
|
|
265
|
+
throw new CliError("The saved B3IQ configuration contains user edits; activation refused.", "usage");
|
|
266
|
+
const mode = await readObject(modePath, true);
|
|
267
|
+
const original = join(root, `${meta.appliedId}.json`);
|
|
268
|
+
await regular(original);
|
|
269
|
+
const journal = { version: 1, before: meta.appliedId, target: setup.saved.id, originalHash: hash(await readFile(original, "utf8")), mode: deploymentMode(mode.value) };
|
|
270
|
+
try {
|
|
271
|
+
await writeFile(journalPath, JSON.stringify(journal, null, 2) + "\n", { flag: "wx", mode: 0o600 });
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
if (error.code === "EEXIST")
|
|
275
|
+
throw new CliError("A B3IQ activation or recovery is already recorded. Run b3iq inference restore claude --desktop before switching again.", "usage");
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
if (journal.mode.present) {
|
|
280
|
+
const value = { ...mode.value };
|
|
281
|
+
delete value.deploymentMode;
|
|
282
|
+
await replaceObject(modePath, mode.original, value);
|
|
283
|
+
}
|
|
284
|
+
await replaceObject(metaPath, m.original, { ...meta, appliedId: journal.target });
|
|
285
|
+
setup.activated = true;
|
|
286
|
+
return { activated: true, original_configuration_id: journal.before, configuration_id: journal.target, restore_command: "b3iq inference restore claude --desktop", restart_required: true };
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
throw new CliError(`Desktop activation did not finish. Its recovery journal is preserved. With Claude closed, run b3iq inference restore claude --desktop. ${error instanceof Error ? error.message : ""}`, "usage");
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
export async function restoreClaudeDesktopConnection(directory) {
|
|
294
|
+
return withLibrary(directory, async (root) => {
|
|
295
|
+
const journalPath = join(root, journalName), journalFile = await readObject(journalPath, true);
|
|
296
|
+
if (journalFile.original === null)
|
|
297
|
+
return { restored: false, defaults_changed: false, instructions: "No B3IQ activation is recorded. Existing settings were preserved." };
|
|
298
|
+
const j = journalFile.value;
|
|
299
|
+
if (j.version !== 1 || !uuid.test(j.before) || !uuid.test(j.target) || !/^[a-f0-9]{64}$/.test(j.originalHash) || !j.mode || typeof j.mode.present !== "boolean" || (j.mode.present && j.mode.value !== "1p" && j.mode.value !== "3p"))
|
|
300
|
+
throw new CliError("The B3IQ recovery journal is invalid; no settings were changed.", "usage");
|
|
301
|
+
const metaPath = join(root, "_meta.json"), modePath = join(dirname(root), "claude_desktop_config.json");
|
|
302
|
+
const m = await readObject(metaPath), meta = libraryMeta(m.original);
|
|
303
|
+
if (![j.before, j.target].includes(meta.appliedId))
|
|
304
|
+
throw new CliError("You selected another Claude configuration after B3IQ activation. Restore refused to overwrite that choice.", "usage");
|
|
305
|
+
const original = join(root, `${j.before}.json`);
|
|
306
|
+
await regular(original);
|
|
307
|
+
if (!meta.entries.some(e => e.id === j.before) || hash(await readFile(original, "utf8")) !== j.originalHash)
|
|
308
|
+
throw new CliError("The original Claude configuration changed or was removed. Review it before restoring.", "usage");
|
|
309
|
+
const mode = await readObject(modePath, true);
|
|
310
|
+
deploymentMode(mode.value);
|
|
311
|
+
if (meta.appliedId !== j.before)
|
|
312
|
+
await replaceObject(metaPath, m.original, { ...meta, appliedId: j.before });
|
|
313
|
+
const value = { ...mode.value };
|
|
314
|
+
if (j.mode.present)
|
|
315
|
+
value.deploymentMode = j.mode.value;
|
|
316
|
+
else
|
|
317
|
+
delete value.deploymentMode;
|
|
318
|
+
if (JSON.stringify(value) !== JSON.stringify(mode.value))
|
|
319
|
+
await replaceObject(modePath, mode.original, value);
|
|
320
|
+
await unlink(journalPath);
|
|
321
|
+
return { restored: true, configuration_id: j.before, restart_required: true, instructions: "Reopen Claude. Your original configuration and account-mode preference are restored; saved B3IQ entries remain inactive." };
|
|
322
|
+
});
|
|
323
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { mkdir, readFile, writeFile, lstat } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { CliError } from "../lib/output.js";
|
|
8
|
+
const exec = promisify(execFile);
|
|
9
|
+
const quote = (s) => `'${s.replace(/'/g, `'"'"'`)}'`;
|
|
10
|
+
export async function findCodexDesktop(app) {
|
|
11
|
+
if (process.platform !== "darwin")
|
|
12
|
+
throw new CliError("Isolated Codex desktop setup is experimental and currently available on macOS only.", "usage");
|
|
13
|
+
for (const path of app
|
|
14
|
+
? [resolve(app)]
|
|
15
|
+
: ["/Applications/ChatGPT.app", "/Applications/Codex.app"]) {
|
|
16
|
+
try {
|
|
17
|
+
const plist = join(path, "Contents", "Info.plist");
|
|
18
|
+
const { stdout: id } = await exec("/usr/libexec/PlistBuddy", [
|
|
19
|
+
"-c",
|
|
20
|
+
"Print :CFBundleIdentifier",
|
|
21
|
+
plist,
|
|
22
|
+
]);
|
|
23
|
+
if (id.trim() !== "com.openai.codex")
|
|
24
|
+
continue;
|
|
25
|
+
const { stdout: version } = await exec("/usr/libexec/PlistBuddy", [
|
|
26
|
+
"-c",
|
|
27
|
+
"Print :CFBundleShortVersionString",
|
|
28
|
+
plist,
|
|
29
|
+
]);
|
|
30
|
+
const bundle = await readFile(join(path, "Contents", "Resources", "app.asar"));
|
|
31
|
+
if (!bundle.includes(Buffer.from("CODEX_ELECTRON_USER_DATA_PATH")) ||
|
|
32
|
+
!bundle.includes(Buffer.from("CODEX_HOME"))) {
|
|
33
|
+
throw new CliError("This Codex desktop build does not expose the required isolation settings. The normal desktop configuration was not changed.", "usage");
|
|
34
|
+
}
|
|
35
|
+
return { path, version: version.trim() };
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (error instanceof CliError)
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
throw new CliError("Install the macOS Codex desktop app, or provide its path with --desktop-app. Normal desktop settings were not changed.", "usage");
|
|
43
|
+
}
|
|
44
|
+
async function privateDirectory(path) {
|
|
45
|
+
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
46
|
+
const s = await lstat(path);
|
|
47
|
+
if (!s.isDirectory() || s.isSymbolicLink())
|
|
48
|
+
throw new CliError("Refusing a linked or non-directory desktop configuration path.", "usage");
|
|
49
|
+
}
|
|
50
|
+
async function saveExclusive(path, content, mode = 0o600) {
|
|
51
|
+
try {
|
|
52
|
+
await writeFile(path, content, { flag: "wx", mode });
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error.code !== "EEXIST")
|
|
56
|
+
throw error;
|
|
57
|
+
const s = await lstat(path);
|
|
58
|
+
if (!s.isFile() ||
|
|
59
|
+
s.isSymbolicLink() ||
|
|
60
|
+
s.nlink !== 1 ||
|
|
61
|
+
(await readFile(path, "utf8")) !== content) {
|
|
62
|
+
throw new CliError(`The isolated Codex file ${JSON.stringify(path)} differs from the generated setup or is not a regular, unlinked file. Existing files were preserved. Changed setup options, a CLI update, or app/user edits can cause this. Keep this setup and rerun with --setup-name <new-name> to create a separate instance. Normal settings were not changed.`, "usage");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export function codexDesktopLaunchArgs(s) {
|
|
67
|
+
return [
|
|
68
|
+
"-n",
|
|
69
|
+
"--env",
|
|
70
|
+
`CODEX_HOME=${s.engine_home}`,
|
|
71
|
+
"--env",
|
|
72
|
+
`CODEX_ELECTRON_USER_DATA_PATH=${s.desktop_data}`,
|
|
73
|
+
"--env",
|
|
74
|
+
"CODEX_APP_SERVER_WS_URL=",
|
|
75
|
+
s.app,
|
|
76
|
+
"--args",
|
|
77
|
+
`--user-data-dir=${s.desktop_data}`,
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
export async function saveCodexDesktop(config, dir, app) {
|
|
81
|
+
const engine = join(dir, "codex-home"), data = join(dir, "desktop-data");
|
|
82
|
+
await privateDirectory(dir);
|
|
83
|
+
await privateDirectory(engine);
|
|
84
|
+
await privateDirectory(data);
|
|
85
|
+
await saveExclusive(join(engine, "config.toml"), await readFile(config, "utf8"));
|
|
86
|
+
const setup = {
|
|
87
|
+
app: app.path,
|
|
88
|
+
app_version: app.version,
|
|
89
|
+
engine_home: engine,
|
|
90
|
+
desktop_data: data,
|
|
91
|
+
launcher: join(dir, "B3IQ Codex.command"),
|
|
92
|
+
};
|
|
93
|
+
const check = "const fs=require('node:fs');const b=fs.readFileSync(process.argv[1]);process.exit(b.includes(Buffer.from('CODEX_HOME'))&&b.includes(Buffer.from('CODEX_ELECTRON_USER_DATA_PATH'))?0:1)";
|
|
94
|
+
const script = "#!/bin/sh\nset -eu\n" +
|
|
95
|
+
"# B3IQ uses separate Codex engine and desktop state. Normal defaults remain unchanged.\n" +
|
|
96
|
+
`[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' ${quote(join(setup.app, "Contents", "Info.plist"))})" = 'com.openai.codex' ] || exit 1\n` +
|
|
97
|
+
`${quote(process.execPath)} -e ${quote(check)} ${quote(join(setup.app, "Contents", "Resources", "app.asar"))} || { printf '%s\\n' 'Codex desktop isolation support is unavailable. Re-run B3IQ desktop setup.' >&2; exit 1; }\n` +
|
|
98
|
+
"exec /usr/bin/open " +
|
|
99
|
+
codexDesktopLaunchArgs(setup).map(quote).join(" ") +
|
|
100
|
+
"\n";
|
|
101
|
+
await saveExclusive(setup.launcher, script, 0o700);
|
|
102
|
+
return setup;
|
|
103
|
+
}
|
|
104
|
+
export async function installCodexDesktopLauncher(setup, directory = join(homedir(), "Applications")) {
|
|
105
|
+
await privateDirectory(directory);
|
|
106
|
+
const id = createHash("sha256")
|
|
107
|
+
.update(setup.engine_home)
|
|
108
|
+
.digest("hex")
|
|
109
|
+
.slice(0, 12);
|
|
110
|
+
const path = join(directory, `B3IQ Codex ${id}.command`);
|
|
111
|
+
await saveExclusive(path, await readFile(setup.launcher, "utf8"), 0o700);
|
|
112
|
+
setup.installed_launcher = path;
|
|
113
|
+
}
|
|
114
|
+
export async function launchCodexDesktop(setup) {
|
|
115
|
+
await findCodexDesktop(setup.app);
|
|
116
|
+
await exec("/usr/bin/open", codexDesktopLaunchArgs(setup));
|
|
117
|
+
setup.launched = true;
|
|
118
|
+
}
|