@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
package/dist/cli-main.js CHANGED
@@ -1,9 +1,27 @@
1
- import { writeFile, readFile } from "node:fs/promises";
1
+ import { auditDesignRules } from "./audit-rules.js";
2
+ import { projectStatus, projectFileUrl, openProject } from "./project-status.js";
3
+ import { artifactSchemas, migrateProjectConfig } from "./formats.js";
4
+ import { importDtcg, exportDtcg } from "./portable-tokens.js";
5
+ import { captureStructuralSnapshot, writeStructuralSnapshot, readStructuralSnapshot, diffStructuralSnapshots, renderStructuralDiff, } from "./structure-snapshot.js";
6
+ import { recordApproval, approvalFingerprint, artifactSha } from "./approvals.js";
7
+ import { AsyncLocalStorage } from "node:async_hooks";
8
+ import { buildDesignSpec, renderDesignSpec, checkDesignSpec, readDesignJson, validateComponentMap, emptyComponentMap, writeDesignSpec, } from "./design-spec.js";
9
+ import { readBaselineCoverage } from "./baseline-coverage.js";
10
+ import { runKitSmoke } from "./kit-smoke.js";
11
+ import { checkKits, loadKits } from "./kit.js";
12
+ import { prepareFileScript, runFileScript } from "./run-file.js";
13
+ import { tokenEditOperation, validateTokenEdit } from "./token-edit.js";
14
+ import { withCommandSignal, commandSignal, commandOutput as output, commandFailure, commandRedactor, collectCommand, CommandInterruptedError, } from "./command-result.js";
15
+ import { readFile } from "node:fs/promises";
2
16
  import path from "node:path";
3
- import { parseArgs } from "node:util";
17
+ import { CommandArgumentError, parseCommandArgs } from "./cli-arguments.js";
4
18
  import { connect, executeCode } from "./client.js";
5
- import { redactEndpoint, resolveEndpoint } from "./endpoint.js";
19
+ import { endpointRedactor, redactEndpoint, resolveEndpoint } from "./endpoint.js";
6
20
  import { PenpairError } from "./errors.js";
21
+ import { captureInspection, inspectChunk, validateCaptureBudget, validateInspectionQuery, } from "./inspection.js";
22
+ import { resolveProjectOutput } from "./project-input.js";
23
+ import { mkdir } from "node:fs/promises";
24
+ import { captureShots, exportShapeByName, findShapesByName } from "./shots.js";
7
25
  import { exportShapePng } from "./export.js";
8
26
  import { exportBaselines } from "./baselines.js";
9
27
  import { archiveOperation, runOnPage, runOperation } from "./operation.js";
@@ -13,48 +31,86 @@ import { installSkills, listBundledSkills } from "./skills.js";
13
31
  import { checkProjectSnippets, loadProjectSnippets } from "./project-snippets.js";
14
32
  import { builtInRegistry, coerceParams, RegistryError } from "./registry.js";
15
33
  import { runSnippet } from "./snippet.js";
16
- import { audit, pages, tokensDump, tree } from "./snippets/read.js";
17
- import { diffEffective, effectiveValues } from "./tokens-diff.js";
18
- import { ConfigError, isExternal, loadConfig, requireLocal } from "./instance/config.js";
34
+ import { audit, pages, tree } from "./snippets/read.js";
35
+ import { diffEffective, effectiveValues, parseTokenDump } from "./tokens-diff.js";
36
+ import { ConfigError, isExternal, loadConfig, loadOptionalConfig, requireLocal, } from "./instance/config.js";
37
+ import { projectConfigSchema } from "./instance/config-schema.js";
19
38
  import { init } from "./instance/init.js";
20
39
  import { ConnectError, connectProject, keyFileCheck } from "./instance/connect.js";
21
40
  import { hostedOrigin, setup } from "./instance/setup.js";
22
41
  import { askHidden, askLine, openInBrowser } from "./instance/tty.js";
23
- import { loadAdapters, readRawConfig, reservedCommands } from "./adapters.js";
42
+ import { loadAdapters, readRawConfig, reservedCommands, } from "./adapters.js";
24
43
  import { requireLocalProfile } from "./local-profile.js";
25
- import { assertEndpointIsInstance, doctorExternal, LifecycleError } from "./instance/connection.js";
44
+ import { assertEndpointIsInstance, doctorExternal, LifecycleError, } from "./instance/connection.js";
26
45
  import { nodeRunner } from "./instance/runner.js";
27
46
  import { version } from "./version.js";
47
+ import { guardFile } from "./file-guard.js";
48
+ import { atomicWriteFile } from "./atomic-output.js";
49
+ import { onboardingChecklist } from "./onboard.js";
50
+ import { exportFileArchive } from "./file-archive.js";
51
+ import { backupNoteCheck } from "./backup-note.js";
52
+ import { createCheckpoint } from "./native-api.js";
53
+ import { listJournals, readJournal } from "./journal.js";
28
54
  const usageHead = `penpair ${version}
29
55
  Start here (guided, plain language; safe to run again):
30
56
  penpair setup asks where your Penpot is (web is the default), sets everything up and checks it
31
57
  penpair setup --web [--external <address>] Penpot on the web: design.penpot.app, or your team's address; no questions about it
32
58
  @@SETUP@@ penpair connect [--replace] only the connection: takes your personal key without showing it, waits for the editor
33
59
  (a pipe works too: pbpaste | penpair connect)
34
- Read-only:
35
- penpair probe what the connected Penpot and its MCP offer, and the limits
36
- penpair probe --deep --write also tries layout, tokens, components, swap, clone and export on a temporary
37
- board it removes again (it touches the open file, so --write is required)
38
- (in a project with an instance every command below acts on that instance only; --any-instance overrides)
39
- penpair pages the file, its revision, pages and their sizes
40
- penpair tree --page <name> [--depth 3] [--prefix <text>]
41
- penpair audit --page <name> --prefix <frame name start> [--steps 0,2,4,8] [--ignore <name starts>]
42
- penpair tokens dump token sets and themes as JSON
43
- penpair tokens diff <before.json> <after.json> --theme <name>
44
- penpair export <shapeId> <out.png> [--page <name>] [--scale 1]
60
+ penpair connect --pin-file pin the connected design file ID in project configuration
61
+ (--any-file explicitly overrides a pinned file; instance guarding still applies)
62
+ Connect / diagnose:
63
+ penpair config check [--json] validate configuration offline
64
+ penpair config schema print its JSON Schema
65
+ penpair doctor [--json] [--timeout-ms 5000] origin, transport, fresh editor read and file pin (1000–30000 ms)
66
+ penpair status [--adapters] observed project health and optional companion readiness
67
+ penpair open [--print] open the pinned public file URL, or print it
68
+ penpair probe [--integrity] observed APIs and limits
69
+ penpair probe --deep --write [--kit] temporary scratch checks; requires an authorized disposable file
70
+ penpair onboard [--json] personal-key colleague setup checklist
71
+ Build:
72
+ penpair kit check [--include @kit] compile helpers offline
73
+ penpair recipes [--include @kit] list available recipes without building
74
+ penpair run-file <script.js> --include @kit --inspect inspect.js --readback readback.js --page PAGE [--apply]
75
+ penpair run <name> [--param key=value] [--apply] configured snippet; inspect first
76
+ penpair snippets / snippets check list or validate configured trusted scripts
77
+ penpair tokens add-set|set|remove|promote inspect token edits; --apply writes once
78
+ penpair archive --page PAGE --name Archive --keep PREFIX [--apply]
79
+ (run-file/export/shots accept --layout auto|native|geometry; default auto)
80
+ (journaled writes accept --resume RUN_ID --apply, --checkpoint and --trace)
81
+ Inspect / spec:
82
+ penpair pages file identity, revision and pages
83
+ penpair tree --page PAGE [--depth 3] [--prefix TEXT] bounded preview
84
+ penpair inspect ID --page PAGE --all --out design/inspection.json
85
+ penpair spec --from design/inspection.json --out design/spec.md
86
+ penpair spec check --from design/inspection.json --out design/spec.md
87
+ penpair spec --page PAGE --frame ID [--map mapping.json]
88
+ penpair audit --page PAGE --prefix PREFIX [--rules] advisory findings and unknowns
89
+ penpair tokens dump complete typed token catalog
90
+ Snapshot / diff:
91
+ penpair snapshot [--write] [--out design/snapshots/current]
92
+ penpair snapshot diff design/snapshots/before --from design/snapshots/current
93
+ penpair tokens diff before.json after.json --theme NAME
94
+ penpair runs [RUN_ID] private recovery evidence, offline
95
+ Capture / approve:
96
+ penpair export ID out.png [--page PAGE] [--scale 1]
97
+ penpair export --name EXACT_NAME --page PAGE out.png
98
+ penpair shots --page PAGE [--prefix PREFIX] [--out design/shots]
99
+ penpair approve --page PAGE --frame ID [--case CASE_ID] preview exact-artifact evidence
100
+ penpair approve --page PAGE --frame ID --write --note "OWNER WORDS" --by OWNER
101
+ penpair baselines [--apply] [--update] capture configured references; approval requires exact owner evidence
102
+ penpair baselines coverage [--json] approved intent, missing cases and unknowns
103
+ penpair backup file new.penpot bounded full-file archive; restore proof remains separate
104
+ Compare:
105
+ penpair reflection plan --map cases.json requires the configured comparison companion
106
+ penpair reflection check --map cases.json --provider provider.mjs
107
+ (install/register the companion, then supply real browser setup/readback; scores require owner review)
108
+ Workflow and limitations: docs/design-workflow.md, docs/adapters.md, docs/recovery.md
45
109
  @@INSTANCE@@Skills (the rules an AI agent follows, shipped with penpair):
46
110
  penpair skills list them
47
- penpair skills install [--to <folder>] [--only <name>] [--force] copy them into the project and make them discoverable
48
- Snippets (the built-in ones and the project's own, listed under "snippets" in penpair.config.json):
49
- penpair snippets what can be run, with its parameters
50
- penpair snippets check validate the project's snippets without a connection
51
- penpair run <name> [--param key=value ...] [--apply] run one; a snippet that changes things only looks unless --apply
52
- Hand a page's design to a check (Reflection or any image comparison):
53
- penpair baselines [--apply] [--update] export the frames listed under "baselines" in penpair.config.json as approved images,
54
- a manifest and ready-made Reflection cases. Looks first; --apply writes new images;
55
- --update also replaces images whose design changed (approving the new design)
56
- Changes in a Penpot file (inspect first; nothing is written without --apply):
57
- penpair archive --page <name> --name <archive board> --keep <prefixes> --parts <prefixes> [--x 9000] [--apply]
111
+ penpair skills install [--to <folder>] [--only <name>] [--force] [--agents AGENTS.md] [--dry-run]
112
+ (built-in help lists configured extensions without importing their trusted code)
113
+ (config-free inspection requires --standalone; writes require a project)
58
114
  penpair --version`;
