@monster-orange/den 0.4.3 → 0.4.5

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.
Files changed (85) hide show
  1. package/CHANGELOG.md +348 -0
  2. package/README.md +12 -11
  3. package/docs/distributions.md +438 -91
  4. package/docs/reference.md +8 -3
  5. package/package.json +1 -1
  6. package/src/shared/agents/catalog-runtime.ts +178 -0
  7. package/src/shared/agents/catalog.ts +343 -0
  8. package/src/shared/agents/den-claude.ts +14 -18
  9. package/src/shared/agents/runtime-search.ts +142 -15
  10. package/src/shared/agents/runtime.ts +18 -1
  11. package/src/shared/agents/runtimes.ts +23 -1
  12. package/src/shared/auth/sudo-vault.ts +131 -11
  13. package/src/shared/cli-command.ts +186 -0
  14. package/src/shared/confirm-code.ts +34 -0
  15. package/src/shared/den-config.ts +64 -3
  16. package/src/shared/dev-metrics/emit.ts +31 -1
  17. package/src/shared/edge/config.ts +39 -0
  18. package/src/shared/edge/render.ts +92 -18
  19. package/src/shared/edge/runtime.ts +57 -1
  20. package/src/shared/edge/status.ts +134 -0
  21. package/src/shared/edge/types.ts +11 -1
  22. package/src/shared/environment-manager/health.ts +82 -8
  23. package/src/shared/environment-manager/workspaces.ts +54 -0
  24. package/src/shared/github-app.ts +94 -0
  25. package/src/shared/github-device.ts +286 -0
  26. package/src/shared/health/not-checked.ts +42 -0
  27. package/src/shared/health/rollup.ts +4 -0
  28. package/src/shared/process/spawn-streaming.ts +8 -3
  29. package/src/shared/secrets/onepassword.ts +27 -4
  30. package/src/shared/sessions/client.ts +5 -1
  31. package/src/shared/sessions/host.ts +29 -1
  32. package/src/shared/sessions/protocol.ts +6 -1
  33. package/src/shared/sessions/types.ts +6 -0
  34. package/src/shared/stacks/status.ts +24 -0
  35. package/src/shared/strategies/registry.ts +5 -0
  36. package/src/shared/types/index.ts +6 -0
  37. package/src/shared/web-owner.ts +91 -0
  38. package/src/shared/web-session.ts +135 -1
  39. package/src/ui/cli/agent-ops.ts +210 -19
  40. package/src/ui/cli/agents-command.ts +50 -21
  41. package/src/ui/cli/edge-command.ts +16 -3
  42. package/src/ui/cli/flag-parser.ts +1 -1
  43. package/src/ui/cli/health-display.ts +4 -1
  44. package/src/ui/cli/web-command.ts +512 -33
  45. package/src/ui/mcp/scope.ts +23 -3
  46. package/src/ui/mcp/server.ts +31 -7
  47. package/src/ui/mcp/tools.ts +327 -20
  48. package/src/ui/mcp/views.ts +450 -0
  49. package/src/ui/server/agent-routes.ts +118 -28
  50. package/src/ui/server/device-flows.ts +274 -0
  51. package/src/ui/server/edge-exposure.ts +16 -4
  52. package/src/ui/server/github-routes.ts +321 -0
  53. package/src/ui/server/http-errors.ts +10 -0
  54. package/src/ui/server/index.ts +540 -33
  55. package/src/ui/server/login-waits.ts +203 -0
  56. package/src/ui/server/pair-requests.ts +237 -0
  57. package/src/ui/server/pair-routes.ts +196 -0
  58. package/src/ui/server/public/agent-start.js +46 -7
  59. package/src/ui/server/public/agents-page.js +232 -97
  60. package/src/ui/server/public/agents-section.js +3 -5
  61. package/src/ui/server/public/agents.html +12 -28
  62. package/src/ui/server/public/error-page.js +452 -40
  63. package/src/ui/server/public/error.html +59 -0
  64. package/src/ui/server/public/graph-model.js +2 -0
  65. package/src/ui/server/public/header.js +2 -3
  66. package/src/ui/server/public/health-rollup.js +20 -8
  67. package/src/ui/server/public/i18n/es.js +89 -21
  68. package/src/ui/server/public/i18n/ja.js +88 -21
  69. package/src/ui/server/public/index.html +17 -4
  70. package/src/ui/server/public/local-dev.html +44 -21
  71. package/src/ui/server/public/qr.js +317 -0
  72. package/src/ui/server/public/services.html +21 -5
  73. package/src/ui/server/public/settings-page.js +10 -2
  74. package/src/ui/server/public/settings-registry.js +617 -1
  75. package/src/ui/server/public/settings.html +90 -1
  76. package/src/ui/server/public/shell.js +151 -33
  77. package/src/ui/server/public/topbar.js +170 -206
  78. package/src/ui/server/public/workspace-picker.js +1 -1
  79. package/src/ui/server/readonly-routes.ts +29 -5
  80. package/src/ui/server/request-guard.ts +103 -36
  81. package/src/ui/server/route-classes.ts +116 -4
  82. package/src/ui/server/session-routes.ts +9 -1
  83. package/src/ui/server/sudo-store.ts +170 -6
  84. package/src/ui/server/ui-assets.ts +2 -0
  85. package/src/ui/server/workspace-routes.ts +10 -3
