Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -396,10 +396,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)**
Expand Down
10 changes: 8 additions & 2 deletions README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)**
Expand Down
9 changes: 8 additions & 1 deletion docs/openclaw-integration-en.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,15 +110,22 @@ 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
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:
Expand Down
9 changes: 8 additions & 1 deletion docs/openclaw-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,15 +110,22 @@ 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

# 使用帮助
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 兼容层提供以下端点:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import java.util.Map;
import org.slf4j.MDC;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;

/**
Expand All @@ -37,6 +38,8 @@
@Service
public class ClawHubCompatAppService {

private static final String GLOBAL_NAMESPACE = "global";

private final CanonicalSlugMapper mapper;
private final SkillSearchAppService skillSearchAppService;
private final SkillQueryService skillQueryService;
Expand Down Expand Up @@ -268,7 +271,7 @@ public ClawHubPublishResponse publishSkill(String payloadJson,
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(),
Expand Down Expand Up @@ -371,8 +374,28 @@ private ClawHubSkillListResponse.SkillListItem toSkillListItem(SkillSummaryRespo
);
}

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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public MultipartPackageExtractor(SkillPublishProperties properties, ObjectMapper
}

public record PublishPayload(
String namespace,
String slug,
String displayName,
String version,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,46 @@

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
Expand All @@ -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))
Expand Down Expand Up @@ -204,9 +221,122 @@ void whoami_with_auth_returns_user_info() throws Exception {
.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