agy-context-saver 1.5.0 → 1.6.0

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.
@@ -22,7 +22,7 @@ const fsPromises = fs.promises;
22
22
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
23
  const repoRoot = path.resolve(__dirname, "..");
24
24
 
25
- const homeDir = os.homedir();
25
+ const homeDir = process.env.AGY_TEST_PROFILE_DIR || os.homedir();
26
26
  const geminiConfigDir = path.join(homeDir, ".gemini", "config");
27
27
  const pluginsDir = path.join(geminiConfigDir, "plugins");
28
28
  const pluginDest = path.join(pluginsDir, "agy-context-saver");
@@ -138,13 +138,62 @@ export function detectExistingInstallation() {
138
138
  };
139
139
  }
140
140
 
141
+ export function parseAndValidateJsonConfig(filePath, configName) {
142
+ if (!fs.existsSync(filePath)) return null;
143
+ const content = fs.readFileSync(filePath, "utf-8");
144
+ let parsed;
145
+ try {
146
+ parsed = JSON.parse(content);
147
+ } catch (err) {
148
+ throw new Error(`Configuration error: ${filePath} contains invalid JSON (${err.message}). Installation aborted to prevent data loss. Original file is unchanged.`);
149
+ }
150
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
151
+ throw new Error(`Configuration error: ${filePath} must contain a JSON object, but found ${parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed}. Installation aborted to prevent data loss. Original file is unchanged.`);
152
+ }
153
+ if (configName === "mcp" && parsed.mcpServers !== undefined) {
154
+ if (typeof parsed.mcpServers !== "object" || parsed.mcpServers === null || Array.isArray(parsed.mcpServers)) {
155
+ throw new Error(`Configuration error: ${filePath} property "mcpServers" must be a JSON object. Installation aborted to prevent data loss. Original file is unchanged.`);
156
+ }
157
+ }
158
+ return parsed;
159
+ }
160
+
141
161
  export async function runInstall(options = {}) {
142
162
  const log = options.silent ? () => {} : console.log;
143
163
  const warn = options.silent ? () => {} : console.warn;
144
164
 
145
165
  const startTime = performance.now();
166
+
167
+ // Pre-flight Fail-Closed Validation: Verify existing configs BEFORE ANY DISK MUTATION OR CHECK RETURN
168
+ const validatedHooks = parseAndValidateJsonConfig(hooksJsonPath, "hooks");
169
+ const validatedMcp = parseAndValidateJsonConfig(mcpJsonPath, "mcp");
170
+
146
171
  const existing = detectExistingInstallation();
147
172
 
173
+ // If checkOnly is requested, execute strictly read-only inspection (zero disk writes, zero network calls)
174
+ if (options.checkOnly) {
175
+ if (existing.isInstalled) {
176
+ log("šŸ” Existing installation detected:");
177
+ if (existing.details.plugin.exists) {
178
+ log(` āœ“ Native Plugin Link: ${pluginDest}`);
179
+ }
180
+ if (existing.details.hook.registered) {
181
+ log(` āœ“ Governor Hook: Registered in ${hooksJsonPath}`);
182
+ }
183
+ if (existing.details.mcp.registered) {
184
+ log(` āœ“ Universal MCP Server: Configured in ${mcpJsonPath}`);
185
+ }
186
+ if (existing.details.schemas.exists) {
187
+ log(` āœ“ Antigravity Tool Schemas: Present in ${antigravityMcpDir}`);
188
+ }
189
+ log("\n[OK] Pre-flight check complete. Installation is active.");
190
+ } else {
191
+ log("šŸ” Pre-flight check: No active installation detected (clean profile).");
192
+ log("\n[OK] Pre-flight check complete. Zero changes written.");
193
+ }
194
+ return existing;
195
+ }
196
+
148
197
  if (existing.isInstalled) {
149
198
  log("šŸ” Existing installation detected:");
150
199
  if (existing.details.plugin.exists) {
@@ -159,11 +208,6 @@ export async function runInstall(options = {}) {
159
208
  if (existing.details.schemas.exists) {
160
209
  log(` āœ“ Antigravity Tool Schemas: Present in ${antigravityMcpDir}`);
161
210
  }
162
-
163
- if (options.checkOnly) {
164
- log("\n[OK] Pre-flight check complete. Installation is active.");
165
- return existing;
166
- }
167
211
  log("\n⚔ Re-verifying and synchronizing to latest version...");
168
212
  } else {
169
213
  log("⚔ Fresh installation of Agy-Context-Saver (Zero-Delay Mode)...");
@@ -184,17 +228,27 @@ export async function runInstall(options = {}) {
184
228
  const rtkResult = await ensureRtkInstalled({ silent: options.silent, existingRtk: existing.details.rtk });
185
229
  log(`[OK] RTK Command Optimizer verified: ${rtkResult.version} (${rtkResult.path})`);
186
230
  } catch (err) {
187
- warn(`[WARN] RTK provisioning check encountered an issue: ${err.message}`);
231
+ if (options.allowDegraded) {
232
+ warn(`[WARN] RTK provisioning check encountered an issue: ${err.message}. Continuing in degraded mode.`);
233
+ } else {
234
+ throw new Error(`RTK provisioning failed: ${err.message}. To install without RTK (degraded mode), re-run with --allow-degraded.`);
235
+ }
188
236
  }
189
237
 
190
238
  // 1. Instant Native Plugin Junction / Symlink or Permanent Copy (Takes ~5ms)
191
239
  await fsPromises.mkdir(pluginsDir, { recursive: true });
192
240
  const isGitRepo = fs.existsSync(path.join(repoRoot, ".git"));
193
241
  const isTempOrNpx = !isGitRepo || repoRoot.includes("_npx") || repoRoot.includes("npm-cache") || repoRoot.includes("node_modules");
242
+ const copyFilter = (src) => {
243
+ const rel = path.relative(repoRoot, src);
244
+ if (!rel) return true;
245
+ const topSegment = rel.split(path.sep)[0];
246
+ return ![".git", "node_modules", ".venv", "scratch", "site"].includes(topSegment);
247
+ };
194
248
 
195
249
  if (!fs.existsSync(pluginDest)) {
196
- if (isTempOrNpx) {
197
- await fsPromises.cp(repoRoot, pluginDest, { recursive: true });
250
+ if (isTempOrNpx || options.forceCopyFallback) {
251
+ await fsPromises.cp(repoRoot, pluginDest, { recursive: true, filter: copyFilter });
198
252
  log(`[OK] Native Antigravity Plugin installed to: ${pluginDest}`);
199
253
  } else {
200
254
  try {
@@ -202,14 +256,14 @@ export async function runInstall(options = {}) {
202
256
  fs.symlinkSync(repoRoot, pluginDest, isWin ? "junction" : "dir");
203
257
  log(`[OK] Native Antigravity Plugin linked: ${pluginDest} -> ${repoRoot}`);
204
258
  } catch (err) {
205
- await fsPromises.cp(repoRoot, pluginDest, { recursive: true });
259
+ await fsPromises.cp(repoRoot, pluginDest, { recursive: true, filter: copyFilter });
206
260
  log(`[OK] Native Antigravity Plugin copied to: ${pluginDest}`);
207
261
  }
208
262
  }
209
263
  } else {
210
- if (isTempOrNpx) {
264
+ if (isTempOrNpx || options.forceCopyFallback) {
211
265
  try {
212
- await fsPromises.cp(repoRoot, pluginDest, { recursive: true });
266
+ await fsPromises.cp(repoRoot, pluginDest, { recursive: true, filter: copyFilter });
213
267
  } catch {}
214
268
  }
215
269
  log(`[OK] Native Antigravity Plugin verified: ${pluginDest}`);
@@ -221,21 +275,20 @@ export async function runInstall(options = {}) {
221
275
  log(`[OK] Governor hook script mirrored to: ${destHook}${hookUpdated ? "" : " (up-to-date)"}`);
222
276
 
223
277
  // 3. Register Hook in ~/.gemini/config/hooks.json (Preserves existing hooks + creates backup)
224
- let hooksConfig = {};
225
- if (fs.existsSync(hooksJsonPath)) {
278
+ let hooksConfig = validatedHooks ? { ...validatedHooks } : {};
279
+ if (fs.existsSync(hooksJsonPath) && !fs.existsSync(hooksBakPath)) {
226
280
  try {
227
- hooksConfig = JSON.parse(fs.readFileSync(hooksJsonPath, "utf-8"));
228
- // Create backup if none exists
229
- if (!fs.existsSync(hooksBakPath)) {
230
- await fsPromises.copyFile(hooksJsonPath, hooksBakPath);
231
- log(`[OK] Created configuration backup: ${hooksBakPath}`);
232
- }
281
+ await fsPromises.copyFile(hooksJsonPath, hooksBakPath);
282
+ log(`[OK] Created configuration backup: ${hooksBakPath}`);
233
283
  } catch (err) {
234
- hooksConfig = {};
284
+ warn(`[WARN] Could not create backup of ${hooksJsonPath}: ${err.message}`);
235
285
  }
236
286
  }
237
287
 
238
288
  const alreadyHadHook = Boolean(hooksConfig["execution-guard"]);
289
+ if (hooksConfig["agy-context-saver"]) {
290
+ delete hooksConfig["agy-context-saver"];
291
+ }
239
292
  hooksConfig["execution-guard"] = {
240
293
  PreToolUse: [
241
294
  {
@@ -254,18 +307,18 @@ export async function runInstall(options = {}) {
254
307
  log(`[OK] Lifecycle hook ${alreadyHadHook ? "re-verified" : "registered"} in: ${hooksJsonPath}${hooksUpdated ? "" : " (up-to-date)"}`);
255
308
 
256
309
  // 4. Register MCP Server in ~/.gemini/config/mcp_config.json (Preserves existing servers + creates backup)
257
- let mcpConfig = { mcpServers: {} };
258
- if (fs.existsSync(mcpJsonPath)) {
310
+ let mcpConfig = validatedMcp ? { ...validatedMcp } : { mcpServers: {} };
311
+ if (!mcpConfig.mcpServers || typeof mcpConfig.mcpServers !== "object") {
312
+ mcpConfig.mcpServers = {};
313
+ } else {
314
+ mcpConfig.mcpServers = { ...mcpConfig.mcpServers };
315
+ }
316
+ if (fs.existsSync(mcpJsonPath) && !fs.existsSync(mcpBakPath)) {
259
317
  try {
260
- mcpConfig = JSON.parse(fs.readFileSync(mcpJsonPath, "utf-8"));
261
- if (!mcpConfig.mcpServers) mcpConfig.mcpServers = {};
262
- // Create backup if none exists
263
- if (!fs.existsSync(mcpBakPath)) {
264
- await fsPromises.copyFile(mcpJsonPath, mcpBakPath);
265
- log(`[OK] Created configuration backup: ${mcpBakPath}`);
266
- }
318
+ await fsPromises.copyFile(mcpJsonPath, mcpBakPath);
319
+ log(`[OK] Created configuration backup: ${mcpBakPath}`);
267
320
  } catch (err) {
268
- mcpConfig = { mcpServers: {} };
321
+ warn(`[WARN] Could not create backup of ${mcpJsonPath}: ${err.message}`);
269
322
  }
270
323
  }
271
324
 
@@ -318,7 +371,7 @@ Agy-Context-Saver is the universal Model Context Protocol server and lifecycle g
318
371
 
319
372
  ## Available MCP Tools
320
373
  - \`check_context_health\`: Diagnoses transcript turn counts, byte payload, and polling loops. Accepts \`transcriptPath\` or \`conversationId\`.
321
- - \`subagent_brief\`: Generates scope-isolated instructions for delegated subagents with Pointer-Over-Wire delivery contract.
374
+ - \`subagent_brief\`: Formulates scope-isolated prompts and ready-to-use invoke_subagent JSON payloads for single or multiple delegated subagents with Pointer-Over-Wire delivery contract.
322
375
  - \`read_transcript\`: Streams recent turns from \`transcript.jsonl\` or \`transcript_full.jsonl\` formatted as clean Markdown.
323
376
  - \`query_transcript\`: Forensic regex search and filtering engine across transcript steps with optional \`summaryOnly: true\`, and surgical step extraction via \`stepIndex\` and \`field\`.
324
377
  - \`get_installation_status\`: Audits live health of the plugin link, hook, MCP server, tool schemas, and RTK.
@@ -351,15 +404,37 @@ Agy-Context-Saver is the universal Model Context Protocol server and lifecycle g
351
404
 
352
405
  const subagentBriefSchema = {
353
406
  name: "subagent_brief",
354
- description: "Generates a scope-isolated, anti-bloat prompt for invoking a subagent. Instructs the subagent to absorb all intermediate file reads/searches and return only high-signal findings.",
407
+ description: "Generates scope-isolated, anti-bloat prompts and ready-to-use invoke_subagent JSON payloads for single or multiple delegated subagents. Instructs subagents to absorb intermediate file reads/searches and return only high-signal findings.",
355
408
  parameters: {
356
409
  type: "object",
357
410
  properties: {
358
- objective: { type: "string", description: "The core research or investigative objective." },
411
+ objective: { type: "string", description: "The core research or investigative objective (for single subagent)." },
412
+ role: { type: "string", description: "2-5 word role description (e.g. 'Comprehensive Test Engineer', 'Codebase Security Auditor'). Defaults to 'Scope-Isolated Deep Research Subagent'." },
413
+ instructions: { type: "string", description: "Optional step-by-step instructions or specialized directives." },
359
414
  scopeFiles: { type: "array", items: { type: "string" }, description: "List of files or directories to investigate." },
360
- expectedDeliverable: { type: "string", description: "The concise format expected back by the parent agent." }
361
- },
362
- required: ["objective"]
415
+ expectedDeliverable: { type: "string", description: "The concise format expected back by the parent agent." },
416
+ typeName: { type: "string", description: "Subagent type name: 'self' (default, inherits parent tools/prompt/model) or 'research' (read-only)." },
417
+ model: { type: "string", enum: ["inherit", "flash_lite", "flash", "pro"], description: "Model tier: 'inherit' (default), 'flash_lite', 'flash', or 'pro'." },
418
+ workspace: { type: "string", enum: ["inherit", "branch", "share"], description: "Workspace isolation: 'inherit' (default), 'branch', or 'share'." },
419
+ subagents: {
420
+ type: "array",
421
+ description: "Optional array of subagent configurations to formulate multiple subagents at once.",
422
+ items: {
423
+ type: "object",
424
+ properties: {
425
+ role: { type: "string", description: "2-5 word role description." },
426
+ objective: { type: "string", description: "The core objective for this subagent." },
427
+ instructions: { type: "string", description: "Optional step-by-step instructions." },
428
+ scopeFiles: { type: "array", items: { type: "string" }, description: "Files/directories to investigate." },
429
+ expectedDeliverable: { type: "string", description: "Expected deliverable format." },
430
+ typeName: { type: "string", description: "Subagent type name: 'self' or 'research'." },
431
+ model: { type: "string", enum: ["inherit", "flash_lite", "flash", "pro"], description: "Model tier." },
432
+ workspace: { type: "string", enum: ["inherit", "branch", "share"], description: "Workspace isolation." }
433
+ },
434
+ required: ["objective"]
435
+ }
436
+ }
437
+ }
363
438
  }
364
439
  };
365
440
 
@@ -494,7 +569,8 @@ Agy-Context-Saver is the universal Model Context Protocol server and lifecycle g
494
569
  const elapsed = (performance.now() - startTime).toFixed(1);
495
570
  const statusVerb = existing.isInstalled ? "synchronized & up-to-date" : "completed";
496
571
  log(`\nāœ“ Installation ${statusVerb} in ${elapsed}ms (Zero Delay)!\n`);
497
- return { ...existing, isInstalled: true, elapsedMs: elapsed };
572
+ const postInstall = detectExistingInstallation();
573
+ return { ...postInstall, isInstalled: true, elapsedMs: elapsed };
498
574
  }
499
575
 
500
576
  export async function runUninstall(options = {}) {
@@ -557,25 +633,42 @@ export async function runUninstall(options = {}) {
557
633
 
558
634
  // 6. Optional: Restore configuration files from backups if requested
559
635
  if (options.restoreBackups) {
560
- if (fs.existsSync(hooksBakPath)) {
561
- try {
562
- await fsPromises.copyFile(hooksBakPath, hooksJsonPath);
563
- log(`[OK] Restored hooks.json from backup: ${hooksBakPath}`);
564
- } catch (err) {
565
- warn(`[WARN] Could not restore hooks backup: ${err.message}`);
566
- }
636
+ await runRestoreBackups(options);
637
+ }
638
+
639
+ log("\nāœ“ Uninstallation complete.\n");
640
+ }
641
+
642
+ export async function runRestoreBackups(options = {}) {
643
+ const log = options.silent ? () => {} : console.log;
644
+ const warn = options.silent ? () => {} : console.warn;
645
+ let restoredCount = 0;
646
+
647
+ if (fs.existsSync(hooksBakPath)) {
648
+ try {
649
+ await fsPromises.copyFile(hooksBakPath, hooksJsonPath);
650
+ log(`[OK] Restored hooks.json from backup: ${hooksBakPath}`);
651
+ restoredCount++;
652
+ } catch (err) {
653
+ warn(`[WARN] Could not restore hooks backup: ${err.message}`);
567
654
  }
568
- if (fs.existsSync(mcpBakPath)) {
569
- try {
570
- await fsPromises.copyFile(mcpBakPath, mcpJsonPath);
571
- log(`[OK] Restored mcp_config.json from backup: ${mcpBakPath}`);
572
- } catch (err) {
573
- warn(`[WARN] Could not restore mcp backup: ${err.message}`);
574
- }
655
+ } else {
656
+ log(`[INFO] No hooks.json backup found at ${hooksBakPath}`);
657
+ }
658
+
659
+ if (fs.existsSync(mcpBakPath)) {
660
+ try {
661
+ await fsPromises.copyFile(mcpBakPath, mcpJsonPath);
662
+ log(`[OK] Restored mcp_config.json from backup: ${mcpBakPath}`);
663
+ restoredCount++;
664
+ } catch (err) {
665
+ warn(`[WARN] Could not restore mcp backup: ${err.message}`);
575
666
  }
667
+ } else {
668
+ log(`[INFO] No mcp_config.json backup found at ${mcpBakPath}`);
576
669
  }
577
670
 
578
- log("\nāœ“ Uninstallation complete.\n");
671
+ return restoredCount > 0;
579
672
  }
580
673
 
581
674
  export function runStatus(options = {}) {
@@ -613,7 +706,8 @@ Commands & Options:
613
706
  --status, status Check live integration status across all layers + RTK
614
707
  --check, -c Run pre-flight validation check without writing changes
615
708
  --uninstall, uninstall Remove governor hook, plugin link, and MCP server
616
- --restore-backups Restore original configuration backup files (*.bak) during uninstall
709
+ --restore-backups Restore original configuration backup files (*.bak) (standalone or during uninstall)
710
+ --allow-degraded Allow installation to proceed even if RTK optimizer provisioning fails
617
711
  --help, -h, help Show this help message
618
712
  (no arguments) Synchronize and verify full 4-layer installation
619
713
  `);
@@ -621,18 +715,38 @@ Commands & Options:
621
715
 
622
716
  // Auto-run if executed directly as script
623
717
  if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
624
- const arg = process.argv[2];
625
- if (arg === "--help" || arg === "-h" || arg === "help") {
718
+ const args = process.argv.slice(2);
719
+ const isHelp = args.includes("--help") || args.includes("-h") || args.includes("help");
720
+ const isUninstall = args.includes("--uninstall") || args.includes("uninstall");
721
+ const isStatus = args.includes("--status") || args.includes("status");
722
+ const isCheck = args.includes("--check") || args.includes("-c");
723
+ const isRestoreBackups = args.includes("--restore-backups") || args.includes("restore-backups");
724
+ const allowDegraded = args.includes("--allow-degraded");
725
+
726
+ if (isHelp) {
626
727
  printCliHelp();
627
728
  process.exit(0);
628
- } else if (arg === "--uninstall" || arg === "uninstall") {
629
- const restoreBackups = process.argv.includes("--restore-backups");
630
- await runUninstall({ restoreBackups });
631
- } else if (arg === "--status" || arg === "status") {
729
+ }
730
+
731
+ // Reject conflicting read-only and mutating flags explicitly
732
+ if ((isCheck || isStatus) && (isUninstall || isRestoreBackups)) {
733
+ console.error("Error: Read-only flags (--check, --status) cannot be combined with mutating flags (--uninstall, --restore-backups).");
734
+ process.exit(1);
735
+ }
736
+ if (isCheck && isStatus) {
737
+ console.error("Error: Conflicting flags: specify either --check or --status, not both.");
738
+ process.exit(1);
739
+ }
740
+
741
+ if (isCheck) {
742
+ await runInstall({ checkOnly: true, allowDegraded });
743
+ } else if (isStatus) {
632
744
  runStatus();
633
- } else if (arg === "--check" || arg === "-c") {
634
- await runInstall({ checkOnly: true });
745
+ } else if (isUninstall) {
746
+ await runUninstall({ restoreBackups: isRestoreBackups });
747
+ } else if (isRestoreBackups) {
748
+ await runRestoreBackups();
635
749
  } else {
636
- await runInstall();
750
+ await runInstall({ allowDegraded });
637
751
  }
638
752
  }
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import crypto from "node:crypto";
4
5
  import { execSync } from "node:child_process";
5
6
  import { fileURLToPath } from "node:url";
6
7
 
@@ -9,6 +10,26 @@ const repoRoot = path.resolve(__dirname, "..");
9
10
  const binDir = path.join(repoRoot, "bin");
10
11
 
11
12
  export const RTK_VERSION = "v0.51.0";
13
+ export const MIN_COMPATIBLE_RTK_VERSION = "0.50.0";
14
+ export const MAX_COMPATIBLE_RTK_VERSION = "0.52.0";
15
+
16
+ export const RTK_CHECKSUMS = {
17
+ "rtk-x86_64-pc-windows-msvc.zip": "1623e9b45d28b15122d69e7314776e1123a804224885ce07e7182fb40080f05c",
18
+ "rtk-aarch64-apple-darwin.tar.gz": "8817d8b71afc02ac8bf06eb24bcc41c306592ab735b68e8fee9db1ba0de7cb59",
19
+ "rtk-x86_64-apple-darwin.tar.gz": "bd39c8153f4147358360c7dc51665a8131cc9ee16f81f69a9402ffa500be3cc2",
20
+ "rtk-x86_64-unknown-linux-musl.tar.gz": "5028d3b19a8f0990d30fec9fbb07e32782bc5698e618fb1861aad8a9ccba4eb5",
21
+ "rtk-aarch64-unknown-linux-gnu.tar.gz": "8d6d1aad9e69b42481eda7039507d1f7ee93698f87713cecd873d287c1931632"
22
+ };
23
+
24
+ export function isCompatibleRtkVersion(versionStr) {
25
+ if (!versionStr || typeof versionStr !== "string") return false;
26
+ const match = versionStr.match(/(\d+)\.(\d+)\.(\d+)/);
27
+ if (!match) return false;
28
+ const major = parseInt(match[1], 10);
29
+ const minor = parseInt(match[2], 10);
30
+ // Compatible if 0.50.x or 0.51.x (>= 0.50.0 and < 0.52.0)
31
+ return major === 0 && (minor === 50 || minor === 51);
32
+ }
12
33
 
13
34
  export function getRtkPlatformAsset() {
14
35
  const platform = os.platform();
@@ -32,21 +53,36 @@ export function getRtkPlatformAsset() {
32
53
  return null;
33
54
  }
34
55
 
35
- export function findExistingRtk() {
36
- // Check local binDir first
37
- const info = getRtkPlatformAsset();
38
- const localExe = info ? path.join(binDir, info.exeName) : null;
39
- if (localExe && fs.existsSync(localExe)) {
40
- try {
41
- const out = execSync(`"${localExe}" --version`, { encoding: "utf-8", timeout: 5000 }).trim();
42
- return { path: localExe, version: out, isLocal: true };
43
- } catch {}
56
+ export function findExistingRtk(options = {}) {
57
+ const names = os.platform() === "win32" ? ["rtk.exe", "rtk.cmd"] : ["rtk"];
58
+ const profileHome = process.env.AGY_TEST_PROFILE_DIR || os.homedir();
59
+ const pluginBinDir = path.join(profileHome, ".gemini", "config", "plugins", "agy-context-saver", "bin");
60
+ const candidateDirs = [binDir, pluginBinDir];
61
+
62
+ for (const dir of candidateDirs) {
63
+ for (const exeName of names) {
64
+ const localExe = path.join(dir, exeName);
65
+ if (fs.existsSync(localExe)) {
66
+ try {
67
+ const out = execSync(`"${localExe}" --version`, { encoding: "utf-8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] }).trim();
68
+ if (isCompatibleRtkVersion(out)) {
69
+ return { path: localExe, version: out, isLocal: true };
70
+ }
71
+ } catch {}
72
+ }
73
+ }
44
74
  }
45
75
 
46
76
  // Check PATH
47
77
  try {
48
- const out = execSync("rtk --version", { encoding: "utf-8", timeout: 5000 }).trim();
49
- return { path: "rtk", version: out, isLocal: false };
78
+ const out = execSync("rtk --version", { encoding: "utf-8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] }).trim();
79
+ if (isCompatibleRtkVersion(out)) {
80
+ return { path: "rtk", version: out, isLocal: false };
81
+ } else {
82
+ if (!options.silent) {
83
+ console.warn(`[WARN] System RTK version '${out}' is outside supported range [>=${MIN_COMPATIBLE_RTK_VERSION} <${MAX_COMPATIBLE_RTK_VERSION}]. Local pinned ${RTK_VERSION} will be required.`);
84
+ }
85
+ }
50
86
  } catch {}
51
87
 
52
88
  return null;
@@ -54,9 +90,9 @@ export function findExistingRtk() {
54
90
 
55
91
  export async function ensureRtkInstalled(options = {}) {
56
92
  const log = options.silent ? () => {} : console.log;
57
- const existing = options.existingRtk || findExistingRtk();
93
+ const existing = options.existingRtk || findExistingRtk(options);
58
94
  if (existing) {
59
- log(`āœ“ RTK is already installed: ${existing.version} (${existing.path})`);
95
+ log(`āœ“ RTK is already installed and verified: ${existing.version} (${existing.path})`);
60
96
  return existing;
61
97
  }
62
98
 
@@ -79,6 +115,23 @@ export async function ensureRtkInstalled(options = {}) {
79
115
  }
80
116
 
81
117
  const buffer = Buffer.from(await response.arrayBuffer());
118
+
119
+ // Fail-closed SHA-256 verification before writing archive to disk or extracting
120
+ const expectedHash = RTK_CHECKSUMS[assetInfo.asset];
121
+ if (!expectedHash) {
122
+ throw new Error(`Integrity error: No pinned SHA-256 checksum found for asset '${assetInfo.asset}'`);
123
+ }
124
+ const actualHash = crypto.createHash("sha256").update(buffer).digest("hex");
125
+ if (actualHash !== expectedHash) {
126
+ throw new Error(
127
+ `SHA-256 checksum verification failed for ${assetInfo.asset}!\n` +
128
+ ` Expected: ${expectedHash}\n` +
129
+ ` Actual: ${actualHash}\n` +
130
+ `Archive extraction aborted to protect system integrity.`
131
+ );
132
+ }
133
+ log(`āœ“ Verified SHA-256 checksum: ${actualHash}`);
134
+
82
135
  const archivePath = path.join(binDir, assetInfo.asset);
83
136
  fs.writeFileSync(archivePath, buffer);
84
137
 
@@ -102,7 +155,7 @@ export async function ensureRtkInstalled(options = {}) {
102
155
  try { fs.chmodSync(targetExe, 0o755); } catch {}
103
156
  }
104
157
 
105
- const verified = findExistingRtk();
158
+ const verified = findExistingRtk(options);
106
159
  if (!verified) {
107
160
  throw new Error(`RTK installation verification failed: could not run ${targetExe}`);
108
161
  }
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Non-destructive release metadata/package preflight for Agy-Context-Saver.
4
+ * Run from the repository root: node scripts/release-preflight.mjs
5
+ * This script reads files only; it does not install the plugin or provision RTK.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
12
+ const errors = [];
13
+ const warnings = [];
14
+
15
+ function readText(relativePath) {
16
+ const target = path.join(root, relativePath);
17
+ try {
18
+ return fs.readFileSync(target, 'utf8');
19
+ } catch {
20
+ errors.push(`Required file is missing or unreadable: ${relativePath}`);
21
+ return '';
22
+ }
23
+ }
24
+
25
+ function exists(relativePath) {
26
+ return fs.existsSync(path.join(root, relativePath));
27
+ }
28
+
29
+ let pkg;
30
+ try {
31
+ pkg = JSON.parse(readText('package.json'));
32
+ } catch (error) {
33
+ errors.push(`package.json is not valid JSON: ${error.message}`);
34
+ }
35
+
36
+ if (pkg) {
37
+ if (pkg.name !== 'agy-context-saver') {
38
+ errors.push(`Expected package name "agy-context-saver"; found ${JSON.stringify(pkg.name)}.`);
39
+ }
40
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(pkg.version ?? '')) {
41
+ errors.push(`package.json version is not a valid semver release version: ${JSON.stringify(pkg.version)}.`);
42
+ }
43
+
44
+ const requiredMetadata = [
45
+ ['repository.url', pkg.repository?.url],
46
+ ['homepage', pkg.homepage],
47
+ ['bugs.url', pkg.bugs?.url],
48
+ ['engines.node', pkg.engines?.node],
49
+ ];
50
+ for (const [label, value] of requiredMetadata) {
51
+ if (typeof value !== 'string' || value.trim() === '') {
52
+ errors.push(`Add package.json metadata field "${label}" before publishing.`);
53
+ }
54
+ }
55
+ if (pkg.engines?.node && !/^>=\s*22(?:\.0\.0)?$/.test(pkg.engines.node.trim())) {
56
+ warnings.push(`Current release policy expects engines.node to be ">=22"; found ${JSON.stringify(pkg.engines.node)}. Update this check if the support policy changes.`);
57
+ }
58
+
59
+ const requiredPackageFiles = [
60
+ 'mcp/index.js',
61
+ 'scripts/install-register.mjs',
62
+ 'scripts/provision-rtk.mjs',
63
+ 'plugin.json',
64
+ 'hooks.json',
65
+ 'mcp_config.json',
66
+ 'README.md',
67
+ 'CHANGELOG.md',
68
+ 'LICENSE',
69
+ ];
70
+
71
+ for (const relativePath of requiredPackageFiles) {
72
+ if (!exists(relativePath)) errors.push(`Required runtime/release file is missing: ${relativePath}`);
73
+ const coveredByFiles = (pkg.files ?? []).some((entry) => {
74
+ const normalized = entry.replace(/\\/g, '/').replace(/\/$/, '');
75
+ return relativePath === normalized || relativePath.startsWith(`${normalized}/`);
76
+ });
77
+ if (!coveredByFiles) {
78
+ errors.push(`package.json "files" does not clearly include ${relativePath}.`);
79
+ }
80
+ }
81
+
82
+ const binEntries = Object.values(pkg.bin ?? {});
83
+ if (binEntries.length === 0) errors.push('package.json has no bin entry.');
84
+ for (const binPath of binEntries) {
85
+ if (!exists(binPath)) errors.push(`package.json bin target does not exist: ${binPath}`);
86
+ const coveredByFiles = (pkg.files ?? []).some((entry) => {
87
+ const normalized = entry.replace(/\\/g, '/').replace(/\/$/, '');
88
+ return binPath === normalized || binPath.startsWith(`${normalized}/`);
89
+ });
90
+ if (!coveredByFiles) errors.push(`package.json "files" does not include the bin target: ${binPath}`);
91
+ }
92
+
93
+ const testCommand = pkg.scripts?.test;
94
+ if (typeof testCommand !== 'string' || !testCommand.trim()) {
95
+ errors.push('package.json is missing the npm test script.');
96
+ } else {
97
+ const suitePaths = [...testCommand.matchAll(/(?:^|&&\s*)node\s+(tests\/[\w.-]+\.mjs)/g)].map((match) => match[1]);
98
+ if (suitePaths.length === 0) {
99
+ errors.push('Could not find test entrypoints in package.json scripts.test; update release-preflight.mjs for the new test runner format.');
100
+ }
101
+ for (const suitePath of suitePaths) {
102
+ if (!exists(suitePath)) errors.push(`npm test references a missing test file: ${suitePath}`);
103
+ }
104
+
105
+ const readme = readText('README.md');
106
+ const countMatch = readme.match(/Runs all\s+(\d+)\s+test suites?/i);
107
+ if (!countMatch) {
108
+ errors.push('README.md must state the exact suite count using a sentence such as "Runs all 8 test suites".');
109
+ } else if (Number(countMatch[1]) !== suitePaths.length) {
110
+ errors.push(`README.md says ${countMatch[1]} test suites, but package.json scripts.test runs ${suitePaths.length}.`);
111
+ }
112
+ for (const suitePath of suitePaths) {
113
+ if (!readme.includes(suitePath)) {
114
+ errors.push(`README.md does not list the test entrypoint that npm test runs: ${suitePath}`);
115
+ }
116
+ }
117
+ }
118
+
119
+ for (const relativePath of ['SECURITY.md', 'CONTRIBUTING.md', 'CODE_OF_CONDUCT.md']) {
120
+ if (!exists(relativePath)) errors.push(`Add ${relativePath} before the public release.`);
121
+ }
122
+
123
+ const rtkProvisioner = readText('scripts/provision-rtk.mjs');
124
+ const rtkVersionMatch = rtkProvisioner.match(/RTK_VERSION\s*=\s*['"]([^'"]+)['"]/);
125
+ if (!rtkVersionMatch) {
126
+ errors.push('Could not locate a pinned RTK_VERSION in scripts/provision-rtk.mjs.');
127
+ } else {
128
+ console.log(`RTK provisioner pin found: ${rtkVersionMatch[1]}`);
129
+ }
130
+
131
+ if (!pkg.scripts?.setup || !pkg.scripts?.status || !pkg.scripts?.test) {
132
+ errors.push('Expected npm scripts setup, status, and test to be present.');
133
+ }
134
+ if (Object.keys(pkg.dependencies ?? {}).length > 0) {
135
+ warnings.push(`Runtime npm dependencies are present (${Object.keys(pkg.dependencies).join(', ')}); review whether each is required and trusted.`);
136
+ }
137
+ }
138
+
139
+ for (const warning of warnings) console.warn(`WARN: ${warning}`);
140
+ for (const error of errors) console.error(`FAIL: ${error}`);
141
+
142
+ if (errors.length) {
143
+ console.error(`\nRelease preflight failed with ${errors.length} error(s) and ${warnings.length} warning(s).`);
144
+ process.exitCode = 1;
145
+ } else {
146
+ console.log(`\nRelease preflight passed${warnings.length ? ` with ${warnings.length} warning(s)` : ''}.`);
147
+ }