@catalyst-cloud/cli 0.16.23 → 0.16.25

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
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.25
4
+
5
+ A self-hosted machine that runs several teams' work from one `compose.json` can now take each approved release by itself. Run `catalyst runner update --fleet --dir <the folder that holds compose.json>`, by hand or on a schedule. It switches only the supervisor to the approved images and leaves work that is already running alone. If the machine's teams are not ready again within four minutes, it puts the previous images back. `--check` shows what would change without changing anything.
6
+
7
+ ## 0.16.24
8
+
9
+ One self-hosted machine can now work for several Linear teams. On a machine already set up for one team, run `catalyst setup --runner` with another team selected, and setup adds that team instead of stopping. Each team's own setting still decides whether its work may run there, and the machine asks each of its teams for work in turn. A new machine starts on Catalyst's current approved release, which can serve several teams from its first setup; a machine you already set up gets that release on its next 15-minute update check.
10
+
11
+ New: `catalyst mcp status <server>` shows one MCP server's state, how it authenticates, how many of its tools are granted to this machine's login, and how many tools it offers. It then lists the server's last 50 tool calls: when each ran, the ticket and phase it ran for, the tool, how long it took and the outcome. A call that failed names its reason in plain words. The command exits 0 whenever it could read the server, even when some of the calls failed. `--json` prints the server's status and calls exactly as Catalyst Cloud returns them.
12
+
3
13
  ## 0.16.23
4
14
 
5
15
  You can now answer an ask by telling your agent in chat, and have the agent record it for you. `catalyst ask answer <ask> --proxy --quote "<your exact words>" --said-in <session>` records your words on the ask as an answer by proxy, with `--option <letter>` when you picked one. It is kept apart from answers you give on the ask yourself, and the work the ask held moves on. It works only on your own asks, and only after you turn it on with `catalyst ask proxy on` (`catalyst ask proxy` shows whether it is on; `off` turns it off). If an agent recorded something you did not mean, `catalyst ask disown <ask>` takes it back and reopens the ask. Update the Catalyst plugin's skills with the CLI so your agent knows when it may do this.
package/README.md CHANGED
@@ -236,7 +236,7 @@ Some of these only read, and some write: a comment, a card move, an ask, a relea
236
236
 
237
237
  ## What a key cannot see yet
238
238
 
