@imfusion/web-ui 0.5.1-dev.6.gece7a7b5 → 0.6.0

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 (98) hide show
  1. package/README.md +90 -43
  2. package/bin/install.js +428 -0
  3. package/bin/install.test.ts +329 -0
  4. package/dist/build/vite-css-module-names/index.d.ts +20 -0
  5. package/dist/build/vite-css-module-names.js +17 -0
  6. package/dist/chunk-DmhlhrBa.js +11 -0
  7. package/dist/code-Blo48PGr.js +136 -0
  8. package/dist/codegen/gen-icons.d.ts +24 -0
  9. package/dist/components/callout/callout.d.ts +1 -1
  10. package/dist/components/chip-link/chip-link.d.ts +1 -1
  11. package/dist/components/code/code.d.ts +10 -18
  12. package/dist/components/field/field.d.ts +104 -0
  13. package/dist/components/field/field.meta.d.ts +2 -0
  14. package/dist/components/field/index.d.ts +2 -0
  15. package/dist/components/fieldset/fieldset.d.ts +29 -0
  16. package/dist/components/fieldset/fieldset.meta.d.ts +2 -0
  17. package/dist/components/fieldset/index.d.ts +2 -0
  18. package/dist/components/icon/icon.d.ts +16 -0
  19. package/dist/components/icon/icon.meta.d.ts +2 -0
  20. package/dist/components/icon/index.d.ts +4 -0
  21. package/dist/components/icon/types.d.ts +2 -0
  22. package/dist/components/input/input.d.ts +3 -1
  23. package/dist/components/typo/typo.d.ts +2 -2
  24. package/dist/docgen/component-sources.d.ts +7 -0
  25. package/dist/hooks/index.d.ts +1 -0
  26. package/dist/hooks/use-resize-observer.d.ts +2 -0
  27. package/dist/icons/catalog.gen.d.ts +8357 -0
  28. package/dist/icons/icon-config-provider.d.ts +8 -0
  29. package/dist/icons/icon-context.d.ts +4 -0
  30. package/dist/icons/icons.gen.d.ts +1672 -0
  31. package/dist/icons/index.d.ts +3 -0
  32. package/dist/icons-wBmF0U2x.js +78 -0
  33. package/dist/icons.js +2 -0
  34. package/dist/index.d.ts +4 -1
  35. package/dist/index.js +1793 -12210
  36. package/dist/integrations/code-highlight/code-highlight.d.ts +8 -6
  37. package/dist/integrations/code-highlight/highlighter.d.ts +32 -3
  38. package/dist/integrations/code-highlight/language-patterns.d.ts +7 -0
  39. package/dist/integrations/code-highlight/languages/cmake.d.ts +1 -0
  40. package/dist/integrations/code-highlight/languages/cpp.d.ts +1 -0
  41. package/dist/integrations/code-highlight/languages/python.d.ts +1 -0
  42. package/dist/integrations/code-highlight.js +198 -59
  43. package/dist/integrations/image-display-options.js +70 -69
  44. package/dist/llms/gen-tokens.d.ts +7 -0
  45. package/dist/meta-CySnRuVp.js +21 -0
  46. package/dist/provider/web-ui-provider.d.ts +4 -1
  47. package/dist/style.css +1 -1
  48. package/dist/tabs-CMKvMF4E.js +369 -0
  49. package/package.json +48 -26
  50. package/src/docgen/doc.gen.json +381 -38
  51. package/src/llms/icon-catalog.gen.json +11203 -0
  52. package/src/llms/install-templates/AGENTS.md +34 -0
  53. package/src/llms/install-templates/codex-hooks.json +44 -0
  54. package/src/llms/install-templates/hooks/baseline-staleness.sh +17 -0
  55. package/src/llms/install-templates/hooks/session-start.sh +5 -0
  56. package/src/llms/install-templates/hooks/stop.sh +18 -0
  57. package/src/llms/install-templates/hooks/subagent-start.sh +5 -0
  58. package/src/llms/install-templates/hooks/user-prompt-submit.sh +5 -0
  59. package/src/llms/install-templates/settings.json +45 -0
  60. package/src/llms/llms.gen.txt +17 -0
  61. package/src/llms/skills/imf-web-ui/SKILL.md +13 -12
  62. package/src/llms/skills/imf-web-ui-audit/SKILL.md +119 -0
  63. package/src/llms/skills/imf-web-ui-components/SKILL.md +56 -3
  64. package/src/llms/skills/imf-web-ui-conventions/SKILL.md +57 -0
  65. package/src/llms/skills/imf-web-ui-conventions/templates/AUDIT_CHECKLIST.md +141 -0
  66. package/src/llms/skills/imf-web-ui-conventions/templates/REPORT.md +45 -0
  67. package/src/llms/skills/imf-web-ui-conventions/topics/agent-tooling.md +82 -0
  68. package/src/llms/skills/imf-web-ui-conventions/topics/assets.md +27 -0
  69. package/src/llms/skills/imf-web-ui-conventions/topics/authentication.md +65 -0
  70. package/src/llms/skills/imf-web-ui-conventions/topics/class-names.md +50 -0
  71. package/src/llms/skills/imf-web-ui-conventions/topics/components.md +101 -0
  72. package/src/llms/skills/imf-web-ui-conventions/topics/data.md +221 -0
  73. package/src/llms/skills/imf-web-ui-conventions/topics/docs-structure.md +40 -0
  74. package/src/llms/skills/imf-web-ui-conventions/topics/git.md +34 -0
  75. package/src/llms/skills/imf-web-ui-conventions/topics/library-boundary.md +35 -0
  76. package/src/llms/skills/imf-web-ui-conventions/topics/library-setup.md +26 -0
  77. package/src/llms/skills/imf-web-ui-conventions/topics/npm-project.md +53 -0
  78. package/src/llms/skills/imf-web-ui-conventions/topics/project-structure.md +44 -0
  79. package/src/llms/skills/imf-web-ui-conventions/topics/react.md +109 -0
  80. package/src/llms/skills/imf-web-ui-conventions/topics/styling.md +88 -0
  81. package/src/llms/skills/imf-web-ui-conventions/topics/testing.md +25 -0
  82. package/src/llms/skills/imf-web-ui-conventions/topics/tokens.md +7 -0
  83. package/src/llms/skills/imf-web-ui-conventions/topics/tooling.md +116 -0
  84. package/src/llms/skills/imf-web-ui-conventions/topics/typescript.md +73 -0
  85. package/src/llms/skills/imf-web-ui-conventions/topics/validation.md +62 -0
  86. package/src/llms/skills/imf-web-ui-setup/SKILL.md +67 -37
  87. package/src/llms/skills/imf-web-ui-update/SKILL.md +157 -0
  88. package/src/llms/skills/imf-web-ui-ux/SKILL.md +4 -4
  89. package/src/llms/skills/imf-web-ui-ux/references/forms.md +2 -2
  90. package/src/llms/tokens.gen.json +887 -0
  91. package/bin/install-skill.js +0 -180
  92. package/dist/code-qBbqAHK-.js +0 -190
  93. package/dist/meta-B8C51eyL.js +0 -74
  94. package/dist/tabs-DqBFSqq6.js +0 -3789
  95. package/src/llms/skills/imf-web-ui-frontend-patterns/SKILL.md +0 -93
  96. package/src/llms/skills/imf-web-ui-frontend-patterns/references/code-conventions.md +0 -133
  97. package/src/llms/skills/imf-web-ui-frontend-patterns/references/react-patterns.md +0 -94
  98. package/src/llms/skills/imf-web-ui-imfusion-frontend-setup/SKILL.md +0 -201
