@paramour-js/next 0.4.0 → 0.5.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 (74) hide show
  1. package/README.md +1 -1
  2. package/dist/app.d.ts +48 -49
  3. package/dist/app.js +11 -12
  4. package/dist/cli-args.d.ts +4 -4
  5. package/dist/cli-args.js +4 -4
  6. package/dist/cli-inputs.d.ts +6 -7
  7. package/dist/cli-inputs.js +6 -7
  8. package/dist/cli.js +1 -1
  9. package/dist/collisions.d.ts +8 -8
  10. package/dist/collisions.js +9 -9
  11. package/dist/commands/doctor.d.ts +12 -0
  12. package/dist/commands/doctor.js +12 -7
  13. package/dist/commands/generate.d.ts +55 -7
  14. package/dist/commands/generate.js +29 -22
  15. package/dist/commands/init.d.ts +40 -0
  16. package/dist/commands/init.js +111 -19
  17. package/dist/commands/list.d.ts +21 -0
  18. package/dist/commands/list.js +12 -7
  19. package/dist/commands/skills.d.ts +45 -0
  20. package/dist/commands/skills.js +222 -0
  21. package/dist/config.d.ts +17 -10
  22. package/dist/config.js +17 -4
  23. package/dist/devtools-seam.d.ts +19 -19
  24. package/dist/devtools-seam.js +3 -3
  25. package/dist/doctor/checks.js +7 -3
  26. package/dist/emit.d.ts +12 -12
  27. package/dist/emit.js +13 -13
  28. package/dist/generate.d.ts +14 -14
  29. package/dist/generate.js +11 -11
  30. package/dist/init/agents-md.d.ts +32 -0
  31. package/dist/init/agents-md.js +78 -0
  32. package/dist/init/scaffold.js +54 -0
  33. package/dist/list/discover-route-defs.d.ts +4 -4
  34. package/dist/list/discover-route-defs.js +4 -4
  35. package/dist/lock.d.ts +8 -9
  36. package/dist/lock.js +11 -12
  37. package/dist/navigation-adapter.d.ts +17 -18
  38. package/dist/navigation-adapter.js +4 -4
  39. package/dist/observe.d.ts +14 -14
  40. package/dist/observe.js +4 -4
  41. package/dist/pages.d.ts +30 -31
  42. package/dist/pages.js +10 -10
  43. package/dist/run-cli.d.ts +3 -1
  44. package/dist/run-cli.js +7 -3
  45. package/dist/scan-app.d.ts +10 -10
  46. package/dist/scan-app.js +30 -30
  47. package/dist/scan-pages.d.ts +6 -6
  48. package/dist/scan-pages.js +28 -26
  49. package/dist/scan.d.ts +13 -10
  50. package/dist/scan.js +7 -7
  51. package/dist/select.d.ts +40 -40
  52. package/dist/select.js +30 -30
  53. package/dist/skills/doctor.d.ts +11 -0
  54. package/dist/skills/doctor.js +71 -0
  55. package/dist/skills/manifest.d.ts +41 -0
  56. package/dist/skills/manifest.js +96 -0
  57. package/dist/skills/packaged.d.ts +21 -0
  58. package/dist/skills/packaged.js +32 -0
  59. package/dist/skills/sync.d.ts +84 -0
  60. package/dist/skills/sync.js +136 -0
  61. package/dist/skills/targets.d.ts +29 -0
  62. package/dist/skills/targets.js +73 -0
  63. package/dist/testing.d.ts +28 -32
  64. package/dist/testing.js +15 -15
  65. package/dist/watch.d.ts +12 -12
  66. package/dist/watch.js +13 -13
  67. package/dist/with-typed-routes.d.ts +11 -10
  68. package/dist/with-typed-routes.js +42 -39
  69. package/package.json +4 -3
  70. package/skills/paramour/SKILL.md +43 -0
  71. package/skills/paramour/references/authoring.md +113 -0
  72. package/skills/paramour/references/migration.md +141 -0
  73. package/skills/paramour/references/reference.md +96 -0
  74. package/skills/paramour/references/setup.md +139 -0
