btca-server 1.0.92 → 1.0.93

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/README.md CHANGED
@@ -125,7 +125,7 @@ The server reads configuration from `~/.btca/config.toml` or your local project'
125
125
 
126
126
  - **AI Provider**: OpenCode AI provider (e.g., "opencode", "anthropic")
127
127
  - **Model**: AI model to use (e.g., "claude-3-7-sonnet-20250219")
128
- - **Resources**: Local directories or git repositories to query
128
+ - **Resources**: Local directories, git repositories, or npm packages to query
129
129
 
130
130
  Example config.toml:
131
131
 
@@ -144,6 +144,12 @@ type = "git"
144
144
  name = "some-repo"
145
145
  url = "https://github.com/user/repo"
146
146
  branch = "main"
147
+
148
+ [[resources]]
149
+ type = "npm"
150
+ name = "react-npm"
151
+ package = "react"
152
+ version = "latest"
147
153
  ```
148
154
 
149
155
  ## Supported Providers
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "btca-server",
3
- "version": "1.0.92",
3
+ "version": "1.0.93",
4
4
  "description": "BTCA server for answering questions about your codebase using OpenCode AI",
5
5
  "author": "Ben Davis",
6
6
  "license": "MIT",
package/src/agent/loop.ts CHANGED
@@ -61,10 +61,19 @@ export namespace AgentLoop {
61
61
  '- list: List directory contents',
62
62
  '',
63
63
  'Guidelines:',
64
- '- Use glob to find relevant files first, then read them',
65
- '- Use grep to search for specific code patterns or text',
66
- '- Always cite the source files in your answers',
67
- '- Be concise but thorough in your responses',
64
+ '- Ground answers in the loaded resources. Do not rely on unstated prior knowledge.',
65
+ '- Search efficiently: start with one focused list/glob pass, then read likely files; only expand search when evidence is insufficient.',
66
+ '- Prefer targeted grep/read over broad repeated scans once candidate files are known.',
67
+ '- Give clear, unambiguous answers. State assumptions, prerequisites, and important version-sensitive caveats.',
68
+ '- For implementation/how-to questions, provide complete step-by-step instructions with commands and code snippets.',
69
+ '- Be concise but thorough in your responses.',
70
+ '- End every answer with a "Sources" section.',
71
+ '- For git resources, source links must be full GitHub blob URLs.',
72
+ '- In "Sources", format git citations as markdown links: "- [repo/relative/path.ext](https://github.com/.../blob/.../repo/relative/path.ext)".',
73
+ '- Do not use raw URLs as link labels.',
74
+ '- Do not repeat a URL in parentheses after a link.',
75
+ '- Do not output sources in "url (url)" format.',
76
+ '- For local resources, cite local file paths (no GitHub URL required).',
68
77
  '- If you cannot find the answer, say so clearly',
69
78
  '',
70
79
  agentInstructions
@@ -5,7 +5,7 @@
5
5
  import { Result } from 'better-result';
6
6
 
7
7
  import { Config } from '../config/index.ts';
8
- import { type TaggedErrorOptions } from '../errors.ts';
8
+ import { getErrorHint, getErrorMessage, type TaggedErrorOptions } from '../errors.ts';
9
9
  import { Metrics } from '../metrics/index.ts';
10
10
  import { Auth, getSupportedProviders } from '../providers/index.ts';
11
11
  import type { CollectionResult } from '../collections/types.ts';
@@ -217,30 +217,30 @@ export namespace Agent {
217
217
 
218
218
  await cleanup();
219
219
 
220
- return runResult.match({
221
- ok: (result) => {
222
- Metrics.info('agent.ask.complete', {
223
- provider: config.provider,
224
- model: config.model,
225
- answerLength: result.answer.length,
226
- eventCount: result.events.length
227
- });
220
+ if (!Result.isOk(runResult)) {
221
+ const cause = runResult.error;
222
+ Metrics.error('agent.ask.error', { error: Metrics.errorInfo(cause) });
223
+ throw new AgentError({
224
+ message: getErrorMessage(cause),
225
+ hint:
226
+ getErrorHint(cause) ?? 'This may be a temporary issue. Try running the command again.',
227
+ cause
228
+ });
229
+ }
228
230
 
229
- return {
230
- answer: result.answer,
231
- model: result.model,
232
- events: result.events
233
- };
234
- },
235
- err: (error) => {
236
- Metrics.error('agent.ask.error', { error: Metrics.errorInfo(error) });
237
- throw new AgentError({
238
- message: 'Failed to get response from AI',
239
- hint: 'This may be a temporary issue. Try running the command again.',
240
- cause: error
241
- });
242
- }
231
+ const result = runResult.value;
232
+ Metrics.info('agent.ask.complete', {
233
+ provider: config.provider,
234
+ model: config.model,
235
+ answerLength: result.answer.length,
236
+ eventCount: result.events.length
243
237
  });
238
+
239
+ return {
240
+ answer: result.answer,
241
+ model: result.model,
242
+ events: result.events
243
+ };
244
244
  };
245
245
 
246
246
  /**
@@ -7,8 +7,9 @@ import { Transaction } from '../context/transaction.ts';
7
7
  import { CommonHints, getErrorHint, getErrorMessage } from '../errors.ts';
8
8
  import { Metrics } from '../metrics/index.ts';
9
9
  import { Resources } from '../resources/service.ts';
10
- import { isGitResource } from '../resources/schema.ts';
10
+ import { isGitResource, isNpmResource } from '../resources/schema.ts';
11
11
  import { FS_RESOURCE_SYSTEM_NOTE, type BtcaFsResource } from '../resources/types.ts';
12
+ import { parseNpmReference } from '../validation/index.ts';
12
13
  import { CollectionError, getCollectionKey, type CollectionResult } from './types.ts';
13
14
  import { VirtualFs } from '../vfs/virtual-fs.ts';
14
15
  import {
@@ -25,14 +26,51 @@ export namespace Collections {
25
26
  }) => Promise<CollectionResult>;
26
27
  };
27
28
 
28
- const createCollectionInstructionBlock = (resource: BtcaFsResource): string => {
29
+ const encodePathSegments = (value: string) => value.split('/').map(encodeURIComponent).join('/');
30
+
31
+ const trimGitSuffix = (url: string) => url.replace(/\.git$/u, '').replace(/\/+$/u, '');
32
+ const getNpmCitationAlias = (metadata?: VirtualResourceMetadata) => {
33
+ if (!metadata?.package) return undefined;
34
+ return `npm:${metadata.package}@${metadata.version ?? 'latest'}`;
35
+ };
36
+
37
+ const createCollectionInstructionBlock = (
38
+ resource: BtcaFsResource,
39
+ metadata?: VirtualResourceMetadata
40
+ ): string => {
29
41
  const focusLines = resource.repoSubPaths.map(
30
42
  (subPath) => `Focus: ./${resource.fsName}/${subPath}`
31
43
  );
44
+ const gitRef = metadata?.branch ?? metadata?.commit;
45
+ const githubPrefix =
46
+ resource.type === 'git' && metadata?.url && gitRef
47
+ ? `${trimGitSuffix(metadata.url)}/blob/${encodeURIComponent(gitRef)}`
48
+ : undefined;
49
+ const npmCitationAlias = resource.type === 'npm' ? getNpmCitationAlias(metadata) : undefined;
32
50
  const lines = [
33
51
  `## Resource: ${resource.name}`,
