@imfusion/web-ui 0.5.1-dev.33.gadde98d1 → 0.5.1-dev.39.gfc64049e
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/README.md +18 -10
- package/bin/install.js +96 -42
- package/bin/install.test.ts +150 -64
- package/dist/llms/gen-tokens.d.ts +7 -0
- package/package.json +6 -3
- package/src/llms/install-templates/codex-hooks.json +44 -0
- package/src/llms/install-templates/hooks/session-start.sh +5 -0
- package/src/llms/install-templates/hooks/stop.sh +18 -0
- package/src/llms/install-templates/hooks/subagent-start.sh +5 -0
- package/src/llms/install-templates/hooks/user-prompt-submit.sh +5 -0
- package/src/llms/{skills/imf-web-ui-agent-setup/templates → install-templates}/settings.json +14 -6
- package/src/llms/skills/imf-web-ui/SKILL.md +8 -11
- package/src/llms/skills/imf-web-ui-audit/SKILL.md +87 -0
- package/src/llms/skills/imf-web-ui-components/SKILL.md +1 -1
- package/src/llms/skills/imf-web-ui-conventions/SKILL.md +57 -0
- package/src/llms/skills/imf-web-ui-conventions/templates/AUDIT_CHECKLIST.md +141 -0
- package/src/llms/skills/{imf-web-ui-frontend-setup/templates/FRONTEND_SETUP_REPORT.md → imf-web-ui-conventions/templates/REPORT.md} +4 -4
- package/src/llms/skills/imf-web-ui-conventions/topics/agent-tooling.md +82 -0
- package/src/llms/skills/imf-web-ui-conventions/topics/assets.md +27 -0
- package/src/llms/skills/imf-web-ui-conventions/topics/authentication.md +65 -0
- package/src/llms/skills/imf-web-ui-conventions/topics/class-names.md +50 -0
- package/src/llms/skills/imf-web-ui-conventions/topics/components.md +101 -0
- package/src/llms/skills/imf-web-ui-conventions/topics/data.md +212 -0
- package/src/llms/skills/imf-web-ui-conventions/topics/docs-structure.md +40 -0
- package/src/llms/skills/imf-web-ui-conventions/topics/git.md +34 -0
- package/src/llms/skills/imf-web-ui-conventions/topics/library-boundary.md +33 -0
- package/src/llms/skills/imf-web-ui-conventions/topics/library-setup.md +26 -0
- package/src/llms/skills/{imf-web-ui-frontend-conventions/references → imf-web-ui-conventions/topics}/npm-project.md +14 -18
- package/src/llms/skills/imf-web-ui-conventions/topics/project-structure.md +44 -0
- package/src/llms/skills/imf-web-ui-conventions/topics/react.md +107 -0
- package/src/llms/skills/imf-web-ui-conventions/topics/styling.md +88 -0
- package/src/llms/skills/imf-web-ui-conventions/topics/testing.md +25 -0
- package/src/llms/skills/imf-web-ui-conventions/topics/tokens.md +7 -0
- package/src/llms/skills/imf-web-ui-conventions/topics/tooling.md +116 -0
- package/src/llms/skills/{imf-web-ui-frontend-conventions/references → imf-web-ui-conventions/topics}/typescript.md +3 -4
- package/src/llms/skills/imf-web-ui-conventions/topics/validation.md +62 -0
- package/src/llms/skills/imf-web-ui-setup/SKILL.md +71 -0
- package/src/llms/skills/imf-web-ui-update/SKILL.md +9 -3
- package/src/llms/skills/imf-web-ui-ux/SKILL.md +4 -4
- package/src/llms/skills/imf-web-ui-ux/references/forms.md +2 -2
- package/src/llms/tokens.gen.json +887 -0
- package/src/llms/skills/imf-web-ui-agent-setup/SKILL.md +0 -82
- package/src/llms/skills/imf-web-ui-agent-setup/templates/hooks/post-tool-use.sh +0 -33
- package/src/llms/skills/imf-web-ui-agent-setup/templates/hooks/session-start.sh +0 -4
- package/src/llms/skills/imf-web-ui-agent-setup/templates/hooks/user-prompt-submit.sh +0 -4
- package/src/llms/skills/imf-web-ui-frontend-conventions/SKILL.md +0 -46
- package/src/llms/skills/imf-web-ui-frontend-conventions/references/assets.md +0 -23
- package/src/llms/skills/imf-web-ui-frontend-conventions/references/class-names.md +0 -42
- package/src/llms/skills/imf-web-ui-frontend-conventions/references/components.md +0 -63
- package/src/llms/skills/imf-web-ui-frontend-conventions/references/data.md +0 -201
- package/src/llms/skills/imf-web-ui-frontend-conventions/references/docs-structure.md +0 -44
- package/src/llms/skills/imf-web-ui-frontend-conventions/references/git.md +0 -33
- package/src/llms/skills/imf-web-ui-frontend-conventions/references/library-boundary.md +0 -37
- package/src/llms/skills/imf-web-ui-frontend-conventions/references/project-structure.md +0 -21
- package/src/llms/skills/imf-web-ui-frontend-conventions/references/react.md +0 -110
- package/src/llms/skills/imf-web-ui-frontend-conventions/references/stack.md +0 -39
- package/src/llms/skills/imf-web-ui-frontend-conventions/references/styling.md +0 -91
- package/src/llms/skills/imf-web-ui-frontend-conventions/references/testing.md +0 -18
- package/src/llms/skills/imf-web-ui-frontend-conventions/references/tooling.md +0 -76
- package/src/llms/skills/imf-web-ui-frontend-conventions/references/validation.md +0 -88
- package/src/llms/skills/imf-web-ui-frontend-setup/SKILL.md +0 -66
- package/src/llms/skills/imf-web-ui-frontend-setup/templates/README.md +0 -27
- package/src/llms/skills/imf-web-ui-library-setup/SKILL.md +0 -36
- /package/src/llms/{skills/imf-web-ui-frontend-setup/templates → install-templates}/AGENTS.md +0 -0
- /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
|
|
57
|
-
|
|
|
58
|
-
| `/imf-web-ui`
|
|
59
|
-
| `/imf-web-ui-
|
|
60
|
-
| `/imf-web-ui-components`
|
|
61
|
-
| `/imf-web-ui-ux`
|
|
62
|
-
| `/imf-web-ui-
|
|
63
|
-
| `/imf-web-ui-
|
|
64
|
-
| `/imf-web-ui-
|
|
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
|
|
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
|
|
17
|
-
//
|
|
18
|
-
//
|
|
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
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
// .
|
|
133
|
-
const
|
|
134
|
-
const
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
182
|
-
|
|
183
|
-
|
|
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(
|
|
202
|
+
if (existsSync(targetPath)) {
|
|
186
203
|
try {
|
|
187
|
-
settings = JSON.parse(readFileSync(
|
|
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
|
-
|
|
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
|
|
208
|
-
mkdirSync(dirname(
|
|
209
|
-
writeFileSync(
|
|
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(
|
|
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
|
-
//
|
|
266
|
-
//
|
|
267
|
-
// already registers
|
|
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 =
|
|
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
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
|
package/bin/install.test.ts
CHANGED
|
@@ -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-
|
|
47
|
-
expect(installed).toContain("imf-web-ui-
|
|
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", "
|
|
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(
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
104
|
-
expect(
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
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.
|
|
137
|
-
expect(commands.filter(command => command?.includes("
|
|
138
|
-
expect(commands).toContain('"${CLAUDE_PROJECT_DIR}/.agents/hooks/imf-web-ui/
|
|
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
|
|
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
|
|
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("
|
|
201
|
-
runInstall(consumer, "--hooks");
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
expect(
|
|
226
|
-
|
|
227
|
-
|
|
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.
|
|
3
|
+
"version": "0.5.1-dev.39.gfc64049e",
|
|
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": {
|