skiller 0.7.17 → 0.7.19

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,15 +53,17 @@ 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
- - Syncs enabled plugin `skills/` into agent skills directories on `skiller apply`
61
+ - Reads plugin content from `~/.claude/plugins/marketplaces` (never from `cache/`)
62
+ - Syncs enabled plugin `skills/` into agent skills directories on `skiller apply` (recursive, flattened names)
61
63
  - Syncs enabled plugin `commands/*.md` as skills (`SKILL.md`) into agent skills directories
62
64
  - Syncs enabled plugin `agents/**/*.md` as skills (`SKILL.md`) into agent skills directories
63
65
  - Uses the skill/command/agent name by default (matches existing Codex skill names)
64
- - If a name conflicts, local skills win and the plugin skill is namespaced as `<pluginId>-<name>`
66
+ - If a name conflicts, local skills win and the plugin skill is namespaced as `<pluginName>-<name>` (marketplace only when needed)
65
67
  - Tracks plugin-managed skills in a single `.skiller.json` file per agent skills directory
66
68
  - Removes stale plugin skills when plugins are disabled
67
69
 
@@ -606,7 +608,7 @@ If your project enables Claude Code plugins in `.claude/settings.json`, Skiller
606
608
  - Plugin `skills/` are copied as skills
607
609
  - Plugin `commands/*.md` are converted into skills (`SKILL.md`)
608
610
  - Plugin skills use their original skill/command name by default
609
- - If a name conflicts, local skills win and the plugin skill is namespaced as `<pluginId>-<name>`
611
+ - If a name conflicts, local skills win and the plugin skill is namespaced as `<pluginName>-<name>` (marketplace only when needed)
610
612
  - Plugin-managed skills are tracked via `.skiller.json` in each agent skills directory
611
613
 
612
614
  ### Skills Directory Structure
@@ -71,6 +71,14 @@ async function fileExists(p) {
71
71
  return false;
72
72
  }
73
73
  }
74
+ async function dirExists(p) {
75
+ try {
76
+ return (await fs.stat(p)).isDirectory();
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ }
74
82
  function makeItemKey(pluginId, sourceKind, sourceRelPath) {
75
83
  return `${pluginId}::${sourceKind}::${sourceRelPath}`;
76
84
  }
@@ -107,6 +115,59 @@ async function readInstalledPluginsIndex(claudeDir) {
107
115
  return null;
108
116
  }
109
117
  }
