@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.
- package/LICENSE +21 -0
- package/README.md +308 -0
- package/bin/adg.ts +758 -0
- package/docs/agents-spec.md +132 -0
- package/docs/authoring.md +352 -0
- package/package.json +50 -0
- package/schemas/adg-plugin.schema.json +77 -0
- package/schemas/marketplace.schema.json +86 -0
- package/schemas/plugin-lock.schema.json +90 -0
- package/src/adapters/anthropic.ts +54 -0
- package/src/adapters/index.ts +24 -0
- package/src/adapters/openai.ts +37 -0
- package/src/adapters/reverse.ts +60 -0
- package/src/agents/claude.ts +124 -0
- package/src/agents/codex.ts +67 -0
- package/src/agents/index.ts +12 -0
- package/src/agents/registry.ts +30 -0
- package/src/agents/types.ts +47 -0
- package/src/commands/adapt.ts +36 -0
- package/src/commands/import.ts +69 -0
- package/src/commands/init.ts +146 -0
- package/src/commands/install.ts +411 -0
- package/src/commands/link.ts +61 -0
- package/src/commands/list.ts +28 -0
- package/src/commands/marketplace.ts +198 -0
- package/src/commands/migrate.ts +84 -0
- package/src/commands/multiselect-skills.ts +137 -0
- package/src/commands/remove.ts +136 -0
- package/src/commands/select-agents.ts +45 -0
- package/src/commands/select-components.ts +66 -0
- package/src/commands/select-plugins.ts +28 -0
- package/src/commands/select-scope.ts +21 -0
- package/src/commands/update.ts +85 -0
- package/src/commands/validate.ts +57 -0
- package/src/components.ts +90 -0
- package/src/deps.ts +64 -0
- package/src/fsutil.ts +38 -0
- package/src/hash.ts +61 -0
- package/src/lock.ts +57 -0
- package/src/manifest.ts +113 -0
- package/src/marketplace.ts +41 -0
- package/src/package.ts +74 -0
- package/src/paths.ts +129 -0
- package/src/semver.ts +67 -0
- package/src/skills.ts +88 -0
- package/src/sources.ts +159 -0
- package/src/types.ts +140 -0
- package/vendor/skills/LICENSE +29 -0
- package/vendor/skills/PROVENANCE.md +60 -0
- package/vendor/skills/ThirdPartyNoticeText.txt +117 -0
- package/vendor/skills/package.json +143 -0
- package/vendor/skills/src/add.ts +1999 -0
- package/vendor/skills/src/agents.ts +755 -0
- package/vendor/skills/src/blob.ts +567 -0
- package/vendor/skills/src/cli.ts +387 -0
- package/vendor/skills/src/constants.ts +3 -0
- package/vendor/skills/src/detect-agent.ts +62 -0
- package/vendor/skills/src/find.ts +357 -0
- package/vendor/skills/src/frontmatter.ts +16 -0
- package/vendor/skills/src/git-tree.ts +36 -0
- package/vendor/skills/src/git.ts +277 -0
- package/vendor/skills/src/install.ts +91 -0
- package/vendor/skills/src/installer.ts +1097 -0
- package/vendor/skills/src/list.ts +231 -0
- package/vendor/skills/src/local-lock.ts +182 -0
- package/vendor/skills/src/plugin-manifest.ts +183 -0
- package/vendor/skills/src/prompts/search-multiselect.ts +387 -0
- package/vendor/skills/src/providers/index.ts +14 -0
- package/vendor/skills/src/providers/registry.ts +51 -0
- package/vendor/skills/src/providers/types.ts +97 -0
- package/vendor/skills/src/providers/wellknown.ts +804 -0
- package/vendor/skills/src/remove.ts +323 -0
- package/vendor/skills/src/sanitize.ts +65 -0
- package/vendor/skills/src/self-cli.ts +20 -0
- package/vendor/skills/src/skill-lock.ts +329 -0
- package/vendor/skills/src/skills.ts +316 -0
- package/vendor/skills/src/source-parser.ts +438 -0
- package/vendor/skills/src/sync.ts +478 -0
- package/vendor/skills/src/telemetry.ts +186 -0
- package/vendor/skills/src/test-utils.ts +73 -0
- package/vendor/skills/src/types.ts +128 -0
- package/vendor/skills/src/update-source.ts +90 -0
- package/vendor/skills/src/update.ts +749 -0
- 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
|
+
}
|