@alint-js/cli 0.0.38 → 0.1.1

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.38";
19
+ var version = "0.1.1";
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,17 +440,36 @@ 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
  }
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
+ }
385
473
  //#endregion
386
474
  //#region src/cli/commands/config/models/ls.ts
387
475
  const ls$1 = defineCommand({
@@ -396,61 +484,338 @@ const ls$1 = defineCommand({
396
484
  //#endregion
397
485
  //#region src/cli/commands/config/probe.ts
398
486
  function providerHeadersFromOptions(options) {
399
- return parseHeaderList(toArray$1(options.providerHeader)) ?? {};
487
+ return parseHeaderList(toArray$2(options.providerHeader)) ?? {};
400
488
  }
401
- function toArray$1(value) {
489
+ function toArray$2(value) {
402
490
  if (value === void 0) return [];
403
491
  return (Array.isArray(value) ? value : [value]).filter((item) => typeof item === "string");
404
492
  }
405
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
406
784
  //#region src/cli/commands/config/models/models.ts
407
785
  const models = defineCommand({
408
786
  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
- }),
787
+ probe$1,
788
+ prune$1,
436
789
  ls$1,
437
790
  defineCommand({
438
791
  async action(context, model) {
439
- const candidate = findModel(await loadMergedSetupConfig(context.io), model);
440
- if (candidate === void 0) {
792
+ const candidates = findModels(await loadMergedSetupConfig(context.io), model);
793
+ if (candidates.length === 0) {
441
794
  context.io.stderr.write(`unknown model "${model}".\n`);
442
795
  return 2;
443
796
  }
444
- context.io.stdout.write(formatModelShow(candidate));
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]));
445
802
  return 0;
446
803
  },
447
804
  arguments: "<model>",
448
805
  description: "Show configured model",
449
806
  name: "show"
450
- })
807
+ }),
808
+ rm$1
451
809
  ],
452
810
  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"),
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"),
454
819
  name: "models"
455
820
  });
456
821
  //#endregion
@@ -492,6 +857,756 @@ const probe = defineCommand({
492
857
  }]
493
858
  });
