@cabane/companion 0.6.236 → 0.6.237

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/README.md CHANGED
@@ -10,16 +10,16 @@ Pairing supplies a device token, not your Cabane password or a full-account toke
10
10
 
11
11
  ## Install
12
12
 
13
- On the device, in a terminal. One of:
13
+ On the device, in a terminal. Copy the line from Your settings → Devices → Add a device in your Cabane: it carries your Cabane's address, shown here as `https://cabane.example.com`. One of:
14
14
 
15
15
  ```sh
16
- curl -fsSL https://cabane.ai/install.sh | bash
16
+ curl -fsSL https://cabane.example.com/install.sh | bash
17
17
  ```
18
18
 
19
- The script brings its own Node (checksum-checked, into `~/.cabane`) and needs no sudo. Or, with Node 22 or newer already there (the only route on Windows):
19
+ Your Cabane serves the script, with its own address written in. It brings its own Node (checksum-checked, into `~/.cabane`) and needs no sudo. Or, with Node 22 or newer already there (the only route on Windows):
20
20
 
21
21
  ```sh
22
- npm i -g @cabane/companion && cabane setup
22
+ npm i -g @cabane/companion && cabane setup --server https://cabane.example.com
23
23
  ```
24
24
 
25
25
  `cabane setup` on an unpaired device prints a code and a link:
@@ -29,14 +29,14 @@ Pair this device
29
29
  Enter this code in Cabane, or press Return to open the page:
30
30
 
31
31
  WXYZ-2345
32
- https://app.cabane.ai/pair/enter-code
32
+ https://cabane.example.com/pair/enter-code
33
33
 
34
34
  ⠋ Waiting for you to confirm in Cabane… (the code is good for 15 minutes)
35
35
  ```
36
36
 
37
- Enter the code in Cabane, signed in as yourself: from your own Settings → Devices → Add a device, from a workspace's Settings → Devices → Set up a new device, or on the page the link opens. Cabane shows which device asked; confirming pairs it to your account and nothing else. A workspace's dialog then adds the device to that workspace; from anywhere else, add it to a workspace from that workspace's Settings → Devices. Pairing from a workspace you're setting up places its default agent on the device once a harness there is usable.
37
+ Enter the code in Cabane, signed in as yourself: from Your settings → Devices → Add a device, from a workspace's Settings → Devices → Set up a new device, or on the page the link opens. Cabane shows which device asked; confirming pairs it to your account and nothing else. A workspace's dialog then adds the device to that workspace; from anywhere else, add it to a workspace from that workspace's Settings → Devices. Pairing from a workspace you're setting up places its default agent on the device once a harness there is usable.
38
38
 
39
- Without a terminal, setup prints the same lines once and waits, so a container's logs carry the code. An expired code is replaced by a fresh one, up to three; Ctrl-C stops cleanly. The link is on the server the device pairs with: pass `--server <url>` for your own Cabane.
39
+ Without a terminal, setup prints the same lines once and waits, so a container's logs carry the code. An expired code is replaced by a fresh one, up to three; Ctrl-C stops cleanly. The link is on the Cabane the device pairs with. There is no default: setup pairs with the Cabane named by `--server <url>`, by `CABANE_BASE_URL`, or by the device's config, and stops if none names one. Once a Cabane has issued a code, setup keeps its address, so after a cancelled or expired pairing a bare `cabane setup` pairs with the same one.
40
40
 
41
41
  From an interactive terminal, setup then starts the Companion in the background, waits for its first check-in and reports the device's state. Without an interactive terminal, setup stays attached to the running Companion.
42
42
 
@@ -54,7 +54,7 @@ If no harness is usable, interactive setup asks which one you want to set up. Fo
54
54
  Status cabane status Stop cabane stop Log cabane logs
55
55
  Start at login and keep Cabane updating itself: cabane service install
56
56
 
57
- In Cabane: https://app.cabane.ai/settings/devices
57
+ In Cabane: https://cabane.example.com/settings/devices
58
58
  ```
59
59
 
60
60
  Setup never installs the service. On macOS or Linux, run `cabane service install` when you want the Companion to start at login and keep itself updated; the closing block names it. Native Windows uses the background process and has no service installer.
package/dist/bin.js CHANGED
@@ -4,8 +4,12 @@
4
4
  var MIN_NODE_MAJOR = 22;
5
5
  var major = Number(process.versions.node.split(".")[0]);
