@nodesify/astria 1.0.6 → 1.0.8

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.
Files changed (63) hide show
  1. package/dist/commands/add.d.ts +1 -0
  2. package/dist/commands/add.d.ts.map +1 -1
  3. package/dist/commands/add.js +16 -2
  4. package/dist/commands/add.js.map +1 -1
  5. package/dist/commands/affected.d.ts.map +1 -1
  6. package/dist/commands/affected.js +12 -1
  7. package/dist/commands/affected.js.map +1 -1
  8. package/dist/commands/explain.d.ts.map +1 -1
  9. package/dist/commands/explain.js +4 -1
  10. package/dist/commands/explain.js.map +1 -1
  11. package/dist/commands/install.d.ts.map +1 -1
  12. package/dist/commands/install.js +26 -1
  13. package/dist/commands/install.js.map +1 -1
  14. package/dist/commands/llm-summary.d.ts +1 -0
  15. package/dist/commands/llm-summary.d.ts.map +1 -1
  16. package/dist/commands/llm-summary.js +3 -2
  17. package/dist/commands/llm-summary.js.map +1 -1
  18. package/dist/commands/neighbors.d.ts.map +1 -1
  19. package/dist/commands/neighbors.js +3 -1
  20. package/dist/commands/neighbors.js.map +1 -1
  21. package/dist/commands/run.d.ts +1 -0
  22. package/dist/commands/run.d.ts.map +1 -1
  23. package/dist/commands/run.js +7 -0
  24. package/dist/commands/run.js.map +1 -1
  25. package/dist/commands/update.d.ts +8 -2
  26. package/dist/commands/update.d.ts.map +1 -1
  27. package/dist/commands/update.js +44 -7
  28. package/dist/commands/update.js.map +1 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +10 -5
  31. package/dist/index.js.map +1 -1
  32. package/dist/install/hooks.d.ts.map +1 -1
  33. package/dist/install/hooks.js +22 -10
  34. package/dist/install/hooks.js.map +1 -1
  35. package/dist/install/index.d.ts.map +1 -1
  36. package/dist/install/index.js +41 -1
  37. package/dist/install/index.js.map +1 -1
  38. package/dist/install/markdown-inject.d.ts +1 -1
  39. package/dist/install/markdown-inject.d.ts.map +1 -1
  40. package/dist/install/markdown-inject.js +5 -2
  41. package/dist/install/markdown-inject.js.map +1 -1
  42. package/dist/install/platforms.d.ts +5 -3
  43. package/dist/install/platforms.d.ts.map +1 -1
  44. package/dist/install/platforms.js +69 -0
  45. package/dist/install/platforms.js.map +1 -1
  46. package/dist/install/settings-inject.d.ts +6 -1
  47. package/dist/install/settings-inject.d.ts.map +1 -1
  48. package/dist/install/settings-inject.js +515 -32
  49. package/dist/install/settings-inject.js.map +1 -1
  50. package/dist/native.d.ts +1 -0
  51. package/dist/native.d.ts.map +1 -1
  52. package/dist/native.js +20 -3
  53. package/dist/native.js.map +1 -1
  54. package/package.json +8 -8
  55. package/skills/skill-aider.md +2 -2
  56. package/skills/skill-codex.md +4 -2
  57. package/skills/skill-copilot.md +4 -2
  58. package/skills/skill-cursor.md +4 -2
  59. package/skills/skill-gemini.md +4 -2
  60. package/skills/skill-kiro.md +4 -2
  61. package/skills/skill-opencode.md +4 -2
  62. package/skills/skill-trae.md +4 -2
  63. package/skills/skill.md +36 -4
@@ -43,14 +43,20 @@ exports.injectOpenCodePlugin = injectOpenCodePlugin;
43
43
  exports.removeOpenCodePlugin = removeOpenCodePlugin;
44
44
  exports.injectCursorRule = injectCursorRule;
45
45
  exports.removeCursorRule = removeCursorRule;
46
+ exports.cleanupLegacyCopilotMcp = cleanupLegacyCopilotMcp;
47
+ exports.injectCodexMcp = injectCodexMcp;
48
+ exports.removeCodexMcp = removeCodexMcp;
46
49
  exports.injectAgentMcp = injectAgentMcp;
47
50
  exports.removeAgentMcp = removeAgentMcp;
48
51
  exports.injectZcodeMcp = injectZcodeMcp;
49
52
  exports.removeZcodeMcp = removeZcodeMcp;