239
- Your personal key reads everything the skills need — tickets, pull requests, the eligibility explainer, the dispatch queue, fleet activity, per-ticket execution history (`catalyst explain --history <ticket>`: phase attempts, remediation rounds, park state) and coding-account status (`catalyst accounts`: provider, declared and observed state, usage limits, walls, quarantine — never a credential; enrolling or pausing one is `<your cloud>/settings/coding-accounts`, with no command yet). It also releases a parked or held ticket once its cause is fixed: `catalyst release <ticket> --because <what changed>` (the `unstick` skill runs it), recorded against your name and shown in the ticket's history. And it declares what your containers need: `catalyst environment` reads the account-wide declaration, `environment propose --file <path> --approve` proposes and approves it in one compare-and-set, and the values behind the names stay in the cloud — reading needs any active seat, proposing and approving need an admin or owner one. An admin or owner can put a repository's values in from the terminal. `catalyst secret import .env --repo owner/name` stores every name in the file and lists the declared names that still have no value. `catalyst secret set NAME --repo owner/name --command 'op read op://Vault/item/field'` runs the command on your machine and stores its output; with no `--command` it reads the value from stdin, or asks for it without echoing. No value is ever printed, and the cloud's audit records the command, not its output. That is the account's own declaration — a repository's own `.catalyst/catalyst.toml`, which `catalyst-onboard` already reports on, is a separate thing. `catalyst env inventory` lists the environment variable names a repository needs — never a value; `env check` checks the TOML syntax and environment variable table in `.catalyst/catalyst.toml` offline; `env migrate [catalyst.env.json]` prints a TOML environment table from a legacy declaration, with all names optional and values omitted. These commands need no login (the `what-this-repo-needs` skill walks through them). Two things it cannot do, and the skills say so by name rather than guess:
239
+ Your personal key reads everything the skills need — tickets, pull requests, the eligibility explainer, the dispatch queue, fleet activity, per-ticket execution history (`catalyst explain --history <ticket>`: phase attempts, remediation rounds, park state) and coding-account status (`catalyst accounts`: provider, declared and observed state, usage limits, walls, quarantine — never a credential; enrolling or pausing one is `<your cloud>/settings/coding-accounts`, with no command yet). It also releases a parked or held ticket once its cause is fixed: `catalyst release <ticket> --because <what changed>` (the `unstick` skill runs it), recorded against your name and shown in the ticket's history. And it declares what your containers need: `catalyst environment` reads the account-wide declaration, `environment propose --file <path> --approve` proposes and approves it in one compare-and-set, and the values behind the names stay in the cloud — reading needs any active seat, proposing and approving need an admin or owner one. An admin or owner can put a repository's values in from the terminal. `catalyst secret import .env --repo owner/name` stores every name in the file and lists the declared names that still have no value. `catalyst secret set NAME --repo owner/name --command 'op read op://Vault/item/field'` runs the command on your machine and stores its output; with no `--command` it reads the value from stdin, or asks for it without echoing. No value is ever printed, and the cloud's audit records the command, not its output. That is the account's own declaration — a repository's own `.catalyst/catalyst.toml`, which `catalyst-onboard` already reports on, is a separate thing. `catalyst env inventory` lists the environment variable names a repository needs — never a value; `env check` checks the TOML syntax and environment variable table in `.catalyst/catalyst.toml` offline; `env migrate [catalyst.env.json]` prints a TOML environment table from a legacy declaration, with all names optional and values omitted. `env draft --write` writes a new `.catalyst/catalyst.toml` from the repository's files and from this computer — the names the repository's `.envrc` exports, and Claude Code's HTTP MCP servers with each header as a variable reference — then offers to store this computer's values; `catalyst env import` stores them later. A value is never written to the file or printed. These commands need no login, except `env import` (the `what-this-repo-needs` skill walks through them). Two things it cannot do, and the skills say so by name rather than guess:
240
240
 
241
241
  - Read pull-request labels or the reviewer's reaction. The mirror does not carry them; GitHub's own page does.
242
242
  - Compute a flow number — cycle time, throughput, or how long pull requests have been open. Nothing serves those yet, so say they are not computed rather than counting something else and calling it that.
package/dist/args.js CHANGED
@@ -25,6 +25,7 @@ export const FLAG_TABLES = {
25
25
  check: { value: false, help: "read the approved release and say what would change, without changing it" },
26
26
  auto: { value: false, help: "one-line output for the scheduled run" },
27
27
  dir: { value: true, help: "the runner directory (default: the one setup created)" },
28
+ fleet: { value: false, help: "update a multi-team supervisor fleet's compose.json in --dir" },
28
29
  },
