@monster-orange/den 0.4.3 → 0.4.4
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 +179 -0
- package/README.md +9 -9
- package/docs/distributions.md +215 -65
- package/package.json +1 -1
- package/src/shared/agents/catalog-runtime.ts +178 -0
- package/src/shared/agents/catalog.ts +343 -0
- package/src/shared/agents/den-claude.ts +14 -18
- package/src/shared/agents/runtime-search.ts +142 -15
- package/src/shared/agents/runtime.ts +18 -1
- package/src/shared/agents/runtimes.ts +23 -1
- package/src/shared/auth/sudo-vault.ts +16 -0
- package/src/shared/den-config.ts +57 -3
- package/src/shared/edge/status.ts +134 -0
- package/src/shared/environment-manager/health.ts +82 -8
- package/src/shared/environment-manager/workspaces.ts +54 -0
- package/src/shared/health/not-checked.ts +42 -0
- package/src/shared/health/rollup.ts +4 -0
- package/src/shared/process/spawn-streaming.ts +8 -3
- package/src/shared/sessions/client.ts +5 -1
- package/src/shared/sessions/host.ts +29 -1
- package/src/shared/sessions/protocol.ts +6 -1
- package/src/shared/sessions/types.ts +6 -0
- package/src/shared/stacks/status.ts +24 -0
- package/src/shared/strategies/registry.ts +5 -0
- package/src/shared/types/index.ts +6 -0
- package/src/ui/cli/agent-ops.ts +210 -19
- package/src/ui/cli/agents-command.ts +50 -21
- package/src/ui/cli/edge-command.ts +16 -3
- package/src/ui/cli/health-display.ts +4 -1
- package/src/ui/mcp/scope.ts +23 -3
- package/src/ui/mcp/server.ts +31 -7
- package/src/ui/mcp/tools.ts +327 -20
- package/src/ui/mcp/views.ts +450 -0
- package/src/ui/server/agent-routes.ts +118 -28
- package/src/ui/server/index.ts +77 -6
- package/src/ui/server/public/agent-start.js +46 -7
- package/src/ui/server/public/agents-page.js +232 -97
- package/src/ui/server/public/agents-section.js +3 -5
- package/src/ui/server/public/agents.html +12 -28
- package/src/ui/server/public/graph-model.js +2 -0
- package/src/ui/server/public/header.js +2 -3
- package/src/ui/server/public/health-rollup.js +20 -8
- package/src/ui/server/public/i18n/es.js +15 -13
- package/src/ui/server/public/i18n/ja.js +14 -13
- package/src/ui/server/public/index.html +17 -4
- package/src/ui/server/public/local-dev.html +28 -17
- package/src/ui/server/public/services.html +21 -5
- package/src/ui/server/public/settings-page.js +3 -2
- package/src/ui/server/public/settings-registry.js +225 -0
- package/src/ui/server/public/settings.html +90 -1
- package/src/ui/server/public/shell.js +28 -23
- package/src/ui/server/public/topbar.js +166 -206
- package/src/ui/server/readonly-routes.ts +29 -5
- package/src/ui/server/request-guard.ts +1 -1
- package/src/ui/server/route-classes.ts +14 -2
- package/src/ui/server/session-routes.ts +9 -1
- package/src/ui/server/sudo-store.ts +170 -6
- package/src/ui/server/workspace-routes.ts +10 -3
|
@@ -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
|
-
|
|
56
|
-
|
|
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
|
-
/**
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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:
|
|
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 {
|
|
@@ -5,10 +5,13 @@
|
|
|
5
5
|
* Claude Code's native installer puts `claude`.
|
|
6
6
|
*
|
|
7
7
|
* The search, first match wins:
|
|
8
|
+
* 0. workspace `<ws>.agents.runtimes.<id>.bin` in
|
|
9
|
+
* config/local/workspaces.yml (AG-0.5), for an agent of
|
|
10
|
+
* that workspace;
|
|
8
11
|
* 1. setting `agents.runtimes.<id>.bin` in config/local/user.yml
|
|
9
12
|
* (per machine, so never in the distribution's config):
|
|
10
|
-
* an absolute path to an executable file; a bad one
|
|
11
|
-
* refused with its reason, never skipped.
|
|
13
|
+
* an absolute path to an executable file; a bad one (at
|
|
14
|
+
* either scope) is refused with its reason, never skipped.
|
|
12
15
|
* 2. login-shell the login shell's PATH (`$SHELL -lc`), resolved once
|
|
13
16
|
* at server start, async, under a 3-second timeout; on
|
|
14
17
|
* any failure it is left out (the current PATH remains).
|
|
@@ -18,6 +21,11 @@
|
|
|
18
21
|
* /opt/homebrew/bin, /usr/local/bin and npm's global
|
|
19
22
|
* bin, derived from npm_config_prefix and node's own
|
|
20
23
|
* directory, never by running npm).
|
|
24
|
+
* 5. interactive-shell only when a runtime is still missing: the
|
|
25
|
+
* interactive login shell's PATH (`$SHELL -ilc`, where
|
|
26
|
+
* some people set PATH in .zshrc/.bashrc), asked once per
|
|
27
|
+
* process with stdin closed under the same 3-second
|
|
28
|
+
* timeout, and kept (ensureInteractivePath).
|
|
21
29
|
*
|
|
22
30
|
* Sessions and agents Den starts get the same directories on their PATH
|
|
23
31
|
* (sessionPath), so the runtime's own tools are found too.
|
|
@@ -32,7 +40,13 @@ import { spawn } from "child_process";
|
|
|
32
40
|
import * as fs from "fs";
|
|
33
41
|
import * as path from "path";
|
|
34
42
|
|
|
35
|
-
export type BinSource =
|
|
43
|
+
export type BinSource =
|
|
44
|
+
| "workspace"
|
|
45
|
+
| "setting"
|
|
46
|
+
| "login-shell"
|
|
47
|
+
| "well-known"
|
|
48
|
+
| "PATH"
|
|
49
|
+
| "interactive-shell";
|
|
36
50
|
|
|
37
51
|
/** The shell asked for the login PATH instead of $SHELL; "off" for none. */
|
|
38
52
|
export const LOGIN_SHELL_ENV = "DEN_LOGIN_SHELL";
|
|
@@ -52,7 +66,7 @@ export interface RuntimeFind {
|
|
|
52
66
|
|
|
53
67
|
export interface SearchDir {
|
|
54
68
|
dir: string;
|
|
55
|
-
source: Exclude<BinSource, "setting">;
|
|
69
|
+
source: Exclude<BinSource, "setting" | "workspace">;
|
|
56
70
|
}
|
|
57
71
|
|
|
58
72
|
export interface Located {
|
|
@@ -96,6 +110,8 @@ const END = "__DEN_PATH_END__";
|
|
|
96
110
|
export interface LoginPathOptions {
|
|
97
111
|
env?: NodeJS.ProcessEnv;
|
|
98
112
|
timeoutMs?: number;
|
|
113
|
+
/** The shell's flags: "-lc" (a login shell), "-ilc" (interactive too). */
|
|
114
|
+
flags?: "-lc" | "-ilc";
|
|
99
115
|
}
|
|
100
116
|
|
|
101
117
|
/**
|
|
@@ -127,10 +143,14 @@ export function resolveLoginPath(
|
|
|
127
143
|
};
|
|
128
144
|
let child: ReturnType<typeof spawn>;
|
|
129
145
|
try {
|
|
130
|
-
child = spawn(
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
146
|
+
child = spawn(
|
|
147
|
+
shell,
|
|
148
|
+
[o.flags ?? "-lc", `printf '${BEGIN}%s${END}' "$PATH"`],
|
|
149
|
+
{
|
|
150
|
+
env,
|
|
151
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
152
|
+
},
|
|
153
|
+
);
|
|
134
154
|
} catch {
|
|
135
155
|
resolve(undefined);
|
|
136
156
|
return;
|
|
@@ -167,6 +187,38 @@ export async function initLoginPath(o: LoginPathOptions = {}): Promise<void> {
|
|
|
167
187
|
if (p) setLoginPath(p);
|
|
168
188
|
}
|
|
169
189
|
|
|
190
|
+
// ── the interactive shell's PATH (the fallback) ─────────────────────────────
|
|
191
|
+
|
|
192
|
+
let interactiveDirs: string[] | null = null;
|
|
193
|
+
let interactiveAsked: Promise<void> | null = null;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Ask the interactive login shell (`$SHELL -ilc`) for its PATH, once per
|
|
197
|
+
* process (stdin closed, the same timeout), and keep its directories as
|
|
198
|
+
* the last place a runtime is looked for. Only for a runtime still missing
|
|
199
|
+
* after the login PATH and the install directories; a second call waits
|
|
200
|
+
* on (or returns) the first. Never throws.
|
|
201
|
+
*/
|
|
202
|
+
export function ensureInteractivePath(
|
|
203
|
+
o: Omit<LoginPathOptions, "flags"> = {},
|
|
204
|
+
): Promise<void> {
|
|
205
|
+
interactiveAsked ??= resolveLoginPath({ ...o, flags: "-ilc" }).then((p) => {
|
|
206
|
+
if (p) interactiveDirs = absoluteDirs(p);
|
|
207
|
+
});
|
|
208
|
+
return interactiveAsked;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Whether the interactive shell was asked already (the answer is kept). */
|
|
212
|
+
export function interactivePathAsked(): boolean {
|
|
213
|
+
return interactiveAsked !== null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Forget the interactive shell's PATH and that it was asked (tests). */
|
|
217
|
+
export function resetInteractivePath(): void {
|
|
218
|
+
interactiveDirs = null;
|
|
219
|
+
interactiveAsked = null;
|
|
220
|
+
}
|
|
221
|
+
|
|
170
222
|
// ── the directories ─────────────────────────────────────────────────────────
|
|
171
223
|
|
|
172
224
|
/** A PATH-style list's absolute entries, in order, once each. */
|
|
@@ -213,7 +265,10 @@ export function wellKnownDirs(
|
|
|
213
265
|
: [];
|
|
214
266
|
}
|
|
215
267
|
|
|
216
|
-
/**
|
|
268
|
+
/**
|
|
269
|
+
* Where a runtime is looked for, in order: login shell, PATH, well-known,
|
|
270
|
+
* then (once asked) the interactive shell's PATH.
|
|
271
|
+
*/
|
|
217
272
|
export function searchDirs(
|
|
218
273
|
find: RuntimeFind | undefined,
|
|
219
274
|
env: NodeJS.ProcessEnv,
|
|
@@ -226,6 +281,7 @@ export function searchDirs(
|
|
|
226
281
|
add(loginDirs ?? [], "login-shell");
|
|
227
282
|
add(absoluteDirs(env.PATH ?? ""), "PATH");
|
|
228
283
|
add(wellKnownDirs(find, env), "well-known");
|
|
284
|
+
add(interactiveDirs ?? [], "interactive-shell");
|
|
229
285
|
return out;
|
|
230
286
|
}
|
|
231
287
|
|
|
@@ -248,8 +304,8 @@ export const binSettingKey = (id: string) => `agents.runtimes.${id}.bin`;
|
|
|
248
304
|
export function checkBinSetting(
|
|
249
305
|
id: string,
|
|
250
306
|
value: unknown,
|
|
307
|
+
key: string = binSettingKey(id),
|
|
251
308
|
): { path: string } | { why: string } {
|
|
252
|
-
const key = binSettingKey(id);
|
|
253
309
|
if (
|
|
254
310
|
value &&
|
|
255
311
|
typeof value === "object" &&
|
|
@@ -268,10 +324,18 @@ export function checkBinSetting(
|
|
|
268
324
|
return { path: p };
|
|
269
325
|
}
|
|
270
326
|
|
|
327
|
+
/** A path someone set: where (global or the workspace's) and its key. */
|
|
328
|
+
export interface BinSetting {
|
|
329
|
+
value: unknown;
|
|
330
|
+
source: "setting" | "workspace";
|
|
331
|
+
/** The key it is under, for a refusal ("agents.runtimes.codex.bin"). */
|
|
332
|
+
key?: string;
|
|
333
|
+
}
|
|
334
|
+
|
|
271
335
|
/**
|
|
272
336
|
* Find runtime `id`'s CLI: the setting when there is one (refused with its
|
|
273
337
|
* reason when bad), else the first of `find.bins` in searchDirs. Runs
|
|
274
|
-
* nothing.
|
|
338
|
+
* nothing. `setting` is a value (global, as before) or a BinSetting.
|
|
275
339
|
*/
|
|
276
340
|
export function locateRuntime(
|
|
277
341
|
id: string,
|
|
@@ -279,11 +343,17 @@ export function locateRuntime(
|
|
|
279
343
|
env: NodeJS.ProcessEnv,
|
|
280
344
|
setting?: unknown,
|
|
281
345
|
): Located {
|
|
282
|
-
|
|
283
|
-
|
|
346
|
+
const set: BinSetting | null =
|
|
347
|
+
setting === undefined || setting === null
|
|
348
|
+
? null
|
|
349
|
+
: isBinSetting(setting)
|
|
350
|
+
? setting
|
|
351
|
+
: { value: setting, source: "setting" };
|
|
352
|
+
if (set && set.value !== undefined && set.value !== null) {
|
|
353
|
+
const c = checkBinSetting(id, set.value, set.key);
|
|
284
354
|
return "path" in c
|
|
285
|
-
? { available: true, path: c.path, source:
|
|
286
|
-
: { available: false, why: c.why, source:
|
|
355
|
+
? { available: true, path: c.path, source: set.source, searched: [] }
|
|
356
|
+
: { available: false, why: c.why, source: set.source, searched: [] };
|
|
287
357
|
}
|
|
288
358
|
const dirs = searchDirs(find, env);
|
|
289
359
|
for (const { dir, source } of dirs)
|
|
@@ -304,6 +374,17 @@ export function locateRuntime(
|
|
|
304
374
|
};
|
|
305
375
|
}
|
|
306
376
|
|
|
377
|
+
function isBinSetting(v: unknown): v is BinSetting {
|
|
378
|
+
return (
|
|
379
|
+
!!v &&
|
|
380
|
+
typeof v === "object" &&
|
|
381
|
+
"source" in v &&
|
|
382
|
+
"value" in v &&
|
|
383
|
+
((v as BinSetting).source === "setting" ||
|
|
384
|
+
(v as BinSetting).source === "workspace")
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
307
388
|
/**
|
|
308
389
|
* The PATH a session Den starts gets: the login shell's, the caller's,
|
|
309
390
|
* then each runtime's well-known directories that exist, and `extra` (a
|
|
@@ -410,6 +491,52 @@ export function runtimeVersion(
|
|
|
410
491
|
return p;
|
|
411
492
|
}
|
|
412
493
|
|
|
494
|
+
/**
|
|
495
|
+
* Run `<bin> <args>` (stdin closed, under a timeout, never cached) and
|
|
496
|
+
* return what it printed and whether it exited 0: to check a path someone
|
|
497
|
+
* sets is the runtime it claims to be. Never throws.
|
|
498
|
+
*/
|
|
499
|
+
export function versionOutput(
|
|
500
|
+
bin: string,
|
|
501
|
+
args: readonly string[],
|
|
502
|
+
env: NodeJS.ProcessEnv,
|
|
503
|
+
timeoutMs: number = VERSION_TIMEOUT_MS,
|
|
504
|
+
): Promise<{ ok: boolean; out: string }> {
|
|
505
|
+
return new Promise((resolve) => {
|
|
506
|
+
let out = "";
|
|
507
|
+
let done = false;
|
|
508
|
+
const finish = (ok: boolean) => {
|
|
509
|
+
if (done) return;
|
|
510
|
+
done = true;
|
|
511
|
+
clearTimeout(timer);
|
|
512
|
+
resolve({ ok, out });
|
|
513
|
+
};
|
|
514
|
+
let child: ReturnType<typeof spawn>;
|
|
515
|
+
try {
|
|
516
|
+
child = spawn(bin, [...args], { env, stdio: ["ignore", "pipe", "pipe"] });
|
|
517
|
+
} catch {
|
|
518
|
+
resolve({ ok: false, out: "" });
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const timer = setTimeout(() => {
|
|
522
|
+
try {
|
|
523
|
+
child.kill("SIGKILL");
|
|
524
|
+
} catch {
|
|
525
|
+
/* gone */
|
|
526
|
+
}
|
|
527
|
+
finish(false);
|
|
528
|
+
}, timeoutMs);
|
|
529
|
+
timer.unref?.();
|
|
530
|
+
const take = (d: Buffer) => {
|
|
531
|
+
if (out.length < 4096) out += d.toString("utf-8");
|
|
532
|
+
};
|
|
533
|
+
child.stdout?.on("data", take);
|
|
534
|
+
child.stderr?.on("data", take);
|
|
535
|
+
child.on("error", () => finish(false));
|
|
536
|
+
child.on("close", (code) => finish(code === 0));
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
413
540
|
/** Forget every version asked (tests). */
|
|
414
541
|
export function clearVersionCache(): void {
|
|
415
542
|
versions.clear();
|