@monster-orange/den 0.4.2 → 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 +273 -0
- package/README.md +9 -9
- package/docs/distributions.md +313 -50
- 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 +53 -12
- package/src/shared/agents/generic.ts +1 -0
- package/src/shared/agents/runtime-search.ts +543 -0
- package/src/shared/agents/runtime.ts +43 -1
- package/src/shared/agents/runtimes.ts +53 -4
- package/src/shared/auth/sudo-vault.ts +16 -0
- package/src/shared/den-config.ts +92 -2
- package/src/shared/edge/render.ts +31 -0
- 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 +298 -19
- package/src/ui/cli/agents-command.ts +129 -4
- package/src/ui/cli/edge-command.ts +16 -3
- package/src/ui/cli/health-display.ts +4 -1
- package/src/ui/cli/sessions-command.ts +9 -4
- package/src/ui/cli/web-command.ts +53 -8
- 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 +123 -26
- package/src/ui/server/edge-exposure.ts +143 -0
- package/src/ui/server/index.ts +171 -25
- package/src/ui/server/public/agent-start.js +241 -0
- package/src/ui/server/public/agents-page.js +246 -45
- package/src/ui/server/public/agents-section.js +7 -21
- package/src/ui/server/public/agents.html +55 -1
- 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 +26 -13
- package/src/ui/server/public/i18n/ja.js +25 -13
- package/src/ui/server/public/index.html +17 -4
- package/src/ui/server/public/local-dev.html +62 -22
- 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 +226 -1
- package/src/ui/server/public/settings.html +90 -1
- package/src/ui/server/public/shell.js +67 -22
- package/src/ui/server/public/topbar.js +217 -158
- package/src/ui/server/readonly-routes.ts +29 -5
- package/src/ui/server/request-guard.ts +123 -8
- 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/ui-assets.ts +2 -0
- package/src/ui/server/workspace-routes.ts +10 -3
|
@@ -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
|
+
}
|
|
@@ -28,27 +28,43 @@
|
|
|
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
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
-
|
|
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 { builtinEntry } from "./catalog.js";
|
|
55
|
+
import { catalogFind } from "./catalog-runtime.js";
|
|
56
|
+
import { locateRuntime, type RuntimeFind } from "./runtime-search.js";
|
|
49
57
|
|
|
50
58
|
export const DEN_CLAUDE_ID = "den-claude";
|
|
51
59
|
const BIN = "claude";
|
|
60
|
+
|
|
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);
|
|
52
68
|
const PACKAGE = "@anthropic-ai/claude-code";
|
|
53
69
|
const SEMVER = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
|
|
54
70
|
|
|
@@ -97,11 +113,29 @@ export function installedVersion(bin: string): string | undefined {
|
|
|
97
113
|
return undefined;
|
|
98
114
|
}
|
|
99
115
|
|
|
100
|
-
function detect(
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
116
|
+
function detect(
|
|
117
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
118
|
+
opts: DetectOptions = {},
|
|
119
|
+
): RuntimeDetect {
|
|
120
|
+
const setting =
|
|
121
|
+
opts.bin === undefined ? configuredRuntimeBin(DEN_CLAUDE_ID) : opts.bin;
|
|
122
|
+
const found = locateRuntime(DEN_CLAUDE_ID, DEN_CLAUDE_FIND, env, setting);
|
|
123
|
+
if (!found.available || !found.path)
|
|
124
|
+
return {
|
|
125
|
+
available: false,
|
|
126
|
+
why: found.why ?? `${BIN} not found`,
|
|
127
|
+
// "setting": a bad agents.runtimes.<id>.bin is the reason, not the search.
|
|
128
|
+
...(found.source ? { source: found.source } : {}),
|
|
129
|
+
searched: found.searched,
|
|
130
|
+
};
|
|
131
|
+
const version = installedVersion(found.path);
|
|
132
|
+
return {
|
|
133
|
+
available: true,
|
|
134
|
+
...(version ? { version } : {}),
|
|
135
|
+
path: found.path,
|
|
136
|
+
...(found.source ? { source: found.source } : {}),
|
|
137
|
+
searched: found.searched,
|
|
138
|
+
};
|
|
105
139
|
}
|
|
106
140
|
|
|
107
141
|
/**
|
|
@@ -231,13 +265,20 @@ function common(input: LaunchInput): {
|
|
|
231
265
|
}
|
|
232
266
|
|
|
233
267
|
function binFor(input: LaunchInput): string {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
268
|
+
if (input.bin) return input.bin;
|
|
269
|
+
const d = detect(input.env);
|
|
270
|
+
if (!d.available || !d.path)
|
|
271
|
+
throw new LaunchError(`${DEN_CLAUDE_ID}: ${d.why ?? `${BIN} not found`}`);
|
|
272
|
+
return d.path;
|
|
237
273
|
}
|
|
238
274
|
|
|
239
275
|
export const denClaude: Runtime = {
|
|
240
276
|
id: DEN_CLAUDE_ID,
|
|
277
|
+
label: DEN_CLAUDE_CATALOG.name,
|
|
278
|
+
vendor: DEN_CLAUDE_CATALOG.vendor,
|
|
279
|
+
catalog: DEN_CLAUDE_CATALOG,
|
|
280
|
+
denTools: true,
|
|
281
|
+
find: DEN_CLAUDE_FIND,
|
|
241
282
|
detect,
|
|
242
283
|
launch(input): LaunchOutput {
|
|
243
284
|
const bin = binFor(input);
|