feat(bootstrap): initialize built-in skills

Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
dongmucat 2026-06-04 10:38:40 +08:00
parent 0b1c366f8d
commit 90e113ee65
20 changed files with 1774 additions and 59 deletions

View file

@ -119,6 +119,10 @@ skillhub login --token sk_xxx --registry https://skill.xfyun.cn
skillhub search pdf
skillhub install pdf-parser --agent codex
# Verify the bundled example skill after a fresh deployment
skillhub search skillhub-hello
skillhub install skillhub-hello --agent codex
# List installed skills
skillhub list
```
@ -286,6 +290,7 @@ Recommended production baseline:
- keep PostgreSQL / Redis bound to `127.0.0.1`
- use external S3 / OSS via `SKILLHUB_STORAGE_S3_*`
- change `BOOTSTRAP_ADMIN_PASSWORD` to a strong password (`validate-release-config.sh` rejects the default `ChangeMe!2026`)
- set `SKILLHUB_BUILTIN_SKILLS_ENABLED=false` if you do not want the bundled `skillhub-hello` skill initialized in `@global`
- rotate or disable the bootstrap admin after initial setup
- run `make validate-release-config` before `docker compose up -d`
@ -421,6 +426,8 @@ clawhub login --token YOUR_API_TOKEN
npx clawhub search email
npx clawhub install my-skill
npx clawhub install my-namespace--my-skill
npx clawhub search skillhub-hello
npx clawhub install skillhub-hello
# Publish to global namespace
npx clawhub publish ./my-skill --slug my-skill --version 1.0.0

View file

@ -0,0 +1,382 @@
# Built-in Skills Initialization Design
## Context
New SkillHub deployments currently start without a guaranteed installable skill in the registry.
Users must first understand publishing or find an external package before they can verify search,
detail, download, and CLI installation flows.
This design adds a small, built-in example skill that is bundled with the Java service and published
automatically to `@global` during application startup.
The MVP example skill is `skillhub-hello`. It is intentionally generic and does not encode an
AgentGuard-specific product decision. Future official skills can reuse the same initialization
mechanism.
## Goals
- Bundle one or more directory-form skills in the Java service resources.
- Enable built-in skill initialization by default.
- Publish built-in skills to the fixed `global` namespace.
- Publish as `PUBLIC` and `PUBLISHED` so the skill is immediately searchable and installable.
- Use a fixed system publisher, `builtin-skill-publisher`, for owner and audit traceability.
- Reuse the existing `SkillPublishService.publishFromEntries(...)` pipeline.
- Keep initialization idempotent across repeated container deployments.
- Treat published versions as immutable: same version with changed content is skipped with a warning.
- Avoid new seed state tables and distributed locks in the MVP.
- Keep initialization failures non-fatal to application startup.
- Document `skillhub-hello` as an out-of-the-box verification skill.
## Non-Goals
- No new database table for seed state.
- No Redis or database distributed lock.
- No zip-based built-in skill packages.
- No configurable target namespace; built-in skills always publish to `global`.
- No label creation or binding for `skillhub-hello`.
- No landing page or frontend recommendation slot.
- No direct SQL/JPA insertion into `skill`, `skill_version`, or `skill_file`.
- No changes to ordinary publish, review, promotion, or lifecycle behavior.
## Key Decisions
| Decision | Choice | Reason |
|----------|--------|--------|
| Source location | Java service classpath resources | The runtime artifact always contains the built-in package |
| Directory | `server/skillhub-app/src/main/resources/builtin-skills/` | Spring Boot resource packaging is predictable |
| First built-in skill | `skillhub-hello` | Generic verification skill, not product-specific |
| Startup default | Enabled | Supports out-of-the-box discovery and installation |
| Target namespace | Fixed `global` | Built-in examples are platform-level public skills |
| Publication state | `PUBLIC + PUBLISHED` | Immediately searchable and installable |
| Publisher | `builtin-skill-publisher` | Stable owner and audit source |
| Version mutability | Same version is never overwritten | Published versions remain reproducible |
| Seed state table | None | Existing skill/version/file records are enough for MVP idempotency |
| Distributed lock | None | Conflict-tolerant startup is sufficient for a small built-in set |
| Labels | None | MVP validates the built-in publish mechanism only |
| Failure behavior | Log and continue | A sample skill must not make the service unavailable |
| Package format | Directory only | Easier review and classpath loading |
## Resource Layout
Built-in skills live under the `skillhub-app` resource tree:
```text
server/skillhub-app/src/main/resources/builtin-skills/
skillhub-hello/
SKILL.md
README.md
```
Each direct child directory under `builtin-skills/` is treated as one skill package.
Rules:
- The directory must contain root-level `SKILL.md`.
- File paths are relative to the skill directory.
- Files are converted into `PackageEntry` values.
- Files outside the skill directory are ignored.
- Zip packages are not supported in the MVP.
Suggested `skillhub-hello/SKILL.md`:
```markdown
---
name: skillhub-hello
description: A built-in example skill that verifies SkillHub discovery and installation.
version: 1.0.0
---
# SkillHub Hello
This skill is bundled with SkillHub as a minimal example for validating discovery and installation.
```
Published coordinate:
```text
@global/skillhub-hello
```
ClawHub canonical slug:
```text
skillhub-hello
```
## Configuration
Add one configuration property:
```yaml
skillhub:
builtin-skills:
enabled: true
```
Environment override:
```bash
SKILLHUB_BUILTIN_SKILLS_ENABLED=false
```
The MVP does not expose a namespace or locations property. The implementation uses the fixed
classpath location:
```text
classpath*:builtin-skills/*/SKILL.md
```
## Backend Design
### Components
Add the following app-layer bootstrap components:
- `BuiltinSkillProperties`
- `BuiltinSkillPackageLoader`
- `BuiltinSkillInitializer`
Responsibilities:
| Component | Responsibility |
|-----------|----------------|
| `BuiltinSkillProperties` | Bind `skillhub.builtin-skills.enabled` |
| `BuiltinSkillPackageLoader` | Read classpath skill directories and construct `PackageEntry` values |
| `BuiltinSkillInitializer` | Ensure publisher, evaluate idempotency, and call the publish pipeline |
These classes belong in:
```text
server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/
```
### Publish Pipeline
The initializer must call the existing domain publish service:
```java
skillPublishService.publishFromEntries(
"global",
entries,
"builtin-skill-publisher",
SkillVisibility.PUBLIC,
Set.of("SUPER_ADMIN"),
false
);
```
This preserves existing behavior for:
- package policy validation
- `SKILL.md` parsing
- slug generation
- `Skill` creation or reuse
- `SkillVersion` creation
- `PUBLISHED` state assignment
- `latestVersionId` updates
- `SkillFile` records
- object storage writes
- bundle zip creation
- `SkillPublishedEvent`
- after-commit search index rebuild
The initializer must not create skill/version/file rows directly.
Passing `false` for warning confirmation means built-in packages with validation warnings are treated
as package quality failures and skipped instead of being silently accepted.
### System Publisher
The initializer ensures this system user exists:
```text
userId: builtin-skill-publisher
displayName: SkillHub Built-in Publisher
email: builtin-skill-publisher@example.invalid
```
Requirements:
- Create `UserAccount` if missing.
- Ensure the user is an `OWNER` member of `@global`.
- Do not create a local login credential.
- Do not require a persisted platform role binding.
- Pass `Set.of("SUPER_ADMIN")` only for the publish call to reuse auto-publish behavior.
## Idempotency And Version Policy
### Content Fingerprint
The initializer computes a package fingerprint from current classpath resources:
1. Sort entries by normalized path.
2. Hash each file content with SHA-256.
3. Build a canonical stream of `path + fileSha256`.
4. Hash that stream to produce the package fingerprint.
For an existing published version, the initializer recomputes the same fingerprint from `skill_file`:
1. Query the target `SkillVersion`.
2. Query its `SkillFile` rows.
3. Sort by `filePath`.
4. Build the canonical stream from `filePath + sha256`.
5. Hash that stream.
No new persistence field is added.
### Startup Rules
For each built-in skill:
| Existing state | Action |
|----------------|--------|
| Skill does not exist | Publish |
| Skill exists, same version does not exist | Publish new version |
| Same version is `PUBLISHED` and fingerprint matches | Skip |
| Same version is `PUBLISHED` and fingerprint differs | Warn and skip |
| Same version exists but is not `PUBLISHED` | Warn and skip |
Same-version content changes must bump the version in `SKILL.md`. The initializer must never
overwrite a published version.
### Concurrent Startup
The MVP does not use a distributed lock.
If multiple application instances start at the same time:
- each instance performs the idempotency check;
- one instance may publish first;
- later instances may hit an existing-version conflict;
- conflict handling should re-read the existing version and skip when a valid published version is present;
- conflicts must be logged but must not fail application startup.
## Failure Behavior
Built-in skill initialization is best-effort.
Rules:
- Failure in one built-in skill does not prevent other built-in skills from being processed.
- Any initialization failure is logged at error level.
- Same-version fingerprint drift is logged at warning level.
- Same-version matching content is logged at info level.
- Successful publishing is logged at info level.
- Exceptions are contained inside the initializer and do not abort Spring Boot startup.
Log context should include:
- skill directory
- resolved slug
- resolved version
- namespace `global`
- action
- error message when applicable
## Object Storage And Search
Classpath resources are only the source for initialization. Published files still go through the
configured object storage backend:
- LocalFile
- MinIO
- S3
The initializer does not write object storage directly.
Search index updates also remain event-driven. The publish service emits `SkillPublishedEvent`, and
the existing search listener rebuilds the search document after transaction commit.
## User Experience
The MVP does not add frontend UI.
After startup, users can discover and install the built-in skill through existing flows:
- search for `skillhub-hello`;
- open the skill detail page;
- copy the existing install command;
- install with ClawHub/OpenClaw.
Expected command:
```bash
npx clawhub install skillhub-hello --registry <your-skillhub-url>
```
Because labels and recommendation slots are out of scope, this design does not guarantee permanent
homepage prominence for `skillhub-hello`.
## Documentation
Update the user-facing docs to mention the built-in verification skill:
- `README.md`
- `docs/openclaw-integration.md`
- `docs/openclaw-integration-en.md`
Recommended example:
```bash
npx clawhub search skillhub-hello --registry <your-skillhub-url>
npx clawhub install skillhub-hello --registry <your-skillhub-url>
```
The docs should explain:
- `skillhub-hello` is bundled with SkillHub;
- it validates registry search and installation;
- operators can disable initialization with `SKILLHUB_BUILTIN_SKILLS_ENABLED=false`.
## Testing Strategy
### Unit Tests
Add backend tests for:
- `enabled=false` skips all publishing.
- first startup publishes `skillhub-hello`;
- same version and same fingerprint skips;
- same version and different fingerprint warns and skips;
- same version in a non-`PUBLISHED` state warns and skips;
- publish exceptions are swallowed after logging;
- missing system publisher is created;
- missing `@global` membership is created;
- loader reads classpath directories into stable `PackageEntry` order;
- loader reports a directory missing `SKILL.md`.
### Local Validation
Run:
```bash
make test-backend-app
```
If implementation touches runtime packaging or staging startup behavior, also run:
```bash
make staging
```
### Manual Validation
After local startup:
```bash
curl "http://localhost:8080/api/web/skills?q=skillhub-hello"
npx clawhub search skillhub-hello --registry http://localhost:8080
npx clawhub install skillhub-hello --registry http://localhost:8080
```
## Future Extensions
Potential follow-up work:
- external filesystem source locations;
- zip package support;
- seed state table;
- distributed lock;
- official label binding;
- landing page official/recommended slot;
- AgentGuard or other official skills built on the same mechanism.
These are outside the MVP.

View file

@ -0,0 +1,270 @@
# Built-in Skills Implementation Plan
> **Spec:** `docs/20-builtin-skills-design.md`
> **Goal:** Implement the MVP built-in skill initialization flow for `skillhub-hello`, using Java service resources and publishing to `@global` on startup.
## Scope
Implement only the MVP decisions from the design:
- Java service classpath resource source.
- `skillhub-hello` as the first built-in skill.
- Initialization enabled by default.
- Fixed target namespace `global`.
- `PUBLIC + PUBLISHED` publication.
- System publisher `builtin-skill-publisher`.
- Idempotent same-version skip.
- Same-version content drift warning and skip.
- No seed table.
- No distributed lock.
- No label binding.
- No frontend UI changes.
## Work Items
### 1. Add Built-In Skill Resource
- [ ] Create `server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/`.
- [ ] Add `SKILL.md` with `name`, `description`, and `version: 1.0.0`.
- [ ] Add a concise `README.md` explaining this is a bundled verification skill.
- [ ] Confirm the package passes the existing skill package policy.
Acceptance criteria:
- `skillhub-hello/SKILL.md` is at the package root.
- The package can be converted to `PackageEntry` values without path rewriting.
### 2. Add Configuration Binding
- [ ] Add `BuiltinSkillProperties` under `com.iflytek.skillhub.bootstrap`.
- [ ] Bind prefix `skillhub.builtin-skills`.
- [ ] Add `enabled=true` default.
- [ ] Register the configuration properties if current application setup requires explicit registration.
- [ ] Add a properties binding test.
Acceptance criteria:
- `SKILLHUB_BUILTIN_SKILLS_ENABLED=false` disables initialization.
- Default behavior is enabled.
### 3. Implement Package Loader
- [ ] Add `BuiltinSkillPackageLoader`.
- [ ] Discover built-in packages with a jar-safe classpath scan.
- [ ] Use `PathMatchingResourcePatternResolver` or an equivalent Spring resource resolver.
- [ ] Discover package roots by matching `classpath*:builtin-skills/*/SKILL.md` instead of enumerating `classpath:builtin-skills/` as a filesystem directory.
- [ ] Require root-level `SKILL.md` for each skill directory.
- [ ] Normalize relative paths with `/` separators.
- [ ] Reject unsafe paths if encountered.
- [ ] Build `PackageEntry` records with content bytes, size, and content type.
- [ ] Sort entries deterministically by path.
- [ ] Extract shared content-type resolution instead of adding a third private mapping copy.
- [ ] Reuse that shared content-type helper from upload archive extraction and the built-in loader where practical.
Acceptance criteria:
- Loader returns one package for `skillhub-hello`.
- Entry order is stable.
- Missing `SKILL.md` is reported without crashing the whole initializer.
### 4. Implement Fingerprint Logic
- [ ] Add package fingerprint calculation from `PackageEntry` list.
- [ ] Calculate per-file SHA-256.
- [ ] Calculate aggregate SHA-256 from sorted `path + sha256` pairs.
- [ ] Add existing-version fingerprint calculation from `SkillFile` rows.
- [ ] Keep this logic package-private or in a small helper, covered by unit tests.
Acceptance criteria:
- Same entries in different input order produce the same fingerprint.
- Changing file content changes the fingerprint.
- Changing file path changes the fingerprint.
### 5. Implement System Publisher Setup
- [ ] In initializer flow, ensure `UserAccount("builtin-skill-publisher")` exists.
- [ ] Ensure `@global` namespace exists or log error and skip built-in publish.
- [ ] Ensure the publisher has `NamespaceRole.OWNER` membership in `@global`.
- [ ] Do not create local credentials.
- [ ] Do not persist a real platform role binding unless existing code requires it.
- [ ] Keep system publisher setup transactionally separate from per-package publish attempts.
Acceptance criteria:
- Fresh database startup creates the system publisher and membership.
- Existing publisher and membership are reused idempotently.
### 6. Implement BuiltinSkillInitializer
- [ ] Add `BuiltinSkillInitializer` as an `ApplicationRunner`.
- [ ] Do not annotate `run(...)` with a single outer `@Transactional`.
- [ ] Keep exception handling outside the transactional publish call so rollback-only state cannot escape and fail application startup.
- [ ] If helper methods need transactions, split them into separate bean methods or services so Spring transaction proxies apply correctly.
- [ ] Exit early when `enabled=false`.
- [ ] Load built-in packages.
- [ ] Parse each package's `SKILL.md` metadata to resolve slug and version.
- [ ] For each package, check existing `@global/{slug}`, owner, and version state.
- [ ] Only manage skills owned by `builtin-skill-publisher`.
- [ ] If the same slug has a published version owned by another user, log a warning and skip the built-in package.
- [ ] Publish when no same version exists.
- [ ] Skip when same version is `PUBLISHED` and fingerprint matches.
- [ ] Warn and skip when same version is `PUBLISHED` and fingerprint differs.
- [ ] Warn and skip when same version exists but is not `PUBLISHED`.
- [ ] Catch expected publish conflicts from concurrent startup and re-check existing version.
- [ ] Catch unexpected exceptions per package and continue.
Acceptance criteria:
- Repeated startup of the same artifact publishes only once.
- Rebuilding the app with modified `skillhub-hello` content but unchanged version does not overwrite the existing version.
- A new `version` in `SKILL.md` publishes a new version.
- Any single package failure does not abort application startup.
- A user-owned `@global/skillhub-hello` is not overwritten or replaced by the built-in initializer.
- A caught publish failure cannot mark an outer startup transaction rollback-only.
### 7. Reuse Publish Pipeline
- [ ] Call `SkillPublishService.publishFromEntries(...)`.
- [ ] Use namespace `global`.
- [ ] Use publisher `builtin-skill-publisher`.
- [ ] Use `SkillVisibility.PUBLIC`.
- [ ] Pass `Set.of("SUPER_ADMIN")`.
- [ ] Pass `confirmWarnings=false`.
- [ ] Treat any package validation or pre-publish warning as a built-in package quality issue.
- [ ] If warnings are present, log an error and skip the package instead of confirming them.
Acceptance criteria:
- Published version is `PUBLISHED`.
- `latestVersionId` points to the built-in version.
- `SkillFile` rows and bundle object are created by the existing publish service.
- `SkillPublishedEvent` is emitted through the existing path.
### 8. Add Tests
- [ ] Add loader tests.
- [ ] Add properties binding test.
- [ ] Add initializer tests for disabled mode.
- [ ] Add initializer tests for first publish.
- [ ] Add initializer tests for same-version same-fingerprint skip.
- [ ] Add initializer tests for same-version changed-fingerprint skip.
- [ ] Add initializer tests for non-published same version skip.
- [ ] Add initializer tests for a newer built-in version publishing when an older built-in version already exists.
- [ ] Add initializer tests for same slug owned by another published skill owner warning and skipping.
- [ ] Add initializer tests for publish exception swallow-and-log behavior.
- [ ] Add system publisher and membership idempotency tests.
- [ ] Add tests that publish failures do not occur inside a single outer initializer transaction.
- [ ] Add a test that loads the real `skillhub-hello` resource and validates it with `SkillPackageValidator`.
Acceptance criteria:
- Tests cover all MVP idempotency branches.
- Tests do not require real object storage.
- Tests cover jar-safe resource discovery behavior as closely as practical without relying on filesystem-only assumptions.
### 9. Update Documentation
- [ ] Update `README.md` with `skillhub-hello` search/install verification commands.
- [ ] Update `docs/openclaw-integration.md`.
- [ ] Update `docs/openclaw-integration-en.md`.
- [ ] Mention `SKILLHUB_BUILTIN_SKILLS_ENABLED=false` for operators who want to disable built-in initialization.
Acceptance criteria:
- Users can find a documented command to search and install `skillhub-hello`.
- Docs do not mention AgentGuard as the MVP built-in skill.
### 10. Validation
- [ ] Run `make test-backend-app`.
- [ ] Run `make staging` because this feature depends on packaged runtime resources and startup behavior.
- [ ] Manually start local app and verify `skillhub-hello` appears in search.
- [ ] Verify reinstall/restart does not create duplicate published versions.
Acceptance criteria:
- Backend tests pass.
- Packaged/staging startup can discover `skillhub-hello` from classpath resources.
- Manual search returns `skillhub-hello`.
- Repeated startup is idempotent.
### 11. Code Review
- [ ] Perform a focused backend code review after tests and validation pass.
- [ ] Review transaction boundaries around `BuiltinSkillInitializer`.
- [ ] Verify `run(...)` is not wrapped in a single outer transaction.
- [ ] Verify publish failures cannot mark startup rollback-only or abort application startup.
- [ ] Verify classpath scanning is jar-safe and does not rely on filesystem-only directory enumeration.
- [ ] Verify the initializer only manages skills owned by `builtin-skill-publisher`.
- [ ] Verify same-version published skills are never overwritten.
- [ ] Verify warning handling uses `confirmWarnings=false` and skips invalid built-in packages.
- [ ] Verify no label, seed table, distributed lock, frontend UI, or controller/API drift was introduced.
- [ ] Verify tests cover all MVP idempotency and failure branches.
Acceptance criteria:
- Review findings are resolved or explicitly accepted before PR.
- No unresolved blocker remains in startup reliability, idempotency, ownership, or resource packaging.
## Implementation Order
1. Add `skillhub-hello` resource.
2. Add properties binding.
3. Extract shared content-type helper.
4. Add jar-safe loader and fingerprint helper.
5. Add initializer with separated transaction boundaries, publisher setup, owner checks, and idempotency checks.
6. Add tests.
7. Update README and OpenClaw docs.
8. Run backend and staging validation.
9. Perform focused code review and resolve findings.
## Files Expected To Change
Backend:
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillProperties.java`
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoader.java`
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializer.java`
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolver.java`
- `server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/SKILL.md`
- `server/skillhub-app/src/main/resources/builtin-skills/skillhub-hello/README.md`
Tests:
- `server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPropertiesBindingTest.java`
- `server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillPackageLoaderTest.java`
- `server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BuiltinSkillInitializerTest.java`
- `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageContentTypeResolverTest.java`
Docs:
- `README.md`
- `docs/openclaw-integration.md`
- `docs/openclaw-integration-en.md`
No generated OpenAPI files are expected to change because this implementation does not add or modify controllers.
## Risks And Mitigations
| Risk | Mitigation |
|------|------------|
| Classpath resource scanning behaves differently from filesystem scanning | Test loader against test resources and verify packaged startup |
| Same-version conflict during multi-instance startup | Catch conflict, re-read existing version, skip if published |
| Publish service writes object storage before a later failure | Keep failure non-fatal and rely on existing publish behavior; avoid custom storage writes |
| Built-in package accidentally changes without version bump | Fingerprint mismatch warning and skip |
| System publisher missing foreign-key requirements | Ensure `UserAccount` and `@global` membership before publish |
| Publish failure marks an outer transaction rollback-only | Do not wrap initializer `run(...)` in one transaction; catch outside publish transactions |
| User already owns the target slug | Only manage built-in-publisher-owned skills; warn and skip other owners |
| Built-in package warnings are silently accepted | Use `confirmWarnings=false`; treat warnings as package quality failures |
## Out Of Scope Follow-Ups
- Labels such as `official` or `example`.
- Homepage or landing recommended sections.
- Seed state table.
- Distributed lock.
- External `file:` locations.
- Zip package support.
- AgentGuard as a built-in official skill.

View file

@ -72,6 +72,10 @@ npx clawhub search find-skills
npx clawhub search find-skills --limit 5
npx clawhub inspect find-skills
# Bundled verification skill initialized by default on new deployments
npx clawhub search skillhub-hello
npx clawhub inspect skillhub-hello
# Help
npx clawhub search --help
npx clawhub inspect --help
@ -100,6 +104,9 @@ npx clawhub list
npx clawhub --dir ~/.claude/skills install find-skills
CLAWHUB_WORKDIR=~/.claude/skills npx clawhub install find-skills
# Install the bundled verification skill
npx clawhub install skillhub-hello
# Help
npx clawhub install --help
npx clawhub update --help
@ -210,6 +217,12 @@ export CLAWHUB_REGISTRY=https://skillhub.your-company.com
clawhub login --token sk_your_api_token_here
```
New SkillHub deployments initialize the bundled `skillhub-hello` skill in `@global` by default. To disable built-in skill initialization, set this before starting the backend:
```bash
export SKILLHUB_BUILTIN_SKILLS_ENABLED=false
```
## FAQ
### Q: How do I switch back to public ClawHub?

