@monster-orange/den 0.4.2 → 0.4.3

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/CHANGELOG.md CHANGED
@@ -4,6 +4,99 @@ All notable changes to Den are documented here. Each release lists the
4
4
  config and manifest changes a distribution must make to move to it under
5
5
  **Migrations**.
6
6
 
7
+ ## [0.4.3] - 2026-11-06
8
+
9
+ Agents start, and the edge counts as local.
10
+
11
+ ### Fixed
12
+
13
+ - **The Start agent dialog can be submitted (AG-10.0).** A persona's whole
14
+ role sentence was in its option, so the select grew past the dialog and
15
+ pushed Cancel and Start out of sight. A persona option now shows its id,
16
+ with its role on a help line under the select that follows the pick. The
17
+ runtime's first option names what it resolves to ("Claude Code (persona
18
+ default)"), a runtime that can't run here says why, and Enter starts
19
+ from any field. The Workspaces page's agents form gets the same choices.
20
+
21
+ - **Agents start on a Den run as a service (AG-0.1).** Starting any agent
22
+ failed with "den-claude is unavailable: claude is not on PATH" when the
23
+ web server ran under launchd: its PATH leaves out `~/.local/bin`, where
24
+ Claude Code's native installer puts `claude`, and Den searched only that
25
+ PATH. Den now finds a runtime's CLI as your terminal would:
26
+ `agents.runtimes.<id>.bin` in `config/local/user.yml` wins (an absolute
27
+ path, checked; a bad one is refused with its reason), then the login
28
+ shell's PATH (`$SHELL -lc`, asked once at server start in the
29
+ background, 3-second timeout, falling back to the current PATH), the
30
+ server's PATH, and the runtime's install directories (`~/.local/bin`,
31
+ `~/.claude/local`, `/opt/homebrew/bin`, `/usr/local/bin`, npm's global
32
+ bin). Agents and plain sessions get the same directories on their PATH.
33
+ - **The lock is always reachable (LK-1 to LK-3).** Start agent opened its
34
+ dialog and only then asked for the lock, whose popover sat under the
35
+ dialog where nothing could reach it. The lock is now checked before the
36
+ dialog opens, and the dialog asks for the admin password at its top:
37
+ Start unlocks, then starts. The lock popover is in the top layer, so no
38
+ dialog covers it. A page that thinks it's locked asks the server before
39
+ asking you (another tab or device may have unlocked it), and, when a
40
+ password is stored in the vault, the first action that needs the lock
41
+ loads it from there on every page, not only Local Dev. A page view still
42
+ never wakes the vault.
43
+
44
+ ### Changed
45
+
46
+ - **The edge from this machine needs no sign-in.** A request for the edge
47
+ hostname from an edge peer is treated as loopback: no sign-in on any
48
+ route, agents and the terminal included (0.4.2 asked for one on every
49
+ route). Host, Origin, CSRF and the agent-token rules stay. The client is
50
+ the left-most `X-Forwarded-For` entry: loopback or one of this machine's
51
+ own interface addresses (a Tailscale `100.x` included) is local; missing,
52
+ or a Docker/VM network's address (what Docker Desktop and OrbStack hand
53
+ the edge; the subnets of the edge's own Docker networks, `<org>-edge`
54
+ and those it joins, read once at start with `docker network inspect`;
55
+ OrbStack's host address `0.250.250.254`), is "can't tell", also local, so
56
+ a TCP-passthrough proxy container on the edge's network is no other
57
+ machine; any other address is another
58
+ machine and needs a sign-in. A non-loopback `DEN_BIND_HOST` bind (other
59
+ than the Linux edge's Docker bridge) and `web.allow_plain_http_remote`'s
60
+ LAN names still need one. See docs/distributions.md, "Signing in".
61
+
62
+ ### Added
63
+
64
+ - **The Start dialog says before Start whether the agent can run
65
+ (AG-0.2).** Under the runtime a line names what Start will run, with its
66
+ version and path ("Starts Claude Code 2.1.0 from …"); the runtime's
67
+ option shows its version. When the runtime can't run here, Start is
68
+ disabled and the line says why and how to fix it: where Den looked, then
69
+ install it, or set `agents.runtimes.<id>.bin` in the user.yml it names,
70
+ or run `<cli> agents doctor`. The Workspaces page's agents form does the
71
+ same. `GET /api/personas` gives each runtime `version` (its version
72
+ command, run once under a timeout and kept), `path`, `source`
73
+ (`setting`, `login-shell`, `well-known` or `PATH`), `searched` and
74
+ `setting`, and the response a `userConfig` path.
75
+ - **`<cli> agents doctor [--json]` (AG-0.3)**: each runtime's status, the
76
+ CLI it runs and where it came from, its version, the directories
77
+ searched (names only, never an environment value), and the fix when it
78
+ is missing. It exits 1 when the default runtime is unavailable.
79
+
80
+ - **"Your edge is reachable from other machines".** When an edge hostname
81
+ resolves to an address that isn't loopback, or the edge container
82
+ publishes a port on every interface (`docker inspect`), Den says so once
83
+ in `web.log` at start and in a banner on every page (dismissed per
84
+ browser): "Your edge is reachable from other machines; anyone who can
85
+ reach it can use terminals. Auth is coming."
86
+ - **`<cli> web login --host <name>`** mints the one-time link for an edge
87
+ hostname (an edge domain or alias, a VPN name among them) at its edge
88
+ URL, instead of localhost. A name the edge doesn't route to this server
89
+ is refused, naming the hostnames it does and what to add. `--origin`
90
+ stays.
91
+
92
+ ### Migrations
93
+
94
+ - **A Den run as a service whose agents' CLI is somewhere unusual**: set
95
+ `agents.runtimes.<id>.bin` (an absolute path) in `config/local/user.yml`.
96
+ Nothing else changes: a runtime on the server's PATH is found as before.
97
+ - **A page script that called `requireUnlock()`** (header.js): it now
98
+ returns a promise of the answer; `await` it.
99
+
7
100
  ## [0.4.2] - 2026-11-06
8
101
 
9
102
  The 0.4.1 carry-overs.
@@ -43,6 +136,7 @@ The 0.4.1 carry-overs.
43
136
  that…"); there are two spaces now. `down`, `restart` and `setup --help`
44
137
  were already spaced.
45
138
 
139
+
46
140
  ### Migrations
47
141
 
48
142
  - **More than 4 plain sessions at once**, or a script that relied on
package/README.md CHANGED
@@ -10,8 +10,8 @@
10
10
  </p>
11
11
 
12
12
  <picture>
13
- <source media="(prefers-color-scheme: dark)" srcset="docs/images/0.4.1/hero-dark.png">
14
- <img src="docs/images/0.4.1/hero-light.png" width="100%" alt="Den's Graph page: the demo workspace's services grouped by application, all healthy, with the status chip and the left rail">
13
+ <source media="(prefers-color-scheme: dark)" srcset="docs/images/0.4.3/hero-dark.png">
14
+ <img src="docs/images/0.4.3/hero-light.png" width="100%" alt="Den's Graph page: the demo workspace's services grouped by application, all healthy, with the status chip and the left rail">
15
15
  </picture>
16
16
 
17
17
  ## What Den is
@@ -24,7 +24,7 @@ It doesn't replace your tools. Your repos, Docker, your test runners, your secre
24
24
  yours. Den connects them and turns on good practice from the start.
25
25
 
26
26
  <p align="center">
27
- <img src="docs/images/0.4.1/up.gif" width="100%" alt="make up in the Graph page: every stack down, then demo-api, demo-web and demo-notes come up in dependency order until the status chip reads All 3 healthy">
27
+ <img src="docs/images/0.4.3/up.gif" width="100%" alt="make up in the Graph page: every stack down, then demo-api, demo-web and demo-notes come up in dependency order until the status chip reads All 3 healthy">
28
28
  </p>
29
29
 
30
30
  ## How it helps
@@ -53,10 +53,10 @@ yours. Den connects them and turns on good practice from the start.
53
53
  lives in your own repo.
54
54
 
55
55
  <p align="center">
56
- <img src="docs/images/0.4.1/agents.png" width="49%" alt="The Agents tab: the demo workspace's sessions on the left, and one open in a terminal pane on the right, running demo-api's test and then the service">
57
- <img src="docs/images/0.4.1/workspaces.png" width="49%" alt="The Workspaces page: the demo workspace's repos with their health, mode and Start, Stop, Restart and Logs actions">
58
- <img src="docs/images/0.4.1/status-drawer.png" width="49%" alt="The status drawer: nothing needs attention, and every service in the demo workspace, by application, with its latency">
59
- <img src="docs/images/0.4.1/edge.png" width="49%" alt="The Edge page: the edge container up, its ports and TLS, and every route it serves, Den's own and each stack's, with its upstream">
56
+ <img src="docs/images/0.4.3/agents.png" width="49%" alt="The Agents tab: the demo workspace's sessions on the left, and one open in a terminal pane on the right, running demo-api's test and then the service">
57
+ <img src="docs/images/0.4.3/workspaces.png" width="49%" alt="The Workspaces page: the demo workspace's repos with their health, mode and Start, Stop, Restart and Logs actions">
58
+ <img src="docs/images/0.4.3/status-drawer.png" width="49%" alt="The status drawer: nothing needs attention, and every service in the demo workspace, by application, with its latency">
59
+ <img src="docs/images/0.4.3/edge.png" width="49%" alt="The Edge page: the edge container up, its ports and TLS, and every route it serves, Den's own and each stack's, with its upstream">
60
60
  </p>
61
61
 
62
62
  ## Get started
@@ -383,6 +383,14 @@ works; only the saved password needs `refs.sudo`.
383
383
  `configured`, `backendAvailable`, `reason` and `hasStored` (`opAvailable`
384
384
  is a deprecated alias of `backendAvailable`, kept for one minor).
385
385
 
386
+ The lock (🔒 in the top bar) is asked for only when an action needs it,
387
+ on any page: a page that thinks it's locked asks the server again first
388
+ (another tab or device may have unlocked it), then, when a password is
389
+ stored, loads it from the vault (once a page; a page view alone never
390
+ wakes the vault), and only then shows the password field. Its popover is
391
+ always on top, even over a dialog, and a dialog that needs the lock (Start
392
+ agent) asks for the password itself.
393
+
386
394
  A `1password` distribution with no refs gets one warning, in the web
387
395
  server's startup log and in `<cli> validate`, with the YAML to add.
388
396
 
@@ -1183,9 +1191,11 @@ are the same directory.
1183
1191
 
1184
1192
  On localhost nothing asks you to sign in: every page, the agents and the
1185
1193
  terminal work as they are, and the lock (R8) is the only prompt (for
1186
- privileged actions). Signing in is for when the server is reached beyond
1187
- this machine: through the edge hostname, a LAN address, or a bind that
1188
- isn't loopback (`DEN_BIND_HOST`).
1194
+ privileged actions). The edge counts as local too (0.4.3): opening
1195
+ `https://acme.acme.localhost` from this machine asks for nothing.
1196
+ Signing in is for when the server is reached from another machine: the
1197
+ edge for a client the edge says is elsewhere, a LAN address, or a bind
1198
+ that isn't loopback (`DEN_BIND_HOST`).
1189
1199
 
