@penpair/core 0.2.0 → 0.3.1
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/adapters.js +58 -11
- package/dist/approvals.js +143 -29
- package/dist/atomic-output.js +9 -4
- package/dist/audit-rules.js +345 -79
- package/dist/backup-note.js +29 -8
- package/dist/baseline-coverage.js +169 -35
- package/dist/baselines-config.js +119 -29
- package/dist/baselines.js +280 -57
- package/dist/cli-arguments.js +18 -6
- package/dist/cli-main.js +1645 -392
- package/dist/client.d.ts +5 -2
- package/dist/client.js +127 -28
- package/dist/command-result.js +341 -52
- package/dist/coordination.js +91 -29
- package/dist/credentials.js +57 -13
- package/dist/deep-check.js +56 -12
- package/dist/design-spec.js +189 -49
- package/dist/endpoint.js +102 -34
- package/dist/export.js +86 -18
- package/dist/file-archive.js +33 -7
- package/dist/file-guard.js +29 -11
- package/dist/formats.js +215 -14
- package/dist/index.d.ts +8 -7
- package/dist/index.js +8 -7
- package/dist/inspection.d.ts +1 -1
- package/dist/inspection.js +378 -65
- package/dist/instance/config-schema.js +400 -41
- package/dist/instance/config.js +101 -30
- package/dist/instance/connect.js +151 -44
- package/dist/instance/connection.d.ts +26 -5
- package/dist/instance/connection.js +194 -21
- package/dist/instance/gitignore.js +5 -2
- package/dist/instance/init.js +32 -7
- package/dist/instance/runner.js +32 -10
- package/dist/instance/setup.js +46 -13
- package/dist/instance/tty.js +26 -8
- package/dist/journal.d.ts +10 -0
- package/dist/journal.js +212 -49
- package/dist/kit-smoke.js +82 -20
- package/dist/kit-source.js +10 -40
- package/dist/kit.js +102 -34
- package/dist/layout-policy.d.ts +2 -0
- package/dist/layout-policy.js +86 -0
- package/dist/layout.d.ts +7 -1
- package/dist/layout.js +27 -70
- package/dist/live-smoke.js +13 -4
- package/dist/local-profile.js +2 -1
- package/dist/mcp-server.js +360 -47
- package/dist/native-api.js +47 -11
- package/dist/onboard.js +33 -7
- package/dist/operation.d.ts +2 -2
- package/dist/operation.js +279 -69
- package/dist/page-session.d.ts +23 -0
- package/dist/page-session.js +142 -0
- package/dist/png.js +18 -3
- package/dist/portable-tokens.js +254 -57
- package/dist/probe.d.ts +1 -1
- package/dist/probe.js +48 -11
- package/dist/project-input.js +24 -10
- package/dist/project-snippets.js +18 -6
- package/dist/project-status.js +133 -24
- package/dist/registry.js +217 -60
- package/dist/run-file.js +129 -30
- package/dist/shots.js +108 -26
- package/dist/skill-check-entry.d.ts +17 -0
- package/dist/skill-check-entry.js +89 -0
- package/dist/skill-check-instructions.d.ts +7 -0
- package/dist/skill-check-instructions.js +39 -0
- package/dist/skill-check.d.ts +15 -0
- package/dist/skill-check.js +36 -0
- package/dist/skill-file.d.ts +2 -0
- package/dist/skill-file.js +31 -0
- package/dist/skill-instructions.d.ts +4 -0
- package/dist/skill-instructions.js +97 -22
- 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.js +131 -41
- package/dist/snippet.js +27 -7
- package/dist/snippets/read.js +8 -2
- package/dist/structure-snapshot.d.ts +12 -0
- package/dist/structure-snapshot.js +439 -85
- package/dist/token-edit.js +219 -64
- package/dist/token-projection.d.ts +56 -0
- package/dist/token-projection.js +286 -0
- package/dist/tokens-diff.js +125 -32
- 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 +14 -1
- package/skills/penpair-design/agents/openai.yaml +6 -0
- package/skills/penpair-design/references/penpot-plugin-gotchas.md +2 -2
- package/skills/penpair-design/references/project-facts.md +2 -0
- package/skills/penpair-handoff/SKILL.md +14 -1
- package/skills/penpair-handoff/agents/openai.yaml +6 -0
- package/skills/penpair-handoff/references/comparison-companion.md +16 -0
- package/skills/penpair-handoff/references/handoff-flow.md +2 -0
- package/skills/penpair-recipes/SKILL.md +13 -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 +9 -3
- 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.d.ts +0 -2
- package/dist/cli.js +0 -3
- package/dist/docker/lifecycle.d.ts +0 -96
- package/dist/docker/lifecycle.js +0 -273
- package/dist/docker/secrets.d.ts +0 -11
- package/dist/docker/secrets.js +0 -38
- package/dist/docker/template.d.ts +0 -8
- package/dist/docker/template.js +0 -158
- package/dist/docker-profile.d.ts +0 -114
- package/dist/docker-profile.js +0 -18
- package/dist/instance/lifecycle.d.ts +0 -129
- package/dist/instance/lifecycle.js +0 -336
- package/dist/instance/secrets.d.ts +0 -11
- package/dist/instance/secrets.js +0 -38
- package/dist/instance/template.d.ts +0 -8
- package/dist/instance/template.js +0 -158
|
@@ -2,15 +2,22 @@ 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
|
-
|
|
5
|
+
import { renderSkillRouting } from "./skill-routing.js";
|
|
6
|
+
const start = "<!-- penpair:skills:start -->";
|
|
7
|
+
const end = "<!-- penpair:skills:end -->";
|
|
8
|
+
export const skillInstructionMarkers = {
|
|
9
|
+
start,
|
|
10
|
+
end,
|
|
11
|
+
};
|
|
6
12
|
async function exists(file) {
|
|
7
13
|
try {
|
|
8
14
|
await lstat(file);
|
|
9
15
|
return true;
|
|
10
16
|
}
|
|
11
17
|
catch (error) {
|
|
12
|
-
if (error.code === "ENOENT")
|
|
18
|
+
if (error.code === "ENOENT") {
|
|
13
19
|
return false;
|
|
20
|
+
}
|
|
14
21
|
throw error;
|
|
15
22
|
}
|
|
16
23
|
}
|
|
@@ -18,11 +25,23 @@ export async function planSkillInstructions(root, rows, destination) {
|
|
|
18
25
|
let selected = destination;
|
|
19
26
|
if (!selected) {
|
|
20
27
|
const candidates = [];
|
|
21
|
-
for (const name of [
|
|
22
|
-
|
|
28
|
+
for (const name of [
|
|
29
|
+
"AGENTS.md",
|
|
30
|
+
"CLAUDE.md",
|
|
31
|
+
]) {
|
|
32
|
+
if (await exists(path.join(root, name))) {
|
|
23
33
|
candidates.push(name);
|
|
24
|
-
|
|
25
|
-
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (candidates.length !== 1) {
|
|
37
|
+
return {
|
|
38
|
+
report: {
|
|
39
|
+
path: null,
|
|
40
|
+
status: candidates.length ? "choice-required" : "not-selected",
|
|
41
|
+
diff: "",
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
26
45
|
selected = candidates[0];
|
|
27
46
|
}
|
|
28
47
|
const canonicalRoot = await realpath(root);
|
|
@@ -32,44 +51,100 @@ export async function planSkillInstructions(root, rows, destination) {
|
|
|
32
51
|
before = await readFile(file, "utf8");
|
|
33
52
|
}
|
|
34
53
|
catch (error) {
|
|
35
|
-
if (error.code !== "ENOENT")
|
|
54
|
+
if (error.code !== "ENOENT") {
|
|
36
55
|
throw error;
|
|
56
|
+
}
|
|
37
57
|
}
|
|
38
58
|
const text = before ?? "";
|
|
39
|
-
const starts = text.split(start).length - 1
|
|
40
|
-
|
|
59
|
+
const starts = text.split(start).length - 1;
|
|
60
|
+
const ends = text.split(end).length - 1;
|
|
61
|
+
if (starts !== ends || starts > 1 || (starts === 1 && text.indexOf(start) > text.indexOf(end))) {
|
|
41
62
|
throw new Error("Malformed penpair skills markers; repair them before installation (no instruction text was changed)");
|
|
63
|
+
}
|
|
42
64
|
const newline = text.includes("\r\n") ? "\r\n" : "\n";
|
|
43
65
|
const relativeRows = rows.map((row) => row.replace(/\]\(([^)]+)\)/g, (_match, target) => {
|
|
44
|
-
const relative = path
|
|
45
|
-
|
|
66
|
+
const relative = path
|
|
67
|
+
.relative(path.dirname(file), path.resolve(canonicalRoot, decodeURIComponent(target)))
|
|
68
|
+
.split(path.sep)
|
|
69
|
+
.join("/");
|
|
70
|
+
const link = relative
|
|
71
|
+
.split("/")
|
|
72
|
+
.map((part) => encodeURIComponent(part).replace(/[()]/g, (character) => `%${character.charCodeAt(0).toString(16)}`))
|
|
73
|
+
.join("/");
|
|
46
74
|
return `](${link})`;
|
|
47
75
|
}));
|
|
48
|
-
const block = [
|
|
76
|
+
const block = [
|
|
77
|
+
start,
|
|
78
|
+
...renderSkillRouting(relativeRows),
|
|
79
|
+
"| Skill | Purpose |",
|
|
80
|
+
"| --- | --- |",
|
|
81
|
+
...relativeRows,
|
|
82
|
+
end,
|
|
83
|
+
].join(newline);
|
|
49
84
|
let after;
|
|
50
|
-
if (starts === 1)
|
|
85
|
+
if (starts === 1) {
|
|
51
86
|
after = text.slice(0, text.indexOf(start)) + block + text.slice(text.indexOf(end) + end.length);
|
|
52
|
-
|
|
53
|
-
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
after =
|
|
90
|
+
text +
|
|
91
|
+
(text ? (text.endsWith(newline) ? newline : newline + newline) : "") +
|
|
92
|
+
"## Penpair skills" +
|
|
93
|
+
newline +
|
|
94
|
+
newline +
|
|
95
|
+
block +
|
|
96
|
+
newline;
|
|
97
|
+
}
|
|
54
98
|
const changed = text !== after;
|
|
55
99
|
// Diff only the owned region, never repeat unrelated instruction content in reports.
|
|
56
100
|
const oldBlock = starts ? text.slice(text.indexOf(start), text.indexOf(end) + end.length) : "";
|
|
57
|
-
const diff = changed
|
|
58
|
-
|
|
101
|
+
const diff = changed
|
|
102
|
+
? [
|
|
103
|
+
`--- ${file}`,
|
|
104
|
+
`+++ ${file}`,
|
|
105
|
+
"@@ penpair-owned skills block @@",
|
|
106
|
+
...oldBlock
|
|
107
|
+
.split(/\r?\n/)
|
|
108
|
+
.filter(Boolean)
|
|
109
|
+
.map((line) => `-${line}`),
|
|
110
|
+
...(starts ? block : "## Penpair skills" + newline + newline + block)
|
|
111
|
+
.split(/\r?\n/)
|
|
112
|
+
.map((line) => `+${line}`),
|
|
113
|
+
].join("\n")
|
|
114
|
+
: "";
|
|
115
|
+
return {
|
|
116
|
+
before,
|
|
117
|
+
after,
|
|
118
|
+
mode: before === undefined ? 0o644 : (await lstat(file)).mode & 0o777,
|
|
119
|
+
report: {
|
|
120
|
+
path: file,
|
|
121
|
+
status: changed ? "preview" : "unchanged",
|
|
122
|
+
diff,
|
|
123
|
+
},
|
|
124
|
+
};
|
|
59
125
|
}
|
|
60
126
|
export async function applySkillInstructions(plan) {
|
|
61
|
-
if (plan.report.status !== "preview" || !plan.report.path || plan.after === undefined)
|
|
127
|
+
if (plan.report.status !== "preview" || !plan.report.path || plan.after === undefined) {
|
|
62
128
|
return plan.report;
|
|
129
|
+
}
|
|
63
130
|
let current;
|
|
64
131
|
try {
|
|
65
132
|
current = await readFile(plan.report.path, "utf8");
|
|
66
133
|
}
|
|
67
134
|
catch (error) {
|
|
68
|
-
if (error.code !== "ENOENT")
|
|
135
|
+
if (error.code !== "ENOENT") {
|
|
69
136
|
throw error;
|
|
137
|
+
}
|
|
70
138
|
}
|
|
71
|
-
if (current !== plan.before)
|
|
139
|
+
if (current !== plan.before) {
|
|
72
140
|
throw new Error("Instruction file changed after planning; nothing was overwritten");
|
|
73
|
-
|
|
74
|
-
|
|
141
|
+
}
|
|
142
|
+
await atomicWriteFile(plan.report.path, plan.after, {
|
|
143
|
+
replace: plan.before !== undefined,
|
|
144
|
+
mode: plan.mode,
|
|
145
|
+
});
|
|
146
|
+
return {
|
|
147
|
+
...plan.report,
|
|
148
|
+
status: "written",
|
|
149
|
+
};
|
|
75
150
|
}
|
|
@@ -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.js
CHANGED
|
@@ -1,25 +1,38 @@
|
|
|
1
1
|
import { atomicWriteFile } from "./atomic-output.js";
|
|
2
2
|
import { resolveProjectOutput } from "./project-input.js";
|
|
3
|
-
import { planSkillInstructions, applySkillInstructions } from "./skill-instructions.js";
|
|
3
|
+
import { planSkillInstructions, applySkillInstructions, } from "./skill-instructions.js";
|
|
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. */
|
|
10
|
-
const projectOwned = new Set([
|
|
11
|
+
const projectOwned = new Set([
|
|
12
|
+
"references/project-facts.md",
|
|
13
|
+
"PROJECT.md",
|
|
14
|
+
]);
|
|
11
15
|
export async function listBundledSkills(directory = bundledSkillsDirectory()) {
|
|
12
|
-
const entries = await readdir(directory, {
|
|
13
|
-
|
|
16
|
+
const entries = await readdir(directory, {
|
|
17
|
+
withFileTypes: true,
|
|
18
|
+
});
|
|
19
|
+
return entries
|
|
20
|
+
.filter((entry) => entry.isDirectory())
|
|
21
|
+
.map((entry) => entry.name)
|
|
22
|
+
.sort();
|
|
14
23
|
}
|
|
15
24
|
async function filesUnder(directory, prefix = "") {
|
|
16
25
|
const out = [];
|
|
17
|
-
for (const entry of await readdir(path.join(directory, prefix), {
|
|
26
|
+
for (const entry of await readdir(path.join(directory, prefix), {
|
|
27
|
+
withFileTypes: true,
|
|
28
|
+
})) {
|
|
18
29
|
const relative = path.posix.join(prefix, entry.name);
|
|
19
|
-
if (entry.isDirectory())
|
|
30
|
+
if (entry.isDirectory()) {
|
|
20
31
|
out.push(...(await filesUnder(directory, relative)));
|
|
21
|
-
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
22
34
|
out.push(relative);
|
|
35
|
+
}
|
|
23
36
|
}
|
|
24
37
|
return out.sort();
|
|
25
38
|
}
|
|
@@ -28,8 +41,9 @@ async function readIfExists(file) {
|
|
|
28
41
|
return await readFile(file, "utf8");
|
|
29
42
|
}
|
|
30
43
|
catch (error) {
|
|
31
|
-
if (error.code === "ENOENT")
|
|
44
|
+
if (error.code === "ENOENT") {
|
|
32
45
|
return undefined;
|
|
46
|
+
}
|
|
33
47
|
throw error;
|
|
34
48
|
}
|
|
35
49
|
}
|
|
@@ -39,27 +53,41 @@ async function exists(file) {
|
|
|
39
53
|
return true;
|
|
40
54
|
}
|
|
41
55
|
catch (error) {
|
|
42
|
-
if (error.code === "ENOENT")
|
|
56
|
+
if (error.code === "ENOENT") {
|
|
43
57
|
return false;
|
|
58
|
+
}
|
|
44
59
|
throw error;
|
|
45
60
|
}
|
|
46
61
|
}
|
|
47
62
|
/** The skill's name and the first sentence of its description, from the frontmatter, for a row in a project's agent guide. */
|
|
48
63
|
export async function describeSkill(skillDirectory) {
|
|
49
64
|
const text = (await readIfExists(path.join(skillDirectory, "SKILL.md"))) ?? "";
|
|
50
|
-
const name =
|
|
51
|
-
|
|
52
|
-
|
|
65
|
+
const { name, description } = parseSkillMetadata(text, path.basename(skillDirectory));
|
|
66
|
+
return {
|
|
67
|
+
name,
|
|
68
|
+
summary: description.split(/(?<=\.)\s/)[0] ?? description,
|
|
69
|
+
};
|
|
53
70
|
}
|
|
54
71
|
/** Skills that changed name. Installing the new one leaves the old folder behind, which `installSkills` then reports. */
|
|
55
|
-
export const renamedSkills = {
|
|
72
|
+
export const renamedSkills = {
|
|
73
|
+
"penpot-design": "penpair-design",
|
|
74
|
+
};
|
|
56
75
|
/** Where the penpair skills already live in a project as real folders (not links), so a later install goes to the same place. */
|
|
57
76
|
export async function existingSkillsFolder(root) {
|
|
58
|
-
for (const folder of [
|
|
59
|
-
|
|
77
|
+
for (const folder of [
|
|
78
|
+
".skills",
|
|
79
|
+
".claude/skills",
|
|
80
|
+
".agents/skills",
|
|
81
|
+
]) {
|
|
82
|
+
for (const name of [
|
|
83
|
+
"penpair-design",
|
|
84
|
+
"penpair-setup",
|
|
85
|
+
...Object.keys(renamedSkills),
|
|
86
|
+
]) {
|
|
60
87
|
const found = await lstat(path.join(root, folder, name)).catch(() => undefined);
|
|
61
|
-
if (found?.isDirectory())
|
|
88
|
+
if (found?.isDirectory()) {
|
|
62
89
|
return folder;
|
|
90
|
+
}
|
|
63
91
|
}
|
|
64
92
|
}
|
|
65
93
|
return undefined;
|
|
@@ -70,16 +98,38 @@ export async function installSkills(options) {
|
|
|
70
98
|
await resolveProjectOutput(options.root, to);
|
|
71
99
|
const names = await listBundledSkills(source);
|
|
72
100
|
const chosen = options.only ?? names;
|
|
73
|
-
for (const name of chosen)
|
|
74
|
-
if (!names.includes(name))
|
|
101
|
+
for (const name of chosen) {
|
|
102
|
+
if (!names.includes(name)) {
|
|
75
103
|
throw new Error(`no skill named ${name}; shipped: ${names.join(", ")}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
76
106
|
const rows = await Promise.all(chosen.map(async (name) => {
|
|
77
107
|
const { summary } = await describeSkill(path.join(source, name));
|
|
78
|
-
const link = path.posix
|
|
108
|
+
const link = path.posix
|
|
109
|
+
.join(to, name, "SKILL.md")
|
|
110
|
+
.split("/")
|
|
111
|
+
.map((part) => encodeURIComponent(part).replace(/[()]/g, (character) => `%${character.charCodeAt(0).toString(16)}`))
|
|
112
|
+
.join("/");
|
|
79
113
|
return `| [${name}](${link}) | ${summary.replace(/\|/g, "\\|")} |`;
|
|
80
114
|
}));
|
|
81
115
|
const instructionPlan = await planSkillInstructions(options.root, rows, options.agents);
|
|
82
|
-
const report = {
|
|
116
|
+
const report = {
|
|
117
|
+
to,
|
|
118
|
+
installed: [],
|
|
119
|
+
unchanged: [],
|
|
120
|
+
kept: [],
|
|
121
|
+
linked: [],
|
|
122
|
+
agentsRows: rows,
|
|
123
|
+
legacy: [],
|
|
124
|
+
instructions: instructionPlan.report,
|
|
125
|
+
...(options.dryRun
|
|
126
|
+
? {
|
|
127
|
+
dryRun: true,
|
|
128
|
+
wouldInstall: [],
|
|
129
|
+
wouldLink: [],
|
|
130
|
+
}
|
|
131
|
+
: {}),
|
|
132
|
+
};
|
|
83
133
|
const writes = new Map();
|
|
84
134
|
const links = [];
|
|
85
135
|
for (const name of chosen) {
|
|
@@ -89,56 +139,96 @@ export async function installSkills(options) {
|
|
|
89
139
|
const shipped = await readFile(path.join(from, file), "utf8");
|
|
90
140
|
const present = await readIfExists(target);
|
|
91
141
|
const label = path.posix.join(to, name, file);
|
|
92
|
-
if (present === shipped)
|
|
142
|
+
if (present === shipped) {
|
|
93
143
|
report.unchanged.push(label);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
144
|
+
}
|
|
145
|
+
else if (present === undefined || (options.force && !projectOwned.has(file))) {
|
|
146
|
+
writes.set(target, {
|
|
147
|
+
label,
|
|
148
|
+
before: present,
|
|
149
|
+
content: shipped,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
97
153
|
report.kept.push(label);
|
|
154
|
+
}
|
|
98
155
|
}
|
|
99
|
-
if (options.links !== false)
|
|
100
|
-
for (const hub of [
|
|
156
|
+
if (options.links !== false) {
|
|
157
|
+
for (const hub of [
|
|
158
|
+
".agents/skills",
|
|
159
|
+
".claude/skills",
|
|
160
|
+
]) {
|
|
101
161
|
const link = path.join(options.root, hub, name);
|
|
102
|
-
if (path.resolve(link) === path.resolve(options.root, to, name) || await exists(link))
|
|
162
|
+
if (path.resolve(link) === path.resolve(options.root, to, name) || (await exists(link))) {
|
|
103
163
|
continue;
|
|
164
|
+
}
|
|
104
165
|
const safe = await resolveProjectOutput(options.root, path.join(hub, name));
|
|
105
166
|
const target = await resolveProjectOutput(options.root, path.join(to, name));
|
|
106
|
-
if (safe === target)
|
|
167
|
+
if (safe === target) {
|
|
107
168
|
continue;
|
|
108
|
-
|
|
169
|
+
}
|
|
170
|
+
links.push({
|
|
171
|
+
file: safe,
|
|
172
|
+
target: path.relative(path.dirname(safe), target),
|
|
173
|
+
label: path.posix.join(hub, name),
|
|
174
|
+
});
|
|
109
175
|
}
|
|
176
|
+
}
|
|
110
177
|
}
|
|
111
178
|
for (const [oldName, newName] of Object.entries(renamedSkills)) {
|
|
112
|
-
if (!chosen.includes(newName) || !(await exists(path.join(options.root, to, oldName))))
|
|
179
|
+
if (!chosen.includes(newName) || !(await exists(path.join(options.root, to, oldName)))) {
|
|
113
180
|
continue;
|
|
181
|
+
}
|
|
114
182
|
const oldFacts = await readIfExists(await resolveProjectOutput(options.root, path.join(to, oldName, "references/project-facts.md")));
|
|
115
183
|
const newFactsFile = await resolveProjectOutput(options.root, path.join(to, newName, "references/project-facts.md"));
|
|
116
184
|
const template = await readIfExists(path.join(source, newName, "references/project-facts.md"));
|
|
117
|
-
const current = writes.get(newFactsFile)?.content ?? await readIfExists(newFactsFile);
|
|
185
|
+
const current = writes.get(newFactsFile)?.content ?? (await readIfExists(newFactsFile));
|
|
118
186
|
const carriedFacts = oldFacts !== undefined && oldFacts !== template && current === template;
|
|
119
|
-
if (carriedFacts)
|
|
120
|
-
writes.set(newFactsFile, {
|
|
121
|
-
|
|
187
|
+
if (carriedFacts) {
|
|
188
|
+
writes.set(newFactsFile, {
|
|
189
|
+
label: path.posix.join(to, newName, "references/project-facts.md"),
|
|
190
|
+
before: await readIfExists(newFactsFile),
|
|
191
|
+
content: oldFacts,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
report.legacy.push({
|
|
195
|
+
folder: path.posix.join(to, oldName),
|
|
196
|
+
renamedTo: newName,
|
|
197
|
+
carriedFacts: carriedFacts && !options.dryRun,
|
|
198
|
+
});
|
|
122
199
|
}
|
|
123
200
|
if (options.dryRun) {
|
|
124
|
-
report.wouldInstall = [
|
|
201
|
+
report.wouldInstall = [
|
|
202
|
+
...writes.values(),
|
|
203
|
+
].map((write) => write.label);
|
|
125
204
|
report.wouldLink = links.map((link) => link.label);
|
|
126
205
|
return report;
|
|
127
206
|
}
|
|
128
207
|
for (const [file, write] of writes) {
|
|
129
|
-
if (await readIfExists(file) !== write.before)
|
|
208
|
+
if ((await readIfExists(file)) !== write.before) {
|
|
130
209
|
throw new Error("A skill file changed after planning; nothing overwrote that file");
|
|
131
|
-
|
|
132
|
-
await
|
|
210
|
+
}
|
|
211
|
+
await mkdir(path.dirname(file), {
|
|
212
|
+
recursive: true,
|
|
213
|
+
});
|
|
214
|
+
await atomicWriteFile(file, write.content, {
|
|
215
|
+
replace: write.before !== undefined,
|
|
216
|
+
mode: write.before === undefined ? 0o644 : (await lstat(file)).mode & 0o777,
|
|
217
|
+
});
|
|
133
218
|
report.installed.push(write.label);
|
|
134
219
|
}
|
|
135
220
|
for (const link of links) {
|
|
136
|
-
await mkdir(path.dirname(link.file), {
|
|
221
|
+
await mkdir(path.dirname(link.file), {
|
|
222
|
+
recursive: true,
|
|
223
|
+
});
|
|
137
224
|
await symlink(link.target, link.file);
|
|
138
225
|
report.linked.push(link.label);
|
|
139
226
|
}
|
|
140
|
-
if (instructionPlan.report.path)
|
|
141
|
-
await mkdir(path.dirname(instructionPlan.report.path), {
|
|
227
|
+
if (instructionPlan.report.path) {
|
|
228
|
+
await mkdir(path.dirname(instructionPlan.report.path), {
|
|
229
|
+
recursive: true,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
142
232
|
report.instructions = await applySkillInstructions(instructionPlan);
|
|
143
233
|
return report;
|
|
144
234
|
}
|
package/dist/snippet.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
|
-
import { executeCode, maxSnippetBytes, UncertainExecutionError } from "./client.js";
|
|
2
|
+
import { executeCode, maxSnippetBytes, UncertainExecutionError, } from "./client.js";
|
|
3
3
|
import { PenpairError } from "./errors.js";
|
|
4
4
|
/**
|
|
5
5
|
* Wraps snippet body text for the Penpot editor: the parameters arrive as `CFG`, and any error comes back as
|
|
@@ -13,9 +13,14 @@ export const requirePage = `if (CFG.page && penpot.currentFile?.pages?.filter((p
|
|
|
13
13
|
if (CFG.page && penpot.currentPage.name !== CFG.page) return { ok: false, wrongPage: true, current: penpot.currentPage.name, message: "the editor shows " + penpot.currentPage.name + ", not " + CFG.page };`;
|
|
14
14
|
export function snippet(name, body, params = {}, effect = "unknown") {
|
|
15
15
|
const code = wrap(body, params);
|
|
16
|
-
if (Buffer.byteLength(code, "utf8") > maxSnippetBytes)
|
|
16
|
+
if (Buffer.byteLength(code, "utf8") > maxSnippetBytes) {
|
|
17
17
|
throw new PenpairError("snippet-too-large", `Snippet ${name} is ${Buffer.byteLength(code, "utf8")} bytes`);
|
|
18
|
-
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
name,
|
|
21
|
+
code,
|
|
22
|
+
effect,
|
|
23
|
+
};
|
|
19
24
|
}
|
|
20
25
|
/** Whether the plugin source is valid JavaScript for an async function. Catches typos without a Penpot connection. */
|
|
21
26
|
export function compiles(code) {
|
|
@@ -30,12 +35,27 @@ export function compiles(code) {
|
|
|
30
35
|
}
|
|
31
36
|
/** Runs a snippet and returns its result record. A snippet that reports `ok: false` throws, except a wrong page, which the caller handles. */
|
|
32
37
|
export async function runSnippet(client, s, options) {
|
|
33
|
-
const { result, runId, metrics } = await executeCode(client, s.code, {
|
|
34
|
-
|
|
38
|
+
const { result, runId, metrics } = await executeCode(client, s.code, {
|
|
39
|
+
effect: s.effect,
|
|
40
|
+
...options,
|
|
41
|
+
});
|
|
42
|
+
const record = typeof result === "object" && result !== null && !Array.isArray(result)
|
|
43
|
+
? result
|
|
44
|
+
: {
|
|
45
|
+
value: result,
|
|
46
|
+
};
|
|
35
47
|
if (record.ok === false && !record.wrongPage) {
|
|
36
|
-
if ((options?.effect ?? s.effect) !== "read")
|
|
48
|
+
if ((options?.effect ?? s.effect) !== "read") {
|
|
37
49
|
throw new UncertainExecutionError(runId, `${s.name}: ${record.message ?? "failed"}`);
|
|
50
|
+
}
|
|
38
51
|
throw new PenpairError("tool-failed", `${s.name}: ${record.message ?? "failed"}`);
|
|
39
52
|
}
|
|
40
|
-
return {
|
|
53
|
+
return {
|
|
54
|
+
...record,
|
|
55
|
+
execution: {
|
|
56
|
+
runId,
|
|
57
|
+
outcome: "ran",
|
|
58
|
+
metrics,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
41
61
|
}
|
package/dist/snippets/read.js
CHANGED
|
@@ -10,7 +10,9 @@ export function switchPage(name) {
|
|
|
10
10
|
if (!target) return { ok: false, message: "no page named " + CFG.name };
|
|
11
11
|
if (penpot.currentPage.id === target.id) return { ok: true, alreadyThere: true };
|
|
12
12
|
penpot.openPage(target);
|
|
13
|
-
return { ok: true, switched: true };`, {
|
|
13
|
+
return { ok: true, switched: true };`, {
|
|
14
|
+
name,
|
|
15
|
+
});
|
|
14
16
|
}
|
|
15
17
|
/** The top-level frames of a page: id, name and size, so they can be found by name and exported. */
|
|
16
18
|
export function frames(params) {
|
|
@@ -20,8 +22,12 @@ return { ok: true, readOnly: true, file: penpot.currentFile.name, fileId: penpot
|
|
|
20
22
|
}
|
|
21
23
|
/** One line per shape: type, name, size, position on the page, layout and sizing, the text it carries. */
|
|
22
24
|
export function tree(params) {
|
|
23
|
-
if (
|
|
25
|
+
if ((params.limit !== undefined &&
|
|
26
|
+
(!Number.isInteger(params.limit) || params.limit < 1 || params.limit > 1000)) ||
|
|
27
|
+
(params.depth !== undefined &&
|
|
28
|
+
(!Number.isInteger(params.depth) || params.depth < 0 || params.depth > 100))) {
|
|
24
29
|
throw new Error("Readable tree budgets require limit 1..1000 and depth 0..100; use inspect for larger structured scopes");
|
|
30
|
+
}
|
|
25
31
|
return readSnippet("tree", `${requirePage}
|
|
26
32
|
const depth=CFG.depth===undefined?3:CFG.depth,limit=CFG.limit||400,lines=[];
|
|
27
33
|
let depthLimited=false,textLimited=false,labelLimited=false;
|
|
@@ -48,8 +48,20 @@ export interface StructuralManifest {
|
|
|
48
48
|
export declare const structuralJson: (value: unknown) => string;
|
|
49
49
|
export declare function structuralPage(raw: InspectionDocument, name: string): StructuralPage;
|
|
50
50
|
export declare function assembleStructuralSnapshot(pages: StructuralPage[], rawTokens: InspectionDocument): StructuralSnapshot;
|
|
51
|
+
export interface SnapshotProgress {
|
|
52
|
+
pageId: string;
|
|
53
|
+
stage: "start" | "complete";
|
|
54
|
+
elapsedMs: number;
|
|
55
|
+
}
|
|
56
|
+
export declare class SnapshotCaptureError extends Error {
|
|
57
|
+
readonly progress: SnapshotProgress[];
|
|
58
|
+
readonly restorationError?: string | undefined;
|
|
59
|
+
constructor(message: string, progress: SnapshotProgress[], restorationError?: string | undefined);
|
|
60
|
+
}
|
|
51
61
|
export declare function captureStructuralSnapshot(client: PenpotToolClient, options?: ExecuteOptions & {
|
|
52
62
|
signal?: AbortSignal;
|
|
63
|
+
onStep?: (message: string) => void;
|
|
64
|
+
onProgress?: (event: SnapshotProgress) => void;
|
|
53
65
|
}): Promise<StructuralSnapshot>;
|
|
54
66
|
export declare function writeStructuralSnapshot(root: string, out: string, snapshot: StructuralSnapshot, redact?: (text: string) => string): Promise<StructuralManifest>;
|
|
55
67
|
export declare function readStructuralSnapshot(root: string, folder: string): Promise<StructuralSnapshot>;
|