@penpair/core 0.1.1 → 0.1.3

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.
@@ -0,0 +1,49 @@
1
+ import type { PenpotToolClient } from "./client.js";
2
+ import { type ProjectConfig } from "./instance/config.js";
3
+ /**
4
+ * An adapter is a package that teaches penpair about one library or tool (a component library's tokens, a checker) by adding
5
+ * commands. A project lists the adapters it uses under `adapters` in penpair.config.json; the core loads them by name from the
6
+ * project and knows nothing about any of them. An adapter keeps its own settings in its own block of the same file.
7
+ */
8
+ export interface AdapterContext {
9
+ /** The project's folder. */
10
+ root: string;
11
+ /** The project's validated configuration. */
12
+ config: ProjectConfig;
13
+ /** The whole penpair.config.json as written, for the adapter's own block. */
14
+ rawConfig: Record<string, unknown>;
15
+ log(message: string): void;
16
+ /**
17
+ * Connects to the project's Penpot and runs `work` with the connection, closing it afterwards. The endpoint must belong to this
18
+ * project's Penpot, as for every command. Not called by commands that work offline.
19
+ */
20
+ withPenpot<T>(work: (client: PenpotToolClient) => Promise<T>): Promise<T>;
21
+ }
22
+ export interface AdapterCommand {
23
+ /** The word after `penpair`, for example `hello`. It must not be a command the core already has. */
24
+ name: string;
25
+ /** One line for `penpair --help`. */
26
+ summary: string;
27
+ /** Lines of usage for `penpair --help`. */
28
+ usage: string[];
29
+ /** Runs with the arguments after the command's name; returns the exit code. The adapter parses its own flags. */
30
+ run(context: AdapterContext, args: string[]): Promise<number>;
31
+ }
32
+ export interface Adapter {
33
+ name: string;
34
+ commands: AdapterCommand[];
35
+ }
36
+ /** Declares an adapter. Returns the definition unchanged, so a module can `export default defineAdapter({...})` with types checked. */
37
+ export declare const defineAdapter: <T extends Adapter>(adapter: T) => T;
38
+ /** The commands of the core itself; an adapter cannot take one of these names. */
39
+ export declare const reservedCommands: readonly ["probe", "pages", "tree", "audit", "tokens", "export", "archive", "snippets", "run", "skills", "init", "setup", "connect", "up", "down", "status", "doctor", "backup", "restore", "baselines"];
40
+ export interface LoadedAdapters {
41
+ adapters: Adapter[];
42
+ problems: string[];
43
+ }
44
+ export type AdapterImporter = (specifier: string, root: string) => Promise<unknown>;
45
+ /** Resolves an adapter the way the project would: a relative path from the project's folder, or a package installed in the project. */
46
+ export declare const importFromProject: AdapterImporter;
47
+ export declare function loadAdapters(root: string, specifiers: string[] | undefined, importer?: AdapterImporter): Promise<LoadedAdapters>;
48
+ /** The whole penpair.config.json as written (an adapter's own settings live in it). Empty when there is no file. */
49
+ export declare function readRawConfig(root: string): Promise<Record<string, unknown>>;
@@ -0,0 +1,71 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { configFileName } from "./instance/config.js";
6
+ /** Declares an adapter. Returns the definition unchanged, so a module can `export default defineAdapter({...})` with types checked. */
7
+ export const defineAdapter = (adapter) => adapter;
8
+ /** The commands of the core itself; an adapter cannot take one of these names. */
9
+ export const reservedCommands = [
10
+ "probe", "pages", "tree", "audit", "tokens", "export", "archive", "snippets", "run", "skills", "init", "setup", "connect",
11
+ "up", "down", "status", "doctor", "backup", "restore", "baselines",
12
+ ];
13
+ /** Resolves an adapter the way the project would: a relative path from the project's folder, or a package installed in the project. */
14
+ export const importFromProject = async (specifier, root) => {
15
+ let file;
16
+ if (specifier.startsWith(".") || path.isAbsolute(specifier))
17
+ file = path.resolve(root, specifier);
18
+ else {
19
+ try {
20
+ file = createRequire(path.join(root, "package.json")).resolve(specifier);
21
+ }
22
+ catch {
23
+ const error = Object.assign(new Error(`${specifier} is not installed in this project`), { code: "ERR_MODULE_NOT_FOUND" });
24
+ throw error;
25
+ }
26
+ }
27
+ return import(pathToFileURL(file).href);
28
+ };
29
+ export async function loadAdapters(root, specifiers, importer = importFromProject) {
30
+ const adapters = [];
31
+ const problems = [];
32
+ const taken = new Map();
33
+ for (const specifier of specifiers ?? []) {
34
+ let loaded;
35
+ try {
36
+ loaded = (await importer(specifier, root));
37
+ }
38
+ catch (error) {
39
+ const code = error.code;
40
+ problems.push(code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND"
41
+ ? `adapter ${specifier} is not installed in this project. Install it (for example \`npm i -D ${specifier}\`).`
42
+ : `adapter ${specifier} could not be loaded: ${String(error.message).split("\n")[0]}`);
43
+ continue;
44
+ }
45
+ const adapter = loaded.default;
46
+ if (!adapter || typeof adapter.name !== "string" || !Array.isArray(adapter.commands)) {
47
+ problems.push(`adapter ${specifier} does not export a default adapter (use \`export default defineAdapter({...})\`)`);
48
+ continue;
49
+ }
50
+ for (const command of adapter.commands) {
51
+ if (reservedCommands.includes(command.name))
52
+ problems.push(`adapter ${specifier}: the command "${command.name}" is one the core already has`);
53
+ else if (taken.has(command.name))
54
+ problems.push(`adapter ${specifier}: the command "${command.name}" is already provided by ${taken.get(command.name)}`);
55
+ else
56
+ taken.set(command.name, adapter.name);
57
+ }
58
+ adapters.push({ ...adapter, commands: adapter.commands.filter((c) => taken.get(c.name) === adapter.name) });
59
+ }
60
+ return { adapters, problems };
61
+ }
62
+ /** The whole penpair.config.json as written (an adapter's own settings live in it). Empty when there is no file. */
63
+ export async function readRawConfig(root) {
64
+ try {
65
+ const parsed = JSON.parse(await readFile(path.join(root, configFileName), "utf8"));
66
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
67
+ }
68
+ catch {
69
+ return {};
70
+ }
71
+ }
@@ -1,5 +1,8 @@
1
+ import { type Adapter } from "./adapters.js";
1
2
  import { type LocalProfile } from "./local-profile.js";
