@agi-cli/server 0.1.66 → 0.1.68

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/src/routes/git.ts CHANGED
@@ -1,10 +1,14 @@
1
1
  import type { Hono } from 'hono';
2
2
  import { execFile } from 'node:child_process';
3
- import { extname } from 'node:path';
3
+ import { extname, join } from 'node:path';
4
+ import { readFile } from 'node:fs/promises';
4
5
  import { promisify } from 'node:util';
5
6
  import { z } from 'zod';
6
- import { generateText, resolveModel, type ProviderId } from '@agi-cli/sdk';
7
- import { loadConfig } from '@agi-cli/sdk';
7
+ import { generateText } from 'ai';
8
+ import type { ProviderId } from '@agi-cli/sdk';
9
+ import { loadConfig, getAuth } from '@agi-cli/sdk';
10
+ import { resolveModel } from '../runtime/provider.ts';
11
+ import { getProviderSpoofPrompt } from '../runtime/prompt.ts';
8
12
 
9
13
  const execFileAsync = promisify(execFile);
10
14
 
@@ -113,10 +117,13 @@ const gitPushSchema = z.object({
113
117
  // Types
114
118
  export interface GitFile {
115
119
  path: string;
120
+ absPath: string; // NEW: Absolute filesystem path
116
121
  status: 'modified' | 'added' | 'deleted' | 'renamed' | 'untracked';
117
122
  staged: boolean;
118
123
  insertions?: number;
119
124
  deletions?: number;
125
+ oldPath?: string; // For renamed files
126
+ isNew: boolean; // NEW: True for untracked or newly added files
120
127
  }
121
128
 
122
129
  interface GitRoot {
@@ -149,7 +156,25 @@ async function validateAndGetGitRoot(
149
156
  }
150
157
  }
151
158
 
152
- function parseGitStatus(statusOutput: string): {
159
+ /**
160
+ * Check if a file is new/untracked (not in git index)
161
+ */
162
+ async function checkIfNewFile(gitRoot: string, file: string): Promise<boolean> {
163
+ try {
164
+ // Check if file exists in git index or committed
165
+ await execFileAsync('git', ['ls-files', '--error-unmatch', file], {
166
+ cwd: gitRoot,
167
+ });
168
+ return false; // File exists in git
169
+ } catch {
170
+ return true; // File is new/untracked
171
+ }
172
+ }
173
+
174
+ function parseGitStatus(
175
+ statusOutput: string,
176
+ gitRoot: string,
177
+ ): {
153
178
  staged: GitFile[];
154
179
  unstaged: GitFile[];
155
180
  untracked: GitFile[];
@@ -160,34 +185,50 @@ function parseGitStatus(statusOutput: string): {
160
185
  const untracked: GitFile[] = [];
161
186
 
162
187
  for (const line of lines) {
163
- const x = line[0]; // staged status
164
- const y = line[1]; // unstaged status
165
- const path = line.slice(3).trim();
166
-
167
- // Check if file is staged (X is not space or ?)
168
- if (x !== ' ' && x !== '?') {
169
- staged.push({
170
- path,
171
- status: getStatusFromCode(x),
172
- staged: true,
173
- });
174
- }
175
-
176
- // Check if file is unstaged (Y is not space)
177
- if (y !== ' ' && y !== '?') {
178
- unstaged.push({
179
- path,
180
- status: getStatusFromCode(y),
181
- staged: false,
182
- });
183
- }
188
+ // Porcelain v2 format has different line types
189
+ if (line.startsWith('1 ') || line.startsWith('2 ')) {
190
+ // Regular changed entry: "1 XY sub <mH> <mI> <mW> <hH> <hI> <path>"
191
+ // XY is a 2-character field with staged (X) and unstaged (Y) status
192
+ const parts = line.split(' ');
193
+ if (parts.length < 9) continue;
194
+
195
+ const xy = parts[1]; // e.g., ".M", "M.", "MM", "A.", etc.
196
+ const x = xy[0]; // staged status
197
+ const y = xy[1]; // unstaged status
198
+ const path = parts.slice(8).join(' '); // Path can contain spaces
199
+ const absPath = join(gitRoot, path);
200
+
201
+ // Check if file is staged (X is not '.')
202
+ if (x !== '.') {
203
+ staged.push({
204
+ path,
205
+ absPath,
206
+ status: getStatusFromCodeV2(x),
207
+ staged: true,
208
+ isNew: x === 'A',
209
+ });
210
+ }
184
211
 
185
- // Check if file is untracked
186
- if (x === '?' && y === '?') {
212
+ // Check if file is unstaged (Y is not '.')
213
+ if (y !== '.') {
214
+ unstaged.push({
215
+ path,
216
+ absPath,
217
+ status: getStatusFromCodeV2(y),
218
+ staged: false,
219
+ isNew: false,
220
+ });
221
+ }
222
+ } else if (line.startsWith('? ')) {
223
+ // Untracked file: "? <path>"
224
+ const path = line.slice(2);
225
+ const absPath = join(gitRoot, path);
187
226
  untracked.push({
188
227
  path,
228
+ absPath,
189
229
  status: 'untracked',
190
230
  staged: false,
231
+ isNew: true,
191
232
  });
192
233
  }
193
234
  }
@@ -195,7 +236,22 @@ function parseGitStatus(statusOutput: string): {
195
236
  return { staged, unstaged, untracked };
196
237
  }
197
238
 
198
- function getStatusFromCode(code: string): GitFile['status'] {
239
+ function _getStatusFromCode(code: string): GitFile['status'] {
240
+ switch (code) {
241
+ case 'M':
242
+ return 'modified';
243
+ case 'A':
244
+ return 'added';
245
+ case 'D':
246
+ return 'deleted';
247
+ case 'R':
248
+ return 'renamed';
249
+ default:
250
+ return 'modified';
251
+ }
252
+ }
253
+
254
+ function getStatusFromCodeV2(code: string): GitFile['status'] {
199
255
  switch (code) {
200
256
  case 'M':
201
257
  return 'modified';
@@ -205,6 +261,8 @@ function getStatusFromCode(code: string): GitFile['status'] {
205
261
  return 'deleted';
206
262
  case 'R':
207
263
  return 'renamed';
264
+ case 'C':
265
+ return 'modified'; // Copied - treat as modified
208
266
  default:
209
267
  return 'modified';
210
268
  }
@@ -264,11 +322,14 @@ export function registerGitRoutes(app: Hono) {
264
322
  // Get status
265
323
  const { stdout: statusOutput } = await execFileAsync(
266
324
  'git',
267
- ['status', '--porcelain=v1'],
325
+ ['status', '--porcelain=v2'],
268
326
  { cwd: gitRoot },
269
327
  );
270
328
 
271
- const { staged, unstaged, untracked } = parseGitStatus(statusOutput);
329
+ const { staged, unstaged, untracked } = parseGitStatus(
330
+ statusOutput,
331
+ gitRoot,
332
+ );
272
333
 
273
334
  // Get ahead/behind counts
274
335
  const { ahead, behind } = await getAheadBehind(gitRoot);
@@ -286,6 +347,8 @@ export function registerGitRoutes(app: Hono) {
286
347
  branch,
287
348
  ahead,
288
349
  behind,
350
+ gitRoot, // NEW: Expose git root path
351
+ workingDir: requestedPath, // NEW: Current working directory
289
352
  staged,
290
353
  unstaged,
291
354
  untracked,
@@ -324,8 +387,46 @@ export function registerGitRoutes(app: Hono) {
324
387
  }
325
388
 
326
389
  const { gitRoot } = validation;
390
+ const absPath = join(gitRoot, query.file);
391
+
392
+ // Check if file is new/untracked
393
+ const isNewFile = await checkIfNewFile(gitRoot, query.file);
394
+
395
+ // For new files, read and return full content
396
+ if (isNewFile) {
397
+ try {
398
+ const content = await readFile(absPath, 'utf-8');
399
+ const lineCount = content.split('\n').length;
400
+ const language = inferLanguage(query.file);
401
+
402
+ return c.json({
403
+ status: 'ok',
404
+ data: {
405
+ file: query.file,
406
+ absPath,
407
+ diff: '', // Empty diff for new files
408
+ content, // NEW: Full file content
409
+ isNewFile: true, // NEW: Flag indicating this is a new file
410
+ isBinary: false,
411
+ insertions: lineCount,
412
+ deletions: 0,
413
+ language,
414
+ staged: !!query.staged, // NEW: Whether showing staged or unstaged
415
+ },
416
+ });
417
+ } catch (error) {
418
+ return c.json(
419
+ {
420
+ status: 'error',
421
+ error:
422
+ error instanceof Error ? error.message : 'Failed to read file',
423
+ },
424
+ 500,
425
+ );
426
+ }
427
+ }
327
428
 
328
- // Get diff output and stats for the requested file
429
+ // For existing files, get diff output and stats
329
430
  const diffArgs = query.staged
330
431
  ? ['diff', '--cached', '--', query.file]
331
432
  : ['diff', '--', query.file];
@@ -370,11 +471,14 @@ export function registerGitRoutes(app: Hono) {
370
471
  status: 'ok',
371
472
  data: {
372
473
  file: query.file,
474
+ absPath, // NEW: Absolute path
373
475
  diff: diffText,
476
+ isNewFile: false, // NEW: Not a new file
477
+ isBinary: binary,
374
478
  insertions,
375
479
  deletions,
376
480
  language,
377
- binary,
481
+ staged: !!query.staged, // NEW: Whether showing staged or unstaged
378
482
  },
379
483
  });
380
484
  } catch (error) {
@@ -568,25 +672,31 @@ export function registerGitRoutes(app: Hono) {
568
672
  // Get file list for context
569
673
  const { stdout: statusOutput } = await execFileAsync(
570
674
  'git',
571
- ['status', '--porcelain=v1'],
675
+ ['status', '--porcelain=v2'],
572
676
  { cwd: gitRoot },
573
677
  );
574
- const { staged } = parseGitStatus(statusOutput);
678
+ const { staged } = parseGitStatus(statusOutput, gitRoot);
575
679
  const fileList = staged.map((f) => `${f.status}: ${f.path}`).join('\n');
576
680
 
577
681
  // Load config to get provider settings
578
682
  const config = await loadConfig();
579
683
 
580
- // Use a simple model for quick commit message generation
581
- const provider = config.defaults?.provider || 'anthropic';
582
- const model = await resolveModel(
583
- provider as ProviderId,
584
- config.defaults?.model,
585
- undefined,
586
- );
684
+ // Use the default provider and model for quick commit message generation
685
+ const provider = (config.defaults?.provider || 'anthropic') as ProviderId;
686
+ const modelId = config.defaults?.model || 'claude-3-5-sonnet-20241022';
687
+
688
+ // Check if we need OAuth spoof prompt (same as runner)
689
+ const auth = await getAuth(provider, config.projectRoot);
690
+ const needsSpoof = auth?.type === 'oauth';
691
+ const spoofPrompt = needsSpoof
692
+ ? getProviderSpoofPrompt(provider)
693
+ : undefined;
694
+
695
+ // Resolve model with proper authentication (3-level fallback: OAuth, API key, env var)
696
+ const model = await resolveModel(provider, modelId, config);
587
697
 
588
698
  // Generate commit message using AI
589
- const prompt = `Generate a concise, conventional commit message for these git changes.
699
+ const userPrompt = `Generate a concise, conventional commit message for these git changes.
590
700
 
591
701
  Staged files:
592
702
  ${fileList}
@@ -604,12 +714,15 @@ Guidelines:
604
714
 
605
715
  Commit message:`;
606
716
 
717
+ // Use spoof prompt as system if OAuth, otherwise use normal system prompt
718
+ const systemPrompt = spoofPrompt
719
+ ? spoofPrompt
720
+ : 'You are a helpful assistant that generates git commit messages.';
721
+
607
722
  const { text } = await generateText({
608
- provider: provider as ProviderId,
609
- model: model.id,
610
- systemPrompt:
611
- 'You are a helpful assistant that generates git commit messages.',
612
- prompt,
723
+ model,
724
+ system: systemPrompt,
725
+ prompt: userPrompt,
613
726
  maxTokens: 200,
614
727
  });
615
728
 
@@ -9,6 +9,8 @@ import {
9
9
  ensureProviderEnv,
10
10
  } from '@agi-cli/sdk';
11
11
  import { dispatchAssistantMessage } from '../runtime/message-service.ts';
12
+ import { logger } from '../runtime/logger.ts';
13
+ import { serializeError } from '../runtime/api-error.ts';
12
14
 
13
15
  type MessagePartRow = typeof messageParts.$inferSelect;
14
16
  type SessionRow = typeof sessions.$inferSelect;
@@ -16,108 +18,125 @@ type SessionRow = typeof sessions.$inferSelect;
16
18
  export function registerSessionMessagesRoutes(app: Hono) {
17
19
  // List messages for a session
18
20
  app.get('/v1/sessions/:id/messages', async (c) => {
19
- const projectRoot = c.req.query('project') || process.cwd();
20
- const cfg = await loadConfig(projectRoot);
21
- const db = await getDb(cfg.projectRoot);
22
- const id = c.req.param('id');
23
- const rows = await db
24
- .select()
25
- .from(messages)
26
- .where(eq(messages.sessionId, id))
27
- .orderBy(messages.createdAt);
28
- const without = c.req.query('without');
29
- if (without !== 'parts') {
30
- const ids = rows.map((m) => m.id);
31
- const parts = ids.length
32
- ? await db
33
- .select()
34
- .from(messageParts)
35
- .where(inArray(messageParts.messageId, ids))
36
- : [];
37
- const partsByMsg = new Map<string, MessagePartRow[]>();
38
- for (const p of parts) {
39
- const existing = partsByMsg.get(p.messageId);
40
- if (existing) existing.push(p);
41
- else partsByMsg.set(p.messageId, [p]);
42
- }
43
- const wantParsed = (() => {
44
- const q = (c.req.query('parsed') || '').toLowerCase();
45
- return q === '1' || q === 'true' || q === 'yes';
46
- })();
47
- function parseContent(raw: string): Record<string, unknown> | string {
48
- try {
49
- const v = JSON.parse(String(raw ?? ''));
50
- if (v && typeof v === 'object' && !Array.isArray(v))
51
- return v as Record<string, unknown>;
52
- } catch {}
53
- return raw;
54
- }
55
- const enriched = rows.map((m) => {
56
- const parts = (partsByMsg.get(m.id) ?? []).sort(
57
- (a, b) => a.index - b.index,
58
- );
59
- const mapped = parts.map((p) => {
60
- const parsed = parseContent(p.content);
61
- return wantParsed
62
- ? { ...p, content: parsed }
63
- : { ...p, contentJson: parsed };
21
+ try {
22
+ const projectRoot = c.req.query('project') || process.cwd();
23
+ const cfg = await loadConfig(projectRoot);
24
+ const db = await getDb(cfg.projectRoot);
25
+ const id = c.req.param('id');
26
+ const rows = await db
27
+ .select()
28
+ .from(messages)
29
+ .where(eq(messages.sessionId, id))
30
+ .orderBy(messages.createdAt);
31
+ const without = c.req.query('without');
32
+ if (without !== 'parts') {
33
+ const ids = rows.map((m) => m.id);
34
+ const parts = ids.length
35
+ ? await db
36
+ .select()
37
+ .from(messageParts)
38
+ .where(inArray(messageParts.messageId, ids))
39
+ : [];
40
+ const partsByMsg = new Map<string, MessagePartRow[]>();
41
+ for (const p of parts) {
42
+ const existing = partsByMsg.get(p.messageId);
43
+ if (existing) existing.push(p);
44
+ else partsByMsg.set(p.messageId, [p]);
45
+ }
46
+ const wantParsed = (() => {
47
+ const q = (c.req.query('parsed') || '').toLowerCase();
48
+ return q === '1' || q === 'true' || q === 'yes';
49
+ })();
50
+ function parseContent(raw: string): Record<string, unknown> | string {
51
+ try {
52
+ const v = JSON.parse(String(raw ?? ''));
53
+ if (v && typeof v === 'object' && !Array.isArray(v))
54
+ return v as Record<string, unknown>;
55
+ } catch {}
56
+ return raw;
57
+ }
58
+ const enriched = rows.map((m) => {
59
+ const parts = (partsByMsg.get(m.id) ?? []).sort(
60
+ (a, b) => a.index - b.index,
61
+ );
62
+ const mapped = parts.map((p) => {
63
+ const parsed = parseContent(p.content);
64
+ return wantParsed
65
+ ? { ...p, content: parsed }
66
+ : { ...p, contentJson: parsed };
67
+ });
68
+ return { ...m, parts: mapped };
64
69
  });
65
- return { ...m, parts: mapped };
66
- });
67
- return c.json(enriched);
70
+ return c.json(enriched);
71
+ }
72
+ return c.json(rows);
73
+ } catch (error) {
74
+ logger.error('Failed to list session messages', error);
75
+ const errorResponse = serializeError(error);
76
+ return c.json(errorResponse, errorResponse.error.status || 500);
68
77
  }
69
- return c.json(rows);
70
78
  });
71
79
 
72
80
  // Post a user message and get assistant reply (non-streaming for v0)
73
81
  app.post('/v1/sessions/:id/messages', async (c) => {
74
- const projectRoot = c.req.query('project') || process.cwd();
75
- const cfg = await loadConfig(projectRoot);
76
- const db = await getDb(cfg.projectRoot);
77
- const sessionId = c.req.param('id');
78
- const body = await c.req.json().catch(() => ({}));
79
- // Load session to inherit its provider/model/agent by default
80
- const sessionRows = await db
81
- .select()
82
- .from(sessions)
83
- .where(eq(sessions.id, sessionId));
84
- if (!sessionRows.length) return c.json({ error: 'Session not found' }, 404);
85
- const sess: SessionRow = sessionRows[0];
86
- const provider = body?.provider ?? sess.provider ?? cfg.defaults.provider;
87
- const modelName = body?.model ?? sess.model ?? cfg.defaults.model;
88
- const agent = body?.agent ?? sess.agent ?? cfg.defaults.agent;
89
- const content = body?.content ?? '';
90
-
91
- // Validate model capabilities if tools are allowed for this agent
92
- const wantsToolCalls = true; // agent toolset may be non-empty
93
82
  try {
94
- validateProviderModel(provider, modelName, { wantsToolCalls });
95
- } catch (err) {
96
- const message = err instanceof Error ? err.message : String(err);
97
- return c.json({ error: message }, 400);
98
- }
99
- // Enforce provider auth: only allow providers/models the user authenticated for
100
- const authorized = await isProviderAuthorized(cfg, provider);
101
- if (!authorized) {
102
- return c.json(
103
- {
104
- error: `Provider ${provider} is not configured. Run \`agi auth login\` to add credentials.`,
105
- },
106
- 400,
107
- );
108
- }
109
- await ensureProviderEnv(cfg, provider);
83
+ const projectRoot = c.req.query('project') || process.cwd();
84
+ const cfg = await loadConfig(projectRoot);
85
+ const db = await getDb(cfg.projectRoot);
86
+ const sessionId = c.req.param('id');
87
+ const body = await c.req.json().catch(() => ({}));
88
+ // Load session to inherit its provider/model/agent by default
89
+ const sessionRows = await db
90
+ .select()
91
+ .from(sessions)
92
+ .where(eq(sessions.id, sessionId));
93
+ if (!sessionRows.length) {
94
+ logger.warn('Session not found', { sessionId });
95
+ return c.json({ error: 'Session not found' }, 404);
96
+ }
97
+ const sess: SessionRow = sessionRows[0];
98
+ const provider = body?.provider ?? sess.provider ?? cfg.defaults.provider;
99
+ const modelName = body?.model ?? sess.model ?? cfg.defaults.model;
100
+ const agent = body?.agent ?? sess.agent ?? cfg.defaults.agent;
101
+ const content = body?.content ?? '';
102
+
103
+ // Validate model capabilities if tools are allowed for this agent
104
+ const wantsToolCalls = true; // agent toolset may be non-empty
105
+ try {
106
+ validateProviderModel(provider, modelName, { wantsToolCalls });
107
+ } catch (err) {
108
+ logger.error('Model validation failed', err, { provider, modelName });
109
+ const message = err instanceof Error ? err.message : String(err);
110
+ return c.json({ error: message }, 400);
111
+ }
112
+ // Enforce provider auth: only allow providers/models the user authenticated for
113
+ const authorized = await isProviderAuthorized(cfg, provider);
114
+ if (!authorized) {
115
+ logger.warn('Provider not authorized', { provider });
116
+ return c.json(
117
+ {
118
+ error: `Provider ${provider} is not configured. Run \`agi auth login\` to add credentials.`,
119
+ },
120
+ 400,
121
+ );
122
+ }
123
+ await ensureProviderEnv(cfg, provider);
110
124
 
111
- const { assistantMessageId } = await dispatchAssistantMessage({
112
- cfg,
113
- db,
114
- session: sess,
115
- agent,
116
- provider,
117
- model: modelName,
118
- content,
119
- oneShot: Boolean(body?.oneShot),
120
- });
121
- return c.json({ messageId: assistantMessageId }, 202);
125
+ const { assistantMessageId } = await dispatchAssistantMessage({
126
+ cfg,
127
+ db,
128
+ session: sess,
129
+ agent,
130
+ provider,
131
+ model: modelName,
132
+ content,
133
+ oneShot: Boolean(body?.oneShot),
134
+ });
135
+ return c.json({ messageId: assistantMessageId }, 202);
136
+ } catch (error) {
137
+ logger.error('Failed to create session message', error);
138
+ const errorResponse = serializeError(error);
139
+ return c.json(errorResponse, errorResponse.error.status || 500);
140
+ }
122
141
  });
123
142
  }
@@ -7,6 +7,8 @@ import type { ProviderId } from '@agi-cli/sdk';
7
7
  import { isProviderId } from '@agi-cli/sdk';
8
8
  import { resolveAgentConfig } from '../runtime/agent-registry.ts';
9
9
  import { createSession as createSessionRow } from '../runtime/session-manager.ts';
10
+ import { serializeError } from '../runtime/api-error.ts';
11
+ import { logger } from '../runtime/logger.ts';
10
12
 
11
13
  export function registerSessionsRoutes(app: Hono) {
12
14
  // List sessions
@@ -72,8 +74,9 @@ export function registerSessionsRoutes(app: Hono) {
72
74
  });
73
75
  return c.json(row, 201);
74
76
  } catch (err) {
75
- const message = err instanceof Error ? err.message : String(err);
76
- return c.json({ error: message }, 400);
77
+ logger.error('Failed to create session', err);
78
+ const errorResponse = serializeError(err);
79
+ return c.json(errorResponse, errorResponse.error.status || 400);
77
80
  }
78
81
  });
79
82