34
52
  FS_RESOURCE_SYSTEM_NOTE,
35
53
  `Path: ./${resource.fsName}`,
54
+ resource.type === 'git' && metadata?.url ? `Repo URL: ${trimGitSuffix(metadata.url)}` : '',
55
+ resource.type === 'git' && metadata?.branch ? `Repo Branch: ${metadata.branch}` : '',
56
+ resource.type === 'git' && metadata?.commit ? `Repo Commit: ${metadata.commit}` : '',
57
+ resource.type === 'npm' && metadata?.package ? `NPM Package: ${metadata.package}` : '',
58
+ resource.type === 'npm' && metadata?.version ? `NPM Version: ${metadata.version}` : '',
59
+ resource.type === 'npm' && metadata?.url ? `NPM URL: ${metadata.url}` : '',
60
+ npmCitationAlias ? `NPM Citation Alias: ${npmCitationAlias}` : '',
61
+ githubPrefix ? `GitHub Blob Prefix: ${githubPrefix}` : '',
62
+ githubPrefix
63
+ ? `GitHub Citation Rule: Convert virtual paths under ./${resource.fsName}/ to repo-relative paths, then encode each path segment for GitHub URLs (example segment: "+page.server.js" -> "${encodeURIComponent('+page.server.js')}").`
64
+ : '',
65
+ githubPrefix
66
+ ? `GitHub Citation Example: ${githubPrefix}/${encodePathSegments('src/routes/blog/+page.server.js')}`
67
+ : '',
68
+ resource.type !== 'git'
69
+ ? 'Citation Rule: Cite local file paths only for this resource (no GitHub URL).'
70
+ : '',
71
+ npmCitationAlias
72
+ ? `NPM Citation Rule: In "Sources", cite npm files using "${npmCitationAlias}/<file>" (for example, "${npmCitationAlias}/README.md"). Do not cite encoded virtual folder names.`
73
+ : '',
36
74
  ...focusLines,
