@rbbtsn0w/adg 0.1.0-alpha.1 → 0.1.0-beta.2

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