@catalyst-cloud/cli 0.16.22 → 0.16.24
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 +10 -0
- package/README.md +1 -1
- package/dist/args.js +31 -6
- package/dist/ask.js +240 -6
- package/dist/capabilities.js +5 -0
- package/dist/cli.js +2 -2
- package/dist/env/draft.js +56 -29
- package/dist/env/machine-values.js +63 -0
- package/dist/env/machine.js +119 -0
- package/dist/env/scan-claude-mcp.js +274 -0
- package/dist/env/scan-envrc.js +271 -0
- package/dist/env/settings-toml.js +8 -0
- package/dist/env/shell-plan.js +4 -1
- package/dist/env-import.js +274 -0
- package/dist/env.js +101 -9
- package/dist/hosts.js +2 -14
- package/dist/mcp-status.js +111 -0
- package/dist/mcp.js +5 -2
- package/dist/onboard-next.js +5 -1
- package/dist/onboard-runner.js +112 -20
- package/dist/prompt.js +15 -2
- package/dist/secret.js +3 -3
- package/dist/write.js +5 -3
- package/package.json +4 -4
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-onboard/SKILL.md +1 -1
- package/skills/catalyst-onboard/references/declaring-a-repository.md +6 -2
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +14 -1
- package/skills/what-needs-me/references/settling-an-answer.md +2 -0
- package/skills/what-this-repo-needs/SKILL.md +1 -1
- package/skills/whats-happening/SKILL.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.16.24
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
## 0.16.23
|
|
10
|
+
|
|
11
|
+
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.
|
|
12
|
+
|
|
3
13
|
## 0.16.22
|
|
4
14
|
|
|
5
15
|
A self-hosted Linux machine set up over SSH now keeps updating after you log out, with no `sudo` step. Setup turns on lingering for your user by itself, and says so along with how to undo it (`loginctl disable-linger $USER`). Only when the machine does not allow that does setup name the one `sudo loginctl enable-linger $USER` command, and it says updates stop at logout until you run it. `catalyst runner auto-update status` now says whether updates keep running after you log out. For a machine you already set up, install this version and run `catalyst runner auto-update on`.
|
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
|
@@ -278,7 +278,7 @@ export const FLAG_TABLES = {
|
|
|
278
278
|
option: {
|
|
279
279
|
value: true,
|
|
280
280
|
repeat: true,
|
|
281
|
-
help: "raise: an option (repeatable)",
|
|
281
|
+
help: "raise: an option (repeatable); answer: the letter of the option the owner chose (leave out for free text)",
|
|
282
282
|
},
|
|
283
283
|
default: { value: true, help: "raise: the default if silent" },
|
|
284
284
|
blocks: {
|
|
@@ -315,7 +315,20 @@ export const FLAG_TABLES = {
|
|
|
315
315
|
},
|
|
316
316
|
"ask-key": { value: true, help: "raise: idempotency key" },
|
|
317
317
|
answer: { value: true, help: "accept: the answering comment id" },
|
|
318
|
-
role: { value: true, help: "accept: the role recording the answer" },
|
|
318
|
+
role: { value: true, help: "accept/answer: the role recording the answer" },
|
|
319
|
+
proxy: {
|
|
320
|
+
value: false,
|
|
321
|
+
help: "answer: record an answer the ask's owner gave you in chat (required)",
|
|
322
|
+
},
|
|
323
|
+
quote: { value: true, help: "answer: the owner's exact words" },
|
|
324
|
+
"said-in": {
|
|
325
|
+
value: true,
|
|
326
|
+
help: "answer: the chat session or channel id where the owner said it",
|
|
327
|
+
},
|
|
328
|
+
"said-at": {
|
|
329
|
+
value: true,
|
|
330
|
+
help: "answer: when the owner said it, as an ISO-8601 time (default now)",
|
|
331
|
+
},
|
|
319
332
|
},
|
|
320
333
|
ready: {
|
|
321
334
|
onboarding: {
|
|
@@ -344,7 +357,7 @@ export const FLAG_TABLES = {
|
|
|
344
357
|
env: {
|
|
345
358
|
root: {
|
|
346
359
|
value: true,
|
|
347
|
-
help: "draft, shell: repository directory (default: the current repository's top level)",
|
|
360
|
+
help: "draft, import, shell: repository directory (default: current directory; shell: the current repository's top level)",
|
|
348
361
|
},
|
|
349
362
|
write: {
|
|
350
363
|
value: false,
|
|
@@ -354,6 +367,18 @@ export const FLAG_TABLES = {
|
|
|
354
367
|
value: false,
|
|
355
368
|
help: "draft: show the generated TOML as an additions-only diff",
|
|
356
369
|
},
|
|
370
|
+
"import-values": {
|
|
371
|
+
value: false,
|
|
372
|
+
help: "draft: after --write, store this computer's values for the declared names without asking",
|
|
373
|
+
},
|
|
374
|
+
repo: {
|
|
375
|
+
value: true,
|
|
376
|
+
help: "draft --import-values, import: the repository (owner/name); default: this checkout's GitHub origin",
|
|
377
|
+
},
|
|
378
|
+
yes: {
|
|
379
|
+
value: false,
|
|
380
|
+
help: "import: store without asking (needed without a terminal)",
|
|
381
|
+
},
|
|
357
382
|
"env-file": {
|
|
358
383
|
value: true,
|
|
359
384
|
repeat: true,
|
|
@@ -538,13 +563,13 @@ export const VERB_USAGE = {
|
|
|
538
563
|
queue: "queue [--team K] [--json]",
|
|
539
564
|
watch: "watch [--team K] [--ticket T]... [--project P] [--exec CMD] [--cursor-file <path>] [--from cursor|head]",
|
|
540
565
|
write: "write <comment <ticket> --body|--stdin [--parent] [--bookkeeping] [--as-user] | state <ticket> --slot|--state-id|--state-type | label <ticket> --add... --remove... | create --team --title [--description|--stdin] [--label] [--priority] | reaction <ticket>|--comment <id> --emoji <e> | attachment <ticket> --title --url | session <ticket> [--title] [--plan-file] [--activity]>",
|
|
541
|
-
ask: "ask <raise --team --title [--context] [--option]... [--default] --blocks <ticket>...|--nothing-to-block [--ask-key] [--gates-pr <n>... --released-by <letter>... [--gates-label hold|hold:preview]... [--gates-repo <owner/name>]] | accept <askTicket> --answer <commentId> --role <role> | list [--anyone] [--json]
|
|
566
|
+
ask: "ask <raise --team --title [--context] [--option]... [--default] --blocks <ticket>...|--nothing-to-block [--ask-key] [--gates-pr <n>... --released-by <letter>... [--gates-label hold|hold:preview]... [--gates-repo <owner/name>]] | accept <askTicket> --answer <commentId> --role <role> | answer <askTicket> --proxy --quote <owner's exact words> [--option <letter>] --said-in <session or channel id> [--said-at <ISO-8601 time>] [--role <role>] | proxy [status|on|off] | disown <askTicket> | list [--anyone]> [--json]",
|
|
542
567
|
ready: "ready [--onboarding] [--local-sync] [--json] [--offline]",
|
|
543
568
|
accounts: "accounts [--all] [--json]",
|
|
544
569
|
seat: "seat checkout --size big|med --harness claude,codex,opencode [--session-id ID] [--stage STAGE] [--key-env NAME]",
|
|
545
|
-
mcp: "mcp <add <name> --url URL <--auth none|--bearer SECRET_NAME|--header NAME=SECRET_NAME...>|list|remove <name>> [--json]",
|
|
570
|
+
mcp: "mcp <add <name> --url URL <--auth none|--bearer SECRET_NAME|--header NAME=SECRET_NAME...>|list|remove <name>|status <name>> [--json]",
|
|
546
571
|
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]",
|
|
547
|
-
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)",
|
|
572
|
+
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)",
|
|
548
573
|
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)",
|
|
549
574
|
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]",
|
|
550
575
|
var: "var <set NAME|import <file>> [--repo <owner/name>] [--names NAME[,NAME...]] [--json] (plain environment variables; set reads stdin or a hidden prompt)",
|
package/dist/ask.js
CHANGED
|
@@ -1,28 +1,41 @@
|
|
|
1
|
-
// ask.ts — `ask raise|accept|list`: raise a decision through the contract's ask
|
|
2
|
-
// renders the body from its own template, so this passes fields, never headings),
|
|
3
|
-
// through ask-accept,
|
|
1
|
+
// ask.ts — `ask raise|accept|answer|proxy|disown|list`: raise a decision through the contract's ask
|
|
2
|
+
// route (the cloud renders the body from its own template, so this passes fields, never headings),
|
|
3
|
+
// record an answer through ask-accept, record an owner's chat answer by proxy (CTC-6279) and let the
|
|
4
|
+
// owner switch that on, off or take one back, and list open asks ranked by the work each one holds.
|
|
4
5
|
import { flagBool, flagList, flagString, positionals } from "./args.js";
|
|
5
6
|
import { requireConfig } from "./config.js";
|
|
6
7
|
import { loadContract, teamByKey } from "./contract.js";
|
|
7
|
-
import { UsageError } from "./errors.js";
|
|
8
|
+
import { CliError, UsageError } from "./errors.js";
|
|
8
9
|
import { apiClient } from "./transport.js";
|
|
9
10
|
import { fetchAllPages } from "./pagination.js";
|
|
10
11
|
import { fetchWorkflowStates, postAgent, resolveIssue } from "./write.js";
|
|
11
12
|
export async function cmdAsk(args, ctx) {
|
|
12
13
|
const [sub, ...rest] = positionals(args);
|
|
13
14
|
if (!sub)
|
|
14
|
-
throw new UsageError("ask needs a subcommand: raise | accept | list");
|
|
15
|
+
throw new UsageError("ask needs a subcommand: raise | accept | answer | proxy | disown | list");
|
|
15
16
|
if (sub === "raise" && !flagBool(args, "nothing-to-block") && flagList(args, "blocks").length === 0) {
|
|
16
17
|
throw new UsageError("ask raise needs --blocks <ticket>... (what this decision holds) or --nothing-to-block");
|
|
17
18
|
}
|
|
19
|
+
// Everything these three would refuse is refused before the config is read or anything is sent.
|
|
20
|
+
const proxyBody = sub === "answer" ? proxyAnswerBody(args, rest, ctx.now().getTime()) : undefined;
|
|
21
|
+
const proxyAction = sub === "proxy" ? proxySettingAction(rest) : undefined;
|
|
22
|
+
if (sub === "disown" && (!rest[0] || rest.length > 1))
|
|
23
|
+
throw new UsageError("ask disown needs exactly one <ask>");
|
|
18
24
|
const cfg = requireConfig(ctx);
|
|
19
|
-
const { doc } = await loadContract(ctx, cfg);
|
|
20
25
|
const api = apiClient(cfg, ctx);
|
|
26
|
+
// The two personal settings routes are not contract routes: they act as the logged-in person.
|
|
27
|
+
if (proxyAction)
|
|
28
|
+
return proxySetting(args, ctx, api, proxyAction);
|
|
29
|
+
if (sub === "disown")
|
|
30
|
+
return disown(args, ctx, api, rest[0]);
|
|
31
|
+
const { doc } = await loadContract(ctx, cfg);
|
|
21
32
|
switch (sub) {
|
|
22
33
|
case "raise":
|
|
23
34
|
return raise(args, ctx, doc, api);
|
|
24
35
|
case "accept":
|
|
25
36
|
return accept(args, ctx, doc, api, rest);
|
|
37
|
+
case "answer":
|
|
38
|
+
return answerByProxy(args, ctx, doc, api, proxyBody);
|
|
26
39
|
case "list":
|
|
27
40
|
return list(args, ctx, doc, api, cfg);
|
|
28
41
|
default:
|
|
@@ -130,6 +143,227 @@ async function accept(args, ctx, doc, api, rest) {
|
|
|
130
143
|
ctx.stdout(args.json ? JSON.stringify(result) : `ask ${ask.identifier}: answer ${answer} recorded by ${role}`);
|
|
131
144
|
return 0;
|
|
132
145
|
}
|
|
146
|
+
/** The cloud's bounds on an answer by proxy, checked here first so a refusal never costs a write. */
|
|
147
|
+
const QUOTE_MAX = 4000;
|
|
148
|
+
const SAID_IN_MAX = 200;
|
|
149
|
+
const ROLE_MAX = 64;
|
|
150
|
+
const FUTURE_SKEW_MS = 5 * 60_000;
|
|
151
|
+
const SINGLE_LINE = /^[^\r\n]+$/;
|
|
152
|
+
/** CTC-6279 — `ask answer <ask> --proxy ...` → the ask-answer-proxy body. `--proxy` is required so the
|
|
153
|
+
* command can never be mistaken for the owner answering: an answer the owner posted on the ask is
|
|
154
|
+
* recorded with `ask accept`. The option letter maps A=0; no `--option` is a free-text answer, recorded
|
|
155
|
+
* as written. The quote is sent exactly as given, never trimmed. */
|
|
156
|
+
export function proxyAnswerBody(args, rest, nowMs) {
|
|
157
|
+
const usage = 'ask answer <ask> --proxy --quote "<owner\'s exact words>" [--option <letter>] --said-in <session or channel id> [--said-at <ISO-8601 time>] [--role <role>]';
|
|
158
|
+
if (!flagBool(args, "proxy")) {
|
|
159
|
+
throw new UsageError(`ask answer records only an answer the ask's owner gave you in chat, and needs --proxy: ${usage}. For an answer the owner posted on the ask themselves, use: catalyst ask accept <ask> --answer <commentId> --role <role>`);
|
|
160
|
+
}
|
|
161
|
+
const ask = rest[0];
|
|
162
|
+
if (!ask || rest.length > 1)
|
|
163
|
+
throw new UsageError(`ask answer needs exactly one <ask>: ${usage}`);
|
|
164
|
+
const quote = flagString(args, "quote");
|
|
165
|
+
if (quote === undefined || quote.trim() === "")
|
|
166
|
+
throw new UsageError("ask answer --proxy needs --quote with the owner's exact words");
|
|
167
|
+
if (quote.length > QUOTE_MAX)
|
|
168
|
+
throw new UsageError(`--quote is ${quote.length} characters; the most an answer can carry is ${QUOTE_MAX}`);
|
|
169
|
+
const saidIn = flagString(args, "said-in");
|
|
170
|
+
if (saidIn === undefined || saidIn.trim() === "")
|
|
171
|
+
throw new UsageError("ask answer --proxy needs --said-in <session or channel id>: where the owner said it");
|
|
172
|
+
if (!SINGLE_LINE.test(saidIn) || saidIn.length > SAID_IN_MAX)
|
|
173
|
+
throw new UsageError(`--said-in must be one line of at most ${SAID_IN_MAX} characters`);
|
|
174
|
+
const role = flagString(args, "role");
|
|
175
|
+
if (role !== undefined && (!SINGLE_LINE.test(role) || role.length > ROLE_MAX))
|
|
176
|
+
throw new UsageError(`--role must be one line of at most ${ROLE_MAX} characters`);
|
|
177
|
+
const saidAtRaw = flagString(args, "said-at");
|
|
178
|
+
let saidAtMs = nowMs;
|
|
179
|
+
if (saidAtRaw !== undefined) {
|
|
180
|
+
saidAtMs = Date.parse(saidAtRaw);
|
|
181
|
+
if (!Number.isFinite(saidAtMs))
|
|
182
|
+
throw new UsageError(`--said-at must be an ISO-8601 time, such as 2026-10-20T14:05:00Z (got "${saidAtRaw}")`);
|
|
183
|
+
if (saidAtMs > nowMs + FUTURE_SKEW_MS)
|
|
184
|
+
throw new UsageError(`--said-at ${saidAtRaw} is in the future; give the time the owner said it`);
|
|
185
|
+
}
|
|
186
|
+
const options = flagList(args, "option");
|
|
187
|
+
if (options.length > 1)
|
|
188
|
+
throw new UsageError("ask answer takes one --option: the letter the owner chose");
|
|
189
|
+
let optionIndex;
|
|
190
|
+
if (options.length === 1) {
|
|
191
|
+
const letter = options[0].trim().toUpperCase();
|
|
192
|
+
if (!/^[A-Z]$/.test(letter))
|
|
193
|
+
throw new UsageError(`--option must be one letter from A to Z (got "${options[0]}"); leave it out for a free-text answer`);
|
|
194
|
+
optionIndex = letter.charCodeAt(0) - 65;
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
// The cloud matches an identifier exactly, so `ctc-12` is sent as `CTC-12`.
|
|
198
|
+
askIssueId: /^[A-Za-z][A-Za-z0-9]*-\d+$/.test(ask) ? ask.toUpperCase() : ask,
|
|
199
|
+
ownerQuote: quote,
|
|
200
|
+
...(optionIndex !== undefined ? { optionIndex } : {}),
|
|
201
|
+
saidIn,
|
|
202
|
+
saidAtMs,
|
|
203
|
+
...(role !== undefined ? { acceptedByRole: role } : {}),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
const letterOf = (index) => String.fromCharCode(65 + index);
|
|
207
|
+
/** One plain sentence per refusal reason the ask-answer-proxy route answers. */
|
|
208
|
+
function proxyRefusal(ask, reason, body, optionCount) {
|
|
209
|
+
switch (reason) {
|
|
210
|
+
case "not-an-ask":
|
|
211
|
+
return `${ask} is not an ask, so there is no answer to record.`;
|
|
212
|
+
case "issue-not-mirrored":
|
|
213
|
+
return `Catalyst cannot see ${ask} yet. Check the identifier, or try again in a minute.`;
|
|
214
|
+
case "already-resolved":
|
|
215
|
+
return `${ask} already has an answer. Nothing was recorded.`;
|
|
216
|
+
case "proxy-answer-already-recorded":
|
|
217
|
+
return `An answer by proxy is already recorded on ${ask}, so nothing new was recorded. If an earlier run of this command failed partway, run it again from the same login with the same --quote, --option, --said-in, --said-at and --role to finish recording that answer.`;
|
|
218
|
+
case "readiness-needs-tap":
|
|
219
|
+
return `${ask} must be answered by its owner in the app. An answer by proxy cannot settle it.`;
|
|
220
|
+
case "ask-has-no-team":
|
|
221
|
+
return `${ask} is not in a project Catalyst works, so it cannot be answered by proxy.`;
|
|
222
|
+
case "ask-has-no-owner":
|
|
223
|
+
return `${ask} has no owner, so nobody's chat answer can be recorded on it.`;
|
|
224
|
+
case "proxy-caller-not-owner":
|
|
225
|
+
return `${ask} belongs to someone else. Only an agent logged in as the ask's owner can record their answer. If it is your ask, check that your Linear identity is matched: catalyst identity linear status`;
|
|
226
|
+
case "proxy-answering-off":
|
|
227
|
+
return `${ask}'s owner has not turned on answers by proxy, so nothing was recorded. Only the owner can turn it on, by running: catalyst ask proxy on`;
|
|
228
|
+
case "option-out-of-range": {
|
|
229
|
+
const chosen = body.optionIndex !== undefined ? letterOf(body.optionIndex) : "that option";
|
|
230
|
+
if (optionCount === 0)
|
|
231
|
+
return `${ask} has no lettered options. Leave out --option to record the answer as written.`;
|
|
232
|
+
if (typeof optionCount === "number" && optionCount > 0)
|
|
233
|
+
return `${ask} has options A to ${letterOf(optionCount - 1)}; ${chosen} is not one of them.`;
|
|
234
|
+
return `${ask} has no option ${chosen}.`;
|
|
235
|
+
}
|
|
236
|
+
case "already-in-flight":
|
|
237
|
+
return `Another answer to ${ask} is being recorded right now. Check it again in a moment.`;
|
|
238
|
+
default:
|
|
239
|
+
return `${ask}: the answer was refused (${reason || "no reason given"}).`;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
async function answerByProxy(args, ctx, doc, api, body) {
|
|
243
|
+
const result = await postAgent(api, doc, "ask-answer-proxy", body, { accept: [409, 502] });
|
|
244
|
+
const outcome = result.outcome;
|
|
245
|
+
// A record-failed answer may be partly written, and the cloud finishes it only for the same said-at,
|
|
246
|
+
// so the retry names the time this run sent rather than letting --said-at default to a new now.
|
|
247
|
+
const saidAt = new Date(body.saidAtMs).toISOString();
|
|
248
|
+
if (args.json)
|
|
249
|
+
ctx.stdout(JSON.stringify(outcome === "record-failed" ? { ...result, retrySaidAt: saidAt } : result));
|
|
250
|
+
const ask = typeof result.askIdentifier === "string" ? result.askIdentifier : body.askIssueId;
|
|
251
|
+
if (outcome === "recorded") {
|
|
252
|
+
if (!args.json) {
|
|
253
|
+
const how = body.optionIndex !== undefined ? `${letterOf(body.optionIndex)} by proxy` : "by proxy, in the owner's words";
|
|
254
|
+
ctx.stdout(`${ask} answered ${how}: "${body.ownerQuote}"`);
|
|
255
|
+
const failed = Array.isArray(result.failedBlockedComments) ? result.failedBlockedComments.length : typeof result.failedBlockedComments === "number" ? result.failedBlockedComments : 0;
|
|
256
|
+
if (failed > 0)
|
|
257
|
+
ctx.stdout(`${failed} held ticket${failed === 1 ? "" : "s"} did not get the note naming this answer; the answer itself is recorded.`);
|
|
258
|
+
ctx.stdout(`The owner can take it back with: catalyst ask disown ${ask}`);
|
|
259
|
+
}
|
|
260
|
+
return 0;
|
|
261
|
+
}
|
|
262
|
+
if (outcome === "refused") {
|
|
263
|
+
if (!args.json)
|
|
264
|
+
ctx.stdout(proxyRefusal(ask, typeof result.reason === "string" ? result.reason : "", body, result.optionCount));
|
|
265
|
+
return 1;
|
|
266
|
+
}
|
|
267
|
+
if (outcome === "record-failed") {
|
|
268
|
+
if (!args.json)
|
|
269
|
+
ctx.stdout(`The answer to ${ask} did not finish recording${typeof result.reason === "string" && result.reason ? ` (${result.reason})` : ""}; part of it may already be on the ask. Run the same command again with --said-at ${saidAt} to finish it.`);
|
|
270
|
+
return 2;
|
|
271
|
+
}
|
|
272
|
+
throw new CliError(`ask-answer-proxy answered an outcome this CLI does not know: ${JSON.stringify(outcome)}`, "shape");
|
|
273
|
+
}
|
|
274
|
+
const PROXY_SETTING_PATH = "/api/v1/me/ask-proxy-answering";
|
|
275
|
+
const ASK_DISOWN_PATH = "/api/v1/me/ask-disown";
|
|
276
|
+
function proxySettingAction(rest) {
|
|
277
|
+
const action = rest[0] ?? "status";
|
|
278
|
+
if (rest.length > 1 || !["status", "on", "off"].includes(action))
|
|
279
|
+
throw new UsageError("ask proxy takes: status | on | off");
|
|
280
|
+
return action;
|
|
281
|
+
}
|
|
282
|
+
/** The two refusals both personal routes share, in plain words: an account key (403) throws, an
|
|
283
|
+
* unmatched Linear identity (409) prints and exits 1. Returns undefined when the status was neither;
|
|
284
|
+
* --json has already printed the body, so it prints nothing more. */
|
|
285
|
+
function personalRefusal(args, ctx, status, body, what) {
|
|
286
|
+
const error = body && typeof body === "object" ? body.error : undefined;
|
|
287
|
+
if (status === 403 && error === "personal_credential_required") {
|
|
288
|
+
throw new CliError(`${what} needs your own login, not the workspace's shared key. Run catalyst login as yourself, then try again.`, "member-required", 2, 403);
|
|
289
|
+
}
|
|
290
|
+
if (status === 409 && error === "no_linear_identity") {
|
|
291
|
+
if (!args.json)
|
|
292
|
+
ctx.stdout("Your Linear identity is not matched yet, so Catalyst cannot tell which asks are yours. Match it with: catalyst identity linear options, then catalyst identity linear set <linearUserId>");
|
|
293
|
+
return 1;
|
|
294
|
+
}
|
|
295
|
+
if (status === 403 || (status === 409 && error !== undefined)) {
|
|
296
|
+
throw new CliError(`${what} was refused (${status}${typeof error === "string" ? `: ${error}` : ""})`, status === 403 ? "forbidden" : "http", 2, status);
|
|
297
|
+
}
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
function proxySettingLine(enabled, changed) {
|
|
301
|
+
return enabled
|
|
302
|
+
? `${changed ? "Turned on: " : "On: "}agents may record an answer you give them in chat on your asks. Turn it off with: catalyst ask proxy off`
|
|
303
|
+
: `${changed ? "Turned off: " : "Off: "}agents may not record an answer you give them in chat; answer on the ask itself. Turn it on with: catalyst ask proxy on`;
|
|
304
|
+
}
|
|
305
|
+
async function proxySetting(args, ctx, api, action) {
|
|
306
|
+
const res = action === "status"
|
|
307
|
+
? await api.getJson(PROXY_SETTING_PATH, { accept: [403, 409], parseAccepted: true })
|
|
308
|
+
: await api.putJson(PROXY_SETTING_PATH, { enabled: action === "on" }, { accept: [403, 409] });
|
|
309
|
+
if (args.json && res.status !== 403)
|
|
310
|
+
ctx.stdout(JSON.stringify(res.body ?? {}));
|
|
311
|
+
const refused = personalRefusal(args, ctx, res.status, res.body, "ask proxy");
|
|
312
|
+
if (refused !== undefined)
|
|
313
|
+
return refused;
|
|
314
|
+
if (typeof res.body?.enabled !== "boolean")
|
|
315
|
+
throw new CliError(`${PROXY_SETTING_PATH} answered without an enabled flag`, "shape");
|
|
316
|
+
if (!args.json)
|
|
317
|
+
ctx.stdout(proxySettingLine(res.body.enabled, action !== "status"));
|
|
318
|
+
return 0;
|
|
319
|
+
}
|
|
320
|
+
/** One plain sentence per refusal reason the ask-disown route answers. */
|
|
321
|
+
function disownRefusal(ask, reason) {
|
|
322
|
+
switch (reason) {
|
|
323
|
+
case "not-owner":
|
|
324
|
+
return `${ask} is not your ask, so you cannot take back its answer.`;
|
|
325
|
+
case "not-proxy-answered":
|
|
326
|
+
return `${ask} was not answered by proxy, so there is nothing to take back.`;
|
|
327
|
+
case "already-disowned":
|
|
328
|
+
return `You already took back the answer on ${ask}.`;
|
|
329
|
+
case "already-in-flight":
|
|
330
|
+
return `A change to ${ask} is being recorded right now. Check it again in a moment.`;
|
|
331
|
+
default:
|
|
332
|
+
return `${ask}: taking back the answer was refused (${reason || "no reason given"}).`;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
async function disown(args, ctx, api, askTicket) {
|
|
336
|
+
const ask = await resolveIssue(api, askTicket);
|
|
337
|
+
const res = await api.postJson(ASK_DISOWN_PATH, { issueId: ask.id }, { accept: [403, 409, 502] });
|
|
338
|
+
const body = res.body ?? {};
|
|
339
|
+
if (args.json && res.status !== 403)
|
|
340
|
+
ctx.stdout(JSON.stringify(body));
|
|
341
|
+
const refused = personalRefusal(args, ctx, res.status, body, "ask disown");
|
|
342
|
+
if (refused !== undefined)
|
|
343
|
+
return refused;
|
|
344
|
+
const name = typeof body.askIdentifier === "string" ? body.askIdentifier : ask.identifier;
|
|
345
|
+
switch (body.outcome) {
|
|
346
|
+
case "disowned": {
|
|
347
|
+
if (args.json)
|
|
348
|
+
return 0;
|
|
349
|
+
ctx.stdout(`${name} is open again: the answer recorded by proxy is taken back.`);
|
|
350
|
+
const tickets = Array.isArray(body.tickets) ? body.tickets : [];
|
|
351
|
+
for (const t of tickets)
|
|
352
|
+
ctx.stdout(` ${String(t.ticket ?? "?")}: ${String(t.phase ?? "?")} ${String(t.status ?? "")}`.trimEnd());
|
|
353
|
+
return 0;
|
|
354
|
+
}
|
|
355
|
+
case "refused":
|
|
356
|
+
if (!args.json)
|
|
357
|
+
ctx.stdout(disownRefusal(name, typeof body.reason === "string" ? body.reason : ""));
|
|
358
|
+
return 1;
|
|
359
|
+
case "disown-failed":
|
|
360
|
+
if (!args.json)
|
|
361
|
+
ctx.stdout(`The answer on ${name} was not taken back${typeof body.reason === "string" && body.reason ? ` (${body.reason})` : ""}. Nothing changed; run the same command again.`);
|
|
362
|
+
return 2;
|
|
363
|
+
default:
|
|
364
|
+
throw new CliError(`${ASK_DISOWN_PATH} answered an outcome this CLI does not know: ${JSON.stringify(body.outcome)}`, "shape");
|
|
365
|
+
}
|
|
366
|
+
}
|
|
133
367
|
/** Decide the inbox scope from the connected identity and the `--anyone` flag. A personal key with a
|
|
134
368
|
* matched Linear identity reads "mine" by default; `--anyone` widens; an unmatched identity or a
|
|
135
369
|
* host key cannot mean "me" and says so rather than filtering to nothing. */
|
package/dist/capabilities.js
CHANGED
|
@@ -37,12 +37,17 @@ 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" },
|
|
43
44
|
{ verb: "ask raise|accept|list", does: "raise a decision to a human, or answer one", needs: "member", routes: [agent("POST", "ask"), agent("POST", "ask-accept")], since: "0.1.0" },
|
|
45
|
+
{ verb: "ask answer --proxy", does: "record an answer the ask's owner gave in chat, quoted exactly, when the owner has turned answers by proxy on", needs: "member", routes: [agent("POST", "ask-answer-proxy")], since: "0.16.23" },
|
|
46
|
+
{ verb: "ask proxy status|on|off", does: "read or set whether agents may record an answer you give them in chat (your own login only)", needs: "member", routes: [], since: "0.16.23" },
|
|
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" },
|
|
44
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" },
|
|
45
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" },
|
|
46
51
|
];
|
|
47
52
|
/** Judge each capability against the routes a contract serves. `null` means no contract is cached,
|
|
48
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]>',
|
|
@@ -95,7 +95,7 @@ export function usageText() {
|
|
|
95
95
|
" catalyst explain <ticket> | history <ticket> | running [--ticket T --phase P] | queue [--team K]",
|
|
96
96
|
" catalyst watch [--team K] [--ticket T]... [--project P] [--exec CMD]",
|
|
97
97
|
" catalyst write <comment|state|label|create|reaction|attachment|session> ...",
|
|
98
|
-
" catalyst ask <raise|accept|list> ...",
|
|
98
|
+
" catalyst ask <raise|accept|answer --proxy|proxy [status|on|off]|disown|list> ...",
|
|
99
99
|
" catalyst release <ticket> --because <what changed> [--retry-unchanged] [--dry-run] | release --class <c> --team <K> ...",
|
|
100
100
|
" catalyst environment [read] | environment propose --file <path>|--stdin [--approve] | environment approve (your account; needs login)",
|
|
101
101
|
" catalyst 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)",
|
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
|
-
|
|
13
|
-
|
|
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
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
for (const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
+
}
|