2
3
  export interface CliOptions {
4
+ /** Adapters already loaded (the command loads the project's own from penpair.config.json). */
5
+ adapters?: Adapter[];
3
6
  /** A way to run Penpot on this computer, from a package that provides one (`@penpair/docker`). Without it those commands explain where they come from. */
4
7
  local?: LocalProfile;
5
8
  }
package/dist/cli-main.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { writeFile, readFile } from "node:fs/promises";
2
+ import path from "node:path";
2
3
  import { parseArgs } from "node:util";
3
4
  import { connect, executeCode } from "./client.js";
4
5
  import { redactEndpoint, resolveEndpoint } from "./endpoint.js";
@@ -19,6 +20,7 @@ import { init } from "./instance/init.js";
19
20
  import { ConnectError, connectProject, keyFileCheck } from "./instance/connect.js";
20
21
  import { hostedOrigin, setup } from "./instance/setup.js";
21
22
  import { askHidden, askLine, openInBrowser } from "./instance/tty.js";
23
+ import { loadAdapters, readRawConfig, reservedCommands } from "./adapters.js";
22
24
  import { requireLocalProfile } from "./local-profile.js";
23
25
  import { assertEndpointIsInstance, doctorExternal, LifecycleError } from "./instance/connection.js";
24
26
  import { nodeRunner } from "./instance/runner.js";
@@ -42,7 +44,7 @@ Read-only:
42
44
  penpair export <shapeId> <out.png> [--page <name>] [--scale 1]
43
45
  @@INSTANCE@@Skills (the rules an AI agent follows, shipped with penpair):
44
46
  penpair skills list them
