1
0

移除技能

This commit is contained in:
2026-03-18 22:28:57 +08:00
parent 3b27d2cfd4
commit 2d2404e3cf
53 changed files with 134 additions and 4966 deletions

View File

@@ -1,55 +0,0 @@
基于知识库项目进行问答。
**输入**: `/lyxy-kb-ask` 后的参数为项目名称,可选附带问题。例如:
- `/lyxy-kb-ask my-project` — 进入问答模式
- `/lyxy-kb-ask my-project 这个系统用了什么技术栈?` — 直接提问
**前置条件**: 查找并阅读名为 **lyxy-kb** 的 skill了解渐进式查询策略和来源引用格式。
**步骤**
1. **获取项目名称并验证结构**
从参数中获取项目名称。如果未提供参数,提示用户输入。
按照 lyxy-kb skill「结构完整性验证」规则检查项目目录不完整则提示用户先 init。
2. **加载项目摘要**
读取 `<project-name>/project.md`,获取项目概述和文件索引。
按照 lyxy-kb skill「空知识库」规则如果文件索引为空尚无已入库文件告知用户知识库为空建议先使用 `/lyxy-kb-ingest <project-name>` 入库文档,终止操作。
3. **进入问答模式**
如果用户在参数中附带了问题,直接回答该问题。否则提示用户可以开始提问。
**对每个问题,按照 lyxy-kb skill「渐进式查询策略」执行**
**a) 分析问题与文件索引的关联**
根据用户的问题内容,对照 project.md 文件索引表中各文件的摘要,判断需要查阅哪些 parsed 文件。
**b) 按需加载 parsed 文件**
读取相关的 `<project-name>/parsed/<文件名>.md` 文件。如果文件较大,可以先提取标题结构,再读取相关章节。
**c) 回答并标注来源**
基于获取的信息回答问题。按照 lyxy-kb skill「来源引用格式」标注来源
```
根据《文件名》(parsed/文件名.md)...
```
如果回答综合了多个文件的信息,分别标注各信息点的来源。
**d) 无相关信息处理**
按照 lyxy-kb skill「无相关信息」规则明确告知用户当前知识库中未找到相关信息不编造答案。
4. **保持会话上下文**
回答完成后,保持当前的知识库上下文。用户可以继续提问,无需每次重新加载 project.md。已加载的 parsed 文件内容可在后续问答中复用。
会话的退出由用户自然决定(开启新话题或新会话),不主动终止问答模式。

View File

@@ -1,65 +0,0 @@
解析 sources/ 中的新文件并增量更新知识库。
**输入**: `/lyxy-kb-ingest` 后的参数为项目名称。
**前置条件**: 查找并阅读名为 **lyxy-kb** 的 skill了解知识库的完整规范。
**步骤**
1. **获取项目名称并验证结构**
从参数中获取项目名称。如果未提供参数,提示用户输入。
按照 lyxy-kb skill「结构完整性验证」规则检查项目目录不完整则提示用户先 init。
2. **检查 office 文档解析能力**
按照 lyxy-kb skill「Office 文档解析」规则,查找当前环境中名为 **lyxy-reader-office** 的 skill。如果不存在且无其他可替代的文档解析 skill则提示用户无法处理 office 文档并中止流程。
3. **读取 manifest.json**
读取 `<project-name>/manifest.json`,获取已入库文件的信息。
4. **递归扫描 sources/ 目录**
按照 lyxy-kb skill「sources/ 扫描规则」,递归检查 sources/ 及其所有子目录中的文件。如果无任何文件,提示用户无待处理文件并终止。
5. **预检查**
**空文件检测**:按照 lyxy-kb skill「空文件处理」规则识别 0 字节文件,标记为跳过。
**同名不同扩展名冲突检测**:按照 lyxy-kb skill「同名不同扩展名冲突检测」中的两条检测规则执行。冲突文件标记为跳过。
如果有跳过的文件,列出详情(空文件 / 冲突文件分别列出)。如果所有文件都被跳过,终止流程。
6. **逐个处理文件**
对每个通过预检查的文件:
**a) 解析**:按照 lyxy-kb skill「文件类型解析策略」判断解析方式。office 文档使用 lyxy-reader-office skill查找并阅读该 skill 获取具体命令),其他文件直接读取。
**b) 写入 parsed**:按照 lyxy-kb skill「parsed 文件元信息标记」格式,在内容头部添加元信息注释,写入 `<project-name>/parsed/<文件名>.md`(同名覆盖)。
**c) 归档**:按照 lyxy-kb skill「归档命名规则」移动原始文件到 archive/(带时间戳后缀 `YYYYMMDDHHmm`)。
**d) 更新 manifest.json**:新文件追加条目,已有文件在 versions 数组追加新版本。使用 `sha256sum` 计算文件哈希。更新 `last_ingest`
**e) 解析失败处理**:按照 lyxy-kb skill「解析失败处理」规则失败文件保留在 sources/ 中不移动,报告错误,继续处理下一个文件。
7. **增量更新 project.md**
按照 lyxy-kb skill「增量追加」策略
- 对每个新处理的文件,读取其 parsed 内容生成简要摘要1-2 句话)
- 新文件:在文件索引表追加新行
- 已有文件更新:更新文件索引表中对应行
- 在更新记录追加本次 ingest 条目
- 不修改概述和关键信息部分
8. **输出结果**
汇总显示:
- 成功处理的文件列表
- 跳过的文件(空文件 / 冲突文件 / 解析失败文件,分别列出)
- 当前项目已入库文件总数
- 提示可使用 `/lyxy-kb-rebuild <project-name>` 更新概述和关键信息
- 提示可使用 `/lyxy-kb-ask <project-name>` 进行知识问答

View File

@@ -1,67 +0,0 @@
初始化一个知识库项目。
**输入**: `/lyxy-kb-init` 后的参数为项目名称。
**前置条件**: 查找并阅读名为 **lyxy-kb** 的 skill了解知识库的目录结构规范、项目名称规则和 project.md 格式规范。
**步骤**
1. **获取项目名称**
从参数中获取项目名称。如果未提供参数,提示用户输入项目名称。
2. **验证项目名称**
按照 lyxy-kb skill 中的「项目名称规则」验证名称是否合法(只允许中文、英文、数字、短横线、下划线,不允许空格和其他特殊字符)。不合法时提示用户修改。
3. **检查目标目录是否已存在**
检查 CWD 下是否已存在同名目录。如果目录已存在,提示用户该目录已存在,不覆盖任何现有内容,终止操作。
4. **创建目录结构**
```bash
mkdir -p <project-name>/parsed <project-name>/sources <project-name>/archive
```
5. **创建 project.md**
按照 lyxy-kb skill 中定义的「project.md 格式规范」,生成初始内容:
```markdown
# <项目名称>
## 概述
(待补充)
## 关键信息
(待补充)
## 文件索引
| 文件名 | 解析文件 | 最新归档 | 摘要 |
|--------|----------|----------|------|
## 更新记录
- <YYYY-MM-DD HH:mm>: 初始化项目
```
6. **创建 manifest.json**
```json
{
"project": "<项目名称>",
"created_at": "<当前时间 ISO 格式>",
"last_ingest": null,
"files": []
}
```
7. **输出结果**
提示用户:
- 项目已创建,显示完整的目录结构
- 引导用户将文档放入 `<project-name>/sources/` 目录
- 提示使用 `/lyxy-kb-ingest <project-name>` 解析入库

View File

@@ -1,55 +0,0 @@
全量重新生成 project.md。
**输入**: `/lyxy-kb-rebuild` 后的参数为项目名称。
**前置条件**: 查找并阅读名为 **lyxy-kb** 的 skill了解 project.md 格式规范和全量重写策略。
**步骤**
1. **获取项目名称并验证结构**
从参数中获取项目名称。如果未提供参数,提示用户输入。
按照 lyxy-kb skill「结构完整性验证」规则检查项目目录不完整则提示用户先 init。
2. **检查 parsed 目录**
列出 `<project-name>/parsed/` 下的所有 `.md` 文件。如果为空,提示用户尚无已解析文件,建议先执行 `/lyxy-kb-ingest <project-name>`
3. **检查 sources/ 待处理文件**
检查 `<project-name>/sources/` 中是否还有未 ingest 的文件。如果有,提醒用户 sources/ 中存在未入库文件rebuild 将仅基于已有的 parsed 文件生成,建议先执行 ingest。
4. **确认操作**
向用户说明 rebuild 将覆盖当前 project.md 的概述、关键信息和文件索引(更新记录会保留),请求用户确认是否继续。用户确认后再执行。
5. **读取所有 parsed 文件**
逐个读取 `<project-name>/parsed/` 下的所有 `.md` 文件内容。
6. **读取 manifest.json**
读取 `<project-name>/manifest.json`,获取文件元信息(用于生成文件索引表中的归档路径等信息)。
7. **读取现有更新记录**
读取当前 `<project-name>/project.md`,提取 `## 更新记录` 部分的内容以保留历史记录。
8. **全量重新生成 project.md**
按照 lyxy-kb skill「全量重写」策略和 project.md 格式规范,基于所有 parsed 文件内容重新生成:
- **概述**:基于所有文件内容,生成高度总结的项目信息(几百字以内)
- **关键信息**:从所有文档中提炼核心要点
- **文件索引**:基于 manifest.json 和 parsed 文件,重新生成完整索引表(文件名、解析文件路径、最新归档路径、简要摘要)
- **更新记录**:保留历史记录,追加本次 rebuild 条目,格式:`- <YYYY-MM-DD HH:mm>: 全量重建 project.md`
将生成的内容写入 `<project-name>/project.md`,覆盖原有内容。
9. **输出结果**
提示用户:
- project.md 已全量重建
- 显示处理的文件数量
- 提示可使用 `/lyxy-kb-ask <project-name>` 进行知识问答

29
manager/.gitignore vendored
View File

@@ -1,29 +0,0 @@
# Binaries
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary
*.test
# Output of go coverage
*.out
# Go workspace file
go.work
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Build output
/bin/
/dist/

View File

@@ -1,92 +0,0 @@
.PHONY: all build build-all build-macos build-windows test clean install lint
# 变量
BINARY_NAME := skillmgr
BUILD_DIR := bin
MAIN_PACKAGE := ./cmd/skillmgr
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
BUILD_TIME := $(shell date -u '+%Y-%m-%d_%H:%M:%S')
LDFLAGS := -ldflags "-s -w -X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME)"
# 默认目标
all: build
# 构建当前平台
build:
@echo "=== 构建 $(BINARY_NAME) ==="
@mkdir -p $(BUILD_DIR)
go build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) $(MAIN_PACKAGE)
@echo "构建完成: $(BUILD_DIR)/$(BINARY_NAME)"
# 构建所有平台
build-all: build-macos build-windows
@echo ""
@echo "=== 所有平台构建完成 ==="
@find $(BUILD_DIR) -type f -name "$(BINARY_NAME)*" | sort
# 构建 macOS (Intel + Apple Silicon)
build-macos:
@echo "=== 构建 macOS (amd64) ==="
@mkdir -p $(BUILD_DIR)/darwin-amd64
GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/darwin-amd64/$(BINARY_NAME) $(MAIN_PACKAGE)
@echo "构建完成: $(BUILD_DIR)/darwin-amd64/$(BINARY_NAME)"
@echo ""
@echo "=== 构建 macOS (arm64) ==="
@mkdir -p $(BUILD_DIR)/darwin-arm64
GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o $(BUILD_DIR)/darwin-arm64/$(BINARY_NAME) $(MAIN_PACKAGE)
@echo "构建完成: $(BUILD_DIR)/darwin-arm64/$(BINARY_NAME)"
# 构建 Windows
build-windows:
@echo "=== 构建 Windows (amd64) ==="
@mkdir -p $(BUILD_DIR)/windows-amd64
GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/windows-amd64/$(BINARY_NAME).exe $(MAIN_PACKAGE)
@echo "构建完成: $(BUILD_DIR)/windows-amd64/$(BINARY_NAME).exe"
# 构建 Linux (可选)
build-linux:
@echo "=== 构建 Linux (amd64) ==="
@mkdir -p $(BUILD_DIR)/linux-amd64
GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/linux-amd64/$(BINARY_NAME) $(MAIN_PACKAGE)
@echo "构建完成: $(BUILD_DIR)/linux-amd64/$(BINARY_NAME)"
# 测试
test:
@echo "=== 运行测试 ==="
./scripts/test.sh
# 单元测试(不使用脚本)
test-unit:
go test -v ./...
# 覆盖率测试
test-coverage:
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
@echo "覆盖率报告: coverage.html"
# 清理
clean:
rm -rf $(BUILD_DIR)
rm -f coverage.out coverage.html
# 安装到 $GOPATH/bin
install: build
cp $(BUILD_DIR)/$(BINARY_NAME) $(GOPATH)/bin/
# 代码检查
lint:
golangci-lint run ./...
# 格式化
fmt:
go fmt ./...
# 依赖
deps:
go mod download
go mod tidy
# 沙盒环境
sandbox:
./scripts/sandbox.sh

View File

