@ottocode/server 0.1.260 → 0.1.262

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.
Files changed (67) hide show
  1. package/package.json +4 -3
  2. package/src/index.ts +5 -4
  3. package/src/openapi/register.ts +92 -0
  4. package/src/openapi/route.ts +22 -0
  5. package/src/routes/ask.ts +210 -99
  6. package/src/routes/auth.ts +1701 -626
  7. package/src/routes/branch.ts +281 -90
  8. package/src/routes/config/agents.ts +79 -32
  9. package/src/routes/config/cwd.ts +46 -14
  10. package/src/routes/config/debug.ts +159 -30
  11. package/src/routes/config/defaults.ts +182 -64
  12. package/src/routes/config/main.ts +109 -73
  13. package/src/routes/config/models.ts +304 -137
  14. package/src/routes/config/providers.ts +462 -166
  15. package/src/routes/config/utils.ts +2 -2
  16. package/src/routes/doctor.ts +395 -161
  17. package/src/routes/files.ts +650 -260
  18. package/src/routes/git/branch.ts +143 -52
  19. package/src/routes/git/commit.ts +347 -141
  20. package/src/routes/git/diff.ts +239 -116
  21. package/src/routes/git/init.ts +103 -23
  22. package/src/routes/git/pull.ts +167 -65
  23. package/src/routes/git/push.ts +222 -117
  24. package/src/routes/git/remote.ts +401 -100
  25. package/src/routes/git/staging.ts +502 -141
  26. package/src/routes/git/status.ts +171 -78
  27. package/src/routes/mcp.ts +1129 -404
  28. package/src/routes/openapi.ts +27 -4
  29. package/src/routes/ottorouter.ts +1221 -389
  30. package/src/routes/provider-usage.ts +153 -36
  31. package/src/routes/research.ts +817 -370
  32. package/src/routes/root.ts +50 -6
  33. package/src/routes/session-approval.ts +228 -54
  34. package/src/routes/session-files.ts +265 -134
  35. package/src/routes/session-messages.ts +330 -150
  36. package/src/routes/session-stream.ts +83 -2
  37. package/src/routes/sessions.ts +1830 -780
  38. package/src/routes/skills.ts +849 -161
  39. package/src/routes/terminals.ts +469 -103
  40. package/src/routes/tunnel.ts +394 -118
  41. package/src/runtime/ask/service.ts +1 -0
  42. package/src/runtime/message/compaction-limits.ts +3 -3
  43. package/src/runtime/provider/reasoning.ts +2 -1
  44. package/src/runtime/session/db-operations.ts +4 -3
  45. package/src/runtime/utils/token.ts +7 -2
  46. package/src/tools/adapter.ts +21 -0
  47. package/src/openapi/paths/ask.ts +0 -81
  48. package/src/openapi/paths/auth.ts +0 -687
  49. package/src/openapi/paths/branch.ts +0 -102
  50. package/src/openapi/paths/config.ts +0 -485
  51. package/src/openapi/paths/doctor.ts +0 -165
  52. package/src/openapi/paths/files.ts +0 -236
  53. package/src/openapi/paths/git.ts +0 -690
  54. package/src/openapi/paths/mcp.ts +0 -339
  55. package/src/openapi/paths/messages.ts +0 -103
  56. package/src/openapi/paths/ottorouter.ts +0 -594
  57. package/src/openapi/paths/provider-usage.ts +0 -59
  58. package/src/openapi/paths/research.ts +0 -227
  59. package/src/openapi/paths/session-approval.ts +0 -93
  60. package/src/openapi/paths/session-extras.ts +0 -336
  61. package/src/openapi/paths/session-files.ts +0 -91
  62. package/src/openapi/paths/sessions.ts +0 -210
  63. package/src/openapi/paths/skills.ts +0 -377
  64. package/src/openapi/paths/stream.ts +0 -26
  65. package/src/openapi/paths/terminals.ts +0 -226
  66. package/src/openapi/paths/tunnel.ts +0 -163
  67. package/src/openapi/spec.ts +0 -73
@@ -8,6 +8,7 @@ import { promisify } from 'node:util';
8
8
  import { serializeError } from '../runtime/errors/api-error.ts';
9
9
  import { logger } from '@ottocode/sdk';
10
10
  import { resolveBinary } from '@ottocode/sdk/tools/bin-manager';
