@imfusion/web-ui 0.5.1-dev.18.g81350f83 → 0.5.1-dev.2.g0a349a2a
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 +43 -122
- package/bin/install-skill.js +180 -0
- package/dist/components/logo/logo.d.ts +1 -1
- package/dist/index.js +2 -4
- package/dist/style.css +1 -1
- package/package.json +24 -34
- package/src/docgen/doc.gen.json +1 -1
- package/src/llms/skills/imf-web-ui/SKILL.md +11 -15
- package/src/llms/skills/imf-web-ui-components/SKILL.md +1 -1
- package/src/llms/skills/imf-web-ui-frontend-patterns/SKILL.md +93 -0
- package/src/llms/skills/imf-web-ui-frontend-patterns/references/code-conventions.md +133 -0
- package/src/llms/skills/{imf-web-ui-frontend-conventions/references/react.md → imf-web-ui-frontend-patterns/references/react-patterns.md} +12 -26
- package/src/llms/skills/imf-web-ui-imfusion-frontend-setup/SKILL.md +201 -0
- package/src/llms/skills/imf-web-ui-setup/SKILL.md +57 -0
- package/src/llms/skills/imf-web-ui-ux/SKILL.md +4 -4
- package/src/llms/skills/imf-web-ui-ux/references/forms.md +1 -1
- package/bin/install.js +0 -319
- package/bin/install.test.ts +0 -139
- package/dist/build/vite-css-module-names/index.d.ts +0 -20
- package/dist/build/vite-css-module-names.js +0 -17
- package/src/llms/skills/imf-web-ui-agent-setup/SKILL.md +0 -71
- package/src/llms/skills/imf-web-ui-agent-setup/templates/hooks/baseline-staleness.sh +0 -17
- package/src/llms/skills/imf-web-ui-agent-setup/templates/hooks/post-tool-use.sh +0 -21
- 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-agent-setup/templates/settings.json +0 -37
- package/src/llms/skills/imf-web-ui-frontend-conventions/SKILL.md +0 -45
- 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 -130
- 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/npm-project.md +0 -57
- 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/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 -16
- package/src/llms/skills/imf-web-ui-frontend-conventions/references/tooling.md +0 -76
- package/src/llms/skills/imf-web-ui-frontend-conventions/references/typescript.md +0 -45
- package/src/llms/skills/imf-web-ui-frontend-setup/SKILL.md +0 -89
- package/src/llms/skills/imf-web-ui-frontend-setup/templates/AGENTS.md +0 -34
- 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
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: imf-web-ui-imfusion-frontend-setup
|
|
3
|
+
description:
|
|
4
|
+
"Set up or audit an ImFusion frontend's project tooling: the stack, package.json scripts, formatting, linting, typecheck,
|
|
5
|
+
staged-file and pre-commit hooks, verification scopes, dependency pinning, tsconfig, folder structure. House conventions,
|
|
6
|
+
not industry standards. Load when starting a new ImFusion frontend, or when asked what an existing one's setup is missing.
|
|
7
|
+
Not for adding one config file on request — that's just the edit. Not for wiring the library itself (imf-web-ui-setup)."
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# imf-web-ui-imfusion-frontend-setup
|
|
11
|
+
|
|
12
|
+
ImFusion house conventions, **not** industry standards. Report as "missing against the ImFusion baseline", never "against
|
|
13
|
+
best practice". Only apply to an ImFusion frontend.
|
|
14
|
+
|
|
15
|
+
Two modes, same list:
|
|
16
|
+
|
|
17
|
+
- **New project** — work down the list and set each piece up.
|
|
18
|
+
- **Existing project** — audit. Read the repo (don't ask what it has), report present / missing / broken, change nothing
|
|
19
|
+
until the human picks. An established repo is where a forgotten piece hides.
|
|
20
|
+
|
|
21
|
+
**The project wins.** Where the repo already decided, that stands. Report what's _absent_; a working convention you'd have
|
|
22
|
+
chosen differently is not a finding.
|
|
23
|
+
|
|
24
|
+
## The stack
|
|
25
|
+
|
|
26
|
+
| Concern | Tool | Notes |
|
|
27
|
+
| ------------ | --------------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
|
28
|
+
| Format | [Prettier](https://prettier.io) | Values below are shared across repos |
|
|
29
|
+
| Lint | [ESLint](https://eslint.org) flat config | `--cache --max-warnings=0` |
|
|
30
|
+
| Types | `tsc --noEmit` | Own script, own CI step |
|
|
31
|
+
| Staged files | [lint-staged](https://github.com/lint-staged/lint-staged) | [nano-staged](https://github.com/usmanyunusov/nano-staged) is a drop-in alternative |
|
|
32
|
+
| Routing | [TanStack Router](https://tanstack.com/router) | File-based, `src/routes/` |
|
|
33
|
+
| Server state | [TanStack Query](https://tanstack.com/query) | Query keys colocated per API topic |
|
|
34
|
+
| Client state | [TanStack Store](https://tanstack.com/store) | Only for state that isn't URL- or server-owned |
|
|
35
|
+
| Styling | CSS Modules | Colocated `<component>.module.css` |
|
|
36
|
+
| Dead code | [knip](https://knipjs.dev) | Needs per-repo config |
|
|
37
|
+
| Build | [Vite](https://vite.dev) | |
|
|
38
|
+
| Test | [Vitest](https://vitest.dev) | |
|
|
39
|
+
|
|
40
|
+
Either staged-file runner is fine; lint-staged is the larger project and the safer default when one misbehaves.
|
|
41
|
+
|
|
42
|
+
### Devtools come with the library
|
|
43
|
+
|
|
44
|
+
Every TanStack library that ships a devtools package gets it as a dev dependency alongside the library itself, mounted in
|
|
45
|
+
development only. Router is a given in any ImFusion frontend, so `@tanstack/react-router-devtools` is a given too; Query's
|
|
46
|
+
goes in when Query does, and so on. Look for a `-devtools` sibling whenever you add a TanStack dependency rather than working
|
|
47
|
+
from a fixed list — the set grows, and not every library has one yet (Store doesn't). Once a project has several,
|
|
48
|
+
`@tanstack/devtools` hosts them in one panel.
|
|
49
|
+
|
|
50
|
+
## package.json
|
|
51
|
+
|
|
52
|
+
`"type": "module"`, `"private": true`. Scripts — these names, in every repo:
|
|
53
|
+
|
|
54
|
+
| Script | Runs |
|
|
55
|
+
| ------------------ | -------------------------------------------------------- |
|
|
56
|
+
| `dev` | dev server |
|
|
57
|
+
| `build` | production build |
|
|
58
|
+
| `verify:lint` | `eslint . --cache --max-warnings=0` |
|
|
59
|
+
| `verify:format` | `prettier --check .` |
|
|
60
|
+
| `verify:typecheck` | `tsc --noEmit` |
|
|
61
|
+
| `verify:tests` | `vitest run` |
|
|
62
|
+
| `verify:staged` | staged-file subset, called by the pre-commit hook |
|
|
63
|
+
| `verify:full` | every check above plus the build; what CI runs |
|
|
64
|
+
| `format` | `prettier --write .` |
|
|
65
|
+
| `lint:fix` | `eslint . --cache --fix` |
|
|
66
|
+
| `git:config` | `git config core.hooksPath …` + `pull.rebase`/`merge.ff` |
|
|
67
|
+
|
|
68
|
+
**Every check is `verify:*`.** One namespace, so "what can I run to check this?" is answered by tab-completion. Write-mode
|
|
69
|
+
scripts keep tool names — `format` and `lint:fix` change files, which isn't verifying. Same name, same meaning, every repo.
|
|
70
|
+
|
|
71
|
+
**Git config is an explicit command.** `git:config` holds the real command, greppable and run by hand after cloning, and the
|
|
72
|
+
README's setup steps name it. `ignore-scripts=true` disables the root package's own lifecycle scripts alongside its
|
|
73
|
+
dependencies', so setup that matters is a command someone runs, not a hook that fires on install.
|
|
74
|
+
|
|
75
|
+
**Dependencies pinned exactly.** No `^`, `~`, or `latest`, in `dependencies` and `devDependencies` alike. A check script in
|
|
76
|
+
the verify chain enforces it, but that only catches drift after it lands — `save-exact=true` in `.npmrc` stops `npm install`
|
|
77
|
+
reintroducing ranges in the first place.
|
|
78
|
+
|
|
79
|
+
## Config
|
|
80
|
+
|
|
81
|
+
**Prettier** — config file shape is free (`.prettierrc`, `prettier.config.ts`); the values are not:
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
printWidth: 125 tabWidth: 2 useTabs: false trailingComma: "none"
|
|
85
|
+
arrowParens: "avoid" semi: true singleQuote: false proseWrap: "always"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
No config file at all means Prettier runs on defaults — flag it, the values silently differ.
|
|
89
|
+
|
|
90
|
+
**ESLint** — flat config (`eslint.config.ts`), `strictTypeChecked` + `stylisticTypeChecked` with `projectService: true`, `as`
|
|
91
|
+
and `!` banned outside tests, and `.gitignore` as the ignore source (`includeIgnoreFile` from `@eslint/compat`) so ignores
|
|
92
|
+
aren't maintained twice.
|
|
93
|
+
|
|
94
|
+
**tsconfig** — defaults:
|
|
95
|
+
|
|
96
|
+
```jsonc
|
|
97
|
+
{
|
|
98
|
+
"compilerOptions": {
|
|
99
|
+
"strict": true,
|
|
100
|
+
"moduleResolution": "bundler",
|
|
101
|
+
"verbatimModuleSyntax": true, // import type stays import type
|
|
102
|
+
"noUnusedLocals": true,
|
|
103
|
+
"noUnusedParameters": true,
|
|
104
|
+
"noFallthroughCasesInSwitch": true,
|
|
105
|
+
"noUncheckedSideEffectImports": true,
|
|
106
|
+
"skipLibCheck": true,
|
|
107
|
+
"paths": { "#/*": ["./src/*"] }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The alias is always `#/` → `src/`. `#` is Node's own subpath-import prefix, so it resolves without a bundler-specific
|
|
113
|
+
convention, and it can't collide with an npm scope the way `@/` does.
|
|
114
|
+
|
|
115
|
+
**Staged files** — runner config applying eslint `--fix` and prettier `--write` to staged files only.
|
|
116
|
+
|
|
117
|
+
**Pre-commit** — the hook installs itself via `prepare` → `git:config`, which sets `core.hooksPath` to a tracked directory
|
|
118
|
+
plus `pull.rebase true` and `merge.ff only`, so history strategy doesn't depend on personal git config. Two silent failure
|
|
119
|
+
modes: no `prepare` at all (hooks exist only on the machine that ran `git config` by hand), and `core.hooksPath` pointing at
|
|
120
|
+
a directory that doesn't exist. Check config **and** directory.
|
|
121
|
+
|
|
122
|
+
**Verify scopes** — two blocking, one advisory:
|
|
123
|
+
|
|
124
|
+
- **staged** — `verify:staged`, called by the pre-commit hook: lint, format, restage. Fast. A passing commit is not CI green.
|
|
125
|
+
- **full** — `verify:full`: the build plus every `verify:*` check. What CI runs.
|
|
126
|
+
- **files** — optional post-edit agent hook. Advisory, never exits non-zero, so a mid-flight refactor can't trap the agent.
|
|
127
|
+
|
|
128
|
+
One script owns each scope's step list; npm scripts and hooks only launch them. Name by depth, not by occasion — a name like
|
|
129
|
+
`preflight` needs explaining and invites a second, near-identical script beside it. Two of those drift, and the drift reads
|
|
130
|
+
as "passes locally, fails in CI".
|
|
131
|
+
|
|
132
|
+
**Node pinning** — `.nvmrc` or `engines.node`. Not a personal version manager's config; that pins it for you alone.
|
|
133
|
+
|
|
134
|
+
## Folder structure
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
src/
|
|
138
|
+
routes/ # TanStack Router file-based routes; nothing but routing
|
|
139
|
+
api/<topic>/ # one folder per API topic
|
|
140
|
+
<topic>.ts # queries/mutations
|
|
141
|
+
query-key.ts # key factory
|
|
142
|
+
types.ts # request/response types
|
|
143
|
+
components/ # see below
|
|
144
|
+
http/ # client, error normalisation — the only transport-aware place
|
|
145
|
+
lib/ # framework-free helpers
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Everything is kebab-case, folders and files alike.
|
|
149
|
+
|
|
150
|
+
**Inside `components/`, group by what kind of component it is** — `layouts/`, `primitives/`, `forms/`, or a domain name. Not
|
|
151
|
+
a hard rule: a handful of components reads fine flat, and the grouping should follow what the project actually has rather
|
|
152
|
+
than a structure imposed up front. But most codebases grow past flat, and a clear layout component belongs under `layouts/`
|
|
153
|
+
rather than beside a domain widget.
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
components/
|
|
157
|
+
page-header/ # flat is fine
|
|
158
|
+
layouts/
|
|
159
|
+
page-shell/
|
|
160
|
+
data-table/ # a component with sub-component files
|
|
161
|
+
data-table.tsx
|
|
162
|
+
data-table-row.tsx
|
|
163
|
+
data-table.module.css
|
|
164
|
+
index.ts
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
A component gets a folder when it has more than one file — sub-components, styles, tests. Single-file components can stay
|
|
168
|
+
single files. The folder's `index.ts` only re-exports, so imports read `#/components/data-table` and the inside can be
|
|
169
|
+
restructured without touching call sites.
|
|
170
|
+
|
|
171
|
+
Routes stay thin: they compose, they don't fetch inline. `api/<topic>/` holds the query and its key factory together so a key
|
|
172
|
+
is never spelled out at a call site. Anything transport-level (base client, error normalisation) lives in `http/` and nowhere
|
|
173
|
+
else.
|
|
174
|
+
|
|
175
|
+
## Styling
|
|
176
|
+
|
|
177
|
+
CSS Modules by default, colocated as `<component>.module.css` next to the component. No CSS-in-JS, no utility-class
|
|
178
|
+
framework.
|
|
179
|
+
|
|
180
|
+
On a project using `@imfusion/web-ui`, style through the sanctioned seams — `--imf-ui-*` tokens and `data-imf-ui-component`
|
|
181
|
+
attributes, never the library's generated class names. `imf-web-ui-frontend-patterns` covers that contract.
|
|
182
|
+
|
|
183
|
+
If more than one tool compiles the CSS (app build plus Storybook), the generated class-name pattern must be defined **once**
|
|
184
|
+
and imported by both, or the same source file gets different class names in each and styles silently don't apply.
|
|
185
|
+
`build/css-modules-config.ts` in web-ui is the reference shape; pick your own prefix.
|
|
186
|
+
|
|
187
|
+
## Optional
|
|
188
|
+
|
|
189
|
+
Recommend when the shape calls for it; absence is not a finding.
|
|
190
|
+
|
|
191
|
+
- **knip** — once several people delete things independently.
|
|
192
|
+
- **`ignore-scripts=true` in `.npmrc`** — blocks most supply-chain worm payloads; costs an explicit `npm rebuild` for native
|
|
193
|
+
deps.
|
|
194
|
+
- **`eslint-plugin-jsx-a11y`** — anything user-facing.
|
|
195
|
+
|
|
196
|
+
Out of scope, project-specific: CI, env and secrets, error tracking, deploy, dependency updates.
|
|
197
|
+
|
|
198
|
+
## Not this skill
|
|
199
|
+
|
|
200
|
+
- Library wiring (styles import, provider) → `imf-web-ui-setup`
|
|
201
|
+
- Code conventions (TypeScript, naming, testing) → `imf-web-ui-frontend-patterns`, `references/code-conventions.md`
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: imf-web-ui-setup
|
|
3
|
+
description:
|
|
4
|
+
"One-time wiring of a consumer project: the @imfusion/web-ui styles import and WebUIProvider wrapper. Also covers what to
|
|
5
|
+
say about the Agent Skills a dependency ships. Load when installing the library for the first time, or when components
|
|
6
|
+
render unstyled or without theme context."
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# imf-web-ui-setup
|
|
10
|
+
|
|
11
|
+
This is library wiring: the styles import and the provider. It applies to anyone using `@imfusion/web-ui`.
|
|
12
|
+
|
|
13
|
+
If the project is an **ImFusion** frontend and this is first-time setup, mention once that
|
|
14
|
+
`imf-web-ui-imfusion-frontend-setup` sets up or audits the repo's tooling (formatting, linting, hooks, scripts) against the
|
|
15
|
+
ImFusion baseline, and let the human decide. Offer it; never run it uninvited, and don't raise it again if they pass — the
|
|
16
|
+
library works fine without any of it.
|
|
17
|
+
|
|
18
|
+
Every consumer entry point needs exactly two lines, in this order:
|
|
19
|
+
|
|
20
|
+
```tsx
|
|
21
|
+
import "@imfusion/web-ui/styles.css";
|
|
22
|
+
import { WebUIProvider, Button } from "@imfusion/web-ui";
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Wrap the app root in `<WebUIProvider>` once. Components rendered outside it won't have the theme/CSS-variable context they
|
|
26
|
+
expect.
|
|
27
|
+
|
|
28
|
+
Never import a Base UI (or other upstream) stylesheet or component directly — everything a web-ui component needs is already
|
|
29
|
+
inside `styles.css` and the package's own exports; reaching around web-ui to the upstream library is always wrong, even if
|
|
30
|
+
the upstream docs show it that way.
|
|
31
|
+
|
|
32
|
+
## Dependency-shipped skills
|
|
33
|
+
|
|
34
|
+
Some libraries ship Agent Skills inside their npm package; TanStack does across much of the suite.
|
|
35
|
+
[`@tanstack/intent`](https://github.com/TanStack/intent) is the CLI that surfaces them — an agent holding a dependency but
|
|
36
|
+
not its guidance writes plausible code against a half-remembered API.
|
|
37
|
+
|
|
38
|
+
Setting it up is the project's own call, not something web-ui does on its behalf. Point it out:
|
|
39
|
+
|
|
40
|
+
> This project has TanStack dependencies that ship their own Agent Skills. `@tanstack/intent` can make them reachable — worth
|
|
41
|
+
> a look if you want your agent working from the library's own guidance.
|
|
42
|
+
|
|
43
|
+
Intent offers two things: a fenced instructions block in `AGENTS.md`, and a `PreToolUse` hook that blocks an edit until a
|
|
44
|
+
matching skill has been read. The house preference is both — the block alone is advice an agent can walk past. The hook
|
|
45
|
+
refuses every edit while no matching skill is loadable, so a project adopting it wants the current docs open; that sequencing
|
|
46
|
+
belongs to whoever runs it.
|
|
47
|
+
|
|
48
|
+
Whatever the project decides, guidance you didn't read is not guidance you have: use `npx @tanstack/cli` for TanStack docs,
|
|
49
|
+
and never guess at a skill name.
|
|
50
|
+
|
|
51
|
+
## Symptoms of a broken setup
|
|
52
|
+
|
|
53
|
+
- **Components render but look unstyled** — the `styles.css` import is missing from the entry point.
|
|
54
|
+
- **Components render but ignore the theme (wrong colors, no CSS variables resolving)** — they're mounted outside
|
|
55
|
+
`<WebUIProvider>`.
|
|
56
|
+
- **An integration component throws on import** — its optional peer dependency isn't installed; check the component's
|
|
57
|
+
description in the docgen index (`imf-web-ui-components`) for which peer to add to your `package.json`.
|
|
@@ -11,7 +11,7 @@ description:
|
|
|
11
11
|
Most teams consuming `@imfusion/web-ui` don't have a designer on call. This skill stands in: it encodes the library authors'
|
|
12
12
|
UX experience — the whole experience of a screen, its visual design, and the usability where both meet. Follow it by default;
|
|
13
13
|
deviate when the product has a real reason to. Code-level patterns (tokens, layers, wrappers) live in
|
|
14
|
-
`imf-web-ui-frontend-
|
|
14
|
+
`imf-web-ui-frontend-patterns`; project wiring lives in `imf-web-ui-setup`.
|
|
15
15
|
|
|
16
16
|
Component names below are real — verify any API against the docgen index (`imf-web-ui-components`) before use. Never invent a
|
|
17
17
|
component this library doesn't ship.
|
|
@@ -82,13 +82,13 @@ Every screen ships four states, not one:
|
|
|
82
82
|
|
|
83
83
|
Custom UI the library doesn't cover should be indistinguishable from library UI: build it from `--imf-ui-*` tokens and
|
|
84
84
|
compose it with library primitives. The goal lives here; the mechanics (tokens, layers, wrappers) live in
|
|
85
|
-
`imf-web-ui-frontend-
|
|
85
|
+
`imf-web-ui-frontend-patterns`.
|
|
86
86
|
|
|
87
87
|
## Experimental components
|
|
88
88
|
|
|
89
89
|
The identity index marks each component `stable` or `experimental`. Experimental ones are fine to use, but expect API
|
|
90
|
-
movement across releases — prefer wrapping them once (see `imf-web-ui-frontend-
|
|
91
|
-
|
|
90
|
+
movement across releases — prefer wrapping them once (see `imf-web-ui-frontend-patterns`) so a breaking change lands in one
|
|
91
|
+
file, not forty call sites.
|
|
92
92
|
|
|
93
93
|
## Deep dives
|
|
94
94
|
|
|
@@ -8,7 +8,7 @@ non-compliant ones. Read before building any form beyond two fields.
|
|
|
8
8
|
|
|
9
9
|
The library ships the controls (`Input`, `Select`, `Checkbox`, `Switch`, `Slider`) but no form or field wrapper, so labels,
|
|
10
10
|
grouping, and where errors appear are composed by you. That's exactly where these rules apply. Composing the markup is not
|
|
11
|
-
the same as owning the state: form state and validation belong to a form library (see `imf-web-ui-frontend-
|
|
11
|
+
the same as owning the state: form state and validation belong to a form library (see `imf-web-ui-frontend-patterns`), and
|
|
12
12
|
these rules govern how its errors get presented.
|
|
13
13
|
|
|
14
14
|
## Structure
|
package/bin/install.js
DELETED
|
@@ -1,319 +0,0 @@
|
|
|
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 the frontend-setup skill ships as templates. 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 (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,
|
|
19
|
-
// --reconfigure to re-open the target prompt on an existing install.
|
|
20
|
-
|
|
21
|
-
import {
|
|
22
|
-
chmodSync,
|
|
23
|
-
cpSync,
|
|
24
|
-
existsSync,
|
|
25
|
-
lstatSync,
|
|
26
|
-
mkdirSync,
|
|
27
|
-
readdirSync,
|
|
28
|
-
readFileSync,
|
|
29
|
-
rmSync,
|
|
30
|
-
symlinkSync,
|
|
31
|
-
writeFileSync
|
|
32
|
-
} from "node:fs";
|
|
33
|
-
import { dirname, relative, resolve } from "node:path";
|
|
34
|
-
import { fileURLToPath } from "node:url";
|
|
35
|
-
import * as p from "@clack/prompts";
|
|
36
|
-
|
|
37
|
-
const SKILL_PREFIX = "imf-web-ui";
|
|
38
|
-
const VERSION_MARKER = ".imf-web-ui-skill-version.json";
|
|
39
|
-
|
|
40
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
41
|
-
// Source is relative to THIS SCRIPT's location (inside node_modules), not
|
|
42
|
-
// the consumer's cwd — the script is invoked from the consumer's project
|
|
43
|
-
// root, but the skills it copies ship alongside this file in the package.
|
|
44
|
-
const skillsRoot = resolve(here, "..", "src", "llms", "skills");
|
|
45
|
-
const packageJson = JSON.parse(readFileSync(resolve(here, "..", "package.json"), "utf-8"));
|
|
46
|
-
const currentVersion = packageJson.version;
|
|
47
|
-
|
|
48
|
-
// Destination is relative to the consumer's project root (cwd), since
|
|
49
|
-
// that's where their `.claude/` or `.agents/` directory lives.
|
|
50
|
-
const projectRoot = process.cwd();
|
|
51
|
-
|
|
52
|
-
const TARGETS = {
|
|
53
|
-
claude: { label: "Claude Code", root: resolve(projectRoot, ".claude", "skills") },
|
|
54
|
-
agents: { label: "Vendor-neutral (.agents/)", root: resolve(projectRoot, ".agents", "skills") }
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
function discoverSkills() {
|
|
58
|
-
if (!existsSync(skillsRoot)) return [];
|
|
59
|
-
return readdirSync(skillsRoot, { withFileTypes: true })
|
|
60
|
-
.filter(entry => entry.isDirectory() && entry.name.startsWith(SKILL_PREFIX))
|
|
61
|
-
.map(entry => entry.name)
|
|
62
|
-
.sort();
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function displayPath(absPath) {
|
|
66
|
-
return `./${relative(projectRoot, absPath)}`;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function readInstalledVersion(dir) {
|
|
70
|
-
const markerPath = resolve(dir, VERSION_MARKER);
|
|
71
|
-
if (!existsSync(markerPath)) return null;
|
|
72
|
-
try {
|
|
73
|
-
return JSON.parse(readFileSync(markerPath, "utf-8")).version ?? null;
|
|
74
|
-
} catch {
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function writeRealCopy(sourceDir, destDir) {
|
|
80
|
-
mkdirSync(dirname(destDir), { recursive: true });
|
|
81
|
-
// force: true makes re-running after a version bump overwrite cleanly.
|
|
82
|
-
cpSync(sourceDir, destDir, { recursive: true, force: true });
|
|
83
|
-
writeFileSync(resolve(destDir, VERSION_MARKER), JSON.stringify({ version: currentVersion }, null, 2) + "\n");
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function writeSymlink(linkPath, targetPath) {
|
|
87
|
-
mkdirSync(dirname(linkPath), { recursive: true });
|
|
88
|
-
// lstat (not existsSync, which follows symlinks) catches a broken/stale
|
|
89
|
-
// symlink left over from a prior run, not just a real file or directory.
|
|
90
|
-
if (lstatSync(linkPath, { throwIfNoEntry: false })) {
|
|
91
|
-
rmSync(linkPath, { recursive: true, force: true });
|
|
92
|
-
}
|
|
93
|
-
symlinkSync(relative(dirname(linkPath), targetPath), linkPath);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// A prior install is recoverable from the filesystem, so a re-run doesn't
|
|
97
|
-
// re-ask: a target counts as chosen when any bundled skill is present under
|
|
98
|
-
// it. Symlinks count — they're how the both-targets layout represents
|
|
99
|
-
// .claude/, and lstat avoids following them into the real copy.
|
|
100
|
-
function detectInstalledTargets(skills) {
|
|
101
|
-
return Object.keys(TARGETS).filter(key =>
|
|
102
|
-
skills.some(name => lstatSync(resolve(TARGETS[key].root, name), { throwIfNoEntry: false }))
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Hook templates ship inside the agent-setup skill so they're readable as
|
|
107
|
-
// part of its documentation; the installer copies the scripts into the
|
|
108
|
-
// consumer's .agents/hooks/ and merges the registrations into
|
|
109
|
-
// .claude/settings.json.
|
|
110
|
-
const hooksSourceDir = resolve(skillsRoot, "imf-web-ui-agent-setup", "templates", "hooks");
|
|
111
|
-
const hooksSettingsTemplate = resolve(skillsRoot, "imf-web-ui-agent-setup", "templates", "settings.json");
|
|
112
|
-
// Installer-owned subdirectory: refreshed wholesale on every run, so a repo's
|
|
113
|
-
// own hooks in .agents/hooks/ are never touched.
|
|
114
|
-
const hooksDestDir = resolve(projectRoot, ".agents", "hooks", "imf-web-ui");
|
|
115
|
-
const settingsPath = resolve(projectRoot, ".claude", "settings.json");
|
|
116
|
-
|
|
117
|
-
function installHookScripts() {
|
|
118
|
-
const scripts = readdirSync(hooksSourceDir).filter(name => name.endsWith(".sh"));
|
|
119
|
-
mkdirSync(hooksDestDir, { recursive: true });
|
|
120
|
-
for (const name of scripts) {
|
|
121
|
-
const dest = resolve(hooksDestDir, name);
|
|
122
|
-
cpSync(resolve(hooksSourceDir, name), dest, { force: true });
|
|
123
|
-
chmodSync(dest, 0o755);
|
|
124
|
-
}
|
|
125
|
-
return scripts.length;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// Merge, never clobber: a registration is added only when no existing entry
|
|
129
|
-
// for that event already runs the same command, so re-runs are idempotent
|
|
130
|
-
// and hand-written settings survive.
|
|
131
|
-
function mergeHookRegistrations() {
|
|
132
|
-
const template = JSON.parse(readFileSync(hooksSettingsTemplate, "utf-8"));
|
|
133
|
-
let settings = {};
|
|
134
|
-
if (existsSync(settingsPath)) {
|
|
135
|
-
try {
|
|
136
|
-
settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
137
|
-
} catch {
|
|
138
|
-
return null;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
settings.hooks ??= {};
|
|
142
|
-
let added = 0;
|
|
143
|
-
for (const [event, entries] of Object.entries(template.hooks)) {
|
|
144
|
-
settings.hooks[event] ??= [];
|
|
145
|
-
for (const entry of entries) {
|
|
146
|
-
const commands = entry.hooks.map(hook => hook.command);
|
|
147
|
-
const present = settings.hooks[event].some(existing =>
|
|
148
|
-
(existing.hooks ?? []).some(hook => commands.includes(hook.command))
|
|
149
|
-
);
|
|
150
|
-
if (!present) {
|
|
151
|
-
settings.hooks[event].push(entry);
|
|
152
|
-
added++;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
157
|
-
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
158
|
-
return added;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// The AGENTS.md baseline note lives inside a fenced block owned by this
|
|
162
|
-
// installer. The template between the markers is the source of truth; an
|
|
163
|
-
// existing AGENTS.md gets the block replaced in place (or appended when
|
|
164
|
-
// absent), everything outside the fence is untouched. No AGENTS.md at all
|
|
165
|
-
// is left alone — scaffolding one is the setup skill's job.
|
|
166
|
-
const AGENTS_BLOCK_BEGIN = "<!-- imf-web-ui:begin";
|
|
167
|
-
const AGENTS_BLOCK_END = "<!-- imf-web-ui:end -->";
|
|
168
|
-
const agentsTemplatePath = resolve(skillsRoot, "imf-web-ui-frontend-setup", "templates", "AGENTS.md");
|
|
169
|
-
const agentsPath = resolve(projectRoot, "AGENTS.md");
|
|
170
|
-
|
|
171
|
-
function extractAgentsBlock(content) {
|
|
172
|
-
const begin = content.indexOf(AGENTS_BLOCK_BEGIN);
|
|
173
|
-
const end = content.indexOf(AGENTS_BLOCK_END);
|
|
174
|
-
if (begin === -1 || end === -1) return null;
|
|
175
|
-
return content.slice(begin, end + AGENTS_BLOCK_END.length);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function upsertAgentsBlock() {
|
|
179
|
-
if (!existsSync(agentsPath)) return "absent";
|
|
180
|
-
const block = extractAgentsBlock(readFileSync(agentsTemplatePath, "utf-8"));
|
|
181
|
-
if (!block) return "absent";
|
|
182
|
-
const current = readFileSync(agentsPath, "utf-8");
|
|
183
|
-
const existing = extractAgentsBlock(current);
|
|
184
|
-
const next = existing ? current.replace(existing, block) : `${current.trimEnd()}\n\n${block}\n`;
|
|
185
|
-
if (next === current) return "unchanged";
|
|
186
|
-
writeFileSync(agentsPath, next);
|
|
187
|
-
return existing ? "updated" : "added";
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// --target claude|agents (repeatable) selects targets without the
|
|
191
|
-
// interactive prompt — for CI and scripted installs.
|
|
192
|
-
function parseTargetFlags(argv) {
|
|
193
|
-
const targets = [];
|
|
194
|
-
for (let i = 0; i < argv.length; i++) {
|
|
195
|
-
if (argv[i] !== "--target") continue;
|
|
196
|
-
const value = argv[i + 1];
|
|
197
|
-
if (!value || !(value in TARGETS)) {
|
|
198
|
-
console.error(`--target expects one of: ${Object.keys(TARGETS).join(", ")}`);
|
|
199
|
-
process.exit(1);
|
|
200
|
-
}
|
|
201
|
-
targets.push(value);
|
|
202
|
-
i++;
|
|
203
|
-
}
|
|
204
|
-
return targets;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
async function main() {
|
|
208
|
-
const skills = discoverSkills();
|
|
209
|
-
const argv = process.argv.slice(2);
|
|
210
|
-
const flagTargets = parseTargetFlags(argv);
|
|
211
|
-
const reconfigure = argv.includes("--reconfigure");
|
|
212
|
-
// Bare invocation installs skills only. Hooks are opt-in via --hooks — the
|
|
213
|
-
// imf-web-ui-agent-setup skill drives that after checking what the repo
|
|
214
|
-
// already registers; the binary stays the dumb mechanical tail.
|
|
215
|
-
const wantHooks = argv.includes("--hooks");
|
|
216
|
-
const wantSkills = argv.includes("--skills") || !wantHooks;
|
|
217
|
-
|
|
218
|
-
const components = [wantSkills && `${skills.length} skills`, wantHooks && "agent hooks"].filter(Boolean);
|
|
219
|
-
p.intro(`@imfusion/web-ui install — ${components.join(" + ")}`);
|
|
220
|
-
|
|
221
|
-
if (wantSkills && skills.length === 0) {
|
|
222
|
-
p.log.error(`No skills found at ${skillsRoot}. Reinstall @imfusion/web-ui and try again.`);
|
|
223
|
-
p.outro("Nothing installed.");
|
|
224
|
-
process.exitCode = 1;
|
|
225
|
-
return;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
if (wantHooks) {
|
|
229
|
-
const scriptCount = installHookScripts();
|
|
230
|
-
const added = mergeHookRegistrations();
|
|
231
|
-
p.log.success(`Agent hooks -> ${displayPath(hooksDestDir)} (${scriptCount} scripts)`);
|
|
232
|
-
if (added === null) {
|
|
233
|
-
p.log.warn(`${displayPath(settingsPath)} is not valid JSON — registrations not merged, fix it and re-run.`);
|
|
234
|
-
} else if (added > 0) {
|
|
235
|
-
p.log.success(`Registered ${added} hook(s) in ${displayPath(settingsPath)}`);
|
|
236
|
-
} else {
|
|
237
|
-
p.log.info(`Hook registrations already present in ${displayPath(settingsPath)}`);
|
|
238
|
-
}
|
|
239
|
-
if (!wantSkills) {
|
|
240
|
-
p.outro("Done — hooks installed.");
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const installedTargets = detectInstalledTargets(skills);
|
|
246
|
-
|
|
247
|
-
const existingVersions = Object.values(TARGETS)
|
|
248
|
-
.flatMap(({ root }) => skills.map(name => readInstalledVersion(resolve(root, name))))
|
|
249
|
-
.filter(Boolean);
|
|
250
|
-
if (existingVersions.length > 0 && existingVersions.every(v => v === currentVersion)) {
|
|
251
|
-
p.log.info(`Already up to date (v${currentVersion}). Re-running will overwrite with the same content.`);
|
|
252
|
-
} else if (existingVersions.some(v => v !== currentVersion)) {
|
|
253
|
-
const from = existingVersions.find(v => v !== currentVersion);
|
|
254
|
-
p.log.info(`Updating installed skills from v${from} to v${currentVersion}.`);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
p.log.message(`Skills in this bundle:\n${skills.map(name => ` - ${name}`).join("\n")}`);
|
|
258
|
-
|
|
259
|
-
let selected;
|
|
260
|
-
if (flagTargets.length > 0) {
|
|
261
|
-
selected = flagTargets;
|
|
262
|
-
p.log.info(`Targets from --target flags: ${selected.join(", ")}`);
|
|
263
|
-
} else if (installedTargets.length > 0 && !reconfigure) {
|
|
264
|
-
selected = installedTargets;
|
|
265
|
-
p.log.info(
|
|
266
|
-
`Refreshing the existing install: ${selected.map(key => displayPath(TARGETS[key].root)).join(", ")}` +
|
|
267
|
-
` — pass --reconfigure to choose different targets.`
|
|
268
|
-
);
|
|
269
|
-
} else {
|
|
270
|
-
selected = await p.multiselect({
|
|
271
|
-
message: "Install into which skill directory (or directories)?",
|
|
272
|
-
options: Object.entries(TARGETS).map(([key, { label, root }]) => ({
|
|
273
|
-
value: key,
|
|
274
|
-
label,
|
|
275
|
-
hint: displayPath(root)
|
|
276
|
-
})),
|
|
277
|
-
required: true
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
if (p.isCancel(selected)) {
|
|
281
|
-
p.cancel("Cancelled — nothing installed.");
|
|
282
|
-
return;
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
const both = selected.includes("claude") && selected.includes("agents");
|
|
287
|
-
|
|
288
|
-
for (const name of skills) {
|
|
289
|
-
const sourceDir = resolve(skillsRoot, name);
|
|
290
|
-
if (both) {
|
|
291
|
-
// .agents/ is the canonical real copy; .claude/ aliases it via symlink.
|
|
292
|
-
const realDir = resolve(TARGETS.agents.root, name);
|
|
293
|
-
writeRealCopy(sourceDir, realDir);
|
|
294
|
-
writeSymlink(resolve(TARGETS.claude.root, name), realDir);
|
|
295
|
-
} else {
|
|
296
|
-
for (const key of selected) {
|
|
297
|
-
writeRealCopy(sourceDir, resolve(TARGETS[key].root, name));
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
if (both) {
|
|
303
|
-
p.log.success(`Vendor-neutral (.agents/) -> ${displayPath(TARGETS.agents.root)}/${SKILL_PREFIX}*`);
|
|
304
|
-
p.log.success(`Claude Code -> ${displayPath(TARGETS.claude.root)}/${SKILL_PREFIX}* (symlinks -> .agents/)`);
|
|
305
|
-
} else {
|
|
306
|
-
for (const key of selected) {
|
|
307
|
-
p.log.success(`${TARGETS[key].label} -> ${displayPath(TARGETS[key].root)}/${SKILL_PREFIX}*`);
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
const agentsResult = upsertAgentsBlock();
|
|
312
|
-
if (agentsResult === "updated" || agentsResult === "added") {
|
|
313
|
-
p.log.success(`Refreshed the imf-web-ui block in ${displayPath(agentsPath)}`);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
p.outro(`Done — ${skills.length} skills installed${wantHooks ? " + agent hooks" : ""}.`);
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
await main();
|