@@ -1,219 +0,0 @@
# skillmgr
一个用于管理和分发 LLM 编程助手命令和技能的 CLI 工具。
## 功能特性
- 从 git 仓库拉取 skills 和 commands
- 支持多平台部署Claude Code、OpenCode
- 支持全局安装和项目级安装
- 事务性安装,避免安装失败导致的文件污染
- 完整的安装追踪和管理
## 安装
```bash
# 从源码构建
git clone https://github.com/your/skills.git
cd skills/manager
make build
# 将可执行文件添加到 PATH
cp bin/skillmgr /usr/local/bin/
```
## 快速开始
```bash
# 添加仓库
skillmgr add https://github.com/your/skills-repo.git --name my-skills
# 同步仓库内容
skillmgr sync
# 搜索可用的 skills 和 commands
skillmgr search
# 安装 skill 到 Claude Code全局
skillmgr install skill my-skill --platform claude --global
# 安装 command 到 OpenCode项目级
skillmgr install command my-cmd --platform opencode
```
## 命令参考
### 仓库管理
```bash
# 添加仓库
skillmgr add <url> --name <name> [--branch <branch>]
# 移除仓库
skillmgr remove <name>
# 列出仓库
skillmgr repos
# 同步仓库(拉取最新)
skillmgr sync [name]
```
### 安装管理
```bash
# 安装
skillmgr install <skill|command> <name> --platform <claude|opencode> [--global]
# 卸载
skillmgr uninstall <skill|command> <name> --platform <claude|opencode> [--global]
# 更新
skillmgr update <skill|command> <name> --platform <claude|opencode> [--global]
skillmgr update --all
# 列出已安装
skillmgr list [--type <skill|command>] [--platform <claude|opencode>] [--global]
# 搜索可用项
skillmgr search [keyword] [--type <skill|command>] [--repo <name>]
# 清理孤立记录
skillmgr clean [--dry-run]
```
## 平台适配
### Claude Code
- Skills 安装到 `~/.claude/skills/<skill-name>/` (全局) 或 `./.claude/skills/<skill-name>/` (项目)
- Commands 安装到 `~/.claude/commands/<cmd-name>/` (全局) 或 `./.claude/commands/<cmd-name>/` (项目)
- 保持原始目录结构
### OpenCode
- Skills 全局安装到 `~/.config/opencode/skills/<skill-name>/`,项目级安装到 `./.opencode/skills/<skill-name>/`
- Commands 全局安装到 `~/.config/opencode/commands/`,项目级安装到 `./.opencode/commands/`
- Command 文件名扁平化:`<group>-<action>.md`
- 例如:`commands/lyxy-kb/init.md``~/.config/opencode/commands/lyxy-kb-init.md`
## 配置文件
### 仓库配置
位置:`~/.skillmgr/repository.json`
```json
{
"repositories": [
{
"name": "my-skills",
"url": "https://github.com/user/skills.git",
"branch": "main",
"added_at": "2024-01-01T00:00:00Z"
}
]
}
```
### 安装记录
位置:`~/.skillmgr/install.json`
```json
{
"installations": [
{
"type": "skill",
"name": "my-skill",
"source_repo": "my-skills",
"platform": "claude",
"scope": "global",
"install_path": "/Users/xxx/.claude/skills/my-skill",
"installed_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
]
}
```
## 仓库结构
skillmgr 期望源仓库具有以下结构:
```
your-skills-repo/
├── skills/
│ ├── skill-a/
│ │ ├── SKILL.md # 必需skill 定义文件
│ │ └── ... # 其他支持文件
│ └── skill-b/
│ └── SKILL.md
└── commands/
├── cmd-group-a/
│ ├── init.md
│ └── run.md
└── cmd-group-b/
└── action.md
```
## 测试
```bash
# 运行所有测试
make test
# 运行单元测试
make test-unit
# 生成覆盖率报告
make test-coverage
# 使用沙盒环境手动测试
make sandbox
```
### 测试环境变量
- `SKILLMGR_TEST_ROOT`: 覆盖配置目录(`~/.skillmgr`
- `SKILLMGR_TEST_BASE`: 覆盖安装基础目录(用户主目录或当前目录)
## 故障排除
### 常见问题
1. **Git clone 失败**
- 检查网络连接
- 确认仓库 URL 正确
- 对于私有仓库,确保已配置 SSH 密钥或 token
2. **找不到 skill/command**
- 运行 `skillmgr sync` 更新本地缓存
- 使用 `skillmgr search` 查看可用项
3. **安装冲突**
- 已安装的项会提示覆盖确认
- 使用 `skillmgr uninstall` 先卸载
4. **孤立记录**
- 当文件被手动删除时,使用 `skillmgr clean` 清理记录
## 开发
```bash
# 依赖
make deps
# 构建
make build
# 代码格式化
make fmt
# 代码检查
make lint
```
## License
MIT

View File

@@ -1,90 +0,0 @@
package main
import (
"fmt"
"strings"
"time"
"github.com/spf13/cobra"
"skillmgr/internal/config"
"skillmgr/internal/repo"
"skillmgr/internal/types"
)
// validateGitURL 验证 Git URL 格式
func validateGitURL(url string) error {
if url == "" {
return fmt.Errorf("URL 不能为空")
}
// 支持 https://, http://, git@, git:// 协议
validPrefixes := []string{"https://", "http://", "git@", "git://"}
for _, prefix := range validPrefixes {
if strings.HasPrefix(url, prefix) {
return nil
}
}
return fmt.Errorf("无效的 Git URL 格式,必须以 https://, http://, git@ 或 git:// 开头")
}
var addCmd = &cobra.Command{
Use: "add <url>",
Short: "添加源仓库",
Long: `添加一个 git 仓库作为 skills/commands 的源。
示例:
skillmgr add https://github.com/user/skills
skillmgr add https://github.com/user/skills --name my-skills
skillmgr add https://github.com/user/skills --branch main`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
url := args[0]
// 验证 URL 格式
if err := validateGitURL(url); err != nil {
return err
}
name, _ := cmd.Flags().GetString("name")
branch, _ := cmd.Flags().GetString("branch")
// 如果没有指定名称,从 URL 生成
if name == "" {
name = repo.URLToPathName(url)
}
if branch == "" {
branch = "main" // 默认分支
}
// Clone 仓库
fmt.Printf("正在克隆仓库 %s...\n", url)
repoPath, err := repo.CloneOrPull(url, branch)
if err != nil {
return fmt.Errorf("克隆仓库失败: %w", err)
}
// 保存到配置
repository := types.Repository{
Name: name,
URL: url,
Branch: branch,
AddedAt: time.Now(),
}
if err := config.AddRepository(repository); err != nil {
return err
}
fmt.Printf("✓ 仓库 '%s' 添加成功\n", name)
fmt.Printf(" 缓存路径: %s\n", repoPath)
return nil
},
}
func init() {
addCmd.Flags().String("name", "", "仓库别名")
addCmd.Flags().String("branch", "main", "克隆的分支")
rootCmd.AddCommand(addCmd)
}

View File

@@ -1,80 +0,0 @@
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
"skillmgr/internal/config"
"skillmgr/internal/types"
)
var cleanCmd = &cobra.Command{
Use: "clean",
Short: "清理孤立的安装记录",
Long: `检查并清理不存在的安装记录。
当文件被手动删除但安装记录仍存在时,使用此命令清理。
示例:
# 检查孤立记录(不删除)
skillmgr clean --dry-run
# 清理孤立记录
skillmgr clean`,
RunE: func(cmd *cobra.Command, args []string) error {
dryRun, _ := cmd.Flags().GetBool("dry-run")
cfg, err := config.LoadInstallConfig()
if err != nil {
return err
}
if len(cfg.Installations) == 0 {
fmt.Println("无安装记录")
return nil
}
var orphans []types.InstallRecord
var valid []types.InstallRecord
for _, r := range cfg.Installations {
if _, err := os.Stat(r.InstallPath); os.IsNotExist(err) {
orphans = append(orphans, r)
} else {
valid = append(valid, r)
}
}
if len(orphans) == 0 {
fmt.Println("无孤立记录")
return nil
}
fmt.Printf("发现 %d 个孤立记录:\n", len(orphans))
for _, r := range orphans {
fmt.Printf(" [%s] %s (%s, %s)\n", r.Type, r.Name, r.Platform, r.Scope)
fmt.Printf(" 路径: %s\n", r.InstallPath)
}
if dryRun {
fmt.Println("\n使用 --dry-run未执行清理")
return nil
}
cfg.Installations = valid
if err := config.SaveInstallConfig(cfg); err != nil {
return fmt.Errorf("保存配置失败: %w", err)
}
fmt.Printf("\n已清理 %d 个孤立记录\n", len(orphans))
return nil
},
}
func init() {
cleanCmd.Flags().Bool("dry-run", false, "仅检查,不执行清理")
rootCmd.AddCommand(cleanCmd)
}

View File

@@ -1,64 +0,0 @@
package main
import (
"fmt"
"github.com/spf13/cobra"
"skillmgr/internal/installer"
"skillmgr/internal/types"
)
var installCmd = &cobra.Command{
Use: "install <type> <name>",
Short: "安装 skill 或 command",
Long: `将 skill 或 command 安装到目标平台。
类型: skill, command
示例:
# 全局安装到 Claude Code
skillmgr install skill lyxy-kb --platform claude --global
# 项目级安装到 OpenCode
skillmgr install command lyxy-kb --platform opencode`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
itemType := args[0]
name := args[1]
platformStr, _ := cmd.Flags().GetString("platform")
global, _ := cmd.Flags().GetBool("global")
from, _ := cmd.Flags().GetString("from")
platform := types.Platform(platformStr)
scope := types.ScopeProject
if global {
scope = types.ScopeGlobal
}
switch itemType {
case "skill":
if from != "" {
return installer.InstallSkillFrom(name, platform, scope, from)
}
return installer.InstallSkill(name, platform, scope)
case "command":
if from != "" {
return installer.InstallCommandFrom(name, platform, scope, from)
}
return installer.InstallCommand(name, platform, scope)
default:
return fmt.Errorf("无效的类型: %s必须是 'skill' 或 'command'", itemType)
}
},
}
func init() {
installCmd.Flags().StringP("platform", "p", "", "目标平台 (claude|opencode)")
installCmd.Flags().BoolP("global", "g", false, "全局安装")
installCmd.Flags().String("from", "", "临时仓库 URL不保存到配置")
installCmd.MarkFlagRequired("platform")
rootCmd.AddCommand(installCmd)
}

View File

@@ -1,86 +0,0 @@
package main
import (
"fmt"
"github.com/spf13/cobra"
"skillmgr/internal/config"
"skillmgr/internal/types"
)
var listCmd = &cobra.Command{
Use: "list",
Short: "列出已安装的 skills 和 commands",
Long: `显示所有已安装的 skills 和 commands。
示例:
skillmgr list
skillmgr list --type skill
skillmgr list --platform claude
skillmgr list --global`,
RunE: func(cmd *cobra.Command, args []string) error {
itemTypeStr, _ := cmd.Flags().GetString("type")
platformStr, _ := cmd.Flags().GetString("platform")
global, _ := cmd.Flags().GetBool("global")
cfg, err := config.LoadInstallConfig()
if err != nil {
return err
}
if len(cfg.Installations) == 0 {
fmt.Println("无已安装的 skills/commands")
return nil
}
// 过滤
var filtered []types.InstallRecord
for _, r := range cfg.Installations {
// 按类型过滤
if itemTypeStr != "" && string(r.Type) != itemTypeStr {
continue
}
// 按平台过滤
if platformStr != "" && string(r.Platform) != platformStr {
continue
}
// 按作用域过滤
if global && r.Scope != types.ScopeGlobal {
continue
}
if !global && cmd.Flags().Changed("global") && r.Scope != types.ScopeProject {
continue
}
filtered = append(filtered, r)
}
if len(filtered) == 0 {
fmt.Println("无匹配的安装记录")
return nil
}
fmt.Println("已安装:")
for _, r := range filtered {
fmt.Printf("\n [%s] %s\n", r.Type, r.Name)
fmt.Printf(" 平台: %s\n", r.Platform)
fmt.Printf(" 作用域: %s\n", r.Scope)
fmt.Printf(" 来源: %s\n", r.SourceRepo)
fmt.Printf(" 路径: %s\n", r.InstallPath)
fmt.Printf(" 安装于: %s\n", r.InstalledAt.Format("2006-01-02 15:04:05"))
if !r.UpdatedAt.Equal(r.InstalledAt) {
fmt.Printf(" 更新于: %s\n", r.UpdatedAt.Format("2006-01-02 15:04:05"))
}
}
return nil
},
}
func init() {
listCmd.Flags().String("type", "", "过滤类型 (skill|command)")
listCmd.Flags().String("platform", "", "过滤平台 (claude|opencode)")
listCmd.Flags().BoolP("global", "g", false, "仅显示全局安装")
rootCmd.AddCommand(listCmd)
}

View File

@@ -1,44 +0,0 @@
package main
import (
"fmt"
"github.com/spf13/cobra"
"skillmgr/internal/config"
)
var listReposCmd = &cobra.Command{
Use: "list-repos",
Short: "列出已配置的源仓库",
Long: `显示所有已添加的源仓库及其信息。
示例:
skillmgr list-repos`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.LoadRepositoryConfig()
if err != nil {
return err
}
if len(cfg.Repositories) == 0 {
fmt.Println("无已配置的源仓库")
fmt.Println("\n使用 'skillmgr add <url>' 添加仓库")
return nil
}
fmt.Println("已配置的源仓库:")
for _, repo := range cfg.Repositories {
fmt.Printf("\n %s\n", repo.Name)
fmt.Printf(" URL: %s\n", repo.URL)
fmt.Printf(" 分支: %s\n", repo.Branch)
fmt.Printf(" 添加于: %s\n", repo.AddedAt.Format("2006-01-02 15:04:05"))
}
return nil
},
}
func init() {
rootCmd.AddCommand(listReposCmd)
}

View File

@@ -1,5 +0,0 @@
package main
func main() {
Execute()
}

View File

@@ -1,43 +0,0 @@
package main
import (
"fmt"
"github.com/spf13/cobra"
"skillmgr/internal/config"
)
var removeCmd = &cobra.Command{
Use: "remove <name>",
Short: "移除源仓库",
Long: `从配置中移除已添加的源仓库。
示例:
skillmgr remove my-skills`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
// 检查仓库是否存在
repo, err := config.FindRepository(name)
if err != nil {
return err
}
if repo == nil {
fmt.Printf("仓库 '%s' 不存在\n", name)
return nil
}
if err := config.RemoveRepository(name); err != nil {
return err
}
fmt.Printf("✓ 仓库 '%s' 已移除\n", name)
return nil
},
}
func init() {
rootCmd.AddCommand(removeCmd)
}

View File

@@ -1,55 +0,0 @@
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
"skillmgr/internal/config"
)
var rootCmd = &cobra.Command{
Use: "skillmgr",
Short: "AI 编程平台 skills 和 commands 管理工具",
Long: `skillmgr 是一个用于管理和分发 AI 编程平台 skills 和 commands 的命令行工具。
支持从 git 仓库拉取 skills/commands并根据目标平台Claude Code、OpenCode
将其安装到全局目录或项目目录中。
示例:
# 添加源仓库
skillmgr add https://github.com/user/skills --name my-skills
# 安装 skill
skillmgr install skill lyxy-kb --platform claude --global
# 列出已安装
skillmgr list
# 更新
skillmgr update skill lyxy-kb --platform claude --global
# 卸载
skillmgr uninstall skill lyxy-kb --platform claude --global`,
}
// Execute 执行根命令
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func init() {
// 初始化配置目录
cobra.OnInitialize(initConfig)
}
func initConfig() {
if err := config.EnsureConfigDirs(); err != nil {
fmt.Fprintf(os.Stderr, "初始化配置目录失败: %v\n", err)
os.Exit(1)
}
}

