@penpair/core 0.1.2 → 0.2.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 (123) hide show
  1. package/dist/adapters.d.ts +16 -1
  2. package/dist/adapters.js +8 -12
  3. package/dist/approvals.d.ts +32 -0
  4. package/dist/approvals.js +99 -0
  5. package/dist/atomic-output.d.ts +5 -0
  6. package/dist/atomic-output.js +28 -0
  7. package/dist/audit-rules.d.ts +60 -0
  8. package/dist/audit-rules.js +177 -0
  9. package/dist/backup-note.d.ts +4 -0
  10. package/dist/backup-note.js +28 -0
  11. package/dist/baseline-coverage.d.ts +25 -0
  12. package/dist/baseline-coverage.js +114 -0
  13. package/dist/baselines-config.d.ts +25 -0
  14. package/dist/baselines-config.js +52 -3
  15. package/dist/baselines.d.ts +37 -8
  16. package/dist/baselines.js +132 -52
  17. package/dist/cli-arguments.d.ts +7 -0
  18. package/dist/cli-arguments.js +35 -0
  19. package/dist/cli-main.d.ts +13 -1
  20. package/dist/cli-main.js +770 -139
  21. package/dist/client.d.ts +26 -5
  22. package/dist/client.js +46 -20
  23. package/dist/command-result.d.ts +159 -0
  24. package/dist/command-result.js +179 -0
  25. package/dist/coordination.d.ts +15 -0
  26. package/dist/coordination.js +122 -0
  27. package/dist/credentials.d.ts +21 -0
  28. package/dist/credentials.js +61 -0
  29. package/dist/deep-check.js +9 -5
  30. package/dist/design-spec.d.ts +55 -0
  31. package/dist/design-spec.js +135 -0
  32. package/dist/endpoint.d.ts +8 -1
  33. package/dist/endpoint.js +48 -4
  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 +42 -7
  38. package/dist/file-archive.d.ts +16 -0
  39. package/dist/file-archive.js +32 -0
  40. package/dist/file-guard.d.ts +8 -0
  41. package/dist/file-guard.js +40 -0
  42. package/dist/formats.d.ts +4 -0
  43. package/dist/formats.js +28 -0
  44. package/dist/index.d.ts +33 -1
  45. package/dist/index.js +33 -1
  46. package/dist/inspection.d.ts +106 -0
  47. package/dist/inspection.js +344 -0
  48. package/dist/instance/config-schema.d.ts +422 -0
  49. package/dist/instance/config-schema.js +76 -0
  50. package/dist/instance/config.d.ts +29 -0
  51. package/dist/instance/config.js +102 -11
  52. package/dist/instance/connect.d.ts +10 -1
  53. package/dist/instance/connect.js +53 -12
  54. package/dist/instance/connection.d.ts +14 -0
  55. package/dist/instance/connection.js +13 -2
  56. package/dist/instance/init.js +1 -1
  57. package/dist/instance/setup.js +1 -1
  58. package/dist/instance/tty.js +3 -1
  59. package/dist/journal.d.ts +77 -0
  60. package/dist/journal.js +260 -0
  61. package/dist/kit-smoke.d.ts +31 -0
  62. package/dist/kit-smoke.js +83 -0
  63. package/dist/kit-source.d.ts +2 -0
  64. package/dist/kit-source.js +309 -0
  65. package/dist/kit.d.ts +25 -0
  66. package/dist/kit.js +90 -0
  67. package/dist/layout.d.ts +15 -0
  68. package/dist/layout.js +79 -0
  69. package/dist/live-smoke.d.ts +8 -0
  70. package/dist/live-smoke.js +14 -0
  71. package/dist/mcp-server.d.ts +12 -0
  72. package/dist/mcp-server.js +102 -0
  73. package/dist/native-api.d.ts +15 -0
  74. package/dist/native-api.js +42 -0
  75. package/dist/onboard.d.ts +10 -0
  76. package/dist/onboard.js +15 -0
  77. package/dist/operation.d.ts +20 -1
  78. package/dist/operation.js +113 -13
  79. package/dist/png.d.ts +5 -0
  80. package/dist/png.js +7 -0
  81. package/dist/portable-tokens.d.ts +22 -0
  82. package/dist/portable-tokens.js +134 -0
  83. package/dist/probe.d.ts +6 -2
  84. package/dist/probe.js +19 -3
  85. package/dist/project-input.d.ts +6 -0
  86. package/dist/project-input.js +51 -0
  87. package/dist/project-snippets.js +12 -7
  88. package/dist/project-status.d.ts +24 -0
  89. package/dist/project-status.js +79 -0
  90. package/dist/registry.d.ts +2 -1
  91. package/dist/registry.js +17 -3
  92. package/dist/run-file.d.ts +26 -0
  93. package/dist/run-file.js +100 -0
  94. package/dist/shots.d.ts +43 -0
  95. package/dist/shots.js +90 -0
  96. package/dist/skill-instructions.d.ts +13 -0
  97. package/dist/skill-instructions.js +75 -0
  98. package/dist/skills.d.ts +11 -0
  99. package/dist/skills.js +71 -33
  100. package/dist/snippet.d.ts +3 -2
  101. package/dist/snippet.js +12 -8
  102. package/dist/snippets/archive.d.ts +9 -0
  103. package/dist/snippets/archive.js +10 -4
  104. package/dist/snippets/read.d.ts +1 -1
  105. package/dist/snippets/read.js +71 -40
  106. package/dist/structure-snapshot.d.ts +73 -0
  107. package/dist/structure-snapshot.js +171 -0
  108. package/dist/token-edit.d.ts +53 -0
  109. package/dist/token-edit.js +197 -0
  110. package/dist/tokens-diff.d.ts +19 -6
  111. package/dist/tokens-diff.js +89 -18
  112. package/dist/version.d.ts +1 -1
  113. package/dist/version.js +1 -1
  114. package/package.json +1 -1
  115. package/skills/penpair-design/SKILL.md +12 -40
  116. package/skills/penpair-design/references/penpot-plugin-gotchas.md +56 -0
  117. package/skills/penpair-design/references/project-facts.md +1 -1
  118. package/skills/penpair-design/references/write-protocol.md +15 -3
  119. package/skills/penpair-handoff/SKILL.md +14 -0
  120. package/skills/penpair-handoff/references/handoff-flow.md +11 -0
  121. package/skills/penpair-recipes/SKILL.md +16 -0
  122. package/skills/penpair-recipes/references/build-flow.md +11 -0
  123. package/skills/penpair-setup/SKILL.md +6 -2
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,9 +31,10 @@ 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";
@@ -25,26 +44,46 @@ import { requireLocalProfile } from "./local-profile.js";
25
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)
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)
34
62
  Read-only:
35
- penpair probe what the connected Penpot and its MCP offer, and the limits
63
+ penpair config check [--json] validate project configuration offline, without loading extensions
64
+ penpair config schema print its JSON Schema (also works while fixing invalid config)
65
+ (without a project config, built-in inspection requires --standalone; writes require a project)
66
+ penpair probe [--integrity] APIs and limits; optionally validate file integrity when supported
36
67
  penpair probe --deep --write also tries layout, tokens, components, swap, clone and export on a temporary
37
68
  board it removes again (it touches the open file, so --write is required)
38
69
  (in a project with an instance every command below acts on that instance only; --any-instance overrides)
39
70
  penpair pages the file, its revision, pages and their sizes
71
+ penpair inspect <shape-id> --page <name> [--all] [--fields geometry,text,tokens] [--cursor <cursor>]
40
72
  penpair tree --page <name> [--depth 3] [--prefix <text>]
41
73
  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
74
+ penpair baselines coverage [--json] offline case matrix: approved intent, missing, unsupported and unknown
75
+ penpair kit check / recipes [--include @kit] offline compilation and recipe metadata
76
+ penpair run-file <script.js> [--include helpers.js] trusted plugin body; read declaration or reviewed inspect/apply/readback
77
+ penpair tokens dump typed token sets and themes as JSON
78
+ penpair tokens add-set|set|remove|promote inspect token edits; --apply writes once
43
79
  penpair tokens diff <before.json> <after.json> --theme <name>
44
80
  penpair export <shapeId> <out.png> [--page <name>] [--scale 1]
81
+ penpair export --name <exact-name> --page <name> <out.png>
82
+ penpair shots --page <name> [--prefix <prefix>] [--out design/shots]
83
+ penpair backup file <new.penpot> try a bounded full-file export, without replacing an existing archive
45
84
  @@INSTANCE@@Skills (the rules an AI agent follows, shipped with penpair):
46
85
  penpair skills list them