package/README.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  `@imfusion/web-ui` — the official shared Web UI library for ImFusion web apps, built on Base UI.
4
4
 
5
+ - [Usage](#usage) — install, wire up, and keep the version current
6
+ - [Documentation](#documentation) — Storybook and the developer docs
7
+ - [Development](#development) — working on the library itself, including the Agent Skills
8
+ - [Releasing](#releasing) — how versions are derived and how to cut one
9
+
5
10
  ## Usage
6
11
 
7
12
  The packages are private on npmjs.com.
@@ -33,8 +38,41 @@ import "@imfusion/web-ui/styles.css";
33
38
  import { WebUIProvider } from "@imfusion/web-ui";
34
39
  ```
35
40
 
36
- **There is also an AI skill to help you set up the Web UI library in your project:
37
- [`/imf-web-ui-setup`](./src/llms/skills/imf-web-ui-setup/SKILL.md). **
41
+ ### LLM integration
42
+
43
+ The `web-ui` package does offer first-class support for LLMs, but it's not wired up by default.
44
+
45
+ To install the skills, run this command after the package is installed:
46
+
47
+ ```bash
48
+ npx web-ui-install
49
+ ```
50
+
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
+ the directories it already installed into, without asking again — `--reconfigure` re-opens that choice.
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
+
63
+ **Available consumer skills - `/imf-web-ui-*`**
64
+
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 and icon 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, ending in a plan you approve. |
73
+ | `/imf-web-ui-update` | Update the library, skills, and optional hooks, then verify before committing. |
74
+
75
+ Start at `/imf-web-ui` — it routes to the rest. Storybook's **User Guide → AI Agents** page covers the whole family.
38
76
 
39
77
  ### Versions and updates
40
78
 
@@ -58,20 +96,6 @@ Every merge to master publishes a new build under the `dev` dist-tag. Which spec
58
96
  - **A `file:` tarball never updates.** npm copies the archive into `node_modules` and re-copies the same one on every
59
97
  install. Switch to `dev` or a version range to get updates.
60
98
 
61
- ### Consumer skills — `imf-web-ui-*`
62
-
63
- These skills are shipped with a `imf-web-ui` prefix, so to make their names identifiable and not pollute the skill namespace
64
- of the user.
65
-
66
- | Skill | What it does |
67
- | ------------------------------------- | ------------------------------------------------------------------------------------ |
68
- | `/imf-web-ui` | The router: decides whether guidance is needed, then which companion to open. |
69
- | `/imf-web-ui-setup` | One-time wiring in a consumer project — the styles import and the provider. |
70
- | `/imf-web-ui-components` | Component reference — what exists and how it's meant to be used. |
71
- | `/imf-web-ui-ux` | UX guidance for building interfaces with the library. |
72
- | `/imf-web-ui-frontend-patterns` | Frontend patterns, including the sanctioned styling seams (tokens, data attributes). |
73
- | `/imf-web-ui-imfusion-frontend-setup` | Set up or audit an ImFusion frontend's tooling against the house baseline. |
74
-
75
99
  ## Documentation
76
100
 
77
101
  **[Storybook](https://storybook.js.org/)** is the documentation platform — the component catalog, every prop, and the setup
@@ -94,27 +118,35 @@ npm run git:config # hooks path + rebase-only pull/merge
94
118
 
95
119
  **Try out **`/web-ui-dev-getting-started`** for an ai assisted start.**
96
120
 
97
- ### Development skills — `web-ui-dev-*`
98
-
99
- These skills are shipped with a `web-ui-dev` prefix, so to make their names identifiable and not pollute the skill namespace.
100
-
101
- | Skill | What it does |
102
- | ----------------------------------------- | ------------------------------------------------------------------------------------- |
103
- | `/web-ui-dev-getting-started` | Interactive intro — gauges your experience, works out your goal, routes you. |
104
- | `/web-ui-dev-start` | Starting ritual for any task: Jira context, `master` vs. a worktree, context summary. |
105
- | `/web-ui-dev-new-component` | Scaffolds an architecture-compliant primitive (adapted or absorbed). |
106
- | `/web-ui-dev-update-component` | Pointers to every file a prop, variant, or sub-component change touches. |
107
- | `/web-ui-dev-design-component` | Brand design pass for a component that works but isn't styled yet. |
108
- | `/web-ui-dev-story` | Author or update a Storybook story. |
109
- | `/web-ui-dev-commit` | Commit workflow: staged docs audit, CI-parity checks, house commit format. |
110
- | `/web-ui-dev-audit-docs` | Audit docs against staged or recent changes for staleness, gaps, and drift. |
111
- | `/web-ui-dev-audit-pass-through-defaults` | Check `@default` annotations on pass-through props against Base UI upstream. |
112
- | `/web-ui-dev-manage-worktrees` | Create, close, or discard a worktree in `.workspaces/`. |
113
- | `/web-ui-dev-refresh-design-reference` | Refresh the committed brand snapshots in `design/` from the Figma styleguide. |
114
- | `/web-ui-dev-mcp` | Set up or recover Storybook, DevTools, Atlassian, or Figma MCP access. |
121
+ ### LLM integration
122
+
123
+ Web UI development skills use a `/web-ui-dev-*` prefix. `documentation-writer` comes from `npx skills`; it is not a native
124
+ Web UI skill.
125
+
126
+ | Skill | What it does |
127
+ | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
128
+ | `/documentation-writer` | External `npx skills` workflow for Diátaxis documentation. |
129
+ | `/web-ui-dev-getting-started` | Interactive intro — gauges your experience, works out your goal, routes you. |
130
+ | `/web-ui-dev-start` | Starting ritual for any task: Jira context, `master` vs. a worktree, context summary. |
131
+ | `/web-ui-dev-new-component` | Scaffolds an architecture-compliant primitive (adapted or absorbed). |
132
+ | `/web-ui-dev-update-component` | Pointers to every file a prop, variant, or sub-component change touches. |
133
+ | `/web-ui-dev-design-component` | Brand design pass for a component that works but isn't styled yet. |
134
+ | `/web-ui-dev-story` | Author or update a Storybook story. |
135
+ | `/web-ui-dev-commit` | Commit workflow: changelog skill, staged docs audit, CI-parity checks, house commit format. |
136
+ | `/web-ui-dev-release` | Prepare release highlights, commit release notes, verify, and create an annotated local tag. |
137
+ | `/web-ui-dev-changelog` | Generate and validate the required one-line CHANGELOG.md entry for a web-ui change, including direct commits to master. |
138
+ | `/web-ui-dev-audit-docs` | Audit docs against staged or recent changes for staleness, gaps, and drift. |
139
+ | `/web-ui-dev-audit-pass-through-defaults` | Check `@default` annotations on pass-through props against Base UI upstream. |
140
+ | `/web-ui-dev-teardown-worktree` | Tear down a worktree, wherever it lives — merging its branch first or dropping it. |
141
+ | `/web-ui-dev-refresh-design-reference` | Refresh the committed brand snapshots in `design/` from the Figma styleguide. |
142
+ | `/web-ui-dev-mcp` | Set up or recover Storybook, DevTools, Atlassian, or Figma MCP access. |
115
143
 
116
144
  ## Releasing
117
145
 
146
+ Release-facing changes are recorded as one-line entries in [`CHANGELOG.md`](./CHANGELOG.md) under `## [Unreleased]`. The
147
+ commit workflow requires an entry for pull-request commits and direct commits to `master`, with only narrow mechanical
148
+ exceptions. Each entry ends with its author as ` — Name <email>`, taken from the committer's git config.
149
+
118
150
  The published version is derived from git tags, not from `package.json`.
119
151
 
120
152
  The `version` field stays at `0.0.0` in the repository and he CI computes the real number with
@@ -131,8 +163,18 @@ Given the tag `web-ui/v0.5.0`:
131
163
  | Exactly on `web-ui/v0.5.0` | `0.5.0` | `latest` |
132
164
  | 5 commits after that tag | `0.5.1-dev.5.gb4de52d7` | `dev` |
133
165
 
134
- where `5` is the number of commits since the tag and `b4de52d7` the abbreviated hash. Off-tag builds bump the patch, so a dev
135
- build always sorts above the release it follows and below the next one.
166
+ The off-tag string breaks down as:
167
+
168
+ ```
169
+ 0.5.1-dev.5.gb4de52d7
170
+ └─┬─┘ └┬┘ │ └───┬────┘
171
+ │ │ │ └── commit hash, abbreviated; the leading g means "git"
172
+ │ │ └──────── commits since the tag
173
+ │ └─────────── pre-release marker, which is what puts it on the dev tag
174
+ └──────────────── the tag's version, patch bumped
175
+ ```
176
+
177
+ Bumping the patch is what makes a dev build sort above the release it follows and below the next one.
136
178
 
137
179
  Every build of `master` publishes a `dev` version automatically. To check what the current checkout would publish:
138
180
 
@@ -142,18 +184,23 @@ npx tsx scripts/version.ts # prints, changes nothing
142
184
 
143
185
  ### Cutting a release
144
186
 
145
- Tag the commit you want to ship and push the tag:
187
+ The detailed Unreleased entries are the release record. Before cutting a version, add one to three titled `Highlights` blocks
188
+ at the top for people deciding whether to update. Start each with the outcome; include a small usage example or link when it
189
+ helps someone explore the feature. Then prepare the dated release section:
146
190
 
147
191
  ```bash
148
- git tag -a web-ui/v0.5.1 -m "web-ui 0.5.1"
149
- git push origin web-ui/v0.5.1
192
+ npm run release:prepare -- --version 0.5.1
150
193
  ```
151
194
 
152
- The tag triggers a build that publishes `0.5.1` as `latest` and repoints the `dev` dist-tag at the same version.
195
+ Commit that change through `/web-ui-dev-release`, then create the local annotated tag:
196
+
197
+ ```bash
198
+ npm run release:tag -- --version 0.5.1
199
+ ```
153
200
 
154
- **The tag must be annotated (`-a`).** `git describe` ignores lightweight tags, so a tag pushed without `-a` is invisible to
155
- the version script — the build reads through it to the previous release tag and publishes another `dev` version instead of
156
- the release.
201
+ The tag command requires a clean working tree, runs full verification, and creates `web-ui/v0.5.1`. It never pushes. Push
202
+ that tag only after explicit approval. The tag triggers a build that publishes `0.5.1` as `latest` and repoints the `dev`
203
+ dist-tag at the same version.
157
204
 
158
205
  The tag name must match `web-ui/vX.Y.Z` exactly. Both the version script's `--match` and the TeamCity branch filter in
159
206
  [`.teamcity/settings.kts`](./.teamcity/settings.kts) key off that shape; a tag in any other form publishes nothing.
package/bin/install.js ADDED
@@ -0,0 +1,428 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Installs this package's agent tooling into the consumer project: the
4
+ // skill family (../src/llms/skills/imf-web-ui*/) and the agent lifecycle
5
+ // hooks in the installer-owned template home. Runs in the
6
+ // CONSUMER's environment, so it may only use this package's real runtime
7
+ // dependencies (@clack/prompts). Never wire this into a postinstall hook:
8
+ // ambient script execution on `npm install` is a live supply-chain attack
9
+ // vector — install stays an explicit, user-run command.
10
+ //
11
+ // The skills cross-reference each other, so they install as one bundle.
12
+ // When both targets are selected, .agents/skills/ holds the real copy and
13
+ // .claude/skills/ symlinks it (this repo's own convention) so the two
14
+ // can't drift apart.
15
+ //
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,
20
+ // --reconfigure to re-open the target prompt on an existing install.
21
+
22
+ import {
23
+ chmodSync,
24
+ cpSync,
25
+ existsSync,
26
+ lstatSync,
27
+ mkdirSync,
28
+ readdirSync,
29
+ readFileSync,
30
+ rmSync,
31
+ symlinkSync,
32
+ writeFileSync
33
+ } from "node:fs";
34
+ import { spawnSync } from "node:child_process";
35
+ import { dirname, relative, resolve } from "node:path";
36
+ import { fileURLToPath } from "node:url";
37
+ import * as p from "@clack/prompts";
38
+
39
+ const SKILL_PREFIX = "imf-web-ui";
40
+ const VERSION_MARKER = ".imf-web-ui-skill-version.json";
41
+
42
+ const here = dirname(fileURLToPath(import.meta.url));
43
+ // Source is relative to THIS SCRIPT's location (inside node_modules), not
44
+ // the consumer's cwd — the script is invoked from the consumer's project
45
+ // root, but the skills it copies ship alongside this file in the package.
46
+ const skillsRoot = resolve(here, "..", "src", "llms", "skills");
47
+ const packageJson = JSON.parse(readFileSync(resolve(here, "..", "package.json"), "utf-8"));
48
+ const currentVersion = packageJson.version;
49
+
50
+ // Destination is relative to the consumer's project root (cwd), since
51
+ // that's where their `.claude/` or `.agents/` directory lives.
52
+ const projectRoot = process.cwd();
53
+
54
+ const TARGETS = {
55
+ claude: { label: "Claude Code", root: resolve(projectRoot, ".claude", "skills") },
56
+ agents: { label: "Vendor-neutral (.agents/)", root: resolve(projectRoot, ".agents", "skills") }
57
+ };
58
+
59
+ function discoverSkills() {
60
+ if (!existsSync(skillsRoot)) return [];
61
+ return readdirSync(skillsRoot, { withFileTypes: true })
62
+ .filter(entry => entry.isDirectory() && entry.name.startsWith(SKILL_PREFIX))
63
+ .map(entry => entry.name)
64
+ .sort();
65
+ }
66
+
67
+ function displayPath(absPath) {
68
+ return `./${relative(projectRoot, absPath)}`;
69
+ }
70
+
71
+ function readInstalledVersion(dir) {
72
+ const markerPath = resolve(dir, VERSION_MARKER);
73
+ if (!existsSync(markerPath)) return null;
74
+ try {
75
+ return JSON.parse(readFileSync(markerPath, "utf-8")).version ?? null;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ function writeRealCopy(sourceDir, destDir) {
82
+ mkdirSync(dirname(destDir), { recursive: true });
83
+ // force: true makes re-running after a version bump overwrite cleanly.
84
+ cpSync(sourceDir, destDir, { recursive: true, force: true });
85
+ writeFileSync(resolve(destDir, VERSION_MARKER), JSON.stringify({ version: currentVersion }, null, 2) + "\n");
86
+ }
87
+
88
+ function writeSymlink(linkPath, targetPath) {
89
+ mkdirSync(dirname(linkPath), { recursive: true });
90
+ // lstat (not existsSync, which follows symlinks) catches a broken/stale
91
+ // symlink left over from a prior run, not just a real file or directory.
92
+ if (lstatSync(linkPath, { throwIfNoEntry: false })) {
93
+ rmSync(linkPath, { recursive: true, force: true });
94
+ }
95
+ symlinkSync(relative(dirname(linkPath), targetPath), linkPath);
96
+ }
97
+
98
+ function removeOrphanedSkills(selected, skills, both) {
99
+ const bundledSkills = new Set(skills);
100
+ const selectedTargets = both ? ["agents", "claude"] : selected;
101
+
102
+ for (const key of selectedTargets) {
103
+ const { root } = TARGETS[key];
104
+ if (!existsSync(root)) continue;
105
+
106
+ const orphanedSkills = readdirSync(root).filter(name => name.startsWith(`${SKILL_PREFIX}-`) && !bundledSkills.has(name));
107
+ for (const name of orphanedSkills) {
108
+ rmSync(resolve(root, name), { recursive: true, force: true });
109
+ // In the two-target layout, .claude/ is a symlinked view of the
110
+ // canonical .agents/ copy. Remove its matching link along with the
111
+ // orphaned canonical skill.
112
+ if (both && key === "agents") {
113
+ rmSync(resolve(TARGETS.claude.root, name), { recursive: true, force: true });
114
+ }
115
+ p.log.info(`Removed orphaned skill: ${name} (no longer in this bundle)`);
116
+ }
117
+ }
118
+ }
119
+
120
+ // A prior install is recoverable from the filesystem, so a re-run doesn't
121
+ // re-ask: a target counts as chosen when any bundled skill is present under
122
+ // it. Symlinks count — they're how the both-targets layout represents
123
+ // .claude/, and lstat avoids following them into the real copy.
124
+ function detectInstalledTargets(skills) {
125
+ return Object.keys(TARGETS).filter(key =>
126
+ skills.some(name => lstatSync(resolve(TARGETS[key].root, name), { throwIfNoEntry: false }))
127
+ );
128
+ }
129
+
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");
138
+ // Installer-owned subdirectory: refreshed wholesale on every run, so a repo's
139
+ // own hooks in .agents/hooks/ are never touched.
140
+ const hooksDestDir = resolve(projectRoot, ".agents", "hooks", "imf-web-ui");
141
+ const settingsPath = resolve(projectRoot, ".claude", "settings.json");
142
+ const codexHooksPath = resolve(projectRoot, ".codex", "hooks.json");
143
+
144
+ // Serializing the file reflows entries this installer doesn't own; the
145
+ // consumer's own Prettier puts them back. Best-effort: absent, or the path is
146
+ // .prettierignore'd, nothing happens.
147
+ function formatSettings(path) {
148
+ let toolRoot = projectRoot;
149
+ while (toolRoot !== dirname(toolRoot) && !existsSync(resolve(toolRoot, "node_modules", ".bin"))) {
150
+ toolRoot = dirname(toolRoot);
151
+ }
152
+ const prettier = resolve(toolRoot, "node_modules", ".bin", "prettier");
153
+ if (!existsSync(prettier)) return;
154
+ spawnSync(prettier, ["--write", path], { cwd: projectRoot, stdio: "ignore" });
155
+ }
156
+
157
+ function installHookScripts() {
158
+ const scripts = readdirSync(hooksSourceDir).filter(name => name.endsWith(".sh"));
159
+ mkdirSync(hooksDestDir, { recursive: true });
160
+ for (const name of scripts) {
161
+ const dest = resolve(hooksDestDir, name);
162
+ cpSync(resolve(hooksSourceDir, name), dest, { force: true });
163
+ chmodSync(dest, 0o755);
164
+ }
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 };
172
+ }
173
+
174
+ // A registration is identified by the script it runs, not the command string:
175
+ // "$VAR/..." and "${VAR}/..." are the same hook.
176
+ const HOOKS_DIR_SEGMENT = ".agents/hooks/imf-web-ui/";
177
+
178
+ function hookScriptId(command) {
179
+ if (typeof command !== "string") return null;
180
+ const at = command.lastIndexOf(HOOKS_DIR_SEGMENT);
181
+ if (at === -1) return null;
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;
188
+ }
189
+
190
+ function entryScriptIds(entry) {
191
+ return (entry.hooks ?? []).map(hook => hookScriptId(hook.command)).filter(Boolean);
192
+ }
193
+
194
+ // Merge, never clobber: a registration is added only when no existing entry
195
+ // for that event already runs the same script, so re-runs are idempotent and
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) {
201
+ let settings = {};
202
+ if (existsSync(targetPath)) {
203
+ try {
204
+ settings = JSON.parse(readFileSync(targetPath, "utf-8"));
205
+ } catch {
206
+ return null;
207
+ }
208
+ }
209
+ settings.hooks ??= {};
210
+ let added = 0;
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)) {
240
+ settings.hooks[event] ??= [];
241
+ for (const entry of entries) {
242
+ const scripts = entryScriptIds(entry);
243
+ const present = settings.hooks[event].some(existing =>
244
+ entryScriptIds(existing).some(script => scripts.includes(script))
245
+ );
246
+ if (!present) {
247
+ settings.hooks[event].push(entry);
248
+ added++;
249
+ }
250
+ }
251
+ }
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 };
257
+ }
258
+
259
+ // The AGENTS.md baseline note lives inside a fenced block owned by this
260
+ // installer. The template between the markers is the source of truth; an
261
+ // existing AGENTS.md gets the block replaced in place (or appended when
262
+ // absent), everything outside the fence is untouched. No AGENTS.md at all
263
+ // is left alone — scaffolding one is the setup skill's job.
264
+ const AGENTS_BLOCK_BEGIN = "<!-- imf-web-ui:begin";
265
+ const AGENTS_BLOCK_END = "<!-- imf-web-ui:end -->";
266
+ const agentsTemplatePath = resolve(installTemplatesRoot, "AGENTS.md");
267
+ const agentsPath = resolve(projectRoot, "AGENTS.md");
268
+
269
+ function extractAgentsBlock(content) {
270
+ const begin = content.indexOf(AGENTS_BLOCK_BEGIN);
271
+ const end = content.indexOf(AGENTS_BLOCK_END);
272
+ if (begin === -1 || end === -1) return null;
273
+ return content.slice(begin, end + AGENTS_BLOCK_END.length);
274
+ }
275
+
276
+ function upsertAgentsBlock() {
277
+ if (!existsSync(agentsPath)) return "absent";
278
+ const block = extractAgentsBlock(readFileSync(agentsTemplatePath, "utf-8"));
279
+ if (!block) return "absent";
280
+ const current = readFileSync(agentsPath, "utf-8");
281
+ const existing = extractAgentsBlock(current);
282
+ const next = existing ? current.replace(existing, block) : `${current.trimEnd()}\n\n${block}\n`;
283
+ if (next === current) return "unchanged";
284
+ writeFileSync(agentsPath, next);
285
+ return existing ? "updated" : "added";
286
+ }
287
+
288
+ // --target claude|agents (repeatable) selects targets without the
289
+ // interactive prompt — for CI and scripted installs.
290
+ function parseTargetFlags(argv) {
291
+ const targets = [];
292
+ for (let i = 0; i < argv.length; i++) {
293
+ if (argv[i] !== "--target") continue;
294
+ const value = argv[i + 1];
295
+ if (!value || !(value in TARGETS)) {
296
+ console.error(`--target expects one of: ${Object.keys(TARGETS).join(", ")}`);
297
+ process.exit(1);
298
+ }
299
+ targets.push(value);
300
+ i++;
301
+ }
302
+ return targets;
303
+ }
304
+
305
+ async function main() {
306
+ const skills = discoverSkills();
307
+ const argv = process.argv.slice(2);
308
+ const flagTargets = parseTargetFlags(argv);
309
+ const reconfigure = argv.includes("--reconfigure");
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.
313
+ const wantHooks = argv.includes("--hooks");
314
+ const wantSkills = true;
315
+
316
+ const components = [wantSkills && `${skills.length} skills`, wantHooks && "agent hooks"].filter(Boolean);
317
+ p.intro(`@imfusion/web-ui install — ${components.join(" + ")}`);
318
+
319
+ if (wantSkills && skills.length === 0) {
320
+ p.log.error(`No skills found at ${skillsRoot}. Reinstall @imfusion/web-ui and try again.`);
321
+ p.outro("Nothing installed.");
322
+ process.exitCode = 1;
323
+ return;
324
+ }
325
+
326
+ if (wantHooks) {
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)`);
331
+ }
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
+ }
349
+ }
350
+ }
351
+
352
+ const installedTargets = detectInstalledTargets(skills);
353
+
354
+ const existingVersions = Object.values(TARGETS)
355
+ .flatMap(({ root }) => skills.map(name => readInstalledVersion(resolve(root, name))))
356
+ .filter(Boolean);
357
+ if (existingVersions.length > 0 && existingVersions.every(v => v === currentVersion)) {
358
+ p.log.info(`Already up to date (v${currentVersion}). Re-running will overwrite with the same content.`);
359
+ } else if (existingVersions.some(v => v !== currentVersion)) {
360
+ const from = existingVersions.find(v => v !== currentVersion);
361
+ p.log.info(`Updating installed skills from v${from} to v${currentVersion}.`);
362
+ }
363
+
364
+ p.log.message(`Skills in this bundle:\n${skills.map(name => ` - ${name}`).join("\n")}`);
365
+
366
+ let selected;
367
+ if (flagTargets.length > 0) {
368
+ selected = flagTargets;
369
+ p.log.info(`Targets from --target flags: ${selected.join(", ")}`);
370
+ } else if (installedTargets.length > 0 && !reconfigure) {
371
+ selected = installedTargets;
372
+ p.log.info(
373
+ `Refreshing the existing install: ${selected.map(key => displayPath(TARGETS[key].root)).join(", ")}` +
374
+ ` — pass --reconfigure to choose different targets.`
375
+ );
376
+ } else {
377
+ selected = await p.multiselect({
378
+ message: "Install into which skill directory (or directories)?",
379
+ options: Object.entries(TARGETS).map(([key, { label, root }]) => ({
380
+ value: key,
381
+ label,
382
+ hint: displayPath(root)
383
+ })),
384
+ required: true
385
+ });
386
+
387
+ if (p.isCancel(selected)) {
388
+ p.cancel("Cancelled — nothing installed.");
389
+ return;
390
+ }
391
+ }
392
+
393
+ const both = selected.includes("claude") && selected.includes("agents");
394
+
395
+ for (const name of skills) {
396
+ const sourceDir = resolve(skillsRoot, name);
397
+ if (both) {
398
+ // .agents/ is the canonical real copy; .claude/ aliases it via symlink.
399
+ const realDir = resolve(TARGETS.agents.root, name);
400
+ writeRealCopy(sourceDir, realDir);
401
+ writeSymlink(resolve(TARGETS.claude.root, name), realDir);
402
+ } else {
403
+ for (const key of selected) {
404
+ writeRealCopy(sourceDir, resolve(TARGETS[key].root, name));
405
+ }
406
+ }
407
+ }
408
+
409
+ removeOrphanedSkills(selected, skills, both);
410
+
411
+ if (both) {
412
+ p.log.success(`Vendor-neutral (.agents/) -> ${displayPath(TARGETS.agents.root)}/${SKILL_PREFIX}*`);
413
+ p.log.success(`Claude Code -> ${displayPath(TARGETS.claude.root)}/${SKILL_PREFIX}* (symlinks -> .agents/)`);
414
+ } else {
415
+ for (const key of selected) {
416
+ p.log.success(`${TARGETS[key].label} -> ${displayPath(TARGETS[key].root)}/${SKILL_PREFIX}*`);
417
+ }
418
+ }
419
+
420
+ const agentsResult = upsertAgentsBlock();
421
+ if (agentsResult === "updated" || agentsResult === "added") {
422
+ p.log.success(`Refreshed the imf-web-ui block in ${displayPath(agentsPath)}`);
423
+ }
424
+
425
+ p.outro(`Done — ${skills.length} skills installed${wantHooks ? " + agent hooks" : ""}.`);
426
+ }
427
+
428
+ await main();