skiller 0.7.18 → 0.7.20

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
@@ -53,27 +53,29 @@ A Claude-centric fork of [ruler](https://github.com/intellectronica/ruler) with
53
53
  - Shared paths are deduplicated (Claude/Copilot/Kilo share `.claude/skills`, Goose/Amp share `.agents/skills`)
54
54
  - Agent skills directories are auto-added to `.gitignore` (excluding `.claude/skills`)
55
55
  - Validates skill structure — warns on missing `SKILL.md`
56
+ - Flattens nested skills into dash-separated names for agent skills dirs (e.g., `workflows/lfg` → `workflows-lfg`)
56
57
 
57
58
  ## 9. Claude Code Plugins → Skills
58
59
 
59
60
  - Reads `.claude/settings.json` `enabledPlugins`
60
61
  - Reads plugin content from `~/.claude/plugins/marketplaces` (never from `cache/`)
61
- - Syncs enabled plugin `skills/` into agent skills directories on `skiller apply`
62
- - Syncs enabled plugin `commands/*.md` as skills (`SKILL.md`) into agent skills directories
62
+ - Syncs enabled plugin `skills/` into agent skills directories on `skiller apply` (recursive, flattened names)
63
+ - Syncs enabled plugin `commands/**/*.md` as skills (`SKILL.md`) into agent skills directories (recursive, flattened names)
63
64
  - Syncs enabled plugin `agents/**/*.md` as skills (`SKILL.md`) into agent skills directories
64
65
  - Uses the skill/command/agent name by default (matches existing Codex skill names)
65
- - If a name conflicts, local skills win and the plugin skill is namespaced as `<pluginId>-<name>`
66
- - Tracks plugin-managed skills in a single `.skiller.json` file per agent skills directory
66
+ - If a name conflicts, local skills win and the plugin item is namespaced as `<pluginName>-<name>` (numeric suffix if multiple enabled plugins share the same name)
67
+ - Tracks plugin-managed items in `.claude/.skiller.json` (per project, grouped by agent skills dir)
67
68
  - Removes stale plugin skills when plugins are disabled
68
69
 
69
70
  ## 10. Claude Commands/Agents → Skills
70
71
 
71
72
  - Syncs `.claude/commands/**/*.md` as skills (`SKILL.md`) into agent skills directories
73
+ - Flattens nested commands into dash-separated names (e.g., `workflows/brainstorm.md` → `workflows-brainstorm`)
72
74
  - Syncs `.claude/agents/**/*.md` as skills (`SKILL.md`) into agent skills directories
73
75
  - Uses the command/agent name by default
74
76
  - If a name conflicts, existing local/manual skills win and the project item is namespaced as `claude-<name>`
75
77
  - Project items win over plugin skills/commands/agents on name conflicts
76
- - Tracks project-managed items in a single `.skiller.json` file per agent skills directory
78
+ - Tracks project-managed items in `.claude/.skiller.json` (per project, grouped by agent skills dir)
77
79
 
78
80
  ---
79
81
 
@@ -605,10 +607,11 @@ Shared paths are deduplicated — agents sharing the same directory only trigger
605
607
  If your project enables Claude Code plugins in `.claude/settings.json`, Skiller also syncs plugin content into agent skills directories on `skiller apply`:
606
608
 
607
609
  - Plugin `skills/` are copied as skills
608
- - Plugin `commands/*.md` are converted into skills (`SKILL.md`)
610
+ - Plugin `commands/**/*.md` are converted into skills (`SKILL.md`)
611
+ - Plugin `agents/**/*.md` are converted into skills (`SKILL.md`)
609
612
  - Plugin skills use their original skill/command name by default
610
- - If a name conflicts, local skills win and the plugin skill is namespaced as `<pluginId>-<name>`
611
- - Plugin-managed skills are tracked via `.skiller.json` in each agent skills directory
613
+ - If a name conflicts, local skills win and the plugin item is namespaced as `<pluginName>-<name>` (numeric suffix if multiple enabled plugins share the same name)
614
+ - Plugin-managed items are tracked via `.claude/.skiller.json` (per project, grouped by agent skills dir)
612
615
 
613
616
  ### Skills Directory Structure
614
617
 
@@ -126,36 +126,11 @@ function parsePluginId(pluginId) {
126
126
  marketplaceId: pluginId.slice(at + 1),
127
127
  };
128
128
  }
