@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.
@@ -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",
@@ -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
@@ -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. tsconfig covers the artifact (init's warn-level heuristic).
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
- // 7. Route-definition discovery health (list's engine).
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
+ }
@@ -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
- const COMMANDS = {
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;