@konvert7/klint 0.35.2 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -178,16 +178,19 @@ Every lint run is recorded in `.klint/stats.json` next to your config. `klint st
178
178
  ```
179
179
  klint stats (.klint/stats.json)
180
180
 
181
- runs 42 (hook 30, direct 12)
182
- errors prevented 17 (caught by hook 11, direct 6)
181
+ runs 42 (hook 30, direct 12)
182
+ violations reported 61 (hook 48, direct 13)
183
+ errors prevented 17 (caught by hook 11, direct 6)
183
184
 
184
- prevented by rule
185
- no-floating-promise 9
186
- arch/imports 5
187
- no-string-match 3
185
+ by rule reported prevented
186
+ no-floating-promise 29 9
187
+ arch/imports 20 5
188
+ no-string-match 12 3
188
189
  ```
189
190
 
190
- An error counts as prevented once: when a run reports it and a later run no longer does. Errors match by rule, file, and message, so moving code to another line doesn't count as a fix, and running klint again on an unfixed error doesn't count twice. Warnings aren't tracked. A prevented error is credited to the run that first caught it: pass `--hook` from agent hooks, and every other run (an agent's own shell, a person, CI) counts as direct.
191
+ Violations reported counts every error and warning each run shows, so an unfixed error reported by three runs counts three times. That is the measure of how often klint stepped in. klint's own `klint/*` advisories, such as a stale skill, aren't counted.
192
+
193
+ Errors prevented is stricter: an error counts once, when a run reports it and a later run no longer does. Errors match by rule, file, and message, so moving code to another line doesn't count as a fix, and running klint again on an unfixed error doesn't count twice. Warnings aren't tracked as prevented. A prevented error is credited to the run that first caught it: pass `--hook` from agent hooks, and every other run (an agent's own shell, a person, CI) counts as direct.
191
194
 
192
195
  `.klint/` contains a `.gitignore` with `*`, so git ignores the whole folder and nothing needs adding to your own `.gitignore`. Set `KLINT_NO_STATS=1` to stop recording. Stats are best effort: a read-only checkout never fails a lint run.
193
196
 
package/cli/command.ts ADDED
@@ -0,0 +1,189 @@
1
+ interface OptionSpec {
2
+ description: string;
3
+ value?: string;
4
+ short?: string;
5
+ aliases?: string[];
6
+ }
7
+
8
+ type OptionSpecs = Record<string, OptionSpec>;
9
+ export type OptionValues = Record<string, string | true | undefined>;
10
+
11
+ interface Leaf {
12
+ kind: "leaf";
13
+ summary: string;
14
+ options: OptionSpecs;
15
+ details?: () => string[];
16
+ run(values: OptionValues): unknown;
17
+ }
18
+
19
+ export interface Group {
20
+ kind: "group";
21
+ summary: string;
22
+ commands: Record<string, Leaf>;
23
+ aliases?: Record<string, string>;
24
+ defaultCommand?: Leaf;
25
+ details?: () => string[];
26
+ }
27
+
28
+ export type Command = Leaf | Group;
29
+
30
+ const HELP_FLAGS = new Set(["-h", "--help"]);
31
+ const HELP_WORD = "help";
32
+ const HELP_OPTION: OptionSpec = { short: "h", description: "Show this help" };
33
+
34
+ interface ParsedOptions {
35
+ values: OptionValues;
36
+ positionals: string[];
37
+ help: boolean;
38
+ error?: string;
39
+ rest: string[];
40
+ }
41
+
42
+ function findOption(
43
+ options: OptionSpecs,
44
+ token: string
45
+ ): [string, OptionSpec] | undefined {
46
+ return Object.entries(options).find(
47
+ ([name, spec]) =>
48
+ token === `--${name}` ||
49
+ (spec.short !== undefined && token === `-${spec.short}`) ||
50
+ (spec.aliases?.includes(token) ?? false)
51
+ );
52
+ }
53
+
54
+ function parseOptions(
55
+ args: string[],
56
+ options: OptionSpecs,
57
+ stopAtPositional: boolean
58
+ ): ParsedOptions {
59
+ const parsed: ParsedOptions = { values: {}, positionals: [], help: false, rest: [] };
60
+ for (let i = 0; i < args.length; i++) {
61
+ const token = args[i];
62
+ const match = findOption(options, token);
63
+ if (HELP_FLAGS.has(token)) {
64
+ parsed.help = true;
65
+ } else if (match?.[1].value !== undefined) {
66
+ const [name, spec] = match;
67
+ const value = args[i + 1];
68
+ if (value === undefined) parsed.error ??= `option '--${name}' needs ${spec.value}`;
69
+ else parsed.values[name] = value;
70
+ i++;
71
+ } else if (match) {
72
+ parsed.values[match[0]] = true;
73
+ } else if (token.startsWith("-")) {
74
+ parsed.error ??= `unknown option '${token}'`;
75
+ } else if (stopAtPositional) {
76
+ parsed.positionals.push(token);
77
+ parsed.rest = args.slice(i + 1);
78
+ return parsed;
79
+ } else {
80
+ parsed.positionals.push(token);
81
+ }
82
+ }
83
+ return parsed;
84
+ }
85
+
86
+ export async function runCommand(
87
+ command: Command,
88
+ args: string[],
89
+ path: string[]
90
+ ): Promise<number> {
91
+ return command.kind === "group"
92
+ ? runGroup(command, args, path)
93
+ : runLeaf(command, args, helpText(command, path));
94
+ }
95
+
96
+ async function runGroup(group: Group, args: string[], path: string[]): Promise<number> {
97
+ const help = helpText(group, path);
98
+ const parsed = parseOptions(args, group.defaultCommand?.options ?? {}, true);
99
+ const [name] = parsed.positionals;
100
+ if (parsed.help || name === HELP_WORD || group.aliases?.[name] === HELP_WORD) {
101
+ return showHelp(help);
102
+ }
103
+ if (name !== undefined) {
104
+ const sub = group.commands[group.aliases?.[name] ?? name];
105
+ if (!sub) return usageFailure(`unknown command '${name}'`, help);
106
+ if (parsed.error) return usageFailure(parsed.error, help);
107
+ return runCommand(sub, parsed.rest, [...path, name]);
108
+ }
109
+ if (!group.defaultCommand) return showHelp(help);
110
+ return runLeaf(group.defaultCommand, args, help);
111
+ }
112
+
113
+ async function runLeaf(leaf: Leaf, args: string[], help: string): Promise<number> {
114
+ const parsed = parseOptions(args, leaf.options, false);
115
+ if (parsed.help || parsed.positionals.includes(HELP_WORD)) return showHelp(help);
116
+ if (parsed.error) return usageFailure(parsed.error, help);
117
+ const [extra] = parsed.positionals;
118
+ if (extra !== undefined) return usageFailure(`unexpected argument '${extra}'`, help);
119
+ const result = await leaf.run(parsed.values);
120
+ return typeof result === "number" ? result : 0;
121
+ }
122
+
123
+ function showHelp(help: string): number {
124
+ process.stdout.write(`${help}\n`);
125
+ return 0;
126
+ }
127
+
128
+ function usageFailure(message: string, help: string): number {
129
+ process.stderr.write(`error: ${message}\n\n${help}\n`);
130
+ return 1;
131
+ }
132
+
133
+ function helpText(command: Command, path: string[]): string {
134
+ return command.kind === "group" ? groupHelp(command, path) : leafHelp(command, path);
135
+ }
136
+
137
+ function optionLabel(name: string, spec: OptionSpec): string {
138
+ const short = spec.short ? `-${spec.short}, ` : "";
139
+ const value = spec.value ? ` ${spec.value}` : "";
140
+ return `${short}--${name}${value}`;
141
+ }
142
+
143
+ function optionRows(options: OptionSpecs): string[] {
144
+ return table(
145
+ Object.entries({ ...options, help: HELP_OPTION }).map(([name, spec]) => [
146
+ optionLabel(name, spec),
147
+ spec.description,
148
+ ])
149
+ );
150
+ }
151
+
152
+ function leafHelp(leaf: Leaf, path: string[]): string {
153
+ return [
154
+ `Usage: ${path.join(" ")} [options]`,
155
+ "",
156
+ ` ${leaf.summary}`,
157
+ "",
158
+ "Options:",
159
+ ...optionRows(leaf.options),
160
+ ...detailLines(leaf.details),
161
+ ].join("\n");
162
+ }
163
+
164
+ function groupHelp(group: Group, path: string[]): string {
165
+ const fallback = group.defaultCommand;
166
+ const synopsis = fallback ? `[<command>] [options]` : "<command>";
167
+ return [
168
+ `Usage: ${path.join(" ")} ${synopsis}`,
169
+ "",
170
+ ` ${group.summary}`,
171
+ "",
172
+ "Commands:",
173
+ ...table(Object.entries(group.commands).map(([name, sub]) => [name, sub.summary])),
174
+ ...(fallback ? ["", "Options:", ...optionRows(fallback.options)] : []),
175
+ "",
176
+ ...(fallback ? [`With no command, ${fallback.summary.toLowerCase()}.`] : []),
177
+ `Run '${path.join(" ")} <command> --help' for its options.`,
178
+ ...detailLines(group.details),
179
+ ].join("\n");
180
+ }
181
+
182
+ function detailLines(details: (() => string[]) | undefined): string[] {
183
+ return details ? ["", ...details()] : [];
184
+ }
185
+
186
+ function table(rows: string[][]): string[] {
187
+ const width = Math.max(...rows.map(([label]) => label.length));
188
+ return rows.map(([label, text]) => ` ${label.padEnd(width)} ${text}`);
189
+ }
@@ -0,0 +1,90 @@
1
+ import { AGENT_SKILL_DIRS } from "../core/skill";
2
+ import type { Group, OptionValues } from "./command";
3
+ import { builtInRuleLines } from "./help";
4
+
5
+ interface CommandHandlers {
6
+ lint(values: OptionValues): unknown;
7
+ stats(values: OptionValues): unknown;
8
+ installSkill(values: OptionValues): unknown;
9
+ }
10
+
11
+ export function klintCommand(handlers: CommandHandlers): Group {
12
+ return {
13
+ kind: "group",
14
+ summary:
15
+ "Architecture-as-Code linter: layer boundaries, import rules and forbidden patterns",
16
+ aliases: { h: "help" },
17
+ defaultCommand: {
18
+ kind: "leaf",
19
+ summary: "Lints the project",
20
+ options: {
21
+ config: {
22
+ value: "<dir>",
23
+ description:
24
+ "Directory containing klint.yaml or klint.config.json (default: cwd)",
25
+ },
26
+ rules: {
27
+ value: "<file>",
28
+ description:
29
+ "Custom rules file (default: <configDir>/klint.rules.ts if present)",
30
+ },
31
+ engine: {
32
+ value: "<name>",
33
+ description:
34
+ "Engine to use: ts (default), rust, compare, or auto (experimental)",
35
+ },
36
+ fix: { description: "Apply auto-fixes for fixable violations in place" },
37
+ json: { description: "Emit structured JSON to stdout (for agent and CI use)" },
38
+ debug: {
39
+ aliases: ["-debug"],
40
+ description: "Print file resolution and rule progress to stderr",
41
+ },
42
+ hook: { description: "Count this run as an agent hook run in klint stats" },
43
+ version: { short: "V", description: "Print the installed klint version" },
44
+ },
45
+ run: handlers.lint,
46
+ },
47
+ commands: {
48
+ stats: {
49
+ kind: "leaf",
50
+ summary:
51
+ "Print runs, violations reported and errors prevented, in total and per rule",
52
+ options: {
53
+ config: {
54
+ value: "<dir>",
55
+ description: "Directory holding .klint/stats.json (default: cwd)",
56
+ },
57
+ },
58
+ details: () => ["Set KLINT_NO_STATS=1 to stop recording."],
59
+ run: handlers.stats,
60
+ },
61
+ "install-skill": {
62
+ kind: "leaf",
63
+ summary: "Install the rule-authoring skill into agent config directories",
64
+ options: {
65
+ agents: {
66
+ value: "<list>",
67
+ description: `Comma-separated: ${Object.keys(AGENT_SKILL_DIRS).join(",")} (default: all)`,
68
+ },
69
+ symlink: {
70
+ aliases: ["--shared"],
71
+ description:
72
+ "Keep one skill in .agents/skills and symlink the others to it (default)",
73
+ },
74
+ copy: { description: "Give every agent directory its own copy" },
75
+ force: { description: "Replace a skill directory klint did not install" },
76
+ },
77
+ details: () => [
78
+ "Asks which agents and how to store the skill when run on a terminal without",
79
+ "--agents, --symlink or --copy.",
80
+ "",
81
+ "Installed skills record the version and hash they came from. klint warns with",
82
+ "klint/skill-stale once the shipped skill moves on, and with",
83
+ "klint/skill-legacy-link for old symlinks that point into node_modules.",
84
+ ],
85
+ run: handlers.installSkill,
86
+ },
87
+ },
88
+ details: builtInRuleLines,
89
+ };
90
+ }
package/cli/help.ts CHANGED
@@ -1,52 +1,21 @@
1
1
  import { BUILT_IN_PLUGINS } from "../plugins/index";
2
2
  import { BUILT_IN_RULES } from "../rules/index";
3
3
 
4
- export function printHelp(): void {
4
+ export function builtInRuleLines(): string[] {
5
5
  const pluginRules = new Set(
6
6
  Object.values(BUILT_IN_PLUGINS).flatMap((p) => Object.keys(p.rules))
7
7
  );
8
8
  const standaloneRules = Object.keys(BUILT_IN_RULES).filter((r) => !pluginRules.has(r));
9
9
  const pluginEntries = Object.entries(BUILT_IN_PLUGINS);
10
10
 
11
- process.stdout.write(
12
- [
13
- "klint — agent harness for TypeScript architecture rules",
14
- "",
15
- "Usage: klint [--config <dir>] [--rules <file>] [--engine ts|rust|compare|auto] [--fix] [--json] [--hook]",
16
- " klint stats [--config <dir>]",
17
- " klint install-skill [--agents <list>] [--symlink | --copy] [--force]",
18
- "",
19
- " --config <dir> directory containing klint.yaml or klint.config.json (default: cwd)",
20
- " --rules <file> custom rules file (default: <configDir>/klint.rules.ts if present)",
21
- " --engine <name> engine to use: ts (default), rust, compare, or auto (experimental)",
22
- " --fix apply auto-fixes for fixable violations in-place",
23
- " --json emit structured JSON to stdout (for agent/CI consumption)",
24
- " --debug print file resolution and rule progress to stderr",
25
- " --hook count this run as an agent hook run in klint stats",
26
- "",
27
- " stats print runs and errors prevented, in total and per rule, from",
28
- " .klint/stats.json (set KLINT_NO_STATS=1 to stop recording)",
29
- "",
30
- " install-skill install the rule-authoring skill into agent config directories",
31
- " --agents <list> comma-separated: claude,opencode,cursor,codex,copilot (default: all)",
32
- " --symlink keep one skill in .agents/skills and symlink the",
33
- " other agents to it (default)",
34
- " --copy give every agent directory its own copy",
35
- " --force replace a skill directory klint did not install",
36
- "",
37
- " Installed skills record the version and hash they came from. klint warns with",
38
- " klint/skill-stale once the shipped skill moves on, and with",
39
- " klint/skill-legacy-link for old symlinks that point into node_modules.",
40
- "",
41
- `Built-in rules (${standaloneRules.length}):`,
42
- ...standaloneRules.map((r) => ` ${r}`),
43
- "",
44
- `Plugins (${pluginEntries.length}):`,
45
- ...pluginEntries.flatMap(([name, plugin]) => [
46
- ` ${name}`,
47
- ...Object.keys(plugin.rules).map((r) => ` ${r}`),
48
- ]),
49
- "",
50
- ].join("\n")
51
- );
11
+ return [
12
+ `Built-in rules (${standaloneRules.length}):`,
13
+ ...standaloneRules.map((r) => ` ${r}`),
14
+ "",
15
+ `Plugins (${pluginEntries.length}):`,
16
+ ...pluginEntries.flatMap(([name, plugin]) => [
17
+ ` ${name}`,
18
+ ...Object.keys(plugin.rules).map((r) => ` ${r}`),
19
+ ]),
20
+ ];
52
21
  }
@@ -13,6 +13,7 @@ import {
13
13
  type SkillReceipt,
14
14
  skillHash,
15
15
  } from "../core/skill";
16
+ import type { OptionValues } from "./command";
16
17
  import { installedVersion, PACKAGE_ROOT } from "./paths";
17
18
 
18
19
  const AGENT_TARGETS = [
@@ -29,14 +30,14 @@ interface InstallPlan {
29
30
  force: boolean;
30
31
  }
31
32
 
32
- export async function installSkill(args: string[]): Promise<void> {
33
+ export async function installSkill(values: OptionValues): Promise<void> {
33
34
  const skillSrc = join(PACKAGE_ROOT, "skill", SKILL_DIR_NAME);
34
35
  if (!existsSync(skillSrc)) {
35
36
  process.stderr.write(`klint: skill source not found at ${skillSrc}\n`);
36
37
  process.exit(1);
37
38
  }
38
39
 
39
- const plan = await resolvePlan(args);
40
+ const plan = await resolvePlan(values);
40
41
  const dirs = agentSkillDirs(plan.agents);
41
42
 
42
43
  try {
@@ -164,8 +165,8 @@ async function writeReceipt(dest: string, skillSrc: string): Promise<void> {
164
165
  await writeFile(join(dest, RECEIPT_FILE_NAME), `${JSON.stringify(receipt, null, 2)}\n`);
165
166
  }
166
167
 
167
- async function resolvePlan(args: string[]): Promise<InstallPlan> {
168
- const flags = parseFlags(args);
168
+ async function resolvePlan(values: OptionValues): Promise<InstallPlan> {
169
+ const flags = parseFlags(values);
169
170
  const decided = flags.agents !== undefined || flags.shared !== undefined;
170
171
 
171
172
  if (!process.stdin.isTTY || decided) {
@@ -184,27 +185,21 @@ async function resolvePlan(args: string[]): Promise<InstallPlan> {
184
185
  };
185
186
  }
186
187
 
187
- function parseFlags(args: string[]): {
188
+ function parseFlags(values: OptionValues): {
188
189
  agents?: AgentKey[];
189
190
  shared?: boolean;
190
191
  force: boolean;
191
192
  } {
192
- let agents: AgentKey[] | undefined;
193
- let shared: boolean | undefined;
194
- let force = false;
195
-
196
- for (let i = 0; i < args.length; i++) {
197
- if (args[i] === "--agents" && args[i + 1]) agents = parseAgents(args[++i]);
198
- else if (args[i] === "--symlink" || args[i] === "--shared") shared = true;
199
- else if (args[i] === "--copy") shared = false;
200
- else if (args[i] === "--force") force = true;
201
- else {
202
- process.stderr.write(`klint: unknown install-skill argument "${args[i]}"\n`);
203
- process.exit(1);
204
- }
205
- }
193
+ return {
194
+ agents: typeof values.agents === "string" ? parseAgents(values.agents) : undefined,
195
+ shared: sharedChoice(values),
196
+ force: values.force === true,
197
+ };
198
+ }
206
199
 
207
- return { agents, force, shared };
200
+ function sharedChoice(values: OptionValues): boolean | undefined {
201
+ if (values.copy) return false;
202
+ return values.symlink ? true : undefined;
208
203
  }
209
204
 
210
205
  function parseAgents(value: string): AgentKey[] {
@@ -6,6 +6,7 @@ import { BUILT_IN_PLUGINS } from "../plugins/index";
6
6
  import { PACKAGE_ROOT } from "./paths";
7
7
 
8
8
  const RUST_SUPPORTED_RULES = new Set([
9
+ "no-async-predicate",
9
10
  "no-consecutive-array-push",
10
11
  "no-nested-template-literals",
11
12
  "no-string-match",
package/cli/stats.ts CHANGED
@@ -1,34 +1,92 @@
1
1
  import { resolve } from "node:path";
2
2
  import { type KlintStats, readStats, STATS_DIR, STATS_FILE } from "../core/stats";
3
+ import type { OptionValues } from "./command";
3
4
 
4
- function rulesByCount(byRule: Record<string, number>): Array<[string, number]> {
5
+ const RULE_HEADING = "by rule";
6
+ const REPORTED_HEADING = "reported";
7
+ const PREVENTED_HEADING = "prevented";
8
+ const LABEL_WIDTH = "violations reported".length;
9
+
10
+ interface RuleRow {
11
+ rule: string;
12
+ reported: number;
13
+ prevented: number;
14
+ }
15
+
16
+ function ruleRows(stats: KlintStats): RuleRow[] {
17
+ const rules = new Set([
18
+ ...Object.keys(stats.reported.byRule),
19
+ ...Object.keys(stats.prevented.byRule),
20
+ ]);
5
21
  const byCodePoint = (a: string, b: string) => Number(a > b) - Number(a < b);
6
- return Object.entries(byRule).sort(([a, x], [b, y]) => y - x || byCodePoint(a, b));
22
+ return [...rules]
23
+ .map((rule) => ({
24
+ rule,
25
+ reported: stats.reported.byRule[rule] ?? 0,
26
+ prevented: stats.prevented.byRule[rule] ?? 0,
27
+ }))
28
+ .sort(
29
+ (a, b) =>
30
+ b.reported - a.reported ||
31
+ b.prevented - a.prevented ||
32
+ byCodePoint(a.rule, b.rule)
33
+ );
34
+ }
35
+
36
+ interface Summary {
37
+ label: string;
38
+ total: number;
39
+ detail: string;
40
+ }
41
+
42
+ function summaryLines(summaries: Summary[]): string[] {
43
+ const width = Math.max(...summaries.map(({ total }) => String(total).length));
44
+ return summaries.map(
45
+ ({ label, total, detail }) =>
46
+ ` ${label.padEnd(LABEL_WIDTH)} ${String(total).padStart(width)} (${detail})`
47
+ );
48
+ }
49
+
50
+ function ruleTable(rows: RuleRow[]): string[] {
51
+ const width = Math.max(RULE_HEADING.length - 2, ...rows.map(({ rule }) => rule.length));
52
+ const heading = ` ${RULE_HEADING.padEnd(width + 2)} ${REPORTED_HEADING} ${PREVENTED_HEADING}`;
53
+ const lines = rows.map(
54
+ ({ rule, reported, prevented }) =>
55
+ ` ${rule.padEnd(width)} ${String(reported).padStart(REPORTED_HEADING.length)} ${String(prevented).padStart(PREVENTED_HEADING.length)}`
56
+ );
57
+ return [heading, ...lines];
7
58
  }
8
59
 
9
60
  export function formatStats(stats: KlintStats): string {
10
- const runs = stats.runs.hook + stats.runs.direct;
11
- const prevented = stats.prevented.bySource.hook + stats.prevented.bySource.direct;
12
- const rules = rulesByCount(stats.prevented.byRule);
13
- const width = Math.max(0, ...rules.map(([rule]) => rule.length));
61
+ const { runs, reported, prevented } = stats;
14
62
  const lines = [
15
63
  `klint stats (${STATS_DIR}/${STATS_FILE})`,
16
64
  "",
17
- ` runs ${runs} (hook ${stats.runs.hook}, direct ${stats.runs.direct})`,
18
- ` errors prevented ${prevented} (caught by hook ${stats.prevented.bySource.hook}, direct ${stats.prevented.bySource.direct})`,
65
+ ...summaryLines([
66
+ {
67
+ label: "runs",
68
+ total: runs.hook + runs.direct,
69
+ detail: `hook ${runs.hook}, direct ${runs.direct}`,
70
+ },
71
+ {
72
+ label: "violations reported",
73
+ total: reported.bySource.hook + reported.bySource.direct,
74
+ detail: `hook ${reported.bySource.hook}, direct ${reported.bySource.direct}`,
75
+ },
76
+ {
77
+ label: "errors prevented",
78
+ total: prevented.bySource.hook + prevented.bySource.direct,
79
+ detail: `caught by hook ${prevented.bySource.hook}, direct ${prevented.bySource.direct}`,
80
+ },
81
+ ]),
19
82
  ];
20
- if (rules.length > 0) {
21
- lines.push("", " prevented by rule");
22
- for (const [rule, count] of rules) lines.push(` ${rule.padEnd(width)} ${count}`);
23
- }
83
+ const rows = ruleRows(stats);
84
+ if (rows.length > 0) lines.push("", ...ruleTable(rows));
24
85
  return `${lines.join("\n")}\n`;
25
86
  }
26
87
 
27
- export function printStats(args: string[]): void {
28
- const configFlag = args.indexOf("--config");
88
+ export function printStats(values: OptionValues): void {
29
89
  const configDir =
30
- configFlag !== -1 && args[configFlag + 1]
31
- ? resolve(args[configFlag + 1])
32
- : process.cwd();
90
+ typeof values.config === "string" ? resolve(values.config) : process.cwd();
33
91
  process.stdout.write(formatStats(readStats(configDir)));
34
92
  }
package/cli.ts CHANGED
@@ -3,7 +3,8 @@ import { existsSync } from "node:fs";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { resolve } from "node:path";
5
5
  import { parse as parseYaml } from "yaml";
6
- import { printHelp } from "./cli/help";
6
+ import { type OptionValues, runCommand } from "./cli/command";
7
+ import { klintCommand } from "./cli/commands";
7
8
  import { installSkill } from "./cli/install-skill";
8
9
  import {
9
10
  formatDuration,
@@ -22,60 +23,33 @@ import { skillAdvisories } from "./core/skill";
22
23
  import { runRecorder } from "./core/stats";
23
24
  import type { ArchConfig, KlintConfig, KlintRule, RuleConfigValue } from "./core/types";
24
25
 
25
- interface CliOptions {
26
- configDir?: string;
27
- rulesFile?: string;
28
- }
29
-
30
- export async function main(opts: CliOptions = {}): Promise<void> {
26
+ export async function main(): Promise<number> {
31
27
  const startedAt = performance.now();
32
- const args = process.argv.slice(2);
33
-
34
- if (args[0] === "help" || args[0] === "h") {
35
- printHelp();
36
- process.exit(0);
37
- }
28
+ const command = klintCommand({
29
+ lint: (values) => lint(values, startedAt),
30
+ stats: printStats,
31
+ installSkill,
32
+ });
33
+ return runCommand(command, process.argv.slice(2), ["klint"]);
34
+ }
38
35
 
39
- if (args[0] === "stats") {
40
- printStats(args.slice(1));
41
- return;
42
- }
36
+ function optionalPath(value: string | true | undefined): string | undefined {
37
+ return typeof value === "string" ? resolve(value) : undefined;
38
+ }
43
39
 
44
- const subcommand = args.indexOf("install-skill");
45
- if (subcommand !== -1) {
46
- await installSkill(args.slice(subcommand + 1));
40
+ async function lint(values: OptionValues, startedAt: number): Promise<void> {
41
+ if (values.version) {
42
+ process.stdout.write(`klint ${installedVersion()}\n`);
47
43
  return;
48
44
  }
49
-
50
- let configDir = opts.configDir;
51
- let rulesFile = opts.rulesFile;
52
- let fix = false;
53
- let json = false;
54
- let debug = false;
55
- let hook = false;
56
- let engine = process.env.KLINT_ENGINE;
57
-
58
- for (let i = 0; i < args.length; i++) {
59
- if (args[i] === "--config" && args[i + 1]) configDir = resolve(args[++i]);
60
- else if (args[i] === "--rules" && args[i + 1]) rulesFile = resolve(args[++i]);
61
- else if (args[i] === "--engine" && args[i + 1]) engine = args[++i];
62
- else if (args[i] === "--fix") fix = true;
63
- else if (args[i] === "--json") json = true;
64
- else if (args[i] === "--hook") hook = true;
65
- else if (args[i] === "--debug" || args[i] === "-debug") debug = true;
66
- else if (args[i] === "--help" || args[i] === "-h") {
67
- printHelp();
68
- process.exit(0);
69
- } else if (args[i] === "--version" || args[i] === "-V") {
70
- process.stdout.write(`klint ${installedVersion()}\n`);
71
- process.exit(0);
72
- } else {
73
- process.stderr.write(`klint: unknown argument "${args[i]}"\n`);
74
- process.exit(1);
75
- }
76
- }
77
-
78
- configDir ??= process.cwd();
45
+ const rulesFile = optionalPath(values.rules);
46
+ const fix = values.fix === true;
47
+ const json = values.json === true;
48
+ const debug = values.debug === true;
49
+ const hook = values.hook === true;
50
+ const engine =
51
+ typeof values.engine === "string" ? values.engine : process.env.KLINT_ENGINE;
52
+ const configDir = optionalPath(values.config) ?? process.cwd();
79
53
  const record = runRecorder(configDir, hook ? "hook" : "direct");
80
54
 
81
55
  const yamlPath = resolve(configDir, "klint.yaml");
@@ -237,4 +211,4 @@ export async function main(opts: CliOptions = {}): Promise<void> {
237
211
  writeTextOutput([...violations, ...advisories], startedAt);
238
212
  }
239
213
 
240
- if (import.meta.main) await main();
214
+ if (import.meta.main) process.exitCode = await main();