1
0

完成一个简易的全局skill、command管理器

This commit is contained in:
2026-02-25 14:33:56 +08:00
parent f4cb809f9d
commit 2d327b5af8
60 changed files with 6053 additions and 1 deletions

View File

@@ -0,0 +1,141 @@
package config
import (
"encoding/json"
"fmt"
"os"
"skillmgr/internal/types"
)
// LoadInstallConfig 加载安装配置
func LoadInstallConfig() (*types.InstallConfig, error) {
path, err := GetInstallConfigPath()
if err != nil {
return nil, err
}
if _, err := os.Stat(path); os.IsNotExist(err) {
return &types.InstallConfig{
Installations: []types.InstallRecord{},
}, nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg types.InstallConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("解析 install.json 失败: %w请检查 JSON 格式)", err)
}
return &cfg, nil
}
// SaveInstallConfig 保存安装配置
func SaveInstallConfig(cfg *types.InstallConfig) error {
path, err := GetInstallConfigPath()
if err != nil {
return err
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
// AddInstallRecord 添加安装记录
func AddInstallRecord(record types.InstallRecord) error {
cfg, err := LoadInstallConfig()
if err != nil {
return err
}
cfg.Installations = append(cfg.Installations, record)
return SaveInstallConfig(cfg)
}
// RemoveInstallRecord 移除安装记录
func RemoveInstallRecord(itemType types.ItemType, name string, platform types.Platform, scope types.Scope) error {
cfg, err := LoadInstallConfig()
if err != nil {
return err
}
for i, r := range cfg.Installations {
if r.Type == itemType && r.Name == name && r.Platform == platform && r.Scope == scope {
cfg.Installations = append(cfg.Installations[:i], cfg.Installations[i+1:]...)
return SaveInstallConfig(cfg)
}
}
return nil
}
// FindInstallRecord 查找安装记录
func FindInstallRecord(itemType types.ItemType, name string, platform types.Platform, scope types.Scope) (*types.InstallRecord, error) {
cfg, err := LoadInstallConfig()
if err != nil {
return nil, err
}
for _, r := range cfg.Installations {
if r.Type == itemType && r.Name == name && r.Platform == platform && r.Scope == scope {
return &r, nil
}
}
return nil, nil
}
// UpdateInstallRecord 更新安装记录
func UpdateInstallRecord(record types.InstallRecord) error {
cfg, err := LoadInstallConfig()
if err != nil {
return err
}
for i, r := range cfg.Installations {
if r.Type == record.Type && r.Name == record.Name &&
r.Platform == record.Platform && r.Scope == record.Scope {
cfg.Installations[i] = record
return SaveInstallConfig(cfg)
}
}
return nil
}
// CleanOrphanRecords 清理孤立记录(安装路径不存在)
func CleanOrphanRecords() ([]types.InstallRecord, error) {
cfg, err := LoadInstallConfig()
if err != nil {
return nil, err
}
// 预分配切片容量,减少内存分配次数
cleaned := make([]types.InstallRecord, 0, len(cfg.Installations)/2)
valid := make([]types.InstallRecord, 0, len(cfg.Installations))
for _, r := range cfg.Installations {
if _, err := os.Stat(r.InstallPath); os.IsNotExist(err) {
cleaned = append(cleaned, r)
} else {
valid = append(valid, r)
}
}
if len(cleaned) > 0 {
cfg.Installations = valid
if err := SaveInstallConfig(cfg); err != nil {
return nil, err
}
}
return cleaned, nil
}

View File

@@ -0,0 +1,169 @@
package config
import (
"os"
"path/filepath"
"testing"
"time"
"skillmgr/internal/types"
)
func setupInstallTestEnv(t *testing.T) (string, func()) {
t.Helper()
tmpDir, err := os.MkdirTemp("", "skillmgr-install-test-*")
if err != nil {
t.Fatalf("创建临时目录失败: %v", err)
}
os.Setenv("SKILLMGR_TEST_ROOT", tmpDir)
cleanup := func() {
os.Unsetenv("SKILLMGR_TEST_ROOT")
os.RemoveAll(tmpDir)
}
return tmpDir, cleanup
}
func TestLoadInstallConfig_Empty(t *testing.T) {
_, cleanup := setupInstallTestEnv(t)
defer cleanup()
cfg, err := LoadInstallConfig()
if err != nil {
t.Fatalf("LoadInstallConfig 失败: %v", err)
}
if len(cfg.Installations) != 0 {
t.Errorf("期望空安装列表,得到 %d 个", len(cfg.Installations))
}
}
func TestAddInstallRecord_Success(t *testing.T) {
_, cleanup := setupInstallTestEnv(t)
defer cleanup()
record := types.InstallRecord{
Type: types.ItemTypeSkill,
Name: "test-skill",
SourceRepo: "test-repo",
Platform: types.PlatformClaude,
Scope: types.ScopeGlobal,
InstallPath: "/path/to/skill",
InstalledAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := AddInstallRecord(record); err != nil {
t.Fatalf("AddInstallRecord 失败: %v", err)
}
cfg, _ := LoadInstallConfig()
if len(cfg.Installations) != 1 {
t.Errorf("期望 1 条记录,得到 %d 条", len(cfg.Installations))
}
}
func TestFindInstallRecord_Found(t *testing.T) {
_, cleanup := setupInstallTestEnv(t)
defer cleanup()
record := types.InstallRecord{
Type: types.ItemTypeSkill,
Name: "test-skill",
Platform: types.PlatformClaude,
Scope: types.ScopeGlobal,
InstalledAt: time.Now(),
UpdatedAt: time.Now(),
}
AddInstallRecord(record)
found, err := FindInstallRecord(types.ItemTypeSkill, "test-skill", types.PlatformClaude, types.ScopeGlobal)
if err != nil {
t.Fatalf("FindInstallRecord 失败: %v", err)
}
if found.Name != "test-skill" {
t.Errorf("期望名称 'test-skill',得到 '%s'", found.Name)
}
}
func TestRemoveInstallRecord_Success(t *testing.T) {
_, cleanup := setupInstallTestEnv(t)
defer cleanup()
record := types.InstallRecord{
Type: types.ItemTypeSkill,
Name: "test-skill",
Platform: types.PlatformClaude,
Scope: types.ScopeGlobal,
InstalledAt: time.Now(),
UpdatedAt: time.Now(),
}
AddInstallRecord(record)
if err := RemoveInstallRecord(types.ItemTypeSkill, "test-skill", types.PlatformClaude, types.ScopeGlobal); err != nil {
t.Fatalf("RemoveInstallRecord 失败: %v", err)
}
cfg, _ := LoadInstallConfig()
if len(cfg.Installations) != 0 {
t.Errorf("期望 0 条记录,得到 %d 条", len(cfg.Installations))
}
}
func TestCleanOrphanRecords(t *testing.T) {
tmpDir, cleanup := setupInstallTestEnv(t)
defer cleanup()
// 创建一个存在的路径
existingPath := filepath.Join(tmpDir, "existing-skill")
os.MkdirAll(existingPath, 0755)
// 添加两条记录:一条存在,一条不存在
record1 := types.InstallRecord{
Type: types.ItemTypeSkill,
Name: "existing-skill",
Platform: types.PlatformClaude,
Scope: types.ScopeGlobal,
InstallPath: existingPath,
InstalledAt: time.Now(),
UpdatedAt: time.Now(),
}
record2 := types.InstallRecord{
Type: types.ItemTypeSkill,
Name: "orphan-skill",
Platform: types.PlatformClaude,
Scope: types.ScopeGlobal,
InstallPath: "/nonexistent/path",
InstalledAt: time.Now(),
UpdatedAt: time.Now(),
}
AddInstallRecord(record1)
AddInstallRecord(record2)
cleaned, err := CleanOrphanRecords()
if err != nil {
t.Fatalf("CleanOrphanRecords 失败: %v", err)
}
if len(cleaned) != 1 {
t.Errorf("期望清理 1 条记录,清理了 %d 条", len(cleaned))
}
if len(cleaned) > 0 && cleaned[0].Name != "orphan-skill" {
t.Errorf("期望清理 'orphan-skill',清理了 '%s'", cleaned[0].Name)
}
// 验证只剩下存在的记录
cfg, _ := LoadInstallConfig()
if len(cfg.Installations) != 1 {
t.Errorf("期望剩余 1 条记录,剩余 %d 条", len(cfg.Installations))
}
}

View File

@@ -0,0 +1,77 @@
package config
import (
"os"
"path/filepath"
)
const (
ConfigDir = ".skillmgr"
RepositoryFile = "repository.json"
InstallFile = "install.json"
CacheDir = "cache"
)
// GetConfigRoot 获取配置根目录
// 支持通过环境变量 SKILLMGR_TEST_ROOT 覆盖(用于测试隔离)
func GetConfigRoot() (string, error) {
// 测试模式:使用环境变量指定的临时目录
if testRoot := os.Getenv("SKILLMGR_TEST_ROOT"); testRoot != "" {
return testRoot, nil
}
// 生产模式:使用用户主目录
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ConfigDir), nil
}
// GetRepositoryConfigPath 获取 repository.json 路径
func GetRepositoryConfigPath() (string, error) {
root, err := GetConfigRoot()
if err != nil {
return "", err
}
return filepath.Join(root, RepositoryFile), nil
}
// GetInstallConfigPath 获取 install.json 路径
func GetInstallConfigPath() (string, error) {
root, err := GetConfigRoot()
if err != nil {
return "", err
}
return filepath.Join(root, InstallFile), nil
}
// GetCachePath 获取缓存目录路径
func GetCachePath() (string, error) {
root, err := GetConfigRoot()
if err != nil {
return "", err
}
return filepath.Join(root, CacheDir), nil
}
// EnsureConfigDirs 确保配置目录存在
func EnsureConfigDirs() error {
root, err := GetConfigRoot()
if err != nil {
return err
}
dirs := []string{
root,
filepath.Join(root, CacheDir),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,60 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestGetConfigRoot_Default(t *testing.T) {
// 清除环境变量
os.Unsetenv("SKILLMGR_TEST_ROOT")
root, err := GetConfigRoot()
if err != nil {
t.Fatalf("GetConfigRoot 失败: %v", err)
}
home, _ := os.UserHomeDir()
expected := filepath.Join(home, ConfigDir)
if root != expected {
t.Errorf("期望 %s得到 %s", expected, root)
}
}
func TestGetConfigRoot_WithEnvOverride(t *testing.T) {
testRoot := "/tmp/skillmgr-test"
os.Setenv("SKILLMGR_TEST_ROOT", testRoot)
defer os.Unsetenv("SKILLMGR_TEST_ROOT")
root, err := GetConfigRoot()
if err != nil {
t.Fatalf("GetConfigRoot 失败: %v", err)
}
if root != testRoot {
t.Errorf("期望 %s得到 %s", testRoot, root)
}
}
func TestEnsureConfigDirs(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "skillmgr-test-*")
if err != nil {
t.Fatalf("创建临时目录失败: %v", err)
}
defer os.RemoveAll(tmpDir)
os.Setenv("SKILLMGR_TEST_ROOT", tmpDir)
defer os.Unsetenv("SKILLMGR_TEST_ROOT")
if err := EnsureConfigDirs(); err != nil {
t.Fatalf("EnsureConfigDirs 失败: %v", err)
}
// 检查目录是否存在
cacheDir := filepath.Join(tmpDir, CacheDir)
if _, err := os.Stat(cacheDir); os.IsNotExist(err) {
t.Errorf("缓存目录未创建: %s", cacheDir)
}
}

View File

@@ -0,0 +1,105 @@
package config
import (
"encoding/json"
"fmt"
"os"
"skillmgr/internal/types"
)
// LoadRepositoryConfig 加载仓库配置
func LoadRepositoryConfig() (*types.RepositoryConfig, error) {
path, err := GetRepositoryConfigPath()
if err != nil {
return nil, err
}
// 如果文件不存在,返回空配置
if _, err := os.Stat(path); os.IsNotExist(err) {
return &types.RepositoryConfig{
Repositories: []types.Repository{},
}, nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg types.RepositoryConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("解析 repository.json 失败: %w请检查 JSON 格式)", err)
}
return &cfg, nil
}
// SaveRepositoryConfig 保存仓库配置
func SaveRepositoryConfig(cfg *types.RepositoryConfig) error {
path, err := GetRepositoryConfigPath()
if err != nil {
return err
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
// AddRepository 添加仓库
// 如果仓库名已存在,返回错误提示先移除
func AddRepository(repo types.Repository) error {
cfg, err := LoadRepositoryConfig()
if err != nil {
return err
}
// 检查是否已存在同名仓库
for _, r := range cfg.Repositories {
if r.Name == repo.Name {
return fmt.Errorf("仓库名称 '%s' 已存在,请先使用 `skillmgr remove %s` 移除", repo.Name, repo.Name)
}
}
// 新增
cfg.Repositories = append(cfg.Repositories, repo)
return SaveRepositoryConfig(cfg)
}
// RemoveRepository 移除仓库
func RemoveRepository(name string) error {
cfg, err := LoadRepositoryConfig()
if err != nil {
return err
}
for i, r := range cfg.Repositories {
if r.Name == name {
cfg.Repositories = append(cfg.Repositories[:i], cfg.Repositories[i+1:]...)
return SaveRepositoryConfig(cfg)
}
}
// 仓库不存在,不报错
return nil
}
// FindRepository 查找仓库
func FindRepository(name string) (*types.Repository, error) {
cfg, err := LoadRepositoryConfig()
if err != nil {
return nil, err
}
for _, r := range cfg.Repositories {
if r.Name == name {
return &r, nil
}
}
return nil, nil
}

View File

@@ -0,0 +1,159 @@
package config
import (
"os"
"testing"
"time"
"skillmgr/internal/types"
)
func setupRepoTestEnv(t *testing.T) (string, func()) {
t.Helper()
tmpDir, err := os.MkdirTemp("", "skillmgr-repo-test-*")
if err != nil {
t.Fatalf("创建临时目录失败: %v", err)
}
os.Setenv("SKILLMGR_TEST_ROOT", tmpDir)
cleanup := func() {
os.Unsetenv("SKILLMGR_TEST_ROOT")
os.RemoveAll(tmpDir)
}
return tmpDir, cleanup
}
func TestLoadRepositoryConfig_Empty(t *testing.T) {
_, cleanup := setupRepoTestEnv(t)
defer cleanup()
cfg, err := LoadRepositoryConfig()
if err != nil {
t.Fatalf("LoadRepositoryConfig 失败: %v", err)
}
if len(cfg.Repositories) != 0 {
t.Errorf("期望空仓库列表,得到 %d 个", len(cfg.Repositories))
}
}
func TestAddRepository_Success(t *testing.T) {
_, cleanup := setupRepoTestEnv(t)
defer cleanup()
repo := types.Repository{
Name: "test-repo",
URL: "https://github.com/test/repo.git",
Branch: "main",
AddedAt: time.Now(),
}
if err := AddRepository(repo); err != nil {
t.Fatalf("AddRepository 失败: %v", err)
}
// 验证已添加
cfg, _ := LoadRepositoryConfig()
if len(cfg.Repositories) != 1 {
t.Errorf("期望 1 个仓库,得到 %d 个", len(cfg.Repositories))
}
if cfg.Repositories[0].Name != "test-repo" {
t.Errorf("期望名称 'test-repo',得到 '%s'", cfg.Repositories[0].Name)
}
}
func TestAddRepository_RejectDuplicate(t *testing.T) {
_, cleanup := setupRepoTestEnv(t)
defer cleanup()
repo := types.Repository{
Name: "test-repo",
URL: "https://github.com/test/repo.git",
Branch: "main",
AddedAt: time.Now(),
}
// 第一次添加
if err := AddRepository(repo); err != nil {
t.Fatalf("第一次 AddRepository 失败: %v", err)
}
// 第二次添加应该失败
err := AddRepository(repo)
if err == nil {
t.Error("期望添加重复仓库时返回错误")
}
}
func TestRemoveRepository_Success(t *testing.T) {
_, cleanup := setupRepoTestEnv(t)
defer cleanup()
repo := types.Repository{
Name: "test-repo",
URL: "https://github.com/test/repo.git",
AddedAt: time.Now(),
}
AddRepository(repo)
if err := RemoveRepository("test-repo"); err != nil {
t.Fatalf("RemoveRepository 失败: %v", err)
}
cfg, _ := LoadRepositoryConfig()
if len(cfg.Repositories) != 0 {
t.Errorf("期望 0 个仓库,得到 %d 个", len(cfg.Repositories))
}
}
func TestRemoveRepository_NotFound(t *testing.T) {
_, cleanup := setupRepoTestEnv(t)
defer cleanup()
// RemoveRepository 实现中,不存在的仓库不报错
err := RemoveRepository("nonexistent")
if err != nil {
t.Errorf("RemoveRepository 不应该报错: %v", err)
}
}
func TestFindRepository_Found(t *testing.T) {
_, cleanup := setupRepoTestEnv(t)
defer cleanup()
repo := types.Repository{
Name: "test-repo",
URL: "https://github.com/test/repo.git",
AddedAt: time.Now(),
}
AddRepository(repo)
found, err := FindRepository("test-repo")
if err != nil {
t.Fatalf("FindRepository 失败: %v", err)
}
if found == nil || found.Name != "test-repo" {
t.Errorf("期望找到 'test-repo'")
}
}
func TestFindRepository_NotFound(t *testing.T) {
_, cleanup := setupRepoTestEnv(t)
defer cleanup()
// FindRepository 实现中,找不到时返回 nil, nil
found, err := FindRepository("nonexistent")
if err != nil {
t.Errorf("FindRepository 不应该报错: %v", err)
}
if found != nil {
t.Errorf("期望返回 nil")
}
}