pi-gitnexus-fork 0.7.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.
Files changed (117) hide show
  1. package/.github/workflows/ci.yml +20 -0
  2. package/.gitnexusignore +11 -0
  3. package/.sg-rules/async-function-must-await-or-return.yml +55 -0
  4. package/.sg-rules/catch-must-log-error.yml +78 -0
  5. package/.sg-rules/class-must-implement-or-extend.yml +61 -0
  6. package/.sg-rules/class-property-must-be-readonly.yml +61 -0
  7. package/.sg-rules/error-must-extend-base.yml +56 -0
  8. package/.sg-rules/generic-must-be-constrained.yml +60 -0
  9. package/.sg-rules/import-reexport-risk.yml +9 -0
  10. package/.sg-rules/missing-session-id-in-api.yml +16 -0
  11. package/.sg-rules/no-any-in-generic-args.yml +57 -0
  12. package/.sg-rules/no-await-in-promise-all.yml +28 -0
  13. package/.sg-rules/no-barrel-export.yml +17 -0
  14. package/.sg-rules/no-bq-write-in-module.yml +65 -0
  15. package/.sg-rules/no-console-except-error.yml +27 -0
  16. package/.sg-rules/no-console-in-server.yml +42 -0
  17. package/.sg-rules/no-empty-catch.yml +20 -0
  18. package/.sg-rules/no-empty-function.yml +24 -0
  19. package/.sg-rules/no-eval.yml +28 -0
  20. package/.sg-rules/no-explicit-any.yml +34 -0
  21. package/.sg-rules/no-hardcoded-placeholder-string.yml +23 -0
  22. package/.sg-rules/no-hardcoded-secrets.yml +32 -0
  23. package/.sg-rules/no-innerHTML.yml +22 -0
  24. package/.sg-rules/no-json-parse-without-trycatch.yml +33 -0
  25. package/.sg-rules/no-magic-numbers.yml +25 -0
  26. package/.sg-rules/no-nested-ternary.yml +21 -0
  27. package/.sg-rules/no-non-null-assertion.yml +25 -0
  28. package/.sg-rules/no-stub-implementation.yml +44 -0
  29. package/.sg-rules/no-throw-literal.yml +50 -0
  30. package/.sg-rules/no-todo-comment.yml +24 -0
  31. package/.sg-rules/no-ts-ignore-comment.yml +48 -0
  32. package/.sg-rules/no-type-assertion-in-jsx.yml +23 -0
  33. package/.sg-rules/no-unguarded-trim.yml +24 -0
  34. package/.sg-rules/no-unknown-without-narrowing.yml +76 -0
  35. package/.sg-rules/no-unsafe-bracket-access.yml +58 -0
  36. package/.sg-rules/no-unsafe-type-assertion.yml +45 -0
  37. package/.sg-rules/switch-must-be-exhaustive.yml +62 -0
  38. package/.sg-rules/zod-async-refine-without-abort.yml +62 -0
  39. package/.sg-rules/zod-enum-unsafe-access.yml +59 -0
  40. package/.sg-rules/zod-nested-object-deep-path.yml +70 -0
  41. package/.sg-rules/zod-optional-without-default-in-route.yml +50 -0
  42. package/.sg-rules/zod-parse-not-safe.yml +42 -0
  43. package/.sg-rules/zod-preprocess-without-fallback.yml +58 -0
  44. package/.sg-rules/zod-refine-no-return-undefined.yml +54 -0
  45. package/.sg-rules/zod-transform-without-output-type.yml +52 -0
  46. package/.sg-sha +1 -0
  47. package/.sgignore +4 -0
  48. package/AGENTS.md +1 -0
  49. package/CHANGELOG.md +99 -0
  50. package/LICENSE +21 -0
  51. package/README.md +113 -0
  52. package/biome.json +25 -0
  53. package/coverage/base.css +224 -0
  54. package/coverage/block-navigation.js +87 -0
  55. package/coverage/clover.xml +890 -0
  56. package/coverage/coverage-final.json +12 -0
  57. package/coverage/favicon.png +0 -0
  58. package/coverage/index.html +131 -0
  59. package/coverage/prettify.css +1 -0
  60. package/coverage/prettify.js +2 -0
  61. package/coverage/sort-arrow-sprite.png +0 -0
  62. package/coverage/sorter.js +210 -0
  63. package/coverage/src/augment-remote.ts.html +274 -0
  64. package/coverage/src/gitnexus.ts.html +1363 -0
  65. package/coverage/src/index.html +236 -0
  66. package/coverage/src/index.ts.html +1561 -0
  67. package/coverage/src/mcp-client-factory.ts.html +367 -0
  68. package/coverage/src/mcp-client-stdio.ts.html +736 -0
  69. package/coverage/src/mcp-client.ts.html +568 -0
  70. package/coverage/src/remote-mcp-client.ts.html +709 -0
  71. package/coverage/src/repo-resolver.ts.html +526 -0
  72. package/coverage/src/tools.ts.html +970 -0
  73. package/coverage/src/ui/index.html +131 -0
  74. package/coverage/src/ui/main-menu.ts.html +502 -0
  75. package/coverage/src/ui/settings-menu.ts.html +460 -0
  76. package/dist/augment-remote.d.ts +11 -0
  77. package/dist/augment-remote.js +55 -0
  78. package/dist/gitnexus.d.ts +103 -0
  79. package/dist/gitnexus.js +410 -0
  80. package/dist/index.d.ts +2 -0
  81. package/dist/index.js +479 -0
  82. package/dist/mcp-client-factory.d.ts +19 -0
  83. package/dist/mcp-client-factory.js +78 -0
  84. package/dist/mcp-client-stdio.d.ts +35 -0
  85. package/dist/mcp-client-stdio.js +186 -0
  86. package/dist/mcp-client.d.ts +45 -0
  87. package/dist/mcp-client.js +145 -0
  88. package/dist/remote-mcp-client.d.ts +43 -0
  89. package/dist/remote-mcp-client.js +181 -0
  90. package/dist/repo-resolver.d.ts +47 -0
  91. package/dist/repo-resolver.js +123 -0
  92. package/dist/tools.d.ts +6 -0
  93. package/dist/tools.js +230 -0
  94. package/dist/ui/main-menu.d.ts +33 -0
  95. package/dist/ui/main-menu.js +102 -0
  96. package/dist/ui/settings-menu.d.ts +16 -0
  97. package/dist/ui/settings-menu.js +95 -0
  98. package/docs/design/remote-mcp-backend.md +153 -0
  99. package/media/screenshot.png +0 -0
  100. package/package.json +61 -0
  101. package/sgconfig.yml +4 -0
  102. package/skills/gitnexus-debugging/SKILL.md +84 -0
  103. package/skills/gitnexus-exploring/SKILL.md +73 -0
  104. package/skills/gitnexus-impact-analysis/SKILL.md +93 -0
  105. package/skills/gitnexus-pr-review/SKILL.md +109 -0
  106. package/skills/gitnexus-refactoring/SKILL.md +85 -0
  107. package/src/augment-remote.ts +63 -0
  108. package/src/gitnexus.ts +426 -0
  109. package/src/index.ts +492 -0
  110. package/src/mcp-client-factory.ts +94 -0
  111. package/src/mcp-client-stdio.ts +217 -0
  112. package/src/mcp-client.ts +208 -0
  113. package/src/remote-mcp-client.ts +250 -0
  114. package/src/repo-resolver.ts +147 -0
  115. package/src/tools.ts +295 -0
  116. package/src/ui/main-menu.ts +139 -0
  117. package/src/ui/settings-menu.ts +125 -0
