diff --git a/README-zh.md b/README-zh.md new file mode 100644 index 00000000..b579747c --- /dev/null +++ b/README-zh.md @@ -0,0 +1,286 @@ +
+
+
+ AI CLI 技能(Skills)、智能体(Agents)、规则(Rules)、命令(Commands)等资源的唯一事实来源。
+ 一键同步到所有平台——从个人到组织级全覆盖。
+ 支持 Codex、Claude Code、OpenClaw、OpenCode 及 60+ 更多工具。
+
+
+
+ 官网 • + 安装 • + 快速开始 • + 亮点功能 • + 截图预览 • + 文档 +
+ +> [!NOTE] +> **最新版本**: [v0.19.23](https://github.com/runkids/skillshare/releases/tag/v0.19.23) — 子目录安装的技能在更新后不再重复标记为可更新,仪表盘更新检测状态现在会跨会话保留。[查看全部版本 →](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
diff --git a/cmd/skillshare/install.go b/cmd/skillshare/install.go
index 88f6a044..33840aae 100644
--- a/cmd/skillshare/install.go
+++ b/cmd/skillshare/install.go
@@ -252,6 +252,8 @@ func parseOptsFromConfig(cfg *config.Config) install.ParseOptions {
return install.ParseOptions{
GitLabHosts: cfg.EffectiveGitLabHosts(),
AzureHosts: cfg.EffectiveAzureHosts(),
+ CNBHosts: cfg.EffectiveCNBHosts(),
+ GiteaHosts: cfg.EffectiveGiteaHosts(),
}
}
@@ -260,6 +262,8 @@ func parseOptsFromProjectConfig(cfg *config.ProjectConfig) install.ParseOptions
return 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 c88a74d0..682c097f 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -278,6 +278,8 @@ 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"`
+ 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
@@ -358,6 +360,16 @@ 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)
+}
+
+// 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 {
@@ -499,6 +511,20 @@ 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
+
+ // 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 {
@@ -758,6 +784,14 @@ func normalizeAzureHosts(hosts []string) ([]string, error) {
return normalizeHostList(hosts, "azure_hosts")
}
+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 {
@@ -791,6 +825,14 @@ func mergeAzureHostsFromEnv(configHosts []string) []string {
return mergeHostsFromEnv(configHosts, "SKILLSHARE_AZURE_HOSTS")
}
+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 7af009b9..24f3bfc9 100644
--- a/internal/config/project.go
+++ b/internal/config/project.go
@@ -273,6 +273,8 @@ 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"`
+ GiteaHosts []string `yaml:"gitea_hosts,omitempty"`
}
// EffectiveSkillsSource returns the resolved skills source directory.
@@ -339,6 +341,16 @@ 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)
+}
+
+// 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")
@@ -381,6 +393,20 @@ 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
+
+ // 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 c21a511f..948e344a 100644
--- a/internal/install/auth.go
+++ b/internal/install/auth.go
@@ -17,6 +17,8 @@ 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
+ PlatformGitea // gitea.com and self-hosted Gitea instances
)
// extractHost returns the hostname from a clone URL.
@@ -62,6 +64,12 @@ 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
+ }
+ if strings.Contains(host, "gitea") {
+ return PlatformGitea
+ }
return PlatformUnknown
}
@@ -98,6 +106,14 @@ 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"
+ }
+ case PlatformGitea:
+ if t := os.Getenv("GITEA_TOKEN"); t != "" {
+ return t, "x-access-token"
+ }
}
// Generic fallback — use platform-appropriate username, or preserve
@@ -195,6 +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", "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 00000000..86854e1c
--- /dev/null
+++ b/internal/install/gitea_download.go
@@ -0,0 +1,296 @@
+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 shortHash(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, escapeGiteaPath(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@") {
+ colon := strings.LastIndex(u, ":")
+ if colon != -1 {
+ segments := strings.Split(strings.Trim(u[colon+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 "", ""
+}
+
+// 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, "/")
+}
diff --git a/internal/install/install_apply.go b/internal/install/install_apply.go
index f825d21f..f793fd76 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 d7a30ea2..3169f94d 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: 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,
+ Warnings: warnings,
+ }, 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 f68e69ac..5b1554fa 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@