synthesisui 0.16.11 → 0.16.12

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,104 @@
1
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ const HOOK_MATCHER = "Write|Edit|MultiEdit";
4
+ const exists = (p) => access(p).then(() => true, () => false);
5
+ async function readJson(path) {
6
+ const raw = await readFile(path, "utf8").catch(() => "");
7
+ if (!raw.trim())
8
+ return {};
9
+ try {
10
+ return JSON.parse(raw);
11
+ }
12
+ catch {
13
+ // A settings file we cannot parse is a file we must not rewrite. Throwing
14
+ // here is right: silently replacing somebody's broken JSON with ours would
15
+ // destroy the very thing they are in the middle of fixing.
16
+ throw new Error(`${path} is not valid JSON. Fix or move it, then run this again - I will not overwrite it.`);
17
+ }
18
+ }
19
+ /**
20
+ * HOW THE HOOK INVOKES THE CLI, AND WHY IT IS MEASURED.
21
+ *
22
+ * This runs after every Write and Edit, so the difference between paths is
23
+ * paid dozens of times a session. Measured 27/07: `npx synthesisui@latest hook`
24
+ * costs 720ms-1.5s, and pinning the version does not help - the overhead is
25
+ * npx itself, not the registry lookup. Running the CLI already present in
26
+ * node_modules is roughly ten times cheaper.
27
+ *
28
+ * So: use the local install when there is one, and fall back to npx pinned to
29
+ * the version being installed now. Pinned rather than `@latest` because a hook
30
+ * whose behaviour changes under you, on every edit, with no diff to read, is
31
+ * not something anyone should have to debug.
32
+ */
33
+ export async function hookCommand(root, version) {
34
+ const local = join(root, "node_modules", ".bin", "synthesisui");
35
+ return (await exists(local))
36
+ ? "npx --no-install synthesisui hook"
37
+ : `npx synthesisui@${version} hook`;
38
+ }
39
+ async function wireHook(root, command) {
40
+ const dir = join(root, ".claude");
41
+ const path = join(dir, "settings.json");
42
+ const settings = await readJson(path);
43
+ const hooks = (settings.hooks ?? {});
44
+ const post = Array.isArray(hooks.PostToolUse) ? hooks.PostToolUse : [];
45
+ // Idempotent on the COMMAND, not on the whole entry: someone may have
46
+ // widened the matcher or added a second hook beside ours, and running this
47
+ // again must not undo that.
48
+ const already = post.some((e) => (e.hooks ?? []).some((h) => (h.command ?? "").includes("synthesisui")));
49
+ if (already)
50
+ return "already there";
51
+ post.push({
52
+ matcher: HOOK_MATCHER,
53
+ hooks: [{ type: "command", command }],
54
+ });
55
+ await mkdir(dir, { recursive: true });
56
+ await writeFile(path, `${JSON.stringify({ ...settings, hooks: { ...hooks, PostToolUse: post } }, null, 2)}\n`, "utf8");
57
+ return "added";
58
+ }
59
+ async function wireMcp(root, version) {
60
+ const path = join(root, ".mcp.json");
61
+ const config = await readJson(path);
62
+ const servers = (config.mcpServers ?? {});
63
+ if (servers.synthesisui)
64
+ return "already there";
65
+ const local = await exists(join(root, "node_modules", ".bin", "synthesisui"));
66
+ await writeFile(path, `${JSON.stringify({
67
+ ...config,
68
+ mcpServers: {
69
+ ...servers,
70
+ synthesisui: {
71
+ type: "stdio",
72
+ command: "npx",
73
+ args: local
74
+ ? ["--no-install", "synthesisui", "mcp"]
75
+ : [`synthesisui@${version}`, "mcp"],
76
+ },
77
+ },
78
+ }, null, 2)}\n`, "utf8");
79
+ return "added";
80
+ }
81
+ export async function wireAgent(root, version, want) {
82
+ const command = await hookCommand(root, version);
83
+ return {
84
+ command,
85
+ hook: want.hook ? await wireHook(root, command) : "skipped",
86
+ mcp: want.mcp ? await wireMcp(root, version) : "skipped",
87
+ };
88
+ }
89
+ /**
90
+ * Whether THIS project already has the hook.
91
+ *
92
+ * Read by the managed CLAUDE.md block, which must stop repeating an
93
+ * instruction the hook already guarantees. Two places asking for the same
94
+ * thing is drift in the instructions, which is a poor look for a tool that
95
+ * measures drift.
96
+ */
97
+ export async function hasHook(root) {
98
+ const settings = await readJson(join(root, ".claude", "settings.json")).catch(() => ({}));
99
+ const post = (settings.hooks ?? {})
100
+ .PostToolUse;
101
+ if (!Array.isArray(post))
102
+ return false;
103
+ return post.some((e) => (e.hooks ?? []).some((h) => (h.command ?? "").includes("synthesisui")));
104
+ }
package/dist/claude-md.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
+ import { hasHook } from "./agent-wiring.js";
3
4
  const START = "<!-- synthesisui:start -->";
