@openparachute/vault 0.4.6-rc.3 → 0.4.7-rc.1

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.
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Helpers for `parachute-vault export --watch` and `--git-commit`.
3
+ *
4
+ * Split out from `cli.ts` so the template renderer and git-shell helpers are
5
+ * unit-testable without spawning the full CLI. The CLI imports and wires
6
+ * these into `cmdExport`'s watch loop; tests import them directly.
7
+ *
8
+ * No new deps — `git` is shelled out via `Bun.spawn`. Watch detection is
9
+ * polling: every `intervalSeconds`, run an incremental export with the
10
+ * cursor captured at the *start* of the previous cycle. Vault writes are
11
+ * HTTP-mediated and don't surface to filesystem watchers (the bun:sqlite DB
12
+ * is opaque), so polling on `updated_at >= cursor` is the simplest robust
13
+ * detection. See `parachute-patterns/cookbook/vault-portable-export.md`.
14
+ */
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Commit message templating
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /** Variables exposed to `--git-message-template`. */
21
+ export interface CommitTemplateVars {
22
+ /** ISO-formatted UTC timestamp captured at commit time. */
23
+ date: string;
24
+ /** Count of notes changed since the previous export cursor. */
25
+ notes_changed: number;
26
+ /**
27
+ * First changed note's title (or path / id fallback) since the previous
28
+ * cursor — useful for single-note commits. Empty when nothing matched or
29
+ * on the initial export (where "first changed" is ambiguous).
30
+ */
31
+ first_note_title: string;
32
+ /** Source vault name. */
33
+ vault_name: string;
34
+ }
35
+
36
+ /**
37
+ * Substitute `{{var}}` tokens in `template`. Recognized vars:
38
+ *
39
+ * `{{date}}` `{{notes_changed}}` `{{plural}}` `{{first_note_title}}` `{{vault_name}}`
40
+ *
41
+ * `{{plural}}` is `""` when `notes_changed === 1`, else `"s"` — so
42
+ * `"{{notes_changed}} note{{plural}}"` reads naturally for both 1 and N
43
+ * without the operator writing their own conditional.
44
+ *
45
+ * Unknown tokens pass through untouched (so a typo in the template is
46
+ * visible in the commit message rather than silently dropped).
47
+ */
48
+ export function renderCommitMessage(template: string, vars: CommitTemplateVars): string {
49
+ return template
50
+ .replace(/\{\{date\}\}/g, vars.date)
51
+ .replace(/\{\{notes_changed\}\}/g, String(vars.notes_changed))
52
+ .replace(/\{\{plural\}\}/g, vars.notes_changed === 1 ? "" : "s")
53
+ .replace(/\{\{first_note_title\}\}/g, vars.first_note_title)
54
+ .replace(/\{\{vault_name\}\}/g, vars.vault_name);
55
+ }
56
+
57
+ /** Default commit message template. Reads as "export: <iso> (N note[s])". */
58
+ export const DEFAULT_COMMIT_TEMPLATE =
59
+ "export: {{date}} ({{notes_changed}} note{{plural}})";
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Git shell helpers (Bun.spawn — no new dep)
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /**
66
+ * Return true if `dir` is inside a git working tree.
67
+ *
68
+ * Implementation: `git rev-parse --is-inside-work-tree`, exit code 0 = yes.
69
+ * No reliance on `.git/` existing as a directory (handles worktrees + bare
70
+ * submodule layouts uniformly).
71
+ */
72
+ export async function isGitRepo(dir: string): Promise<boolean> {
73
+ const proc = Bun.spawn(["git", "rev-parse", "--is-inside-work-tree"], {
74
+ cwd: dir,
75
+ stdout: "pipe",
76
+ stderr: "pipe",
77
+ });
78
+ const exitCode = await proc.exited;
79
+ return exitCode === 0;
80
+ }
81
+
82
+ /** List the paths git currently has staged (cached). One per line. */
83
+ export async function listStagedFiles(repoDir: string): Promise<string[]> {
84
+ const proc = Bun.spawn(["git", "diff", "--cached", "--name-only"], {
85
+ cwd: repoDir,
86
+ stdout: "pipe",
87
+ stderr: "pipe",
88
+ });
89
+ await proc.exited;
90
+ const out = new TextDecoder().decode(await new Response(proc.stdout).arrayBuffer());
91
+ return out
92
+ .split("\n")
93
+ .map((l) => l.trim())
94
+ .filter((l) => l.length > 0);
95
+ }
96
+
97
+ /** Run `git add -A` in `repoDir`. Returns true on success. */
98
+ export async function gitAddAll(repoDir: string): Promise<{ ok: boolean; stderr: string }> {
99
+ const proc = Bun.spawn(["git", "add", "-A"], {
100
+ cwd: repoDir,
101
+ stdout: "pipe",
102
+ stderr: "pipe",
103
+ });
104
+ const exitCode = await proc.exited;
105
+ const stderr = new TextDecoder().decode(await new Response(proc.stderr).arrayBuffer());
106
+ return { ok: exitCode === 0, stderr: stderr.trim() };
107
+ }
108
+
109
+ /** Run `git commit -m <message>` in `repoDir`. Returns true on success. */
110
+ export async function gitCommit(
111
+ repoDir: string,
112
+ message: string,
113
+ ): Promise<{ ok: boolean; stderr: string }> {
114
+ const proc = Bun.spawn(["git", "commit", "-m", message], {
115
+ cwd: repoDir,
116
+ stdout: "pipe",
117
+ stderr: "pipe",
118
+ });
119
+ const exitCode = await proc.exited;
120
+ const stderr = new TextDecoder().decode(await new Response(proc.stderr).arrayBuffer());
121
+ return { ok: exitCode === 0, stderr: stderr.trim() };
122
+ }
123
+
124
+ /** Run `git push` in `repoDir`. Returns true on success. */
125
+ export async function gitPush(
126
+ repoDir: string,
127
+ ): Promise<{ ok: boolean; stderr: string }> {
128
+ const proc = Bun.spawn(["git", "push"], {
129
+ cwd: repoDir,
130
+ stdout: "pipe",
131
+ stderr: "pipe",
132
+ });
133
+ const exitCode = await proc.exited;
134
+ const stderr = new TextDecoder().decode(await new Response(proc.stderr).arrayBuffer());
135
+ return { ok: exitCode === 0, stderr: stderr.trim() };
136
+ }
137
+
138
+ /**
139
+ * Unstage everything in `repoDir` (no-op if nothing staged).
140
+ *
141
+ * Uses bare `git reset` (no `HEAD`, no path args) so the call works on a
142
+ * fresh repo with no commits yet — an operator who runs `--git-commit`
143
+ * against a `git init`'d empty repo and lands in the `.parachute/`-only
144
+ * skip path on the first cycle would otherwise leave staging dirty.
145
+ * Both `git reset HEAD -- .` and `git restore --staged .` require a
146
+ * resolvable HEAD; bare `git reset` falls back cleanly when none exists.
147
+ * See vault#346 reviewer note.
148
+ */
149
+ export async function gitUnstageAll(repoDir: string): Promise<void> {
150
+ const proc = Bun.spawn(["git", "reset"], {
151
+ cwd: repoDir,
152
+ stdout: "pipe",
153
+ stderr: "pipe",
154
+ });
155
+ await proc.exited;
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Cycle-level helpers: commit-or-skip decision + execution
160
+ // ---------------------------------------------------------------------------
161
+
162
+ /**
163
+ * Decide whether the currently-staged set warrants a commit, given the
164
+ * `notes_changed` reported by the export cycle.
165
+ *
166
+ * Skip rules:
167
+ * - Empty staging → skip (nothing to commit).
168
+ * - `notes_changed === 0` AND every staged path lives under `.parachute/`
169
+ * → skip. This filters the pure `vault.yaml` `exported_at` churn that
170
+ * would otherwise produce a commit per watch interval forever.
171
+ *
172
+ * Otherwise → commit.
173
+ *
174
+ * Note: a vault rename (vault.yaml `name` field changes) also follows the
175
+ * skip path. Metadata-only changes don't commit by design — the operator
176
+ * who renames a vault and wants that in the git mirror history can either
177
+ * touch a note or run a one-shot `git commit --allow-empty -m "rename vault"`
178
+ * by hand.
179
+ */
180
+ export function shouldCommit(stagedFiles: string[], notesChanged: number): {
181
+ commit: boolean;
182
+ reason: "ok" | "empty" | "parachute_meta_only";
183
+ } {
184
+ if (stagedFiles.length === 0) return { commit: false, reason: "empty" };
185
+ if (notesChanged === 0 && stagedFiles.every((f) => f.startsWith(".parachute/"))) {
186
+ return { commit: false, reason: "parachute_meta_only" };
187
+ }
188
+ return { commit: true, reason: "ok" };
189
+ }
190
+
191
+ /**
192
+ * Stage → decide → commit → optionally push. Logs status to stdout/stderr.
193
+ * Returns whether a commit landed.
194
+ */
195
+ export async function runGitCommitCycle(opts: {
196
+ repoDir: string;
197
+ template: string;
198
+ notesChanged: number;
199
+ vaultName: string;
200
+ firstNoteTitle: string;
201
+ push: boolean;
202
+ /** Override for tests — defaults to `new Date().toISOString()`. */
203
+ now?: () => string;
204
+ }): Promise<{ committed: boolean; message?: string }> {
205
+ const now = opts.now ?? (() => new Date().toISOString());
206
+
207
+ const add = await gitAddAll(opts.repoDir);
208
+ if (!add.ok) {
209
+ console.error(`[git-commit] git add failed: ${add.stderr}`);
210
+ return { committed: false };
211
+ }
212
+
213
+ const staged = await listStagedFiles(opts.repoDir);
214
+ const decision = shouldCommit(staged, opts.notesChanged);
215
+ if (!decision.commit) {
216
+ if (decision.reason === "empty") {
217
+ console.log(`[git-commit] no changes; skipping commit`);
218
+ } else if (decision.reason === "parachute_meta_only") {
219
+ // Unstage so the next cycle starts clean and the operator's
220
+ // `git status` reflects the skip-decision.
221
+ await gitUnstageAll(opts.repoDir);
222
+ console.log(
223
+ `[git-commit] no note changes (only .parachute/ metadata); skipping commit`,
224
+ );
225
+ }
226
+ return { committed: false };
227
+ }
228
+
229
+ const message = renderCommitMessage(opts.template, {
230
+ date: now(),
231
+ notes_changed: opts.notesChanged,
232
+ first_note_title: opts.firstNoteTitle,
233
+ vault_name: opts.vaultName,
234
+ });
235
+
236
+ const commit = await gitCommit(opts.repoDir, message);
237
+ if (!commit.ok) {
238
+ console.error(`[git-commit] git commit failed: ${commit.stderr}`);
239
+ return { committed: false };
240
+ }
241
+ console.log(`[git-commit] ${message}`);
242
+
243
+ if (opts.push) {
244
+ const pushResult = await gitPush(opts.repoDir);
245
+ if (!pushResult.ok) {
246
+ // Non-fatal — a network blip shouldn't kill a watch loop. Warn and
247
+ // move on; the next successful commit's push will catch up history.
248
+ console.warn(`[git-commit] git push failed (non-fatal): ${pushResult.stderr}`);
249
+ } else {
250
+ console.log(`[git-commit] pushed`);
251
+ }
252
+ }
253
+
254
+ return { committed: true, message };
255
+ }
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Tests for `parachute-vault mcp-config <vault-name>` — the JSON synthesizer
3
+ * for `claude -p --mcp-config '<json>'` runners. Three concerns:
4
+ *
5
+ * 1. Pure helper: `buildMcpConfigJson` — shape correctness for both literal
6
+ * and `--env-vars` template modes.
7
+ * 2. CLI flag parsing: required vault arg, --token / PARACHUTE_VAULT_TOKEN
8
+ * precedence, --base-url override, --env-vars short-circuit (no vault
9
+ * lookup, no bearer required).
10
+ * 3. End-to-end stdout — verify pipe-to-jq usability (valid JSON, exact
11
+ * shape claude consumes).
12
+ */
13
+
14
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
15
+ import fs from "node:fs";
16
+ import os from "node:os";
17
+ import path from "node:path";
18
+ import { buildMcpConfigJson } from "./mcp-install.ts";
19
+
20
+ const CLI = path.resolve(import.meta.dir, "cli.ts");
21
+
22
+ function runCli(
23
+ args: string[],
24
+ parachuteHome: string,
25
+ extraEnv: Record<string, string | undefined> = {},
26
+ cwd?: string,
27
+ ): { exitCode: number; stdout: string; stderr: string } {
28
+ const proc = Bun.spawnSync({
29
+ cmd: ["bun", CLI, ...args],
30
+ cwd: cwd ?? parachuteHome,
31
+ env: {
32
+ ...process.env,
33
+ PARACHUTE_HOME: parachuteHome,
34
+ HOME: parachuteHome,
35
+ ...extraEnv,
36
+ },
37
+ stdout: "pipe",
38
+ stderr: "pipe",
39
+ });
40
+ return {
41
+ exitCode: proc.exitCode ?? -1,
42
+ stdout: new TextDecoder().decode(proc.stdout),
43
+ stderr: new TextDecoder().decode(proc.stderr),
44
+ };
45
+ }
46
+
47
+ function setupBareVault(parachuteHome: string, name: string): void {
48
+ const vaultsDir = path.join(parachuteHome, "vault", "data");
49
+ fs.mkdirSync(path.join(vaultsDir, name), { recursive: true });
50
+ fs.writeFileSync(
51
+ path.join(vaultsDir, name, "vault.yaml"),
52
+ `name: ${name}\napi_keys: []\n`,
53
+ );
54
+ const globalPath = path.join(parachuteHome, "vault", "config.yaml");
55
+ if (!fs.existsSync(globalPath)) {
56
+ fs.mkdirSync(path.dirname(globalPath), { recursive: true });
57
+ fs.writeFileSync(globalPath, `default_vault: ${name}\nport: 1940\n`);
58
+ }
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Unit: buildMcpConfigJson
63
+ // ---------------------------------------------------------------------------
64
+
65
+ describe("buildMcpConfigJson", () => {
66
+ test("literal mode embeds the bearer and URL verbatim", () => {
67
+ const json = buildMcpConfigJson({
68
+ vaultName: "gitcoin",
69
+ baseUrl: "http://127.0.0.1:1940",
70
+ bearer: "pvt_abc123",
71
+ });
72
+ const parsed = JSON.parse(json);
73
+ expect(parsed.mcpServers["parachute-vault-gitcoin"]).toEqual({
74
+ type: "http",
75
+ url: "http://127.0.0.1:1940/vault/gitcoin/mcp",
76
+ headers: { Authorization: "Bearer pvt_abc123" },
77
+ });
78
+ });
79
+
80
+ test("env-var mode emits placeholders that survive JSON parsing", () => {
81
+ const json = buildMcpConfigJson({
82
+ vaultName: "gitcoin",
83
+ baseUrl: "",
84
+ bearer: "",
85
+ useEnvVars: true,
86
+ });
87
+ // The placeholders must come through verbatim — `${...}` is a normal
88
+ // string value to JSON, and that's what shell expansion later acts on.
89
+ const parsed = JSON.parse(json);
90
+ expect(parsed.mcpServers["parachute-vault-gitcoin"].url).toBe(
91
+ "${PARACHUTE_HUB_URL}/vault/gitcoin/mcp",
92
+ );
93
+ expect(parsed.mcpServers["parachute-vault-gitcoin"].headers.Authorization).toBe(
94
+ "Bearer ${PARACHUTE_VAULT_TOKEN}",
95
+ );
96
+ });
97
+
98
+ test("strips trailing slash from baseUrl (no double slashes in the path)", () => {
99
+ const json = buildMcpConfigJson({
100
+ vaultName: "default",
101
+ baseUrl: "https://hub.example.com/",
102
+ bearer: "t",
103
+ });
104
+ const parsed = JSON.parse(json);
105
+ expect(parsed.mcpServers["parachute-vault-default"].url).toBe(
106
+ "https://hub.example.com/vault/default/mcp",
107
+ );
108
+ });
109
+
110
+ test("entry key always uses parachute-vault-<name> (multi-vault-ready)", () => {
111
+ const json = buildMcpConfigJson({
112
+ vaultName: "work",
113
+ baseUrl: "http://127.0.0.1:1940",
114
+ bearer: "t",
115
+ });
116
+ const parsed = JSON.parse(json);
117
+ expect(Object.keys(parsed.mcpServers)).toEqual(["parachute-vault-work"]);
118
+ });
119
+ });
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // CLI end-to-end
123
+ // ---------------------------------------------------------------------------
124
+
125
+ describe("mcp-config CLI", () => {
126
+ let tmp: string;
127
+ beforeEach(() => {
128
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "vault-mcp-config-"));
129
+ });
130
+ afterEach(() => {
131
+ fs.rmSync(tmp, { recursive: true, force: true });
132
+ });
133
+
134
+ test("emits well-formed JSON to stdout with --token", () => {
135
+ setupBareVault(tmp, "gitcoin");
136
+ const res = runCli(
137
+ ["mcp-config", "gitcoin", "--token", "pvt_test123"],
138
+ tmp,
139
+ );
140
+ expect(res.exitCode).toBe(0);
141
+ const parsed = JSON.parse(res.stdout);
142
+ expect(parsed.mcpServers["parachute-vault-gitcoin"].type).toBe("http");
143
+ expect(parsed.mcpServers["parachute-vault-gitcoin"].headers.Authorization).toBe(
144
+ "Bearer pvt_test123",
145
+ );
146
+ expect(parsed.mcpServers["parachute-vault-gitcoin"].url).toContain(
147
+ "/vault/gitcoin/mcp",
148
+ );
149
+ });
150
+
151
+ test("reads PARACHUTE_VAULT_TOKEN env var when --token absent", () => {
152
+ setupBareVault(tmp, "gitcoin");
153
+ const res = runCli(
154
+ ["mcp-config", "gitcoin"],
155
+ tmp,
156
+ { PARACHUTE_VAULT_TOKEN: "pvt_envvar" },
157
+ );
158
+ expect(res.exitCode).toBe(0);
159
+ const parsed = JSON.parse(res.stdout);
160
+ expect(parsed.mcpServers["parachute-vault-gitcoin"].headers.Authorization).toBe(
161
+ "Bearer pvt_envvar",
162
+ );
163
+ });
164
+
165
+ test("--token wins over PARACHUTE_VAULT_TOKEN when both are present", () => {
166
+ setupBareVault(tmp, "gitcoin");
167
+ const res = runCli(
168
+ ["mcp-config", "gitcoin", "--token", "from-flag"],
169
+ tmp,
170
+ { PARACHUTE_VAULT_TOKEN: "from-env" },
171
+ );
172
+ expect(res.exitCode).toBe(0);
173
+ const parsed = JSON.parse(res.stdout);
174
+ expect(parsed.mcpServers["parachute-vault-gitcoin"].headers.Authorization).toBe(
175
+ "Bearer from-flag",
176
+ );
177
+ });
178
+
179
+ test("exits 1 with a clear error when no token is supplied", () => {
180
+ setupBareVault(tmp, "gitcoin");
181
+ // Explicitly unset PARACHUTE_VAULT_TOKEN (test env may have it).
182
+ const res = runCli(
183
+ ["mcp-config", "gitcoin"],
184
+ tmp,
185
+ { PARACHUTE_VAULT_TOKEN: "" },
186
+ );
187
+ expect(res.exitCode).toBe(1);
188
+ expect(res.stderr).toMatch(/No bearer token/);
189
+ expect(res.stderr).toMatch(/--token/);
190
+ expect(res.stderr).toMatch(/PARACHUTE_VAULT_TOKEN/);
191
+ // The error message points operators at the workaround paths so they
192
+ // can recover without re-reading the docs.
193
+ expect(res.stderr).toMatch(/parachute-vault tokens create/);
194
+ expect(res.stderr).toMatch(/--env-vars/);
195
+ });
196
+
197
+ test("exits 1 when the vault doesn't exist (literal mode)", () => {
198
+ setupBareVault(tmp, "default");
199
+ const res = runCli(
200
+ ["mcp-config", "ghost", "--token", "t"],
201
+ tmp,
202
+ );
203
+ expect(res.exitCode).toBe(1);
204
+ expect(res.stderr).toMatch(/Vault "ghost" not found/);
205
+ });
206
+
207
+ test("--env-vars short-circuits — no vault lookup, no token required", () => {
208
+ // Deliberately no setupBareVault: --env-vars mode emits the template
209
+ // shape without resolving anything. Operators commit the template from
210
+ // machines that may not have the target vault.
211
+ const res = runCli(
212
+ ["mcp-config", "gitcoin", "--env-vars"],
213
+ tmp,
214
+ { PARACHUTE_VAULT_TOKEN: "" },
215
+ );
216
+ expect(res.exitCode).toBe(0);
217
+ const parsed = JSON.parse(res.stdout);
218
+ expect(parsed.mcpServers["parachute-vault-gitcoin"].url).toBe(
219
+ "${PARACHUTE_HUB_URL}/vault/gitcoin/mcp",
220
+ );
221
+ expect(parsed.mcpServers["parachute-vault-gitcoin"].headers.Authorization).toBe(
222
+ "Bearer ${PARACHUTE_VAULT_TOKEN}",
223
+ );
224
+ });
225
+
226
+ test("--base-url overrides the auto-detected origin", () => {
227
+ setupBareVault(tmp, "gitcoin");
228
+ const res = runCli(
229
+ [
230
+ "mcp-config",
231
+ "gitcoin",
232
+ "--token",
233
+ "t",
234
+ "--base-url",
235
+ "https://hub.taildf9ce2.ts.net",
236
+ ],
237
+ tmp,
238
+ );
239
+ expect(res.exitCode).toBe(0);
240
+ const parsed = JSON.parse(res.stdout);
241
+ expect(parsed.mcpServers["parachute-vault-gitcoin"].url).toBe(
242
+ "https://hub.taildf9ce2.ts.net/vault/gitcoin/mcp",
243
+ );
244
+ });
245
+
246
+ test("missing vault-name arg prints usage and exits 1", () => {
247
+ const res = runCli(["mcp-config"], tmp);
248
+ expect(res.exitCode).toBe(1);
249
+ expect(res.stderr).toMatch(/Usage:/);
250
+ expect(res.stderr).toMatch(/mcp-config <vault-name>/);
251
+ });
252
+
253
+ test("flag passed in place of vault-name (e.g. --help) is rejected", () => {
254
+ // Guards against `parachute-vault mcp-config --token foo` being
255
+ // silently misparsed as vault-name="--token".
256
+ const res = runCli(["mcp-config", "--help"], tmp);
257
+ expect(res.exitCode).toBe(1);
258
+ expect(res.stderr).toMatch(/Usage:/);
259
+ });
260
+ });
@@ -580,6 +580,10 @@ describe("mcp-install end-to-end", () => {
580
580
  // scoped to this directory (and how to widen if they wanted global).
581
581
  expect(res.stdout).toMatch(/this directory only/);
582
582
  expect(res.stdout).toMatch(/--install-scope user/);
583
+ // Headless-flow heads-up: local-scope entries don't propagate to
584
+ // claude -p subprocesses; operators wiring up runners need to know.
585
+ expect(res.stdout).toMatch(/Headless flows/);
586
+ expect(res.stdout).toMatch(/claude -p/);
583
587
  });
