@paramour-js/next 0.4.1 → 0.7.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 +1 -1
- package/dist/commands/doctor.d.ts +12 -0
- package/dist/commands/doctor.js +12 -7
- package/dist/commands/generate.d.ts +48 -0
- package/dist/commands/generate.js +13 -6
- package/dist/commands/init.d.ts +40 -0
- package/dist/commands/init.js +110 -18
- package/dist/commands/list.d.ts +21 -0
- package/dist/commands/list.js +12 -7
- package/dist/commands/skills.d.ts +45 -0
- package/dist/commands/skills.js +222 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.js +13 -0
- package/dist/devtools-seam.d.ts +1 -1
- package/dist/doctor/checks.js +6 -2
- package/dist/init/agents-md.d.ts +32 -0
- package/dist/init/agents-md.js +78 -0
- package/dist/init/scaffold.js +54 -0
- package/dist/run-cli.d.ts +2 -0
- package/dist/run-cli.js +6 -2
- package/dist/skills/doctor.d.ts +11 -0
- package/dist/skills/doctor.js +71 -0
- package/dist/skills/manifest.d.ts +41 -0
- package/dist/skills/manifest.js +96 -0
- package/dist/skills/packaged.d.ts +21 -0
- package/dist/skills/packaged.js +32 -0
- package/dist/skills/sync.d.ts +84 -0
- package/dist/skills/sync.js +136 -0
- package/dist/skills/targets.d.ts +29 -0
- package/dist/skills/targets.js +73 -0
- package/package.json +4 -3
- package/skills/paramour/SKILL.md +43 -0
- package/skills/paramour/references/authoring.md +113 -0
- package/skills/paramour/references/migration.md +141 -0
- package/skills/paramour/references/reference.md +96 -0
- package/skills/paramour/references/setup.md +139 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { parseCommandFlags } from "../cli-args.js";
|
|
2
|
+
import { message, resolveIo } from "../cli-io.js";
|
|
3
|
+
import { loadPackagedSkill } from "../skills/packaged.js";
|
|
4
|
+
import { auditTarget, FILE_STATUS_DETAIL, isOutdated, syncTarget, } from "../skills/sync.js";
|
|
5
|
+
import { resolveTargets } from "../skills/targets.js";
|
|
6
|
+
const USAGE = [
|
|
7
|
+
"Usage: paramour skills [options]",
|
|
8
|
+
"",
|
|
9
|
+
"Install the bundled Paramour agent skill into each detected agent tool's",
|
|
10
|
+
"skills directory (.agents/, .claude/, .codex/, .cursor/), stamping every",
|
|
11
|
+
"install with a .paramour-skills.json manifest so re-syncs are safe:",
|
|
12
|
+
"locally-modified files are never overwritten without --force. With no",
|
|
13
|
+
"tools detected, installs to the portable .agents/skills/.",
|
|
14
|
+
"",
|
|
15
|
+
"Exit codes: 0 success (including skipped local edits), 1 under --check",
|
|
16
|
+
"when any installed copy is missing or stale, 2 usage/operational errors.",
|
|
17
|
+
"",
|
|
18
|
+
"Options:",
|
|
19
|
+
" --check verify installed skills instead of writing; exit 1 when",
|
|
20
|
+
" any copy is missing or stale; passes with nothing to",
|
|
21
|
+
" check when no agent tooling is detected",
|
|
22
|
+
" --dry-run report what would be written without writing",
|
|
23
|
+
" --force overwrite locally-modified skill files",
|
|
24
|
+
" --help, -h show this help",
|
|
25
|
+
" --json machine-readable output",
|
|
26
|
+
" --tool <t> target tool(s): agents, claude, codex, cursor",
|
|
27
|
+
" (repeatable or comma-separated; overrides detection)",
|
|
28
|
+
].join("\n");
|
|
29
|
+
/** @internal `paramour skills` flags — skill-drift test's source of truth. */
|
|
30
|
+
export const SKILLS_OPTIONS = {
|
|
31
|
+
check: { default: false, type: "boolean" },
|
|
32
|
+
"dry-run": { default: false, type: "boolean" },
|
|
33
|
+
force: { default: false, type: "boolean" },
|
|
34
|
+
help: { default: false, short: "h", type: "boolean" },
|
|
35
|
+
json: { default: false, type: "boolean" },
|
|
36
|
+
tool: { multiple: true, type: "string" },
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* @internal Per-orphan reporting for a sync: removals (deletions are the one
|
|
40
|
+
* thing a sync does that must never happen silently) and kept locally-modified
|
|
41
|
+
* orphans. Shared by `paramour skills` and `paramour init` so the two commands
|
|
42
|
+
* describe the same operation in the same words.
|
|
43
|
+
*/
|
|
44
|
+
export function reportOrphans(result, dry, stdout) {
|
|
45
|
+
for (const orphan of result.removedOrphans) {
|
|
46
|
+
stdout(` ${dry ? "would remove" : "removed"} ${orphan} — no longer part of the skill`);
|
|
47
|
+
}
|
|
48
|
+
for (const orphan of result.keptOrphans) {
|
|
49
|
+
stdout(` left ${orphan} — locally modified and no longer part of the skill`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* @internal `paramour skills` — the installer for the bundled agent skill.
|
|
54
|
+
* `--check` follows `check`'s exit class: 1 means "the installed copies do
|
|
55
|
+
* not reflect the installed package". A plain run treats refusing to clobber
|
|
56
|
+
* local edits as success (init's manual-fallback precedent): the refusal is
|
|
57
|
+
* printed, the fix (`--force`) is named, and nothing is broken.
|
|
58
|
+
*/
|
|
59
|
+
export function runSkills(argv, io) {
|
|
60
|
+
const { stderr, stdout } = resolveIo(io);
|
|
61
|
+
const parsed = parseCommandFlags(argv, SKILLS_OPTIONS, USAGE, {
|
|
62
|
+
stderr,
|
|
63
|
+
stdout,
|
|
64
|
+
});
|
|
65
|
+
if ("exit" in parsed)
|
|
66
|
+
return parsed.exit;
|
|
67
|
+
const flags = parsed.values;
|
|
68
|
+
// --check never writes, so a write-shaping flag alongside it is a
|
|
69
|
+
// contradiction, not a no-op — reject loudly.
|
|
70
|
+
for (const conflicting of ["dry-run", "force"]) {
|
|
71
|
+
if (flags.check && flags[conflicting]) {
|
|
72
|
+
stderr(`paramour: --check cannot be combined with --${conflicting}`);
|
|
73
|
+
stderr(USAGE);
|
|
74
|
+
return 2;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const projectRoot = process.cwd();
|
|
78
|
+
const resolved = resolveTargets(projectRoot, flags.tool);
|
|
79
|
+
if ("error" in resolved) {
|
|
80
|
+
stderr(`paramour: ${resolved.error}`);
|
|
81
|
+
stderr(USAGE);
|
|
82
|
+
return 2;
|
|
83
|
+
}
|
|
84
|
+
let packaged;
|
|
85
|
+
try {
|
|
86
|
+
packaged = loadPackagedSkill();
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
stderr(`paramour: bundled skill content unreadable: ${message(error)}`);
|
|
90
|
+
return 2;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
return flags.check
|
|
94
|
+
? runCheck(resolved.targets, packaged, { fallback: resolved.fallback, json: flags.json }, stdout)
|
|
95
|
+
: runInstall(resolved.targets, packaged, {
|
|
96
|
+
dry: flags["dry-run"],
|
|
97
|
+
fallback: resolved.fallback,
|
|
98
|
+
force: flags.force,
|
|
99
|
+
json: flags.json,
|
|
100
|
+
}, stdout);
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
stderr(`paramour: ${message(error)}`);
|
|
104
|
+
return 2;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** Target-level `--check` verdict, doctor's status vocabulary. */
|
|
108
|
+
function checkStatus(audit) {
|
|
109
|
+
const statuses = audit.files.map((file) => file.status);
|
|
110
|
+
if (statuses.some(isOutdated))
|
|
111
|
+
return "fail";
|
|
112
|
+
return statuses.some((status) => status === "modified") ? "warn" : "pass";
|
|
113
|
+
}
|
|
114
|
+
function reportInstall(result, dry, stdout) {
|
|
115
|
+
const { rel } = result.audit.target;
|
|
116
|
+
const freshInstall = result.audit.manifest === undefined;
|
|
117
|
+
if (result.written.length > 0) {
|
|
118
|
+
const verb = dry
|
|
119
|
+
? freshInstall
|
|
120
|
+
? "would install"
|
|
121
|
+
: "would update"
|
|
122
|
+
: freshInstall
|
|
123
|
+
? "installed"
|
|
124
|
+
: "updated";
|
|
125
|
+
const count = `${String(result.written.length)} file${result.written.length === 1 ? "" : "s"}`;
|
|
126
|
+
stdout(` ✔ ${rel} — ${verb} ${freshInstall ? `(${count})` : count}`);
|
|
127
|
+
if (!freshInstall)
|
|
128
|
+
stdout(` ${result.written.join(", ")}`);
|
|
129
|
+
}
|
|
130
|
+
else if (result.skippedModified.length === 0) {
|
|
131
|
+
stdout(` • ${rel} — up to date`);
|
|
132
|
+
}
|
|
133
|
+
if (result.skippedModified.length > 0) {
|
|
134
|
+
const count = result.skippedModified.length;
|
|
135
|
+
stdout(` ⚠ ${rel} — ${String(count)} locally-modified file${count === 1 ? "" : "s"} left as-is (--force overwrites)`);
|
|
136
|
+
stdout(` ${result.skippedModified.join(", ")}`);
|
|
137
|
+
}
|
|
138
|
+
reportOrphans(result, dry, stdout);
|
|
139
|
+
}
|
|
140
|
+
function runCheck(targets, packaged, options, stdout) {
|
|
141
|
+
// --check's verdict is about the detection result. With no agent tooling
|
|
142
|
+
// detected (and no explicit --tool), the only "target" is the invented
|
|
143
|
+
// portable fallback a bare install would use — a location this project
|
|
144
|
+
// never opted into, so auditing it can only ever fail. There is nothing
|
|
145
|
+
// to check, and nothing to check is a pass.
|
|
146
|
+
if (options.fallback) {
|
|
147
|
+
if (options.json) {
|
|
148
|
+
stdout(JSON.stringify({ fallback: true, status: "pass", targets: [] }, null, 2));
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
stdout(" • no agent tooling detected — nothing to check");
|
|
152
|
+
}
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
const audits = targets.map((target) => auditTarget(target, packaged));
|
|
156
|
+
const verdicts = audits.map(checkStatus);
|
|
157
|
+
const failed = verdicts.filter((verdict) => verdict === "fail").length;
|
|
158
|
+
const warned = verdicts.filter((verdict) => verdict === "warn").length;
|
|
159
|
+
const status = failed > 0 ? "fail" : warned > 0 ? "warn" : "pass";
|
|
160
|
+
if (options.json) {
|
|
161
|
+
stdout(JSON.stringify({
|
|
162
|
+
fallback: false,
|
|
163
|
+
status,
|
|
164
|
+
targets: audits.map((audit) => ({
|
|
165
|
+
files: audit.files,
|
|
166
|
+
rel: audit.target.rel,
|
|
167
|
+
tool: audit.target.tool,
|
|
168
|
+
})),
|
|
169
|
+
}, null, 2));
|
|
170
|
+
return failed > 0 ? 1 : 0;
|
|
171
|
+
}
|
|
172
|
+
const marks = { fail: "✖", pass: "✔", warn: "⚠" };
|
|
173
|
+
for (const [index, audit] of audits.entries()) {
|
|
174
|
+
const verdict = verdicts[index] ?? "pass";
|
|
175
|
+
const label = verdict === "pass"
|
|
176
|
+
? "up to date"
|
|
177
|
+
: verdict === "warn"
|
|
178
|
+
? "locally modified"
|
|
179
|
+
: audit.files.every((file) => file.status === "missing")
|
|
180
|
+
? "not installed (run `paramour skills`)"
|
|
181
|
+
: "stale (run `paramour skills`)";
|
|
182
|
+
stdout(` ${marks[verdict]} ${audit.target.rel} — ${label}`);
|
|
183
|
+
for (const file of audit.files) {
|
|
184
|
+
const detail = FILE_STATUS_DETAIL[file.status];
|
|
185
|
+
if (detail !== undefined)
|
|
186
|
+
stdout(` ${file.relPath}: ${detail}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
stdout("");
|
|
190
|
+
stdout(`skills: ${String(audits.length)} target${audits.length === 1 ? "" : "s"} — ${String(failed)} stale, ${String(warned)} modified`);
|
|
191
|
+
return failed > 0 ? 1 : 0;
|
|
192
|
+
}
|
|
193
|
+
function runInstall(targets, packaged, options, stdout) {
|
|
194
|
+
const results = targets.map((target) => syncTarget(target, packaged, { dry: options.dry, force: options.force }));
|
|
195
|
+
if (options.json) {
|
|
196
|
+
stdout(JSON.stringify({
|
|
197
|
+
dry: options.dry,
|
|
198
|
+
fallback: options.fallback,
|
|
199
|
+
status: results.some((result) => result.skippedModified.length > 0)
|
|
200
|
+
? "warn"
|
|
201
|
+
: "ok",
|
|
202
|
+
targets: results.map((result) => ({
|
|
203
|
+
keptOrphans: result.keptOrphans,
|
|
204
|
+
rel: result.audit.target.rel,
|
|
205
|
+
removedOrphans: result.removedOrphans,
|
|
206
|
+
skippedModified: result.skippedModified,
|
|
207
|
+
tool: result.audit.target.tool,
|
|
208
|
+
written: result.written,
|
|
209
|
+
})),
|
|
210
|
+
}, null, 2));
|
|
211
|
+
return 0;
|
|
212
|
+
}
|
|
213
|
+
stdout(options.dry
|
|
214
|
+
? "paramour skills (dry run — nothing written)"
|
|
215
|
+
: "paramour skills");
|
|
216
|
+
if (options.fallback) {
|
|
217
|
+
stdout(" → no agent tooling detected — installing to the portable .agents/skills/");
|
|
218
|
+
}
|
|
219
|
+
for (const result of results)
|
|
220
|
+
reportInstall(result, options.dry, stdout);
|
|
221
|
+
return 0;
|
|
222
|
+
}
|
package/dist/config.d.ts
CHANGED
|
@@ -20,6 +20,13 @@ export interface ParamourConfig {
|
|
|
20
20
|
*/
|
|
21
21
|
routeFiles?: string[];
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* @internal Every ParamourConfig key — the skill-drift test's source of
|
|
25
|
+
* truth. `satisfies Record<keyof ParamourConfig, true>` rejects a missing
|
|
26
|
+
* AND an extra key at compile time, so this roster cannot drift from the
|
|
27
|
+
* interface.
|
|
28
|
+
*/
|
|
29
|
+
export declare const PARAMOUR_CONFIG_KEYS: readonly string[];
|
|
23
30
|
/** @internal Discovery order at the project root — first match wins. */
|
|
24
31
|
export declare const CONFIG_FILE_NAMES: readonly ["paramour.config.ts", "paramour.config.mjs", "paramour.config.json"];
|
|
25
32
|
/**
|
package/dist/config.js
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* @internal Every ParamourConfig key — the skill-drift test's source of
|
|
5
|
+
* truth. `satisfies Record<keyof ParamourConfig, true>` rejects a missing
|
|
6
|
+
* AND an extra key at compile time, so this roster cannot drift from the
|
|
7
|
+
* interface.
|
|
8
|
+
*/
|
|
9
|
+
export const PARAMOUR_CONFIG_KEYS = Object.keys({
|
|
10
|
+
appDir: true,
|
|
11
|
+
outFile: true,
|
|
12
|
+
pageExtensions: true,
|
|
13
|
+
pagesDir: true,
|
|
14
|
+
routeFiles: true,
|
|
15
|
+
});
|
|
3
16
|
/** @internal Discovery order at the project root — first match wins. */
|
|
4
17
|
export const CONFIG_FILE_NAMES = [
|
|
5
18
|
"paramour.config.ts",
|
package/dist/devtools-seam.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { AnyRoute, ParamsSource, RouterKind, SafeResult } from "paramour";
|
|
|
2
2
|
/**
|
|
3
3
|
* The devtools observation seam: a dependency-free global
|
|
4
4
|
* slot the hooks push decode observations into and the devtools panel
|
|
5
|
-
* (`@paramour-js/devtools`) reads out of. This module's JSDoc is the
|
|
5
|
+
* (`@paramour-js/devtools-panel`) reads out of. This module's JSDoc is the
|
|
6
6
|
* CONTRACT OF RECORD for the slot — the panel never imports runtime code
|
|
7
7
|
* from this package (its `./devtools-seam` exports entry is types-only, so
|
|
8
8
|
* a runtime import fails module resolution); it attaches to the same
|
package/dist/doctor/checks.js
CHANGED
|
@@ -8,6 +8,7 @@ import { tsconfigCheck } from "../init/scaffold.js";
|
|
|
8
8
|
import { detectWrapState, findNextConfig } from "../init/wrap-next-config.js";
|
|
9
9
|
import { discoverRouteDefinitions, routeKey, } from "../list/discover-route-defs.js";
|
|
10
10
|
import { scanRoutes } from "../scan.js";
|
|
11
|
+
import { skillsDoctorChecks } from "../skills/doctor.js";
|
|
11
12
|
/**
|
|
12
13
|
* @internal The check battery, in report order. Each check degrades
|
|
13
14
|
* independently — doctor exists to diagnose broken setups, so a throwing
|
|
@@ -135,7 +136,10 @@ export async function runDoctorChecks(projectRoot) {
|
|
|
135
136
|
}
|
|
136
137
|
// 5. Version alignment between the two packages.
|
|
137
138
|
checks.push(versionCheck(projectRoot));
|
|
138
|
-
// 6.
|
|
139
|
+
// 6. Installed agent skills reflect this package's bundled content —
|
|
140
|
+
// upgrade-adjacent like the version check, hence its neighbor.
|
|
141
|
+
checks.push(...skillsDoctorChecks(projectRoot));
|
|
142
|
+
// 7. tsconfig covers the artifact (init's warn-level heuristic).
|
|
139
143
|
// resolve, not join — an absolute outFile must win, as it does in
|
|
140
144
|
// resolveInputs.
|
|
141
145
|
const artifactPath = inputs?.artifactPath ??
|
|
@@ -146,7 +150,7 @@ export async function runDoctorChecks(projectRoot) {
|
|
|
146
150
|
label: `tsconfig: ${coverage.label}`,
|
|
147
151
|
status: coverage.ok ? "pass" : "warn",
|
|
148
152
|
});
|
|
149
|
-
//
|
|
153
|
+
// 8. Route-definition discovery health (list's engine).
|
|
150
154
|
checks.push(await discoveryCheck(projectRoot, config, routes));
|
|
151
155
|
return checks;
|
|
152
156
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export declare const AGENTS_MARKER_START = "<!-- paramour:start -->";
|
|
2
|
+
export declare const AGENTS_MARKER_END = "<!-- paramour:end -->";
|
|
3
|
+
/**
|
|
4
|
+
* @internal The marker-managed section init appends to an agent
|
|
5
|
+
* instructions file. Install-dir-agnostic on purpose: the skill lands in
|
|
6
|
+
* `.claude/`, `.cursor/`, or the portable `.agents/` depending on what
|
|
7
|
+
* `detectTargets` found, and this snippet must stay true for all of them.
|
|
8
|
+
*/
|
|
9
|
+
export declare function agentsSnippet(): string;
|
|
10
|
+
/**
|
|
11
|
+
* @internal The instructions file the snippet goes into: AGENTS.md first
|
|
12
|
+
* (the cross-tool convention), CLAUDE.md as the fallback, never both (tools
|
|
13
|
+
* that read both would see duplicated content, and projects keeping both
|
|
14
|
+
* usually mirror them — one marker-managed copy is the single source of
|
|
15
|
+
* truth). Never creates a file: a root AGENTS.md is a `detectTargets`
|
|
16
|
+
* detection signal, so init inventing one would change what the skills
|
|
17
|
+
* step sees on the next run.
|
|
18
|
+
*/
|
|
19
|
+
export declare function findAgentsFile(projectRoot: string): string | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* @internal Add or refresh the marker-delimited paramour section. Pure
|
|
22
|
+
* string→string (the `addPackageScript` pattern) so the write/dry-run
|
|
23
|
+
* decision stays with the caller. Re-runs reconcile the section to the
|
|
24
|
+
* current snippet — the markers exist precisely to delimit
|
|
25
|
+
* paramour-managed content; prose outside them is never touched. A start
|
|
26
|
+
* marker without an end marker is the one state left alone: repairing it
|
|
27
|
+
* would mean guessing where the user's own text begins.
|
|
28
|
+
*/
|
|
29
|
+
export declare function upsertAgentsSection(text: string): {
|
|
30
|
+
status: "added" | "unchanged" | "unterminated" | "updated";
|
|
31
|
+
text: string;
|
|
32
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
export const AGENTS_MARKER_START = "<!-- paramour:start -->";
|
|
4
|
+
export const AGENTS_MARKER_END = "<!-- paramour:end -->";
|
|
5
|
+
/**
|
|
6
|
+
* @internal The marker-managed section init appends to an agent
|
|
7
|
+
* instructions file. Install-dir-agnostic on purpose: the skill lands in
|
|
8
|
+
* `.claude/`, `.cursor/`, or the portable `.agents/` depending on what
|
|
9
|
+
* `detectTargets` found, and this snippet must stay true for all of them.
|
|
10
|
+
*/
|
|
11
|
+
export function agentsSnippet() {
|
|
12
|
+
return [
|
|
13
|
+
AGENTS_MARKER_START,
|
|
14
|
+
"",
|
|
15
|
+
"## paramour",
|
|
16
|
+
"",
|
|
17
|
+
"This project uses paramour for type-safe routing. The paramour agent",
|
|
18
|
+
"skill (installed by `paramour skills` into detected agent-tool skills",
|
|
19
|
+
"directories) documents codecs, route definitions, hooks, and the CLI —",
|
|
20
|
+
"read it before route work.",
|
|
21
|
+
"",
|
|
22
|
+
"After changing routes: run `paramour generate`, verify with `paramour",
|
|
23
|
+
"check` (exit 0 = artifact current), inspect shapes with `paramour list`,",
|
|
24
|
+
"and commit `paramour-env.d.ts`.",
|
|
25
|
+
"",
|
|
26
|
+
AGENTS_MARKER_END,
|
|
27
|
+
].join("\n");
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* @internal The instructions file the snippet goes into: AGENTS.md first
|
|
31
|
+
* (the cross-tool convention), CLAUDE.md as the fallback, never both (tools
|
|
32
|
+
* that read both would see duplicated content, and projects keeping both
|
|
33
|
+
* usually mirror them — one marker-managed copy is the single source of
|
|
34
|
+
* truth). Never creates a file: a root AGENTS.md is a `detectTargets`
|
|
35
|
+
* detection signal, so init inventing one would change what the skills
|
|
36
|
+
* step sees on the next run.
|
|
37
|
+
*/
|
|
38
|
+
export function findAgentsFile(projectRoot) {
|
|
39
|
+
for (const name of ["AGENTS.md", "CLAUDE.md"]) {
|
|
40
|
+
const path = join(projectRoot, name);
|
|
41
|
+
if (existsSync(path))
|
|
42
|
+
return path;
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* @internal Add or refresh the marker-delimited paramour section. Pure
|
|
48
|
+
* string→string (the `addPackageScript` pattern) so the write/dry-run
|
|
49
|
+
* decision stays with the caller. Re-runs reconcile the section to the
|
|
50
|
+
* current snippet — the markers exist precisely to delimit
|
|
51
|
+
* paramour-managed content; prose outside them is never touched. A start
|
|
52
|
+
* marker without an end marker is the one state left alone: repairing it
|
|
53
|
+
* would mean guessing where the user's own text begins.
|
|
54
|
+
*/
|
|
55
|
+
export function upsertAgentsSection(text) {
|
|
56
|
+
const start = text.indexOf(AGENTS_MARKER_START);
|
|
57
|
+
if (start === -1) {
|
|
58
|
+
const base = text === "" || text.endsWith("\n") ? text : `${text}\n`;
|
|
59
|
+
const separator = base === "" ? "" : "\n";
|
|
60
|
+
return {
|
|
61
|
+
status: "added",
|
|
62
|
+
text: `${base}${separator}${agentsSnippet()}\n`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const end = text.indexOf(AGENTS_MARKER_END, start);
|
|
66
|
+
if (end === -1)
|
|
67
|
+
return { status: "unterminated", text };
|
|
68
|
+
const current = text.slice(start, end + AGENTS_MARKER_END.length);
|
|
69
|
+
const snippet = agentsSnippet();
|
|
70
|
+
if (current === snippet)
|
|
71
|
+
return { status: "unchanged", text };
|
|
72
|
+
return {
|
|
73
|
+
status: "updated",
|
|
74
|
+
text: text.slice(0, start) +
|
|
75
|
+
snippet +
|
|
76
|
+
text.slice(end + AGENTS_MARKER_END.length),
|
|
77
|
+
};
|
|
78
|
+
}
|
package/dist/init/scaffold.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join, relative } from "node:path";
|
|
3
3
|
import { resolveRouteDirs } from "../scan.js";
|
|
4
|
+
import { loadPackagedSkill } from "../skills/packaged.js";
|
|
5
|
+
import { auditTarget, isOutdated } from "../skills/sync.js";
|
|
6
|
+
import { detectTargets } from "../skills/targets.js";
|
|
4
7
|
/**
|
|
5
8
|
* Insert `"paramour": "paramour generate"` into a package.json's scripts,
|
|
6
9
|
* preserving the file's own indentation and trailing-newline choice.
|
|
@@ -58,6 +61,7 @@ export function checkSetup(projectRoot, artifactPath) {
|
|
|
58
61
|
}
|
|
59
62
|
checks.push(dependenciesCheck(projectRoot));
|
|
60
63
|
checks.push(tsconfigCheck(projectRoot, artifactPath));
|
|
64
|
+
checks.push(...skillsSetupCheck(projectRoot));
|
|
61
65
|
return checks;
|
|
62
66
|
}
|
|
63
67
|
/** The starter `paramour.config.ts` — every field commented-out defaults. */
|
|
@@ -205,6 +209,56 @@ function dependenciesCheck(projectRoot) {
|
|
|
205
209
|
ok: false,
|
|
206
210
|
};
|
|
207
211
|
}
|
|
212
|
+
/**
|
|
213
|
+
* Agent-skills summary line — only for projects with agent tooling; an
|
|
214
|
+
* agent-free project gets no line at all, keeping the summary signal-dense.
|
|
215
|
+
*/
|
|
216
|
+
function skillsSetupCheck(projectRoot) {
|
|
217
|
+
try {
|
|
218
|
+
const detected = detectTargets(projectRoot);
|
|
219
|
+
if (detected.length === 0)
|
|
220
|
+
return [];
|
|
221
|
+
const packaged = loadPackagedSkill();
|
|
222
|
+
const audits = detected
|
|
223
|
+
.map((target) => auditTarget(target, packaged))
|
|
224
|
+
.filter((audit) => audit.manifest !== undefined);
|
|
225
|
+
if (audits.length === 0) {
|
|
226
|
+
return [
|
|
227
|
+
{
|
|
228
|
+
detail: "run `paramour skills`",
|
|
229
|
+
label: "agent skills: not installed",
|
|
230
|
+
ok: false,
|
|
231
|
+
},
|
|
232
|
+
];
|
|
233
|
+
}
|
|
234
|
+
// Local edits are legitimate tailoring, not drift — only missing/stale
|
|
235
|
+
// content makes the line a warning.
|
|
236
|
+
const outdated = audits.filter((audit) => audit.files.some((file) => isOutdated(file.status)));
|
|
237
|
+
if (outdated.length > 0) {
|
|
238
|
+
return [
|
|
239
|
+
{
|
|
240
|
+
detail: "run `paramour skills` to re-sync",
|
|
241
|
+
label: `agent skills: ${outdated
|
|
242
|
+
.map((audit) => audit.target.rel)
|
|
243
|
+
.join(", ")} out of date`,
|
|
244
|
+
ok: false,
|
|
245
|
+
},
|
|
246
|
+
];
|
|
247
|
+
}
|
|
248
|
+
return [
|
|
249
|
+
{
|
|
250
|
+
label: `agent skills: ${audits
|
|
251
|
+
.map((audit) => audit.target.rel)
|
|
252
|
+
.join(", ")} up to date`,
|
|
253
|
+
ok: true,
|
|
254
|
+
},
|
|
255
|
+
];
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
// A broken skills probe must not break the warn-level summary.
|
|
259
|
+
return [];
|
|
260
|
+
}
|
|
261
|
+
}
|
|
208
262
|
/**
|
|
209
263
|
* String-aware trailing-comma removal over comment-free JSONC — a flat
|
|
210
264
|
* regex would also rewrite `,}`/`,]` inside string literals (glob patterns).
|
package/dist/run-cli.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type CliIo } from "./cli-io.js";
|
|
2
2
|
export { type CliIo } from "./cli-io.js";
|
|
3
|
+
type Command = (argv: readonly string[], io: CliIo) => Promise<number>;
|
|
4
|
+
export declare const COMMANDS: Record<string, Command>;
|
|
3
5
|
/**
|
|
4
6
|
* @internal The CLI dispatcher, in-process testable: returns the exit
|
|
5
7
|
* code instead of exiting. The exit-code contract holds across every
|
package/dist/run-cli.js
CHANGED
|
@@ -3,14 +3,17 @@ import { runDoctor } from "./commands/doctor.js";
|
|
|
3
3
|
import { runGenerate } from "./commands/generate.js";
|
|
4
4
|
import { runInit } from "./commands/init.js";
|
|
5
5
|
import { runList } from "./commands/list.js";
|
|
6
|
+
import { runSkills } from "./commands/skills.js";
|
|
6
7
|
export {} from "./cli-io.js";
|
|
7
|
-
// Alphabetical; the unknown-command message derives from these keys
|
|
8
|
-
|
|
8
|
+
// Alphabetical; the unknown-command message derives from these keys, and the
|
|
9
|
+
// skill-drift test pins them against the skill's CLI table.
|
|
10
|
+
export const COMMANDS = {
|
|
9
11
|
check: (argv, io) => runGenerate(argv, io, "check"),
|
|
10
12
|
doctor: runDoctor,
|
|
11
13
|
generate: (argv, io) => runGenerate(argv, io, "generate"),
|
|
12
14
|
init: runInit,
|
|
13
15
|
list: runList,
|
|
16
|
+
skills: (argv, io) => Promise.resolve(runSkills(argv, io)),
|
|
14
17
|
};
|
|
15
18
|
const USAGE = [
|
|
16
19
|
"Usage: paramour <command> [options]",
|
|
@@ -21,6 +24,7 @@ const USAGE = [
|
|
|
21
24
|
" generate generate paramour-env.d.ts from the app and pages directories",
|
|
22
25
|
" init set up paramour in this project",
|
|
23
26
|
" list print every route with its params/search shape",
|
|
27
|
+
" skills install or verify the bundled agent skill for detected agent tools",
|
|
24
28
|
"",
|
|
25
29
|
"Run `paramour <command> --help` for that command's options.",
|
|
26
30
|
].join("\n");
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { DoctorCheck } from "../doctor/checks.js";
|
|
2
|
+
/**
|
|
3
|
+
* Doctor's skills battery. Intent comes from manifest presence — doctor is
|
|
4
|
+
* passive and must not nag a project that never opted into skills — unlike
|
|
5
|
+
* `skills --check`, whose intent is the detection result (it is added to CI
|
|
6
|
+
* deliberately). Findings are warn-level, never fail: stale agent guidance
|
|
7
|
+
* degrades agent output but breaks nothing at build or runtime, and a fail
|
|
8
|
+
* here would flip doctor's exit to 1 in every consumer the day after every
|
|
9
|
+
* paramour release. The CI-fatal surface is `paramour skills --check`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function skillsDoctorChecks(projectRoot: string): DoctorCheck[];
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { message } from "../cli-io.js";
|
|
2
|
+
import { loadPackagedSkill } from "./packaged.js";
|
|
3
|
+
import { auditTarget, FILE_STATUS_DETAIL, isOutdated } from "./sync.js";
|
|
4
|
+
import { detectTargets } from "./targets.js";
|
|
5
|
+
/**
|
|
6
|
+
* Doctor's skills battery. Intent comes from manifest presence — doctor is
|
|
7
|
+
* passive and must not nag a project that never opted into skills — unlike
|
|
8
|
+
* `skills --check`, whose intent is the detection result (it is added to CI
|
|
9
|
+
* deliberately). Findings are warn-level, never fail: stale agent guidance
|
|
10
|
+
* degrades agent output but breaks nothing at build or runtime, and a fail
|
|
11
|
+
* here would flip doctor's exit to 1 in every consumer the day after every
|
|
12
|
+
* paramour release. The CI-fatal surface is `paramour skills --check`.
|
|
13
|
+
*/
|
|
14
|
+
export function skillsDoctorChecks(projectRoot) {
|
|
15
|
+
try {
|
|
16
|
+
const detected = detectTargets(projectRoot);
|
|
17
|
+
if (detected.length === 0) {
|
|
18
|
+
return [
|
|
19
|
+
{
|
|
20
|
+
label: "skills: no agent tooling detected — skipped",
|
|
21
|
+
status: "pass",
|
|
22
|
+
},
|
|
23
|
+
];
|
|
24
|
+
}
|
|
25
|
+
const packaged = loadPackagedSkill();
|
|
26
|
+
const audits = detected
|
|
27
|
+
.map((target) => auditTarget(target, packaged))
|
|
28
|
+
.filter((audit) => audit.manifest !== undefined);
|
|
29
|
+
if (audits.length === 0) {
|
|
30
|
+
// Pass with an advisory label (check 1's "defaults in effect"
|
|
31
|
+
// precedent): declining skills is a legitimate steady state.
|
|
32
|
+
return [
|
|
33
|
+
{
|
|
34
|
+
label: `skills: not installed (\`paramour skills\` installs agent skills for ${detected
|
|
35
|
+
.map((target) => `${target.rel.split("/")[0] ?? ""}/`)
|
|
36
|
+
.join(", ")})`,
|
|
37
|
+
status: "pass",
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
return audits.map((audit) => {
|
|
42
|
+
const detail = audit.files
|
|
43
|
+
.map((file) => {
|
|
44
|
+
const note = FILE_STATUS_DETAIL[file.status];
|
|
45
|
+
return note === undefined ? undefined : `${file.relPath}: ${note}`;
|
|
46
|
+
})
|
|
47
|
+
.filter((line) => line !== undefined);
|
|
48
|
+
if (detail.length === 0) {
|
|
49
|
+
return {
|
|
50
|
+
label: `skills: ${audit.target.rel} is up to date`,
|
|
51
|
+
status: "pass",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const outdated = audit.files.some((file) => isOutdated(file.status));
|
|
55
|
+
return {
|
|
56
|
+
detail,
|
|
57
|
+
label: `skills: ${audit.target.rel} ${outdated ? "is stale" : "has local edits"}`,
|
|
58
|
+
status: "warn",
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
return [
|
|
64
|
+
{
|
|
65
|
+
detail: [message(error)],
|
|
66
|
+
label: "skills: could not audit installed skills",
|
|
67
|
+
status: "warn",
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sidecar stamp `paramour skills` writes next to every installed copy.
|
|
3
|
+
* It lives inside the installed skill directory (not at the project root) so
|
|
4
|
+
* deleting a tool directory is a clean uninstall — no orphaned record keeps
|
|
5
|
+
* reporting the skill as missing after a deliberate removal. Skill loaders
|
|
6
|
+
* ignore dotfiles under progressive disclosure, so the installed skill files
|
|
7
|
+
* themselves stay byte-identical to the published ones.
|
|
8
|
+
*/
|
|
9
|
+
export interface SkillManifest {
|
|
10
|
+
/** POSIX-relative path → `sha256:<hex>` of the content last synced. */
|
|
11
|
+
files: Record<string, string>;
|
|
12
|
+
skill: "paramour";
|
|
13
|
+
/**
|
|
14
|
+
* The `@paramour-js/next` version that last wrote this manifest.
|
|
15
|
+
* Informational only: staleness truth is always hash comparison, so a
|
|
16
|
+
* version bump without content changes never reads as stale.
|
|
17
|
+
*/
|
|
18
|
+
version: string;
|
|
19
|
+
}
|
|
20
|
+
export declare const MANIFEST_FILENAME = ".paramour-skills.json";
|
|
21
|
+
/**
|
|
22
|
+
* CRLF-normalized sha256. Normalizing before hashing makes every status
|
|
23
|
+
* computation immune to a consumer repo's git `autocrlf` rewriting the
|
|
24
|
+
* installed markdown on checkout — a line-ending flip is not a content edit.
|
|
25
|
+
*/
|
|
26
|
+
export declare function hashContent(text: string): string;
|
|
27
|
+
/**
|
|
28
|
+
* Read a target's manifest; `undefined` when absent or malformed. A corrupt
|
|
29
|
+
* manifest degrades to "no provenance": byte-identical files still audit as
|
|
30
|
+
* fresh and anything else audits as locally modified, which the installer
|
|
31
|
+
* refuses to overwrite without `--force` — the safe direction. A manifest
|
|
32
|
+
* containing any file key that could escape the skill directory is treated
|
|
33
|
+
* as corrupt the same way, since those keys become deletion paths in sync.
|
|
34
|
+
*/
|
|
35
|
+
export declare function readSkillManifest(skillDir: string): SkillManifest | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* Deterministic serialization: sorted file keys, two-space indent, LF,
|
|
38
|
+
* trailing newline, no timestamps — same doctrine as the generated artifact,
|
|
39
|
+
* so a re-run that changes nothing writes nothing.
|
|
40
|
+
*/
|
|
41
|
+
export declare function renderManifest(manifest: SkillManifest): string;
|