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