@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.
Files changed (125) hide show
  1. package/dist/adapters.js +58 -11
  2. package/dist/approvals.js +143 -29
  3. package/dist/atomic-output.js +9 -4
  4. package/dist/audit-rules.js +345 -79
  5. package/dist/backup-note.js +29 -8
  6. package/dist/baseline-coverage.js +169 -35
  7. package/dist/baselines-config.js +119 -29
  8. package/dist/baselines.js +280 -57
  9. package/dist/cli-arguments.js +18 -6
  10. package/dist/cli-main.js +1645 -392
  11. package/dist/client.d.ts +5 -2
  12. package/dist/client.js +127 -28
  13. package/dist/command-result.js +341 -52
  14. package/dist/coordination.js +91 -29
  15. package/dist/credentials.js +57 -13
  16. package/dist/deep-check.js +56 -12
  17. package/dist/design-spec.js +189 -49
  18. package/dist/endpoint.js +102 -34
  19. package/dist/export.js +86 -18
  20. package/dist/file-archive.js +33 -7
  21. package/dist/file-guard.js +29 -11
  22. package/dist/formats.js +215 -14
  23. package/dist/index.d.ts +8 -7
  24. package/dist/index.js +8 -7
  25. package/dist/inspection.d.ts +1 -1
  26. package/dist/inspection.js +378 -65
  27. package/dist/instance/config-schema.js +400 -41
  28. package/dist/instance/config.js +101 -30
  29. package/dist/instance/connect.js +151 -44
  30. package/dist/instance/connection.d.ts +26 -5
  31. package/dist/instance/connection.js +194 -21
  32. package/dist/instance/gitignore.js +5 -2
  33. package/dist/instance/init.js +32 -7
  34. package/dist/instance/runner.js +32 -10
  35. package/dist/instance/setup.js +46 -13
  36. package/dist/instance/tty.js +26 -8
  37. package/dist/journal.d.ts +10 -0
  38. package/dist/journal.js +212 -49
  39. package/dist/kit-smoke.js +82 -20
  40. package/dist/kit-source.js +10 -40
  41. package/dist/kit.js +102 -34
  42. package/dist/layout-policy.d.ts +2 -0
  43. package/dist/layout-policy.js +86 -0
  44. package/dist/layout.d.ts +7 -1
  45. package/dist/layout.js +27 -70
  46. package/dist/live-smoke.js +13 -4
  47. package/dist/local-profile.js +2 -1
  48. package/dist/mcp-server.js +360 -47
  49. package/dist/native-api.js +47 -11
  50. package/dist/onboard.js +33 -7
  51. package/dist/operation.d.ts +2 -2
  52. package/dist/operation.js +279 -69
  53. package/dist/page-session.d.ts +23 -0
  54. package/dist/page-session.js +142 -0
  55. package/dist/png.js +18 -3
  56. package/dist/portable-tokens.js +254 -57
  57. package/dist/probe.d.ts +1 -1
  58. package/dist/probe.js +48 -11
  59. package/dist/project-input.js +24 -10
  60. package/dist/project-snippets.js +18 -6
  61. package/dist/project-status.js +133 -24
  62. package/dist/registry.js +217 -60
  63. package/dist/run-file.js +129 -30
  64. package/dist/shots.js +108 -26
  65. package/dist/skill-check-entry.d.ts +17 -0
  66. package/dist/skill-check-entry.js +89 -0
  67. package/dist/skill-check-instructions.d.ts +7 -0
  68. package/dist/skill-check-instructions.js +39 -0
  69. package/dist/skill-check.d.ts +15 -0
  70. package/dist/skill-check.js +36 -0
  71. package/dist/skill-file.d.ts +2 -0
  72. package/dist/skill-file.js +31 -0
  73. package/dist/skill-instructions.d.ts +4 -0
  74. package/dist/skill-instructions.js +97 -22
  75. package/dist/skill-metadata.d.ts +8 -0
  76. package/dist/skill-metadata.js +52 -0
  77. package/dist/skill-routing.d.ts +2 -0
  78. package/dist/skill-routing.js +36 -0
  79. package/dist/skills.js +131 -41
  80. package/dist/snippet.js +27 -7
  81. package/dist/snippets/read.js +8 -2
  82. package/dist/structure-snapshot.d.ts +12 -0
  83. package/dist/structure-snapshot.js +439 -85
  84. package/dist/token-edit.js +219 -64
  85. package/dist/token-projection.d.ts +56 -0
  86. package/dist/token-projection.js +286 -0
  87. package/dist/tokens-diff.js +125 -32
  88. package/dist/version.d.ts +1 -1
  89. package/dist/version.js +1 -1
  90. package/package.json +3 -2
  91. package/skills/design-judgment/SKILL.md +1 -1
  92. package/skills/design-judgment/agents/openai.yaml +6 -0
  93. package/skills/penpair-design/SKILL.md +14 -1
  94. package/skills/penpair-design/agents/openai.yaml +6 -0
  95. package/skills/penpair-design/references/penpot-plugin-gotchas.md +2 -2
  96. package/skills/penpair-design/references/project-facts.md +2 -0
  97. package/skills/penpair-handoff/SKILL.md +14 -1
  98. package/skills/penpair-handoff/agents/openai.yaml +6 -0
  99. package/skills/penpair-handoff/references/comparison-companion.md +16 -0
  100. package/skills/penpair-handoff/references/handoff-flow.md +2 -0
  101. package/skills/penpair-recipes/SKILL.md +13 -1
  102. package/skills/penpair-recipes/agents/openai.yaml +6 -0
  103. package/skills/penpair-recipes/assets/visual-checkpoint.md +48 -0
  104. package/skills/penpair-recipes/references/visual-checkpoint.md +37 -0
  105. package/skills/penpair-setup/SKILL.md +9 -3
  106. package/skills/penpair-setup/agents/openai.yaml +6 -0
  107. package/skills/penpair-verify/SKILL.md +49 -0
  108. package/skills/penpair-verify/agents/openai.yaml +6 -0
  109. package/skills/penpair-verify/references/browser-evidence.md +33 -0
  110. package/dist/cli.d.ts +0 -2
  111. package/dist/cli.js +0 -3
  112. package/dist/docker/lifecycle.d.ts +0 -96
  113. package/dist/docker/lifecycle.js +0 -273
  114. package/dist/docker/secrets.d.ts +0 -11
  115. package/dist/docker/secrets.js +0 -38
  116. package/dist/docker/template.d.ts +0 -8
  117. package/dist/docker/template.js +0 -158
  118. package/dist/docker-profile.d.ts +0 -114
  119. package/dist/docker-profile.js +0 -18
  120. package/dist/instance/lifecycle.d.ts +0 -129
  121. package/dist/instance/lifecycle.js +0 -336
  122. package/dist/instance/secrets.d.ts +0 -11
  123. package/dist/instance/secrets.js +0 -38
  124. package/dist/instance/template.d.ts +0 -8
  125. 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
