@imfusion/web-ui 0.5.1-dev.33.gadde98d1 → 0.5.1-dev.38.g1e36ac04

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 (65) hide show
  1. package/README.md +18 -10
  2. package/bin/install.js +96 -42
  3. package/bin/install.test.ts +150 -64
  4. package/dist/llms/gen-tokens.d.ts +7 -0
  5. package/package.json +6 -3
  6. package/src/llms/install-templates/codex-hooks.json +44 -0
  7. package/src/llms/install-templates/hooks/session-start.sh +5 -0
  8. package/src/llms/install-templates/hooks/stop.sh +18 -0
  9. package/src/llms/install-templates/hooks/subagent-start.sh +5 -0
  10. package/src/llms/install-templates/hooks/user-prompt-submit.sh +5 -0
  11. package/src/llms/{skills/imf-web-ui-agent-setup/templates → install-templates}/settings.json +14 -6
  12. package/src/llms/skills/imf-web-ui/SKILL.md +8 -11
  13. package/src/llms/skills/imf-web-ui-audit/SKILL.md +87 -0
  14. package/src/llms/skills/imf-web-ui-components/SKILL.md +1 -1
  15. package/src/llms/skills/imf-web-ui-conventions/SKILL.md +57 -0
  16. package/src/llms/skills/imf-web-ui-conventions/templates/AUDIT_CHECKLIST.md +141 -0
  17. package/src/llms/skills/{imf-web-ui-frontend-setup/templates/FRONTEND_SETUP_REPORT.md → imf-web-ui-conventions/templates/REPORT.md} +4 -4
  18. package/src/llms/skills/imf-web-ui-conventions/topics/agent-tooling.md +82 -0
  19. package/src/llms/skills/imf-web-ui-conventions/topics/assets.md +27 -0
  20. package/src/llms/skills/imf-web-ui-conventions/topics/authentication.md +65 -0
  21. package/src/llms/skills/imf-web-ui-conventions/topics/class-names.md +50 -0
  22. package/src/llms/skills/imf-web-ui-conventions/topics/components.md +101 -0
  23. package/src/llms/skills/imf-web-ui-conventions/topics/data.md +212 -0
  24. package/src/llms/skills/imf-web-ui-conventions/topics/docs-structure.md +40 -0
  25. package/src/llms/skills/imf-web-ui-conventions/topics/git.md +34 -0
  26. package/src/llms/skills/imf-web-ui-conventions/topics/library-boundary.md +33 -0
  27. package/src/llms/skills/imf-web-ui-conventions/topics/library-setup.md +26 -0
  28. package/src/llms/skills/{imf-web-ui-frontend-conventions/references → imf-web-ui-conventions/topics}/npm-project.md +14 -18
  29. package/src/llms/skills/imf-web-ui-conventions/topics/project-structure.md +44 -0
  30. package/src/llms/skills/imf-web-ui-conventions/topics/react.md +107 -0
  31. package/src/llms/skills/imf-web-ui-conventions/topics/styling.md +88 -0
  32. package/src/llms/skills/imf-web-ui-conventions/topics/testing.md +25 -0
  33. package/src/llms/skills/imf-web-ui-conventions/topics/tokens.md +7 -0
  34. package/src/llms/skills/imf-web-ui-conventions/topics/tooling.md +116 -0
  35. package/src/llms/skills/{imf-web-ui-frontend-conventions/references → imf-web-ui-conventions/topics}/typescript.md +3 -4
  36. package/src/llms/skills/imf-web-ui-conventions/topics/validation.md +62 -0
  37. package/src/llms/skills/imf-web-ui-setup/SKILL.md +71 -0
  38. package/src/llms/skills/imf-web-ui-update/SKILL.md +5 -3
  39. package/src/llms/skills/imf-web-ui-ux/SKILL.md +4 -4
  40. package/src/llms/skills/imf-web-ui-ux/references/forms.md +2 -2
  41. package/src/llms/tokens.gen.json +887 -0
  42. package/src/llms/skills/imf-web-ui-agent-setup/SKILL.md +0 -82
  43. package/src/llms/skills/imf-web-ui-agent-setup/templates/hooks/post-tool-use.sh +0 -33
  44. package/src/llms/skills/imf-web-ui-agent-setup/templates/hooks/session-start.sh +0 -4
  45. package/src/llms/skills/imf-web-ui-agent-setup/templates/hooks/user-prompt-submit.sh +0 -4
  46. package/src/llms/skills/imf-web-ui-frontend-conventions/SKILL.md +0 -46
  47. package/src/llms/skills/imf-web-ui-frontend-conventions/references/assets.md +0 -23
  48. package/src/llms/skills/imf-web-ui-frontend-conventions/references/class-names.md +0 -42
  49. package/src/llms/skills/imf-web-ui-frontend-conventions/references/components.md +0 -63
  50. package/src/llms/skills/imf-web-ui-frontend-conventions/references/data.md +0 -201
  51. package/src/llms/skills/imf-web-ui-frontend-conventions/references/docs-structure.md +0 -44
  52. package/src/llms/skills/imf-web-ui-frontend-conventions/references/git.md +0 -33
  53. package/src/llms/skills/imf-web-ui-frontend-conventions/references/library-boundary.md +0 -37
  54. package/src/llms/skills/imf-web-ui-frontend-conventions/references/project-structure.md +0 -21
  55. package/src/llms/skills/imf-web-ui-frontend-conventions/references/react.md +0 -110
  56. package/src/llms/skills/imf-web-ui-frontend-conventions/references/stack.md +0 -39
  57. package/src/llms/skills/imf-web-ui-frontend-conventions/references/styling.md +0 -91
  58. package/src/llms/skills/imf-web-ui-frontend-conventions/references/testing.md +0 -18
  59. package/src/llms/skills/imf-web-ui-frontend-conventions/references/tooling.md +0 -76
  60. package/src/llms/skills/imf-web-ui-frontend-conventions/references/validation.md +0 -88
  61. package/src/llms/skills/imf-web-ui-frontend-setup/SKILL.md +0 -66
  62. package/src/llms/skills/imf-web-ui-frontend-setup/templates/README.md +0 -27
  63. package/src/llms/skills/imf-web-ui-library-setup/SKILL.md +0 -36
  64. /package/src/llms/{skills/imf-web-ui-frontend-setup/templates → install-templates}/AGENTS.md +0 -0
  65. /package/src/llms/{skills/imf-web-ui-agent-setup/templates → install-templates}/hooks/baseline-staleness.sh +0 -0