6
6
  if (!(major >= MIN_NODE_MAJOR)) {
7
+ const found = `Cabane needs Node ${MIN_NODE_MAJOR} or newer, and this is Node ${process.versions.node}.`;
7
8
  process.stderr.write(
8
- `Cabane needs Node ${MIN_NODE_MAJOR} or newer, and this is Node ${process.versions.node}. Install Node 22 or newer, or use the install script, which brings its own: curl -fsSL https://cabane.ai/install.sh | bash
9
+ process.platform === "win32" ? `${found} Install Node 22 or newer.
10
+ ` : `${found}
11
+ Install Node 22 or newer. Or, to install Cabane with its own Node, run the
12
+ installer line from Your settings \u2192 Devices \u2192 Add a device in your Cabane.
9
13
  `
10
14
  );
11
15
  process.exit(1);
package/dist/cli.js CHANGED
@@ -14,6 +14,13 @@ import { realpathSync } from "fs";
14
14
  import { homedir } from "os";
15
15
  import { dirname, join, sep } from "path";
16
16
  import { fileURLToPath } from "url";
17
+ function installScriptCommand(address) {
18
+ return `curl -fsSL ${address.replace(/\/+$/, "")}/install.sh | bash`;
19
+ }
20
+ function installLineWords(install = companionInstall()) {
21
+ const line = install === "script" ? "installer line" : "npm line";
22
+ return `the ${line} from Your settings \u2192 Devices \u2192 Add a device in your Cabane`;
23
+ }
17
24
  function companionInstall(execPath = process.execPath, home = homedir()) {
18
25
  const real = (path) => {
19
26
  try {
@@ -36,11 +43,9 @@ function repairRoute(packageRoot = ownRoot(), install = companionInstall()) {
36
43
  function ownRoot() {
37
44
  return dirname(dirname(fileURLToPath(import.meta.url)));
38
45
  }
39
- var INSTALL_SCRIPT_COMMAND;
40
46
  var init_install_kind = __esm({
41
47
  "src/install-kind.ts"() {
42
48
  "use strict";
43
- INSTALL_SCRIPT_COMMAND = "curl -fsSL https://cabane.ai/install.sh | bash";
44
49
  }
45
50
  });
46
51
 
@@ -222,7 +227,7 @@ var init_cabane_command = __esm({
222
227
  });
223
228
 
224
229
  // src/errors.ts
225
- var CompanionError, ApiError, EdgeBlockError, ConfigError, PrepareHookError;
230
+ var CompanionError, StandaloneError, ApiError, EdgeBlockError, ConfigError, PrepareHookError;
226
231
  var init_errors = __esm({
227
232
  "src/errors.ts"() {
228
233
  "use strict";
@@ -232,6 +237,12 @@ var init_errors = __esm({
232
237
  this.name = "CompanionError";
233
238
  }
234
239
  };
240
+ StandaloneError = class extends CompanionError {
241
+ constructor(message2) {
242
+ super(message2);
243
+ this.name = "StandaloneError";
244
+ }
245
+ };
235
246
  ApiError = class extends CompanionError {
236
247
  constructor(status2, message2, body) {
237
248
  super(message2);
@@ -603,31 +614,13 @@ function loadConfigTolerant() {
603
614
  const strict = companionConfigSchema.safeParse(parsed);
604
615
  if (strict.success) {
605
616
  const {
606
- agents: agents2,
607
- prepareHook: prepareHook2,
608
- root: root2,
609
- dashboardPort,
610
- autoOpen,
611
- logLevel,
612
- logFormat,
613
- harnesses: harnesses2,
614
- opencode: opencode2
617
+ baseUrl: _baseUrl,
618
+ deviceToken: _deviceToken,
619
+ deviceId: _deviceId,
620
+ deviceLabel: _deviceLabel,
621
+ ...local2
615
622
  } = strict.data;
616
- return {
617
- local: {
618
- ...agents2 !== void 0 ? { agents: agents2 } : {},
619
- ...prepareHook2 !== void 0 ? { prepareHook: prepareHook2 } : {},
620
- ...root2 !== void 0 ? { root: root2 } : {},
621
- ...dashboardPort !== void 0 ? { dashboardPort } : {},
622
- ...autoOpen !== void 0 ? { autoOpen } : {},
623
- ...logLevel !== void 0 ? { logLevel } : {},
624
- ...logFormat !== void 0 ? { logFormat } : {},
625
- ...harnesses2 !== void 0 ? { harnesses: harnesses2 } : {},
626
- ...opencode2 !== void 0 ? { opencode: opencode2 } : {}
627
- },
628
- note: null,
629
- hadPriorConfig: true
630
- };
623
+ return { local: local2, note: null, hadPriorConfig: true };
631
624
  }
632
625
  const obj = parsed && typeof parsed === "object" ? parsed : {};
633
626
  const local = {};
@@ -647,6 +640,11 @@ function loadConfigTolerant() {
647
640
  if (harnesses.success && harnesses.data !== void 0) local.harnesses = harnesses.data;
648
641
  const opencode = companionConfigSchema.shape.opencode.safeParse(obj.opencode);
649
642
  if (opencode.success && opencode.data !== void 0) local.opencode = opencode.data;
643
+ const update2 = companionConfigSchema.shape.update.safeParse(obj.update);
644
+ if (update2.success && update2.data !== void 0) local.update = update2.data;
645
+ if (typeof obj.serviceDeclined === "boolean") local.serviceDeclined = obj.serviceDeclined;
646
+ const runner = companionConfigSchema.shape.runner.safeParse(obj.runner);
647
+ if (runner.success && runner.data !== void 0) local.runner = runner.data;
650
648
  return {
651
649
  local,
652
650
  note: `the existing ${path} was from an older or incompatible companion; re-pairing rewrote it.`,
@@ -958,18 +956,32 @@ function resolveBundledBinary(runtime, env = defaultResolveEnv()) {
958
956
  function missingNoun(status2) {
959
957
  return status2 === "executable_missing" ? "executable" : "platform package";
960
958
  }
961
- function repairCommands(route = repairRoute(), spell = cabane) {
959
+ function repairCommands(route = repairRoute(), spell = cabane, address = configuredAddress()) {
962
960
  if (route === "managed") return [spell("stop"), spell("update")];
963
- if (route === "script") return [spell("stop"), INSTALL_SCRIPT_COMMAND];
961
+ if (route === "script")
962
+ return [
963
+ spell("stop"),
964
+ address ? installScriptCommand(address) : { words: installLineWords("script") }
965
+ ];
964
966
  return ["npm i -g @cabane/companion --include=optional"];
965
967
  }
966
- function repairAction(route = repairRoute(), spell = cabane) {
967
- const [first, ...rest] = repairCommands(route, spell);
968
- if (rest.length === 0) return `Reinstall with \`${first}\`.`;
969
- return `Run \`${first}\`, then \`${rest.join("`, then `")}\`. ${STOP_WARNING}`;
968
+ function configuredAddress() {
969
+ try {
970
+ return loadConfig()?.baseUrl ?? null;
971
+ } catch {
972
+ return null;
973
+ }
974
+ }
975
+ function quoteStep(step, quote) {
976
+ return typeof step === "string" ? quote(step) : step.words;
977
+ }
978
+ function repairAction(route = repairRoute(), spell = cabane, address = configuredAddress()) {
979
+ const [first, ...rest] = repairCommands(route, spell, address);
980
+ if (rest.length === 0) return `Reinstall with ${quoteStep(first, backticks)}.`;
981
+ return `Run ${quoteStep(first, backticks)}, then ${rest.map((step) => quoteStep(step, backticks)).join(", then ")}. ${STOP_WARNING}`;
970
982
  }
971
983
  function repairSteps(route = repairRoute()) {
972
- return repairCommands(route).join(", then ");
984
+ return repairCommands(route).map((step) => quoteStep(step, (command) => command)).join(", then ");
973
985
  }
974
986
  function repairCost(route = repairRoute()) {
975
987
  return repairCommands(route).length > 1 ? STOP_WARNING : "";
@@ -981,11 +993,12 @@ function startupWarning(r) {
981
993
  function turnIncompleteCopy(runtime) {
982
994
  return `**This device's ${HARNESS_LABEL[runtime]} runtime is incomplete.** ${repairAction()} Restart it, then try again.`;
983
995
  }
984
- var CLAUDE_SDK, CODEX_SDK, CODEX_CLI, CODEX_PACKAGE_BY_TRIPLE, HARNESS_LABEL, HARNESS_INTENT_WORD, STOP_WARNING;
996
+ var CLAUDE_SDK, CODEX_SDK, CODEX_CLI, CODEX_PACKAGE_BY_TRIPLE, HARNESS_LABEL, HARNESS_INTENT_WORD, backticks, STOP_WARNING;
985
997
  var init_bundled_binaries = __esm({
986
998
  "src/bundled-binaries.ts"() {
987
999
  "use strict";
988
1000
  init_cabane_command();
1001
+ init_config();
989
1002
  init_install_kind();
990
1003
  CLAUDE_SDK = "@anthropic-ai/claude-agent-sdk";
991
1004
  CODEX_SDK = "@openai/codex-sdk";
@@ -1006,6 +1019,7 @@ var init_bundled_binaries = __esm({
1006
1019
  "claude-code": "connected",
1007
1020
  codex: "enabled"
1008
1021
  };
1022
+ backticks = (command) => `\`${command}\``;
1009
1023
  STOP_WARNING = "Stopping ends any turn in progress.";
1010
1024
  }
1011
1025
  });
@@ -9959,6 +9973,7 @@ import { hostname } from "os";
9959
9973
 
9960
9974
  // src/enrollment.ts
9961
9975
  init_errors();
9976
+ init_install_kind();
9962
9977
  var EnrollmentExpiredError = class extends CompanionError {
9963
9978
  constructor() {
9964
9979
  super("this pairing code expired before it was confirmed.");
@@ -10012,8 +10027,9 @@ async function postJson(baseUrl, path, body, signal) {
10012
10027
  "Too many pairing requests from this network. Wait a few minutes and run cabane setup again."
10013
10028
  );
10014
10029
  if (res.status === 404 && path.endsWith("/code"))
10015
- throw new CompanionError(
10016
- `No pairing endpoint at ${baseUrl}. Either that server is too old for this companion, or it isn't a Cabane API origin: the hosted app is https://app.cabane.ai (pass \`--server <url>\` for your own instance).`
10030
+ throw new StandaloneError(
10031
+ `No pairing endpoint at ${baseUrl}: it isn't a Cabane's address, or that Cabane is too old for this companion.
10032
+ Run ${installLineWords()}.`
10017
10033
  );
10018
10034
  const msg = parsed && typeof parsed === "object" && "error" in parsed ? String(parsed.error) : `${res.status}`;
10019
10035
  throw new ApiError(res.status, `${res.status} ${msg}`, parsed);
@@ -10198,6 +10214,7 @@ async function pairDevice(opts, deps = {}) {
10198
10214
  process.exitCode = 130;
10199
10215
  return null;
10200
10216
  }
10217
+ if (!current) opts.onCode?.();
10201
10218
  current = code;
10202
10219
  const minutes = Math.max(1, Math.round(code.expiresIn / 60));
10203
10220
  const codeLines = ["", ` ${code.userCode}`, ` ${code.verificationUri}`, ""];
@@ -10304,10 +10321,11 @@ init_cabane_command();
10304
10321
  init_config();
10305
10322
  init_pairing();
10306
10323
  init_errors();
10324
+ init_install_kind();
10307
10325
  init_errors();
10308
- var DEFAULT_BASE_URL = "https://app.cabane.ai";
10309
10326
  function resolvePairBaseUrl(server) {
10310
- const raw = (server ?? process.env.CABANE_BASE_URL ?? DEFAULT_BASE_URL).trim();
10327
+ const raw = (server ?? process.env.CABANE_BASE_URL ?? "").trim();
10328
+ if (!raw) throw new NoPairAddressError();
10311
10329
  if (!isAllowedBaseUrl(raw)) {
10312
10330
  throw new CompanionError(
10313
10331
  `invalid server URL "${raw}": must be an https URL (loopback http is allowed for local dev). Pass it as \`${cabane("setup --server https://your-cabane")}\`.`
@@ -10315,6 +10333,20 @@ function resolvePairBaseUrl(server) {
10315
10333
  }
10316
10334
  return raw;
10317
10335
  }
10336
+ var NoPairAddressError = class extends StandaloneError {
10337
+ constructor(install = companionInstall()) {
10338
+ const words = installLineWords(install);
10339
+ super(
10340
+ `Setup doesn't know which Cabane to pair this device with. Run the
10341
+ ${words.slice("the ".length)}.`
10342
+ );
10343
+ this.name = "NoPairAddressError";
10344
+ }
10345
+ };
10346
+ function rememberPairAddress(baseUrl) {
10347
+ const { local } = loadConfigTolerant();
10348
+ saveConfig({ ...local, baseUrl });
10349
+ }
10318
10350
  function writePairedConfig(paired) {
10319
10351
  const { local, note: note2 } = loadConfigTolerant();
10320
10352
  const config = {
@@ -10652,7 +10684,7 @@ function turnFailureCopy(reason, runtime) {
10652
10684
  const stopsFirst = commands.length > 1;
10653
10685
  if (!stopsFirst) commands.push("cabane stop");
10654
10686
  commands.push("cabane start");
10655
- const list = commands.map((command) => `\`${command}\``);
10687
+ const list = commands.map((step) => quoteStep(step, (command) => `\`${command}\``));
10656
10688
  return `${harnessLabel(runtime ?? reason.slice("runtime_incomplete:".length))}'s installation on this device is incomplete. Run ${list.slice(0, -1).join(", ")} and ${list.at(-1)}. ${stopsFirst ? `${STOP_WARNING} ` : ""}Reply in Cabane to try again.`;
10657
10689
  }
10658
10690
  const unavailable = /^(codex|opencode)_unavailable:/.exec(reason ?? "")?.[1] ?? (reason?.startsWith("runtime_unavailable:") ? reason.slice("runtime_unavailable:".length).trim() : void 0);
@@ -14822,6 +14854,7 @@ function userName() {
14822
14854
  return process.env.USER ?? "";
14823
14855
  }
14824
14856
  }
14857
+ var SCRIPT_WRAPPER_COMMENT = /^# Written by \S+\/install\.sh\. /m;
14825
14858
  function rewriteScriptWrappers(dir2 = join27(cabaneDir(), "bin")) {
14826
14859
  for (const name of ["cabane", "cabane-companion"]) {
14827
14860
  const path = join27(dir2, name);
@@ -14831,7 +14864,7 @@ function rewriteScriptWrappers(dir2 = join27(cabaneDir(), "bin")) {
14831
14864
  } catch {
14832
14865
  continue;
14833
14866
  }
14834
- if (!text.includes("Written by https://cabane.ai/install.sh")) continue;
14867
+ if (!SCRIPT_WRAPPER_COMMENT.test(text)) continue;
14835
14868
  if (!text.includes("/dist/cli.js")) continue;
14836
14869
  writeAtomic(path, text.replace("/dist/cli.js", "/dist/bin.js"));
14837
14870
  chmodSync5(path, 493);
@@ -15168,6 +15201,9 @@ async function runAttached(runtime) {
15168
15201
  });
15169
15202
  }
15170
15203
 
15204
+ // src/commands/setup.ts
15205
+ init_errors();
15206
+
15171
15207
  // src/prompts.ts
15172
15208
  init_term();
15173
15209
  import * as clack from "@clack/prompts";
@@ -15188,7 +15224,8 @@ function terminalUi() {
15188
15224
  },
15189
15225
  spinner: () => railSpinner(),
15190
15226
  outro: (lines2) => clack.outro(lines2.join("\n ")),
15191
- cancel: (message2) => clack.cancel(message2)
15227
+ // Clack draws only the first line on the rail's end; the rest align under its text.
15228
+ cancel: (message2) => clack.cancel(message2.split("\n").join("\n "))
15192
15229
  };
15193
15230
  }
15194
15231
  var FRAMES = ["\u25D2", "\u25D0", "\u25D3", "\u25D1"];
@@ -15244,7 +15281,10 @@ function plainUi() {
15244
15281
  outro: (lines2) => {
15245
15282
  for (const line of lines2) write(`${INDENT}${line}`);
15246
15283
  },
15247
- cancel: (message2) => write(`${INDENT}${message2}`)
15284
+ // A closing message can run to more than one line; each is indented like the first.
15285
+ cancel: (message2) => {
15286
+ for (const line of message2.split("\n")) write(`${INDENT}${line}`);
15287
+ }
15248
15288
  };
15249
15289
  }
15250
15290
 
@@ -15256,9 +15296,13 @@ async function setup(opts = {}, deps = {}) {
15256
15296
  try {
15257
15297
  await setupScript(opts, deps);
15258
15298
  } catch (err) {
15259
- for (const line of pathLines(reloadLine())) write(line);
15260
- write(err instanceof Error ? err.message : String(err));
15261
- write(`Check the server connection, then run ${cabane("setup")} again.`);
15299
+ if (err instanceof StandaloneError) {
15300
+ write(err.message);
15301
+ } else {
15302
+ for (const line of pathLines(reloadLine())) write(line);
15303
+ write(err instanceof Error ? err.message : String(err));
15304
+ write(`Check the server connection, then run ${cabane("setup")} again.`);
15305
+ }
15262
15306
  process.exitCode = 1;
15263
15307
  } finally {
15264
15308
  setConsoleLogging(true);
@@ -15286,8 +15330,21 @@ async function setupScript(opts, deps) {
15286
15330
  }
15287
15331
  let justPaired = false;
15288
15332
  if (!paired) {
15289
- const baseUrl = resolvePairBaseUrl(opts.server ?? cfg?.baseUrl);
15290
- const device = await (deps.pair ?? pairDevice)({ baseUrl, interactive, ui });
15333
+ let baseUrl;
15334
+ try {
15335
+ baseUrl = resolvePairBaseUrl(opts.server ?? cfg?.baseUrl);
15336
+ } catch (err) {
15337
+ if (!(err instanceof NoPairAddressError)) throw err;
15338
+ ui.cancel(err.message);
15339
+ process.exitCode = 1;
15340
+ return;
15341
+ }
15342
+ const device = await (deps.pair ?? pairDevice)({
15343
+ baseUrl,
15344
+ interactive,
15345
+ ui,
15346
+ onCode: () => rememberPairAddress(baseUrl)
15347
+ });
15291
15348
  if (!device) return;
15292
15349
  writePairedConfig(device);
15293
15350
  justPaired = true;
@@ -16378,7 +16435,7 @@ init_cabane_command();
16378
16435
  rememberInvokedCommand();
16379
16436
  var program = new Command();
16380
16437
  program.name("cabane").description("Run your Cabane agents on this device, with Claude Code, Codex or OpenCode.").version(COMPANION_VERSION);
16381
- program.command("setup").argument("[code]", "not taken: run cabane setup and enter the code it shows in Cabane").description("set up this device: find your agents, pair it with Cabane, and keep it running.").allowExcessArguments(false).option("--server <url>", "the Cabane instance to pair with (default https://app.cabane.ai)").addOption(
16438
+ program.command("setup").argument("[code]", "not taken: run cabane setup and enter the code it shows in Cabane").description("set up this device: find your agents, pair it with Cabane, and keep it running.").allowExcessArguments(false).option("--server <url>", "the address of the Cabane to pair with").addOption(
16382
16439
  new Option("--log-level <level>", "info (default), warn, or debug").choices([
16383
16440
  "info",
16384
16441
  "warn",
@@ -8,6 +8,21 @@ import { realpathSync } from "fs";
8
8
  import { homedir } from "os";
9
9
  import { dirname, join, sep } from "path";
10
10
  import { fileURLToPath } from "url";
11
+ function installLineWords(install = companionInstall()) {
12
+ const line = install === "script" ? "installer line" : "npm line";
13
+ return `the ${line} from Your settings \u2192 Devices \u2192 Add a device in your Cabane`;
14
+ }
15
+ function companionInstall(execPath = process.execPath, home = homedir()) {
16
+ const real = (path) => {
17
+ try {
18
+ return realpathSync(path);
19
+ } catch {
20
+ return path;
21
+ }
22
+ };
23
+ const scriptNode = join(real(home), ".cabane", "node") + sep;
24
+ return real(execPath).startsWith(scriptNode) ? "script" : "npm";
25
+ }
11
26
 
12
27
  // src/cli-entry.ts
13
28
  import { existsSync } from "fs";
@@ -49,6 +64,12 @@ var CompanionError = class extends Error {
49
64
  this.name = "CompanionError";
50
65
  }
51
66
  };
67
+ var StandaloneError = class extends CompanionError {
68
+ constructor(message) {
69
+ super(message);
70
+ this.name = "StandaloneError";
71
+ }
72
+ };
52
73
  var ApiError = class extends CompanionError {
53
74
  constructor(status, message, body) {
54
75
  super(message);
@@ -341,31 +362,13 @@ function loadConfigTolerant() {
341
362
  const strict = companionConfigSchema.safeParse(parsed);
342
363
  if (strict.success) {
343
364
  const {
344
- agents: agents2,
345
- prepareHook: prepareHook2,
346
- root: root2,
347
- dashboardPort,
348
- autoOpen,
349
- logLevel,
350
- logFormat,
351
- harnesses: harnesses2,
352
- opencode: opencode2
365
+ baseUrl: _baseUrl,
366
+ deviceToken: _deviceToken,
367
+ deviceId: _deviceId,
368
+ deviceLabel: _deviceLabel,
369
+ ...local2
353
370
  } = strict.data;
354
- return {
355
- local: {
356
- ...agents2 !== void 0 ? { agents: agents2 } : {},
357
- ...prepareHook2 !== void 0 ? { prepareHook: prepareHook2 } : {},
358
- ...root2 !== void 0 ? { root: root2 } : {},
359
- ...dashboardPort !== void 0 ? { dashboardPort } : {},
360
- ...autoOpen !== void 0 ? { autoOpen } : {},
361
- ...logLevel !== void 0 ? { logLevel } : {},
362
- ...logFormat !== void 0 ? { logFormat } : {},
363
- ...harnesses2 !== void 0 ? { harnesses: harnesses2 } : {},
364
- ...opencode2 !== void 0 ? { opencode: opencode2 } : {}
365
- },
366
- note: null,
367
- hadPriorConfig: true
368
- };
371
+ return { local: local2, note: null, hadPriorConfig: true };
369
372
  }
370
373
  const obj = parsed && typeof parsed === "object" ? parsed : {};
371
374
  const local = {};
@@ -385,6 +388,11 @@ function loadConfigTolerant() {
385
388
  if (harnesses.success && harnesses.data !== void 0) local.harnesses = harnesses.data;
386
389
  const opencode = companionConfigSchema.shape.opencode.safeParse(obj.opencode);
387
390
  if (opencode.success && opencode.data !== void 0) local.opencode = opencode.data;
391
+ const update = companionConfigSchema.shape.update.safeParse(obj.update);
392
+ if (update.success && update.data !== void 0) local.update = update.data;
393
+ if (typeof obj.serviceDeclined === "boolean") local.serviceDeclined = obj.serviceDeclined;
394
+ const runner = companionConfigSchema.shape.runner.safeParse(obj.runner);
395
+ if (runner.success && runner.data !== void 0) local.runner = runner.data;
388
396
  return {
389
397
  local,
390
398
  note: `the existing ${path} was from an older or incompatible companion; re-pairing rewrote it.`,
@@ -469,8 +477,9 @@ async function postJson(baseUrl, path, body, signal) {
469
477
  "Too many pairing requests from this network. Wait a few minutes and run cabane setup again."
470
478
  );
471
479
  if (res.status === 404 && path.endsWith("/code"))
472
- throw new CompanionError(
473
- `No pairing endpoint at ${baseUrl}. Either that server is too old for this companion, or it isn't a Cabane API origin: the hosted app is https://app.cabane.ai (pass \`--server <url>\` for your own instance).`
480
+ throw new StandaloneError(
481
+ `No pairing endpoint at ${baseUrl}: it isn't a Cabane's address, or that Cabane is too old for this companion.
482
+ Run ${installLineWords()}.`
474
483
  );
475
484
  const msg = parsed && typeof parsed === "object" && "error" in parsed ? String(parsed.error) : `${res.status}`;
476
485
  throw new ApiError(res.status, `${res.status} ${msg}`, parsed);
@@ -524,9 +533,9 @@ async function awaitEnrollment(baseUrl, code, opts = {}) {
524
533
  }
525
534
 
526
535
  // src/pairing-config.ts
527
- var DEFAULT_BASE_URL = "https://app.cabane.ai";
528
536
  function resolvePairBaseUrl(server) {
529
- const raw = (server ?? process.env.CABANE_BASE_URL ?? DEFAULT_BASE_URL).trim();
537
+ const raw = (server ?? process.env.CABANE_BASE_URL ?? "").trim();
538
+ if (!raw) throw new NoPairAddressError();
530
539
  if (!isAllowedBaseUrl(raw)) {
531
540
  throw new CompanionError(
532
541
  `invalid server URL "${raw}": must be an https URL (loopback http is allowed for local dev). Pass it as \`${cabane("setup --server https://your-cabane")}\`.`
@@ -534,6 +543,20 @@ function resolvePairBaseUrl(server) {
534
543
  }
535
544
  return raw;
536
545
  }
546
+ var NoPairAddressError = class extends StandaloneError {
547
+ constructor(install = companionInstall()) {
548
+ const words = installLineWords(install);
549
+ super(
550
+ `Setup doesn't know which Cabane to pair this device with. Run the
551
+ ${words.slice("the ".length)}.`
552
+ );
553
+ this.name = "NoPairAddressError";
554
+ }
555
+ };
556
+ function rememberPairAddress(baseUrl) {
557
+ const { local } = loadConfigTolerant();
558
+ saveConfig({ ...local, baseUrl });
559
+ }
537
560
  function writePairedConfig(paired) {
538
561
  const { local, note } = loadConfigTolerant();
539
562
  const config = {
@@ -558,8 +581,10 @@ export {
558
581
  CompanionError,
559
582
  EnrollmentCancelledError,
560
583
  EnrollmentExpiredError,
584
+ NoPairAddressError,
561
585
  awaitEnrollment,
562
586
  isDevicePaired,
587
+ rememberPairAddress,
563
588
  requestEnrollmentCode,
564
589
  resolvePairBaseUrl,
565
590
  writePairedConfig
@@ -1,7 +1,7 @@
1
1
  // src/bundled-binaries.ts
2
2
  import { realpathSync as realpathSync3, statSync } from "fs";
3
3
  import { createRequire } from "module";
4
- import { dirname as dirname3, join as join4 } from "path";
4
+ import { dirname as dirname4, join as join5 } from "path";
5
5
  import { fileURLToPath as fileURLToPath3 } from "url";
6
6
 
7
7
  // src/cabane-command.ts
@@ -14,7 +14,13 @@ import { realpathSync } from "fs";
14
14
  import { homedir } from "os";
15
15
  import { dirname, join, sep } from "path";
16
16
  import { fileURLToPath } from "url";
17
- var INSTALL_SCRIPT_COMMAND = "curl -fsSL https://cabane.ai/install.sh | bash";
17
+ function installScriptCommand(address) {
18
+ return `curl -fsSL ${address.replace(/\/+$/, "")}/install.sh | bash`;
19
+ }
20
+ function installLineWords(install = companionInstall()) {
21
+ const line = install === "script" ? "installer line" : "npm line";
22
+ return `the ${line} from Your settings \u2192 Devices \u2192 Add a device in your Cabane`;
23
+ }
18
24
  function companionInstall(execPath = process.execPath, home = homedir()) {
19
25
  const real = (path) => {
20
26
  try {
@@ -57,6 +63,308 @@ function cabane(rest) {
57
63
  return `cabane ${rest}`;
58
64
  }
59
65
 
66
+ // src/config.ts
67
+ import {
68
+ chmodSync,
69
+ mkdirSync,
70
+ readFileSync as readFileSync2,
71
+ renameSync,
72
+ rmSync,
73
+ writeFileSync,
74
+ existsSync as existsSync3
75
+ } from "fs";
76
+ import { homedir as homedir5, userInfo } from "os";
77
+ import { dirname as dirname3, isAbsolute, join as join4 } from "path";
78
+ import { z as z2 } from "zod";
79
+
80
+ // src/errors.ts
81
+ var CompanionError = class extends Error {
82
+ constructor(message) {
83
+ super(message);
84
+ this.name = "CompanionError";
85
+ }
86
+ };
87
+ var ConfigError = class extends CompanionError {
88
+ constructor(message) {
89
+ super(message);
90
+ this.name = "ConfigError";
91
+ }
92
+ };
93
+
94
+ // src/pairing.ts
95
+ function isAllowedBaseUrl(raw) {
96
+ let url;
97
+ try {
98
+ url = new URL(raw);
99
+ } catch {
100
+ return false;
101
+ }
102
+ if (url.protocol === "https:") return true;
103
+ if (url.protocol === "http:") {
104
+ const host = url.hostname.toLowerCase();
105
+ return host === "localhost" || host.endsWith(".localhost") || host === "127.0.0.1" || host === "::1" || host === "[::1]";
106
+ }
107
+ return false;
108
+ }
109
+
110
+ // src/prepare-hook.ts
111
+ import { spawn } from "child_process";
112
+ import { z } from "zod";
113
+ var prepareHookSchema = z.object({
114
+ command: z.string().min(1),
115
+ args: z.array(z.string()).optional(),
116
+ // Extra env handed to the hook process itself (merged over process.env).
117
+ env: z.record(z.string(), z.string()).optional(),
118
+ // Wall-clock cap for the hook. Provisioning is slow (minutes), so the
119
+ // default is generous; a hook that hangs past this is killed and the turn
120
+ // fails with a clear timeout message rather than pinning the companion.
121
+ timeoutMs: z.number().int().positive().optional()
122
+ }).strict();
123
+ var DEFAULT_TIMEOUT_MS = 10 * 6e4;
124
+ var prepareResultSchema = z.object({
125
+ cwd: z.string().min(1),
126
+ env: z.record(z.string(), z.string()).optional()
127
+ });
128
+
129
+ // src/config.ts
130
+ var HARNESS_RUNTIMES = ["claude-code", "codex", "opencode"];
131
+ var realHomeCache;
132
+ function realAccountHome() {
133
+ if (realHomeCache === void 0) {
134
+ try {
135
+ realHomeCache = userInfo().homedir;
136
+ } catch {
137
+ realHomeCache = null;
138
+ }
139
+ }
140
+ return realHomeCache;
141
+ }
142
+ function cabaneDir() {
143
+ const dir = join4(process.env.CABANE_COMPANION_HOME || homedir5(), ".cabane");
144
+ if (process.env.VITEST) {
145
+ const real = realAccountHome();
146
+ if (real && dir === join4(real, ".cabane")) {
147
+ throw new Error(
148
+ `cabaneDir() resolved to the real ${dir} during a test run. Companion tests must swap process.env.HOME to a tmp dir before touching the config dir; this guard prevents wiping the operator's real companion config (see apps/companion/test/setup-home.ts).`
149
+ );
150
+ }
151
+ }
152
+ return dir;
153
+ }
154
+ function configPath() {
155
+ return join4(cabaneDir(), "config.json");
156
+ }
157
+ var localAgentConfigSchema = z2.object({
158
+ cwd: z2.string().optional(),
159
+ prepareHook: prepareHookSchema.optional(),
160
+ // The Claude Code auto-memory escape hatch. Auto-memory is forced OFF by
161
+ // default (memory belongs in the Cabane workspace, and a shared companion
162
+ // would pool one cwd-keyed memory dir across users). On a companion you run
163
+ // yourself, `claudeCode: { autoMemory: true }` hands auto-memory back to your
164
+ // own `~/.claude/settings.json` — Cabane then stops
165
+ // injecting the off switch and your normal Claude Code memory workflow applies
166
+ // (the user and project settings load on every Claude Code turn).
167
+ claudeCode: z2.object({ autoMemory: z2.boolean().optional() }).strict().optional()
168
+ }).strict();
169
+ var agentsConfigSchema = z2.record(z2.string(), localAgentConfigSchema);
170
+ function isRootPath(value) {
171
+ return isAbsolute(value) || value === "~" || value.startsWith("~/");
172
+ }
173
+ var companionConfigSchema = z2.object({
174
+ // https-enforced (loopback exempt), so a hand-edited config can't put the
175
+ // device token and prompt on a plaintext channel.
176
+ baseUrl: z2.string().url().refine(isAllowedBaseUrl, {
177
+ message: "baseUrl must be https (loopback http is allowed for local dev only)"
178
+ }),
179
+ // The `cabdev_` device token plaintext — the companion's one durable credential,
180
+ // presented as `Authorization: Bearer <token>` on the device-facing pull +
181
+ // heartbeat endpoints. Optional so `cabane-companion logout` can strip it (a
182
+ // "paired but logged out" state the supervisor refuses to run) while keeping
183
+ // the rest of the config; `pair` always writes one.
184
+ deviceToken: z2.string().optional(),
185
+ // Identity hints, learned from the device flow and refreshed on the first
186
+ // assignments pull. Cosmetic — used for `status`/dashboard display only.
187
+ deviceId: z2.string().optional(),
188
+ deviceLabel: z2.string().optional(),
189
+ // Optional per-agent machine-local overrides (cwd / prepareHook), keyed by
190
+ // agentId / username / `slug/username`. Hand-added by the operator; the companion
191
+ // never writes this (it only persists credentials + the device, elsewhere).
192
+ agents: agentsConfigSchema.optional(),
193
+ // The DEVICE-LEVEL prepare hook, for every agent assigned here unless its own
194
+ // `agents` row names one. Per-agent hooks alone make the config a second
195
+ // silent roster: an agent nobody listed gets no hook and no error, and starts
196
+ // in the operator's `$HOME`.
197
+ prepareHook: prepareHookSchema.optional(),
198
+ // The CLI ignores both dashboard settings. Desktop uses `dashboardPort`;
199
+ // `autoOpen` remains parseable for older config files but no longer controls
200
+ // startup. `logLevel` is pino's level, live-editable.
201
+ dashboardPort: z2.number().int().min(1).max(65535).optional(),
202
+ autoOpen: z2.boolean().optional(),
203
+ logLevel: z2.enum(["warn", "info", "debug"]).optional(),
204
+ logFormat: z2.enum(["human", "json"]).optional(),
205
+ // Where this device's `opencode serve` answers, when it isn't the default
206
+ // address (`DEFAULT_OPENCODE_SERVER_URL`). Detection probes this one; a serve
207
+ // that answers is connected. Auth is opencode's own; Cabane never sees
208
+ // provider keys.
209
+ opencode: z2.object({
210
+ serverUrl: z2.string().url()
211
+ }).strict().optional(),
212
+ // The one knob on agents: an allowlist. Agents are detected, never offered
213
+ // (`harness-detect.ts`): whatever is signed in here is connected. Someone with
214
+ // Codex on the device who doesn't want Cabane to see it lists the ones it may:
215
+ // `"harnesses": ["claude-code"]`. Absent means all three.
216
+ harnesses: z2.array(z2.enum(HARNESS_RUNTIMES)).optional(),
217
+ // Whether this companion keeps itself current: installs the version Cabane names, between
218
+ // turns, and rolls back one that doesn't come up (`updater.ts`). Absent means never asked,
219
+ // which `start` offers; off either way until someone says yes.
220
+ update: z2.object({
221
+ auto: z2.boolean()
222
+ }).strict().optional(),
223
+ // The answer to `start`'s "start at boot?" when it was no, so it isn't asked again.
224
+ // `cabane service install` still installs it.
225
+ serviceDeclined: z2.boolean().optional(),
226
+ // Where agents stand on this device. Every (agent, conversation) pair gets a directory under it,
227
+ // and an agent with no folder of its own and no prepare hook works there
228
+ // (`turn-execution.ts`, `prepareEnvironment`). Default `~/.cabane/work`: hidden, so pairing a
229
+ // laptop creates no new visible folder; set it to make the work visible.
230
+ root: z2.string().min(1).refine(isRootPath, { message: "must be an absolute path, or start with ~/" }).optional(),
231
+ // An argv prefix the operator puts in front of every turn's runner: the companion starts
232
+ // `<prefix…> <its own run-turn command…>` instead of the runner bare. What the prefix does
233
+ // is the operator's (a container or VM per turn, say); the companion knows nothing of it
234
+ // beyond handing the runner over, with the turn's inputs in its environment and the
235
+ // credential and secrets on its stdin (`runner/launch.ts`, `wrapped`). The README lists
236
+ // what a wrapper can rely on.
237
+ runner: z2.object({
238
+ command: z2.array(z2.string().min(1)).min(1),
239
+ // The external wrapper supplies only broker placeholders and removes
240
+ // credential mounts. Do not hand provider logins to that runner on stdin.
241
+ brokeredCredentials: z2.boolean().optional()
242
+ }).strict().optional()
243
+ });
244
+ var RETIRED_ROW_KEYS = ["sandbox"];
245
+ var RETIRED_HOOK_KEYS = ["validateCached", "failureReporter"];
246
+ function isObject(value) {
247
+ return !!value && typeof value === "object" && !Array.isArray(value);
248
+ }
249
+ function splitRetired(raw) {
250
+ const keys = [];
251
+ const without = (obj, names, path) => {
252
+ const out = {};
253
+ for (const [k, v] of Object.entries(obj)) {
254
+ if (names.includes(k)) keys.push(`${path}${k}`);
255
+ else out[k] = v;
256
+ }
257
+ return out;
258
+ };
259
+ const hook = (row, path) => isObject(row.prepareHook) ? { ...row, prepareHook: without(row.prepareHook, RETIRED_HOOK_KEYS, `${path}prepareHook.`) } : row;
260
+ const kept = hook(without(raw, ["runners"], ""), "");
261
+ if (isObject(kept.agents)) {
262
+ const agents = {};
263
+ for (const [key, row] of Object.entries(kept.agents)) {
264
+ const path = `agents.${key}.`;
265
+ agents[key] = isObject(row) ? hook(without(row, RETIRED_ROW_KEYS, path), path) : row;
266
+ }
267
+ kept.agents = agents;
268
+ }
269
+ return { keys, kept };
270
+ }
271
+ function withoutRetiredKeys(raw) {
272
+ return isObject(raw) ? splitRetired(raw).kept : raw;
273
+ }
274
+ var legacyOfferSchema = z2.object({
275
+ claudeCode: z2.object({ enabled: z2.boolean().optional() }).passthrough().optional(),
276
+ codex: z2.object({ enabled: z2.boolean().optional() }).passthrough().optional(),
277
+ harnessesChosen: z2.boolean().optional(),
278
+ opencodeDeclined: z2.boolean().optional()
279
+ });
280
+ function migrateHarnessAllowlist(raw) {
281
+ const legacy = legacyOfferSchema.safeParse(raw);
282
+ if (!legacy.success || !legacy.data.harnessesChosen) return null;
283
+ const declined = /* @__PURE__ */ new Set();
284
+ if (legacy.data.claudeCode?.enabled === false) declined.add("claude-code");
285
+ if (legacy.data.codex?.enabled === false) declined.add("codex");
286
+ if (legacy.data.opencodeDeclined === true) declined.add("opencode");
287
+ if (declined.size === 0) return null;
288
+ return HARNESS_RUNTIMES.filter((runtime) => !declined.has(runtime));
289
+ }
290
+ function carriesLegacyOffer(raw) {
291
+ if (!raw || typeof raw !== "object") return false;
292
+ const obj = raw;
293
+ return "harnessesChosen" in obj || "opencodeDeclined" in obj || typeof obj.claudeCode === "object" && obj.claudeCode !== null && "enabled" in obj.claudeCode || obj.codex !== void 0;
294
+ }
295
+ function loadConfig() {
296
+ const path = configPath();
297
+ if (!existsSync3(path)) return null;
298
+ let raw;
299
+ try {
300
+ raw = readFileSync2(path, "utf8");
301
+ } catch (err) {
302
+ throw new ConfigError(
303
+ `couldn't read ${path}: ${err instanceof Error ? err.message : String(err)}`
304
+ );
305
+ }
306
+ if (raw.trim().length === 0) return null;
307
+ let parsed;
308
+ try {
309
+ parsed = withoutRetiredKeys(JSON.parse(raw));
310
+ } catch (err) {
311
+ throw new ConfigError(
312
+ `${path} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
313
+ );
314
+ }
315
+ const result = companionConfigSchema.safeParse(parsed);
316
+ if (!result.success) {
317
+ const agentIssue = result.error.issues.find((i) => i.path[0] === "agents");
318
+ if (agentIssue) {
319
+ throw new ConfigError(
320
+ `${path}: invalid "agents" config at \`${agentIssue.path.join(".")}\` \u2014 ${agentIssue.message}. Fix the agents block in the config and retry.`
321
+ );
322
+ }
323
+ const rootIssue = result.error.issues.find((i) => i.path[0] === "root");
324
+ if (rootIssue) {
325
+ throw new ConfigError(
326
+ `${path}: invalid "root" \u2014 ${rootIssue.message}. Fix it in the config and retry.`
327
+ );
328
+ }
329
+ throw new ConfigError(
330
+ `${path} is from an incompatible or older version of the companion, or was hand-edited. Run \`${cabane("setup")}\` to pair again, or \`${cabane("logout --purge")}\` to reset.`
331
+ );
332
+ }
333
+ if (carriesLegacyOffer(parsed)) {
334
+ const allowlist = migrateHarnessAllowlist(parsed);
335
+ const migrated = {
336
+ ...result.data,
337
+ ...allowlist && !result.data.harnesses ? { harnesses: allowlist } : {}
338
+ };
339
+ saveConfig(migrated);
340
+ return migrated;
341
+ }
342
+ return result.data;
343
+ }
344
+ function saveConfig(cfg) {
345
+ const path = configPath();
346
+ mkdirSync(dirname3(path), { recursive: true });
347
+ try {
348
+ chmodSync(cabaneDir(), 448);
349
+ } catch {
350
+ }
351
+ const tmp = `${path}.${process.pid}.tmp`;
352
+ try {
353
+ writeFileSync(tmp, JSON.stringify(cfg, null, 2) + "\n", { mode: 384 });
354
+ try {
355
+ chmodSync(tmp, 384);
356
+ } catch {
357
+ }
358
+ renameSync(tmp, path);
359
+ } catch (err) {
360
+ try {
361
+ rmSync(tmp, { force: true });
362
+ } catch {
363
+ }
364
+ throw err;
365
+ }
366
+ }
367
+
60
368
  // src/bundled-binaries.ts
61
369
  function nodeResolveSelf(specifier) {
62
370
  const here = fileURLToPath3(import.meta.url);
@@ -73,9 +381,9 @@ function nodeResolveSelf(specifier) {
73
381
  return walkNodeModules(specifier, here);
74
382
  }
75
383
  function walkNodeModules(pkg, fromPath) {
76
- let dir = dirname3(fromPath);
384
+ let dir = dirname4(fromPath);
77
385
  for (; ; ) {
78
- const manifest = join4(dir, "node_modules", pkg, "package.json");
386
+ const manifest = join5(dir, "node_modules", pkg, "package.json");
79
387
  if (nodeIsFile(manifest)) {
80
388
  try {
81
389
  return realpathSync3(manifest);
@@ -83,7 +391,7 @@ function walkNodeModules(pkg, fromPath) {
83
391
  return manifest;
84
392
  }
85
393
  }
86
- const parent = dirname3(dir);
394
+ const parent = dirname4(dir);
87
395
  if (parent === dir) return null;
88
396
  dir = parent;
89
397
  }
@@ -145,7 +453,7 @@ function resolveClaude(env) {
145
453
  const manifest = env.resolve(`${pkg}/package.json`, sdkEntry);
146
454
  if (!manifest) continue;
147
455
  sawPackage ??= pkg;
148
- const exe = join4(dirname3(manifest), env.platform === "win32" ? "claude.exe" : "claude");
456
+ const exe = join5(dirname4(manifest), env.platform === "win32" ? "claude.exe" : "claude");
149
457
  if (env.isFile(exe)) {
150
458
  return { ...base, packageName: pkg, status: "present", path: exe };
151
459
  }
@@ -196,13 +504,13 @@ function resolveCodex(env) {
196
504
  if (!cliManifest) return { ...base, status: "platform_package_missing", path: null };
197
505
  const platformManifest = env.resolve(`${pkg}/package.json`, cliManifest);
198
506
  if (!platformManifest) return { ...base, status: "platform_package_missing", path: null };
199
- const packageRoot = join4(dirname3(platformManifest), "vendor", triple);
507
+ const packageRoot = join5(dirname4(platformManifest), "vendor", triple);
200
508
  const exeName = env.platform === "win32" ? "codex.exe" : "codex";
201
- const current = join4(packageRoot, "bin", exeName);
202
- if (env.isFile(current) && env.isFile(join4(packageRoot, "codex-package.json"))) {
509
+ const current = join5(packageRoot, "bin", exeName);
510
+ if (env.isFile(current) && env.isFile(join5(packageRoot, "codex-package.json"))) {
203
511
  return { ...base, status: "present", path: current };
204
512
  }
205
- const legacy = join4(packageRoot, "codex", exeName);
513
+ const legacy = join5(packageRoot, "codex", exeName);
206
514
  if (env.isFile(legacy)) return { ...base, status: "present", path: legacy };
207
515
  return { ...base, status: "executable_missing", path: null };
208
516
  }
@@ -233,15 +541,30 @@ var HARNESS_LABEL = {
233
541
  function missingNoun(status) {
234
542
  return status === "executable_missing" ? "executable" : "platform package";
235
543
  }
236
- function repairCommands(route = repairRoute(), spell = cabane) {
544
+ function repairCommands(route = repairRoute(), spell = cabane, address = configuredAddress()) {
237
545
  if (route === "managed") return [spell("stop"), spell("update")];
238
- if (route === "script") return [spell("stop"), INSTALL_SCRIPT_COMMAND];
546
+ if (route === "script")
547
+ return [
548
+ spell("stop"),
549
+ address ? installScriptCommand(address) : { words: installLineWords("script") }
550
+ ];
239
551
  return ["npm i -g @cabane/companion --include=optional"];
240
552
  }
241
- function repairAction(route = repairRoute(), spell = cabane) {
242
- const [first, ...rest] = repairCommands(route, spell);
243
- if (rest.length === 0) return `Reinstall with \`${first}\`.`;
244
- return `Run \`${first}\`, then \`${rest.join("`, then `")}\`. ${STOP_WARNING}`;
553
+ function configuredAddress() {
554
+ try {
555
+ return loadConfig()?.baseUrl ?? null;
556
+ } catch {
557
+ return null;
558
+ }
559
+ }
560
+ function quoteStep(step, quote) {
561
+ return typeof step === "string" ? quote(step) : step.words;
562
+ }
563
+ var backticks = (command) => `\`${command}\``;
564
+ function repairAction(route = repairRoute(), spell = cabane, address = configuredAddress()) {
565
+ const [first, ...rest] = repairCommands(route, spell, address);
566
+ if (rest.length === 0) return `Reinstall with ${quoteStep(first, backticks)}.`;
567
+ return `Run ${quoteStep(first, backticks)}, then ${rest.map((step) => quoteStep(step, backticks)).join(", then ")}. ${STOP_WARNING}`;
245
568
  }
246
569
  var STOP_WARNING = "Stopping ends any turn in progress.";
247
570
  function installWarning(r) {
package/dist/runtime.js CHANGED
@@ -12,7 +12,13 @@ import { realpathSync } from "fs";
12
12
  import { homedir } from "os";
13
13
  import { dirname, join, sep } from "path";
14
14
  import { fileURLToPath } from "url";
15
- var INSTALL_SCRIPT_COMMAND = "curl -fsSL https://cabane.ai/install.sh | bash";
15
+ function installScriptCommand(address) {
16
+ return `curl -fsSL ${address.replace(/\/+$/, "")}/install.sh | bash`;
17
+ }
18
+ function installLineWords(install = companionInstall()) {
19
+ const line = install === "script" ? "installer line" : "npm line";
20
+ return `the ${line} from Your settings \u2192 Devices \u2192 Add a device in your Cabane`;
21
+ }
16
22
  function companionInstall(execPath = process.execPath, home = homedir()) {
17
23
  const real = (path) => {
18
24
  try {
@@ -983,19 +989,34 @@ var HARNESS_INTENT_WORD = {
983
989
  function missingNoun(status) {
984
990
  return status === "executable_missing" ? "executable" : "platform package";
985
991
  }
986
- function repairCommands(route = repairRoute(), spell = cabane) {
992
+ function repairCommands(route = repairRoute(), spell = cabane, address = configuredAddress()) {
987
993
  if (route === "managed") return [spell("stop"), spell("update")];
988
- if (route === "script") return [spell("stop"), INSTALL_SCRIPT_COMMAND];
994
+ if (route === "script")
995
+ return [
996
+ spell("stop"),
997
+ address ? installScriptCommand(address) : { words: installLineWords("script") }
998
+ ];
989
999
  return ["npm i -g @cabane/companion --include=optional"];
990
1000
  }
991
- function repairAction(route = repairRoute(), spell = cabane) {
992
- const [first, ...rest] = repairCommands(route, spell);
993
- if (rest.length === 0) return `Reinstall with \`${first}\`.`;
994
- return `Run \`${first}\`, then \`${rest.join("`, then `")}\`. ${STOP_WARNING}`;
1001
+ function configuredAddress() {
1002
+ try {
1003
+ return loadConfig()?.baseUrl ?? null;
1004
+ } catch {
1005
+ return null;
1006
+ }
1007
+ }
1008
+ function quoteStep(step, quote) {
1009
+ return typeof step === "string" ? quote(step) : step.words;
1010
+ }
1011
+ var backticks = (command) => `\`${command}\``;
1012
+ function repairAction(route = repairRoute(), spell = cabane, address = configuredAddress()) {
1013
+ const [first, ...rest] = repairCommands(route, spell, address);
1014
+ if (rest.length === 0) return `Reinstall with ${quoteStep(first, backticks)}.`;
1015
+ return `Run ${quoteStep(first, backticks)}, then ${rest.map((step) => quoteStep(step, backticks)).join(", then ")}. ${STOP_WARNING}`;
995
1016
  }
996
1017
  var STOP_WARNING = "Stopping ends any turn in progress.";
997
1018
  function repairSteps(route = repairRoute()) {
998
- return repairCommands(route).join(", then ");
1019
+ return repairCommands(route).map((step) => quoteStep(step, (command) => command)).join(", then ");
999
1020
  }
1000
1021
  function repairCost(route = repairRoute()) {
1001
1022
  return repairCommands(route).length > 1 ? STOP_WARNING : "";
@@ -1610,7 +1631,7 @@ function turnFailureCopy(reason, runtime) {
1610
1631
  const stopsFirst = commands.length > 1;
1611
1632
  if (!stopsFirst) commands.push("cabane stop");
1612
1633
  commands.push("cabane start");
1613
- const list = commands.map((command) => `\`${command}\``);
1634
+ const list = commands.map((step) => quoteStep(step, (command) => `\`${command}\``));
1614
1635
  return `${harnessLabel(runtime ?? reason.slice("runtime_incomplete:".length))}'s installation on this device is incomplete. Run ${list.slice(0, -1).join(", ")} and ${list.at(-1)}. ${stopsFirst ? `${STOP_WARNING} ` : ""}Reply in Cabane to try again.`;
1615
1636
  }
1616
1637
  const unavailable = /^(codex|opencode)_unavailable:/.exec(reason ?? "")?.[1] ?? (reason?.startsWith("runtime_unavailable:") ? reason.slice("runtime_unavailable:".length).trim() : void 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabane/companion",
3
- "version": "0.6.236",
3
+ "version": "0.6.237",
4
4
  "type": "module",
5
5
  "description": "The Cabane Companion (headless): connect an agent on your machine to your Cabane workspace as a responder — drive work against your own codebase, files, and MCP servers without putting any of it in Cabane.",
6
6
  "license": "UNLICENSED",