@openparachute/vault 0.4.5 → 0.4.6

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
@@ -53,6 +53,7 @@ import type { VaultConfig } from "./config.ts";
53
53
  import { DATA_DIR } from "./config.ts";
54
54
  import { installAgent, uninstallAgent, isAgentLoaded, restartAgent } from "./launchd.ts";
55
55
  import {
56
+ buildMcpConfigJson,
56
57
  buildMcpEntryPlan,
57
58
  chooseHubOrigin,
58
59
  detectInstallContext,
@@ -169,6 +170,9 @@ switch (command) {
169
170
  case "mcp-install":
170
171
  await cmdMcpInstall(cmdArgs);
171
172
  break;
173
+ case "mcp-config":
174
+ await cmdMcpConfig(cmdArgs);
175
+ break;
172
176
  case "remove":
173
177
  case "rm":
174
178
  cmdRemove(cmdArgs);
@@ -965,6 +969,7 @@ async function cmdMcpInstall(args: string[]): Promise<void> {
965
969
  "--install-scope",
966
970
  "--vault",
967
971
  "--client",
972
+ "--dry-run",
968
973
  ];
969
974
 
970
975
  const hasFlag = args.some((a) => MCP_INSTALL_FLAG_NAMES.includes(a));
@@ -1053,6 +1058,12 @@ async function cmdMcpInstall(args: string[]): Promise<void> {
1053
1058
  }
1054
1059
 
1055
1060
  // --- Vault target. Default = default_vault; validate existence. ---
1061
+ // Read --dry-run *before* the vault-existence guard so a probe like
1062
+ // `mcp-install --vault future-vault --dry-run` can describe the
1063
+ // intended write even when the vault hasn't been created yet. The
1064
+ // dry-run contract ("no side effects, including failures on state
1065
+ // the caller is asking us about") is broken otherwise.
1066
+ const dryRun = args.includes("--dry-run");
1056
1067
  const vaultArg = takeArgValue(args, "--vault");
1057
1068
  if (vaultArg.missingValue) {
1058
1069
  console.error("--vault requires a value (the vault name).");
@@ -1062,7 +1073,7 @@ async function cmdMcpInstall(args: string[]): Promise<void> {
1062
1073
  const defaultVault = globalConfig.default_vault || "default";
1063
1074
  const vaultName = vaultArg.value ?? defaultVault;
1064
1075
  const vaultExplicit = vaultArg.value !== undefined;
1065
- if (!readVaultConfig(vaultName)) {
1076
+ if (!dryRun && !readVaultConfig(vaultName)) {
1066
1077
  console.error(`Vault "${vaultName}" not found. Available: ${listVaults().join(", ") || "(none)"}.`);
1067
1078
  process.exit(1);
1068
1079
  }
@@ -1075,6 +1086,7 @@ async function cmdMcpInstall(args: string[]): Promise<void> {
1075
1086
  vaultExplicit,
1076
1087
  pastedToken: mode === "token" ? tokenArg.value : undefined,
1077
1088
  globalConfig,
1089
+ dryRun,
1078
1090
  });
1079
1091
  }
1080
1092
 
@@ -1110,6 +1122,107 @@ async function cmdMcpInstallInteractive(): Promise<void> {
1110
1122
  });
1111
1123
  }
1112
1124
 
1125
+ /**
1126
+ * `parachute-vault mcp-config <vault-name>` — emit the JSON shape
1127
+ * `claude -p --mcp-config '<json>'` consumes, scoped to a named vault.
1128
+ * Pattern from Aaron's Gitcoin Brain runner:
1129
+ *
1130
+ * claude -p --mcp-config "$(parachute-vault mcp-config gitcoin)" \
1131
+ * --strict-mcp-config ...
1132
+ *
1133
+ * Synthesizing this JSON by hand was per-script boilerplate every runner
1134
+ * that spawned `claude -p` against a vault had to write. This command is
1135
+ * the canonical synthesizer — the JSON shape is owned here once.
1136
+ *
1137
+ * No state is mutated; this only emits to stdout. The bearer is read from
1138
+ * `--token <pvt_...>` or `PARACHUTE_VAULT_TOKEN` env (deliberate — we don't
1139
+ * mint here, since this is a stdout-piped subprocess where prompting would
1140
+ * deadlock the parent script). If neither is present, we exit 1 with a
1141
+ * clear stderr message; runners get a fail-fast.
1142
+ *
1143
+ * Flags:
1144
+ * --token <bearer> Bearer to embed verbatim (alternative: PARACHUTE_VAULT_TOKEN).
1145
+ * --base-url <url> Override the auto-detected hub origin (default: chooseHubOrigin).
1146
+ * Useful for tailnet-exposed hubs (`--base-url https://hub.tail.ts.net`).
1147
+ * --env-vars Emit the template form with `${PARACHUTE_HUB_URL}` and
1148
+ * `${PARACHUTE_VAULT_TOKEN}` placeholders rather than
1149
+ * inlined values. Safe to commit; the shell expands at
1150
+ * runtime.
1151
+ */
1152
+ async function cmdMcpConfig(args: string[]): Promise<void> {
1153
+ const vaultName = args[0];
1154
+ if (!vaultName || vaultName.startsWith("--")) {
1155
+ console.error("Usage: parachute-vault mcp-config <vault-name> [--token <pvt_...>] [--base-url <url>] [--env-vars]");
1156
+ console.error("");
1157
+ console.error("Emits the JSON config consumed by `claude -p --mcp-config '<json>'`.");
1158
+ console.error("Pattern: claude -p --mcp-config \"$(parachute-vault mcp-config <name>)\" --strict-mcp-config ...");
1159
+ process.exit(1);
1160
+ }
1161
+
1162
+ const tokenArg = takeArgValue(args, "--token");
1163
+ if (tokenArg.missingValue) {
1164
+ console.error("--token requires a value (the bearer to embed).");
1165
+ process.exit(1);
1166
+ }
1167
+ const baseUrlArg = takeArgValue(args, "--base-url");
1168
+ if (baseUrlArg.missingValue) {
1169
+ console.error("--base-url requires a value (e.g. https://hub.example.ts.net).");
1170
+ process.exit(1);
1171
+ }
1172
+ const useEnvVars = args.includes("--env-vars");
1173
+
1174
+ // --env-vars mode is shape-only — no need to resolve the vault, mint a
1175
+ // token, or even verify the vault exists. Operators committing the
1176
+ // template don't necessarily have the target vault on the machine
1177
+ // generating the config.
1178
+ if (useEnvVars) {
1179
+ const json = buildMcpConfigJson({
1180
+ vaultName,
1181
+ baseUrl: "",
1182
+ bearer: "",
1183
+ useEnvVars: true,
1184
+ });
1185
+ process.stdout.write(json + "\n");
1186
+ return;
1187
+ }
1188
+
1189
+ // Literal mode: verify the vault exists locally, resolve the URL, and
1190
+ // require a bearer (either --token or PARACHUTE_VAULT_TOKEN env).
1191
+ if (!readVaultConfig(vaultName)) {
1192
+ const available = listVaults();
1193
+ console.error(`Vault "${vaultName}" not found. Available: ${available.join(", ") || "(none — run `parachute-vault create <name>`)"}.`);
1194
+ process.exit(1);
1195
+ }
1196
+
1197
+ const bearer = tokenArg.value ?? process.env.PARACHUTE_VAULT_TOKEN;
1198
+ if (!bearer) {
1199
+ console.error("No bearer token provided. Pass --token <bearer> or set PARACHUTE_VAULT_TOKEN.");
1200
+ console.error(" Mint a token with: parachute-vault tokens create --vault " + vaultName);
1201
+ console.error(" Or use --env-vars to emit the template form (safe to commit; expands at runtime).");
1202
+ process.exit(1);
1203
+ }
1204
+
1205
+ const globalConfig = readGlobalConfig();
1206
+ const port = globalConfig.port || DEFAULT_PORT;
1207
+ let baseUrl: string;
1208
+ if (baseUrlArg.value) {
1209
+ baseUrl = baseUrlArg.value;
1210
+ } else {
1211
+ // chooseHubOrigin walks PARACHUTE_HUB_ORIGIN → expose-state → loopback;
1212
+ // for a script invoking `claude -p` against a local vault, loopback is
1213
+ // the right default.
1214
+ baseUrl = chooseHubOrigin(port).url;
1215
+ }
1216
+
1217
+ const json = buildMcpConfigJson({
1218
+ vaultName,
1219
+ baseUrl,
1220
+ bearer,
1221
+ useEnvVars: false,
1222
+ });
1223
+ process.stdout.write(json + "\n");
1224
+ }
1225
+
1113
1226
  interface ExecuteMcpInstallOpts {
1114
1227
  mode: "mint" | "token" | "legacy-pat";
1115
1228
  /** Full scope string (e.g. "vault:read"). The verb segment narrows downstream. */
@@ -1131,6 +1244,16 @@ interface ExecuteMcpInstallOpts {
1131
1244
  existingEntryKey?: string;
1132
1245
  /** Reused across the call chain to avoid re-parsing config.yaml. */
1133
1246
  globalConfig: ReturnType<typeof readGlobalConfig>;
1247
+ /**
1248
+ * When true, describe the write that *would* happen (target path,
1249
+ * entry key, URL, install scope) and exit 0 without touching the
1250
+ * filesystem or hitting the network for a hub-mint. Useful for
1251
+ * probing the command from a script that's setting up a runner.
1252
+ * Aaron hit this when accidentally creating an empty
1253
+ * `projects[<cwd>]` entry while just running `--help` — see the
1254
+ * `mcp-config` PR notes.
1255
+ */
1256
+ dryRun?: boolean;
1134
1257
  }
1135
1258
 
1136
1259
  /**
@@ -1140,7 +1263,7 @@ interface ExecuteMcpInstallOpts {
1140
1263
  * preview-and-confirm step so a cancel skips the network mint entirely.
1141
1264
  */
1142
1265
  async function executeMcpInstall(opts: ExecuteMcpInstallOpts): Promise<void> {
1143
- const { mode, rawScope, installScope, vaultName, vaultExplicit, pastedToken, globalConfig, existingEntryKey } = opts;
1266
+ const { mode, rawScope, installScope, vaultName, vaultExplicit, pastedToken, globalConfig, existingEntryKey, dryRun } = opts;
1144
1267
  const verb = rawScope.split(":")[1]!;
1145
1268
  const target = resolveInstallTarget(installScope);
1146
1269
  // Single source of truth shared with the interactive walkthrough's
@@ -1154,6 +1277,29 @@ async function executeMcpInstall(opts: ExecuteMcpInstallOpts): Promise<void> {
1154
1277
  ...(existingEntryKey ? { existingEntryKey } : {}),
1155
1278
  });
1156
1279
 
1280
+ // --dry-run: describe the write that *would* happen without touching
1281
+ // the filesystem or minting any token. Print to stdout (scripts may
1282
+ // parse this); exit 0. Skip the auth-acquisition entirely — the point
1283
+ // of --dry-run is to inspect without side effects, including the
1284
+ // network round-trip for hub-mint *and* the vault-existence guard
1285
+ // (probing the install for a not-yet-created vault is a legitimate
1286
+ // pre-create check, not an error).
1287
+ if (dryRun) {
1288
+ const vaultExists = readVaultConfig(vaultName) !== null;
1289
+ console.log(`[dry-run] No changes written.`);
1290
+ console.log(` Target file: ${target.path}`);
1291
+ console.log(` Install scope: ${target.scope}`);
1292
+ if (target.localProjectKey) {
1293
+ console.log(` Project key: ${target.localProjectKey}`);
1294
+ }
1295
+ console.log(` Entry key: ${entryKey}`);
1296
+ console.log(` MCP URL: ${url} (${source})`);
1297
+ console.log(` Auth mode: ${mode}${mode === "mint" ? ` (scope vault:${vaultName}:${verb})` : ""}`);
1298
+ console.log(` Vault: ${vaultName}${vaultExists ? "" : " (does not exist yet — create with `parachute-vault create " + vaultName + "`)"}`);
1299
+ console.log(` Re-run without --dry-run to apply.`);
1300
+ return;
1301
+ }
1302
+
1157
1303
  let bearer: string;
1158
1304
  if (mode === "token") {
1159
1305
  if (!pastedToken) {
@@ -1248,6 +1394,12 @@ async function executeMcpInstall(opts: ExecuteMcpInstallOpts): Promise<void> {
1248
1394
  console.log(`Added MCP server "${entryKey}" to ${target.path} under projects["${target.localProjectKey}"] (local scope).`);
1249
1395
  console.log(` Installed locally for this directory only — runs only when Claude Code launches from ${target.localProjectKey}.`);
1250
1396
  console.log(` To install globally instead, re-run with --install-scope user.`);
1397
+ // Headless-flow heads-up: local-scope MCP entries do NOT propagate
1398
+ // to subprocesses spawned by `claude -p` (script / cron / runner
1399
+ // shapes), even with --setting-sources covering local. Operators
1400
+ // wiring up a runner usually want `--install-scope user` so the
1401
+ // entry reaches every `claude` invocation on this machine.
1402
+ console.log(` Headless flows (claude -p in scripts, cron, runners): prefer --install-scope user — local-scope entries don't propagate to claude -p subprocesses.`);
1251
1403
  } else {
1252
1404
  console.log(`Added MCP server "${entryKey}" to ${target.path}`);
1253
1405
  }
@@ -2714,6 +2866,12 @@ async function cmdExport(args: string[]) {
2714
2866
  let vaultName = "default";
2715
2867
  let outputPath = "";
2716
2868
  let since: string | undefined;
2869
+ let watch = false;
2870
+ let intervalSeconds = 5;
2871
+ let gitCommit = false;
2872
+ const { DEFAULT_COMMIT_TEMPLATE } = await import("./export-watch.ts");
2873
+ let gitMessageTemplate = DEFAULT_COMMIT_TEMPLATE;
2874
+ let gitPush = false;
2717
2875
 
2718
2876
  const positional: string[] = [];
2719
2877
  for (let i = 0; i < args.length; i++) {
@@ -2740,6 +2898,31 @@ async function cmdExport(args: string[]) {
2740
2898
  process.exit(1);
2741
2899
  }
2742
2900
  since = v;
2901
+ } else if (arg === "--watch") {
2902
+ watch = true;
2903
+ } else if (arg === "--interval") {
2904
+ const v = args[++i];
2905
+ if (!v) {
2906
+ console.error("--interval requires a number of seconds.");
2907
+ process.exit(1);
2908
+ }
2909
+ const n = Number(v);
2910
+ if (!Number.isFinite(n) || n <= 0) {
2911
+ console.error(`--interval: must be a positive number of seconds (got '${v}')`);
2912
+ process.exit(1);
2913
+ }
2914
+ intervalSeconds = n;
2915
+ } else if (arg === "--git-commit") {
2916
+ gitCommit = true;
2917
+ } else if (arg === "--git-message-template") {
2918
+ const v = args[++i];
2919
+ if (!v) {
2920
+ console.error("--git-message-template requires a value.");
2921
+ process.exit(1);
2922
+ }
2923
+ gitMessageTemplate = v;
2924
+ } else if (arg === "--git-push") {
2925
+ gitPush = true;
2743
2926
  } else {
2744
2927
  positional.push(arg);
2745
2928
  }
@@ -2747,13 +2930,37 @@ async function cmdExport(args: string[]) {
2747
2930
  outputPath = positional[0] ?? "";
2748
2931
 
2749
2932
  if (!outputPath) {
2750
- console.error("Usage: parachute-vault export <dir> [--since <iso>] [--vault <name>]");
2933
+ console.error(
2934
+ "Usage: parachute-vault export <dir> [--since <iso>] [--vault <name>]\n" +
2935
+ " [--watch [--interval <seconds>]]\n" +
2936
+ " [--git-commit [--git-message-template <tpl>] [--git-push]]",
2937
+ );
2751
2938
  console.error("\nExports a Parachute Vault as portable markdown — round-trippable across");
2752
2939
  console.error("Obsidian / Logseq / Foam / Quartz / Dendron and most markdown SSGs.");
2753
2940
  console.error("\nOptions:");
2754
- console.error(" --vault <name> Source vault (default: 'default')");
2755
- console.error(" --since <iso> Only export notes whose updated_at >= ISO timestamp");
2756
- console.error(" (incremental — useful for git-projection cadences)");
2941
+ console.error(" --vault <name> Source vault (default: 'default')");
2942
+ console.error(" --since <iso> Only export notes whose updated_at >= ISO timestamp");
2943
+ console.error(" (incremental — useful for git-projection cadences)");
2944
+ console.error(" --watch Stay alive; re-export incrementally on every");
2945
+ console.error(" vault write. Polls every --interval seconds.");
2946
+ console.error(" --interval <seconds> Watch poll interval (default: 5)");
2947
+ console.error(" --git-commit After each export, `git add -A` + commit in <dir>.");
2948
+ console.error(" Requires <dir> to be an initialized git repo.");
2949
+ console.error(" Combine with --watch for auto-history.");
2950
+ console.error(" --git-message-template <t> Commit message template. Variables:");
2951
+ console.error(" {{date}}, {{notes_changed}}, {{plural}},");
2952
+ console.error(" {{first_note_title}}, {{vault_name}}");
2953
+ console.error(" (default: \"export: {{date}} ({{notes_changed}} note{{plural}})\")");
2954
+ console.error(" --git-push After commit, run `git push` (non-fatal on failure).");
2955
+ process.exit(1);
2956
+ }
2957
+
2958
+ if (intervalSeconds !== 5 && !watch) {
2959
+ console.error("--interval only applies with --watch.");
2960
+ process.exit(1);
2961
+ }
2962
+ if ((gitPush || gitMessageTemplate !== DEFAULT_COMMIT_TEMPLATE) && !gitCommit) {
2963
+ console.error("--git-push / --git-message-template only apply with --git-commit.");
2757
2964
  process.exit(1);
2758
2965
  }
2759
2966
 
@@ -2772,25 +2979,180 @@ async function cmdExport(args: string[]) {
2772
2979
 
2773
2980
  const store = getVaultStore(vaultName);
2774
2981
  const assetsDirPath = assetsDir(vaultName);
2775
- console.log(`Exporting vault "${vaultName}" to ${fullPath}${since ? ` (since ${since})` : ""}`);
2776
- const stats = await exportVaultToDir(store, {
2777
- outDir: fullPath,
2778
- vaultName,
2779
- assetsDir: assetsDirPath,
2780
- ...(config.description ? { vaultDescription: config.description } : {}),
2781
- ...(since ? { since } : {}),
2782
- });
2982
+ const vaultDescription = config.description;
2983
+
2984
+ // Git-repo precheck: fail fast and loud before we run a long-lived loop.
2985
+ if (gitCommit) {
2986
+ await ensureGitRepo(fullPath);
2987
+ }
2988
+
2989
+ /**
2990
+ * Run a single export cycle: export → optionally commit/push. Returns the
2991
+ * stats + a freshly-captured cursor for the next incremental run. We
2992
+ * capture the cursor *before* the export starts, so any write that lands
2993
+ * during the export is picked up next cycle (cost: at most one note
2994
+ * re-exported next round when its `updated_at` equals our cursor).
2995
+ */
2996
+ async function runCycle(opts: { sinceCursor?: string; isInitial: boolean }): Promise<{
2997
+ stats: Awaited<ReturnType<typeof exportVaultToDir>>;
2998
+ nextCursor: string;
2999
+ committed: boolean;
3000
+ }> {
3001
+ const nextCursor = new Date().toISOString();
3002
+ if (opts.isInitial) {
3003
+ console.log(
3004
+ `Exporting vault "${vaultName}" to ${fullPath}${opts.sinceCursor ? ` (since ${opts.sinceCursor})` : ""}`,
3005
+ );
3006
+ }
3007
+ const stats = await exportVaultToDir(store, {
3008
+ outDir: fullPath,
3009
+ vaultName,
3010
+ assetsDir: assetsDirPath,
3011
+ ...(vaultDescription ? { vaultDescription } : {}),
3012
+ ...(opts.sinceCursor ? { since: opts.sinceCursor } : {}),
3013
+ });
3014
+
3015
+ if (opts.isInitial) {
3016
+ console.log(
3017
+ `Exported ${stats.notes} note(s), ${stats.schemas} tag schema(s), ${stats.attachments} attachment(s).`,
3018
+ );
3019
+ console.log(`Sidecar: ${fullPath}/.parachute/`);
3020
+ if (stats.filtered_by_since) {
3021
+ console.log(`Incremental: filtered by --since ${opts.sinceCursor}.`);
3022
+ }
3023
+ if (stats.skipped_traversal > 0) {
3024
+ console.log(
3025
+ `Note: ${stats.skipped_traversal} note(s) skipped (path-traversal). See [export] warnings above.`,
3026
+ );
3027
+ }
3028
+ if (stats.skipped_attachments.length > 0) {
3029
+ console.log(
3030
+ `Note: ${stats.skipped_attachments.length} attachment(s) skipped. See [export] warnings above.`,
3031
+ );
3032
+ }
3033
+ } else {
3034
+ // Watch-mode status line: keep tight; the loop logs every interval.
3035
+ if (stats.notes > 0) {
3036
+ console.log(
3037
+ `[watch] exported ${stats.notes} note${stats.notes === 1 ? "" : "s"} (cursor: ${nextCursor})`,
3038
+ );
3039
+ } else {
3040
+ console.log(`[watch] no changes`);
3041
+ }
3042
+ }
2783
3043
 
2784
- console.log(`Exported ${stats.notes} note(s), ${stats.schemas} tag schema(s), ${stats.attachments} attachment(s).`);
2785
- console.log(`Sidecar: ${fullPath}/.parachute/`);
2786
- if (stats.filtered_by_since) {
2787
- console.log(`Incremental: filtered by --since ${since}.`);
3044
+ let committed = false;
3045
+ if (gitCommit) {
3046
+ const { runGitCommitCycle } = await import("./export-watch.ts");
3047
+ const commitResult = await runGitCommitCycle({
3048
+ repoDir: fullPath,
3049
+ template: gitMessageTemplate,
3050
+ notesChanged: stats.notes,
3051
+ vaultName,
3052
+ firstNoteTitle: await firstChangedNoteTitle(store, opts.sinceCursor),
3053
+ push: gitPush,
3054
+ });
3055
+ committed = commitResult.committed;
3056
+ }
3057
+
3058
+ return { stats, nextCursor, committed };
2788
3059
  }
2789
- if (stats.skipped_traversal > 0) {
2790
- console.log(`Note: ${stats.skipped_traversal} note(s) skipped (path-traversal). See [export] warnings above.`);
3060
+
3061
+ // ---- Single-shot mode ----
3062
+ if (!watch) {
3063
+ await runCycle({ sinceCursor: since, isInitial: true });
3064
+ return;
2791
3065
  }
2792
- if (stats.skipped_attachments.length > 0) {
2793
- console.log(`Note: ${stats.skipped_attachments.length} attachment(s) skipped. See [export] warnings above.`);
3066
+
3067
+ // ---- Watch mode ----
3068
+ // Initial full (or since-filtered) export, then poll every interval.
3069
+ const initial = await runCycle({ sinceCursor: since, isInitial: true });
3070
+ let cursor = initial.nextCursor;
3071
+ console.log(`[watch] polling every ${intervalSeconds}s; press Ctrl-C to stop.`);
3072
+
3073
+ let stopping = false;
3074
+ let inFlight = false;
3075
+ let timer: ReturnType<typeof setInterval> | undefined;
3076
+ const onStop = () => {
3077
+ if (stopping) return;
3078
+ stopping = true;
3079
+ // Clear the interval immediately so the timer can't fire one more
3080
+ // time during the in-flight settle window — `stopping` already guards
3081
+ // re-entry, but symmetry beats relying on that guard.
3082
+ if (timer) clearInterval(timer);
3083
+ console.log("\n[watch] stopping watch");
3084
+ // Give an in-flight cycle a brief moment to settle, then exit. Don't
3085
+ // hang forever — operator hit Ctrl-C, they want out.
3086
+ setTimeout(() => process.exit(0), inFlight ? 250 : 0);
3087
+ };
3088
+ process.on("SIGINT", onStop);
3089
+ process.on("SIGTERM", onStop);
3090
+
3091
+ timer = setInterval(async () => {
3092
+ if (stopping || inFlight) return;
3093
+ inFlight = true;
3094
+ try {
3095
+ const cycle = await runCycle({ sinceCursor: cursor, isInitial: false });
3096
+ cursor = cycle.nextCursor;
3097
+ } catch (err) {
3098
+ // Don't kill the loop on a transient export error — log and keep
3099
+ // polling. Operator can Ctrl-C if they want to bail.
3100
+ console.error(`[watch] export error: ${(err as Error).message ?? err}`);
3101
+ } finally {
3102
+ inFlight = false;
3103
+ }
3104
+ }, intervalSeconds * 1000);
3105
+ // Keep a reference so the timer isn't GC'd; nothing else holds the loop open.
3106
+ timer.unref?.();
3107
+ // Block forever (signal handler is the exit path).
3108
+ await new Promise(() => {});
3109
+ }
3110
+
3111
+ // ---------------------------------------------------------------------------
3112
+ // Export-watch glue. The git-shell + commit-message logic lives in
3113
+ // `./export-watch.ts` for unit-testability; cli.ts just wires it in.
3114
+ // ---------------------------------------------------------------------------
3115
+
3116
+ async function ensureGitRepo(dir: string): Promise<void> {
3117
+ const { isGitRepo } = await import("./export-watch.ts");
3118
+ if (!(await isGitRepo(dir))) {
3119
+ console.error(
3120
+ `error: --git-commit requires "${dir}" to be a git repo.\n` +
3121
+ `Initialize it first:\n` +
3122
+ ` cd "${dir}" && git init && git add -A && git commit -m "initial"\n` +
3123
+ `Then re-run the export.`,
3124
+ );
3125
+ process.exit(1);
3126
+ }
3127
+ }
3128
+
3129
+ /**
3130
+ * Snapshot the title of the first changed note since `cursor` — used as the
3131
+ * `{{first_note_title}}` template variable for single-note commit messages.
3132
+ * Best-effort: returns empty string when nothing matches, or when the cursor
3133
+ * is unset (initial export, where "first changed note" is ambiguous).
3134
+ *
3135
+ * Filters at the DB layer via `dateFilter: { field: "updated_at", from:
3136
+ * cursor }` — earlier versions used `sort: "asc"` + `limit: 1` + a
3137
+ * client-side `stamp >= cursor` post-filter, which fetched the vault's
3138
+ * oldest note and almost always failed the filter, rendering the template
3139
+ * variable as empty in production. See vault#346 reviewer note.
3140
+ */
3141
+ async function firstChangedNoteTitle(
3142
+ store: ReturnType<typeof import("./vault-store.ts")["getVaultStore"]>,
3143
+ cursor: string | undefined,
3144
+ ): Promise<string> {
3145
+ if (!cursor) return "";
3146
+ try {
3147
+ const notes = await store.queryNotes({
3148
+ limit: 1,
3149
+ sort: "asc",
3150
+ dateFilter: { field: "updated_at", from: cursor },
3151
+ });
3152
+ return notes[0]?.path ?? notes[0]?.id ?? "";
3153
+ } catch {
3154
+ // Best-effort; template var defaults to empty.
3155
+ return "";
2794
3156
  }
2795
3157
  }
2796
3158
 
@@ -2921,6 +3283,7 @@ Vaults:
2921
3283
  [--scope vault:read|vault:write|vault:admin]
2922
3284
  [--install-scope local|user|project]
2923
3285
  [--vault <name>] [--client claude-code]
3286
+ [--dry-run]
2924
3287
  Install vault MCP into a client config.
2925
3288
  From a terminal with no flags: walks you
2926
3289
  through a contextual conversation (vault,
@@ -2944,9 +3307,38 @@ Vaults:
2944
3307
  directory only). user writes top-level
2945
3308
  mcpServers (every project). project
2946
3309
  writes ./.mcp.json (check into the repo).
3310
+ For headless flows (\`claude -p\` in
3311
+ scripts, cron jobs, runners), prefer
3312
+ --install-scope user — local-scope
3313
+ entries don't propagate to claude -p
3314
+ subprocesses; interactive sessions
3315
+ from the install directory still see
3316
+ local just fine.
2947
3317
  --vault <name> targets a specific
2948
3318
  vault and keys the entry as
2949
3319
  parachute-vault-<name>.
3320
+ --dry-run prints the write that would
3321
+ happen (target file, entry key, URL)
3322
+ without touching disk or hitting the
3323
+ hub. Useful for probing.
3324
+
3325
+ parachute-vault mcp-config <vault-name> [--token <pvt_...>] [--base-url <url>]
3326
+ [--env-vars]
3327
+ Emit the JSON config consumed by
3328
+ \`claude -p --mcp-config '<json>'\`.
3329
+ Pattern:
3330
+ claude -p --mcp-config \\
3331
+ "$(parachute-vault mcp-config <name>)" \\
3332
+ --strict-mcp-config ...
3333
+ --token or PARACHUTE_VAULT_TOKEN env
3334
+ supplies the bearer; both absent
3335
+ exits 1 with a clear error.
3336
+ --base-url overrides the auto-detected
3337
+ origin (e.g. tailnet-exposed hubs).
3338
+ --env-vars emits the template form
3339
+ with \${PARACHUTE_HUB_URL} and
3340
+ \${PARACHUTE_VAULT_TOKEN} placeholders
3341
+ (safe to commit; expanded at runtime).
2950
3342
 
2951
3343
  Tokens:
2952
3344
  parachute-vault tokens List tokens (every vault)
@@ -2993,6 +3385,12 @@ Import/Export:
2993
3385
  parachute-vault export <dir> Export vault as portable markdown
2994
3386
  (Obsidian/Logseq/Foam/Quartz-compatible)
2995
3387
  parachute-vault export <dir> --since <iso> Incremental: only notes updated_at >= ISO
3388
+ parachute-vault export <dir> --watch Stay alive; re-export on every write
3389
+ (poll every --interval seconds, default 5)
3390
+ parachute-vault export <dir> --git-commit After export, git add -A + commit in <dir>
3391
+ (combine with --watch for auto-history;
3392
+ template via --git-message-template;
3393
+ --git-push to push after commit)
2996
3394
 
2997
3395
  ── Advanced / standalone ──────────────────────────────────────────────
2998
3396