@morya-ui/setup 0.3.7 → 0.4.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.
package/README.md CHANGED
@@ -38,6 +38,12 @@ npx @morya-ui/setup ai --skills=all
38
38
  # Only Cursor + VS Code MCP targets (still writes .mcp.json)
39
39
  npx @morya-ui/setup ai --editors=cursor,vscode
40
40
 
41
+ # Narrow skill agents (default: cursor, github-copilot, zed, claude-code, windsurf)
42
+ npx @morya-ui/setup ai --agents=github-copilot,zed
43
+
44
+ # Install companion skills to every skills-CLI agent (many dirs)
45
+ npx @morya-ui/setup ai --agents=all
46
+
41
47
  # Refresh AI template / MCP without touching dependencies
42
48
  npx @morya-ui/setup ai --skip-install
43
49
  ```
@@ -50,8 +56,9 @@ npx @morya-ui/setup ai --skip-install
50
56
  | `--pm pnpm\|yarn\|npm` | Package manager (default: detect from lockfile, else `pnpm`) |
51
57
  | `--skills <list>` | Comma-separated skill ids, or `all` (skips the prompt) |
52
58
  | `--editors <list>` | MCP targets: `cursor`, `vscode`, `zed`, or `all` (default: all three). Always also writes root `.mcp.json` |
59
+ | `--agents <list>` | Skill agents: `cursor`, `github-copilot`, `zed`, `claude-code`, `windsurf` (default), or `all` (`-a '*'`) |
53
60
  | `--yes` / `-y` | Use default skills without prompting |
54
- | `--force` | Overwrite existing template files and the `morya-ui` MCP entry |
61
+ | `--force` | Overwrite existing template files, MCP `morya-ui` entry, and skill-dir links |
55
62
  | `--dry-run` | Print actions without writing or installing |
56
63
  | `--skip-install` | Do not install / upgrade `morya-ui` or `@morya-ui/*` |
57
64
  | `--skip-template` | Do not copy AI skill / rules / docs (also skips companion skill install) |
@@ -75,7 +82,8 @@ Catalog: [`catalog/skills.json`](./catalog/skills.json).
75
82
 
76
83
  - Dependencies: always install / upgrade `morya-ui@latest` and any existing `@morya-ui/*` (e.g. `@morya-ui/nuxt`) to `@latest` unless `--skip-install`.
77
84
  - Template files and `.cursor/rules/*`: **skip** if the destination exists (unless `--force`).
78
- - Companion skills (`skills-cli`): always reinstall/update to **latest** when selected.
85
+ - Companion skills (`skills-cli`): always reinstall/update to **latest** when selected (default agents: the five listed under `--agents`).
86
+ - Mirrored skill dirs (`.claude/skills`, `.windsurf/skills`, `.github/skills`): **skip** if the destination exists (unless `--force`).
79
87
  - MCP configs: merge other servers / settings; skip existing `morya-ui` entry unless `--force`. New or forced entries use `npx -y @morya-ui/mcp@latest`.
80
88
  - `package.json` `check:colors`: add only if missing (unless `--force`).
81
89
  - Styles: inject only when an entry file is found and the import is not already present.
@@ -90,10 +98,20 @@ From the package `template/` (synced from repo `design-kit/`):
90
98
  - `.cursor/rules/` — Cursor-specific always-apply rules
91
99
  - `scripts/check-raw-colors.mjs`
92
100
 
93
- Via `npx skills add …` when optional companions are selected:
101
+ Via `npx skills add …` when optional companions are selected (default agents: Cursor, GitHub Copilot, Zed, Claude Code, Windsurf):
94
102
 
95
103
  - `.agents/skills/frontend-design/`, `fixing-accessibility/`, and/or `impeccable/` (latest upstream)
96
104
 
105
+ Setup then mirrors selected skills from `.agents/skills/` into agent-specific dirs that do not share that path:
106
+
107
+ | Agent | Extra project dir |
108
+ | --- | --- |
109
+ | Claude Code | `.claude/skills/` |
110
+ | Windsurf | `.windsurf/skills/` |
111
+ | GitHub Copilot | `.github/skills/` (in addition to `.agents/skills`) |
112
+
113
+ Cursor / Zed / Copilot already read `.agents/skills` directly.
114
+
97
115
  ### MCP (multi-editor)
98
116
 
99
117
  | Editor | Path | JSON key |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@morya-ui/setup",
3
3
  "type": "module",
4
- "version": "0.3.7",
4
+ "version": "0.4.0",
5
5
  "description": "One-shot setup for morya-ui: install the library, multi-editor MCP, Agent skills, and design config.",
6
6
  "license": "MIT",
7
7
  "homepage": "https://github.com/morya-space/morya-ui/tree/main/packages/setup#readme",
@@ -0,0 +1,174 @@
1
+ import assert from 'node:assert/strict'
2
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { afterEach, describe, it } from 'node:test'
6
+ import {
7
+ DEFAULT_SKILL_AGENTS,
8
+ extraSkillDirsForAgents,
9
+ installSkillsCli,
10
+ parseAgentsFlag,
11
+ skillAgentCliFlags,
12
+ syncProjectSkillsToAgents,
13
+ } from '../skills.mjs'
14
+
15
+ /** @type {string[]} */
16
+ const temps = []
17
+
18
+ function makeCwd() {
19
+ const cwd = mkdtempSync(join(tmpdir(), 'morya-setup-skills-'))
20
+ temps.push(cwd)
21
+ return cwd
22
+ }
23
+
24
+ afterEach(() => {
25
+ while (temps.length) {
26
+ rmSync(temps.pop(), { recursive: true, force: true })
27
+ }
28
+ })
29
+
30
+ function seedSkill(cwd, name) {
31
+ const dir = join(cwd, '.agents', 'skills', name)
32
+ mkdirSync(dir, { recursive: true })
33
+ writeFileSync(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: test\n---\n`, 'utf8')
34
+ return dir
35
+ }
36
+
37
+ describe('parseAgentsFlag', () => {
38
+ it('defaults to the five curated agents', () => {
39
+ assert.deepEqual(parseAgentsFlag(), [...DEFAULT_SKILL_AGENTS])
40
+ assert.deepEqual(parseAgentsFlag(''), [...DEFAULT_SKILL_AGENTS])
41
+ })
42
+
43
+ it('returns * for all', () => {
44
+ assert.equal(parseAgentsFlag('all'), '*')
45
+ assert.equal(parseAgentsFlag('*'), '*')
46
+ })
47
+
48
+ it('parses and dedupes a subset', () => {
49
+ assert.deepEqual(parseAgentsFlag('zed,cursor,zed'), ['zed', 'cursor'])
50
+ })
51
+
52
+ it('rejects unknown agents', () => {
53
+ assert.throws(() => parseAgentsFlag('cursor,not-a-real-agent'), /Unknown skill agent/)
54
+ })
55
+ })
56
+
57
+ describe('skillAgentCliFlags', () => {
58
+ it('emits one -a per default agent', () => {
59
+ const flags = skillAgentCliFlags(DEFAULT_SKILL_AGENTS)
60
+ assert.match(flags, /-a cursor/)
61
+ assert.match(flags, /-a github-copilot/)
62
+ assert.match(flags, /-a zed/)
63
+ assert.match(flags, /-a claude-code/)
64
+ assert.match(flags, /-a windsurf/)
65
+ })
66
+
67
+ it('emits wildcard for all', () => {
68
+ assert.equal(skillAgentCliFlags('*'), "-a '*'")
69
+ })
70
+ })
71
+
72
+ describe('extraSkillDirsForAgents', () => {
73
+ it('mirrors claude / windsurf / github dirs for defaults', () => {
74
+ assert.deepEqual(extraSkillDirsForAgents(DEFAULT_SKILL_AGENTS), [
75
+ '.github/skills',
76
+ '.claude/skills',
77
+ '.windsurf/skills',
78
+ ])
79
+ })
80
+
81
+ it('skips extras for agents that only use .agents/skills', () => {
82
+ assert.deepEqual(extraSkillDirsForAgents(['cursor', 'zed']), [])
83
+ })
84
+ })
85
+
86
+ describe('installSkillsCli', () => {
87
+ it('dry-run builds multi-agent commands', () => {
88
+ const cwd = makeCwd()
89
+ const skills = [
90
+ {
91
+ id: 'frontend-design',
92
+ name: 'Frontend Design',
93
+ description: 'x',
94
+ install: 'skills-cli',
95
+ source: 'anthropics/skills',
96
+ skill: 'frontend-design',
97
+ path: '.agents/skills/frontend-design',
98
+ },
99
+ ]
100
+ const result = installSkillsCli(cwd, ['frontend-design'], skills, {
101
+ dryRun: true,
102
+ agents: ['cursor', 'zed', 'github-copilot'],
103
+ })
104
+ assert.equal(result.dryRun, true)
105
+ assert.equal(result.commands.length, 1)
106
+ assert.match(result.commands[0], /skills add anthropics\/skills/)
107
+ assert.match(result.commands[0], /-s frontend-design/)
108
+ assert.match(result.commands[0], /-a cursor/)
109
+ assert.match(result.commands[0], /-a zed/)
110
+ assert.match(result.commands[0], /-a github-copilot/)
111
+ assert.doesNotMatch(result.commands[0], /-a cursor -y$/)
112
+ })
113
+ })
114
+
115
+ describe('syncProjectSkillsToAgents', () => {
116
+ it('links into claude / windsurf / github skill dirs', () => {
117
+ const cwd = makeCwd()
118
+ seedSkill(cwd, 'morya-ui-pages')
119
+
120
+ const { results } = syncProjectSkillsToAgents(cwd, ['morya-ui-pages'], {
121
+ agents: DEFAULT_SKILL_AGENTS,
122
+ })
123
+
124
+ const byDir = Object.fromEntries(results.map((r) => [r.dir, r]))
125
+ assert.ok(byDir['.claude/skills'])
126
+ assert.ok(byDir['.windsurf/skills'])
127
+ assert.ok(byDir['.github/skills'])
128
+ assert.ok(['linked', 'copied'].includes(byDir['.claude/skills'].action))
129
+
130
+ assert.ok(readFileSync(join(cwd, '.claude', 'skills', 'morya-ui-pages', 'SKILL.md'), 'utf8'))
131
+ assert.ok(readFileSync(join(cwd, '.windsurf', 'skills', 'morya-ui-pages', 'SKILL.md'), 'utf8'))
132
+ assert.ok(readFileSync(join(cwd, '.github', 'skills', 'morya-ui-pages', 'SKILL.md'), 'utf8'))
133
+ })
134
+
135
+ it('skips existing links unless force', () => {
136
+ const cwd = makeCwd()
137
+ seedSkill(cwd, 'morya-ui-pages')
138
+ mkdirSync(join(cwd, '.claude', 'skills', 'morya-ui-pages'), { recursive: true })
139
+ writeFileSync(join(cwd, '.claude', 'skills', 'morya-ui-pages', 'SKILL.md'), 'old\n', 'utf8')
140
+
141
+ const skipped = syncProjectSkillsToAgents(cwd, ['morya-ui-pages'], {
142
+ agents: ['claude-code'],
143
+ })
144
+ assert.equal(skipped.results[0].action, 'skipped')
145
+ assert.equal(
146
+ readFileSync(join(cwd, '.claude', 'skills', 'morya-ui-pages', 'SKILL.md'), 'utf8'),
147
+ 'old\n',
148
+ )
149
+
150
+ const forced = syncProjectSkillsToAgents(cwd, ['morya-ui-pages'], {
151
+ agents: ['claude-code'],
152
+ force: true,
153
+ })
154
+ assert.ok(['linked', 'copied'].includes(forced.results[0].action))
155
+ assert.match(
156
+ readFileSync(join(cwd, '.claude', 'skills', 'morya-ui-pages', 'SKILL.md'), 'utf8'),
157
+ /morya-ui-pages/,
158
+ )
159
+ })
160
+
161
+ it('dry-run does not create dirs', () => {
162
+ const cwd = makeCwd()
163
+ seedSkill(cwd, 'morya-ui-pages')
164
+ const { results } = syncProjectSkillsToAgents(cwd, ['morya-ui-pages'], {
165
+ agents: ['claude-code'],
166
+ dryRun: true,
167
+ })
168
+ assert.equal(results[0].action, 'linked')
169
+ assert.throws(
170
+ () => readFileSync(join(cwd, '.claude', 'skills', 'morya-ui-pages', 'SKILL.md')),
171
+ /ENOENT/,
172
+ )
173
+ })
174
+ })
package/src/cli.mjs CHANGED
@@ -5,11 +5,17 @@ import { installMoryaUi } from './install.mjs'
5
5
  import { DEFAULT_EDITORS, mergeMcpConfig, parseEditorsFlag } from './mcp.mjs'
6
6
  import { ensureCheckColorsScript } from './package-json.mjs'
7
7
  import {
8
+ DEFAULT_SKILL_AGENTS,
8
9
  buildAiInclude,
9
10
  installSkillsCli,
11
+ listCanonicalSkillFolders,
10
12
  loadSkillsCatalog,
13
+ parseAgentsFlag,
11
14
  parseSkillsFlag,
15
+ resolveSkillAgents,
12
16
  resolveSkillSelection,
17
+ skillFolderNames,
18
+ syncProjectSkillsToAgents,
13
19
  } from './skills.mjs'
14
20
  import { ensureStylesImport } from './styles.mjs'
15
21
 
@@ -54,6 +60,7 @@ Commands:
54
60
  Default command:
55
61
  - install / upgrade morya-ui@latest and any existing @morya-ui/* to @latest
56
62
  - copy DESIGN.md, AGENTS.md, selected Agent skills, Cursor rules
63
+ - install companion skills + sync into multi-agent skill dirs
57
64
  - merge MCP configs for @morya-ui/mcp@latest (Cursor, VS Code, Zed, + .mcp.json)
58
65
  - inject import 'morya-ui/styles.css' into the app entry when found
59
66
  - add check:colors script when missing
@@ -63,8 +70,9 @@ Options:
63
70
  --pm <name> Package manager: pnpm | yarn | npm (auto-detect by lockfile)
64
71
  --skills <list> Comma-separated skill ids, or "all" (skips interactive prompt)
65
72
  --editors <list> MCP targets: cursor,vscode,zed (default: all); always also writes .mcp.json
73
+ --agents <list> Skill agents: cursor,github-copilot,zed,claude-code,windsurf (default), or all
66
74
  --yes Use default skills without prompting (CI / non-interactive)
67
- --force Overwrite existing template files and MCP server entry
75
+ --force Overwrite existing template files, MCP entry, and skill dir links
68
76
  --dry-run Print actions without writing or installing
69
77
  --skip-install Skip dependency install / upgrade
70
78
  --skip-template Skip copying AI template files
@@ -88,6 +96,7 @@ export function parseArgs(argv) {
88
96
  pm: undefined,
89
97
  skills: undefined,
90
98
  editors: undefined,
99
+ agents: undefined,
91
100
  yes: false,
92
101
  force: false,
93
102
  dryRun: false,
@@ -165,6 +174,16 @@ export function parseArgs(argv) {
165
174
  options.editors = arg.slice('--editors='.length)
166
175
  continue
167
176
  }
177
+ if (arg === '--agents') {
178
+ const value = argv[++i]
179
+ if (!value) throw new Error('--agents requires a comma-separated list or "all"')
180
+ options.agents = value
181
+ continue
182
+ }
183
+ if (arg.startsWith('--agents=')) {
184
+ options.agents = arg.slice('--agents='.length)
185
+ continue
186
+ }
168
187
  throw new Error(`Unknown argument: ${arg}`)
169
188
  }
170
189
 
@@ -198,10 +217,13 @@ export async function runSetup(options) {
198
217
  pm,
199
218
  skills: skillsFlag,
200
219
  editors: editorsFlag,
220
+ agents: agentsFlag,
201
221
  yes,
202
222
  } = options
203
223
 
204
224
  const editors = editorsFlag != null ? parseEditorsFlag(editorsFlag) : [...DEFAULT_EDITORS]
225
+ const agents = agentsFlag != null ? parseAgentsFlag(agentsFlag) : [...DEFAULT_SKILL_AGENTS]
226
+ const agentsLabel = agents === '*' ? 'all (*)' : resolveSkillAgents(agents).join(',')
205
227
 
206
228
  console.log(`@morya-ui/setup [${mode}] → ${cwd}${dryRun ? ' (dry-run)' : ''}`)
207
229
  console.log('')
@@ -221,6 +243,7 @@ export async function runSetup(options) {
221
243
  }
222
244
  aiInclude = buildAiInclude(selectedSkills, catalog.skills)
223
245
  console.log(`Skills: ${selectedSkills.join(', ')}`)
246
+ console.log(`Skill agents: ${agentsLabel}`)
224
247
  console.log('')
225
248
  }
226
249
 
@@ -236,8 +259,21 @@ export async function runSetup(options) {
236
259
  })
237
260
 
238
261
  const remoteSkills = skipTemplate || !selectedSkills.length
239
- ? { installed: [], commands: [], skipped: true }
240
- : installSkillsCli(cwd, selectedSkills, catalog.skills, { dryRun })
262
+ ? { installed: [], commands: [], agents, skipped: true }
263
+ : installSkillsCli(cwd, selectedSkills, catalog.skills, { dryRun, agents })
264
+
265
+ const skillNamesForSync = needsTemplate
266
+ ? [
267
+ ...new Set([
268
+ ...skillFolderNames(selectedSkills, catalog.skills),
269
+ ...(dryRun ? [] : listCanonicalSkillFolders(cwd)),
270
+ ]),
271
+ ]
272
+ : []
273
+
274
+ const skillSync = skipTemplate || !skillNamesForSync.length
275
+ ? { results: [], skipped: true }
276
+ : syncProjectSkillsToAgents(cwd, skillNamesForSync, { force, dryRun, agents })
241
277
 
242
278
  const mcp = skipMcp
243
279
  ? { skipped: true, results: [] }
@@ -278,10 +314,27 @@ export async function runSetup(options) {
278
314
  if (remoteSkills.skipped) {
279
315
  // no companions selected or template step skipped
280
316
  } else if (remoteSkills.dryRun) {
281
- console.log(`Skills CLI: dry-run — would install ${remoteSkills.installed.join(', ')}`)
317
+ console.log(`Skills CLI: dry-run — would install ${remoteSkills.installed.join(', ')} → ${agentsLabel}`)
282
318
  for (const command of remoteSkills.commands) console.log(` $ ${command}`)
283
319
  } else {
284
- console.log(`Skills CLI: installed ${remoteSkills.installed.join(', ')} (latest)`)
320
+ console.log(`Skills CLI: installed ${remoteSkills.installed.join(', ')} (latest) → ${agentsLabel}`)
321
+ }
322
+
323
+ if (skillSync.skipped) {
324
+ // nothing to mirror
325
+ } else {
326
+ const linked = skillSync.results.filter((r) => r.action === 'linked' || r.action === 'copied')
327
+ const skippedLinks = skillSync.results.filter((r) => r.action === 'skipped')
328
+ console.log(
329
+ `Skill dirs: ${linked.length} linked/copied, ${skippedLinks.length} skipped` +
330
+ (skillSync.dryRun ? ' (dry-run)' : ''),
331
+ )
332
+ for (const entry of skillSync.results.slice(0, 8)) {
333
+ console.log(` ${entry.action} ${entry.dir}/${entry.skill}`)
334
+ }
335
+ if (skillSync.results.length > 8) {
336
+ console.log(` … and ${skillSync.results.length - 8} more`)
337
+ }
285
338
  }
286
339
 
287
340
  if (mcp.skipped) {
@@ -326,5 +379,17 @@ export async function runSetup(options) {
326
379
  console.log(' 4. Optional: pnpm check:colors')
327
380
  }
328
381
 
329
- return { mode, install, template, remoteSkills, mcp, styles, scripts, skills: selectedSkills, editors }
382
+ return {
383
+ mode,
384
+ install,
385
+ template,
386
+ remoteSkills,
387
+ skillSync,
388
+ mcp,
389
+ styles,
390
+ scripts,
391
+ skills: selectedSkills,
392
+ editors,
393
+ agents,
394
+ }
330
395
  }
package/src/skills.mjs CHANGED
@@ -1,12 +1,53 @@
1
1
  import { execSync } from 'node:child_process'
2
+ import {
3
+ cpSync,
4
+ existsSync,
5
+ lstatSync,
6
+ mkdirSync,
7
+ readdirSync,
8
+ rmSync,
9
+ symlinkSync,
10
+ } from 'node:fs'
11
+ import { dirname, join, relative } from 'node:path'
2
12
  import { createInterface } from 'node:readline'
3
- import { dirname, join } from 'node:path'
4
13
  import { fileURLToPath } from 'node:url'
5
- import { readJson } from './fs-utils.mjs'
14
+ import { ensureDir, readJson } from './fs-utils.mjs'
6
15
 
7
16
  const __dirname = dirname(fileURLToPath(import.meta.url))
8
17
  const CATALOG_PATH = join(__dirname, '..', 'catalog', 'skills.json')
9
18
 
19
+ /** Canonical project skills root shared by Cursor / Zed / GitHub Copilot. */
20
+ export const CANONICAL_SKILLS_DIR = '.agents/skills'
21
+
22
+ /**
23
+ * Default skills CLI agents for consumer projects.
24
+ * VS Code Copilot uses `github-copilot` (reads `.agents/skills`).
25
+ */
26
+ export const DEFAULT_SKILL_AGENTS = [
27
+ 'cursor',
28
+ 'github-copilot',
29
+ 'zed',
30
+ 'claude-code',
31
+ 'windsurf',
32
+ ]
33
+
34
+ /**
35
+ * Project-relative skills directories for agents that do **not** share
36
+ * `.agents/skills`. Agents that already use the canonical dir are omitted.
37
+ *
38
+ * @type {Record<string, string[]>}
39
+ */
40
+ export const EXTRA_AGENT_SKILL_DIRS = {
41
+ 'claude-code': ['.claude/skills'],
42
+ windsurf: ['.windsurf/skills'],
43
+ // Copilot also discovers `.github/skills`; keep a link for clients that prefer it.
44
+ 'github-copilot': ['.github/skills'],
45
+ }
46
+
47
+ export const KNOWN_SKILL_AGENTS = [
48
+ ...new Set([...DEFAULT_SKILL_AGENTS, ...Object.keys(EXTRA_AGENT_SKILL_DIRS)]),
49
+ ]
50
+
10
51
  /**
11
52
  * @typedef {{
12
53
  * id: string
@@ -49,6 +90,72 @@ export function defaultSkillIds(skills) {
49
90
  return skills.filter((s) => s.required || s.default).map((s) => s.id)
50
91
  }
51
92
 
93
+ /**
94
+ * @param {string | undefined} raw
95
+ * @returns {string[] | '*'}
96
+ */
97
+ export function parseAgentsFlag(raw) {
98
+ if (raw == null || raw.trim() === '') return [...DEFAULT_SKILL_AGENTS]
99
+ if (raw.trim() === 'all' || raw.trim() === '*') return '*'
100
+
101
+ const ids = raw
102
+ .split(',')
103
+ .map((part) => part.trim().toLowerCase())
104
+ .filter(Boolean)
105
+
106
+ if (!ids.length) return [...DEFAULT_SKILL_AGENTS]
107
+ if (ids.includes('*') || ids.includes('all')) return '*'
108
+
109
+ const unknown = ids.filter((id) => !KNOWN_SKILL_AGENTS.includes(id))
110
+ if (unknown.length) {
111
+ throw new Error(
112
+ `Unknown skill agent id(s): ${unknown.join(', ')}. Available: ${KNOWN_SKILL_AGENTS.join(', ')}, or all`,
113
+ )
114
+ }
115
+
116
+ return [...new Set(ids)]
117
+ }
118
+
119
+ /**
120
+ * Resolve concrete agent names for CLI / sync (expands `*`).
121
+ * @param {string[] | '*'} agents
122
+ * @returns {string[]}
123
+ */
124
+ export function resolveSkillAgents(agents) {
125
+ if (agents === '*') return [...DEFAULT_SKILL_AGENTS]
126
+ if (!Array.isArray(agents) || !agents.length) return [...DEFAULT_SKILL_AGENTS]
127
+ return [...new Set(agents)]
128
+ }
129
+
130
+ /**
131
+ * Build `-a` flags for the skills CLI.
132
+ * @param {string[] | '*'} agents
133
+ */
134
+ export function skillAgentCliFlags(agents) {
135
+ if (agents === '*') return "-a '*'"
136
+ const list = resolveSkillAgents(agents)
137
+ return list.map((name) => `-a ${name}`).join(' ')
138
+ }
139
+
140
+ /**
141
+ * Extra project skill dirs to mirror for the selected agents.
142
+ * @param {string[] | '*'} agents
143
+ * @returns {string[]}
144
+ */
145
+ export function extraSkillDirsForAgents(agents) {
146
+ const list = agents === '*' ? [...DEFAULT_SKILL_AGENTS] : resolveSkillAgents(agents)
147
+ /** @type {string[]} */
148
+ const dirs = []
149
+ for (const agent of list) {
150
+ const extras = EXTRA_AGENT_SKILL_DIRS[agent]
151
+ if (!extras) continue
152
+ for (const dir of extras) {
153
+ if (!dirs.includes(dir)) dirs.push(dir)
154
+ }
155
+ }
156
+ return dirs
157
+ }
158
+
52
159
  /**
53
160
  * @param {string | undefined} raw
54
161
  * @param {SkillEntry[]} skills
@@ -111,18 +218,38 @@ export function buildAiInclude(skillIds, skills) {
111
218
  ]
112
219
  }
113
220
 
221
+ /**
222
+ * Folder names under `.agents/skills` for the selected skill ids.
223
+ * @param {string[]} skillIds
224
+ * @param {SkillEntry[]} skills
225
+ * @returns {string[]}
226
+ */
227
+ export function skillFolderNames(skillIds, skills) {
228
+ const byId = new Map(skills.map((s) => [s.id, s]))
229
+ return skillIds.map((id) => {
230
+ const entry = byId.get(id)
231
+ if (!entry) throw new Error(`Unknown skill id: ${id}`)
232
+ return entry.skill || entry.id
233
+ })
234
+ }
235
+
114
236
  /**
115
237
  * Install selected companion skills via the skills CLI (always latest from source).
116
238
  * @param {string} cwd
117
239
  * @param {string[]} skillIds
118
240
  * @param {SkillEntry[]} skills
119
- * @param {{ dryRun?: boolean }} [options]
120
- * @returns {{ installed: string[], commands: string[], dryRun?: boolean, skipped?: boolean }}
241
+ * @param {{ dryRun?: boolean, agents?: string[] | '*' }} [options]
242
+ * @returns {{ installed: string[], commands: string[], agents: string[] | '*', dryRun?: boolean, skipped?: boolean }}
121
243
  */
122
- export function installSkillsCli(cwd, skillIds, skills, { dryRun = false } = {}) {
244
+ export function installSkillsCli(
245
+ cwd,
246
+ skillIds,
247
+ skills,
248
+ { dryRun = false, agents = DEFAULT_SKILL_AGENTS } = {},
249
+ ) {
123
250
  const selected = skills.filter((s) => skillIds.includes(s.id) && isSkillsCliSkill(s))
124
251
  if (!selected.length) {
125
- return { installed: [], commands: [], skipped: true }
252
+ return { installed: [], commands: [], agents, skipped: true }
126
253
  }
127
254
 
128
255
  for (const entry of selected) {
@@ -140,14 +267,15 @@ export function installSkillsCli(cwd, skillIds, skills, { dryRun = false } = {})
140
267
  bySource.set(entry.source, list)
141
268
  }
142
269
 
270
+ const agentFlags = skillAgentCliFlags(agents)
143
271
  const commands = []
144
272
  for (const [source, names] of bySource) {
145
273
  const skillFlags = names.map((name) => `-s ${name}`).join(' ')
146
- commands.push(`npx -y skills add ${source} ${skillFlags} -a cursor -y`)
274
+ commands.push(`npx -y skills add ${source} ${skillFlags} ${agentFlags} -y`)
147
275
  }
148
276
 
149
277
  if (dryRun) {
150
- return { installed: selected.map((s) => s.id), commands, dryRun: true }
278
+ return { installed: selected.map((s) => s.id), commands, agents, dryRun: true }
151
279
  }
152
280
 
153
281
  console.log('Companion skills (latest via skills CLI):')
@@ -156,7 +284,116 @@ export function installSkillsCli(cwd, skillIds, skills, { dryRun = false } = {})
156
284
  execSync(command, { cwd, stdio: 'inherit', shell: true })
157
285
  }
158
286
 
159
- return { installed: selected.map((s) => s.id), commands }
287
+ return { installed: selected.map((s) => s.id), commands, agents }
288
+ }
289
+
290
+ /**
291
+ * Link (or copy) canonical `.agents/skills/<name>` into agent-specific dirs.
292
+ * @param {string} cwd
293
+ * @param {string[]} skillNames folder names under `.agents/skills`
294
+ * @param {{ force?: boolean, dryRun?: boolean, agents?: string[] | '*' }} [options]
295
+ * @returns {{ results: Array<{ skill: string, dir: string, action: string }>, dryRun?: boolean, skipped?: boolean }}
296
+ */
297
+ export function syncProjectSkillsToAgents(
298
+ cwd,
299
+ skillNames,
300
+ { force = false, dryRun = false, agents = DEFAULT_SKILL_AGENTS } = {},
301
+ ) {
302
+ const names = [...new Set(skillNames.filter(Boolean))]
303
+ const targetDirs = extraSkillDirsForAgents(agents)
304
+
305
+ if (!names.length || !targetDirs.length) {
306
+ return { results: [], skipped: true, dryRun }
307
+ }
308
+
309
+ /** @type {Array<{ skill: string, dir: string, action: string }>} */
310
+ const results = []
311
+
312
+ for (const dir of targetDirs) {
313
+ for (const name of names) {
314
+ const source = join(cwd, CANONICAL_SKILLS_DIR, name)
315
+ const dest = join(cwd, dir, name)
316
+
317
+ if (!existsSync(source)) {
318
+ results.push({ skill: name, dir, action: 'missing-source' })
319
+ continue
320
+ }
321
+
322
+ if (existsSync(dest) || isSymlink(dest)) {
323
+ if (!force) {
324
+ results.push({ skill: name, dir, action: 'skipped' })
325
+ continue
326
+ }
327
+ if (!dryRun) {
328
+ rmSync(dest, { recursive: true, force: true })
329
+ }
330
+ }
331
+
332
+ if (dryRun) {
333
+ results.push({ skill: name, dir, action: 'linked' })
334
+ continue
335
+ }
336
+
337
+ ensureDir(join(cwd, dir))
338
+ const action = linkOrCopySkill(source, dest)
339
+ results.push({ skill: name, dir, action })
340
+ }
341
+ }
342
+
343
+ return { results, dryRun }
344
+ }
345
+
346
+ /**
347
+ * @param {string} path
348
+ */
349
+ function isSymlink(path) {
350
+ try {
351
+ return lstatSync(path).isSymbolicLink()
352
+ } catch {
353
+ return false
354
+ }
355
+ }
356
+
357
+ /**
358
+ * Prefer symlink; fall back to recursive copy (e.g. Windows without privilege).
359
+ * @param {string} source absolute
360
+ * @param {string} dest absolute
361
+ * @returns {'linked' | 'copied'}
362
+ */
363
+ function linkOrCopySkill(source, dest) {
364
+ mkdirSync(dirname(dest), { recursive: true })
365
+ // Windows junctions need an absolute target; POSIX prefers a relative link.
366
+ const rel = relative(dirname(dest), source) || '.'
367
+ try {
368
+ if (process.platform === 'win32') {
369
+ symlinkSync(source, dest, 'junction')
370
+ } else {
371
+ symlinkSync(rel, dest, 'dir')
372
+ }
373
+ return 'linked'
374
+ } catch {
375
+ try {
376
+ symlinkSync(rel, dest, 'dir')
377
+ return 'linked'
378
+ } catch {
379
+ cpSync(source, dest, { recursive: true })
380
+ return 'copied'
381
+ }
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Discover skill folder names already present under `.agents/skills`.
387
+ * @param {string} cwd
388
+ * @returns {string[]}
389
+ */
390
+ export function listCanonicalSkillFolders(cwd) {
391
+ const root = join(cwd, CANONICAL_SKILLS_DIR)
392
+ if (!existsSync(root)) return []
393
+ return readdirSync(root, { withFileTypes: true })
394
+ .filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
395
+ .filter((entry) => existsSync(join(root, entry.name, 'SKILL.md')))
396
+ .map((entry) => entry.name)
160
397
  }
161
398
 
162
399
  /**
@@ -22,7 +22,7 @@ Full API: docs site `/components` or MCP (`get_component`, `search`, `validate_u
22
22
 
23
23
  ## Forms · inputs
24
24
 
25
- `MForm`, `MFormItem`, `MInput`, `MInputPassword`, `MInputNumber`, `MTextarea`, `MInputOtp`, `MInputColor`, `MInputGroup`, `MInputTags`, `MSelect`, `MTreeSelect`, `MCascadeSelect`, `MListbox`, `MSelectButton`, `MDatePicker`, `MAutoComplete`, `MCheckbox` / `MCheckboxGroup`, `MRadio` / `MRadioGroup`, `MSwitch`, `MToggleButton`, `MSlider`, `MRating`, `MKnob`, `MFileUpload`, `MFloatLabel`, `MIconField`, `MLabel`
25
+ `MForm` / `MFormItem` / `MFormList`, `MInput`, `MInputPassword`, `MInputNumber`, `MTextarea`, `MInputOtp`, `MInputColor`, `MInputGroup`, `MInputTags`, `MMentions`, `MSelect`, `MTreeSelect`, `MCascadeSelect`, `MListbox`, `MSelectButton`, `MDatePicker`, `MTimePicker`, `MCalendar`, `MAutoComplete`, `MCheckbox` / `MCheckboxGroup`, `MRadio` / `MRadioGroup`, `MSwitch`, `MToggleButton`, `MSlider`, `MRating`, `MKnob`, `MFileUpload`, `MFloatLabel`, `MIconField`, `MLabel`
26
26
 
27
27
  ## Layout helpers
28
28
 
@@ -30,7 +30,7 @@ Full API: docs site `/components` or MCP (`get_component`, `search`, `validate_u
30
30
 
31
31
  ## Data
32
32
 
33
- `MTable` (`columns` + **`rows`**, not `data`; optional `virtual`, `resizable`/`filterable`, `children`, `span-method`, `tree-config`), `MDataView`, `MTree`, `MPagination`, `MOrderList`, `MPickList`, `MStatus` / `MTag` / `MChip` / `MBadge`, `MAvatar` / `MAvatarGroup`, `MTimeline`, `MMeterGroup`, `MVirtualScroller`
33
+ `MTable` (`columns` + **`rows`**, not `data`; optional `virtual`, `resizable`/`filterable`, `children`, `span-method`, `tree-config`), `MDataView`, `MList` / `MListItem` / `MListItemMeta`, `MTree`, `MPagination`, `MOrderList`, `MPickList`, `MDescriptions`, `MStatistic`, `MStatus` / `MTag` / `MChip` / `MBadge`, `MAvatar` / `MAvatarGroup`, `MTimeline`, `MMeterGroup`, `MVirtualScroller`
34
34
 
35
35
  ## Feedback
36
36
 
@@ -40,6 +40,7 @@ Full API: docs site `/components` or MCP (`get_component`, `search`, `validate_u
40
40
  | `toast` | `summary` + `detail`, or async / background feel | `feedback-choice` |
41
41
  | `<MMessage>` | Optional host for the `message` service. **Not** an inline alert | `feedback-choice` |
42
42
  | field `errorMessage` or token `role="alert"` | Persistent form / auth error | `feedback-choice` |
43
+ | `MAlert` | Persistent in-page notice (not a toast) | `feedback-choice` |
43
44
  | `MEmpty` | No-data / first-use / filtered empty | `empty-result-choice` |
44
45
  | `MResult` | Terminal outcome: success, failure, 403 / 404 / 500 | `empty-result-choice` |
45
46
  | `MLoading` / `v-loading` / `loading.service` | **Default** region or fullscreen loading mask | `loading-choice` |
@@ -50,11 +51,11 @@ Full API: docs site `/components` or MCP (`get_component`, `search`, `validate_u
50
51
 
51
52
  ## Overlays & menus
52
53
 
53
- `MDialog`, `MDrawer`, `MConfirmDialog` / `MConfirmPopup`, `MPopover`, `MTooltip`, `MDropdown` (**actions only**), `MContextMenu`, `MCommandMenu`, `MMenu` / `MMenubar` / `MTieredMenu` / `MMegaMenu`, `MTabs`, `MStepper`
54
+ `MDialog`, `MDrawer`, `MConfirmDialog` / `MConfirmPopup`, `MPopover`, `MTooltip`, `MDropdown` (**actions only**), `MContextMenu`, `MCommandMenu`, `MMenu` / `MMenubar` / `MTieredMenu` / `MMegaMenu`, `MTabs`, `MStepper`, `MTour`, `MAffix`, `MAnchor`
54
55
 
55
56
  ## Surfaces / media
56
57
 
57
- `MCard`, `MPanel`, `MAccordion`, `MCarousel`, `MGallery`, `MIcon`, `MScrollbar`, `MInplace`, `MScrollTop`, `MTerminal`
58
+ `MCard`, `MPanel`, `MAccordion`, `MCarousel`, `MGallery`, `MImage`, `MIcon`, `MQRCode`, `MScrollbar`, `MInplace`, `MScrollTop`, `MTerminal`, `MTypography` / `MTitle` / `MText` / `MParagraph` / `MLink`, `MWatermark`
58
59
 
59
60
  ## Scenario → decision
60
61
 
@@ -1308,7 +1308,7 @@ This file is the **offline** mirror for agents without MCP.
1308
1308
  **Recipe · props**
1309
1309
 
1310
1310
  - title + description
1311
- - icon 或 illustration 按需(默认即可)
1311
+ - 主区域优先 illustration(no-content / no-result…)
1312
1312
  - 表格内放在 #empty
1313
1313
 
1314
1314
  **Recipe · slots**
@@ -9,7 +9,7 @@ Golden pages are **assembly demos / structure baselines**, not the default clone
9
9
  | List | List page | `list-page` (`list-page-dense` / `list-page-rail` craft only) | `layout-app-shell`, `page-header-actions`, **`list-filters-stack`** (dense → `list-filters-dense`), `list-table`, `form-in-dialog`, `confirm-delete` |
10
10
  | Form (long / dedicated) | Form page | `form-page` | `page-content-form`, `form-header`, `form-body`, `form-actions` |
11
11
  | List create/edit dialog | List create/edit dialog | `form-in-dialog` | **`form-in-dialog`** (preferred) |
12
- | Detail | Detail page | `detail-page` | `detail-toolbar`, `form-in-dialog` |
12
+ | Detail | Detail page | `detail-page` | `detail-toolbar`, `detail-descriptions`, `form-in-dialog` |
13
13
  | Dashboard | Dashboard | `dashboard-page` | `dashboard-kpi-grid`, `dashboard-chart-card`, `dashboard-recent-table` |
14
14
  | Login | — (see [surfaces.md](surfaces.md)) | `login-page` | **`auth-split-shell`** |
15
15
  | Landing | — (see [surfaces.md](surfaces.md)) | `landing-page` | — |
@@ -74,8 +74,10 @@ Craft: [visual-craft.md](visual-craft.md) § Ops polish.
74
74
 
75
75
  1. `MLayout fillViewport` → `MLayoutHeader` → `MBreadcrumb`
76
76
  2. `MPageContent density="spacious"` → `MPageHeader` (title + short domain description when useful)
77
- 3. KPI row: `MGrid` + `MPageStat` (4 columns or responsive) — `dashboard-kpi-grid`
78
- 4. Main split: `MCard shadow="always"` + `MEmpty` (chart pending) and/or recent `MTable` — `dashboard-chart-card` / `dashboard-recent-table`
77
+ 3. Optional `MAlert` for ops tip / degraded dependency (dismissible when appropriate)
78
+ 4. KPI row: `MGrid` + **`MPageStat`** (trend + icon) — `dashboard-kpi-grid`
79
+ 5. Main split: `MCard shadow="always"` + **`MEmpty illustration`** (chart pending) and/or recent `MTable` / `MList` — `dashboard-chart-card` / `dashboard-recent-table`
80
+ 6. Plain numeric callouts inside a card (no trend UI) → `MStatistic`, not a second custom KPI CSS
79
81
 
80
82
  Craft: spacious density + Ops polish; do not turn the first viewport into a marketing hero.
81
83
 
@@ -89,7 +91,7 @@ Craft: spacious density + Ops polish; do not turn the first viewport into a mark
89
91
  | Spacing | `MSpace` / `MFlex` for peers; page gap from `MPageContent` | Nested padded divs stacking gaps |
90
92
  | Scroll | Full-viewport main lists may use table-body scroll via `fill`; otherwise layout / local `MScrollbar` | Forcing overflow on every content slot; stacked page + table scrollbars without reason |
91
93
  | Color | `--m-*` | Page-level hex / rgb |
92
- | Feedback | One-line → `message`; danger → confirm dialog | Toast for a single short string |
94
+ | Feedback | One-line → `message`; danger → confirm dialog; sticky page tip → `MAlert` | Toast for a single short string |
93
95
  | A11y | Labels + icon `aria-label` | Unlabeled icon controls |
94
96
 
95
97
  Inline style is acceptable for control widths (e.g. filter `width: 14rem`).
@@ -98,9 +100,10 @@ Inline style is acceptable for control widths (e.g. filter `width: 14rem`).
98
100
 
99
101
  1. Same admin chrome as list (breadcrumb → `MPageContent`)
100
102
  2. `MPageHeader` — title, `MStatus` in `#actions`, primary/secondary/danger → `detail-toolbar`
101
- 3. Summary `MPageSection` + property `MCard` (definition grid with `--m-*` only)
102
- 4. Related data: `MCard` + `MTable` / tabs / timeline
103
- 5. Short edit → `form-in-dialog`; long edit → form page / `form-in-drawer`
103
+ 3. Summary chips optional (`MTag`) — keep quiet
104
+ 4. **Attributes** → `MDescriptions` / `MDescriptionsItem` (bordered when comparing many fields) — `detail-descriptions`
105
+ 5. Related activity: prefer `MList` + `MListItemMeta` for narrative feeds; `MTable` when columns matter
106
+ 6. Short edit → `form-in-dialog`; long edit → form page / `form-in-drawer`
104
107
 
105
108
  ## List create/edit dialog — block order
106
109
 
@@ -9,7 +9,7 @@ Pick a lane, then a surface. Prefer project golden pages for **Ops**; use the re
9
9
  | List | Scan, filter, act on many rows | Golden list + [page-layouts.md](page-layouts.md); **create/edit with ≤~8 fields → `MDialog` on the list** |
10
10
  | Form | Create / edit one entity as a **dedicated page** | Golden form — only when multi-section, many fields, or user asked for an independent form route |
11
11
  | Dashboard | Orient + jump to work | Golden dashboard |
12
- | Detail | Read-heavy record + secondary actions | `MPageHeader` + sections via `MCard`/`MPageSection`; edit via `MDialog` / `MDrawer` (prefer over a second form route) |
12
+ | Detail | Read-heavy record + secondary actions | `MPageHeader` + **`MDescriptions`** for attributes; related feed via `MList` or `MTable`; edit via `MDialog` / `MDrawer` |
13
13
  | Settings | Grouped preferences | Narrow `MPageContent` + stacked `MPageSection` / `MTabs` + `MForm` |
14
14
  | Hybrid list | List + drawer/dialog | **Default Ops CRUD pattern**: list golden + `MDialog`/`MDrawer` form |
15
15
 
@@ -17,7 +17,7 @@ Pick a lane, then a surface. Prefer project golden pages for **Ops**; use the re
17
17
 
18
18
  | Surface | Job | Recipe |
19
19
  | --- | --- | --- |
20
- | Login / register | Authenticate with trust | Golden: MCP `get_golden_page` `login-page` — split brand + `MForm` (`MInput`, `MInputPassword`); form-level errors via token `role="alert"` (not Toast). Brand wash: [visual-craft.md](visual-craft.md) § Atmosphere |
20
+ | Login / register | Authenticate with trust | Golden: MCP `get_golden_page` `login-page` — split brand + `MForm`; prefer `MConfigProvider input-variant="filled"`; optional `themeConfig` for brand seed; failures via `role="alert"` / `errorMessage` (not Toast). Brand wash: [visual-craft.md](visual-craft.md) § Shell recipes |
21
21
  | Invite / accept | Join org | Same shell; show org name clearly |
22
22
  | Forgot / reset | Recover access | Short form + success state with next step |
23
23
  | Profile / account | Edit self | Settings-like sections; avatar via `MAvatar` |
@@ -28,8 +28,8 @@ Keep credential forms quiet: one primary CTA, clear labels, no decorative noise
28
28
 
29
29
  | Surface | Job | Recipe |
30
30
  | --- | --- | --- |
31
- | Empty state | Invite first action | Golden: MCP `get_golden_page` `empty-state` — `MEmpty` + primary/secondary `MButton` in `#extra`; dashed shell + radial wash from visual-craft |
32
- | Onboarding | Teach the product path | `MStepper` or paced cards; one decision per step; finish → Ops home |
31
+ | Empty state | Invite first action | Golden: MCP `get_golden_page` `empty-state` — `MEmpty` + **`illustration`** (`no-content` / `no-result` / …) + primary/secondary in `#extra` |
32
+ | Onboarding | Teach the product path | `MStepper` or paced cards; optional `MTour` for in-app spotlight; one decision per step; finish → Ops home |
33
33
  | Wizard | Multi-step create | `MStepper` + `MForm` per step + sticky actions (`上一步` / `下一步` / `提交`) |
34
34
  | Success / result | Confirm completion | `MResult status="success"` / `error` + `#footer` next actions (`查看详情` / `返回列表`); same shell idea as `result-page` |
35
35
 
@@ -51,7 +51,7 @@ Use when the brief is landing, pricing, launch, or docs marketing — **not** fo
51
51
 
52
52
  | Surface | Job | Recipe |
53
53
  | --- | --- | --- |
54
- | Landing | Convert / explain product | Golden: MCP `get_golden_page` `landing-page` — one-job hero; CTAs → `MButton`; chips → `MTag`; FAQ → `MAccordion` |
54
+ | Landing | Convert / explain product | Golden: MCP `get_golden_page` `landing-page` — one-job hero; CTAs → `MButton`; chips → `MTag`; FAQ → `MAccordion`; prefer `MTypography` / `MTitle` / `MParagraph` for hierarchy when available |
55
55
  | Pricing | Choose a plan | Clear plan cards (`MCard`) + primary CTA; highlight recommended plan without clutter |
56
56
  | Feature showcase | Prove capability | Alternating media/copy; live `M*` demo only if lightweight |
57
57
  | Docs marketing chrome | Frame documentation | Header + nav using `M*` where suitable; content area stays readable |
@@ -48,13 +48,32 @@ If the plan looks like any-SaaS (purple wash, cream serif, neon glow) and the us
48
48
  3. **Status** → `MStatus`; `MTag` for categories/filters only.
49
49
  4. **Row actions** — text / outlined / small.
50
50
  5. **Filter rhythm** — search ~`14rem`, selects ~`10–12rem`.
51
- 6. **Empty** — `MEmpty` with next action.
51
+ 6. **Empty** — `MEmpty` with domain next-step copy + **one** primary in `#extra`. Prefer `illustration` (`no-content` / `no-result` / `no-message` / `network-error` / …) over a generic icon when the empty is a main region.
52
52
  7. **No extra cards** around filters/table “for polish”.
53
53
  8. **Domain copy**.
54
54
  9. **Density** — follow user cues (`compact` / spacious); don’t invent a second scale.
55
55
  10. **Quiet chrome** — no marketing heroes / glow unless the user asked.
56
+ 11. **Page alerts** — persistent in-page notices → `MAlert` (not Toast, not `<MMessage>` as markup).
57
+ 12. **Detail fields** — attribute grids → `MDescriptions` / `MDescriptionsItem` (not hand-rolled `<dl>`).
58
+ 13. **Plain metrics** — numbers without trend chrome → `MStatistic` inside a section/card; KPI strips with trend → keep `MPageStat`.
59
+ 14. **Activity / feed rows** — same-shape items without dense columns → `MList` + `MListItem` / `MListItemMeta`; tabular compare → `MTable`.
56
60
 
57
- Ops beauty = alignment + semantics + restraint, **not** gradients.
61
+ Ops beauty = alignment + semantics + restraint + the right `M*` for the job, **not** gradients.
62
+
63
+ ## Component craft map (post-library upgrades)
64
+
65
+ | Need | Prefer | Avoid |
66
+ | --- | --- | --- |
67
+ | Admin KPI with trend | `MPageStat` (`dashboard-kpi-grid`) | Custom stat CSS |
68
+ | Inline / detail number | `MStatistic` | Fake big numbers with raw `<p>` |
69
+ | Detail attribute grid | `MDescriptions` (`detail-descriptions`) | Hand-written `<dl>` / nested tables |
70
+ | Lightweight feed | `MList` + Meta | Forcing a full `MTable` for 3–5 narrative rows |
71
+ | Empty main region | `MEmpty` + `illustration` | “暂无数据” + bare icon only |
72
+ | In-page notice | `MAlert` | Toast for sticky page tips |
73
+ | Auth / form inputs | `MConfigProvider input-variant="filled"` (or filled controls) | Mixing outlined/filled without reason |
74
+ | Brand seed | `themeConfig` / project theme CSS | Page-level hex |
75
+ | Express headings | `MTypography` / `MTitle` / `MParagraph` where it helps hierarchy | Second font kit |
76
+ | First-run teach | `MTour` after empty/onboarding | Modal essay walls |
58
77
 
59
78
  ## Anti-default looks
60
79
 
@@ -30,4 +30,4 @@ Editor-neutral always-on guidance for AI agents (Cursor, VS Code, Zed, Copilot,
30
30
  | Component pick | `recommend_component`, `get_component`, `get_example` |
31
31
  | Offline recipes | skill `references/` under `morya-ui-pages` |
32
32
 
33
- Setup writes editor MCP configs (Cursor / VS Code / Zed) and root `.mcp.json`. Reload MCP after `npx @morya-ui/setup ai`.
33
+ Canonical skills live under `.agents/skills/` (Cursor, Zed, VS Code Copilot). Setup also mirrors them into `.claude/skills`, `.windsurf/skills`, and `.github/skills` for Claude Code / Windsurf / Copilot discovery. MCP configs: Cursor / VS Code / Zed + root `.mcp.json`. Reload MCP after `npx @morya-ui/setup ai`. Zed may require trusting the worktree before project skills load.
@@ -18,6 +18,12 @@ const HSL = /\bhsl\s*\(/g
18
18
  /** Allow transparent, currentColor, inherit in CSS values */
19
19
  const ALLOW_LINE = /var\s*\(\s*--m-|color-mix\s*\(|transparent|currentColor|inherit|none/
20
20
 
21
+ /** Theme seed / createTheme brand inputs must be concrete colors. */
22
+ const ALLOW_THEME_SEED = /\bcolorPrimary\s*:|\bseed\s*:\s*\{[^}]*colorPrimary/
23
+
24
+ /** Canvas / encoder fallbacks that must stay concrete for off-DOM drawing. */
25
+ const ALLOW_CANVAS_FALLBACK = /rgb\(\s*0\s*,\s*0\s*,\s*0\s*\)|rgb\(\s*255\s*,\s*255\s*,\s*255\s*\)/
26
+
21
27
  /** Skip demo IDs like '#1024' or 'WO-1024' in script/template strings */
22
28
  const DEMO_ID = /['"]#?[A-Z0-9-]{2,}['"]/
23
29
 
@@ -28,7 +34,7 @@ function walk(dir) {
28
34
  const full = path.join(dir, name)
29
35
  const st = statSync(full)
30
36
  if (st.isDirectory()) {
31
- if (IGNORE_DIRS.has(name)) continue
37
+ if (IGNORE_DIRS.has(name) || name === 'demos') continue
32
38
  walk(full)
33
39
  continue
34
40
  }
@@ -36,12 +42,15 @@ function walk(dir) {
36
42
  if (!EXT.has(ext)) continue
37
43
  const normalized = full.replace(/\\/g, '/')
38
44
  if (normalized.includes('design-tokens/') || normalized.includes('/theme/')) continue
45
+ if (normalized.includes('/docs/demos/')) continue
39
46
  if (normalized.includes('.test.') || normalized.includes('/__tests__/')) continue
40
47
 
41
48
  const text = readFileSync(full, 'utf8')
42
49
  const lines = text.split(/\r?\n/)
43
50
  lines.forEach((line, index) => {
44
51
  if (ALLOW_LINE.test(line)) return
52
+ if (ALLOW_THEME_SEED.test(line)) return
53
+ if (ALLOW_CANVAS_FALLBACK.test(line)) return
45
54
  if (DEMO_ID.test(line)) return
46
55
  if (HEX.test(line) || RGB.test(line) || HSL.test(line)) {
47
56
  HEX.lastIndex = 0