@konvert7/klint 0.33.0 → 0.33.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.
- package/README.md +38 -2
- package/cli/help.ts +9 -3
- package/cli/install-skill.ts +236 -74
- package/cli/paths.ts +17 -0
- package/cli.ts +23 -19
- package/core/skill.ts +163 -0
- package/klint.schema.json +1 -1
- package/klint.schema.yaml +1 -1
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -108,7 +108,7 @@ Rules give agents room to move quickly because the unsafe moves are blocked stru
|
|
|
108
108
|
|
|
109
109
|
```sh
|
|
110
110
|
klint [--config <dir>] [--rules <file>] [--engine ts|rust|compare|auto] [--fix] [--json]
|
|
111
|
-
klint install-skill [--agents <list>] [--symlink | --copy]
|
|
111
|
+
klint install-skill [--agents <list>] [--symlink | --copy] [--force]
|
|
112
112
|
```
|
|
113
113
|
|
|
114
114
|
| Flag | Description |
|
|
@@ -122,9 +122,45 @@ klint install-skill [--agents <list>] [--symlink | --copy]
|
|
|
122
122
|
`klint install-skill` installs the bundled rule-authoring skill for agent environments:
|
|
123
123
|
|
|
124
124
|
```sh
|
|
125
|
-
klint install-skill
|
|
125
|
+
klint install-skill
|
|
126
126
|
```
|
|
127
127
|
|
|
128
|
+
The skill is embedded in the native engine, so this works from every distribution — the npm package, the PyPI wheel, the NuGet tool, and the standalone binary — not just Node projects.
|
|
129
|
+
|
|
130
|
+
### Shared layout (default)
|
|
131
|
+
|
|
132
|
+
By default the skill lives in exactly one place and every other agent directory points at it:
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
.agents/skills/klint-rules/ real skill + .klint-skill.json receipt
|
|
136
|
+
.claude/skills/klint-rules -> ../../.agents/skills/klint-rules
|
|
137
|
+
.cursor/skills/klint-rules -> ../../.agents/skills/klint-rules
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`.agents/skills` is where opencode and Codex already read from, so it doubles as the hub. One source of truth means one file to diff, one receipt, and one warning when it drifts. The links are relative and stay inside the repo — nothing points into `node_modules`, so reinstalling dependencies can never break them.
|
|
141
|
+
|
|
142
|
+
Pass `--copy` instead to give every agent directory its own independent copy and receipt.
|
|
143
|
+
|
|
144
|
+
### Staleness and legacy links
|
|
145
|
+
|
|
146
|
+
Each installed skill carries a `.klint-skill.json` receipt recording the klint version and the SHA-256 of the skill it came from. Every later run compares that hash against the skill the installed klint ships:
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
.agents/skills/klint-rules/SKILL.md:1 warn klint/skill-stale
|
|
150
|
+
this klint-rules skill was installed from klint 0.32.0 and no longer matches klint 0.33.0 — reinstall it with: klint install-skill
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
In the shared layout this fires once, not once per symlink. Hand-copied or edited skills have no receipt, so they are never flagged — and `install-skill` refuses to overwrite them unless you pass `--force`.
|
|
154
|
+
|
|
155
|
+
Earlier versions symlinked into `node_modules/@konvert7/klint`, which breaks the moment dependencies are reinstalled. klint now reports those:
|
|
156
|
+
|
|
157
|
+
```
|
|
158
|
+
.claude/skills/klint-rules:1 warn klint/skill-legacy-link
|
|
159
|
+
this skill is a symlink into node_modules (../../node_modules/@konvert7/klint/skill/klint-rules), which breaks as soon as dependencies are reinstalled — run: klint install-skill
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Running `klint install-skill` replaces those links with the shared layout, including links whose target is already gone. No `--force` needed — a symlink holds no content to lose.
|
|
163
|
+
|
|
128
164
|
## Engines
|
|
129
165
|
|
|
130
166
|
klint currently ships a TypeScript engine and an experimental Rust engine. The Rust path is built for portable architecture checks and syntax-local rules; type-aware checks stay in TypeScript for now.
|
package/cli/help.ts
CHANGED
|
@@ -13,7 +13,7 @@ export function printHelp(): void {
|
|
|
13
13
|
"klint — agent harness for TypeScript architecture rules",
|
|
14
14
|
"",
|
|
15
15
|
"Usage: klint [--config <dir>] [--rules <file>] [--engine ts|rust|compare|auto] [--fix] [--json]",
|
|
16
|
-
" klint install-skill [--agents <list>] [--symlink | --copy]",
|
|
16
|
+
" klint install-skill [--agents <list>] [--symlink | --copy] [--force]",
|
|
17
17
|
"",
|
|
18
18
|
" --config <dir> directory containing klint.yaml or klint.config.json (default: cwd)",
|
|
19
19
|
" --rules <file> custom rules file (default: <configDir>/klint.rules.ts if present)",
|
|
@@ -24,8 +24,14 @@ export function printHelp(): void {
|
|
|
24
24
|
"",
|
|
25
25
|
" install-skill install the rule-authoring skill into agent config directories",
|
|
26
26
|
" --agents <list> comma-separated: claude,opencode,cursor,codex (default: all)",
|
|
27
|
-
" --symlink
|
|
28
|
-
"
|
|
27
|
+
" --symlink keep one skill in .agents/skills and symlink the",
|
|
28
|
+
" other agents to it (default)",
|
|
29
|
+
" --copy give every agent directory its own copy",
|
|
30
|
+
" --force replace a skill directory klint did not install",
|
|
31
|
+
"",
|
|
32
|
+
" Installed skills record the version and hash they came from. klint warns with",
|
|
33
|
+
" klint/skill-stale once the shipped skill moves on, and with",
|
|
34
|
+
" klint/skill-legacy-link for old symlinks that point into node_modules.",
|
|
29
35
|
"",
|
|
30
36
|
`Built-in rules (${standaloneRules.length}):`,
|
|
31
37
|
...standaloneRules.map((r) => ` ${r}`),
|
package/cli/install-skill.ts
CHANGED
|
@@ -1,102 +1,264 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { cp, mkdir, rm, symlink } from "node:fs/promises";
|
|
2
|
+
import { cp, lstat, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
|
3
3
|
import { dirname, join, relative, resolve } from "node:path";
|
|
4
4
|
import * as clack from "@clack/prompts";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
AGENT_SKILL_DIRS,
|
|
7
|
+
type AgentKey,
|
|
8
|
+
agentSkillDirs,
|
|
9
|
+
CANONICAL_SKILL_DIR,
|
|
10
|
+
RECEIPT_FILE_NAME,
|
|
11
|
+
SKILL_DIR_NAME,
|
|
12
|
+
SKILL_FILE_NAME,
|
|
13
|
+
type SkillReceipt,
|
|
14
|
+
skillHash,
|
|
15
|
+
} from "../core/skill";
|
|
16
|
+
import { installedVersion, PACKAGE_ROOT } from "./paths";
|
|
6
17
|
|
|
7
18
|
const AGENT_TARGETS = [
|
|
8
19
|
{ value: "claude", label: "Claude Code" },
|
|
9
20
|
{ value: "opencode", label: "opencode" },
|
|
10
21
|
{ value: "cursor", label: "Cursor" },
|
|
11
22
|
{ value: "codex", label: "Codex" },
|
|
12
|
-
] as const;
|
|
23
|
+
] as const satisfies ReadonlyArray<{ value: AgentKey; label: string }>;
|
|
13
24
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
cursor: ".cursor/skills",
|
|
20
|
-
codex: ".agents/skills",
|
|
21
|
-
};
|
|
25
|
+
interface InstallPlan {
|
|
26
|
+
agents: AgentKey[];
|
|
27
|
+
shared: boolean;
|
|
28
|
+
force: boolean;
|
|
29
|
+
}
|
|
22
30
|
|
|
23
31
|
export async function installSkill(args: string[]): Promise<void> {
|
|
24
|
-
const skillSrc = join(PACKAGE_ROOT, "skill",
|
|
32
|
+
const skillSrc = join(PACKAGE_ROOT, "skill", SKILL_DIR_NAME);
|
|
25
33
|
if (!existsSync(skillSrc)) {
|
|
26
34
|
process.stderr.write(`klint: skill source not found at ${skillSrc}\n`);
|
|
27
35
|
process.exit(1);
|
|
28
36
|
}
|
|
29
37
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
const plan = await resolvePlan(args);
|
|
39
|
+
const dirs = agentSkillDirs(plan.agents);
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const report = plan.shared
|
|
43
|
+
? await installShared({ dirs, force: plan.force, skillSrc })
|
|
44
|
+
: await installCopies({ dirs, force: plan.force, skillSrc });
|
|
45
|
+
for (const line of report) process.stdout.write(`klint: ${line}\n`);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
process.stderr.write(`${(error as Error).message}\n`);
|
|
48
|
+
process.exit(1);
|
|
38
49
|
}
|
|
39
50
|
|
|
40
|
-
|
|
41
|
-
|
|
51
|
+
if (process.stdin.isTTY) {
|
|
52
|
+
clack.outro("Done.");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
42
55
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
56
|
+
async function installShared({
|
|
57
|
+
dirs,
|
|
58
|
+
force,
|
|
59
|
+
skillSrc,
|
|
60
|
+
}: {
|
|
61
|
+
dirs: string[];
|
|
62
|
+
force: boolean;
|
|
63
|
+
skillSrc: string;
|
|
64
|
+
}): Promise<string[]> {
|
|
65
|
+
const hub = join(CANONICAL_SKILL_DIR, SKILL_DIR_NAME);
|
|
66
|
+
const report = [await installCopy(hub, skillSrc, force)];
|
|
48
67
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
initialValues: AGENT_TARGETS.map((a) => a.value) as AgentKey[],
|
|
53
|
-
});
|
|
54
|
-
if (clack.isCancel(agents)) {
|
|
55
|
-
clack.cancel("Cancelled.");
|
|
56
|
-
process.exit(0);
|
|
57
|
-
}
|
|
58
|
-
selectedAgents = agents as AgentKey[];
|
|
59
|
-
|
|
60
|
-
const mode = await clack.select<"symlink" | "copy">({
|
|
61
|
-
message: "Install as symlink or copy?",
|
|
62
|
-
options: [
|
|
63
|
-
{
|
|
64
|
-
value: "symlink",
|
|
65
|
-
label: "Symlink",
|
|
66
|
-
hint: "stays in sync when klint updates",
|
|
67
|
-
},
|
|
68
|
-
{
|
|
69
|
-
value: "copy",
|
|
70
|
-
label: "Copy",
|
|
71
|
-
hint: "one-time snapshot, no ongoing dependency",
|
|
72
|
-
},
|
|
73
|
-
],
|
|
74
|
-
});
|
|
75
|
-
if (clack.isCancel(mode)) {
|
|
76
|
-
clack.cancel("Cancelled.");
|
|
77
|
-
process.exit(0);
|
|
78
|
-
}
|
|
79
|
-
useSymlink = mode === "symlink";
|
|
68
|
+
for (const dir of dirs) {
|
|
69
|
+
if (dir === CANONICAL_SKILL_DIR) continue;
|
|
70
|
+
report.push(await linkToHub(join(dir, SKILL_DIR_NAME), hub, skillSrc, force));
|
|
80
71
|
}
|
|
72
|
+
return report;
|
|
73
|
+
}
|
|
81
74
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
75
|
+
async function installCopies({
|
|
76
|
+
dirs,
|
|
77
|
+
force,
|
|
78
|
+
skillSrc,
|
|
79
|
+
}: {
|
|
80
|
+
dirs: string[];
|
|
81
|
+
force: boolean;
|
|
82
|
+
skillSrc: string;
|
|
83
|
+
}): Promise<string[]> {
|
|
84
|
+
const report: string[] = [];
|
|
85
|
+
for (const dir of dirs) {
|
|
86
|
+
report.push(await installCopy(join(dir, SKILL_DIR_NAME), skillSrc, force));
|
|
87
|
+
}
|
|
88
|
+
return report;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function installCopy(
|
|
92
|
+
target: string,
|
|
93
|
+
skillSrc: string,
|
|
94
|
+
force: boolean
|
|
95
|
+
): Promise<string> {
|
|
96
|
+
const dest = resolve(process.cwd(), target);
|
|
97
|
+
await prepareDestination(dest, target, force);
|
|
98
|
+
await cp(skillSrc, dest, { recursive: true });
|
|
99
|
+
await writeReceipt(dest, skillSrc);
|
|
100
|
+
return `installed ${target}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function linkToHub(
|
|
104
|
+
target: string,
|
|
105
|
+
hub: string,
|
|
106
|
+
skillSrc: string,
|
|
107
|
+
force: boolean
|
|
108
|
+
): Promise<string> {
|
|
109
|
+
const dest = resolve(process.cwd(), target);
|
|
110
|
+
await prepareDestination(dest, target, force);
|
|
111
|
+
|
|
112
|
+
const absoluteHub = resolve(process.cwd(), hub);
|
|
113
|
+
const linkPath = relative(dirname(dest), absoluteHub);
|
|
114
|
+
if (await linkResolves(linkPath, dest, "dir")) return `linked ${target} -> ${linkPath}`;
|
|
115
|
+
if (await linkResolves(absoluteHub, dest, "junction"))
|
|
116
|
+
return `linked ${target} -> ${hub}`;
|
|
117
|
+
|
|
118
|
+
await cp(skillSrc, dest, { recursive: true });
|
|
119
|
+
await writeReceipt(dest, skillSrc);
|
|
120
|
+
return `installed ${target} (symlinks unavailable here)`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function linkResolves(
|
|
124
|
+
linkTarget: string,
|
|
125
|
+
dest: string,
|
|
126
|
+
type: "dir" | "junction"
|
|
127
|
+
): Promise<boolean> {
|
|
128
|
+
try {
|
|
129
|
+
await symlink(linkTarget, dest, type);
|
|
130
|
+
if (existsSync(join(dest, SKILL_FILE_NAME))) return true;
|
|
131
|
+
} catch {
|
|
132
|
+
/* try the next strategy */
|
|
133
|
+
}
|
|
134
|
+
await rm(dest, { force: true, recursive: true });
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function prepareDestination(
|
|
139
|
+
dest: string,
|
|
140
|
+
target: string,
|
|
141
|
+
force: boolean
|
|
142
|
+
): Promise<void> {
|
|
143
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
144
|
+
|
|
145
|
+
const existing = await lstat(dest).catch(() => undefined);
|
|
146
|
+
if (existing === undefined) return;
|
|
147
|
+
|
|
148
|
+
if (!existing.isDirectory() || existsSync(join(dest, RECEIPT_FILE_NAME)) || force) {
|
|
149
|
+
await rm(dest, { force: true, recursive: true });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
throw new Error(
|
|
154
|
+
`klint: ${target} exists and was not installed by klint — pass --force to replace it`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function writeReceipt(dest: string, skillSrc: string): Promise<void> {
|
|
159
|
+
const receipt: SkillReceipt = {
|
|
160
|
+
version: installedVersion(),
|
|
161
|
+
sha256: skillHash(await readFile(join(skillSrc, SKILL_FILE_NAME))),
|
|
162
|
+
};
|
|
163
|
+
await writeFile(join(dest, RECEIPT_FILE_NAME), `${JSON.stringify(receipt, null, 2)}\n`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function resolvePlan(args: string[]): Promise<InstallPlan> {
|
|
167
|
+
const flags = parseFlags(args);
|
|
168
|
+
const decided = flags.agents !== undefined || flags.shared !== undefined;
|
|
169
|
+
|
|
170
|
+
if (!process.stdin.isTTY || decided) {
|
|
171
|
+
return {
|
|
172
|
+
agents: flags.agents ?? everyAgent(),
|
|
173
|
+
shared: flags.shared ?? true,
|
|
174
|
+
force: flags.force,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
clack.intro("klint install-skill");
|
|
179
|
+
return {
|
|
180
|
+
agents: await promptForAgents(),
|
|
181
|
+
shared: await promptForShared(),
|
|
182
|
+
force: flags.force,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function parseFlags(args: string[]): {
|
|
187
|
+
agents?: AgentKey[];
|
|
188
|
+
shared?: boolean;
|
|
189
|
+
force: boolean;
|
|
190
|
+
} {
|
|
191
|
+
let agents: AgentKey[] | undefined;
|
|
192
|
+
let shared: boolean | undefined;
|
|
193
|
+
let force = false;
|
|
194
|
+
|
|
195
|
+
for (let i = 0; i < args.length; i++) {
|
|
196
|
+
if (args[i] === "--agents" && args[i + 1]) agents = parseAgents(args[++i]);
|
|
197
|
+
else if (args[i] === "--symlink" || args[i] === "--shared") shared = true;
|
|
198
|
+
else if (args[i] === "--copy") shared = false;
|
|
199
|
+
else if (args[i] === "--force") force = true;
|
|
200
|
+
else {
|
|
201
|
+
process.stderr.write(`klint: unknown install-skill argument "${args[i]}"\n`);
|
|
202
|
+
process.exit(1);
|
|
96
203
|
}
|
|
97
204
|
}
|
|
98
205
|
|
|
99
|
-
|
|
100
|
-
|
|
206
|
+
return { agents, force, shared };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function parseAgents(value: string): AgentKey[] {
|
|
210
|
+
return value
|
|
211
|
+
.split(",")
|
|
212
|
+
.map((agent) => agent.trim())
|
|
213
|
+
.filter((agent) => agent.length > 0)
|
|
214
|
+
.map((agent) => {
|
|
215
|
+
if (!(agent in AGENT_SKILL_DIRS)) {
|
|
216
|
+
process.stderr.write(
|
|
217
|
+
`klint: unknown agent "${agent}" (expected one of: ${everyAgent().join(", ")})\n`
|
|
218
|
+
);
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
return agent as AgentKey;
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function everyAgent(): AgentKey[] {
|
|
226
|
+
return AGENT_TARGETS.map((target) => target.value);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function promptForAgents(): Promise<AgentKey[]> {
|
|
230
|
+
const agents = await clack.multiselect<AgentKey>({
|
|
231
|
+
message: "Which agents should the skill be installed for?",
|
|
232
|
+
options: AGENT_TARGETS.map((a) => ({ value: a.value, label: a.label })),
|
|
233
|
+
initialValues: everyAgent(),
|
|
234
|
+
});
|
|
235
|
+
if (clack.isCancel(agents)) {
|
|
236
|
+
clack.cancel("Cancelled.");
|
|
237
|
+
process.exit(0);
|
|
238
|
+
}
|
|
239
|
+
return agents as AgentKey[];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function promptForShared(): Promise<boolean> {
|
|
243
|
+
const mode = await clack.select<"shared" | "copy">({
|
|
244
|
+
message: "How should the skill be stored?",
|
|
245
|
+
initialValue: "shared",
|
|
246
|
+
options: [
|
|
247
|
+
{
|
|
248
|
+
value: "shared",
|
|
249
|
+
label: "Shared (recommended)",
|
|
250
|
+
hint: `one skill in ${CANONICAL_SKILL_DIR}, other agents symlink to it`,
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
value: "copy",
|
|
254
|
+
label: "Separate copies",
|
|
255
|
+
hint: "an independent copy in every agent directory",
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
});
|
|
259
|
+
if (clack.isCancel(mode)) {
|
|
260
|
+
clack.cancel("Cancelled.");
|
|
261
|
+
process.exit(0);
|
|
101
262
|
}
|
|
263
|
+
return mode === "shared";
|
|
102
264
|
}
|
package/cli/paths.ts
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
1
2
|
import { resolve } from "node:path";
|
|
2
3
|
|
|
3
4
|
export const PACKAGE_ROOT = resolve(import.meta.dir, "..");
|
|
5
|
+
|
|
6
|
+
export const SHIPPED_SKILL_PATH = resolve(
|
|
7
|
+
PACKAGE_ROOT,
|
|
8
|
+
"skill",
|
|
9
|
+
"klint-rules",
|
|
10
|
+
"SKILL.md"
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
export function installedVersion(): string {
|
|
14
|
+
try {
|
|
15
|
+
const manifest = readFileSync(resolve(PACKAGE_ROOT, "package.json"), "utf-8");
|
|
16
|
+
return (JSON.parse(manifest) as { version?: string }).version ?? "0.0.0";
|
|
17
|
+
} catch {
|
|
18
|
+
return "0.0.0";
|
|
19
|
+
}
|
|
20
|
+
}
|
package/cli.ts
CHANGED
|
@@ -12,22 +12,14 @@ import {
|
|
|
12
12
|
writeDebugEvent,
|
|
13
13
|
writeTextOutput,
|
|
14
14
|
} from "./cli/output";
|
|
15
|
-
import {
|
|
15
|
+
import { installedVersion, SHIPPED_SKILL_PATH } from "./cli/paths";
|
|
16
16
|
import { runAutoEngine, runCompareEngine, runRustEngine } from "./cli/rust-engine";
|
|
17
17
|
import { applyFixes } from "./core/fixer";
|
|
18
18
|
import { runKlint } from "./core/runner";
|
|
19
19
|
import { schemaVersionAdvisory } from "./core/schema-version";
|
|
20
|
+
import { skillAdvisories } from "./core/skill";
|
|
20
21
|
import type { ArchConfig, KlintConfig, KlintRule, RuleConfigValue } from "./core/types";
|
|
21
22
|
|
|
22
|
-
async function installedVersion(): Promise<string> {
|
|
23
|
-
const manifest = await readFile(resolve(PACKAGE_ROOT, "package.json"), "utf-8");
|
|
24
|
-
try {
|
|
25
|
-
return (JSON.parse(manifest) as { version?: string }).version ?? "0.0.0";
|
|
26
|
-
} catch {
|
|
27
|
-
return "0.0.0";
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
23
|
interface CliOptions {
|
|
32
24
|
configDir?: string;
|
|
33
25
|
rulesFile?: string;
|
|
@@ -42,8 +34,9 @@ export async function main(opts: CliOptions = {}): Promise<void> {
|
|
|
42
34
|
process.exit(0);
|
|
43
35
|
}
|
|
44
36
|
|
|
45
|
-
|
|
46
|
-
|
|
37
|
+
const subcommand = args.indexOf("install-skill");
|
|
38
|
+
if (subcommand !== -1) {
|
|
39
|
+
await installSkill(args.slice(subcommand + 1));
|
|
47
40
|
return;
|
|
48
41
|
}
|
|
49
42
|
|
|
@@ -65,8 +58,11 @@ export async function main(opts: CliOptions = {}): Promise<void> {
|
|
|
65
58
|
printHelp();
|
|
66
59
|
process.exit(0);
|
|
67
60
|
} else if (args[i] === "--version" || args[i] === "-V") {
|
|
68
|
-
process.stdout.write(`klint ${
|
|
61
|
+
process.stdout.write(`klint ${installedVersion()}\n`);
|
|
69
62
|
process.exit(0);
|
|
63
|
+
} else {
|
|
64
|
+
process.stderr.write(`klint: unknown argument "${args[i]}"\n`);
|
|
65
|
+
process.exit(1);
|
|
70
66
|
}
|
|
71
67
|
}
|
|
72
68
|
|
|
@@ -103,12 +99,20 @@ export async function main(opts: CliOptions = {}): Promise<void> {
|
|
|
103
99
|
}
|
|
104
100
|
const root = resolve(configDir, raw.root ?? ".");
|
|
105
101
|
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
102
|
+
const installed = installedVersion();
|
|
103
|
+
const advisories = [
|
|
104
|
+
...schemaVersionAdvisory({
|
|
105
|
+
schema: raw.$schema,
|
|
106
|
+
installed,
|
|
107
|
+
configFile: usingYaml ? "klint.yaml" : "klint.config.json",
|
|
108
|
+
configText,
|
|
109
|
+
}),
|
|
110
|
+
...skillAdvisories({
|
|
111
|
+
configDir,
|
|
112
|
+
installed,
|
|
113
|
+
shippedSkillPath: SHIPPED_SKILL_PATH,
|
|
114
|
+
}),
|
|
115
|
+
];
|
|
112
116
|
|
|
113
117
|
if (engine === "rust") {
|
|
114
118
|
runRustEngine({ advisories, fix, json, raw, root, rulesFile, startedAt });
|
package/core/skill.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstatSync, readFileSync, readlinkSync, realpathSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { Violation } from "./types";
|
|
5
|
+
|
|
6
|
+
export const AGENT_SKILL_DIRS = {
|
|
7
|
+
claude: ".claude/skills",
|
|
8
|
+
codex: ".agents/skills",
|
|
9
|
+
cursor: ".cursor/skills",
|
|
10
|
+
opencode: ".agents/skills",
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
export type AgentKey = keyof typeof AGENT_SKILL_DIRS;
|
|
14
|
+
|
|
15
|
+
export const CANONICAL_SKILL_DIR = ".agents/skills";
|
|
16
|
+
export const SKILL_DIR_NAME = "klint-rules";
|
|
17
|
+
export const SKILL_FILE_NAME = "SKILL.md";
|
|
18
|
+
export const RECEIPT_FILE_NAME = ".klint-skill.json";
|
|
19
|
+
|
|
20
|
+
const STALE_RULE = "klint/skill-stale";
|
|
21
|
+
const LEGACY_RULE = "klint/skill-legacy-link";
|
|
22
|
+
const LEGACY_TARGET = "node_modules";
|
|
23
|
+
|
|
24
|
+
export interface SkillReceipt {
|
|
25
|
+
version: string;
|
|
26
|
+
sha256: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function skillHash(contents: Buffer | string): string {
|
|
30
|
+
return createHash("sha256").update(contents).digest("hex");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function agentSkillDirs(agents?: readonly AgentKey[]): string[] {
|
|
34
|
+
const selected = (agents ?? (Object.keys(AGENT_SKILL_DIRS) as AgentKey[])).map(
|
|
35
|
+
(agent) => AGENT_SKILL_DIRS[agent]
|
|
36
|
+
);
|
|
37
|
+
return [...new Set(selected)];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function skillAdvisories({
|
|
41
|
+
configDir,
|
|
42
|
+
installed,
|
|
43
|
+
shippedSkillPath,
|
|
44
|
+
}: {
|
|
45
|
+
configDir: string;
|
|
46
|
+
installed: string;
|
|
47
|
+
shippedSkillPath: string;
|
|
48
|
+
}): Array<Omit<Violation, "fix">> {
|
|
49
|
+
return [
|
|
50
|
+
...legacyLinkAdvisories(configDir),
|
|
51
|
+
...stalenessAdvisories({ configDir, installed, shippedSkillPath }),
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function legacyLinkAdvisories(configDir: string): Array<Omit<Violation, "fix">> {
|
|
56
|
+
return agentSkillDirs().flatMap((dir) => {
|
|
57
|
+
const target = symlinkTarget(join(configDir, dir, SKILL_DIR_NAME));
|
|
58
|
+
if (target === undefined || !target.includes(LEGACY_TARGET)) return [];
|
|
59
|
+
return [
|
|
60
|
+
{
|
|
61
|
+
file: `${dir}/${SKILL_DIR_NAME}`,
|
|
62
|
+
line: 1,
|
|
63
|
+
rule: LEGACY_RULE,
|
|
64
|
+
severity: "warn",
|
|
65
|
+
message: `this skill is a symlink into ${LEGACY_TARGET} (${target}), which breaks as soon as dependencies are reinstalled — run: klint install-skill`,
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function stalenessAdvisories({
|
|
72
|
+
configDir,
|
|
73
|
+
installed,
|
|
74
|
+
shippedSkillPath,
|
|
75
|
+
}: {
|
|
76
|
+
configDir: string;
|
|
77
|
+
installed: string;
|
|
78
|
+
shippedSkillPath: string;
|
|
79
|
+
}): Array<Omit<Violation, "fix">> {
|
|
80
|
+
const shipped = readShippedHash(shippedSkillPath);
|
|
81
|
+
if (shipped === undefined) return [];
|
|
82
|
+
|
|
83
|
+
const reported = new Set<string>();
|
|
84
|
+
return realSkillDirsFirst(configDir).flatMap((dir) => {
|
|
85
|
+
const skillDir = join(configDir, dir, SKILL_DIR_NAME);
|
|
86
|
+
const canonical = resolveSkillDir(skillDir);
|
|
87
|
+
if (canonical === undefined || reported.has(canonical)) return [];
|
|
88
|
+
|
|
89
|
+
const receipt = readReceipt(join(skillDir, RECEIPT_FILE_NAME));
|
|
90
|
+
if (receipt === undefined || receipt.sha256 === shipped) return [];
|
|
91
|
+
|
|
92
|
+
reported.add(canonical);
|
|
93
|
+
return [staleAdvisory(dir, receipt.version, installed)];
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function staleAdvisory(
|
|
98
|
+
dir: string,
|
|
99
|
+
installedFrom: string,
|
|
100
|
+
installed: string
|
|
101
|
+
): Omit<Violation, "fix"> {
|
|
102
|
+
return {
|
|
103
|
+
file: `${dir}/${SKILL_DIR_NAME}/${SKILL_FILE_NAME}`,
|
|
104
|
+
line: 1,
|
|
105
|
+
rule: STALE_RULE,
|
|
106
|
+
severity: "warn",
|
|
107
|
+
message: `this klint-rules skill was installed from klint ${installedFrom} and no longer matches klint ${installed} — reinstall it with: klint install-skill`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function realSkillDirsFirst(configDir: string): string[] {
|
|
112
|
+
const dirs = agentSkillDirs();
|
|
113
|
+
const linked = new Set(
|
|
114
|
+
dirs.filter(
|
|
115
|
+
(dir) => symlinkTarget(join(configDir, dir, SKILL_DIR_NAME)) !== undefined
|
|
116
|
+
)
|
|
117
|
+
);
|
|
118
|
+
return [...dirs.filter((dir) => !linked.has(dir)), ...linked];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function symlinkTarget(path: string): string | undefined {
|
|
122
|
+
try {
|
|
123
|
+
if (!lstatSync(path).isSymbolicLink()) return undefined;
|
|
124
|
+
return readlinkSync(path);
|
|
125
|
+
} catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function resolveSkillDir(path: string): string | undefined {
|
|
131
|
+
try {
|
|
132
|
+
return realpathSync(path);
|
|
133
|
+
} catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function readShippedHash(shippedSkillPath: string): string | undefined {
|
|
139
|
+
try {
|
|
140
|
+
return skillHash(readFileSync(shippedSkillPath));
|
|
141
|
+
} catch {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function readReceipt(path: string): SkillReceipt | undefined {
|
|
147
|
+
let text: string;
|
|
148
|
+
try {
|
|
149
|
+
text = readFileSync(path, "utf-8");
|
|
150
|
+
} catch {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
const parsed = JSON.parse(text) as Partial<SkillReceipt>;
|
|
156
|
+
if (typeof parsed.version !== "string" || typeof parsed.sha256 !== "string") {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
return { version: parsed.version, sha256: parsed.sha256 };
|
|
160
|
+
} catch {
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
}
|
package/klint.schema.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
-
"$id": "https://raw.githubusercontent.com/konvert7/klint/refs/tags/v0.33.
|
|
3
|
+
"$id": "https://raw.githubusercontent.com/konvert7/klint/refs/tags/v0.33.1/klint.schema.json",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
6
6
|
"$schema": {
|
package/klint.schema.yaml
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
$schema: http://json-schema.org/draft-07/schema#
|
|
2
|
-
$id: https://raw.githubusercontent.com/konvert7/klint/refs/tags/v0.33.
|
|
2
|
+
$id: https://raw.githubusercontent.com/konvert7/klint/refs/tags/v0.33.1/klint.schema.json
|
|
3
3
|
type: object
|
|
4
4
|
properties:
|
|
5
5
|
$schema:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@konvert7/klint",
|
|
3
|
-
"version": "0.33.
|
|
3
|
+
"version": "0.33.1",
|
|
4
4
|
"description": "Architecture-as-Code linter for TypeScript. Enforce layer boundaries, import rules, and singleton patterns in YAML.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -70,10 +70,10 @@
|
|
|
70
70
|
"zod": "^4.4.3"
|
|
71
71
|
},
|
|
72
72
|
"optionalDependencies": {
|
|
73
|
-
"@konvert7/klint-darwin-arm64": "0.33.
|
|
74
|
-
"@konvert7/klint-darwin-x64": "0.33.
|
|
75
|
-
"@konvert7/klint-linux-x64": "0.33.
|
|
76
|
-
"@konvert7/klint-win32-x64": "0.33.
|
|
73
|
+
"@konvert7/klint-darwin-arm64": "0.33.1",
|
|
74
|
+
"@konvert7/klint-darwin-x64": "0.33.1",
|
|
75
|
+
"@konvert7/klint-linux-x64": "0.33.1",
|
|
76
|
+
"@konvert7/klint-win32-x64": "0.33.1"
|
|
77
77
|
},
|
|
78
78
|
"devDependencies": {
|
|
79
79
|
"@biomejs/biome": "2.4.7",
|