584
588
 
585
589
  test("--install-scope project writes <cwd>/.mcp.json instead of ~/.claude.json", () => {
@@ -798,6 +802,62 @@ describe("mcp-install end-to-end", () => {
798
802
  expect(bearer).toMatch(/^Bearer pvt_/);
799
803
  });
800
804
 
805
+ test("--dry-run describes the write without touching disk or hitting the hub", () => {
806
+ // Aaron hit this when probing `mcp-install --help`: the bare CLI
807
+ // dispatch was creating an empty `projects[<cwd>]` slot in
808
+ // ~/.claude.json as a side effect of writing the install. --dry-run
809
+ // is the deliberate "tell me what you'd do" path.
810
+ setupBareVault(tmp, "default");
811
+ const res = runCli(
812
+ ["mcp-install", "--install-scope", "user", "--token", "should-not-be-used", "--dry-run"],
813
+ tmp,
814
+ );
815
+ expect(res.exitCode).toBe(0);
816
+ // No claude.json should exist: the install was inhibited.
817
+ expect(fs.existsSync(path.join(tmp, ".claude.json"))).toBe(false);
818
+ // The dry-run output names target file, entry key, URL, and how to apply.
819
+ expect(res.stdout).toMatch(/\[dry-run\]/);
820
+ expect(res.stdout).toMatch(/Target file:/);
821
+ expect(res.stdout).toMatch(/Entry key:\s+parachute-vault/);
822
+ expect(res.stdout).toMatch(/MCP URL:/);
823
+ expect(res.stdout).toMatch(/Re-run without --dry-run to apply\./);
824
+ });
825
+
826
+ test("--dry-run works for a vault that doesn't exist yet (probe shape)", () => {
827
+ // The dry-run contract is "no side effects, including failures on
828
+ // state the caller is asking about." A future-vault probe — would
829
+ // installing for this not-yet-created vault land where I expect? —
830
+ // is a legitimate pre-create check, not an error. The
831
+ // vault-existence guard runs only on the real-install path.
832
+ setupBareVault(tmp, "default");
833
+ const res = runCli(
834
+ ["mcp-install", "--vault", "future-vault", "--token", "t", "--dry-run"],
835
+ tmp,
836
+ );
837
+ expect(res.exitCode).toBe(0);
838
+ expect(res.stdout).toMatch(/\[dry-run\]/);
839
+ // Output names the absent vault explicitly so the operator can tell
840
+ // the dry-run worked against the right target.
841
+ expect(res.stdout).toMatch(/Vault:\s+future-vault/);
842
+ expect(res.stdout).toMatch(/does not exist yet/);
843
+ expect(res.stdout).toMatch(/Entry key:\s+parachute-vault-future-vault/);
844
+ // No claude.json written — dry-run still didn't touch disk.
845
+ expect(fs.existsSync(path.join(tmp, ".claude.json"))).toBe(false);
846
+ });
847
+
848
+ test("--dry-run with --mint skips the hub round-trip and operator-token check", () => {
849
+ // The whole point of --dry-run is "no side effects" — that includes
850
+ // the hub-mint network call. A naive implementation might still try
851
+ // to read operator.token and fail before the dry-run print fires.
852
+ setupBareVault(tmp, "default");
853
+ // Deliberately no operator.token file present, no PARACHUTE_HUB_ORIGIN.
854
+ const res = runCli(["mcp-install", "--dry-run"], tmp, { PARACHUTE_HUB_ORIGIN: "" });
855
+ expect(res.exitCode).toBe(0);
856
+ expect(res.stdout).toMatch(/\[dry-run\]/);
857
+ expect(res.stderr).not.toMatch(/No operator token found/);
858
+ expect(res.stderr).not.toMatch(/No hub origin configured/);
859
+ });
860
+
801
861
  test("subsequent --token install on top of an existing entry overwrites the bearer (user scope)", () => {
802
862
  setupBareVault(tmp, "default");
803
863
  runCli(["mcp-install", "--install-scope", "user", "--token", "first"], tmp);
@@ -334,6 +334,67 @@ export async function mintHubJwt(opts: MintHubJwtOpts): Promise<MintedHubJwt | M
334
334
  };
335
335
  }
336
336
 
337
+ // ---------------------------------------------------------------------------
338
+ // `mcp-config` — emit the JSON shape `claude -p --mcp-config '<json>'` expects
339
+ // ---------------------------------------------------------------------------
340
+
341
+ /**
342
+ * Build the JSON config shape consumed by `claude -p --mcp-config '<json>'`.
343
+ *
344
+ * Two emission modes:
345
+ *
346
+ * - **literal** (`useEnvVars: false`): the URL and bearer are inlined. The
347
+ * emitted JSON is ready to splice into a runner via shell substitution
348
+ * (`--mcp-config "$(parachute-vault mcp-config <name>)"`). Treat the
349
+ * output as secret — it carries a usable bearer.
350
+ *
351
+ * - **env-var template** (`useEnvVars: true`): the URL becomes
352
+ * `${PARACHUTE_HUB_URL}/vault/<name>/mcp` and the Authorization value
353
+ * becomes `Bearer ${PARACHUTE_VAULT_TOKEN}`. Shape-only; safe to commit.
354
+ * Consumers must expand the placeholders at runtime (most shells do this
355
+ * under double-quoted interpolation; `claude -p` itself does not).
356
+ *
357
+ * The `entryKey` rule mirrors `buildMcpEntryPlan`: vault-explicit installs
358
+ * key as `parachute-vault-<name>` so multi-vault runners can mount more than
359
+ * one vault under distinct slots. (The default install — singular
360
+ * `parachute-vault` — is reserved for the local-scope MCP entry; `mcp-config`
361
+ * always pins the per-vault key, since this is the script-spawning shape
362
+ * where you usually do name the vault.)
363
+ *
364
+ * Always emits stable JSON with two-space indent so the output is diff-able
365
+ * across runs.
366
+ */
367
+ export interface BuildMcpConfigJsonOpts {
368
+ vaultName: string;
369
+ /** Base URL (without `/vault/<name>/mcp`). Ignored when `useEnvVars` is true. */
370
+ baseUrl: string;
371
+ /** Bearer to embed verbatim. Ignored when `useEnvVars` is true. */
372
+ bearer: string;
373
+ /** Emit `${PARACHUTE_HUB_URL}` / `${PARACHUTE_VAULT_TOKEN}` placeholders instead of inlined values. */
374
+ useEnvVars?: boolean;
375
+ }
376
+
377
+ export function buildMcpConfigJson(opts: BuildMcpConfigJsonOpts): string {
378
+ const { vaultName, baseUrl, bearer, useEnvVars } = opts;
379
+ const entryKey = `parachute-vault-${vaultName}`;
380
+ const url = useEnvVars
381
+ ? `\${PARACHUTE_HUB_URL}/vault/${vaultName}/mcp`
382
+ : `${baseUrl.replace(/\/$/, "")}/vault/${vaultName}/mcp`;
383
+ const authValue = useEnvVars
384
+ ? "Bearer ${PARACHUTE_VAULT_TOKEN}"
385
+ : `Bearer ${bearer}`;
386
+ const config = {
387
+ mcpServers: {
388
+ [entryKey]: {
389
+ type: "http",
390
+ url,
391
+ headers: { Authorization: authValue },
392
+ },
393
+ },
394
+ };
395
+ return JSON.stringify(config, null, 2);
396
+ }
397
+
337
398
  // ---------------------------------------------------------------------------
338
399
  // Install target resolver
339
400
  // ---------------------------------------------------------------------------