@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.
- package/dist/commands/add.d.ts +1 -0
- package/dist/commands/add.d.ts.map +1 -1
- package/dist/commands/add.js +16 -2
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/affected.d.ts.map +1 -1
- package/dist/commands/affected.js +12 -1
- package/dist/commands/affected.js.map +1 -1
- package/dist/commands/explain.d.ts.map +1 -1
- package/dist/commands/explain.js +4 -1
- package/dist/commands/explain.js.map +1 -1
- package/dist/commands/install.d.ts.map +1 -1
- package/dist/commands/install.js +26 -1
- package/dist/commands/install.js.map +1 -1
- package/dist/commands/llm-summary.d.ts +1 -0
- package/dist/commands/llm-summary.d.ts.map +1 -1
- package/dist/commands/llm-summary.js +3 -2
- package/dist/commands/llm-summary.js.map +1 -1
- package/dist/commands/neighbors.d.ts.map +1 -1
- package/dist/commands/neighbors.js +3 -1
- package/dist/commands/neighbors.js.map +1 -1
- package/dist/commands/run.d.ts +1 -0
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +7 -0
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/update.d.ts +8 -2
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +44 -7
- package/dist/commands/update.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -5
- package/dist/index.js.map +1 -1
- package/dist/install/hooks.d.ts.map +1 -1
- package/dist/install/hooks.js +22 -10
- package/dist/install/hooks.js.map +1 -1
- package/dist/install/index.d.ts.map +1 -1
- package/dist/install/index.js +41 -1
- package/dist/install/index.js.map +1 -1
- package/dist/install/markdown-inject.d.ts +1 -1
- package/dist/install/markdown-inject.d.ts.map +1 -1
- package/dist/install/markdown-inject.js +5 -2
- package/dist/install/markdown-inject.js.map +1 -1
- package/dist/install/platforms.d.ts +5 -3
- package/dist/install/platforms.d.ts.map +1 -1
- package/dist/install/platforms.js +69 -0
- package/dist/install/platforms.js.map +1 -1
- package/dist/install/settings-inject.d.ts +6 -1
- package/dist/install/settings-inject.d.ts.map +1 -1
- package/dist/install/settings-inject.js +515 -32
- package/dist/install/settings-inject.js.map +1 -1
- package/dist/native.d.ts +1 -0
- package/dist/native.d.ts.map +1 -1
- package/dist/native.js +20 -3
- package/dist/native.js.map +1 -1
- package/package.json +8 -8
- package/skills/skill-aider.md +2 -2
- package/skills/skill-codex.md +4 -2
- package/skills/skill-copilot.md +4 -2
- package/skills/skill-cursor.md +4 -2
- package/skills/skill-gemini.md +4 -2
- package/skills/skill-kiro.md +4 -2
- package/skills/skill-opencode.md +4 -2
- package/skills/skill-trae.md +4 -2
- 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
|
-
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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 (
|
|
250
|
-
config.plugins
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
fs.
|
|
264
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
318
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
372
|
-
|
|
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
|