@penpair/core 0.3.0 → 0.3.3

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 (38) hide show
  1. package/dist/cli-main.js +36 -3
  2. package/dist/skill-check-entry.d.ts +19 -0
  3. package/dist/skill-check-entry.js +107 -0
  4. package/dist/skill-check-instructions.d.ts +7 -0
  5. package/dist/skill-check-instructions.js +39 -0
  6. package/dist/skill-check.d.ts +18 -0
  7. package/dist/skill-check.js +58 -0
  8. package/dist/skill-file.d.ts +2 -0
  9. package/dist/skill-file.js +31 -0
  10. package/dist/skill-hosts.d.ts +16 -0
  11. package/dist/skill-hosts.js +20 -0
  12. package/dist/skill-instructions.d.ts +4 -0
  13. package/dist/skill-instructions.js +6 -0
  14. package/dist/skill-metadata.d.ts +8 -0
  15. package/dist/skill-metadata.js +52 -0
  16. package/dist/skill-routing.d.ts +2 -0
  17. package/dist/skill-routing.js +36 -0
  18. package/dist/skills.d.ts +1 -1
  19. package/dist/skills.js +38 -10
  20. package/dist/version.d.ts +1 -1
  21. package/dist/version.js +1 -1
  22. package/package.json +3 -2
  23. package/skills/design-judgment/SKILL.md +1 -1
  24. package/skills/design-judgment/agents/openai.yaml +6 -0
  25. package/skills/penpair-design/SKILL.md +11 -1
  26. package/skills/penpair-design/agents/openai.yaml +6 -0
  27. package/skills/penpair-handoff/SKILL.md +12 -1
  28. package/skills/penpair-handoff/agents/openai.yaml +6 -0
  29. package/skills/penpair-handoff/references/comparison-companion.md +4 -1
  30. package/skills/penpair-recipes/SKILL.md +8 -1
  31. package/skills/penpair-recipes/agents/openai.yaml +6 -0
  32. package/skills/penpair-recipes/assets/visual-checkpoint.md +48 -0
  33. package/skills/penpair-recipes/references/visual-checkpoint.md +37 -0
  34. package/skills/penpair-setup/SKILL.md +258 -36
  35. package/skills/penpair-setup/agents/openai.yaml +6 -0
  36. package/skills/penpair-verify/SKILL.md +49 -0
  37. package/skills/penpair-verify/agents/openai.yaml +6 -0
  38. package/skills/penpair-verify/references/browser-evidence.md +33 -0
package/dist/cli-main.js CHANGED
@@ -28,6 +28,7 @@ import { archiveOperation, runOnPage, runOperation } from "./operation.js";
28
28
  import { formatDeepReport, runDeepCheck } from "./deep-check.js";
29
29
  import { probe } from "./probe.js";
30
30
  import { installSkills, listBundledSkills } from "./skills.js";
31
+ import { checkSkills } from "./skill-check.js";
31
32
  import { checkProjectSnippets, loadProjectSnippets } from "./project-snippets.js";
32
33
  import { builtInRegistry, coerceParams, RegistryError } from "./registry.js";
33
34
  import { runSnippet } from "./snippet.js";
@@ -39,6 +40,7 @@ import { init } from "./instance/init.js";
39
40
  import { ConnectError, connectProject, keyFileCheck } from "./instance/connect.js";
40
41
  import { hostedOrigin, setup } from "./instance/setup.js";
41
42
  import { askHidden, askLine, openInBrowser } from "./instance/tty.js";
43
+ import { validateSkillAgent } from "./skill-hosts.js";
42
44
  import { loadAdapters, readRawConfig, reservedCommands, } from "./adapters.js";
43
45
  import { requireLocalProfile } from "./local-profile.js";
44
46
  import { assertEndpointIsInstance, doctorExternal, LifecycleError, } from "./instance/connection.js";
@@ -109,6 +111,7 @@ Compare:
109
111
  @@INSTANCE@@Skills (the rules an AI agent follows, shipped with penpair):
110
112
  penpair skills list them
111
113
  penpair skills install [--to <folder>] [--only <name>] [--force] [--agents AGENTS.md] [--dry-run]
