@rbbtsn0w/adg 0.1.0-alpha.1

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.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +308 -0
  3. package/bin/adg.ts +758 -0
  4. package/docs/agents-spec.md +132 -0
  5. package/docs/authoring.md +352 -0
  6. package/package.json +50 -0
  7. package/schemas/adg-plugin.schema.json +77 -0
  8. package/schemas/marketplace.schema.json +86 -0
  9. package/schemas/plugin-lock.schema.json +90 -0
  10. package/src/adapters/anthropic.ts +54 -0
  11. package/src/adapters/index.ts +24 -0
  12. package/src/adapters/openai.ts +37 -0
  13. package/src/adapters/reverse.ts +60 -0
  14. package/src/agents/claude.ts +124 -0
  15. package/src/agents/codex.ts +67 -0
  16. package/src/agents/index.ts +12 -0
  17. package/src/agents/registry.ts +30 -0
  18. package/src/agents/types.ts +47 -0
  19. package/src/commands/adapt.ts +36 -0
  20. package/src/commands/import.ts +69 -0
  21. package/src/commands/init.ts +146 -0
  22. package/src/commands/install.ts +411 -0
  23. package/src/commands/link.ts +61 -0
  24. package/src/commands/list.ts +28 -0
  25. package/src/commands/marketplace.ts +198 -0
  26. package/src/commands/migrate.ts +84 -0
  27. package/src/commands/multiselect-skills.ts +137 -0
  28. package/src/commands/remove.ts +136 -0
  29. package/src/commands/select-agents.ts +45 -0
  30. package/src/commands/select-components.ts +66 -0
  31. package/src/commands/select-plugins.ts +28 -0
  32. package/src/commands/select-scope.ts +21 -0
  33. package/src/commands/update.ts +85 -0
  34. package/src/commands/validate.ts +57 -0
  35. package/src/components.ts +90 -0
  36. package/src/deps.ts +64 -0
  37. package/src/fsutil.ts +38 -0
  38. package/src/hash.ts +61 -0
  39. package/src/lock.ts +57 -0
  40. package/src/manifest.ts +113 -0
  41. package/src/marketplace.ts +41 -0
  42. package/src/package.ts +74 -0
  43. package/src/paths.ts +129 -0
  44. package/src/semver.ts +67 -0
  45. package/src/skills.ts +88 -0
  46. package/src/sources.ts +159 -0
  47. package/src/types.ts +140 -0
  48. package/vendor/skills/LICENSE +29 -0
  49. package/vendor/skills/PROVENANCE.md +60 -0
  50. package/vendor/skills/ThirdPartyNoticeText.txt +117 -0
  51. package/vendor/skills/package.json +143 -0
  52. package/vendor/skills/src/add.ts +1999 -0
  53. package/vendor/skills/src/agents.ts +755 -0
  54. package/vendor/skills/src/blob.ts +567 -0
  55. package/vendor/skills/src/cli.ts +387 -0
  56. package/vendor/skills/src/constants.ts +3 -0
  57. package/vendor/skills/src/detect-agent.ts +62 -0
  58. package/vendor/skills/src/find.ts +357 -0
  59. package/vendor/skills/src/frontmatter.ts +16 -0
  60. package/vendor/skills/src/git-tree.ts +36 -0
  61. package/vendor/skills/src/git.ts +277 -0
  62. package/vendor/skills/src/install.ts +91 -0
  63. package/vendor/skills/src/installer.ts +1097 -0
  64. package/vendor/skills/src/list.ts +231 -0
  65. package/vendor/skills/src/local-lock.ts +182 -0
  66. package/vendor/skills/src/plugin-manifest.ts +183 -0
  67. package/vendor/skills/src/prompts/search-multiselect.ts +387 -0
  68. package/vendor/skills/src/providers/index.ts +14 -0
  69. package/vendor/skills/src/providers/registry.ts +51 -0
  70. package/vendor/skills/src/providers/types.ts +97 -0
  71. package/vendor/skills/src/providers/wellknown.ts +804 -0
  72. package/vendor/skills/src/remove.ts +323 -0
  73. package/vendor/skills/src/sanitize.ts +65 -0
  74. package/vendor/skills/src/self-cli.ts +20 -0
  75. package/vendor/skills/src/skill-lock.ts +329 -0
  76. package/vendor/skills/src/skills.ts +316 -0
  77. package/vendor/skills/src/source-parser.ts +438 -0
  78. package/vendor/skills/src/sync.ts +478 -0
  79. package/vendor/skills/src/telemetry.ts +186 -0
  80. package/vendor/skills/src/test-utils.ts +73 -0
  81. package/vendor/skills/src/types.ts +128 -0
  82. package/vendor/skills/src/update-source.ts +90 -0
  83. package/vendor/skills/src/update.ts +749 -0
  84. package/vendor/skills/src/use.ts +675 -0