494
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;
1604
+ }
1605
+ function toArray$1(value) {
1606
+ if (value === void 0) return [];
1607
+ return Array.isArray(value) ? value : [value];
1608
+ }
1609
+ //#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
@@ -582,7 +1711,7 @@ function formatStylish(input, options = {}) {
582
1711
  function countCachedDiagnostics(diagnostics, severity) {
583
1712
  return diagnostics.filter((diagnostic) => diagnostic.severity === severity && diagnostic.cached === true).length;
584
1713
  }
585
- function countDiagnostics$2(diagnostics, severity) {
1714
+ function countDiagnostics$1(diagnostics, severity) {
586
1715
  return diagnostics.filter((diagnostic) => diagnostic.severity === severity).length;
587
1716
  }
588
1717
  function createStyle(color) {
@@ -611,8 +1740,8 @@ function formatExecutionSummary(execution) {
611
1740
  return `${execution.cached}/${execution.planned} cached`;
612
1741
  }
613
1742
  function formatSummary(diagnostics, result, style) {
614
- const warnCount = countDiagnostics$2(diagnostics, "warn");
615
- const errorCount = countDiagnostics$2(diagnostics, "error");
1743
+ const warnCount = countDiagnostics$1(diagnostics, "warn");
1744
+ const errorCount = countDiagnostics$1(diagnostics, "error");
616
1745
  const cachedWarnCount = countCachedDiagnostics(diagnostics, "warn");
617
1746
  const cachedErrorCount = countCachedDiagnostics(diagnostics, "error");
618
1747
  const tokens = result === void 0 ? void 0 : formatTokenSummary(result);
@@ -650,19 +1779,18 @@ function createPlainProgressReporter(options) {
650
1779
  const target = payload.job.target.name ? `${payload.job.target.kind} ${payload.job.target.name}` : payload.job.target.kind;
651
1780
  writeLine(`scan ${payload.job.inputPath} > ${target} > ${payload.job.ruleId}`);
652
1781
  },
1782
+ onPrepareEnd: ({ filesTotal }) => writeLine(`alint prepared: ${filesTotal} files`),
1783
+ onPrepareStart: () => writeLine("alint preparing"),
653
1784
  onRunEnd: (payload) => {
654
- const warnCount = countDiagnostics$1(payload.diagnostics, "warn");
655
- const errorCount = countDiagnostics$1(payload.diagnostics, "error");
1785
+ const warnCount = countDiagnostics(payload.diagnostics, "warn");
1786
+ const errorCount = countDiagnostics(payload.diagnostics, "error");
656
1787
  const state = payload.execution.failed > 0 ? "failed" : "finished";
657
1788
  const { cached, cancelled, completed, failed, skipped } = payload.execution;
658
1789
  writeLine(`alint ${state}: ${warnCount} warn, ${errorCount} error, ${payload.usage.totalTokens} tokens, ${completed} completed, ${cached} cached, ${failed} failed, ${cancelled} cancelled, ${skipped} skipped`);
659
- },
660
- onRunStart: (payload) => {
661
- writeLine(`alint started: ${payload.jobsTotal} queued jobs`);
662
1790
  }
663
1791
  };
664
1792
  }
665
- function countDiagnostics$1(diagnostics, severity) {
1793
+ function countDiagnostics(diagnostics, severity) {
666
1794
  return diagnostics.filter((diagnostic) => diagnostic.severity === severity).length;
667
1795
  }
668
1796
  //#endregion
@@ -686,9 +1814,9 @@ function formatMiniBar(options) {
686
1814
  if (index <= frontierIndex) return "█";
687
1815
  return "░";
688
1816
  }
689
- if (index < phase - 1) return "█";
690
- if (index === phase - 1) return "░";
1817
+ if (index < phase) return "█";
691
1818
  if (index === phase) return "▓";
1819
+ if (index === phase + 1 && index <= frontierIndex) return "░";
692
1820
  if (index <= frontierIndex) return "█";
693
1821
  return "░";
694
1822
  }).join("")}]`;
@@ -708,35 +1836,48 @@ function createSummaryProgressReporter(options) {
708
1836
  const state = createInitialState();
709
1837
  return {
710
1838
  getRows: () => createRows(state, options, now()),
711
- onDiagnostic: ({ diagnostic }) => {
712
- state.diagnostics.push(diagnostic);
1839
+ onDiagnostic: ({ diagnostic, progress }) => {
1840
+ updateProgress(state, progress);
1841
+ if (diagnostic.severity === "warn") state.warnCount += 1;
1842
+ else state.errorCount += 1;
1843
+ },
1844
+ onExecuteEnd: ({ progress }) => {
1845
+ updateProgress(state, progress);
713
1846
  },
1847
+ onExecuteStart: ({ progress, startedAt }) => {
1848
+ updateProgress(state, progress);
1849
+ state.runStartedAt = startedAt ?? state.runStartedAt ?? now();
1850
+ },
1851
+ onFileReady: ({ progress }) => updateProgress(state, progress),
714
1852
  onJobEnd: (payload) => {
715
- const current = state.jobs.get(payload.job.id);
716
- if (payload.state === "cached" && current?.state !== "cached") {
1853
+ updateProgress(state, payload.progress);
1854
+ const current = state.activeJobs.get(payload.job.id);
1855
+ const group = state.ruleGroups.get(payload.job.ruleId);
1856
+ if (!group) throw new Error(`Cannot finish unqueued progress job "${payload.job.id}".`);
1857
+ group[payload.state] += 1;
1858
+ if (payload.endedAt !== void 0 && current?.startedAt !== void 0 && payload.state !== "cached") group.terminalDurationMs += Math.max(payload.endedAt - current.startedAt, 0);
1859
+ if (payload.state === "cached") {
717
1860
  const cachedTokens = state.jobTokens.get(payload.job.id) ?? 0;
718
1861
  state.totalTokens = Math.max(state.totalTokens - cachedTokens, 0);
719
1862
  state.cachedTokens += cachedTokens;
720
1863
  }
721
- state.jobs.set(payload.job.id, {
722
- endedAt: payload.endedAt ?? now(),
723
- job: payload.job,
724
- retry: current?.retry,
725
- startedAt: payload.startedAt ?? current?.startedAt,
726
- state: payload.state
727
- });
728
- transition(state.execution, "running", payload.state);
1864
+ state.activeJobs.delete(payload.job.id);
1865
+ state.jobTokens.delete(payload.job.id);
729
1866
  },
730
- onJobQueued: ({ job }) => {
731
- state.jobs.set(job.id, {
1867
+ onJobQueued: ({ job, progress }) => {
1868
+ updateProgress(state, progress);
1869
+ const group = state.ruleGroups.get(job.ruleId) ?? createRuleGroup(job);
1870
+ group.planned += 1;
1871
+ state.ruleGroups.set(job.ruleId, group);
1872
+ state.activeJobs.set(job.id, {
732
1873
  job,
733
1874
  state: "queued"
734
1875
  });
735
- state.execution.queued += 1;
736
1876
  },
737
1877
  onJobRetry: (payload) => {
738
- const current = state.jobs.get(payload.job.id);
739
- state.jobs.set(payload.job.id, {
1878
+ updateProgress(state, payload.progress);
1879
+ const current = state.activeJobs.get(payload.job.id);
1880
+ state.activeJobs.set(payload.job.id, {
740
1881
  job: payload.job,
741
1882
  retry: {
742
1883
  attempt: payload.attempt,
@@ -748,39 +1889,58 @@ function createSummaryProgressReporter(options) {
748
1889
  });
749
1890
  },
750
1891
  onJobStart: (payload) => {
751
- state.jobs.set(payload.job.id, {
1892
+ updateProgress(state, payload.progress);
1893
+ state.activeJobs.set(payload.job.id, {
752
1894
  job: payload.job,
753
1895
  startedAt: payload.startedAt ?? now(),
754
1896
  state: "running"
755
1897
  });
756
- transition(state.execution, "queued", "running");
757
- },
758
- onRunEnd: (payload) => {
759
- state.diagnostics = [...payload.diagnostics];
760
- state.execution = { ...payload.execution };
761
- state.totalTokens = payload.usage.totalTokens;
762
- state.cachedTokens = payload.usage.cached?.totalTokens ?? 0;
763
- state.runStartedAt = payload.startedAt ?? state.runStartedAt;
764
1898
  },
765
- onRunStart: ({ jobsTotal, startedAt }) => {
766
- state.diagnostics = [];
767
- state.execution = createCounts(jobsTotal);
768
- state.jobs.clear();
1899
+ onPrepareStart: ({ startedAt }) => {
1900
+ state.activeJobs.clear();
1901
+ state.errorCount = 0;
1902
+ state.execution = createCounts();
1903
+ state.progress = createProgress();
1904
+ state.ruleGroups.clear();
769
1905
  state.runStartedAt = startedAt ?? now();
770
- state.spinnerIndex = 0;
1906
+ state.animationTick = 0;
771
1907
  state.cachedTokens = 0;
772
1908
  state.jobTokens.clear();
773
1909
  state.totalTokens = 0;
1910
+ state.warnCount = 0;
1911
+ },
1912
+ onRunEnd: (payload) => {
1913
+ state.warnCount = payload.diagnostics.filter((diagnostic) => diagnostic.severity === "warn").length;
1914
+ state.errorCount = payload.diagnostics.length - state.warnCount;
1915
+ updateProgress(state, payload.progress);
1916
+ state.totalTokens = payload.usage.totalTokens;
1917
+ state.cachedTokens = payload.usage.cached?.totalTokens ?? 0;
1918
+ state.runStartedAt = payload.startedAt ?? state.runStartedAt;
774
1919
  },
775
- onUsage: ({ job, record }) => {
1920
+ onUsage: ({ job, progress, record }) => {
1921
+ updateProgress(state, progress);
776
1922
  if (record.totalTokens != null && Number.isFinite(record.totalTokens)) {
777
1923
  state.jobTokens.set(job.id, (state.jobTokens.get(job.id) ?? 0) + record.totalTokens);
778
- if (state.jobs.get(job.id)?.state === "cached") state.cachedTokens += record.totalTokens;
779
- else state.totalTokens += record.totalTokens;
1924
+ 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
  }
@@ -806,9 +1966,6 @@ function completeGraphemeBoundary(input, maximumIndex) {
806
1966
  }
807
1967
  return boundary;
808
1968
  }
809
- function countDiagnostics(diagnostics, severity) {
810
- return diagnostics.filter((diagnostic) => diagnostic.severity === severity).length;
811
- }
812
1969
  function createCounts(planned = 0) {
813
1970
  return {
814
1971
  cached: 0,
@@ -823,19 +1980,30 @@ function createCounts(planned = 0) {
823
1980
  }
824
1981
  function createInitialState() {
825
1982
  return {
1983
+ activeJobs: /* @__PURE__ */ new Map(),
1984
+ animationTick: 0,
826
1985
  cachedTokens: 0,
827
- diagnostics: [],
1986
+ errorCount: 0,
828
1987
  execution: createCounts(),
829
- jobs: /* @__PURE__ */ new Map(),
830
1988
  jobTokens: /* @__PURE__ */ new Map(),
831
- spinnerIndex: 0,
832
- totalTokens: 0
1989
+ progress: createProgress(),
1990
+ ruleGroups: /* @__PURE__ */ new Map(),
1991
+ totalTokens: 0,
1992
+ warnCount: 0
1993
+ };
1994
+ }
1995
+ function createProgress() {
1996
+ return {
1997
+ execution: createCounts(),
1998
+ filesTotal: 0,
1999
+ final: false,
2000
+ jobsCompleted: 0,
2001
+ jobsStarted: 0,
2002
+ jobsTotal: 0
833
2003
  };
834
2004
  }
835
2005
  function createRows(state, options, now) {
836
2006
  if (options.rows !== void 0 && options.rows <= 0) return [];
837
- const warnCount = countDiagnostics(state.diagnostics, "warn");
838
- const errorCount = countDiagnostics(state.diagnostics, "error");
839
2007
  const groups = createRuleGroups(state);
840
2008
  const footerRows = options.rows === void 0 ? 3 : Math.min(options.rows, 3);
841
2009
  const separatorRows = options.rows === void 0 || options.rows - footerRows > 1 ? 1 : 0;
@@ -845,34 +2013,27 @@ function createRows(state, options, now) {
845
2013
  const visibleFooters = options.rows === void 0 ? footer : footer.slice(0, Math.max(options.rows - visible.length - actualSeparatorRows, 0));
846
2014
  if (actualSeparatorRows === 1) visible.push("");
847
2015
  visible.push(...visibleFooters);
848
- return options.color ? visible.map((row) => styleRow(row, state, warnCount, errorCount, options)) : visible;
2016
+ return options.color ? visible.map((row) => styleRow(row, state, state.warnCount, state.errorCount, options)) : visible;
2017
+ }
2018
+ function createRuleGroup(job) {
2019
+ return {
2020
+ cached: 0,
2021
+ cancelled: 0,
2022
+ completed: 0,
2023
+ failed: 0,
2024
+ firstJobIndex: job.index,
2025
+ planned: 0,
2026
+ ruleId: job.ruleId,
2027
+ skipped: 0,
2028
+ terminalDurationMs: 0
2029
+ };
849
2030
  }
850
2031
  function createRuleGroups(state) {
851
- const groups = /* @__PURE__ */ new Map();
852
- for (const job of state.jobs.values()) {
853
- const group = groups.get(job.job.ruleId) ?? {
854
- cached: 0,
855
- cancelled: 0,
856
- completed: 0,
857
- failed: 0,
858
- firstJobIndex: job.job.index,
859
- jobs: [],
860
- planned: 0,
861
- ruleId: job.job.ruleId,
862
- skipped: 0,
863
- terminalDurationMs: 0
864
- };
865
- group.firstJobIndex = Math.min(group.firstJobIndex, job.job.index);
866
- group.jobs.push(job);
867
- group.planned = Math.max(group.planned, job.job.ruleTotal);
868
- if (job.state === "cached") group.cached += 1;
869
- if (job.state === "cancelled") group.cancelled += 1;
870
- if (job.state === "completed") group.completed += 1;
871
- if (job.state === "failed") group.failed += 1;
872
- if (job.state === "skipped") group.skipped += 1;
873
- if (job.endedAt !== void 0 && job.startedAt !== void 0 && job.state !== "cached") group.terminalDurationMs += Math.max(job.endedAt - job.startedAt, 0);
874
- groups.set(group.ruleId, group);
875
- }
2032
+ const groups = new Map([...state.ruleGroups].map(([ruleId, group]) => [ruleId, {
2033
+ ...group,
2034
+ jobs: []
2035
+ }]));
2036
+ for (const job of state.activeJobs.values()) groups.get(job.job.ruleId)?.jobs.push(job);
876
2037
  return [...groups.values()].sort(compareRuleGroups);
877
2038
  }
878
2039
  function estimateEta(durationMs, terminal, planned) {
@@ -937,11 +2098,12 @@ function formatDuration(ms) {
937
2098
  function formatFooters(state, options, now) {
938
2099
  const terminal = terminalCount(state.execution);
939
2100
  const elapsed = state.runStartedAt === void 0 ? 0 : now - state.runStartedAt;
940
- const progressBar = formatMiniBarSegment(terminal, state.execution.planned, state.spinnerIndex, options);
941
- const eta = estimateEta(elapsed, terminal, state.execution.planned);
942
- const projectedTokens = terminal > 0 && state.execution.planned > 0 ? Math.ceil((state.totalTokens + state.cachedTokens) * state.execution.planned / terminal) : void 0;
2101
+ const progressBar = state.progress.final ? formatMiniBarSegment(terminal, state.execution.planned, state.animationTick, options) : "";
2102
+ const eta = state.progress.final ? estimateEta(elapsed, terminal, state.execution.planned) : "eta ?";
2103
+ const projectedTokens = state.progress.final && terminal > 0 && state.execution.planned > 0 ? Math.ceil((state.totalTokens + state.cachedTokens) * state.execution.planned / terminal) : void 0;
2104
+ const discovering = state.progress.final ? "" : " jobs (discovering)";
943
2105
  return [
944
- fitRow(`${terminal}/${state.execution.planned}${progressBar} ${formatDuration(elapsed)} -> ${eta === "eta ?" ? "~?" : eta.replace("eta ", "")}`, options.columns),
2106
+ fitRow(`${terminal}/${state.execution.planned}${discovering}${progressBar} ${formatDuration(elapsed)} -> ${eta === "eta ?" ? "~?" : eta.replace("eta ", "")}`, options.columns),
945
2107
  fitRow(`${state.execution.running} concurrency / ${state.execution.queued} queued / ${state.execution.cached} cached / ${state.execution.failed} failed`, options.columns),
946
2108
  fitRow(`${state.totalTokens.toLocaleString("en-US")} tokens (${state.cachedTokens.toLocaleString("en-US")} cached) -> ${projectedTokens === void 0 ? "~?" : `~${projectedTokens.toLocaleString("en-US")} tokens`}`, options.columns)
947
2109
  ];
@@ -956,15 +2118,18 @@ function formatMiniBarSegment(completed, planned, tick, options) {
956
2118
  })}`;
