btca-server 1.0.40 → 1.0.43

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": "btca-server",
3
- "version": "1.0.40",
3
+ "version": "1.0.43",
4
4
  "description": "BTCA server for answering questions about your codebase using OpenCode AI",
5
5
  "author": "Ben Davis",
6
6
  "license": "MIT",
@@ -89,9 +89,15 @@ export namespace Agent {
89
89
  url: string;
90
90
  model: { provider: string; model: string };
91
91
  }>;
92
+
93
+ listProviders: () => Promise<{ all: { id: string; models: Record<string, unknown> }[]; connected: string[] }>;
92
94
  };
93
95
 
94
- const buildOpenCodeConfig = (args: { agentInstructions: string }): OpenCodeConfig => {
96
+ const buildOpenCodeConfig = (args: {
97
+ agentInstructions: string;
98
+ providerId?: string;
99
+ providerTimeoutMs?: number;
100
+ }): OpenCodeConfig => {
95
101
  const prompt = [
96
102
  'You are an expert internal agent whose job is to answer questions about the collection.',
97
103
  'You operate inside a collection directory.',
@@ -99,6 +105,17 @@ export namespace Agent {
99
105
  args.agentInstructions
100
106
  ].join('\n');
101
107
 
108
+ const providerConfig =
109
+ args.providerId && typeof args.providerTimeoutMs === 'number'
110
+ ? {
111
+ [args.providerId]: {
112
+ options: {
113
+ timeout: args.providerTimeoutMs
114
+ }
115
+ }
116
+ }
117
+ : undefined;
118
+
102
119
  return {
103
120
  agent: {
104
121
  build: { disable: true },
@@ -135,7 +152,8 @@ export namespace Agent {
135
152
  edit: false
136
153
  }
137
154
  }
138
- }
155
+ },
156
+ ...(providerConfig ? { provider: providerConfig } : {})
139
157
  };
140
158
  };
141
159
 
@@ -164,7 +182,7 @@ export namespace Agent {
164
182
  }
165
183
  };
166
184
 