View File

@@ -1,118 +0,0 @@
package main
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"skillmgr/internal/repo"
"skillmgr/internal/types"
)
var searchCmd = &cobra.Command{
Use: "search [keyword]",
Short: "搜索可用的 skills 和 commands",
Long: `在已配置的仓库中搜索 skills 和 commands。
示例:
# 搜索所有
skillmgr search
# 按关键字搜索
skillmgr search kb
# 按类型过滤
skillmgr search --type skill
# 按仓库过滤
skillmgr search --repo lyxy`,
RunE: func(cmd *cobra.Command, args []string) error {
keyword := ""
if len(args) > 0 {
keyword = strings.ToLower(args[0])
}
itemTypeStr, _ := cmd.Flags().GetString("type")
repoFilter, _ := cmd.Flags().GetString("repo")
var results []searchResult
// 搜索 skills
if itemTypeStr == "" || itemTypeStr == "skill" {
skills, err := repo.ListAvailableSkills()
if err != nil {
fmt.Printf("警告: 无法获取 skills: %v\n", err)
} else {
for _, s := range skills {
// 按仓库过滤
if repoFilter != "" && !strings.Contains(strings.ToLower(s.SourceRepo), strings.ToLower(repoFilter)) {
continue
}
// 按关键字过滤
if keyword == "" || strings.Contains(strings.ToLower(s.Name), keyword) {
results = append(results, searchResult{
Type: types.ItemTypeSkill,
Name: s.Name,
RepoName: s.SourceRepo,
})
}
}
}
}
// 搜索 commands
if itemTypeStr == "" || itemTypeStr == "command" {
commands, err := repo.ListAvailableCommands()
if err != nil {
fmt.Printf("警告: 无法获取 commands: %v\n", err)
} else {
for _, c := range commands {
// 按仓库过滤
if repoFilter != "" && !strings.Contains(strings.ToLower(c.SourceRepo), strings.ToLower(repoFilter)) {
continue
}
// 按关键字过滤
if keyword == "" || strings.Contains(strings.ToLower(c.Name), keyword) {
results = append(results, searchResult{
Type: types.ItemTypeCommand,
Name: c.Name,
RepoName: c.SourceRepo,
Files: c.Files,
})
}
}
}
}
if len(results) == 0 {
fmt.Println("未找到匹配项")
return nil
}
fmt.Printf("找到 %d 个结果:\n\n", len(results))
for _, r := range results {
fmt.Printf(" [%s] %s\n", r.Type, r.Name)
fmt.Printf(" 来源: %s\n", r.RepoName)
if len(r.Files) > 0 {
fmt.Printf(" 文件: %s\n", strings.Join(r.Files, ", "))
}
}
return nil
},
}
type searchResult struct {
Type types.ItemType
Name string
RepoName string
Files []string
}
func init() {
searchCmd.Flags().String("type", "", "过滤类型 (skill|command)")
searchCmd.Flags().String("repo", "", "过滤仓库")
rootCmd.AddCommand(searchCmd)
}

View File

@@ -1,70 +0,0 @@
package main
import (
"fmt"
"github.com/spf13/cobra"
"skillmgr/internal/config"
"skillmgr/internal/repo"
)
var syncCmd = &cobra.Command{
Use: "sync [name]",
Short: "同步源仓库",
Long: `从远程拉取最新代码,更新本地缓存。
示例:
skillmgr sync # 同步所有仓库
skillmgr sync my-skills # 同步指定仓库`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.LoadRepositoryConfig()
if err != nil {
return err
}
if len(cfg.Repositories) == 0 {
fmt.Println("无已配置的源仓库")
return nil
}
// 如果指定了仓库名称,只同步该仓库
if len(args) > 0 {
name := args[0]
for _, r := range cfg.Repositories {
if r.Name == name {
fmt.Printf("正在同步 %s...\n", r.Name)
if _, err := repo.CloneOrPull(r.URL, r.Branch); err != nil {
fmt.Printf(" ✗ 同步失败: %v\n", err)
return err
}
fmt.Printf(" ✓ 同步成功\n")
return nil
}
}
return fmt.Errorf("仓库 '%s' 不存在", name)
}
// 同步所有仓库
var hasError bool
for _, r := range cfg.Repositories {
fmt.Printf("正在同步 %s...\n", r.Name)
if _, err := repo.CloneOrPull(r.URL, r.Branch); err != nil {
fmt.Printf(" ✗ 同步失败: %v\n", err)
hasError = true
continue
}
fmt.Printf(" ✓ 同步成功\n")
}
if hasError {
fmt.Println("\n部分仓库同步失败")
}
return nil
},
}
func init() {
rootCmd.AddCommand(syncCmd)
}

View File

@@ -1,53 +0,0 @@
package main
import (
"fmt"
"github.com/spf13/cobra"
"skillmgr/internal/installer"
"skillmgr/internal/types"
)
var uninstallCmd = &cobra.Command{
Use: "uninstall <type> <name>",
Short: "卸载 skill 或 command",
Long: `卸载已安装的 skill 或 command。
类型: skill, command
示例:
skillmgr uninstall skill lyxy-kb --platform claude --global
skillmgr uninstall command lyxy-kb --platform opencode`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
itemType := args[0]
name := args[1]
platformStr, _ := cmd.Flags().GetString("platform")
global, _ := cmd.Flags().GetBool("global")
platform := types.Platform(platformStr)
scope := types.ScopeProject
if global {
scope = types.ScopeGlobal
}
switch itemType {
case "skill":
return installer.UninstallSkill(name, platform, scope)
case "command":
return installer.UninstallCommand(name, platform, scope)
default:
return fmt.Errorf("无效的类型: %s必须是 'skill' 或 'command'", itemType)
}
},
}
func init() {
uninstallCmd.Flags().StringP("platform", "p", "", "目标平台 (claude|opencode)")
uninstallCmd.Flags().BoolP("global", "g", false, "全局卸载")
uninstallCmd.MarkFlagRequired("platform")
rootCmd.AddCommand(uninstallCmd)
}

View File

@@ -1,99 +0,0 @@
package main
import (
"fmt"
"github.com/spf13/cobra"
"skillmgr/internal/config"
"skillmgr/internal/installer"
"skillmgr/internal/types"
)
var updateCmd = &cobra.Command{
Use: "update [type] [name]",
Short: "更新已安装的 skill 或 command",
Long: `从源仓库重新安装最新版本。
示例:
# 更新单个
skillmgr update skill lyxy-kb --platform claude --global
skillmgr update command lyxy-kb --platform opencode
# 更新所有
skillmgr update --all`,
RunE: func(cmd *cobra.Command, args []string) error {
all, _ := cmd.Flags().GetBool("all")
if all {
return updateAll()
}
if len(args) != 2 {
return fmt.Errorf("需要指定类型和名称,或使用 --all 更新所有")
}
itemType := args[0]
name := args[1]
platformStr, _ := cmd.Flags().GetString("platform")
global, _ := cmd.Flags().GetBool("global")
platform := types.Platform(platformStr)
scope := types.ScopeProject
if global {
scope = types.ScopeGlobal
}
switch itemType {
case "skill":
return installer.UpdateSkill(name, platform, scope)
case "command":
return installer.UpdateCommand(name, platform, scope)
default:
return fmt.Errorf("无效的类型: %s必须是 'skill' 或 'command'", itemType)
}
},
}
func updateAll() error {
cfg, err := config.LoadInstallConfig()
if err != nil {
return err
}
if len(cfg.Installations) == 0 {
fmt.Println("无已安装的 skills/commands")
return nil
}
var hasError bool
for _, r := range cfg.Installations {
fmt.Printf("正在更新 [%s] %s...\n", r.Type, r.Name)
var err error
if r.Type == types.ItemTypeSkill {
err = installer.UpdateSkill(r.Name, r.Platform, r.Scope)
} else {
err = installer.UpdateCommand(r.Name, r.Platform, r.Scope)
}
if err != nil {
fmt.Printf(" ✗ 更新失败: %v\n", err)
hasError = true
continue
}
}
if hasError {
fmt.Println("\n部分项目更新失败")
}
return nil
}
func init() {
updateCmd.Flags().StringP("platform", "p", "", "目标平台 (claude|opencode)")
updateCmd.Flags().BoolP("global", "g", false, "全局更新")
updateCmd.Flags().Bool("all", false, "更新所有已安装项")
rootCmd.AddCommand(updateCmd)
}

View File

@@ -1,9 +0,0 @@
module skillmgr
go 1.25.0
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.9 // indirect
)

View File

@@ -1,10 +0,0 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -1,50 +0,0 @@
package adapter
import (
"fmt"
"os"
"skillmgr/internal/types"
)
// PlatformAdapter 平台适配器接口
type PlatformAdapter interface {
// GetSkillInstallPath 获取 skill 安装路径
GetSkillInstallPath(scope types.Scope, skillName string) (string, error)
// GetCommandInstallPath 获取 command 安装路径
GetCommandInstallPath(scope types.Scope, commandGroup string) (string, error)
// AdaptSkill 适配 skill返回 source → dest 映射)
AdaptSkill(sourcePath, destBasePath string) (map[string]string, error)
// AdaptCommand 适配 command返回 source → dest 映射)
AdaptCommand(sourcePath, destBasePath, commandGroup string) (map[string]string, error)
}
// GetAdapter 获取平台适配器
func GetAdapter(platform types.Platform) (PlatformAdapter, error) {
switch platform {
case types.PlatformClaude:
return &ClaudeAdapter{}, nil
case types.PlatformOpenCode:
return &OpenCodeAdapter{}, nil
default:
return nil, fmt.Errorf("不支持的平台: %s", platform)
}
}
// getBasePath 获取基础路径
// 支持通过环境变量 SKILLMGR_TEST_BASE 覆盖(用于测试隔离)
func getBasePath(scope types.Scope) (string, error) {
// 测试模式:使用环境变量指定的目录
if testBase := os.Getenv("SKILLMGR_TEST_BASE"); testBase != "" {
return testBase, nil
}
// 生产模式
if scope == types.ScopeGlobal {
return os.UserHomeDir()
}
return os.Getwd()
}

View File

@@ -1,73 +0,0 @@
package adapter
import (
"fmt"
"os"
"path/filepath"
"skillmgr/internal/types"
)
// ClaudeAdapter Claude Code 平台适配器
type ClaudeAdapter struct{}
// GetSkillInstallPath 获取 skill 安装路径
func (a *ClaudeAdapter) GetSkillInstallPath(scope types.Scope, skillName string) (string, error) {
base, err := getBasePath(scope)
if err != nil {
return "", err
}
return filepath.Join(base, ".claude", "skills", skillName), nil
}
// GetCommandInstallPath 获取 command 安装路径
func (a *ClaudeAdapter) GetCommandInstallPath(scope types.Scope, commandGroup string) (string, error) {
base, err := getBasePath(scope)
if err != nil {
return "", err
}
return filepath.Join(base, ".claude", "commands", commandGroup), nil
}
// AdaptSkill 适配 skill遍历源目录生成文件映射
func (a *ClaudeAdapter) AdaptSkill(sourcePath, destBasePath string) (map[string]string, error) {
mapping := make(map[string]string)
err := filepath.Walk(sourcePath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(sourcePath, path)
if err != nil {
return fmt.Errorf("计算相对路径失败: %w", err)
}
destPath := filepath.Join(destBasePath, relPath)
if !info.IsDir() {
mapping[path] = destPath
}
return nil
})
return mapping, err
}
// AdaptCommand 适配 command保持目录结构
func (a *ClaudeAdapter) AdaptCommand(sourcePath, destBasePath, commandGroup string) (map[string]string, error) {
mapping := make(map[string]string)
files, err := filepath.Glob(filepath.Join(sourcePath, "*.md"))
if err != nil {
return nil, err
}
for _, file := range files {
fileName := filepath.Base(file)
destPath := filepath.Join(destBasePath, fileName)
mapping[file] = destPath
}
return mapping, nil
}

View File

@@ -1,133 +0,0 @@
package adapter
import (
"os"
"path/filepath"
"testing"
"skillmgr/internal/types"
)
func setupAdapterTestEnv(t *testing.T) (string, func()) {
t.Helper()
tmpDir, err := os.MkdirTemp("", "skillmgr-adapter-test-*")
if err != nil {
t.Fatalf("创建临时目录失败: %v", err)
}
os.Setenv("SKILLMGR_TEST_BASE", tmpDir)
cleanup := func() {
os.Unsetenv("SKILLMGR_TEST_BASE")
os.RemoveAll(tmpDir)
}
return tmpDir, cleanup
}
func TestClaudeAdapter_GetSkillInstallPath_Global(t *testing.T) {
tmpDir, cleanup := setupAdapterTestEnv(t)
defer cleanup()
adapter := &ClaudeAdapter{}
path, err := adapter.GetSkillInstallPath(types.ScopeGlobal, "test-skill")
if err != nil {
t.Fatalf("GetSkillInstallPath 失败: %v", err)
}
expected := filepath.Join(tmpDir, ".claude", "skills", "test-skill")
if path != expected {
t.Errorf("期望 %s得到 %s", expected, path)
}
}
func TestClaudeAdapter_GetSkillInstallPath_Project(t *testing.T) {
_, cleanup := setupAdapterTestEnv(t)
defer cleanup()
adapter := &ClaudeAdapter{}
path, err := adapter.GetSkillInstallPath(types.ScopeProject, "test-skill")
if err != nil {
t.Fatalf("GetSkillInstallPath 失败: %v", err)
}
// 项目级路径是相对当前目录的
if !filepath.IsAbs(path) {
// 相对路径应该包含 .claude/skills
if filepath.Base(filepath.Dir(path)) != "skills" {
t.Errorf("期望路径包含 skills 目录,得到 %s", path)
}
}
}
func TestClaudeAdapter_GetCommandInstallPath_Global(t *testing.T) {
tmpDir, cleanup := setupAdapterTestEnv(t)
defer cleanup()
adapter := &ClaudeAdapter{}
path, err := adapter.GetCommandInstallPath(types.ScopeGlobal, "test-cmd")
if err != nil {
t.Fatalf("GetCommandInstallPath 失败: %v", err)
}
expected := filepath.Join(tmpDir, ".claude", "commands", "test-cmd")
if path != expected {
t.Errorf("期望 %s得到 %s", expected, path)
}
}
func TestClaudeAdapter_AdaptSkill(t *testing.T) {
tmpDir, cleanup := setupAdapterTestEnv(t)
defer cleanup()
// 创建源目录
srcDir := filepath.Join(tmpDir, "src-skill")
os.MkdirAll(srcDir, 0755)
os.WriteFile(filepath.Join(srcDir, "SKILL.md"), []byte("test"), 0644)
os.WriteFile(filepath.Join(srcDir, "helper.md"), []byte("test"), 0644)
destDir := filepath.Join(tmpDir, "dest-skill")
adapter := &ClaudeAdapter{}
mapping, err := adapter.AdaptSkill(srcDir, destDir)
if err != nil {
t.Fatalf("AdaptSkill 失败: %v", err)
}
if len(mapping) != 2 {
t.Errorf("期望 2 个文件映射,得到 %d 个", len(mapping))
}
}
func TestClaudeAdapter_AdaptCommand(t *testing.T) {
tmpDir, cleanup := setupAdapterTestEnv(t)
defer cleanup()
// 创建源目录
srcDir := filepath.Join(tmpDir, "src-cmd")
os.MkdirAll(srcDir, 0755)
os.WriteFile(filepath.Join(srcDir, "init.md"), []byte("test"), 0644)
os.WriteFile(filepath.Join(srcDir, "run.md"), []byte("test"), 0644)
destDir := filepath.Join(tmpDir, "dest-cmd")
adapter := &ClaudeAdapter{}
mapping, err := adapter.AdaptCommand(srcDir, destDir, "test-cmd")
if err != nil {
t.Fatalf("AdaptCommand 失败: %v", err)
}
if len(mapping) != 2 {
t.Errorf("期望 2 个文件映射,得到 %d 个", len(mapping))
}
// 验证文件名保持原样
for src, dest := range mapping {
srcBase := filepath.Base(src)
destBase := filepath.Base(dest)
if srcBase != destBase {
t.Errorf("Claude 适配器应保持文件名:源 %s目标 %s", srcBase, destBase)
}
}
}

