@rokkit/cli 1.1.11 → 1.1.13

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 CHANGED
@@ -135,3 +135,21 @@ lib/
135
135
  ---
136
136
 
137
137
  Part of [Rokkit](https://github.com/jerrythomas/rokkit) — a Svelte 5 component library and design system.
138
+
139
+ ## AI skills
140
+
141
+ Rokkit ships curated AI skills (SKILL.md guides) that teach coding agents how to
142
+ use the library well — theming with the named-token system and building UI with
143
+ the components.
144
+
145
+ ```bash
146
+ rokkit skills list # see the catalog
147
+ rokkit skills add semantic-styles-rokkit # install one
148
+ rokkit skills add # interactive multi-select
149
+ rokkit skills add --all # install everything
150
+ rokkit skills add <name> --force # overwrite an existing install
151
+ ```
152
+
153
+ Skills install into your project's `.claude/skills/<name>/`, so they're shared
154
+ with your team via version control. Browse the source under
155
+ [`packages/cli/skills/`](./skills).
@@ -77,12 +77,20 @@ export function generateUnoConfig(): string;
77
77
  export function generateAppCssImports(themes: string[]): string[];
78
78
  /**
79
79
  * Generate the flash-prevention script for app.html.
80
- * Returns null when no script is needed (system switcher).
80
+ *
81
+ * Themes flip on `[data-mode="dark"]` only — there is no `@media
82
+ * (prefers-color-scheme)` fallback — so every switcher needs a script that
83
+ * resolves the mode pre-paint (mirrors `@rokkit/unocss`'s `themeHook`):
84
+ * - `system` tracks the OS preference directly (no persisted toggle).
85
+ * - `manual` / `full` read the persisted mode and resolve a stored-or-default
86
+ * `'system'`/`'auto'` against the OS preference (default: follow the OS).
87
+ *
81
88
  * @param {string} switcher — 'system' | 'manual' | 'full'
82
89
  * @param {string} [storageKey='rokkit-theme']
83
- * @returns {string | null}
90
+ * @param {string} [defaultStyle='rokkit']
91
+ * @returns {string} the `<script>` tag to inject after `<body>`
84
92
  */
85
- export function generateInitScript(switcher: string, storageKey?: string, defaultStyle?: string): string | null;
93
+ export function generateInitScript(switcher: string, storageKey?: string, defaultStyle?: string): string;
86
94
  /**
87
95
  * Interactive init command — prompts the user, writes config files.
88
96
  * @param {Record<string, unknown>} [_opts]
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Parse `name` and `description` from a SKILL.md YAML frontmatter block.
3
+ * @param {string} md
4
+ * @returns {{ name: string, description: string }}
5
+ */
6
+ export function parseFrontmatter(md: string): {
7
+ name: string;
8
+ description: string;
9
+ };
10
+ /**
11
+ * Read the bundled skill catalog (derived from each SKILL.md's frontmatter).
12
+ * @param {{ skillsDir?: string }} [opts]
13
+ * @returns {Array<{ name: string, description: string }>}
14
+ */
15
+ export function listSkills({ skillsDir }?: {
16
+ skillsDir?: string;
17
+ }): Array<{
18
+ name: string;
19
+ description: string;
20
+ }>;
21
+ /**
22
+ * Copy named skills into <cwd>/.claude/skills/<name>/ (idempotent).
23
+ * @param {string[]} names
24
+ * @param {{ cwd?: string, force?: boolean, skillsDir?: string }} [opts]
25
+ * @returns {Array<{ name: string, status: 'added' | 'skipped' | 'unknown' }>}
26
+ */
27
+ export function installSkills(names: string[], { cwd, force, skillsDir }?: {
28
+ cwd?: string;
29
+ force?: boolean;
30
+ skillsDir?: string;
31
+ }): Array<{
32
+ name: string;
33
+ status: "added" | "skipped" | "unknown";
34
+ }>;
35
+ /**
36
+ * Print the catalog, marking already-installed skills.
37
+ * @param {{ skillsDir?: string, cwd?: string }} [opts]
38
+ */
39
+ export function runSkillsList({ skillsDir, cwd }?: {
40
+ skillsDir?: string;
41
+ cwd?: string;
42
+ }): Promise<void>;
43
+ /**
44
+ * Resolve which skills to install, then copy and report.
45
+ * @param {string[]} names
46
+ * @param {{ all?: boolean, force?: boolean, skillsDir?: string, cwd?: string }} [opts]
47
+ */
48
+ export function runSkillsAdd(names: string[], { all, force, skillsDir, cwd }?: {
49
+ all?: boolean;
50
+ force?: boolean;
51
+ skillsDir?: string;
52
+ cwd?: string;
53
+ }): Promise<void>;
54
+ /**
55
+ * CLI entry for `rokkit skills` subcommands.
56
+ * @param {'list' | 'add'} sub
57
+ * @param {{ _?: string[], all?: boolean, force?: boolean }} [opts]
58
+ */
59
+ export function skillsCommand(sub: "list" | "add", opts?: {
60
+ _?: string[];
61
+ all?: boolean;
62
+ force?: boolean;
63
+ }): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/cli",
3
- "version": "1.1.11",
3
+ "version": "1.1.13",
4
4
  "description": "Command line utilities for icon bundling and conversion.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -36,6 +36,7 @@
36
36
  "files": [
37
37
  "lib/**/*",
38
38
  "src/**/*.js",
39
+ "skills/**",
39
40
  "dist/**/*.d.ts",
40
41
  "README.md",
41
42
  "package.json",
@@ -0,0 +1,415 @@
1
+ ---
2
+ name: rokkit-components
3
+ description: Use when building UI with Rokkit components in a Svelte 5 app — the data-first contract (items, bindable value, fields field-mapping, onchange/onselect), snippet-based customization, built-in keyboard navigation + ARIA, and which component to reach for (List, Tree, Select, MultiSelect, Menu, Table, Tabs, Toggle).
4
+ ---
5
+
6
+ # Rokkit Components — Usage
7
+
8
+ Rokkit components are data-first: you pass an array, bind a value, and the component
9
+ handles rendering, keyboard navigation, and accessibility out of the box. You extend
10
+ behavior through the `fields` prop (remap data keys) and named snippets (customize
11
+ rendering) — not by modifying components or writing boilerplate.
12
+
13
+ Import from `@rokkit/ui`:
14
+
15
+ ```svelte
16
+ import { List, Tree, Select, MultiSelect, Menu, Table, Tabs, Toggle } from '@rokkit/ui'
17
+ ```
18
+
19
+ ---
20
+
21
+ ## The data-first contract
22
+
23
+ Every Rokkit component follows a consistent API:
24
+
25
+ | Prop | Purpose |
26
+ |------|---------|
27
+ | `items` / `options` / `data` | The data array to render |
28
+ | `bind:value` | Bindable selected value (or values array for MultiSelect) |
29
+ | `fields` | Remap your data's key names to component semantic keys |
30
+ | `onchange` / `onselect` | Callback fired when selection changes |
31
+
32
+ Primitive arrays (strings, numbers) work without any transformation:
33
+
34
+ ```svelte
35
+ <script>
36
+ import { List } from '@rokkit/ui'
37
+
38
+ const nav = ['Dashboard', 'Reports', 'Settings']
39
+ let value = $state(null)
40
+ </script>
41
+
42
+ <List items={nav} bind:value onselect={(v) => console.log('selected:', v)} />
43
+ ```
44
+
45
+ Object arrays are equally direct — use `fields` only when your key names differ from defaults:
46
+
47
+ ```svelte
48
+ <script>
49
+ import { Select } from '@rokkit/ui'
50
+
51
+ const countries = [
52
+ { code: 'us', name: 'United States' },
53
+ { code: 'gb', name: 'United Kingdom' }
54
+ ]
55
+ let value = $state(null)
56
+ </script>
57
+
58
+ <!-- fields remaps 'name' → label slot, 'code' → value slot -->
59
+ <Select items={countries} fields={{ label: 'name', value: 'code' }} bind:value />
60
+ ```
61
+
62
+ **Prop naming note:** `List`, `Tree`, `Select`, `MultiSelect`, and `Menu` all take `items`.
63
+ `Tabs` and `Toggle` take `options`. `Table` takes `data`.
64
+
65
+ ---
66
+
67
+ ## Field mapping (fields)
68
+
69
+ The `fields` prop is a plain object that maps **semantic slot names** → **your data's key names**.
70
+ It never mutates your data — it is only a read instruction for the component.
71
+
72
+ ### Default field keys
73
+
74
+ These are the raw key names each component reads by default. Override only the ones that differ in your data.
75
+
76
+ | Semantic slot | Default key | Used by |
77
+ |---------------|-------------|---------|
78
+ | `label` / `text` | `'label'` / `'text'` | display text (see note) |
79
+ | `value` | `'value'` | selection value |
80
+ | `icon` | `'icon'` | icon CSS class |
81
+ | `children` | `'children'` | nested items array |
82
+ | `href` | `'href'` | URL (renders as `<a>`) |
83
+ | `disabled` | `'disabled'` | disabled state |
84
+ | `expanded` | `'expanded'` | initial expand state |
85
+ | `type` | `'type'` | `'separator'` / `'spacer'` markers |
86
+ | `snippet` | `'snippet'` | per-item named snippet override |
87
+ | `badge` | `'badge'` | badge / count indicator |
88
+ | `description` | `'description'` | secondary subtitle text |
89
+
90
+ > **Text field note:** `List` / `Tree` / `Menu` read `item.label` by default.
91
+ > `Select` / `MultiSelect` also read `item.label` (they use `fields.label || 'label'`
92
+ > internally). Tabs reads `item.text` or `item.label`. `Toggle` reads `item.text`
93
+ > (with `label` as fallback). When in doubt, provide both keys or use `fields`.
94
+
95
+ ### Remapping example
96
+
97
+ Your API returns `{ id, title, subitems }`. Map to Rokkit slots with `fields`:
98
+
99
+ ```svelte
100
+ <List
101
+ items={apiData}
102
+ fields={{ value: 'id', label: 'title', children: 'subitems' }}
103
+ bind:value
104
+ />
105
+ ```
106
+
107
+ ### Table columns
108
+
109
+ `Table` uses a separate `columns` array to define column shapes. Row-level identity
110
+ is set via `fields={{ value: 'id' }}` (defaults to `'id'`):
111
+
112
+ ```svelte
113
+ <script>
114
+ import { Table } from '@rokkit/ui'
115
+
116
+ const columns = [
117
+ { name: 'name', label: 'Full Name', sortable: true },
118
+ { name: 'role', label: 'Role', sortable: true },
119
+ { name: 'active', label: 'Active', sortable: false, align: 'center' }
120
+ ]
121
+ const rows = [
122
+ { id: 1, name: 'Alice', role: 'Admin', active: true },
123
+ { id: 2, name: 'Bob', role: 'Editor', active: false }
124
+ ]
125
+ let value = $state(null)
126
+ </script>
127
+
128
+ <Table data={rows} {columns} bind:value />
129
+ ```
130
+
131
+ ---
132
+
133
+ ## Snippet customization
134
+
135
+ Snippets let you replace inner rendering without changing the component's
136
+ wrapper, ARIA structure, or keyboard handling. The component always owns the
137
+ interactive container (`<button>`, `<a>`, tab panel, etc.) — snippets control
138
+ the **inner content** only.
139
+
140
+ ### itemContent — List, Tree, Menu (and Toggle, for option labels)
141
+
142
+ The most common override. Receives a `ProxyItem` instance:
143
+
144
+ ```svelte
145
+ <List {items} bind:value onselect={(v) => (value = v)}>
146
+ {#snippet itemContent(proxy)}
147
+ {#if proxy.icon}
148
+ <span class={proxy.icon} aria-hidden="true"></span>
149
+ {/if}
150
+ <span class="flex-1">{proxy.label}</span>
151
+ {#if proxy.get('badge')}
152
+ <span class="ml-auto text-xs text-ink-mute">{proxy.get('badge')}</span>
153
+ {/if}
154
+ {/snippet}
155
+ </List>
156
+ ```
157
+
158
+ ### groupContent — List, Menu (not Tree — tree nodes use itemContent)
159
+
160
+ Overrides the group header button content (the expand/collapse header row):
161
+
162
+ ```svelte
163
+ <List {items} collapsible bind:value>
164
+ {#snippet groupContent(proxy)}
165
+ <span class={proxy.icon}></span>
166
+ <span>{proxy.label}</span>
167
+ <span class={proxy.expanded ? 'i-lucide:chevron-down' : 'i-lucide:chevron-right'}></span>
168
+ {/snippet}
169
+ </List>
170
+ ```
171
+
172
+ ### ProxyItem API (passed to all content snippets)
173
+
174
+ | Property / method | Description |
175
+ |-------------------|-------------|
176
+ | `proxy.label` | Mapped display text |
177
+ | `proxy.icon` | Mapped icon CSS class |
178
+ | `proxy.value` | The original raw item (object or primitive) |
179
+ | `proxy.href` | Mapped href |
180
+ | `proxy.disabled` | Whether this item is disabled |
181
+ | `proxy.expanded` | Expand state for groups/nodes (reactive `$state`) |
182
+ | `proxy.hasChildren` | True when the item has a `children` array |
183
+ | `proxy.get('key')` | Read any field by semantic or raw name |
184
+
185
+ ### Select and MultiSelect snippets
186
+
187
+ Select and MultiSelect do not expose a trigger or chips snippet — the trigger face
188
+ and selected chips are rendered from `fields`/`label` and are not consumer-overridable.
189
+
190
+ Both components support `itemContent` and `groupContent` for the **dropdown options**,
191
+ using the same `ProxyItem` API as `List`:
192
+
193
+ ```svelte
194
+ <Select {items} bind:value>
195
+ {#snippet itemContent(proxy)}
196
+ {#if proxy.icon}
197
+ <span class={proxy.icon} aria-hidden="true"></span>
198
+ {/if}
199
+ <span>{proxy.label}</span>
200
+ {#if proxy.get('description')}
201
+ <span class="text-xs text-ink-mute ml-auto">{proxy.get('description')}</span>
202
+ {/if}
203
+ {/snippet}
204
+ </Select>
205
+ ```
206
+
207
+ The same `itemContent` / `groupContent` patterns work identically on `MultiSelect`.
208
+
209
+ ### Tabs snippets
210
+
211
+ `Tabs` supports `tabPanel` (receives one `ProxyItem` argument) and `itemContent`
212
+ (for the tab trigger button content). Access panel data via `proxy.get('key')`:
213
+
214
+ ```svelte
215
+ <Tabs {options} bind:value>
216
+ {#snippet tabPanel(proxy)}
217
+ <div class="p-4">
218
+ <h2 class="text-ink font-medium">{proxy.label}</h2>
219
+ <div>{proxy.get('content')}</div>
220
+ </div>
221
+ {/snippet}
222
+ </Tabs>
223
+ ```
224
+
225
+ `Tabs` also supports an `empty` snippet (no arguments) rendered when `options` is empty.
226
+
227
+ ### Per-item named snippets (List)
228
+
229
+ Set `item.snippet = 'name'` on specific items to route them to a named snippet.
230
+ Other items fall back to `itemContent`:
231
+
232
+ ```svelte
233
+ <script>
234
+ const items = [
235
+ { label: 'Apple', snippet: 'fruit' },
236
+ { label: 'Carrot', snippet: 'veggie' }
237
+ ]
238
+ </script>
239
+
240
+ <List {items}>
241
+ {#snippet fruit(proxy)}
242
+ <span class="i-lucide:apple"></span>
243
+ <span>{proxy.label}</span>
244
+ {/snippet}
245
+ {#snippet veggie(proxy)}
246
+ <span class="i-lucide:carrot"></span>
247
+ <span>{proxy.label}</span>
248
+ {/snippet}
249
+ </List>
250
+ ```
251
+
252
+ ---
253
+
254
+ ## Keyboard navigation & accessibility
255
+
256
+ Rokkit components are accessible out of the box — no extra wiring required as a consumer.
257
+ The navigator pattern handles keyboard events on the component's root or dropdown container.
258
+
259
+ ### What you get for free
260
+
261
+ | Component | Keys | ARIA |
262
+ |-----------|------|------|
263
+ | List / Tree | `↑↓` move focus, `↵`/`Space` select, `→`/`←` expand/collapse groups, `Home`/`End`, typeahead `a-z` | `role="navigation"`, `aria-current="page"` on active link |
264
+ | Select / MultiSelect | `↑↓` navigate dropdown, `↵` select/toggle, `Esc` close, `Home`/`End` | `role="combobox"`, `aria-expanded`, `aria-selected` |
265
+ | Menu | `↑↓` navigate items, `↵`/`Space` select, `Esc` close + return focus | `role="menu"`, `role="menuitem"` |
266
+ | Tree | `↑↓` navigate, `→` expand/enter first child, `←` collapse/go to parent | `role="tree"`, `role="treeitem"`, `aria-expanded`, `aria-selected` |
267
+ | Table | `↑↓` between rows, `↵`/`Space` select | `role="grid"`, `aria-sort` on sortable columns, `aria-selected` on rows |
268
+ | Tabs | `←`/`→` (horizontal) or `↑`/`↓` (vertical) between tabs, `↵`/`Space` select | `role="tablist"`, `role="tab"`, `role="tabpanel"`, `aria-selected`, `aria-controls` |
269
+ | Toggle | `←`/`→` between options, `↵`/`Space` select | `role="radiogroup"`, `role="radio"`, `aria-checked` |
270
+
271
+ ### Focus management note
272
+
273
+ The components sync `bind:value` to DOM focus automatically — when `value` changes
274
+ programmatically, the component moves keyboard focus to the matching item. You do not
275
+ need to call `focus()` yourself.
276
+
277
+ ### Interactive elements inside snippets
278
+
279
+ If your `itemContent` snippet contains its own interactive elements (inputs, buttons),
280
+ stop propagation to prevent the navigator from also triggering selection:
281
+
282
+ ```svelte
283
+ <List {items}>
284
+ {#snippet itemContent(proxy)}
285
+ <span class="flex-1">{proxy.label}</span>
286
+ <button
287
+ onclick={(e) => { e.stopPropagation(); doAction(proxy.value) }}
288
+ >
289
+ Action
290
+ </button>
291
+ {/snippet}
292
+ </List>
293
+ ```
294
+
295
+ Do NOT add an `onclick` on elements that also carry `data-path` — the navigator
296
+ intercepts those clicks and would double-fire.
297
+
298
+ ---
299
+
300
+ ## Which component when
301
+
302
+ | Need | Component | Data prop | Callback |
303
+ |------|-----------|-----------|----------|
304
+ | Vertical navigable list (sidebar nav, option list) | `List` | `items` | `onselect` |
305
+ | Hierarchical tree with expand/collapse | `Tree` | `items` | `onselect` |
306
+ | Dropdown single selection | `Select` | `items` | `onchange` |
307
+ | Dropdown multi-selection with chips | `MultiSelect` | `items` | `onchange` |
308
+ | Contextual action menu (dropdown actions) | `Menu` | `items` | `onselect` |
309
+ | Flat data table with columns + row selection | `Table` | `data` | `onselect` |
310
+ | Tabbed panels (horizontal or vertical) | `Tabs` | `options` | `onchange` |
311
+ | Compact mode/view switcher (segmented control) | `Toggle` | `options` | `onchange` |
312
+
313
+ **Use `List` not `Select`** when the options are always visible (sidebar, settings panel).
314
+ **Use `Select` not `List`** when screen space is limited and the dropdown can close.
315
+ **Use `Toggle` not `Tabs`** for 2–5 options with no panel content (just a mode switch).
316
+ **Use `Tree` not `List`** when data is genuinely hierarchical (file system, nested categories).
317
+
318
+ ---
319
+
320
+ ## Recipes
321
+
322
+ ### Select bound to a value
323
+
324
+ ```svelte
325
+ <script>
326
+ import { Select } from '@rokkit/ui'
327
+
328
+ const roles = [
329
+ { label: 'Admin', value: 'admin' },
330
+ { label: 'Editor', value: 'editor' },
331
+ { label: 'Viewer', value: 'viewer' }
332
+ ]
333
+ let role = $state('viewer')
334
+ </script>
335
+
336
+ <Select items={roles} bind:value={role} onchange={(v) => console.log('role:', v)} />
337
+ <p>Current role: {role}</p>
338
+ ```
339
+
340
+ ### Tree with nested children
341
+
342
+ ```svelte
343
+ <script>
344
+ import { Tree } from '@rokkit/ui'
345
+
346
+ const files = [
347
+ {
348
+ label: 'src',
349
+ icon: 'i-lucide:folder',
350
+ children: [
351
+ { label: 'index.ts', icon: 'i-lucide:file-code' },
352
+ { label: 'utils.ts', icon: 'i-lucide:file-code' }
353
+ ]
354
+ },
355
+ { label: 'README.md', icon: 'i-lucide:file-text' }
356
+ ]
357
+
358
+ let selected = $state(null)
359
+ </script>
360
+
361
+ <Tree items={files} bind:value={selected} onselect={(v) => console.log('file:', v)} />
362
+ ```
363
+
364
+ ### MultiSelect with chips
365
+
366
+ ```svelte
367
+ <script>
368
+ import { MultiSelect } from '@rokkit/ui'
369
+
370
+ const tags = [
371
+ { label: 'Frontend', value: 'fe' },
372
+ { label: 'Backend', value: 'be' },
373
+ { label: 'DevOps', value: 'ops' }
374
+ ]
375
+ let selected = $state([])
376
+ </script>
377
+
378
+ <MultiSelect
379
+ items={tags}
380
+ bind:value={selected}
381
+ placeholder="Select tags…"
382
+ onchange={(vals) => console.log('selected:', vals)}
383
+ />
384
+ <p>Tags: {selected.join(', ')}</p>
385
+ ```
386
+
387
+ ### Table with explicit columns and field mapping
388
+
389
+ ```svelte
390
+ <script>
391
+ import { Table } from '@rokkit/ui'
392
+
393
+ // Row identifier is 'uid' — remap via fields
394
+ const users = [
395
+ { uid: 1, fullName: 'Alice Chen', dept: 'Engineering' },
396
+ { uid: 2, fullName: 'Bob Tanaka', dept: 'Design' }
397
+ ]
398
+
399
+ const columns = [
400
+ { name: 'fullName', label: 'Name', sortable: true },
401
+ { name: 'dept', label: 'Department', sortable: true }
402
+ ]
403
+
404
+ let selectedUser = $state(null)
405
+ </script>
406
+
407
+ <Table
408
+ data={users}
409
+ {columns}
410
+ fields={{ value: 'uid' }}
411
+ bind:value={selectedUser}
412
+ caption="User list"
413
+ onselect={(v, row) => console.log('selected row:', row)}
414
+ />
415
+ ```
@@ -0,0 +1,390 @@
1
+ ---
2
+ name: semantic-styles-rokkit
3
+ description: Use when building, styling, or auditing a Rokkit-powered app (Svelte 5 + UnoCSS presetRokkit) — covers the named-token vocabulary (paper / ink / primary / on-primary / *-soft), the rokkit.config.js palette→skin→tokens pipeline, dual-palette dark mode, migrating legacy z-scale utilities (bg-surface-z0, text-primary-z5) to named tokens, and the rokkit init / rokkit doctor CLI.
4
+ ---
5
+
6
+ # Semantic Styles — Rokkit
7
+
8
+ Rokkit's token pipeline turns a small config into a consistent, themeable, dark-mode-ready
9
+ set of utility classes. Components use **named tokens** (`bg-paper`, `text-ink`,
10
+ `text-on-primary`) — never raw hex/oklch values. That indirection is the consistency
11
+ guarantee: change the config, the whole app reskins.
12
+
13
+ ```
14
+ rokkit.config.js
15
+ palettes + skin + colorSpace + tokens
16
+ ↓
17
+ UnoCSS preset (presetRokkit) → CSS custom properties + utility classes
18
+ ↓
19
+ Components use named-token utilities: bg-paper · text-ink · bg-primary · text-on-primary
20
+ (legacy z-scale utilities — bg-surface-z1 — still resolve via back-compat aliases)
21
+ ```
22
+
23
+ The pipeline has three layers. Most authoring happens in **Layer 3 (named tokens)**.
24
+
25
+ ---
26
+
27
+ ## Layer 1 — Palettes
28
+
29
+ **What:** A palette is a named 11-stop color scale from shade `50` (lightest) to `950`
30
+ (darkest), declared in `rokkit.config.js` under `palettes:`.
31
+
32
+ **Why:** Each role needs a range of shades so hover, active, tint-on-surface, and
33
+ text-on-fill all come from one color family. 11 stops gives resolution without banding.
34
+
35
+ **Two formats:**
36
+
37
+ *RGB hex* — standard web colors; use when you have an existing brand palette:
38
+ ```js
39
+ palettes: { shu: { 50: '#FEF3EE', 500: '#E8552B', 950: '#3C100A' } }
40
+ // colorSpace: 'rgb' (default — may be omitted)
41
+ ```
42
+
43
+ *OKLCH bare components* — perceptual, uniform lightness steps; best for palettes designed
44
+ from scratch (increment L by equal steps for balanced results):
45
+ ```js
46
+ palettes: { shu: { 50: '0.970 0.020 35', 500: '0.580 0.150 35', 950: '0.220 0.060 35' } },
47
+ colorSpace: 'oklch' // REQUIRED when palette values are bare "L C H" components
48
+ ```
49
+
50
+ The preset wraps values into complete colors when it emits CSS vars (see Layer 3).
51
+
52
+ ---
53
+
54
+ ## Layer 2 — Skin and skins
55
+
56
+ **What:** A skin maps semantic role names to palette names. The core roles that back the
57
+ named-token vocabulary are: `surface`, `ink`, `primary`, `accent`, `success`, `warning`,
58
+ `danger`, `error`, `info`. (`secondary`/`tertiary` are accepted but no named token reads
59
+ them — they only matter if you also use `extended` z-scale utilities for those roles.)
60
+
61
+ **Why:** Decoupling role from palette lets you swap the whole scheme in config, not code.
62
+ `bg-paper` doesn't know whether `surface` is `kami`, `slate`, or `zinc`.
63
+
64
+ ### Single skin (`skin:`) — one fixed colormap
65
+ ```js
66
+ skin: {
67
+ surface: 'slate', ink: 'slate', // ink backs the ink-* text tokens
68
+ primary: 'orange', accent: 'sky',
69
+ success: 'green', warning: 'yellow', danger: 'red', error: 'red', info: 'cyan',
70
+ }
71
+ ```
72
+ `colors:` is accepted as a back-compat alias but **prefer `skin:`**. Always include an
73
+ `ink` role — without it the `ink-*` text tokens silently fall back to the surface palette.
74
+
75
+ ### Multi-skin (`skins:`) — named colormaps for runtime switching
76
+ ```js
77
+ skins: {
78
+ default: { surface: { light: 'kami', dark: 'sumi' }, ink: { light: 'kami', dark: 'sumi' }, primary: 'shu' },
79
+ ocean: { surface: 'slate', primary: 'sky', accent: 'teal' },
80
+ }
81
+ ```
82
+ `skins.default` is active on first load. **Use `skin:` OR `skins:`, not both** — providing
83
+ `skins:` makes `skin:` ignored.
84
+
85
+ ### `tokens:` — which CSS the preset emits
86
+ ```js
87
+ tokens: 'core' // default — emit the 24 named tokens (palette values inlined) + z-aliases
88
+ tokens: 'extended' // also emit the full 11-shade ladder (--color-{role}-{shade}) per role
89
+ ```
90
+ `'core'` is the default and what apps should use. Reach for `'extended'` only when you need
91
+ the full ladder (charts / data-viz). Per-role is allowed: `tokens: { surface: 'extended' }`.
92
+
93
+ ---
94
+
95
+ ## Layer 2b — Dual-palette dark mode (how the flip actually works)
96
+
97
+ **Named tokens flip in dark mode ONLY when the skin uses a dual-palette `{ light, dark }`
98
+ mapping for a role (or an override carries a `dark` value).** The preset emits a
99
+ `[data-mode="dark"]` block redefining the tokens **only then** — there is no automatic
100
+ z-flip in the themes CSS anymore.
101
+
102
+ ```js
103
+ skin: {
104
+ surface: { light: 'kami', dark: 'sumi' }, // ← emits the dark block
105
+ ink: { light: 'kami', dark: 'sumi' },
106
+ primary: 'shu', // single palette — constant across modes (correct for an accent)
107
+ }
108
+ ```
109
+
110
+ The trick is that the **dark palette is authored inverted** relative to the light one, so
111
+ the same shade index reads correctly per mode:
112
+
113
+ | token (shade) | light (`kami`) | dark (`sumi`) |
114
+ |---------------|-----------------------|-----------------------|
115
+ | `--paper` (50) | `0.985…` (light bg) | `0.170…` (dark bg) |
116
+ | `--ink` (900) | `0.220…` (dark text) | `0.940…` (light text) |
117
+
118
+ > **Gotcha:** a **single-palette** surface (e.g. `surface: 'slate'`) emits **no dark
119
+ > block → no flip** → effectively light-only. For dark mode, make `surface` and `ink`
120
+ > dual-palette (the canonical `kami`/`sumi` OKLCH pair, where `sumi` is authored inverted).
121
+ > The `rokkit init` "Zen-Sumi" starter ships this; the plain Tailwind starter does not.
122
+
123
+ Dark mode flips when `data-mode="dark"` is on `<html>`/`<body>` — see the wiring section
124
+ below for how to apply and persist that correctly.
125
+
126
+ ---
127
+
128
+ ## Applying & persisting the theme (don't hand-roll this)
129
+
130
+ **Never** hand-roll `data-mode` writes, `localStorage` save/load, or your own theme store.
131
+ The pattern is: the **`vibe` store** (`@rokkit/states`) holds reactive state; the
132
+ **`themable` action** (`@rokkit/actions`) syncs it to the DOM + storage; **`ThemeSwitcherToggle`**
133
+ (`@rokkit/app`) is the ready-made control.
134
+
135
+ **1. Wire the root once** (e.g. `+layout.svelte`). `themable` loads from storage on mount,
136
+ writes `data-style`/`data-mode`/`data-density` (mirrored to `<html>` to match the
137
+ flash-prevention init script), **saves on every change**, and syncs across tabs via the
138
+ `storage` event:
139
+
140
+ ```svelte
141
+ <script>
142
+ import { vibe } from '@rokkit/states'
143
+ import { themable } from '@rokkit/actions'
144
+ </script>
145
+ <svelte:body use:themable={{ theme: vibe, storageKey: 'app-theme' }} />
146
+ ```
147
+
148
+ **2. Add the mode control** anywhere — it drives `vibe.mode`, which `themable` then writes:
149
+
150
+ ```svelte
151
+ <script>import { ThemeSwitcherToggle } from '@rokkit/app'</script>
152
+ <ThemeSwitcherToggle variant="single" /> <!-- one button, cycles light ↔ dark -->
153
+ <ThemeSwitcherToggle variant="triad" /> <!-- group: system | light | dark (default) -->
154
+ <ThemeSwitcherToggle variant="triad" includeSystem={false} /> <!-- group: light | dark -->
155
+ ```
156
+
157
+ **3. Switch the visual style** by setting state — no DOM writes:
158
+ ```svelte
159
+ <button onclick={() => (vibe.style = 'minimal')}>Minimal</button>
160
+ ```
161
+
162
+ **4. Prevent the flash on first paint.** Inject the pre-paint script so the persisted
163
+ theme applies before first render. SvelteKit apps use the SSR `themeHook`; static apps use
164
+ the script `rokkit init` writes into `app.html`:
165
+
166
+ ```js
167
+ // src/hooks.server.ts
168
+ import { themeHook } from '@rokkit/unocss/hooks'
169
+ export const handle = themeHook({ storageKey: 'app-theme', defaultMode: 'system' })
170
+ ```
171
+
172
+ `defaultMode: 'system'` (also the default when omitted) resolves `prefers-color-scheme`
173
+ to a concrete `light`/`dark` pre-paint — never an invalid `data-mode="system"`. `'auto'`
174
+ is accepted as a legacy alias. (Requires `@rokkit/unocss` ≥ 1.1.12.)
175
+
176
+ `vibe` (singleton) exposes reactive `mode` (`light`|`dark`), `style`
177
+ (`rokkit`|`minimal`|`material`), `density` (`compact`|`comfortable`|`cozy`), `direction`,
178
+ plus `load(key)` / `save(key)` / `update(partial)`. `ThemeSwitcherToggle` lives in
179
+ `@rokkit/app` (the store-wired shell package), built on `@rokkit/ui`'s `Toggle`; it adds
180
+ `system` on top of `vibe.mode` via `ColorModeManager` (resolving `system` through
181
+ `prefers-color-scheme`). The `storageKey` must be the same everywhere — `themable`,
182
+ `ThemeSwitcherToggle`'s store, and the flash-prevention script (step 4).
183
+
184
+ ---
185
+
186
+ ## Layer 3 — Named tokens (what components use)
187
+
188
+ The 24-token core vocabulary, all flipping automatically under `[data-mode="dark"]`:
189
+
190
+ | Group | Tokens | Use |
191
+ |---------|---------------------------------------------------------|--------------------------------------------------|
192
+ | Surface | `paper` · `paper-soft` · `paper-mute` · `paper-edge` | canvas · card · subdued panel · hairline border |
193
+ | Text | `ink` · `ink-mute` · `ink-soft` · `ink-faint` | body · secondary · placeholder · disabled |
194
+ | Primary | `primary` · `on-primary` | CTA fill · text/icon on a primary fill |
195
+ | Accent | `accent` · `accent-soft` | accent fill · tinted accent background |
196
+ | Status | `success`/`-soft`, `warning`/`-soft`, `danger`/`-soft`, `error`/`-soft`, `info`/`-soft` | solid status · tinted callout bg |
197
+ | Misc | `focus-ring` · `shadow-tint` | focus ring color · shadow color |
198
+
199
+ **Prefixes:** `bg-`, `text-`, `border-` (+ `border-t/-b/-l/-r`), `fill-`, `stroke-`,
200
+ `ring-`, `outline-`, `divide-`. Two constraints: `on-primary` emits **text only**
201
+ (`text-on-primary`); `focus-ring` emits ring/outline/border/divide only; `shadow-tint`
202
+ emits no utility (used inside `box-shadow` expressions).
203
+
204
+ ```html
205
+ <!-- card -->
206
+ <div class="bg-paper-soft border border-paper-edge rounded-md p-4">
207
+ <h3 class="text-ink text-sm font-medium">Title</h3>
208
+ <p class="text-ink-mute text-xs mt-1">Description</p>
209
+ </div>
210
+
211
+ <!-- primary button + status badge -->
212
+ <button class="bg-primary text-on-primary rounded-md px-3 py-2">Save</button>
213
+ <span class="bg-success-soft text-success text-xs px-2 py-0.5 rounded-full">Active</span>
214
+
215
+ <!-- input -->
216
+ <input class="bg-paper border border-paper-edge text-ink rounded-md px-3 py-2
217
+ focus:border-primary focus:outline-none text-[13px]" />
218
+ ```
219
+
220
+ ### CSS custom properties
221
+
222
+ Named tokens emit `--{token}` vars holding **complete color values** —
223
+ `--paper: oklch(0.985 0.005 85)` (oklch) or `--primary: rgb(232, 85, 43)` (rgb). Use them
224
+ directly in CSS-only contexts; for alpha, use `color-mix` (the form the preset itself uses):
225
+ ```css
226
+ .thing { background: var(--paper); border-color: var(--paper-edge); }
227
+ .thing-50pc { background: color-mix(in oklch, var(--primary) 50%, transparent); }
228
+ ```
229
+ Prefer utility classes over raw vars where you can. (In `core` mode the legacy
230
+ `--color-{role}-z{n}` vars are aliases pointing at named tokens, so they too resolve to
231
+ complete colors — the old "wrap bare components in `rgb()`/`oklch()`" rule no longer
232
+ applies in core mode.)
233
+
234
+ ### Legacy z-scale utilities (back-compat)
235
+
236
+ The older z-scale utilities (`bg-surface-z0`, `text-surface-z9`, `text-primary-z5`, …)
237
+ **still resolve** as a back-compat layer that collapses onto the named tokens above —
238
+ existing code keeps working. New code should use named tokens. See the migration section.
239
+
240
+ ---
241
+
242
+ ## Migrating from z-scale to named tokens
243
+
244
+ `rokkit doctor` scans `src/**` and prints the suggested replacement per occurrence
245
+ (advisory — it never rewrites your code). The mapping it uses:
246
+
247
+ **Surface role** (`{prefix}-surface-z{n}` → `{prefix}-{token}`):
248
+
249
+ | z | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
250
+ |---|---|---|---|---|---|---|---|---|---|---|----|
251
+ | token | `paper` | `paper-soft` | `paper-mute` | `paper-mute` | `paper-edge` | `ink-soft` | `ink-soft` | `ink-mute` | `ink-mute` | `ink` | `ink` |
252
+
253
+ **Ink role** (inverted scale):
254
+
255
+ | z | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
256
+ |---|---|---|---|---|---|---|---|---|---|---|----|
257
+ | token | `ink` | `ink-mute` | `ink-mute` | `ink-soft` | `ink-soft` | `paper-edge` | `paper-edge` | `paper-mute` | `paper-mute` | `paper-soft` | `paper` |
258
+
259
+ **Accent / status roles** (`primary`, `accent`, `success`, `warning`, `danger`, `error`,
260
+ `info`): `z0–z2 → {role}-soft` (tinted background), `z3+ → {role}` (solid). Exception:
261
+ `primary` has **no** `-soft` companion — `primary-z*` all map to `primary`; for a tinted
262
+ primary background use `accent-soft` or a custom token.
263
+
264
+ Common rewrites:
265
+ ```
266
+ bg-surface-z0 → bg-paper text-surface-z9 → text-ink
267
+ bg-surface-z1 → bg-paper-soft text-surface-z7 → text-ink-mute
268
+ border-surface-z3 → border-paper-edge text-surface-z5 → text-ink-soft
269
+ text-primary-z5 → text-primary bg-success-z1 → bg-success-soft
270
+ ```
271
+
272
+ ---
273
+
274
+ ## Rokkit CLI — `init` and `doctor`
275
+
276
+ **`rokkit init`** scaffolds `rokkit.config.js` in the named-token shape (a header comment
277
+ documents the vocabulary). Two starters via the palette prompt:
278
+ - **Tailwind (rgb)** — `skin` of Tailwind palette names, `colorSpace: 'rgb'`, `tokens: 'core'`. Single-palette → light-leaning (see Layer 2b).
279
+ - **Zen-Sumi (OKLCH)** — ships `palettes` (kami/sumi/shu/…), `colorSpace: 'oklch'`, dual-palette `surface`/`ink` → full dark mode out of the box.
280
+
281
+ **`rokkit doctor`** validates an existing setup. Beyond file checks (config exists,
282
+ `uno.config.js` uses `presetRokkit(config)`, `app.css` imports a theme, `app.html` has the
283
+ mode init script), it runs **advisory** config-shape warnings and migration hints — none
284
+ fail the command or block `--fix`:
285
+
286
+ | Check | Warns when |
287
+ |-------|-----------|
288
+ | `skin-ink-role` | the active colormap has no `ink` role (text tokens fall back) |
289
+ | `oklch-needs-palettes` | `colorSpace: 'oklch'` but no `palettes` block |
290
+ | `colors-alias` | config uses the legacy `colors:` instead of `skin:` |
291
+ | legacy z-utilities | `src/**` contains `bg-surface-z*` / `text-primary-z*` etc. (prints named-token suggestions) |
292
+
293
+ The "active colormap" is resolved the same way the preset does: `skins.default ?? skin ??
294
+ colors`. Run `rokkit doctor --fix` to auto-write a real starter config when one is missing.
295
+
296
+ ---
297
+
298
+ ## Typography configuration
299
+
300
+ Set faces in config; the preset emits CSS vars and Tailwind `fontFamily` keys.
301
+ ```js
302
+ typography: {
303
+ display: "'Fraunces', 'Iowan Old Style', Georgia, serif", // heading/display face
304
+ sans: "'Inter', system-ui, sans-serif", // UI/body face (alias: `ui`)
305
+ mono: "'JetBrains Mono', 'SF Mono', Menlo, monospace",
306
+ }
307
+ ```
308
+ Canonical config keys are `display` / `ui` / `mono`; `heading` / `sans` are accepted
309
+ aliases. Usage classes: `font-heading` (display face), `font-body` (UI face), `font-mono`.
310
+ ```html
311
+ <h1 class="font-heading text-2xl text-ink">Page Title</h1>
312
+ <p class="font-body text-[13px] text-ink-mute">Description</p>
313
+ <code class="font-mono text-xs text-ink-soft">path/to/file.ts</code>
314
+ ```
315
+
316
+ ---
317
+
318
+ ## Shape / radius configuration
319
+
320
+ `shape: { radius: 'soft' }` selects a preset emitting `--radius-{sm,md,lg,xl,full}`.
321
+
322
+ | Preset | sm | md | lg | xl | full | Personality |
323
+ |--------|----|----|----|----|------|-------------|
324
+ | `sharp` | 0 | 0 | 0 | 0 | 9999px | Minimal, editorial |
325
+ | `soft` | 2px | 6px | 10px | 12px | 9999px | Professional, calm |
326
+ | `rounded` | 4px | 8px | 12px | 16px | 9999px | Friendly, modern |
327
+ | `pill` | 9999px | 9999px | 9999px | 9999px | 9999px | Playful, pill-native |
328
+
329
+ Custom: `shape: { radius: { sm: '2px', md: '6px', lg: '10px', xl: '14px', full: '9999px' } }`.
330
+ Use the Tailwind radius utilities that map to the vars (`rounded-md`, `rounded-lg`, …) —
331
+ never `rounded-[6px]`, which bypasses the config.
332
+
333
+ ---
334
+
335
+ ## Spacing
336
+
337
+ Rokkit inherits Tailwind's 4px-grid spacing. **Stay on the 4px grid, use Tailwind names.**
338
+
339
+ | Tailwind | px | When |
340
+ |----------|----|------|
341
+ | `1` / `2` / `3` | 4 / 8 / 12 | tight gap · component gap · compact padding |
342
+ | `4` / `6` | 16 / 24 | default padding · panel padding |
343
+ | `8` / `12` / `16` | 32 / 48 / 64 | section separation · page padding · hero whitespace |
344
+
345
+ Density: **compact** (tables/sidebars) `gap-1`/`gap-2`, `p-2`/`p-3`; **comfortable**
346
+ (cards/forms) `gap-3`/`gap-4`, `p-4`/`p-6`; **relaxed** (marketing) `16–24px+`.
347
+
348
+ ---
349
+
350
+ ## Full config example
351
+
352
+ ```js
353
+ // rokkit.config.js
354
+ export default {
355
+ palettes: {
356
+ kami: { 50: '0.985 0.005 85', /* … */ 950: '0.170 0.010 50' }, // light surface (warm paper)
357
+ sumi: { 50: '0.170 0.010 50', /* … */ 950: '0.975 0.008 85' }, // dark surface (inverted ink)
358
+ shu: { 50: '0.970 0.020 35', 500: '0.580 0.150 35', 950: '0.220 0.060 35' }, // accent
359
+ },
360
+ colorSpace: 'oklch', // required for bare "L C H" palette values
361
+ tokens: 'core', // emit the named-token vocabulary (default)
362
+ skin: {
363
+ surface: { light: 'kami', dark: 'sumi' }, // dual-palette → dark mode
364
+ ink: { light: 'kami', dark: 'sumi' },
365
+ primary: 'shu', accent: 'shu',
366
+ success: 'hisui', warning: 'kohaku', danger: 'shu', error: 'shu', info: 'kohaku',
367
+ },
368
+ typography: { display: "'Fraunces', Georgia, serif", sans: "'Inter', sans-serif", mono: "'JetBrains Mono', monospace" },
369
+ shape: { radius: 'soft' },
370
+ switcher: 'full', // 'system' | 'manual' | 'full'
371
+ storageKey: 'app-theme',
372
+ }
373
+ ```
374
+
375
+ ---
376
+
377
+ ## Common mistakes
378
+
379
+ | Mistake | Why it fails | Fix |
380
+ |---|---|---|
381
+ | `color: #3D3730` in a component | breaks on theme change | `text-ink` (or the right named token) |
382
+ | Using `bg-surface-z5` / `text-primary-z5` in new code | legacy back-compat layer | named tokens — see migration table (or run `rokkit doctor`) |
383
+ | Single-palette `surface` but expecting dark mode | no `[data-mode]` block is emitted | make `surface`/`ink` dual-palette `{ light, dark }` |
384
+ | Skin with no `ink` role | `ink-*` text tokens fall back to surface | add `ink: '<palette>'` (reuse surface is fine) |
385
+ | Using `skin:` and `skins:` together | `skins:` silently wins | pick one |
386
+ | `oklch(0.58 0.15 35)` literal in a component | loses theming + composability | `bg-primary` / `var(--primary)` |
387
+ | Double-wrapping in core mode: `oklch(var(--paper))` | `--paper` is already a complete color | use `var(--paper)` directly |
388
+ | `colorSpace: 'oklch'` with hex palettes (or vice-versa) | wrap form won't match values | match `colorSpace` to the palette format |
389
+ | `border-radius: 6px` / `font-family: 'Inter'` hardcoded | bypasses shape/typography config | `rounded-md` / `font-body` |
390
+ | `@apply bg-paper` on `body {}` | descendant-combinator shortcut can't match `body` when `data-mode` is on it | `body { background: var(--paper); }` |
package/src/index.js CHANGED
@@ -121,4 +121,22 @@ prog
121
121
  await theme('create', opts)
122
122
  })