View file

@ -72,6 +72,10 @@ npx clawhub search find-skills
npx clawhub search find-skills --limit 5
npx clawhub inspect find-skills
# 新部署默认内置的验证 Skill
npx clawhub search skillhub-hello
npx clawhub inspect skillhub-hello
# 使用帮助
npx clawhub search --help
npx clawhub inspect --help
@ -100,6 +104,9 @@ npx clawhub list
npx clawhub --dir ~/.claude/skills install find-skills
CLAWHUB_WORKDIR=~/.claude/skills npx clawhub install find-skills
# 安装默认内置的验证 Skill
npx clawhub install skillhub-hello
# 使用帮助
npx clawhub install --help
npx clawhub update --help
@ -210,6 +217,12 @@ export CLAWHUB_REGISTRY=https://skillhub.your-company.com
clawhub login --token sk_your_api_token_here
```
SkillHub 新部署默认会在 `@global` 初始化内置 `skillhub-hello`。如需关闭内置 Skill 初始化,请在启动后端前设置:
```bash
export SKILLHUB_BUILTIN_SKILLS_ENABLED=false
```
## 常见问题
### Q: 如何切换回公共 ClawHub

View file

@ -0,0 +1,42 @@
package com.iflytek.skillhub.bootstrap;
import com.iflytek.skillhub.domain.skill.SkillFile;
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.List;
final class BuiltinSkillFingerprints {
private BuiltinSkillFingerprints() {
}
static String fromEntries(List<PackageEntry> entries) {
StringBuilder canonical = new StringBuilder();
entries.stream()
.sorted(Comparator.comparing(PackageEntry::path))
.map(entry -> entry.path() + "\0" + sha256(entry.content()))
.forEach(line -> canonical.append(line).append('\n'));
return sha256(canonical.toString().getBytes(StandardCharsets.UTF_8));
}
static String fromFiles(List<SkillFile> files) {
StringBuilder canonical = new StringBuilder();
files.stream()
.sorted(Comparator.comparing(SkillFile::getFilePath))
.map(file -> file.getFilePath() + "\0" + file.getSha256())
.forEach(line -> canonical.append(line).append('\n'));
return sha256(canonical.toString().getBytes(StandardCharsets.UTF_8));
}
private static String sha256(byte[] content) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(content));
} catch (Exception exception) {
throw new IllegalStateException("Failed to calculate SHA-256 fingerprint", exception);
}
}
}

View file

@ -0,0 +1,295 @@
package com.iflytek.skillhub.bootstrap;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillFileRepository;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata;
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator;
import com.iflytek.skillhub.domain.skill.validation.ValidationResult;
import com.iflytek.skillhub.domain.namespace.SlugValidator;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
/**
* Publishes bundled example skills into the global namespace during startup.
*/
@Component
public class BuiltinSkillInitializer implements ApplicationRunner {
static final String BUILTIN_PUBLISHER_ID = "builtin-skill-publisher";
static final String GLOBAL_NAMESPACE = "global";
private static final String BUILTIN_PUBLISHER_NAME = "SkillHub Built-in Publisher";
private static final String BUILTIN_PUBLISHER_EMAIL = "builtin-skill-publisher@example.invalid";
private static final Logger log = LoggerFactory.getLogger(BuiltinSkillInitializer.class);
private final BuiltinSkillProperties properties;
private final BuiltinSkillPackageLoader packageLoader;
private final SkillMetadataParser metadataParser;
private final SkillPackageValidator packageValidator;
private final SkillPublishService skillPublishService;
private final NamespaceRepository namespaceRepository;
private final NamespaceMemberRepository namespaceMemberRepository;
private final UserAccountRepository userAccountRepository;
private final SkillRepository skillRepository;
private final SkillVersionRepository skillVersionRepository;
private final SkillFileRepository skillFileRepository;
private final TransactionTemplate transactionTemplate;
public BuiltinSkillInitializer(BuiltinSkillProperties properties,
BuiltinSkillPackageLoader packageLoader,
SkillMetadataParser metadataParser,
SkillPackageValidator packageValidator,
SkillPublishService skillPublishService,
NamespaceRepository namespaceRepository,
NamespaceMemberRepository namespaceMemberRepository,
UserAccountRepository userAccountRepository,
SkillRepository skillRepository,
SkillVersionRepository skillVersionRepository,
SkillFileRepository skillFileRepository,
PlatformTransactionManager transactionManager) {
this.properties = properties;
this.packageLoader = packageLoader;
this.metadataParser = metadataParser;
this.packageValidator = packageValidator;
this.skillPublishService = skillPublishService;
this.namespaceRepository = namespaceRepository;
this.namespaceMemberRepository = namespaceMemberRepository;
this.userAccountRepository = userAccountRepository;
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
this.skillFileRepository = skillFileRepository;
this.transactionTemplate = new TransactionTemplate(transactionManager);
}
@Override
public void run(ApplicationArguments args) {
if (!properties.isEnabled()) {
log.info("Built-in skill initialization is disabled");
return;
}
Namespace globalNamespace;
try {
globalNamespace = ensurePublisher();
} catch (RuntimeException exception) {
log.error("Failed to prepare built-in skill publisher, skipping built-in skill initialization",
exception);
return;
}
if (globalNamespace == null) {
return;
}
List<BuiltinSkillPackageLoader.BuiltinSkillPackage> packages;
try {
packages = packageLoader.loadPackages();
} catch (Exception exception) {
log.error("Failed to load built-in skill packages", exception);
return;
}
for (BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage : packages) {
try {
initializePackage(globalNamespace, skillPackage);
} catch (Exception exception) {
log.error("Failed to initialize built-in skill package [directory={}]",
skillPackage.directory(), exception);
}
}
}
private Namespace ensurePublisher() {
return transactionTemplate.execute(status -> {
Namespace globalNamespace = namespaceRepository.findBySlug(GLOBAL_NAMESPACE)
.orElse(null);
if (globalNamespace == null) {
log.error("Missing built-in global namespace, skipping built-in skill initialization");
return null;
}
UserAccount publisher = userAccountRepository.findById(BUILTIN_PUBLISHER_ID)
.orElseGet(() -> new UserAccount(
BUILTIN_PUBLISHER_ID,
BUILTIN_PUBLISHER_NAME,
BUILTIN_PUBLISHER_EMAIL,
null
));
publisher.setDisplayName(BUILTIN_PUBLISHER_NAME);
publisher.setEmail(BUILTIN_PUBLISHER_EMAIL);
publisher.setStatus(UserStatus.ACTIVE);
userAccountRepository.save(publisher);
NamespaceMember member = namespaceMemberRepository
.findByNamespaceIdAndUserId(globalNamespace.getId(), BUILTIN_PUBLISHER_ID)
.orElseGet(() -> new NamespaceMember(globalNamespace.getId(), BUILTIN_PUBLISHER_ID, NamespaceRole.OWNER));
if (member.getRole() != NamespaceRole.OWNER) {
member.setRole(NamespaceRole.OWNER);
}
namespaceMemberRepository.save(member);
return globalNamespace;
});
}
private void initializePackage(Namespace globalNamespace,
BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage) {
List<PackageEntry> entries = skillPackage.entries();
SkillMetadata metadata = parseMetadata(skillPackage.directory(), entries);
if (metadata.version() == null || metadata.version().isBlank()) {
log.error("Built-in skill package is missing an explicit version [directory={}]",
skillPackage.directory());
return;
}
String skillSlug = SlugValidator.slugify(metadata.name());
ValidationResult validation = packageValidator.validate(entries);
if (!validation.passed() || validation.hasWarnings()) {
log.error("Built-in skill package failed validation [directory={}, slug={}, version={}, errors={}, warnings={}]",
skillPackage.directory(), skillSlug, metadata.version(), validation.errors(), validation.warnings());
return;
}
if (hasPublishedOtherOwnerConflict(globalNamespace.getId(), skillSlug)) {
log.warn("Skipping built-in skill because another owner already published the slug [directory={}, namespace={}, slug={}]",
skillPackage.directory(), GLOBAL_NAMESPACE, skillSlug);
return;
}
Optional<Skill> existingBuiltInSkill =
skillRepository.findByNamespaceIdAndSlugAndOwnerId(globalNamespace.getId(), skillSlug, BUILTIN_PUBLISHER_ID);
if (existingBuiltInSkill.isPresent()
&& shouldSkipExistingVersion(skillPackage, existingBuiltInSkill.get(), metadata.version())) {
return;
}
publishPackage(skillPackage, skillSlug, metadata.version());
}
private SkillMetadata parseMetadata(String directory, List<PackageEntry> entries) {
PackageEntry skillMd = entries.stream()
.filter(entry -> "SKILL.md".equals(entry.path()))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Built-in package missing SKILL.md: " + directory));
return metadataParser.parse(new String(skillMd.content(), StandardCharsets.UTF_8));
}
private boolean hasPublishedOtherOwnerConflict(Long namespaceId, String skillSlug) {
return skillRepository.findByNamespaceIdAndSlug(namespaceId, skillSlug).stream()
.filter(skill -> !BUILTIN_PUBLISHER_ID.equals(skill.getOwnerId()))
.anyMatch(skill -> !skillVersionRepository
.findBySkillIdAndStatus(skill.getId(), SkillVersionStatus.PUBLISHED)
.isEmpty());
}
private boolean shouldSkipExistingVersion(BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage,
Skill skill,
String version) {
Optional<SkillVersion> existingVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version);
if (existingVersion.isEmpty()) {
return false;
}
SkillVersion skillVersion = existingVersion.get();
if (skillVersion.getStatus() != SkillVersionStatus.PUBLISHED) {
log.warn("Skipping built-in skill because the same version is not published [directory={}, skillId={}, version={}, status={}]",
skillPackage.directory(), skill.getId(), version, skillVersion.getStatus());
return true;
}
String currentFingerprint = BuiltinSkillFingerprints.fromEntries(skillPackage.entries());
String existingFingerprint = BuiltinSkillFingerprints.fromFiles(skillFileRepository.findByVersionId(skillVersion.getId()));
if (currentFingerprint.equals(existingFingerprint)) {
log.info("Built-in skill version already exists, skipping [directory={}, skillId={}, version={}]",
skillPackage.directory(), skill.getId(), version);
} else {
log.warn("Skipping built-in skill because same published version has different content [directory={}, skillId={}, version={}]",
skillPackage.directory(), skill.getId(), version);
}
return true;
}
private void publishPackage(BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage,
String skillSlug,
String version) {
try {
SkillPublishService.PublishResult result = skillPublishService.publishFromEntries(
GLOBAL_NAMESPACE,
skillPackage.entries(),
BUILTIN_PUBLISHER_ID,
SkillVisibility.PUBLIC,
Set.of("SUPER_ADMIN"),
false
);
log.info("Published built-in skill [directory={}, namespace={}, slug={}, version={}, status={}]",
skillPackage.directory(), GLOBAL_NAMESPACE, result.slug(),
result.version().getVersion(), result.version().getStatus());
} catch (RuntimeException exception) {
ConcurrentVersionState concurrentVersionState =
findConcurrentPublishedBuiltInVersionState(skillPackage, skillSlug, version);
if (concurrentVersionState == ConcurrentVersionState.MATCHING) {
log.warn("Built-in skill was already published concurrently, skipping [directory={}, namespace={}, slug={}, version={}]",
skillPackage.directory(), GLOBAL_NAMESPACE, skillSlug, version);
return;
}
if (concurrentVersionState == ConcurrentVersionState.DIFFERENT) {
log.warn("Skipping built-in skill because concurrently published same version has different content [directory={}, namespace={}, slug={}, version={}]",
skillPackage.directory(), GLOBAL_NAMESPACE, skillSlug, version);
return;
}
throw exception;
}
}
private ConcurrentVersionState findConcurrentPublishedBuiltInVersionState(
BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage,
String skillSlug,
String version) {
return namespaceRepository.findBySlug(GLOBAL_NAMESPACE)
.flatMap(namespace -> skillRepository.findByNamespaceIdAndSlugAndOwnerId(
namespace.getId(),
skillSlug,
BUILTIN_PUBLISHER_ID
))
.flatMap(skill -> skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version))
.filter(skillVersion -> skillVersion.getStatus() == SkillVersionStatus.PUBLISHED)
.map(skillVersion -> {
String currentFingerprint = BuiltinSkillFingerprints.fromEntries(skillPackage.entries());
String existingFingerprint = BuiltinSkillFingerprints.fromFiles(
skillFileRepository.findByVersionId(skillVersion.getId()));
if (currentFingerprint.equals(existingFingerprint)) {
return ConcurrentVersionState.MATCHING;
}
return ConcurrentVersionState.DIFFERENT;
})
.orElse(ConcurrentVersionState.MISSING);
}
private enum ConcurrentVersionState {
MISSING,
MATCHING,
DIFFERENT
}
}

View file

@ -0,0 +1,107 @@
package com.iflytek.skillhub.bootstrap;
import com.iflytek.skillhub.controller.support.SkillPackageContentTypeResolver;
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.core.io.support.ResourcePatternResolver;
import org.springframework.stereotype.Component;
/**
* Loads directory-form built-in skill packages from classpath resources.
*/
@Component
public class BuiltinSkillPackageLoader {
static final String BUILTIN_SKILLS_PATTERN = "classpath*:builtin-skills/*/SKILL.md";
private static final String ROOT_MARKER = "builtin-skills/";
private static final String SKILL_MD = "SKILL.md";
private final ResourcePatternResolver resourcePatternResolver;
public BuiltinSkillPackageLoader() {
this(new PathMatchingResourcePatternResolver());
}
BuiltinSkillPackageLoader(ResourcePatternResolver resourcePatternResolver) {
this.resourcePatternResolver = resourcePatternResolver;
}
public List<BuiltinSkillPackage> loadPackages() throws IOException {
Set<String> directories = discoverPackageDirectories();
List<BuiltinSkillPackage> packages = new ArrayList<>();
for (String directory : directories) {
List<PackageEntry> packageEntries = loadPackageEntries(directory);
boolean hasSkillMd = packageEntries.stream().anyMatch(packageEntry -> SKILL_MD.equals(packageEntry.path()));
if (hasSkillMd) {
packages.add(new BuiltinSkillPackage(directory, packageEntries));
}
}
return packages;
}
private Set<String> discoverPackageDirectories() throws IOException {
Resource[] skillMdResources = resourcePatternResolver.getResources(BUILTIN_SKILLS_PATTERN);
Set<String> directories = new TreeSet<>();
for (Resource resource : skillMdResources) {
String relativePath = relativeBuiltinPath(resource);
if (relativePath == null || relativePath.isBlank()) {
continue;
}
int separatorIndex = relativePath.indexOf('/');
if (separatorIndex > 0 && SKILL_MD.equals(relativePath.substring(separatorIndex + 1))) {
directories.add(relativePath.substring(0, separatorIndex));
}
}
return directories;
}
private List<PackageEntry> loadPackageEntries(String directory) throws IOException {
Resource[] resources = resourcePatternResolver.getResources("classpath*:builtin-skills/" + directory + "/**");
List<PackageEntry> entries = new ArrayList<>();
for (Resource resource : resources) {
String relativePath = relativeBuiltinPath(resource);
if (relativePath == null || relativePath.isBlank() || relativePath.endsWith("/")) {
continue;
}
String directoryPrefix = directory + "/";
if (!relativePath.startsWith(directoryPrefix) || directoryPrefix.length() == relativePath.length()) {
continue;
}
String packagePath = SkillPackagePolicy.normalizeEntryPath(relativePath.substring(directoryPrefix.length()));
try (InputStream inputStream = resource.getInputStream()) {
byte[] content = inputStream.readAllBytes();
entries.add(new PackageEntry(
packagePath,
content,
content.length,
SkillPackageContentTypeResolver.determineContentType(packagePath)
));
}
}
return entries.stream()
.sorted(Comparator.comparing(PackageEntry::path))
.toList();
}
private String relativeBuiltinPath(Resource resource) throws IOException {
String url = resource.getURL().toExternalForm();
int markerIndex = url.lastIndexOf(ROOT_MARKER);
if (markerIndex < 0) {
return null;
}
return url.substring(markerIndex + ROOT_MARKER.length());
}
public record BuiltinSkillPackage(String directory, List<PackageEntry> entries) {
}
}

View file

@ -0,0 +1,21 @@
package com.iflytek.skillhub.bootstrap;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* Configuration for publishing bundled example skills at application startup.
*/
@Component
@ConfigurationProperties(prefix = "skillhub.builtin-skills")
public class BuiltinSkillProperties {
private boolean enabled = true;
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
}

View file

@ -85,7 +85,7 @@ public class MultipartPackageExtractor {
normalizedPath,
content,
content.length,
determineContentType(normalizedPath)
SkillPackageContentTypeResolver.determineContentType(normalizedPath)
));
}
}
@ -113,12 +113,4 @@ public class MultipartPackageExtractor {
return path;
}
private String determineContentType(String filename) {
if (filename.endsWith(".py")) return "text/x-python";
if (filename.endsWith(".json")) return "application/json";
if (filename.endsWith(".yaml") || filename.endsWith(".yml")) return "application/x-yaml";
if (filename.endsWith(".txt")) return "text/plain";
if (filename.endsWith(".md")) return "text/markdown";
return "application/octet-stream";
}
}

View file

@ -74,7 +74,7 @@ public class SkillPackageArchiveExtractor {
normalizedPath,
content,
content.length,
determineContentType(normalizedPath)
SkillPackageContentTypeResolver.determineContentType(normalizedPath)
));
zis.closeEntry();
}
@ -189,28 +189,4 @@ public class SkillPackageArchiveExtractor {
return outputStream.toByteArray();
}
private String determineContentType(String filename) {
String lower = filename.toLowerCase();
if (lower.endsWith(".py")) return "text/x-python";
if (lower.endsWith(".json")) return "application/json";
if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml";
if (lower.endsWith(".txt")) return "text/plain";
if (lower.endsWith(".md")) return "text/markdown";
if (lower.endsWith(".html")) return "text/html";
if (lower.endsWith(".css")) return "text/css";
if (lower.endsWith(".csv")) return "text/csv";
if (lower.endsWith(".xml")) return "application/xml";
if (lower.endsWith(".js") || lower.endsWith(".cjs") || lower.endsWith(".mjs")) return "text/javascript";
if (lower.endsWith(".ts")) return "text/typescript";
if (lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh")) return "text/x-shellscript";
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
if (lower.endsWith(".gif")) return "image/gif";
if (lower.endsWith(".svg")) return "image/svg+xml";
if (lower.endsWith(".webp")) return "image/webp";
if (lower.endsWith(".ico")) return "image/x-icon";
if (lower.endsWith(".pdf")) return "application/pdf";
if (lower.endsWith(".toml")) return "application/toml";
return "application/octet-stream";
}
}

View file

@ -0,0 +1,35 @@
package com.iflytek.skillhub.controller.support;
/**
* Resolves package entry content types from filenames for upload and built-in package ingestion.
*/
public final class SkillPackageContentTypeResolver {
private SkillPackageContentTypeResolver() {
}
public static String determineContentType(String filename) {
String lower = filename.toLowerCase();
if (lower.endsWith(".py")) return "text/x-python";
if (lower.endsWith(".json")) return "application/json";
if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml";
if (lower.endsWith(".txt")) return "text/plain";
if (lower.endsWith(".md")) return "text/markdown";
if (lower.endsWith(".html")) return "text/html";
if (lower.endsWith(".css")) return "text/css";
if (lower.endsWith(".csv")) return "text/csv";
if (lower.endsWith(".xml")) return "application/xml";
if (lower.endsWith(".js") || lower.endsWith(".cjs") || lower.endsWith(".mjs")) return "text/javascript";
if (lower.endsWith(".ts")) return "text/typescript";
if (lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh")) return "text/x-shellscript";
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
if (lower.endsWith(".gif")) return "image/gif";
if (lower.endsWith(".svg")) return "image/svg+xml";
if (lower.endsWith(".webp")) return "image/webp";
if (lower.endsWith(".ico")) return "image/x-icon";
if (lower.endsWith(".pdf")) return "application/pdf";
if (lower.endsWith(".toml")) return "application/toml";
return "application/octet-stream";
}
}

View file

@ -66,7 +66,7 @@ public class ZipPackageExtractor {
normalizedPath,
content,
content.length,
determineContentType(normalizedPath)
SkillPackageContentTypeResolver.determineContentType(normalizedPath)
));
zis.closeEntry();
}
@ -119,28 +119,4 @@ public class ZipPackageExtractor {
}
}
private String determineContentType(String filename) {
String lower = filename.toLowerCase();
if (lower.endsWith(".py")) return "text/x-python";
if (lower.endsWith(".json")) return "application/json";
if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml";
if (lower.endsWith(".txt")) return "text/plain";
if (lower.endsWith(".md")) return "text/markdown";
if (lower.endsWith(".html")) return "text/html";
if (lower.endsWith(".css")) return "text/css";
if (lower.endsWith(".csv")) return "text/csv";
if (lower.endsWith(".xml")) return "application/xml";
if (lower.endsWith(".js")) return "text/javascript";
if (lower.endsWith(".ts")) return "text/typescript";
if (lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh")) return "text/x-shellscript";
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
if (lower.endsWith(".gif")) return "image/gif";
if (lower.endsWith(".svg")) return "image/svg+xml";
if (lower.endsWith(".webp")) return "image/webp";
if (lower.endsWith(".ico")) return "image/x-icon";
if (lower.endsWith(".pdf")) return "application/pdf";
if (lower.endsWith(".toml")) return "application/toml";
return "application/octet-stream";
}
}

View file

@ -93,6 +93,8 @@ spring:
enable: ${SPRING_MAIL_SMTP_STARTTLS_ENABLE:false}
skillhub:
builtin-skills:
enabled: ${SKILLHUB_BUILTIN_SKILLS_ENABLED:true}
auth:
mock:
enabled: ${SKILLHUB_AUTH_MOCK_ENABLED:false}

View file

@ -0,0 +1,5 @@
# SkillHub Hello
This built-in example skill is published to `@global` when SkillHub starts.
Use it to verify that skill discovery and CLI installation are working in a new deployment.

View file

@ -0,0 +1,8 @@
---
name: skillhub-hello
description: A built-in example skill that verifies SkillHub discovery and installation.
version: 1.0.0
---
# SkillHub Hello
This skill is bundled with SkillHub as a minimal example for validating discovery and installation.

View file

@ -0,0 +1,383 @@
package com.iflytek.skillhub.bootstrap;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillFile;
import com.iflytek.skillhub.domain.skill.SkillFileRepository;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator;
import com.iflytek.skillhub.domain.skill.validation.ValidationResult;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.boot.DefaultApplicationArguments;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.SimpleTransactionStatus;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class BuiltinSkillInitializerTest {
private static final String PUBLISHER_ID = BuiltinSkillInitializer.BUILTIN_PUBLISHER_ID;
@Mock private BuiltinSkillPackageLoader packageLoader;
@Mock private SkillPackageValidator packageValidator;
@Mock private SkillPublishService skillPublishService;
@Mock private NamespaceRepository namespaceRepository;
@Mock private NamespaceMemberRepository namespaceMemberRepository;
@Mock private UserAccountRepository userAccountRepository;
@Mock private SkillRepository skillRepository;
@Mock private SkillVersionRepository skillVersionRepository;
@Mock private SkillFileRepository skillFileRepository;
@Mock private PlatformTransactionManager transactionManager;
private BuiltinSkillProperties properties;
private BuiltinSkillInitializer initializer;
private Namespace globalNamespace;
@BeforeEach
void setUp() {
properties = new BuiltinSkillProperties();
globalNamespace = new Namespace("global", "Global", "system");
ReflectionTestUtils.setField(globalNamespace, "id", 1L);
lenient().when(transactionManager.getTransaction(any())).thenAnswer(ignored -> new SimpleTransactionStatus());
lenient().when(packageValidator.validate(any())).thenReturn(ValidationResult.pass());
initializer = new BuiltinSkillInitializer(
properties,
packageLoader,
new SkillMetadataParser(),
packageValidator,
skillPublishService,
namespaceRepository,
namespaceMemberRepository,
userAccountRepository,
skillRepository,
skillVersionRepository,
skillFileRepository,
transactionManager
);
}
@Test
void disabledInitializerDoesNotLoadPackages() throws Exception {
properties.setEnabled(false);
initializer.run(new DefaultApplicationArguments(new String[0]));
verify(packageLoader, never()).loadPackages();
verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean());
}
@Test
void firstStartupCreatesPublisherMembershipAndPublishesPackage() throws Exception {
BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello");
setupPublisher();
when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage));
when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of());
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID))
.thenReturn(Optional.empty());
when(skillPublishService.publishFromEntries(
eq("global"),
eq(skillPackage.entries()),
eq(PUBLISHER_ID),
eq(SkillVisibility.PUBLIC),
eq(Set.of("SUPER_ADMIN")),
eq(false)
)).thenReturn(publishResult("1.0.0"));
initializer.run(new DefaultApplicationArguments(new String[0]));
verify(userAccountRepository).save(any(UserAccount.class));
verify(namespaceMemberRepository).save(any(NamespaceMember.class));
verify(skillPublishService).publishFromEntries(
"global",
skillPackage.entries(),
PUBLISHER_ID,
SkillVisibility.PUBLIC,
Set.of("SUPER_ADMIN"),
false
);
}
@Test
void existingPublisherAndMembershipAreReusedIdempotently() throws Exception {
UserAccount existingPublisher = new UserAccount(PUBLISHER_ID, "Old name", "old@example.invalid", null);
NamespaceMember existingMember = new NamespaceMember(1L, PUBLISHER_ID, NamespaceRole.MEMBER);
when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(globalNamespace));
when(userAccountRepository.findById(PUBLISHER_ID)).thenReturn(Optional.of(existingPublisher));
when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER_ID))
.thenReturn(Optional.of(existingMember));
when(namespaceMemberRepository.save(any(NamespaceMember.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(packageLoader.loadPackages()).thenReturn(List.of());
initializer.run(new DefaultApplicationArguments(new String[0]));
verify(userAccountRepository).save(existingPublisher);
verify(namespaceMemberRepository).save(existingMember);
org.assertj.core.api.Assertions.assertThat(existingMember.getRole()).isEqualTo(NamespaceRole.OWNER);
}
@Test
void validationWarningsSkipPublishWithoutConfirmingWarnings() throws Exception {
BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello");
setupPublisher();
when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage));
when(packageValidator.validate(skillPackage.entries()))
.thenReturn(new ValidationResult(true, List.of(), List.of("warning")));
initializer.run(new DefaultApplicationArguments(new String[0]));
verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean());
}
@Test
void samePublishedVersionWithSameFingerprintSkipsPublish() throws Exception {
BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello");
Skill skill = builtInSkill(11L);
SkillVersion version = version(11L, 22L, "1.0.0", SkillVersionStatus.PUBLISHED);
setupPublisher();
when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage));
when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(skill));
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID))
.thenReturn(Optional.of(skill));
when(skillVersionRepository.findBySkillIdAndVersion(11L, "1.0.0")).thenReturn(Optional.of(version));
when(skillFileRepository.findByVersionId(22L)).thenReturn(filesFor(version.getId(), skillPackage.entries()));
initializer.run(new DefaultApplicationArguments(new String[0]));
verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean());
}
@Test
void samePublishedVersionWithDifferentFingerprintSkipsPublish() throws Exception {
BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello");
Skill skill = builtInSkill(11L);
SkillVersion version = version(11L, 22L, "1.0.0", SkillVersionStatus.PUBLISHED);
setupPublisher();
when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage));
when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(skill));
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID))
.thenReturn(Optional.of(skill));
when(skillVersionRepository.findBySkillIdAndVersion(11L, "1.0.0")).thenReturn(Optional.of(version));
when(skillFileRepository.findByVersionId(22L)).thenReturn(List.of(
new SkillFile(22L, "SKILL.md", 10L, "text/markdown", "different", "key")
));
initializer.run(new DefaultApplicationArguments(new String[0]));
verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean());
}
@Test
void sameNonPublishedVersionSkipsPublish() throws Exception {
BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello");
Skill skill = builtInSkill(11L);
SkillVersion version = version(11L, 22L, "1.0.0", SkillVersionStatus.UPLOADED);
setupPublisher();
when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage));
when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(skill));
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID))
.thenReturn(Optional.of(skill));
when(skillVersionRepository.findBySkillIdAndVersion(11L, "1.0.0")).thenReturn(Optional.of(version));
initializer.run(new DefaultApplicationArguments(new String[0]));
verify(skillFileRepository, never()).findByVersionId(any());
verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean());
}
@Test
void newerBuiltInVersionPublishesWhenOlderVersionExists() throws Exception {
BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.1", "Hello");
Skill skill = builtInSkill(11L);
setupPublisher();
when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage));
when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(skill));
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID))
.thenReturn(Optional.of(skill));
when(skillVersionRepository.findBySkillIdAndVersion(11L, "1.0.1")).thenReturn(Optional.empty());
when(skillPublishService.publishFromEntries(any(), any(), any(), any(), any(), eq(false)))
.thenReturn(publishResult("1.0.1"));
initializer.run(new DefaultApplicationArguments(new String[0]));
verify(skillPublishService).publishFromEntries(
"global",
skillPackage.entries(),
PUBLISHER_ID,
SkillVisibility.PUBLIC,
Set.of("SUPER_ADMIN"),
false
);
}
@Test
void userOwnedPublishedSlugSkipsBuiltInPublish() throws Exception {
BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello");
Skill otherSkill = new Skill(1L, "skillhub-hello", "user-1", SkillVisibility.PUBLIC);
ReflectionTestUtils.setField(otherSkill, "id", 33L);
SkillVersion otherPublishedVersion = version(33L, 44L, "1.0.0", SkillVersionStatus.PUBLISHED);
setupPublisher();
when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage));
when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of(otherSkill));
when(skillVersionRepository.findBySkillIdAndStatus(33L, SkillVersionStatus.PUBLISHED))
.thenReturn(List.of(otherPublishedVersion));
initializer.run(new DefaultApplicationArguments(new String[0]));
verify(skillRepository, never()).findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID);
verify(skillPublishService, never()).publishFromEntries(any(), any(), any(), any(), any(), anyBoolean());
}
@Test
void publishFailureIsContainedAndDoesNotAbortStartup() throws Exception {
BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello");
setupPublisher();
when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage));
when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of());
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID))
.thenReturn(Optional.empty());
when(skillPublishService.publishFromEntries(any(), any(), any(), any(), any(), eq(false)))
.thenThrow(new IllegalStateException("storage unavailable"));
assertThatCode(() -> initializer.run(new DefaultApplicationArguments(new String[0])))
.doesNotThrowAnyException();
}
@Test
void concurrentPublishedSameFingerprintSkipsAfterPublishFailure() throws Exception {
BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello");
Skill skill = builtInSkill(11L);
SkillVersion version = version(11L, 22L, "1.0.0", SkillVersionStatus.PUBLISHED);
setupPublisher();
when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage));
when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of());
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID))
.thenReturn(Optional.empty(), Optional.of(skill));
when(skillPublishService.publishFromEntries(any(), any(), any(), any(), any(), eq(false)))
.thenThrow(new IllegalStateException("version exists"));
when(skillVersionRepository.findBySkillIdAndVersion(11L, "1.0.0")).thenReturn(Optional.of(version));
when(skillFileRepository.findByVersionId(22L)).thenReturn(filesFor(version.getId(), skillPackage.entries()));
assertThatCode(() -> initializer.run(new DefaultApplicationArguments(new String[0])))
.doesNotThrowAnyException();
}
@Test
void concurrentPublishedDifferentFingerprintSkipsAfterPublishFailure() throws Exception {
BuiltinSkillPackageLoader.BuiltinSkillPackage skillPackage = packageWithVersion("1.0.0", "Hello");
Skill skill = builtInSkill(11L);
SkillVersion version = version(11L, 22L, "1.0.0", SkillVersionStatus.PUBLISHED);
setupPublisher();
when(packageLoader.loadPackages()).thenReturn(List.of(skillPackage));
when(skillRepository.findByNamespaceIdAndSlug(1L, "skillhub-hello")).thenReturn(List.of());
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(1L, "skillhub-hello", PUBLISHER_ID))
.thenReturn(Optional.empty(), Optional.of(skill));
when(skillPublishService.publishFromEntries(any(), any(), any(), any(), any(), eq(false)))
.thenThrow(new IllegalStateException("version exists"));
when(skillVersionRepository.findBySkillIdAndVersion(11L, "1.0.0")).thenReturn(Optional.of(version));
when(skillFileRepository.findByVersionId(22L)).thenReturn(List.of(
new SkillFile(22L, "SKILL.md", 10L, "text/markdown", "different", "key")
));
assertThatCode(() -> initializer.run(new DefaultApplicationArguments(new String[0])))
.doesNotThrowAnyException();
}
private void setupPublisher() {
when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(globalNamespace));
when(userAccountRepository.findById(PUBLISHER_ID)).thenReturn(Optional.empty());
when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, PUBLISHER_ID)).thenReturn(Optional.empty());
when(namespaceMemberRepository.save(any(NamespaceMember.class))).thenAnswer(invocation -> invocation.getArgument(0));
}
private BuiltinSkillPackageLoader.BuiltinSkillPackage packageWithVersion(String version, String body) {
byte[] skillMd = ("""
---
name: skillhub-hello
description: SkillHub hello
version: %s
---
# SkillHub Hello
%s
""".formatted(version, body)).getBytes(StandardCharsets.UTF_8);
byte[] readme = "# SkillHub Hello\n".getBytes(StandardCharsets.UTF_8);
return new BuiltinSkillPackageLoader.BuiltinSkillPackage("skillhub-hello", List.of(
new PackageEntry("README.md", readme, readme.length, "text/markdown"),
new PackageEntry("SKILL.md", skillMd, skillMd.length, "text/markdown")
));
}
private Skill builtInSkill(Long id) {
Skill skill = new Skill(1L, "skillhub-hello", PUBLISHER_ID, SkillVisibility.PUBLIC);
ReflectionTestUtils.setField(skill, "id", id);
return skill;
}
private SkillVersion version(Long skillId, Long versionId, String version, SkillVersionStatus status) {
SkillVersion skillVersion = new SkillVersion(skillId, version, PUBLISHER_ID);
skillVersion.setStatus(status);
ReflectionTestUtils.setField(skillVersion, "id", versionId);
return skillVersion;
}
private SkillPublishService.PublishResult publishResult(String version) {
SkillVersion skillVersion = version(11L, 22L, version, SkillVersionStatus.PUBLISHED);
return new SkillPublishService.PublishResult(11L, "skillhub-hello", skillVersion);
}
private List<SkillFile> filesFor(Long versionId, List<PackageEntry> entries) {
return entries.stream()
.map(entry -> new SkillFile(
versionId,
entry.path(),
entry.size(),
entry.contentType(),
sha256(entry.content()),
"skills/11/" + versionId + "/" + entry.path()
))
.toList();
}
private String sha256(byte[] content) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(content));
} catch (Exception exception) {
throw new IllegalStateException(exception);
}
}
}

View file

@ -0,0 +1,124 @@
package com.iflytek.skillhub.bootstrap;
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser;
import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator;
import java.net.URLClassLoader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.jar.JarEntry;
import java.util.jar.JarOutputStream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import static org.assertj.core.api.Assertions.assertThat;
class BuiltinSkillPackageLoaderTest {
@TempDir
Path tempDir;
@Test
void loadsSkillhubHelloFromClasspath() throws Exception {
BuiltinSkillPackageLoader loader =
new BuiltinSkillPackageLoader(new PathMatchingResourcePatternResolver());
var packages = loader.loadPackages();
var skillhubHello = packages.stream()
.filter(skillPackage -> skillPackage.directory().equals("skillhub-hello"))
.findFirst()
.orElseThrow();
assertThat(skillhubHello.entries())
.extracting(entry -> entry.path())
.containsExactly("README.md", "SKILL.md");
assertThat(skillhubHello.entries())
.allSatisfy(entry -> assertThat(entry.contentType()).isEqualTo("text/markdown"));
}
@Test
void productionSkillhubHelloResourcePassesPackageValidation() throws Exception {
BuiltinSkillPackageLoader loader =
new BuiltinSkillPackageLoader(new PathMatchingResourcePatternResolver());
SkillPackageValidator validator = new SkillPackageValidator(new SkillMetadataParser());
var skillhubHello = loader.loadPackages().stream()
.filter(skillPackage -> skillPackage.directory().equals("skillhub-hello"))
.findFirst()
.orElseThrow();
var result = validator.validate(skillhubHello.entries());
assertThat(result.passed()).isTrue();
assertThat(result.warnings()).isEmpty();
}
@Test
void loadsBuiltInPackageFromJarClasspath() throws Exception {
Path jarPath = tempDir.resolve("builtin-skills.jar");
try (JarOutputStream jarOutputStream = new JarOutputStream(Files.newOutputStream(jarPath))) {
writeJarDirectory(jarOutputStream, "builtin-skills/");
writeJarDirectory(jarOutputStream, "builtin-skills/skillhub-hello/");
writeJarEntry(jarOutputStream, "builtin-skills/skillhub-hello/SKILL.md", """
---
name: skillhub-hello
description: SkillHub hello
version: 1.0.0
---
# SkillHub Hello
""");
writeJarEntry(jarOutputStream, "builtin-skills/skillhub-hello/README.md", "# SkillHub Hello\n");
}
try (URLClassLoader classLoader = new URLClassLoader(
new java.net.URL[]{jarPath.toUri().toURL()},
null
)) {
BuiltinSkillPackageLoader loader =
new BuiltinSkillPackageLoader(new PathMatchingResourcePatternResolver(classLoader));
var packages = loader.loadPackages();
assertThat(packages).hasSize(1);
assertThat(packages.getFirst().directory()).isEqualTo("skillhub-hello");
assertThat(packages.getFirst().entries())
.extracting(entry -> entry.path())
.containsExactly("README.md", "SKILL.md");
}
}
@Test
void fingerprintsAreStableAcrossEntryOrdering() {
byte[] skillMd = """
---
name: demo
description: Demo
version: 1.0.0
---
# Demo
""".getBytes(java.nio.charset.StandardCharsets.UTF_8);
byte[] readme = "# Demo\n".getBytes(java.nio.charset.StandardCharsets.UTF_8);
var first = List.of(
new com.iflytek.skillhub.domain.skill.validation.PackageEntry(
"SKILL.md", skillMd, skillMd.length, "text/markdown"),
new com.iflytek.skillhub.domain.skill.validation.PackageEntry(
"README.md", readme, readme.length, "text/markdown")
);
var second = List.of(first.get(1), first.get(0));
assertThat(BuiltinSkillFingerprints.fromEntries(first))
.isEqualTo(BuiltinSkillFingerprints.fromEntries(second));
}
private void writeJarDirectory(JarOutputStream jarOutputStream, String name) throws Exception {
jarOutputStream.putNextEntry(new JarEntry(name));
jarOutputStream.closeEntry();
}
private void writeJarEntry(JarOutputStream jarOutputStream, String name, String content) throws Exception {
jarOutputStream.putNextEntry(new JarEntry(name));
jarOutputStream.write(content.getBytes(StandardCharsets.UTF_8));
jarOutputStream.closeEntry();
}
}

View file

@ -0,0 +1,47 @@
package com.iflytek.skillhub.bootstrap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.StandardEnvironment;
import org.springframework.core.env.SystemEnvironmentPropertySource;
import org.springframework.core.io.ClassPathResource;
import static org.assertj.core.api.Assertions.assertThat;
class BuiltinSkillPropertiesBindingTest {
@Test
void defaultConfigEnablesBuiltinSkills() throws Exception {
BuiltinSkillProperties properties = bindProperties(Map.of());
assertThat(properties.isEnabled()).isTrue();
}
@Test
void environmentVariableCanDisableBuiltinSkills() throws Exception {
BuiltinSkillProperties properties = bindProperties(Map.of("SKILLHUB_BUILTIN_SKILLS_ENABLED", "false"));
assertThat(properties.isEnabled()).isFalse();
}
private BuiltinSkillProperties bindProperties(Map<String, Object> envVars) throws Exception {
ConfigurableEnvironment environment = new StandardEnvironment();
environment.getPropertySources().addFirst(new SystemEnvironmentPropertySource("test-env", envVars));
YamlPropertySourceLoader loader = new YamlPropertySourceLoader();
for (org.springframework.core.env.PropertySource<?> propertySource :
loader.load("application.yml", new ClassPathResource("application.yml"))) {
environment.getPropertySources().addLast(propertySource);
}
ConfigurationPropertySources.attach(environment);
return Binder.get(environment)
.bind("skillhub.builtin-skills", BuiltinSkillProperties.class)
.orElseThrow(() -> new IllegalStateException("Failed to bind built-in skill properties"));
}
}

View file

@ -0,0 +1,17 @@
package com.iflytek.skillhub.controller.support;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class SkillPackageContentTypeResolverTest {
@Test
void determinesKnownContentTypes() {
assertThat(SkillPackageContentTypeResolver.determineContentType("SKILL.md")).isEqualTo("text/markdown");
assertThat(SkillPackageContentTypeResolver.determineContentType("script.py")).isEqualTo("text/x-python");
assertThat(SkillPackageContentTypeResolver.determineContentType("config.yaml")).isEqualTo("application/x-yaml");
assertThat(SkillPackageContentTypeResolver.determineContentType("image.png")).isEqualTo("image/png");
assertThat(SkillPackageContentTypeResolver.determineContentType("archive.bin")).isEqualTo("application/octet-stream");
}
}