47
- penpair skills install [--to .skills] [--only <name>] [--force] copy them into the project and make them discoverable
86
+ penpair skills install [--to <folder>] [--only <name>] [--force] [--agents AGENTS.md] [--dry-run] copy them into the project and make them discoverable
48
87
  Snippets (the built-in ones and the project's own, listed under "snippets" in penpair.config.json):
49
88
  penpair snippets what can be run, with its parameters
50
89
  penpair snippets check validate the project's snippets without a connection
@@ -54,6 +93,9 @@ Hand a page's design to a check (Reflection or any image comparison):
54
93
  a manifest and ready-made Reflection cases. Looks first; --apply writes new images;
55
94
  --update also replaces images whose design changed (approving the new design)
56
95
  Changes in a Penpot file (inspect first; nothing is written without --apply):
96
+ penpair onboard [--json] colleague setup checklist, offline
97
+ penpair runs [run-id] list recovery runs or inspect one, offline
98
+ (write commands support --resume <run-id> --apply and optional --checkpoint)
57
99
  penpair archive --page <name> --name <archive board> --keep <prefixes> --parts <prefixes> [--x 9000] [--apply]
58
100
  penpair --version`;
59
101
  const usageLocalInstance = `An instance on this computer (one Penpot per project, configured in penpair.config.json):
@@ -69,21 +111,46 @@ const usageConnection = `Connecting to a Penpot (one project, configured in penp
69
111
  penpair init --external <origin> use a Penpot someone else runs (a team server or https://design.penpot.app)
70
112
  penpair doctor [--json] what is up, what is not, and what to do about it (--json for scripts and agents)
71
113
  `;
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);
114
+ const usage = (hasLocal, adapters = [], configured = []) => usageHead.replace("@@INSTANCE@@", usageConnection + (hasLocal ? usageLocalInstance : usageNoLocalInstance)).replace("@@SETUP@@", hasLocal ? " penpair setup --docker Penpot on this computer in Docker\n" : "") + adapterUsage(adapters) + (configured.length ? `\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` : "");
73
115
  /** The commands that adapters from penpair.config.json add, after the core's own. */
74
116
  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
117
  const list = (value) => (value ?? "").split(",").map((v) => v.trim()).filter(Boolean);
