gitnexushub 0.2.1

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.
@@ -0,0 +1,83 @@
1
+ /**
2
+ * OpenCode Editor Setup
3
+ *
4
+ * Writes MCP config to ~/.config/opencode/config.json (under `mcp` key)
5
+ * Installs skills to ~/.config/opencode/skill/
6
+ */
7
+ import os from 'os';
8
+ import path from 'path';
9
+ import fs from 'fs/promises';
10
+ import { readJsonFile, writeJsonFile } from '../utils.js';
11
+ import { HUB_SKILLS } from '../content.js';
12
+ function getMcpConfig(hubUrl, token) {
13
+ return {
14
+ type: 'streamable-http',
15
+ url: `${hubUrl}/mcp`,
16
+ headers: { Authorization: `Bearer ${token}` },
17
+ };
18
+ }
19
+ async function configure(hubUrl, token) {
20
+ const configPath = path.join(os.homedir(), '.config', 'opencode', 'config.json');
21
+ try {
22
+ const existing = (await readJsonFile(configPath)) || {};
23
+ if (!existing.mcp || typeof existing.mcp !== 'object') {
24
+ existing.mcp = {};
25
+ }
26
+ existing.mcp.gitnexus = getMcpConfig(hubUrl, token);
27
+ await writeJsonFile(configPath, existing);
28
+ return { success: true, message: 'MCP configured in ~/.config/opencode/config.json' };
29
+ }
30
+ catch (err) {
31
+ return { success: false, message: `Failed: ${err.message}` };
32
+ }
33
+ }
34
+ async function installSkills(skills) {
35
+ const skillsDir = path.join(os.homedir(), '.config', 'opencode', 'skill');
36
+ let installed = 0;
37
+ for (const skill of skills) {
38
+ try {
39
+ const skillDir = path.join(skillsDir, skill.name);
40
+ await fs.mkdir(skillDir, { recursive: true });
41
+ await fs.writeFile(path.join(skillDir, 'SKILL.md'), skill.content, 'utf-8');
42
+ installed++;
43
+ }
44
+ catch {
45
+ // Skip on error
46
+ }
47
+ }
48
+ return installed;
49
+ }
50
+ async function unconfigure() {
51
+ const configPath = path.join(os.homedir(), '.config', 'opencode', 'config.json');
52
+ try {
53
+ const existing = await readJsonFile(configPath);
54
+ if (existing?.mcp?.gitnexus) {
55
+ delete existing.mcp.gitnexus;
56
+ await writeJsonFile(configPath, existing);
57
+ }
58
+ return { success: true, message: 'MCP removed from ~/.config/opencode/config.json' };
59
+ }
60
+ catch (err) {
61
+ return { success: false, message: `Failed: ${err.message}` };
62
+ }
63
+ }
64
+ async function removeSkills() {
65
+ const skillsDir = path.join(os.homedir(), '.config', 'opencode', 'skill');
66
+ let removed = 0;
67
+ for (const name of HUB_SKILLS) {
68
+ try {
69
+ await fs.rm(path.join(skillsDir, name), { recursive: true, force: true });
70
+ removed++;
71
+ }
72
+ catch { /* already gone */ }
73
+ }
74
+ return removed;
75
+ }
76
+ export const opencodeEditor = {
77
+ id: 'opencode',
78
+ name: 'OpenCode',
79
+ configure,
80
+ unconfigure,
81
+ installSkills,
82
+ removeSkills,
83
+ };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Editor Types
3
+ */
4
+ export type EditorId = 'claude-code' | 'cursor' | 'windsurf' | 'opencode';
5
+ export interface EditorConfig {
6
+ id: EditorId;
7
+ name: string;
8
+ configure: (hubUrl: string, token: string) => Promise<ConfigureResult>;
9
+ unconfigure: () => Promise<ConfigureResult>;
10
+ installSkills?: (skills: Array<{
11
+ name: string;
12
+ content: string;
13
+ }>) => Promise<number>;
14
+ removeSkills?: () => Promise<number>;
15
+ }
16
+ export interface ConfigureResult {
17
+ success: boolean;
18
+ message: string;
19
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Editor Types
3
+ */
4
+ export {};
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Windsurf Editor Setup
3
+ *
4
+ * Writes MCP config to ~/.codeium/windsurf/mcp_config.json
5
+ * Installs skills to ~/.codeium/windsurf/skills/
6
+ */
7
+ import type { EditorConfig } from './types.js';
8
+ export declare const windsurfEditor: EditorConfig;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Windsurf Editor Setup
3
+ *
4
+ * Writes MCP config to ~/.codeium/windsurf/mcp_config.json
5
+ * Installs skills to ~/.codeium/windsurf/skills/
6
+ */
7
+ import os from 'os';
8
+ import path from 'path';
9
+ import fs from 'fs/promises';
10
+ import { readJsonFile, writeJsonFile } from '../utils.js';
11
+ import { HUB_SKILLS } from '../content.js';
12
+ function getMcpConfig(hubUrl, token) {
13
+ return {
14
+ type: 'streamable-http',
15
+ url: `${hubUrl}/mcp`,
16
+ headers: { Authorization: `Bearer ${token}` },
17
+ };
18
+ }
19
+ async function configure(hubUrl, token) {
20
+ const mcpPath = path.join(os.homedir(), '.codeium', 'windsurf', 'mcp_config.json');
21
+ try {
22
+ const existing = (await readJsonFile(mcpPath)) || {};
23
+ if (!existing.mcpServers || typeof existing.mcpServers !== 'object') {
24
+ existing.mcpServers = {};
25
+ }
26
+ existing.mcpServers.gitnexus = getMcpConfig(hubUrl, token);
27
+ await writeJsonFile(mcpPath, existing);
28
+ return { success: true, message: 'MCP configured in ~/.codeium/windsurf/mcp_config.json' };
29
+ }
30
+ catch (err) {
31
+ return { success: false, message: `Failed: ${err.message}` };
32
+ }
33
+ }
34
+ async function installSkills(skills) {
35
+ const skillsDir = path.join(os.homedir(), '.codeium', 'windsurf', 'skills');
36
+ let installed = 0;
37
+ for (const skill of skills) {
38
+ try {
39
+ const skillDir = path.join(skillsDir, skill.name);
40
+ await fs.mkdir(skillDir, { recursive: true });
41
+ await fs.writeFile(path.join(skillDir, 'SKILL.md'), skill.content, 'utf-8');
42
+ installed++;
43
+ }
44
+ catch {
45
+ // Skip on error
46
+ }
47
+ }
48
+ return installed;
49
+ }
50
+ async function unconfigure() {
51
+ const mcpPath = path.join(os.homedir(), '.codeium', 'windsurf', 'mcp_config.json');
52
+ try {
53
+ const existing = await readJsonFile(mcpPath);
54
+ if (existing?.mcpServers?.gitnexus) {
55
+ delete existing.mcpServers.gitnexus;
56
+ await writeJsonFile(mcpPath, existing);
57
+ }
58
+ return { success: true, message: 'MCP removed from ~/.codeium/windsurf/mcp_config.json' };
59
+ }
60
+ catch (err) {
61
+ return { success: false, message: `Failed: ${err.message}` };
62
+ }
63
+ }
64
+ async function removeSkills() {
65
+ const skillsDir = path.join(os.homedir(), '.codeium', 'windsurf', 'skills');
66
+ let removed = 0;
67
+ for (const name of HUB_SKILLS) {
68
+ try {
69
+ await fs.rm(path.join(skillsDir, name), { recursive: true, force: true });
70
+ removed++;
71
+ }
72
+ catch { /* already gone */ }
73
+ }
74
+ return removed;
75
+ }
76
+ export const windsurfEditor = {
77
+ id: 'windsurf',
78
+ name: 'Windsurf',
79
+ configure,
80
+ unconfigure,
81
+ installSkills,
82
+ removeSkills,
83
+ };
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @gitnexus/connect — Enterprise Editor Setup
4
+ *
5
+ * Commands:
6
+ * (default) Register Hub MCP, write CLAUDE.md / AGENTS.md, install skills
7
+ * disconnect Remove all GitNexus config, skills, and project files
8
+ * index Trigger indexing for a GitHub repo on the Hub
9
+ */
10
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,397 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @gitnexus/connect — Enterprise Editor Setup
4
+ *
5
+ * Commands:
6
+ * (default) Register Hub MCP, write CLAUDE.md / AGENTS.md, install skills
7
+ * disconnect Remove all GitNexus config, skills, and project files
8
+ * index Trigger indexing for a GitHub repo on the Hub
9
+ */
10
+ import { Command } from 'commander';
11
+ import pc from 'picocolors';
12
+ import { loadConfig, saveConfig, clearConfig } from './config.js';
13
+ import { HubAPI } from './api.js';
14
+ import { isGitRepo, getGitRemoteUrl, parseGitRemote, matchRepo } from './project.js';
15
+ import { writeProjectContext, removeProjectContext } from './context.js';
16
+ import { generateConnectContext } from './content.js';
17
+ import { detectInstalledEditors } from './editors/detect.js';
18
+ import { cursorEditor } from './editors/cursor.js';
19
+ import { windsurfEditor } from './editors/windsurf.js';
20
+ import { opencodeEditor } from './editors/opencode.js';
21
+ import { claudeCodeEditor } from './editors/claude-code.js';
22
+ const DEFAULT_HUB_URL = 'https://gitnexus-enterprise-production.up.railway.app';
23
+ const EDITORS = {
24
+ 'claude-code': claudeCodeEditor,
25
+ cursor: cursorEditor,
26
+ windsurf: windsurfEditor,
27
+ opencode: opencodeEditor,
28
+ };
29
+ const BANNER = [
30
+ ' ██████╗ ██╗████████╗███╗ ██╗███████╗██╗ ██╗██╗ ██╗███████╗',
31
+ '██╔════╝ ██║╚══██╔══╝████╗ ██║██╔════╝╚██╗██╔╝██║ ██║██╔════╝',
32
+ '██║ ███╗██║ ██║ ██╔██╗ ██║█████╗ ╚███╔╝ ██║ ██║███████╗',
33
+ '██║ ██║██║ ██║ ██║╚██╗██║██╔══╝ ██╔██╗ ██║ ██║╚════██║',
34
+ '╚██████╔╝██║ ██║ ██║ ╚████║███████╗██╔╝ ██╗╚██████╔╝███████║',
35
+ ' ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═══╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝',
36
+ ];
37
+ const ok = (msg) => console.log(` ${pc.green('✔')} ${msg}`);
38
+ const info = (msg) => console.log(` ${pc.cyan('ℹ')} ${msg}`);
39
+ const warn = (msg) => console.log(` ${pc.yellow('⚠')} ${msg}`);
40
+ const fail = (msg) => console.error(` ${pc.red('✗')} ${msg}`);
41
+ function printBanner() {
42
+ console.log('');
43
+ for (const line of BANNER) {
44
+ console.log(` ${pc.cyan(line)}`);
45
+ }
46
+ console.log('');
47
+ console.log(` ${pc.dim('Plug into the living brain of your codebase')}`);
48
+ console.log('');
49
+ }
50
+ /**
51
+ * Resolve token + Hub URL from args/config. Exits on failure.
52
+ */
53
+ async function resolveAuth(tokenArg, hubOpt) {
54
+ const config = await loadConfig();
55
+ const token = tokenArg || config.hubToken;
56
+ const hubUrl = hubOpt || config.hubUrl || DEFAULT_HUB_URL;
57
+ if (!token) {
58
+ fail('No API token provided.');
59
+ console.error('');
60
+ console.error(` Usage: ${pc.cyan('npx gitnexushub gnx_YOUR_TOKEN --editor cursor')}`);
61
+ console.error(` Generate a token at: ${pc.cyan(hubUrl + '/settings/tokens')}`);
62
+ console.error('');
63
+ process.exit(1);
64
+ }
65
+ if (!token.startsWith('gnx_')) {
66
+ fail(`Invalid token format. Tokens must start with ${pc.bold('gnx_')}`);
67
+ console.error('');
68
+ process.exit(1);
69
+ }
70
+ const api = new HubAPI(hubUrl, token);
71
+ const user = await api.getMe().catch((err) => {
72
+ fail(`Authentication failed: ${err.message}`);
73
+ console.error(` Check your token and try again.`);
74
+ console.error('');
75
+ process.exit(1);
76
+ });
77
+ ok(`Hello, ${pc.bold(user.name)}!`);
78
+ console.log('');
79
+ await saveConfig({ hubToken: token, hubUrl });
80
+ return { api, hubUrl, token };
81
+ }
82
+ // ─── CLI Setup ────────────────────────────────────────────────────
83
+ const program = new Command();
84
+ program
85
+ .name('gnx')
86
+ .description('Connect your editor to GitNexus Hub')
87
+ .version('0.2.0');
88
+ // ─── Default command: connect ─────────────────────────────────────
89
+ const connectOpts = (cmd) => cmd
90
+ .argument('[token]', 'gnx_ API token (optional if already saved)')
91
+ .option('--editor <name>', 'Editor to configure: claude-code | cursor | windsurf | opencode')
92
+ .option('--hub <url>', 'Hub URL', DEFAULT_HUB_URL)
93
+ .option('--skip-project', 'Only configure MCP, skip project files');
94
+ const connectAction = async (tokenArg, opts) => {
95
+ try {
96
+ printBanner();
97
+ await runConnect(tokenArg, opts);
98
+ }
99
+ catch (err) {
100
+ console.error('');
101
+ fail(err.message);
102
+ console.error('');
103
+ process.exit(1);
104
+ }
105
+ };
106
+ // `gnx <token>` (default)
107
+ connectOpts(program).action(connectAction);
108
+ // `gnx connect <token>` (explicit)
109
+ connectOpts(program.command('connect').description('Register Hub MCP, install skills, and write project files')).action(connectAction);
110
+ // ─── disconnect command ───────────────────────────────────────────
111
+ program
112
+ .command('disconnect')
113
+ .description('Remove GitNexus MCP config, skills, and project files')
114
+ .option('--editor <name>', 'Editor to unconfigure: claude-code | cursor | windsurf | opencode')
115
+ .action(async (opts) => {
116
+ try {
117
+ printBanner();
118
+ await runDisconnect(opts);
119
+ }
120
+ catch (err) {
121
+ console.error('');
122
+ fail(err.message);
123
+ console.error('');
124
+ process.exit(1);
125
+ }
126
+ });
127
+ // ─── index command ────────────────────────────────────────────────
128
+ program
129
+ .command('index <repo>')
130
+ .description('Index a GitHub repo (owner/repo or URL)')
131
+ .option('--hub <url>', 'Hub URL', DEFAULT_HUB_URL)
132
+ .option('--token <token>', 'gnx_ API token (optional if already saved)')
133
+ .option('--wait', 'Wait for indexing to complete', false)
134
+ .action(async (repo, opts) => {
135
+ try {
136
+ printBanner();
137
+ await runIndex(repo, opts);
138
+ }
139
+ catch (err) {
140
+ console.error('');
141
+ fail(err.message);
142
+ console.error('');
143
+ process.exit(1);
144
+ }
145
+ });
146
+ program.parse();
147
+ // ─── Connect Flow ─────────────────────────────────────────────────
148
+ async function runConnect(tokenArg, opts) {
149
+ const { api, hubUrl, token } = await resolveAuth(tokenArg, opts.hub);
150
+ // ── Resolve editor ─────────────────────────────────────────────
151
+ const config = await loadConfig();
152
+ let editorId;
153
+ if (opts.editor) {
154
+ if (!(opts.editor in EDITORS)) {
155
+ fail(`Unknown editor: ${pc.bold(opts.editor)}`);
156
+ console.error(` Supported: ${pc.cyan(Object.keys(EDITORS).join(', '))}`);
157
+ console.error('');
158
+ return process.exit(1);
159
+ }
160
+ editorId = opts.editor;
161
+ }
162
+ else if (!config.hubToken) {
163
+ const detected = await detectInstalledEditors();
164
+ if (detected.length === 1) {
165
+ editorId = detected[0];
166
+ info(`Auto-detected editor: ${pc.bold(EDITORS[editorId].name)}`);
167
+ }
168
+ else if (detected.length > 1) {
169
+ warn('Multiple editors detected. Please specify one:');
170
+ for (const id of detected) {
171
+ console.error(` ${pc.cyan('--editor ' + id)}`);
172
+ }
173
+ console.error('');
174
+ return process.exit(1);
175
+ }
176
+ else {
177
+ fail('No editor detected. Please specify one:');
178
+ console.error(` ${pc.cyan('--editor claude-code | cursor | windsurf | opencode')}`);
179
+ console.error('');
180
+ return process.exit(1);
181
+ }
182
+ }
183
+ // ── Configure editor MCP ────────────────────────────────────────
184
+ let skills = [];
185
+ if (editorId) {
186
+ const editor = EDITORS[editorId];
187
+ info(`Configuring ${pc.bold(editor.name)}...`);
188
+ const result = await editor.configure(hubUrl, token);
189
+ if (result.success) {
190
+ ok(result.message);
191
+ }
192
+ else {
193
+ fail(result.message);
194
+ }
195
+ try {
196
+ const bundled = await generateConnectContext('_', {});
197
+ skills = bundled.skills;
198
+ }
199
+ catch {
200
+ // Skills load failed — continue without
201
+ }
202
+ if (editor.installSkills && skills.length > 0) {
203
+ const count = await editor.installSkills(skills);
204
+ if (count > 0) {
205
+ ok(`${count} skills installed`);
206
+ }
207
+ }
208
+ }
209
+ // ── Write project context ───────────────────────────────────────
210
+ if (!opts.skipProject && isGitRepo()) {
211
+ const remoteUrl = getGitRemoteUrl();
212
+ const remoteFullName = remoteUrl ? parseGitRemote(remoteUrl) : null;
213
+ if (remoteFullName) {
214
+ console.log('');
215
+ info(`Project: ${pc.bold(remoteFullName)}`);
216
+ try {
217
+ const repos = await api.listRepos();
218
+ const matched = matchRepo(remoteFullName, repos);
219
+ if (matched) {
220
+ if (matched.status === 'ready') {
221
+ const ctx = await generateConnectContext(matched.fullName, matched.stats || {});
222
+ const result = await writeProjectContext(process.cwd(), ctx);
223
+ for (const file of result.files) {
224
+ ok(file);
225
+ }
226
+ }
227
+ else {
228
+ warn(`Repo status: ${pc.yellow(matched.status)} ${pc.dim('(waiting for indexing)')}`);
229
+ }
230
+ }
231
+ else {
232
+ warn('Repo not indexed on Hub yet');
233
+ console.log(` Add it at: ${pc.cyan(hubUrl)}`);
234
+ }
235
+ }
236
+ catch (err) {
237
+ fail(`Failed to fetch project context: ${err.message}`);
238
+ }
239
+ }
240
+ else {
241
+ console.log('');
242
+ warn('No GitHub remote found — skipping project context');
243
+ }
244
+ }
245
+ else if (!opts.skipProject) {
246
+ console.log('');
247
+ info('Not inside a git repo — skipping project context');
248
+ }
249
+ // ── Summary ────────────────────────────────────────────────────
250
+ console.log('');
251
+ console.log(` ${pc.green('✔')} ${pc.bold('Done!')} Open your editor — GitNexus MCP is ready.`);
252
+ console.log('');
253
+ }
254
+ // ─── Disconnect Flow ──────────────────────────────────────────────
255
+ async function runDisconnect(opts) {
256
+ // ── Resolve which editors to clean ─────────────────────────────
257
+ let editorIds;
258
+ if (opts.editor) {
259
+ if (!(opts.editor in EDITORS)) {
260
+ fail(`Unknown editor: ${pc.bold(opts.editor)}`);
261
+ console.error(` Supported: ${pc.cyan(Object.keys(EDITORS).join(', '))}`);
262
+ console.error('');
263
+ return process.exit(1);
264
+ }
265
+ editorIds = [opts.editor];
266
+ }
267
+ else {
268
+ // Clean all editors
269
+ editorIds = Object.keys(EDITORS);
270
+ }
271
+ // ── Remove MCP config and skills from each editor ──────────────
272
+ for (const id of editorIds) {
273
+ const editor = EDITORS[id];
274
+ const result = await editor.unconfigure();
275
+ if (result.success) {
276
+ ok(result.message);
277
+ }
278
+ else {
279
+ warn(`${editor.name}: ${result.message}`);
280
+ }
281
+ if (editor.removeSkills) {
282
+ const count = await editor.removeSkills();
283
+ if (count > 0) {
284
+ ok(`${count} skills removed from ${editor.name}`);
285
+ }
286
+ }
287
+ }
288
+ // ── Remove project-level files ─────────────────────────────────
289
+ if (isGitRepo()) {
290
+ const removed = await removeProjectContext(process.cwd());
291
+ for (const file of removed) {
292
+ ok(`Removed ${file}`);
293
+ }
294
+ }
295
+ // ── Clear saved config ─────────────────────────────────────────
296
+ await clearConfig();
297
+ ok('Cleared ~/.gitnexus/config.json');
298
+ console.log('');
299
+ console.log(` ${pc.green('✔')} ${pc.bold('GitNexus fully disconnected.')}`);
300
+ console.log('');
301
+ }
302
+ // ─── Index Flow ───────────────────────────────────────────────────
303
+ async function runIndex(repo, opts) {
304
+ const { api } = await resolveAuth(opts.token, opts.hub);
305
+ // Normalize: accept URLs or owner/repo
306
+ const fullName = repo
307
+ .replace(/^https?:\/\/github\.com\//, '')
308
+ .replace(/\.git\/?$/, '')
309
+ .replace(/\/+$/, '');
310
+ if (!fullName.includes('/')) {
311
+ fail(`Invalid repo format. Use ${pc.cyan('owner/repo')} or a GitHub URL.`);
312
+ console.error('');
313
+ process.exit(1);
314
+ }
315
+ info(`Indexing ${pc.bold(fullName)}...`);
316
+ let result;
317
+ try {
318
+ result = await api.indexRepo(fullName);
319
+ ok(`Repository added ${pc.dim(`(id: ${result.id})`)}`);
320
+ info(`Status: ${pc.yellow(result.status)}`);
321
+ }
322
+ catch (err) {
323
+ const msg = err.message || '';
324
+ // Already added — look it up instead of failing
325
+ if (msg.includes('already added')) {
326
+ const repos = await api.listRepos();
327
+ const existing = repos.find(r => r.fullName === fullName);
328
+ if (existing) {
329
+ if (existing.status === 'ready') {
330
+ ok(`${pc.bold(fullName)} is already indexed and ready.`);
331
+ const stats = existing.stats || {};
332
+ ok(`${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} processes`);
333
+ console.log('');
334
+ console.log(` ${pc.green('✔')} ${pc.bold('Done!')} Run ${pc.cyan('npx gitnexushub --editor claude-code')} to connect.`);
335
+ console.log('');
336
+ return;
337
+ }
338
+ info(`${pc.bold(fullName)} already added — status: ${pc.yellow(existing.status)}`);
339
+ result = { id: existing.id, status: existing.status };
340
+ }
341
+ else {
342
+ throw err;
343
+ }
344
+ }
345
+ else {
346
+ throw err;
347
+ }
348
+ }
349
+ if (!opts.wait) {
350
+ console.log('');
351
+ info(`Indexing started in the background.`);
352
+ console.log(` Run ${pc.cyan(`npx gitnexushub index ${fullName} --wait`)} to follow progress.`);
353
+ console.log('');
354
+ return;
355
+ }
356
+ // ── Poll for completion ─────────────────────────────────────────
357
+ console.log('');
358
+ const repoId = result.id;
359
+ const spinFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
360
+ let frame = 0;
361
+ while (true) {
362
+ await new Promise(r => setTimeout(r, 2000));
363
+ try {
364
+ const detail = await api.getRepo(repoId);
365
+ const spinner = pc.cyan(spinFrames[frame % spinFrames.length]);
366
+ frame++;
367
+ if (detail.status === 'ready') {
368
+ const stats = detail.stats || {};
369
+ ok(`Indexing complete!`);
370
+ ok(`${pc.bold(fullName)}: ${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} processes`);
371
+ console.log('');
372
+ console.log(` ${pc.green('✔')} ${pc.bold('Done!')} Run ${pc.cyan('npx gitnexushub --editor claude-code')} to connect.`);
373
+ console.log('');
374
+ return;
375
+ }
376
+ if (detail.status === 'failed') {
377
+ fail(`Indexing failed: ${detail.error || 'unknown error'}`);
378
+ console.error('');
379
+ process.exit(1);
380
+ }
381
+ // Show progress
382
+ const phase = detail.job?.phase || detail.status;
383
+ const progress = detail.job?.progress ?? 0;
384
+ const bar = renderProgressBar(progress);
385
+ process.stdout.write(`\r ${spinner} ${pc.dim(phase)} ${bar} ${progress}% `);
386
+ }
387
+ catch {
388
+ // Network hiccup — retry silently
389
+ }
390
+ }
391
+ }
392
+ function renderProgressBar(percent) {
393
+ const width = 20;
394
+ const filled = Math.round((percent / 100) * width);
395
+ const empty = width - filled;
396
+ return pc.green('█'.repeat(filled)) + pc.dim('░'.repeat(empty));
397
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Project Detection
3
+ *
4
+ * Detects git repos, parses remote URLs, and matches against Hub repos.
5
+ */
6
+ import type { HubRepo } from './api.js';
7
+ export declare function isGitRepo(): boolean;
8
+ export declare function getGitRemoteUrl(): string | null;
9
+ /**
10
+ * Parse owner/repo from a git remote URL.
11
+ *
12
+ * Supports:
13
+ * https://github.com/owner/repo.git
14
+ * git@github.com:owner/repo.git
15
+ * https://github.com/owner/repo
16
+ */
17
+ export declare function parseGitRemote(url: string): string | null;
18
+ /**
19
+ * Match a local git remote against Hub repos.
20
+ */
21
+ export declare function matchRepo(remoteFullName: string, hubRepos: HubRepo[]): HubRepo | null;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Project Detection
3
+ *
4
+ * Detects git repos, parses remote URLs, and matches against Hub repos.
5
+ */
6
+ import { execSync } from 'child_process';
7
+ export function isGitRepo() {
8
+ try {
9
+ execSync('git rev-parse --is-inside-work-tree', { stdio: 'pipe' });
10
+ return true;
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ export function getGitRemoteUrl() {
17
+ try {
18
+ return execSync('git remote get-url origin', { stdio: 'pipe', encoding: 'utf-8' }).trim();
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ /**
25
+ * Parse owner/repo from a git remote URL.
26
+ *
27
+ * Supports:
28
+ * https://github.com/owner/repo.git
29
+ * git@github.com:owner/repo.git
30
+ * https://github.com/owner/repo
31
+ */
32
+ export function parseGitRemote(url) {
33
+ // HTTPS format
34
+ const httpsMatch = url.match(/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/);
35
+ if (httpsMatch)
36
+ return httpsMatch[1];
37
+ // SSH format
38
+ const sshMatch = url.match(/github\.com:([^/]+\/[^/]+?)(?:\.git)?$/);
39
+ if (sshMatch)
40
+ return sshMatch[1];
41
+ return null;
42
+ }
43
+ /**
44
+ * Match a local git remote against Hub repos.
45
+ */
46
+ export function matchRepo(remoteFullName, hubRepos) {
47
+ return hubRepos.find(r => r.fullName === remoteFullName) || null;
48
+ }