114
+ penpair skills check [--agent codex|claude|copilot] [--only <name>] [--agents <file>] [--json]
112
115
  (built-in help lists configured extensions without importing their trusted code)
113
116
  (config-free inspection requires --standalone; writes require a project)
114
117
  penpair --version`;
@@ -337,6 +340,9 @@ async function main(argv, cli) {
337
340
  agents: {
338
341
  type: "string",
339
342
  },
343
+ agent: {
344
+ type: "string",
345
+ },
340
346
  "dry-run": {
341
347
  type: "boolean",
342
348
  },
@@ -541,6 +547,7 @@ async function main(argv, cli) {
541
547
  "kit",
542
548
  ],
543
549
  skills: [
550
+ "agent",
544
551
  "to",
545
552
  "only",
546
553
  "force",
@@ -801,14 +808,21 @@ async function main(argv, cli) {
801
808
  if (command === "skills" &&
802
809
  rest[0] !== "install" &&
803
810
  [
804
- values.agents,
805
811
  values["dry-run"],
806
812
  values.to,
807
- values.only,
808
813
  values.force,
809
814
  ].some((value) => value !== undefined)) {
810
815
  throw new Error("Installation flags require skills install");
811
816
  }
817
+ if (values.agent !== undefined && (command !== "skills" || rest[0] !== "check")) {
818
+ throw new CommandArgumentError("--agent requires skills check");
819
+ }
820
+ if (command === "skills" &&
821
+ rest[0] !== "install" &&
822
+ rest[0] !== "check" &&
823
+ (values.agents !== undefined || values.only !== undefined)) {
824
+ throw new CommandArgumentError("--agents and --only require skills install or skills check");
825
+ }
812
826
  if ((values.resume || values.checkpoint || values["force-lock"] || values.trace) &&
813
827
  (!values.apply ||
814
828
  !([
@@ -1165,6 +1179,25 @@ async function main(argv, cli) {
1165
1179
  return 0;
1166
1180
  }
1167
1181
  if (command === "skills") {
1182
+ if (rest.length > 1 ||
1183
+ (rest[0] !== undefined &&
1184
+ ![
1185
+ "install",
1186
+ "check",
1187
+ ].includes(rest[0]))) {
1188
+ throw new CommandArgumentError("Usage: skills [install | check]; see penpair --help");
1189
+ }
1190
+ if (rest[0] === "check") {
1191
+ const report = await checkSkills({
1192
+ root,
1193
+ agent: validateSkillAgent(values.agent),
1194
+ agents: values.agents,
1195
+ only: values.only,
1196
+ });
1197
+ output.result(report);
1198
+ output.log(JSON.stringify(report, null, 2));
1199
+ return report.ready ? 0 : 3;
1200
+ }
1168
1201
  if (rest[0] === "install") {
1169
1202
  const report = await installSkills({
1170
1203
  root,
@@ -1206,7 +1239,7 @@ async function main(argv, cli) {
1206
1239
  output.log("\nChoose --agents <path> to integrate these rows:\n" + report.agentsRows.join("\n"));
1207
1240
  }
1208
1241
  for (const old of report.legacy) {
1209
- output.log(`\nnote ${old.folder} is the old name of ${old.renamedTo}. ${old.carriedFacts ? "Your project-facts.md was copied to the new folder. " : ""}Delete the old folder and its links (.agents/skills/${path.posix.basename(old.folder)}, .claude/skills/${path.posix.basename(old.folder)}) and update its row in your AGENTS.md.`);
1242
+ output.log(`\nnote ${old.folder} is replaced by ${old.renamedTo}. ${old.carriedFacts ? "Your project-facts.md was copied to the new folder. " : ""}Review and migrate custom guidance before removing the old folder, its discovery links and stale instruction rows. No legacy files were deleted.`);
1210
1243
  }
1211
1244
  output.log("\nThen fill in references/project-facts.md in penpair-design: it is yours and is never replaced.");
1212
1245
  return report.instructions?.status === "choice-required" ? 3 : 0;
@@ -0,0 +1,19 @@
1
+ import { type SkillInstructionCheck } from "./skill-check-instructions.js";
2
+ import { type SkillAgent } from "./skill-hosts.js";
3
+ export interface SkillDiagnostic {
4
+ name: string;
5
+ path: string;
6
+ implicitInvocation: boolean | null;
7
+ entrypoint: "current" | "modified" | "unavailable";
8
+ problems: string[];
9
+ warnings: string[];
10
+ }
11
+ interface EntryOptions {
12
+ root: string;
13
+ agent: SkillAgent;
14
+ name: string;
15
+ bundledDirectory: string;
16
+ instructions: SkillInstructionCheck;
17
+ }
18
+ export declare function checkSkillEntry(options: EntryOptions): Promise<SkillDiagnostic>;
19
+ export {};
@@ -0,0 +1,107 @@
1
+ import { lstat, readFile, realpath } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { parseSkillMetadata, parseSkillYaml } from "./skill-metadata.js";
4
+ import { readSkillFile } from "./skill-file.js";
5
+ import { resolveSkillLink } from "./skill-check-instructions.js";
6
+ import { skillHosts } from "./skill-hosts.js";
7
+ async function invocationAllowed(root, skillPath) {
8
+ const relative = path.posix.join(path.posix.dirname(skillPath), "agents/openai.yaml");
9
+ const present = await lstat(path.join(root, relative)).catch((error) => {
10
+ if (error.code !== "ENOENT") {
11
+ throw error;
12
+ }
13
+ return undefined;
14
+ });
15
+ if (!present) {
16
+ return true;
17
+ }
18
+ const metadata = parseSkillYaml(await readSkillFile(root, relative));
19
+ if (metadata.policy === undefined) {
20
+ return true;
21
+ }
22
+ if (!metadata.policy || typeof metadata.policy !== "object" || Array.isArray(metadata.policy)) {
23
+ throw new Error("Invalid invocation policy");
24
+ }
25
+ const allowed = metadata.policy.allow_implicit_invocation;
26
+ if (allowed !== undefined && typeof allowed !== "boolean") {
27
+ throw new Error("Invocation policy must use a boolean");
28
+ }
29
+ return allowed !== false;
30
+ }
31
+ export async function checkSkillEntry(options) {
32
+ const { root, agent, name, bundledDirectory, instructions } = options;
33
+ const skillPath = path.posix.join(skillHosts[agent].folder, name, "SKILL.md");
34
+ const report = {
35
+ name,
36
+ path: skillPath,
37
+ implicitInvocation: null,
38
+ entrypoint: "unavailable",
39
+ problems: [],
40
+ warnings: [],
41
+ };
42
+ try {
43
+ const source = await readSkillFile(root, skillPath);
44
+ parseSkillMetadata(source, name);
45
+ const bundled = await readFile(path.join(bundledDirectory, name, "SKILL.md"), "utf8");
46
+ report.entrypoint = source === bundled ? "current" : "modified";
47
+ if (report.entrypoint === "modified") {
48
+ report.warnings.push("The discoverable entrypoint differs from the bundled skill; review intentional customization before refresh.");
49
+ }
50
+ const localLinks = [
51
+ ...source.matchAll(/\[[^\]]+\]\(([^)]+)\)/g),
52
+ ]
53
+ .map((match) => match[1])
54
+ .filter((target) => target && !target.startsWith("#") && !/^[a-z][a-z0-9+.-]*:/i.test(target));
55
+ if (localLinks.length > 100) {
56
+ throw new Error("Too many local resource links");
57
+ }
58
+ for (const target of localLinks) {
59
+ await resolveSkillLink(root, skillPath, target);
60
+ }
61
+ if (agent === "codex") {
62
+ report.implicitInvocation = await invocationAllowed(root, skillPath);
63
+ }
64
+ else if (agent === "claude") {
65
+ const frontmatter = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---/.exec(source);
66
+ const disabled = parseSkillYaml(frontmatter[1])["disable-model-invocation"];
67
+ if (disabled !== undefined && typeof disabled !== "boolean") {
68
+ throw new Error("Invalid Claude invocation policy");
69
+ }
70
+ report.implicitInvocation = disabled !== true;
71
+ }
72
+ if (report.implicitInvocation === false) {
73
+ report.problems.push("Implicit invocation is disabled by this host's skill policy; explicit invocation remains available.");
74
+ }
75
+ for (const folder of [
76
+ ".skills",
77
+ ".claude/skills",
78
+ ".agents/skills",
79
+ ".github/skills",
80
+ ]) {
81
+ const otherPath = path.posix.join(folder, name, "SKILL.md");
82
+ const other = await readSkillFile(root, otherPath).catch(() => undefined);
83
+ if (other !== undefined && other !== source) {
84
+ report.problems.push("Discoverable skill copies differ; review the conflicting copies before refreshing.");
85
+ }
86
+ else if (other !== undefined &&
87
+ (await realpath(path.join(root, otherPath))) !==
88
+ (await realpath(path.join(root, skillPath)))) {
89
+ report.problems.push("Independent skill copies require consolidation even when their contents match; preserve customizations before removing duplicates.");
90
+ }
91
+ }
92
+ const routes = instructions.links[name] ?? [];
93
+ if (!routes.length) {
94
+ report.problems.push("No generated task route links this skill from the selected instruction file.");
95
+ }
96
+ const resolvedSkill = await realpath(path.join(root, skillPath));
97
+ for (const target of routes) {
98
+ if ((await resolveSkillLink(root, instructions.path, target)) !== resolvedSkill) {
99
+ report.problems.push("An instruction route points to a different skill copy.");
100
+ }
101
+ }
102
+ }
103
+ catch {
104
+ report.problems.push("Skill discovery, frontmatter, local resources or UI policy is missing, invalid or resolves outside the project; inspect the local files and preview a skill refresh.");
105
+ }
106
+ return report;
107
+ }
@@ -0,0 +1,7 @@
1
+ export interface SkillInstructionCheck {
2
+ path: string;
3
+ problems: string[];
4
+ links: Record<string, string[]>;
5
+ }
6
+ export declare function resolveSkillLink(root: string, from: string, target: string): Promise<string>;
7
+ export declare function checkSkillInstructions(root: string, instructionFile: string): Promise<SkillInstructionCheck>;
@@ -0,0 +1,39 @@
1
+ import path from "node:path";
2
+ import { readSkillFile } from "./skill-file.js";
3
+ import { skillInstructionMarkers } from "./skill-instructions.js";
4
+ import { resolveProjectInput } from "./project-input.js";
5
+ export async function resolveSkillLink(root, from, target) {
6
+ const decoded = decodeURIComponent(target.split("#")[0]);
7
+ if (!decoded || path.isAbsolute(decoded) || /^[a-z][a-z0-9+.-]*:/i.test(decoded)) {
8
+ throw new Error("Skill resource link must resolve inside the project");
9
+ }
10
+ const relative = path.relative(root, path.resolve(root, path.dirname(from), decoded));
11
+ return resolveProjectInput(root, relative);
12
+ }
13
+ export async function checkSkillInstructions(root, instructionFile) {
14
+ const report = {
15
+ path: instructionFile,
16
+ problems: [],
17
+ links: {},
18
+ };
19
+ try {
20
+ const resolved = await resolveProjectInput(root, instructionFile);
21
+ report.path = path.relative(root, resolved).split(path.sep).join("/");
22
+ const source = await readSkillFile(root, report.path);
23
+ const { start, end } = skillInstructionMarkers;
24
+ if (source.split(start).length !== 2 ||
25
+ source.split(end).length !== 2 ||
26
+ source.indexOf(start) > source.indexOf(end)) {
27
+ throw new Error("Missing or malformed installer-owned skill routing block");
28
+ }
29
+ const block = source.slice(source.indexOf(start) + start.length, source.indexOf(end));
30
+ const routing = block.split("| Skill | Purpose |")[0];
31
+ for (const match of routing.matchAll(/\[([a-z0-9-]+)\]\(([^)]+)\)/g)) {
32
+ (report.links[match[1]] ??= []).push(match[2]);
33
+ }
34
+ }
35
+ catch {
36
+ report.problems.push("Project instructions are missing, unreadable or lack a valid owned routing block; preview skills install --agents with this destination.");
37
+ }
38
+ return report;
39
+ }
@@ -0,0 +1,18 @@
1
+ import { type SkillDiagnostic } from "./skill-check-entry.js";
2
+ import { type SkillInstructionCheck } from "./skill-check-instructions.js";
3
+ import { type SkillAgent } from "./skill-hosts.js";
4
+ export interface SkillCheckOptions {
5
+ root: string;
6
+ agent?: SkillAgent;
7
+ agents?: string;
8
+ only?: string[];
9
+ }
10
+ export interface SkillCheckReport {
11
+ agent: SkillAgent;
12
+ ready: boolean;
13
+ legacy: string[];
14
+ instructions: Pick<SkillInstructionCheck, "path" | "problems">;
15
+ skills: SkillDiagnostic[];
16
+ limitations: string[];
17
+ }
18
+ export declare function checkSkills(options: SkillCheckOptions): Promise<SkillCheckReport>;
@@ -0,0 +1,58 @@
1
+ import { lstat, realpath } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { bundledSkillsDirectory, listBundledSkills } from "./skills.js";
4
+ import { checkSkillEntry } from "./skill-check-entry.js";
5
+ import { checkSkillInstructions } from "./skill-check-instructions.js";
6
+ import { skillHosts, validateSkillAgent } from "./skill-hosts.js";
7
+ export async function checkSkills(options) {
8
+ const agent = validateSkillAgent(options.agent);
9
+ const root = await realpath(options.root);
10
+ const bundledDirectory = bundledSkillsDirectory();
11
+ const names = await listBundledSkills(bundledDirectory);
12
+ const selected = options.only ?? names;
13
+ if (!selected.length || selected.some((name) => !names.includes(name))) {
14
+ throw new Error("Select one or more bundled skill names; use penpair skills to list them");
15
+ }
16
+ const instructions = await checkSkillInstructions(root, options.agents ?? skillHosts[agent].instructions);
17
+ const skills = await Promise.all([
18
+ ...new Set(selected),
19
+ ].map((name) => checkSkillEntry({
20
+ root,
21
+ agent,
22
+ name,
23
+ bundledDirectory,
24
+ instructions,
25
+ })));
26
+ const legacy = [];
27
+ if (selected.includes("penpair-setup")) {
28
+ for (const folder of [
29
+ ".skills",
30
+ ".agents/skills",
31
+ ".claude/skills",
32
+ ".github/skills",
33
+ ]) {
34
+ const relative = path.posix.join(folder, "penpair-setup-guide");
35
+ if (await lstat(path.join(root, relative)).catch(() => undefined)) {
36
+ legacy.push(relative);
37
+ }
38
+ }
39
+ }
40
+ return {
41
+ agent,
42
+ ready: !legacy.length &&
43
+ !instructions.problems.length &&
44
+ skills.every((skill) => !skill.problems.length),
45
+ legacy,
46
+ instructions: {
47
+ path: instructions.path,
48
+ problems: instructions.problems,
49
+ },
50
+ skills,
51
+ limitations: [
52
+ "Offline project-file checks only; no connection, adapter/provider execution, credential reads or writes.",
53
+ "Does not inspect global host settings, session skill catalogs, context budgets or UI selection badges.",
54
+ "Checks the selected instruction file, not whether the running host loaded it; Copilot invocation policy is not evaluated.",
55
+ "A valid install and allowed invocation policy do not prove model selection, correct workflow application, visual quality or owner approval.",
56
+ ],
57
+ };
58
+ }
@@ -0,0 +1,2 @@
1
+ /** Read only bounded regular files inside the explicitly selected project. */
2
+ export declare function readSkillFile(root: string, relative: string): Promise<string>;
@@ -0,0 +1,31 @@
1
+ import { constants } from "node:fs";
2
+ import { open } from "node:fs/promises";
3
+ import { resolveProjectInput } from "./project-input.js";
4
+ import { maxSkillBytes } from "./skill-metadata.js";
5
+ /** Read only bounded regular files inside the explicitly selected project. */
6
+ export async function readSkillFile(root, relative) {
7
+ const resolved = await resolveProjectInput(root, relative);
8
+ const handle = await open(resolved, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
9
+ try {
10
+ const info = await handle.stat();
11
+ if (!info.isFile() || info.size > maxSkillBytes) {
12
+ throw new Error("Skill input must be a regular file within the 128 KB limit");
13
+ }
14
+ const buffer = Buffer.alloc(maxSkillBytes + 1);
15
+ let length = 0;
16
+ while (length < buffer.length) {
17
+ const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length);
18
+ if (!bytesRead) {
19
+ break;
20
+ }
21
+ length += bytesRead;
22
+ }
23
+ if (length > maxSkillBytes) {
24
+ throw new Error("Skill input exceeds the 128 KB limit");
25
+ }
26
+ return buffer.subarray(0, length).toString("utf8");
27
+ }
28
+ finally {
29
+ await handle.close();
30
+ }
31
+ }
@@ -0,0 +1,16 @@
1
+ export declare const skillHosts: {
2
+ readonly codex: {
3
+ readonly folder: ".agents/skills";
4
+ readonly instructions: "AGENTS.md";
5
+ };
6
+ readonly claude: {
7
+ readonly folder: ".claude/skills";
8
+ readonly instructions: "CLAUDE.md";
9
+ };
10
+ readonly copilot: {
11
+ readonly folder: ".agents/skills";
12
+ readonly instructions: "AGENTS.md";
13
+ };
14
+ };
15
+ export type SkillAgent = keyof typeof skillHosts;
16
+ export declare function validateSkillAgent(value?: string): SkillAgent;
@@ -0,0 +1,20 @@
1
+ export const skillHosts = {
2
+ codex: {
3
+ folder: ".agents/skills",
4
+ instructions: "AGENTS.md",
5
+ },
6
+ claude: {
7
+ folder: ".claude/skills",
8
+ instructions: "CLAUDE.md",
9
+ },
10
+ copilot: {
11
+ folder: ".agents/skills",
12
+ instructions: "AGENTS.md",
13
+ },
14
+ };
15
+ export function validateSkillAgent(value = "codex") {
16
+ if (value === "codex" || value === "claude" || value === "copilot") {
17
+ return value;
18
+ }
19
+ throw new Error("Skill agent must be codex, claude or copilot");
20
+ }
@@ -1,3 +1,7 @@
1
+ export declare const skillInstructionMarkers: {
2
+ start: string;
3
+ end: string;
4
+ };
1
5
  export interface InstructionReport {
2
6
  path: string | null;
3
7
  status: "not-selected" | "choice-required" | "unchanged" | "preview" | "written";
@@ -2,8 +2,13 @@ import { lstat, readFile, realpath } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { resolveProjectOutput } from "./project-input.js";
4
4
  import { atomicWriteFile } from "./atomic-output.js";
5
+ import { renderSkillRouting } from "./skill-routing.js";
5
6
  const start = "<!-- penpair:skills:start -->";
6
7
  const end = "<!-- penpair:skills:end -->";
8
+ export const skillInstructionMarkers = {
9
+ start,
10
+ end,
11
+ };
7
12
  async function exists(file) {
8
13
  try {
9
14
  await lstat(file);
@@ -70,6 +75,7 @@ export async function planSkillInstructions(root, rows, destination) {
70
75
  }));
71
76
  const block = [
72
77
  start,
78
+ ...renderSkillRouting(relativeRows),
73
79
  "| Skill | Purpose |",
74
80
  "| --- | --- |",
75
81
  ...relativeRows,
@@ -0,0 +1,8 @@
1
+ export interface SkillMetadata {
2
+ name: string;
3
+ description: string;
4
+ }
5
+ export declare const maxSkillBytes: number;
6
+ /** Parse data only; aliases and unknown tags cannot expand a local skill file. */
7
+ export declare function parseSkillYaml(source: string): Record<string, unknown>;
8
+ export declare function parseSkillMetadata(source: string, expectedName?: string): SkillMetadata;
@@ -0,0 +1,52 @@
1
+ import { parseDocument } from "yaml";
2
+ export const maxSkillBytes = 128 * 1024;
3
+ /** Parse data only; aliases and unknown tags cannot expand a local skill file. */
4
+ export function parseSkillYaml(source) {
5
+ if (Buffer.byteLength(source) > maxSkillBytes) {
6
+ throw new Error("Skill metadata exceeds the 128 KB limit");
7
+ }
8
+ const document = parseDocument(source, {
9
+ uniqueKeys: true,
10
+ });
11
+ if (document.errors.length || document.warnings.length) {
12
+ throw new Error("Invalid skill YAML metadata");
13
+ }
14
+ let value;
15
+ try {
16
+ value = document.toJS({
17
+ maxAliasCount: 0,
18
+ });
19
+ }
20
+ catch {
21
+ throw new Error("Skill YAML aliases are not supported");
22
+ }
23
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
24
+ throw new Error("Skill YAML metadata must be an object");
25
+ }
26
+ return value;
27
+ }
28
+ export function parseSkillMetadata(source, expectedName) {
29
+ if (Buffer.byteLength(source) > maxSkillBytes) {
30
+ throw new Error("SKILL.md exceeds the 128 KB limit");
31
+ }
32
+ const frontmatter = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(source);
33
+ if (!frontmatter) {
34
+ throw new Error("SKILL.md requires YAML frontmatter with name and description");
35
+ }
36
+ const metadata = parseSkillYaml(frontmatter[1]);
37
+ if (typeof metadata.name !== "string" ||
38
+ !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(metadata.name) ||
39
+ metadata.name.length > 64 ||
40
+ (expectedName !== undefined && metadata.name !== expectedName)) {
41
+ throw new Error("Skill name must match its kebab-case folder (at most 64 characters)");
42
+ }
43
+ if (typeof metadata.description !== "string" ||
44
+ !metadata.description.trim() ||
45
+ metadata.description.length > 1024) {
46
+ throw new Error("Skill description must be nonempty text (at most 1024 characters)");
47
+ }
48
+ return {
49
+ name: metadata.name,
50
+ description: metadata.description.trim().replace(/\s+/g, " "),
51
+ };
52
+ }
@@ -0,0 +1,2 @@
1
+ /** Instruction links have already been made relative to their destination. */
2
+ export declare function renderSkillRouting(rows: string[]): string[];
@@ -0,0 +1,36 @@
1
+ const tasksBySkill = {
2
+ "penpair-setup": "Install Penpair or repair a Penpot connection",
3
+ "penpair-design": "Inspect, edit, audit or export a Penpot file",
4
+ "penpair-recipes": "Build a Penpot screen or derive a state variant from an approved frame",
5
+ "design-judgment": "Choose or critique hierarchy, density and screen states",
6
+ "penpair-handoff": "Implement a frontend from an approved Penpot design",
7
+ "penpair-verify": "Compare a frontend with its approved design or recheck visual fixes",
8
+ };
9
+ /** Instruction links have already been made relative to their destination. */
10
+ export function renderSkillRouting(rows) {
11
+ const routes = [];
12
+ for (const row of rows) {
13
+ const match = /^\| \[([a-z0-9-]+)\]\(([^)]+)\) \|/.exec(row);
14
+ if (!match) {
15
+ continue;
16
+ }
17
+ const name = match[1];
18
+ const task = tasksBySkill[name];
19
+ if (task) {
20
+ routes.push(`| ${task} | [${name}](${match[2]}) |`);
21
+ }
22
+ }
23
+ if (!routes.length) {
24
+ return [];
25
+ }
26
+ return [
27
+ "Before working on a matching task, read the linked SKILL.md and follow its relevant workflow before running design or implementation tools.",
28
+ "Briefly name the selected skill and its purpose when starting or changing workflow; report actual evidence separately from the intention to use a skill.",
29
+ "Use the smallest applicable set of skills; follow a linked companion only when the task reaches that stage. Skill selection does not authorize additional writes or replace owner approval.",
30
+ "",
31
+ "| Task | Read first |",
32
+ "| --- | --- |",
33
+ ...routes,
34
+ "",
35
+ ];
36
+ }
package/dist/skills.d.ts CHANGED
@@ -18,7 +18,7 @@ export interface InstallOptions {
18
18
  only?: string[];
19
19
  /** Replace files that differ from the shipped ones (never the project-owned ones). */
20
20
  force?: boolean;
21
- /** Make the skills discoverable for Codex and Claude Code with relative links (default true). */
21
+ /** Make one shared copy discoverable for Codex, Claude Code and Copilot (default true). */
22
22
  links?: boolean;
23
23
  /** For tests. */
24
24
  source?: string;
package/dist/skills.js CHANGED
@@ -4,6 +4,7 @@ import { planSkillInstructions, applySkillInstructions, } from "./skill-instruct
4
4
  import { lstat, mkdir, readdir, readFile, symlink } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
+ import { parseSkillMetadata } from "./skill-metadata.js";
7
8
  /** The skills shipped inside the package (`skills/` next to `dist/`). */
8
9
  export const bundledSkillsDirectory = () => path.join(path.dirname(fileURLToPath(import.meta.url)), "../skills");
9
10
  /** Files a project fills in. They are written once and never replaced, not even with --force. */
@@ -61,8 +62,7 @@ async function exists(file) {
61
62
  /** The skill's name and the first sentence of its description, from the frontmatter, for a row in a project's agent guide. */
62
63
  export async function describeSkill(skillDirectory) {
63
64
  const text = (await readIfExists(path.join(skillDirectory, "SKILL.md"))) ?? "";
64
- const name = /^name:\s*(.+)$/m.exec(text)?.[1]?.trim() ?? path.basename(skillDirectory);
65
- const description = /^description:\s*(.+)$/m.exec(text)?.[1]?.trim() ?? "";
65
+ const { name, description } = parseSkillMetadata(text, path.basename(skillDirectory));
66
66
  return {
67
67
  name,
68
68
  summary: description.split(/(?<=\.)\s/)[0] ?? description,
@@ -71,22 +71,30 @@ export async function describeSkill(skillDirectory) {
71
71
  /** Skills that changed name. Installing the new one leaves the old folder behind, which `installSkills` then reports. */
72
72
  export const renamedSkills = {
73
73
  "penpot-design": "penpair-design",
74
+ "penpair-setup-guide": "penpair-setup",
74
75
  };
75
76
  /** Where the penpair skills already live in a project as real folders (not links), so a later install goes to the same place. */
76
77
  export async function existingSkillsFolder(root) {
77
- for (const folder of [
78
+ const folders = [
78
79
  ".skills",
79
80
  ".claude/skills",
80
81
  ".agents/skills",
81
- ]) {
82
- for (const name of [
82
+ ".github/skills",
83
+ ];
84
+ // Prefer an existing canonical pack over a legacy bootstrap in another host.
85
+ for (const names of [
86
+ [
83
87
  "penpair-design",
84
88
  "penpair-setup",
85
- ...Object.keys(renamedSkills),
86
- ]) {
87
- const found = await lstat(path.join(root, folder, name)).catch(() => undefined);
88
- if (found?.isDirectory()) {
89
- return folder;
89
+ ],
90
+ Object.keys(renamedSkills),
91
+ ]) {
92
+ for (const folder of folders) {
93
+ for (const name of names) {
94
+ const found = await lstat(path.join(root, folder, name)).catch(() => undefined);
95
+ if (found?.isDirectory()) {
96
+ return folder;
97
+ }
90
98
  }
91
99
  }
92
100
  }
@@ -197,6 +205,26 @@ export async function installSkills(options) {
197
205
  carriedFacts: carriedFacts && !options.dryRun,
198
206
  });
199
207
  }
208
+ // A standalone bootstrap guide may have been installed in a host's own folder.
209
+ // Report those copies for review; never delete a user's customized skill.
210
+ if (chosen.includes("penpair-setup")) {
211
+ for (const folder of [
212
+ ".skills",
213
+ ".agents/skills",
214
+ ".claude/skills",
215
+ ".github/skills",
216
+ ]) {
217
+ const legacyFolder = path.posix.join(folder, "penpair-setup-guide");
218
+ if (!report.legacy.some((entry) => entry.folder === legacyFolder) &&
219
+ (await exists(path.join(options.root, legacyFolder)))) {
220
+ report.legacy.push({
221
+ folder: legacyFolder,
222
+ renamedTo: "penpair-setup",
223
+ carriedFacts: false,
224
+ });
225
+ }
226
+ }
227
+ }
200
228
  if (options.dryRun) {
201
229
  report.wouldInstall = [
202
230
  ...writes.values(),