53
+ exports.injectPiExtension = injectPiExtension;
54
+ exports.removePiExtension = removePiExtension;
50
55
  exports.injectKiroSteering = injectKiroSteering;
51
56
  exports.removeKiroSteering = removeKiroSteering;
52
57
  const fs = __importStar(require("fs"));
53
58
  const path = __importStar(require("path"));
59
+ const os = __importStar(require("os"));
54
60
  function readJson(filePath) {
55
61
  if (!fs.existsSync(filePath))
56
62
  return {};
@@ -234,45 +240,64 @@ export const AstriaPlugin = async ({ directory }) => {
234
240
  };
235
241
  `;
236
242
  function injectOpenCodePlugin(projectDir) {
243
+ // OpenCode auto-discovers plugins from `.opencode/plugins/` — the current
244
+ // documented convention (opencode.ai/docs/plugins). Verified against
245
+ // opencode 1.17.8 locally: `opencode debug config` lists plugin files from
246
+ // both the plural and the singular directory, so writing `plugins/` works
247
+ // there too. opencode 1.17+ REJECTS a `plugins` key in opencode.json
248
+ // ("Unrecognized key"), so the plugin is a file drop with no config
249
+ // registration. The 1.0.9-era singular `plugin/` directory and the config
250
+ // key are cleaned up on inject.
237
251
  const pluginDir = path.join(projectDir, '.opencode', 'plugins');
238
252
  const pluginPath = path.join(pluginDir, 'astria.js');
239
- // Pre-1.0 installs registered plugins/graphify.js — drop it on upgrade.
240
- const legacyPath = path.join(pluginDir, 'graphify.js');
241
- if (fs.existsSync(legacyPath))
242
- fs.unlinkSync(legacyPath);
243
- if (!fs.existsSync(pluginDir)) {
244
- fs.mkdirSync(pluginDir, { recursive: true });
253
+ const legacyDir = path.join(projectDir, '.opencode', 'plugin');
254
+ const legacyPaths = [
255
+ // 1.0.9-era singular directory, both names.
256
+ path.join(legacyDir, 'graphify.js'),
257
+ path.join(legacyDir, 'astria.js'),
258
+ // pre-1.0.9 wrote the old graphify name into the plural directory.
259
+ path.join(pluginDir, 'graphify.js'),
260
+ ];
261
+ let changed = false;
262
+ for (const legacy of legacyPaths) {
263
+ if (fs.existsSync(legacy)) {
264
+ fs.unlinkSync(legacy);
265
+ changed = true;
266
+ }
245
267
  }
246
- fs.writeFileSync(pluginPath, OPENCODE_PLUGIN_JS, 'utf-8');
268
+ // Strip the now-invalid `plugins` key this installer used to write.
247
269
  const configPath = path.join(projectDir, '.opencode', 'opencode.json');
248
270
  const config = readJson(configPath);
249
- if (!config.plugins)
250
- config.plugins = [];
251
- if (!config.plugins.includes('./plugins/astria.js')) {
252
- config.plugins.push('./plugins/astria.js');
271
+ if (Array.isArray(config.plugins) && config.plugins.length > 0) {
272
+ delete config.plugins;
273
+ writeJson(configPath, config);
274
+ changed = true;
275
+ }
276
+ if (fs.existsSync(pluginPath))
277
+ return changed;
278
+ if (!fs.existsSync(pluginDir)) {
279
+ fs.mkdirSync(pluginDir, { recursive: true });
253
280
  }
254
- config.plugins = config.plugins.filter((p) => p !== './plugins/graphify.js');
255
- writeJson(configPath, config);
281
+ fs.writeFileSync(pluginPath, OPENCODE_PLUGIN_JS, 'utf-8');
256
282
  return true;
257
283
  }
258
284
  function removeOpenCodePlugin(projectDir) {
259
285
  let changed = false;
260
- for (const name of ['astria.js', 'graphify.js']) {
261
- const pluginPath = path.join(projectDir, '.opencode', 'plugins', name);
262
- if (fs.existsSync(pluginPath)) {
263
- fs.unlinkSync(pluginPath);
264
- changed = true;
286
+ for (const dirName of ['plugin', 'plugins']) {
287
+ for (const name of ['astria.js', 'graphify.js']) {
288
+ const pluginPath = path.join(projectDir, '.opencode', dirName, name);
289
+ if (fs.existsSync(pluginPath)) {
290
+ fs.unlinkSync(pluginPath);
291
+ changed = true;
292
+ }
265
293
  }
266
294
  }
267
295
  const configPath = path.join(projectDir, '.opencode', 'opencode.json');
268
296
  const config = readJson(configPath);
269
- if (config.plugins) {
270
- const filtered = config.plugins.filter((p) => p !== './plugins/astria.js' && p !== './plugins/graphify.js');
271
- if (filtered.length !== config.plugins.length) {
272
- config.plugins = filtered;
273
- writeJson(configPath, config);
274
- changed = true;
275
- }
297
+ if (Array.isArray(config.plugins)) {
298
+ delete config.plugins;
299
+ writeJson(configPath, config);
300
+ changed = true;
276
301
  }
277
302
  return changed;
278
303
  }
@@ -314,18 +339,209 @@ function removeCursorRule(projectDir) {
314
339
  return changed;
315
340
  }
316
341
  const ASTRIA_MCP_SERVER = { type: 'stdio', command: 'astria', args: ['mcp'] };
317
- // Server registration key per flavor. The legacy "graphify" key is matched
318
- // for upgrade (inject) and cleanup (remove).
342
+ // Server registration key per flavor, plus an optional vendor-specific
343
+ // server object (OpenCode's schema differs: `type: "local"`, command array,
344
+ // explicit `enabled` — verified against opencode 1.17.8's config validator).
345
+ // The legacy "graphify" key is matched for upgrade (inject) and cleanup
346
+ // (remove). 'codex' has no JSON target — injectAgentMcp dispatches it to
347
+ // the TOML writer.
319
348
  const MCP_TARGETS = {
320
349
  zcode: { configPath: path.join('.zcode', 'config.json'), serverPath: ['mcp', 'servers', 'astria'] },
321
350
  claude: { configPath: '.mcp.json', serverPath: ['mcpServers', 'astria'] },
322
351
  cursor: { configPath: path.join('.cursor', 'mcp.json'), serverPath: ['mcpServers', 'astria'] },
323
352
  gemini: { configPath: path.join('.gemini', 'settings.json'), serverPath: ['mcpServers', 'astria'] },
353
+ // VS Code native workspace MCP (also what Copilot inside VS Code uses).
354
+ // Shape matches VS Code 1.137's own `--add-mcp` writer exactly: a
355
+ // `servers` map with bare command/args (no type field). The 1.0.8-era
356
+ // `mcpServers` key this installer wrote is migrated away on inject.
357
+ vscode: {
358
+ configPath: path.join('.vscode', 'mcp.json'),
359
+ serverPath: ['servers', 'astria'],
360
+ server: { command: 'astria', args: ['mcp'] },
361
+ legacyServerPath: ['mcpServers', 'astria'],
362
+ },
363
+ trae: { configPath: path.join('.trae', 'mcp.json'), serverPath: ['mcpServers', 'astria'] },
364
+ windsurf: { configPath: path.join('.windsurf', 'mcp.json'), serverPath: ['mcpServers', 'astria'] },
365
+ // Kiro workspace MCP servers live in .kiro/settings/mcp.json (kiro.dev docs:
366
+ // workspace scope, `mcpServers` key — verified 2026-09-30). The 1.0.9-era
367
+ // bare mcp.json at the project root this installer wrote is a path nothing
368
+ // reads; it is cleaned up on inject and remove.
369
+ kiro: {
370
+ configPath: path.join('.kiro', 'settings', 'mcp.json'),
371
+ serverPath: ['mcpServers', 'astria'],
372
+ legacyConfigPaths: ['mcp.json'],
373
+ },
374
+ // OpenCode keys servers directly under `mcp` (no `servers` intermediate)
375
+ // and requires its own server shape. The 1.0.8-era nested path is
376
+ // migrated away on inject.
377
+ opencode: {
378
+ configPath: path.join('.opencode', 'opencode.json'),
379
+ serverPath: ['mcp', 'astria'],
380
+ server: { type: 'local', command: ['astria', 'mcp'], enabled: true },
381
+ legacyServerPath: ['mcp', 'servers', 'astria'],
382
+ },
383
+ // Pi reads the standard .mcp.json via the pi-mcp-adapter extension —
384
+ // same file as claude; its own flavor so install output stays honest.
385
+ pi: { configPath: '.mcp.json', serverPath: ['mcpServers', 'astria'] },
324
386
  };
325
387
  const LEGACY_MCP_SERVER_NAME = 'graphify';
388
+ // ---- Copilot (.github/copilot-mcp.json) ----
389
+ // The Copilot coding agent has no committed repo file for MCP servers:
390
+ // repository-level configuration is entered as JSON in the repository
391
+ // Settings UI (docs.github.com, "Configure MCP servers for your repository").
392
+ // The 1.0.9-era .github/copilot-mcp.json this installer wrote is read by
393
+ // nothing; it is removed on install and uninstall when it only carries the
394
+ // astria entry this installer produced — a file with other content stays.
395
+ function cleanupLegacyCopilotMcp(projectDir) {
396
+ const legacyPath = path.join(projectDir, '.github', 'copilot-mcp.json');
397
+ if (!fs.existsSync(legacyPath))
398
+ return false;
399
+ const data = readJson(legacyPath);
400
+ const ours = (entry) => entry && (entry.command === 'astria' || entry.command === 'nodesify-graphify');
401
+ let found = false;
402
+ for (const container of ['servers', 'mcpServers']) {
403
+ const node = data[container];
404
+ if (typeof node !== 'object' || node === null)
405
+ continue;
406
+ const names = Object.keys(node).filter((n) => n === 'astria' || n === LEGACY_MCP_SERVER_NAME);
407
+ if (!names.every((n) => ours(node[n])))
408
+ continue;
409
+ for (const n of names)
410
+ delete node[n];
411
+ if (Object.keys(node).length === 0)
412
+ delete data[container];
413
+ found = found || names.length > 0;
414
+ }
415
+ if (!found)
416
+ return false;
417
+ if (Object.keys(data).length === 0) {
418
+ fs.unlinkSync(legacyPath);
419
+ }
420
+ else {
421
+ writeJson(legacyPath, data);
422
+ }
423
+ return true;
424
+ }
425
+ /// Removes astria entries this installer wrote in legacy config *files*
426
+ /// (whole files at since-corrected paths, e.g. Kiro's 1.0.9 root mcp.json).
427
+ /// Only installer-produced entries (command astria / nodesify-graphify) are
428
+ /// deleted; empty containers are pruned and a file left as `{}` is removed.
429
+ /// `keepPath` skips the flavor's current target file. Returns whether
430
+ /// anything changed.
431
+ function cleanupLegacyConfigPaths(projectDir, target, keepPath) {
432
+ if (!target.legacyConfigPaths)
433
+ return false;
434
+ let changed = false;
435
+ for (const legacyRel of target.legacyConfigPaths) {
436
+ const legacyPath = path.join(projectDir, legacyRel);
437
+ if (legacyPath === keepPath || !fs.existsSync(legacyPath))
438
+ continue;
439
+ const data = readJson(legacyPath);
440
+ let node = data;
441
+ const parents = [];
442
+ let reachable = true;
443
+ for (const key of target.serverPath.slice(0, -1)) {
444
+ if (typeof node[key] !== 'object' || node[key] === null) {
445
+ reachable = false;
446
+ break;
447
+ }
448
+ parents.push([node, key]);
449
+ node = node[key];
450
+ }
451
+ if (!reachable)
452
+ continue;
453
+ const name = target.serverPath[target.serverPath.length - 1];
454
+ const ours = (entry) => entry && (entry.command === 'astria'
455
+ || (Array.isArray(entry.command) && entry.command[0] === 'astria')
456
+ || entry.command === 'nodesify-graphify');
457
+ if (!ours(node[name]) && !ours(node[LEGACY_MCP_SERVER_NAME]))
458
+ continue;
459
+ delete node[name];
460
+ delete node[LEGACY_MCP_SERVER_NAME];
461
+ for (let i = parents.length - 1; i >= 0; i--) {
462
+ const [parent, key] = parents[i];
463
+ if (Object.keys(parent[key]).length === 0)
464
+ delete parent[key];
465
+ }
466
+ if (Object.keys(data).length === 0) {
467
+ fs.unlinkSync(legacyPath);
468
+ }
469
+ else {
470
+ writeJson(legacyPath, data);
471
+ }
472
+ changed = true;
473
+ }
474
+ return changed;
475
+ }
476
+ // ---- Codex MCP (~/.codex/config.toml, user-global TOML) ----
477
+ // The managed table is deterministic so removal can verify it is ours and
478
+ // never touch a hand-written [mcp_servers.astria] section.
479
+ const CODEX_MCP_BLOCK = '[mcp_servers.astria]\ncommand = "astria"\nargs = ["mcp"]';
480
+ function codexConfigPath() {
481
+ return path.join(os.homedir(), '.codex', 'config.toml');
482
+ }
483
+ function injectCodexMcp() {
484
+ const configPath = codexConfigPath();
485
+ let text = '';
486
+ try {
487
+ text = fs.readFileSync(configPath, 'utf8');
488
+ }
489
+ catch {
490
+ // absent — created below
491
+ }
492
+ const hasSection = text
493
+ .split('\n')
494
+ .some((line) => line.trim() === '[mcp_servers.astria]');
495
+ if (hasSection) {
496
+ // Ours (the exact managed block) or the user's own definition — either
497
+ // way it is present and must not be duplicated or clobbered.
498
+ return false;
499
+ }
500
+ const next = (text.length > 0 && !text.endsWith('\n') ? text + '\n' : text) + CODEX_MCP_BLOCK + '\n';
501
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
502
+ fs.writeFileSync(configPath, next, 'utf8');
503
+ return true;
504
+ }
505
+ function removeCodexMcp() {
506
+ const configPath = codexConfigPath();
507
+ let text;
508
+ try {
509
+ text = fs.readFileSync(configPath, 'utf8');
510
+ }
511
+ catch {
512
+ return false;
513
+ }
514
+ const lines = text.split('\n');
515
+ const headerIdx = lines.findIndex((line) => line.trim() === '[mcp_servers.astria]');
516
+ if (headerIdx === -1)
517
+ return false;
518
+ // The section body runs to the next TOML table header (or EOF).
519
+ let end = headerIdx + 1;
520
+ while (end < lines.length && !lines[end].trimStart().startsWith('[')) {
521
+ end++;
522
+ }
523
+ const body = lines.slice(headerIdx + 1, end).join('\n').trim();
524
+ const managed = CODEX_MCP_BLOCK.split('\n').slice(1).join('\n').trim();
525
+ if (body !== managed) {
526
+ // User-customized — leave it alone, same rule as the JSON legacy entry.
527
+ return false;
528
+ }
529
+ lines.splice(headerIdx, end - headerIdx);
530
+ let next = lines.join('\n');
531
+ // Drop the blank line the injection left before the block, if any.
532
+ next = next.replace(/\n\n+$/, '\n');
533
+ fs.writeFileSync(configPath, next, 'utf8');
534
+ return true;
535
+ }
326
536
  function injectAgentMcp(projectDir, flavor) {
537
+ if (flavor === 'codex') {
538
+ return injectCodexMcp();
539
+ }
327
540
  const target = MCP_TARGETS[flavor];
328
541
  const configPath = path.join(projectDir, target.configPath);
542
+ // Config files a previous release wrote at since-corrected paths are
543
+ // cleaned up whether or not the current target ends up being written.
544
+ const removedLegacyFile = cleanupLegacyConfigPaths(projectDir, target, configPath);
329
545
  const data = readJson(configPath);
330
546
  let node = data;
331
547
  for (const key of target.serverPath.slice(0, -1)) {
@@ -342,22 +558,70 @@ function injectAgentMcp(projectDir, flavor) {
342
558
  delete node[LEGACY_MCP_SERVER_NAME];
343
559
  removedLegacy = true;
344
560
  }
561
+ // A server this installer wrote at a since-moved path is migrated away,
562
+ // not duplicated — opencode rejects the old nested location outright.
563
+ // `node` guard: pruning the old parent chain never orphans the new entry.
564
+ if (migrateLegacyServerPath(data, target, node))
565
+ removedLegacy = true;
345
566
  if (node[name]) {
346
567
  if (removedLegacy) {
347
568
  writeJson(configPath, data);
348
569
  return true;
349
570
  }
350
- return false;
571
+ return removedLegacyFile;
351
572
  }
352
- node[name] = { ...ASTRIA_MCP_SERVER };
573
+ node[name] = { ...(target.server ?? ASTRIA_MCP_SERVER) };
574
+ migrateLegacyServerPath(data, target, node);
353
575
  writeJson(configPath, data);
354
576
  return true;
355
577
  }
578
+ /// Removes a server entry this installer wrote at a since-moved path
579
+ /// (`legacyServerPath`), pruning its now-empty parents. Only installer-
580
+ /// produced entries (command astria / nodesify-graphify) are touched;
581
+ /// returns whether anything changed.
582
+ function migrateLegacyServerPath(data, target, keepNode) {
583
+ if (!target.legacyServerPath)
584
+ return false;
585
+ let legacyNode = data;
586
+ const legacyParents = [];
587
+ for (const key of target.legacyServerPath.slice(0, -1)) {
588
+ if (typeof legacyNode[key] !== 'object' || legacyNode[key] === null) {
589
+ legacyNode = null;
590
+ break;
591
+ }
592
+ legacyParents.push([legacyNode, key]);
593
+ legacyNode = legacyNode[key];
594
+ }
595
+ if (!legacyNode)
596
+ return false;
597
+ const legacyName = target.legacyServerPath[target.legacyServerPath.length - 1];
598
+ const legacyEntry = legacyNode[legacyName] ?? legacyNode[LEGACY_MCP_SERVER_NAME];
599
+ const isOurs = legacyEntry && (legacyEntry.command === 'astria'
600
+ || (Array.isArray(legacyEntry.command) && legacyEntry.command[0] === 'astria')
601
+ || legacyEntry.command === 'nodesify-graphify');
602
+ if (!isOurs)
603
+ return false;
604
+ delete legacyNode[legacyName];
605
+ delete legacyNode[LEGACY_MCP_SERVER_NAME];
606
+ for (let i = legacyParents.length - 1; i >= 0; i--) {
607
+ const [parent, key] = legacyParents[i];
608
+ if (parent[key] !== keepNode && Object.keys(parent[key]).length === 0) {
609
+ delete parent[key];
610
+ }
611
+ }
612
+ return true;
613
+ }
356
614
  function removeAgentMcp(projectDir, flavor) {
615
+ if (flavor === 'codex') {
616
+ return removeCodexMcp();
617
+ }
357
618
  const target = MCP_TARGETS[flavor];
358
619
  const configPath = path.join(projectDir, target.configPath);
620
+ // Legacy config files are cleaned even when the current target is absent
621
+ // (an install made before a path correction, then a straight uninstall).
622
+ const removedLegacyFile = cleanupLegacyConfigPaths(projectDir, target, configPath);
359
623
  if (!fs.existsSync(configPath))
360
- return false;
624
+ return removedLegacyFile;
361
625
  const data = readJson(configPath);
362
626
  const parents = [];
363
627
  let node = data;
@@ -368,10 +632,36 @@ function removeAgentMcp(projectDir, flavor) {
368
632
  node = node[key];
369
633
  }
370
634
  const name = target.serverPath[target.serverPath.length - 1];
371
- if (!node[name] && !node[LEGACY_MCP_SERVER_NAME])
372
- return false;
635
+ // The legacy-name and since-moved-path entries go too when present.
636
+ let removed = Boolean(node[name]) || Boolean(node[LEGACY_MCP_SERVER_NAME]);
373
637
  delete node[name];
374
638
  delete node[LEGACY_MCP_SERVER_NAME];
639
+ if (target.legacyServerPath) {
640
+ let legacyNode = data;
641
+ const legacyParents = [];
642
+ for (const key of target.legacyServerPath.slice(0, -1)) {
643
+ if (typeof legacyNode[key] !== 'object' || legacyNode[key] === null) {
644
+ legacyNode = null;
645
+ break;
646
+ }
647
+ legacyParents.push([legacyNode, key]);
648
+ legacyNode = legacyNode[key];
649
+ }
650
+ if (legacyNode) {
651
+ const legacyName = target.legacyServerPath[target.legacyServerPath.length - 1];
652
+ if (legacyNode[legacyName] || legacyNode[LEGACY_MCP_SERVER_NAME])
653
+ removed = true;
654
+ delete legacyNode[legacyName];
655
+ delete legacyNode[LEGACY_MCP_SERVER_NAME];
656
+ for (let i = legacyParents.length - 1; i >= 0; i--) {
657
+ const [parent, key] = legacyParents[i];
658
+ if (Object.keys(parent[key]).length === 0)
659
+ delete parent[key];
660
+ }
661
+ }
662
+ }
663
+ if (!removed)
664
+ return removedLegacyFile;
375
665
  for (let i = parents.length - 1; i >= 0; i--) {
376
666
  const [parent, key] = parents[i];
377
667
  if (Object.keys(parent[key]).length === 0)
@@ -386,6 +676,199 @@ function injectZcodeMcp(projectDir) {
386
676
  function removeZcodeMcp(projectDir) {
387
677
  return removeAgentMcp(projectDir, 'zcode');
388
678
  }
679
+ // ---- Pi (~/.pi/agent/extensions/astria.mjs) ----
680
+ // Pi auto-discovers extensions from ~/.pi/agent/extensions/; entry shape is
681
+ // `export default function (pi)`. The API surface here (registerTool,
682
+ // tool_result, registerCommand) was read from pi 0.87's ExtensionAPI types.
683
+ // Native tools are the pi-idiomatic integration — pi's own philosophy is
684
+ // CLI tools over MCP definitions (a registered tool costs a few hundred
685
+ // context tokens; ten MCP tool definitions cost 10k+), so the graph is
686
+ // exposed as first-class pi tools backed by the astria CLI. The standard
687
+ // .mcp.json registration stays for users of the pi-mcp-adapter extension.
688
+ const PI_EXTENSION_JS = `// astria extension for the Pi coding agent.
689
+ // Native tools (pi-idiomatic): astria_query/map/explain/path/affected,
690
+ // backed by the astria CLI. Freshness: write/edit refresh the graph
691
+ // (throttled, detached). /astria: guidance.
692
+ import { spawn } from "node:child_process";
693
+ import { existsSync } from "node:fs";
694
+ import { join } from "node:path";
695
+
696
+ function astriaCli(cwd) {
697
+ return existsSync(join(cwd, "packages", "astria-cli", "dist", "index.js"))
698
+ ? "node packages/astria-cli/dist/index.js"
699
+ : "astria";
700
+ }
701
+
702
+ function run(args, cwd) {
703
+ if (!existsSync(join(cwd, ".astria"))) {
704
+ return Promise.resolve({
705
+ out: 'No astria graph in this project. Build one first: run "astria run ." (creates .astria/ with the knowledge graph).',
706
+ });
707
+ }
708
+ return new Promise((resolve) => {
709
+ const child = spawn(astriaCli(cwd) + " " + args, { cwd, shell: true });
710
+ let out = "";
711
+ let err = "";
712
+ const timer = setTimeout(() => child.kill(), 90000);
713
+ child.stdout.on("data", (d) => (out += d));
714
+ child.stderr.on("data", (d) => (err += d));
715
+ child.on("error", (e) => { clearTimeout(timer); resolve({ out: String(e) }); });
716
+ child.on("close", () => { clearTimeout(timer); resolve({ out: out || err }); });
717
+ });
718
+ }
719
+
720
+ function textResult(text, command) {
721
+ return { content: [{ type: "text", text: String(text) }], details: { command } };
722
+ }
723
+
724
+ function schema(props, required) {
725
+ return { type: "object", properties: props, required };
726
+ }
727
+
728
+ let lastUpdate = 0;
729
+ function refreshGraph(cwd, awaitSpawn) {
730
+ try {
731
+ const now = Date.now();
732
+ if (now - lastUpdate < 120000) return Promise.resolve(false);
733
+ lastUpdate = now;
734
+ const child = spawn(astriaCli(cwd) + " update .", { cwd, shell: true, detached: true, stdio: "ignore" });
735
+ child.unref();
736
+ if (!awaitSpawn) return Promise.resolve(true);
737
+ // Print-mode sessions exit the instant the tool returns — a detached
738
+ // child whose spawn syscall has not completed yet never materializes.
739
+ // Wait until the OS has actually created it (bounded) before returning.
740
+ return new Promise((resolve) => {
741
+ const done = () => resolve(true);
742
+ child.once("spawn", done);
743
+ setTimeout(done, 1000);
744
+ });
745
+ } catch {
746
+ return Promise.resolve(false);
747
+ }
748
+ }
749
+
750
+ const ASTRIA_TOOLS = [
751
+ {
752
+ name: "astria_query",
753
+ cli: "query",
754
+ args: (p) => "query " + JSON.stringify(p.question) + " --budget " + (p.budget || 3000) + " --graph .",
755
+ description: 'Query the repo knowledge graph with a natural-language question. Ranked nodes carry file:line anchors and edge provenance. Prefer over grep for architecture/cross-module questions. "No confident match" means the vocabulary is absent — rephrase toward symbol names.',
756
+ props: {
757
+ question: { type: "string", description: "Natural-language question about the codebase" },
758
+ budget: { type: "integer", description: "Max output tokens (default 3000)" },
759
+ },
760
+ required: ["question"],
761
+ },
762
+ {
763
+ name: "astria_map",
764
+ cli: "map",
765
+ args: () => "map --budget 2000 --graph .",
766
+ description: "PageRank-ranked repo map with top symbols per file — orient before diving in.",
767
+ props: {},
768
+ required: [],
769
+ },
770
+ {
771
+ name: "astria_explain",
772
+ cli: "explain",
773
+ args: (p) => "explain " + JSON.stringify(p.node) + " --graph .",
774
+ description: "Explain a symbol: metadata plus its strongest connections with real edge direction and evidence tier.",
775
+ props: { node: { type: "string", description: "Symbol or node name" } },
776
+ required: ["node"],
777
+ },
778
+ {
779
+ name: "astria_path",
780
+ cli: "path",
781
+ args: (p) => "path " + JSON.stringify(p.from) + " " + JSON.stringify(p.to) + " --graph .",
782
+ description: "Shortest connection path between two symbols or concepts, relations per hop.",
783
+ props: { from: { type: "string" }, to: { type: "string" } },
784
+ required: ["from", "to"],
785
+ },
786
+ {
787
+ name: "astria_affected",
788
+ cli: "affected",
789
+ args: (p) => "affected " + JSON.stringify(p.node) + " --depth " + (p.depth || 2) + " --graph .",
790
+ description: "Blast radius — run BEFORE changing a shared symbol. Hops show evidence tier (RESOLVED = source-extracted call uniquely bound; INFERRED = weaker).",
791
+ props: {
792
+ node: { type: "string", description: "Symbol to assess" },
793
+ depth: { type: "integer", description: "Max hops (default 2)" },
794
+ },
795
+ required: ["node"],
796
+ },
797
+ ];
798
+
799
+ export default function (pi) {
800
+ for (const t of ASTRIA_TOOLS) {
801
+ try {
802
+ pi.registerTool({
803
+ name: t.name,
804
+ label: t.name,
805
+ description: t.description,
806
+ parameters: schema(t.props, t.required),
807
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
808
+ const cwd = (ctx && ctx.cwd) || (typeof process !== "undefined" ? process.cwd() : ".");
809
+ const out = await run(t.args(params || {}), cwd);
810
+ // Mode-independent freshness: pi 0.87 print-mode sessions do not
811
+ // deliver tool_result events to extensions (measured), so every
812
+ // graph tool call also kicks the throttled refresh — awaited
813
+ // until the update process exists, because the session exits
814
+ // the moment this tool returns. The staleness disclosure in the
815
+ // output covers the gap until the next query.
816
+ await refreshGraph(cwd, true);
817
+ return textResult(out.out || "(no output)", "astria " + t.cli);
818
+ },
819
+ });
820
+ } catch { /* one failed registration must not block the rest */ }
821
+ }
822
+
823
+ pi.on("tool_result", (event, ctx) => {
824
+ const tool = event && (event.tool ?? event.toolName);
825
+ if (tool !== "write" && tool !== "edit") return;
826
+ refreshGraph((ctx && ctx.cwd) || (typeof process !== "undefined" ? process.cwd() : "."));
827
+ });
828
+
829
+ try {
830
+ pi.registerCommand("astria", {
831
+ description: "astria knowledge-graph guidance for this repo",
832
+ handler: async (args, ctx) => {
833
+ const lines = [
834
+ "astria knowledge graph:",
835
+ "- .astria/ holds a queryable graph of this repo (built by 'astria run').",
836
+ "- Native tools available: astria_query, astria_map, astria_explain,",
837
+ " astria_path, astria_affected — prefer them over grep for",
838
+ " architecture and cross-module questions.",
839
+ "- Output carries file:line anchors and edge provenance",
840
+ ' (EXTRACTED/RESOLVED/INFERRED); "No confident match" means rephrase',
841
+ " toward symbol names or file paths.",
842
+ "- After edits the graph refreshes automatically (throttled);",
843
+ " 'astria update .' refreshes on demand.",
844
+ ];
845
+ const msg = lines.join("\\n");
846
+ try { pi.sendToolResult({ title: "astria", data: msg }); }
847
+ catch { console.log(msg); }
848
+ },
849
+ });
850
+ } catch { /* command registration is optional polish */ }
851
+ }
852
+ `;
853
+ function injectPiExtension() {
854
+ const dir = path.join(os.homedir(), '.pi', 'agent', 'extensions');
855
+ const extPath = path.join(dir, 'astria.mjs');
856
+ if (fs.existsSync(extPath)) {
857
+ const current = fs.readFileSync(extPath, 'utf-8');
858
+ if (current === PI_EXTENSION_JS)
859
+ return false;
860
+ }
861
+ fs.mkdirSync(dir, { recursive: true });
862
+ fs.writeFileSync(extPath, PI_EXTENSION_JS, 'utf-8');
863
+ return true;
864
+ }
865
+ function removePiExtension() {
866
+ const extPath = path.join(os.homedir(), '.pi', 'agent', 'extensions', 'astria.mjs');
867
+ if (!fs.existsSync(extPath))
868
+ return false;
869
+ fs.unlinkSync(extPath);
870
+ return true;
871
+ }
389
872
  // ---- Kiro (.kiro/steering/astria.md) ----
390
873
  const KIRO_STEERING = `---
391
874
  inclusion: always