@@ -0,0 +1,96 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { isAbsolute, join, resolve, sep } from "node:path";
4
+ export const MANIFEST_FILENAME = ".paramour-skills.json";
5
+ /**
6
+ * CRLF-normalized sha256. Normalizing before hashing makes every status
7
+ * computation immune to a consumer repo's git `autocrlf` rewriting the
8
+ * installed markdown on checkout — a line-ending flip is not a content edit.
9
+ */
10
+ export function hashContent(text) {
11
+ const digest = createHash("sha256")
12
+ .update(text.replaceAll("\r\n", "\n"))
13
+ .digest("hex");
14
+ return `sha256:${digest}`;
15
+ }
16
+ /**
17
+ * Read a target's manifest; `undefined` when absent or malformed. A corrupt
18
+ * manifest degrades to "no provenance": byte-identical files still audit as
19
+ * fresh and anything else audits as locally modified, which the installer
20
+ * refuses to overwrite without `--force` — the safe direction. A manifest
21
+ * containing any file key that could escape the skill directory is treated
22
+ * as corrupt the same way, since those keys become deletion paths in sync.
23
+ */
24
+ export function readSkillManifest(skillDir) {
25
+ const path = join(skillDir, MANIFEST_FILENAME);
26
+ if (!existsSync(path))
27
+ return undefined;
28
+ let parsed;
29
+ try {
30
+ parsed = JSON.parse(readFileSync(path, "utf8"));
31
+ }
32
+ catch {
33
+ return undefined;
34
+ }
35
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
36
+ return undefined;
37
+ }
38
+ // Fields typed unknown, not Partial<SkillManifest> — the JSON is
39
+ // arbitrary bytes, and asserting the target shape up front would make
40
+ // these guards type-narrow the wrong way.
41
+ const candidate = parsed;
42
+ if (candidate.skill !== "paramour")
43
+ return undefined;
44
+ if (typeof candidate.version !== "string")
45
+ return undefined;
46
+ const files = candidate.files;
47
+ if (typeof files !== "object" || files === null || Array.isArray(files)) {
48
+ return undefined;
49
+ }
50
+ for (const [key, value] of Object.entries(files)) {
51
+ if (!isSafeManifestKey(key, skillDir))
52
+ return undefined;
53
+ if (typeof value !== "string")
54
+ return undefined;
55
+ }
56
+ return {
57
+ files: files,
58
+ skill: "paramour",
59
+ version: candidate.version,
60
+ };
61
+ }
62
+ /**
63
+ * Deterministic serialization: sorted file keys, two-space indent, LF,
64
+ * trailing newline, no timestamps — same doctrine as the generated artifact,
65
+ * so a re-run that changes nothing writes nothing.
66
+ */
67
+ export function renderManifest(manifest) {
68
+ // Byte-order comparison, not localeCompare — locale collation varies by
69
+ // machine, and this file must be byte-identical wherever it is written.
70
+ const files = Object.fromEntries(Object.entries(manifest.files).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
71
+ const ordered = {
72
+ files,
73
+ skill: manifest.skill,
74
+ version: manifest.version,
75
+ };
76
+ return `${JSON.stringify(ordered, null, 2)}\n`;
77
+ }
78
+ /**
79
+ * Manifest keys are joined onto the skill directory and fed to the
80
+ * filesystem during sync — orphan removal in particular deletes the path a
81
+ * key names — so a tampered key like `"../../../src/index.ts"` would reach
82
+ * files far outside the install. Only a plain relative POSIX path that
83
+ * resolves strictly inside the skill directory is acceptable: no empty
84
+ * keys, no backslashes, no absolute paths (POSIX, Windows drive, or UNC),
85
+ * and the resolved path must sit under `resolve(skillDir)` followed by a
86
+ * separator so a sibling directory sharing the prefix does not slip
87
+ * through.
88
+ */
89
+ function isSafeManifestKey(key, skillDir) {
90
+ if (key === "" || key.includes("\\"))
91
+ return false;
92
+ if (isAbsolute(key) || key.startsWith("/") || /^[A-Za-z]:/.test(key)) {
93
+ return false;
94
+ }
95
+ return resolve(skillDir, key).startsWith(resolve(skillDir) + sep);
96
+ }
@@ -0,0 +1,21 @@
1
+ /** One file of the bundled skill, hashed and ready to install. */
2
+ export interface PackagedFile {
3
+ content: string;
4
+ hash: string;
5
+ /** POSIX-relative to the skill root, e.g. `references/setup.md`. */
6
+ relPath: string;
7
+ }
8
+ /** The bundled skill as shipped in this package's `skills/` directory. */
9
+ export interface PackagedSkill {
10
+ files: readonly PackagedFile[];
11
+ /** This package's own version — the value stamped into manifests. */
12
+ version: string;
13
+ }
14
+ /**
15
+ * Load the bundled skill content. Hashes are computed here, at read time,
16
+ * never committed — the changesets release flow bumps versions without a
17
+ * build step, so any committed hash would go stale on the first release.
18
+ * Throws when the bundled content is unreadable (an operational error: the
19
+ * package itself is broken).
20
+ */
21
+ export declare function loadPackagedSkill(): PackagedSkill;
@@ -0,0 +1,32 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { hashContent } from "./manifest.js";
5
+ // Two levels up is the package root from both `src/skills/` (vitest runs
6
+ // the sources) and `dist/skills/` (the built CLI): tsc mirrors src/ into
7
+ // dist/ at the same depth, so one relative URL serves both worlds.
8
+ const PACKAGE_ROOT = fileURLToPath(new URL("../../", import.meta.url));
9
+ /**
10
+ * Load the bundled skill content. Hashes are computed here, at read time,
11
+ * never committed — the changesets release flow bumps versions without a
12
+ * build step, so any committed hash would go stale on the first release.
13
+ * Throws when the bundled content is unreadable (an operational error: the
14
+ * package itself is broken).
15
+ */
16
+ export function loadPackagedSkill() {
17
+ const root = join(PACKAGE_ROOT, "skills", "paramour");
18
+ const files = readdirSync(root, { recursive: true, withFileTypes: true })
19
+ .filter((entry) => entry.isFile())
20
+ .map((entry) => {
21
+ const content = readFileSync(join(entry.parentPath, entry.name), "utf8");
22
+ return {
23
+ content,
24
+ hash: hashContent(content),
25
+ relPath: relative(root, join(entry.parentPath, entry.name)).replaceAll("\\", "/"),
26
+ };
27
+ })
28
+ // Byte-order, not localeCompare — locale collation varies by machine.
29
+ .sort((a, b) => a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0);
30
+ const manifest = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8"));
31
+ return { files, version: manifest.version };
32
+ }
@@ -0,0 +1,84 @@
1
+ import { type SkillManifest } from "./manifest.js";
2
+ import { type PackagedSkill } from "./packaged.js";
3
+ import { type SkillTarget } from "./targets.js";
4
+ /** One packaged file's audit against a target. */
5
+ export interface FileAudit {
6
+ relPath: string;
7
+ status: FileStatus;
8
+ }
9
+ /**
10
+ * Per-file audit verdict, from three hashes: P (packaged), M (what the
11
+ * manifest recorded at last sync, possibly absent), I (installed on disk).
12
+ * - `fresh` — I equals P; nothing to do (identical unmanaged files are
13
+ * adopted rather than flagged).
14
+ * - `missing` — no file on disk.
15
+ * - `stale` — I equals M but M differs from P: untouched locally, the
16
+ * package moved on. Safe to overwrite.
17
+ * - `modified` — local edits relative to the current package (M equals P but
18
+ * I differs, or no provenance at all). Never overwritten without --force.
19
+ * - `stale-modified` — all three differ: local edits on an outdated base.
20
+ * Never overwritten without --force, but stale as far as `--check` cares.
21
+ */
22
+ export type FileStatus = "fresh" | "missing" | "modified" | "stale" | "stale-modified";
23
+ /**
24
+ * One human-readable detail line per file status, shared verbatim by
25
+ * `skills --check`, doctor, and init's summary so every surface describes
26
+ * the same state in the same words. `fresh` needs no line. Exhaustively
27
+ * typed: a new FileStatus member fails to compile until it gets an entry.
28
+ */
29
+ export declare const FILE_STATUS_DETAIL: Record<FileStatus, string | undefined>;
30
+ /** A manifest entry whose file is no longer part of the packaged skill. */
31
+ export interface OrphanAudit {
32
+ /**
33
+ * True when the installed bytes still match the manifest record (or the
34
+ * file is already gone) — provably ours and untouched, so sync may remove
35
+ * it. False means local edits: sync leaves the file and stops tracking it.
36
+ */
37
+ managed: boolean;
38
+ relPath: string;
39
+ }
40
+ /** Everything `--check`, doctor, and the installer need about one target. */
41
+ export interface TargetAudit {
42
+ files: FileAudit[];
43
+ manifest: SkillManifest | undefined;
44
+ orphans: OrphanAudit[];
45
+ target: SkillTarget;
46
+ }
47
+ /** What `syncTarget` did (or, under `dry`, would do) to one target. */
48
+ export interface TargetSyncResult {
49
+ audit: TargetAudit;
50
+ /** Unmanaged orphans left on disk, no longer tracked by the manifest. */
51
+ keptOrphans: string[];
52
+ /** Managed orphans removed from disk. */
53
+ removedOrphans: string[];
54
+ /** Files with local edits, left as-is because `force` was off. */
55
+ skippedModified: string[];
56
+ /** Files written (created or overwritten). */
57
+ written: string[];
58
+ }
59
+ /** Audit one target without writing anything. */
60
+ export declare function auditTarget(target: SkillTarget, packaged: PackagedSkill): TargetAudit;
61
+ /**
62
+ * Whether a status means the installed copy no longer reflects the packaged
63
+ * skill — the single classifier behind `skills --check` failures, doctor's
64
+ * stale verdict, and init's summary line. `modified` is deliberate local
65
+ * tailoring, not drift, so it does not count. The lookup is exhaustively
66
+ * typed: a new FileStatus member fails to compile until it is classified.
67
+ */
68
+ export declare function isOutdated(status: FileStatus): boolean;
69
+ /**
70
+ * Install or re-sync one target: write missing/stale files, leave fresh
71
+ * ones alone, refuse to overwrite local edits unless `force`, then rewrite
72
+ * the manifest. Skipped modified files keep their previously recorded hash
73
+ * so a later audit can still tell `stale-modified` from `modified`; orphans
74
+ * are dropped from the manifest either way (a kept unmanaged orphan becomes
75
+ * the user's file — reported once here, never nagged about again). One
76
+ * exception to the manifest rewrite: when the target had no manifest,
77
+ * nothing was written, and every packaged file was refused as locally
78
+ * modified, no manifest is written either — that directory is the user's
79
+ * own work and must not be adopted as paramour-managed.
80
+ */
81
+ export declare function syncTarget(target: SkillTarget, packaged: PackagedSkill, options: {
82
+ dry: boolean;
83
+ force: boolean;
84
+ }): TargetSyncResult;
@@ -0,0 +1,136 @@
1
+ import { existsSync, readFileSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { writeIfChanged } from "../emit.js";
4
+ import { hashContent, MANIFEST_FILENAME, readSkillManifest, renderManifest, } from "./manifest.js";
5
+ import {} from "./packaged.js";
6
+ import {} from "./targets.js";
7
+ /**
8
+ * One human-readable detail line per file status, shared verbatim by
9
+ * `skills --check`, doctor, and init's summary so every surface describes
10
+ * the same state in the same words. `fresh` needs no line. Exhaustively
11
+ * typed: a new FileStatus member fails to compile until it gets an entry.
12
+ */
13
+ export const FILE_STATUS_DETAIL = {
14
+ fresh: undefined,
15
+ missing: "missing — run `paramour skills`",
16
+ modified: "locally modified",
17
+ stale: "packaged content changed — run `paramour skills`",
18
+ "stale-modified": "locally modified on an outdated base — `paramour skills --force` overwrites",
19
+ };
20
+ /** Audit one target without writing anything. */
21
+ export function auditTarget(target, packaged) {
22
+ const manifest = readSkillManifest(target.skillDir);
23
+ const files = packaged.files.map((file) => {
24
+ const installedPath = join(target.skillDir, file.relPath);
25
+ if (!existsSync(installedPath)) {
26
+ return { relPath: file.relPath, status: "missing" };
27
+ }
28
+ const installed = hashContent(readFileSync(installedPath, "utf8"));
29
+ const recorded = manifest?.files[file.relPath];
30
+ const status = installed === file.hash
31
+ ? "fresh"
32
+ : recorded === undefined || recorded === file.hash
33
+ ? "modified"
34
+ : installed === recorded
35
+ ? "stale"
36
+ : "stale-modified";
37
+ return { relPath: file.relPath, status };
38
+ });
39
+ const packagedPaths = new Set(packaged.files.map((file) => file.relPath));
40
+ const orphans = Object.entries(manifest?.files ?? {})
41
+ .filter(([relPath]) => !packagedPaths.has(relPath))
42
+ .map(([relPath, recorded]) => {
43
+ const installedPath = join(target.skillDir, relPath);
44
+ const managed = !existsSync(installedPath) ||
45
+ hashContent(readFileSync(installedPath, "utf8")) === recorded;
46
+ return { managed, relPath };
47
+ });
48
+ return { files, manifest, orphans, target };
49
+ }
50
+ /**
51
+ * Whether a status means the installed copy no longer reflects the packaged
52
+ * skill — the single classifier behind `skills --check` failures, doctor's
53
+ * stale verdict, and init's summary line. `modified` is deliberate local
54
+ * tailoring, not drift, so it does not count. The lookup is exhaustively
55
+ * typed: a new FileStatus member fails to compile until it is classified.
56
+ */
57
+ export function isOutdated(status) {
58
+ const outdated = {
59
+ fresh: false,
60
+ missing: true,
61
+ modified: false,
62
+ stale: true,
63
+ "stale-modified": true,
64
+ };
65
+ return outdated[status];
66
+ }
67
+ /**
68
+ * Install or re-sync one target: write missing/stale files, leave fresh
69
+ * ones alone, refuse to overwrite local edits unless `force`, then rewrite
70
+ * the manifest. Skipped modified files keep their previously recorded hash
71
+ * so a later audit can still tell `stale-modified` from `modified`; orphans
72
+ * are dropped from the manifest either way (a kept unmanaged orphan becomes
73
+ * the user's file — reported once here, never nagged about again). One
74
+ * exception to the manifest rewrite: when the target had no manifest,
75
+ * nothing was written, and every packaged file was refused as locally
76
+ * modified, no manifest is written either — that directory is the user's
77
+ * own work and must not be adopted as paramour-managed.
78
+ */
79
+ export function syncTarget(target, packaged, options) {
80
+ const audit = auditTarget(target, packaged);
81
+ const statusOf = new Map(audit.files.map((file) => [file.relPath, file.status]));
82
+ const manifestFiles = {};
83
+ const skippedModified = [];
84
+ const written = [];
85
+ for (const file of packaged.files) {
86
+ const status = statusOf.get(file.relPath) ?? "missing";
87
+ if (status === "fresh") {
88
+ manifestFiles[file.relPath] = file.hash;
89
+ continue;
90
+ }
91
+ if ((status === "modified" || status === "stale-modified") &&
92
+ !options.force) {
93
+ skippedModified.push(file.relPath);
94
+ const recorded = audit.manifest?.files[file.relPath];
95
+ if (recorded !== undefined)
96
+ manifestFiles[file.relPath] = recorded;
97
+ continue;
98
+ }
99
+ if (!options.dry) {
100
+ writeIfChanged(join(target.skillDir, file.relPath), file.content);
101
+ }
102
+ manifestFiles[file.relPath] = file.hash;
103
+ written.push(file.relPath);
104
+ }
105
+ const keptOrphans = [];
106
+ const removedOrphans = [];
107
+ for (const orphan of audit.orphans) {
108
+ if (orphan.managed) {
109
+ if (!options.dry) {
110
+ rmSync(join(target.skillDir, orphan.relPath), { force: true });
111
+ }
112
+ removedOrphans.push(orphan.relPath);
113
+ }
114
+ else {
115
+ keptOrphans.push(orphan.relPath);
116
+ }
117
+ }
118
+ // Writing the manifest is what marks a directory as paramour-managed. A
119
+ // target with no prior manifest where nothing was written and every
120
+ // packaged file was refused as locally modified is a hand-authored
121
+ // directory that happens to share the path — dropping a manifest there
122
+ // would adopt it and have doctor report local edits forever. Anything
123
+ // written, any prior provenance, or an all-fresh byte-identical tree (the
124
+ // designed adoption case) still rewrites the manifest.
125
+ const wouldAdoptUserDir = audit.manifest === undefined &&
126
+ skippedModified.length > 0 &&
127
+ written.length === 0;
128
+ if (!options.dry && !wouldAdoptUserDir) {
129
+ writeIfChanged(join(target.skillDir, MANIFEST_FILENAME), renderManifest({
130
+ files: manifestFiles,
131
+ skill: "paramour",
132
+ version: packaged.version,
133
+ }));
134
+ }
135
+ return { audit, keptOrphans, removedOrphans, skippedModified, written };
136
+ }
@@ -0,0 +1,29 @@
1
+ /** One resolved install destination. */
2
+ export interface SkillTarget {
3
+ /** POSIX display path, e.g. `.claude/skills/paramour`. */
4
+ rel: string;
5
+ /** Absolute path to the skill's install directory. */
6
+ skillDir: string;
7
+ tool: ToolId;
8
+ }
9
+ /** The agent tools the installer knows how to target. */
10
+ export type ToolId = "agents" | "claude" | "codex" | "cursor";
11
+ export declare const TOOL_IDS: readonly ToolId[];
12
+ /** `resolveTargets` result when the inputs were valid. */
13
+ export interface ResolvedTargets {
14
+ /** True when nothing was detected and the portable location was chosen. */
15
+ fallback: boolean;
16
+ targets: SkillTarget[];
17
+ }
18
+ /** Targets whose tool is present in the project, alphabetical by tool. */
19
+ export declare function detectTargets(projectRoot: string): SkillTarget[];
20
+ /**
21
+ * Resolve install destinations: explicit `--tool` values (repeatable and
22
+ * comma-splittable) override detection entirely; otherwise every detected
23
+ * tool is targeted; with nothing detected the portable `.agents/skills/`
24
+ * location is the fallback so a bare `paramour skills` always installs
25
+ * somewhere tools can find.
26
+ */
27
+ export declare function resolveTargets(projectRoot: string, toolFlags: readonly string[] | undefined): ResolvedTargets | {
28
+ error: string;
29
+ };
@@ -0,0 +1,73 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ // Detection is directory presence only — no config parsing. Per-tool config
4
+ // formats move monthly; the dot-directory at the project root is the one
5
+ // signal that has stayed stable across the ecosystem. `agents` additionally
6
+ // matches an AGENTS.md file, the portable convention's sibling marker.
7
+ const TOOLS = {
8
+ agents: {
9
+ detect: (root) => isDirectory(join(root, ".agents")) || existsSync(join(root, "AGENTS.md")),
10
+ dir: ".agents",
11
+ },
12
+ claude: {
13
+ detect: (root) => isDirectory(join(root, ".claude")),
14
+ dir: ".claude",
15
+ },
16
+ codex: { detect: (root) => isDirectory(join(root, ".codex")), dir: ".codex" },
17
+ cursor: {
18
+ detect: (root) => isDirectory(join(root, ".cursor")),
19
+ dir: ".cursor",
20
+ },
21
+ };
22
+ export const TOOL_IDS = Object.keys(TOOLS);
23
+ /** Targets whose tool is present in the project, alphabetical by tool. */
24
+ export function detectTargets(projectRoot) {
25
+ return TOOL_IDS.filter((tool) => TOOLS[tool].detect(projectRoot)).map((tool) => toTarget(projectRoot, tool));
26
+ }
27
+ /**
28
+ * Resolve install destinations: explicit `--tool` values (repeatable and
29
+ * comma-splittable) override detection entirely; otherwise every detected
30
+ * tool is targeted; with nothing detected the portable `.agents/skills/`
31
+ * location is the fallback so a bare `paramour skills` always installs
32
+ * somewhere tools can find.
33
+ */
34
+ export function resolveTargets(projectRoot, toolFlags) {
35
+ const requested = (toolFlags ?? [])
36
+ .flatMap((flag) => flag.split(","))
37
+ .map((id) => id.trim())
38
+ .filter((id) => id.length > 0);
39
+ if (requested.length > 0) {
40
+ for (const id of requested) {
41
+ if (!TOOL_IDS.includes(id)) {
42
+ return {
43
+ error: `unknown --tool "${id}" (expected one of: ${TOOL_IDS.join(", ")})`,
44
+ };
45
+ }
46
+ }
47
+ const unique = TOOL_IDS.filter((tool) => requested.includes(tool)); /* dedupes and restores alphabetical order */
48
+ return {
49
+ fallback: false,
50
+ targets: unique.map((tool) => toTarget(projectRoot, tool)),
51
+ };
52
+ }
53
+ const detected = detectTargets(projectRoot);
54
+ if (detected.length > 0)
55
+ return { fallback: false, targets: detected };
56
+ return { fallback: true, targets: [toTarget(projectRoot, "agents")] };
57
+ }
58
+ function isDirectory(path) {
59
+ try {
60
+ return statSync(path).isDirectory();
61
+ }
62
+ catch {
63
+ return false;
64
+ }
65
+ }
66
+ function toTarget(projectRoot, tool) {
67
+ const { dir } = TOOLS[tool];
68
+ return {
69
+ rel: `${dir}/skills/paramour`,
70
+ skillDir: join(projectRoot, dir, "skills", "paramour"),
71
+ tool,
72
+ };
73
+ }
package/dist/testing.d.ts CHANGED
@@ -1,46 +1,42 @@
1
1
  import type { ParamsSource } from "paramour";
2
2
  import type { ReactElement, ReactNode } from "react";
3
3
  /**
4
- * `@paramour-js/next/testing` (design-16): a provider that overrides the
5
- * hooks' framework reads through the adapter seam (TA1), so client
6
- * components calling `useSearch`/`useRouteParams` (either flavor) can be
7
- * unit-tested without runner-specific `next/*` module mocking. One provider
8
- * feeds BOTH flavor contexts (TA5) — hybrid apps and pages components need
9
- * no second import. Server code needs none of this: `parse`/`safeParse` and
10
- * server components are pure functions over props (TA8).
4
+ * `@paramour-js/next/testing`: a provider that overrides the hooks'
5
+ * framework reads through the adapter seam, so client components calling
6
+ * `useSearch`/`useRouteParams` (either flavor) can be unit-tested without
7
+ * runner-specific `next/*` module mocking. One provider feeds BOTH flavor
8
+ * contexts — hybrid apps and pages components need no second import. Server
9
+ * code needs none of this: `parse`/`safeParse` and server components are
10
+ * pure functions over props.
11
11
  *
12
12
  * This module imports ONLY react and the (Next-free) adapter-seam module —
13
- * no `next/*` specifier and no `@testing-library/*` (TA5; dist.test.ts pins
14
- * the bundle graph, and the hermeticity check there is why these docs never
15
- * spell out the two Next specifiers). It carries `"use client"` like
16
- * app.ts.
13
+ * no `next/*` specifier and no `@testing-library/*`; dist.test.ts pins the
14
+ * bundle graph, and the hermeticity check there is why these docs never
15
+ * spell out the two Next specifiers. It carries `"use client"` like app.ts.
17
16
  *
18
- * Stability contract (TA4): the provider holds ONE adapter pair for its
19
- * lifetime, created once and closing over a latest-props ref that is
20
- * reassigned every render — prop changes mutate what the stable adapters
21
- * RETURN, they never mint new adapters, so the hooks' context reads stay
22
- * identity-stable and mid-test URL changes are driven by ordinary rerenders
23
- * with new props.
17
+ * Stability contract: the provider holds ONE adapter pair for its lifetime,
18
+ * created once and closing over a latest-props ref that is reassigned every
19
+ * render — prop changes mutate what the stable adapters RETURN, they never
20
+ * mint new adapters, so the hooks' context reads stay identity-stable and
21
+ * mid-test URL changes are driven by ordinary rerenders with new props.
24
22
  */
25
23
  /**
26
- * Input shape mirrors what Next hands the hooks (TA6) — no `url`
27
- * reverse-matching in v1 (deferred: needs a core matcher). `params: null`
28
- * and `mounted: false` are first-class because they are the two states
29
- * nobody hand-rolling a mock models.
24
+ * Input shape mirrors what Next hands the hooks — no `url` reverse-matching
25
+ * in v1 (deferred: needs a core matcher). `params: null` and
26
+ * `mounted: false` are first-class because they are the two states nobody
27
+ * hand-rolling a mock models.
30
28
  */
31
29
  export interface ParamourTestingOptions {
32
30
  /**
33
31
  * Pages flavor only: `false` is the pre-hydration state of a
34
- * statically-optimized page (`query` not yet populated — the PR5
35
- * `pending` arm). Defaults to `true`. design-16 listed this as deferred,
36
- * but TA7 migrates the whole pages suite — which pins the pending arm —
37
- * to this provider, so it was promoted from the deferred list.
32
+ * statically-optimized page (`query` not yet populated — the hooks'
33
+ * `pending` arm). Defaults to `true`.
38
34
  */
39
35
  isReady?: boolean;
40
36
  /**
41
37
  * Pages flavor only: `false` reproduces the pages router's
42
- * throw-on-unmounted state under `app/` (PR5) — pages.ts translates it to
43
- * a `ParamourError` naming the actual mistake.
38
+ * throw-on-unmounted state under `app/` — pages.ts translates it to a
39
+ * `ParamourError` naming the actual mistake.
44
40
  */
45
41
  mounted?: boolean;
46
42
  /** Captures `replace(href)` from either flavor's router. */
@@ -56,16 +52,16 @@ export interface ParamourTestingOptions {
56
52
  search?: string | URLSearchParams;
57
53
  }
58
54
  /**
59
- * Renders BOTH flavor contexts' providers around `children` (TA5). Exported
60
- * for people composing their own wrappers (Storybook decorators, custom
61
- * render helpers); testing-library users want {@link withParamourTesting}.
55
+ * Renders BOTH flavor contexts' providers around `children`. Exported for
56
+ * people composing their own wrappers (Storybook decorators, custom render
57
+ * helpers); testing-library users want {@link withParamourTesting}.
62
58
  */
63
59
  export declare function ParamourTestingProvider(props: ParamourTestingOptions & {
64
60
  children?: ReactNode;
65
61
  }): ReactElement;
66
62
  /**
67
- * Wrapper-component form for testing-library's `wrapper` option (TA5,
68
- * mirroring `withNuqsTestingAdapter`).
63
+ * Wrapper-component form for testing-library's `wrapper` option, mirroring
64
+ * `withNuqsTestingAdapter`.
69
65
  */
70
66
  export declare function withParamourTesting(options?: ParamourTestingOptions): (props: {
71
67
  children?: ReactNode;
package/dist/testing.js CHANGED
@@ -3,21 +3,21 @@ import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { useRef, useState } from "react";
4
4
  import { AppNavigationContext, PagesNavigationContext, } from "./navigation-adapter.js";
5
5
  /**
6
- * Renders BOTH flavor contexts' providers around `children` (TA5). Exported
7
- * for people composing their own wrappers (Storybook decorators, custom
8
- * render helpers); testing-library users want {@link withParamourTesting}.
6
+ * Renders BOTH flavor contexts' providers around `children`. Exported for
7
+ * people composing their own wrappers (Storybook decorators, custom render
8
+ * helpers); testing-library users want {@link withParamourTesting}.
9
9
  */
10
10
  export function ParamourTestingProvider(props) {
11
- // Latest-ref pattern (TA4): reassigned every render so the stable
12
- // adapters below always read the CURRENT render's props.
11
+ // Latest-ref pattern: reassigned every render so the stable adapters
12
+ // below always read the CURRENT render's props.
13
13
  const latest = useRef(props);
14
14
  latest.current = props;
15
15
  const [adapters] = useState(() => createAdapters(latest));
16
16
  return (_jsx(AppNavigationContext.Provider, { value: adapters.app, children: _jsx(PagesNavigationContext.Provider, { value: adapters.pages, children: props.children }) }));
17
17
  }
18
18
  /**
19
- * Wrapper-component form for testing-library's `wrapper` option (TA5,
20
- * mirroring `withNuqsTestingAdapter`).
19
+ * Wrapper-component form for testing-library's `wrapper` option, mirroring
20
+ * `withNuqsTestingAdapter`.
21
21
  */
22
22
  export function withParamourTesting(options = {}) {
23
23
  return function ParamourTestingWrapper({ children, }) {
@@ -25,10 +25,10 @@ export function withParamourTesting(options = {}) {
25
25
  };
26
26
  }
27
27
  /**
28
- * The one adapter pair a provider instance ever holds (TA4). Every read
29
- * defers to `latest.current`, so the adapters are stable while their
30
- * answers track prop updates. Fresh `URLSearchParams` per call is fine —
31
- * the hooks fingerprint the declared slice (SEL4), not the instance.
28
+ * The one adapter pair a provider instance ever holds. Every read defers to
29
+ * `latest.current`, so the adapters are stable while their answers track
30
+ * prop updates. Fresh `URLSearchParams` per call is fine — the hooks
31
+ * fingerprint the declared slice, not the instance.
32
32
  */
33
33
  function createAdapters(latest) {
34
34
  const app = {
@@ -57,20 +57,20 @@ function createAdapters(latest) {
57
57
  const options = latest.current;
58
58
  if (options.mounted === false) {
59
59
  // Verbatim prefix of next/router's real unmounted error — pages.ts
60
- // matches on the message to translate it (PR5).
60
+ // matches on the message to translate it.
61
61
  throw new Error("NextRouter was not mounted. https://nextjs.org/docs/messages/next-router-not-mounted");
62
62
  }
63
63
  const search = normalizeSearch(options.search);
64
64
  return {
65
- // asPath derives from pathname + normalized search (TA6) —
65
+ // asPath derives from pathname + normalized search —
66
66
  // basePath-relative, what the devtools navigate capability resolves
67
- // against (DT8).
67
+ // against.
68
68
  asPath: (options.pathname ?? "/") + (search === "" ? "" : `?${search}`),
69
69
  isReady: options.isReady ?? true,
70
70
  query: mergedQuery(options),
71
71
  replace(url) {
72
72
  latest.current.onReplace?.(url);
73
- // Real next/router resolves `true` on a completed replace (TA6).
73
+ // Real next/router resolves `true` on a completed replace.
74
74
  return Promise.resolve(true);
75
75
  },
76
76
  };