@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.
- package/package.json +41 -0
- package/src/events/bus.ts +28 -0
- package/src/events/types.ts +20 -0
- package/src/index.ts +183 -0
- package/src/openapi/spec.ts +474 -0
- package/src/routes/ask.ts +59 -0
- package/src/routes/config.ts +124 -0
- package/src/routes/git.ts +736 -0
- package/src/routes/openapi.ts +6 -0
- package/src/routes/root.ts +5 -0
- package/src/routes/session-messages.ts +123 -0
- package/src/routes/session-stream.ts +45 -0
- package/src/routes/sessions.ts +87 -0
- package/src/runtime/agent-registry.ts +327 -0
- package/src/runtime/ask-service.ts +363 -0
- package/src/runtime/cwd.ts +69 -0
- package/src/runtime/db-operations.ts +94 -0
- package/src/runtime/debug.ts +104 -0
- package/src/runtime/environment.ts +131 -0
- package/src/runtime/error-handling.ts +196 -0
- package/src/runtime/history-builder.ts +156 -0
- package/src/runtime/message-service.ts +392 -0
- package/src/runtime/prompt.ts +79 -0
- package/src/runtime/provider-selection.ts +123 -0
- package/src/runtime/provider.ts +138 -0
- package/src/runtime/runner.ts +313 -0
- package/src/runtime/session-manager.ts +95 -0
- package/src/runtime/session-queue.ts +82 -0
- package/src/runtime/stream-handlers.ts +275 -0
- package/src/runtime/token-utils.ts +35 -0
- package/src/runtime/tool-context-setup.ts +58 -0
- package/src/runtime/tool-context.ts +72 -0
- package/src/tools/adapter.ts +380 -0
- package/src/types/sql-imports.d.ts +5 -0
- package/tsconfig.json +7 -0
|
@@ -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
|
+
}
|