package/README.md CHANGED
@@ -51,18 +51,26 @@ npx web-ui-install
51
51
  The binary asks which LLM client you use and installs everything it needs. Run it again after a version bump and it refreshes
52
52
  the directories it already installed into, without asking again — `--reconfigure` re-opens that choice.
53
53
 
54
+ The optional `--hooks` flag also installs the agent lifecycle hooks and registers them for both hosts, in
55
+ `.claude/settings.json` (Claude Code) and `.codex/hooks.json` (Codex). Three hooks inject one fixed line each: `SessionStart`
56
+ and `SubagentStart` point the agent at the `imf-web-ui` skills router, and `UserPromptSubmit` names the companion skills to
57
+ consult per task. A fourth, the `Stop` gate, blocks a turn that edited source files once, until the agent has addressed the
58
+ project's verification. Re-running the installer refreshes the scripts and prunes retired ones along with their
59
+ registrations. Without `--hooks`, the skills are still available but no lifecycle hook runs. The full flow is documented in
60
+ the shipped [`agent-tooling` topic](./src/llms/skills/imf-web-ui-conventions/topics/agent-tooling.md) of the conventions
61
+ skill.
62
+
54
63
  **Available consumer skills - `/imf-web-ui-*`**
55
64
 
56
- | Skill | What it does |
57
- | ---------------------------------- | ------------------------------------------------------------------------------ |
58
- | `/imf-web-ui` | The router: decides whether guidance is needed, then which companion to open. |
59
- | `/imf-web-ui-library-setup` | One-time wiring in a consumer project — the styles import and the provider. |
60
- | `/imf-web-ui-components` | Component reference — what exists and how it's meant to be used. |
61
- | `/imf-web-ui-ux` | UX guidance for building interfaces with the library. |
62
- | `/imf-web-ui-frontend-conventions` | The frontend conventions baseline, including the sanctioned styling seams. |
63
- | `/imf-web-ui-frontend-setup` | Assess an ImFusion frontend and write a reviewable setup report. |
64
- | `/imf-web-ui-agent-setup` | Install or update the vendored skills, agent hooks, and TanStack Intent. |
65
- | `/imf-web-ui-update` | Update the library, skills, and optional hooks, then verify before committing. |
65
+ | Skill | What it does |
66
+ | ------------------------- | ------------------------------------------------------------------------------ |
67
+ | `/imf-web-ui` | The router: decides whether guidance is needed, then which companion to open. |
68
+ | `/imf-web-ui-setup` | Plan and, after approval, bootstrap a consumer project by topic. |
69
+ | `/imf-web-ui-components` | Component reference — what exists and how it's meant to be used. |
70
+ | `/imf-web-ui-ux` | UX guidance for building interfaces with the library. |
71
+ | `/imf-web-ui-conventions` | The frontend conventions baseline, including the sanctioned styling seams. |
72
+ | `/imf-web-ui-audit` | Read-only health check for a consumer project, with a reviewable audit report. |
73
+ | `/imf-web-ui-update` | Update the library, skills, and optional hooks, then verify before committing. |
66
74
 
67
75
  Start at `/imf-web-ui` — it routes to the rest. Storybook's **User Guide → AI Agents** page covers the whole family.
68
76
 
package/bin/install.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // Installs this package's agent tooling into the consumer project: the
4
4
  // skill family (../src/llms/skills/imf-web-ui*/) and the agent lifecycle
5
- // hooks the frontend-setup skill ships as templates. Runs in the
5
+ // hooks in the installer-owned template home. Runs in the
6
6
  // CONSUMER's environment, so it may only use this package's real runtime
7
7
  // dependencies (@clack/prompts). Never wire this into a postinstall hook:
8
8
  // ambient script execution on `npm install` is a live supply-chain attack
@@ -13,9 +13,10 @@
13
13
  // .claude/skills/ symlinks it (this repo's own convention) so the two
14
14
  // can't drift apart.
15
15
  //
16
- // Flags: bare = skills only; --hooks adds the agent lifecycle hooks (driven
17
- // by the imf-web-ui-agent-setup skill, which checks the repo's existing hooks
18
- // first). --target claude|agents (repeatable) for scripted installs,
16
+ // Flags: bare = skills only; --hooks adds the agent lifecycle hooks after the
17
+ // consumer checks the repo's existing hooks and still installs the skills.
18
+ // --target claude|agents
19
+ // (repeatable) selects scripted install targets,
19
20
  // --reconfigure to re-open the target prompt on an existing install.
20
21
 
21
22
  import {
@@ -126,28 +127,31 @@ function detectInstalledTargets(skills) {
126
127
  );
127
128
  }
128
129
 
