mirror of
https://github.com/skidoodle/ctx.git
synced 2026-04-28 03:07:41 +02:00
init ctx
This commit is contained in:
@@ -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 }}
|
||||
@@ -0,0 +1 @@
|
||||
ctx*
|
||||
@@ -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:"
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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] <directory>\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 <file> 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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+76
@@ -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
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
Reference in New Issue
Block a user