@alint-js/cli 0.0.37 → 0.1.0

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.
@@ -2,7 +2,7 @@ import process from "node:process";
2
2
  import { inspect } from "node:util";
3
3
  import { cac } from "cac";
4
4
  import { appendFile, mkdir, readFile, readdir, rm, stat } from "node:fs/promises";
5
- import { getGlobalSetupConfigPath, getProjectSetupConfigPath, getStatsDir, installStaticPlugins, loadAlintConfig, loadSetupConfig, mergeSetupConfigs, writeSetupConfig } from "@alint-js/config";
5
+ import { addProviderModels, getGlobalSetupConfigPath, getProjectSetupConfigPath, getStatsDir, installStaticPlugins, loadAlintConfig, loadSetupConfig, mergeSetupConfigs, pruneProviderModels, removeProviderModels, replaceSetupProvider, setProviderEndpoint, setProviderHeader, unsetProviderHeader, writeSetupConfig } from "@alint-js/config";
6
6
  import { AlintRunCancelledError, AlintRunError, hasDiscoveryFilePatterns, matchesDiscoveryFile, normalizeConfig, resolveConfigForDirectory, resolveConfigForFile, runAlint } from "@alint-js/core";
7
7
  import { isAbsolute, join, relative, resolve } from "pathe";
8
8
  import { getBorderCharacters, table } from "table";
@@ -16,7 +16,12 @@ import Gitignore from "gitignore-fs";
16
16
  import { Minimatch, minimatch } from "minimatch";
17
17
  import { errorMessageFrom as errorMessageFrom$1 } from "@moeru/std";
18
18
  //#region package.json
