1
0

Compare commits

...

5 Commits

Author SHA1 Message Date
2d2404e3cf 移除技能 2026-03-18 22:28:57 +08:00
3b27d2cfd4 移除未开发好的技能 2026-03-18 22:28:40 +08:00
6b4fcf2647 创建 lyxy-reader-html skill
- 新增 skill: lyxy-reader-html,用于解析 HTML 文件和 URL 网页内容
- 支持 URL 下载(pyppeteer → selenium → httpx → urllib 优先级回退)
- 支持 HTML 解析(trafilatura → domscribe → MarkItDown → html2text 优先级回退)
- 支持查询功能:全文提取、字数统计、行数统计、标题提取、章节提取、正则搜索
- 新增 spec: html-document-parsing
- 归档 change: create-lyxy-reader-html-skill
2026-03-08 02:02:03 +08:00
0bd9ec8a36 补充mac运行 2026-02-26 18:09:06 +08:00
e35d16a92e 忽略temp目录 2026-02-26 09:06:11 +08:00
81 changed files with 628 additions and 7538 deletions

View File

@@ -4,11 +4,16 @@
"Bash(uv:*)",
"Bash(openspec:*)",
"WebSearch",
"WebFetch(domain:pypi.org)",
"WebFetch(domain:github.com)",
"WebFetch(*)",
"Bash(pip index:*)",
"Bash(pip show:*)",
"Bash(mkdir:*)"
"Bash(mkdir:*)",
"Bash(ls:*)",
"Bash(git:*)",
"mcp__context7__resolve-library-id",
"mcp__context7__query-docs",
"mcp__exa__get_code_context_exa",
"mcp__exa__web_search_exa"
]
}
}

3
.gitignore vendored
View File

@@ -41,4 +41,5 @@ $RECYCLE.BIN/
*.swo
skills/**/test
__pycache__
__pycache__
temp

View File

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

View File

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

View File

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

View File

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

29
manager/.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,109 +0,0 @@
package repo
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"skillmgr/internal/config"
)
// URLToPathName 将 URL 转换为缓存目录名
// 例如: https://github.com/user/repo.git -> github.com_user_repo
func URLToPathName(url string) string {
clean := strings.TrimPrefix(url, "https://")
clean = strings.TrimPrefix(clean, "http://")
clean = strings.TrimSuffix(clean, ".git")
clean = strings.ReplaceAll(clean, "/", "_")
return clean
}
// CloneOrPull 克隆或更新仓库
// 如果仓库不存在则 clone存在则 pull
func CloneOrPull(url, branch string) (string, error) {
cachePath, err := config.GetCachePath()
if err != nil {
return "", err
}
repoPath := filepath.Join(cachePath, URLToPathName(url))
// 检查是否已存在
if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil {
// 已存在,执行 pull
return repoPath, pullRepo(repoPath, branch)
}
// 不存在,执行 clone
return repoPath, cloneRepo(url, branch, repoPath)
}
// cloneRepo 克隆仓库
func cloneRepo(url, branch, dest string) error {
args := []string{"clone", "--depth", "1"}
if branch != "" {
args = append(args, "--branch", branch)
}
args = append(args, url, dest)
cmd := exec.Command("git", args...)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git clone 失败: %w\n%s", err, output)
}
return nil
}
// pullRepo 更新仓库
func pullRepo(path, branch string) error {
// 先 fetch
fetchCmd := exec.Command("git", "-C", path, "fetch", "origin")
if output, err := fetchCmd.CombinedOutput(); err != nil {
return fmt.Errorf("git fetch 失败: %w\n%s", err, output)
}
// 然后 pull
pullArgs := []string{"-C", path, "pull", "origin"}
if branch != "" {
pullArgs = append(pullArgs, branch)
}
pullCmd := exec.Command("git", pullArgs...)
output, err := pullCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git pull 失败: %w\n%s", err, output)
}
return nil
}
// GetRepoPath 获取仓库缓存路径
func GetRepoPath(url string) (string, error) {
cachePath, err := config.GetCachePath()
if err != nil {
return "", err
}
return filepath.Join(cachePath, URLToPathName(url)), nil
}
// CloneTemporary 克隆临时仓库到临时目录
// 返回临时目录路径和清理函数
func CloneTemporary(url, branch string) (repoPath string, cleanup func(), err error) {
// 创建临时目录
tmpDir, err := os.MkdirTemp("", "skillmgr-temp-*")
if err != nil {
return "", nil, fmt.Errorf("创建临时目录失败: %w", err)
}
cleanup = func() {
os.RemoveAll(tmpDir)
}
// 克隆到临时目录
if err := cloneRepo(url, branch, tmpDir); err != nil {
cleanup()
return "", nil, err
}
return tmpDir, cleanup, nil
}

View File

@@ -1,24 +0,0 @@
package repo
import (
"testing"
)
func TestURLToPathName(t *testing.T) {
tests := []struct {
url string
expected string
}{
{"https://github.com/user/repo.git", "github.com_user_repo"},
{"https://github.com/user/repo", "github.com_user_repo"},
{"http://gitlab.com/org/project.git", "gitlab.com_org_project"},
{"https://github.com/user/my-repo.git", "github.com_user_my-repo"},
}
for _, tc := range tests {
result := URLToPathName(tc.url)
if result != tc.expected {
t.Errorf("URLToPathName(%s): 期望 %s得到 %s", tc.url, tc.expected, result)
}
}
}

View File