123
123
 
124
+ prog
125
+ .command('skills list')
126
+ .describe('List available Rokkit AI skills')
127
+ .action(async () => {
128
+ const { skillsCommand } = await import('./skills.js')
129
+ await skillsCommand('list')
130
+ })
131
+
132
+ prog
133
+ .command('skills add')
134
+ .describe('Add Rokkit AI skills to .claude/skills/')
135
+ .option('--all', 'Install all available skills')
136
+ .option('--force', 'Overwrite an existing skill')
137
+ .action(async (opts) => {
138
+ const { skillsCommand } = await import('./skills.js')
139
+ await skillsCommand('add', opts)
140
+ })
141
+
124
142
  prog.parse(process.argv)
package/src/init.js CHANGED
@@ -314,13 +314,30 @@ export function generateAppCssImports(themes) {
314
314
 
315
315
  /**
316
316
  * Generate the flash-prevention script for app.html.
317
- * Returns null when no script is needed (system switcher).
317
+ *
318
+ * Themes flip on `[data-mode="dark"]` only — there is no `@media
319
+ * (prefers-color-scheme)` fallback — so every switcher needs a script that
320
+ * resolves the mode pre-paint (mirrors `@rokkit/unocss`'s `themeHook`):
321
+ * - `system` tracks the OS preference directly (no persisted toggle).
322
+ * - `manual` / `full` read the persisted mode and resolve a stored-or-default
323
+ * `'system'`/`'auto'` against the OS preference (default: follow the OS).
324
+ *
318
325
  * @param {string} switcher — 'system' | 'manual' | 'full'
319
326
  * @param {string} [storageKey='rokkit-theme']
320
- * @returns {string | null}
327
+ * @param {string} [defaultStyle='rokkit']
328
+ * @returns {string} the `<script>` tag to inject after `<body>`
321
329
  */
322
330
  export function generateInitScript(switcher, storageKey = 'rokkit-theme', defaultStyle = 'rokkit') {
323
- if (switcher === 'system') return null
331
+ if (switcher === 'system') {
332
+ return ` <script>
333
+ (function () {
334
+ try {
335
+ document.body.dataset.mode =
336
+ matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
337
+ } catch (e) {}
338
+ })()
339
+ </script>`
340
+ }
324
341
 
325
342
  const setStyle =
326
343
  switcher === 'full' ? `b.dataset.style = t.style || '${defaultStyle}'\n ` : ''
@@ -330,7 +347,10 @@ export function generateInitScript(switcher, storageKey = 'rokkit-theme', defaul
330
347
  try {
331
348
  var t = JSON.parse(localStorage.getItem('${storageKey}') || '{}')
332
349
  var b = document.body
333
- ${setStyle}b.dataset.mode = t.mode || 'dark'
350
+ ${setStyle}var m = t.mode || 'system'
351
+ if (m === 'system' || m === 'auto')
352
+ m = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
353
+ b.dataset.mode = m
334
354
  } catch (e) {}
335
355
  })()
336
356
  </script>`
package/src/skills.js ADDED
@@ -0,0 +1,141 @@
1
+ /* eslint-disable no-console */
2
+ import { readFileSync, readdirSync, existsSync, mkdirSync, cpSync } from 'node:fs'
3
+ import { join, resolve } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import prompts from 'prompts'
6
+
7
+ // The catalog ships inside the package: packages/cli/skills/ in dev/test,
8
+ // node_modules/@rokkit/cli/skills/ once installed. One relative path, both worlds.
9
+ // Guard against non-file: URLs (e.g. jsdom sets import.meta.url to http://localhost/...).
10
+ const DEFAULT_SKILLS_DIR = (() => {
11
+ try {
12
+ return fileURLToPath(new URL('../skills/', import.meta.url))
13
+ } catch {
14
+ return resolve(process.cwd(), 'packages/cli/skills')
15
+ }
16
+ })()
17
+
18
+ /**
19
+ * Parse `name` and `description` from a SKILL.md YAML frontmatter block.
20
+ * @param {string} md
21
+ * @returns {{ name: string, description: string }}
22
+ */
23
+ export function parseFrontmatter(md) {
24
+ // Keep these as literal regexes — do NOT collapse into a dynamic
25
+ // `new RegExp(`^${key}:...`)` helper: the keys are fixed, so the dynamic form
26
+ // adds nothing but trips the no-dynamic-regex lint, and the literals are
27
+ // plainly linear-time. The `?.[1]?.trim() ?? ''` chain returns '' for a
28
+ // missing block, a missing field, or a bare `key:` with no value.
29
+ const block = md.match(/^---\n([\s\S]*?)\n---/)?.[1] ?? ''
30
+ const name = block.match(/^name:\s*(.+)$/m)?.[1]?.trim() ?? ''
31
+ const description = block.match(/^description:\s*(.+)$/m)?.[1]?.trim() ?? ''
32
+ return { name, description }
33
+ }
34
+
35
+ /**
36
+ * Read the bundled skill catalog (derived from each SKILL.md's frontmatter).
37
+ * @param {{ skillsDir?: string }} [opts]
38
+ * @returns {Array<{ name: string, description: string }>}
39
+ */
40
+ export function listSkills({ skillsDir = DEFAULT_SKILLS_DIR } = {}) {
41
+ if (!existsSync(skillsDir)) return []
42
+ return readdirSync(skillsDir)
43
+ .filter((entry) => existsSync(join(skillsDir, entry, 'SKILL.md')))
44
+ .map((entry) => parseFrontmatter(readFileSync(join(skillsDir, entry, 'SKILL.md'), 'utf-8')))
45
+ .filter((s) => s.name)
46
+ .sort((a, b) => a.name.localeCompare(b.name))
47
+ }
48
+
49
+ /**
50
+ * Copy named skills into <cwd>/.claude/skills/<name>/ (idempotent).
51
+ * @param {string[]} names
52
+ * @param {{ cwd?: string, force?: boolean, skillsDir?: string }} [opts]
53
+ * @returns {Array<{ name: string, status: 'added' | 'skipped' | 'unknown' }>}
54
+ */
55
+ export function installSkills(
56
+ names,
57
+ { cwd = process.cwd(), force = false, skillsDir = DEFAULT_SKILLS_DIR } = {}
58
+ ) {
59
+ const targetRoot = resolve(cwd, '.claude', 'skills')
60
+ return names.map((name) => {
61
+ const src = join(skillsDir, name)
62
+ if (!existsSync(join(src, 'SKILL.md'))) return { name, status: 'unknown' }
63
+ const dest = join(targetRoot, name)
64
+ if (existsSync(dest) && !force) return { name, status: 'skipped' }
65
+ mkdirSync(targetRoot, { recursive: true })
66
+ cpSync(src, dest, { recursive: true })
67
+ return { name, status: 'added' }
68
+ })
69
+ }
70
+
71
+ /**
72
+ * Print the catalog, marking already-installed skills.
73
+ * @param {{ skillsDir?: string, cwd?: string }} [opts]
74
+ */
75
+ export async function runSkillsList({ skillsDir, cwd = process.cwd() } = {}) {
76
+ const catalog = listSkills({ skillsDir })
77
+ if (catalog.length === 0) {
78
+ console.info('No skills available.')
79
+ return
80
+ }
81
+ const installedRoot = resolve(cwd, '.claude', 'skills')
82
+ console.info('Available Rokkit skills:\n')
83
+ for (const s of catalog) {
84
+ const mark = existsSync(join(installedRoot, s.name, 'SKILL.md')) ? '✓ ' : ' '
85
+ console.info(`${mark}${s.name}`)
86
+ console.info(` ${s.description}`)
87
+ }
88
+ console.info('\nInstall with: rokkit skills add <name> [...] (or --all)')
89
+ }
90
+
91
+ /**
92
+ * Resolve which skills to install, then copy and report.
93
+ * @param {string[]} names
94
+ * @param {{ all?: boolean, force?: boolean, skillsDir?: string, cwd?: string }} [opts]
95
+ */
96
+ export async function runSkillsAdd(
97
+ names,
98
+ { all = false, force = false, skillsDir, cwd = process.cwd() } = {}
99
+ ) {
100
+ const catalog = listSkills({ skillsDir })
101
+ let selected = names
102
+ if (all) {
103
+ selected = catalog.map((s) => s.name)
104
+ } else if (selected.length === 0) {
105
+ const res = await prompts({
106
+ type: 'multiselect',
107
+ name: 'picked',
108
+ message: 'Select skills to add',
109
+ choices: catalog.map((s) => ({ title: s.name, value: s.name, description: s.description }))
110
+ })
111
+ selected = res.picked ?? []
112
+ }
113
+ if (selected.length === 0) {
114
+ console.info('No skills selected.')
115
+ return
116
+ }
117
+ const results = installSkills(selected, { cwd, force, skillsDir })
118
+ for (const r of results) {
119
+ if (r.status === 'added') console.info(` added .claude/skills/${r.name}`)
120
+ else if (r.status === 'skipped')
121
+ console.info(` skipped ${r.name} (exists — use --force to overwrite)`)
122
+ else console.error(` unknown skill: ${r.name}`)
123
+ }
124
+ if (results.some((r) => r.status === 'unknown')) {
125
+ console.error(`\nValid skills: ${catalog.map((s) => s.name).join(', ')}`)
126
+ process.exitCode = 1
127
+ }
128
+ }
129
+
130
+ /**
131
+ * CLI entry for `rokkit skills` subcommands.
132
+ * @param {'list' | 'add'} sub
133
+ * @param {{ _?: string[], all?: boolean, force?: boolean }} [opts]
134
+ */
135
+ export async function skillsCommand(sub, opts = {}) {
136
+ if (sub === 'add') {
137
+ await runSkillsAdd(opts._ ?? [], { all: opts.all, force: opts.force })
138
+ } else {
139
+ await runSkillsList()
140
+ }
141
+ }