@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.
- package/README.md +1 -1
- package/dist/app.d.ts +48 -49
- package/dist/app.js +11 -12
- package/dist/cli-args.d.ts +4 -4
- package/dist/cli-args.js +4 -4
- package/dist/cli-inputs.d.ts +6 -7
- package/dist/cli-inputs.js +6 -7
- package/dist/cli.js +1 -1
- package/dist/collisions.d.ts +8 -8
- package/dist/collisions.js +9 -9
- package/dist/commands/doctor.d.ts +12 -0
- package/dist/commands/doctor.js +12 -7
- package/dist/commands/generate.d.ts +55 -7
- package/dist/commands/generate.js +29 -22
- package/dist/commands/init.d.ts +40 -0
- package/dist/commands/init.js +111 -19
- 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 +17 -10
- package/dist/config.js +17 -4
- package/dist/devtools-seam.d.ts +19 -19
- package/dist/devtools-seam.js +3 -3
- package/dist/doctor/checks.js +7 -3
- package/dist/emit.d.ts +12 -12
- package/dist/emit.js +13 -13
- package/dist/generate.d.ts +14 -14
- package/dist/generate.js +11 -11
- 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/list/discover-route-defs.d.ts +4 -4
- package/dist/list/discover-route-defs.js +4 -4
- package/dist/lock.d.ts +8 -9
- package/dist/lock.js +11 -12
- package/dist/navigation-adapter.d.ts +17 -18
- package/dist/navigation-adapter.js +4 -4
- package/dist/observe.d.ts +14 -14
- package/dist/observe.js +4 -4
- package/dist/pages.d.ts +30 -31
- package/dist/pages.js +10 -10
- package/dist/run-cli.d.ts +3 -1
- package/dist/run-cli.js +7 -3
- package/dist/scan-app.d.ts +10 -10
- package/dist/scan-app.js +30 -30
- package/dist/scan-pages.d.ts +6 -6
- package/dist/scan-pages.js +28 -26
- package/dist/scan.d.ts +13 -10
- package/dist/scan.js +7 -7
- package/dist/select.d.ts +40 -40
- package/dist/select.js +30 -30
- 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/dist/testing.d.ts +28 -32
- package/dist/testing.js +15 -15
- package/dist/watch.d.ts +12 -12
- package/dist/watch.js +13 -13
- package/dist/with-typed-routes.d.ts +11 -10
- package/dist/with-typed-routes.js +42 -39
- 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,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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
14
|
-
*
|
|
15
|
-
* spell out the two Next specifiers
|
|
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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
|
35
|
-
* `pending` arm). Defaults to `true`.
|
|
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/`
|
|
43
|
-
*
|
|
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
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
|
68
|
-
*
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
12
|
-
//
|
|
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
|
|
20
|
-
*
|
|
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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
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
|
|
65
|
+
// asPath derives from pathname + normalized search —
|
|
66
66
|
// basePath-relative, what the devtools navigate capability resolves
|
|
67
|
-
// against
|
|
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
|
|
73
|
+
// Real next/router resolves `true` on a completed replace.
|
|
74
74
|
return Promise.resolve(true);
|
|
75
75
|
},
|
|
76
76
|
};
|