@@ -1,191 +0,0 @@
package repo
import (
"fmt"
"os"
"path/filepath"
"skillmgr/internal/config"
"skillmgr/internal/types"
)
// ScanSkills 扫描仓库中的 skills
func ScanSkills(repoPath string) ([]types.SkillMetadata, error) {
skillsPath := filepath.Join(repoPath, "skills")
entries, err := os.ReadDir(skillsPath)
if err != nil {
if os.IsNotExist(err) {
return []types.SkillMetadata{}, nil
}
return nil, err
}
var skills []types.SkillMetadata
for _, entry := range entries {
if !entry.IsDir() {
continue
}
// 检查是否有 SKILL.md
skillFile := filepath.Join(skillsPath, entry.Name(), "SKILL.md")
if _, err := os.Stat(skillFile); err == nil {
skills = append(skills, types.SkillMetadata{
Name: entry.Name(),
})
}
}
return skills, nil
}
// ScanCommands 扫描仓库中的 commands
func ScanCommands(repoPath string) ([]types.CommandGroup, error) {
commandsPath := filepath.Join(repoPath, "commands")
entries, err := os.ReadDir(commandsPath)
if err != nil {
if os.IsNotExist(err) {
return []types.CommandGroup{}, nil
}
return nil, err
}
var groups []types.CommandGroup
for _, entry := range entries {
if !entry.IsDir() {
continue
}
// 列出目录下的 .md 文件
files, err := filepath.Glob(filepath.Join(commandsPath, entry.Name(), "*.md"))
if err != nil {
fmt.Fprintf(os.Stderr, "警告: 无法扫描 %s 下的 markdown 文件: %v\n", entry.Name(), err)
continue
}
var fileNames []string
for _, f := range files {
fileNames = append(fileNames, filepath.Base(f))
}
if len(fileNames) > 0 {
groups = append(groups, types.CommandGroup{
Name: entry.Name(),
Files: fileNames,
})
}
}
return groups, nil
}
// FindSkill 在所有仓库中查找 skill
func FindSkill(name string) (repoPath, skillPath string, repoName string, err error) {
cfg, err := config.LoadRepositoryConfig()
if err != nil {
return "", "", "", err
}
cachePath, err := config.GetCachePath()
if err != nil {
return "", "", "", err
}
for _, repo := range cfg.Repositories {
rp := filepath.Join(cachePath, URLToPathName(repo.URL))
sp := filepath.Join(rp, "skills", name)
if _, err := os.Stat(filepath.Join(sp, "SKILL.md")); err == nil {
return rp, sp, repo.Name, nil
}
}
return "", "", "", fmt.Errorf("skill '%s' 未在任何仓库中找到", name)
}
// FindCommand 在所有仓库中查找 command
func FindCommand(name string) (repoPath, commandPath string, repoName string, err error) {
cfg, err := config.LoadRepositoryConfig()
if err != nil {
return "", "", "", err
}
cachePath, err := config.GetCachePath()
if err != nil {
return "", "", "", err
}
for _, repo := range cfg.Repositories {
rp := filepath.Join(cachePath, URLToPathName(repo.URL))
cp := filepath.Join(rp, "commands", name)
if info, err := os.Stat(cp); err == nil && info.IsDir() {
// 检查目录是否包含 .md 文件
files, _ := filepath.Glob(filepath.Join(cp, "*.md"))
if len(files) > 0 {
return rp, cp, repo.Name, nil
}
// 目录存在但为空,返回特定错误
return "", "", "", fmt.Errorf("command group '%s' 不包含任何命令文件", name)
}
}
return "", "", "", fmt.Errorf("command '%s' 未在任何仓库中找到", name)
}
// ListAvailableSkills 列出所有可用的 skills
func ListAvailableSkills() ([]types.SkillMetadata, error) {
cfg, err := config.LoadRepositoryConfig()
if err != nil {
return nil, err
}
cachePath, err := config.GetCachePath()
if err != nil {
return nil, err
}
var allSkills []types.SkillMetadata
for _, repo := range cfg.Repositories {
rp := filepath.Join(cachePath, URLToPathName(repo.URL))
skills, err := ScanSkills(rp)
if err != nil {
continue
}
for i := range skills {
skills[i].SourceRepo = repo.Name
}
allSkills = append(allSkills, skills...)
}
return allSkills, nil
}
// ListAvailableCommands 列出所有可用的 commands
func ListAvailableCommands() ([]types.CommandGroup, error) {
cfg, err := config.LoadRepositoryConfig()
if err != nil {
return nil, err
}
cachePath, err := config.GetCachePath()
if err != nil {
return nil, err
}
var allCommands []types.CommandGroup
for _, repo := range cfg.Repositories {
rp := filepath.Join(cachePath, URLToPathName(repo.URL))
commands, err := ScanCommands(rp)
if err != nil {
continue
}
for i := range commands {
commands[i].SourceRepo = repo.Name
}
allCommands = append(allCommands, commands...)
}
return allCommands, nil
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-07

View File

@@ -0,0 +1,124 @@
## Context
当前项目已有 `lyxy-reader-office` skill 用于解析办公文档,其架构设计成熟,包含统一命令行入口、多解析器回退机制、多功能查询等特性。现在需要创建 `lyxy-reader-html` skill功能上类似但针对 HTML 内容,同时需支持 URL 下载能力。
**约束条件**
-`lyxy-reader-office` 保持相同的用户体验(参数、输出格式)
- 代码完全独立,不复用 `lyxy-reader-office` 的代码
- 参考 `temp/downloader/download.py``temp/parser/parse.py` 的现有实现
## Goals / Non-Goals
**Goals:**
- 创建完整的 `lyxy-reader-html` skill 目录结构
- 实现统一的命令行入口 `parser.py`,支持 URL 和 HTML 文件输入
- 实现下载器模块,按 pyppeteer → selenium → httpx → urllib 优先级回退
- 实现解析器模块,按 trafilatura → domscribe → MarkItDown → html2text 优先级回退
- 实现 HTML 预处理清理和 Markdown 后处理
- 实现与 `lyxy-reader-office` 一致的查询功能(全文、字数、行数、标题、章节、搜索)
**Non-Goals:**
- 不支持可配置的 HTML 清理选项
- 不支持 JavaScript 渲染开关(默认启用完整下载链)
- 不支持正文/全文切换(默认使用解析器的正文提取)
## Decisions
### 1. 目录结构参考 lyxy-reader-office
**决策**:采用与 `lyxy-reader-office` 相同的目录结构
```
lyxy-reader-html/
├── SKILL.md
├── scripts/
│ ├── parser.py # 统一入口
│ ├── common.py # 公共函数
│ ├── downloader.py # URL 下载
│ ├── html_parser.py # HTML 解析
│ └── README.md
└── references/
├── examples.md
├── parsers.md
└── error-handling.md
```
**理由**:保持项目一致性,降低用户学习成本
---
### 2. 下载器优先级pyppeteer → selenium → httpx → urllib
**决策**:直接采用 `temp/downloader/download.py` 的优先级顺序
- pyppeteer支持 JS 渲染)
- selenium支持 JS 渲染,作为 pyppeteer 的备选)
- httpx轻量级 HTTP 客户端)
- urllib标准库兜底
**备选方案考虑**
- httpx → urllib → pyppeteer → selenium速度优先
- 选择保持原顺序,因为 JS 渲染能力对许多现代网页很重要
---
### 3. 解析器优先级trafilatura → domscribe → MarkItDown → html2text
**决策**:采用精简后的 4 个解析器,顺序参考 `temp/parser/parse.py`
1. trafilatura - 专门用于网页正文提取,质量高
2. domscribe - 专注内容提取
3. MarkItDown - 微软官方,格式规范
4. html2text - 经典库,作为兜底
**备选方案考虑**
- 保留原 6 个解析器(增加 markdownify 和 html-to-markdown
- 选择精简为 4 个,减少维护复杂度
---
### 4. HTML 预处理清理默认开启且不可配置
**决策**:解析前固定执行 HTML 清理,移除 script/style/link/svg 标签和 URL 属性
- 使用 `temp/clean_html.py` 的清理逻辑
- 不提供 `--no-clean` 参数
**理由**:简化设计,减少用户选择负担
---
### 5. Markdown 处理函数独立实现
**决策**:在 `common.py` 中独立实现以下函数,不复用 `lyxy-reader-office`
- `remove_markdown_images()` - 移除图片标记
- `normalize_markdown_whitespace()` - 规范化空行
- `extract_titles()` - 提取标题
- `extract_title_content()` - 提取章节内容
- `search_markdown()` - 正则搜索
**理由**:保持 skill 之间完全隔离
---
### 6. 命令行参数与 lyxy-reader-office 保持一致
**决策**:支持以下参数,与 `lyxy-reader-office` 完全一致:
- (无参数):全文输出
- `-c` / `--count`:字数统计
- `-l` / `--lines`:行数统计
- `-t` / `--titles`:提取标题
- `-tc <name>` / `--title-content <name>`:提取章节
- `-s <pattern>` / `--search <pattern>`:正则搜索
- `-n <num>` / `--context <num>`:搜索上下文行数
**不添加** HTML 专用参数
**理由**:统一用户体验
## Risks / Trade-offs
| 风险 | 影响 | 缓解措施 |
|------|------|----------|
| pyppeteer/selenium 依赖重,安装困难 | 中 | 提供 httpx/urllib 作为轻量备选 |
| trafilatura 可能提取不到正文 | 低 | 后续解析器会继续尝试 |
| 不同解析器输出质量差异大 | 中 | 用户可通过安装不同依赖来间接选择解析器 |
| URL 下载超时或被反爬 | 中 | 多下载器回退增加成功率 |
## Migration Plan
不适用 - 这是新 skill 创建,无迁移需求。
## Open Questions
无 - 所有决策已明确。

View File

@@ -0,0 +1,26 @@
## Why
当前已有 `lyxy-reader-office` skill 用于解析办公文档,但缺少对 HTML 网页内容的解析能力。用户需要从 URL 或本地 HTML 文件中提取内容并转换为 Markdown 格式,同时支持标题提取、内容搜索等查询功能。
## What Changes
- 创建新 skill `lyxy-reader-html`,目录结构参考 `lyxy-reader-office`
- 实现命令行工具 `scripts/parser.py`,接受 URL 或 HTML 文件作为输入
- URL 模式下按优先级尝试下载器pyppeteer → selenium → httpx → urllib
- HTML 解析按优先级尝试trafilatura → domscribe → MarkItDown → html2text
- 支持 HTML 预处理清理(移除 script/style/link 等标签和 URL 属性)
- 实现查询功能:全文输出、字数统计、行数统计、标题提取、章节提取、正则搜索
## Capabilities
### New Capabilities
- `html-document-parsing`: HTML 文档和 URL 内容解析能力,将 HTML 转换为 Markdown 并支持多种查询模式
### Modified Capabilities
(无)
## Impact
- 新增目录 `skills/lyxy-reader-html/`
- 新增 Python 脚本依赖trafilatura、domscribe、markitdown、html2text、httpx、pyppeteer、selenium、beautifulsoup4
- 与现有 skill 完全隔离,不影响其他功能

View File

@@ -0,0 +1,137 @@
## ADDED Requirements
### Requirement: 优先使用 lyxy-reader-html 解析 HTML 内容
大模型在遇到 URL、.html 或 .htm 文件时SHALL 优先激活并使用 lyxy-reader-html skill 来读取内容。
#### Scenario: 用户请求读取 URL
- **WHEN** 用户提供以 http:// 或 https:// 开头的 URL
- **THEN** 大模型 SHALL 激活 lyxy-reader-html skill
- **AND** 使用 skill 目录下的 `scripts/parser.py` 执行解析
#### Scenario: 用户请求读取 HTML 文件
- **WHEN** 用户提供的文件路径以 .html 或 .htm 结尾
- **THEN** 大模型 SHALL 激活 lyxy-reader-html skill
- **AND** 使用 skill 目录下的 `scripts/parser.py` 执行解析
### Requirement: 必须通过 lyxy-runner-python 执行脚本
当环境中存在 lyxy-runner-python skill 时,大模型 SHALL 必须使用该 skill 来运行 parser.py 脚本。
#### Scenario: lyxy-runner-python 可用
- **WHEN** 大模型环境中存在 lyxy-runner-python skill
- **THEN** 大模型 SHALL 通过 lyxy-runner-python skill 执行 parser.py
- **AND** 利用 lyxy-runner-python 的自动依赖管理功能uv安装所需的 Python 包
#### Scenario: lyxy-runner-python 不可用
- **WHEN** 大模型环境中不存在 lyxy-runner-python skill
- **THEN** 大模型 SHALL 降级到直接使用 Python 执行 parser.py
- **AND** 提示用户当前使用直接执行模式
- **AND** 禁止自动执行 pip install 安装依赖
### Requirement: 引导阅读 README 获取详细用法
大模型在需要了解 parser.py 的详细使用方式时SHALL 阅读 `scripts/README.md` 文件。
#### Scenario: 首次使用 skill 执行解析
- **WHEN** 大模型首次使用 lyxy-reader-html skill 或不确定具体参数用法
- **THEN** 大模型 SHALL 阅读 `scripts/README.md` 获取命令行参数、依赖安装和使用示例等详细信息
#### Scenario: 遇到特殊参数需求
- **WHEN** 用户请求使用特殊功能(如章节提取、正则搜索等)
- **THEN** 大模型 SHALL 参考 `scripts/README.md` 中的对应参数说明
### Requirement: 支持 URL 和 HTML 文件两种输入源
系统 SHALL 支持从 URL 下载 HTML 内容或直接读取本地 HTML 文件两种输入方式。
#### Scenario: 输入为 URL
- **WHEN** 输入以 http:// 或 https:// 开头
- **THEN** 系统 SHALL 识别为 URL 模式
- **AND** 尝试下载 URL 对应的 HTML 内容
#### Scenario: 输入为本地 HTML 文件
- **WHEN** 输入是存在的本地文件且扩展名为 .html 或 .htm
- **THEN** 系统 SHALL 识别为 HTML 文件模式
- **AND** 直接读取文件内容进行解析
#### Scenario: 输入无法识别
- **WHEN** 输入既不是 URL 也不是有效的 HTML 文件
- **THEN** 系统 SHALL 输出错误信息
- **AND** 以退出码 1 退出
### Requirement: URL 下载器按优先级降级
系统 SHALL 按 pyppeteer → selenium → httpx → urllib 的优先级尝试下载 URL 内容。
#### Scenario: 下载器按优先级降级
- **WHEN** 优先级最高的下载器不可用或下载失败
- **THEN** 系统自动尝试下一优先级的下载器
- **AND** 记录每个下载器的失败原因
#### Scenario: 所有下载器失败
- **WHEN** 所有下载策略均失败
- **THEN** 系统返回详细的失败信息
- **AND** 列出每种下载策略的失败原因
- **AND** 以退出码 1 退出
### Requirement: HTML 解析器按优先级降级
系统 SHALL 按 trafilatura → domscribe → MarkItDown → html2text 的优先级尝试解析 HTML 内容。
#### Scenario: 解析器按优先级降级
- **WHEN** 优先级最高的解析器不可用或解析失败
- **THEN** 系统自动尝试下一优先级的解析器
- **AND** 记录每个解析器的失败原因
#### Scenario: 所有解析器失败
- **WHEN** 所有解析策略均失败
- **THEN** 系统返回详细的失败信息
- **AND** 列出每种解析策略的失败原因
- **AND** 以退出码 1 退出
### Requirement: HTML 内容预处理清理
系统 SHALL 在解析前对 HTML 内容进行预处理清理,移除噪声元素。
#### Scenario: 清理 script 和 style 标签
- **WHEN** HTML 内容包含 &lt;script&gt;、&lt;style&gt;、&lt;link&gt;、&lt;svg&gt; 标签
- **THEN** 系统 SHALL 移除这些标签及其内容
#### Scenario: 清理 URL 属性
- **WHEN** HTML 标签包含 href、src、srcset、action、data-*src 等 URL 属性
- **THEN** 系统 SHALL 移除这些属性
#### Scenario: 清理 style 属性
- **WHEN** HTML 标签包含 style 属性
- **THEN** 系统 SHALL 移除 style 属性
### Requirement: 支持统一的查询功能
系统 SHALL 提供统一的查询接口,包括全文提取、元数据查询、标题提取、章节提取和正则搜索。
#### Scenario: 获取文档字数
- **WHEN** 用户请求获取文档的字数
- **THEN** 系统使用 `-c` 参数返回文档的总字符数
#### Scenario: 获取文档行数
- **WHEN** 用户请求获取文档的行数
- **THEN** 系统使用 `-l` 参数返回文档的总行数
#### Scenario: 提取所有标题
- **WHEN** 用户请求提取文档的标题结构
- **THEN** 系统使用 `-t` 参数返回所有 1-6 级标题
#### Scenario: 提取指定章节内容
- **WHEN** 用户请求提取特定标题名称的章节内容
- **THEN** 系统使用 `-tc` 参数返回该章节的完整内容
- **AND** 包含完整的上级标题链和所有下级内容
#### Scenario: 正则表达式搜索
- **WHEN** 用户请求在文档中搜索关键词或模式
- **THEN** 系统使用 `-s` 参数返回所有匹配结果及上下文
- **AND** 默认包含前后各 2 行非空行上下文
- **AND** 支持 `-n` 参数自定义上下文行数
### Requirement: Markdown 后处理
系统 SHALL 对解析后的 Markdown 内容进行后处理,提升可读性。
#### Scenario: 移除图片标记
- **WHEN** 解析结果包含 Markdown 图片语法 `![alt](url)`
- **THEN** 系统 SHALL 移除这些图片标记
#### Scenario: 规范化空行
- **WHEN** 解析结果包含连续 3 个或更多空行
- **THEN** 系统 SHALL 将其合并为单个空行

View File

@@ -0,0 +1,58 @@
## 1. 初始化 Skill 目录结构
- [x] 1.1 创建 `skills/lyxy-reader-html/` 目录
- [x] 1.2 创建 `skills/lyxy-reader-html/scripts/` 子目录
- [x] 1.3 创建 `skills/lyxy-reader-html/references/` 子目录
## 2. 创建 SKILL.md 主文档
- [x] 2.1 编写 YAML 前置元数据name、description、compatibility
- [x] 2.2 编写 Purpose 章节
- [x] 2.3 编写 When to Use 章节(含触发词)
- [x] 2.4 编写 Quick Reference 章节(参数表)
- [x] 2.5 编写 Workflow 章节
- [x] 2.6 编写 References 章节
## 3. 实现 common.py 公共模块
- [x] 3.1 实现 HTML 清理函数 `clean_html_content()`
- [x] 3.2 实现 Markdown 图片移除函数 `remove_markdown_images()`
- [x] 3.3 实现 Markdown 空行规范化函数 `normalize_markdown_whitespace()`
- [x] 3.4 实现标题级别检测函数 `get_heading_level()`
- [x] 3.5 实现标题提取函数 `extract_titles()`
- [x] 3.6 实现章节内容提取函数 `extract_title_content()`
- [x] 3.7 实现正则搜索函数 `search_markdown()`
## 4. 实现 downloader.py URL 下载模块
- [x] 4.1 实现 `download_with_pyppeteer()` 函数
- [x] 4.2 实现 `download_with_selenium()` 函数
- [x] 4.3 实现 `download_with_httpx()` 函数
- [x] 4.4 实现 `download_with_urllib()` 函数
- [x] 4.5 实现统一的 `download_html()` 入口函数,按优先级尝试各下载器
## 5. 实现 html_parser.py HTML 解析模块
- [x] 5.1 实现 `parse_with_trafilatura()` 函数
- [x] 5.2 实现 `parse_with_domscribe()` 函数
- [x] 5.3 实现 `parse_with_markitdown()` 函数
- [x] 5.4 实现 `parse_with_html2text()` 函数
- [x] 5.5 实现统一的 `parse_html()` 入口函数,按优先级尝试各解析器
## 6. 实现 parser.py 命令行入口
- [x] 6.1 实现命令行参数解析argparse
- [x] 6.2 实现输入源判断URL / HTML 文件)
- [x] 6.3 实现 URL 下载流程(如需要)
- [x] 6.4 实现 HTML 清理流程
- [x] 6.5 实现 HTML 解析流程
- [x] 6.6 实现 Markdown 后处理(移除图片、规范化空行)
- [x] 6.7 实现各查询模式(全文、字数、行数、标题、章节、搜索)
- [x] 6.8 实现错误处理和退出码
## 7. 创建参考文档
- [x] 7.1 创建 `scripts/README.md` 详细使用文档
- [x] 7.2 创建 `references/examples.md` 使用示例
- [x] 7.3 创建 `references/parsers.md` 解析器说明
- [x] 7.4 创建 `references/error-handling.md` 错误处理指南

View File

@@ -0,0 +1,137 @@
## Requirements
### Requirement: 优先使用 lyxy-reader-html 解析 HTML 内容
大模型在遇到 URL、.html 或 .htm 文件时SHALL 优先激活并使用 lyxy-reader-html skill 来读取内容。
#### Scenario: 用户请求读取 URL
- **WHEN** 用户提供以 http:// 或 https:// 开头的 URL
- **THEN** 大模型 SHALL 激活 lyxy-reader-html skill
- **AND** 使用 skill 目录下的 `scripts/parser.py` 执行解析
#### Scenario: 用户请求读取 HTML 文件
- **WHEN** 用户提供的文件路径以 .html 或 .htm 结尾
- **THEN** 大模型 SHALL 激活 lyxy-reader-html skill
- **AND** 使用 skill 目录下的 `scripts/parser.py` 执行解析
### Requirement: 必须通过 lyxy-runner-python 执行脚本
当环境中存在 lyxy-runner-python skill 时,大模型 SHALL 必须使用该 skill 来运行 parser.py 脚本。
#### Scenario: lyxy-runner-python 可用
- **WHEN** 大模型环境中存在 lyxy-runner-python skill
- **THEN** 大模型 SHALL 通过 lyxy-runner-python skill 执行 parser.py
- **AND** 利用 lyxy-runner-python 的自动依赖管理功能uv安装所需的 Python 包
#### Scenario: lyxy-runner-python 不可用
- **WHEN** 大模型环境中不存在 lyxy-runner-python skill
- **THEN** 大模型 SHALL 降级到直接使用 Python 执行 parser.py
- **AND** 提示用户当前使用直接执行模式
- **AND** 禁止自动执行 pip install 安装依赖
### Requirement: 引导阅读 README 获取详细用法
大模型在需要了解 parser.py 的详细使用方式时SHALL 阅读 `scripts/README.md` 文件。
#### Scenario: 首次使用 skill 执行解析
- **WHEN** 大模型首次使用 lyxy-reader-html skill 或不确定具体参数用法
- **THEN** 大模型 SHALL 阅读 `scripts/README.md` 获取命令行参数、依赖安装和使用示例等详细信息
#### Scenario: 遇到特殊参数需求
- **WHEN** 用户请求使用特殊功能(如章节提取、正则搜索等)
- **THEN** 大模型 SHALL 参考 `scripts/README.md` 中的对应参数说明
### Requirement: 支持 URL 和 HTML 文件两种输入源
系统 SHALL 支持从 URL 下载 HTML 内容或直接读取本地 HTML 文件两种输入方式。
#### Scenario: 输入为 URL
- **WHEN** 输入以 http:// 或 https:// 开头
- **THEN** 系统 SHALL 识别为 URL 模式
- **AND** 尝试下载 URL 对应的 HTML 内容
#### Scenario: 输入为本地 HTML 文件
- **WHEN** 输入是存在的本地文件且扩展名为 .html 或 .htm
- **THEN** 系统 SHALL 识别为 HTML 文件模式
- **AND** 直接读取文件内容进行解析
#### Scenario: 输入无法识别
- **WHEN** 输入既不是 URL 也不是有效的 HTML 文件
- **THEN** 系统 SHALL 输出错误信息
- **AND** 以退出码 1 退出
### Requirement: URL 下载器按优先级降级
系统 SHALL 按 pyppeteer → selenium → httpx → urllib 的优先级尝试下载 URL 内容。
#### Scenario: 下载器按优先级降级
- **WHEN** 优先级最高的下载器不可用或下载失败
- **THEN** 系统自动尝试下一优先级的下载器
- **AND** 记录每个下载器的失败原因
#### Scenario: 所有下载器失败
- **WHEN** 所有下载策略均失败
- **THEN** 系统返回详细的失败信息
- **AND** 列出每种下载策略的失败原因
- **AND** 以退出码 1 退出
### Requirement: HTML 解析器按优先级降级
系统 SHALL 按 trafilatura → domscribe → MarkItDown → html2text 的优先级尝试解析 HTML 内容。
#### Scenario: 解析器按优先级降级
- **WHEN** 优先级最高的解析器不可用或解析失败
- **THEN** 系统自动尝试下一优先级的解析器
- **AND** 记录每个解析器的失败原因
#### Scenario: 所有解析器失败
- **WHEN** 所有解析策略均失败
- **THEN** 系统返回详细的失败信息
- **AND** 列出每种解析策略的失败原因
- **AND** 以退出码 1 退出
### Requirement: HTML 内容预处理清理
系统 SHALL 在解析前对 HTML 内容进行预处理清理,移除噪声元素。
#### Scenario: 清理 script 和 style 标签
- **WHEN** HTML 内容包含 &lt;script&gt;、&lt;style&gt;、&lt;link&gt;、&lt;svg&gt; 标签
- **THEN** 系统 SHALL 移除这些标签及其内容
#### Scenario: 清理 URL 属性
- **WHEN** HTML 标签包含 href、src、srcset、action、data-*src 等 URL 属性
- **THEN** 系统 SHALL 移除这些属性
#### Scenario: 清理 style 属性
- **WHEN** HTML 标签包含 style 属性
- **THEN** 系统 SHALL 移除 style 属性
### Requirement: 支持统一的查询功能
系统 SHALL 提供统一的查询接口,包括全文提取、元数据查询、标题提取、章节提取和正则搜索。
#### Scenario: 获取文档字数
- **WHEN** 用户请求获取文档的字数
- **THEN** 系统使用 `-c` 参数返回文档的总字符数
#### Scenario: 获取文档行数
- **WHEN** 用户请求获取文档的行数
- **THEN** 系统使用 `-l` 参数返回文档的总行数
#### Scenario: 提取所有标题
- **WHEN** 用户请求提取文档的标题结构
- **THEN** 系统使用 `-t` 参数返回所有 1-6 级标题
#### Scenario: 提取指定章节内容
- **WHEN** 用户请求提取特定标题名称的章节内容
- **THEN** 系统使用 `-tc` 参数返回该章节的完整内容
- **AND** 包含完整的上级标题链和所有下级内容
#### Scenario: 正则表达式搜索
- **WHEN** 用户请求在文档中搜索关键词或模式
- **THEN** 系统使用 `-s` 参数返回所有匹配结果及上下文
- **AND** 默认包含前后各 2 行非空行上下文
- **AND** 支持 `-n` 参数自定义上下文行数
### Requirement: Markdown 后处理
系统 SHALL 对解析后的 Markdown 内容进行后处理,提升可读性。
#### Scenario: 移除图片标记
- **WHEN** 解析结果包含 Markdown 图片语法 `![alt](url)`
- **THEN** 系统 SHALL 移除这些图片标记
#### Scenario: 规范化空行
- **WHEN** 解析结果包含连续 3 个或更多空行
- **THEN** 系统 SHALL 将其合并为单个空行

