ai-agent-skills 1.9.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +4 -0
  2. package/cli.js +332 -7
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -42,6 +42,9 @@ npx ai-agent-skills browse
42
42
  npx ai-agent-skills install anthropics/skills
43
43
  npx ai-agent-skills install anthropics/skills/pdf # specific skill
44
44
 
45
+ # Install from any git URL (SSH or HTTPS)
46
+ npx ai-agent-skills install git@github.com:anthropics/skills.git
47
+
45
48
  # Install from local path
46
49
  npx ai-agent-skills install ./my-custom-skill
47
50
  ```
@@ -135,6 +138,7 @@ npx ai-agent-skills list --installed --agent cursor
135
138
  npx ai-agent-skills install <name> # installs to ALL agents
136
139
  npx ai-agent-skills install <name> --agent cursor # install to specific agent only
137
140
  npx ai-agent-skills install <owner/repo> # from GitHub (all agents)
141
+ npx ai-agent-skills install <git-url> # from any git URL (ssh/https)
138
142
  npx ai-agent-skills install ./path # from local path (all agents)
139
143
  npx ai-agent-skills install <name> --dry-run # preview only
140
144
 
package/cli.js CHANGED
@@ -620,6 +620,93 @@ function updateFromGitHub(meta, skillName, agent, destPath, dryRun) {
620
620
  }
621
621
  }
622
622
 
623
+ function updateFromGitUrl(meta, skillName, agent, destPath, dryRun) {
624
+ const { execFileSync } = require('child_process');
625
+ const parsed = parseGitUrl(meta.url);
626
+ const url = parsed.url;
627
+ const ref = meta.ref || parsed.ref;
628
+
629
+ // Validate URL from metadata
630
+ try {
631
+ validateGitUrl(url);
632
+ } catch (e) {
633
+ error(`Invalid git URL in metadata: ${e.message}. Try reinstalling the skill.`);
634
+ return false;
635
+ }
636
+
637
+ if (dryRun) {
638
+ log(`\n${colors.bold}Dry Run${colors.reset} (no changes made)\n`);
639
+ info(`Would update: ${skillName} (from git:${url}${ref ? `#${ref}` : ''})`);
640
+ info(`Agent: ${agent}`);
641
+ info(`Path: ${destPath}`);
642
+ return true;
643
+ }
644
+
645
+ // Use secure temp directory creation
646
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-skills-update-'));
647
+
648
+ try {
649
+ info(`Updating ${skillName} from ${url}${ref ? `#${ref}` : ''}...`);
650
+ const cloneArgs = ['clone', '--depth', '1'];
651
+ if (ref) {
652
+ cloneArgs.push('--branch', ref);
653
+ }
654
+ cloneArgs.push(url, tempDir);
655
+ execFileSync('git', cloneArgs, { stdio: 'pipe' });
656
+
657
+ let sourcePath;
658
+ if (meta.isRootSkill) {
659
+ sourcePath = tempDir;
660
+ } else if (meta.skillPath) {
661
+ const skillsSubdir = path.join(tempDir, 'skills', meta.skillPath);
662
+ const directPath = path.join(tempDir, meta.skillPath);
663
+ sourcePath = fs.existsSync(skillsSubdir) ? skillsSubdir : directPath;
664
+ } else {
665
+ sourcePath = tempDir;
666
+ }
667
+
668
+ if (!fs.existsSync(sourcePath) || !fs.existsSync(path.join(sourcePath, 'SKILL.md'))) {
669
+ error(`Skill not found in repository ${url}`);
670
+ fs.rmSync(tempDir, { recursive: true });
671
+ return false;
672
+ }
673
+
674
+ fs.rmSync(destPath, { recursive: true });
675
+ copyDir(sourcePath, destPath);
676
+
677
+ // Sanitize URL before storing
678
+ const sanitizedUrl = sanitizeGitUrl(url);
679
+
680
+ writeSkillMeta(destPath, {
681
+ ...meta,
682
+ source: 'git',
683
+ url: sanitizedUrl,
684
+ ref: ref || null
685
+ });
686
+
687
+ fs.rmSync(tempDir, { recursive: true });
688
+
689
+ success(`\nUpdated: ${skillName}`);
690
+ info(`Source: git:${url}${ref ? `#${ref}` : ''}`);
691
+ info(`Agent: ${agent}`);
692
+ info(`Location: ${destPath}`);
693
+ return true;
694
+ } catch (e) {
695
+ // Provide more helpful error messages for common git failures
696
+ let errorMsg = e.message;
697
+ if (e.message.includes('not found') || e.message.includes('Repository not found')) {
698
+ errorMsg = `Repository not found. The URL may have changed or been removed.`;
699
+ } else if (e.message.includes('Authentication failed') || e.message.includes('Permission denied')) {
700
+ errorMsg = `Authentication failed. Check your credentials or SSH key.`;
701
+ } else if (e.message.includes('Could not resolve host')) {
702
+ errorMsg = `Could not resolve host. Check your network connection.`;
703
+ }
704
+ error(`Failed to update from git: ${errorMsg}`);
705
+ try { fs.rmSync(tempDir, { recursive: true }); } catch {}
706
+ return false;
707
+ }
708
+ }
709
+
623
710
  // Update from local path
624
711
  function updateFromLocalPath(meta, skillName, agent, destPath, dryRun) {
625
712
  const sourcePath = meta.path;
@@ -696,6 +783,8 @@ function updateSkill(skillName, agent = 'claude', dryRun = false) {
696
783
  switch (meta.source) {
697
784
  case 'github':
698
785
  return updateFromGitHub(meta, skillName, agent, destPath, dryRun);
786
+ case 'git':
787
+ return updateFromGitUrl(meta, skillName, agent, destPath, dryRun);
699
788
  case 'local':
700
789
  return updateFromLocalPath(meta, skillName, agent, destPath, dryRun);
701
790
  case 'registry':
@@ -991,6 +1080,96 @@ function isGitHubUrl(source) {
991
1080
  !isWindowsPath(source);
992
1081
  }
993
1082
 
1083
+ function isGitUrl(source) {
1084
+ if (!source || typeof source !== 'string') return false;
1085
+
1086
+ // Avoid treating local filesystem paths as git URLs
1087
+ if (isLocalPath(source)) return false;
1088
+
1089
+ // SSH-style: git@host:path (with optional .git suffix and #ref)
1090
+ const sshLike = /^git@[a-zA-Z0-9._-]+:[a-zA-Z0-9._\/-]+(?:\.git)?(?:#[a-zA-Z0-9._\/-]+)?$/;
1091
+ // Protocol URLs: https://, git://, ssh://, file:// (allows @ for user in ssh://git@host)
1092
+ const protocolLike = /^(https?|git|ssh|file):\/\/[a-zA-Z0-9._@:\/-]+(?:#[a-zA-Z0-9._\/-]+)?$/;
1093
+
1094
+ return sshLike.test(source) || protocolLike.test(source);
1095
+ }
1096
+
1097
+ function parseGitUrl(source) {
1098
+ if (!source || typeof source !== 'string') return { url: null, ref: null };
1099
+ // Split on first # only (ref might contain special chars)
1100
+ const hashIndex = source.indexOf('#');
1101
+ if (hashIndex === -1) {
1102
+ return { url: source, ref: null };
1103
+ }
1104
+ return {
1105
+ url: source.slice(0, hashIndex),
1106
+ ref: source.slice(hashIndex + 1) || null
1107
+ };
1108
+ }
1109
+
1110
+ function getRepoNameFromUrl(url) {
1111
+ if (!url || typeof url !== 'string') return null;
1112
+
1113
+ // Remove trailing slashes and .git suffix
1114
+ let cleaned = url.replace(/\/+$/, '').replace(/\.git$/, '');
1115
+
1116
+ // Handle SSH URLs: git@host:org/repo -> extract 'repo'
1117
+ if (cleaned.includes('@') && cleaned.includes(':')) {
1118
+ const colonIndex = cleaned.lastIndexOf(':');
1119
+ const pathPart = cleaned.slice(colonIndex + 1);
1120
+ const segments = pathPart.split('/').filter(Boolean);
1121
+ return segments.length > 0 ? segments[segments.length - 1] : null;
1122
+ }
1123
+
1124
+ // Handle protocol URLs: extract last path segment
1125
+ const segments = cleaned.split('/').filter(Boolean);
1126
+ return segments.length > 0 ? segments[segments.length - 1] : null;
1127
+ }
1128
+
1129
+ // Validate git URL to prevent malformed/malicious input
1130
+ function validateGitUrl(url) {
1131
+ if (!url || typeof url !== 'string') {
1132
+ throw new Error('Invalid git URL: empty or not a string');
1133
+ }
1134
+
1135
+ // Max reasonable URL length
1136
+ if (url.length > 2048) {
1137
+ throw new Error('Git URL too long (max 2048 characters)');
1138
+ }
1139
+
1140
+ // Check for dangerous characters that could cause issues
1141
+ const dangerousChars = /[\x00-\x1f\x7f`$\\]/;
1142
+ if (dangerousChars.test(url)) {
1143
+ throw new Error('Git URL contains invalid characters');
1144
+ }
1145
+
1146
+ // Must match expected patterns
1147
+ if (!isGitUrl(url)) {
1148
+ throw new Error('Invalid git URL format');
1149
+ }
1150
+
1151
+ return true;
1152
+ }
1153
+
1154
+ // Sanitize URL for storage (remove credentials if present)
1155
+ function sanitizeGitUrl(url) {
1156
+ if (!url) return url;
1157
+ try {
1158
+ // Handle protocol URLs
1159
+ if (url.includes('://')) {
1160
+ const parsed = new URL(url);
1161
+ // Remove any embedded credentials
1162
+ parsed.username = '';
1163
+ parsed.password = '';
1164
+ return parsed.toString();
1165
+ }
1166
+ // SSH URLs don't typically have credentials embedded
1167
+ return url;
1168
+ } catch {
1169
+ return url;
1170
+ }
1171
+ }
1172
+
994
1173
  function isWindowsPath(source) {
995
1174
  // Match Windows absolute paths like C:\, D:\, etc.
996
1175
  return /^[a-zA-Z]:[\\\/]/.test(source);
@@ -1184,6 +1363,148 @@ async function installFromGitHub(source, agent = 'claude', dryRun = false) {
1184
1363
  }
1185
1364
  }
