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 +

+ +

skillshare

+ +

+ 网站 + License: MIT + 发布版本 + 平台 + Go 质量报告 + DeepWiki 提问 +

+ +

+ 在 GitHub 上点亮 Star +

+ +

+ runkids%2Fskillshare | Trendshift +

+ +

+ AI CLI 技能(Skills)、智能体(Agents)、规则(Rules)、命令(Commands)等资源的唯一事实来源。
+ 一键同步到所有平台——从个人到组织级全覆盖。
+ 支持 Codex、Claude Code、OpenClaw、OpenCode 及 60+ 更多工具。 +

+ +

+ skillshare 演示 +

+ +

+ 官网 • + 安装 • + 快速开始 • + 亮点功能 • + 截图预览 • + 文档 +

+ +> [!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 预览 + +| 技能详细页 | 安全审计 | +|---|---| +| CLI 同步输出 | CLI 安装附带安全审计 | + +| UI 仪表盘 | UI 技能列表 | +|---|---| +| Web 仪表盘概览 | Web 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 变得更好的人。 + +leeeezx +Vergil333 +romanr +xocasdashdash +philippe-granet +terranc +benrfairless +nerveband +EarthChen +gdm257 +skovtunenko +TyceHerrman +1am2syman +thealokkr +JasonLandbridge +masonc15 +richardwhatever +reneleonhardt +ndeybach +hhh2210 +leoarry +salmonumbrella +daylamtayari +dstotijn +ipruning +massukio +kevincobain2000 +StephenPAdams +mk-imagine +Curtion +amdoi7 +jessica-engel +AlimuratYusup +thor-shuang +bishopmatthew +chaosky +iFwu +ildunari +aestilog +xarthurx +m0cun + +--- + +如果 skillshare 对你有帮助,不妨点个 ⭐ 支持一下 + +## Star 历史 + +[![Star 历史图](https://api.star-history.com/svg?repos=runkids/skillshare&type=date&legend=top-left)](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 # 格式化 + 代码检查 + 测试 aestilog xarthurx m0cun +bit3125 +eekryuos +Bongseop-Kim +sophodex +PeterTianbuhan +dotned +ismferd +jblackburn21 +jnhu76 +jacobleft +rhysmcneill +druellan +12britz ---