@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,329 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
2
|
+
import { join, dirname } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { createHash } from 'crypto';
|
|
5
|
+
import { execSync } from 'child_process';
|
|
6
|
+
import pc from 'picocolors';
|
|
7
|
+
|
|
8
|
+
const AGENTS_DIR = '.agents';
|
|
9
|
+
const LOCK_FILE = '.skill-lock.json';
|
|
10
|
+
const CURRENT_VERSION = 3; // Bumped from 2 to 3 for folder hash support (GitHub tree SHA)
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Represents a single installed skill entry in the lock file.
|
|
14
|
+
*/
|
|
15
|
+
export interface SkillLockEntry {
|
|
16
|
+
/** Normalized source identifier (e.g., "owner/repo", "mintlify/bun.com") */
|
|
17
|
+
source: string;
|
|
18
|
+
/** The provider/source type (e.g., "github", "mintlify", "huggingface", "local") */
|
|
19
|
+
sourceType: string;
|
|
20
|
+
/** The original URL used to install the skill (for re-fetching updates) */
|
|
21
|
+
sourceUrl: string;
|
|
22
|
+
/** Branch or tag ref used for installation (for ref-aware updates) */
|
|
23
|
+
ref?: string;
|
|
24
|
+
/** Subpath within the source repo, if applicable */
|
|
25
|
+
skillPath?: string;
|
|
26
|
+
/**
|
|
27
|
+
* GitHub tree SHA for the entire skill folder.
|
|
28
|
+
* This hash changes when ANY file in the skill folder changes.
|
|
29
|
+
* Fetched via GitHub Trees API by the telemetry server.
|
|
30
|
+
*/
|
|
31
|
+
skillFolderHash: string;
|
|
32
|
+
/** ISO timestamp when the skill was first installed */
|
|
33
|
+
installedAt: string;
|
|
34
|
+
/** ISO timestamp when the skill was last updated */
|
|
35
|
+
updatedAt: string;
|
|
36
|
+
/** Name of the plugin this skill belongs to (if any) */
|
|
37
|
+
pluginName?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Tracks dismissed prompts so they're not shown again.
|
|
42
|
+
*/
|
|
43
|
+
export interface DismissedPrompts {
|
|
44
|
+
/** Dismissed the find-skills skill installation prompt */
|
|
45
|
+
findSkillsPrompt?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The structure of the skill lock file.
|
|
50
|
+
*/
|
|
51
|
+
export interface SkillLockFile {
|
|
52
|
+
/** Schema version for future migrations */
|
|
53
|
+
version: number;
|
|
54
|
+
/** Map of skill name to its lock entry */
|
|
55
|
+
skills: Record<string, SkillLockEntry>;
|
|
56
|
+
/** Tracks dismissed prompts */
|
|
57
|
+
dismissed?: DismissedPrompts;
|
|
58
|
+
/** Last selected agents for installation */
|
|
59
|
+
lastSelectedAgents?: string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get the path to the global skill lock file.
|
|
64
|
+
*
|
|
65
|
+
* ADG patch: anchor the lock under `.agents/` in BOTH modes so the skills and
|
|
66
|
+
* plugins domains always share one universal `~/.agents` (or
|
|
67
|
+
* `$XDG_STATE_HOME/.agents`) home — matching ADG's `globalPluginsDir()`.
|
|
68
|
+
* Upstream put the XDG variant at `$XDG_STATE_HOME/skills/`, which broke that
|
|
69
|
+
* shared root when XDG_STATE_HOME was set. See vendor/skills/PROVENANCE.md.
|
|
70
|
+
*
|
|
71
|
+
* Resolves to `$XDG_STATE_HOME/.agents/.skill-lock.json` if set,
|
|
72
|
+
* otherwise `~/.agents/.skill-lock.json`.
|
|
73
|
+
*/
|
|
74
|
+
export function getSkillLockPath(): string {
|
|
75
|
+
const root = process.env.XDG_STATE_HOME ?? homedir();
|
|
76
|
+
return join(root, AGENTS_DIR, LOCK_FILE);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Read the skill lock file.
|
|
81
|
+
* Returns an empty lock file structure if the file doesn't exist.
|
|
82
|
+
* Wipes the lock file if it's an old format (version < CURRENT_VERSION).
|
|
83
|
+
*/
|
|
84
|
+
export async function readSkillLock(): Promise<SkillLockFile> {
|
|
85
|
+
const lockPath = getSkillLockPath();
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const content = await readFile(lockPath, 'utf-8');
|
|
89
|
+
const parsed = JSON.parse(content) as SkillLockFile;
|
|
90
|
+
|
|
91
|
+
// Validate version - wipe if old format
|
|
92
|
+
if (typeof parsed.version !== 'number' || !parsed.skills) {
|
|
93
|
+
return createEmptyLockFile();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// If old version, wipe and start fresh (backwards incompatible change)
|
|
97
|
+
// v3 adds skillFolderHash - we want fresh installs to populate it
|
|
98
|
+
if (parsed.version < CURRENT_VERSION) {
|
|
99
|
+
return createEmptyLockFile();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return parsed;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
// File doesn't exist or is invalid - return empty
|
|
105
|
+
return createEmptyLockFile();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Write the skill lock file.
|
|
111
|
+
* Creates the directory if it doesn't exist.
|
|
112
|
+
*/
|
|
113
|
+
export async function writeSkillLock(lock: SkillLockFile): Promise<void> {
|
|
114
|
+
const lockPath = getSkillLockPath();
|
|
115
|
+
|
|
116
|
+
// Ensure directory exists
|
|
117
|
+
await mkdir(dirname(lockPath), { recursive: true });
|
|
118
|
+
|
|
119
|
+
// Write with pretty formatting for human readability
|
|
120
|
+
const content = JSON.stringify(lock, null, 2);
|
|
121
|
+
await writeFile(lockPath, content, 'utf-8');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Compute SHA-256 hash of content.
|
|
126
|
+
*/
|
|
127
|
+
export function computeContentHash(content: string): string {
|
|
128
|
+
return createHash('sha256').update(content, 'utf-8').digest('hex');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let _ghWarningShown = false;
|
|
132
|
+
|
|
133
|
+
/** For tests only. Resets the one-shot warning flag. */
|
|
134
|
+
export function resetGhAuthWarning(): void {
|
|
135
|
+
_ghWarningShown = false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Get GitHub token from user's environment.
|
|
140
|
+
* Tries in order:
|
|
141
|
+
* 1. GITHUB_TOKEN environment variable (silent)
|
|
142
|
+
* 2. GH_TOKEN environment variable (silent)
|
|
143
|
+
* 3. gh CLI auth token, if gh is installed. Prints a one-time warning to
|
|
144
|
+
* stderr before invoking `gh auth token`, because that subprocess call
|
|
145
|
+
* is flagged by some corporate endpoint security tooling (Defender, etc.)
|
|
146
|
+
* as credential extraction. Callers should invoke this function lazily
|
|
147
|
+
* (e.g. only after an unauthenticated request hits a rate limit) so the
|
|
148
|
+
* fallback rarely runs in practice.
|
|
149
|
+
*
|
|
150
|
+
* @returns The token string or null if not available
|
|
151
|
+
*/
|
|
152
|
+
export function getGitHubToken(): string | null {
|
|
153
|
+
// Check environment variables first (silent: user has explicitly opted in)
|
|
154
|
+
if (process.env.GITHUB_TOKEN) {
|
|
155
|
+
return process.env.GITHUB_TOKEN;
|
|
156
|
+
}
|
|
157
|
+
if (process.env.GH_TOKEN) {
|
|
158
|
+
return process.env.GH_TOKEN;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Last resort: spawn gh CLI. Warn the user once per process before doing so.
|
|
162
|
+
// ADG patch: message broadened — this resolver is now also used to reach
|
|
163
|
+
// private repos, not just to recover from a rate limit.
|
|
164
|
+
if (!_ghWarningShown) {
|
|
165
|
+
process.stderr.write(
|
|
166
|
+
`${pc.yellow('│')} ${pc.yellow('GitHub authentication needed')} — using your ${pc.cyan('gh')} login to continue.\n` +
|
|
167
|
+
`${pc.yellow('│')} ${pc.dim(`Tip: set ${pc.cyan('GITHUB_TOKEN')} to avoid this prompt, or use ${pc.cyan('--full-depth')} to clone instead.\n`)}`
|
|
168
|
+
);
|
|
169
|
+
_ghWarningShown = true;
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
const token = execSync('gh auth token', {
|
|
173
|
+
encoding: 'utf-8',
|
|
174
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
175
|
+
}).trim();
|
|
176
|
+
if (token) {
|
|
177
|
+
return token;
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
// gh not installed or not authenticated
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Fetch the tree SHA (folder hash) for a skill folder using GitHub's Trees API.
|
|
188
|
+
* This makes ONE API call to get the entire repo tree, then extracts the SHA
|
|
189
|
+
* for the specific skill folder.
|
|
190
|
+
*
|
|
191
|
+
* @param ownerRepo - GitHub owner/repo (e.g., "vercel-labs/agent-skills")
|
|
192
|
+
* @param skillPath - Path to skill folder or SKILL.md (e.g., "skills/react-best-practices/SKILL.md")
|
|
193
|
+
* @param getToken - Optional lazy token resolver. Invoked only if the
|
|
194
|
+
* unauthenticated request hits a rate limit.
|
|
195
|
+
* @param ref - Optional branch/tag ref. Defaults to trying main then master.
|
|
196
|
+
* @returns The tree SHA for the skill folder, or null if not found
|
|
197
|
+
*/
|
|
198
|
+
export async function fetchSkillFolderHash(
|
|
199
|
+
ownerRepo: string,
|
|
200
|
+
skillPath: string,
|
|
201
|
+
getToken?: (() => string | null) | null,
|
|
202
|
+
ref?: string
|
|
203
|
+
): Promise<string | null> {
|
|
204
|
+
const { fetchRepoTree, getSkillFolderHashFromTree } = await import('./blob.ts');
|
|
205
|
+
const tree = await fetchRepoTree(ownerRepo, ref, getToken ?? undefined);
|
|
206
|
+
if (!tree) return null;
|
|
207
|
+
return getSkillFolderHashFromTree(tree, skillPath);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Add or update a skill entry in the lock file.
|
|
212
|
+
*/
|
|
213
|
+
export async function addSkillToLock(
|
|
214
|
+
skillName: string,
|
|
215
|
+
entry: Omit<SkillLockEntry, 'installedAt' | 'updatedAt'>
|
|
216
|
+
): Promise<void> {
|
|
217
|
+
const lock = await readSkillLock();
|
|
218
|
+
const now = new Date().toISOString();
|
|
219
|
+
|
|
220
|
+
const existingEntry = lock.skills[skillName];
|
|
221
|
+
|
|
222
|
+
lock.skills[skillName] = {
|
|
223
|
+
...entry,
|
|
224
|
+
installedAt: existingEntry?.installedAt ?? now,
|
|
225
|
+
updatedAt: now,
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
await writeSkillLock(lock);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Remove a skill from the lock file.
|
|
233
|
+
*/
|
|
234
|
+
export async function removeSkillFromLock(skillName: string): Promise<boolean> {
|
|
235
|
+
const lock = await readSkillLock();
|
|
236
|
+
|
|
237
|
+
if (!(skillName in lock.skills)) {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
delete lock.skills[skillName];
|
|
242
|
+
await writeSkillLock(lock);
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Get a skill entry from the lock file.
|
|
248
|
+
*/
|
|
249
|
+
export async function getSkillFromLock(skillName: string): Promise<SkillLockEntry | null> {
|
|
250
|
+
const lock = await readSkillLock();
|
|
251
|
+
return lock.skills[skillName] ?? null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Get all skills from the lock file.
|
|
256
|
+
*/
|
|
257
|
+
export async function getAllLockedSkills(): Promise<Record<string, SkillLockEntry>> {
|
|
258
|
+
const lock = await readSkillLock();
|
|
259
|
+
return lock.skills;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Get skills grouped by source for batch update operations.
|
|
264
|
+
*/
|
|
265
|
+
export async function getSkillsBySource(): Promise<
|
|
266
|
+
Map<string, { skills: string[]; entry: SkillLockEntry }>
|
|
267
|
+
> {
|
|
268
|
+
const lock = await readSkillLock();
|
|
269
|
+
const bySource = new Map<string, { skills: string[]; entry: SkillLockEntry }>();
|
|
270
|
+
|
|
271
|
+
for (const [skillName, entry] of Object.entries(lock.skills)) {
|
|
272
|
+
const existing = bySource.get(entry.source);
|
|
273
|
+
if (existing) {
|
|
274
|
+
existing.skills.push(skillName);
|
|
275
|
+
} else {
|
|
276
|
+
bySource.set(entry.source, { skills: [skillName], entry });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return bySource;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Create an empty lock file structure.
|
|
285
|
+
*/
|
|
286
|
+
function createEmptyLockFile(): SkillLockFile {
|
|
287
|
+
return {
|
|
288
|
+
version: CURRENT_VERSION,
|
|
289
|
+
skills: {},
|
|
290
|
+
dismissed: {},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Check if a prompt has been dismissed.
|
|
296
|
+
*/
|
|
297
|
+
export async function isPromptDismissed(promptKey: keyof DismissedPrompts): Promise<boolean> {
|
|
298
|
+
const lock = await readSkillLock();
|
|
299
|
+
return lock.dismissed?.[promptKey] === true;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Mark a prompt as dismissed.
|
|
304
|
+
*/
|
|
305
|
+
export async function dismissPrompt(promptKey: keyof DismissedPrompts): Promise<void> {
|
|
306
|
+
const lock = await readSkillLock();
|
|
307
|
+
if (!lock.dismissed) {
|
|
308
|
+
lock.dismissed = {};
|
|
309
|
+
}
|
|
310
|
+
lock.dismissed[promptKey] = true;
|
|
311
|
+
await writeSkillLock(lock);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Get the last selected agents.
|
|
316
|
+
*/
|
|
317
|
+
export async function getLastSelectedAgents(): Promise<string[] | undefined> {
|
|
318
|
+
const lock = await readSkillLock();
|
|
319
|
+
return lock.lastSelectedAgents;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Save the selected agents to the lock file.
|
|
324
|
+
*/
|
|
325
|
+
export async function saveSelectedAgents(agents: string[]): Promise<void> {
|
|
326
|
+
const lock = await readSkillLock();
|
|
327
|
+
lock.lastSelectedAgents = agents;
|
|
328
|
+
await writeSkillLock(lock);
|
|
329
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'fs/promises';
|
|
2
|
+
import { join, basename, dirname, resolve, normalize, sep, relative } from 'path';
|
|
3
|
+
import { parseFrontmatter } from './frontmatter.ts';
|
|
4
|
+
import { sanitizeMetadata } from './sanitize.ts';
|
|
5
|
+
import type { Skill } from './types.ts';
|
|
6
|
+
import { getPluginSkillPaths, getPluginGroupings } from './plugin-manifest.ts';
|
|
7
|
+
import { readLocalLock } from './local-lock.ts';
|
|
8
|
+
|
|
9
|
+
const SKIP_DIRS = ['node_modules', '.git', 'dist', 'build', '__pycache__'];
|
|
10
|
+
|
|
11
|
+
const AGENT_PROJECT_SKILL_DIRS = [
|
|
12
|
+
'.agents/skills',
|
|
13
|
+
'.claude/skills',
|
|
14
|
+
'.cline/skills',
|
|
15
|
+
'.codebuddy/skills',
|
|
16
|
+
'.codex/skills',
|
|
17
|
+
'.commandcode/skills',
|
|
18
|
+
'.continue/skills',
|
|
19
|
+
'.github/skills',
|
|
20
|
+
'.goose/skills',
|
|
21
|
+
'.iflow/skills',
|
|
22
|
+
'.junie/skills',
|
|
23
|
+
'.kilocode/skills',
|
|
24
|
+
'.kiro/skills',
|
|
25
|
+
'.mux/skills',
|
|
26
|
+
'.neovate/skills',
|
|
27
|
+
'.opencode/skills',
|
|
28
|
+
'.openhands/skills',
|
|
29
|
+
'.pi/skills',
|
|
30
|
+
'.qoder/skills',
|
|
31
|
+
'.roo/skills',
|
|
32
|
+
'.trae/skills',
|
|
33
|
+
'.windsurf/skills',
|
|
34
|
+
'.zencoder/skills',
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
function normalizeSkillName(name: string): string {
|
|
38
|
+
return name.toLowerCase().replace(/[\s_]+/g, '-');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeRelativePath(path: string): string {
|
|
42
|
+
return path.split(sep).join('/').replace(/\/+/g, '/');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Check if internal skills should be installed.
|
|
47
|
+
* Internal skills are hidden by default unless INSTALL_INTERNAL_SKILLS=1 is set.
|
|
48
|
+
*/
|
|
49
|
+
export function shouldInstallInternalSkills(): boolean {
|
|
50
|
+
const envValue = process.env.INSTALL_INTERNAL_SKILLS;
|
|
51
|
+
return envValue === '1' || envValue === 'true';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function hasSkillMd(dir: string): Promise<boolean> {
|
|
55
|
+
try {
|
|
56
|
+
const skillPath = join(dir, 'SKILL.md');
|
|
57
|
+
const stats = await stat(skillPath);
|
|
58
|
+
return stats.isFile();
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function parseSkillMd(
|
|
65
|
+
skillMdPath: string,
|
|
66
|
+
options?: { includeInternal?: boolean }
|
|
67
|
+
): Promise<Skill | null> {
|
|
68
|
+
try {
|
|
69
|
+
const content = await readFile(skillMdPath, 'utf-8');
|
|
70
|
+
const { data } = parseFrontmatter(content);
|
|
71
|
+
|
|
72
|
+
if (!data.name || !data.description) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Ensure name and description are strings (YAML can parse numbers, booleans, etc.)
|
|
77
|
+
if (typeof data.name !== 'string' || typeof data.description !== 'string') {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ADG patch: narrow `metadata` from the loosely-typed frontmatter (`unknown`)
|
|
82
|
+
// before reading it, so the file typechecks under the root tsconfig without
|
|
83
|
+
// changing behavior. See vendor/skills/PROVENANCE.md.
|
|
84
|
+
const metadata =
|
|
85
|
+
data.metadata && typeof data.metadata === 'object'
|
|
86
|
+
? (data.metadata as Record<string, unknown>)
|
|
87
|
+
: undefined;
|
|
88
|
+
|
|
89
|
+
// Skip internal skills unless:
|
|
90
|
+
// 1. INSTALL_INTERNAL_SKILLS=1 is set, OR
|
|
91
|
+
// 2. includeInternal option is true (e.g., when user explicitly requests a skill)
|
|
92
|
+
const isInternal = metadata?.internal === true;
|
|
93
|
+
if (isInternal && !shouldInstallInternalSkills() && !options?.includeInternal) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
name: sanitizeMetadata(data.name),
|
|
99
|
+
description: sanitizeMetadata(data.description),
|
|
100
|
+
path: dirname(skillMdPath),
|
|
101
|
+
rawContent: content,
|
|
102
|
+
metadata,
|
|
103
|
+
};
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function findSkillDirs(dir: string, depth = 0, maxDepth = 5): Promise<string[]> {
|
|
110
|
+
if (depth > maxDepth) return [];
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const [hasSkill, entries] = await Promise.all([
|
|
114
|
+
hasSkillMd(dir),
|
|
115
|
+
readdir(dir, { withFileTypes: true }).catch(() => []),
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
const currentDir = hasSkill ? [dir] : [];
|
|
119
|
+
|
|
120
|
+
// Search subdirectories in parallel
|
|
121
|
+
const subDirResults = await Promise.all(
|
|
122
|
+
entries
|
|
123
|
+
.filter((entry) => entry.isDirectory() && !SKIP_DIRS.includes(entry.name))
|
|
124
|
+
.map((entry) => findSkillDirs(join(dir, entry.name), depth + 1, maxDepth))
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
return [...currentDir, ...subDirResults.flat()];
|
|
128
|
+
} catch {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface DiscoverSkillsOptions {
|
|
134
|
+
/** Include internal skills (e.g., when user explicitly requests a skill by name) */
|
|
135
|
+
includeInternal?: boolean;
|
|
136
|
+
/** Search all subdirectories even when a root SKILL.md exists */
|
|
137
|
+
fullDepth?: boolean;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Validates that a resolved subpath stays within the base directory.
|
|
142
|
+
* Prevents path traversal attacks where subpath contains ".." segments
|
|
143
|
+
* that would escape the cloned repository directory.
|
|
144
|
+
*/
|
|
145
|
+
export function isSubpathSafe(basePath: string, subpath: string): boolean {
|
|
146
|
+
const normalizedBase = normalize(resolve(basePath));
|
|
147
|
+
const normalizedTarget = normalize(resolve(join(basePath, subpath)));
|
|
148
|
+
|
|
149
|
+
return normalizedTarget.startsWith(normalizedBase + sep) || normalizedTarget === normalizedBase;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function discoverSkills(
|
|
153
|
+
basePath: string,
|
|
154
|
+
subpath?: string,
|
|
155
|
+
options?: DiscoverSkillsOptions
|
|
156
|
+
): Promise<Skill[]> {
|
|
157
|
+
const skills: Skill[] = [];
|
|
158
|
+
const seenNames = new Set<string>();
|
|
159
|
+
const localLock = await readLocalLock(basePath);
|
|
160
|
+
const lockedSkillNames = new Set(Object.keys(localLock.skills).map(normalizeSkillName));
|
|
161
|
+
|
|
162
|
+
// Validate subpath doesn't escape basePath (prevent path traversal)
|
|
163
|
+
if (subpath && !isSubpathSafe(basePath, subpath)) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`Invalid subpath: "${subpath}" resolves outside the repository directory. Subpath must not contain ".." segments that escape the base path.`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const searchPath = subpath ? join(basePath, subpath) : basePath;
|
|
170
|
+
|
|
171
|
+
// Get plugin groupings to map skills to their parent plugin
|
|
172
|
+
// We search for plugin definitions from the base search path
|
|
173
|
+
const pluginGroupings = await getPluginGroupings(searchPath);
|
|
174
|
+
|
|
175
|
+
// Helper to assign plugin name if available
|
|
176
|
+
const enhanceSkill = (skill: Skill) => {
|
|
177
|
+
const resolvedPath = resolve(skill.path);
|
|
178
|
+
if (pluginGroupings.has(resolvedPath)) {
|
|
179
|
+
skill.pluginName = pluginGroupings.get(resolvedPath);
|
|
180
|
+
}
|
|
181
|
+
return skill;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const isInstalledProjectSkill = (skill: Skill): boolean => {
|
|
185
|
+
if (lockedSkillNames.size === 0) return false;
|
|
186
|
+
|
|
187
|
+
const relativeDir = normalizeRelativePath(relative(basePath, skill.path));
|
|
188
|
+
const isAgentSkillPath = AGENT_PROJECT_SKILL_DIRS.some(
|
|
189
|
+
(dir) => relativeDir === dir || relativeDir.startsWith(`${dir}/`)
|
|
190
|
+
);
|
|
191
|
+
if (!isAgentSkillPath) return false;
|
|
192
|
+
|
|
193
|
+
const skillName = normalizeSkillName(skill.name);
|
|
194
|
+
const directoryName = normalizeSkillName(basename(skill.path));
|
|
195
|
+
return lockedSkillNames.has(skillName) || lockedSkillNames.has(directoryName);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// If pointing directly at a skill, add it (and return early unless fullDepth is set).
|
|
199
|
+
// If the root SKILL.md is an installed project skill tracked by skills-lock.json,
|
|
200
|
+
// ignore it and continue scanning in case the repo also contains source skills.
|
|
201
|
+
if (await hasSkillMd(searchPath)) {
|
|
202
|
+
let skill = await parseSkillMd(join(searchPath, 'SKILL.md'), options);
|
|
203
|
+
if (skill) {
|
|
204
|
+
if (!isInstalledProjectSkill(skill)) {
|
|
205
|
+
skill = enhanceSkill(skill);
|
|
206
|
+
skills.push(skill);
|
|
207
|
+
seenNames.add(skill.name);
|
|
208
|
+
// Only return early if fullDepth is not set
|
|
209
|
+
if (!options?.fullDepth) {
|
|
210
|
+
return skills;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Search common skill locations first
|
|
217
|
+
const prioritySearchDirs = [
|
|
218
|
+
searchPath,
|
|
219
|
+
join(searchPath, 'skills'),
|
|
220
|
+
join(searchPath, 'skills/.curated'),
|
|
221
|
+
join(searchPath, 'skills/.experimental'),
|
|
222
|
+
join(searchPath, 'skills/.system'),
|
|
223
|
+
...AGENT_PROJECT_SKILL_DIRS.map((dir) => join(searchPath, dir)),
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
// Known skill container dirs are walked one extra level deep so layouts
|
|
227
|
+
// like `skills/<category>/<skill>/SKILL.md` are discovered without
|
|
228
|
+
// requiring `--full-depth`. The repo root (first entry) keeps its
|
|
229
|
+
// existing depth-1 behavior to avoid surfacing unrelated `SKILL.md`
|
|
230
|
+
// files (e.g. `examples/foo/SKILL.md`), and plugin-manifest-declared
|
|
231
|
+
// dirs (appended below) stay at depth-1 to honor the manifest spec.
|
|
232
|
+
const deepContainerDirs = new Set(prioritySearchDirs.slice(1));
|
|
233
|
+
|
|
234
|
+
// Add skill paths declared in plugin manifests
|
|
235
|
+
prioritySearchDirs.push(...(await getPluginSkillPaths(searchPath)));
|
|
236
|
+
|
|
237
|
+
const tryAddSkillAt = async (skillDir: string): Promise<boolean> => {
|
|
238
|
+
if (!(await hasSkillMd(skillDir))) return false;
|
|
239
|
+
let skill = await parseSkillMd(join(skillDir, 'SKILL.md'), options);
|
|
240
|
+
if (!skill || seenNames.has(skill.name)) return true;
|
|
241
|
+
if (isInstalledProjectSkill(skill)) return true;
|
|
242
|
+
skill = enhanceSkill(skill);
|
|
243
|
+
skills.push(skill);
|
|
244
|
+
seenNames.add(skill.name);
|
|
245
|
+
return true;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
for (const dir of prioritySearchDirs) {
|
|
249
|
+
const walkDeep = deepContainerDirs.has(dir);
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
253
|
+
|
|
254
|
+
for (const entry of entries) {
|
|
255
|
+
if (!entry.isDirectory()) continue;
|
|
256
|
+
|
|
257
|
+
const childDir = join(dir, entry.name);
|
|
258
|
+
const foundAtChild = await tryAddSkillAt(childDir);
|
|
259
|
+
|
|
260
|
+
// Don't descend past a discovered SKILL.md (matches the existing
|
|
261
|
+
// flat-layout semantics) and don't go deeper inside non-container
|
|
262
|
+
// priority dirs.
|
|
263
|
+
if (foundAtChild || !walkDeep) continue;
|
|
264
|
+
if (SKIP_DIRS.includes(entry.name)) continue;
|
|
265
|
+
|
|
266
|
+
// Walk one extra level for catalog layouts.
|
|
267
|
+
try {
|
|
268
|
+
const grandEntries = await readdir(childDir, { withFileTypes: true });
|
|
269
|
+
for (const grand of grandEntries) {
|
|
270
|
+
if (!grand.isDirectory() || SKIP_DIRS.includes(grand.name)) continue;
|
|
271
|
+
await tryAddSkillAt(join(childDir, grand.name));
|
|
272
|
+
}
|
|
273
|
+
} catch {
|
|
274
|
+
// Child dir unreadable; skip silently.
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
} catch {
|
|
278
|
+
// Directory doesn't exist
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Fall back to recursive search if nothing found, or if fullDepth is set
|
|
283
|
+
if (skills.length === 0 || options?.fullDepth) {
|
|
284
|
+
const allSkillDirs = await findSkillDirs(searchPath);
|
|
285
|
+
|
|
286
|
+
for (const skillDir of allSkillDirs) {
|
|
287
|
+
let skill = await parseSkillMd(join(skillDir, 'SKILL.md'), options);
|
|
288
|
+
if (skill && !seenNames.has(skill.name) && !isInstalledProjectSkill(skill)) {
|
|
289
|
+
skill = enhanceSkill(skill);
|
|
290
|
+
skills.push(skill);
|
|
291
|
+
seenNames.add(skill.name);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return skills;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function getSkillDisplayName(skill: Skill): string {
|
|
300
|
+
return skill.name || basename(skill.path);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Filter skills based on user input (case-insensitive direct matching).
|
|
305
|
+
* Multi-word skill names must be quoted on the command line.
|
|
306
|
+
*/
|
|
307
|
+
export function filterSkills(skills: Skill[], inputNames: string[]): Skill[] {
|
|
308
|
+
const normalizedInputs = inputNames.map((n) => n.toLowerCase());
|
|
309
|
+
|
|
310
|
+
return skills.filter((skill) => {
|
|
311
|
+
const name = skill.name.toLowerCase();
|
|
312
|
+
const displayName = getSkillDisplayName(skill).toLowerCase();
|
|
313
|
+
|
|
314
|
+
return normalizedInputs.some((input) => input === name || input === displayName);
|
|
315
|
+
});
|
|
316
|
+
}
|