@@ -0,0 +1,231 @@
1
+ import { homedir } from 'os';
2
+ import type { AgentType } from './types.ts';
3
+ import { agents } from './agents.ts';
4
+ import { listInstalledSkills, type InstalledSkill } from './installer.ts';
5
+ import { sanitizeMetadata } from './sanitize.ts';
6
+ import { getAllLockedSkills } from './skill-lock.ts';
7
+
8
+ const RESET = '\x1b[0m';
9
+ const BOLD = '\x1b[1m';
10
+ const DIM = '\x1b[38;5;102m';
11
+ const TEXT = '\x1b[38;5;145m';
12
+ const CYAN = '\x1b[36m';
13
+ const YELLOW = '\x1b[33m';
14
+
15
+ interface ListOptions {
16
+ global?: boolean;
17
+ agent?: string[];
18
+ json?: boolean;
19
+ }
20
+
21
+ /**
22
+ * Shortens a path for display: replaces homedir with ~ and cwd with .
23
+ */
24
+ function shortenPath(fullPath: string, cwd: string): string {
25
+ const home = homedir();
26
+ if (fullPath.startsWith(home)) {
27
+ return fullPath.replace(home, '~');
28
+ }
29
+ if (fullPath.startsWith(cwd)) {
30
+ return '.' + fullPath.slice(cwd.length);
31
+ }
32
+ return fullPath;
33
+ }
34
+
35
+ /**
36
+ * Formats a list of items, truncating if too many
37
+ */
38
+ function formatList(items: string[], maxShow: number = 5): string {
39
+ if (items.length <= maxShow) {
40
+ return items.join(', ');
41
+ }
42
+ const shown = items.slice(0, maxShow);
43
+ const remaining = items.length - maxShow;
44
+ return `${shown.join(', ')} +${remaining} more`;
45
+ }
46
+
47
+ export function parseListOptions(args: string[]): ListOptions {
48
+ const options: ListOptions = {};
49
+
50
+ for (let i = 0; i < args.length; i++) {
51
+ const arg = args[i];
52
+ if (arg === '-g' || arg === '--global') {
53
+ options.global = true;
54
+ } else if (arg === '--json') {
55
+ options.json = true;
56
+ } else if (arg === '-a' || arg === '--agent') {
57
+ options.agent = options.agent || [];
58
+ // Collect all following arguments until next flag
59
+ while (i + 1 < args.length && !args[i + 1]!.startsWith('-')) {
60
+ options.agent.push(args[++i]!);
61
+ }
62
+ }
63
+ }
64
+
65
+ return options;
66
+ }
67
+
68
+ export async function runList(args: string[]): Promise<void> {
69
+ const options = parseListOptions(args);
70
+
71
+ // Default to project only (local), use -g for global
72
+ const scope = options.global === true ? true : false;
73
+
74
+ // Validate agent filter if provided
75
+ let agentFilter: AgentType[] | undefined;
76
+ if (options.agent && options.agent.length > 0) {
77
+ const validAgents = Object.keys(agents);
78
+ const invalidAgents = options.agent.filter((a) => !validAgents.includes(a));
79
+
80
+ if (invalidAgents.length > 0) {
81
+ console.log(`${YELLOW}Invalid agents: ${invalidAgents.join(', ')}${RESET}`);
82
+ console.log(`${DIM}Valid agents: ${validAgents.join(', ')}${RESET}`);
83
+ process.exit(1);
84
+ }
85
+
86
+ agentFilter = options.agent as AgentType[];
87
+ }
88
+
89
+ const installedSkills = await listInstalledSkills({
90
+ global: scope,
91
+ agentFilter,
92
+ });
93
+
94
+ // JSON output mode: structured, no ANSI, untruncated agent lists
95
+ if (options.json) {
96
+ const jsonOutput = installedSkills.map((skill) => ({
97
+ name: skill.name,
98
+ path: skill.canonicalPath,
99
+ scope: skill.scope,
100
+ agents: skill.agents.map((a) => agents[a].displayName),
101
+ }));
102
+ console.log(JSON.stringify(jsonOutput, null, 2));
103
+ return;
104
+ }
105
+
106
+ // Fetch lock entries to get plugin grouping info
107
+ const lockedSkills = await getAllLockedSkills();
108
+
109
+ const cwd = process.cwd();
110
+ const scopeLabel = scope ? 'Global' : 'Project';
111
+
112
+ if (installedSkills.length === 0) {
113
+ if (options.json) {
114
+ console.log('[]');
115
+ return;
116
+ }
117
+ console.log(`${DIM}No ${scopeLabel.toLowerCase()} skills found.${RESET}`);
118
+ if (scope) {
119
+ console.log(`${DIM}Try listing project skills without -g${RESET}`);
120
+ } else {
121
+ console.log(`${DIM}Try listing global skills with -g${RESET}`);
122
+ }
123
+ return;
124
+ }
125
+
126
+ function printSkill(
127
+ skill: InstalledSkill,
128
+ indent: boolean = false,
129
+ maxNameLength: number = 0,
130
+ maxPathLength: number = 0
131
+ ): void {
132
+ const prefix = indent ? ' ' : '';
133
+ const shortPath = shortenPath(skill.canonicalPath, cwd);
134
+ const agentNames = skill.agents.map((a) => agents[a].displayName);
135
+ const agentInfo =
136
+ skill.agents.length > 0 ? formatList(agentNames) : `${YELLOW}not linked${RESET}`;
137
+
138
+ // Pad skill name and path for alignment
139
+ const paddedName = sanitizeMetadata(skill.name).padEnd(maxNameLength);
140
+ const paddedPath = shortPath.padEnd(maxPathLength);
141
+
142
+ console.log(
143
+ `${prefix}${CYAN}${paddedName}${RESET} ${DIM}${paddedPath}${RESET} ${DIM}Agents:${RESET} ${agentInfo}`
144
+ );
145
+ }
146
+
147
+ console.log(`${BOLD}${scopeLabel} Skills${RESET}`);
148
+ console.log();
149
+
150
+ // Group skills by plugin
151
+ const groupedSkills: Record<string, InstalledSkill[]> = {};
152
+ const ungroupedSkills: InstalledSkill[] = [];
153
+
154
+ for (const skill of installedSkills) {
155
+ const lockEntry = lockedSkills[skill.name];
156
+ if (lockEntry?.pluginName) {
157
+ const group = lockEntry.pluginName;
158
+ if (!groupedSkills[group]) {
159
+ groupedSkills[group] = [];
160
+ }
161
+ groupedSkills[group].push(skill);
162
+ } else {
163
+ ungroupedSkills.push(skill);
164
+ }
165
+ }
166
+
167
+ const hasGroups = Object.keys(groupedSkills).length > 0;
168
+
169
+ if (hasGroups) {
170
+ // Print groups sorted alphabetically
171
+ const sortedGroups = Object.keys(groupedSkills).sort();
172
+ for (const group of sortedGroups) {
173
+ // Convert kebab-case to Title Case for display header
174
+ const title = group
175
+ .split('-')
176
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
177
+ .join(' ');
178
+
179
+ console.log(`${BOLD}${title}${RESET}`);
180
+ const skills = groupedSkills[group];
181
+ if (skills) {
182
+ // Calculate max lengths for alignment within this group
183
+ let maxNameLength = 0;
184
+ let maxPathLength = 0;
185
+ for (const skill of skills) {
186
+ const nameLength = sanitizeMetadata(skill.name).length;
187
+ const pathLength = shortenPath(skill.canonicalPath, cwd).length;
188
+ if (nameLength > maxNameLength) maxNameLength = nameLength;
189
+ if (pathLength > maxPathLength) maxPathLength = pathLength;
190
+ }
191
+ for (const skill of skills) {
192
+ printSkill(skill, true, maxNameLength, maxPathLength);
193
+ }
194
+ }
195
+ console.log();
196
+ }
197
+
198
+ // Print ungrouped skills if any exist
199
+ if (ungroupedSkills.length > 0) {
200
+ console.log(`${BOLD}General${RESET}`);
201
+ // Calculate max lengths for alignment within ungrouped skills
202
+ let maxNameLength = 0;
203
+ let maxPathLength = 0;
204
+ for (const skill of ungroupedSkills) {
205
+ const nameLength = sanitizeMetadata(skill.name).length;
206
+ const pathLength = shortenPath(skill.canonicalPath, cwd).length;
207
+ if (nameLength > maxNameLength) maxNameLength = nameLength;
208
+ if (pathLength > maxPathLength) maxPathLength = pathLength;
209
+ }
210
+ for (const skill of ungroupedSkills) {
211
+ printSkill(skill, true, maxNameLength, maxPathLength);
212
+ }
213
+ console.log();
214
+ }
215
+ } else {
216
+ // No groups, print flat list as before
217
+ // Calculate max lengths for alignment in flat list
218
+ let maxNameLength = 0;
219
+ let maxPathLength = 0;
220
+ for (const skill of installedSkills) {
221
+ const nameLength = sanitizeMetadata(skill.name).length;
222
+ const pathLength = shortenPath(skill.canonicalPath, cwd).length;
223
+ if (nameLength > maxNameLength) maxNameLength = nameLength;
224
+ if (pathLength > maxPathLength) maxPathLength = pathLength;
225
+ }
226
+ for (const skill of installedSkills) {
227
+ printSkill(skill, false, maxNameLength, maxPathLength);
228
+ }
229
+ console.log();
230
+ }
231
+ }
@@ -0,0 +1,182 @@
1
+ import { readFile, writeFile, readdir, stat } from 'fs/promises';
2
+ import { join, relative } from 'path';
3
+ import { createHash } from 'crypto';
4
+
5
+ const LOCAL_LOCK_FILE = 'skills-lock.json';
6
+ const CURRENT_VERSION = 1;
7
+
8
+ /**
9
+ * Represents a single skill entry in the local (project) lock file.
10
+ *
11
+ * Intentionally minimal and timestamp-free to minimize merge conflicts.
12
+ * Two branches adding different skills produce non-overlapping JSON keys
13
+ * that git can auto-merge cleanly.
14
+ */
15
+ export interface LocalSkillLockEntry {
16
+ /** Where the skill came from: npm package name, owner/repo, local path, etc. */
17
+ source: string;
18
+ /** Branch or tag ref used for installation */
19
+ ref?: string;
20
+ /** The provider/source type (e.g., "github", "node_modules", "local") */
21
+ sourceType: string;
22
+ /**
23
+ * Path to the skill's SKILL.md within the source repo (e.g., "skills/pdf/SKILL.md").
24
+ * Required to re-install only this skill on update — without it, an update would
25
+ * refetch every skill in the source repo. Optional for backward compatibility with
26
+ * lock files written before this field existed, and omitted for non-repo sources
27
+ * (node_modules, local paths) where there is no subfolder to target.
28
+ */
29
+ skillPath?: string;
30
+ /**
31
+ * SHA-256 hash computed from all files in the skill folder.
32
+ * Unlike the global lock which uses GitHub tree SHA, the local lock
33
+ * computes the hash from actual file contents on disk.
34
+ */
35
+ computedHash: string;
36
+ }
37
+
38
+ /**
39
+ * The structure of the local (project-scoped) skill lock file.
40
+ * This file is meant to be checked into version control.
41
+ *
42
+ * Skills are sorted alphabetically by name when written to produce
43
+ * deterministic output and minimize merge conflicts.
44
+ */
45
+ export interface LocalSkillLockFile {
46
+ /** Schema version for future migrations */
47
+ version: number;
48
+ /** Map of skill name to its lock entry (sorted alphabetically) */
49
+ skills: Record<string, LocalSkillLockEntry>;
50
+ }
51
+
52
+ /**
53
+ * Get the path to the local skill lock file for a project.
54
+ */
55
+ export function getLocalLockPath(cwd?: string): string {
56
+ return join(cwd || process.cwd(), LOCAL_LOCK_FILE);
57
+ }
58
+
59
+ /**
60
+ * Read the local skill lock file.
61
+ * Returns an empty lock file structure if the file doesn't exist
62
+ * or is corrupted (e.g., merge conflict markers).
63
+ */
64
+ export async function readLocalLock(cwd?: string): Promise<LocalSkillLockFile> {
65
+ const lockPath = getLocalLockPath(cwd);
66
+
67
+ try {
68
+ const content = await readFile(lockPath, 'utf-8');
69
+ const parsed = JSON.parse(content) as LocalSkillLockFile;
70
+
71
+ if (typeof parsed.version !== 'number' || !parsed.skills) {
72
+ return createEmptyLocalLock();
73
+ }
74
+
75
+ if (parsed.version < CURRENT_VERSION) {
76
+ return createEmptyLocalLock();
77
+ }
78
+
79
+ return parsed;
80
+ } catch {
81
+ return createEmptyLocalLock();
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Write the local skill lock file.
87
+ * Skills are sorted alphabetically by name for deterministic output.
88
+ */
89
+ export async function writeLocalLock(lock: LocalSkillLockFile, cwd?: string): Promise<void> {
90
+ const lockPath = getLocalLockPath(cwd);
91
+
92
+ // Sort skills alphabetically for deterministic output / clean diffs
93
+ const sortedSkills: Record<string, LocalSkillLockEntry> = {};
94
+ for (const key of Object.keys(lock.skills).sort()) {
95
+ sortedSkills[key] = lock.skills[key]!;
96
+ }
97
+
98
+ const sorted: LocalSkillLockFile = { version: lock.version, skills: sortedSkills };
99
+ const content = JSON.stringify(sorted, null, 2) + '\n';
100
+ await writeFile(lockPath, content, 'utf-8');
101
+ }
102
+
103
+ /**
104
+ * Compute a SHA-256 hash from all files in a skill directory.
105
+ * Reads all files recursively, sorts them by relative path for determinism,
106
+ * and produces a single hash from their concatenated contents.
107
+ */
108
+ export async function computeSkillFolderHash(skillDir: string): Promise<string> {
109
+ const files: Array<{ relativePath: string; content: Buffer }> = [];
110
+ await collectFiles(skillDir, skillDir, files);
111
+
112
+ // Sort by relative path for deterministic hashing
113
+ files.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
114
+
115
+ const hash = createHash('sha256');
116
+ for (const file of files) {
117
+ // Include the path in the hash so renames are detected
118
+ hash.update(file.relativePath);
119
+ hash.update(file.content);
120
+ }
121
+
122
+ return hash.digest('hex');
123
+ }
124
+
125
+ async function collectFiles(
126
+ baseDir: string,
127
+ currentDir: string,
128
+ results: Array<{ relativePath: string; content: Buffer }>
129
+ ): Promise<void> {
130
+ const entries = await readdir(currentDir, { withFileTypes: true });
131
+
132
+ await Promise.all(
133
+ entries.map(async (entry) => {
134
+ const fullPath = join(currentDir, entry.name);
135
+
136
+ if (entry.isDirectory()) {
137
+ // Skip .git and node_modules within skill dirs
138
+ if (entry.name === '.git' || entry.name === 'node_modules') return;
139
+ await collectFiles(baseDir, fullPath, results);
140
+ } else if (entry.isFile()) {
141
+ const content = await readFile(fullPath);
142
+ const relativePath = relative(baseDir, fullPath).split('\\').join('/');
143
+ results.push({ relativePath, content });
144
+ }
145
+ })
146
+ );
147
+ }
148
+
149
+ /**
150
+ * Add or update a skill entry in the local lock file.
151
+ */
152
+ export async function addSkillToLocalLock(
153
+ skillName: string,
154
+ entry: LocalSkillLockEntry,
155
+ cwd?: string
156
+ ): Promise<void> {
157
+ const lock = await readLocalLock(cwd);
158
+ lock.skills[skillName] = entry;
159
+ await writeLocalLock(lock, cwd);
160
+ }
161
+
162
+ /**
163
+ * Remove a skill from the local lock file.
164
+ */
165
+ export async function removeSkillFromLocalLock(skillName: string, cwd?: string): Promise<boolean> {
166
+ const lock = await readLocalLock(cwd);
167
+
168
+ if (!(skillName in lock.skills)) {
169
+ return false;
170
+ }
171
+
172
+ delete lock.skills[skillName];
173
+ await writeLocalLock(lock, cwd);
174
+ return true;
175
+ }
176
+
177
+ function createEmptyLocalLock(): LocalSkillLockFile {
178
+ return {
179
+ version: CURRENT_VERSION,
180
+ skills: {},
181
+ };
182
+ }
@@ -0,0 +1,183 @@
1
+ import { readFile } from 'fs/promises';
2
+ import { join, dirname, resolve, normalize, sep } from 'path';
3
+
4
+ /**
5
+ * Check if a path is contained within a base directory.
6
+ * Prevents path traversal attacks via `..` segments or absolute paths.
7
+ */
8
+ function isContainedIn(targetPath: string, basePath: string): boolean {
9
+ const normalizedBase = normalize(resolve(basePath));
10
+ const normalizedTarget = normalize(resolve(targetPath));
11
+ return normalizedTarget.startsWith(normalizedBase + sep) || normalizedTarget === normalizedBase;
12
+ }
13
+
14
+ /**
15
+ * Validate that a relative path follows Claude Code conventions.
16
+ * Paths must start with './' per the plugin manifest spec.
17
+ */
18
+ function isValidRelativePath(path: string): boolean {
19
+ return path.startsWith('./');
20
+ }
21
+
22
+ /**
23
+ * Plugin manifest types
24
+ */
25
+ interface PluginManifestEntry {
26
+ source?: string | { source: string; repo?: string };
27
+ skills?: string[];
28
+ /** Optional name for grouping skills (e.g., "document-skills") */
29
+ name?: string;
30
+ }
31
+
32
+ interface MarketplaceManifest {
33
+ metadata?: { pluginRoot?: string };
34
+ plugins?: PluginManifestEntry[];
35
+ }
36
+
37
+ interface PluginManifest {
38
+ skills?: string[];
39
+ name?: string;
40
+ }
41
+
42
+ /**
43
+ * Extract skill search directories from plugin manifests.
44
+ * Handles both marketplace.json (multi-plugin) and plugin.json (single plugin).
45
+ * Only resolves local paths - remote sources are skipped.
46
+ *
47
+ * Returns directories that CONTAIN skills (to be searched for child SKILL.md files).
48
+ * For explicit skill paths in manifests, adds the parent directory so the
49
+ * existing discovery loop finds them.
50
+ */
51
+ export async function getPluginSkillPaths(basePath: string): Promise<string[]> {
52
+ const searchDirs: string[] = [];
53
+
54
+ // Helper: add skill paths for a plugin at a given base path
55
+ // Only adds paths that are contained within basePath (security: prevents traversal)
56
+ const addPluginSkillPaths = (pluginBase: string, skills?: string[]) => {
57
+ // Validate pluginBase itself is contained
58
+ if (!isContainedIn(pluginBase, basePath)) return;
59
+
60
+ if (skills && skills.length > 0) {
61
+ // Plugin explicitly declares skill paths - add parent dirs so existing loop finds them
62
+ for (const skillPath of skills) {
63
+ // Validate skill path starts with './' (per Claude Code convention)
64
+ if (!isValidRelativePath(skillPath)) continue;
65
+
66
+ const skillDir = dirname(join(pluginBase, skillPath));
67
+ if (isContainedIn(skillDir, basePath)) {
68
+ searchDirs.push(skillDir);
69
+ }
70
+ }
71
+ }
72
+ // Always add conventional skills/ directory for discovery
73
+ // (deduplication happens via seenNames in discoverSkills)
74
+ searchDirs.push(join(pluginBase, 'skills'));
75
+ };
76
+
77
+ // Try marketplace.json (multi-plugin catalog)
78
+ try {
79
+ const content = await readFile(join(basePath, '.claude-plugin/marketplace.json'), 'utf-8');
80
+ const manifest: MarketplaceManifest = JSON.parse(content);
81
+ const pluginRoot = manifest.metadata?.pluginRoot;
82
+
83
+ // Validate pluginRoot starts with './' if provided (per Claude Code convention)
84
+ const validPluginRoot = pluginRoot === undefined || isValidRelativePath(pluginRoot);
85
+
86
+ if (validPluginRoot) {
87
+ for (const plugin of manifest.plugins ?? []) {
88
+ // Skip remote sources (object with source/repo) - only handle local string paths
89
+ if (typeof plugin.source !== 'string' && plugin.source !== undefined) continue;
90
+
91
+ // Validate source starts with './' if provided (per Claude Code convention)
92
+ if (plugin.source !== undefined && !isValidRelativePath(plugin.source)) continue;
93
+
94
+ const pluginBase = join(basePath, pluginRoot ?? '', plugin.source ?? '');
95
+ addPluginSkillPaths(pluginBase, plugin.skills);
96
+ }
97
+ }
98
+ } catch {
99
+ // File doesn't exist or invalid JSON
100
+ }
101
+
102
+ // Try plugin.json (single plugin at root)
103
+ try {
104
+ const content = await readFile(join(basePath, '.claude-plugin/plugin.json'), 'utf-8');
105
+ const manifest: PluginManifest = JSON.parse(content);
106
+ addPluginSkillPaths(basePath, manifest.skills);
107
+ } catch {
108
+ // File doesn't exist or invalid JSON
109
+ }
110
+
111
+ return searchDirs;
112
+ }
113
+
114
+ /**
115
+ * Get a map of skill directory paths to plugin names from plugin manifests.
116
+ * This allows grouping skills by their parent plugin.
117
+ *
118
+ * Returns Map<AbsolutePath, PluginName>
119
+ */
120
+ export async function getPluginGroupings(basePath: string): Promise<Map<string, string>> {
121
+ const groupings = new Map<string, string>();
122
+
123
+ // Try marketplace.json (multi-plugin catalog)
124
+ try {
125
+ const content = await readFile(join(basePath, '.claude-plugin/marketplace.json'), 'utf-8');
126
+ const manifest: MarketplaceManifest = JSON.parse(content);
127
+ const pluginRoot = manifest.metadata?.pluginRoot;
128
+
129
+ // Validate pluginRoot starts with './' if provided (per Claude Code convention)
130
+ const validPluginRoot = pluginRoot === undefined || isValidRelativePath(pluginRoot);
131
+
132
+ if (validPluginRoot) {
133
+ for (const plugin of manifest.plugins ?? []) {
134
+ if (!plugin.name) continue;
135
+
136
+ // Skip remote sources (object with source/repo) - only handle local string paths
137
+ if (typeof plugin.source !== 'string' && plugin.source !== undefined) continue;
138
+
139
+ // Validate source starts with './' if provided (per Claude Code convention)
140
+ if (plugin.source !== undefined && !isValidRelativePath(plugin.source)) continue;
141
+
142
+ const pluginBase = join(basePath, pluginRoot ?? '', plugin.source ?? '');
143
+
144
+ // Validate pluginBase itself is contained
145
+ if (!isContainedIn(pluginBase, basePath)) continue;
146
+
147
+ if (plugin.skills && plugin.skills.length > 0) {
148
+ for (const skillPath of plugin.skills) {
149
+ // Validate skill path starts with './' (per Claude Code convention)
150
+ if (!isValidRelativePath(skillPath)) continue;
151
+
152
+ const skillDir = join(pluginBase, skillPath);
153
+ if (isContainedIn(skillDir, basePath)) {
154
+ // Store absolute path as key for reliable matching
155
+ groupings.set(resolve(skillDir), plugin.name);
156
+ }
157
+ }
158
+ }
159
+ }
160
+ }
161
+ } catch {
162
+ // File doesn't exist or invalid JSON
163
+ }
164
+
165
+ // Try plugin.json (single plugin at root)
166
+ try {
167
+ const content = await readFile(join(basePath, '.claude-plugin/plugin.json'), 'utf-8');
168
+ const manifest: PluginManifest = JSON.parse(content);
169
+ if (manifest.name && manifest.skills && manifest.skills.length > 0) {
170
+ for (const skillPath of manifest.skills) {
171
+ if (!isValidRelativePath(skillPath)) continue;
172
+ const skillDir = join(basePath, skillPath);
173
+ if (isContainedIn(skillDir, basePath)) {
174
+ groupings.set(resolve(skillDir), manifest.name);
175
+ }
176
+ }
177
+ }
178
+ } catch {
179
+ // File doesn't exist or invalid JSON
180
+ }
181
+
182
+ return groupings;
183
+ }