@agi-cli/server 0.1.65 → 0.1.67

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agi-cli/server",
3
- "version": "0.1.65",
3
+ "version": "0.1.67",
4
4
  "description": "HTTP API server for AGI CLI",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -29,10 +29,11 @@
29
29
  "typecheck": "tsc --noEmit"
30
30
  },
31
31
  "dependencies": {
32
- "@agi-cli/sdk": "0.1.65",
33
- "@agi-cli/database": "0.1.65",
32
+ "@agi-cli/sdk": "0.1.67",
33
+ "@agi-cli/database": "0.1.67",
34
34
  "drizzle-orm": "^0.44.5",
35
- "hono": "^4.9.9"
35
+ "hono": "^4.9.9",
36
+ "zod": "^4.1.8"
36
37
  },
37
38
  "devDependencies": {
38
39
  "@types/bun": "latest",
@@ -4,6 +4,63 @@ import { catalog, type ProviderId, isProviderAuthorized } from '@agi-cli/sdk';
4
4
  import { readdir } from 'node:fs/promises';
5
5
  import { join, basename } from 'node:path';
6
6
  import type { EmbeddedAppConfig } from '../index.ts';
7
+ import type { AGIConfig } from '@agi-cli/sdk';
8
+
9
+ /**
10
+ * Check if a provider is authorized in either embedded config or file-based config
11
+ */
12
+ async function isProviderAuthorizedHybrid(
13
+ embeddedConfig: EmbeddedAppConfig | undefined,
14
+ fileConfig: AGIConfig,
15
+ provider: ProviderId,
16
+ ): Promise<boolean> {
17
+ // Check embedded auth first
18
+ const hasEmbeddedAuth =
19
+ embeddedConfig?.provider === provider ||
20
+ (embeddedConfig?.auth && provider in embeddedConfig.auth);
21
+
22
+ if (hasEmbeddedAuth) {
23
+ return true;
24
+ }
25
+
26
+ // Fallback to file-based auth
27
+ return await isProviderAuthorized(fileConfig, provider);
28
+ }
29
+
30
+ /**
31
+ * Get all authorized providers from both embedded and file-based config
32
+ */
33
+ async function getAuthorizedProviders(
34
+ embeddedConfig: EmbeddedAppConfig | undefined,
35
+ fileConfig: AGIConfig,
36
+ ): Promise<ProviderId[]> {
37
+ const allProviders = Object.keys(catalog) as ProviderId[];
38
+ const authorizedProviders: ProviderId[] = [];
39
+
40
+ for (const provider of allProviders) {
41
+ const authorized = await isProviderAuthorizedHybrid(
42
+ embeddedConfig,
43
+ fileConfig,
44
+ provider,
45
+ );
46
+ if (authorized) {
47
+ authorizedProviders.push(provider);
48
+ }
49
+ }
50
+
51
+ return authorizedProviders;
52
+ }
53
+
54
+ /**
55
+ * Get default value with embedded config taking priority over file config
56
+ */
57
+ function getDefault<T>(
58
+ embeddedValue: T | undefined,
59
+ embeddedDefaultValue: T | undefined,
60
+ fileValue: T,
61
+ ): T {
62
+ return embeddedValue ?? embeddedDefaultValue ?? fileValue;
63
+ }
7
64
 
8
65
  export function registerConfigRoutes(app: Hono) {
9
66
  // Get working directory info
@@ -46,37 +103,28 @@ export function registerConfigRoutes(app: Hono) {
46
103
  const allAgents = Array.from(new Set([...embeddedAgents, ...fileAgents]));
47
104
 
48
105
  // Providers: Check both embedded and file-based auth
49
- const allProviders = Object.keys(catalog) as ProviderId[];
50
- const authorizedProviders: ProviderId[] = [];
51
-
52
- for (const provider of allProviders) {
53
- // Check embedded auth first
54
- const hasEmbeddedAuth =
55
- embeddedConfig?.provider === provider ||
56
- (embeddedConfig?.auth && provider in embeddedConfig.auth);
57
-
58
- // Fallback to file-based auth
59
- const hasFileAuth = await isProviderAuthorized(cfg, provider);
60
-
61
- if (hasEmbeddedAuth || hasFileAuth) {
62
- authorizedProviders.push(provider);
63
- }
64
- }
106
+ const authorizedProviders = await getAuthorizedProviders(
107
+ embeddedConfig,
108
+ cfg,
109
+ );
65
110
 
66
111
  // Defaults: Embedded overrides file config
67
112
  const defaults = {
68
- agent:
69
- embeddedConfig?.defaults?.agent ||
70
- embeddedConfig?.agent ||
113
+ agent: getDefault(
114
+ embeddedConfig?.agent,
115
+ embeddedConfig?.defaults?.agent,
71
116
  cfg.defaults.agent,
72
- provider:
73
- embeddedConfig?.defaults?.provider ||
74
- embeddedConfig?.provider ||
117
+ ),
118
+ provider: getDefault(
119
+ embeddedConfig?.provider,
120
+ embeddedConfig?.defaults?.provider,
75
121
  cfg.defaults.provider,
76
- model:
77
- embeddedConfig?.defaults?.model ||
78
- embeddedConfig?.model ||
122
+ ),
123
+ model: getDefault(
124
+ embeddedConfig?.model,
125
+ embeddedConfig?.defaults?.model,
79
126
  cfg.defaults.model,
127
+ ),
80
128
  };
81
129
 
82
130
  return c.json({
@@ -98,8 +146,11 @@ export function registerConfigRoutes(app: Hono) {
98
146
  : ['general', 'build', 'plan'];
99
147
  return c.json({
100
148
  agents,
101
- default:
102
- embeddedConfig.agent || embeddedConfig.defaults?.agent || 'general',
149
+ default: getDefault(
150
+ embeddedConfig.agent,
151
+ embeddedConfig.defaults?.agent,
152
+ 'general',
153
+ ),
103
154
  });
104
155
  }
105
156
 
@@ -140,22 +191,18 @@ export function registerConfigRoutes(app: Hono) {
140
191
 
141
192
  return c.json({
142
193
  providers,
143
- default: embeddedConfig.defaults?.provider || embeddedConfig.provider,
194
+ default: getDefault(
195
+ embeddedConfig.provider,
196
+ embeddedConfig.defaults?.provider,
197
+ undefined,
198
+ ),
144
199
  });
145
200
  }
146
201
 
147
202
  const projectRoot = c.req.query('project') || process.cwd();
148
203
  const cfg = await loadConfig(projectRoot);
149
204
 
150
- const allProviders = Object.keys(catalog) as ProviderId[];
151
- const authorizedProviders: ProviderId[] = [];
152
-
153
- for (const provider of allProviders) {
154
- const authorized = await isProviderAuthorized(cfg, provider);
155
- if (authorized) {
156
- authorizedProviders.push(provider);
157
- }
158
- }
205
+ const authorizedProviders = await getAuthorizedProviders(undefined, cfg);
159
206
 
160
207
  return c.json({
161
208
  providers: authorizedProviders,
@@ -170,36 +217,17 @@ export function registerConfigRoutes(app: Hono) {
170
217
  | undefined;
171
218
  const provider = c.req.param('provider') as ProviderId;
172
219
 
173
- if (embeddedConfig) {
174
- // Check if provider is authorized in embedded mode
175
- const hasAuth =
176
- embeddedConfig.provider === provider ||
177
- (embeddedConfig.auth && provider in embeddedConfig.auth);
178
-
179
- if (!hasAuth) {
180
- return c.json({ error: 'Provider not authorized' }, 403);
181
- }
182
-
183
- const providerCatalog = catalog[provider];
184
- if (!providerCatalog) {
185
- return c.json({ error: 'Provider not found' }, 404);
186
- }
187
-
188
- return c.json({
189
- models: providerCatalog.models.map((m) => ({
190
- id: m.id,
191
- label: m.label || m.id,
192
- toolCall: m.toolCall,
193
- reasoning: m.reasoning,
194
- })),
195
- default: embeddedConfig.model || embeddedConfig.defaults?.model,
196
- });
197
- }
198
-
220
+ // Always load file config for fallback auth check
199
221
  const projectRoot = c.req.query('project') || process.cwd();
200
222
  const cfg = await loadConfig(projectRoot);
201
223
 
202
- const authorized = await isProviderAuthorized(cfg, provider);
224
+ // Check if provider is authorized (hybrid: embedded OR file-based)
225
+ const authorized = await isProviderAuthorizedHybrid(
226
+ embeddedConfig,
227
+ cfg,
228
+ provider,
229
+ );
230
+
203
231
  if (!authorized) {
204
232
  return c.json({ error: 'Provider not authorized' }, 403);
205
233
  }
@@ -216,7 +244,11 @@ export function registerConfigRoutes(app: Hono) {
216
244
  toolCall: m.toolCall,
217
245
  reasoning: m.reasoning,
218
246
  })),
219
- default: cfg.defaults.model,
247
+ default: getDefault(
248
+ embeddedConfig?.model,
249
+ embeddedConfig?.defaults?.model,
250
+ cfg.defaults.model,
251
+ ),
220
252
  });
221
253
  });
222
254
  }
package/src/routes/git.ts CHANGED
@@ -1,6 +1,7 @@
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
7
  import { generateText, resolveModel, type ProviderId } from '@agi-cli/sdk';
@@ -113,10 +114,13 @@ const gitPushSchema = z.object({
113
114
  // Types
114
115
  export interface GitFile {
115
116
  path: string;
117
+ absPath: string; // NEW: Absolute filesystem path
116
118
  status: 'modified' | 'added' | 'deleted' | 'renamed' | 'untracked';
117
119
  staged: boolean;
118
120
  insertions?: number;
119
121
  deletions?: number;
122
+ oldPath?: string; // For renamed files
123
+ isNew: boolean; // NEW: True for untracked or newly added files
120
124
  }
121
125
 
122
126
  interface GitRoot {
@@ -149,7 +153,25 @@ async function validateAndGetGitRoot(
149
153
  }
150
154
  }
151
155
 
152
- function parseGitStatus(statusOutput: string): {
156
+ /**
157
+ * Check if a file is new/untracked (not in git index)
158
+ */
159
+ async function checkIfNewFile(gitRoot: string, file: string): Promise<boolean> {
160
+ try {
161
+ // Check if file exists in git index or committed
162
+ await execFileAsync('git', ['ls-files', '--error-unmatch', file], {
163
+ cwd: gitRoot,
164
+ });
165
+ return false; // File exists in git
166
+ } catch {
167
+ return true; // File is new/untracked
168
+ }
169
+ }
170
+
171
+ function parseGitStatus(
172
+ statusOutput: string,
173
+ gitRoot: string,
174
+ ): {
153
175
  staged: GitFile[];
154
176
  unstaged: GitFile[];
155
177
  untracked: GitFile[];
@@ -160,34 +182,50 @@ function parseGitStatus(statusOutput: string): {
160
182
  const untracked: GitFile[] = [];
161
183
 
162
184
  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
- }
185
+ // Porcelain v2 format has different line types
186
+ if (line.startsWith('1 ') || line.startsWith('2 ')) {
187
+ // Regular changed entry: "1 XY sub <mH> <mI> <mW> <hH> <hI> <path>"
188
+ // XY is a 2-character field with staged (X) and unstaged (Y) status
189
+ const parts = line.split(' ');
190
+ if (parts.length < 9) continue;
191
+
192
+ const xy = parts[1]; // e.g., ".M", "M.", "MM", "A.", etc.
193
+ const x = xy[0]; // staged status
194
+ const y = xy[1]; // unstaged status
195
+ const path = parts.slice(8).join(' '); // Path can contain spaces
196
+ const absPath = join(gitRoot, path);
197
+
198
+ // Check if file is staged (X is not '.')
199
+ if (x !== '.') {
200
+ staged.push({
201
+ path,
202
+ absPath,
203
+ status: getStatusFromCodeV2(x),
204
+ staged: true,
205
+ isNew: x === 'A',
206
+ });
207
+ }
184
208
 
185
- // Check if file is untracked
186
- if (x === '?' && y === '?') {
209
+ // Check if file is unstaged (Y is not '.')
210
+ if (y !== '.') {
211
+ unstaged.push({
212
+ path,
213
+ absPath,
214
+ status: getStatusFromCodeV2(y),
215
+ staged: false,
216
+ isNew: false,
217
+ });
218
+ }
219
+ } else if (line.startsWith('? ')) {
220
+ // Untracked file: "? <path>"
221
+ const path = line.slice(2);
222
+ const absPath = join(gitRoot, path);
187
223
  untracked.push({
188
224
  path,
225
+ absPath,
189
226
  status: 'untracked',
190
227
  staged: false,
228
+ isNew: true,
191
229
  });
192
230
  }
193
231
  }
@@ -210,6 +248,23 @@ function getStatusFromCode(code: string): GitFile['status'] {
210
248
  }
211
249
  }
212
250
 
251
+ function getStatusFromCodeV2(code: string): GitFile['status'] {
252
+ switch (code) {
253
+ case 'M':
254
+ return 'modified';
255
+ case 'A':
256
+ return 'added';
257
+ case 'D':
258
+ return 'deleted';
259
+ case 'R':
260
+ return 'renamed';
261
+ case 'C':
262
+ return 'modified'; // Copied - treat as modified
263
+ default:
264
+ return 'modified';
265
+ }
266
+ }
267
+
213
268
  async function getAheadBehind(
214
269
  gitRoot: string,
215
270
  ): Promise<{ ahead: number; behind: number }> {
@@ -264,11 +319,14 @@ export function registerGitRoutes(app: Hono) {
264
319
  // Get status
265
320
  const { stdout: statusOutput } = await execFileAsync(
266
321
  'git',
267
- ['status', '--porcelain=v1'],
322
+ ['status', '--porcelain=v2'],
268
323
  { cwd: gitRoot },
269
324
  );
270
325
 
271
- const { staged, unstaged, untracked } = parseGitStatus(statusOutput);
326
+ const { staged, unstaged, untracked } = parseGitStatus(
327
+ statusOutput,
328
+ gitRoot,
329
+ );
272
330
 
273
331
  // Get ahead/behind counts
274
332
  const { ahead, behind } = await getAheadBehind(gitRoot);
@@ -286,6 +344,8 @@ export function registerGitRoutes(app: Hono) {
286
344
  branch,
287
345
  ahead,
288
346
  behind,
347
+ gitRoot, // NEW: Expose git root path
348
+ workingDir: requestedPath, // NEW: Current working directory
289
349
  staged,
290
350
  unstaged,
291
351
  untracked,
@@ -324,8 +384,46 @@ export function registerGitRoutes(app: Hono) {
324
384
  }
325
385
 
326
386
  const { gitRoot } = validation;
387
+ const absPath = join(gitRoot, query.file);
388
+
389
+ // Check if file is new/untracked
390
+ const isNewFile = await checkIfNewFile(gitRoot, query.file);
391
+
392
+ // For new files, read and return full content
393
+ if (isNewFile) {
394
+ try {
395
+ const content = await readFile(absPath, 'utf-8');
396
+ const lineCount = content.split('\n').length;
397
+ const language = inferLanguage(query.file);
398
+
399
+ return c.json({
400
+ status: 'ok',
401
+ data: {
402
+ file: query.file,
403
+ absPath,
404
+ diff: '', // Empty diff for new files
405
+ content, // NEW: Full file content
406
+ isNewFile: true, // NEW: Flag indicating this is a new file
407
+ isBinary: false,
408
+ insertions: lineCount,
409
+ deletions: 0,
410
+ language,
411
+ staged: !!query.staged, // NEW: Whether showing staged or unstaged
412
+ },
413
+ });
414
+ } catch (error) {
415
+ return c.json(
416
+ {
417
+ status: 'error',
418
+ error:
419
+ error instanceof Error ? error.message : 'Failed to read file',
420
+ },
421
+ 500,
422
+ );
423
+ }
424
+ }
327
425
 
328
- // Get diff output and stats for the requested file
426
+ // For existing files, get diff output and stats
329
427
  const diffArgs = query.staged
330
428
  ? ['diff', '--cached', '--', query.file]
331
429
  : ['diff', '--', query.file];
@@ -370,11 +468,14 @@ export function registerGitRoutes(app: Hono) {
370
468
  status: 'ok',
371
469
  data: {
372
470
  file: query.file,
471
+ absPath, // NEW: Absolute path
373
472
  diff: diffText,
473
+ isNewFile: false, // NEW: Not a new file
474
+ isBinary: binary,
374
475
  insertions,
375
476
  deletions,
376
477
  language,
377
- binary,
478
+ staged: !!query.staged, // NEW: Whether showing staged or unstaged
378
479
  },
379
480
  });
380
481
  } catch (error) {
@@ -568,10 +669,10 @@ export function registerGitRoutes(app: Hono) {
568
669
  // Get file list for context
569
670
  const { stdout: statusOutput } = await execFileAsync(
570
671
  'git',
571
- ['status', '--porcelain=v1'],
672
+ ['status', '--porcelain=v2'],
572
673
  { cwd: gitRoot },
573
674
  );
574
- const { staged } = parseGitStatus(statusOutput);
675
+ const { staged } = parseGitStatus(statusOutput, gitRoot);
575
676
  const fileList = staged.map((f) => `${f.status}: ${f.path}`).join('\n');
576
677
 
577
678
  // Load config to get provider settings