package/docs/reference.md CHANGED
@@ -110,9 +110,14 @@ That is `examples/demo/demo-api/.den.yml`; the other demo repos are in
110
110
  workspace's checkout, with an MCP server pinned to that workspace and a
111
111
  token of its own. The web UI's Agents tab lists them and opens their
112
112
  terminals.
113
- - **Browser sign-in beyond loopback.** On localhost no route needs a sign-in.
114
- Through the edge or on a non-loopback bind, every route needs one:
115
- `<cli> web login` prints a one-time link (`web open` opens it).
113
+ - **Browser sign-in beyond loopback.** On localhost no route needs a sign-in,
114
+ nor through the edge when it shows the browser is on this machine.
115
+ Anywhere else, every route needs one: Sign in with GitHub as the Den's
116
+ owner (`<cli> web owner link` links the account once), approving the
117
+ computer with the code its page shows (`<cli> web approve <code>`, or
118
+ Settings › Access › Waiting devices), or a one-time link (`<cli> web
119
+ login`, which waits until it's used; `web open` opens it; Settings ›
120
+ Access pairs a device with a QR code). The rail's chip shows who you are.
116
121
  - **Web UI and CLI** over the same state.
117
122
 
118
123
  ## Strategies
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monster-orange/den",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,178 @@
1
+ /**
2
+ * A runtime from a catalog entry (AG-0.5, catalog.ts): found by AG-0.1's
3
+ * search with the entry's binary names and install directories, and
4
+ * launched as its interactive CLI in the agent's directory (the entry's
5
+ * launch argv, "{bin}" the path found).
6
+ *
7
+ * These don't get Den's tools yet: no MCP config, no permission mode
8
+ * (AG-12 and AG-13 wire them per runtime). Lists tag them "Den tools:
9
+ * coming" (denTools: false). Claude Code is den-claude, which takes its
10
+ * search from the catalog and keeps its own launch.
11
+ *
12
+ * checkRuntimeBin is what "Set path…" runs: the entry's version command,
13
+ * and the entry's identity (its output names it, it resolves into the
14
+ * vendor's npm package, or under the vendor's install directory), so a
15
+ * path that isn't that runtime is refused.
16
+ */
17
+ import * as fs from "fs";
18
+ import * as path from "path";
19
+ import { configuredRuntimeBin } from "../den-config.js";
20
+ import { entryDirs, type CatalogEntry } from "./catalog.js";
21
+ import {
22
+ LaunchError,
23
+ type DetectOptions,
24
+ type LaunchOutput,
25
+ type Runtime,
26
+ type RuntimeDetect,
27
+ } from "./runtime.js";
28
+ import {
29
+ locateRuntime,
30
+ parseVersion,
31
+ systemBinDirs,
32
+ versionOutput,
33
+ type RuntimeFind,
34
+ } from "./runtime-search.js";
35
+
36
+ /** An entry as AG-0.1's search takes it. */
37
+ export function catalogFind(e: CatalogEntry): RuntimeFind {
38
+ return {
39
+ bins: e.bins,
40
+ dirs: (home, env) => entryDirs(e, home, env, systemBinDirs),
41
+ versionArgs: e.version.args,
42
+ };
43
+ }
44
+
45
+ /** Find an entry's CLI: a setting (workspace or global), else the search. */
46
+ export function detectEntry(
47
+ e: CatalogEntry,
48
+ find: RuntimeFind,
49
+ env: NodeJS.ProcessEnv,
50
+ opts: DetectOptions,
51
+ ): RuntimeDetect {
52
+ const setting =
53
+ opts.bin === undefined ? configuredRuntimeBin(e.id) : opts.bin;
54
+ const found = locateRuntime(e.id, find, env, setting);
55
+ if (!found.available || !found.path)
56
+ return {
57
+ available: false,
58
+ why: found.why ?? `${e.bins.join(" or ")} not found`,
59
+ ...(found.source ? { source: found.source } : {}),
60
+ searched: found.searched,
61
+ };
62
+ return {
63
+ available: true,
64
+ path: found.path,
65
+ ...(found.source ? { source: found.source } : {}),
66
+ searched: found.searched,
67
+ };
68
+ }
69
+
70
+ /** The launch argv: the entry's template, "{bin}" the path found. */
71
+ export function launchArgv(e: CatalogEntry, bin: string): string[] {
72
+ return e.launch.argv.map((a) => a.split("{bin}").join(bin));
73
+ }
74
+
75
+ /** A runtime for a catalog entry other than Claude Code (den-claude). */
76
+ export function catalogRuntime(e: CatalogEntry): Runtime {
77
+ const find = catalogFind(e);
78
+ const detect = (
79
+ env: NodeJS.ProcessEnv = process.env,
80
+ opts: DetectOptions = {},
81
+ ) => detectEntry(e, find, env, opts);
82
+ return {
83
+ id: e.id,
84
+ label: e.name,
85
+ vendor: e.vendor,
86
+ catalog: e,
87
+ find,
88
+ denTools: false,
89
+ detect,
90
+ launch(input): LaunchOutput {
91
+ let bin = input.bin;
92
+ if (!bin) {
93
+ const d = detect(input.env);
94
+ if (!d.available || !d.path)
95
+ throw new LaunchError(`${e.id}: ${d.why ?? "not found"}`);
96
+ bin = d.path;
97
+ }
98
+ return {
99
+ argv: launchArgv(e, bin),
100
+ env: {},
101
+ warnings: [
102
+ `${e.name} runs as its own CLI in ${input.worktree}: Den's tools (MCP) and permission modes are coming for it`,
103
+ ],
104
+ };
105
+ },
106
+ approvals: "terminal",
107
+ scrubEnv: [],
108
+ };
109
+ }
110
+
111
+ /** The npm package a file resolves into (the nearest package.json's name). */
112
+ function packageOf(real: string): string | undefined {
113
+ for (let dir = path.dirname(real), i = 0; i < 8; i++) {
114
+ try {
115
+ const pkg = JSON.parse(
116
+ fs.readFileSync(path.join(dir, "package.json"), "utf-8"),
117
+ ) as { name?: unknown };
118
+ if (typeof pkg.name === "string") return pkg.name;
119
+ } catch {
120
+ /* not here */
121
+ }
122
+ const up = path.dirname(dir);
123
+ if (up === dir) break;
124
+ dir = up;
125
+ }
126
+ return undefined;
127
+ }
128
+
129
+ export type BinCheck =
130
+ | { ok: true; path: string; version: string }
131
+ | { ok: false; why: string };
132
+
133
+ /**
134
+ * Whether `bin` is entry `e`'s runtime: an absolute path to an executable
135
+ * file whose version command exits 0 and prints a version, and whose
136
+ * output, npm package or real location names it. Runs only that command
137
+ * (stdin closed, under a timeout). Never throws.
138
+ */
139
+ export async function checkRuntimeBin(
140
+ e: CatalogEntry,
141
+ bin: string,
142
+ env: NodeJS.ProcessEnv,
143
+ ): Promise<BinCheck> {
144
+ const p = bin.trim();
145
+ const shown = p.replace(/[^\x20-\x7e]/g, "?").slice(0, 200);
146
+ if (!path.isAbsolute(p))
147
+ return { ok: false, why: `"${shown}" is not an absolute path` };
148
+ let real: string;
149
+ try {
150
+ const st = fs.statSync(p);
151
+ fs.accessSync(p, fs.constants.X_OK);
152
+ if (!st.isFile()) throw new Error("not a file");
153
+ real = fs.realpathSync(p);
154
+ } catch {
155
+ return { ok: false, why: `${shown} is not an executable file` };
156
+ }
157
+ const cmd = `${path.basename(p)} ${e.version.args.join(" ")}`;
158
+ const r = await versionOutput(p, e.version.args, env);
159
+ if (!r.ok) return { ok: false, why: `\`${cmd}\` failed: not ${e.name}` };
160
+ const version = parseVersion(r.out);
161
+ if (!version)
162
+ return { ok: false, why: `\`${cmd}\` printed no version: not ${e.name}` };
163
+ const idn = e.version.identity;
164
+ const home = env.HOME && path.isAbsolute(env.HOME) ? env.HOME : undefined;
165
+ const under = (idn.realpath_under ?? []).flatMap((d) =>
166
+ d.startsWith("~/") ? (home ? [path.join(home, d.slice(2))] : []) : [d],
167
+ );
168
+ const named =
169
+ (idn.output !== undefined && new RegExp(idn.output, "i").test(r.out)) ||
170
+ (idn.package !== undefined && packageOf(real) === idn.package) ||
171
+ under.some((d) => real === d || real.startsWith(d + path.sep));
172
+ if (!named)
173
+ return {
174
+ ok: false,
175
+ why: `${shown} is not ${e.name}: its version output, package and location don't name it`,
176
+ };
177
+ return { ok: true, path: p, version };
178
+ }
@@ -0,0 +1,343 @@
1
+ /**
2
+ * The runtime catalog (AG-0.5): every vendor CLI Den knows how to find,
3
+ * shipped as data with each release. An entry says:
4
+ *
5
+ * id the runtime's id ("den-claude", "codex")
6
+ * vendor who makes it ("Anthropic")
7
+ * name its name for people ("Claude Code")
8
+ * bins its executable names, in order
9
+ * version args: what prints its version ("--version"); identity: how
10
+ * a path is known to be this runtime, not just any program that
11
+ * prints a version (its output names it, it lives in the
12
+ * vendor's npm package, or under the vendor's install dir).
13
+ * A path someone sets is refused unless one of these holds.
14
+ * dirs its well-known install directories: "~/…", an absolute
15
+ * path, "$NAME" (an install-dir variable the vendor's installer
16
+ * honours; skipped when unset) or "{system}" (Homebrew's,
17
+ * /usr/local/bin and npm's global bin)
18
+ * launch argv: its interactive CLI, "{bin}" first (the path found)
19
+ *
20
+ * Each built-in entry was checked against the vendor's own docs or
21
+ * installer (the URLs beside it). A vendor with no official CLI is left
22
+ * out (docs/distributions.md, Runtimes, says which, and why); nothing is
23
+ * guessed.
24
+ *
25
+ * A distribution (`agents.runtime_catalog` in its config) and a person
26
+ * (`agents.runtime_catalog` in config/local/user.yml) add entries, checked
27
+ * by the same validator (validateCatalogEntry). An entry can't replace a
28
+ * built-in one: a path is set with agents.runtimes.<id>.bin instead.
29
+ */
30
+ import * as path from "path";
31
+
32
+ export interface CatalogIdentity {
33
+ /** A case-insensitive pattern the version command's output matches. */
34
+ output?: string;
35
+ /** The npm package the binary resolves into (its package.json name). */
36
+ package?: string;
37
+ /** Directories ("~/…" or absolute) the binary resolves under. */
38
+ realpath_under?: string[];
39
+ }
40
+
41
+ export interface CatalogEntry {
42
+ id: string;
43
+ vendor: string;
44
+ name: string;
45
+ bins: string[];
46
+ version: { args: string[]; identity: CatalogIdentity };
47
+ dirs: string[];
48
+ launch: { argv: string[] };
49
+ }
50
+
51
+ /** The keys an entry takes (the validator refuses any other). */
52
+ const ENTRY_KEYS = [
53
+ "id",
54
+ "vendor",
55
+ "name",
56
+ "bins",
57
+ "version",
58
+ "dirs",
59
+ "launch",
60
+ ] as const;
61
+
62
+ /** Den's built-in catalog, in the order lists show them. */
63
+ export const BUILTIN_CATALOG: readonly CatalogEntry[] = [
64
+ // Claude Code. https://code.claude.com/docs/en/setup: the native
65
+ // installer's launcher is ~/.local/bin/claude; `claude --version` prints
66
+ // "2.1.211 (Claude Code)"; npm package @anthropic-ai/claude-code;
67
+ // Homebrew cask claude-code. ~/.claude/local is the older local install.
68
+ {
69
+ id: "den-claude",
70
+ vendor: "Anthropic",
71
+ name: "Claude Code",
72
+ bins: ["claude"],
73
+ version: {
74
+ args: ["--version"],
75
+ identity: {
76
+ output: "\\(Claude Code\\)",
77
+ package: "@anthropic-ai/claude-code",
78
+ realpath_under: ["~/.local/share/claude"],
79
+ },
80
+ },
81
+ dirs: ["~/.local/bin", "~/.claude/local", "{system}"],
82
+ launch: { argv: ["{bin}"] },
83
+ },
84
+ // Codex CLI. https://github.com/openai/codex: binary `codex`, npm
85
+ // @openai/codex, Homebrew cask codex. The standalone installer
86
+ // (https://chatgpt.com/codex/install.sh → releases.openai.com) installs
87
+ // to ${CODEX_INSTALL_DIR:-$HOME/.local/bin}/codex and reads the version
88
+ // from `codex --version`; the CLI declares clap's version flag
89
+ // (codex-rs/cli/src/main.rs, bin_name "codex").
90
+ {
91
+ id: "codex",
92
+ vendor: "OpenAI",
93
+ name: "Codex CLI",
94
+ bins: ["codex"],
95
+ version: {
96
+ args: ["--version"],
97
+ identity: { output: "codex", package: "@openai/codex" },
98
+ },
99
+ dirs: ["$CODEX_INSTALL_DIR", "~/.local/bin", "{system}"],
100
+ launch: { argv: ["{bin}"] },
101
+ },
102
+ // Gemini CLI. https://github.com/google-gemini/gemini-cli: binary
103
+ // `gemini`, npm @google/gemini-cli, Homebrew formula gemini-cli;
104
+ // docs/cli/cli-reference.md: `--version`/`-v` "Show CLI version number
105
+ // and exit" (the number only, so the package names it), and no
106
+ // arguments starts the interactive REPL.
107
+ {
108
+ id: "gemini",
109
+ vendor: "Google",
110
+ name: "Gemini CLI",
111
+ bins: ["gemini"],
112
+ version: {
113
+ args: ["--version"],
114
+ identity: { output: "gemini", package: "@google/gemini-cli" },
115
+ },
116
+ dirs: ["{system}"],
117
+ launch: { argv: ["{bin}"] },
118
+ },
119
+ // Grok Build. https://docs.x.ai/build/overview: install with
120
+ // https://x.ai/cli/install.sh, run `grok` in a project directory. The
121
+ // installer puts the binary in ~/.grok/downloads, links it as
122
+ // ${GROK_BIN_DIR:-$HOME/.grok/bin}/grok (and into ~/.local/bin or
123
+ // /usr/local/bin when that dir isn't on PATH), and checks it with
124
+ // `grok --version`.
125
+ {
126
+ id: "grok",
127
+ vendor: "xAI",
128
+ name: "Grok Build",
129
+ bins: ["grok"],
130
+ version: {
131
+ args: ["--version"],
132
+ identity: { output: "grok", realpath_under: ["~/.grok"] },
133
+ },
134
+ dirs: ["$GROK_BIN_DIR", "~/.grok/bin", "~/.local/bin", "/usr/local/bin"],
135
+ launch: { argv: ["{bin}"] },
136
+ },
137
+ ];
138
+
139
+ /** Den's own runtime ids that a catalog entry can't take. */
140
+ const RESERVED_IDS = ["generic"];
141
+ const ID_RE = /^[a-z][a-z0-9-]{0,31}$/;
142
+ const BIN_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
143
+ const ENV_DIR_RE = /^\$[A-Z_][A-Z0-9_]*$/;
144
+ const PKG_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
145
+
146
+ const isMapping = (v: unknown): v is Record<string, unknown> =>
147
+ v !== null && typeof v === "object" && !Array.isArray(v);
148
+ const shown = (v: unknown) =>
149
+ JSON.stringify(v)
150
+ ?.replace(/[^\x20-\x7e]/g, "?")
151
+ .slice(0, 60) ?? "undefined";
152
+
153
+ function text(where: string, v: unknown, max = 60): string {
154
+ if (typeof v !== "string" || !v.trim() || v.length > max)
155
+ throw new Error(`${where}: must be a name of 1-${max} characters`);
156
+ return v.trim();
157
+ }
158
+
159
+ function list(
160
+ where: string,
161
+ v: unknown,
162
+ each: (x: unknown, at: string) => string,
163
+ { min = 1, max = 16 } = {},
164
+ ): string[] {
165
+ if (!Array.isArray(v) || v.length < min || v.length > max)
166
+ throw new Error(`${where}: must be a list of ${min}-${max} entries`);
167
+ return v.map((x, i) => each(x, `${where}[${i}]`));
168
+ }
169
+
170
+ function dirOk(at: string, x: unknown): string {
171
+ if (
172
+ typeof x === "string" &&
173
+ (x === "{system}" ||
174
+ ENV_DIR_RE.test(x) ||
175
+ (x.startsWith("~/") && !x.includes("..")) ||
176
+ (path.isAbsolute(x) && !x.includes("..")))
177
+ )
178
+ return x;
179
+ throw new Error(
180
+ `${at}: ${shown(x)} must be "~/…", an absolute path, "$NAME" or "{system}"`,
181
+ );
182
+ }
183
+
184
+ /**
185
+ * One catalog entry, checked; throws naming the key (`where`, e.g.
186
+ * "agents.runtime_catalog[0]"). Never guesses a missing field.
187
+ */
188
+ export function validateCatalogEntry(
189
+ raw: unknown,
190
+ where: string,
191
+ ): CatalogEntry {
192
+ if (!isMapping(raw)) throw new Error(`${where}: must be a mapping`);
193
+ for (const k of Object.keys(raw))
194
+ if (!(ENTRY_KEYS as readonly string[]).includes(k))
195
+ throw new Error(
196
+ `${where}.${k}: unknown key (an entry takes ${ENTRY_KEYS.join(", ")})`,
197
+ );
198
+ const id = raw.id;
199
+ if (typeof id !== "string" || !ID_RE.test(id) || RESERVED_IDS.includes(id))
200
+ throw new Error(
201
+ `${where}.id: ${shown(id)} must be lowercase letters, digits and "-" (1-32), not ${RESERVED_IDS.join(", ")}`,
202
+ );
203
+ const bins = list(`${where}.bins`, raw.bins, (x, at) => {
204
+ if (typeof x === "string" && BIN_RE.test(x)) return x;
205
+ throw new Error(`${at}: ${shown(x)} must be an executable's name, no path`);
206
+ });
207
+ const version = raw.version;
208
+ if (!isMapping(version))
209
+ throw new Error(`${where}.version: must be a mapping { args, identity }`);
210
+ for (const k of Object.keys(version))
211
+ if (k !== "args" && k !== "identity")
212
+ throw new Error(
213
+ `${where}.version.${k}: unknown key (version takes args, identity)`,
214
+ );
215
+ const args = list(
216
+ `${where}.version.args`,
217
+ version.args,
218
+ (x, at) => {
219
+ if (typeof x === "string" && x.length <= 40 && !/[\0\n]/.test(x))
220
+ return x;
221
+ throw new Error(`${at}: must be an argument of up to 40 characters`);
222
+ },
223
+ { min: 1, max: 4 },
224
+ );
225
+ const idn = version.identity;
226
+ if (!isMapping(idn))
227
+ throw new Error(
228
+ `${where}.version.identity: must be a mapping with output, package or realpath_under`,
229
+ );
230
+ const identity: CatalogIdentity = {};
231
+ for (const [k, v] of Object.entries(idn)) {
232
+ const at = `${where}.version.identity.${k}`;
233
+ if (k === "output") {
234
+ if (typeof v !== "string" || !v || v.length > 100)
235
+ throw new Error(`${at}: must be a pattern of 1-100 characters`);
236
+ try {
237
+ new RegExp(v, "i");
238
+ } catch {
239
+ throw new Error(`${at}: ${shown(v)} is not a valid pattern`);
240
+ }
241
+ identity.output = v;
242
+ } else if (k === "package") {
243
+ if (typeof v !== "string" || !PKG_RE.test(v))
244
+ throw new Error(`${at}: ${shown(v)} is not an npm package name`);
245
+ identity.package = v;
246
+ } else if (k === "realpath_under") {
247
+ identity.realpath_under = list(at, v, (x, a) => dirOk(a, x)).map((d) => {
248
+ if (d === "{system}" || d.startsWith("$"))
249
+ throw new Error(`${at}: ${shown(d)} must be "~/…" or absolute`);
250
+ return d;
251
+ });
252
+ } else
253
+ throw new Error(
254
+ `${at}: unknown key (identity takes output, package, realpath_under)`,
255
+ );
256
+ }
257
+ if (!identity.output && !identity.package && !identity.realpath_under)
258
+ throw new Error(
259
+ `${where}.version.identity: needs output, package or realpath_under (how a path is known to be ${id})`,
260
+ );
261
+ const dirs = list(`${where}.dirs`, raw.dirs, (x, at) => dirOk(at, x), {
262
+ min: 0,
263
+ max: 16,
264
+ });
265
+ const launch = raw.launch;
266
+ if (!isMapping(launch) || Object.keys(launch).some((k) => k !== "argv"))
267
+ throw new Error(`${where}.launch: must be a mapping { argv: [...] }`);
268
+ const argv = list(
269
+ `${where}.launch.argv`,
270
+ launch.argv,
271
+ (x, at) => {
272
+ if (typeof x === "string" && x.length <= 200 && !/[\0\n]/.test(x))
273
+ return x;
274
+ throw new Error(`${at}: must be an argument of up to 200 characters`);
275
+ },
276
+ { min: 1, max: 16 },
277
+ );
278
+ if (argv[0] !== "{bin}")
279
+ throw new Error(
280
+ `${where}.launch.argv[0]: must be "{bin}" (the path Den found)`,
281
+ );
282
+ return {
283
+ id,
284
+ vendor: text(`${where}.vendor`, raw.vendor),
285
+ name: text(`${where}.name`, raw.name),
286
+ bins,
287
+ version: { args, identity },
288
+ dirs,
289
+ launch: { argv },
290
+ };
291
+ }
292
+
293
+ /**
294
+ * Extra entries (a distribution's, then user.yml's), checked; an id that
295
+ * is already in the catalog (a built-in, or an earlier entry) is refused.
296
+ */
297
+ export function validateCatalog(
298
+ raw: unknown,
299
+ where: string,
300
+ taken: readonly string[] = BUILTIN_CATALOG.map((e) => e.id),
301
+ ): CatalogEntry[] {
302
+ if (raw === undefined || raw === null) return [];
303
+ if (!Array.isArray(raw))
304
+ throw new Error(`${where}: must be a list of runtime entries`);
305
+ const ids = [...taken];
306
+ return raw.map((x, i) => {
307
+ const e = validateCatalogEntry(x, `${where}[${i}]`);
308
+ if (ids.includes(e.id))
309
+ throw new Error(
310
+ `${where}[${i}].id: "${e.id}" is already in the catalog (set its path with agents.runtimes.${e.id}.bin instead)`,
311
+ );
312
+ ids.push(e.id);
313
+ return e;
314
+ });
315
+ }
316
+
317
+ /**
318
+ * An entry's install directories on this machine: "~/" from HOME, "$NAME"
319
+ * from the env (left out when unset), "{system}" as `system` gives them.
320
+ */
321
+ export function entryDirs(
322
+ e: Pick<CatalogEntry, "dirs">,
323
+ home: string | undefined,
324
+ env: NodeJS.ProcessEnv,
325
+ system: (env: NodeJS.ProcessEnv) => string[],
326
+ ): string[] {
327
+ const out: string[] = [];
328
+ for (const d of e.dirs) {
329
+ if (d === "{system}") out.push(...system(env));
330
+ else if (d.startsWith("$")) {
331
+ const v = env[d.slice(1)];
332
+ if (v && path.isAbsolute(v)) out.push(v);
333
+ } else if (d.startsWith("~/")) {
334
+ if (home) out.push(path.join(home, d.slice(2)));
335
+ } else out.push(d);
336
+ }
337
+ return out;
338
+ }
339
+
340
+ /** A built-in entry by id. */
341
+ export function builtinEntry(id: string): CatalogEntry | undefined {
342
+ return BUILTIN_CATALOG.find((e) => e.id === id);
343
+ }
@@ -51,27 +51,20 @@ import {
51
51
  type Runtime,
52
52
  type RuntimeDetect,
53
53
  } from "./runtime.js";
