@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,1999 @@
1
+ import * as p from '@clack/prompts';
2
+ import pc from 'picocolors';
3
+ import { existsSync } from 'fs';
4
+ import { homedir } from 'os';
5
+ import { sep, join, dirname } from 'path';
6
+ import { parseSource, getOwnerRepo, parseOwnerRepo, isRepoPrivate } from './source-parser.ts';
7
+ import { stripTerminalEscapes } from './sanitize.ts';
8
+ import { searchMultiselect } from './prompts/search-multiselect.ts';
9
+
10
+ // Helper to check if a value is a cancel symbol (works with both clack and our custom prompts)
11
+ const isCancelled = (value: unknown): value is symbol => typeof value === 'symbol';
12
+
13
+ /**
14
+ * Check if a source identifier (owner/repo format) represents a private GitHub repo.
15
+ * Returns true if private, false if public, null if unable to determine or not a GitHub repo.
16
+ */
17
+ async function isSourcePrivate(source: string): Promise<boolean | null> {
18
+ const ownerRepo = parseOwnerRepo(source);
19
+ if (!ownerRepo) {
20
+ // Not in owner/repo format, assume not private (could be other providers)
21
+ return false;
22
+ }
23
+ return isRepoPrivate(ownerRepo.owner, ownerRepo.repo);
24
+ }
25
+
26
+ export function getLockSource(parsedUrl: string, normalizedSource: string | null): string | null {
27
+ // Preserve SSH URLs in lock files instead of normalizing to owner/repo shorthand.
28
+ // When normalizedSource is used, parseSource() later resolves it to HTTPS,
29
+ // breaking restore for private repos that require SSH authentication.
30
+ const isSSH = parsedUrl.startsWith('git@') || parsedUrl.startsWith('ssh://');
31
+ return isSSH ? parsedUrl : normalizedSource;
32
+ }
33
+ import { cloneRepo, cleanupTempDir, GitCloneError } from './git.ts';
34
+ import { gitTreeShaForFolder } from './git-tree.ts';
35
+ import { discoverSkills, getSkillDisplayName, filterSkills } from './skills.ts';
36
+ import {
37
+ installSkillForAgent,
38
+ installBlobSkillForAgent,
39
+ isSkillInstalled,
40
+ getCanonicalPath,
41
+ installWellKnownSkillForAgent,
42
+ type InstallMode,
43
+ } from './installer.ts';
44
+ import {
45
+ detectInstalledAgents,
46
+ agents,
47
+ getUniversalAgents,
48
+ getVisibleUniversalAgents,
49
+ getNonUniversalAgents,
50
+ isUniversalAgent,
51
+ } from './agents.ts';
52
+ import {
53
+ track,
54
+ setVersion,
55
+ fetchAuditData,
56
+ type AuditResponse,
57
+ type PartnerAudit,
58
+ } from './telemetry.ts';
59
+ import { detectAgent, getAgentType } from './detect-agent.ts';
60
+ import { wellKnownProvider, type WellKnownSkill } from './providers/index.ts';
61
+ import {
62
+ addSkillToLock,
63
+ fetchSkillFolderHash,
64
+ getGitHubToken,
65
+ isPromptDismissed,
66
+ dismissPrompt,
67
+ getLastSelectedAgents,
68
+ saveSelectedAgents,
69
+ } from './skill-lock.ts';
70
+ import { addSkillToLocalLock, computeSkillFolderHash } from './local-lock.ts';
71
+ import type { Skill, AgentType } from './types.ts';
72
+ import {
73
+ tryBlobInstall,
74
+ BLOB_ALLOWED_REPOS,
75
+ getSkillFolderHashFromTree,
76
+ fetchRepoTree,
77
+ type BlobSkill,
78
+ type BlobInstallResult,
79
+ } from './blob.ts';
80
+ import packageJson from '../package.json' with { type: 'json' };
81
+ export function initTelemetry(version: string): void {
82
+ setVersion(version);
83
+ }
84
+
85
+ // ─── Security Advisory ───
86
+
87
+ function riskLabel(risk: string): string {
88
+ switch (risk) {
89
+ case 'critical':
90
+ return pc.red(pc.bold('Critical Risk'));
91
+ case 'high':
92
+ return pc.red('High Risk');
93
+ case 'medium':
94
+ return pc.yellow('Med Risk');
95
+ case 'low':
96
+ return pc.green('Low Risk');
97
+ case 'safe':
98
+ return pc.green('Safe');
99
+ default:
100
+ return pc.dim('--');
101
+ }
102
+ }
103
+
104
+ function socketLabel(audit: PartnerAudit | undefined): string {
105
+ if (!audit) return pc.dim('--');
106
+ const count = audit.alerts ?? 0;
107
+ return count > 0 ? pc.red(`${count} alert${count !== 1 ? 's' : ''}`) : pc.green('0 alerts');
108
+ }
109
+
110
+ /** Pad a string to a given visible width (ignoring ANSI escape codes). */
111
+ function padEnd(str: string, width: number): string {
112
+ // Strip ANSI codes to measure visible length
113
+ const visible = stripTerminalEscapes(str);
114
+ const pad = Math.max(0, width - visible.length);
115
+ return str + ' '.repeat(pad);
116
+ }
117
+
118
+ /**
119
+ * Render a compact security table showing partner audit results.
120
+ * Returns the lines to display, or empty array if no data.
121
+ */
122
+ function buildSecurityLines(
123
+ auditData: AuditResponse | null,
124
+ skills: Array<{ slug: string; displayName: string }>,
125
+ source: string
126
+ ): string[] {
127
+ if (!auditData) return [];
128
+
129
+ // Check if we have any audit data at all
130
+ const hasAny = skills.some((s) => {
131
+ const data = auditData[s.slug];
132
+ return data && Object.keys(data).length > 0;
133
+ });
134
+ if (!hasAny) return [];
135
+
136
+ // Compute column width for skill names
137
+ const nameWidth = Math.min(Math.max(...skills.map((s) => s.displayName.length)), 36);
138
+
139
+ // Header
140
+ const lines: string[] = [];
141
+ const header =
142
+ padEnd('', nameWidth + 2) +
143
+ padEnd(pc.dim('Gen'), 18) +
144
+ padEnd(pc.dim('Socket'), 18) +
145
+ pc.dim('Snyk');
146
+ lines.push(header);
147
+
148
+ // Rows
149
+ for (const skill of skills) {
150
+ const data = auditData[skill.slug];
151
+ const name =
152
+ skill.displayName.length > nameWidth
153
+ ? skill.displayName.slice(0, nameWidth - 1) + '\u2026'
154
+ : skill.displayName;
155
+
156
+ const ath = data?.ath ? riskLabel(data.ath.risk) : pc.dim('--');
157
+ const socket = data?.socket ? socketLabel(data.socket) : pc.dim('--');
158
+ const snyk = data?.snyk ? riskLabel(data.snyk.risk) : pc.dim('--');
159
+
160
+ lines.push(padEnd(pc.cyan(name), nameWidth + 2) + padEnd(ath, 18) + padEnd(socket, 18) + snyk);
161
+ }
162
+
163
+ // Footer link
164
+ lines.push('');
165
+ lines.push(`${pc.dim('Details:')} ${pc.dim(`https://skills.sh/${source}`)}`);
166
+
167
+ return lines;
168
+ }
169
+
170
+ /**
171
+ * Shortens a path for display: replaces homedir with ~ and cwd with .
172
+ * Handles both Unix and Windows path separators.
173
+ */
174
+ function shortenPath(fullPath: string, cwd: string): string {
175
+ const home = homedir();
176
+ // Ensure we match complete path segments by checking for separator after the prefix
177
+ if (fullPath === home || fullPath.startsWith(home + sep)) {
178
+ return '~' + fullPath.slice(home.length);
179
+ }
180
+ if (fullPath === cwd || fullPath.startsWith(cwd + sep)) {
181
+ return '.' + fullPath.slice(cwd.length);
182
+ }
183
+ return fullPath;
184
+ }
185
+
186
+ /**
187
+ * Formats a list of items, truncating if too many
188
+ */
189
+ function formatList(items: string[], maxShow: number = 5): string {
190
+ if (items.length <= maxShow) {
191
+ return items.join(', ');
192
+ }
193
+ const shown = items.slice(0, maxShow);
194
+ const remaining = items.length - maxShow;
195
+ return `${shown.join(', ')} +${remaining} more`;
196
+ }
197
+
198
+ /**
199
+ * Splits agents into universal and non-universal (symlinked) groups.
200
+ * Returns display names for each group.
201
+ */
202
+ function splitAgentsByType(agentTypes: AgentType[]): {
203
+ universal: string[];
204
+ symlinked: string[];
205
+ } {
206
+ const universal: string[] = [];
207
+ const symlinked: string[] = [];
208
+
209
+ for (const a of agentTypes) {
210
+ if (isUniversalAgent(a)) {
211
+ universal.push(agents[a].displayName);
212
+ } else {
213
+ symlinked.push(agents[a].displayName);
214
+ }
215
+ }
216
+
217
+ return { universal, symlinked };
218
+ }
219
+
220
+ /**
221
+ * Builds summary lines showing universal vs symlinked agents
222
+ */
223
+ function buildAgentSummaryLines(targetAgents: AgentType[], installMode: InstallMode): string[] {
224
+ const lines: string[] = [];
225
+ const { universal, symlinked } = splitAgentsByType(targetAgents);
226
+
227
+ if (installMode === 'symlink') {
228
+ if (universal.length > 0) {
229
+ lines.push(` ${pc.green('universal:')} ${formatList(universal)}`);
230
+ }
231
+ if (symlinked.length > 0) {
232
+ lines.push(` ${pc.dim('symlink →')} ${formatList(symlinked)}`);
233
+ }
234
+ } else {
235
+ // Copy mode - all agents get copies
236
+ const allNames = targetAgents.map((a) => agents[a].displayName);
237
+ lines.push(` ${pc.dim('copy →')} ${formatList(allNames)}`);
238
+ }
239
+
240
+ return lines;
241
+ }
242
+
243
+ /**
244
+ * Ensures universal agents are always included in the target agents list.
245
+ * Used when -y flag is passed or when auto-selecting agents.
246
+ */
247
+ function ensureUniversalAgents(targetAgents: AgentType[]): AgentType[] {
248
+ const universalAgents = getUniversalAgents();
249
+ const result = [...targetAgents];
250
+
251
+ for (const ua of universalAgents) {
252
+ if (!result.includes(ua)) {
253
+ result.push(ua);
254
+ }
255
+ }
256
+
257
+ return result;
258
+ }
259
+
260
+ /**
261
+ * Builds result lines from installation results, splitting by universal vs symlinked
262
+ */
263
+ function buildResultLines(
264
+ results: Array<{
265
+ agent: string;
266
+ symlinkFailed?: boolean;
267
+ skipped?: boolean;
268
+ }>,
269
+ targetAgents: AgentType[]
270
+ ): string[] {
271
+ const lines: string[] = [];
272
+
273
+ // Split target agents by type
274
+ const { universal, symlinked: symlinkAgents } = splitAgentsByType(targetAgents);
275
+
276
+ // For symlink results, also track which ones actually succeeded vs failed
277
+ // Exclude skipped agents (those whose config dir doesn't exist in the project)
278
+ const successfulSymlinks = results
279
+ .filter((r) => !r.symlinkFailed && !r.skipped && !universal.includes(r.agent))
280
+ .map((r) => r.agent);
281
+ const failedSymlinks = results.filter((r) => r.symlinkFailed && !r.skipped).map((r) => r.agent);
282
+
283
+ if (universal.length > 0) {
284
+ lines.push(` ${pc.green('universal:')} ${formatList(universal)}`);
285
+ }
286
+ if (successfulSymlinks.length > 0) {
287
+ lines.push(` ${pc.dim('symlinked:')} ${formatList(successfulSymlinks)}`);
288
+ }
289
+ if (failedSymlinks.length > 0) {
290
+ lines.push(` ${pc.yellow('copied:')} ${formatList(failedSymlinks)}`);
291
+ }
292
+
293
+ return lines;
294
+ }
295
+
296
+ /**
297
+ * Wrapper around p.multiselect that adds a hint for keyboard usage.
298
+ * Accepts options with required labels (matching our usage pattern).
299
+ */
300
+ function multiselect<Value>(opts: {
301
+ message: string;
302
+ options: Array<{ value: Value; label: string; hint?: string }>;
303
+ initialValues?: Value[];
304
+ required?: boolean;
305
+ }) {
306
+ return p.multiselect({
307
+ ...opts,
308
+ // Cast is safe: our options always have labels, which satisfies p.Option requirements
309
+ options: opts.options as p.Option<Value>[],
310
+ message: `${opts.message} ${pc.dim('(space to toggle)')}`,
311
+ }) as Promise<Value[] | symbol>;
312
+ }
313
+
314
+ /**
315
+ * Prompts the user to select agents using interactive search.
316
+ * Pre-selects the last used agents if available.
317
+ * Saves the selection for future use.
318
+ */
319
+ export async function promptForAgents(
320
+ message: string,
321
+ choices: Array<{ value: AgentType; label: string; hint?: string }>
322
+ ): Promise<AgentType[] | symbol> {
323
+ // Get last selected agents to pre-select
324
+ let lastSelected: string[] | undefined;
325
+ try {
326
+ lastSelected = await getLastSelectedAgents();
327
+ } catch {
328
+ // Silently ignore errors reading lock file
329
+ }
330
+
331
+ const validAgents = choices.map((c) => c.value);
332
+
333
+ // Default agents to pre-select when no valid history exists
334
+ const defaultAgents: AgentType[] = ['claude-code', 'opencode', 'codex'];
335
+ const defaultValues = defaultAgents.filter((a) => validAgents.includes(a));
336
+
337
+ let initialValues: AgentType[] = [];
338
+
339
+ if (lastSelected && lastSelected.length > 0) {
340
+ // Filter stored agents against currently valid agents
341
+ initialValues = lastSelected.filter((a) => validAgents.includes(a as AgentType)) as AgentType[];
342
+ }
343
+
344
+ // If no valid selection from history, use defaults
345
+ if (initialValues.length === 0) {
346
+ initialValues = defaultValues;
347
+ }
348
+
349
+ const selected = await searchMultiselect({
350
+ message,
351
+ items: choices,
352
+ initialSelected: initialValues,
353
+ required: true,
354
+ });
355
+
356
+ if (!isCancelled(selected)) {
357
+ // Save selection for next time
358
+ try {
359
+ await saveSelectedAgents(selected as string[]);
360
+ } catch {
361
+ // Silently ignore errors writing lock file
362
+ }
363
+ }
364
+
365
+ return selected as AgentType[] | symbol;
366
+ }
367
+
368
+ /**
369
+ * Interactive agent selection using fuzzy search.
370
+ * Shows universal agents as locked (always selected), and other agents as selectable.
371
+ */
372
+ async function selectAgentsInteractive(options: {
373
+ global?: boolean;
374
+ }): Promise<AgentType[] | symbol> {
375
+ // Filter out agents that don't support global installation when --global is used
376
+ const supportsGlobalFilter = (a: AgentType) => !options.global || agents[a].globalSkillsDir;
377
+
378
+ const universalAgents = getUniversalAgents().filter(supportsGlobalFilter);
379
+ const visibleUniversalAgents = getVisibleUniversalAgents().filter(supportsGlobalFilter);
380
+ const otherAgents = getNonUniversalAgents().filter(supportsGlobalFilter);
381
+
382
+ // Universal agents shown as locked section
383
+ const universalSection = {
384
+ title: 'Universal (.agents/skills)',
385
+ items: visibleUniversalAgents.map((a) => ({
386
+ value: a,
387
+ label: agents[a].displayName,
388
+ })),
389
+ hiddenCount: universalAgents.length - visibleUniversalAgents.length,
390
+ };
391
+
392
+ // Other agents are selectable with their skillsDir as hint
393
+ const otherChoices = otherAgents.map((a) => ({
394
+ value: a,
395
+ label: agents[a].displayName,
396
+ hint: options.global ? agents[a].globalSkillsDir! : agents[a].skillsDir,
397
+ }));
398
+
399
+ // Get last selected agents (filter to only non-universal ones for initial selection)
400
+ let lastSelected: string[] | undefined;
401
+ try {
402
+ lastSelected = await getLastSelectedAgents();
403
+ } catch {
404
+ // Silently ignore errors
405
+ }
406
+
407
+ const initialSelected = lastSelected
408
+ ? (lastSelected.filter(
409
+ (a) => otherAgents.includes(a as AgentType) && !universalAgents.includes(a as AgentType)
410
+ ) as AgentType[])
411
+ : [];
412
+
413
+ const selected = await searchMultiselect({
414
+ message: 'Which agents do you want to install to?',
415
+ items: otherChoices,
416
+ initialSelected,
417
+ lockedSection: universalSection,
418
+ });
419
+
420
+ if (!isCancelled(selected)) {
421
+ // Save selection (all agents including universal)
422
+ try {
423
+ await saveSelectedAgents(selected as string[]);
424
+ } catch {
425
+ // Silently ignore errors
426
+ }
427
+ }
428
+
429
+ return selected as AgentType[] | symbol;
430
+ }
431
+
432
+ const version = packageJson.version;
433
+ setVersion(version);
434
+
435
+ export interface AddOptions {
436
+ global?: boolean;
437
+ agent?: string[];
438
+ yes?: boolean;
439
+ skill?: string[];
440
+ list?: boolean;
441
+ all?: boolean;
442
+ fullDepth?: boolean;
443
+ copy?: boolean;
444
+ dangerouslyAcceptOpenclawRisks?: boolean;
445
+ }
446
+
447
+ /**
448
+ * Handle skills from a well-known endpoint (RFC 8615).
449
+ * Discovers skills from /.well-known/agent-skills/index.json (preferred)
450
+ * or /.well-known/skills/index.json (legacy fallback).
451
+ */
452
+ async function handleWellKnownSkills(
453
+ source: string,
454
+ url: string,
455
+ options: AddOptions,
456
+ spinner: ReturnType<typeof p.spinner>
457
+ ): Promise<void> {
458
+ spinner.start('Discovering skills from well-known endpoint...');
459
+
460
+ // Fetch all skills from the well-known endpoint
461
+ const skills = await wellKnownProvider.fetchAllSkills(url);
462
+
463
+ if (skills.length === 0) {
464
+ spinner.stop(pc.red('No skills found'));
465
+ p.outro(
466
+ pc.red(
467
+ 'No skills found at this URL. Make sure the server has a /.well-known/agent-skills/index.json or /.well-known/skills/index.json file.'
468
+ )
469
+ );
470
+ process.exit(1);
471
+ }
472
+
473
+ spinner.stop(`Found ${pc.green(skills.length)} skill${skills.length > 1 ? 's' : ''}`);
474
+
475
+ // Log discovered skills
476
+ for (const skill of skills) {
477
+ p.log.info(`Skill: ${pc.cyan(skill.installName)}`);
478
+ p.log.message(pc.dim(skill.description));
479
+ if (skill.files.size > 1) {
480
+ p.log.message(pc.dim(` Files: ${Array.from(skill.files.keys()).join(', ')}`));
481
+ }
482
+ }
483
+
484
+ if (options.list) {
485
+ console.log();
486
+ p.log.step(pc.bold('Available Skills'));
487
+ for (const skill of skills) {
488
+ p.log.message(` ${pc.cyan(skill.installName)}`);
489
+ p.log.message(` ${pc.dim(skill.description)}`);
490
+ if (skill.files.size > 1) {
491
+ p.log.message(` ${pc.dim(`Files: ${skill.files.size}`)}`);
492
+ }
493
+ }
494
+ console.log();
495
+ p.outro('Run without --list to install');
496
+ process.exit(0);
497
+ }
498
+
499
+ // Filter skills if --skill option is provided
500
+ let selectedSkills: WellKnownSkill[];
501
+
502
+ if (options.skill?.includes('*')) {
503
+ // --skill '*' selects all skills
504
+ selectedSkills = skills;
505
+ p.log.info(`Installing all ${skills.length} skills`);
506
+ } else if (options.skill && options.skill.length > 0) {
507
+ selectedSkills = skills.filter((s) =>
508
+ options.skill!.some(
509
+ (name) =>
510
+ s.installName.toLowerCase() === name.toLowerCase() ||
511
+ s.name.toLowerCase() === name.toLowerCase()
512
+ )
513
+ );
514
+
515
+ if (selectedSkills.length === 0) {
516
+ p.log.error(`No matching skills found for: ${options.skill.join(', ')}`);
517
+ p.log.info('Available skills:');
518
+ for (const s of skills) {
519
+ p.log.message(` - ${s.installName}`);
520
+ }
521
+ process.exit(1);
522
+ }
523
+ } else if (skills.length === 1) {
524
+ selectedSkills = skills;
525
+ const firstSkill = skills[0]!;
526
+ p.log.info(`Skill: ${pc.cyan(firstSkill.installName)}`);
527
+ } else if (options.yes) {
528
+ selectedSkills = skills;
529
+ p.log.info(`Installing all ${skills.length} skills`);
530
+ } else {
531
+ // Prompt user to select skills
532
+ const skillChoices = skills.map((s) => ({
533
+ value: s,
534
+ label: s.installName,
535
+ hint: s.description.length > 60 ? s.description.slice(0, 57) + '...' : s.description,
536
+ }));
537
+
538
+ const selected = await multiselect({
539
+ message: 'Select skills to install',
540
+ options: skillChoices,
541
+ required: true,
542
+ });
543
+
544
+ if (p.isCancel(selected)) {
545
+ p.cancel('Installation cancelled');
546
+ process.exit(0);
547
+ }
548
+
549
+ selectedSkills = selected as WellKnownSkill[];
550
+ }
551
+
552
+ // Detect agents
553
+ let targetAgents: AgentType[];
554
+ const validAgents = Object.keys(agents);
555
+
556
+ if (options.agent?.includes('*')) {
557
+ // --agent '*' selects all agents
558
+ targetAgents = validAgents as AgentType[];
559
+ p.log.info(`Installing to all ${targetAgents.length} agents`);
560
+ } else if (options.agent && options.agent.length > 0) {
561
+ const invalidAgents = options.agent.filter((a) => !validAgents.includes(a));
562
+
563
+ if (invalidAgents.length > 0) {
564
+ p.log.error(`Invalid agents: ${invalidAgents.join(', ')}`);
565
+ p.log.info(`Valid agents: ${validAgents.join(', ')}`);
566
+ process.exit(1);
567
+ }
568
+
569
+ targetAgents = options.agent as AgentType[];
570
+ } else {
571
+ spinner.start('Loading agents...');
572
+ const installedAgents = await detectInstalledAgents();
573
+ const totalAgents = Object.keys(agents).length;
574
+ spinner.stop(`${totalAgents} agents`);
575
+
576
+ if (installedAgents.length === 0) {
577
+ if (options.yes) {
578
+ targetAgents = validAgents as AgentType[];
579
+ p.log.info('Installing to all agents');
580
+ } else {
581
+ p.log.info('Select agents to install skills to');
582
+
583
+ const allAgentChoices = Object.entries(agents).map(([key, config]) => ({
584
+ value: key as AgentType,
585
+ label: config.displayName,
586
+ }));
587
+
588
+ // Use helper to prompt with search
589
+ const selected = await promptForAgents(
590
+ 'Which agents do you want to install to?',
591
+ allAgentChoices
592
+ );
593
+
594
+ if (p.isCancel(selected)) {
595
+ p.cancel('Installation cancelled');
596
+ process.exit(0);
597
+ }
598
+
599
+ targetAgents = selected as AgentType[];
600
+ }
601
+ } else if (installedAgents.length === 1 || options.yes) {
602
+ // Auto-select detected agents + ensure universal agents are included
603
+ targetAgents = ensureUniversalAgents(installedAgents);
604
+ if (installedAgents.length === 1) {
605
+ const firstAgent = installedAgents[0]!;
606
+ p.log.info(`Installing to: ${pc.cyan(agents[firstAgent].displayName)}`);
607
+ } else {
608
+ p.log.info(
609
+ `Installing to: ${installedAgents.map((a) => pc.cyan(agents[a].displayName)).join(', ')}`
610
+ );
611
+ }
612
+ } else {
613
+ const selected = await selectAgentsInteractive({ global: options.global });
614
+
615
+ if (p.isCancel(selected)) {
616
+ p.cancel('Installation cancelled');
617
+ process.exit(0);
618
+ }
619
+
620
+ targetAgents = selected as AgentType[];
621
+ }
622
+ }
623
+
624
+ let installGlobally = options.global ?? false;
625
+
626
+ // Check if any selected agents support global installation
627
+ const supportsGlobal = targetAgents.some((a) => agents[a].globalSkillsDir !== undefined);
628
+
629
+ if (options.global === undefined && !options.yes && supportsGlobal) {
630
+ const scope = await p.select({
631
+ message: 'Installation scope',
632
+ options: [
633
+ {
634
+ value: false,
635
+ label: 'Project',
636
+ hint: 'Install in current directory (committed with your project)',
637
+ },
638
+ {
639
+ value: true,
640
+ label: 'Global',
641
+ hint: 'Install in home directory (available across all projects)',
642
+ },
643
+ ],
644
+ });
645
+
646
+ if (p.isCancel(scope)) {
647
+ p.cancel('Installation cancelled');
648
+ process.exit(0);
649
+ }
650
+
651
+ installGlobally = scope as boolean;
652
+ }
653
+
654
+ // Determine install mode (symlink vs copy)
655
+ let installMode: InstallMode = options.copy ? 'copy' : 'symlink';
656
+
657
+ // Only prompt for install mode when there are multiple unique target directories.
658
+ // When all selected agents share the same skillsDir, symlink vs copy is meaningless.
659
+ const uniqueDirs = new Set(targetAgents.map((a) => agents[a].skillsDir));
660
+
661
+ if (!options.copy && !options.yes && uniqueDirs.size > 1) {
662
+ const modeChoice = await p.select({
663
+ message: 'Installation method',
664
+ options: [
665
+ {
666
+ value: 'symlink',
667
+ label: 'Symlink (Recommended)',
668
+ hint: 'Single source of truth, easy updates',
669
+ },
670
+ { value: 'copy', label: 'Copy to all agents', hint: 'Independent copies for each agent' },
671
+ ],
672
+ });
673
+
674
+ if (p.isCancel(modeChoice)) {
675
+ p.cancel('Installation cancelled');
676
+ process.exit(0);
677
+ }
678
+
679
+ installMode = modeChoice as InstallMode;
680
+ } else if (uniqueDirs.size <= 1) {
681
+ // Single target directory — default to copy (no symlink needed)
682
+ installMode = 'copy';
683
+ }
684
+
685
+ const cwd = process.cwd();
686
+
687
+ // Build installation summary
688
+ const summaryLines: string[] = [];
689
+ const agentNames = targetAgents.map((a) => agents[a].displayName);
690
+
691
+ // Check if any skill will be overwritten (parallel)
692
+ const overwriteChecks = await Promise.all(
693
+ selectedSkills.flatMap((skill) =>
694
+ targetAgents.map(async (agent) => ({
695
+ skillName: skill.installName,
696
+ agent,
697
+ installed: await isSkillInstalled(skill.installName, agent, { global: installGlobally }),
698
+ }))
699
+ )
700
+ );
701
+ const overwriteStatus = new Map<string, Map<string, boolean>>();
702
+ for (const { skillName, agent, installed } of overwriteChecks) {
703
+ if (!overwriteStatus.has(skillName)) {
704
+ overwriteStatus.set(skillName, new Map());
705
+ }
706
+ overwriteStatus.get(skillName)!.set(agent, installed);
707
+ }
708
+
709
+ for (const skill of selectedSkills) {
710
+ if (summaryLines.length > 0) summaryLines.push('');
711
+
712
+ const canonicalPath = getCanonicalPath(skill.installName, { global: installGlobally });
713
+ const shortCanonical = shortenPath(canonicalPath, cwd);
714
+ summaryLines.push(`${pc.cyan(shortCanonical)}`);
715
+ summaryLines.push(...buildAgentSummaryLines(targetAgents, installMode));
716
+ if (skill.files.size > 1) {
717
+ summaryLines.push(` ${pc.dim('files:')} ${skill.files.size}`);
718
+ }
719
+
720
+ const skillOverwrites = overwriteStatus.get(skill.installName);
721
+ const overwriteAgents = targetAgents
722
+ .filter((a) => skillOverwrites?.get(a))
723
+ .map((a) => agents[a].displayName);
724
+
725
+ if (overwriteAgents.length > 0) {
726
+ summaryLines.push(` ${pc.yellow('overwrites:')} ${formatList(overwriteAgents)}`);
727
+ }
728
+ }
729
+
730
+ console.log();
731
+ p.note(summaryLines.join('\n'), 'Installation Summary');
732
+
733
+ if (!options.yes) {
734
+ const confirmed = await p.confirm({ message: 'Proceed with installation?' });
735
+
736
+ if (p.isCancel(confirmed) || !confirmed) {
737
+ p.cancel('Installation cancelled');
738
+ process.exit(0);
739
+ }
740
+ }
741
+
742
+ // Kick off privacy check early so it runs in parallel with installation
743
+ const sourceIdentifier = wellKnownProvider.getSourceIdentifier(url);
744
+ const wellKnownPrivacyPromise = isSourcePrivate(sourceIdentifier).catch(() => null);
745
+
746
+ spinner.start('Installing skills...');
747
+
748
+ const results: {
749
+ skill: string;
750
+ agent: string;
751
+ success: boolean;
752
+ path: string;
753
+ canonicalPath?: string;
754
+ mode: InstallMode;
755
+ symlinkFailed?: boolean;
756
+ error?: string;
757
+ }[] = [];
758
+
759
+ for (const skill of selectedSkills) {
760
+ for (const agent of targetAgents) {
761
+ const result = await installWellKnownSkillForAgent(skill, agent, {
762
+ global: installGlobally,
763
+ mode: installMode,
764
+ });
765
+ results.push({
766
+ skill: skill.installName,
767
+ agent: agents[agent].displayName,
768
+ ...result,
769
+ });
770
+ }
771
+ }
772
+
773
+ spinner.stop('Installation complete');
774
+
775
+ console.log();
776
+ const successful = results.filter((r) => r.success);
777
+ const failed = results.filter((r) => !r.success);
778
+
779
+ // Build skillFiles map: { skillName: sourceUrl }
780
+ const skillFiles: Record<string, string> = {};
781
+ for (const skill of selectedSkills) {
782
+ skillFiles[skill.installName] = skill.sourceUrl;
783
+ }
784
+
785
+ // Privacy promise was started before installation — should be resolved by now
786
+ const isPrivate = await wellKnownPrivacyPromise;
787
+ if (isPrivate !== true) {
788
+ track({
789
+ event: 'install',
790
+ source: sourceIdentifier,
791
+ skills: selectedSkills.map((s) => s.installName).join(','),
792
+ agents: targetAgents.join(','),
793
+ ...(installGlobally && { global: '1' }),
794
+ skillFiles: JSON.stringify(skillFiles),
795
+ sourceType: 'well-known',
796
+ });
797
+ }
798
+
799
+ // Add to skill lock file for update tracking (only for global installs)
800
+ if (successful.length > 0 && installGlobally) {
801
+ const successfulSkillNames = new Set(successful.map((r) => r.skill));
802
+ for (const skill of selectedSkills) {
803
+ if (successfulSkillNames.has(skill.installName)) {
804
+ try {
805
+ await addSkillToLock(skill.installName, {
806
+ source: sourceIdentifier,
807
+ sourceType: 'well-known',
808
+ sourceUrl: skill.sourceUrl,
809
+ skillFolderHash: '', // Well-known skills don't have a folder hash
810
+ });
811
+ } catch {
812
+ // Don't fail installation if lock file update fails
813
+ }
814
+ }
815
+ }
816
+ }
817
+
818
+ // Add to local lock file for project-scoped installs
819
+ if (successful.length > 0 && !installGlobally) {
820
+ const successfulSkillNames = new Set(successful.map((r) => r.skill));
821
+ for (const skill of selectedSkills) {
822
+ if (successfulSkillNames.has(skill.installName)) {
823
+ try {
824
+ const matchingResult = successful.find((r) => r.skill === skill.installName);
825
+ const installDir = matchingResult?.canonicalPath || matchingResult?.path;
826
+ if (installDir) {
827
+ const computedHash = await computeSkillFolderHash(installDir);
828
+ await addSkillToLocalLock(
829
+ skill.installName,
830
+ {
831
+ source: sourceIdentifier,
832
+ sourceType: 'well-known',
833
+ computedHash,
834
+ },
835
+ cwd
836
+ );
837
+ }
838
+ } catch {
839
+ // Don't fail installation if lock file update fails
840
+ }
841
+ }
842
+ }
843
+ }
844
+
845
+ if (successful.length > 0) {
846
+ const bySkill = new Map<string, typeof results>();
847
+ for (const r of successful) {
848
+ const skillResults = bySkill.get(r.skill) || [];
849
+ skillResults.push(r);
850
+ bySkill.set(r.skill, skillResults);
851
+ }
852
+
853
+ const skillCount = bySkill.size;
854
+ const symlinkFailures = successful.filter((r) => r.mode === 'symlink' && r.symlinkFailed);
855
+ const copiedAgents = symlinkFailures.map((r) => r.agent);
856
+ const resultLines: string[] = [];
857
+
858
+ for (const [skillName, skillResults] of bySkill) {
859
+ const firstResult = skillResults[0]!;
860
+
861
+ if (firstResult.mode === 'copy') {
862
+ // Copy mode: show skill name and list all agent paths
863
+ resultLines.push(`${pc.green('✓')} ${skillName} ${pc.dim('(copied)')}`);
864
+ for (const r of skillResults) {
865
+ const shortPath = shortenPath(r.path, cwd);
866
+ resultLines.push(` ${pc.dim('→')} ${shortPath}`);
867
+ }
868
+ } else {
869
+ // Symlink mode: show canonical path and universal/symlinked agents
870
+ if (firstResult.canonicalPath) {
871
+ const shortPath = shortenPath(firstResult.canonicalPath, cwd);
872
+ resultLines.push(`${pc.green('✓')} ${shortPath}`);
873
+ } else {
874
+ resultLines.push(`${pc.green('✓')} ${skillName}`);
875
+ }
876
+ resultLines.push(...buildResultLines(skillResults, targetAgents));
877
+ }
878
+ }
879
+
880
+ const title = pc.green(`Installed ${skillCount} skill${skillCount !== 1 ? 's' : ''}`);
881
+ p.note(resultLines.join('\n'), title);
882
+
883
+ // Show symlink failure warning (only for symlink mode)
884
+ if (symlinkFailures.length > 0) {
885
+ p.log.warn(pc.yellow(`Symlinks failed for: ${formatList(copiedAgents)}`));
886
+ p.log.message(
887
+ pc.dim(
888
+ ' Files were copied instead. On Windows, enable Developer Mode for symlink support.'
889
+ )
890
+ );
891
+ }
892
+ }
893
+
894
+ if (failed.length > 0) {
895
+ console.log();
896
+ p.log.error(pc.red(`Failed to install ${failed.length}`));
897
+ for (const r of failed) {
898
+ p.log.message(` ${pc.red('✗')} ${r.skill} → ${r.agent}: ${pc.dim(r.error)}`);
899
+ }
900
+ }
901
+
902
+ console.log();
903
+ p.outro(
904
+ pc.green('Done!') + pc.dim(' Review skills before use; they run with full agent permissions.')
905
+ );
906
+
907
+ // Prompt for find-skills after successful install
908
+ await promptForFindSkills(options, targetAgents);
909
+ }
910
+
911
+ export async function runAdd(args: string[], options: AddOptions = {}): Promise<void> {
912
+ const source = args[0];
913
+ let installTipShown = false;
914
+
915
+ const showInstallTip = (): void => {
916
+ if (installTipShown) return;
917
+ p.log.message(
918
+ pc.dim('Tip: use the --yes (-y) and --global (-g) flags to install without prompts.')
919
+ );
920
+ installTipShown = true;
921
+ };
922
+
923
+ if (!source) {
924
+ console.log();
925
+ console.log(
926
+ pc.bgRed(pc.white(pc.bold(' ERROR '))) + ' ' + pc.red('Missing required argument: source')
927
+ );
928
+ console.log();
929
+ console.log(pc.dim(' Usage:'));
930
+ console.log(` ${pc.cyan('npx skills add')} ${pc.yellow('<source>')} ${pc.dim('[options]')}`);
931
+ console.log();
932
+ console.log(pc.dim(' Example:'));
933
+ console.log(` ${pc.cyan('npx skills add')} ${pc.yellow('vercel-labs/agent-skills')}`);
934
+ console.log();
935
+ process.exit(1);
936
+ }
937
+
938
+ // --all implies --skill '*' and --agent '*' and -y
939
+ if (options.all) {
940
+ options.skill = ['*'];
941
+ options.agent = ['*'];
942
+ options.yes = true;
943
+ }
944
+
945
+ // Auto-enable non-interactive mode when running inside an AI agent
946
+ const agentResult = await detectAgent();
947
+ if (agentResult.isAgent) {
948
+ options.yes = true;
949
+ // Auto-select the detected agent + universal agents (unless user explicitly specified agents)
950
+ if (!options.agent || options.agent.length === 0) {
951
+ const mappedAgent = getAgentType(agentResult.agent.name);
952
+ if (mappedAgent) {
953
+ options.agent = ensureUniversalAgents([mappedAgent]);
954
+ }
955
+ }
956
+ }
957
+
958
+ console.log();
959
+ if (!agentResult.isAgent) {
960
+ p.intro(pc.bgCyan(pc.black(' skills ')));
961
+ }
962
+
963
+ if (agentResult.isAgent) {
964
+ p.log.info(
965
+ pc.bgCyan(pc.black(pc.bold(` ${agentResult.agent.name} `))) +
966
+ ' ' +
967
+ 'Agent detected — installing non-interactively'
968
+ );
969
+ } else if (!process.stdin.isTTY) {
970
+ showInstallTip();
971
+ }
972
+
973
+ let tempDir: string | null = null;
974
+
975
+ try {
976
+ const spinner = p.spinner();
977
+
978
+ spinner.start('Parsing source...');
979
+ const parsed = parseSource(source);
980
+ spinner.stop(
981
+ `Source: ${parsed.type === 'local' ? parsed.localPath! : parsed.url}${parsed.ref ? ` @ ${pc.yellow(parsed.ref)}` : ''}${parsed.subpath ? ` (${parsed.subpath})` : ''}${parsed.skillFilter ? ` ${pc.dim('@')}${pc.cyan(parsed.skillFilter)}` : ''}`
982
+ );
983
+
984
+ // Kick off the repo privacy check early so it runs in parallel with
985
+ // cloning/discovering/installing. The result is only needed later for
986
+ // telemetry gating — it should never block user-visible output.
987
+ const ownerRepoRaw = getOwnerRepo(parsed);
988
+ const repoPrivacyPromise: Promise<boolean | null> = (() => {
989
+ if (!ownerRepoRaw) return Promise.resolve(null);
990
+ const ownerRepo = parseOwnerRepo(ownerRepoRaw);
991
+ if (!ownerRepo) return Promise.resolve(null);
992
+ return isRepoPrivate(ownerRepo.owner, ownerRepo.repo).catch(() => null);
993
+ })();
994
+
995
+ // Block openclaw sources unless explicitly opted in
996
+ const sourceOwner = ownerRepoRaw?.split('/')[0]?.toLowerCase();
997
+ if (sourceOwner === 'openclaw' && !options.dangerouslyAcceptOpenclawRisks) {
998
+ console.log();
999
+ p.log.warn(pc.yellow(pc.bold('⚠ OpenClaw skills are unverified community submissions.')));
1000
+ p.log.message(
1001
+ pc.yellow(
1002
+ 'This source contains user-submitted skills that have not been reviewed for safety or quality.'
1003
+ )
1004
+ );
1005
+ p.log.message(pc.yellow('Skills run with full agent permissions and could be malicious.'));
1006
+ console.log();
1007
+ p.log.message(
1008
+ `If you understand the risks, re-run with:\n\n ${pc.cyan(`npx skills add ${source} --dangerously-accept-openclaw-risks`)}\n`
1009
+ );
1010
+ p.outro(pc.red('Installation blocked'));
1011
+ process.exit(1);
1012
+ }
1013
+
1014
+ // Handle well-known skills from arbitrary URLs
1015
+ if (parsed.type === 'well-known') {
1016
+ await handleWellKnownSkills(source, parsed.url, options, spinner);
1017
+ return;
1018
+ }
1019
+
1020
+ // If skillFilter is present from @skill syntax (e.g., owner/repo@skill-name),
1021
+ // merge it into options.skill
1022
+ if (parsed.skillFilter) {
1023
+ options.skill = options.skill || [];
1024
+ if (!options.skill.includes(parsed.skillFilter)) {
1025
+ options.skill.push(parsed.skillFilter);
1026
+ }
1027
+ }
1028
+
1029
+ // Include internal skills when a specific skill is explicitly requested
1030
+ // (via --skill or @skill syntax)
1031
+ const includeInternal = !!(options.skill && options.skill.length > 0);
1032
+
1033
+ let skills: Skill[];
1034
+ let blobResult: BlobInstallResult | null = null;
1035
+
1036
+ if (parsed.type === 'local') {
1037
+ // Use local path directly, no cloning needed
1038
+ spinner.start('Validating local path...');
1039
+ if (!existsSync(parsed.localPath!)) {
1040
+ spinner.stop(pc.red('Path not found'));
1041
+ p.outro(pc.red(`Local path does not exist: ${parsed.localPath}`));
1042
+ process.exit(1);
1043
+ }
1044
+ spinner.stop('Local path validated');
1045
+
1046
+ spinner.start('Discovering skills...');
1047
+ skills = await discoverSkills(parsed.localPath!, parsed.subpath, {
1048
+ includeInternal,
1049
+ fullDepth: options.fullDepth,
1050
+ });
1051
+ } else if (parsed.type === 'github' && !options.fullDepth) {
1052
+ // Try the blob-based fast install for GitHub sources; skip for --full-depth.
1053
+ // Eligible per repo (a BLOB_ALLOWED_REPOS entry = self-hosted download URL) or
1054
+ // per owner (BLOB_ALLOWED_OWNERS = all their repos, skills.sh-hosted).
1055
+ const BLOB_ALLOWED_OWNERS = ['vercel', 'vercel-labs', 'heygen-com'];
1056
+ const ownerRepo = getOwnerRepo(parsed);
1057
+ const owner = ownerRepo?.split('/')[0]?.toLowerCase();
1058
+ const isSelfHostedRepo =
1059
+ !!ownerRepo && Object.hasOwn(BLOB_ALLOWED_REPOS, ownerRepo.toLowerCase());
1060
+ if (ownerRepo && owner && (isSelfHostedRepo || BLOB_ALLOWED_OWNERS.includes(owner))) {
1061
+ spinner.start('Fetching skills...');
1062
+ blobResult = await tryBlobInstall(ownerRepo, {
1063
+ subpath: parsed.subpath,
1064
+ skillFilter: parsed.skillFilter,
1065
+ ref: parsed.ref,
1066
+ getToken: getGitHubToken,
1067
+ includeInternal,
1068
+ });
1069
+ if (!blobResult) {
1070
+ spinner.stop(pc.dim('Falling back to clone...'));
1071
+ }
1072
+ }
1073
+
1074
+ if (blobResult) {
1075
+ skills = blobResult.skills;
1076
+ spinner.stop(`Found ${pc.green(skills.length)} skill${skills.length > 1 ? 's' : ''}`);
1077
+ } else {
1078
+ // Blob failed — fall back to git clone
1079
+ spinner.start('Cloning repository...');
1080
+ tempDir = await cloneRepo(parsed.url, parsed.ref);
1081
+ spinner.stop('Repository cloned');
1082
+
1083
+ spinner.start('Discovering skills...');
1084
+ skills = await discoverSkills(tempDir, parsed.subpath, {
1085
+ includeInternal,
1086
+ fullDepth: options.fullDepth,
1087
+ });
1088
+ }
1089
+ } else {
1090
+ // GitLab, git URL, or --full-depth: always clone
1091
+ spinner.start('Cloning repository...');
1092
+ tempDir = await cloneRepo(parsed.url, parsed.ref);
1093
+ spinner.stop('Repository cloned');
1094
+
1095
+ spinner.start('Discovering skills...');
1096
+ skills = await discoverSkills(tempDir, parsed.subpath, {
1097
+ includeInternal,
1098
+ fullDepth: options.fullDepth,
1099
+ });
1100
+ }
1101
+
1102
+ if (skills.length === 0) {
1103
+ spinner.stop(pc.red('No skills found'));
1104
+ p.outro(
1105
+ pc.red('No valid skills found. Skills require a SKILL.md with name and description.')
1106
+ );
1107
+ await cleanup(tempDir);
1108
+ process.exit(1);
1109
+ }
1110
+
1111
+ if (!blobResult) {
1112
+ spinner.stop(`Found ${pc.green(skills.length)} skill${skills.length > 1 ? 's' : ''}`);
1113
+ }
1114
+
1115
+ if (options.list) {
1116
+ console.log();
1117
+ p.log.step(pc.bold('Available Skills'));
1118
+
1119
+ // Group available skills by plugin for list output
1120
+ const groupedSkills: Record<string, Skill[]> = {};
1121
+ const ungroupedSkills: Skill[] = [];
1122
+
1123
+ for (const skill of skills) {
1124
+ if (skill.pluginName) {
1125
+ const group = skill.pluginName;
1126
+ if (!groupedSkills[group]) groupedSkills[group] = [];
1127
+ groupedSkills[group].push(skill);
1128
+ } else {
1129
+ ungroupedSkills.push(skill);
1130
+ }
1131
+ }
1132
+
1133
+ // Print groups
1134
+ const sortedGroups = Object.keys(groupedSkills).sort();
1135
+ for (const group of sortedGroups) {
1136
+ // Convert kebab-case to Title Case for display header
1137
+ const title = group
1138
+ .split('-')
1139
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
1140
+ .join(' ');
1141
+
1142
+ console.log(pc.bold(title));
1143
+ for (const skill of groupedSkills[group]!) {
1144
+ p.log.message(` ${pc.cyan(getSkillDisplayName(skill))}`);
1145
+ p.log.message(` ${pc.dim(skill.description)}`);
1146
+ }
1147
+ console.log();
1148
+ }
1149
+
1150
+ // Print ungrouped
1151
+ if (ungroupedSkills.length > 0) {
1152
+ if (sortedGroups.length > 0) console.log(pc.bold('General'));
1153
+ for (const skill of ungroupedSkills) {
1154
+ p.log.message(` ${pc.cyan(getSkillDisplayName(skill))}`);
1155
+ p.log.message(` ${pc.dim(skill.description)}`);
1156
+ }
1157
+ }
1158
+
1159
+ console.log();
1160
+ p.outro('Use --skill <name> to install specific skills');
1161
+ await cleanup(tempDir);
1162
+ process.exit(0);
1163
+ }
1164
+
1165
+ let selectedSkills: Skill[];
1166
+
1167
+ if (options.skill?.includes('*')) {
1168
+ // --skill '*' selects all skills
1169
+ selectedSkills = skills;
1170
+ p.log.info(`Installing all ${skills.length} skills`);
1171
+ } else if (options.skill && options.skill.length > 0) {
1172
+ selectedSkills = filterSkills(skills, options.skill);
1173
+
1174
+ if (selectedSkills.length === 0) {
1175
+ p.log.error(`No matching skills found for: ${options.skill.join(', ')}`);
1176
+ p.log.info('Available skills:');
1177
+ for (const s of skills) {
1178
+ p.log.message(` - ${getSkillDisplayName(s)}`);
1179
+ }
1180
+ await cleanup(tempDir);
1181
+ process.exit(1);
1182
+ }
1183
+
1184
+ p.log.info(
1185
+ `Selected ${selectedSkills.length} skill${selectedSkills.length !== 1 ? 's' : ''}: ${selectedSkills.map((s) => pc.cyan(getSkillDisplayName(s))).join(', ')}`
1186
+ );
1187
+ } else if (skills.length === 1) {
1188
+ selectedSkills = skills;
1189
+ const firstSkill = skills[0]!;
1190
+ p.log.info(`Skill: ${pc.cyan(getSkillDisplayName(firstSkill))}`);
1191
+ p.log.message(pc.dim(firstSkill.description));
1192
+ } else if (options.yes) {
1193
+ selectedSkills = skills;
1194
+ p.log.info(`Installing all ${skills.length} skills`);
1195
+ } else {
1196
+ // Sort skills by plugin name first, then by skill name
1197
+ const sortedSkills = [...skills].sort((a, b) => {
1198
+ if (a.pluginName && !b.pluginName) return -1;
1199
+ if (!a.pluginName && b.pluginName) return 1;
1200
+ if (a.pluginName && b.pluginName && a.pluginName !== b.pluginName) {
1201
+ return a.pluginName.localeCompare(b.pluginName);
1202
+ }
1203
+ return getSkillDisplayName(a).localeCompare(getSkillDisplayName(b));
1204
+ });
1205
+
1206
+ // Check if any skills have plugin grouping
1207
+ const hasGroups = sortedSkills.some((s) => s.pluginName);
1208
+
1209
+ // Pre-check which skills are already installed (project .agents/skills/ or global ~/.agents/skills/)
1210
+ // Uses the 'universal' agent which maps to the ADG canonical paths for both scopes.
1211
+ const alreadyInstalledSet = new Set<string>();
1212
+ {
1213
+ const checks = await Promise.all(
1214
+ sortedSkills.map(async (s) => {
1215
+ const name = getSkillDisplayName(s);
1216
+ const [projectInstalled, globalInstalled] = await Promise.all([
1217
+ isSkillInstalled(name, 'universal', { global: false }),
1218
+ isSkillInstalled(name, 'universal', { global: true }),
1219
+ ]);
1220
+ return { name, installed: projectInstalled || globalInstalled };
1221
+ })
1222
+ );
1223
+ for (const { name, installed } of checks) {
1224
+ if (installed) alreadyInstalledSet.add(name);
1225
+ }
1226
+ }
1227
+
1228
+ const skillLabel = (s: Skill): string => {
1229
+ const name = getSkillDisplayName(s);
1230
+ const badge = alreadyInstalledSet.has(name) ? pc.dim('[↑] ') : ' ';
1231
+ return `${badge}${name}`;
1232
+ };
1233
+
1234
+ let selected: Skill[] | symbol;
1235
+
1236
+ if (hasGroups) {
1237
+ // Build grouped options for groupMultiselect
1238
+ const kebabToTitle = (s: string) =>
1239
+ s
1240
+ .split('-')
1241
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
1242
+ .join(' ');
1243
+
1244
+ const grouped: Record<string, p.Option<Skill>[]> = {};
1245
+ for (const s of sortedSkills) {
1246
+ const groupName = s.pluginName ? kebabToTitle(s.pluginName) : 'Other';
1247
+ if (!grouped[groupName]) grouped[groupName] = [];
1248
+ grouped[groupName]!.push({
1249
+ value: s,
1250
+ label: skillLabel(s),
1251
+ hint: s.description.length > 60 ? s.description.slice(0, 57) + '...' : s.description,
1252
+ });
1253
+ }
1254
+
1255
+ selected = await p.groupMultiselect({
1256
+ message: `Select skills to install ${pc.dim('(space to toggle)')}`,
1257
+ options: grouped,
1258
+ required: true,
1259
+ });
1260
+ } else {
1261
+ const skillChoices = sortedSkills.map((s) => ({
1262
+ value: s,
1263
+ label: skillLabel(s),
1264
+ hint: s.description.length > 60 ? s.description.slice(0, 57) + '...' : s.description,
1265
+ }));
1266
+
1267
+ selected = await multiselect({
1268
+ message: 'Select skills to install',
1269
+ options: skillChoices,
1270
+ required: true,
1271
+ });
1272
+ }
1273
+
1274
+ if (p.isCancel(selected)) {
1275
+ p.cancel('Installation cancelled');
1276
+ await cleanup(tempDir);
1277
+ process.exit(0);
1278
+ }
1279
+
1280
+ selectedSkills = selected as Skill[];
1281
+ }
1282
+
1283
+ // Kick off security audit fetch early (non-blocking) so it runs
1284
+ // in parallel with agent selection, scope, and mode prompts.
1285
+ const ownerRepoForAudit = getOwnerRepo(parsed);
1286
+ const auditPromise = ownerRepoForAudit
1287
+ ? fetchAuditData(
1288
+ ownerRepoForAudit,
1289
+ selectedSkills.map((s) => getSkillDisplayName(s))
1290
+ )
1291
+ : Promise.resolve(null);
1292
+
1293
+ let targetAgents: AgentType[];
1294
+ const validAgents = Object.keys(agents);
1295
+
1296
+ if (options.agent?.includes('*')) {
1297
+ // --agent '*' selects all agents
1298
+ targetAgents = validAgents as AgentType[];
1299
+ p.log.info(`Installing to all ${targetAgents.length} agents`);
1300
+ } else if (options.agent && options.agent.length > 0) {
1301
+ const invalidAgents = options.agent.filter((a) => !validAgents.includes(a));
1302
+
1303
+ if (invalidAgents.length > 0) {
1304
+ p.log.error(`Invalid agents: ${invalidAgents.join(', ')}`);
1305
+ p.log.info(`Valid agents: ${validAgents.join(', ')}`);
1306
+ await cleanup(tempDir);
1307
+ process.exit(1);
1308
+ }
1309
+
1310
+ targetAgents = options.agent as AgentType[];
1311
+ } else {
1312
+ spinner.start('Loading agents...');
1313
+ const installedAgents = await detectInstalledAgents();
1314
+ const totalAgents = Object.keys(agents).length;
1315
+ spinner.stop(`${totalAgents} agents`);
1316
+
1317
+ if (installedAgents.length === 0) {
1318
+ if (options.yes) {
1319
+ targetAgents = validAgents as AgentType[];
1320
+ p.log.info('Installing to all agents');
1321
+ } else {
1322
+ p.log.info('Select agents to install skills to');
1323
+
1324
+ const allAgentChoices = Object.entries(agents).map(([key, config]) => ({
1325
+ value: key as AgentType,
1326
+ label: config.displayName,
1327
+ }));
1328
+
1329
+ // Use helper to prompt with search
1330
+ const selected = await promptForAgents(
1331
+ 'Which agents do you want to install to?',
1332
+ allAgentChoices
1333
+ );
1334
+
1335
+ if (p.isCancel(selected)) {
1336
+ p.cancel('Installation cancelled');
1337
+ await cleanup(tempDir);
1338
+ process.exit(0);
1339
+ }
1340
+
1341
+ targetAgents = selected as AgentType[];
1342
+ }
1343
+ } else if (installedAgents.length === 1 || options.yes) {
1344
+ // Auto-select detected agents + ensure universal agents are included
1345
+ targetAgents = ensureUniversalAgents(installedAgents);
1346
+ if (installedAgents.length === 1) {
1347
+ const firstAgent = installedAgents[0]!;
1348
+ p.log.info(`Installing to: ${pc.cyan(agents[firstAgent].displayName)}`);
1349
+ } else {
1350
+ p.log.info(
1351
+ `Installing to: ${installedAgents.map((a) => pc.cyan(agents[a].displayName)).join(', ')}`
1352
+ );
1353
+ }
1354
+ } else {
1355
+ const selected = await selectAgentsInteractive({ global: options.global });
1356
+
1357
+ if (p.isCancel(selected)) {
1358
+ p.cancel('Installation cancelled');
1359
+ await cleanup(tempDir);
1360
+ process.exit(0);
1361
+ }
1362
+
1363
+ targetAgents = selected as AgentType[];
1364
+ }
1365
+ }
1366
+
1367
+ let installGlobally = options.global ?? false;
1368
+
1369
+ // Check if any selected agents support global installation
1370
+ const supportsGlobal = targetAgents.some((a) => agents[a].globalSkillsDir !== undefined);
1371
+
1372
+ if (options.global === undefined && !options.yes && supportsGlobal) {
1373
+ const scope = await p.select({
1374
+ message: 'Installation scope',
1375
+ options: [
1376
+ {
1377
+ value: false,
1378
+ label: 'Project',
1379
+ hint: 'Install in current directory (committed with your project)',
1380
+ },
1381
+ {
1382
+ value: true,
1383
+ label: 'Global',
1384
+ hint: 'Install in home directory (available across all projects)',
1385
+ },
1386
+ ],
1387
+ });
1388
+
1389
+ if (p.isCancel(scope)) {
1390
+ p.cancel('Installation cancelled');
1391
+ await cleanup(tempDir);
1392
+ process.exit(0);
1393
+ }
1394
+
1395
+ installGlobally = scope as boolean;
1396
+ }
1397
+
1398
+ // Determine install mode (symlink vs copy)
1399
+ let installMode: InstallMode = options.copy ? 'copy' : 'symlink';
1400
+
1401
+ // Only prompt for install mode when there are multiple unique target directories.
1402
+ // When all selected agents share the same skillsDir, symlink vs copy is meaningless.
1403
+ const uniqueDirs = new Set(targetAgents.map((a) => agents[a].skillsDir));
1404
+
1405
+ if (!options.copy && !options.yes && uniqueDirs.size > 1) {
1406
+ const modeChoice = await p.select({
1407
+ message: 'Installation method',
1408
+ options: [
1409
+ {
1410
+ value: 'symlink',
1411
+ label: 'Symlink (Recommended)',
1412
+ hint: 'Single source of truth, easy updates',
1413
+ },
1414
+ { value: 'copy', label: 'Copy to all agents', hint: 'Independent copies for each agent' },
1415
+ ],
1416
+ });
1417
+
1418
+ if (p.isCancel(modeChoice)) {
1419
+ p.cancel('Installation cancelled');
1420
+ await cleanup(tempDir);
1421
+ process.exit(0);
1422
+ }
1423
+
1424
+ installMode = modeChoice as InstallMode;
1425
+ } else if (uniqueDirs.size <= 1) {
1426
+ // Single target directory — default to copy (no symlink needed)
1427
+ installMode = 'copy';
1428
+ }
1429
+
1430
+ const cwd = process.cwd();
1431
+
1432
+ // Build installation summary
1433
+ const summaryLines: string[] = [];
1434
+ const agentNames = targetAgents.map((a) => agents[a].displayName);
1435
+
1436
+ // Check if any skill will be overwritten (parallel)
1437
+ const overwriteChecks = await Promise.all(
1438
+ selectedSkills.flatMap((skill) =>
1439
+ targetAgents.map(async (agent) => ({
1440
+ skillName: skill.name,
1441
+ agent,
1442
+ installed: await isSkillInstalled(skill.name, agent, { global: installGlobally }),
1443
+ }))
1444
+ )
1445
+ );
1446
+ const overwriteStatus = new Map<string, Map<string, boolean>>();
1447
+ for (const { skillName, agent, installed } of overwriteChecks) {
1448
+ if (!overwriteStatus.has(skillName)) {
1449
+ overwriteStatus.set(skillName, new Map());
1450
+ }
1451
+ overwriteStatus.get(skillName)!.set(agent, installed);
1452
+ }
1453
+
1454
+ // Group selected skills for summary
1455
+ const groupedSummary: Record<string, Skill[]> = {};
1456
+ const ungroupedSummary: Skill[] = [];
1457
+
1458
+ for (const skill of selectedSkills) {
1459
+ if (skill.pluginName) {
1460
+ const group = skill.pluginName;
1461
+ if (!groupedSummary[group]) groupedSummary[group] = [];
1462
+ groupedSummary[group].push(skill);
1463
+ } else {
1464
+ ungroupedSummary.push(skill);
1465
+ }
1466
+ }
1467
+
1468
+ // Helper to print summary lines for a list of skills
1469
+ const printSkillSummary = (skills: Skill[]) => {
1470
+ for (const skill of skills) {
1471
+ if (summaryLines.length > 0) summaryLines.push('');
1472
+
1473
+ const canonicalPath = getCanonicalPath(skill.name, { global: installGlobally });
1474
+ const shortCanonical = shortenPath(canonicalPath, cwd);
1475
+ summaryLines.push(`${pc.cyan(shortCanonical)}`);
1476
+ summaryLines.push(...buildAgentSummaryLines(targetAgents, installMode));
1477
+
1478
+ const skillOverwrites = overwriteStatus.get(skill.name);
1479
+ const overwriteAgents = targetAgents
1480
+ .filter((a) => skillOverwrites?.get(a))
1481
+ .map((a) => agents[a].displayName);
1482
+
1483
+ if (overwriteAgents.length > 0) {
1484
+ summaryLines.push(` ${pc.yellow('overwrites:')} ${formatList(overwriteAgents)}`);
1485
+ }
1486
+ }
1487
+ };
1488
+
1489
+ // Build grouped summary
1490
+ const sortedGroups = Object.keys(groupedSummary).sort();
1491
+
1492
+ for (const group of sortedGroups) {
1493
+ const title = group
1494
+ .split('-')
1495
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
1496
+ .join(' ');
1497
+
1498
+ summaryLines.push('');
1499
+ summaryLines.push(pc.bold(title));
1500
+ printSkillSummary(groupedSummary[group]!);
1501
+ }
1502
+
1503
+ if (ungroupedSummary.length > 0) {
1504
+ if (sortedGroups.length > 0) {
1505
+ summaryLines.push('');
1506
+ summaryLines.push(pc.bold('General'));
1507
+ }
1508
+ printSkillSummary(ungroupedSummary);
1509
+ }
1510
+
1511
+ console.log();
1512
+ p.note(summaryLines.join('\n'), 'Installation Summary');
1513
+
1514
+ // Await and display security audit results (started earlier in parallel)
1515
+ // Wrapped in try/catch so a failed audit fetch never blocks installation.
1516
+ try {
1517
+ const auditData = await auditPromise;
1518
+ if (auditData && ownerRepoForAudit) {
1519
+ const securityLines = buildSecurityLines(
1520
+ auditData,
1521
+ selectedSkills.map((s) => ({
1522
+ slug: getSkillDisplayName(s),
1523
+ displayName: getSkillDisplayName(s),
1524
+ })),
1525
+ ownerRepoForAudit
1526
+ );
1527
+ if (securityLines.length > 0) {
1528
+ p.note(securityLines.join('\n'), 'Security Risk Assessments');
1529
+ }
1530
+ }
1531
+ } catch {
1532
+ // Silently skip — security info is advisory only
1533
+ }
1534
+
1535
+ if (!options.yes) {
1536
+ const confirmed = await p.confirm({ message: 'Proceed with installation?' });
1537
+
1538
+ if (p.isCancel(confirmed) || !confirmed) {
1539
+ p.cancel('Installation cancelled');
1540
+ await cleanup(tempDir);
1541
+ process.exit(0);
1542
+ }
1543
+ }
1544
+
1545
+ spinner.start('Installing skills...');
1546
+
1547
+ const results: {
1548
+ skill: string;
1549
+ agent: string;
1550
+ success: boolean;
1551
+ path: string;
1552
+ canonicalPath?: string;
1553
+ mode: InstallMode;
1554
+ symlinkFailed?: boolean;
1555
+ error?: string;
1556
+ pluginName?: string;
1557
+ }[] = [];
1558
+
1559
+ for (const skill of selectedSkills) {
1560
+ for (const agent of targetAgents) {
1561
+ let result;
1562
+ if (blobResult && 'files' in skill) {
1563
+ // Blob-based install: write files from snapshot
1564
+ const blobSkill = skill as BlobSkill;
1565
+ result = await installBlobSkillForAgent(
1566
+ { installName: blobSkill.name, files: blobSkill.files },
1567
+ agent,
1568
+ { global: installGlobally, mode: installMode }
1569
+ );
1570
+ } else {
1571
+ // Disk-based install: copy from cloned/local directory
1572
+ result = await installSkillForAgent(skill, agent, {
1573
+ global: installGlobally,
1574
+ mode: installMode,
1575
+ });
1576
+ }
1577
+ results.push({
1578
+ skill: getSkillDisplayName(skill),
1579
+ agent: agents[agent].displayName,
1580
+ pluginName: skill.pluginName,
1581
+ ...result,
1582
+ });
1583
+ }
1584
+ }
1585
+
1586
+ spinner.stop('Installation complete');
1587
+
1588
+ console.log();
1589
+ const successful = results.filter((r) => r.success);
1590
+ const failed = results.filter((r) => !r.success);
1591
+ // Track installation result
1592
+ // Build skillFiles map: { skillName: relative path to SKILL.md from repo root }
1593
+ const skillFiles: Record<string, string> = {};
1594
+ for (const skill of selectedSkills) {
1595
+ if (blobResult && 'repoPath' in skill) {
1596
+ // Blob-based: repoPath is already the repo-relative path (e.g., "skills/react/SKILL.md")
1597
+ skillFiles[skill.name] = (skill as BlobSkill).repoPath;
1598
+ } else if (tempDir && skill.path === tempDir) {
1599
+ // Skill is at root level of repo
1600
+ skillFiles[skill.name] = 'SKILL.md';
1601
+ } else if (tempDir && skill.path.startsWith(tempDir + sep)) {
1602
+ // Compute path relative to repo root (tempDir), not search path
1603
+ // Use forward slashes for telemetry (URL-style paths)
1604
+ skillFiles[skill.name] =
1605
+ skill.path
1606
+ .slice(tempDir.length + 1)
1607
+ .split(sep)
1608
+ .join('/') + '/SKILL.md';
1609
+ } else {
1610
+ // Local path - skip telemetry for local installs
1611
+ continue;
1612
+ }
1613
+ }
1614
+
1615
+ // Normalize source to owner/repo format for telemetry
1616
+ const normalizedSource = getOwnerRepo(parsed);
1617
+
1618
+ const lockSource = getLockSource(parsed.url, normalizedSource);
1619
+
1620
+ // Only track if we have a valid remote source and it's not a private repo.
1621
+ // repoPrivacyPromise was started early (right after parsing) so it has
1622
+ // already been running in parallel with the entire install — no stall here.
1623
+ if (normalizedSource) {
1624
+ const ownerRepo = parseOwnerRepo(normalizedSource);
1625
+ if (ownerRepo) {
1626
+ const isPrivate = await repoPrivacyPromise;
1627
+ // Only send telemetry if repo is public (isPrivate === false)
1628
+ // If we can't determine (null), err on the side of caution and skip telemetry
1629
+ if (isPrivate === false) {
1630
+ track({
1631
+ event: 'install',
1632
+ source: normalizedSource,
1633
+ skills: selectedSkills.map((s) => s.name).join(','),
1634
+ agents: targetAgents.join(','),
1635
+ ...(installGlobally && { global: '1' }),
1636
+ skillFiles: JSON.stringify(skillFiles),
1637
+ });
1638
+ }
1639
+ } else {
1640
+ // If we can't parse owner/repo, still send telemetry (for non-GitHub sources)
1641
+ track({
1642
+ event: 'install',
1643
+ source: normalizedSource,
1644
+ skills: selectedSkills.map((s) => s.name).join(','),
1645
+ agents: targetAgents.join(','),
1646
+ ...(installGlobally && { global: '1' }),
1647
+ skillFiles: JSON.stringify(skillFiles),
1648
+ });
1649
+ }
1650
+ }
1651
+
1652
+ // Add to skill lock file for update tracking (only for global installs)
1653
+ if (successful.length > 0 && installGlobally && normalizedSource) {
1654
+ const successfulSkillNames = new Set(successful.map((r) => r.skill));
1655
+
1656
+ // For GitHub clone installs, fetch the repo tree once and reuse it
1657
+ // for all skills — avoids N sequential API calls that take ~400ms each.
1658
+ let cachedTree: Awaited<ReturnType<typeof fetchRepoTree>> | undefined;
1659
+ if (parsed.type === 'github' && !blobResult) {
1660
+ cachedTree = await fetchRepoTree(normalizedSource, parsed.ref, getGitHubToken);
1661
+ }
1662
+
1663
+ for (const skill of selectedSkills) {
1664
+ const skillDisplayName = getSkillDisplayName(skill);
1665
+ if (successfulSkillNames.has(skillDisplayName)) {
1666
+ try {
1667
+ let skillFolderHash = '';
1668
+ const skillPathValue = skillFiles[skill.name];
1669
+
1670
+ if (blobResult && skillPathValue) {
1671
+ const hash = getSkillFolderHashFromTree(blobResult.tree, skillPathValue);
1672
+ if (hash) skillFolderHash = hash;
1673
+ } else if (parsed.type === 'github' && skillPathValue && cachedTree) {
1674
+ const hash = getSkillFolderHashFromTree(cachedTree, skillPathValue);
1675
+ if (hash) skillFolderHash = hash;
1676
+ } else if (skillPathValue && tempDir) {
1677
+ const folder = dirname(skillPathValue);
1678
+ if (parsed.type === 'github') {
1679
+ // ADG patch: github sources are compared against the git tree SHA
1680
+ // at update time. When the tree API failed and we fell back to a
1681
+ // clone, derive that same tree SHA from the clone instead of a
1682
+ // sha256 content hash — otherwise every update perpetually
1683
+ // re-flags this skill (hash schemes can never match).
1684
+ const treeSha = await gitTreeShaForFolder(tempDir, folder === '.' ? '' : folder);
1685
+ if (treeSha) skillFolderHash = treeSha;
1686
+ } else {
1687
+ const skillDir = join(tempDir, folder);
1688
+ const hash = await computeSkillFolderHash(skillDir);
1689
+ if (hash) skillFolderHash = hash;
1690
+ }
1691
+ }
1692
+
1693
+ await addSkillToLock(skill.name, {
1694
+ source: lockSource || normalizedSource,
1695
+ sourceType: parsed.type,
1696
+ sourceUrl: parsed.url,
1697
+ ref: parsed.ref,
1698
+ skillPath: skillPathValue,
1699
+ skillFolderHash,
1700
+ pluginName: skill.pluginName,
1701
+ });
1702
+ } catch {
1703
+ // Don't fail installation if lock file update fails
1704
+ }
1705
+ }
1706
+ }
1707
+ }
1708
+
1709
+ // Add to local lock file for project-scoped installs
1710
+ if (successful.length > 0 && !installGlobally) {
1711
+ const successfulSkillNames = new Set(successful.map((r) => r.skill));
1712
+ for (const skill of selectedSkills) {
1713
+ const skillDisplayName = getSkillDisplayName(skill);
1714
+ if (successfulSkillNames.has(skillDisplayName)) {
1715
+ try {
1716
+ // For blob skills, use the snapshot hash; for disk skills, compute from files
1717
+ const computedHash =
1718
+ blobResult && 'snapshotHash' in skill
1719
+ ? (skill as BlobSkill).snapshotHash
1720
+ : await computeSkillFolderHash(skill.path);
1721
+ const skillPathValue = skillFiles[skill.name];
1722
+ await addSkillToLocalLock(
1723
+ skill.name,
1724
+ {
1725
+ source: lockSource || parsed.url,
1726
+ ref: parsed.ref,
1727
+ sourceType: parsed.type,
1728
+ ...(skillPathValue && { skillPath: skillPathValue }),
1729
+ computedHash,
1730
+ },
1731
+ cwd
1732
+ );
1733
+ } catch {
1734
+ // Don't fail installation if lock file update fails
1735
+ }
1736
+ }
1737
+ }
1738
+ }
1739
+
1740
+ if (successful.length > 0) {
1741
+ const bySkill = new Map<string, typeof results>();
1742
+
1743
+ // Group results by plugin name
1744
+ const groupedResults: Record<string, typeof results> = {};
1745
+ const ungroupedResults: typeof results = [];
1746
+
1747
+ for (const r of successful) {
1748
+ const skillResults = bySkill.get(r.skill) || [];
1749
+ skillResults.push(r);
1750
+ bySkill.set(r.skill, skillResults);
1751
+
1752
+ // We only need to group once per skill (take the first result for that skill)
1753
+ if (skillResults.length === 1) {
1754
+ if (r.pluginName) {
1755
+ const group = r.pluginName;
1756
+ if (!groupedResults[group]) groupedResults[group] = [];
1757
+ // We'll store just one entry per skill here to drive the loop
1758
+ groupedResults[group].push(r);
1759
+ } else {
1760
+ ungroupedResults.push(r);
1761
+ }
1762
+ }
1763
+ }
1764
+
1765
+ const skillCount = bySkill.size;
1766
+ const symlinkFailures = successful.filter((r) => r.mode === 'symlink' && r.symlinkFailed);
1767
+ const copiedAgents = symlinkFailures.map((r) => r.agent);
1768
+ const resultLines: string[] = [];
1769
+
1770
+ const printSkillResults = (entries: typeof results) => {
1771
+ for (const entry of entries) {
1772
+ const skillResults = bySkill.get(entry.skill) || [];
1773
+ const firstResult = skillResults[0]!;
1774
+
1775
+ if (firstResult.mode === 'copy') {
1776
+ // Copy mode: show skill name and list all agent paths
1777
+ resultLines.push(`${pc.green('✓')} ${entry.skill} ${pc.dim('(copied)')}`);
1778
+ for (const r of skillResults) {
1779
+ const shortPath = shortenPath(r.path, cwd);
1780
+ resultLines.push(` ${pc.dim('→')} ${shortPath}`);
1781
+ }
1782
+ } else {
1783
+ // Symlink mode: show canonical path and universal/symlinked agents
1784
+ if (firstResult.canonicalPath) {
1785
+ const shortPath = shortenPath(firstResult.canonicalPath, cwd);
1786
+ resultLines.push(`${pc.green('✓')} ${shortPath}`);
1787
+ } else {
1788
+ resultLines.push(`${pc.green('✓')} ${entry.skill}`);
1789
+ }
1790
+ resultLines.push(...buildResultLines(skillResults, targetAgents));
1791
+ }
1792
+ }
1793
+ };
1794
+
1795
+ // Print grouped results
1796
+ const sortedResultGroups = Object.keys(groupedResults).sort();
1797
+
1798
+ for (const group of sortedResultGroups) {
1799
+ const title = group
1800
+ .split('-')
1801
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
1802
+ .join(' ');
1803
+
1804
+ resultLines.push('');
1805
+ resultLines.push(pc.bold(title));
1806
+ printSkillResults(groupedResults[group]!);
1807
+ }
1808
+
1809
+ if (ungroupedResults.length > 0) {
1810
+ if (sortedResultGroups.length > 0) {
1811
+ resultLines.push('');
1812
+ resultLines.push(pc.bold('General'));
1813
+ }
1814
+ printSkillResults(ungroupedResults);
1815
+ }
1816
+
1817
+ const title = pc.green(`Installed ${skillCount} skill${skillCount !== 1 ? 's' : ''}`);
1818
+ p.note(resultLines.join('\n'), title);
1819
+
1820
+ // Show symlink failure warning (only for symlink mode)
1821
+ if (symlinkFailures.length > 0) {
1822
+ p.log.warn(pc.yellow(`Symlinks failed for: ${formatList(copiedAgents)}`));
1823
+ p.log.message(
1824
+ pc.dim(
1825
+ ' Files were copied instead. On Windows, enable Developer Mode for symlink support.'
1826
+ )
1827
+ );
1828
+ }
1829
+ }
1830
+
1831
+ if (failed.length > 0) {
1832
+ console.log();
1833
+ p.log.error(pc.red(`Failed to install ${failed.length}`));
1834
+ for (const r of failed) {
1835
+ p.log.message(` ${pc.red('✗')} ${r.skill} → ${r.agent}: ${pc.dim(r.error)}`);
1836
+ }
1837
+ }
1838
+
1839
+ console.log();
1840
+ p.outro(
1841
+ pc.green('Done!') +
1842
+ pc.dim(' Review skills before use; they run with full agent permissions.')
1843
+ );
1844
+
1845
+ // Prompt for find-skills after successful install
1846
+ await promptForFindSkills(options, targetAgents);
1847
+ } catch (error) {
1848
+ if (error instanceof GitCloneError) {
1849
+ p.log.error(pc.red('Failed to clone repository'));
1850
+ // Print each line of the error message separately for better formatting
1851
+ for (const line of error.message.split('\n')) {
1852
+ p.log.message(pc.dim(line));
1853
+ }
1854
+ } else {
1855
+ p.log.error(error instanceof Error ? error.message : 'Unknown error occurred');
1856
+ }
1857
+ showInstallTip();
1858
+ p.outro(pc.red('Installation failed'));
1859
+ process.exit(1);
1860
+ } finally {
1861
+ await cleanup(tempDir);
1862
+ }
1863
+ }
1864
+
1865
+ // Cleanup helper
1866
+ async function cleanup(tempDir: string | null) {
1867
+ if (tempDir) {
1868
+ try {
1869
+ await cleanupTempDir(tempDir);
1870
+ } catch {
1871
+ // Ignore cleanup errors
1872
+ }
1873
+ }
1874
+ }
1875
+
1876
+ /**
1877
+ * Prompt user to install the find-skills skill after their first installation.
1878
+ */
1879
+ async function promptForFindSkills(
1880
+ options?: AddOptions,
1881
+ targetAgents?: AgentType[]
1882
+ ): Promise<void> {
1883
+ // Skip if already dismissed or not in interactive mode
1884
+ if (!process.stdin.isTTY) return;
1885
+ if (options?.yes) return;
1886
+
1887
+ try {
1888
+ const dismissed = await isPromptDismissed('findSkillsPrompt');
1889
+ if (dismissed) return;
1890
+
1891
+ // Check if find-skills is already installed
1892
+ const findSkillsInstalled = await isSkillInstalled('find-skills', 'claude-code', {
1893
+ global: true,
1894
+ });
1895
+ if (findSkillsInstalled) {
1896
+ // Mark as dismissed so we don't check again
1897
+ await dismissPrompt('findSkillsPrompt');
1898
+ return;
1899
+ }
1900
+
1901
+ console.log();
1902
+ p.log.message(pc.dim("One-time prompt - you won't be asked again if you dismiss."));
1903
+ const install = await p.confirm({
1904
+ message: `Install the ${pc.cyan('find-skills')} skill? It helps your agent discover and suggest skills.`,
1905
+ });
1906
+
1907
+ if (p.isCancel(install)) {
1908
+ await dismissPrompt('findSkillsPrompt');
1909
+ return;
1910
+ }
1911
+
1912
+ if (install) {
1913
+ // Install find-skills to the same agents the user selected, excluding replit
1914
+ await dismissPrompt('findSkillsPrompt');
1915
+
1916
+ // Filter out replit from target agents
1917
+ const findSkillsAgents = targetAgents?.filter((a) => a !== 'replit');
1918
+
1919
+ // Skip if no valid agents remain after filtering
1920
+ if (!findSkillsAgents || findSkillsAgents.length === 0) {
1921
+ return;
1922
+ }
1923
+
1924
+ console.log();
1925
+ p.log.step('Installing find-skills skill...');
1926
+
1927
+ try {
1928
+ // Call runAdd directly
1929
+ await runAdd(['vercel-labs/skills'], {
1930
+ skill: ['find-skills'],
1931
+ global: true,
1932
+ yes: true,
1933
+ agent: findSkillsAgents,
1934
+ });
1935
+ } catch {
1936
+ p.log.warn('Failed to install find-skills. You can try again with:');
1937
+ p.log.message(pc.dim(' npx skills add vercel-labs/skills@find-skills -g -y --all'));
1938
+ }
1939
+ } else {
1940
+ // User declined - dismiss the prompt
1941
+ await dismissPrompt('findSkillsPrompt');
1942
+ p.log.message(
1943
+ pc.dim('You can install it later with: npx skills add vercel-labs/skills@find-skills')
1944
+ );
1945
+ }
1946
+ } catch {
1947
+ // Don't fail the main installation if prompt fails
1948
+ }
1949
+ }
1950
+
1951
+ // Parse command line options from args array
1952
+ export function parseAddOptions(args: string[]): { source: string[]; options: AddOptions } {
1953
+ const options: AddOptions = {};
1954
+ const source: string[] = [];
1955
+
1956
+ for (let i = 0; i < args.length; i++) {
1957
+ const arg = args[i];
1958
+
1959
+ if (arg === '-g' || arg === '--global') {
1960
+ options.global = true;
1961
+ } else if (arg === '-y' || arg === '--yes') {
1962
+ options.yes = true;
1963
+ } else if (arg === '-l' || arg === '--list') {
1964
+ options.list = true;
1965
+ } else if (arg === '--all') {
1966
+ options.all = true;
1967
+ } else if (arg === '-a' || arg === '--agent') {
1968
+ options.agent = options.agent || [];
1969
+ i++;
1970
+ let nextArg = args[i];
1971
+ while (i < args.length && nextArg && !nextArg.startsWith('-')) {
1972
+ options.agent.push(nextArg);
1973
+ i++;
1974
+ nextArg = args[i];
1975
+ }
1976
+ i--; // Back up one since the loop will increment
1977
+ } else if (arg === '-s' || arg === '--skill') {
1978
+ options.skill = options.skill || [];
1979
+ i++;
1980
+ let nextArg = args[i];
1981
+ while (i < args.length && nextArg && !nextArg.startsWith('-')) {
1982
+ options.skill.push(nextArg);
1983
+ i++;
1984
+ nextArg = args[i];
1985
+ }
1986
+ i--; // Back up one since the loop will increment
1987
+ } else if (arg === '--full-depth') {
1988
+ options.fullDepth = true;
1989
+ } else if (arg === '--copy') {
1990
+ options.copy = true;
1991
+ } else if (arg === '--dangerously-accept-openclaw-risks') {
1992
+ options.dangerouslyAcceptOpenclawRisks = true;
1993
+ } else if (arg && !arg.startsWith('-')) {
1994
+ source.push(arg);
1995
+ }
1996
+ }
1997
+
1998
+ return { source, options };
1999
+ }