@penpair/core 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) 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 +1621 -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-instructions.js +91 -22
  66. package/dist/skills.js +129 -39
  67. package/dist/snippet.js +27 -7
  68. package/dist/snippets/read.js +8 -2
  69. package/dist/structure-snapshot.d.ts +12 -0
  70. package/dist/structure-snapshot.js +439 -85
  71. package/dist/token-edit.js +219 -64
  72. package/dist/token-projection.d.ts +56 -0
  73. package/dist/token-projection.js +286 -0
  74. package/dist/tokens-diff.js +125 -32
  75. package/dist/version.d.ts +1 -1
  76. package/dist/version.js +1 -1
  77. package/package.json +1 -1
  78. package/skills/penpair-design/SKILL.md +3 -0
  79. package/skills/penpair-design/references/penpot-plugin-gotchas.md +2 -2
  80. package/skills/penpair-design/references/project-facts.md +2 -0
  81. package/skills/penpair-handoff/SKILL.md +2 -0
  82. package/skills/penpair-handoff/references/comparison-companion.md +13 -0
  83. package/skills/penpair-handoff/references/handoff-flow.md +2 -0
  84. package/skills/penpair-recipes/SKILL.md +5 -0
  85. package/skills/penpair-setup/SKILL.md +3 -1
  86. package/dist/cli.d.ts +0 -2
  87. package/dist/cli.js +0 -3
  88. package/dist/docker/lifecycle.d.ts +0 -96
  89. package/dist/docker/lifecycle.js +0 -273
  90. package/dist/docker/secrets.d.ts +0 -11
  91. package/dist/docker/secrets.js +0 -38
  92. package/dist/docker/template.d.ts +0 -8
  93. package/dist/docker/template.js +0 -158
  94. package/dist/docker-profile.d.ts +0 -114
  95. package/dist/docker-profile.js +0 -18
  96. package/dist/instance/lifecycle.d.ts +0 -129
  97. package/dist/instance/lifecycle.js +0 -336
  98. package/dist/instance/secrets.d.ts +0 -11
  99. package/dist/instance/secrets.js +0 -38
  100. package/dist/instance/template.d.ts +0 -8
  101. package/dist/instance/template.js +0 -158
package/dist/skills.js CHANGED
@@ -1,25 +1,37 @@
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
7
  /** The skills shipped inside the package (`skills/` next to `dist/`). */
8
8
  export const bundledSkillsDirectory = () => path.join(path.dirname(fileURLToPath(import.meta.url)), "../skills");
9
9
  /** 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"]);
10
+ const projectOwned = new Set([
11
+ "references/project-facts.md",
12
+ "PROJECT.md",
13
+ ]);
11
14
  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();
15
+ const entries = await readdir(directory, {
16
+ withFileTypes: true,
17
+ });
18
+ return entries
19
+ .filter((entry) => entry.isDirectory())
20
+ .map((entry) => entry.name)
21
+ .sort();
14
22
  }
15
23
  async function filesUnder(directory, prefix = "") {
16
24
  const out = [];
17
- for (const entry of await readdir(path.join(directory, prefix), { withFileTypes: true })) {
25
+ for (const entry of await readdir(path.join(directory, prefix), {
26
+ withFileTypes: true,
27
+ })) {
18
28
  const relative = path.posix.join(prefix, entry.name);
19
- if (entry.isDirectory())
29
+ if (entry.isDirectory()) {
20
30
  out.push(...(await filesUnder(directory, relative)));
21
- else
31
+ }
32
+ else {
22
33
  out.push(relative);
34
+ }
23
35
  }
24
36
  return out.sort();
25
37
  }
@@ -28,8 +40,9 @@ async function readIfExists(file) {
28
40
  return await readFile(file, "utf8");
29
41
  }
30
42
  catch (error) {
31
- if (error.code === "ENOENT")
43
+ if (error.code === "ENOENT") {
32
44
  return undefined;
45
+ }
33
46
  throw error;
34
47
  }
35
48
  }
@@ -39,8 +52,9 @@ async function exists(file) {
39
52
  return true;
40
53
  }
41
54
  catch (error) {
42
- if (error.code === "ENOENT")
55
+ if (error.code === "ENOENT") {
43
56
  return false;
57
+ }
44
58
  throw error;
45
59
  }
46
60
  }
@@ -49,17 +63,31 @@ export async function describeSkill(skillDirectory) {
49
63
  const text = (await readIfExists(path.join(skillDirectory, "SKILL.md"))) ?? "";
50
64
  const name = /^name:\s*(.+)$/m.exec(text)?.[1]?.trim() ?? path.basename(skillDirectory);
51
65
  const description = /^description:\s*(.+)$/m.exec(text)?.[1]?.trim() ?? "";
52
- return { name, summary: description.split(/(?<=\.)\s/)[0] ?? description };
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>;