fix(compat): support namespace-aware clawhub publish (#291)

This commit is contained in:
dongmucat 2026-04-13 20:26:42 +08:00 committed by GitHub
parent 532d0450aa
commit 1184e00a00
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 189 additions and 9 deletions

View file

@ -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)**

View file

@ -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 slugSkillHub 会将其解析为
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)**

View file

@ -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:

View file

@ -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 slugSkillHub 会将其解析为 namespace `my-space` 和 skill slug `my-skill`
- 为避免 CLI 展示与服务端最终坐标不一致,建议让 `SKILL.md` 中的 `name` 与 canonical slug 后半段保持一致
## API 端点说明
SkillHub 兼容层提供以下端点:

View file

@ -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,

View file

@ -30,6 +30,7 @@ public class MultipartPackageExtractor {
}
public record PublishPayload(
String namespace,
String slug,
String displayName,
String version,

View file

@ -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"))
);
}
}