View File

@@ -1,89 +0,0 @@
package adapter
import (
"fmt"
"os"
"path/filepath"
"strings"
"skillmgr/internal/types"
)
// OpenCodeAdapter OpenCode 平台适配器
type OpenCodeAdapter struct{}
// GetSkillInstallPath 获取 skill 安装路径
func (a *OpenCodeAdapter) GetSkillInstallPath(scope types.Scope, skillName string) (string, error) {
base, err := getBasePath(scope)
if err != nil {
return "", err
}
if scope == types.ScopeGlobal {
// 全局: ~/.config/opencode/skills/<name>/
return filepath.Join(base, ".config", "opencode", "skills", skillName), nil
}
// 项目级: ./.opencode/skills/<name>/
return filepath.Join(base, ".opencode", "skills", skillName), nil
}
// GetCommandInstallPath 获取 command 安装路径
func (a *OpenCodeAdapter) GetCommandInstallPath(scope types.Scope, commandGroup string) (string, error) {
base, err := getBasePath(scope)
if err != nil {
return "", err
}
if scope == types.ScopeGlobal {
// 全局: ~/.config/opencode/commands/(扁平化,所有命令在同一目录)
return filepath.Join(base, ".config", "opencode", "commands"), nil
}
// 项目级: ./.opencode/commands/
return filepath.Join(base, ".opencode", "commands"), nil
}
// AdaptSkill 适配 skill与 Claude 相同,保持目录结构)
func (a *OpenCodeAdapter) AdaptSkill(sourcePath, destBasePath string) (map[string]string, error) {
mapping := make(map[string]string)
err := filepath.Walk(sourcePath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(sourcePath, path)
if err != nil {
return fmt.Errorf("计算相对路径失败: %w", err)
}
destPath := filepath.Join(destBasePath, relPath)
if !info.IsDir() {
mapping[path] = destPath
}
return nil
})
return mapping, err
}
// AdaptCommand 适配 command扁平化文件名<group>-<action>.md
func (a *OpenCodeAdapter) AdaptCommand(sourcePath, destBasePath, commandGroup string) (map[string]string, error) {
mapping := make(map[string]string)
files, err := filepath.Glob(filepath.Join(sourcePath, "*.md"))
if err != nil {
return nil, err
}
for _, file := range files {
fileName := filepath.Base(file)
baseName := strings.TrimSuffix(fileName, ".md")
// 重命名init.md → lyxy-kb-init.md
newName := commandGroup + "-" + baseName + ".md"
destPath := filepath.Join(destBasePath, newName)
mapping[file] = destPath
}
return mapping, nil
}

View File

@@ -1,109 +0,0 @@
package adapter
import (
"os"
"path/filepath"
"strings"
"testing"
"skillmgr/internal/types"
)
func TestOpenCodeAdapter_GetSkillInstallPath_Global(t *testing.T) {
tmpDir, cleanup := setupAdapterTestEnv(t)
defer cleanup()
adapter := &OpenCodeAdapter{}
path, err := adapter.GetSkillInstallPath(types.ScopeGlobal, "test-skill")
if err != nil {
t.Fatalf("GetSkillInstallPath 失败: %v", err)
}
// OpenCode 全局 skill 使用 ~/.config/opencode/skills/
expected := filepath.Join(tmpDir, ".config", "opencode", "skills", "test-skill")
if path != expected {
t.Errorf("期望 %s得到 %s", expected, path)
}
}
func TestOpenCodeAdapter_GetCommandInstallPath_Global(t *testing.T) {
tmpDir, cleanup := setupAdapterTestEnv(t)
defer cleanup()
adapter := &OpenCodeAdapter{}
path, err := adapter.GetCommandInstallPath(types.ScopeGlobal, "test-cmd")
if err != nil {
t.Fatalf("GetCommandInstallPath 失败: %v", err)
}
// OpenCode 全局 command 使用 ~/.config/opencode/commands/
expected := filepath.Join(tmpDir, ".config", "opencode", "commands")
if path != expected {
t.Errorf("期望 %s得到 %s", expected, path)
}
}
func TestOpenCodeAdapter_AdaptCommand_Flattening(t *testing.T) {
tmpDir, cleanup := setupAdapterTestEnv(t)
defer cleanup()
// 创建源目录
srcDir := filepath.Join(tmpDir, "src-cmd")
os.MkdirAll(srcDir, 0755)
os.WriteFile(filepath.Join(srcDir, "init.md"), []byte("test"), 0644)
os.WriteFile(filepath.Join(srcDir, "run.md"), []byte("test"), 0644)
destDir := filepath.Join(tmpDir, "dest-cmd")
adapter := &OpenCodeAdapter{}
mapping, err := adapter.AdaptCommand(srcDir, destDir, "test-cmd")
if err != nil {
t.Fatalf("AdaptCommand 失败: %v", err)
}
if len(mapping) != 2 {
t.Errorf("期望 2 个文件映射,得到 %d 个", len(mapping))
}
// 验证文件名被扁平化
for src, dest := range mapping {
srcBase := filepath.Base(src)
destBase := filepath.Base(dest)
// init.md -> test-cmd-init.md
nameWithoutExt := strings.TrimSuffix(srcBase, ".md")
expectedBase := "test-cmd-" + nameWithoutExt + ".md"
if destBase != expectedBase {
t.Errorf("OpenCode 适配器应扁平化文件名:期望 %s得到 %s", expectedBase, destBase)
}
}
}
func TestGetAdapter_Claude(t *testing.T) {
adapter, err := GetAdapter(types.PlatformClaude)
if err != nil {
t.Fatalf("GetAdapter(claude) 失败: %v", err)
}
if _, ok := adapter.(*ClaudeAdapter); !ok {
t.Error("期望 ClaudeAdapter 类型")
}
}
func TestGetAdapter_OpenCode(t *testing.T) {
adapter, err := GetAdapter(types.PlatformOpenCode)
if err != nil {
t.Fatalf("GetAdapter(opencode) 失败: %v", err)
}
if _, ok := adapter.(*OpenCodeAdapter); !ok {
t.Error("期望 OpenCodeAdapter 类型")
}
}
func TestGetAdapter_Invalid(t *testing.T) {
_, err := GetAdapter(types.Platform("invalid"))
if err == nil {
t.Error("期望无效平台返回错误")
}
}

View File

@@ -1,141 +0,0 @@
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

@@ -1,169 +0,0 @@
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

@@ -1,77 +0,0 @@
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

@@ -1,60 +0,0 @@
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

@@ -1,105 +0,0 @@
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

@@ -1,159 +0,0 @@
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")
}
}

View File

@@ -1,304 +0,0 @@
package installer
import (
"fmt"
"os"
"path/filepath"
"time"
"skillmgr/internal/adapter"
"skillmgr/internal/config"
"skillmgr/internal/prompt"
"skillmgr/internal/repo"
"skillmgr/internal/types"
)
// InstallSkill 安装 skill
func InstallSkill(name string, platform types.Platform, scope types.Scope) error {
return InstallSkillFrom(name, platform, scope, "")
}
// InstallSkillFrom 从指定源安装 skill
// fromURL 为空时从已配置的仓库安装,否则从临时仓库安装
func InstallSkillFrom(name string, platform types.Platform, scope types.Scope, fromURL string) error {
var skillPath string
var repoName string
var cleanup func()
if fromURL != "" {
// 从临时仓库安装
tmpRepo, cleanupFunc, err := repo.CloneTemporary(fromURL, "")
if err != nil {
return fmt.Errorf("克隆临时仓库失败: %w", err)
}
cleanup = cleanupFunc
defer cleanup()
// 检查 skill 是否存在
sp := filepath.Join(tmpRepo, "skills", name)
if _, err := os.Stat(filepath.Join(sp, "SKILL.md")); err != nil {
return fmt.Errorf("skill '%s' 未在临时仓库中找到", name)
}
skillPath = sp
repoName = "(临时)"
} else {
// 从已配置的仓库安装
_, sp, rn, err := repo.FindSkill(name)
if err != nil {
// 列出可用的 skills
skills, _ := repo.ListAvailableSkills()
if len(skills) > 0 {
fmt.Println("\n可用的 skills:")
for _, s := range skills {
fmt.Printf(" - %s (from %s)\n", s.Name, s.SourceRepo)
}
}
return err
}
skillPath = sp
repoName = rn
}
// 2. 获取适配器
adp, err := adapter.GetAdapter(platform)
if err != nil {
return err
}
// 3. 确定安装路径
installPath, err := adp.GetSkillInstallPath(scope, name)
if err != nil {
return err
}
// 4. 检查是否已存在
if err := checkExistingInstallation(types.ItemTypeSkill, name, platform, scope, installPath); err != nil {
return err
}
// 5. 适配文件映射
fileMap, err := adp.AdaptSkill(skillPath, installPath)
if err != nil {
return err
}
// 6. 事务性安装
tx, err := NewTransaction(installPath, fileMap)
if err != nil {
return err
}
defer tx.Rollback() // 确保失败时清理
if err := tx.Stage(); err != nil {
return fmt.Errorf("staging 失败: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit 失败: %w", err)
}
// 7. 记录安装
record := types.InstallRecord{
Type: types.ItemTypeSkill,
Name: name,
SourceRepo: repoName,
Platform: platform,
Scope: scope,
InstallPath: installPath,
InstalledAt: time.Now(),
UpdatedAt: time.Now(),
}
// 先移除旧记录(如果存在)
config.RemoveInstallRecord(types.ItemTypeSkill, name, platform, scope)
if err := config.AddInstallRecord(record); err != nil {
return fmt.Errorf("保存安装记录失败: %w", err)
}
fmt.Printf("✓ Skill '%s' 已安装到 %s\n", name, installPath)
return nil
}
// InstallCommand 安装 command
func InstallCommand(name string, platform types.Platform, scope types.Scope) error {
return InstallCommandFrom(name, platform, scope, "")
}
// InstallCommandFrom 从指定源安装 command
// fromURL 为空时从已配置的仓库安装,否则从临时仓库安装
func InstallCommandFrom(name string, platform types.Platform, scope types.Scope, fromURL string) error {
var commandPath string
var repoName string
var cleanup func()
if fromURL != "" {
// 从临时仓库安装
tmpRepo, cleanupFunc, err := repo.CloneTemporary(fromURL, "")
if err != nil {
return fmt.Errorf("克隆临时仓库失败: %w", err)
}
cleanup = cleanupFunc
defer cleanup()
// 检查 command 是否存在
cp := filepath.Join(tmpRepo, "commands", name)
if info, err := os.Stat(cp); err != nil || !info.IsDir() {
return fmt.Errorf("command '%s' 未在临时仓库中找到", name)
}
// 检查是否包含 .md 文件
files, _ := filepath.Glob(filepath.Join(cp, "*.md"))
if len(files) == 0 {
return fmt.Errorf("command group '%s' 不包含任何命令文件", name)
}
commandPath = cp
repoName = "(临时)"
} else {
// 从已配置的仓库安装
_, cp, rn, err := repo.FindCommand(name)
if err != nil {
// 列出可用的 commands
commands, _ := repo.ListAvailableCommands()
if len(commands) > 0 {
fmt.Println("\n可用的 commands:")
for _, c := range commands {
fmt.Printf(" - %s (from %s)\n", c.Name, c.SourceRepo)
}
}
return err
}
commandPath = cp
repoName = rn
}
// 2. 获取适配器
adp, err := adapter.GetAdapter(platform)
if err != nil {
return err
}
// 3. 确定安装路径
installPath, err := adp.GetCommandInstallPath(scope, name)
if err != nil {
return err
}
// 4. 检查是否已存在
if err := checkExistingInstallation(types.ItemTypeCommand, name, platform, scope, installPath); err != nil {
return err
}
// 5. 适配文件映射
fileMap, err := adp.AdaptCommand(commandPath, installPath, name)
if err != nil {
return err
}
// 6. 事务性安装
tx, err := NewTransaction(installPath, fileMap)
if err != nil {
return err
}
defer tx.Rollback()
if err := tx.Stage(); err != nil {
return fmt.Errorf("staging 失败: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit 失败: %w", err)
}
// 7. 记录安装
record := types.InstallRecord{
Type: types.ItemTypeCommand,
Name: name,
SourceRepo: repoName,
Platform: platform,
Scope: scope,
InstallPath: installPath,
InstalledAt: time.Now(),
UpdatedAt: time.Now(),
}
// 先移除旧记录(如果存在)
config.RemoveInstallRecord(types.ItemTypeCommand, name, platform, scope)
if err := config.AddInstallRecord(record); err != nil {
return fmt.Errorf("保存安装记录失败: %w", err)
}
fmt.Printf("✓ Command '%s' 已安装到 %s\n", name, installPath)
return nil
}
// checkExistingInstallation 检查现有安装
func checkExistingInstallation(itemType types.ItemType, name string, platform types.Platform, scope types.Scope, installPath string) error {
// 检查 install.json 中是否有记录
record, err := config.FindInstallRecord(itemType, name, platform, scope)
if err != nil {
return err
}
// 检查目录是否实际存在
_, dirErr := os.Stat(installPath)
dirExists := dirErr == nil
if record != nil && dirExists {
// 已安装,询问是否覆盖
if !prompt.Confirm(fmt.Sprintf("%s '%s' 已安装。是否覆盖?", itemType, name)) {
return fmt.Errorf("用户取消安装")
}
} else if record == nil && dirExists {
// 目录存在但没有记录,询问是否覆盖
if !prompt.Confirm(fmt.Sprintf("目录 %s 已存在但不是由 skillmgr 管理。是否覆盖?", installPath)) {
return fmt.Errorf("用户取消安装")
}
}
return nil
}
// UpdateSkill 更新 skill
func UpdateSkill(name string, platform types.Platform, scope types.Scope) error {
// 查找记录
record, err := config.FindInstallRecord(types.ItemTypeSkill, name, platform, scope)
if err != nil {
return err
}
if record == nil {
return fmt.Errorf("未找到 skill '%s' 的安装记录", name)
}
// 重新安装
if err := InstallSkill(name, platform, scope); err != nil {
return err
}
// 更新记录时间
record.UpdatedAt = time.Now()
return config.UpdateInstallRecord(*record)
}
// UpdateCommand 更新 command
func UpdateCommand(name string, platform types.Platform, scope types.Scope) error {
// 查找记录
record, err := config.FindInstallRecord(types.ItemTypeCommand, name, platform, scope)
if err != nil {
return err
}
if record == nil {
return fmt.Errorf("未找到 command '%s' 的安装记录", name)
}
// 重新安装
if err := InstallCommand(name, platform, scope); err != nil {
return err
}
// 更新记录时间
record.UpdatedAt = time.Now()
return config.UpdateInstallRecord(*record)
}