11
+ import { openApiRoute } from '../openapi/route.ts';
11
12
 
12
13
  const execAsync = promisify(exec);
13
14
 
@@ -314,291 +315,680 @@ async function getGitIgnoredFiles(
314
315
  }
315
316
 
316
317
  export function registerFilesRoutes(app: Hono) {
317
- app.get('/v1/files', async (c) => {
318
- try {
319
- const projectRoot = c.req.query('project') || process.cwd();
320
- const policy = getSearchPolicy(projectRoot);
321
- const maxDepth = clampNumber(
322
- Number.parseInt(c.req.query('maxDepth') || String(policy.maxDepth), 10),
323
- 1,
324
- policy.maxDepth,
325
- );
326
- const limit = clampNumber(
327
- Number.parseInt(c.req.query('limit') || String(policy.limit), 10),
328
- 1,
329
- policy.limit,
330
- );
331
-
332
- let result = await listFilesWithRg(
333
- projectRoot,
334
- maxDepth,
335
- limit,
336
- policy.includeIgnored,
337
- );
318
+ openApiRoute(
319
+ app,
320
+ {
321
+ method: 'get',
322
+ path: '/v1/files',
323
+ tags: ['files'],
324
+ operationId: 'listFiles',
325
+ summary: 'List project files',
326
+ description:
327
+ 'Returns list of files in the project directory, excluding common build artifacts and dependencies',
328
+ parameters: [
329
+ {
330
+ in: 'query',
331
+ name: 'project',
332
+ required: false,
333
+ schema: {
334
+ type: 'string',
335
+ },
336
+ description:
337
+ 'Project root override (defaults to current working directory).',
338
+ },
339
+ {
340
+ in: 'query',
341
+ name: 'maxDepth',
342
+ required: false,
343
+ schema: {
344
+ type: 'integer',
345
+ default: 10,
346
+ },
347
+ description: 'Maximum directory depth to traverse',
348
+ },
349
+ {
350
+ in: 'query',
351
+ name: 'limit',
352
+ required: false,
353
+ schema: {
354
+ type: 'integer',
355
+ default: 1000,
356
+ },
357
+ description: 'Maximum number of files to return',
358
+ },
359
+ ],
360
+ responses: {
361
+ '200': {
362
+ description: 'OK',
363
+ content: {
364
+ 'application/json': {
365
+ schema: {
366
+ type: 'object',
367
+ properties: {
368
+ files: {
369
+ type: 'array',
370
+ items: {
371
+ type: 'string',
372
+ },
373
+ },
374
+ changedFiles: {
375
+ type: 'array',
376
+ items: {
377
+ type: 'object',
378
+ properties: {
379
+ path: {
380
+ type: 'string',
381
+ },
382
+ status: {
383
+ type: 'string',
384
+ enum: [
385
+ 'added',
386
+ 'modified',
387
+ 'deleted',
388
+ 'renamed',
389
+ 'untracked',
390
+ ],
391
+ },
392
+ },
393
+ required: ['path', 'status'],
394
+ },
395
+ description:
396
+ 'List of files with uncommitted changes (from git status)',
397
+ },
398
+ truncated: {
399
+ type: 'boolean',
400
+ },
401
+ },
402
+ required: ['files', 'changedFiles', 'truncated'],
403
+ },
404
+ },
405
+ },
406
+ },
407
+ },
408
+ },
409
+ async (c) => {
410
+ try {
411
+ const projectRoot = c.req.query('project') || process.cwd();
412
+ const policy = getSearchPolicy(projectRoot);
413
+ const maxDepth = clampNumber(
414
+ Number.parseInt(
415
+ c.req.query('maxDepth') || String(policy.maxDepth),
416
+ 10,
417
+ ),
418
+ 1,
419
+ policy.maxDepth,
420
+ );
421
+ const limit = clampNumber(
422
+ Number.parseInt(c.req.query('limit') || String(policy.limit), 10),
423
+ 1,
424
+ policy.limit,
425
+ );
338
426
 
339
- if (result.files.length === 0) {
340
- const gitignorePatterns = await parseGitignore(projectRoot);
341
- result = await traverseDirectory(
342
- projectRoot,
427
+ let result = await listFilesWithRg(
343
428
  projectRoot,
344
429
  maxDepth,
345
- 0,
346
430
  limit,
347
- [],
348
- gitignorePatterns,
431
+ policy.includeIgnored,
349
432
  );
350
- }
351
433
 
352
- const [changedFiles, ignoredFiles] = await Promise.all([
353
- getChangedFiles(projectRoot),
354
- getGitIgnoredFiles(projectRoot, result.files),
355
- ]);
356
-
357
- result.files.sort((a, b) => {
358
- const aIgnored = ignoredFiles.has(a);
359
- const bIgnored = ignoredFiles.has(b);
360
- if (aIgnored !== bIgnored) return aIgnored ? 1 : -1;
361
- const aChanged = changedFiles.has(a);
362
- const bChanged = changedFiles.has(b);
363
- if (aChanged && !bChanged) return -1;
364
- if (!aChanged && bChanged) return 1;
365
- return a.localeCompare(b);
366
- });
434
+ if (result.files.length === 0) {
435
+ const gitignorePatterns = await parseGitignore(projectRoot);
436
+ result = await traverseDirectory(
437
+ projectRoot,
438
+ projectRoot,
439
+ maxDepth,
440
+ 0,
441
+ limit,
442
+ [],
443
+ gitignorePatterns,
444
+ );
445
+ }
367
446
 
368
- return c.json({
369
- files: result.files,
370
- ignoredFiles: Array.from(ignoredFiles),
371
- changedFiles: Array.from(changedFiles.entries()).map(
372
- ([path, status]) => ({
373
- path,
374
- status,
375
- }),
376
- ),
377
- truncated: result.truncated,
378
- policy: {
379
- maxDepth,
380
- limit,
381
- home: isHomeDirectory(projectRoot),
447
+ const [changedFiles, ignoredFiles] = await Promise.all([
448
+ getChangedFiles(projectRoot),
449
+ getGitIgnoredFiles(projectRoot, result.files),
450
+ ]);
451
+
452
+ result.files.sort((a, b) => {
453
+ const aIgnored = ignoredFiles.has(a);
454
+ const bIgnored = ignoredFiles.has(b);
455
+ if (aIgnored !== bIgnored) return aIgnored ? 1 : -1;
456
+ const aChanged = changedFiles.has(a);
457
+ const bChanged = changedFiles.has(b);
458
+ if (aChanged && !bChanged) return -1;
459
+ if (!aChanged && bChanged) return 1;
460
+ return a.localeCompare(b);
461
+ });
462
+
463
+ return c.json({
464
+ files: result.files,
465
+ ignoredFiles: Array.from(ignoredFiles),
466
+ changedFiles: Array.from(changedFiles.entries()).map(
467
+ ([path, status]) => ({
468
+ path,
469
+ status,
470
+ }),
471
+ ),
472
+ truncated: result.truncated,
473
+ policy: {
474
+ maxDepth,
475
+ limit,
476
+ home: isHomeDirectory(projectRoot),
477
+ },
478
+ });
479
+ } catch (err) {
480
+ logger.error('Files route error:', err);
481
+ return c.json({ error: serializeError(err) }, 500);
482
+ }
483
+ },
484
+ );
485
+
486
+ openApiRoute(
487
+ app,
488
+ {
489
+ method: 'get',
490
+ path: '/v1/files/search',
491
+ tags: ['files'],
492
+ operationId: 'searchFiles',
493
+ summary: 'Search project files',
494
+ description:
495
+ 'Searches files for mentions and quick-open. Excludes dependencies, build artifacts, and gitignored files by default.',
496
+ parameters: [
497
+ {
498
+ in: 'query',
499
+ name: 'project',
500
+ required: false,
501
+ schema: {
502
+ type: 'string',
503
+ },
504
+ description:
505
+ 'Project root override (defaults to current working directory).',
382
506
  },
383
- });
384
- } catch (err) {
385
- logger.error('Files route error:', err);
386
- return c.json({ error: serializeError(err) }, 500);
387
- }
388
- });
389
-
390
- app.get('/v1/files/search', async (c) => {
391
- try {
392
- const projectRoot = c.req.query('project') || process.cwd();
393
- const query = c.req.query('q') || '';
394
- const policy = getSearchPolicy(projectRoot);
395
- const maxDepth = clampNumber(
396
- Number.parseInt(c.req.query('maxDepth') || String(policy.maxDepth), 10),
397
- 1,
398
- policy.maxDepth,
399
- );
400
- const limit = clampNumber(
401
- Number.parseInt(c.req.query('limit') || String(policy.limit), 10),
402
- 1,
403
- policy.limit,
404
- );
405
-
406
- let result = await listFilesWithRg(
407
- projectRoot,
408
- maxDepth,
409
- limit,
410
- policy.includeIgnored,
411
- query,
412
- );
507
+ {
508
+ in: 'query',
509
+ name: 'q',
510
+ required: false,
511
+ schema: {
512
+ type: 'string',
513
+ default: '',
514
+ },
515
+ description: 'Search query',
516
+ },
517
+ {
518
+ in: 'query',
519
+ name: 'maxDepth',
520
+ required: false,
521
+ schema: {
522
+ type: 'integer',
523
+ },
524
+ description: 'Maximum directory depth to traverse',
525
+ },
526
+ {
527
+ in: 'query',
528
+ name: 'limit',
529
+ required: false,
530
+ schema: {
531
+ type: 'integer',
532
+ },
533
+ description: 'Maximum number of files to return',
534
+ },
535
+ ],
536
+ responses: {
537
+ '200': {
538
+ description: 'OK',
539
+ content: {
540
+ 'application/json': {
541
+ schema: {
542
+ type: 'object',
543
+ properties: {
544
+ files: {
545
+ type: 'array',
546
+ items: {
547
+ type: 'string',
548
+ },
549
+ },
550
+ changedFiles: {
551
+ type: 'array',
552
+ items: {
553
+ type: 'object',
554
+ properties: {
555
+ path: {
556
+ type: 'string',
557
+ },
558
+ status: {
559
+ type: 'string',
560
+ },
561
+ },
562
+ required: ['path', 'status'],
563
+ },
564
+ },
565
+ truncated: {
566
+ type: 'boolean',
567
+ },
568
+ },
569
+ required: ['files', 'changedFiles', 'truncated'],
570
+ },
571
+ },
572
+ },
573
+ },
574
+ },
575
+ },
576
+ async (c) => {
577
+ try {
578
+ const projectRoot = c.req.query('project') || process.cwd();
579
+ const query = c.req.query('q') || '';
580
+ const policy = getSearchPolicy(projectRoot);
581
+ const maxDepth = clampNumber(
582
+ Number.parseInt(
583
+ c.req.query('maxDepth') || String(policy.maxDepth),
584
+ 10,
585
+ ),
586
+ 1,
587
+ policy.maxDepth,
588
+ );
589
+ const limit = clampNumber(
590
+ Number.parseInt(c.req.query('limit') || String(policy.limit), 10),
591
+ 1,
592
+ policy.limit,
593
+ );
413
594
 
414
- if (result.files.length === 0) {
415
- const gitignorePatterns = await parseGitignore(projectRoot);
416
- result = await traverseDirectory(
417
- projectRoot,
595
+ let result = await listFilesWithRg(
418
596
  projectRoot,
419
597
  maxDepth,
420
- 0,
421
598
  limit,
422
- [],
423
- gitignorePatterns,
599
+ policy.includeIgnored,
600
+ query,
424
601
  );
425
- const normalizedQuery = query.trim().toLowerCase();
426
- if (normalizedQuery) {
427
- const files = result.files.filter((file) =>
428
- file.toLowerCase().includes(normalizedQuery),
602
+
603
+ if (result.files.length === 0) {
604
+ const gitignorePatterns = await parseGitignore(projectRoot);
605
+ result = await traverseDirectory(
606
+ projectRoot,
607
+ projectRoot,
608
+ maxDepth,
609
+ 0,
610
+ limit,
611
+ [],
612
+ gitignorePatterns,
429
613
  );
430
- result = {
431
- files: files.slice(0, limit),
432
- truncated: files.length > limit,
433
- };
614
+ const normalizedQuery = query.trim().toLowerCase();
615
+ if (normalizedQuery) {
616
+ const files = result.files.filter((file) =>
617
+ file.toLowerCase().includes(normalizedQuery),
618
+ );
619
+ result = {
620
+ files: files.slice(0, limit),
621
+ truncated: files.length > limit,
622
+ };
623
+ }
434
624
  }
435
- }
436
-
437
- const [changedFiles, ignoredFiles] = await Promise.all([
438
- getChangedFiles(projectRoot),
439
- getGitIgnoredFiles(projectRoot, result.files),
440
- ]);
441
-
442
- result.files.sort((a, b) => {
443
- const aIgnored = ignoredFiles.has(a);
444
- const bIgnored = ignoredFiles.has(b);
445
- if (aIgnored !== bIgnored) return aIgnored ? 1 : -1;
446
- const aChanged = changedFiles.has(a);
447
- const bChanged = changedFiles.has(b);
448
- if (aChanged && !bChanged) return -1;
449
- if (!aChanged && bChanged) return 1;
450
- return a.localeCompare(b);
451
- });
452
-
453
- return c.json({
454
- files: result.files,
455
- ignoredFiles: Array.from(ignoredFiles),
456
- changedFiles: Array.from(changedFiles.entries()).map(
457
- ([path, status]) => ({
458
- path,
459
- status,
460
- }),
461
- ),
462
- truncated: result.truncated,
463
- policy: {
464
- maxDepth,
465
- limit,
466
- home: isHomeDirectory(projectRoot),
467
- },
468
- });
469
- } catch (err) {
470
- logger.error('Files search route error:', err);
471
- return c.json({ error: serializeError(err) }, 500);
472
- }
473
- });
474
625
 
475
- app.get('/v1/files/tree', async (c) => {
476
- try {
477
- const projectRoot = c.req.query('project') || process.cwd();
478
- const dirPath = c.req.query('path') || '.';
479
- const targetDir = resolve(projectRoot, dirPath);
480
- if (!targetDir.startsWith(resolve(projectRoot))) {
481
- return c.json({ error: 'Path traversal not allowed' }, 403);
626
+ const [changedFiles, ignoredFiles] = await Promise.all([
627
+ getChangedFiles(projectRoot),
628
+ getGitIgnoredFiles(projectRoot, result.files),
629
+ ]);
630
+
631
+ result.files.sort((a, b) => {
632
+ const aIgnored = ignoredFiles.has(a);
633
+ const bIgnored = ignoredFiles.has(b);
634
+ if (aIgnored !== bIgnored) return aIgnored ? 1 : -1;
635
+ const aChanged = changedFiles.has(a);
636
+ const bChanged = changedFiles.has(b);
637
+ if (aChanged && !bChanged) return -1;
638
+ if (!aChanged && bChanged) return 1;
639
+ return a.localeCompare(b);
640
+ });
641
+
642
+ return c.json({
643
+ files: result.files,
644
+ ignoredFiles: Array.from(ignoredFiles),
645
+ changedFiles: Array.from(changedFiles.entries()).map(
646
+ ([path, status]) => ({
647
+ path,
648
+ status,
649
+ }),
650
+ ),
651
+ truncated: result.truncated,
652
+ policy: {
653
+ maxDepth,
654
+ limit,
655
+ home: isHomeDirectory(projectRoot),
656
+ },
657
+ });
658
+ } catch (err) {
659
+ logger.error('Files search route error:', err);
660
+ return c.json({ error: serializeError(err) }, 500);
482
661
  }
483
-
484
- const gitignorePatterns = await parseGitignore(projectRoot);
485
- const entries = await readdir(targetDir, { withFileTypes: true });
486
- const truncated = entries.length > TREE_ENTRY_LIMIT;
487
-
488
- const items: Array<{
489
- name: string;
490
- path: string;
491
- type: 'file' | 'directory';
492
- gitignored?: boolean;
493
- vendor?: boolean;
494
- searchable?: boolean;
495
- }> = [];
496
-
497
- for (const entry of entries.slice(0, TREE_ENTRY_LIMIT)) {
498
- const relPath = relative(projectRoot, join(targetDir, entry.name));
499
-
500
- if (entry.isDirectory()) {
501
- const ignored = matchesGitignorePattern(relPath, gitignorePatterns);
502
- const vendor = shouldExcludeDir(entry.name);
503
- items.push({
504
- name: entry.name,
505
- path: relPath,
506
- type: 'directory',
507
- gitignored: ignored || undefined,
508
- vendor: vendor || undefined,
509
- searchable: vendor || ignored ? false : undefined,
510
- });
511
- } else if (entry.isFile()) {
512
- if (shouldExcludeFile(entry.name)) continue;
513
- const ignored = matchesGitignorePattern(relPath, gitignorePatterns);
514
- items.push({
515
- name: entry.name,
516
- path: relPath,
517
- type: 'file',
518
- gitignored: ignored || undefined,
519
- searchable: ignored ? false : undefined,
520
- });
662
+ },
663
+ );
664
+
665
+ openApiRoute(
666
+ app,
667
+ {
668
+ method: 'get',
669
+ path: '/v1/files/tree',
670
+ tags: ['files'],
671
+ operationId: 'getFileTree',
672
+ summary: 'Get directory tree listing',
673
+ parameters: [
674
+ {
675
+ in: 'query',
676
+ name: 'project',
677
+ required: false,
678
+ schema: {
679
+ type: 'string',
680
+ },
681
+ description:
682
+ 'Project root override (defaults to current working directory).',
683
+ },
684
+ {
685
+ in: 'query',
686
+ name: 'path',
687
+ required: false,
688
+ schema: {
689
+ type: 'string',
690
+ default: '.',
691
+ },
692
+ description: 'Directory path relative to project root',
693
+ },
694
+ ],
695
+ responses: {
696
+ '200': {
697
+ description: 'OK',
698
+ content: {
699
+ 'application/json': {
700
+ schema: {
701
+ type: 'object',
702
+ properties: {
703
+ items: {
704
+ type: 'array',
705
+ items: {
706
+ type: 'object',
707
+ properties: {
708
+ name: {
709
+ type: 'string',
710
+ },
711
+ path: {
712
+ type: 'string',
713
+ },
714
+ type: {
715
+ type: 'string',
716
+ enum: ['file', 'directory'],
717
+ },
718
+ gitignored: {
719
+ type: 'boolean',
720
+ },
721
+ vendor: {
722
+ type: 'boolean',
723
+ },
724
+ searchable: {
725
+ type: 'boolean',
726
+ },
727
+ },
728
+ required: ['name', 'path', 'type'],
729
+ },
730
+ },
731
+ path: {
732
+ type: 'string',
733
+ },
734
+ truncated: {
735
+ type: 'boolean',
736
+ },
737
+ },
738
+ required: ['items', 'path', 'truncated'],
739
+ },
740
+ },
741
+ },
742
+ },
743
+ },
744
+ },
745
+ async (c) => {
746
+ try {
747
+ const projectRoot = c.req.query('project') || process.cwd();
748
+ const dirPath = c.req.query('path') || '.';
749
+ const targetDir = resolve(projectRoot, dirPath);
750
+ if (!targetDir.startsWith(resolve(projectRoot))) {
751
+ return c.json({ error: 'Path traversal not allowed' }, 403);
521
752
  }
522
- }
523
753
 
524
- items.sort((a, b) => {
525
- if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
526
- const aIgnored = a.gitignored ?? false;
527
- const bIgnored = b.gitignored ?? false;
528
- if (aIgnored !== bIgnored) return aIgnored ? 1 : -1;
529
- return a.name.localeCompare(b.name);
530
- });
531
-
532
- return c.json({ items, path: dirPath, truncated });
533
- } catch (err) {
534
- logger.error('Files tree route error:', err);
535
- return c.json({ error: serializeError(err) }, 500);
536
- }
537
- });
538
-
539
- app.get('/v1/files/read', async (c) => {
540
- try {
541
- const projectRoot = c.req.query('project') || process.cwd();
542
- const filePath = c.req.query('path');
543
-
544
- if (!filePath) {
545
- return c.json({ error: 'Missing required query parameter: path' }, 400);
546
- }
754
+ const gitignorePatterns = await parseGitignore(projectRoot);
755
+ const entries = await readdir(targetDir, { withFileTypes: true });
756
+ const truncated = entries.length > TREE_ENTRY_LIMIT;
757
+
758
+ const items: Array<{
759
+ name: string;
760
+ path: string;
761
+ type: 'file' | 'directory';
762
+ gitignored?: boolean;
763
+ vendor?: boolean;
764
+ searchable?: boolean;
765
+ }> = [];
766
+
767
+ for (const entry of entries.slice(0, TREE_ENTRY_LIMIT)) {
768
+ const relPath = relative(projectRoot, join(targetDir, entry.name));
769
+
770
+ if (entry.isDirectory()) {
771
+ const ignored = matchesGitignorePattern(relPath, gitignorePatterns);
772
+ const vendor = shouldExcludeDir(entry.name);
773
+ items.push({
774
+ name: entry.name,
775
+ path: relPath,
776
+ type: 'directory',
777
+ gitignored: ignored || undefined,
778
+ vendor: vendor || undefined,
779
+ searchable: vendor || ignored ? false : undefined,
780
+ });
781
+ } else if (entry.isFile()) {
782
+ if (shouldExcludeFile(entry.name)) continue;
783
+ const ignored = matchesGitignorePattern(relPath, gitignorePatterns);
784
+ items.push({
785
+ name: entry.name,
786
+ path: relPath,
787
+ type: 'file',
788
+ gitignored: ignored || undefined,
789
+ searchable: ignored ? false : undefined,
790
+ });
791
+ }
792
+ }
547
793
 
548
- const absPath = join(projectRoot, filePath);
549
- if (!absPath.startsWith(projectRoot)) {
550
- return c.json({ error: 'Path traversal not allowed' }, 403);
794
+ items.sort((a, b) => {
795
+ if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
796
+ const aIgnored = a.gitignored ?? false;
797
+ const bIgnored = b.gitignored ?? false;
798
+ if (aIgnored !== bIgnored) return aIgnored ? 1 : -1;
799
+ return a.name.localeCompare(b.name);
800
+ });
801
+
802
+ return c.json({ items, path: dirPath, truncated });
803
+ } catch (err) {
804
+ logger.error('Files tree route error:', err);
805
+ return c.json({ error: serializeError(err) }, 500);
551
806
  }
807
+ },
808
+ );
809
+
810
+ openApiRoute(
811
+ app,
812
+ {
813
+ method: 'get',
814
+ path: '/v1/files/read',
815
+ tags: ['files'],
816
+ operationId: 'readFile',
817
+ summary: 'Read file content',
818
+ parameters: [
819
+ {
820
+ in: 'query',
821
+ name: 'project',
822
+ required: false,
823
+ schema: {
824
+ type: 'string',
825
+ },
826
+ description:
827
+ 'Project root override (defaults to current working directory).',
828
+ },
829
+ {
830
+ in: 'query',
831
+ name: 'path',
832
+ required: true,
833
+ schema: {
834
+ type: 'string',
835
+ },
836
+ description: 'File path relative to project root',
837
+ },
838
+ ],
839
+ responses: {
840
+ '200': {
841
+ description: 'OK',
842
+ content: {
843
+ 'application/json': {
844
+ schema: {
845
+ type: 'object',
846
+ properties: {
847
+ content: {
848
+ type: 'string',
849
+ },
850
+ path: {
851
+ type: 'string',
852
+ },
853
+ extension: {
854
+ type: 'string',
855
+ },
856
+ lineCount: {
857
+ type: 'integer',
858
+ },
859
+ },
860
+ required: ['content', 'path', 'extension', 'lineCount'],
861
+ },
862
+ },
863
+ },
864
+ },
865
+ '400': {
866
+ description: 'Bad Request',
867
+ content: {
868
+ 'application/json': {
869
+ schema: {
870
+ type: 'object',
871
+ properties: {
872
+ error: {
873
+ type: 'string',
874
+ },
875
+ },
876
+ required: ['error'],
877
+ },
878
+ },
879
+ },
880
+ },
881
+ },
882
+ },
883
+ async (c) => {
884
+ try {
885
+ const projectRoot = c.req.query('project') || process.cwd();
886
+ const filePath = c.req.query('path');
887
+
888
+ if (!filePath) {
889
+ return c.json(
890
+ { error: 'Missing required query parameter: path' },
891
+ 400,
892
+ );
893
+ }
552
894
 
553
- const content = await readFile(absPath, 'utf-8');
554
- const extension = filePath.split('.').pop()?.toLowerCase() ?? '';
555
- const lineCount = content.split('\n').length;
556
-
557
- return c.json({ content, path: filePath, extension, lineCount });
558
- } catch (err) {
559
- logger.error('Files read route error:', err);
560
- return c.json({ error: serializeError(err) }, 500);
561
- }
562
- });
895
+ const absPath = join(projectRoot, filePath);
896
+ if (!absPath.startsWith(projectRoot)) {
897
+ return c.json({ error: 'Path traversal not allowed' }, 403);
898
+ }
563
899
 
564
- app.get('/v1/files/raw', async (c) => {
565
- try {
566
- const projectRoot = c.req.query('project') || process.cwd();
567
- const filePath = c.req.query('path');
900
+ const content = await readFile(absPath, 'utf-8');
901
+ const extension = filePath.split('.').pop()?.toLowerCase() ?? '';
902
+ const lineCount = content.split('\n').length;
568
903
 
569
- if (!filePath) {
570
- return c.json({ error: 'Missing required query parameter: path' }, 400);
904
+ return c.json({ content, path: filePath, extension, lineCount });
905
+ } catch (err) {
906
+ logger.error('Files read route error:', err);
907
+ return c.json({ error: serializeError(err) }, 500);
571
908
  }
909
+ },
910
+ );
911
+
912
+ openApiRoute(
913
+ app,
914
+ {
915
+ method: 'get',
916
+ path: '/v1/files/raw',
917
+ tags: ['files'],
918
+ operationId: 'getFileRaw',
919
+ summary: 'Read raw file bytes',
920
+ parameters: [
921
+ {
922
+ in: 'query',
923
+ name: 'project',
924
+ required: false,
925
+ schema: { type: 'string' },
926
+ description:
927
+ 'Project root override (defaults to current working directory).',
928
+ },
929
+ {
930
+ in: 'query',
931
+ name: 'path',
932
+ required: true,
933
+ schema: { type: 'string' },
934
+ description: 'Relative file path to read.',
935
+ },
936
+ ],
937
+ responses: {
938
+ '200': {
939
+ description: 'Raw file content',
940
+ content: {
941
+ 'application/octet-stream': {
942
+ schema: { type: 'string', format: 'binary' },
943
+ },
944
+ },
945
+ },
946
+ '400': { description: 'Missing path parameter' },
947
+ '403': { description: 'Path traversal not allowed' },
948
+ },
949
+ },
950
+ async (c) => {
951
+ try {
952
+ const projectRoot = c.req.query('project') || process.cwd();
953
+ const filePath = c.req.query('path');
954
+
955
+ if (!filePath) {
956
+ return c.json(
957
+ { error: 'Missing required query parameter: path' },
958
+ 400,
959
+ );
960
+ }
572
961
 
573
- const absPath = join(projectRoot, filePath);
574
- if (!absPath.startsWith(projectRoot)) {
575
- return c.json({ error: 'Path traversal not allowed' }, 403);
576
- }
962
+ const absPath = join(projectRoot, filePath);
963
+ if (!absPath.startsWith(projectRoot)) {
964
+ return c.json({ error: 'Path traversal not allowed' }, 403);
965
+ }
577
966
 
578
- const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
579
- const mimeTypes: Record<string, string> = {
580
- png: 'image/png',
581
- jpg: 'image/jpeg',
582
- jpeg: 'image/jpeg',
583
- gif: 'image/gif',
584
- svg: 'image/svg+xml',
585
- webp: 'image/webp',
586
- ico: 'image/x-icon',
587
- bmp: 'image/bmp',
588
- avif: 'image/avif',
589
- };
590
- const contentType = mimeTypes[ext] || 'application/octet-stream';
591
-
592
- const data = await readFile(absPath);
593
- return new Response(data, {
594
- headers: {
595
- 'Content-Type': contentType,
596
- 'Cache-Control': 'no-cache',
597
- },
598
- });
599
- } catch (err) {
600
- logger.error('Files raw route error:', err);
601
- return c.json({ error: serializeError(err) }, 500);
602
- }
603
- });
967
+ const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
968
+ const mimeTypes: Record<string, string> = {
969
+ png: 'image/png',
970
+ jpg: 'image/jpeg',
971
+ jpeg: 'image/jpeg',
972
+ gif: 'image/gif',
973
+ svg: 'image/svg+xml',
974
+ webp: 'image/webp',
975
+ ico: 'image/x-icon',
976
+ bmp: 'image/bmp',
977
+ avif: 'image/avif',
978
+ };
979
+ const contentType = mimeTypes[ext] || 'application/octet-stream';
980
+
981
+ const data = await readFile(absPath);
982
+ return new Response(data, {
983
+ headers: {
984
+ 'Content-Type': contentType,
985
+ 'Cache-Control': 'no-cache',
986
+ },
987
+ });
988
+ } catch (err) {
989
+ logger.error('Files raw route error:', err);
990
+ return c.json({ error: serializeError(err) }, 500);
991
+ }
992
+ },
993
+ );
604
994
  }