@penpair/core 0.1.3 → 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 (149) hide show
  1. package/dist/adapters.d.ts +16 -1
  2. package/dist/adapters.js +62 -19
  3. package/dist/approvals.d.ts +32 -0
  4. package/dist/approvals.js +213 -0
  5. package/dist/atomic-output.d.ts +5 -0
  6. package/dist/atomic-output.js +33 -0
  7. package/dist/audit-rules.d.ts +60 -0
  8. package/dist/audit-rules.js +443 -0
  9. package/dist/backup-note.d.ts +4 -0
  10. package/dist/backup-note.js +49 -0
  11. package/dist/baseline-coverage.d.ts +25 -0
  12. package/dist/baseline-coverage.js +248 -0
  13. package/dist/baselines-config.d.ts +25 -0
  14. package/dist/baselines-config.js +150 -11
  15. package/dist/baselines.d.ts +37 -8
  16. package/dist/baselines.js +362 -59
  17. package/dist/cli-arguments.d.ts +7 -0
  18. package/dist/cli-arguments.js +47 -0
  19. package/dist/cli-main.d.ts +13 -1
  20. package/dist/cli-main.js +2107 -247
  21. package/dist/client.d.ts +30 -6
  22. package/dist/client.js +160 -35
  23. package/dist/command-result.d.ts +159 -0
  24. package/dist/command-result.js +468 -0
  25. package/dist/coordination.d.ts +15 -0
  26. package/dist/coordination.js +184 -0
  27. package/dist/credentials.d.ts +21 -0
  28. package/dist/credentials.js +105 -0
  29. package/dist/deep-check.js +57 -9
  30. package/dist/design-spec.d.ts +55 -0
  31. package/dist/design-spec.js +275 -0
  32. package/dist/endpoint.d.ts +8 -1
  33. package/dist/endpoint.js +133 -21
  34. package/dist/errors.d.ts +2 -1
  35. package/dist/errors.js +8 -4
  36. package/dist/export.d.ts +18 -0
  37. package/dist/export.js +113 -10
  38. package/dist/file-archive.d.ts +16 -0
  39. package/dist/file-archive.js +58 -0
  40. package/dist/file-guard.d.ts +8 -0
  41. package/dist/file-guard.js +58 -0
  42. package/dist/formats.d.ts +4 -0
  43. package/dist/formats.js +229 -0
  44. package/dist/index.d.ts +34 -1
  45. package/dist/index.js +34 -1
  46. package/dist/inspection.d.ts +106 -0
  47. package/dist/inspection.js +657 -0
  48. package/dist/instance/config-schema.d.ts +422 -0
  49. package/dist/instance/config-schema.js +435 -0
  50. package/dist/instance/config.d.ts +29 -0
  51. package/dist/instance/config.js +191 -29
  52. package/dist/instance/connect.d.ts +10 -1
  53. package/dist/instance/connect.js +178 -30
  54. package/dist/instance/connection.d.ts +39 -4
  55. package/dist/instance/connection.js +198 -14
  56. package/dist/instance/gitignore.js +5 -2
  57. package/dist/instance/init.js +32 -7
  58. package/dist/instance/runner.js +32 -10
  59. package/dist/instance/setup.js +46 -13
  60. package/dist/instance/tty.js +28 -8
  61. package/dist/journal.d.ts +87 -0
  62. package/dist/journal.js +423 -0
  63. package/dist/kit-smoke.d.ts +31 -0
  64. package/dist/kit-smoke.js +145 -0
  65. package/dist/kit-source.d.ts +2 -0
  66. package/dist/kit-source.js +279 -0
  67. package/dist/kit.d.ts +25 -0
  68. package/dist/kit.js +158 -0
  69. package/dist/layout-policy.d.ts +2 -0
  70. package/dist/layout-policy.js +86 -0
  71. package/dist/layout.d.ts +21 -0
  72. package/dist/layout.js +36 -0
  73. package/dist/live-smoke.d.ts +8 -0
  74. package/dist/live-smoke.js +23 -0
  75. package/dist/local-profile.js +2 -1
  76. package/dist/mcp-server.d.ts +12 -0
  77. package/dist/mcp-server.js +415 -0
  78. package/dist/native-api.d.ts +15 -0
  79. package/dist/native-api.js +78 -0
  80. package/dist/onboard.d.ts +10 -0
  81. package/dist/onboard.js +41 -0
  82. package/dist/operation.d.ts +22 -3
  83. package/dist/operation.js +341 -31
  84. package/dist/page-session.d.ts +23 -0
  85. package/dist/page-session.js +142 -0
  86. package/dist/png.d.ts +5 -0
  87. package/dist/png.js +22 -0
  88. package/dist/portable-tokens.d.ts +22 -0
  89. package/dist/portable-tokens.js +331 -0
  90. package/dist/probe.d.ts +6 -2
  91. package/dist/probe.js +64 -11
  92. package/dist/project-input.d.ts +6 -0
  93. package/dist/project-input.js +65 -0
  94. package/dist/project-snippets.js +29 -12
  95. package/dist/project-status.d.ts +24 -0
  96. package/dist/project-status.js +188 -0
  97. package/dist/registry.d.ts +2 -1
  98. package/dist/registry.js +224 -53
  99. package/dist/run-file.d.ts +26 -0
  100. package/dist/run-file.js +199 -0
  101. package/dist/shots.d.ts +43 -0
  102. package/dist/shots.js +172 -0
  103. package/dist/skill-instructions.d.ts +13 -0
  104. package/dist/skill-instructions.js +144 -0
  105. package/dist/skills.d.ts +7 -0
  106. package/dist/skills.js +163 -46
  107. package/dist/snippet.d.ts +3 -2
  108. package/dist/snippet.js +33 -9
  109. package/dist/snippets/archive.d.ts +9 -0
  110. package/dist/snippets/archive.js +10 -4
  111. package/dist/snippets/read.d.ts +1 -1
  112. package/dist/snippets/read.js +78 -41
  113. package/dist/structure-snapshot.d.ts +85 -0
  114. package/dist/structure-snapshot.js +525 -0
  115. package/dist/token-edit.d.ts +53 -0
  116. package/dist/token-edit.js +352 -0
  117. package/dist/token-projection.d.ts +56 -0
  118. package/dist/token-projection.js +286 -0
  119. package/dist/tokens-diff.d.ts +19 -6
  120. package/dist/tokens-diff.js +182 -18
  121. package/dist/version.d.ts +1 -1
  122. package/dist/version.js +1 -1
  123. package/package.json +1 -1
  124. package/skills/penpair-design/SKILL.md +20 -45
  125. package/skills/penpair-design/references/penpot-plugin-gotchas.md +58 -2
  126. package/skills/penpair-design/references/project-facts.md +3 -1
  127. package/skills/penpair-design/references/write-protocol.md +15 -3
  128. package/skills/penpair-handoff/SKILL.md +16 -0
  129. package/skills/penpair-handoff/references/comparison-companion.md +13 -0
  130. package/skills/penpair-handoff/references/handoff-flow.md +13 -0
  131. package/skills/penpair-recipes/SKILL.md +21 -0
  132. package/skills/penpair-recipes/references/build-flow.md +11 -0
  133. package/skills/penpair-setup/SKILL.md +8 -2
  134. package/dist/cli.d.ts +0 -2
  135. package/dist/cli.js +0 -3
  136. package/dist/docker/lifecycle.d.ts +0 -96
  137. package/dist/docker/lifecycle.js +0 -273
  138. package/dist/docker/secrets.d.ts +0 -11
  139. package/dist/docker/secrets.js +0 -38
  140. package/dist/docker/template.d.ts +0 -8
  141. package/dist/docker/template.js +0 -158
  142. package/dist/docker-profile.d.ts +0 -114
  143. package/dist/docker-profile.js +0 -18
  144. package/dist/instance/lifecycle.d.ts +0 -129
  145. package/dist/instance/lifecycle.js +0 -336
  146. package/dist/instance/secrets.d.ts +0 -11
  147. package/dist/instance/secrets.js +0 -38
  148. package/dist/instance/template.d.ts +0 -8
  149. package/dist/instance/template.js +0 -158