@@ -0,0 +1,123 @@
1
+ import { exec } from 'child_process';
2
+ import { basename } from 'path';
3
+ import { findGitNexusRoot } from './gitnexus';
4
+ /**
5
+ * RepoResolver — maps a host cwd to a server-side repo path.
6
+ *
7
+ * Strategy:
8
+ * 1. Fetch registry from GET <serverUrl>/api/repos
9
+ * 2. Match by git remote URL (most reliable)
10
+ * 3. Match by basename of cwd vs basename of server path
11
+ * 4. Fallback: return findGitNexusRoot(cwd)
12
+ *
13
+ * Uses instance-level cache so separate resolver instances don't share state.
14
+ */
15
+ export class RepoResolver {
16
+ serverUrl;
17
+ registry = [];
18
+ registryFetched = false;
19
+ resolutionCache = new Map();
20
+ constructor(config) {
21
+ this.serverUrl = config.serverUrl.replace(/\/+$/, '');
22
+ }
23
+ /**
24
+ * Fetch the repo registry from the server.
25
+ * Only fetches once per instance; subsequent calls return cached data.
26
+ */
27
+ async fetchRegistry() {
28
+ if (this.registryFetched)
29
+ return this.registry;
30
+ try {
31
+ const res = await fetch(`${this.serverUrl}/api/repos`, {
32
+ method: 'GET',
33
+ headers: { Accept: 'application/json' },
34
+ signal: AbortSignal.timeout(10_000),
35
+ });
36
+ if (!res.ok) {
37
+ this.registry = [];
38
+ this.registryFetched = true;
39
+ return this.registry;
40
+ }
41
+ const data = (await res.json());
42
+ this.registry = Array.isArray(data) ? data : [];
43
+ this.registryFetched = true;
44
+ return this.registry;
45
+ }
46
+ catch {
47
+ this.registry = [];
48
+ this.registryFetched = true;
49
+ return this.registry;
50
+ }
51
+ }
52
+ /** Clear cached registry and resolution cache. Re-fetches on next resolveRepo. */
53
+ async refreshRegistry() {
54
+ this.registry = [];
55
+ this.registryFetched = false;
56
+ this.resolutionCache.clear();
57
+ await this.fetchRegistry();
58
+ }
59
+ /** Return the currently cached registry entries. */
60
+ getRegistry() {
61
+ return this.registry;
62
+ }
63
+ /**
64
+ * Resolve a host cwd to a server-side repo path (string).
65
+ * Uses instance-level resolution cache.
66
+ */
67
+ async resolveRepo(cwd) {
68
+ if (this.resolutionCache.has(cwd)) {
69
+ return this.resolutionCache.get(cwd);
70
+ }
71
+ // Ensure registry is loaded
72
+ await this.fetchRegistry();
73
+ const result = await this.doResolve(cwd);
74
+ this.resolutionCache.set(cwd, result);
75
+ return result;
76
+ }
77
+ async doResolve(cwd) {
78
+ const hostBasename = basename(cwd);
79
+ // Try to get git remote URL from host cwd
80
+ const hostRemoteUrl = await this.getGitRemoteUrl(cwd);
81
+ // Strategy 1: match by git remote URL
82
+ if (hostRemoteUrl) {
83
+ const match = this.registry.find((r) => r.remoteUrl && normalizeGitUrl(r.remoteUrl) === normalizeGitUrl(hostRemoteUrl));
84
+ if (match)
85
+ return match.path;
86
+ }
87
+ // Strategy 2: match by basename
88
+ const basenameMatch = this.registry.find((r) => basename(r.path) === hostBasename || r.name === hostBasename);
89
+ if (basenameMatch)
90
+ return basenameMatch.path;
91
+ // Strategy 3: fallback — findGitNexusRoot
92
+ return findGitNexusRoot(cwd);
93
+ }
94
+ getGitRemoteUrl(cwd) {
95
+ return new Promise((resolve_) => {
96
+ exec('git remote -v', { cwd, timeout: 5000 }, (err, stdout) => {
97
+ if (err) {
98
+ resolve_(null);
99
+ return;
100
+ }
101
+ // Parse "origin\t<url> (fetch)" format
102
+ const match = stdout?.match(/\S+\s+(\S+)\s+\(fetch\)/);
103
+ resolve_(match ? match[1] : null);
104
+ });
105
+ });
106
+ }
107
+ }
108
+ /**
109
+ * Normalize a git remote URL for comparison.
110
+ * Strips trailing .git, protocol prefixes, and user@host: prefixes.
111
+ */
112
+ export function normalizeGitUrl(url) {
113
+ let normalized = url.trim();
114
+ if (normalized.endsWith('.git'))
115
+ normalized = normalized.slice(0, -4);
116
+ if (normalized.includes(':') && !normalized.startsWith('http')) {
117
+ normalized = normalized.split(':').pop() ?? normalized;
118
+ }
119
+ normalized = normalized.replace(/^https?:\/\//, '');
120
+ normalized = normalized.replace(/^[^@]+@/, '');
121
+ normalized = normalized.replace(/\\/g, '/');
122
+ return normalized;
123
+ }
@@ -0,0 +1,6 @@
1
+ import type { ExtensionAPI } from '@mariozechner/pi-coding-agent';
2
+ /**
3
+ * Register all GitNexus tools with pi.
4
+ * Called once from index.ts — this is the only way tools.ts accesses pi.
5
+ */
6
+ export declare function registerTools(pi: ExtensionAPI): void;
package/dist/tools.js ADDED
@@ -0,0 +1,230 @@
1
+ import { StringEnum } from '@mariozechner/pi-ai';
2
+ import { isAbsolute } from 'path';
3
+ import { Type } from 'typebox';
4
+ import { expandUserPath, findGitNexusIndex, findGitNexusRoot, normalizePathArg, safeResolvePath, toRepoRelativePath, validateRepoRelativePath } from './gitnexus';
5
+ import { mcpClient } from './mcp-client';
6
+ function text(msg) {
7
+ return { content: [{ type: 'text', text: msg }], details: undefined };
8
+ }
9
+ const NO_INDEX = 'No GitNexus index found. Run: /gitnexus analyze';
10
+ function normalizeRepoOverride(repo) {
11
+ if (!repo?.trim())
12
+ return undefined;
13
+ return looksLikeRepoPath(repo) ? expandUserPath(repo) : repo;
14
+ }
15
+ function buildRepoArgs(ctx, params) {
16
+ const normalizedRepo = typeof params.repo === 'string' ? normalizeRepoOverride(params.repo) : undefined;
17
+ if (normalizedRepo) {
18
+ return { ...params, repo: normalizedRepo };
19
+ }
20
+ const repoRoot = findGitNexusRoot(ctx.cwd);
21
+ return repoRoot ? { ...params, repo: repoRoot } : params;
22
+ }
23
+ function hasRepoOverride(params) {
24
+ return typeof params.repo === 'string' && params.repo.trim().length > 0;
25
+ }
26
+ function looksLikeRepoPath(repo) {
27
+ if (!repo)
28
+ return false;
29
+ const expanded = expandUserPath(repo);
30
+ return isAbsolute(expanded) || expanded.startsWith('./') || expanded.startsWith('../');
31
+ }
32
+ function shouldAllowQuery(ctx, params) {
33
+ return hasRepoOverride(params) || findGitNexusIndex(ctx.cwd);
34
+ }
35
+ function resolveFilePath(ctx, filePath, repo) {
36
+ const normalizedPath = normalizePathArg(filePath);
37
+ if (looksLikeRepoPath(repo)) {
38
+ return toRepoRelativePath(normalizedPath, expandUserPath(repo));
39
+ }
40
+ if (repo?.trim()) {
41
+ return validateRepoRelativePath(normalizedPath);
42
+ }
43
+ const repoRoot = findGitNexusRoot(ctx.cwd);
44
+ if (repoRoot) {
45
+ return toRepoRelativePath(normalizedPath, repoRoot);
46
+ }
47
+ if (isAbsolute(normalizedPath)) {
48
+ return safeResolvePath(normalizedPath, ctx.cwd);
49
+ }
50
+ return validateRepoRelativePath(normalizedPath);
51
+ }
52
+ function normalizeContextArgs(ctx, params) {
53
+ const filePath = params.file_path ?? params.file;
54
+ const args = {
55
+ ...(params.name ? { name: params.name } : {}),
56
+ ...(params.uid ? { uid: params.uid } : {}),
57
+ ...(params.include_content !== undefined ? { include_content: params.include_content } : {}),
58
+ };
59
+ if (filePath) {
60
+ const safe = resolveFilePath(ctx, filePath, params.repo);
61
+ if (!safe)
62
+ return null;
63
+ args.file_path = safe;
64
+ }
65
+ return buildRepoArgs(ctx, params.repo ? { ...args, repo: params.repo } : args);
66
+ }
67
+ function normalizeImpactArgs(ctx, params) {
68
+ return buildRepoArgs(ctx, {
69
+ target: params.target,
70
+ ...(params.direction ? { direction: params.direction } : {}),
71
+ ...((params.maxDepth ?? params.depth) !== undefined ? { maxDepth: params.maxDepth ?? params.depth } : {}),
72
+ ...((params.includeTests ?? params.include_tests) !== undefined
73
+ ? { includeTests: params.includeTests ?? params.include_tests }
74
+ : {}),
75
+ ...(params.relationTypes ? { relationTypes: params.relationTypes } : {}),
76
+ ...(params.minConfidence !== undefined ? { minConfidence: params.minConfidence } : {}),
77
+ ...(params.repo ? { repo: params.repo } : {}),
78
+ });
79
+ }
80
+ /**
81
+ * Register all GitNexus tools with pi.
82
+ * Called once from index.ts — this is the only way tools.ts accesses pi.
83
+ */
84
+ export function registerTools(pi) {
85
+ pi.registerTool({
86
+ name: 'gitnexus_list_repos',
87
+ label: 'GitNexus List Repos',
88
+ description: 'List all repositories indexed by GitNexus. Use first when multiple repos may be indexed.',
89
+ parameters: Type.Object({}),
90
+ execute: async (_id, _params, _signal, _onUpdate, ctx) => {
91
+ const out = await mcpClient.callTool('list_repos', {}, ctx.cwd);
92
+ return text(out || 'No indexed repositories found.');
93
+ },
94
+ });
95
+ pi.registerTool({
96
+ name: 'gitnexus_query',
97
+ label: 'GitNexus Query',
98
+ description: 'Search the knowledge graph for execution flows related to a concept or error.',
99
+ parameters: Type.Object({
100
+ query: Type.String({ minLength: 1, maxLength: 200, pattern: '^[^-]' }),
101
+ task_context: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
102
+ goal: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
103
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, default: 5 })),
104
+ max_symbols: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, default: 10 })),
105
+ include_content: Type.Optional(Type.Boolean()),
106
+ repo: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
107
+ }),
108
+ execute: async (_id, params, _signal, _onUpdate, ctx) => {
109
+ if (!shouldAllowQuery(ctx, params))
110
+ return text(NO_INDEX);
111
+ const out = await mcpClient.callTool('query', buildRepoArgs(ctx, params), ctx.cwd);
112
+ return text(out || 'No results.');
113
+ },
114
+ });
115
+ pi.registerTool({
116
+ name: 'gitnexus_context',
117
+ label: 'GitNexus Context',
118
+ description: '360-degree view of a code symbol: callers, callees, processes it participates in.',
119
+ parameters: Type.Object({
120
+ name: Type.Optional(Type.String({ minLength: 1, maxLength: 200, pattern: '^[^-]' })),
121
+ uid: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })),
122
+ file: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
123
+ file_path: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
124
+ include_content: Type.Optional(Type.Boolean()),
125
+ repo: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
126
+ }),
127
+ execute: async (_id, params, _signal, _onUpdate, ctx) => {
128
+ if (!shouldAllowQuery(ctx, params))
129
+ return text(NO_INDEX);
130
+ const typedParams = params;
131
+ if (!typedParams.name && !typedParams.uid)
132
+ return text('Provide either name or uid.');
133
+ const args = normalizeContextArgs(ctx, typedParams);
134
+ if (!args)
135
+ throw new Error('Invalid file path.');
136
+ const out = await mcpClient.callTool('context', args, ctx.cwd);
137
+ return text(out || 'No results.');
138
+ },
139
+ });
140
+ pi.registerTool({
141
+ name: 'gitnexus_impact',
142
+ label: 'GitNexus Impact',
143
+ description: 'Blast radius analysis: what breaks at each depth if you change a symbol.',
144
+ parameters: Type.Object({
145
+ target: Type.String({ minLength: 1, maxLength: 200, pattern: '^[^-]' }),
146
+ direction: StringEnum(['upstream', 'downstream']),
147
+ depth: Type.Optional(Type.Integer({ minimum: 1, maximum: 10, default: 3 })),
148
+ maxDepth: Type.Optional(Type.Integer({ minimum: 1, maximum: 10 })),
149
+ include_tests: Type.Optional(Type.Boolean()),
150
+ includeTests: Type.Optional(Type.Boolean()),
151
+ relationTypes: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 50 }), { maxItems: 20 })),
152
+ minConfidence: Type.Optional(Type.Number({ minimum: 0, maximum: 1 })),
153
+ repo: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
154
+ }),
155
+ execute: async (_id, params, _signal, _onUpdate, ctx) => {
156
+ if (!shouldAllowQuery(ctx, params))
157
+ return text(NO_INDEX);
158
+ const out = await mcpClient.callTool('impact', normalizeImpactArgs(ctx, params), ctx.cwd);
159
+ return text(out || 'No results.');
160
+ },
161
+ });
162
+ pi.registerTool({
163
+ name: 'gitnexus_detect_changes',
164
+ label: 'GitNexus Detect Changes',
165
+ description: 'Analyze git changes and map them to affected execution flows.',
166
+ parameters: Type.Object({
167
+ scope: Type.Optional(StringEnum(['unstaged', 'staged', 'all', 'compare'])),
168
+ base_ref: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })),
169
+ repo: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
170
+ }),
171
+ execute: async (_id, params, _signal, _onUpdate, ctx) => {
172
+ if (!shouldAllowQuery(ctx, params))
173
+ return text(NO_INDEX);
174
+ const out = await mcpClient.callTool('detect_changes', buildRepoArgs(ctx, params), ctx.cwd);
175
+ return text(out || 'No affected flows detected.');
176
+ },
177
+ });
178
+ pi.registerTool({
179
+ name: 'gitnexus_rename',
180
+ label: 'GitNexus Rename',
181
+ description: 'Multi-file coordinated rename using the knowledge graph plus text search. Use dry_run first.',
182
+ parameters: Type.Object({
183
+ symbol_name: Type.Optional(Type.String({ minLength: 1, maxLength: 200, pattern: '^[^-]' })),
184
+ symbol_uid: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })),
185
+ new_name: Type.String({ minLength: 1, maxLength: 200, pattern: '^[^-]' }),
186
+ file_path: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
187
+ dry_run: Type.Optional(Type.Boolean()),
188
+ repo: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
189
+ }),
190
+ execute: async (_id, params, _signal, _onUpdate, ctx) => {
191
+ if (!shouldAllowQuery(ctx, params))
192
+ return text(NO_INDEX);
193
+ const typedParams = params;
194
+ if (!typedParams.symbol_name && !typedParams.symbol_uid) {
195
+ return text('Provide either symbol_name or symbol_uid.');
196
+ }
197
+ let filePath;
198
+ if (typedParams.file_path) {
199
+ const safe = resolveFilePath(ctx, typedParams.file_path, typedParams.repo);
200
+ if (!safe)
201
+ throw new Error('Invalid file path.');
202
+ filePath = safe;
203
+ }
204
+ const out = await mcpClient.callTool('rename', buildRepoArgs(ctx, {
205
+ ...(typedParams.symbol_name ? { symbol_name: typedParams.symbol_name } : {}),
206
+ ...(typedParams.symbol_uid ? { symbol_uid: typedParams.symbol_uid } : {}),
207
+ new_name: typedParams.new_name,
208
+ ...(filePath ? { file_path: filePath } : {}),
209
+ ...(typedParams.dry_run !== undefined ? { dry_run: typedParams.dry_run } : {}),
210
+ ...(typedParams.repo ? { repo: typedParams.repo } : {}),
211
+ }), ctx.cwd);
212
+ return text(out || 'No rename preview generated.');
213
+ },
214
+ });
215
+ pi.registerTool({
216
+ name: 'gitnexus_cypher',
217
+ label: 'GitNexus Cypher',
218
+ description: 'Execute a raw Cypher query against the code knowledge graph.',
219
+ parameters: Type.Object({
220
+ query: Type.String({ minLength: 1, maxLength: 10_000, pattern: '^[^-]' }),
221
+ repo: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
222
+ }),
223
+ execute: async (_id, params, _signal, _onUpdate, ctx) => {
224
+ if (!shouldAllowQuery(ctx, params))
225
+ return text(NO_INDEX);
226
+ const out = await mcpClient.callTool('cypher', buildRepoArgs(ctx, params), ctx.cwd);
227
+ return text(out || 'No results.');
228
+ },
229
+ });
230
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * main-menu.ts — Interactive main menu for /gitnexus.
3
+ *
4
+ * Shows status in the title, with Analyze, Settings, and Help actions.
5
+ */
6
+ import type { GitNexusConfig } from '../gitnexus.js';
7
+ export type MenuUI = {
8
+ select(title: string, options: string[]): Promise<string | undefined>;
9
+ notify(message: string, type: 'info' | 'warning' | 'error'): void;
10
+ custom<T>(factory: (tui: any, theme: any, keybindings: any, done: (result: T) => void) => any, options?: {
11
+ overlay?: boolean;
12
+ overlayOptions?: any;
13
+ }): Promise<T>;
14
+ };
15
+ export interface MenuContext {
16
+ ui: MenuUI;
17
+ cwd: string;
18
+ cfg: GitNexusConfig;
19
+ state: {
20
+ augmentEnabled: boolean;
21
+ };
22
+ binaryAvailable: boolean;
23
+ gitnexusCmd: string[];
24
+ spawnEnv: NodeJS.ProcessEnv;
25
+ getHookFires: () => number;
26
+ getAugmentHits: () => number;
27
+ findGitNexusIndex: (cwd: string) => boolean;
28
+ clearIndexCache: () => void;
29
+ setGitnexusCmd: (cmd: string[]) => void;
30
+ setAugmentTimeout: (seconds: number) => void;
31
+ syncState: () => void;
32
+ }
33
+ export declare function openMainMenu(mctx: MenuContext): Promise<void>;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * main-menu.ts — Interactive main menu for /gitnexus.
3
+ *
4
+ * Shows status in the title, with Analyze, Settings, and Help actions.
5
+ */
6
+ import spawn from 'cross-spawn';
7
+ import { openSettingsMenu } from './settings-menu.js';
8
+ // ── Status ──────────────────────────────────────────────────────────────────
9
+ async function getStatusLine(mctx) {
10
+ if (!mctx.binaryAvailable)
11
+ return 'gitnexus not installed';
12
+ if (!mctx.findGitNexusIndex(mctx.cwd))
13
+ return 'No index — run /gitnexus analyze';
14
+ const out = await new Promise((resolve_) => {
15
+ let stdout = '';
16
+ const [bin, ...baseArgs] = mctx.gitnexusCmd;
17
+ const proc = spawn(bin, [...baseArgs, 'status'], {
18
+ cwd: mctx.cwd,
19
+ stdio: ['ignore', 'pipe', 'ignore'],
20
+ env: mctx.spawnEnv,
21
+ });
22
+ proc.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
23
+ proc.on('close', () => resolve_(stdout.trim()));
24
+ proc.on('error', () => resolve_(''));
25
+ });
26
+ const augmentLine = mctx.state.augmentEnabled
27
+ ? `Auto-augment: on (${mctx.getHookFires()} intercepted, ${mctx.getAugmentHits()} enriched)`
28
+ : 'Auto-augment: off';
29
+ return (out ? out + '\n' : '') + augmentLine;
30
+ }
31
+ // ── Analyze ─────────────────────────────────────────────────────────────────
32
+ async function runAnalyze(mctx) {
33
+ if (!mctx.binaryAvailable) {
34
+ mctx.ui.notify('gitnexus is not installed. Install: npm i -g gitnexus', 'warning');
35
+ return;
36
+ }
37
+ mctx.state.augmentEnabled = false;
38
+ mctx.syncState();
39
+ mctx.ui.notify('GitNexus: analyzing codebase, this may take a while…', 'info');
40
+ const exitCode = await new Promise((resolve_) => {
41
+ const [bin, ...baseArgs] = mctx.gitnexusCmd;
42
+ const proc = spawn(bin, [...baseArgs, 'analyze'], {
43
+ cwd: mctx.cwd,
44
+ stdio: 'ignore',
45
+ env: mctx.spawnEnv,
46
+ });
47
+ proc.on('close', resolve_);
48
+ proc.on('error', () => resolve_(null));
49
+ });
50
+ if (exitCode === 0) {
51
+ mctx.clearIndexCache();
52
+ mctx.state.augmentEnabled = true;
53
+ mctx.syncState();
54
+ mctx.ui.notify('GitNexus: analysis complete. Knowledge graph ready.', 'info');
55
+ }
56
+ else {
57
+ mctx.state.augmentEnabled = true;
58
+ mctx.syncState();
59
+ mctx.ui.notify('GitNexus: analysis failed. Check the terminal for details.', 'error');
60
+ }
61
+ }
62
+ // ── Help ────────────────────────────────────────────────────────────────────
63
+ function showHelp(mctx) {
64
+ mctx.ui.notify('Subcommands:\n' +
65
+ ' /gitnexus status — show index & augmentation stats\n' +
66
+ ' /gitnexus analyze — build/rebuild the knowledge graph\n' +
67
+ ' /gitnexus on|off — toggle auto-augment\n' +
68
+ ' /gitnexus <pattern> — manual graph lookup\n' +
69
+ ' /gitnexus query <q> — search execution flows\n' +
70
+ ' /gitnexus context <n> — callers/callees of a symbol\n' +
71
+ ' /gitnexus impact <n> — blast radius of a change', 'info');
72
+ }
73
+ // ── Main menu ───────────────────────────────────────────────────────────────
74
+ export async function openMainMenu(mctx) {
75
+ const mainMenu = async () => {
76
+ const statusLine = await getStatusLine(mctx);
77
+ const title = `GitNexus\n${statusLine}`;
78
+ const choices = [
79
+ 'Analyze',
80
+ 'Settings',
81
+ 'Help',
82
+ ];
83
+ const choice = await mctx.ui.select(title, choices);
84
+ if (!choice)
85
+ return;
86
+ if (choice === 'Analyze') {
87
+ await runAnalyze(mctx);
88
+ return mainMenu();
89
+ }
90
+ if (choice === 'Settings') {
91
+ await openSettingsMenu(mctx.ui, mctx.cfg, mctx.state, async () => {
92
+ mctx.syncState();
93
+ });
94
+ return mainMenu();
95
+ }
96
+ if (choice === 'Help') {
97
+ showHelp(mctx);
98
+ return mainMenu();
99
+ }
100
+ };
101
+ await mainMenu();
102
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * settings-menu.ts — Interactive settings panel for /gitnexus settings.
3
+ *
4
+ * Uses ui.custom() + SettingsList for native TUI rendering with keyboard
5
+ * navigation, live toggle, and per-row descriptions.
6
+ */
7
+ import { type GitNexusConfig } from "../gitnexus.js";
8
+ export type SettingsUI = {
9
+ custom<T>(factory: (tui: any, theme: any, keybindings: any, done: (result: T) => void) => any, options?: {
10
+ overlay?: boolean;
11
+ overlayOptions?: any;
12
+ }): Promise<T>;
13
+ };
14
+ export declare function openSettingsMenu(ui: SettingsUI, cfg: GitNexusConfig, state: {
15
+ augmentEnabled: boolean;
16
+ }, onBack: () => Promise<void>): Promise<void>;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * settings-menu.ts — Interactive settings panel for /gitnexus settings.
3
+ *
4
+ * Uses ui.custom() + SettingsList for native TUI rendering with keyboard
5
+ * navigation, live toggle, and per-row descriptions.
6
+ */
7
+ import { getSettingsListTheme } from "@mariozechner/pi-coding-agent";
8
+ import { Container, SettingsList, Spacer, Text } from "@mariozechner/pi-tui";
9
+ import { saveConfig } from "../gitnexus.js";
10
+ // ── Settings panel ──────────────────────────────────────────────────────────
11
+ export async function openSettingsMenu(ui, cfg, state, onBack) {
12
+ await ui.custom((_tui, theme, _kb, done) => {
13
+ const items = [
14
+ {
15
+ id: "autoAugment",
16
+ label: "Auto-augment",
17
+ description: "When ON: search results (grep, find, read, bash) are automatically " +
18
+ "enriched with knowledge graph context. When OFF: graph context is " +
19
+ "only available via explicit /gitnexus commands and tools.",
20
+ currentValue: state.augmentEnabled ? "on" : "off",
21
+ values: ["on", "off"],
22
+ },
23
+ {
24
+ id: "augmentTimeout",
25
+ label: "Augment timeout",
26
+ description: "Maximum time (in seconds) to wait for a graph augmentation response. " +
27
+ "Increase for large repos with slow I/O, decrease for snappier responses. " +
28
+ "Default: 8s.",
29
+ currentValue: String(cfg.augmentTimeout ?? 8),
30
+ values: ["4", "6", "8", "10", "15", "20"],
31
+ },
32
+ {
33
+ id: "maxAugmentsPerResult",
34
+ label: "Max augments per result",
35
+ description: "Maximum number of patterns to augment in parallel per search result. " +
36
+ "Higher values provide more context but use more tokens. Default: 3.",
37
+ currentValue: String(cfg.maxAugmentsPerResult ?? 3),
38
+ values: ["1", "2", "3", "5"],
39
+ },
40
+ {
41
+ id: "maxSecondaryPatterns",
42
+ label: "Max secondary patterns",
43
+ description: "Maximum number of secondary file-based patterns extracted from grep/bash " +
44
+ "output. These provide additional context from files mentioned in results. " +
45
+ "Default: 2.",
46
+ currentValue: String(cfg.maxSecondaryPatterns ?? 2),
47
+ values: ["0", "1", "2", "3", "5"],
48
+ },
49
+ {
50
+ id: "cmd",
51
+ label: "GitNexus command",
52
+ description: "The shell command used to invoke gitnexus. " +
53
+ "Change if gitnexus is installed in a non-standard location or you want " +
54
+ "to use npx. Default: gitnexus.",
55
+ currentValue: cfg.cmd ?? "gitnexus",
56
+ values: ["gitnexus", "npx gitnexus@latest", "npx -y gitnexus@latest"],
57
+ },
58
+ ];
59
+ const list = new SettingsList(items, Math.min(items.length + 2, 15), getSettingsListTheme(),
60
+ /* onChange */ (id, newValue) => {
61
+ if (id === "autoAugment") {
62
+ state.augmentEnabled = newValue === "on";
63
+ cfg.autoAugment = newValue === "on";
64
+ saveConfig(cfg);
65
+ }
66
+ if (id === "augmentTimeout") {
67
+ cfg.augmentTimeout = parseInt(newValue, 10);
68
+ saveConfig(cfg);
69
+ }
70
+ if (id === "maxAugmentsPerResult") {
71
+ cfg.maxAugmentsPerResult = parseInt(newValue, 10);
72
+ saveConfig(cfg);
73
+ }
74
+ if (id === "maxSecondaryPatterns") {
75
+ cfg.maxSecondaryPatterns = parseInt(newValue, 10);
76
+ saveConfig(cfg);
77
+ }
78
+ if (id === "cmd") {
79
+ cfg.cmd = newValue;
80
+ saveConfig(cfg);
81
+ }
82
+ },
83
+ /* onCancel */ () => done(undefined));
84
+ const container = new Container();
85
+ container.addChild(new Text(theme.bold(theme.fg("accent", "⚙ GitNexus Settings")), 0, 0));
86
+ container.addChild(new Spacer(1));
87
+ container.addChild(list);
88
+ return {
89
+ render: (w) => container.render(w),
90
+ invalidate: () => container.invalidate(),
91
+ handleInput: (data) => list.handleInput?.(data),
92
+ };
93
+ });
94
+ return onBack();
95
+ }