19
- var version = "0.0.37";
19
+ var version = "0.1.0";
20
+ //#endregion
21
+ //#region src/cli/output.ts
22
+ function escapeLineValue(value) {
23
+ return JSON.stringify(value).slice(1, -1).replace(/[\u0080-\u009F\u2028\u2029]/gu, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
24
+ }
20
25
  //#endregion
21
26
  //#region src/cli/commands/command.ts
22
27
  function defineCommand(node) {
@@ -41,6 +46,13 @@ function dispatchCommand(context, node, args, options, path) {
41
46
  const child = node.children?.find((item) => item.name === subcommand || item.alias?.includes(subcommand ?? ""));
42
47
  if (child) return dispatchCommand(context, child, restArgs, options, [...path, child.name]);
43
48
  if (!node.action) return Promise.resolve(reportUnknownCommand(context, path, args));
49
+ if (node.exactArguments === true) {
50
+ const unexpectedArgument = args[node.arguments?.split(/\s+/u).filter(Boolean).length ?? 0];
51
+ if (unexpectedArgument !== void 0) {
52
+ context.io.stderr.write(`unexpected argument "${escapeLineValue(unexpectedArgument)}". usage: alint ${formatUsagePattern(path, node)}.\n`);
53
+ return Promise.resolve(2);
54
+ }
55
+ }
44
56
  return Promise.resolve(node.action(context, ...parseCommandArguments(node, args), options));
45
57
  }
46
58
  function formatChildCommandHelp(parentPath, child) {
@@ -197,6 +209,7 @@ function shouldSkipOptionValue(arg) {
197
209
  "--interval",
198
210
  "--metric",
199
211
  "--model",
212
+ "--provider",
200
213
  "--provider-endpoint",
201
214
  "--provider-header",
202
215
  "--provider-id",
@@ -234,6 +247,13 @@ async function runConfigInspectCommand(path, configPath, io) {
234
247
  }
235
248
  //#endregion
236
249
  //#region src/cli/provider-registry.ts
250
+ function formatDuplicateModelIdentity(identity) {
251
+ return [
252
+ `model "${escapeLineValue(identity)}" is configured more than once.`,
253
+ "remove duplicate provider/model definitions from the setup configuration.",
254
+ ""
255
+ ].join("\n");
256
+ }
237
257
  const providerSetupSources = [
238
258
  {
239
259
  defaultEndpoint: "http://127.0.0.1:8317/v1",
@@ -247,6 +267,20 @@ const providerSetupSources = [
247
267
  probeModels: true,
248
268
  value: "custom"
249
269
  },
270
+ {
271
+ defaultEndpoint: "https://api.cerebras.ai/v1",
272
+ defaultProviderId: "cerebras",
273
+ label: "Cerebras",
274
+ probeModels: true,
275
+ value: "cerebras"
276
+ },
277
+ {
278
+ defaultEndpoint: "https://api.groq.com/openai/v1",
279
+ defaultProviderId: "groq",
280
+ label: "Groq",
281
+ probeModels: true,
282
+ value: "groq"
283
+ },
250
284
  {
251
285
  defaultEndpoint: "http://localhost:11434/v1",
252
286
  label: "Ollama",
@@ -277,8 +311,17 @@ function createProviderId(endpoint, existingIds) {
277
311
  if (!existingIds.has(candidate)) return candidate;
278
312
  }
279
313
  }
280
- function findModel(config, request) {
281
- return flattenModels(config).find(({ model }) => model.id === request || model.name === request || (model.aliases ?? []).includes(request));
314
+ function findModels(config, request) {
315
+ const candidates = flattenModels(config);
316
+ const canonicalCandidates = candidates.filter((candidate) => canonicalIdentity(candidate) === request);
317
+ if (canonicalCandidates.length > 0) return canonicalCandidates;
318
+ return candidates.filter((candidate) => {
319
+ return [
320
+ candidate.model.id,
321
+ candidate.model.name,
322
+ ...candidate.model.aliases ?? []
323
+ ].filter((name) => name !== void 0).some((name) => name === request || `${candidate.provider.id}/${name}` === request);
324
+ });
282
325
  }
283
326
  function findProviderSetupSource(value) {
284
327
  return providerSetupSources.find((source) => source.value === value);
@@ -293,6 +336,23 @@ function flattenModels(config) {
293
336
  provider
294
337
  })));
295
338
  }
339
+ function formatAmbiguousModels(request, candidates) {
340
+ const candidatesByIdentity = /* @__PURE__ */ new Map();
341
+ for (const candidate of candidates) {
342
+ const identity = canonicalIdentity(candidate);
343
+ const matchingCandidates = candidatesByIdentity.get(identity) ?? [];
344
+ matchingCandidates.push(candidate);
345
+ candidatesByIdentity.set(identity, matchingCandidates);
346
+ }
347
+ const duplicateIdentity = [...candidatesByIdentity].find(([, matchingCandidates]) => matchingCandidates.length > 1)?.[0];
348
+ if (duplicateIdentity !== void 0) return formatDuplicateModelIdentity(duplicateIdentity);
349
+ const choices = [...candidatesByIdentity.keys()];
350
+ return `${[
351
+ `ambiguous model "${escapeLineValue(request)}".`,
352
+ "specify a provider-qualified model:",
353
+ ...choices.map((choice) => ` ${escapeLineValue(choice)}`)
354
+ ].join("\n")}\n`;
355
+ }
296
356
  function formatModelList(config) {
297
357
  return formatTable([[
298
358
  "id",
@@ -343,13 +403,19 @@ function formatProviderShow(provider) {
343
403
  if (headerKeys.length > 0) lines.push(`headers: ${headerKeys.join(", ")}`);
344
404
  return `${lines.join("\n")}\n`;
345
405
  }
406
+ const httpTokenPattern = /^[\w^`\-!#$%&'*+.|~]+$/u;
407
+ function isValidProviderHeaderName(name) {
408
+ return httpTokenPattern.test(name);
409
+ }
346
410
  function parseHeaderList(headers) {
347
411
  if (headers.length === 0) return;
348
412
  const parsedHeaders = {};
349
413
  for (const header of headers) {
350
414
  const separatorIndex = header.indexOf("=");
351
- if (separatorIndex <= 0) throw new Error(`Invalid provider header "${header}". Expected Key=Value.`);
352
- parsedHeaders[header.slice(0, separatorIndex)] = header.slice(separatorIndex + 1);
415
+ if (separatorIndex <= 0) throw new Error("Invalid provider header. Expected Key=Value.");
416
+ const name = header.slice(0, separatorIndex);
417
+ if (!isValidProviderHeaderName(name)) throw new Error("Invalid provider header name. Expected an HTTP field-name token.");
418
+ parsedHeaders[name] = header.slice(separatorIndex + 1);
353
419
  }
354
420
  return parsedHeaders;
355
421
  }
@@ -357,8 +423,11 @@ async function probeModels(endpoint, headers = {}) {
357
423
  const response = await fetch(buildModelsUrl(endpoint), { headers });
358
424
  if (!response.ok) throw new Error(`GET ${buildModelsUrl(endpoint)} returned ${response.status}.`);
359
425
  const body = await response.json();
360
- if (!Array.isArray(body.data)) throw new TypeError("Expected OpenAI-compatible models response with data array.");
361
- return body.data.map((model) => model.id).filter((id) => typeof id === "string" && id.length > 0);
426
+ if (!isRecord$1(body) || !Array.isArray(body.data) || !body.data.every(isModelRecord)) throw new TypeError("Expected OpenAI-compatible models response with data array.");
427
+ return body.data.map((model) => model.id);
428
+ }
429
+ function canonicalIdentity(candidate) {
430
+ return `${candidate.provider.id}/${candidate.model.id}`;
362
431
  }
363
432
  function formatTable(rows) {
364
433
  if (rows.length <= 1) return "";
@@ -371,127 +440,1173 @@ function formatTable(rows) {
371
440
  drawHorizontalLine: () => false
372
441
  });
373
442
  }
443
+ function isModelRecord(value) {
444
+ return isRecord$1(value) && typeof value.id === "string" && value.id.length > 0;
445
+ }
446
+ function isRecord$1(value) {
447
+ return typeof value === "object" && value !== null && !Array.isArray(value);
448
+ }
374
449
  function normalizeProviderIdBase(value) {
375
450
  return value.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").toLowerCase() || "provider";
376
451
  }
377
452
  //#endregion
378
453
  //#region src/cli/commands/config/setup-config.ts
454
+ function formatUnknownProvider(providerId, scope) {
455
+ const hint = scope === "local" ? " Remove --local to inspect global configuration." : " Add --local to inspect project-local configuration.";
456
+ return `unknown provider "${escapeLineValue(providerId)}" in ${scope} setup config.${hint}\n`;
457
+ }
379
458
  async function loadMergedSetupConfig(io) {
380
459
  const globalSetupConfigPath = getGlobalSetupConfigPath(io.env ?? process.env);
381
460
  const projectSetupConfigPath = getProjectSetupConfigPath(io.cwd);
382
461
  const [globalSetupConfig, projectSetupConfig] = await Promise.all([loadSetupConfig(globalSetupConfigPath), loadSetupConfig(projectSetupConfigPath)]);
383
462
  return mergeSetupConfigs(globalSetupConfig, projectSetupConfig);
384
463
  }
385
- //#endregion
386
- //#region src/cli/commands/config/models/ls.ts
387
- const ls$1 = defineCommand({
388
- async action(context) {
389
- context.io.stdout.write(formatModelList(await loadMergedSetupConfig(context.io)));
390
- return 0;
391
- },
392
- alias: ["ls"],
393
- description: "List configured models",
394
- name: "list"
395
- });
396
- //#endregion
397
- //#region src/cli/commands/config/probe.ts
398
- function providerHeadersFromOptions(options) {
399
- return parseHeaderList(toArray$1(options.providerHeader)) ?? {};
464
+ async function loadScopedSetupConfig(io, local) {
465
+ const scope = local === true ? "local" : "global";
466
+ const path = scope === "local" ? getProjectSetupConfigPath(io.cwd) : getGlobalSetupConfigPath(io.env ?? process.env);
467
+ return {
468
+ config: await loadSetupConfig(path),
469
+ path,
470
+ scope
471
+ };
472
+ }
473
+ //#endregion
474
+ //#region src/cli/commands/config/models/ls.ts
475
+ const ls$1 = defineCommand({
476
+ async action(context) {
477
+ context.io.stdout.write(formatModelList(await loadMergedSetupConfig(context.io)));
478
+ return 0;
479
+ },
480
+ alias: ["ls"],
481
+ description: "List configured models",
482
+ name: "list"
483
+ });
484
+ //#endregion
485
+ //#region src/cli/commands/config/probe.ts
486
+ function providerHeadersFromOptions(options) {
487
+ return parseHeaderList(toArray$2(options.providerHeader)) ?? {};
488
+ }
489
+ function toArray$2(value) {
490
+ if (value === void 0) return [];
491
+ return (Array.isArray(value) ? value : [value]).filter((item) => typeof item === "string");
492
+ }
493
+ //#endregion
494
+ //#region src/cli/commands/config/models/probe.ts
495
+ const probe$1 = defineCommand({
496
+ async action(context, options) {
497
+ if (!options.endpoint) {
498
+ context.io.stderr.write("config models probe requires --endpoint.\n");
499
+ return 2;
500
+ }
501
+ try {
502
+ const models = await probeModels(options.endpoint, providerHeadersFromOptions(options));
503
+ context.io.stdout.write(`${models.join("\n")}${models.length > 0 ? "\n" : ""}`);
504
+ return 0;
505
+ } catch (error) {
506
+ context.io.stderr.write(`failed to probe models: ${errorMessageFrom(error) ?? String(error)}\n`);
507
+ return 2;
508
+ }
509
+ },
510
+ description: "Probe OpenAI-compatible models",
511
+ examples: [["# Probe a provider endpoint and print available model ids", "alint config models probe --endpoint https://openrouter.ai/api/v1"].join("\n"), ["# Probe an endpoint that requires an authorization header", "alint config models probe --endpoint https://api.example.com/v1 --provider-header \"Authorization=Bearer $TOKEN\""].join("\n")],
512
+ help: ["Probe an OpenAI-compatible models endpoint before saving it in setup config.", "The command calls the provider models endpoint using the supplied endpoint and optional headers, then prints the model ids returned by that provider."].join("\n\n"),
513
+ name: "probe",
514
+ options: [{
515
+ description: "Provider endpoint",
516
+ flags: "--endpoint <url>"
517
+ }, {
518
+ description: "Provider header",
519
+ flags: "--provider-header <Key=Value>"
520
+ }]
521
+ });
522
+ //#endregion
523
+ //#region src/cli/commands/config/models/prune.ts
524
+ const prune$1 = defineCommand({
525
+ action: runPruneModelsCommand,
526
+ description: "Remove models unavailable from providers",
527
+ name: "prune",
528
+ options: [
529
+ {
530
+ description: "Provider id",
531
+ flags: "--provider <id>"
532
+ },
533
+ {
534
+ description: "Read and write project-local config",
535
+ flags: "--local"
536
+ },
537
+ {
538
+ description: "Disable interactive confirmation",
539
+ flags: "-N, --no-interactive"
540
+ },
541
+ {
542
+ description: "Confirm model deletion",
543
+ flags: "--yes"
544
+ }
545
+ ]
546
+ });
547
+ /**
548
+ * Removes configured models absent from their providers' model registries.
549
+ *
550
+ * Triggering workflow:
551
+ *
552
+ * {@link registerCommandTree}
553
+ * -> `config models prune`
554
+ * -> {@link runPruneModelsCommand}
555
+ *
556
+ * Upstream:
557
+ * - {@link dispatchCommand}
558
+ *
559
+ * Downstream:
560
+ * - {@link confirmPruneModels}
561
+ * - {@link probeModels}
562
+ * - {@link pruneProviderModels}
563
+ * - {@link writeSetupConfig}
564
+ */
565
+ async function runPruneModelsCommand(context, options, confirm = confirmPruneModels) {
566
+ if (Array.isArray(options.provider)) {
567
+ context.io.stderr.write("config models prune accepts --provider only once.\n");
568
+ return 2;
569
+ }
570
+ const { config, path, scope } = await loadScopedSetupConfig(context.io, options.local);
571
+ const providers = options.provider === void 0 ? config.providers : config.providers.filter((provider) => provider.id === options.provider);
572
+ if (options.provider !== void 0 && providers.length === 0) {
573
+ context.io.stderr.write(formatUnknownProvider(options.provider, scope));
574
+ return 2;
575
+ }
576
+ const duplicateProviderId = providers.find((provider, providerIndex) => providers.findIndex((candidate) => candidate.id === provider.id) !== providerIndex)?.id;
577
+ if (duplicateProviderId !== void 0) {
578
+ context.io.stderr.write([
579
+ `provider "${escapeLineValue(duplicateProviderId)}" is configured more than once.`,
580
+ "remove duplicate provider definitions from the setup configuration.",
581
+ ""
582
+ ].join("\n"));
583
+ return 2;
584
+ }
585
+ const plans = [];
586
+ let hadFailure = false;
587
+ for (const provider of providers) {
588
+ let remoteModelIds;
589
+ try {
590
+ remoteModelIds = new Set(await probeModels(provider.endpoint, provider.headers));
591
+ } catch (error) {
592
+ hadFailure = true;
593
+ context.io.stderr.write(`failed to probe provider "${escapeLineValue(provider.id)}" at "${escapeLineValue(provider.endpoint)}": ${classifyProbeFailure(error)}.\n`);
594
+ continue;
595
+ }
596
+ const removedModels = provider.models.filter((model) => !remoteModelIds.has(model.id));
597
+ const defaultModel = removedModels.find((model) => (model.aliases ?? []).includes("default"));
598
+ if (defaultModel !== void 0) {
599
+ hadFailure = true;
600
+ context.io.stderr.write(`cannot prune default model "${escapeLineValue(provider.id)}/${escapeLineValue(defaultModel.id)}". select another default first.\n`);
601
+ continue;
602
+ }
603
+ if (removedModels.length > 0) plans.push({
604
+ provider,
605
+ remoteModelIds,
606
+ removedModels
607
+ });
608
+ }
609
+ if (plans.length === 0) {
610
+ context.io.stdout.write("no models to prune.\n");
611
+ return hadFailure ? 2 : 0;
612
+ }
613
+ context.io.stdout.write([
614
+ "Models to prune:",
615
+ ...plans.flatMap((plan) => plan.removedModels.map((model) => ` ${escapeLineValue(plan.provider.id)}/${escapeLineValue(model.id)}`)),
616
+ ""
617
+ ].join("\n"));
618
+ if (context.setupNoInteractive && options.yes !== true) {
619
+ context.io.stderr.write("config models prune requires --yes in --no-interactive mode.\n");
620
+ return 2;
621
+ }
622
+ if (!context.setupNoInteractive && options.yes !== true) {
623
+ if (context.io.stdin?.isTTY !== true || context.io.stdout.isTTY !== true) {
624
+ context.io.stderr.write("config models prune requires a TTY or -N --yes.\n");
625
+ return 2;
626
+ }
627
+ if (await confirm() !== "confirmed") return 1;
628
+ }
629
+ await writeSetupConfig(path, applyPlans(config, plans));
630
+ return hadFailure ? 2 : 0;
631
+ }
632
+ function applyPlans(config, plans) {
633
+ return plans.reduce((nextConfig, plan) => pruneProviderModels(nextConfig, plan.provider.id, plan.remoteModelIds), config);
634
+ }
635
+ function classifyProbeFailure(error) {
636
+ const message = errorMessageFrom(error);
637
+ const httpStatus = message?.match(/^GET .+ returned ([1-5]\d\d)\.$/u)?.[1];
638
+ if (httpStatus !== void 0) return `provider returned HTTP ${httpStatus}`;
639
+ if (error instanceof TypeError && message === "Expected OpenAI-compatible models response with data array.") return "provider returned an invalid models response";
640
+ if (error instanceof SyntaxError) return "provider returned invalid JSON";
641
+ return "model request failed";
642
+ }
643
+ async function confirmPruneModels() {
644
+ const prompts = await import("@clack/prompts");
645
+ const confirmed = await prompts.confirm({ message: "Remove these configured models?" });
646
+ if (prompts.isCancel(confirmed)) return "cancelled";
647
+ return confirmed ? "confirmed" : "declined";
648
+ }
649
+ //#endregion
650
+ //#region src/cli/commands/config/models/target.ts
651
+ function resolveExactModelTarget(config, request, requestedProviderId) {
652
+ const separatorIndex = request.indexOf("/");
653
+ const qualifier = separatorIndex === -1 ? void 0 : request.slice(0, separatorIndex);
654
+ const qualifiedProviders = qualifier === void 0 ? [] : config.providers.filter((provider) => provider.id === qualifier);
655
+ const qualifiedProviderId = qualifiedProviders[0]?.id;
656
+ const modelId = qualifiedProviders.length === 0 ? request : request.slice(separatorIndex + 1);
657
+ if (qualifiedProviderId !== void 0 && requestedProviderId !== void 0 && qualifiedProviderId !== requestedProviderId) return {
658
+ qualifiedProviderId,
659
+ requestedProviderId,
660
+ status: "conflict"
661
+ };
662
+ const requestedProviders = requestedProviderId === void 0 ? [] : config.providers.filter((provider) => provider.id === requestedProviderId);
663
+ if (requestedProviderId !== void 0 && requestedProviders.length === 0) return {
664
+ providerId: requestedProviderId,
665
+ status: "unknown-provider"
666
+ };
667
+ const selectedProviders = qualifiedProviders.length > 0 ? qualifiedProviders : requestedProviders;
668
+ const selectedProvider = selectedProviders[0];
669
+ if (selectedProvider !== void 0 && selectedProviders.length > 1) return {
670
+ providerId: selectedProvider.id,
671
+ status: "duplicate-provider"
672
+ };
673
+ const matchingProviders = (selectedProviders.length > 0 ? selectedProviders : config.providers).filter((provider) => provider.models.some((model) => model.id === modelId));
674
+ if (selectedProviders.length === 0) {
675
+ const duplicateProvider = matchingProviders.find((provider) => config.providers.filter((candidate) => candidate.id === provider.id).length > 1);
676
+ if (duplicateProvider !== void 0) return {
677
+ providerId: duplicateProvider.id,
678
+ status: "duplicate-provider"
679
+ };
680
+ }
681
+ const candidates = matchingProviders.flatMap((provider) => {
682
+ const model = findExactModel(provider, modelId);
683
+ return model === void 0 ? [] : [{
684
+ model,
685
+ provider
686
+ }];
687
+ });
688
+ if (candidates.length === 0) return {
689
+ modelId,
690
+ provider: selectedProviders[0],
691
+ status: "missing"
692
+ };
693
+ if (candidates.length > 1) return {
694
+ candidates: candidates.map((candidate) => ({
695
+ modelId: candidate.model.id,
696
+ providerId: candidate.provider.id
697
+ })),
698
+ status: "ambiguous"
699
+ };
700
+ return {
701
+ ...candidates[0],
702
+ status: "found"
703
+ };
704
+ }
705
+ function findExactModel(provider, modelId) {
706
+ const matches = provider.models.filter((model) => model.id === modelId);
707
+ return matches.find((model) => model.aliases?.includes("default")) ?? matches[0];
708
+ }
709
+ //#endregion
710
+ //#region src/cli/commands/config/models/rm.ts
711
+ const rm$1 = defineCommand({
712
+ action: runRemoveModelCommand,
713
+ arguments: "<model-id>",
714
+ description: "Remove a configured model",
715
+ exactArguments: true,
716
+ name: "rm",
717
+ options: [{
718
+ description: "Provider id",
719
+ flags: "--provider <id>"
720
+ }, {
721
+ description: "Read and write project-local config",
722
+ flags: "--local"
723
+ }]
724
+ });
725
+ /**
726
+ * Removes an exact model ID from the explicitly selected setup-config scope.
727
+ *
728
+ * Triggering workflow:
729
+ *
730
+ * {@link registerCommandTree}
731
+ * -> `config models rm`
732
+ * -> {@link runRemoveModelCommand}
733
+ *
734
+ * Upstream:
735
+ * - {@link dispatchCommand}
736
+ *
737
+ * Downstream:
738
+ * - {@link resolveExactModelTarget}
739
+ * - {@link removeProviderModels}
740
+ * - {@link writeSetupConfig}
741
+ */
742
+ async function runRemoveModelCommand(context, request, options) {
743
+ if (Array.isArray(options.provider)) {
744
+ context.io.stderr.write("config models rm accepts --provider only once.\n");
745
+ return 2;
746
+ }
747
+ const { config, path, scope } = await loadScopedSetupConfig(context.io, options.local);
748
+ const target = resolveExactModelTarget(config, request, options.provider);
749
+ if (target.status === "conflict") {
750
+ context.io.stderr.write(`model provider "${escapeLineValue(target.qualifiedProviderId)}" conflicts with --provider "${escapeLineValue(target.requestedProviderId)}".\n`);
751
+ return 2;
752
+ }
753
+ if (target.status === "unknown-provider") {
754
+ context.io.stderr.write(formatUnknownProvider(target.providerId, scope));
755
+ return 2;
756
+ }
757
+ if (target.status === "ambiguous") {
758
+ context.io.stderr.write([
759
+ `ambiguous model id "${escapeLineValue(request)}".`,
760
+ "specify <provider>/<model-id> or pass --provider <provider-id>:",
761
+ ...target.candidates.map((candidate) => ` ${escapeLineValue(candidate.providerId)}/${escapeLineValue(candidate.modelId)}`),
762
+ ""
763
+ ].join("\n"));
764
+ return 2;
765
+ }
766
+ if (target.status === "duplicate-provider") {
767
+ context.io.stderr.write([
768
+ `provider "${escapeLineValue(target.providerId)}" is configured more than once.`,
769
+ "remove duplicate provider definitions from the setup configuration.",
770
+ ""
771
+ ].join("\n"));
772
+ return 2;
773
+ }
774
+ if (target.status === "missing") return 0;
775
+ if ((target.model.aliases ?? []).includes("default")) {
776
+ context.io.stderr.write(`cannot remove default model "${escapeLineValue(target.provider.id)}/${escapeLineValue(target.model.id)}". select another default first.\n`);
777
+ return 2;
778
+ }
779
+ await writeSetupConfig(path, removeProviderModels(config, target.provider.id, /* @__PURE__ */ new Set([target.model.id])));
780
+ context.io.stdout.write(`removed model: ${escapeLineValue(target.provider.id)}/${escapeLineValue(target.model.id)}\nscope: ${scope}\n`);
781
+ return 0;
782
+ }
783
+ //#endregion
784
+ //#region src/cli/commands/config/models/models.ts
785
+ const models = defineCommand({
786
+ children: [
787
+ probe$1,
788
+ prune$1,
789
+ ls$1,
790
+ defineCommand({
791
+ async action(context, model) {
792
+ const candidates = findModels(await loadMergedSetupConfig(context.io), model);
793
+ if (candidates.length === 0) {
794
+ context.io.stderr.write(`unknown model "${model}".\n`);
795
+ return 2;
796
+ }
797
+ if (candidates.length > 1) {
798
+ context.io.stderr.write(formatAmbiguousModels(model, candidates));
799
+ return 2;
800
+ }
801
+ context.io.stdout.write(formatModelShow(candidates[0]));
802
+ return 0;
803
+ },
804
+ arguments: "<model>",
805
+ description: "Show configured model",
806
+ name: "show"
807
+ }),
808
+ rm$1
809
+ ],
810
+ description: "Manage configured models",
811
+ examples: [["# Remove an exact model from one provider", "alint config models rm qwen --provider ollama"].join("\n"), ["# Destructively remove models no longer reported by one provider", "alint config models prune --provider ollama -N --yes"].join("\n")],
812
+ help: [
813
+ "Inspect, list, and probe model entries from alint setup configuration. Remove exact entries when they are no longer needed.",
814
+ "Model entries describe the provider/model ids alint can use for model-backed rules. Probe endpoints before saving them when you want to verify what model ids a provider exposes.",
815
+ "Use a provider-qualified model such as ollama/qwen or pass --provider ollama when the same model id is configured for multiple providers.",
816
+ "Prune is destructive: it removes configured models absent from the provider response. Non-interactive pruning requires -N --yes.",
817
+ "Writes use global scope by default. Pass --local to select the current project's setup configuration."
818
+ ].join("\n\n"),
819
+ name: "models"
820
+ });
821
+ //#endregion
822
+ //#region src/cli/commands/config/providers/ls.ts
823
+ const ls = defineCommand({
824
+ async action(context) {
825
+ context.io.stdout.write(formatProviderList(await loadMergedSetupConfig(context.io)));
826
+ return 0;
827
+ },
828
+ alias: ["ls"],
829
+ description: "List configured providers",
830
+ name: "list"
831
+ });
832
+ //#endregion
833
+ //#region src/cli/commands/config/providers/probe.ts
834
+ const probe = defineCommand({
835
+ async action(context, options) {
836
+ if (!options.endpoint) {
837
+ context.io.stderr.write("config providers probe requires --endpoint.\n");
838
+ return 2;
839
+ }
840
+ try {
841
+ const models = await probeModels(options.endpoint, providerHeadersFromOptions(options));
842
+ context.io.stdout.write(`endpoint: ${options.endpoint}\nmodels: ${models.length}\n`);
843
+ return 0;
844
+ } catch (error) {
845
+ context.io.stderr.write(`failed to probe provider: ${errorMessageFrom(error) ?? String(error)}\n`);
846
+ return 2;
847
+ }
848
+ },
849
+ description: "Probe provider reachability",
850
+ name: "probe",
851
+ options: [{
852
+ description: "Provider endpoint",
853
+ flags: "--endpoint <url>"
854
+ }, {
855
+ description: "Provider header",
856
+ flags: "--provider-header <Key=Value>"
857
+ }]
858
+ });
859
+ //#endregion
860
+ //#region src/cli/commands/config/providers/set.ts
861
+ const set = defineCommand({
862
+ action: runSetProviderCommand,
863
+ arguments: "<key> <value>",
864
+ description: "Set a provider configuration field",
865
+ exactArguments: true,
866
+ name: "set",
867
+ options: [{
868
+ description: "Provider id",
869
+ flags: "--provider <id>"
870
+ }, {
871
+ description: "Read and write project-local config",
872
+ flags: "--local"
873
+ }]
874
+ });
875
+ /**
876
+ * Sets one provider field in the explicitly selected setup-config scope.
877
+ *
878
+ * Triggering workflow:
879
+ *
880
+ * {@link registerCommandTree}
881
+ * -> `config providers set`
882
+ * -> {@link runSetProviderCommand}
883
+ *
884
+ * Upstream:
885
+ * - {@link dispatchCommand}
886
+ *
887
+ * Downstream:
888
+ * - {@link setProviderEndpoint}
889
+ * - {@link setProviderHeader}
890
+ * - {@link writeSetupConfig}
891
+ */
892
+ async function runSetProviderCommand(context, key, value, options) {
893
+ const providerId = scalarProvider$1(options.provider);
894
+ if (Array.isArray(options.provider)) {
895
+ context.io.stderr.write("config providers set accepts --provider only once.\n");
896
+ return 2;
897
+ }
898
+ if (providerId === void 0) {
899
+ context.io.stderr.write("config providers set requires --provider.\n");
900
+ return 2;
901
+ }
902
+ const { config, path, scope } = await loadScopedSetupConfig(context.io, options.local);
903
+ if (!config.providers.some((provider) => provider.id === providerId)) {
904
+ context.io.stderr.write(formatUnknownProvider(providerId, scope));
905
+ return 2;
906
+ }
907
+ let nextConfig;
908
+ if (key === "endpoint") nextConfig = setProviderEndpoint(config, providerId, value);
909
+ else if (key.startsWith("headers.") && key.length > 8) {
910
+ const headerName = key.slice(8);
911
+ if (!isValidProviderHeaderName(headerName)) {
912
+ context.io.stderr.write("invalid provider header name. expected an HTTP field-name token.\n");
913
+ return 2;
914
+ }
915
+ nextConfig = setProviderHeader(config, providerId, headerName, value);
916
+ } else {
917
+ context.io.stderr.write(`unsupported provider key "${escapeLineValue(key)}". expected endpoint or headers.<name>.\n`);
918
+ return 2;
919
+ }
920
+ await writeSetupConfig(path, nextConfig);
921
+ context.io.stdout.write(`provider: ${escapeLineValue(providerId)}\nkey: ${escapeLineValue(key)}\nscope: ${scope}\n`);
922
+ return 0;
923
+ }
924
+ function scalarProvider$1(provider) {
925
+ return Array.isArray(provider) ? void 0 : provider;
926
+ }
927
+ //#endregion
928
+ //#region src/cli/commands/config/providers/show.ts
929
+ const show = defineCommand({
930
+ async action(context, providerId) {
931
+ const provider = (await loadMergedSetupConfig(context.io)).providers.find((item) => item.id === providerId);
932
+ if (provider === void 0) {
933
+ context.io.stderr.write(`unknown provider "${providerId}".\n`);
934
+ return 2;
935
+ }
936
+ context.io.stdout.write(formatProviderShow(provider));
937
+ return 0;
938
+ },
939
+ arguments: "<provider>",
940
+ description: "Show configured provider",
941
+ name: "show"
942
+ });
943
+ //#endregion
944
+ //#region src/cli/commands/config/providers/unset.ts
945
+ const unset = defineCommand({
946
+ action: runUnsetProviderCommand,
947
+ arguments: "<key>",
948
+ description: "Unset a provider configuration field",
949
+ exactArguments: true,
950
+ name: "unset",
951
+ options: [{
952
+ description: "Provider id",
953
+ flags: "--provider <id>"
954
+ }, {
955
+ description: "Read and write project-local config",
956
+ flags: "--local"
957
+ }]
958
+ });
959
+ /**
960
+ * Unsets one provider header in the explicitly selected setup-config scope.
961
+ *
962
+ * Triggering workflow:
963
+ *
964
+ * {@link registerCommandTree}
965
+ * -> `config providers unset`
966
+ * -> {@link runUnsetProviderCommand}
967
+ *
968
+ * Upstream:
969
+ * - {@link dispatchCommand}
970
+ *
971
+ * Downstream:
972
+ * - {@link unsetProviderHeader}
973
+ * - {@link writeSetupConfig}
974
+ */
975
+ async function runUnsetProviderCommand(context, key, options) {
976
+ const providerId = scalarProvider(options.provider);
977
+ if (Array.isArray(options.provider)) {
978
+ context.io.stderr.write("config providers unset accepts --provider only once.\n");
979
+ return 2;
980
+ }
981
+ if (providerId === void 0) {
982
+ context.io.stderr.write("config providers unset requires --provider.\n");
983
+ return 2;
984
+ }
985
+ const { config, path, scope } = await loadScopedSetupConfig(context.io, options.local);
986
+ if (!config.providers.some((provider) => provider.id === providerId)) {
987
+ context.io.stderr.write(formatUnknownProvider(providerId, scope));
988
+ return 2;
989
+ }
990
+ if (key === "endpoint") {
991
+ context.io.stderr.write("provider endpoint cannot be unset.\n");
992
+ return 2;
993
+ }
994
+ if (!key.startsWith("headers.") || key.length === 8) {
995
+ context.io.stderr.write(`unsupported provider key "${escapeLineValue(key)}". expected headers.<name>.\n`);
996
+ return 2;
997
+ }
998
+ const headerName = key.slice(8);
999
+ if (!isValidProviderHeaderName(headerName)) {
1000
+ context.io.stderr.write("invalid provider header name. expected an HTTP field-name token.\n");
1001
+ return 2;
1002
+ }
1003
+ await writeSetupConfig(path, unsetProviderHeader(config, providerId, headerName));
1004
+ context.io.stdout.write(`provider: ${escapeLineValue(providerId)}\nkey: ${escapeLineValue(key)}\nscope: ${scope}\n`);
1005
+ return 0;
1006
+ }
1007
+ function scalarProvider(provider) {
1008
+ return Array.isArray(provider) ? void 0 : provider;
1009
+ }
1010
+ //#endregion
1011
+ //#region src/cli/tui/provider-editor/model-selection.ts
1012
+ function applyDefaultAlias(config, target) {
1013
+ return {
1014
+ ...config,
1015
+ providers: config.providers.map((provider) => ({
1016
+ ...provider,
1017
+ headers: provider.headers === void 0 ? void 0 : { ...provider.headers },
1018
+ models: provider.models.map((model) => {
1019
+ const aliasesWithoutDefault = (model.aliases ?? []).filter((alias) => alias !== "default");
1020
+ const aliases = provider.id === target.providerId && model.id === target.modelId ? [...aliasesWithoutDefault, "default"] : aliasesWithoutDefault;
1021
+ return {
1022
+ ...cloneModel(model),
1023
+ aliases: aliases.length > 0 ? aliases : void 0
1024
+ };
1025
+ })
1026
+ }))
1027
+ };
1028
+ }
1029
+ function applyHeaderSelection(existingHeaders, retainedNames, replacements) {
1030
+ const selectedNames = new Set(retainedNames.map(normalizeHeaderName));
1031
+ const headersByName = /* @__PURE__ */ new Map();
1032
+ for (const [name, value] of Object.entries(existingHeaders)) {
1033
+ const normalizedName = normalizeHeaderName(name);
1034
+ if (selectedNames.has(normalizedName)) headersByName.set(normalizedName, [name, value]);
1035
+ }
1036
+ for (const [name, value] of Object.entries(replacements)) headersByName.set(normalizeHeaderName(name), [name, value]);
1037
+ const headers = Object.fromEntries(headersByName.values());
1038
+ return Object.keys(headers).length > 0 ? headers : void 0;
1039
+ }
1040
+ function createDefaultModelCandidates(config, newProviderId, newModelIds) {
1041
+ const candidates = [];
1042
+ const seen = /* @__PURE__ */ new Set();
1043
+ const newModelIdSet = new Set(newModelIds);
1044
+ const allModels = config.providers.flatMap((provider) => provider.models.map((model) => ({
1045
+ isCurrentDefault: (model.aliases ?? []).includes("default"),
1046
+ isNew: provider.id === newProviderId && newModelIdSet.has(model.id),
1047
+ model,
1048
+ provider,
1049
+ value: createDefaultModelCandidateValue(provider.id, model.id)
1050
+ })));
1051
+ const addCandidate = (candidate) => {
1052
+ if (candidate === void 0 || seen.has(candidate.value)) return;
1053
+ seen.add(candidate.value);
1054
+ candidates.push({
1055
+ isCurrentDefault: candidate.isCurrentDefault,
1056
+ isNew: candidate.isNew,
1057
+ label: `${candidate.provider.id} / ${candidate.model.id}`,
1058
+ modelId: candidate.model.id,
1059
+ providerId: candidate.provider.id,
1060
+ value: candidate.value
1061
+ });
1062
+ };
1063
+ addCandidate(allModels.find((candidate) => candidate.isCurrentDefault));
1064
+ for (const modelId of newModelIds) addCandidate(allModels.find((candidate) => candidate.provider.id === newProviderId && candidate.model.id === modelId));
1065
+ for (const candidate of allModels) addCandidate(candidate);
1066
+ return candidates;
1067
+ }
1068
+ function createModelOptions(provider, discoveredIds) {
1069
+ const discovered = new Set(discoveredIds);
1070
+ const existing = firstModelsById(provider);
1071
+ const optionIds = new Set(existing.keys());
1072
+ const options = [...existing.values()].map((model) => ({
1073
+ hint: discovered.has(model.id) ? void 0 : "not reported by provider",
1074
+ label: model.id,
1075
+ value: model.id
1076
+ }));
1077
+ for (const modelId of discoveredIds) {
1078
+ if (optionIds.has(modelId)) continue;
1079
+ optionIds.add(modelId);
1080
+ options.push({
1081
+ hint: "new",
1082
+ label: modelId,
1083
+ value: modelId
1084
+ });
1085
+ }
1086
+ return options;
1087
+ }
1088
+ function modelsFromSelection(provider, selectedIds) {
1089
+ const existing = firstModelsById(provider);
1090
+ const models = [];
1091
+ const selected = /* @__PURE__ */ new Set();
1092
+ for (const modelId of selectedIds) {
1093
+ if (selected.has(modelId)) continue;
1094
+ selected.add(modelId);
1095
+ const model = existing.get(modelId);
1096
+ models.push(model === void 0 ? {
1097
+ id: modelId,
1098
+ name: modelId
1099
+ } : cloneModel(model));
1100
+ }
1101
+ return models;
1102
+ }
1103
+ function normalizeHeaderName(name) {
1104
+ return name.toLowerCase();
1105
+ }
1106
+ function cloneModel(model) {
1107
+ return {
1108
+ ...model,
1109
+ aliases: model.aliases === void 0 ? void 0 : [...model.aliases],
1110
+ capabilities: model.capabilities === void 0 ? void 0 : [...model.capabilities],
1111
+ defaultParams: model.defaultParams === void 0 ? void 0 : { ...model.defaultParams }
1112
+ };
1113
+ }
1114
+ function createDefaultModelCandidateValue(providerId, modelId) {
1115
+ return `${providerId}\u0000${modelId}`;
1116
+ }
1117
+ function firstModelsById(provider) {
1118
+ const models = /* @__PURE__ */ new Map();
1119
+ for (const model of provider?.models ?? []) if (!models.has(model.id)) models.set(model.id, model);
1120
+ return models;
1121
+ }
1122
+ //#endregion
1123
+ //#region src/cli/tui/provider-editor/prompts.ts
1124
+ const backValue$1 = "__alint_back__";
1125
+ /**
1126
+ * Adapts Clack prompt results into the provider editor's reversible actions.
1127
+ *
1128
+ * Triggering workflow:
1129
+ *
1130
+ * {@link runProviderEditor}
1131
+ * -> {@link createProviderEditorPrompts}
1132
+ * -> `provider-editor.prompt`
1133
+ * -> {@link ProviderEditorPromptPort}
1134
+ *
1135
+ * Upstream:
1136
+ * - {@link runProviderEditor}
1137
+ *
1138
+ * Downstream:
1139
+ * - Clack terminal prompts and {@link probeModels}
1140
+ *
1141
+ * Cancellation and Back use tagged results, so submitted text cannot collide
1142
+ * with control values. Probe failures render in the spinner and return no models.
1143
+ */
1144
+ function createProviderEditorPrompts(prompts) {
1145
+ return {
1146
+ async confirm(summary) {
1147
+ return selectionResult(prompts, await prompts.select({
1148
+ message: summary,
1149
+ options: withBackOption([{
1150
+ label: "Yes",
1151
+ value: "yes"
1152
+ }, {
1153
+ label: "No",
1154
+ value: "no"
1155
+ }])
1156
+ }));
1157
+ },
1158
+ async defaultAction(firstModelId) {
1159
+ return selectionResult(prompts, await prompts.select({
1160
+ message: `Add alias "default" to ${firstModelId}?`,
1161
+ options: withBackOption([
1162
+ {
1163
+ label: "Yes",
1164
+ value: "yes"
1165
+ },
1166
+ {
1167
+ label: "No",
1168
+ value: "no"
1169
+ },
1170
+ {
1171
+ label: "Select another",
1172
+ value: "selectAnother"
1173
+ }
1174
+ ])
1175
+ }));
1176
+ },
1177
+ async defaultModel(options) {
1178
+ return selectionResult(prompts, await prompts.select({
1179
+ message: "Select default model",
1180
+ options: withBackOption(options)
1181
+ }));
1182
+ },
1183
+ async endpoint(initialValue) {
1184
+ return textResult(prompts, await prompts.text({
1185
+ initialValue,
1186
+ message: "Provider endpoint (type .. to go back)",
1187
+ placeholder: initialValue ?? "https://example.test/v1",
1188
+ validate: (value) => isBackInput(value ?? "") || (value ?? "").trim().length > 0 ? void 0 : "Provider endpoint is required."
1189
+ }));
1190
+ },
1191
+ async headerInput() {
1192
+ return textResult(prompts, await prompts.text({
1193
+ message: "Headers (leave empty to skip; type .. to go back)",
1194
+ placeholder: "Authorization=Bearer token, X-Test=true",
1195
+ validate: (value) => {
1196
+ if (isBackInput(value ?? "")) return;
1197
+ try {
1198
+ parseHeaderList(splitInput$1(value ?? ""));
1199
+ return;
1200
+ } catch {
1201
+ return "Headers must be comma-separated Key=Value entries.";
1202
+ }
1203
+ }
1204
+ }));
1205
+ },
1206
+ async manualModels() {
1207
+ const value = textResult(prompts, await prompts.text({
1208
+ message: "Models",
1209
+ placeholder: "qwen:8b, qwen:32b; type .. to go back",
1210
+ validate: (value) => isBackInput(value ?? "") || splitInput$1(value ?? "").length > 0 ? void 0 : "At least one model is required."
1211
+ }));
1212
+ return value.status === "submitted" ? submitted(splitInput$1(value.value)) : value;
1213
+ },
1214
+ async models(options, initialValues) {
1215
+ const result = await prompts.multiselect({
1216
+ initialValues,
1217
+ message: "Select models",
1218
+ options: withBackOption(options),
1219
+ required: true
1220
+ });
1221
+ if (prompts.isCancel(result)) return { status: "cancelled" };
1222
+ return result.includes(backValue$1) ? { status: "back" } : submitted(result);
1223
+ },
1224
+ async probe(endpoint, headers) {
1225
+ const spinner = prompts.spinner();
1226
+ spinner.start("Probing models");
1227
+ try {
1228
+ const models = await probeModels(endpoint, headers);
1229
+ spinner.stop(models.length > 0 ? `Found ${models.length} models` : "No models discovered");
1230
+ return models;
1231
+ } catch (error) {
1232
+ spinner.stop(formatProbeModelsFailure(endpoint, error));
1233
+ return [];
1234
+ }
1235
+ },
1236
+ async providerId(initialValue, editable) {
1237
+ if (!editable) return submitted(initialValue);
1238
+ return textResult(prompts, await prompts.text({
1239
+ initialValue,
1240
+ message: "Provider id (type .. to go back)",
1241
+ validate: (value) => isBackInput(value ?? "") || (value ?? "").trim().length > 0 ? void 0 : "Provider id is required."
1242
+ }));
1243
+ },
1244
+ async retainedHeaders(headerNames, initialValues) {
1245
+ if (headerNames.length === 0) return submitted([]);
1246
+ const result = await prompts.multiselect({
1247
+ initialValues,
1248
+ message: "Select existing headers to keep",
1249
+ options: withBackOption(headerNames.map((name) => ({
1250
+ label: name,
1251
+ value: name
1252
+ }))),
1253
+ required: false
1254
+ });
1255
+ if (prompts.isCancel(result)) return { status: "cancelled" };
1256
+ return result.includes(backValue$1) ? { status: "back" } : submitted(result);
1257
+ }
1258
+ };
1259
+ }
1260
+ function formatProbeModelsFailure(endpoint, error) {
1261
+ const hint = endpoint.startsWith("https://localhost:11434") ? " Ollama usually uses http://localhost:11434/v1." : "";
1262
+ return `Could not probe models: ${errorMessageFrom(error)}.${hint}`;
1263
+ }
1264
+ function isBackInput(value) {
1265
+ return value.trim() === "..";
1266
+ }
1267
+ function withBackOption(options) {
1268
+ return [...options, {
1269
+ label: "Back",
1270
+ value: backValue$1
1271
+ }];
1272
+ }
1273
+ function selectionResult(prompts, result) {
1274
+ if (prompts.isCancel(result)) return { status: "cancelled" };
1275
+ return result === backValue$1 ? { status: "back" } : submitted(result);
1276
+ }
1277
+ function splitInput$1(value) {
1278
+ return value.split(",").map((item) => item.trim()).filter(Boolean);
1279
+ }
1280
+ function submitted(value) {
1281
+ return {
1282
+ status: "submitted",
1283
+ value
1284
+ };
1285
+ }
1286
+ function textResult(prompts, result) {
1287
+ if (prompts.isCancel(result)) return { status: "cancelled" };
1288
+ return isBackInput(result) ? { status: "back" } : submitted(result);
1289
+ }
1290
+ //#endregion
1291
+ //#region src/cli/tui/provider-editor/index.ts
1292
+ /**
1293
+ * Runs one provider create/update request as a reversible prompt state machine.
1294
+ *
1295
+ * Triggering workflow:
1296
+ *
1297
+ * {@link runInteractiveSetup}
1298
+ * -> {@link runProviderEditor}
1299
+ * -> `provider-editor.prompt-result`
1300
+ * -> {@link ProviderEditorResult}
1301
+ *
1302
+ * Upstream:
1303
+ * - {@link runInteractiveSetup}
1304
+ *
1305
+ * Downstream:
1306
+ * - {@link createProviderEditorPrompts} and the caller-owned setup-config write
1307
+ *
1308
+ * The editor reads the selected-scope config, changes only its in-memory draft,
1309
+ * and returns without persistence on Back, cancellation, or a negative confirmation.
1310
+ */
1311
+ async function runProviderEditor(input, promptPort) {
1312
+ const prompts = promptPort ?? createProviderEditorPrompts(await import("@clack/prompts"));
1313
+ const existingProvider = input.mode === "update" ? input.existingProvider : void 0;
1314
+ const configuredHeaders = { ...existingProvider?.headers };
1315
+ const initialHeaders = applyHeaderSelection(configuredHeaders, Object.keys(configuredHeaders), {}) ?? {};
1316
+ const draft = {
1317
+ discoveredModels: [],
1318
+ endpoint: existingProvider?.endpoint ?? input.source.defaultEndpoint,
1319
+ headerValues: initialHeaders,
1320
+ providerId: existingProvider?.id,
1321
+ retainedHeaderNames: Object.keys(initialHeaders)
1322
+ };
1323
+ let step = "endpoint";
1324
+ while (true) {
1325
+ if (step === "endpoint") {
1326
+ const endpoint = await prompts.endpoint(draft.endpoint);
1327
+ if (endpoint.status === "cancelled") return { status: "cancelled" };
1328
+ if (endpoint.status === "back") return { status: "back" };
1329
+ draft.endpoint = endpoint.value.trim();
1330
+ step = "providerId";
1331
+ continue;
1332
+ }
1333
+ if (step === "providerId") {
1334
+ const initialProviderId = draft.providerId ?? createProviderId(draft.endpoint ?? "", new Set(input.config.providers.map((provider) => provider.id)));
1335
+ const providerId = await prompts.providerId(initialProviderId, input.mode === "create");
1336
+ if (providerId.status === "cancelled") return { status: "cancelled" };
1337
+ if (providerId.status === "back") {
1338
+ step = "endpoint";
1339
+ continue;
1340
+ }
1341
+ draft.providerId = providerId.value.trim();
1342
+ step = "retainedHeaders";
1343
+ continue;
1344
+ }
1345
+ if (step === "retainedHeaders") {
1346
+ const headerNames = Object.keys(draft.headerValues);
1347
+ const retainedHeaders = await prompts.retainedHeaders(headerNames, draft.retainedHeaderNames.filter((name) => headerNames.includes(name)));
1348
+ if (retainedHeaders.status === "cancelled") return { status: "cancelled" };
1349
+ if (retainedHeaders.status === "back") {
1350
+ step = "providerId";
1351
+ continue;
1352
+ }
1353
+ draft.retainedHeaderNames = retainedHeaders.value;
1354
+ step = "headerInput";
1355
+ continue;
1356
+ }
1357
+ if (step === "headerInput") {
1358
+ const headerInput = await prompts.headerInput();
1359
+ if (headerInput.status === "cancelled") return { status: "cancelled" };
1360
+ if (headerInput.status === "back") {
1361
+ step = "retainedHeaders";
1362
+ continue;
1363
+ }
1364
+ const replacements = parseHeaderList(splitInput(headerInput.value)) ?? {};
1365
+ const retainedLogicalNames = new Set([...draft.retainedHeaderNames, ...Object.keys(replacements)].map(normalizeHeaderName));
1366
+ draft.headerValues = applyHeaderSelection(draft.headerValues, Object.keys(draft.headerValues), replacements) ?? {};
1367
+ draft.retainedHeaderNames = Object.keys(draft.headerValues).filter((name) => retainedLogicalNames.has(normalizeHeaderName(name)));
1368
+ const headers = effectiveHeaders(draft) ?? {};
1369
+ draft.discoveredModels = input.source.probeModels ? await prompts.probe(draft.endpoint ?? "", headers) : [];
1370
+ step = "models";
1371
+ continue;
1372
+ }
1373
+ if (step === "models") {
1374
+ const options = createModelOptions(existingProvider, draft.discoveredModels);
1375
+ const optionIds = options.map((option) => option.value);
1376
+ const initialValues = draft.selectedModelIds === void 0 || draft.modelOptionIds === void 0 ? optionIds : optionIds.filter((modelId) => draft.selectedModelIds?.includes(modelId) === true || !draft.modelOptionIds?.includes(modelId));
1377
+ draft.modelOptionIds = optionIds;
1378
+ const selectedModels = options.length === 0 ? await prompts.manualModels() : await prompts.models(options, initialValues);
1379
+ if (selectedModels.status === "cancelled") return { status: "cancelled" };
1380
+ if (selectedModels.status === "back") {
1381
+ step = "headerInput";
1382
+ continue;
1383
+ }
1384
+ draft.selectedModelIds = selectedModels.value;
1385
+ step = "defaultAction";
1386
+ continue;
1387
+ }
1388
+ if (step === "defaultAction") {
1389
+ const defaultAction = await prompts.defaultAction(draft.selectedModelIds?.[0] ?? "");
1390
+ if (defaultAction.status === "cancelled") return { status: "cancelled" };
1391
+ if (defaultAction.status === "back") {
1392
+ step = "models";
1393
+ continue;
1394
+ }
1395
+ draft.defaultAliasTarget = defaultAction.value === "yes" ? {
1396
+ modelId: draft.selectedModelIds?.[0] ?? "",
1397
+ providerId: draft.providerId ?? ""
1398
+ } : void 0;
1399
+ const removesCurrentDefault = existingProvider?.models.some((model) => model.aliases?.includes("default") === true && !draft.selectedModelIds?.includes(model.id)) === true;
1400
+ step = defaultAction.value === "selectAnother" || defaultAction.value === "no" && removesCurrentDefault ? "defaultModel" : "confirm";
1401
+ continue;
1402
+ }
1403
+ const provider = createProvider(input, draft);
1404
+ if (step === "defaultModel") {
1405
+ const provisionalConfig = input.mode === "update" ? replaceSetupProvider(input.config, provider) : mergeSetupConfigs(input.config, {
1406
+ providers: [provider],
1407
+ version: 1
1408
+ });
1409
+ const existingModelIds = new Set(existingProvider?.models.map((model) => model.id) ?? []);
1410
+ const addedModelIds = provider.models.map((model) => model.id).filter((modelId) => !existingModelIds.has(modelId));
1411
+ const candidates = createDefaultModelCandidates(provisionalConfig, provider.id, addedModelIds);
1412
+ const selectedDefault = await prompts.defaultModel(candidates.map((candidate) => ({
1413
+ hint: candidate.isCurrentDefault ? "current default" : candidate.isNew ? "new" : void 0,
1414
+ label: candidate.label,
1415
+ value: candidate.value
1416
+ })));
1417
+ if (selectedDefault.status === "cancelled") return { status: "cancelled" };
1418
+ if (selectedDefault.status === "back") {
1419
+ step = "defaultAction";
1420
+ continue;
1421
+ }
1422
+ const candidate = candidates.find((item) => item.value === selectedDefault.value);
1423
+ if (candidate === void 0) return { status: "cancelled" };
1424
+ draft.defaultAliasTarget = {
1425
+ modelId: candidate.modelId,
1426
+ providerId: candidate.providerId
1427
+ };
1428
+ step = "confirm";
1429
+ continue;
1430
+ }
1431
+ const confirmed = await prompts.confirm(createSummary(input, provider, draft.defaultAliasTarget));
1432
+ if (confirmed.status === "cancelled" || confirmed.status === "submitted" && confirmed.value === "no") return { status: "cancelled" };
1433
+ if (confirmed.status === "back") {
1434
+ step = "defaultAction";
1435
+ continue;
1436
+ }
1437
+ return {
1438
+ defaultAliasTarget: draft.defaultAliasTarget,
1439
+ provider,
1440
+ status: "confirmed"
1441
+ };
1442
+ }
1443
+ }
1444
+ function createProvider(input, draft) {
1445
+ return {
1446
+ ...input.existingProvider,
1447
+ endpoint: draft.endpoint ?? "",
1448
+ headers: effectiveHeaders(draft),
1449
+ id: draft.providerId ?? "",
1450
+ models: modelsFromSelection(input.existingProvider, draft.selectedModelIds ?? []),
1451
+ type: "openai-compatible"
1452
+ };
1453
+ }
1454
+ function createSummary(input, provider, defaultAliasTarget) {
1455
+ const existingModelIds = new Set(input.existingProvider?.models.map((model) => model.id) ?? []);
1456
+ const nextModelIds = new Set(provider.models.map((model) => model.id));
1457
+ const additions = [...nextModelIds].filter((modelId) => !existingModelIds.has(modelId));
1458
+ const removals = [...existingModelIds].filter((modelId) => !nextModelIds.has(modelId));
1459
+ const defaultChange = defaultAliasTarget === void 0 ? "unchanged" : `${defaultAliasTarget.providerId}/${defaultAliasTarget.modelId}`;
1460
+ return [
1461
+ `${input.mode === "create" ? "Create" : "Update"} provider?`,
1462
+ `Provider: ${escapeLineValue(provider.id)}`,
1463
+ `Endpoint: ${escapeLineValue(provider.endpoint)}`,
1464
+ `Headers: ${formatSummaryValues(Object.keys(provider.headers ?? {}))}`,
1465
+ `Added models: ${formatSummaryValues(additions)}`,
1466
+ `Removed models: ${formatSummaryValues(removals)}`,
1467
+ `Default: ${defaultAliasTarget === void 0 ? defaultChange : escapeLineValue(defaultChange)}`
1468
+ ].join("\n");
1469
+ }
1470
+ function effectiveHeaders(draft) {
1471
+ return applyHeaderSelection(draft.headerValues, draft.retainedHeaderNames, {});
1472
+ }
1473
+ function formatSummaryValues(values) {
1474
+ return values.length > 0 ? values.map(escapeLineValue).join(", ") : "(none)";
1475
+ }
1476
+ function splitInput(value) {
1477
+ return value.split(",").map((item) => item.trim()).filter(Boolean);
1478
+ }
1479
+ //#endregion
1480
+ //#region src/cli/commands/config/providers/update.ts
1481
+ const update = defineCommand({
1482
+ action: runUpdateProviderCommand,
1483
+ description: "Update a configured provider",
1484
+ name: "update",
1485
+ options: [
1486
+ {
1487
+ description: "Provider id",
1488
+ flags: "--provider <id>"
1489
+ },
1490
+ {
1491
+ description: "Read and write project-local config",
1492
+ flags: "--local"
1493
+ },
1494
+ {
1495
+ description: "Disable interactive editing",
1496
+ flags: "-N, --no-interactive"
1497
+ },
1498
+ {
1499
+ description: "Replace provider endpoint before probing",
1500
+ flags: "--provider-endpoint <endpoint>"
1501
+ },
1502
+ {
1503
+ description: "Add or replace provider header",
1504
+ flags: "--provider-header <Key=Value>"
1505
+ },
1506
+ {
1507
+ description: "Add a model in addition to probed models",
1508
+ flags: "--provider-model <model>"
1509
+ }
1510
+ ]
1511
+ });
1512
+ function providerUpdateSource(endpoint) {
1513
+ const custom = findProviderSetupSource("custom");
1514
+ try {
1515
+ return findProviderSetupSourceByEndpoint(endpoint) ?? custom;
1516
+ } catch {
1517
+ return custom;
1518
+ }
1519
+ }
1520
+ function mergeProviderHeaders(existing, replacements) {
1521
+ const headers = { ...existing };
1522
+ for (const [replacementName, replacementValue] of Object.entries(replacements ?? {})) {
1523
+ for (const existingName of Object.keys(headers)) if (existingName.toLowerCase() === replacementName.toLowerCase()) delete headers[existingName];
1524
+ headers[replacementName] = replacementValue;
1525
+ }
1526
+ return Object.keys(headers).length > 0 ? headers : void 0;
1527
+ }
1528
+ async function runNonInteractiveUpdate(context, options, providerEndpoint, existingProvider, config, path) {
1529
+ let parsedHeaders;
1530
+ try {
1531
+ parsedHeaders = parseHeaderList(toArray$1(options.providerHeader));
1532
+ } catch (error) {
1533
+ context.io.stderr.write(`${errorMessageFrom(error) ?? "Invalid provider header."}\n`);
1534
+ return 2;
1535
+ }
1536
+ const draft = {
1537
+ ...existingProvider,
1538
+ endpoint: providerEndpoint ?? existingProvider.endpoint,
1539
+ headers: mergeProviderHeaders(existingProvider.headers, parsedHeaders)
1540
+ };
1541
+ let remoteModelIds;
1542
+ try {
1543
+ remoteModelIds = await probeModels(draft.endpoint, draft.headers);
1544
+ } catch (error) {
1545
+ context.io.stderr.write(`failed to probe provider: ${errorMessageFrom(error) ?? "Unknown error."}\n`);
1546
+ return 2;
1547
+ }
1548
+ await writeSetupConfig(path, addProviderModels(addProviderModels(replaceSetupProvider(config, draft), draft.id, remoteModelIds), draft.id, toArray$1(options.providerModel)));
1549
+ return 0;
1550
+ }
1551
+ /**
1552
+ * Updates one provider in the explicitly selected setup-config scope.
1553
+ *
1554
+ * Triggering workflow:
1555
+ *
1556
+ * {@link registerCommandTree}
1557
+ * -> `config providers update`
1558
+ * -> {@link runUpdateProviderCommand}
1559
+ *
1560
+ * Upstream:
1561
+ * - {@link dispatchCommand}
1562
+ *
1563
+ * Downstream:
1564
+ * - {@link runProviderEditor}
1565
+ * - {@link probeModels}
1566
+ * - {@link writeSetupConfig}
1567
+ */
1568
+ async function runUpdateProviderCommand(context, options) {
1569
+ let providerEndpoint;
1570
+ let providerId;
1571
+ try {
1572
+ providerId = scalarOption(options.provider, "--provider");
1573
+ providerEndpoint = scalarOption(options.providerEndpoint, "--provider-endpoint");
1574
+ } catch (error) {
1575
+ context.io.stderr.write(`${errorMessageFrom(error) ?? "Invalid provider update option."}\n`);
1576
+ return 2;
1577
+ }
1578
+ if (providerId === void 0) {
1579
+ context.io.stderr.write("config providers update requires --provider.\n");
1580
+ return 2;
1581
+ }
1582
+ const { config, path, scope } = await loadScopedSetupConfig(context.io, options.local);
1583
+ const existingProvider = config.providers.find((provider) => provider.id === providerId);
1584
+ if (existingProvider === void 0) {
1585
+ context.io.stderr.write(formatUnknownProvider(providerId, scope));
1586
+ return 2;
1587
+ }
1588
+ if (context.setupNoInteractive) return runNonInteractiveUpdate(context, options, providerEndpoint, existingProvider, config, path);
1589
+ const result = await runProviderEditor({
1590
+ config,
1591
+ existingProvider,
1592
+ io: context.io,
1593
+ mode: "update",
1594
+ source: providerUpdateSource(existingProvider.endpoint)
1595
+ });
1596
+ if (result.status !== "confirmed") return 0;
1597
+ const replaced = replaceSetupProvider(config, result.provider);
1598
+ await writeSetupConfig(path, result.defaultAliasTarget === void 0 ? replaced : applyDefaultAlias(replaced, result.defaultAliasTarget));
1599
+ return 0;
1600
+ }
1601
+ function scalarOption(value, flag) {
1602
+ if (Array.isArray(value)) throw new TypeError(`config providers update accepts ${flag} only once.`);
1603
+ return value;
400
1604
  }
401
1605
  function toArray$1(value) {
402
1606
  if (value === void 0) return [];
403
- return (Array.isArray(value) ? value : [value]).filter((item) => typeof item === "string");
1607
+ return Array.isArray(value) ? value : [value];
404
1608
  }
405
1609
  //#endregion
406
- //#region src/cli/commands/config/models/models.ts
407
- const models = defineCommand({
408
- children: [
409
- defineCommand({
410
- async action(context, options) {
411
- if (!options.endpoint) {
412
- context.io.stderr.write("config models probe requires --endpoint.\n");
413
- return 2;
414
- }
415
- try {
416
- const models = await probeModels(options.endpoint, providerHeadersFromOptions(options));
417
- context.io.stdout.write(`${models.join("\n")}${models.length > 0 ? "\n" : ""}`);
418
- return 0;
419
- } catch (error) {
420
- context.io.stderr.write(`failed to probe models: ${errorMessageFrom(error) ?? String(error)}\n`);
421
- return 2;
422
- }
423
- },
424
- description: "Probe OpenAI-compatible models",
425
- examples: [["# Probe a provider endpoint and print available model ids", "alint config models probe --endpoint https://openrouter.ai/api/v1"].join("\n"), ["# Probe an endpoint that requires an authorization header", "alint config models probe --endpoint https://api.example.com/v1 --provider-header \"Authorization=Bearer $TOKEN\""].join("\n")],
426
- help: ["Probe an OpenAI-compatible models endpoint before saving it in setup config.", "The command calls the provider models endpoint using the supplied endpoint and optional headers, then prints the model ids returned by that provider."].join("\n\n"),
427
- name: "probe",
428
- options: [{
429
- description: "Provider endpoint",
430
- flags: "--endpoint <url>"
431
- }, {
432
- description: "Provider header",
433
- flags: "--provider-header <Key=Value>"
434
- }]
435
- }),
436
- ls$1,
437
- defineCommand({
438
- async action(context, model) {
439
- const candidate = findModel(await loadMergedSetupConfig(context.io), model);
440
- if (candidate === void 0) {
441
- context.io.stderr.write(`unknown model "${model}".\n`);
442
- return 2;
443
- }
444
- context.io.stdout.write(formatModelShow(candidate));
445
- return 0;
446
- },
447
- arguments: "<model>",
448
- description: "Show configured model",
449
- name: "show"
450
- })
451
- ],
452
- description: "Manage configured models",
453
- help: ["Inspect, list, and probe model entries from alint setup configuration.", "Model entries describe the provider/model ids alint can use for model-backed rules. Probe endpoints before saving them when you want to verify what model ids a provider exposes."].join("\n\n"),
454
- name: "models"
455
- });
456
- //#endregion
457
- //#region src/cli/commands/config/providers/ls.ts
458
- const ls = defineCommand({
459
- async action(context) {
460
- context.io.stdout.write(formatProviderList(await loadMergedSetupConfig(context.io)));
461
- return 0;
462
- },
463
- alias: ["ls"],
464
- description: "List configured providers",
465
- name: "list"
466
- });
467
- //#endregion
468
- //#region src/cli/commands/config/providers/probe.ts
469
- const probe = defineCommand({
470
- async action(context, options) {
471
- if (!options.endpoint) {
472
- context.io.stderr.write("config providers probe requires --endpoint.\n");
473
- return 2;
474
- }
475
- try {
476
- const models = await probeModels(options.endpoint, providerHeadersFromOptions(options));
477
- context.io.stdout.write(`endpoint: ${options.endpoint}\nmodels: ${models.length}\n`);
478
- return 0;
479
- } catch (error) {
480
- context.io.stderr.write(`failed to probe provider: ${errorMessageFrom(error) ?? String(error)}\n`);
481
- return 2;
482
- }
483
- },
484
- description: "Probe provider reachability",
485
- name: "probe",
486
- options: [{
487
- description: "Provider endpoint",
488
- flags: "--endpoint <url>"
489
- }, {
490
- description: "Provider header",
491
- flags: "--provider-header <Key=Value>"
492
- }]
493
- });
494
- //#endregion
495
1610
  //#region src/cli/commands/config/config.ts
496
1611
  const config = defineCommand({
497
1612
  children: [
@@ -500,23 +1615,25 @@ const config = defineCommand({
500
1615
  defineCommand({
501
1616
  children: [
502
1617
  ls,
503
- defineCommand({
504
- async action(context, providerId) {
505
- const provider = (await loadMergedSetupConfig(context.io)).providers.find((item) => item.id === providerId);
506
- if (provider === void 0) {
507
- context.io.stderr.write(`unknown provider "${providerId}".\n`);
508
- return 2;
509
- }
510
- context.io.stdout.write(formatProviderShow(provider));
511
- return 0;
512
- },
513
- arguments: "<provider>",
514
- description: "Show configured provider",
515
- name: "show"
516
- }),
517
- probe
1618
+ show,
1619
+ probe,
1620
+ update,
1621
+ set,
1622
+ unset
518
1623
  ],
519
1624
  description: "Manage configured providers",
1625
+ examples: [["# Probe and add newly reported models", "alint config providers update --provider openrouter"].join("\n"), [
1626
+ "# Set or unset one provider header",
1627
+ "alint config providers set --provider openrouter headers.Authorization \"Bearer $TOKEN\"",
1628
+ "alint config providers unset --provider openrouter headers.Authorization"
1629
+ ].join("\n")],
1630
+ help: [
1631
+ "Inspect and edit providers in alint setup configuration.",
1632
+ "Provider update is additive by default. It does not automatically remove a configured model merely because the remote provider no longer reports it.",
1633
+ "Deselecting a configured model in the interactive TUI removes it when you confirm the update. Use the destructive config models prune command to remove configured models absent from provider responses.",
1634
+ "Writes use global scope by default. Pass --local to select the current project's setup configuration.",
1635
+ "Header values passed as command arguments may remain in shell history. Use environment-variable placeholders such as $TOKEN instead of literal secrets."
1636
+ ].join("\n\n"),
520
1637
  name: "providers"
521
1638
  })
522
1639
  ],
@@ -538,9 +1655,21 @@ const config = defineCommand({
538
1655
  "# Probe an OpenAI-compatible endpoint before saving it",
539
1656
  "alint config providers probe --endpoint https://openrouter.ai/api/v1",
540
1657
  "alint config models probe --endpoint https://openrouter.ai/api/v1"
1658
+ ].join("\n"),
1659
+ ["# Probe and add newly reported provider models", "alint config providers update --provider openrouter"].join("\n"),
1660
+ [
1661
+ "# Remove one model or prune models missing from a provider",
1662
+ "alint config models rm qwen --provider ollama",
1663
+ "alint config models prune --provider ollama -N --yes"
541
1664
  ].join("\n")
542
1665
  ],
543
- help: ["Inspect and update alint setup/configuration state.", "Use these commands to understand the effective config for a file, inspect saved provider/model setup, and probe OpenAI-compatible endpoints before using them in model-backed rules."].join("\n\n"),
1666
+ help: [
1667
+ "Inspect and update alint setup/configuration state.",
1668
+ "Use these commands to understand the effective config for a file, inspect saved provider/model setup, and probe OpenAI-compatible endpoints before using them in model-backed rules.",
1669
+ "Configuration writes use global scope by default. Pass --local to read and write the current project's .alint/config.toml instead.",
1670
+ "Provider updates are additive by default. They do not automatically remove configured models merely because the remote provider no longer reports them.",
1671
+ "Interactive TUI deselection can remove a configured model. Model pruning is destructive and requires confirmation."
1672
+ ].join("\n\n"),
544
1673
  name: "config"
545
1674
  });
546
1675
  //#endregion
@@ -686,9 +1815,9 @@ function formatMiniBar(options) {
686
1815
  if (index <= frontierIndex) return "█";
687
1816
  return "░";
688
1817
  }
689
- if (index < phase - 1) return "█";
690
- if (index === phase - 1) return "░";
1818
+ if (index < phase) return "█";
691
1819
  if (index === phase) return "▓";
1820
+ if (index === phase + 1 && index <= frontierIndex) return "░";
692
1821
  if (index <= frontierIndex) return "█";
693
1822
  return "░";
694
1823
  }).join("")}]`;
@@ -762,12 +1891,28 @@ function createSummaryProgressReporter(options) {
762
1891
  state.cachedTokens = payload.usage.cached?.totalTokens ?? 0;
763
1892
  state.runStartedAt = payload.startedAt ?? state.runStartedAt;
764
1893
  },
1894
+ /**
1895
+ * Resets summary state when the core run starts.
1896
+ *
1897
+ * Triggering workflow:
1898
+ *
1899
+ * `@alint-js/core run`
1900
+ * -> `ProgressReporter.onRunStart`
1901
+ * -> `createRenderingProgressReporter(...).onRunStart`
1902
+ * -> `SummaryProgressReporter.onRunStart` (this handler)
1903
+ *
1904
+ * Upstream:
1905
+ * - `createRenderingProgressReporter(...).onRunStart`
1906
+ *
1907
+ * Downstream:
1908
+ * - Resets `state.animationTick` and run data consumed by {@link createRows}.
1909
+ */
765
1910
  onRunStart: ({ jobsTotal, startedAt }) => {
766
1911
  state.diagnostics = [];
767
1912
  state.execution = createCounts(jobsTotal);
768
1913
  state.jobs.clear();
769
1914
  state.runStartedAt = startedAt ?? now();
770
- state.spinnerIndex = 0;
1915
+ state.animationTick = 0;
771
1916
  state.cachedTokens = 0;
772
1917
  state.jobTokens.clear();
773
1918
  state.totalTokens = 0;
@@ -779,8 +1924,23 @@ function createSummaryProgressReporter(options) {
779
1924
  else state.totalTokens += record.totalTokens;
780
1925
  }
781
1926
  },
1927
+ /**
1928
+ * Advances the summary animation on each renderer interval.
1929
+ *
1930
+ * Triggering workflow:
1931
+ *
1932
+ * `createTtyProgressRenderer`
1933
+ * -> `createCliProgressReporter` `createInterval` callback
1934
+ * -> `SummaryProgressReporter.tick` (this handler)
1935
+ *
1936
+ * Upstream:
1937
+ * - `createCliProgressReporter` `createInterval` callback registered by `createTtyProgressRenderer`
1938
+ *
1939
+ * Downstream:
1940
+ * - Increments `state.animationTick`, consumed by {@link createRows}, {@link formatSpinnerFrame}, and {@link formatMiniBarSegment}.
1941
+ */
782
1942
  tick: () => {
783
- state.spinnerIndex = (state.spinnerIndex + 1) % Math.max(options.spinnerFrames.length, 1);
1943
+ state.animationTick += 1;
784
1944
  }
785
1945
  };
786
1946
  }
@@ -823,12 +1983,12 @@ function createCounts(planned = 0) {
823
1983
  }
824
1984
  function createInitialState() {
825
1985
  return {
1986
+ animationTick: 0,
826
1987
  cachedTokens: 0,
827
1988
  diagnostics: [],
828
1989
  execution: createCounts(),
829
1990
  jobs: /* @__PURE__ */ new Map(),
830
1991
  jobTokens: /* @__PURE__ */ new Map(),
831
- spinnerIndex: 0,
832
1992
  totalTokens: 0
833
1993
  };
834
1994
  }
@@ -937,7 +2097,7 @@ function formatDuration(ms) {
937
2097
  function formatFooters(state, options, now) {
938
2098
  const terminal = terminalCount(state.execution);
939
2099
  const elapsed = state.runStartedAt === void 0 ? 0 : now - state.runStartedAt;
940
- const progressBar = formatMiniBarSegment(terminal, state.execution.planned, state.spinnerIndex, options);
2100
+ const progressBar = formatMiniBarSegment(terminal, state.execution.planned, state.animationTick, options);
941
2101
  const eta = estimateEta(elapsed, terminal, state.execution.planned);
942
2102
  const projectedTokens = terminal > 0 && state.execution.planned > 0 ? Math.ceil((state.totalTokens + state.cachedTokens) * state.execution.planned / terminal) : void 0;
943
2103
  return [
@@ -956,15 +2116,18 @@ function formatMiniBarSegment(completed, planned, tick, options) {
956
2116
  })}`;
957
2117
  }
958
2118
  function formatRuleRow(group, state, options) {
959
- const spinner = options.spinnerFrames[state.spinnerIndex] ?? "";
2119
+ const spinner = formatSpinnerFrame(state.animationTick, options.spinnerFrames);
960
2120
  const terminal = group.completed + group.cached + group.failed + group.cancelled + group.skipped;
961
2121
  const percent = group.planned > 0 ? Math.floor(terminal / group.planned * 100) : 0;
962
- const bar = formatMiniBarSegment(terminal, group.planned, state.spinnerIndex, options);
2122
+ const bar = formatMiniBarSegment(terminal, group.planned, state.animationTick, options);
963
2123
  const running = group.jobs.filter((job) => job.state === "running").length;
964
2124
  const failed = group.failed > 0 ? ` ${group.failed} failed` : "";
965
2125
  const eta = estimateEta(group.terminalDurationMs, terminal, group.planned);
966
2126
  return fitRow(`${spinner} ${group.ruleId} ${terminal}/${group.planned} ${percent}%${bar} ${eta} ${running} running${failed}`, options.columns);
967
2127
  }
2128
+ function formatSpinnerFrame(animationTick, spinnerFrames) {
2129
+ return spinnerFrames[animationTick % Math.max(spinnerFrames.length, 1)] ?? "";
2130
+ }
968
2131
  function formatTargetRow(jobState, options, now, last) {
969
2132
  const inputPath = options.cwd ? relative$1(options.cwd, jobState.job.inputPath) || "." : jobState.job.inputPath;
970
2133
  const target = jobState.job.target.name ? `${jobState.job.target.kind} ${jobState.job.target.name}` : jobState.job.target.kind;
@@ -973,7 +2136,7 @@ function formatTargetRow(jobState, options, now, last) {
973
2136
  }
974
2137
  function styleRow(row, state, warnCount, errorCount, options) {
975
2138
  let styledRow = row;
976
- const spinner = options.spinnerFrames[state.spinnerIndex] ?? "";
2139
+ const spinner = formatSpinnerFrame(state.animationTick, options.spinnerFrames);
977
2140
  if (spinner) styledRow = styledRow.replace(spinner, colors$2.cyan(spinner));
978
2141
  return styledRow.replace(`${state.execution.running} concurrency`, colors$2.cyan(`${state.execution.running} concurrency`)).replace(`${state.totalTokens.toLocaleString("en-US")} tokens`, colors$2.cyan(`${state.totalTokens.toLocaleString("en-US")} tokens`)).replace(`(${state.cachedTokens.toLocaleString("en-US")} cached)`, colors$2.dim(`(${state.cachedTokens.toLocaleString("en-US")} cached)`)).replace(` / ${state.execution.cached} cached / `, ` / ${colors$2.dim(`${state.execution.cached} cached`)} / `).replace(`${warnCount} warn`, colors$2.yellow(`${warnCount} warn`)).replace(`${errorCount} error`, (errorCount > 0 ? colors$2.red : colors$2.gray)(`${errorCount} error`)).replace(`${state.execution.failed} failed`, (state.execution.failed > 0 ? colors$2.red : colors$2.gray)(`${state.execution.failed} failed`)).replace(/\[([█▓░]*?)(░+)\]/g, (_, active, pending) => `[${active}${colors$2.gray(pending)}]`).replace(/\//g, colors$2.gray("/"));
979
2142
  }
@@ -1900,6 +3063,9 @@ async function runLintCommand(files, options, io, interceptConsoleOutput) {
1900
3063
  const persistStats = async (runResult) => {
1901
3064
  if (statsTarget && statsCollector) await writeRunStats(statsTarget, statsCollector, runResult, io.cwd);
1902
3065
  };
3066
+ const writeResult = (runResult) => {
3067
+ io.stdout.write(formatDiagnostics(options.format, runResult, { color: io.stdout.isTTY === true }));
3068
+ };
1903
3069
  let result;
1904
3070
  try {
1905
3071
  result = await runAlint({
@@ -1919,11 +3085,13 @@ async function runLintCommand(files, options, io, interceptConsoleOutput) {
1919
3085
  progress?.dispose();
1920
3086
  if (error instanceof AlintRunError) {
1921
3087
  await persistStats(error.result);
3088
+ writeResult(error.result);
1922
3089
  io.stderr.write(formatRunError(error, io.stderr.isTTY === true));
1923
3090
  return 2;
1924
3091
  }
1925
3092
  if (error instanceof AlintRunCancelledError) {
1926
3093
  await persistStats(error.result);
3094
+ writeResult(error.result);
1927
3095
  io.stderr.write(formatCancelledError(error, io.stderr.isTTY === true));
1928
3096
  return 2;
1929
3097
  }
@@ -1932,7 +3100,7 @@ async function runLintCommand(files, options, io, interceptConsoleOutput) {
1932
3100
  restoreProgressConsole?.();
1933
3101
  progress?.dispose();
1934
3102
  await persistStats(result);
1935
- io.stdout.write(formatDiagnostics(options.format, result, { color: io.stdout.isTTY === true }));
3103
+ writeResult(result);
1936
3104
  return result.diagnostics.some((diagnostic) => diagnostic.severity === "error") ? 1 : 0;
1937
3105
  }
1938
3106
  function shouldEnableProgress(options, io) {
@@ -2062,60 +3230,25 @@ const plugin = defineCommand({
2062
3230
  //#region src/cli/commands/setup/interactive.ts
2063
3231
  const nonTtyMessage = "interactive setup requires a TTY. Use -N/--no-interactive with --provider-id and --provider-endpoint.\n";
2064
3232
  const backValue = "__alint_back__";
2065
- function applyDefaultAlias(config, target) {
2066
- return {
2067
- ...config,
2068
- providers: config.providers.map((provider) => ({
2069
- ...provider,
2070
- headers: provider.headers === void 0 ? void 0 : { ...provider.headers },
2071
- models: provider.models.map((model) => {
2072
- const aliasesWithoutDefault = (model.aliases ?? []).filter((alias) => alias !== "default");
2073
- const aliases = provider.id === target.providerId && model.id === target.modelId ? [...aliasesWithoutDefault, "default"] : aliasesWithoutDefault;
2074
- return {
2075
- ...model,
2076
- aliases: aliases.length > 0 ? aliases : void 0,
2077
- capabilities: model.capabilities === void 0 ? void 0 : [...model.capabilities],
2078
- defaultParams: model.defaultParams === void 0 ? void 0 : { ...model.defaultParams }
2079
- };
2080
- })
2081
- }))
2082
- };
2083
- }
2084
- function createDefaultModelCandidates(config, newProviderId, newModelIds) {
2085
- const candidates = [];
2086
- const seen = /* @__PURE__ */ new Set();
2087
- const newModelIdSet = new Set(newModelIds);
2088
- const allModels = config.providers.flatMap((provider) => provider.models.map((model) => ({
2089
- isCurrentDefault: (model.aliases ?? []).includes("default"),
2090
- isNew: provider.id === newProviderId && newModelIdSet.has(model.id),
2091
- model,
2092
- provider,
2093
- value: createDefaultModelCandidateValue(provider.id, model.id)
2094
- })));
2095
- const addCandidate = (candidate) => {
2096
- if (candidate === void 0 || seen.has(candidate.value)) return;
2097
- seen.add(candidate.value);
2098
- candidates.push({
2099
- isCurrentDefault: candidate.isCurrentDefault,
2100
- isNew: candidate.isNew,
2101
- label: `${candidate.provider.id} / ${candidate.model.id}`,
2102
- modelId: candidate.model.id,
2103
- providerId: candidate.provider.id,
2104
- value: candidate.value
2105
- });
2106
- };
2107
- addCandidate(allModels.find((candidate) => candidate.isCurrentDefault));
2108
- for (const modelId of newModelIds) addCandidate(allModels.find((candidate) => candidate.provider.id === newProviderId && candidate.model.id === modelId));
2109
- for (const candidate of allModels) addCandidate(candidate);
2110
- return candidates;
2111
- }
2112
- function formatProbeModelsFailure(endpoint, error) {
2113
- const hint = endpoint.startsWith("https://localhost:11434") ? " Ollama usually uses http://localhost:11434/v1." : "";
2114
- return `Could not probe models: ${errorMessageFrom(error)}.${hint}`;
2115
- }
2116
- function isBackInput(value) {
2117
- return value.trim() === "..";
2118
- }
3233
+ /**
3234
+ * Handles setup-only scope/source navigation and persists a confirmed provider.
3235
+ *
3236
+ * Triggering workflow:
3237
+ *
3238
+ * {@link setup}
3239
+ * -> {@link runInteractiveSetup}
3240
+ * -> `provider-editor.confirmed`
3241
+ * -> {@link writeSetupConfig}
3242
+ *
3243
+ * Upstream:
3244
+ * - setup command action
3245
+ *
3246
+ * Downstream:
3247
+ * - {@link runProviderEditor} and {@link writeSetupConfig}
3248
+ *
3249
+ * The handler loads one selected scope once. Back returns to navigation,
3250
+ * cancellation writes nothing, and only a confirmed editor result is persisted.
3251
+ */
2119
3252
  async function runInteractiveSetup(io) {
2120
3253
  if (io.stdin?.isTTY !== true || io.stdout.isTTY !== true) {
2121
3254
  io.stderr.write(nonTtyMessage);
@@ -2127,11 +3260,11 @@ async function runInteractiveSetup(io) {
2127
3260
  return 1;
2128
3261
  };
2129
3262
  prompts.intro("alint setup");
2130
- const draft = {};
3263
+ let scope = "global";
2131
3264
  let step = "scope";
2132
3265
  while (true) {
2133
3266
  if (step === "scope") {
2134
- const scope = await prompts.select({
3267
+ const selectedScope = await prompts.select({
2135
3268
  message: "Where should alint write setup config?",
2136
3269
  options: [{
2137
3270
  label: "Global",
@@ -2141,263 +3274,50 @@ async function runInteractiveSetup(io) {
2141
3274
  value: "local"
2142
3275
  }]
2143
3276
  });
2144
- if (prompts.isCancel(scope)) return cancelPrompt();
2145
- draft.scope = scope;
3277
+ if (prompts.isCancel(selectedScope)) return cancelPrompt();
3278
+ scope = selectedScope;
2146
3279
  step = "source";
2147
3280
  continue;
2148
3281
  }
2149
- if (step === "source") {
2150
- const source = await prompts.select({
2151
- message: "Choose provider setup mode.",
2152
- options: withBackOption(providerSetupSources.map(({ label, value }) => ({
2153
- label,
2154
- value
2155
- })))
2156
- });
2157
- if (prompts.isCancel(source)) return cancelPrompt();
2158
- if (source === backValue) {
2159
- step = "scope";
2160
- continue;
2161
- }
2162
- draft.source = source;
2163
- step = "endpoint";
2164
- continue;
2165
- }
2166
- if (step === "endpoint") {
2167
- const setupSource = findProviderSetupSource(draft.source ?? "custom");
2168
- if (setupSource === void 0) return cancelPrompt();
2169
- const endpoint = await promptEndpoint(prompts, setupSource);
2170
- if (prompts.isCancel(endpoint)) return cancelPrompt();
2171
- if (typeof endpoint !== "string") return cancelPrompt();
2172
- if (isBackInput(endpoint)) {
2173
- step = "source";
2174
- continue;
2175
- }
2176
- draft.endpoint = endpoint;
2177
- step = "providerId";
2178
- continue;
2179
- }
2180
- if (step === "providerId") {
2181
- const existingConfig = await loadSetupConfig(getConfigPath(io, draft.scope ?? "global"));
2182
- const providerId = await prompts.text({
2183
- initialValue: draft.providerId ?? createProviderId(draft.endpoint ?? "", new Set(existingConfig.providers.map((provider) => provider.id))),
2184
- message: "Provider id (type .. to go back)",
2185
- validate: (value) => isBackInput(value ?? "") || (value ?? "").trim().length > 0 ? void 0 : "Provider id is required."
2186
- });
2187
- if (prompts.isCancel(providerId)) return cancelPrompt();
2188
- if (typeof providerId !== "string") return cancelPrompt();
2189
- if (isBackInput(providerId)) {
2190
- step = "endpoint";
2191
- continue;
2192
- }
2193
- draft.providerId = providerId;
2194
- step = "headers";
2195
- continue;
2196
- }
2197
- if (step === "headers") {
2198
- const headerInput = await prompts.text({
2199
- initialValue: draft.headerInput,
2200
- message: "Headers (leave empty to skip; type .. to go back)",
2201
- placeholder: "Authorization=Bearer token, X-Test=true",
2202
- validate: (value) => {
2203
- if (isBackInput(value ?? "")) return;
2204
- try {
2205
- parseHeaderList(splitHeaderInput(value ?? ""));
2206
- return;
2207
- } catch {
2208
- return "Headers must be comma-separated Key=Value entries.";
2209
- }
2210
- }
2211
- });
2212
- if (prompts.isCancel(headerInput)) return cancelPrompt();
2213
- if (typeof headerInput !== "string") return cancelPrompt();
2214
- if (isBackInput(headerInput)) {
2215
- step = "providerId";
2216
- continue;
2217
- }
2218
- draft.headerInput = headerInput;
2219
- draft.headers = parseHeaderList(splitHeaderInput(headerInput));
2220
- draft.discoveredModels = findProviderSetupSource(draft.source ?? "custom")?.probeModels === false ? [] : await probeModelsWithSpinner(prompts, draft.endpoint ?? "", draft.headers);
2221
- step = "models";
2222
- continue;
2223
- }
2224
- if (step === "models") {
2225
- const selectedModels = await promptModels(prompts, draft.discoveredModels ?? []);
2226
- if (prompts.isCancel(selectedModels)) return cancelPrompt();
2227
- if (selectedModels === backValue) {
2228
- step = "headers";
2229
- continue;
2230
- }
2231
- if (!Array.isArray(selectedModels)) return cancelPrompt();
2232
- draft.selectedModels = selectedModels;
2233
- step = "defaultAlias";
2234
- continue;
2235
- }
2236
- if (step === "defaultAlias") {
2237
- const defaultAliasAction = await prompts.select({
2238
- message: `Add alias "default" to ${draft.selectedModels?.[0]}?`,
2239
- options: withBackOption([
2240
- {
2241
- label: "Yes",
2242
- value: "yes"
2243
- },
2244
- {
2245
- label: "No",
2246
- value: "no"
2247
- },
2248
- {
2249
- label: "Select another",
2250
- value: "selectAnother"
2251
- }
2252
- ])
2253
- });
2254
- if (prompts.isCancel(defaultAliasAction)) return cancelPrompt();
2255
- if (defaultAliasAction === backValue) {
2256
- step = "models";
2257
- continue;
2258
- }
2259
- if (defaultAliasAction === "selectAnother") {
2260
- step = "selectDefaultModel";
2261
- continue;
2262
- }
2263
- draft.defaultAliasTarget = defaultAliasAction === "yes" ? {
2264
- modelId: draft.selectedModels?.[0] ?? "",
2265
- providerId: (draft.providerId ?? "").trim()
2266
- } : void 0;
2267
- step = "confirm";
2268
- continue;
2269
- }
2270
- if (step === "selectDefaultModel") {
2271
- const existingConfig = await loadSetupConfig(getConfigPath(io, draft.scope ?? "global"));
2272
- const draftProvider = createProviderConfig((draft.providerId ?? "").trim(), (draft.endpoint ?? "").trim(), draft.headers, draft.selectedModels ?? []);
2273
- const candidates = createDefaultModelCandidates(mergeSetupConfigs(existingConfig, {
2274
- providers: [draftProvider],
2275
- version: 1
2276
- }), draftProvider.id, draft.selectedModels ?? []);
2277
- const defaultModel = await prompts.select({
2278
- message: "Select default model",
2279
- options: withBackOption(candidates.map((candidate) => ({
2280
- hint: candidate.isCurrentDefault ? "current default" : candidate.isNew ? "new" : void 0,
2281
- label: candidate.label,
2282
- value: candidate.value
2283
- })))
2284
- });
2285
- if (prompts.isCancel(defaultModel)) return cancelPrompt();
2286
- if (defaultModel === backValue) {
2287
- step = "defaultAlias";
2288
- continue;
2289
- }
2290
- const candidate = candidates.find((item) => item.value === defaultModel);
2291
- if (candidate === void 0) return cancelPrompt();
2292
- draft.defaultAliasTarget = {
2293
- modelId: candidate.modelId,
2294
- providerId: candidate.providerId
2295
- };
2296
- step = "confirm";
3282
+ const selectedSource = await prompts.select({
3283
+ message: "Choose provider setup mode.",
3284
+ options: withBackOption(providerSetupSources.map(({ label, value }) => ({
3285
+ label,
3286
+ value
3287
+ })))
3288
+ });
3289
+ if (prompts.isCancel(selectedSource)) return cancelPrompt();
3290
+ if (selectedSource === backValue) {
3291
+ step = "scope";
2297
3292
  continue;
2298
3293
  }
2299
- const nextProvider = createProviderConfig((draft.providerId ?? "").trim(), (draft.endpoint ?? "").trim(), draft.headers, draft.selectedModels ?? []);
2300
- const confirmed = await prompts.select({
2301
- message: [
2302
- `Write ${draft.scope} setup config?`,
2303
- `Provider: ${nextProvider.id}`,
2304
- `Endpoint: ${nextProvider.endpoint}`,
2305
- `Models: ${(draft.selectedModels ?? []).join(", ")}`
2306
- ].join("\n"),
2307
- options: withBackOption([{
2308
- label: "Yes",
2309
- value: "yes"
2310
- }, {
2311
- label: "No",
2312
- value: "no"
2313
- }])
3294
+ const source = findProviderSetupSource(selectedSource);
3295
+ if (source === void 0) return cancelPrompt();
3296
+ const configPath = getConfigPath(io, scope);
3297
+ const existingConfig = await loadSetupConfig(configPath);
3298
+ const result = await runProviderEditor({
3299
+ config: existingConfig,
3300
+ io,
3301
+ mode: "create",
3302
+ source
2314
3303
  });
2315
- if (prompts.isCancel(confirmed)) return cancelPrompt();
2316
- if (confirmed === backValue) {
2317
- step = "defaultAlias";
3304
+ if (result.status === "back") {
3305
+ step = "source";
2318
3306
  continue;
2319
3307
  }
2320
- if (confirmed === "no") return cancelPrompt();
2321
- const configPath = getConfigPath(io, draft.scope ?? "global");
2322
- const mergedConfig = mergeSetupConfigs(await loadSetupConfig(configPath), {
2323
- providers: [nextProvider],
3308
+ if (result.status === "cancelled") return cancelPrompt();
3309
+ const merged = mergeSetupConfigs(existingConfig, {
3310
+ providers: [result.provider],
2324
3311
  version: 1
2325
3312
  });
2326
- await writeSetupConfig(configPath, draft.defaultAliasTarget === void 0 ? mergedConfig : applyDefaultAlias(mergedConfig, draft.defaultAliasTarget));
3313
+ await writeSetupConfig(configPath, result.defaultAliasTarget === void 0 ? merged : applyDefaultAlias(merged, result.defaultAliasTarget));
2327
3314
  prompts.outro(`Wrote ${configPath}`);
2328
3315
  return 0;
2329
3316
  }
2330
3317
  }
2331
- function withBackOption(options) {
2332
- return [...options, {
2333
- label: "Back",
2334
- value: backValue
2335
- }];
2336
- }
2337
- function createDefaultModelCandidateValue(providerId, modelId) {
2338
- return `${providerId}\u0000${modelId}`;
2339
- }
2340
- function createProviderConfig(providerId, endpoint, headers, modelIds) {
2341
- return {
2342
- endpoint,
2343
- headers,
2344
- id: providerId,
2345
- models: modelIds.map((modelId) => ({
2346
- id: modelId,
2347
- name: modelId
2348
- })),
2349
- type: "openai-compatible"
2350
- };
2351
- }
2352
3318
  function getConfigPath(io, scope) {
2353
3319
  return scope === "local" ? getProjectSetupConfigPath(io.cwd) : getGlobalSetupConfigPath(io.env ?? process.env);
2354
3320
  }
2355
- async function probeModelsWithSpinner(prompts, endpoint, headers) {
2356
- const spinner = prompts.spinner();
2357
- spinner.start("Probing models");
2358
- try {
2359
- const models = await probeModels(endpoint, headers ?? {});
2360
- spinner.stop(models.length > 0 ? `Found ${models.length} models` : "No models discovered");
2361
- return models;
2362
- } catch (error) {
2363
- spinner.stop(formatProbeModelsFailure(endpoint, error));
2364
- return [];
2365
- }
2366
- }
2367
- async function promptEndpoint(prompts, source) {
2368
- return prompts.text({
2369
- initialValue: source.defaultEndpoint,
2370
- message: "Provider endpoint (type .. to go back)",
2371
- placeholder: source.defaultEndpoint ?? "https://example.test/v1",
2372
- validate: (value) => isBackInput(value ?? "") || (value ?? "").trim().length > 0 ? void 0 : "Provider endpoint is required."
2373
- });
2374
- }
2375
- async function promptModels(prompts, discoveredModels) {
2376
- if (discoveredModels.length > 0) {
2377
- const selectedModels = await prompts.multiselect({
2378
- message: "Select models",
2379
- options: withBackOption(discoveredModels.map((model) => ({
2380
- label: model,
2381
- value: model
2382
- }))),
2383
- required: true
2384
- });
2385
- return Array.isArray(selectedModels) && selectedModels.includes(backValue) ? backValue : selectedModels;
2386
- }
2387
- const modelInput = await prompts.text({
2388
- message: "Models",
2389
- placeholder: "qwen:8b, qwen:32b; type .. to go back",
2390
- validate: (value) => isBackInput(value ?? "") || splitModelInput(value ?? "").length > 0 ? void 0 : "At least one model is required."
2391
- });
2392
- if (prompts.isCancel(modelInput)) return modelInput;
2393
- return isBackInput(modelInput) ? backValue : splitModelInput(modelInput);
2394
- }
2395
- function splitHeaderInput(value) {
2396
- return value.split(",").map((item) => item.trim()).filter(Boolean);
2397
- }
2398
- function splitModelInput(value) {
2399
- return value.split(",").map((item) => item.trim()).filter(Boolean);
2400
- }
2401
3321
  //#endregion
2402
3322
  //#region src/cli/commands/setup/index.ts
2403
3323
  const setup = defineCommand({