@@ -0,0 +1,199 @@
1
+ import { open, realpath } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { maxSnippetBytes } from "./client.js";
5
+ import { resolveProjectInput } from "./project-input.js";
6
+ import { compiles, requirePage, snippet } from "./snippet.js";
7
+ import { normalize } from "./registry.js";
8
+ import { runOnPage, runOperation } from "./operation.js";
9
+ const digest = (value) => createHash("sha256").update(value).digest("hex");
10
+ async function sourceFile(root, entry) {
11
+ if (!/\.m?js$/.test(entry)) {
12
+ throw new Error("Plugin bodies must be .js or .mjs files; host imports and TypeScript transpilation are not performed");
13
+ }
14
+ const target = await resolveProjectInput(root, entry);
15
+ const handle = await open(target, "r");
16
+ let body;
17
+ try {
18
+ const info = await handle.stat();
19
+ if (!info.isFile() || info.size > maxSnippetBytes) {
20
+ throw new Error("Script file exceeds the 90 KB input bound or is not a regular file");
21
+ }
22
+ body = (await handle.readFile("utf8")).replace(/^\uFEFF/, "");
23
+ }
24
+ finally {
25
+ await handle.close();
26
+ }
27
+ if (body.includes("\0")) {
28
+ throw new Error("Plugin script contains a NUL character");
29
+ }
30
+ const header = body.trimStart().split(/\r?\n/)[0]?.trim();
31
+ const declaration = header === "// penpair: read" ? "read" : header === "// penpair: write" ? "write" : "unknown";
32
+ // The idempotency assertion must be in the initial comment block, before executable source.
33
+ const comments = [];
34
+ for (const line of body.trimStart().split(/\r?\n/)) {
35
+ if (!line.trim().startsWith("//")) {
36
+ break;
37
+ }
38
+ comments.push(line.trim());
39
+ }
40
+ return {
41
+ file: path
42
+ .relative(await realpath(root), target)
43
+ .split(path.sep)
44
+ .join("/"),
45
+ sha256: digest(body),
46
+ body,
47
+ declaration,
48
+ idempotent: comments.includes("// penpair: idempotent"),
49
+ };
50
+ }
51
+ function checked(value) {
52
+ const error = compiles(value.code);
53
+ if (error) {
54
+ throw new Error(`${value.name}: invalid plugin body: ${error}`);
55
+ }
56
+ return value;
57
+ }
58
+ /** Reads and compiles explicitly selected files without importing project modules on the host. */
59
+ export async function prepareFileScript(root, file, options = {}) {
60
+ const includes = options.include ?? [];
61
+ if (includes.length > 20 || new Set(includes).size !== includes.length) {
62
+ throw new Error("Use at most 20 distinct ordered includes");
63
+ }
64
+ const params = options.params ?? {};
65
+ if (Object.keys(params).length > 100 ||
66
+ Object.keys(params).some((key) => !/^[A-Za-z][A-Za-z0-9]*$/.test(key))) {
67
+ throw new Error("Use at most 100 named parameters (letters followed by letters/digits)");
68
+ }
69
+ if (options.page && Object.hasOwn(params, "page")) {
70
+ throw new Error("--page owns CFG.page; do not also pass --param page");
71
+ }
72
+ if (Object.hasOwn(params, "inspection")) {
73
+ throw new Error("CFG.inspection is reserved for the independently captured write inspection");
74
+ }
75
+ const helpers = [];
76
+ for (const include of includes) {
77
+ const virtual = options.virtualIncludes?.[include];
78
+ const helper = virtual === undefined
79
+ ? await sourceFile(root, include)
80
+ : {
81
+ file: include,
82
+ body: virtual,
83
+ sha256: digest(virtual),
84
+ declaration: virtual.startsWith("// penpair: read\n") ? "read" : "unknown",
85
+ idempotent: false,
86
+ };
87
+ if (helper.declaration !== "read") {
88
+ throw new Error(`Include ${include} must declare // penpair: read for its top-level inclusion effects`);
89
+ }
90
+ helpers.push(helper);
91
+ }
92
+ const main = await sourceFile(root, file);
93
+ const prelude = helpers.map((helper) => `// Included helper\n${helper.body}\n`).join("\n");
94
+ const cfg = {
95
+ ...params,
96
+ ...(options.page
97
+ ? {
98
+ page: options.page,
99
+ }
100
+ : {}),
101
+ };
102
+ const build = (label, body, effect, inspected) => checked(snippet(label, `${requirePage}\n${prelude}\n${body}`, {
103
+ ...cfg,
104
+ ...(effect === "write"
105
+ ? {
106
+ inspection: inspected ?? null,
107
+ }
108
+ : {}),
109
+ }, effect));
110
+ if (main.declaration === "read") {
111
+ if (options.inspect || options.readback) {
112
+ throw new Error("Read scripts do not take write inspect/readback phases");
113
+ }
114
+ return {
115
+ kind: "read",
116
+ source: [
117
+ ...helpers,
118
+ main,
119
+ ].map(({ file, sha256 }) => ({
120
+ file,
121
+ sha256,
122
+ })),
123
+ params: cfg,
124
+ page: options.page,
125
+ read: build("run-file-read", main.body, "read"),
126
+ };
127
+ }
128
+ if (main.declaration !== "write" || !main.idempotent || !options.inspect || !options.readback) {
129
+ throw new Error("Write script execution requires // penpair: write, // penpair: idempotent, and explicit --inspect/--readback files. Declarations are trusted code contracts, not a sandbox.");
130
+ }
131
+ const inspect = await sourceFile(root, options.inspect);
132
+ const readback = await sourceFile(root, options.readback);
133
+ if (inspect.declaration !== "read" || readback.declaration !== "read") {
134
+ throw new Error("Inspection and readback files must declare // penpair: read");
135
+ }
136
+ // Reuse the registry's positive evidence verifier; never equate an acknowledgment with a verified write.
137
+ const normalized = normalize({
138
+ name: "run-file",
139
+ description: "Explicitly selected reviewed file operation",
140
+ kind: "write",
141
+ inspect: inspect.body,
142
+ apply: main.body,
143
+ readback: readback.body,
144
+ }, "project");
145
+ const operation = {
146
+ ...normalized.operation,
147
+ page: () => options.page,
148
+ inspect: () => build("run-file-inspect", inspect.body, "read"),
149
+ apply: (_params, inspected) => build("run-file-apply", main.body, "write", inspected),
150
+ readback: () => build("run-file-readback", readback.body, "read"),
151
+ };
152
+ operation.inspect({});
153
+ operation.apply({});
154
+ operation.readback({});
155
+ return {
156
+ kind: "write",
157
+ source: [
158
+ ...helpers,
159
+ main,
160
+ inspect,
161
+ readback,
162
+ ].map(({ file, sha256 }) => ({
163
+ file,
164
+ sha256,
165
+ })),
166
+ params: cfg,
167
+ page: options.page,
168
+ operation,
169
+ };
170
+ }
171
+ export async function runFileScript(client, prepared, options = {}) {
172
+ if (prepared.kind === "read") {
173
+ if (options.apply || options.journal) {
174
+ throw new Error("Read files do not accept apply or write recovery options");
175
+ }
176
+ const result = await runOnPage(client, prepared.read, prepared.page, {
177
+ ...options,
178
+ effect: "read",
179
+ retries: 0,
180
+ });
181
+ if (result.ok !== true) {
182
+ throw new Error("Read script returned no explicit ok acknowledgment");
183
+ }
184
+ return {
185
+ phase: "read",
186
+ kind: "declared-read",
187
+ source: prepared.source,
188
+ data: result,
189
+ };
190
+ }
191
+ const report = await runOperation(client, prepared.operation, {
192
+ parameters: prepared.params,
193
+ sourceFiles: prepared.source,
194
+ }, options);
195
+ return {
196
+ ...report,
197
+ source: prepared.source,
198
+ };
199
+ }
@@ -0,0 +1,43 @@
1
+ import type { PenpotToolClient } from "./client.js";
2
+ import { type ExportOptions, type CaptureIdentity } from "./export.js";
3
+ import type { LayoutResult } from "./layout.js";
4
+ export interface ShapeMatch {
5
+ id: string;
6
+ name: string;
7
+ type: string;
8
+ }
9
+ export interface ShapeLookup {
10
+ fileId: string;
11
+ pageId: string;
12
+ revision: number;
13
+ matches: ShapeMatch[];
14
+ }
15
+ /** Exact names across the selected page. A match never silently resolves an ambiguity. */
16
+ export declare function findShapesByName(client: PenpotToolClient, name: string, page: string, options?: ExportOptions): Promise<ShapeLookup>;
17
+ export declare function exportShapeByName(client: PenpotToolClient, name: string, page: string, options?: ExportOptions): Promise<import("./export.js").ExportResult>;
18
+ export interface ShotsReport {
19
+ schemaVersion: 1;
20
+ complete: boolean;
21
+ fileId: string;
22
+ pageId: string;
23
+ revision: number;
24
+ captures: {
25
+ name: string;
26
+ file: string;
27
+ sha256: string;
28
+ capture: CaptureIdentity;
29
+ layout?: LayoutResult;
30
+ width: number;
31
+ height: number;
32
+ }[];
33
+ wrote: string[];
34
+ reason?: string;
35
+ }
36
+ /** Ad-hoc captures use immutable generations, but never carry baseline approval records. */
37
+ export declare function captureShots(client: PenpotToolClient, root: string, params: {
38
+ page: string;
39
+ prefix?: string;
40
+ out?: string;
41
+ }, options?: ExportOptions & {
42
+ signal?: AbortSignal;
43
+ }): Promise<ShotsReport>;
package/dist/shots.js ADDED
@@ -0,0 +1,172 @@
1
+ import { randomUUID, createHash } from "node:crypto";
2
+ import { mkdir, rename, rm } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { PenpairError } from "./errors.js";
5
+ import { snippet, requirePage } from "./snippet.js";
6
+ import { runOnPage } from "./operation.js";
7
+ import { frames } from "./snippets/read.js";
8
+ import { exportShapePng } from "./export.js";
9
+ import { resolveProjectOutput } from "./project-input.js";
10
+ import { atomicWriteFile } from "./atomic-output.js";
11
+ import { pngSize } from "./baselines.js";
12
+ import { observationHash } from "./journal.js";
13
+ /** Exact names across the selected page. A match never silently resolves an ambiguity. */
14
+ export async function findShapesByName(client, name, page, options = {}) {
15
+ if (!name || !page) {
16
+ throw new Error("Name lookup requires an exact name and explicit page");
17
+ }
18
+ const result = await runOnPage(client, snippet("find-shape-name", `${requirePage}
19
+ const matches = [], stack = [...penpot.currentPage.root.children]; let visited = 0;
20
+ while (stack.length && visited < 10000) {
21
+ const shape = stack.pop(); visited++;
22
+ if (shape.name === CFG.name) matches.push({id:shape.id,name:shape.name,type:shape.type});
23
+ for (const child of shape.children || []) stack.push(child);
24
+ }
25
+ return {ok:true,fileId:penpot.currentFile.id,pageId:penpot.currentPage.id,revision:penpot.currentFile.revn,matches,truncated:stack.length>0};`, {
26
+ name,
27
+ page,
28
+ }, "read"), page, options);
29
+ if (result.truncated) {
30
+ throw new PenpairError("incomplete-output", "Shape name search exceeded 10000 nodes; narrow the page");
31
+ }
32
+ if (result.ok !== true ||
33
+ typeof result.truncated !== "boolean" ||
34
+ typeof result.fileId !== "string" ||
35
+ !result.fileId ||
36
+ typeof result.pageId !== "string" ||
37
+ !result.pageId ||
38
+ !Number.isInteger(result.revision) ||
39
+ !Array.isArray(result.matches) ||
40
+ result.matches.some((match) => !match ||
41
+ typeof match.id !== "string" ||
42
+ typeof match.name !== "string" ||
43
+ typeof match.type !== "string")) {
44
+ throw new Error("Shape search returned incomplete identity evidence");
45
+ }
46
+ return result;
47
+ }
48
+ export async function exportShapeByName(client, name, page, options = {}) {
49
+ const lookup = await findShapesByName(client, name, page, options);
50
+ if (lookup.matches.length !== 1) {
51
+ throw new Error(`Exact name ${JSON.stringify(name)} matched ${lookup.matches.length} shapes on ${JSON.stringify(page)}. Candidates: ${JSON.stringify(lookup.matches.slice(0, 20))}. Use an ID for an ambiguous name.`);
52
+ }
53
+ const result = await exportShapePng(client, lookup.matches[0].id, {
54
+ ...options,
55
+ page,
56
+ });
57
+ if (result.capture.fileId !== lookup.fileId ||
58
+ result.capture.pageId !== lookup.pageId ||
59
+ result.capture.revision !== lookup.revision) {
60
+ throw new Error("Named export source changed after lookup; no output was published");
61
+ }
62
+ return result;
63
+ }
64
+ /** Ad-hoc captures use immutable generations, but never carry baseline approval records. */
65
+ export async function captureShots(client, root, params, options = {}) {
66
+ const listing = await runOnPage(client, frames({
67
+ page: params.page,
68
+ }), params.page, options);
69
+ if (typeof listing.fileId !== "string" ||
70
+ typeof listing.pageId !== "string" ||
71
+ !Number.isInteger(listing.revn) ||
72
+ !Array.isArray(listing.frames)) {
73
+ throw new Error("Shots listing lacks identity/revision evidence");
74
+ }
75
+ const selected = listing.frames.filter((frame) => !params.prefix || frame.name.startsWith(params.prefix));
76
+ const report = {
77
+ schemaVersion: 1,
78
+ complete: false,
79
+ fileId: listing.fileId,
80
+ pageId: listing.pageId,
81
+ revision: Number(listing.revn),
82
+ captures: [],
83
+ wrote: [],
84
+ };
85
+ if (!selected.length || selected.length > 100) {
86
+ return {
87
+ ...report,
88
+ reason: !selected.length
89
+ ? "No matching top-level frames"
90
+ : "More than 100 frames; narrow the prefix",
91
+ };
92
+ }
93
+ const folder = await resolveProjectOutput(root, params.out ?? "design/shots");
94
+ const runId = randomUUID();
95
+ const generation = `captures/${runId}`;
96
+ const images = [];
97
+ for (const [index, frame] of selected.entries()) {
98
+ options.signal?.throwIfAborted();
99
+ options.onStep?.(`exporting ${JSON.stringify(frame.name)}`);
100
+ const result = await exportShapePng(client, frame.id, {
101
+ ...options,
102
+ page: params.page,
103
+ stable: true,
104
+ });
105
+ if (!result.stable) {
106
+ return {
107
+ ...report,
108
+ reason: "A frame did not stabilize; no output was published",
109
+ };
110
+ }
111
+ if (result.capture.fileId !== report.fileId ||
112
+ result.capture.pageId !== report.pageId ||
113
+ result.capture.revision !== report.revision) {
114
+ throw new Error("Shots source changed during capture; no output was published");
115
+ }
116
+ const size = pngSize(result.png);
117
+ if (!size || size.width <= 0 || size.height <= 0) {
118
+ throw new Error("Shot export is missing valid PNG dimensions");
119
+ }
120
+ report.captures.push({
121
+ layout: result.layout,
122
+ ...size,
123
+ name: frame.name,
124
+ file: `${generation}/shot-${String(index + 1).padStart(4, "0")}.png`,
125
+ sha256: createHash("sha256").update(result.png).digest("hex"),
126
+ capture: result.capture,
127
+ });
128
+ images.push(result.png);
129
+ }
130
+ const after = await runOnPage(client, frames({
131
+ page: params.page,
132
+ }), params.page, options);
133
+ const identity = (entry) => ({
134
+ fileId: entry.fileId,
135
+ pageId: entry.pageId,
136
+ revision: entry.revn,
137
+ frames: entry.frames,
138
+ });
139
+ if (observationHash(identity(listing)) !== observationHash(identity(after))) {
140
+ throw new Error("Shots source changed before publication; previous output was preserved");
141
+ }
142
+ options.signal?.throwIfAborted();
143
+ await mkdir(path.join(folder, "captures"), {
144
+ recursive: true,
145
+ });
146
+ const staged = path.join(folder, `.stage-${runId}`);
147
+ await mkdir(staged);
148
+ try {
149
+ for (const [index, bytes] of images.entries()) {
150
+ options.signal?.throwIfAborted();
151
+ await atomicWriteFile(path.join(staged, path.basename(report.captures[index].file)), bytes);
152
+ }
153
+ options.signal?.throwIfAborted();
154
+ await rename(staged, path.join(folder, generation));
155
+ options.signal?.throwIfAborted();
156
+ report.complete = true;
157
+ report.wrote = [
158
+ ...report.captures.map((entry) => entry.file),
159
+ "penpair-shots.json",
160
+ ];
161
+ await atomicWriteFile(path.join(folder, "penpair-shots.json"), `${JSON.stringify(report, null, 2)}\n`, {
162
+ replace: true,
163
+ });
164
+ return report;
165
+ }
166
+ finally {
167
+ await rm(staged, {
168
+ recursive: true,
169
+ force: true,
170
+ });
171
+ }
172
+ }
@@ -0,0 +1,13 @@
1
+ export interface InstructionReport {
2
+ path: string | null;
3
+ status: "not-selected" | "choice-required" | "unchanged" | "preview" | "written";
4
+ diff: string;
5
+ }
6
+ export interface InstructionPlan {
7
+ report: InstructionReport;
8
+ before?: string;
9
+ after?: string;
10
+ mode?: number;
11
+ }
12
+ export declare function planSkillInstructions(root: string, rows: string[], destination?: string): Promise<InstructionPlan>;
13
+ export declare function applySkillInstructions(plan: InstructionPlan): Promise<InstructionReport>;
@@ -0,0 +1,144 @@
1
+ import { lstat, readFile, realpath } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { resolveProjectOutput } from "./project-input.js";
4
+ import { atomicWriteFile } from "./atomic-output.js";
5
+ const start = "<!-- penpair:skills:start -->";
6
+ const end = "<!-- penpair:skills:end -->";
7
+ async function exists(file) {
8
+ try {
9
+ await lstat(file);
10
+ return true;
11
+ }
12
+ catch (error) {
13
+ if (error.code === "ENOENT") {
14
+ return false;
15
+ }
16
+ throw error;
17
+ }
18
+ }
19
+ export async function planSkillInstructions(root, rows, destination) {
20
+ let selected = destination;
21
+ if (!selected) {
22
+ const candidates = [];
23
+ for (const name of [
24
+ "AGENTS.md",
25
+ "CLAUDE.md",
26
+ ]) {
27
+ if (await exists(path.join(root, name))) {
28
+ candidates.push(name);
29
+ }
30
+ }
31
+ if (candidates.length !== 1) {
32
+ return {
33
+ report: {
34
+ path: null,
35
+ status: candidates.length ? "choice-required" : "not-selected",
36
+ diff: "",
37
+ },
38
+ };
39
+ }
40
+ selected = candidates[0];
41
+ }
42
+ const canonicalRoot = await realpath(root);
43
+ const file = await resolveProjectOutput(canonicalRoot, selected);
44
+ let before;
45
+ try {
46
+ before = await readFile(file, "utf8");
47
+ }
48
+ catch (error) {
49
+ if (error.code !== "ENOENT") {
50
+ throw error;
51
+ }
52
+ }
53
+ const text = before ?? "";
54
+ const starts = text.split(start).length - 1;
55
+ const ends = text.split(end).length - 1;
56
+ if (starts !== ends || starts > 1 || (starts === 1 && text.indexOf(start) > text.indexOf(end))) {
57
+ throw new Error("Malformed penpair skills markers; repair them before installation (no instruction text was changed)");
58
+ }
59
+ const newline = text.includes("\r\n") ? "\r\n" : "\n";
60
+ const relativeRows = rows.map((row) => row.replace(/\]\(([^)]+)\)/g, (_match, target) => {
61
+ const relative = path
62
+ .relative(path.dirname(file), path.resolve(canonicalRoot, decodeURIComponent(target)))
63
+ .split(path.sep)
64
+ .join("/");
65
+ const link = relative
66
+ .split("/")
67
+ .map((part) => encodeURIComponent(part).replace(/[()]/g, (character) => `%${character.charCodeAt(0).toString(16)}`))
68
+ .join("/");
69
+ return `](${link})`;
70
+ }));
71
+ const block = [
72
+ start,
73
+ "| Skill | Purpose |",
74
+ "| --- | --- |",
75
+ ...relativeRows,
76
+ end,
77
+ ].join(newline);
78
+ let after;
79
+ if (starts === 1) {
80
+ after = text.slice(0, text.indexOf(start)) + block + text.slice(text.indexOf(end) + end.length);
81
+ }
82
+ else {
83
+ after =
84
+ text +
85
+ (text ? (text.endsWith(newline) ? newline : newline + newline) : "") +
86
+ "## Penpair skills" +
87
+ newline +
88
+ newline +
89
+ block +
90
+ newline;
91
+ }
92
+ const changed = text !== after;
93
+ // Diff only the owned region, never repeat unrelated instruction content in reports.
94
+ const oldBlock = starts ? text.slice(text.indexOf(start), text.indexOf(end) + end.length) : "";
95
+ const diff = changed
96
+ ? [
97
+ `--- ${file}`,
98
+ `+++ ${file}`,
99
+ "@@ penpair-owned skills block @@",
100
+ ...oldBlock
101
+ .split(/\r?\n/)
102
+ .filter(Boolean)
103
+ .map((line) => `-${line}`),
104
+ ...(starts ? block : "## Penpair skills" + newline + newline + block)
105
+ .split(/\r?\n/)
106
+ .map((line) => `+${line}`),
107
+ ].join("\n")
108
+ : "";
109
+ return {
110
+ before,
111
+ after,
112
+ mode: before === undefined ? 0o644 : (await lstat(file)).mode & 0o777,
113
+ report: {
114
+ path: file,
115
+ status: changed ? "preview" : "unchanged",
116
+ diff,
117
+ },
118
+ };
119
+ }
120
+ export async function applySkillInstructions(plan) {
121
+ if (plan.report.status !== "preview" || !plan.report.path || plan.after === undefined) {
122
+ return plan.report;
123
+ }
124
+ let current;
125
+ try {
126
+ current = await readFile(plan.report.path, "utf8");
127
+ }
128
+ catch (error) {
129
+ if (error.code !== "ENOENT") {
130
+ throw error;
131
+ }
132
+ }
133
+ if (current !== plan.before) {
134
+ throw new Error("Instruction file changed after planning; nothing was overwritten");
135
+ }
136
+ await atomicWriteFile(plan.report.path, plan.after, {
137
+ replace: plan.before !== undefined,
138
+ mode: plan.mode,
139
+ });
140
+ return {
141
+ ...plan.report,
142
+ status: "written",
143
+ };
144
+ }
package/dist/skills.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type InstructionReport } from "./skill-instructions.js";
1
2
  /** The skills shipped inside the package (`skills/` next to `dist/`). */
2
3
  export declare const bundledSkillsDirectory: () => string;
3
4
  export declare function listBundledSkills(directory?: string): Promise<string[]>;
@@ -7,6 +8,8 @@ export declare function describeSkill(skillDirectory: string): Promise<{
7
8
  summary: string;
8
9
  }>;
9
10
  export interface InstallOptions {
11
+ agents?: string;
12
+ dryRun?: boolean;
10
13
  /** The project's root. */
11
14
  root: string;
12
15
  /** Where the skills go, relative to the root. */
@@ -23,6 +26,10 @@ export interface InstallOptions {
23
26
  /** Skills that changed name. Installing the new one leaves the old folder behind, which `installSkills` then reports. */
24
27
  export declare const renamedSkills: Record<string, string>;
25
28
  export interface InstallReport {
29
+ instructions?: InstructionReport;
30
+ dryRun?: boolean;
31
+ wouldInstall?: string[];
32
+ wouldLink?: string[];
26
33
  /** The folder the skills went to, relative to the root. */
27
34
  to: string;
28
35
  installed: string[];