167
- const getOpencodeInstance = async (args: {
185
+ const createOpencodeInstance = async (args: {
168
186
  collectionPath: string;
169
187
  ocConfig: OpenCodeConfig;
170
188
  }): Promise<{
@@ -248,8 +266,12 @@ export namespace Agent {
248
266
 
249
267
  export const create = (config: Config.Service): Service => {
250
268
  const askStream: Service['askStream'] = async ({ collection, question }) => {
251
- const ocConfig = buildOpenCodeConfig({ agentInstructions: collection.agentInstructions });
252
- const { client, server, baseUrl } = await getOpencodeInstance({
269
+ const ocConfig = buildOpenCodeConfig({
270
+ agentInstructions: collection.agentInstructions,
271
+ providerId: config.provider,
272
+ providerTimeoutMs: config.providerTimeoutMs
273
+ });
274
+ const { client, server, baseUrl } = await createOpencodeInstance({
253
275
  collectionPath: collection.path,
254
276
  ocConfig
255
277
  });
@@ -354,8 +376,12 @@ export namespace Agent {
354
376
  };
355
377
 
356
378
  const getOpencodeInstanceMethod: Service['getOpencodeInstance'] = async ({ collection }) => {
357
- const ocConfig = buildOpenCodeConfig({ agentInstructions: collection.agentInstructions });
358
- const { baseUrl } = await getOpencodeInstance({
379
+ const ocConfig = buildOpenCodeConfig({
380
+ agentInstructions: collection.agentInstructions,
381
+ providerId: config.provider,
382
+ providerTimeoutMs: config.providerTimeoutMs
383
+ });
384
+ const { baseUrl } = await createOpencodeInstance({
359
385
  collectionPath: collection.path,
360
386
  ocConfig
361
387
  });
@@ -371,6 +397,41 @@ export namespace Agent {
371
397
  };
372
398
  };
373
399
 
374
- return { askStream, ask, getOpencodeInstance: getOpencodeInstanceMethod };
400
+ const listProviders: Service['listProviders'] = async () => {
401
+ const ocConfig = buildOpenCodeConfig({
402
+ agentInstructions: '',
403
+ providerId: config.provider,
404
+ providerTimeoutMs: config.providerTimeoutMs
405
+ });
406
+ const { client, server } = await createOpencodeInstance({
407
+ collectionPath: process.cwd(),
408
+ ocConfig
409
+ });
410
+
411
+ try {
412
+ const response = await client.provider.list().catch((cause: unknown) => {
413
+ throw new AgentError({
414
+ message: 'Failed to fetch provider list',
415
+ hint: CommonHints.RUN_AUTH,
416
+ cause
417
+ });
418
+ });
419
+ if (!response?.data) {
420
+ throw new AgentError({
421
+ message: 'Failed to fetch provider list',
422
+ hint: CommonHints.RUN_AUTH
423
+ });
424
+ }
425
+ const data = response.data as {
426
+ all: { id: string; models: Record<string, unknown> }[];
427
+ connected: string[];
428
+ };
429
+ return { all: data.all, connected: data.connected };
430
+ } finally {
431
+ server.close();
432
+ }
433
+ };
434
+
435
+ return { askStream, ask, getOpencodeInstance: getOpencodeInstanceMethod, listProviders };
375
436
  };
376
437
  }
@@ -18,11 +18,14 @@ export namespace Collections {
18
18
  };
19
19
 
20
20
  const createCollectionInstructionBlock = (resource: BtcaFsResource): string => {
21
+ const focusLines = resource.repoSubPaths.map(
22
+ (subPath) => `Focus: ./${resource.fsName}/${subPath}`
23
+ );
21
24
  const lines = [
22
25
  `## Resource: ${resource.name}`,
23
26
  FS_RESOURCE_SYSTEM_NOTE,
24
- `Path: ./${resource.name}`,
25
- resource.repoSubPath ? `Focus: ./${resource.name}/${resource.repoSubPath}` : '',
27
+ `Path: ./${resource.fsName}`,
28
+ ...focusLines,
26
29
  resource.specialAgentInstructions ? `Notes: ${resource.specialAgentInstructions}` : ''
27
30
  ].filter(Boolean);
28
31
 
@@ -89,7 +92,7 @@ export namespace Collections {
89
92
  });
90
93
  }
91
94
 
92
- const linkPath = path.join(collectionPath, resource.name);
95
+ const linkPath = path.join(collectionPath, resource.fsName);
93
96
  try {
94
97
  await fs.rm(linkPath, { recursive: true, force: true });
95
98
  } catch {
@@ -97,7 +100,7 @@ export namespace Collections {
97
100
  }
98
101
 
99
102
  try {
100
- await fs.symlink(resourcePath, linkPath);
103
+ await fs.symlink(resourcePath, linkPath, 'junction');
101
104
  } catch (cause) {
102
105
  throw new CollectionError({
103
106
  message: `Failed to create symlink for resource "${resource.name}"`,
@@ -1,4 +1,5 @@
1
1
  import type { TaggedErrorOptions } from '../errors.ts';
2
+ import { resourceNameToKey } from '../resources/helpers.ts';
2
3
 
3
4
  export type CollectionResult = {
4
5
  path: string;
@@ -18,5 +19,5 @@ export class CollectionError extends Error {
18
19
  }
19
20
 
20
21
  export const getCollectionKey = (resourceNames: readonly string[]): string => {
21
- return [...resourceNames].sort().join('+');
22
+ return [...resourceNames].map(resourceNameToKey).sort().join('+');
22
23
  };
@@ -1,4 +1,5 @@
1
1
  import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
2
3
 
3
4
  import { z } from 'zod';
4
5
  import { CommonHints, type TaggedErrorOptions } from '../errors.ts';
@@ -10,11 +11,11 @@ export const GLOBAL_CONFIG_FILENAME = 'btca.config.jsonc';
10
11
  export const LEGACY_CONFIG_FILENAME = 'btca.json';
11
12
  export const GLOBAL_DATA_DIR = '~/.local/share/btca';
12
13
  export const PROJECT_CONFIG_FILENAME = 'btca.config.jsonc';
13
- export const PROJECT_DATA_DIR = '.btca';
14
14
  export const CONFIG_SCHEMA_URL = 'https://btca.dev/btca.schema.json';
15
15
 
16
16
  export const DEFAULT_MODEL = 'claude-haiku-4-5';
17
17
  export const DEFAULT_PROVIDER = 'opencode';
18
+ export const DEFAULT_PROVIDER_TIMEOUT_MS = 300_000;
18
19
 
19
20
  export const DEFAULT_RESOURCES: ResourceDefinition[] = [
20
21
  {
@@ -48,6 +49,8 @@ export const DEFAULT_RESOURCES: ResourceDefinition[] = [
48
49
 
49
50
  const StoredConfigSchema = z.object({
50
51
  $schema: z.string().optional(),
52
+ dataDirectory: z.string().optional(),
53
+ providerTimeoutMs: z.number().int().positive().optional(),
51
54
  resources: z.array(ResourceDefinitionSchema),
52
55
  model: z.string(),
53
56
  provider: z.string()
@@ -113,6 +116,7 @@ export namespace Config {
113
116
  resources: readonly ResourceDefinition[];
114
117
  model: string;
115
118
  provider: string;
119
+ providerTimeoutMs?: number;
116
120
  configPath: string;
117
121
  getResource: (name: string) => ResourceDefinition | undefined;
118
122
  updateModel: (provider: string, model: string) => Promise<{ provider: string; model: string }>;
@@ -127,6 +131,12 @@ export namespace Config {
127
131
  return path;
128
132
  };
129
133
 
134
+ const resolveDataDirectory = (rawPath: string, baseDir: string): string => {
135
+ const expanded = expandHome(rawPath);
136
+ if (path.isAbsolute(expanded)) return expanded;
137
+ return path.resolve(baseDir, expanded);
138
+ };
139
+
130
140
  const stripJsonc = (content: string): string => {
131
141
  // Remove // and /* */ comments without touching strings.
132
142
  let out = '';
@@ -427,7 +437,8 @@ export namespace Config {
427
437
  $schema: CONFIG_SCHEMA_URL,
428
438
  resources: DEFAULT_RESOURCES,
429
439
  model: DEFAULT_MODEL,
430
- provider: DEFAULT_PROVIDER
440
+ provider: DEFAULT_PROVIDER,
441
+ providerTimeoutMs: DEFAULT_PROVIDER_TIMEOUT_MS
431
442
  };
432
443
 
433
444
  try {
@@ -526,6 +537,9 @@ export namespace Config {
526
537
  get provider() {
527
538
  return getActiveConfig().provider;
528
539
  },
540
+ get providerTimeoutMs() {
541
+ return getActiveConfig().providerTimeoutMs;
542
+ },
529
543
  getResource: (name: string) => getMergedResources().find((r) => r.name === name),
530
544
 
531
545
  updateModel: async (provider: string, model: string) => {
@@ -680,7 +694,7 @@ export namespace Config {
680
694
  const projectExists = await Bun.file(projectConfigPath).exists();
681
695
  if (projectExists) {
682
696
  Metrics.info('config.load.project', { source: 'project', path: projectConfigPath });
683
- const projectConfig = await loadConfigFromPath(projectConfigPath);
697
+ let projectConfig = await loadConfigFromPath(projectConfigPath);
684
698
 
685
699
  Metrics.info('config.load.merged', {
686
700
  globalResources: globalConfig.resources.length,
@@ -689,22 +703,50 @@ export namespace Config {
689
703
 
690
704
  // Use project paths for data storage when project config exists
691
705
  // Pass both configs separately to avoid resource leakage on mutations
706
+ let projectDataDir =
707
+ projectConfig.dataDirectory ?? globalConfig.dataDirectory ?? expandHome(GLOBAL_DATA_DIR);
708
+
709
+ // Migration: if no dataDirectory is set and legacy .btca exists, use it and update config
710
+ if (!projectConfig.dataDirectory) {
711
+ const legacyProjectDataDir = `${cwd}/.btca`;
712
+ const legacyExists = await fs
713
+ .stat(legacyProjectDataDir)
714
+ .then(() => true)
715
+ .catch(() => false);
716
+ if (legacyExists) {
717
+ Metrics.info('config.project.legacy_data_dir', {
718
+ path: legacyProjectDataDir,
719
+ action: 'migrating'
720
+ });
721
+ projectDataDir = '.btca';
722
+ const updatedProjectConfig = { ...projectConfig, dataDirectory: '.btca' };
723
+ await saveConfig(projectConfigPath, updatedProjectConfig);
724
+ projectConfig = updatedProjectConfig;
725
+ }
726
+ }
727
+
728
+ const resolvedProjectDataDir = resolveDataDirectory(projectDataDir, cwd);
692
729
  return makeService(
693
730
  globalConfig,
694
731
  projectConfig,
695
- `${cwd}/${PROJECT_DATA_DIR}/resources`,
696
- `${cwd}/${PROJECT_DATA_DIR}/collections`,
732
+ `${resolvedProjectDataDir}/resources`,
733
+ `${resolvedProjectDataDir}/collections`,
697
734
  projectConfigPath
698
735
  );
699
736
  }
700
737
 
701
738
  // No project config, use global only
702
739
  Metrics.info('config.load.source', { source: 'global', path: globalConfigPath });
740
+ const globalDataDir = globalConfig.dataDirectory ?? expandHome(GLOBAL_DATA_DIR);
741
+ const resolvedGlobalDataDir = resolveDataDirectory(
742
+ globalDataDir,
743
+ expandHome(GLOBAL_CONFIG_DIR)
744
+ );
703
745
  return makeService(
704
746
  globalConfig,
705
747
  null,
706
- `${expandHome(GLOBAL_DATA_DIR)}/resources`,
707
- `${expandHome(GLOBAL_DATA_DIR)}/collections`,
748
+ `${resolvedGlobalDataDir}/resources`,
749
+ `${resolvedGlobalDataDir}/collections`,
708
750
  globalConfigPath
709
751
  );
710
752
  };
package/src/index.ts CHANGED
@@ -13,7 +13,7 @@ import { Resources } from './resources/service.ts';
13
13
  import { GitResourceSchema, LocalResourceSchema } from './resources/schema.ts';
14
14
  import { StreamService } from './stream/service.ts';
15
15
  import type { BtcaStreamMetaEvent } from './stream/types.ts';
16
- import { LIMITS } from './validation/index.ts';
16
+ import { LIMITS, normalizeGitHubUrl } from './validation/index.ts';
17
17
 
18
18
  /**
19
19
  * BTCA Server API
@@ -42,7 +42,7 @@ const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : DEFAULT_PORT;
42
42
  /**
43
43
  * Resource name pattern: must start with a letter, alphanumeric and hyphens only.
44
44
  */
45
- const RESOURCE_NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9-]*$/;
45
+ const RESOURCE_NAME_REGEX = /^@?[a-zA-Z0-9][a-zA-Z0-9._-]*(\/[a-zA-Z0-9][a-zA-Z0-9._-]*)*$/;
46
46
 
47
47
  /**
48
48
  * Safe name pattern for provider/model names.
@@ -56,7 +56,10 @@ const ResourceNameField = z
56
56
  .string()
57
57
  .min(1, 'Resource name cannot be empty')
58
58
  .max(LIMITS.RESOURCE_NAME_MAX)
59
- .regex(RESOURCE_NAME_REGEX, 'Invalid resource name format');
59
+ .regex(RESOURCE_NAME_REGEX, 'Invalid resource name format')
60
+ .refine((name) => !name.includes('..'), 'Resource name must not contain ".."')
61
+ .refine((name) => !name.includes('//'), 'Resource name must not contain "//"')
62
+ .refine((name) => !name.endsWith('/'), 'Resource name must not end with "/"');
60
63
 
61
64
  const QuestionRequestSchema = z.object({
62
65
  question: z
@@ -110,6 +113,7 @@ const AddGitResourceRequestSchema = z.object({
110
113
  url: GitResourceSchema.shape.url,
111
114
  branch: GitResourceSchema.shape.branch.optional().default('main'),
112
115
  searchPath: GitResourceSchema.shape.searchPath,
116
+ searchPaths: GitResourceSchema.shape.searchPaths,
113
117
  specialNotes: GitResourceSchema.shape.specialNotes
114
118
  });
115
119
 
@@ -220,6 +224,7 @@ const createApp = (deps: {
220
224
  return c.json({
221
225
  provider: config.provider,
222
226
  model: config.model,
227
+ providerTimeoutMs: config.providerTimeoutMs ?? null,
223
228
  resourcesDirectory: config.resourcesDirectory,
224
229
  collectionsDirectory: config.collectionsDirectory,
225
230
  resourceCount: config.resources.length
@@ -237,6 +242,7 @@ const createApp = (deps: {
237
242
  url: r.url,
238
243
  branch: r.branch,
239
244
  searchPath: r.searchPath ?? null,
245
+ searchPaths: r.searchPaths ?? null,
240
246
  specialNotes: r.specialNotes ?? null
241
247
  };
242
248
  } else {
@@ -251,6 +257,12 @@ const createApp = (deps: {
251
257
  });
252
258
  })
253
259
 
260
+ // GET /providers
261
+ .get('/providers', async (c: HonoContext) => {
262
+ const providers = await agent.listProviders();
263
+ return c.json(providers);
264
+ })
265
+
254
266
  // POST /question
255
267
  .post('/question', async (c: HonoContext) => {
256
268
  const decoded = await decodeJson(c.req.raw, QuestionRequestSchema);
@@ -375,16 +387,20 @@ const createApp = (deps: {
375
387
 
376
388
  // POST /config/resources - Add a new resource
377
389
  // All validation (URL, branch, path traversal, etc.) is handled by the schema
390
+ // GitHub URLs are normalized to their base repository format
378
391
  .post('/config/resources', async (c: HonoContext) => {
379
392
  const decoded = await decodeJson(c.req.raw, AddResourceRequestSchema);
380
393
 
381
394
  if (decoded.type === 'git') {
395
+ // Normalize GitHub URLs (e.g., /blob/main/file.txt → base repo URL)
396
+ const normalizedUrl = normalizeGitHubUrl(decoded.url);
382
397
  const resource = {
383
398
  type: 'git' as const,
384
399
  name: decoded.name,
385
- url: decoded.url,
400
+ url: normalizedUrl,
386
401
  branch: decoded.branch ?? 'main',
387
402
  ...(decoded.searchPath && { searchPath: decoded.searchPath }),
403
+ ...(decoded.searchPaths && { searchPaths: decoded.searchPaths }),
388
404
  ...(decoded.specialNotes && { specialNotes: decoded.specialNotes })
389
405
  };
390
406
  const added = await config.addResource(resource);
@@ -468,7 +484,8 @@ export const startServer = async (options: StartServerOptions = {}): Promise<Ser
468
484
 
469
485
  const server = Bun.serve({
470
486
  port: requestedPort,
471
- fetch: app.fetch
487
+ fetch: app.fetch,
488
+ idleTimeout: 60
472
489
  });
473
490
 
474
491
  const actualPort = server.port ?? requestedPort;
@@ -12,3 +12,7 @@ export class ResourceError extends Error {
12
12
  if (args.stack) this.stack = args.stack;
13
13
  }
14
14
  }
15
+
16
+ export const resourceNameToKey = (name: string): string => {
17
+ return encodeURIComponent(name);
18
+ };
@@ -25,7 +25,7 @@ describe('Git Resource', () => {
25
25
  name: 'test-repo',
26
26
  url: 'https://github.com/honojs/hono',
27
27
  branch: 'main',
28
- repoSubPath: 'docs',
28
+ repoSubPaths: ['docs'],
29
29
  resourcesDirectoryPath: testDir,
30
30
  specialAgentInstructions: 'Test notes',
31
31
  quiet: true
@@ -36,7 +36,7 @@ describe('Git Resource', () => {
36
36
  expect(resource._tag).toBe('fs-based');
37
37
  expect(resource.name).toBe('test-repo');
38
38
  expect(resource.type).toBe('git');
39
- expect(resource.repoSubPath).toBe('docs');
39
+ expect(resource.repoSubPaths).toEqual(['docs']);
40
40
  expect(resource.specialAgentInstructions).toBe('Test notes');
41
41
 
42
42
  const resourcePath = await resource.getAbsoluteDirectoryPath();
@@ -55,7 +55,7 @@ describe('Git Resource', () => {
55
55
  name: 'update-test',
56
56
  url: 'https://github.com/honojs/hono',
57
57
  branch: 'main',
58
- repoSubPath: '',
58
+ repoSubPaths: [],
59
59
  resourcesDirectoryPath: testDir,
60
60
  specialAgentInstructions: '',
61
61
  quiet: true
@@ -77,13 +77,13 @@ describe('Git Resource', () => {
77
77
  name: 'invalid-url',
78
78
  url: 'not-a-valid-url',
79
79
  branch: 'main',
80
- repoSubPath: '',
80
+ repoSubPaths: [],
81
81
  resourcesDirectoryPath: testDir,
82
82
  specialAgentInstructions: '',
83
83
  quiet: true
84
84
  };
85
85
 
86
- expect(loadGitResource(args)).rejects.toThrow('Invalid git URL');
86
+ expect(loadGitResource(args)).rejects.toThrow('Git URL must be a valid HTTPS URL');
87
87
  });
88
88
 
89
89
  it('throws error for invalid branch name', async () => {
@@ -92,13 +92,13 @@ describe('Git Resource', () => {
92
92
  name: 'invalid-branch',
93
93
  url: 'https://github.com/test/repo',
94
94
  branch: 'invalid branch name!',
95
- repoSubPath: '',
95
+ repoSubPaths: [],
96
96
  resourcesDirectoryPath: testDir,
97
97
  specialAgentInstructions: '',
98
98
  quiet: true
99
99
  };
100
100
 
101
- expect(loadGitResource(args)).rejects.toThrow('Invalid branch name');
101
+ expect(loadGitResource(args)).rejects.toThrow('Branch name must contain only');
102
102
  });
103
103
 
104
104
  it('throws error for path traversal attempt', async () => {
@@ -107,13 +107,13 @@ describe('Git Resource', () => {
107
107
  name: 'path-traversal',
108
108
  url: 'https://github.com/test/repo',
109
109
  branch: 'main',
110
- repoSubPath: '../../../etc',
110
+ repoSubPaths: ['../../../etc'],
111
111
  resourcesDirectoryPath: testDir,
112
112
  specialAgentInstructions: '',
113
113
  quiet: true
114
114
  };
115
115
 
116
- expect(loadGitResource(args)).rejects.toThrow('Invalid search path');
116
+ expect(loadGitResource(args)).rejects.toThrow('path traversal');
117
117
  });
118
118
  });
119
119
  });
@@ -1,13 +1,31 @@
1
1
  import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
2
3
 
3
4
  import { Metrics } from '../../metrics/index.ts';
4
5
  import { CommonHints } from '../../errors.ts';
5
- import { ResourceError } from '../helpers.ts';
6
+ import { ResourceError, resourceNameToKey } from '../helpers.ts';
7
+ import { GitResourceSchema } from '../schema.ts';
6
8
  import type { BtcaFsResource, BtcaGitResourceArgs } from '../types.ts';
7
9
 
8
- const isValidGitUrl = (url: string) => /^https:\/\//.test(url);
9
- const isValidBranch = (branch: string) => /^[\w\-./]+$/.test(branch);
10
- const isValidPath = (path: string) => !path.includes('..') && /^[\w\-./]*$/.test(path);
10
+ const validateGitUrl = (url: string): { success: true } | { success: false; error: string } => {
11
+ const result = GitResourceSchema.shape.url.safeParse(url);
12
+ if (result.success) return { success: true };
13
+ return { success: false, error: result.error.errors[0]?.message ?? 'Invalid git URL' };
14
+ };
15
+
16
+ const validateBranch = (branch: string): { success: true } | { success: false; error: string } => {
17
+ const result = GitResourceSchema.shape.branch.safeParse(branch);
18
+ if (result.success) return { success: true };
19
+ return { success: false, error: result.error.errors[0]?.message ?? 'Invalid branch name' };
20
+ };
21
+
22
+ const validateSearchPath = (
23
+ searchPath: string
24
+ ): { success: true } | { success: false; error: string } => {
25
+ const result = GitResourceSchema.shape.searchPath.safeParse(searchPath);
26
+ if (result.success) return { success: true };
27
+ return { success: false, error: result.error.errors[0]?.message ?? 'Invalid search path' };
28
+ };
11
29
 
12
30
  const directoryExists = async (path: string): Promise<boolean> => {
13
31
  try {
@@ -173,33 +191,38 @@ const runGit = async (
173
191
  const gitClone = async (args: {
174
192
  repoUrl: string;
175
193
  repoBranch: string;
176
- repoSubPath: string;
194
+ repoSubPaths: readonly string[];
177
195
  localAbsolutePath: string;
178
196
  quiet: boolean;
179
197
  }) => {
180
- if (!isValidGitUrl(args.repoUrl)) {
198
+ const urlValidation = validateGitUrl(args.repoUrl);
199
+ if (!urlValidation.success) {
181
200
  throw new ResourceError({
182
- message: 'Invalid git URL format',
183
- hint: 'URLs must start with "https://". Example: https://github.com/user/repo',
201
+ message: urlValidation.error,
202
+ hint: 'URLs must be valid HTTPS URLs. Example: https://github.com/user/repo',
184
203
  cause: new Error('URL validation failed')
185
204
  });
186
205
  }
187
- if (!isValidBranch(args.repoBranch)) {
206
+ const branchValidation = validateBranch(args.repoBranch);
207
+ if (!branchValidation.success) {
188
208
  throw new ResourceError({
189
- message: `Invalid branch name: "${args.repoBranch}"`,
209
+ message: branchValidation.error,
190
210
  hint: 'Branch names can only contain letters, numbers, hyphens, underscores, dots, and forward slashes.',
191
211
  cause: new Error('Branch validation failed')
192
212
  });
193
213
  }
194
- if (args.repoSubPath && !isValidPath(args.repoSubPath)) {
195
- throw new ResourceError({
196
- message: `Invalid search path: "${args.repoSubPath}"`,
197
- hint: 'Search paths cannot contain ".." (path traversal) and must use only safe characters.',
198
- cause: new Error('Path validation failed')
199
- });
214
+ for (const repoSubPath of args.repoSubPaths) {
215
+ const pathValidation = validateSearchPath(repoSubPath);
216
+ if (!pathValidation.success) {
217
+ throw new ResourceError({
218
+ message: pathValidation.error,
219
+ hint: 'Search paths cannot contain ".." (path traversal) and must use only safe characters.',
220
+ cause: new Error('Path validation failed')
221
+ });
222
+ }
200
223
  }
201
224
 
202
- const needsSparseCheckout = args.repoSubPath && args.repoSubPath !== '/';
225
+ const needsSparseCheckout = args.repoSubPaths.length > 0;
203
226
  const cloneArgs = needsSparseCheckout
204
227
  ? [
205
228
  'clone',
@@ -231,15 +254,15 @@ const gitClone = async (args: {
231
254
  }
232
255
 
233
256
  if (needsSparseCheckout) {
234
- const sparseResult = await runGit(['sparse-checkout', 'set', args.repoSubPath], {
257
+ const sparseResult = await runGit(['sparse-checkout', 'set', ...args.repoSubPaths], {
235
258
  cwd: args.localAbsolutePath,
236
259
  quiet: args.quiet
237
260
  });
238
261
 
239
262
  if (sparseResult.exitCode !== 0) {
240
263
  throw new ResourceError({
241
- message: `Failed to set sparse-checkout path: "${args.repoSubPath}"`,
242
- hint: 'Verify the search path exists in the repository. Check the repository structure to find the correct path.',
264
+ message: `Failed to set sparse-checkout path(s): "${args.repoSubPaths.join(', ')}"`,
265
+ hint: 'Verify the search paths exist in the repository. Check the repository structure to find the correct path.',
243
266
  cause: new Error(
244
267
  `git sparse-checkout failed with exit code ${sparseResult.exitCode}: ${sparseResult.stderr}`
245
268
  )
@@ -263,7 +286,12 @@ const gitClone = async (args: {
263
286
  }
264
287
  };
265
288
 
266
- const gitUpdate = async (args: { localAbsolutePath: string; branch: string; quiet: boolean }) => {
289
+ const gitUpdate = async (args: {
290
+ localAbsolutePath: string;
291
+ branch: string;
292
+ repoSubPaths: readonly string[];
293
+ quiet: boolean;
294
+ }) => {
267
295
  const fetchResult = await runGit(['fetch', '--depth', '1', 'origin', args.branch], {
268
296
  cwd: args.localAbsolutePath,
269
297
  quiet: args.quiet
@@ -299,10 +327,60 @@ const gitUpdate = async (args: { localAbsolutePath: string; branch: string; quie
299
327
  )
300
328
  });
301
329
  }
330
+
331
+ if (args.repoSubPaths.length > 0) {
332
+ const sparseResult = await runGit(['sparse-checkout', 'set', ...args.repoSubPaths], {
333
+ cwd: args.localAbsolutePath,
334
+ quiet: args.quiet
335
+ });
336
+
337
+ if (sparseResult.exitCode !== 0) {
338
+ throw new ResourceError({
339
+ message: `Failed to set sparse-checkout path(s): "${args.repoSubPaths.join(', ')}"`,
340
+ hint: 'Verify the search paths exist in the repository. Check the repository structure to find the correct path.',
341
+ cause: new Error(
342
+ `git sparse-checkout failed with exit code ${sparseResult.exitCode}: ${sparseResult.stderr}`
343
+ )
344
+ });
345
+ }
346
+
347
+ const checkoutResult = await runGit(['checkout'], {
348
+ cwd: args.localAbsolutePath,
349
+ quiet: args.quiet
350
+ });
351
+
352
+ if (checkoutResult.exitCode !== 0) {
353
+ throw new ResourceError({
354
+ message: 'Failed to checkout repository',
355
+ hint: CommonHints.CLEAR_CACHE,
356
+ cause: new Error(
357
+ `git checkout failed with exit code ${checkoutResult.exitCode}: ${checkoutResult.stderr}`
358
+ )
359
+ });
360
+ }
361
+ }
362
+ };
363
+
364
+ const ensureSearchPathsExist = async (
365
+ localPath: string,
366
+ repoSubPaths: readonly string[]
367
+ ): Promise<void> => {
368
+ for (const repoSubPath of repoSubPaths) {
369
+ const subPath = path.join(localPath, repoSubPath);
370
+ const exists = await directoryExists(subPath);
371
+ if (!exists) {
372
+ throw new ResourceError({
373
+ message: `Search path does not exist: "${repoSubPath}"`,
374
+ hint: 'Check the repository structure and update the search path in your btca config.',
375
+ cause: new Error(`Missing search path: ${repoSubPath}`)
376
+ });
377
+ }
378
+ }
302
379
  };
303
380
 
304
381
  const ensureGitResource = async (config: BtcaGitResourceArgs): Promise<string> => {
305
- const localPath = `${config.resourcesDirectoryPath}/${config.name}`;
382
+ const resourceKey = resourceNameToKey(config.name);
383
+ const localPath = path.join(config.resourcesDirectoryPath, resourceKey);
306
384
 
307
385
  return Metrics.span(
308
386
  'resource.git.ensure',
@@ -313,20 +391,24 @@ const ensureGitResource = async (config: BtcaGitResourceArgs): Promise<string> =
313
391
  Metrics.info('resource.git.update', {
314
392
  name: config.name,
315
393
  branch: config.branch,
316
- repoSubPath: config.repoSubPath
394
+ repoSubPaths: config.repoSubPaths
317
395
  });
318
396
  await gitUpdate({
319
397
  localAbsolutePath: localPath,
320
398
  branch: config.branch,
399
+ repoSubPaths: config.repoSubPaths,
321
400
  quiet: config.quiet
322
401
  });
402
+ if (config.repoSubPaths.length > 0) {
403
+ await ensureSearchPathsExist(localPath, config.repoSubPaths);
404
+ }
323
405
  return localPath;
324
406
  }
325
407
 
326
408
  Metrics.info('resource.git.clone', {
327
409
  name: config.name,
328
410
  branch: config.branch,
329
- repoSubPath: config.repoSubPath
411
+ repoSubPaths: config.repoSubPaths
330
412
  });
331
413
 
332
414
  try {
@@ -342,10 +424,13 @@ const ensureGitResource = async (config: BtcaGitResourceArgs): Promise<string> =
342
424
  await gitClone({
343
425
  repoUrl: config.url,
344
426
  repoBranch: config.branch,
345
- repoSubPath: config.repoSubPath,
427
+ repoSubPaths: config.repoSubPaths,
346
428
  localAbsolutePath: localPath,
347
429
  quiet: config.quiet
348
430
  });
431
+ if (config.repoSubPaths.length > 0) {
432
+ await ensureSearchPathsExist(localPath, config.repoSubPaths);
433
+ }
349
434
 
350
435
  return localPath;
351
436
  },
@@ -358,8 +443,9 @@ export const loadGitResource = async (config: BtcaGitResourceArgs): Promise<Btca
358
443
  return {
359
444
  _tag: 'fs-based',
360
445
  name: config.name,
446
+ fsName: resourceNameToKey(config.name),
361
447
  type: 'git',
362
- repoSubPath: config.repoSubPath,
448
+ repoSubPaths: config.repoSubPaths,
363
449
  specialAgentInstructions: config.specialAgentInstructions,
364
450
  getAbsoluteDirectoryPath: async () => localPath
365
451
  };
@@ -10,7 +10,7 @@ import { LIMITS } from '../validation/index.ts';
10
10
  * Resource name: must start with a letter, followed by alphanumeric and hyphens only.
11
11
  * Prevents path traversal, git option injection, and shell metacharacters.
12
12
  */
13
- const RESOURCE_NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9-]*$/;
13
+ const RESOURCE_NAME_REGEX = /^@?[a-zA-Z0-9][a-zA-Z0-9._-]*(\/[a-zA-Z0-9][a-zA-Z0-9._-]*)*$/;
14
14
 
15
15
  /**
16
16
  * Branch name: alphanumeric, forward slashes, dots, underscores, and hyphens.
@@ -31,8 +31,17 @@ const ResourceNameSchema = z
31
31
  .max(LIMITS.RESOURCE_NAME_MAX, `Resource name too long (max ${LIMITS.RESOURCE_NAME_MAX} chars)`)
32
32
  .regex(
33
33
  RESOURCE_NAME_REGEX,
34
- 'Resource name must start with a letter and contain only alphanumeric characters and hyphens'
35
- );
34
+ 'Resource name must start with a letter or @ and contain only letters, numbers, ., _, -, and /'
35
+ )
36
+ .refine((name) => !name.includes('..'), {
37
+ message: 'Resource name must not contain ".."'
38
+ })
39
+ .refine((name) => !name.includes('//'), {
40
+ message: 'Resource name must not contain "//"'
41
+ })
42
+ .refine((name) => !name.endsWith('/'), {
43
+ message: 'Resource name must not end with "/"'
44
+ });
36
45
 
37
46
  /**
38
47
  * Git URL field with security validation.
@@ -113,7 +122,13 @@ const SearchPathSchema = z
113
122
  })
114
123
  .refine((path) => !path.startsWith('/') && !path.match(/^[a-zA-Z]:\\/), {
115
124
  message: 'Search path must not be an absolute path'
116
- })
125
+ });
126
+
127
+ const OptionalSearchPathSchema = SearchPathSchema.optional();
128
+
129
+ const SearchPathsSchema = z
130
+ .array(SearchPathSchema)
131
+ .refine((paths) => paths.length > 0, { message: 'searchPaths must include at least one path' })
117
132
  .optional();
118
133
 
119
134
  /**
@@ -151,7 +166,8 @@ export const GitResourceSchema = z.object({
151
166
  name: ResourceNameSchema,
152
167
  url: GitUrlSchema,
153
168
  branch: BranchNameSchema,
154
- searchPath: SearchPathSchema,
169
+ searchPath: OptionalSearchPathSchema,
170
+ searchPaths: SearchPathsSchema,
155
171
  specialNotes: SpecialNotesSchema
156
172
  });
157
173
 
@@ -1,6 +1,6 @@
1
1
  import { Config } from '../config/index.ts';
2
2
 
3
- import { ResourceError } from './helpers.ts';
3
+ import { ResourceError, resourceNameToKey } from './helpers.ts';
4
4
  import { loadGitResource } from './impls/git.ts';
5
5
  import {
6
6
  isGitResource,
@@ -20,6 +20,14 @@ export namespace Resources {
20
20
  ) => Promise<BtcaFsResource>;
21
21
  };
22
22
 
23
+ const normalizeSearchPaths = (definition: GitResource): string[] => {
24
+ const paths = [
25
+ ...(definition.searchPaths ?? []),
26
+ ...(definition.searchPath ? [definition.searchPath] : [])
27
+ ];
28
+ return paths.filter((path) => path.trim().length > 0);
29
+ };
30
+
23
31
  const definitionToGitArgs = (
24
32
  definition: GitResource,
25
33
  resourcesDirectory: string,
@@ -29,7 +37,7 @@ export namespace Resources {
29
37
  name: definition.name,
30
38
  url: definition.url,
31
39
  branch: definition.branch,
32
- repoSubPath: definition.searchPath ?? '',
40
+ repoSubPaths: normalizeSearchPaths(definition),
33
41
  resourcesDirectoryPath: resourcesDirectory,
34
42
  specialAgentInstructions: definition.specialNotes ?? '',
35
43
  quiet
@@ -45,8 +53,9 @@ export namespace Resources {
45
53
  const loadLocalResource = (args: BtcaLocalResourceArgs): BtcaFsResource => ({
46
54
  _tag: 'fs-based',
47
55
  name: args.name,
56
+ fsName: resourceNameToKey(args.name),
48
57
  type: 'local',
49
- repoSubPath: '',
58
+ repoSubPaths: [],
50
59
  specialAgentInstructions: args.specialAgentInstructions,
51
60
  getAbsoluteDirectoryPath: async () => args.path
52
61
  });
@@ -4,8 +4,9 @@ export const FS_RESOURCE_SYSTEM_NOTE =
4
4
  export type BtcaFsResource = {
5
5
  readonly _tag: 'fs-based';
6
6
  readonly name: string;
7
+ readonly fsName: string;
7
8
  readonly type: 'git' | 'local';
8
- readonly repoSubPath: string;
9
+ readonly repoSubPaths: readonly string[];
9
10
  readonly specialAgentInstructions: string;
10
11
  readonly getAbsoluteDirectoryPath: () => Promise<string>;
11
12
  };
@@ -15,7 +16,7 @@ export type BtcaGitResourceArgs = {
15
16
  readonly name: string;
16
17
  readonly url: string;
17
18
  readonly branch: string;
18
- readonly repoSubPath: string;
19
+ readonly repoSubPaths: readonly string[];
19
20
  readonly resourcesDirectoryPath: string;
20
21
  readonly specialAgentInstructions: string;
21
22
  readonly quiet: boolean;
@@ -16,7 +16,7 @@
16
16
  * Resource name: must start with a letter, followed by alphanumeric and hyphens only.
17
17
  * This prevents path traversal (../), git option injection (-), and shell metacharacters.
18
18
  */
19
- const RESOURCE_NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9-]*$/;
19
+ const RESOURCE_NAME_REGEX = /^@?[a-zA-Z0-9][a-zA-Z0-9._-]*(\/[a-zA-Z0-9][a-zA-Z0-9._-]*)*$/;
20
20
 
21
21
  /**
22
22
  * Branch name: alphanumeric, forward slashes, dots, underscores, and hyphens.
@@ -59,8 +59,17 @@ export const LIMITS = {
59
59
 
60
60
  export type ValidationResult = { valid: true } | { valid: false; error: string };
61
61
 
62
+ /**
63
+ * Validation result that includes a normalized value.
64
+ */
65
+ export type ValidationResultWithValue<T> =
66
+ | { valid: true; value: T }
67
+ | { valid: false; error: string };
68
+
62
69
  const ok = (): ValidationResult => ({ valid: true });
70
+ const okWithValue = <T>(value: T): ValidationResultWithValue<T> => ({ valid: true, value });
63
71
  const fail = (error: string): ValidationResult => ({ valid: false, error });
72
+ const failWithValue = <T>(error: string): ValidationResultWithValue<T> => ({ valid: false, error });
64
73
 
65
74
  // ─────────────────────────────────────────────────────────────────────────────
66
75
  // Validators
@@ -86,9 +95,18 @@ export const validateResourceName = (name: string): ValidationResult => {
86
95
 
87
96
  if (!RESOURCE_NAME_REGEX.test(name)) {
88
97
  return fail(
89
- `Invalid resource name: "${name}". Must start with a letter and contain only alphanumeric characters and hyphens`
98
+ `Invalid resource name: "${name}". Must start with a letter or @ and contain only letters, numbers, ., _, -, and /`
90
99
  );
91
100
  }
101
+ if (name.includes('..')) {
102
+ return fail('Resource name must not contain ".."');
103
+ }
104
+ if (name.includes('//')) {
105
+ return fail('Resource name must not contain "//"');
106
+ }
107
+ if (name.endsWith('/')) {
108
+ return fail('Resource name must not end with "/"');
109
+ }
92
110
 
93
111
  return ok();
94
112
  };
@@ -126,37 +144,84 @@ export const validateBranchName = (branch: string): ValidationResult => {
126
144
  return ok();
127
145
  };
128
146
 
147
+ /**
148
+ * Normalize a GitHub URL to its base repository format.
149
+ *
150
+ * Handles URLs like:
151
+ * - https://github.com/owner/repo/blob/main/README.md → https://github.com/owner/repo
152
+ * - https://github.com/owner/repo/tree/branch/path → https://github.com/owner/repo
153
+ * - https://github.com/owner/repo.git → https://github.com/owner/repo
154
+ * - https://github.com/owner/repo/ → https://github.com/owner/repo
155
+ *
156
+ * Non-GitHub URLs are returned unchanged.
157
+ */
158
+ export const normalizeGitHubUrl = (url: string): string => {
159
+ let parsed: URL;
160
+ try {
161
+ parsed = new URL(url);
162
+ } catch {
163
+ return url; // Return as-is if not a valid URL
164
+ }
165
+
166
+ const hostname = parsed.hostname.toLowerCase();
167
+ if (hostname !== 'github.com') {
168
+ return url; // Non-GitHub URLs pass through unchanged
169
+ }
170
+
171
+ // Extract path segments, filtering out empty strings
172
+ const segments = parsed.pathname.split('/').filter((s) => s.length > 0);
173
+
174
+ // Need at least owner and repo
175
+ if (segments.length < 2) {
176
+ return url;
177
+ }
178
+
179
+ // Get owner and repo (first two segments)
180
+ const owner = segments[0];
181
+ let repo = segments[1]!;
182
+
183
+ // Remove .git suffix if present
184
+ if (repo.endsWith('.git')) {
185
+ repo = repo.slice(0, -4);
186
+ }
187
+
188
+ return `https://github.com/${owner}/${repo}`;
189
+ };
190
+
129
191
  /**
130
192
  * Validate a git URL to prevent unsafe git operations.
193
+ * Returns the normalized URL on success.
131
194
  *
132
195
  * Requirements:
133
196
  * - Valid URL format
134
197
  * - HTTPS protocol only (rejects file://, git://, ssh://, ext::, etc.)
135
198
  * - No embedded credentials
136
199
  * - No localhost or private IP addresses
200
+ *
201
+ * For GitHub URLs, the URL is normalized to the base repository format.
137
202
  */
138
- export const validateGitUrl = (url: string): ValidationResult => {
203
+ export const validateGitUrl = (url: string): ValidationResultWithValue<string> => {
139
204
  if (!url || url.trim().length === 0) {
140
- return fail('Git URL cannot be empty');
205
+ return failWithValue('Git URL cannot be empty');
141
206
  }
142
207
 
143
208
  let parsed: URL;
144
209
  try {
145
210
  parsed = new URL(url);
146
211
  } catch {
147
- return fail(`Invalid URL format: "${url}"`);
212
+ return failWithValue(`Invalid URL format: "${url}"`);
148
213
  }
149
214
 
150
215
  // Only allow HTTPS protocol
151
216
  if (parsed.protocol !== 'https:') {
152
- return fail(
217
+ return failWithValue(
153
218
  `Invalid URL protocol: ${parsed.protocol}. Only HTTPS URLs are allowed for security reasons`
154
219
  );
155
220
  }
156
221
 
157
222
  // Reject embedded credentials
158
223
  if (parsed.username || parsed.password) {
159
- return fail('URL must not contain embedded credentials');
224
+ return failWithValue('URL must not contain embedded credentials');
160
225
  }
161
226
 
162
227
  // Reject localhost and private IP addresses
@@ -170,10 +235,12 @@ export const validateGitUrl = (url: string): ValidationResult => {
170
235
  hostname === '::1' ||
171
236
  hostname === '0.0.0.0'
172
237
  ) {
173
- return fail(`URL must not point to localhost or private IP addresses: ${hostname}`);
238
+ return failWithValue(`URL must not point to localhost or private IP addresses: ${hostname}`);
174
239
  }
175
240
 
176
- return ok();
241
+ // Normalize GitHub URLs
242
+ const normalizedUrl = normalizeGitHubUrl(url);
243
+ return okWithValue(normalizedUrl);
177
244
  };
178
245
 
179
246
  /**
@@ -213,6 +280,20 @@ export const validateSearchPath = (searchPath: string | undefined): ValidationRe
213
280
  return ok();
214
281
  };
215
282
 
283
+ export const validateSearchPaths = (
284
+ searchPaths: string[] | undefined
285
+ ): ValidationResult => {
286
+ if (!searchPaths) return ok();
287
+ if (searchPaths.length === 0) return fail('searchPaths must include at least one path');
288
+
289
+ for (const searchPath of searchPaths) {
290
+ const result = validateSearchPath(searchPath);
291
+ if (!result.valid) return result;
292
+ }
293
+
294
+ return ok();
295
+ };
296
+
216
297
  /**
217
298
  * Validate a local file path.
218
299
  *
@@ -351,52 +432,89 @@ export const validateResourcesArray = (resources: string[] | undefined): Validat
351
432
  // Composite Validators
352
433
  // ─────────────────────────────────────────────────────────────────────────────
353
434
 
435
+ /**
436
+ * Validated git resource with normalized URL.
437
+ */
438
+ export interface ValidatedGitResource {
439
+ name: string;
440
+ url: string;
441
+ branch: string;
442
+ searchPath?: string;
443
+ searchPaths?: string[];
444
+ specialNotes?: string;
445
+ }
446
+
447
+ /**
448
+ * Validated local resource.
449
+ */
450
+ export interface ValidatedLocalResource {
451
+ name: string;
452
+ path: string;
453
+ specialNotes?: string;
454
+ }
455
+
354
456
  /**
355
457
  * Validate a complete git resource definition.
458
+ * Returns the resource with a normalized URL on success.
356
459
  */
357
460
  export const validateGitResource = (resource: {
358
461
  name: string;
359
462
  url: string;
360
463
  branch: string;
361
464
  searchPath?: string;
465
+ searchPaths?: string[];
362
466
  specialNotes?: string;
363
- }): ValidationResult => {
467
+ }): ValidationResultWithValue<ValidatedGitResource> => {
364
468
  const nameResult = validateResourceName(resource.name);
365
- if (!nameResult.valid) return nameResult;
469
+ if (!nameResult.valid) return failWithValue(nameResult.error);
366
470
 
367
471
  const urlResult = validateGitUrl(resource.url);
368
- if (!urlResult.valid) return urlResult;
472
+ if (!urlResult.valid) return failWithValue(urlResult.error);
369
473
 
370
474
  const branchResult = validateBranchName(resource.branch);
371
- if (!branchResult.valid) return branchResult;
475
+ if (!branchResult.valid) return failWithValue(branchResult.error);
372
476
 
373
477
  const searchPathResult = validateSearchPath(resource.searchPath);
374
- if (!searchPathResult.valid) return searchPathResult;
478
+ if (!searchPathResult.valid) return failWithValue(searchPathResult.error);
479
+ const searchPathsResult = validateSearchPaths(resource.searchPaths);
480
+ if (!searchPathsResult.valid) return failWithValue(searchPathsResult.error);
375
481
 
376
482
  const notesResult = validateNotes(resource.specialNotes);
377
- if (!notesResult.valid) return notesResult;
378
-
379
- return ok();
483
+ if (!notesResult.valid) return failWithValue(notesResult.error);
484
+
485
+ return okWithValue({
486
+ name: resource.name,
487
+ url: urlResult.value, // Use the normalized URL
488
+ branch: resource.branch,
489
+ ...(resource.searchPath && { searchPath: resource.searchPath }),
490
+ ...(resource.searchPaths && { searchPaths: resource.searchPaths }),
491
+ ...(resource.specialNotes && { specialNotes: resource.specialNotes })
492
+ });
380
493
  };
381
494
 
382
495
  /**
383
496
  * Validate a complete local resource definition.
497
+ * Returns the validated resource on success.
384
498
  */
385
499
  export const validateLocalResource = (resource: {
386
500
  name: string;
387
501
  path: string;
388
502
  specialNotes?: string;
389
- }): ValidationResult => {
503
+ }): ValidationResultWithValue<ValidatedLocalResource> => {
390
504
  const nameResult = validateResourceName(resource.name);
391
- if (!nameResult.valid) return nameResult;
505
+ if (!nameResult.valid) return failWithValue(nameResult.error);
392
506
 
393
507
  const pathResult = validateLocalPath(resource.path);
394
- if (!pathResult.valid) return pathResult;
508
+ if (!pathResult.valid) return failWithValue(pathResult.error);
395
509
 
396
510
  const notesResult = validateNotes(resource.specialNotes);
397
- if (!notesResult.valid) return notesResult;
511
+ if (!notesResult.valid) return failWithValue(notesResult.error);
398
512
 
399
- return ok();
513
+ return okWithValue({
514
+ name: resource.name,
515
+ path: resource.path,
516
+ ...(resource.specialNotes && { specialNotes: resource.specialNotes })
517
+ });
400
518
  };
401
519
 
402
520
  /**