View File

@@ -1,856 +0,0 @@
package installer
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"skillmgr/internal/config"
"skillmgr/internal/testutil"
"skillmgr/internal/types"
)
// setupIntegrationTest 设置集成测试环境
// 返回临时目录、仓库路径、清理函数
func setupIntegrationTest(t *testing.T) (tmpDir string, repoPath string, cleanup func()) {
t.Helper()
tmpDir, cleanupEnv := testutil.SetupTestEnv(t)
// 确保配置目录存在
if err := config.EnsureConfigDirs(); err != nil {
t.Fatalf("创建配置目录失败: %v", err)
}
// 获取 fixture 路径
fixturePath := testutil.GetFixturePath(t)
fixtureRepo := filepath.Join(fixturePath, "test-repo")
// 获取缓存路径
cachePath, err := config.GetCachePath()
if err != nil {
t.Fatalf("获取缓存路径失败: %v", err)
}
// 使用与 URLToPathName 一致的路径格式
// URL: file://localhost/test-repo -> URLToPathName: file:__localhost_test-repo
repoURL := "file://localhost/test-repo"
repoDirName := "file:__localhost_test-repo"
repoPath = filepath.Join(cachePath, repoDirName)
// 复制 fixture 到正确的缓存目录
if err := os.MkdirAll(repoPath, 0755); err != nil {
t.Fatalf("创建仓库目录失败: %v", err)
}
// 复制 skills 和 commands 目录
srcSkills := filepath.Join(fixtureRepo, "skills")
dstSkills := filepath.Join(repoPath, "skills")
if err := copyDir(srcSkills, dstSkills); err != nil {
t.Fatalf("复制 skills 失败: %v", err)
}
srcCommands := filepath.Join(fixtureRepo, "commands")
dstCommands := filepath.Join(repoPath, "commands")
if err := copyDir(srcCommands, dstCommands); err != nil {
t.Fatalf("复制 commands 失败: %v", err)
}
// 添加仓库配置
repo := types.Repository{
Name: "test-repo",
URL: repoURL,
Branch: "main",
AddedAt: time.Now(),
}
if err := config.AddRepository(repo); err != nil {
t.Fatalf("添加仓库失败: %v", err)
}
cleanup = func() {
cleanupEnv()
}
return tmpDir, repoPath, cleanup
}
// copyDir 递归复制目录(测试辅助函数)
func copyDir(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(src, path)
if err != nil {
return err
}
dstPath := filepath.Join(dst, relPath)
if info.IsDir() {
return os.MkdirAll(dstPath, info.Mode())
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(dstPath, data, info.Mode())
})
}
// ============================================================
// 18.2 测试完整安装流程
// ============================================================
func TestInstallSkill_CompleteFlow(t *testing.T) {
tmpDir, _, cleanup := setupIntegrationTest(t)
defer cleanup()
// 安装 skill 到 Claude 平台
err := InstallSkill("test-skill", types.PlatformClaude, types.ScopeGlobal)
if err != nil {
t.Fatalf("安装 skill 失败: %v", err)
}
// 验证文件存在
installPath := filepath.Join(tmpDir, ".claude", "skills", "test-skill")
if _, err := os.Stat(installPath); os.IsNotExist(err) {
t.Errorf("安装目录不存在: %s", installPath)
}
skillFile := filepath.Join(installPath, "SKILL.md")
if _, err := os.Stat(skillFile); os.IsNotExist(err) {
t.Errorf("SKILL.md 文件不存在")
}
// 验证安装记录
record, err := config.FindInstallRecord(types.ItemTypeSkill, "test-skill", types.PlatformClaude, types.ScopeGlobal)
if err != nil {
t.Fatalf("查找安装记录失败: %v", err)
}
if record == nil {
t.Error("安装记录不存在")
} else {
if record.InstallPath != installPath {
t.Errorf("安装路径不匹配: got %s, want %s", record.InstallPath, installPath)
}
if record.SourceRepo != "test-repo" {
t.Errorf("源仓库不匹配: got %s, want test-repo", record.SourceRepo)
}
}
}
func TestInstallCommand_CompleteFlow(t *testing.T) {
tmpDir, _, cleanup := setupIntegrationTest(t)
defer cleanup()
// 安装 command 到 Claude 平台
err := InstallCommand("test-cmd", types.PlatformClaude, types.ScopeGlobal)
if err != nil {
t.Fatalf("安装 command 失败: %v", err)
}
// 验证文件存在
installPath := filepath.Join(tmpDir, ".claude", "commands", "test-cmd")
if _, err := os.Stat(installPath); os.IsNotExist(err) {
t.Errorf("安装目录不存在: %s", installPath)
}
// 验证命令文件
initFile := filepath.Join(installPath, "init.md")
runFile := filepath.Join(installPath, "run.md")
if _, err := os.Stat(initFile); os.IsNotExist(err) {
t.Errorf("init.md 文件不存在")
}
if _, err := os.Stat(runFile); os.IsNotExist(err) {
t.Errorf("run.md 文件不存在")
}
// 验证安装记录
record, err := config.FindInstallRecord(types.ItemTypeCommand, "test-cmd", types.PlatformClaude, types.ScopeGlobal)
if err != nil {
t.Fatalf("查找安装记录失败: %v", err)
}
if record == nil {
t.Error("安装记录不存在")
}
}
// ============================================================
// 18.3 测试冲突覆盖场景
// ============================================================
func TestInstallSkill_ConflictWithRecord(t *testing.T) {
tmpDir, _, cleanup := setupIntegrationTest(t)
defer cleanup()
// 首次安装
err := InstallSkill("test-skill", types.PlatformClaude, types.ScopeGlobal)
if err != nil {
t.Fatalf("首次安装失败: %v", err)
}
// 记录首次安装时间
record1, _ := config.FindInstallRecord(types.ItemTypeSkill, "test-skill", types.PlatformClaude, types.ScopeGlobal)
firstInstallTime := record1.InstalledAt
// 完全卸载后重新安装(测试正常覆盖流程)
err = UninstallSkill("test-skill", types.PlatformClaude, types.ScopeGlobal)
if err != nil {
t.Fatalf("卸载失败: %v", err)
}
// 等待一小段时间确保时间戳不同
time.Sleep(10 * time.Millisecond)
// 再次安装
err = InstallSkill("test-skill", types.PlatformClaude, types.ScopeGlobal)
if err != nil {
t.Fatalf("重新安装失败: %v", err)
}
// 验证记录已更新
record2, _ := config.FindInstallRecord(types.ItemTypeSkill, "test-skill", types.PlatformClaude, types.ScopeGlobal)
if record2 == nil {
t.Fatal("安装记录丢失")
}
// 验证安装时间更新
if !record2.InstalledAt.After(firstInstallTime) {
t.Error("重新安装的时间应该晚于首次安装")
}
// 验证文件仍然存在
installPath := filepath.Join(tmpDir, ".claude", "skills", "test-skill")
if _, err := os.Stat(installPath); os.IsNotExist(err) {
t.Errorf("安装目录应该存在")
}
}
func TestInstallSkill_ConflictWithoutRecord(t *testing.T) {
tmpDir, _, cleanup := setupIntegrationTest(t)
defer cleanup()
// 手动创建目标目录(模拟非 skillmgr 管理的目录)
installPath := filepath.Join(tmpDir, ".claude", "skills", "test-skill")
os.MkdirAll(installPath, 0755)
os.WriteFile(filepath.Join(installPath, "existing.txt"), []byte("existing file"), 0644)
// 验证目录存在
if _, err := os.Stat(installPath); os.IsNotExist(err) {
t.Fatal("预创建的目录应该存在")
}
// 由于 prompt.Confirm 会读取 stdin在测试中会导致用户取消
// 所以我们测试的是:目录存在时,安装会请求确认(失败说明确认机制工作)
err := InstallSkill("test-skill", types.PlatformClaude, types.ScopeGlobal)
// 在非交互测试环境中,用户取消是预期行为
if err == nil {
// 如果成功了,说明没有检测到冲突(不应该发生)
t.Log("注意: 安装成功,可能是因为冲突检测没有触发确认")
} else if !strings.Contains(err.Error(), "用户取消") {
// 如果是其他错误,记录但不失败(冲突检测机制正常工作)
t.Logf("冲突检测正常工作,用户取消安装: %v", err)
}
}
// ============================================================
// 18.4 测试事务回滚
// ============================================================
func TestTransaction_RollbackOnStagingFailure(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "skillmgr-rollback-test-*")
if err != nil {
t.Fatalf("创建临时目录失败: %v", err)
}
defer os.RemoveAll(tmpDir)
// 创建指向不存在文件的映射(会导致 Stage 失败)
targetDir := filepath.Join(tmpDir, "target")
fileMap := map[string]string{
"/nonexistent/path/file.md": filepath.Join(targetDir, "file.md"),
}
tx, err := NewTransaction(targetDir, fileMap)
if err != nil {
t.Fatalf("NewTransaction 失败: %v", err)
}
stagingDir := tx.stagingDir
// Stage 应该失败
err = tx.Stage()
if err == nil {
t.Error("Stage 应该失败(源文件不存在)")
}
// 调用 Rollback
tx.Rollback()
// 验证 staging 目录已清理
if _, err := os.Stat(stagingDir); !os.IsNotExist(err) {
t.Error("Staging 目录应该被清理")
}
// 验证目标目录不存在
if _, err := os.Stat(targetDir); !os.IsNotExist(err) {
t.Error("目标目录不应该存在")
}
}
func TestTransaction_DeferredRollback(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "skillmgr-defer-test-*")
if err != nil {
t.Fatalf("创建临时目录失败: %v", err)
}
defer os.RemoveAll(tmpDir)
// 创建源文件
srcDir := filepath.Join(tmpDir, "src")
os.MkdirAll(srcDir, 0755)
os.WriteFile(filepath.Join(srcDir, "test.md"), []byte("test"), 0644)
targetDir := filepath.Join(tmpDir, "target")
fileMap := map[string]string{
filepath.Join(srcDir, "test.md"): filepath.Join(targetDir, "test.md"),
}
var stagingDir string
// 在函数内使用 defer tx.Rollback() 模拟安装函数
func() {
tx, err := NewTransaction(targetDir, fileMap)
if err != nil {
t.Fatalf("NewTransaction 失败: %v", err)
}
defer tx.Rollback() // 确保清理
stagingDir = tx.stagingDir
if err := tx.Stage(); err != nil {
t.Fatalf("Stage 失败: %v", err)
}
// 不调用 Commit模拟中途失败
// defer 会触发 Rollback
}()
// 验证 staging 目录已被 defer 清理
if _, err := os.Stat(stagingDir); !os.IsNotExist(err) {
t.Error("Staging 目录应该被 defer 清理")
}
}
// ============================================================
// 18.5 测试卸载流程
// ============================================================
func TestUninstallSkill_CompleteFlow(t *testing.T) {
tmpDir, _, cleanup := setupIntegrationTest(t)
defer cleanup()
// 先安装
err := InstallSkill("test-skill", types.PlatformClaude, types.ScopeGlobal)
if err != nil {
t.Fatalf("安装失败: %v", err)
}
installPath := filepath.Join(tmpDir, ".claude", "skills", "test-skill")
// 验证安装成功
if _, err := os.Stat(installPath); os.IsNotExist(err) {
t.Fatal("安装目录应该存在")
}
// 卸载
err = UninstallSkill("test-skill", types.PlatformClaude, types.ScopeGlobal)
if err != nil {
t.Fatalf("卸载失败: %v", err)
}
// 验证目录已删除
if _, err := os.Stat(installPath); !os.IsNotExist(err) {
t.Error("安装目录应该被删除")
}
// 验证记录已移除
record, err := config.FindInstallRecord(types.ItemTypeSkill, "test-skill", types.PlatformClaude, types.ScopeGlobal)
if err != nil {
t.Fatalf("查找记录失败: %v", err)
}
if record != nil {
t.Error("安装记录应该被移除")
}
}
func TestUninstallCommand_CompleteFlow(t *testing.T) {
tmpDir, _, cleanup := setupIntegrationTest(t)
defer cleanup()
// 先安装
err := InstallCommand("test-cmd", types.PlatformClaude, types.ScopeGlobal)
if err != nil {
t.Fatalf("安装失败: %v", err)
}
installPath := filepath.Join(tmpDir, ".claude", "commands", "test-cmd")
// 卸载
err = UninstallCommand("test-cmd", types.PlatformClaude, types.ScopeGlobal)
if err != nil {
t.Fatalf("卸载失败: %v", err)
}
// 验证目录已删除
if _, err := os.Stat(installPath); !os.IsNotExist(err) {
t.Error("安装目录应该被删除")
}
// 验证记录已移除
record, err := config.FindInstallRecord(types.ItemTypeCommand, "test-cmd", types.PlatformClaude, types.ScopeGlobal)
if err != nil {
t.Fatalf("查找记录失败: %v", err)
}
if record != nil {
t.Error("安装记录应该被移除")
}
}
func TestUninstallSkill_NotFound(t *testing.T) {
_, _, cleanup := setupIntegrationTest(t)
defer cleanup()
// 尝试卸载不存在的 skill
err := UninstallSkill("nonexistent", types.PlatformClaude, types.ScopeGlobal)
if err == nil {
t.Error("卸载不存在的 skill 应该报错")
}
if !strings.Contains(err.Error(), "未找到") {
t.Errorf("错误信息应该包含 '未找到': %v", err)
}
}
func TestUninstallSkill_FilesAlreadyDeleted(t *testing.T) {
tmpDir, _, cleanup := setupIntegrationTest(t)
defer cleanup()
// 安装
err := InstallSkill("test-skill", types.PlatformClaude, types.ScopeGlobal)
if err != nil {
t.Fatalf("安装失败: %v", err)
}
// 手动删除文件(模拟用户手动删除)
installPath := filepath.Join(tmpDir, ".claude", "skills", "test-skill")
os.RemoveAll(installPath)
// 卸载应该成功(仅移除记录)
err = UninstallSkill("test-skill", types.PlatformClaude, types.ScopeGlobal)
if err != nil {
t.Fatalf("卸载失败(文件已手动删除): %v", err)
}
// 验证记录已移除
record, _ := config.FindInstallRecord(types.ItemTypeSkill, "test-skill", types.PlatformClaude, types.ScopeGlobal)
if record != nil {
t.Error("安装记录应该被移除")
}
}
// ============================================================
// 18.6 测试更新流程
// ============================================================
func TestUpdateSkill_CompleteFlow(t *testing.T) {
tmpDir, repoPath, cleanup := setupIntegrationTest(t)
defer cleanup()
// 安装
err := InstallSkill("test-skill", types.PlatformClaude, types.ScopeGlobal)
if err != nil {
t.Fatalf("安装失败: %v", err)
}
// 记录初始内容
installPath := filepath.Join(tmpDir, ".claude", "skills", "test-skill", "SKILL.md")
initialContent, err := os.ReadFile(installPath)
if err != nil {
t.Fatalf("读取初始文件失败: %v", err)
}
// 修改源文件
sourceFile := filepath.Join(repoPath, "skills", "test-skill", "SKILL.md")
newContent := "# Updated content\n\nThis is updated.\n"
os.WriteFile(sourceFile, []byte(newContent), 0644)
// 卸载后重新安装(模拟更新,避免 prompt
err = UninstallSkill("test-skill", types.PlatformClaude, types.ScopeGlobal)
if err != nil {
t.Fatalf("卸载失败: %v", err)
}
err = InstallSkill("test-skill", types.PlatformClaude, types.ScopeGlobal)
if err != nil {
t.Fatalf("重新安装失败: %v", err)
}
// 验证文件内容已更新
updatedContent, err := os.ReadFile(installPath)
if err != nil {
t.Fatalf("读取更新后文件失败: %v", err)
}
if string(updatedContent) == string(initialContent) {
t.Error("安装文件内容应该已更新")
}
if !strings.Contains(string(updatedContent), "Updated content") {
t.Error("安装文件应该包含更新的内容")
}
}
func TestUpdateSkill_NotInstalled(t *testing.T) {
_, _, cleanup := setupIntegrationTest(t)
defer cleanup()
// 尝试更新未安装的 skill
err := UpdateSkill("nonexistent", types.PlatformClaude, types.ScopeGlobal)
if err == nil {
t.Error("更新未安装的 skill 应该报错")
}
if !strings.Contains(err.Error(), "未找到") {
t.Errorf("错误信息应该包含 '未找到': %v", err)
}
}
// ============================================================
// 18.7 测试清理孤立记录
// ============================================================
func TestCleanOrphanRecords(t *testing.T) {
tmpDir, _, cleanup := setupIntegrationTest(t)
defer cleanup()
// 安装
err := InstallSkill("test-skill", types.PlatformClaude, types.ScopeGlobal)
if err != nil {
t.Fatalf("安装失败: %v", err)
}
// 手动删除安装目录
installPath := filepath.Join(tmpDir, ".claude", "skills", "test-skill")
os.RemoveAll(installPath)
// 验证记录仍存在
record, _ := config.FindInstallRecord(types.ItemTypeSkill, "test-skill", types.PlatformClaude, types.ScopeGlobal)
if record == nil {
t.Fatal("删除文件后记录应该仍存在")
}
// 清理孤立记录
cleaned, err := config.CleanOrphanRecords()
if err != nil {
t.Fatalf("清理孤立记录失败: %v", err)
}
// 验证清理了正确的记录
if len(cleaned) != 1 {
t.Errorf("应该清理 1 个记录,实际清理了 %d 个", len(cleaned))
}
if len(cleaned) > 0 && cleaned[0].Name != "test-skill" {
t.Errorf("清理的记录名称不匹配: got %s, want test-skill", cleaned[0].Name)
}
// 验证记录已被移除
record, _ = config.FindInstallRecord(types.ItemTypeSkill, "test-skill", types.PlatformClaude, types.ScopeGlobal)
if record != nil {
t.Error("孤立记录应该被清理")
}
}
func TestCleanOrphanRecords_NoOrphans(t *testing.T) {
_, _, cleanup := setupIntegrationTest(t)
defer cleanup()
// 安装并保持文件存在
err := InstallSkill("test-skill", types.PlatformClaude, types.ScopeGlobal)
if err != nil {
t.Fatalf("安装失败: %v", err)
}
// 清理(应该没有孤立记录)
cleaned, err := config.CleanOrphanRecords()
if err != nil {
t.Fatalf("清理孤立记录失败: %v", err)
}
if len(cleaned) != 0 {
t.Errorf("不应该有孤立记录被清理,实际清理了 %d 个", len(cleaned))
}
// 验证记录仍存在
record, _ := config.FindInstallRecord(types.ItemTypeSkill, "test-skill", types.PlatformClaude, types.ScopeGlobal)
if record == nil {
t.Error("记录不应该被清理")
}
}
// ============================================================
// 18.8 测试 Claude Code 平台安装
// ============================================================
func TestInstall_ClaudePlatform_Skill(t *testing.T) {
tmpDir, _, cleanup := setupIntegrationTest(t)
defer cleanup()
// 测试全局安装
err := InstallSkill("test-skill", types.PlatformClaude, types.ScopeGlobal)
if err != nil {
t.Fatalf("全局安装失败: %v", err)
}
globalPath := filepath.Join(tmpDir, ".claude", "skills", "test-skill")
if _, err := os.Stat(globalPath); os.IsNotExist(err) {
t.Errorf("全局安装路径不正确: %s", globalPath)
}
// 清理后测试项目级安装
UninstallSkill("test-skill", types.PlatformClaude, types.ScopeGlobal)
err = InstallSkill("test-skill", types.PlatformClaude, types.ScopeProject)
if err != nil {
t.Fatalf("项目级安装失败: %v", err)
}
projectPath := filepath.Join(tmpDir, ".claude", "skills", "test-skill")
if _, err := os.Stat(projectPath); os.IsNotExist(err) {
t.Errorf("项目级安装路径不正确: %s", projectPath)
}
}
func TestInstall_ClaudePlatform_Command(t *testing.T) {
tmpDir, _, cleanup := setupIntegrationTest(t)
defer cleanup()
err := InstallCommand("test-cmd", types.PlatformClaude, types.ScopeGlobal)
if err != nil {
t.Fatalf("安装失败: %v", err)
}
// 验证目录结构保持不变
cmdPath := filepath.Join(tmpDir, ".claude", "commands", "test-cmd")
if _, err := os.Stat(cmdPath); os.IsNotExist(err) {
t.Errorf("命令组目录不存在: %s", cmdPath)
}
// 验证原始文件名保持不变
if _, err := os.Stat(filepath.Join(cmdPath, "init.md")); os.IsNotExist(err) {
t.Error("init.md 应该存在(保持原始文件名)")
}
if _, err := os.Stat(filepath.Join(cmdPath, "run.md")); os.IsNotExist(err) {
t.Error("run.md 应该存在(保持原始文件名)")
}
}
// ============================================================
// 18.9 测试 OpenCode 平台安装
// ============================================================
func TestInstall_OpenCodePlatform_Skill(t *testing.T) {
tmpDir, _, cleanup := setupIntegrationTest(t)
defer cleanup()
// 测试全局安装
err := InstallSkill("test-skill", types.PlatformOpenCode, types.ScopeGlobal)
if err != nil {
t.Fatalf("全局安装失败: %v", err)
}
globalPath := filepath.Join(tmpDir, ".config", "opencode", "skills", "test-skill")
if _, err := os.Stat(globalPath); os.IsNotExist(err) {
t.Errorf("全局安装路径不正确: %s", globalPath)
}
// 清理后测试项目级安装
UninstallSkill("test-skill", types.PlatformOpenCode, types.ScopeGlobal)
err = InstallSkill("test-skill", types.PlatformOpenCode, types.ScopeProject)
if err != nil {
t.Fatalf("项目级安装失败: %v", err)
}
projectPath := filepath.Join(tmpDir, ".opencode", "skills", "test-skill")
if _, err := os.Stat(projectPath); os.IsNotExist(err) {
t.Errorf("项目级安装路径不正确: %s", projectPath)
}
}
func TestInstall_OpenCodePlatform_Command_Flattening(t *testing.T) {
tmpDir, _, cleanup := setupIntegrationTest(t)
defer cleanup()
// 全局安装
err := InstallCommand("test-cmd", types.PlatformOpenCode, types.ScopeGlobal)
if err != nil {
t.Fatalf("安装失败: %v", err)
}
// 验证扁平化路径
cmdPath := filepath.Join(tmpDir, ".config", "opencode", "commands")
if _, err := os.Stat(cmdPath); os.IsNotExist(err) {
t.Fatalf("命令目录不存在: %s", cmdPath)
}
// 验证文件名已扁平化: <group>-<action>.md
flattenedInit := filepath.Join(cmdPath, "test-cmd-init.md")
flattenedRun := filepath.Join(cmdPath, "test-cmd-run.md")
if _, err := os.Stat(flattenedInit); os.IsNotExist(err) {
t.Errorf("扁平化文件 test-cmd-init.md 不存在")
}
if _, err := os.Stat(flattenedRun); os.IsNotExist(err) {
t.Errorf("扁平化文件 test-cmd-run.md 不存在")
}
// 验证原始文件名不存在
if _, err := os.Stat(filepath.Join(cmdPath, "init.md")); !os.IsNotExist(err) {
t.Error("原始文件名 init.md 不应该存在")
}
}
func TestInstall_OpenCodePlatform_Command_ProjectScope(t *testing.T) {
tmpDir, _, cleanup := setupIntegrationTest(t)
defer cleanup()
// 项目级安装
err := InstallCommand("test-cmd", types.PlatformOpenCode, types.ScopeProject)
if err != nil {
t.Fatalf("安装失败: %v", err)
}
// 验证项目级路径
cmdPath := filepath.Join(tmpDir, ".opencode", "commands")
if _, err := os.Stat(cmdPath); os.IsNotExist(err) {
t.Fatalf("命令目录不存在: %s", cmdPath)
}
// 验证扁平化
flattenedInit := filepath.Join(cmdPath, "test-cmd-init.md")
if _, err := os.Stat(flattenedInit); os.IsNotExist(err) {
t.Errorf("扁平化文件 test-cmd-init.md 不存在")
}
}
// ============================================================
// 额外测试:多 skill 安装和边界情况
// ============================================================
func TestInstallMultipleSkills(t *testing.T) {
tmpDir, _, cleanup := setupIntegrationTest(t)
defer cleanup()
// 安装两个 skill
err := InstallSkill("test-skill", types.PlatformClaude, types.ScopeGlobal)
if err != nil {
t.Fatalf("安装 test-skill 失败: %v", err)
}
err = InstallSkill("test-skill-2", types.PlatformClaude, types.ScopeGlobal)
if err != nil {
t.Fatalf("安装 test-skill-2 失败: %v", err)
}
// 验证两个都存在
skill1 := filepath.Join(tmpDir, ".claude", "skills", "test-skill")
skill2 := filepath.Join(tmpDir, ".claude", "skills", "test-skill-2")
if _, err := os.Stat(skill1); os.IsNotExist(err) {
t.Error("test-skill 应该存在")
}
if _, err := os.Stat(skill2); os.IsNotExist(err) {
t.Error("test-skill-2 应该存在")
}
// 验证两个记录都存在
cfg, _ := config.LoadInstallConfig()
if len(cfg.Installations) != 2 {
t.Errorf("应该有 2 个安装记录,实际有 %d 个", len(cfg.Installations))
}
}
func TestInstallSkill_NotFound(t *testing.T) {
_, _, cleanup := setupIntegrationTest(t)
defer cleanup()
err := InstallSkill("nonexistent-skill", types.PlatformClaude, types.ScopeGlobal)
if err == nil {
t.Error("安装不存在的 skill 应该失败")
}
if !strings.Contains(err.Error(), "未在任何仓库中找到") {
t.Errorf("错误信息应该包含 '未在任何仓库中找到': %v", err)
}
}
func TestInstallCommand_NotFound(t *testing.T) {
_, _, cleanup := setupIntegrationTest(t)
defer cleanup()
err := InstallCommand("nonexistent-cmd", types.PlatformClaude, types.ScopeGlobal)
if err == nil {
t.Error("安装不存在的 command 应该失败")
}
if !strings.Contains(err.Error(), "未在任何仓库中找到") {
t.Errorf("错误信息应该包含 '未在任何仓库中找到': %v", err)
}
}
func TestStagingIntegrityVerification(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "skillmgr-integrity-test-*")
if err != nil {
t.Fatalf("创建临时目录失败: %v", err)
}
defer os.RemoveAll(tmpDir)
// 创建多个源文件
srcDir := filepath.Join(tmpDir, "src")
os.MkdirAll(srcDir, 0755)
os.WriteFile(filepath.Join(srcDir, "file1.md"), []byte("content1"), 0644)
os.WriteFile(filepath.Join(srcDir, "file2.md"), []byte("content2"), 0644)
os.WriteFile(filepath.Join(srcDir, "file3.md"), []byte("content3"), 0644)
targetDir := filepath.Join(tmpDir, "target")
fileMap := map[string]string{
filepath.Join(srcDir, "file1.md"): filepath.Join(targetDir, "file1.md"),
filepath.Join(srcDir, "file2.md"): filepath.Join(targetDir, "file2.md"),
filepath.Join(srcDir, "file3.md"): filepath.Join(targetDir, "file3.md"),
}
tx, err := NewTransaction(targetDir, fileMap)
if err != nil {
t.Fatalf("NewTransaction 失败: %v", err)
}
defer tx.Rollback()
// Stage 应该成功并验证完整性
if err := tx.Stage(); err != nil {
t.Fatalf("Stage 失败: %v", err)
}
// 手动验证 staging 目录中有 3 个文件
count := 0
filepath.Walk(tx.stagingDir, func(path string, info os.FileInfo, err error) error {
if !info.IsDir() {
count++
}
return nil
})
if count != 3 {
t.Errorf("Staging 目录应该有 3 个文件,实际有 %d 个", count)
}
}

