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.
- package/dist/agent-wiring.js +104 -0
- package/dist/claude-md.js +42 -6
- package/dist/commands/connect.js +55 -0
- package/dist/commands/init.js +5 -0
- package/dist/index.js +17 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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.${
|
|
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.${
|
|
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
|
+
}
|
package/dist/commands/init.js
CHANGED
|
@@ -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