1186
1365
 
1366
+ async function installFromGitUrl(source, agent = 'claude', dryRun = false) {
1367
+ const { execFileSync } = require('child_process');
1368
+ const { url, ref } = parseGitUrl(source);
1369
+
1370
+ // Validate URL format and safety
1371
+ try {
1372
+ validateGitUrl(url);
1373
+ if (ref && !/^[a-zA-Z0-9._\/-]+$/.test(ref)) {
1374
+ throw new Error('Invalid ref format');
1375
+ }
1376
+ } catch (e) {
1377
+ error(`Invalid git URL: ${e.message}`);
1378
+ return false;
1379
+ }
1380
+
1381
+ const repoName = getRepoNameFromUrl(url);
1382
+ if (!repoName) {
1383
+ error('Could not determine repository name from git URL');
1384
+ return false;
1385
+ }
1386
+
1387
+ // Use secure temp directory creation
1388
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-skills-'));
1389
+
1390
+ if (dryRun) {
1391
+ log(`\n${colors.bold}Dry Run${colors.reset} (no changes made)\n`);
1392
+ info(`Would clone: ${url}${ref ? `#${ref}` : ''}`);
1393
+ info('Would install skills discovered in repository');
1394
+ info(`Agent: ${agent}`);
1395
+ return true;
1396
+ }
1397
+
1398
+ try {
1399
+ info(`Cloning ${url}${ref ? `#${ref}` : ''}...`);
1400
+ const cloneArgs = ['clone', '--depth', '1'];
1401
+ if (ref) {
1402
+ cloneArgs.push('--branch', ref);
1403
+ }
1404
+ cloneArgs.push(url, tempDir);
1405
+ execFileSync('git', cloneArgs, { stdio: 'pipe' });
1406
+
1407
+ const skillsDir = fs.existsSync(path.join(tempDir, 'skills'))
1408
+ ? path.join(tempDir, 'skills')
1409
+ : tempDir;
1410
+
1411
+ const isRootSkill = fs.existsSync(path.join(tempDir, 'SKILL.md'));
1412
+
1413
+ if (isRootSkill) {
1414
+ const skillName = repoName.toLowerCase()
1415
+ .replace(/[^a-z0-9-]/g, '-')
1416
+ .replace(/-+/g, '-')
1417
+ .replace(/^-|-$/g, '');
1418
+
1419
+ try {
1420
+ validateSkillName(skillName);
1421
+ } catch (e) {
1422
+ error(`Cannot install: repo name "${repoName}" cannot be converted to valid skill name`);
1423
+ fs.rmSync(tempDir, { recursive: true });
1424
+ return false;
1425
+ }
1426
+
1427
+ const destDir = AGENT_PATHS[agent] || AGENT_PATHS.claude;
1428
+ const destPath = path.join(destDir, skillName);
1429
+
1430
+ if (!fs.existsSync(destDir)) {
1431
+ fs.mkdirSync(destDir, { recursive: true });
1432
+ }
1433
+
1434
+ copyDir(tempDir, destPath);
1435
+
1436
+ // Sanitize URL before storing in metadata
1437
+ const sanitizedUrl = sanitizeGitUrl(url);
1438
+
1439
+ writeSkillMeta(destPath, {
1440
+ source: 'git',
1441
+ url: sanitizedUrl,
1442
+ ref: ref || null,
1443
+ isRootSkill: true
1444
+ });
1445
+
1446
+ success(`\nInstalled: ${skillName} from ${url}`);
1447
+ info(`Location: ${destPath}`);
1448
+ } else {
1449
+ const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
1450
+ let installed = 0;
1451
+
1452
+ // Sanitize URL before storing in metadata
1453
+ const sanitizedUrl = sanitizeGitUrl(url);
1454
+
1455
+ for (const entry of entries) {
1456
+ if (entry.isDirectory()) {
1457
+ const skillPath = path.join(skillsDir, entry.name);
1458
+ if (fs.existsSync(path.join(skillPath, 'SKILL.md'))) {
1459
+ const destDir = AGENT_PATHS[agent] || AGENT_PATHS.claude;
1460
+ const destPath = path.join(destDir, entry.name);
1461
+
1462
+ if (!fs.existsSync(destDir)) {
1463
+ fs.mkdirSync(destDir, { recursive: true });
1464
+ }
1465
+
1466
+ copyDir(skillPath, destPath);
1467
+
1468
+ writeSkillMeta(destPath, {
1469
+ source: 'git',
1470
+ url: sanitizedUrl,
1471
+ ref: ref || null,
1472
+ skillPath: entry.name
1473
+ });
1474
+
1475
+ log(` ${colors.green}✓${colors.reset} ${entry.name}`);
1476
+ installed++;
1477
+ }
1478
+ }
1479
+ }
1480
+
1481
+ if (installed > 0) {
1482
+ success(`\nInstalled ${installed} skill(s) from ${url}`);
1483
+ } else {
1484
+ warn('No skills found in repository');
1485
+ }
1486
+ }
1487
+
1488
+ fs.rmSync(tempDir, { recursive: true });
1489
+ return true;
1490
+ } catch (e) {
1491
+ // Provide more helpful error messages for common git failures
1492
+ let errorMsg = e.message;
1493
+ if (e.message.includes('not found') || e.message.includes('Repository not found')) {
1494
+ errorMsg = `Repository not found. Check the URL is correct and you have access.`;
1495
+ } else if (e.message.includes('Authentication failed') || e.message.includes('Permission denied')) {
1496
+ errorMsg = `Authentication failed. For SSH URLs, ensure your SSH key is configured. For HTTPS, check credentials.`;
1497
+ } else if (e.message.includes('Could not resolve host')) {
1498
+ errorMsg = `Could not resolve host. Check your network connection and the URL.`;
1499
+ } else if (e.message.includes('Connection refused') || e.message.includes('Connection timed out')) {
1500
+ errorMsg = `Connection failed. Check your network connection.`;
1501
+ }
1502
+ error(`Failed to install from git: ${errorMsg}`);
1503
+ try { fs.rmSync(tempDir, { recursive: true }); } catch {}
1504
+ return false;
1505
+ }
1506
+ }
1507
+
1187
1508
  function installFromLocalPath(source, agent = 'claude', dryRun = false) {
1188
1509
  const sourcePath = expandPath(source);
1189
1510
 
@@ -1286,6 +1607,7 @@ ${colors.bold}Commands:${colors.reset}
1286
1607
  ${colors.green}install <name>${colors.reset} Install to ALL agents (default)
1287
1608
  ${colors.green}install <name> --agent cursor${colors.reset} Install to specific agent only
1288
1609
  ${colors.green}install <owner/repo>${colors.reset} Install from GitHub repository
1610
+ ${colors.green}install <git-url>${colors.reset} Install from any git URL (ssh/https)
1289
1611
  ${colors.green}install ./path${colors.reset} Install from local path
1290
1612
  ${colors.green}install <name> --dry-run${colors.reset} Preview installation without changes
1291
1613
  ${colors.green}uninstall <name>${colors.reset} Remove an installed skill
@@ -1323,13 +1645,14 @@ ${colors.bold}Categories:${colors.reset}
1323
1645
  development, document, creative, business, productivity
1324
1646
 
1325
1647
  ${colors.bold}Examples:${colors.reset}
1326
- npx ai-agent-skills browse # Interactive browser
1327
- npx ai-agent-skills install frontend-design # Install to ALL agents
1328
- npx ai-agent-skills install pdf --agent cursor # Install to Cursor only
1329
- npx ai-agent-skills install pdf --agents claude,cursor # Install to specific agents
1330
- npx ai-agent-skills install anthropics/skills # Install from GitHub
1331
- npx ai-agent-skills install ./my-skill # Install from local path
1332
- npx ai-agent-skills install pdf --dry-run # Preview install
1648
+ npx ai-agent-skills browse # Interactive browser
1649
+ npx ai-agent-skills install frontend-design # Install to ALL agents
1650
+ npx ai-agent-skills install pdf --agent cursor # Install to Cursor only
1651
+ npx ai-agent-skills install pdf --agents claude,cursor # Install to specific agents
1652
+ npx ai-agent-skills install anthropics/skills # Install from GitHub
1653
+ npx ai-agent-skills install git@example.com:user/repo.git # Install from any git URL (ssh/https)
1654
+ npx ai-agent-skills install ./my-skill # Install from local path
1655
+ npx ai-agent-skills install pdf --dry-run # Preview install
1333
1656
  npx ai-agent-skills list --category development
1334
1657
  npx ai-agent-skills search testing
1335
1658
  npx ai-agent-skills update --all
@@ -1488,6 +1811,8 @@ switch (command || 'help') {
1488
1811
  for (const agent of installTargets) {
1489
1812
  if (isLocalPath(param)) {
1490
1813
  installFromLocalPath(param, agent, dryRun);
1814
+ } else if (isGitUrl(param)) {
1815
+ installFromGitUrl(param, agent, dryRun);
1491
1816
  } else if (isGitHubUrl(param)) {
1492
1817
  installFromGitHub(param, agent, dryRun);
1493
1818
  } else {
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "ai-agent-skills",
3
- "version": "1.9.0",
3
+ "version": "1.9.1",
4
4
  "description": "Install curated AI agent skills with one command. Works with Claude Code, Cursor, Codex, Gemini CLI, VS Code, Copilot, and 11+ agents.",
5
5
  "main": "cli.js",
6
6
  "bin": {
7
- "ai-agent-skills": "./cli.js",
8
- "skills": "./cli.js"
7
+ "ai-agent-skills": "cli.js",
8
+ "skills": "cli.js"
9
9
  },
10
10
  "engines": {
11
11
  "node": ">=14.14.0"