- const start = "<!-- penpair:skills:start -->", end = "<!-- penpair:skills:end -->";
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 ["AGENTS.md", "CLAUDE.md"])
22
- if (await exists(path.join(root, name)))
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
- if (candidates.length !== 1)
25
- return { report: { path: null, status: candidates.length ? "choice-required" : "not-selected", diff: "" } };
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, ends = text.split(end).length - 1;
40
- if (starts !== ends || starts > 1 || starts === 1 && text.indexOf(start) > text.indexOf(end))
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.relative(path.dirname(file), path.resolve(canonicalRoot, decodeURIComponent(target))).split(path.sep).join("/");
45
- const link = relative.split("/").map((part) => encodeURIComponent(part).replace(/[()]/g, (character) => `%${character.charCodeAt(0).toString(16)}`)).join("/");
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 = [start, "| Skill | Purpose |", "| --- | --- |", ...relativeRows, end].join(newline);
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
- else
53
- after = text + (text ? text.endsWith(newline) ? newline : newline + newline : "") + "## Penpair skills" + newline + newline + block + newline;
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 ? [`--- ${file}`, `+++ ${file}`, "@@ penpair-owned skills block @@", ...oldBlock.split(/\r?\n/).filter(Boolean).map((line) => `-${line}`), ...(starts ? block : "## Penpair skills" + newline + newline + block).split(/\r?\n/).map((line) => `+${line}`)].join("\n") : "";
58
- return { before, after, mode: before === undefined ? 0o644 : (await lstat(file)).mode & 0o777, report: { path: file, status: changed ? "preview" : "unchanged", diff } };
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
- await atomicWriteFile(plan.report.path, plan.after, { replace: plan.before !== undefined, mode: plan.mode });
74
- return { ...plan.report, status: "written" };
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,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.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(["references/project-facts.md", "PROJECT.md"]);
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, { withFileTypes: true });
13
- return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
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), { withFileTypes: true })) {
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
- else
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 = /^name:\s*(.+)$/m.exec(text)?.[1]?.trim() ?? path.basename(skillDirectory);
51
- const description = /^description:\s*(.+)$/m.exec(text)?.[1]?.trim() ?? "";
52
- return { name, summary: description.split(/(?<=\.)\s/)[0] ?? description };
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 = { "penpot-design": "penpair-design" };
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 [".skills", ".claude/skills", ".agents/skills"]) {
59
- for (const name of ["penpair-design", "penpair-setup", ...Object.keys(renamedSkills)]) {
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.join(to, name, "SKILL.md").split("/").map((part) => encodeURIComponent(part).replace(/[()]/g, (character) => `%${character.charCodeAt(0).toString(16)}`)).join("/");
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 = { to, installed: [], unchanged: [], kept: [], linked: [], agentsRows: rows, legacy: [], instructions: instructionPlan.report, ...(options.dryRun ? { dryRun: true, wouldInstall: [], wouldLink: [] } : {}) };
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
- else if (present === undefined || options.force && !projectOwned.has(file))
95
- writes.set(target, { label, before: present, content: shipped });
96
- else
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 [".agents/skills", ".claude/skills"]) {
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
- links.push({ file: safe, target: path.relative(path.dirname(safe), target), label: path.posix.join(hub, name) });
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, { label: path.posix.join(to, newName, "references/project-facts.md"), before: await readIfExists(newFactsFile), content: oldFacts });
121
- report.legacy.push({ folder: path.posix.join(to, oldName), renamedTo: newName, carriedFacts: carriedFacts && !options.dryRun });
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 = [...writes.values()].map((write) => write.label);
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
- await mkdir(path.dirname(file), { recursive: true });
132
- await atomicWriteFile(file, write.content, { replace: write.before !== undefined, mode: write.before === undefined ? 0o644 : (await lstat(file)).mode & 0o777 });
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), { recursive: true });
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), { recursive: true });
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
- return { name, code, effect };
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, { effect: s.effect, ...options });
34
- const record = typeof result === "object" && result !== null && !Array.isArray(result) ? result : { value: result };
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 { ...record, execution: { runId, outcome: "ran", metrics } };
53
+ return {
54
+ ...record,
55
+ execution: {
56
+ runId,
57
+ outcome: "ran",
58
+ metrics,
59
+ },
60
+ };
41
61
  }
@@ -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 };`, { name });
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 (params.limit !== undefined && (!Number.isInteger(params.limit) || params.limit < 1 || params.limit > 1000) || params.depth !== undefined && (!Number.isInteger(params.depth) || params.depth < 0 || params.depth > 100))
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>;