@konvert7/klint 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +286 -0
- package/cli.ts +306 -0
- package/core/arch.ts +238 -0
- package/core/ast.ts +63 -0
- package/core/config.schema.ts +87 -0
- package/core/fixer.ts +44 -0
- package/core/runner.ts +119 -0
- package/core/types.ts +92 -0
- package/package.json +78 -0
- package/plugins/index.ts +6 -0
- package/plugins/sonar.ts +29 -0
- package/rules/index.ts +26 -0
- package/rules/no-async-predicate.ts +72 -0
- package/rules/no-consecutive-array-push.ts +56 -0
- package/rules/no-date-equality.ts +55 -0
- package/rules/no-floating-promise.ts +58 -0
- package/rules/no-misused-promises.ts +82 -0
- package/rules/no-nested-template-literals.ts +42 -0
- package/rules/no-object-in-template.ts +119 -0
- package/rules/no-optional-chain-on-non-nullable.ts +68 -0
- package/rules/no-single-char-class.ts +118 -0
- package/rules/no-string-match.ts +58 -0
- package/rules/no-sync-in-async.ts +35 -0
- package/rules/no-unguarded-json-parse.ts +30 -0
- package/rules/prefer-at.ts +54 -0
- package/rules/prefer-nullish-coalescing-assign.ts +68 -0
- package/rules/prefer-string-raw-regexp.ts +88 -0
- package/rules/prefer-string-raw.ts +47 -0
- package/rules/prefer-string-replaceall.ts +76 -0
- package/skill/klint-rules/SKILL.md +112 -0
- package/tools/generate-schema.ts +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# klint
|
|
2
|
+
|
|
3
|
+
The bridge between vibe coding and agentic engineering.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
Biome and oxlint enforce syntax-level style. klint enforces architecture-level rules — the kind that require TypeScript's type graph, span multiple files, or encode constraints that an AI agent must not bypass. If a rule needs to know that `fetchUser()` returns `Promise<User>`, or that sync filesystem calls are banned inside async hooks, that's a klint rule.
|
|
8
|
+
|
|
9
|
+
Rules give your agent freedom. Without constraints, every decision is a risk. With klint, your agent knows exactly where it can move fast — and where it can't.
|
|
10
|
+
|
|
11
|
+
## Architecture as Code
|
|
12
|
+
|
|
13
|
+
klint's YAML config supports an `arch:` section that lets you define architectural rules declaratively — no code required.
|
|
14
|
+
|
|
15
|
+
> **AGENTS.md tells the model what to do. Klint ensures it actually did.**
|
|
16
|
+
>
|
|
17
|
+
> Instructions in a prompt are a contract with no enforcement. A model that's drifting, context-starved, or just wrong will violate AGENTS.md silently and ship anyway. Klint makes the violation structurally impossible to land — the gate blocks it regardless of what the model thought it understood.
|
|
18
|
+
|
|
19
|
+
### Layers
|
|
20
|
+
|
|
21
|
+
Define named file groups once, reference them everywhere:
|
|
22
|
+
|
|
23
|
+
```yaml
|
|
24
|
+
arch:
|
|
25
|
+
layers:
|
|
26
|
+
core: ["src/hooks/lib/**", "src/tools/**"]
|
|
27
|
+
skills: ["assets/skills/**"]
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Import boundaries
|
|
31
|
+
|
|
32
|
+
```yaml
|
|
33
|
+
arch:
|
|
34
|
+
imports:
|
|
35
|
+
# deny: block imports from one layer into another
|
|
36
|
+
- from: skills
|
|
37
|
+
deny: core
|
|
38
|
+
message: "Skills must be self-contained and portable"
|
|
39
|
+
severity: warn # optional, default: error
|
|
40
|
+
|
|
41
|
+
# allow: whitelist mode — anything not listed is denied (npm/node: builtins always pass)
|
|
42
|
+
- from: ["src/dao/**"]
|
|
43
|
+
allow: ["src/dao/**", "src/prisma/**", "src/types/**"]
|
|
44
|
+
message: "DAO may only import from dao, prisma, or types"
|
|
45
|
+
|
|
46
|
+
# type-only: allow — import type {} is permitted even when value imports are denied
|
|
47
|
+
- from: core
|
|
48
|
+
deny: ["src/targets/**"]
|
|
49
|
+
type-only: allow
|
|
50
|
+
message: "Core must not depend on agent-specific code"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Forbidden patterns
|
|
54
|
+
|
|
55
|
+
Block literal string patterns inside a scoped layer:
|
|
56
|
+
|
|
57
|
+
```yaml
|
|
58
|
+
arch:
|
|
59
|
+
forbidden:
|
|
60
|
+
- pattern: "console.log("
|
|
61
|
+
in: core
|
|
62
|
+
message: "Leaks into the agent event stream — use the hook output API instead"
|
|
63
|
+
|
|
64
|
+
- pattern: "process.exit("
|
|
65
|
+
in: ["src/hooks/lib/**"]
|
|
66
|
+
message: "Library functions should return or throw, not terminate the process"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Singleton locations
|
|
70
|
+
|
|
71
|
+
Enforce that a pattern appears only in one designated file:
|
|
72
|
+
|
|
73
|
+
```yaml
|
|
74
|
+
arch:
|
|
75
|
+
singleton:
|
|
76
|
+
- pattern: "process.env.PAL_HOME"
|
|
77
|
+
only: "src/hooks/lib/paths.ts"
|
|
78
|
+
in: ["src/**"] # optional: limit scan scope
|
|
79
|
+
message: "Use the paths module"
|
|
80
|
+
|
|
81
|
+
- pattern: "process.env.API_KEY"
|
|
82
|
+
only: "src/lib/auth.ts"
|
|
83
|
+
message: "Use the auth module"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Agent integration
|
|
87
|
+
|
|
88
|
+
Wire `--json` into your Stop hook so violations are machine-readable:
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
// .agents/hooks/klint.ts
|
|
92
|
+
import { runHook } from "./run-hook";
|
|
93
|
+
const exitCode = runHook(["bun", "klint/cli.ts", "--json"]);
|
|
94
|
+
process.exit(exitCode);
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
On errors the hook exits 2 (blocking) and emits:
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
{
|
|
101
|
+
"violations": [
|
|
102
|
+
{
|
|
103
|
+
"rule": "arch/imports",
|
|
104
|
+
"file": "assets/skills/telos/tools/update-telos.ts",
|
|
105
|
+
"line": 23,
|
|
106
|
+
"severity": "warn",
|
|
107
|
+
"message": "Skills must be self-contained and portable",
|
|
108
|
+
"fix": null
|
|
109
|
+
}
|
|
110
|
+
],
|
|
111
|
+
"summary": { "errors": 0, "warnings": 1 }
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The agent reads the structured violations and fixes them before the session can close.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Usage
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
bun klint/cli.ts [--config <dir>] [--rules <file>] [--fix] [--json]
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
| Flag | Description |
|
|
126
|
+
|------|-------------|
|
|
127
|
+
| `--config <dir>` | Directory containing `klint.yaml` or `klint.config.json` (default: cwd) |
|
|
128
|
+
| `--rules <file>` | Path to custom rules file (default: auto-discovered — see below) |
|
|
129
|
+
| `--fix` | Apply auto-fixes for fixable violations in-place |
|
|
130
|
+
| `--json` | Emit structured JSON to stdout (for agent/CI consumption) |
|
|
131
|
+
|
|
132
|
+
If `--rules` is omitted, klint looks for `klint.rules.ts` next to the config file. If it exists it is loaded automatically; if it doesn't, no custom rules are used.
|
|
133
|
+
|
|
134
|
+
## Configuration
|
|
135
|
+
|
|
136
|
+
**`klint.yaml`** — lives at your project root alongside `biome.json` and `knip.json`:
|
|
137
|
+
|
|
138
|
+
```yaml
|
|
139
|
+
# yaml-language-server: $schema=./klint.schema.yaml
|
|
140
|
+
|
|
141
|
+
include: ["src", "klint", "!**/node_modules/**"]
|
|
142
|
+
plugins: [sonar]
|
|
143
|
+
rules:
|
|
144
|
+
no-unguarded-json-parse: error
|
|
145
|
+
no-sync-in-async:
|
|
146
|
+
severity: error
|
|
147
|
+
include: ["src/hooks/**"]
|
|
148
|
+
no-floating-promise: error
|
|
149
|
+
my-custom-rule: warn
|
|
150
|
+
|
|
151
|
+
arch:
|
|
152
|
+
layers:
|
|
153
|
+
core: ["src/hooks/lib/**", "src/tools/**"]
|
|
154
|
+
imports:
|
|
155
|
+
- from: ["assets/skills/**"]
|
|
156
|
+
deny: ["src/**"]
|
|
157
|
+
message: "Skills must be self-contained"
|
|
158
|
+
severity: warn
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`include` — glob patterns selecting which `.ts` files to lint.
|
|
162
|
+
`plugins` — named rule bundles (`"sonar"`) that apply a default set of rules.
|
|
163
|
+
`rules` — map of rule name → `"error" | "warn" | "off"` or an options object with `severity` and/or `include`.
|
|
164
|
+
`arch` — declarative architecture constraints (see Architecture as Code above).
|
|
165
|
+
|
|
166
|
+
A `klint.config.json` fallback is supported for backwards compatibility.
|
|
167
|
+
|
|
168
|
+
## Built-in Rules
|
|
169
|
+
|
|
170
|
+
| Rule | Type-aware | Description |
|
|
171
|
+
|------|-----------|-------------|
|
|
172
|
+
| `no-unguarded-json-parse` | No | `JSON.parse()` called outside a try/catch |
|
|
173
|
+
| `no-sync-in-async` | No | Sync filesystem calls (`readFileSync` etc.) inside async functions |
|
|
174
|
+
| `no-floating-promise` | **Yes** | Promise-returning call whose result is discarded |
|
|
175
|
+
| `no-misused-promises` | **Yes** | Async function passed where a sync callback is expected |
|
|
176
|
+
|
|
177
|
+
## Custom Rules
|
|
178
|
+
|
|
179
|
+
Create `klint.rules.ts` at your project root and export a `Record<string, KlintRule>` as default. Each key is the rule name:
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
import { relative } from "node:path";
|
|
183
|
+
import type { KlintRule } from "./klint/core/types";
|
|
184
|
+
|
|
185
|
+
const myCustomRule: KlintRule = {
|
|
186
|
+
check({ files, root, fileContents }, violations) {
|
|
187
|
+
for (const file of files) {
|
|
188
|
+
const lines = (fileContents.get(file) ?? "").split("\n");
|
|
189
|
+
for (let i = 0; i < lines.length; i++) {
|
|
190
|
+
if (/forbidden-pattern/.test(lines[i])) {
|
|
191
|
+
violations.push({
|
|
192
|
+
file: relative(root, file),
|
|
193
|
+
line: i + 1,
|
|
194
|
+
message: "Explain what's wrong and how to fix it.",
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export default {
|
|
203
|
+
"my-custom-rule": myCustomRule,
|
|
204
|
+
};
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
All exported rules run at `"error"` severity by default. Override severity or scope them via `rules` in `klint.yaml` — the same mechanism as built-in rules:
|
|
208
|
+
|
|
209
|
+
```yaml
|
|
210
|
+
rules:
|
|
211
|
+
my-custom-rule: warn
|
|
212
|
+
my-scoped-rule:
|
|
213
|
+
severity: error
|
|
214
|
+
include: ["src/hooks/**"]
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
No separate registration step — everything exported from `klint.rules.ts` is picked up automatically.
|
|
218
|
+
|
|
219
|
+
### Auto-fix support
|
|
220
|
+
|
|
221
|
+
Add a `fix` field to a violation to make it auto-fixable with `--fix`. The fix replaces a line range with new text:
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
violations.push({
|
|
225
|
+
file: relative(root, file),
|
|
226
|
+
line: i + 1,
|
|
227
|
+
message: "Use foo() instead of bar().",
|
|
228
|
+
fix: {
|
|
229
|
+
startLine: i + 1,
|
|
230
|
+
endLine: i + 1,
|
|
231
|
+
replacement: lines[i].replace("bar()", "foo()"),
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### Type-aware rules
|
|
237
|
+
|
|
238
|
+
For rules that need TypeScript's type checker, use `walkAst` from `klint/core/ast`:
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
import ts from "typescript";
|
|
242
|
+
import { walkAst } from "./klint/core/ast";
|
|
243
|
+
|
|
244
|
+
const myTypeAwareRule: KlintRule = {
|
|
245
|
+
check({ files, root, fileContents }, violations) {
|
|
246
|
+
for (const file of files) {
|
|
247
|
+
const content = fileContents.get(file) ?? "";
|
|
248
|
+
walkAst(file, content, (node, src) => {
|
|
249
|
+
if (ts.isCallExpression(node)) {
|
|
250
|
+
// inspect node using the TypeScript AST
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
## Scoped includes
|
|
259
|
+
|
|
260
|
+
Any rule can be restricted to a file subset via the `include` option. Patterns support `**` globs and negation with `!`:
|
|
261
|
+
|
|
262
|
+
```yaml
|
|
263
|
+
no-sync-in-async:
|
|
264
|
+
severity: error
|
|
265
|
+
include: ["src/hooks/**", "!src/hooks/scripts/**"]
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
## Architecture
|
|
269
|
+
|
|
270
|
+
```
|
|
271
|
+
klint/
|
|
272
|
+
cli.ts — CLI entry point; discovers config + rules, reports violations
|
|
273
|
+
core/
|
|
274
|
+
types.ts — KlintRule, KlintConfig, ArchConfig, Violation, RuleEntry
|
|
275
|
+
runner.ts — runKlint(); resolves files, dispatches rules, calls arch engine
|
|
276
|
+
arch.ts — runArchRules(); AST import scanner, layers/imports/forbidden/singleton
|
|
277
|
+
ast.ts — walkAst(), createProgram(), nearestFunctionIsAsync(), isInsideTry()
|
|
278
|
+
fixer.ts — applyFixes(); bottom-up line-range patch with overlap detection
|
|
279
|
+
rules/
|
|
280
|
+
index.ts — BUILT_IN_RULES registry
|
|
281
|
+
...
|
|
282
|
+
tests/
|
|
283
|
+
...
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
The `klint/` directory is intentionally decoupled from the rest of the codebase — no imports cross the boundary in either direction. When it has enough rules, it ships as a standalone package.
|
package/cli.ts
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import {
|
|
3
|
+
cpSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
symlinkSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
11
|
+
import * as clack from "@clack/prompts";
|
|
12
|
+
import { parse as parseYaml } from "yaml";
|
|
13
|
+
import { applyFixes } from "./core/fixer";
|
|
14
|
+
import { runKlint } from "./core/runner";
|
|
15
|
+
import type { ArchConfig, KlintConfig, KlintRule, RuleConfigValue } from "./core/types";
|
|
16
|
+
import { BUILT_IN_PLUGINS } from "./plugins/index";
|
|
17
|
+
import { BUILT_IN_RULES } from "./rules/index";
|
|
18
|
+
|
|
19
|
+
interface CliOptions {
|
|
20
|
+
configDir?: string;
|
|
21
|
+
rulesFile?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function main(opts: CliOptions = {}): Promise<void> {
|
|
25
|
+
const args = process.argv.slice(2);
|
|
26
|
+
|
|
27
|
+
if (args[0] === "install-skill") {
|
|
28
|
+
await installSkill(args.slice(1));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let configDir = opts.configDir;
|
|
33
|
+
let rulesFile = opts.rulesFile;
|
|
34
|
+
let fix = false;
|
|
35
|
+
let json = false;
|
|
36
|
+
|
|
37
|
+
for (let i = 0; i < args.length; i++) {
|
|
38
|
+
if (args[i] === "--config" && args[i + 1]) configDir = resolve(args[++i]);
|
|
39
|
+
else if (args[i] === "--rules" && args[i + 1]) rulesFile = resolve(args[++i]);
|
|
40
|
+
else if (args[i] === "--fix") fix = true;
|
|
41
|
+
else if (args[i] === "--json") json = true;
|
|
42
|
+
else if (args[i] === "--help" || args[i] === "-h") {
|
|
43
|
+
printHelp();
|
|
44
|
+
process.exit(0);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
configDir ??= process.cwd();
|
|
49
|
+
|
|
50
|
+
const yamlPath = resolve(configDir, "klint.yaml");
|
|
51
|
+
const jsonPath = resolve(configDir, "klint.config.json");
|
|
52
|
+
const usingYaml = existsSync(yamlPath);
|
|
53
|
+
const configPath = usingYaml ? yamlPath : jsonPath;
|
|
54
|
+
|
|
55
|
+
if (!existsSync(configPath)) {
|
|
56
|
+
process.stderr.write(
|
|
57
|
+
`klint: no config file found — create klint.yaml (or klint.config.json) at ${configDir}\n`
|
|
58
|
+
);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface RawConfig {
|
|
63
|
+
root?: string;
|
|
64
|
+
include?: string[];
|
|
65
|
+
plugins?: string[];
|
|
66
|
+
rules?: Record<string, RuleConfigValue>;
|
|
67
|
+
arch?: unknown;
|
|
68
|
+
}
|
|
69
|
+
let raw: RawConfig;
|
|
70
|
+
try {
|
|
71
|
+
const text = readFileSync(configPath, "utf-8");
|
|
72
|
+
raw = (usingYaml ? parseYaml(text) : JSON.parse(text)) as RawConfig;
|
|
73
|
+
} catch {
|
|
74
|
+
process.stderr.write(`klint: failed to parse ${configPath}\n`);
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
const root = resolve(configDir, raw.root ?? ".");
|
|
78
|
+
|
|
79
|
+
let customRules: Record<string, KlintRule> = {};
|
|
80
|
+
const defaultRulesPath = resolve(configDir, "klint.rules.ts");
|
|
81
|
+
const rulesPath =
|
|
82
|
+
rulesFile ?? (existsSync(defaultRulesPath) ? defaultRulesPath : undefined);
|
|
83
|
+
if (rulesPath) {
|
|
84
|
+
const mod = await import(rulesPath);
|
|
85
|
+
customRules = (mod.default ?? {}) as Record<string, KlintRule>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const customRulesMap: Record<string, RuleConfigValue> = Object.fromEntries(
|
|
89
|
+
Object.keys(customRules).map((name) => [name, "error" as const])
|
|
90
|
+
);
|
|
91
|
+
const allRules: KlintConfig["rules"] = { ...customRulesMap, ...(raw.rules ?? {}) };
|
|
92
|
+
|
|
93
|
+
const violations = runKlint(
|
|
94
|
+
{
|
|
95
|
+
root,
|
|
96
|
+
include: raw.include ?? ["."],
|
|
97
|
+
plugins: raw.plugins,
|
|
98
|
+
rules: allRules,
|
|
99
|
+
arch: raw.arch as ArchConfig | undefined,
|
|
100
|
+
},
|
|
101
|
+
customRules
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
if (json) {
|
|
105
|
+
const errors = violations.filter((v) => v.severity === "error");
|
|
106
|
+
process.stdout.write(
|
|
107
|
+
JSON.stringify({
|
|
108
|
+
violations: violations.map((v) => ({ ...v, fix: v.fix ?? null })),
|
|
109
|
+
summary: { errors: errors.length, warnings: violations.length - errors.length },
|
|
110
|
+
})
|
|
111
|
+
);
|
|
112
|
+
process.exit(errors.length > 0 ? 2 : 0);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (fix) {
|
|
116
|
+
let totalApplied = 0;
|
|
117
|
+
let current = violations;
|
|
118
|
+
while (true) {
|
|
119
|
+
const applied = applyFixes(current, root);
|
|
120
|
+
totalApplied += applied;
|
|
121
|
+
if (applied === 0) break;
|
|
122
|
+
current = runKlint(
|
|
123
|
+
{
|
|
124
|
+
root,
|
|
125
|
+
include: raw.include ?? ["."],
|
|
126
|
+
plugins: raw.plugins,
|
|
127
|
+
rules: allRules,
|
|
128
|
+
arch: raw.arch as ArchConfig | undefined,
|
|
129
|
+
},
|
|
130
|
+
customRules
|
|
131
|
+
);
|
|
132
|
+
if (current.every((v) => !v.fix)) break;
|
|
133
|
+
}
|
|
134
|
+
const unfixed = current.filter((v) => !v.fix).length;
|
|
135
|
+
const msg =
|
|
136
|
+
unfixed > 0
|
|
137
|
+
? `klint: applied ${totalApplied} fix(es). ${unfixed} violation(s) require manual attention.`
|
|
138
|
+
: `klint: applied ${totalApplied} fix(es). No remaining violations.`;
|
|
139
|
+
process.stdout.write(JSON.stringify({ output: msg }));
|
|
140
|
+
process.exit(0);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const errors = violations.filter((v) => v.severity === "error");
|
|
144
|
+
const warns = violations.filter((v) => v.severity === "warn");
|
|
145
|
+
|
|
146
|
+
if (errors.length === 0 && warns.length === 0) {
|
|
147
|
+
process.stdout.write(JSON.stringify({ output: "klint: 0 violations" }));
|
|
148
|
+
process.exit(0);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const formatBlock = (v: (typeof violations)[number]) => {
|
|
152
|
+
const prefix = v.severity === "warn" ? "⚠" : "×";
|
|
153
|
+
const header = `${v.file}:${v.line} [${v.rule}]`;
|
|
154
|
+
const sep = "━".repeat(Math.max(0, 80 - header.length));
|
|
155
|
+
return `${header} ${sep}\n\n ${prefix} ${v.message}\n`;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
if (warns.length > 0) {
|
|
159
|
+
process.stderr.write(
|
|
160
|
+
`klint: ${warns.length} warning(s)\n\n${warns.map(formatBlock).join("\n")}`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
if (errors.length > 0) {
|
|
164
|
+
process.stderr.write(
|
|
165
|
+
`klint: ${errors.length} error(s)\n\n${errors.map(formatBlock).join("\n")}`
|
|
166
|
+
);
|
|
167
|
+
process.exit(2);
|
|
168
|
+
}
|
|
169
|
+
process.exit(0);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const AGENT_TARGETS = [
|
|
173
|
+
{ value: "claude", label: "Claude Code" },
|
|
174
|
+
{ value: "opencode", label: "opencode" },
|
|
175
|
+
{ value: "cursor", label: "Cursor" },
|
|
176
|
+
{ value: "codex", label: "Codex" },
|
|
177
|
+
] as const;
|
|
178
|
+
|
|
179
|
+
type AgentKey = (typeof AGENT_TARGETS)[number]["value"];
|
|
180
|
+
|
|
181
|
+
const AGENT_DIRS: Record<AgentKey, string> = {
|
|
182
|
+
claude: ".claude/skills",
|
|
183
|
+
opencode: ".agents/skills",
|
|
184
|
+
cursor: ".cursor/skills",
|
|
185
|
+
codex: ".agents/skills",
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
async function installSkill(args: string[]): Promise<void> {
|
|
189
|
+
const skillSrc = join(import.meta.dir, "skill", "klint-rules");
|
|
190
|
+
if (!existsSync(skillSrc)) {
|
|
191
|
+
process.stderr.write(`klint: skill source not found at ${skillSrc}\n`);
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Parse non-interactive flags
|
|
196
|
+
let flagAgents: AgentKey[] | undefined;
|
|
197
|
+
let flagSymlink: boolean | undefined;
|
|
198
|
+
for (let i = 0; i < args.length; i++) {
|
|
199
|
+
if (args[i] === "--agents" && args[i + 1])
|
|
200
|
+
flagAgents = args[++i].split(",") as AgentKey[];
|
|
201
|
+
else if (args[i] === "--symlink") flagSymlink = true;
|
|
202
|
+
else if (args[i] === "--copy") flagSymlink = false;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let selectedAgents: AgentKey[];
|
|
206
|
+
let useSymlink: boolean;
|
|
207
|
+
|
|
208
|
+
if (!process.stdin.isTTY || flagAgents !== undefined || flagSymlink !== undefined) {
|
|
209
|
+
selectedAgents = flagAgents ?? (AGENT_TARGETS.map((a) => a.value) as AgentKey[]);
|
|
210
|
+
useSymlink = flagSymlink ?? false;
|
|
211
|
+
} else {
|
|
212
|
+
clack.intro("klint install-skill");
|
|
213
|
+
|
|
214
|
+
const agents = await clack.multiselect<AgentKey>({
|
|
215
|
+
message: "Which agents should the skill be installed for?",
|
|
216
|
+
options: AGENT_TARGETS.map((a) => ({ value: a.value, label: a.label })),
|
|
217
|
+
initialValues: AGENT_TARGETS.map((a) => a.value) as AgentKey[],
|
|
218
|
+
});
|
|
219
|
+
if (clack.isCancel(agents)) {
|
|
220
|
+
clack.cancel("Cancelled.");
|
|
221
|
+
process.exit(0);
|
|
222
|
+
}
|
|
223
|
+
selectedAgents = agents as AgentKey[];
|
|
224
|
+
|
|
225
|
+
const mode = await clack.select<"symlink" | "copy">({
|
|
226
|
+
message: "Install as symlink or copy?",
|
|
227
|
+
options: [
|
|
228
|
+
{
|
|
229
|
+
value: "symlink",
|
|
230
|
+
label: "Symlink",
|
|
231
|
+
hint: "stays in sync when klint updates",
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
value: "copy",
|
|
235
|
+
label: "Copy",
|
|
236
|
+
hint: "one-time snapshot, no ongoing dependency",
|
|
237
|
+
},
|
|
238
|
+
],
|
|
239
|
+
});
|
|
240
|
+
if (clack.isCancel(mode)) {
|
|
241
|
+
clack.cancel("Cancelled.");
|
|
242
|
+
process.exit(0);
|
|
243
|
+
}
|
|
244
|
+
useSymlink = mode === "symlink";
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const cwd = process.cwd();
|
|
248
|
+
const linkType = process.platform === "win32" ? "junction" : "dir";
|
|
249
|
+
for (const key of selectedAgents) {
|
|
250
|
+
const dest = resolve(cwd, AGENT_DIRS[key], "klint-rules");
|
|
251
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
252
|
+
try {
|
|
253
|
+
rmSync(dest, { recursive: true, force: true });
|
|
254
|
+
} catch {
|
|
255
|
+
/* already gone */
|
|
256
|
+
}
|
|
257
|
+
if (useSymlink) {
|
|
258
|
+
symlinkSync(relative(dirname(dest), skillSrc), dest, linkType);
|
|
259
|
+
} else {
|
|
260
|
+
cpSync(skillSrc, dest, { recursive: true });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (process.stdin.isTTY) {
|
|
265
|
+
clack.outro("Done.");
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function printHelp(): void {
|
|
270
|
+
const pluginRules = new Set(
|
|
271
|
+
Object.values(BUILT_IN_PLUGINS).flatMap((p) => Object.keys(p.rules))
|
|
272
|
+
);
|
|
273
|
+
const standaloneRules = Object.keys(BUILT_IN_RULES).filter((r) => !pluginRules.has(r));
|
|
274
|
+
const pluginEntries = Object.entries(BUILT_IN_PLUGINS);
|
|
275
|
+
|
|
276
|
+
process.stdout.write(
|
|
277
|
+
[
|
|
278
|
+
"klint — agent harness for TypeScript architecture rules",
|
|
279
|
+
"",
|
|
280
|
+
"Usage: klint [--config <dir>] [--rules <file>] [--fix] [--json]",
|
|
281
|
+
" klint install-skill [--out <path>]",
|
|
282
|
+
"",
|
|
283
|
+
" --config <dir> directory containing klint.yaml or klint.config.json (default: cwd)",
|
|
284
|
+
" --rules <file> custom rules file (default: <configDir>/klint.rules.ts if present)",
|
|
285
|
+
" --fix apply auto-fixes for fixable violations in-place",
|
|
286
|
+
" --json emit structured JSON to stdout (for agent/CI consumption)",
|
|
287
|
+
"",
|
|
288
|
+
" install-skill install the rule-authoring skill into agent config directories",
|
|
289
|
+
" --agents <list> comma-separated: claude,opencode,cursor,codex (default: all)",
|
|
290
|
+
" --symlink install as symlink (stays in sync with updates)",
|
|
291
|
+
" --copy install as copy (default in non-TTY)",
|
|
292
|
+
"",
|
|
293
|
+
`Built-in rules (${standaloneRules.length}):`,
|
|
294
|
+
...standaloneRules.map((r) => ` ${r}`),
|
|
295
|
+
"",
|
|
296
|
+
`Plugins (${pluginEntries.length}):`,
|
|
297
|
+
...pluginEntries.flatMap(([name, plugin]) => [
|
|
298
|
+
` ${name}`,
|
|
299
|
+
...Object.keys(plugin.rules).map((r) => ` ${r}`),
|
|
300
|
+
]),
|
|
301
|
+
"",
|
|
302
|
+
].join("\n")
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (import.meta.main) await main();
|