移除技能
This commit is contained in:
@@ -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 文件内容可在后续问答中复用。
|
|
||||||
|
|
||||||
会话的退出由用户自然决定(开启新话题或新会话),不主动终止问答模式。
|
|
||||||
@@ -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>` 进行知识问答
|
|
||||||
@@ -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>` 解析入库
|
|
||||||
@@ -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
29
manager/.gitignore
vendored
@@ -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/
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
Execute()
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
@@ -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=
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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("期望无效平台返回错误")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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("目标目录不应该存在")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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 // 来源仓库
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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 "=== 测试完成 ==="
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# Init Command
|
|
||||||
|
|
||||||
Test command for initialization.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```
|
|
||||||
/test-cmd:init
|
|
||||||
```
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# Run Command
|
|
||||||
|
|
||||||
Test command for running.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```
|
|
||||||
/test-cmd:run
|
|
||||||
```
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# Test Skill 2
|
|
||||||
|
|
||||||
Second test skill for testing multiple skills.
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
Another test skill fixture.
|
|
||||||
@@ -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
134
publish.sh
Normal 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 "发布成功!"
|
||||||
Reference in New Issue
Block a user