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

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