129
- // Hook templates ship inside the agent-setup skill so they're readable as
130
- // part of its documentation; the installer copies the scripts into the
131
- // consumer's .agents/hooks/ and merges the registrations into
132
- // .claude/settings.json.
133
- const hooksSourceDir = resolve(skillsRoot, "imf-web-ui-agent-setup", "templates", "hooks");
134
- const hooksSettingsTemplate = resolve(skillsRoot, "imf-web-ui-agent-setup", "templates", "settings.json");
130
+ // Hook templates are installer-owned; the installer copies the scripts into the
131
+ // consumer's .agents/hooks/ and merges the registrations into the host
132
+ // registration files: .claude/settings.json from settings.json, .codex/hooks.json
133
+ // from codex-hooks.json. One pristine template per host, merged verbatim.
134
+ const installTemplatesRoot = resolve(here, "..", "src", "llms", "install-templates");
135
+ const hooksSourceDir = resolve(installTemplatesRoot, "hooks");
136
+ const hooksSettingsTemplate = resolve(installTemplatesRoot, "settings.json");
137
+ const codexHooksTemplate = resolve(installTemplatesRoot, "codex-hooks.json");
135
138
  // Installer-owned subdirectory: refreshed wholesale on every run, so a repo's
136
139
  // own hooks in .agents/hooks/ are never touched.
137
140
  const hooksDestDir = resolve(projectRoot, ".agents", "hooks", "imf-web-ui");
138
141
  const settingsPath = resolve(projectRoot, ".claude", "settings.json");
142
+ const codexHooksPath = resolve(projectRoot, ".codex", "hooks.json");
139
143
 
140
144
  // Serializing the file reflows entries this installer doesn't own; the
141
145
  // consumer's own Prettier puts them back. Best-effort: absent, or the path is
142
146
  // .prettierignore'd, nothing happens.
