diff --git a/.gitignore b/.gitignore index 2eae3701e..32f405cc1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # OS files .DS_Store Thumbs.db +.nfs* # Editors / IDEs / local tooling .claude/ @@ -14,6 +15,7 @@ Thumbs.db *.iml *.swp *.swo +*.code-workspace # Logs *.log @@ -86,3 +88,7 @@ CLAUDE.md # Local config file .mcp.json + +# Local scripts (personal utilities) +scripts/notify-feishu.sh +scripts/release-cli.sh diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java index 19115087f..3e0996105 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java @@ -41,12 +41,12 @@ public ApiResponse create( @Valid @RequestBody TokenCreateRequest request) { String scopeJson; if (request.scopes() == null || request.scopes().isEmpty()) { - scopeJson = "[\"skill:read\",\"skill:publish\"]"; + scopeJson = "[\"skill:read\",\"skill:publish\",\"skill:manage\"]"; } else { try { scopeJson = objectMapper.writeValueAsString(request.scopes()); } catch (JsonProcessingException e) { - scopeJson = "[\"skill:read\",\"skill:publish\"]"; + scopeJson = "[\"skill:read\",\"skill:publish\",\"skill:manage\"]"; } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminAuditorController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminAuditorController.java new file mode 100644 index 000000000..698511246 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminAuditorController.java @@ -0,0 +1,35 @@ +package com.iflytek.skillhub.controller.admin; + +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Auditor-specific endpoints for read-only access to platform data. + * AUDITOR role can view all data for compliance and auditing purposes. + */ +@RestController +@RequestMapping("/api/v1/admin/auditor") +public class AdminAuditorController extends BaseApiController { + + public AdminAuditorController(ApiResponseFactory responseFactory) { + super(responseFactory); + } + + // TODO: Implement auditor-specific endpoints + // Examples: + // - GET /all-skills - View all skills including hidden + // - GET /review-history - View review history + // - GET /user-activity - View user activity logs + // - GET /statistics - View platform statistics + + @GetMapping("/status") + @PreAuthorize("hasAnyRole('AUDITOR', 'SUPER_ADMIN')") + public ApiResponse getStatus() { + return ok("response.success", "Auditor API is ready. More endpoints coming soon."); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java index 077538a97..6eb92e3fe 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java @@ -33,12 +33,12 @@ public AdminSkillController(ApiResponseFactory responseFactory, } @PostMapping("/{skillId}/hide") - @PreAuthorize("hasRole('SUPER_ADMIN')") + @PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')") public ApiResponse hideSkill(@PathVariable Long skillId, @RequestBody(required = false) AdminSkillActionRequest request, @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest httpRequest) { - var skill = skillGovernanceService.hideSkill( + var skill = skillGovernanceService.hideSkillAsAdmin( skillId, principal.userId(), httpRequest.getRemoteAddr(), @@ -49,11 +49,11 @@ public ApiResponse hideSkill(@PathVariable Long skil } @PostMapping("/{skillId}/unhide") - @PreAuthorize("hasRole('SUPER_ADMIN')") + @PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')") public ApiResponse unhideSkill(@PathVariable Long skillId, @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest httpRequest) { - var skill = skillGovernanceService.unhideSkill( + var skill = skillGovernanceService.unhideSkillAsAdmin( skillId, principal.userId(), httpRequest.getRemoteAddr(), diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillDeleteController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillDeleteController.java index 40fdb3212..8651c94c8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillDeleteController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillDeleteController.java @@ -32,7 +32,7 @@ public SkillDeleteController(SkillDeleteAppService skillDeleteAppService, } @DeleteMapping("/id/{skillId}") - @PreAuthorize("hasRole('SUPER_ADMIN')") + @PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')") public ApiResponse deleteSkillById(@PathVariable Long skillId, @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request) { @@ -50,7 +50,7 @@ public ApiResponse deleteSkillById(@PathVariable Long skill } @DeleteMapping("/{namespace}/{slug}") - @PreAuthorize("hasRole('SUPER_ADMIN')") + @PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')") public ApiResponse deleteSkill(@PathVariable String namespace, @PathVariable String slug, @RequestParam(required = false) String ownerId, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java index b590fa22d..f21d9f702 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java @@ -14,6 +14,7 @@ import jakarta.validation.Valid; import jakarta.servlet.http.HttpServletRequest; import java.util.Map; +import java.util.Set; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -44,6 +45,7 @@ public ApiResponse archiveSkill(@PathVariable St @RequestBody(required = false) AdminSkillActionRequest request, @RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles, HttpServletRequest httpRequest) { return ok("response.success.updated", governanceWorkflowAppService.archiveSkill( @@ -52,6 +54,7 @@ public ApiResponse archiveSkill(@PathVariable St request, userId, userNsRoles, + platformRoles, AuditRequestContext.from(httpRequest))); } @@ -60,6 +63,7 @@ public ApiResponse unarchiveSkill(@PathVariable @PathVariable String slug, @RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles, HttpServletRequest httpRequest) { return ok("response.success.updated", governanceWorkflowAppService.unarchiveSkill( @@ -67,6 +71,7 @@ public ApiResponse unarchiveSkill(@PathVariable slug, userId, userNsRoles, + platformRoles, AuditRequestContext.from(httpRequest))); } @@ -76,6 +81,7 @@ public ApiResponse deleteVersion(@PathVariable S @PathVariable String version, @RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles, HttpServletRequest httpRequest) { return ok("response.success.deleted", governanceWorkflowAppService.deleteVersion( @@ -84,6 +90,7 @@ public ApiResponse deleteVersion(@PathVariable S version, userId, userNsRoles, + platformRoles, AuditRequestContext.from(httpRequest))); } @@ -141,11 +148,11 @@ public ApiResponse submitForReview(@PathVariable @PostMapping("/{namespace}/{slug}/confirm-publish") public ApiResponse confirmPublish(@PathVariable String namespace, - @PathVariable String slug, - @Valid @RequestBody ConfirmPublishRequest request, - @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, - HttpServletRequest httpRequest) { + @PathVariable String slug, + @Valid @RequestBody ConfirmPublishRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { return ok("response.success.updated", governanceWorkflowAppService.confirmPublish( namespace, @@ -155,4 +162,40 @@ public ApiResponse confirmPublish(@PathVariable userNsRoles, AuditRequestContext.from(httpRequest))); } + + @PostMapping("/{namespace}/{slug}/hide") + public ApiResponse hideSkill(@PathVariable String namespace, + @PathVariable String slug, + @RequestBody(required = false) AdminSkillActionRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles, + HttpServletRequest httpRequest) { + return ok("response.success.updated", + governanceWorkflowAppService.hideSkill( + namespace, + slug, + request, + userId, + userNsRoles, + platformRoles, + AuditRequestContext.from(httpRequest))); + } + + @PostMapping("/{namespace}/{slug}/unhide") + public ApiResponse unhideSkill(@PathVariable String namespace, + @PathVariable String slug, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles, + HttpServletRequest httpRequest) { + return ok("response.success.updated", + governanceWorkflowAppService.unhideSkill( + namespace, + slug, + userId, + userNsRoles, + platformRoles, + AuditRequestContext.from(httpRequest))); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillRatingController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillRatingController.java index 8ad74f97a..fae3aaddd 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillRatingController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillRatingController.java @@ -1,20 +1,16 @@ package com.iflytek.skillhub.controller.portal; -import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.SkillRatingRequest; import com.iflytek.skillhub.dto.SkillRatingStatusResponse; import com.iflytek.skillhub.domain.social.SkillRatingService; import jakarta.validation.Valid; -import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.Optional; -/** - * Endpoints for reading and mutating the current user's rating on a skill. - */ @RestController @RequestMapping({"/api/v1/skills", "/api/web/skills"}) public class SkillRatingController extends BaseApiController { @@ -31,19 +27,22 @@ public SkillRatingController(ApiResponseFactory responseFactory, public ApiResponse rateSkill( @PathVariable Long skillId, @Valid @RequestBody SkillRatingRequest request, - @AuthenticationPrincipal PlatformPrincipal principal) { - skillRatingService.rate(skillId, principal.userId(), request.score()); + @RequestAttribute("userId") String userId) { + if (userId == null) { + throw new DomainForbiddenException("error.auth.required"); + } + skillRatingService.rate(skillId, userId, request.score()); return ok("response.success.updated", null); } @GetMapping("/{skillId}/rating") public ApiResponse getUserRating( @PathVariable Long skillId, - @AuthenticationPrincipal PlatformPrincipal principal) { - if (principal == null) { + @RequestAttribute(value = "userId", required = false) String userId) { + if (userId == null) { return ok("response.success.read", new SkillRatingStatusResponse((short) 0, false)); } - Optional rating = skillRatingService.getUserRating(skillId, principal.userId()); + Optional rating = skillRatingService.getUserRating(skillId, userId); return ok( "response.success.read", new SkillRatingStatusResponse( diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillStarController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillStarController.java index 5d95837e8..8b022e12e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillStarController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillStarController.java @@ -1,16 +1,12 @@ package com.iflytek.skillhub.controller.portal; -import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.domain.social.SkillStarService; -import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -/** - * Endpoints for starring, unstarring, and checking star state on a skill. - */ @RestController @RequestMapping({"/api/v1/skills", "/api/web/skills"}) public class SkillStarController extends BaseApiController { @@ -26,27 +22,33 @@ public SkillStarController(ApiResponseFactory responseFactory, @PutMapping("/{skillId}/star") public ApiResponse starSkill( @PathVariable Long skillId, - @AuthenticationPrincipal PlatformPrincipal principal) { - skillStarService.star(skillId, principal.userId()); + @RequestAttribute("userId") String userId) { + if (userId == null) { + throw new DomainForbiddenException("error.auth.required"); + } + skillStarService.star(skillId, userId); return ok("response.success.updated", null); } @DeleteMapping("/{skillId}/star") public ApiResponse unstarSkill( @PathVariable Long skillId, - @AuthenticationPrincipal PlatformPrincipal principal) { - skillStarService.unstar(skillId, principal.userId()); + @RequestAttribute("userId") String userId) { + if (userId == null) { + throw new DomainForbiddenException("error.auth.required"); + } + skillStarService.unstar(skillId, userId); return ok("response.success.updated", null); } @GetMapping("/{skillId}/star") public ApiResponse checkStarred( @PathVariable Long skillId, - @AuthenticationPrincipal PlatformPrincipal principal) { - if (principal == null) { + @RequestAttribute(value = "userId", required = false) String userId) { + if (userId == null) { return ok("response.success.read", false); } - boolean starred = skillStarService.isStarred(skillId, principal.userId()); + boolean starred = skillStarService.isStarred(skillId, userId); return ok("response.success.read", starred); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java index 81cebbde2..d61b746fc 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java @@ -38,12 +38,15 @@ public ApiAccessDeniedHandler(ObjectMapper objectMapper, public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { - logger.info( - "Forbidden API request [requestId={}, method={}, path={}, reason={}]", + String userId = request.getAttribute("userId") != null ? request.getAttribute("userId").toString() : "anonymous"; + logger.warn( + "Forbidden API request [requestId={}, method={}, path={}, userId={}, reason={}, message={}]", MDC.get("requestId"), request.getMethod(), sensitiveLogSanitizer.sanitizeRequestTarget(request), - accessDeniedException.getClass().getSimpleName() + userId, + accessDeniedException.getClass().getSimpleName(), + accessDeniedException.getMessage() ); ApiResponse body = apiResponseFactory.error(403, "error.forbidden"); response.setStatus(HttpServletResponse.SC_FORBIDDEN); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java index 6cca39525..b13d364e7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java @@ -13,6 +13,7 @@ import com.iflytek.skillhub.dto.SkillVersionRereleaseRequest; import java.io.InputStream; import java.util.Map; +import java.util.Set; import org.springframework.stereotype.Service; /** @@ -182,16 +183,18 @@ public SkillLifecycleMutationResponse archiveSkill(String namespace, AdminSkillActionRequest request, String userId, Map userNsRoles, + Set platformRoles, AuditRequestContext auditContext) { - return skillLifecycleAppService.archiveSkill(namespace, slug, request, userId, userNsRoles, auditContext); + return skillLifecycleAppService.archiveSkill(namespace, slug, request, userId, userNsRoles, platformRoles, auditContext); } public SkillLifecycleMutationResponse unarchiveSkill(String namespace, String slug, String userId, Map userNsRoles, + Set platformRoles, AuditRequestContext auditContext) { - return skillLifecycleAppService.unarchiveSkill(namespace, slug, userId, userNsRoles, auditContext); + return skillLifecycleAppService.unarchiveSkill(namespace, slug, userId, userNsRoles, platformRoles, auditContext); } public SkillLifecycleMutationResponse deleteVersion(String namespace, @@ -199,8 +202,9 @@ public SkillLifecycleMutationResponse deleteVersion(String namespace, String version, String userId, Map userNsRoles, + Set platformRoles, AuditRequestContext auditContext) { - return skillLifecycleAppService.deleteVersion(namespace, slug, version, userId, userNsRoles, auditContext); + return skillLifecycleAppService.deleteVersion(namespace, slug, version, userId, userNsRoles, platformRoles, auditContext); } public SkillLifecycleMutationResponse withdrawReviewVersion(String namespace, @@ -273,11 +277,11 @@ public SkillLifecycleMutationResponse submitForReview(String namespace, } public SkillLifecycleMutationResponse confirmPublish(String namespace, - String slug, - String version, - String userId, - Map userNsRoles, - AuditRequestContext auditContext) { + String slug, + String version, + String userId, + Map userNsRoles, + AuditRequestContext auditContext) { return skillLifecycleAppService.confirmPublish( namespace, slug, @@ -286,4 +290,23 @@ public SkillLifecycleMutationResponse confirmPublish(String namespace, userNsRoles, auditContext); } + + public SkillLifecycleMutationResponse hideSkill(String namespace, + String slug, + AdminSkillActionRequest request, + String userId, + Map userNsRoles, + Set platformRoles, + AuditRequestContext auditContext) { + return skillLifecycleAppService.hideSkill(namespace, slug, request, userId, userNsRoles, platformRoles, auditContext); + } + + public SkillLifecycleMutationResponse unhideSkill(String namespace, + String slug, + String userId, + Map userNsRoles, + Set platformRoles, + AuditRequestContext auditContext) { + return skillLifecycleAppService.unhideSkill(namespace, slug, userId, userNsRoles, platformRoles, auditContext); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java index e7f86d459..269294c9a 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java @@ -17,6 +17,7 @@ import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse; import com.iflytek.skillhub.dto.SkillVersionRereleaseRequest; import java.util.Map; +import java.util.Set; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -60,12 +61,14 @@ public SkillLifecycleMutationResponse archiveSkill(String namespace, AdminSkillActionRequest request, String userId, Map userNamespaceRoles, + Set platformRoles, AuditRequestContext auditContext) { Skill skill = findSkill(namespace, slug, userId); Skill archived = skillGovernanceService.archiveSkill( skill.getId(), userId, normalizeRoles(userNamespaceRoles), + platformRoles, auditContext.clientIp(), auditContext.userAgent(), request != null ? request.reason() : null @@ -78,12 +81,14 @@ public SkillLifecycleMutationResponse unarchiveSkill(String namespace, String slug, String userId, Map userNamespaceRoles, + Set platformRoles, AuditRequestContext auditContext) { Skill skill = findSkill(namespace, slug, userId); Skill restored = skillGovernanceService.unarchiveSkill( skill.getId(), userId, normalizeRoles(userNamespaceRoles), + platformRoles, auditContext.clientIp(), auditContext.userAgent() ); @@ -96,6 +101,7 @@ public SkillLifecycleMutationResponse deleteVersion(String namespace, String version, String userId, Map userNamespaceRoles, + Set platformRoles, AuditRequestContext auditContext) { Skill skill = findSkill(namespace, slug, userId); SkillVersion skillVersion = findVersion(skill.getId(), version); @@ -104,6 +110,7 @@ public SkillLifecycleMutationResponse deleteVersion(String namespace, skillVersion, userId, normalizeRoles(userNamespaceRoles), + platformRoles, auditContext.clientIp(), auditContext.userAgent(), namespace @@ -213,11 +220,11 @@ public SkillLifecycleMutationResponse submitForReview(String namespace, @Transactional public SkillLifecycleMutationResponse confirmPublish(String namespace, - String slug, - String version, - String userId, - Map userNamespaceRoles, - AuditRequestContext auditContext) { + String slug, + String version, + String userId, + Map userNamespaceRoles, + AuditRequestContext auditContext) { Skill skill = findSkill(namespace, slug, userId); SkillVersion skillVersion = findVersion(skill.getId(), version); skillReviewSubmitService.confirmPublish( @@ -244,6 +251,46 @@ public SkillLifecycleMutationResponse confirmPublish(String namespace, ); } + @Transactional + public SkillLifecycleMutationResponse hideSkill(String namespace, + String slug, + AdminSkillActionRequest request, + String userId, + Map userNamespaceRoles, + Set platformRoles, + AuditRequestContext auditContext) { + Skill skill = findSkill(namespace, slug, userId); + Skill hidden = skillGovernanceService.hideSkill( + skill.getId(), + userId, + normalizeRoles(userNamespaceRoles), + platformRoles, + auditContext.clientIp(), + auditContext.userAgent(), + request != null ? request.reason() : null + ); + return new SkillLifecycleMutationResponse(hidden.getId(), null, "HIDE", hidden.getStatus().name()); + } + + @Transactional + public SkillLifecycleMutationResponse unhideSkill(String namespace, + String slug, + String userId, + Map userNamespaceRoles, + Set platformRoles, + AuditRequestContext auditContext) { + Skill skill = findSkill(namespace, slug, userId); + Skill unhidden = skillGovernanceService.unhideSkill( + skill.getId(), + userId, + normalizeRoles(userNamespaceRoles), + platformRoles, + auditContext.clientIp(), + auditContext.userAgent() + ); + return new SkillLifecycleMutationResponse(unhidden.getId(), null, "UNHIDE", unhidden.getStatus().name()); + } + private Skill findSkill(String namespaceSlug, String skillSlug, String currentUserId) { String cleanNamespace = namespaceSlug.startsWith("@") ? namespaceSlug.substring(1) : namespaceSlug; Namespace namespace = namespaceRepository.findBySlug(cleanNamespace) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillControllerTest.java index 2a6215b3f..34b3e2a52 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillControllerTest.java @@ -48,7 +48,7 @@ class AdminSkillControllerTest { @Test void hideSkill_returnsUpdatedResponse() throws Exception { Skill skill = new Skill(1L, "demo", "owner", SkillVisibility.PUBLIC); - given(skillGovernanceService.hideSkill(org.mockito.ArgumentMatchers.eq(10L), org.mockito.ArgumentMatchers.eq("admin"), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.eq("policy"))) + given(skillGovernanceService.hideSkillAsAdmin(org.mockito.ArgumentMatchers.eq(10L), org.mockito.ArgumentMatchers.eq("admin"), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.eq("policy"))) .willReturn(skill); PlatformPrincipal principal = new PlatformPrincipal("admin", "admin", "a@example.com", "", "github", Set.of("SUPER_ADMIN")); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java index 09751065f..8b7452f14 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.controller.portal; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.nullable; @@ -88,7 +89,7 @@ void archiveSkill_returnsUnifiedEnvelope() throws Exception { given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); given(skillSlugResolutionService.resolve(1L, "demo-skill", "usr_1", SkillSlugResolutionService.Preference.CURRENT_USER)) .willReturn(skill); - given(skillGovernanceService.archiveSkill(eq(1L), eq("usr_1"), anyMap(), nullable(String.class), nullable(String.class), eq("cleanup"))) + given(skillGovernanceService.archiveSkill(eq(1L), eq("usr_1"), anyMap(), any(), nullable(String.class), nullable(String.class), eq("cleanup"))) .willReturn(skillWithStatus(skill, com.iflytek.skillhub.domain.skill.SkillStatus.ARCHIVED)); mockMvc.perform(post("/api/web/skills/global/demo-skill/archive") @@ -116,7 +117,7 @@ void unarchiveSkill_returnsUnifiedEnvelope() throws Exception { given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); given(skillSlugResolutionService.resolve(1L, "demo-skill", "usr_1", SkillSlugResolutionService.Preference.CURRENT_USER)) .willReturn(skill); - given(skillGovernanceService.unarchiveSkill(eq(1L), eq("usr_1"), anyMap(), nullable(String.class), nullable(String.class))) + given(skillGovernanceService.unarchiveSkill(eq(1L), eq("usr_1"), anyMap(), any(), nullable(String.class), nullable(String.class))) .willReturn(skillWithStatus(skill, com.iflytek.skillhub.domain.skill.SkillStatus.ACTIVE)); mockMvc.perform(post("/api/web/skills/global/demo-skill/unarchive") @@ -241,7 +242,7 @@ void archiveSkill_acceptsAtPrefixedNamespaceSlug() throws Exception { given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); given(skillSlugResolutionService.resolve(1L, "demo-skill", "usr_1", SkillSlugResolutionService.Preference.CURRENT_USER)) .willReturn(skill); - given(skillGovernanceService.archiveSkill(eq(1L), eq("usr_1"), anyMap(), nullable(String.class), nullable(String.class), eq("cleanup"))) + given(skillGovernanceService.archiveSkill(eq(1L), eq("usr_1"), anyMap(), any(), nullable(String.class), nullable(String.class), eq("cleanup"))) .willReturn(skillWithStatus(skill, com.iflytek.skillhub.domain.skill.SkillStatus.ARCHIVED)); mockMvc.perform(post("/api/web/skills/@global/demo-skill/archive") diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java index e2c101462..e8488b3f0 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.nullable; @@ -59,7 +60,7 @@ void archiveSkill_resolvesNamespaceAndDelegatesLifecycleMutation() { when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); when(skillSlugResolutionService.resolve(7L, "demo-skill", "owner-1", SkillSlugResolutionService.Preference.CURRENT_USER)) .thenReturn(skill); - when(skillGovernanceService.archiveSkill(eq(11L), eq("owner-1"), anyMap(), nullable(String.class), nullable(String.class), eq("cleanup"))) + when(skillGovernanceService.archiveSkill(eq(11L), eq("owner-1"), anyMap(), any(), nullable(String.class), nullable(String.class), eq("cleanup"))) .thenReturn(skill); var response = service.archiveSkill( @@ -68,12 +69,13 @@ void archiveSkill_resolvesNamespaceAndDelegatesLifecycleMutation() { new AdminSkillActionRequest("cleanup"), "owner-1", Map.of(7L, NamespaceRole.OWNER), + null, new AuditRequestContext("127.0.0.1", "JUnit") ); assertThat(response.skillId()).isEqualTo(11L); assertThat(response.action()).isEqualTo("ARCHIVE"); assertThat(response.status()).isEqualTo("ARCHIVED"); - verify(skillGovernanceService).archiveSkill(11L, "owner-1", Map.of(7L, NamespaceRole.OWNER), "127.0.0.1", "JUnit", "cleanup"); + verify(skillGovernanceService).archiveSkill(11L, "owner-1", Map.of(7L, NamespaceRole.OWNER), null, "127.0.0.1", "JUnit", "cleanup"); } } diff --git a/server/skillhub-auth/.factorypath b/server/skillhub-auth/.factorypath new file mode 100644 index 000000000..b547b27b0 --- /dev/null +++ b/server/skillhub-auth/.factorypath @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java index e838c9a82..333a1ca18 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java @@ -29,7 +29,7 @@ public class DeviceAuthService { private static final long PENDING_CODE_TTL_MINUTES = EXPIRES_IN_SECONDS / 60L; private static final long USED_CODE_TTL_MINUTES = 1L; private static final String CLI_DEVICE_TOKEN_NAME = "CLI Device Flow"; - private static final String CLI_DEVICE_SCOPE_JSON = "[\"skill:read\",\"skill:publish\"]"; + private static final String CLI_DEVICE_SCOPE_JSON = "[\"skill:read\",\"skill:publish\",\"skill:manage\"]"; private final RedisTemplate redisTemplate; private final ApiTokenService apiTokenService; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java index 8235032cc..e9de30d67 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java @@ -67,14 +67,34 @@ public class RouteSecurityPolicyRegistry { RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/tags/*/files"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/tags/*/file"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/labels"), - RouteAuthorizationPolicy.roles(HttpMethod.DELETE, "/api/v1/skills/id/*", "SUPER_ADMIN"), - RouteAuthorizationPolicy.roles(HttpMethod.DELETE, "/api/v1/skills/*/*", "SUPER_ADMIN"), + RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/v1/skills/id/*"), + RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/v1/skills/*/*"), RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/skills/id/*"), RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/skills/*/*"), RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/namespaces"), RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/namespaces/*"), RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/namespaces"), RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/namespaces/*"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/me/**"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/me/**"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/notifications"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/notifications/**"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/notifications/*"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/notifications"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/notifications/**"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/notifications/*"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/reviews/**"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/reviews/**"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/reviews/**"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/reviews/**"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/skills/**"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/skills/**"), + RouteAuthorizationPolicy.authenticated(HttpMethod.PUT, "/api/v1/skills/**"), + RouteAuthorizationPolicy.authenticated(HttpMethod.PUT, "/api/web/skills/**"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/namespaces/**"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/namespaces/**"), + RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/v1/skills/*/*"), + RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/skills/*/*"), RouteAuthorizationPolicy.authenticated(null, "/api/v1/admin/**") ); @@ -94,6 +114,43 @@ public class RouteSecurityPolicyRegistry { ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/namespaces/*"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/namespaces"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/namespaces/*"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/me/**"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/me/**"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/notifications"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/notifications/**"), + ApiTokenPolicy.allow(HttpMethod.POST, "/api/v1/notifications/*"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/notifications"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/notifications/**"), + ApiTokenPolicy.allow(HttpMethod.POST, "/api/web/notifications/*"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/reviews/**"), + ApiTokenPolicy.allow(HttpMethod.POST, "/api/v1/reviews/**"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/reviews/**"), + ApiTokenPolicy.allow(HttpMethod.POST, "/api/web/reviews/**"), + // Lifecycle governance — require skill:manage scope (must appear before broad allow) + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills/*/*/hide", "skill:manage"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills/*/*/unhide", "skill:manage"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills/*/*/archive", "skill:manage"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills/*/*/unarchive", "skill:manage"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/web/skills/*/*/hide", "skill:manage"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/web/skills/*/*/unhide", "skill:manage"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/web/skills/*/*/archive", "skill:manage"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/web/skills/*/*/unarchive", "skill:manage"), + // Social / personal actions — no scope required + ApiTokenPolicy.allow(HttpMethod.PUT, "/api/v1/skills/*/star"), + ApiTokenPolicy.allow(HttpMethod.DELETE, "/api/v1/skills/*/star"), + ApiTokenPolicy.allow(HttpMethod.PUT, "/api/web/skills/*/star"), + ApiTokenPolicy.allow(HttpMethod.DELETE, "/api/web/skills/*/star"), + ApiTokenPolicy.allow(HttpMethod.PUT, "/api/v1/skills/*/rating"), + ApiTokenPolicy.allow(HttpMethod.PUT, "/api/web/skills/*/rating"), + ApiTokenPolicy.allow(HttpMethod.POST, "/api/v1/skills/*/*/reports"), + ApiTokenPolicy.allow(HttpMethod.POST, "/api/web/skills/*/*/reports"), + // Broad fallback for remaining skill operations (publish, etc.) + ApiTokenPolicy.allow(HttpMethod.POST, "/api/v1/skills/**"), + ApiTokenPolicy.allow(HttpMethod.POST, "/api/web/skills/**"), + ApiTokenPolicy.allow(HttpMethod.PUT, "/api/v1/skills/**"), + ApiTokenPolicy.allow(HttpMethod.PUT, "/api/web/skills/**"), + ApiTokenPolicy.allow(HttpMethod.POST, "/api/v1/namespaces/**"), + ApiTokenPolicy.allow(HttpMethod.POST, "/api/web/namespaces/**"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/resolve/**"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/download"), ApiTokenPolicy.allow(null, "/.well-known/**"), diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java index 82c0f2c1e..00e023824 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java @@ -20,6 +20,7 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -37,15 +38,18 @@ public class ApiTokenAuthenticationFilter extends OncePerRequestFilter { private final UserAccountRepository userRepo; private final UserRoleBindingRepository roleBindingRepo; private final ApiTokenScopeService apiTokenScopeService; + private final com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository namespaceMemberRepo; public ApiTokenAuthenticationFilter(ApiTokenService apiTokenService, UserAccountRepository userRepo, UserRoleBindingRepository roleBindingRepo, - ApiTokenScopeService apiTokenScopeService) { + ApiTokenScopeService apiTokenScopeService, + com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository namespaceMemberRepo) { this.apiTokenService = apiTokenService; this.userRepo = userRepo; this.roleBindingRepo = roleBindingRepo; this.apiTokenScopeService = apiTokenScopeService; + this.namespaceMemberRepo = namespaceMemberRepo; } @Override @@ -77,6 +81,13 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse .toList()); var auth = new UsernamePasswordAuthenticationToken(principal, null, authorities); SecurityContextHolder.getContext().setAuthentication(auth); + Map userNsRoles = + namespaceMemberRepo.findByUserId(user.getId()).stream() + .collect(Collectors.toMap( + com.iflytek.skillhub.domain.namespace.NamespaceMember::getNamespaceId, + com.iflytek.skillhub.domain.namespace.NamespaceMember::getRole, + (left, right) -> left)); + request.setAttribute("userNsRoles", userNsRoles); apiTokenService.touchLastUsed(token); }); }); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java index e82f7a1ab..ad4be5e36 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java @@ -34,11 +34,13 @@ class ApiTokenAuthenticationFilterTest { private final UserRoleBindingRepository roleBindingRepository = mock(UserRoleBindingRepository.class); private final ApiTokenScopeService scopeService = new ApiTokenScopeService(new ObjectMapper(), new RouteSecurityPolicyRegistry()); + private final com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository namespaceMemberRepository = mock(com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository.class); private final ApiTokenAuthenticationFilter filter = new ApiTokenAuthenticationFilter( apiTokenService, userAccountRepository, roleBindingRepository, - scopeService + scopeService, + namespaceMemberRepository ); @AfterEach diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java index 3a9b82b1b..67bf1aa83 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java @@ -102,7 +102,7 @@ public SkillReport resolveReport(Long reportId, String userAgent) { SkillReport report = requirePendingReport(reportId); if (disposition == SkillReportDisposition.RESOLVE_AND_HIDE) { - skillGovernanceService.hideSkill(report.getSkillId(), actorUserId, clientIp, userAgent, comment); + skillGovernanceService.hideSkillAsAdmin(report.getSkillId(), actorUserId, clientIp, userAgent, comment); } else if (disposition == SkillReportDisposition.RESOLVE_AND_ARCHIVE) { skillGovernanceService.archiveSkillAsAdmin(report.getSkillId(), actorUserId, clientIp, userAgent, comment); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java index 0dfcb5f35..c8b5429cf 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java @@ -21,6 +21,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Set; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.ApplicationEventPublisher; @@ -69,15 +70,37 @@ public SkillGovernanceService(SkillRepository skillRepository, } @Transactional - public Skill hideSkill(Long skillId, String actorUserId, String clientIp, String userAgent, String reason) { + public Skill hideSkill(Long skillId, + String actorUserId, + Map userNamespaceRoles, + Set platformRoles, + String clientIp, + String userAgent, + String reason) { Skill skill = skillRepository.findById(skillId) .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId)); + assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles, platformRoles); + return hideSkillInternal(skill, actorUserId, clientIp, userAgent, reason); + } + + @Transactional + public Skill hideSkillAsAdmin(Long skillId, + String actorUserId, + String clientIp, + String userAgent, + String reason) { + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId)); + return hideSkillInternal(skill, actorUserId, clientIp, userAgent, reason); + } + + private Skill hideSkillInternal(Skill skill, String actorUserId, String clientIp, String userAgent, String reason) { skill.setHidden(true); skill.setHiddenAt(currentInstant()); skill.setHiddenBy(actorUserId); skill.setUpdatedBy(actorUserId); Skill saved = skillRepository.save(skill); - auditLogService.record(actorUserId, "HIDE_SKILL", "SKILL", skillId, null, clientIp, userAgent, jsonReason(reason)); + auditLogService.record(actorUserId, "HIDE_SKILL", "SKILL", skill.getId(), null, clientIp, userAgent, jsonReason(reason)); return saved; } @@ -85,12 +108,13 @@ public Skill hideSkill(Long skillId, String actorUserId, String clientIp, String public Skill archiveSkill(Long skillId, String actorUserId, Map userNamespaceRoles, + Set platformRoles, String clientIp, String userAgent, String reason) { Skill skill = skillRepository.findById(skillId) .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId)); - assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles); + assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles, platformRoles); return archiveSkillInternal(skill, actorUserId, clientIp, userAgent, reason); } @@ -120,15 +144,35 @@ private Skill archiveSkillInternal(Skill skill, } @Transactional - public Skill unhideSkill(Long skillId, String actorUserId, String clientIp, String userAgent) { + public Skill unhideSkill(Long skillId, + String actorUserId, + Map userNamespaceRoles, + Set platformRoles, + String clientIp, + String userAgent) { + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId)); + assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles, platformRoles); + return unhideSkillInternal(skill, actorUserId, clientIp, userAgent); + } + + @Transactional + public Skill unhideSkillAsAdmin(Long skillId, + String actorUserId, + String clientIp, + String userAgent) { Skill skill = skillRepository.findById(skillId) .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId)); + return unhideSkillInternal(skill, actorUserId, clientIp, userAgent); + } + + private Skill unhideSkillInternal(Skill skill, String actorUserId, String clientIp, String userAgent) { skill.setHidden(false); skill.setHiddenAt(null); skill.setHiddenBy(null); skill.setUpdatedBy(actorUserId); Skill saved = skillRepository.save(skill); - auditLogService.record(actorUserId, "UNHIDE_SKILL", "SKILL", skillId, null, clientIp, userAgent, null); + auditLogService.record(actorUserId, "UNHIDE_SKILL", "SKILL", skill.getId(), null, clientIp, userAgent, null); return saved; } @@ -136,11 +180,12 @@ public Skill unhideSkill(Long skillId, String actorUserId, String clientIp, Stri public Skill unarchiveSkill(Long skillId, String actorUserId, Map userNamespaceRoles, + Set platformRoles, String clientIp, String userAgent) { Skill skill = skillRepository.findById(skillId) .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId)); - assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles); + assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles, platformRoles); SkillStatus previousStatus = skill.getStatus(); skill.setStatus(SkillStatus.ACTIVE); @@ -156,10 +201,11 @@ public void deleteVersion(Skill skill, SkillVersion version, String actorUserId, Map userNamespaceRoles, + Set platformRoles, String clientIp, String userAgent, String namespaceSlug) { - assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles); + assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles, platformRoles); if (version.getStatus() != SkillVersionStatus.DRAFT && version.getStatus() != SkillVersionStatus.REJECTED && version.getStatus() != SkillVersionStatus.SCAN_FAILED @@ -286,8 +332,13 @@ private Long findLatestPublishedVersionId(Long skillId) { private void assertCanManageLifecycle(Skill skill, String actorUserId, - Map userNamespaceRoles) { - NamespaceRole namespaceRole = userNamespaceRoles.get(skill.getNamespaceId()); + Map userNamespaceRoles, + Set platformRoles) { + if (platformRoles != null && + (platformRoles.contains("SUPER_ADMIN") || platformRoles.contains("SKILL_ADMIN"))) { + return; + } + NamespaceRole namespaceRole = userNamespaceRoles != null ? userNamespaceRoles.get(skill.getNamespaceId()) : null; boolean canManage = skill.getOwnerId().equals(actorUserId) || namespaceRole == NamespaceRole.ADMIN || namespaceRole == NamespaceRole.OWNER; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java index 174d23527..b1db81167 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java @@ -134,7 +134,7 @@ void resolveReport_withHideDisposition_hidesSkillAndNotifiesReporter() { ); assertThat(saved.getStatus()).isEqualTo(SkillReportStatus.RESOLVED); - verify(skillGovernanceService).hideSkill(10L, "admin", "127.0.0.1", "JUnit", "handled"); + verify(skillGovernanceService).hideSkillAsAdmin(10L, "admin", "127.0.0.1", "JUnit", "handled"); verify(governanceNotificationService).notifyUser( eq("user-1"), eq("REPORT"), diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java index b6f3faff4..36c761d0d 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java @@ -92,7 +92,7 @@ void hideSkill_marksSkillHidden() { given(skillRepository.findById(10L)).willReturn(Optional.of(skill)); given(skillRepository.save(skill)).willReturn(skill); - Skill result = service.hideSkill(10L, "admin", "127.0.0.1", "JUnit", "policy"); + Skill result = service.hideSkill(10L, "admin", java.util.Map.of(), null, "127.0.0.1", "JUnit", "policy"); assertThat(result.isHidden()).isTrue(); assertThat(result.getHiddenBy()).isEqualTo("admin"); @@ -107,7 +107,7 @@ void archiveSkill_marksSkillArchived() { given(skillRepository.findById(10L)).willReturn(Optional.of(skill)); given(skillRepository.save(skill)).willReturn(skill); - Skill result = service.archiveSkill(10L, "owner", Map.of(), "127.0.0.1", "JUnit", "cleanup"); + Skill result = service.archiveSkill(10L, "owner", Map.of(), null, "127.0.0.1", "JUnit", "cleanup"); assertThat(result.getStatus()).isEqualTo(SkillStatus.ARCHIVED); verify(auditLogService).record("owner", "ARCHIVE_SKILL", "SKILL", 10L, null, "127.0.0.1", "JUnit", "{\"reason\":\"cleanup\"}"); @@ -122,7 +122,7 @@ void unarchiveSkill_restoresActiveStatus() { given(skillRepository.findById(10L)).willReturn(Optional.of(skill)); given(skillRepository.save(skill)).willReturn(skill); - Skill result = service.unarchiveSkill(10L, "owner", Map.of(), "127.0.0.1", "JUnit"); + Skill result = service.unarchiveSkill(10L, "owner", Map.of(), null, "127.0.0.1", "JUnit"); assertThat(result.getStatus()).isEqualTo(SkillStatus.ACTIVE); verify(auditLogService).record("owner", "UNARCHIVE_SKILL", "SKILL", 10L, null, "127.0.0.1", "JUnit", null); @@ -136,7 +136,7 @@ void archiveSkill_requiresOwnerOrNamespaceAdmin() { given(skillRepository.findById(10L)).willReturn(Optional.of(skill)); assertThrows(DomainForbiddenException.class, - () -> service.archiveSkill(10L, "other", Map.of(1L, NamespaceRole.MEMBER), "127.0.0.1", "JUnit", null)); + () -> service.archiveSkill(10L, "other", Map.of(1L, NamespaceRole.MEMBER), null, "127.0.0.1", "JUnit", null)); } @Test @@ -216,7 +216,7 @@ void deleteVersion_removesDraftFilesAndBundle() { SkillFile icon = new SkillFile(version.getId(), "icon.png", 20L, "image/png", "sha2", "skills/demo/icon"); given(skillFileRepository.findByVersionId(version.getId())).willReturn(java.util.List.of(readme, icon)); - service.deleteVersion(skill, version, "owner", Map.of(), "127.0.0.1", "JUnit", "test-ns"); + service.deleteVersion(skill, version, "owner", Map.of(), null, "127.0.0.1", "JUnit", "test-ns"); verify(objectStorageService).deleteObjects(argThat(keys -> keys.size() == 3 @@ -246,7 +246,7 @@ void deleteVersion_deletesStorageAfterCommitWhenSynchronizationIsActive() { TransactionSynchronizationManager.initSynchronization(); - service.deleteVersion(skill, version, "owner", Map.of(), "127.0.0.1", "JUnit", "test-ns"); + service.deleteVersion(skill, version, "owner", Map.of(), null, "127.0.0.1", "JUnit", "test-ns"); verify(objectStorageService, never()).deleteObjects(argThat(keys -> !keys.isEmpty())); @@ -279,7 +279,7 @@ void deleteVersion_recordsCompensationWhenDeferredDeleteFails() { TransactionSynchronizationManager.initSynchronization(); - service.deleteVersion(skill, version, "owner", Map.of(), "127.0.0.1", "JUnit", "test-ns"); + service.deleteVersion(skill, version, "owner", Map.of(), null, "127.0.0.1", "JUnit", "test-ns"); for (TransactionSynchronization synchronization : TransactionSynchronizationManager.getSynchronizations()) { synchronization.afterCommit(); @@ -307,7 +307,7 @@ void deleteVersion_rejectsPublishedVersion() { version.setStatus(SkillVersionStatus.PUBLISHED); assertThrows(DomainBadRequestException.class, - () -> service.deleteVersion(skill, version, "owner", Map.of(), "127.0.0.1", "JUnit", "test-ns")); + () -> service.deleteVersion(skill, version, "owner", Map.of(), null, "127.0.0.1", "JUnit", "test-ns")); verify(skillVersionRepository, never()).delete(any()); verify(objectStorageService, never()).deleteObject(any()); @@ -323,7 +323,7 @@ void deleteVersion_rejectsLastRemainingVersion() { given(skillVersionRepository.findBySkillId(1L)).willReturn(java.util.List.of(version)); DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, - () -> service.deleteVersion(skill, version, "owner", Map.of(), "127.0.0.1", "JUnit", "test-ns")); + () -> service.deleteVersion(skill, version, "owner", Map.of(), null, "127.0.0.1", "JUnit", "test-ns")); assertThat(ex.messageCode()).isEqualTo("error.skill.version.delete.lastVersion"); verify(skillVersionRepository, never()).delete(any()); @@ -351,7 +351,7 @@ void deleteVersion_updatesLatestVersionPointerWhenDeletingArchivedSkillsLatestDr given(skillRepository.save(skill)).willReturn(skill); given(skillFileRepository.findByVersionId(2L)).willReturn(java.util.List.of()); - service.deleteVersion(skill, draftVersion, "owner", Map.of(), "127.0.0.1", "JUnit", "test-ns"); + service.deleteVersion(skill, draftVersion, "owner", Map.of(), null, "127.0.0.1", "JUnit", "test-ns"); assertThat(skill.getLatestVersionId()).isEqualTo(3L); verify(skillRepository).save(skill); diff --git a/skillhub-cli/.npmignore b/skillhub-cli/.npmignore new file mode 100644 index 000000000..fe3e5ff23 --- /dev/null +++ b/skillhub-cli/.npmignore @@ -0,0 +1,15 @@ +tmp/ +tests/ +vitest.config.ts +unbuild.config.ts +tsconfig.json +*.test.ts +src/ +.agents/ +.claude/ +.omc/ +AGENTS.md +README.md +Makefile +docs/ +scripts/ \ No newline at end of file diff --git a/skillhub-cli/package.json b/skillhub-cli/package.json new file mode 100644 index 000000000..9cb42d4d5 --- /dev/null +++ b/skillhub-cli/package.json @@ -0,0 +1,33 @@ +{ + "name": "motovis-skillhub", + "version": "1.2.11", + "type": "module", + "description": "SkillHub CLI - 企业级 Agent Skill 管理工具,支持命名空间", + "bin": { + "skillhub": "dist/cli.mjs" + }, + "scripts": { + "build": "unbuild", + "dev": "unbuild --stub", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@clack/prompts": "^1.2.0", + "chalk": "^5.3.0", + "commander": "^14.0.3", + "ora": "^9.3.0", + "picocolors": "^1.1.1", + "semver": "^7.7.4", + "undici": "^7.24.0", + "unzipper": "^0.12.3" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/semver": "^7.5.8", + "@types/unzipper": "^0.10.11", + "typescript": "^5.6.0", + "unbuild": "^3.0.0", + "vitest": "^3.0.0" + } +} diff --git a/skillhub-cli/pnpm-lock.yaml b/skillhub-cli/pnpm-lock.yaml new file mode 100644 index 000000000..4a105fa44 --- /dev/null +++ b/skillhub-cli/pnpm-lock.yaml @@ -0,0 +1,2758 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@clack/prompts': + specifier: ^1.2.0 + version: 1.2.0 + chalk: + specifier: ^5.3.0 + version: 5.6.2 + commander: + specifier: ^14.0.3 + version: 14.0.3 + ora: + specifier: ^9.3.0 + version: 9.3.0 + picocolors: + specifier: ^1.1.1 + version: 1.1.1 + semver: + specifier: ^7.7.4 + version: 7.7.4 + undici: + specifier: ^7.24.0 + version: 7.24.7 + unzipper: + specifier: ^0.12.3 + version: 0.12.3 + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.15 + '@types/semver': + specifier: ^7.5.8 + version: 7.7.1 + '@types/unzipper': + specifier: ^0.10.11 + version: 0.10.11 + typescript: + specifier: ^5.6.0 + version: 5.9.3 + unbuild: + specifier: ^3.0.0 + version: 3.6.1(typescript@5.9.3) + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@22.19.15)(jiti@2.6.1) + +packages: + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@clack/core@1.2.0': + resolution: {integrity: sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==} + + '@clack/prompts@1.2.0': + resolution: {integrity: sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==} + + '@colordx/core@5.0.3': + resolution: {integrity: sha512-xBQ0MYRTNNxW3mS2sJtlQTT7C3Sasqgh1/PsHva7fyDb5uqYY+gv9V0utDdX8X80mqzbGz3u/IDJdn2d/uW09g==} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rollup/plugin-alias@5.1.1': + resolution: {integrity: sha512-PR9zDb+rOzkRb2VD+EuKB7UC41vU5DIwZ5qqCpk0KJudcWAyi8rvYOhS7+L5aZCspw1stTViLgN5v6FF1p5cgQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-commonjs@28.0.9': + resolution: {integrity: sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA==} + engines: {node: '>=16.0.0 || 14 >= 14.17'} + peerDependencies: + rollup: ^2.68.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-json@6.1.0': + resolution: {integrity: sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-node-resolve@16.0.3': + resolution: {integrity: sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.78.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-replace@6.0.3': + resolution: {integrity: sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.60.1': + resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.1': + resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.1': + resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.1': + resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.1': + resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.1': + resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.1': + resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.1': + resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.1': + resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.1': + resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.1': + resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.1': + resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.1': + resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.1': + resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} + cpu: [x64] + os: [win32] + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/node@22.19.15': + resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} + + '@types/resolve@1.20.2': + resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + + '@types/unzipper@0.10.11': + resolution: {integrity: sha512-D25im2zjyMCcgL9ag6N46+wbtJBnXIr7SI4zHf9eJD2Dw2tEB5e+p5MYkrxKIVRscs5QV0EhtU9rgXSPx90oJg==} + + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + autoprefixer@10.4.27: + resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + baseline-browser-mapping@2.10.13: + resolution: {integrity: sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==} + engines: {node: '>=6.0.0'} + hasBin: true + + bluebird@3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + caniuse-api@3.0.0: + resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} + + caniuse-lite@1.0.30001784: + resolution: {integrity: sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@3.4.0: + resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} + engines: {node: '>=18.20'} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + css-declaration-sorter@7.3.1: + resolution: {integrity: sha512-gz6x+KkgNCjxq3Var03pRYLhyNfwhkKF1g/yoLgDNtFvVu0/fOLV9C8fFEZRjACp/XQLumjAYo7JVjzH3wLbxA==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.0.9 + + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + cssnano-preset-default@7.0.12: + resolution: {integrity: sha512-B3Eoouzw/sl2zANI0AL9KbacummJTCww+fkHaDBMZad/xuVx8bUduPLly6hKVQAlrmvYkS1jB1CVQEKm3gn0AA==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + cssnano-utils@5.0.1: + resolution: {integrity: sha512-ZIP71eQgG9JwjVZsTPSqhc6GHgEr53uJ7tK5///VfyWj6Xp2DBmixWHqJgPno+PqATzn48pL42ww9x5SSGmhZg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + cssnano@7.1.4: + resolution: {integrity: sha512-T9PNS7y+5Nc9Qmu9mRONqfxG1RVY7Vuvky0XN6MZ+9hqplesTEwnj9r0ROtVuSwUVfaDhVlavuzWIVLUgm4hkQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + defu@6.1.6: + resolution: {integrity: sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + duplexer2@0.1.4: + resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + + electron-to-chromium@1.5.331: + resolution: {integrity: sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + + fast-string-truncated-width@1.2.1: + resolution: {integrity: sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==} + + fast-string-width@1.1.0: + resolution: {integrity: sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ==} + + fast-wrap-ansi@0.1.6: + resolution: {integrity: sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + fs-extra@11.3.4: + resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} + engines: {node: '>=14.14'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + + is-module@1.0.0: + resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} + + is-reference@1.2.1: + resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + + knitwork@1.3.0: + resolution: {integrity: sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + + log-symbols@7.0.1: + resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} + engines: {node: '>=18'} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + mkdist@2.4.1: + resolution: {integrity: sha512-Ezk0gi04GJBkqMfsksICU5Rjoemc4biIekwgrONWVPor2EO/N9nBgN6MZXAf7Yw4mDDhrNyKbdETaHNevfumKg==} + hasBin: true + peerDependencies: + sass: ^1.92.1 + typescript: '>=5.9.2' + vue: ^3.5.21 + vue-sfc-transformer: ^0.1.1 + vue-tsc: ^1.8.27 || ^2.0.21 || ^3.0.0 + peerDependenciesMeta: + sass: + optional: true + typescript: + optional: true + vue: + optional: true + vue-sfc-transformer: + optional: true + vue-tsc: + optional: true + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + node-releases@2.0.37: + resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + ora@9.3.0: + resolution: {integrity: sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==} + engines: {node: '>=20'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + + postcss-calc@10.1.1: + resolution: {integrity: sha512-NYEsLHh8DgG/PRH2+G9BTuUdtf9ViS+vdoQ0YA5OQdGsfN4ztiwtDWNtBl9EKeqNMFnIu8IKZ0cLxEQ5r5KVMw==} + engines: {node: ^18.12 || ^20.9 || >=22.0} + peerDependencies: + postcss: ^8.4.38 + + postcss-colormin@7.0.7: + resolution: {integrity: sha512-sBQ628lSj3VQpDquQel8Pen5mmjFPsO4pH9lDLaHB1AVkMRHtkl0pRB5DCWznc9upWsxint/kV+AveSj7W1tew==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-convert-values@7.0.9: + resolution: {integrity: sha512-l6uATQATZaCa0bckHV+r6dLXfWtUBKXxO3jK+AtxxJJtgMPD+VhhPCCx51I4/5w8U5uHV67g3w7PXj+V3wlMlg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-discard-comments@7.0.6: + resolution: {integrity: sha512-Sq+Fzj1Eg5/CPf1ERb0wS1Im5cvE2gDXCE+si4HCn1sf+jpQZxDI4DXEp8t77B/ImzDceWE2ebJQFXdqZ6GRJw==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-discard-duplicates@7.0.2: + resolution: {integrity: sha512-eTonaQvPZ/3i1ASDHOKkYwAybiM45zFIc7KXils4mQmHLqIswXD9XNOKEVxtTFnsmwYzF66u4LMgSr0abDlh5w==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-discard-empty@7.0.1: + resolution: {integrity: sha512-cFrJKZvcg/uxB6Ijr4l6qmn3pXQBna9zyrPC+sK0zjbkDUZew+6xDltSF7OeB7rAtzaaMVYSdbod+sZOCWnMOg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-discard-overridden@7.0.1: + resolution: {integrity: sha512-7c3MMjjSZ/qYrx3uc1940GSOzN1Iqjtlqe8uoSg+qdVPYyRb0TILSqqmtlSFuE4mTDECwsm397Ya7iXGzfF7lg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-merge-longhand@7.0.5: + resolution: {integrity: sha512-Kpu5v4Ys6QI59FxmxtNB/iHUVDn9Y9sYw66D6+SZoIk4QTz1prC4aYkhIESu+ieG1iylod1f8MILMs1Em3mmIw==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-merge-rules@7.0.8: + resolution: {integrity: sha512-BOR1iAM8jnr7zoQSlpeBmCsWV5Uudi/+5j7k05D0O/WP3+OFMPD86c1j/20xiuRtyt45bhxw/7hnhZNhW2mNFA==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-minify-font-values@7.0.1: + resolution: {integrity: sha512-2m1uiuJeTplll+tq4ENOQSzB8LRnSUChBv7oSyFLsJRtUgAAJGP6LLz0/8lkinTgxrmJSPOEhgY1bMXOQ4ZXhQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-minify-gradients@7.0.2: + resolution: {integrity: sha512-fVY3AB8Um7SJR5usHqTY2Ngf9qh8IRN+FFzrBP0ONJy6yYXsP7xyjK2BvSAIrpgs1cST+H91V0TXi3diHLYJtw==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-minify-params@7.0.6: + resolution: {integrity: sha512-YOn02gC68JijlaXVuKvFSCvQOhTpblkcfDre2hb/Aaa58r2BIaK4AtE/cyZf2wV7YKAG+UlP9DT+By0ry1E4VQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-minify-selectors@7.0.6: + resolution: {integrity: sha512-lIbC0jy3AAwDxEgciZlBullDiMBeBCT+fz5G8RcA9MWqh/hfUkpOI3vNDUNEZHgokaoiv0juB9Y8fGcON7rU/A==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-nested@7.0.2: + resolution: {integrity: sha512-5osppouFc0VR9/VYzYxO03VaDa3e8F23Kfd6/9qcZTUI8P58GIYlArOET2Wq0ywSl2o2PjELhYOFI4W7l5QHKw==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-normalize-charset@7.0.1: + resolution: {integrity: sha512-sn413ofhSQHlZFae//m9FTOfkmiZ+YQXsbosqOWRiVQncU2BA3daX3n0VF3cG6rGLSFVc5Di/yns0dFfh8NFgQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-normalize-display-values@7.0.1: + resolution: {integrity: sha512-E5nnB26XjSYz/mGITm6JgiDpAbVuAkzXwLzRZtts19jHDUBFxZ0BkXAehy0uimrOjYJbocby4FVswA/5noOxrQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-normalize-positions@7.0.1: + resolution: {integrity: sha512-pB/SzrIP2l50ZIYu+yQZyMNmnAcwyYb9R1fVWPRxm4zcUFCY2ign7rcntGFuMXDdd9L2pPNUgoODDk91PzRZuQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-normalize-repeat-style@7.0.1: + resolution: {integrity: sha512-NsSQJ8zj8TIDiF0ig44Byo3Jk9e4gNt9x2VIlJudnQQ5DhWAHJPF4Tr1ITwyHio2BUi/I6Iv0HRO7beHYOloYQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-normalize-string@7.0.1: + resolution: {integrity: sha512-QByrI7hAhsoze992kpbMlJSbZ8FuCEc1OT9EFbZ6HldXNpsdpZr+YXC5di3UEv0+jeZlHbZcoCADgb7a+lPmmQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-normalize-timing-functions@7.0.1: + resolution: {integrity: sha512-bHifyuuSNdKKsnNJ0s8fmfLMlvsQwYVxIoUBnowIVl2ZAdrkYQNGVB4RxjfpvkMjipqvbz0u7feBZybkl/6NJg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-normalize-unicode@7.0.6: + resolution: {integrity: sha512-z6bwTV84YW6ZvvNoaNLuzRW4/uWxDKYI1iIDrzk6D2YTL7hICApy+Q1LP6vBEsljX8FM7YSuV9qI79XESd4ddQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-normalize-url@7.0.1: + resolution: {integrity: sha512-sUcD2cWtyK1AOL/82Fwy1aIVm/wwj5SdZkgZ3QiUzSzQQofrbq15jWJ3BA7Z+yVRwamCjJgZJN0I9IS7c6tgeQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-normalize-whitespace@7.0.1: + resolution: {integrity: sha512-vsbgFHMFQrJBJKrUFJNZ2pgBeBkC2IvvoHjz1to0/0Xk7sII24T0qFOiJzG6Fu3zJoq/0yI4rKWi7WhApW+EFA==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-ordered-values@7.0.2: + resolution: {integrity: sha512-AMJjt1ECBffF7CEON/Y0rekRLS6KsePU6PRP08UqYW4UGFRnTXNrByUzYK1h8AC7UWTZdQ9O3Oq9kFIhm0SFEw==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-reduce-initial@7.0.6: + resolution: {integrity: sha512-G6ZyK68AmrPdMB6wyeA37ejnnRG2S8xinJrZJnOv+IaRKf6koPAVbQsiC7MfkmXaGmF1UO+QCijb27wfpxuRNg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-reduce-transforms@7.0.1: + resolution: {integrity: sha512-MhyEbfrm+Mlp/36hvZ9mT9DaO7dbncU0CvWI8V93LRkY6IYlu38OPg3FObnuKTUxJ4qA8HpurdQOo5CyqqO76g==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + engines: {node: '>=4'} + + postcss-svgo@7.1.1: + resolution: {integrity: sha512-zU9H9oEDrUFKa0JB7w+IYL7Qs9ey1mZyjhbf0KLxwJDdDRtoPvCmaEfknzqfHj44QS9VD6c5sJnBAVYTLRg/Sg==} + engines: {node: ^18.12.0 || ^20.9.0 || >= 18} + peerDependencies: + postcss: ^8.4.32 + + postcss-unique-selectors@7.0.5: + resolution: {integrity: sha512-3QoYmEt4qg/rUWDn6Tc8+ZVPmbp4G1hXDtCNWDx0st8SjtCbRcxRXDDM1QrEiXGG3A45zscSJFb4QH90LViyxg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + pretty-bytes@7.1.0: + resolution: {integrity: sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw==} + engines: {node: '>=20'} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + rollup-plugin-dts@6.4.1: + resolution: {integrity: sha512-l//F3Zf7ID5GoOfLfD8kroBjQKEKpy1qfhtAdnpibFZMffPaylrg1CoDC2vGkPeTeyxUe4bVFCln2EFuL7IGGg==} + engines: {node: '>=20'} + peerDependencies: + rollup: ^3.29.4 || ^4 + typescript: ^4.5 || ^5.0 || ^6.0 + + rollup@4.60.1: + resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + stdin-discarder@0.3.1: + resolution: {integrity: sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==} + engines: {node: '>=18'} + + string-width@8.2.0: + resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} + engines: {node: '>=20'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + stylehacks@7.0.8: + resolution: {integrity: sha512-I3f053GBLIiS5Fg6OMFhq/c+yW+5Hc2+1fgq7gElDMMSqwlRb3tBf2ef6ucLStYRpId4q//bQO1FjcyNyy4yDQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + svgo@4.0.1: + resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==} + engines: {node: '>=16'} + hasBin: true + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + + unbuild@3.6.1: + resolution: {integrity: sha512-+U5CdtrdjfWkZhuO4N9l5UhyiccoeMEXIc2Lbs30Haxb+tRwB3VwB8AoZRxlAzORXunenSo+j6lh45jx+xkKgg==} + hasBin: true + peerDependencies: + typescript: ^5.9.2 + peerDependenciesMeta: + typescript: + optional: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici@7.24.7: + resolution: {integrity: sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==} + engines: {node: '>=20.18.1'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + untyped@2.0.0: + resolution: {integrity: sha512-nwNCjxJTjNuLCgFr42fEak5OcLuB3ecca+9ksPFNvtfYSLpjf+iJqSIaSnIile6ZPbKYxI5k2AfXqeopGudK/g==} + hasBin: true + + unzipper@0.12.3: + resolution: {integrity: sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + +snapshots: + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + optional: true + + '@babel/helper-validator-identifier@7.28.5': + optional: true + + '@clack/core@1.2.0': + dependencies: + fast-wrap-ansi: 0.1.6 + sisteransi: 1.0.5 + + '@clack/prompts@1.2.0': + dependencies: + '@clack/core': 1.2.0 + fast-string-width: 1.1.0 + fast-wrap-ansi: 0.1.6 + sisteransi: 1.0.5 + + '@colordx/core@5.0.3': {} + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rollup/plugin-alias@5.1.1(rollup@4.60.1)': + optionalDependencies: + rollup: 4.60.1 + + '@rollup/plugin-commonjs@28.0.9(rollup@4.60.1)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.60.1) + commondir: 1.0.1 + estree-walker: 2.0.2 + fdir: 6.5.0(picomatch@4.0.4) + is-reference: 1.2.1 + magic-string: 0.30.21 + picomatch: 4.0.4 + optionalDependencies: + rollup: 4.60.1 + + '@rollup/plugin-json@6.1.0(rollup@4.60.1)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.60.1) + optionalDependencies: + rollup: 4.60.1 + + '@rollup/plugin-node-resolve@16.0.3(rollup@4.60.1)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.60.1) + '@types/resolve': 1.20.2 + deepmerge: 4.3.1 + is-module: 1.0.0 + resolve: 1.22.11 + optionalDependencies: + rollup: 4.60.1 + + '@rollup/plugin-replace@6.0.3(rollup@4.60.1)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.60.1) + magic-string: 0.30.21 + optionalDependencies: + rollup: 4.60.1 + + '@rollup/pluginutils@5.3.0(rollup@4.60.1)': + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.4 + optionalDependencies: + rollup: 4.60.1 + + '@rollup/rollup-android-arm-eabi@4.60.1': + optional: true + + '@rollup/rollup-android-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-x64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.1': + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/node@22.19.15': + dependencies: + undici-types: 6.21.0 + + '@types/resolve@1.20.2': {} + + '@types/semver@7.7.1': {} + + '@types/unzipper@0.10.11': + dependencies: + '@types/node': 22.19.15 + + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + acorn@8.16.0: {} + + ansi-regex@6.2.2: {} + + assertion-error@2.0.1: {} + + autoprefixer@10.4.27(postcss@8.5.8): + dependencies: + browserslist: 4.28.2 + caniuse-lite: 1.0.30001784 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + baseline-browser-mapping@2.10.13: {} + + bluebird@3.7.2: {} + + boolbase@1.0.0: {} + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.13 + caniuse-lite: 1.0.30001784 + electron-to-chromium: 1.5.331 + node-releases: 2.0.37 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + cac@6.7.14: {} + + caniuse-api@3.0.0: + dependencies: + browserslist: 4.28.2 + caniuse-lite: 1.0.30001784 + lodash.memoize: 4.1.2 + lodash.uniq: 4.5.0 + + caniuse-lite@1.0.30001784: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@5.6.2: {} + + check-error@2.1.3: {} + + citty@0.1.6: + dependencies: + consola: 3.4.2 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@3.4.0: {} + + commander@11.1.0: {} + + commander@14.0.3: {} + + commondir@1.0.1: {} + + confbox@0.1.8: {} + + confbox@0.2.4: {} + + consola@3.4.2: {} + + convert-source-map@2.0.0: {} + + core-util-is@1.0.3: {} + + css-declaration-sorter@7.3.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.1 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css-what@6.2.2: {} + + cssesc@3.0.0: {} + + cssnano-preset-default@7.0.12(postcss@8.5.8): + dependencies: + browserslist: 4.28.2 + css-declaration-sorter: 7.3.1(postcss@8.5.8) + cssnano-utils: 5.0.1(postcss@8.5.8) + postcss: 8.5.8 + postcss-calc: 10.1.1(postcss@8.5.8) + postcss-colormin: 7.0.7(postcss@8.5.8) + postcss-convert-values: 7.0.9(postcss@8.5.8) + postcss-discard-comments: 7.0.6(postcss@8.5.8) + postcss-discard-duplicates: 7.0.2(postcss@8.5.8) + postcss-discard-empty: 7.0.1(postcss@8.5.8) + postcss-discard-overridden: 7.0.1(postcss@8.5.8) + postcss-merge-longhand: 7.0.5(postcss@8.5.8) + postcss-merge-rules: 7.0.8(postcss@8.5.8) + postcss-minify-font-values: 7.0.1(postcss@8.5.8) + postcss-minify-gradients: 7.0.2(postcss@8.5.8) + postcss-minify-params: 7.0.6(postcss@8.5.8) + postcss-minify-selectors: 7.0.6(postcss@8.5.8) + postcss-normalize-charset: 7.0.1(postcss@8.5.8) + postcss-normalize-display-values: 7.0.1(postcss@8.5.8) + postcss-normalize-positions: 7.0.1(postcss@8.5.8) + postcss-normalize-repeat-style: 7.0.1(postcss@8.5.8) + postcss-normalize-string: 7.0.1(postcss@8.5.8) + postcss-normalize-timing-functions: 7.0.1(postcss@8.5.8) + postcss-normalize-unicode: 7.0.6(postcss@8.5.8) + postcss-normalize-url: 7.0.1(postcss@8.5.8) + postcss-normalize-whitespace: 7.0.1(postcss@8.5.8) + postcss-ordered-values: 7.0.2(postcss@8.5.8) + postcss-reduce-initial: 7.0.6(postcss@8.5.8) + postcss-reduce-transforms: 7.0.1(postcss@8.5.8) + postcss-svgo: 7.1.1(postcss@8.5.8) + postcss-unique-selectors: 7.0.5(postcss@8.5.8) + + cssnano-utils@5.0.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + + cssnano@7.1.4(postcss@8.5.8): + dependencies: + cssnano-preset-default: 7.0.12(postcss@8.5.8) + lilconfig: 3.1.3 + postcss: 8.5.8 + + csso@5.0.5: + dependencies: + css-tree: 2.2.1 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + deepmerge@4.3.1: {} + + defu@6.1.6: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + duplexer2@0.1.4: + dependencies: + readable-stream: 2.3.8 + + electron-to-chromium@1.5.331: {} + + entities@4.5.0: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + escalade@3.2.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + + exsolve@1.0.8: {} + + fast-string-truncated-width@1.2.1: {} + + fast-string-width@1.1.0: + dependencies: + fast-string-truncated-width: 1.2.1 + + fast-wrap-ansi@0.1.6: + dependencies: + fast-string-width: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.2 + rollup: 4.60.1 + + fraction.js@5.3.4: {} + + fs-extra@11.3.4: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-east-asian-width@1.5.0: {} + + graceful-fs@4.2.11: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hookable@5.5.3: {} + + inherits@2.0.4: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-interactive@2.0.0: {} + + is-module@1.0.0: {} + + is-reference@1.2.1: + dependencies: + '@types/estree': 1.0.8 + + is-unicode-supported@2.1.0: {} + + isarray@1.0.0: {} + + jiti@1.21.7: {} + + jiti@2.6.1: {} + + js-tokens@4.0.0: + optional: true + + js-tokens@9.0.1: {} + + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + knitwork@1.3.0: {} + + lilconfig@3.1.3: {} + + lodash.memoize@4.1.2: {} + + lodash.uniq@4.5.0: {} + + log-symbols@7.0.1: + dependencies: + is-unicode-supported: 2.1.0 + yoctocolors: 2.1.2 + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mdn-data@2.0.28: {} + + mdn-data@2.27.1: {} + + mimic-function@5.0.1: {} + + mkdist@2.4.1(typescript@5.9.3): + dependencies: + autoprefixer: 10.4.27(postcss@8.5.8) + citty: 0.1.6 + cssnano: 7.1.4(postcss@8.5.8) + defu: 6.1.6 + esbuild: 0.25.12 + jiti: 1.21.7 + mlly: 1.8.2 + pathe: 2.0.3 + pkg-types: 2.3.0 + postcss: 8.5.8 + postcss-nested: 7.0.2(postcss@8.5.8) + semver: 7.7.4 + tinyglobby: 0.2.15 + optionalDependencies: + typescript: 5.9.3 + + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + node-int64@0.4.0: {} + + node-releases@2.0.37: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + ora@9.3.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 3.4.0 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 7.0.1 + stdin-discarder: 0.3.1 + string-width: 8.2.0 + + path-parse@1.0.7: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + pkg-types@2.3.0: + dependencies: + confbox: 0.2.4 + exsolve: 1.0.8 + pathe: 2.0.3 + + postcss-calc@10.1.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + + postcss-colormin@7.0.7(postcss@8.5.8): + dependencies: + '@colordx/core': 5.0.3 + browserslist: 4.28.2 + caniuse-api: 3.0.0 + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + postcss-convert-values@7.0.9(postcss@8.5.8): + dependencies: + browserslist: 4.28.2 + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + postcss-discard-comments@7.0.6(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-selector-parser: 7.1.1 + + postcss-discard-duplicates@7.0.2(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + + postcss-discard-empty@7.0.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + + postcss-discard-overridden@7.0.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + + postcss-merge-longhand@7.0.5(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + stylehacks: 7.0.8(postcss@8.5.8) + + postcss-merge-rules@7.0.8(postcss@8.5.8): + dependencies: + browserslist: 4.28.2 + caniuse-api: 3.0.0 + cssnano-utils: 5.0.1(postcss@8.5.8) + postcss: 8.5.8 + postcss-selector-parser: 7.1.1 + + postcss-minify-font-values@7.0.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + postcss-minify-gradients@7.0.2(postcss@8.5.8): + dependencies: + '@colordx/core': 5.0.3 + cssnano-utils: 5.0.1(postcss@8.5.8) + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + postcss-minify-params@7.0.6(postcss@8.5.8): + dependencies: + browserslist: 4.28.2 + cssnano-utils: 5.0.1(postcss@8.5.8) + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + postcss-minify-selectors@7.0.6(postcss@8.5.8): + dependencies: + cssesc: 3.0.0 + postcss: 8.5.8 + postcss-selector-parser: 7.1.1 + + postcss-nested@7.0.2(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-selector-parser: 7.1.1 + + postcss-normalize-charset@7.0.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + + postcss-normalize-display-values@7.0.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + postcss-normalize-positions@7.0.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + postcss-normalize-repeat-style@7.0.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + postcss-normalize-string@7.0.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + postcss-normalize-timing-functions@7.0.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + postcss-normalize-unicode@7.0.6(postcss@8.5.8): + dependencies: + browserslist: 4.28.2 + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + postcss-normalize-url@7.0.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + postcss-normalize-whitespace@7.0.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + postcss-ordered-values@7.0.2(postcss@8.5.8): + dependencies: + cssnano-utils: 5.0.1(postcss@8.5.8) + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + postcss-reduce-initial@7.0.6(postcss@8.5.8): + dependencies: + browserslist: 4.28.2 + caniuse-api: 3.0.0 + postcss: 8.5.8 + + postcss-reduce-transforms@7.0.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + postcss-selector-parser@7.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-svgo@7.1.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + svgo: 4.0.1 + + postcss-unique-selectors@7.0.5(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-selector-parser: 7.1.1 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-bytes@7.1.0: {} + + process-nextick-args@2.0.1: {} + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + rollup-plugin-dts@6.4.1(rollup@4.60.1)(typescript@5.9.3): + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + convert-source-map: 2.0.0 + magic-string: 0.30.21 + rollup: 4.60.1 + typescript: 5.9.3 + optionalDependencies: + '@babel/code-frame': 7.29.0 + + rollup@4.60.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.1 + '@rollup/rollup-android-arm64': 4.60.1 + '@rollup/rollup-darwin-arm64': 4.60.1 + '@rollup/rollup-darwin-x64': 4.60.1 + '@rollup/rollup-freebsd-arm64': 4.60.1 + '@rollup/rollup-freebsd-x64': 4.60.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.1 + '@rollup/rollup-linux-arm-musleabihf': 4.60.1 + '@rollup/rollup-linux-arm64-gnu': 4.60.1 + '@rollup/rollup-linux-arm64-musl': 4.60.1 + '@rollup/rollup-linux-loong64-gnu': 4.60.1 + '@rollup/rollup-linux-loong64-musl': 4.60.1 + '@rollup/rollup-linux-ppc64-gnu': 4.60.1 + '@rollup/rollup-linux-ppc64-musl': 4.60.1 + '@rollup/rollup-linux-riscv64-gnu': 4.60.1 + '@rollup/rollup-linux-riscv64-musl': 4.60.1 + '@rollup/rollup-linux-s390x-gnu': 4.60.1 + '@rollup/rollup-linux-x64-gnu': 4.60.1 + '@rollup/rollup-linux-x64-musl': 4.60.1 + '@rollup/rollup-openbsd-x64': 4.60.1 + '@rollup/rollup-openharmony-arm64': 4.60.1 + '@rollup/rollup-win32-arm64-msvc': 4.60.1 + '@rollup/rollup-win32-ia32-msvc': 4.60.1 + '@rollup/rollup-win32-x64-gnu': 4.60.1 + '@rollup/rollup-win32-x64-msvc': 4.60.1 + fsevents: 2.3.3 + + safe-buffer@5.1.2: {} + + sax@1.6.0: {} + + scule@1.3.0: {} + + semver@7.7.4: {} + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + sisteransi@1.0.5: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + stdin-discarder@0.3.1: {} + + string-width@8.2.0: + dependencies: + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + stylehacks@7.0.8(postcss@8.5.8): + dependencies: + browserslist: 4.28.2 + postcss: 8.5.8 + postcss-selector-parser: 7.1.1 + + supports-preserve-symlinks-flag@1.0.0: {} + + svgo@4.0.1: + dependencies: + commander: 11.1.0 + css-select: 5.2.2 + css-tree: 3.2.1 + css-what: 6.2.2 + csso: 5.0.5 + picocolors: 1.1.1 + sax: 1.6.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + typescript@5.9.3: {} + + ufo@1.6.3: {} + + unbuild@3.6.1(typescript@5.9.3): + dependencies: + '@rollup/plugin-alias': 5.1.1(rollup@4.60.1) + '@rollup/plugin-commonjs': 28.0.9(rollup@4.60.1) + '@rollup/plugin-json': 6.1.0(rollup@4.60.1) + '@rollup/plugin-node-resolve': 16.0.3(rollup@4.60.1) + '@rollup/plugin-replace': 6.0.3(rollup@4.60.1) + '@rollup/pluginutils': 5.3.0(rollup@4.60.1) + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.6 + esbuild: 0.25.12 + fix-dts-default-cjs-exports: 1.0.1 + hookable: 5.5.3 + jiti: 2.6.1 + magic-string: 0.30.21 + mkdist: 2.4.1(typescript@5.9.3) + mlly: 1.8.2 + pathe: 2.0.3 + pkg-types: 2.3.0 + pretty-bytes: 7.1.0 + rollup: 4.60.1 + rollup-plugin-dts: 6.4.1(rollup@4.60.1)(typescript@5.9.3) + scule: 1.3.0 + tinyglobby: 0.2.15 + untyped: 2.0.0 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - sass + - vue + - vue-sfc-transformer + - vue-tsc + + undici-types@6.21.0: {} + + undici@7.24.7: {} + + universalify@2.0.1: {} + + untyped@2.0.0: + dependencies: + citty: 0.1.6 + defu: 6.1.6 + jiti: 2.6.1 + knitwork: 1.3.0 + scule: 1.3.0 + + unzipper@0.12.3: + dependencies: + bluebird: 3.7.2 + duplexer2: 0.1.4 + fs-extra: 11.3.4 + graceful-fs: 4.2.11 + node-int64: 0.4.0 + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + util-deprecate@1.0.2: {} + + vite-node@3.2.4(@types/node@22.19.15)(jiti@2.6.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1): + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.8 + rollup: 4.60.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.15 + fsevents: 2.3.3 + jiti: 2.6.1 + + vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1) + vite-node: 3.2.4(@types/node@22.19.15)(jiti@2.6.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.15 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + yoctocolors@2.1.2: {} diff --git a/skillhub-cli/src/cli.ts b/skillhub-cli/src/cli.ts new file mode 100644 index 000000000..13291e9cf --- /dev/null +++ b/skillhub-cli/src/cli.ts @@ -0,0 +1,242 @@ +#!/usr/bin/env node +import { Command } from "commander"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// ANSI helpers (zero-dependency) +const bold = (s: string) => `\x1b[1m${s}\x1b[0m`; +const dim = (s: string) => `\x1b[2m${s}\x1b[0m`; +const cyan = (s: string) => `\x1b[36m${s}\x1b[0m`; + +function getPackageVersion(): string { + try { + const pkgPath = resolve(__dirname, "../package.json"); + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + return pkg.version; + } catch { + return "0.1.0"; + } +} + +interface HelpEntry { + cmd: string; + desc: string; + alias?: string; +} + +function formatSection(header: string, entries: HelpEntry[]): string { + const lines = [bold(header)]; + + for (const e of entries) { + const aliasPart = e.alias ? dim(` (${e.alias})`) : ""; + lines.push(` ${cyan(e.cmd)}${aliasPart}`); + lines.push(` ${e.desc}`); + } + return lines.join("\n"); +} + +function buildTopLevelHelp(version: string): string { + const sections: string[] = []; + + sections.push(`${bold("skillhub")} ${dim(`v${version}`)}`); + sections.push(dim("CLI for SkillHub — publish, search, and manage agent skills")); + sections.push(""); + + sections.push(formatSection("Discovery", [ + { cmd: "explore", desc: "Browse or search skills from the registry", alias: "find, find-skills, search" }, + { cmd: "inspect ", desc: "View skill metadata and versions", alias: "info, view" }, + ])); + sections.push(""); + + sections.push(formatSection("Install & Manage", [ + { cmd: "install ", desc: "Install from registry, git, or local path", alias: "i" }, + { cmd: "download ", desc: "Download a skill package to local directory" }, + { cmd: "update [skill]", desc: "Update installed skills from their source", alias: "up" }, + { cmd: "uninstall [skill]", desc: "Uninstall a skill from local agent", alias: "un" }, + { cmd: "list", desc: "List installed skills", alias: "ls" }, + ])); + sections.push(""); + + sections.push(formatSection("My Profile", [ + { cmd: "me skills", desc: "List your published skills", alias: "me ls" }, + { cmd: "me stars", desc: "List your starred skills" }, + { cmd: "me namespaces", desc: "List namespaces you have access to" }, + { cmd: "me submissions", desc: "List your review submissions" }, + { cmd: "rating ", desc: "View your rating for a skill" }, + ])); + sections.push(""); + + sections.push(formatSection("Publish & Manage", [ + { cmd: "publish [path]", desc: "Publish a skill to SkillHub registry" }, + { cmd: "sync [path]", desc: "Scan and publish all skills from a directory" }, + { cmd: "delete ", desc: "Delete a skill you own", alias: "del, unpublish" }, + { cmd: "archive ", desc: "Archive a skill you own" }, + { cmd: "hide ", desc: "Hide a skill" }, + { cmd: "unhide ", desc: "Unhide a skill" }, + ])); + sections.push(""); + + sections.push(formatSection("Community", [ + { cmd: "star ", desc: "Star or unstar a skill" }, + { cmd: "rate ", desc: "Rate a skill (1-5)" }, + { cmd: "report ", desc: "Report a skill for review" }, + ])); + sections.push(""); + + sections.push(formatSection("Notifications & Admin", [ + { cmd: "notifications", desc: "Manage notifications", alias: "notif" }, + { cmd: "transfer ", desc: "Transfer namespace ownership" }, + ])); + sections.push(""); + + sections.push(formatSection("Configuration", [ + { cmd: "config list", desc: "Show current registry configuration" }, + { cmd: "config set ", desc: "Set registry URL" }, + { cmd: "config get", desc: "Get current registry configuration" }, + { cmd: "login", desc: "Authenticate with SkillHub registry" }, + { cmd: "logout", desc: "Remove stored authentication token" }, + { cmd: "whoami", desc: "Show current authenticated user" }, + ])); + sections.push(""); + + sections.push(bold("Examples")); + sections.push(dim(" skillhub install vision2group/fork-workflow Install a skill from registry")); + sections.push(dim(" skillhub install find-skills --from https://... Install from GitHub or local path")); + sections.push(dim(" skillhub explore Interactive skill search")); + sections.push(dim(" skillhub explore --hot Browse popular skills")); + sections.push(dim(" skillhub config list Show current configuration")); + sections.push(dim(" skillhub --registry explore One-time registry override")); + sections.push(dim(" skillhub publish Publish current directory")); + sections.push(dim(" skillhub me skills List your published skills")); + sections.push(dim(" skillhub update Update installed skills")); + sections.push(""); + sections.push(dim("Run 'skillhub --help' for command-specific options.")); + sections.push(""); + + sections.push(bold("Global Options")); + sections.push(` ${cyan("--registry ")} Registry API base URL`); + sections.push(` ${cyan("--json")} Output results as JSON`); + sections.push(` ${cyan("--help")} Show help for a command`); + sections.push(` ${cyan("--version")} Show version number`); + + return sections.join("\n"); +} + +export async function createCli(): Promise { + const program = new Command(); + const version = getPackageVersion(); + + program + .name("skillhub") + .description("CLI for SkillHub — publish, search, and manage agent skills") + .version(version) + .option("--registry ", "Registry API base URL") + .option("--json", "Output results as JSON") + .option("--debug", "Show debug information for API requests"); + + const customHelp = buildTopLevelHelp(version); + const originalHelpInformation = program.helpInformation.bind(program); + program.helpInformation = () => { + if (program.parent) return originalHelpInformation(); + return customHelp; + }; + + const [ + { registerLogin }, + { registerLogout }, + { registerWhoami }, + { registerPublish }, + { registerInstall }, + { registerDownload }, + { registerList }, + { registerStar }, + { registerInit }, + { registerMe }, + { registerReviews }, + { registerNotifications }, + { registerDelete }, + { registerReport }, + { registerResolve }, + { registerRating, registerRate }, + { registerArchive }, + { registerUpdate }, + { registerCheck }, + { registerUninstall }, + { registerSync }, + { registerInspect }, + { registerExplore }, + { registerTransfer }, + { registerHide, registerUnhide }, + { registerConfig }, + ] = await Promise.all([ + import("./commands/login.js"), + import("./commands/logout.js"), + import("./commands/whoami.js"), + import("./commands/publish.js"), + import("./commands/install.js"), + import("./commands/download.js"), + import("./commands/list.js"), + import("./commands/star.js"), + import("./commands/init.js"), + import("./commands/me.js"), + import("./commands/reviews.js"), + import("./commands/notifications.js"), + import("./commands/delete.js"), + import("./commands/report.js"), + import("./commands/resolve.js"), + import("./commands/rating.js"), + import("./commands/archive.js"), + import("./commands/update.js"), + import("./commands/check.js"), + import("./commands/uninstall.js"), + import("./commands/sync.js"), + import("./commands/inspect.js"), + import("./commands/explore.js"), + import("./commands/transfer.js"), + import("./commands/hide.js"), + import("./commands/config.js"), + ]); + + registerLogin(program); + registerLogout(program); + registerWhoami(program); + registerPublish(program); + registerInstall(program); + registerDownload(program); + registerList(program); + registerStar(program); + registerInit(program); + registerMe(program); + registerReviews(program); + registerNotifications(program); + registerDelete(program); + registerReport(program); + registerResolve(program); + registerRating(program); + registerRate(program); + registerArchive(program); + registerUpdate(program); + registerCheck(program); + registerUninstall(program); + registerSync(program); + registerInspect(program); + registerExplore(program); + registerTransfer(program); + registerHide(program); + registerUnhide(program); + registerConfig(program); + + return program; +} + +export async function main() { + const program = await createCli(); + program.parse(); +} + +main(); diff --git a/skillhub-cli/src/commands/archive.ts b/skillhub-cli/src/commands/archive.ts new file mode 100644 index 000000000..777cabe10 --- /dev/null +++ b/skillhub-cli/src/commands/archive.ts @@ -0,0 +1,48 @@ +import { Command } from "commander"; +import { ApiClient } from "../core/api-client.js"; +import { requireToken } from "../core/auth-token.js"; +import { loadConfig, loadConfigFromProgram } from "../core/config.js"; +import { success, error } from "../utils/logger.js"; +export function registerArchive(program: Command) { + program + .command("archive") + .description("Archive a skill you own") + .argument("", "Skill name or namespace/skill-name") + .option("-y, --yes", "Skip confirmation") + .option("--namespace ", "Override namespace (default: parsed from skill or 'global')") + .action(async (slug: string, opts: { yes?: boolean; namespace?: string }) => { + const { parseSkillNamespace } = await import("../core/skill-resolver.js"); + const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace); + if (!opts.yes) { + const { createInterface } = await import("node:readline"); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((r) => + rl.question(`Archive ${skillSlug} from ${namespace}? [y/N] `, r) + ); + rl.close(); + if (answer.toLowerCase() !== "y") { + console.log("Cancelled."); + return; + } + } + + try { + const token = await requireToken(); + const config = loadConfigFromProgram(program); + const client = new ApiClient({ baseUrl: config.registry, token }); + await client.post(`/api/v1/skills/${namespace}/${skillSlug}/archive`); + success(`Archived ${skillSlug}`); + } catch (e: any) { + const status = e.status || e.statusCode; + if (status === 404) { + error(`Skill not found: ${namespace}/${skillSlug}`); + if (!slug.includes("/")) { + dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push"); + } + } else { + error(`Failed: ${e.message}`); + } + process.exitCode = 1; + } + }); +} diff --git a/skillhub-cli/src/commands/check.ts b/skillhub-cli/src/commands/check.ts new file mode 100644 index 000000000..3f8294f79 --- /dev/null +++ b/skillhub-cli/src/commands/check.ts @@ -0,0 +1,24 @@ +import { Command } from "commander"; +import { listAction } from "./list.js"; + +export function registerCheck(program: Command) { + program + .command("check") + .description("Check installed skills (alias for 'list --status managed,missing')") + .option("--scope ", "Scope to check (global, project, all)") + .option("--agent ", "Filter by specific agents") + .option("--json", "Output results as JSON") + .action(async (opts: { + scope?: string; + agent?: string[]; + json?: boolean; + }) => { + console.log("Tip: Use 'skillhub list' for more options including orphaned skills"); + console.log(""); + + await listAction({ + ...opts, + status: ["managed", "missing"], + }); + }); +} diff --git a/skillhub-cli/src/commands/config.ts b/skillhub-cli/src/commands/config.ts new file mode 100644 index 000000000..ad75af81b --- /dev/null +++ b/skillhub-cli/src/commands/config.ts @@ -0,0 +1,193 @@ +import { Command } from "commander"; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { success, error, info, dim } from "../utils/logger.js"; +import chalk from "chalk"; + +const CONFIG_DIR = join(homedir(), ".skillhub"); +const CONFIG_FILE = join(CONFIG_DIR, "config.json"); + +const cyan = (s: string) => `\x1b[36m${s}\x1b[0m`; +const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`; +const green = (s: string) => `\x1b[32m${s}\x1b[0m`; + +export function registerConfig(program: Command) { + const configCmd = program + .command("config") + .description("Manage SkillHub CLI configuration") + .addHelpCommand(false); + + configCmd + .command("list") + .description("Show all config values and their sources") + .action(() => { + const env = process.env.SKILLHUB_REGISTRY; + let fileConfig: { registry?: string } = {}; + if (existsSync(CONFIG_FILE)) { + try { + fileConfig = JSON.parse(readFileSync(CONFIG_FILE, "utf-8")); + } catch { + // Invalid config file, ignore + } + } + + info("Current configuration:\n"); + + info(` ${cyan("Environment")}`); + info(` SKILLHUB_REGISTRY: ${env || dim("not set")}`); + + info(`\n ${cyan("Config file")}`); + info(` ~/.skillhub/config.json: ${fileConfig.registry || dim("not set")}`); + + info(`\n ${cyan("Default")}`); + info(` http://localhost:8080\n`); + + const active = env || fileConfig.registry || "http://localhost:8080"; + const source = env + ? green("environment variable") + : fileConfig.registry + ? yellow("config file") + : dim("default"); + + success(`Active registry: ${active}`); + info(`Source: ${source}`); + }); + + configCmd + .command("set ") + .description("Set registry URL (e.g., https://api.example.com)") + .action((value: string) => { + if (!existsSync(CONFIG_DIR)) { + mkdirSync(CONFIG_DIR, { recursive: true }); + } + + let config: Record = {}; + if (existsSync(CONFIG_FILE)) { + try { + config = JSON.parse(readFileSync(CONFIG_FILE, "utf-8")); + } catch { + // Invalid config file, start fresh + } + } + + config.registry = value; + writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); + success(`Registry set to: ${value}`); + info(`Config file: ${CONFIG_FILE}`); + }); + + configCmd + .command("get") + .description("Show the current registry URL") + .option("--source ", "Source: env, file, or resolved (default)") + .action((opts: { source?: string }) => { + const source = opts.source || "resolved"; + const envValue = process.env.SKILLHUB_REGISTRY; + const fileValue = existsSync(CONFIG_FILE) + ? (() => { + try { + return JSON.parse(readFileSync(CONFIG_FILE, "utf-8")).registry; + } catch { + return null; + } + })() + : null; + + if (source === "env") { + if (envValue) { + success(envValue); + dim("Source: environment variable"); + } else { + dim("Environment variable SKILLHUB_REGISTRY is not set"); + process.exitCode = 1; + } + } else if (source === "file") { + if (fileValue) { + success(fileValue); + dim("Source: config file"); + } else { + dim("Config file does not have registry set"); + process.exitCode = 1; + } + } else if (source === "resolved") { + const defaultValue = "http://localhost:8080"; + const value = envValue || fileValue || defaultValue; + const actualSource = envValue + ? "environment variable" + : fileValue + ? "config file" + : "default"; + + success(value); + dim(`Source: ${actualSource}`); + if (!envValue) { + dim(`To override with env var: export SKILLHUB_REGISTRY="${value}"`); + } + } else { + error(`Unknown source: ${source}. Supported sources: env, file, resolved`); + process.exitCode = 1; + } + }); + + configCmd + .command("show-env-instructions") + .description("Show how to set SKILLHUB_REGISTRY environment variable") + .action(() => { + const lines: string[] = []; + + lines.push(yellow("Environment variable setup for SKILLHUB_REGISTRY:")); + lines.push(""); + + lines.push(cyan("🔹 Temporary (current session only):")); + lines.push(""); + + lines.push(` ${green("Linux/macOS:")}`); + lines.push(` ${cyan('export SKILLHUB_REGISTRY="http://:"')}`); + lines.push(` ${chalk.dim("# Example: export SKILLHUB_REGISTRY=\"http://192.168.1.100:8080\"")}`); + lines.push(""); + + lines.push(` ${green("Windows CMD:")}`); + lines.push(` ${cyan("set SKILLHUB_REGISTRY=http://:")}`); + lines.push(` ${chalk.dim("# Example: set SKILLHUB_REGISTRY=http://192.168.1.100:8080")}`); + lines.push(""); + + lines.push(` ${green("Windows PowerShell:")}`); + lines.push(` ${cyan('$env:SKILLHUB_REGISTRY="http://:"')}`); + lines.push(` ${chalk.dim("# Example: $env:SKILLHUB_REGISTRY='http://192.168.1.100:8080'")}`); + lines.push(""); + + lines.push(cyan("🔹 Permanent (survives terminal restart):")); + lines.push(""); + + lines.push(` ${green("Linux/macOS (~/.bashrc or ~/.zshrc):")}`); + lines.push(` ${cyan('echo \'export SKILLHUB_REGISTRY="http://:"\' >> ~/.bashrc')}`); + lines.push(` ${cyan("source ~/.bashrc")}`); + lines.push(` ${chalk.dim("# Add to ~/.bashrc for bash, ~/.zshrc for zsh")}`); + lines.push(""); + + lines.push(` ${green("Windows (User environment variable):")}`); + lines.push(` ${cyan('setx SKILLHUB_REGISTRY "http://:"')}`); + lines.push(` ${chalk.dim("# Restart terminal after running this command")}`); + lines.push(""); + + lines.push(` ${green("PowerShell (User profile):")}`); + lines.push(` ${cyan("[System.Environment]::SetEnvironmentVariable('SKILLHUB_REGISTRY', 'http://:', 'User')")}`); + lines.push(` ${chalk.dim("# Restart PowerShell after running this command")}`); + lines.push(""); + + lines.push(cyan("📋 Configuration priority (highest to lowest):")); + lines.push(` 1. ${green("--registry flag")} (one-time, per command)`); + lines.push(` 2. ${green("SKILLHUB_REGISTRY")} (environment variable)`); + lines.push(` 3. ${green("~/.skillhub/config.json")} (config file)`); + lines.push(` 4. ${chalk.dim("http://localhost:8080")} (default)`); + lines.push(""); + + lines.push(cyan("💡 Quick examples:")); + lines.push(` skillhub config set http://192.168.1.100:8080`); + lines.push(` skillhub --registry http://192.168.1.100:8080 explore`); + lines.push(` skillhub config list`); + + console.log(lines.join("\n")); + }); +} diff --git a/skillhub-cli/src/commands/delete.ts b/skillhub-cli/src/commands/delete.ts new file mode 100644 index 000000000..42b6fb1eb --- /dev/null +++ b/skillhub-cli/src/commands/delete.ts @@ -0,0 +1,49 @@ +import { Command } from "commander"; +import { ApiClient } from "../core/api-client.js"; +import { requireToken } from "../core/auth-token.js"; +import { loadConfig, loadConfigFromProgram } from "../core/config.js"; +import { success, error } from "../utils/logger.js"; +export function registerDelete(program: Command) { + program + .command("delete") + .aliases(["del", "unpublish"]) + .description("Delete a skill you own") + .argument("", "Skill name or namespace/skill-name") + .option("-y, --yes", "Skip confirmation") + .option("--namespace ", "Override namespace (default: parsed from skill or 'global')") + .action(async (slug: string, opts: { yes?: boolean; namespace?: string }) => { + const { parseSkillNamespace } = await import("../core/skill-resolver.js"); + const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace); + if (!opts.yes) { + const { createInterface } = await import("node:readline"); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((r) => + rl.question(`Delete ${skillSlug} from ${namespace}? This cannot be undone. [y/N] `, r) + ); + rl.close(); + if (answer.toLowerCase() !== "y") { + console.log("Cancelled."); + return; + } + } + + try { + const token = await requireToken(); + const config = loadConfigFromProgram(program); + const client = new ApiClient({ baseUrl: config.registry, token, debug: program.opts().debug }); + await client.delete(`/api/v1/skills/${namespace}/${skillSlug}`); + success(`Deleted ${skillSlug} from ${namespace}`); + } catch (e: any) { + const status = e.status || e.statusCode; + if (status === 404) { + error(`Skill not found: ${namespace}/${skillSlug}`); + if (!slug.includes("/")) { + dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push"); + } + } else { + error(`Failed: ${e.message}`); + } + process.exitCode = 1; + } + }); +} diff --git a/skillhub-cli/src/commands/download.ts b/skillhub-cli/src/commands/download.ts new file mode 100644 index 000000000..db0d1e0e3 --- /dev/null +++ b/skillhub-cli/src/commands/download.ts @@ -0,0 +1,210 @@ +import { Command } from "commander"; +import { createWriteStream, mkdirSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { finished } from "node:stream/promises"; +import { ApiClient } from "../core/api-client.js"; +import { loadConfigFromProgram } from "../core/config.js"; +import { readToken } from "../core/auth-token.js"; +import { success, error } from "../utils/logger.js"; +import * as p from "@clack/prompts"; + +import ora from "ora"; + +function buildDownloadHelp(cmd: Command): string { + const lines: string[] = []; + const BOLD = "\x1b[1m"; + const RESET = "\x1b[0m"; + const CYAN = "\x1b[36m"; + const DIM = "\x1b[38;5;102m"; + + lines.push(`${BOLD}Usage:${RESET} skillhub download [options] `); + lines.push(""); + lines.push("Download a skill package as a .zip file"); + lines.push(""); + + lines.push(`${BOLD}Arguments:${RESET}`); + lines.push(` ${CYAN}skill${RESET} Skill name or namespace/skill-name`); + lines.push(""); + + lines.push(`${BOLD}Options:${RESET}`); + lines.push(` ${CYAN}-v, --skill-version ${RESET} Specific version to download`); + lines.push(` ${CYAN}--tag ${RESET} Tag to download (default: "latest")`); + lines.push(` ${CYAN}--output ${RESET} Output directory (default: current directory)`); + lines.push(` ${CYAN}--namespace ${RESET} Override namespace (default: parsed from skill or 'global')`); + lines.push(` ${CYAN}-h, --help${RESET} Display help for command`); + lines.push(""); + + lines.push(`${BOLD}Examples:${RESET}`); + lines.push(`${DIM} skillhub download docker-build-push${RESET}`); + lines.push(`${DIM} skillhub download vision2group/docker-build-push${RESET}`); + lines.push(`${DIM} skillhub download docker-build-push --output ./skills${RESET}`); + lines.push(`${DIM} skillhub download docker-build-push --skill-version 1.0.0${RESET}`); + lines.push(`${DIM} skillhub download docker-build-push --tag v1.0.0${RESET}`); + + return lines.join("\n"); +} + +export function registerDownload(program: Command) { + const downloadCmd = program + .command("download") + .description("Download a skill package as a .zip file") + .argument("", "Skill name or namespace/skill-name") + .option("-v, --skill-version ", "Specific version") + .option("--tag ", "Tag to download", "latest") + .option("--output ", "Output directory") + .option("--namespace ", "Override namespace (default: parsed from skill or 'global')"); + + downloadCmd.helpInformation = () => buildDownloadHelp(downloadCmd); + + downloadCmd.action(async (slug: string, opts: Record) => { + const { resolveSkillNamespace, parseSkillNamespace } = await import("../core/skill-resolver.js"); + const config = loadConfigFromProgram(program); + const token = await readToken(); + const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); + + const outputDir = opts.output ? resolve(process.cwd(), opts.output) : process.cwd(); + + try { + let namespace: string; + let skillSlug: string; + + if (opts.namespace || slug.includes("/")) { + const parsed = parseSkillNamespace(slug, opts.namespace); + namespace = parsed.namespace; + skillSlug = parsed.slug; + } else { + const spinner = ora(`Searching for ${slug}`).start(); + try { + const resolved = await resolveSkillNamespace(client, slug); + namespace = resolved.namespace; + skillSlug = resolved.slug; + spinner.succeed(`Found ${namespace}/${skillSlug}`); + } catch (e: any) { + spinner.fail(e.message); + process.exitCode = 1; + return; + } + } + + const spinner = ora(`Downloading ${skillSlug} from ${namespace}`).start(); + + let selectedVersion: string; + if (opts.skillVersion) { + selectedVersion = opts.skillVersion; + } else if (opts.tag && opts.tag !== "latest") { + spinner.text = `Resolving tag ${opts.tag}`; + try { + const tagsResp = await client.get>( + `/api/v1/skills/${namespace}/${skillSlug}/tags` + ); + const tags = tagsResp || []; + const matchedTag = tags.find((t) => t.tagName === opts.tag); + if (matchedTag) { + selectedVersion = matchedTag.version; + } else { + spinner.fail(`Tag not found: ${opts.tag}`); + process.exitCode = 1; + return; + } + } catch (e: any) { + spinner.fail(`Failed to fetch tags: ${e.message}`); + process.exitCode = 1; + return; + } + } else { + spinner.stop(); + try { + const versionsResp = await client.get<{ items: Array<{ version: string; publishedAt: string }> }>( + `/api/v1/skills/${namespace}/${skillSlug}/versions` + ); + const versions = versionsResp.items || []; + if (versions.length === 0) { + error(`No versions found for ${namespace}/${skillSlug}`); + process.exitCode = 1; + return; + } + if (versions.length === 1) { + selectedVersion = versions[0].version; + } else { + const picked = await p.select({ + message: "Select version to download", + options: versions.map((v) => ({ + value: v.version, + label: `v${v.version}`, + hint: new Date(v.publishedAt).toLocaleDateString(), + })), + }); + if (p.isCancel(picked)) { + console.log("Cancelled."); + return; + } + selectedVersion = picked as string; + } + spinner.start(`Downloading ${skillSlug}@${selectedVersion}`); + } catch (e: any) { + error(`Failed to fetch versions: ${e.message}`); + process.exitCode = 1; + return; + } + } + + const downloadUrl = `${config.registry.replace(/\/$/, "")}/api/v1/skills/${namespace}/${skillSlug}/versions/${selectedVersion}/download`; + + const { request } = await import("undici"); + const url = new URL(downloadUrl, config.registry); + let response = await request(url.toString(), { + method: "GET", + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (response.statusCode === 302 || response.statusCode === 307 || response.statusCode === 308) { + const location = response.headers.location; + if (!location) { + spinner.fail(`Redirect response has no Location header`); + process.exitCode = 1; + return; + } + response = await request(location as string, { method: "GET" }); + } + const { statusCode, body } = response; + + if (statusCode >= 400) { + spinner.fail(`Download failed: HTTP ${statusCode}`); + if (statusCode === 503) { + error("\n💡 Service Unavailable (503). This could mean:"); + error(" - The server is temporarily overloaded or under maintenance"); + error(" - The storage service is unavailable"); + error(" - Network connectivity issues"); + error("\nSuggestions:"); + error(" - Wait a moment and try again"); + error(" - Check your internet connection"); + error(" - Contact your administrator if the problem persists"); + } else if (statusCode === 404) { + error(`\n💡 Skill version not found: ${namespace}/${skillSlug}@${selectedVersion}`); + error(" - Try listing available versions: skillhub inspect " + slug); + } else if (statusCode === 403) { + error("\n💡 Access denied. You may not have permission to download this skill."); + error(" - Try: skillhub login"); + } + process.exitCode = 1; + return; + } + + const outPath = resolve(outputDir, `${skillSlug}.zip`); + const outDir = dirname(outPath); + try { + mkdirSync(outDir, { recursive: true }); + } catch (e: any) { + spinner.fail(`Failed to create output directory: ${outDir}`); + process.exitCode = 1; + return; + } + const fileStream = createWriteStream(outPath); + await finished(body.pipe(fileStream)); + + spinner.succeed(`Downloaded ${skillSlug} to ${outPath}`); + } catch (e: any) { + error(`Download failed: ${e.message}`); + process.exitCode = 1; + } + }); +} diff --git a/skillhub-cli/src/commands/explore.ts b/skillhub-cli/src/commands/explore.ts new file mode 100644 index 000000000..46776ccc3 --- /dev/null +++ b/skillhub-cli/src/commands/explore.ts @@ -0,0 +1,354 @@ +import { Command } from "commander"; +import { ApiClient } from "../core/api-client.js"; +import { loadConfig, loadConfigFromProgram } from "../core/config.js"; +import { readToken } from "../core/auth-token.js"; +import { ApiRoutes } from "../schema/routes.js"; +import { info, dim } from "../utils/logger.js"; +import * as readline from "readline"; +import { searchSkills, type SearchSkill } from "../core/interactive-search.js"; + +const HIDE_CURSOR = "\x1b[?25l"; +const SHOW_CURSOR = "\x1b[?25h"; +const CLEAR_DOWN = "\x1b[J"; +const MOVE_UP = (n: number) => `\x1b[${n}A`; +const MOVE_TO_COL = (n: number) => `\x1b[${n}G`; + +const RESET = "\x1b[0m"; +const BOLD = "\x1b[1m"; +const DIM = "\x1b[38;5;102m"; +const TEXT = "\x1b[38;5;145m"; +const CYAN = "\x1b[36m"; +const YELLOW = "\x1b[33m"; +const GREEN = "\x1b[32m"; + +function formatInstalls(count: number): string { + if (!count || count <= 0) return ""; + if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, "")}M installs`; + if (count >= 1_000) return `${(count / 1_000).toFixed(1).replace(/\.0$/, "")}K installs`; + return `${count} install${count === 1 ? "" : "s"}`; +} + +interface SkillDetail { + starCount: number; + downloadCount: number; + version: string; + ratingAvg?: number; +} + +async function fetchSkillDetail(client: ApiClient, namespace: string, name: string): Promise { + try { + const detail = await client.get( + `${ApiRoutes.skillDetail.replace("{namespace}", namespace).replace("{slug}", name)}` + ); + return detail; + } catch { + return null; + } +} + +async function runInteractiveSearch( + client: ApiClient, + initialQuery: string = "", + sort: string = "newest" +): Promise { + const MAX_VISIBLE = 8; + let query = initialQuery; + let results: SearchSkill[] = []; + let selectedIndex = 0; + let loading = false; + let lastRenderedLines = 0; + let debounceTimer: ReturnType | null = null; + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const width = process.stdout.columns || 80; + + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + process.stdin.resume(); + process.stdout.write(HIDE_CURSOR); + + function render(): void { + if (lastRenderedLines > 0) { + process.stdout.write(MOVE_UP(lastRenderedLines) + MOVE_TO_COL(1)); + } + process.stdout.write(CLEAR_DOWN); + + const lines: string[] = []; + + const cursor = `${BOLD}_${RESET}`; + const searchLine = `${TEXT}Search skills:${RESET} ${query}${cursor}`; + lines.push(searchLine); + lines.push(""); + + if (!query || query.length < 2) { + lines.push(`${DIM}Start typing to search (min 2 chars)${RESET}`); + } else if (results.length === 0 && loading) { + lines.push(`${DIM}Searching...${RESET}`); + } else if (results.length === 0) { + lines.push(`${DIM}No skills found${RESET}`); + } else { + const visible = results.slice(0, MAX_VISIBLE); + for (let i = 0; i < visible.length; i++) { + const skill = visible[i]!; + const isSelected = i === selectedIndex; + const arrow = isSelected ? `${BOLD}>${RESET}` : " "; + const name = isSelected ? `${BOLD}${skill.name}${RESET}` : `${TEXT}${skill.name}${RESET}`; + const nsBadge = skill.namespace !== "global" ? ` ${YELLOW}[${skill.namespace}]${RESET}` : ""; + const versionBadge = skill.version ? ` ${DIM}v${skill.version}${RESET}` : ""; + const loadingIndicator = loading && i === 0 ? ` ${DIM}...${RESET}` : ""; + + lines.push(` ${arrow} ${name}${nsBadge}${versionBadge}${loadingIndicator}`); + } + } + + lines.push(""); + lines.push(`${DIM}up/down navigate | enter select | esc cancel${RESET}`); + + for (const line of lines) { + process.stdout.write(line + "\n"); + } + + lastRenderedLines = lines.length; + } + + function triggerSearch(q: string): void { + if (debounceTimer) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + + loading = false; + + if (!q || q.length < 2) { + results = []; + selectedIndex = 0; + render(); + return; + } + + loading = true; + render(); + + const debounceMs = Math.max(150, 350 - q.length * 50); + + debounceTimer = setTimeout(async () => { + try { + results = await searchSkills(client, q, 10, sort); + selectedIndex = 0; + } catch { + results = []; + } finally { + loading = false; + debounceTimer = null; + render(); + } + }, debounceMs); + } + + if (initialQuery) { + triggerSearch(initialQuery); + } + render(); + + return new Promise((resolve) => { + function cleanup(): void { + process.stdin.removeListener("keypress", handleKeypress); + if (process.stdin.isTTY) { + process.stdin.setRawMode(false); + } + process.stdout.write(SHOW_CURSOR); + process.stdin.pause(); + rl.close(); + } + + function handleKeypress(_ch: string | undefined, key: readline.Key): void { + if (!key) return; + + if (key.name === "escape" || (key.ctrl && key.name === "c")) { + cleanup(); + resolve(null); + return; + } + + if (key.name === "return") { + cleanup(); + resolve(results[selectedIndex] ? `${results[selectedIndex].namespace}/${results[selectedIndex].name}` : null); + return; + } + + if (key.name === "up") { + selectedIndex = Math.max(0, selectedIndex - 1); + render(); + return; + } + + if (key.name === "down") { + selectedIndex = Math.min(Math.max(0, results.length - 1), selectedIndex + 1); + render(); + return; + } + + if (key.name === "backspace") { + if (query.length > 0) { + query = query.slice(0, -1); + triggerSearch(query); + } + return; + } + + if (key.sequence && !key.ctrl && !key.meta && key.sequence.length === 1) { + const char = key.sequence; + if (char >= " " && char <= "~") { + query += char; + triggerSearch(query); + } + } + } + + process.stdin.on("keypress", handleKeypress); + }); +} + +function buildExploreHelp(cmd: Command): string { + const lines: string[] = []; + + lines.push(`${BOLD}Usage:${RESET} skillhub explore [options] [query]`); + lines.push(""); + lines.push("Browse or search skills from the registry"); + lines.push(""); + + lines.push(`${BOLD}Arguments:${RESET}`); + lines.push(` ${CYAN}[query]${RESET} Search query for finding skills`); + lines.push(""); + + lines.push(`${BOLD}Search Options:${RESET}`); + lines.push(` ${CYAN}-n, --limit ${RESET} Max results (default: "20")`); + lines.push(""); + + lines.push(`${BOLD}Sorting Options:${RESET}`); + lines.push(` ${CYAN}-s, --sort ${RESET} Sort by: hot, newest, downloads, stars, rating`); + lines.push(` ${CYAN}--hot${RESET} Sort by comprehensive popularity (downloads + stars)`); + lines.push(` ${CYAN}--newest${RESET} Sort by newest first`); + lines.push(` ${CYAN}--downloads${RESET} Sort by download count`); + lines.push(` ${CYAN}--stars${RESET} Sort by star count`); + lines.push(` ${CYAN}--rating${RESET} Sort by average rating`); + lines.push(""); + + lines.push(`${BOLD}Other Options:${RESET}`); + lines.push(` ${CYAN}-h, --help${RESET} Display help for command`); + lines.push(""); + + lines.push(`${BOLD}Examples:${RESET}`); + lines.push(`${DIM} skillhub explore Interactive skill search${RESET}`); + lines.push(`${DIM} skillhub explore --hot Browse popular skills${RESET}`); + lines.push(`${DIM} skillhub explore ai-assistant Search for skills${RESET}`); + lines.push(`${DIM} skillhub explore --sort newest --limit 10 Show 10 newest skills${RESET}`); + + return lines.join("\n"); +} + +export function registerExplore(program: Command) { + const exploreCmd = program + .command("explore") + .aliases(["find", "find-skills", "search"]) + .description("Browse or search skills from the registry") + .argument("[query]", "Search query for finding skills") + .option("-n, --limit ", "Max results", "20") + .option("-s, --sort ", "Sort by: hot, newest, downloads, stars, rating (browse mode)") + .option("--hot", "Sort by comprehensive popularity (downloads + stars)") + .option("--newest", "Sort by newest first (shorthand for --sort newest)") + .option("--downloads", "Sort by download count (shorthand for --sort downloads)") + .option("--stars", "Sort by star count (shorthand for --sort stars)") + .option("--rating", "Sort by average rating (shorthand for --sort rating)"); + + exploreCmd.helpInformation = () => buildExploreHelp(exploreCmd); + + exploreCmd.action(async (query: string | undefined, opts: { limit: string; sort?: string; hot?: boolean; newest?: boolean; downloads?: boolean; stars?: boolean; rating?: boolean }) => { + const config = loadConfigFromProgram(program); + const token = await readToken(); + const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); + + // Backend-supported sorts: newest, downloads, rating + // Client-side sorts: hot, stars (fetch with newest, then re-sort) + const backendSortMap: Record = { + newest: "newest", + downloads: "downloads", + rating: "rating" + }; + + // Resolve sort priority: explicit --sort > shorthand flags > default + let effectiveSort = opts.sort; + if (!effectiveSort) { + if (opts.hot) effectiveSort = "hot"; + else if (opts.newest) effectiveSort = "newest"; + else if (opts.downloads) effectiveSort = "downloads"; + else if (opts.stars) effectiveSort = "stars"; + else if (opts.rating) effectiveSort = "rating"; + } + + // Determine API sort and client-side re-sort + const isClientSort = effectiveSort === "hot" || effectiveSort === "stars"; + const apiSort = isClientSort ? "newest" : (backendSortMap[effectiveSort || "newest"] || "newest"); + const clientSort = isClientSort ? effectiveSort : undefined; + + try { + // Enter interactive mode only if no query AND no sort option (explicit or shorthand) + const hasSortOption = opts.sort || opts.hot || opts.newest || opts.downloads || opts.stars || opts.rating; + if (!query && !hasSortOption) { + const selected = await runInteractiveSearch(client, "", apiSort); + if (!selected) { + console.log("\nCancelled."); + return; + } + info(`\nSelected: ${selected}`); + dim("Run: skillhub install " + selected + " for installation"); + dim("Run: skillhub inspect " + selected + " for details"); + return; + } + + const results = await searchSkills(client, query || "", parseInt(opts.limit, 10), apiSort, clientSort); + + if (results.length === 0) { + console.log(`${DIM}No skills found${RESET}`); + return; + } + + const maxResults = Math.min(results.length, parseInt(opts.limit, 10)); + + const detailPromises = results.slice(0, maxResults).map((s) => + fetchSkillDetail(client, s.namespace, s.name) + ); + const details = await Promise.all(detailPromises); + + console.log(`${DIM}Install with${RESET} skillhub install `); + console.log(); + + for (let i = 0; i < maxResults; i++) { + const skill = results[i]!; + const detail = details[i]; + const slug = `${skill.namespace}--${skill.name}`; + const nsBadge = skill.namespace !== "global" ? ` ${YELLOW}[${skill.namespace}]${RESET}` : ""; + const stars = detail?.starCount ? ` ${YELLOW}⭐ ${detail.starCount}${RESET}` : ""; + const downloads = detail?.downloadCount ? ` ${CYAN}↓ ${formatInstalls(detail.downloadCount)}${RESET}` : ""; + const rating = detail?.ratingAvg ? ` ${GREEN}★ ${detail.ratingAvg.toFixed(1)}${RESET}` : ""; + + console.log(`${TEXT}${skill.name}${RESET}${nsBadge}${stars}${downloads}${rating}`); + console.log(`${DIM}└ skillhub install ${skill.namespace}/${skill.name}${RESET}`); + if (skill.summary) { + console.log(`${DIM} ${skill.summary.slice(0, 60)}${RESET}`); + } + console.log(); + } + + dim("Tip: Use skillhub explore without args for interactive mode"); + console.log(""); + } catch (e: any) { + console.log(`Error: ${e.message}`); + } + }); +} diff --git a/skillhub-cli/src/commands/hide.ts b/skillhub-cli/src/commands/hide.ts new file mode 100644 index 000000000..bbca13c02 --- /dev/null +++ b/skillhub-cli/src/commands/hide.ts @@ -0,0 +1,80 @@ +import { Command } from "commander"; +import { ApiClient } from "../core/api-client.js"; +import { requireToken } from "../core/auth-token.js"; +import { loadConfig, loadConfigFromProgram } from "../core/config.js"; +import { success, error, dim } from "../utils/logger.js"; + +async function hideSkill( + program: Command, + slug: string, + opts: { yes?: boolean; namespace?: string }, + action: "hide" | "unhide" +) { + const { parseSkillNamespace } = await import("../core/skill-resolver.js"); + const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace); + if (!opts.yes) { + const { createInterface } = await import("node:readline"); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const actionText = action === "hide" ? "Hide" : "Unhide"; + const answer = await new Promise((r) => + rl.question(`${actionText} ${skillSlug} from ${namespace}? [y/N] `, r) + ); + rl.close(); + if (answer.toLowerCase() !== "y") { + console.log("Cancelled."); + return; + } + } + + try { + const token = await requireToken(); + const config = loadConfigFromProgram(program); + const client = new ApiClient({ baseUrl: config.registry, token }); + + const detail = await client.get<{ id: number }>( + `/api/v1/skills/${namespace}/${skillSlug}` + ); + + await client.post(`/api/v1/skills/${namespace}/${skillSlug}/${action}`, { + body: JSON.stringify({}), + headers: { "Content-Type": "application/json" }, + }); + + success(`${action === "hide" ? "Hidden" : "Unhidden"} ${skillSlug}`); + } catch (e: any) { + const status = e.status || e.statusCode; + if (status === 404) { + error(`Skill not found: ${namespace}/${skillSlug}`); + if (!slug.includes("/")) { + dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push"); + } + } else { + error(`Failed: ${e.message}`); + } + process.exitCode = 1; + } +} + +export function registerHide(program: Command) { + program + .command("hide") + .description("Hide a skill") + .argument("", "Skill name or namespace/skill-name") + .option("-y, --yes", "Skip confirmation") + .option("--namespace ", "Override namespace (default: parsed from skill or 'global')") + .action(async (slug: string, opts: { yes?: boolean; namespace?: string }) => { + await hideSkill(program, slug, opts, "hide"); + }); +} + +export function registerUnhide(program: Command) { + program + .command("unhide") + .description("Unhide a skill") + .argument("", "Skill name or namespace/skill-name") + .option("-y, --yes", "Skip confirmation") + .option("--namespace ", "Override namespace (default: parsed from skill or 'global')") + .action(async (slug: string, opts: { yes?: boolean; namespace?: string }) => { + await hideSkill(program, slug, opts, "unhide"); + }); +} diff --git a/skillhub-cli/src/commands/init.ts b/skillhub-cli/src/commands/init.ts new file mode 100644 index 000000000..25d9077ab --- /dev/null +++ b/skillhub-cli/src/commands/init.ts @@ -0,0 +1,46 @@ +import { Command } from "commander"; +import { mkdirSync, writeFileSync, existsSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { success, error } from "../utils/logger.js"; + +export function registerInit(program: Command) { + program + .command("init [name]") + .description("Create a new SKILL.md template") + .action((name?: string) => { + const dir = name ? resolve(process.cwd(), name) : process.cwd(); + + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + const skillMd = join(dir, "SKILL.md"); + if (existsSync(skillMd)) { + error("SKILL.md already exists"); + process.exitCode = 1; + } + + const slug = name || "my-skill"; + const content = `--- +name: ${slug} +description: What this skill does and when to use it +--- + +# ${slug} + +Instructions for the agent to follow when this skill is activated. + +## When to Use + +Describe the scenarios where this skill should be used. + +## Steps + +1. First, do this +2. Then, do that +`; + + writeFileSync(skillMd, content); + success(`Created SKILL.md at ${skillMd}`); + }); +} diff --git a/skillhub-cli/src/commands/inspect.ts b/skillhub-cli/src/commands/inspect.ts new file mode 100644 index 000000000..74d367253 --- /dev/null +++ b/skillhub-cli/src/commands/inspect.ts @@ -0,0 +1,254 @@ +import { Command } from "commander"; +import { ApiClient } from "../core/api-client.js"; +import { ApiRoutes } from "../schema/routes.js"; +import { loadConfigFromProgram } from "../core/config.js"; +import { readToken } from "../core/auth-token.js"; + +import { info, dim, error } from "../utils/logger.js"; +import { searchSkills } from "../core/interactive-search.js"; +import * as p from "@clack/prompts"; +import ora from "ora"; + +interface SkillDetailResponse { + id: number; + namespace: string; + slug: string; + displayName: string; + ownerDisplayName: string; + summary: string; + visibility: string; + status: string; + starCount: number; + downloadCount: number; + labels: Array<{ slug: string; name: string }>; + publishedVersion?: { version: string }; +} + +interface NamespaceInfo { + slug: string; + displayName: string; + currentUserRole: string; + status: string; +} + +interface SkillVersionItem { + id: number; + version: string; + status: string; + changelog: string | null; + fileCount: number; + totalSize: number; + publishedAt: string; + downloadAvailable: boolean; +} + +interface VersionsResponse { + items: SkillVersionItem[]; + total: number; + page: number; + size: number; +} + +interface SkillTag { + id: number; + tagName: string; + versionId: number; + createdAt: string; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function printSkillDetail(detail: SkillDetailResponse, versions?: SkillVersionItem[], tags?: SkillTag[]) { + console.log(""); + info(`${detail.displayName} (${detail.slug})`); + dim(`Namespace: ${detail.namespace}`); + dim(`Version: ${detail.publishedVersion?.version || "N/A"}`); + dim(`Author: ${detail.ownerDisplayName}`); + dim(`Stars: ${detail.starCount} Downloads: ${detail.downloadCount}`); + if (detail.summary) console.log(`\n${detail.summary}`); + dim(`Status: ${detail.status}`); + if (detail.labels && detail.labels.length > 0) { + dim(`Labels: ${detail.labels.map((l) => l.name || l.slug).join(", ")}`); + } + + if (versions && versions.length > 0) { + console.log(""); + info("Versions:"); + const versionTagsMap = new Map(); + if (tags) { + for (const tag of tags) { + if (!versionTagsMap.has(tag.versionId)) { + versionTagsMap.set(tag.versionId, []); + } + versionTagsMap.get(tag.versionId)!.push(tag.tagName); + } + } + for (const v of versions) { + const tagStr = versionTagsMap.get(v.id)?.join(", ") || ""; + dim(` v${v.version} ${v.status} · ${v.fileCount} files · ${formatBytes(v.totalSize)} · ${v.publishedAt}${tagStr ? " · tags: " + tagStr : ""}`); + } + } + + console.log(""); +} + +function printInspectHeader(detail: SkillDetailResponse, versions?: SkillVersionItem[], tags?: SkillTag[]) { + console.log(""); + info(`=== ${detail.displayName} ===`); + dim(`Namespace: ${detail.namespace}`); + dim(`Slug: ${detail.slug}`); + dim(`Version: ${detail.publishedVersion?.version || "N/A"}`); + dim(`Author: ${detail.ownerDisplayName}`); + console.log(""); + info("Summary:"); + console.log(` ${detail.summary || "N/A"}`); + console.log(""); + dim(`Stars: ${detail.starCount} · Downloads: ${detail.downloadCount}`); + dim(`Visibility: ${detail.visibility} · Status: ${detail.status}`); + if (detail.labels && detail.labels.length > 0) { + console.log(""); + dim(`Labels: ${detail.labels.map((l) => l.name || l.slug).join(", ")}`); + } + + if (versions && versions.length > 0) { + console.log(""); + info("Versions:"); + const versionTagsMap = new Map(); + if (tags) { + for (const tag of tags) { + if (!versionTagsMap.has(tag.versionId)) { + versionTagsMap.set(tag.versionId, []); + } + versionTagsMap.get(tag.versionId)!.push(tag.tagName); + } + } + for (const v of versions) { + const tagStr = versionTagsMap.get(v.id)?.join(", ") || ""; + dim(` v${v.version} ${v.status} · ${v.fileCount} files · ${formatBytes(v.totalSize)} · ${v.publishedAt}${tagStr ? " · tags: " + tagStr : ""}`); + } + } + + console.log(""); +} + +export function registerInspect(program: Command) { + program + .command("inspect") + .aliases(["info", "view"]) + .description("View skill metadata without installing") + .argument("", "Skill name or namespace/skill-name") + .option("--namespace ", "Search in specific namespace (searches all if not specified)") + .option("--details", "Show all versions with tags") + .option("-v, --skill-version ", "Inspect specific version") + .action(async (slug: string, opts: { namespace?: string; details?: boolean; skillVersion?: string }) => { + const config = loadConfigFromProgram(program); + const token = await readToken(); + const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); + + const isJson = program.opts().json; + + async function fetchVersionsAndTags(ns: string, skillSlug: string) { + try { + const [versionsResp, tagsResp] = await Promise.all([ + client.get(`/api/v1/skills/${ns}/${skillSlug}/versions`), + client.get(`/api/v1/skills/${ns}/${skillSlug}/tags`).catch(() => [] as SkillTag[]), + ]); + return { versions: versionsResp.items || [], tags: tagsResp || [] }; + } catch { + return { versions: undefined, tags: undefined }; + } + } + + async function displaySkillDetail(ns: string, skillSlug: string, version?: string) { + try { + const detail = await client.get( + `${ApiRoutes.skillDetail.replace("{namespace}", ns).replace("{slug}", skillSlug)}` + ); + + const { versions, tags } = await fetchVersionsAndTags(ns, skillSlug); + + if (version && versions) { + const selectedVersion = versions.find(v => v.version === version); + if (selectedVersion) { + detail.publishedVersion = { version: selectedVersion.version }; + } + } + + if (isJson) { + const output = opts.details ? { ...detail, versions, tags } : detail; + console.log(JSON.stringify(output, null, 2)); + } else { + printSkillDetail(detail, opts.details ? versions : undefined, opts.details ? tags : undefined); + } + } catch (e: any) { + if (e.statusCode === 403) { + error(`Access denied: ${ns}/${skillSlug}`); + dim("Run 'skillhub login' to authenticate."); + } else if (e.statusCode === 404) { + error(`Skill not found: ${ns}/${skillSlug}`); + } else { + error(`Failed to fetch skill details: ${e.message}`); + } + process.exitCode = 1; + } + } + + async function inspectWithVersionSelection(ns: string, skillSlug: string) { + const { versions, tags } = await fetchVersionsAndTags(ns, skillSlug); + + if (!versions || versions.length === 0) { + await displaySkillDetail(ns, skillSlug); + return; + } + + if (opts.details) { + await displaySkillDetail(ns, skillSlug); + return; + } + + if (versions.length === 1) { + await displaySkillDetail(ns, skillSlug); + return; + } + + const versionTagsMap = new Map(); + if (tags) { + for (const tag of tags) { + if (!versionTagsMap.has(tag.versionId)) { + versionTagsMap.set(tag.versionId, []); + } + versionTagsMap.get(tag.versionId)!.push(tag.tagName); + } + } + + const selected = await p.select({ + message: "Select version to inspect", + options: versions.map((v) => ({ + value: v.version, + label: `v${v.version}`, + hint: versionTagsMap.get(v.id)?.join(", ") || "", + })), + }); + + if (p.isCancel(selected)) { + console.log("Cancelled."); + return; + } + + await displaySkillDetail(ns, skillSlug, selected as string); + } + + const { resolveSkillNamespace } = await import("../core/skill-resolver.js"); + const { namespace: targetNamespace, slug: parsedSlug } = await resolveSkillNamespace(client, slug, opts.namespace); + + if (opts.skillVersion) { + await displaySkillDetail(targetNamespace, parsedSlug, opts.skillVersion); + } else { + await inspectWithVersionSelection(targetNamespace, parsedSlug); + } + }); +} diff --git a/skillhub-cli/src/commands/install.ts b/skillhub-cli/src/commands/install.ts new file mode 100644 index 000000000..60a025490 --- /dev/null +++ b/skillhub-cli/src/commands/install.ts @@ -0,0 +1,923 @@ +import { Command } from "commander"; +import { mkdtemp, rm, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createWriteStream, createReadStream, existsSync, mkdirSync } from "node:fs"; +import { ApiClient } from "../core/api-client.js"; +import { loadConfigFromProgram } from "../core/config.js"; +import { readToken } from "../core/auth-token.js"; +import { discoverSkills } from "../core/skill-discovery.js"; +import { installSkill } from "../core/installer.js"; +import { getAllAgents, detectInstalledAgents, isUniversalForScope, getAgentTargetDir, type AgentInfo } from "../core/agent-detector.js"; +import { parseSource, getCloneUrl } from "../core/source-parser.js"; +import { addToLock } from "../core/skill-lock.js"; +import { success, error, info, dim } from "../utils/logger.js"; +import chalk from "chalk"; +import unzipper from "unzipper"; +import { multiSelect, sectionMultiSelect } from "../utils/prompts.js"; +import { searchMultiselect, cancelSymbol } from "../utils/search-multiselect.js"; +import { runInteractiveSearch, searchSkills } from "../core/interactive-search.js"; +import type { SkillVersionItem } from "../schema/routes.js"; + +interface SkillTag { + id: number; + tagName: string; + versionId: number; + createdAt: string; +} +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import ora from "ora"; +import { execSync } from "node:child_process"; +import { finished } from "node:stream/promises"; + +export type SourceType = "auto" | "registry" | "git" | "local"; + +function detectSourceType(arg: string): SourceType { + if (arg.startsWith(".") || arg.startsWith("/") || arg.startsWith("~")) { + return "local"; + } + if (arg.includes("github.com") || arg.includes("gitlab.com") || arg.includes("://") || arg.endsWith(".git")) { + return "git"; + } + if (/^[\w-]+\/[\w-]+$/.test(arg)) { + return "registry"; + } + return "registry"; +} + +function getInstallSpinner(sourceType: SourceType, arg: string): string { + if (sourceType === "registry") { + return `Searching registry for ${arg}`; + } + return `Resolving ${arg}`; +} + +interface InstallResult { + skill: string; + agent: string; + success: boolean; + path: string; + error?: string; +} + +/** + * Build grouped install result lines: agents sharing the same install path + * are merged into one line for cleaner output. + * + * Example: + * ✓ fork-workflow + * → Amp, Cline, Codex, Cursor, Deep Agents +7: .agents/skills/fork-workflow + * → Claude Code: .claude/skills/fork-workflow + */ +function buildInstallResultLines( + selectedSkills: { name: string }[], + results: InstallResult[], +): string[] { + const MAX_NAMES = 5; + const resultLines: string[] = []; + + for (const skill of selectedSkills) { + const skillResults = results.filter((r) => r.skill === skill.name && r.success); + if (skillResults.length === 0) continue; + + resultLines.push(`${pc.green("✓")} ${skill.name}`); + + // Group agents by install path + const pathGroups = new Map(); + for (const r of skillResults) { + const agents = pathGroups.get(r.path) || []; + agents.push(r.agent); + pathGroups.set(r.path, agents); + } + + // Sort groups: put the group with the most agents first + const sortedGroups = [...pathGroups.entries()].sort((a, b) => b[1].length - a[1].length); + + for (const [path, agents] of sortedGroups) { + const sorted = agents.sort((a, b) => a.localeCompare(b)); + let label: string; + if (sorted.length <= MAX_NAMES) { + label = sorted.join(", "); + } else { + const shown = sorted.slice(0, MAX_NAMES).join(", "); + const extra = sorted.length - MAX_NAMES; + label = `${shown} ${pc.dim(`+${extra}`)}`; + } + resultLines.push(` ${pc.dim("→")} ${label}: ${pc.dim(path)}`); + } + } + + return resultLines; +} + +async function selectAgentsInteractive(isGlobal: boolean): Promise { + const allAgents = getAllAgents(); + + // Use scope-aware universal check: an agent is "universal" when its target + // install dir equals the canonical .agents/skills directory for the given scope. + // Exclude agents with showInUniversalList === false from the locked section. + const universalAgents = allAgents + .filter((a) => isUniversalForScope(a, isGlobal) && a.showInUniversalList !== false) + .sort((a, b) => a.name.localeCompare(b.name)); + const nonUniversalAgents = allAgents + .filter((a) => !isUniversalForScope(a, isGlobal)) + .sort((a, b) => a.name.localeCompare(b.name)); + + const canonicalLabel = isGlobal ? "Universal (~/.agents/skills)" : "Universal (.agents/skills)"; + const lockedSection = { + title: canonicalLabel, + items: universalAgents.map((a) => ({ + value: a.key, + label: a.name, + })), + }; + + const selectableItems = nonUniversalAgents.map((a) => ({ + value: a.key, + label: a.name, + hint: isGlobal ? (a.globalSkillsDir || a.skillsDir) : a.skillsDir, + })); + + const result = await searchMultiselect({ + message: "Which agents do you want to install to?", + items: selectableItems, + lockedSection, + }); + + if (result === cancelSymbol) { + return null; + } + + return result as string[]; +} + +async function selectInstallMode(): Promise<"symlink" | "copy" | null> { + const result = await p.select({ + message: "Installation method?", + options: [ + { + value: "symlink", + label: "Symlink (Recommended)", + hint: "single source of truth", + }, + { + value: "copy", + label: "Copy to all agents", + hint: "independent copies", + }, + ], + }); + + if (p.isCancel(result)) { + return null; + } + + return result as "symlink" | "copy"; +} + +function buildAgentSummary(targetAgents: AgentInfo[], mode: "symlink" | "copy", isGlobal: boolean): string[] { + const lines: string[] = []; + const universal = targetAgents.filter((a) => isUniversalForScope(a, isGlobal)); + const symlinked = targetAgents.filter((a) => !isUniversalForScope(a, isGlobal)); + + const sortNames = (agents: AgentInfo[]) => agents.map((a) => a.name).sort((a, b) => a.localeCompare(b)); + + if (mode === "symlink") { + if (universal.length > 0) { + lines.push(` universal: ${sortNames(universal).join(", ")}`); + } + if (symlinked.length > 0) { + lines.push(` symlink → ${sortNames(symlinked).join(", ")}`); + } + } else { + lines.push(` copy → ${sortNames(targetAgents).join(", ")}`); + } + + return lines; +} + +function buildInstallHelp(cmd: Command): string { + const lines: string[] = []; + + lines.push(`${chalk.bold("Usage:")} skillhub install [options] ${chalk.cyan("")}`); + lines.push(""); + lines.push("Install skills from registry, git repositories, or local paths"); + lines.push(""); + + lines.push(chalk.bold("Arguments:")); + lines.push(` ${chalk.cyan("skill")} Skill name or namespace/skill-name from registry`); + lines.push(""); + + lines.push(chalk.bold("Source Options:")); + lines.push(` ${chalk.cyan("--from ")} Install from GitHub or local path`); + lines.push(` ${chalk.cyan("-a, --add ")} Alias for --from`); + lines.push(""); + + lines.push(chalk.bold("Target Options:")); + lines.push(` ${chalk.cyan("--agent ")} Target specific agents`); + lines.push(` ${chalk.cyan("-g, --global")} Install to global scope`); + lines.push(""); + + lines.push(chalk.bold("Version Options:")); + lines.push(` ${chalk.cyan("-v, --skill-version ")} Install specific version (non-interactive)`); + lines.push(` ${chalk.cyan("--tag ")} Install specific tag (non-interactive, resolves to version)`); + lines.push(""); + + lines.push(chalk.bold("Mode Options:")); + lines.push(` ${chalk.cyan("--copy")} Copy instead of symlink`); + lines.push(` ${chalk.cyan("--list")} List available skills without installing`); + lines.push(""); + + lines.push(chalk.bold("Other Options:")); + lines.push(` ${chalk.cyan("-y, --yes")} Skip all prompts`); + lines.push(` ${chalk.cyan("-h, --help")} Display help for command`); + lines.push(""); + + lines.push(chalk.bold("Examples:")); + lines.push(chalk.dim(" skillhub install vision2group/fork-workflow Install a skill from registry")); + lines.push(chalk.dim(" skillhub install my-skill --from ./local/path Install from local directory")); + lines.push(chalk.dim(" skillhub install my-skill --from github.com/user/repo Install from GitHub")); + lines.push(chalk.dim(" skillhub install my-skill -g --yes Install globally, skip prompts")); + lines.push(chalk.dim(" skillhub install my-skill --tag v1.0.0 Install specific tag")); + + return lines.join("\n"); +} + +export function registerInstall(program: Command) { + const installCmd = program + .command("install") + .alias("i") + .description("Install skills from registry, git repositories, or local paths") + .argument("", "Skill name or namespace/skill-name from registry") + .option("--from ", "Install from GitHub or local path") + .option("-a, --add ", "Alias for --from") + .option("--agent ", "Target specific agents") + .option("-g, --global", "Install to global scope") + .option("-y, --yes", "Skip all prompts") + .option("--copy", "Copy instead of symlink") + .option("--list", "List available skills without installing") + .option("-v, --skill-version ", "Install specific version (non-interactive)") + .option("--tag ", "Install specific tag (non-interactive, resolves to version)") + .configureHelp({ showGlobalOptions: true }); + + const originalHelp = installCmd.helpInformation.bind(installCmd); + installCmd.helpInformation = () => { + return buildInstallHelp(installCmd); + }; + + installCmd.action(async (source: string, opts: Record) => { + const fromSource = (opts.from || opts.add) as string | undefined; + + let effectiveSource: SourceType; + let installSource = source; + + if (fromSource) { + effectiveSource = detectSourceType(fromSource); + installSource = fromSource; + } else { + effectiveSource = detectSourceType(source); + } + + const spinner = ora(getInstallSpinner(effectiveSource, installSource)).start(); + + try { + if (effectiveSource === "registry") { + await installFromRegistry(source, opts, spinner, program); + } else { + await installFromGit(source, installSource, effectiveSource, opts, spinner, program); + } + } catch (e: any) { + spinner.fail(e.message); + process.exitCode = 1; + } + }); +} + +async function installFromRegistry( + slug: string, + opts: Record, + spinner: any, + program: Command +) { + let ns = "global"; + let actualSlug = slug; + let userSpecifiedNamespace = false; + + if (slug.includes("/") && !slug.startsWith("/")) { + const parts = slug.split("/"); + if (parts.length === 2) { + ns = parts[0]; + actualSlug = parts[1]; + userSpecifiedNamespace = true; + } + } + + const config = loadConfigFromProgram(program); + const token = await readToken(); + const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); + + if (!userSpecifiedNamespace) { + const results = await searchSkills(client, actualSlug, 50); + + // Deduplicate by namespace/name + const seen = new Set(); + const uniqueResults = results.filter(r => { + const key = `${r.namespace}/${r.name}`; + if (!seen.has(key)) { + seen.add(key); + return true; + } + return false; + }); + + if (uniqueResults.length === 0) { + spinner.fail(`Skill not found: ${actualSlug}`); + process.exitCode = 1; + } + + if (uniqueResults.length === 1) { + ns = uniqueResults[0].namespace; + actualSlug = uniqueResults[0].name; + } else { + spinner.succeed(`Found ${actualSlug}`); + const selected = await runInteractiveSearch(client, actualSlug); + if (!selected) { + console.log("Cancelled."); + return; + } + spinner.start(`Fetching ${selected}`); + const [selectedNs, selectedName] = selected.split("/", 2); + ns = selectedNs; + actualSlug = selectedName; + } + } + + spinner.text = `Fetching versions for ${ns}/${actualSlug}`; + const [versionsResp, tagsResp] = await Promise.all([ + client.get<{ items: SkillVersionItem[] }>(`/api/v1/skills/${ns}/${actualSlug}/versions`), + client.get(`/api/v1/skills/${ns}/${actualSlug}/tags`).catch(() => [] as SkillTag[]), + ]); + + const versions = versionsResp.items || []; + + // Map tags to versions by versionId + const versionTagsMap = new Map(); + for (const tag of tagsResp || []) { + if (!versionTagsMap.has(tag.versionId)) { + versionTagsMap.set(tag.versionId, []); + } + versionTagsMap.get(tag.versionId)!.push(tag.tagName); + } + + // Present version selection + let selectedVersion: string = "latest"; + if (opts.skillVersion) { + selectedVersion = String(opts.skillVersion).replace(/^v/, ""); + const versionExists = versions.some((v) => v.version === selectedVersion); + if (!versionExists) { + spinner.fail(`Version not found: ${opts.skillVersion}`); + if (versions.length > 0) { + info(`Available versions: ${versions.map((v) => v.version).join(", ")}`); + } + process.exitCode = 1; + return; + } + } else if (opts.tag) { + for (const [vid, tags] of versionTagsMap) { + if (tags.includes(opts.tag as string)) { + const v = versions.find((ver) => ver.id === vid); + if (v) { + selectedVersion = v.version; + break; + } + } + } + if (!selectedVersion) { + // Fallback: use latest if tag not found + selectedVersion = versions[0]?.version || "latest"; + } + } else { + // Interactive: show version selection + spinner.succeed(`Found ${ns}/${actualSlug}`); + + const picked = await p.select({ + message: "Select version", + options: versions.map((v) => ({ + value: v.version, + label: `v${v.version}`, + hint: versionTagsMap.get(v.id)?.join(", ") || "", + })), + }); + + if (p.isCancel(picked)) { + console.log("Cancelled."); + return; + } + + selectedVersion = picked as string; + spinner.start(`Downloading ${ns}/${actualSlug}@${selectedVersion}`); + } + + const baseUrl = config.registry.replace(/\/$/, ""); + const downloadUrl = `${baseUrl}/api/v1/skills/${ns}/${actualSlug}/versions/${selectedVersion}/download`; + const tmpDir = await mkdtemp(join(tmpdir(), "skillhub-install-")); + const zipPath = join(tmpDir, `${actualSlug}.zip`); + + spinner.text = `Downloading ${ns}/${actualSlug}@${selectedVersion}`; + + const { request } = await import("undici"); + let response = await request(downloadUrl, { + method: "GET", + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + // undici 不自动跟随 redirect,手动处理 302/307/308 + if (response.statusCode === 302 || response.statusCode === 307 || response.statusCode === 308) { + const location = response.headers.location; + if (!location) { + spinner.fail(`Redirect response has no Location header`); + await rm(tmpDir, { recursive: true, force: true }); + process.exitCode = 1; + } + response = await request(location as string, { method: "GET" }); + } + const { statusCode, body } = response; + + if (statusCode >= 400) { + let errorMsg = `Failed to download skill: ${ns}/${actualSlug}`; + if (statusCode === 404) { + errorMsg = `Skill not found: ${ns}/${actualSlug}`; + } else if (statusCode === 403) { + errorMsg = `Access denied: ${ns}/${actualSlug}. Check your token permissions.`; + } else if (statusCode === 503) { + errorMsg = `Service temporarily unavailable. Please try again later.`; + } else if (statusCode >= 500) { + errorMsg = `Server error (${statusCode}). Please try again later.`; + } + spinner.fail(errorMsg); + await rm(tmpDir, { recursive: true, force: true }); + process.exitCode = 1; + return; + } + + const fileStream = createWriteStream(zipPath); + await finished(body.pipe(fileStream)); + + spinner.text = `Extracting ${actualSlug}`; + const extractDir = join(tmpDir, "extracted"); + mkdirSync(extractDir, { recursive: true }); + await createReadStream(zipPath) + .pipe(unzipper.Extract({ path: extractDir })) + .promise(); + + const skills = discoverSkills(extractDir); + if (skills.length === 0) { + spinner.fail("No SKILL.md found in package"); + process.exitCode = 1; + } + + spinner.succeed(`Found ${skills.length} skill(s) in ${ns}/${actualSlug}`); + + if (opts.list) { + const sorted = [...skills].sort((a, b) => a.name.localeCompare(b.name)); + for (const s of sorted) { + info(`${s.name}`); + dim(` ${s.description}`); + } + return; + } + + let selectedSkills = skills; + if (!opts.yes && skills.length > 1) { + const selected = await searchMultiselect({ + message: "Select skills to install", + items: skills.map((s) => ({ value: s.name, label: s.name, hint: s.description })), + }); + if (selected === cancelSymbol) { + console.log("Cancelled."); + return; + } + selectedSkills = skills.filter((s) => (selected as string[]).includes(s.name)); + } + + let isGlobal = !!opts.global; + + // Determine scope first, so that agent selection can use the correct + // universal/non-universal grouping based on the actual scope. + const allAgents = getAllAgents(); + const supportsGlobal = allAgents.some((a) => a.globalSkillsDir); + + if (opts.global === undefined && !opts.yes && supportsGlobal) { + const scope = await p.select({ + message: "Installation scope", + options: [ + { + value: false, + label: "Project", + hint: "Install in current directory (committed with your project)", + }, + { + value: true, + label: "Global", + hint: "Install in home directory (available across all projects)", + }, + ], + }); + + if (p.isCancel(scope)) { + console.log("Cancelled."); + return; + } + + isGlobal = scope as boolean; + } + + let targetAgents = opts.agent + ? allAgents.filter((a) => (opts.agent as string[]).includes(a.key)) + : detectInstalledAgents(); + + if (targetAgents.length === 0) { + const claude = allAgents.find((a) => a.key === "claude-code"); + if (claude) targetAgents.push(claude); + } + + let mode: "symlink" | "copy" = opts.copy ? "copy" : "symlink"; + + if (!opts.yes && !opts.agent) { + const selected = await selectAgentsInteractive(isGlobal); + if (!selected) { + console.log("Cancelled."); + return; + } + targetAgents = allAgents.filter((a) => selected.includes(a.key)); + } + + // Only prompt for install mode when there are multiple unique target directories. + // When all selected agents share the same skillsDir, symlink vs copy is meaningless. + const uniqueDirs = new Set(targetAgents.map((a) => getAgentTargetDir(a, isGlobal))); + + if (uniqueDirs.size <= 1) { + // Single target directory — default to copy (no symlink needed) + mode = 'copy'; + } else if (!opts.yes) { + const selectedMode = await selectInstallMode(); + if (selectedMode === null) { + console.log("Cancelled."); + return; + } + mode = selectedMode; + } + + const cwd = process.cwd(); + const summaryLines: string[] = []; + + for (const skill of selectedSkills) { + if (summaryLines.length > 0) summaryLines.push(""); + const canonicalPath = isGlobal + ? `~/.agents/skills/${skill.name}` + : `./.agents/skills/${skill.name}`; + summaryLines.push(`${pc.cyan(canonicalPath)}`); + for (const line of buildAgentSummary(targetAgents, mode, isGlobal)) { + summaryLines.push(` ${line}`); + } + } + summaryLines.push(""); + summaryLines.push(`${pc.dim("Mode:")} ${mode}`); + summaryLines.push(`${pc.dim("Scope:")} ${isGlobal ? "global" : "project"}`); + + console.log(""); + p.note(summaryLines.join("\n"), "Installation Summary"); + + if (!opts.yes) { + const confirmed = await p.confirm({ message: "Proceed with installation?" }); + + if (p.isCancel(confirmed) || !confirmed) { + console.log("Cancelled."); + return; + } + } + + spinner.start("Installing skills..."); + + let installed = 0; + let failed = 0; + const results: { skill: string; agent: string; success: boolean; path: string; error?: string }[] = []; + + for (const skill of selectedSkills) { + for (const agent of targetAgents) { + const result = installSkill( + skill.dir, + skill.name, + agent.key, + getAgentTargetDir(agent, isGlobal), + mode, + isGlobal, + agent, + ); + results.push({ + skill: skill.name, + agent: agent.name, + success: result.success, + path: result.path || "", + error: result.error, + }); + if (result.success) { + installed++; + await addToLock(skill.name, { + source: `${ns}/${actualSlug}`, + sourceType: "registry", + sourceUrl: `${config.registry}/api/v1/skills/${ns}/${actualSlug}`, + namespace: ns, + slug: skill.name, + version: "latest", + }); + } else { + failed++; + error(`Failed to install ${skill.name} to ${agent.name}: ${result.error}`); + } + } + } + + spinner.succeed("Installation complete"); + + console.log(""); + const successful = results.filter((r) => r.success); + + if (successful.length > 0) { + const resultLines = buildInstallResultLines(selectedSkills, results); + p.note(resultLines.join("\n"), `Installed ${successful.length} skill(s)`); + } + + if (failed > 0) { + p.log.error(pc.red(`Failed to install ${failed}`)); + for (const r of results.filter((r) => !r.success)) { + p.log.message(`${pc.red("✗")} ${r.skill} → ${r.agent}: ${pc.dim(r.error || "unknown error")}`); + } + } + + console.log(""); + p.outro(pc.green("Done!") + pc.dim(" Review skills before use; they run with full agent permissions.")); + + await rm(tmpDir, { recursive: true, force: true }); +} + +async function installFromGit( + skillName: string, + source: string, + sourceType: SourceType, + opts: Record, + spinner: any, + program: Command +) { + let skillsDir: string; + + const parsed = parseSource(source); + + if (parsed.skillFilter) { + opts.skill = opts.skill || []; + if (!Array.isArray(opts.skill)) { + opts.skill = [opts.skill as string]; + } + if (!opts.skill.includes(parsed.skillFilter)) { + opts.skill.push(parsed.skillFilter); + } + } + + // If skillName is a skill identifier (not a path), use it to filter + if (skillName && !skillName.startsWith(".") && !skillName.startsWith("/") && !skillName.startsWith("~")) { + opts.skill = opts.skill || []; + if (!Array.isArray(opts.skill)) { + opts.skill = [opts.skill as string]; + } + if (!opts.skill.includes(skillName)) { + opts.skill.push(skillName); + } + } + + if (parsed.type === "local") { + skillsDir = parsed.localPath!; + spinner.text = `Scanning ${parsed.localPath}`; + } else { + const cloneUrl = getCloneUrl(parsed); + spinner.text = `Cloning ${cloneUrl}`; + const tmpDir = await mkdtemp(join(tmpdir(), "skillhub-install-")); + const refArg = parsed.ref ? `--branch ${parsed.ref}` : ""; + const depth = parsed.ref ? "" : "--depth 1"; + execSync(`git clone ${depth} ${refArg} ${cloneUrl} ${tmpDir}`, { stdio: "pipe" }); + skillsDir = tmpDir; + + process.on("exit", () => { rm(tmpDir, { recursive: true, force: true }).catch(() => {}); }); + } + + spinner.text = "Discovering skills"; + const skills = discoverSkills(skillsDir); + + if (skills.length === 0) { + spinner.fail("No skills found. Ensure the directory contains SKILL.md files."); + process.exitCode = 1; + } + + spinner.succeed(`Found ${skills.length} skill(s)`); + + if (opts.list) { + const sorted = [...skills].sort((a, b) => a.name.localeCompare(b.name)); + for (const s of sorted) { + info(`${s.name}`); + dim(` ${s.description}`); + } + return; + } + + let selectedSkills = skills; + if (opts.skill) { + const skillNames = opts.skill as string[]; + if (skillNames.includes("*")) { + selectedSkills = skills; + } else { + selectedSkills = skills.filter((s) => skillNames.includes(s.name)); + if (selectedSkills.length === 0) { + error(`No matching skills for: ${skillNames.join(", ")}`); + info("Available: " + skills.map((s) => s.name).join(", ")); + process.exitCode = 1; + } + } + } else if (!opts.yes && skills.length > 1) { + const selected = await searchMultiselect({ + message: "Select skills to install", + items: skills.map((s) => ({ value: s.name, label: s.name, hint: s.description })), + }); + + if (selected === cancelSymbol) { + console.log("Cancelled."); + return; + } + + selectedSkills = skills.filter((s) => (selected as string[]).includes(s.name)); + } + + let isGlobal = !!opts.global; + + // Determine scope first, so that agent selection can use the correct + // universal/non-universal grouping based on the actual scope. + const allAgents = getAllAgents(); + const supportsGlobal = allAgents.some((a) => a.globalSkillsDir); + + if (opts.global === undefined && !opts.yes && supportsGlobal) { + const scope = await p.select({ + message: "Installation scope", + options: [ + { + value: false, + label: "Project", + hint: "Install in current directory (committed with your project)", + }, + { + value: true, + label: "Global", + hint: "Install in home directory (available across all projects)", + }, + ], + }); + + if (p.isCancel(scope)) { + console.log("Cancelled."); + return; + } + + isGlobal = scope as boolean; + } + + let targetAgents = opts.agent + ? allAgents.filter((a) => (opts.agent as string[]).includes(a.key)) + : detectInstalledAgents(); + + if (targetAgents.length === 0) { + if (!opts.yes) { + info("No agents detected. Installing to Claude Code by default."); + } + const claude = allAgents.find((a) => a.key === "claude-code"); + if (claude) targetAgents.push(claude); + else targetAgents.push(allAgents[0]); + } + + let mode: "symlink" | "copy" = opts.copy ? "copy" : "symlink"; + + if (!opts.yes && !opts.agent) { + const selected = await selectAgentsInteractive(isGlobal); + if (!selected) { + console.log("Cancelled."); + return; + } + targetAgents = allAgents.filter((a) => selected.includes(a.key)); + } + + // Only prompt for install mode when there are multiple unique target directories. + // When all selected agents share the same skillsDir, symlink vs copy is meaningless. + const uniqueDirs = new Set(targetAgents.map((a) => getAgentTargetDir(a, isGlobal))); + + if (uniqueDirs.size <= 1) { + // Single target directory — default to copy (no symlink needed) + mode = 'copy'; + } else if (!opts.yes) { + const selectedMode = await selectInstallMode(); + if (selectedMode === null) { + console.log("Cancelled."); + return; + } + mode = selectedMode; + } + + const cwd = process.cwd(); + const summaryLines: string[] = []; + + for (const skill of selectedSkills) { + if (summaryLines.length > 0) summaryLines.push(""); + const canonicalPath = isGlobal + ? `~/.agents/skills/${skill.name}` + : `./.agents/skills/${skill.name}`; + summaryLines.push(`${pc.cyan(canonicalPath)}`); + for (const line of buildAgentSummary(targetAgents, mode, isGlobal)) { + summaryLines.push(` ${line}`); + } + } + summaryLines.push(""); + summaryLines.push(`${pc.dim("Mode:")} ${mode}`); + summaryLines.push(`${pc.dim("Scope:")} ${isGlobal ? "global" : "project"}`); + + console.log(""); + p.note(summaryLines.join("\n"), "Installation Summary"); + + if (!opts.yes) { + const confirmed = await p.confirm({ message: "Proceed with installation?" }); + + if (p.isCancel(confirmed) || !confirmed) { + console.log("Cancelled."); + return; + } + } + + spinner.start("Installing skills..."); + + let installed = 0; + let failed = 0; + const results: { skill: string; agent: string; success: boolean; path: string; error?: string }[] = []; + + for (const skill of selectedSkills) { + for (const agent of targetAgents) { + const result = installSkill( + skill.dir, + skill.name, + agent.key, + getAgentTargetDir(agent, isGlobal), + mode, + isGlobal, + agent, + ); + results.push({ + skill: skill.name, + agent: agent.name, + success: result.success, + path: result.path || "", + error: result.error, + }); + if (result.success) { + installed++; + const sourceUrl = parsed.type === "local" + ? (parsed.localPath as string) + : getCloneUrl(parsed); + await addToLock(skill.name, { + source: source, + sourceType: parsed.type === "local" ? "local" : "git", + sourceUrl: sourceUrl, + ref: parsed.ref, + namespace: "global", + slug: skill.name, + version: parsed.ref || "main", + }); + } else { + failed++; + error(`Failed to install ${skill.name} to ${agent.name}: ${result.error}`); + } + } + } + + spinner.succeed("Installation complete"); + + console.log(""); + const successful = results.filter((r) => r.success); + + if (successful.length > 0) { + const resultLines = buildInstallResultLines(selectedSkills, results); + p.note(resultLines.join("\n"), `Installed ${successful.length} skill(s)`); + } + + if (failed > 0) { + p.log.error(pc.red(`Failed to install ${failed}`)); + for (const r of results.filter((r) => !r.success)) { + p.log.message(`${pc.red("✗")} ${r.skill} → ${r.agent}: ${pc.dim(r.error || "unknown error")}`); + } + } + + console.log(""); + p.outro(pc.green("Done!") + pc.dim(" Review skills before use; they run with full agent permissions.")); +} diff --git a/skillhub-cli/src/commands/list.ts b/skillhub-cli/src/commands/list.ts new file mode 100644 index 000000000..b5d4f4877 --- /dev/null +++ b/skillhub-cli/src/commands/list.ts @@ -0,0 +1,203 @@ +import { Command } from "commander"; +import { existsSync } from "node:fs"; +import { getAllAgents } from "../core/agent-detector.js"; +import { getSkillLockPath } from "../core/skill-lock.js"; +import { discoverInstalledSkills, filterSkillsByStatus, type DiscoveredSkill } from "../core/skill-status.js"; +import { success, error, info, warn, dim } from "../utils/logger.js"; +import { searchMultiselect, cancelSymbol } from "../utils/search-multiselect.js"; +import * as p from "@clack/prompts"; +import pc from "picocolors"; + +interface ListOptions { + scope?: string; + agent?: string[]; + status?: string[]; + json?: boolean; +} + +export async function listAction(opts: ListOptions) { + let scopes: ("local" | "global")[] = []; + + if (opts.scope) { + const scopeValue = opts.scope.toLowerCase(); + if (scopeValue === "all") { + scopes = ["local", "global"]; + } else if (scopeValue === "global") { + scopes = ["global"]; + } else if (scopeValue === "project" || scopeValue === "local") { + scopes = ["local"]; + } + } else { + const scopeSelection = await p.select({ + message: "Which scope to list?", + options: [ + { value: "all", label: "All (global + project)" }, + { value: "global", label: "Global only" }, + { value: "project", label: "Project only" }, + ], + }); + + if (p.isCancel(scopeSelection)) { + console.log("Cancelled."); + return; + } + + if (scopeSelection === "all") { + scopes = ["local", "global"]; + } else if (scopeSelection === "global") { + scopes = ["global"]; + } else { + scopes = ["local"]; + } + } + + let targetAgents = opts.agent + ? getAllAgents().filter((a) => opts.agent!.includes(a.key)) + : undefined; + + if (!opts.agent) { + const allAgents = getAllAgents(); + const agentItems = allAgents + .map((a) => ({ value: a.key, label: a.name })) + .sort((a, b) => a.label.localeCompare(b.label)); + + const selected = await searchMultiselect({ + message: "Which agents to list from?", + items: agentItems, + required: false, + }); + + if (selected === cancelSymbol) { + console.log("Cancelled."); + return; + } + + if (selected && selected.length > 0) { + targetAgents = allAgents.filter((a) => (selected as string[]).includes(a.key)); + } + } + + let showManaged = false; + let showOrphaned = false; + let showMissing = false; + + if (opts.status && opts.status.length > 0) { + const statusSet = new Set(opts.status.map((s) => s.toLowerCase())); + if (statusSet.has("all")) { + showManaged = true; + showOrphaned = true; + showMissing = true; + } else { + showManaged = statusSet.has("managed"); + showOrphaned = statusSet.has("orphaned"); + showMissing = statusSet.has("missing"); + } + } else { + const statusSelection = await p.multiselect({ + message: "Which statuses to show?", + options: [ + { value: "managed", label: "managed", hint: "installed and in lock file" }, + { value: "orphaned", label: "orphaned", hint: "installed but not in lock file" }, + { value: "missing", label: "missing", hint: "in lock file but not installed" }, + ], + required: false, + initialValues: ["managed", "orphaned"], + }); + + if (p.isCancel(statusSelection)) { + console.log("Cancelled."); + return; + } + + const selected = statusSelection as string[]; + showManaged = selected.includes("managed"); + showOrphaned = selected.includes("orphaned"); + showMissing = selected.includes("missing"); + } + + const allSkills = await discoverInstalledSkills(scopes, targetAgents); + const filteredSkills = filterSkillsByStatus(allSkills, { + managed: showManaged, + orphaned: showOrphaned, + missing: showMissing, + }); + + if (opts.json) { + console.log(JSON.stringify(filteredSkills, null, 2)); + return; + } + + displaySkillList(filteredSkills, scopes, targetAgents); +} + +export function registerList(program: Command) { + program + .command("list") + .alias("ls") + .description("List installed skills with status") + .option("--scope ", "Scope to list (global, project, all)") + .option("--agent ", "Filter by specific agents") + .option("--status ", "Filter by status (managed, orphaned, missing, all)") + .option("--json", "Output as JSON") + .action(async (opts: ListOptions) => { + await listAction(opts); + }); +} + +function displaySkillList( + skills: DiscoveredSkill[], + scopes: ("local" | "global")[], + targetAgents?: import("../core/agent-detector.js").AgentInfo[] +) { + const scopeLabel = scopes.length === 2 ? "all scopes" : `${scopes[0]} scope`; + const agentLabel = targetAgents + ? ` (${targetAgents.map((a) => a.name).sort((a, b) => a.localeCompare(b)).join(", ")})` + : ""; + + console.log(""); + info(`Installed Skills (${scopeLabel})${agentLabel}:`); + console.log(""); + + if (skills.length === 0) { + dim(" No skills found."); + console.log(""); + return; + } + + let managed = 0, missing = 0, orphaned = 0; + + for (const skill of skills) { + if (skill.status === "managed") { + managed++; + success(` ✓ ${skill.name}`); + if (skill.source) { + dim(` Source: ${skill.source}`); + } + for (const loc of skill.locations) { + dim(` → ${loc.agent}: ${loc.path}`); + } + } else if (skill.status === "missing") { + missing++; + error(` ✗ ${skill.name}`); + if (skill.source) { + dim(` Source: ${skill.source}`); + } + dim(` Status: NOT INSTALLED`); + } else if (skill.status === "orphaned") { + orphaned++; + warn(` ! ${skill.name}`); + for (const loc of skill.locations) { + dim(` → ${loc.agent}: ${loc.path}`); + } + dim(` Status: NOT IN LOCK FILE`); + } + } + + console.log(""); + const lockPath = getSkillLockPath(); + if (existsSync(lockPath)) { + dim(`Lock file: ${lockPath}`); + } + dim(`Summary: ${managed} managed, ${missing} missing, ${orphaned} orphaned`); + console.log(""); +} diff --git a/skillhub-cli/src/commands/login.ts b/skillhub-cli/src/commands/login.ts new file mode 100644 index 000000000..4e72f621c --- /dev/null +++ b/skillhub-cli/src/commands/login.ts @@ -0,0 +1,42 @@ +import { Command } from "commander"; +import { createInterface } from "node:readline"; +import { stdin, stdout } from "node:process"; +import { writeToken } from "../core/auth-token.js"; +import { ApiClient } from "../core/api-client.js"; +import { ApiRoutes, WhoamiResponse } from "../schema/routes.js"; +import { loadConfigFromProgram } from "../core/config.js"; +import { success, error, info } from "../utils/logger.js"; + +export function registerLogin(program: Command) { + program + .command("login") + .description("Authenticate with SkillHub registry") + .option("--token ", "Auth token (skipped prompt)") + .option("--registry ", "Registry URL override") + .action(async (opts: { token?: string; registry?: string }) => { + const rl = createInterface({ input: stdin, output: stdout }); + const ask = (q: string) => new Promise((r) => rl.question(q, r)); + + const token = opts.token || (await ask("Enter your SkillHub token: ")); + rl.close(); + + const config = loadConfigFromProgram(program); + const registry = opts.registry || config.registry; + + try { + const client = new ApiClient({ baseUrl: registry, token }); + const resp = await client.get(ApiRoutes.whoami); + await writeToken(token); + success(`Authenticated as ${resp.user.displayName} (@${resp.user.handle})`); + } catch (e: any) { + const detail = e.message || e.code || e.constructor?.name || String(e); + error(`Authentication failed: ${detail}`); + if (e.statusCode) { + info(`Registry: ${registry} (HTTP ${e.statusCode})`); + } else { + info(`Registry: ${registry} — connection or network error`); + } + process.exitCode = 1; + } + }); +} diff --git a/skillhub-cli/src/commands/logout.ts b/skillhub-cli/src/commands/logout.ts new file mode 100644 index 000000000..94c2e5827 --- /dev/null +++ b/skillhub-cli/src/commands/logout.ts @@ -0,0 +1,13 @@ +import { Command } from "commander"; +import { removeToken } from "../core/auth-token.js"; +import { success } from "../utils/logger.js"; + +export function registerLogout(program: Command) { + program + .command("logout") + .description("Remove stored authentication token") + .action(async () => { + await removeToken(); + success("Logged out successfully"); + }); +} diff --git a/skillhub-cli/src/commands/me.ts b/skillhub-cli/src/commands/me.ts new file mode 100644 index 000000000..fde1d0de6 --- /dev/null +++ b/skillhub-cli/src/commands/me.ts @@ -0,0 +1,139 @@ +import { Command } from "commander"; +import { ApiClient } from "../core/api-client.js"; +import { loadConfig, loadConfigFromProgram } from "../core/config.js"; +import { requireToken } from "../core/auth-token.js"; +import { error, info, dim } from "../utils/logger.js"; + +export interface MeSkillItem { + id: number; + namespace: string; + slug: string; + displayName: string; + status: string; + starCount: number; + downloadCount: number; + headlineVersion?: { version: string }; + publishedVersion?: { version: string }; +} + +export interface MeSkillsResponse { + items: MeSkillItem[]; + total: number; + page: number; + size: number; +} + +export function registerMe(program: Command) { + const me = program.command("me").description("View your profile information"); + + me + .command("skills") + .alias("ls") + .description("List your published skills") + .action(async () => { + try { + const token = await requireToken(); + const config = loadConfigFromProgram(program); + const client = new ApiClient({ baseUrl: config.registry, token, debug: program.opts().debug }); + const resp = await client.get("/api/v1/me/skills"); + const skills = resp.items || []; + const isJson = program.opts().json; + if (isJson) { + console.log(JSON.stringify(resp, null, 2)); + } else { + if (skills.length === 0) { + console.log("No skills published yet."); + return; + } + for (const s of skills) { + const version = s.headlineVersion?.version || s.publishedVersion?.version || "unknown"; + info(`${s.displayName} (${s.slug})`); + dim(` ${s.namespace} · v${version} · ⭐ ${s.starCount} · ↓ ${s.downloadCount} · ${s.status}`); + } + } + } catch (e: any) { + error(`Failed: ${e.message}`); + process.exitCode = 1; + } + }); + + me + .command("stars") + .description("List your starred skills") + .action(async () => { + try { + const token = await requireToken(); + const config = loadConfigFromProgram(program); + const client = new ApiClient({ baseUrl: config.registry, token, debug: program.opts().debug }); + const resp = await client.get("/api/v1/me/stars"); + const skills = resp.items || []; + const isJson = program.opts().json; + if (isJson) { + console.log(JSON.stringify(resp, null, 2)); + } else { + if (skills.length === 0) { + console.log("No starred skills."); + return; + } + for (const s of skills) { + const version = s.headlineVersion?.version || s.publishedVersion?.version || "unknown"; + info(`${s.displayName} (${s.slug})`); + dim(` ${s.namespace} · v${version} · ⭐ ${s.starCount}`); + } + } + } catch (e: any) { + error(`Failed: ${e.message}`); + process.exitCode = 1; + } + }); + + me + .command("namespaces") + .description("List namespaces you have access to") + .action(async () => { + try { + const token = await requireToken(); + const config = loadConfigFromProgram(program); + const client = new ApiClient({ baseUrl: config.registry, token, debug: program.opts().debug }); + const namespaces = await client.get<{ slug: string; displayName: string; currentUserRole: string; status: string }[]>("/api/v1/me/namespaces"); + const isJson = program.opts().json; + if (isJson) { + console.log(JSON.stringify(namespaces, null, 2)); + } else { + if (!namespaces || namespaces.length === 0) { + console.log("No namespaces found."); + return; + } + for (const ns of namespaces) { + console.log(`${ns.slug} — ${ns.displayName} [${ns.currentUserRole}] (${ns.status})`); + } + } + } catch (e: any) { + error(`Failed to list namespaces: ${e.message}`); + process.exitCode = 1; + } + }); + + me + .command("submissions") + .description("List your review submissions") + .action(async () => { + try { + const token = await requireToken(); + const config = loadConfigFromProgram(program); + const client = new ApiClient({ baseUrl: config.registry, token, debug: program.opts().debug }); + const submissions = await client.get<{ id: number; skillSlug: string; skillDisplayName: string; namespace: string; version: string; status: string; createdAt: string }[]>("/api/v1/reviews/my-submissions"); + if (!submissions || submissions.length === 0) { + console.log("No review submissions."); + return; + } + for (const r of submissions) { + info(`${r.skillDisplayName} (${r.skillSlug})`); + dim(` ${r.namespace} · v${r.version} · ${r.status} · ${r.createdAt}`); + } + } catch (e: any) { + error(`Failed: ${e.message}`); + process.exitCode = 1; + } + }); +} diff --git a/skillhub-cli/src/commands/namespaces.ts b/skillhub-cli/src/commands/namespaces.ts new file mode 100644 index 000000000..db045de96 --- /dev/null +++ b/skillhub-cli/src/commands/namespaces.ts @@ -0,0 +1,35 @@ +import { Command } from "commander"; +import { ApiClient } from "../core/api-client.js"; +import { ApiRoutes, NamespaceResponse } from "../schema/routes.js"; +import { requireToken } from "../core/auth-token.js"; +import { loadConfig, loadConfigFromProgram } from "../core/config.js"; +import { error } from "../utils/logger.js"; + +export function registerNamespaces(program: Command) { + program + .command("namespaces") + .description("List namespaces you have access to") + .action(async () => { + try { + const token = await requireToken(); + const config = loadConfigFromProgram(program); + const client = new ApiClient({ baseUrl: config.registry, token, debug: program.opts().debug }); + const namespaces = await client.get(ApiRoutes.meNamespaces); + const isJson = program.opts().json; + if (isJson) { + console.log(JSON.stringify(namespaces, null, 2)); + } else { + if (!namespaces || namespaces.length === 0) { + console.log("No namespaces found."); + return; + } + for (const ns of namespaces) { + console.log(`${ns.slug} — ${ns.displayName} [${ns.currentUserRole}] (${ns.status})`); + } + } + } catch (e: any) { + error(`Failed to list namespaces: ${e.message}`); + process.exitCode = 1; + } + }); +} diff --git a/skillhub-cli/src/commands/notifications.ts b/skillhub-cli/src/commands/notifications.ts new file mode 100644 index 000000000..0e83a6bd3 --- /dev/null +++ b/skillhub-cli/src/commands/notifications.ts @@ -0,0 +1,77 @@ +import { Command } from "commander"; +import { ApiClient } from "../core/api-client.js"; +import { loadConfig, loadConfigFromProgram } from "../core/config.js"; +import { requireToken } from "../core/auth-token.js"; +import { success, error, info, dim } from "../utils/logger.js"; + +export interface Notification { + id: number; + title: string; + message: string; + read: boolean; + createdAt: string; +} + +export function registerNotifications(program: Command) { + const cmd = program + .command("notifications") + .alias("notif") + .description("Manage notifications"); + + cmd + .command("list") + .description("List notifications") + .option("--unread", "Show unread only") + .action(async (opts: { unread?: boolean }) => { + try { + const token = await requireToken(); + const config = loadConfigFromProgram(program); + const client = new ApiClient({ baseUrl: config.registry, token }); + const notifs = await client.get("/api/v1/notifications"); + const filtered = opts.unread ? notifs.filter((n) => !n.read) : notifs; + if (filtered.length === 0) { + console.log(opts.unread ? "No unread notifications." : "No notifications."); + return; + } + for (const n of filtered) { + info(`${n.read ? "✓" : "○"} ${n.title}`); + dim(` ${n.message} · ${n.createdAt}`); + } + } catch (e: any) { + error(`Failed: ${e.message}`); + process.exitCode = 1; + } + }); + + cmd + .command("read ") + .description("Mark notification as read") + .action(async (id: string) => { + try { + const token = await requireToken(); + const config = loadConfigFromProgram(program); + const client = new ApiClient({ baseUrl: config.registry, token }); + await client.put(`/api/v1/notifications/${id}/read`); + success(`Marked notification ${id} as read`); + } catch (e: any) { + error(`Failed: ${e.message}`); + process.exitCode = 1; + } + }); + + cmd + .command("read-all") + .description("Mark all notifications as read") + .action(async () => { + try { + const token = await requireToken(); + const config = loadConfigFromProgram(program); + const client = new ApiClient({ baseUrl: config.registry, token }); + await client.put("/api/v1/notifications/read-all"); + success("All notifications marked as read"); + } catch (e: any) { + error(`Failed: ${e.message}`); + process.exitCode = 1; + } + }); +} diff --git a/skillhub-cli/src/commands/publish.ts b/skillhub-cli/src/commands/publish.ts new file mode 100644 index 000000000..26e02aec9 --- /dev/null +++ b/skillhub-cli/src/commands/publish.ts @@ -0,0 +1,98 @@ +import { Command } from "commander"; +import { stat, readFile } from "node:fs/promises"; +import { basename, resolve } from "node:path"; +import { FormData } from "undici"; +import { ApiClient } from "../core/api-client.js"; +import { ApiRoutes, PublishResponse } from "../schema/routes.js"; +import { requireToken } from "../core/auth-token.js"; +import { loadConfig, loadConfigFromProgram } from "../core/config.js"; +import { success, error, info } from "../utils/logger.js"; +import ora from "ora"; +import semver from "semver"; + +export function registerPublish(program: Command) { + program + .command("publish [path]") + .description("Publish a skill to SkillHub registry") + .option("--namespace ", "Target namespace (default: global)") + .option("--slug ", "Skill slug") + .option("-v, --skill-version ", "Version (semver)") + .option("--name ", "Display name") + .option("--changelog ", "Changelog text") + .option("--tag ", "Comma-separated tags (e.g. beta,stable)", "latest") + .action(async (path: string | undefined, opts: Record) => { + const folder = path ? resolve(process.cwd(), path) : process.cwd(); + const folderStat = await stat(folder).catch(() => null); + if (!folderStat || !folderStat.isDirectory()) { + error("Path must be a directory containing SKILL.md"); + process.exitCode = 1; + } + + const slug = opts.slug || basename(folder); + let version = opts["skill-version"] || opts.ver; + if (!version) { + const now = new Date(); + const yyyymmdd = now.getFullYear() * 10000 + (now.getMonth() + 1) * 100 + now.getDate(); + const hhmmss = now.getHours() * 10000 + now.getMinutes() * 100 + now.getSeconds(); + version = `${yyyymmdd}.${hhmmss}`; + } + // Allow timestamp format (YYYYMMDD.HHMMSS) or standard semver + const isValidVersion = semver.valid(version) || /^\d{8}\.\d+$/.test(version); + if (!isValidVersion) { + error("--skill-version must be a valid semver (e.g. 1.0.0) or timestamp (e.g. 20260414.123045)"); + process.exitCode = 1; + } + + const namespace = opts.namespace || "global"; + const changelog = opts.changelog || ""; + const tags = opts.tag.split(",").map((t: string) => t.trim()).filter(Boolean); + + try { + const token = await requireToken(); + const config = loadConfigFromProgram(program); + const client = new ApiClient({ baseUrl: config.registry, token }); + + const spinner = ora(`Publishing ${slug}@${version} to ${namespace}`).start(); + + const skillMdPath = resolve(folder, "SKILL.md"); + const skillMdStat = await stat(skillMdPath).catch(() => null); + if (!skillMdStat) { + spinner.fail("SKILL.md not found in directory"); + process.exitCode = 1; + } + + const skillMdContent = await readFile(skillMdPath, "utf-8"); + + const form = new FormData(); + form.set("payload", JSON.stringify({ + slug, + displayName: opts.name || slug, + version, + changelog, + acceptLicenseTerms: true, + tags, + })); + const blob = new Blob([Buffer.from(skillMdContent)], { type: "text/markdown" }); + form.append("files", blob, "SKILL.md"); + + const result = await client.postForm( + ApiRoutes.skills, + form, + { namespace } + ); + + spinner.succeed(`Published ${slug}@${version} (${result.skillId})`); + const actualNamespace = result.namespace || namespace; + const isDefaultNamespace = actualNamespace === namespace && !opts.namespace; + info(`Namespace: ${actualNamespace}${isDefaultNamespace ? " (default)" : ""}`); + info(`Status: Published`); + info(`Tags: ${tags.join(", ")}`); + if (changelog) { + info(`Changelog: ${changelog}`); + } + } catch (e: any) { + error(`Publish failed: ${e.message}`); + process.exitCode = 1; + } + }); +} diff --git a/skillhub-cli/src/commands/rating.ts b/skillhub-cli/src/commands/rating.ts new file mode 100644 index 000000000..f4aec8988 --- /dev/null +++ b/skillhub-cli/src/commands/rating.ts @@ -0,0 +1,92 @@ +import { Command } from "commander"; +import { ApiClient } from "../core/api-client.js"; +import { loadConfig, loadConfigFromProgram } from "../core/config.js"; +import { requireToken } from "../core/auth-token.js"; +import { success, error, info, dim } from "../utils/logger.js"; +export function registerRating(program: Command) { + program + .command("rating") + .description("View your rating for a skill") + .argument("", "Skill name or namespace/skill-name") + .option("--namespace ", "Override namespace (default: parsed from skill or 'global')") + .action(async (slug: string, opts: { namespace?: string }) => { + try { + const { parseSkillNamespace } = await import("../core/skill-resolver.js"); + const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace); + const token = await requireToken(); + const config = loadConfigFromProgram(program); + const client = new ApiClient({ baseUrl: config.registry, token }); + + const detail = await client.get<{ id: number }>( + `/api/v1/skills/${namespace}/${skillSlug}` + ); + + const rating = await client.get<{ score: number; rated: boolean }>( + `/api/v1/skills/${detail.id}/rating` + ); + + if (rating.rated) { + info(`${skillSlug}: ${"★".repeat(rating.score)}${"☆".repeat(5 - rating.score)} (${rating.score}/5)`); + } else { + info(`${skillSlug}: Not rated yet`); + dim("Use: skillhub rate "); + } + } catch (e: any) { + const status = e.status || e.statusCode; + if (status === 404) { + error(`Skill not found: ${namespace}/${skillSlug}`); + if (!slug.includes("/")) { + dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push"); + } + } else { + error(`Failed: ${e.message}`); + } + process.exitCode = 1; + } + }); +} + +export function registerRate(program: Command) { + program + .command("rate") + .description("Rate a skill (1-5)") + .argument("", "Skill name or namespace/skill-name") + .argument("", "Rating score (1-5)") + .option("--namespace ", "Override namespace (default: parsed from skill or 'global')") + .action(async (slug: string, scoreStr: string, opts: { namespace?: string }) => { + const score = parseInt(scoreStr, 10); + if (isNaN(score) || score < 1 || score > 5) { + error("Score must be between 1 and 5"); + process.exitCode = 1; + } + + try { + const { parseSkillNamespace } = await import("../core/skill-resolver.js"); + const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace); + const token = await requireToken(); + const config = loadConfigFromProgram(program); + const client = new ApiClient({ baseUrl: config.registry, token }); + + const detail = await client.get<{ id: number }>( + `/api/v1/skills/${namespace}/${skillSlug}` + ); + + await client.put(`/api/v1/skills/${detail.id}/rating`, { + body: JSON.stringify({ score }), + headers: { "Content-Type": "application/json" }, + }); + success(`Rated ${skillSlug}: ${"★".repeat(score)}${"☆".repeat(5 - score)}`); + } catch (e: any) { + const status = e.status || e.statusCode; + if (status === 404) { + error(`Skill not found: ${namespace}/${skillSlug}`); + if (!slug.includes("/")) { + dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push"); + } + } else { + error(`Failed: ${e.message}`); + } + process.exitCode = 1; + } + }); +} diff --git a/skillhub-cli/src/commands/report.ts b/skillhub-cli/src/commands/report.ts new file mode 100644 index 000000000..b0740b75d --- /dev/null +++ b/skillhub-cli/src/commands/report.ts @@ -0,0 +1,49 @@ +import { Command } from "commander"; +import { createInterface } from "node:readline"; +import { ApiClient } from "../core/api-client.js"; +import { requireToken } from "../core/auth-token.js"; +import { loadConfig, loadConfigFromProgram } from "../core/config.js"; +import { success, error } from "../utils/logger.js"; +export function registerReport(program: Command) { + program + .command("report") + .description("Report a skill for review") + .argument("", "Skill name or namespace/skill-name") + .option("--reason ", "Report reason") + .option("--namespace ", "Override namespace (default: parsed from skill or 'global')") + .action(async (slug: string, opts: { reason?: string; namespace?: string }) => { + try { + const { parseSkillNamespace } = await import("../core/skill-resolver.js"); + const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace); + const token = await requireToken(); + const config = loadConfigFromProgram(program); + const client = new ApiClient({ baseUrl: config.registry, token }); + + let reason = opts.reason; + if (!reason) { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + reason = await new Promise((r) => + rl.question("Report reason: ", r) + ); + rl.close(); + } + + await client.post(`/api/v1/skills/${namespace}/${skillSlug}/reports`, { + body: JSON.stringify({ reason }), + headers: { "Content-Type": "application/json" }, + }); + success(`Report submitted for ${skillSlug}`); + } catch (e: any) { + const status = e.status || e.statusCode; + if (status === 404) { + error(`Skill not found: ${namespace}/${skillSlug}`); + if (!slug.includes("/")) { + dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push"); + } + } else { + error(`Failed: ${e.message}`); + } + process.exitCode = 1; + } + }); +} diff --git a/skillhub-cli/src/commands/resolve.ts b/skillhub-cli/src/commands/resolve.ts new file mode 100644 index 000000000..13deba671 --- /dev/null +++ b/skillhub-cli/src/commands/resolve.ts @@ -0,0 +1,181 @@ +import { Command } from "commander"; +import { ApiClient } from "../core/api-client.js"; +import { loadConfig, loadConfigFromProgram } from "../core/config.js"; +import { readToken } from "../core/auth-token.js"; +import { success, error, info, dim } from "../utils/logger.js"; +import { parseSkillName } from "../core/skill-name.js"; +import { runInteractiveSearch, searchSkills } from "../core/interactive-search.js"; + +export interface ResolveResponse { + skillId: number; + namespace: string; + slug: string; + version: string; + versionId: number; + fingerprint: string; + matched: string; + downloadUrl: string; +} + +interface VersionSearchResult { + namespace: string; + name: string; + exists: boolean; +} + +async function resolveWithVersion( + client: ApiClient, + namespace: string, + slug: string, + version: string +): Promise { + try { + const result = await client.get( + `/api/v1/skills/${namespace}/${slug}/resolve?version=${version}` + ); + return result; + } catch (e: any) { + const status = e.status || e.statusCode; + if (status === 404 || status === 400) return null; + throw e; + } +} + +export function registerResolve(program: Command) { + program + .command("resolve") + .description("Resolve the latest version of a skill") + .argument("", "Skill name or namespace/skill-name") + .option("-v, --skill-version ", "Specific version") + .option("--tag ", "Tag to resolve (default: latest, ignored if --skill-version)") + .option("--hash ", "Content hash") + .option("--namespace ", "Override namespace (default: parsed from skill or 'global')") + .action(async (slug: string, opts: Record) => { + try { + const { parseSkillNamespace } = await import("../core/skill-resolver.js"); + const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace); + const config = loadConfigFromProgram(program); + const token = await readToken(); + const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); + + let targetNamespace = namespace; + let targetSlug = skillSlug; + // Strip 'v' prefix if present (both 1.0.0 and v1.0.0 should work) + const specifiedVersion = opts.skillVersion?.replace(/^v/, ""); + + // Case 1: User specified a version + if (specifiedVersion) { + if (namespace && namespace !== "global") { + const result = await resolveWithVersion(client, namespace, skillSlug, specifiedVersion); + if (result) { + printResolveResult(result); + return; + } + error(`Version ${specifiedVersion} not found for ${namespace}/${skillSlug}`); + error(`Please check if the version number is correct.`); + process.exitCode = 1; + } + + const results = await searchSkills(client, skillSlug, 50); + const seen = new Set(); + const uniqueResults = results.filter((r) => { + const key = `${r.namespace}/${r.name}`; + if (!seen.has(key)) { + seen.add(key); + return true; + } + return false; + }); + + if (uniqueResults.length === 0) { + error(`Skill not found: ${skillSlug}`); + process.exitCode = 1; + } + + const resolvePromises = uniqueResults.map(async (r) => ({ + ...r, + result: await resolveWithVersion(client, r.namespace, r.name, specifiedVersion), + })); + const resolvedResults = await Promise.all(resolvePromises); + const matches = resolvedResults.filter((r) => r.result !== null); + + if (matches.length === 0) { + error(`Version ${specifiedVersion} not found for ${skillSlug}`); + error(`Please check if the version number is correct.`); + process.exitCode = 1; + } + + if (matches.length === 1) { + // Only one match, auto-select + printResolveResult(matches[0].result!); + return; + } + + // Multiple matches, list them for user to choose manually + info(`Found multiple skills with version ${specifiedVersion}:`); + for (const m of matches) { + console.log(` ${m.namespace}/${m.name}`); + } + dim(`\nUse: resolve / --skill-version ${specifiedVersion}`); + process.exitCode = 1; + } + + // Case 2: No version specified (original behavior) + if (namespace === "global") { + const results = await searchSkills(client, skillSlug, 50); + + const seen = new Set(); + const uniqueResults = results.filter((r) => { + const key = `${r.namespace}/${r.name}`; + if (!seen.has(key)) { + seen.add(key); + return true; + } + return false; + }); + + if (uniqueResults.length === 0) { + error(`Skill not found: ${skillSlug}`); + process.exitCode = 1; + } + + if (uniqueResults.length === 1) { + targetNamespace = uniqueResults[0].namespace; + targetSlug = uniqueResults[0].name; + } else { + const selected = await runInteractiveSearch(client, skillSlug); + if (!selected) { + info("Cancelled."); + return; + } + const [ns, name] = selected.split("/", 2); + targetNamespace = ns; + targetSlug = name; + } + } + + const params = new URLSearchParams(); + if (opts.tag) { + params.set("tag", opts.tag); + } + if (opts.hash) params.set("hash", opts.hash); + + const qs = params.toString(); + const path = `/api/v1/skills/${targetNamespace}/${targetSlug}/resolve${qs ? "?" + qs : ""}`; + const result = await client.get(path); + printResolveResult(result); + } catch (e: any) { + error(`Failed: ${e.message}`); + process.exitCode = 1; + } + }); +} + +function printResolveResult(result: ResolveResponse) { + info(`${result.slug}@${result.version}`); + dim(`Namespace: ${result.namespace}`); + dim(`Version ID: ${result.versionId}`); + dim(`Fingerprint: ${result.fingerprint}`); + dim(`Matched: ${result.matched}`); + dim(`Download URL: ${result.downloadUrl}`); +} diff --git a/skillhub-cli/src/commands/reviews.ts b/skillhub-cli/src/commands/reviews.ts new file mode 100644 index 000000000..745c6113d --- /dev/null +++ b/skillhub-cli/src/commands/reviews.ts @@ -0,0 +1,43 @@ +import { Command } from "commander"; +import { ApiClient } from "../core/api-client.js"; +import { requireToken } from "../core/auth-token.js"; +import { loadConfig, loadConfigFromProgram } from "../core/config.js"; +import { success, error, info, dim } from "../utils/logger.js"; + +export interface ReviewSubmission { + id: number; + skillSlug: string; + skillDisplayName: string; + namespace: string; + version: string; + status: string; + createdAt: string; +} + +export function registerReviews(program: Command) { + const reviews = program.command("reviews").description("Manage skill reviews"); + + reviews + .command("my") + .alias("submissions") + .description("List your review submissions") + .action(async () => { + try { + const token = await requireToken(); + const config = loadConfigFromProgram(program); + const client = new ApiClient({ baseUrl: config.registry, token }); + const submissions = await client.get("/api/v1/reviews/my-submissions"); + if (!submissions || submissions.length === 0) { + console.log("No review submissions."); + return; + } + for (const r of submissions) { + info(`${r.skillDisplayName} (${r.skillSlug})`); + dim(` ${r.namespace} · v${r.version} · ${r.status} · ${r.createdAt}`); + } + } catch (e: any) { + error(`Failed: ${e.message}`); + process.exitCode = 1; + } + }); +} diff --git a/skillhub-cli/src/commands/search.ts b/skillhub-cli/src/commands/search.ts new file mode 100644 index 000000000..fb7a3cc59 --- /dev/null +++ b/skillhub-cli/src/commands/search.ts @@ -0,0 +1,54 @@ +import { Command } from "commander"; +import { ApiClient } from "../core/api-client.js"; +import { ApiRoutes, SearchResponse } from "../schema/routes.js"; +import { loadConfig, loadConfigFromProgram } from "../core/config.js"; +import { readToken } from "../core/auth-token.js"; +import { error, dim } from "../utils/logger.js"; + +export function registerSearch(program: Command) { + program + .command("search ") + .description("[Deprecated: use 'explore' instead] Search for skills on SkillHub") + .option("-n, --limit ", "Max results", "20") + .option("--namespace ", "Filter by namespace") + .action(async (query: string[], opts: { limit: string; namespace?: string }) => { + const config = loadConfigFromProgram(program); + const token = await readToken(); + const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); + + const isJson = program.opts().json; + + try { + let searchUrl = `${ApiRoutes.search}?q=${encodeURIComponent(query.join(" "))}&limit=${opts.limit}`; + if (opts.namespace) { + searchUrl += `&namespace=${encodeURIComponent(opts.namespace)}`; + } + const result = await client.get(searchUrl); + if (!result.results || result.results.length === 0) { + if (isJson) { + console.log(JSON.stringify({ results: [] })); + } else { + console.log("No skills found."); + } + return; + } + + if (isJson) { + console.log(JSON.stringify(result, null, 2)); + } else { + const hasNamespaceFilter = !!opts.namespace; + for (const s of result.results) { + const ns = s.namespace ? `[${s.namespace}] ` : ''; + console.log(`${ns}${s.slug} (${s.version}) — ${s.displayName}`); + if (s.summary) console.log(` ${s.summary}`); + } + if (hasNamespaceFilter) { + dim(`\nTip: remove --namespace filter to search all namespaces`); + } + } + } catch (e: any) { + error(`Search failed: ${e.message}`); + process.exitCode = 1; + } + }); +} diff --git a/skillhub-cli/src/commands/star.ts b/skillhub-cli/src/commands/star.ts new file mode 100644 index 000000000..a42223dcd --- /dev/null +++ b/skillhub-cli/src/commands/star.ts @@ -0,0 +1,48 @@ +import { Command } from "commander"; +import { ApiClient } from "../core/api-client.js"; +import { ApiRoutes } from "../schema/routes.js"; +import { requireToken } from "../core/auth-token.js"; +import { loadConfig, loadConfigFromProgram } from "../core/config.js"; +import { success, error, dim } from "../utils/logger.js"; + + +export function registerStar(program: Command) { + program + .command("star") + .description("Star a skill") + .argument("", "Skill name or namespace/skill-name") + .option("--unstar", "Remove star") + .option("--namespace ", "Override namespace (default: parsed from skill or 'global')") + .action(async (slug: string, opts: { unstar: boolean; namespace?: string }) => { + try { + const { parseSkillNamespace } = await import("../core/skill-resolver.js"); + const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace); + const token = await requireToken(); + const config = loadConfigFromProgram(program); + const client = new ApiClient({ baseUrl: config.registry, token }); + + const detailPath = ApiRoutes.skillDetail.replace("{namespace}", namespace).replace("{slug}", skillSlug); + const detail = await client.get<{ id: number }>(detailPath); + + const starPath = `/api/v1/skills/${detail.id}/star`; + if (opts.unstar) { + await client.delete(starPath); + success(`Unstarred ${skillSlug}`); + } else { + await client.put(starPath); + success(`Starred ${skillSlug}`); + } + } catch (e: any) { + const status = e.status || e.statusCode; + if (status === 404) { + error(`Skill not found: ${namespace}/${skillSlug}`); + if (!slug.includes("/")) { + dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push"); + } + } else { + error(`Failed: ${e.message}`); + } + process.exitCode = 1; + } + }); +} diff --git a/skillhub-cli/src/commands/sync.ts b/skillhub-cli/src/commands/sync.ts new file mode 100644 index 000000000..cfb13002b --- /dev/null +++ b/skillhub-cli/src/commands/sync.ts @@ -0,0 +1,156 @@ +import { Command } from "commander"; +import { stat, readFile } from "node:fs/promises"; +import { basename, resolve } from "node:path"; +import { FormData } from "undici"; +import { existsSync } from "node:fs"; +import { discoverSkills } from "../core/skill-discovery.js"; +import { requireToken } from "../core/auth-token.js"; +import { loadConfig, loadConfigFromProgram } from "../core/config.js"; +import { ApiClient } from "../core/api-client.js"; +import { ApiRoutes } from "../schema/routes.js"; +import { info, dim, success, error } from "../utils/logger.js"; +import semver from "semver"; + +interface SyncResult { + name: string; + slug: string; + namespace: string; + success: boolean; + message?: string; +} + +export function registerSync(program: Command) { + program + .command("sync [path]") + .description("Scan and publish all skills from a directory") + .option("--namespace ", "Target namespace", "global") + .option("--all", "Include all skills (even with changes)") + .option("-y, --yes", "Skip confirmation") + .action(async (path: string | undefined, opts: { namespace: string; all?: boolean; yes?: boolean }) => { + const scanPath = path ? resolve(path) : process.cwd(); + + if (!existsSync(scanPath)) { + error(`Directory not found: ${scanPath}`); + process.exitCode = 1; + } + + try { + const token = await requireToken(); + const config = loadConfigFromProgram(program); + const client = new ApiClient({ baseUrl: config.registry, token }); + + info(`Scanning ${scanPath} for skills...`); + const skills = discoverSkills(scanPath); + + if (skills.length === 0) { + console.log("No skills found. Ensure directories contain SKILL.md files."); + return; + } + + console.log(""); + info(`Found ${skills.length} skill(s):`); + const sortedSkills = [...skills].sort((a, b) => a.name.localeCompare(b.name)); + for (const skill of sortedSkills) { + console.log(` - ${skill.name} (${skill.description})`); + } + console.log(""); + + if (!opts.yes) { + const { createInterface } = await import("node:readline"); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((r) => + rl.question(`Publish ${skills.length} skill(s) to ${opts.namespace}? [y/N] `, r) + ); + rl.close(); + if (answer.toLowerCase() !== "y") { + console.log("Cancelled."); + return; + } + } + + const results: SyncResult[] = []; + console.log(""); + + for (const skill of skills) { + const slug = skill.name; + + try { + info(`Publishing ${slug}...`); + + const version = generateVersion(); + + const skillMdPath = resolve(skill.dir, "SKILL.md"); + const skillMdStat = await stat(skillMdPath); + if (!skillMdStat) { + error(`SKILL.md not found in ${skill.dir}`); + results.push({ name: skill.name, slug, namespace: opts.namespace, success: false, message: "SKILL.md not found" }); + continue; + } + + const skillMdContent = await readFile(skillMdPath, "utf-8"); + + const form = new FormData(); + form.set("payload", JSON.stringify({ + slug, + displayName: skill.name, + version, + changelog: "Synced from local directory", + acceptLicenseTerms: true, + tags: ["latest"], + })); + const blob = new Blob([Buffer.from(skillMdContent)], { type: "text/markdown" }); + form.append("files", blob, "SKILL.md"); + + const publishResponse = await client.postForm<{ ok: boolean; skillId: string; versionId: string }>( + ApiRoutes.skills, + form, + { namespace: opts.namespace } + ); + + if (publishResponse.ok) { + success(`Published ${slug}@${version}`); + results.push({ name: skill.name, slug, namespace: opts.namespace, success: true }); + } else { + error(`Failed to publish ${slug}`); + results.push({ name: skill.name, slug, namespace: opts.namespace, success: false, message: "Server returned ok=false" }); + } + } catch (e: any) { + error(`Failed to publish ${slug}: ${e.message}`); + results.push({ name: skill.name, slug, namespace: opts.namespace, success: false, message: e.message }); + } + } + + console.log(""); + info("=== Sync Summary ==="); + const successCount = results.filter((r) => r.success).length; + const failCount = results.filter((r) => !r.success).length; + console.log(` Total: ${results.length}`); + console.log(` Success: ${successCount}`); + console.log(` Failed: ${failCount}`); + + if (failCount > 0) { + console.log(""); + dim("Failed skills:"); + for (const r of results.filter((r) => !r.success)) { + console.log(` - ${r.slug}: ${r.message}`); + } + } + + console.log(""); + } catch (e: any) { + error(`Sync failed: ${e.message}`); + process.exitCode = 1; + } + }); +} + +function generateVersion(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + const hours = String(now.getHours()).padStart(2, "0"); + const minutes = String(now.getMinutes()).padStart(2, "0"); + const seconds = String(now.getSeconds()).padStart(2, "0"); + return `${year}${month}${day}.${hours}${minutes}${seconds}`; +} diff --git a/skillhub-cli/src/commands/transfer.ts b/skillhub-cli/src/commands/transfer.ts new file mode 100644 index 000000000..cf79e3473 --- /dev/null +++ b/skillhub-cli/src/commands/transfer.ts @@ -0,0 +1,38 @@ +import { Command } from "commander"; +import { ApiClient } from "../core/api-client.js"; +import { ApiRoutes } from "../schema/routes.js"; +import { requireToken } from "../core/auth-token.js"; +import { loadConfig, loadConfigFromProgram } from "../core/config.js"; +import { success, error } from "../utils/logger.js"; + +export function registerTransfer(program: Command) { + program + .command("transfer ") + .description("Transfer ownership of a namespace to another user") + .option("-y, --yes", "Skip confirmation") + .action(async (namespace: string, newOwnerId: string, opts: { yes?: boolean }) => { + if (!opts.yes) { + const { createInterface } = await import("node:readline"); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((r) => + rl.question(`Transfer ownership of ${namespace} to ${newOwnerId}? [y/N] `, r) + ); + rl.close(); + if (answer.toLowerCase() !== "y") { + console.log("Cancelled."); + return; + } + } + + try { + const token = await requireToken(); + const config = loadConfigFromProgram(program); + const client = new ApiClient({ baseUrl: config.registry, token }); + await client.post(ApiRoutes.namespaceTransferOwnership.replace("{namespace}", namespace), { body: JSON.stringify({ newOwnerId }) }); + success(`Ownership of ${namespace} transferred to ${newOwnerId}`); + } catch (e: any) { + error(`Failed: ${e.message}`); + process.exitCode = 1; + } + }); +} \ No newline at end of file diff --git a/skillhub-cli/src/commands/uninstall.ts b/skillhub-cli/src/commands/uninstall.ts new file mode 100644 index 000000000..827fae7ba --- /dev/null +++ b/skillhub-cli/src/commands/uninstall.ts @@ -0,0 +1,384 @@ +import { Command } from "commander"; +import { existsSync, readdirSync, statSync, unlinkSync, rmdirSync, lstatSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { getAllAgents, isUniversalForScope, type AgentInfo } from "../core/agent-detector.js"; +import { success, info, dim } from "../utils/logger.js"; +import { removeFromLock } from "../core/skill-lock.js"; +import { discoverInstalledSkills as discoverSkillsWithStatus, type DiscoveredSkill } from "../core/skill-status.js"; +import { searchMultiselect, cancelSymbol } from "../utils/search-multiselect.js"; +import * as p from "@clack/prompts"; +import pc from "picocolors"; + +function removeDir(path: string) { + try { + const stat = lstatSync(path); + if (stat.isSymbolicLink()) { + unlinkSync(path); + } else if (stat.isDirectory()) { + for (const entry of readdirSync(path)) { + removeDir(join(path, entry)); + } + rmdirSync(path); + } else { + unlinkSync(path); + } + } catch {} +} + +async function uninstallSkill( + name: string, + agent: AgentInfo, + scope: "local" | "global", + yes: boolean +): Promise { + const home = homedir(); + let baseDir: string; + + if (scope === "global") { + if (isUniversalForScope(agent, true)) { + baseDir = join(home, ".agents/skills"); + } else { + baseDir = join(home, agent.globalSkillsDir || agent.skillsDir); + } + } else { + baseDir = join(process.cwd(), agent.skillsDir); + } + + const skillPath = join(baseDir, name); + + if (!existsSync(skillPath)) return false; + if (!statSync(skillPath).isDirectory()) return false; + + if (!yes) { + const confirmed = await p.confirm({ + message: `Uninstall ${name} from ${agent.name}?`, + initialValue: false, + }); + if (!confirmed) return false; + } + + removeDir(skillPath); + return true; +} + +function getSkillPath(skillName: string, agent: AgentInfo, scope: "global" | "local"): string | null { + const home = homedir(); + let baseDir: string; + + if (scope === "global") { + if (isUniversalForScope(agent, true)) { + baseDir = join(home, ".agents/skills"); + } else { + baseDir = join(home, agent.globalSkillsDir || agent.skillsDir); + } + } else { + baseDir = join(process.cwd(), agent.skillsDir); + } + + const skillPath = join(baseDir, skillName); + if (existsSync(skillPath) && statSync(skillPath).isDirectory()) { + return skillPath; + } + return null; +} + +function findAgentsWithSkill(skillName: string, scope: "global" | "local", agents: AgentInfo[]): AgentInfo[] { + return agents.filter((a) => getSkillPath(skillName, a, scope) !== null); +} + +export function registerUninstall(program: Command) { + program + .command("uninstall [skill]") + .alias("un") + .description("Uninstall a skill or all skills from local agent") + .option("-g, --global", "Uninstall from global scope") + .option("-a, --agent ", "Uninstall from specific agents") + .option("-y, --yes", "Skip confirmation") + .option("--all", "Uninstall all installed skills") + .action(async (name: string | undefined, opts: { global?: boolean; agent?: string[]; yes?: boolean; all?: boolean }) => { + let scope: "global" | "local" = opts.global ? "global" : "local"; + let scopeAll = false; + + if (!opts.global && !opts.agent) { + const scopeSelection = await p.select({ + message: "Which scope to uninstall from?", + options: [ + { value: "all", label: "All (global + project)" }, + { value: "global", label: "Global only" }, + { value: "project", label: "Project only" }, + ], + }); + + if (p.isCancel(scopeSelection)) { + console.log("Cancelled."); + return; + } + + if (scopeSelection === "global") { + scope = "global"; + } else if (scopeSelection === "project") { + scope = "local"; + } else if (scopeSelection === "all") { + scopeAll = true; + } + } + + const allAgents = getAllAgents(); + const isGlobal = scope === "global"; + const searchScopes = scopeAll ? ["local", "global"] : [scope]; + + const discoveredSkills = await discoverSkillsWithStatus(searchScopes); + const installedSkills = discoveredSkills.filter( + (s) => s.status === "managed" || s.status === "orphaned" + ); + + if (opts.all) { + if (installedSkills.length === 0) { + dim("No skills installed."); + return; + } + + const selected = await searchMultiselect({ + message: "Select skills to uninstall", + items: installedSkills.map((s) => ({ + value: s.name, + label: s.status === "orphaned" ? `${s.name} [orphaned]` : s.name, + })), + required: true, + }); + + if (selected === cancelSymbol) { + console.log("Cancelled."); + return; + } + + const selectedSkills = selected as string[]; + const results: { skill: string; agent: string; path: string; ok: boolean }[] = []; + + for (const skillName of selectedSkills) { + for (const searchScope of searchScopes) { + const agentsWithSkill = findAgentsWithSkill(skillName, searchScope as "global" | "local", allAgents); + for (const agent of agentsWithSkill) { + const ok = await uninstallSkill(skillName, agent, searchScope as "global" | "local", true); + const skillPath = getSkillPath(skillName, agent, searchScope as "global" | "local"); + results.push({ skill: skillName, agent: agent.name, path: skillPath || "", ok }); + } + } + await removeFromLock(skillName); + } + + printUninstallResults(results); + return; + } + + if (!name) { + if (installedSkills.length === 0) { + dim("No skills installed."); + return; + } + + const selected = await searchMultiselect({ + message: "Select skills to uninstall", + items: installedSkills.map((s) => ({ + value: s.name, + label: s.status === "orphaned" ? `${s.name} [orphaned]` : s.name, + })), + required: true, + }); + + if (selected === cancelSymbol) { + console.log("Cancelled."); + return; + } + + const selectedSkills = selected as string[]; + const results: { skill: string; agent: string; path: string; ok: boolean }[] = []; + + for (const skillName of selectedSkills) { + for (const searchScope of searchScopes) { + const agentsWithSkill = findAgentsWithSkill(skillName, searchScope as "global" | "local", allAgents); + for (const agent of agentsWithSkill) { + const ok = await uninstallSkill(skillName, agent, searchScope as "global" | "local", !!opts.yes); + const skillPath = getSkillPath(skillName, agent, searchScope as "global" | "local"); + results.push({ skill: skillName, agent: agent.name, path: skillPath || "", ok }); + } + } + await removeFromLock(skillName); + } + + printUninstallResults(results); + return; + } + + let agentsWithSkill = findAgentsWithSkill(name, scope, allAgents); + + if (agentsWithSkill.length === 0 && !scopeAll) { + agentsWithSkill = findAgentsWithSkill(name, scope === "global" ? "local" : "global", allAgents); + if (agentsWithSkill.length > 0) { + const otherScope = scope === "global" ? "project" : "global"; + dim(`Skill "${name}" not found in ${scope}, but found in ${otherScope}.`); + } else { + info(`Skill "${name}" not found.`); + return; + } + } + + if (agentsWithSkill.length === 0 && scopeAll) { + agentsWithSkill = [ + ...findAgentsWithSkill(name, "global", allAgents), + ...findAgentsWithSkill(name, "local", allAgents), + ]; + if (agentsWithSkill.length === 0) { + info(`Skill "${name}" not found.`); + return; + } + } + + // Dynamic universal grouping based on scope + const universalAgents = allAgents + .filter((a) => isUniversalForScope(a, isGlobal) && a.showInUniversalList !== false) + .sort((a, b) => a.name.localeCompare(b.name)); + const nonUniversalAgents = allAgents + .filter((a) => !isUniversalForScope(a, isGlobal)) + .sort((a, b) => a.name.localeCompare(b.name)); + + const canonicalLabel = isGlobal ? "Universal (~/.agents/skills)" : "Universal (.agents/skills)"; + const universalSection = { + title: canonicalLabel, + items: universalAgents + .filter((a) => agentsWithSkill.some((w) => w.key === a.key)) + .map((a) => ({ + value: a.key, + label: a.name, + })), + }; + + const selectableItems = nonUniversalAgents + .filter((a) => agentsWithSkill.some((w) => w.key === a.key)) + .map((a) => ({ + value: a.key, + label: a.name, + })); + + if (selectableItems.length === 0 && universalSection.items.length === 0) { + info(`Skill "${name}" not found.`); + return; + } + + const selected = await searchMultiselect({ + message: `Uninstall ${name} from which agents?`, + items: selectableItems, + lockedSection: universalSection.items.length > 0 ? universalSection : undefined, + }); + + if (selected === cancelSymbol) { + console.log("Cancelled."); + return; + } + + const selectedAgentKeys = selected as string[]; + const pathToAgents = new Map(); + + for (const agentKey of selectedAgentKeys) { + const agent = allAgents.find((a) => a.key === agentKey); + if (agent) { + if (scopeAll) { + const okGlobal = await uninstallSkill(name, agent, "global", !!opts.yes); + const okLocal = await uninstallSkill(name, agent, "local", !!opts.yes); + if (okGlobal) { + const skillPath = getSkillPath(name, agent, "global"); + if (skillPath) { + const agents = pathToAgents.get(skillPath) || []; + agents.push(agent.name); + pathToAgents.set(skillPath, agents); + } + } + if (okLocal) { + const skillPath = getSkillPath(name, agent, "local"); + if (skillPath) { + const agents = pathToAgents.get(skillPath) || []; + agents.push(agent.name); + pathToAgents.set(skillPath, agents); + } + } + } else { + const ok = await uninstallSkill(name, agent, scope, !!opts.yes); + if (ok) { + const skillPath = getSkillPath(name, agent, scope); + if (skillPath) { + const agents = pathToAgents.get(skillPath) || []; + agents.push(agent.name); + pathToAgents.set(skillPath, agents); + } + } + } + } + } + + if (pathToAgents.size > 0) { + const lines: string[] = []; + const sortedEntries = [...pathToAgents.entries()].sort((a, b) => a[0].localeCompare(b[0])); + for (const [path, agents] of sortedEntries) { + const sorted = agents.sort((a, b) => a.localeCompare(b)); + const label = sorted.length <= 5 + ? sorted.join(", ") + : sorted.slice(0, 5).join(", ") + ` ${pc.dim(`+${sorted.length - 5}`)}`; + lines.push(` ${pc.dim("→")} ${label}: ${pc.dim(path)}`); + } + success(`Uninstalled ${name} from ${selectedAgentKeys.length} agent(s):`); + console.log(lines.join("\n")); + await removeFromLock(name); + } else { + info(`Skill "${name}" not found.`); + } + }); +} + +/** + * Print uninstall results grouped by skill and path (consistent with install output format). + */ +function printUninstallResults(results: { skill: string; agent: string; path: string; ok: boolean }[]) { + const successful = results.filter((r) => r.ok); + if (successful.length === 0) { + info("No skills were uninstalled."); + return; + } + + // Group by skill + const skillGroups = new Map(); + for (const r of successful) { + let group = skillGroups.get(r.skill); + if (!group) { + group = []; + skillGroups.set(r.skill, group); + } + group.push({ agent: r.agent, path: r.path }); + } + + const lines: string[] = []; + const sortedSkills = [...skillGroups.entries()].sort((a, b) => a[0].localeCompare(b[0])); + + for (const [skillName, entries] of sortedSkills) { + lines.push(`${pc.green("✓")} ${skillName}`); + + // Group by path + const pathGroups = new Map(); + for (const e of entries) { + const agents = pathGroups.get(e.path) || []; + agents.push(e.agent); + pathGroups.set(e.path, agents); + } + + const sortedPaths = [...pathGroups.entries()].sort((a, b) => a[0].localeCompare(b[0])); + for (const [path, agents] of sortedPaths) { + const sorted = agents.sort((a, b) => a.localeCompare(b)); + const label = sorted.length <= 5 + ? sorted.join(", ") + : sorted.slice(0, 5).join(", ") + ` ${pc.dim(`+${sorted.length - 5}`)}`; + lines.push(` ${pc.dim("→")} ${label}: ${pc.dim(path)}`); + } + } + + p.note(lines.join("\n"), `Uninstalled ${successful.length} skill(s)`); +} diff --git a/skillhub-cli/src/commands/update.ts b/skillhub-cli/src/commands/update.ts new file mode 100644 index 000000000..c0ec3eae7 --- /dev/null +++ b/skillhub-cli/src/commands/update.ts @@ -0,0 +1,224 @@ +import { Command } from "commander"; +import { success, error, info, warn, dim } from "../utils/logger.js"; +import { getAllLockedSkills, getSkillLockPath, type SkillLockEntry } from "../core/skill-lock.js"; +import { existsSync } from "node:fs"; +import { execSync } from "node:child_process"; +import { searchMultiselect, cancelSymbol } from "../utils/search-multiselect.js"; +import { ApiClient } from "../core/api-client.js"; +import { loadConfigFromProgram } from "../core/config.js"; +import { readToken } from "../core/auth-token.js"; +import * as p from "@clack/prompts"; +import ora from "ora"; + +function getCliCommand(): string { + const cliPath = process.argv[1]; + return `node "${cliPath}"`; +} + +interface UpdateInfo { + name: string; + currentVersion: string; + latestVersion: string; + namespace: string; + slug: string; + source: string; + sourceType: string; + hasUpdate: boolean; +} + +export function registerUpdate(program: Command) { + program + .command("update [skill]") + .alias("up") + .description("Update installed skills from their source") + .option("-a, --all", "Update all installed skills") + .option("-g, --global", "Update global scope skills") + .option("-y, --yes", "Skip confirmation and update all outdated skills") + .action(async (slug: string | undefined, opts: Record) => { + const lockPath = getSkillLockPath(); + + if (!existsSync(lockPath)) { + error("No skillhub.lock found. Have you installed any skills?"); + process.exitCode = 1; + return; + } + + const lockedSkills = await getAllLockedSkills(); + const allSkillNames = Object.keys(lockedSkills).sort((a, b) => a.localeCompare(b)); + + if (allSkillNames.length === 0) { + error("No skills in lock file."); + process.exitCode = 1; + return; + } + + const config = loadConfigFromProgram(program); + const token = await readToken(); + const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); + + let skillsToCheck: string[] = []; + + if (opts.all) { + skillsToCheck = allSkillNames; + } else if (slug) { + if (!lockedSkills[slug]) { + error(`Skill not found in lock file: ${slug}`); + error(`Installed skills: ${allSkillNames.join(", ")}`); + process.exitCode = 1; + return; + } + skillsToCheck = [slug]; + } else { + const selected = await searchMultiselect({ + message: "Select skills to check for updates", + items: allSkillNames.map((name) => ({ + value: name, + label: name, + hint: `${lockedSkills[name].namespace}/${lockedSkills[name].slug} @ ${lockedSkills[name].version}`, + })), + required: true, + }); + + if (selected === cancelSymbol) { + console.log("Cancelled."); + return; + } + + skillsToCheck = selected as string[]; + } + + const spinner = ora("Checking for updates...").start(); + const updates: UpdateInfo[] = []; + const upToDate: string[] = []; + const checkFailed: string[] = []; + + for (const name of skillsToCheck) { + const entry = lockedSkills[name]; + if (!entry) continue; + + if (entry.sourceType !== "registry") { + checkFailed.push(name); + continue; + } + + try { + const versionsResp = await client.get<{ items: Array<{ version: string }> }>( + `/api/v1/skills/${entry.namespace}/${entry.slug}/versions` + ); + const versions = versionsResp.items || []; + + if (versions.length === 0) { + checkFailed.push(name); + continue; + } + + const latestVersion = versions[0].version; + const currentVersion = entry.version; + + if (latestVersion === currentVersion) { + upToDate.push(name); + } else { + updates.push({ + name, + currentVersion, + latestVersion, + namespace: entry.namespace, + slug: entry.slug, + source: entry.source, + sourceType: entry.sourceType, + hasUpdate: true, + }); + } + } catch (e: any) { + checkFailed.push(name); + } + } + + spinner.stop(); + + if (upToDate.length > 0) { + console.log(""); + info(`Up to date (${upToDate.length}):`); + for (const name of upToDate) { + dim(` ✓ ${name} @ ${lockedSkills[name].version}`); + } + } + + if (checkFailed.length > 0) { + console.log(""); + warn(`Check failed (${checkFailed.length}):`); + for (const name of checkFailed) { + dim(` ✗ ${name}`); + } + } + + if (updates.length === 0) { + console.log(""); + success("All skills are up to date!"); + return; + } + + console.log(""); + info(`Updates available (${updates.length}):`); + for (const u of updates) { + console.log(` ↑ ${u.name}: ${u.currentVersion} → ${u.latestVersion}`); + } + + let skillsToUpdate = updates; + + if (!opts.yes && !opts.all && !slug) { + const selected = await searchMultiselect({ + message: "Select skills to update", + items: updates.map((u) => ({ + value: u.name, + label: u.name, + hint: `${u.currentVersion} → ${u.latestVersion}`, + })), + required: true, + }); + + if (selected === cancelSymbol) { + console.log("Cancelled."); + return; + } + + skillsToUpdate = updates.filter((u) => (selected as string[]).includes(u.name)); + } + + if (!opts.yes) { + const confirmed = await p.confirm({ + message: `Update ${skillsToUpdate.length} skill(s)?`, + }); + + if (p.isCancel(confirmed) || !confirmed) { + console.log("Cancelled."); + return; + } + } + + const scope = opts.global ? "--global" : ""; + const cliCmd = getCliCommand(); + + let updated = 0; + let failed = 0; + + for (const skill of skillsToUpdate) { + try { + info(`Updating ${skill.name} from ${skill.currentVersion} to ${skill.latestVersion}...`); + const cmd = `${cliCmd} install ${skill.namespace}/${skill.slug} --skill-version ${skill.latestVersion} ${scope}`.trim(); + execSync(cmd, { stdio: "inherit" }); + updated++; + } catch (e: any) { + error(`Failed to update ${skill.name}: ${e.message}`); + failed++; + } + } + + console.log(""); + if (failed === 0) { + success(`Updated ${updated} skill(s)`); + } else { + warn(`Updated ${updated}, failed ${failed}`); + } + }); +} diff --git a/skillhub-cli/src/commands/whoami.ts b/skillhub-cli/src/commands/whoami.ts new file mode 100644 index 000000000..cfcb50e52 --- /dev/null +++ b/skillhub-cli/src/commands/whoami.ts @@ -0,0 +1,30 @@ +import { Command } from "commander"; +import { ApiClient } from "../core/api-client.js"; +import { ApiRoutes, WhoamiResponse } from "../schema/routes.js"; +import { requireToken } from "../core/auth-token.js"; +import { success, error } from "../utils/logger.js"; +import { loadConfig, loadConfigFromProgram } from "../core/config.js"; + +export function registerWhoami(program: Command) { + program + .command("whoami") + .description("Show current authenticated user") + .action(async () => { + try { + const token = await requireToken(); + const config = loadConfigFromProgram(program); + const client = new ApiClient({ baseUrl: config.registry, token, debug: program.opts().debug }); + const resp = await client.get(ApiRoutes.whoami); + const isJson = program.opts().json; + if (isJson) { + console.log(JSON.stringify(resp, null, 2)); + } else { + console.log(`Handle: ${resp.user.handle}`); + console.log(`Display Name: ${resp.user.displayName}`); + } + } catch (e: any) { + error(e.message); + process.exitCode = 1; + } + }); +} diff --git a/skillhub-cli/src/core/agent-detector.ts b/skillhub-cli/src/core/agent-detector.ts new file mode 100644 index 000000000..e3a67f5be --- /dev/null +++ b/skillhub-cli/src/core/agent-detector.ts @@ -0,0 +1,146 @@ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export interface AgentInfo { + key: string; + name: string; + skillsDir: string; + globalSkillsDir?: string; + /** Whether to show this agent in the Universal section of interactive prompts. + * Agents with skillsDir === ".agents/skills" are universal by default, + * but some (like "replit" for cloud environments) should be hidden. */ + showInUniversalList?: boolean; +} + +const home = homedir(); + +const AGENTS: AgentInfo[] = [ + // Universal agents (.agents/skills) — share the canonical .agents/skills directory + { key: "amp", name: "Amp", skillsDir: ".agents/skills", globalSkillsDir: ".config/agents/skills" }, + { key: "antigravity", name: "Antigravity", skillsDir: ".agents/skills", globalSkillsDir: ".gemini/antigravity/skills" }, + { key: "cline", name: "Cline", skillsDir: ".agents/skills", globalSkillsDir: ".agents/skills" }, + { key: "codex", name: "Codex", skillsDir: ".agents/skills", globalSkillsDir: ".codex/skills" }, + { key: "cursor", name: "Cursor", skillsDir: ".agents/skills", globalSkillsDir: ".cursor/skills" }, + { key: "deepagents", name: "Deep Agents", skillsDir: ".agents/skills", globalSkillsDir: ".deepagents/agent/skills" }, + { key: "firebender", name: "Firebender", skillsDir: ".agents/skills", globalSkillsDir: ".firebender/skills" }, + { key: "gemini-cli", name: "Gemini CLI", skillsDir: ".agents/skills", globalSkillsDir: ".gemini/skills" }, + { key: "github-copilot", name: "GitHub Copilot", skillsDir: ".agents/skills", globalSkillsDir: ".copilot/skills" }, + { key: "kimi-cli", name: "Kimi Code CLI", skillsDir: ".agents/skills", globalSkillsDir: ".config/agents/skills" }, + { key: "opencode", name: "OpenCode", skillsDir: ".agents/skills", globalSkillsDir: ".config/opencode/skills" }, + { key: "warp", name: "Warp", skillsDir: ".agents/skills", globalSkillsDir: ".agents/skills" }, + // Universal agents hidden from the interactive list + { key: "replit", name: "Replit", skillsDir: ".agents/skills", globalSkillsDir: ".config/agents/skills", showInUniversalList: false }, + { key: "universal", name: "Universal", skillsDir: ".agents/skills", globalSkillsDir: ".config/agents/skills", showInUniversalList: false }, + + // Agent-specific path agents (non-universal) + { key: "claude-code", name: "Claude Code", skillsDir: ".claude/skills", globalSkillsDir: ".claude/skills" }, + { key: "augment", name: "Augment", skillsDir: ".augment/skills", globalSkillsDir: ".augment/skills" }, + { key: "bob", name: "IBM Bob", skillsDir: ".bob/skills", globalSkillsDir: ".bob/skills" }, + { key: "openclaw", name: "OpenClaw", skillsDir: "skills", globalSkillsDir: ".openclaw/skills" }, + { key: "codebuddy", name: "CodeBuddy", skillsDir: ".codebuddy/skills", globalSkillsDir: ".codebuddy/skills" }, + { key: "command-code", name: "Command Code", skillsDir: ".commandcode/skills", globalSkillsDir: ".commandcode/skills" }, + { key: "continue", name: "Continue", skillsDir: ".continue/skills", globalSkillsDir: ".continue/skills" }, + { key: "cortex", name: "Cortex Code", skillsDir: ".cortex/skills", globalSkillsDir: ".snowflake/cortex/skills" }, + { key: "crush", name: "Crush", skillsDir: ".crush/skills", globalSkillsDir: ".config/crush/skills" }, + { key: "droid", name: "Droid", skillsDir: ".factory/skills", globalSkillsDir: ".factory/skills" }, + { key: "goose", name: "Goose", skillsDir: ".goose/skills", globalSkillsDir: ".config/goose/skills" }, + { key: "junie", name: "Junie", skillsDir: ".junie/skills", globalSkillsDir: ".junie/skills" }, + { key: "iflow-cli", name: "iFlow CLI", skillsDir: ".iflow/skills", globalSkillsDir: ".iflow/skills" }, + { key: "kilo", name: "Kilo Code", skillsDir: ".kilocode/skills", globalSkillsDir: ".kilocode/skills" }, + { key: "kiro-cli", name: "Kiro CLI", skillsDir: ".kiro/skills", globalSkillsDir: ".kiro/skills" }, + { key: "kode", name: "Kode", skillsDir: ".kode/skills", globalSkillsDir: ".kode/skills" }, + { key: "mcpjam", name: "MCPJam", skillsDir: ".mcpjam/skills", globalSkillsDir: ".mcpjam/skills" }, + { key: "mistral-vibe", name: "Mistral Vibe", skillsDir: ".vibe/skills", globalSkillsDir: ".vibe/skills" }, + { key: "mux", name: "Mux", skillsDir: ".mux/skills", globalSkillsDir: ".mux/skills" }, + { key: "openhands", name: "OpenHands", skillsDir: ".openhands/skills", globalSkillsDir: ".openhands/skills" }, + { key: "pi", name: "Pi", skillsDir: ".pi/skills", globalSkillsDir: ".pi/agent/skills" }, + { key: "qoder", name: "Qoder", skillsDir: ".qoder/skills", globalSkillsDir: ".qoder/skills" }, + { key: "qwen-code", name: "Qwen Code", skillsDir: ".qwen/skills", globalSkillsDir: ".qwen/skills" }, + { key: "roo", name: "Roo Code", skillsDir: ".roo/skills", globalSkillsDir: ".roo/skills" }, + { key: "trae", name: "Trae", skillsDir: ".trae/skills", globalSkillsDir: ".trae/skills" }, + { key: "trae-cn", name: "Trae CN", skillsDir: ".trae/skills", globalSkillsDir: ".trae-cn/skills" }, + { key: "windsurf", name: "Windsurf", skillsDir: ".windsurf/skills", globalSkillsDir: ".codeium/windsurf/skills" }, + { key: "zencoder", name: "Zencoder", skillsDir: ".zencoder/skills", globalSkillsDir: ".zencoder/skills" }, + { key: "neovate", name: "Neovate", skillsDir: ".neovate/skills", globalSkillsDir: ".neovate/skills" }, + { key: "pochi", name: "Pochi", skillsDir: ".pochi/skills", globalSkillsDir: ".pochi/skills" }, + { key: "adal", name: "AdaL", skillsDir: ".adal/skills", globalSkillsDir: ".adal/skills" }, +]; + +export function getAllAgents(): AgentInfo[] { + return AGENTS; +} + +export function detectInstalledAgents(): AgentInfo[] { + return AGENTS.filter((agent) => { + const globalPath = agent.globalSkillsDir ? join(home, agent.globalSkillsDir) : join(home, agent.skillsDir); + return existsSync(globalPath); + }); +} + +export function getAgentByKey(key: string): AgentInfo | undefined { + return AGENTS.find((a) => a.key === key); +} + +const CANONICAL_SKILLS_DIR = ".agents/skills"; + +/** + * Check if an agent uses the canonical .agents/skills directory at the project level. + * Used for UI grouping (Universal section in interactive prompts). + */ +export function isUniversalAgent(agent: AgentInfo): boolean { + return agent.skillsDir === CANONICAL_SKILLS_DIR; +} + +/** + * Get the target installation directory for an agent in the given scope. + * In global scope, uses globalSkillsDir if defined, otherwise falls back to skillsDir. + * In project scope, always uses skillsDir. + */ +export function getAgentTargetDir(agent: AgentInfo, isGlobal: boolean): string { + return isGlobal + ? (agent.globalSkillsDir || agent.skillsDir) + : agent.skillsDir; +} + +/** + * Dynamically determine if an agent is "universal" for the given scope. + * An agent is universal when its target installation directory equals the canonical + * .agents/skills directory — meaning no symlink is needed because the canonical + * location IS the agent's own directory. + * + * This differs from isUniversalAgent() which only checks project-level skillsDir. + * For example, Codex has skillsDir=".agents/skills" (universal at project level) + * but globalSkillsDir=".codex/skills" (NOT universal at global level — needs symlink). + */ +export function isUniversalForScope(agent: AgentInfo, isGlobal: boolean): boolean { + return getAgentTargetDir(agent, isGlobal) === CANONICAL_SKILLS_DIR; +} + +/** + * Returns universal agents that should appear in the interactive selection list. + * Excludes agents with showInUniversalList === false (e.g. replit, which is cloud-only). + */ +export function getUniversalAgents(): AgentInfo[] { + return AGENTS.filter((a) => isUniversalAgent(a) && a.showInUniversalList !== false); +} + +export function getNonUniversalAgents(): AgentInfo[] { + return AGENTS.filter((a) => !isUniversalAgent(a)); +} + +/** + * Ensure that all universal agents are included in the target agent list. + * This guarantees that skills are always installed to ~/.agents/skills (the canonical location), + * making them available to any agent that reads from that directory. + */ +export function ensureUniversalAgents(targetAgents: AgentInfo[]): AgentInfo[] { + const universalAgents = getUniversalAgents(); + const result = [...targetAgents]; + for (const ua of universalAgents) { + if (!result.some((a) => a.key === ua.key)) { + result.push(ua); + } + } + return result; +} diff --git a/skillhub-cli/src/core/api-client.ts b/skillhub-cli/src/core/api-client.ts new file mode 100644 index 000000000..bbea37d19 --- /dev/null +++ b/skillhub-cli/src/core/api-client.ts @@ -0,0 +1,172 @@ +import { request, FormData as UndiciFormData } from "undici"; + +export interface ApiClientOptions { + baseUrl: string; + token?: string; +} + +interface NativeApiResponse { + code: number; + msg: string; + data: T; + timestamp: string; +} + +export class ApiClient { + constructor(private options: ApiClientOptions) {} + + /** + * Unwrap Native API response format: + * { code: 0, msg: "success", data: T } -> returns T + * { code: non-zero, msg: "error", data: null } -> throws ApiError + * + * Pass-through Compat API format (no code field): + * { user: {...} } -> returns as-is + * { results: [...] } -> returns as-is + */ + private unwrapResponse(data: unknown): T { + // Check if it's a Native API response (has code field) + if (typeof data === "object" && data !== null && "code" in data) { + const native = data as NativeApiResponse; + if (native.code !== 0) { + throw new ApiError(native.code, native); + } + return native.data as T; + } + // Otherwise it's a Compat API response, return as-is + return data as T; + } + + async get(path: string): Promise { + const url = new URL(path, this.options.baseUrl); + const { statusCode, body } = await request(url.toString(), { + method: "GET", + headers: this.headers(), + }); + const data = await body.json(); + if (statusCode >= 400) { + throw new ApiError(statusCode, data); + } + return this.unwrapResponse(data); + } + + async postForm(path: string, form: UndiciFormData, queryParams?: Record): Promise { + const url = new URL(path, this.options.baseUrl); + if (queryParams) { + for (const [k, v] of Object.entries(queryParams)) { + url.searchParams.set(k, v); + } + } + const { statusCode, body } = await request(url.toString(), { + method: "POST", + headers: { + ...this.headers(), + }, + body: form, + }); + const data = await body.json(); + if (statusCode >= 400) { + throw new ApiError(statusCode, data); + } + return this.unwrapResponse(data); + } + + async post(path: string, opts?: { body?: string; headers?: Record }): Promise { + const url = new URL(path, this.options.baseUrl); + const { statusCode, body } = await request(url.toString(), { + method: "POST", + headers: { ...this.headers(), ...opts?.headers }, + body: opts?.body, + }); + const data = await body.json(); + if (statusCode >= 400) { + throw new ApiError(statusCode, data); + } + return this.unwrapResponse(data); + } + + async put(path: string, opts?: { body?: string; headers?: Record }): Promise { + const url = new URL(path, this.options.baseUrl); + const { statusCode, body } = await request(url.toString(), { + method: "PUT", + headers: { ...this.headers(), ...opts?.headers }, + body: opts?.body, + }); + const data = await body.json(); + if (statusCode >= 400) { + throw new ApiError(statusCode, data); + } + return this.unwrapResponse(data); + } + + async delete(path: string): Promise { + const url = new URL(path, this.options.baseUrl); + const { statusCode, body } = await request(url.toString(), { + method: "DELETE", + headers: this.headers(), + }); + const data = await body.json(); + if (statusCode >= 400) { + throw new ApiError(statusCode, data); + } + return this.unwrapResponse(data); + } + + private headers(): Record { + const h: Record = {}; + if (this.options.token) { + h["Authorization"] = `Bearer ${this.options.token}`; + } + return h; + } +} + +function extractHumanMessage(body: unknown): string | null { + if (typeof body !== "object" || body === null) return null; + + const b = body as Record; + + // Native API: { code, msg, data } — "msg" is authoritative + if (typeof b.msg === "string" && b.msg.length > 0) return b.msg; + if (typeof b.message === "string" && b.message.length > 0) return b.message; + if (typeof b.error === "string" && b.error.length > 0) return b.error; + if (typeof b.detail === "string" && b.detail.length > 0) return b.detail; + if (typeof b.reason === "string" && b.reason.length > 0) return b.reason; + if (typeof b.description === "string" && b.description.length > 0) return b.description; + + if (typeof b.data === "string" && b.data.length > 0) return b.data; + + return null; +} + +export class ApiError extends Error { + constructor( + public statusCode: number, + public body: unknown, + ) { + const msg = extractHumanMessage(body); + let detail = msg ?? `HTTP ${statusCode}`; + + if (statusCode === 401) { + detail += "\nRun `skillhub login` to authenticate."; + } + + // Enhanced error messages for connection issues + if (statusCode === 0 || detail.includes("ECONNREFUSED") || detail.includes("ENOTFOUND")) { + detail += "\n\n💡 Connection failed. Check your registry configuration:\n"; + detail += " - Run 'skillhub config list' to see current configuration\n"; + detail += " - Run 'skillhub config show-env-instructions' for setup guide\n"; + detail += " - Or use: skillhub --registry "; + } + + // Enhanced error messages for 403 Forbidden + if (statusCode === 403) { + detail += "\n\n💡 Access denied. This could mean:\n"; + detail += " - Your account doesn't have permission to access this resource\n"; + detail += " - Contact your administrator if you believe this is an error\n"; + detail += " - Run 'skillhub whoami' to verify your account"; + } + + super(detail); + } +} diff --git a/skillhub-cli/src/core/auth-token.ts b/skillhub-cli/src/core/auth-token.ts new file mode 100644 index 000000000..3ab111835 --- /dev/null +++ b/skillhub-cli/src/core/auth-token.ts @@ -0,0 +1,39 @@ +import { readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, dirname } from "node:path"; +import { mkdir } from "node:fs/promises"; + +const TOKEN_DIR = join(homedir(), ".skillhub"); +const TOKEN_FILE = join(TOKEN_DIR, "token"); + +export async function readToken(): Promise { + if (!existsSync(TOKEN_FILE)) return null; + return readFileSync(TOKEN_FILE, "utf-8").trim(); +} + +export async function writeToken(token: string): Promise { + if (!existsSync(TOKEN_DIR)) { + await mkdir(TOKEN_DIR, { recursive: true }); + } + writeFileSync(TOKEN_FILE, token); + try { + chmodSync(TOKEN_FILE, 0o600); + } catch { + // Permission change not critical + } +} + +export async function removeToken(): Promise { + if (existsSync(TOKEN_FILE)) { + const { unlinkSync } = await import("node:fs"); + unlinkSync(TOKEN_FILE); + } +} + +export async function requireToken(): Promise { + const token = await readToken(); + if (!token) { + throw new Error("Not authenticated. Run `skillhub login` first."); + } + return token; +} diff --git a/skillhub-cli/src/core/config.ts b/skillhub-cli/src/core/config.ts new file mode 100644 index 000000000..fd42523d8 --- /dev/null +++ b/skillhub-cli/src/core/config.ts @@ -0,0 +1,64 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { Command } from "commander"; + +const CONFIG_DIR = join(homedir(), ".skillhub"); +const CONFIG_FILE = join(CONFIG_DIR, "config.json"); + +export interface CliConfig { + registry: string; + dir?: string; +} + +const DEFAULT_CONFIG: CliConfig = { + registry: "http://localhost:8080", +}; + +export function loadConfig(overrides?: Partial): CliConfig { + // Priority: overrides > env > config file > defaults + const envRegistry = process.env.SKILLHUB_REGISTRY; + const baseConfig: CliConfig = envRegistry + ? { registry: envRegistry } + : { ...DEFAULT_CONFIG }; + + if (existsSync(CONFIG_FILE)) { + try { + const raw = readFileSync(CONFIG_FILE, "utf-8"); + Object.assign(baseConfig, JSON.parse(raw)); + } catch { + // Use base config if file is invalid + } + } + + // Apply overrides (e.g., from command-line options) + if (overrides) { + Object.assign(baseConfig, overrides); + } + + return baseConfig; +} + +/** + * Helper function to load config with command-line options from Commander.js program + * Use this in command actions to get config that respects --registry flag + */ +export function loadConfigFromProgram(program: Command): CliConfig { + const opts = program.opts(); + const overrides: Partial = {}; + + if (opts.registry) { + overrides.registry = opts.registry as string; + } + + return loadConfig(overrides); +} + +export function saveConfig(config: Partial): void { + const existing = loadConfig(); + const merged = { ...existing, ...config }; + if (!existsSync(CONFIG_DIR)) { + mkdirSync(CONFIG_DIR, { recursive: true }); + } + writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2)); +} diff --git a/skillhub-cli/src/core/installer.ts b/skillhub-cli/src/core/installer.ts new file mode 100644 index 000000000..20941f78f --- /dev/null +++ b/skillhub-cli/src/core/installer.ts @@ -0,0 +1,146 @@ +import { mkdirSync, symlinkSync, copyFileSync, readdirSync, lstatSync, unlinkSync, existsSync } from "node:fs"; +import { join, dirname, relative } from "node:path"; +import { homedir, platform } from "node:os"; +import { isUniversalForScope, type AgentInfo } from "./agent-detector.js"; + +export interface SkillInstallResult { + skillName: string; + agentKey: string; + path: string; + mode: "symlink" | "copy"; + success: boolean; + error?: string; +} + +const CANONICAL_SKILLS_DIR = ".agents/skills"; + +function getCanonicalBase(isGlobal: boolean, cwd: string): string { + const home = homedir(); + return isGlobal ? join(home, CANONICAL_SKILLS_DIR) : join(cwd, CANONICAL_SKILLS_DIR); +} + +function getAgentBaseDir(skillsDir: string, isGlobal: boolean, cwd: string): string { + const home = homedir(); + if (isGlobal) { + return join(home, skillsDir); + } + return join(cwd, skillsDir); +} + +function removePath(path: string): void { + try { + const stat = lstatSync(path); + if (stat.isSymbolicLink()) { + unlinkSync(path); + } else if (stat.isDirectory()) { + for (const entry of readdirSync(path)) { + removePath(join(path, entry)); + } + if (platform() !== "win32") { + try { unlinkSync(path); } catch { } + } + } else { + unlinkSync(path); + } + } catch { } +} + +function ensureDir(path: string): void { + if (!existsSync(path)) { + mkdirSync(path, { recursive: true }); + } +} + +function createSymlink(target: string, linkPath: string): boolean { + try { + if (target === linkPath) { + return true; + } + + removePath(linkPath); + + const linkDir = dirname(linkPath); + const resolvedLinkDir = linkDir.startsWith("~") ? join(homedir(), linkDir.slice(1)) : linkDir; + ensureDir(resolvedLinkDir); + + const relativePath = relative(resolvedLinkDir, target); + const symlinkType = platform() === "win32" ? "junction" : "dir"; + + symlinkSync(relativePath, linkPath, symlinkType); + return true; + } catch { + return false; + } +} + +export function installSkill( + skillDir: string, + skillName: string, + agentKey: string, + targetDir: string, + mode: "symlink" | "copy", + isGlobal: boolean, + agent?: AgentInfo, +): SkillInstallResult { + const cwd = process.cwd(); + const canonicalBase = getCanonicalBase(isGlobal, cwd); + const canonicalDir = join(canonicalBase, skillName); + const agentBase = getAgentBaseDir(targetDir, isGlobal, cwd); + const agentDir = join(agentBase, skillName); + + // Use dynamic scope-aware universal check if agent info is available, + // otherwise fall back to static targetDir check + const agentIsUniversal = agent + ? isUniversalForScope(agent, isGlobal) + : targetDir === CANONICAL_SKILLS_DIR; + + try { + if (mode === "copy") { + const copyDestDir = dirname(agentDir); + const resolvedCopyDestDir = copyDestDir.startsWith("~") ? join(homedir(), copyDestDir.slice(1)) : copyDestDir; + ensureDir(resolvedCopyDestDir); + removePath(agentDir); + mkdirSync(agentDir, { recursive: true }); + copyDir(skillDir, agentDir); + return { skillName, agentKey, path: agentDir, mode, success: true }; + } + + ensureDir(dirname(canonicalDir)); + removePath(canonicalDir); + mkdirSync(canonicalDir, { recursive: true }); + copyDir(skillDir, canonicalDir); + + if (isGlobal && agentIsUniversal) { + return { skillName, agentKey, path: canonicalDir, mode, success: true }; + } + + const symlinkCreated = createSymlink(canonicalDir, agentDir); + + if (!symlinkCreated) { + const agentLinkDir = dirname(agentDir); + const resolvedAgentLinkDir = agentLinkDir.startsWith("~") ? join(homedir(), agentLinkDir.slice(1)) : agentLinkDir; + ensureDir(resolvedAgentLinkDir); + removePath(agentDir); + mkdirSync(agentDir, { recursive: true }); + copyDir(skillDir, agentDir); + return { skillName, agentKey, path: agentDir, mode, success: true }; + } + + return { skillName, agentKey, path: agentDir, mode, success: true }; + } catch (e: any) { + return { skillName, agentKey, path: agentDir, mode, success: false, error: e.message }; + } +} + +function copyDir(src: string, dest: string) { + mkdirSync(dest, { recursive: true }); + for (const entry of readdirSync(src)) { + const srcPath = join(src, entry); + const destPath = join(dest, entry); + if (lstatSync(srcPath).isDirectory()) { + copyDir(srcPath, destPath); + } else { + copyFileSync(srcPath, destPath); + } + } +} diff --git a/skillhub-cli/src/core/interactive-search.ts b/skillhub-cli/src/core/interactive-search.ts new file mode 100644 index 000000000..f0544a439 --- /dev/null +++ b/skillhub-cli/src/core/interactive-search.ts @@ -0,0 +1,314 @@ +import { ApiClient } from "./api-client.js"; +import { ApiRoutes, SearchResponse, SkillsListResponse } from "../schema/routes.js"; +import * as readline from "readline"; +import { dim, info } from "../utils/logger.js"; + +const HIDE_CURSOR = "\x1b[?25l"; +const SHOW_CURSOR = "\x1b[?25h"; +const CLEAR_DOWN = "\x1b[J"; +const MOVE_UP = (n: number) => `\x1b[${n}A`; +const MOVE_TO_COL = (n: number) => `\x1b[${n}G`; + +const RESET = "\x1b[0m"; +const BOLD = "\x1b[1m"; +const TEXT = "\x1b[38;5;145m"; +const YELLOW = "\x1b[33m"; +const DIM = "\x1b[38;5;102m"; + +export interface SearchSkill { + name: string; + slug: string; + namespace: string; + version?: string; + summary?: string; + installs?: number; + stars?: number; + rating?: number; + updatedAt?: number; +} + +interface SkillDetail { + starCount: number; + downloadCount: number; + version: string; +} + +export function parseNamespace(slug: string): { namespace: string; name: string } { + const parts = slug.split("--"); + if (parts.length >= 2) { + return { namespace: parts[0], name: parts.slice(1).join("--") }; + } + return { namespace: "global", name: slug }; +} + +function formatInstalls(count: number): string { + if (!count || count <= 0) return ""; + if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, "")}M installs`; + if (count >= 1_000) return `${(count / 1_000).toFixed(1).replace(/\.0$/, "")}K installs`; + return `${count} install${count === 1 ? "" : "s"}`; +} + +async function fetchSkillDetail(client: ApiClient, namespace: string, name: string): Promise { + try { + const detail = await client.get( + `${ApiRoutes.skillDetail.replace("{namespace}", namespace).replace("{slug}", name)}` + ); + return detail; + } catch { + return null; + } +} + +function applyClientSort(skills: SearchSkill[], sort: string): SearchSkill[] { + switch (sort) { + case "downloads": + return skills.sort((a, b) => (b.installs || 0) - (a.installs || 0)); + case "stars": + return skills.sort((a, b) => (b.stars || 0) - (a.stars || 0)); + case "rating": + return skills.sort((a, b) => (b.rating || 0) - (a.rating || 0)); + case "hot": + return skills.sort((a, b) => { + const hotA = (a.installs || 0) * 0.6 + (a.stars || 0) * 0.4; + const hotB = (b.installs || 0) * 0.6 + (b.stars || 0) * 0.4; + return hotB - hotA; + }); + case "newest": + default: + return skills.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)); + } +} + +export async function searchSkills( + client: ApiClient, + query: string, + limit: number = 10, + apiSort: string = "newest", + clientSort?: string +): Promise { + const needsClientSort = clientSort && clientSort !== apiSort; + + if (!query) { + const params = new URLSearchParams({ limit: limit.toString() }); + if (apiSort && apiSort !== "newest") { + params.set("sort", apiSort); + } + const result = await client.get( + `${ApiRoutes.skills}?${params.toString()}` + ); + if (!result.items || result.items.length === 0) { + return []; + } + const skills = result.items.map((s) => { + const { namespace, name } = parseNamespace(s.slug); + return { + name, + slug: s.slug, + namespace, + version: s.latestVersion?.version || "", + summary: s.summary, + installs: s.stats?.downloads || 0, + stars: s.stats?.stars || 0, + rating: s.ratingAvg || 0, + updatedAt: s.updatedAt || 0, + }; + }); + return needsClientSort ? applyClientSort(skills, clientSort) : applyClientSort(skills, apiSort); + } + + const params = new URLSearchParams({ q: query, limit: limit.toString() }); + if (apiSort && apiSort !== "newest") { + params.set("sort", apiSort); + } + const result = await client.get( + `${ApiRoutes.search}?${params.toString()}` + ); + + if (!result.results || result.results.length === 0) { + return []; + } + + const skills = result.results.map((s) => { + const { namespace, name } = parseNamespace(s.slug); + return { + name, + slug: s.slug, + namespace, + version: s.version, + summary: s.summary, + installs: s.downloadCount || 0, + stars: s.starCount || 0, + rating: s.ratingAvg || 0, + updatedAt: s.updatedAt ? new Date(s.updatedAt).getTime() : 0, + }; + }); + + return needsClientSort ? applyClientSort(skills, clientSort) : applyClientSort(skills, apiSort); +} + +export async function runInteractiveSearch( + client: ApiClient, + initialQuery: string = "", + sort: string = "newest" +): Promise { + const MAX_VISIBLE = 8; + let query = initialQuery; + let results: SearchSkill[] = []; + let selectedIndex = 0; + let loading = false; + let lastRenderedLines = 0; + let debounceTimer: ReturnType | null = null; + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const width = process.stdout.columns || 80; + + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + process.stdin.resume(); + process.stdout.write(HIDE_CURSOR); + + function render(): void { + if (lastRenderedLines > 0) { + process.stdout.write(MOVE_UP(lastRenderedLines) + MOVE_TO_COL(1)); + } + process.stdout.write(CLEAR_DOWN); + + const lines: string[] = []; + + const cursor = `${BOLD}_${RESET}`; + const searchLine = `${TEXT}Select namespace:${RESET} ${query}${cursor}`; + lines.push(searchLine); + lines.push(""); + + if (!query || query.length < 2) { + lines.push(`${DIM}Start typing to search (min 2 chars)${RESET}`); + } else if (results.length === 0 && loading) { + lines.push(`${DIM}Searching...${RESET}`); + } else if (results.length === 0) { + lines.push(`${DIM}No skills found${RESET}`); + } else { + const visible = results.slice(0, MAX_VISIBLE); + for (let i = 0; i < visible.length; i++) { + const skill = visible[i]!; + const isSelected = i === selectedIndex; + const arrow = isSelected ? `${BOLD}>${RESET}` : " "; + const name = isSelected ? `${BOLD}${skill.name}${RESET}` : `${TEXT}${skill.name}${RESET}`; + const nsBadge = skill.namespace !== "global" ? ` ${YELLOW}[${skill.namespace}]${RESET}` : ""; + const versionBadge = skill.version ? ` ${DIM}v${skill.version}${RESET}` : ""; + + lines.push(` ${arrow} ${name}${nsBadge}${versionBadge}`); + } + } + + lines.push(""); + lines.push(`${DIM}up/down navigate | enter select | esc cancel${RESET}`); + + for (const line of lines) { + process.stdout.write(line + "\n"); + } + + lastRenderedLines = lines.length; + } + + function triggerSearch(q: string): void { + if (debounceTimer) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + + loading = false; + + if (!q || q.length < 2) { + results = []; + selectedIndex = 0; + render(); + return; + } + + loading = true; + render(); + + const debounceMs = Math.max(150, 350 - q.length * 50); + + debounceTimer = setTimeout(async () => { + try { + results = await searchSkills(client, q, 10, sort); + selectedIndex = 0; + } catch { + results = []; + } finally { + loading = false; + debounceTimer = null; + render(); + } + }, debounceMs); + } + + if (initialQuery) { + triggerSearch(initialQuery); + } + render(); + + return new Promise((resolve) => { + function cleanup(): void { + process.stdin.removeListener("keypress", handleKeypress); + if (process.stdin.isTTY) { + process.stdin.setRawMode(false); + } + process.stdout.write(SHOW_CURSOR); + process.stdin.pause(); + rl.close(); + } + + function handleKeypress(_ch: string | undefined, key: readline.Key): void { + if (!key) return; + + if (key.name === "escape" || (key.ctrl && key.name === "c")) { + cleanup(); + resolve(null); + return; + } + + if (key.name === "return") { + cleanup(); + resolve(results[selectedIndex] ? `${results[selectedIndex].namespace}/${results[selectedIndex].name}` : null); + return; + } + + if (key.name === "up") { + selectedIndex = Math.max(0, selectedIndex - 1); + render(); + return; + } + + if (key.name === "down") { + selectedIndex = Math.min(Math.max(0, results.length - 1), selectedIndex + 1); + render(); + return; + } + + if (key.name === "backspace") { + if (query.length > 0) { + query = query.slice(0, -1); + triggerSearch(query); + } + return; + } + + if (key.sequence && !key.ctrl && !key.meta && key.sequence.length === 1) { + const char = key.sequence; + if (char >= " " && char <= "~") { + query += char; + triggerSearch(query); + } + } + } + + process.stdin.on("keypress", handleKeypress); + }); +} diff --git a/skillhub-cli/src/core/skill-discovery.ts b/skillhub-cli/src/core/skill-discovery.ts new file mode 100644 index 000000000..2ca968143 --- /dev/null +++ b/skillhub-cli/src/core/skill-discovery.ts @@ -0,0 +1,86 @@ +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +export interface DiscoveredSkill { + name: string; + description: string; + dir: string; +} + +const SKILL_DIRS = [ + "skills", + ".agents/skills", + ".claude/skills", + ".augment/skills", + ".cursor/skills", + ".codex/skills", +]; + +export function discoverSkills(rootDir: string): DiscoveredSkill[] { + const skills: DiscoveredSkill[] = []; + + for (const subDir of SKILL_DIRS) { + const fullPath = join(rootDir, subDir); + if (!existsSync(fullPath)) continue; + skills.push(...scanDir(fullPath)); + } + + if (skills.length === 0) { + skills.push(...scanDir(rootDir)); + } + + // Also check for SKILL.md directly in rootDir (for registry downloads) + if (skills.length === 0) { + const rootSkillMd = join(rootDir, "SKILL.md"); + if (existsSync(rootSkillMd)) { + try { + const content = readFileSync(rootSkillMd, "utf-8"); + const name = extractFrontmatterField(content, "name"); + const description = extractFrontmatterField(content, "description"); + if (name) { + skills.push({ name, description: description || name, dir: rootDir }); + } + } catch { + // Skip unreadable files + } + } + } + + return skills; +} + +function scanDir(dir: string): DiscoveredSkill[] { + const skills: DiscoveredSkill[] = []; + if (!existsSync(dir)) return skills; + + try { + for (const entry of readdirSync(dir)) { + const entryPath = join(dir, entry); + const stat = statSync(entryPath); + if (!stat.isDirectory()) continue; + + const skillMd = join(entryPath, "SKILL.md"); + if (!existsSync(skillMd)) continue; + + const content = readFileSync(skillMd, "utf-8"); + const name = extractFrontmatterField(content, "name"); + const description = extractFrontmatterField(content, "description"); + + if (name) { + skills.push({ name, description: description || name, dir: entryPath }); + } + } + } catch { + // Skip unreadable directories + } + + return skills; +} + +function extractFrontmatterField(content: string, field: string): string | undefined { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) return undefined; + const frontmatter = match[1]; + const fieldMatch = frontmatter.match(new RegExp(`^${field}:\\s*(.+)$`, "m")); + return fieldMatch ? fieldMatch[1].trim().replace(/^["']|["']$/g, "") : undefined; +} diff --git a/skillhub-cli/src/core/skill-lock.ts b/skillhub-cli/src/core/skill-lock.ts new file mode 100644 index 000000000..8244c52b7 --- /dev/null +++ b/skillhub-cli/src/core/skill-lock.ts @@ -0,0 +1,106 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; + +const LOCK_FILE_VERSION = 1; +const LOCK_DIR = join(homedir(), ".skillhub"); +const LOCK_FILE = join(LOCK_DIR, "lock.json"); + +export interface SkillLockEntry { + source: string; + sourceType: "git" | "registry" | "local"; + sourceUrl: string; + ref?: string; + namespace: string; + slug: string; + version: string; + fingerprint?: string; + installedAt: string; + updatedAt: string; +} + +export interface SkillLockFile { + version: number; + skills: Record; + lastSelectedAgents?: string[]; +} + +function createEmptyLock(): SkillLockFile { + return { + version: LOCK_FILE_VERSION, + skills: {}, + }; +} + +export function getSkillLockPath(): string { + return LOCK_FILE; +} + +export async function readSkillLock(): Promise { + if (!existsSync(LOCK_FILE)) { + return createEmptyLock(); + } + try { + const content = readFileSync(LOCK_FILE, "utf-8"); + const lock = JSON.parse(content) as SkillLockFile; + if (typeof lock.version !== "number" || !lock.skills) { + return createEmptyLock(); + } + return lock; + } catch { + return createEmptyLock(); + } +} + +export async function writeSkillLock(lock: SkillLockFile): Promise { + if (!existsSync(LOCK_DIR)) { + mkdirSync(LOCK_DIR, { recursive: true }); + } + writeFileSync(LOCK_FILE, JSON.stringify(lock, null, 2)); +} + +export async function addToLock( + name: string, + entry: Omit +): Promise { + const lock = await readSkillLock(); + const now = new Date().toISOString(); + const existing = lock.skills[name]; + lock.skills[name] = { + ...entry, + installedAt: existing?.installedAt ?? now, + updatedAt: now, + }; + await writeSkillLock(lock); +} + +export async function removeFromLock(name: string): Promise { + const lock = await readSkillLock(); + if (!(name in lock.skills)) { + return false; + } + delete lock.skills[name]; + await writeSkillLock(lock); + return true; +} + +export async function getFromLock(name: string): Promise { + const lock = await readSkillLock(); + return lock.skills[name] ?? null; +} + +export async function getAllLockedSkills(): Promise> { + const lock = await readSkillLock(); + return lock.skills; +} + +export async function getLastSelectedAgents(): Promise { + const lock = await readSkillLock(); + return lock.lastSelectedAgents; +} + +export async function saveLastSelectedAgents(agents: string[]): Promise { + const lock = await readSkillLock(); + lock.lastSelectedAgents = agents; + await writeSkillLock(lock); +} diff --git a/skillhub-cli/src/core/skill-name.ts b/skillhub-cli/src/core/skill-name.ts new file mode 100644 index 000000000..fa2c924c4 --- /dev/null +++ b/skillhub-cli/src/core/skill-name.ts @@ -0,0 +1,12 @@ +export interface ParsedSkillName { + namespace: string; + slug: string; +} + +export function parseSkillName(input: string, defaultNamespace = "global"): ParsedSkillName { + const parts = input.split("/"); + if (parts.length >= 2) { + return { namespace: parts[0], slug: parts.slice(1).join("/") }; + } + return { namespace: defaultNamespace, slug: input }; +} diff --git a/skillhub-cli/src/core/skill-resolver.ts b/skillhub-cli/src/core/skill-resolver.ts new file mode 100644 index 000000000..71f7ae9e9 --- /dev/null +++ b/skillhub-cli/src/core/skill-resolver.ts @@ -0,0 +1,86 @@ +import { ApiClient } from "./api-client.js"; +import { parseSkillName } from "./skill-name.js"; +import { searchSkills, runInteractiveSearch } from "./interactive-search.js"; + +export interface ResolvedSkill { + namespace: string; + slug: string; + userSpecified: boolean; +} + +/** + * 解析 skill 的 namespace,支持智能搜索 + * + * @param client - API 客户端 + * @param slug - 输入的 skill 名称(可能包含 namespace) + * @param explicitNamespace - 通过 --namespace 选项显式指定的 namespace + * @returns 解析后的 namespace 和 slug + */ +export async function resolveSkillNamespace( + client: ApiClient, + slug: string, + explicitNamespace?: string +): Promise { + const { namespace: parsedNs, slug: actualSlug } = parseSkillName(slug); + + if (explicitNamespace) { + return { namespace: explicitNamespace, slug: actualSlug, userSpecified: true }; + } + + if (parsedNs !== "global") { + return { namespace: parsedNs, slug: actualSlug, userSpecified: true }; + } + + const results = await searchSkills(client, actualSlug, 50); + + const seen = new Set(); + const uniqueResults = results.filter((r) => { + const key = `${r.namespace}/${r.name}`; + if (!seen.has(key)) { + seen.add(key); + return true; + } + return false; + }); + + if (uniqueResults.length === 0) { + throw new Error(`Skill not found: ${actualSlug}`); + } + + if (uniqueResults.length === 1) { + return { + namespace: uniqueResults[0].namespace, + slug: uniqueResults[0].name, + userSpecified: false, + }; + } + + const selected = await runInteractiveSearch(client, actualSlug); + if (!selected) { + throw new Error("Cancelled"); + } + + const [ns, name] = selected.split("/", 2); + return { namespace: ns, slug: name, userSpecified: false }; +} + +/** + * 简单解析 skill namespace,不触发智能搜索 + * 用于不需要搜索的命令(delete, star 等) + */ +export function parseSkillNamespace( + slug: string, + explicitNamespace?: string +): ResolvedSkill { + const { namespace: parsedNs, slug: actualSlug } = parseSkillName(slug); + + if (explicitNamespace) { + return { namespace: explicitNamespace, slug: actualSlug, userSpecified: true }; + } + + return { + namespace: parsedNs, + slug: actualSlug, + userSpecified: parsedNs !== "global", + }; +} diff --git a/skillhub-cli/src/core/skill-status.ts b/skillhub-cli/src/core/skill-status.ts new file mode 100644 index 000000000..785d0f273 --- /dev/null +++ b/skillhub-cli/src/core/skill-status.ts @@ -0,0 +1,133 @@ +import { existsSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { getAllAgents, type AgentInfo } from "./agent-detector.js"; +import { getAllLockedSkills } from "./skill-lock.js"; + +export interface SkillLocation { + agent: string; + path: string; + scope: "local" | "global"; +} + +export interface DiscoveredSkill { + name: string; + status: "managed" | "orphaned" | "missing"; + source?: string; + locations: SkillLocation[]; +} + +function findInstalledSkills( + scope: "local" | "global", + agents?: AgentInfo[] +): Map { + const skillsMap = new Map(); + const allAgents = getAllAgents(); + const targetAgents = agents || allAgents; + + for (const agent of targetAgents) { + const baseDir = scope === "global" + ? join(homedir(), agent.globalSkillsDir || agent.skillsDir) + : join(process.cwd(), agent.skillsDir); + + if (!existsSync(baseDir)) continue; + + try { + for (const entry of readdirSync(baseDir)) { + const skillPath = join(baseDir, entry); + if (statSync(skillPath).isDirectory() && existsSync(join(skillPath, "SKILL.md"))) { + const existing = skillsMap.get(entry) || []; + const displayPath = scope === "global" + ? baseDir.replace(homedir(), "~") + : baseDir.replace(process.cwd(), "."); + existing.push({ + agent: agent.name, + path: `${displayPath}/${entry}`, + scope, + }); + skillsMap.set(entry, existing); + } + } + } catch {} + } + + return skillsMap; +} + +export async function discoverInstalledSkills( + scopes: ("local" | "global")[], + agents?: AgentInfo[] +): Promise { + const lockedSkills = await getAllLockedSkills(); + const allInstalled = new Map(); + + for (const scope of scopes) { + const installed = findInstalledSkills(scope, agents); + for (const [name, locations] of installed) { + const existing = allInstalled.get(name) || []; + existing.push(...locations); + allInstalled.set(name, existing); + } + } + + const results: DiscoveredSkill[] = []; + const checkedNames = new Set(); + + for (const [name, entry] of Object.entries(lockedSkills)) { + const locations = allInstalled.get(name); + if (locations && locations.length > 0) { + results.push({ + name, + status: "managed", + source: entry.source, + locations, + }); + } else { + results.push({ + name, + status: "missing", + source: entry.source, + locations: [], + }); + } + checkedNames.add(name); + } + + for (const [name, locations] of allInstalled) { + if (!checkedNames.has(name)) { + results.push({ + name, + status: "orphaned", + locations, + }); + } + } + + const order = { managed: 0, missing: 1, orphaned: 2 }; + results.sort((a, b) => { + const diff = order[a.status] - order[b.status]; + return diff !== 0 ? diff : a.name.localeCompare(b.name); + }); + + return results; +} + +export function filterSkillsByStatus( + skills: DiscoveredSkill[], + options: { + managed?: boolean; + orphaned?: boolean; + missing?: boolean; + } +): DiscoveredSkill[] { + const showManaged = options.managed ?? true; + const showOrphaned = options.orphaned ?? true; + const showMissing = options.missing ?? false; + + return skills.filter((s) => { + if (s.status === "managed" && showManaged) return true; + if (s.status === "orphaned" && showOrphaned) return true; + if (s.status === "missing" && showMissing) return true; + return false; + }); +} diff --git a/skillhub-cli/src/core/source-parser.ts b/skillhub-cli/src/core/source-parser.ts new file mode 100644 index 000000000..cd64589d0 --- /dev/null +++ b/skillhub-cli/src/core/source-parser.ts @@ -0,0 +1,69 @@ +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; + +const DEFAULT_GITHUB_HOST = "github.com"; + +function getGitHubHost(): string { + return process.env.GITHUB_MIRROR || DEFAULT_GITHUB_HOST; +} + +function isGitHubHost(hostname: string): boolean { + const ghHost = getGitHubHost(); + return hostname === ghHost || hostname.endsWith(`.${ghHost}`); +} + +export interface ParsedSource { + type: "local" | "github" | "gitlab" | "url"; + owner?: string; + repo?: string; + ref?: string; + subpath?: string; + localPath?: string; + cloneUrl?: string; + skillFilter?: string; +} + +export function parseSource(input: string): ParsedSource { + if (input.startsWith(".") || input.startsWith("/") || /^[a-zA-Z]:\\/.test(input)) { + const localPath = resolve(process.cwd(), input); + if (!existsSync(localPath)) { + throw new Error(`Local path not found: ${localPath}`); + } + return { type: "local", localPath }; + } + + if (input.startsWith("http://") || input.startsWith("https://")) { + const url = new URL(input); + if (isGitHubHost(url.hostname)) { + const [, owner, repo, , ref] = url.pathname.split("/"); + return { type: "github", owner, repo: repo?.replace(/\.git$/, ""), ref, cloneUrl: input }; + } + if (url.hostname.includes("gitlab.com")) { + const [, owner, repo] = url.pathname.split("/"); + return { type: "gitlab", owner, repo, cloneUrl: input }; + } + return { type: "url", cloneUrl: input }; + } + + const parts = input.split("/"); + if (parts.length === 2) { + const atIndex = parts[1].indexOf("@"); + if (atIndex > 0) { + const repo = parts[1].substring(0, atIndex); + const skillFilter = parts[1].substring(atIndex + 1); + return { type: "github", owner: parts[0], repo, skillFilter }; + } + return { type: "github", owner: parts[0], repo: parts[1] }; + } + + throw new Error(`Invalid source format: ${input}. Use owner/repo, local path, URL, or registry namespace/slug.`); +} + +export function getCloneUrl(source: ParsedSource): string { + if (source.cloneUrl) return source.cloneUrl; + if (source.type === "github" && source.owner && source.repo) { + const ghHost = getGitHubHost(); + return `https://${ghHost}/${source.owner}/${source.repo}.git`; + } + throw new Error("Cannot determine clone URL"); +} diff --git a/skillhub-cli/src/schema/routes.ts b/skillhub-cli/src/schema/routes.ts new file mode 100644 index 000000000..83829ecb8 --- /dev/null +++ b/skillhub-cli/src/schema/routes.ts @@ -0,0 +1,86 @@ +export const ApiRoutes = { + whoami: "/api/v1/whoami", + skills: "/api/v1/skills", + search: "/api/v1/search", + meNamespaces: "/api/v1/me/namespaces", + skillDetail: "/api/v1/skills/{namespace}/{slug}", + skillStar: "/api/v1/skills/{namespace}/{slug}/star", + skillVersions: "/api/v1/skills/{namespace}/{slug}/versions", + skillDownload: "/api/v1/skills/{namespace}/{slug}/download", + skillResolve: "/api/v1/skills/{namespace}/{slug}/resolve", + namespaceTransferOwnership: "/api/v1/namespaces/{namespace}/transfer-ownership", +} as const; + +export interface PublishResponse { + skillId: string; + namespace: string; + slug: string; + version: string; + status: string; +} + +export interface WhoamiResponse { + user: { + handle: string; + displayName: string; + image: string | null; + }; +} + +export interface NamespaceResponse { + id: number; + slug: string; + displayName: string; + currentUserRole: string; + status: string; +} + +export interface SearchResponse { + results: Array<{ + slug: string; + displayName: string; + summary: string; + version: string; + namespace?: string; + downloadCount?: number; + starCount?: number; + ratingAvg?: number; + updatedAt?: string; + }>; +} + +export interface SkillsListResponse { + items: Array<{ + slug: string; + displayName: string; + summary: string; + updatedAt: number; + stats: { + downloads?: number; + stars?: number; + }; + ratingAvg?: number; + latestVersion?: { + version: string; + }; + }>; + nextCursor: string | null; +} + +export interface SkillVersionItem { + id: number; + version: string; + status: string; + changelog: string | null; + fileCount: number; + totalSize: number; + publishedAt: string; + downloadAvailable: boolean; +} + +export interface VersionsResponse { + items: SkillVersionItem[]; + total: number; + page: number; + size: number; +} diff --git a/skillhub-cli/src/utils/install-helpers.ts b/skillhub-cli/src/utils/install-helpers.ts new file mode 100644 index 000000000..e09f6e168 --- /dev/null +++ b/skillhub-cli/src/utils/install-helpers.ts @@ -0,0 +1,253 @@ +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import { homedir } from "node:os"; +import { sep } from "node:path"; + +export function riskLabel(risk: string): string { + switch (risk) { + case "critical": + return pc.red(pc.bold("Critical Risk")); + case "high": + return pc.red("High Risk"); + case "medium": + return pc.yellow("Med Risk"); + case "low": + return pc.green("Low Risk"); + case "safe": + return pc.green("Safe"); + default: + return pc.dim("--"); + } +} + +export function socketLabel(audit: { alerts?: number } | undefined): string { + if (!audit) return pc.dim("--"); + const count = audit.alerts ?? 0; + return count > 0 ? pc.red(`${count} alert${count !== 1 ? "s" : ""}`) : pc.green("0 alerts"); +} + +export function padEnd(str: string, width: number): string { + const visible = str.replace(/\x1b\[[0-9;]*m/g, ""); + const pad = Math.max(0, width - visible.length); + return str + " ".repeat(pad); +} + +export interface AuditSkill { + slug: string; + displayName: string; +} + +export interface AuditData { + ath?: { risk: string }; + socket?: { alerts?: number }; + snyk?: { risk: string }; +} + +export type AuditResponse = Record; + +export function buildSecurityLines( + auditData: AuditResponse | null, + skills: AuditSkill[], + _source: string +): string[] { + if (!auditData) return []; + + const hasAny = skills.some((s) => { + const data = auditData[s.slug]; + return data && Object.keys(data).length > 0; + }); + if (!hasAny) return []; + + const nameWidth = Math.min(Math.max(...skills.map((s) => s.displayName.length)), 36); + + const lines: string[] = []; + const header = + padEnd("", nameWidth + 2) + + padEnd(pc.dim("Gen"), 18) + + padEnd(pc.dim("Socket"), 18) + + pc.dim("Snyk"); + lines.push(header); + + for (const skill of skills) { + const data = auditData[skill.slug]; + const name = + skill.displayName.length > nameWidth + ? skill.displayName.slice(0, nameWidth - 1) + "\u2026" + : skill.displayName; + + const ath = data?.ath ? riskLabel(data.ath.risk) : pc.dim("--"); + const socket = data?.socket ? socketLabel(data.socket) : pc.dim("--"); + const snyk = data?.snyk ? riskLabel(data.snyk.risk) : pc.dim("--"); + + lines.push(padEnd(pc.cyan(name), nameWidth + 2) + padEnd(ath, 18) + padEnd(socket, 18) + snyk); + } + + lines.push(""); + lines.push(`${pc.dim("Details:")} ${pc.dim(`https://skills.sh/${_source}`)}`); + + return lines; +} + +export function shortenPath(fullPath: string, cwd: string): string { + const home = homedir(); + if (fullPath === home || fullPath.startsWith(home + sep)) { + return "~" + fullPath.slice(home.length); + } + if (fullPath === cwd || fullPath.startsWith(cwd + sep)) { + return "." + fullPath.slice(cwd.length); + } + return fullPath; +} + +export function formatList(items: string[], maxShow: number = 5): string { + if (items.length <= maxShow) { + return items.join(", "); + } + const shown = items.slice(0, maxShow); + const remaining = items.length - maxShow; + return `${shown.join(", ")} +${remaining} more`; +} + +export interface AgentInfo { + key: string; + name: string; + skillsDir: string; + globalSkillsDir?: string; +} + +export function splitAgentsByType( + agentTypes: string[], + agents: Record +): { universal: string[]; symlinked: string[] } { + const universal: string[] = []; + const symlinked: string[] = []; + + for (const a of agentTypes) { + const agent = agents[a]; + if (agent) { + if (agent.skillsDir === ".agents/skills") { + universal.push(agent.name); + } else { + symlinked.push(agent.name); + } + } + } + + return { universal, symlinked }; +} + +export function buildAgentSummaryLines( + targetAgents: string[], + installMode: string, + agents: Record +): string[] { + const lines: string[] = []; + const { universal, symlinked } = splitAgentsByType(targetAgents, agents); + + if (installMode === "symlink") { + if (universal.length > 0) { + lines.push(` ${pc.green("universal:")} ${formatList(universal)}`); + } + if (symlinked.length > 0) { + lines.push(` ${pc.dim("symlink →")} ${formatList(symlinked)}`); + } + } else { + const allNames = targetAgents.map((a) => agents[a]?.name || a); + lines.push(` ${pc.dim("copy →")} ${formatList(allNames)}`); + } + + return lines; +} + +export function ensureUniversalAgents( + targetAgents: string[], + getUniversalAgentsFn: () => string[] +): string[] { + const universalAgents = getUniversalAgentsFn(); + const result = [...targetAgents]; + + for (const ua of universalAgents) { + if (!result.includes(ua)) { + result.push(ua); + } + } + + return result; +} + +export interface InstallResult { + agent: string; + symlinkFailed?: boolean; +} + +export function buildResultLines( + results: InstallResult[], + targetAgents: string[], + agents: Record +): string[] { + const lines: string[] = []; + const { universal, symlinked } = splitAgentsByType(targetAgents, agents); + + const successfulSymlinks = results + .filter((r) => !r.symlinkFailed && !universal.includes(r.agent)) + .map((r) => r.agent); + const failedSymlinks = results.filter((r) => r.symlinkFailed).map((r) => r.agent); + + if (universal.length > 0) { + lines.push(` ${pc.green("universal:")} ${formatList(universal)}`); + } + if (successfulSymlinks.length > 0) { + lines.push(` ${pc.dim("symlinked:")} ${formatList(successfulSymlinks)}`); + } + if (failedSymlinks.length > 0) { + lines.push(` ${pc.yellow("copied:")} ${formatList(failedSymlinks)}`); + } + + return lines; +} + +export function isCancelled(value: unknown): value is symbol { + return typeof value === "symbol"; +} + +export async function interactiveSelect(opts: { + message: string; + options: Array<{ value: T; label: string; hint?: string }>; +}): Promise { + const selected = await p.select({ + message: opts.message, + options: opts.options as p.Option[], + }); + return selected as T | symbol; +} + +export async function interactiveConfirm(message: string): Promise { + const confirmed = await p.confirm({ message }); + return confirmed as boolean | symbol; +} + +export async function interactiveMultiSelect(opts: { + message: string; + options: Array<{ value: T; label: string; hint?: string }>; + initialValues?: T[]; + required?: boolean; +}): Promise { + return p.multiselect({ + message: `${opts.message} ${pc.dim("(space to toggle)")}`, + options: opts.options as p.Option[], + initialValues: opts.initialValues as T[], + required: opts.required, + }) as Promise; +} + +export function getCanonicalPath(skillName: string, isGlobal: boolean, agents: Record): string { + const universalAgents = Object.values(agents).filter((a) => a.skillsDir === ".agents/skills"); + if (universalAgents.length > 0) { + return `~/.agents/skills/${skillName}`; + } + if (isGlobal) { + const home = homedir(); + return `${home}/.agents/skills/${skillName}`; + } + return `.agents/skills/${skillName}`; +} \ No newline at end of file diff --git a/skillhub-cli/src/utils/logger.ts b/skillhub-cli/src/utils/logger.ts new file mode 100644 index 000000000..f9177de43 --- /dev/null +++ b/skillhub-cli/src/utils/logger.ts @@ -0,0 +1,25 @@ +import chalk from "chalk"; + +export function log(msg: string) { + console.log(msg); +} + +export function success(msg: string) { + console.log(chalk.green(msg)); +} + +export function error(msg: string) { + console.error(chalk.red(msg)); +} + +export function warn(msg: string) { + console.warn(chalk.yellow(msg)); +} + +export function info(msg: string) { + console.log(chalk.cyan(msg)); +} + +export function dim(msg: string) { + console.log(chalk.dim(msg)); +} diff --git a/skillhub-cli/src/utils/prompts.ts b/skillhub-cli/src/utils/prompts.ts new file mode 100644 index 000000000..90f86f7ae --- /dev/null +++ b/skillhub-cli/src/utils/prompts.ts @@ -0,0 +1,449 @@ +import * as readline from "readline"; +import { Writable } from "stream"; + +const silentOutput = new Writable({ + write(_chunk, _encoding, callback) { + callback(); + }, +}); + +const S_STEP_ACTIVE = "\x1b[32m◆\x1b[0m"; +const S_STEP_CANCEL = "\x1b[31m■\x1b[0m"; +const S_STEP_SUBMIT = "\x1b[32m◇\x1b[0m"; +const S_RADIO_ACTIVE = "\x1b[32m●\x1b[0m"; +const S_RADIO_INACTIVE = "\x1b[2m○\x1b[0m"; +const S_BULLET = "\x1b[32m•\x1b[0m"; +const S_BAR = "\x1b[2m│\x1b[0m"; +const S_BAR_H = "\x1b[2m─\x1b[0m"; +const S_ESC = "\x1b["; +const S_BOLD = "\x1b[1m"; +const S_DIM = "\x1b[2m"; +const S_UNDERLINE = "\x1b[4m"; +const S_INVERSE = "\x1b[7m"; +const S_RESET = "\x1b[0m"; +const S_CYAN = "\x1b[36m"; +const S_GREEN = "\x1b[32m"; +const S_YELLOW = "\x1b[33m"; +const S_RED = "\x1b[31m"; + +const bold = (s: string) => `${S_BOLD}${s}${S_RESET}`; +const dim = (s: string) => `${S_DIM}${s}${S_RESET}`; +const cyan = (s: string) => `${S_CYAN}${s}${S_RESET}`; +const green = (s: string) => `${S_GREEN}${s}${S_RESET}`; +const yellow = (s: string) => `${S_YELLOW}${s}${S_RESET}`; +const red = (s: string) => `${S_RED}${s}${S_RESET}`; + +function moveUp(n: number): string { + return `${S_ESC}${n}A`; +} + +function clearLine(): string { + return `${S_ESC}2K`; +} + +function clearRender(lastHeight: number): void { + if (lastHeight > 0) { + process.stdout.write(moveUp(lastHeight)); + for (let i = 0; i < lastHeight; i++) { + process.stdout.write(clearLine() + (i < lastHeight - 1 ? moveUp(1) + "\x1b[G" : "\n")); + } + process.stdout.write(moveUp(lastHeight)); + } +} + +export interface SelectItem { + value: string; + label: string; + hint?: string; +} + +export interface SelectSection { + title: string; + items: SelectItem[]; + locked?: boolean; +} + +export async function multiSelect( + message: string, + items: SelectItem[] +): Promise { + return new Promise((resolve) => { + console.log(""); + console.log(message); + console.log(dim("(输入数字选择,逗号分隔,如 1,3,5,输入 a 全选,n 取消)")); + console.log(""); + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + const num = (i + 1).toString().padStart(2, " "); + console.log(` [${num}] ${item.label}`); + } + console.log(""); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + rl.question("请选择: ", (answer) => { + rl.close(); + console.log(""); + + const trimmed = answer.trim().toLowerCase(); + + if (trimmed === "n" || trimmed === "no") { + resolve(null); + return; + } + + if (trimmed === "a" || trimmed === "all") { + resolve(items.map((i) => i.value)); + return; + } + + const selected: string[] = []; + const parts = trimmed.split(",").map((s) => s.trim()); + + for (const part of parts) { + const num = parseInt(part, 10); + if (!isNaN(num) && num >= 1 && num <= items.length) { + selected.push(items[num - 1].value); + } + } + + if (selected.length === 0) { + console.log("未选择任何 skill"); + resolve(null); + return; + } + + resolve(selected); + }); + }); +} + +export async function sectionMultiSelect( + message: string, + sections: SelectSection[] +): Promise { + return new Promise((resolve) => { + console.log(""); + console.log(message); + console.log(dim("(输入数字选择,逗号分隔,如 1,3,5,输入 a 全选,n 取消)")); + console.log(""); + + let selectableIdx = 0; + for (const section of sections) { + if (section.locked) { + console.log(` ${section.title} ${"[always included]"}`); + for (const item of section.items) { + console.log(` ● ${item.label}${item.hint ? ` ${item.hint}` : ""}`); + } + } else { + console.log(` ${section.title}`); + for (const item of section.items) { + selectableIdx++; + console.log(` [${selectableIdx.toString().padStart(2, " ")}] ${item.label}${item.hint ? ` ${item.hint}` : ""}`); + } + } + } + console.log(""); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + rl.question("请选择: ", (answer) => { + rl.close(); + console.log(""); + + const trimmed = answer.trim().toLowerCase(); + + if (trimmed === "n" || trimmed === "no") { + resolve(null); + return; + } + + const selected: string[] = []; + const parts = trimmed.split(",").map((s) => s.trim()); + + selectableIdx = 0; + for (const section of sections) { + if (section.locked) { + selected.push(...section.items.map((i) => i.value)); + } else { + for (const item of section.items) { + selectableIdx++; + if (parts.includes(selectableIdx.toString())) { + selected.push(item.value); + } + } + } + } + + if (selected.length === 0) { + console.log("未选择任何项"); + resolve(null); + return; + } + + resolve(selected); + }); + }); +} + +export const cancelSymbol = Symbol("cancel"); + +export interface InteractiveSelectOptions { + message: string; + items: SelectItem[]; + initialSelected?: string[]; + lockedSection?: SelectSection; + hint?: string; +} + +export async function interactiveMultiSelect( + options: InteractiveSelectOptions +): Promise { + const { + message, + items, + initialSelected = [], + lockedSection, + hint = "↑↓ move, space select, enter confirm", + } = options; + + const selectableItems = items; + + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: silentOutput, + terminal: false, + }); + + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + readline.emitKeypressEvents(process.stdin, rl); + + let query = ""; + let cursor = 0; + const selected = new Set(initialSelected); + let lastRenderHeight = 0; + + const lockedValues = lockedSection ? lockedSection.items.map((i) => i.value) : []; + + const filter = (item: SelectItem, q: string): boolean => { + if (!q) return true; + const lowerQ = q.toLowerCase(); + return ( + item.label.toLowerCase().includes(lowerQ) || + item.value.toLowerCase().includes(lowerQ) + ); + }; + + const getFiltered = (): SelectItem[] => { + return selectableItems.filter((item) => filter(item, query)); + }; + + const render = (state: "active" | "submit" | "cancel" = "active"): void => { + clearRender(lastRenderHeight); + + const lines: string[] = []; + const filtered = getFiltered(); + + const icon = + state === "active" ? S_STEP_ACTIVE : state === "cancel" ? S_STEP_CANCEL : S_STEP_SUBMIT; + lines.push(`${icon} ${bold(message)}`); + + if (state === "active") { + if (lockedSection && lockedSection.items.length > 0) { + lines.push(`${S_BAR}`); + const lockedTitle = `${bold(lockedSection.title)} ${dim("── always included")}`; + lines.push(`${S_BAR} ${S_BAR_H}${S_BAR_H} ${lockedTitle} ${S_BAR_H.repeat(12)}`); + for (const item of lockedSection.items) { + lines.push(`${S_BAR} ${S_BULLET} ${bold(item.label)}`); + } + lines.push(`${S_BAR}`); + lines.push( + `${S_BAR} ${S_BAR_H}${S_BAR_H} ${bold("Additional agents")} ${S_BAR_H.repeat(29)}` + ); + } + + const searchLine = `${S_BAR} ${dim("Search:")} ${query}${S_INVERSE} ${S_RESET}`; + lines.push(searchLine); + + lines.push(`${S_BAR} ${dim(hint)}`); + lines.push(`${S_BAR}`); + + const maxVisible = 10; + const visibleStart = Math.max( + 0, + Math.min(cursor - Math.floor(maxVisible / 2), filtered.length - maxVisible) + ); + const visibleEnd = Math.min(filtered.length, visibleStart + maxVisible); + const visibleItems = filtered.slice(visibleStart, visibleEnd); + + if (filtered.length === 0) { + lines.push(`${S_BAR} ${dim("No matches found")}`); + } else { + for (let i = 0; i < visibleItems.length; i++) { + const item = visibleItems[i]!; + const actualIndex = visibleStart + i; + const isSelected = selected.has(item.value); + const isCursor = actualIndex === cursor; + + const radio = isSelected ? S_RADIO_ACTIVE : S_RADIO_INACTIVE; + const label = isCursor ? `${S_UNDERLINE}${item.label}${S_RESET}` : item.label; + const hintStr = item.hint ? dim(` (${item.hint})`) : ""; + + const prefix = isCursor ? `${cyan("❯")}` : " "; + lines.push(`${S_BAR} ${prefix} ${radio} ${label}${hintStr}`); + } + + const hiddenBefore = visibleStart; + const hiddenAfter = filtered.length - visibleEnd; + if (hiddenBefore > 0 || hiddenAfter > 0) { + const parts: string[] = []; + if (hiddenBefore > 0) parts.push(`↑ ${hiddenBefore} more`); + if (hiddenAfter > 0) parts.push(`↓ ${hiddenAfter} more`); + lines.push(`${S_BAR} ${dim(parts.join(" "))}`); + } + } + + lines.push(`${S_BAR}`); + const allSelectedLabels = [ + ...(lockedSection ? lockedSection.items.map((i) => i.label) : []), + ...items.filter((item) => selected.has(item.value)).map((item) => item.label), + ]; + if (allSelectedLabels.length === 0) { + lines.push(`${S_BAR} ${dim("Selected: (none)")}`); + } else { + const summary = + allSelectedLabels.length <= 3 + ? allSelectedLabels.join(", ") + : `${allSelectedLabels.slice(0, 3).join(", ")} +${allSelectedLabels.length - 3} more`; + lines.push(`${S_BAR} ${green("Selected:")} ${summary}`); + } + + lines.push(`${dim("└")}`); + } else if (state === "submit") { + const allSelectedLabels = [ + ...(lockedSection ? lockedSection.items.map((i) => i.label) : []), + ...items.filter((item) => selected.has(item.value)).map((item) => item.label), + ]; + lines.push(`${S_BAR} ${dim(allSelectedLabels.join(", "))}`); + } else if (state === "cancel") { + lines.push(`${S_BAR} ${red("Cancelled")}`); + } + + process.stdout.write(lines.join("\n") + "\n"); + lastRenderHeight = lines.length; + }; + + const cleanup = (): void => { + process.stdin.removeListener("keypress", keypressHandler); + if (process.stdin.isTTY) { + process.stdin.setRawMode(false); + } + rl.close(); + }; + + const submit = (): void => { + render("submit"); + cleanup(); + process.stdout.write("\n"); + resolve([...lockedValues, ...Array.from(selected)]); + }; + + const cancel = (): void => { + render("cancel"); + cleanup(); + process.stdout.write("\n"); + resolve(cancelSymbol); + }; + + const keypressHandler = (_str: string, key: readline.Key): void => { + if (!key) return; + + const filtered = getFiltered(); + + if (key.name === "return") { + submit(); + return; + } + + if (key.name === "escape" || (key.ctrl && key.name === "c")) { + cancel(); + return; + } + + if (key.name === "up") { + cursor = Math.max(0, cursor - 1); + render(); + return; + } + + if (key.name === "down") { + cursor = Math.min(filtered.length - 1, cursor + 1); + render(); + return; + } + + if (key.name === "space") { + const item = filtered[cursor]; + if (item) { + if (selected.has(item.value)) { + selected.delete(item.value); + } else { + selected.add(item.value); + } + } + render(); + return; + } + + if (key.name === "backspace") { + query = query.slice(0, -1); + cursor = 0; + render(); + return; + } + + if (key.sequence && !key.ctrl && !key.meta && key.sequence.length === 1) { + query += key.sequence; + cursor = 0; + render(); + return; + } + }; + + process.stdin.on("keypress", keypressHandler); + + render(); + }); +} + +export async function interactiveSelect( + message: string, + items: SelectItem[] +): Promise { + const options: InteractiveSelectOptions = { + message, + items, + }; + + const result = await interactiveMultiSelect(options); + + if (result === cancelSymbol) { + return cancelSymbol; + } + + if (result.length === 0) { + return cancelSymbol; + } + + return result[0]; +} diff --git a/skillhub-cli/src/utils/search-multiselect.ts b/skillhub-cli/src/utils/search-multiselect.ts new file mode 100644 index 000000000..a208c2623 --- /dev/null +++ b/skillhub-cli/src/utils/search-multiselect.ts @@ -0,0 +1,297 @@ +import * as readline from 'readline'; +import { Writable } from 'stream'; +import pc from 'picocolors'; + +// Silent writable stream to prevent readline from echoing input +const silentOutput = new Writable({ + write(_chunk, _encoding, callback) { + callback(); + }, +}); + +export interface SearchItem { + value: T; + label: string; + hint?: string; +} + +export interface LockedSection { + title: string; + items: SearchItem[]; +} + +export interface SearchMultiselectOptions { + message: string; + items: SearchItem[]; + maxVisible?: number; + initialSelected?: T[]; + /** If true, require at least one item to be selected before submitting */ + required?: boolean; + /** Locked section shown above the searchable list - items are always selected and can't be toggled */ + lockedSection?: LockedSection; +} + +const S_STEP_ACTIVE = pc.green('◆'); +const S_STEP_CANCEL = pc.red('■'); +const S_STEP_SUBMIT = pc.green('◇'); +const S_RADIO_ACTIVE = pc.green('●'); +const S_RADIO_INACTIVE = pc.dim('○'); +const S_CHECKBOX_LOCKED = pc.green('✓'); +const S_BULLET = pc.green('•'); +const S_BAR = pc.dim('│'); +const S_BAR_H = pc.dim('─'); + +export const cancelSymbol = Symbol('cancel'); + +/** + * Interactive search multiselect prompt. + * Allows users to filter a long list by typing and select multiple items. + * Optionally supports a "locked" section that displays always-selected items. + */ +export async function searchMultiselect( + options: SearchMultiselectOptions +): Promise { + const { + message, + items, + maxVisible = 8, + initialSelected = [], + required = false, + lockedSection, + } = options; + + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: silentOutput, + terminal: false, + }); + + // Enable raw mode for keypress detection + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + readline.emitKeypressEvents(process.stdin, rl); + + let query = ''; + let cursor = 0; + const selected = new Set(initialSelected); + let lastRenderHeight = 0; + + // Locked items are always included in the result + const lockedValues = lockedSection ? lockedSection.items.map((i) => i.value) : []; + + const filter = (item: SearchItem, q: string): boolean => { + if (!q) return true; + const lowerQ = q.toLowerCase(); + return ( + item.label.toLowerCase().includes(lowerQ) || + String(item.value).toLowerCase().includes(lowerQ) + ); + }; + + const getFiltered = (): SearchItem[] => { + return items.filter((item) => filter(item, query)); + }; + + const clearRender = (): void => { + if (lastRenderHeight > 0) { + // Move up and clear each line + process.stdout.write(`\x1b[${lastRenderHeight}A`); + for (let i = 0; i < lastRenderHeight; i++) { + process.stdout.write('\x1b[2K\x1b[1B'); + } + process.stdout.write(`\x1b[${lastRenderHeight}A`); + } + }; + + const render = (state: 'active' | 'submit' | 'cancel' = 'active'): void => { + clearRender(); + + const lines: string[] = []; + const filtered = getFiltered(); + + // Header + const icon = + state === 'active' ? S_STEP_ACTIVE : state === 'cancel' ? S_STEP_CANCEL : S_STEP_SUBMIT; + lines.push(`${icon} ${pc.bold(message)}`); + + if (state === 'active') { + // Locked section (universal agents) + if (lockedSection && lockedSection.items.length > 0) { + lines.push(`${S_BAR}`); + const lockedTitle = `${pc.bold(lockedSection.title)} ${pc.dim('── always included')}`; + lines.push(`${S_BAR} ${S_BAR_H}${S_BAR_H} ${lockedTitle} ${S_BAR_H.repeat(12)}`); + for (const item of lockedSection.items) { + lines.push(`${S_BAR} ${S_BULLET} ${pc.bold(item.label)}`); + } + lines.push(`${S_BAR}`); + lines.push( + `${S_BAR} ${S_BAR_H}${S_BAR_H} ${pc.bold('Additional agents')} ${S_BAR_H.repeat(29)}` + ); + } + + // Search input + const searchLine = `${S_BAR} ${pc.dim('Search:')} ${query}${pc.inverse(' ')}`; + lines.push(searchLine); + + // Hint + lines.push(`${S_BAR} ${pc.dim('↑↓ move, space select, enter confirm')}`); + lines.push(`${S_BAR}`); + + // Items + const visibleStart = Math.max( + 0, + Math.min(cursor - Math.floor(maxVisible / 2), filtered.length - maxVisible) + ); + const visibleEnd = Math.min(filtered.length, visibleStart + maxVisible); + const visibleItems = filtered.slice(visibleStart, visibleEnd); + + if (filtered.length === 0) { + lines.push(`${S_BAR} ${pc.dim('No matches found')}`); + } else { + for (let i = 0; i < visibleItems.length; i++) { + const item = visibleItems[i]!; + const actualIndex = visibleStart + i; + const isSelected = selected.has(item.value); + const isCursor = actualIndex === cursor; + + const radio = isSelected ? S_RADIO_ACTIVE : S_RADIO_INACTIVE; + const label = isCursor ? pc.underline(item.label) : item.label; + const hint = item.hint ? pc.dim(` (${item.hint})`) : ''; + + const prefix = isCursor ? pc.cyan('❯') : ' '; + lines.push(`${S_BAR} ${prefix} ${radio} ${label}${hint}`); + } + + // Show count if more items + const hiddenBefore = visibleStart; + const hiddenAfter = filtered.length - visibleEnd; + if (hiddenBefore > 0 || hiddenAfter > 0) { + const parts: string[] = []; + if (hiddenBefore > 0) parts.push(`↑ ${hiddenBefore} more`); + if (hiddenAfter > 0) parts.push(`↓ ${hiddenAfter} more`); + lines.push(`${S_BAR} ${pc.dim(parts.join(' '))}`); + } + } + + // Selected summary (include locked items) + lines.push(`${S_BAR}`); + const allSelectedLabels = [ + ...(lockedSection ? lockedSection.items.map((i) => i.label) : []), + ...items.filter((item) => selected.has(item.value)).map((item) => item.label), + ]; + if (allSelectedLabels.length === 0) { + lines.push(`${S_BAR} ${pc.dim('Selected: (none)')}`); + } else { + const summary = + allSelectedLabels.length <= 3 + ? allSelectedLabels.join(', ') + : `${allSelectedLabels.slice(0, 3).join(', ')} +${allSelectedLabels.length - 3} more`; + lines.push(`${S_BAR} ${pc.green('Selected:')} ${summary}`); + } + + lines.push(`${pc.dim('└')}`); + } else if (state === 'submit') { + // Final state - show what was selected (including locked) + const allSelectedLabels = [ + ...(lockedSection ? lockedSection.items.map((i) => i.label) : []), + ...items.filter((item) => selected.has(item.value)).map((item) => item.label), + ]; + lines.push(`${S_BAR} ${pc.dim(allSelectedLabels.join(', '))}`); + } else if (state === 'cancel') { + lines.push(`${S_BAR} ${pc.strikethrough(pc.dim('Cancelled'))}`); + } + + process.stdout.write(lines.join('\n') + '\n'); + lastRenderHeight = lines.length; + }; + + const cleanup = (): void => { + process.stdin.removeListener('keypress', keypressHandler); + if (process.stdin.isTTY) { + process.stdin.setRawMode(false); + } + rl.close(); + }; + + const submit = (): void => { + // If required and no locked items, don't allow submitting with no selection + if (required && selected.size === 0 && lockedValues.length === 0) { + return; + } + render('submit'); + cleanup(); + // Include locked values in the result + resolve([...lockedValues, ...Array.from(selected)]); + }; + + const cancel = (): void => { + render('cancel'); + cleanup(); + resolve(cancelSymbol); + }; + + // Handle keypresses + const keypressHandler = (_str: string, key: readline.Key): void => { + if (!key) return; + + const filtered = getFiltered(); + + if (key.name === 'return') { + submit(); + return; + } + + if (key.name === 'escape' || (key.ctrl && key.name === 'c')) { + cancel(); + return; + } + + if (key.name === 'up') { + cursor = Math.max(0, cursor - 1); + render(); + return; + } + + if (key.name === 'down') { + cursor = Math.min(filtered.length - 1, cursor + 1); + render(); + return; + } + + if (key.name === 'space') { + const item = filtered[cursor]; + if (item) { + if (selected.has(item.value)) { + selected.delete(item.value); + } else { + selected.add(item.value); + } + } + render(); + return; + } + + if (key.name === 'backspace') { + query = query.slice(0, -1); + cursor = 0; + render(); + return; + } + + // Regular character input + if (key.sequence && !key.ctrl && !key.meta && key.sequence.length === 1) { + query += key.sequence; + cursor = 0; + render(); + return; + } + }; + + process.stdin.on('keypress', keypressHandler); + + // Initial render + render(); + }); +} diff --git a/skillhub-cli/src/utils/telemetry.ts b/skillhub-cli/src/utils/telemetry.ts new file mode 100644 index 000000000..3aa760ec1 --- /dev/null +++ b/skillhub-cli/src/utils/telemetry.ts @@ -0,0 +1,99 @@ +/** + * Telemetry utility for anonymous usage tracking. + * + * Respects the following environment variables: + * - DISABLE_TELEMETRY=1 (or 'true') + * - DO_NOT_TRACK=1 + * + * Telemetry is automatically disabled in CI environments. + */ + +export interface TelemetryEvent { + command: string; + args?: string[]; + options?: Record; +} + +export interface TelemetryConfig { + enabled: boolean; + reason?: string; +} + +/** + * Check if telemetry should be disabled. + * Respects user preferences and CI environments. + */ +export function isTelemetryDisabled(): TelemetryConfig { + // Check CI environment + if (process.env.CI === 'true' || process.env.CONTINUOUS_INTEGRATION === 'true') { + return { enabled: false, reason: 'CI environment' }; + } + + // Check explicit opt-out flags + const disableTelemetry = process.env.DISABLE_TELEMETRY; + const doNotTrack = process.env.DO_NOT_TRACK; + + if (disableTelemetry === '1' || disableTelemetry === 'true') { + return { enabled: false, reason: 'DISABLE_TELEMETRY is set' }; + } + + if (doNotTrack === '1' || doNotTrack === 'true') { + return { enabled: false, reason: 'DO_NOT_TRACK is set' }; + } + + // Check Node.js built-in doNotTrack + if (process.env.NODE_OPTIONS?.includes('do-not-track')) { + return { enabled: false, reason: 'NODE_OPTIONS includes do-not-track' }; + } + + return { enabled: true }; +} + +/** + * Track a command execution event. + * Currently a stub - actual tracking implementation would send to a telemetry endpoint. + */ +export function trackEvent(event: TelemetryEvent): void { + const { enabled, reason } = isTelemetryDisabled(); + + if (!enabled) { + // Silently skip tracking + return; + } + + // TODO: Implement actual telemetry tracking + // For now, this is a stub that could be expanded to: + // - Send events to a configured endpoint + // - Batch events and send periodically + // - Store events locally if offline + // + // Example implementation: + // if (process.env.SKILLHUB_TELEMETRY_URL) { + // fetch(process.env.SKILLHUB_TELEMETRY_URL, { + // method: 'POST', + // headers: { 'Content-Type': 'application/json' }, + // body: JSON.stringify({ event, timestamp: Date.now() }) + // }); + // } +} + +/** + * Track a command execution. + * Call this at the start of each command. + */ +export function trackCommand(command: string, args?: string[], options?: Record): void { + trackEvent({ command, args, options }); +} + +/** + * Get telemetry status for display (e.g., in --help or version output). + */ +export function getTelemetryStatus(): string { + const { enabled, reason } = isTelemetryDisabled(); + + if (!enabled) { + return `Telemetry: Disabled (${reason})`; + } + + return 'Telemetry: Enabled (anonymous usage collection)'; +} diff --git a/skillhub-cli/tests/api-client.test.ts b/skillhub-cli/tests/api-client.test.ts new file mode 100644 index 000000000..fd6b2a324 --- /dev/null +++ b/skillhub-cli/tests/api-client.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("undici", () => ({ + request: vi.fn(), + FormData: class FormData { + private _data = new Map(); + set(k: string, v: string) { this._data.set(k, v); } + append(k: string, v: unknown) { this._data.set(k, String(v)); } + }, +})); + +import { ApiClient, ApiError } from "../src/core/api-client.js"; +import { request } from "undici"; + +const mockRequest = request as ReturnType; + +function mockResponse(statusCode: number, body: unknown) { + mockRequest.mockResolvedValueOnce({ + statusCode, + body: { json: async () => body }, + }); +} + +describe("ApiClient", () => { + let client: ApiClient; + + beforeEach(() => { + vi.clearAllMocks(); + client = new ApiClient({ baseUrl: "http://localhost:8080" }); + }); + + describe("ApiResponse unwrapping", () => { + it("unwraps Native API success response", async () => { + mockResponse(200, { + code: 0, + msg: "success", + data: { id: 1, name: "test" }, + timestamp: "2026-01-01T00:00:00Z", + }); + + const result = await client.get<{ id: number; name: string }>("/api/v1/test"); + + expect(result).toEqual({ id: 1, name: "test" }); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + it("throws ApiError on Native API error response", async () => { + mockResponse(200, { + code: 403, + msg: "Forbidden", + data: null, + timestamp: "2026-01-01T00:00:00Z", + }); + + await expect(client.get("/api/v1/test")).rejects.toThrow(ApiError); + }); + + it("returns raw response for Compat layer (no code/data)", async () => { + mockResponse(200, { + user: { handle: "test-user", displayName: "Test", image: null }, + }); + + const result = await client.get<{ user: { handle: string } }>("/api/v1/whoami"); + + expect(result).toEqual({ + user: { handle: "test-user", displayName: "Test", image: null }, + }); + }); + + it("returns raw response for Compat search results", async () => { + mockResponse(200, { + results: [ + { slug: "test-skill", displayName: "Test", summary: "A test", version: "1.0.0" }, + ], + }); + + const result = await client.get<{ results: Array<{ slug: string }> }>("/api/v1/search"); + + expect(result.results).toHaveLength(1); + expect(result.results[0].slug).toBe("test-skill"); + }); + + it("returns raw response for Compat publish result", async () => { + mockResponse(200, { ok: true, skillId: "1", versionId: "1" }); + + const result = await client.postForm<{ ok: boolean; skillId: string }>("/api/v1/skills", {} as any); + + expect(result.ok).toBe(true); + expect(result.skillId).toBe("1"); + }); + }); + + describe("HTTP error handling", () => { + it("throws ApiError on HTTP 404", async () => { + mockResponse(404, { code: 404, msg: "Not found", data: null }); + + await expect(client.get("/api/v1/nonexistent")).rejects.toThrow(ApiError); + }); + + it("throws ApiError on HTTP 500", async () => { + mockResponse(500, { code: 500, msg: "Internal error", data: null }); + + await expect(client.get("/api/v1/test")).rejects.toThrow(ApiError); + }); + }); + + describe("Authorization header", () => { + it("includes Bearer token when provided", async () => { + mockResponse(200, { code: 0, data: {}, msg: "ok" }); + + const clientWithToken = new ApiClient({ + baseUrl: "http://localhost:8080", + token: "sk_test123", + }); + + await clientWithToken.get("/api/v1/test"); + + expect(mockRequest).toHaveBeenCalledWith( + "http://localhost:8080/api/v1/test", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer sk_test123", + }), + }) + ); + }); + + it("omits Authorization header when no token", async () => { + mockResponse(200, { results: [] }); + + await client.get("/api/v1/search"); + + const callArgs = mockRequest.mock.calls[0][1]; + expect(callArgs.headers).not.toHaveProperty("Authorization"); + }); + }); + + describe("POST method", () => { + it("unwraps Native API POST response", async () => { + mockResponse(200, { + code: 0, + data: { id: 1 }, + msg: "created", + }); + + const result = await client.post<{ id: number }>("/api/v1/test", { body: "{}" }); + + expect(result).toEqual({ id: 1 }); + }); + + it("returns raw Compat POST response", async () => { + mockResponse(200, { ok: true, skillId: "2" }); + + const result = await client.post<{ ok: boolean }>("/api/v1/skills", { body: "{}" }); + + expect(result.ok).toBe(true); + }); + }); + + describe("PUT method", () => { + it("unwraps Native API PUT response", async () => { + mockResponse(200, { code: 0, data: { updated: true }, msg: "ok" }); + + const result = await client.put<{ updated: boolean }>("/api/v1/test", { body: "{}" }); + + expect(result.updated).toBe(true); + }); + }); + + describe("DELETE method", () => { + it("unwraps Native API DELETE response", async () => { + mockResponse(200, { code: 0, data: { deleted: true }, msg: "ok" }); + + const result = await client.delete<{ deleted: boolean }>("/api/v1/test"); + + expect(result.deleted).toBe(true); + }); + }); +}); diff --git a/skillhub-cli/tests/commands.test.ts b/skillhub-cli/tests/commands.test.ts new file mode 100644 index 000000000..6b90d9dc6 --- /dev/null +++ b/skillhub-cli/tests/commands.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect } from "vitest"; +import { Command } from "commander"; +import { registerInspect } from "../src/commands/inspect.js"; +import { registerWhoami } from "../src/commands/whoami.js"; +import { registerLogin } from "../src/commands/login.js"; +import { registerPublish } from "../src/commands/publish.js"; +import { registerMe } from "../src/commands/me.js"; +import { registerNotifications } from "../src/commands/notifications.js"; +import { registerReviews } from "../src/commands/reviews.js"; +import { registerNamespaces } from "../src/commands/namespaces.js"; +import { registerResolve } from "../src/commands/resolve.js"; +import { registerRating, registerRate } from "../src/commands/rating.js"; +import { registerStar } from "../src/commands/star.js"; +import { registerDelete } from "../src/commands/delete.js"; +import { registerArchive } from "../src/commands/archive.js"; +import { registerReport } from "../src/commands/report.js"; +import { registerSearch } from "../src/commands/search.js"; +import { registerInstall } from "../src/commands/install.js"; +import { registerDownload } from "../src/commands/download.js"; +import { registerInit } from "../src/commands/init.js"; +import { registerList } from "../src/commands/list.js"; +import { registerLogout } from "../src/commands/logout.js"; +import { registerUninstall } from "../src/commands/uninstall.js"; +import { registerSync } from "../src/commands/sync.js"; + +describe("Command registrations", () => { + function getCommandNames(program: Command): string[] { + return program.commands.map((c) => c.name()); + } + + it("registers inspect command", () => { + const program = new Command(); + registerInspect(program); + const names = getCommandNames(program); + expect(names).toContain("inspect"); + }); + + it("registers whoami command", () => { + const program = new Command(); + registerWhoami(program); + expect(getCommandNames(program)).toContain("whoami"); + }); + + it("registers login command", () => { + const program = new Command(); + registerLogin(program); + expect(getCommandNames(program)).toContain("login"); + }); + + it("registers publish command with correct options", () => { + const program = new Command(); + registerPublish(program); + const cmd = program.commands.find((c) => c.name() === "publish"); + expect(cmd).toBeDefined(); + const opts = cmd!.options.map((o) => o.flags); + expect(opts).toContain("--namespace "); + expect(opts).toContain("--slug "); + expect(opts).toContain("-v, --skill-version "); + expect(opts).not.toContain("--version "); + }); + + it("registers me command with skills and stars subcommands", () => { + const program = new Command(); + registerMe(program); + const cmd = program.commands.find((c) => c.name() === "me"); + expect(cmd).toBeDefined(); + const subNames = cmd!.commands.map((c) => c.name()); + expect(subNames).toContain("skills"); + expect(subNames).toContain("stars"); + }); + + it("registers notifications command with subcommands", () => { + const program = new Command(); + registerNotifications(program); + const cmd = program.commands.find((c) => c.name() === "notifications"); + expect(cmd).toBeDefined(); + const subNames = cmd!.commands.map((c) => c.name()); + expect(subNames).toContain("list"); + expect(subNames).toContain("read"); + expect(subNames).toContain("read-all"); + }); + + it("registers reviews command with subcommands", () => { + const program = new Command(); + registerReviews(program); + const cmd = program.commands.find((c) => c.name() === "reviews"); + expect(cmd).toBeDefined(); + const subNames = cmd!.commands.map((c) => c.name()); + expect(subNames).toContain("my"); + }); + + it("registers namespaces command", () => { + const program = new Command(); + registerNamespaces(program); + expect(getCommandNames(program)).toContain("namespaces"); + }); + + it("registers resolve command", () => { + const program = new Command(); + registerResolve(program); + expect(getCommandNames(program)).toContain("resolve"); + }); + + it("registers rating and rate commands", () => { + const program = new Command(); + registerRating(program); + registerRate(program); + const names = getCommandNames(program); + expect(names).toContain("rating"); + expect(names).toContain("rate"); + }); + + it("registers star command", () => { + const program = new Command(); + registerStar(program); + expect(getCommandNames(program)).toContain("star"); + }); + + it("registers delete command", () => { + const program = new Command(); + registerDelete(program); + expect(getCommandNames(program)).toContain("delete"); + }); + + it("registers archive command", () => { + const program = new Command(); + registerArchive(program); + expect(getCommandNames(program)).toContain("archive"); + }); + + it("registers report command", () => { + const program = new Command(); + registerReport(program); + expect(getCommandNames(program)).toContain("report"); + }); + + it("registers search command", () => { + const program = new Command(); + registerSearch(program); + expect(getCommandNames(program)).toContain("search"); + }); + + it("registers install command", () => { + const program = new Command(); + registerInstall(program); + expect(getCommandNames(program)).toContain("install"); + }); + + it("registers download command", () => { + const program = new Command(); + registerDownload(program); + expect(getCommandNames(program)).toContain("download"); + }); + + it("registers init command", () => { + const program = new Command(); + registerInit(program); + expect(getCommandNames(program)).toContain("init"); + }); + + it("registers list command", () => { + const program = new Command(); + registerList(program); + expect(getCommandNames(program)).toContain("list"); + }); + + it("registers uninstall command", () => { + const program = new Command(); + registerUninstall(program); + expect(getCommandNames(program)).toContain("uninstall"); + }); + + it("registers logout command", () => { + const program = new Command(); + registerLogout(program); + expect(getCommandNames(program)).toContain("logout"); + }); + + it("registers sync command", () => { + const program = new Command(); + registerSync(program); + expect(getCommandNames(program)).toContain("sync"); + }); +}); diff --git a/skillhub-cli/tests/install.test.ts b/skillhub-cli/tests/install.test.ts new file mode 100644 index 000000000..eec77ccf5 --- /dev/null +++ b/skillhub-cli/tests/install.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const mockSuccess = vi.fn(); +const mockError = vi.fn(); +const mockInfo = vi.fn(); + +vi.mock("../src/utils/logger.js", () => ({ + success: mockSuccess, + error: mockError, + info: mockInfo, + dim: vi.fn(), +})); + +describe("install command", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), "install-test-" + Date.now()); + mkdirSync(tempDir, { recursive: true }); + mockSuccess.mockClear(); + mockError.mockClear(); + mockInfo.mockClear(); + }); + + afterEach(() => { + try { rmSync(tempDir, { recursive: true, force: true }); } catch {} + vi.restoreAllMocks(); + }); + + describe("source auto-detection", () => { + it("should detect git source: owner/repo format", () => { + const source = "vercel-labs/agent-skills"; + const isGitSource = /^[\w-]+\/[\w-]+$/.test(source); + expect(isGitSource).toBe(true); + }); + + it("should detect git source: GitHub URL", () => { + const source = "https://github.com/vercel-labs/agent-skills"; + expect(source.includes("github.com")).toBe(true); + }); + + it("should detect git source: GitLab URL", () => { + const source = "https://gitlab.com/vercel-labs/agent-skills"; + expect(source.includes("gitlab.com")).toBe(true); + }); + + it("should detect local source: relative path", () => { + const source = "./my-skill"; + const isLocal = source.startsWith(".") || source.startsWith("/") || source.startsWith("~"); + expect(isLocal).toBe(true); + }); + + it("should detect local source: absolute path", () => { + const source = "/Users/me/skills/my-skill"; + expect(source.startsWith("/")).toBe(true); + }); + + it("should detect registry source: plain slug", () => { + const source = "my-skill"; + const isGit = /^[\w-]+\/[\w-]+$/.test(source) || source.includes("github.com") || source.includes("gitlab.com"); + const isLocal = source.startsWith(".") || source.startsWith("/") || source.startsWith("~"); + expect(isGit || isLocal).toBe(false); + }); + + it("should detect registry source: namespace--slug format", () => { + const source = "global--my-skill"; + const isScoped = source.includes("--"); + expect(isScoped).toBe(true); + }); + }); + + describe("--list option", () => { + it("should list skills without installing", () => { + const skills = [ + { name: "skill-one", description: "First skill" }, + { name: "skill-two", description: "Second skill" }, + ]; + expect(skills.length).toBe(2); + expect(skills[0].name).toBe("skill-one"); + }); + }); + + describe("registry install", () => { + it("should construct correct download URL", () => { + const ns = "global"; + const slug = "my-skill"; + const downloadUrl = `/api/v1/skills/${ns}/${slug}/download`; + expect(downloadUrl).toBe("/api/v1/skills/global/my-skill/download"); + }); + + it("should use default namespace when not specified", () => { + const defaultNs = "global"; + expect(defaultNs).toBe("global"); + }); + }); + + describe("git install", () => { + it("should parse owner/repo correctly", () => { + const input = "vercel-labs/skills"; + const parts = input.split("/"); + expect(parts[0]).toBe("vercel-labs"); + expect(parts[1]).toBe("skills"); + }); + + it("should construct GitHub clone URL", () => { + const owner = "vercel-labs"; + const repo = "skills"; + const cloneUrl = `https://github.com/${owner}/${repo}.git`; + expect(cloneUrl).toBe("https://github.com/vercel-labs/skills.git"); + }); + }); + + describe("agent selection", () => { + it("should detect installed agents", () => { + const allAgents = [ + { key: "claude-code", name: "Claude Code" }, + { key: "cursor", name: "Cursor" }, + ]; + expect(allAgents.length).toBe(2); + }); + + it("should filter by specified agent keys", () => { + const allAgents = [ + { key: "claude-code", name: "Claude Code" }, + { key: "cursor", name: "Cursor" }, + ]; + const selected = allAgents.filter((a) => ["claude-code"].includes(a.key)); + expect(selected.length).toBe(1); + expect(selected[0].key).toBe("claude-code"); + }); + }); + + describe("install modes", () => { + it("should support symlink mode (default)", () => { + const mode = "symlink"; + expect(mode).toBe("symlink"); + }); + + it("should support copy mode with --copy flag", () => { + const useCopy = true; + const mode = useCopy ? "copy" : "symlink"; + expect(mode).toBe("copy"); + }); + + it("should support global scope with --global flag", () => { + const isGlobal = true; + expect(isGlobal).toBe(true); + }); + + it("should support project scope (default)", () => { + const isGlobal = false; + expect(isGlobal).toBe(false); + }); + }); +}); + +describe("--skill option", () => { + it("should select all skills when '*' is specified", () => { + const allSkills = [ + { name: "skill-one", description: "First" }, + { name: "skill-two", description: "Second" }, + { name: "skill-three", description: "Third" }, + ]; + const skillNames = ["*"] as string[]; + + let selectedSkills; + if (skillNames.includes("*")) { + selectedSkills = allSkills; + } + + expect(selectedSkills).toEqual(allSkills); + expect(selectedSkills.length).toBe(3); + }); + + it("should filter skills by exact name match", () => { + const allSkills = [ + { name: "skill-one", description: "First" }, + { name: "skill-two", description: "Second" }, + { name: "skill-three", description: "Third" }, + ]; + const skillNames = ["skill-one", "skill-three"] as string[]; + + const selectedSkills = allSkills.filter((s) => skillNames.includes(s.name)); + + expect(selectedSkills.length).toBe(2); + expect(selectedSkills[0].name).toBe("skill-one"); + expect(selectedSkills[1].name).toBe("skill-three"); + }); + + it("should return empty array when no skills match", () => { + const allSkills = [ + { name: "skill-one", description: "First" }, + { name: "skill-two", description: "Second" }, + ]; + const skillNames = ["non-existent"] as string[]; + + const selectedSkills = allSkills.filter((s) => skillNames.includes(s.name)); + + expect(selectedSkills.length).toBe(0); + }); + + it("should handle case-sensitive name matching", () => { + const allSkills = [ + { name: "OpenSpec", description: "OpenSpec skill" }, + { name: "openspec", description: "lowercase" }, + ]; + const skillNames = ["openspec"] as string[]; + + const selectedSkills = allSkills.filter((s) => skillNames.includes(s.name)); + + expect(selectedSkills.length).toBe(1); + expect(selectedSkills[0].name).toBe("openspec"); + }); +}); + +describe("add command alias", () => { + it("should be equivalent to install --source git", () => { + const installSource = "git"; + expect(installSource).toBe("git"); + }); +}); diff --git a/skillhub-cli/tests/installer.test.ts b/skillhub-cli/tests/installer.test.ts new file mode 100644 index 000000000..36582a407 --- /dev/null +++ b/skillhub-cli/tests/installer.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { installSkill } from "../src/core/installer.js"; +import { mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +describe("installSkill", () => { + let tempDir: string; + let skillDir: string; + let targetDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), "installer-test-" + Date.now()); + skillDir = join(tempDir, "source-skill"); + targetDir = tempDir; + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "# Test Skill\n"); + }); + + afterEach(() => { + try { rmSync(tempDir, { recursive: true, force: true }); } catch {} + }); + + it("should return correct mode when mode is 'symlink'", () => { + const result = installSkill(skillDir, "test-skill", "claude-code", targetDir, "symlink", false); + expect(result.mode).toBe("symlink"); + expect(result.success).toBe(true); + }); + + it("should return correct mode when mode is 'copy'", () => { + const result = installSkill(skillDir, "test-skill", "claude-code", targetDir, "copy", false); + expect(result.mode).toBe("copy"); + expect(result.success).toBe(true); + }); +}); diff --git a/skillhub-cli/tests/prompts.test.ts b/skillhub-cli/tests/prompts.test.ts new file mode 100644 index 000000000..abd4f23fa --- /dev/null +++ b/skillhub-cli/tests/prompts.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, vi } from "vitest"; + +describe("multiSelect parsing", () => { + it("parses comma-separated numbers", () => { + const input = "1,3,5"; + const parts = input.split(",").map((s) => s.trim()); + const indices = parts.map((p) => parseInt(p, 10) - 1); + const items = [ + { value: "a", label: "A" }, + { value: "b", label: "B" }, + { value: "c", label: "C" }, + { value: "d", label: "D" }, + { value: "e", label: "E" }, + ]; + const selected = indices.filter((i) => i >= 0 && i < items.length).map((i) => items[i].value); + expect(selected).toEqual(["a", "c", "e"]); + }); + + it("parses 'a' for all", () => { + const trimmed = "a"; + const items = [{ value: "a", label: "A" }, { value: "b", label: "B" }]; + if (trimmed === "a" || trimmed === "all") { + const selected = items.map((i) => i.value); + expect(selected).toEqual(["a", "b"]); + } + }); + + it("parses 'n' for none", () => { + const trimmed = "n"; + let result: string[] | null = null; + if (trimmed === "n" || trimmed === "no") { + result = null; + } + expect(result).toBe(null); + }); + + it("ignores out-of-range numbers", () => { + const input = "1,10,2"; + const parts = input.split(",").map((s) => s.trim()); + const items = [{ value: "a", label: "A" }, { value: "b", label: "B" }]; + const indices = parts.map((p) => parseInt(p, 10) - 1); + const selected = indices.filter((i) => i >= 0 && i < items.length).map((i) => items[i].value); + expect(selected).toEqual(["a", "b"]); + }); +}); diff --git a/skillhub-cli/tests/skill-lock.test.ts b/skillhub-cli/tests/skill-lock.test.ts new file mode 100644 index 000000000..a29460865 --- /dev/null +++ b/skillhub-cli/tests/skill-lock.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const LOCK_FILE_VERSION = 1; + +interface SkillLockEntry { + source: string; + sourceType: "git" | "registry" | "local"; + sourceUrl: string; + ref?: string; + namespace: string; + slug: string; + version: string; + fingerprint?: string; + installedAt: string; + updatedAt: string; +} + +interface SkillLockFile { + version: number; + skills: Record; + lastSelectedAgents?: string[]; +} + +function getSkillLockPath(dir: string): string { + return join(dir, "lock.json"); +} + +async function readSkillLock(dir: string): Promise { + const lockPath = getSkillLockPath(dir); + if (!existsSync(lockPath)) { + return { version: LOCK_FILE_VERSION, skills: {} }; + } + const content = readFileSync(lockPath, "utf-8"); + return JSON.parse(content); +} + +async function writeSkillLock(dir: string, lock: SkillLockFile): Promise { + const lockPath = getSkillLockPath(dir); + mkdirSync(dir, { recursive: true }); + writeFileSync(lockPath, JSON.stringify(lock, null, 2)); +} + +async function addToLock(dir: string, name: string, entry: SkillLockEntry): Promise { + const lock = await readSkillLock(dir); + const now = new Date().toISOString(); + const existing = lock.skills[name]; + lock.skills[name] = { + ...entry, + installedAt: existing?.installedAt ?? entry.installedAt ?? now, + updatedAt: now, + }; + await writeSkillLock(dir, lock); +} + +async function removeFromLock(dir: string, name: string): Promise { + const lock = await readSkillLock(dir); + if (!(name in lock.skills)) return false; + delete lock.skills[name]; + await writeSkillLock(dir, lock); + return true; +} + +async function getFromLock(dir: string, name: string): Promise { + const lock = await readSkillLock(dir); + return lock.skills[name] ?? null; +} + +describe("skill-lock", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), "skill-lock-test-" + Date.now()); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + try { rmSync(tempDir, { recursive: true, force: true }); } catch {} + }); + + describe("read/write", () => { + it("should read empty lock file", async () => { + const lock = await readSkillLock(tempDir); + expect(lock.version).toBe(LOCK_FILE_VERSION); + expect(lock.skills).toEqual({}); + }); + + it("should write and read lock file", async () => { + const entry: SkillLockEntry = { + source: "owner/repo", + sourceType: "git", + sourceUrl: "https://github.com/owner/repo", + namespace: "global", + slug: "my-skill", + version: "1.0.0", + installedAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }; + await addToLock(tempDir, "my-skill", entry); + + const lock = await readSkillLock(tempDir); + expect(Object.keys(lock.skills)).toContain("my-skill"); + expect(lock.skills["my-skill"].source).toBe("owner/repo"); + }); + }); + + describe("addToLock", () => { + it("should add new entry", async () => { + const entry: SkillLockEntry = { + source: "owner/repo", + sourceType: "git", + sourceUrl: "https://github.com/owner/repo", + namespace: "global", + slug: "new-skill", + version: "1.0.0", + installedAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }; + await addToLock(tempDir, "new-skill", entry); + + const result = await getFromLock(tempDir, "new-skill"); + expect(result).not.toBeNull(); + expect(result!.slug).toBe("new-skill"); + }); + + it("should preserve installedAt on update", async () => { + const entry1: SkillLockEntry = { + source: "owner/repo", + sourceType: "git", + sourceUrl: "https://github.com/owner/repo", + namespace: "global", + slug: "skill", + version: "1.0.0", + installedAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }; + await addToLock(tempDir, "skill", entry1); + + const savedFirst = await getFromLock(tempDir, "skill"); + expect(savedFirst!.installedAt).toBe("2024-01-01T00:00:00Z"); + + const entry2: SkillLockEntry = { + ...entry1, + version: "1.1.0", + }; + await addToLock(tempDir, "skill", entry2); + + const result = await getFromLock(tempDir, "skill"); + expect(result!.installedAt).toBe("2024-01-01T00:00:00Z"); + expect(result!.version).toBe("1.1.0"); + expect(result!.updatedAt).not.toBe("2024-01-01T00:00:00Z"); + }); + }); + + describe("removeFromLock", () => { + it("should remove existing entry", async () => { + const entry: SkillLockEntry = { + source: "owner/repo", + sourceType: "git", + sourceUrl: "https://github.com/owner/repo", + namespace: "global", + slug: "to-remove", + version: "1.0.0", + installedAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }; + await addToLock(tempDir, "to-remove", entry); + + const removed = await removeFromLock(tempDir, "to-remove"); + expect(removed).toBe(true); + + const result = await getFromLock(tempDir, "to-remove"); + expect(result).toBeNull(); + }); + + it("should return false for non-existent entry", async () => { + const removed = await removeFromLock(tempDir, "non-existent"); + expect(removed).toBe(false); + }); + }); + + describe("getFromLock", () => { + it("should return null for non-existent key", async () => { + const result = await getFromLock(tempDir, "non-existent"); + expect(result).toBeNull(); + }); + + it("should return entry for existing key", async () => { + const entry: SkillLockEntry = { + source: "global/my-skill", + sourceType: "registry", + sourceUrl: "https://registry.example.com/global/my-skill", + namespace: "global", + slug: "my-skill", + version: "2.0.0", + installedAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }; + await addToLock(tempDir, "my-skill", entry); + + const result = await getFromLock(tempDir, "my-skill"); + expect(result).not.toBeNull(); + expect(result!.version).toBe("2.0.0"); + expect(result!.sourceType).toBe("registry"); + }); + }); + + describe("lastSelectedAgents", () => { + it("should persist lastSelectedAgents", async () => { + const lock: SkillLockFile = { + version: LOCK_FILE_VERSION, + skills: {}, + lastSelectedAgents: ["claude-code", "cursor"], + }; + await writeSkillLock(tempDir, lock); + + const read = await readSkillLock(tempDir); + expect(read.lastSelectedAgents).toEqual(["claude-code", "cursor"]); + }); + }); +}); diff --git a/skillhub-cli/tests/skill-name.test.ts b/skillhub-cli/tests/skill-name.test.ts new file mode 100644 index 000000000..ba8d5e284 --- /dev/null +++ b/skillhub-cli/tests/skill-name.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import { parseSkillName } from "../src/core/skill-name.js"; + +describe("parseSkillName", () => { + it("should parse namespace/slug format", () => { + const result = parseSkillName("global/test"); + expect(result.namespace).toBe("global"); + expect(result.slug).toBe("test"); + }); + + it("should use default namespace for plain slug", () => { + const result = parseSkillName("test"); + expect(result.namespace).toBe("global"); + expect(result.slug).toBe("test"); + }); + + it("should allow custom default namespace", () => { + const result = parseSkillName("test", "vision2group"); + expect(result.namespace).toBe("vision2group"); + expect(result.slug).toBe("test"); + }); + + it("should handle team/namespace format", () => { + const result = parseSkillName("vision2group/test-publish"); + expect(result.namespace).toBe("vision2group"); + expect(result.slug).toBe("test-publish"); + }); + + it("should handle slug with multiple slashes (use first two parts)", () => { + const result = parseSkillName("a/b/c"); + expect(result.namespace).toBe("a"); + expect(result.slug).toBe("b/c"); + }); +}); diff --git a/skillhub-cli/tests/source-parser.test.ts b/skillhub-cli/tests/source-parser.test.ts new file mode 100644 index 000000000..3e5ab8d66 --- /dev/null +++ b/skillhub-cli/tests/source-parser.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi } from "vitest"; + +// local fs mock +vi.mock("node:fs", () => ({ + existsSync: vi.fn(), +})); + +it("parses local path", async () => { + vi.resetModules(); + vi.doMock("node:fs", () => ({ existsSync: vi.fn().mockReturnValue(true) })); + const mod = await import("../src/core/source-parser"); + const res = mod.parseSource("/abs/path"); + expect(res.type).toBe("local"); + expect(res.localPath).toBe("/abs/path"); +}); + +it("parses github url from github.com", async () => { + vi.resetModules(); + const mod = await import("../src/core/source-parser"); + const res = mod.parseSource("https://github.com/owner/repo.git"); + expect(res.type).toBe("github"); + expect(res.owner).toBe("owner"); + expect(res.repo).toBe("repo"); + expect(res.cloneUrl).toBe("https://github.com/owner/repo.git"); +}); + +it("parses shorthand owner/repo", async () => { + vi.resetModules(); + const mod = await import("../src/core/source-parser"); + const res = mod.parseSource("alice/awesome"); + expect(res.type).toBe("github"); + expect(res.owner).toBe("alice"); + expect(res.repo).toBe("awesome"); +}); + +it("throws on invalid source format", async () => { + vi.resetModules(); + const mod = await import("../src/core/source-parser"); + expect(() => mod.parseSource("invalid")).toThrow(); +}); + +it("getCloneUrl uses cloneUrl when provided", async () => { + vi.resetModules(); + const mod = await import("../src/core/source-parser"); + const url = mod.getCloneUrl({ type: "github", owner: "a", repo: "b", cloneUrl: "https://example.com/a/b.git" } as any); + expect(url).toBe("https://example.com/a/b.git"); +}); + +it("getCloneUrl builds default for github without cloneUrl", async () => { + vi.resetModules(); + const mod = await import("../src/core/source-parser"); + const url = mod.getCloneUrl({ type: "github", owner: "x", repo: "y" } as any); + expect(url).toBe("https://github.com/x/y.git"); +}); + +it("parses @skill syntax: owner/repo@skillname", async () => { + vi.resetModules(); + const mod = await import("../src/core/source-parser"); + const res = mod.parseSource("vercel-labs/skills@openspec"); + expect(res.type).toBe("github"); + expect(res.owner).toBe("vercel-labs"); + expect(res.repo).toBe("skills"); + expect(res.skillFilter).toBe("openspec"); +}); + +it("parses @skill syntax with branch: owner/repo@skillname", async () => { + vi.resetModules(); + const mod = await import("../src/core/source-parser"); + const res = mod.parseSource("owner/repo@something"); + expect(res.type).toBe("github"); + expect(res.owner).toBe("owner"); + expect(res.repo).toBe("repo"); + expect(res.skillFilter).toBe("something"); +}); + +it("does not confuse @ in path with @skill syntax", async () => { + vi.resetModules(); + const mod = await import("../src/core/source-parser"); + const res = mod.parseSource("https://github.com/owner/repo"); + expect(res.type).toBe("github"); + expect(res.owner).toBe("owner"); + expect(res.repo).toBe("repo"); + expect(res.skillFilter).toBeUndefined(); +}); diff --git a/skillhub-cli/tests/uninstall.test.ts b/skillhub-cli/tests/uninstall.test.ts new file mode 100644 index 000000000..504a931d3 --- /dev/null +++ b/skillhub-cli/tests/uninstall.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const mockSuccess = vi.fn(); +const mockError = vi.fn(); +const mockInfo = vi.fn(); + +vi.mock("../src/utils/logger.js", () => ({ + success: mockSuccess, + error: mockError, + info: mockInfo, + dim: vi.fn(), +})); + +describe("uninstall command", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), "uninstall-test-" + Date.now()); + mkdirSync(tempDir, { recursive: true }); + mockSuccess.mockClear(); + mockError.mockClear(); + mockInfo.mockClear(); + }); + + afterEach(() => { + try { rmSync(tempDir, { recursive: true, force: true }); } catch {} + vi.restoreAllMocks(); + }); + + describe("removeDir utility", () => { + it("should handle file removal", () => { + const testFile = join(tempDir, "test-file.txt"); + writeFileSync(testFile, "content"); + expect(existsSync(testFile)).toBe(true); + }); + + it("should handle directory removal recursively", () => { + const testSubDir = join(tempDir, "subdir", "nested"); + mkdirSync(testSubDir, { recursive: true }); + writeFileSync(join(testSubDir, "file.txt"), "content"); + expect(existsSync(testSubDir)).toBe(true); + }); + }); + + describe("--all flag", () => { + it("should discover all installed skills", async () => { + const skill1 = join(tempDir, ".claude", "skills", "skill-one"); + const skill2 = join(tempDir, ".claude", "skills", "skill-two"); + mkdirSync(skill1, { recursive: true }); + mkdirSync(skill2, { recursive: true }); + writeFileSync(join(skill1, "SKILL.md"), "# Skill One\n"); + writeFileSync(join(skill2, "SKILL.md"), "# Skill Two\n"); + + expect(existsSync(skill1)).toBe(true); + expect(existsSync(skill2)).toBe(true); + }); + }); + + describe("--agent filter", () => { + it("should filter by specific agent", () => { + const claudeDir = join(tempDir, ".claude", "skills", "shared-skill"); + const cursorDir = join(tempDir, ".agents", "skills", "shared-skill"); + mkdirSync(claudeDir, { recursive: true }); + mkdirSync(cursorDir, { recursive: true }); + writeFileSync(join(claudeDir, "SKILL.md"), "# Shared Skill\n"); + writeFileSync(join(cursorDir, "SKILL.md"), "# Shared Skill\n"); + + expect(existsSync(claudeDir)).toBe(true); + expect(existsSync(cursorDir)).toBe(true); + }); + }); + + describe("--global flag", () => { + it("should target global scope only", () => { + const globalSkill = join(tempDir, ".claude", "skills", "global-skill"); + mkdirSync(globalSkill, { recursive: true }); + writeFileSync(join(globalSkill, "SKILL.md"), "# Global Skill\n"); + + expect(existsSync(globalSkill)).toBe(true); + }); + }); +}); + +describe("source parser", () => { + describe("parseSource", () => { + it("should identify git source: owner/repo", () => { + const source = "vercel-labs/agent-skills"; + const pattern = /^[\w-]+\/[\w-]+/; + expect(pattern.test(source)).toBe(true); + }); + + it("should identify git source: GitHub URL", () => { + const source = "https://github.com/vercel-labs/agent-skills"; + expect(source.startsWith("https://github.com/")).toBe(true); + }); + + it("should identify registry source: slug", () => { + const source = "my-skill"; + const isGit = /^[\w-]+\/[\w-]+/.test(source) || source.startsWith("https://github.com/"); + expect(isGit).toBe(false); + }); + + it("should identify registry source: namespace--slug", () => { + const source = "global--my-skill"; + const parts = source.split("--"); + expect(parts.length >= 2).toBe(true); + }); + }); +}); diff --git a/skillhub-cli/tsconfig.json b/skillhub-cli/tsconfig.json new file mode 100644 index 000000000..ab8d1ce7d --- /dev/null +++ b/skillhub-cli/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/skillhub-cli/unbuild.config.ts b/skillhub-cli/unbuild.config.ts new file mode 100644 index 000000000..dee0b39f3 --- /dev/null +++ b/skillhub-cli/unbuild.config.ts @@ -0,0 +1,10 @@ +import { defineBuildConfig } from "unbuild"; + +export default defineBuildConfig({ + entries: ["src/cli"], + outDir: "dist", + clean: true, + rollup: { + emitCJS: false, + }, +}); diff --git a/skillhub-cli/vitest.config.ts b/skillhub-cli/vitest.config.ts new file mode 100644 index 000000000..3f824fb95 --- /dev/null +++ b/skillhub-cli/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + }, +}); diff --git a/web/src/docs/skill.md b/web/src/docs/skill.md index 18ea1e88c..e692b7d22 100644 --- a/web/src/docs/skill.md +++ b/web/src/docs/skill.md @@ -1,13 +1,13 @@ --- name: skillhub-registry -description: Use this when you need to search, inspect, install, or publish agent skills against a SkillHub registry. SkillHub is a skill registry with a ClawHub-compatible API layer, so prefer the `clawhub` CLI for registry operations instead of making raw HTTP calls. +description: Use this when you need to search, inspect, install, or publish agent skills against a SkillHub registry. SkillHub is a skill registry with a ClawHub-compatible API layer, so prefer the `skillhub` CLI for registry operations instead of making raw HTTP calls. --- # SkillHub Registry Use this skill when you need to work with a SkillHub registry: search skills, inspect metadata, install a package, or publish a new version. -> Important: Prefer the `clawhub` CLI for registry workflows. SkillHub exposes a ClawHub-compatible API surface and a discovery endpoint at `/.well-known/clawhub.json`, so the CLI is the safest path for auth, resolution, and download behavior. Only fall back to raw HTTP when debugging the server itself. +> Important: Prefer the `skillhub` CLI for registry workflows. SkillHub exposes a ClawHub-compatible API surface and a discovery endpoint at `/.well-known/clawhub.json`, so the CLI is the safest path for auth, resolution, and download behavior. Only fall back to raw HTTP when debugging the server itself. ## What SkillHub Is @@ -16,8 +16,8 @@ SkillHub is an enterprise-oriented skill registry. It stores versioned skill pac Key facts: - Internal coordinates use `@{namespace}/{skill_slug}`. -- If using the clawhub CLI, the compatible format is `{namespace}--{skill_slug}`. -- ClawHub-compatible clients use a `{namespace}--{skill_slug}` slug instead. +- If using the skillhub CLI, the compatible format is `{namespace}--{skill_slug}`. +- ClawHub-compatible clients use a `{namespace}--{skill_slug}` slug format. - `latest` always means the latest published version, never draft or pending review. - Public skills in `@global` can be downloaded anonymously. - If no namespace is specified, it defaults to `@global`. @@ -26,23 +26,23 @@ Key facts: ## Configure The CLI -Point `clawhub` at the SkillHub base URL: +Point `skillhub` at the SkillHub base URL: ```bash -export CLAWHUB_REGISTRY=https://skillhub.your-company.com +export SKILLHUB_REGISTRY=https://skillhub.your-company.com ``` Alternatively, use the `--registry` parameter every time, for example: ```bash -npx clawhub install my-skill --registry https://skillhub.your-company.com +npx motovis-skillhub install my-skill --registry https://skillhub.your-company.com ``` If you need authenticated access, provide an API token: ```bash -clawhub login --token sk_your_api_token_here +skillhub login --token sk_your_api_token_here ``` Optional local check: @@ -61,23 +61,23 @@ Expected response: SkillHub has two naming forms: -| SkillHub coordinate | Canonical slug for `clawhub` | +| SkillHub coordinate | CLI format | |---|---| | `@global/my-skill` | `my-skill` | -| `@team-name/my-skill` | `team-name--my-skill` | +| `@team-name/my-skill` | `team-name/my-skill` | Rules: -- `--` is the namespace separator in the compatibility layer. -- If there is no `--`, the skill is treated as `@global/...`. +- `/` is the namespace separator in CLI commands. +- If no namespace is specified, the skill is treated as `@global/...`. - `latest` resolves to the latest published version only. Examples: ```bash -npx clawhub install my-skill -npx clawhub install my-skill@1.2.0 -npx clawhub install team-name--my-skill +npx motovis-skillhub install my-skill +npx motovis-skillhub install my-skill@1.2.0 +npx motovis-skillhub install team-name/my-skill ``` ## Common Workflows @@ -85,28 +85,28 @@ npx clawhub install team-name--my-skill ### Search ```bash -npx clawhub search email +npx motovis-skillhub search email ``` Use an empty query when you want a broad listing: ```bash -npx clawhub search "" +npx motovis-skillhub search "" ``` ### Inspect A Skill ```bash -npx clawhub info my-skill -npx clawhub info team-name--my-skill +npx motovis-skillhub info my-skill +npx motovis-skillhub info team-name/my-skill ``` ### Install ```bash -npx clawhub install my-skill -npx clawhub install my-skill@1.2.0 -npx clawhub install team-name--my-skill +npx motovis-skillhub install my-skill +npx motovis-skillhub install my-skill@1.2.0 +npx motovis-skillhub install team-name/my-skill ``` ### Publish @@ -114,7 +114,7 @@ npx clawhub install team-name--my-skill Prepare a skill package directory, then publish it: ```bash -npx clawhub publish ./my-skill +npx motovis-skillhub publish ./my-skill ``` Publishing requires authentication and sufficient permissions in the target namespace. diff --git a/web/src/docs/skill.md.template b/web/src/docs/skill.md.template index 1d13200f9..c636b0a8e 100644 --- a/web/src/docs/skill.md.template +++ b/web/src/docs/skill.md.template @@ -1,13 +1,13 @@ --- name: skillhub-registry -description: Use this when you need to search, inspect, install, or publish agent skills against a SkillHub registry. SkillHub is a skill registry with a ClawHub-compatible API layer, so prefer the `clawhub` CLI for registry operations instead of making raw HTTP calls. +description: Use this when you need to search, inspect, install, or publish agent skills against a SkillHub registry. SkillHub is a skill registry with a ClawHub-compatible API layer, so prefer the `skillhub` CLI for registry operations instead of making raw HTTP calls. --- # SkillHub Registry Use this skill when you need to work with a SkillHub registry: search skills, inspect metadata, install a package, or publish a new version. -> Important: Prefer the `clawhub` CLI for registry workflows. SkillHub exposes a ClawHub-compatible API surface and a discovery endpoint at `/.well-known/clawhub.json`, so the CLI is the safest path for auth, resolution, and download behavior. Only fall back to raw HTTP when debugging the server itself. +> Important: Prefer the `skillhub` CLI for registry workflows. SkillHub exposes a ClawHub-compatible API surface and a discovery endpoint at `/.well-known/clawhub.json`, so the CLI is the safest path for auth, resolution, and download behavior. Only fall back to raw HTTP when debugging the server itself. ## What SkillHub Is @@ -16,8 +16,8 @@ SkillHub is an enterprise-oriented skill registry. It stores versioned skill pac Key facts: - Internal coordinates use `@{namespace}/{skill_slug}`. -- If using the clawhub CLI, the compatible format is `{namespace}--{skill_slug}`. -- ClawHub-compatible clients use a `{namespace}--{skill_slug}` slug instead. +- If using the skillhub CLI, the compatible format is `{namespace}--{skill_slug}`. +- ClawHub-compatible clients use a `{namespace}--{skill_slug}` slug format. - `latest` always means the latest published version, never draft or pending review. - Public skills in `@global` can be downloaded anonymously. - If no namespace is specified, it defaults to `@global`. @@ -26,23 +26,23 @@ Key facts: ## Configure The CLI -Point `clawhub` at the SkillHub base URL: +Point `skillhub` at the SkillHub base URL: ```bash -export CLAWHUB_REGISTRY=${SKILLHUB_PUBLIC_BASE_URL} +export SKILLHUB_REGISTRY=${SKILLHUB_PUBLIC_BASE_URL} ``` Alternatively, use the `--registry` parameter every time, for example: ```bash -npx clawhub install my-skill --registry ${SKILLHUB_PUBLIC_BASE_URL} +npx motovis-skillhub install my-skill --registry ${SKILLHUB_PUBLIC_BASE_URL} ``` If you need authenticated access, provide an API token: ```bash -clawhub login --token sk_your_api_token_here +skillhub login --token sk_your_api_token_here ``` Optional local check: @@ -61,23 +61,23 @@ Expected response: SkillHub has two naming forms: -| SkillHub coordinate | Canonical slug for `clawhub` | +| SkillHub coordinate | CLI format | |---|---| | `@global/my-skill` | `my-skill` | -| `@team-name/my-skill` | `team-name--my-skill` | +| `@team-name/my-skill` | `team-name/my-skill` | Rules: -- `--` is the namespace separator in the compatibility layer. -- If there is no `--`, the skill is treated as `@global/...`. +- `/` is the namespace separator in CLI commands. +- If no namespace is specified, the skill is treated as `@global/...`. - `latest` resolves to the latest published version only. Examples: ```bash -npx clawhub install my-skill -npx clawhub install my-skill@1.2.0 -npx clawhub install team-name--my-skill +npx motovis-skillhub install my-skill +npx motovis-skillhub install my-skill@1.2.0 +npx motovis-skillhub install team-name/my-skill ``` ## Common Workflows @@ -85,28 +85,28 @@ npx clawhub install team-name--my-skill ### Search ```bash -npx clawhub search email +npx motovis-skillhub search email ``` Use an empty query when you want a broad listing: ```bash -npx clawhub search "" +npx motovis-skillhub search "" ``` ### Inspect A Skill ```bash -npx clawhub info my-skill -npx clawhub info team-name--my-skill +npx motovis-skillhub info my-skill +npx motovis-skillhub info team-name/my-skill ``` ### Install ```bash -npx clawhub install my-skill -npx clawhub install my-skill@1.2.0 -npx clawhub install team-name--my-skill +npx motovis-skillhub install my-skill +npx motovis-skillhub install my-skill@1.2.0 +npx motovis-skillhub install team-name/my-skill ``` ### Publish @@ -114,7 +114,7 @@ npx clawhub install team-name--my-skill Prepare a skill package directory, then publish it: ```bash -npx clawhub publish ./my-skill +npx motovis-skillhub publish ./my-skill ``` Publishing requires authentication and sufficient permissions in the target namespace. diff --git a/web/src/features/skill/install-command.test.ts b/web/src/features/skill/install-command.test.ts index 1f4daf504..cecd209c3 100644 --- a/web/src/features/skill/install-command.test.ts +++ b/web/src/features/skill/install-command.test.ts @@ -51,14 +51,14 @@ describe('install-command', () => { it('uses the plain slug for the global namespace', () => { expect(buildInstallTarget('global', 'my-skill')).toBe('my-skill') expect(buildInstallCommand('global', 'my-skill', 'https://skill.xfyun.cn')).toBe( - 'npx clawhub install my-skill --registry https://skill.xfyun.cn', + 'npx motovis-skillhub install my-skill --registry https://skill.xfyun.cn', ) }) it('prefixes non-global namespaces in the install target', () => { - expect(buildInstallTarget('team-alpha', 'my-skill')).toBe('team-alpha--my-skill') + expect(buildInstallTarget('team-alpha', 'my-skill')).toBe('team-alpha/my-skill') expect(buildInstallCommand('team-alpha', 'my-skill', 'https://skill.xfyun.cn')).toBe( - 'npx clawhub install team-alpha--my-skill --registry https://skill.xfyun.cn', + 'npx motovis-skillhub install team-alpha/my-skill --registry https://skill.xfyun.cn', ) }) diff --git a/web/src/features/skill/install-command.tsx b/web/src/features/skill/install-command.tsx index f9989abb3..c1330ed9e 100644 --- a/web/src/features/skill/install-command.tsx +++ b/web/src/features/skill/install-command.tsx @@ -11,7 +11,7 @@ interface InstallCommandProps { } export function buildInstallTarget(namespace: string, slug: string): string { - return namespace === 'global' ? slug : `${namespace}--${slug}` + return namespace === 'global' ? slug : `${namespace}/${slug}` } export function getBaseUrl(): string { @@ -30,7 +30,7 @@ export function getBaseUrl(): string { export function buildInstallCommand(namespace: string, slug: string, baseUrl: string): string { const installTarget = buildInstallTarget(namespace, slug) - return `npx clawhub install ${installTarget} --registry ${baseUrl}` + return `npx motovis-skillhub install ${installTarget} --registry ${baseUrl}` } export function InstallCommand({ namespace, slug }: InstallCommandProps) { diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index c96495f9d..ef1a3ef3b 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -112,22 +112,22 @@ }, "human": { "description": "Use the CLI tool to install Skills", - "command": "npx clawhub search " + "command": "npx motovis-skillhub search " }, "steps": { "configureEnv": { "title": "1. Configure Environment Variables", - "description": "Configure ClawHub CLI to connect to SkillHub" + "description": "Configure SkillHub CLI to connect to Registry" }, "installSkills": { "title": "2. Install Skills", "description": "Search and install the skills you need", - "code": "# Search skills\nclawhub search \n\n# Install skill\nclawhub install " + "code": "# Search skills\nskillhub search \n\n# Install skill\nskillhub install " }, "publishSkills": { "title": "3. Publish Skills", "description": "Share your skills with the team", - "code": "# Publish skill\nclawhub publish\n\n# Or use web interface\n# Click \"Publish Skill\"" + "code": "# Publish skill\nskillhub publish\n\n# Or use web interface\n# Click \"Publish Skill\"" } } } @@ -150,17 +150,17 @@ "steps": { "configureEnv": { "title": "1. Configure Environment Variables", - "description": "Configure ClawHub CLI to connect to SkillHub" + "description": "Configure SkillHub CLI to connect to Registry" }, "installSkills": { "title": "2. Install Skills", "description": "Search and install the skills you need", - "code": "# Search skills\nclawhub search \n\n# Install skill\nclawhub install " + "code": "# Search skills\nskillhub search \n\n# Install skill\nskillhub install " }, "publishSkills": { "title": "3. Publish Skills", "description": "Share your skills with the team", - "code": "# Publish skill\nclawhub publish\n\n# Or use web interface\n# Click \"Publish Skill\"" + "code": "# Publish skill\nskillhub publish\n\n# Or use web interface\n# Click \"Publish Skill\"" } } } diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 122192852..3d7f9fa08 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -112,22 +112,22 @@ }, "human": { "description": "使用CLI工具安装Skills", - "command": "npx clawhub search " + "command": "npx motovis-skillhub search " }, "steps": { "configureEnv": { "title": "1. 配置环境变量", - "description": "设置 ClawHub CLI 连接到 SkillHub" + "description": "设置 SkillHub CLI 连接到 Registry" }, "installSkills": { "title": "2. 安装技能", "description": "搜索并安装你需要的技能", - "code": "# 搜索技能\nclawhub search \n\n# 安装技能\nclawhub install " + "code": "# 搜索技能\nskillhub search \n\n# 安装技能\nskillhub install " }, "publishSkills": { "title": "3. 发布技能", "description": "分享你的技能给团队使用", - "code": "# 发布技能\nclawhub publish\n\n# 或使用网页界面\n# 点击\"发布技能\"" + "code": "# 发布技能\nskillhub publish\n\n# 或使用网页界面\n# 点击\"发布技能\"" } } } @@ -150,17 +150,17 @@ "steps": { "configureEnv": { "title": "1. 配置环境变量", - "description": "设置 ClawHub CLI 连接到 SkillHub" + "description": "设置 SkillHub CLI 连接到 Registry" }, "installSkills": { "title": "2. 安装技能", "description": "搜索并安装你需要的技能", - "code": "# 搜索技能\nclawhub search \n\n# 安装技能\nclawhub install " + "code": "# 搜索技能\nskillhub search \n\n# 安装技能\nskillhub install " }, "publishSkills": { "title": "3. 发布技能", "description": "分享你的技能给团队使用", - "code": "# 发布技能\nclawhub publish\n\n# 或使用网页界面\n# 点击\"发布技能\"" + "code": "# 发布技能\nskillhub publish\n\n# 或使用网页界面\n# 点击\"发布技能\"" } } } diff --git a/web/src/shared/components/quick-start.tsx b/web/src/shared/components/quick-start.tsx index daedcb05e..2a0cd18c1 100644 --- a/web/src/shared/components/quick-start.tsx +++ b/web/src/shared/components/quick-start.tsx @@ -64,11 +64,11 @@ function CodeLine({ line }: { line: string }) { ) } - if (line.startsWith('clawhub')) { + if (line.startsWith('skillhub')) { return ( <> - clawhub - {line.slice(7)} + skillhub + {line.slice(8)} ) } @@ -132,19 +132,17 @@ export function QuickStartSection({ variant = 'page', ns = 'landing' }: QuickSta const baseUrl = useMemo(() => getAppBaseUrl(), []) const envCode = `# Linux/macOS -export CLAWHUB_SITE=${baseUrl} -export CLAWHUB_REGISTRY=${baseUrl} +export SKILLHUB_REGISTRY=${baseUrl} # Windows PowerShell -$env:CLAWHUB_SITE = '${baseUrl}' -$env:CLAWHUB_REGISTRY = '${baseUrl}'` +$env:SKILLHUB_REGISTRY = '${baseUrl}'` const installCode = t(`${ns}.quickStart.steps.installSkills.code`, { - defaultValue: '# 搜索技能\nclawhub search \n\n# 安装技能\nclawhub install ', + defaultValue: '# 搜索技能\nskillhub search \n\n# 安装技能\nskillhub install ', }) const publishCode = t(`${ns}.quickStart.steps.publishSkills.code`, { - defaultValue: '# 发布技能\nclawhub publish\n\n# 或使用网页界面\n# 点击"发布技能"', + defaultValue: '# 发布技能\nskillhub publish\n\n# 或使用网页界面\n# 点击"发布技能"', }) const steps: CodeBlockProps[] = [