promptarch 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.
Files changed (3) hide show
  1. package/README.md +118 -0
  2. package/dist/index.js +955 -0
  3. package/package.json +39 -0
package/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # promptarch
2
+
3
+ Generate and lint AI coding-agent context files (`CLAUDE.md`, `AGENTS.md`, Cursor `.mdc` rules, GitHub Copilot instructions) straight from your repository.
4
+
5
+ Powered by the same deterministic linter and Context Pack exporters that run in the [PromptArch](https://promptarch.ai) web app and MCP server, bundled into the CLI, so there's nothing else to install.
6
+
7
+ - **`lint`** is 100% offline and free: no account, no network. Great for CI.
8
+ - **`init`** extracts your repo's context and generates every agent format in one shot (this one calls the PromptArch API and uses credits).
9
+
10
+ ## Requirements
11
+
12
+ - Node.js **≥ 18**
13
+ - A PromptArch account + API key **only for `init`** (see [Getting an API key](#getting-an-api-key)). `lint` needs neither.
14
+
15
+ ## Quick start
16
+
17
+ Lint an existing config without installing anything:
18
+
19
+ ```bash
20
+ npx promptarch lint CLAUDE.md
21
+ ```
22
+
23
+ Or install globally:
24
+
25
+ ```bash
26
+ npm install -g promptarch
27
+ promptarch lint CLAUDE.md AGENTS.md
28
+ ```
29
+
30
+ ## Commands
31
+
32
+ ### `promptarch lint <files...>`
33
+
34
+ Deterministic, offline lint of agent context files. Prints findings and exits non-zero when any **error** is found (add `--strict` to also fail on warnings), so it drops straight into CI.
35
+
36
+ ```bash
37
+ promptarch lint CLAUDE.md
38
+ promptarch lint CLAUDE.md AGENTS.md .cursor/rules/*.mdc
39
+ promptarch lint CLAUDE.md --strict
40
+ ```
41
+
42
+ | Option | Description |
43
+ |--------|-------------|
44
+ | `--strict` | Treat warnings as failures too (not just errors) |
45
+
46
+ ### `promptarch login`
47
+
48
+ Store your API key (`pk_…`) in `~/.config/promptarch/config.json` (written with `0600` permissions). Required before `init`.
49
+
50
+ ```bash
51
+ promptarch login # paste the key when prompted
52
+ promptarch login --key pk_xxx # non-interactive (CI)
53
+ promptarch login --url https://promptarch-preview.workers.dev # target a non-prod deploy
54
+ ```
55
+
56
+ | Option | Description |
57
+ |--------|-------------|
58
+ | `--key <key>` | Provide the key non-interactively (for CI) |
59
+ | `--url <url>` | Override the API base URL (saved to config) |
60
+
61
+ ### `promptarch init`
62
+
63
+ Scan the current repository (package manifest, scripts, directory tree, README, and any existing agent config), generate a canonical **Context Pack** via the API, and write every supported format (`CLAUDE.md`, `AGENTS.md`, Cursor `.mdc`, Copilot instructions).
64
+
65
+ ```bash
66
+ promptarch init --dry-run # print the extracted payload, no network call
67
+ promptarch init # generate + write files into the current directory
68
+ promptarch init --out ./generated # write to a specific directory
69
+ ```
70
+
71
+ | Option | Description |
72
+ |--------|-------------|
73
+ | `--dry-run` | Print the extracted payload without calling the API (no key needed) |
74
+ | `--out <dir>` | Output directory (default: current directory) |
75
+
76
+ > `init` consumes credits. The first generation on a new account is free.
77
+
78
+ Run `promptarch --help` (or `promptarch <command> --help`) and `promptarch --version` at any time.
79
+
80
+ ## Getting an API key
81
+
82
+ 1. Sign in at **[promptarch.ai](https://promptarch.ai)**.
83
+ 2. Go to your **Profile → API keys**, create a key (an optional label helps you track it), and copy the `pk_…` value. It's shown **once**.
84
+ 3. `promptarch login --key pk_…` (or run `promptarch login` and paste it).
85
+
86
+ Keys are hashed at rest and can be revoked from the same page.
87
+
88
+ ## Configuration
89
+
90
+ Resolution order (first match wins):
91
+
92
+ | Setting | Env var | Config file | Default |
93
+ |---------|---------|-------------|---------|
94
+ | API key | `PROMPTARCH_API_KEY` | `apiKey` in `~/.config/promptarch/config.json` | - |
95
+ | API URL | `PROMPTARCH_API_URL` | `apiUrl` in config | `https://promptarch.ai` |
96
+
97
+ Env vars are handy in CI:
98
+
99
+ ```bash
100
+ PROMPTARCH_API_KEY=pk_xxx promptarch init --out ./generated
101
+ ```
102
+
103
+ ## Related
104
+
105
+ - **[PromptArch](https://promptarch.ai)** - the web app (prompt builder + Context Studio)
106
+ - **`promptarch-mcp`** - MCP server exposing `lint` / `generate` tools to agents over Streamable HTTP
107
+
108
+ ## Develop
109
+
110
+ ```bash
111
+ npm install
112
+ npm run build # tsup → dist/index.js
113
+ node dist/index.js lint ../CLAUDE.md
114
+ ```
115
+
116
+ ## License
117
+
118
+ Proprietary. © PromptArch. All rights reserved. This CLI is provided for use with the PromptArch service; it is not open-source, and no rights to copy, modify, or redistribute the code are granted.
package/dist/index.js ADDED
@@ -0,0 +1,955 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/commands/init.ts
7
+ import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
8
+ import { dirname, join as join3, resolve } from "path";
9
+
10
+ // ../packages/core/context-model/types.ts
11
+ var SECTION_ORDER = [
12
+ "overview",
13
+ "structure",
14
+ "commands",
15
+ "testing",
16
+ "style",
17
+ "git",
18
+ "boundaries",
19
+ "custom"
20
+ ];
21
+ function sortSections(sections) {
22
+ return [...sections].sort(
23
+ (a, b) => SECTION_ORDER.indexOf(a.id) - SECTION_ORDER.indexOf(b.id)
24
+ );
25
+ }
26
+
27
+ // ../packages/core/linter/types.ts
28
+ var SEVERITY_PENALTY = {
29
+ error: 15,
30
+ warn: 7,
31
+ info: 2
32
+ };
33
+ var SIZE_BUDGETS = {
34
+ claudeMdWarnLines: 300,
35
+ claudeMdErrorChars: 4e4,
36
+ cursorMdcWarnLines: 500,
37
+ agentsMdErrorBytes: 32 * 1024,
38
+ // Codex truncation
39
+ copilotWarnLines: 400
40
+ };
41
+
42
+ // ../packages/core/context-model/exporters.ts
43
+ function renderMarkdown(pack, includeTitle = true) {
44
+ const parts = [];
45
+ if (includeTitle && pack.projectName) {
46
+ parts.push(`# ${pack.projectName}
47
+ `);
48
+ }
49
+ for (const s of sortSections(pack.sections)) {
50
+ parts.push(`## ${s.title}
51
+
52
+ ${s.body.trim()}
53
+ `);
54
+ }
55
+ return parts.join("\n").trim() + "\n";
56
+ }
57
+ function withinBudget(content, maxChars) {
58
+ if (content.length <= maxChars) return content;
59
+ return content.slice(0, maxChars - 80).trimEnd() + "\n\n<!-- truncated to stay within tool size budget -->\n";
60
+ }
61
+ function exportAgentsMd(pack) {
62
+ const content = withinBudget(
63
+ renderMarkdown(pack),
64
+ SIZE_BUDGETS.agentsMdErrorBytes
65
+ );
66
+ return [{ path: "AGENTS.md", content }];
67
+ }
68
+ function exportClaudeMd(pack, mode = "full") {
69
+ if (mode === "import") {
70
+ const name = pack.projectName ?? "Project";
71
+ return [
72
+ {
73
+ path: "CLAUDE.md",
74
+ content: `# ${name}
75
+
76
+ Project context lives in AGENTS.md.
77
+
78
+ @AGENTS.md
79
+ `
80
+ }
81
+ ];
82
+ }
83
+ const content = withinBudget(
84
+ renderMarkdown(pack),
85
+ SIZE_BUDGETS.claudeMdErrorChars
86
+ );
87
+ return [{ path: "CLAUDE.md", content }];
88
+ }
89
+ function mdcFrontmatter(section) {
90
+ const globs = section.scope?.globs ?? [];
91
+ const alwaysApply = globs.length === 0;
92
+ const lines = [
93
+ "---",
94
+ `description: ${section.title}`,
95
+ `globs: ${globs.join(", ")}`,
96
+ `alwaysApply: ${alwaysApply}`,
97
+ "---"
98
+ ];
99
+ return lines.join("\n");
100
+ }
101
+ function exportCursorMdc(pack) {
102
+ return sortSections(pack.sections).map((section) => {
103
+ const body = `${mdcFrontmatter(section)}
104
+
105
+ # ${section.title}
106
+
107
+ ${section.body.trim()}
108
+ `;
109
+ return {
110
+ path: `.cursor/rules/${section.id}.mdc`,
111
+ content: body
112
+ };
113
+ });
114
+ }
115
+ function exportCopilotInstructions(pack) {
116
+ const files = [];
117
+ const rootSections = sortSections(pack.sections).filter(
118
+ (s) => !s.scope?.globs?.length
119
+ );
120
+ const scopedSections = sortSections(pack.sections).filter(
121
+ (s) => s.scope?.globs?.length
122
+ );
123
+ if (rootSections.length) {
124
+ files.push({
125
+ path: ".github/copilot-instructions.md",
126
+ content: renderMarkdown({ ...pack, sections: rootSections })
127
+ });
128
+ }
129
+ for (const s of scopedSections) {
130
+ const applyTo = (s.scope?.globs ?? []).join(", ");
131
+ files.push({
132
+ path: `.github/instructions/${s.id}.instructions.md`,
133
+ content: `---
134
+ applyTo: "${applyTo}"
135
+ ---
136
+
137
+ # ${s.title}
138
+
139
+ ${s.body.trim()}
140
+ `
141
+ });
142
+ }
143
+ return files;
144
+ }
145
+ function exportPack(pack, formats, opts = {}) {
146
+ const out = [];
147
+ for (const f of formats) {
148
+ switch (f) {
149
+ case "agents_md":
150
+ out.push(...exportAgentsMd(pack));
151
+ break;
152
+ case "claude_md":
153
+ out.push(...exportClaudeMd(pack, opts.claudeMode ?? "full"));
154
+ break;
155
+ case "cursor_mdc":
156
+ out.push(...exportCursorMdc(pack));
157
+ break;
158
+ case "copilot_instructions":
159
+ out.push(...exportCopilotInstructions(pack));
160
+ break;
161
+ }
162
+ }
163
+ return out;
164
+ }
165
+ var ALL_EXPORT_FORMATS = [
166
+ "agents_md",
167
+ "claude_md",
168
+ "cursor_mdc",
169
+ "copilot_instructions"
170
+ ];
171
+
172
+ // ../packages/core/context-model/parse.ts
173
+ var VALID_IDS = [
174
+ "overview",
175
+ "commands",
176
+ "testing",
177
+ "structure",
178
+ "style",
179
+ "git",
180
+ "boundaries",
181
+ "custom"
182
+ ];
183
+ function parseContextModel(text) {
184
+ const match = text.match(/<context_model>([\s\S]*?)<\/context_model>/);
185
+ const fallback = () => ({
186
+ sections: [{ id: "overview", title: "Overview", body: text.trim() }]
187
+ });
188
+ if (!match) return fallback();
189
+ let raw = match[1].trim();
190
+ raw = raw.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
191
+ try {
192
+ const parsed = JSON.parse(raw);
193
+ if (!Array.isArray(parsed.sections) || parsed.sections.length === 0) {
194
+ return fallback();
195
+ }
196
+ const sections = parsed.sections.filter((s) => typeof s.body === "string" && s.body.trim().length > 0).map((s) => {
197
+ const id = VALID_IDS.includes(s.id) ? s.id : "custom";
198
+ const section = {
199
+ id,
200
+ title: s.title?.trim() || id,
201
+ body: (s.body ?? "").trim()
202
+ };
203
+ if (Array.isArray(s.scope?.globs) && s.scope.globs.length) {
204
+ section.scope = { globs: s.scope.globs.filter(Boolean) };
205
+ }
206
+ return section;
207
+ });
208
+ if (!sections.length) return fallback();
209
+ return { projectName: parsed.projectName?.trim(), sections };
210
+ } catch {
211
+ return fallback();
212
+ }
213
+ }
214
+
215
+ // src/extract.ts
216
+ import { readdir, readFile } from "fs/promises";
217
+ import { basename, join } from "path";
218
+ var IGNORE = /* @__PURE__ */ new Set([
219
+ "node_modules",
220
+ ".git",
221
+ "dist",
222
+ ".next",
223
+ ".open-next",
224
+ ".wrangler",
225
+ "build",
226
+ "coverage",
227
+ ".turbo",
228
+ "out"
229
+ ]);
230
+ var STACK_HINTS = [
231
+ [/^next$/, "Next.js"],
232
+ [/^react$/, "React"],
233
+ [/^react-native$|^expo$/, "React Native / Expo"],
234
+ [/^vue$/, "Vue"],
235
+ [/^svelte$/, "Svelte"],
236
+ [/^@angular\/core$/, "Angular"],
237
+ [/^express$|^fastify$|^koa$/, "Node HTTP server"],
238
+ [/^@supabase\//, "Supabase"],
239
+ [/^prisma$|^@prisma\//, "Prisma"],
240
+ [/^drizzle-orm$/, "Drizzle"],
241
+ [/^tailwindcss$/, "Tailwind CSS"],
242
+ [/^typescript$/, "TypeScript"],
243
+ [/^vitest$|^jest$/, "unit tests"],
244
+ [/^@playwright\/test$|^playwright$/, "Playwright E2E"]
245
+ ];
246
+ async function extractRepo(cwd) {
247
+ const pkg = await readJson(join(cwd, "package.json"));
248
+ const name = pkg?.name || basename(cwd);
249
+ const description = pkg?.description || await firstReadmeParagraph(cwd) || "";
250
+ return {
251
+ name,
252
+ description,
253
+ techStack: detectStack(pkg),
254
+ tree: await buildTree(cwd),
255
+ commands: formatScripts(pkg?.scripts),
256
+ conventions: await readExistingConfig(cwd)
257
+ };
258
+ }
259
+ async function readJson(path) {
260
+ try {
261
+ return JSON.parse(await readFile(path, "utf8"));
262
+ } catch {
263
+ return null;
264
+ }
265
+ }
266
+ function detectStack(pkg) {
267
+ if (!pkg) return "";
268
+ const deps = {
269
+ ...pkg.dependencies ?? {},
270
+ ...pkg.devDependencies ?? {}
271
+ };
272
+ const names = Object.keys(deps);
273
+ const found = /* @__PURE__ */ new Set();
274
+ for (const [pattern, label] of STACK_HINTS) {
275
+ if (names.some((n) => pattern.test(n))) found.add(label);
276
+ }
277
+ return [...found].join(", ");
278
+ }
279
+ function formatScripts(scripts) {
280
+ if (!scripts || typeof scripts !== "object") return "";
281
+ return Object.entries(scripts).map(([k, v]) => `npm run ${k}: ${v}`).join("\n");
282
+ }
283
+ async function buildTree(cwd) {
284
+ const lines = [];
285
+ async function walk(dir, prefix, depth) {
286
+ if (depth > 2) return;
287
+ let entries;
288
+ try {
289
+ entries = await readdir(dir, { withFileTypes: true });
290
+ } catch {
291
+ return;
292
+ }
293
+ const visible = entries.filter((e) => !IGNORE.has(e.name) && !e.name.startsWith(".")).sort((a, b) => a.name.localeCompare(b.name)).slice(0, 40);
294
+ for (const e of visible) {
295
+ lines.push(`${prefix}${e.name}${e.isDirectory() ? "/" : ""}`);
296
+ if (e.isDirectory()) await walk(join(dir, e.name), prefix + " ", depth + 1);
297
+ }
298
+ }
299
+ await walk(cwd, "", 0);
300
+ return lines.slice(0, 200).join("\n");
301
+ }
302
+ async function firstReadmeParagraph(cwd) {
303
+ for (const name of ["README.md", "readme.md", "Readme.md"]) {
304
+ try {
305
+ const text = await readFile(join(cwd, name), "utf8");
306
+ const para = text.split("\n").filter((l) => l.trim() && !l.trim().startsWith("#") && !l.startsWith("![")).slice(0, 3).join(" ").trim();
307
+ if (para) return para.slice(0, 400);
308
+ } catch {
309
+ }
310
+ }
311
+ return "";
312
+ }
313
+ async function readExistingConfig(cwd) {
314
+ for (const name of ["CLAUDE.md", "AGENTS.md", ".cursorrules"]) {
315
+ try {
316
+ const text = await readFile(join(cwd, name), "utf8");
317
+ if (text.trim()) return text.slice(0, 4e3);
318
+ } catch {
319
+ }
320
+ }
321
+ return "";
322
+ }
323
+
324
+ // src/config.ts
325
+ import { homedir } from "os";
326
+ import { join as join2 } from "path";
327
+ import { mkdir, readFile as readFile2, writeFile } from "fs/promises";
328
+ var CONFIG_DIR = join2(homedir(), ".config", "promptarch");
329
+ var CONFIG_PATH = join2(CONFIG_DIR, "config.json");
330
+ var DEFAULT_API_URL = "https://promptarch.ai";
331
+ async function loadConfig() {
332
+ try {
333
+ return JSON.parse(await readFile2(CONFIG_PATH, "utf8"));
334
+ } catch {
335
+ return {};
336
+ }
337
+ }
338
+ async function saveConfig(cfg) {
339
+ await mkdir(CONFIG_DIR, { recursive: true });
340
+ await writeFile(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n", {
341
+ mode: 384
342
+ });
343
+ }
344
+ function resolveApiUrl(cfg) {
345
+ return process.env.PROMPTARCH_API_URL || cfg.apiUrl || DEFAULT_API_URL;
346
+ }
347
+ function resolveApiKey(cfg) {
348
+ return process.env.PROMPTARCH_API_KEY || cfg.apiKey;
349
+ }
350
+
351
+ // src/commands/init.ts
352
+ async function initCmd(opts) {
353
+ const cwd = process.cwd();
354
+ const info = await extractRepo(cwd);
355
+ const payload = {
356
+ domain: "ai_skills_rules",
357
+ subType: "context_pack",
358
+ targetModel: "Claude",
359
+ answers: {
360
+ skill_name: info.name,
361
+ skill_description: info.description,
362
+ tech_stack: info.techStack,
363
+ repo_structure: info.tree,
364
+ commands: info.commands,
365
+ coding_conventions: info.conventions,
366
+ _subType: "context_pack"
367
+ }
368
+ };
369
+ if (opts.dryRun) {
370
+ console.log(JSON.stringify(payload, null, 2));
371
+ return;
372
+ }
373
+ const cfg = await loadConfig();
374
+ const key = resolveApiKey(cfg);
375
+ if (!key) {
376
+ console.error(
377
+ "No API key. Run `promptarch login` first (or set PROMPTARCH_API_KEY)."
378
+ );
379
+ process.exitCode = 1;
380
+ return;
381
+ }
382
+ const url = `${resolveApiUrl(cfg)}/api/build`;
383
+ let res;
384
+ try {
385
+ res = await fetch(url, {
386
+ method: "POST",
387
+ headers: {
388
+ "Content-Type": "application/json",
389
+ Authorization: `Bearer ${key}`
390
+ },
391
+ body: JSON.stringify(payload)
392
+ });
393
+ } catch {
394
+ console.error(`Network error contacting ${url}`);
395
+ process.exitCode = 1;
396
+ return;
397
+ }
398
+ if (res.status === 401) {
399
+ console.error("Unauthorized: check your API key (`promptarch login`).");
400
+ process.exitCode = 1;
401
+ return;
402
+ }
403
+ if (res.status === 402) {
404
+ console.error("Insufficient credits. Top up at promptarch.ai.");
405
+ process.exitCode = 1;
406
+ return;
407
+ }
408
+ if (!res.ok) {
409
+ console.error(`Build failed (HTTP ${res.status}).`);
410
+ process.exitCode = 1;
411
+ return;
412
+ }
413
+ const body = await res.json();
414
+ if (!body.prompt) {
415
+ console.error("Empty response from the server.");
416
+ process.exitCode = 1;
417
+ return;
418
+ }
419
+ const pack = parseContextModel(body.prompt);
420
+ const files = exportPack(pack, ALL_EXPORT_FORMATS);
421
+ const outDir = opts.out ? resolve(cwd, opts.out) : cwd;
422
+ for (const f of files) {
423
+ const dest = join3(outDir, f.path);
424
+ await mkdir2(dirname(dest), { recursive: true });
425
+ await writeFile2(dest, f.content, "utf8");
426
+ console.log(`wrote ${f.path}`);
427
+ }
428
+ console.log(`
429
+ \u2713 Generated ${files.length} file(s) in ${outDir}`);
430
+ }
431
+
432
+ // src/commands/lint.ts
433
+ import { readFile as readFile3 } from "fs/promises";
434
+
435
+ // ../packages/core/linter/detect.ts
436
+ function detectFormat(content, filename) {
437
+ const name = (filename ?? "").toLowerCase();
438
+ if (name.endsWith(".mdc") || name.includes(".cursor/rules")) return "cursor_mdc";
439
+ if (name.includes("copilot-instructions")) return "copilot_instructions";
440
+ if (name.includes("claude.md") || name === "claude.md") return "claude_md";
441
+ if (name.includes("agents.md") || name === "agents.md") return "agents_md";
442
+ if (name === "memory.md" || name.endsWith("/memory.md") || name.includes("/memory/") || name.startsWith("memory/")) {
443
+ return "memory_file";
444
+ }
445
+ const head = content.slice(0, 400);
446
+ if (/^#\s*memory policy/im.test(head)) return "memory_file";
447
+ if (/^---[\s\S]*?\b(globs|alwaysApply)\b[\s\S]*?---/.test(head)) {
448
+ return "cursor_mdc";
449
+ }
450
+ if (/copilot/i.test(head)) return "copilot_instructions";
451
+ if (/\bAGENTS\b/.test(content) && !/\bCLAUDE\b/.test(content)) {
452
+ return "agents_md";
453
+ }
454
+ return "claude_md";
455
+ }
456
+ function parseFrontmatter(content) {
457
+ const match = content.match(/^---\n([\s\S]*?)\n---\n?/);
458
+ if (!match) return { frontmatter: null, bodyStartLine: 1 };
459
+ const block = match[1];
460
+ const fm = {};
461
+ for (const rawLine of block.split("\n")) {
462
+ const line = rawLine.trim();
463
+ if (!line || line.startsWith("#")) continue;
464
+ const idx = line.indexOf(":");
465
+ if (idx === -1) continue;
466
+ const key = line.slice(0, idx).trim();
467
+ const value = line.slice(idx + 1).trim();
468
+ fm[key] = value;
469
+ }
470
+ const bodyStartLine = match[0].split("\n").length;
471
+ return { frontmatter: fm, bodyStartLine };
472
+ }
473
+
474
+ // ../packages/core/linter/rules.ts
475
+ function lineOf(content, index) {
476
+ return content.slice(0, index).split("\n").length;
477
+ }
478
+ function ruleLength(content, format) {
479
+ const findings = [];
480
+ const lines = content.split("\n").length;
481
+ const chars = content.length;
482
+ const bytes = new TextEncoder().encode(content).length;
483
+ if (format === "claude_md") {
484
+ if (chars > SIZE_BUDGETS.claudeMdErrorChars) {
485
+ findings.push({
486
+ ruleId: "length/claude-md-chars",
487
+ severity: "error",
488
+ message: `CLAUDE.md is ${chars.toLocaleString()} characters (>${SIZE_BUDGETS.claudeMdErrorChars.toLocaleString()}). Oversized context gets ignored. Trim aggressively.`
489
+ });
490
+ } else if (lines > SIZE_BUDGETS.claudeMdWarnLines) {
491
+ findings.push({
492
+ ruleId: "length/claude-md-lines",
493
+ severity: "warn",
494
+ message: `CLAUDE.md is ${lines} lines (>${SIZE_BUDGETS.claudeMdWarnLines}). Anthropic warns bloat causes instructions to be ignored, and both frontier labs now report leaner prompts score higher (OpenAI: 10\u201315% on internal evals).`
495
+ });
496
+ }
497
+ }
498
+ if (format === "cursor_mdc" && lines > SIZE_BUDGETS.cursorMdcWarnLines) {
499
+ findings.push({
500
+ ruleId: "length/cursor-lines",
501
+ severity: "warn",
502
+ message: `Cursor rule is ${lines} lines (>${SIZE_BUDGETS.cursorMdcWarnLines}). Split into focused, scoped rules.`
503
+ });
504
+ }
505
+ if (format === "agents_md" && bytes > SIZE_BUDGETS.agentsMdErrorBytes) {
506
+ findings.push({
507
+ ruleId: "length/agents-bytes",
508
+ severity: "error",
509
+ message: `AGENTS.md is ${(bytes / 1024).toFixed(1)}KiB (>32KiB). Codex truncates beyond 32KiB. Content past the cap is silently dropped.`
510
+ });
511
+ }
512
+ if (format === "copilot_instructions" && lines > SIZE_BUDGETS.copilotWarnLines) {
513
+ findings.push({
514
+ ruleId: "length/copilot-lines",
515
+ severity: "warn",
516
+ message: `Copilot instructions are ${lines} lines. Keep them short and high-signal.`
517
+ });
518
+ }
519
+ return findings;
520
+ }
521
+ function ruleCursorFrontmatter(content, format) {
522
+ if (format !== "cursor_mdc") return [];
523
+ const findings = [];
524
+ const { frontmatter } = parseFrontmatter(content);
525
+ if (!frontmatter) {
526
+ findings.push({
527
+ ruleId: "cursor/missing-frontmatter",
528
+ severity: "error",
529
+ message: "Cursor .mdc requires YAML frontmatter. A plain .md file in .cursor/rules is ignored.",
530
+ line: 1
531
+ });
532
+ return findings;
533
+ }
534
+ for (const key of ["description", "globs", "alwaysApply"]) {
535
+ if (!(key in frontmatter)) {
536
+ findings.push({
537
+ ruleId: `cursor/frontmatter-${key}`,
538
+ severity: key === "description" ? "warn" : "info",
539
+ message: `Cursor .mdc frontmatter is missing "${key}". Cursor uses description/globs/alwaysApply to decide when to apply the rule.`,
540
+ line: 1
541
+ });
542
+ }
543
+ }
544
+ if ("alwaysApply" in frontmatter && !/^(true|false)$/i.test(frontmatter.alwaysApply.trim())) {
545
+ findings.push({
546
+ ruleId: "cursor/alwaysApply-boolean",
547
+ severity: "warn",
548
+ message: `"alwaysApply" should be true or false (got "${frontmatter.alwaysApply}").`,
549
+ line: 1
550
+ });
551
+ }
552
+ return findings;
553
+ }
554
+ var VAGUE_PHRASES = [
555
+ "be helpful",
556
+ "write clean code",
557
+ "write good code",
558
+ "do your best",
559
+ "as an ai",
560
+ "you are a helpful assistant",
561
+ "follow best practices",
562
+ "use good judgment",
563
+ "be professional"
564
+ ];
565
+ function ruleVagueLanguage(content) {
566
+ const findings = [];
567
+ const lower = content.toLowerCase();
568
+ for (const phrase of VAGUE_PHRASES) {
569
+ const idx = lower.indexOf(phrase);
570
+ if (idx !== -1) {
571
+ findings.push({
572
+ ruleId: "vague/low-signal",
573
+ severity: "warn",
574
+ message: `Vague instruction "${phrase}": Anthropic's exclude table flags this as noise. Replace with a concrete, checkable rule.`,
575
+ line: lineOf(content, idx)
576
+ });
577
+ }
578
+ }
579
+ return findings;
580
+ }
581
+ var COMMON_COMMAND_PATTERNS = [
582
+ /\bnpm install\b/i,
583
+ /\bgit (commit|add|push)\b/i,
584
+ /\bpytest\b/i,
585
+ /\byarn install\b/i,
586
+ /\bpip install\b/i
587
+ ];
588
+ function ruleAntiPatterns(content) {
589
+ const findings = [];
590
+ const commonHits = COMMON_COMMAND_PATTERNS.filter((re) => re.test(content));
591
+ if (commonHits.length >= 2) {
592
+ findings.push({
593
+ ruleId: "antipattern/common-commands",
594
+ severity: "info",
595
+ message: "Documents standard tooling commands (npm/git/pytest). Agents already know these. Spend the budget on project-specific facts."
596
+ });
597
+ }
598
+ const todo = content.match(/\b(TODO|FIXME|XXX)\b/);
599
+ if (todo) {
600
+ findings.push({
601
+ ruleId: "antipattern/unresolved-todo",
602
+ severity: "info",
603
+ message: `Contains an unresolved ${todo[1]}. Resolve or remove before shipping this as agent context.`,
604
+ line: lineOf(content, todo.index ?? 0)
605
+ });
606
+ }
607
+ return findings;
608
+ }
609
+ var REASONING_TRANSCRIPTION_PHRASES = [
610
+ "transcribe your thinking",
611
+ "transcribe your reasoning",
612
+ "show your full reasoning",
613
+ "reveal your reasoning",
614
+ "output your chain of thought",
615
+ "echo your reasoning",
616
+ "include your internal reasoning"
617
+ ];
618
+ var REASONING_SCAFFOLDING_PHRASES = [
619
+ "think step by step",
620
+ "think step-by-step",
621
+ "show your work",
622
+ "explain your reasoning"
623
+ ];
624
+ function ruleReasoningEcho(content) {
625
+ const findings = [];
626
+ const lower = content.toLowerCase();
627
+ for (const phrase of REASONING_TRANSCRIPTION_PHRASES) {
628
+ const idx = lower.indexOf(phrase);
629
+ if (idx !== -1) {
630
+ findings.push({
631
+ ruleId: "antipattern/reasoning-echo",
632
+ severity: "warn",
633
+ message: `Instruction asks the model to echo internal reasoning ("${phrase}"). On Claude Fable 5 this triggers reasoning_extraction refusals. Read structured thinking blocks via the API instead.`,
634
+ line: lineOf(content, idx)
635
+ });
636
+ }
637
+ }
638
+ for (const phrase of REASONING_SCAFFOLDING_PHRASES) {
639
+ const idx = lower.indexOf(phrase);
640
+ if (idx !== -1) {
641
+ findings.push({
642
+ ruleId: "antipattern/reasoning-echo",
643
+ severity: "warn",
644
+ message: `Chain-of-thought scaffolding ("${phrase}"): current reasoning models reason internally; this adds noise and can degrade output. Remove it, or move reasoning needs to the effort/thinking API controls.`,
645
+ line: lineOf(content, idx)
646
+ });
647
+ }
648
+ }
649
+ return findings;
650
+ }
651
+ var VERBOSITY_PHRASES = [
652
+ "be concise",
653
+ "keep it short",
654
+ "keep responses brief",
655
+ "answer briefly",
656
+ "respond concisely"
657
+ ];
658
+ function ruleVerbosityInstruction(content) {
659
+ const findings = [];
660
+ const lower = content.toLowerCase();
661
+ for (const phrase of VERBOSITY_PHRASES) {
662
+ const idx = lower.indexOf(phrase);
663
+ if (idx !== -1) {
664
+ findings.push({
665
+ ruleId: "antipattern/verbosity-instruction",
666
+ severity: "info",
667
+ message: `Verbosity instruction "${phrase}": on current models verbosity is an API parameter (text.verbosity on GPT-5.6; effort on Claude). Prompt-level brevity lines double-steer and can make responses too brief; keep only if re-tested.`,
668
+ line: lineOf(content, idx)
669
+ });
670
+ }
671
+ }
672
+ return findings;
673
+ }
674
+ var COUNTDOWN_PHRASES = [
675
+ "tokens remaining",
676
+ "remaining tokens",
677
+ "tokens left",
678
+ "context budget:"
679
+ ];
680
+ var NEGATION_MARKERS = ["never", "don't", "do not", "avoid", "without"];
681
+ function ruleContextCountdown(content) {
682
+ const findings = [];
683
+ const lines = content.split("\n");
684
+ for (let i = 0; i < lines.length; i++) {
685
+ const lower = lines[i].toLowerCase();
686
+ const hit = COUNTDOWN_PHRASES.find((p) => lower.includes(p));
687
+ if (!hit) continue;
688
+ if (NEGATION_MARKERS.some((n) => lower.includes(n))) continue;
689
+ findings.push({
690
+ ruleId: "antipattern/context-countdown",
691
+ severity: "warn",
692
+ message: `Surfaces a running context budget to the model ("${hit}"). Models shown a token countdown may abandon the task and suggest a new session. Expose action thresholds ("compact now") instead.`,
693
+ line: i + 1
694
+ });
695
+ }
696
+ return findings;
697
+ }
698
+ var EMPHASIS_RE = /\b(IMPORTANT|CRITICAL|ALWAYS|NEVER|MUST(?: NOT)?|DO NOT)\b/g;
699
+ var EMPHASIS_THRESHOLD = 8;
700
+ function ruleEmphasisInflation(content) {
701
+ const matches = [...content.matchAll(EMPHASIS_RE)];
702
+ if (matches.length < EMPHASIS_THRESHOLD) return [];
703
+ const anchor = matches[EMPHASIS_THRESHOLD - 1];
704
+ return [
705
+ {
706
+ ruleId: "antipattern/emphasis-inflation",
707
+ severity: "info",
708
+ message: `${matches.length} ALL-CAPS emphasis markers (IMPORTANT/ALWAYS/NEVER/MUST\u2026). Emphasis works by contrast. Reserve caps for the few non-negotiable rules; on current frontier models leaner prompts outperform prescriptive ones.`,
709
+ line: lineOf(content, anchor.index ?? 0)
710
+ }
711
+ ];
712
+ }
713
+ var DUPLICATE_MIN_LENGTH = 20;
714
+ var DUPLICATE_MAX_FINDINGS = 3;
715
+ function ruleDuplicateLine(content) {
716
+ const findings = [];
717
+ const lines = content.split("\n");
718
+ const seen = /* @__PURE__ */ new Map();
719
+ let inFence = false;
720
+ for (let i = 0; i < lines.length; i++) {
721
+ const raw = lines[i];
722
+ if (raw.trimStart().startsWith("```")) {
723
+ inFence = !inFence;
724
+ continue;
725
+ }
726
+ if (inFence) continue;
727
+ const normalized = raw.trim();
728
+ if (normalized.length < DUPLICATE_MIN_LENGTH) continue;
729
+ const first = seen.get(normalized);
730
+ if (first === void 0) {
731
+ seen.set(normalized, i + 1);
732
+ continue;
733
+ }
734
+ if (findings.length >= DUPLICATE_MAX_FINDINGS) break;
735
+ findings.push({
736
+ ruleId: "antipattern/duplicate-line",
737
+ severity: "info",
738
+ message: `Line ${i + 1} repeats line ${first} verbatim ("${normalized.slice(0, 60)}${normalized.length > 60 ? "\u2026" : ""}"). State each rule once. Update the original instead of duplicating; duplicated rules drift apart and dilute the context.`,
739
+ line: i + 1
740
+ });
741
+ }
742
+ return findings;
743
+ }
744
+ function ruleMemoryFile(content, format) {
745
+ if (format !== "memory_file") return [];
746
+ const lines = content.split("\n");
747
+ let i = 0;
748
+ while (i < lines.length && lines[i].trim() === "") i++;
749
+ const hasHeading = lines[i]?.trim().startsWith("#") ?? false;
750
+ if (hasHeading) i++;
751
+ while (i < lines.length && lines[i].trim() === "") i++;
752
+ const summary = lines[i]?.trim() ?? "";
753
+ if (!hasHeading || summary === "" || summary.startsWith("#")) {
754
+ return [
755
+ {
756
+ ruleId: "memory/missing-summary-line",
757
+ severity: "warn",
758
+ message: "A memory file should open with a title and a one-line summary: one lesson per file, summary at the top, so the index and future reads stay cheap.",
759
+ line: 1
760
+ }
761
+ ];
762
+ }
763
+ if (summary.length > 200) {
764
+ return [
765
+ {
766
+ ruleId: "memory/missing-summary-line",
767
+ severity: "warn",
768
+ message: `The line after the title runs ${summary.length} characters. Keep the top-of-file summary to one short line; details go below.`,
769
+ line: i + 1
770
+ }
771
+ ];
772
+ }
773
+ return [];
774
+ }
775
+ var SECRET_PATTERNS = [
776
+ { re: /\bsk-[a-zA-Z0-9]{16,}\b/, label: "OpenAI-style API key (sk-\u2026)" },
777
+ { re: /\bsk-ant-[a-zA-Z0-9-]{16,}\b/, label: "Anthropic API key (sk-ant-\u2026)" },
778
+ { re: /\bghp_[a-zA-Z0-9]{20,}\b/, label: "GitHub token (ghp_\u2026)" },
779
+ { re: /\bAKIA[0-9A-Z]{16}\b/, label: "AWS access key (AKIA\u2026)" },
780
+ { re: /\bxox[baprs]-[a-zA-Z0-9-]{10,}\b/, label: "Slack token (xox\u2026)" }
781
+ ];
782
+ var DANGEROUS_PATTERNS = [
783
+ { re: /rm\s+-rf\s+\/(?:\s|$)/, label: "rm -rf /" },
784
+ { re: /curl[^\n|]*\|\s*(?:sudo\s+)?(?:bash|sh)\b/, label: "curl | bash" },
785
+ { re: /wget[^\n|]*\|\s*(?:sudo\s+)?(?:bash|sh)\b/, label: "wget | sh" }
786
+ ];
787
+ function ruleSecurity(content) {
788
+ const findings = [];
789
+ for (const { re, label } of SECRET_PATTERNS) {
790
+ const m = content.match(re);
791
+ if (m) {
792
+ findings.push({
793
+ ruleId: "security/secret",
794
+ severity: "error",
795
+ message: `Possible leaked secret (${label}). Never commit credentials into agent context.`,
796
+ line: lineOf(content, m.index ?? 0)
797
+ });
798
+ }
799
+ }
800
+ for (const { re, label } of DANGEROUS_PATTERNS) {
801
+ const m = content.match(re);
802
+ if (m) {
803
+ findings.push({
804
+ ruleId: "security/dangerous-command",
805
+ severity: "warn",
806
+ message: `Dangerous command in instructions (${label}). An agent may execute this literally.`,
807
+ line: lineOf(content, m.index ?? 0)
808
+ });
809
+ }
810
+ }
811
+ return findings;
812
+ }
813
+ function ruleSubstance(content) {
814
+ if (content.trim().length < 80) {
815
+ return [
816
+ {
817
+ ruleId: "substance/too-short",
818
+ severity: "warn",
819
+ message: "Very little content. An effective agent config should describe the project, commands, and conventions."
820
+ }
821
+ ];
822
+ }
823
+ return [];
824
+ }
825
+ function runRules(content, format) {
826
+ return [
827
+ ...ruleLength(content, format),
828
+ ...ruleCursorFrontmatter(content, format),
829
+ ...ruleVagueLanguage(content),
830
+ ...ruleAntiPatterns(content),
831
+ ...ruleReasoningEcho(content),
832
+ ...ruleVerbosityInstruction(content),
833
+ ...ruleContextCountdown(content),
834
+ ...ruleEmphasisInflation(content),
835
+ ...ruleDuplicateLine(content),
836
+ ...ruleMemoryFile(content, format),
837
+ ...ruleSecurity(content),
838
+ ...ruleSubstance(content)
839
+ ];
840
+ }
841
+
842
+ // ../packages/core/linter/index.ts
843
+ function scoreToGrade(score) {
844
+ if (score >= 90) return "A";
845
+ if (score >= 80) return "B";
846
+ if (score >= 70) return "C";
847
+ if (score >= 60) return "D";
848
+ return "F";
849
+ }
850
+ function computeScore(findings) {
851
+ const penalty = findings.reduce(
852
+ (sum, f) => sum + SEVERITY_PENALTY[f.severity],
853
+ 0
854
+ );
855
+ return Math.max(0, 100 - penalty);
856
+ }
857
+ function lint(content, opts = {}) {
858
+ const format = opts.format ?? detectFormat(content, opts.filename);
859
+ const findings = runRules(content, format).sort((a, b) => {
860
+ const order = { error: 0, warn: 1, info: 2 };
861
+ return order[a.severity] - order[b.severity];
862
+ });
863
+ const objectiveScore = computeScore(findings);
864
+ return {
865
+ format,
866
+ objectiveScore,
867
+ grade: scoreToGrade(objectiveScore),
868
+ findings
869
+ };
870
+ }
871
+
872
+ // src/commands/lint.ts
873
+ async function lintCmd(files, opts) {
874
+ if (files.length === 0) {
875
+ console.error("Usage: promptarch lint <file> [more files...]");
876
+ process.exitCode = 1;
877
+ return;
878
+ }
879
+ let failed = false;
880
+ for (const file of files) {
881
+ let content;
882
+ try {
883
+ content = await readFile3(file, "utf8");
884
+ } catch {
885
+ console.error(`\u2717 ${file}: cannot read file`);
886
+ failed = true;
887
+ continue;
888
+ }
889
+ const report = lint(content, { filename: file });
890
+ printReport(file, report);
891
+ if (report.findings.some((f) => f.severity === "error")) failed = true;
892
+ if (opts.strict && report.findings.some((f) => f.severity === "warn")) {
893
+ failed = true;
894
+ }
895
+ }
896
+ if (failed) process.exitCode = 1;
897
+ }
898
+ function printReport(file, report) {
899
+ console.log(
900
+ `
901
+ ${file} - grade ${report.grade} (${report.objectiveScore}/100, ${report.format})`
902
+ );
903
+ if (report.findings.length === 0) {
904
+ console.log(" \u2713 no findings");
905
+ return;
906
+ }
907
+ for (const f of report.findings) {
908
+ const mark = f.severity === "error" ? "\u2717" : f.severity === "warn" ? "\u26A0" : "\xB7";
909
+ const loc = f.line ? `line ${f.line}` : "";
910
+ console.log(
911
+ ` ${mark} ${f.severity.padEnd(5)} ${loc.padEnd(8)} ${f.ruleId}: ${f.message}`
912
+ );
913
+ }
914
+ }
915
+
916
+ // src/commands/login.ts
917
+ import { createInterface } from "readline";
918
+ async function login(opts) {
919
+ let key = opts.key;
920
+ if (!key) {
921
+ key = (await prompt("Paste your PromptArch API key (pk_...): ")).trim();
922
+ }
923
+ if (!key.startsWith("pk_")) {
924
+ console.error("Invalid key: expected a pk_... key. Create one in Settings.");
925
+ process.exitCode = 1;
926
+ return;
927
+ }
928
+ const cfg = await loadConfig();
929
+ cfg.apiKey = key;
930
+ if (opts.url) cfg.apiUrl = opts.url;
931
+ await saveConfig(cfg);
932
+ console.log(`Saved credentials to ${CONFIG_PATH}`);
933
+ }
934
+ function prompt(question) {
935
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
936
+ return new Promise(
937
+ (resolve2) => rl.question(question, (answer) => {
938
+ rl.close();
939
+ resolve2(answer);
940
+ })
941
+ );
942
+ }
943
+
944
+ // src/index.ts
945
+ var program = new Command();
946
+ program.name("promptarch").description(
947
+ "Generate and lint AI coding-agent context files (CLAUDE.md, AGENTS.md, Cursor rules) from your repo."
948
+ ).version("0.1.0");
949
+ program.command("login").description("Store a PromptArch API key (pk_...) in ~/.config/promptarch").option("--key <key>", "API key (non-interactive; for CI)").option("--url <url>", "Override the API base URL").action(login);
950
+ program.command("lint").description("Lint agent context files offline (deterministic, free, CI-friendly)").argument("<files...>", "Files to lint (CLAUDE.md, AGENTS.md, *.mdc, ...)").option("--strict", "Fail on warnings too, not just errors").action((files, opts) => lintCmd(files, opts));
951
+ program.command("init").description("Extract repo context and generate CLAUDE.md / AGENTS.md / Cursor / Copilot files").option("--dry-run", "Print the extracted payload without calling the API").option("--out <dir>", "Output directory (default: current directory)").action(initCmd);
952
+ program.parseAsync().catch((err) => {
953
+ console.error(err instanceof Error ? err.message : String(err));
954
+ process.exitCode = 1;
955
+ });
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "promptarch",
3
+ "version": "1.0.0",
4
+ "description": "Generate and lint AI coding-agent context files (CLAUDE.md, AGENTS.md, Cursor rules) from your repo.",
5
+ "keywords": [
6
+ "claude",
7
+ "cursor",
8
+ "agents.md",
9
+ "claude.md",
10
+ "ai",
11
+ "context-engineering",
12
+ "linter"
13
+ ],
14
+ "license": "UNLICENSED",
15
+ "homepage": "https://promptarch.ai",
16
+ "type": "module",
17
+ "bin": {
18
+ "promptarch": "dist/index.js"
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "README.md"
23
+ ],
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "scripts": {
28
+ "build": "tsup",
29
+ "dev": "tsup --watch"
30
+ },
31
+ "dependencies": {
32
+ "commander": "^12.1.0"
33
+ },
34
+ "devDependencies": {
35
+ "tsup": "^8.3.5",
36
+ "typescript": "^5.6.3",
37
+ "@types/node": "^22.9.0"
38
+ }
39
+ }