reskill 1.16.0-beta.1 → 1.17.0

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/cli/index.js CHANGED
@@ -3473,6 +3473,7 @@ class RegistryClient {
3473
3473
  formData.append('metadata', JSON.stringify(metadata));
3474
3474
  if (payload.skillMd?.allowedTools) formData.append('allowed_tools', payload.skillMd.allowedTools.join(' '));
3475
3475
  if (options.tag) formData.append('tag', options.tag);
3476
+ if (options.groupPath) formData.append('group_path', options.groupPath);
3476
3477
  // Append tarball as Blob
3477
3478
  const tarballBlob = new Blob([
3478
3479
  tarball
@@ -3490,6 +3491,186 @@ class RegistryClient {
3490
3491
  if (!response.ok) throw new RegistryError(data.error || `Publish failed: ${response.status}`, response.status, data);
3491
3492
  return data;
3492
3493
  }
3494
+ // ============================================================================
3495
+ // Group Methods
3496
+ // ============================================================================
3497
+ /**
3498
+ * Resolve a human-readable group path to its details.
3499
+ *
3500
+ * @param groupPath - Human-readable path (e.g., "kanyun/frontend")
3501
+ * @returns Group detail with current_user_role if authenticated
3502
+ * @throws RegistryError if not found or request failed
3503
+ */ async resolveGroup(groupPath) {
3504
+ const params = new URLSearchParams({
3505
+ path: groupPath
3506
+ });
3507
+ const url = `${this.getApiBase()}/skill-groups/resolve?${params.toString()}`;
3508
+ const response = await fetch(url, {
3509
+ method: 'GET',
3510
+ headers: this.getAuthHeaders()
3511
+ });
3512
+ if (!response.ok) {
3513
+ const data = await response.json();
3514
+ throw new RegistryError(data.error || `Group not found: ${groupPath}`, response.status, data);
3515
+ }
3516
+ const body = await response.json();
3517
+ return body.data;
3518
+ }
3519
+ /**
3520
+ * List groups visible to the current user.
3521
+ *
3522
+ * @param options - Filter options (parent_id, visibility)
3523
+ * @returns Array of groups
3524
+ */ async listGroups(options = {}) {
3525
+ const params = new URLSearchParams();
3526
+ if (options.parentId) params.set('parent_id', options.parentId);
3527
+ if (options.visibility) params.set('visibility', options.visibility);
3528
+ if (options.flat) params.set('flat', 'true');
3529
+ const qs = params.toString();
3530
+ const url = `${this.getApiBase()}/skill-groups${qs ? `?${qs}` : ''}`;
3531
+ const response = await fetch(url, {
3532
+ method: 'GET',
3533
+ headers: this.getAuthHeaders()
3534
+ });
3535
+ if (!response.ok) {
3536
+ const data = await response.json();
3537
+ throw new RegistryError(data.error || `Failed to list groups: ${response.status}`, response.status, data);
3538
+ }
3539
+ const body = await response.json();
3540
+ return body.data;
3541
+ }
3542
+ /**
3543
+ * Create a new skill group.
3544
+ *
3545
+ * @param input - Group creation parameters
3546
+ * @returns Created group
3547
+ */ async createGroup(input) {
3548
+ const url = `${this.getApiBase()}/skill-groups`;
3549
+ const response = await fetch(url, {
3550
+ method: 'POST',
3551
+ headers: {
3552
+ ...this.getAuthHeaders(),
3553
+ 'Content-Type': 'application/json'
3554
+ },
3555
+ body: JSON.stringify(input)
3556
+ });
3557
+ if (!response.ok) {
3558
+ const data = await response.json();
3559
+ throw new RegistryError(data.error || `Failed to create group: ${response.status}`, response.status, data);
3560
+ }
3561
+ const body = await response.json();
3562
+ return body.data;
3563
+ }
3564
+ /**
3565
+ * Delete a skill group.
3566
+ *
3567
+ * @param groupId - Group UUID
3568
+ * @param dryRun - If true, only preview what would be deleted
3569
+ * @returns Deletion result (affected skills count in dry-run mode)
3570
+ */ async deleteGroup(groupId, dryRun = false) {
3571
+ const params = dryRun ? '?dry_run=true' : '';
3572
+ const encodedGroupId = encodeURIComponent(groupId);
3573
+ const url = `${this.getApiBase()}/skill-groups/${encodedGroupId}${params}`;
3574
+ const response = await fetch(url, {
3575
+ method: 'DELETE',
3576
+ headers: this.getAuthHeaders()
3577
+ });
3578
+ if (!response.ok) {
3579
+ const data = await response.json();
3580
+ throw new RegistryError(data.error || `Failed to delete group: ${response.status}`, response.status, data);
3581
+ }
3582
+ const body = await response.json();
3583
+ return body.data;
3584
+ }
3585
+ /**
3586
+ * List members of a group.
3587
+ *
3588
+ * @param groupId - Group UUID
3589
+ * @returns Array of members
3590
+ */ async listGroupMembers(groupId) {
3591
+ const encodedGroupId = encodeURIComponent(groupId);
3592
+ const url = `${this.getApiBase()}/skill-groups/${encodedGroupId}/members`;
3593
+ const response = await fetch(url, {
3594
+ method: 'GET',
3595
+ headers: this.getAuthHeaders()
3596
+ });
3597
+ if (!response.ok) {
3598
+ const data = await response.json();
3599
+ throw new RegistryError(data.error || `Failed to list members: ${response.status}`, response.status, data);
3600
+ }
3601
+ const body = await response.json();
3602
+ return body.data;
3603
+ }
3604
+ /**
3605
+ * Add members to a group.
3606
+ *
3607
+ * @param groupId - Group UUID
3608
+ * @param userIds - Array of user IDs to add
3609
+ * @param role - Role to assign (defaults to 'developer')
3610
+ */ async addGroupMembers(groupId, userIds, role = 'developer') {
3611
+ const encodedGroupId = encodeURIComponent(groupId);
3612
+ const url = `${this.getApiBase()}/skill-groups/${encodedGroupId}/members`;
3613
+ const response = await fetch(url, {
3614
+ method: 'POST',
3615
+ headers: {
3616
+ ...this.getAuthHeaders(),
3617
+ 'Content-Type': 'application/json'
3618
+ },
3619
+ body: JSON.stringify({
3620
+ user_ids: userIds,
3621
+ role
3622
+ })
3623
+ });
3624
+ if (!response.ok) {
3625
+ const data = await response.json();
3626
+ throw new RegistryError(data.error || `Failed to add members: ${response.status}`, response.status, data);
3627
+ }
3628
+ }
3629
+ /**
3630
+ * Remove a member from a group.
3631
+ *
3632
+ * @param groupId - Group UUID
3633
+ * @param userId - User ID to remove
3634
+ */ async removeGroupMember(groupId, userId) {
3635
+ const params = new URLSearchParams({
3636
+ user_id: userId
3637
+ });
3638
+ const encodedGroupId = encodeURIComponent(groupId);
3639
+ const url = `${this.getApiBase()}/skill-groups/${encodedGroupId}/members?${params.toString()}`;
3640
+ const response = await fetch(url, {
3641
+ method: 'DELETE',
3642
+ headers: this.getAuthHeaders()
3643
+ });
3644
+ if (!response.ok) {
3645
+ const data = await response.json();
3646
+ throw new RegistryError(data.error || `Failed to remove member: ${response.status}`, response.status, data);
3647
+ }
3648
+ }
3649
+ /**
3650
+ * Update a member's role in a group.
3651
+ *
3652
+ * @param groupId - Group UUID
3653
+ * @param userId - User ID to update
3654
+ * @param role - New role to assign
3655
+ */ async updateGroupMemberRole(groupId, userId, role) {
3656
+ const encodedGroupId = encodeURIComponent(groupId);
3657
+ const url = `${this.getApiBase()}/skill-groups/${encodedGroupId}/members`;
3658
+ const response = await fetch(url, {
3659
+ method: 'PATCH',
3660
+ headers: {
3661
+ ...this.getAuthHeaders(),
3662
+ 'Content-Type': 'application/json'
3663
+ },
3664
+ body: JSON.stringify({
3665
+ user_id: userId,
3666
+ role
3667
+ })
3668
+ });
3669
+ if (!response.ok) {
3670
+ const data = await response.json();
3671
+ throw new RegistryError(data.error || `Failed to update member role: ${response.status}`, response.status, data);
3672
+ }
3673
+ }
3493
3674
  }
3494
3675
  /**
3495
3676
  * Tarball Extractor (Step 3.6)
@@ -4784,15 +4965,15 @@ class RegistryResolver {
4784
4965
  /**
4785
4966
  * Install a web-published skill.
4786
4967
  *
4787
- * Web-published skills do not support versioning. Branches to different
4968
+ * Web-published skills (except local) do not support versioning. Branches to different
4788
4969
  * installation logic based on source_type:
4789
4970
  * - github/gitlab: reuses installToAgentsFromGit
4790
4971
  * - oss_url/custom_url: reuses installToAgentsFromHttp
4791
4972
  * - local: downloads tarball via Registry API
4792
4973
  */ async installFromWebPublished(skillInfo, parsed, targetAgents, options = {}) {
4793
4974
  const { source_type, source_url } = skillInfo;
4794
- // Web-published skills do not support version specifiers
4795
- if (parsed.version && 'latest' !== parsed.version) throw new Error(`Version specifier not supported for web-published skills.\n'${parsed.fullName}' was published via web and does not support versioning.\nUse: reskill install ${parsed.fullName}`);
4975
+ // Web-published skills (except local) do not support version specifiers
4976
+ if ('local' !== source_type && parsed.version && 'latest' !== parsed.version) throw new Error(`Version specifier not supported for web-published skills.\n'${parsed.fullName}' was published via web and does not support versioning.\nUse: reskill install ${parsed.fullName}`);
4796
4977
  if (!source_url) throw new Error(`Missing source_url for web-published skill: ${parsed.fullName}`);
4797
4978
  logger_logger["package"](`Installing ${parsed.fullName} from ${source_type} source...`);
4798
4979
  // Build registry context so downstream methods use the registry name
@@ -4883,12 +5064,13 @@ class RegistryResolver {
4883
5064
  const { save = true, mode = 'symlink' } = options;
4884
5065
  const registryUrl = await this.resolveRegistryUrl(parsed.fullName, options.registry);
4885
5066
  const shortName = getShortName(parsed.fullName);
4886
- const version = 'latest';
4887
- // Download tarball via RegistryClient (handles auth + 302 redirect to signed URL)
5067
+ // Resolve version via dist-tags (supports @latest, @1.0.0, etc.)
4888
5068
  const client = new RegistryClient({
4889
5069
  registry: registryUrl,
4890
5070
  token: options.token
4891
5071
  });
5072
+ const version = await client.resolveVersion(parsed.fullName, parsed.version);
5073
+ // Download tarball via RegistryClient (handles auth + 302 redirect to signed URL)
4892
5074
  const { tarball } = await client.downloadSkill(parsed.fullName, version);
4893
5075
  logger_logger["package"](`Installing ${shortName} from ${registryUrl} to ${targetAgents.length} agent(s)...`);
4894
5076
  // Extract tarball to temp directory (clean stale files first)
@@ -6420,113 +6602,513 @@ class AuthManager {
6420
6602
  // ============================================================================
6421
6603
  const findCommand = new __WEBPACK_EXTERNAL_MODULE_commander__.Command('find').alias('search').description('Search for skills in the registry').argument('<query>', 'Search query').option('-r, --registry <url>', 'Registry URL (or set RESKILL_REGISTRY env var, or defaults.publishRegistry in skills.json)').option('-l, --limit <n>', 'Maximum number of results', '10').option('-j, --json', 'Output as JSON').action(findAction);
6422
6604
  /**
6423
- * info command - Show skill details
6424
- */ const infoCommand = new __WEBPACK_EXTERNAL_MODULE_commander__.Command('info').description('Show skill details').argument('<skill>', 'Skill name').option('-j, --json', 'Output as JSON').action((skillName, options)=>{
6425
- const skillManager = new SkillManager();
6426
- const info = skillManager.getInfo(skillName);
6427
- if (options.json) {
6428
- console.log(JSON.stringify(info, null, 2));
6429
- return;
6605
+ * Group path utilities normalization, slug generation, and validation.
6606
+ *
6607
+ * Shared by the `group` and `publish` CLI commands.
6608
+ */ const SLUG_REGEX = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
6609
+ const MAX_GROUP_DEPTH = 3;
6610
+ const MAX_SEGMENT_LENGTH = 64;
6611
+ /**
6612
+ * Normalize a group path for API usage.
6613
+ *
6614
+ * Rules from spec §13.2:
6615
+ * - Strip leading/trailing slashes and whitespace
6616
+ * - Collapse consecutive slashes
6617
+ * - Lowercase
6618
+ */ function normalizeGroupPath(raw) {
6619
+ return raw.trim().replace(/\/+/g, '/').replace(/^\/|\/$/g, '').toLowerCase();
6620
+ }
6621
+ /**
6622
+ * Generate a URL-safe slug from a human-readable name.
6623
+ *
6624
+ * Spec §13.4:
6625
+ * - Lowercase, trim, replace spaces/underscores with hyphens
6626
+ * - Strip non-alphanumeric characters (except hyphens)
6627
+ * - Collapse consecutive hyphens, strip leading/trailing hyphens
6628
+ * - Truncate to MAX_SEGMENT_LENGTH characters
6629
+ */ function generateSlug(name) {
6630
+ return name.toLowerCase().trim().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, MAX_SEGMENT_LENGTH).replace(/-$/g, '');
6631
+ }
6632
+ /**
6633
+ * Validate a normalized group path.
6634
+ *
6635
+ * Spec §13.2:
6636
+ * - Segment slug must match SLUG_REGEX
6637
+ * - Segment length <= 64
6638
+ * - Max depth <= 3
6639
+ */ function validateGroupPath(path) {
6640
+ if (!path) return {
6641
+ valid: false,
6642
+ error: 'Group path cannot be empty'
6643
+ };
6644
+ const segments = path.split('/');
6645
+ if (segments.length > MAX_GROUP_DEPTH) return {
6646
+ valid: false,
6647
+ error: `Group path depth cannot exceed ${MAX_GROUP_DEPTH} segments`
6648
+ };
6649
+ for (const segment of segments){
6650
+ if (segment.length > MAX_SEGMENT_LENGTH) return {
6651
+ valid: false,
6652
+ error: `Group path segment "${segment}" exceeds ${MAX_SEGMENT_LENGTH} characters`
6653
+ };
6654
+ if (!SLUG_REGEX.test(segment)) return {
6655
+ valid: false,
6656
+ error: `Invalid group path segment "${segment}". Segments must match ${SLUG_REGEX}`
6657
+ };
6430
6658
  }
6431
- if (!info.installed && !info.config) {
6432
- logger_logger.error(`Skill ${skillName} not found`);
6659
+ return {
6660
+ valid: true
6661
+ };
6662
+ }
6663
+ /**
6664
+ * group command - Manage skill groups
6665
+ *
6666
+ * Provides subcommands for listing, creating, inspecting, and deleting
6667
+ * skill groups, as well as managing group membership.
6668
+ *
6669
+ * Usage:
6670
+ * reskill group list # List visible groups
6671
+ * reskill group create <name> # Create a group
6672
+ * reskill group info <path> # Show group details
6673
+ * reskill group delete <path> # Delete a group
6674
+ * reskill group member list <path> # List members
6675
+ * reskill group member add <path> <users...> # Add members
6676
+ * reskill group member remove <path> <user> # Remove a member
6677
+ * reskill group member role <path> <user> <role> # Change member role
6678
+ */ // ============================================================================
6679
+ // Constants
6680
+ // ============================================================================
6681
+ const VALID_ROLES = [
6682
+ 'owner',
6683
+ 'maintainer',
6684
+ 'developer'
6685
+ ];
6686
+ /**
6687
+ * Validate that a role string is one of the allowed values.
6688
+ */ function validateRole(role) {
6689
+ return VALID_ROLES.includes(role);
6690
+ }
6691
+ function assertValidGroupPath(path) {
6692
+ const validation = validateGroupPath(path);
6693
+ if (!validation.valid) {
6694
+ logger_logger.error(validation.error);
6433
6695
  process.exit(1);
6434
6696
  }
6435
- logger_logger.log(`Skill: ${skillName}`);
6436
- logger_logger.newline();
6437
- if (info.config) {
6438
- logger_logger.log("Configuration (skills.json):");
6439
- logger_logger.log(` Reference: ${info.config}`);
6440
- }
6441
- if (info.locked) {
6442
- logger_logger.log("Locked Version (skills.lock):");
6443
- logger_logger.log(` Version: ${info.locked.version}`);
6444
- logger_logger.log(` Source: ${info.locked.source}`);
6445
- logger_logger.log(` Commit: ${info.locked.commit}`);
6446
- logger_logger.log(` Installed: ${info.locked.installedAt}`);
6447
- }
6448
- if (info.installed) {
6449
- logger_logger.log("Installed:");
6450
- logger_logger.log(` Path: ${info.installed.path}`);
6451
- logger_logger.log(` Version: ${info.installed.version}`);
6452
- logger_logger.log(` Linked: ${info.installed.isLinked ? 'Yes' : 'No'}`);
6453
- if (info.installed.metadata) {
6454
- const meta = info.installed.metadata;
6455
- logger_logger.log("Metadata (SKILL.md):");
6456
- if (meta.description) logger_logger.log(` Description: ${meta.description}`);
6457
- if (meta.author) logger_logger.log(` Author: ${meta.author}`);
6458
- if (meta.license) logger_logger.log(` License: ${meta.license}`);
6459
- if (meta.keywords?.length) logger_logger.log(` Keywords: ${meta.keywords.join(', ')}`);
6460
- }
6461
- } else logger_logger.warn(`Skill ${skillName} is not installed`);
6462
- });
6697
+ }
6463
6698
  // ============================================================================
6464
- // Constants
6699
+ // Client Factory
6465
6700
  // ============================================================================
6466
- const DEFAULT_INSTALL_DIR = '.skills';
6701
+ function createClient(registry) {
6702
+ const authManager = new AuthManager();
6703
+ const token = authManager.getToken(registry);
6704
+ if (!token) {
6705
+ logger_logger.error('Authentication required');
6706
+ logger_logger.newline();
6707
+ logger_logger.log("Run 'reskill login' to authenticate.");
6708
+ process.exit(1);
6709
+ }
6710
+ return new RegistryClient({
6711
+ registry,
6712
+ token
6713
+ });
6714
+ }
6467
6715
  // ============================================================================
6468
- // Helper Functions
6716
+ // Display Helpers
6469
6717
  // ============================================================================
6470
- /**
6471
- * Display configuration summary after initialization
6472
- */ function displayConfigSummary(installDir) {
6473
- logger_logger.success('Created skills.json');
6718
+ function getGroupIndentLevel(group) {
6719
+ if ('number' == typeof group.level && Number.isFinite(group.level)) return Math.max(0, group.level - 1);
6720
+ return Math.max(0, group.path.split('/').length - 1);
6721
+ }
6722
+ function displayGroupList(groups, json, tree = false) {
6723
+ if (json) {
6724
+ console.log(JSON.stringify(groups, null, 2));
6725
+ return;
6726
+ }
6727
+ if (0 === groups.length) {
6728
+ logger_logger.warn('No groups found');
6729
+ return;
6730
+ }
6474
6731
  logger_logger.newline();
6475
- logger_logger.log('Configuration:');
6476
- logger_logger.log(` Install directory: ${installDir}`);
6732
+ logger_logger.log(`Found ${__WEBPACK_EXTERNAL_MODULE_chalk__["default"].bold(String(groups.length))} group${1 === groups.length ? '' : 's'}:`);
6733
+ logger_logger.newline();
6734
+ if (tree) {
6735
+ const sortedGroups = [
6736
+ ...groups
6737
+ ].sort((a, b)=>a.path.localeCompare(b.path));
6738
+ for (const group of sortedGroups){
6739
+ const vis = 'private' === group.visibility ? __WEBPACK_EXTERNAL_MODULE_chalk__["default"].yellow(' (private)') : '';
6740
+ const desc = group.description ? __WEBPACK_EXTERNAL_MODULE_chalk__["default"].gray(` - ${group.description}`) : '';
6741
+ const indent = ' '.repeat(getGroupIndentLevel(group));
6742
+ const nodeLabel = group.path.split('/').pop() || group.path;
6743
+ logger_logger.log(` ${indent}└─ ${__WEBPACK_EXTERNAL_MODULE_chalk__["default"].bold.cyan(nodeLabel)}${vis}${desc} ${__WEBPACK_EXTERNAL_MODULE_chalk__["default"].gray(`(${group.path})`)}`);
6744
+ }
6745
+ logger_logger.newline();
6746
+ return;
6747
+ }
6748
+ for (const group of groups){
6749
+ const vis = 'private' === group.visibility ? __WEBPACK_EXTERNAL_MODULE_chalk__["default"].yellow(' (private)') : '';
6750
+ const desc = group.description ? __WEBPACK_EXTERNAL_MODULE_chalk__["default"].gray(` - ${group.description}`) : '';
6751
+ logger_logger.log(` ${__WEBPACK_EXTERNAL_MODULE_chalk__["default"].bold.cyan(group.path)}${vis}${desc}`);
6752
+ const meta = [];
6753
+ if (void 0 !== group.skill_count) meta.push(`${group.skill_count} skill(s)`);
6754
+ if (void 0 !== group.member_count) meta.push(`${group.member_count} member(s)`);
6755
+ if (meta.length > 0) logger_logger.log(` ${__WEBPACK_EXTERNAL_MODULE_chalk__["default"].gray(meta.join(' · '))}`);
6756
+ }
6477
6757
  logger_logger.newline();
6478
- logger_logger.log('Next steps:');
6479
- logger_logger.log(' reskill install <skill> Install a skill');
6480
- logger_logger.log(' reskill list List installed skills');
6481
6758
  }
6482
- // ============================================================================
6483
- // Command Definition
6484
- // ============================================================================
6485
- /**
6486
- * init command - Initialize skills.json configuration
6487
- *
6488
- * Creates a new skills.json file in the current directory with default settings.
6489
- * Will not overwrite an existing skills.json file.
6490
- *
6491
- * @example
6492
- * ```bash
6493
- * # Initialize with defaults
6494
- * reskill init
6495
- *
6496
- * # Initialize with custom install directory
6497
- * reskill init -d my-skills
6498
- *
6499
- * # Skip prompts (for CI/scripts)
6500
- * reskill init -y
6501
- * ```
6502
- */ const initCommand = new __WEBPACK_EXTERNAL_MODULE_commander__.Command('init').description('Initialize a new skills.json configuration').option('-d, --install-dir <dir>', 'Skills installation directory', DEFAULT_INSTALL_DIR).option('-y, --yes', 'Skip prompts and use defaults').action((options)=>{
6503
- const configLoader = new ConfigLoader();
6504
- // Check if configuration already exists
6505
- if (configLoader.exists()) {
6506
- logger_logger.warn('skills.json already exists');
6759
+ function displayGroupDetail(detail, json) {
6760
+ if (json) {
6761
+ console.log(JSON.stringify(detail, null, 2));
6507
6762
  return;
6508
6763
  }
6509
- // Create new configuration
6510
- configLoader.create({
6511
- defaults: {
6512
- installDir: options.installDir
6513
- }
6514
- });
6515
- // Display summary (use options.installDir directly since we just set it)
6516
- displayConfigSummary(options.installDir);
6517
- });
6764
+ logger_logger.newline();
6765
+ logger_logger.log(`${__WEBPACK_EXTERNAL_MODULE_chalk__["default"].bold(detail.name)} ${__WEBPACK_EXTERNAL_MODULE_chalk__["default"].gray(`(${detail.path})`)}`);
6766
+ if (detail.description) logger_logger.log(` ${detail.description}`);
6767
+ logger_logger.newline();
6768
+ const vis = 'private' === detail.visibility ? __WEBPACK_EXTERNAL_MODULE_chalk__["default"].yellow('private') : __WEBPACK_EXTERNAL_MODULE_chalk__["default"].green('public');
6769
+ logger_logger.log(` Visibility: ${vis}`);
6770
+ logger_logger.log(` Level: ${detail.level}`);
6771
+ if (void 0 !== detail.skill_count) logger_logger.log(` Skills: ${detail.skill_count}`);
6772
+ if (void 0 !== detail.member_count) logger_logger.log(` Members: ${detail.member_count}`);
6773
+ if (detail.current_user_role) logger_logger.log(` Your role: ${__WEBPACK_EXTERNAL_MODULE_chalk__["default"].bold(detail.current_user_role)}`);
6774
+ if (detail.children && detail.children.length > 0) {
6775
+ logger_logger.newline();
6776
+ logger_logger.log(' Sub groups:');
6777
+ for (const child of detail.children)logger_logger.log(` • ${child.path}`);
6778
+ }
6779
+ logger_logger.newline();
6780
+ }
6781
+ function displayMemberList(members, groupPath, json) {
6782
+ if (json) {
6783
+ console.log(JSON.stringify(members, null, 2));
6784
+ return;
6785
+ }
6786
+ if (0 === members.length) {
6787
+ logger_logger.warn(`No members in group "${groupPath}"`);
6788
+ return;
6789
+ }
6790
+ logger_logger.newline();
6791
+ logger_logger.log(`${__WEBPACK_EXTERNAL_MODULE_chalk__["default"].bold(String(members.length))} member${1 === members.length ? '' : 's'} in ${__WEBPACK_EXTERNAL_MODULE_chalk__["default"].bold(groupPath)}:`);
6792
+ logger_logger.newline();
6793
+ for (const member of members){
6794
+ const role = __WEBPACK_EXTERNAL_MODULE_chalk__["default"].bold(member.role);
6795
+ const handle = member.handle || member.user_id;
6796
+ logger_logger.log(` ${handle} ${role}`);
6797
+ }
6798
+ logger_logger.newline();
6799
+ }
6518
6800
  // ============================================================================
6519
- // Utility Functions
6801
+ // Subcommand Actions
6520
6802
  // ============================================================================
6521
- /**
6522
- * Format agent names list for display
6523
- * Truncates long lists with "+N more" suffix
6524
- */ function formatAgentNames(agentTypes, maxShow = 5) {
6525
- const names = agentTypes.map((a)=>agents[a].displayName);
6526
- if (names.length <= maxShow) return names.join(', ');
6527
- const shown = names.slice(0, maxShow);
6528
- const remaining = names.length - maxShow;
6529
- return `${shown.join(', ')} +${remaining} more`;
6803
+ async function listAction(options) {
6804
+ const registry = resolveRegistry(options.registry);
6805
+ const client = createClient(registry);
6806
+ try {
6807
+ const groups = await client.listGroups({
6808
+ flat: Boolean(options.tree)
6809
+ });
6810
+ displayGroupList(groups, options.json || false, Boolean(options.tree));
6811
+ } catch (error) {
6812
+ logger_logger.error(`Failed to list groups: ${error.message}`);
6813
+ process.exit(1);
6814
+ }
6815
+ }
6816
+ async function createAction(name, options) {
6817
+ const registry = resolveRegistry(options.registry);
6818
+ const slug = generateSlug(name);
6819
+ if (!slug) {
6820
+ logger_logger.error('Name must contain at least one ASCII alphanumeric character to generate a valid slug');
6821
+ process.exit(1);
6822
+ }
6823
+ if (!SLUG_REGEX.test(slug)) {
6824
+ logger_logger.error(`Generated slug "${slug}" is invalid. Name must produce a slug matching ${SLUG_REGEX.source}`);
6825
+ process.exit(1);
6826
+ }
6827
+ let parentId;
6828
+ let client;
6829
+ if (options.parent) {
6830
+ const normalizedParent = normalizeGroupPath(options.parent);
6831
+ assertValidGroupPath(normalizedParent);
6832
+ client = createClient(registry);
6833
+ try {
6834
+ const parentGroup = await client.resolveGroup(normalizedParent);
6835
+ parentId = parentGroup.id;
6836
+ } catch (error) {
6837
+ if (error instanceof RegistryError) logger_logger.error(`Parent group "${options.parent}" not found`);
6838
+ else logger_logger.error(`Failed to resolve parent: ${error.message}`);
6839
+ process.exit(1);
6840
+ }
6841
+ }
6842
+ try {
6843
+ const ensuredClient = client ?? createClient(registry);
6844
+ const group = await ensuredClient.createGroup({
6845
+ name,
6846
+ slug,
6847
+ description: options.description,
6848
+ visibility: options.visibility,
6849
+ parent_id: parentId
6850
+ });
6851
+ if (options.json) console.log(JSON.stringify(group, null, 2));
6852
+ else {
6853
+ logger_logger.newline();
6854
+ logger_logger.success(`Group created: ${__WEBPACK_EXTERNAL_MODULE_chalk__["default"].bold(group.path)}`);
6855
+ logger_logger.log(` ID: ${group.id}`);
6856
+ logger_logger.log(` Visibility: ${group.visibility}`);
6857
+ logger_logger.newline();
6858
+ }
6859
+ } catch (error) {
6860
+ logger_logger.error(`Failed to create group: ${error.message}`);
6861
+ process.exit(1);
6862
+ }
6863
+ }
6864
+ async function infoAction(groupPath, options) {
6865
+ const normalized = normalizeGroupPath(groupPath);
6866
+ assertValidGroupPath(normalized);
6867
+ const registry = resolveRegistry(options.registry);
6868
+ const client = createClient(registry);
6869
+ try {
6870
+ const detail = await client.resolveGroup(normalized);
6871
+ displayGroupDetail(detail, options.json || false);
6872
+ } catch (error) {
6873
+ if (error instanceof RegistryError) {
6874
+ if (404 === error.statusCode) logger_logger.error(`Group "${groupPath}" not found`);
6875
+ else logger_logger.error(`Failed to get group info: ${error.message}`);
6876
+ } else logger_logger.error(`Failed to get group info: ${error.message}`);
6877
+ process.exit(1);
6878
+ }
6879
+ }
6880
+ async function deleteAction(groupPath, options) {
6881
+ const normalized = normalizeGroupPath(groupPath);
6882
+ assertValidGroupPath(normalized);
6883
+ const registry = resolveRegistry(options.registry);
6884
+ const client = createClient(registry);
6885
+ try {
6886
+ const detail = await client.resolveGroup(normalized);
6887
+ if (options.dryRun) {
6888
+ const result = await client.deleteGroup(detail.id, true);
6889
+ logger_logger.newline();
6890
+ logger_logger.log(`Dry run: deleting group "${__WEBPACK_EXTERNAL_MODULE_chalk__["default"].bold(groupPath)}"`);
6891
+ if (void 0 !== result.affected_skills) logger_logger.log(` Affected skills: ${result.affected_skills}`);
6892
+ logger_logger.log('No changes made (--dry-run)');
6893
+ logger_logger.newline();
6894
+ return;
6895
+ }
6896
+ if (!options.yes) {
6897
+ const { createInterface } = await import("node:readline");
6898
+ const rl = createInterface({
6899
+ input: process.stdin,
6900
+ output: process.stdout
6901
+ });
6902
+ const answer = await new Promise((resolve)=>{
6903
+ rl.question(`\n? Delete group "${groupPath}" and all its contents? (y/N) `, resolve);
6904
+ rl.once('close', ()=>resolve(''));
6905
+ });
6906
+ rl.close();
6907
+ if ('y' !== answer.trim().toLowerCase() && 'yes' !== answer.trim().toLowerCase()) {
6908
+ logger_logger.log('Cancelled.');
6909
+ return;
6910
+ }
6911
+ }
6912
+ await client.deleteGroup(detail.id, false);
6913
+ logger_logger.success(`Group "${groupPath}" deleted`);
6914
+ } catch (error) {
6915
+ if (error instanceof RegistryError) {
6916
+ if (404 === error.statusCode) logger_logger.error(`Group "${groupPath}" not found`);
6917
+ else logger_logger.error(`Failed to delete group: ${error.message}`);
6918
+ } else logger_logger.error(`Failed to delete group: ${error.message}`);
6919
+ process.exit(1);
6920
+ }
6921
+ }
6922
+ // ============================================================================
6923
+ // Member Subcommand Actions
6924
+ // ============================================================================
6925
+ async function memberListAction(groupPath, options) {
6926
+ const normalized = normalizeGroupPath(groupPath);
6927
+ assertValidGroupPath(normalized);
6928
+ const registry = resolveRegistry(options.registry);
6929
+ const client = createClient(registry);
6930
+ try {
6931
+ const detail = await client.resolveGroup(normalized);
6932
+ const members = await client.listGroupMembers(detail.id);
6933
+ displayMemberList(members, groupPath, options.json || false);
6934
+ } catch (error) {
6935
+ logger_logger.error(`Failed to list members: ${error.message}`);
6936
+ process.exit(1);
6937
+ }
6938
+ }
6939
+ async function memberAddAction(groupPath, userIds, options) {
6940
+ const normalized = normalizeGroupPath(groupPath);
6941
+ assertValidGroupPath(normalized);
6942
+ const registry = resolveRegistry(options.registry);
6943
+ const client = createClient(registry);
6944
+ const role = options.role || 'developer';
6945
+ if (!validateRole(role)) {
6946
+ logger_logger.error(`Invalid role "${role}". Must be one of: ${VALID_ROLES.join(', ')}`);
6947
+ process.exit(1);
6948
+ }
6949
+ try {
6950
+ const detail = await client.resolveGroup(normalized);
6951
+ await client.addGroupMembers(detail.id, userIds, role);
6952
+ logger_logger.success(`Added ${userIds.length} member(s) to "${groupPath}" as ${__WEBPACK_EXTERNAL_MODULE_chalk__["default"].bold(role)}`);
6953
+ } catch (error) {
6954
+ logger_logger.error(`Failed to add members: ${error.message}`);
6955
+ process.exit(1);
6956
+ }
6957
+ }
6958
+ async function memberRemoveAction(groupPath, userId, options) {
6959
+ const normalized = normalizeGroupPath(groupPath);
6960
+ assertValidGroupPath(normalized);
6961
+ const registry = resolveRegistry(options.registry);
6962
+ const client = createClient(registry);
6963
+ try {
6964
+ const detail = await client.resolveGroup(normalized);
6965
+ await client.removeGroupMember(detail.id, userId);
6966
+ logger_logger.success(`Removed "${userId}" from "${groupPath}"`);
6967
+ } catch (error) {
6968
+ logger_logger.error(`Failed to remove member: ${error.message}`);
6969
+ process.exit(1);
6970
+ }
6971
+ }
6972
+ async function memberRoleAction(groupPath, userId, role, options) {
6973
+ const normalized = normalizeGroupPath(groupPath);
6974
+ assertValidGroupPath(normalized);
6975
+ const registry = resolveRegistry(options.registry);
6976
+ const client = createClient(registry);
6977
+ if (!validateRole(role)) {
6978
+ logger_logger.error(`Invalid role "${role}". Must be one of: ${VALID_ROLES.join(', ')}`);
6979
+ process.exit(1);
6980
+ }
6981
+ try {
6982
+ const detail = await client.resolveGroup(normalized);
6983
+ await client.updateGroupMemberRole(detail.id, userId, role);
6984
+ logger_logger.success(`Updated role of "${userId}" in "${groupPath}" to ${__WEBPACK_EXTERNAL_MODULE_chalk__["default"].bold(role)}`);
6985
+ } catch (error) {
6986
+ logger_logger.error(`Failed to update role: ${error.message}`);
6987
+ process.exit(1);
6988
+ }
6989
+ }
6990
+ // ============================================================================
6991
+ // Command Definitions
6992
+ // ============================================================================
6993
+ const memberCommand = new __WEBPACK_EXTERNAL_MODULE_commander__.Command('member').description('Manage group members');
6994
+ memberCommand.command('list <path>').description('List members of a group').option('-r, --registry <url>', 'Registry URL').option('-j, --json', 'Output as JSON').action(memberListAction);
6995
+ memberCommand.command('add <path> <users...>').description('Add members to a group').option('-r, --registry <url>', 'Registry URL').option('--role <role>', 'Role to assign (owner|maintainer|developer)', 'developer').action(memberAddAction);
6996
+ memberCommand.command('remove <path> <user>').description('Remove a member from a group').option('-r, --registry <url>', 'Registry URL').action(memberRemoveAction);
6997
+ memberCommand.command('role <path> <user> <role>').description("Change a member's role").option('-r, --registry <url>', 'Registry URL').action(memberRoleAction);
6998
+ const groupCommand = new __WEBPACK_EXTERNAL_MODULE_commander__.Command('group').description('Manage skill groups');
6999
+ groupCommand.command('list').description('List visible groups').option('-r, --registry <url>', 'Registry URL').option('--tree', 'Render groups as a tree (requests flat group list)').option('-j, --json', 'Output as JSON').action(listAction);
7000
+ groupCommand.command('create <name>').description('Create a new group').option('-r, --registry <url>', 'Registry URL').option('-d, --description <text>', 'Group description').option('--visibility <level>', 'Visibility: public or private', 'public').option('--parent <path>', 'Parent group path (for sub groups)').option('-j, --json', 'Output as JSON').action(createAction);
7001
+ groupCommand.command('info <path>').description('Show group details').option('-r, --registry <url>', 'Registry URL').option('-j, --json', 'Output as JSON').action(infoAction);
7002
+ groupCommand.command('delete <path>').description('Delete a group').option('-r, --registry <url>', 'Registry URL').option('-n, --dry-run', 'Preview deletion without executing').option('-y, --yes', 'Skip confirmation').action(deleteAction);
7003
+ groupCommand.addCommand(memberCommand);
7004
+ /**
7005
+ * info command - Show skill details
7006
+ */ const infoCommand = new __WEBPACK_EXTERNAL_MODULE_commander__.Command('info').description('Show skill details').argument('<skill>', 'Skill name').option('-j, --json', 'Output as JSON').action((skillName, options)=>{
7007
+ const skillManager = new SkillManager();
7008
+ const info = skillManager.getInfo(skillName);
7009
+ if (options.json) {
7010
+ console.log(JSON.stringify(info, null, 2));
7011
+ return;
7012
+ }
7013
+ if (!info.installed && !info.config) {
7014
+ logger_logger.error(`Skill ${skillName} not found`);
7015
+ process.exit(1);
7016
+ }
7017
+ logger_logger.log(`Skill: ${skillName}`);
7018
+ logger_logger.newline();
7019
+ if (info.config) {
7020
+ logger_logger.log("Configuration (skills.json):");
7021
+ logger_logger.log(` Reference: ${info.config}`);
7022
+ }
7023
+ if (info.locked) {
7024
+ logger_logger.log("Locked Version (skills.lock):");
7025
+ logger_logger.log(` Version: ${info.locked.version}`);
7026
+ logger_logger.log(` Source: ${info.locked.source}`);
7027
+ logger_logger.log(` Commit: ${info.locked.commit}`);
7028
+ logger_logger.log(` Installed: ${info.locked.installedAt}`);
7029
+ }
7030
+ if (info.installed) {
7031
+ logger_logger.log("Installed:");
7032
+ logger_logger.log(` Path: ${info.installed.path}`);
7033
+ logger_logger.log(` Version: ${info.installed.version}`);
7034
+ logger_logger.log(` Linked: ${info.installed.isLinked ? 'Yes' : 'No'}`);
7035
+ if (info.installed.metadata) {
7036
+ const meta = info.installed.metadata;
7037
+ logger_logger.log("Metadata (SKILL.md):");
7038
+ if (meta.description) logger_logger.log(` Description: ${meta.description}`);
7039
+ if (meta.author) logger_logger.log(` Author: ${meta.author}`);
7040
+ if (meta.license) logger_logger.log(` License: ${meta.license}`);
7041
+ if (meta.keywords?.length) logger_logger.log(` Keywords: ${meta.keywords.join(', ')}`);
7042
+ }
7043
+ } else logger_logger.warn(`Skill ${skillName} is not installed`);
7044
+ });
7045
+ // ============================================================================
7046
+ // Constants
7047
+ // ============================================================================
7048
+ const DEFAULT_INSTALL_DIR = '.skills';
7049
+ // ============================================================================
7050
+ // Helper Functions
7051
+ // ============================================================================
7052
+ /**
7053
+ * Display configuration summary after initialization
7054
+ */ function displayConfigSummary(installDir) {
7055
+ logger_logger.success('Created skills.json');
7056
+ logger_logger.newline();
7057
+ logger_logger.log('Configuration:');
7058
+ logger_logger.log(` Install directory: ${installDir}`);
7059
+ logger_logger.newline();
7060
+ logger_logger.log('Next steps:');
7061
+ logger_logger.log(' reskill install <skill> Install a skill');
7062
+ logger_logger.log(' reskill list List installed skills');
7063
+ }
7064
+ // ============================================================================
7065
+ // Command Definition
7066
+ // ============================================================================
7067
+ /**
7068
+ * init command - Initialize skills.json configuration
7069
+ *
7070
+ * Creates a new skills.json file in the current directory with default settings.
7071
+ * Will not overwrite an existing skills.json file.
7072
+ *
7073
+ * @example
7074
+ * ```bash
7075
+ * # Initialize with defaults
7076
+ * reskill init
7077
+ *
7078
+ * # Initialize with custom install directory
7079
+ * reskill init -d my-skills
7080
+ *
7081
+ * # Skip prompts (for CI/scripts)
7082
+ * reskill init -y
7083
+ * ```
7084
+ */ const initCommand = new __WEBPACK_EXTERNAL_MODULE_commander__.Command('init').description('Initialize a new skills.json configuration').option('-d, --install-dir <dir>', 'Skills installation directory', DEFAULT_INSTALL_DIR).option('-y, --yes', 'Skip prompts and use defaults').action((options)=>{
7085
+ const configLoader = new ConfigLoader();
7086
+ // Check if configuration already exists
7087
+ if (configLoader.exists()) {
7088
+ logger_logger.warn('skills.json already exists');
7089
+ return;
7090
+ }
7091
+ // Create new configuration
7092
+ configLoader.create({
7093
+ defaults: {
7094
+ installDir: options.installDir
7095
+ }
7096
+ });
7097
+ // Display summary (use options.installDir directly since we just set it)
7098
+ displayConfigSummary(options.installDir);
7099
+ });
7100
+ // ============================================================================
7101
+ // Utility Functions
7102
+ // ============================================================================
7103
+ /**
7104
+ * Format agent names list for display
7105
+ * Truncates long lists with "+N more" suffix
7106
+ */ function formatAgentNames(agentTypes, maxShow = 5) {
7107
+ const names = agentTypes.map((a)=>agents[a].displayName);
7108
+ if (names.length <= maxShow) return names.join(', ');
7109
+ const shown = names.slice(0, maxShow);
7110
+ const remaining = names.length - maxShow;
7111
+ return `${shown.join(', ')} +${remaining} more`;
6530
7112
  }
6531
7113
  /**
6532
7114
  * Format agent names with chalk coloring
@@ -7483,955 +8065,956 @@ const logoutCommand = new __WEBPACK_EXTERNAL_MODULE_commander__.Command('logout'
7483
8065
  }
7484
8066
  });
7485
8067
  /**
7486
- * Publisher - Handle Git information and publish payload building
8068
+ * ContentScanner - Detect malicious patterns in SKILL.md content
7487
8069
  *
7488
- * Extracts Git metadata and builds the payload for publishing to registry.
7489
- */ class PublishError extends Error {
7490
- constructor(message){
7491
- super(message);
7492
- this.name = 'PublishError';
7493
- }
7494
- }
7495
- // ============================================================================
7496
- // Publisher Class
8070
+ * Features:
8071
+ * - Context-aware: skips safe zones (frontmatter, code blocks, quotes, blockquotes)
8072
+ * - 6 built-in detection rules across 3 risk levels
8073
+ * - Configurable: override levels, disable rules, add custom rules
8074
+ * - Pure string operations in scan() — no fs dependency, suitable for server use
8075
+ * - scanFile() convenience method for CLI use
8076
+ */ // ============================================================================
8077
+ // Safe Zone Masking
7497
8078
  // ============================================================================
7498
- class Publisher {
7499
- /**
7500
- * Get Git information from a skill directory
7501
- */ async getGitInfo(skillPath, specifiedTag) {
7502
- const info = {
7503
- isRepo: false,
7504
- remoteUrl: null,
7505
- currentBranch: null,
7506
- currentCommit: null,
7507
- commitDate: null,
7508
- tag: null,
7509
- tagCommit: null,
7510
- isDirty: false,
7511
- sourceRef: null
7512
- };
7513
- // Check if it's a git repository
7514
- try {
7515
- (0, __WEBPACK_EXTERNAL_MODULE_node_child_process__.execSync)('git rev-parse --git-dir', {
7516
- cwd: skillPath,
7517
- stdio: 'pipe'
7518
- });
7519
- info.isRepo = true;
7520
- } catch {
7521
- return info;
7522
- }
7523
- // Get remote URL
7524
- try {
7525
- info.remoteUrl = (0, __WEBPACK_EXTERNAL_MODULE_node_child_process__.execSync)('git remote get-url origin', {
7526
- cwd: skillPath,
7527
- encoding: 'utf-8'
7528
- }).trim();
7529
- // Parse to sourceRef format
7530
- info.sourceRef = this.parseRemoteToSourceRef(info.remoteUrl);
7531
- } catch {
7532
- // No remote configured
7533
- }
7534
- // Get current branch
7535
- try {
7536
- info.currentBranch = (0, __WEBPACK_EXTERNAL_MODULE_node_child_process__.execSync)('git branch --show-current', {
7537
- cwd: skillPath,
7538
- encoding: 'utf-8'
7539
- }).trim() || null;
7540
- } catch {
7541
- // Detached HEAD or other error
7542
- }
7543
- // Get current commit
7544
- try {
7545
- info.currentCommit = (0, __WEBPACK_EXTERNAL_MODULE_node_child_process__.execSync)('git rev-parse HEAD', {
7546
- cwd: skillPath,
7547
- encoding: 'utf-8'
7548
- }).trim();
7549
- // Get commit date
7550
- info.commitDate = (0, __WEBPACK_EXTERNAL_MODULE_node_child_process__.execSync)('git show -s --format=%cI HEAD', {
7551
- cwd: skillPath,
7552
- encoding: 'utf-8'
7553
- }).trim();
7554
- } catch {
7555
- // No commits yet
7556
- }
7557
- // Get tag
7558
- if (specifiedTag) // Use specified tag
7559
- try {
7560
- info.tagCommit = (0, __WEBPACK_EXTERNAL_MODULE_node_child_process__.execSync)(`git rev-parse "${specifiedTag}^{commit}"`, {
7561
- cwd: skillPath,
7562
- encoding: 'utf-8'
7563
- }).trim();
7564
- info.tag = specifiedTag;
7565
- } catch {
7566
- throw new PublishError(`Tag "${specifiedTag}" not found`);
7567
- }
7568
- else // Try to get tag on current commit
7569
- try {
7570
- const tag = (0, __WEBPACK_EXTERNAL_MODULE_node_child_process__.execSync)('git describe --exact-match --tags HEAD', {
7571
- cwd: skillPath,
7572
- encoding: 'utf-8'
7573
- }).trim();
7574
- info.tag = tag;
7575
- info.tagCommit = info.currentCommit;
7576
- } catch {
7577
- // No tag on current commit
8079
+ /**
8080
+ * Mask safe zones in Markdown content with spaces, preserving line structure.
8081
+ *
8082
+ * Safe zones (content replaced with spaces):
8083
+ * - YAML frontmatter (`---` ... `---` at file start)
8084
+ * - Fenced code blocks (``` or ~~~)
8085
+ * - Indented code blocks (4 spaces / tab after blank line)
8086
+ * - Blockquotes (`> ` prefix)
8087
+ * - Inline code (`` `...` ``)
8088
+ * - Double-quoted text (`"..."`, min 3 chars between quotes)
8089
+ *
8090
+ * Line breaks are preserved so line numbers remain correct.
8091
+ */ function maskSafeZones(content) {
8092
+ const lines = content.split('\n');
8093
+ const result = [];
8094
+ let inFrontmatter = false;
8095
+ let inFencedCode = false;
8096
+ let fenceChar = '';
8097
+ let fenceLength = 0;
8098
+ let prevLineBlank = false;
8099
+ let prevLineIndentedCode = false;
8100
+ for(let i = 0; i < lines.length; i++){
8101
+ const line = lines[i];
8102
+ // --- YAML Frontmatter (only at file start) ---
8103
+ if (0 === i && '---' === line.trim()) {
8104
+ inFrontmatter = true;
8105
+ result.push(maskLine(line));
8106
+ continue;
7578
8107
  }
7579
- // Check if working tree is dirty
7580
- try {
7581
- const status = (0, __WEBPACK_EXTERNAL_MODULE_node_child_process__.execSync)('git status --porcelain', {
7582
- cwd: skillPath,
7583
- encoding: 'utf-8'
7584
- }).trim();
7585
- info.isDirty = status.length > 0;
7586
- } catch {
7587
- // Ignore errors
8108
+ if (inFrontmatter) {
8109
+ result.push(maskLine(line));
8110
+ if ('---' === line.trim()) inFrontmatter = false;
8111
+ continue;
7588
8112
  }
7589
- return info;
7590
- }
7591
- /**
7592
- * Parse remote URL to sourceRef format (e.g., github:user/repo)
7593
- */ parseRemoteToSourceRef(remoteUrl) {
7594
- // SSH format: git@github.com:user/repo.git
7595
- const sshMatch = remoteUrl.match(/^git@([^:]+):([^/]+)\/(.+?)(\.git)?$/);
7596
- if (sshMatch) {
7597
- const [, host, owner, repo] = sshMatch;
7598
- const registry = this.normalizeHost(host);
7599
- return `${registry}:${owner}/${repo.replace(/\.git$/, '')}`;
8113
+ // --- Fenced code blocks (``` or ~~~) ---
8114
+ const fenceMatch = line.match(/^(`{3,}|~{3,})/);
8115
+ if (!inFencedCode && fenceMatch) {
8116
+ inFencedCode = true;
8117
+ fenceChar = fenceMatch[1][0];
8118
+ fenceLength = fenceMatch[1].length;
8119
+ result.push(maskLine(line));
8120
+ prevLineBlank = false;
8121
+ prevLineIndentedCode = false;
8122
+ continue;
7600
8123
  }
7601
- // HTTPS format: https://github.com/user/repo.git
7602
- const httpsMatch = remoteUrl.match(/^https?:\/\/([^/]+)\/([^/]+)\/(.+?)(\.git)?$/);
7603
- if (httpsMatch) {
7604
- const [, host, owner, repo] = httpsMatch;
7605
- const registry = this.normalizeHost(host);
7606
- return `${registry}:${owner}/${repo.replace(/\.git$/, '')}`;
8124
+ if (inFencedCode) {
8125
+ result.push(maskLine(line));
8126
+ const closeMatch = line.match(/^(`{3,}|~{3,})\s*$/);
8127
+ if (closeMatch && closeMatch[1][0] === fenceChar && closeMatch[1].length >= fenceLength) inFencedCode = false;
8128
+ prevLineBlank = false;
8129
+ prevLineIndentedCode = false;
8130
+ continue;
7607
8131
  }
7608
- return null;
7609
- }
7610
- /**
7611
- * Normalize host to registry name
7612
- */ normalizeHost(host) {
7613
- if ('github.com' === host) return 'github';
7614
- if ('gitlab.com' === host) return 'gitlab';
7615
- return host;
7616
- }
7617
- /**
7618
- * Build publish payload
7619
- */ buildPayload(skill, gitInfo, integrity) {
7620
- const { skillJson, skillMd, readme, files } = skill;
7621
- const payload = {
7622
- version: skillJson.version,
7623
- description: skillJson.description || '',
7624
- gitRef: gitInfo.tag || gitInfo.currentCommit || 'HEAD',
7625
- gitCommit: gitInfo.tagCommit || gitInfo.currentCommit || '',
7626
- gitCommitDate: gitInfo.commitDate || void 0,
7627
- repositoryUrl: gitInfo.remoteUrl || '',
7628
- sourceRef: gitInfo.sourceRef || '',
7629
- skillJson,
7630
- files,
7631
- entry: skillJson.entry || 'SKILL.md',
7632
- integrity
7633
- };
7634
- // Add optional fields
7635
- if (skillMd) payload.skillMd = {
7636
- name: skillMd.name,
7637
- description: skillMd.description,
7638
- license: skillMd.license,
7639
- compatibility: skillMd.compatibility,
7640
- allowedTools: skillMd.allowedTools
7641
- };
7642
- if (readme) payload.readmePreview = readme;
7643
- if (skillJson.keywords && skillJson.keywords.length > 0) payload.keywords = skillJson.keywords;
7644
- if (skillJson.compatibility) {
7645
- // Filter out undefined values from compatibility
7646
- const compat = {};
7647
- for (const [key, value] of Object.entries(skillJson.compatibility))if (void 0 !== value) compat[key] = value;
7648
- if (Object.keys(compat).length > 0) payload.compatibility = compat;
8132
+ // --- Blockquote ---
8133
+ if (/^>\s?/.test(line)) {
8134
+ result.push(maskLine(line));
8135
+ prevLineBlank = false;
8136
+ prevLineIndentedCode = false;
8137
+ continue;
7649
8138
  }
7650
- return payload;
7651
- }
7652
- /**
7653
- * Format bytes for display
7654
- */ formatBytes(bytes) {
7655
- if (bytes < 1024) return `${bytes} B`;
7656
- if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
7657
- return `${(bytes / 1048576).toFixed(1)} MB`;
7658
- }
7659
- /**
7660
- * Calculate total size of files
7661
- */ calculateTotalSize(skillPath, files) {
7662
- let total = 0;
7663
- for (const file of files){
7664
- const filePath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(skillPath, file);
7665
- if (external_node_fs_.existsSync(filePath)) {
7666
- const stats = external_node_fs_.statSync(filePath);
7667
- total += stats.size;
7668
- }
8139
+ // --- Indented code block (4 spaces or tab, after blank line) ---
8140
+ if (/^(?: |\t)/.test(line) && (prevLineBlank || prevLineIndentedCode)) {
8141
+ result.push(maskLine(line));
8142
+ prevLineBlank = false;
8143
+ prevLineIndentedCode = true;
8144
+ continue;
7669
8145
  }
7670
- return total;
8146
+ // --- Normal line: mask inline code and double-quoted text ---
8147
+ result.push(maskInline(line));
8148
+ prevLineBlank = '' === line.trim();
8149
+ prevLineIndentedCode = false;
7671
8150
  }
8151
+ return result.join('\n');
8152
+ }
8153
+ /** Replace all characters in a line with spaces (preserving length) */ function maskLine(line) {
8154
+ return ' '.repeat(line.length);
7672
8155
  }
7673
8156
  /**
7674
- * SkillValidator - Validate skills for publishing
7675
- *
7676
- * Following agentskills.io specification: https://agentskills.io/specification
7677
- *
7678
- * Key points:
7679
- * - SKILL.md is the SOLE source of metadata (name, description, version, etc.)
7680
- * - skill.json is NOT used - all metadata comes from SKILL.md frontmatter
7681
- * - Version defaults to "0.0.0" if not specified in SKILL.md
7682
- */ // ============================================================================
7683
- // Constants
7684
- // ============================================================================
7685
- const MAX_NAME_LENGTH = 64;
7686
- const MAX_DESCRIPTION_LENGTH = 1024;
7687
- const MAX_KEYWORDS = 10;
7688
- const SINGLE_CHAR_NAME_PATTERN = /^[a-z0-9]$/;
7689
- const DEFAULT_VERSION = '0.0.0';
7690
- // Default files to include in publish
7691
- const DEFAULT_FILES = [
7692
- 'SKILL.md',
7693
- 'README.md',
7694
- 'LICENSE'
7695
- ];
8157
+ * Mask inline code (`` `...` ``) and double-quoted text (`"..."`) within a line.
8158
+ * Uses regex replacement for efficiency (avoids char-by-char concatenation on long lines).
8159
+ * Single quotes are NOT masked to avoid false matches with apostrophes.
8160
+ */ function maskInline(line) {
8161
+ let result = line;
8162
+ // Inline code: `...`
8163
+ result = result.replace(/`[^`]+`/g, (m)=>' '.repeat(m.length));
8164
+ // Double-quoted text: "..." (min 3 chars between quotes)
8165
+ result = result.replace(/"[^"]{3,}"/g, (m)=>' '.repeat(m.length));
8166
+ return result;
8167
+ }
7696
8168
  // ============================================================================
7697
- // SkillValidator Class
8169
+ // Rule Helpers
7698
8170
  // ============================================================================
7699
- class SkillValidator {
7700
- /**
7701
- * Validate skill name format
7702
- *
7703
- * Requirements:
7704
- * - Lowercase letters, numbers, and hyphens only
7705
- * - 1-64 characters
7706
- * - Cannot start or end with hyphen
7707
- * - Cannot have consecutive hyphens
7708
- */ validateName(name) {
7709
- const errors = [];
7710
- if (!name) {
7711
- errors.push({
7712
- field: 'name',
7713
- message: 'Skill name is required',
7714
- suggestion: 'Add "name" field to SKILL.md frontmatter'
7715
- });
7716
- return {
7717
- valid: false,
7718
- errors,
7719
- warnings: []
7720
- };
7721
- }
7722
- if (name.length > MAX_NAME_LENGTH) errors.push({
7723
- field: 'name',
7724
- message: `Skill name must be at most ${MAX_NAME_LENGTH} characters`,
7725
- suggestion: `Shorten the name to ${MAX_NAME_LENGTH} characters or less`
8171
+ /** Find lines matching any of the given patterns, return one match per line */ function findLineMatches(content, patterns) {
8172
+ const lines = content.split('\n');
8173
+ const matches = [];
8174
+ for(let i = 0; i < lines.length; i++)for (const pattern of patterns)if (pattern.test(lines[i])) {
8175
+ matches.push({
8176
+ line: i + 1
7726
8177
  });
7727
- // Check for uppercase
7728
- if (/[A-Z]/.test(name)) errors.push({
7729
- field: 'name',
7730
- message: 'Skill name must be lowercase',
7731
- suggestion: `Change "${name}" to "${name.toLowerCase()}"`
7732
- });
7733
- // Check for invalid characters
7734
- if (/[^a-z0-9-]/.test(name)) errors.push({
7735
- field: 'name',
7736
- message: 'Skill name can only contain lowercase letters, numbers, and hyphens',
7737
- suggestion: 'Remove special characters from the name'
7738
- });
7739
- // Check pattern for multi-char names
7740
- if (1 === name.length) {
7741
- if (!SINGLE_CHAR_NAME_PATTERN.test(name)) errors.push({
7742
- field: 'name',
7743
- message: 'Single character name must be a lowercase letter or number'
7744
- });
7745
- } else if (name.length > 1) {
7746
- // Check start/end with hyphen
7747
- if (name.startsWith('-')) errors.push({
7748
- field: 'name',
7749
- message: 'Skill name cannot start with a hyphen'
8178
+ break;
8179
+ }
8180
+ return matches;
8181
+ }
8182
+ // ============================================================================
8183
+ // Default Rules
8184
+ // ============================================================================
8185
+ const SNIPPET_MAX_LENGTH = 120;
8186
+ /** Built-in detection rules */ const DEFAULT_RULES = [
8187
+ // Rule 1: Prompt Injection (high)
8188
+ {
8189
+ id: 'prompt-injection',
8190
+ level: 'high',
8191
+ message: 'Detected prompt injection attempt',
8192
+ skipSafeZones: true,
8193
+ check: (content)=>findLineMatches(content, [
8194
+ // English patterns
8195
+ /ignore\s+(all\s+)?previous\s+instructions/i,
8196
+ /disregard\s+(all\s+)?(prior|previous|above)\s+(instructions|rules|context)/i,
8197
+ /you\s+are\s+now\s+(?:(?:a|an)\s+)?(?:(?:\w+\s+){0,3}(?:agent|ai|assistant|bot|model|character|persona|entity|system)|DAN\b|jailbr\w*|unrestricted|unfiltered|free\s+from)/i,
8198
+ /from\s+now\s+on[,\s]+you\s+are/i,
8199
+ /new\s+system\s+prompt/i,
8200
+ /override\s+(your|the)\s+(system|safety|security)\s+(prompt|rules|instructions)/i,
8201
+ /forget\s+(?:all\s+)?(?:your\s+)?(?:previous\s+|prior\s+)?(?:instructions|rules|constraints)/i,
8202
+ /(?:you\s+are|you're)\s+(?:now\s+)?entering\s+(?:a\s+)?new\s+(?:mode|context|session)/i,
8203
+ // Chinese patterns (中文提示词注入)
8204
+ /[忽无][略视]\s*(所有\s*)?(之前的?|先前的?|以前的?)?\s*(指令|指示|规则|约束|限制)/,
8205
+ /你现在是/,
8206
+ /从现在开始.{0,10}你是/,
8207
+ /新的系统提示词/,
8208
+ /[覆改]写?\s*(你的|系统)\s*(提示词|规则|指令|安全)/,
8209
+ /忘记\s*(所有\s*)?(之前的?|先前的?)?\s*(指令|指示|规则|约束)/,
8210
+ /进入.{0,5}新的?\s*(模式|上下文|会话)/,
8211
+ /不要遵守.{0,10}(安全|限制|规则|约束)/,
8212
+ /解除.{0,5}(限制|约束|安全)/,
8213
+ /无限制模式/,
8214
+ /安全模式已关闭/
8215
+ ])
8216
+ },
8217
+ // Rule 2: Data Exfiltration (high)
8218
+ {
8219
+ id: 'data-exfiltration',
8220
+ level: 'high',
8221
+ message: 'Detected potential data exfiltration command',
8222
+ skipSafeZones: true,
8223
+ check: (content)=>{
8224
+ const lines = content.split('\n');
8225
+ const matches = [];
8226
+ const commandPattern = /\b(curl|wget|fetch|http\.post|requests\.post|nc\b|ncat|netcat)\b/i;
8227
+ const sensitivePattern = /(\$[A-Z_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH)[A-Z_]*|\$ENV\b|\$\{[^}]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[^}]*\})/i;
8228
+ for(let i = 0; i < lines.length; i++)if (commandPattern.test(lines[i]) && sensitivePattern.test(lines[i])) matches.push({
8229
+ line: i + 1
7750
8230
  });
7751
- if (name.endsWith('-')) errors.push({
7752
- field: 'name',
7753
- message: 'Skill name cannot end with a hyphen'
8231
+ return matches;
8232
+ }
8233
+ },
8234
+ // Rule 3: Content Obfuscation (high) — scans ALL content including safe zones
8235
+ {
8236
+ id: 'obfuscation',
8237
+ level: 'high',
8238
+ message: 'Detected content obfuscation',
8239
+ skipSafeZones: false,
8240
+ check: (content)=>{
8241
+ const matches = [];
8242
+ const lines = content.split('\n');
8243
+ // Zero-width characters (suspicious in any context)
8244
+ const zeroWidthPattern = /[\u200B\u200C\u200D\uFEFF\u2060\u180E]/;
8245
+ for(let i = 0; i < lines.length; i++)if (zeroWidthPattern.test(lines[i])) matches.push({
8246
+ line: i + 1,
8247
+ snippet: 'Zero-width Unicode characters detected'
7754
8248
  });
7755
- // Check consecutive hyphens
7756
- if (/--/.test(name)) errors.push({
7757
- field: 'name',
7758
- message: 'Skill name cannot contain consecutive hyphens'
8249
+ // Long base64-like strings (>200 continuous chars)
8250
+ const base64Pattern = /[A-Za-z0-9+/=]{200,}/;
8251
+ for(let i = 0; i < lines.length; i++)if (base64Pattern.test(lines[i])) matches.push({
8252
+ line: i + 1,
8253
+ snippet: 'Suspicious base64-encoded block detected'
7759
8254
  });
8255
+ // Large HTML comments (>200 chars of content)
8256
+ const commentRegex = /<!--([\s\S]{200,}?)-->/g;
8257
+ let match;
8258
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard regex exec loop
8259
+ while(null !== (match = commentRegex.exec(content))){
8260
+ const lineNum = content.slice(0, match.index).split('\n').length;
8261
+ matches.push({
8262
+ line: lineNum,
8263
+ snippet: `Large HTML comment block (${match[1].length} chars)`
8264
+ });
8265
+ }
8266
+ return matches;
7760
8267
  }
7761
- return {
7762
- valid: 0 === errors.length,
7763
- errors,
7764
- warnings: []
7765
- };
7766
- }
7767
- /**
7768
- * Validate version format (semver)
7769
- */ validateVersion(version) {
7770
- const errors = [];
7771
- if (!version) {
7772
- errors.push({
7773
- field: 'version',
7774
- message: 'Version is required',
7775
- suggestion: 'Add "version" field to SKILL.md frontmatter (e.g., "1.0.0")'
7776
- });
7777
- return {
7778
- valid: false,
7779
- errors,
7780
- warnings: []
7781
- };
8268
+ },
8269
+ // Rule 4: Sensitive File Access (medium)
8270
+ {
8271
+ id: 'sensitive-file-access',
8272
+ level: 'medium',
8273
+ message: 'References sensitive file path',
8274
+ skipSafeZones: true,
8275
+ check: (content)=>findLineMatches(content, [
8276
+ /~\/\.ssh\b/,
8277
+ /~\/\.aws\b/,
8278
+ /~\/\.gnupg\b/,
8279
+ /~\/\.config\/gcloud\b/,
8280
+ /\bid_rsa\b/i,
8281
+ /\bid_ed25519\b/i,
8282
+ /\/etc\/passwd\b/,
8283
+ /\/etc\/shadow\b/,
8284
+ /\.env\b(?!\.\w)/
8285
+ ])
8286
+ },
8287
+ // Rule 5: Stealth Instructions (medium) — phrase + action verb matching
8288
+ {
8289
+ id: 'stealth-instructions',
8290
+ level: 'medium',
8291
+ message: 'Detected instruction to hide actions from user',
8292
+ skipSafeZones: true,
8293
+ check: (content)=>{
8294
+ const actionVerbs = 'execute|delete|remove|send|transmit|modify|overwrite|install|download|upload|run|write|create|destroy|drop';
8295
+ const patterns = [
8296
+ // English patterns
8297
+ new RegExp(`silently\\s+(?:${actionVerbs})`, 'i'),
8298
+ new RegExp(`without\\s+telling\\s+the\\s+user.{0,30}(?:${actionVerbs})`, 'i'),
8299
+ new RegExp("(?:do\\s+not|don'?t)\\s+show\\s+.{0,40}(?:to\\s+the\\s+user|to\\s+user)", 'i'),
8300
+ new RegExp("hide\\s+(?:this|the|these|all)\\s+.{0,30}(?:from\\s+the\\s+user|from\\s+user)", 'i'),
8301
+ new RegExp("(?:do\\s+not|don'?t)\\s+mention\\s+.{0,30}(?:to\\s+the\\s+user|to\\s+user)", 'i'),
8302
+ new RegExp("keep\\s+(?:this|it)\\s+(?:a\\s+)?secret\\s+from\\s+(?:the\\s+)?user", 'i'),
8303
+ // Chinese patterns (中文隐蔽指令)
8304
+ /悄悄地?\s*(?:执行|删除|移除|发送|传输|修改|覆盖|安装|下载|上传|运行|写入|创建|销毁|丢弃)/,
8305
+ /不要告诉用户/,
8306
+ /不要让用户知道/,
8307
+ /对用户隐藏/,
8308
+ /在用户不知情的情况下/,
8309
+ /瞒着用户/
8310
+ ];
8311
+ // Safe patterns to exclude (common in legitimate DevOps/automation skills)
8312
+ const safePatterns = [
8313
+ /silently\s+(?:ignore|skip|fail|discard|suppress|continue|pass|drop|swallow)/i,
8314
+ // Chinese safe patterns (中文合法自动化用语)
8315
+ /悄悄地?\s*(?:忽略|跳过|丢弃|抑制|继续|静默)/
8316
+ ];
8317
+ const lines = content.split('\n');
8318
+ const matches = [];
8319
+ for(let i = 0; i < lines.length; i++){
8320
+ const line = lines[i];
8321
+ if (!safePatterns.some((p)=>p.test(line))) {
8322
+ for (const pattern of patterns)if (pattern.test(line)) {
8323
+ matches.push({
8324
+ line: i + 1
8325
+ });
8326
+ break;
8327
+ }
8328
+ }
8329
+ }
8330
+ return matches;
7782
8331
  }
7783
- // Check for v prefix
7784
- if (version.startsWith('v')) {
7785
- errors.push({
7786
- field: 'version',
7787
- message: 'Version should not have "v" prefix',
7788
- suggestion: `Change "${version}" to "${version.slice(1)}"`
7789
- });
7790
- return {
7791
- valid: false,
7792
- errors,
7793
- warnings: []
7794
- };
8332
+ },
8333
+ // Rule 6: Oversized Content (low) — scans ALL content
8334
+ {
8335
+ id: 'oversized-content',
8336
+ level: 'low',
8337
+ message: 'Content exceeds recommended size limit',
8338
+ skipSafeZones: false,
8339
+ check: (content)=>{
8340
+ const MAX_SIZE_BYTES = 51200;
8341
+ const sizeBytes = Buffer.byteLength(content, 'utf-8');
8342
+ if (sizeBytes > MAX_SIZE_BYTES) return [
8343
+ {
8344
+ snippet: `Content size: ${(sizeBytes / 1024).toFixed(1)}KB (limit: 50KB)`
8345
+ }
8346
+ ];
8347
+ return [];
7795
8348
  }
7796
- if (!__WEBPACK_EXTERNAL_MODULE_semver__.valid(version)) errors.push({
7797
- field: 'version',
7798
- message: `Invalid version format: "${version}". Must follow semver (x.y.z)`,
7799
- suggestion: 'Use format like "1.0.0" or "1.0.0-beta.1"'
7800
- });
7801
- return {
7802
- valid: 0 === errors.length,
7803
- errors,
7804
- warnings: []
7805
- };
7806
8349
  }
7807
- /**
7808
- * Validate description
7809
- *
7810
- * Following agentskills.io specification:
7811
- * - Max 1024 characters
7812
- * - Non-empty
7813
- */ validateDescription(description) {
7814
- const errors = [];
7815
- if (!description) {
7816
- errors.push({
7817
- field: 'description',
7818
- message: 'Description is required',
7819
- suggestion: 'Add "description" field to SKILL.md frontmatter'
7820
- });
7821
- return {
7822
- valid: false,
7823
- errors,
7824
- warnings: []
7825
- };
7826
- }
7827
- if (description.length > MAX_DESCRIPTION_LENGTH) errors.push({
7828
- field: 'description',
7829
- message: `Description must be at most ${MAX_DESCRIPTION_LENGTH} characters`
7830
- });
7831
- // Note: angle brackets are allowed per agentskills.io spec
8350
+ ];
8351
+ // ============================================================================
8352
+ // ContentScanner
8353
+ // ============================================================================
8354
+ /** Build the effective rule set from defaults + options */ function buildRuleSet(options) {
8355
+ let rules = DEFAULT_RULES.map((r)=>({
8356
+ ...r
8357
+ }));
8358
+ if (options?.disabledRules?.length) {
8359
+ const disabled = new Set(options.disabledRules);
8360
+ rules = rules.filter((r)=>!disabled.has(r.id));
8361
+ }
8362
+ if (options?.overrides) for (const rule of rules){
8363
+ const override = options.overrides[rule.id];
8364
+ if (override) rule.level = override;
8365
+ }
8366
+ if (options?.customRules?.length) rules.push(...options.customRules);
8367
+ return rules;
8368
+ }
8369
+ /**
8370
+ * Content scanner for SKILL.md files.
8371
+ *
8372
+ * Detects prompt injection, data exfiltration, obfuscation, sensitive file
8373
+ * access, stealth instructions, and oversized content.
8374
+ *
8375
+ * @example
8376
+ * ```typescript
8377
+ * // Default usage (CLI)
8378
+ * const scanner = new ContentScanner();
8379
+ * const result = scanner.scan(content);
8380
+ *
8381
+ * // Custom usage (private registry server)
8382
+ * const scanner = new ContentScanner({
8383
+ * overrides: { 'prompt-injection': 'medium' },
8384
+ * disabledRules: ['stealth-instructions'],
8385
+ * });
8386
+ * ```
8387
+ */ class ContentScanner {
8388
+ rules;
8389
+ constructor(options){
8390
+ this.rules = buildRuleSet(options);
8391
+ }
8392
+ /**
8393
+ * Scan content string for malicious patterns.
8394
+ * Pure string operation — no file system access.
8395
+ */ scan(content) {
8396
+ const originalLines = content.split('\n');
8397
+ const maskedContent = maskSafeZones(content);
8398
+ const findings = [];
8399
+ for (const rule of this.rules){
8400
+ const targetContent = rule.skipSafeZones ? maskedContent : content;
8401
+ const matches = rule.check(targetContent);
8402
+ for (const match of matches){
8403
+ // Use custom snippet if provided, otherwise generate from original content
8404
+ const snippet = match.snippet ?? (null != match.line ? originalLines[match.line - 1]?.trim().slice(0, SNIPPET_MAX_LENGTH) : void 0);
8405
+ findings.push({
8406
+ rule: rule.id,
8407
+ level: rule.level,
8408
+ message: rule.message,
8409
+ line: match.line,
8410
+ snippet
8411
+ });
8412
+ }
8413
+ }
8414
+ const hasHighRisk = findings.some((f)=>'high' === f.level);
7832
8415
  return {
7833
- valid: 0 === errors.length,
7834
- errors,
7835
- warnings: []
8416
+ passed: !hasHighRisk,
8417
+ findings
7836
8418
  };
7837
8419
  }
7838
8420
  /**
7839
- * Load skill information from directory
7840
- *
7841
- * Following agentskills.io specification:
7842
- * - SKILL.md is the SOLE source of metadata
7843
- * - skillJson is synthesized from SKILL.md for backward compatibility with publish API
7844
- */ loadSkill(skillPath) {
7845
- const result = {
7846
- path: skillPath,
7847
- skillJson: null,
7848
- skillMd: null,
7849
- readme: null,
7850
- files: []
8421
+ * Scan a file for malicious patterns.
8422
+ * Convenience wrapper that reads the file then calls scan().
8423
+ */ scanFile(filePath) {
8424
+ if (!external_node_fs_.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
8425
+ const content = external_node_fs_.readFileSync(filePath, 'utf-8');
8426
+ return this.scan(content);
8427
+ }
8428
+ }
8429
+ /**
8430
+ * Publisher - Handle Git information and publish payload building
8431
+ *
8432
+ * Extracts Git metadata and builds the payload for publishing to registry.
8433
+ */ class PublishError extends Error {
8434
+ constructor(message){
8435
+ super(message);
8436
+ this.name = 'PublishError';
8437
+ }
8438
+ }
8439
+ // ============================================================================
8440
+ // Publisher Class
8441
+ // ============================================================================
8442
+ class Publisher {
8443
+ /**
8444
+ * Get Git information from a skill directory
8445
+ */ async getGitInfo(skillPath, specifiedTag) {
8446
+ const info = {
8447
+ isRepo: false,
8448
+ remoteUrl: null,
8449
+ currentBranch: null,
8450
+ currentCommit: null,
8451
+ commitDate: null,
8452
+ tag: null,
8453
+ tagCommit: null,
8454
+ isDirty: false,
8455
+ sourceRef: null
7851
8456
  };
7852
- // Load SKILL.md (sole source of metadata)
7853
- const skillMdPath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(skillPath, 'SKILL.md');
7854
- if (external_node_fs_.existsSync(skillMdPath)) try {
7855
- result.skillMd = parseSkillMdFile(skillMdPath);
7856
- // Always synthesize skillJson from SKILL.md for backward compatibility
7857
- if (result.skillMd) result.skillJson = this.synthesizeSkillJson(result.skillMd);
8457
+ // Check if it's a git repository
8458
+ try {
8459
+ (0, __WEBPACK_EXTERNAL_MODULE_node_child_process__.execSync)('git rev-parse --git-dir', {
8460
+ cwd: skillPath,
8461
+ stdio: 'pipe'
8462
+ });
8463
+ info.isRepo = true;
7858
8464
  } catch {
7859
- // Will be caught in validation
8465
+ return info;
7860
8466
  }
7861
- // Load README.md
7862
- const readmePath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(skillPath, 'README.md');
7863
- if (external_node_fs_.existsSync(readmePath)) {
7864
- const content = external_node_fs_.readFileSync(readmePath, 'utf-8');
7865
- // Only keep first 500 chars as preview
7866
- result.readme = content.slice(0, 500);
8467
+ // Get remote URL
8468
+ try {
8469
+ info.remoteUrl = (0, __WEBPACK_EXTERNAL_MODULE_node_child_process__.execSync)('git remote get-url origin', {
8470
+ cwd: skillPath,
8471
+ encoding: 'utf-8'
8472
+ }).trim();
8473
+ // Parse to sourceRef format
8474
+ info.sourceRef = this.parseRemoteToSourceRef(info.remoteUrl);
8475
+ } catch {
8476
+ // No remote configured
7867
8477
  }
7868
- // Scan files (use files array from SKILL.md metadata if available)
7869
- const filesPattern = result.skillMd?.metadata?.files;
7870
- result.files = this.scanFiles(skillPath, filesPattern);
7871
- return result;
8478
+ // Get current branch
8479
+ try {
8480
+ info.currentBranch = (0, __WEBPACK_EXTERNAL_MODULE_node_child_process__.execSync)('git branch --show-current', {
8481
+ cwd: skillPath,
8482
+ encoding: 'utf-8'
8483
+ }).trim() || null;
8484
+ } catch {
8485
+ // Detached HEAD or other error
8486
+ }
8487
+ // Get current commit
8488
+ try {
8489
+ info.currentCommit = (0, __WEBPACK_EXTERNAL_MODULE_node_child_process__.execSync)('git rev-parse HEAD', {
8490
+ cwd: skillPath,
8491
+ encoding: 'utf-8'
8492
+ }).trim();
8493
+ // Get commit date
8494
+ info.commitDate = (0, __WEBPACK_EXTERNAL_MODULE_node_child_process__.execSync)('git show -s --format=%cI HEAD', {
8495
+ cwd: skillPath,
8496
+ encoding: 'utf-8'
8497
+ }).trim();
8498
+ } catch {
8499
+ // No commits yet
8500
+ }
8501
+ // Get tag
8502
+ if (specifiedTag) // Use specified tag
8503
+ try {
8504
+ info.tagCommit = (0, __WEBPACK_EXTERNAL_MODULE_node_child_process__.execSync)(`git rev-parse "${specifiedTag}^{commit}"`, {
8505
+ cwd: skillPath,
8506
+ encoding: 'utf-8'
8507
+ }).trim();
8508
+ info.tag = specifiedTag;
8509
+ } catch {
8510
+ throw new PublishError(`Tag "${specifiedTag}" not found`);
8511
+ }
8512
+ else // Try to get tag on current commit
8513
+ try {
8514
+ const tag = (0, __WEBPACK_EXTERNAL_MODULE_node_child_process__.execSync)('git describe --exact-match --tags HEAD', {
8515
+ cwd: skillPath,
8516
+ encoding: 'utf-8'
8517
+ }).trim();
8518
+ info.tag = tag;
8519
+ info.tagCommit = info.currentCommit;
8520
+ } catch {
8521
+ // No tag on current commit
8522
+ }
8523
+ // Check if working tree is dirty
8524
+ try {
8525
+ const status = (0, __WEBPACK_EXTERNAL_MODULE_node_child_process__.execSync)('git status --porcelain', {
8526
+ cwd: skillPath,
8527
+ encoding: 'utf-8'
8528
+ }).trim();
8529
+ info.isDirty = status.length > 0;
8530
+ } catch {
8531
+ // Ignore errors
8532
+ }
8533
+ return info;
7872
8534
  }
7873
8535
  /**
7874
- * Synthesize a SkillJson object from SKILL.md frontmatter
7875
- *
7876
- * This creates a SkillJson representation from SKILL.md for backward compatibility
7877
- * with the publish API. All metadata comes from SKILL.md.
7878
- */ synthesizeSkillJson(skillMd) {
7879
- // Extract version: first from top-level frontmatter, then from metadata, then default
7880
- const version = skillMd.version || skillMd.metadata?.version || DEFAULT_VERSION;
7881
- // Only include keywords if it's a valid array
7882
- const keywords = Array.isArray(skillMd.metadata?.keywords) ? skillMd.metadata.keywords : void 0;
7883
- return {
7884
- name: skillMd.name,
7885
- version,
7886
- description: skillMd.description,
7887
- license: skillMd.license,
7888
- keywords,
7889
- entry: 'SKILL.md'
7890
- };
8536
+ * Parse remote URL to sourceRef format (e.g., github:user/repo)
8537
+ */ parseRemoteToSourceRef(remoteUrl) {
8538
+ // SSH format: git@github.com:user/repo.git
8539
+ const sshMatch = remoteUrl.match(/^git@([^:]+):([^/]+)\/(.+?)(\.git)?$/);
8540
+ if (sshMatch) {
8541
+ const [, host, owner, repo] = sshMatch;
8542
+ const registry = this.normalizeHost(host);
8543
+ return `${registry}:${owner}/${repo.replace(/\.git$/, '')}`;
8544
+ }
8545
+ // HTTPS format: https://github.com/user/repo.git
8546
+ const httpsMatch = remoteUrl.match(/^https?:\/\/([^/]+)\/([^/]+)\/(.+?)(\.git)?$/);
8547
+ if (httpsMatch) {
8548
+ const [, host, owner, repo] = httpsMatch;
8549
+ const registry = this.normalizeHost(host);
8550
+ return `${registry}:${owner}/${repo.replace(/\.git$/, '')}`;
8551
+ }
8552
+ return null;
7891
8553
  }
7892
8554
  /**
7893
- * Scan files to include in publish
7894
- *
7895
- * If includePatterns is specified, only include those files/directories.
7896
- * Otherwise, scan all files in the directory (excluding ignored patterns).
7897
- */ scanFiles(skillPath, includePatterns) {
7898
- const files = [];
7899
- const seen = new Set();
7900
- // If includePatterns specified, use selective scanning
7901
- if (includePatterns && includePatterns.length > 0) {
7902
- // Add default files first
7903
- for (const file of DEFAULT_FILES){
7904
- const filePath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(skillPath, file);
7905
- if (external_node_fs_.existsSync(filePath) && !seen.has(file)) {
7906
- files.push(file);
7907
- seen.add(file);
7908
- }
7909
- }
7910
- // Add files from SKILL.md metadata.files array
7911
- for (const pattern of includePatterns){
7912
- const targetPath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(skillPath, pattern);
7913
- if (external_node_fs_.existsSync(targetPath)) {
7914
- const stat = external_node_fs_.statSync(targetPath);
7915
- if (stat.isDirectory()) // Recursively add all files in directory
7916
- this.addFilesFromDir(skillPath, pattern, files, seen);
7917
- else if (!seen.has(pattern)) {
7918
- files.push(pattern);
7919
- seen.add(pattern);
7920
- }
7921
- }
7922
- }
7923
- } else // No includePatterns: scan entire directory (default behavior)
7924
- this.addFilesFromDir(skillPath, '', files, seen);
7925
- return files;
8555
+ * Normalize host to registry name
8556
+ */ normalizeHost(host) {
8557
+ if ('github.com' === host) return 'github';
8558
+ if ('gitlab.com' === host) return 'gitlab';
8559
+ return host;
7926
8560
  }
7927
8561
  /**
7928
- * Patterns to ignore when scanning directories
7929
- */ static IGNORE_PATTERNS = [
7930
- '.git',
7931
- '.svn',
7932
- '.hg',
7933
- 'node_modules',
7934
- '.DS_Store',
7935
- 'Thumbs.db',
7936
- '.idea',
7937
- '.vscode',
7938
- '*.log',
7939
- '*.tmp',
7940
- '*.swp',
7941
- '*.bak'
7942
- ];
8562
+ * Build publish payload
8563
+ */ buildPayload(skill, gitInfo, integrity) {
8564
+ const { skillJson, skillMd, readme, files } = skill;
8565
+ const payload = {
8566
+ version: skillJson.version,
8567
+ description: skillJson.description || '',
8568
+ gitRef: gitInfo.tag || gitInfo.currentCommit || 'HEAD',
8569
+ gitCommit: gitInfo.tagCommit || gitInfo.currentCommit || '',
8570
+ gitCommitDate: gitInfo.commitDate || void 0,
8571
+ repositoryUrl: gitInfo.remoteUrl || '',
8572
+ sourceRef: gitInfo.sourceRef || '',
8573
+ skillJson,
8574
+ files,
8575
+ entry: skillJson.entry || 'SKILL.md',
8576
+ integrity
8577
+ };
8578
+ // Add optional fields
8579
+ if (skillMd) payload.skillMd = {
8580
+ name: skillMd.name,
8581
+ description: skillMd.description,
8582
+ license: skillMd.license,
8583
+ compatibility: skillMd.compatibility,
8584
+ allowedTools: skillMd.allowedTools
8585
+ };
8586
+ if (readme) payload.readmePreview = readme;
8587
+ if (skillJson.keywords && skillJson.keywords.length > 0) payload.keywords = skillJson.keywords;
8588
+ if (skillJson.compatibility) {
8589
+ // Filter out undefined values from compatibility
8590
+ const compat = {};
8591
+ for (const [key, value] of Object.entries(skillJson.compatibility))if (void 0 !== value) compat[key] = value;
8592
+ if (Object.keys(compat).length > 0) payload.compatibility = compat;
8593
+ }
8594
+ return payload;
8595
+ }
7943
8596
  /**
7944
- * Check if a file/directory should be ignored
7945
- */ shouldIgnore(name) {
7946
- for (const pattern of SkillValidator.IGNORE_PATTERNS)if (pattern.startsWith('*')) {
7947
- // Wildcard pattern (e.g., *.log)
7948
- const ext = pattern.slice(1);
7949
- if (name.endsWith(ext)) return true;
7950
- } else if (name === pattern) return true;
7951
- return false;
8597
+ * Format bytes for display
8598
+ */ formatBytes(bytes) {
8599
+ if (bytes < 1024) return `${bytes} B`;
8600
+ if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
8601
+ return `${(bytes / 1048576).toFixed(1)} MB`;
7952
8602
  }
7953
8603
  /**
7954
- * Recursively add files from directory
7955
- */ addFilesFromDir(basePath, dirPath, files, seen) {
7956
- const fullPath = dirPath ? __WEBPACK_EXTERNAL_MODULE_node_path__.join(basePath, dirPath) : basePath;
7957
- const entries = external_node_fs_.readdirSync(fullPath, {
7958
- withFileTypes: true
7959
- });
7960
- for (const entry of entries){
7961
- // Skip ignored files/directories
7962
- if (this.shouldIgnore(entry.name)) continue;
7963
- const relativePath = dirPath ? __WEBPACK_EXTERNAL_MODULE_node_path__.join(dirPath, entry.name) : entry.name;
7964
- if (entry.isDirectory()) this.addFilesFromDir(basePath, relativePath, files, seen);
7965
- else if (!seen.has(relativePath)) {
7966
- files.push(relativePath);
7967
- seen.add(relativePath);
8604
+ * Calculate total size of files
8605
+ */ calculateTotalSize(skillPath, files) {
8606
+ let total = 0;
8607
+ for (const file of files){
8608
+ const filePath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(skillPath, file);
8609
+ if (external_node_fs_.existsSync(filePath)) {
8610
+ const stats = external_node_fs_.statSync(filePath);
8611
+ total += stats.size;
7968
8612
  }
7969
8613
  }
8614
+ return total;
7970
8615
  }
8616
+ }
8617
+ /**
8618
+ * SkillValidator - Validate skills for publishing
8619
+ *
8620
+ * Following agentskills.io specification: https://agentskills.io/specification
8621
+ *
8622
+ * Key points:
8623
+ * - SKILL.md is the SOLE source of metadata (name, description, version, etc.)
8624
+ * - skill.json is NOT used - all metadata comes from SKILL.md frontmatter
8625
+ * - Version defaults to "0.0.0" if not specified in SKILL.md
8626
+ */ // ============================================================================
8627
+ // Constants
8628
+ // ============================================================================
8629
+ const MAX_NAME_LENGTH = 64;
8630
+ const MAX_DESCRIPTION_LENGTH = 1024;
8631
+ const MAX_KEYWORDS = 10;
8632
+ const SINGLE_CHAR_NAME_PATTERN = /^[a-z0-9]$/;
8633
+ const DEFAULT_VERSION = '0.0.0';
8634
+ // Default files to include in publish
8635
+ const DEFAULT_FILES = [
8636
+ 'SKILL.md',
8637
+ 'README.md',
8638
+ 'LICENSE'
8639
+ ];
8640
+ // ============================================================================
8641
+ // SkillValidator Class
8642
+ // ============================================================================
8643
+ class SkillValidator {
7971
8644
  /**
7972
- * Validate a skill directory for publishing
8645
+ * Validate skill name format
7973
8646
  *
7974
- * Following agentskills.io specification:
7975
- * - SKILL.md is the SOLE source of metadata
7976
- * - name and description are REQUIRED in frontmatter
7977
- */ validate(skillPath) {
8647
+ * Requirements:
8648
+ * - Lowercase letters, numbers, and hyphens only
8649
+ * - 1-64 characters
8650
+ * - Cannot start or end with hyphen
8651
+ * - Cannot have consecutive hyphens
8652
+ */ validateName(name) {
7978
8653
  const errors = [];
7979
- const warnings = [];
7980
- // Check SKILL.md exists (REQUIRED per spec)
7981
- const skillMdPath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(skillPath, 'SKILL.md');
7982
- if (!external_node_fs_.existsSync(skillMdPath)) {
8654
+ if (!name) {
7983
8655
  errors.push({
7984
- field: 'SKILL.md',
7985
- message: 'SKILL.md not found. This file is required for publishing.',
7986
- suggestion: 'Create a SKILL.md file with name and description in YAML frontmatter'
8656
+ field: 'name',
8657
+ message: 'Skill name is required',
8658
+ suggestion: 'Add "name" field to SKILL.md frontmatter'
7987
8659
  });
7988
8660
  return {
7989
8661
  valid: false,
7990
8662
  errors,
7991
- warnings
8663
+ warnings: []
7992
8664
  };
7993
8665
  }
7994
- // Parse SKILL.md
7995
- let skillMd;
7996
- try {
7997
- skillMd = parseSkillMdFile(skillMdPath);
7998
- if (!skillMd) {
7999
- errors.push({
8000
- field: 'SKILL.md',
8001
- message: 'SKILL.md must have valid YAML frontmatter with name and description',
8002
- suggestion: 'Add frontmatter: ---\\nname: your-skill\\ndescription: Your description\\n---'
8003
- });
8004
- return {
8005
- valid: false,
8006
- errors,
8007
- warnings
8008
- };
8009
- }
8010
- } catch (error) {
8666
+ if (name.length > MAX_NAME_LENGTH) errors.push({
8667
+ field: 'name',
8668
+ message: `Skill name must be at most ${MAX_NAME_LENGTH} characters`,
8669
+ suggestion: `Shorten the name to ${MAX_NAME_LENGTH} characters or less`
8670
+ });
8671
+ // Check for uppercase
8672
+ if (/[A-Z]/.test(name)) errors.push({
8673
+ field: 'name',
8674
+ message: 'Skill name must be lowercase',
8675
+ suggestion: `Change "${name}" to "${name.toLowerCase()}"`
8676
+ });
8677
+ // Check for invalid characters
8678
+ if (/[^a-z0-9-]/.test(name)) errors.push({
8679
+ field: 'name',
8680
+ message: 'Skill name can only contain lowercase letters, numbers, and hyphens',
8681
+ suggestion: 'Remove special characters from the name'
8682
+ });
8683
+ // Check pattern for multi-char names
8684
+ if (1 === name.length) {
8685
+ if (!SINGLE_CHAR_NAME_PATTERN.test(name)) errors.push({
8686
+ field: 'name',
8687
+ message: 'Single character name must be a lowercase letter or number'
8688
+ });
8689
+ } else if (name.length > 1) {
8690
+ // Check start/end with hyphen
8691
+ if (name.startsWith('-')) errors.push({
8692
+ field: 'name',
8693
+ message: 'Skill name cannot start with a hyphen'
8694
+ });
8695
+ if (name.endsWith('-')) errors.push({
8696
+ field: 'name',
8697
+ message: 'Skill name cannot end with a hyphen'
8698
+ });
8699
+ // Check consecutive hyphens
8700
+ if (/--/.test(name)) errors.push({
8701
+ field: 'name',
8702
+ message: 'Skill name cannot contain consecutive hyphens'
8703
+ });
8704
+ }
8705
+ return {
8706
+ valid: 0 === errors.length,
8707
+ errors,
8708
+ warnings: []
8709
+ };
8710
+ }
8711
+ /**
8712
+ * Validate version format (semver)
8713
+ */ validateVersion(version) {
8714
+ const errors = [];
8715
+ if (!version) {
8011
8716
  errors.push({
8012
- field: 'SKILL.md',
8013
- message: `Failed to parse SKILL.md: ${error.message}`,
8014
- suggestion: 'Check the YAML frontmatter syntax is valid'
8717
+ field: 'version',
8718
+ message: 'Version is required',
8719
+ suggestion: 'Add "version" field to SKILL.md frontmatter (e.g., "1.0.0")'
8015
8720
  });
8016
8721
  return {
8017
8722
  valid: false,
8018
8723
  errors,
8019
- warnings
8724
+ warnings: []
8020
8725
  };
8021
8726
  }
8022
- // Validate name from SKILL.md
8023
- const nameResult = this.validateName(skillMd.name);
8024
- errors.push(...nameResult.errors);
8025
- // Validate description from SKILL.md
8026
- const descResult = this.validateDescription(skillMd.description);
8027
- errors.push(...descResult.errors);
8028
- // Check version in SKILL.md
8029
- const skillMdVersion = skillMd.version || skillMd.metadata?.version;
8030
- if (skillMdVersion) {
8031
- // Validate the version from SKILL.md
8032
- const versionResult = this.validateVersion(skillMdVersion);
8033
- errors.push(...versionResult.errors);
8034
- } else warnings.push({
8035
- field: 'version',
8036
- message: `No version specified, defaulting to "${DEFAULT_VERSION}"`,
8037
- suggestion: 'Add version in SKILL.md frontmatter'
8038
- });
8039
- // Check keywords count (only if metadata.keywords is a valid array)
8040
- const keywords = skillMd.metadata?.keywords;
8041
- if (Array.isArray(keywords) && keywords.length > MAX_KEYWORDS) warnings.push({
8042
- field: 'keywords',
8043
- message: `Too many keywords (${keywords.length}). Recommended max: ${MAX_KEYWORDS}`
8044
- });
8045
- // Check license
8046
- if (!skillMd.license) warnings.push({
8047
- field: 'license',
8048
- message: 'No license specified',
8049
- suggestion: 'Add license in SKILL.md frontmatter'
8727
+ // Check for v prefix
8728
+ if (version.startsWith('v')) {
8729
+ errors.push({
8730
+ field: 'version',
8731
+ message: 'Version should not have "v" prefix',
8732
+ suggestion: `Change "${version}" to "${version.slice(1)}"`
8733
+ });
8734
+ return {
8735
+ valid: false,
8736
+ errors,
8737
+ warnings: []
8738
+ };
8739
+ }
8740
+ if (!__WEBPACK_EXTERNAL_MODULE_semver__.valid(version)) errors.push({
8741
+ field: 'version',
8742
+ message: `Invalid version format: "${version}". Must follow semver (x.y.z)`,
8743
+ suggestion: 'Use format like "1.0.0" or "1.0.0-beta.1"'
8050
8744
  });
8051
8745
  return {
8052
8746
  valid: 0 === errors.length,
8053
8747
  errors,
8054
- warnings
8748
+ warnings: []
8055
8749
  };
8056
8750
  }
8057
8751
  /**
8058
- * Generate integrity hash for files
8059
- */ generateIntegrity(skillPath, files) {
8060
- const hash = __WEBPACK_EXTERNAL_MODULE_node_crypto__.createHash('sha256');
8061
- // Sort files for consistent ordering
8062
- const sortedFiles = [
8063
- ...files
8064
- ].sort();
8065
- for (const file of sortedFiles){
8066
- const filePath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(skillPath, file);
8067
- if (external_node_fs_.existsSync(filePath)) {
8068
- hash.update(file);
8069
- const content = external_node_fs_.readFileSync(filePath);
8070
- hash.update(content);
8071
- }
8752
+ * Validate description
8753
+ *
8754
+ * Following agentskills.io specification:
8755
+ * - Max 1024 characters
8756
+ * - Non-empty
8757
+ */ validateDescription(description) {
8758
+ const errors = [];
8759
+ if (!description) {
8760
+ errors.push({
8761
+ field: 'description',
8762
+ message: 'Description is required',
8763
+ suggestion: 'Add "description" field to SKILL.md frontmatter'
8764
+ });
8765
+ return {
8766
+ valid: false,
8767
+ errors,
8768
+ warnings: []
8769
+ };
8072
8770
  }
8073
- return `sha256-${hash.digest('hex')}`;
8771
+ if (description.length > MAX_DESCRIPTION_LENGTH) errors.push({
8772
+ field: 'description',
8773
+ message: `Description must be at most ${MAX_DESCRIPTION_LENGTH} characters`
8774
+ });
8775
+ // Note: angle brackets are allowed per agentskills.io spec
8776
+ return {
8777
+ valid: 0 === errors.length,
8778
+ errors,
8779
+ warnings: []
8780
+ };
8074
8781
  }
8075
- }
8076
- /**
8077
- * ContentScanner - Detect malicious patterns in SKILL.md content
8078
- *
8079
- * Features:
8080
- * - Context-aware: skips safe zones (frontmatter, code blocks, quotes, blockquotes)
8081
- * - 6 built-in detection rules across 3 risk levels
8082
- * - Configurable: override levels, disable rules, add custom rules
8083
- * - Pure string operations in scan() — no fs dependency, suitable for server use
8084
- * - scanFile() convenience method for CLI use
8085
- */ // ============================================================================
8086
- // Safe Zone Masking
8087
- // ============================================================================
8088
- /**
8089
- * Mask safe zones in Markdown content with spaces, preserving line structure.
8090
- *
8091
- * Safe zones (content replaced with spaces):
8092
- * - YAML frontmatter (`---` ... `---` at file start)
8093
- * - Fenced code blocks (``` or ~~~)
8094
- * - Indented code blocks (4 spaces / tab after blank line)
8095
- * - Blockquotes (`> ` prefix)
8096
- * - Inline code (`` `...` ``)
8097
- * - Double-quoted text (`"..."`, min 3 chars between quotes)
8098
- *
8099
- * Line breaks are preserved so line numbers remain correct.
8100
- */ function maskSafeZones(content) {
8101
- const lines = content.split('\n');
8102
- const result = [];
8103
- let inFrontmatter = false;
8104
- let inFencedCode = false;
8105
- let fenceChar = '';
8106
- let fenceLength = 0;
8107
- let prevLineBlank = false;
8108
- let prevLineIndentedCode = false;
8109
- for(let i = 0; i < lines.length; i++){
8110
- const line = lines[i];
8111
- // --- YAML Frontmatter (only at file start) ---
8112
- if (0 === i && '---' === line.trim()) {
8113
- inFrontmatter = true;
8114
- result.push(maskLine(line));
8115
- continue;
8116
- }
8117
- if (inFrontmatter) {
8118
- result.push(maskLine(line));
8119
- if ('---' === line.trim()) inFrontmatter = false;
8120
- continue;
8121
- }
8122
- // --- Fenced code blocks (``` or ~~~) ---
8123
- const fenceMatch = line.match(/^(`{3,}|~{3,})/);
8124
- if (!inFencedCode && fenceMatch) {
8125
- inFencedCode = true;
8126
- fenceChar = fenceMatch[1][0];
8127
- fenceLength = fenceMatch[1].length;
8128
- result.push(maskLine(line));
8129
- prevLineBlank = false;
8130
- prevLineIndentedCode = false;
8131
- continue;
8132
- }
8133
- if (inFencedCode) {
8134
- result.push(maskLine(line));
8135
- const closeMatch = line.match(/^(`{3,}|~{3,})\s*$/);
8136
- if (closeMatch && closeMatch[1][0] === fenceChar && closeMatch[1].length >= fenceLength) inFencedCode = false;
8137
- prevLineBlank = false;
8138
- prevLineIndentedCode = false;
8139
- continue;
8140
- }
8141
- // --- Blockquote ---
8142
- if (/^>\s?/.test(line)) {
8143
- result.push(maskLine(line));
8144
- prevLineBlank = false;
8145
- prevLineIndentedCode = false;
8146
- continue;
8782
+ /**
8783
+ * Load skill information from directory
8784
+ *
8785
+ * Following agentskills.io specification:
8786
+ * - SKILL.md is the SOLE source of metadata
8787
+ * - skillJson is synthesized from SKILL.md for backward compatibility with publish API
8788
+ */ loadSkill(skillPath) {
8789
+ const result = {
8790
+ path: skillPath,
8791
+ skillJson: null,
8792
+ skillMd: null,
8793
+ readme: null,
8794
+ files: []
8795
+ };
8796
+ // Load SKILL.md (sole source of metadata)
8797
+ const skillMdPath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(skillPath, 'SKILL.md');
8798
+ if (external_node_fs_.existsSync(skillMdPath)) try {
8799
+ result.skillMd = parseSkillMdFile(skillMdPath);
8800
+ // Always synthesize skillJson from SKILL.md for backward compatibility
8801
+ if (result.skillMd) result.skillJson = this.synthesizeSkillJson(result.skillMd);
8802
+ } catch {
8803
+ // Will be caught in validation
8147
8804
  }
8148
- // --- Indented code block (4 spaces or tab, after blank line) ---
8149
- if (/^(?: |\t)/.test(line) && (prevLineBlank || prevLineIndentedCode)) {
8150
- result.push(maskLine(line));
8151
- prevLineBlank = false;
8152
- prevLineIndentedCode = true;
8153
- continue;
8805
+ // Load README.md
8806
+ const readmePath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(skillPath, 'README.md');
8807
+ if (external_node_fs_.existsSync(readmePath)) {
8808
+ const content = external_node_fs_.readFileSync(readmePath, 'utf-8');
8809
+ // Only keep first 500 chars as preview
8810
+ result.readme = content.slice(0, 500);
8154
8811
  }
8155
- // --- Normal line: mask inline code and double-quoted text ---
8156
- result.push(maskInline(line));
8157
- prevLineBlank = '' === line.trim();
8158
- prevLineIndentedCode = false;
8812
+ // Scan files (use files array from SKILL.md metadata if available)
8813
+ const filesPattern = result.skillMd?.metadata?.files;
8814
+ result.files = this.scanFiles(skillPath, filesPattern);
8815
+ return result;
8159
8816
  }
8160
- return result.join('\n');
8161
- }
8162
- /** Replace all characters in a line with spaces (preserving length) */ function maskLine(line) {
8163
- return ' '.repeat(line.length);
8164
- }
8165
- /**
8166
- * Mask inline code (`` `...` ``) and double-quoted text (`"..."`) within a line.
8167
- * Uses regex replacement for efficiency (avoids char-by-char concatenation on long lines).
8168
- * Single quotes are NOT masked to avoid false matches with apostrophes.
8169
- */ function maskInline(line) {
8170
- let result = line;
8171
- // Inline code: `...`
8172
- result = result.replace(/`[^`]+`/g, (m)=>' '.repeat(m.length));
8173
- // Double-quoted text: "..." (min 3 chars between quotes)
8174
- result = result.replace(/"[^"]{3,}"/g, (m)=>' '.repeat(m.length));
8175
- return result;
8176
- }
8177
- // ============================================================================
8178
- // Rule Helpers
8179
- // ============================================================================
8180
- /** Find lines matching any of the given patterns, return one match per line */ function findLineMatches(content, patterns) {
8181
- const lines = content.split('\n');
8182
- const matches = [];
8183
- for(let i = 0; i < lines.length; i++)for (const pattern of patterns)if (pattern.test(lines[i])) {
8184
- matches.push({
8185
- line: i + 1
8186
- });
8187
- break;
8817
+ /**
8818
+ * Synthesize a SkillJson object from SKILL.md frontmatter
8819
+ *
8820
+ * This creates a SkillJson representation from SKILL.md for backward compatibility
8821
+ * with the publish API. All metadata comes from SKILL.md.
8822
+ */ synthesizeSkillJson(skillMd) {
8823
+ // Extract version: first from top-level frontmatter, then from metadata, then default
8824
+ const version = skillMd.version || skillMd.metadata?.version || DEFAULT_VERSION;
8825
+ // Only include keywords if it's a valid array
8826
+ const keywords = Array.isArray(skillMd.metadata?.keywords) ? skillMd.metadata.keywords : void 0;
8827
+ return {
8828
+ name: skillMd.name,
8829
+ version,
8830
+ description: skillMd.description,
8831
+ license: skillMd.license,
8832
+ keywords,
8833
+ entry: 'SKILL.md'
8834
+ };
8188
8835
  }
8189
- return matches;
8190
- }
8191
- // ============================================================================
8192
- // Default Rules
8193
- // ============================================================================
8194
- const SNIPPET_MAX_LENGTH = 120;
8195
- /** Built-in detection rules */ const DEFAULT_RULES = [
8196
- // Rule 1: Prompt Injection (high)
8197
- {
8198
- id: 'prompt-injection',
8199
- level: 'high',
8200
- message: 'Detected prompt injection attempt',
8201
- skipSafeZones: true,
8202
- check: (content)=>findLineMatches(content, [
8203
- // English patterns
8204
- /ignore\s+(all\s+)?previous\s+instructions/i,
8205
- /disregard\s+(all\s+)?(prior|previous|above)\s+(instructions|rules|context)/i,
8206
- /you\s+are\s+now\s+(?:(?:a|an)\s+)?(?:(?:\w+\s+){0,3}(?:agent|ai|assistant|bot|model|character|persona|entity|system)|DAN\b|jailbr\w*|unrestricted|unfiltered|free\s+from)/i,
8207
- /from\s+now\s+on[,\s]+you\s+are/i,
8208
- /new\s+system\s+prompt/i,
8209
- /override\s+(your|the)\s+(system|safety|security)\s+(prompt|rules|instructions)/i,
8210
- /forget\s+(?:all\s+)?(?:your\s+)?(?:previous\s+|prior\s+)?(?:instructions|rules|constraints)/i,
8211
- /(?:you\s+are|you're)\s+(?:now\s+)?entering\s+(?:a\s+)?new\s+(?:mode|context|session)/i,
8212
- // Chinese patterns (中文提示词注入)
8213
- /[忽无][略视]\s*(所有\s*)?(之前的?|先前的?|以前的?)?\s*(指令|指示|规则|约束|限制)/,
8214
- /你现在是/,
8215
- /从现在开始.{0,10}你是/,
8216
- /新的系统提示词/,
8217
- /[覆改]写?\s*(你的|系统)\s*(提示词|规则|指令|安全)/,
8218
- /忘记\s*(所有\s*)?(之前的?|先前的?)?\s*(指令|指示|规则|约束)/,
8219
- /进入.{0,5}新的?\s*(模式|上下文|会话)/,
8220
- /不要遵守.{0,10}(安全|限制|规则|约束)/,
8221
- /解除.{0,5}(限制|约束|安全)/,
8222
- /无限制模式/,
8223
- /安全模式已关闭/
8224
- ])
8225
- },
8226
- // Rule 2: Data Exfiltration (high)
8227
- {
8228
- id: 'data-exfiltration',
8229
- level: 'high',
8230
- message: 'Detected potential data exfiltration command',
8231
- skipSafeZones: true,
8232
- check: (content)=>{
8233
- const lines = content.split('\n');
8234
- const matches = [];
8235
- const commandPattern = /\b(curl|wget|fetch|http\.post|requests\.post|nc\b|ncat|netcat)\b/i;
8236
- const sensitivePattern = /(\$[A-Z_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH)[A-Z_]*|\$ENV\b|\$\{[^}]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[^}]*\})/i;
8237
- for(let i = 0; i < lines.length; i++)if (commandPattern.test(lines[i]) && sensitivePattern.test(lines[i])) matches.push({
8238
- line: i + 1
8239
- });
8240
- return matches;
8241
- }
8242
- },
8243
- // Rule 3: Content Obfuscation (high) — scans ALL content including safe zones
8244
- {
8245
- id: 'obfuscation',
8246
- level: 'high',
8247
- message: 'Detected content obfuscation',
8248
- skipSafeZones: false,
8249
- check: (content)=>{
8250
- const matches = [];
8251
- const lines = content.split('\n');
8252
- // Zero-width characters (suspicious in any context)
8253
- const zeroWidthPattern = /[\u200B\u200C\u200D\uFEFF\u2060\u180E]/;
8254
- for(let i = 0; i < lines.length; i++)if (zeroWidthPattern.test(lines[i])) matches.push({
8255
- line: i + 1,
8256
- snippet: 'Zero-width Unicode characters detected'
8257
- });
8258
- // Long base64-like strings (>200 continuous chars)
8259
- const base64Pattern = /[A-Za-z0-9+/=]{200,}/;
8260
- for(let i = 0; i < lines.length; i++)if (base64Pattern.test(lines[i])) matches.push({
8261
- line: i + 1,
8262
- snippet: 'Suspicious base64-encoded block detected'
8263
- });
8264
- // Large HTML comments (>200 chars of content)
8265
- const commentRegex = /<!--([\s\S]{200,}?)-->/g;
8266
- let match;
8267
- // biome-ignore lint/suspicious/noAssignInExpressions: standard regex exec loop
8268
- while(null !== (match = commentRegex.exec(content))){
8269
- const lineNum = content.slice(0, match.index).split('\n').length;
8270
- matches.push({
8271
- line: lineNum,
8272
- snippet: `Large HTML comment block (${match[1].length} chars)`
8273
- });
8274
- }
8275
- return matches;
8276
- }
8277
- },
8278
- // Rule 4: Sensitive File Access (medium)
8279
- {
8280
- id: 'sensitive-file-access',
8281
- level: 'medium',
8282
- message: 'References sensitive file path',
8283
- skipSafeZones: true,
8284
- check: (content)=>findLineMatches(content, [
8285
- /~\/\.ssh\b/,
8286
- /~\/\.aws\b/,
8287
- /~\/\.gnupg\b/,
8288
- /~\/\.config\/gcloud\b/,
8289
- /\bid_rsa\b/i,
8290
- /\bid_ed25519\b/i,
8291
- /\/etc\/passwd\b/,
8292
- /\/etc\/shadow\b/,
8293
- /\.env\b(?!\.\w)/
8294
- ])
8295
- },
8296
- // Rule 5: Stealth Instructions (medium) — phrase + action verb matching
8297
- {
8298
- id: 'stealth-instructions',
8299
- level: 'medium',
8300
- message: 'Detected instruction to hide actions from user',
8301
- skipSafeZones: true,
8302
- check: (content)=>{
8303
- const actionVerbs = 'execute|delete|remove|send|transmit|modify|overwrite|install|download|upload|run|write|create|destroy|drop';
8304
- const patterns = [
8305
- // English patterns
8306
- new RegExp(`silently\\s+(?:${actionVerbs})`, 'i'),
8307
- new RegExp(`without\\s+telling\\s+the\\s+user.{0,30}(?:${actionVerbs})`, 'i'),
8308
- new RegExp("(?:do\\s+not|don'?t)\\s+show\\s+.{0,40}(?:to\\s+the\\s+user|to\\s+user)", 'i'),
8309
- new RegExp("hide\\s+(?:this|the|these|all)\\s+.{0,30}(?:from\\s+the\\s+user|from\\s+user)", 'i'),
8310
- new RegExp("(?:do\\s+not|don'?t)\\s+mention\\s+.{0,30}(?:to\\s+the\\s+user|to\\s+user)", 'i'),
8311
- new RegExp("keep\\s+(?:this|it)\\s+(?:a\\s+)?secret\\s+from\\s+(?:the\\s+)?user", 'i'),
8312
- // Chinese patterns (中文隐蔽指令)
8313
- /悄悄地?\s*(?:执行|删除|移除|发送|传输|修改|覆盖|安装|下载|上传|运行|写入|创建|销毁|丢弃)/,
8314
- /不要告诉用户/,
8315
- /不要让用户知道/,
8316
- /对用户隐藏/,
8317
- /在用户不知情的情况下/,
8318
- /瞒着用户/
8319
- ];
8320
- // Safe patterns to exclude (common in legitimate DevOps/automation skills)
8321
- const safePatterns = [
8322
- /silently\s+(?:ignore|skip|fail|discard|suppress|continue|pass|drop|swallow)/i,
8323
- // Chinese safe patterns (中文合法自动化用语)
8324
- /悄悄地?\s*(?:忽略|跳过|丢弃|抑制|继续|静默)/
8325
- ];
8326
- const lines = content.split('\n');
8327
- const matches = [];
8328
- for(let i = 0; i < lines.length; i++){
8329
- const line = lines[i];
8330
- if (!safePatterns.some((p)=>p.test(line))) {
8331
- for (const pattern of patterns)if (pattern.test(line)) {
8332
- matches.push({
8333
- line: i + 1
8334
- });
8335
- break;
8336
- }
8836
+ /**
8837
+ * Scan files to include in publish
8838
+ *
8839
+ * If includePatterns is specified, only include those files/directories.
8840
+ * Otherwise, scan all files in the directory (excluding ignored patterns).
8841
+ */ scanFiles(skillPath, includePatterns) {
8842
+ const files = [];
8843
+ const seen = new Set();
8844
+ // If includePatterns specified, use selective scanning
8845
+ if (includePatterns && includePatterns.length > 0) {
8846
+ // Add default files first
8847
+ for (const file of DEFAULT_FILES){
8848
+ const filePath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(skillPath, file);
8849
+ if (external_node_fs_.existsSync(filePath) && !seen.has(file)) {
8850
+ files.push(file);
8851
+ seen.add(file);
8337
8852
  }
8338
8853
  }
8339
- return matches;
8340
- }
8341
- },
8342
- // Rule 6: Oversized Content (low) — scans ALL content
8343
- {
8344
- id: 'oversized-content',
8345
- level: 'low',
8346
- message: 'Content exceeds recommended size limit',
8347
- skipSafeZones: false,
8348
- check: (content)=>{
8349
- const MAX_SIZE_BYTES = 51200;
8350
- const sizeBytes = Buffer.byteLength(content, 'utf-8');
8351
- if (sizeBytes > MAX_SIZE_BYTES) return [
8352
- {
8353
- snippet: `Content size: ${(sizeBytes / 1024).toFixed(1)}KB (limit: 50KB)`
8854
+ // Add files from SKILL.md metadata.files array
8855
+ for (const pattern of includePatterns){
8856
+ const targetPath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(skillPath, pattern);
8857
+ if (external_node_fs_.existsSync(targetPath)) {
8858
+ const stat = external_node_fs_.statSync(targetPath);
8859
+ if (stat.isDirectory()) // Recursively add all files in directory
8860
+ this.addFilesFromDir(skillPath, pattern, files, seen);
8861
+ else if (!seen.has(pattern)) {
8862
+ files.push(pattern);
8863
+ seen.add(pattern);
8864
+ }
8354
8865
  }
8355
- ];
8356
- return [];
8357
- }
8358
- }
8359
- ];
8360
- // ============================================================================
8361
- // ContentScanner
8362
- // ============================================================================
8363
- /** Build the effective rule set from defaults + options */ function buildRuleSet(options) {
8364
- let rules = DEFAULT_RULES.map((r)=>({
8365
- ...r
8366
- }));
8367
- if (options?.disabledRules?.length) {
8368
- const disabled = new Set(options.disabledRules);
8369
- rules = rules.filter((r)=>!disabled.has(r.id));
8866
+ }
8867
+ } else // No includePatterns: scan entire directory (default behavior)
8868
+ this.addFilesFromDir(skillPath, '', files, seen);
8869
+ return files;
8370
8870
  }
8371
- if (options?.overrides) for (const rule of rules){
8372
- const override = options.overrides[rule.id];
8373
- if (override) rule.level = override;
8871
+ /**
8872
+ * Patterns to ignore when scanning directories
8873
+ */ static IGNORE_PATTERNS = [
8874
+ '.git',
8875
+ '.svn',
8876
+ '.hg',
8877
+ 'node_modules',
8878
+ '.DS_Store',
8879
+ 'Thumbs.db',
8880
+ '.idea',
8881
+ '.vscode',
8882
+ '*.log',
8883
+ '*.tmp',
8884
+ '*.swp',
8885
+ '*.bak'
8886
+ ];
8887
+ /**
8888
+ * Check if a file/directory should be ignored
8889
+ */ shouldIgnore(name) {
8890
+ for (const pattern of SkillValidator.IGNORE_PATTERNS)if (pattern.startsWith('*')) {
8891
+ // Wildcard pattern (e.g., *.log)
8892
+ const ext = pattern.slice(1);
8893
+ if (name.endsWith(ext)) return true;
8894
+ } else if (name === pattern) return true;
8895
+ return false;
8374
8896
  }
8375
- if (options?.customRules?.length) rules.push(...options.customRules);
8376
- return rules;
8377
- }
8378
- /**
8379
- * Content scanner for SKILL.md files.
8380
- *
8381
- * Detects prompt injection, data exfiltration, obfuscation, sensitive file
8382
- * access, stealth instructions, and oversized content.
8383
- *
8384
- * @example
8385
- * ```typescript
8386
- * // Default usage (CLI)
8387
- * const scanner = new ContentScanner();
8388
- * const result = scanner.scan(content);
8389
- *
8390
- * // Custom usage (private registry server)
8391
- * const scanner = new ContentScanner({
8392
- * overrides: { 'prompt-injection': 'medium' },
8393
- * disabledRules: ['stealth-instructions'],
8394
- * });
8395
- * ```
8396
- */ class ContentScanner {
8397
- rules;
8398
- constructor(options){
8399
- this.rules = buildRuleSet(options);
8897
+ /**
8898
+ * Recursively add files from directory
8899
+ */ addFilesFromDir(basePath, dirPath, files, seen) {
8900
+ const fullPath = dirPath ? __WEBPACK_EXTERNAL_MODULE_node_path__.join(basePath, dirPath) : basePath;
8901
+ const entries = external_node_fs_.readdirSync(fullPath, {
8902
+ withFileTypes: true
8903
+ });
8904
+ for (const entry of entries){
8905
+ // Skip ignored files/directories
8906
+ if (this.shouldIgnore(entry.name)) continue;
8907
+ const relativePath = dirPath ? __WEBPACK_EXTERNAL_MODULE_node_path__.join(dirPath, entry.name) : entry.name;
8908
+ if (entry.isDirectory()) this.addFilesFromDir(basePath, relativePath, files, seen);
8909
+ else if (!seen.has(relativePath)) {
8910
+ files.push(relativePath);
8911
+ seen.add(relativePath);
8912
+ }
8913
+ }
8400
8914
  }
8401
8915
  /**
8402
- * Scan content string for malicious patterns.
8403
- * Pure string operation — no file system access.
8404
- */ scan(content) {
8405
- const originalLines = content.split('\n');
8406
- const maskedContent = maskSafeZones(content);
8407
- const findings = [];
8408
- for (const rule of this.rules){
8409
- const targetContent = rule.skipSafeZones ? maskedContent : content;
8410
- const matches = rule.check(targetContent);
8411
- for (const match of matches){
8412
- // Use custom snippet if provided, otherwise generate from original content
8413
- const snippet = match.snippet ?? (null != match.line ? originalLines[match.line - 1]?.trim().slice(0, SNIPPET_MAX_LENGTH) : void 0);
8414
- findings.push({
8415
- rule: rule.id,
8416
- level: rule.level,
8417
- message: rule.message,
8418
- line: match.line,
8419
- snippet
8916
+ * Validate a skill directory for publishing
8917
+ *
8918
+ * Following agentskills.io specification:
8919
+ * - SKILL.md is the SOLE source of metadata
8920
+ * - name and description are REQUIRED in frontmatter
8921
+ */ validate(skillPath) {
8922
+ const errors = [];
8923
+ const warnings = [];
8924
+ // Check SKILL.md exists (REQUIRED per spec)
8925
+ const skillMdPath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(skillPath, 'SKILL.md');
8926
+ if (!external_node_fs_.existsSync(skillMdPath)) {
8927
+ errors.push({
8928
+ field: 'SKILL.md',
8929
+ message: 'SKILL.md not found. This file is required for publishing.',
8930
+ suggestion: 'Create a SKILL.md file with name and description in YAML frontmatter'
8931
+ });
8932
+ return {
8933
+ valid: false,
8934
+ errors,
8935
+ warnings
8936
+ };
8937
+ }
8938
+ // Parse SKILL.md
8939
+ let skillMd;
8940
+ try {
8941
+ skillMd = parseSkillMdFile(skillMdPath);
8942
+ if (!skillMd) {
8943
+ errors.push({
8944
+ field: 'SKILL.md',
8945
+ message: 'SKILL.md must have valid YAML frontmatter with name and description',
8946
+ suggestion: 'Add frontmatter: ---\\nname: your-skill\\ndescription: Your description\\n---'
8420
8947
  });
8948
+ return {
8949
+ valid: false,
8950
+ errors,
8951
+ warnings
8952
+ };
8421
8953
  }
8954
+ } catch (error) {
8955
+ errors.push({
8956
+ field: 'SKILL.md',
8957
+ message: `Failed to parse SKILL.md: ${error.message}`,
8958
+ suggestion: 'Check the YAML frontmatter syntax is valid'
8959
+ });
8960
+ return {
8961
+ valid: false,
8962
+ errors,
8963
+ warnings
8964
+ };
8422
8965
  }
8423
- const hasHighRisk = findings.some((f)=>'high' === f.level);
8966
+ // Validate name from SKILL.md
8967
+ const nameResult = this.validateName(skillMd.name);
8968
+ errors.push(...nameResult.errors);
8969
+ // Validate description from SKILL.md
8970
+ const descResult = this.validateDescription(skillMd.description);
8971
+ errors.push(...descResult.errors);
8972
+ // Check version in SKILL.md
8973
+ const skillMdVersion = skillMd.version || skillMd.metadata?.version;
8974
+ if (skillMdVersion) {
8975
+ // Validate the version from SKILL.md
8976
+ const versionResult = this.validateVersion(skillMdVersion);
8977
+ errors.push(...versionResult.errors);
8978
+ } else warnings.push({
8979
+ field: 'version',
8980
+ message: `No version specified, defaulting to "${DEFAULT_VERSION}"`,
8981
+ suggestion: 'Add version in SKILL.md frontmatter'
8982
+ });
8983
+ // Check keywords count (only if metadata.keywords is a valid array)
8984
+ const keywords = skillMd.metadata?.keywords;
8985
+ if (Array.isArray(keywords) && keywords.length > MAX_KEYWORDS) warnings.push({
8986
+ field: 'keywords',
8987
+ message: `Too many keywords (${keywords.length}). Recommended max: ${MAX_KEYWORDS}`
8988
+ });
8989
+ // Check license
8990
+ if (!skillMd.license) warnings.push({
8991
+ field: 'license',
8992
+ message: 'No license specified',
8993
+ suggestion: 'Add license in SKILL.md frontmatter'
8994
+ });
8424
8995
  return {
8425
- passed: !hasHighRisk,
8426
- findings
8996
+ valid: 0 === errors.length,
8997
+ errors,
8998
+ warnings
8427
8999
  };
8428
9000
  }
8429
9001
  /**
8430
- * Scan a file for malicious patterns.
8431
- * Convenience wrapper that reads the file then calls scan().
8432
- */ scanFile(filePath) {
8433
- const content = external_node_fs_.readFileSync(filePath, 'utf-8');
8434
- return this.scan(content);
9002
+ * Generate integrity hash for files
9003
+ */ generateIntegrity(skillPath, files) {
9004
+ const hash = __WEBPACK_EXTERNAL_MODULE_node_crypto__.createHash('sha256');
9005
+ // Sort files for consistent ordering
9006
+ const sortedFiles = [
9007
+ ...files
9008
+ ].sort();
9009
+ for (const file of sortedFiles){
9010
+ const filePath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(skillPath, file);
9011
+ if (external_node_fs_.existsSync(filePath)) {
9012
+ hash.update(file);
9013
+ const content = external_node_fs_.readFileSync(filePath);
9014
+ hash.update(content);
9015
+ }
9016
+ }
9017
+ return `sha256-${hash.digest('hex')}`;
8435
9018
  }
8436
9019
  }
8437
9020
  /**
@@ -8759,6 +9342,7 @@ async function publishAction(skillPath, options) {
8759
9342
  const absolutePath = __WEBPACK_EXTERNAL_MODULE_node_path__.resolve(skillPath);
8760
9343
  // Use cwd() as project root to find skills.json, not the skill path
8761
9344
  const registry = resolveRegistry(options.registry, process.cwd());
9345
+ let normalizedGroupPath;
8762
9346
  // Validate registry is not a blocked public registry
8763
9347
  validateRegistry(registry);
8764
9348
  // Check directory exists
@@ -8766,6 +9350,14 @@ async function publishAction(skillPath, options) {
8766
9350
  logger_logger.error(`Directory not found: ${skillPath}`);
8767
9351
  process.exit(1);
8768
9352
  }
9353
+ if (options.group) {
9354
+ normalizedGroupPath = normalizeGroupPath(options.group);
9355
+ const validation = validateGroupPath(normalizedGroupPath);
9356
+ if (!validation.valid) {
9357
+ logger_logger.error(validation.error);
9358
+ process.exit(1);
9359
+ }
9360
+ }
8769
9361
  const validator = new SkillValidator();
8770
9362
  const publisher = new Publisher();
8771
9363
  try {
@@ -8796,7 +9388,7 @@ async function publishAction(skillPath, options) {
8796
9388
  const validation = validator.validate(absolutePath);
8797
9389
  // 3.5. Content security scan
8798
9390
  const skillMdPath = __WEBPACK_EXTERNAL_MODULE_node_path__.join(absolutePath, 'SKILL.md');
8799
- if (external_node_fs_.existsSync(skillMdPath)) {
9391
+ if (exists(skillMdPath)) {
8800
9392
  const scanner = new ContentScanner();
8801
9393
  const scanResult = scanner.scanFile(skillMdPath);
8802
9394
  displayScanFindings(scanResult);
@@ -8851,6 +9443,11 @@ async function publishAction(skillPath, options) {
8851
9443
  displayFiles(absolutePath, skill.files, publisher);
8852
9444
  // Display metadata
8853
9445
  displayMetadata(skill);
9446
+ // Display group info
9447
+ if (normalizedGroupPath) {
9448
+ logger_logger.newline();
9449
+ logger_logger.log(`Group: ${normalizedGroupPath}`);
9450
+ }
8854
9451
  // 8. Dry run mode ends here
8855
9452
  if (options.dryRun && payload) {
8856
9453
  displayDryRunSummary(payload);
@@ -8896,7 +9493,8 @@ async function publishAction(skillPath, options) {
8896
9493
  process.exit(1);
8897
9494
  }
8898
9495
  const result = await client.publish(skillName, payload, absolutePath, {
8899
- tag: options.tag
9496
+ tag: options.tag,
9497
+ groupPath: normalizedGroupPath
8900
9498
  });
8901
9499
  if (!result.success || !result.data) {
8902
9500
  logger_logger.error(result.error || 'Publish failed');
@@ -8930,7 +9528,7 @@ async function publishAction(skillPath, options) {
8930
9528
  // ============================================================================
8931
9529
  // Command Definition
8932
9530
  // ============================================================================
8933
- const publishCommand = new __WEBPACK_EXTERNAL_MODULE_commander__.Command('publish').alias('pub').description('Publish a skill to the registry').argument('[path]', 'Path to skill directory', '.').option('-r, --registry <url>', 'Registry URL (or set RESKILL_REGISTRY env var, or defaults.publishRegistry in skills.json)').option('-t, --tag <tag>', 'Git tag to publish').option('--access <level>', 'Access level: public or restricted', 'public').option('-n, --dry-run', 'Validate without publishing').option('-y, --yes', 'Skip confirmation prompts').action(publishAction);
9531
+ const publishCommand = new __WEBPACK_EXTERNAL_MODULE_commander__.Command('publish').alias('pub').description('Publish a skill to the registry').argument('[path]', 'Path to skill directory', '.').option('-r, --registry <url>', 'Registry URL (or set RESKILL_REGISTRY env var, or defaults.publishRegistry in skills.json)').option('-t, --tag <tag>', 'Git tag to publish').option('--access <level>', 'Access level: public or restricted', 'public').option('-n, --dry-run', 'Validate without publishing').option('-y, --yes', 'Skip confirmation prompts').option('-g, --group <path>', 'Publish skill into a group (e.g., "kanyun/frontend")').action(publishAction);
8934
9532
  /**
8935
9533
  * uninstall command - Uninstall one or more skills
8936
9534
  */ const uninstallCommand = new __WEBPACK_EXTERNAL_MODULE_commander__.Command('uninstall').alias('un').alias('remove').alias('rm').description('Uninstall one or more skills').argument('<skills...>', 'Skill names to uninstall').option('-g, --global', 'Uninstall from global installation (~/.claude/skills)').option('-y, --yes', 'Skip confirmation prompts').action(async (skillNames, options)=>{
@@ -9106,6 +9704,7 @@ program.addCommand(publishCommand);
9106
9704
  program.addCommand(loginCommand);
9107
9705
  program.addCommand(logoutCommand);
9108
9706
  program.addCommand(whoamiCommand);
9707
+ program.addCommand(groupCommand);
9109
9708
  program.addCommand(completionCommand);
9110
9709
  program.addCommand(doctorCommand);
9111
9710
  // Start update check in background (non-blocking)