59
115
  const usageLocalInstance = `An instance on this computer (one Penpot per project, configured in penpair.config.json):
60
116
  penpair init [--instance <name>] [--port 9001] [--gitignore] set up the config, the compose file and the secrets
@@ -69,79 +125,930 @@ const usageConnection = `Connecting to a Penpot (one project, configured in penp
69
125
  penpair init --external <origin> use a Penpot someone else runs (a team server or https://design.penpot.app)
70
126
  penpair doctor [--json] what is up, what is not, and what to do about it (--json for scripts and agents)
71
127
  `;
72
- const usage = (hasLocal, adapters = []) => usageHead.replace("@@INSTANCE@@", usageConnection + (hasLocal ? usageLocalInstance : usageNoLocalInstance)).replace("@@SETUP@@", hasLocal ? " penpair setup --docker Penpot on this computer in Docker\n" : "") + adapterUsage(adapters);
128
+ const usage = (hasLocal, adapters = [], configured = []) => usageHead
129
+ .replace("@@INSTANCE@@", usageConnection + (hasLocal ? usageLocalInstance : usageNoLocalInstance))
130
+ .replace("@@SETUP@@", hasLocal
131
+ ? " penpair setup --docker Penpot on this computer in Docker\n"
132
+ : "") +
133
+ adapterUsage(adapters) +
134
+ (configured.length
135
+ ? `\nConfigured executable extensions (not loaded for built-in help):\n${configured.map((name) => ` ${JSON.stringify(name)}`).join("\n")}\nUse the extension command to load its trusted project code.\n`
136
+ : "");
73
137
  /** The commands that adapters from penpair.config.json add, after the core's own. */
74
- const adapterUsage = (adapters) => adapters.length === 0 ? "" : `\nAdapters (listed under "adapters" in penpair.config.json):\n${adapters.flatMap((a) => a.commands.flatMap((c) => c.usage.map((line) => ` ${line}`))).join("\n")}\n`;
75
- const list = (value) => (value ?? "").split(",").map((v) => v.trim()).filter(Boolean);
138
+ const adapterUsage = (adapters) => adapters.length === 0
139
+ ? ""
140
+ : `\nAdapters (listed under "adapters" in penpair.config.json):\n${adapters.flatMap((a) => a.commands.flatMap((c) => c.usage.map((line) => ` ${line}`))).join("\n")}\n`;
141
+ const list = (value) => (value ?? "")
142
+ .split(",")
143
+ .map((v) => v.trim())
144
+ .filter(Boolean);
145
+ const commandProjects = new AsyncLocalStorage();
146
+ const targetSignature = (config) => JSON.stringify({
147
+ instance: config.instance,
148
+ file: config.file,
149
+ profile: "profile" in config ? config.profile : null,
150
+ ports: "ports" in config ? config.ports : null,
151
+ });
152
+ async function loadCommandConfig(root) {
153
+ const config = await loadConfig(root);
154
+ const pinned = commandProjects.getStore()?.pinnedConfig;
155
+ if (pinned && targetSignature(config) !== targetSignature(pinned)) {
156
+ throw new Error("Pinned project target changed; restart and review the server configuration");
157
+ }
158
+ return config;
159
+ }
160
+ const projectRoot = () => commandProjects.getStore()?.root ?? process.cwd();
161
+ const projectEndpoint = async (root) => {
162
+ const config = await loadOptionalConfig(root);
163
+ return resolveEndpoint({
164
+ envFiles: [
165
+ path.join(root, ".env.local"),
166
+ ],
167
+ credentialRoot: root,
168
+ credential: config?.connection?.credential,
169
+ globalConfig: !commandProjects.getStore()?.projectEndpointOnly,
170
+ });
171
+ };
76
172
  async function main(argv, cli) {
77
- const { values, positionals } = parseArgs({
173
+ const { values, positionals } = parseCommandArgs({
78
174
  args: argv,
79
175
  allowPositionals: true,
80
176
  options: {
81
- page: { type: "string" },
82
- depth: { type: "string" },
83
- prefix: { type: "string" },
84
- steps: { type: "string" },
85
- ignore: { type: "string" },
86
- theme: { type: "string" },
87
- scale: { type: "string" },
88
- name: { type: "string" },
89
- keep: { type: "string" },
90
- parts: { type: "string" },
91
- x: { type: "string" },
92
- apply: { type: "boolean" },
93
- instance: { type: "string" },
94
- external: { type: "string" },
95
- port: { type: "string" },
96
- gitignore: { type: "boolean" },
97
- yes: { type: "boolean" },
98
- "any-instance": { type: "boolean" },
99
- web: { type: "boolean" },
100
- docker: { type: "boolean" },
101
- replace: { type: "boolean" },
102
- update: { type: "boolean" },
103
- json: { type: "boolean" },
104
- deep: { type: "boolean" },
105
- force: { type: "boolean" },
106
- to: { type: "string" },
107
- only: { type: "string", multiple: true },
108
- write: { type: "boolean" },
109
- param: { type: "string", multiple: true },
110
- version: { type: "boolean" },
111
- help: { type: "boolean" },
177
+ include: {
178
+ type: "string",
179
+ multiple: true,
180
+ },
181
+ rules: {
182
+ type: "boolean",
183
+ },
184
+ "token-source": {
185
+ type: "string",
186
+ },
187
+ adapters: {
188
+ type: "boolean",
189
+ },
190
+ print: {
191
+ type: "boolean",
192
+ },
193
+ note: {
194
+ type: "string",
195
+ },
196
+ by: {
197
+ type: "string",
198
+ },
199
+ case: {
200
+ type: "string",
201
+ },
202
+ "force-reason": {
203
+ type: "string",
204
+ },
205
+ from: {
206
+ type: "string",
207
+ },
208
+ map: {
209
+ type: "string",
210
+ },
211
+ frame: {
212
+ type: "string",
213
+ },
214
+ inspect: {
215
+ type: "string",
216
+ },
217
+ readback: {
218
+ type: "string",
219
+ },
220
+ "timeout-ms": {
221
+ type: "string",
222
+ },
223
+ layout: {
224
+ type: "string",
225
+ },
226
+ page: {
227
+ type: "string",
228
+ },
229
+ out: {
230
+ type: "string",
231
+ },
232
+ fields: {
233
+ type: "string",
234
+ },
235
+ cursor: {
236
+ type: "string",
237
+ },
238
+ all: {
239
+ type: "boolean",
240
+ },
241
+ "max-nodes": {
242
+ type: "string",
243
+ },
244
+ "response-bytes": {
245
+ type: "string",
246
+ },
247
+ "time-budget-ms": {
248
+ type: "string",
249
+ },
250
+ "document-bytes": {
251
+ type: "string",
252
+ },
253
+ "max-calls": {
254
+ type: "string",
255
+ },
256
+ "max-total-bytes": {
257
+ type: "string",
258
+ },
259
+ "capture-timeout-ms": {
260
+ type: "string",
261
+ },
262
+ depth: {
263
+ type: "string",
264
+ },
265
+ limit: {
266
+ type: "string",
267
+ },
268
+ prefix: {
269
+ type: "string",
270
+ },
271
+ steps: {
272
+ type: "string",
273
+ },
274
+ ignore: {
275
+ type: "string",
276
+ },
277
+ theme: {
278
+ type: "string",
279
+ },
280
+ type: {
281
+ type: "string",
282
+ },
283
+ value: {
284
+ type: "string",
285
+ },
286
+ description: {
287
+ type: "string",
288
+ },
289
+ scale: {
290
+ type: "string",
291
+ },
292
+ name: {
293
+ type: "string",
294
+ },
295
+ keep: {
296
+ type: "string",
297
+ },
298
+ parts: {
299
+ type: "string",
300
+ },
301
+ x: {
302
+ type: "string",
303
+ },
304
+ apply: {
305
+ type: "boolean",
306
+ },
307
+ instance: {
308
+ type: "string",
309
+ },
310
+ external: {
311
+ type: "string",
312
+ },
313
+ port: {
314
+ type: "string",
315
+ },
316
+ gitignore: {
317
+ type: "boolean",
318
+ },
319
+ yes: {
320
+ type: "boolean",
321
+ },
322
+ "any-instance": {
323
+ type: "boolean",
324
+ },
325
+ "any-file": {
326
+ type: "boolean",
327
+ },
328
+ "pin-file": {
329
+ type: "boolean",
330
+ },
331
+ standalone: {
332
+ type: "boolean",
333
+ },
334
+ "builtins-only": {
335
+ type: "boolean",
336
+ },
337
+ agents: {
338
+ type: "string",
339
+ },
340
+ "dry-run": {
341
+ type: "boolean",
342
+ },
343
+ web: {
344
+ type: "boolean",
345
+ },
346
+ docker: {
347
+ type: "boolean",
348
+ },
349
+ replace: {
350
+ type: "boolean",
351
+ },
352
+ update: {
353
+ type: "boolean",
354
+ },
355
+ json: {
356
+ type: "boolean",
357
+ },
358
+ deep: {
359
+ type: "boolean",
360
+ },
361
+ integrity: {
362
+ type: "boolean",
363
+ },
364
+ kit: {
365
+ type: "boolean",
366
+ },
367
+ resume: {
368
+ type: "string",
369
+ },
370
+ checkpoint: {
371
+ type: "boolean",
372
+ },
373
+ "force-lock": {
374
+ type: "boolean",
375
+ },
376
+ trace: {
377
+ type: "boolean",
378
+ },
379
+ force: {
380
+ type: "boolean",
381
+ },
382
+ to: {
383
+ type: "string",
384
+ },
385
+ only: {
386
+ type: "string",
387
+ multiple: true,
388
+ },
389
+ write: {
390
+ type: "boolean",
391
+ },
392
+ param: {
393
+ type: "string",
394
+ multiple: true,
395
+ },
396
+ version: {
397
+ type: "boolean",
398
+ },
399
+ help: {
400
+ type: "boolean",
401
+ },
112
402
  },
113
403
  });
114
404
  const [command, ...rest] = positionals;
405
+ const inspectionFlags = [
406
+ "cursor",
407
+ "all",
408
+ "out",
409
+ "max-nodes",
410
+ "response-bytes",
411
+ "time-budget-ms",
412
+ "document-bytes",
413
+ "max-calls",
414
+ "max-total-bytes",
415
+ "capture-timeout-ms",
416
+ ];
417
+ const flags = {
418
+ formats: [
419
+ "out",
420
+ ],
421
+ snapshot: [
422
+ "write",
423
+ "out",
424
+ "from",
425
+ ],
426
+ approve: [
427
+ "page",
428
+ "frame",
429
+ "note",
430
+ "by",
431
+ "case",
432
+ "write",
433
+ ],
434
+ spec: [
435
+ "from",
436
+ "map",
437
+ "out",
438
+ "page",
439
+ "frame",
440
+ ],
441
+ pages: [],
442
+ tree: [
443
+ "page",
444
+ "depth",
445
+ "prefix",
446
+ "limit",
447
+ ],
448
+ audit: [
449
+ "page",
450
+ "prefix",
451
+ "steps",
452
+ "ignore",
453
+ "rules",
454
+ "from",
455
+ "token-source",
456
+ ],
457
+ inspect: [
458
+ "page",
459
+ "depth",
460
+ "fields",
461
+ ...inspectionFlags,
462
+ ],
463
+ tokens: rest[0] === "dump"
464
+ ? inspectionFlags
465
+ : rest[0] === "diff"
466
+ ? [
467
+ "theme",
468
+ ]
469
+ : [
470
+ "type",
471
+ "value",
472
+ "description",
473
+ "apply",
474
+ "resume",
475
+ "checkpoint",
476
+ "force-lock",
477
+ "trace",
478
+ ],
479
+ export: [
480
+ "page",
481
+ "scale",
482
+ "name",
483
+ "layout",
484
+ ],
485
+ shots: [
486
+ "page",
487
+ "prefix",
488
+ "out",
489
+ "scale",
490
+ "layout",
491
+ ],
492
+ archive: [
493
+ "page",
494
+ "name",
495
+ "keep",
496
+ "parts",
497
+ "x",
498
+ "apply",
499
+ "resume",
500
+ "checkpoint",
501
+ "force-lock",
502
+ "trace",
503
+ ],
504
+ kit: [
505
+ "include",
506
+ ],
507
+ recipes: [
508
+ "include",
509
+ ],
510
+ "run-file": [
511
+ "layout",
512
+ "include",
513
+ "inspect",
514
+ "readback",
515
+ "page",
516
+ "param",
517
+ "apply",
518
+ "resume",
519
+ "checkpoint",
520
+ "force-lock",
521
+ "trace",
522
+ ],
523
+ run: [
524
+ "builtins-only",
525
+ "param",
526
+ "apply",
527
+ "resume",
528
+ "checkpoint",
529
+ "force-lock",
530
+ "trace",
531
+ ],
532
+ baselines: [
533
+ "apply",
534
+ "update",
535
+ "force-reason",
536
+ ],
537
+ probe: [
538
+ "deep",
539
+ "write",
540
+ "integrity",
541
+ "kit",
542
+ ],
543
+ skills: [
544
+ "to",
545
+ "only",
546
+ "force",
547
+ "agents",
548
+ "dry-run",
549
+ ],
550
+ snippets: [
551
+ "builtins-only",
552
+ ],
553
+ init: [
554
+ "instance",
555
+ "port",
556
+ "gitignore",
557
+ "external",
558
+ ],
559
+ setup: [
560
+ "agents",
561
+ "web",
562
+ "docker",
563
+ "external",
564
+ "instance",
565
+ "port",
566
+ ],
567
+ connect: [
568
+ "replace",
569
+ "pin-file",
570
+ "any-file",
571
+ ],
572
+ doctor: [
573
+ "timeout-ms",
574
+ ],
575
+ up: [],
576
+ down: [],
577
+ open: [
578
+ "print",
579
+ ],
580
+ status: [
581
+ "adapters",
582
+ ],
583
+ backup: [],
584
+ restore: [
585
+ "yes",
586
+ ],
587
+ };
588
+ const network = [
589
+ "snapshot",
590
+ "approve",
591
+ "spec",
592
+ "inspect",
593
+ "pages",
594
+ "tree",
595
+ "audit",
596
+ "tokens",
597
+ "export",
598
+ "shots",
599
+ "archive",
600
+ "run",
601
+ "run-file",
602
+ "baselines",
603
+ "probe",
604
+ "backup",
605
+ "doctor",
606
+ ];
607
+ const allowed = new Set([
608
+ "help",
609
+ "version",
610
+ "json",
611
+ ...(flags[command ?? ""] ?? []),
612
+ ...(network.includes(command ?? "")
613
+ ? [
614
+ "any-instance",
615
+ "any-file",
616
+ "standalone",
617
+ ]
618
+ : []),
619
+ ]);
620
+ for (const key of Object.keys(values)) {
621
+ if (!allowed.has(key)) {
622
+ throw new Error(`--${key} is not supported by ${command || "this command"}`);
623
+ }
624
+ }
625
+ for (const [key, min, max, integer] of [
626
+ [
627
+ "depth",
628
+ 0,
629
+ command === "inspect" ? 10000 : 100,
630
+ true,
631
+ ],
632
+ [
633
+ "limit",
634
+ 1,
635
+ 1000,
636
+ true,
637
+ ],
638
+ [
639
+ "scale",
640
+ 0.01,
641
+ 8,
642
+ false,
643
+ ],
644
+ [
645
+ "x",
646
+ -10000000,
647
+ 10000000,
648
+ false,
649
+ ],
650
+ [
651
+ "port",
652
+ 1024,
653
+ 65535,
654
+ true,
655
+ ],
656
+ ]) {
657
+ const raw = values[key];
658
+ if (raw === undefined) {
659
+ continue;
660
+ }
661
+ const number = Number(raw);
662
+ if (!raw.trim() ||
663
+ !Number.isFinite(number) ||
664
+ number < min ||
665
+ number > max ||
666
+ (integer && !Number.isInteger(number))) {
667
+ throw new Error(`--${key} must be ${integer ? "an integer" : "a number"} from ${min} to ${max}`);
668
+ }
669
+ }
670
+ if (command === "export" &&
671
+ (rest.length !== (values.name ? 1 : 2) || (values.name && !values.page))) {
672
+ throw new CommandArgumentError("Export requires an ID and output path, or --name with an explicit --page and one output path");
673
+ }
674
+ if (command === "shots" && (!values.page || rest.length)) {
675
+ throw new CommandArgumentError("Shots requires --page and supports optional --prefix/--out");
676
+ }
677
+ if (values.layout !== undefined &&
678
+ ![
679
+ "auto",
680
+ "native",
681
+ "geometry",
682
+ ].includes(values.layout)) {
683
+ throw new CommandArgumentError("--layout must be auto, native or geometry");
684
+ }
685
+ if (command === "doctor" &&
686
+ values["timeout-ms"] !== undefined &&
687
+ (!/^\d+$/.test(values["timeout-ms"]) ||
688
+ Number(values["timeout-ms"]) < 1000 ||
689
+ Number(values["timeout-ms"]) > 30000)) {
690
+ throw new CommandArgumentError("doctor --timeout-ms must be an integer from 1000 to 30000");
691
+ }
692
+ let tokenEdit;
693
+ if (command === "tokens" &&
694
+ ![
695
+ "dump",
696
+ "diff",
697
+ ].includes(rest[0] ?? "")) {
698
+ const [kind, set, name] = rest;
699
+ if (kind === "add-set" && rest.length === 2) {
700
+ tokenEdit = {
701
+ kind,
702
+ set: set,
703
+ };
704
+ }
705
+ else if (kind === "remove" && rest.length === 3) {
706
+ tokenEdit = {
707
+ kind,
708
+ set: set,
709
+ name: name,
710
+ };
711
+ }
712
+ else if (kind === "promote" && rest.length === 3) {
713
+ tokenEdit = {
714
+ kind,
715
+ set: set,
716
+ to: name,
717
+ };
718
+ }
719
+ else if (kind === "set" && rest.length === 3 && values.type && values.value) {
720
+ let value;
721
+ try {
722
+ value = JSON.parse(values.value);
723
+ }
724
+ catch {
725
+ throw new CommandArgumentError("--value must be JSON: quote strings or supply a composite object");
726
+ }
727
+ tokenEdit = {
728
+ kind,
729
+ set: set,
730
+ token: {
731
+ name: name,
732
+ type: values.type,
733
+ value: value,
734
+ description: values.description ?? "",
735
+ },
736
+ };
737
+ }
738
+ else {
739
+ throw new CommandArgumentError("Usage: tokens add-set SET; tokens set SET NAME --type TYPE --value JSON; tokens remove SET NAME; tokens promote SOURCE DESTINATION");
740
+ }
741
+ if (kind !== "set" &&
742
+ [
743
+ values.type,
744
+ values.value,
745
+ values.description,
746
+ ].some((v) => v !== undefined)) {
747
+ throw new CommandArgumentError("Token value options apply only to tokens set");
748
+ }
749
+ tokenEdit = validateTokenEdit(tokenEdit);
750
+ }
751
+ let inspectionRequest;
752
+ if (command === "inspect" || (command === "tokens" && rest[0] === "dump")) {
753
+ if (rest.length !== 1 || (command === "inspect" && !values.page)) {
754
+ throw new CommandArgumentError("Inspect requires one shape ID and --page; tokens dump accepts no extra positionals");
755
+ }
756
+ if ((values.cursor && values.all) ||
757
+ (values.cursor && values.out) ||
758
+ (command === "inspect" && values.out && !values.all)) {
759
+ throw new CommandArgumentError("Use --all for a complete output file; --cursor selects one continuation chunk");
760
+ }
761
+ if ((values.cursor || (command === "inspect" && !values.all)) &&
762
+ [
763
+ values["max-calls"],
764
+ values["max-total-bytes"],
765
+ values["capture-timeout-ms"],
766
+ ].some((value) => value !== undefined)) {
767
+ throw new CommandArgumentError("Collection budgets require a full capture rather than one cursor chunk");
768
+ }
769
+ const numeric = (key) => values[key] === undefined ? undefined : Number(values[key]);
770
+ const query = {
771
+ kind: command === "inspect" ? "shape" : "tokens",
772
+ shapeId: command === "inspect" ? rest[0] : undefined,
773
+ page: values.page,
774
+ fields: values.fields?.split(",").map((field) => field.trim()),
775
+ depth: numeric("depth"),
776
+ maxNodes: numeric("max-nodes"),
777
+ responseBytes: numeric("response-bytes"),
778
+ timeBudgetMs: numeric("time-budget-ms"),
779
+ documentBytes: numeric("document-bytes"),
780
+ cursor: values.cursor,
781
+ };
782
+ validateInspectionQuery(query);
783
+ inspectionRequest = {
784
+ query,
785
+ budget: validateCaptureBudget({
786
+ maxCalls: numeric("max-calls"),
787
+ maxTotalBytes: numeric("max-total-bytes"),
788
+ timeoutMs: numeric("capture-timeout-ms"),
789
+ }),
790
+ };
791
+ }
792
+ if (values.kit && (!values.deep || !values.write)) {
793
+ throw new CommandArgumentError("--kit requires the explicit scratch probe --deep --write");
794
+ }
795
+ if (values.steps &&
796
+ values.steps
797
+ .split(",")
798
+ .some((step) => !step.trim() || !Number.isFinite(Number(step)) || Number(step) < 0)) {
799
+ throw new Error("--steps requires finite nonnegative numbers");
800
+ }
801
+ if (command === "skills" &&
802
+ rest[0] !== "install" &&
803
+ [
804
+ values.agents,
805
+ values["dry-run"],
806
+ values.to,
807
+ values.only,
808
+ values.force,
809
+ ].some((value) => value !== undefined)) {
810
+ throw new Error("Installation flags require skills install");
811
+ }
812
+ if ((values.resume || values.checkpoint || values["force-lock"] || values.trace) &&
813
+ (!values.apply ||
814
+ !([
815
+ "archive",
816
+ "run",
817
+ "run-file",
818
+ ].includes(command ?? "") ||
819
+ (command === "tokens" && tokenEdit)))) {
820
+ throw new Error("--resume and --checkpoint require an operation command (archive/run/run-file/tokens) with --apply");
821
+ }
115
822
  if (values.version) {
116
- console.log(version);
823
+ output.log(version);
824
+ return 0;
825
+ }
826
+ if (!command || command === "help" || values.help) {
827
+ output.log(usage(Boolean(cli.local), cli.adapters, cli.configuredAdapters));
828
+ return 0;
829
+ }
830
+ if (command === "audit" && values.from) {
831
+ if (rest.length ||
832
+ values.page ||
833
+ values.prefix ||
834
+ values.steps ||
835
+ values.ignore ||
836
+ values["any-file"] ||
837
+ values["any-instance"] ||
838
+ values.standalone) {
839
+ throw new CommandArgumentError("audit --from INSPECTION [--token-source TOKENS] uses configured advisory rules offline");
840
+ }
841
+ const config = await loadCommandConfig(projectRoot());
842
+ if (!config.audit) {
843
+ throw new Error("Configure explicit audit rules first");
844
+ }
845
+ const report = auditDesignRules((await readDesignJson(projectRoot(), values.from)), config.audit, values["token-source"]
846
+ ? (await readDesignJson(projectRoot(), values["token-source"]))
847
+ : undefined);
848
+ output.result(report);
849
+ output.log(JSON.stringify(report, null, 2));
850
+ return report.complete ? 0 : 3;
851
+ }
852
+ if (command === "formats") {
853
+ const [action, input] = rest;
854
+ if (action === "schema" && rest.length === 2 && !values.out && artifactSchemas[input]) {
855
+ output.result(artifactSchemas[input]);
856
+ output.log(JSON.stringify(artifactSchemas[input], null, 2));
857
+ return 0;
858
+ }
859
+ if (![
860
+ "import-tokens",
861
+ "export-tokens",
862
+ "migrate-config",
863
+ ].includes(action ?? "") ||
864
+ rest.length !== 2) {
865
+ throw new CommandArgumentError("formats schema NAME | import-tokens FILE | export-tokens FILE | migrate-config FILE [--out NEW_FILE]");
866
+ }
867
+ const raw = await readDesignJson(projectRoot(), input, 8 * 1024 * 1024);
868
+ const result = action === "import-tokens"
869
+ ? importDtcg(raw)
870
+ : action === "export-tokens"
871
+ ? exportDtcg(raw)
872
+ : migrateProjectConfig(raw);
873
+ if (values.out) {
874
+ const target = await resolveProjectOutput(projectRoot(), values.out);
875
+ await mkdir(path.dirname(target), {
876
+ recursive: true,
877
+ });
878
+ await atomicWriteFile(target, JSON.stringify(result, null, 2) + "\n");
879
+ }
880
+ output.result(result);
881
+ output.log(JSON.stringify(result, null, 2));
882
+ return 0;
883
+ }
884
+ if (command === "snapshot" && rest[0] === "diff") {
885
+ if (rest.length !== 2 ||
886
+ !values.from ||
887
+ values.write ||
888
+ values["any-file"] ||
889
+ values["any-instance"] ||
890
+ values.standalone) {
891
+ throw new CommandArgumentError("snapshot diff BEFORE_FOLDER --from AFTER_FOLDER [--out FILE]");
892
+ }
893
+ const diff = diffStructuralSnapshots(await readStructuralSnapshot(projectRoot(), rest[1]), await readStructuralSnapshot(projectRoot(), values.from));
894
+ if (values.out) {
895
+ await writeDesignSpec(projectRoot(), values.out, renderStructuralDiff(diff));
896
+ }
897
+ output.result(diff);
898
+ if (!values.json) {
899
+ output.log(renderStructuralDiff(diff));
900
+ }
901
+ return !diff.complete ? 3 : diff.totalChanges ? 2 : 0;
902
+ }
903
+ if (command === "snapshot" && (rest.length || values.from)) {
904
+ throw new CommandArgumentError("snapshot [--write] [--out FOLDER], or snapshot diff BEFORE --from AFTER");
905
+ }
906
+ if (command === "spec" && values.from) {
907
+ if (rest.length > 1 ||
908
+ (rest.length === 1 && rest[0] !== "check") ||
909
+ values.page ||
910
+ values.frame ||
911
+ values.standalone ||
912
+ values["any-file"] ||
913
+ values["any-instance"]) {
914
+ throw new CommandArgumentError("Offline spec requires --from INSPECTION [--map MAPPING] [--out FILE]; spec check also requires --out");
915
+ }
916
+ const inspection = (await readDesignJson(projectRoot(), values.from));
917
+ const mapping = values.map
918
+ ? validateComponentMap(await readDesignJson(projectRoot(), values.map, 1024 * 1024))
919
+ : emptyComponentMap;
920
+ if (rest[0] === "check") {
921
+ if (!values.out) {
922
+ throw new CommandArgumentError("spec check requires --out and --from");
923
+ }
924
+ const file = await import("./project-input.js").then((m) => m.resolveProjectInput(projectRoot(), values.out));
925
+ const report = checkDesignSpec(await readFile(file, "utf8"), inspection, mapping);
926
+ output.result(report);
927
+ output.log(JSON.stringify(report));
928
+ return report.current ? 0 : 2;
929
+ }
930
+ const spec = buildDesignSpec(inspection, mapping);
931
+ const markdown = renderDesignSpec(spec);
932
+ if (values.out) {
933
+ await writeDesignSpec(projectRoot(), values.out, markdown);
934
+ }
935
+ output.result(spec);
936
+ if (!values.json) {
937
+ output.log(markdown);
938
+ }
117
939
  return 0;
118
940
  }
119
- if (!command || values.help) {
120
- console.log(usage(Boolean(cli.local), cli.adapters));
941
+ if (command === "spec" && (rest.length || !values.page || !values.frame)) {
942
+ throw new CommandArgumentError("Live spec requires --page and --frame ID (or --from for offline generation)");
943
+ }
944
+ if (command === "baselines" && rest[0] === "coverage") {
945
+ if (rest.length !== 1 ||
946
+ values.apply ||
947
+ values.update ||
948
+ values["any-file"] ||
949
+ values["any-instance"] ||
950
+ values.standalone) {
951
+ throw new CommandArgumentError("baselines coverage is an offline report with optional --json");
952
+ }
953
+ const config = await loadCommandConfig(projectRoot());
954
+ if (!config.baselines) {
955
+ throw new Error("Configure baselines before reporting coverage");
956
+ }
957
+ const report = await readBaselineCoverage(projectRoot(), config.baselines, config.file?.id);
958
+ output.result(report);
959
+ if (!values.json) {
960
+ output.log(JSON.stringify(report, null, 2));
961
+ }
962
+ return report.designCasesComplete ? 0 : 3;
963
+ }
964
+ if (command === "kit" || command === "recipes") {
965
+ if (command === "kit" ? rest.length !== 1 || rest[0] !== "check" : rest.length) {
966
+ throw new CommandArgumentError("Use kit check, or recipes, with optional ordered --include aliases");
967
+ }
968
+ const kits = await loadKits(projectRoot(), values.include ?? [
969
+ "@kit",
970
+ ]);
971
+ output.result(command === "kit"
972
+ ? checkKits(kits)
973
+ : {
974
+ recipes: kits.flatMap((value) => value.recipes.map((recipe) => ({
975
+ ...recipe,
976
+ include: value.include,
977
+ }))),
978
+ includes: kits.map((value) => value.include),
979
+ });
980
+ if (!values.json) {
981
+ output.log(command === "kit"
982
+ ? JSON.stringify(checkKits(kits), null, 2)
983
+ : kits
984
+ .flatMap((value) => value.recipes.map((recipe) => `${value.include} ${recipe.name}: ${recipe.description}`))
985
+ .join("\n"));
986
+ }
121
987
  return 0;
122
988
  }
123
989
  if (command === "tokens" && rest[0] === "diff") {
124
990
  const [, beforeFile, afterFile] = rest;
125
- if (!beforeFile || !afterFile || !values.theme)
991
+ if (!beforeFile || !afterFile || !values.theme) {
126
992
  throw new Error("usage: penpair tokens diff <before.json> <after.json> --theme <name>");
127
- const load = async (file) => JSON.parse(await readFile(file, "utf8"));
993
+ }
994
+ const load = async (file) => parseTokenDump(JSON.parse(await readFile(file, "utf8")));
128
995
  const diff = diffEffective(effectiveValues(await load(beforeFile), values.theme), effectiveValues(await load(afterFile), values.theme));
129
- console.log(JSON.stringify(diff, null, 2));
130
- return diff.added.length + diff.removed.length + diff.changed.length === 0 ? 0 : 2;
996
+ output.log(JSON.stringify(diff, null, 2));
997
+ return !diff.complete
998
+ ? 3
999
+ : diff.added.length + diff.removed.length + diff.changed.length === 0
1000
+ ? 0
1001
+ : 2;
1002
+ }
1003
+ const root = projectRoot();
1004
+ const log = (message) => output.error(`… ${message}`);
1005
+ if (values.standalone) {
1006
+ const inspection = [
1007
+ "inspect",
1008
+ "pages",
1009
+ "tree",
1010
+ "audit",
1011
+ "export",
1012
+ "shots",
1013
+ ].includes(command) ||
1014
+ (command === "probe" && !values.deep) ||
1015
+ (command === "tokens" && rest[0] === "dump");
1016
+ if ((await loadOptionalConfig(root)) || !inspection || values.apply || values.write) {
1017
+ throw new ConfigError("--standalone is only for built-in inspection commands in a folder without penpair.config.json. It cannot bypass a project or run writes/snippets.");
1018
+ }
131
1019
  }
132
- const root = process.cwd();
133
- const log = (message) => console.error(`… ${message}`);
134
1020
  /** What `doctor` needs to look for the editor and plugin, or nothing when no endpoint is set yet. */
135
1021
  async function mcpProbeFor() {
136
1022
  try {
137
- const endpoint = await resolveEndpoint();
1023
+ const endpoint = await projectEndpoint(root);
1024
+ commandRedactor(endpointRedactor(endpoint));
138
1025
  return {
139
1026
  origin: endpoint.description.origin,
140
- connect: async () => {
141
- const client = await connect(endpoint);
1027
+ connect: async (request) => {
1028
+ if (!request) {
1029
+ throw new Error("Doctor requires a fresh challenge");
1030
+ }
1031
+ const started = Date.now();
1032
+ const client = await connect(endpoint, {
1033
+ timeoutMs: request.timeoutMs,
1034
+ signal: request.signal,
1035
+ });
142
1036
  try {
143
- const { result } = await executeCode(client, "return { file: penpot.currentFile.name };", { retries: 0 });
144
- return { file: String(result.file ?? "?") };
1037
+ request.signal.throwIfAborted();
1038
+ request.onTransport();
1039
+ const remaining = request.timeoutMs - (Date.now() - started);
1040
+ if (remaining < 1) {
1041
+ throw new Error("Doctor connection exhausted execution budget");
1042
+ }
1043
+ const { result } = await executeCode(client, `return { challenge: ${JSON.stringify(request.challenge)}, file: penpot.currentFile.name, fileId: penpot.currentFile.id, pageId: penpot.currentPage.id, apiVersion: typeof penpot.version === "string" ? penpot.version : undefined };`, {
1044
+ retries: 0,
1045
+ effect: "read",
1046
+ timeoutMs: remaining,
1047
+ });
1048
+ if (!result || typeof result !== "object") {
1049
+ throw new Error("No fresh editor execution evidence");
1050
+ }
1051
+ return result;
145
1052
  }
146
1053
  finally {
147
1054
  await client.close();
@@ -153,30 +1060,51 @@ async function main(argv, cli) {
153
1060
  return undefined;
154
1061
  }
155
1062
  }
156
- async function registryFor() {
1063
+ async function registryFor(requested) {
157
1064
  const registry = builtInRegistry();
158
- let snippets;
159
- try {
160
- snippets = (await loadConfig(root)).snippets;
161
- }
162
- catch {
163
- // not a penpair project: only the built-in snippets
1065
+ if (values["builtins-only"] ||
1066
+ (requested && registry.list().some((entry) => entry.name === requested))) {
1067
+ return {
1068
+ registry,
1069
+ problems: [],
1070
+ files: [],
1071
+ };
164
1072
  }
1073
+ const snippets = (await loadOptionalConfig(root))?.snippets;
165
1074
  const report = await loadProjectSnippets(root, snippets, registry);
166
- return { registry, problems: report.problems, files: report.files };
1075
+ return {
1076
+ registry,
1077
+ problems: report.problems,
1078
+ files: report.files,
1079
+ };
167
1080
  }
168
1081
  const connectIo = () => ({
169
- say: (line) => console.log(line),
1082
+ say: (line) => output.log(line),
170
1083
  askHidden,
171
1084
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
1085
+ confirmFile: async (file) => values["pin-file"] === true ||
1086
+ /^(y|yes)$/i.test(await askLine(`Pin this project's file to "${file.name}" (${file.id})? [y/N] `, "n")),
172
1087
  openFile: async (url) => {
173
- const endpoint = await resolveEndpoint({ environment: { PENPAIR_MCP_URL: url }, envFiles: [] });
1088
+ const endpoint = await resolveEndpoint({
1089
+ environment: {
1090
+ PENPAIR_MCP_URL: url,
1091
+ },
1092
+ envFiles: [],
1093
+ });
1094
+ commandRedactor(endpointRedactor(endpoint));
174
1095
  try {
175
1096
  const client = await connect(endpoint);
176
1097
  try {
177
- const { result } = await executeCode(client, "return { file: penpot.currentFile.name, pages: penpot.currentFile.pages.map((p) => p.name) };", { retries: 0 });
1098
+ const { result } = await executeCode(client, "return { file: penpot.currentFile.name, fileId: penpot.currentFile.id, pages: penpot.currentFile.pages.map((p) => p.name) };", {
1099
+ retries: 0,
1100
+ effect: "read",
1101
+ });
178
1102
  const read = result;
179
- return { file: String(read.file ?? "?"), pages: read.pages ?? [] };
1103
+ return {
1104
+ file: String(read.file ?? "?"),
1105
+ fileId: read.fileId,
1106
+ pages: read.pages ?? [],
1107
+ };
180
1108
  }
181
1109
  finally {
182
1110
  await client.close();
@@ -189,17 +1117,31 @@ async function main(argv, cli) {
189
1117
  });
190
1118
  if (command === "setup" || command === "connect") {
191
1119
  if (command === "connect") {
192
- const project = await loadConfig(root);
193
- await connectProject(root, project, connectIo(), { replace: values.replace === true });
194
- console.log("Next: `penpair doctor`, then `penpair skills install` if you have not yet.");
1120
+ const project = await loadCommandConfig(root);
1121
+ await connectProject(root, project, connectIo(), {
1122
+ replace: values.replace === true,
1123
+ anyFile: values["any-file"] === true,
1124
+ });
1125
+ output.log("Next: `penpair doctor`, then `penpair skills install` if you have not yet.");
195
1126
  return 0;
196
1127
  }
197
1128
  let where;
198
- if (values.docker)
199
- where = { type: "docker" };
200
- else if (values.web || values.external)
201
- where = { type: "web", origin: values.external ?? hostedOrigin };
202
- await setup(root, { ...connectIo(), ask: (question, fallback) => askLine(question, fallback), openBrowser: openInBrowser }, {
1129
+ if (values.docker) {
1130
+ where = {
1131
+ type: "docker",
1132
+ };
1133
+ }
1134
+ else if (values.web || values.external) {
1135
+ where = {
1136
+ type: "web",
1137
+ origin: values.external ?? hostedOrigin,
1138
+ };
1139
+ }
1140
+ await setup(root, {
1141
+ ...connectIo(),
1142
+ ask: (question, fallback) => askLine(question, fallback),
1143
+ openBrowser: openInBrowser,
1144
+ }, {
203
1145
  where,
204
1146
  instance: values.instance,
205
1147
  port: values.port ? Number(values.port) : undefined,
@@ -207,161 +1149,476 @@ async function main(argv, cli) {
207
1149
  log,
208
1150
  local: cli.local,
209
1151
  installSkills: async () => {
210
- const report = await installSkills({ root });
211
- console.log(`Installed ${report.installed.length} rule file(s) in ${report.to}/ (${report.unchanged.length} already there). Add these rows to your AGENTS.md skills table:\n${report.agentsRows.join("\n")}`);
1152
+ const report = await installSkills({
1153
+ root,
1154
+ agents: values.agents,
1155
+ });
1156
+ output.log(`Installed ${report.installed.length} rule file(s) in ${report.to}/ (${report.unchanged.length} already there).`);
1157
+ if (report.instructions?.path) {
1158
+ output.log(`Instructions: ${report.instructions.path} (${report.instructions.status})`);
1159
+ }
1160
+ else {
1161
+ output.log(`Choose an instruction destination with skills install --agents <path>. Rows:\n${report.agentsRows.join("\n")}`);
1162
+ }
212
1163
  },
213
1164
  });
214
1165
  return 0;
215
1166
  }
216
1167
  if (command === "skills") {
217
1168
  if (rest[0] === "install") {
218
- const report = await installSkills({ root, to: values.to, only: values.only, force: values.force });
219
- for (const file of report.installed)
220
- console.log(`wrote ${file}`);
221
- for (const file of report.kept)
222
- console.log(`kept ${file} (differs from the shipped one; --force replaces it, except the project-owned files)`);
223
- for (const link of report.linked)
224
- console.log(`linked ${link}`);
225
- console.log(`${report.unchanged.length} file(s) already up to date`);
226
- console.log("\nAdd to your AGENTS.md skills table:\n" + report.agentsRows.join("\n"));
227
- for (const old of report.legacy)
228
- console.log(`\nnote ${old.folder} is the old name of ${old.renamedTo}. ${old.carriedFacts ? "Your project-facts.md was copied to the new folder. " : ""}Delete the old folder and its links (.agents/skills/${path.posix.basename(old.folder)}, .claude/skills/${path.posix.basename(old.folder)}) and update its row in your AGENTS.md.`);
229
- console.log("\nThen fill in references/project-facts.md in penpair-design: it is yours and is never replaced.");
230
- return 0;
1169
+ const report = await installSkills({
1170
+ root,
1171
+ to: values.to,
1172
+ only: values.only,
1173
+ force: values.force,
1174
+ agents: values.agents,
1175
+ dryRun: values["dry-run"],
1176
+ });
1177
+ output.result(report);
1178
+ if (report.instructions?.path) {
1179
+ output.log(`instructions: ${report.instructions.path} (${report.instructions.status})`);
1180
+ }
1181
+ if (report.dryRun) {
1182
+ for (const file of report.wouldInstall ?? []) {
1183
+ output.log(`would write ${file}`);
1184
+ }
1185
+ for (const file of report.wouldLink ?? []) {
1186
+ output.log(`would link ${file}`);
1187
+ }
1188
+ if (report.instructions?.diff) {
1189
+ output.log(report.instructions.diff);
1190
+ }
1191
+ }
1192
+ if (report.instructions?.status === "choice-required") {
1193
+ output.error("Both AGENTS.md and CLAUDE.md exist; choose --agents <path> explicitly. Instruction files were not changed.");
1194
+ }
1195
+ for (const file of report.installed) {
1196
+ output.log(`wrote ${file}`);
1197
+ }
1198
+ for (const file of report.kept) {
1199
+ output.log(`kept ${file} (differs from the shipped one; --force replaces it, except the project-owned files)`);
1200
+ }
1201
+ for (const link of report.linked) {
1202
+ output.log(`linked ${link}`);
1203
+ }
1204
+ output.log(`${report.unchanged.length} file(s) already up to date`);
1205
+ if (!report.instructions?.path) {
1206
+ output.log("\nChoose --agents <path> to integrate these rows:\n" + report.agentsRows.join("\n"));
1207
+ }
1208
+ for (const old of report.legacy) {
1209
+ output.log(`\nnote ${old.folder} is the old name of ${old.renamedTo}. ${old.carriedFacts ? "Your project-facts.md was copied to the new folder. " : ""}Delete the old folder and its links (.agents/skills/${path.posix.basename(old.folder)}, .claude/skills/${path.posix.basename(old.folder)}) and update its row in your AGENTS.md.`);
1210
+ }
1211
+ output.log("\nThen fill in references/project-facts.md in penpair-design: it is yours and is never replaced.");
1212
+ return report.instructions?.status === "choice-required" ? 3 : 0;
1213
+ }
1214
+ const bundled = await listBundledSkills();
1215
+ output.result({
1216
+ skills: bundled,
1217
+ });
1218
+ for (const name of bundled) {
1219
+ output.log(name);
231
1220
  }
232
- for (const name of await listBundledSkills())
233
- console.log(name);
234
1221
  return 0;
235
1222
  }
236
1223
  if (command === "snippets") {
237
1224
  const { registry, problems, files } = await registryFor();
238
1225
  if (rest[0] === "check") {
239
- const found = [...problems, ...checkProjectSnippets(registry)];
1226
+ const found = [
1227
+ ...problems,
1228
+ ...checkProjectSnippets(registry),
1229
+ ];
1230
+ output.result({
1231
+ files,
1232
+ problems: found,
1233
+ valid: found.length === 0,
1234
+ });
240
1235
  const project = registry.list().filter((entry) => entry.source === "project");
241
- console.log(`${project.length} project snippet(s) from ${files.length} file(s)`);
242
- for (const problem of found)
243
- console.log(`FAIL ${problem}`);
244
- if (found.length === 0)
245
- console.log("ok every snippet is valid plugin code and small enough");
1236
+ output.log(`${project.length} project snippet(s) from ${files.length} file(s)`);
1237
+ for (const problem of found) {
1238
+ output.log(`FAIL ${problem}`);
1239
+ }
1240
+ if (found.length === 0) {
1241
+ output.log("ok every snippet is valid plugin code and small enough");
1242
+ }
246
1243
  return found.length === 0 ? 0 : 1;
247
1244
  }
1245
+ output.result({
1246
+ snippets: registry.list().map(({ name, description, kind, params, source }) => ({
1247
+ name,
1248
+ description,
1249
+ kind,
1250
+ params,
1251
+ source,
1252
+ })),
1253
+ problems,
1254
+ complete: problems.length === 0,
1255
+ });
248
1256
  for (const entry of registry.list()) {
249
- console.log(`${entry.name.padEnd(14)} ${entry.kind === "write" ? "changes " : "reads "} ${entry.source === "project" ? "[project] " : ""}${entry.description}`);
250
- for (const p of entry.params)
251
- console.log(`${"".padEnd(14)} --param ${p.name}=<${p.type}>${p.required ? " (required)" : p.default !== undefined ? ` (default ${JSON.stringify(p.default)})` : ""}${p.description ? ` ${p.description}` : ""}`);
1257
+ output.log(`${entry.name.padEnd(14)} ${entry.kind === "write" ? "changes " : "reads "} ${entry.source === "project" ? "[project] " : ""}${entry.description}`);
1258
+ for (const p of entry.params) {
1259
+ output.log(`${"".padEnd(14)} --param ${p.name}=<${p.type}>${p.required ? " (required)" : p.default !== undefined ? ` (default ${JSON.stringify(p.default)})` : ""}${p.description ? ` ${p.description}` : ""}`);
1260
+ }
1261
+ }
1262
+ for (const problem of problems) {
1263
+ output.error(`warning: ${problem}`);
252
1264
  }
253
- for (const problem of problems)
254
- console.error(`warning: ${problem}`);
255
1265
  return 0;
256
1266
  }
257
1267
  if (command === "init") {
258
- const result = await init(root, { instance: values.instance, port: values.port ? Number(values.port) : undefined, gitignore: values.gitignore, external: values.external }, { local: cli.local });
1268
+ const result = await init(root, {
1269
+ instance: values.instance,
1270
+ port: values.port ? Number(values.port) : undefined,
1271
+ gitignore: values.gitignore,
1272
+ external: values.external,
1273
+ }, {
1274
+ local: cli.local,
1275
+ });
1276
+ output.result({
1277
+ createdConfig: result.createdConfig,
1278
+ createdSecrets: result.createdSecrets,
1279
+ instance: result.config.instance,
1280
+ files: result.files,
1281
+ gitignoreAdded: result.gitignoreAdded,
1282
+ });
259
1283
  if (isExternal(result.config)) {
260
- console.log(`${result.createdConfig ? "created" : "kept"} penpair.config.json (external Penpot at ${result.config.profile.origin})`);
261
- console.log("next: in Penpot switch on Account, Integrations, MCP Server and copy the address; open a design file and choose File, MCP Server, Connect; then run penpair connect (takes the address without showing it)");
1284
+ output.log(`${result.createdConfig ? "created" : "kept"} penpair.config.json (external Penpot at ${result.config.profile.origin})`);
1285
+ output.log("next: in Penpot switch on Account, Integrations, MCP Server and copy the address; open a design file and choose File, MCP Server, Connect; then run penpair connect (takes the address without showing it)");
262
1286
  return 0;
263
1287
  }
264
- console.log(`${result.createdConfig ? "created" : "kept"} penpair.config.json (instance ${result.config.instance}, Penpot ${result.config.penpotVersion}, port ${result.config.ports.frontend})`);
265
- console.log(`wrote ${result.config.directory}/docker-compose.yaml; secrets ${result.createdSecrets ? "generated" : "kept"} in ${result.config.directory}/.env`);
266
- if (!result.gitignoreAdded)
267
- console.log(`keep ${result.config.directory}/ out of git (add it to .gitignore, or run init with --gitignore)`);
268
- console.log("next: penpair up, then penpair connect");
1288
+ output.log(`${result.createdConfig ? "created" : "kept"} penpair.config.json (instance ${result.config.instance}, Penpot ${result.config.penpotVersion}, port ${result.config.ports.frontend})`);
1289
+ output.log(`wrote ${result.config.directory}/docker-compose.yaml; secrets ${result.createdSecrets ? "generated" : "kept"} in ${result.config.directory}/.env`);
1290
+ if (!result.gitignoreAdded) {
1291
+ output.log(`keep ${result.config.directory}/ out of git (add it to .gitignore, or run init with --gitignore)`);
1292
+ }
1293
+ output.log("next: penpair up, then penpair connect");
269
1294
  return 0;
270
1295
  }
271
1296
  /** Prints the checks one per line, or as JSON for scripts and agents. The key file check is added to both kinds of project. */
272
1297
  async function report(checks, mcp) {
273
1298
  const keyCheck = await keyFileCheck(root, nodeRunner);
274
- const all = keyCheck ? [...checks, keyCheck] : checks;
1299
+ const backupCheck = await backupNoteCheck(root, await loadCommandConfig(root));
1300
+ const all = [
1301
+ ...checks,
1302
+ ...(!mcp
1303
+ ? [
1304
+ {
1305
+ check: "editor and plugin",
1306
+ ok: false,
1307
+ detail: "No resolved project credential/connection; editor identity was not observed",
1308
+ remedy: "Configure the private credential source, connect the intended editor, then rerun doctor.",
1309
+ },
1310
+ ]
1311
+ : []),
1312
+ ...(keyCheck
1313
+ ? [
1314
+ keyCheck,
1315
+ ]
1316
+ : []),
1317
+ ...(backupCheck
1318
+ ? [
1319
+ backupCheck,
1320
+ ]
1321
+ : []),
1322
+ ];
275
1323
  const ok = all.every((check) => check.ok);
276
1324
  if (values.json) {
277
- console.log(JSON.stringify({ ok, mcpConfigured: Boolean(mcp), checks: all }, null, 2));
1325
+ output.log(JSON.stringify({
1326
+ ok,
1327
+ mcpConfigured: Boolean(mcp),
1328
+ file: all.find((check) => check.file)?.file,
1329
+ checks: all,
1330
+ }, null, 2));
278
1331
  return ok ? 0 : 1;
279
1332
  }
280
- for (const check of all)
281
- console.log(`${check.ok ? "ok " : "FAIL"} ${check.check}: ${check.detail}${"remedy" in check && check.remedy ? `\n → ${check.remedy}` : ""}`);
282
- if (!mcp)
283
- console.log("skip editor and plugin: no key yet. Run `penpair connect`.");
1333
+ for (const check of all) {
1334
+ output.log(`${check.warning ? "WARN" : check.ok ? "ok " : "FAIL"} ${check.check}: ${check.detail}${"remedy" in check && check.remedy ? `\n → ${check.remedy}` : ""}`);
1335
+ }
1336
+ if (!mcp) {
1337
+ output.log("skip editor and plugin: no key yet. Run `penpair connect`.");
1338
+ }
284
1339
  return ok ? 0 : 1;
285
1340
  }
286
- const lifecycle = new Set(["up", "down", "status", "doctor", "backup", "restore"]);
287
- if (lifecycle.has(command)) {
288
- const project = await loadConfig(root);
289
- if (isExternal(project) && command !== "doctor")
1341
+ if (command === "open") {
1342
+ if (rest.length) {
1343
+ throw new CommandArgumentError("open accepts only --print for a URL preview");
1344
+ }
1345
+ const config = await loadCommandConfig(root);
1346
+ const url = values.print ? projectFileUrl(config) : await openProject(config, openInBrowser);
1347
+ output.result({
1348
+ url,
1349
+ opened: !values.print,
1350
+ });
1351
+ if (!values.json) {
1352
+ output.log(url);
1353
+ }
1354
+ return 0;
1355
+ }
1356
+ if (command === "status") {
1357
+ if (rest.length) {
1358
+ throw new CommandArgumentError("status accepts optional --adapters");
1359
+ }
1360
+ const config = await loadCommandConfig(root);
1361
+ let endpoint;
1362
+ try {
1363
+ endpoint = await projectEndpoint(root);
1364
+ commandRedactor(endpointRedactor(endpoint));
1365
+ assertEndpointIsInstance(endpoint.description, config);
1366
+ }
1367
+ catch (error) {
1368
+ if (endpoint) {
1369
+ throw error;
1370
+ }
1371
+ }
1372
+ const report = await projectStatus(root, config, {
1373
+ ...(endpoint
1374
+ ? {
1375
+ connection: async () => guardFile(await connect(endpoint), config.file, {
1376
+ mode: "strict",
1377
+ }),
1378
+ redact: endpointRedactor(endpoint),
1379
+ }
1380
+ : {}),
1381
+ ...(values.adapters
1382
+ ? {
1383
+ adapterStatus: async () => {
1384
+ const loaded = await loadAdapters(root, config.adapters ?? []);
1385
+ if (loaded.problems.length) {
1386
+ throw new Error(loaded.problems.join("; "));
1387
+ }
1388
+ const context = await adapterContext(root, []);
1389
+ return (await Promise.all(loaded.adapters.map(async (a) => a.status
1390
+ ? a.status(context)
1391
+ : [
1392
+ {
1393
+ id: a.name,
1394
+ label: a.name,
1395
+ state: "unknown",
1396
+ detail: "No status hook",
1397
+ },
1398
+ ]))).flat();
1399
+ },
1400
+ }
1401
+ : {}),
1402
+ });
1403
+ let localState = null;
1404
+ if (!isExternal(config)) {
1405
+ if (cli.local) {
1406
+ const state = await cli.local.status({
1407
+ root,
1408
+ config,
1409
+ runner: nodeRunner,
1410
+ log,
1411
+ });
1412
+ localState = state;
1413
+ report.lines.push({
1414
+ id: "local-runtime",
1415
+ label: "Local containers",
1416
+ state: state.frontendReachable && state.backendReady ? "ok" : "failed",
1417
+ detail: `${state.url}; backend ${state.backendReady ? "ready" : "not ready"}`,
1418
+ });
1419
+ }
1420
+ else {
1421
+ report.lines.push({
1422
+ id: "local-runtime",
1423
+ label: "Local containers",
1424
+ state: "unknown",
1425
+ detail: "Install the local-instance command package for container diagnostics",
1426
+ });
1427
+ }
1428
+ report.complete = report.lines.every((line) => line.state !== "unknown");
1429
+ report.ok = report.complete && report.lines.every((line) => line.state === "ok");
1430
+ }
1431
+ output.result({
1432
+ ...report,
1433
+ localState,
1434
+ });
1435
+ if (!values.json) {
1436
+ for (const line of report.lines) {
1437
+ output.log(`${line.state.padEnd(7)} ${line.label}: ${line.detail}`);
1438
+ }
1439
+ }
1440
+ return report.lines.some((l) => l.state === "failed") ? 1 : report.ok ? 0 : 3;
1441
+ }
1442
+ const lifecycle = new Set([
1443
+ "up",
1444
+ "down",
1445
+ "status",
1446
+ "doctor",
1447
+ "backup",
1448
+ "restore",
1449
+ ]);
1450
+ if (lifecycle.has(command) && !(command === "backup" && rest[0] === "file")) {
1451
+ const project = await loadCommandConfig(root);
1452
+ if (isExternal(project) && command !== "doctor") {
290
1453
  requireLocal(project);
1454
+ }
291
1455
  if (isExternal(project)) {
292
1456
  const mcp = await mcpProbeFor();
293
- return report(await doctorExternal(project, { mcp }), mcp);
1457
+ return report(await doctorExternal(project, {
1458
+ mcp,
1459
+ timeoutMs: Number(values["timeout-ms"] ?? 5000),
1460
+ }), mcp);
294
1461
  }
295
1462
  const local = requireLocalProfile(cli.local);
296
- const c = { root, config: requireLocal(project), runner: nodeRunner, log };
1463
+ const c = {
1464
+ root,
1465
+ config: requireLocal(project),
1466
+ runner: nodeRunner,
1467
+ log,
1468
+ };
297
1469
  if (command === "up") {
298
1470
  const result = await local.up(c);
299
- console.log(result.ready ? `Penpot is up at ${result.url}. Create an account and open a file, then run \`penpair connect\`.` : `The containers started but ${result.url} does not answer yet. Run \`penpair status\`.`);
1471
+ output.log(result.ready
1472
+ ? `Penpot is up at ${result.url}. Create an account and open a file, then run \`penpair connect\`.`
1473
+ : `The containers started but ${result.url} does not answer yet. Run \`penpair status\`.`);
300
1474
  return result.ready ? 0 : 1;
301
1475
  }
302
1476
  if (command === "down") {
303
1477
  await local.down(c);
304
- console.log("stopped; the data volumes are kept");
1478
+ output.log("stopped; the data volumes are kept");
305
1479
  return 0;
306
1480
  }
307
1481
  if (command === "status") {
308
1482
  const state = await local.status(c);
309
- for (const s of state.services)
310
- console.log(`${s.state.padEnd(10)} ${s.service}${s.health ? ` (${s.health})` : ""}`);
311
- console.log(`${state.frontendReachable ? "answers" : "does not answer"} at ${state.url}; backend ${state.backendReady ? "ready" : "not ready"}`);
1483
+ for (const s of state.services) {
1484
+ output.log(`${s.state.padEnd(10)} ${s.service}${s.health ? ` (${s.health})` : ""}`);
1485
+ }
1486
+ output.log(`${state.frontendReachable ? "answers" : "does not answer"} at ${state.url}; backend ${state.backendReady ? "ready" : "not ready"}`);
312
1487
  return state.frontendReachable && state.backendReady ? 0 : 1;
313
1488
  }
314
1489
  if (command === "doctor") {
315
1490
  const mcp = await mcpProbeFor();
316
- return report(await local.doctor(c, { mcp }), mcp);
1491
+ return report(await local.doctor(c, {
1492
+ mcp,
1493
+ timeoutMs: Number(values["timeout-ms"] ?? 5000),
1494
+ }), mcp);
317
1495
  }
318
1496
  if (command === "backup") {
319
1497
  if (rest[0] === "verify") {
320
- if (!rest[1])
1498
+ if (!rest[1]) {
321
1499
  throw new Error("usage: penpair backup verify <backup directory>");
1500
+ }
322
1501
  const result = await local.verifyBackup(c, rest[1]);
323
- for (const check of result.checks)
324
- console.log(`${check.ok ? "ok " : "FAIL"} ${check.check}: ${check.detail}`);
1502
+ for (const check of result.checks) {
1503
+ output.log(`${check.ok ? "ok " : "FAIL"} ${check.check}: ${check.detail}`);
1504
+ }
325
1505
  return result.ok ? 0 : 1;
326
1506
  }
327
1507
  const result = await local.backup(c);
328
- console.log(`backup in ${result.directory} (${result.manifest.counts.files} files, ${result.manifest.counts.storageObjects} assets). It holds account data: never commit it.`);
1508
+ output.log(`backup in ${result.directory} (${result.manifest.counts.files} files, ${result.manifest.counts.storageObjects} assets). It holds account data: never commit it.`);
329
1509
  return 0;
330
1510
  }
331
1511
  if (command === "restore") {
332
- if (!rest[0])
1512
+ if (!rest[0]) {
333
1513
  throw new Error("usage: penpair restore <backup directory> --yes");
334
- const result = await local.restore(c, rest[0], { yes: values.yes === true });
335
- for (const check of result.checks)
336
- console.log(`${check.ok ? "ok " : "FAIL"} ${check.check}: ${check.detail}`);
337
- if (result.safetyBackup)
338
- console.log(`safety backup of the previous data: ${result.safetyBackup}`);
1514
+ }
1515
+ const result = await local.restore(c, rest[0], {
1516
+ yes: values.yes === true,
1517
+ });
1518
+ for (const check of result.checks) {
1519
+ output.log(`${check.ok ? "ok " : "FAIL"} ${check.check}: ${check.detail}`);
1520
+ }
1521
+ if (result.safetyBackup) {
1522
+ output.log(`safety backup of the previous data: ${result.safetyBackup}`);
1523
+ }
339
1524
  return result.ok ? 0 : 1;
340
1525
  }
341
1526
  }
342
- const endpoint = await resolveEndpoint();
343
- // In a project with an instance, act only on that instance unless told otherwise.
344
- if (!values["any-instance"]) {
345
- let projectConfig;
346
- try {
347
- projectConfig = await loadConfig(root);
1527
+ if (command === "run-file" && rest.length !== 1) {
1528
+ throw new CommandArgumentError("run-file requires exactly one plugin body path");
1529
+ }
1530
+ const projectConfig = values.standalone
1531
+ ? await loadOptionalConfig(root)
1532
+ : await loadCommandConfig(root);
1533
+ if (values.standalone && projectConfig) {
1534
+ throw new ConfigError("--standalone cannot bypass a project configuration.");
1535
+ }
1536
+ let preparedFile;
1537
+ if (command === "run-file") {
1538
+ const params = Object.create(null);
1539
+ for (const pair of values.param ?? []) {
1540
+ const at = pair.indexOf("=");
1541
+ if (at < 1) {
1542
+ throw new CommandArgumentError("--param needs key=value");
1543
+ }
1544
+ const key = pair.slice(0, at);
1545
+ if (Object.hasOwn(params, key)) {
1546
+ throw new CommandArgumentError("Duplicate script parameter: " + key);
1547
+ }
1548
+ params[key] = pair.slice(at + 1);
348
1549
  }
349
- catch {
350
- // not a penpair project: any endpoint will do
1550
+ const aliases = (values.include ?? []).filter((include) => include.startsWith("@"));
1551
+ const kits = aliases.length ? await loadKits(root, aliases) : [];
1552
+ const virtualIncludes = Object.fromEntries(kits.map((value) => [
1553
+ value.include,
1554
+ value.source,
1555
+ ]));
1556
+ preparedFile = await prepareFileScript(root, rest[0], {
1557
+ virtualIncludes,
1558
+ include: values.include,
1559
+ inspect: values.inspect,
1560
+ readback: values.readback,
1561
+ page: values.page,
1562
+ params,
1563
+ });
1564
+ if (preparedFile.kind === "read" && values.apply) {
1565
+ throw new CommandArgumentError("Read files do not require or accept --apply");
351
1566
  }
352
- if (projectConfig)
353
- assertEndpointIsInstance(endpoint.description, projectConfig);
354
1567
  }
1568
+ const endpoint = await projectEndpoint(root);
1569
+ commandRedactor(endpointRedactor(endpoint));
1570
+ if (projectConfig && !values["any-instance"]) {
1571
+ assertEndpointIsInstance(endpoint.description, projectConfig);
1572
+ }
1573
+ const guarded = async () => guardFile(await connect(endpoint), projectConfig?.file, {
1574
+ anyFile: values["any-file"] === true,
1575
+ mode: !commandProjects.getStore()?.strictFileGuard &&
1576
+ ([
1577
+ "pages",
1578
+ "tree",
1579
+ "audit",
1580
+ "tokens",
1581
+ ].includes(command) ||
1582
+ (command === "probe" && !values.deep && !values.integrity))
1583
+ ? "warn"
1584
+ : "strict",
1585
+ warn: log,
1586
+ });
355
1587
  if (command === "probe") {
356
- if (values.deep && !values.write)
1588
+ if (values.deep && !values.write) {
357
1589
  throw new Error("probe --deep creates a temporary board, a token set and two components in the open file and removes them again. Run it on a scratch file, with --write to confirm.");
358
- console.log(JSON.stringify(await probe(endpoint, () => connect(endpoint)), null, 2));
1590
+ }
1591
+ const capabilityReport = await probe(endpoint, guarded, {
1592
+ integrity: values.integrity === true,
1593
+ });
1594
+ output.log(JSON.stringify(capabilityReport, null, 2));
1595
+ if (capabilityReport.integrity?.supported && capabilityReport.integrity.valid === false) {
1596
+ return 1;
1597
+ }
359
1598
  if (values.deep) {
360
- const client = await connect(endpoint);
1599
+ const client = await guarded();
361
1600
  try {
362
- const report = await runDeepCheck(client, { onWait: (m) => console.error(`… ${m}`) });
363
- console.log(`\n${formatDeepReport(report)}`);
364
- return report.ok ? 0 : 1;
1601
+ const report = await runDeepCheck(client, {
1602
+ onWait: (m) => output.error(`… ${m}`),
1603
+ });
1604
+ const kitReport = values.kit && report.ok
1605
+ ? await runKitSmoke(client, root, {
1606
+ apply: true,
1607
+ onStep: log,
1608
+ redact: endpointRedactor(endpoint),
1609
+ })
1610
+ : undefined;
1611
+ output.result({
1612
+ ...report,
1613
+ ...(kitReport
1614
+ ? {
1615
+ kit: kitReport,
1616
+ }
1617
+ : {}),
1618
+ capabilities: capabilityReport,
1619
+ });
1620
+ output.log(`\n${formatDeepReport(report)}`);
1621
+ return report.ok && (!kitReport || kitReport.verified) ? 0 : 1;
365
1622
  }
366
1623
  finally {
367
1624
  await client.close();
@@ -369,89 +1626,440 @@ async function main(argv, cli) {
369
1626
  }
370
1627
  return 0;
371
1628
  }
372
- const client = await connect(endpoint);
373
- const options = { onWait: (m) => console.error(`… ${m}`), onStep: (m) => console.error(`… ${m}`) };
1629
+ const client = await guarded();
1630
+ commandRedactor(endpointRedactor(endpoint));
1631
+ const options = {
1632
+ ...(values.layout
1633
+ ? {
1634
+ layout: {
1635
+ strategy: values.layout,
1636
+ },
1637
+ }
1638
+ : {}),
1639
+ signal: commandSignal(),
1640
+ redact: endpointRedactor(endpoint),
1641
+ onWait: (m) => output.error(`… ${m}`),
1642
+ onStep: (m) => output.error(`… ${m}`),
1643
+ };
1644
+ const writeOptions = (operation) => ({
1645
+ ...options,
1646
+ apply: values.apply === true,
1647
+ redact: (text) => redactEndpoint(text, endpoint),
1648
+ ...(values.apply
1649
+ ? {
1650
+ journal: {
1651
+ root,
1652
+ trace: values.trace === true,
1653
+ forceLock: values["force-lock"] === true,
1654
+ runId: values.resume,
1655
+ resume: Boolean(values.resume),
1656
+ requireCheckpoint: values.checkpoint === true,
1657
+ ...(values.checkpoint
1658
+ ? {
1659
+ checkpoint: (runId) => createCheckpoint(client, {
1660
+ label: operation,
1661
+ runId,
1662
+ }, {
1663
+ apply: true,
1664
+ }),
1665
+ }
1666
+ : {}),
1667
+ },
1668
+ }
1669
+ : {}),
1670
+ });
374
1671
  try {
1672
+ if (command === "backup" && rest[0] === "file") {
1673
+ const out = rest[1];
1674
+ if (!out || !out.endsWith(".penpot")) {
1675
+ throw new Error("usage: penpair backup file <new.penpot>");
1676
+ }
1677
+ const archive = await exportFileArchive(client);
1678
+ if (archive.status !== "exported") {
1679
+ output.error(redactEndpoint(archive.message, endpoint));
1680
+ return 1;
1681
+ }
1682
+ await atomicWriteFile(out, archive.bytes);
1683
+ output.log(JSON.stringify({
1684
+ file: out,
1685
+ fileId: archive.fileId,
1686
+ revn: archive.revn,
1687
+ bytes: archive.bytes.length,
1688
+ sha256: archive.sha256,
1689
+ restoreVerified: false,
1690
+ }, null, 2));
1691
+ output.error("Archive bytes saved. Restore is not verified: import into a scratch project and compare content and renders.");
1692
+ return 0;
1693
+ }
1694
+ if (command === "run-file") {
1695
+ const report = await runFileScript(client, preparedFile, writeOptions("run-file"));
1696
+ output.log(JSON.stringify(report, null, 2));
1697
+ if (report.phase === "inspected") {
1698
+ output.error("Nothing was written. Review the inspection, then use --apply.");
1699
+ }
1700
+ return report.complete === false ? 3 : report.phase === "applied" && !report.verified ? 1 : 0;
1701
+ }
375
1702
  if (command === "run") {
376
1703
  const [name] = rest;
377
- if (!name)
1704
+ if (!name) {
378
1705
  throw new Error("usage: penpair run <snippet> [--param key=value ...] [--apply]");
379
- const { registry, problems } = await registryFor();
380
- for (const problem of problems)
381
- console.error(`warning: ${problem}`);
1706
+ }
1707
+ const { registry, problems } = await registryFor(name);
1708
+ for (const problem of problems) {
1709
+ output.error(`warning: ${problem}`);
1710
+ }
382
1711
  const raw = {};
383
1712
  for (const pair of values.param ?? []) {
384
1713
  const at = pair.indexOf("=");
385
- if (at < 1)
1714
+ if (at < 1) {
386
1715
  throw new RegistryError(`--param needs key=value, got ${JSON.stringify(pair)}`);
1716
+ }
387
1717
  const key = pair.slice(0, at);
388
1718
  const value = pair.slice(at + 1);
389
1719
  const earlier = raw[key];
390
- raw[key] = earlier === undefined ? value : [...(Array.isArray(earlier) ? earlier : [earlier]), value];
1720
+ raw[key] =
1721
+ earlier === undefined
1722
+ ? value
1723
+ : [
1724
+ ...(Array.isArray(earlier)
1725
+ ? earlier
1726
+ : [
1727
+ earlier,
1728
+ ]),
1729
+ value,
1730
+ ];
391
1731
  }
392
1732
  const entry = registry.get(name);
393
- const result = await registry.run(client, name, coerceParams(entry.params, raw), { ...options, apply: values.apply === true });
394
- console.log(JSON.stringify(result, null, 2));
395
- if (entry.kind === "write" && !values.apply)
396
- console.error("Nothing was written. Run again with --apply to do it.");
397
- return result.phase === "applied" && !result.verified ? 1 : 0;
1733
+ if (entry.kind === "read" && (values.resume || values.checkpoint)) {
1734
+ throw new Error("Read snippets have no write run to resume or checkpoint");
1735
+ }
1736
+ const result = await registry.run(client, name, coerceParams(entry.params, raw), writeOptions(name));
1737
+ output.log(JSON.stringify(result, null, 2));
1738
+ if (entry.kind === "write" && !values.apply) {
1739
+ output.error("Nothing was written. Run again with --apply to do it.");
1740
+ }
1741
+ return result.phase === "applied" &&
1742
+ !result.verified
1743
+ ? 1
1744
+ : 0;
398
1745
  }
399
1746
  if (command === "baselines") {
400
- const config = (await loadConfig(root)).baselines;
401
- if (!config)
1747
+ const config = (await loadCommandConfig(root)).baselines;
1748
+ if (!config) {
402
1749
  throw new Error('penpair.config.json has no "baselines" block. Add {"page": "<page>", "root": "tests/baselines", "frames": [{"frame": "<frame name>", "id": "<file name>", "route": "<route>"}]}; see docs/guide.md.');
403
- const report = await exportBaselines(client, root, config, { ...options, apply: values.apply === true, update: values.update === true });
404
- for (const r of report.results)
405
- console.log(`${r.status.padEnd(9)} ${r.id.padEnd(28)} ${r.frame}${r.imageWidth ? ` (${r.imageWidth}x${r.imageHeight})` : ""}${r.written ? " written" : ""}`);
406
- for (const file of report.wrote)
407
- console.log(`wrote ${file}`);
1750
+ }
1751
+ const report = await exportBaselines(client, root, config, {
1752
+ ...options,
1753
+ apply: values.apply === true,
1754
+ update: values.update === true,
1755
+ forceReason: values["force-reason"],
1756
+ });
1757
+ output.result(report);
1758
+ for (const r of report.results) {
1759
+ output.log(`${r.status.padEnd(9)} ${r.id.padEnd(28)} ${r.frame}${r.imageWidth ? ` (${r.imageWidth}x${r.imageHeight})` : ""}${r.written ? " written" : ""}`);
1760
+ }
1761
+ for (const file of report.wrote) {
1762
+ output.log(`wrote ${file}`);
1763
+ }
408
1764
  const changed = report.results.filter((r) => r.status === "changed" && !r.written);
409
- if (!values.apply)
410
- console.error("Nothing was written. Run again with --apply to write new images.");
411
- if (changed.length > 0)
412
- console.error(`${changed.length} design(s) differ from the image in the folder. That is a change of the approved design: review it in Penpot, then run again with --apply --update to approve it.`);
413
- if (report.results.some((r) => r.status === "missing" || r.status === "unstable"))
414
- return 1;
1765
+ if (!values.apply) {
1766
+ output.error("Nothing was written. Run again with --apply to write new images.");
1767
+ }
1768
+ if (changed.length > 0) {
1769
+ output.error(`${changed.length} design(s) differ from the image in the folder. That is a change of the approved design: review it in Penpot, then run again with --apply --update to approve it.`);
1770
+ }
1771
+ for (const row of report.coverage.rows) {
1772
+ if (row.status !== "approved-design-intent") {
1773
+ output.error(`coverage ${row.status}: ${row.requirement.route} ${row.requirement.theme}/${row.requirement.state} ${row.requirement.viewport.width}x${row.requirement.viewport.height}${row.reason ? ": " + row.reason : ""}`);
1774
+ }
1775
+ }
1776
+ if (!report.complete ||
1777
+ (report.coverage.configured && !report.coverage.designCasesComplete)) {
1778
+ return 3;
1779
+ }
415
1780
  return changed.length > 0 ? 2 : 0;
416
1781
  }
417
- if (command === "pages")
418
- console.log(JSON.stringify(await runSnippet(client, pages(), options), null, 2));
1782
+ if (command === "audit" && (values.rules || projectConfig?.audit)) {
1783
+ if (!values.page ||
1784
+ !projectConfig?.audit ||
1785
+ rest.length ||
1786
+ values.prefix ||
1787
+ values.steps ||
1788
+ values.ignore ||
1789
+ values["token-source"]) {
1790
+ throw new CommandArgumentError("Configured audit requires --page and explicit config rules, without legacy filters");
1791
+ }
1792
+ const shapes = await captureInspection(client, {
1793
+ kind: "shape",
1794
+ shapeId: "@page-root",
1795
+ page: values.page,
1796
+ depth: 10000,
1797
+ maxNodes: 50000,
1798
+ });
1799
+ const tokens = projectConfig.audit.contrast
1800
+ ? await captureInspection(client, {
1801
+ kind: "tokens",
1802
+ })
1803
+ : undefined;
1804
+ if (!shapes.complete || (tokens && !tokens.complete)) {
1805
+ output.result({
1806
+ complete: false,
1807
+ reason: "Audit inspection incomplete",
1808
+ });
1809
+ return 3;
1810
+ }
1811
+ const report = auditDesignRules(shapes, projectConfig.audit, tokens);
1812
+ output.result(report);
1813
+ output.log(JSON.stringify(report, null, 2));
1814
+ return report.complete ? 0 : 3;
1815
+ }
1816
+ if (command === "pages") {
1817
+ output.log(JSON.stringify(await runSnippet(client, pages(), options), null, 2));
1818
+ }
419
1819
  else if (command === "tree") {
420
- if (!values.page)
1820
+ if (!values.page) {
421
1821
  throw new Error("--page is required");
422
- const record = await runOnPage(client, tree({ page: values.page, depth: values.depth ? Number(values.depth) : 3, namePrefix: values.prefix }), values.page, options);
423
- console.log((record.lines ?? []).join("\n"));
1822
+ }
1823
+ const record = await runOnPage(client, tree({
1824
+ page: values.page,
1825
+ depth: values.depth ? Number(values.depth) : 3,
1826
+ limit: values.limit ? Number(values.limit) : 400,
1827
+ namePrefix: values.prefix,
1828
+ }), values.page, options);
1829
+ if (values.json) {
1830
+ output.log(JSON.stringify(record));
1831
+ }
1832
+ else {
1833
+ output.log((record.lines ?? []).join("\n"));
1834
+ }
1835
+ if (record.truncated === true) {
1836
+ output.error("Tree is a limited preview; use inspect --all for full text or narrow the tree scope.");
1837
+ return 3;
1838
+ }
424
1839
  }
425
1840
  else if (command === "audit") {
426
- if (!values.page || !values.prefix)
1841
+ if (!values.page || !values.prefix) {
427
1842
  throw new Error("--page and --prefix are required");
1843
+ }
428
1844
  const steps = values.steps ? values.steps.split(",").map(Number) : undefined;
429
- const record = await runOnPage(client, audit({ page: values.page, framePrefix: values.prefix, spacingSteps: steps, ignoreUnbound: list(values.ignore) }), values.page, options);
430
- console.log(JSON.stringify(record.frames, null, 2));
1845
+ const record = await runOnPage(client, audit({
1846
+ page: values.page,
1847
+ framePrefix: values.prefix,
1848
+ spacingSteps: steps,
1849
+ ignoreUnbound: list(values.ignore),
1850
+ }), values.page, options);
1851
+ output.log(JSON.stringify(record, null, 2));
1852
+ if (record.truncated === true) {
1853
+ return 3;
1854
+ }
1855
+ }
1856
+ else if (command === "snapshot") {
1857
+ const snapshot = await captureStructuralSnapshot(client, options);
1858
+ if (values.write) {
1859
+ const manifest = await writeStructuralSnapshot(root, values.out ?? "design/snapshots", snapshot, endpointRedactor(endpoint));
1860
+ output.result(manifest);
1861
+ output.log(JSON.stringify(manifest, null, 2));
1862
+ }
1863
+ else {
1864
+ output.result(snapshot);
1865
+ output.log(JSON.stringify(snapshot, null, 2));
1866
+ }
1867
+ return 0;
431
1868
  }
432
- else if (command === "tokens" && rest[0] === "dump") {
433
- const { ok: _ok, readOnly: _readOnly, ...dump } = await runSnippet(client, tokensDump(), options);
434
- console.log(JSON.stringify(dump, null, 2));
1869
+ else if (command === "approve") {
1870
+ if (rest.length ||
1871
+ !values.page ||
1872
+ !values.frame ||
1873
+ (values.write && (!values.note?.trim() || !values.by?.trim())) ||
1874
+ (!values.write && (values.note || values.by))) {
1875
+ throw new CommandArgumentError("approve --page PAGE --frame ID [--case CASE_ID] previews evidence; --write requires --note exact-owner-words and --by identity");
1876
+ }
1877
+ let intent;
1878
+ if (values.case) {
1879
+ const baseline = projectConfig?.baselines;
1880
+ const wanted = baseline?.frames.find((f) => f.id === values.case);
1881
+ if (!wanted?.case || baseline?.page !== values.page) {
1882
+ throw new Error("Approval case must reference an explicitly configured frame on this page");
1883
+ }
1884
+ const found = await findShapesByName(client, wanted.frame, values.page);
1885
+ if (found.matches.length !== 1 || found.matches[0].id !== values.frame) {
1886
+ throw new Error("Approval frame ID does not match the configured case");
1887
+ }
1888
+ intent = wanted.case;
1889
+ }
1890
+ const captured = await exportShapePng(client, values.frame, {
1891
+ ...options,
1892
+ page: values.page,
1893
+ stable: true,
1894
+ });
1895
+ if (!captured.stable) {
1896
+ throw new Error("Approval export did not stabilize");
1897
+ }
1898
+ const sha256 = artifactSha(captured.png);
1899
+ const report = values.write
1900
+ ? await recordApproval(root, captured, {
1901
+ note: values.note,
1902
+ by: values.by,
1903
+ }, intent, endpointRedactor(endpoint))
1904
+ : {
1905
+ schemaVersion: 1,
1906
+ kind: "approval-candidate",
1907
+ capture: captured.capture,
1908
+ sha256,
1909
+ fingerprint: approvalFingerprint(captured.capture, sha256, intent),
1910
+ recorded: false,
1911
+ };
1912
+ output.result(report);
1913
+ output.log(JSON.stringify(report, null, 2));
1914
+ return 0;
1915
+ }
1916
+ else if (command === "spec") {
1917
+ const capture = await captureInspection(client, {
1918
+ shapeId: values.frame,
1919
+ page: values.page,
1920
+ kind: "shape",
1921
+ });
1922
+ if (!capture.complete || capture.kind !== "shape-inspection") {
1923
+ output.result(capture);
1924
+ return 3;
1925
+ }
1926
+ const mapping = values.map
1927
+ ? validateComponentMap(await readDesignJson(root, values.map, 1024 * 1024))
1928
+ : emptyComponentMap;
1929
+ const spec = buildDesignSpec(capture, mapping);
1930
+ const markdown = renderDesignSpec(spec);
1931
+ if (values.out) {
1932
+ await writeDesignSpec(root, values.out, markdown, endpointRedactor(endpoint));
1933
+ }
1934
+ output.result(spec);
1935
+ if (!values.json) {
1936
+ output.log(markdown);
1937
+ }
1938
+ return 0;
1939
+ }
1940
+ else if (command === "inspect" || (command === "tokens" && rest[0] === "dump")) {
1941
+ const { query, budget } = inspectionRequest;
1942
+ const result = values.cursor || (command === "inspect" && !values.all)
1943
+ ? await inspectChunk(client, query)
1944
+ : await captureInspection(client, query, budget);
1945
+ const redact = endpointRedactor(endpoint);
1946
+ const fragments = endpointRedactor(endpoint, true);
1947
+ let altered = redact(JSON.stringify(result)) !== JSON.stringify(result);
1948
+ const chunks = result.kind === "inspection-chunk"
1949
+ ? [
1950
+ result,
1951
+ ]
1952
+ : result.kind === "inspection-partial"
1953
+ ? result.chunks
1954
+ : [];
1955
+ for (const chunk of chunks) {
1956
+ const safe = fragments(chunk.chunk);
1957
+ if (safe !== chunk.chunk) {
1958
+ altered = true;
1959
+ chunk.chunk = safe;
1960
+ }
1961
+ }
1962
+ if (altered) {
1963
+ result.complete = false;
1964
+ Object.assign(result, {
1965
+ redacted: true,
1966
+ reason: "Output required credential redaction; source evidence is incomplete",
1967
+ });
1968
+ if ("nextCursor" in result) {
1969
+ result.nextCursor = null;
1970
+ }
1971
+ }
1972
+ output.log(JSON.stringify(result, null, 2));
1973
+ if (!result.complete) {
1974
+ return 3;
1975
+ }
1976
+ if (values.out) {
1977
+ const serialized = JSON.stringify(result, null, 2) + "\n";
1978
+ const target = await resolveProjectOutput(root, values.out);
1979
+ await mkdir(path.dirname(target), {
1980
+ recursive: true,
1981
+ });
1982
+ await atomicWriteFile(target, serialized, {
1983
+ replace: true,
1984
+ });
1985
+ }
1986
+ return 0;
435
1987
  }
436
1988
  else if (command === "export") {
437
- const [shapeId, out] = rest;
438
- if (!shapeId || !out)
439
- throw new Error("usage: penpair export <shapeId> <out.png> [--page <name>] [--scale 1]");
440
- const result = await exportShapePng(client, shapeId, { ...options, page: values.page, scale: values.scale ? Number(values.scale) : 1 });
441
- await writeFile(out, result.png);
442
- console.log(`saved ${out} (${result.png.length} bytes, ${result.stable ? "stable" : "not confirmed stable"} after ${result.exports} exports)`);
1989
+ const [shapeId, out] = values.name
1990
+ ? [
1991
+ undefined,
1992
+ rest[0],
1993
+ ]
1994
+ : rest;
1995
+ if (!out || rest.length !== (values.name ? 1 : 2) || (values.name && !values.page)) {
1996
+ throw new Error("usage: penpair export <shapeId> <out.png>, or export --name <exact-name> --page <name> <out.png>");
1997
+ }
1998
+ const exportOptions = {
1999
+ ...options,
2000
+ page: values.page,
2001
+ scale: values.scale ? Number(values.scale) : 1,
2002
+ };
2003
+ const result = values.name
2004
+ ? await exportShapeByName(client, values.name, values.page, exportOptions)
2005
+ : await exportShapePng(client, shapeId, exportOptions);
2006
+ if (!result.stable) {
2007
+ throw new PenpairError("incomplete-output", "Export did not stabilize; previous output was preserved");
2008
+ }
2009
+ await atomicWriteFile(out, result.png, {
2010
+ replace: true,
2011
+ });
2012
+ output.log(JSON.stringify({
2013
+ file: out,
2014
+ bytes: result.png.length,
2015
+ stable: result.stable,
2016
+ exports: result.exports,
2017
+ layout: result.layout,
2018
+ capture: result.capture,
2019
+ }, null, 2));
2020
+ }
2021
+ else if (command === "shots") {
2022
+ if (!values.page || rest.length) {
2023
+ throw new Error("usage: penpair shots --page <name> [--prefix <prefix>] [--out design/shots]");
2024
+ }
2025
+ const report = await captureShots(client, root, {
2026
+ page: values.page,
2027
+ prefix: values.prefix,
2028
+ out: values.out,
2029
+ }, {
2030
+ ...options,
2031
+ scale: values.scale ? Number(values.scale) : 1,
2032
+ });
2033
+ output.log(JSON.stringify(report, null, 2));
2034
+ return report.complete ? 0 : 3;
2035
+ }
2036
+ else if (command === "tokens" && tokenEdit) {
2037
+ const report = await runOperation(client, tokenEditOperation, tokenEdit, writeOptions("tokens-edit"));
2038
+ output.log(JSON.stringify(report, null, 2));
2039
+ if (!values.apply) {
2040
+ output.error("Nothing was written. Run again with --apply to execute this token edit.");
2041
+ }
2042
+ return report.phase === "applied" && !report.verified ? 1 : 0;
443
2043
  }
444
2044
  else if (command === "archive") {
445
- if (!values.page || !values.name)
2045
+ if (!values.page || !values.name) {
446
2046
  throw new Error("--page and --name are required");
447
- const report = await runOperation(client, archiveOperation, { page: values.page, archiveName: values.name, keepPrefixes: list(values.keep), partPrefixes: list(values.parts), x: values.x ? Number(values.x) : 9000 }, { ...options, apply: values.apply === true });
448
- console.log(JSON.stringify(report, null, 2));
449
- if (report.phase === "inspected")
450
- console.error("Nothing was written. Run again with --apply to do it.");
2047
+ }
2048
+ const report = await runOperation(client, archiveOperation, {
2049
+ page: values.page,
2050
+ archiveName: values.name,
2051
+ keepPrefixes: list(values.keep),
2052
+ partPrefixes: list(values.parts),
2053
+ x: values.x ? Number(values.x) : 9000,
2054
+ }, writeOptions("archive"));
2055
+ output.log(JSON.stringify(report, null, 2));
2056
+ if (report.phase === "inspected") {
2057
+ output.error("Nothing was written. Run again with --apply to do it.");
2058
+ }
451
2059
  return report.phase === "applied" && !report.verified ? 1 : 0;
452
2060
  }
453
2061
  else {
454
- console.log(usage(Boolean(cli.local), cli.adapters));
2062
+ output.log(usage(Boolean(cli.local), cli.adapters, cli.configuredAdapters));
455
2063
  return 1;
456
2064
  }
457
2065
  return 0;
@@ -461,18 +2069,26 @@ async function main(argv, cli) {
461
2069
  }
462
2070
  }
463
2071
  /** Runs one command of an adapter with a context that can reach the project's Penpot. */
464
- async function runAdapterCommand(command, root, args) {
465
- const config = await loadConfig(root);
2072
+ async function adapterContext(root, args) {
2073
+ const config = await loadCommandConfig(root);
466
2074
  const rawConfig = await readRawConfig(root);
2075
+ let redact = (text) => redactEndpoint(text);
467
2076
  const context = {
2077
+ redact: (text) => redact(text),
468
2078
  root,
469
2079
  config,
470
2080
  rawConfig,
471
- log: (message) => console.error(`… ${message}`),
2081
+ output,
2082
+ log: (message) => output.error(`… ${message}`),
472
2083
  async withPenpot(work) {
473
- const endpoint = await resolveEndpoint();
2084
+ const endpoint = await projectEndpoint(root);
2085
+ redact = (text) => redactEndpoint(text, endpoint);
2086
+ commandRedactor(endpointRedactor(endpoint));
474
2087
  assertEndpointIsInstance(endpoint.description, config);
475
- const client = await connect(endpoint);
2088
+ const client = guardFile(await connect(endpoint), config.file, {
2089
+ anyFile: args.includes("--any-file"),
2090
+ warn: context.log,
2091
+ });
476
2092
  try {
477
2093
  return await work(client);
478
2094
  }
@@ -481,27 +2097,271 @@ async function runAdapterCommand(command, root, args) {
481
2097
  }
482
2098
  },
483
2099
  };
484
- return command.run(context, args);
2100
+ return context;
2101
+ }
2102
+ async function runAdapterCommand(command, root, args) {
2103
+ const context = await adapterContext(root, args);
2104
+ commandSignal()?.throwIfAborted();
2105
+ return command.run(context, args.filter((arg) => arg !== "--any-file"));
485
2106
  }
486
2107
  /** Runs the command line and returns the exit code. The `penpair` command calls it with nothing extra; a package that adds a way to run Penpot calls it with its profile. */
487
- export async function runCli(argv, options = {}) {
2108
+ async function runCliInternal(argv, options = {}) {
488
2109
  try {
489
- const root = process.cwd();
2110
+ const root = projectRoot();
2111
+ if (argv[0] === "onboard") {
2112
+ if (argv.slice(1).some((arg) => arg !== "--json")) {
2113
+ throw new Error("usage: penpair onboard [--json]");
2114
+ }
2115
+ const report = onboardingChecklist(await loadCommandConfig(root));
2116
+ if (argv.includes("--json")) {
2117
+ output.log(JSON.stringify(report, null, 2));
2118
+ }
2119
+ else {
2120
+ output.log(`Join ${report.project}: ${report.origin}`);
2121
+ if (report.fileUrl) {
2122
+ output.log(`File: ${report.fileUrl}`);
2123
+ }
2124
+ report.steps.forEach((step, index) => output.log(`${index + 1}. ${step}`));
2125
+ report.warnings.forEach((warning) => output.log(`Note: ${warning}`));
2126
+ }
2127
+ return 0;
2128
+ }
2129
+ if (argv[0] === "runs") {
2130
+ if (argv.filter((arg) => arg !== "--json").length > 2 ||
2131
+ (argv[1]?.startsWith("--") && argv[1] !== "--json")) {
2132
+ throw new Error("usage: penpair runs [run-id]");
2133
+ }
2134
+ output.log(JSON.stringify(argv[1] && argv[1] !== "--json"
2135
+ ? await readJournal(root, argv[1])
2136
+ : await listJournals(root), null, 2));
2137
+ return 0;
2138
+ }
2139
+ if (argv[0] === "config") {
2140
+ if (argv[1] === "schema" && argv.slice(2).every((arg) => arg === "--json")) {
2141
+ output.log(JSON.stringify(projectConfigSchema, null, 2));
2142
+ return 0;
2143
+ }
2144
+ if (argv[1] !== "check" || argv.slice(2).some((arg) => arg !== "--json")) {
2145
+ throw new ConfigError("usage: penpair config check [--json] | penpair config schema");
2146
+ }
2147
+ try {
2148
+ await loadCommandConfig(root);
2149
+ }
2150
+ catch (error) {
2151
+ if (!argv.includes("--json") || !(error instanceof ConfigError)) {
2152
+ throw error;
2153
+ }
2154
+ commandFailure(error);
2155
+ output.log(JSON.stringify({
2156
+ valid: false,
2157
+ file: "penpair.config.json",
2158
+ error: {
2159
+ code: error.code,
2160
+ message: error.message,
2161
+ },
2162
+ }));
2163
+ return 1;
2164
+ }
2165
+ output.log(argv.includes("--json")
2166
+ ? JSON.stringify({
2167
+ valid: true,
2168
+ file: "penpair.config.json",
2169
+ })
2170
+ : "ok penpair.config.json is valid (extension settings are validated by their adapters)");
2171
+ return 0;
2172
+ }
490
2173
  const raw = await readRawConfig(root);
491
- const specifiers = Array.isArray(raw.adapters) ? raw.adapters.filter((entry) => typeof entry === "string") : [];
492
- const loaded = options.adapters ? { adapters: options.adapters, problems: [] } : specifiers.length > 0 ? await loadAdapters(root, specifiers) : { adapters: [], problems: [] };
2174
+ const specifiers = Array.isArray(raw.adapters)
2175
+ ? raw.adapters.filter((entry) => typeof entry === "string")
2176
+ : [];
493
2177
  const [first] = argv;
494
- const adapterCommand = loaded.adapters.flatMap((adapter) => adapter.commands).find((command) => command.name === first);
495
- if (adapterCommand)
2178
+ const builtIn = first === undefined ||
2179
+ first.startsWith("--") ||
2180
+ reservedCommands.includes(first);
2181
+ if (builtIn) {
2182
+ return await main(argv, {
2183
+ ...options,
2184
+ configuredAdapters: specifiers,
2185
+ });
2186
+ }
2187
+ // Dynamic discovery is necessary only for a requested extension command; it executes trusted project modules.
2188
+ const loaded = options.adapters
2189
+ ? {
2190
+ adapters: options.adapters,
2191
+ problems: [],
2192
+ }
2193
+ : await loadAdapters(root, specifiers);
2194
+ const adapterCommand = loaded.adapters
2195
+ .flatMap((adapter) => adapter.commands)
2196
+ .find((command) => command.name === first);
2197
+ if (adapterCommand) {
496
2198
  return await runAdapterCommand(adapterCommand, root, argv.slice(1));
497
- const builtIn = reservedCommands.includes(first ?? "");
498
- if (loaded.problems.length > 0 && (!builtIn || argv.includes("--help")))
499
- for (const problem of loaded.problems)
500
- console.error(`warning: ${problem}`);
501
- return await main(argv, { ...options, adapters: loaded.adapters });
2199
+ }
2200
+ for (const problem of loaded.problems) {
2201
+ output.error(`warning: ${problem}`);
2202
+ }
2203
+ return await main(argv, {
2204
+ ...options,
2205
+ adapters: loaded.adapters,
2206
+ configuredAdapters: specifiers,
2207
+ });
502
2208
  }
503
2209
  catch (error) {
504
- console.error(error instanceof PenpairError ? error.message : error instanceof ConfigError || error instanceof ConnectError || error instanceof LifecycleError || error instanceof RegistryError ? `penpair: ${error.message}` : `penpair: ${String(error?.message ?? error)}`);
505
- return 1;
2210
+ commandFailure(error);
2211
+ output.error(error instanceof PenpairError
2212
+ ? error.message
2213
+ : error instanceof ConfigError ||
2214
+ error instanceof ConnectError ||
2215
+ error instanceof LifecycleError ||
2216
+ error instanceof RegistryError
2217
+ ? `penpair: ${error.message}`
2218
+ : `penpair: ${String(error?.message ?? error)}`);
2219
+ const value = error;
2220
+ return value.code === "write-uncertain" || value.outcome === "uncertain"
2221
+ ? 4
2222
+ : value.code === "incomplete-output"
2223
+ ? 3
2224
+ : value.code === "cancelled"
2225
+ ? 130
2226
+ : value.code === "deadline"
2227
+ ? 124
2228
+ : 1;
506
2229
  }
507
2230
  }
2231
+ export async function runCommand(argv, options = {}) {
2232
+ const args = [
2233
+ ...argv.filter((arg) => arg !== "--json"),
2234
+ "--json",
2235
+ ];
2236
+ return collectCommand(args[0] ?? "help", async () => {
2237
+ if ([
2238
+ "setup",
2239
+ "connect",
2240
+ ].includes(argv[0] ?? "")) {
2241
+ throw new Error("Interactive setup/connect require a terminal; run without --json and use doctor --json afterwards");
2242
+ }
2243
+ return commandProjects.run({
2244
+ root: path.resolve(options.root ?? projectRoot()),
2245
+ projectEndpointOnly: options.projectEndpointOnly,
2246
+ pinnedConfig: options.pinnedConfig,
2247
+ strictFileGuard: options.strictFileGuard,
2248
+ }, () => runCliInternal(args, options));
2249
+ }, {
2250
+ signal: options.signal,
2251
+ });
2252
+ }
2253
+ async function runCliBound(argv, options = {}) {
2254
+ if (argv[0] === "mcp") {
2255
+ try {
2256
+ const parsed = parseCommandArgs({
2257
+ args: argv.slice(1),
2258
+ allowPositionals: true,
2259
+ options: {
2260
+ root: {
2261
+ type: "string",
2262
+ },
2263
+ "allow-writes": {
2264
+ type: "boolean",
2265
+ },
2266
+ },
2267
+ });
2268
+ if (!parsed.values.root ||
2269
+ !path.isAbsolute(parsed.values.root) ||
2270
+ parsed.positionals.length) {
2271
+ throw new Error("mcp requires an explicit absolute --root PROJECT; optionally --allow-writes");
2272
+ }
2273
+ const { serveMcp } = await import("./mcp-server.js");
2274
+ await serveMcp({
2275
+ root: parsed.values.root,
2276
+ allowWrites: parsed.values["allow-writes"] === true,
2277
+ cli: options,
2278
+ });
2279
+ return 0;
2280
+ }
2281
+ catch (error) {
2282
+ console.error(redactEndpoint(String(error.message)));
2283
+ return 1;
2284
+ }
2285
+ }
2286
+ const controller = new AbortController();
2287
+ const interrupt = () => controller.abort(new CommandInterruptedError("cancelled"));
2288
+ let timer;
2289
+ const args = [
2290
+ ...argv,
2291
+ ];
2292
+ const timeoutAt = args.indexOf("--timeout-ms");
2293
+ let invalidTimeout = false;
2294
+ if (timeoutAt !== -1) {
2295
+ const milliseconds = Number(args[timeoutAt + 1]);
2296
+ invalidTimeout =
2297
+ !Number.isInteger(milliseconds) ||
2298
+ milliseconds < (args[0] === "doctor" ? 1000 : 1) ||
2299
+ milliseconds > (args[0] === "doctor" ? 30000 : 1_800_000) ||
2300
+ args.indexOf("--timeout-ms", timeoutAt + 1) !== -1;
2301
+ if (args[0] !== "doctor") {
2302
+ args.splice(timeoutAt, 2);
2303
+ }
2304
+ if (!invalidTimeout) {
2305
+ timer = setTimeout(() => controller.abort(new CommandInterruptedError("deadline")), milliseconds);
2306
+ }
2307
+ }
2308
+ const json = args.includes("--json");
2309
+ if (!json &&
2310
+ [
2311
+ "setup",
2312
+ "connect",
2313
+ ].includes(args[0] ?? "")) {
2314
+ if (timer) {
2315
+ clearTimeout(timer);
2316
+ }
2317
+ if (timeoutAt !== -1) {
2318
+ console.error("--timeout-ms is not supported for interactive setup/connect");
2319
+ return 1;
2320
+ }
2321
+ return withCommandSignal(controller.signal, () => runCliInternal(args, options));
2322
+ }
2323
+ process.on("SIGINT", interrupt);
2324
+ process.on("SIGTERM", interrupt);
2325
+ try {
2326
+ if (invalidTimeout) {
2327
+ const result = await collectCommand(args[0] ?? "help", async () => {
2328
+ throw new Error(args[0] === "doctor"
2329
+ ? "doctor --timeout-ms must be one integer from 1000 to 30000"
2330
+ : "--timeout-ms must be one integer from 1 to 1800000");
2331
+ });
2332
+ if (json) {
2333
+ console.log(JSON.stringify(result, null, 2));
2334
+ }
2335
+ else {
2336
+ console.error(result.errors[0]?.message);
2337
+ }
2338
+ return result.exitCode;
2339
+ }
2340
+ return await withCommandSignal(controller.signal, async () => {
2341
+ if (!json) {
2342
+ return runCliInternal(args, options);
2343
+ }
2344
+ const result = await runCommand(args, {
2345
+ ...options,
2346
+ signal: controller.signal,
2347
+ });
2348
+ console.log(JSON.stringify(result, null, 2));
2349
+ return result.exitCode;
2350
+ });
2351
+ }
2352
+ finally {
2353
+ if (timer) {
2354
+ clearTimeout(timer);
2355
+ }
2356
+ process.removeListener("SIGINT", interrupt);
2357
+ process.removeListener("SIGTERM", interrupt);
2358
+ }
2359
+ }
2360
+ export async function runCli(argv, options = {}) {
2361
+ return commandProjects.run({
2362
+ root: path.resolve(options.root ?? projectRoot()),
2363
+ projectEndpointOnly: options.projectEndpointOnly,
2364
+ pinnedConfig: options.pinnedConfig,
2365
+ strictFileGuard: options.strictFileGuard,
2366
+ }, () => runCliBound(argv, options));
2367
+ }