View File

@@ -1,134 +0,0 @@
package installer
import (
"fmt"
"os"
"path/filepath"
"skillmgr/pkg/fileutil"
)
// Transaction 事务性安装
type Transaction struct {
stagingDir string
targetDir string
fileMap map[string]string // source → dest
}
// NewTransaction 创建事务
// 在系统临时目录创建 staging 目录
func NewTransaction(targetDir string, fileMap map[string]string) (*Transaction, error) {
// 在系统临时目录创建 staging 目录
stagingDir, err := os.MkdirTemp("", "skillmgr-*")
if err != nil {
return nil, fmt.Errorf("创建 staging 目录失败: %w", err)
}
return &Transaction{
stagingDir: stagingDir,
targetDir: targetDir,
fileMap: fileMap,
}, nil
}
// Stage 阶段:复制文件到 staging 目录
func (t *Transaction) Stage() error {
stagedCount := 0
for src, dest := range t.fileMap {
// 计算相对于 targetDir 的路径
relPath, err := filepath.Rel(t.targetDir, dest)
if err != nil {
return fmt.Errorf("计算相对路径失败: %w", err)
}
stagingDest := filepath.Join(t.stagingDir, relPath)
// 确保目标目录存在
if err := os.MkdirAll(filepath.Dir(stagingDest), 0755); err != nil {
return fmt.Errorf("创建 staging 子目录失败: %w", err)
}
// 复制文件
if err := fileutil.CopyFile(src, stagingDest); err != nil {
return fmt.Errorf("复制文件到 staging 失败: %w", err)
}
stagedCount++
}
// 验证 staging 完整性:检查文件数量是否与预期一致
if err := t.verifyStagingIntegrity(stagedCount); err != nil {
return fmt.Errorf("staging 验证失败: %w", err)
}
return nil
}
// verifyStagingIntegrity 验证 staging 目录中的文件数量
func (t *Transaction) verifyStagingIntegrity(expectedCount int) error {
actualCount := 0
err := filepath.Walk(t.stagingDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
actualCount++
}
return nil
})
if err != nil {
return fmt.Errorf("遍历 staging 目录失败: %w", err)
}
if actualCount != expectedCount {
return fmt.Errorf("文件数量不匹配: 预期 %d 个文件,实际 %d 个", expectedCount, actualCount)
}
return nil
}
// Commit 提交:将 staging 目录移动到目标位置
func (t *Transaction) Commit() error {
// 确保目标目录的父目录存在
if err := os.MkdirAll(filepath.Dir(t.targetDir), 0755); err != nil {
return fmt.Errorf("创建目标父目录失败: %w", err)
}
// 如果目标目录已存在,先删除(已经过用户确认)
if _, err := os.Stat(t.targetDir); err == nil {
if err := os.RemoveAll(t.targetDir); err != nil {
return fmt.Errorf("删除已存在的目标目录失败: %w", err)
}
}
// 尝试原子性移动 staging 目录到目标位置
if err := os.Rename(t.stagingDir, t.targetDir); err != nil {
// 如果跨文件系统Rename 会失败,改用复制
// 使用 defer 确保 staging 目录被清理
defer os.RemoveAll(t.stagingDir)
if err := fileutil.CopyDir(t.stagingDir, t.targetDir); err != nil {
return fmt.Errorf("复制 staging 到目标失败: %w", err)
}
}
return nil
}
// Rollback 回滚:清理 staging 目录
func (t *Transaction) Rollback() {
if t.stagingDir != "" {
os.RemoveAll(t.stagingDir)
}
}
// StagingDir 获取 staging 目录路径
func (t *Transaction) StagingDir() string {
return t.stagingDir
}
// TargetDir 获取目标目录路径
func (t *Transaction) TargetDir() string {
return t.targetDir
}

