mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
fix(compat): support namespace-aware clawhub publish (#291)
This commit is contained in:
parent
532d0450aa
commit
1184e00a00
7 changed files with 189 additions and 9 deletions
10
README.md
10
README.md
|
|
@ -397,10 +397,16 @@ npx clawhub search email
|
|||
npx clawhub install my-skill
|
||||
npx clawhub install my-namespace--my-skill
|
||||
|
||||
# Publish a skill
|
||||
npx clawhub publish ./my-skill
|
||||
# Publish to global namespace
|
||||
npx clawhub publish ./my-skill --slug my-skill --version 1.0.0
|
||||
|
||||
# Publish to a team namespace such as my-space
|
||||
npx clawhub publish ./my-skill --slug my-space--my-skill --version 1.0.0
|
||||
```
|
||||
|
||||
`my-space--my-skill` is the canonical compat slug. SkillHub parses it as
|
||||
namespace `my-space` plus skill slug `my-skill`.
|
||||
|
||||
> 💡 **Tip**: The above commands are not only applicable to OpenClaw, but also to other CLI Coding Agents or Agent assistants by specifying the installation directory (`--dir`). For example: `npx clawhub --dir ~/.claude/skills install my-skill`
|
||||
|
||||
📖 **[Complete OpenClaw Integration Guide →](./docs/openclaw-integration.md)**
|
||||
|
|
|
|||
10
README_zh.md
10
README_zh.md
|
|
@ -331,10 +331,16 @@ npx clawhub search email
|
|||
npx clawhub install my-skill
|
||||
npx clawhub install my-namespace--my-skill
|
||||
|
||||
# 发布技能
|
||||
npx clawhub publish ./my-skill
|
||||
# 发布到 global 空间
|
||||
npx clawhub publish ./my-skill --slug my-skill --version 1.0.0
|
||||
|
||||
# 发布到如 my-space 这样的团队空间
|
||||
npx clawhub publish ./my-skill --slug my-space--my-skill --version 1.0.0
|
||||
```
|
||||
|
||||
其中 `my-space--my-skill` 是兼容层使用的 canonical slug,SkillHub 会将其解析为
|
||||
namespace `my-space` 和 skill slug `my-skill`。
|
||||
|
||||
> 💡 **提示**:上述命令不仅适用于 OpenClaw,通过指定安装目录(`--dir`),也可适用于其他的 CLI Coding Agent 或 Agent 助手。例如:`npx clawhub --dir ~/.claude/skills install my-skill`
|
||||
|
||||
📖 **[完整 OpenClaw 集成指南 →](./docs/openclaw-integration.md)**
|
||||
|
|
|
|||
|
|
@ -110,8 +110,11 @@ npx clawhub list --help
|
|||
### 5. Publish Skills
|
||||
|
||||
```bash
|
||||
# Publish skill (requires appropriate permissions)
|
||||
# Publish to the global namespace (requires appropriate permissions)
|
||||
npx clawhub publish ./my-skill --slug my-skill --name "My Skill" --version 1.0.0
|
||||
|
||||
# Publish to a team namespace such as my-space
|
||||
npx clawhub publish ./my-skill --slug my-space--my-skill --name "My Skill" --version 1.0.0
|
||||
npx clawhub sync --all # Upload all skills in current folder
|
||||
|
||||
# Help
|
||||
|
|
@ -119,6 +122,10 @@ npx clawhub publish --help
|
|||
npx clawhub sync --help
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `my-space--my-skill` is the canonical compatibility slug. SkillHub parses it as namespace `my-space` plus skill slug `my-skill`
|
||||
- To avoid mismatches between CLI display text and the final persisted coordinate, keep the `name` in `SKILL.md` aligned with the canonical slug suffix
|
||||
|
||||
## API Endpoints
|
||||
|
||||
SkillHub compatibility layer provides the following endpoints:
|
||||
|
|
|
|||
|
|
@ -110,8 +110,11 @@ npx clawhub list --help
|
|||
### 5. 发布技能
|
||||
|
||||
```bash
|
||||
# 发布技能(需要相应权限)
|
||||
# 发布到 global 空间(需要相应权限)
|
||||
npx clawhub publish ./my-skill --slug my-skill --name "My Skill" --version 1.0.0
|
||||
|
||||
# 发布到如 my-space 这样的团队空间
|
||||
npx clawhub publish ./my-skill --slug my-space--my-skill --name "My Skill" --version 1.0.0
|
||||
npx clawhub sync --all # 上传当前文件夹中所有的 skill
|
||||
|
||||
# 使用帮助
|
||||
|
|
@ -119,6 +122,10 @@ npx clawhub publish --help
|
|||
npx clawhub sync --help
|
||||
```
|
||||
|
||||
说明:
|
||||
- `my-space--my-skill` 是兼容层 canonical slug,SkillHub 会将其解析为 namespace `my-space` 和 skill slug `my-skill`
|
||||
- 为避免 CLI 展示与服务端最终坐标不一致,建议让 `SKILL.md` 中的 `name` 与 canonical slug 后半段保持一致
|
||||
|
||||
## API 端点说明
|
||||
|
||||
SkillHub 兼容层提供以下端点:
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import java.util.List;
|
|||
import java.util.Map;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/**
|
||||
|
|
@ -37,6 +38,8 @@ import org.springframework.web.multipart.MultipartFile;
|
|||
@Service
|
||||
public class ClawHubCompatAppService {
|
||||
|
||||
private static final String GLOBAL_NAMESPACE = "global";
|
||||
|
||||
private final CanonicalSlugMapper mapper;
|
||||
private final SkillSearchAppService skillSearchAppService;
|
||||
private final SkillQueryService skillQueryService;
|
||||
|
|
@ -268,7 +271,7 @@ public class ClawHubCompatAppService {
|
|||
String clientIp,
|
||||
String userAgent) throws IOException {
|
||||
MultipartPackageExtractor.ExtractedPackage extracted = multipartPackageExtractor.extract(files, payloadJson);
|
||||
String namespace = determineNamespace(principal, extracted.payload());
|
||||
String namespace = determineNamespace(extracted.payload());
|
||||
SkillPublishService.PublishResult result = skillPublishService.publishFromEntries(
|
||||
namespace,
|
||||
extracted.entries(),
|
||||
|
|
@ -371,8 +374,28 @@ public class ClawHubCompatAppService {
|
|||
);
|
||||
}
|
||||
|
||||
private String determineNamespace(PlatformPrincipal principal, MultipartPackageExtractor.PublishPayload payload) {
|
||||
return "global";
|
||||
private String determineNamespace(MultipartPackageExtractor.PublishPayload payload) {
|
||||
if (payload == null) {
|
||||
return GLOBAL_NAMESPACE;
|
||||
}
|
||||
|
||||
if (StringUtils.hasText(payload.namespace())) {
|
||||
return normalizeNamespace(payload.namespace());
|
||||
}
|
||||
|
||||
if (StringUtils.hasText(payload.slug()) && payload.slug().contains("--")) {
|
||||
return mapper.fromCanonical(payload.slug()).namespace();
|
||||
}
|
||||
|
||||
return GLOBAL_NAMESPACE;
|
||||
}
|
||||
|
||||
private String normalizeNamespace(String namespace) {
|
||||
String trimmed = namespace.trim();
|
||||
if (trimmed.startsWith("@")) {
|
||||
return trimmed.substring(1);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private void recordCompatPublishAudit(String userId,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ public class MultipartPackageExtractor {
|
|||
}
|
||||
|
||||
public record PublishPayload(
|
||||
String namespace,
|
||||
String slug,
|
||||
String displayName,
|
||||
String version,
|
||||
|
|
|
|||
|
|
@ -2,35 +2,46 @@ package com.iflytek.skillhub.compat;
|
|||
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.auth.device.DeviceAuthService;
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersion;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse;
|
||||
import com.iflytek.skillhub.dto.SkillSummaryResponse;
|
||||
import com.iflytek.skillhub.service.SkillSearchAppService;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@SpringBootTest
|
||||
|
|
@ -56,6 +67,12 @@ class ClawHubCompatControllerTest {
|
|||
@MockBean
|
||||
private CompatSkillLookupService compatSkillLookupService;
|
||||
|
||||
@MockBean
|
||||
private SkillPublishService skillPublishService;
|
||||
|
||||
@MockBean
|
||||
private AuditLogService auditLogService;
|
||||
|
||||
@Test
|
||||
void search_returns_mapped_results() throws Exception {
|
||||
when(skillSearchAppService.search("test", null, "relevance", 0, 20, null, null))
|
||||
|
|
@ -204,9 +221,122 @@ class ClawHubCompatControllerTest {
|
|||
.andExpect(jsonPath("$.user.image").value("https://example.com/avatar.png"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publish_skill_with_canonical_slug_routes_to_namespace_publish() throws Exception {
|
||||
SkillVersion version = publishVersion("1.0.0", 34L);
|
||||
given(skillPublishService.publishFromEntries(
|
||||
eq("team-ai"),
|
||||
anyList(),
|
||||
eq("user-42"),
|
||||
eq(SkillVisibility.PUBLIC),
|
||||
eq(Set.of("SUPER_ADMIN")),
|
||||
eq(false)))
|
||||
.willReturn(new SkillPublishService.PublishResult(12L, "my-skill", version));
|
||||
|
||||
mockMvc.perform(multipart("/api/v1/skills")
|
||||
.file(skillMdFile())
|
||||
.param("payload", """
|
||||
{"slug":"team-ai--my-skill","displayName":"My Skill","version":"1.0.0","acceptLicenseTerms":true,"tags":["latest"]}
|
||||
""")
|
||||
.with(authentication(superAdminAuth()))
|
||||
.with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.ok").value(true))
|
||||
.andExpect(jsonPath("$.skillId").value("12"))
|
||||
.andExpect(jsonPath("$.versionId").value("34"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publish_skill_with_plain_slug_defaults_to_global_namespace() throws Exception {
|
||||
SkillVersion version = publishVersion("1.0.0", 35L);
|
||||
given(skillPublishService.publishFromEntries(
|
||||
eq("global"),
|
||||
anyList(),
|
||||
eq("user-42"),
|
||||
eq(SkillVisibility.PUBLIC),
|
||||
eq(Set.of("SUPER_ADMIN")),
|
||||
eq(false)))
|
||||
.willReturn(new SkillPublishService.PublishResult(13L, "my-skill", version));
|
||||
|
||||
mockMvc.perform(multipart("/api/v1/skills")
|
||||
.file(skillMdFile())
|
||||
.param("payload", """
|
||||
{"slug":"my-skill","displayName":"My Skill","version":"1.0.0","acceptLicenseTerms":true,"tags":["latest"]}
|
||||
""")
|
||||
.with(authentication(superAdminAuth()))
|
||||
.with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.ok").value(true))
|
||||
.andExpect(jsonPath("$.skillId").value("13"))
|
||||
.andExpect(jsonPath("$.versionId").value("35"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publish_skill_with_payload_namespace_uses_explicit_namespace() throws Exception {
|
||||
SkillVersion version = publishVersion("1.0.0", 36L);
|
||||
given(skillPublishService.publishFromEntries(
|
||||
eq("team-explicit"),
|
||||
anyList(),
|
||||
eq("user-42"),
|
||||
eq(SkillVisibility.PUBLIC),
|
||||
eq(Set.of("SUPER_ADMIN")),
|
||||
eq(false)))
|
||||
.willReturn(new SkillPublishService.PublishResult(14L, "my-skill", version));
|
||||
|
||||
mockMvc.perform(multipart("/api/v1/skills")
|
||||
.file(skillMdFile())
|
||||
.param("payload", """
|
||||
{"namespace":"@team-explicit","slug":"my-skill","displayName":"My Skill","version":"1.0.0","acceptLicenseTerms":true,"tags":["latest"]}
|
||||
""")
|
||||
.with(authentication(superAdminAuth()))
|
||||
.with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.ok").value(true))
|
||||
.andExpect(jsonPath("$.skillId").value("14"))
|
||||
.andExpect(jsonPath("$.versionId").value("36"));
|
||||
}
|
||||
|
||||
private CompatSkillLookupService.CompatSkillContext legacyCompatContext(String namespaceSlug, String skillSlug) {
|
||||
Namespace namespace = new Namespace(namespaceSlug, namespaceSlug, "tester");
|
||||
Skill skill = new Skill(1L, skillSlug, "tester", SkillVisibility.PUBLIC);
|
||||
return new CompatSkillLookupService.CompatSkillContext(namespace, skill, Optional.empty());
|
||||
}
|
||||
|
||||
private MockMultipartFile skillMdFile() {
|
||||
return new MockMultipartFile(
|
||||
"files",
|
||||
"SKILL.md",
|
||||
"text/markdown",
|
||||
"""
|
||||
---
|
||||
name: my-skill
|
||||
description: Demo skill
|
||||
version: 1.0.0
|
||||
---
|
||||
""".getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
}
|
||||
|
||||
private SkillVersion publishVersion(String versionValue, long versionId) {
|
||||
SkillVersion version = new SkillVersion(12L, versionValue, "user-42");
|
||||
version.setStatus(SkillVersionStatus.PENDING_REVIEW);
|
||||
ReflectionTestUtils.setField(version, "id", versionId);
|
||||
return version;
|
||||
}
|
||||
|
||||
private UsernamePasswordAuthenticationToken superAdminAuth() {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"user-42",
|
||||
"tester",
|
||||
"tester@example.com",
|
||||
"https://example.com/avatar.png",
|
||||
"github",
|
||||
Set.of("SUPER_ADMIN")
|
||||
);
|
||||
return new UsernamePasswordAuthenticationToken(
|
||||
principal,
|
||||
null,
|
||||
List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue