From 8c145b1728118823444d0905740224c8a6a1ff87 Mon Sep 17 00:00:00 2001
From: zishuo
Date: Tue, 26 May 2026 14:04:36 +0800
Subject: [PATCH 1/9] docs: add Chinese README translation
---
README-zh.md | 286 +++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 286 insertions(+)
create mode 100644 README-zh.md
diff --git a/README-zh.md b/README-zh.md
new file mode 100644
index 000000000..3950ffd94
--- /dev/null
+++ b/README-zh.md
@@ -0,0 +1,286 @@
+
+
+
+
+skillshare
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ AI CLI 技能(Skills)、智能体(Agents)、规则(Rules)、命令(Commands)等资源的唯一事实来源。
+ 一键同步到所有平台——从个人到组织级全覆盖。
+ 支持 Codex、Claude Code、OpenClaw、OpenCode 及 60+ 更多工具。
+
+
+
+
+
+
+
+ 官网 •
+ 安装 •
+ 快速开始 •
+ 亮点功能 •
+ 截图预览 •
+ 文档
+
+
+> [!NOTE]
+> **最新版本**: [v0.19.12](https://github.com/runkids/skillshare/releases/tag/v0.19.12) — config.yaml 中的 `skills:` 字段现在会保留(修复了团队共享问题)。[查看全部版本 →](https://github.com/runkids/skillshare/releases)
+
+## 为什么选择 skillshare
+
+每个 AI CLI 都有自己的技能目录。
+你在一个工具里编辑了技能,却忘了复制到另一个,最后记不清哪个在哪里。
+
+skillshare 解决了这个问题:
+
+- **单一来源,覆盖所有智能体** — 一条 `skillshare sync` 命令同步到 Claude、Cursor、Codex 及 60+ 工具
+- **智能体管理** — 将自定义智能体与技能一起同步到支持智能体的目标端
+- **不止于技能** — 使用 [extras](https://skillshare.runkids.cc/docs/reference/targets/configuration#extras) 管理规则、命令、提示词及任何基于文件的资源
+- **从任何地方安装** — GitHub、GitLab、Bitbucket、Azure DevOps 或任何自托管的 Git 仓库
+- **内置安全** — 在使用前审计技能是否存在提示注入和数据泄露风险
+- **团队就绪** — 项目中通过 `.skillshare/` 管理技能,组织级技能通过代码仓库同步
+- **本地轻量** — 单一二进制文件,无需注册中心,无遥测,完全支持离线使用
+- **细粒度过滤** — 通过 [`.skillignore`](https://skillshare.runkids.cc/docs/how-to/daily-tasks/filtering-skills)、SKILL.md 中的 `targets` 字段以及按目标端的 include/exclude 配置,精确控制哪些技能同步到哪些目标端
+
+> 从其他工具迁移?[迁移指南](https://skillshare.runkids.cc/docs/how-to/advanced/migration) · [功能对比](https://skillshare.runkids.cc/docs/understand/philosophy/comparison)
+
+## 工作原理
+
+- macOS / Linux:`~/.config/skillshare/`
+- Windows:`%AppData%\skillshare\`
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ 源目录 │
+│ ~/.config/skillshare/skills/ ← 技能(SKILL.md) │
+│ ~/.config/skillshare/agents/ ← 智能体 │
+│ ~/.config/skillshare/extras/ ← 规则、命令等 │
+└─────────────────────────────────────────────────────────────┘
+ │ sync
+ ┌───────────────┼───────────────┐
+ ▼ ▼ ▼
+ ┌───────────┐ ┌───────────┐ ┌───────────┐
+ │ Claude │ │ OpenCode │ │ OpenClaw │ ...
+ └───────────┘ └───────────┘ └───────────┘
+```
+
+| 平台 | 技能源目录 | 智能体源目录 | 扩展资源源目录 | 链接方式 |
+|----------|---------------|---------------|---------------|-----------|
+| macOS/Linux | `~/.config/skillshare/skills/` | `~/.config/skillshare/agents/` | `~/.config/skillshare/extras/` | 符号链接 |
+| Windows | `%AppData%\skillshare\skills\` | `%AppData%\skillshare\agents\` | `%AppData%\skillshare\extras\` | NTFS 交接点(无需管理员权限) |
+
+| | 命令式(逐命令安装) | 声明式(skillshare) |
+|---|---|---|
+| **事实来源** | 技能各自独立复制 | 单一来源 → 符号链接(或复制) |
+| **新机器配置** | 重新手动执行每次安装 | `git clone` 配置 + `sync` |
+| **安全审计** | 无 | 内置 `audit` + 安装/更新时自动扫描 |
+| **Web 仪表盘** | 无 | `skillshare ui` |
+| **运行时依赖** | Node.js + npm | 无(单一 Go 二进制文件) |
+
+> [完整对比 →](https://skillshare.runkids.cc/docs/understand/philosophy/comparison)
+
+## CLI 和 UI 预览
+
+| 技能详细页 | 安全审计 |
+|---|---|
+|
|
|
+
+| UI 仪表盘 | UI 技能列表 |
+|---|---|
+|
|
|
+
+## 安装
+
+### macOS / Linux
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/runkids/skillshare/main/install.sh | sh
+```
+
+### Windows PowerShell
+
+```powershell
+irm https://raw.githubusercontent.com/runkids/skillshare/main/install.ps1 | iex
+```
+
+### Homebrew
+
+```bash
+brew install skillshare
+```
+
+> **提示:** 运行 `skillshare upgrade` 即可更新到最新版本。它会自动检测你的安装方式并完成后续操作。
+
+### GitHub Actions
+
+```yaml
+- uses: runkids/setup-skillshare@v1
+ with:
+ source: ./skills
+- run: skillshare sync
+```
+
+查看 [`setup-skillshare`](https://github.com/marketplace/actions/setup-skillshare) 获取所有选项(审计、项目模式、版本锁定等)。
+
+### 缩写别名(可选)
+
+在 shell 配置(`~/.zshrc` 或 `~/.bashrc`)中添加别名:
+
+```bash
+alias ss='skillshare'
+```
+
+## 快速开始
+
+```bash
+skillshare init # 创建配置、源目录并检测目标端
+skillshare sync # 将技能同步到所有目标端
+```
+
+## 亮点功能
+
+**安装和更新技能** — 从 GitHub、GitLab 或任何 Git 仓库
+
+```bash
+skillshare install github.com/reponame/skills
+skillshare update --all
+skillshare target claude --mode copy # 如果符号链接不适用
+```
+
+**符号链接有问题?** — 为每个目标端切换到复制模式
+
+```bash
+skillshare target <名称> --mode copy
+skillshare sync
+```
+
+**安全审计** — 在技能到达智能体之前进行扫描
+
+```bash
+skillshare audit
+```
+
+**项目级技能** — 按仓库管理,随代码一起提交
+
+```bash
+skillshare init -p && skillshare sync
+```
+
+**智能体** — 将自定义智能体同步到支持智能体的目标端
+
+```bash
+skillshare sync agents # 仅同步智能体
+skillshare sync --all # 同步技能 + 智能体 + 扩展资源
+```
+
+**扩展资源** — 管理规则、命令、提示词等
+
+```bash
+skillshare extras init rules # 创建一个 "rules" 扩展
+skillshare sync --all # 同步技能 + 扩展资源
+skillshare extras collect rules # 将本地文件收集回源目录
+```
+
+**Shell 自动补全** — Tab 键补全命令、标志和子命令
+
+```bash
+skillshare completion bash --install # 也支持:zsh、fish、powershell、nushell
+```
+
+**Web 仪表盘** — 可视化控制面板
+
+```bash
+skillshare ui
+```
+
+[所有命令和指南 →](https://skillshare.runkids.cc/docs/reference/commands)
+
+## 参与贡献
+
+欢迎贡献!请先提交 issue,然后提交带测试的草稿 PR。
+查看 [CONTRIBUTING.md](CONTRIBUTING.md) 了解开发环境设置。
+
+```bash
+git clone https://github.com/runkids/skillshare.git && cd skillshare
+make check # 格式化 + 代码检查 + 测试
+```
+
+> [!TIP]
+> 不知道从哪里开始?浏览 [open issues](https://github.com/runkids/skillshare/issues) 或尝试 [Playground](https://skillshare.runkids.cc/docs/learn/with-playground) 获取零配置开发环境。
+
+## 贡献者
+
+感谢所有帮助 skillshare 变得更好的人。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+---
+
+如果 skillshare 对你有帮助,不妨点个 ⭐ 支持一下
+
+## Star 历史
+
+[](https://www.star-history.com/#runkids/skillshare&type=date&legend=top-left)
+
+---
+
+## 许可证
+
+MIT
From 17a9a0879b60b62232f30a78c7aa04ea5f8ca079 Mon Sep 17 00:00:00 2001
From: zishuo
Date: Tue, 26 May 2026 18:37:54 +0800
Subject: [PATCH 2/9] feat(install): add CNB platform support
Support CNB (Cloud Native Base, cnb.cool) as a git hosting platform for
installing skills. This adds platform detection, token authentication
via CNB_TOKEN, host configuration (cnb_hosts / SKILLSHARE_CNB_HOSTS),
and URL parsing for both direct and web URLs (including /-/tree paths).
Changes:
- auth.go: add PlatformCNB, detect cnb.cool hosts, resolve CNB_TOKEN
- config.go / project.go: add cnb_hosts config field with normalization
and env var merge, plus EffectiveCNBHosts() accessor
- source.go: add isCNBHost() and CNBHosts to ParseOptions
- install.go: pass CNBHosts from config to source parsing
- install_git.go: include CNB_TOKEN in auth error hints
---
cmd/skillshare/install.go | 2 ++
internal/config/config.go | 21 +++++++++++++++++++++
internal/config/project.go | 13 +++++++++++++
internal/install/auth.go | 9 +++++++++
internal/install/install_git.go | 2 +-
internal/install/source.go | 6 ++++++
6 files changed, 52 insertions(+), 1 deletion(-)
diff --git a/cmd/skillshare/install.go b/cmd/skillshare/install.go
index 88f6a0444..f1308b26f 100644
--- a/cmd/skillshare/install.go
+++ b/cmd/skillshare/install.go
@@ -252,6 +252,7 @@ func parseOptsFromConfig(cfg *config.Config) install.ParseOptions {
return install.ParseOptions{
GitLabHosts: cfg.EffectiveGitLabHosts(),
AzureHosts: cfg.EffectiveAzureHosts(),
+ CNBHosts: cfg.EffectiveCNBHosts(),
}
}
@@ -260,6 +261,7 @@ func parseOptsFromProjectConfig(cfg *config.ProjectConfig) install.ParseOptions
return install.ParseOptions{
GitLabHosts: cfg.EffectiveGitLabHosts(),
AzureHosts: cfg.EffectiveAzureHosts(),
+ CNBHosts: cfg.EffectiveCNBHosts(),
}
}
diff --git a/internal/config/config.go b/internal/config/config.go
index c88a74d02..53f5ed918 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -278,6 +278,7 @@ type Config struct {
TUI *bool `yaml:"tui,omitempty"` // nil = default true
GitLabHosts []string `yaml:"gitlab_hosts,omitempty"`
AzureHosts []string `yaml:"azure_hosts,omitempty"`
+ CNBHosts []string `yaml:"cnb_hosts,omitempty"`
// PreserveTildeOnSave folds $HOME prefixes back to ~ when serializing the
// config to YAML. Useful when the config is shared via dotfiles across
@@ -358,6 +359,11 @@ func (c *Config) EffectiveAzureHosts() []string {
return mergeAzureHostsFromEnv(c.AzureHosts)
}
+// EffectiveCNBHosts returns CNBHosts merged with SKILLSHARE_CNB_HOSTS env var.
+func (c *Config) EffectiveCNBHosts() []string {
+ return mergeCNBHostsFromEnv(c.CNBHosts)
+}
+
// IsTUIEnabled reports whether interactive TUI is enabled.
// nil (absent from config) is treated as true for backward compatibility.
func (c *Config) IsTUIEnabled() bool {
@@ -499,6 +505,13 @@ func Load() (*Config, error) {
}
cfg.AzureHosts = azureHosts
+ // Validate and normalize cnb_hosts
+ cnbHosts, err := normalizeCNBHosts(cfg.CNBHosts)
+ if err != nil {
+ return nil, err
+ }
+ cfg.CNBHosts = cnbHosts
+
// Migrate legacy flat target fields to skills: sub-key (one-time, persisted immediately)
if migrateTargetConfigs(cfg.Targets) {
if data, err := marshalYAML(&cfg); err == nil {
@@ -758,6 +771,10 @@ func normalizeAzureHosts(hosts []string) ([]string, error) {
return normalizeHostList(hosts, "azure_hosts")
}
+func normalizeCNBHosts(hosts []string) ([]string, error) {
+ return normalizeHostList(hosts, "cnb_hosts")
+}
+
// mergeHostsFromEnv merges comma-separated env var entries with config file hosts.
// Invalid entries in the env var are silently skipped.
func mergeHostsFromEnv(configHosts []string, envKey string) []string {
@@ -791,6 +808,10 @@ func mergeAzureHostsFromEnv(configHosts []string) []string {
return mergeHostsFromEnv(configHosts, "SKILLSHARE_AZURE_HOSTS")
}
+func mergeCNBHostsFromEnv(configHosts []string) []string {
+ return mergeHostsFromEnv(configHosts, "SKILLSHARE_CNB_HOSTS")
+}
+
func normalizeAuditBlockThreshold(v string) (string, error) {
threshold := strings.ToUpper(strings.TrimSpace(v))
if threshold == "" {
diff --git a/internal/config/project.go b/internal/config/project.go
index 7af009b9d..342a37a76 100644
--- a/internal/config/project.go
+++ b/internal/config/project.go
@@ -273,6 +273,7 @@ type ProjectConfig struct {
Hub HubConfig `yaml:"hub,omitempty"`
GitLabHosts []string `yaml:"gitlab_hosts,omitempty"`
AzureHosts []string `yaml:"azure_hosts,omitempty"`
+ CNBHosts []string `yaml:"cnb_hosts,omitempty"`
}
// EffectiveSkillsSource returns the resolved skills source directory.
@@ -339,6 +340,11 @@ func (c *ProjectConfig) EffectiveAzureHosts() []string {
return mergeAzureHostsFromEnv(c.AzureHosts)
}
+// EffectiveCNBHosts returns CNBHosts merged with SKILLSHARE_CNB_HOSTS env var.
+func (c *ProjectConfig) EffectiveCNBHosts() []string {
+ return mergeCNBHostsFromEnv(c.CNBHosts)
+}
+
// ProjectConfigPath returns the project config path for the given root.
func ProjectConfigPath(projectRoot string) string {
return filepath.Join(projectRoot, ".skillshare", "config.yaml")
@@ -381,6 +387,13 @@ func LoadProject(projectRoot string) (*ProjectConfig, error) {
}
cfg.AzureHosts = azureHosts
+ // Validate and normalize cnb_hosts
+ cnbHosts, err := normalizeCNBHosts(cfg.CNBHosts)
+ if err != nil {
+ return nil, fmt.Errorf("project config: %w", err)
+ }
+ cfg.CNBHosts = cnbHosts
+
for _, target := range cfg.Targets {
if strings.TrimSpace(target.Name) == "" {
return nil, fmt.Errorf("project config has target with empty name")
diff --git a/internal/install/auth.go b/internal/install/auth.go
index c21a511f8..9bf2a4f74 100644
--- a/internal/install/auth.go
+++ b/internal/install/auth.go
@@ -17,6 +17,7 @@ const (
PlatformGitLab // gitlab.com and self-hosted GitLab
PlatformBitbucket // bitbucket.org
PlatformAzureDevOps // dev.azure.com and visualstudio.com
+ PlatformCNB // cnb.cool and self-hosted CNB instances
)
// extractHost returns the hostname from a clone URL.
@@ -62,6 +63,9 @@ func detectPlatform(cloneURL string) Platform {
if host == "dev.azure.com" || host == "ssh.dev.azure.com" || strings.HasSuffix(host, ".visualstudio.com") {
return PlatformAzureDevOps
}
+ if strings.Contains(host, "cnb.cool") {
+ return PlatformCNB
+ }
return PlatformUnknown
}
@@ -98,6 +102,10 @@ func resolveToken(cloneURL string) (token, username string) {
if t := os.Getenv("AZURE_DEVOPS_TOKEN"); t != "" {
return t, "x-access-token"
}
+ case PlatformCNB:
+ if t := os.Getenv("CNB_TOKEN"); t != "" {
+ return t, "cnb"
+ }
}
// Generic fallback — use platform-appropriate username, or preserve
@@ -195,6 +203,7 @@ func sanitizeTokens(text string) string {
vars := []string{
"GITHUB_TOKEN", "GH_TOKEN", "GITLAB_TOKEN", "BITBUCKET_TOKEN",
"AZURE_DEVOPS_TOKEN", "SKILLSHARE_GIT_TOKEN", "BITBUCKET_USERNAME",
+ "CNB_TOKEN",
}
for _, v := range vars {
if t := os.Getenv(v); t != "" {
diff --git a/internal/install/install_git.go b/internal/install/install_git.go
index f68e69ac2..5224ee351 100644
--- a/internal/install/install_git.go
+++ b/internal/install/install_git.go
@@ -74,7 +74,7 @@ func WrapGitError(stderr string, err error, tokenAuthAttempted bool) error {
}
return fmt.Errorf("authentication required — options:\n"+
" 1. SSH URL: git@:/.git\n"+
- " 2. Token env var: GITHUB_TOKEN, GITLAB_TOKEN, BITBUCKET_TOKEN, AZURE_DEVOPS_TOKEN, or SKILLSHARE_GIT_TOKEN\n"+
+ " 2. Token env var: GITHUB_TOKEN, GITLAB_TOKEN, BITBUCKET_TOKEN, AZURE_DEVOPS_TOKEN, CNB_TOKEN, or SKILLSHARE_GIT_TOKEN\n"+
" 3. Git credential helper: gh auth login\n %s", s)
}
if s != "" {
diff --git a/internal/install/source.go b/internal/install/source.go
index db69d81ff..faa19a8c2 100644
--- a/internal/install/source.go
+++ b/internal/install/source.go
@@ -81,6 +81,7 @@ var azureOnPremPattern = regexp.MustCompile(
type ParseOptions struct {
GitLabHosts []string // extra hostnames to treat as GitLab (nested subgroup support)
AzureHosts []string // extra hostnames to treat as Azure DevOps on-premises
+ CNBHosts []string // extra hostnames to treat as CNB instances
}
// ParseSource analyzes the input string and returns a Source struct.
@@ -462,6 +463,11 @@ func isGitLabHost(host string, extraHosts []string) bool {
hostMatchesAny(host, extraHosts)
}
+// isCNBHost returns true if the host should be treated as a CNB instance.
+func isCNBHost(host string, extraHosts []string) bool {
+ return strings.Contains(host, "cnb.cool") || hostMatchesAny(host, extraHosts)
+}
+
// stripGitBranchPrefix removes platform-specific branch path segments from web URLs.
// Bitbucket: src/{branch}/path → path
// GitLab: -/tree/{branch}/path → path, -/blob/{branch}/path → path
From ce9593e98e8e4da28d4e452fb89d0f49e2d85b11 Mon Sep 17 00:00:00 2001
From: zishuo
Date: Tue, 26 May 2026 18:49:18 +0800
Subject: [PATCH 3/9] feat(install): add Gitea platform support with API fast
path
Support Gitea (gitea.com and self-hosted instances) as a git hosting
platform for installing skills. Includes both core platform integration
and a Gitea Contents API fast path for direct file downloads without
full git clone.
Platform support (P0):
- auth.go: add PlatformGitea, detect gitea hosts, resolve GITEA_TOKEN
- config.go / project.go: add gitea_hosts config field with normalization,
env var merge (SKILLSHARE_GITEA_HOSTS), and EffectiveGiteaHosts()
- source.go: add isGiteaHost() and GiteaHosts to ParseOptions
- install.go: pass GiteaHosts from config to source parsing
- install_git.go: include GITEA_TOKEN in auth error hints
API fast path (P1):
- gitea_download.go: Gitea Contents API recursive directory download
(GET /api/v1/repos/{owner}/{repo}/contents/{path}), matching the
GitHub Contents API pattern
- install_apply.go / install_discovery.go: hook Gitea API fallback
after sparse checkout, before full clone
---
cmd/skillshare/install.go | 2 +
internal/config/config.go | 21 ++
internal/config/project.go | 13 ++
internal/install/auth.go | 10 +-
internal/install/gitea_download.go | 286 ++++++++++++++++++++++++++
internal/install/install_apply.go | 15 ++
internal/install/install_discovery.go | 27 +++
internal/install/install_git.go | 2 +-
internal/install/source.go | 6 +
9 files changed, 380 insertions(+), 2 deletions(-)
create mode 100644 internal/install/gitea_download.go
diff --git a/cmd/skillshare/install.go b/cmd/skillshare/install.go
index f1308b26f..33840aaed 100644
--- a/cmd/skillshare/install.go
+++ b/cmd/skillshare/install.go
@@ -253,6 +253,7 @@ func parseOptsFromConfig(cfg *config.Config) install.ParseOptions {
GitLabHosts: cfg.EffectiveGitLabHosts(),
AzureHosts: cfg.EffectiveAzureHosts(),
CNBHosts: cfg.EffectiveCNBHosts(),
+ GiteaHosts: cfg.EffectiveGiteaHosts(),
}
}
@@ -262,6 +263,7 @@ func parseOptsFromProjectConfig(cfg *config.ProjectConfig) install.ParseOptions
GitLabHosts: cfg.EffectiveGitLabHosts(),
AzureHosts: cfg.EffectiveAzureHosts(),
CNBHosts: cfg.EffectiveCNBHosts(),
+ GiteaHosts: cfg.EffectiveGiteaHosts(),
}
}
diff --git a/internal/config/config.go b/internal/config/config.go
index 53f5ed918..682c097fc 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -279,6 +279,7 @@ type Config struct {
GitLabHosts []string `yaml:"gitlab_hosts,omitempty"`
AzureHosts []string `yaml:"azure_hosts,omitempty"`
CNBHosts []string `yaml:"cnb_hosts,omitempty"`
+ GiteaHosts []string `yaml:"gitea_hosts,omitempty"`
// PreserveTildeOnSave folds $HOME prefixes back to ~ when serializing the
// config to YAML. Useful when the config is shared via dotfiles across
@@ -364,6 +365,11 @@ func (c *Config) EffectiveCNBHosts() []string {
return mergeCNBHostsFromEnv(c.CNBHosts)
}
+// EffectiveGiteaHosts returns GiteaHosts merged with SKILLSHARE_GITEA_HOSTS env var.
+func (c *Config) EffectiveGiteaHosts() []string {
+ return mergeGiteaHostsFromEnv(c.GiteaHosts)
+}
+
// IsTUIEnabled reports whether interactive TUI is enabled.
// nil (absent from config) is treated as true for backward compatibility.
func (c *Config) IsTUIEnabled() bool {
@@ -512,6 +518,13 @@ func Load() (*Config, error) {
}
cfg.CNBHosts = cnbHosts
+ // Validate and normalize gitea_hosts
+ giteaHosts, err := normalizeGiteaHosts(cfg.GiteaHosts)
+ if err != nil {
+ return nil, err
+ }
+ cfg.GiteaHosts = giteaHosts
+
// Migrate legacy flat target fields to skills: sub-key (one-time, persisted immediately)
if migrateTargetConfigs(cfg.Targets) {
if data, err := marshalYAML(&cfg); err == nil {
@@ -775,6 +788,10 @@ func normalizeCNBHosts(hosts []string) ([]string, error) {
return normalizeHostList(hosts, "cnb_hosts")
}
+func normalizeGiteaHosts(hosts []string) ([]string, error) {
+ return normalizeHostList(hosts, "gitea_hosts")
+}
+
// mergeHostsFromEnv merges comma-separated env var entries with config file hosts.
// Invalid entries in the env var are silently skipped.
func mergeHostsFromEnv(configHosts []string, envKey string) []string {
@@ -812,6 +829,10 @@ func mergeCNBHostsFromEnv(configHosts []string) []string {
return mergeHostsFromEnv(configHosts, "SKILLSHARE_CNB_HOSTS")
}
+func mergeGiteaHostsFromEnv(configHosts []string) []string {
+ return mergeHostsFromEnv(configHosts, "SKILLSHARE_GITEA_HOSTS")
+}
+
func normalizeAuditBlockThreshold(v string) (string, error) {
threshold := strings.ToUpper(strings.TrimSpace(v))
if threshold == "" {
diff --git a/internal/config/project.go b/internal/config/project.go
index 342a37a76..24f3bfc9c 100644
--- a/internal/config/project.go
+++ b/internal/config/project.go
@@ -274,6 +274,7 @@ type ProjectConfig struct {
GitLabHosts []string `yaml:"gitlab_hosts,omitempty"`
AzureHosts []string `yaml:"azure_hosts,omitempty"`
CNBHosts []string `yaml:"cnb_hosts,omitempty"`
+ GiteaHosts []string `yaml:"gitea_hosts,omitempty"`
}
// EffectiveSkillsSource returns the resolved skills source directory.
@@ -345,6 +346,11 @@ func (c *ProjectConfig) EffectiveCNBHosts() []string {
return mergeCNBHostsFromEnv(c.CNBHosts)
}
+// EffectiveGiteaHosts returns GiteaHosts merged with SKILLSHARE_GITEA_HOSTS env var.
+func (c *ProjectConfig) EffectiveGiteaHosts() []string {
+ return mergeGiteaHostsFromEnv(c.GiteaHosts)
+}
+
// ProjectConfigPath returns the project config path for the given root.
func ProjectConfigPath(projectRoot string) string {
return filepath.Join(projectRoot, ".skillshare", "config.yaml")
@@ -394,6 +400,13 @@ func LoadProject(projectRoot string) (*ProjectConfig, error) {
}
cfg.CNBHosts = cnbHosts
+ // Validate and normalize gitea_hosts
+ giteaHosts, err := normalizeGiteaHosts(cfg.GiteaHosts)
+ if err != nil {
+ return nil, fmt.Errorf("project config: %w", err)
+ }
+ cfg.GiteaHosts = giteaHosts
+
for _, target := range cfg.Targets {
if strings.TrimSpace(target.Name) == "" {
return nil, fmt.Errorf("project config has target with empty name")
diff --git a/internal/install/auth.go b/internal/install/auth.go
index 9bf2a4f74..948e344a4 100644
--- a/internal/install/auth.go
+++ b/internal/install/auth.go
@@ -18,6 +18,7 @@ const (
PlatformBitbucket // bitbucket.org
PlatformAzureDevOps // dev.azure.com and visualstudio.com
PlatformCNB // cnb.cool and self-hosted CNB instances
+ PlatformGitea // gitea.com and self-hosted Gitea instances
)
// extractHost returns the hostname from a clone URL.
@@ -66,6 +67,9 @@ func detectPlatform(cloneURL string) Platform {
if strings.Contains(host, "cnb.cool") {
return PlatformCNB
}
+ if strings.Contains(host, "gitea") {
+ return PlatformGitea
+ }
return PlatformUnknown
}
@@ -106,6 +110,10 @@ func resolveToken(cloneURL string) (token, username string) {
if t := os.Getenv("CNB_TOKEN"); t != "" {
return t, "cnb"
}
+ case PlatformGitea:
+ if t := os.Getenv("GITEA_TOKEN"); t != "" {
+ return t, "x-access-token"
+ }
}
// Generic fallback — use platform-appropriate username, or preserve
@@ -203,7 +211,7 @@ func sanitizeTokens(text string) string {
vars := []string{
"GITHUB_TOKEN", "GH_TOKEN", "GITLAB_TOKEN", "BITBUCKET_TOKEN",
"AZURE_DEVOPS_TOKEN", "SKILLSHARE_GIT_TOKEN", "BITBUCKET_USERNAME",
- "CNB_TOKEN",
+ "CNB_TOKEN", "GITEA_TOKEN",
}
for _, v := range vars {
if t := os.Getenv(v); t != "" {
diff --git a/internal/install/gitea_download.go b/internal/install/gitea_download.go
new file mode 100644
index 000000000..2e76a3295
--- /dev/null
+++ b/internal/install/gitea_download.go
@@ -0,0 +1,286 @@
+package install
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+type giteaContentItem struct {
+ Type string `json:"type"`
+ Name string `json:"name"`
+ Path string `json:"path"`
+ DownloadURL string `json:"download_url"`
+}
+
+type giteaCommit struct {
+ SHA string `json:"sha"`
+}
+
+// isGiteaAPISource reports whether the source is a Gitea instance that supports
+// the Contents API for direct file downloads.
+func isGiteaAPISource(source *Source) bool {
+ if source == nil {
+ return false
+ }
+ host := strings.ToLower(extractHost(source.CloneURL))
+ return strings.Contains(host, "gitea")
+}
+
+// downloadGiteaDir downloads a repository subdirectory via the Gitea Contents API.
+func downloadGiteaDir(owner, repo, path, destDir string, source *Source, onProgress ProgressCallback) (string, error) {
+ if owner == "" || repo == "" {
+ return "", fmt.Errorf("gitea download requires owner and repo")
+ }
+
+ apiBase := giteaAPIBase(source)
+ if err := os.MkdirAll(destDir, 0755); err != nil {
+ return "", err
+ }
+
+ if onProgress != nil {
+ onProgress("Downloading via Gitea API...")
+ }
+
+ client := &http.Client{Timeout: 30 * time.Second}
+ if err := giteaDownloadDirRecursive(client, apiBase, owner, repo, strings.Trim(path, "/"), destDir, onProgress); err != nil {
+ return "", err
+ }
+
+ commitHash, err := giteaFetchLatestCommitHash(apiBase, owner, repo, source)
+ if err != nil {
+ return "", nil
+ }
+ return shortCommitHash(commitHash), nil
+}
+
+// giteaAPIBase returns the base API URL for a Gitea instance.
+func giteaAPIBase(source *Source) string {
+ host := strings.ToLower(extractHost(source.CloneURL))
+ scheme := "https"
+ // For standard gitea.com, use api.gitea.com convention
+ // For self-hosted, use https://{host}/api/v1
+ if host == "gitea.com" {
+ return "https://gitea.com/api/v1"
+ }
+ return fmt.Sprintf("%s://%s/api/v1", scheme, host)
+}
+
+// giteaDownloadDirRecursive recursively downloads a directory via the Gitea Contents API.
+func giteaDownloadDirRecursive(client *http.Client, apiBase, owner, repo, path, destDir string, onProgress ProgressCallback) error {
+ contentsURL := fmt.Sprintf("%s/repos/%s/%s/contents/%s",
+ strings.TrimRight(apiBase, "/"), owner, repo, url.PathEscape(path))
+
+ req, err := giteaNewRequest(contentsURL)
+ if err != nil {
+ return err
+ }
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return fmt.Errorf("Gitea API request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("Gitea contents API returned %d for %s", resp.StatusCode, path)
+ }
+
+ var raw json.RawMessage
+ if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
+ return fmt.Errorf("failed to parse Gitea contents response: %w", err)
+ }
+
+ trimmed := bytes.TrimSpace(raw)
+ if len(trimmed) == 0 {
+ return fmt.Errorf("empty Gitea contents response for %q", path)
+ }
+
+ // Single file response: {type, name, path, download_url, ...}
+ if trimmed[0] == '{' {
+ var item giteaContentItem
+ if err := json.Unmarshal(trimmed, &item); err != nil {
+ return err
+ }
+ if item.Type != "file" {
+ return fmt.Errorf("unsupported Gitea content type %q", item.Type)
+ }
+ fileName, err := giteaSanitizeName(item.Name)
+ if err != nil {
+ return err
+ }
+ target := filepath.Join(destDir, fileName)
+ if onProgress != nil {
+ onProgress(fmt.Sprintf("Downloading %s", item.Path))
+ }
+ return giteaDownloadFile(client, item.DownloadURL, target)
+ }
+
+ // Directory listing response: [{type, name, path, download_url, ...}]
+ if trimmed[0] == '[' {
+ var items []giteaContentItem
+ if err := json.Unmarshal(trimmed, &items); err != nil {
+ return err
+ }
+ for _, item := range items {
+ name, err := giteaSanitizeName(item.Name)
+ if err != nil {
+ return err
+ }
+ switch item.Type {
+ case "dir":
+ childDir := filepath.Join(destDir, name)
+ if err := os.MkdirAll(childDir, 0755); err != nil {
+ return err
+ }
+ if err := giteaDownloadDirRecursive(client, apiBase, owner, repo, item.Path, childDir, onProgress); err != nil {
+ return err
+ }
+ case "file":
+ target := filepath.Join(destDir, name)
+ if onProgress != nil {
+ onProgress(fmt.Sprintf("Downloading %s", item.Path))
+ }
+ if err := giteaDownloadFile(client, item.DownloadURL, target); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+ }
+
+ return fmt.Errorf("unexpected Gitea contents payload for %q", path)
+}
+
+// giteaDownloadFile downloads a single file from a URL.
+func giteaDownloadFile(client *http.Client, fileURL, destPath string) error {
+ req, err := giteaNewRequest(fileURL)
+ if err != nil {
+ return err
+ }
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("download returned %d", resp.StatusCode)
+ }
+
+ if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
+ return err
+ }
+
+ f, err := os.OpenFile(destPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ _, err = io.Copy(f, resp.Body)
+ return err
+}
+
+// giteaNewRequest creates a GET request with Gitea API headers and optional
+// token authentication (GITEA_TOKEN or platform-resolved token).
+func giteaNewRequest(reqURL string) (*http.Request, error) {
+ req, err := http.NewRequest(http.MethodGet, reqURL, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("Accept", "application/json")
+
+ // Try GITEA_TOKEN first, then SKILLSHARE_GIT_TOKEN
+ token := os.Getenv("GITEA_TOKEN")
+ if token == "" {
+ token = os.Getenv("SKILLSHARE_GIT_TOKEN")
+ }
+ if token != "" {
+ req.Header.Set("Authorization", "token "+token)
+ }
+
+ return req, nil
+}
+
+// giteaFetchLatestCommitHash retrieves the latest commit SHA from a Gitea repo.
+func giteaFetchLatestCommitHash(apiBase, owner, repo string, source *Source) (string, error) {
+ commitsURL := fmt.Sprintf("%s/repos/%s/%s/commits?per_page=1",
+ strings.TrimRight(apiBase, "/"), owner, repo)
+
+ req, err := giteaNewRequest(commitsURL)
+ if err != nil {
+ return "", err
+ }
+
+ client := &http.Client{Timeout: 15 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode == http.StatusOK {
+ var commits []giteaCommit
+ if decodeErr := json.NewDecoder(resp.Body).Decode(&commits); decodeErr == nil && len(commits) > 0 {
+ return commits[0].SHA, nil
+ }
+ }
+
+ // Fallback: use git ls-remote
+ if source != nil && source.CloneURL != "" {
+ return getRemoteHeadCommit(source.CloneURL)
+ }
+
+ return "", fmt.Errorf("failed to fetch latest commit hash")
+}
+
+// giteaSanitizeName validates a Gitea file/directory name.
+func giteaSanitizeName(name string) (string, error) {
+ name = strings.TrimSpace(name)
+ if name == "" || name == "." || name == ".." || strings.Contains(name, "/") || strings.Contains(name, "\\") {
+ return "", fmt.Errorf("invalid Gitea item name %q", name)
+ }
+ return name, nil
+}
+
+// giteaOwnerRepo extracts the owner and repo name from a Gitea clone URL.
+// Clone URLs are in the format: https://host/owner/repo.git or git@host:owner/repo.git
+func giteaOwnerRepo(cloneURL string) (owner, repo string) {
+ u := strings.TrimSpace(cloneURL)
+ u = strings.TrimSuffix(u, ".git")
+ u = strings.TrimSuffix(u, "/")
+
+ // SSH: git@host:owner/repo
+ if strings.HasPrefix(u, "git@") {
+ parts := strings.Split(u, ":")
+ if len(parts) == 2 {
+ segments := strings.Split(parts[1], "/")
+ if len(segments) >= 2 {
+ return segments[0], segments[1]
+ }
+ }
+ return "", ""
+ }
+
+ // HTTPS: https://host/owner/repo
+ parsed, err := url.Parse(u)
+ if err != nil {
+ return "", ""
+ }
+ path := strings.Trim(parsed.Path, "/")
+ segments := strings.Split(path, "/")
+ if len(segments) >= 2 {
+ return segments[0], segments[1]
+ }
+ return "", ""
+}
diff --git a/internal/install/install_apply.go b/internal/install/install_apply.go
index f825d21f5..f793fd760 100644
--- a/internal/install/install_apply.go
+++ b/internal/install/install_apply.go
@@ -492,6 +492,21 @@ func installFromGitSubdir(source *Source, destPath string, result *InstallResult
}
}
+ // Fast path 2b: Gitea Contents API
+ if subdirPath == "" && isGiteaAPISource(source) {
+ owner, repo := giteaOwnerRepo(source.CloneURL)
+ resolved = source.Subdir
+ subdirPath = filepath.Join(tempRepoPath, resolved)
+ hash, dlErr := downloadGiteaDir(owner, repo, source.Subdir, subdirPath, source, opts.OnProgress)
+ if dlErr == nil {
+ commitHash = hash
+ } else {
+ result.Warnings = append(result.Warnings, fmt.Sprintf("Gitea API install fallback: %v", dlErr))
+ subdirPath = ""
+ _ = os.RemoveAll(tempRepoPath)
+ }
+ }
+
// Fallback: full clone + fuzzy subdir resolution
if subdirPath == "" {
_ = os.RemoveAll(tempRepoPath)
diff --git a/internal/install/install_discovery.go b/internal/install/install_discovery.go
index d7a30ea2a..ccc11675e 100644
--- a/internal/install/install_discovery.go
+++ b/internal/install/install_discovery.go
@@ -389,6 +389,33 @@ func discoverFromGitSubdirWithProgressImpl(source *Source, onProgress ProgressCa
subdirPath = ""
}
+ // Fast path 2b: Gitea Contents API
+ if subdirPath == "" && isGiteaAPISource(source) {
+ owner, repo := giteaOwnerRepo(source.CloneURL)
+ subdirPath = filepath.Join(repoPath, source.Subdir)
+ hash, dlErr := downloadGiteaDir(owner, repo, source.Subdir, subdirPath, source, onProgress)
+ if dlErr == nil {
+ commitHash = hash
+ skills := discoverSkills(subdirPath, true)
+ agents := discoverAgents(subdirPath, len(skills) > 0)
+ skills, agents, err = constrainDiscoveryToExplicitSkill(source, skills, agents)
+ if err != nil {
+ _ = os.RemoveAll(tempDir)
+ return nil, err
+ }
+ return &DiscoveryResult{
+ RepoPath: tempDir,
+ Skills: skills,
+ Agents: agents,
+ Source: source,
+ CommitHash: commitHash,
+ }, nil
+ }
+ warnings = append(warnings, fmt.Sprintf("Gitea API discovery fallback: %v", dlErr))
+ _ = os.RemoveAll(repoPath)
+ subdirPath = ""
+ }
+
// Fallback: full clone + fuzzy subdir resolution
_ = os.RemoveAll(repoPath)
if onProgress != nil {
diff --git a/internal/install/install_git.go b/internal/install/install_git.go
index 5224ee351..5b1554fab 100644
--- a/internal/install/install_git.go
+++ b/internal/install/install_git.go
@@ -74,7 +74,7 @@ func WrapGitError(stderr string, err error, tokenAuthAttempted bool) error {
}
return fmt.Errorf("authentication required — options:\n"+
" 1. SSH URL: git@:/.git\n"+
- " 2. Token env var: GITHUB_TOKEN, GITLAB_TOKEN, BITBUCKET_TOKEN, AZURE_DEVOPS_TOKEN, CNB_TOKEN, or SKILLSHARE_GIT_TOKEN\n"+
+ " 2. Token env var: GITHUB_TOKEN, GITLAB_TOKEN, BITBUCKET_TOKEN, AZURE_DEVOPS_TOKEN, CNB_TOKEN, GITEA_TOKEN, or SKILLSHARE_GIT_TOKEN\n"+
" 3. Git credential helper: gh auth login\n %s", s)
}
if s != "" {
diff --git a/internal/install/source.go b/internal/install/source.go
index faa19a8c2..1b4300c72 100644
--- a/internal/install/source.go
+++ b/internal/install/source.go
@@ -82,6 +82,7 @@ type ParseOptions struct {
GitLabHosts []string // extra hostnames to treat as GitLab (nested subgroup support)
AzureHosts []string // extra hostnames to treat as Azure DevOps on-premises
CNBHosts []string // extra hostnames to treat as CNB instances
+ GiteaHosts []string // extra hostnames to treat as Gitea instances
}
// ParseSource analyzes the input string and returns a Source struct.
@@ -468,6 +469,11 @@ func isCNBHost(host string, extraHosts []string) bool {
return strings.Contains(host, "cnb.cool") || hostMatchesAny(host, extraHosts)
}
+// isGiteaHost returns true if the host should be treated as a Gitea instance.
+func isGiteaHost(host string, extraHosts []string) bool {
+ return strings.Contains(host, "gitea") || hostMatchesAny(host, extraHosts)
+}
+
// stripGitBranchPrefix removes platform-specific branch path segments from web URLs.
// Bitbucket: src/{branch}/path → path
// GitLab: -/tree/{branch}/path → path, -/blob/{branch}/path → path
From 4112b9d47e6c20ddf84b4d2d29dba6cee2e6e022 Mon Sep 17 00:00:00 2001
From: zishuo
Date: Tue, 26 May 2026 19:05:45 +0800
Subject: [PATCH 4/9] docs(zh): update version to v0.19.23 in Chinese README
---
README-zh.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README-zh.md b/README-zh.md
index 3950ffd94..b579747c5 100644
--- a/README-zh.md
+++ b/README-zh.md
@@ -41,7 +41,7 @@
> [!NOTE]
-> **最新版本**: [v0.19.12](https://github.com/runkids/skillshare/releases/tag/v0.19.12) — config.yaml 中的 `skills:` 字段现在会保留(修复了团队共享问题)。[查看全部版本 →](https://github.com/runkids/skillshare/releases)
+> **最新版本**: [v0.19.23](https://github.com/runkids/skillshare/releases/tag/v0.19.23) — 子目录安装的技能在更新后不再重复标记为可更新,仪表盘更新检测状态现在会跨会话保留。[查看全部版本 →](https://github.com/runkids/skillshare/releases)
## 为什么选择 skillshare
From c3c0764365452437f42deddcf0a295de3500c901 Mon Sep 17 00:00:00 2001
From: zishuo
Date: Tue, 26 May 2026 19:21:31 +0800
Subject: [PATCH 5/9] fix(gitea): use segment-by-segment path escaping in
Contents API URL
Replace url.PathEscape(path) with escapeGiteaPath() that escapes each
path segment individually, preserving directory separators. The previous
approach would encode slashes as %2F, breaking nested subdirectory
downloads via the Gitea Contents API.
---
internal/install/gitea_download.go | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/internal/install/gitea_download.go b/internal/install/gitea_download.go
index 2e76a3295..2886454bd 100644
--- a/internal/install/gitea_download.go
+++ b/internal/install/gitea_download.go
@@ -76,7 +76,7 @@ func giteaAPIBase(source *Source) string {
// giteaDownloadDirRecursive recursively downloads a directory via the Gitea Contents API.
func giteaDownloadDirRecursive(client *http.Client, apiBase, owner, repo, path, destDir string, onProgress ProgressCallback) error {
contentsURL := fmt.Sprintf("%s/repos/%s/%s/contents/%s",
- strings.TrimRight(apiBase, "/"), owner, repo, url.PathEscape(path))
+ strings.TrimRight(apiBase, "/"), owner, repo, escapeGiteaPath(path))
req, err := giteaNewRequest(contentsURL)
if err != nil {
@@ -284,3 +284,13 @@ func giteaOwnerRepo(cloneURL string) (owner, repo string) {
}
return "", ""
}
+
+// escapeGiteaPath escapes each path segment individually for the Gitea Contents API.
+// This preserves directory separators while encoding special characters in each segment.
+func escapeGiteaPath(path string) string {
+ parts := strings.Split(path, "/")
+ for i := range parts {
+ parts[i] = url.PathEscape(parts[i])
+ }
+ return strings.Join(parts, "/")
+}
From bbd9090348920fbf33c0a44bdb2f85470f50696f Mon Sep 17 00:00:00 2001
From: zishuo
Date: Tue, 26 May 2026 19:54:58 +0800
Subject: [PATCH 6/9] fix: address bot review comments on PR #168
- fix(gitea): use shortHash instead of undefined shortCommitHash
- fix(gitea): use strings.LastIndex for robust SSH URL parsing
- fix(discovery): preserve warnings in Gitea API discovery fast path
- feat(auth): add detectPlatformFromHost with configured host support
---
internal/install/gitea_download.go | 8 ++++----
internal/install/install_discovery.go | 1 +
internal/install/source.go | 28 +++++++++++++++++++++++++++
3 files changed, 33 insertions(+), 4 deletions(-)
diff --git a/internal/install/gitea_download.go b/internal/install/gitea_download.go
index 2886454bd..86854e1c6 100644
--- a/internal/install/gitea_download.go
+++ b/internal/install/gitea_download.go
@@ -58,7 +58,7 @@ func downloadGiteaDir(owner, repo, path, destDir string, source *Source, onProgr
if err != nil {
return "", nil
}
- return shortCommitHash(commitHash), nil
+ return shortHash(commitHash), nil
}
// giteaAPIBase returns the base API URL for a Gitea instance.
@@ -262,9 +262,9 @@ func giteaOwnerRepo(cloneURL string) (owner, repo string) {
// SSH: git@host:owner/repo
if strings.HasPrefix(u, "git@") {
- parts := strings.Split(u, ":")
- if len(parts) == 2 {
- segments := strings.Split(parts[1], "/")
+ colon := strings.LastIndex(u, ":")
+ if colon != -1 {
+ segments := strings.Split(strings.Trim(u[colon+1:], "/"), "/")
if len(segments) >= 2 {
return segments[0], segments[1]
}
diff --git a/internal/install/install_discovery.go b/internal/install/install_discovery.go
index ccc11675e..3169f94d7 100644
--- a/internal/install/install_discovery.go
+++ b/internal/install/install_discovery.go
@@ -409,6 +409,7 @@ func discoverFromGitSubdirWithProgressImpl(source *Source, onProgress ProgressCa
Agents: agents,
Source: source,
CommitHash: commitHash,
+ Warnings: warnings,
}, nil
}
warnings = append(warnings, fmt.Sprintf("Gitea API discovery fallback: %v", dlErr))
diff --git a/internal/install/source.go b/internal/install/source.go
index 1b4300c72..ae960333a 100644
--- a/internal/install/source.go
+++ b/internal/install/source.go
@@ -474,6 +474,34 @@ func isGiteaHost(host string, extraHosts []string) bool {
return strings.Contains(host, "gitea") || hostMatchesAny(host, extraHosts)
}
+// detectPlatformFromHost returns the platform for a given hostname, using
+// configured extra hosts for CNB and Gitea self-hosted instances.
+func detectPlatformFromHost(host string, cnbHosts, giteaHosts []string) Platform {
+ host = strings.ToLower(host)
+ if host == "" {
+ return PlatformUnknown
+ }
+ if strings.Contains(host, "github") {
+ return PlatformGitHub
+ }
+ if strings.Contains(host, "gitlab") {
+ return PlatformGitLab
+ }
+ if strings.Contains(host, "bitbucket") {
+ return PlatformBitbucket
+ }
+ if host == "dev.azure.com" || host == "ssh.dev.azure.com" || strings.HasSuffix(host, ".visualstudio.com") {
+ return PlatformAzureDevOps
+ }
+ if isCNBHost(host, cnbHosts) {
+ return PlatformCNB
+ }
+ if isGiteaHost(host, giteaHosts) {
+ return PlatformGitea
+ }
+ return PlatformUnknown
+}
+
// stripGitBranchPrefix removes platform-specific branch path segments from web URLs.
// Bitbucket: src/{branch}/path → path
// GitLab: -/tree/{branch}/path → path, -/blob/{branch}/path → path
From 45390d47610508d796ab0fcf40d7becd0d87f488 Mon Sep 17 00:00:00 2001
From: zishuo
Date: Tue, 23 Jun 2026 18:35:03 +0800
Subject: [PATCH 7/9] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20CNB=20?=
=?UTF-8?q?=E5=B9=B3=E5=8F=B0=20API=20=E5=AE=89=E8=A3=85=E4=B8=8E=E5=8F=91?=
=?UTF-8?q?=E7=8E=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
新增 CNB (cnb.cool) 代码托管平台的 Contents API 支持,实现免克隆直接下载仓库子目录内容,用于技能的安装与发现。同时将 Gitea/CNB 的主机检测逻辑统一提取,并在 URL 解析中对 Gitea/CNB 主机显式处理 owner/repo 分割,修复了 Gitea/CNB 带子目录路径及 `.git` 后缀的解析问题。
---
internal/install/auth_test.go | 17 ++
internal/install/gitea_download.go | 252 +++++++++++++++++++++++++-
internal/install/install_apply.go | 15 ++
internal/install/install_discovery.go | 28 +++
internal/install/source.go | 13 ++
internal/install/source_test.go | 59 ++++++
6 files changed, 378 insertions(+), 6 deletions(-)
diff --git a/internal/install/auth_test.go b/internal/install/auth_test.go
index f23ea7668..9b740544a 100644
--- a/internal/install/auth_test.go
+++ b/internal/install/auth_test.go
@@ -11,6 +11,8 @@ var authTestEnvKeys = []string{
"BITBUCKET_TOKEN",
"BITBUCKET_USERNAME",
"AZURE_DEVOPS_TOKEN",
+ "CNB_TOKEN",
+ "GITEA_TOKEN",
"SKILLSHARE_GIT_TOKEN",
"GIT_CONFIG_COUNT",
}
@@ -164,6 +166,21 @@ func TestResolveToken(t *testing.T) {
wantTok: "tok",
wantUser: "oauth2",
},
+
+ {
+ name: "cnb token",
+ url: "https://cnb.cool/org/repo.git",
+ envVars: map[string]string{"CNB_TOKEN": "cnb_pat"},
+ wantTok: "cnb_pat",
+ wantUser: "cnb",
+ },
+ {
+ name: "gitea token",
+ url: "https://gitea.example.com/org/repo.git",
+ envVars: map[string]string{"GITEA_TOKEN": "gitea_pat"},
+ wantTok: "gitea_pat",
+ wantUser: "x-access-token",
+ },
{
name: "azure devops token",
url: "https://dev.azure.com/org/proj/_git/repo",
diff --git a/internal/install/gitea_download.go b/internal/install/gitea_download.go
index 86854e1c6..77b6c6251 100644
--- a/internal/install/gitea_download.go
+++ b/internal/install/gitea_download.go
@@ -2,6 +2,7 @@ package install
import (
"bytes"
+ "encoding/base64"
"encoding/json"
"fmt"
"io"
@@ -20,6 +21,14 @@ type giteaContentItem struct {
DownloadURL string `json:"download_url"`
}
+type cnbContentItem struct {
+ Type string `json:"type"`
+ Name string `json:"name"`
+ Path string `json:"path"`
+ Content string `json:"content"`
+ Entries []cnbContentItem `json:"entries"`
+}
+
type giteaCommit struct {
SHA string `json:"sha"`
}
@@ -31,7 +40,17 @@ func isGiteaAPISource(source *Source) bool {
return false
}
host := strings.ToLower(extractHost(source.CloneURL))
- return strings.Contains(host, "gitea")
+ return isGiteaHost(host, nil)
+}
+
+// isCNBAPISource reports whether the source is a CNB instance that supports
+// the git contents API documented in bin/cnb-swagger.json.
+func isCNBAPISource(source *Source) bool {
+ if source == nil {
+ return false
+ }
+ host := strings.ToLower(extractHost(source.CloneURL))
+ return isCNBHost(host, nil)
}
// downloadGiteaDir downloads a repository subdirectory via the Gitea Contents API.
@@ -75,8 +94,7 @@ func giteaAPIBase(source *Source) string {
// giteaDownloadDirRecursive recursively downloads a directory via the Gitea Contents API.
func giteaDownloadDirRecursive(client *http.Client, apiBase, owner, repo, path, destDir string, onProgress ProgressCallback) error {
- contentsURL := fmt.Sprintf("%s/repos/%s/%s/contents/%s",
- strings.TrimRight(apiBase, "/"), owner, repo, escapeGiteaPath(path))
+ contentsURL := buildGiteaContentsURL(apiBase, owner, repo, path)
req, err := giteaNewRequest(contentsURL)
if err != nil {
@@ -215,7 +233,7 @@ func giteaNewRequest(reqURL string) (*http.Request, error) {
// giteaFetchLatestCommitHash retrieves the latest commit SHA from a Gitea repo.
func giteaFetchLatestCommitHash(apiBase, owner, repo string, source *Source) (string, error) {
commitsURL := fmt.Sprintf("%s/repos/%s/%s/commits?per_page=1",
- strings.TrimRight(apiBase, "/"), owner, repo)
+ strings.TrimRight(apiBase, "/"), url.PathEscape(owner), url.PathEscape(repo))
req, err := giteaNewRequest(commitsURL)
if err != nil {
@@ -266,7 +284,7 @@ func giteaOwnerRepo(cloneURL string) (owner, repo string) {
if colon != -1 {
segments := strings.Split(strings.Trim(u[colon+1:], "/"), "/")
if len(segments) >= 2 {
- return segments[0], segments[1]
+ return segments[0], strings.TrimSuffix(segments[1], ".git")
}
}
return "", ""
@@ -280,7 +298,7 @@ func giteaOwnerRepo(cloneURL string) (owner, repo string) {
path := strings.Trim(parsed.Path, "/")
segments := strings.Split(path, "/")
if len(segments) >= 2 {
- return segments[0], segments[1]
+ return segments[0], strings.TrimSuffix(segments[1], ".git")
}
return "", ""
}
@@ -294,3 +312,225 @@ func escapeGiteaPath(path string) string {
}
return strings.Join(parts, "/")
}
+
+// downloadCNBDir downloads a repository subdirectory via the CNB git contents API.
+func downloadCNBDir(repo, path, destDir string, source *Source, onProgress ProgressCallback) (string, error) {
+ if repo == "" {
+ return "", fmt.Errorf("cnb download requires repo")
+ }
+ apiBase := cnbAPIBase(source)
+ if err := os.MkdirAll(destDir, 0755); err != nil {
+ return "", err
+ }
+ if onProgress != nil {
+ onProgress("Downloading via CNB API...")
+ }
+ client := &http.Client{Timeout: 30 * time.Second}
+ if err := cnbDownloadDirRecursive(client, apiBase, repo, strings.Trim(path, "/"), destDir, onProgress); err != nil {
+ return "", err
+ }
+ commitHash, err := cnbFetchLatestCommitHash(apiBase, repo, source)
+ if err != nil {
+ return "", nil
+ }
+ return shortCommitHash(commitHash), nil
+}
+
+func cnbAPIBase(source *Source) string {
+ host := strings.ToLower(extractHost(source.CloneURL))
+ return fmt.Sprintf("https://%s", host)
+}
+
+func cnbDownloadDirRecursive(client *http.Client, apiBase, repo, path, destDir string, onProgress ProgressCallback) error {
+ contentsURL := buildCNBContentsURL(apiBase, repo, path)
+ req, err := cnbNewRequest(contentsURL)
+ if err != nil {
+ return err
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ return fmt.Errorf("CNB API request failed: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("CNB contents API returned %d for %s", resp.StatusCode, path)
+ }
+ var item cnbContentItem
+ if err := json.NewDecoder(resp.Body).Decode(&item); err != nil {
+ return fmt.Errorf("failed to parse CNB contents response: %w", err)
+ }
+ return cnbWriteContentItem(client, apiBase, repo, item, destDir, onProgress)
+}
+
+func cnbWriteContentItem(client *http.Client, apiBase, repo string, item cnbContentItem, destDir string, onProgress ProgressCallback) error {
+ switch item.Type {
+ case "tree":
+ entries := item.Entries
+ for _, entry := range entries {
+ name, err := giteaSanitizeName(entry.Name)
+ if err != nil {
+ return err
+ }
+ target := filepath.Join(destDir, name)
+ switch entry.Type {
+ case "tree":
+ if err := os.MkdirAll(target, 0755); err != nil {
+ return err
+ }
+ if err := cnbDownloadDirRecursive(client, apiBase, repo, entry.Path, target, onProgress); err != nil {
+ return err
+ }
+ case "blob":
+ if onProgress != nil {
+ onProgress(fmt.Sprintf("Downloading %s", entry.Path))
+ }
+ if err := cnbDownloadFile(client, apiBase, repo, entry.Path, target); err != nil {
+ return err
+ }
+ case "empty":
+ if err := os.MkdirAll(target, 0755); err != nil {
+ return err
+ }
+ case "link", "submodule":
+ // Ignore links and submodules; they are not portable skill content.
+ default:
+ return fmt.Errorf("unsupported CNB content type %q", entry.Type)
+ }
+ }
+ return nil
+ case "blob":
+ name, err := giteaSanitizeName(item.Name)
+ if err != nil {
+ return err
+ }
+ if onProgress != nil {
+ onProgress(fmt.Sprintf("Downloading %s", item.Path))
+ }
+ return cnbWriteBlob(item, filepath.Join(destDir, name))
+ case "empty":
+ return nil
+ }
+ return fmt.Errorf("unsupported CNB content type %q", item.Type)
+}
+
+func cnbDownloadFile(client *http.Client, apiBase, repo, filePath, destPath string) error {
+ req, err := cnbNewRequest(buildCNBContentsURL(apiBase, repo, filePath))
+ if err != nil {
+ return err
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("CNB file download returned %d", resp.StatusCode)
+ }
+ var item cnbContentItem
+ if err := json.NewDecoder(resp.Body).Decode(&item); err != nil {
+ return fmt.Errorf("failed to parse CNB file response: %w", err)
+ }
+ return cnbWriteBlob(item, destPath)
+}
+
+func cnbWriteBlob(item cnbContentItem, destPath string) error {
+ if item.Type != "blob" {
+ return fmt.Errorf("unsupported CNB file type %q", item.Type)
+ }
+ data := []byte{}
+ if item.Content != "" {
+ decoded, decodeErr := base64.StdEncoding.DecodeString(item.Content)
+ if decodeErr != nil {
+ return fmt.Errorf("failed to decode CNB file content: %w", decodeErr)
+ }
+ data = decoded
+ }
+ if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
+ return err
+ }
+ return os.WriteFile(destPath, data, 0644)
+}
+
+func cnbNewRequest(reqURL string) (*http.Request, error) {
+ req, err := http.NewRequest(http.MethodGet, reqURL, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Accept", "application/vnd.cnb.api+json")
+ token := os.Getenv("CNB_TOKEN")
+ if token == "" {
+ token = os.Getenv("SKILLSHARE_GIT_TOKEN")
+ }
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ return req, nil
+}
+
+func cnbFetchLatestCommitHash(apiBase, repo string, source *Source) (string, error) {
+ commitsURL := fmt.Sprintf("%s/%s/-/git/commits?page=1&page_size=1", strings.TrimRight(apiBase, "/"), escapeRepoPath(repo))
+ req, err := cnbNewRequest(commitsURL)
+ if err != nil {
+ return "", err
+ }
+ client := &http.Client{Timeout: 15 * time.Second}
+ resp, err := client.Do(req)
+ if err == nil {
+ defer resp.Body.Close()
+ if resp.StatusCode == http.StatusOK {
+ var commits []giteaCommit
+ if decodeErr := json.NewDecoder(resp.Body).Decode(&commits); decodeErr == nil && len(commits) > 0 {
+ return commits[0].SHA, nil
+ }
+ }
+ }
+ if source != nil && source.CloneURL != "" {
+ return getRemoteHeadCommit(source.CloneURL)
+ }
+ return "", fmt.Errorf("failed to fetch latest commit hash")
+}
+
+func cnbRepoPath(cloneURL string) string {
+ u := strings.TrimSpace(cloneURL)
+ u = strings.TrimSuffix(u, ".git")
+ u = strings.TrimSuffix(u, "/")
+ if strings.Contains(u, "://") {
+ parsed, err := url.Parse(u)
+ if err != nil {
+ return ""
+ }
+ return strings.Trim(parsed.Path, "/")
+ }
+ if strings.Contains(u, "@") {
+ if colon := strings.LastIndex(u, ":"); colon != -1 {
+ return strings.Trim(u[colon+1:], "/")
+ }
+ }
+ return ""
+}
+
+func buildGiteaContentsURL(apiBase, owner, repo, path string) string {
+ base := fmt.Sprintf("%s/repos/%s/%s/contents", strings.TrimRight(apiBase, "/"), url.PathEscape(owner), url.PathEscape(repo))
+ path = strings.Trim(strings.TrimSpace(path), "/")
+ if path == "" {
+ return base
+ }
+ return base + "/" + escapeGiteaPath(path)
+}
+
+func buildCNBContentsURL(apiBase, repo, path string) string {
+ base := fmt.Sprintf("%s/%s/-/git/contents", strings.TrimRight(apiBase, "/"), escapeRepoPath(repo))
+ path = strings.Trim(strings.TrimSpace(path), "/")
+ if path == "" {
+ return base
+ }
+ return base + "/" + escapeGiteaPath(path)
+}
+
+func escapeRepoPath(repo string) string {
+ parts := strings.Split(strings.Trim(repo, "/"), "/")
+ for i := range parts {
+ parts[i] = url.PathEscape(parts[i])
+ }
+ return strings.Join(parts, "/")
+}
diff --git a/internal/install/install_apply.go b/internal/install/install_apply.go
index f41bbacdf..d13ae972f 100644
--- a/internal/install/install_apply.go
+++ b/internal/install/install_apply.go
@@ -492,6 +492,21 @@ func installFromGitSubdir(source *Source, destPath string, result *InstallResult
}
}
+ // Fast path 2b: CNB Contents API
+ if subdirPath == "" && isCNBAPISource(source) {
+ repo := cnbRepoPath(source.CloneURL)
+ resolved = source.Subdir
+ subdirPath = filepath.Join(tempRepoPath, resolved)
+ hash, dlErr := downloadCNBDir(repo, source.Subdir, subdirPath, source, opts.OnProgress)
+ if dlErr == nil {
+ commitHash = hash
+ } else {
+ result.Warnings = append(result.Warnings, fmt.Sprintf("CNB API install fallback: %v", dlErr))
+ subdirPath = ""
+ _ = os.RemoveAll(tempRepoPath)
+ }
+ }
+
// Fast path 2b: Gitea Contents API
if subdirPath == "" && isGiteaAPISource(source) {
owner, repo := giteaOwnerRepo(source.CloneURL)
diff --git a/internal/install/install_discovery.go b/internal/install/install_discovery.go
index 8983bc94b..3cedd7a92 100644
--- a/internal/install/install_discovery.go
+++ b/internal/install/install_discovery.go
@@ -389,6 +389,34 @@ func discoverFromGitSubdirWithProgressImpl(source *Source, onProgress ProgressCa
subdirPath = ""
}
+ // Fast path 2b: CNB Contents API
+ if subdirPath == "" && isCNBAPISource(source) {
+ repo := cnbRepoPath(source.CloneURL)
+ subdirPath = filepath.Join(repoPath, source.Subdir)
+ hash, dlErr := downloadCNBDir(repo, source.Subdir, subdirPath, source, onProgress)
+ if dlErr == nil {
+ commitHash = hash
+ skills := discoverSkills(subdirPath, true)
+ agents := discoverAgents(subdirPath, len(skills) > 0)
+ skills, agents, err = constrainDiscoveryToExplicitSkill(source, skills, agents)
+ if err != nil {
+ _ = os.RemoveAll(tempDir)
+ return nil, err
+ }
+ return &DiscoveryResult{
+ RepoPath: tempDir,
+ Skills: skills,
+ Agents: agents,
+ Source: source,
+ CommitHash: commitHash,
+ Warnings: warnings,
+ }, nil
+ }
+ warnings = append(warnings, fmt.Sprintf("CNB API discovery fallback: %v", dlErr))
+ _ = os.RemoveAll(repoPath)
+ subdirPath = ""
+ }
+
// Fast path 2b: Gitea Contents API
if subdirPath == "" && isGiteaAPISource(source) {
owner, repo := giteaOwnerRepo(source.CloneURL)
diff --git a/internal/install/source.go b/internal/install/source.go
index 72001adf3..c90f2a45b 100644
--- a/internal/install/source.go
+++ b/internal/install/source.go
@@ -670,6 +670,19 @@ func parseGitHTTPS(matches []string, source *Source, opts ParseOptions) (*Source
// may have nested subgroups up to 20 levels deep.
// Without .git, treat entire path as repo.
repoPath = path
+ } else if isCNBHost(host, opts.CNBHosts) || isGiteaHost(host, opts.GiteaHosts) {
+ // CNB and Gitea Contents APIs address repositories as {owner}/{repo};
+ // keep the existing owner/repo + subdir split but make configured hosts
+ // explicit so platform detection and future URL handling stay aligned.
+ parts := strings.SplitN(path, "/", 3)
+ if len(parts) >= 2 {
+ repoPath = parts[0] + "/" + parts[1]
+ if len(parts) == 3 {
+ subdir = parts[2]
+ }
+ } else {
+ repoPath = path
+ }
} else {
// Default for GHE, Gitea, Gogs, and other platforms:
// assume owner/repo (2 segments), remainder is subdir.
diff --git a/internal/install/source_test.go b/internal/install/source_test.go
index 1c5a0e243..ef0e61d56 100644
--- a/internal/install/source_test.go
+++ b/internal/install/source_test.go
@@ -1520,6 +1520,65 @@ func TestParseSourceWithOptions_GitLabHosts(t *testing.T) {
}
}
+func TestParseSourceWithOptions_CNBGiteaHosts(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ opts ParseOptions
+ wantCloneURL string
+ wantSubdir string
+ wantName string
+ }{
+ {
+ name: "cnb root repo keeps two segment repo",
+ input: "https://cnb.cool/org/repo",
+ wantCloneURL: "https://cnb.cool/org/repo.git",
+ wantName: "repo",
+ },
+ {
+ name: "cnb subdir uses owner repo split",
+ input: "https://cnb.cool/org/repo/skills/foo",
+ wantCloneURL: "https://cnb.cool/org/repo.git",
+ wantSubdir: "skills/foo",
+ wantName: "foo",
+ },
+ {
+ name: "custom cnb host uses configured host",
+ input: "https://git.corp.example/org/repo/skills/foo",
+ opts: ParseOptions{CNBHosts: []string{"git.corp.example"}},
+ wantCloneURL: "https://git.corp.example/org/repo.git",
+ wantSubdir: "skills/foo",
+ wantName: "foo",
+ },
+ {
+ name: "custom gitea host uses configured host",
+ input: "https://git.example.com/org/repo/skills/foo",
+ opts: ParseOptions{GiteaHosts: []string{"git.example.com"}},
+ wantCloneURL: "https://git.example.com/org/repo.git",
+ wantSubdir: "skills/foo",
+ wantName: "foo",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ source, err := ParseSourceWithOptions(tt.input, tt.opts)
+ if err != nil {
+ t.Fatalf("ParseSourceWithOptions(%q) error = %v", tt.input, err)
+ }
+ if source.CloneURL != tt.wantCloneURL {
+ t.Errorf("CloneURL = %q, want %q", source.CloneURL, tt.wantCloneURL)
+ }
+ if source.Subdir != tt.wantSubdir {
+ t.Errorf("Subdir = %q, want %q", source.Subdir, tt.wantSubdir)
+ }
+ if source.Name != tt.wantName {
+ t.Errorf("Name = %q, want %q", source.Name, tt.wantName)
+ }
+ })
+ }
+}
+
func TestParseSourceWithOptions_AzureHosts(t *testing.T) {
tests := []struct {
name string
From 9fcba2744d47bc1b9b3b456f9fd8be8eaa4f23e8 Mon Sep 17 00:00:00 2001
From: zishuo
Date: Tue, 23 Jun 2026 18:45:43 +0800
Subject: [PATCH 8/9] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E9=85=8D?=
=?UTF-8?q?=E7=BD=AE=E8=87=AA=E5=AE=9A=E4=B9=89=20CNB=20=E5=92=8C=20Gitea?=
=?UTF-8?q?=20=E4=B8=BB=E6=9C=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
将 CNBHosts 和 GiteaHosts 配置从 InstallContext 传递到 Source,使认证、平台检测和克隆操作能正确识别自定义的 CNB 和 Gitea 实例,而非仅依赖内置默认域名。
---
cmd/skillshare/install_context.go | 8 ++++
internal/install/auth.go | 34 ++++++++++++++++-
internal/install/auth_test.go | 55 +++++++++++++++++++++++++++
internal/install/context.go | 6 +++
internal/install/gitea_download.go | 4 +-
internal/install/install_apply.go | 2 +-
internal/install/install_config.go | 2 +
internal/install/install_discovery.go | 2 +-
internal/install/install_git.go | 10 +++--
internal/install/install_tracked.go | 35 ++++++++++++++---
internal/install/source.go | 6 ++-
internal/server/server.go | 4 ++
12 files changed, 152 insertions(+), 16 deletions(-)
diff --git a/cmd/skillshare/install_context.go b/cmd/skillshare/install_context.go
index 54399489a..2c794e8e9 100644
--- a/cmd/skillshare/install_context.go
+++ b/cmd/skillshare/install_context.go
@@ -65,6 +65,8 @@ func (g *globalInstallContext) PostInstallSkill(string) error { return nil }
func (g *globalInstallContext) Mode() string { return "global" }
func (g *globalInstallContext) GitLabHosts() []string { return g.cfg.EffectiveGitLabHosts() }
func (g *globalInstallContext) AzureHosts() []string { return g.cfg.EffectiveAzureHosts() }
+func (g *globalInstallContext) CNBHosts() []string { return g.cfg.EffectiveCNBHosts() }
+func (g *globalInstallContext) GiteaHosts() []string { return g.cfg.EffectiveGiteaHosts() }
// ---------------------------------------------------------------------------
// projectInstallContext
@@ -107,3 +109,9 @@ func (p *projectInstallContext) GitLabHosts() []string {
func (p *projectInstallContext) AzureHosts() []string {
return p.runtime.config.EffectiveAzureHosts()
}
+func (p *projectInstallContext) CNBHosts() []string {
+ return p.runtime.config.EffectiveCNBHosts()
+}
+func (p *projectInstallContext) GiteaHosts() []string {
+ return p.runtime.config.EffectiveGiteaHosts()
+}
diff --git a/internal/install/auth.go b/internal/install/auth.go
index 7b132e48f..809167edf 100644
--- a/internal/install/auth.go
+++ b/internal/install/auth.go
@@ -78,11 +78,15 @@ func detectPlatform(cloneURL string) Platform {
// SKILLSHARE_GIT_TOKEN. Returns empty strings if no token is available or
// the URL is not HTTPS.
func resolveToken(cloneURL string) (token, username string) {
+ return resolveTokenWithOptions(cloneURL, nil, nil)
+}
+
+func resolveTokenWithOptions(cloneURL string, cnbHosts, giteaHosts []string) (token, username string) {
if !isHTTPS(cloneURL) {
return "", ""
}
- platform := detectPlatform(cloneURL)
+ platform := detectPlatformWithOptions(cloneURL, cnbHosts, giteaHosts)
switch platform {
case PlatformGitHub:
if t := os.Getenv("GITHUB_TOKEN"); t != "" {
@@ -142,7 +146,11 @@ func resolveToken(cloneURL string) (token, username string) {
// git config entries (e.g. from CI pipelines).
// Returns nil for SSH/file URLs or when no token is available.
func authEnv(cloneURL string) []string {
- token, username := resolveToken(cloneURL)
+ return authEnvWithOptions(cloneURL, nil, nil)
+}
+
+func authEnvWithOptions(cloneURL string, cnbHosts, giteaHosts []string) []string {
+ token, username := resolveTokenWithOptions(cloneURL, cnbHosts, giteaHosts)
if token == "" {
return nil
}
@@ -179,6 +187,28 @@ func DetectPlatformForURL(cloneURL string) Platform {
return detectPlatform(cloneURL)
}
+func detectPlatformWithOptions(cloneURL string, cnbHosts, giteaHosts []string) Platform {
+ host := strings.ToLower(extractHost(cloneURL))
+ if host == "" {
+ return PlatformUnknown
+ }
+ return detectPlatformFromHost(host, cnbHosts, giteaHosts)
+}
+
+func (s *Source) authEnv() []string {
+ if s == nil {
+ return nil
+ }
+ return authEnvWithOptions(s.CloneURL, s.CNBHosts, s.GiteaHosts)
+}
+
+func (s *Source) platform() Platform {
+ if s == nil {
+ return PlatformUnknown
+ }
+ return detectPlatformWithOptions(s.CloneURL, s.CNBHosts, s.GiteaHosts)
+}
+
// existingConfigCount returns the current GIT_CONFIG_COUNT from the
// environment, or 0 if unset/invalid.
func existingConfigCount() int {
diff --git a/internal/install/auth_test.go b/internal/install/auth_test.go
index 9b740544a..dfe93d261 100644
--- a/internal/install/auth_test.go
+++ b/internal/install/auth_test.go
@@ -248,6 +248,61 @@ func TestResolveToken(t *testing.T) {
}
}
+func TestSourceAuthEnv_ConfiguredCNBGiteaHosts(t *testing.T) {
+ tests := []struct {
+ name string
+ source *Source
+ envVars map[string]string
+ wantKey string
+ }{
+ {
+ name: "configured CNB host uses CNB_TOKEN",
+ source: &Source{
+ CloneURL: "https://git.corp.example/org/repo.git",
+ CNBHosts: []string{"git.corp.example"},
+ },
+ envVars: map[string]string{"CNB_TOKEN": "cnb_private"},
+ wantKey: "url.https://cnb:cnb_private@git.corp.example/.insteadOf",
+ },
+ {
+ name: "configured Gitea host uses GITEA_TOKEN",
+ source: &Source{
+ CloneURL: "https://git.example.com/org/repo.git",
+ GiteaHosts: []string{"git.example.com"},
+ },
+ envVars: map[string]string{"GITEA_TOKEN": "gitea_private"},
+ wantKey: "url.https://x-access-token:gitea_private@git.example.com/.insteadOf",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ resetAuthTestEnv(t)
+ for k, v := range tt.envVars {
+ t.Setenv(k, v)
+ }
+ got := tt.source.authEnv()
+ if len(got) < 2 {
+ t.Fatalf("authEnv() = %v, want git config env", got)
+ }
+ if got[1] != "GIT_CONFIG_KEY_0="+tt.wantKey {
+ t.Errorf("auth key = %q, want %q", got[1], "GIT_CONFIG_KEY_0="+tt.wantKey)
+ }
+ })
+ }
+}
+
+func TestAPISourceDetection_UsesConfiguredHosts(t *testing.T) {
+ cnb := &Source{CloneURL: "https://git.corp.example/org/repo.git", CNBHosts: []string{"git.corp.example"}}
+ if !isCNBAPISource(cnb) {
+ t.Fatalf("configured CNB host was not treated as CNB API source")
+ }
+ gitea := &Source{CloneURL: "https://git.example.com/org/repo.git", GiteaHosts: []string{"git.example.com"}}
+ if !isGiteaAPISource(gitea) {
+ t.Fatalf("configured Gitea host was not treated as Gitea API source")
+ }
+}
+
func TestAuthEnv(t *testing.T) {
tests := []struct {
name string
diff --git a/internal/install/context.go b/internal/install/context.go
index 1d699bb3b..cf1f28feb 100644
--- a/internal/install/context.go
+++ b/internal/install/context.go
@@ -62,6 +62,12 @@ type InstallContext interface {
// AzureHosts returns extra hostnames to treat as Azure DevOps on-premises instances.
AzureHosts() []string
+
+ // CNBHosts returns extra hostnames to treat as CNB instances.
+ CNBHosts() []string
+
+ // GiteaHosts returns extra hostnames to treat as Gitea instances.
+ GiteaHosts() []string
}
// ConfigInstallResult summarises the outcome of InstallFromConfig.
diff --git a/internal/install/gitea_download.go b/internal/install/gitea_download.go
index 77b6c6251..f3d8c1077 100644
--- a/internal/install/gitea_download.go
+++ b/internal/install/gitea_download.go
@@ -40,7 +40,7 @@ func isGiteaAPISource(source *Source) bool {
return false
}
host := strings.ToLower(extractHost(source.CloneURL))
- return isGiteaHost(host, nil)
+ return isGiteaHost(host, source.GiteaHosts)
}
// isCNBAPISource reports whether the source is a CNB instance that supports
@@ -50,7 +50,7 @@ func isCNBAPISource(source *Source) bool {
return false
}
host := strings.ToLower(extractHost(source.CloneURL))
- return isCNBHost(host, nil)
+ return isCNBHost(host, source.CNBHosts)
}
// downloadGiteaDir downloads a repository subdirectory via the Gitea Contents API.
diff --git a/internal/install/install_apply.go b/internal/install/install_apply.go
index d13ae972f..8c9c1df4e 100644
--- a/internal/install/install_apply.go
+++ b/internal/install/install_apply.go
@@ -460,7 +460,7 @@ func installFromGitSubdir(source *Source, destPath string, result *InstallResult
// Works for GitHub and non-GitHub hosts.
if gitSupportsSparseCheckout() {
resolved = source.Subdir
- if err := sparseCloneSubdir(source.CloneURL, resolved, tempRepoPath, source.Branch, authEnv(source.CloneURL), opts.OnProgress); err == nil {
+ if err := sparseCloneSubdir(source.CloneURL, resolved, tempRepoPath, source.Branch, source.authEnv(), opts.OnProgress); err == nil {
subdirPath = filepath.Join(tempRepoPath, resolved)
if info, statErr := os.Stat(subdirPath); statErr != nil || !info.IsDir() {
subdirPath = ""
diff --git a/internal/install/install_config.go b/internal/install/install_config.go
index 69138123b..a1589c222 100644
--- a/internal/install/install_config.go
+++ b/internal/install/install_config.go
@@ -108,6 +108,8 @@ func InstallFromConfig(ctx InstallContext, opts InstallOptions) (ConfigInstallRe
parseOpts := ParseOptions{
GitLabHosts: ctx.GitLabHosts(),
AzureHosts: ctx.AzureHosts(),
+ CNBHosts: ctx.CNBHosts(),
+ GiteaHosts: ctx.GiteaHosts(),
}
// ── Classify skills: tracked / plain (groupable vs singles) ──
diff --git a/internal/install/install_discovery.go b/internal/install/install_discovery.go
index 3cedd7a92..a74e48053 100644
--- a/internal/install/install_discovery.go
+++ b/internal/install/install_discovery.go
@@ -329,7 +329,7 @@ func discoverFromGitSubdirWithProgressImpl(source *Source, onProgress ProgressCa
// Fast path 1: sparse checkout (preferred for speed if git is modern)
// Works for GitHub and non-GitHub hosts.
if gitSupportsSparseCheckout() {
- if err := sparseCloneSubdir(source.CloneURL, source.Subdir, repoPath, source.Branch, authEnv(source.CloneURL), onProgress); err == nil {
+ if err := sparseCloneSubdir(source.CloneURL, source.Subdir, repoPath, source.Branch, source.authEnv(), onProgress); err == nil {
subdirPath = filepath.Join(repoPath, source.Subdir)
if info, statErr := os.Stat(subdirPath); statErr == nil && info.IsDir() {
if hash, hashErr := getGitCommit(repoPath); hashErr == nil {
diff --git a/internal/install/install_git.go b/internal/install/install_git.go
index 883a0cb05..544489fde 100644
--- a/internal/install/install_git.go
+++ b/internal/install/install_git.go
@@ -139,6 +139,10 @@ func extractGitFatal(stderr string) string {
// GIT_CONFIG env vars without modifying the stored remote URL.
// branch specifies the branch to clone; empty string uses the remote default.
func cloneRepo(url, destPath, branch string, shallow bool, onProgress ProgressCallback) error {
+ return cloneRepoWithEnv(url, destPath, branch, shallow, authEnv(url), onProgress)
+}
+
+func cloneRepoWithEnv(url, destPath, branch string, shallow bool, extraEnv []string, onProgress ProgressCallback) error {
args := []string{"clone"}
if onProgress != nil {
args = append(args, "--progress")
@@ -152,11 +156,11 @@ func cloneRepo(url, destPath, branch string, shallow bool, onProgress ProgressCa
args = append(args, "--branch", branch)
}
args = append(args, url, destPath)
- return runGitCommandWithProgress(args, "", authEnv(url), onProgress)
+ return runGitCommandWithProgress(args, "", extraEnv, onProgress)
}
func cloneRepoForSource(source *Source, destPath, branch string, shallow bool, onProgress ProgressCallback) error {
- err := cloneRepo(source.CloneURL, destPath, branch, shallow, onProgress)
+ err := cloneRepoWithEnv(source.CloneURL, destPath, branch, shallow, source.authEnv(), onProgress)
if err == nil {
return nil
}
@@ -172,7 +176,7 @@ func cloneRepoForSource(source *Source, destPath, branch string, shallow bool, o
if onProgress != nil {
onProgress("Clone failed; retrying as a nested GitLab repository...")
}
- fallbackErr := cloneRepo(fallback.CloneURL, destPath, branch, shallow, onProgress)
+ fallbackErr := cloneRepoWithEnv(fallback.CloneURL, destPath, branch, shallow, fallback.authEnv(), onProgress)
if fallbackErr == nil {
*source = fallback
return nil
diff --git a/internal/install/install_tracked.go b/internal/install/install_tracked.go
index 04acf0e63..ddeecbf0f 100644
--- a/internal/install/install_tracked.go
+++ b/internal/install/install_tracked.go
@@ -197,7 +197,7 @@ func cloneRepoFull(url, destPath, branch string, onProgress ProgressCallback) er
}
func cloneTrackedRepoForSource(source *Source, destPath, branch string, onProgress ProgressCallback) error {
- err := cloneTrackedRepo(source.CloneURL, source.Subdir, destPath, branch, onProgress)
+ err := cloneTrackedRepoForParsedSource(source, destPath, branch, onProgress)
if err == nil {
return nil
}
@@ -213,7 +213,7 @@ func cloneTrackedRepoForSource(source *Source, destPath, branch string, onProgre
if onProgress != nil {
onProgress("Clone failed; retrying as a nested GitLab repository...")
}
- fallbackErr := cloneTrackedRepo(fallback.CloneURL, fallback.Subdir, destPath, branch, onProgress)
+ fallbackErr := cloneTrackedRepoForParsedSource(&fallback, destPath, branch, onProgress)
if fallbackErr == nil {
*source = fallback
return nil
@@ -234,13 +234,36 @@ func cloneTrackedRepoForSource(source *Source, destPath, branch string, onProgre
// When subdir is provided, sparse checkout is attempted first to reduce payload
// while preserving .git for future tracked updates.
func cloneTrackedRepo(url, subdir, destPath, branch string, onProgress ProgressCallback) error {
+ return cloneTrackedRepoWithEnv(url, subdir, destPath, branch, authEnv(url), onProgress)
+}
+
+func cloneTrackedRepoForParsedSource(source *Source, destPath, branch string, onProgress ProgressCallback) error {
+ if source == nil {
+ return fmt.Errorf("nil source")
+ }
+ return cloneTrackedRepoWithEnv(source.CloneURL, source.Subdir, destPath, branch, source.authEnv(), onProgress)
+}
+
+func cloneTrackedRepoWithEnv(url, subdir, destPath, branch string, extraEnv []string, onProgress ProgressCallback) error {
subdir = strings.TrimSpace(subdir)
if subdir != "" && gitSupportsSparseCheckout() {
if onProgress != nil {
onProgress("Preparing sparse checkout...")
}
- if err := sparseCloneSubdir(url, subdir, destPath, branch, authEnv(url), onProgress); err == nil {
- return nil
+ if err := sparseCloneSubdir(url, subdir, destPath, branch, extraEnv, onProgress); err == nil {
+ checkedSubdir := strings.TrimLeft(subdir, "/")
+ if checkedSubdir == "" {
+ return nil
+ }
+ if info, statErr := os.Stat(filepath.Join(destPath, checkedSubdir)); statErr == nil && info.IsDir() {
+ return nil
+ }
+ if cleanupErr := removeAll(destPath); cleanupErr != nil {
+ return fmt.Errorf("sparse checkout produced no subdirectory %q, and cleanup failed: %w", subdir, cleanupErr)
+ }
+ if onProgress != nil {
+ onProgress("Sparse checkout path missing; retrying standard clone...")
+ }
} else if shouldFallbackSparseTrackedClone(err) {
// sparseCloneSubdir may have already created destPath. Clean it before
// falling back to a standard clone strategy.
@@ -271,7 +294,7 @@ func cloneTrackedRepo(url, subdir, destPath, branch string, onProgress ProgressC
}
args = append(args, url, destPath)
- err := runGitCommandWithProgress(args, "", authEnv(url), onProgress)
+ err := runGitCommandWithProgress(args, "", extraEnv, onProgress)
if err == nil {
return nil
}
@@ -281,7 +304,7 @@ func cloneTrackedRepo(url, subdir, destPath, branch string, onProgress ProgressC
if onProgress != nil {
onProgress("Remote lacks partial clone support; retrying standard clone...")
}
- return cloneRepoFull(url, destPath, branch, onProgress)
+ return cloneRepoWithEnv(url, destPath, branch, false, extraEnv, onProgress)
}
// isAuthOrAccessError returns true for auth failures and access denials that
diff --git a/internal/install/source.go b/internal/install/source.go
index c90f2a45b..1895ea16c 100644
--- a/internal/install/source.go
+++ b/internal/install/source.go
@@ -126,6 +126,10 @@ type Source struct {
Path string // Local path (empty for git)
Name string // Derived skill name
Branch string // Git branch to clone from (empty = remote default)
+ // CNBHosts contains configured CNB hostnames for platform-specific auth/API behavior.
+ CNBHosts []string
+ // GiteaHosts contains configured Gitea hostnames for platform-specific auth/API behavior.
+ GiteaHosts []string
// ExplicitSkill is true when the user pointed directly at a SKILL.md file.
// That intent should resolve to exactly one skill, not a pack/discovery view.
ExplicitSkill bool
@@ -213,7 +217,7 @@ func ParseSourceWithOptions(input string, opts ParseOptions) (*Source, error) {
// Expand GitHub shorthand: owner/repo -> github.com/owner/repo
input = expandGitHubShorthand(input)
- source := &Source{Raw: input}
+ source := &Source{Raw: input, CNBHosts: opts.CNBHosts, GiteaHosts: opts.GiteaHosts}
// Check for file:// URL (for testing with local git repos)
if matches := fileURLPattern.FindStringSubmatch(input); matches != nil {
diff --git a/internal/server/server.go b/internal/server/server.go
index 8488da808..49056989b 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -176,11 +176,15 @@ func (s *Server) parseOpts() install.ParseOptions {
return install.ParseOptions{
GitLabHosts: s.projectCfg.EffectiveGitLabHosts(),
AzureHosts: s.projectCfg.EffectiveAzureHosts(),
+ CNBHosts: s.projectCfg.EffectiveCNBHosts(),
+ GiteaHosts: s.projectCfg.EffectiveGiteaHosts(),
}
}
return install.ParseOptions{
GitLabHosts: s.cfg.EffectiveGitLabHosts(),
AzureHosts: s.cfg.EffectiveAzureHosts(),
+ CNBHosts: s.cfg.EffectiveCNBHosts(),
+ GiteaHosts: s.cfg.EffectiveGiteaHosts(),
}
}
From 4ed760b70be55640074e4a8777386c9cf1c69caa Mon Sep 17 00:00:00 2001
From: zishuo
Date: Tue, 23 Jun 2026 18:49:49 +0800
Subject: [PATCH 9/9] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E4=B8=AD?=
=?UTF-8?q?=E6=96=87=20README=20=E8=87=B3=20v0.20.0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README-zh.md | 22 +++++++++++++++++++++-
1 file changed, 21 insertions(+), 1 deletion(-)
diff --git a/README-zh.md b/README-zh.md
index b579747c5..676efada4 100644
--- a/README-zh.md
+++ b/README-zh.md
@@ -41,7 +41,7 @@
> [!NOTE]
-> **最新版本**: [v0.19.23](https://github.com/runkids/skillshare/releases/tag/v0.19.23) — 子目录安装的技能在更新后不再重复标记为可更新,仪表盘更新检测状态现在会跨会话保留。[查看全部版本 →](https://github.com/runkids/skillshare/releases)
+> **最新版本**: [v0.20.0](https://github.com/runkids/skillshare/releases/tag/v0.20.0) — 通过 git_root scope 选择 commit/push/pull 的版本范围(skills、agents、extras,或全部合并在一个仓库中);extras 扩展转换在同步时将 Markdown 转换为原生格式(Gemini TOML 命令、Codex TOML 智能体)。[查看全部版本 →](https://github.com/runkids/skillshare/releases)
## 为什么选择 skillshare
@@ -204,6 +204,13 @@ skillshare extras collect rules # 将本地文件收集回源目录
skillshare completion bash --install # 也支持:zsh、fish、powershell、nushell
```
+**本地检查点** — 提交源目录变更而不推送
+
+```bash
+skillshare commit -m "更新审查技能"
+skillshare commit --dry-run
+```
+
**Web 仪表盘** — 可视化控制面板
```bash
@@ -270,6 +277,19 @@ make check # 格式化 + 代码检查 + 测试
+
+
+
+
+
+
+
+
+
+
+
+
+
---