完成一个简易的全局skill、command管理器
This commit is contained in:
109
manager/internal/repo/git.go
Normal file
109
manager/internal/repo/git.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"skillmgr/internal/config"
|
||||
)
|
||||
|
||||
// URLToPathName 将 URL 转换为缓存目录名
|
||||
// 例如: https://github.com/user/repo.git -> github.com_user_repo
|
||||
func URLToPathName(url string) string {
|
||||
clean := strings.TrimPrefix(url, "https://")
|
||||
clean = strings.TrimPrefix(clean, "http://")
|
||||
clean = strings.TrimSuffix(clean, ".git")
|
||||
clean = strings.ReplaceAll(clean, "/", "_")
|
||||
return clean
|
||||
}
|
||||
|
||||
// CloneOrPull 克隆或更新仓库
|
||||
// 如果仓库不存在则 clone,存在则 pull
|
||||
func CloneOrPull(url, branch string) (string, error) {
|
||||
cachePath, err := config.GetCachePath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
repoPath := filepath.Join(cachePath, URLToPathName(url))
|
||||
|
||||
// 检查是否已存在
|
||||
if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil {
|
||||
// 已存在,执行 pull
|
||||
return repoPath, pullRepo(repoPath, branch)
|
||||
}
|
||||
|
||||
// 不存在,执行 clone
|
||||
return repoPath, cloneRepo(url, branch, repoPath)
|
||||
}
|
||||
|
||||
// cloneRepo 克隆仓库
|
||||
func cloneRepo(url, branch, dest string) error {
|
||||
args := []string{"clone", "--depth", "1"}
|
||||
if branch != "" {
|
||||
args = append(args, "--branch", branch)
|
||||
}
|
||||
args = append(args, url, dest)
|
||||
|
||||
cmd := exec.Command("git", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git clone 失败: %w\n%s", err, output)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pullRepo 更新仓库
|
||||
func pullRepo(path, branch string) error {
|
||||
// 先 fetch
|
||||
fetchCmd := exec.Command("git", "-C", path, "fetch", "origin")
|
||||
if output, err := fetchCmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("git fetch 失败: %w\n%s", err, output)
|
||||
}
|
||||
|
||||
// 然后 pull
|
||||
pullArgs := []string{"-C", path, "pull", "origin"}
|
||||
if branch != "" {
|
||||
pullArgs = append(pullArgs, branch)
|
||||
}
|
||||
pullCmd := exec.Command("git", pullArgs...)
|
||||
output, err := pullCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git pull 失败: %w\n%s", err, output)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRepoPath 获取仓库缓存路径
|
||||
func GetRepoPath(url string) (string, error) {
|
||||
cachePath, err := config.GetCachePath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(cachePath, URLToPathName(url)), nil
|
||||
}
|
||||
|
||||
// CloneTemporary 克隆临时仓库到临时目录
|
||||
// 返回临时目录路径和清理函数
|
||||
func CloneTemporary(url, branch string) (repoPath string, cleanup func(), err error) {
|
||||
// 创建临时目录
|
||||
tmpDir, err := os.MkdirTemp("", "skillmgr-temp-*")
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("创建临时目录失败: %w", err)
|
||||
}
|
||||
|
||||
cleanup = func() {
|
||||
os.RemoveAll(tmpDir)
|
||||
}
|
||||
|
||||
// 克隆到临时目录
|
||||
if err := cloneRepo(url, branch, tmpDir); err != nil {
|
||||
cleanup()
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
return tmpDir, cleanup, nil
|
||||
}
|
||||
24
manager/internal/repo/git_test.go
Normal file
24
manager/internal/repo/git_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestURLToPathName(t *testing.T) {
|
||||
tests := []struct {
|
||||
url string
|
||||
expected string
|
||||
}{
|
||||
{"https://github.com/user/repo.git", "github.com_user_repo"},
|
||||
{"https://github.com/user/repo", "github.com_user_repo"},
|
||||
{"http://gitlab.com/org/project.git", "gitlab.com_org_project"},
|
||||
{"https://github.com/user/my-repo.git", "github.com_user_my-repo"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
result := URLToPathName(tc.url)
|
||||
if result != tc.expected {
|
||||
t.Errorf("URLToPathName(%s): 期望 %s,得到 %s", tc.url, tc.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
191
manager/internal/repo/scanner.go
Normal file
191
manager/internal/repo/scanner.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"skillmgr/internal/config"
|
||||
"skillmgr/internal/types"
|
||||
)
|
||||
|
||||
// ScanSkills 扫描仓库中的 skills
|
||||
func ScanSkills(repoPath string) ([]types.SkillMetadata, error) {
|
||||
skillsPath := filepath.Join(repoPath, "skills")
|
||||
|
||||
entries, err := os.ReadDir(skillsPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []types.SkillMetadata{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var skills []types.SkillMetadata
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否有 SKILL.md
|
||||
skillFile := filepath.Join(skillsPath, entry.Name(), "SKILL.md")
|
||||
if _, err := os.Stat(skillFile); err == nil {
|
||||
skills = append(skills, types.SkillMetadata{
|
||||
Name: entry.Name(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return skills, nil
|
||||
}
|
||||
|
||||
// ScanCommands 扫描仓库中的 commands
|
||||
func ScanCommands(repoPath string) ([]types.CommandGroup, error) {
|
||||
commandsPath := filepath.Join(repoPath, "commands")
|
||||
|
||||
entries, err := os.ReadDir(commandsPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []types.CommandGroup{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var groups []types.CommandGroup
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// 列出目录下的 .md 文件
|
||||
files, err := filepath.Glob(filepath.Join(commandsPath, entry.Name(), "*.md"))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "警告: 无法扫描 %s 下的 markdown 文件: %v\n", entry.Name(), err)
|
||||
continue
|
||||
}
|
||||
|
||||
var fileNames []string
|
||||
for _, f := range files {
|
||||
fileNames = append(fileNames, filepath.Base(f))
|
||||
}
|
||||
|
||||
if len(fileNames) > 0 {
|
||||
groups = append(groups, types.CommandGroup{
|
||||
Name: entry.Name(),
|
||||
Files: fileNames,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// FindSkill 在所有仓库中查找 skill
|
||||
func FindSkill(name string) (repoPath, skillPath string, repoName string, err error) {
|
||||
cfg, err := config.LoadRepositoryConfig()
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
cachePath, err := config.GetCachePath()
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
for _, repo := range cfg.Repositories {
|
||||
rp := filepath.Join(cachePath, URLToPathName(repo.URL))
|
||||
sp := filepath.Join(rp, "skills", name)
|
||||
|
||||
if _, err := os.Stat(filepath.Join(sp, "SKILL.md")); err == nil {
|
||||
return rp, sp, repo.Name, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", "", fmt.Errorf("skill '%s' 未在任何仓库中找到", name)
|
||||
}
|
||||
|
||||
// FindCommand 在所有仓库中查找 command
|
||||
func FindCommand(name string) (repoPath, commandPath string, repoName string, err error) {
|
||||
cfg, err := config.LoadRepositoryConfig()
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
cachePath, err := config.GetCachePath()
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
for _, repo := range cfg.Repositories {
|
||||
rp := filepath.Join(cachePath, URLToPathName(repo.URL))
|
||||
cp := filepath.Join(rp, "commands", name)
|
||||
|
||||
if info, err := os.Stat(cp); err == nil && info.IsDir() {
|
||||
// 检查目录是否包含 .md 文件
|
||||
files, _ := filepath.Glob(filepath.Join(cp, "*.md"))
|
||||
if len(files) > 0 {
|
||||
return rp, cp, repo.Name, nil
|
||||
}
|
||||
// 目录存在但为空,返回特定错误
|
||||
return "", "", "", fmt.Errorf("command group '%s' 不包含任何命令文件", name)
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", "", fmt.Errorf("command '%s' 未在任何仓库中找到", name)
|
||||
}
|
||||
|
||||
// ListAvailableSkills 列出所有可用的 skills
|
||||
func ListAvailableSkills() ([]types.SkillMetadata, error) {
|
||||
cfg, err := config.LoadRepositoryConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cachePath, err := config.GetCachePath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var allSkills []types.SkillMetadata
|
||||
for _, repo := range cfg.Repositories {
|
||||
rp := filepath.Join(cachePath, URLToPathName(repo.URL))
|
||||
skills, err := ScanSkills(rp)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for i := range skills {
|
||||
skills[i].SourceRepo = repo.Name
|
||||
}
|
||||
allSkills = append(allSkills, skills...)
|
||||
}
|
||||
|
||||
return allSkills, nil
|
||||
}
|
||||
|
||||
// ListAvailableCommands 列出所有可用的 commands
|
||||
func ListAvailableCommands() ([]types.CommandGroup, error) {
|
||||
cfg, err := config.LoadRepositoryConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cachePath, err := config.GetCachePath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var allCommands []types.CommandGroup
|
||||
for _, repo := range cfg.Repositories {
|
||||
rp := filepath.Join(cachePath, URLToPathName(repo.URL))
|
||||
commands, err := ScanCommands(rp)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for i := range commands {
|
||||
commands[i].SourceRepo = repo.Name
|
||||
}
|
||||
allCommands = append(allCommands, commands...)
|
||||
}
|
||||
|
||||
return allCommands, nil
|
||||
}
|
||||
Reference in New Issue
Block a user