37
75
  resource.specialAgentInstructions ? `Notes: ${resource.specialAgentInstructions}` : ''
38
76
  ].filter(Boolean);
@@ -159,6 +197,32 @@ export namespace Collections {
159
197
  const ANON_PREFIX = 'anonymous:';
160
198
  const getAnonymousUrlFromName = (name: string) =>
161
199
  name.startsWith(ANON_PREFIX) ? name.slice(ANON_PREFIX.length) : undefined;
200
+ const NPM_ANON_PREFIX = `${ANON_PREFIX}npm:`;
201
+ const NPM_META_FILE = '.btca-npm-meta.json';
202
+ const getAnonymousNpmReferenceFromName = (name: string) =>
203
+ name.startsWith(NPM_ANON_PREFIX) ? name.slice(ANON_PREFIX.length) : undefined;
204
+
205
+ const readNpmMeta = async (resourcePath: string) => {
206
+ const result = await Result.gen(async function* () {
207
+ const content = yield* Result.await(
208
+ Result.tryPromise(() => Bun.file(path.join(resourcePath, NPM_META_FILE)).text())
209
+ );
210
+ const parsed = yield* Result.try(
211
+ () =>
212
+ JSON.parse(content) as {
213
+ packageName?: string;
214
+ resolvedVersion?: string;
215
+ packageUrl?: string;
216
+ }
217
+ );
218
+ return Result.ok(parsed);
219
+ });
220
+
221
+ return result.match({
222
+ ok: (value) => value,
223
+ err: () => null
224
+ });
225
+ };
162
226
 
163
227
  const buildVirtualMetadata = async (args: {
164
228
  resource: BtcaFsResource;
@@ -174,6 +238,27 @@ export namespace Collections {
174
238
  repoSubPaths: args.resource.repoSubPaths,
175
239
  loadedAt: args.loadedAt
176
240
  };
241
+
242
+ if (args.resource.type === 'npm') {
243
+ const configuredDefinition =
244
+ args.definition && isNpmResource(args.definition) ? args.definition : null;
245
+ const anonymousReference = getAnonymousNpmReferenceFromName(args.resource.name);
246
+ const anonymousNpm = anonymousReference ? parseNpmReference(anonymousReference) : null;
247
+ const cached = await readNpmMeta(args.resourcePath);
248
+ const packageName =
249
+ configuredDefinition?.package ?? cached?.packageName ?? anonymousNpm?.packageName;
250
+ const version =
251
+ configuredDefinition?.version ?? cached?.resolvedVersion ?? anonymousNpm?.version;
252
+ const url = cached?.packageUrl ?? anonymousNpm?.packageUrl;
253
+
254
+ return {
255
+ ...base,
256
+ ...(packageName ? { package: packageName } : {}),
257
+ ...(version ? { version } : {}),
258
+ ...(url ? { url } : {})
259
+ };
260
+ }
261
+
177
262
  if (args.resource.type !== 'git') return base;
178
263
 
179
264
  const configuredDefinition =
@@ -267,7 +352,12 @@ export namespace Collections {
267
352
  resources: metadataResources
268
353
  });
269
354
 
270
- const instructionBlocks = loadedResources.map(createCollectionInstructionBlock);
355
+ const metadataByName = new Map(
356
+ metadataResources.map((resource) => [resource.name, resource])
357
+ );
358
+ const instructionBlocks = loadedResources.map((resource) =>
359
+ createCollectionInstructionBlock(resource, metadataByName.get(resource.name))
360
+ );
271
361
 
272
362
  return Result.ok({
273
363
  path: collectionPath,
@@ -1,12 +1,14 @@
1
1
  export type VirtualResourceMetadata = {
2
2
  name: string;
3
3
  fsName: string;
4
- type: 'git' | 'local';
4
+ type: 'git' | 'local' | 'npm';
5
5
  path: string;
6
6
  repoSubPaths: readonly string[];
7
7
  url?: string;
8
8
  branch?: string;
9
9
  commit?: string;
10
+ package?: string;
11
+ version?: string;
10
12
  loadedAt: string;
11
13
  };
12
14
 
@@ -66,6 +66,34 @@ describe('Config', () => {
66
66
  expect(config.getResource('svelte')).toBeDefined();
67
67
  });
68
68
 
69
+ it('loads npm resources from config', async () => {
70
+ const projectConfig = {
71
+ $schema: 'https://btca.dev/btca.schema.json',
72
+ provider: 'test-provider',
73
+ model: 'test-model',
74
+ resources: [
75
+ {
76
+ name: 'reactNpm',
77
+ type: 'npm',
78
+ package: 'react',
79
+ version: 'latest'
80
+ }
81
+ ]
82
+ };
83
+
84
+ await fs.writeFile(path.join(testDir, 'btca.config.jsonc'), JSON.stringify(projectConfig));
85
+ process.chdir(testDir);
86
+
87
+ const config = await Config.load();
88
+ const npmResource = config.getResource('reactNpm');
89
+ expect(npmResource).toBeDefined();
90
+ expect(npmResource?.type).toBe('npm');
91
+ if (npmResource?.type === 'npm') {
92
+ expect(npmResource.package).toBe('react');
93
+ expect(npmResource.version).toBe('latest');
94
+ }
95
+ });
96
+
69
97
  it('handles JSONC with comments', async () => {
70
98
  const projectConfigWithComments = `{
71
99
  // This is a comment
@@ -71,6 +71,7 @@ const StoredConfigSchema = z.object({
71
71
  type StoredConfig = z.infer<typeof StoredConfigSchema>;
72
72
  type ProviderOptionsConfig = z.infer<typeof ProviderOptionsSchema>;
73
73
  type ProviderOptionsMap = z.infer<typeof ProviderOptionsMapSchema>;
74
+ type ConfigScope = 'project' | 'global';
74
75
 
75
76
  // Legacy config schemas (btca.json format from old CLI)
76
77
  // There are two legacy formats:
@@ -137,7 +138,7 @@ export namespace Config {
137
138
  provider: string,
138
139
  model: string,
139
140
  providerOptions?: ProviderOptionsConfig
140
- ) => Promise<{ provider: string; model: string }>;
141
+ ) => Promise<{ provider: string; model: string; savedTo: ConfigScope }>;
141
142
  addResource: (resource: ResourceDefinition) => Promise<ResourceDefinition>;
142
143
  removeResource: (name: string) => Promise<void>;
143
144
  clearResources: () => Promise<{ cleared: number }>;
@@ -689,7 +690,11 @@ export namespace Config {
689
690
  setMutableConfig(updated);
690
691
  await saveConfig(configPath, updated);
691
692
  Metrics.info('config.model.updated', { provider, model });
692
- return { provider, model };
693
+ return {
694
+ provider,
695
+ model,
696
+ savedTo: currentProjectConfig ? 'project' : 'global'
697
+ };
693
698
  },
694
699
 
695
700
  addResource: async (resource: ResourceDefinition) => {
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+
3
+ import { formatErrorForDisplay, getErrorHint, getErrorMessage, getErrorTag } from './errors.ts';
4
+
5
+ describe('errors', () => {
6
+ it('unwraps panic wrappers to the underlying tagged error details', () => {
7
+ const providerError = Object.assign(new Error('Provider "opencode" is not authenticated.'), {
8
+ _tag: 'ProviderNotAuthenticatedError',
9
+ hint: 'Run "opencode auth" to configure provider credentials.'
10
+ });
11
+ const wrapped = {
12
+ _tag: 'Panic',
13
+ message: 'match err handler threw',
14
+ cause: {
15
+ _tag: 'AgentError',
16
+ message: 'Failed to get response from AI',
17
+ hint: 'This may be a temporary issue. Try running the command again.',
18
+ cause: {
19
+ _tag: 'UnhandledException',
20
+ message: 'Unhandled exception: Provider "opencode" is not authenticated.',
21
+ cause: providerError
22
+ }
23
+ }
24
+ };
25
+
26
+ expect(getErrorTag(wrapped)).toBe('ProviderNotAuthenticatedError');
27
+ expect(getErrorMessage(wrapped)).toBe('Provider "opencode" is not authenticated.');
28
+ expect(getErrorHint(wrapped)).toBe('Run "opencode auth" to configure provider credentials.');
29
+ expect(formatErrorForDisplay(wrapped)).toBe(
30
+ 'Provider "opencode" is not authenticated.\n\nHint: Run "opencode auth" to configure provider credentials.'
31
+ );
32
+ });
33
+
34
+ it('keeps contextual non-wrapper error messages', () => {
35
+ const error = {
36
+ _tag: 'CollectionError',
37
+ message: 'Failed to load resource "npm:prettier": missing docs index',
38
+ hint: 'Try running "btca clear" and reload resources.',
39
+ cause: {
40
+ _tag: 'Panic',
41
+ message: 'match err handler threw',
42
+ cause: new Error('boom')
43
+ }
44
+ };
45
+
46
+ expect(getErrorTag(error)).toBe('CollectionError');
47
+ expect(getErrorMessage(error)).toBe(
48
+ 'Failed to load resource "npm:prettier": missing docs index'
49
+ );
50
+ expect(getErrorHint(error)).toBe('Try running "btca clear" and reload resources.');
51
+ });
52
+
53
+ it('handles cyclic cause chains without throwing', () => {
54
+ const cyclic = { _tag: 'Panic', message: 'match err handler threw' } as {
55
+ _tag: string;
56
+ message: string;
57
+ cause?: unknown;
58
+ };
59
+ cyclic.cause = cyclic;
60
+
61
+ expect(getErrorTag(cyclic)).toBe('Panic');
62
+ expect(getErrorMessage(cyclic)).toBe('match err handler threw');
63
+ expect(getErrorHint(cyclic)).toBeUndefined();
64
+ });
65
+ });
package/src/errors.ts CHANGED
@@ -11,22 +11,115 @@ export type TaggedErrorLike = {
11
11
  readonly hint?: string;
12
12
  };
13
13
 
14
+ const MAX_CAUSE_DEPTH = 12;
15
+ const WRAPPER_TAGS = new Set(['Panic', 'UnhandledException']);
16
+ const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
17
+ typeof value === 'object' && value !== null;
18
+
19
+ const readStringField = (value: unknown, field: '_tag' | 'message' | 'hint') => {
20
+ if (!isObjectRecord(value) || !(field in value)) return undefined;
21
+ const candidate = value[field];
22
+ return typeof candidate === 'string' && candidate.length > 0 ? candidate : undefined;
23
+ };
24
+
25
+ const readCauseField = (value: unknown) => {
26
+ if (!isObjectRecord(value) || !('cause' in value)) return undefined;
27
+ return value.cause;
28
+ };
29
+
30
+ const isWrapperMessage = (message?: string) =>
31
+ Boolean(
32
+ message &&
33
+ (message.startsWith('Unhandled exception:') ||
34
+ /handler threw$/u.test(message) ||
35
+ /callback threw$/u.test(message))
36
+ );
37
+
38
+ const normalizeMessage = (message: string) => {
39
+ if (!message.startsWith('Unhandled exception:')) return message;
40
+ const stripped = message.slice('Unhandled exception:'.length).trim();
41
+ return stripped.length > 0 ? stripped : message;
42
+ };
43
+
44
+ const isWrapperEntry = (entry: unknown) => {
45
+ const tag = readStringField(entry, '_tag');
46
+ const message = readStringField(entry, 'message');
47
+ const hasCause = readCauseField(entry) !== undefined;
48
+ const isAgentWrapper =
49
+ tag === 'AgentError' && message === 'Failed to get response from AI' && hasCause;
50
+ return (
51
+ isAgentWrapper || (tag !== undefined && WRAPPER_TAGS.has(tag)) || isWrapperMessage(message)
52
+ );
53
+ };
54
+
55
+ const getErrorChain = (error: unknown) => {
56
+ const chain: unknown[] = [];
57
+ const visited = new Set<unknown>();
58
+ let current: unknown = error;
59
+ let depth = 0;
60
+
61
+ while (current !== undefined && depth < MAX_CAUSE_DEPTH && !visited.has(current)) {
62
+ chain.push(current);
63
+ visited.add(current);
64
+ const cause = readCauseField(current);
65
+ if (cause === undefined) break;
66
+ current = cause;
67
+ depth += 1;
68
+ }
69
+
70
+ return chain;
71
+ };
72
+
14
73
  export const getErrorTag = (error: unknown): string => {
15
- if (error && typeof error === 'object' && '_tag' in error) return String((error as any)._tag);
74
+ const chain = getErrorChain(error);
75
+
76
+ for (const entry of chain) {
77
+ const tag = readStringField(entry, '_tag');
78
+ if (tag && !isWrapperEntry(entry)) return tag;
79
+ }
80
+
81
+ for (const entry of chain) {
82
+ const tag = readStringField(entry, '_tag');
83
+ if (tag) return tag;
84
+ }
85
+
16
86
  return 'UnknownError';
17
87
  };
18
88
 
19
89
  export const getErrorMessage = (error: unknown): string => {
20
- if (error && typeof error === 'object' && 'message' in error)
21
- return String((error as any).message);
90
+ const chain = getErrorChain(error);
91
+
92
+ for (const entry of chain) {
93
+ const message = readStringField(entry, 'message');
94
+ if (message && !isWrapperEntry(entry)) return message;
95
+ }
96
+
97
+ for (const entry of chain) {
98
+ const message = readStringField(entry, 'message');
99
+ if (message && !isWrapperMessage(message)) return message;
100
+ }
101
+
102
+ for (const entry of chain) {
103
+ const message = readStringField(entry, 'message');
104
+ if (message) return normalizeMessage(message);
105
+ }
106
+
22
107
  return String(error);
23
108
  };
24
109
 
25
110
  export const getErrorHint = (error: unknown): string | undefined => {
26
- if (error && typeof error === 'object' && 'hint' in error) {
27
- const hint = (error as any).hint;
28
- return typeof hint === 'string' ? hint : undefined;
111
+ const chain = getErrorChain(error);
112
+
113
+ for (const entry of chain) {
114
+ const hint = readStringField(entry, 'hint');
115
+ if (hint && !isWrapperEntry(entry)) return hint;
29
116
  }
117
+
118
+ for (const entry of chain) {
119
+ const hint = readStringField(entry, 'hint');
120
+ if (hint) return hint;
121
+ }
122
+
30
123
  return undefined;
31
124
  };
32
125
 
package/src/index.ts CHANGED
@@ -12,12 +12,13 @@ import { getErrorMessage, getErrorTag, getErrorHint } from './errors.ts';
12
12
  import { Metrics } from './metrics/index.ts';
13
13
  import { ModelsDevPricing } from './pricing/models-dev.ts';
14
14
  import { Resources } from './resources/service.ts';
15
- import { GitResourceSchema, LocalResourceSchema } from './resources/schema.ts';
15
+ import { GitResourceSchema, LocalResourceSchema, NpmResourceSchema } from './resources/schema.ts';
16
16
  import { StreamService } from './stream/service.ts';
17
17
  import type { BtcaStreamMetaEvent } from './stream/types.ts';
18
18
  import {
19
19
  LIMITS,
20
20
  normalizeGitHubUrl,
21
+ parseNpmReference,
21
22
  validateGitUrl,
22
23
  validateResourceReference
23
24
  } from './validation/index.ts';
@@ -82,6 +83,8 @@ const ResourceReferenceField = z.string().superRefine((value, ctx) => {
82
83
  });
83
84
 
84
85
  const normalizeQuestionResourceReference = (reference: string): string => {
86
+ const npmReference = parseNpmReference(reference);
87
+ if (npmReference) return npmReference.normalizedReference;
85
88
  const gitUrlResult = validateGitUrl(reference);
86
89
  if (gitUrlResult.valid) return gitUrlResult.value;
87
90
  return reference;
@@ -165,9 +168,18 @@ const AddLocalResourceRequestSchema = z.object({
165
168
  specialNotes: LocalResourceSchema.shape.specialNotes
166
169
  });
167
170
 
171
+ const AddNpmResourceRequestSchema = z.object({
172
+ type: z.literal('npm'),
173
+ name: NpmResourceSchema.shape.name,
174
+ package: NpmResourceSchema.shape.package,
175
+ version: NpmResourceSchema.shape.version,
176
+ specialNotes: NpmResourceSchema.shape.specialNotes
177
+ });
178
+
168
179
  const AddResourceRequestSchema = z.discriminatedUnion('type', [
169
180
  AddGitResourceRequestSchema,
170
- AddLocalResourceRequestSchema
181
+ AddLocalResourceRequestSchema,
182
+ AddNpmResourceRequestSchema
171
183
  ]);
172
184
 
173
185
  const RemoveResourceRequestSchema = z.object({
@@ -188,16 +200,16 @@ class RequestError extends Error {
188
200
 
189
201
  const decodeJson = async <T>(req: Request, schema: z.ZodType<T>): Promise<T> => {
190
202
  const bodyResult = await Result.tryPromise(() => req.json());
191
- return bodyResult.match({
192
- ok: (body) => {
193
- const parsed = schema.safeParse(body);
194
- if (!parsed.success) throw new RequestError('Invalid request body', parsed.error);
195
- return parsed.data;
196
- },
197
- err: (cause) => {
198
- throw new RequestError('Failed to parse request JSON', cause);
199
- }
200
- });
203
+ if (!Result.isOk(bodyResult)) {
204
+ throw new RequestError('Failed to parse request JSON', bodyResult.error);
205
+ }
206
+
207
+ const parsed = schema.safeParse(bodyResult.value);
208
+ if (!parsed.success) {
209
+ throw new RequestError('Invalid request body', parsed.error);
210
+ }
211
+
212
+ return parsed.data;
201
213
  };
202
214
 
203
215
  // ─────────────────────────────────────────────────────────────────────────────
@@ -242,6 +254,9 @@ const createApp = (deps: {
242
254
  tag === 'ConfigError' ||
243
255
  tag === 'InvalidProviderError' ||
244
256
  tag === 'InvalidModelError' ||
257
+ tag === 'ProviderNotAuthenticatedError' ||
258
+ tag === 'ProviderAuthTypeError' ||
259
+ tag === 'ProviderNotFoundError' ||
245
260
  tag === 'ProviderNotConnectedError' ||
246
261
  tag === 'ProviderOptionsError'
247
262
  ? 400
@@ -287,7 +302,8 @@ const createApp = (deps: {
287
302
  searchPaths: r.searchPaths ?? null,
288
303
  specialNotes: r.specialNotes ?? null
289
304
  };
290
- } else {
305
+ }
306
+ if (r.type === 'local') {
291
307
  return {
292
308
  name: r.name,
293
309
  type: r.type,
@@ -295,6 +311,13 @@ const createApp = (deps: {
295
311
  specialNotes: r.specialNotes ?? null
296
312
  };
297
313
  }
314
+ return {
315
+ name: r.name,
316
+ type: r.type,
317
+ package: r.package,
318
+ version: r.version ?? null,
319
+ specialNotes: r.specialNotes ?? null
320
+ };
298
321
  })
299
322
  });
300
323
  })
@@ -435,7 +458,8 @@ const createApp = (deps: {
435
458
  };
436
459
  const added = await config.addResource(resource);
437
460
  return c.json(added, 201);
438
- } else {
461
+ }
462
+ if (decoded.type === 'local') {
439
463
  const resource = {
440
464
  type: 'local' as const,
441
465
  name: decoded.name,
@@ -445,6 +469,15 @@ const createApp = (deps: {
445
469
  const added = await config.addResource(resource);
446
470
  return c.json(added, 201);
447
471
  }
472
+ const resource = {
473
+ type: 'npm' as const,
474
+ name: decoded.name,
475
+ package: decoded.package,
476
+ ...(decoded.version ? { version: decoded.version } : {}),
477
+ ...(decoded.specialNotes ? { specialNotes: decoded.specialNotes } : {})
478
+ };
479
+ const added = await config.addResource(resource);
480
+ return c.json(added, 201);
448
481
  })
449
482
 
450
483
  // DELETE /config/resources - Remove a resource
@@ -181,7 +181,7 @@ export namespace Auth {
181
181
  return 'Run "btca connect -p minimax" and enter your API key. Get your API key at https://platform.minimax.io/user-center/basic-information.';
182
182
  default:
183
183
  return 'Run "btca connect" and configure credentials for this provider.';
184
- }
184
+ }
185
185
  };
186
186
 
187
187
  /**