54
- import {
55
- locateRuntime,
56
- systemBinDirs,
57
- type RuntimeFind,
58
- } from "./runtime-search.js";
54
+ import { builtinEntry } from "./catalog.js";
55
+ import { catalogFind } from "./catalog-runtime.js";
56
+ import { locateRuntime, type RuntimeFind } from "./runtime-search.js";
59
57
 
60
58
  export const DEN_CLAUDE_ID = "den-claude";
61
59
  const BIN = "claude";
62
60
 
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
- };
61
+ /**
62
+ * Where Claude Code is found, and how its version is asked: its catalog
63
+ * entry (catalog.ts): the native installer's ~/.local/bin, the older
64
+ * local install's ~/.claude/local, Homebrew's, /usr/local/bin, npm's.
65
+ */
66
+ export const DEN_CLAUDE_CATALOG = builtinEntry(DEN_CLAUDE_ID)!;
67
+ export const DEN_CLAUDE_FIND: RuntimeFind = catalogFind(DEN_CLAUDE_CATALOG);
75
68
  const PACKAGE = "@anthropic-ai/claude-code";
76
69
  const SEMVER = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
77
70
 
@@ -281,7 +274,10 @@ function binFor(input: LaunchInput): string {
281
274
 
282
275
  export const denClaude: Runtime = {
283
276
  id: DEN_CLAUDE_ID,
284
- label: "Claude Code",
277
+ label: DEN_CLAUDE_CATALOG.name,
278
+ vendor: DEN_CLAUDE_CATALOG.vendor,
279
+ catalog: DEN_CLAUDE_CATALOG,
280
+ denTools: true,
285
281
  find: DEN_CLAUDE_FIND,
286
282
  detect,
287
283
  launch(input): LaunchOutput {