118
+ const commandProjects = new AsyncLocalStorage();
119
+ const targetSignature = (config) => JSON.stringify({ instance: config.instance, file: config.file, profile: "profile" in config ? config.profile : null, ports: "ports" in config ? config.ports : null });
120
+ async function loadCommandConfig(root) {
121
+ const config = await loadConfig(root), pinned = commandProjects.getStore()?.pinnedConfig;
122
+ if (pinned && targetSignature(config) !== targetSignature(pinned))
123
+ throw new Error("Pinned project target changed; restart and review the server configuration");
124
+ return config;
125
+ }
126
+ const projectRoot = () => commandProjects.getStore()?.root ?? process.cwd();
127
+ const projectEndpoint = async (root) => {
128
+ const config = await loadOptionalConfig(root);
129
+ return resolveEndpoint({ envFiles: [path.join(root, ".env.local")], credentialRoot: root, credential: config?.connection?.credential, globalConfig: !commandProjects.getStore()?.projectEndpointOnly });
130
+ };
76
131
  async function main(argv, cli) {
77
- const { values, positionals } = parseArgs({
132
+ const { values, positionals } = parseCommandArgs({
78
133
  args: argv,
79
134
  allowPositionals: true,
80
135
  options: {
136
+ include: { type: "string", multiple: true },
137
+ rules: { type: "boolean" }, "token-source": { type: "string" },
138
+ adapters: { type: "boolean" }, print: { type: "boolean" },
139
+ note: { type: "string" }, by: { type: "string" }, "case": { type: "string" }, "force-reason": { type: "string" },
140
+ from: { type: "string" }, map: { type: "string" }, frame: { type: "string" },
141
+ inspect: { type: "string" }, readback: { type: "string" },
81
142
  page: { type: "string" },
143
+ out: { type: "string" },
144
+ fields: { type: "string" }, cursor: { type: "string" }, all: { type: "boolean" },
145
+ "max-nodes": { type: "string" }, "response-bytes": { type: "string" }, "time-budget-ms": { type: "string" }, "document-bytes": { type: "string" },
146
+ "max-calls": { type: "string" }, "max-total-bytes": { type: "string" }, "capture-timeout-ms": { type: "string" },
82
147
  depth: { type: "string" },
148
+ limit: { type: "string" },
83
149
  prefix: { type: "string" },
84
150
  steps: { type: "string" },
85
151
  ignore: { type: "string" },
86
152
  theme: { type: "string" },
153
+ type: { type: "string" }, value: { type: "string" }, description: { type: "string" },
87
154
  scale: { type: "string" },
88
155
  name: { type: "string" },
89
156
  keep: { type: "string" },
@@ -96,12 +163,23 @@ async function main(argv, cli) {
96
163
  gitignore: { type: "boolean" },
97
164
  yes: { type: "boolean" },
98
165
  "any-instance": { type: "boolean" },
166
+ "any-file": { type: "boolean" },
167
+ "pin-file": { type: "boolean" },
168
+ standalone: { type: "boolean" },
169
+ "builtins-only": { type: "boolean" },
170
+ agents: { type: "string" },
171
+ "dry-run": { type: "boolean" },
99
172
  web: { type: "boolean" },
100
173
  docker: { type: "boolean" },
101
174
  replace: { type: "boolean" },
102
175
  update: { type: "boolean" },
103
176
  json: { type: "boolean" },
104
177
  deep: { type: "boolean" },
178
+ integrity: { type: "boolean" },
179
+ kit: { type: "boolean" },
180
+ resume: { type: "string" },
181
+ checkpoint: { type: "boolean" },
182
+ "force-lock": { type: "boolean" },
105
183
  force: { type: "boolean" },
106
184
  to: { type: "string" },
107
185
  only: { type: "string", multiple: true },
@@ -112,36 +190,210 @@ async function main(argv, cli) {
112
190
  },
113
191
  });
114
192
  const [command, ...rest] = positionals;
193
+ const inspectionFlags = ["cursor", "all", "out", "max-nodes", "response-bytes", "time-budget-ms", "document-bytes", "max-calls", "max-total-bytes", "capture-timeout-ms"];
194
+ const flags = {
195
+ formats: ["out"],
196
+ snapshot: ["write", "out", "from"],
197
+ approve: ["page", "frame", "note", "by", "case", "write"],
198
+ spec: ["from", "map", "out", "page", "frame"],
199
+ pages: [], tree: ["page", "depth", "prefix", "limit"], audit: ["page", "prefix", "steps", "ignore", "rules", "from", "token-source"], inspect: ["page", "depth", "fields", ...inspectionFlags], tokens: rest[0] === "dump" ? inspectionFlags : rest[0] === "diff" ? ["theme"] : ["type", "value", "description", "apply", "resume", "checkpoint", "force-lock"], export: ["page", "scale", "name"], shots: ["page", "prefix", "out", "scale"],
200
+ archive: ["page", "name", "keep", "parts", "x", "apply", "resume", "checkpoint", "force-lock"],
201
+ kit: ["include"], recipes: ["include"],
202
+ "run-file": ["include", "inspect", "readback", "page", "param", "apply", "resume", "checkpoint", "force-lock"],
203
+ run: ["builtins-only", "param", "apply", "resume", "checkpoint", "force-lock"], baselines: ["apply", "update", "force-reason"], probe: ["deep", "write", "integrity", "kit"],
204
+ skills: ["to", "only", "force", "agents", "dry-run"], snippets: ["builtins-only"], init: ["instance", "port", "gitignore", "external"], setup: ["agents", "web", "docker", "external", "instance", "port"],
205
+ connect: ["replace", "pin-file", "any-file"], doctor: [], up: [], down: [], open: ["print"], status: ["adapters"], backup: [], restore: ["yes"],
206
+ };
207
+ const network = ["snapshot", "approve", "spec", "inspect", "pages", "tree", "audit", "tokens", "export", "shots", "archive", "run", "run-file", "baselines", "probe", "backup", "doctor"];
208
+ const allowed = new Set(["help", "version", "json", ...(flags[command ?? ""] ?? []), ...(network.includes(command ?? "") ? ["any-instance", "any-file", "standalone"] : [])]);
209
+ for (const key of Object.keys(values))
210
+ if (!allowed.has(key))
211
+ throw new Error(`--${key} is not supported by ${command || "this command"}`);
212
+ for (const [key, min, max, integer] of [["depth", 0, command === "inspect" ? 10000 : 100, true], ["limit", 1, 1000, true], ["scale", 0.01, 8, false], ["x", -10000000, 10000000, false], ["port", 1024, 65535, true]]) {
213
+ const raw = values[key];
214
+ if (raw === undefined)
215
+ continue;
216
+ const number = Number(raw);
217
+ if (!raw.trim() || !Number.isFinite(number) || number < min || number > max || integer && !Number.isInteger(number))
218
+ throw new Error(`--${key} must be ${integer ? "an integer" : "a number"} from ${min} to ${max}`);
219
+ }
220
+ if (command === "export" && (rest.length !== (values.name ? 1 : 2) || values.name && !values.page))
221
+ throw new CommandArgumentError("Export requires an ID and output path, or --name with an explicit --page and one output path");
222
+ if (command === "shots" && (!values.page || rest.length))
223
+ throw new CommandArgumentError("Shots requires --page and supports optional --prefix/--out");
224
+ let tokenEdit;
225
+ if (command === "tokens" && !["dump", "diff"].includes(rest[0] ?? "")) {
226
+ const [kind, set, name] = rest;
227
+ if (kind === "add-set" && rest.length === 2)
228
+ tokenEdit = { kind, set: set };
229
+ else if (kind === "remove" && rest.length === 3)
230
+ tokenEdit = { kind, set: set, name: name };
231
+ else if (kind === "promote" && rest.length === 3)
232
+ tokenEdit = { kind, set: set, to: name };
233
+ else if (kind === "set" && rest.length === 3 && values.type && values.value) {
234
+ let value;
235
+ try {
236
+ value = JSON.parse(values.value);
237
+ }
238
+ catch {
239
+ throw new CommandArgumentError("--value must be JSON: quote strings or supply a composite object");
240
+ }
241
+ tokenEdit = { kind, set: set, token: { name: name, type: values.type, value: value, description: values.description ?? "" } };
242
+ }
243
+ else
244
+ throw new CommandArgumentError("Usage: tokens add-set SET; tokens set SET NAME --type TYPE --value JSON; tokens remove SET NAME; tokens promote SOURCE DESTINATION");
245
+ if (kind !== "set" && [values.type, values.value, values.description].some(v => v !== undefined))
246
+ throw new CommandArgumentError("Token value options apply only to tokens set");
247
+ tokenEdit = validateTokenEdit(tokenEdit);
248
+ }
249
+ let inspectionRequest;
250
+ if (command === "inspect" || command === "tokens" && rest[0] === "dump") {
251
+ if (rest.length !== 1 || command === "inspect" && !values.page)
252
+ throw new CommandArgumentError("Inspect requires one shape ID and --page; tokens dump accepts no extra positionals");
253
+ if (values.cursor && values.all || values.cursor && values.out || command === "inspect" && values.out && !values.all)
254
+ throw new CommandArgumentError("Use --all for a complete output file; --cursor selects one continuation chunk");
255
+ if ((values.cursor || command === "inspect" && !values.all) && [values["max-calls"], values["max-total-bytes"], values["capture-timeout-ms"]].some(value => value !== undefined))
256
+ throw new CommandArgumentError("Collection budgets require a full capture rather than one cursor chunk");
257
+ const numeric = (key) => values[key] === undefined ? undefined : Number(values[key]);
258
+ const query = { kind: command === "inspect" ? "shape" : "tokens", shapeId: command === "inspect" ? rest[0] : undefined, page: values.page,
259
+ fields: values.fields?.split(",").map(field => field.trim()), depth: numeric("depth"), maxNodes: numeric("max-nodes"), responseBytes: numeric("response-bytes"), timeBudgetMs: numeric("time-budget-ms"), documentBytes: numeric("document-bytes"), cursor: values.cursor };
260
+ validateInspectionQuery(query);
261
+ inspectionRequest = { query, budget: validateCaptureBudget({ maxCalls: numeric("max-calls"), maxTotalBytes: numeric("max-total-bytes"), timeoutMs: numeric("capture-timeout-ms") }) };
262
+ }
263
+ if (values.kit && (!values.deep || !values.write))
264
+ throw new CommandArgumentError("--kit requires the explicit scratch probe --deep --write");
265
+ if (values.steps && values.steps.split(",").some((step) => !step.trim() || !Number.isFinite(Number(step)) || Number(step) < 0))
266
+ throw new Error("--steps requires finite nonnegative numbers");
267
+ if (command === "skills" && rest[0] !== "install" && [values.agents, values["dry-run"], values.to, values.only, values.force].some((value) => value !== undefined))
268
+ throw new Error("Installation flags require skills install");
269
+ if ((values.resume || values.checkpoint || values["force-lock"]) && (!values.apply || !(["archive", "run", "run-file"].includes(command ?? "") || command === "tokens" && tokenEdit)))
270
+ throw new Error("--resume and --checkpoint require an operation command (archive/run/run-file/tokens) with --apply");
115
271
  if (values.version) {
116
- console.log(version);
272
+ output.log(version);
273
+ return 0;
274
+ }
275
+ if (!command || command === "help" || values.help) {
276
+ output.log(usage(Boolean(cli.local), cli.adapters, cli.configuredAdapters));
117
277
  return 0;
118
278
  }
119
- if (!command || values.help) {
120
- console.log(usage(Boolean(cli.local), cli.adapters));
279
+ if (command === "audit" && values.from) {
280
+ if (rest.length || values.page || values.prefix || values.steps || values.ignore || values["any-file"] || values["any-instance"] || values.standalone)
281
+ throw new CommandArgumentError("audit --from INSPECTION [--token-source TOKENS] uses configured advisory rules offline");
282
+ const config = await loadCommandConfig(projectRoot());
283
+ if (!config.audit)
284
+ throw new Error("Configure explicit audit rules first");
285
+ const report = auditDesignRules(await readDesignJson(projectRoot(), values.from), config.audit, values["token-source"] ? await readDesignJson(projectRoot(), values["token-source"]) : undefined);
286
+ output.result(report);
287
+ output.log(JSON.stringify(report, null, 2));
288
+ return report.complete ? 0 : 3;
289
+ }
290
+ if (command === "formats") {
291
+ const [action, input] = rest;
292
+ if (action === "schema" && rest.length === 2 && !values.out && artifactSchemas[input]) {
293
+ output.result(artifactSchemas[input]);
294
+ output.log(JSON.stringify(artifactSchemas[input], null, 2));
295
+ return 0;
296
+ }
297
+ if (!["import-tokens", "export-tokens", "migrate-config"].includes(action ?? "") || rest.length !== 2)
298
+ throw new CommandArgumentError("formats schema NAME | import-tokens FILE | export-tokens FILE | migrate-config FILE [--out NEW_FILE]");
299
+ const raw = await readDesignJson(projectRoot(), input, 8 * 1024 * 1024);
300
+ const result = action === "import-tokens" ? importDtcg(raw) : action === "export-tokens" ? exportDtcg(raw) : migrateProjectConfig(raw);
301
+ if (values.out) {
302
+ const target = await resolveProjectOutput(projectRoot(), values.out);
303
+ await mkdir(path.dirname(target), { recursive: true });
304
+ await atomicWriteFile(target, JSON.stringify(result, null, 2) + "\n");
305
+ }
306
+ output.result(result);
307
+ output.log(JSON.stringify(result, null, 2));
308
+ return 0;
309
+ }
310
+ if (command === "snapshot" && rest[0] === "diff") {
311
+ if (rest.length !== 2 || !values.from || values.write || values["any-file"] || values["any-instance"] || values.standalone)
312
+ throw new CommandArgumentError("snapshot diff BEFORE_FOLDER --from AFTER_FOLDER [--out FILE]");
313
+ const diff = diffStructuralSnapshots(await readStructuralSnapshot(projectRoot(), rest[1]), await readStructuralSnapshot(projectRoot(), values.from));
314
+ if (values.out)
315
+ await writeDesignSpec(projectRoot(), values.out, renderStructuralDiff(diff));
316
+ output.result(diff);
317
+ if (!values.json)
318
+ output.log(renderStructuralDiff(diff));
319
+ return !diff.complete ? 3 : diff.totalChanges ? 2 : 0;
320
+ }
321
+ if (command === "snapshot" && (rest.length || values.from))
322
+ throw new CommandArgumentError("snapshot [--write] [--out FOLDER], or snapshot diff BEFORE --from AFTER");
323
+ if (command === "spec" && values.from) {
324
+ if (rest.length > 1 || rest.length === 1 && rest[0] !== "check" || values.page || values.frame || values.standalone || values["any-file"] || values["any-instance"])
325
+ throw new CommandArgumentError("Offline spec requires --from INSPECTION [--map MAPPING] [--out FILE]; spec check also requires --out");
326
+ const inspection = await readDesignJson(projectRoot(), values.from);
327
+ const mapping = values.map ? validateComponentMap(await readDesignJson(projectRoot(), values.map, 1024 * 1024)) : emptyComponentMap;
328
+ if (rest[0] === "check") {
329
+ if (!values.out)
330
+ throw new CommandArgumentError("spec check requires --out and --from");
331
+ const file = await import("./project-input.js").then(m => m.resolveProjectInput(projectRoot(), values.out));
332
+ const report = checkDesignSpec(await readFile(file, "utf8"), inspection, mapping);
333
+ output.result(report);
334
+ output.log(JSON.stringify(report));
335
+ return report.current ? 0 : 2;
336
+ }
337
+ const spec = buildDesignSpec(inspection, mapping), markdown = renderDesignSpec(spec);
338
+ if (values.out)
339
+ await writeDesignSpec(projectRoot(), values.out, markdown);
340
+ output.result(spec);
341
+ if (!values.json)
342
+ output.log(markdown);
343
+ return 0;
344
+ }
345
+ if (command === "spec" && (rest.length || !values.page || !values.frame))
346
+ throw new CommandArgumentError("Live spec requires --page and --frame ID (or --from for offline generation)");
347
+ if (command === "baselines" && rest[0] === "coverage") {
348
+ if (rest.length !== 1 || values.apply || values.update || values["any-file"] || values["any-instance"] || values.standalone)
349
+ throw new CommandArgumentError("baselines coverage is an offline report with optional --json");
350
+ const config = await loadCommandConfig(projectRoot());
351
+ if (!config.baselines)
352
+ throw new Error("Configure baselines before reporting coverage");
353
+ const report = await readBaselineCoverage(projectRoot(), config.baselines, config.file?.id);
354
+ output.result(report);
355
+ if (!values.json)
356
+ output.log(JSON.stringify(report, null, 2));
357
+ return report.designCasesComplete ? 0 : 3;
358
+ }
359
+ if (command === "kit" || command === "recipes") {
360
+ if (command === "kit" ? rest.length !== 1 || rest[0] !== "check" : rest.length)
361
+ throw new CommandArgumentError("Use kit check, or recipes, with optional ordered --include aliases");
362
+ const kits = await loadKits(projectRoot(), values.include ?? ["@kit"]);
363
+ output.result(command === "kit" ? checkKits(kits) : { recipes: kits.flatMap(value => value.recipes.map(recipe => ({ ...recipe, include: value.include }))), includes: kits.map(value => value.include) });
364
+ if (!values.json)
365
+ output.log(command === "kit" ? JSON.stringify(checkKits(kits), null, 2) : kits.flatMap(value => value.recipes.map(recipe => `${value.include} ${recipe.name}: ${recipe.description}`)).join("\n"));
121
366
  return 0;
122
367
  }
123
368
  if (command === "tokens" && rest[0] === "diff") {
124
369
  const [, beforeFile, afterFile] = rest;
125
370
  if (!beforeFile || !afterFile || !values.theme)
126
371
  throw new Error("usage: penpair tokens diff <before.json> <after.json> --theme <name>");
127
- const load = async (file) => JSON.parse(await readFile(file, "utf8"));
372
+ const load = async (file) => parseTokenDump(JSON.parse(await readFile(file, "utf8")));
128
373
  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;
374
+ output.log(JSON.stringify(diff, null, 2));
375
+ return !diff.complete ? 3 : diff.added.length + diff.removed.length + diff.changed.length === 0 ? 0 : 2;
376
+ }
377
+ const root = projectRoot();
378
+ const log = (message) => output.error(`… ${message}`);
379
+ if (values.standalone) {
380
+ const inspection = ["inspect", "pages", "tree", "audit", "export", "shots"].includes(command) || (command === "probe" && !values.deep) || (command === "tokens" && rest[0] === "dump");
381
+ if (await loadOptionalConfig(root) || !inspection || values.apply || values.write)
382
+ 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.");
131
383
  }
132
- const root = process.cwd();
133
- const log = (message) => console.error(`… ${message}`);
134
384
  /** What `doctor` needs to look for the editor and plugin, or nothing when no endpoint is set yet. */
135
385
  async function mcpProbeFor() {
136
386
  try {
137
- const endpoint = await resolveEndpoint();
387
+ const endpoint = await projectEndpoint(root);
388
+ commandRedactor(endpointRedactor(endpoint));
138
389
  return {
139
390
  origin: endpoint.description.origin,
140
391
  connect: async () => {
141
392
  const client = await connect(endpoint);
142
393
  try {
143
- const { result } = await executeCode(client, "return { file: penpot.currentFile.name };", { retries: 0 });
144
- return { file: String(result.file ?? "?") };
394
+ const { result } = await executeCode(client, "return { file: penpot.currentFile.name, fileId: penpot.currentFile.id };", { retries: 0, effect: "read" });
395
+ const actual = result;
396
+ return { file: String(actual.file ?? "?"), fileId: actual.fileId };
145
397
  }
146
398
  finally {
147
399
  await client.close();
@@ -153,30 +405,28 @@ async function main(argv, cli) {
153
405
  return undefined;
154
406
  }
155
407
  }
156
- async function registryFor() {
408
+ async function registryFor(requested) {
157
409
  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
164
- }
410
+ if (values["builtins-only"] || requested && registry.list().some((entry) => entry.name === requested))
411
+ return { registry, problems: [], files: [] };
412
+ const snippets = (await loadOptionalConfig(root))?.snippets;
165
413
  const report = await loadProjectSnippets(root, snippets, registry);
166
414
  return { registry, problems: report.problems, files: report.files };
167
415
  }
168
416
  const connectIo = () => ({
169
- say: (line) => console.log(line),
417
+ say: (line) => output.log(line),
170
418
  askHidden,
171
419
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
420
+ confirmFile: async (file) => values["pin-file"] === true || /^(y|yes)$/i.test(await askLine(`Pin this project's file to "${file.name}" (${file.id})? [y/N] `, "n")),
172
421
  openFile: async (url) => {
173
422
  const endpoint = await resolveEndpoint({ environment: { PENPAIR_MCP_URL: url }, envFiles: [] });
423
+ commandRedactor(endpointRedactor(endpoint));
174
424
  try {
175
425
  const client = await connect(endpoint);
176
426
  try {
177
- const { result } = await executeCode(client, "return { file: penpot.currentFile.name, pages: penpot.currentFile.pages.map((p) => p.name) };", { retries: 0 });
427
+ const { result } = await executeCode(client, "return { file: penpot.currentFile.name, fileId: penpot.currentFile.id, pages: penpot.currentFile.pages.map((p) => p.name) };", { retries: 0, effect: "read" });
178
428
  const read = result;
179
- return { file: String(read.file ?? "?"), pages: read.pages ?? [] };
429
+ return { file: String(read.file ?? "?"), fileId: read.fileId, pages: read.pages ?? [] };
180
430
  }
181
431
  finally {
182
432
  await client.close();
@@ -189,9 +439,9 @@ async function main(argv, cli) {
189
439
  });
190
440
  if (command === "setup" || command === "connect") {
191
441
  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.");
442
+ const project = await loadCommandConfig(root);
443
+ await connectProject(root, project, connectIo(), { replace: values.replace === true, anyFile: values["any-file"] === true });
444
+ output.log("Next: `penpair doctor`, then `penpair skills install` if you have not yet.");
195
445
  return 0;
196
446
  }
197
447
  let where;
@@ -207,85 +457,160 @@ async function main(argv, cli) {
207
457
  log,
208
458
  local: cli.local,
209
459
  installSkills: async () => {
210
- const report = await installSkills({ root });
211
- console.log(`Installed ${report.installed.length} rule file(s) in .skills/ (${report.unchanged.length} already there). Add these rows to your AGENTS.md skills table:\n${report.agentsRows.join("\n")}`);
460
+ const report = await installSkills({ root, agents: values.agents });
461
+ output.log(`Installed ${report.installed.length} rule file(s) in ${report.to}/ (${report.unchanged.length} already there).`);
462
+ if (report.instructions?.path)
463
+ output.log(`Instructions: ${report.instructions.path} (${report.instructions.status})`);
464
+ else
465
+ output.log(`Choose an instruction destination with skills install --agents <path>. Rows:\n${report.agentsRows.join("\n")}`);
212
466
  },
213
467
  });
214
468
  return 0;
215
469
  }
216
470
  if (command === "skills") {
217
471
  if (rest[0] === "install") {
218
- const report = await installSkills({ root, to: values.to, only: values.only, force: values.force });
472
+ const report = await installSkills({ root, to: values.to, only: values.only, force: values.force, agents: values.agents, dryRun: values["dry-run"] });
473
+ output.result(report);
474
+ if (report.instructions?.path)
475
+ output.log(`instructions: ${report.instructions.path} (${report.instructions.status})`);
476
+ if (report.dryRun) {
477
+ for (const file of report.wouldInstall ?? [])
478
+ output.log(`would write ${file}`);
479
+ for (const file of report.wouldLink ?? [])
480
+ output.log(`would link ${file}`);
481
+ if (report.instructions?.diff)
482
+ output.log(report.instructions.diff);
483
+ }
484
+ if (report.instructions?.status === "choice-required")
485
+ output.error("Both AGENTS.md and CLAUDE.md exist; choose --agents <path> explicitly. Instruction files were not changed.");
219
486
  for (const file of report.installed)
220
- console.log(`wrote ${file}`);
487
+ output.log(`wrote ${file}`);
221
488
  for (const file of report.kept)
222
- console.log(`kept ${file} (differs from the shipped one; --force replaces it, except the project-owned files)`);
489
+ output.log(`kept ${file} (differs from the shipped one; --force replaces it, except the project-owned files)`);
223
490
  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"));
491
+ output.log(`linked ${link}`);
492
+ output.log(`${report.unchanged.length} file(s) already up to date`);
493
+ if (!report.instructions?.path)
494
+ output.log("\nChoose --agents <path> to integrate these rows:\n" + report.agentsRows.join("\n"));
227
495
  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;
496
+ 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.`);
497
+ output.log("\nThen fill in references/project-facts.md in penpair-design: it is yours and is never replaced.");
498
+ return report.instructions?.status === "choice-required" ? 3 : 0;
231
499
  }
232
- for (const name of await listBundledSkills())
233
- console.log(name);
500
+ const bundled = await listBundledSkills();
501
+ output.result({ skills: bundled });
502
+ for (const name of bundled)
503
+ output.log(name);
234
504
  return 0;
235
505
  }
236
506
  if (command === "snippets") {
237
507
  const { registry, problems, files } = await registryFor();
238
508
  if (rest[0] === "check") {
239
509
  const found = [...problems, ...checkProjectSnippets(registry)];
510
+ output.result({ files, problems: found, valid: found.length === 0 });
240
511
  const project = registry.list().filter((entry) => entry.source === "project");
241
- console.log(`${project.length} project snippet(s) from ${files.length} file(s)`);
512
+ output.log(`${project.length} project snippet(s) from ${files.length} file(s)`);
242
513
  for (const problem of found)
243
- console.log(`FAIL ${problem}`);
514
+ output.log(`FAIL ${problem}`);
244
515
  if (found.length === 0)
245
- console.log("ok every snippet is valid plugin code and small enough");
516
+ output.log("ok every snippet is valid plugin code and small enough");
246
517
  return found.length === 0 ? 0 : 1;
247
518
  }
519
+ output.result({ snippets: registry.list().map(({ name, description, kind, params, source }) => ({ name, description, kind, params, source })), problems, complete: problems.length === 0 });
248
520
  for (const entry of registry.list()) {
249
- console.log(`${entry.name.padEnd(14)} ${entry.kind === "write" ? "changes " : "reads "} ${entry.source === "project" ? "[project] " : ""}${entry.description}`);
521
+ output.log(`${entry.name.padEnd(14)} ${entry.kind === "write" ? "changes " : "reads "} ${entry.source === "project" ? "[project] " : ""}${entry.description}`);
250
522
  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}` : ""}`);
523
+ output.log(`${"".padEnd(14)} --param ${p.name}=<${p.type}>${p.required ? " (required)" : p.default !== undefined ? ` (default ${JSON.stringify(p.default)})` : ""}${p.description ? ` ${p.description}` : ""}`);
252
524
  }
253
525
  for (const problem of problems)
254
- console.error(`warning: ${problem}`);
526
+ output.error(`warning: ${problem}`);
255
527
  return 0;
256
528
  }
257
529
  if (command === "init") {
258
530
  const result = await init(root, { instance: values.instance, port: values.port ? Number(values.port) : undefined, gitignore: values.gitignore, external: values.external }, { local: cli.local });
531
+ output.result({ createdConfig: result.createdConfig, createdSecrets: result.createdSecrets, instance: result.config.instance, files: result.files, gitignoreAdded: result.gitignoreAdded });
259
532
  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: penpair connect (takes your personal key without showing it, then waits for the editor)");
533
+ output.log(`${result.createdConfig ? "created" : "kept"} penpair.config.json (external Penpot at ${result.config.profile.origin})`);
534
+ 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
535
  return 0;
263
536
  }
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`);
537
+ output.log(`${result.createdConfig ? "created" : "kept"} penpair.config.json (instance ${result.config.instance}, Penpot ${result.config.penpotVersion}, port ${result.config.ports.frontend})`);
538
+ output.log(`wrote ${result.config.directory}/docker-compose.yaml; secrets ${result.createdSecrets ? "generated" : "kept"} in ${result.config.directory}/.env`);
266
539
  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");
540
+ output.log(`keep ${result.config.directory}/ out of git (add it to .gitignore, or run init with --gitignore)`);
541
+ output.log("next: penpair up, then penpair connect");
269
542
  return 0;
270
543
  }
271
544
  /** 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
545
  async function report(checks, mcp) {
273
546
  const keyCheck = await keyFileCheck(root, nodeRunner);
274
- const all = keyCheck ? [...checks, keyCheck] : checks;
547
+ const backupCheck = await backupNoteCheck(root, await loadCommandConfig(root));
548
+ const all = [...checks, ...(!mcp ? [{ check: "editor and plugin", ok: false, detail: "No resolved project credential/connection; editor identity was not observed", remedy: "Configure the private credential source, connect the intended editor, then rerun doctor." }] : []), ...(keyCheck ? [keyCheck] : []), ...(backupCheck ? [backupCheck] : [])];
275
549
  const ok = all.every((check) => check.ok);
276
550
  if (values.json) {
277
- console.log(JSON.stringify({ ok, mcpConfigured: Boolean(mcp), checks: all }, null, 2));
551
+ output.log(JSON.stringify({ ok, mcpConfigured: Boolean(mcp), file: all.find((check) => check.file)?.file, checks: all }, null, 2));
278
552
  return ok ? 0 : 1;
279
553
  }
280
554
  for (const check of all)
281
- console.log(`${check.ok ? "ok " : "FAIL"} ${check.check}: ${check.detail}${"remedy" in check && check.remedy ? `\n → ${check.remedy}` : ""}`);
555
+ output.log(`${check.warning ? "WARN" : check.ok ? "ok " : "FAIL"} ${check.check}: ${check.detail}${"remedy" in check && check.remedy ? `\n → ${check.remedy}` : ""}`);
282
556
  if (!mcp)
283
- console.log("skip editor and plugin: no key yet. Run `penpair connect`.");
557
+ output.log("skip editor and plugin: no key yet. Run `penpair connect`.");
284
558
  return ok ? 0 : 1;
285
559
  }
560
+ if (command === "open") {
561
+ if (rest.length)
562
+ throw new CommandArgumentError("open accepts only --print for a URL preview");
563
+ const config = await loadCommandConfig(root), url = values.print ? projectFileUrl(config) : await openProject(config, openInBrowser);
564
+ output.result({ url, opened: !values.print });
565
+ if (!values.json)
566
+ output.log(url);
567
+ return 0;
568
+ }
569
+ if (command === "status") {
570
+ if (rest.length)
571
+ throw new CommandArgumentError("status accepts optional --adapters");
572
+ const config = await loadCommandConfig(root);
573
+ let endpoint;
574
+ try {
575
+ endpoint = await projectEndpoint(root);
576
+ commandRedactor(endpointRedactor(endpoint));
577
+ assertEndpointIsInstance(endpoint.description, config);
578
+ }
579
+ catch (error) {
580
+ if (endpoint)
581
+ throw error;
582
+ }
583
+ const report = await projectStatus(root, config, {
584
+ ...(endpoint ? { connection: async () => guardFile(await connect(endpoint), config.file, { mode: "strict" }), redact: endpointRedactor(endpoint) } : {}),
585
+ ...(values.adapters ? { adapterStatus: async () => {
586
+ const loaded = await loadAdapters(root, config.adapters ?? []);
587
+ if (loaded.problems.length)
588
+ throw new Error(loaded.problems.join("; "));
589
+ const context = await adapterContext(root, []);
590
+ return (await Promise.all(loaded.adapters.map(async (a) => a.status ? a.status(context) : [{ id: a.name, label: a.name, state: "unknown", detail: "No status hook" }]))).flat();
591
+ } } : {}),
592
+ });
593
+ let localState = null;
594
+ if (!isExternal(config)) {
595
+ if (cli.local) {
596
+ const state = await cli.local.status({ root, config, runner: nodeRunner, log });
597
+ localState = state;
598
+ report.lines.push({ id: "local-runtime", label: "Local containers", state: state.frontendReachable && state.backendReady ? "ok" : "failed", detail: `${state.url}; backend ${state.backendReady ? "ready" : "not ready"}` });
599
+ }
600
+ else
601
+ report.lines.push({ id: "local-runtime", label: "Local containers", state: "unknown", detail: "Install the local-instance command package for container diagnostics" });
602
+ report.complete = report.lines.every(line => line.state !== "unknown");
603
+ report.ok = report.complete && report.lines.every(line => line.state === "ok");
604
+ }
605
+ output.result({ ...report, localState });
606
+ if (!values.json)
607
+ for (const line of report.lines)
608
+ output.log(`${line.state.padEnd(7)} ${line.label}: ${line.detail}`);
609
+ return report.lines.some(l => l.state === "failed") ? 1 : report.ok ? 0 : 3;
610
+ }
286
611
  const lifecycle = new Set(["up", "down", "status", "doctor", "backup", "restore"]);
287
- if (lifecycle.has(command)) {
288
- const project = await loadConfig(root);
612
+ if (lifecycle.has(command) && !(command === "backup" && rest[0] === "file")) {
613
+ const project = await loadCommandConfig(root);
289
614
  if (isExternal(project) && command !== "doctor")
290
615
  requireLocal(project);
291
616
  if (isExternal(project)) {
@@ -296,19 +621,19 @@ async function main(argv, cli) {
296
621
  const c = { root, config: requireLocal(project), runner: nodeRunner, log };
297
622
  if (command === "up") {
298
623
  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\`.`);
624
+ output.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\`.`);
300
625
  return result.ready ? 0 : 1;
301
626
  }
302
627
  if (command === "down") {
303
628
  await local.down(c);
304
- console.log("stopped; the data volumes are kept");
629
+ output.log("stopped; the data volumes are kept");
305
630
  return 0;
306
631
  }
307
632
  if (command === "status") {
308
633
  const state = await local.status(c);
309
634
  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"}`);
635
+ output.log(`${s.state.padEnd(10)} ${s.service}${s.health ? ` (${s.health})` : ""}`);
636
+ output.log(`${state.frontendReachable ? "answers" : "does not answer"} at ${state.url}; backend ${state.backendReady ? "ready" : "not ready"}`);
312
637
  return state.frontendReachable && state.backendReady ? 0 : 1;
313
638
  }
314
639
  if (command === "doctor") {
@@ -321,11 +646,11 @@ async function main(argv, cli) {
321
646
  throw new Error("usage: penpair backup verify <backup directory>");
322
647
  const result = await local.verifyBackup(c, rest[1]);
323
648
  for (const check of result.checks)
324
- console.log(`${check.ok ? "ok " : "FAIL"} ${check.check}: ${check.detail}`);
649
+ output.log(`${check.ok ? "ok " : "FAIL"} ${check.check}: ${check.detail}`);
325
650
  return result.ok ? 0 : 1;
326
651
  }
327
652
  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.`);
653
+ output.log(`backup in ${result.directory} (${result.manifest.counts.files} files, ${result.manifest.counts.storageObjects} assets). It holds account data: never commit it.`);
329
654
  return 0;
330
655
  }
331
656
  if (command === "restore") {
@@ -333,35 +658,60 @@ async function main(argv, cli) {
333
658
  throw new Error("usage: penpair restore <backup directory> --yes");
334
659
  const result = await local.restore(c, rest[0], { yes: values.yes === true });
335
660
  for (const check of result.checks)
336
- console.log(`${check.ok ? "ok " : "FAIL"} ${check.check}: ${check.detail}`);
661
+ output.log(`${check.ok ? "ok " : "FAIL"} ${check.check}: ${check.detail}`);
337
662
  if (result.safetyBackup)
338
- console.log(`safety backup of the previous data: ${result.safetyBackup}`);
663
+ output.log(`safety backup of the previous data: ${result.safetyBackup}`);
339
664
  return result.ok ? 0 : 1;
340
665
  }
341
666
  }
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);
348
- }
349
- catch {
350
- // not a penpair project: any endpoint will do
667
+ if (command === "run-file" && rest.length !== 1)
668
+ throw new CommandArgumentError("run-file requires exactly one plugin body path");
669
+ const projectConfig = values.standalone ? await loadOptionalConfig(root) : await loadCommandConfig(root);
670
+ if (values.standalone && projectConfig)
671
+ throw new ConfigError("--standalone cannot bypass a project configuration.");
672
+ let preparedFile;
673
+ if (command === "run-file") {
674
+ const params = Object.create(null);
675
+ for (const pair of values.param ?? []) {
676
+ const at = pair.indexOf("=");
677
+ if (at < 1)
678
+ throw new CommandArgumentError("--param needs key=value");
679
+ const key = pair.slice(0, at);
680
+ if (Object.hasOwn(params, key))
681
+ throw new CommandArgumentError("Duplicate script parameter: " + key);
682
+ params[key] = pair.slice(at + 1);
351
683
  }
352
- if (projectConfig)
353
- assertEndpointIsInstance(endpoint.description, projectConfig);
684
+ const aliases = (values.include ?? []).filter(include => include.startsWith("@"));
685
+ const kits = aliases.length ? await loadKits(root, aliases) : [];
686
+ const virtualIncludes = Object.fromEntries(kits.map(value => [value.include, value.source]));
687
+ preparedFile = await prepareFileScript(root, rest[0], { virtualIncludes, include: values.include, inspect: values.inspect, readback: values.readback, page: values.page, params });
688
+ if (preparedFile.kind === "read" && values.apply)
689
+ throw new CommandArgumentError("Read files do not require or accept --apply");
354
690
  }
691
+ const endpoint = await projectEndpoint(root);
692
+ commandRedactor(endpointRedactor(endpoint));
693
+ if (projectConfig && !values["any-instance"])
694
+ assertEndpointIsInstance(endpoint.description, projectConfig);
695
+ const guarded = async () => guardFile(await connect(endpoint), projectConfig?.file, {
696
+ anyFile: values["any-file"] === true,
697
+ mode: !commandProjects.getStore()?.strictFileGuard && (["pages", "tree", "audit", "tokens"].includes(command) || command === "probe" && !values.deep && !values.integrity) ? "warn" : "strict",
698
+ warn: log,
699
+ });
355
700
  if (command === "probe") {
356
701
  if (values.deep && !values.write)
357
702
  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));
703
+ const capabilityReport = await probe(endpoint, guarded, { integrity: values.integrity === true });
704
+ output.log(JSON.stringify(capabilityReport, null, 2));
705
+ if (capabilityReport.integrity?.supported && capabilityReport.integrity.valid === false)
706
+ return 1;
359
707
  if (values.deep) {
360
- const client = await connect(endpoint);
708
+ const client = await guarded();
361
709
  try {
362
- const report = await runDeepCheck(client, { onWait: (m) => console.error(`… ${m}`) });
363
- console.log(`\n${formatDeepReport(report)}`);
364
- return report.ok ? 0 : 1;
710
+ const report = await runDeepCheck(client, { onWait: (m) => output.error(`… ${m}`) });
711
+ const kitReport = values.kit && report.ok ? await runKitSmoke(client, root, { apply: true, onStep: log, redact: endpointRedactor(endpoint) }) : undefined;
712
+ output.result({ ...report, ...(kitReport ? { kit: kitReport } : {}), capabilities: capabilityReport });
713
+ output.log(`\n${formatDeepReport(report)}`);
714
+ return report.ok && (!kitReport || kitReport.verified) ? 0 : 1;
365
715
  }
366
716
  finally {
367
717
  await client.close();
@@ -369,16 +719,45 @@ async function main(argv, cli) {
369
719
  }
370
720
  return 0;
371
721
  }
372
- const client = await connect(endpoint);
373
- const options = { onWait: (m) => console.error(`… ${m}`), onStep: (m) => console.error(`… ${m}`) };
722
+ const client = await guarded();
723
+ commandRedactor(endpointRedactor(endpoint));
724
+ const options = { signal: commandSignal(), redact: endpointRedactor(endpoint), onWait: (m) => output.error(`… ${m}`), onStep: (m) => output.error(`… ${m}`) };
725
+ const writeOptions = (operation) => ({
726
+ ...options, apply: values.apply === true, redact: (text) => redactEndpoint(text, endpoint),
727
+ ...(values.apply ? { journal: {
728
+ root, forceLock: values["force-lock"] === true, runId: values.resume, resume: Boolean(values.resume), requireCheckpoint: values.checkpoint === true,
729
+ ...(values.checkpoint ? { checkpoint: (runId) => createCheckpoint(client, { label: operation, runId }, { apply: true }) } : {}),
730
+ } } : {}),
731
+ });
374
732
  try {
733
+ if (command === "backup" && rest[0] === "file") {
734
+ const out = rest[1];
735
+ if (!out || !out.endsWith(".penpot"))
736
+ throw new Error("usage: penpair backup file <new.penpot>");
737
+ const archive = await exportFileArchive(client);
738
+ if (archive.status !== "exported") {
739
+ output.error(redactEndpoint(archive.message, endpoint));
740
+ return 1;
741
+ }
742
+ await atomicWriteFile(out, archive.bytes);
743
+ output.log(JSON.stringify({ file: out, fileId: archive.fileId, revn: archive.revn, bytes: archive.bytes.length, sha256: archive.sha256, restoreVerified: false }, null, 2));
744
+ output.error("Archive bytes saved. Restore is not verified: import into a scratch project and compare content and renders.");
745
+ return 0;
746
+ }
747
+ if (command === "run-file") {
748
+ const report = await runFileScript(client, preparedFile, writeOptions("run-file"));
749
+ output.log(JSON.stringify(report, null, 2));
750
+ if (report.phase === "inspected")
751
+ output.error("Nothing was written. Review the inspection, then use --apply.");
752
+ return report.complete === false ? 3 : report.phase === "applied" && !report.verified ? 1 : 0;
753
+ }
375
754
  if (command === "run") {
376
755
  const [name] = rest;
377
756
  if (!name)
378
757
  throw new Error("usage: penpair run <snippet> [--param key=value ...] [--apply]");
379
- const { registry, problems } = await registryFor();
758
+ const { registry, problems } = await registryFor(name);
380
759
  for (const problem of problems)
381
- console.error(`warning: ${problem}`);
760
+ output.error(`warning: ${problem}`);
382
761
  const raw = {};
383
762
  for (const pair of values.param ?? []) {
384
763
  const at = pair.indexOf("=");
@@ -390,68 +769,190 @@ async function main(argv, cli) {
390
769
  raw[key] = earlier === undefined ? value : [...(Array.isArray(earlier) ? earlier : [earlier]), value];
391
770
  }
392
771
  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));
772
+ if (entry.kind === "read" && (values.resume || values.checkpoint))
773
+ throw new Error("Read snippets have no write run to resume or checkpoint");
774
+ const result = await registry.run(client, name, coerceParams(entry.params, raw), writeOptions(name));
775
+ output.log(JSON.stringify(result, null, 2));
395
776
  if (entry.kind === "write" && !values.apply)
396
- console.error("Nothing was written. Run again with --apply to do it.");
777
+ output.error("Nothing was written. Run again with --apply to do it.");
397
778
  return result.phase === "applied" && !result.verified ? 1 : 0;
398
779
  }
399
780
  if (command === "baselines") {
400
- const config = (await loadConfig(root)).baselines;
781
+ const config = (await loadCommandConfig(root)).baselines;
401
782
  if (!config)
402
783
  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 });
784
+ const report = await exportBaselines(client, root, config, { ...options, apply: values.apply === true, update: values.update === true, forceReason: values["force-reason"] });
785
+ output.result(report);
404
786
  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" : ""}`);
787
+ output.log(`${r.status.padEnd(9)} ${r.id.padEnd(28)} ${r.frame}${r.imageWidth ? ` (${r.imageWidth}x${r.imageHeight})` : ""}${r.written ? " written" : ""}`);
406
788
  for (const file of report.wrote)
407
- console.log(`wrote ${file}`);
789
+ output.log(`wrote ${file}`);
408
790
  const changed = report.results.filter((r) => r.status === "changed" && !r.written);
409
791
  if (!values.apply)
410
- console.error("Nothing was written. Run again with --apply to write new images.");
792
+ output.error("Nothing was written. Run again with --apply to write new images.");
411
793
  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;
794
+ 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.`);
795
+ for (const row of report.coverage.rows)
796
+ if (row.status !== "approved-design-intent")
797
+ 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 : ""}`);
798
+ if (!report.complete || report.coverage.configured && !report.coverage.designCasesComplete)
799
+ return 3;
415
800
  return changed.length > 0 ? 2 : 0;
416
801
  }
802
+ if (command === "audit" && (values.rules || projectConfig?.audit)) {
803
+ if (!values.page || !projectConfig?.audit || rest.length || values.prefix || values.steps || values.ignore || values["token-source"])
804
+ throw new CommandArgumentError("Configured audit requires --page and explicit config rules, without legacy filters");
805
+ const shapes = await captureInspection(client, { kind: "shape", shapeId: "@page-root", page: values.page, depth: 10000, maxNodes: 50000 });
806
+ const tokens = projectConfig.audit.contrast ? await captureInspection(client, { kind: "tokens" }) : undefined;
807
+ if (!shapes.complete || tokens && !tokens.complete) {
808
+ output.result({ complete: false, reason: "Audit inspection incomplete" });
809
+ return 3;
810
+ }
811
+ const report = auditDesignRules(shapes, projectConfig.audit, tokens);
812
+ output.result(report);
813
+ output.log(JSON.stringify(report, null, 2));
814
+ return report.complete ? 0 : 3;
815
+ }
417
816
  if (command === "pages")
418
- console.log(JSON.stringify(await runSnippet(client, pages(), options), null, 2));
817
+ output.log(JSON.stringify(await runSnippet(client, pages(), options), null, 2));
419
818
  else if (command === "tree") {
420
819
  if (!values.page)
421
820
  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"));
821
+ const record = await runOnPage(client, tree({ page: values.page, depth: values.depth ? Number(values.depth) : 3, limit: values.limit ? Number(values.limit) : 400, namePrefix: values.prefix }), values.page, options);
822
+ if (values.json)
823
+ output.log(JSON.stringify(record));
824
+ else
825
+ output.log((record.lines ?? []).join("\n"));
826
+ if (record.truncated === true) {
827
+ output.error("Tree is a limited preview; use inspect --all for full text or narrow the tree scope.");
828
+ return 3;
829
+ }
424
830
  }
425
831
  else if (command === "audit") {
426
832
  if (!values.page || !values.prefix)
427
833
  throw new Error("--page and --prefix are required");
428
834
  const steps = values.steps ? values.steps.split(",").map(Number) : undefined;
429
835
  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));
836
+ output.log(JSON.stringify(record, null, 2));
837
+ if (record.truncated === true)
838
+ return 3;
431
839
  }
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));
840
+ else if (command === "snapshot") {
841
+ const snapshot = await captureStructuralSnapshot(client, options);
842
+ if (values.write) {
843
+ const manifest = await writeStructuralSnapshot(root, values.out ?? "design/snapshots", snapshot, endpointRedactor(endpoint));
844
+ output.result(manifest);
845
+ output.log(JSON.stringify(manifest, null, 2));
846
+ }
847
+ else {
848
+ output.result(snapshot);
849
+ output.log(JSON.stringify(snapshot, null, 2));
850
+ }
851
+ return 0;
852
+ }
853
+ else if (command === "approve") {
854
+ if (rest.length || !values.page || !values.frame || values.write && (!values.note?.trim() || !values.by?.trim()) || !values.write && (values.note || values.by))
855
+ throw new CommandArgumentError("approve --page PAGE --frame ID [--case CASE_ID] previews evidence; --write requires --note exact-owner-words and --by identity");
856
+ let intent;
857
+ if (values.case) {
858
+ const baseline = projectConfig?.baselines, wanted = baseline?.frames.find(f => f.id === values.case);
859
+ if (!wanted?.case || baseline?.page !== values.page)
860
+ throw new Error("Approval case must reference an explicitly configured frame on this page");
861
+ const found = await findShapesByName(client, wanted.frame, values.page);
862
+ if (found.matches.length !== 1 || found.matches[0].id !== values.frame)
863
+ throw new Error("Approval frame ID does not match the configured case");
864
+ intent = wanted.case;
865
+ }
866
+ const captured = await exportShapePng(client, values.frame, { ...options, page: values.page, stable: true });
867
+ if (!captured.stable)
868
+ throw new Error("Approval export did not stabilize");
869
+ const sha256 = artifactSha(captured.png);
870
+ const report = values.write ? await recordApproval(root, captured, { note: values.note, by: values.by }, intent, endpointRedactor(endpoint)) : { schemaVersion: 1, kind: "approval-candidate", capture: captured.capture, sha256, fingerprint: approvalFingerprint(captured.capture, sha256, intent), recorded: false };
871
+ output.result(report);
872
+ output.log(JSON.stringify(report, null, 2));
873
+ return 0;
874
+ }
875
+ else if (command === "spec") {
876
+ const capture = await captureInspection(client, { shapeId: values.frame, page: values.page, kind: "shape" });
877
+ if (!capture.complete || capture.kind !== "shape-inspection") {
878
+ output.result(capture);
879
+ return 3;
880
+ }
881
+ const mapping = values.map ? validateComponentMap(await readDesignJson(root, values.map, 1024 * 1024)) : emptyComponentMap;
882
+ const spec = buildDesignSpec(capture, mapping), markdown = renderDesignSpec(spec);
883
+ if (values.out)
884
+ await writeDesignSpec(root, values.out, markdown, endpointRedactor(endpoint));
885
+ output.result(spec);
886
+ if (!values.json)
887
+ output.log(markdown);
888
+ return 0;
889
+ }
890
+ else if (command === "inspect" || command === "tokens" && rest[0] === "dump") {
891
+ const { query, budget } = inspectionRequest;
892
+ const result = values.cursor || command === "inspect" && !values.all ? await inspectChunk(client, query) : await captureInspection(client, query, budget);
893
+ const redact = endpointRedactor(endpoint), fragments = endpointRedactor(endpoint, true);
894
+ let altered = redact(JSON.stringify(result)) !== JSON.stringify(result);
895
+ const chunks = result.kind === "inspection-chunk" ? [result] : result.kind === "inspection-partial" ? result.chunks : [];
896
+ for (const chunk of chunks) {
897
+ const safe = fragments(chunk.chunk);
898
+ if (safe !== chunk.chunk) {
899
+ altered = true;
900
+ chunk.chunk = safe;
901
+ }
902
+ }
903
+ if (altered) {
904
+ result.complete = false;
905
+ Object.assign(result, { redacted: true, reason: "Output required credential redaction; source evidence is incomplete" });
906
+ if ("nextCursor" in result)
907
+ result.nextCursor = null;
908
+ }
909
+ output.log(JSON.stringify(result, null, 2));
910
+ if (!result.complete)
911
+ return 3;
912
+ if (values.out) {
913
+ const serialized = JSON.stringify(result, null, 2) + "\n";
914
+ const target = await resolveProjectOutput(root, values.out);
915
+ await mkdir(path.dirname(target), { recursive: true });
916
+ await atomicWriteFile(target, serialized, { replace: true });
917
+ }
918
+ return 0;
435
919
  }
436
920
  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)`);
921
+ const [shapeId, out] = values.name ? [undefined, rest[0]] : rest;
922
+ if (!out || rest.length !== (values.name ? 1 : 2) || values.name && !values.page)
923
+ throw new Error("usage: penpair export <shapeId> <out.png>, or export --name <exact-name> --page <name> <out.png>");
924
+ const exportOptions = { ...options, page: values.page, scale: values.scale ? Number(values.scale) : 1 };
925
+ const result = values.name ? await exportShapeByName(client, values.name, values.page, exportOptions) : await exportShapePng(client, shapeId, exportOptions);
926
+ if (!result.stable)
927
+ throw new PenpairError("incomplete-output", "Export did not stabilize; previous output was preserved");
928
+ await atomicWriteFile(out, result.png, { replace: true });
929
+ output.log(JSON.stringify({ file: out, bytes: result.png.length, stable: result.stable, exports: result.exports, layout: result.layout, capture: result.capture }, null, 2));
930
+ }
931
+ else if (command === "shots") {
932
+ if (!values.page || rest.length)
933
+ throw new Error("usage: penpair shots --page <name> [--prefix <prefix>] [--out design/shots]");
934
+ const report = await captureShots(client, root, { page: values.page, prefix: values.prefix, out: values.out }, { ...options, scale: values.scale ? Number(values.scale) : 1 });
935
+ output.log(JSON.stringify(report, null, 2));
936
+ return report.complete ? 0 : 3;
937
+ }
938
+ else if (command === "tokens" && tokenEdit) {
939
+ const report = await runOperation(client, tokenEditOperation, tokenEdit, writeOptions("tokens-edit"));
940
+ output.log(JSON.stringify(report, null, 2));
941
+ if (!values.apply)
942
+ output.error("Nothing was written. Run again with --apply to execute this token edit.");
943
+ return report.phase === "applied" && !report.verified ? 1 : 0;
443
944
  }
444
945
  else if (command === "archive") {
445
946
  if (!values.page || !values.name)
446
947
  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));
948
+ 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 }, writeOptions("archive"));
949
+ output.log(JSON.stringify(report, null, 2));
449
950
  if (report.phase === "inspected")
450
- console.error("Nothing was written. Run again with --apply to do it.");
951
+ output.error("Nothing was written. Run again with --apply to do it.");
451
952
  return report.phase === "applied" && !report.verified ? 1 : 0;
452
953
  }
453
954
  else {
454
- console.log(usage(Boolean(cli.local), cli.adapters));
955
+ output.log(usage(Boolean(cli.local), cli.adapters, cli.configuredAdapters));
455
956
  return 1;
456
957
  }
457
958
  return 0;
@@ -461,18 +962,23 @@ async function main(argv, cli) {
461
962
  }
462
963
  }
463
964
  /** 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);
965
+ async function adapterContext(root, args) {
966
+ const config = await loadCommandConfig(root);
466
967
  const rawConfig = await readRawConfig(root);
968
+ let redact = (text) => redactEndpoint(text);
467
969
  const context = {
970
+ redact: (text) => redact(text),
468
971
  root,
469
972
  config,
470
973
  rawConfig,
471
- log: (message) => console.error(`… ${message}`),
974
+ output,
975
+ log: (message) => output.error(`… ${message}`),
472
976
  async withPenpot(work) {
473
- const endpoint = await resolveEndpoint();
977
+ const endpoint = await projectEndpoint(root);
978
+ redact = (text) => redactEndpoint(text, endpoint);
979
+ commandRedactor(endpointRedactor(endpoint));
474
980
  assertEndpointIsInstance(endpoint.description, config);
475
- const client = await connect(endpoint);
981
+ const client = guardFile(await connect(endpoint), config.file, { anyFile: args.includes("--any-file"), warn: context.log });
476
982
  try {
477
983
  return await work(client);
478
984
  }
@@ -481,27 +987,152 @@ async function runAdapterCommand(command, root, args) {
481
987
  }
482
988
  },
483
989
  };
484
- return command.run(context, args);
990
+ return context;
991
+ }
992
+ async function runAdapterCommand(command, root, args) {
993
+ const context = await adapterContext(root, args);
994
+ commandSignal()?.throwIfAborted();
995
+ return command.run(context, args.filter((arg) => arg !== "--any-file"));
485
996
  }
486
997
  /** 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 = {}) {
998
+ async function runCliInternal(argv, options = {}) {
488
999
  try {
489
- const root = process.cwd();
1000
+ const root = projectRoot();
1001
+ if (argv[0] === "onboard") {
1002
+ if (argv.slice(1).some((arg) => arg !== "--json"))
1003
+ throw new Error("usage: penpair onboard [--json]");
1004
+ const report = onboardingChecklist(await loadCommandConfig(root));
1005
+ if (argv.includes("--json"))
1006
+ output.log(JSON.stringify(report, null, 2));
1007
+ else {
1008
+ output.log(`Join ${report.project}: ${report.origin}`);
1009
+ if (report.fileUrl)
1010
+ output.log(`File: ${report.fileUrl}`);
1011
+ report.steps.forEach((step, index) => output.log(`${index + 1}. ${step}`));
1012
+ report.warnings.forEach((warning) => output.log(`Note: ${warning}`));
1013
+ }
1014
+ return 0;
1015
+ }
1016
+ if (argv[0] === "runs") {
1017
+ if (argv.filter((arg) => arg !== "--json").length > 2 || argv[1]?.startsWith("--") && argv[1] !== "--json")
1018
+ throw new Error("usage: penpair runs [run-id]");
1019
+ output.log(JSON.stringify(argv[1] && argv[1] !== "--json" ? await readJournal(root, argv[1]) : await listJournals(root), null, 2));
1020
+ return 0;
1021
+ }
1022
+ if (argv[0] === "config") {
1023
+ if (argv[1] === "schema" && argv.slice(2).every((arg) => arg === "--json")) {
1024
+ output.log(JSON.stringify(projectConfigSchema, null, 2));
1025
+ return 0;
1026
+ }
1027
+ if (argv[1] !== "check" || argv.slice(2).some((arg) => arg !== "--json"))
1028
+ throw new ConfigError("usage: penpair config check [--json] | penpair config schema");
1029
+ try {
1030
+ await loadCommandConfig(root);
1031
+ }
1032
+ catch (error) {
1033
+ if (!argv.includes("--json") || !(error instanceof ConfigError))
1034
+ throw error;
1035
+ commandFailure(error);
1036
+ output.log(JSON.stringify({ valid: false, file: "penpair.config.json", error: { code: error.code, message: error.message } }));
1037
+ return 1;
1038
+ }
1039
+ output.log(argv.includes("--json") ? JSON.stringify({ valid: true, file: "penpair.config.json" }) : "ok penpair.config.json is valid (extension settings are validated by their adapters)");
1040
+ return 0;
1041
+ }
490
1042
  const raw = await readRawConfig(root);
491
1043
  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: [] };
493
1044
  const [first] = argv;
1045
+ const builtIn = first === undefined || first.startsWith("--") || reservedCommands.includes(first);
1046
+ if (builtIn)
1047
+ return await main(argv, { ...options, configuredAdapters: specifiers });
1048
+ // Dynamic discovery is necessary only for a requested extension command; it executes trusted project modules.
1049
+ const loaded = options.adapters ? { adapters: options.adapters, problems: [] } : await loadAdapters(root, specifiers);
494
1050
  const adapterCommand = loaded.adapters.flatMap((adapter) => adapter.commands).find((command) => command.name === first);
495
1051
  if (adapterCommand)
496
1052
  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 });
1053
+ for (const problem of loaded.problems)
1054
+ output.error(`warning: ${problem}`);
1055
+ return await main(argv, { ...options, adapters: loaded.adapters, configuredAdapters: specifiers });
502
1056
  }
503
1057
  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;
1058
+ commandFailure(error);
1059
+ output.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)}`);
1060
+ const value = error;
1061
+ return value.code === "write-uncertain" || value.outcome === "uncertain" ? 4 : value.code === "incomplete-output" ? 3 : value.code === "cancelled" ? 130 : value.code === "deadline" ? 124 : 1;
1062
+ }
1063
+ }
1064
+ export async function runCommand(argv, options = {}) {
1065
+ const args = [...argv.filter((arg) => arg !== "--json"), "--json"];
1066
+ return collectCommand(args[0] ?? "help", async () => {
1067
+ if (["setup", "connect"].includes(argv[0] ?? ""))
1068
+ throw new Error("Interactive setup/connect require a terminal; run without --json and use doctor --json afterwards");
1069
+ return commandProjects.run({ root: path.resolve(options.root ?? projectRoot()), projectEndpointOnly: options.projectEndpointOnly, pinnedConfig: options.pinnedConfig, strictFileGuard: options.strictFileGuard }, () => runCliInternal(args, options));
1070
+ }, { signal: options.signal });
1071
+ }
1072
+ async function runCliBound(argv, options = {}) {
1073
+ if (argv[0] === "mcp") {
1074
+ try {
1075
+ const parsed = parseCommandArgs({ args: argv.slice(1), allowPositionals: true, options: { root: { type: "string" }, "allow-writes": { type: "boolean" } } });
1076
+ if (!parsed.values.root || !path.isAbsolute(parsed.values.root) || parsed.positionals.length)
1077
+ throw new Error("mcp requires an explicit absolute --root PROJECT; optionally --allow-writes");
1078
+ const { serveMcp } = await import("./mcp-server.js");
1079
+ await serveMcp({ root: parsed.values.root, allowWrites: parsed.values["allow-writes"] === true, cli: options });
1080
+ return 0;
1081
+ }
1082
+ catch (error) {
1083
+ console.error(redactEndpoint(String(error.message)));
1084
+ return 1;
1085
+ }
1086
+ }
1087
+ const controller = new AbortController();
1088
+ const interrupt = () => controller.abort(new CommandInterruptedError("cancelled"));
1089
+ let timer;
1090
+ const args = [...argv];
1091
+ const timeoutAt = args.indexOf("--timeout-ms");
1092
+ let invalidTimeout = false;
1093
+ if (timeoutAt !== -1) {
1094
+ const milliseconds = Number(args[timeoutAt + 1]);
1095
+ invalidTimeout = !Number.isInteger(milliseconds) || milliseconds < 1 || milliseconds > 1_800_000 || args.indexOf("--timeout-ms", timeoutAt + 1) !== -1;
1096
+ args.splice(timeoutAt, 2);
1097
+ if (!invalidTimeout)
1098
+ timer = setTimeout(() => controller.abort(new CommandInterruptedError("deadline")), milliseconds);
1099
+ }
1100
+ const json = args.includes("--json");
1101
+ if (!json && ["setup", "connect"].includes(args[0] ?? "")) {
1102
+ if (timer)
1103
+ clearTimeout(timer);
1104
+ if (timeoutAt !== -1) {
1105
+ console.error("--timeout-ms is not supported for interactive setup/connect");
1106
+ return 1;
1107
+ }
1108
+ return withCommandSignal(controller.signal, () => runCliInternal(args, options));
1109
+ }
1110
+ process.on("SIGINT", interrupt);
1111
+ process.on("SIGTERM", interrupt);
1112
+ try {
1113
+ if (invalidTimeout) {
1114
+ const result = await collectCommand(args[0] ?? "help", async () => { throw new Error("--timeout-ms must be one integer from 1 to 1800000"); });
1115
+ if (json)
1116
+ console.log(JSON.stringify(result, null, 2));
1117
+ else
1118
+ console.error(result.errors[0]?.message);
1119
+ return result.exitCode;
1120
+ }
1121
+ return await withCommandSignal(controller.signal, async () => {
1122
+ if (!json)
1123
+ return runCliInternal(args, options);
1124
+ const result = await runCommand(args, { ...options, signal: controller.signal });
1125
+ console.log(JSON.stringify(result, null, 2));
1126
+ return result.exitCode;
1127
+ });
506
1128
  }
1129
+ finally {
1130
+ if (timer)
1131
+ clearTimeout(timer);
1132
+ process.removeListener("SIGINT", interrupt);
1133
+ process.removeListener("SIGTERM", interrupt);
1134
+ }
1135
+ }
1136
+ export async function runCli(argv, options = {}) {
1137
+ return commandProjects.run({ root: path.resolve(options.root ?? projectRoot()), projectEndpointOnly: options.projectEndpointOnly, pinnedConfig: options.pinnedConfig, strictFileGuard: options.strictFileGuard }, () => runCliBound(argv, options));
507
1138
  }