@clinebot/core 0.0.23 → 0.0.25

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.
@@ -3,6 +3,7 @@ import * as LlmsProviders from "@clinebot/llms/providers";
3
3
  import type {
4
4
  RpcAddProviderActionRequest,
5
5
  RpcOAuthProviderId,
6
+ RpcProviderCapability,
6
7
  RpcProviderListItem,
7
8
  RpcProviderModel,
8
9
  RpcSaveProviderSettingsActionRequest,
@@ -22,6 +23,23 @@ import {
22
23
 
23
24
  export { ensureCustomProvidersLoaded } from "./local-provider-registry";
24
25
 
26
+ export interface UpdateLocalProviderRequest {
27
+ providerId: string;
28
+ name?: string;
29
+ baseUrl?: string;
30
+ apiKey?: string | null;
31
+ headers?: Record<string, string> | null;
32
+ timeoutMs?: number | null;
33
+ models?: string[];
34
+ defaultModelId?: string | null;
35
+ modelsSourceUrl?: string | null;
36
+ capabilities?: RpcProviderCapability[] | null;
37
+ }
38
+
39
+ export interface DeleteLocalProviderRequest {
40
+ providerId: string;
41
+ }
42
+
25
43
  // --- Small pure helpers ---
26
44
 
27
45
  function resolveVisibleApiKey(settings: {
@@ -169,6 +187,72 @@ async function resolveProviderModelMap(
169
187
  : registeredModels;
170
188
  }
171
189
 
190
+ function uniqueTrimmed(values?: string[]): string[] {
191
+ return [...new Set((values ?? []).map((v) => v.trim()).filter(Boolean))];
192
+ }
193
+
194
+ function normalizeHeaders(
195
+ headers: Record<string, string> | null | undefined,
196
+ ): Record<string, string> | undefined {
197
+ const entries = Object.entries(headers ?? {}).filter(
198
+ ([key]) => key.trim().length > 0,
199
+ );
200
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
201
+ }
202
+
203
+ function buildProviderModels(
204
+ modelIds: string[],
205
+ capabilities: RpcProviderCapability[] | undefined,
206
+ ) {
207
+ const supportsVision = capabilities?.includes("vision") ?? false;
208
+ const supportsReasoning = capabilities?.includes("reasoning") ?? false;
209
+ return Object.fromEntries(
210
+ modelIds.map((id) => [
211
+ id,
212
+ {
213
+ id,
214
+ name: id,
215
+ supportsVision,
216
+ supportsAttachments: supportsVision,
217
+ supportsReasoning,
218
+ },
219
+ ]),
220
+ );
221
+ }
222
+
223
+ async function resolveModelIds(params: {
224
+ providerId: string;
225
+ explicitModels?: string[];
226
+ modelsSourceUrl?: string;
227
+ fallbackModelIds?: string[];
228
+ shouldRecompute: boolean;
229
+ }): Promise<string[]> {
230
+ if (!params.shouldRecompute) {
231
+ return params.fallbackModelIds ?? [];
232
+ }
233
+ const fetchedModels = params.modelsSourceUrl
234
+ ? await fetchModelIdsFromSource(params.modelsSourceUrl, params.providerId)
235
+ : [];
236
+ return [...new Set([...(params.explicitModels ?? []), ...fetchedModels])];
237
+ }
238
+
239
+ function removeProviderFromSettingsState(
240
+ manager: ProviderSettingsManager,
241
+ providerId: string,
242
+ ): void {
243
+ const state = manager.read();
244
+ let mutated = false;
245
+ if (state.providers[providerId]) {
246
+ delete state.providers[providerId];
247
+ mutated = true;
248
+ }
249
+ if (state.lastUsedProvider === providerId) {
250
+ delete state.lastUsedProvider;
251
+ mutated = true;
252
+ }
253
+ if (mutated) manager.write(state);
254
+ }
255
+
172
256
  // --- Public API ---
173
257
 
174
258
  export async function addLocalProvider(
@@ -182,23 +266,48 @@ export async function addLocalProvider(
182
266
  }> {
183
267
  const providerId = request.providerId.trim().toLowerCase();
184
268
  if (!providerId) throw new Error("providerId is required");
269
+ const baseUrl = request.baseUrl.trim();
270
+ const apiKey = request.apiKey?.trim() ?? "";
271
+
272
+ // Compatibility path: empty baseUrl + empty apiKey is treated as a delete request.
273
+ if (!baseUrl && !apiKey) {
274
+ const modelsPath = resolveModelsRegistryPath(manager);
275
+ const modelsState = await readModelsFile(modelsPath);
276
+ if (modelsState.providers[providerId]) {
277
+ const deleted = await deleteLocalProvider(manager, { providerId });
278
+ return {
279
+ providerId,
280
+ settingsPath: deleted.settingsPath,
281
+ modelsPath: deleted.modelsPath,
282
+ modelsCount: 0,
283
+ };
284
+ }
285
+
286
+ removeProviderFromSettingsState(manager, providerId);
287
+
288
+ return {
289
+ providerId,
290
+ settingsPath: manager.getFilePath(),
291
+ modelsPath,
292
+ modelsCount: 0,
293
+ };
294
+ }
295
+
185
296
  if (LlmsModels.hasProvider(providerId))
186
297
  throw new Error(`provider "${providerId}" already exists`);
187
298
 
188
299
  const providerName = request.name.trim();
189
300
  if (!providerName) throw new Error("name is required");
190
-
191
- const baseUrl = request.baseUrl.trim();
192
301
  if (!baseUrl) throw new Error("baseUrl is required");
193
302
 
194
- const typedModels = (request.models ?? [])
195
- .map((m) => m.trim())
196
- .filter(Boolean);
303
+ const typedModels = uniqueTrimmed(request.models);
197
304
  const sourceUrl = request.modelsSourceUrl?.trim();
198
- const fetchedModels = sourceUrl
199
- ? await fetchModelIdsFromSource(sourceUrl, providerId)
200
- : [];
201
- const modelIds = [...new Set([...typedModels, ...fetchedModels])];
305
+ const modelIds = await resolveModelIds({
306
+ providerId,
307
+ explicitModels: typedModels,
308
+ modelsSourceUrl: sourceUrl,
309
+ shouldRecompute: true,
310
+ });
202
311
  if (modelIds.length === 0) {
203
312
  throw new Error(
204
313
  "at least one model is required (manual or via modelsSourceUrl)",
@@ -214,19 +323,14 @@ export async function addLocalProvider(
214
323
  const capabilities = request.capabilities?.length
215
324
  ? [...new Set(request.capabilities)]
216
325
  : undefined;
217
- const headerEntries = Object.entries(request.headers ?? {}).filter(
218
- ([k]) => k.trim().length > 0,
219
- );
326
+ const normalizedHeaders = normalizeHeaders(request.headers);
220
327
 
221
328
  manager.saveProviderSettings(
222
329
  {
223
330
  provider: providerId,
224
- apiKey: request.apiKey?.trim() || undefined,
331
+ apiKey: apiKey || undefined,
225
332
  baseUrl,
226
- headers:
227
- headerEntries.length > 0
228
- ? Object.fromEntries(headerEntries)
229
- : undefined,
333
+ headers: normalizedHeaders,
230
334
  timeout: request.timeoutMs,
231
335
  model: defaultModelId,
232
336
  },
@@ -235,8 +339,6 @@ export async function addLocalProvider(
235
339
 
236
340
  const modelsPath = resolveModelsRegistryPath(manager);
237
341
  const modelsState = await readModelsFile(modelsPath);
238
- const supportsVision = capabilities?.includes("vision") ?? false;
239
- const supportsReasoning = capabilities?.includes("reasoning") ?? false;
240
342
 
241
343
  modelsState.providers[providerId] = {
242
344
  provider: {
@@ -246,18 +348,7 @@ export async function addLocalProvider(
246
348
  capabilities,
247
349
  modelsSourceUrl: sourceUrl,
248
350
  },
249
- models: Object.fromEntries(
250
- modelIds.map((id) => [
251
- id,
252
- {
253
- id,
254
- name: id,
255
- supportsVision,
256
- supportsAttachments: supportsVision,
257
- supportsReasoning,
258
- },
259
- ]),
260
- ),
351
+ models: buildProviderModels(modelIds, capabilities),
261
352
  };
262
353
  await writeModelsFile(modelsPath, modelsState);
263
354
  registerCustomProvider(providerId, modelsState.providers[providerId]);
@@ -270,6 +361,151 @@ export async function addLocalProvider(
270
361
  };
271
362
  }
272
363
 
364
+ export async function updateLocalProvider(
365
+ manager: ProviderSettingsManager,
366
+ request: UpdateLocalProviderRequest,
367
+ ): Promise<{
368
+ providerId: string;
369
+ settingsPath: string;
370
+ modelsPath: string;
371
+ modelsCount: number;
372
+ }> {
373
+ const providerId = request.providerId.trim().toLowerCase();
374
+ if (!providerId) throw new Error("providerId is required");
375
+
376
+ const modelsPath = resolveModelsRegistryPath(manager);
377
+ const modelsState = await readModelsFile(modelsPath);
378
+ const existingEntry = modelsState.providers[providerId];
379
+ if (!existingEntry) {
380
+ throw new Error(`provider "${providerId}" does not exist`);
381
+ }
382
+
383
+ const providerName =
384
+ request.name?.trim() ?? existingEntry.provider.name.trim();
385
+ if (!providerName) throw new Error("name is required");
386
+
387
+ const baseUrl =
388
+ request.baseUrl?.trim() ?? existingEntry.provider.baseUrl.trim();
389
+ if (!baseUrl) throw new Error("baseUrl is required");
390
+
391
+ const capabilities =
392
+ request.capabilities === undefined
393
+ ? existingEntry.provider.capabilities
394
+ : request.capabilities === null
395
+ ? undefined
396
+ : [...new Set(request.capabilities)];
397
+
398
+ const explicitModels = uniqueTrimmed(request.models);
399
+ const nextModelsSourceUrl =
400
+ request.modelsSourceUrl === undefined
401
+ ? existingEntry.provider.modelsSourceUrl
402
+ : request.modelsSourceUrl?.trim() || undefined;
403
+ const shouldRecomputeModels =
404
+ request.models !== undefined ||
405
+ (request.modelsSourceUrl !== undefined && !!nextModelsSourceUrl);
406
+ const existingModelIds = Object.keys(existingEntry.models)
407
+ .map((id) => id.trim())
408
+ .filter(Boolean);
409
+ const modelIds = await resolveModelIds({
410
+ providerId,
411
+ explicitModels,
412
+ modelsSourceUrl: nextModelsSourceUrl,
413
+ fallbackModelIds: existingModelIds,
414
+ shouldRecompute: shouldRecomputeModels,
415
+ });
416
+ if (modelIds.length === 0) {
417
+ throw new Error(
418
+ "at least one model is required (manual or via modelsSourceUrl)",
419
+ );
420
+ }
421
+
422
+ const defaultModelCandidate =
423
+ request.defaultModelId === undefined
424
+ ? existingEntry.provider.defaultModelId?.trim()
425
+ : request.defaultModelId?.trim();
426
+ const defaultModelId =
427
+ defaultModelCandidate && modelIds.includes(defaultModelCandidate)
428
+ ? defaultModelCandidate
429
+ : modelIds[0];
430
+
431
+ const existingSettings = manager.getProviderSettings(providerId);
432
+ const nextSettings: Record<string, unknown> = {
433
+ ...(existingSettings ?? {}),
434
+ provider: providerId,
435
+ baseUrl,
436
+ model: defaultModelId,
437
+ };
438
+ if (request.apiKey !== undefined) {
439
+ const apiKey = request.apiKey?.trim() ?? "";
440
+ if (apiKey) nextSettings.apiKey = apiKey;
441
+ else delete nextSettings.apiKey;
442
+ }
443
+ if (request.headers !== undefined) {
444
+ const normalizedHeaders = normalizeHeaders(request.headers);
445
+ if (normalizedHeaders) nextSettings.headers = normalizedHeaders;
446
+ else delete nextSettings.headers;
447
+ }
448
+ if (request.timeoutMs !== undefined) {
449
+ if (typeof request.timeoutMs === "number") {
450
+ nextSettings.timeout = request.timeoutMs;
451
+ } else {
452
+ delete nextSettings.timeout;
453
+ }
454
+ }
455
+
456
+ manager.saveProviderSettings(nextSettings, { setLastUsed: false });
457
+
458
+ modelsState.providers[providerId] = {
459
+ provider: {
460
+ name: providerName,
461
+ baseUrl,
462
+ defaultModelId,
463
+ capabilities,
464
+ modelsSourceUrl: nextModelsSourceUrl,
465
+ },
466
+ models: buildProviderModels(modelIds, capabilities),
467
+ };
468
+ await writeModelsFile(modelsPath, modelsState);
469
+ registerCustomProvider(providerId, modelsState.providers[providerId]);
470
+
471
+ return {
472
+ providerId,
473
+ settingsPath: manager.getFilePath(),
474
+ modelsPath,
475
+ modelsCount: modelIds.length,
476
+ };
477
+ }
478
+
479
+ export async function deleteLocalProvider(
480
+ manager: ProviderSettingsManager,
481
+ request: DeleteLocalProviderRequest,
482
+ ): Promise<{
483
+ providerId: string;
484
+ settingsPath: string;
485
+ modelsPath: string;
486
+ }> {
487
+ const providerId = request.providerId.trim().toLowerCase();
488
+ if (!providerId) throw new Error("providerId is required");
489
+
490
+ const modelsPath = resolveModelsRegistryPath(manager);
491
+ const modelsState = await readModelsFile(modelsPath);
492
+ if (!modelsState.providers[providerId]) {
493
+ throw new Error(`provider "${providerId}" does not exist`);
494
+ }
495
+
496
+ delete modelsState.providers[providerId];
497
+ await writeModelsFile(modelsPath, modelsState);
498
+ LlmsModels.unregisterProvider(providerId);
499
+
500
+ removeProviderFromSettingsState(manager, providerId);
501
+
502
+ return {
503
+ providerId,
504
+ settingsPath: manager.getFilePath(),
505
+ modelsPath,
506
+ };
507
+ }
508
+
273
509
  export async function listLocalProviders(
274
510
  manager: ProviderSettingsManager,
275
511
  ): Promise<{ providers: RpcProviderListItem[]; settingsPath: string }> {
@@ -246,6 +246,47 @@ Use conventional commits.`,
246
246
  runtime.shutdown("test");
247
247
  });
248
248
 
249
+ it("allows tool routing rules to disable skills even when skills exist", () => {
250
+ const cwd = mkdtempSync(
251
+ join(tmpdir(), "runtime-builder-skills-routing-disabled-"),
252
+ );
253
+ const skillDir = join(cwd, ".cline", "skills", "commit");
254
+ mkdirSync(skillDir, { recursive: true });
255
+ writeFileSync(
256
+ join(skillDir, "SKILL.md"),
257
+ `---
258
+ name: commit
259
+ description: Create commit message
260
+ ---
261
+ Use conventional commits.`,
262
+ "utf8",
263
+ );
264
+
265
+ const runtime = new DefaultRuntimeBuilder().build({
266
+ config: {
267
+ providerId: "openrouter",
268
+ modelId: "google/gemini-3-flash-preview",
269
+ apiKey: "key",
270
+ systemPrompt: "test",
271
+ cwd,
272
+ enableTools: true,
273
+ enableSpawnAgent: false,
274
+ enableAgentTeams: false,
275
+ toolRoutingRules: [
276
+ {
277
+ mode: "act",
278
+ providerIdIncludes: ["openrouter"],
279
+ modelIdIncludes: ["gemini"],
280
+ disableTools: ["skills"],
281
+ },
282
+ ],
283
+ },
284
+ });
285
+
286
+ expect(runtime.tools.map((tool) => tool.name)).not.toContain("skills");
287
+ runtime.shutdown("test");
288
+ });
289
+
249
290
  it("marks configured but disabled skills in executor metadata", async () => {
250
291
  const cwd = mkdtempSync(join(tmpdir(), "runtime-builder-skills-disabled-"));
251
292
  const enabledDir = join(cwd, ".cline", "skills", "commit");
@@ -72,8 +72,8 @@ function createBuiltinToolsList(
72
72
  return createBuiltinTools({
73
73
  cwd,
74
74
  ...preset,
75
- ...toolRoutingConfig,
76
75
  enableSkills: !!skillsExecutor,
76
+ ...toolRoutingConfig,
77
77
  executors: {
78
78
  ...(skillsExecutor
79
79
  ? {
@@ -531,10 +531,9 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
531
531
  }
532
532
  teamToolsRegistered = true;
533
533
 
534
- const leadAgentId = config.sessionId || "lead";
535
534
  const teamBootstrap = bootstrapAgentTeams({
536
535
  runtime: teamRuntime,
537
- leadAgentId,
536
+ leadAgentId: "lead",
538
537
  restoredFromPersistence: Boolean(restoredTeamState),
539
538
  restoredTeammates: restoredTeammateSpecs,
540
539
  createBaseTools: normalized.enableTools
@@ -81,5 +81,5 @@ describe("UnifiedSessionPersistenceService", () => {
81
81
  expect(readFileSync(artifacts.transcriptPath, "utf8")).toContain(
82
82
  "[shutdown] failed_external_process_exit",
83
83
  );
84
- });
84
+ }, 15_000);
85
85
  });