@morya-ui/setup 0.3.7 → 0.3.8

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.3.8",
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
  /**
@@ -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.