@agi-cli/server 0.1.55

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,736 @@
1
+ import type { Hono } from 'hono';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import { extname } from 'node:path';
5
+ import { z } from 'zod';
6
+ import { generateText, resolveModel } from '@agi-cli/sdk';
7
+ import { loadConfig } from '@agi-cli/sdk';
8
+
9
+ const execFileAsync = promisify(execFile);
10
+
11
+ // Validation schemas - make project optional with default
12
+ const gitStatusSchema = z.object({
13
+ project: z.string().optional(),
14
+ });
15
+
16
+ const gitDiffSchema = z.object({
17
+ project: z.string().optional(),
18
+ file: z.string(),
19
+ staged: z
20
+ .string()
21
+ .optional()
22
+ .transform((val) => val === 'true'),
23
+ });
24
+
25
+ const gitStageSchema = z.object({
26
+ project: z.string().optional(),
27
+ files: z.array(z.string()),
28
+ });
29
+
30
+ const gitUnstageSchema = z.object({
31
+ project: z.string().optional(),
32
+ files: z.array(z.string()),
33
+ });
34
+
35
+ const gitCommitSchema = z.object({
36
+ project: z.string().optional(),
37
+ message: z.string().min(1),
38
+ });
39
+
40
+ const gitGenerateCommitMessageSchema = z.object({
41
+ project: z.string().optional(),
42
+ });
43
+
44
+ // Types
45
+ export interface GitFile {
46
+ path: string;
47
+ status: 'modified' | 'added' | 'deleted' | 'renamed' | 'untracked';
48
+ staged: boolean;
49
+ insertions?: number;
50
+ deletions?: number;
51
+ oldPath?: string;
52
+ }
53
+
54
+ export interface GitStatus {
55
+ branch: string;
56
+ ahead: number;
57
+ behind: number;
58
+ staged: GitFile[];
59
+ unstaged: GitFile[];
60
+ untracked: GitFile[];
61
+ hasChanges: boolean;
62
+ }
63
+
64
+ // File extension to language mapping
65
+ const languageMap: Record<string, string> = {
66
+ '.ts': 'typescript',
67
+ '.tsx': 'typescript',
68
+ '.js': 'javascript',
69
+ '.jsx': 'javascript',
70
+ '.py': 'python',
71
+ '.java': 'java',
72
+ '.c': 'c',
73
+ '.cpp': 'cpp',
74
+ '.go': 'go',
75
+ '.rs': 'rust',
76
+ '.rb': 'ruby',
77
+ '.php': 'php',
78
+ '.css': 'css',
79
+ '.html': 'html',
80
+ '.json': 'json',
81
+ '.xml': 'xml',
82
+ '.yaml': 'yaml',
83
+ '.yml': 'yaml',
84
+ '.md': 'markdown',
85
+ '.sh': 'bash',
86
+ };
87
+
88
+ function detectLanguage(filePath: string): string {
89
+ const ext = extname(filePath).toLowerCase();
90
+ return languageMap[ext] || 'plaintext';
91
+ }
92
+
93
+ // Helper function to find git root directory
94
+ async function findGitRoot(startPath: string): Promise<string> {
95
+ try {
96
+ const { stdout } = await execFileAsync(
97
+ 'git',
98
+ ['rev-parse', '--show-toplevel'],
99
+ { cwd: startPath },
100
+ );
101
+ return stdout.trim();
102
+ } catch (_error) {
103
+ // If not in a git repository, return the original path
104
+ return startPath;
105
+ }
106
+ }
107
+
108
+ // Git status parsing
109
+ function parseGitStatus(porcelainOutput: string): {
110
+ staged: GitFile[];
111
+ unstaged: GitFile[];
112
+ untracked: GitFile[];
113
+ } {
114
+ const staged: GitFile[] = [];
115
+ const unstaged: GitFile[] = [];
116
+ const untracked: GitFile[] = [];
117
+
118
+ const lines = porcelainOutput.split('\n').filter((line) => line.trim());
119
+
120
+ for (const line of lines) {
121
+ if (line.length < 4) continue;
122
+
123
+ const stagedStatus = line[0];
124
+ const unstagedStatus = line[1];
125
+ const filePath = line.slice(3);
126
+
127
+ // Parse staged files
128
+ if (stagedStatus !== ' ' && stagedStatus !== '?') {
129
+ let status: GitFile['status'] = 'modified';
130
+ if (stagedStatus === 'A') status = 'added';
131
+ else if (stagedStatus === 'D') status = 'deleted';
132
+ else if (stagedStatus === 'R') status = 'renamed';
133
+ else if (stagedStatus === 'M') status = 'modified';
134
+
135
+ staged.push({
136
+ path: filePath,
137
+ status,
138
+ staged: true,
139
+ });
140
+ }
141
+
142
+ // Parse unstaged files
143
+ // NOTE: A file can appear in both staged and unstaged if it has staged changes
144
+ // and additional working directory changes
145
+ if (unstagedStatus !== ' ' && unstagedStatus !== '?') {
146
+ let status: GitFile['status'] = 'modified';
147
+ if (unstagedStatus === 'M') status = 'modified';
148
+ else if (unstagedStatus === 'D') status = 'deleted';
149
+
150
+ unstaged.push({
151
+ path: filePath,
152
+ status,
153
+ staged: false,
154
+ });
155
+ }
156
+
157
+ // Parse untracked files
158
+ if (stagedStatus === '?' && unstagedStatus === '?') {
159
+ untracked.push({
160
+ path: filePath,
161
+ status: 'untracked',
162
+ staged: false,
163
+ });
164
+ }
165
+ }
166
+
167
+ return { staged, unstaged, untracked };
168
+ }
169
+
170
+ async function parseNumstat(
171
+ numstatOutput: string,
172
+ files: GitFile[],
173
+ ): Promise<void> {
174
+ const lines = numstatOutput.split('\n').filter((line) => line.trim());
175
+
176
+ for (const line of lines) {
177
+ const parts = line.split('\t');
178
+ if (parts.length < 3) continue;
179
+
180
+ const insertions = Number.parseInt(parts[0], 10);
181
+ const deletions = Number.parseInt(parts[1], 10);
182
+ const filePath = parts[2];
183
+
184
+ const file = files.find((f) => f.path === filePath);
185
+ if (file) {
186
+ file.insertions = Number.isNaN(insertions) ? 0 : insertions;
187
+ file.deletions = Number.isNaN(deletions) ? 0 : deletions;
188
+ }
189
+ }
190
+ }
191
+
192
+ async function getCurrentBranch(cwd: string): Promise<string> {
193
+ try {
194
+ const { stdout } = await execFileAsync(
195
+ 'git',
196
+ ['branch', '--show-current'],
197
+ { cwd },
198
+ );
199
+ return stdout.trim() || 'HEAD';
200
+ } catch {
201
+ return 'HEAD';
202
+ }
203
+ }
204
+
205
+ async function getAheadBehind(
206
+ cwd: string,
207
+ ): Promise<{ ahead: number; behind: number }> {
208
+ try {
209
+ const { stdout } = await execFileAsync(
210
+ 'git',
211
+ ['rev-list', '--left-right', '--count', 'HEAD...@{u}'],
212
+ { cwd },
213
+ );
214
+ const parts = stdout.trim().split('\t');
215
+ return {
216
+ ahead: Number.parseInt(parts[0], 10) || 0,
217
+ behind: Number.parseInt(parts[1], 10) || 0,
218
+ };
219
+ } catch {
220
+ return { ahead: 0, behind: 0 };
221
+ }
222
+ }
223
+
224
+ // Route handlers
225
+ export function registerGitRoutes(app: Hono) {
226
+ // GET /v1/git/status - Get current git status
227
+ app.get('/v1/git/status', async (c) => {
228
+ try {
229
+ const query = gitStatusSchema.parse({
230
+ project: c.req.query('project'),
231
+ });
232
+
233
+ const requestedPath = query.project || process.cwd();
234
+ const gitRoot = await findGitRoot(requestedPath);
235
+
236
+ // Get git status
237
+ const { stdout: statusOutput } = await execFileAsync(
238
+ 'git',
239
+ ['status', '--porcelain=v1'],
240
+ { cwd: gitRoot },
241
+ );
242
+
243
+ const { staged, unstaged, untracked } = parseGitStatus(statusOutput);
244
+
245
+ // Get stats for staged files
246
+ if (staged.length > 0) {
247
+ try {
248
+ const { stdout: stagedNumstat } = await execFileAsync(
249
+ 'git',
250
+ ['diff', '--cached', '--numstat'],
251
+ { cwd: gitRoot },
252
+ );
253
+ await parseNumstat(stagedNumstat, staged);
254
+ } catch {
255
+ // Ignore numstat errors
256
+ }
257
+ }
258
+
259
+ // Get stats for unstaged files
260
+ if (unstaged.length > 0) {
261
+ try {
262
+ const { stdout: unstagedNumstat } = await execFileAsync(
263
+ 'git',
264
+ ['diff', '--numstat'],
265
+ { cwd: gitRoot },
266
+ );
267
+ await parseNumstat(unstagedNumstat, unstaged);
268
+ } catch {
269
+ // Ignore numstat errors
270
+ }
271
+ }
272
+
273
+ // Get branch info
274
+ const branch = await getCurrentBranch(gitRoot);
275
+ const { ahead, behind } = await getAheadBehind(gitRoot);
276
+
277
+ const status: GitStatus = {
278
+ branch,
279
+ ahead,
280
+ behind,
281
+ staged,
282
+ unstaged,
283
+ untracked,
284
+ hasChanges:
285
+ staged.length > 0 || unstaged.length > 0 || untracked.length > 0,
286
+ };
287
+
288
+ return c.json({
289
+ status: 'ok',
290
+ data: status,
291
+ });
292
+ } catch (error) {
293
+ console.error('Git status error:', error);
294
+ return c.json(
295
+ {
296
+ status: 'error',
297
+ error:
298
+ error instanceof Error ? error.message : 'Failed to get git status',
299
+ },
300
+ 500,
301
+ );
302
+ }
303
+ });
304
+
305
+ // GET /v1/git/diff - Get diff for a specific file
306
+ app.get('/v1/git/diff', async (c) => {
307
+ try {
308
+ const query = gitDiffSchema.parse({
309
+ project: c.req.query('project'),
310
+ file: c.req.query('file'),
311
+ staged: c.req.query('staged'),
312
+ });
313
+
314
+ const requestedPath = query.project || process.cwd();
315
+ const gitRoot = await findGitRoot(requestedPath);
316
+ const file = query.file;
317
+ const staged = query.staged;
318
+
319
+ // Check if file is untracked (new file)
320
+ const { stdout: statusOutput } = await execFileAsync(
321
+ 'git',
322
+ ['status', '--porcelain=v1', file],
323
+ { cwd: gitRoot },
324
+ );
325
+
326
+ const isUntracked = statusOutput.trim().startsWith('??');
327
+
328
+ let diffOutput = '';
329
+ let insertions = 0;
330
+ let deletions = 0;
331
+
332
+ if (isUntracked) {
333
+ // For untracked files, show the entire file content as additions
334
+ try {
335
+ const { readFile } = await import('node:fs/promises');
336
+ const { join } = await import('node:path');
337
+ const filePath = join(gitRoot, file);
338
+ const content = await readFile(filePath, 'utf-8');
339
+ const lines = content.split('\n');
340
+
341
+ // Create a diff-like output showing all lines as additions
342
+ diffOutput = `diff --git a/${file} b/${file}\n`;
343
+ diffOutput += `new file mode 100644\n`;
344
+ diffOutput += `--- /dev/null\n`;
345
+ diffOutput += `+++ b/${file}\n`;
346
+ diffOutput += `@@ -0,0 +1,${lines.length} @@\n`;
347
+ diffOutput += lines.map((line) => `+${line}`).join('\n');
348
+
349
+ insertions = lines.length;
350
+ deletions = 0;
351
+ } catch (err) {
352
+ console.error('Error reading new file:', err);
353
+ diffOutput = `Error reading file: ${err instanceof Error ? err.message : 'Unknown error'}`;
354
+ }
355
+ } else {
356
+ // For tracked files, use git diff
357
+ const args = ['diff'];
358
+ if (staged) {
359
+ args.push('--cached');
360
+ }
361
+ args.push('--', file);
362
+
363
+ const { stdout: gitDiff } = await execFileAsync('git', args, {
364
+ cwd: gitRoot,
365
+ });
366
+ diffOutput = gitDiff;
367
+
368
+ // Get stats
369
+ const numstatArgs = ['diff', '--numstat'];
370
+ if (staged) {
371
+ numstatArgs.push('--cached');
372
+ }
373
+ numstatArgs.push('--', file);
374
+
375
+ try {
376
+ const { stdout: numstatOutput } = await execFileAsync(
377
+ 'git',
378
+ numstatArgs,
379
+ { cwd: gitRoot },
380
+ );
381
+ const parts = numstatOutput.trim().split('\t');
382
+ if (parts.length >= 2) {
383
+ insertions = Number.parseInt(parts[0], 10) || 0;
384
+ deletions = Number.parseInt(parts[1], 10) || 0;
385
+ }
386
+ } catch {
387
+ // Ignore numstat errors
388
+ }
389
+ }
390
+
391
+ // Check if binary
392
+ const isBinary = diffOutput.includes('Binary files');
393
+
394
+ return c.json({
395
+ status: 'ok',
396
+ data: {
397
+ file,
398
+ diff: diffOutput,
399
+ insertions,
400
+ deletions,
401
+ language: detectLanguage(file),
402
+ binary: isBinary,
403
+ },
404
+ });
405
+ } catch (error) {
406
+ console.error('Git diff error:', error);
407
+ return c.json(
408
+ {
409
+ status: 'error',
410
+ error:
411
+ error instanceof Error ? error.message : 'Failed to get git diff',
412
+ },
413
+ 500,
414
+ );
415
+ }
416
+ });
417
+
418
+ // POST /v1/git/generate-commit-message - Generate AI commit message
419
+ app.post('/v1/git/generate-commit-message', async (c) => {
420
+ try {
421
+ const body = await c.req.json();
422
+ const { project } = gitGenerateCommitMessageSchema.parse(body);
423
+ const requestedPath = project || process.cwd();
424
+ const gitRoot = await findGitRoot(requestedPath);
425
+
426
+ // Check if there are staged changes
427
+ const { stdout: statusOutput } = await execFileAsync(
428
+ 'git',
429
+ ['diff', '--cached', '--name-only'],
430
+ { cwd: gitRoot },
431
+ );
432
+
433
+ if (!statusOutput.trim()) {
434
+ return c.json(
435
+ {
436
+ status: 'error',
437
+ error: 'No staged changes to generate commit message for',
438
+ },
439
+ 400,
440
+ );
441
+ }
442
+
443
+ // Get the full staged diff
444
+ const { stdout: stagedDiff } = await execFileAsync(
445
+ 'git',
446
+ ['diff', '--cached'],
447
+ { cwd: gitRoot },
448
+ );
449
+
450
+ // Limit diff size to avoid token limits (keep first 8000 chars)
451
+ const limitedDiff =
452
+ stagedDiff.length > 8000
453
+ ? `${stagedDiff.slice(0, 8000)}\n\n... (diff truncated due to size)`
454
+ : stagedDiff;
455
+
456
+ // Generate commit message using AI
457
+ const prompt = `Based on the following git diff of staged changes, generate a clear and concise commit message following these guidelines:
458
+
459
+ 1. Use conventional commit format: <type>(<scope>): <subject>
460
+ 2. Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
461
+ 3. Keep the subject line under 72 characters
462
+ 4. Use imperative mood ("add" not "added" or "adds")
463
+ 5. Don't end the subject line with a period
464
+ 6. If there are multiple significant changes, focus on the most important one
465
+ 7. Be specific about what changed
466
+
467
+ Git diff of staged changes:
468
+ \`\`\`diff
469
+ ${limitedDiff}
470
+ \`\`\`
471
+
472
+ Generate only the commit message, nothing else.`;
473
+
474
+ // Load config to get default provider/model
475
+ const cfg = await loadConfig(gitRoot);
476
+
477
+ // Resolve the model using SDK - this doesn't create any session
478
+ const model = await resolveModel(
479
+ cfg.defaults.provider,
480
+ cfg.defaults.model,
481
+ );
482
+
483
+ // Generate text directly - no session involved
484
+ const result = await generateText({
485
+ model: model as Parameters<typeof generateText>[0]['model'],
486
+ prompt,
487
+ });
488
+
489
+ // Extract and clean up the message
490
+ let message = result.text || '';
491
+
492
+ // Clean up the message (remove markdown code blocks if present)
493
+ if (typeof message === 'string') {
494
+ message = message
495
+ .replace(/^```(?:text|commit)?\n?/gm, '')
496
+ .replace(/```$/gm, '')
497
+ .trim();
498
+ }
499
+
500
+ return c.json({
501
+ status: 'ok',
502
+ data: {
503
+ message,
504
+ },
505
+ });
506
+ } catch (error) {
507
+ console.error('Generate commit message error:', error);
508
+ return c.json(
509
+ {
510
+ status: 'error',
511
+ error:
512
+ error instanceof Error
513
+ ? error.message
514
+ : 'Failed to generate commit message',
515
+ },
516
+ 500,
517
+ );
518
+ }
519
+ });
520
+
521
+ // POST /v1/git/stage - Stage files
522
+ app.post('/v1/git/stage', async (c) => {
523
+ try {
524
+ const body = await c.req.json();
525
+ const { project, files } = gitStageSchema.parse(body);
526
+
527
+ const requestedPath = project || process.cwd();
528
+ const gitRoot = await findGitRoot(requestedPath);
529
+
530
+ // Stage files - git add handles paths relative to git root
531
+ await execFileAsync('git', ['add', ...files], { cwd: gitRoot });
532
+
533
+ return c.json({
534
+ status: 'ok',
535
+ data: {
536
+ staged: files,
537
+ failed: [],
538
+ },
539
+ });
540
+ } catch (error) {
541
+ console.error('Git stage error:', error);
542
+ return c.json(
543
+ {
544
+ status: 'error',
545
+ error:
546
+ error instanceof Error ? error.message : 'Failed to stage files',
547
+ },
548
+ 500,
549
+ );
550
+ }
551
+ });
552
+
553
+ // POST /v1/git/unstage - Unstage files
554
+ app.post('/v1/git/unstage', async (c) => {
555
+ try {
556
+ const body = await c.req.json();
557
+ const { project, files } = gitUnstageSchema.parse(body);
558
+
559
+ const requestedPath = project || process.cwd();
560
+ const gitRoot = await findGitRoot(requestedPath);
561
+
562
+ // Try modern git restore first, fallback to reset
563
+ try {
564
+ await execFileAsync('git', ['restore', '--staged', ...files], {
565
+ cwd: gitRoot,
566
+ });
567
+ } catch {
568
+ // Fallback to older git reset HEAD
569
+ await execFileAsync('git', ['reset', 'HEAD', ...files], {
570
+ cwd: gitRoot,
571
+ });
572
+ }
573
+
574
+ return c.json({
575
+ status: 'ok',
576
+ data: {
577
+ unstaged: files,
578
+ failed: [],
579
+ },
580
+ });
581
+ } catch (error) {
582
+ console.error('Git unstage error:', error);
583
+ return c.json(
584
+ {
585
+ status: 'error',
586
+ error:
587
+ error instanceof Error ? error.message : 'Failed to unstage files',
588
+ },
589
+ 500,
590
+ );
591
+ }
592
+ });
593
+
594
+ // POST /v1/git/commit - Commit staged changes
595
+ app.post('/v1/git/commit', async (c) => {
596
+ try {
597
+ const body = await c.req.json();
598
+ const { project, message } = gitCommitSchema.parse(body);
599
+
600
+ const requestedPath = project || process.cwd();
601
+ const gitRoot = await findGitRoot(requestedPath);
602
+
603
+ // Check if there are staged changes
604
+ const { stdout: statusOutput } = await execFileAsync(
605
+ 'git',
606
+ ['diff', '--cached', '--name-only'],
607
+ { cwd: gitRoot },
608
+ );
609
+
610
+ if (!statusOutput.trim()) {
611
+ return c.json(
612
+ {
613
+ status: 'error',
614
+ error: 'No staged changes to commit',
615
+ },
616
+ 400,
617
+ );
618
+ }
619
+
620
+ // Commit
621
+ const { stdout: commitOutput } = await execFileAsync(
622
+ 'git',
623
+ ['commit', '-m', message],
624
+ { cwd: gitRoot },
625
+ );
626
+
627
+ // Parse commit output for hash
628
+ const hashMatch = commitOutput.match(/[\w/]+ ([a-f0-9]+)\]/);
629
+ const hash = hashMatch ? hashMatch[1] : '';
630
+
631
+ // Get commit stats
632
+ const { stdout: statOutput } = await execFileAsync(
633
+ 'git',
634
+ ['show', '--stat', '--format=', 'HEAD'],
635
+ { cwd: gitRoot },
636
+ );
637
+
638
+ const filesChangedMatch = statOutput.match(/(\d+) files? changed/);
639
+ const insertionsMatch = statOutput.match(/(\d+) insertions?/);
640
+ const deletionsMatch = statOutput.match(/(\d+) deletions?/);
641
+
642
+ return c.json({
643
+ status: 'ok',
644
+ data: {
645
+ hash,
646
+ message: message.split('\n')[0],
647
+ filesChanged: filesChangedMatch
648
+ ? Number.parseInt(filesChangedMatch[1], 10)
649
+ : 0,
650
+ insertions: insertionsMatch
651
+ ? Number.parseInt(insertionsMatch[1], 10)
652
+ : 0,
653
+ deletions: deletionsMatch
654
+ ? Number.parseInt(deletionsMatch[1], 10)
655
+ : 0,
656
+ },
657
+ });
658
+ } catch (error) {
659
+ console.error('Git commit error:', error);
660
+ return c.json(
661
+ {
662
+ status: 'error',
663
+ error: error instanceof Error ? error.message : 'Failed to commit',
664
+ },
665
+ 500,
666
+ );
667
+ }
668
+ });
669
+
670
+ // GET /v1/git/branch - Get branch information
671
+ app.get('/v1/git/branch', async (c) => {
672
+ try {
673
+ const query = gitStatusSchema.parse({
674
+ project: c.req.query('project'),
675
+ });
676
+
677
+ const requestedPath = query.project || process.cwd();
678
+ const gitRoot = await findGitRoot(requestedPath);
679
+
680
+ const branch = await getCurrentBranch(gitRoot);
681
+ const { ahead, behind } = await getAheadBehind(gitRoot);
682
+
683
+ // Get all branches
684
+ let allBranches: string[] = [];
685
+ try {
686
+ const { stdout: branchesOutput } = await execFileAsync(
687
+ 'git',
688
+ ['branch', '--list'],
689
+ { cwd: gitRoot },
690
+ );
691
+ allBranches = branchesOutput
692
+ .split('\n')
693
+ .map((line) => line.replace(/^\*?\s*/, '').trim())
694
+ .filter(Boolean);
695
+ } catch {
696
+ allBranches = [branch];
697
+ }
698
+
699
+ // Get upstream branch
700
+ let upstream = '';
701
+ try {
702
+ const { stdout: upstreamOutput } = await execFileAsync(
703
+ 'git',
704
+ ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'],
705
+ { cwd: gitRoot },
706
+ );
707
+ upstream = upstreamOutput.trim();
708
+ } catch {
709
+ // No upstream configured
710
+ }
711
+
712
+ return c.json({
713
+ status: 'ok',
714
+ data: {
715
+ current: branch,
716
+ upstream,
717
+ ahead,
718
+ behind,
719
+ all: allBranches,
720
+ },
721
+ });
722
+ } catch (error) {
723
+ console.error('Git branch error:', error);
724
+ return c.json(
725
+ {
726
+ status: 'error',
727
+ error:
728
+ error instanceof Error
729
+ ? error.message
730
+ : 'Failed to get branch info',
731
+ },
732
+ 500,
733
+ );
734
+ }
735
+ });
736
+ }