4
5
  const END = "<!-- synthesisui:end -->";
5
6
  /** Reads the installed DSs from the .lock files in _synthesisui/ds/<slug>/. */
@@ -93,7 +94,31 @@ async function readManifest(projectRoot, ds) {
93
94
  * Held in one constant on purpose: the same paragraph typed into two template
94
95
  * literals is two paragraphs that drift.
95
96
  */
96
- const SELF_CHECK = `
97
+ /**
98
+ * WHAT THE BLOCK SAYS ABOUT CHECKING DEPENDS ON WHETHER THE HOOK IS THERE.
99
+ *
100
+ * The instruction below was measured being ignored: present, prominent, at
101
+ * line 24 of a 44-line file written three hours before the work started
102
+ * (my-test4, 27/07). "Do X after every Y" is a standing obligation that
103
+ * competes with the task, and 100k tokens in, the task wins.
104
+ *
105
+ * With the hook installed it is worse than useless. The check already runs
106
+ * after every write, so repeating the demand asks for something that has
107
+ * already happened - two places asking for the same thing, which is drift in
108
+ * the instructions, from a tool that measures drift.
109
+ *
110
+ * So the block says one of two things, decided by reading the project's own
111
+ * settings rather than by asking anybody. The refusal is in both, because it
112
+ * is the one rule no mechanism can enforce: a machine can tell you a value has
113
+ * no token, and only a person can decide what to call it.
114
+ */
115
+ const REFUSAL = `
116
+
117
+ If it reports a value with no token, do NOT invent one. Say which value and what
118
+ you would call it, and let a person decide. An agent that quiets the report by
119
+ adding a token is growing the system unsupervised, which is worse than the
120
+ drift because it looks solved.`;
121
+ const SELF_CHECK_MANUAL = `
97
122
 
98
123
  **After writing or editing any UI file, check it before moving on:**
99
124
 
@@ -102,10 +127,18 @@ const SELF_CHECK = `
102
127
  It names the token for every hardcoded value it finds. Fix what it names, run it
103
128
  again, and only then move to the next thing. Do not batch this up for the end -
104
129
  a component checked while you still have its context is a two-line fix; the same
105
- component found in a repo-wide audit next week is an archaeology exercise.
130
+ component found in a repo-wide audit next week is an archaeology exercise.${REFUSAL}
106
131
 
107
- If it reports a value with no token, do NOT invent one. Say which value and what
108
- you would call it, and let a person decide.`;
132
+ Better than remembering: \`npx synthesisui@latest connect\` installs the check as
133
+ an editor hook, so it runs on its own and this paragraph stops being your job.`;
134
+ const SELF_CHECK_HOOKED = `
135
+
136
+ **The check runs by itself.** A hook reports on every file you write, naming the
137
+ token this project already has for anything hardcoded. You do not need to run
138
+ \`doctor\` by hand, and you should not wait for a final pass.
139
+
140
+ When it names something, fix it in that file before moving to the next one -
141
+ you still have the context, so it is a two-line change.${REFUSAL}`;
109
142
  /**
110
143
  * THE INTERFACE LANGUAGE, READ FROM THE PROJECT RATHER THAN ASKED FOR.
111
144
  *
@@ -171,12 +204,15 @@ async function renderRegion(projectRoot, installed) {
171
204
  // `data-ds`; an ADOPTED one is the project's own vocabulary, already wired,
172
205
  // with no `data-ds` anywhere to scope to.
173
206
  const onlyAdopted = installed.every((d) => d.adopted);
207
+ const selfCheck = (await hasHook(projectRoot))
208
+ ? SELF_CHECK_HOOKED
209
+ : SELF_CHECK_MANUAL;
174
210
  const rule = onlyAdopted
175
211
  ? `**When creating or editing components, read the system's GUIDE.md and follow it:** use the
176
212
  project's OWN custom properties, exactly as the guide lists them. Do not write raw colours,
177
213
  spacings or radii that a token already covers, and do not invent a new token silently - say so
178
214
  instead, because a new token is a decision for a person to make. There is no component
179
- manifest for an adopted system - the tokens ARE the contract.${SELF_CHECK}`
215
+ manifest for an adopted system - the tokens ARE the contract.${selfCheck}`
180
216
  : `**When creating or editing components, read the system's GUIDE.md and follow it:** use only semantic tokens
181
217
  (\`var(--ds-color-semantic-*)\`, \`--ds-spacing-*\`, etc.), scope the UI with \`data-ds="<slug>"\`,
182
218
  and reuse the \`.ds-*\` classes. Do not use raw values outside the system's scale.
@@ -193,7 +229,7 @@ names or type them; finding the right component is your job, not theirs.
193
229
  Only write something new when nothing in the manifest covers the purpose - and when you do,
194
230
  say which entry you considered and why it did not fit. To review a
195
231
  component, create an isolated sample page (e.g. \`app/synthesisui-samples/<component>/\`) - do not
196
- apply it to real production pages unless asked.${SELF_CHECK}`;
232
+ apply it to real production pages unless asked.${selfCheck}`;
197
233
  const locale = await readInterfaceLanguage(projectRoot);
198
234
  const language = locale === null
199
235
  ? ""
@@ -0,0 +1,55 @@
1
+ import { wireAgent } from "../agent-wiring.js";
2
+ import { syncClaudeMd } from "../claude-md.js";
3
+ import { body, section, snippet } from "../output.js";
4
+ /**
5
+ * `synthesisui connect` - put the three layers where they actually run.
6
+ *
7
+ * They were all built before anything installed them, so a stranger got none
8
+ * of them and got, instead, the CLAUDE.md instruction we had already measured
9
+ * being ignored.
10
+ *
11
+ * hook guarantees runs after every write, whether the agent likes it or not
12
+ * mcp audits lets the agent ask the system instead of guessing
13
+ * md informs and now stops repeating what the hook already ensures
14
+ *
15
+ * Separate from `init` on purpose. Installing a hook puts a command on
16
+ * somebody's machine that runs after every edit they make; that is a question,
17
+ * not a default, and it deserves its own answer rather than riding along inside
18
+ * a setup command.
19
+ */
20
+ export async function connect(opts) {
21
+ const root = opts.dir ?? process.cwd();
22
+ // Both unless one is explicitly turned off - somebody who says `--no-hook`
23
+ // means it, and somebody who says nothing wants the thing to work.
24
+ const want = { hook: opts.hook !== false, mcp: opts.mcp !== false };
25
+ const wired = await wireAgent(root, opts.version, want);
26
+ // The block reads the settings we just wrote, so it must be regenerated
27
+ // after them, not before.
28
+ await syncClaudeMd(root);
29
+ console.log(section("Connected"));
30
+ if (want.hook) {
31
+ console.log(body(wired.hook === "added"
32
+ ? "✓ .claude/settings.json the check now runs after every write"
33
+ : "· .claude/settings.json already had it"));
34
+ console.log(snippet([wired.command]));
35
+ }
36
+ if (want.mcp) {
37
+ console.log(body(wired.mcp === "added"
38
+ ? "✓ .mcp.json four tools, so the agent can ask instead of guess"
39
+ : "· .mcp.json already had it"));
40
+ }
41
+ console.log(body("✓ CLAUDE.md rewritten for what is installed"));
42
+ // The step that cost a round trip the first time this was tried by hand,
43
+ // and would cost every single person one.
44
+ console.log("");
45
+ console.log(body("Reopen your editor session - both are read at startup."));
46
+ if (want.mcp) {
47
+ console.log(body('A project MCP server needs approving once; say yes when it asks. Then "/mcp" lists synthesisui.'));
48
+ }
49
+ if (wired.command.startsWith("npx synthesisui@")) {
50
+ console.log("");
51
+ console.log(body("The hook runs through npx, which costs about 0.7s per edit. Adding"));
52
+ console.log(body("synthesisui to your devDependencies makes it roughly ten times faster;"));
53
+ console.log(body("run this again afterwards and it will switch by itself."));
54
+ }
55
+ }
@@ -27,10 +27,15 @@ export async function init(opts) {
27
27
  if (opts.ds) {
28
28
  console.log("");
29
29
  await add(opts.ds, { registry: opts.registry, dir: root });
30
+ // The layers exist and nothing installs them unless this is said out loud.
31
+ console.log("");
32
+ console.log(" • synthesisui connect so the check runs on its own, and your agent");
33
+ console.log(" can ask this system instead of guessing");
30
34
  return;
31
35
  }
32
36
  console.log("");
33
37
  console.log("Next steps:");
38
+ console.log(" • synthesisui connect wire your agent to the system");
34
39
  console.log(" • synthesisui add <slug> bring a design system in");
35
40
  console.log(" • synthesisui template <slug> <name> materialize a full page");
36
41
  }
package/dist/index.js CHANGED
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
2
3
  import { add } from "./commands/add.js";
3
4
  import { adopt } from "./commands/adopt.js";
4
5
  import { advise } from "./commands/advise.js";
5
6
  import { clean } from "./commands/clean.js";
6
7
  import { component } from "./commands/component.js";
8
+ import { connect } from "./commands/connect.js";
7
9
  import { doctor } from "./commands/doctor.js";
8
10
  import { generate } from "./commands/generate.js";
9
11
  import { hook } from "./commands/hook.js";
@@ -16,6 +18,10 @@ import { template } from "./commands/template.js";
16
18
  import { upgrade } from "./commands/upgrade.js";
17
19
  import { use } from "./commands/use.js";
18
20
  import { RegistryError } from "./registry.js";
21
+ /** Our own version, for pinning the hook and MCP commands we write into a
22
+ * project. Read from the package we are running out of, so a pinned command
23
+ * always names the version that produced it. */
24
+ const CLI_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
19
25
  const HELP = `synthesisui - bring SynthesisUI design systems into your project
20
26
 
21
27
  Usage - deterministic, FREE:
@@ -137,6 +143,17 @@ async function main() {
137
143
  break;
138
144
  // Both of these read stdin and write a protocol frame to stdout, so
139
145
  // nothing that prints may run near them.
146
+ // Its own command, not a flag on init: installing a hook puts a command on
147
+ // somebody's machine that runs after every edit, and that is a question
148
+ // rather than a step that rides along inside setup.
149
+ case "connect":
150
+ await connect({
151
+ dir,
152
+ version: CLI_VERSION,
153
+ hook: flags.hook !== false,
154
+ mcp: flags.mcp !== false,
155
+ });
156
+ return;
140
157
  case "hook":
141
158
  await hook({ dir });
142
159
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.11",
3
+ "version": "0.16.12",
4
4
  "description": "Bring SynthesisUI design systems into any project - tokens, typed components, whole pages and an agent-ready CLAUDE.md manifest.",
5
5
  "type": "module",
6
6
  "bin": {