commit 7624928d9966fb823b6fa15405d6504b428c6a3f Author: skidoodle Date: Fri Jan 30 19:21:54 2026 +0100 init ctx diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..81143f7 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,32 @@ +name: goreleaser + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: stable + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9128783 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +ctx* diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..f9bb9a4 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,40 @@ +version: 2 + +project_name: ctx + +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + - windows + - darwin + goarch: + - amd64 + - arm64 + ldflags: + - -s -w + +archives: + - format: tar.gz + format_overrides: + - goos: windows + format: zip + name_template: >- + {{ .ProjectName }}_ + {{- .Version }}_ + {{- .Os }}_ + {{- .Arch }} + +checksum: + name_template: "checksums.txt" + +snapshot: + version_template: "{{ .Tag }}-next" + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f5e7c87 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 github.com/skidoodle + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5000623 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# ctx + +CLI to convert a directory tree and file contents into a single text file for LLM context. +It respects `.gitignore` and comes with sensible defaults for ignoring binaries and lockfiles. + +## Installation + +### Go Install + +```bash +go install github.com/skidoodle/ctx@latest +``` + +### Binaries + +Download pre-compiled binaries for Windows, macOS, and Linux from the [Releases](https://github.com/skidoodle/ctx/releases) page. + +## Usage + +Generate context for the current directory (outputs to `ctx.txt`): + +```bash +ctx . +``` + +Generate context for a specific folder and save to a custom file: + +```bash +ctx -o context.md ./src +``` + +### Configuration + +`ctx` ignores common artifacts (node_modules, .git, binaries) by default. +To edit the global ignore list: + +```bash +ctx -config +``` + +## License +MIT +``` diff --git a/config.go b/config.go new file mode 100644 index 0000000..6e78c24 --- /dev/null +++ b/config.go @@ -0,0 +1,219 @@ +package main + +import ( + "bufio" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +const ( + configDirName = "ctx" + ignoreFileName = "ignore" +) + +const defaultIgnoreText = ` +.git +.svn +.hg +.gitignore +.idea +.vscode +.vs +.settings +.classpath +.project +.DS_Store +*.swp +*.swo +*.tmp +*.bak +node_modules +bower_components +jspm_packages +vendor +.venv +venv +env +.env +__pycache__ +.tox +.mypy_cache +.pytest_cache +.npm +.yarn +.pnpm-store +dist +build +out +target +bin +obj +cmake-build-* +package-lock.json +yarn.lock +pnpm-lock.yaml +bun.lockb +go.sum +Cargo.lock +poetry.lock +Pipfile.lock +*.exe +*.dll +*.so +*.dylib +*.test +*.class +*.jar +*.war +*.ear +*.o +*.obj +*.sqlite +*.db +*.sqlitedb +*.zip +*.tar +*.tar.gz +*.tgz +*.rar +*.7z +*.jpg +*.jpeg +*.png +*.gif +*.ico +*.svg +*.webp +*.mp3 +*.mp4 +*.mov +*.avi +*.pdf +*.doc +*.docx +*.pyc +*.pyo +*.pyd +*.egg-info +*_templ.go +` + +type Config struct { + ExactIgnores map[string]struct{} + ExtIgnores map[string]struct{} + ComplexGlobs []string +} + +func parseConfig(text string) *Config { + cfg := &Config{ + ExactIgnores: make(map[string]struct{}), + ExtIgnores: make(map[string]struct{}), + } + + scanner := bufio.NewScanner(strings.NewReader(text)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + if strings.HasPrefix(line, "*.") && strings.Count(line, "*") == 1 && !strings.Contains(line, "/") { + ext := strings.TrimPrefix(line, "*") + cfg.ExtIgnores[ext] = struct{}{} + } else if !strings.ContainsAny(line, "*?[]") && !strings.Contains(line, "/") { + cfg.ExactIgnores[line] = struct{}{} + } else { + cfg.ComplexGlobs = append(cfg.ComplexGlobs, line) + } + } + return cfg +} + +func (c *Config) IsIgnored(name, relPath string, isDir bool) bool { + if _, ok := c.ExactIgnores[name]; ok { + return true + } + + if !isDir { + ext := filepath.Ext(name) + if _, ok := c.ExtIgnores[ext]; ok { + return true + } + } + + checkPath := filepath.ToSlash(relPath) + + for _, p := range c.ComplexGlobs { + cleanPattern := strings.TrimSuffix(p, "/") + + if matched, _ := filepath.Match(cleanPattern, name); matched { + return true + } + + if matched, _ := filepath.Match(cleanPattern, checkPath); matched { + return true + } + + if strings.HasPrefix(checkPath, cleanPattern+"/") { + return true + } + } + return false +} + +func getConfigPath() (string, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return "", err + } + appDir := filepath.Join(configDir, configDirName) + if err := os.MkdirAll(appDir, 0755); err != nil { + return "", err + } + return filepath.Join(appDir, ignoreFileName), nil +} + +func loadConfig() (*Config, error) { + path, err := getConfigPath() + if err != nil { + return nil, err + } + + if _, err := os.Stat(path); os.IsNotExist(err) { + if err := os.WriteFile(path, []byte(defaultIgnoreText), 0644); err != nil { + return nil, err + } + return parseConfig(defaultIgnoreText), nil + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return parseConfig(string(data)), nil +} + +func openConfigFile() error { + path, err := getConfigPath() + if err != nil { + return err + } + + if _, err := os.Stat(path); os.IsNotExist(err) { + _, _ = loadConfig() + } + + var cmd *exec.Cmd + switch runtime.GOOS { + case "windows": + cmd = exec.Command("cmd", "/c", "start", "", path) + case "darwin": + cmd = exec.Command("open", path) + default: + cmd = exec.Command("xdg-open", path) + } + return cmd.Start() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6ee8485 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/skidoodle/ctx + +go 1.25.6 diff --git a/main.go b/main.go new file mode 100644 index 0000000..2b81971 --- /dev/null +++ b/main.go @@ -0,0 +1,81 @@ +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "time" +) + +const defaultOutputFilename = "ctx.txt" + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "ctx: %v\n", err) + os.Exit(1) + } +} + +func run() error { + flag.Usage = usage + configFlag := flag.Bool("config", false, "open global ignore file for editing") + outputFlag := flag.String("o", defaultOutputFilename, "output filename") + flag.Parse() + + if *configFlag { + return openConfigFile() + } + + args := flag.Args() + if len(args) == 0 { + usage() + return nil + } + + targetDir := args[0] + absPath, err := filepath.Abs(targetDir) + if err != nil { + return fmt.Errorf("resolving path: %w", err) + } + + start := time.Now() + + cfg, err := loadConfig() + if err != nil { + fmt.Fprintf(os.Stderr, "ctx: warning: could not load config, using defaults (%v)\n", err) + cfg = parseConfig(defaultIgnoreText) + } + + scanner := NewScanner(absPath, cfg) + files, err := scanner.Scan() + if err != nil { + return fmt.Errorf("scanning directory: %w", err) + } + + if len(files) == 0 { + return fmt.Errorf("no files found in %s", absPath) + } + + tokenCount, err := writeOutput(absPath, files, *outputFlag) + if err != nil { + return fmt.Errorf("writing output: %w", err) + } + + duration := time.Since(start).Round(time.Millisecond) + fmt.Printf("ctx: generated %s (%d files, ~%d tokens) in %v\n", + *outputFlag, len(files), tokenCount, duration) + + return nil +} + +func usage() { + fmt.Fprintf(os.Stderr, "Usage: ctx [options] \n\n") + fmt.Fprintf(os.Stderr, "Options:\n") + fmt.Fprintf(os.Stderr, " -config Open global ignore file for editing\n") + fmt.Fprintf(os.Stderr, " -o Output filename (default %q)\n", defaultOutputFilename) + fmt.Fprintf(os.Stderr, "\nExamples:\n") + fmt.Fprintf(os.Stderr, " ctx . # Generate context for current dir\n") + fmt.Fprintf(os.Stderr, " ctx -o out.txt src/ # Scan src/ and save to out.txt\n") + fmt.Fprintf(os.Stderr, " ctx -config # Open global ignore file\n") +} diff --git a/output.go b/output.go new file mode 100644 index 0000000..61d3f0c --- /dev/null +++ b/output.go @@ -0,0 +1,129 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" +) + +func writeOutput(root string, files []string, outputPath string) (count int64, err error) { + f, err := os.Create(outputPath) + if err != nil { + return 0, err + } + defer func() { + if cerr := f.Close(); cerr != nil && err == nil { + err = cerr + } + }() + + bw := bufio.NewWriterSize(f, 1024*1024) + defer func() { + if ferr := bw.Flush(); ferr != nil && err == nil { + err = ferr + } + }() + + tc := &TokenCounter{w: bw} + + tc.Printf("Project Path: %s\n\n", filepath.Base(root)) + tc.Println("Source Tree:") + tc.Println("") + + tc.Println("```txt") + tc.Println(filepath.Base(root)) + + if err := writeTree(tc, files); err != nil { + return 0, err + } + + tc.Println("```") + tc.Println("") + + for _, file := range files { + if file == outputPath || filepath.Base(file) == outputPath { + continue + } + + fullPath := filepath.Join(root, file) + content, err := os.ReadFile(fullPath) + if err != nil { + tc.Printf("Error reading %s: %v\n", file, err) + continue + } + + ext := strings.TrimPrefix(filepath.Ext(file), ".") + if ext == "" { + ext = "txt" + } + + tc.Printf("`%s`:\n\n", file) + tc.Printf("```%s\n", ext) + + if _, err := tc.Write(content); err != nil { + return 0, err + } + + if len(content) > 0 && content[len(content)-1] != '\n' { + if err := tc.WriteByte('\n'); err != nil { + return 0, err + } + } + tc.Println("```") + tc.Println("") + } + + return tc.Count, tc.Err +} + +func writeTree(w io.Writer, files []string) error { + root := make(map[string]any) + for _, f := range files { + parts := strings.Split(filepath.ToSlash(f), "/") + current := root + for _, part := range parts { + if _, exists := current[part]; !exists { + current[part] = make(map[string]any) + } + current = current[part].(map[string]any) + } + } + + return printNode(w, root, "") +} + +func printNode(w io.Writer, node map[string]any, prefix string) error { + keys := make([]string, 0, len(node)) + for k := range node { + keys = append(keys, k) + } + slices.Sort(keys) + + for i, key := range keys { + isLast := i == len(keys)-1 + connector := "├── " + if isLast { + connector = "└── " + } + + if _, err := fmt.Fprintf(w, "%s%s%s\n", prefix, connector, key); err != nil { + return err + } + + children := node[key].(map[string]any) + if len(children) > 0 { + childPrefix := prefix + "│ " + if isLast { + childPrefix = prefix + " " + } + if err := printNode(w, children, childPrefix); err != nil { + return err + } + } + } + return nil +} diff --git a/scanner.go b/scanner.go new file mode 100644 index 0000000..a21b54c --- /dev/null +++ b/scanner.go @@ -0,0 +1,76 @@ +package main + +import ( + "io/fs" + "os" + "path/filepath" + "slices" +) + +type Scanner struct { + Root string + GlobalCfg *Config + LocalCfg *Config +} + +func NewScanner(root string, globalCfg *Config) *Scanner { + s := &Scanner{ + Root: root, + GlobalCfg: globalCfg, + } + + gitIgnorePath := filepath.Join(root, ".gitignore") + if data, err := os.ReadFile(gitIgnorePath); err == nil { + s.LocalCfg = parseConfig(string(data)) + } + return s +} + +func (s *Scanner) Scan() ([]string, error) { + var files []string + + err := filepath.WalkDir(s.Root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel(s.Root, path) + if err != nil { + return err + } + + if relPath == "." { + return nil + } + + name := d.Name() + isDir := d.IsDir() + + if isDir && name == ".git" { + return filepath.SkipDir + } + + if s.GlobalCfg.IsIgnored(name, relPath, isDir) { + if isDir { + return filepath.SkipDir + } + return nil + } + + if s.LocalCfg != nil && s.LocalCfg.IsIgnored(name, relPath, isDir) { + if isDir { + return filepath.SkipDir + } + return nil + } + + if !isDir { + files = append(files, relPath) + } + return nil + }) + + slices.Sort(files) + + return files, err +} diff --git a/token.go b/token.go new file mode 100644 index 0000000..e670abb --- /dev/null +++ b/token.go @@ -0,0 +1,68 @@ +package main + +import ( + "fmt" + "io" + "unicode" +) + +type TokenCounter struct { + w io.Writer + Count int64 + Err error + inWord bool + inSpace bool +} + +func (tc *TokenCounter) Write(p []byte) (int, error) { + if tc.Err != nil { + return 0, tc.Err + } + + for _, b := range p { + r := rune(b) + isSpace := unicode.IsSpace(r) + isAlpha := unicode.IsLetter(r) || unicode.IsNumber(r) || r == '_' + + if isAlpha { + if !tc.inWord { + tc.Count++ + tc.inWord = true + tc.inSpace = false + } + } else if isSpace { + if !tc.inSpace { + tc.Count++ + tc.inSpace = true + tc.inWord = false + } + } else { + tc.Count++ + tc.inWord = false + tc.inSpace = false + } + } + + n, err := tc.w.Write(p) + tc.Err = err + return n, err +} + +func (tc *TokenCounter) WriteByte(c byte) error { + _, err := tc.Write([]byte{c}) + return err +} + +func (tc *TokenCounter) Printf(format string, a ...any) { + if tc.Err != nil { + return + } + _, _ = fmt.Fprintf(tc, format, a...) +} + +func (tc *TokenCounter) Println(a ...any) { + if tc.Err != nil { + return + } + _, _ = fmt.Fprintln(tc, a...) +}