View File

@@ -1,100 +0,0 @@
package installer
import (
"os"
"path/filepath"
"testing"
)
func TestTransaction_StageAndCommit(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "skillmgr-tx-test-*")
if err != nil {
t.Fatalf("创建临时目录失败: %v", err)
}
defer os.RemoveAll(tmpDir)
// 创建源文件
srcDir := filepath.Join(tmpDir, "src")
os.MkdirAll(srcDir, 0755)
os.WriteFile(filepath.Join(srcDir, "test.md"), []byte("test content"), 0644)
// 创建文件映射
targetDir := filepath.Join(tmpDir, "target")
fileMap := map[string]string{
filepath.Join(srcDir, "test.md"): filepath.Join(targetDir, "test.md"),
}
tx, err := NewTransaction(targetDir, fileMap)
if err != nil {
t.Fatalf("NewTransaction 失败: %v", err)
}
// Stage
if err := tx.Stage(); err != nil {
t.Fatalf("Stage 失败: %v", err)
}
// 验证 staging 目录存在
if _, err := os.Stat(tx.stagingDir); os.IsNotExist(err) {
t.Error("Staging 目录应该存在")
}
// Commit
if err := tx.Commit(); err != nil {
t.Fatalf("Commit 失败: %v", err)
}
// 验证目标文件存在
if _, err := os.Stat(filepath.Join(targetDir, "test.md")); os.IsNotExist(err) {
t.Error("目标文件应该存在")
}
// 验证 staging 目录已清理
if _, err := os.Stat(tx.stagingDir); !os.IsNotExist(err) {
t.Error("Staging 目录应该被清理")
}
}
func TestTransaction_Rollback(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "skillmgr-tx-test-*")
if err != nil {
t.Fatalf("创建临时目录失败: %v", err)
}
defer os.RemoveAll(tmpDir)
// 创建源文件
srcDir := filepath.Join(tmpDir, "src")
os.MkdirAll(srcDir, 0755)
os.WriteFile(filepath.Join(srcDir, "test.md"), []byte("test content"), 0644)
// 创建文件映射
targetDir := filepath.Join(tmpDir, "target")
fileMap := map[string]string{
filepath.Join(srcDir, "test.md"): filepath.Join(targetDir, "test.md"),
}
tx, err := NewTransaction(targetDir, fileMap)
if err != nil {
t.Fatalf("NewTransaction 失败: %v", err)
}
// Stage
if err := tx.Stage(); err != nil {
t.Fatalf("Stage 失败: %v", err)
}
stagingDir := tx.stagingDir
// Rollback (no return value)
tx.Rollback()
// 验证 staging 目录已清理
if _, err := os.Stat(stagingDir); !os.IsNotExist(err) {
t.Error("Staging 目录应该被清理")
}
// 验证目标目录不存在
if _, err := os.Stat(targetDir); !os.IsNotExist(err) {
t.Error("目标目录不应该存在")
}
}

View File

@@ -1,88 +0,0 @@
package installer
import (
"fmt"
"os"
"path/filepath"
"skillmgr/internal/config"
"skillmgr/internal/types"
)
// UninstallSkill 卸载 skill
func UninstallSkill(name string, platform types.Platform, scope types.Scope) error {
// 查找记录
record, err := config.FindInstallRecord(types.ItemTypeSkill, name, platform, scope)
if err != nil {
return err
}
if record == nil {
return fmt.Errorf("未找到 skill '%s' 的安装记录", name)
}
// 删除目录
if _, err := os.Stat(record.InstallPath); err == nil {
if err := os.RemoveAll(record.InstallPath); err != nil {
return fmt.Errorf("删除目录失败: %w", err)
}
}
// 移除记录
if err := config.RemoveInstallRecord(types.ItemTypeSkill, name, platform, scope); err != nil {
return fmt.Errorf("移除安装记录失败: %w", err)
}
fmt.Printf("✓ Skill '%s' 已卸载\n", name)
return nil
}
// UninstallCommand 卸载 command
func UninstallCommand(name string, platform types.Platform, scope types.Scope) error {
// 查找记录
record, err := config.FindInstallRecord(types.ItemTypeCommand, name, platform, scope)
if err != nil {
return err
}
if record == nil {
return fmt.Errorf("未找到 command '%s' 的安装记录", name)
}
// 根据平台决定删除策略
if platform == types.PlatformClaude {
// Claude: 删除整个命令组目录
if _, err := os.Stat(record.InstallPath); err == nil {
if err := os.RemoveAll(record.InstallPath); err != nil {
return fmt.Errorf("删除目录失败: %w", err)
}
}
} else if platform == types.PlatformOpenCode {
// OpenCode: 删除扁平化的命令文件 (<group>-*.md)
// InstallPath 是 .opencode/command/ 目录
// 需要删除所有 <name>-*.md 文件
entries, err := os.ReadDir(record.InstallPath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("读取目录失败: %w", err)
}
for _, entry := range entries {
if !entry.IsDir() {
// 检查文件名是否以 <name>- 开头
fileName := entry.Name()
prefix := name + "-"
if len(fileName) > len(prefix) && fileName[:len(prefix)] == prefix {
filePath := filepath.Join(record.InstallPath, fileName)
if err := os.Remove(filePath); err != nil {
return fmt.Errorf("删除文件 %s 失败: %w", fileName, err)
}
}
}
}
}
// 移除记录
if err := config.RemoveInstallRecord(types.ItemTypeCommand, name, platform, scope); err != nil {
return fmt.Errorf("移除安装记录失败: %w", err)
}
fmt.Printf("✓ Command '%s' 已卸载\n", name)
return nil
}

View File

@@ -1,39 +0,0 @@
package prompt
import (
"bufio"
"fmt"
"io"
"os"
"strings"
)
// ConfirmWithReader 询问用户确认y/n支持自定义输入源
// 用于测试时注入 mock 输入
func ConfirmWithReader(message string, reader io.Reader) bool {
r := bufio.NewReader(reader)
for {
fmt.Printf("%s [y/N]: ", message)
response, err := r.ReadString('\n')
if err != nil {
return false
}
response = strings.TrimSpace(strings.ToLower(response))
if response == "y" || response == "yes" {
return true
} else if response == "n" || response == "no" || response == "" {
return false
}
fmt.Println("请输入 'y' 或 'n'")
}
}
// Confirm 询问用户确认y/n
// 使用标准输入
func Confirm(message string) bool {
return ConfirmWithReader(message, os.Stdin)
}

View File

@@ -1,30 +0,0 @@
package prompt
import (
"strings"
"testing"
)
func TestConfirmWithReader_Yes(t *testing.T) {
tests := []string{"y", "Y", "yes", "YES", "Yes"}
for _, input := range tests {
reader := strings.NewReader(input + "\n")
result := ConfirmWithReader("测试?", reader)
if !result {
t.Errorf("输入 '%s' 应返回 true", input)
}
}
}
func TestConfirmWithReader_No(t *testing.T) {
tests := []string{"n", "N", "no", "NO", "No", "", "anything"}
for _, input := range tests {
reader := strings.NewReader(input + "\n")
result := ConfirmWithReader("测试?", reader)
if result {
t.Errorf("输入 '%s' 应返回 false", input)
}
}
}

View File

@@ -1,109 +0,0 @@
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
}

View File

@@ -1,24 +0,0 @@
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)
}
}
}

View File

@@ -1,191 +0,0 @@
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
}

View File