29
30
  login: {
30
31
  "start-replica": {
@@ -357,7 +358,7 @@ export const FLAG_TABLES = {
357
358
  env: {
358
359
  root: {
359
360
  value: true,
360
- help: "draft, shell: repository directory (default: the current repository's top level)",
361
+ help: "draft, import, shell: repository directory (default: current directory; shell: the current repository's top level)",
361
362
  },
362
363
  write: {
363
364
  value: false,
@@ -367,6 +368,18 @@ export const FLAG_TABLES = {
367
368
  value: false,
368
369
  help: "draft: show the generated TOML as an additions-only diff",
369
370
  },
371
+ "import-values": {
372
+ value: false,
373
+ help: "draft: after --write, store this computer's values for the declared names without asking",
374
+ },
375
+ repo: {
376
+ value: true,
377
+ help: "draft --import-values, import: the repository (owner/name); default: this checkout's GitHub origin",
378
+ },
379
+ yes: {
380
+ value: false,
381
+ help: "import: store without asking (needed without a terminal)",
382
+ },
370
383
  "env-file": {
371
384
  value: true,
372
385
  repeat: true,
@@ -532,7 +545,7 @@ export const FLAG_TABLES = {
532
545
  };
533
546
  export const VERB_USAGE = {
534
547
  hosts: "hosts [list] | hosts remove <name-or-id> [--yes] | hosts rename <name-or-id> <new-name> [--json]",
535
- runner: "runner update [--check] [--dir <runner directory>] | runner auto-update [status|on|off]",
548
+ runner: "runner update [--check] [--dir <runner directory>] [--fleet] | runner auto-update [status|on|off]",
536
549
  setup: "setup [onboard options] (sets up Catalyst step by step; same as catalyst onboard) | setup --engine <file> --engine-sha256 <hex> [-- <install options>] (run by install.sh; install.sh --help lists the options)",
537
550
  login: "login [--base-url <url>] [--start-replica] (keyless; or --key <personal-key> / CATALYST_CLOUD_TOKEN)",
538
551
  onboard: "onboard [--team <ID|key>] [--repo <owner/name>]... [--coding-account <slot>] [--resume-from <step>] [--only <step>] [--local-sync] [--runner|--no-runner] [--no-color] [--yes] [--dry-run] [--json] | onboard --headless [--key-file <path>] [--team ...] [--repo ...]... [--coding-account ...] [--runner yes|no] [--json]",
@@ -555,9 +568,9 @@ export const VERB_USAGE = {
555
568
  ready: "ready [--onboarding] [--local-sync] [--json] [--offline]",
556
569
  accounts: "accounts [--all] [--json]",
557
570
  seat: "seat checkout --size big|med --harness claude,codex,opencode [--session-id ID] [--stage STAGE] [--key-env NAME]",
558
- mcp: "mcp <add <name> --url URL <--auth none|--bearer SECRET_NAME|--header NAME=SECRET_NAME...>|list|remove <name>> [--json]",
571
+ mcp: "mcp <add <name> --url URL <--auth none|--bearer SECRET_NAME|--header NAME=SECRET_NAME...>|list|remove <name>|status <name>> [--json]",
559
572
  team: "team <list|check <KEY>|check --all|map <KEY> [--stage role=StateName]... [--yes --plan-hash H]|adopt <KEY> [--undo] [--yes --plan-hash H]|migrate <KEY> [--choice sourceId=destinationId]... [--retire] [--yes --plan-hash H]|checklist <KEY>> [--json]",
560
- env: "env <inventory [path] | check <file> | migrate [catalyst.env.json] | draft [--root DIR] [--diff] [--write] | shell [--root DIR] [--env-file FILE]... [--image REF] [--check]> [--json] (THIS repository; no login — shell needs Docker and downloads Catalyst's image once)",
573
+ env: "env <inventory [path] | check <file> | migrate [catalyst.env.json] | draft [--root DIR] [--diff] [--write [--import-values]] | import [--root DIR] [--repo owner/name] [--yes] | shell [--root DIR] [--env-file FILE]... [--image REF] [--check]> [--json] (THIS repository; no login, except import and --import-values, which sign in and store values — shell needs Docker and downloads Catalyst's image once)",
561
574
  environment: "environment [read] [--json] | environment propose --file <path>|--stdin [--expect-revision N] [--approve] [--json] | environment approve [--revision N --hash H] [--json] (your ACCOUNT's declaration; needs login)",
562
575
  secret: "secret set <NAME> --repo <owner/name> [--command '<cmd>'] [--json] (value from --command, stdin, or a hidden prompt) | secret import <file> --repo <owner/name> [--names NAME[,NAME...]] [--rotate NAME]... [--json]",
563
576
  var: "var <set NAME|import <file>> [--repo <owner/name>] [--names NAME[,NAME...]] [--json] (plain environment variables; set reads stdin or a hidden prompt)",
@@ -37,6 +37,7 @@ export const CAPABILITIES = [
37
37
  { verb: "environment approve", does: "approve the workspace-wide environment declaration", needs: "admin", routes: [agent("POST", "account-environment/approve")], since: "0.8.0" },
38
38
  { verb: "secret set|import", does: "enter a repository secret's value from this terminal, never echoed", needs: "admin", routes: [], since: "0.8.0" },
39
39
  { verb: "var set|import", does: "enter plain environment variable values from this terminal, never echoed", needs: "admin", routes: [], since: "0.13.2" },
40
+ { verb: "env import", does: "store this computer's values for the names .catalyst/catalyst.toml declares, never printed", needs: "admin", routes: [], since: "0.16.20" },
40
41
  { verb: "accounts", does: "read the workspace's coding accounts and which can take work", needs: "member", routes: [], since: "0.5.0" },
41
42
  { verb: "explain|running|queue|history", does: "read why a ticket runs or waits, what runs now, and what is queued", needs: "member", routes: [], since: "0.1.0" },
42
43
  { verb: "write comment|state|label|create|reaction|attachment|session", does: "act on a ticket as the person, through the agent proxy", needs: "member", routes: [agent("POST", "issue-comment"), agent("POST", "issue-state"), agent("POST", "issue-label"), agent("POST", "issue-create")], since: "0.1.0" },
@@ -46,6 +47,7 @@ export const CAPABILITIES = [
46
47
  { verb: "ask disown", does: "take back an answer recorded by proxy on your ask, reopening it (your own login only)", needs: "member", routes: [], since: "0.16.23" },
47
48
  { verb: "release", does: "release a parked ticket or a whole failure class back to work", needs: "admin", routes: [agent("POST", "ticket-release"), agent("POST", "ticket-release-class")], since: "0.6.0" },
48
49
  { verb: "mcp add|list|remove", does: "register an MCP server for the workspace by vault-secret name", needs: "admin", routes: [agent("POST", "mcp")], since: "0.9.0" },
50
+ { verb: "mcp status", does: "read an MCP server's state, auth kind, grants for this key, tool count and its last 50 tool calls with each failure's reason", needs: "member", routes: [], since: "0.16.24" },
49
51
  ];
50
52
  /** Judge each capability against the routes a contract serves. `null` means no contract is cached,
51
53
  * so nothing that rides a route can be called available; local and read-plane verbs still are. */
package/dist/cli.js CHANGED
@@ -86,7 +86,7 @@ export function usageText() {
86
86
  " catalyst seat checkout --size big|med --harness claude,codex,opencode [--session-id ID] [--stage STAGE]",
87
87
  " catalyst hosts [list] | hosts remove <name-or-id> [--yes] | hosts rename <name-or-id> <new-name>",
88
88
  " catalyst runner update [--check] | runner auto-update [status|on|off] (move this machine's runner to the approved release)",
89
- " catalyst mcp add|list|remove (vault references only)",
89
+ " catalyst mcp add|list|remove (vault references only) | mcp status <name> (a server's state, grants and last 50 tool calls)",
90
90
  " catalyst contract [--refresh] [--path <a.b.c>]",
91
91
  " catalyst query <issues|issue <id>|pulls|pull <id>|projects|cycles|search <terms>|changes --since <cursor|head>>",
92
92
  ' catalyst replica <start [--detach]|stop|status [--probe]|sql "<select>"|schema [table]>',
package/dist/env/draft.js CHANGED
@@ -2,41 +2,35 @@ import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { convertLegacyEnvironmentJson } from "./legacy.js";
4
4
  import { inventoryRepo } from "./inventory.js";
5
+ import { scanMachine } from "./machine.js";
6
+ import { mcpServerToml } from "./scan-claude-mcp.js";
5
7
  import { walkRepo } from "./walk.js";
6
8
  const SECRET_NAME_RE = /(?:^|_)(?:API_?KEY|AUTH|CREDENTIALS?|PASSWORD|PRIVATE_?KEY|SECRET|TOKEN)(?:_|$)/i;
7
9
  const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
8
10
  const MAX_VARIABLES = 128;
11
+ /** The draft's secret rule: a name that says it holds a key, a token, a password or a secret. */
12
+ export function isSecretName(name) {
13
+ return SECRET_NAME_RE.test(name);
14
+ }
9
15
  /** Build a stable, value-free environment table from repository evidence. `.env` is never read:
10
16
  * inventoryRepo's walker only opens supported examples and source/config files. The legacy
11
- * declaration is parsed for names and explicit secret flags, and no legacy value escapes. */
12
- export function draftEnvironment(root, deps = {}) {
13
- const inventory = inventoryRepo(root, deps);
17
+ * declaration is parsed for names and explicit secret flags, and no legacy value escapes.
18
+ * With `options.machine`, the names this computer's `.envrc` exports and Claude Code's HTTP MCP
19
+ * servers join the draft (`catalyst env draft`); without it (`catalyst onboard`) nothing changes. */
20
+ export function draftEnvironment(root, deps = {}, options = {}) {
21
+ const repository = repositoryNames(root, deps);
22
+ if (repository.state === "invalid")
23
+ return repository;
24
+ const { names, secrets, sources } = repository;
14
25
  const files = (deps.listFiles ?? walkRepo)(root);
15
- const names = new Set();
16
- const secrets = new Set();
17
- const sources = new Set();
18
- for (const entry of inventory.entries) {
19
- if (entry.group === "bindings" || !entry.needsDeclaration || !ENV_NAME_RE.test(entry.name))
20
- continue;
21
- names.add(entry.name);
22
- if (SECRET_NAME_RE.test(entry.name))
23
- secrets.add(entry.name);
24
- for (const finding of entry.found)
25
- sources.add(finding.file);
26
- }
27
- const legacyPath = join(root, "catalyst.env.json");
28
- const legacy = deps.readFile !== undefined ? deps.readFile(legacyPath) : readOptional(legacyPath);
29
- if (legacy !== undefined) {
30
- const converted = convertLegacyEnvironmentJson(legacy);
31
- if (converted.state === "invalid")
32
- return converted;
33
- for (const name of converted.variableNames)
34
- names.add(name);
35
- for (const name of converted.secretNames)
36
- secrets.add(name);
37
- if (converted.variableNames.length > 0)
38
- sources.add("catalyst.env.json");
39
- }
26
+ const machine = options.machine === undefined ? null : scanMachine(root, options.machine, new Set(names));
27
+ for (const name of machine?.names ?? [])
28
+ names.add(name);
29
+ for (const name of machine?.secretNames ?? [])
30
+ secrets.add(name);
31
+ for (const source of machine?.sources ?? [])
32
+ sources.add(source);
33
+ const mcpServers = machine?.claude?.servers ?? [];
40
34
  const sortedNames = [...names].sort((a, b) => a.localeCompare(b, "en"));
41
35
  if (sortedNames.length > MAX_VARIABLES) {
42
36
  return { state: "invalid", errors: [`environment has more than ${MAX_VARIABLES} names`] };
@@ -59,7 +53,7 @@ export function draftEnvironment(root, deps = {}) {
59
53
  "required = false",
60
54
  "",
61
55
  ].join("\n"));
62
- chunks.push(...standard.setup, ...standard.verify);
56
+ chunks.push(...mcpServers.map(mcpServerToml), ...standard.setup, ...standard.verify);
63
57
  const toml = `${chunks.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
64
58
  return {
65
59
  state: "valid",
@@ -69,8 +63,41 @@ export function draftEnvironment(root, deps = {}) {
69
63
  verify: standard.verify,
70
64
  toml,
71
65
  sources: [...sources].sort((a, b) => a.localeCompare(b, "en")),
66
+ mcpServers,
67
+ machine,
72
68
  };
73
69
  }
70
+ /** The names the repository itself declares: its examples, source and config files, and the legacy
71
+ * `catalyst.env.json`. A derived MCP header variable must not take one of these (`scanMachine`). */
72
+ export function repositoryNames(root, deps = {}) {
73
+ const inventory = inventoryRepo(root, deps);
74
+ const names = new Set();
75
+ const secrets = new Set();
76
+ const sources = new Set();
77
+ for (const entry of inventory.entries) {
78
+ if (entry.group === "bindings" || !entry.needsDeclaration || !ENV_NAME_RE.test(entry.name))
79
+ continue;
80
+ names.add(entry.name);
81
+ if (isSecretName(entry.name))
82
+ secrets.add(entry.name);
83
+ for (const finding of entry.found)
84
+ sources.add(finding.file);
85
+ }
86
+ const legacyPath = join(root, "catalyst.env.json");
87
+ const legacy = deps.readFile !== undefined ? deps.readFile(legacyPath) : readOptional(legacyPath);
88
+ if (legacy !== undefined) {
89
+ const converted = convertLegacyEnvironmentJson(legacy);
90
+ if (converted.state === "invalid")
91
+ return converted;
92
+ for (const name of converted.variableNames)
93
+ names.add(name);
94
+ for (const name of converted.secretNames)
95
+ secrets.add(name);
96
+ if (converted.variableNames.length > 0)
97
+ sources.add("catalyst.env.json");
98
+ }
99
+ return { state: "valid", names, secrets, sources };
100
+ }
74
101
  /** Conservative package.json/lockfile inference. No repository script is executed. */
75
102
  function standardSteps(root, files, deps) {
76
103
  if (!files.includes("package.json"))
@@ -0,0 +1,63 @@
1
+ // machine-values.ts — this computer's values for the names a reviewed `.catalyst/catalyst.toml`
2
+ // declares, for `catalyst env import` and `catalyst env draft --import-values`.
3
+ //
4
+ // ⛔ VALUES ARE HELD IN MEMORY AND RETURNED ONLY TO `src/env-import.ts`, which sends them in request
5
+ // bodies and prints names. Nothing here logs, prints or writes a value.
6
+ //
7
+ // A value can come from four places, in this order, and nowhere else:
8
+ // 1. the direnv environment: this process's environment, only when DIRENV_DIR names this
9
+ // repository and only for names `.envrc` exports and assigns (direnv has already run the file;
10
+ // a bare `export NAME` with no assignment would pass on whatever the shell held);
11
+ // 2. a static literal in `.envrc`;
12
+ // 3. a literal header in `~/.claude.json`, only for a variable the draft derived from that header;
13
+ // 4. this process's environment, for a name a `~/.claude.json` header references, since that is
14
+ // where Claude Code itself reads it.
15
+ // `.envrc` is never run, `direnv` is never called, and `.env` is never opened. The file's declared
16
+ // names are the allowlist: a name this computer has but the file does not declare is never resolved.
17
+ import { claudeHeaderLiterals, scanClaudeMcp } from "./scan-claude-mcp.js";
18
+ import { envrcLiteralValues, scanEnvrc } from "./scan-envrc.js";
19
+ import { readMachineFiles } from "./machine.js";
20
+ export function resolveMachineValues(root, options, declared, repositoryNames = new Set()) {
21
+ const files = readMachineFiles(root, options);
22
+ const wanted = new Set(declared.map(({ name }) => name));
23
+ const envrc = files.envrcText === undefined ? null : scanEnvrc(files.envrcText);
24
+ const exported = new Map((envrc?.exported ?? []).map((entry) => [entry.name, entry]));
25
+ const literals = files.envrcText === undefined ? new Map() : envrcLiteralValues(files.envrcText, wanted);
26
+ const direnvDir = options.env.DIRENV_DIR;
27
+ const direnvLoaded = typeof direnvDir === "string" && files.repoRoots.some((path) => direnvDir === `-${path}`);
28
+ // The draft saw the repository's names and the `.envrc` names when it derived header variables;
29
+ // the same set keeps lookups aligned.
30
+ const envrcNames = new Set([...repositoryNames, ...exported.keys()]);
31
+ const headerLiterals = files.claudeText === undefined ? new Map() : claudeHeaderLiterals(files.claudeText, files.repoRoots, wanted, envrcNames);
32
+ const referenced = new Set();
33
+ if (files.claudeText !== undefined) {
34
+ for (const server of scanClaudeMcp(files.claudeText, files.repoRoots, envrcNames).servers) {
35
+ const derived = new Set(server.derived.map(({ variable }) => variable));
36
+ for (const name of server.references)
37
+ if (!derived.has(name))
38
+ referenced.add(name);
39
+ }
40
+ }
41
+ const values = new Map();
42
+ const noValue = [];
43
+ for (const { name } of declared) {
44
+ const found = pick(
45
+ // A bare `export NAME` the file never assigns hands direnv the shell's own value: not the file's.
46
+ direnvLoaded && exported.has(name) && exported.get(name)?.inherited !== true ? [options.env[name], "direnv environment"] : undefined, [literals.get(name), ".envrc"], [headerLiterals.get(name), "~/.claude.json"], referenced.has(name) ? [options.env[name], "shell environment"] : undefined);
47
+ if (found !== null)
48
+ values.set(name, found);
49
+ else
50
+ noValue.push({ name, computed: exported.get(name)?.computed === true });
51
+ }
52
+ return { values, noValue };
53
+ }
54
+ function pick(...candidates) {
55
+ for (const candidate of candidates) {
56
+ if (candidate === undefined)
57
+ continue;
58
+ const [value, from] = candidate;
59
+ if (value !== undefined && value !== "")
60
+ return { value, from };
61
+ }
62
+ return null;
63
+ }
@@ -0,0 +1,119 @@
1
+ // machine.ts — what this computer adds to `catalyst env draft`: the names the repository's `.envrc`
2
+ // exports and Claude Code's HTTP MCP servers. Names and references only; see `scan-envrc.ts` and
3
+ // `scan-claude-mcp.ts` for why neither returns a value.
4
+ //
5
+ // ⛔ `.env` and `.dev.vars` are never opened, even through an `.envrc` that is a link to one: the
6
+ // default reader resolves the real path first and refuses those basenames, the same rule the
7
+ // repository inventory keeps.
8
+ import { closeSync, fstatSync, openSync, readSync, realpathSync } from "node:fs";
9
+ import { basename, join } from "node:path";
10
+ import { isSecretName } from "./draft.js";
11
+ import { scanClaudeMcp } from "./scan-claude-mcp.js";
12
+ import { scanEnvrc } from "./scan-envrc.js";
13
+ const ENVRC_CAP = 1024 * 1024;
14
+ const CLAUDE_JSON_CAP = 32 * 1024 * 1024;
15
+ const REFUSED_BASENAMES = new Set([".env", ".dev.vars"]);
16
+ /** Read `.envrc` and `~/.claude.json` once, through the injected reader or the guarded default. */
17
+ export function readMachineFiles(root, options) {
18
+ const notes = [];
19
+ const read = (path, cap, label) => {
20
+ if (options.readMachineFile)
21
+ return options.readMachineFile(path, cap);
22
+ const result = readGuarded(path, cap);
23
+ if (result.note !== undefined)
24
+ notes.push(`${label} ${result.note}`);
25
+ return result.text;
26
+ };
27
+ const envrcText = read(join(root, ".envrc"), ENVRC_CAP, ".envrc");
28
+ const claudeText = read(join(options.home, ".claude.json"), CLAUDE_JSON_CAP, "~/.claude.json");
29
+ return { envrcText, claudeText, repoRoots: repoRootsFor(root), notes };
30
+ }
31
+ function repoRootsFor(root) {
32
+ const roots = [root];
33
+ try {
34
+ const real = realpathSync(root);
35
+ if (real !== root)
36
+ roots.push(real);
37
+ }
38
+ catch {
39
+ // An unreadable root has no realpath; the given path is the only key.
40
+ }
41
+ return roots;
42
+ }
43
+ /** Scan this computer for names. Missing files leave their half null, so the draft is unchanged.
44
+ * `repositoryNames` are the names the repository already declares; a derived MCP header variable
45
+ * may take neither one of those nor an `.envrc` export. */
46
+ export function scanMachine(root, options, repositoryNames = new Set()) {
47
+ const files = readMachineFiles(root, options);
48
+ const envrc = files.envrcText === undefined ? null : scanEnvrc(files.envrcText);
49
+ const names = new Set();
50
+ const secrets = new Set();
51
+ const sources = [];
52
+ for (const entry of envrc?.exported ?? []) {
53
+ names.add(entry.name);
54
+ if (isSecretName(entry.name))
55
+ secrets.add(entry.name);
56
+ }
57
+ if (envrc !== null && envrc.exported.length > 0)
58
+ sources.push(".envrc");
59
+ const claude = files.claudeText === undefined ? null : scanClaudeMcp(files.claudeText, files.repoRoots, new Set([...repositoryNames, ...names]));
60
+ for (const server of claude?.servers ?? []) {
61
+ for (const name of server.references) {
62
+ names.add(name);
63
+ secrets.add(name);
64
+ }
65
+ }
66
+ if (claude !== null && claude.servers.length > 0)
67
+ sources.push("~/.claude.json");
68
+ const sort = (values) => [...values].sort((a, b) => a.localeCompare(b, "en"));
69
+ return { envrc, claude, names: sort(names), secretNames: sort(secrets), sources, notes: files.notes };
70
+ }
71
+ /** UTF-8 (with or without a byte-order mark) or UTF-16LE with its mark, as Windows editors save it. */
72
+ function decode(bytes) {
73
+ if (bytes[0] === 0xff && bytes[1] === 0xfe)
74
+ return { text: bytes.subarray(2).toString("utf16le") };
75
+ if (bytes[0] === 0xfe && bytes[1] === 0xff)
76
+ return { note: "is UTF-16 big-endian, which this reader does not decode; it was not read" };
77
+ if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf)
78
+ return { text: bytes.subarray(3).toString("utf8") };
79
+ if (bytes.includes(0))
80
+ return { note: "holds NUL bytes, so it is not UTF-8 text; it was not read" };
81
+ return { text: bytes.toString("utf8") };
82
+ }
83
+ function readGuarded(path, cap) {
84
+ let real;
85
+ try {
86
+ real = realpathSync(path);
87
+ }
88
+ catch {
89
+ return {};
90
+ }
91
+ if (REFUSED_BASENAMES.has(basename(real)))
92
+ return { note: `links to a ${basename(real)} file, which is never read; it was not read` };
93
+ let fd;
94
+ try {
95
+ fd = openSync(real, "r");
96
+ }
97
+ catch {
98
+ return { note: "could not be opened; it was not read" };
99
+ }
100
+ try {
101
+ const stat = fstatSync(fd);
102
+ if (!stat.isFile())
103
+ return { note: "is not a regular file; it was not read" };
104
+ if (stat.size > cap)
105
+ return { note: `is larger than ${Math.round(cap / 1024 / 1024)} MiB; it was not read` };
106
+ const buffer = Buffer.alloc(stat.size);
107
+ let offset = 0;
108
+ while (offset < stat.size) {
109
+ const n = readSync(fd, buffer, offset, stat.size - offset, offset);
110
+ if (n === 0)
111
+ break;
112
+ offset += n;
113
+ }
114
+ return decode(buffer.subarray(0, offset));
115
+ }
116
+ finally {
117
+ closeSync(fd);
118
+ }
119
+ }