1190
1200
  There is no password. Reading `web.secret` (32 random bytes, mode 0600 in
1191
1201
  the state dir, made at the first `web up`) proves you are this computer's
@@ -1205,7 +1215,11 @@ user, and the CLI does that for you:
1205
1215
  within 60 seconds, and only this server's secret makes one. It is printed
1206
1216
  only on the one line you asked for (`web login`, or `web up` when it
1207
1217
  couldn't open the browser), never logged. Through the edge or a LAN name,
1208
- the cookie belongs to that name: `web login --origin https://acme.acme.localhost`
1218
+ the cookie belongs to that name: `web login --host acme.acme.localhost`
1219
+ mints the link for an edge hostname (any edge domain or alias, a VPN name
1220
+ among them; its URL comes from the edge config, and a name the edge
1221
+ doesn't route here is refused, saying what to add), and
1222
+ `web login --origin http://<lan name>:<port>` for any other allowed name
1209
1223
  (the signed-out page there shows the exact command).
1210
1224
  - **The cookie** (`den_session_<port>`) is HttpOnly, SameSite=Strict and
1211
1225
  Path=/, and Secure when the request came over HTTPS through the edge. The
@@ -1222,10 +1236,38 @@ user, and the CLI does that for you:
1222
1236
  open, and changes (an agent's start or resume and the terminal included)
1223
1237
  need only the page's own Origin and CSRF token, which the shell sends.
1224
1238
  The Host allowlist, the Origin check and the CSRF token are what stop
1225
- another site or a rebinding DNS name. Through the edge, or from another
1226
- machine, every page and call needs a signed-in browser (or a bearer). A
1227
- page there shows "Sign in to use <name>" with the command to run; it
1228
- never asks for a secret. `/api` answers 401 `signin_required`.
1239
+ another site or a rebinding DNS name. From another machine, every page
1240
+ and call needs a signed-in browser (or a bearer). A page there shows
1241
+ "Sign in to use <name>" with the command to run; it never asks for a
1242
+ secret. `/api` answers 401 `signin_required`.
1243
+ - **The edge counts as local.** A request for the edge hostname from the
1244
+ edge (an edge peer, below) is treated as loopback: no sign-in on any
1245
+ route, the agents and the terminal included. Host, Origin, CSRF and the
1246
+ agent-token rules still apply. Who the client is comes from the left-most
1247
+ `X-Forwarded-For` entry the edge sends:
1248
+ - loopback, or one of this machine's own interface addresses (a LAN
1249
+ address, or a VPN's such as a Tailscale `100.x`): local;
1250
+ - missing, or a container/VM network's address (the Docker ranges, or a
1251
+ subnet of one of this machine's Docker/OrbStack/VM bridges): Den can't
1252
+ tell (Docker Desktop and OrbStack on macOS hand the edge their
1253
+ gateway's address, and a TCP passthrough in front of the edge hides the
1254
+ client), so local;
1255
+ - any other address: another machine, which needs a sign-in.
1256
+
1257
+ With `DEN_BIND_HOST` beyond loopback (a deliberate exposure, other than
1258
+ the Linux edge's Docker bridge address) the edge needs a sign-in as
1259
+ before. With `forwardedHeaders.insecure` (the edge's default) Traefik
1260
+ keeps an `X-Forwarded-For` the client sent, so this is a convenience,
1261
+ not a boundary: whoever can reach the edge can use the terminal.
1262
+
1263
+ - **"Your edge is reachable from other machines".** When Den can tell, at
1264
+ start, that the edge is reachable beyond this machine (an edge hostname
1265
+ that resolves to an address that isn't loopback, such as a LAN or
1266
+ Tailscale address, or the edge container publishing a port on every
1267
+ interface, from `docker inspect`), it writes "Your edge is reachable from
1268
+ other machines; anyone who can reach it can use terminals. Auth is
1269
+ coming." once to `web.log`, and every page shows it as a banner (dismissed
1270
+ per browser).
1229
1271
  - **localhost is the canonical name.** A cookie set on `localhost` isn't
1230
1272
  sent to `127.0.0.1`, so the links Den prints use `localhost`.
1231
1273
  - **Browsers.** Chromium, Firefox and Safari 16.4 or later: a sign-in link
@@ -1616,6 +1658,7 @@ server scoped to its workspace.
1616
1658
  <cli> agents stop <id> [-w <ws>]
1617
1659
  <cli> agents resume <id> [-w <ws>]
1618
1660
  <cli> agents personas [--json]
1661
+ <cli> agents doctor [--json] # can each runtime run here, and why not
1619
1662
  <cli> sessions attach <ws>:<id> # its terminal; ctrl-] detaches
1620
1663
  ```
1621
1664
 
@@ -1654,7 +1697,14 @@ started. The workspace header counts them ("2 agents").
1654
1697
  not running.
1655
1698
  - **+ Start agent** picks a persona (Den's pack, then
1656
1699
  `agents.persona_packs`) and a runtime (the persona's own unless you pick
1657
- one; one that can't run here is listed, disabled), then Start.
1700
+ one; one that can't run here is listed, disabled), then Start. Under the
1701
+ runtime a line says what Start will run, with its version and path
1702
+ ("Starts Claude Code 2.1.0 from ~/.local/bin/claude"). When that runtime
1703
+ can't run here, Start is disabled and the line says why and how to fix
1704
+ it: where it looked, then "Install it, or set
1705
+ `agents.runtimes.<id>.bin` to its path in <your user.yml>, or run
1706
+ `<cli> agents doctor`" (see [Runtimes](#runtimes)). Nothing fails only
1707
+ after Start.
1658
1708
  - The page and the CLI call the same operations and read the same agent
1659
1709
  records, so an agent started in one is listed in the other. A workspace
1660
1710
  lists its own agents only.
@@ -1695,7 +1745,10 @@ agent of the workspaces in view, and their terminals in the browser.
1695
1745
  running, and opening it again replays what you missed. **Stop** (an
1696
1746
  agent only) is `agents stop`, behind the lock. **+ Start agent** (or
1697
1747
  `/agents?start=1`) starts one and opens its pane; under All it asks for
1698
- the workspace.
1748
+ the workspace. It says before Start whether the runtime can run, as the
1749
+ Workspaces page's form does. While the lock is closed, the dialog asks
1750
+ for the admin password at its top, and Start unlocks, then starts, without
1751
+ closing anything.
1699
1752
  - ⌘K lists each as "Agents › <ws> / <name>", and "Start agent…".
1700
1753
  - Several browsers and `<cli> sessions attach` can attach to one session
1701
1754
  at once and each sees every byte; the terminal's size is the last
@@ -1721,7 +1774,8 @@ An agent's environment starts empty and gets, in order:
1721
1774
 
1722
1775
  1. `PATH`, `HOME`, `USER`, `LOGNAME`, `SHELL`, `TERM` (default
1723
1776
  `xterm-256color`), `COLORTERM`, `LANG`, `LC_*`, `TZ` and `TMPDIR` from
1724
- yours;
1777
+ yours (`PATH` with the login shell's and the runtimes' install
1778
+ directories added: [Runtimes](#runtimes));
1725
1779
  2. the variables its runtime names (`envPassthrough`; den-claude:
1726
1780
  `CLAUDE_CONFIG_DIR`);
1727
1781
  3. the runtime's launch env;
@@ -1832,8 +1886,9 @@ are errors naming the file or the key.
1832
1886
 
1833
1887
  `<cli> doctor` lists the runtime kind. Two are built in:
1834
1888
 
1835
- - **`den-claude`** (the default) needs `claude` on PATH; doctor reads its
1836
- version from the install, without running it. It runs `claude --session-id
1889
+ - **`den-claude`** (the default) runs Claude Code's `claude`, found as
1890
+ below; `<cli> doctor` reads its version from the install, without running
1891
+ it. It runs `claude --session-id
1837
1892
  <id> --mcp-config <file> --strict-mcp-config --settings <file> [--model <m>]
1838
1893
  --append-system-prompt <role prompt>`, so Den's MCP server is the agent's
1839
1894
  only one, and resumes with `claude --resume <id>`. Permission prompts stay
@@ -1846,6 +1901,64 @@ are errors naming the file or the key.
1846
1901
  `{mcp_config}` is the path of the agent's MCP config, written only when the
1847
1902
  template references it. It declares no resume.
1848
1903
 
1904
+ **Where a runtime's CLI is found.** As your terminal would find it, not
1905
+ only on the web server's own PATH (a launchd service's PATH leaves out
1906
+ `~/.local/bin`, where Claude Code's native installer puts `claude`). The
1907
+ first match wins:
1908
+
1909
+ 1. **`agents.runtimes.<id>.bin`** in `config/local/user.yml`: the absolute
1910
+ path of the runtime's CLI. Paths are per machine, so it is read from
1911
+ user.yml only, never from the distribution's config. A bad value (not
1912
+ absolute, missing, not an executable file) makes the runtime
1913
+ unavailable with that reason; Den doesn't fall back to a search.
1914
+
1915
+ ```yaml
1916
+ # config/local/user.yml (gitignored)
1917
+ agents:
1918
+ runtimes:
1919
+ den-claude:
1920
+ bin: /Users/me/.local/bin/claude
1921
+ ```
1922
+
1923
+ 2. **Your login shell's PATH**: the web server asks `$SHELL -lc` once at
1924
+ start, in the background, under a 3-second timeout. On a timeout or
1925
+ any failure it is left out.
1926
+ 3. **The server's own PATH.**
1927
+ 4. **The runtime's install directories.** For den-claude: `~/.local/bin`,
1928
+ `~/.claude/local`, `/opt/homebrew/bin`, `/usr/local/bin` and npm's
1929
+ global bin (`$npm_config_prefix/bin`, and node's own directory). Den
1930
+ never runs npm to find it.
1931
+
1932
+ Sessions and agents Den starts get the same directories on their PATH
1933
+ (and a configured CLI's own directory), so the runtime's own tools are
1934
+ found too. Two engine variables override the search for tests and
1935
+ diagnosis: `DEN_LOGIN_SHELL` (the shell to ask instead of `$SHELL`; `off`
1936
+ for none) and `DEN_RUNTIME_DIRS` (a PATH-style list that replaces every
1937
+ runtime's install directories; empty for none).
1938
+
1939
+ **`<cli> agents doctor`** prints, for each runtime, its status, the CLI it
1940
+ runs and where that came from (the `agents.runtimes` setting, the login
1941
+ shell's PATH, PATH, or an install directory), its version (`claude
1942
+ --version`, run once under a timeout), the directories it searched (names
1943
+ only, never an environment value), and the fix when it's missing. It
1944
+ resolves the login shell's PATH first, as the web server does. It exits 1
1945
+ when the default runtime is unavailable; `--json` prints what the Start
1946
+ dialog gets.
1947
+
1948
+ ```
1949
+ den-claude (Claude Code) [default]
1950
+ status ✗ unavailable: claude not found (searched 5 directories)
1951
+ searched /usr/bin
1952
+ /bin
1953
+ …
1954
+ fix install Claude Code, or set agents.runtimes.den-claude.bin to its absolute path in /…/config/local/user.yml
1955
+ then run `<cli> agents doctor` again
1956
+ ```
1957
+
1958
+ `GET /api/personas` gives each runtime its `version`, `path`, `source`
1959
+ (`setting`, `login-shell`, `well-known` or `PATH`), `searched` and
1960
+ `setting` (the user.yml key), and `userConfig`, the user.yml to edit.
1961
+
1849
1962
  ### Workspace scope and grants
1850
1963
 
1851
1964
  An agent's MCP config, `<stateDir>/agent-files/<id>/mcp.json` (0600), runs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monster-orange/den",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,27 +28,50 @@
28
28
  * session Den starts (an agent is not a child of the session that
29
29
  * started it).
30
30
  *
31
- * detect runs nothing: `claude` on PATH, and its version read from where it
32
- * is installed (a native install's versions/<version>, or the npm package's
33
- * package.json).
31
+ * detect runs nothing (AG-0.1, runtime-search.ts): agents.runtimes.den-claude.bin
32
+ * in user.yml when set, else `claude` on the login shell's PATH, the
33
+ * caller's PATH, then its install directories (~/.local/bin, the native
34
+ * installer's; ~/.claude/local; /opt/homebrew/bin; /usr/local/bin; npm's
35
+ * global bin). Its version is read from where it is installed (a native
36
+ * install's versions/<version>, or the npm package's package.json); the
37
+ * Start dialog and `agents doctor` ask `claude --version` once as well.
34
38
  */
35
39
  import { randomUUID } from "crypto";
36
40
  import * as fs from "fs";
37
41
  import * as path from "path";
42
+ import { configuredRuntimeBin } from "../den-config.js";
38
43
  import { rolePrompt } from "./personas.js";
39
44
  import {
40
45
  LaunchError,
41
46
  mcpConfigJson,
42
47
  mcpConfigPath,
43
- whichBin,
48
+ type DetectOptions,
44
49
  type LaunchInput,
45
50
  type LaunchOutput,
46
51
  type Runtime,
47
52
  type RuntimeDetect,
48
53
  } from "./runtime.js";
54
+ import {
55
+ locateRuntime,
56
+ systemBinDirs,
57
+ type RuntimeFind,
58
+ } from "./runtime-search.js";
49
59
 
50
60
  export const DEN_CLAUDE_ID = "den-claude";
51
61
  const BIN = "claude";
62
+
63
+ /** Where Claude Code is found, and how its version is asked. */
64
+ export const DEN_CLAUDE_FIND: RuntimeFind = {
65
+ bins: [BIN],
66
+ dirs: (home, env) => [
67
+ // The native installer's, then the older local install's.
68
+ ...(home
69
+ ? [path.join(home, ".local", "bin"), path.join(home, ".claude", "local")]
70
+ : []),
71
+ ...systemBinDirs(env),
72
+ ],
73
+ versionArgs: ["--version"],
74
+ };
52
75
  const PACKAGE = "@anthropic-ai/claude-code";
53
76
  const SEMVER = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
54
77
 
@@ -97,11 +120,29 @@ export function installedVersion(bin: string): string | undefined {
97
120
  return undefined;
98
121
  }
99
122
 
100
- function detect(env: NodeJS.ProcessEnv = process.env): RuntimeDetect {
101
- const bin = whichBin(BIN, env);
102
- if (!bin) return { available: false, why: `${BIN} is not on PATH` };
103
- const version = installedVersion(bin);
104
- return version ? { available: true, version } : { available: true };
123
+ function detect(
124
+ env: NodeJS.ProcessEnv = process.env,
125
+ opts: DetectOptions = {},
126
+ ): RuntimeDetect {
127
+ const setting =
128
+ opts.bin === undefined ? configuredRuntimeBin(DEN_CLAUDE_ID) : opts.bin;
129
+ const found = locateRuntime(DEN_CLAUDE_ID, DEN_CLAUDE_FIND, env, setting);
130
+ if (!found.available || !found.path)
131
+ return {
132
+ available: false,
133
+ why: found.why ?? `${BIN} not found`,
134
+ // "setting": a bad agents.runtimes.<id>.bin is the reason, not the search.
135
+ ...(found.source ? { source: found.source } : {}),
136
+ searched: found.searched,
137
+ };
138
+ const version = installedVersion(found.path);
139
+ return {
140
+ available: true,
141
+ ...(version ? { version } : {}),
142
+ path: found.path,
143
+ ...(found.source ? { source: found.source } : {}),
144
+ searched: found.searched,
145
+ };
105
146
  }
106
147
 
107
148
  /**
@@ -231,13 +272,17 @@ function common(input: LaunchInput): {
231
272
  }
232
273
 
233
274
  function binFor(input: LaunchInput): string {
234
- const bin = whichBin(BIN, input.env);
235
- if (!bin) throw new LaunchError(`${DEN_CLAUDE_ID}: ${BIN} is not on PATH`);
236
- return bin;
275
+ if (input.bin) return input.bin;
276
+ const d = detect(input.env);
277
+ if (!d.available || !d.path)
278
+ throw new LaunchError(`${DEN_CLAUDE_ID}: ${d.why ?? `${BIN} not found`}`);
279
+ return d.path;
237
280
  }
238
281
 
239
282
  export const denClaude: Runtime = {
240
283
  id: DEN_CLAUDE_ID,
284
+ label: "Claude Code",
285
+ find: DEN_CLAUDE_FIND,
241
286
  detect,
242
287
  launch(input): LaunchOutput {
243
288
  const bin = binFor(input);
@@ -20,6 +20,7 @@ export const GENERIC_ID = "generic";
20
20
 
21
21
  export const generic: Runtime = {
22
22
  id: GENERIC_ID,
23
+ label: "Generic command",
23
24
  // The CLI is the persona's; there is nothing to look for until launch.
24
25
  detect: () => ({ available: true }),
25
26
  scrubEnv: [],