129
- async function readKnownMarketplaceInstallLocations(claudeDir) {
130
- const knownPath = path.join(claudeDir, 'plugins', 'known_marketplaces.json');
131
- let raw;
132
- try {
133
- raw = JSON.parse(await fs.readFile(knownPath, 'utf8'));
134
- }
135
- catch {
136
- return {};
137
- }
138
- if (!raw || typeof raw !== 'object')
139
- return {};
140
- const obj = raw;
141
- const out = {};
142
- for (const [marketplaceId, marketplace] of Object.entries(obj)) {
143
- if (!marketplace || typeof marketplace !== 'object')
144
- continue;
145
- const installLocation = marketplace
146
- .installLocation;
147
- if (typeof installLocation !== 'string' || installLocation.trim() === '')
148
- continue;
149
- out[marketplaceId] = installLocation;
150
- }
151
- return out;
152
- }
153
- async function resolvePluginMarketplaceRoot(pluginId, claudeDir, knownMarketplaceInstallLocations) {
129
+ async function resolvePluginMarketplaceRoot(pluginId, claudeDir) {
154
130
  const parsed = parsePluginId(pluginId);
155
131
  if (!parsed)
156
132
  return null;
157
- const marketplaceRoot = knownMarketplaceInstallLocations[parsed.marketplaceId] ??
158
- path.join(claudeDir, 'plugins', 'marketplaces', parsed.marketplaceId);
133
+ const marketplaceRoot = path.join(claudeDir, 'plugins', 'marketplaces', parsed.marketplaceId);
159
134
  const candidates = [
160
135
  path.join(marketplaceRoot, 'plugins', parsed.pluginName),
161
136
  path.join(marketplaceRoot, 'external_plugins', parsed.pluginName),
@@ -266,19 +241,31 @@ async function discoverPluginCommandFiles(installPath) {
266
241
  const commandsRoot = path.join(installPath, 'commands');
267
242
  if (!(await fileExists(commandsRoot)))
268
243
  return [];
269
- let entries;
270
- try {
271
- entries = await fs.readdir(commandsRoot, { withFileTypes: true });
272
- }
273
- catch {
274
- return [];
244
+ const results = [];
245
+ async function walk(current, depth) {
246
+ if (depth >= constants_1.MAX_RECURSION_DEPTH)
247
+ return;
248
+ let entries;
249
+ try {
250
+ entries = await fs.readdir(current, { withFileTypes: true });
251
+ }
252
+ catch {
253
+ return;
254
+ }
255
+ for (const entry of entries) {
256
+ const full = path.join(current, entry.name);
257
+ if (entry.isDirectory()) {
258
+ await walk(full, depth + 1);
259
+ continue;
260
+ }
261
+ if (!entry.isFile() || !entry.name.endsWith('.md'))
262
+ continue;
263
+ const rel = path.relative(commandsRoot, full).replace(/\\/g, '/');
264
+ results.push({ rel, file: full });
265
+ }
275
266
  }
276
- return entries
277
- .filter((e) => e.isFile() && e.name.endsWith('.md'))
278
- .map((e) => ({
279
- name: path.basename(e.name, '.md'),
280
- file: path.join(commandsRoot, e.name),
281
- }));
267
+ await walk(commandsRoot, 0);
268
+ return results;
282
269
  }
283
270
  async function discoverPluginAgentFiles(installPath) {
284
271
  const agentsRoot = path.join(installPath, 'agents');
@@ -326,13 +313,44 @@ async function discoverPluginAgentFiles(installPath) {
326
313
  function generateBaseNameFromRelId(relId) {
327
314
  const normalized = relId.replace(/\\/g, '/');
328
315
  const segments = normalized.split('/').filter(Boolean);
329
- return segments.map(sanitizeId).join('__');
316
+ return segments.map(sanitizeId).join('-');
317
+ }
318
+ function generateBaseNameFromCommandRelPath(commandRelPath) {
319
+ const normalized = commandRelPath.replace(/\\/g, '/');
320
+ const withoutExt = normalized.endsWith('.md')
321
+ ? normalized.slice(0, -'.md'.length)
322
+ : normalized;
323
+ const segments = withoutExt.split('/').filter(Boolean);
324
+ return segments.map(sanitizeId).join('-');
330
325
  }
331
- function generateBaseNameFromCommand(commandName) {
332
- return sanitizeId(commandName);
326
+ function pluginBaseNamespacePrefix(pluginId) {
327
+ const parsed = parsePluginId(pluginId);
328
+ if (parsed)
329
+ return sanitizeId(parsed.pluginName);
330
+ return sanitizeId(pluginId);
333
331
  }
334
- function generateNamespacedName(pluginId, baseName) {
335
- return `${sanitizeId(pluginId)}-${baseName}`;
332
+ function computePluginNamespacePrefixes(pluginIds) {
333
+ const out = new Map();
334
+ const pluginIdsByBase = new Map();
335
+ for (const pluginId of pluginIds) {
336
+ const base = pluginBaseNamespacePrefix(pluginId);
337
+ const bucket = pluginIdsByBase.get(base) ?? [];
338
+ bucket.push(pluginId);
339
+ pluginIdsByBase.set(base, bucket);
340
+ }
341
+ for (const [base, ids] of pluginIdsByBase.entries()) {
342
+ ids.sort((a, b) => a.localeCompare(b));
343
+ if (ids.length === 1) {
344
+ out.set(ids[0], base);
345
+ continue;
346
+ }
347
+ // If multiple enabled plugins share the same plugin name, disambiguate
348
+ // without including the marketplace in the folder name.
349
+ ids.forEach((id, idx) => {
350
+ out.set(id, idx === 0 ? base : `${base}-${idx + 1}`);
351
+ });
352
+ }
353
+ return out;
336
354
  }
337
355
  async function readLegacyMarkerFile(dir) {
338
356
  const markerPath = path.join(dir, LEGACY_MARKER_FILENAME);
@@ -375,8 +393,8 @@ async function removeLegacyMarkerFile(dir, dryRun) {
375
393
  // ignore
376
394
  }
377
395
  }
378
- async function loadManagedEntries(targetSkillsDir, dryRun) {
379
- const allEntries = await (0, SkillsManifest_1.loadSkillsManifestEntries)(targetSkillsDir);
396
+ async function loadManagedEntries(projectRoot, targetSkillsDir, dryRun) {
397
+ const allEntries = await (0, SkillsManifest_1.loadSkillsManifestEntries)(projectRoot, targetSkillsDir);
380
398
  const pluginEntries = [];
381
399
  const otherEntries = [];
382
400
  for (const entry of allEntries) {
@@ -439,7 +457,14 @@ async function discoverLocalSkillNames(projectRoot) {
439
457
  }
440
458
  const hasSkillMd = entries.some((e) => e.isFile() && e.name === 'SKILL.md');
441
459
  if (hasSkillMd) {
442
- names.add(path.basename(current));
460
+ const rel = path.relative(localSkillsDir, current).replace(/\\/g, '/');
461
+ const segments = rel.split('/').filter(Boolean);
462
+ if (segments.length > 0) {
463
+ names.add(segments.map(sanitizeId).join('-'));
464
+ }
465
+ else {
466
+ names.add(sanitizeId(path.basename(current)));
467
+ }
443
468
  return;
444
469
  }
445
470
  for (const entry of entries) {
@@ -474,7 +499,12 @@ async function discoverLocalCommandNames(projectRoot) {
474
499
  }
475
500
  if (!entry.isFile() || !entry.name.endsWith('.md'))
476
501
  continue;
477
- names.add(sanitizeId(path.basename(entry.name, '.md')));
502
+ const rel = path.relative(localCommandsDir, full).replace(/\\/g, '/');
503
+ const withoutExt = rel.endsWith('.md')
504
+ ? rel.slice(0, -'.md'.length)
505
+ : rel;
506
+ const segments = withoutExt.split('/').filter(Boolean);
507
+ names.add(segments.map(sanitizeId).join('-'));
478
508
  }
479
509
  }
480
510
  await walk(localCommandsDir, 0);
@@ -581,7 +611,6 @@ async function syncClaudePluginsToSkillsDirs(args) {
581
611
  }
582
612
  const claudeDir = path.join(getUserHomeDir(), '.claude');
583
613
  const index = await readInstalledPluginsIndex(claudeDir);
584
- const knownMarketplaceInstallLocations = await readKnownMarketplaceInstallLocations(claudeDir);
585
614
  const localSkillNames = await discoverLocalSkillNames(projectRoot);
586
615
  const localCommandNames = await discoverLocalCommandNames(projectRoot);
587
616
  const localAgentNames = await discoverLocalAgentNames(projectRoot);
@@ -593,10 +622,16 @@ async function syncClaudePluginsToSkillsDirs(args) {
593
622
  const resolvedSources = [];
594
623
  const unresolvedEnabled = new Set();
595
624
  for (const pluginId of enabledPlugins) {
596
- const pluginRoot = await resolvePluginMarketplaceRoot(pluginId, claudeDir, knownMarketplaceInstallLocations);
625
+ const pluginRoot = await resolvePluginMarketplaceRoot(pluginId, claudeDir);
597
626
  if (!pluginRoot) {
598
627
  unresolvedEnabled.add(pluginId);
599
- (0, constants_1.logWarn)(`[plugins] Enabled plugin not installed: ${pluginId}`, dryRun);
628
+ const hasIndexEntry = Boolean(index?.plugins?.[pluginId]?.length);
629
+ if (hasIndexEntry) {
630
+ (0, constants_1.logVerboseInfo)(`[plugins] Enabled plugin has no marketplace content, skipping: ${pluginId}`, verbose, dryRun);
631
+ }
632
+ else {
633
+ (0, constants_1.logWarn)(`[plugins] Enabled plugin not installed: ${pluginId}`, dryRun);
634
+ }
600
635
  continue;
601
636
  }
602
637
  const resolved = index
@@ -626,8 +661,8 @@ async function syncClaudePluginsToSkillsDirs(args) {
626
661
  }
627
662
  const commandFiles = await discoverPluginCommandFiles(plugin.pluginRoot);
628
663
  for (const c of commandFiles) {
629
- const baseName = generateBaseNameFromCommand(c.name);
630
- const sourceRelPath = `commands/${path.basename(c.file)}`;
664
+ const baseName = generateBaseNameFromCommandRelPath(c.rel);
665
+ const sourceRelPath = `commands/${c.rel}`;
631
666
  expectedItems.push({
632
667
  itemKey: makeItemKey(plugin.pluginId, 'command', sourceRelPath),
633
668
  pluginId: plugin.pluginId,
@@ -658,6 +693,9 @@ async function syncClaudePluginsToSkillsDirs(args) {
658
693
  const bk = `${b.baseName}::${b.pluginId}::${b.kind}::${b.sourceRelPath}`;
659
694
  return ak.localeCompare(bk);
660
695
  });
696
+ const pluginNamespacePrefixByPluginId = computePluginNamespacePrefixes([
697
+ ...new Set(resolvedSources.map((p) => p.pluginId)),
698
+ ]);
661
699
  // Sync into each target skills dir.
662
700
  for (const targetSkillsDir of targetSkillsDirs) {
663
701
  const targetExists = await fileExists(targetSkillsDir);
@@ -666,7 +704,7 @@ async function syncClaudePluginsToSkillsDirs(args) {
666
704
  let managedEntries = [];
667
705
  let otherEntries = [];
668
706
  if (targetExists) {
669
- const loaded = await loadManagedEntries(targetSkillsDir, dryRun);
707
+ const loaded = await loadManagedEntries(projectRoot, targetSkillsDir, dryRun);
670
708
  managedEntries = loaded.pluginEntries;
671
709
  otherEntries = loaded.otherEntries;
672
710
  }
@@ -702,11 +740,17 @@ async function syncClaudePluginsToSkillsDirs(args) {
702
740
  const prev = prevDestByItemKey.get(item.itemKey);
703
741
  if (!prev)
704
742
  continue;
705
- // Migration: previous versions used `${pluginId}__${name}`.
706
- // Don't preserve legacy namespaced destinations so we can rename to the
707
- // new `${pluginId}-${name}` format.
743
+ const desiredPrefix = pluginNamespacePrefixByPluginId.get(item.pluginId) ??
744
+ sanitizeId(item.pluginId);
745
+ // Migration: previous versions used `${pluginId}__${name}`, then
746
+ // `${pluginId}-${name}`. If we changed the namespace prefix (for example
747
+ // to omit marketplace), don't preserve the old destination so the item
748
+ // can be renamed.
708
749
  if (prev.startsWith(`${sanitizeId(item.pluginId)}__`))
709
750
  continue;
751
+ if (prev.startsWith(`${sanitizeId(item.pluginId)}-`) &&
752
+ desiredPrefix !== sanitizeId(item.pluginId))
753
+ continue;
710
754
  if (taken.has(prev))
711
755
  continue;
712
756
  assignedDestByItemKey.set(item.itemKey, prev);
@@ -722,7 +766,7 @@ async function syncClaudePluginsToSkillsDirs(args) {
722
766
  taken.add(base);
723
767
  continue;
724
768
  }
725
- const namespacedBase = generateNamespacedName(item.pluginId, base);
769
+ const namespacedBase = `${pluginNamespacePrefixByPluginId.get(item.pluginId) ?? sanitizeId(item.pluginId)}-${base}`;
726
770
  let candidate = namespacedBase;
727
771
  let i = 2;
728
772
  while (taken.has(candidate)) {
@@ -823,6 +867,6 @@ async function syncClaudePluginsToSkillsDirs(args) {
823
867
  destRelPath: item.destRelPath,
824
868
  });
825
869
  }
826
- await (0, SkillsManifest_1.writeSkillsManifestEntries)(targetSkillsDir, [...otherEntries, ...nextEntries], dryRun);
870
+ await (0, SkillsManifest_1.writeSkillsManifestEntries)(projectRoot, targetSkillsDir, [...otherEntries, ...nextEntries], dryRun);
827
871
  }
828
872
  }
@@ -85,7 +85,14 @@ async function discoverCommandFiles(projectRoot) {
85
85
  }
86
86
  if (!entry.isFile() || !entry.name.endsWith('.md'))
87
87
  continue;
88
- const name = sanitizeId(path.basename(entry.name, '.md'));
88
+ const relFromCommands = path
89
+ .relative(commandsRoot, full)
90
+ .replace(/\\/g, '/');
91
+ const withoutExt = relFromCommands.endsWith('.md')
92
+ ? relFromCommands.slice(0, -'.md'.length)
93
+ : relFromCommands;
94
+ const segments = withoutExt.split('/').filter(Boolean);
95
+ const name = segments.map(sanitizeId).join('-');
89
96
  const rel = path.relative(projectRoot, full).replace(/\\/g, '/');
90
97
  results.push({ name, file: full, rel });
91
98
  }
@@ -164,9 +171,9 @@ async function writeMarkdownAsSkill(srcPath, destDir, generatedName, kindLabel,
164
171
  return;
165
172
  await fs.writeFile(path.join(destDir, 'SKILL.md'), next, 'utf8');
166
173
  }
167
- async function readPluginManagedDestNames(targetSkillsDir) {
174
+ async function readPluginManagedDestNames(projectRoot, targetSkillsDir) {
168
175
  const names = new Set();
169
- for (const entry of await (0, SkillsManifest_1.loadSkillsManifestEntries)(targetSkillsDir)) {
176
+ for (const entry of await (0, SkillsManifest_1.loadSkillsManifestEntries)(projectRoot, targetSkillsDir)) {
170
177
  if ((0, SkillsManifest_1.isPluginManifestEntry)(entry)) {
171
178
  names.add(entry.destRelPath);
172
179
  }
@@ -209,7 +216,14 @@ async function discoverLocalSkillNames(projectRoot) {
209
216
  }
210
217
  const hasSkillMd = entries.some((e) => e.isFile() && e.name === 'SKILL.md');
211
218
  if (hasSkillMd) {
212
- names.add(path.basename(current));
219
+ const rel = path.relative(localSkillsDir, current).replace(/\\/g, '/');
220
+ const segments = rel.split('/').filter(Boolean);
221
+ if (segments.length > 0) {
222
+ names.add(segments.map(sanitizeId).join('-'));
223
+ }
224
+ else {
225
+ names.add(sanitizeId(path.basename(current)));
226
+ }
213
227
  return;
214
228
  }
215
229
  for (const entry of entries) {
@@ -255,7 +269,7 @@ async function syncClaudeProjectCommandsAndAgentsToSkillsDirs(args) {
255
269
  const managedEntries = [];
256
270
  const otherEntries = [];
257
271
  if (targetExists) {
258
- const allEntries = await (0, SkillsManifest_1.loadSkillsManifestEntries)(targetSkillsDir);
272
+ const allEntries = await (0, SkillsManifest_1.loadSkillsManifestEntries)(projectRoot, targetSkillsDir);
259
273
  for (const entry of allEntries) {
260
274
  if ((0, SkillsManifest_1.isClaudeManifestEntry)(entry)) {
261
275
  managedEntries.push(entry);
@@ -271,7 +285,7 @@ async function syncClaudeProjectCommandsAndAgentsToSkillsDirs(args) {
271
285
  }
272
286
  const managedDest = new Set(managedEntries.map((e) => e.destRelPath));
273
287
  const pluginManagedDest = targetExists
274
- ? await readPluginManagedDestNames(targetSkillsDir)
288
+ ? await readPluginManagedDestNames(projectRoot, targetSkillsDir)
275
289
  : new Set();
276
290
  const reserved = new Set(localSkillNames);
277
291
  if (targetExists) {
@@ -388,6 +402,6 @@ async function syncClaudeProjectCommandsAndAgentsToSkillsDirs(args) {
388
402
  destRelPath: item.destRelPath,
389
403
  });
390
404
  }
391
- await (0, SkillsManifest_1.writeSkillsManifestEntries)(targetSkillsDir, [...otherEntries, ...nextEntries], dryRun);
405
+ await (0, SkillsManifest_1.writeSkillsManifestEntries)(projectRoot, targetSkillsDir, [...otherEntries, ...nextEntries], dryRun);
392
406
  }
393
407
  }
@@ -41,6 +41,8 @@ exports.writeSkillsManifestEntries = writeSkillsManifestEntries;
41
41
  exports.listSkillDirectories = listSkillDirectories;
42
42
  const fs = __importStar(require("fs/promises"));
43
43
  const path = __importStar(require("path"));
44
+ // Project-level manifest (stored in `.claude/.skiller.json`) that tracks what
45
+ // Skiller installed into each target agent skills directory for this project.
44
46
  exports.SKILLS_MANIFEST_FILENAME = '.skiller.json';
45
47
  exports.LEGACY_UNIFIED_SKILLS_MANIFEST_FILENAME = '.skiller-skills.json';
46
48
  exports.LEGACY_PLUGIN_MANIFEST_FILENAME = '.skiller-plugins.json';
@@ -137,6 +139,37 @@ function parseUnifiedEntries(raw) {
137
139
  }
138
140
  return entries;
139
141
  }
142
+ function normalizePathForKey(p) {
143
+ return path.resolve(p).replace(/\\/g, '/');
144
+ }
145
+ function computeTargetKey(projectRoot, targetSkillsDir) {
146
+ const resolvedProjectRoot = path.resolve(projectRoot);
147
+ const resolvedTarget = path.resolve(targetSkillsDir);
148
+ if (resolvedTarget === resolvedProjectRoot)
149
+ return '.';
150
+ if (resolvedTarget.startsWith(resolvedProjectRoot + path.sep)) {
151
+ return path
152
+ .relative(resolvedProjectRoot, resolvedTarget)
153
+ .replace(/\\/g, '/');
154
+ }
155
+ return normalizePathForKey(resolvedTarget);
156
+ }
157
+ function parseProjectTargets(raw) {
158
+ if (!raw || typeof raw !== 'object')
159
+ return {};
160
+ const obj = raw;
161
+ if (!obj.targets || typeof obj.targets !== 'object')
162
+ return {};
163
+ const targetsObj = obj.targets;
164
+ const out = {};
165
+ for (const [targetKey, rawEntries] of Object.entries(targetsObj)) {
166
+ // Stored as an array of entries per target.
167
+ if (!Array.isArray(rawEntries))
168
+ continue;
169
+ out[targetKey] = normalizeEntries(parseUnifiedEntries({ entries: rawEntries }));
170
+ }
171
+ return out;
172
+ }
140
173
  function parseLegacyPluginEntries(raw) {
141
174
  if (!raw || typeof raw !== 'object')
142
175
  return [];
@@ -197,11 +230,11 @@ function parseLegacyClaudeEntries(raw) {
197
230
  }
198
231
  return entries;
199
232
  }
200
- async function loadSkillsManifestEntries(targetSkillsDir) {
201
- const manifestPath = path.join(targetSkillsDir, exports.SKILLS_MANIFEST_FILENAME);
202
- if (await fileExists(manifestPath)) {
233
+ async function loadLegacyTargetSkillsManifestEntries(targetSkillsDir) {
234
+ const legacyManifestPath = path.join(targetSkillsDir, exports.SKILLS_MANIFEST_FILENAME);
235
+ if (await fileExists(legacyManifestPath)) {
203
236
  try {
204
- const raw = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
237
+ const raw = JSON.parse(await fs.readFile(legacyManifestPath, 'utf8'));
205
238
  return normalizeEntries(parseUnifiedEntries(raw));
206
239
  }
207
240
  catch {
@@ -243,32 +276,78 @@ async function loadSkillsManifestEntries(targetSkillsDir) {
243
276
  }
244
277
  return normalizeEntries(merged);
245
278
  }
246
- async function writeSkillsManifestEntries(targetSkillsDir, entries, dryRun) {
247
- const manifestPath = path.join(targetSkillsDir, exports.SKILLS_MANIFEST_FILENAME);
248
- const legacyUnifiedPath = path.join(targetSkillsDir, exports.LEGACY_UNIFIED_SKILLS_MANIFEST_FILENAME);
249
- const legacyPluginPath = path.join(targetSkillsDir, exports.LEGACY_PLUGIN_MANIFEST_FILENAME);
250
- const legacyClaudePath = path.join(targetSkillsDir, exports.LEGACY_CLAUDE_MANIFEST_FILENAME);
279
+ async function loadSkillsManifestEntries(projectRoot, targetSkillsDir) {
280
+ const projectClaudeDir = path.join(projectRoot, '.claude');
281
+ const projectManifestPath = path.join(projectClaudeDir, exports.SKILLS_MANIFEST_FILENAME);
282
+ const preferredTargetKey = computeTargetKey(projectRoot, targetSkillsDir);
283
+ const absoluteTargetKey = normalizePathForKey(targetSkillsDir);
284
+ if (await fileExists(projectManifestPath)) {
285
+ try {
286
+ const raw = JSON.parse(await fs.readFile(projectManifestPath, 'utf8'));
287
+ const targets = parseProjectTargets(raw);
288
+ const entries = targets[preferredTargetKey] ?? targets[absoluteTargetKey] ?? [];
289
+ return normalizeEntries(entries);
290
+ }
291
+ catch {
292
+ return [];
293
+ }
294
+ }
295
+ // Legacy migration: prior versions stored manifests in the target skills dir.
296
+ return await loadLegacyTargetSkillsManifestEntries(targetSkillsDir);
297
+ }
298
+ async function writeSkillsManifestEntries(projectRoot, targetSkillsDir, entries, dryRun) {
251
299
  const normalized = normalizeEntries(entries);
300
+ const projectClaudeDir = path.join(projectRoot, '.claude');
301
+ const projectManifestPath = path.join(projectClaudeDir, exports.SKILLS_MANIFEST_FILENAME);
302
+ const preferredTargetKey = computeTargetKey(projectRoot, targetSkillsDir);
303
+ const absoluteTargetKey = normalizePathForKey(targetSkillsDir);
304
+ let existingTargets = {};
305
+ if (await fileExists(projectManifestPath)) {
306
+ try {
307
+ const raw = JSON.parse(await fs.readFile(projectManifestPath, 'utf8'));
308
+ existingTargets = parseProjectTargets(raw);
309
+ }
310
+ catch {
311
+ existingTargets = {};
312
+ }
313
+ }
252
314
  if (normalized.length === 0) {
253
- if (dryRun)
254
- return;
255
- await Promise.allSettled([
256
- fs.unlink(manifestPath),
257
- fs.unlink(legacyUnifiedPath),
258
- fs.unlink(legacyPluginPath),
259
- fs.unlink(legacyClaudePath),
260
- ]);
261
- return;
315
+ delete existingTargets[preferredTargetKey];
316
+ if (preferredTargetKey !== absoluteTargetKey) {
317
+ delete existingTargets[absoluteTargetKey];
318
+ }
319
+ }
320
+ else {
321
+ existingTargets[preferredTargetKey] = normalized;
322
+ if (preferredTargetKey !== absoluteTargetKey) {
323
+ delete existingTargets[absoluteTargetKey];
324
+ }
262
325
  }
263
- const manifest = {
264
- version: exports.SKILLS_MANIFEST_VERSION,
265
- entries: normalized,
266
- };
267
326
  if (dryRun)
268
327
  return;
269
- await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
270
- // Clean up legacy manifests once unified is written.
328
+ // Ensure `.claude` exists since the manifest lives there.
329
+ await fs.mkdir(projectClaudeDir, { recursive: true });
330
+ const targetKeys = Object.keys(existingTargets).sort((a, b) => a.localeCompare(b));
331
+ if (targetKeys.length === 0) {
332
+ await Promise.allSettled([fs.unlink(projectManifestPath)]);
333
+ }
334
+ else {
335
+ const nextTargets = {};
336
+ for (const key of targetKeys)
337
+ nextTargets[key] = existingTargets[key];
338
+ const manifest = {
339
+ version: exports.SKILLS_MANIFEST_VERSION,
340
+ targets: nextTargets,
341
+ };
342
+ await fs.writeFile(projectManifestPath, JSON.stringify(manifest, null, 2) + '\n');
343
+ }
344
+ // Remove legacy per-target manifests so users see a single `.claude/.skiller.json`.
345
+ const legacyManifestPath = path.join(targetSkillsDir, exports.SKILLS_MANIFEST_FILENAME);
346
+ const legacyUnifiedPath = path.join(targetSkillsDir, exports.LEGACY_UNIFIED_SKILLS_MANIFEST_FILENAME);
347
+ const legacyPluginPath = path.join(targetSkillsDir, exports.LEGACY_PLUGIN_MANIFEST_FILENAME);
348
+ const legacyClaudePath = path.join(targetSkillsDir, exports.LEGACY_CLAUDE_MANIFEST_FILENAME);
271
349
  await Promise.allSettled([
350
+ fs.unlink(legacyManifestPath),
272
351
  fs.unlink(legacyUnifiedPath),
273
352
  fs.unlink(legacyPluginPath),
274
353
  fs.unlink(legacyClaudePath),
@@ -462,6 +462,32 @@ async function discoverSkills(projectRoot, skillerDir) {
462
462
  async function copySkillsToAgent(sourceSkillsDir, targetSkillsDir, projectRoot, verbose, dryRun) {
463
463
  const warnings = [];
464
464
  let copied = 0;
465
+ function sanitizeId(value) {
466
+ return value.replace(/[^A-Za-z0-9._-]+/g, '_');
467
+ }
468
+ function flattenRelativeSkillPath(relativeSkillPath) {
469
+ const normalized = relativeSkillPath.replace(/\\/g, '/');
470
+ const segments = normalized.split('/').filter(Boolean);
471
+ return segments.map(sanitizeId).join('-');
472
+ }
473
+ async function rewriteSkillMdName(skillMdPath, name) {
474
+ let content;
475
+ try {
476
+ content = await fs.readFile(skillMdPath, 'utf8');
477
+ }
478
+ catch {
479
+ return;
480
+ }
481
+ const { rawFrontmatter, body } = (0, FrontmatterParser_1.parseFrontmatter)(content);
482
+ const fm = rawFrontmatter
483
+ ? { ...rawFrontmatter }
484
+ : {};
485
+ fm.name = name;
486
+ const next = `---\n${yaml
487
+ .dump(fm, { lineWidth: -1, noRefs: true })
488
+ .trim()}\n---\n\n${body}\n`;
489
+ await fs.writeFile(skillMdPath, next, 'utf8');
490
+ }
465
491
  try {
466
492
  await fs.access(sourceSkillsDir);
467
493
  }
@@ -471,8 +497,15 @@ async function copySkillsToAgent(sourceSkillsDir, targetSkillsDir, projectRoot,
471
497
  }
472
498
  // Use walkSkillsTree to discover skills
473
499
  const skillsTree = await (0, SkillsUtils_1.walkSkillsTree)(sourceSkillsDir);
500
+ // Deterministic order so name collision suffixing is stable.
501
+ const sortedSkills = [...skillsTree.skills].sort((a, b) => {
502
+ const ar = path.relative(sourceSkillsDir, a.path).replace(/\\/g, '/');
503
+ const br = path.relative(sourceSkillsDir, b.path).replace(/\\/g, '/');
504
+ return ar.localeCompare(br);
505
+ });
506
+ const taken = new Set();
474
507
  // Validate and copy each skill
475
- for (const skill of skillsTree.skills) {
508
+ for (const skill of sortedSkills) {
476
509
  // skill.path is absolute, use it directly
477
510
  const skillPath = skill.path;
478
511
  const skillMdPath = path.join(skillPath, constants_1.SKILL_MD_FILENAME);
@@ -484,11 +517,23 @@ async function copySkillsToAgent(sourceSkillsDir, targetSkillsDir, projectRoot,
484
517
  warnings.push(`Skill '${skill.name}' missing required SKILL.md file, skipping`);
485
518
  continue;
486
519
  }
487
- // Copy skill directory to target using relative path
520
+ // Flatten nested skills into root-level skill folders for other agents:
521
+ // `category/foo` -> `category-foo`
488
522
  const relativeSkillPath = path.relative(sourceSkillsDir, skill.path);
489
- const targetSkillPath = path.join(targetSkillsDir, relativeSkillPath);
523
+ const baseDestName = flattenRelativeSkillPath(relativeSkillPath);
524
+ let destName = baseDestName;
525
+ let i = 2;
526
+ while (taken.has(destName)) {
527
+ destName = `${baseDestName}-${i++}`;
528
+ }
529
+ taken.add(destName);
530
+ const targetSkillPath = path.join(targetSkillsDir, destName);
490
531
  if (!dryRun) {
491
532
  await copySkillDirectoryForNonClaudeAgents(skillPath, targetSkillPath, projectRoot, skillPath);
533
+ const sourceLeafName = path.basename(skillPath);
534
+ if (destName !== sourceLeafName) {
535
+ await rewriteSkillMdName(path.join(targetSkillPath, constants_1.SKILL_MD_FILENAME), destName);
536
+ }
492
537
  }
493
538
  (0, constants_1.logVerboseInfo)(dryRun
494
539
  ? `DRY RUN: Would copy skill '${skill.name}' to ${targetSkillsDir}`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skiller",
3
- "version": "0.7.18",
3
+ "version": "0.7.20",
4
4
  "description": "Skiller — apply the same rules to all coding agents",
5
5
  "main": "dist/lib.js",
6
6
  "publishConfig": {