118
+ function parsePluginId(pluginId) {
119
+ // `pluginId` format is typically `<pluginName>@<marketplaceId>`.
120
+ // Split by the last `@` so scoped names like `@org/foo@market` work.
121
+ const at = pluginId.lastIndexOf('@');
122
+ if (at <= 0 || at === pluginId.length - 1)
123
+ return null;
124
+ return {
125
+ pluginName: pluginId.slice(0, at),
126
+ marketplaceId: pluginId.slice(at + 1),
127
+ };
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) {
154
+ const parsed = parsePluginId(pluginId);
155
+ if (!parsed)
156
+ return null;
157
+ const marketplaceRoot = knownMarketplaceInstallLocations[parsed.marketplaceId] ??
158
+ path.join(claudeDir, 'plugins', 'marketplaces', parsed.marketplaceId);
159
+ const candidates = [
160
+ path.join(marketplaceRoot, 'plugins', parsed.pluginName),
161
+ path.join(marketplaceRoot, 'external_plugins', parsed.pluginName),
162
+ path.join(marketplaceRoot, '.claude-plugin', 'plugins', parsed.pluginName),
163
+ path.join(marketplaceRoot, '.claude-plugin', 'external_plugins', parsed.pluginName),
164
+ ];
165
+ for (const candidate of candidates) {
166
+ if (await dirExists(candidate))
167
+ return candidate;
168
+ }
169
+ return null;
170
+ }
110
171
  function resolvePluginInstall(pluginId, projectRoot, index) {
111
172
  const entries = index.plugins?.[pluginId];
112
173
  if (!entries || !Array.isArray(entries) || entries.length === 0)
@@ -265,13 +326,33 @@ async function discoverPluginAgentFiles(installPath) {
265
326
  function generateBaseNameFromRelId(relId) {
266
327
  const normalized = relId.replace(/\\/g, '/');
267
328
  const segments = normalized.split('/').filter(Boolean);
268
- return segments.map(sanitizeId).join('__');
329
+ return segments.map(sanitizeId).join('-');
269
330
  }
270
331
  function generateBaseNameFromCommand(commandName) {
271
332
  return sanitizeId(commandName);
272
333
  }
273
- function generateNamespacedName(pluginId, baseName) {
274
- return `${sanitizeId(pluginId)}-${baseName}`;
334
+ function pluginBaseNamespacePrefix(pluginId) {
335
+ const parsed = parsePluginId(pluginId);
336
+ if (parsed)
337
+ return sanitizeId(parsed.pluginName);
338
+ return sanitizeId(pluginId);
339
+ }
340
+ function computePluginNamespacePrefixes(pluginIds) {
341
+ const basePrefixByPluginId = new Map();
342
+ const counts = new Map();
343
+ for (const pluginId of pluginIds) {
344
+ const base = pluginBaseNamespacePrefix(pluginId);
345
+ basePrefixByPluginId.set(pluginId, base);
346
+ counts.set(base, (counts.get(base) ?? 0) + 1);
347
+ }
348
+ const out = new Map();
349
+ for (const pluginId of pluginIds) {
350
+ const base = basePrefixByPluginId.get(pluginId) ?? sanitizeId(pluginId);
351
+ // If multiple enabled plugins share the same pluginName, fall back to the
352
+ // full pluginId for uniqueness (includes marketplace).
353
+ out.set(pluginId, (counts.get(base) ?? 0) > 1 ? sanitizeId(pluginId) : base);
354
+ }
355
+ return out;
275
356
  }
276
357
  async function readLegacyMarkerFile(dir) {
277
358
  const markerPath = path.join(dir, LEGACY_MARKER_FILENAME);
@@ -378,7 +459,14 @@ async function discoverLocalSkillNames(projectRoot) {
378
459
  }
379
460
  const hasSkillMd = entries.some((e) => e.isFile() && e.name === 'SKILL.md');
380
461
  if (hasSkillMd) {
381
- names.add(path.basename(current));
462
+ const rel = path.relative(localSkillsDir, current).replace(/\\/g, '/');
463
+ const segments = rel.split('/').filter(Boolean);
464
+ if (segments.length > 0) {
465
+ names.add(segments.map(sanitizeId).join('-'));
466
+ }
467
+ else {
468
+ names.add(sanitizeId(path.basename(current)));
469
+ }
382
470
  return;
383
471
  }
384
472
  for (const entry of entries) {
@@ -520,6 +608,7 @@ async function syncClaudePluginsToSkillsDirs(args) {
520
608
  }
521
609
  const claudeDir = path.join(getUserHomeDir(), '.claude');
522
610
  const index = await readInstalledPluginsIndex(claudeDir);
611
+ const knownMarketplaceInstallLocations = await readKnownMarketplaceInstallLocations(claudeDir);
523
612
  const localSkillNames = await discoverLocalSkillNames(projectRoot);
524
613
  const localCommandNames = await discoverLocalCommandNames(projectRoot);
525
614
  const localAgentNames = await discoverLocalAgentNames(projectRoot);
@@ -528,66 +617,27 @@ async function syncClaudePluginsToSkillsDirs(args) {
528
617
  ...localCommandNames,
529
618
  ...localAgentNames,
530
619
  ]);
531
- // If we can't read the installed plugins index, we can't install/update
532
- // anything, but we can still clean up managed folders for plugins that
533
- // are no longer enabled.
534
- if (!index) {
535
- for (const targetSkillsDir of targetSkillsDirs) {
536
- if (!(await fileExists(targetSkillsDir)))
537
- continue;
538
- const { pluginEntries: managedEntries, otherEntries } = await loadManagedEntries(targetSkillsDir, dryRun);
539
- const nextEntries = [];
540
- // Build reserved set (local skills always win).
541
- const reserved = new Set(localReservedNames);
542
- // Also reserve any existing non-managed directories.
543
- const managedDest = new Set(managedEntries.map((e) => e.destRelPath));
544
- let dirents = [];
545
- try {
546
- dirents = await fs.readdir(targetSkillsDir, { withFileTypes: true });
547
- }
548
- catch {
549
- // ignore
550
- }
551
- for (const d of dirents) {
552
- if (!d.isDirectory())
553
- continue;
554
- if (!managedDest.has(d.name)) {
555
- reserved.add(d.name);
556
- }
557
- }
558
- for (const entry of managedEntries) {
559
- const isEnabled = enabledPlugins.includes(entry.pluginId);
560
- if (!isEnabled) {
561
- if (reserved.has(entry.destRelPath)) {
562
- // Local took over the folder name; stop managing it but don't delete.
563
- continue;
564
- }
565
- (0, constants_1.logVerboseInfo)(dryRun
566
- ? `DRY RUN: Would remove stale plugin skill '${entry.destRelPath}' from ${targetSkillsDir}`
567
- : `Removing stale plugin skill '${entry.destRelPath}' from ${targetSkillsDir}`, verbose, dryRun);
568
- await removeDir(path.join(targetSkillsDir, entry.destRelPath), dryRun);
569
- continue;
570
- }
571
- nextEntries.push(entry);
572
- }
573
- await (0, SkillsManifest_1.writeSkillsManifestEntries)(targetSkillsDir, [...otherEntries, ...nextEntries], dryRun);
574
- }
575
- return;
576
- }
577
- const resolvedInstalls = [];
620
+ const resolvedSources = [];
578
621
  const unresolvedEnabled = new Set();
579
622
  for (const pluginId of enabledPlugins) {
580
- const resolved = resolvePluginInstall(pluginId, projectRoot, index);
581
- if (!resolved) {
623
+ const pluginRoot = await resolvePluginMarketplaceRoot(pluginId, claudeDir, knownMarketplaceInstallLocations);
624
+ if (!pluginRoot) {
582
625
  unresolvedEnabled.add(pluginId);
583
626
  (0, constants_1.logWarn)(`[plugins] Enabled plugin not installed: ${pluginId}`, dryRun);
584
627
  continue;
585
628
  }
586
- resolvedInstalls.push(resolved);
629
+ const resolved = index
630
+ ? resolvePluginInstall(pluginId, projectRoot, index)
631
+ : null;
632
+ resolvedSources.push({
633
+ pluginId,
634
+ pluginRoot,
635
+ version: resolved?.version,
636
+ });
587
637
  }
588
638
  const expectedItems = [];
589
- for (const plugin of resolvedInstalls) {
590
- const skillDirs = await discoverPluginSkillDirs(plugin.installPath);
639
+ for (const plugin of resolvedSources) {
640
+ const skillDirs = await discoverPluginSkillDirs(plugin.pluginRoot);
591
641
  for (const s of skillDirs) {
592
642
  const baseName = generateBaseNameFromRelId(s.relId);
593
643
  const sourceRelPath = `skills/${s.relId}`;
@@ -601,7 +651,7 @@ async function syncClaudePluginsToSkillsDirs(args) {
601
651
  baseName,
602
652
  });
603
653
  }
604
- const commandFiles = await discoverPluginCommandFiles(plugin.installPath);
654
+ const commandFiles = await discoverPluginCommandFiles(plugin.pluginRoot);
605
655
  for (const c of commandFiles) {
606
656
  const baseName = generateBaseNameFromCommand(c.name);
607
657
  const sourceRelPath = `commands/${path.basename(c.file)}`;
@@ -615,7 +665,7 @@ async function syncClaudePluginsToSkillsDirs(args) {
615
665
  baseName,
616
666
  });
617
667
  }
618
- const agentFiles = await discoverPluginAgentFiles(plugin.installPath);
668
+ const agentFiles = await discoverPluginAgentFiles(plugin.pluginRoot);
619
669
  for (const a of agentFiles) {
620
670
  const baseName = sanitizeId(a.name);
621
671
  const sourceRelPath = `agents/${a.rel}`;
@@ -635,6 +685,9 @@ async function syncClaudePluginsToSkillsDirs(args) {
635
685
  const bk = `${b.baseName}::${b.pluginId}::${b.kind}::${b.sourceRelPath}`;
636
686
  return ak.localeCompare(bk);
637
687
  });
688
+ const pluginNamespacePrefixByPluginId = computePluginNamespacePrefixes([
689
+ ...new Set(resolvedSources.map((p) => p.pluginId)),
690
+ ]);
638
691
  // Sync into each target skills dir.
639
692
  for (const targetSkillsDir of targetSkillsDirs) {
640
693
  const targetExists = await fileExists(targetSkillsDir);
@@ -679,11 +732,17 @@ async function syncClaudePluginsToSkillsDirs(args) {
679
732
  const prev = prevDestByItemKey.get(item.itemKey);
680
733
  if (!prev)
681
734
  continue;
682
- // Migration: previous versions used `${pluginId}__${name}`.
683
- // Don't preserve legacy namespaced destinations so we can rename to the
684
- // new `${pluginId}-${name}` format.
735
+ const desiredPrefix = pluginNamespacePrefixByPluginId.get(item.pluginId) ??
736
+ sanitizeId(item.pluginId);
737
+ // Migration: previous versions used `${pluginId}__${name}`, then
738
+ // `${pluginId}-${name}`. If we changed the namespace prefix (for example
739
+ // to omit marketplace), don't preserve the old destination so the item
740
+ // can be renamed.
685
741
  if (prev.startsWith(`${sanitizeId(item.pluginId)}__`))
686
742
  continue;
743
+ if (prev.startsWith(`${sanitizeId(item.pluginId)}-`) &&
744
+ desiredPrefix !== sanitizeId(item.pluginId))
745
+ continue;
687
746
  if (taken.has(prev))
688
747
  continue;
689
748
  assignedDestByItemKey.set(item.itemKey, prev);
@@ -699,7 +758,7 @@ async function syncClaudePluginsToSkillsDirs(args) {
699
758
  taken.add(base);
700
759
  continue;
701
760
  }
702
- const namespacedBase = generateNamespacedName(item.pluginId, base);
761
+ const namespacedBase = `${pluginNamespacePrefixByPluginId.get(item.pluginId) ?? sanitizeId(item.pluginId)}-${base}`;
703
762
  let candidate = namespacedBase;
704
763
  let i = 2;
705
764
  while (taken.has(candidate)) {
@@ -209,7 +209,14 @@ async function discoverLocalSkillNames(projectRoot) {
209
209
  }
210
210
  const hasSkillMd = entries.some((e) => e.isFile() && e.name === 'SKILL.md');
211
211
  if (hasSkillMd) {
212
- names.add(path.basename(current));
212
+ const rel = path.relative(localSkillsDir, current).replace(/\\/g, '/');
213
+ const segments = rel.split('/').filter(Boolean);
214
+ if (segments.length > 0) {
215
+ names.add(segments.map(sanitizeId).join('-'));
216
+ }
217
+ else {
218
+ names.add(sanitizeId(path.basename(current)));
219
+ }
213
220
  return;
214
221
  }
215
222
  for (const entry of entries) {
@@ -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.17",
3
+ "version": "0.7.19",
4
4
  "description": "Skiller — apply the same rules to all coding agents",
5
5
  "main": "dist/lib.js",
6
6
  "publishConfig": {