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,147 @@
1
+ import { exec } from 'child_process';
2
+ import { basename } from 'path';
3
+ import { findGitNexusRoot } from './gitnexus';
4
+
5
+ export interface RepoEntry {
6
+ name: string;
7
+ path: string;
8
+ remoteUrl?: string | null;
9
+ }
10
+
11
+ export interface RepoResolverConfig {
12
+ serverUrl: string;
13
+ }
14
+
15
+ /**
16
+ * RepoResolver — maps a host cwd to a server-side repo path.
17
+ *
18
+ * Strategy:
19
+ * 1. Fetch registry from GET <serverUrl>/api/repos
20
+ * 2. Match by git remote URL (most reliable)
21
+ * 3. Match by basename of cwd vs basename of server path
22
+ * 4. Fallback: return findGitNexusRoot(cwd)
23
+ *
24
+ * Uses instance-level cache so separate resolver instances don't share state.
25
+ */
26
+ export class RepoResolver {
27
+ private serverUrl: string;
28
+ private registry: RepoEntry[] = [];
29
+ private registryFetched = false;
30
+ private resolutionCache = new Map<string, string | null>();
31
+
32
+ constructor(config: RepoResolverConfig) {
33
+ this.serverUrl = config.serverUrl.replace(/\/+$/, '');
34
+ }
35
+
36
+ /**
37
+ * Fetch the repo registry from the server.
38
+ * Only fetches once per instance; subsequent calls return cached data.
39
+ */
40
+ private async fetchRegistry(): Promise<RepoEntry[]> {
41
+ if (this.registryFetched) return this.registry;
42
+
43
+ try {
44
+ const res = await fetch(`${this.serverUrl}/api/repos`, {
45
+ method: 'GET',
46
+ headers: { Accept: 'application/json' },
47
+ signal: AbortSignal.timeout(10_000),
48
+ });
49
+
50
+ if (!res.ok) {
51
+ this.registry = [];
52
+ this.registryFetched = true;
53
+ return this.registry;
54
+ }
55
+
56
+ const data = (await res.json()) as RepoEntry[];
57
+ this.registry = Array.isArray(data) ? data : [];
58
+ this.registryFetched = true;
59
+ return this.registry;
60
+ } catch {
61
+ this.registry = [];
62
+ this.registryFetched = true;
63
+ return this.registry;
64
+ }
65
+ }
66
+
67
+ /** Clear cached registry and resolution cache. Re-fetches on next resolveRepo. */
68
+ async refreshRegistry(): Promise<void> {
69
+ this.registry = [];
70
+ this.registryFetched = false;
71
+ this.resolutionCache.clear();
72
+ await this.fetchRegistry();
73
+ }
74
+
75
+ /** Return the currently cached registry entries. */
76
+ getRegistry(): RepoEntry[] {
77
+ return this.registry;
78
+ }
79
+
80
+ /**
81
+ * Resolve a host cwd to a server-side repo path (string).
82
+ * Uses instance-level resolution cache.
83
+ */
84
+ async resolveRepo(cwd: string): Promise<string | null> {
85
+ if (this.resolutionCache.has(cwd)) {
86
+ return this.resolutionCache.get(cwd)!;
87
+ }
88
+
89
+ // Ensure registry is loaded
90
+ await this.fetchRegistry();
91
+
92
+ const result = await this.doResolve(cwd);
93
+ this.resolutionCache.set(cwd, result);
94
+ return result;
95
+ }
96
+
97
+ private async doResolve(cwd: string): Promise<string | null> {
98
+ const hostBasename = basename(cwd);
99
+
100
+ // Try to get git remote URL from host cwd
101
+ const hostRemoteUrl = await this.getGitRemoteUrl(cwd);
102
+
103
+ // Strategy 1: match by git remote URL
104
+ if (hostRemoteUrl) {
105
+ const match = this.registry.find(
106
+ (r) => r.remoteUrl && normalizeGitUrl(r.remoteUrl) === normalizeGitUrl(hostRemoteUrl),
107
+ );
108
+ if (match) return match.path;
109
+ }
110
+
111
+ // Strategy 2: match by basename
112
+ const basenameMatch = this.registry.find(
113
+ (r) => basename(r.path) === hostBasename || r.name === hostBasename,
114
+ );
115
+ if (basenameMatch) return basenameMatch.path;
116
+
117
+ // Strategy 3: fallback — findGitNexusRoot
118
+ return findGitNexusRoot(cwd);
119
+ }
120
+
121
+ private getGitRemoteUrl(cwd: string): Promise<string | null> {
122
+ return new Promise((resolve_) => {
123
+ exec('git remote -v', { cwd, timeout: 5000 }, (err, stdout) => {
124
+ if (err) { resolve_(null); return; }
125
+ // Parse "origin\t<url> (fetch)" format
126
+ const match = stdout?.match(/\S+\s+(\S+)\s+\(fetch\)/);
127
+ resolve_(match ? match[1] : null);
128
+ });
129
+ });
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Normalize a git remote URL for comparison.
135
+ * Strips trailing .git, protocol prefixes, and user@host: prefixes.
136
+ */
137
+ export function normalizeGitUrl(url: string): string {
138
+ let normalized = url.trim();
139
+ if (normalized.endsWith('.git')) normalized = normalized.slice(0, -4);
140
+ if (normalized.includes(':') && !normalized.startsWith('http')) {
141
+ normalized = normalized.split(':').pop() ?? normalized;
142
+ }
143
+ normalized = normalized.replace(/^https?:\/\//, '');
144
+ normalized = normalized.replace(/^[^@]+@/, '');
145
+ normalized = normalized.replace(/\\/g, '/');
146
+ return normalized;
147
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,295 @@
1
+ import { StringEnum } from '@mariozechner/pi-ai';
2
+ import type { ExtensionAPI, ExtensionContext } from '@mariozechner/pi-coding-agent';
3
+ import { isAbsolute } from 'path';
4
+ import { Type } from 'typebox';
5
+ import { expandUserPath, findGitNexusIndex, findGitNexusRoot, normalizePathArg, safeResolvePath, toRepoRelativePath, validateRepoRelativePath } from './gitnexus';
6
+ import { mcpClient } from './mcp-client';
7
+
8
+ function text(msg: string) {
9
+ return { content: [{ type: 'text' as const, text: msg }], details: undefined };
10
+ }
11
+
12
+ const NO_INDEX = 'No GitNexus index found. Run: /gitnexus analyze';
13
+
14
+ function normalizeRepoOverride(repo: string | undefined): string | undefined {
15
+ if (!repo?.trim()) return undefined;
16
+ return looksLikeRepoPath(repo) ? expandUserPath(repo) : repo;
17
+ }
18
+
19
+ function buildRepoArgs(
20
+ ctx: ExtensionContext,
21
+ params: Record<string, unknown>,
22
+ ): Record<string, unknown> {
23
+ const normalizedRepo = typeof params.repo === 'string' ? normalizeRepoOverride(params.repo) : undefined;
24
+ if (normalizedRepo) {
25
+ return { ...params, repo: normalizedRepo };
26
+ }
27
+ const repoRoot = findGitNexusRoot(ctx.cwd);
28
+ return repoRoot ? { ...params, repo: repoRoot } : params;
29
+ }
30
+
31
+ function hasRepoOverride(params: Record<string, unknown>): boolean {
32
+ return typeof params.repo === 'string' && params.repo.trim().length > 0;
33
+ }
34
+
35
+ function looksLikeRepoPath(repo: string | undefined): boolean {
36
+ if (!repo) return false;
37
+ const expanded = expandUserPath(repo);
38
+ return isAbsolute(expanded) || expanded.startsWith('./') || expanded.startsWith('../');
39
+ }
40
+
41
+ function shouldAllowQuery(ctx: ExtensionContext, params: Record<string, unknown>): boolean {
42
+ return hasRepoOverride(params) || findGitNexusIndex(ctx.cwd);
43
+ }
44
+
45
+ function resolveFilePath(
46
+ ctx: ExtensionContext,
47
+ filePath: string,
48
+ repo?: string,
49
+ ): string | null {
50
+ const normalizedPath = normalizePathArg(filePath);
51
+ if (looksLikeRepoPath(repo)) {
52
+ return toRepoRelativePath(normalizedPath, expandUserPath(repo!));
53
+ }
54
+ if (repo?.trim()) {
55
+ return validateRepoRelativePath(normalizedPath);
56
+ }
57
+
58
+ const repoRoot = findGitNexusRoot(ctx.cwd);
59
+ if (repoRoot) {
60
+ return toRepoRelativePath(normalizedPath, repoRoot);
61
+ }
62
+
63
+ if (isAbsolute(normalizedPath)) {
64
+ return safeResolvePath(normalizedPath, ctx.cwd);
65
+ }
66
+ return validateRepoRelativePath(normalizedPath);
67
+ }
68
+
69
+ function normalizeContextArgs(
70
+ ctx: ExtensionContext,
71
+ params: {
72
+ name?: string;
73
+ uid?: string;
74
+ file?: string;
75
+ file_path?: string;
76
+ include_content?: boolean;
77
+ repo?: string;
78
+ },
79
+ ): Record<string, unknown> | null {
80
+ const filePath = params.file_path ?? params.file;
81
+ const args: Record<string, unknown> = {
82
+ ...(params.name ? { name: params.name } : {}),
83
+ ...(params.uid ? { uid: params.uid } : {}),
84
+ ...(params.include_content !== undefined ? { include_content: params.include_content } : {}),
85
+ };
86
+
87
+ if (filePath) {
88
+ const safe = resolveFilePath(ctx, filePath, params.repo);
89
+ if (!safe) return null;
90
+ args.file_path = safe;
91
+ }
92
+
93
+ return buildRepoArgs(ctx, params.repo ? { ...args, repo: params.repo } : args);
94
+ }
95
+
96
+ function normalizeImpactArgs(
97
+ ctx: ExtensionContext,
98
+ params: {
99
+ target: string;
100
+ direction?: 'upstream' | 'downstream';
101
+ depth?: number;
102
+ maxDepth?: number;
103
+ include_tests?: boolean;
104
+ includeTests?: boolean;
105
+ relationTypes?: string[];
106
+ minConfidence?: number;
107
+ repo?: string;
108
+ },
109
+ ): Record<string, unknown> {
110
+ return buildRepoArgs(ctx, {
111
+ target: params.target,
112
+ ...(params.direction ? { direction: params.direction } : {}),
113
+ ...((params.maxDepth ?? params.depth) !== undefined ? { maxDepth: params.maxDepth ?? params.depth } : {}),
114
+ ...((params.includeTests ?? params.include_tests) !== undefined
115
+ ? { includeTests: params.includeTests ?? params.include_tests }
116
+ : {}),
117
+ ...(params.relationTypes ? { relationTypes: params.relationTypes } : {}),
118
+ ...(params.minConfidence !== undefined ? { minConfidence: params.minConfidence } : {}),
119
+ ...(params.repo ? { repo: params.repo } : {}),
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Register all GitNexus tools with pi.
125
+ * Called once from index.ts — this is the only way tools.ts accesses pi.
126
+ */
127
+ export function registerTools(pi: ExtensionAPI): void {
128
+ pi.registerTool({
129
+ name: 'gitnexus_list_repos',
130
+ label: 'GitNexus List Repos',
131
+ description: 'List all repositories indexed by GitNexus. Use first when multiple repos may be indexed.',
132
+ parameters: Type.Object({}),
133
+ execute: async (_id, _params, _signal, _onUpdate, ctx) => {
134
+ const out = await mcpClient.callTool('list_repos', {}, ctx.cwd);
135
+ return text(out || 'No indexed repositories found.');
136
+ },
137
+ });
138
+
139
+ pi.registerTool({
140
+ name: 'gitnexus_query',
141
+ label: 'GitNexus Query',
142
+ description: 'Search the knowledge graph for execution flows related to a concept or error.',
143
+ parameters: Type.Object({
144
+ query: Type.String({ minLength: 1, maxLength: 200, pattern: '^[^-]' }),
145
+ task_context: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
146
+ goal: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
147
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, default: 5 })),
148
+ max_symbols: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, default: 10 })),
149
+ include_content: Type.Optional(Type.Boolean()),
150
+ repo: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
151
+ }),
152
+ execute: async (_id, params, _signal, _onUpdate, ctx) => {
153
+ if (!shouldAllowQuery(ctx, params as Record<string, unknown>)) return text(NO_INDEX);
154
+ const out = await mcpClient.callTool('query', buildRepoArgs(ctx, params as Record<string, unknown>), ctx.cwd);
155
+ return text(out || 'No results.');
156
+ },
157
+ });
158
+
159
+ pi.registerTool({
160
+ name: 'gitnexus_context',
161
+ label: 'GitNexus Context',
162
+ description: '360-degree view of a code symbol: callers, callees, processes it participates in.',
163
+ parameters: Type.Object({
164
+ name: Type.Optional(Type.String({ minLength: 1, maxLength: 200, pattern: '^[^-]' })),
165
+ uid: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })),
166
+ file: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
167
+ file_path: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
168
+ include_content: Type.Optional(Type.Boolean()),
169
+ repo: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
170
+ }),
171
+ execute: async (_id, params, _signal, _onUpdate, ctx) => {
172
+ if (!shouldAllowQuery(ctx, params as Record<string, unknown>)) return text(NO_INDEX);
173
+ const typedParams = params as {
174
+ name?: string;
175
+ uid?: string;
176
+ file?: string;
177
+ file_path?: string;
178
+ include_content?: boolean;
179
+ repo?: string;
180
+ };
181
+ if (!typedParams.name && !typedParams.uid) return text('Provide either name or uid.');
182
+ const args = normalizeContextArgs(ctx, typedParams);
183
+ if (!args) throw new Error('Invalid file path.');
184
+ const out = await mcpClient.callTool('context', args, ctx.cwd);
185
+ return text(out || 'No results.');
186
+ },
187
+ });
188
+
189
+ pi.registerTool({
190
+ name: 'gitnexus_impact',
191
+ label: 'GitNexus Impact',
192
+ description: 'Blast radius analysis: what breaks at each depth if you change a symbol.',
193
+ parameters: Type.Object({
194
+ target: Type.String({ minLength: 1, maxLength: 200, pattern: '^[^-]' }),
195
+ direction: StringEnum(['upstream', 'downstream'] as const),
196
+ depth: Type.Optional(Type.Integer({ minimum: 1, maximum: 10, default: 3 })),
197
+ maxDepth: Type.Optional(Type.Integer({ minimum: 1, maximum: 10 })),
198
+ include_tests: Type.Optional(Type.Boolean()),
199
+ includeTests: Type.Optional(Type.Boolean()),
200
+ relationTypes: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 50 }), { maxItems: 20 })),
201
+ minConfidence: Type.Optional(Type.Number({ minimum: 0, maximum: 1 })),
202
+ repo: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
203
+ }),
204
+ execute: async (_id, params, _signal, _onUpdate, ctx) => {
205
+ if (!shouldAllowQuery(ctx, params as Record<string, unknown>)) return text(NO_INDEX);
206
+ const out = await mcpClient.callTool('impact', normalizeImpactArgs(ctx, params as {
207
+ target: string;
208
+ direction?: 'upstream' | 'downstream';
209
+ depth?: number;
210
+ maxDepth?: number;
211
+ include_tests?: boolean;
212
+ includeTests?: boolean;
213
+ relationTypes?: string[];
214
+ minConfidence?: number;
215
+ repo?: string;
216
+ }), ctx.cwd);
217
+ return text(out || 'No results.');
218
+ },
219
+ });
220
+
221
+ pi.registerTool({
222
+ name: 'gitnexus_detect_changes',
223
+ label: 'GitNexus Detect Changes',
224
+ description: 'Analyze git changes and map them to affected execution flows.',
225
+ parameters: Type.Object({
226
+ scope: Type.Optional(StringEnum(['unstaged', 'staged', 'all', 'compare'] as const)),
227
+ base_ref: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })),
228
+ repo: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
229
+ }),
230
+ execute: async (_id, params, _signal, _onUpdate, ctx) => {
231
+ if (!shouldAllowQuery(ctx, params as Record<string, unknown>)) return text(NO_INDEX);
232
+ const out = await mcpClient.callTool('detect_changes', buildRepoArgs(ctx, params as Record<string, unknown>), ctx.cwd);
233
+ return text(out || 'No affected flows detected.');
234
+ },
235
+ });
236
+
237
+ pi.registerTool({
238
+ name: 'gitnexus_rename',
239
+ label: 'GitNexus Rename',
240
+ description: 'Multi-file coordinated rename using the knowledge graph plus text search. Use dry_run first.',
241
+ parameters: Type.Object({
242
+ symbol_name: Type.Optional(Type.String({ minLength: 1, maxLength: 200, pattern: '^[^-]' })),
243
+ symbol_uid: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })),
244
+ new_name: Type.String({ minLength: 1, maxLength: 200, pattern: '^[^-]' }),
245
+ file_path: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
246
+ dry_run: Type.Optional(Type.Boolean()),
247
+ repo: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
248
+ }),
249
+ execute: async (_id, params, _signal, _onUpdate, ctx) => {
250
+ if (!shouldAllowQuery(ctx, params as Record<string, unknown>)) return text(NO_INDEX);
251
+ const typedParams = params as {
252
+ symbol_name?: string;
253
+ symbol_uid?: string;
254
+ new_name: string;
255
+ file_path?: string;
256
+ dry_run?: boolean;
257
+ repo?: string;
258
+ };
259
+ if (!typedParams.symbol_name && !typedParams.symbol_uid) {
260
+ return text('Provide either symbol_name or symbol_uid.');
261
+ }
262
+ let filePath: string | undefined;
263
+ if (typedParams.file_path) {
264
+ const safe = resolveFilePath(ctx, typedParams.file_path, typedParams.repo);
265
+ if (!safe) throw new Error('Invalid file path.');
266
+ filePath = safe;
267
+ }
268
+ const out = await mcpClient.callTool('rename', buildRepoArgs(ctx, {
269
+ ...(typedParams.symbol_name ? { symbol_name: typedParams.symbol_name } : {}),
270
+ ...(typedParams.symbol_uid ? { symbol_uid: typedParams.symbol_uid } : {}),
271
+ new_name: typedParams.new_name,
272
+ ...(filePath ? { file_path: filePath } : {}),
273
+ ...(typedParams.dry_run !== undefined ? { dry_run: typedParams.dry_run } : {}),
274
+ ...(typedParams.repo ? { repo: typedParams.repo } : {}),
275
+ }), ctx.cwd);
276
+ return text(out || 'No rename preview generated.');
277
+ },
278
+ });
279
+
280
+ pi.registerTool({
281
+ name: 'gitnexus_cypher',
282
+ label: 'GitNexus Cypher',
283
+ description: 'Execute a raw Cypher query against the code knowledge graph.',
284
+ parameters: Type.Object({
285
+ query: Type.String({ minLength: 1, maxLength: 10_000, pattern: '^[^-]' }),
286
+ repo: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
287
+ }),
288
+ execute: async (_id, params, _signal, _onUpdate, ctx) => {
289
+ if (!shouldAllowQuery(ctx, params as Record<string, unknown>)) return text(NO_INDEX);
290
+ const out = await mcpClient.callTool('cypher', buildRepoArgs(ctx, params as Record<string, unknown>), ctx.cwd);
291
+ return text(out || 'No results.');
292
+ },
293
+ });
294
+
295
+ }
@@ -0,0 +1,139 @@
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
+
7
+ import spawn from 'cross-spawn';
8
+ import type { GitNexusConfig } from '../gitnexus.js';
9
+ import { openSettingsMenu } from './settings-menu.js';
10
+
11
+ // ── Types ───────────────────────────────────────────────────────────────────
12
+
13
+ export type MenuUI = {
14
+ select(title: string, options: string[]): Promise<string | undefined>;
15
+ notify(message: string, type: 'info' | 'warning' | 'error'): void;
16
+ custom<T>(
17
+ factory: (tui: any, theme: any, keybindings: any, done: (result: T) => void) => any,
18
+ options?: { overlay?: boolean; overlayOptions?: any },
19
+ ): Promise<T>;
20
+ };
21
+
22
+ export interface MenuContext {
23
+ ui: MenuUI;
24
+ cwd: string;
25
+ cfg: GitNexusConfig;
26
+ state: { augmentEnabled: boolean };
27
+ binaryAvailable: boolean;
28
+ gitnexusCmd: string[];
29
+ spawnEnv: NodeJS.ProcessEnv;
30
+ getHookFires: () => number;
31
+ getAugmentHits: () => number;
32
+ findGitNexusIndex: (cwd: string) => boolean;
33
+ clearIndexCache: () => void;
34
+ setGitnexusCmd: (cmd: string[]) => void;
35
+ setAugmentTimeout: (seconds: number) => void;
36
+ syncState: () => void;
37
+ }
38
+
39
+ // ── Status ──────────────────────────────────────────────────────────────────
40
+
41
+ async function getStatusLine(mctx: MenuContext): Promise<string> {
42
+ if (!mctx.binaryAvailable) return 'gitnexus not installed';
43
+ if (!mctx.findGitNexusIndex(mctx.cwd)) return 'No index — run /gitnexus analyze';
44
+ const out = await new Promise<string>((resolve_) => {
45
+ let stdout = '';
46
+ const [bin, ...baseArgs] = mctx.gitnexusCmd;
47
+ const proc = spawn(bin, [...baseArgs, 'status'], {
48
+ cwd: mctx.cwd,
49
+ stdio: ['ignore', 'pipe', 'ignore'],
50
+ env: mctx.spawnEnv,
51
+ });
52
+ proc.stdout!.on('data', (chunk: { toString(): string }) => { stdout += chunk.toString(); });
53
+ proc.on('close', () => resolve_(stdout.trim()));
54
+ proc.on('error', () => resolve_(''));
55
+ });
56
+ const augmentLine = mctx.state.augmentEnabled
57
+ ? `Auto-augment: on (${mctx.getHookFires()} intercepted, ${mctx.getAugmentHits()} enriched)`
58
+ : 'Auto-augment: off';
59
+ return (out ? out + '\n' : '') + augmentLine;
60
+ }
61
+
62
+ // ── Analyze ─────────────────────────────────────────────────────────────────
63
+
64
+ async function runAnalyze(mctx: MenuContext): Promise<void> {
65
+ if (!mctx.binaryAvailable) {
66
+ mctx.ui.notify('gitnexus is not installed. Install: npm i -g gitnexus', 'warning');
67
+ return;
68
+ }
69
+ mctx.state.augmentEnabled = false;
70
+ mctx.syncState();
71
+ mctx.ui.notify('GitNexus: analyzing codebase, this may take a while…', 'info');
72
+ const exitCode = await new Promise<number | null>((resolve_) => {
73
+ const [bin, ...baseArgs] = mctx.gitnexusCmd;
74
+ const proc = spawn(bin, [...baseArgs, 'analyze'], {
75
+ cwd: mctx.cwd,
76
+ stdio: 'ignore',
77
+ env: mctx.spawnEnv,
78
+ });
79
+ proc.on('close', resolve_);
80
+ proc.on('error', () => resolve_(null));
81
+ });
82
+ if (exitCode === 0) {
83
+ mctx.clearIndexCache();
84
+ mctx.state.augmentEnabled = true;
85
+ mctx.syncState();
86
+ mctx.ui.notify('GitNexus: analysis complete. Knowledge graph ready.', 'info');
87
+ } else {
88
+ mctx.state.augmentEnabled = true;
89
+ mctx.syncState();
90
+ mctx.ui.notify('GitNexus: analysis failed. Check the terminal for details.', 'error');
91
+ }
92
+ }
93
+
94
+ // ── Help ────────────────────────────────────────────────────────────────────
95
+
96
+ function showHelp(mctx: MenuContext): void {
97
+ mctx.ui.notify(
98
+ 'Subcommands:\n' +
99
+ ' /gitnexus status — show index & augmentation stats\n' +
100
+ ' /gitnexus analyze — build/rebuild the knowledge graph\n' +
101
+ ' /gitnexus on|off — toggle auto-augment\n' +
102
+ ' /gitnexus <pattern> — manual graph lookup\n' +
103
+ ' /gitnexus query <q> — search execution flows\n' +
104
+ ' /gitnexus context <n> — callers/callees of a symbol\n' +
105
+ ' /gitnexus impact <n> — blast radius of a change',
106
+ 'info',
107
+ );
108
+ }
109
+
110
+ // ── Main menu ───────────────────────────────────────────────────────────────
111
+
112
+ export async function openMainMenu(mctx: MenuContext): Promise<void> {
113
+ const mainMenu = async (): Promise<void> => {
114
+ const statusLine = await getStatusLine(mctx);
115
+ const title = `GitNexus\n${statusLine}`;
116
+ const choices = [
117
+ 'Analyze',
118
+ 'Settings',
119
+ 'Help',
120
+ ];
121
+ const choice = await mctx.ui.select(title, choices);
122
+ if (!choice) return;
123
+ if (choice === 'Analyze') {
124
+ await runAnalyze(mctx);
125
+ return mainMenu();
126
+ }
127
+ if (choice === 'Settings') {
128
+ await openSettingsMenu(mctx.ui, mctx.cfg, mctx.state, async () => {
129
+ mctx.syncState();
130
+ });
131
+ return mainMenu();
132
+ }
133
+ if (choice === 'Help') {
134
+ showHelp(mctx);
135
+ return mainMenu();
136
+ }
137
+ };
138
+ await mainMenu();
139
+ }