@paramour-js/next 0.4.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/commands/doctor.d.ts +12 -0
- package/dist/commands/doctor.js +12 -7
- package/dist/commands/generate.d.ts +48 -0
- package/dist/commands/generate.js +13 -6
- package/dist/commands/init.d.ts +40 -0
- package/dist/commands/init.js +110 -18
- package/dist/commands/list.d.ts +21 -0
- package/dist/commands/list.js +12 -7
- package/dist/commands/skills.d.ts +45 -0
- package/dist/commands/skills.js +222 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.js +13 -0
- package/dist/devtools-seam.d.ts +1 -1
- package/dist/doctor/checks.js +6 -2
- package/dist/init/agents-md.d.ts +32 -0
- package/dist/init/agents-md.js +78 -0
- package/dist/init/scaffold.js +54 -0
- package/dist/run-cli.d.ts +2 -0
- package/dist/run-cli.js +6 -2
- package/dist/skills/doctor.d.ts +11 -0
- package/dist/skills/doctor.js +71 -0
- package/dist/skills/manifest.d.ts +41 -0
- package/dist/skills/manifest.js +96 -0
- package/dist/skills/packaged.d.ts +21 -0
- package/dist/skills/packaged.js +32 -0
- package/dist/skills/sync.d.ts +84 -0
- package/dist/skills/sync.js +136 -0
- package/dist/skills/targets.d.ts +29 -0
- package/dist/skills/targets.js +73 -0
- package/package.json +4 -3
- package/skills/paramour/SKILL.md +43 -0
- package/skills/paramour/references/authoring.md +113 -0
- package/skills/paramour/references/migration.md +141 -0
- package/skills/paramour/references/reference.md +96 -0
- package/skills/paramour/references/setup.md +139 -0
|
@@ -0,0 +1,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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@paramour-js/next",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"jiti": "^2.7.0",
|
|
35
35
|
"magicast": "^0.3.5",
|
|
36
36
|
"tinyglobby": "^0.2.15",
|
|
37
|
-
"paramour": "0.
|
|
37
|
+
"paramour": "0.7.0"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"next": ">=15",
|
|
@@ -76,7 +76,8 @@
|
|
|
76
76
|
},
|
|
77
77
|
"files": [
|
|
78
78
|
"bin",
|
|
79
|
-
"dist"
|
|
79
|
+
"dist",
|
|
80
|
+
"skills"
|
|
80
81
|
],
|
|
81
82
|
"publishConfig": {
|
|
82
83
|
"access": "public",
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: paramour
|
|
3
|
+
description: Type-safe routing for Next.js using the paramour and @paramour-js/next packages. Covers defining routes as route objects (defineAppRoute, definePagesRoute), typing params and searchParams with p.* codecs and the .optional()/.default()/.catch() modifiers, building typed links with href(), reading route state with typed hooks (useSearch, useRouteParams) and server parse surfaces (route.parse), wrapping next.config with withTypedRoutes, and using the paramour CLI (generate, check, init, list, doctor, skills). Load when working in a Next.js project that depends on paramour, when setting paramour up, or when migrating raw params/searchParams usage in an App Router or Pages Router project to validated, typed route objects.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# paramour
|
|
7
|
+
|
|
8
|
+
Paramour is a type-safe routing companion for Next.js: each route is defined once as an importable route object whose params and search params are described by bidirectional wire codecs (parse AND serialize — Standard Schema validators plug in for validation, serialization is library-owned). Two packages: `paramour` (validation-agnostic core: `p.*` codec builders, `defineAppRoute`/`definePagesRoute`, `href`, decode/encode helpers) and `@paramour-js/next` (Next integration: `withTypedRoutes` config wrapper, App/Pages Router hooks, the `paramour` CLI).
|
|
9
|
+
|
|
10
|
+
## Core rules (always apply)
|
|
11
|
+
|
|
12
|
+
1. Import only from package barrels: `paramour`, `@paramour-js/next`, `@paramour-js/next/app`, `@paramour-js/next/pages`, `@paramour-js/next/testing`. Never import `dist/` or deep source paths.
|
|
13
|
+
2. Codec modifier legality is type-state: illegal chains do not compile (the method's type becomes `never`) and throw at runtime for JS callers. The rules:
|
|
14
|
+
- `.optional()` and `.default()` apply only to a bare, unmodified single-value codec — at most ONE of the two, at most once. `.optional().default()`, `.default().optional()`, and any repeat are illegal.
|
|
15
|
+
- `.catch()` applies at most once and combines with either presence modifier in either order. It recovers parse failures of PRESENT wire values only — never absence.
|
|
16
|
+
- `p.array(...)` codecs take no `.optional()`/`.default()` (an absent key and `[]` are the same wire state). `.catch()` is allowed.
|
|
17
|
+
- Codecs in a `params:` config take no presence modifiers at all (`.optional()`/`.default()` are illegal there); `.catch()` is allowed.
|
|
18
|
+
- `p.csv(element)`/`p.array(element)` elements must be bare unmodified scalars: no modifiers, no csv inside csv, no array-arity element.
|
|
19
|
+
- `.default(value)` rejects values whose type includes a function; a function argument is always a factory (`.default(() => value)`).
|
|
20
|
+
3. `paramour-env.d.ts` is a generated artifact. NEVER hand-edit it. Regenerate with `paramour generate` (dev server and builds wrapped by `withTypedRoutes` also regenerate it) and commit the result.
|
|
21
|
+
4. After adding, renaming, moving, or deleting any route file or `defineAppRoute`/`definePagesRoute` call, run `paramour check`. Exit 1 means drift — run `paramour generate`, then commit the artifact with the route change.
|
|
22
|
+
5. When converting an existing project, migrate ONE route per pass — define, wire, verify, commit — never a big-bang rewrite. See `references/migration.md`.
|
|
23
|
+
6. Route objects are the currency: export them from a module and import them where needed. Never build a central string-keyed route registry.
|
|
24
|
+
|
|
25
|
+
## Verification loop
|
|
26
|
+
|
|
27
|
+
- `paramour list` — print every filesystem route with its params/search shape as the library sees it (`--json` for machine output). Warnings (route without a definition, definition without a route) exit 0.
|
|
28
|
+
- `paramour generate` — (re)write `paramour-env.d.ts` from `app/` / `pages/`.
|
|
29
|
+
- `paramour check` — verify the artifact is current. Exit 1 on drift or a missing artifact; never writes.
|
|
30
|
+
- `paramour doctor` — diagnose setup (config validity, artifact freshness, next.config wrapping, version alignment, installed agent skills, tsconfig coverage). Exit 1 on any failing check; warnings exit 0.
|
|
31
|
+
|
|
32
|
+
Also run the project's type check (`tsc --noEmit` or the build) after route changes — paramour's guarantees are compiler-enforced, so a wrong codec chain or a misspelled param surfaces there.
|
|
33
|
+
|
|
34
|
+
## Task router
|
|
35
|
+
|
|
36
|
+
Read exactly the reference file matching the task; each is self-contained.
|
|
37
|
+
|
|
38
|
+
| Task | Read |
|
|
39
|
+
| ------------------------------------------------------------------------------------- | ------------------------- |
|
|
40
|
+
| Install paramour into a project that does not have it yet (init, config, first route) | `references/setup.md` |
|
|
41
|
+
| Convert existing raw `params`/`searchParams` code to paramour routes | `references/migration.md` |
|
|
42
|
+
| Day-to-day work: write codecs, define routes, build links, use hooks | `references/authoring.md` |
|
|
43
|
+
| Look up an export, hook, CLI flag, config option, or wire-format rule | `references/reference.md` |
|