143
- function formatSettings() {
147
+ function formatSettings(path) {
144
148
  let toolRoot = projectRoot;
145
149
  while (toolRoot !== dirname(toolRoot) && !existsSync(resolve(toolRoot, "node_modules", ".bin"))) {
146
150
  toolRoot = dirname(toolRoot);
147
151
  }
148
152
  const prettier = resolve(toolRoot, "node_modules", ".bin", "prettier");
149
153
  if (!existsSync(prettier)) return;
150
- spawnSync(prettier, ["--write", settingsPath], { cwd: projectRoot, stdio: "ignore" });
154
+ spawnSync(prettier, ["--write", path], { cwd: projectRoot, stdio: "ignore" });
151
155
  }
152
156
 
153
157
  function installHookScripts() {
@@ -158,7 +162,13 @@ function installHookScripts() {
158
162
  cpSync(resolve(hooksSourceDir, name), dest, { force: true });
159
163
  chmodSync(dest, 0o755);
160
164
  }
161
- return scripts.length;
165
+ // The directory is installer-owned: a script dropped from the template is
166
+ // removed on refresh, so a stale hook can't keep running after an upgrade.
167
+ const removed = readdirSync(hooksDestDir).filter(name => name.endsWith(".sh") && !scripts.includes(name));
168
+ for (const name of removed) {
169
+ rmSync(resolve(hooksDestDir, name), { force: true });
170
+ }
171
+ return { installed: scripts, removed };
162
172
  }
163
173
 
164
174
  // A registration is identified by the script it runs, not the command string:
@@ -169,7 +179,12 @@ function hookScriptId(command) {
169
179
  if (typeof command !== "string") return null;
170
180
  const at = command.lastIndexOf(HOOKS_DIR_SEGMENT);
171
181
  if (at === -1) return null;
172
- return command.slice(at + HOOKS_DIR_SEGMENT.length).replace(/["'\s]+$/, "");
182
+ // Only a bare script name (plus an optional closing quote) identifies a
183
+ // registration as installer-owned. Trailing arguments or redirection mean
184
+ // the consumer customized the command; that counts as foreign, so the
185
+ // stale-prune below can never delete it.
186
+ const match = command.slice(at + HOOKS_DIR_SEGMENT.length).match(/^([\w.-]+)["']?$/);
187
+ return match ? match[1] : null;
173
188
  }
174
189
 
175
190
  function entryScriptIds(entry) {
@@ -178,20 +193,50 @@ function entryScriptIds(entry) {
178
193
 
179
194
  // Merge, never clobber: a registration is added only when no existing entry
180
195
  // for that event already runs the same script, so re-runs are idempotent and
181
- // hand-written settings survive. A run that adds nothing writes nothing.
182
- function mergeHookRegistrations() {
183
- const template = JSON.parse(readFileSync(hooksSettingsTemplate, "utf-8"));
196
+ // hand-written settings survive. The one exception is entries that only run
197
+ // scripts from the installer-owned directory that are no longer shipped —
198
+ // those are pruned, or they would point at a script the refresh just removed.
199
+ // A run that changes nothing writes nothing.
200
+ function mergeHookRegistrations(targetPath, templateHooks, shippedScripts) {
184
201
  let settings = {};
185
- if (existsSync(settingsPath)) {
202
+ if (existsSync(targetPath)) {
186
203
  try {
187
- settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
204
+ settings = JSON.parse(readFileSync(targetPath, "utf-8"));
188
205
  } catch {
189
206
  return null;
190
207
  }
191
208
  }
192
209
  settings.hooks ??= {};
193
210
  let added = 0;
194
- for (const [event, entries] of Object.entries(template.hooks)) {
211
+ let removed = 0;
212
+ // Pruning is per hook, not per entry: only an individual hook that
213
+ // unambiguously runs a no-longer-shipped installer-owned script goes;
214
+ // consumer hooks sharing the same entry stay.
215
+ const emptiedEvents = [];
216
+ for (const [event, entries] of Object.entries(settings.hooks)) {
217
+ if (!Array.isArray(entries)) continue;
218
+ let pruned = 0;
219
+ const kept = entries
220
+ .map(entry => {
221
+ if (!Array.isArray(entry.hooks)) return entry;
222
+ const hooks = entry.hooks.filter(hook => {
223
+ const script = hookScriptId(hook.command);
224
+ return script === null || shippedScripts.includes(script);
225
+ });
226
+ pruned += entry.hooks.length - hooks.length;
227
+ return hooks.length === entry.hooks.length ? entry : { ...entry, hooks };
228
+ })
229
+ .filter(entry => !Array.isArray(entry.hooks) || entry.hooks.length > 0);
230
+ if (pruned === 0) continue;
231
+ removed += pruned;
232
+ if (kept.length === 0 && !(event in templateHooks)) emptiedEvents.push(event);
233
+ else settings.hooks[event] = kept;
234
+ }
235
+ // An event whose entries were all pruned disappears instead of lingering as [].
236
+ if (emptiedEvents.length > 0) {
237
+ settings.hooks = Object.fromEntries(Object.entries(settings.hooks).filter(([event]) => !emptiedEvents.includes(event)));
238
+ }
239
+ for (const [event, entries] of Object.entries(templateHooks)) {
195
240
  settings.hooks[event] ??= [];
196
241
  for (const entry of entries) {
197
242
  const scripts = entryScriptIds(entry);
@@ -204,11 +249,11 @@ function mergeHookRegistrations() {
204
249
  }
205
250
  }
206
251
  }
207
- if (added === 0) return 0;
208
- mkdirSync(dirname(settingsPath), { recursive: true });
209
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
210
- formatSettings();
211
- return added;
252
+ if (added === 0 && removed === 0) return { added, removed };
253
+ mkdirSync(dirname(targetPath), { recursive: true });
254
+ writeFileSync(targetPath, JSON.stringify(settings, null, 2) + "\n");
255
+ formatSettings(targetPath);
256
+ return { added, removed };
212
257
  }
213
258
 
214
259
  // The AGENTS.md baseline note lives inside a fenced block owned by this
@@ -218,7 +263,7 @@ function mergeHookRegistrations() {
218
263
  // is left alone — scaffolding one is the setup skill's job.
219
264
  const AGENTS_BLOCK_BEGIN = "<!-- imf-web-ui:begin";
220
265
  const AGENTS_BLOCK_END = "<!-- imf-web-ui:end -->";
221
- const agentsTemplatePath = resolve(skillsRoot, "imf-web-ui-frontend-setup", "templates", "AGENTS.md");
266
+ const agentsTemplatePath = resolve(installTemplatesRoot, "AGENTS.md");
222
267
  const agentsPath = resolve(projectRoot, "AGENTS.md");
223
268
 
224
269
  function extractAgentsBlock(content) {
@@ -262,11 +307,11 @@ async function main() {
262
307
  const argv = process.argv.slice(2);
263
308
  const flagTargets = parseTargetFlags(argv);
264
309
  const reconfigure = argv.includes("--reconfigure");
265
- // Bare invocation installs skills only. Hooks are opt-in via --hooks — the
266
- // imf-web-ui-agent-setup skill drives that after checking what the repo
267
- // already registers; the binary stays the dumb mechanical tail.
310
+ // Every install includes the skill bundle. Hooks are opt-in via --hooks —
311
+ // the consumer's setup workflow drives that after checking what the repo
312
+ // already registers, but hooks without their companion skills are invalid.
268
313
  const wantHooks = argv.includes("--hooks");
269
- const wantSkills = argv.includes("--skills") || !wantHooks;
314
+ const wantSkills = true;
270
315
 
271
316
  const components = [wantSkills && `${skills.length} skills`, wantHooks && "agent hooks"].filter(Boolean);
272
317
  p.intro(`@imfusion/web-ui install — ${components.join(" + ")}`);
@@ -279,19 +324,28 @@ async function main() {
279
324
  }
280
325
 
281
326
  if (wantHooks) {
282
- const scriptCount = installHookScripts();
283
- const added = mergeHookRegistrations();
284
- p.log.success(`Agent hooks -> ${displayPath(hooksDestDir)} (${scriptCount} scripts)`);
285
- if (added === null) {
286
- p.log.warn(`${displayPath(settingsPath)} is not valid JSON — registrations not merged, fix it and re-run.`);
287
- } else if (added > 0) {
288
- p.log.success(`Registered ${added} hook(s) in ${displayPath(settingsPath)}`);
289
- } else {
290
- p.log.info(`Hook registrations already present in ${displayPath(settingsPath)}`);
327
+ const { installed, removed } = installHookScripts();
328
+ p.log.success(`Agent hooks -> ${displayPath(hooksDestDir)} (${installed.length} scripts)`);
329
+ if (removed.length > 0) {
330
+ p.log.info(`Removed stale hook script(s): ${removed.join(", ")} (no longer shipped)`);
291
331
  }
292
- if (!wantSkills) {
293
- p.outro("Done — hooks installed.");
294
- return;
332
+ const registrations = [
333
+ [settingsPath, JSON.parse(readFileSync(hooksSettingsTemplate, "utf-8")).hooks],
334
+ [codexHooksPath, JSON.parse(readFileSync(codexHooksTemplate, "utf-8")).hooks]
335
+ ];
336
+ for (const [path, hooks] of registrations) {
337
+ const result = mergeHookRegistrations(path, hooks, installed);
338
+ if (result === null) {
339
+ p.log.warn(`${displayPath(path)} is not valid JSON — registrations not merged, fix it and re-run.`);
340
+ } else if (result.added > 0 || result.removed > 0) {
341
+ const changes = [
342
+ result.added > 0 && `registered ${result.added}`,
343
+ result.removed > 0 && `removed ${result.removed} stale`
344
+ ].filter(Boolean);
345
+ p.log.success(`${changes.join(", ")} hook registration(s) in ${displayPath(path)}`);
346
+ } else {
347
+ p.log.info(`Hook registrations already present in ${displayPath(path)}`);
348
+ }
295
349
  }
296
350
  }
297
351
 
@@ -1,7 +1,6 @@
1
1
  import { beforeEach, describe, expect, it } from "vitest";
2
2
  import { spawnSync } from "node:child_process";
3
3
  import {
4
- chmodSync,
5
4
  existsSync,
6
5
  lstatSync,
7
6
  mkdirSync,
@@ -32,6 +31,10 @@ function readSettings(cwd: string) {
32
31
  }
33
32
 
34
33
  let consumer: string;
34
+ const retiredSkillNames = [
35
+ ["agent", "setup"],
36
+ ["library", "setup"]
37
+ ].map(parts => `imf-web-ui-${parts.join("-")}`);
35
38
 
36
39
  beforeEach(() => {
37
40
  consumer = mkdtempSync(join(tmpdir(), "web-ui-install-"));
@@ -43,8 +46,11 @@ describe("web-ui-install", () => {
43
46
 
44
47
  const installed = readdirSync(join(consumer, ".agents", "skills"));
45
48
  expect(installed).toContain("imf-web-ui");
46
- expect(installed).toContain("imf-web-ui-frontend-conventions");
47
- expect(installed).toContain("imf-web-ui-frontend-setup");
49
+ expect(installed).toContain("imf-web-ui-conventions");
50
+ expect(installed).toContain("imf-web-ui-setup");
51
+ expect(installed).toContain("imf-web-ui-audit");
52
+ expect(installed).toHaveLength(7);
53
+ for (const name of retiredSkillNames) expect(installed).not.toContain(name);
48
54
 
49
55
  for (const name of installed) {
50
56
  expect(existsSync(join(consumer, ".agents", "skills", name, ".imf-web-ui-skill-version.json"))).toBe(true);
@@ -69,20 +75,97 @@ describe("web-ui-install", () => {
69
75
  expect(result.stdout).toContain(`Removed orphaned skill: ${orphan} (no longer in this bundle)`);
70
76
  });
71
77
 
72
- it("installs hooks into an installer-owned directory and registers them", () => {
73
- runInstall(consumer, "--hooks");
78
+ it("installs hooks into an installer-owned directory and registers them for both hosts", () => {
79
+ runInstall(consumer, "--hooks", "--target", "agents");
74
80
 
75
81
  const hookDir = join(consumer, ".agents", "hooks", "imf-web-ui");
76
82
  const scripts = readdirSync(hookDir);
77
- expect(scripts).toEqual(expect.arrayContaining(["session-start.sh", "user-prompt-submit.sh", "post-tool-use.sh"]));
83
+ expect(scripts).toEqual(expect.arrayContaining(["session-start.sh", "subagent-start.sh", "user-prompt-submit.sh"]));
78
84
  for (const script of scripts) {
79
85
  expect(statSync(join(hookDir, script)).mode & 0o100).toBeTruthy();
80
86
  }
81
87
 
82
88
  const settings = readSettings(consumer);
83
- expect(Object.keys(settings.hooks)).toEqual(expect.arrayContaining(["SessionStart", "UserPromptSubmit", "PostToolUse"]));
84
- // --hooks alone installs no skills
85
- expect(existsSync(join(consumer, ".agents", "skills"))).toBe(false);
89
+ expect(Object.keys(settings.hooks)).toEqual(
90
+ expect.arrayContaining(["SessionStart", "SubagentStart", "UserPromptSubmit"])
91
+ );
92
+
93
+ const codexHooks = JSON.parse(readFileSync(join(consumer, ".codex", "hooks.json"), "utf-8")) as {
94
+ hooks: Record<string, { hooks: { command?: string }[] }[]>;
95
+ };
96
+ expect(Object.keys(codexHooks.hooks)).toEqual(Object.keys(settings.hooks));
97
+ const codexCommands = Object.values(codexHooks.hooks).flatMap(entries =>
98
+ entries.flatMap(entry => entry.hooks.map(hook => hook.command))
99
+ );
100
+ for (const command of codexCommands) {
101
+ expect(command).toContain("$(git rev-parse --show-toplevel)/.agents/hooks/imf-web-ui/");
102
+ expect(command).not.toContain("CLAUDE_PROJECT_DIR");
103
+ }
104
+
105
+ expect(readdirSync(join(consumer, ".agents", "skills"))).toHaveLength(7);
106
+ });
107
+
108
+ it("prunes a stale hook script and its registrations on refresh", () => {
109
+ runInstall(consumer, "--hooks", "--target", "agents");
110
+ const hookDir = join(consumer, ".agents", "hooks", "imf-web-ui");
111
+ writeFileSync(join(hookDir, "retired-hook.sh"), "#!/usr/bin/env sh\nexit 0\n");
112
+ const settingsFile = join(consumer, ".claude", "settings.json");
113
+ const settings = readSettings(consumer);
114
+ settings.hooks.PostToolUse = [
115
+ {
116
+ hooks: [{ type: "command", command: '"$CLAUDE_PROJECT_DIR/.agents/hooks/imf-web-ui/retired-hook.sh"' }]
117
+ } as never
118
+ ];
119
+ writeFileSync(settingsFile, JSON.stringify(settings));
120
+
121
+ runInstall(consumer, "--hooks", "--target", "agents");
122
+
123
+ expect(existsSync(join(hookDir, "retired-hook.sh"))).toBe(false);
124
+ const refreshed = readSettings(consumer);
125
+ expect(refreshed.hooks.PostToolUse).toBeUndefined();
126
+ });
127
+
128
+ it("prunes per hook, keeping consumer hooks and customized commands in the same entry", () => {
129
+ runInstall(consumer, "--hooks", "--target", "agents");
130
+ const hookDir = join(consumer, ".agents", "hooks", "imf-web-ui");
131
+ writeFileSync(join(hookDir, "retired-hook.sh"), "#!/usr/bin/env sh\nexit 0\n");
132
+ const settingsFile = join(consumer, ".claude", "settings.json");
133
+ const settings = readSettings(consumer);
134
+ const customized = '"$CLAUDE_PROJECT_DIR/.agents/hooks/imf-web-ui/session-start.sh" 2>/dev/null';
135
+ settings.hooks.PostToolUse = [
136
+ {
137
+ hooks: [
138
+ { type: "command", command: '"$CLAUDE_PROJECT_DIR/.agents/hooks/imf-web-ui/retired-hook.sh"' },
139
+ { type: "command", command: "npm run my-check" },
140
+ { type: "command", command: customized }
141
+ ]
142
+ } as never
143
+ ];
144
+ writeFileSync(settingsFile, JSON.stringify(settings));
145
+
146
+ runInstall(consumer, "--hooks", "--target", "agents");
147
+
148
+ const commands = readSettings(consumer).hooks.PostToolUse.flatMap(entry => entry.hooks.map(hook => hook.command));
149
+ expect(commands).toEqual(["npm run my-check", customized]);
150
+ });
151
+
152
+ it("keeps a consumer-adjusted codex command across re-runs, without duplicating it", () => {
153
+ runInstall(consumer, "--hooks", "--target", "agents");
154
+ const codexPath = join(consumer, ".codex", "hooks.json");
155
+ const adjusted = '"$(git rev-parse --show-toplevel)/frontend/.agents/hooks/imf-web-ui/session-start.sh"';
156
+ const codexHooks = JSON.parse(readFileSync(codexPath, "utf-8")) as {
157
+ hooks: Record<string, { hooks: { command?: string }[] }[]>;
158
+ };
159
+ codexHooks.hooks.SessionStart[0].hooks[0].command = adjusted;
160
+ writeFileSync(codexPath, JSON.stringify(codexHooks));
161
+
162
+ runInstall(consumer, "--hooks", "--target", "agents");
163
+
164
+ const refreshed = JSON.parse(readFileSync(codexPath, "utf-8")) as {
165
+ hooks: Record<string, { hooks: { command?: string }[] }[]>;
166
+ };
167
+ const commands = refreshed.hooks.SessionStart.flatMap(entry => entry.hooks.map(hook => hook.command));
168
+ expect(commands).toEqual([adjusted]);
86
169
  });
87
170
 
88
171
  it("merges registrations without clobbering existing settings, idempotently", () => {
@@ -95,15 +178,16 @@ describe("web-ui-install", () => {
95
178
  })
96
179
  );
97
180
 
98
- runInstall(consumer, "--hooks");
99
- runInstall(consumer, "--hooks");
181
+ runInstall(consumer, "--hooks", "--target", "agents");
182
+ runInstall(consumer, "--hooks", "--target", "agents");
100
183
 
101
184
  const settings = readSettings(consumer);
102
185
  expect(settings.permissions?.allow).toEqual(["Bash(git *)"]);
103
- const commands = settings.hooks.PostToolUse.flatMap(entry => entry.hooks.map(hook => hook.command));
104
- expect(commands).toContain("echo existing");
186
+ const foreign = settings.hooks.PostToolUse.flatMap(entry => entry.hooks.map(hook => hook.command));
187
+ expect(foreign).toContain("echo existing");
105
188
  // the shipped registration is added exactly once across both runs
106
- expect(commands.filter(command => command?.includes("post-tool-use.sh"))).toHaveLength(1);
189
+ const commands = settings.hooks.UserPromptSubmit.flatMap(entry => entry.hooks.map(hook => hook.command));
190
+ expect(commands.filter(command => command?.includes("user-prompt-submit.sh"))).toHaveLength(1);
107
191
  });
108
192
 
109
193
  it("recognises registrations written with braced ${CLAUDE_PROJECT_DIR}", () => {
@@ -112,14 +196,12 @@ describe("web-ui-install", () => {
112
196
  join(consumer, ".claude", "settings.json"),
113
197
  JSON.stringify({
114
198
  hooks: {
115
- PostToolUse: [
199
+ UserPromptSubmit: [
116
200
  {
117
- matcher: "Edit|MultiEdit|Write",
118
201
  hooks: [
119
202
  {
120
203
  type: "command",
121
- command: '"${CLAUDE_PROJECT_DIR}/.agents/hooks/imf-web-ui/post-tool-use.sh"',
122
- statusMessage: "Verifying file"
204
+ command: '"${CLAUDE_PROJECT_DIR}/.agents/hooks/imf-web-ui/user-prompt-submit.sh"'
123
205
  }
124
206
  ]
125
207
  },
@@ -130,23 +212,23 @@ describe("web-ui-install", () => {
130
212
  })
131
213
  );
132
214
 
133
- runInstall(consumer, "--hooks");
215
+ runInstall(consumer, "--hooks", "--target", "agents");
134
216
 
135
217
  const settings = readSettings(consumer);
136
- const commands = settings.hooks.PostToolUse.flatMap(entry => entry.hooks.map(hook => hook.command));
137
- expect(commands.filter(command => command?.includes("post-tool-use.sh"))).toHaveLength(1);
138
- expect(commands).toContain('"${CLAUDE_PROJECT_DIR}/.agents/hooks/imf-web-ui/post-tool-use.sh"');
218
+ const commands = settings.hooks.UserPromptSubmit.flatMap(entry => entry.hooks.map(hook => hook.command));
219
+ expect(commands.filter(command => command?.includes("user-prompt-submit.sh"))).toHaveLength(1);
220
+ expect(commands).toContain('"${CLAUDE_PROJECT_DIR}/.agents/hooks/imf-web-ui/user-prompt-submit.sh"');
139
221
  });
140
222
 
141
223
  it("does not touch settings.json when every registration is already present", () => {
142
- runInstall(consumer, "--hooks");
224
+ runInstall(consumer, "--hooks", "--target", "agents");
143
225
  const settingsFile = join(consumer, ".claude", "settings.json");
144
226
  const settings = readSettings(consumer);
145
227
  settings.hooks.PreToolUse = [{ hooks: [{ command: "./other/gate.mjs" }] }];
146
228
  const before = JSON.stringify(settings, null, 4) + "\n";
147
229
  writeFileSync(settingsFile, before);
148
230
 
149
- const result = runInstall(consumer, "--hooks");
231
+ const result = runInstall(consumer, "--hooks", "--target", "agents");
150
232
 
151
233
  expect(readFileSync(settingsFile, "utf-8")).toBe(before);
152
234
  expect(result.stdout).toContain("Hook registrations already present");
@@ -174,22 +256,22 @@ describe("web-ui-install", () => {
174
256
  expect(appended).toContain("<!-- imf-web-ui:begin");
175
257
  });
176
258
 
177
- it("ships hooks that stay advisory: exit 0 with and without a usable payload", () => {
178
- runInstall(consumer, "--hooks");
259
+ it("ships injection hooks that exit 0 and echo their fixed one-liners", () => {
260
+ runInstall(consumer, "--hooks", "--target", "agents");
179
261
  const hookDir = join(consumer, ".agents", "hooks", "imf-web-ui");
180
262
 
181
- const noFile = spawnSync("sh", [join(hookDir, "post-tool-use.sh")], { input: "{}", encoding: "utf-8" });
182
- expect(noFile.status).toBe(0);
183
-
184
- const missingFile = spawnSync("sh", [join(hookDir, "post-tool-use.sh")], {
185
- input: JSON.stringify({ tool_input: { file_path: join(consumer, "nope.ts") } }),
186
- encoding: "utf-8"
187
- });
188
- expect(missingFile.status).toBe(0);
189
-
190
263
  const sessionStart = spawnSync("sh", [join(hookDir, "session-start.sh")], { encoding: "utf-8" });
191
264
  expect(sessionStart.status).toBe(0);
192
- expect(sessionStart.stdout).toContain("imf-web-ui-frontend-conventions");
265
+ expect(sessionStart.stdout).toContain("imf-web-ui skill");
266
+
267
+ // Subagents get the same router pointer, byte for byte.
268
+ const subagentStart = spawnSync("sh", [join(hookDir, "subagent-start.sh")], { encoding: "utf-8" });
269
+ expect(subagentStart.status).toBe(0);
270
+ expect(subagentStart.stdout).toBe(sessionStart.stdout);
271
+
272
+ const promptSubmit = spawnSync("sh", [join(hookDir, "user-prompt-submit.sh")], { encoding: "utf-8" });
273
+ expect(promptSubmit.status).toBe(0);
274
+ expect(promptSubmit.stdout).toContain("imf-web-ui-components");
193
275
 
194
276
  // no node_modules in the consumer → silent success
195
277
  const staleness = spawnSync("sh", [join(hookDir, "baseline-staleness.sh")], { cwd: consumer, encoding: "utf-8" });
@@ -197,34 +279,38 @@ describe("web-ui-install", () => {
197
279
  expect(staleness.stdout).toBe("");
198
280
  });
199
281
 
200
- it("runs post-edit checks from the package containing the edited file", () => {
201
- runInstall(consumer, "--hooks");
202
-
203
- const frontend = join(consumer, "frontend");
204
- const source = join(frontend, "src", "example.ts");
205
- const binDir = join(frontend, "node_modules", ".bin");
206
- const log = join(consumer, "hook.log");
207
- mkdirSync(join(frontend, "src"), { recursive: true });
208
- mkdirSync(binDir, { recursive: true });
209
- writeFileSync(join(frontend, "package.json"), JSON.stringify({ private: true }));
210
- writeFileSync(source, "export const example = true;\n");
211
-
212
- for (const tool of ["eslint", "prettier"]) {
213
- const path = join(binDir, tool);
214
- writeFileSync(path, '#!/usr/bin/env sh\nprintf "%s|%s\\n" "$PWD" "$*" >> "$HOOK_LOG"\n');
215
- chmodSync(path, 0o755);
216
- }
217
-
218
- const result = spawnSync("sh", [join(consumer, ".agents", "hooks", "imf-web-ui", "post-tool-use.sh")], {
219
- cwd: consumer,
220
- env: { ...process.env, HOOK_LOG: log },
221
- input: JSON.stringify({ tool_input: { file_path: source } }),
222
- encoding: "utf-8"
223
- });
224
-
225
- expect(result.status).toBe(0);
226
- const calls = readFileSync(log, "utf-8").trim().split("\n");
227
- expect(calls).toEqual([`${frontend}|--cache ${source}`, `${frontend}|--check ${source}`]);
282
+ it("gates the turn end only after in-session edits, and never loops", () => {
283
+ runInstall(consumer, "--hooks", "--target", "agents");
284
+ const stopHook = join(consumer, ".agents", "hooks", "imf-web-ui", "stop.sh");
285
+ const transcript = join(consumer, "transcript.jsonl");
286
+ const runStop = (input: string) => spawnSync("sh", [stopHook], { cwd: consumer, input, encoding: "utf-8" });
287
+
288
+ // outside a git repo → silent pass
289
+ const noGit = runStop("{}");
290
+ expect(noGit.status).toBe(0);
291
+ expect(noGit.stdout).toBe("");
292
+
293
+ spawnSync("git", ["init", "--quiet", consumer]);
294
+ mkdirSync(join(consumer, "src"), { recursive: true });
295
+ writeFileSync(join(consumer, "src", "app.ts"), "export const x = 1;\n");
296
+ writeFileSync(transcript, '{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Write"}]}}\n');
297
+ const payload = JSON.stringify({ transcript_path: transcript, stop_hook_active: false });
298
+
299
+ const blocked = runStop(payload);
300
+ expect(blocked.status).toBe(0);
301
+ expect(blocked.stdout).toContain('"decision":"block"');
302
+ expect(blocked.stdout).toContain("web-ui stop gate:");
303
+
304
+ // re-entry after a block → silent pass
305
+ const reentry = runStop(JSON.stringify({ transcript_path: transcript, stop_hook_active: true }));
306
+ expect(reentry.status).toBe(0);
307
+ expect(reentry.stdout).toBe("");
308
+
309
+ // dirty tree, but this session's transcript shows no edits → silent pass
310
+ writeFileSync(transcript, '{"type":"user","message":{"content":"a question"}}\n');
311
+ const questionTurn = runStop(payload);
312
+ expect(questionTurn.status).toBe(0);
313
+ expect(questionTurn.stdout).toBe("");
228
314
  });
229
315
 
230
316
  it("warns from the staleness script when skill markers lag the package", () => {
@@ -0,0 +1,7 @@
1
+ export type TokenEntry = {
2
+ name: string;
3
+ value: string;
4
+ };
5
+ export declare function collectTokenEntries(files: readonly string[]): TokenEntry[];
6
+ export declare function renderTokenIndex(tokens: readonly TokenEntry[]): string;
7
+ export declare function generateTokenIndex(): Promise<number>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imfusion/web-ui",
3
- "version": "0.5.1-dev.33.gadde98d1",
3
+ "version": "0.5.1-dev.38.g1e36ac04",
4
4
  "description": "The official Web UI component library for ImFusion web apps",
5
5
  "author": "ImFusion GmbH",
6
6
  "license": "UNLICENSED",
@@ -41,8 +41,10 @@
41
41
  },
42
42
  "files": [
43
43
  "dist",
44
+ "src/llms/install-templates",
44
45
  "src/docgen/doc.gen.json",
45
46
  "src/llms/llms.gen.txt",
47
+ "src/llms/tokens.gen.json",
46
48
  "src/llms/skills",
47
49
  "bin"
48
50
  ],
@@ -50,7 +52,7 @@
50
52
  "dev": "tsx scripts/dev.ts",
51
53
  "dev:host": "tsx scripts/dev.ts --host",
52
54
  "dev:lib": "concurrently -n codegen,lib -c yellow,blue \"npm run codegen:watch\" \"vite build --watch\"",
53
- "build": "npm run codegen && rm -rf dist && vite build && npm run codegen:docgen && npm run codegen:llms",
55
+ "build": "npm run codegen && rm -rf dist && vite build && npm run codegen:docgen && npm run codegen:llms && npm run codegen:tokens",
54
56
  "build:storybook": "npm run codegen:storybook && storybook build",
55
57
  "storybook": "npm run codegen:storybook && storybook dev -p 6006 --no-open",
56
58
  "storybook:free-ports": "tsx scripts/free-storybook-ports.ts",
@@ -59,10 +61,12 @@
59
61
  "codegen:storybook": "tsx src/codegen/run.ts --storybook",
60
62
  "codegen:docgen": "tsx src/docgen/gen-docgen.ts",
61
63
  "codegen:llms": "tsx src/llms/gen-llms.ts",
64
+ "codegen:tokens": "tsx src/llms/gen-tokens.ts",
62
65
  "verify:format": "prettier --check .",
63
66
  "verify:lint": "eslint . --cache --max-warnings=0",
64
67
  "verify:typecheck": "npm run codegen && tsc -p tsconfig.json --noEmit && tsc -p tsconfig.lib.json --noEmit && tsc -p .storybook/tsconfig.json --noEmit",
65
68
  "verify:tests": "vitest run",
69
+ "verify:knip": "knip",
66
70
  "verify:deps": "tsx scripts/check-pinned-dependencies.ts",
67
71
  "verify:changelog": "tsx scripts/verify/changelog.ts",
68
72
  "verify:staged": "tsx scripts/verify/staged.ts",
@@ -77,7 +81,6 @@
77
81
  "skills:eval:dev": "WEB_UI_SKILL_EVAL_ROOT=/private/tmp/web-ui-dev-skill-evals WEB_UI_SKILL_EVAL_RESULTS_DIR=.agents/evals/results WEB_UI_SKILL_EVAL_REPORT_PATH=.agents/evals/REPORT.md WEB_UI_SKILL_EVAL_SKILL_FAMILY=web-ui-dev WEB_UI_SKILL_EVAL_WORKSPACE_LABEL='development repository' node src/llms/evals/run.mjs --scenarios-dir .agents/evals/scenarios --setup .agents/evals/setup-env.sh",
78
82
  "skills:eval:compare": "node src/llms/evals/compare.mjs",
79
83
  "skills:eval:report": "node src/llms/evals/report.mjs",
80
- "knip": "knip",
81
84
  "git:config": "git config core.hooksPath .githooks && git config pull.rebase true && git config merge.ff only"
82
85
  },
83
86
  "peerDependencies": {