45
- penpair skills install [--to .skills] [--only <name>] [--force] copy them into the project and make them discoverable
47
+ penpair skills install [--to <folder>] [--only <name>] [--force] copy them into the project and make them discoverable
46
48
  Snippets (the built-in ones and the project's own, listed under "snippets" in penpair.config.json):
47
49
  penpair snippets what can be run, with its parameters
48
50
  penpair snippets check validate the project's snippets without a connection
@@ -67,7 +69,9 @@ const usageConnection = `Connecting to a Penpot (one project, configured in penp
67
69
  penpair init --external <origin> use a Penpot someone else runs (a team server or https://design.penpot.app)
68
70
  penpair doctor [--json] what is up, what is not, and what to do about it (--json for scripts and agents)
69
71
  `;
70
- const usage = (hasLocal) => usageHead.replace("@@INSTANCE@@", usageConnection + (hasLocal ? usageLocalInstance : usageNoLocalInstance)).replace("@@SETUP@@", hasLocal ? " penpair setup --docker Penpot on this computer in Docker\n" : "");
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);
73
+ /** 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`;
71
75
  const list = (value) => (value ?? "").split(",").map((v) => v.trim()).filter(Boolean);
72
76
  async function main(argv, cli) {
73
77
  const { values, positionals } = parseArgs({
@@ -113,7 +117,7 @@ async function main(argv, cli) {
113
117
  return 0;
114
118
  }
115
119
  if (!command || values.help) {
116
- console.log(usage(Boolean(cli.local)));
120
+ console.log(usage(Boolean(cli.local), cli.adapters));
117
121
  return 0;
118
122
  }
119
123
  if (command === "tokens" && rest[0] === "diff") {
@@ -204,7 +208,7 @@ async function main(argv, cli) {
204
208
  local: cli.local,
205
209
  installSkills: async () => {
206
210
  const report = await installSkills({ root });
207
- 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")}`);
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")}`);
208
212
  },
209
213
  });
210
214
  return 0;
@@ -220,7 +224,9 @@ async function main(argv, cli) {
220
224
  console.log(`linked ${link}`);
221
225
  console.log(`${report.unchanged.length} file(s) already up to date`);
222
226
  console.log("\nAdd to your AGENTS.md skills table:\n" + report.agentsRows.join("\n"));
223
- console.log("\nThen fill in references/project-facts.md in penpot-design: it is yours and is never replaced.");
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.");
224
230
  return 0;
225
231
  }
226
232
  for (const name of await listBundledSkills())
@@ -252,7 +258,7 @@ async function main(argv, cli) {
252
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 });
253
259
  if (isExternal(result.config)) {
254
260
  console.log(`${result.createdConfig ? "created" : "kept"} penpair.config.json (external Penpot at ${result.config.profile.origin})`);
255
- console.log("next: penpair connect (takes your personal key without showing it, then waits for the editor)");
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)");
256
262
  return 0;
257
263
  }
258
264
  console.log(`${result.createdConfig ? "created" : "kept"} penpair.config.json (instance ${result.config.instance}, Penpot ${result.config.penpotVersion}, port ${result.config.ports.frontend})`);
@@ -445,7 +451,7 @@ async function main(argv, cli) {
445
451
  return report.phase === "applied" && !report.verified ? 1 : 0;
446
452
  }
447
453
  else {
448
- console.log(usage(Boolean(cli.local)));
454
+ console.log(usage(Boolean(cli.local), cli.adapters));
449
455
  return 1;
450
456
  }
451
457
  return 0;
@@ -454,10 +460,45 @@ async function main(argv, cli) {
454
460
  await client.close();
455
461
  }
456
462
  }
463
+ /** 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);
466
+ const rawConfig = await readRawConfig(root);
467
+ const context = {
468
+ root,
469
+ config,
470
+ rawConfig,
471
+ log: (message) => console.error(`… ${message}`),
472
+ async withPenpot(work) {
473
+ const endpoint = await resolveEndpoint();
474
+ assertEndpointIsInstance(endpoint.description, config);
475
+ const client = await connect(endpoint);
476
+ try {
477
+ return await work(client);
478
+ }
479
+ finally {
480
+ await client.close();
481
+ }
482
+ },
483
+ };
484
+ return command.run(context, args);
485
+ }
457
486
  /** 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. */
458
487
  export async function runCli(argv, options = {}) {
459
488
  try {
460
- return await main(argv, options);
489
+ const root = process.cwd();
490
+ 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: [] };
493
+ const [first] = argv;
494
+ const adapterCommand = loaded.adapters.flatMap((adapter) => adapter.commands).find((command) => command.name === first);
495
+ if (adapterCommand)
496
+ 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 });
461
502
  }