957
2119
  }
958
2120
  function formatRuleRow(group, state, options) {
959
- const spinner = options.spinnerFrames[state.spinnerIndex] ?? "";
2121
+ const spinner = formatSpinnerFrame(state.animationTick, options.spinnerFrames);
960
2122
  const terminal = group.completed + group.cached + group.failed + group.cancelled + group.skipped;
961
2123
  const percent = group.planned > 0 ? Math.floor(terminal / group.planned * 100) : 0;
962
- const bar = formatMiniBarSegment(terminal, group.planned, state.spinnerIndex, options);
2124
+ const bar = formatMiniBarSegment(terminal, group.planned, state.animationTick, options);
963
2125
  const running = group.jobs.filter((job) => job.state === "running").length;
964
2126
  const failed = group.failed > 0 ? ` ${group.failed} failed` : "";
965
2127
  const eta = estimateEta(group.terminalDurationMs, terminal, group.planned);
966
2128
  return fitRow(`${spinner} ${group.ruleId} ${terminal}/${group.planned} ${percent}%${bar} ${eta} ${running} running${failed}`, options.columns);
967
2129
  }
2130
+ function formatSpinnerFrame(animationTick, spinnerFrames) {
2131
+ return spinnerFrames[animationTick % Math.max(spinnerFrames.length, 1)] ?? "";
2132
+ }
968
2133
  function formatTargetRow(jobState, options, now, last) {
969
2134
  const inputPath = options.cwd ? relative$1(options.cwd, jobState.job.inputPath) || "." : jobState.job.inputPath;
970
2135
  const target = jobState.job.target.name ? `${jobState.job.target.kind} ${jobState.job.target.name}` : jobState.job.target.kind;
@@ -973,16 +2138,19 @@ function formatTargetRow(jobState, options, now, last) {
973
2138
  }
974
2139
  function styleRow(row, state, warnCount, errorCount, options) {
975
2140
  let styledRow = row;
976
- const spinner = options.spinnerFrames[state.spinnerIndex] ?? "";
2141
+ const spinner = formatSpinnerFrame(state.animationTick, options.spinnerFrames);
977
2142
  if (spinner) styledRow = styledRow.replace(spinner, colors$2.cyan(spinner));
978
2143
  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
2144
  }
980
2145
  function terminalCount(counts) {
981
2146
  return counts.completed + counts.failed + counts.cached + counts.skipped + counts.cancelled;
982
2147
  }
983
- function transition(counts, from, to) {
984
- counts[from] -= 1;
985
- counts[to] += 1;
2148
+ function updateProgress(state, progress) {
2149
+ state.execution = { ...progress.execution };
2150
+ state.progress = {
2151
+ ...progress,
2152
+ execution: { ...progress.execution }
2153
+ };
986
2154
  }
987
2155
  //#endregion
988
2156
  //#region src/cli/reporters/progress/tty.ts
@@ -1072,6 +2240,18 @@ function createRenderingProgressReporter(summary, renderer) {
1072
2240
  summary.onDiagnostic?.(payload);
1073
2241
  renderer.render();
1074
2242
  },
2243
+ onExecuteEnd: (payload) => {
2244
+ summary.onExecuteEnd?.(payload);
2245
+ renderer.render();
2246
+ },
2247
+ onExecuteStart: (payload) => {
2248
+ summary.onExecuteStart?.(payload);
2249
+ renderer.render();
2250
+ },
2251
+ onFileReady: (payload) => {
2252
+ summary.onFileReady?.(payload);
2253
+ renderer.render();
2254
+ },
1075
2255
  onJobEnd: (payload) => {
1076
2256
  summary.onJobEnd?.(payload);
1077
2257
  renderer.render();
@@ -1088,14 +2268,18 @@ function createRenderingProgressReporter(summary, renderer) {
1088
2268
  summary.onJobStart?.(payload);
1089
2269
  renderer.render();
1090
2270
  },
1091
- onRunEnd: (payload) => {
1092
- summary.onRunEnd?.(payload);
2271
+ onPrepareEnd: (payload) => {
2272
+ summary.onPrepareEnd?.(payload);
1093
2273
  renderer.render();
1094
2274
  },
1095
- onRunStart: (payload) => {
1096
- summary.onRunStart?.(payload);
2275
+ onPrepareStart: (payload) => {
2276
+ summary.onPrepareStart?.(payload);
1097
2277
  renderer.start();
1098
2278
  },
2279
+ onRunEnd: (payload) => {
2280
+ summary.onRunEnd?.(payload);
2281
+ renderer.render();
2282
+ },
1099
2283
  onUsage: (payload) => {
1100
2284
  summary.onUsage?.(payload);
1101
2285
  renderer.render();
@@ -1283,13 +2467,21 @@ function formatCancelledError(error, color) {
1283
2467
  }
1284
2468
  function formatRunError(error, color) {
1285
2469
  const label = color ? colors$1.red("error") : "error";
1286
- const groups = groupFailuresByRule(error.failures);
2470
+ const fileFailures = error.failures.filter((failure) => "file" in failure);
2471
+ const groups = groupFailuresByRule(error.failures.filter((failure) => "job" in failure));
1287
2472
  return [
1288
2473
  `${label} ${error.message}`,
1289
2474
  "",
1290
- `Failed Rules ${groups.length}`,
1291
- "",
1292
- ...formatFailureGroups(groups, color)
2475
+ ...fileFailures.length > 0 ? [
2476
+ `Failed Files ${fileFailures.length}`,
2477
+ "",
2478
+ ...formatFileFailures(fileFailures, color)
2479
+ ] : [],
2480
+ ...groups.length > 0 ? [
2481
+ `Failed Rules ${groups.length}`,
2482
+ "",
2483
+ ...formatFailureGroups(groups, color)
2484
+ ] : []
1293
2485
  ].join("\n");
1294
2486
  }
1295
2487
  function formatFailureGroups(groups, color) {
@@ -1298,7 +2490,7 @@ function formatFailureGroups(groups, color) {
1298
2490
  const failLabel = color ? colors$1.bgRed(colors$1.bold(" FAIL ")) : "FAIL";
1299
2491
  const targetLabel = group.length === 1 ? "target" : "targets";
1300
2492
  lines.push(`${failLabel} ${ruleId} ${group.length} ${targetLabel}`);
1301
- for (const failure of group.sort((left, right) => left.job.index - right.job.index)) {
2493
+ for (const failure of group) {
1302
2494
  lines.push(` ${formatTarget(failure)}`);
1303
2495
  lines.push(` [${failure.kind}] ${failure.message}`);
1304
2496
  }
@@ -1306,6 +2498,16 @@ function formatFailureGroups(groups, color) {
1306
2498
  }
1307
2499
  return lines;
1308
2500
  }
2501
+ function formatFileFailures(failures, color) {
2502
+ const lines = [];
2503
+ for (const failure of failures) {
2504
+ const failLabel = color ? colors$1.bgRed(colors$1.bold(" FAIL ")) : "FAIL";
2505
+ lines.push(`${failLabel} ${failure.file.path}`);
2506
+ lines.push(` [${failure.kind}] ${failure.message}`);
2507
+ lines.push("");
2508
+ }
2509
+ return lines;
2510
+ }
1309
2511
  function formatTarget(failure) {
1310
2512
  const target = failure.job.target.name ? `${failure.job.target.kind} ${failure.job.target.name}` : failure.job.target.kind;
1311
2513
  return `${failure.job.inputPath} > ${target}`;
@@ -1741,6 +2943,9 @@ function createStatsCollector() {
1741
2943
  counts,
1742
2944
  durationMs: () => startedAt !== void 0 && endedAt !== void 0 ? endedAt - startedAt : void 0,
1743
2945
  reporter: {
2946
+ onPrepareStart: (payload) => {
2947
+ startedAt = payload.startedAt;
2948
+ },
1744
2949
  onRunEnd: (payload) => {
1745
2950
  counts.cached = payload.execution.cached;
1746
2951
  counts.cancelled = payload.execution.cancelled;
@@ -1748,9 +2953,6 @@ function createStatsCollector() {
1748
2953
  counts.failed = payload.execution.failed;
1749
2954
  counts.planned = payload.execution.planned;
1750
2955
  endedAt = payload.endedAt;
1751
- },
1752
- onRunStart: (payload) => {
1753
- startedAt = payload.startedAt;
1754
2956
  }
1755
2957
  }
1756
2958
  };
@@ -1784,6 +2986,15 @@ function mergeProgressReporters(base, extra) {
1784
2986
  onDiagnostic: (payload) => {
1785
2987
  deliverBoth((reporter) => reporter.onDiagnostic?.(payload));
1786
2988
  },
2989
+ onExecuteEnd: (payload) => {
2990
+ deliverBoth((reporter) => reporter.onExecuteEnd?.(payload));
2991
+ },
2992
+ onExecuteStart: (payload) => {
2993
+ deliverBoth((reporter) => reporter.onExecuteStart?.(payload));
2994
+ },
2995
+ onFileReady: (payload) => {
2996
+ deliverBoth((reporter) => reporter.onFileReady?.(payload));
2997
+ },
1787
2998
  onJobEnd: (payload) => {
1788
2999
  deliverBoth((reporter) => reporter.onJobEnd?.(payload));
1789
3000
  },
@@ -1796,13 +3007,16 @@ function mergeProgressReporters(base, extra) {
1796
3007
  onJobStart: (payload) => {
1797
3008
  deliverBoth((reporter) => reporter.onJobStart?.(payload));
1798
3009
  },
3010
+ onPrepareEnd: (payload) => {
3011
+ deliverBoth((reporter) => reporter.onPrepareEnd?.(payload));
3012
+ },
3013
+ onPrepareStart: (payload) => {
3014
+ deliverBoth((reporter) => reporter.onPrepareStart?.(payload));
3015
+ },
1799
3016
  onRunEnd: (payload) => {
1800
3017
  deliverBoth((reporter) => reporter.onRunEnd?.(payload));
1801
3018
  if (firstFailure) throw firstFailure;
1802
3019
  },
1803
- onRunStart: (payload) => {
1804
- deliverBoth((reporter) => reporter.onRunStart?.(payload));
1805
- },
1806
3020
  onUsage: (payload) => {
1807
3021
  deliverBoth((reporter) => reporter.onUsage?.(payload));
1808
3022
  }
@@ -1900,6 +3114,9 @@ async function runLintCommand(files, options, io, interceptConsoleOutput) {
1900
3114
  const persistStats = async (runResult) => {
1901
3115
  if (statsTarget && statsCollector) await writeRunStats(statsTarget, statsCollector, runResult, io.cwd);
1902
3116
  };
3117
+ const writeResult = (runResult) => {
3118
+ io.stdout.write(formatDiagnostics(options.format, runResult, { color: io.stdout.isTTY === true }));
3119
+ };
1903
3120
  let result;
1904
3121
  try {
1905
3122
  result = await runAlint({
@@ -1919,11 +3136,13 @@ async function runLintCommand(files, options, io, interceptConsoleOutput) {
1919
3136
  progress?.dispose();
1920
3137
  if (error instanceof AlintRunError) {
1921
3138
  await persistStats(error.result);
3139
+ writeResult(error.result);
1922
3140
  io.stderr.write(formatRunError(error, io.stderr.isTTY === true));
1923
3141
  return 2;
1924
3142
  }
1925
3143
  if (error instanceof AlintRunCancelledError) {
1926
3144
  await persistStats(error.result);
3145
+ writeResult(error.result);
1927
3146
  io.stderr.write(formatCancelledError(error, io.stderr.isTTY === true));
1928
3147
  return 2;
1929
3148
  }
@@ -1932,7 +3151,7 @@ async function runLintCommand(files, options, io, interceptConsoleOutput) {
1932
3151
  restoreProgressConsole?.();
1933
3152
  progress?.dispose();
1934
3153
  await persistStats(result);
1935
- io.stdout.write(formatDiagnostics(options.format, result, { color: io.stdout.isTTY === true }));
3154
+ writeResult(result);
1936
3155
  return result.diagnostics.some((diagnostic) => diagnostic.severity === "error") ? 1 : 0;
1937
3156
  }
1938
3157
  function shouldEnableProgress(options, io) {
@@ -2062,60 +3281,25 @@ const plugin = defineCommand({
2062
3281
  //#region src/cli/commands/setup/interactive.ts
2063
3282
  const nonTtyMessage = "interactive setup requires a TTY. Use -N/--no-interactive with --provider-id and --provider-endpoint.\n";
2064
3283
  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
- }
3284
+ /**
3285
+ * Handles setup-only scope/source navigation and persists a confirmed provider.
3286
+ *
3287
+ * Triggering workflow:
3288
+ *
3289
+ * {@link setup}
3290
+ * -> {@link runInteractiveSetup}
3291
+ * -> `provider-editor.confirmed`
3292
+ * -> {@link writeSetupConfig}
3293
+ *
3294
+ * Upstream:
3295
+ * - setup command action
3296
+ *
3297
+ * Downstream:
3298
+ * - {@link runProviderEditor} and {@link writeSetupConfig}
3299
+ *
3300
+ * The handler loads one selected scope once. Back returns to navigation,
3301
+ * cancellation writes nothing, and only a confirmed editor result is persisted.
3302
+ */
2119
3303
  async function runInteractiveSetup(io) {
2120
3304
  if (io.stdin?.isTTY !== true || io.stdout.isTTY !== true) {
2121
3305
  io.stderr.write(nonTtyMessage);
@@ -2127,11 +3311,11 @@ async function runInteractiveSetup(io) {
2127
3311
  return 1;
2128
3312
  };
2129
3313
  prompts.intro("alint setup");
2130
- const draft = {};
3314
+ let scope = "global";
2131
3315
  let step = "scope";
2132
3316
  while (true) {
2133
3317
  if (step === "scope") {
2134
- const scope = await prompts.select({
3318
+ const selectedScope = await prompts.select({
2135
3319
  message: "Where should alint write setup config?",
2136
3320
  options: [{
2137
3321
  label: "Global",
@@ -2141,263 +3325,50 @@ async function runInteractiveSetup(io) {
2141
3325
  value: "local"
2142
3326
  }]
2143
3327
  });
2144
- if (prompts.isCancel(scope)) return cancelPrompt();
2145
- draft.scope = scope;
3328
+ if (prompts.isCancel(selectedScope)) return cancelPrompt();
3329
+ scope = selectedScope;
2146
3330
  step = "source";
2147
3331
  continue;
2148
3332
  }
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";
3333
+ const selectedSource = await prompts.select({
3334
+ message: "Choose provider setup mode.",
3335
+ options: withBackOption(providerSetupSources.map(({ label, value }) => ({
3336
+ label,
3337
+ value
3338
+ })))
3339
+ });
3340
+ if (prompts.isCancel(selectedSource)) return cancelPrompt();
3341
+ if (selectedSource === backValue) {
3342
+ step = "scope";
2297
3343
  continue;
2298
3344
  }
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
- }])
3345
+ const source = findProviderSetupSource(selectedSource);
3346
+ if (source === void 0) return cancelPrompt();
3347
+ const configPath = getConfigPath(io, scope);
3348
+ const existingConfig = await loadSetupConfig(configPath);
3349
+ const result = await runProviderEditor({
3350
+ config: existingConfig,
3351
+ io,
3352
+ mode: "create",
3353
+ source
2314
3354
  });
2315
- if (prompts.isCancel(confirmed)) return cancelPrompt();
2316
- if (confirmed === backValue) {
2317
- step = "defaultAlias";
3355
+ if (result.status === "back") {
3356
+ step = "source";
2318
3357
  continue;
2319
3358
  }
2320
- if (confirmed === "no") return cancelPrompt();
2321
- const configPath = getConfigPath(io, draft.scope ?? "global");
2322
- const mergedConfig = mergeSetupConfigs(await loadSetupConfig(configPath), {
2323
- providers: [nextProvider],
3359
+ if (result.status === "cancelled") return cancelPrompt();
3360
+ const merged = mergeSetupConfigs(existingConfig, {
3361
+ providers: [result.provider],
2324
3362
  version: 1
2325
3363
  });
2326
- await writeSetupConfig(configPath, draft.defaultAliasTarget === void 0 ? mergedConfig : applyDefaultAlias(mergedConfig, draft.defaultAliasTarget));
3364
+ await writeSetupConfig(configPath, result.defaultAliasTarget === void 0 ? merged : applyDefaultAlias(merged, result.defaultAliasTarget));
2327
3365
  prompts.outro(`Wrote ${configPath}`);
2328
3366
  return 0;
2329
3367
  }
2330
3368
  }
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
3369
  function getConfigPath(io, scope) {
2353
3370
  return scope === "local" ? getProjectSetupConfigPath(io.cwd) : getGlobalSetupConfigPath(io.env ?? process.env);
2354
3371
  }
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
3372
  //#endregion
2402
3373
  //#region src/cli/commands/setup/index.ts
2403
3374
  const setup = defineCommand({