@openparachute/vault 0.4.3 → 0.4.4-rc.12

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/src/cli.ts CHANGED
@@ -7,7 +7,8 @@
7
7
  * parachute-vault init — set up everything, one command
8
8
  * parachute-vault create <name> — create a new vault
9
9
  * parachute-vault list — list all vaults
10
- * parachute-vault mcp-install <name> add vault MCP to ~/.claude.json
10
+ * parachute-vault mcp-install [flags] install vault MCP into a client config
11
+ * (defaults: --mint into ~/.claude.json)
11
12
  * parachute-vault remove <name> — remove a vault
12
13
  * parachute-vault config — show all config
13
14
  * parachute-vault config set <key> <val> — set a config value
@@ -51,7 +52,21 @@ import {
51
52
  import type { VaultConfig } from "./config.ts";
52
53
  import { DATA_DIR } from "./config.ts";
53
54
  import { installAgent, uninstallAgent, isAgentLoaded, restartAgent } from "./launchd.ts";
54
- import { chooseMcpUrl } from "./mcp-install.ts";
55
+ import {
56
+ buildMcpEntryPlan,
57
+ chooseHubOrigin,
58
+ detectInstallContext,
59
+ mintHubJwt,
60
+ readOperatorToken,
61
+ removeMcpConfig,
62
+ resolveInstallTarget,
63
+ type InstallScope,
64
+ } from "./mcp-install.ts";
65
+ import {
66
+ defaultInteractiveIO,
67
+ runInteractiveInstall,
68
+ type InteractiveIO,
69
+ } from "./mcp-install-interactive.ts";
55
70
  import { buildInitSummaryLines } from "./init-summary.ts";
56
71
  import {
57
72
  runBackup,
@@ -152,7 +167,7 @@ switch (command) {
152
167
  cmdList();
153
168
  break;
154
169
  case "mcp-install":
155
- cmdMcpInstall(cmdArgs);
170
+ await cmdMcpInstall(cmdArgs);
156
171
  break;
157
172
  case "remove":
158
173
  case "rm":
@@ -503,7 +518,25 @@ async function cmdInit(args: string[] = []) {
503
518
  }
504
519
 
505
520
  if (addMcp) {
506
- installMcpConfig(apiKey);
521
+ // Init's bootstrap path stays on the pvt_* shape so a fresh-install
522
+ // without a hub still works out of the box. Operators with a hub can
523
+ // re-run `parachute-vault mcp-install` (defaults to hub-mint) to
524
+ // upgrade. Goes through `buildMcpEntryPlan` for entryKey + url so this
525
+ // path shares the writer-side invariant with `executeMcpInstall` — a
526
+ // future URL-shape change can't drift between init and mcp-install.
527
+ const target = resolveInstallTarget("user");
528
+ const { entryKey, url, source } = buildMcpEntryPlan({
529
+ vaultName: defaultVault,
530
+ vaultExplicit: false,
531
+ port: globalConfig.port || DEFAULT_PORT,
532
+ });
533
+ installMcpConfig({
534
+ targetPath: target.path,
535
+ entryKey,
536
+ url,
537
+ bearer: apiKey,
538
+ });
539
+ console.log(`MCP URL: ${url} (${source})`);
507
540
  console.log(` MCP server added to ~/.claude.json`);
508
541
  } else {
509
542
  console.log(" Skipped adding MCP to ~/.claude.json.");
@@ -880,10 +913,344 @@ function cmdList() {
880
913
  }
881
914
  }
882
915
 
883
- function cmdMcpInstall(_args: string[]) {
884
- installMcpConfig();
885
- console.log(`Added MCP server "parachute-vault" to ~/.claude.json`);
886
- console.log(`All vaults accessible via the 'vault' parameter on each tool.`);
916
+ /**
917
+ * Parse a `--flag value` pair from an args array. Returns the value (or
918
+ * undefined when the flag isn't present) and a flag whether the flag's value
919
+ * was actually present (catches `--flag` without an argument).
920
+ */
921
+ function takeArgValue(args: string[], name: string): { value?: string; missingValue?: boolean } {
922
+ const idx = args.indexOf(name);
923
+ if (idx === -1) return {};
924
+ const next = args[idx + 1];
925
+ if (!next || next.startsWith("--")) return { missingValue: true };
926
+ return { value: next };
927
+ }
928
+
929
+ /**
930
+ * `parachute-vault mcp-install` — install the vault MCP server into an
931
+ * AI client's config. Three auth modes (mutually exclusive):
932
+ *
933
+ * --mint (default) Mint a hub JWT via `POST <hub>/api/auth/mint-token`
934
+ * using the local operator token.
935
+ * --token <bearer> Use an existing token (hub JWT, pvt_*, anything).
936
+ * --legacy-pat Mint a vault-DB `pvt_*` token (deprecated;
937
+ * self-hosted-without-hub setups).
938
+ *
939
+ * Targeting:
940
+ * --scope <verb> vault:read | vault:write | vault:admin (default: vault:read).
941
+ * For --mint, expands to vault:<vault-name>:<verb>.
942
+ * --install-scope <s> local (default) | user | project. local writes to
943
+ * ~/.claude.json under projects[<cwd>].mcpServers
944
+ * (private, this directory only — matches Claude
945
+ * Code's own default). user writes to top-level
946
+ * mcpServers (every project). project writes to
947
+ * ./.mcp.json (check into the repo).
948
+ * --vault <name> Vault to target (default: default_vault). Multi-vault
949
+ * installs key the entry as `parachute-vault-<name>`.
950
+ * --client <name> Reserved for Phase C. Only `claude-code` accepted.
951
+ */
952
+ async function cmdMcpInstall(args: string[]): Promise<void> {
953
+ // Set of install-shaping flags. Any one flips dispatch to non-interactive
954
+ // — flag-passing semantics are "I know what I want."
955
+ //
956
+ // Declared inside the function (rather than module-top) because this
957
+ // file's top-level dispatch await runs cmdMcpInstall during module
958
+ // initialization, and a module-top `const` is still in its temporal
959
+ // dead zone when the function body first executes from that dispatch.
960
+ const MCP_INSTALL_FLAG_NAMES = [
961
+ "--mint",
962
+ "--legacy-pat",
963
+ "--token",
964
+ "--scope",
965
+ "--install-scope",
966
+ "--vault",
967
+ "--client",
968
+ ];
969
+
970
+ const hasFlag = args.some((a) => MCP_INSTALL_FLAG_NAMES.includes(a));
971
+ const isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY);
972
+
973
+ // Interactive dispatch fires when the operator passed no install-shaping
974
+ // flags AND stdin is a TTY. No separate `--interactive` flag — TTY+
975
+ // no-flags already covers the "walk me through it" case, and forcing
976
+ // interactive with partial flags is a half-feature that silently
977
+ // discards co-present flag values (vault#292 review F1). Non-TTY
978
+ // bare invocation falls through to the flag-driven defaults.
979
+ if (isTTY && !hasFlag) {
980
+ return await cmdMcpInstallInteractive();
981
+ }
982
+
983
+ // --- Auth-mode parsing (mutually exclusive) ---
984
+ const wantMint = args.includes("--mint");
985
+ const wantLegacy = args.includes("--legacy-pat");
986
+ const tokenArg = takeArgValue(args, "--token");
987
+ if (tokenArg.missingValue) {
988
+ console.error("--token requires a value (the bearer token to embed).");
989
+ process.exit(1);
990
+ }
991
+ const wantToken = tokenArg.value !== undefined;
992
+ const modesSet = (wantMint ? 1 : 0) + (wantLegacy ? 1 : 0) + (wantToken ? 1 : 0);
993
+ if (modesSet > 1) {
994
+ console.error("--mint, --token, and --legacy-pat are mutually exclusive.");
995
+ process.exit(1);
996
+ }
997
+ const mode: "mint" | "token" | "legacy-pat" =
998
+ wantToken ? "token" : wantLegacy ? "legacy-pat" : "mint";
999
+
1000
+ // --- Scope parsing. Default vault:read (least-privilege). ---
1001
+ const scopeArg = takeArgValue(args, "--scope");
1002
+ if (scopeArg.missingValue) {
1003
+ console.error("--scope requires a value: vault:read, vault:write, or vault:admin.");
1004
+ process.exit(1);
1005
+ }
1006
+ const rawScope = scopeArg.value ?? "vault:read";
1007
+ if (!(VAULT_SCOPES as readonly string[]).includes(rawScope)) {
1008
+ console.error(
1009
+ `--scope must be one of: ${VAULT_SCOPES.join(", ")}. Got: ${rawScope}.`,
1010
+ );
1011
+ process.exit(1);
1012
+ }
1013
+
1014
+ // --- Install scope: local (default), user, or project. ---
1015
+ // `local` matches Claude Code's own `claude mcp add` default — entry sits
1016
+ // under `projects[<cwd>].mcpServers` in ~/.claude.json, private to this
1017
+ // machine + this directory. Operators wanting a global install pass
1018
+ // `--install-scope user`; team-shared installs pass `--install-scope project`.
1019
+ const installScopeArg = takeArgValue(args, "--install-scope");
1020
+ if (installScopeArg.missingValue) {
1021
+ console.error("--install-scope requires a value: local, user, or project.");
1022
+ process.exit(1);
1023
+ }
1024
+ const installScopeRaw = installScopeArg.value ?? "local";
1025
+ if (
1026
+ installScopeRaw !== "user" &&
1027
+ installScopeRaw !== "project" &&
1028
+ installScopeRaw !== "local"
1029
+ ) {
1030
+ console.error(
1031
+ `--install-scope must be "local", "user", or "project". Got: ${installScopeRaw}.`,
1032
+ );
1033
+ process.exit(1);
1034
+ }
1035
+ const installScope: InstallScope = installScopeRaw;
1036
+
1037
+ // --- Client (Phase C placeholder). Reject anything other than claude-code. ---
1038
+ // Validated before filesystem-dependent vault-existence so a static flag
1039
+ // typo fails on its own terms ("--client cursor not supported") rather
1040
+ // than getting masked by an unrelated "vault not found" error.
1041
+ const clientArg = takeArgValue(args, "--client");
1042
+ if (clientArg.missingValue) {
1043
+ console.error("--client requires a value.");
1044
+ process.exit(1);
1045
+ }
1046
+ const client = clientArg.value ?? "claude-code";
1047
+ if (client !== "claude-code") {
1048
+ console.error(
1049
+ `--client "${client}" is not yet supported. Only "claude-code" is wired up; ` +
1050
+ `Cursor / Claude Desktop / Codex / Zed are Phase C work.`,
1051
+ );
1052
+ process.exit(1);
1053
+ }
1054
+
1055
+ // --- Vault target. Default = default_vault; validate existence. ---
1056
+ const vaultArg = takeArgValue(args, "--vault");
1057
+ if (vaultArg.missingValue) {
1058
+ console.error("--vault requires a value (the vault name).");
1059
+ process.exit(1);
1060
+ }
1061
+ const globalConfig = readGlobalConfig();
1062
+ const defaultVault = globalConfig.default_vault || "default";
1063
+ const vaultName = vaultArg.value ?? defaultVault;
1064
+ const vaultExplicit = vaultArg.value !== undefined;
1065
+ if (!readVaultConfig(vaultName)) {
1066
+ console.error(`Vault "${vaultName}" not found. Available: ${listVaults().join(", ") || "(none)"}.`);
1067
+ process.exit(1);
1068
+ }
1069
+
1070
+ await executeMcpInstall({
1071
+ mode,
1072
+ rawScope,
1073
+ installScope,
1074
+ vaultName,
1075
+ vaultExplicit,
1076
+ pastedToken: mode === "token" ? tokenArg.value : undefined,
1077
+ globalConfig,
1078
+ });
1079
+ }
1080
+
1081
+ /**
1082
+ * Interactive front-end for `mcp-install`. Builds the install context,
1083
+ * walks the operator through prompts, then hands the resolved decision
1084
+ * to `executeMcpInstall`. Failure modes from the walkthrough (operator
1085
+ * aborted, no vaults yet, etc.) exit cleanly without touching disk.
1086
+ */
1087
+ async function cmdMcpInstallInteractive(): Promise<void> {
1088
+ const globalConfig = readGlobalConfig();
1089
+ const defaultVault = globalConfig.default_vault || "default";
1090
+ const port = globalConfig.port || DEFAULT_PORT;
1091
+ const ctx = detectInstallContext({
1092
+ vaults: listVaults(),
1093
+ defaultVault,
1094
+ port,
1095
+ });
1096
+ const io = await defaultInteractiveIO();
1097
+ const decision = await runInteractiveInstall(ctx, io);
1098
+ if (decision === "abort") {
1099
+ process.exit(0);
1100
+ }
1101
+ await executeMcpInstall({
1102
+ mode: decision.mode,
1103
+ rawScope: decision.scope,
1104
+ installScope: decision.installScope,
1105
+ vaultName: decision.vaultName,
1106
+ vaultExplicit: decision.vaultExplicit,
1107
+ pastedToken: decision.pastedToken,
1108
+ existingEntryKey: decision.existingEntryKey,
1109
+ globalConfig,
1110
+ });
1111
+ }
1112
+
1113
+ interface ExecuteMcpInstallOpts {
1114
+ mode: "mint" | "token" | "legacy-pat";
1115
+ /** Full scope string (e.g. "vault:read"). The verb segment narrows downstream. */
1116
+ rawScope: string;
1117
+ installScope: InstallScope;
1118
+ vaultName: string;
1119
+ /** Whether vault target was explicit (controls singular vs per-vault entry key). */
1120
+ vaultExplicit: boolean;
1121
+ /** Bearer the operator pasted in `--token` / interactive paste mode. */
1122
+ pastedToken?: string;
1123
+ /**
1124
+ * When the interactive walkthrough is updating an existing entry, the
1125
+ * walkthrough's preview pinned a specific key (e.g. `parachute-vault-work`
1126
+ * because that's what was already there). Passing it through ensures the
1127
+ * write lands at the same key the operator just confirmed. Absent for
1128
+ * fresh installs and for the flag-driven path (which always synthesizes).
1129
+ * See vault#293.
1130
+ */
1131
+ existingEntryKey?: string;
1132
+ /** Reused across the call chain to avoid re-parsing config.yaml. */
1133
+ globalConfig: ReturnType<typeof readGlobalConfig>;
1134
+ }
1135
+
1136
+ /**
1137
+ * Shared backend for both the flag-driven path and the interactive
1138
+ * walkthrough. Acquires the bearer per mode, writes the entry, prints the
1139
+ * result. The interactive path delays this call until *after* the
1140
+ * preview-and-confirm step so a cancel skips the network mint entirely.
1141
+ */
1142
+ async function executeMcpInstall(opts: ExecuteMcpInstallOpts): Promise<void> {
1143
+ const { mode, rawScope, installScope, vaultName, vaultExplicit, pastedToken, globalConfig, existingEntryKey } = opts;
1144
+ const verb = rawScope.split(":")[1]!;
1145
+ const target = resolveInstallTarget(installScope);
1146
+ // Single source of truth shared with the interactive walkthrough's
1147
+ // preview — preview-shows-this-shape ⇒ this-shape-lands-on-disk. The
1148
+ // helper closes both halves of the invariant (entryKey + url) — see
1149
+ // vault#293 for the seam, vault#302 for closing the writer-side URL.
1150
+ const { entryKey, url, source } = buildMcpEntryPlan({
1151
+ vaultName,
1152
+ vaultExplicit,
1153
+ port: globalConfig.port || DEFAULT_PORT,
1154
+ ...(existingEntryKey ? { existingEntryKey } : {}),
1155
+ });
1156
+
1157
+ let bearer: string;
1158
+ if (mode === "token") {
1159
+ if (!pastedToken) {
1160
+ console.error("Internal error: token mode missing pastedToken.");
1161
+ process.exit(1);
1162
+ }
1163
+ bearer = pastedToken;
1164
+ console.log(`Using supplied token (skipping mint).`);
1165
+ } else if (mode === "legacy-pat") {
1166
+ console.error(
1167
+ "Note: --legacy-pat mints a vault-DB pvt_* token. The hub-issued JWT path (--mint, default) " +
1168
+ "is the canonical install going forward; pvt_* support is preserved for self-hosted-without-hub " +
1169
+ "setups, tracked at vault#288, planned removal 0.6.0.",
1170
+ );
1171
+ const store = getVaultStore(vaultName);
1172
+ const { fullToken } = generateToken();
1173
+ // Narrow the pvt_* to the requested verb's scope set when not full-admin.
1174
+ // `scopes: undefined` leaves the token at full vault permissions
1175
+ // (admin); narrowing to a single-scope array gates it to that verb.
1176
+ const createTokenOpts: Parameters<typeof createToken>[2] = {
1177
+ label: "mcp-install",
1178
+ permission: verb === "read" ? "read" : "full",
1179
+ vault_name: vaultName,
1180
+ };
1181
+ if (verb !== "admin") {
1182
+ createTokenOpts.scopes = [rawScope];
1183
+ }
1184
+ createToken(store.db, fullToken, createTokenOpts);
1185
+ bearer = fullToken;
1186
+ } else {
1187
+ // mode === "mint"
1188
+ const operatorToken = readOperatorToken();
1189
+ if (!operatorToken) {
1190
+ console.error(
1191
+ "No operator token found at ~/.parachute/operator.token. The default install path " +
1192
+ "(--mint) requires a hub-issued operator token to mint scope-narrow JWTs.\n" +
1193
+ " Fix: run `parachute auth rotate-operator` to create one, then re-run.\n" +
1194
+ " Or: use `--token <bearer>` to paste an existing token, or `--legacy-pat` to " +
1195
+ "mint a vault-DB pvt_* token (self-hosted-without-hub).",
1196
+ );
1197
+ process.exit(1);
1198
+ }
1199
+ const port = globalConfig.port || DEFAULT_PORT;
1200
+ const hub = chooseHubOrigin(port);
1201
+ if (hub.source === "loopback") {
1202
+ console.error(
1203
+ "No hub origin configured (PARACHUTE_HUB_ORIGIN unset, no active expose-state). " +
1204
+ "Hub-mint (--mint) needs a real hub URL to call. Either:\n" +
1205
+ " - Start the hub and set PARACHUTE_HUB_ORIGIN, OR\n" +
1206
+ " - Bring up an exposure (`parachute expose tailnet`), OR\n" +
1207
+ " - Use --legacy-pat to mint a vault-DB pvt_* token instead.",
1208
+ );
1209
+ process.exit(1);
1210
+ }
1211
+ const narrowScope = `vault:${vaultName}:${verb}`;
1212
+ const result = await mintHubJwt({
1213
+ hubOrigin: hub.url,
1214
+ operatorToken,
1215
+ scope: narrowScope,
1216
+ subject: "parachute-vault-mcp",
1217
+ });
1218
+ if ("kind" in result) {
1219
+ switch (result.kind) {
1220
+ case "network":
1221
+ console.error(
1222
+ `Hub unreachable at ${result.origin} — ${result.cause}.\n` +
1223
+ ` Fix: verify the hub is running and PARACHUTE_HUB_ORIGIN is set, ` +
1224
+ `or use --legacy-pat to skip hub-mint.`,
1225
+ );
1226
+ break;
1227
+ case "api-error":
1228
+ console.error(
1229
+ `Hub mint-token rejected (HTTP ${result.status}, ${result.error}): ${result.description}`,
1230
+ );
1231
+ break;
1232
+ }
1233
+ process.exit(1);
1234
+ }
1235
+ bearer = result.token;
1236
+ console.log(`Minted hub JWT (jti=${result.jti}, expires ${result.expires_at}, scope ${result.scope}).`);
1237
+ }
1238
+
1239
+ installMcpConfig({
1240
+ targetPath: target.path,
1241
+ entryKey,
1242
+ url,
1243
+ bearer,
1244
+ ...(target.localProjectKey ? { localProjectKey: target.localProjectKey } : {}),
1245
+ });
1246
+ console.log(`MCP URL: ${url} (${source})`);
1247
+ if (target.scope === "local") {
1248
+ console.log(`Added MCP server "${entryKey}" to ${target.path} under projects["${target.localProjectKey}"] (local scope).`);
1249
+ console.log(` Installed locally for this directory only — runs only when Claude Code launches from ${target.localProjectKey}.`);
1250
+ console.log(` To install globally instead, re-run with --install-scope user.`);
1251
+ } else {
1252
+ console.log(`Added MCP server "${entryKey}" to ${target.path}`);
1253
+ }
887
1254
  }
888
1255
 
889
1256
  function cmdRemove(args: string[]) {
@@ -1364,6 +1731,16 @@ function printErrLogTail(n: number) {
1364
1731
  async function cmdUninstall(argsList: string[]) {
1365
1732
  const wipe = argsList.includes("--wipe");
1366
1733
  const skipPrompts = argsList.includes("--yes") || argsList.includes("-y");
1734
+ // Test-isolation escape hatch (vault#296): skip the launchd / systemd /
1735
+ // backup-agent uninstall calls. Those target hardcoded labels
1736
+ // (`computer.parachute.vault*`) that ignore `PARACHUTE_HOME`, so a test
1737
+ // run on a developer's machine would `launchctl bootout` their real
1738
+ // daemon. With this flag tests can exercise the rest of the uninstall
1739
+ // flow (wrapper removal, MCP cleanup, ordering, exit codes) safely.
1740
+ // Intentionally not documented in `usage()` — humans should never need
1741
+ // it; surfacing it would invite "I'll just skip the daemon step" misuse
1742
+ // that leaves an orphaned daemon firing on a missing wrapper.
1743
+ const skipDaemon = argsList.includes("--skip-daemon");
1367
1744
 
1368
1745
  console.log("Parachute Vault uninstall\n");
1369
1746
  console.log("This removes the daemon registration and wrapper script.");
@@ -1393,7 +1770,9 @@ async function cmdUninstall(argsList: string[]) {
1393
1770
  }
1394
1771
 
1395
1772
  // 1. Stop and remove the daemon registration.
1396
- if (process.platform === "darwin") {
1773
+ if (skipDaemon) {
1774
+ console.log("Skipping daemon removal (--skip-daemon).");
1775
+ } else if (process.platform === "darwin") {
1397
1776
  console.log("Removing launchd agent...");
1398
1777
  await uninstallAgent();
1399
1778
  // Scheduled backup agent lives in a separate plist — uninstall it too,
@@ -1554,23 +1933,23 @@ async function cmdDoctor() {
1554
1933
  });
1555
1934
  }
1556
1935
 
1557
- // MCP entry in ~/.claude.json. Split into three separate checks so the
1558
- // user can see exactly which condition fails: "entry present", "port
1559
- // matches vault", "daemon reachable over MCP URL". A common failure is
1560
- // "entry exists but port is stale" after the user changed PORT without
1561
- // re-running `mcp-install`.
1936
+ // MCP entry in ~/.claude.json or ./.mcp.json. Split into three separate
1937
+ // checks so the user can see exactly which condition fails: "entry
1938
+ // present", "port matches vault", "daemon reachable over MCP URL". A
1939
+ // common failure is "entry exists but port is stale" after the user
1940
+ // changed PORT without re-running `mcp-install`.
1562
1941
  const port = resolveVaultPort();
1563
1942
  const mcpEntry = readMcpEntry();
1564
1943
  if (!mcpEntry.found) {
1565
1944
  checks.push({
1566
- name: "MCP entry in ~/.claude.json",
1945
+ name: "MCP entry in MCP client config",
1567
1946
  status: "warn",
1568
1947
  detail: mcpEntry.reason,
1569
1948
  fix: "Run `parachute-vault mcp-install` to register the vault with Claude.",
1570
1949
  });
1571
1950
  } else {
1572
1951
  checks.push({
1573
- name: "MCP entry in ~/.claude.json",
1952
+ name: `MCP entry in ${mcpEntry.locationLabel}`,
1574
1953
  status: "pass",
1575
1954
  detail: mcpEntry.url,
1576
1955
  });
@@ -1734,57 +2113,92 @@ function resolveVaultPort(): number {
1734
2113
 
1735
2114
  type McpEntryLookup =
1736
2115
  | { found: false; reason: string }
1737
- | { found: true; url: string; port: number | null };
2116
+ | { found: true; url: string; port: number | null; locationLabel: string };
1738
2117
 
1739
2118
  /**
1740
- * Read `~/.claude.json` and return the shape of the `parachute-vault` MCP
1741
- * entry if present. The entry is always an HTTP MCP pointing at the local
1742
- * daemon `{ type: "http", url: "http://127.0.0.1:<port>/vault/<name>/mcp" }`
1743
- * — so we parse the URL's port for the port-match check.
2119
+ * Read the parachute vault MCP entry from either `~/.claude.json` (user
2120
+ * scope) or `./.mcp.json` (project scope), preferring user-level. Accepts
2121
+ * the singular `parachute-vault` key or any per-vault `parachute-vault-<name>`
2122
+ * key multi-vault installs key per-vault. The entry is always an HTTP MCP
2123
+ * pointing at the local daemon, so we parse the URL's port for the
2124
+ * port-match check.
1744
2125
  *
1745
- * Invariant: the check is NON-fatal. A missing ~/.claude.json is a warn,
1746
- * not a fail: plenty of users install the vault first and wire it to
1747
- * Claude later. We just make the "is it wired up?" state legible.
2126
+ * Invariant: the check is NON-fatal. A missing entry is a warn, not a fail:
2127
+ * plenty of users install the vault first and wire it to Claude later. We
2128
+ * just make the "is it wired up?" state legible.
1748
2129
  */
1749
2130
  function readMcpEntry(): McpEntryLookup {
1750
- const claudeJsonPath = resolve(homedir(), ".claude.json");
1751
- if (!existsSync(claudeJsonPath)) {
1752
- return { found: false, reason: `${claudeJsonPath} does not exist` };
1753
- }
1754
- let config: any;
1755
- try {
1756
- config = JSON.parse(readFileSync(claudeJsonPath, "utf-8"));
1757
- } catch (err: any) {
1758
- return {
1759
- found: false,
1760
- reason: `${claudeJsonPath} is not valid JSON: ${String(err?.message ?? err)}`,
1761
- };
1762
- }
1763
- const entry = config?.mcpServers?.["parachute-vault"];
1764
- if (!entry) {
1765
- return {
1766
- found: false,
1767
- reason: `no mcpServers["parachute-vault"] entry in ${claudeJsonPath}`,
1768
- };
1769
- }
1770
- // The entry is always a URL-bearing HTTP MCP. Non-URL shapes are
1771
- // unexpected (Claude Code would ignore them anyway) but we surface the
1772
- // raw shape so the user can see what's there.
1773
- const url = typeof entry.url === "string" ? entry.url : null;
1774
- if (!url) {
1775
- return {
1776
- found: false,
1777
- reason: `mcpServers["parachute-vault"] has no \`url\` field (got ${JSON.stringify(entry).slice(0, 80)})`,
1778
- };
1779
- }
1780
- let entryPort: number | null = null;
1781
- try {
1782
- const parsed = new URL(url);
1783
- entryPort = parsed.port ? Number(parsed.port) : null;
1784
- } catch {
1785
- entryPort = null;
2131
+ // Bun's process.cwd() already follows symlinks on macOS, so resolve()
2132
+ // matches the key the install writer used; tests assert with
2133
+ // fs.realpathSync to match the same shape.
2134
+ const cwd = resolve(process.cwd());
2135
+ // Two entries point at the same ~/.claude.json file but search different
2136
+ // key namespaces — top-level `mcpServers` (user scope) vs
2137
+ // `projects[<cwd>].mcpServers` (local scope). Intentional duplication: a
2138
+ // single read can't satisfy both without restructuring the search loop,
2139
+ // and the cost of reading the file twice is trivial against the doctor
2140
+ // run's other work.
2141
+ const candidates: Array<{ path: string; label: string; localProjectKey?: string }> = [
2142
+ { path: resolve(homedir(), ".claude.json"), label: "~/.claude.json" },
2143
+ { path: resolve(homedir(), ".claude.json"), label: `~/.claude.json (projects["${cwd}"])`, localProjectKey: cwd },
2144
+ { path: resolve(cwd, ".mcp.json"), label: `${cwd}/.mcp.json` },
2145
+ ];
2146
+ const checkedReasons: string[] = [];
2147
+ for (const { path, label, localProjectKey } of candidates) {
2148
+ if (!existsSync(path)) {
2149
+ checkedReasons.push(`${label} does not exist`);
2150
+ continue;
2151
+ }
2152
+ let config: any;
2153
+ try {
2154
+ config = JSON.parse(readFileSync(path, "utf-8"));
2155
+ } catch (err: any) {
2156
+ checkedReasons.push(`${label} is not valid JSON: ${String(err?.message ?? err)}`);
2157
+ continue;
2158
+ }
2159
+ const servers = localProjectKey
2160
+ ? (config?.projects?.[localProjectKey]?.mcpServers ?? {})
2161
+ : (config?.mcpServers ?? {});
2162
+ // Prefer the singular `parachute-vault` slot (canonical default
2163
+ // install); fall back to the first `parachute-vault-<name>` per-vault
2164
+ // entry. Multi-vault installs may have several; the doctor reports on
2165
+ // the one it finds first so the basic "is something wired up?" check
2166
+ // still works without enumerating every vault.
2167
+ let entry = servers["parachute-vault"];
2168
+ let entryKey = "parachute-vault";
2169
+ if (!entry) {
2170
+ for (const key of Object.keys(servers)) {
2171
+ if (key.startsWith("parachute-vault-")) {
2172
+ entry = servers[key];
2173
+ entryKey = key;
2174
+ break;
2175
+ }
2176
+ }
2177
+ }
2178
+ if (!entry) {
2179
+ checkedReasons.push(`no parachute-vault entry in ${label}`);
2180
+ continue;
2181
+ }
2182
+ const url = typeof entry.url === "string" ? entry.url : null;
2183
+ if (!url) {
2184
+ checkedReasons.push(
2185
+ `${label} mcpServers["${entryKey}"] has no \`url\` field (got ${JSON.stringify(entry).slice(0, 80)})`,
2186
+ );
2187
+ continue;
2188
+ }
2189
+ let entryPort: number | null = null;
2190
+ try {
2191
+ const parsed = new URL(url);
2192
+ entryPort = parsed.port ? Number(parsed.port) : null;
2193
+ } catch {
2194
+ entryPort = null;
2195
+ }
2196
+ return { found: true, url, port: entryPort, locationLabel: `${label} (${entryKey})` };
1786
2197
  }
1787
- return { found: true, url, port: entryPort };
2198
+ return {
2199
+ found: false,
2200
+ reason: checkedReasons.length > 0 ? checkedReasons.join("; ") : "no MCP config files found",
2201
+ };
1788
2202
  }
1789
2203
 
1790
2204
  /**
@@ -2078,21 +2492,21 @@ function describeNextRun(schedule: BackupSchedule): string {
2078
2492
 
2079
2493
  async function cmdImport(args: string[]) {
2080
2494
  // Parse flags
2081
- let format = "obsidian";
2082
2495
  let vaultName = "default";
2083
2496
  let sourcePath = "";
2084
2497
  let dryRun = false;
2498
+ let blowAway = false;
2499
+ let assumeYes = false;
2500
+ // `--format <name>` / `--obsidian` retained as no-op hints — autodetect
2501
+ // now drives the format choice (presence of `.parachute/vault.yaml`).
2502
+ // Surfaced in help text below; ignored at runtime to avoid surprising
2503
+ // operators with stale flags.
2085
2504
 
2086
2505
  const positional: string[] = [];
2087
2506
  for (let i = 0; i < args.length; i++) {
2088
2507
  const arg = args[i]!;
2089
2508
  if (arg === "--format") {
2090
- const v = args[++i];
2091
- if (!v) {
2092
- console.error("--format requires a value.");
2093
- process.exit(1);
2094
- }
2095
- format = v;
2509
+ i++; // consume the value; ignored.
2096
2510
  } else if (arg === "--vault") {
2097
2511
  const v = args[++i];
2098
2512
  if (!v) {
@@ -2103,7 +2517,11 @@ async function cmdImport(args: string[]) {
2103
2517
  } else if (arg === "--dry-run") {
2104
2518
  dryRun = true;
2105
2519
  } else if (arg === "--obsidian") {
2106
- format = "obsidian";
2520
+ // no-op; autodetect.
2521
+ } else if (arg === "--blow-away") {
2522
+ blowAway = true;
2523
+ } else if (arg === "--yes" || arg === "-y") {
2524
+ assumeYes = true;
2107
2525
  } else {
2108
2526
  positional.push(arg);
2109
2527
  }
@@ -2111,15 +2529,21 @@ async function cmdImport(args: string[]) {
2111
2529
  sourcePath = positional[0] ?? "";
2112
2530
 
2113
2531
  if (!sourcePath) {
2114
- console.error("Usage: parachute-vault import <path> [--vault <name>] [--dry-run]");
2115
- console.error("\nImports an Obsidian vault into Parachute Vault.");
2532
+ console.error("Usage: parachute-vault import <path> [--vault <name>] [--blow-away] [--yes] [--dry-run]");
2533
+ console.error("\nImports a portable-md export OR a legacy Obsidian vault. Format is");
2534
+ console.error("autodetected by the presence of `.parachute/vault.yaml` in the input dir.");
2116
2535
  console.error("\nOptions:");
2117
2536
  console.error(" --vault <name> Target vault (default: 'default')");
2118
- console.error(" --dry-run Show what would be imported without importing");
2537
+ console.error(" --blow-away DESTRUCTIVE: wipe the target vault first, then replay");
2538
+ console.error(" the import. Disaster-recovery path. Confirms unless");
2539
+ console.error(" `--yes` is set.");
2540
+ console.error(" --yes, -y Skip the destructive-action confirm prompt.");
2541
+ console.error(" --dry-run Show what would be imported without writing.");
2119
2542
  process.exit(1);
2120
2543
  }
2121
2544
 
2122
2545
  const { resolve: resolvePath } = await import("path");
2546
+ const { join } = await import("path");
2123
2547
  const fullPath = resolvePath(sourcePath);
2124
2548
 
2125
2549
  if (!existsSync(fullPath)) {
@@ -2134,6 +2558,73 @@ async function cmdImport(args: string[]) {
2134
2558
  process.exit(1);
2135
2559
  }
2136
2560
 
2561
+ // Autodetect: portable-md export → `.parachute/vault.yaml` present.
2562
+ const isPortableMd = existsSync(join(fullPath, ".parachute", "vault.yaml"));
2563
+
2564
+ if (isPortableMd) {
2565
+ // New lossless path. Supports --blow-away for disaster recovery.
2566
+ const { importPortableVault } = await import("../core/src/portable-md.ts");
2567
+ const { getVaultStore } = await import("./vault-store.ts");
2568
+ const { assetsDir } = await import("./routes.ts");
2569
+
2570
+ if (blowAway && !assumeYes && !dryRun) {
2571
+ // Confirm-prompt the destructive action. Default NO — every other
2572
+ // destructive confirm in this CLI (uninstall's wipe, 2FA
2573
+ // re-enrollment) defaults NO, so a distracted Enter-press can't
2574
+ // wipe a vault. vault#319 fold F1.
2575
+ const proceed = await confirm(
2576
+ `\nDESTRUCTIVE: --blow-away will DELETE every note in vault "${vaultName}" before replaying from "${fullPath}". Proceed?`,
2577
+ false,
2578
+ );
2579
+ if (!proceed) {
2580
+ console.log("Cancelled.");
2581
+ return;
2582
+ }
2583
+ }
2584
+
2585
+ const store = getVaultStore(vaultName);
2586
+ console.log(
2587
+ `Importing portable-md export from ${fullPath} into vault "${vaultName}"` +
2588
+ `${blowAway ? " (BLOW-AWAY)" : ""}${dryRun ? " (dry-run)" : ""}`,
2589
+ );
2590
+ const stats = await importPortableVault(store, {
2591
+ inDir: fullPath,
2592
+ blowAway,
2593
+ assetsDir: assetsDir(vaultName),
2594
+ dryRun,
2595
+ });
2596
+
2597
+ console.log(
2598
+ `\n${dryRun ? "Would " : ""}created ${stats.notes_created} note(s), ` +
2599
+ `updated ${stats.notes_updated}, ` +
2600
+ `restored ${stats.schemas_restored} schema(s), ` +
2601
+ `${stats.links_restored} link(s), ` +
2602
+ `${stats.attachments_restored} attachment(s).`,
2603
+ );
2604
+ if (stats.notes_wiped > 0) {
2605
+ console.log(`Blow-away: wiped ${stats.notes_wiped} pre-existing note(s).`);
2606
+ }
2607
+ if (stats.skipped_links.length > 0) {
2608
+ console.log(`Skipped ${stats.skipped_links.length} link(s) — target notes not in import set.`);
2609
+ }
2610
+ if (stats.skipped_attachments.length > 0) {
2611
+ console.log(`Skipped ${stats.skipped_attachments.length} attachment(s) — see warnings above.`);
2612
+ }
2613
+ return;
2614
+ }
2615
+
2616
+ // Legacy obsidian-format path. No-op when --blow-away passed (this
2617
+ // path is for "ingest an external Obsidian vault" not disaster
2618
+ // recovery); explicit error so the operator doesn't get a surprising
2619
+ // partial wipe.
2620
+ if (blowAway) {
2621
+ console.error(
2622
+ "--blow-away requires a portable-md export (`.parachute/vault.yaml` present in the input dir). " +
2623
+ "Legacy Obsidian imports don't support --blow-away — they always upsert by path.",
2624
+ );
2625
+ process.exit(1);
2626
+ }
2627
+
2137
2628
  const { parseObsidianVault } = await import("../core/src/obsidian.ts");
2138
2629
  const { getVaultStore } = await import("./vault-store.ts");
2139
2630
 
@@ -2205,6 +2696,7 @@ async function cmdImport(args: string[]) {
2205
2696
  async function cmdExport(args: string[]) {
2206
2697
  let vaultName = "default";
2207
2698
  let outputPath = "";
2699
+ let since: string | undefined;
2208
2700
 
2209
2701
  const positional: string[] = [];
2210
2702
  for (let i = 0; i < args.length; i++) {
@@ -2216,6 +2708,21 @@ async function cmdExport(args: string[]) {
2216
2708
  process.exit(1);
2217
2709
  }
2218
2710
  vaultName = v;
2711
+ } else if (arg === "--since") {
2712
+ const v = args[++i];
2713
+ if (!v) {
2714
+ console.error("--since requires an ISO-8601 timestamp value.");
2715
+ process.exit(1);
2716
+ }
2717
+ // Defensive: parse to verify it's a real ISO timestamp before we
2718
+ // hand it to the store. Avoids silent "no matches" when the
2719
+ // operator typo's the date and gets an empty export.
2720
+ const parsed = Date.parse(v);
2721
+ if (Number.isNaN(parsed)) {
2722
+ console.error(`--since: invalid ISO-8601 timestamp '${v}'`);
2723
+ process.exit(1);
2724
+ }
2725
+ since = v;
2219
2726
  } else {
2220
2727
  positional.push(arg);
2221
2728
  }
@@ -2223,14 +2730,17 @@ async function cmdExport(args: string[]) {
2223
2730
  outputPath = positional[0] ?? "";
2224
2731
 
2225
2732
  if (!outputPath) {
2226
- console.error("Usage: parachute-vault export <output-path> [--vault <name>]");
2227
- console.error("\nExports a Parachute Vault as Obsidian-compatible markdown files.");
2733
+ console.error("Usage: parachute-vault export <dir> [--since <iso>] [--vault <name>]");
2734
+ console.error("\nExports a Parachute Vault as portable markdown — round-trippable across");
2735
+ console.error("Obsidian / Logseq / Foam / Quartz / Dendron and most markdown SSGs.");
2736
+ console.error("\nOptions:");
2737
+ console.error(" --vault <name> Source vault (default: 'default')");
2738
+ console.error(" --since <iso> Only export notes whose updated_at >= ISO timestamp");
2739
+ console.error(" (incremental — useful for git-projection cadences)");
2228
2740
  process.exit(1);
2229
2741
  }
2230
2742
 
2231
2743
  const { resolve: resolvePath } = await import("path");
2232
- const { mkdirSync: mkdir, writeFileSync: writeFile } = await import("fs");
2233
- const { join, dirname } = await import("path");
2234
2744
  const fullPath = resolvePath(outputPath);
2235
2745
 
2236
2746
  const config = readVaultConfig(vaultName);
@@ -2239,28 +2749,32 @@ async function cmdExport(args: string[]) {
2239
2749
  process.exit(1);
2240
2750
  }
2241
2751
 
2242
- const { toObsidianMarkdown, exportFilePath } = await import("../core/src/obsidian.ts");
2752
+ const { exportVaultToDir } = await import("../core/src/portable-md.ts");
2243
2753
  const { getVaultStore } = await import("./vault-store.ts");
2754
+ const { assetsDir } = await import("./routes.ts");
2244
2755
 
2245
2756
  const store = getVaultStore(vaultName);
2246
- const notes = await store.queryNotes({ limit: 100000, sort: "asc" });
2247
-
2248
- console.log(`Exporting ${notes.length} notes from vault "${vaultName}" to ${fullPath}`);
2249
- mkdir(fullPath, { recursive: true });
2250
-
2251
- let exported = 0;
2252
- for (const note of notes) {
2253
- const filePath = exportFilePath(note);
2254
- const fullFilePath = join(fullPath, filePath);
2255
- const dir = dirname(fullFilePath);
2256
- mkdir(dir, { recursive: true });
2757
+ const assetsDirPath = assetsDir(vaultName);
2758
+ console.log(`Exporting vault "${vaultName}" to ${fullPath}${since ? ` (since ${since})` : ""}`);
2759
+ const stats = await exportVaultToDir(store, {
2760
+ outDir: fullPath,
2761
+ vaultName,
2762
+ assetsDir: assetsDirPath,
2763
+ ...(config.description ? { vaultDescription: config.description } : {}),
2764
+ ...(since ? { since } : {}),
2765
+ });
2257
2766
 
2258
- const markdown = toObsidianMarkdown(note);
2259
- writeFile(fullFilePath, markdown);
2260
- exported++;
2767
+ console.log(`Exported ${stats.notes} note(s), ${stats.schemas} tag schema(s), ${stats.attachments} attachment(s).`);
2768
+ console.log(`Sidecar: ${fullPath}/.parachute/`);
2769
+ if (stats.filtered_by_since) {
2770
+ console.log(`Incremental: filtered by --since ${since}.`);
2771
+ }
2772
+ if (stats.skipped_traversal > 0) {
2773
+ console.log(`Note: ${stats.skipped_traversal} note(s) skipped (path-traversal). See [export] warnings above.`);
2774
+ }
2775
+ if (stats.skipped_attachments.length > 0) {
2776
+ console.log(`Note: ${stats.skipped_attachments.length} attachment(s) skipped. See [export] warnings above.`);
2261
2777
  }
2262
-
2263
- console.log(`Exported ${exported} notes as markdown files.`);
2264
2778
  }
2265
2779
 
2266
2780
  // ---------------------------------------------------------------------------
@@ -2282,59 +2796,68 @@ function createVault(name: string): string {
2282
2796
  return fullToken;
2283
2797
  }
2284
2798
 
2285
- function installMcpConfig(apiKey?: string) {
2286
- const claudeJsonPath = resolve(homedir(), ".claude.json");
2799
+ interface InstallMcpConfigOpts {
2800
+ /** Absolute path to the MCP client config file. Computed by the caller via `resolveInstallTarget`. */
2801
+ targetPath: string;
2802
+ /** `mcpServers.<entryKey>` slot the entry lands at. Default is `parachute-vault`; multi-vault installs key as `parachute-vault-<name>`. */
2803
+ entryKey: string;
2804
+ /**
2805
+ * URL embedded in the entry's `url` field. The caller is the sole source of
2806
+ * truth — `buildMcpEntryPlan` produces both `entryKey` and `url` together,
2807
+ * and passing them through closes the preview ⇄ writer invariant (the
2808
+ * shape the preview promised is the shape that lands on disk). See
2809
+ * vault#302; #301 introduced the seam for `entryKey`, this closes it for
2810
+ * `url` too.
2811
+ */
2812
+ url: string;
2813
+ /** Bearer token to embed in `Authorization: Bearer …`. Omitted means the entry is unauthenticated — only useful for OAuth-capable clients (Claude Code does discovery). */
2814
+ bearer?: string;
2815
+ /**
2816
+ * When set, the entry is keyed under `projects[<localProjectKey>].mcpServers`
2817
+ * inside `~/.claude.json` (Claude Code's `local` scope: private to this
2818
+ * machine, scoped to this directory). When unset, keyed at top-level
2819
+ * `mcpServers` (user scope) or written directly to `./.mcp.json`
2820
+ * (project scope — caller passes the project file path).
2821
+ */
2822
+ localProjectKey?: string;
2823
+ }
2824
+
2825
+ /**
2826
+ * Write the MCP entry into the target config file. Pure file-writer — the
2827
+ * caller has already decided `entryKey` and `url` (via `buildMcpEntryPlan`).
2828
+ * Returns `void`; the caller already has `url` and `source` for logging.
2829
+ */
2830
+ function installMcpConfig(opts: InstallMcpConfigOpts): void {
2831
+ const { targetPath, entryKey, url, bearer, localProjectKey } = opts;
2832
+
2287
2833
  let config: any = {};
2288
- if (existsSync(claudeJsonPath)) {
2834
+ if (existsSync(targetPath)) {
2289
2835
  try {
2290
- config = JSON.parse(readFileSync(claudeJsonPath, "utf-8"));
2836
+ config = JSON.parse(readFileSync(targetPath, "utf-8"));
2291
2837
  } catch {}
2292
2838
  }
2293
2839
 
2294
- if (!config.mcpServers) config.mcpServers = {};
2295
-
2296
- const globalConfig = readGlobalConfig();
2297
- const port = globalConfig.port || DEFAULT_PORT;
2298
-
2299
- // Clean up old per-vault stdio entries
2300
- for (const key of Object.keys(config.mcpServers)) {
2301
- if (key.startsWith("parachute-vault/")) {
2302
- delete config.mcpServers[key];
2303
- }
2840
+ const mcpEntry: Record<string, unknown> = { type: "http", url };
2841
+ if (bearer) {
2842
+ mcpEntry.headers = { Authorization: `Bearer ${bearer}` };
2304
2843
  }
2305
2844
 
2306
- // Single HTTP MCP entry — use per-vault endpoint so pvt_ tokens work.
2307
- // Pick the URL that matches the OAuth issuer vault will advertise, in this
2308
- // order: explicit hub origin env > active tailnet/public exposure >
2309
- // loopback. Otherwise a strict MCP client (Claude Code) hits a loopback URL
2310
- // whose discovery issuer points at the hub and rejects on origin mismatch
2311
- // (RFC 8414).
2312
- const defaultVault = globalConfig.default_vault || "default";
2313
- const { url: mcpUrl, source } = chooseMcpUrl(defaultVault, port);
2314
- console.log(`MCP URL: ${mcpUrl} (${source})`);
2315
- const mcpEntry: Record<string, unknown> = { type: "http", url: mcpUrl };
2316
- if (apiKey) {
2317
- mcpEntry.headers = { Authorization: `Bearer ${apiKey}` };
2845
+ if (localProjectKey) {
2846
+ if (!config.projects || typeof config.projects !== "object") config.projects = {};
2847
+ if (!config.projects[localProjectKey] || typeof config.projects[localProjectKey] !== "object") {
2848
+ config.projects[localProjectKey] = {};
2849
+ }
2850
+ const projectEntry = config.projects[localProjectKey];
2851
+ if (!projectEntry.mcpServers || typeof projectEntry.mcpServers !== "object") {
2852
+ projectEntry.mcpServers = {};
2853
+ }
2854
+ projectEntry.mcpServers[entryKey] = mcpEntry;
2855
+ } else {
2856
+ if (!config.mcpServers) config.mcpServers = {};
2857
+ config.mcpServers[entryKey] = mcpEntry;
2318
2858
  }
2319
- config.mcpServers["parachute-vault"] = mcpEntry;
2320
2859
 
2321
- writeFileSync(claudeJsonPath, JSON.stringify(config, null, 2) + "\n");
2322
- }
2323
-
2324
- function removeMcpConfig() {
2325
- const claudeJsonPath = resolve(homedir(), ".claude.json");
2326
- if (!existsSync(claudeJsonPath)) return;
2327
- try {
2328
- const config = JSON.parse(readFileSync(claudeJsonPath, "utf-8"));
2329
- delete config.mcpServers?.["parachute-vault"];
2330
- // Also clean up any old per-vault entries
2331
- for (const key of Object.keys(config.mcpServers ?? {})) {
2332
- if (key.startsWith("parachute-vault/")) {
2333
- delete config.mcpServers[key];
2334
- }
2335
- }
2336
- writeFileSync(claudeJsonPath, JSON.stringify(config, null, 2) + "\n");
2337
- } catch {}
2860
+ writeFileSync(targetPath, JSON.stringify(config, null, 2) + "\n");
2338
2861
  }
2339
2862
 
2340
2863
  function usage() {
@@ -2377,7 +2900,36 @@ Vaults:
2377
2900
  parachute-vault create <name> [--json] Create a new vault (--json: emit { name, token, paths, set_as_default })
2378
2901
  parachute-vault list List all vaults
2379
2902
  parachute-vault remove <name> [--yes] Remove a vault
2380
- parachute-vault mcp-install Add vault MCP to Claude
2903
+ parachute-vault mcp-install [--mint|--token <t>|--legacy-pat]
2904
+ [--scope vault:read|vault:write|vault:admin]
2905
+ [--install-scope local|user|project]
2906
+ [--vault <name>] [--client claude-code]
2907
+ Install vault MCP into a client config.
2908
+ From a terminal with no flags: walks you
2909
+ through a contextual conversation (vault,
2910
+ location, auth) with smart defaults +
2911
+ preview before write. Pass any flag and
2912
+ the command runs non-interactively.
2913
+ Default (non-interactive): --mint
2914
+ (hub-issued JWT via ~/.parachute/operator.token)
2915
+ into ~/.claude.json under
2916
+ projects[<cwd>].mcpServers (local scope,
2917
+ matches Claude Code's claude-mcp-add
2918
+ default) with vault:read scope.
2919
+ --token <t>: paste an existing bearer
2920
+ (any shape) instead of minting.
2921
+ --legacy-pat: mint a vault-DB pvt_*
2922
+ token (deprecated; for self-hosted-
2923
+ without-hub setups).
2924
+ --install-scope local (default) writes
2925
+ ~/.claude.json under
2926
+ projects[<cwd>].mcpServers (this
2927
+ directory only). user writes top-level
2928
+ mcpServers (every project). project
2929
+ writes ./.mcp.json (check into the repo).
2930
+ --vault <name> targets a specific
2931
+ vault and keys the entry as
2932
+ parachute-vault-<name>.
2381
2933
 
2382
2934
  Tokens:
2383
2935
  parachute-vault tokens List tokens (every vault)
@@ -2415,9 +2967,15 @@ Backup:
2415
2967
  parachute-vault backup status Show schedule, last run, destinations, next run
2416
2968
 
2417
2969
  Import/Export:
2418
- parachute-vault import <path> Import an Obsidian vault
2419
- parachute-vault import <path> --dry-run Preview import without writing
2420
- parachute-vault export <path> Export vault as Obsidian markdown
2970
+ parachute-vault import <path> Import portable-md export OR legacy
2971
+ Obsidian vault (autodetected by
2972
+ presence of .parachute/vault.yaml)
2973
+ parachute-vault import <path> --blow-away DESTRUCTIVE: wipe target vault, replay
2974
+ (disaster recovery; confirms)
2975
+ parachute-vault import <path> --dry-run Preview import without writing
2976
+ parachute-vault export <dir> Export vault as portable markdown
2977
+ (Obsidian/Logseq/Foam/Quartz-compatible)
2978
+ parachute-vault export <dir> --since <iso> Incremental: only notes updated_at >= ISO
2421
2979
 
2422
2980
  ── Advanced / standalone ──────────────────────────────────────────────
2423
2981