@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.
- package/dist/cli-main.js +36 -3
- package/dist/skill-check-entry.d.ts +19 -0
- package/dist/skill-check-entry.js +107 -0
- package/dist/skill-check-instructions.d.ts +7 -0
- package/dist/skill-check-instructions.js +39 -0
- package/dist/skill-check.d.ts +18 -0
- package/dist/skill-check.js +58 -0
- package/dist/skill-file.d.ts +2 -0
- package/dist/skill-file.js +31 -0
- package/dist/skill-hosts.d.ts +16 -0
- package/dist/skill-hosts.js +20 -0
- package/dist/skill-instructions.d.ts +4 -0
- package/dist/skill-instructions.js +6 -0
- package/dist/skill-metadata.d.ts +8 -0
- package/dist/skill-metadata.js +52 -0
- package/dist/skill-routing.d.ts +2 -0
- package/dist/skill-routing.js +36 -0
- package/dist/skills.d.ts +1 -1
- package/dist/skills.js +38 -10
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -2
- package/skills/design-judgment/SKILL.md +1 -1
- package/skills/design-judgment/agents/openai.yaml +6 -0
- package/skills/penpair-design/SKILL.md +11 -1
- package/skills/penpair-design/agents/openai.yaml +6 -0
- package/skills/penpair-handoff/SKILL.md +12 -1
- package/skills/penpair-handoff/agents/openai.yaml +6 -0
- package/skills/penpair-handoff/references/comparison-companion.md +4 -1
- package/skills/penpair-recipes/SKILL.md +8 -1
- package/skills/penpair-recipes/agents/openai.yaml +6 -0
- package/skills/penpair-recipes/assets/visual-checkpoint.md +48 -0
- package/skills/penpair-recipes/references/visual-checkpoint.md +37 -0
- package/skills/penpair-setup/SKILL.md +258 -36
- package/skills/penpair-setup/agents/openai.yaml +6 -0
- package/skills/penpair-verify/SKILL.md +49 -0
- package/skills/penpair-verify/agents/openai.yaml +6 -0
- 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
|
|
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,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
|
+
}
|
|
@@ -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,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
|
|
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 =
|
|
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
|
-
|
|
78
|
+
const folders = [
|
|
78
79
|
".skills",
|
|
79
80
|
".claude/skills",
|
|
80
81
|
".agents/skills",
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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(),
|