ali-skills 0.0.3 → 0.0.4

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