134
publish.sh Normal file
View File

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

View File

@@ -1,76 +0,0 @@
---
name: lyxy-kb
description: 基于文件的个人知识库管理 skill。当用户说"创建知识库"、"初始化知识项目"、"入库文档"、"知识问答"、"基于文档回答"时使用。支持文档解析入库、增量摘要、渐进式问答。配合 /lyxy-kb-init、/lyxy-kb-ingest、/lyxy-kb-ask 等 command 使用。
compatibility: 依赖 lyxy-reader-office skill 解析 office 文档(.docx/.pdf/.pptx/.xlsx依赖 lyxy-runner-python skill 执行 Python 脚本。
---
# 个人知识库 Skill
基于文件的个人知识库管理系统。将项目相关文档组织为可被大模型高效检索和问答的知识库,支持文档解析入库、增量摘要、渐进式问答。
## Purpose
**纯文件驱动**:不依赖数据库或向量存储,所有数据以文件形式存在于项目目录中。
**渐进式查询**:通过 project.md 摘要索引 + parsed 详细文件的分层结构,优先读取摘要,按需加载详细内容,节省 token 消耗。
**增量管理**:支持增量解析入库和增量更新摘要,避免重复处理已入库的文档。
## When to Use
任何需要基于一组项目文档进行知识管理和问答的场景。
### 典型场景
- **项目文档管理**:将需求文档、技术方案、数据表等组织为结构化知识库
- **文档解析入库**:将 office 文档和纯文本文件解析为 markdown 并生成摘要
- **知识问答**:基于已入库的文档回答问题,并标注信息来源
### 不适用场景
- 需要语义搜索或向量化检索
- 需要跨多个知识项目关联查询
- 需要多人协作或权限控制
## Quick Reference
| Command | 触发方式 | 说明 |
|---------|----------|------|
| init | `/lyxy-kb-init <name>` | 初始化知识项目目录结构 |
| ingest | `/lyxy-kb-ingest <name>` | 解析 sources/ 中新文件,增量更新 project.md |
| rebuild | `/lyxy-kb-rebuild <name>` | 全量重新生成 project.md |
| ask | `/lyxy-kb-ask <name>` | 基于知识库进行会话问答 |
## Workflow
### 知识项目目录结构
```
<project-name>/
├── project.md # 高度摘要 + 文件索引
├── manifest.json # 增量追踪
├── parsed/ # 解析后的 markdown
├── sources/ # 待处理区(用户放入原始文档)
└── archive/ # 原始文件备份(带时间戳)
```
### 基本工作流程
1. **初始化**:使用 `/lyxy-kb-init <name>` 创建项目目录结构
2. **入库**:将文档放入 `sources/`,执行 `/lyxy-kb-ingest <name>`
3. **问答**:使用 `/lyxy-kb-ask <name>` 基于知识库回答问题
### 渐进式查询策略
1. **读取 project.md**:获取项目概述和文件索引(低 token 开销)
2. **判断相关文件**:根据用户问题和摘要判断需要查阅哪些 parsed 文件
3. **按需加载**:读取相关 parsed 文件的全部或部分内容
4. **回答并标注来源**:基于获取的信息回答问题
## References
详细文档请参阅 `references/` 目录:
| 文件 | 内容 |
|------|------|
| `references/structure.md` | 目录结构规范、project.md 格式、manifest.json 结构、parsed 元信息标记 |
| `references/workflow.md` | 文档生命周期、归档命名规则、冲突检测、解析策略 |
| `references/query-strategy.md` | 渐进式查询策略、来源引用格式、依赖关系、限制说明 |

View File

@@ -1,43 +0,0 @@
# 渐进式查询策略
## 分层加载策略
问答时采用分层加载策略,节省 token
1. **读取 project.md**:获取项目概述和文件索引(低 token 开销)
2. **判断相关文件**:根据用户问题和文件索引中的摘要,判断需要查阅哪些 parsed 文件
3. **按需加载**:读取相关 parsed 文件的全部或部分内容
4. **回答并标注来源**:基于获取的信息回答问题
## 来源引用格式
回答中引用具体信息时,使用以下格式标注来源:
```
根据《文件名》(parsed/文件名.md)...
```
多个来源时分别标注各信息点的来源文件。
## 无相关信息
当知识库中未找到与用户问题相关的信息时,明确告知用户,不编造答案。
## 空知识库
如果 project.md 文件索引为空(尚无已入库文件),应告知用户知识库为空,建议先使用 `/lyxy-kb-ingest` 入库文档。
## 依赖关系
| 依赖 | 用途 |
|------|------|
| lyxy-reader-office | 解析 .docx、.pdf、.pptx、.xlsx 文件为 markdown |
| lyxy-runner-python | 通过 uv 执行 lyxy-reader-office 的 Python 解析脚本 |
## 限制
- 不支持向量化语义搜索
- 不支持跨知识项目关联查询
- 不支持文档版本对比或 diff
- 不支持多用户协作或权限控制
- 大量文件全量重写时 token 消耗较高

View File

@@ -1,125 +0,0 @@
# 知识项目目录结构
## 目录结构
每个知识项目是当前工作目录CWD下的一个子目录包含以下固定结构
```
<project-name>/
├── project.md # 高度摘要 + 文件索引
├── manifest.json # 增量追踪
├── parsed/ # 解析后的 markdown中间产物
├── sources/ # 待处理区(用户放入原始文档)
└── archive/ # 原始文件备份(带时间戳)
```
### 各目录/文件职责
| 路径 | 职责 |
|------|------|
| `project.md` | 项目的高度摘要和文件索引,作为问答时的入口文件 |
| `manifest.json` | 记录已处理文件的元信息,用于增量检测和版本追踪 |
| `parsed/` | 存放解析后的 markdown 文件,便于大模型读取分析 |
| `sources/` | 用户放入待处理文档的目录,解析后文件会被移走 |
| `archive/` | 原始文件的备份,每个文件都带时间戳后缀 |
### 结构完整性验证
执行任何 commandingest / rebuild / ask必须先验证项目目录结构是否完整即以下 5 项是否全部存在:
- `<project-name>/project.md`
- `<project-name>/manifest.json`
- `<project-name>/parsed/`
- `<project-name>/sources/`
- `<project-name>/archive/`
若不完整,提示用户先执行 `/lyxy-kb-init <project-name>`,终止当前操作。
## 项目名称规则
项目名称只允许使用以下字符:
- 中文字符
- 英文字母a-z、A-Z
- 数字0-9
- 短横线(-
- 下划线_
**不允许包含空格或其他特殊字符。** 不符合规则时应提示用户修改。
## project.md 格式规范
```markdown
# <项目名称>
## 概述
(高度总结的项目信息,几百字以内)
## 关键信息
(从所有文档中提炼的核心要点)
## 文件索引
| 文件名 | 解析文件 | 最新归档 | 摘要 |
|--------|----------|----------|------|
| 需求文档 | parsed/需求文档.md | archive/需求文档_202602181600.docx | 简要摘要... |
## 更新记录
- 2026-02-18 16:00: 解析 需求文档.docx
```
## manifest.json 结构
```json
{
"project": "<项目名称>",
"created_at": "2026-02-18T16:00",
"last_ingest": "2026-02-18T17:25",
"files": [
{
"name": "需求文档",
"ext": ".docx",
"parsed": "parsed/需求文档.md",
"versions": [
{
"archived": "archive/需求文档_202602181600.docx",
"hash": "sha256:abc123...",
"ingested_at": "2026-02-18T16:00"
}
]
}
]
}
```
### 字段说明
| 字段 | 说明 |
|------|------|
| `project` | 项目名称 |
| `created_at` | 项目创建时间 |
| `last_ingest` | 最近一次 ingest 的时间 |
| `files[].name` | 文件名(不含扩展名) |
| `files[].ext` | 原始文件扩展名 |
| `files[].parsed` | 解析产物的相对路径 |
| `files[].versions` | 版本历史数组 |
| `files[].versions[].archived` | 归档文件的相对路径 |
| `files[].versions[].hash` | 文件内容的 SHA-256 哈希(使用 `sha256sum` 命令计算) |
| `files[].versions[].ingested_at` | 该版本的入库时间 |
## parsed 文件元信息标记
每个 parsed markdown 文件头部必须包含元信息注释:
```markdown
<!-- source: 技术方案.pdf -->
<!-- archived: archive/技术方案_202602181725.pdf -->
<!-- parsed_at: 2026-02-18 17:25 -->
# 技术方案
(文档正文内容...
```
| 元信息 | 说明 |
|--------|------|
| `source` | 原始文件名(含扩展名) |
| `archived` | 对应的归档文件相对路径 |
| `parsed_at` | 解析时间YYYY-MM-DD HH:mm 格式) |

View File

@@ -1,86 +0,0 @@
# 文档生命周期和处理策略
## 文档生命周期
```
用户放入 sources/(支持子目录)
检查文件(跳过空文件、检测冲突)
解析文件内容(失败则保留在 sources/
├──▶ 写入 parsed/<文件名>.md含头部元信息
├──▶ 移动原始文件到 archive/<文件名_YYYYMMDDHHmm>.<ext>
├──▶ 更新 manifest.json
└──▶ 增量更新 project.md追加文件索引和更新记录
```
## 归档命名规则
所有进入 archive 的文件都必须带时间戳后缀,格式为 `<文件名_YYYYMMDDHHmm>.<扩展名>`,即使只有一个版本。
示例:
- `需求文档.docx``archive/需求文档_202602181600.docx`
- `技术方案.pdf`(第二次入库)→ `archive/技术方案_202602181725.pdf`
## 同名文件更新
同名同扩展名的文件再次入库时:
- `parsed/` 中的 markdown 文件被覆盖为最新版本
- `archive/` 中保留所有历史版本(每个版本独立的时间戳文件)
- `manifest.json` 中该文件条目的 `versions` 数组追加新版本记录
## 同名不同扩展名冲突检测
因为 parsed 产物以文件名(不含扩展名)+ `.md` 命名,同名不同扩展名的文件会产生冲突。
### 检测规则
1. **sources/ 内部检测**:扫描 sources/ 中所有文件(含子目录),如果存在同名但不同扩展名的文件(如 `技术方案.pdf``技术方案.docx`),拒绝处理并提示用户重命名
2. **与已入库文件检测**:将 sources/ 中文件的名称(不含扩展名)与 manifest.json 中已有记录对比,如果名称相同但扩展名不同,拒绝处理并提示用户重命名
### 处理方式
冲突文件不予处理,保留在 sources/ 中,提示用户重命名后重新执行 ingest。非冲突文件正常处理。
## 文件类型解析策略
| 文件类型 | 解析方式 |
|----------|----------|
| `.docx`, `.pdf`, `.pptx`, `.xlsx` | 使用名为 **lyxy-reader-office** 的 skill 解析 |
| 其他所有文件(`.md`, `.txt`, `.csv`, `.json`, `.xml`, `.yaml`, `.yml`, `.log`, `.html` 等) | 直接读取文件内容 |
### Office 文档解析
解析 office 文档时,必须查找当前环境中名为 **lyxy-reader-office** 的 skill阅读其 SKILL.md 获取具体的执行方式和命令。
**如果环境中不存在 lyxy-reader-office skill且没有其他可替代的文档解析 skill则提示用户无法处理 office 文档,中止整个 ingest 流程。**
### sources/ 扫描规则
扫描 sources/ 时**递归检查所有子目录**中的文件。parsed 产物的路径仍为 `parsed/<文件名>.md`(扁平化存放),不保留 sources 中的子目录结构。
### 空文件处理
sources/ 中 0 字节的空文件应**跳过处理**,不解析、不归档、不更新 manifest。处理完成后向用户列出被跳过的空文件列表提示用户检查。
### 解析失败处理
如果某个文件解析失败(如文档损坏、解析器报错),该文件**保留在 sources/ 中不移动**,报告错误信息,继续处理其他文件。
## 更新策略
**增量追加**(默认,由 ingest 触发):
- 新文件:在文件索引表追加新行,在更新记录追加条目
- 已有文件更新:覆盖文件索引表中对应行的最新归档路径和摘要
- 概述和关键信息部分**不**自动更新
**全量重写**(由 rebuild 触发):
- 读取所有 parsed/*.md 文件
- 重新生成概述、关键信息、文件索引
- 保留历史更新记录,追加本次 rebuild 条目

View File

@@ -1,74 +0,0 @@
---
name: lyxy-reader-office
description: 优先解析 docx、xlsx、pptx、pdf 四种办公文档的 skill将文档转换为 Markdown 格式支持全文提取、标题提取、章节提取、正则搜索、字数统计、行数统计PDF 额外支持 OCR 高精度模式。使用时请阅读 scripts/README.md 获取详细用法。
compatibility: Requires Python 3.6+. DOCX/PPTX/XLSX 无需额外依赖XML 原生解析PDF 至少需要 pypdf。推荐通过 lyxy-runner-python skill 使用 uv 自动管理依赖。
---
# 办公文档解析 Skill
将 Microsoft Office 文档(.docx、.pptx、.xlsx和 PDF 文件解析为 Markdown 格式,支持多种查询模式。
## Purpose
**统一入口**:使用 `scripts/parser.py` 作为统一的命令行入口,自动识别文件类型并分派到对应的格式解析器。
**依赖选项**:此 skill 必须优先使用 lyxy-runner-python skill 执行,不可用时降级到直接 Python 执行。
## When to Use
任何需要读取或解析 .docx、.xlsx、.pptx、.pdf 文件内容的任务都应使用此 skill。
### 典型场景
- **文档内容提取**:将 Word/PPT/Excel/PDF 文档转换为可读的 Markdown 文本
- **文档元数据**:获取文档的字数、行数等信息
- **标题分析**:提取文档的标题结构
- **章节提取**:提取特定章节的内容
- **内容搜索**:在文档中搜索关键词或模式
- **PDF OCR**:对扫描版 PDF 启用 OCR 高精度解析
### 触发词
- 中文:"读取/解析/打开 docx/word/xlsx/excel/pptx/pdf 文档"
- 英文:"read/parse/extract docx/word/xlsx/excel/pptx/powerpoint/pdf"
- 文件扩展名:`.docx``.xlsx``.pptx``.pdf`
## Quick Reference
| 参数 | 说明 |
|------|------|
| (无参数) | 输出完整 Markdown 内容 |
| `-c` | 字数统计 |
| `-l` | 行数统计 |
| `-t` | 提取所有标题 |
| `-tc <name>` | 提取指定标题的章节内容 |
| `-s <pattern>` | 正则表达式搜索 |
| `-n <num>` | 与 `-s` 配合,指定上下文行数 |
| `--high-res` | PDF 专用,启用 OCR 版面分析 |
## Workflow
1. **检查依赖**:优先使用 lyxy-runner-python否则降级到直接 Python 执行
2. **选择格式**:根据文件扩展名自动识别格式
3. **执行解析**:调用 `scripts/parser.py` 并传入参数
4. **输出结果**:返回 Markdown 格式内容或统计信息
### 基本语法
```bash
# 使用 lyxy-runner-python推荐
uv run --with "markitdown[docx]" scripts/parser.py /path/to/file.docx
# 降级到直接执行
python3 scripts/parser.py /path/to/file.docx
```
## References
详细文档请参阅 `references/` 目录:
| 文件 | 内容 |
|------|------|
| `references/examples.md` | 各格式完整提取、字数统计、标题提取、章节提取、搜索等示例 |
| `references/parsers.md` | 解析器说明、依赖安装、各格式输出特点、能力说明 |
| `references/error-handling.md` | 限制说明、最佳实践、依赖执行策略 |
> **详细用法**:请阅读 `scripts/README.md` 获取完整的命令行参数和依赖安装指南。

View File

@@ -1,41 +0,0 @@
# 错误处理和限制说明
## 限制
- 不支持图片提取(仅纯文本)
- 不支持复杂的格式保留(字体、颜色、布局等)
- 不支持文档编辑或修改
- 仅支持 .docx、.xlsx、.pptx、.pdf 格式(不支持 .doc、.xls、.ppt 等旧格式)
- PDF 无内置 XML 原生解析,至少需要安装 pypdf
## 最佳实践
1. **必须优先使用 lyxy-runner-python**:如果环境中存在,必须使用 lyxy-runner-python 执行脚本
2. **查阅 README**:详细参数、依赖安装、解析器对比等信息请阅读 `scripts/README.md`
3. **大文件处理**:对于大文档,建议使用章节提取(`-tc`)或搜索(`-s`)来限制处理范围
4. **PDF 标题**PDF 是版面描述格式,默认不含语义化标题;需要标题层级时使用 `--high-res`
5. **禁止自动安装**:降级到直接 Python 执行时,仅向用户提示安装依赖,不得自动执行 pip install
## 依赖执行策略
### 必须使用 lyxy-runner-python
如果环境中存在 lyxy-runner-python skill**必须**使用它来执行 parser.py 脚本:
- lyxy-runner-python 使用 uv 管理依赖,自动安装所需的第三方库
- 环境隔离,不污染系统 Python
- 跨平台兼容Windows/macOS/Linux
### 降级到直接执行
**仅当** lyxy-runner-python skill 不存在时,才降级到直接 Python 执行:
- 需要用户手动安装依赖
- DOCX/PPTX/XLSX 无需依赖也可通过 XML 原生解析工作
- PDF 至少需要安装 pypdf
- **禁止自动执行 pip install**,仅向用户提示安装建议
## 不适用场景
- 需要提取图片内容(仅支持纯文本)
- 需要保留复杂的格式信息(字体、颜色、布局)
- 需要编辑或修改文档
- 需要处理 .doc、.xls、.ppt 等旧格式

View File

@@ -1,55 +0,0 @@
# 示例
## 提取完整文档内容
```bash
# DOCX
uv run --with "markitdown[docx]" scripts/parser.py /path/to/report.docx
# PPTX
uv run --with "markitdown[pptx]" scripts/parser.py /path/to/slides.pptx
# XLSX
uv run --with "markitdown[xlsx]" scripts/parser.py /path/to/data.xlsx
# PDF
uv run --with "markitdown[pdf]" --with pypdf scripts/parser.py /path/to/doc.pdf
```
## 获取文档字数
```bash
uv run --with "markitdown[docx]" scripts/parser.py -c /path/to/report.docx
```
## 提取所有标题
```bash
uv run --with "markitdown[docx]" scripts/parser.py -t /path/to/report.docx
```
## 提取指定章节
```bash
uv run --with "markitdown[docx]" scripts/parser.py -tc "第一章" /path/to/report.docx
```
## 搜索关键词
```bash
uv run --with "markitdown[docx]" scripts/parser.py -s "关键词" -n 3 /path/to/report.docx
```
## PDF OCR 高精度解析
```bash
uv run --with docling --with pypdf scripts/parser.py /path/to/scanned.pdf --high-res
```
## 降级到直接 Python 执行
仅当 lyxy-runner-python skill 不存在时使用:
```bash
python3 scripts/parser.py /path/to/file.docx
```

View File

@@ -1,58 +0,0 @@
# 解析器说明和依赖安装
## 多策略解析降级
每种文件格式配备多个解析器,按优先级依次尝试,前一个失败自动回退到下一个。
详细的解析器优先级和对比请查阅 `scripts/README.md`
## 依赖安装
### 使用 uv推荐
```bash
# DOCX - 全依赖
uv run --with docling --with "unstructured[docx]" --with markdownify --with pypandoc-binary --with "markitdown[docx]" --with python-docx scripts/parser.py /path/to/file.docx
# PPTX - 全依赖
uv run --with docling --with "unstructured[pptx]" --with markdownify --with "markitdown[pptx]" --with python-pptx scripts/parser.py /path/to/file.pptx
# XLSX - 全依赖
uv run --with docling --with "unstructured[xlsx]" --with markdownify --with "markitdown[xlsx]" --with pandas --with tabulate scripts/parser.py /path/to/file.xlsx
# PDF - 全依赖(基础文本提取)
uv run --with docling --with "unstructured[pdf]" --with markdownify --with "markitdown[pdf]" --with pypdf scripts/parser.py /path/to/file.pdf
# PDF OCR 高精度模式(全依赖)
uv run --with docling --with "unstructured[pdf]" --with unstructured-paddleocr --with "paddlepaddle==2.6.2" --with ml-dtypes --with markdownify --with "markitdown[pdf]" --with pypdf scripts/parser.py /path/to/file.pdf --high-res
```
> **说明**:以上为全依赖安装命令,包含所有解析器以获得最佳兼容性。详细的解析器优先级和对比请查阅 `scripts/README.md`。
## 各格式输出特点
- **DOCX**:标准 Markdown 文档结构
- **PPTX**:每张幻灯片以 `## Slide N` 为标题,幻灯片之间以 `---` 分隔
- **XLSX**:以 `## SheetName` 区分工作表,数据以 Markdown 表格呈现
- **PDF**:纯文本流,使用 `--high-res` 可启用 OCR 版面分析识别标题
## 能力说明
### 1. 全文转换为 Markdown
将完整文档解析为 Markdown 格式,移除图片但保留文本格式(标题、列表、表格、粗体、斜体等)。
### 2. 获取文档元信息
- 字数统计(`-c` 参数)
- 行数统计(`-l` 参数)
### 3. 标题列表提取
提取文档中所有 1-6 级标题(`-t` 参数),按原始层级关系返回。
### 4. 指定章节内容提取
根据标题名称提取特定章节的完整内容(`-tc` 参数),包含上级标题链和所有下级内容。
### 5. 正则表达式搜索
在文档中搜索关键词或模式(`-s` 参数),支持自定义上下文行数(`-n` 参数,默认 2 行)。
### 6. PDF OCR 高精度模式
对 PDF 文件启用 OCR 版面分析(`--high-res` 参数),适用于扫描版 PDF 或需要识别标题层级的场景。

View File

@@ -1,449 +0,0 @@
# Document Parser 使用说明
模块化文档解析器,将 DOCX、PPTX、XLSX、PDF 文件转换为 Markdown 格式。
每种文档类型配备多个解析器按优先级依次尝试前一个失败自动回退到下一个。不安装任何第三方库时DOCX/PPTX/XLSX 仍可通过内置 XML 原生解析工作PDF 至少需要 pypdf
## 快速开始
```bash
# 最简运行XML 原生解析,无需安装依赖)
python parser.py report.docx
# 安装推荐依赖后运行
pip install "markitdown[docx]"
python parser.py report.docx
# 使用 uv 一键运行(自动安装依赖,无需手动 pip install
uv run --with "markitdown[docx]" parser.py report.docx
```
## 命令行用法
### 基本语法
```bash
python parser.py <file_path> [options]
```
`file_path` 为 DOCX、PPTX、XLSX 或 PDF 文件路径(相对或绝对路径)。不带任何选项时输出完整 Markdown 内容。
### 参数说明
以下参数互斥,一次只能使用一个:
| 短选项 | 长选项 | 说明 |
|--------|--------|------|
| `-c` | `--count` | 输出解析后文档的总字符数(不含换行符) |
| `-l` | `--lines` | 输出解析后文档的总行数 |
| `-t` | `--titles` | 输出所有标题行1-6 级,含 `#` 前缀) |
| `-tc <name>` | `--title-content <name>` | 提取指定标题及其下级内容(`name` 不包含 `#` 号) |
| `-s <pattern>` | `--search <pattern>` | 使用正则表达式搜索文档,返回匹配结果 |
搜索辅助参数(与 `-s` 配合使用):
| 短选项 | 长选项 | 说明 |
|--------|--------|------|
| `-n <num>` | `--context <num>` | 每个匹配结果包含的前后非空行数默认2 |
PDF 专用参数:
| 长选项 | 说明 |
|--------|------|
| `--high-res` | 启用 OCR 版面分析(需要额外依赖,处理较慢) |
### 退出码
| 退出码 | 含义 |
|--------|------|
| `0` | 解析成功 |
| `1` | 错误(文件不存在、格式无效、所有解析器失败、标题未找到、正则无效或无匹配) |
### 使用示例
**输出完整 Markdown**
```bash
python parser.py report.docx # 输出到终端
python parser.py report.docx > output.md # 重定向到文件
```
**统计信息(`-c` / `-l`**
输出单个数字,适合管道处理。
```bash
$ python parser.py report.docx -c
8500
$ python parser.py report.docx -l
215
```
**提取标题(`-t`**
每行一个标题,保留 `#` 前缀和层级。PDF 通常不包含语义化标题层级。
```bash
$ python parser.py report.docx -t
# 第一章 概述
## 1.1 背景
## 1.2 目标
# 第二章 实现
```
**提取标题内容(`-tc`**
输出指定标题及其下级内容。如果文档中有多个同名标题,用 `---` 分隔。每段输出包含上级标题链。
```bash
$ python parser.py report.docx -tc "1.1 背景"
# 第一章 概述
## 1.1 背景
这是背景的详细内容...
```
**搜索(`-s`**
支持 Python 正则表达式语法。多个匹配结果用 `---` 分隔。`-n` 控制上下文行数。
```bash
$ python parser.py report.docx -s "测试" -n 1
上一行内容
包含**测试**关键词的行
下一行内容
---
另一处上一行
另一处**测试**内容
另一处下一行
```
### 批量处理
```bash
# Linux/Mac
for file in *.docx; do
python parser.py "$file" > "${file%.docx}.md"
done
# Windows PowerShell
Get-ChildItem *.docx | ForEach-Object {
python parser.py $_.FullName > ($_.BaseName + ".md")
}
```
### 管道使用
```bash
# 过滤包含关键词的行
python parser.py report.docx | grep "重要" > important.md
# 统计含表格行数
python parser.py data.xlsx | grep -c "^|"
```
## 安装
脚本基于 Python 3.6+ 运行。每种文档类型有多个解析器按优先级依次尝试,建议安装对应类型的**所有**依赖以获得最佳兼容性。也可以只安装部分依赖,脚本会自动选择可用的解析器。
### DOCX
优先级Docling → unstructured → pypandoc-binary → MarkItDown → python-docx → XML 原生
```bash
# pip
pip install docling "unstructured[docx]" markdownify pypandoc-binary "markitdown[docx]" python-docx
# uv一键运行无需预安装
uv run --with docling --with "unstructured[docx]" --with markdownify --with pypandoc-binary --with "markitdown[docx]" --with python-docx parser.py report.docx
```
### PPTX
优先级Docling → unstructured → MarkItDown → python-pptx → XML 原生
```bash
# pip
pip install docling "unstructured[pptx]" markdownify "markitdown[pptx]" python-pptx
# uv
uv run --with docling --with "unstructured[pptx]" --with markdownify --with "markitdown[pptx]" --with python-pptx parser.py presentation.pptx
```
### XLSX
优先级Docling → unstructured → MarkItDown → pandas → XML 原生
```bash
# pip
pip install docling "unstructured[xlsx]" markdownify "markitdown[xlsx]" pandas tabulate
# uv
uv run --with docling --with "unstructured[xlsx]" --with markdownify --with "markitdown[xlsx]" --with pandas --with tabulate parser.py data.xlsx
```
### PDF
默认优先级Docling → unstructured (fast) → MarkItDown → pypdf
`--high-res` 优先级Docling OCR → unstructured OCR (hi_res) → Docling → unstructured (fast) → MarkItDown → pypdf
```bash
# pip - 基础文本提取fast 策略,无需 OCR
pip install docling "unstructured[pdf]" markdownify "markitdown[pdf]" pypdf
# pip - OCR 版面分析(--high-res 所需依赖)
pip install docling "unstructured[pdf]" unstructured-paddleocr "paddlepaddle==2.6.2" ml-dtypes markdownify "markitdown[pdf]" pypdf
# uv - 基础文本提取
uv run --with docling --with "unstructured[pdf]" --with markdownify --with "markitdown[pdf]" --with pypdf parser.py report.pdf
# uv - OCR 版面分析
uv run --with docling --with "unstructured[pdf]" --with unstructured-paddleocr --with "paddlepaddle==2.6.2" --with ml-dtypes --with markdownify --with "markitdown[pdf]" --with pypdf parser.py report.pdf --high-res
```
> PDF 无内置 XML 原生解析,至少需要安装 pypdf。默认模式下 Docling 不启用 OCRunstructured 使用 fast 策略。指定 `--high-res` 后Docling 启用 OCRunstructured 使用 hi_res 策略配合 PaddleOCR 进行版面分析。hi_res 策略需要额外安装 `unstructured-paddleocr`、`paddlepaddle==2.6.2`、`ml-dtypes`。PaddlePaddle 必须锁定 2.x 版本3.x 在 Windows 上有 OneDNN 兼容问题。
>
### 安装所有依赖
```bash
# pip - 基础文本提取(不包含 PDF OCR
pip install docling "unstructured[docx,pptx,xlsx,pdf]" markdownify pypandoc-binary "markitdown[docx,pptx,xlsx]" python-docx python-pptx pandas tabulate pypdf
# pip - 完整版(包含 PDF OCR
pip install docling "unstructured[docx,pptx,xlsx,pdf]" markdownify unstructured-paddleocr "paddlepaddle==2.6.2" ml-dtypes pypandoc-binary "markitdown[docx,pptx,xlsx,pdf]" python-docx python-pptx pandas tabulate pypdf
# uv - 基础文本提取
uv run --with docling --with "unstructured[docx,pptx,xlsx,pdf]" --with markdownify --with pypandoc-binary --with "markitdown[docx,pptx,xlsx]" --with python-docx --with python-pptx --with pandas --with tabulate --with pypdf parser.py file.docx
# uv - 完整版(包含 PDF OCR
uv run --with docling --with "unstructured[docx,pptx,xlsx,pdf]" --with markdownify --with unstructured-paddleocr --with "paddlepaddle==2.6.2" --with ml-dtypes --with pypandoc-binary --with "markitdown[docx,pptx,xlsx,pdf]" --with python-docx --with python-pptx --with pandas --with tabulate --with pypdf parser.py file.docx
```
### 依赖说明
**MarkItDown**:需要按文档类型安装可选依赖,直接 `pip install markitdown` 不包含任何格式支持。
```bash
pip install "markitdown[docx]" # DOCX
pip install "markitdown[pptx]" # PPTX
pip install "markitdown[xlsx]" # XLSX
pip install "markitdown[pdf]" # PDF
pip install "markitdown[docx,pptx,xlsx,pdf]" # 全部
```
**Docling**DOCX/PPTX/XLSX 使用 SimplePipeline 直接解析 XML 结构,不涉及 OCR。PDF 默认不启用 OCR`do_ocr=False`),指定 `--high-res` 后启用 OCR`do_ocr=True`)。首次运行 OCR 模式会自动下载模型到缓存目录,需保持网络连通。
**unstructured**:需同时安装 `markdownify`。支持按文档类型安装特定 extras 以减少依赖量:
- `unstructured[docx]` - DOCX 处理(仅需 `python-docx`
- `unstructured[pptx]` - PPTX 处理(仅需 `python-pptx`
- `unstructured[xlsx]` - XLSX 处理(需 `openpyxl``xlrd``pandas` 等)
- `unstructured` - 基础包(用于 PDF fast 策略)
- `unstructured[all-docs]` - 所有文档类型(包含大量不必要的 OCR/视觉依赖)
**PaddleOCR**:不能用 `paddleocr` 代替 `unstructured-paddleocr`unstructured 查找的模块名是 `unstructured_paddleocr`
## 输出格式
### Markdown 文档结构
无选项时输出完整 Markdown包含以下元素
```markdown
# 一级标题
正文段落
## 二级标题
- 无序列表项
- 无序列表项
1. 有序列表项
2. 有序列表项
| 列1 | 列2 | 列3 |
|------|------|------|
| 数据1 | 数据2 | 数据3 |
**粗体** *斜体* <u>下划线</u>
```
### 各格式特有结构
**PPTX** — 每张幻灯片以 `## Slide N` 为标题,幻灯片之间以 `---` 分隔:
```markdown
## Slide 1
幻灯片 1 的内容
---
## Slide 2
幻灯片 2 的内容
---
```
**XLSX** — 以 `## SheetName` 区分工作表,数据以 Markdown 表格呈现:
```markdown
# Excel数据转换结果 (原生XML解析)
## Sheet1
| 列1 | 列2 | 列3 |
|------|------|------|
| 数据1 | 数据2 | 数据3 |
## Sheet2
| 列A | 列B |
|------|------|
| 值1 | 值2 |
```
**PDF** — 纯文本流通常不包含语义化标题层级PDF 是版面描述格式,标题只是视觉样式)。使用 Docling 或 unstructured hi_res 策略可通过版面分析识别部分标题,但准确度取决于排版质量。
### 内容自动处理
输出前会自动进行以下处理:
| 处理 | 说明 |
|------|------|
| 图片移除 | 删除 `![alt](url)` 语法 |
| 空行规范化 | 连续多个空行合并为一个 |
| RGB 噪声过滤 | 移除 `R:255 G:128 B:0` 格式的颜色值行(仅 unstructured 解析器) |
| 页码噪声过滤 | 移除 `— 3 —` 格式的页码行(仅 unstructured 解析器) |
| 页眉/页脚过滤 | 自动跳过 Header/Footer 元素(仅 unstructured 解析器) |
## 错误处理
### 错误消息
```bash
# 文件不存在
$ python parser.py missing.docx
错误: 文件不存在: missing.docx
# 格式无效
$ python parser.py readme.txt
错误: 不是有效的 DOCX、PPTX、XLSX 或 PDF 格式: readme.txt
# 所有解析器失败DOCX 示例)
$ python parser.py report.docx
所有解析方法均失败:
- Docling: docling 库未安装
- unstructured: unstructured 库未安装
- pypandoc-binary: pypandoc-binary 库未安装
- MarkItDown: MarkItDown 库未安装
- python-docx: python-docx 库未安装
- XML 原生解析: document.xml 不存在或无法访问
# 标题未找到
$ python parser.py report.docx -tc "不存在的标题"
错误: 未找到标题 '不存在的标题'
# 无效正则或无匹配
$ python parser.py report.docx -s "[invalid"
错误: 正则表达式无效或未找到匹配: '[invalid'
```
### 解析器回退机制
脚本按优先级依次尝试各解析器。每个解析器失败后记录原因(库未安装 / 解析失败 / 文档为空),然后自动尝试下一个。全部失败时输出汇总信息并以退出码 1 退出。
## 解析器对比
### DOCX
| 解析器 | 优点 | 缺点 | 适用场景 |
|---------|------|--------|---------|
| **Docling** | 单一依赖覆盖全格式;自动 OCR输出结构稳定 | 首次需下载模型;内存占用较高 | 一键解析;需要 OCR |
| **unstructured** | 元素类型感知自动过滤噪声HTML 表格转 Markdown | 需 `unstructured[docx]` / `[pptx]` / `[xlsx]` + `markdownify` | 结构化输出;表格转换 |
| **pypandoc-binary** | 自带 Pandoc输出整洁错误信息清晰 | 仅 DOCX包体积大 | 标准化 Markdown |
| **MarkItDown** | 微软官方;格式规范 | 输出简洁 | 标准格式;自动化处理 |
| **python-docx** | 输出最详细;保留完整结构;支持复杂样式 | 可能含多余空行 | 精确控制输出 |
| **XML 原生** | 无需依赖;速度快 | 样式处理有限 | 依赖不可用时兜底 |
### PPTX
| 解析器 | 优点 | 缺点 | 适用场景 |
|---------|------|--------|---------|
| **Docling** | 文本/表格/图片 OCR统一 Markdown | 需下载模型 | 一次性转换;含图片的 PPTX |
| **unstructured** | 元素感知;过滤 RGB 噪声;表格转换 | 需 `unstructured[pptx]` + `markdownify` | 结构化输出 |
| **MarkItDown** | 自动 Slide 分隔;简洁 | 详细度低 | 快速预览 |
| **python-pptx** | 输出最详细;支持层级列表 | 依赖私有 API | 完整内容提取 |
| **XML 原生** | 无需依赖;速度快 | 分组简单 | 依赖不可用时兜底 |
### XLSX
| 解析器 | 优点 | 缺点 | 适用场景 |
|---------|------|--------|---------|
| **Docling** | 全表导出;处理合并单元格/图像 OCR | 大表可能慢 | 快速全表转换 |
| **unstructured** | 元素感知;过滤噪声;表格转换 | 需 `unstructured[xlsx]` + `markdownify` | 结构化输出 |
| **MarkItDown** | 支持多工作表;简洁 | 详细度低 | 快速预览 |
| **pandas** | 功能强大;支持复杂表格 | 需 `pandas` + `tabulate` | 数据分析 |
| **XML 原生** | 无需依赖;支持所有单元格类型 | 无数据处理能力 | 依赖不可用时兜底 |
### PDF
| 解析器 | 模式 | 优点 | 缺点 | 适用场景 |
|---------|------|------|--------|---------|
| **Docling** | 默认 | 结构化 Markdown表格/图片占位 | 首次需下载模型 | 有文本层的 PDF |
| **Docling OCR** | `--high-res` | 内置 OCR结构化 Markdown | 模型体积大OCR 耗时长 | 扫描版 PDF多语言 |
| **unstructured** | 默认 | fast 策略;速度快 | 不做版面分析;标题不可靠 | 快速文本提取 |
| **unstructured OCR** | `--high-res` | hi_res 版面分析 + PaddleOCR标题识别 | 需额外 PaddleOCR 依赖 | 版面分析OCR |
| **MarkItDown** | 通用 | 微软官方;格式规范 | 输出简洁 | 标准格式 |
| **pypdf** | 通用 | 轻量;速度快;安装简单 | 功能简单 | 快速文本提取 |
## 常见问题
### 为什么有些内容没有提取到?
不同解析器输出详细度不同。优先级高的解析器不一定输出最详细——Docling 和 unstructured 侧重结构化python-docx/python-pptx 输出最详细但不做噪声过滤。建议安装对应类型的所有依赖,脚本会自动选择优先级最高的可用解析器。
### PDF 文件没有标题层级?
PDF 是版面描述格式,不包含语义化标题结构。使用 `--high-res` 参数可启用 Docling OCR 或 unstructured hi_res 策略,通过版面分析识别部分标题,准确度取决于排版质量。默认模式下建议用 `-s` 搜索定位内容,或用 `-c` / `-l` 了解文档规模。
### 表格格式不正确?
XML 原生解析器对复杂表格(合并单元格、嵌套表格)支持有限。安装 Docling、unstructured 或对应的专用库可获得更好的表格处理效果。
### 中文显示乱码?
脚本输出 UTF-8 编码,确保终端支持:
```bash
# Linux/Mac
export LANG=en_US.UTF-8
# Windows PowerShell
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
```
### 如何只使用特定解析器?
当前版本不支持指定解析器,总是按优先级自动选择。可以通过只安装目标解析器的依赖来间接控制——未安装的解析器会被跳过。
### 大文件处理慢?
Docling 和 unstructured 对大文件较慢(尤其是 OCR。如果只需要快速提取文本可以只安装轻量依赖如 pypdf、python-docx让脚本回退到这些解析器。DOCX/PPTX/XLSX 不安装任何依赖时使用 XML 原生解析,速度最快。
## 文件结构
```
scripts/
├── common.py # 公共函数和常量
├── docx_parser.py # DOCX 文件解析
├── pptx_parser.py # PPTX 文件解析
├── xlsx_parser.py # XLSX 文件解析
├── pdf_parser.py # PDF 文件解析
├── parser.py # 命令行入口
└── README.md # 本文档
```

View File

@@ -1,337 +0,0 @@
#!/usr/bin/env python3
"""文档解析器的公共模块,包含所有格式共享的工具函数和验证函数。"""
import os
import re
import zipfile
from typing import List, Optional, Tuple
IMAGE_PATTERN = re.compile(r"!\[[^\]]*\]\([^)]+\)")
# unstructured 噪声匹配: pptx 中的 RGB 颜色值(如 "R:255 G:128 B:0"
_RGB_PATTERN = re.compile(r"^R:\d+\s+G:\d+\s+B:\d+$")
# unstructured 噪声匹配: 破折号页码(如 "— 3 —"
_PAGE_NUMBER_PATTERN = re.compile(r"^—\s*\d+\s*—$")
def parse_with_markitdown(
file_path: str,
) -> Tuple[Optional[str], Optional[str]]:
"""使用 MarkItDown 库解析文件"""
try:
from markitdown import MarkItDown
md = MarkItDown()
result = md.convert(file_path)
if not result.text_content.strip():
return None, "文档为空"
return result.text_content, None
except ImportError:
return None, "MarkItDown 库未安装"
except Exception as e:
return None, f"MarkItDown 解析失败: {str(e)}"
def parse_with_docling(file_path: str) -> Tuple[Optional[str], Optional[str]]:
"""使用 docling 库解析文件"""
try:
from docling.document_converter import DocumentConverter
except ImportError:
return None, "docling 库未安装"
try:
converter = DocumentConverter()
result = converter.convert(file_path)
markdown_content = result.document.export_to_markdown()
if not markdown_content.strip():
return None, "文档为空"
return markdown_content, None
except Exception as e:
return None, f"docling 解析失败: {str(e)}"
def build_markdown_table(rows_data: List[List[str]]) -> str:
"""将二维列表转换为 Markdown 表格格式"""
if not rows_data or not rows_data[0]:
return ""
md_lines = []
for i, row_data in enumerate(rows_data):
row_text = [cell if cell else "" for cell in row_data]
md_lines.append("| " + " | ".join(row_text) + " |")
if i == 0:
md_lines.append("| " + " | ".join(["---"] * len(row_text)) + " |")
return "\n".join(md_lines) + "\n\n"
def flush_list_stack(list_stack: List[str], target: List[str]) -> None:
"""将列表堆栈中的非空项添加到目标列表并清空堆栈"""
for item in list_stack:
if item:
target.append(item + "\n")
list_stack.clear()
def safe_open_zip(zip_file: zipfile.ZipFile, name: str) -> Optional[zipfile.ZipExtFile]:
"""安全地从 ZipFile 中打开文件,防止路径遍历攻击"""
if not name:
return None
if name.startswith("/") or name.startswith(".."):
return None
if "/../" in name or name.endswith("/.."):
return None
if "\\" in name:
return None
return zip_file.open(name)
_CONSECUTIVE_BLANK_LINES = re.compile(r"\n{3,}")
def normalize_markdown_whitespace(content: str) -> str:
"""规范化 Markdown 空白字符,保留单行空行"""
return _CONSECUTIVE_BLANK_LINES.sub("\n\n", content)
def _is_valid_ooxml(file_path: str, required_files: List[str]) -> bool:
try:
with zipfile.ZipFile(file_path, "r") as zip_file:
names = set(zip_file.namelist())
return all(r in names for r in required_files)
except (zipfile.BadZipFile, zipfile.LargeZipFile):
return False
_DOCX_REQUIRED = ["[Content_Types].xml", "_rels/.rels", "word/document.xml"]
_PPTX_REQUIRED = ["[Content_Types].xml", "_rels/.rels", "ppt/presentation.xml"]
_XLSX_REQUIRED = ["[Content_Types].xml", "_rels/.rels", "xl/workbook.xml"]
def is_valid_docx(file_path: str) -> bool:
"""验证文件是否为有效的 DOCX 格式"""
return _is_valid_ooxml(file_path, _DOCX_REQUIRED)
def is_valid_pptx(file_path: str) -> bool:
"""验证文件是否为有效的 PPTX 格式"""
return _is_valid_ooxml(file_path, _PPTX_REQUIRED)
def is_valid_xlsx(file_path: str) -> bool:
"""验证文件是否为有效的 XLSX 格式"""
return _is_valid_ooxml(file_path, _XLSX_REQUIRED)
def is_valid_pdf(file_path: str) -> bool:
"""验证文件是否为有效的 PDF 格式"""
try:
with open(file_path, "rb") as f:
header = f.read(4)
return header == b"%PDF"
except (IOError, OSError):
return False
def remove_markdown_images(markdown_text: str) -> str:
"""移除 Markdown 文本中的图片标记"""
return IMAGE_PATTERN.sub("", markdown_text)
def get_heading_level(line: str) -> int:
"""获取 Markdown 行的标题级别1-6非标题返回 0"""
stripped = line.lstrip()
if not stripped.startswith("#"):
return 0
without_hash = stripped.lstrip("#")
level = len(stripped) - len(without_hash)
if not (1 <= level <= 6):
return 0
if len(stripped) == level:
return level
if stripped[level] != " ":
return 0
return level
def extract_titles(markdown_text: str) -> List[str]:
"""提取 markdown 文本中的所有标题行1-6级"""
title_lines = []
for line in markdown_text.split("\n"):
if get_heading_level(line) > 0:
title_lines.append(line.lstrip())
return title_lines
def extract_title_content(markdown_text: str, title_name: str) -> Optional[str]:
"""提取所有指定标题及其下级内容(每个包含上级标题)"""
lines = markdown_text.split("\n")
match_indices = []
for i, line in enumerate(lines):
level = get_heading_level(line)
if level > 0:
stripped = line.lstrip()
title_text = stripped[level:].strip()
if title_text == title_name:
match_indices.append(i)
if not match_indices:
return None
result_lines = []
for match_num, idx in enumerate(match_indices):
if match_num > 0:
result_lines.append("\n---\n")
target_level = get_heading_level(lines[idx])
parent_titles = []
current_level = target_level
for i in range(idx - 1, -1, -1):
line_level = get_heading_level(lines[i])
if line_level > 0 and line_level < current_level:
parent_titles.append(lines[i])
current_level = line_level
if current_level == 1:
break
parent_titles.reverse()
result_lines.extend(parent_titles)
result_lines.append(lines[idx])
for i in range(idx + 1, len(lines)):
line = lines[i]
line_level = get_heading_level(line)
if line_level == 0 or line_level > target_level:
result_lines.append(line)
else:
break
return "\n".join(result_lines)
def search_markdown(
content: str, pattern: str, context_lines: int = 0
) -> Optional[str]:
"""使用正则表达式搜索 markdown 文档,返回匹配结果及其上下文"""
try:
regex = re.compile(pattern)
except re.error:
return None
lines = content.split("\n")
non_empty_indices = []
non_empty_to_original = {}
for i, line in enumerate(lines):
if line.strip():
non_empty_indices.append(i)
non_empty_to_original[i] = len(non_empty_indices) - 1
matched_non_empty_indices = []
for orig_idx in non_empty_indices:
if regex.search(lines[orig_idx]):
matched_non_empty_indices.append(non_empty_to_original[orig_idx])
if not matched_non_empty_indices:
return None
merged_ranges = []
current_start = matched_non_empty_indices[0]
current_end = matched_non_empty_indices[0]
for idx in matched_non_empty_indices[1:]:
if idx - current_end <= context_lines * 2:
current_end = idx
else:
merged_ranges.append((current_start, current_end))
current_start = idx
current_end = idx
merged_ranges.append((current_start, current_end))
results = []
for start, end in merged_ranges:
context_start_idx = max(0, start - context_lines)
context_end_idx = min(len(non_empty_indices) - 1, end + context_lines)
start_line_idx = non_empty_indices[context_start_idx]
end_line_idx = non_empty_indices[context_end_idx]
result_lines = [
line
for i, line in enumerate(lines)
if start_line_idx <= i <= end_line_idx
]
results.append("\n".join(result_lines))
return "\n---\n".join(results)
_FILE_TYPE_VALIDATORS = {
".docx": is_valid_docx,
".pptx": is_valid_pptx,
".xlsx": is_valid_xlsx,
".pdf": is_valid_pdf,
}
def detect_file_type(file_path: str) -> Optional[str]:
"""检测文件类型,返回 'docx''pptx''xlsx''pdf'"""
ext = os.path.splitext(file_path)[1].lower()
validator = _FILE_TYPE_VALIDATORS.get(ext)
if validator and validator(file_path):
return ext.lstrip(".")
return None
def _unstructured_elements_to_markdown(
elements: list, trust_titles: bool = True
) -> str:
"""将 unstructured 解析出的元素列表转换为 Markdown 文本"""
try:
import markdownify as md_lib
from unstructured.documents.elements import (
Footer,
Header,
Image,
ListItem,
PageBreak,
PageNumber,
Table,
Title,
)
except ImportError:
return "\n\n".join(
el.text for el in elements if hasattr(el, "text") and el.text and el.text.strip()
)
skip_types = (Header, Footer, PageBreak, PageNumber)
parts = []
for el in elements:
if isinstance(el, skip_types):
continue
text = el.text.strip() if hasattr(el, "text") else str(el).strip()
if not text or _RGB_PATTERN.match(text) or _PAGE_NUMBER_PATTERN.match(text):
continue
if isinstance(el, Table):
html = getattr(el.metadata, "text_as_html", None)
if html:
parts.append(md_lib.markdownify(html, strip=["img"]).strip())
else:
parts.append(str(el))
elif isinstance(el, Title) and trust_titles:
depth = getattr(el.metadata, "category_depth", None) or 1
depth = min(max(depth, 1), 4)
parts.append(f"{'#' * depth} {text}")
elif isinstance(el, ListItem):
parts.append(f"- {text}")
elif isinstance(el, Image):
path = getattr(el.metadata, "image_path", None) or ""
if path:
parts.append(f"![image]({path})")
else:
parts.append(text)
return "\n\n".join(parts)

View File

@@ -1,308 +0,0 @@
#!/usr/bin/env python3
"""DOCX 文件解析模块,提供多种解析方法。"""
import xml.etree.ElementTree as ET
import zipfile
from typing import Any, List, Optional, Tuple
from common import (
_unstructured_elements_to_markdown,
build_markdown_table,
parse_with_docling,
parse_with_markitdown,
safe_open_zip,
)
def parse_docx_with_docling(file_path: str) -> Tuple[Optional[str], Optional[str]]:
"""使用 docling 库解析 DOCX 文件"""
return parse_with_docling(file_path)
def parse_docx_with_unstructured(file_path: str) -> Tuple[Optional[str], Optional[str]]:
"""使用 unstructured 库解析 DOCX 文件"""
try:
from unstructured.partition.docx import partition_docx
except ImportError:
return None, "unstructured 库未安装"
try:
elements = partition_docx(filename=file_path, infer_table_structure=True)
content = _unstructured_elements_to_markdown(elements)
if not content.strip():
return None, "文档为空"
return content, None
except Exception as e:
return None, f"unstructured 解析失败: {str(e)}"
def parse_docx_with_pypandoc(file_path: str) -> Tuple[Optional[str], Optional[str]]:
"""使用 pypandoc-binary 库解析 DOCX 文件。"""
try:
import pypandoc
except ImportError:
return None, "pypandoc-binary 库未安装"
try:
content = pypandoc.convert_file(
source_file=file_path,
to="md",
format="docx",
outputfile=None,
extra_args=["--wrap=none"],
)
except OSError as exc:
return None, f"pypandoc-binary 缺少 Pandoc 可执行文件: {exc}"
except RuntimeError as exc:
return None, f"pypandoc-binary 解析失败: {exc}"
content = content.strip()
if not content:
return None, "文档为空"
return content, None
def parse_docx_with_markitdown(file_path: str) -> Tuple[Optional[str], Optional[str]]:
"""使用 MarkItDown 库解析 DOCX 文件"""
return parse_with_markitdown(file_path)
def parse_docx_with_python_docx(file_path: str) -> Tuple[Optional[str], Optional[str]]:
"""使用 python-docx 库解析 DOCX 文件"""
try:
from docx import Document
except ImportError:
return None, "python-docx 库未安装"
try:
doc = Document(file_path)
_HEADING_LEVELS = {
"Title": 1, "Heading 1": 1, "Heading 2": 2, "Heading 3": 3,
"Heading 4": 4, "Heading 5": 5, "Heading 6": 6,
}
def get_heading_level(para: Any) -> int:
if para.style and para.style.name:
return _HEADING_LEVELS.get(para.style.name, 0)
return 0
_LIST_STYLES = {
"Bullet": "bullet", "Number": "number",
}
def get_list_style(para: Any) -> Optional[str]:
if not para.style or not para.style.name:
return None
style_name = para.style.name
if style_name in _LIST_STYLES:
return _LIST_STYLES[style_name]
if style_name.startswith("List Bullet"):
return "bullet"
if style_name.startswith("List Number"):
return "number"
return None
def convert_runs_to_markdown(runs: List[Any]) -> str:
result = []
for run in runs:
text = run.text
if not text:
continue
if run.bold:
text = f"**{text}**"
if run.italic:
text = f"*{text}*"
if run.underline:
text = f"<u>{text}</u>"
result.append(text)
return "".join(result)
def convert_table_to_markdown(table: Any) -> str:
rows_data = []
for row in table.rows:
row_data = []
for cell in row.cells:
cell_text = cell.text.strip().replace("\n", " ")
row_data.append(cell_text)
rows_data.append(row_data)
return build_markdown_table(rows_data)
markdown_lines = []
prev_was_list = False
from docx.table import Table as DocxTable
from docx.text.paragraph import Paragraph
for element in doc.element.body:
if element.tag.endswith('}p'):
para = Paragraph(element, doc)
text = convert_runs_to_markdown(para.runs)
if not text.strip():
continue
heading_level = get_heading_level(para)
if heading_level > 0:
markdown_lines.append(f"{'#' * heading_level} {text}")
prev_was_list = False
else:
list_style = get_list_style(para)
if list_style == "bullet":
if not prev_was_list and markdown_lines:
markdown_lines.append("")
markdown_lines.append(f"- {text}")
prev_was_list = True
elif list_style == "number":
if not prev_was_list and markdown_lines:
markdown_lines.append("")
markdown_lines.append(f"1. {text}")
prev_was_list = True
else:
if prev_was_list and markdown_lines:
markdown_lines.append("")
markdown_lines.append(text)
markdown_lines.append("")
prev_was_list = False
elif element.tag.endswith('}tbl'):
table = DocxTable(element, doc)
table_md = convert_table_to_markdown(table)
if table_md:
markdown_lines.append(table_md)
markdown_lines.append("")
prev_was_list = False
content = "\n".join(markdown_lines)
if not content.strip():
return None, "文档为空"
return content, None
except Exception as e:
return None, f"python-docx 解析失败: {str(e)}"
def parse_docx_with_xml(file_path: str) -> Tuple[Optional[str], Optional[str]]:
"""使用 XML 原生解析 DOCX 文件"""
word_namespace = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
namespaces = {"w": word_namespace}
_STYLE_NAME_TO_HEADING = {
"title": 1, "heading 1": 1, "heading 2": 2, "heading 3": 3,
"heading 4": 4, "heading 5": 5, "heading 6": 6,
}
def get_heading_level(style_id: Optional[str], style_to_level: dict) -> int:
return style_to_level.get(style_id, 0)
def get_list_style(style_id: Optional[str], style_to_list: dict) -> Optional[str]:
return style_to_list.get(style_id, None)
def extract_text_with_formatting(para: Any, namespaces: dict) -> str:
texts = []
for run in para.findall(".//w:r", namespaces=namespaces):
text_elem = run.find(".//w:t", namespaces=namespaces)
if text_elem is not None and text_elem.text:
text = text_elem.text
bold = run.find(".//w:b", namespaces=namespaces) is not None
italic = run.find(".//w:i", namespaces=namespaces) is not None
if bold:
text = f"**{text}**"
if italic:
text = f"*{text}*"
texts.append(text)
return "".join(texts).strip()
def convert_table_to_markdown(table_elem: Any, namespaces: dict) -> str:
rows = table_elem.findall(".//w:tr", namespaces=namespaces)
if not rows:
return ""
rows_data = []
for row in rows:
cells = row.findall(".//w:tc", namespaces=namespaces)
cell_texts = []
for cell in cells:
cell_text = extract_text_with_formatting(cell, namespaces)
cell_text = cell_text.replace("\n", " ").strip()
cell_texts.append(cell_text if cell_text else "")
if cell_texts:
rows_data.append(cell_texts)
return build_markdown_table(rows_data)
try:
style_to_level = {}
style_to_list = {}
markdown_lines = []
with zipfile.ZipFile(file_path) as zip_file:
try:
styles_file = safe_open_zip(zip_file, "word/styles.xml")
if styles_file:
styles_root = ET.parse(styles_file).getroot()
for style in styles_root.findall(
".//w:style", namespaces=namespaces
):
style_id = style.get(f"{{{word_namespace}}}styleId")
style_name_elem = style.find("w:name", namespaces=namespaces)
if style_id and style_name_elem is not None:
style_name = style_name_elem.get(f"{{{word_namespace}}}val")
if style_name:
style_name_lower = style_name.lower()
if style_name_lower in _STYLE_NAME_TO_HEADING:
style_to_level[style_id] = _STYLE_NAME_TO_HEADING[style_name_lower]
elif (
style_name_lower.startswith("list bullet")
or style_name_lower == "bullet"
):
style_to_list[style_id] = "bullet"
elif (
style_name_lower.startswith("list number")
or style_name_lower == "number"
):
style_to_list[style_id] = "number"
except Exception:
pass
document_file = safe_open_zip(zip_file, "word/document.xml")
if not document_file:
return None, "document.xml 不存在或无法访问"
root = ET.parse(document_file).getroot()
body = root.find(".//w:body", namespaces=namespaces)
if body is None:
return None, "document.xml 中未找到 w:body 元素"
for child in body.findall("./*", namespaces=namespaces):
if child.tag.endswith("}p"):
style_elem = child.find(".//w:pStyle", namespaces=namespaces)
style_id = (
style_elem.get(f"{{{word_namespace}}}val")
if style_elem is not None
else None
)
heading_level = get_heading_level(style_id, style_to_level)
list_style = get_list_style(style_id, style_to_list)
para_text = extract_text_with_formatting(child, namespaces)
if para_text:
if heading_level > 0:
markdown_lines.append(f"{'#' * heading_level} {para_text}")
elif list_style == "bullet":
markdown_lines.append(f"- {para_text}")
elif list_style == "number":
markdown_lines.append(f"1. {para_text}")
else:
markdown_lines.append(para_text)
markdown_lines.append("")
elif child.tag.endswith("}tbl"):
table_md = convert_table_to_markdown(child, namespaces)
if table_md:
markdown_lines.append(table_md)
markdown_lines.append("")
content = "\n".join(markdown_lines)
if not content.strip():
return None, "文档为空"
return content, None
except Exception as e:
return None, f"XML 解析失败: {str(e)}"

View File

@@ -1,166 +0,0 @@
#!/usr/bin/env python3
"""文档解析器命令行交互模块,提供命令行接口。支持 DOCX、PPTX、XLSX 和 PDF 文件。"""
import argparse
import logging
import os
import sys
import warnings
# 抑制第三方库的进度条和日志,仅保留解析结果输出
os.environ["HF_HUB_DISABLE_PROGRESS_BARS"] = "1"
os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1"
os.environ["TQDM_DISABLE"] = "1"
warnings.filterwarnings("ignore")
logging.disable(logging.WARNING)
import common
import docx_parser
import pdf_parser
import pptx_parser
import xlsx_parser
def main() -> None:
parser = argparse.ArgumentParser(
description="将 DOCX、PPTX、XLSX 或 PDF 文件解析为 Markdown"
)
parser.add_argument("file_path", help="DOCX、PPTX、XLSX 或 PDF 文件的绝对路径")
parser.add_argument(
"-n",
"--context",
type=int,
default=2,
help="与 -s 配合使用,指定每个检索结果包含的前后行数(不包含空行)",
)
parser.add_argument(
"--high-res",
action="store_true",
help="PDF 解析时启用 OCR 版面分析(需要额外依赖,处理较慢)",
)
group = parser.add_mutually_exclusive_group()
group.add_argument(
"-c", "--count", action="store_true", help="返回解析后的 markdown 文档的总字数"
)
group.add_argument(
"-l", "--lines", action="store_true", help="返回解析后的 markdown 文档的总行数"
)
group.add_argument(
"-t",
"--titles",
action="store_true",
help="返回解析后的 markdown 文档的标题行1-6级",
)
group.add_argument(
"-tc",
"--title-content",
help="指定标题名称,输出该标题及其下级内容(不包含#号)",
)
group.add_argument(
"-s",
"--search",
help="使用正则表达式搜索文档,返回所有匹配结果(用---分隔)",
)
args = parser.parse_args()
if not os.path.exists(args.file_path):
print(f"错误: 文件不存在: {args.file_path}")
sys.exit(1)
file_type = common.detect_file_type(args.file_path)
if not file_type:
print(f"错误: 不是有效的 DOCX、PPTX、XLSX 或 PDF 格式: {args.file_path}")
sys.exit(1)
if file_type == "docx":
parsers = [
("docling", docx_parser.parse_docx_with_docling),
("unstructured", docx_parser.parse_docx_with_unstructured),
("pypandoc-binary", docx_parser.parse_docx_with_pypandoc),
("MarkItDown", docx_parser.parse_docx_with_markitdown),
("python-docx", docx_parser.parse_docx_with_python_docx),
("XML 原生解析", docx_parser.parse_docx_with_xml),
]
elif file_type == "pptx":
parsers = [
("docling", pptx_parser.parse_pptx_with_docling),
("unstructured", pptx_parser.parse_pptx_with_unstructured),
("MarkItDown", pptx_parser.parse_pptx_with_markitdown),
("python-pptx", pptx_parser.parse_pptx_with_python_pptx),
("XML 原生解析", pptx_parser.parse_pptx_with_xml),
]
elif file_type == "xlsx":
parsers = [
("docling", xlsx_parser.parse_xlsx_with_docling),
("unstructured", xlsx_parser.parse_xlsx_with_unstructured),
("MarkItDown", xlsx_parser.parse_xlsx_with_markitdown),
("pandas", xlsx_parser.parse_xlsx_with_pandas),
("XML 原生解析", xlsx_parser.parse_xlsx_with_xml),
]
else:
if args.high_res:
parsers = [
("docling OCR", pdf_parser.parse_pdf_with_docling_ocr),
("unstructured OCR", pdf_parser.parse_pdf_with_unstructured_ocr),
("docling", pdf_parser.parse_pdf_with_docling),
("unstructured", pdf_parser.parse_pdf_with_unstructured),
("MarkItDown", pdf_parser.parse_pdf_with_markitdown),
("pypdf", pdf_parser.parse_pdf_with_pypdf),
]
else:
parsers = [
("docling", pdf_parser.parse_pdf_with_docling),
("unstructured", pdf_parser.parse_pdf_with_unstructured),
("MarkItDown", pdf_parser.parse_pdf_with_markitdown),
("pypdf", pdf_parser.parse_pdf_with_pypdf),
]
failures = []
content = None
for parser_name, parser_func in parsers:
content, error = parser_func(args.file_path)
if content is not None:
content = common.remove_markdown_images(content)
content = common.normalize_markdown_whitespace(content)
break
else:
failures.append(f"- {parser_name}: {error}")
if content is None:
print("所有解析方法均失败:")
for failure in failures:
print(failure)
sys.exit(1)
if args.count:
print(len(content.replace("\n", "")))
elif args.lines:
print(len(content.split("\n")))
elif args.titles:
titles = common.extract_titles(content)
for title in titles:
print(title)
elif args.title_content:
title_content = common.extract_title_content(content, args.title_content)
if title_content is None:
print(f"错误: 未找到标题 '{args.title_content}'")
sys.exit(1)
print(title_content, end="")
elif args.search:
search_result = common.search_markdown(content, args.search, args.context)
if search_result is None:
print(f"错误: 正则表达式无效或未找到匹配: '{args.search}'")
sys.exit(1)
print(search_result, end="")
else:
print(content, end="")
if __name__ == "__main__":
main()

View File

@@ -1,134 +0,0 @@
#!/usr/bin/env python3
"""PDF 文件解析模块,提供多种解析方法。"""
from typing import Optional, Tuple
from common import _unstructured_elements_to_markdown, parse_with_markitdown
def parse_pdf_with_docling(file_path: str) -> Tuple[Optional[str], Optional[str]]:
"""使用 docling 库解析 PDF 文件(不启用 OCR"""
try:
from docling.datamodel.base_models import InputFormat
from docling.datamodel.pipeline_options import PdfPipelineOptions
from docling.document_converter import DocumentConverter, PdfFormatOption
except ImportError:
return None, "docling 库未安装"
try:
converter = DocumentConverter(
format_options={
InputFormat.PDF: PdfFormatOption(
pipeline_options=PdfPipelineOptions(do_ocr=False)
)
}
)
result = converter.convert(file_path)
markdown_content = result.document.export_to_markdown()
if not markdown_content.strip():
return None, "文档为空"
return markdown_content, None
except Exception as e:
return None, f"docling 解析失败: {str(e)}"
def parse_pdf_with_docling_ocr(file_path: str) -> Tuple[Optional[str], Optional[str]]:
"""使用 docling 库解析 PDF 文件(启用 OCR"""
try:
from docling.document_converter import DocumentConverter
except ImportError:
return None, "docling 库未安装"
try:
converter = DocumentConverter()
result = converter.convert(file_path)
markdown_content = result.document.export_to_markdown()
if not markdown_content.strip():
return None, "文档为空"
return markdown_content, None
except Exception as e:
return None, f"docling OCR 解析失败: {str(e)}"
def parse_pdf_with_unstructured(file_path: str) -> Tuple[Optional[str], Optional[str]]:
"""使用 unstructured 库解析 PDF 文件fast 策略)"""
try:
from unstructured.partition.pdf import partition_pdf
except ImportError:
return None, "unstructured 库未安装"
try:
elements = partition_pdf(
filename=file_path,
infer_table_structure=True,
strategy="fast",
languages=["chi_sim"],
)
# fast 策略不做版面分析Title 类型标注不可靠
content = _unstructured_elements_to_markdown(elements, trust_titles=False)
if not content.strip():
return None, "文档为空"
return content, None
except Exception as e:
return None, f"unstructured 解析失败: {str(e)}"
def parse_pdf_with_unstructured_ocr(
file_path: str,
) -> Tuple[Optional[str], Optional[str]]:
"""使用 unstructured 库解析 PDF 文件hi_res 策略 + PaddleOCR"""
try:
from unstructured.partition.pdf import partition_pdf
except ImportError:
return None, "unstructured 库未安装"
try:
from unstructured.partition.utils.constants import OCR_AGENT_PADDLE
except ImportError:
return None, "unstructured-paddleocr 库未安装"
try:
elements = partition_pdf(
filename=file_path,
infer_table_structure=True,
strategy="hi_res",
languages=["chi_sim"],
ocr_agent=OCR_AGENT_PADDLE,
table_ocr_agent=OCR_AGENT_PADDLE,
)
content = _unstructured_elements_to_markdown(elements, trust_titles=True)
if not content.strip():
return None, "文档为空"
return content, None
except Exception as e:
return None, f"unstructured OCR 解析失败: {str(e)}"
def parse_pdf_with_markitdown(file_path: str) -> Tuple[Optional[str], Optional[str]]:
"""使用 MarkItDown 库解析 PDF 文件"""
return parse_with_markitdown(file_path)
def parse_pdf_with_pypdf(file_path: str) -> Tuple[Optional[str], Optional[str]]:
"""使用 pypdf 库解析 PDF 文件"""
try:
from pypdf import PdfReader
except ImportError:
return None, "pypdf 库未安装"
try:
reader = PdfReader(file_path)
md_content = []
for page in reader.pages:
text = page.extract_text(extraction_mode="plain")
if text and text.strip():
md_content.append(text.strip())
md_content.append("")
content = "\n".join(md_content).strip()
if not content:
return None, "文档为空"
return content, None
except Exception as e:
return None, f"pypdf 解析失败: {str(e)}"

View File

@@ -1,330 +0,0 @@
#!/usr/bin/env python3
"""PPTX 文件解析模块,提供三种解析方法。"""
import re
import xml.etree.ElementTree as ET
import zipfile
from typing import Any, List, Optional, Tuple
from common import (
_unstructured_elements_to_markdown,
build_markdown_table,
flush_list_stack,
parse_with_docling,
parse_with_markitdown,
)
def parse_pptx_with_docling(file_path: str) -> Tuple[Optional[str], Optional[str]]:
"""使用 docling 库解析 PPTX 文件"""
return parse_with_docling(file_path)
def parse_pptx_with_unstructured(file_path: str) -> Tuple[Optional[str], Optional[str]]:
"""使用 unstructured 库解析 PPTX 文件"""
try:
from unstructured.partition.pptx import partition_pptx
except ImportError:
return None, "unstructured 库未安装"
try:
elements = partition_pptx(
filename=file_path, infer_table_structure=True, include_metadata=True
)
content = _unstructured_elements_to_markdown(elements)
if not content.strip():
return None, "文档为空"
return content, None
except Exception as e:
return None, f"unstructured 解析失败: {str(e)}"
def parse_pptx_with_markitdown(file_path: str) -> Tuple[Optional[str], Optional[str]]:
"""使用 MarkItDown 库解析 PPTX 文件"""
return parse_with_markitdown(file_path)
def extract_formatted_text_pptx(runs: List[Any]) -> str:
"""从 PPTX 文本运行中提取带有格式的文本"""
result = []
for run in runs:
if not run.text:
continue
text = run.text
font = run.font
is_bold = getattr(font, "bold", False) or False
is_italic = getattr(font, "italic", False) or False
if is_bold and is_italic:
text = f"***{text}***"
elif is_bold:
text = f"**{text}**"
elif is_italic:
text = f"*{text}*"
result.append(text)
return "".join(result).strip()
def convert_table_to_md_pptx(table: Any) -> str:
"""将 PPTX 表格转换为 Markdown 格式"""
rows_data = []
for row in table.rows:
row_data = []
for cell in row.cells:
cell_content = []
for para in cell.text_frame.paragraphs:
text = extract_formatted_text_pptx(para.runs)
if text:
cell_content.append(text)
cell_text = " ".join(cell_content).strip()
row_data.append(cell_text if cell_text else "")
rows_data.append(row_data)
return build_markdown_table(rows_data)
def parse_pptx_with_python_pptx(file_path: str) -> Tuple[Optional[str], Optional[str]]:
"""使用 python-pptx 库解析 PPTX 文件"""
try:
from pptx import Presentation
from pptx.enum.shapes import MSO_SHAPE_TYPE
except ImportError:
return None, "python-pptx 库未安装"
_A_NS = {"a": "http://schemas.openxmlformats.org/drawingml/2006/main"}
try:
prs = Presentation(file_path)
md_content = []
for slide_num, slide in enumerate(prs.slides, 1):
md_content.append(f"\n## Slide {slide_num}\n")
list_stack = []
for shape in slide.shapes:
if shape.shape_type == MSO_SHAPE_TYPE.PICTURE:
continue
if hasattr(shape, "has_table") and shape.has_table:
if list_stack:
flush_list_stack(list_stack, md_content)
table_md = convert_table_to_md_pptx(shape.table)
md_content.append(table_md)
if hasattr(shape, "text_frame"):
for para in shape.text_frame.paragraphs:
pPr = para._element.pPr
is_list = False
if pPr is not None:
is_list = (
para.level > 0
or pPr.find(".//a:buChar", namespaces=_A_NS) is not None
or pPr.find(".//a:buAutoNum", namespaces=_A_NS) is not None
)
if is_list:
level = para.level
while len(list_stack) <= level:
list_stack.append("")
text = extract_formatted_text_pptx(para.runs)
if text:
is_ordered = (
pPr is not None
and pPr.find(".//a:buAutoNum", namespaces=_A_NS) is not None
)
marker = "1. " if is_ordered else "- "
indent = " " * level
list_stack[level] = f"{indent}{marker}{text}"
for i in range(len(list_stack)):
if list_stack[i]:
md_content.append(list_stack[i] + "\n")
list_stack[i] = ""
else:
if list_stack:
flush_list_stack(list_stack, md_content)
text = extract_formatted_text_pptx(para.runs)
if text:
md_content.append(f"{text}\n")
if list_stack:
flush_list_stack(list_stack, md_content)
md_content.append("---\n")
content = "\n".join(md_content)
if not content.strip():
return None, "文档为空"
return content, None
except Exception as e:
return None, f"python-pptx 解析失败: {str(e)}"
def parse_pptx_with_xml(file_path: str) -> Tuple[Optional[str], Optional[str]]:
"""使用 XML 原生解析 PPTX 文件"""
pptx_namespace = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}
def extract_text_with_formatting_xml(text_elem: Any, namespaces: dict) -> str:
result = []
runs = text_elem.findall(".//a:r", namespaces=namespaces)
for run in runs:
t_elem = run.find(".//a:t", namespaces=namespaces)
if t_elem is None or not t_elem.text:
continue
text = t_elem.text
rPr = run.find(".//a:rPr", namespaces=namespaces)
is_bold = False
is_italic = False
if rPr is not None:
is_bold = rPr.find(".//a:b", namespaces=namespaces) is not None
is_italic = rPr.find(".//a:i", namespaces=namespaces) is not None
if is_bold and is_italic:
text = f"***{text}***"
elif is_bold:
text = f"**{text}**"
elif is_italic:
text = f"*{text}*"
result.append(text)
return "".join(result).strip() if result else ""
def convert_table_to_md_xml(table_elem: Any, namespaces: dict) -> str:
rows = table_elem.findall(".//a:tr", namespaces=namespaces)
if not rows:
return ""
rows_data = []
for row in rows:
cells = row.findall(".//a:tc", namespaces=namespaces)
row_data = []
for cell in cells:
cell_text = extract_text_with_formatting_xml(cell, namespaces)
if cell_text:
cell_text = cell_text.replace("\n", " ").replace("\r", "")
row_data.append(cell_text if cell_text else "")
rows_data.append(row_data)
return build_markdown_table(rows_data)
def is_list_item_xml(p_elem: Any, namespaces: dict) -> Tuple[bool, bool]:
if p_elem is None:
return False, False
pPr = p_elem.find(".//a:pPr", namespaces=namespaces)
if pPr is None:
return False, False
buChar = pPr.find(".//a:buChar", namespaces=namespaces)
if buChar is not None:
return True, False
buAutoNum = pPr.find(".//a:buAutoNum", namespaces=namespaces)
if buAutoNum is not None:
return True, True
return False, False
def get_indent_level_xml(p_elem: Any, namespaces: dict) -> int:
if p_elem is None:
return 0
pPr = p_elem.find(".//a:pPr", namespaces=namespaces)
if pPr is None:
return 0
lvl = pPr.get("lvl")
return int(lvl) if lvl else 0
try:
md_content = []
with zipfile.ZipFile(file_path) as zip_file:
slide_files = [
f
for f in zip_file.namelist()
if re.match(r"ppt/slides/slide\d+\.xml$", f)
]
slide_files.sort(
key=lambda f: int(re.search(r"slide(\d+)\.xml$", f).group(1))
)
for slide_idx, slide_file in enumerate(slide_files, 1):
md_content.append("\n## Slide {}\n".format(slide_idx))
with zip_file.open(slide_file) as slide_xml:
slide_root = ET.parse(slide_xml).getroot()
tx_bodies = slide_root.findall(
".//p:sp/p:txBody", namespaces=pptx_namespace
)
tables = slide_root.findall(".//a:tbl", namespaces=pptx_namespace)
for table in tables:
table_md = convert_table_to_md_xml(table, pptx_namespace)
if table_md:
md_content.append(table_md)
for tx_body in tx_bodies:
paragraphs = tx_body.findall(
".//a:p", namespaces=pptx_namespace
)
list_stack = []
for para in paragraphs:
is_list, is_ordered = is_list_item_xml(para, pptx_namespace)
if is_list:
level = get_indent_level_xml(para, pptx_namespace)
while len(list_stack) <= level:
list_stack.append("")
text = extract_text_with_formatting_xml(
para, pptx_namespace
)
if text:
marker = "1. " if is_ordered else "- "
indent = " " * level
list_stack[level] = f"{indent}{marker}{text}"
for i in range(len(list_stack)):
if list_stack[i]:
md_content.append(list_stack[i] + "\n")
list_stack[i] = ""
else:
if list_stack:
flush_list_stack(list_stack, md_content)
text = extract_text_with_formatting_xml(
para, pptx_namespace
)
if text:
md_content.append(f"{text}\n")
if list_stack:
flush_list_stack(list_stack, md_content)
md_content.append("---\n")
content = "\n".join(md_content)
if not content.strip():
return None, "文档为空"
return content, None
except Exception as e:
return None, f"XML 解析失败: {str(e)}"

View File

@@ -1,286 +0,0 @@
#!/usr/bin/env python3
"""XLSX 文件解析模块,提供三种解析方法。"""
import xml.etree.ElementTree as ET
import zipfile
from typing import List, Optional, Tuple
from common import _unstructured_elements_to_markdown, parse_with_docling, parse_with_markitdown
def parse_xlsx_with_docling(file_path: str) -> Tuple[Optional[str], Optional[str]]:
"""使用 docling 库解析 XLSX 文件"""
return parse_with_docling(file_path)
def parse_xlsx_with_unstructured(file_path: str) -> Tuple[Optional[str], Optional[str]]:
"""使用 unstructured 库解析 XLSX 文件"""
try:
from unstructured.partition.xlsx import partition_xlsx
except ImportError:
return None, "unstructured 库未安装"
try:
elements = partition_xlsx(filename=file_path, infer_table_structure=True)
content = _unstructured_elements_to_markdown(elements)
if not content.strip():
return None, "文档为空"
return content, None
except Exception as e:
return None, f"unstructured 解析失败: {str(e)}"
def parse_xlsx_with_markitdown(file_path: str) -> Tuple[Optional[str], Optional[str]]:
"""使用 MarkItDown 库解析 XLSX 文件"""
return parse_with_markitdown(file_path)
def parse_xlsx_with_pandas(file_path: str) -> Tuple[Optional[str], Optional[str]]:
"""使用 pandas 库解析 XLSX 文件"""
try:
import pandas as pd
from tabulate import tabulate
except ImportError as e:
missing_lib = "pandas" if "pandas" in str(e) else "tabulate"
return None, f"{missing_lib} 库未安装"
try:
sheets = pd.read_excel(file_path, sheet_name=None)
markdown_parts = []
for sheet_name, df in sheets.items():
if len(df) == 0:
markdown_parts.append(f"## {sheet_name}\n\n*工作表为空*")
continue
table_md = tabulate(
df, headers="keys", tablefmt="pipe", showindex=True, missingval=""
)
markdown_parts.append(f"## {sheet_name}\n\n{table_md}")
if not markdown_parts:
return None, "Excel 文件为空"
markdown_content = "# Excel数据转换结果\n\n" + "\n\n".join(markdown_parts)
return markdown_content, None
except Exception as e:
return None, f"pandas 解析失败: {str(e)}"
def parse_xlsx_with_xml(file_path: str) -> Tuple[Optional[str], Optional[str]]:
"""使用 XML 原生解析 XLSX 文件"""
xlsx_namespace = {
"main": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
}
def parse_col_index(cell_ref: str) -> int:
col_index = 0
for char in cell_ref:
if char.isalpha():
col_index = col_index * 26 + (ord(char) - ord("A") + 1)
else:
break
return col_index - 1
def parse_cell_value(cell: ET.Element, shared_strings: List[str]) -> str:
cell_type = cell.attrib.get("t")
if cell_type == "inlineStr":
is_elem = cell.find("main:is", xlsx_namespace)
if is_elem is not None:
t_elem = is_elem.find("main:t", xlsx_namespace)
if t_elem is not None and t_elem.text:
return t_elem.text.replace("\n", " ").replace("\r", "")
return ""
cell_value_elem = cell.find("main:v", xlsx_namespace)
if cell_value_elem is None or not cell_value_elem.text:
return ""
cell_value = cell_value_elem.text
if cell_type == "s":
try:
idx = int(cell_value)
if 0 <= idx < len(shared_strings):
text = shared_strings[idx]
return text.replace("\n", " ").replace("\r", "")
except (ValueError, IndexError):
pass
return ""
elif cell_type == "b":
return "TRUE" if cell_value == "1" else "FALSE"
elif cell_type == "str":
return cell_value.replace("\n", " ").replace("\r", "")
elif cell_type == "e":
_ERROR_CODES = {
"#NULL!": "空引用错误",
"#DIV/0!": "除零错误",
"#VALUE!": "值类型错误",
"#REF!": "无效引用",
"#NAME?": "名称错误",
"#NUM!": "数值错误",
"#N/A": "值不可用",
}
return _ERROR_CODES.get(cell_value, f"错误: {cell_value}")
elif cell_type == "d":
return f"[日期] {cell_value}"
elif cell_type == "n":
return cell_value
elif cell_type is None:
try:
float_val = float(cell_value)
if float_val.is_integer():
return str(int(float_val))
return cell_value
except ValueError:
return cell_value
else:
return cell_value
def get_non_empty_columns(data: List[List[str]]) -> set:
non_empty_cols = set()
for row in data:
for col_idx, cell in enumerate(row):
if cell and cell.strip():
non_empty_cols.add(col_idx)
return non_empty_cols
def filter_columns(row: List[str], non_empty_cols: set) -> List[str]:
return [row[i] if i < len(row) else "" for i in sorted(non_empty_cols)]
def data_to_markdown(data: List[List[str]], sheet_name: str) -> str:
if not data or not data[0]:
return f"## {sheet_name}\n\n*工作表为空*"
md_lines = []
md_lines.append(f"## {sheet_name}")
md_lines.append("")
headers = data[0]
non_empty_cols = get_non_empty_columns(data)
if not non_empty_cols:
return f"## {sheet_name}\n\n*工作表为空*"
filtered_headers = filter_columns(headers, non_empty_cols)
header_line = "| " + " | ".join(filtered_headers) + " |"
md_lines.append(header_line)
separator_line = "| " + " | ".join(["---"] * len(filtered_headers)) + " |"
md_lines.append(separator_line)
for row in data[1:]:
filtered_row = filter_columns(row, non_empty_cols)
row_line = "| " + " | ".join(filtered_row) + " |"
md_lines.append(row_line)
md_lines.append("")
return "\n".join(md_lines)
try:
with zipfile.ZipFile(file_path, "r") as zip_file:
sheet_names = []
sheet_rids = []
try:
with zip_file.open("xl/workbook.xml") as f:
root = ET.parse(f).getroot()
rel_ns = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
sheet_elements = root.findall(".//main:sheet", xlsx_namespace)
for sheet in sheet_elements:
sheet_name = sheet.attrib.get("name", "")
rid = sheet.attrib.get(f"{{{rel_ns}}}id", "")
if sheet_name:
sheet_names.append(sheet_name)
sheet_rids.append(rid)
except KeyError:
return None, "无法解析工作表名称"
if not sheet_names:
return None, "未找到工作表"
rid_to_target = {}
try:
rels_ns = "http://schemas.openxmlformats.org/package/2006/relationships"
with zip_file.open("xl/_rels/workbook.xml.rels") as f:
rels_root = ET.parse(f).getroot()
for rel in rels_root.findall(f"{{{rels_ns}}}Relationship"):
rid = rel.attrib.get("Id", "")
target = rel.attrib.get("Target", "")
if rid and target:
rid_to_target[rid] = target
except KeyError:
pass
shared_strings = []
try:
with zip_file.open("xl/sharedStrings.xml") as f:
root = ET.parse(f).getroot()
for si in root.findall(".//main:si", xlsx_namespace):
t_elem = si.find(".//main:t", xlsx_namespace)
if t_elem is not None and t_elem.text:
shared_strings.append(t_elem.text)
else:
shared_strings.append("")
except KeyError:
pass
markdown_content = "# Excel数据转换结果 (原生XML解析)\n\n"
for sheet_index, sheet_name in enumerate(sheet_names):
rid = sheet_rids[sheet_index] if sheet_index < len(sheet_rids) else ""
target = rid_to_target.get(rid, "")
if target:
if target.startswith("/"):
worksheet_path = target.lstrip("/")
else:
worksheet_path = f"xl/{target}"
else:
worksheet_path = f"xl/worksheets/sheet{sheet_index + 1}.xml"
try:
with zip_file.open(worksheet_path) as f:
root = ET.parse(f).getroot()
sheet_data = root.find("main:sheetData", xlsx_namespace)
rows = []
if sheet_data is not None:
row_elements = sheet_data.findall(
"main:row", xlsx_namespace
)
for row_elem in row_elements:
cells = row_elem.findall("main:c", xlsx_namespace)
col_dict = {}
for cell in cells:
cell_ref = cell.attrib.get("r", "")
if not cell_ref:
continue
col_index = parse_col_index(cell_ref)
cell_value = parse_cell_value(cell, shared_strings)
col_dict[col_index] = cell_value
if col_dict:
max_col = max(col_dict.keys())
row_data = [
col_dict.get(i, "") for i in range(max_col + 1)
]
rows.append(row_data)
table_md = data_to_markdown(rows, sheet_name)
markdown_content += table_md + "\n\n"
except KeyError:
markdown_content += f"## {sheet_name}\n\n*工作表解析失败*\n\n"
if not markdown_content.strip():
return None, "解析结果为空"
return markdown_content, None
except Exception as e:
return None, f"XML 解析失败: {str(e)}"