@@ -1,184 +0,0 @@
package testutil
import (
"os"
"os/exec"
"path/filepath"
"testing"
"skillmgr/pkg/fileutil"
)
// SetupTestEnv 设置测试环境
// 返回临时目录路径和清理函数
func SetupTestEnv(t *testing.T) (string, func()) {
t.Helper()
tmpDir, err := os.MkdirTemp("", "skillmgr-test-*")
if err != nil {
t.Fatalf("创建临时目录失败: %v", err)
}
// 设置环境变量
os.Setenv("SKILLMGR_TEST_ROOT", tmpDir)
os.Setenv("SKILLMGR_TEST_BASE", tmpDir)
cleanup := func() {
os.Unsetenv("SKILLMGR_TEST_ROOT")
os.Unsetenv("SKILLMGR_TEST_BASE")
os.RemoveAll(tmpDir)
}
return tmpDir, cleanup
}
// SetupTestRepo 创建一个临时 git 仓库
// 返回仓库路径
func SetupTestRepo(t *testing.T, baseDir string) string {
t.Helper()
repoDir := filepath.Join(baseDir, "test-repo")
if err := os.MkdirAll(repoDir, 0755); err != nil {
t.Fatalf("创建仓库目录失败: %v", err)
}
// 初始化 git 仓库
cmd := exec.Command("git", "init")
cmd.Dir = repoDir
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git init 失败: %v\n%s", err, output)
}
// 配置 git user测试用
configCmds := [][]string{
{"git", "config", "user.email", "test@example.com"},
{"git", "config", "user.name", "Test User"},
}
for _, args := range configCmds {
cmd := exec.Command(args[0], args[1:]...)
cmd.Dir = repoDir
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git config 失败: %v\n%s", err, output)
}
}
return repoDir
}
// CopyFixtureRepo 复制 fixture 仓库并初始化 git
func CopyFixtureRepo(t *testing.T, fixtureDir, destDir string) string {
t.Helper()
repoDir := filepath.Join(destDir, filepath.Base(fixtureDir))
if err := fileutil.CopyDir(fixtureDir, repoDir); err != nil {
t.Fatalf("复制 fixture 仓库失败: %v", err)
}
// 初始化 git 仓库
cmd := exec.Command("git", "init")
cmd.Dir = repoDir
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git init 失败: %v\n%s", err, output)
}
// 配置 git user测试用
configCmds := [][]string{
{"git", "config", "user.email", "test@example.com"},
{"git", "config", "user.name", "Test User"},
}
for _, args := range configCmds {
cmd := exec.Command(args[0], args[1:]...)
cmd.Dir = repoDir
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git config 失败: %v\n%s", err, output)
}
}
// 添加并提交
addCmd := exec.Command("git", "add", ".")
addCmd.Dir = repoDir
if output, err := addCmd.CombinedOutput(); err != nil {
t.Fatalf("git add 失败: %v\n%s", err, output)
}
commitCmd := exec.Command("git", "commit", "-m", "Initial commit")
commitCmd.Dir = repoDir
if output, err := commitCmd.CombinedOutput(); err != nil {
t.Fatalf("git commit 失败: %v\n%s", err, output)
}
return repoDir
}
// GetFixturePath 获取 fixture 目录路径
func GetFixturePath(t *testing.T) string {
t.Helper()
// 尝试几个可能的位置
candidates := []string{
"testdata/fixtures",
"../testdata/fixtures",
"../../testdata/fixtures",
"../../../testdata/fixtures",
}
for _, candidate := range candidates {
if _, err := os.Stat(candidate); err == nil {
abs, _ := filepath.Abs(candidate)
return abs
}
}
// 从工作目录向上查找
wd, _ := os.Getwd()
for {
candidate := filepath.Join(wd, "testdata", "fixtures")
if _, err := os.Stat(candidate); err == nil {
return candidate
}
parent := filepath.Dir(wd)
if parent == wd {
break
}
wd = parent
}
t.Fatalf("无法找到 fixtures 目录")
return ""
}
// CreateTestSkill 在目录中创建测试 skill
func CreateTestSkill(t *testing.T, baseDir, name string) string {
t.Helper()
skillDir := filepath.Join(baseDir, "skills", name)
if err := os.MkdirAll(skillDir, 0755); err != nil {
t.Fatalf("创建 skill 目录失败: %v", err)
}
content := []byte("# " + name + "\n\nTest skill.\n")
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), content, 0644); err != nil {
t.Fatalf("创建 SKILL.md 失败: %v", err)
}
return skillDir
}
// CreateTestCommand 在目录中创建测试命令组
func CreateTestCommand(t *testing.T, baseDir, groupName string, files []string) string {
t.Helper()
cmdDir := filepath.Join(baseDir, "commands", groupName)
if err := os.MkdirAll(cmdDir, 0755); err != nil {
t.Fatalf("创建 command 目录失败: %v", err)
}
for _, file := range files {
content := []byte("# " + file + "\n\nTest command.\n")
if err := os.WriteFile(filepath.Join(cmdDir, file), content, 0644); err != nil {
t.Fatalf("创建 %s 失败: %v", file, err)
}
}
return cmdDir
}

View File

@@ -1,71 +0,0 @@
package types
import "time"
// Platform 平台类型
type Platform string
const (
PlatformClaude Platform = "claude"
PlatformOpenCode Platform = "opencode"
)
// ItemType 安装项类型
type ItemType string
const (
ItemTypeSkill ItemType = "skill"
ItemTypeCommand ItemType = "command"
)
// Scope 安装作用域
type Scope string
const (
ScopeGlobal Scope = "global"
ScopeProject Scope = "project"
)
// Repository 源仓库配置
type Repository struct {
Name string `json:"name"`
URL string `json:"url"`
Branch string `json:"branch"`
AddedAt time.Time `json:"added_at"`
}
// RepositoryConfig 仓库配置文件结构
type RepositoryConfig struct {
Repositories []Repository `json:"repositories"`
}
// InstallRecord 安装记录
type InstallRecord struct {
Type ItemType `json:"type"`
Name string `json:"name"`
SourceRepo string `json:"source_repo"`
Platform Platform `json:"platform"`
Scope Scope `json:"scope"`
InstallPath string `json:"install_path"`
InstalledAt time.Time `json:"installed_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// InstallConfig 安装配置文件结构
type InstallConfig struct {
Installations []InstallRecord `json:"installations"`
}
// SkillMetadata skill 元数据(从 SKILL.md frontmatter 解析)
type SkillMetadata struct {
Name string
Description string
SourceRepo string
}
// CommandGroup 命令组信息
type CommandGroup struct {
Name string // 命令组名称(目录名)
Files []string // 命令文件列表
SourceRepo string // 来源仓库
}

View File

@@ -1,62 +0,0 @@
package fileutil
import (
"io"
"os"
"path/filepath"
)
// CopyFile 复制文件,保留权限
func CopyFile(src, dst string) error {
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer srcFile.Close()
// 确保目标目录存在
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
dstFile, err := os.Create(dst)
if err != nil {
return err
}
defer dstFile.Close()
if _, err := io.Copy(dstFile, srcFile); err != nil {
return err
}
// 保留文件权限移除特殊权限位SETUID/SETGID/STICKY
srcInfo, err := os.Stat(src)
if err != nil {
return err
}
// 只保留标准权限位,移除特殊权限位
mode := srcInfo.Mode() & os.ModePerm
return os.Chmod(dst, mode)
}
// CopyDir 递归复制目录
func CopyDir(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(src, path)
if err != nil {
return err
}
dstPath := filepath.Join(dst, relPath)
if info.IsDir() {
return os.MkdirAll(dstPath, info.Mode())
}
return CopyFile(path, dstPath)
})
}

View File

@@ -1,100 +0,0 @@
package fileutil
import (
"os"
"path/filepath"
"testing"
)
func TestCopyFile(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "skillmgr-fileutil-test-*")
if err != nil {
t.Fatalf("创建临时目录失败: %v", err)
}
defer os.RemoveAll(tmpDir)
// 创建源文件
srcFile := filepath.Join(tmpDir, "src.txt")
content := []byte("test content")
if err := os.WriteFile(srcFile, content, 0644); err != nil {
t.Fatalf("创建源文件失败: %v", err)
}
// 复制
destFile := filepath.Join(tmpDir, "dest.txt")
if err := CopyFile(srcFile, destFile); err != nil {
t.Fatalf("CopyFile 失败: %v", err)
}
// 验证
destContent, err := os.ReadFile(destFile)
if err != nil {
t.Fatalf("读取目标文件失败: %v", err)
}
if string(destContent) != string(content) {
t.Errorf("内容不匹配:期望 %s得到 %s", content, destContent)
}
}
func TestCopyDir(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "skillmgr-fileutil-test-*")
if err != nil {
t.Fatalf("创建临时目录失败: %v", err)
}
defer os.RemoveAll(tmpDir)
// 创建源目录结构
srcDir := filepath.Join(tmpDir, "src")
os.MkdirAll(filepath.Join(srcDir, "subdir"), 0755)
os.WriteFile(filepath.Join(srcDir, "file1.txt"), []byte("file1"), 0644)
os.WriteFile(filepath.Join(srcDir, "subdir", "file2.txt"), []byte("file2"), 0644)
// 复制
destDir := filepath.Join(tmpDir, "dest")
if err := CopyDir(srcDir, destDir); err != nil {
t.Fatalf("CopyDir 失败: %v", err)
}
// 验证文件存在
files := []string{
filepath.Join(destDir, "file1.txt"),
filepath.Join(destDir, "subdir", "file2.txt"),
}
for _, file := range files {
if _, err := os.Stat(file); os.IsNotExist(err) {
t.Errorf("文件应该存在: %s", file)
}
}
}
func TestCopyFile_PreservePermissions(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "skillmgr-fileutil-test-*")
if err != nil {
t.Fatalf("创建临时目录失败: %v", err)
}
defer os.RemoveAll(tmpDir)
// 创建可执行文件
srcFile := filepath.Join(tmpDir, "src.sh")
if err := os.WriteFile(srcFile, []byte("#!/bin/bash"), 0755); err != nil {
t.Fatalf("创建源文件失败: %v", err)
}
// 复制
destFile := filepath.Join(tmpDir, "dest.sh")
if err := CopyFile(srcFile, destFile); err != nil {
t.Fatalf("CopyFile 失败: %v", err)
}
// 验证权限
info, err := os.Stat(destFile)
if err != nil {
t.Fatalf("获取文件信息失败: %v", err)
}
if info.Mode().Perm() != 0755 {
t.Errorf("权限不匹配:期望 0755得到 %o", info.Mode().Perm())
}
}

View File

@@ -1,43 +0,0 @@
#!/bin/bash
# sandbox.sh - 手动测试沙盒环境
set -e
cd "$(dirname "$0")/.."
# 创建沙盒目录
SANDBOX_DIR="${1:-/tmp/skillmgr-sandbox}"
mkdir -p "$SANDBOX_DIR"
echo "=== 沙盒环境 ==="
echo "目录: $SANDBOX_DIR"
echo ""
# 设置环境变量
export SKILLMGR_TEST_ROOT="$SANDBOX_DIR/config"
export SKILLMGR_TEST_BASE="$SANDBOX_DIR/install"
# 确保 skillmgr 已构建
if [ ! -f "bin/skillmgr" ]; then
echo "构建 skillmgr..."
go build -o bin/skillmgr ./cmd/skillmgr
fi
echo "环境变量已设置:"
echo " SKILLMGR_TEST_ROOT=$SKILLMGR_TEST_ROOT"
echo " SKILLMGR_TEST_BASE=$SKILLMGR_TEST_BASE"
echo ""
echo "可执行文件: $(pwd)/bin/skillmgr"
echo ""
echo "示例命令:"
echo " ./bin/skillmgr --help"
echo " ./bin/skillmgr add https://github.com/example/skills.git --name example"
echo " ./bin/skillmgr repos"
echo ""
echo "清理沙盒:"
echo " rm -rf $SANDBOX_DIR"
echo ""
# 进入子 shell
echo "进入沙盒 shell (exit 退出)..."
$SHELL

View File

@@ -1,26 +0,0 @@
#!/bin/bash
# test.sh - 运行测试
set -e
cd "$(dirname "$0")/.."
# 创建临时测试目录
TEST_DIR=$(mktemp -d)
trap "rm -rf $TEST_DIR" EXIT
# 设置测试环境变量
export SKILLMGR_TEST_ROOT="$TEST_DIR/config"
export SKILLMGR_TEST_BASE="$TEST_DIR/install"
echo "=== 测试环境 ==="
echo "SKILLMGR_TEST_ROOT: $SKILLMGR_TEST_ROOT"
echo "SKILLMGR_TEST_BASE: $SKILLMGR_TEST_BASE"
echo ""
# 运行测试
echo "=== 运行测试 ==="
go test -v ./...
echo ""
echo "=== 测试完成 ==="

View File

@@ -1,9 +0,0 @@
# Init Command
Test command for initialization.
## Usage
```
/test-cmd:init
```

View File

@@ -1,9 +0,0 @@
# Run Command
Test command for running.
## Usage
```
/test-cmd:run
```

View File

@@ -1,7 +0,0 @@
# Test Skill 2
Second test skill for testing multiple skills.
## Description
Another test skill fixture.

View File

@@ -1,7 +0,0 @@
# Test Skill
This is a test skill for unit and integration tests.
## Description
A simple skill that does nothing but serves as a test fixture.

134
publish.sh Normal file
View File

@@ -0,0 +1,134 @@
#!/bin/bash
#
# Skill 发布脚本
#
# 使用方式:
# ./publish.sh <skill-name>
#
# 示例:
# ./publish.sh lyxy-kb
# ./publish.sh lyxy-reader-office
#
set -e
# 获取脚本所在目录
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
# 配置
TARGET_REPO_URL="https://github.com/lanyuanxiaoyao/skills.git"
TEMP_DIR_BASE="${TMPDIR:-/tmp}/lyxy-skill-publish"
# 颜色输出
GREEN='\033[0;32m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# 打印信息
info() {
echo -e "${GREEN}>>>${NC} $1"
}
error() {
echo -e "${RED}错误:${NC} $1"
}
# 显示使用说明
show_usage() {
echo "使用方式:"
echo " $0 <skill-name>"
echo ""
echo "可用的 skills:"
for dir in skills/*/; do
if [ -d "$dir" ]; then
name="$(basename "$dir")"
echo " - $name"
fi
done
echo ""
echo "示例:"
echo " $0 lyxy-kb"
echo " $0 lyxy-reader-office"
exit 1
}
# 检查参数
if [ $# -eq 0 ]; then
error "缺少 skill 名称参数"
show_usage
fi
SKILL_NAME="$1"
SKILL_DIR="$SCRIPT_DIR/skills/$SKILL_NAME"
TARGET_PATH="skills/$SKILL_NAME"
# 检查 skill 目录是否存在
if [ ! -d "$SKILL_DIR" ]; then
error "Skill 目录不存在: $SKILL_DIR"
echo ""
echo "可用的 skills:"
for dir in skills/*/; do
if [ -d "$dir" ]; then
name="$(basename "$dir")"
echo " - $name"
fi
done
exit 1
fi
info "发布 Skill: $SKILL_NAME"
info "目标仓库: $TARGET_REPO_URL"
info "目标路径: $TARGET_PATH"
echo ""
# 创建临时目录
TIMESTAMP=$(date +%s)
TEMP_DIR="$TEMP_DIR_BASE-$TIMESTAMP"
mkdir -p "$TEMP_DIR"
# 清理函数
cleanup() {
if [ -d "$TEMP_DIR" ]; then
rm -rf "$TEMP_DIR"
fi
}
trap cleanup EXIT
# Clone 仓库
info "[1/4] Clone 仓库..."
REPO_DIR="$TEMP_DIR/skills-repo"
git clone --depth 1 "$TARGET_REPO_URL" "$REPO_DIR"
# 清空目标目录
info "[2/4] 清空目标目录..."
TARGET_DIR="$REPO_DIR/$TARGET_PATH"
if [ -d "$TARGET_DIR" ]; then
rm -rf "$TARGET_DIR"
fi
mkdir -p "$TARGET_DIR"
# 复制内容
info "[3/4] 复制 Skill 文件..."
# 复制除了 __pycache__ 之外的所有内容
for item in "$SKILL_DIR"/*; do
basename_item="$(basename "$item")"
# 跳过 __pycache__
if [ "$basename_item" = "__pycache__" ]; then
continue
fi
if [ -d "$item" ]; then
cp -r "$item" "$TARGET_DIR/"
else
cp "$item" "$TARGET_DIR/"
fi
done
# 提交并推送
info "[4/4] 提交并推送到 GitHub..."
cd "$REPO_DIR"
git add .
git commit -m "publish: $SKILL_NAME"
git push
info "发布成功!"