pi-readme 1.0.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 +72 -0
- package/extensions/index.ts +452 -0
- package/package.json +33 -0
- package/tests/readme.test.ts +148 -0
- package/tsconfig.json +19 -0
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# pi-readme
|
|
2
|
+
|
|
3
|
+
> Pi-native README generator and linter for pi.dev packages — auto-generate and validate README.md files.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/pi-readme)
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pi install npm:pi-readme
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## What It Does
|
|
14
|
+
|
|
15
|
+
Auto-generates and validates README.md files for pi.dev packages. Reads your package.json, extension tools, and CHANGELOG.md to create a pi-native README with proper install commands, tools documentation, and peer dependencies.
|
|
16
|
+
|
|
17
|
+
**Keywords:** pi-package, pi, readme, documentation, generator, linter
|
|
18
|
+
|
|
19
|
+
## Tools
|
|
20
|
+
|
|
21
|
+
### `readme_generate`
|
|
22
|
+
|
|
23
|
+
Generate a README.md for a pi.dev package from package.json metadata, extension tools, and changelog. Creates a pi-native README with proper install commands, tools documentation, and peer dependencies.
|
|
24
|
+
|
|
25
|
+
**Parameters:**
|
|
26
|
+
- `path` (string, optional) — Path to the package directory (defaults to cwd)
|
|
27
|
+
- `template_overrides` (Record<string, string>, optional) — Optional overrides for template sections
|
|
28
|
+
|
|
29
|
+
**Example:**
|
|
30
|
+
```
|
|
31
|
+
Use the readme_generate tool with path="./my-pi-package"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### `readme_lint`
|
|
35
|
+
|
|
36
|
+
Validate a README.md against pi.dev best practices. Checks for install commands, proper `pi install npm:` format, description, tools section, license, heading hierarchy, and pi-package keyword.
|
|
37
|
+
|
|
38
|
+
**Parameters:**
|
|
39
|
+
- `readme_path` (string, optional) — Path to the README.md file (defaults to ./README.md)
|
|
40
|
+
- `package_path` (string, optional) — Path to the package directory (defaults to cwd)
|
|
41
|
+
|
|
42
|
+
**Example:**
|
|
43
|
+
```
|
|
44
|
+
Use the readme_lint tool to check your README
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Commands
|
|
48
|
+
|
|
49
|
+
### `/readme generate [path]`
|
|
50
|
+
|
|
51
|
+
Generate README for a pi.dev package.
|
|
52
|
+
|
|
53
|
+
### `/readme lint [path]`
|
|
54
|
+
|
|
55
|
+
Validate README against pi.dev standards.
|
|
56
|
+
|
|
57
|
+
## Peer Dependencies
|
|
58
|
+
|
|
59
|
+
- `@earendil-works/pi-coding-agent` >=0.74.0
|
|
60
|
+
- `typebox` >=1.0.0
|
|
61
|
+
|
|
62
|
+
**Version:** 1.0.0
|
|
63
|
+
|
|
64
|
+
## Resources
|
|
65
|
+
|
|
66
|
+
- [npm](https://www.npmjs.com/package/pi-readme)
|
|
67
|
+
- [GitHub](https://github.com/ZachDreamZ/pi-readme)
|
|
68
|
+
- [pi.dev](https://pi.dev/packages/pi-readme)
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
MIT
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
|
|
6
|
+
// ── Types ──
|
|
7
|
+
|
|
8
|
+
interface PackageJson {
|
|
9
|
+
name?: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
version?: string;
|
|
12
|
+
keywords?: string[];
|
|
13
|
+
license?: string;
|
|
14
|
+
peerDependencies?: Record<string, string>;
|
|
15
|
+
dependencies?: Record<string, string>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface LintIssue {
|
|
19
|
+
severity: "error" | "warning" | "info";
|
|
20
|
+
line: number | null;
|
|
21
|
+
rule: string;
|
|
22
|
+
message: string;
|
|
23
|
+
fix: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ToolInfo {
|
|
27
|
+
name: string;
|
|
28
|
+
description: string;
|
|
29
|
+
parameters?: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Helpers ──
|
|
33
|
+
|
|
34
|
+
function readPackageJson(pkgPath: string): PackageJson | null {
|
|
35
|
+
const full = resolve(pkgPath, "package.json");
|
|
36
|
+
if (!existsSync(full)) return null;
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(readFileSync(full, "utf-8"));
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readChangelogVersion(pkgPath: string): string | null {
|
|
45
|
+
const clPath = resolve(pkgPath, "CHANGELOG.md");
|
|
46
|
+
if (!existsSync(clPath)) return null;
|
|
47
|
+
try {
|
|
48
|
+
const content = readFileSync(clPath, "utf-8");
|
|
49
|
+
const match = content.match(/^##\s*\[?(\d+\.\d+\.\d+[^\]\s]*)\]?/m);
|
|
50
|
+
return match ? match[1] : null;
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function extractToolsFromExtension(pkgPath: string): ToolInfo[] {
|
|
57
|
+
const extPaths = [
|
|
58
|
+
resolve(pkgPath, "extensions", "index.ts"),
|
|
59
|
+
resolve(pkgPath, "src", "extension.ts"),
|
|
60
|
+
resolve(pkgPath, "extension.ts"),
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
for (const extPath of extPaths) {
|
|
64
|
+
if (!existsSync(extPath)) continue;
|
|
65
|
+
try {
|
|
66
|
+
const content = readFileSync(extPath, "utf-8");
|
|
67
|
+
const tools: ToolInfo[] = [];
|
|
68
|
+
|
|
69
|
+
const toolRegex =
|
|
70
|
+
/registerTool\(\s*\{[\s\S]*?name:\s*["']([^"']+)["'][\s\S]*?description:\s*["']([^"']+)["']/g;
|
|
71
|
+
let match: RegExpExecArray | null = toolRegex.exec(content);
|
|
72
|
+
while (match !== null) {
|
|
73
|
+
const paramRegex = new RegExp(
|
|
74
|
+
`name:\\s*["']${match[1]}["'][\\s\\S]*?parameters:\\s*Type\\.Object\\(\\{([^}]*)\\}`,
|
|
75
|
+
"m",
|
|
76
|
+
);
|
|
77
|
+
const paramMatch = content.match(paramRegex);
|
|
78
|
+
const params: string[] = [];
|
|
79
|
+
if (paramMatch) {
|
|
80
|
+
const paramNames = paramMatch[1].match(/(\w+)\s*:/g);
|
|
81
|
+
if (paramNames) {
|
|
82
|
+
params.push(
|
|
83
|
+
...paramNames.map((p: string) => p.replace(":", "").trim()),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
tools.push({
|
|
88
|
+
name: match[1],
|
|
89
|
+
description: match[2],
|
|
90
|
+
parameters: params.length > 0 ? params : undefined,
|
|
91
|
+
});
|
|
92
|
+
match = toolRegex.exec(content);
|
|
93
|
+
}
|
|
94
|
+
if (tools.length > 0) return tools;
|
|
95
|
+
} catch {}
|
|
96
|
+
}
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function generateReadmeContent(
|
|
101
|
+
pkg: PackageJson,
|
|
102
|
+
tools: ToolInfo[],
|
|
103
|
+
version: string | null,
|
|
104
|
+
): string {
|
|
105
|
+
const name = pkg.name || "pi-package";
|
|
106
|
+
const desc = pkg.description || "A pi.dev package";
|
|
107
|
+
const ver = version || pkg.version || "1.0.0";
|
|
108
|
+
const license = pkg.license || "MIT";
|
|
109
|
+
const peerDeps = pkg.peerDependencies || {};
|
|
110
|
+
const keywords = pkg.keywords || [];
|
|
111
|
+
|
|
112
|
+
const lines: string[] = [];
|
|
113
|
+
|
|
114
|
+
lines.push(`# ${name}`);
|
|
115
|
+
lines.push("");
|
|
116
|
+
lines.push(`> ${desc}`);
|
|
117
|
+
lines.push("");
|
|
118
|
+
lines.push(
|
|
119
|
+
`[](https://www.npmjs.com/package/${name})`,
|
|
120
|
+
);
|
|
121
|
+
lines.push("");
|
|
122
|
+
lines.push("## Installation");
|
|
123
|
+
lines.push("");
|
|
124
|
+
lines.push("```bash");
|
|
125
|
+
lines.push(`pi install npm:${name}`);
|
|
126
|
+
lines.push("```");
|
|
127
|
+
lines.push("");
|
|
128
|
+
lines.push("## What It Does");
|
|
129
|
+
lines.push("");
|
|
130
|
+
lines.push(desc);
|
|
131
|
+
if (keywords.length > 0) {
|
|
132
|
+
lines.push("");
|
|
133
|
+
lines.push(`**Keywords:** ${keywords.join(", ")}`);
|
|
134
|
+
}
|
|
135
|
+
lines.push("");
|
|
136
|
+
|
|
137
|
+
if (tools.length > 0) {
|
|
138
|
+
lines.push("## Tools");
|
|
139
|
+
lines.push("");
|
|
140
|
+
for (const tool of tools) {
|
|
141
|
+
lines.push(`### \`${tool.name}\``);
|
|
142
|
+
lines.push("");
|
|
143
|
+
lines.push(tool.description);
|
|
144
|
+
lines.push("");
|
|
145
|
+
if (tool.parameters && tool.parameters.length > 0) {
|
|
146
|
+
lines.push("**Parameters:**");
|
|
147
|
+
for (const param of tool.parameters) {
|
|
148
|
+
lines.push(`- \`${param}\``);
|
|
149
|
+
}
|
|
150
|
+
lines.push("");
|
|
151
|
+
}
|
|
152
|
+
lines.push("**Example:**");
|
|
153
|
+
lines.push("```");
|
|
154
|
+
lines.push(`Use the ${tool.name} tool`);
|
|
155
|
+
lines.push("```");
|
|
156
|
+
lines.push("");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const depKeys = Object.keys(peerDeps);
|
|
161
|
+
if (depKeys.length > 0) {
|
|
162
|
+
lines.push("## Peer Dependencies");
|
|
163
|
+
lines.push("");
|
|
164
|
+
for (const dep of depKeys) {
|
|
165
|
+
lines.push(`- \`${dep}\` ${peerDeps[dep]}`);
|
|
166
|
+
}
|
|
167
|
+
lines.push("");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
lines.push(`**Version:** ${ver}`);
|
|
171
|
+
lines.push("");
|
|
172
|
+
lines.push("## Resources");
|
|
173
|
+
lines.push("");
|
|
174
|
+
lines.push(`- [npm](https://www.npmjs.com/package/${name})`);
|
|
175
|
+
lines.push(`- [GitHub](https://github.com/ZachDreamZ/${name})`);
|
|
176
|
+
lines.push(`- [pi.dev](https://pi.dev/packages/${name})`);
|
|
177
|
+
lines.push("");
|
|
178
|
+
lines.push("## License");
|
|
179
|
+
lines.push("");
|
|
180
|
+
lines.push(license);
|
|
181
|
+
lines.push("");
|
|
182
|
+
|
|
183
|
+
return lines.join("\n");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── Lint Rules ──
|
|
187
|
+
|
|
188
|
+
function lintReadme(readmePath: string, pkgPath: string): LintIssue[] {
|
|
189
|
+
const issues: LintIssue[] = [];
|
|
190
|
+
|
|
191
|
+
if (!existsSync(readmePath)) {
|
|
192
|
+
issues.push({
|
|
193
|
+
severity: "error",
|
|
194
|
+
line: null,
|
|
195
|
+
rule: "pi-readme/missing-file",
|
|
196
|
+
message: "README.md file not found",
|
|
197
|
+
fix: "Create a README.md file",
|
|
198
|
+
});
|
|
199
|
+
return issues;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const content = readFileSync(readmePath, "utf-8");
|
|
203
|
+
const lines = content.split("\n");
|
|
204
|
+
|
|
205
|
+
// Check: has title (# heading)
|
|
206
|
+
const hasTitle = lines.some((l) => /^#\s+\S/.test(l));
|
|
207
|
+
if (!hasTitle) {
|
|
208
|
+
issues.push({
|
|
209
|
+
severity: "error",
|
|
210
|
+
line: 1,
|
|
211
|
+
rule: "pi-readme/missing-title",
|
|
212
|
+
message: "README must start with a # title",
|
|
213
|
+
fix: "Add a '# package-name' heading at the top",
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Check: has description (blockquote)
|
|
218
|
+
const hasDescription = lines.some((l) => /^>\s+\S/.test(l));
|
|
219
|
+
if (!hasDescription) {
|
|
220
|
+
const titleLine = lines.findIndex((l) => /^#\s+\S/.test(l));
|
|
221
|
+
issues.push({
|
|
222
|
+
severity: "error",
|
|
223
|
+
line: titleLine >= 0 ? titleLine + 2 : 3,
|
|
224
|
+
rule: "pi-readme/missing-description",
|
|
225
|
+
message: "README must have a description (blockquote after title)",
|
|
226
|
+
fix: "Add a > blockquote description after the # title",
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Check: has install section
|
|
231
|
+
const installSectionIdx = lines.findIndex((l) => /^##\s+Install/i.test(l));
|
|
232
|
+
if (installSectionIdx < 0) {
|
|
233
|
+
issues.push({
|
|
234
|
+
severity: "error",
|
|
235
|
+
line: null,
|
|
236
|
+
rule: "pi-readme/missing-install",
|
|
237
|
+
message: "README must include an installation section",
|
|
238
|
+
fix: "Add an '## Installation' section with `pi install npm:<package-name>`",
|
|
239
|
+
});
|
|
240
|
+
} else {
|
|
241
|
+
const sectionEnd = lines.findIndex(
|
|
242
|
+
(l, i) => i > installSectionIdx && /^##\s/.test(l),
|
|
243
|
+
);
|
|
244
|
+
const installSection = lines
|
|
245
|
+
.slice(installSectionIdx, sectionEnd >= 0 ? sectionEnd : lines.length)
|
|
246
|
+
.join("\n");
|
|
247
|
+
|
|
248
|
+
const hasPiFormat = /pi\s+install\s+npm:/.test(installSection);
|
|
249
|
+
const hasNpmInstall = /npm\s+install\s+/.test(installSection);
|
|
250
|
+
const hasYarnAdd = /yarn\s+add\s+/.test(installSection);
|
|
251
|
+
|
|
252
|
+
if (!hasPiFormat && (hasNpmInstall || hasYarnAdd || !hasPiFormat)) {
|
|
253
|
+
const codeBlockLine =
|
|
254
|
+
installSectionIdx +
|
|
255
|
+
lines.slice(installSectionIdx).findIndex((l) => /```/.test(l));
|
|
256
|
+
issues.push({
|
|
257
|
+
severity: "error",
|
|
258
|
+
line: codeBlockLine >= 0 ? codeBlockLine + 1 : installSectionIdx + 1,
|
|
259
|
+
rule: "pi-readme/wrong-install-format",
|
|
260
|
+
message: "Install command must use `pi install npm:` format",
|
|
261
|
+
fix: "Use `pi install npm:<package-name>` instead of npm/yarn/pnpm install",
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Check: has tools section
|
|
267
|
+
const hasToolsSection = lines.some((l) => /^##\s+Tools/i.test(l));
|
|
268
|
+
if (!hasToolsSection) {
|
|
269
|
+
issues.push({
|
|
270
|
+
severity: "warning",
|
|
271
|
+
line: null,
|
|
272
|
+
rule: "pi-readme/missing-tools-section",
|
|
273
|
+
message: "README should document available tools",
|
|
274
|
+
fix: "Add a '## Tools' section listing each tool with description",
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Check: has license section
|
|
279
|
+
const hasLicenseSection = lines.some((l) => /^##\s+License/i.test(l));
|
|
280
|
+
if (!hasLicenseSection) {
|
|
281
|
+
issues.push({
|
|
282
|
+
severity: "warning",
|
|
283
|
+
line: null,
|
|
284
|
+
rule: "pi-readme/missing-license",
|
|
285
|
+
message: "README should mention the license",
|
|
286
|
+
fix: "Add a '## License' section",
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Check: heading hierarchy
|
|
291
|
+
let lastLevel = 0;
|
|
292
|
+
for (let i = 0; i < lines.length; i++) {
|
|
293
|
+
const match = lines[i].match(/^(#{1,6})\s/);
|
|
294
|
+
if (match) {
|
|
295
|
+
const level = match[1].length;
|
|
296
|
+
if (level > lastLevel + 1 && lastLevel > 0) {
|
|
297
|
+
issues.push({
|
|
298
|
+
severity: "warning",
|
|
299
|
+
line: i + 1,
|
|
300
|
+
rule: "pi-readme/heading-hierarchy",
|
|
301
|
+
message: `Heading levels should not skip (found h${level} after h${lastLevel})`,
|
|
302
|
+
fix: "Use sequential heading levels: # → ## → ###",
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
lastLevel = level;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Check: package.json has pi-package keyword
|
|
310
|
+
const pkg = readPackageJson(pkgPath);
|
|
311
|
+
if (pkg && (!pkg.keywords || !pkg.keywords.includes("pi-package"))) {
|
|
312
|
+
issues.push({
|
|
313
|
+
severity: "info",
|
|
314
|
+
line: null,
|
|
315
|
+
rule: "pi-readme/missing-pi-keyword",
|
|
316
|
+
message: "Consider adding 'pi-package' to package.json keywords",
|
|
317
|
+
fix: 'Add "pi-package" to the keywords array in package.json',
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const severityOrder = { error: 0, warning: 1, info: 2 };
|
|
322
|
+
issues.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
|
|
323
|
+
|
|
324
|
+
return issues;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── Extension Registration ──
|
|
328
|
+
|
|
329
|
+
export default function register(pi: ExtensionAPI): void {
|
|
330
|
+
// Tool 1: readme_generate
|
|
331
|
+
pi.registerTool({
|
|
332
|
+
name: "readme_generate",
|
|
333
|
+
label: "Generate README",
|
|
334
|
+
description:
|
|
335
|
+
"Generate a README.md for a pi.dev package from package.json metadata, extension tools, and changelog. Creates a pi-native README with proper install commands, tools documentation, and peer dependencies.",
|
|
336
|
+
parameters: Type.Object({
|
|
337
|
+
path: Type.Optional(
|
|
338
|
+
Type.String({
|
|
339
|
+
description:
|
|
340
|
+
"Path to the package directory (defaults to current working directory)",
|
|
341
|
+
}),
|
|
342
|
+
),
|
|
343
|
+
template_overrides: Type.Optional(
|
|
344
|
+
Type.Record(Type.String(), Type.String(), {
|
|
345
|
+
description:
|
|
346
|
+
"Optional overrides for template sections (e.g., {description: 'Custom desc'})",
|
|
347
|
+
}),
|
|
348
|
+
),
|
|
349
|
+
}),
|
|
350
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
351
|
+
const pkgPath = resolve(params.path || ".");
|
|
352
|
+
const pkg = readPackageJson(pkgPath);
|
|
353
|
+
|
|
354
|
+
if (!pkg) {
|
|
355
|
+
return {
|
|
356
|
+
content: [
|
|
357
|
+
{
|
|
358
|
+
type: "text",
|
|
359
|
+
text: `Error: No package.json found at ${pkgPath}`,
|
|
360
|
+
},
|
|
361
|
+
],
|
|
362
|
+
details: {
|
|
363
|
+
success: false,
|
|
364
|
+
error: `No package.json found at ${pkgPath}`,
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const tools = extractToolsFromExtension(pkgPath);
|
|
370
|
+
const version = readChangelogVersion(pkgPath);
|
|
371
|
+
|
|
372
|
+
let readme = generateReadmeContent(pkg, tools, version);
|
|
373
|
+
|
|
374
|
+
if (params.template_overrides) {
|
|
375
|
+
for (const [key, value] of Object.entries(params.template_overrides)) {
|
|
376
|
+
if (key === "description") {
|
|
377
|
+
readme = readme.replace(/^> .+$/m, `> ${value}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
content: [{ type: "text", text: readme }],
|
|
384
|
+
details: {
|
|
385
|
+
success: true,
|
|
386
|
+
metadata: {
|
|
387
|
+
name: pkg.name,
|
|
388
|
+
version: version || pkg.version,
|
|
389
|
+
tools_found: tools.length,
|
|
390
|
+
tool_names: tools.map((t) => t.name),
|
|
391
|
+
peer_deps: Object.keys(pkg.peerDependencies || {}),
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// Tool 2: readme_lint
|
|
399
|
+
pi.registerTool({
|
|
400
|
+
name: "readme_lint",
|
|
401
|
+
label: "Lint README",
|
|
402
|
+
description:
|
|
403
|
+
"Validate a README.md against pi.dev best practices. Checks for install commands, proper `pi install npm:` format, description, tools section, license, heading hierarchy, and pi-package keyword. Returns issues with severity, line numbers, and fix suggestions.",
|
|
404
|
+
parameters: Type.Object({
|
|
405
|
+
readme_path: Type.Optional(
|
|
406
|
+
Type.String({
|
|
407
|
+
description: "Path to the README.md file (defaults to ./README.md)",
|
|
408
|
+
}),
|
|
409
|
+
),
|
|
410
|
+
package_path: Type.Optional(
|
|
411
|
+
Type.String({
|
|
412
|
+
description:
|
|
413
|
+
"Path to the package directory for reading package.json (defaults to cwd)",
|
|
414
|
+
}),
|
|
415
|
+
),
|
|
416
|
+
}),
|
|
417
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
418
|
+
const pkgPath = resolve(params.package_path || ".");
|
|
419
|
+
const readmePath = resolve(
|
|
420
|
+
params.readme_path || join(pkgPath, "README.md"),
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
const issues = lintReadme(readmePath, pkgPath);
|
|
424
|
+
|
|
425
|
+
const errors = issues.filter((i) => i.severity === "error").length;
|
|
426
|
+
const warnings = issues.filter((i) => i.severity === "warning").length;
|
|
427
|
+
const infos = issues.filter((i) => i.severity === "info").length;
|
|
428
|
+
|
|
429
|
+
const summary = `${issues.length} issue(s): ${errors} error(s), ${warnings} warning(s), ${infos} info`;
|
|
430
|
+
|
|
431
|
+
const issueLines = issues.map(
|
|
432
|
+
(i) =>
|
|
433
|
+
`[${i.severity.toUpperCase()}] ${i.line ? `Line ${i.line}: ` : ""}${i.message} — Fix: ${i.fix}`,
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
content: [
|
|
438
|
+
{
|
|
439
|
+
type: "text",
|
|
440
|
+
text: `${summary}\n\n${issueLines.join("\n") || "No issues found."}`,
|
|
441
|
+
},
|
|
442
|
+
],
|
|
443
|
+
details: {
|
|
444
|
+
success: errors === 0,
|
|
445
|
+
summary,
|
|
446
|
+
issues,
|
|
447
|
+
stats: { errors, warnings, infos, total: issues.length },
|
|
448
|
+
},
|
|
449
|
+
};
|
|
450
|
+
},
|
|
451
|
+
});
|
|
452
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-readme",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Pi-native README generator and linter for pi.dev packages — auto-generate and validate README.md files",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "extensions/index.ts",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi",
|
|
10
|
+
"readme",
|
|
11
|
+
"documentation",
|
|
12
|
+
"generator",
|
|
13
|
+
"linter"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"@earendil-works/pi-coding-agent": ">=0.74.0",
|
|
18
|
+
"typebox": ">=1.0.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@biomejs/biome": "^1.9.0",
|
|
22
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
23
|
+
"@types/node": "^26.0.1",
|
|
24
|
+
"typebox": "*",
|
|
25
|
+
"typescript": "^5.5.0",
|
|
26
|
+
"vitest": "^3.0.0"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc --noEmit",
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"lint": "biome check ."
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
rmSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join, resolve } from "node:path";
|
|
10
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
11
|
+
|
|
12
|
+
const TEST_DIR = resolve(tmpdir(), `pi-readme-test-${Date.now()}`);
|
|
13
|
+
|
|
14
|
+
function setupDir() {
|
|
15
|
+
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
|
|
16
|
+
mkdirSync(TEST_DIR, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function writeTestPkg(overrides: Record<string, unknown> = {}) {
|
|
20
|
+
const pkg = {
|
|
21
|
+
name: "test-pi-package",
|
|
22
|
+
description: "A test package for pi.dev",
|
|
23
|
+
version: "1.0.0",
|
|
24
|
+
keywords: ["pi-package", "pi", "test"],
|
|
25
|
+
license: "MIT",
|
|
26
|
+
peerDependencies: {
|
|
27
|
+
"@earendil-works/pi-coding-agent": ">=0.74.0",
|
|
28
|
+
typebox: ">=1.0.0",
|
|
29
|
+
},
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
writeFileSync(join(TEST_DIR, "package.json"), JSON.stringify(pkg, null, 2));
|
|
33
|
+
return pkg;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function writeTestReadme(content: string) {
|
|
37
|
+
writeFileSync(join(TEST_DIR, "README.md"), content);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("readme_generate logic", () => {
|
|
41
|
+
beforeEach(setupDir);
|
|
42
|
+
|
|
43
|
+
it("generates README with all sections from package.json", () => {
|
|
44
|
+
writeTestPkg();
|
|
45
|
+
const pkg = JSON.parse(
|
|
46
|
+
readFileSync(join(TEST_DIR, "package.json"), "utf-8"),
|
|
47
|
+
);
|
|
48
|
+
expect(pkg.name).toBe("test-pi-package");
|
|
49
|
+
expect(pkg.description).toBe("A test package for pi.dev");
|
|
50
|
+
expect(pkg.keywords).toContain("pi-package");
|
|
51
|
+
expect(pkg.peerDependencies).toHaveProperty(
|
|
52
|
+
"@earendil-works/pi-coding-agent",
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("handles missing package.json gracefully", () => {
|
|
57
|
+
const path = join(TEST_DIR, "package.json");
|
|
58
|
+
expect(existsSync(path)).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("readme_lint logic", () => {
|
|
63
|
+
beforeEach(setupDir);
|
|
64
|
+
|
|
65
|
+
it("detects missing README", () => {
|
|
66
|
+
writeTestPkg();
|
|
67
|
+
expect(existsSync(join(TEST_DIR, "README.md"))).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("detects missing install section", () => {
|
|
71
|
+
writeTestPkg();
|
|
72
|
+
writeTestReadme(
|
|
73
|
+
"# test-pi-package\n> A test package\n\n## Tools\n### tool1\nDesc",
|
|
74
|
+
);
|
|
75
|
+
const content = readFileSync(join(TEST_DIR, "README.md"), "utf-8");
|
|
76
|
+
expect(content).not.toMatch(/## Install/i);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("detects wrong install format (npm install instead of pi install)", () => {
|
|
80
|
+
writeTestPkg();
|
|
81
|
+
writeTestReadme(
|
|
82
|
+
"# test-pi-package\n> A test package\n\n## Installation\n\n```bash\nnpm install test-pi-package\n```",
|
|
83
|
+
);
|
|
84
|
+
const content = readFileSync(join(TEST_DIR, "README.md"), "utf-8");
|
|
85
|
+
expect(content).toMatch(/npm install/);
|
|
86
|
+
expect(content).not.toMatch(/pi install npm:/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("passes for correct pi install format", () => {
|
|
90
|
+
writeTestPkg();
|
|
91
|
+
writeTestReadme(
|
|
92
|
+
"# test-pi-package\n> A test package\n\n## Installation\n\n```bash\npi install npm:test-pi-package\n```\n\n## Tools\n\n### tool1\nDesc\n\n## License\nMIT",
|
|
93
|
+
);
|
|
94
|
+
const content = readFileSync(join(TEST_DIR, "README.md"), "utf-8");
|
|
95
|
+
expect(content).toMatch(/pi install npm:test-pi-package/);
|
|
96
|
+
expect(content).toMatch(/## Tools/);
|
|
97
|
+
expect(content).toMatch(/## License/);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("detects missing description blockquote", () => {
|
|
101
|
+
writeTestPkg();
|
|
102
|
+
writeTestReadme(
|
|
103
|
+
"# test-pi-package\n\n## Installation\n\n```bash\npi install npm:test-pi-package\n```",
|
|
104
|
+
);
|
|
105
|
+
const content = readFileSync(join(TEST_DIR, "README.md"), "utf-8");
|
|
106
|
+
expect(content).not.toMatch(/^>\s/m);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("detects heading hierarchy skip", () => {
|
|
110
|
+
writeTestPkg();
|
|
111
|
+
writeTestReadme(
|
|
112
|
+
"# test-pi-package\n> Desc\n\n### Skipped heading\n\n## Installation\n\n```bash\npi install npm:test-pi-package\n```",
|
|
113
|
+
);
|
|
114
|
+
const lines = readFileSync(join(TEST_DIR, "README.md"), "utf-8").split(
|
|
115
|
+
"\n",
|
|
116
|
+
);
|
|
117
|
+
const h1 = lines.findIndex((l: string) => /^#\s/.test(l));
|
|
118
|
+
const h3 = lines.findIndex((l: string) => /^###\s/.test(l));
|
|
119
|
+
expect(h1).toBeGreaterThanOrEqual(0);
|
|
120
|
+
expect(h3).toBeGreaterThanOrEqual(0);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("detects missing tools section", () => {
|
|
124
|
+
writeTestPkg();
|
|
125
|
+
writeTestReadme(
|
|
126
|
+
"# test-pi-package\n> Desc\n\n## Installation\n\n```bash\npi install npm:test-pi-package\n```\n\n## License\nMIT",
|
|
127
|
+
);
|
|
128
|
+
const content = readFileSync(join(TEST_DIR, "README.md"), "utf-8");
|
|
129
|
+
expect(content).not.toMatch(/## Tools/i);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("detects missing license section", () => {
|
|
133
|
+
writeTestPkg();
|
|
134
|
+
writeTestReadme(
|
|
135
|
+
"# test-pi-package\n> Desc\n\n## Installation\n\n```bash\npi install npm:test-pi-package\n```\n\n## Tools\n### tool1\nDesc",
|
|
136
|
+
);
|
|
137
|
+
const content = readFileSync(join(TEST_DIR, "README.md"), "utf-8");
|
|
138
|
+
expect(content).not.toMatch(/## License/i);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("detects missing pi-package keyword in package.json", () => {
|
|
142
|
+
writeTestPkg({ keywords: ["test"] });
|
|
143
|
+
const pkg = JSON.parse(
|
|
144
|
+
readFileSync(join(TEST_DIR, "package.json"), "utf-8"),
|
|
145
|
+
);
|
|
146
|
+
expect(pkg.keywords).not.toContain("pi-package");
|
|
147
|
+
});
|
|
148
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"outDir": "dist",
|
|
10
|
+
"rootDir": ".",
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"sourceMap": true,
|
|
13
|
+
"resolveJsonModule": true,
|
|
14
|
+
"lib": ["ES2022"],
|
|
15
|
+
"types": ["node"]
|
|
16
|
+
},
|
|
17
|
+
"include": ["extensions/**/*.ts", "tests/**/*.ts"],
|
|
18
|
+
"exclude": ["node_modules", "dist"]
|
|
19
|
+
}
|