462
503
  catch (error) {
463
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)}`);
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { version } from "./version.js";
2
+ export * from "./adapters.js";
2
3
  export * from "./baselines.js";
3
4
  export { runCli } from "./cli-main.js";
4
5
  export * from "./client.js";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { version } from "./version.js";
2
+ export * from "./adapters.js";
2
3
  export * from "./baselines.js";
3
4
  export { runCli } from "./cli-main.js";
4
5
  export * from "./client.js";
@@ -17,6 +17,8 @@ export interface InstanceConfig {
17
17
  snippets?: string[];
18
18
  /** Frames of a page to export as approved reference images for a check (see `penpair baselines`). */
19
19
  baselines?: BaselinesConfig;
20
+ /** Packages (or relative paths) that add commands for a library or tool; each keeps its own settings in its own block of this file. */
21
+ adapters?: string[];
20
22
  }
21
23
  /** A project that uses a Penpot someone else runs (a team server or the hosted service): no Docker here, only the connection. */
22
24
  export interface ExternalConfig {
@@ -27,6 +29,7 @@ export interface ExternalConfig {
27
29
  };
28
30
  snippets?: string[];
29
31
  baselines?: BaselinesConfig;
32
+ adapters?: string[];
30
33
  }
31
34
  export type ProjectConfig = InstanceConfig | ExternalConfig;
32
35
  export declare const isExternal: (config: ProjectConfig) => config is ExternalConfig;
@@ -39,6 +39,8 @@ export function validateConfig(value) {
39
39
  }
40
40
  if (v.baselines !== undefined)
41
41
  validateBaselines(v.baselines, fail);
42
+ if (v.adapters !== undefined && !(Array.isArray(v.adapters) && v.adapters.every((e) => typeof e === "string" && e.length > 0)))
43
+ fail("adapters must be a list of package names or relative paths");
42
44
  return v;
43
45
  }
44
46
  const instanceNamePattern = /^[a-z][a-z0-9-]{0,30}$/;
@@ -67,6 +69,8 @@ export function validateProjectConfig(value) {
67
69
  fail("snippets must be a list of relative paths that stay inside the project");
68
70
  if (v.baselines !== undefined)
69
71
  validateBaselines(v.baselines, fail);
72
+ if (v.adapters !== undefined && !(Array.isArray(v.adapters) && v.adapters.every((e) => typeof e === "string" && e.length > 0)))
73
+ fail("adapters must be a list of package names or relative paths");
70
74
  return v;
71
75
  }
72
76
  /** The config of a project that runs its own Penpot; anything else cannot start, stop or back up containers. */
@@ -51,6 +51,6 @@ export async function setup(root, io, options) {
51
51
  io.say("Everything answers. Last: the rules your assistant follows.");
52
52
  if (options.installSkills)
53
53
  await options.installSkills();
54
- io.say("Done. Try `penpair pages`, or tell your assistant to read the penpot-design skill and start.");
54
+ io.say("Done. Try `penpair pages`, or tell your assistant to read the penpair-design skill and start.");
55
55
  return { config, connect };
56
56
  }
package/dist/skills.d.ts CHANGED
@@ -20,7 +20,11 @@ export interface InstallOptions {
20
20
  /** For tests. */
21
21
  source?: string;
22
22
  }
23
+ /** Skills that changed name. Installing the new one leaves the old folder behind, which `installSkills` then reports. */
24
+ export declare const renamedSkills: Record<string, string>;
23
25
  export interface InstallReport {
26
+ /** The folder the skills went to, relative to the root. */
27
+ to: string;
24
28
  installed: string[];
25
29
  unchanged: string[];
26
30
  /** Files that differ from the shipped ones and were left alone. */
@@ -28,5 +32,13 @@ export interface InstallReport {
28
32
  linked: string[];
29
33
  /** One line per skill for the project's AGENTS.md. */
30
34
  agentsRows: string[];
35
+ /** Folders of skills that were renamed and are still in the project, and whether their project-facts file was carried over. */
36
+ legacy: {
37
+ folder: string;
38
+ renamedTo: string;
39
+ carriedFacts: boolean;
40
+ }[];
31
41
  }
42
+ /** Where the penpair skills already live in a project as real folders (not links), so a later install goes to the same place. */
43
+ export declare function existingSkillsFolder(root: string): Promise<string | undefined>;
32
44
  export declare function installSkills(options: InstallOptions): Promise<InstallReport>;
package/dist/skills.js CHANGED
@@ -44,15 +44,28 @@ export async function describeSkill(skillDirectory) {
44
44
  const description = /^description:\s*(.+)$/m.exec(text)?.[1]?.trim() ?? "";
45
45
  return { name, summary: description.split(/(?<=\.)\s/)[0] ?? description };
46
46
  }
47
+ /** Skills that changed name. Installing the new one leaves the old folder behind, which `installSkills` then reports. */
48
+ export const renamedSkills = { "penpot-design": "penpair-design" };
49
+ /** Where the penpair skills already live in a project as real folders (not links), so a later install goes to the same place. */
50
+ export async function existingSkillsFolder(root) {
51
+ for (const folder of [".skills", ".claude/skills", ".agents/skills"]) {
52
+ for (const name of ["penpair-design", "penpair-setup", ...Object.keys(renamedSkills)]) {
53
+ const found = await lstat(path.join(root, folder, name)).catch(() => undefined);
54
+ if (found?.isDirectory())
55
+ return folder;
56
+ }
57
+ }
58
+ return undefined;
59
+ }
47
60
  export async function installSkills(options) {
48
61
  const source = options.source ?? bundledSkillsDirectory();
49
- const to = options.to ?? ".skills";
62
+ const to = options.to ?? (await existingSkillsFolder(options.root)) ?? ".skills";
50
63
  const names = await listBundledSkills(source);
51
64
  const chosen = options.only ? options.only : names;
52
65
  for (const name of chosen)
53
66
  if (!names.includes(name))
54
67
  throw new Error(`no skill named ${name}; shipped: ${names.join(", ")}`);
55
- const report = { installed: [], unchanged: [], kept: [], linked: [], agentsRows: [] };
68
+ const report = { to, installed: [], unchanged: [], kept: [], linked: [], agentsRows: [], legacy: [] };
56
69
  for (const name of chosen) {
57
70
  const from = path.join(source, name);
58
71
  for (const file of await filesUnder(from)) {
@@ -87,5 +100,18 @@ export async function installSkills(options) {
87
100
  const { summary } = await describeSkill(from);
88
101
  report.agentsRows.push(`| [${name}](${path.posix.join(to, name, "SKILL.md")}) | ${summary} |`);
89
102
  }
103
+ for (const [oldName, newName] of Object.entries(renamedSkills)) {
104
+ if (!chosen.includes(newName) || !(await exists(path.join(options.root, to, oldName))))
105
+ continue;
106
+ // The project-facts file is the project's own work: carry it over when the new copy is still the empty template.
107
+ const oldFacts = await readIfExists(path.join(options.root, to, oldName, "references/project-facts.md"));
108
+ const newFactsFile = path.join(options.root, to, newName, "references/project-facts.md");
109
+ const template = await readIfExists(path.join(source, newName, "references/project-facts.md"));
110
+ const current = await readIfExists(newFactsFile);
111
+ const carriedFacts = oldFacts !== undefined && oldFacts !== template && current === template;
112
+ if (carriedFacts)
113
+ await writeFile(newFactsFile, oldFacts);
114
+ report.legacy.push({ folder: path.posix.join(to, oldName), renamedTo: newName, carriedFacts });
115
+ }
90
116
  return report;
91
117
  }
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Kept in its own file so that the command line and the library entry can both read it without importing each other. Equals package.json (a test checks). */
2
- export declare const version = "0.1.1";
2
+ export declare const version = "0.1.3";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Kept in its own file so that the command line and the library entry can both read it without importing each other. Equals package.json (a test checks). */
2
- export const version = "0.1.1";
2
+ export const version = "0.1.3";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@penpair/core",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Design together. Build together. The library and engine behind penpair: connect AI agents and people to a Penpot design file, safely. Install @penpair/web or @penpair/docker for the command.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -20,8 +20,8 @@
20
20
  "types": "./dist/index.d.ts",
21
21
  "exports": {
22
22
  ".": {
23
- "import": "./dist/index.js",
24
- "types": "./dist/index.d.ts"
23
+ "types": "./dist/index.d.ts",
24
+ "import": "./dist/index.js"
25
25
  },
26
26
  "./package.json": "./package.json"
27
27
  },
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: design-judgment
3
- description: Decide how a screen is structured, filled and judged before it is built. Use when designing or redesigning a page or flow (list, detail, queue, dashboard, admin), choosing a composition or density, deciding what fills a card or panel, defining loading, empty and error states, mapping status or feedback to components, comparing layout options, or critiquing a design or a rendered screen. Do not use for writing to Penpot (penpot-design) or for picking and verifying components of a specific library.
3
+ description: Decide how a screen is structured, filled and judged before it is built. Use when designing or redesigning a page or flow (list, detail, queue, dashboard, admin), choosing a composition or density, deciding what fills a card or panel, defining loading, empty and error states, mapping status or feedback to components, comparing layout options, or critiquing a design or a rendered screen. Do not use for writing to Penpot (penpair-design) or for picking and verifying components of a specific library.
4
4
  metadata:
5
5
  version: "0.1.0"
6
6
  scope: design reasoning
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: penpot-design
2
+ name: penpair-design
3
3
  description: Work in a Penpot file through penpair: inspect, build, change, audit, export and archive frames and components, bind every value to a token, keep layout as flat auto-layout, and run Penpot as one instance per project. Use when creating, editing, inspecting or exporting anything in Penpot, adding or binding tokens, writing a project snippet, setting up or checking a Penpot instance, or diagnosing a plugin or connection error. Do not use for deciding how a screen should be structured (design-judgment).
4
4
  metadata:
5
5
  version: "0.1.0"
@@ -8,7 +8,7 @@ metadata:
8
8
  compatibility: A running Penpot (local Docker, a team server or the hosted service) with the plugin connected in a visible editor tab, Node 22, penpair installed.
9
9
  ---
10
10
 
11
- # penpot-design
11
+ # penpair-design
12
12
 
13
13
  Penpot is the authority for the design; the code adapts to the frames. The project's component library defines what exists: a component, token or layout the library has wins, and only what it lacks becomes a project-specific component or token.
14
14
 
@@ -43,6 +43,8 @@ Each one cost real time. They are written as the behaviour to use. Penpot 2.18.3
43
43
  - **Exports can fail once and then work, and can be stale right after a write or a page switch.** Export again, and compare two in a row.
44
44
  - The MCP key is personal, one per account, shown once. A key for another instance silently drives another file; penpair refuses it unless told otherwise.
45
45
  - A snippet receives data only through its parameters; it cannot read repository files.
46
+ - **Token values can be changed in place** (`token.value = ...`, `token.description = ...`), and `token.remove()`, `set.remove()`, `theme.addSet(set)`, `theme.removeSet(set)` and `theme.remove()` all work. **Not for a typography token:** creating one normalises it (`fontFamilies: "Inter"` becomes `fontFamily: ["Inter"]`), but assigning a new value stores the object as given, un-normalised. Remove the token and add it again, and compare typography values in their normalised form. (Checked on Penpot 2.18.4, hosted.)
47
+ - A new token set is inactive and a set's `resolvedValue` is null until a theme activates it; a file with no active theme resolves no tokens at all.
46
48
 
47
49
  ## Rendering differences to design for
48
50
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: penpair-setup
3
- description: Set up penpair in a project and connect it to a Penpot, on the web (the default) or in Docker on this computer. Use when a project has no penpair.config.json, when `penpair doctor` reports a failure, when the user asks to install, start, connect, repair or move their Penpot connection, or when an agent is told "set up penpair". Do not use for editing a Penpot file (penpot-design) or deciding how a screen looks (design-judgment).
3
+ description: Set up penpair in a project and connect it to a Penpot, on the web (the default) or in Docker on this computer. Use when a project has no penpair.config.json, when `penpair doctor` reports a failure, when the user asks to install, start, connect, repair or move their Penpot connection, or when an agent is told "set up penpair". Do not use for editing a Penpot file (penpair-design) or deciding how a screen looks (design-judgment).
4
4
  metadata:
5
5
  version: "0.1.0"
6
6
  scope: setup and connection
@@ -39,7 +39,7 @@ The MCP key is the person's password to their Penpot. Keep it out of chat, logs,
39
39
  | editor and plugin: not connected or suspended | The tab is asleep or the plugin is not connected. Do step 4.4 again; keep the tab in front. |
40
40
  | key file: git would commit it | Add `.env.local` to `.gitignore`; `penpair connect` does it. |
41
41
 
42
- 6. **Install the rules.** Run `npx @penpair/web skills install`, add the printed rows to the project's agent guide, then fill in `.skills/penpot-design/references/project-facts.md` with the person (the Penpot file, token sets, naming, who approves). Done when that file has no `<...>` placeholders left.
42
+ 6. **Install the rules.** Run `npx @penpair/web skills install`, add the printed rows to the project's agent guide, then fill in `.skills/penpair-design/references/project-facts.md` with the person (the Penpot file, token sets, naming, who approves). Done when that file has no `<...>` placeholders left.
43
43
 
44
44
  ## Moving between web and Docker
45
45