diff --git a/.cliprepo.yaml b/.cliprepo.yaml index 3973ebb..91f400a 100644 --- a/.cliprepo.yaml +++ b/.cliprepo.yaml @@ -1,23 +1,13 @@ -ignore: - - node_modules - - dist - - "*.log" +config: + path_mode: relative -include: - - "*.go" - - "*.py" - - "*.ts" +prompts: + preset: "下面的代码来自文件 {{path}}:\n---\n{{content}}" -max_file_size_kb: 200 +preset: + internal: + - "internal/tree/*" + - "internal/utils/glob.go" -bundle: - max_tokens: 20000 - add_file_header: true - format: "markdown" # or plain - -clipboard: - auto_copy: true - -tree: - show_size: true - max_depth: 5 \ No newline at end of file + cmd: + - "cmd/*" \ No newline at end of file diff --git a/cmd/bundle.go b/cmd/bundle.go deleted file mode 100644 index b3b3896..0000000 --- a/cmd/bundle.go +++ /dev/null @@ -1,36 +0,0 @@ -package cmd - -import ( - "fmt" - - "cliprepo/internal/bundle" - "github.com/spf13/cobra" -) - -var bundleClipboard bool - -var bundleCmd = &cobra.Command{ - Use: "bundle [key]", - Short: "Bundle files by key from config", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - key := args[0] - - result, err := bundle.Build(key) - if err != nil { - return err - } - - if bundleClipboard { - return bundle.Copy(result) - } - - fmt.Println(result) - return nil - }, -} - -func init() { - rootCmd.AddCommand(bundleCmd) - bundleCmd.Flags().BoolVar(&bundleClipboard, "clipboard", false, "Copy to clipboard") -} \ No newline at end of file diff --git a/cmd/clipboard.go b/cmd/clipboard.go new file mode 100644 index 0000000..3412cbb --- /dev/null +++ b/cmd/clipboard.go @@ -0,0 +1,9 @@ +package cmd + +import ( + "github.com/atotto/clipboard" +) + +func copyToClipboard(content string) error { + return clipboard.WriteAll(content) +} \ No newline at end of file diff --git a/cmd/root.go b/cmd/root.go index d607f9c..40c5cd1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -8,7 +8,7 @@ import ( var rootCmd = &cobra.Command{ Use: "cliprepo", - Short: "Extract repo structure and bundle code for LLMs", + Short: "LLM context builder from repo", } func Execute() { diff --git a/cmd/run.go b/cmd/run.go new file mode 100644 index 0000000..37f42c1 --- /dev/null +++ b/cmd/run.go @@ -0,0 +1,93 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "cliprepo/internal/config" + "cliprepo/internal/tree" + "cliprepo/internal/utils" + + "github.com/spf13/cobra" + "github.com/atotto/clipboard" +) + +var delineate string +var useClipboard bool + +var runCmd = &cobra.Command{ + Use: "run", + Short: "Generate LLM context from repo", + + RunE: func(cmd *cobra.Command, args []string) error { + + cfg, err := config.Load() + if err != nil { + return err + } + + // 1. tree + treeStr, err := tree.Generate(".") + if err != nil { + return err + } + + var builder strings.Builder + + builder.WriteString("===== PROJECT TREE =====\n") + builder.WriteString(treeStr) + builder.WriteString("\n\n") + + // 2. preset files + rawFiles, ok := cfg.Preset[delineate] + if !ok { + return fmt.Errorf("preset not found: %s", delineate) + } + + files, err := utils.ExpandGlob(rawFiles) + if err != nil { + return err + } + + // 3. template + template := cfg.Prompts.Preset + + for _, file := range files { + + content, err := os.ReadFile(file) + if err != nil { + continue + } + + path := file + if cfg.Config.PathMode == "absolute" { + abs, _ := filepath.Abs(file) + path = abs + } + + block := strings.ReplaceAll(template, "{{path}}", path) + block = strings.ReplaceAll(block, "{{content}}", string(content)) + + builder.WriteString(block) + builder.WriteString("\n\n") + } + + result := builder.String() + + if useClipboard { + return clipboard.WriteAll(result) + } + + fmt.Println(result) + return nil + }, +} + +func init() { + rootCmd.AddCommand(runCmd) + + runCmd.Flags().StringVar(&delineate, "delineate", "", "preset key") + runCmd.Flags().BoolVar(&useClipboard, "clipboard", false, "copy output to clipboard") +} \ No newline at end of file diff --git a/cmd/tree.go b/cmd/tree.go deleted file mode 100644 index e793a66..0000000 --- a/cmd/tree.go +++ /dev/null @@ -1,40 +0,0 @@ -package cmd - -import ( - "fmt" - - "cliprepo/internal/tree" - "github.com/spf13/cobra" -) - -var treeOutput string -var treeClipboard bool - -var treeCmd = &cobra.Command{ - Use: "tree", - Short: "Print repo tree respecting .gitignore", - RunE: func(cmd *cobra.Command, args []string) error { - result, err := tree.Generate(".") - if err != nil { - return err - } - - if treeClipboard { - return tree.Copy(result) - } - - if treeOutput != "" { - return tree.Write(treeOutput, result) - } - - fmt.Println(result) - return nil - }, -} - -func init() { - rootCmd.AddCommand(treeCmd) - - treeCmd.Flags().StringVarP(&treeOutput, "output", "o", "", "Output file") - treeCmd.Flags().BoolVar(&treeClipboard, "clipboard", false, "Copy to clipboard") -} \ No newline at end of file diff --git a/internal/bundle/bundle.go b/internal/bundle/bundle.go deleted file mode 100644 index 8b99c4d..0000000 --- a/internal/bundle/bundle.go +++ /dev/null @@ -1,92 +0,0 @@ -package bundle - -import ( - "fmt" - "os" - "strings" - - "cliprepo/internal/config" - "github.com/atotto/clipboard" -) - -const maxFileSize = 1 * 1024 * 1024 // 1MB - -// Build 根据 config 中定义的 bundle key,将多个文件内容聚合成一个字符串 -// -// 设计用途: -// 用于 CLI 工具中快速“打包”一组文件内容,例如: -// cliprepo bundle api -// => 输出 api 相关的一组代码文件内容,方便复制/分享/提交 prompt -// -// 行为特点: -// 1. 从 cliprepo.yaml 中读取 bundles 配置 -// 2. 根据 key 找到对应文件列表 -// 3. 逐个读取文件内容并拼接 -// 4. 自动跳过异常文件(不存在 / 读取失败) -// 5. 自动过滤过大的文件(> 1MB) -// -// 输出格式: -// -// ===== path/to/file ===== -// -// -// ===== path/to/another ===== -// -func Build(key string) (string, error) { - // 读取配置文件(cliprepo.yaml) - cfg, err := config.Load() - if err != nil { - return "", err - } - - // 获取对应 bundle 的文件列表 - files, ok := cfg.Bundles[key] - if !ok { - return "", fmt.Errorf("bundle not found: %s", key) - } - - var builder strings.Builder - - // 遍历 bundle 中的所有文件路径 - for _, file := range files { - // 获取文件信息(用于判断大小 / 是否存在) - info, err := os.Stat(file) - if err != nil { - // 文件不存在或无法访问时跳过 - continue - } - - // 跳过过大的文件,避免 CLI 输出过载或内存占用过高 - if info.Size() > maxFileSize { - continue - } - - // 读取文件内容 - data, err := os.ReadFile(file) - if err != nil { - // 读取失败直接跳过,不中断整个 bundle 构建 - continue - } - - // 写入文件分隔标记,便于阅读结构 - builder.WriteString(fmt.Sprintf("===== %s =====\n", file)) - - // 写入文件内容 - builder.Write(data) - builder.WriteString("\n\n") - } - - return builder.String(), nil -} - -// Copy 将 bundle 内容写入系统剪贴板 -// -// 常见使用场景: -// CLI 一键复制 bundle 输出,用于: -// - prompt 输入 -// - 代码审查 -// - 远程分享 -// - AI 上下文输入 -func Copy(content string) error { - return clipboard.WriteAll(content) -} \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go index 3d440fa..f756aef 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,33 +6,25 @@ import ( "gopkg.in/yaml.v3" ) -// Config 定义 cliprepo 的配置结构 -// -// 当前支持的配置: -// Bundles:用于定义一组文件路径集合 -// key -> bundle 名称 -// value -> 对应的文件路径列表 type Config struct { - Bundles map[string][]string `yaml:"bundles"` + Config struct { + PathMode string `yaml:"path_mode"` + } `yaml:"config"` + + Prompts struct { + Preset string `yaml:"preset"` + } `yaml:"prompts"` + + Preset map[string][]string `yaml:"preset"` } -// Load 从项目根目录读取 cliprepo.yaml 配置文件,并解析为 Config 结构 -// -// 行为说明: -// 1. 默认读取当前工作目录下的 cliprepo.yaml -// 2. 如果文件不存在或读取失败,直接返回 error -// 3. 使用 YAML 解析配置内容到 Config 结构体 func Load() (*Config, error) { - // 读取配置文件内容 - data, err := os.ReadFile("cliprepo.yaml") + data, err := os.ReadFile(".cliprepo.yaml") if err != nil { return nil, err } var cfg Config - - // 解析 YAML 到结构体 err = yaml.Unmarshal(data, &cfg) - return &cfg, err } \ No newline at end of file diff --git a/internal/gitignore/ignore.go b/internal/gitignore/ignore.go deleted file mode 100644 index c90cadf..0000000 --- a/internal/gitignore/ignore.go +++ /dev/null @@ -1,28 +0,0 @@ -package gitignore - -import ( - "os" - "path/filepath" - - ignore "github.com/sabhiram/go-gitignore" -) - -// Load 加载指定 root 目录下的 .gitignore 规则 -// -// 行为说明: -// 1. 如果 root/.gitignore 存在,则解析该文件 -// 2. 如果不存在,则返回一个“空规则集”(不会忽略任何文件) -// 3. 返回 go-gitignore 的 GitIgnore 对象,用于路径匹配 -func Load(root string) (*ignore.GitIgnore, error) { - // 拼接 .gitignore 文件路径 - path := filepath.Join(root, ".gitignore") - - // 如果 .gitignore 不存在,则返回一个空的 ignore 规则 - // 这样后续 MatchesPath 不会 panic,也不会过滤任何文件 - if _, err := os.Stat(path); os.IsNotExist(err) { - return ignore.CompileIgnoreLines(), nil - } - - // 如果存在 .gitignore,则解析文件内容生成规则 - return ignore.CompileIgnoreFile(path) -} \ No newline at end of file diff --git a/internal/tree/ignore.go b/internal/tree/ignore.go new file mode 100644 index 0000000..12f2d8f --- /dev/null +++ b/internal/tree/ignore.go @@ -0,0 +1,19 @@ +package tree + +import ( + "os" + "path/filepath" + + ignore "github.com/sabhiram/go-gitignore" +) + +// Load 加载 .gitignore +func Load(root string) (*ignore.GitIgnore, error) { + path := filepath.Join(root, ".gitignore") + + if _, err := os.Stat(path); os.IsNotExist(err) { + return ignore.CompileIgnoreLines(), nil + } + + return ignore.CompileIgnoreFile(path) +} \ No newline at end of file diff --git a/internal/tree/tree.go b/internal/tree/tree.go index d553020..0dfb866 100644 --- a/internal/tree/tree.go +++ b/internal/tree/tree.go @@ -5,73 +5,40 @@ import ( "os" "path/filepath" "strings" - - "cliprepo/internal/gitignore" - "github.com/atotto/clipboard" ) -// Generate 从 root 目录开始遍历文件树,并生成类似 `tree` 命令的目录结构字符串 -// -// 功能特点: -// 1. 递归遍历目录 -// 2. 支持 .gitignore 规则过滤 -// 3. 输出树形结构字符串 func Generate(root string) (string, error) { - // 加载 gitignore 规则(如果失败则忽略错误,默认不过滤) - ig, _ := gitignore.Load(root) + + ig, _ := Load(root) var builder strings.Builder - // 递归遍历目录结构 err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil { return err } - // 根目录特殊处理,统一显示为 "." if path == root { builder.WriteString(".\n") return nil } - // 转换为相对路径,便于匹配 gitignore 和计算层级 rel, _ := filepath.Rel(root, path) - // 如果路径匹配 gitignore 规则,则跳过 - // 如果是目录,则直接跳过整个子树 - if ig.MatchesPath(rel) { + // gitignore filter + if ig != nil && ig.MatchesPath(rel) { if info.IsDir() { return filepath.SkipDir } return nil } - // 计算当前节点在目录树中的层级 - // 通过路径分隔符数量判断深度 level := strings.Count(rel, string(os.PathSeparator)) - - // 根据层级生成缩进 prefix := strings.Repeat("│ ", level) - // 写入当前节点(文件或目录) builder.WriteString(fmt.Sprintf("%s├── %s\n", prefix, info.Name())) return nil }) return builder.String(), err -} - -// Copy 将生成的字符串写入系统剪贴板 -// -// 常用于 CLI 工具:直接复制 tree 输出结果 -func Copy(content string) error { - return clipboard.WriteAll(content) -} - -// Write 将内容写入指定文件路径 -// -// 如果文件存在则覆盖 -// 权限默认设置为 0644(rw-r--r--) -func Write(path, content string) error { - return os.WriteFile(path, []byte(content), 0644) } \ No newline at end of file diff --git a/internal/utils/files.go b/internal/utils/files.go new file mode 100644 index 0000000..9bddb82 --- /dev/null +++ b/internal/utils/files.go @@ -0,0 +1,27 @@ +package utils + +import ( + "os" + "path/filepath" +) + +func ExpandPaths(patterns []string) ([]string, error) { + var result []string + + for _, pattern := range patterns { + matches, err := filepath.Glob(pattern) + if err != nil { + return nil, err + } + + for _, m := range matches { + info, err := os.Stat(m) + if err != nil || info.IsDir() { + continue + } + result = append(result, m) + } + } + + return result, nil +} \ No newline at end of file diff --git a/internal/utils/glob.go b/internal/utils/glob.go new file mode 100644 index 0000000..02d2618 --- /dev/null +++ b/internal/utils/glob.go @@ -0,0 +1,32 @@ +package utils + +import ( + "os" + "path/filepath" +) + +func ExpandGlob(patterns []string) ([]string, error) { + var result []string + + for _, p := range patterns { + matches, err := filepath.Glob(p) + if err != nil { + return nil, err + } + + for _, m := range matches { + info, err := os.Stat(m) + if err != nil { + continue + } + + if info.IsDir() { + continue + } + + result = append(result, m) + } + } + + return result, nil +} \ No newline at end of file