57 Commits
Author SHA1 Message Date
bartal-lsn f908bffc41 ignore uv.lock 2026-09-03 17:27:00 +01:00
bartal-lsn a1638bd944 add .dirmd and other metadata 2026-09-02 12:01:42 +01:00
bartal-lsn 93d830d500 fix pre delete dirmd set dir bug and implement last git sha and commit# 2026-09-02 11:43:45 +01:00
bartal-lsn 89b7eb78a7 add timestamp to dirmd files 2026-09-02 10:39:55 +01:00
bartal-lsn b1dd808d1e add python support 2026-08-29 21:58:16 +01:00
bartal-lsn d247095e87 fix(drive cli)!: delete remote flag changed 2026-08-28 14:08:58 +01:00
bartal-lsn 66579d068d webm 2026-08-28 01:14:26 +01:00
bartal-lsn 4c14ef2cee webm 2026-08-28 01:10:37 +01:00
bartal-lsn 0f94e50746 webm 2026-08-28 01:07:41 +01:00
bartal-lsn c9aa028b3c webm 2026-08-28 01:01:49 +01:00
bartal-lsn bca125cb92 Update README.md 2026-08-28 00:50:13 +01:00
bartal-lsn 7d73901631 Update README.md 2026-08-28 00:49:26 +01:00
bartal-lsn 6663cb25f8 webm 2026-08-28 00:42:23 +01:00
bartal-lsn 8a28364839 Update README.md 2026-08-28 00:38:34 +01:00
bartal-lsn 894f97820e webm 2026-08-28 00:37:43 +01:00
bartal-lsn fb646a94be Update README.md 2026-08-28 00:32:49 +01:00
bartal-lsn 4bcae1358d Update README.md 2026-08-28 00:32:33 +01:00
bartal-lsn 2428a33046 Update README.md 2026-08-28 00:31:40 +01:00
bartal-lsn a48fe55d4b Update README.md 2026-08-28 00:29:47 +01:00
bartal-lsn 0df346dbf9 Update README.md 2026-08-28 00:29:18 +01:00
bartal-lsn d7f4c6826b Update README.md 2026-08-28 00:27:34 +01:00
bartal-lsn 79be211df3 test 2026-08-28 00:26:00 +01:00
bartal-lsn ba87d6184b test webm 2026-08-28 00:24:20 +01:00
bartal-lsn 180c3f0eba test webm 2026-08-28 00:19:24 +01:00
bartal-lsn be0c761cb3 fix bugs 2026-08-28 00:06:14 +01:00
bartal-lsn 4ebf3bd60f delete redundant docs 2026-08-27 23:04:07 +01:00
bartal-lsn 63547e74c2 add how to install 2026-08-27 22:44:53 +01:00
bartal-lsn 79b630b622 add how to install 2026-08-27 22:43:22 +01:00
bartal-lsn 4520ad3dfd build(new mod): change mod name
change the module name from dirmd to git.flo.fo/FLO/dirmd

Also change all the imports in the module
2026-08-27 22:33:02 +01:00
bartal-lsn 4072ca4a45 fix: correct module path 2026-08-27 22:24:34 +01:00
bartal-lsn ba81eecea8 post commit hook added to readme 2026-08-27 22:07:48 +01:00
bartal-lsn 287d60e978 implement huh 2026-08-27 22:03:04 +01:00
bartal-lsn e33682098a revert(remove file): removed text file
I added a file
2026-08-27 20:33:34 +01:00
bartal-lsn cbf00f3e33 feat(new file): this is a new txt file
here is some
2026-08-27 20:31:43 +01:00
bartal-lsn ab670b3ecd use huh 2026-08-27 20:09:59 +01:00
bartal-lsn 8bfd6b22c5 remove append# 2026-08-27 19:07:53 +01:00
bartal-lsn adc276c28a much better 2026-08-27 18:24:23 +01:00
bartal-lsn 73c2009139 tui better 2026-08-27 17:57:23 +01:00
bartal-lsn 71961f68da working on tui 2026-08-27 15:49:18 +01:00
bartal-lsn 3ea14b91c0 add tui 2026-08-27 15:24:18 +01:00
bartal-lsn a2afce3f8d add omit md 2026-08-27 14:42:00 +01:00
bartal-lsn 2bbbf4e81e add only frontend 2026-08-27 14:20:33 +01:00
bartal-lsn 1f5243d953 add omit test files and only frontend 2026-08-27 14:00:24 +01:00
bartal-lsn 95b5a7b70c remove go mod and go sum from diormd, into ingore 2026-08-24 10:36:04 +01:00
bartal-lsn 7f05519f6f ignore cargo.locak files 2026-08-17 11:14:07 +01:00
bartal-lsn 72bee49d3f add postfix repos name to all files 2026-08-13 20:53:21 +01:00
bartal-lsn 1e913c784e try gif stuff 2026-08-13 12:51:27 +01:00
bartal-lsn bc2cc0b531 docs for post commit hook with dirmd 2026-08-08 13:55:14 +01:00
bartal-lsn fa661300bf remove test files 2026-08-08 13:48:47 +01:00
bartal-lsn 6eaf4727f9 test2 2026-08-08 13:47:50 +01:00
bartal-lsn 55684367e6 hook test 2026-08-08 13:47:21 +01:00
bartal-lsn a483371dc5 fixed correct proton drive upload with vertical slices 2026-08-08 13:45:04 +01:00
bartal-lsn b60588c4a0 ignore java dependency files 2026-08-07 23:43:59 +01:00
bartal-lsn 9951a5947f ability to omit front end files like .css .js .html and so on 2026-08-07 23:26:43 +01:00
bartal-lsn 6fbb9bf983 finished vertical-.slices 2026-08-07 23:10:42 +01:00
bartal-lsn 8c39068453 fixed remote dir typo 2026-08-05 10:33:46 +01:00
bartal-lsn bccf9bf198 simplify readme and make a force flag for local overrides 2026-08-05 10:30:20 +01:00
18 changed files with 1594 additions and 167 deletions
+1
View File
@@ -1,3 +1,4 @@
bin/
.env
vendor/
.dirmd
+219 -26
View File
@@ -1,15 +1,51 @@
# dirmd
Generate markdown documentation from directory structure.
Generate markdown documentation from directory structure. Optimized for LLM ingestion.
<video src="./docs/videos/dirmd.webm" autoplay loop muted playsinline></video>
## Features
- Recursive directory traversal
- Tree visualization (standard tree command format)
- Full file contents with syntax-highlighted code blocks
- ASCII tree visualization (LLM-friendly, minimal token overhead)
- Full file contents with explicit delimiters and metadata
- Filters dotfiles, binaries, and blacklisted extensions
- Vertical slices mode: root file + one markdown per top-level directory
- Append mode for single files or directories
- Force overwrite of existing output files
- Configurable depth, file size limits, ignore patterns
- Optional upload to Proton Drive after generating output
- Skip frontend files (`.html`, `.css`, `.js`, etc.)
- Frontend only mode (include only frontend files and `README.md`)
- Omit test files and inline test blocks (Go, Java, Rust)
## Installation
Only for linux.
Download binary from release page or install with go
```bash
go install git.flo.fo/FLO/dirmd@latest
```
## Use with Git Post Hook
combine with git post hook to sync with proton drive on each commit
add something like this in ./.git/hooks/post-commit
```bash
#!/bin/bash
set -euo pipefail
if ! command -v dirmd &>/dev/null; then
exit 0
fi
REPO_ROOT=$(git rev-parse --show-toplevel)
REPO_NAME=$(basename "$REPO_ROOT")
dirmd -o "/tmp/${REPO_NAME}" "$REPO_ROOT" -f --vertical-slices --proton-drive
```
## Build
@@ -19,65 +55,222 @@ Generate markdown documentation from directory structure.
# Create new documentation
dirmd -o output.md ~/repos/myproject
# Overwrite existing output file
dirmd -o output.md ~/repos/myproject -f
# Append a single file
dirmd -a -i README.md -o existing.md
# Append another directory
dirmd -a ~/repos/anotherproject -o existing.md
# Generate with vertical slices
dirmd -o output.md ~/repos/myproject --vertical-slices
# Skip frontend files (.html, .css, .js, etc.)
dirmd -o output.md ~/repos/myproject --skip-frontend
# Frontend only (include only frontend files and README.md)
dirmd -o output.md ~/repos/myproject --frontend-only
# Omit test files and inline test blocks
# Go: excludes *_test.go
# Java: excludes *Test.java, *Tests.java, src/test/java/*
# Rust: strips #[cfg(test)] mod tests { ... } blocks
dirmd -o output.md ~/repos/myproject --omit-test
# Combine filters
dirmd -o output.md ~/repos/myproject --frontend-only --omit-test
# Generate and upload to Proton Drive (default: /my-files/md/<filename>)
dirmd -o output.md ~/repos/myproject --proton-drive
# Generate and upload to a specific remote path
dirmd -o output.md ~/repos/myproject --proton-drive --drive-path /my-files/docs/api.md
## Test File Omission
The `--omit-test` flag removes test files and inline test blocks to reduce token overhead in LLM context windows. Behavior varies by language:
| Language | Exclusion Strategy | What Gets Removed |
|----------|-------------------|-------------------|
| **Go** | File exclusion | All `*_test.go` files |
| **Java** | File exclusion | `*Test.java`, `*Tests.java`, files under `src/test/java/` |
| **Rust** | Content stripping | Entire `#[cfg(test)] mod tests { ... }` blocks removed from `.rs` files |
The Rust stripping works by detecting the `#[cfg(test)]` attribute, then tracking brace depth until the module closes. Nested braces in string literals may occasionally throw off the count, but this is acceptable for RAG use cases.
## Frontend Filtering
Two complementary flags control frontend file inclusion:
| Flag | Effect |
|------|--------|
| `--skip-frontend` | Exclude frontend files, keep everything else |
| `--frontend-only` | Include only frontend files and `README.md`, exclude everything else |
Frontend extensions covered:
| Type | Extensions |
|------|------------|
| Templates | `.html`, `.htm`, `.gohtml`, `.tmpl` |
| Stylesheets | `.css`, `.scss`, `.sass`, `.less` |
| JavaScript | `.js`, `.jsx`, `.mjs`, `.cjs` |
| TypeScript | `.ts`, `.tsx` |
| Frameworks | `.vue`, `.svelte` |
`--skip-frontend` and `--frontend-only` are mutually exclusive. Both can be combined with `--omit-test`.
## Vertical Slices Mode
When `--vertical-slices` is passed, dirmd splits output into multiple files instead of one monolithic document:
- **Root file** (`-o output.md`): Full directory tree with cross-references to slice files, a vertical slices index table (file counts, total bytes per directory), and full contents of all root-level files.
- **Slice files** (`cmd.md`, `internal.md`, etc.): One per top-level directory, placed alongside the root file. Each contains its own tree, a back-reference to the root document, and full recursive file contents for that directory.
This mode cannot be combined with `--append` or `--input-file`.
Example output with `dirmd -o docs.md ~/repos/myproject --vertical-slices`:
docs/
├── docs.md ← root: tree + slices index + root file contents
├── cmd.md ← slice: tree + contents of cmd/
└── internal.md ← slice: tree + contents of internal/
## Instructions File (`.dirmd`)
If a file named `.dirmd` exists in the root of the input directory, you can include its contents as metadata in the generated output by passing the `--instructions` flag.
This is useful for injecting specific context, constraints, or instructions for LLMs processing the snapshot.
- **File Name**: `.dirmd`
- **Location**: Root of the input directory.
- **Format**: Plain text or Markdown.
- **Output**: Rendered as a quoted block (`> Instructions: ...`) immediately after the timestamp and commit info.
- **Opt-in**: The file is **ignored** unless `--instructions` is explicitly passed.
Example `.dirmd`:
```text
Focus on the `internal/` package for business logic.
Ignore `cmd/` unless explicitly asked.
Prioritize Go files over Python.
```
## LLM-Optimized Output
Output is designed for RAG ingestion and LLM context windows, not human presentation:
- **ISO 8601 Timestamp**: Added as a metadata line (`> Generated: ...`) immediately after the H1 header for every new file created.
- **Git Commit Info**: If the source directory is a Git repository, a second metadata line (`> Commit: <sha> (<message>)`) is added below the timestamp.
- **ASCII tree format** (`+-` instead of Unicode box-drawing) to reduce token overhead
- **Explicit file delimiters**: `--- FILE: ./path (bytes, lines) ---` and `--- END FILE ---`
- **Document type markers**: `[ROOT]` and `[SLICE]` in H1 headers
- **Cross-references**: Directory entries in root tree show `-> see cmd.md`; slice files reference their root document
- **Slices index table**: File counts and total bytes per slice for informed retrieval decisions
- **Tilde fences** (`~~~`) to avoid conflicts with backticks in source files
## Flags
| Flag | Description | Default |
|------|-------------|---------|
| `-o`, `--output` | Output markdown file path (required) | |
| `-a`, `--append` | Append to existing output file | false |
| `-f`, `--force` | Overwrite existing output file (non-append mode) | false |
| `-i`, `--input-file` | Single file input (append mode only) | |
| `--vertical-slices` | Split output into root file + one markdown per top-level directory | false |
| `--max-size` | Max file size in bytes | 524288 |
| `--max-depth` | Max directory recursion depth | 20 |
| `--ignore` | Additional ignore patterns (glob) | |
| `--extensions` | Additional file extensions to skip | |
| `--proton-drive` | Upload output to Proton Drive after writing locally | false |
| `--drive-path` | Full remote path on Proton Drive (e.g. /my-files/md/report.md) | /my-files/md/<filename> |
| `--skip-frontend` | Omit frontend file types (.html, .css, .js, .ts, .vue, .svelte, etc.) | false |
| `--frontend-only` | Include only frontend file types and README.md | false |
| `--omit-test` | Exclude test files (Go *_test.go, Java *Test.java) and strip #[cfg(test)] blocks from Rust | false |
## Proton Drive Integration
dirmd can optionally upload the generated markdown file(s) to Proton Drive using the [Proton Drive CLI](https://proton.me/support/drive-cli). The upload is performed only when `--proton-drive` is passed. Without it, dirmd behaves exactly as before — no Proton Drive dependency is required.
In vertical slices mode, all generated files (root + slices) are uploaded to `/my-files/md/`. The `--drive-path` flag is ignored in slices mode ; each file is uploaded by its basename.
### Prerequisites
1. Install the Proton Drive CLI — see the [official guide](https://proton.me/support/drive-cli) for download and installation instructions.
2. Authenticate by running `proton-drive auth login`.
3. Ensure `proton-drive` is in your PATH.
If the CLI is not installed or you are not logged in, dirmd will report the error to stderr and exit with a non-zero code. Local files are always written successfully before the upload is attempted.
For full documentation on the Proton Drive CLI, including installation, authentication, and troubleshooting, refer to the [official Proton Drive CLI support page](https://proton.me/support/drive-cli).
## Examples
# Custom depth and size limit
dirmd -o output.md ~/repos/project --max-depth 5 --max-size 262144
# Ignore specific directories
dirmd -o output.md ~/repos/project --ignore __pycache__ --ignore .venv
# Skip additional file extensions
dirmd -o output.md ~/repos/project --extensions .log --extensions .tmp
# Vertical slices with overwrite
dirmd -o docs.md ~/repos/project -f --vertical-slices
# Skip frontend and omit tests
dirmd -o docs.md ~/repos/project -f --skip-frontend --omit-test
# Frontend only with test omission
dirmd -o docs.md ~/repos/project -f --frontend-only --omit-test
# Generate, then upload to default remote location
dirmd -o docs.md ~/repos/project --proton-drive
# Generate, then upload to a custom remote location
dirmd -o docs.md ~/repos/project --proton-drive --drive-path /my-files/projects/docs.md
# Overwrite existing local file and upload to Drive
dirmd -o docs.md ~/repos/project -f --proton-drive
## Output Format
Produces markdown with:
1. Absolute path as H1 header
2. Directory tree (indented with 4 spaces per level)
3. All text files with content in code blocks
4. Language detection from file extensions
1. Absolute path as H1 header with `[ROOT]` or `[SLICE]` marker
2. **Timestamp metadata line** (ISO 8601) immediately after the H1 header (only for new files)
3. ASCII directory tree (indented with `+-` and `|` characters)
4. Vertical slices index table (sliced mode only)
5. All text files with content in fenced code blocks
6. Explicit `--- FILE: ---` and `--- END FILE ---` delimiters with byte and line counts
7. Language detection from file extensions
Example output structure:
Example output structure (non-sliced mode):
# /home/user/repos/myproject [ROOT]
> Generated: 2026-09-02T14:30:00Z
> Commit: a1b2c3d (Fix login bug)
# /home/user/repos/myproject
## Directory Tree
~~~
myproject/
├── cmd/
│ └── main.go
└── internal/
└── config.go
+- cmd/
| +- main.go
+- internal/
| +- config.go
~~~
## Contents
### ./cmd/main.go
```go
--- FILE: ./cmd/main.go (45 bytes, 3 lines) ---
~~~go
package main
func main() {}
```
~~~
--- END FILE ---
Note: Uses tilde fences (~~~) internally to avoid conflicts with nested backticks in source files.
+141 -43
View File
@@ -7,42 +7,52 @@ import (
"github.com/spf13/cobra"
"dirmd/internal/config"
"dirmd/internal/drive"
"dirmd/internal/renderer"
"dirmd/internal/walker"
"git.flo.fo/FLO/dirmd/internal/config"
"git.flo.fo/FLO/dirmd/internal/drive"
"git.flo.fo/FLO/dirmd/internal/renderer"
"git.flo.fo/FLO/dirmd/internal/tui"
"git.flo.fo/FLO/dirmd/internal/walker"
)
var (
rootCmd = &cobra.Command{
Use: "dirmd [flags] <directory>",
Short: "Generate markdown documentation from directory structure",
Long: `dirmd walks a directory and generates a markdown file with:
Long: `dirmd walks a directory and generates markdown files with:
- Directory tree structure
- Directory tree structure
- All readable text files with their contents
Output is always written to a directory specified by -o.
Examples:
# Create new documentation
dirmd -o output.md ~/repos/myproject
dirmd -o ./docs ~/repos/myproject
# Append a single file
dirmd -a -i README.md -o existing.md
# Overwrite existing output directory (deletes all contents)
dirmd -o ./docs ~/repos/myproject -f
# Append another directory
dirmd -a -o existing.md ~/repos/anotherproject
# Append a single file
dirmd -a -i README.md -o ./docs
# Generate and upload to Proton Drive (default remote path /my-files/md/<filename>)
dirmd -o output.md ~/repos/myproject --proton-drive
# Vertical slices
dirmd -o ./docs ~/repos/myproject --vertical-slices
# Generate and upload to a specific remote path
dirmd -o output.md ~/repos/myproject --proton-drive --drive-path /my-files/docs/api.md`,
# Include instructions from .dirmd
dirmd -o ./docs ~/repos/myproject --instructions
# Generate and upload to Proton Drive
dirmd -o ./docs ~/repos/myproject --proton-drive
# Interactive TUI mode
dirmd --tui`,
RunE: run,
}
outputPath string
appendMode bool
force bool
singleFile string
maxSize int64
maxDepth int
@@ -50,6 +60,13 @@ Examples:
extensions []string
protonDrive bool
drivePath string
verticalSlices bool
skipFrontend bool
frontendOnly bool
omitTest bool
omitMd bool
tuiMode bool
instructions bool
)
func Execute() {
@@ -59,33 +76,71 @@ func Execute() {
}
func init() {
rootCmd.PersistentFlags().StringVarP(&outputPath, "output", "o", "", "output markdown file path (required)")
rootCmd.Flags().BoolVarP(&appendMode, "append", "a", false, "append to existing output file")
rootCmd.Flags().StringVarP(&singleFile, "input-file", "i", "", "single file input (append mode only)")
rootCmd.PersistentFlags().StringVarP(&outputPath, "output", "o", "", "output directory (required)")
rootCmd.Flags().BoolVarP(&appendMode, "append", "a", false, "append to existing index.md")
rootCmd.Flags().BoolVarP(&force, "force", "f", false, "overwrite existing output directory (deletes all contents)")
rootCmd.Flags().StringVar(&singleFile, "input-file", "", "single file input (append mode only)")
rootCmd.Flags().BoolVarP(&tuiMode, "interactive", "i", false, "launch interactive TUI configuration wizard")
rootCmd.Flags().Int64Var(&maxSize, "max-size", config.DefaultMaxSize, "max file size in bytes")
rootCmd.Flags().IntVar(&maxDepth, "max-depth", config.DefaultMaxDepth, "max directory recursion depth")
rootCmd.Flags().StringArrayVar(&ignorePatterns, "ignore", nil, "additional ignore patterns (glob)")
rootCmd.Flags().StringArrayVar(&extensions, "extensions", nil, "additional file extensions to skip (repeatable)")
rootCmd.Flags().BoolVar(&protonDrive, "proton-drive", false, "upload output to Proton Drive after writing locally")
rootCmd.Flags().StringVar(&drivePath, "drive-path", "", "full remote path on Proton Drive (e.g. /my-files/md/report.md). Requires --proton-drive")
rootCmd.MarkPersistentFlagRequired("output")
rootCmd.Flags().BoolVar(&protonDrive, "proton-drive", false, "upload output directory to Proton Drive after writing locally")
rootCmd.Flags().StringVar(&drivePath, "drive-path", "", "full remote directory on Proton Drive (e.g. /my-files/docs/api). Requires --proton-drive")
rootCmd.Flags().BoolVar(&verticalSlices, "vertical-slices", false, "split output into root index.md + one markdown per top-level directory")
rootCmd.Flags().BoolVar(&skipFrontend, "skip-frontend", false, "omit frontend file types (.html, .css, .js, .ts, .vue, .svelte, etc.)")
rootCmd.Flags().BoolVar(&frontendOnly, "frontend-only", false, "include only frontend file types and README.md")
rootCmd.Flags().BoolVar(&omitTest, "omit-test", false, "exclude test files (Go *_test.go, Java *Test.java) and strip #[cfg(test)] blocks from Rust")
rootCmd.Flags().BoolVar(&omitMd, "omit-md", false, "omit all .md files except README.md")
rootCmd.Flags().BoolVar(&instructions, "instructions", false, "include .dirmd instructions file if present in root")
}
func run(cmd *cobra.Command, args []string) error {
if tuiMode {
return tui.Run()
}
if outputPath == "" {
return fmt.Errorf("-o is required (or use --tui for interactive mode)")
}
if drivePath != "" && !protonDrive {
return fmt.Errorf("--drive-path requires --proton-drive")
}
if verticalSlices && appendMode {
return fmt.Errorf("--vertical-slices cannot be used with --append")
}
if verticalSlices && singleFile != "" {
return fmt.Errorf("--vertical-slices cannot be used with --input-file")
}
if skipFrontend && frontendOnly {
return fmt.Errorf("--skip-frontend and --frontend-only are mutually exclusive")
}
excludedExts := append(config.DefaultExts, extensions...)
if skipFrontend {
excludedExts = append(excludedExts, config.FrontendExts...)
}
cfg := &config.Config{
OutputPath: outputPath,
AppendMode: appendMode,
MaxSize: maxSize,
MaxDepth: maxDepth,
Ignores: append(config.DefaultIgnores, ignorePatterns...),
Extensions: append(config.DefaultExts, extensions...),
ProtonDrive: protonDrive,
DrivePath: drivePath,
OutputPath: outputPath,
AppendMode: appendMode,
Force: force,
MaxSize: maxSize,
MaxDepth: maxDepth,
Ignores: append(config.DefaultIgnores, ignorePatterns...),
Extensions: excludedExts,
ProtonDrive: protonDrive,
DrivePath: drivePath,
VerticalSlices: verticalSlices,
SkipFrontend: skipFrontend,
FrontendOnly: frontendOnly,
OmitTest: omitTest,
OmitMd: omitMd,
IncludeInstructions: instructions,
}
if singleFile != "" {
@@ -107,13 +162,29 @@ func run(cmd *cobra.Command, args []string) error {
}
cfg.AbsRoot = abs
// Non-append mode: output must not exist
absOutput, err := filepath.Abs(outputPath)
if err != nil {
return fmt.Errorf("failed to resolve output path: %w", err)
}
if !appendMode {
if _, err := os.Stat(outputPath); err == nil {
return fmt.Errorf("output file %s already exists (use -a to append)", outputPath)
if _, err := os.Stat(absOutput); err == nil {
if !force {
return fmt.Errorf("output directory %s already exists (use -f to overwrite)", absOutput)
}
if err := os.RemoveAll(absOutput); err != nil {
return fmt.Errorf("failed to remove existing output directory: %w", err)
}
}
}
if err := os.MkdirAll(absOutput, 0755); err != nil {
return fmt.Errorf("cannot create output directory: %w", err)
}
repoName := filepath.Base(cfg.AbsRoot)
fmt.Printf("%s\n", absOutput)
result, err := walker.Run(cfg)
if err != nil {
return err
@@ -123,31 +194,58 @@ func run(cmd *cobra.Command, args []string) error {
return fmt.Errorf("no readable text files found in %s", cfg.AbsRoot)
}
if err := renderer.Write(result, outputPath, appendMode, cfg.SingleFile); err != nil {
indexBasename := "index_" + repoName + ".md"
if verticalSlices {
generated, err := renderer.WriteSlices(result, absOutput, repoName)
if err != nil {
return err
}
fmt.Fprintf(os.Stderr, "generated %d files in %s\n", len(generated), absOutput)
if protonDrive {
remoteDir := drivePath
if remoteDir == "" {
remoteDir = "/my-files/md/" + filepath.Base(absOutput)
}
if err := drive.CheckAuth(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
fmt.Fprintln(os.Stderr, "local files were written successfully, but upload was skipped")
return err
}
if err := drive.UploadDir(absOutput, remoteDir); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
fmt.Fprintln(os.Stderr, "local files were written successfully, but upload failed")
return err
}
fmt.Fprintf(os.Stderr, "uploaded %s to %s on Proton Drive\n", absOutput, remoteDir)
}
return nil
}
indexPath := filepath.Join(absOutput, indexBasename)
if err := renderer.Write(result, indexPath, appendMode, cfg.SingleFile); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "wrote %d entries to %s\n", len(result.Entries), outputPath)
fmt.Fprintf(os.Stderr, "wrote %d entries to %s\n", len(result.Entries), indexPath)
if protonDrive {
remotePath := drivePath
if remotePath == "" {
remotePath = "/my-files/md/" + filepath.Base(outputPath)
remoteDir := drivePath
if remoteDir == "" {
remoteDir = "/my-files/md/" + filepath.Base(absOutput)
}
if err := drive.CheckAuth(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
fmt.Fprintln(os.Stderr, "local file was written successfully, but upload was skipped")
return err
}
if err := drive.Upload(outputPath, remotePath); err != nil {
if err := drive.UploadDir(absOutput, remoteDir); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
fmt.Fprintln(os.Stderr, "local file was written successfully, but upload failed")
return err
}
fmt.Fprintf(os.Stderr, "uploaded %s to %s on Proton Drive\n", outputPath, remotePath)
fmt.Fprintf(os.Stderr, "uploaded %s to %s on Proton Drive\n", absOutput, remoteDir)
}
return nil
Binary file not shown.
Binary file not shown.
+29 -2
View File
@@ -1,10 +1,37 @@
module dirmd
module git.flo.fo/FLO/dirmd
go 1.26.4
require github.com/spf13/cobra v1.10.2
require (
charm.land/bubbles/v2 v2.2.1
charm.land/huh/v2 v2.0.3
github.com/spf13/cobra v1.10.2
)
require (
charm.land/bubbletea/v2 v2.0.9 // indirect
charm.land/lipgloss/v2 v2.0.6 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/catppuccin/go v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect
github.com/charmbracelet/x/ansi v0.11.8 // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/mattn/go-runewidth v0.0.27 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
)
+66
View File
@@ -1,10 +1,76 @@
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8=
charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA=
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro=
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0=
github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
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=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+89 -54
View File
@@ -15,15 +15,9 @@ const (
)
var DefaultIgnores = []string{
".git",
"node_modules",
"vendor",
"bin",
"target",
"build",
"dist",
".idea",
".vscode",
".git", "node_modules", "vendor", "bin", "go.mod", "go.sum",
"target", "build", "dist", "Cargo.lock", ".idea", ".vscode",
"dependency-reduced-pom.xml", "__pycache__", "venv", "env", "uv.lock",
}
var DefaultExts = []string{
@@ -35,35 +29,42 @@ var DefaultExts = []string{
".pdf", ".doc", ".docx", ".xls", ".xlsx",
".pem", ".key", ".cert",
".db", ".sqlite", ".sqlite3",
".class", // Java compiled bytecode
".jar", // JAR archives
".war",
".ear",
".class", ".jar", ".war", ".ear",
".pyc", ".pyo",
}
var FrontendExts = []string{
".html", ".htm", ".gohtml", ".tmpl",
".css", ".scss", ".sass", ".less",
".js", ".jsx", ".mjs", ".cjs",
".ts", ".tsx",
".vue", ".svelte",
}
type stringSlice []string
func (s *stringSlice) String() string {
return strings.Join(*s, ", ")
}
func (s *stringSlice) Set(v string) error {
*s = append(*s, v)
return nil
}
func (s *stringSlice) String() string { return strings.Join(*s, ", ") }
func (s *stringSlice) Set(v string) error { *s = append(*s, v); return nil }
type Config struct {
InputPath string
OutputPath string
AppendMode bool
SingleFile bool
MaxSize int64
MaxDepth int
Ignores []string
Extensions []string
AbsRoot string
ProtonDrive bool
DrivePath string
InputPath string
OutputPath string
AppendMode bool
Force bool
SingleFile bool
MaxSize int64
MaxDepth int
Ignores []string
Extensions []string
AbsRoot string
ProtonDrive bool
DrivePath string
VerticalSlices bool
SkipFrontend bool
FrontendOnly bool
OmitTest bool
OmitMd bool
IncludeInstructions bool
}
func PrintUsage() {
@@ -76,28 +77,42 @@ func PrintUsage() {
func Parse() (*Config, error) {
var (
input string
output string
appendMd bool
maxSize int64
maxDepth int
ignores stringSlice
exts stringSlice
showVer bool
protonDrive bool
drivePath string
input string
output string
appendMd bool
force bool
maxSize int64
maxDepth int
ignores stringSlice
exts stringSlice
showVer bool
protonDrive bool
drivePath string
verticalSlices bool
skipFrontend bool
frontendOnly bool
omitTest bool
omitMd bool
includeInstructions bool
)
flag.StringVar(&input, "i", "", "single file input (append mode only)")
flag.StringVar(&output, "o", "", "output markdown file path (required)")
flag.BoolVar(&appendMd, "a", false, "append to existing output file")
flag.BoolVar(&force, "f", false, "overwrite existing output file (non-append mode)")
flag.Int64Var(&maxSize, "max-size", DefaultMaxSize, "max file size in bytes")
flag.IntVar(&maxDepth, "max-depth", DefaultMaxDepth, "max directory recursion depth")
flag.Var(&ignores, "ignore", "additional ignore patterns (glob, repeatable)")
flag.Var(&exts, "extensions", "additional file extensions to skip (repeatable)")
flag.BoolVar(&showVer, "version", false, "print version and exit")
flag.BoolVar(&protonDrive, "proton-drive", false, "upload output to Proton Drive after writing locally")
flag.StringVar(&drivePath, "drive-path", "", "full remote path on Proton Drive (requires --proton-drive)")
flag.StringVar(&drivePath, "drive-path", "", "full remote path on Proton Drive (requires --proton-drive")
flag.BoolVar(&verticalSlices, "vertical-slices", false, "split output into root file + one markdown per top-level directory")
flag.BoolVar(&skipFrontend, "skip-frontend", false, "omit frontend file types")
flag.BoolVar(&frontendOnly, "frontend-only", false, "include only frontend file types and README.md")
flag.BoolVar(&omitTest, "omit-test", false, "exclude test files and strip #[cfg(test)] blocks")
flag.BoolVar(&omitMd, "omit-md", false, "omit all .md files except README.md")
flag.BoolVar(&includeInstructions, "instructions", false, "include .dirmd instructions file if present in root")
flag.Usage = PrintUsage
flag.Parse()
@@ -110,20 +125,40 @@ func Parse() (*Config, error) {
if output == "" {
return nil, fmt.Errorf("-o is required")
}
if drivePath != "" && !protonDrive {
return nil, fmt.Errorf("--drive-path requires --proton-drive")
}
if verticalSlices && appendMd {
return nil, fmt.Errorf("--vertical-slices cannot be used with --append")
}
if verticalSlices && input != "" {
return nil, fmt.Errorf("--vertical-slices cannot be used with --input-file")
}
if skipFrontend && frontendOnly {
return nil, fmt.Errorf("--skip-frontend and --frontend-only are mutually exclusive")
}
excludedExts := append(DefaultExts, exts...)
if skipFrontend {
excludedExts = append(excludedExts, FrontendExts...)
}
cfg := &Config{
OutputPath: output,
AppendMode: appendMd,
MaxSize: maxSize,
MaxDepth: maxDepth,
Ignores: append(DefaultIgnores, ignores...),
Extensions: append(DefaultExts, exts...),
ProtonDrive: protonDrive,
DrivePath: drivePath,
OutputPath: output,
AppendMode: appendMd,
Force: force,
MaxSize: maxSize,
MaxDepth: maxDepth,
Ignores: append(DefaultIgnores, ignores...),
Extensions: excludedExts,
ProtonDrive: protonDrive,
DrivePath: drivePath,
VerticalSlices: verticalSlices,
SkipFrontend: skipFrontend,
FrontendOnly: frontendOnly,
OmitTest: omitTest,
OmitMd: omitMd,
IncludeInstructions: includeInstructions,
}
if input != "" {
@@ -147,8 +182,8 @@ func Parse() (*Config, error) {
cfg.AbsRoot = abs
if !appendMd {
if _, err := os.Stat(output); err == nil {
return nil, fmt.Errorf("output file %s already exists (use -a to append)", output)
if _, err := os.Stat(output); err == nil && !force {
return nil, fmt.Errorf("output file %s already exists (use -f to overwrite or -a to append)", output)
}
}
+58 -8
View File
@@ -10,12 +10,10 @@ import (
const binaryName = "proton-drive"
// Lookup returns the full path to the proton-drive binary in PATH.
func Lookup() (string, error) {
return exec.LookPath(binaryName)
}
// CheckAuth verifies that the user is authenticated by listing /my-files.
func CheckAuth() error {
binary, err := Lookup()
if err != nil {
@@ -32,11 +30,63 @@ func CheckAuth() error {
return nil
}
// Upload uploads localPath to remotePath on Proton Drive.
// remotePath is a full path including filename (e.g. /my-files/md/report.md).
// The CLI uploads to a parent directory and preserves the local filename,
// so if the remote filename differs from the local one, a temp copy is staged.
// Existing remote files are overwritten (--conflict-strategy replace).
func EnsureRemoteParent(remoteDir string) error {
binary, err := Lookup()
if err != nil {
return fmt.Errorf("proton-drive not found in PATH: %w", err)
}
parentDir := filepath.Dir(remoteDir)
grandparent := filepath.Dir(parentDir)
parentName := filepath.Base(parentDir)
cmd := exec.Command(binary, "filesystem", "create-folder", grandparent, parentName)
cmd.Stdout = nil
cmd.Stderr = nil
cmd.Run()
return nil
}
func CleanRemote(remoteDir string) {
binary, err := Lookup()
if err != nil {
return
}
cmd := exec.Command(binary, "filesystem", "delete", remoteDir)
cmd.Stdout = nil
cmd.Stderr = nil
cmd.Run()
}
func UploadDir(localDir, remoteDir string) error {
binary, err := Lookup()
if err != nil {
return fmt.Errorf("proton-drive not found in PATH: %w", err)
}
if err := EnsureRemoteParent(remoteDir); err != nil {
return fmt.Errorf("cannot prepare remote directory: %w", err)
}
CleanRemote(remoteDir)
remoteParent := filepath.Dir(remoteDir)
cmd := exec.Command(binary, "filesystem", "upload", localDir, remoteParent,
"--folder-conflict-strategy", "replace", "--file-conflict-strategy", "replace")
var stderr strings.Builder
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("upload to proton-drive failed: %w: %s",
err, strings.TrimSpace(stderr.String()))
}
return nil
}
func Upload(localPath, remotePath string) error {
binary, err := Lookup()
if err != nil {
@@ -67,7 +117,7 @@ func Upload(localPath, remotePath string) error {
func runUpload(binary, localPath, remoteDir string) error {
cmd := exec.Command(binary, "filesystem", "upload", localPath, remoteDir,
"--conflict-strategy", "replace")
"--folder-conflict-strategy", "replace", "--file-conflict-strategy", "replace")
var stderr strings.Builder
cmd.Stderr = &stderr
+68 -5
View File
@@ -4,12 +4,17 @@ import (
"os"
"path/filepath"
"strings"
"git.flo.fo/FLO/dirmd/internal/config"
)
type Filter struct {
MaxSize int64
Extensions []string
Ignores []string
MaxSize int64
Extensions []string
Ignores []string
OmitTest bool
FrontendOnly bool
OmitMd bool
}
func (f *Filter) ShouldSkipDir(name string) bool {
@@ -37,6 +42,22 @@ func (f *Filter) ShouldSkipFile(path, name string, info os.FileInfo) bool {
}
}
if info.Size() > f.MaxSize {
return true
}
if f.OmitMd && isNonReadmeMarkdown(path, name) {
return true
}
if f.FrontendOnly && !isFrontendOrReadme(path, name) {
return true
}
if f.OmitTest && isTestFile(path, name) {
return true
}
ext := filepath.Ext(path)
for _, bad := range f.Extensions {
if strings.EqualFold(ext, bad) {
@@ -44,8 +65,50 @@ func (f *Filter) ShouldSkipFile(path, name string, info os.FileInfo) bool {
}
}
if info.Size() > f.MaxSize {
return true
return false
}
func isNonReadmeMarkdown(path, name string) bool {
ext := strings.ToLower(filepath.Ext(path))
if ext != ".md" && ext != ".markdown" {
return false
}
return !strings.EqualFold(name, "README.md")
}
func isFrontendOrReadme(path, name string) bool {
ext := strings.ToLower(filepath.Ext(path))
for _, fe := range config.FrontendExts {
if strings.EqualFold(ext, fe) {
return true
}
}
return strings.EqualFold(name, "README.md")
}
func isTestFile(path, name string) bool {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".go":
return strings.HasSuffix(strings.ToLower(name), "_test.go")
case ".java":
base := strings.TrimSuffix(name, filepath.Ext(name))
if strings.HasSuffix(base, "Test") || strings.HasSuffix(base, "Tests") {
return true
}
slashPath := filepath.ToSlash(path)
return strings.Contains(slashPath, "/src/test/")
case ".py":
lower := strings.ToLower(name)
if strings.HasPrefix(lower, "test_") || strings.HasSuffix(lower, "_test.py") {
return true
}
if strings.EqualFold(name, "conftest.py") {
return true
}
slashPath := filepath.ToSlash(path)
return strings.Contains(slashPath, "/tests/")
}
return false
+37
View File
@@ -0,0 +1,37 @@
package git
import (
"fmt"
"os/exec"
"strings"
)
type CommitInfo struct {
ShortSHA string
Message string
}
func GetCommitInfo(rootDir string) *CommitInfo {
cmd := exec.Command("git", "-C", rootDir, "log", "-1", "--format=%h %s")
output, err := cmd.Output()
if err != nil {
return nil
}
parts := strings.SplitN(strings.TrimSpace(string(output)), " ", 2)
if len(parts) < 2 {
return nil
}
return &CommitInfo{
ShortSHA: parts[0],
Message: parts[1],
}
}
func FormatCommitLine(info *CommitInfo) string {
if info == nil {
return ""
}
return fmt.Sprintf("> Commit: %s (%s)\n", info.ShortSHA, info.Message)
}
+41
View File
@@ -0,0 +1,41 @@
package instructions
import (
"os"
"path/filepath"
"strings"
)
const FileName = ".dirmd"
func Read(rootDir string) (string, error) {
path := filepath.Join(rootDir, FileName)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
return string(data), nil
}
func Format(content string) string {
if content == "" {
return ""
}
content = strings.TrimSpace(content)
if content == "" {
return ""
}
lines := strings.Split(content, "\n")
var sb strings.Builder
sb.WriteString("> Instructions:\n")
for _, line := range lines {
sb.WriteString("> ")
sb.WriteString(line)
sb.WriteString("\n")
}
sb.WriteString("\n")
return sb.String()
}
+415 -9
View File
@@ -4,11 +4,15 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"unicode/utf8"
"dirmd/internal/tree"
"dirmd/internal/walker"
"git.flo.fo/FLO/dirmd/internal/git"
"git.flo.fo/FLO/dirmd/internal/instructions"
"git.flo.fo/FLO/dirmd/internal/tree"
"git.flo.fo/FLO/dirmd/internal/walker"
)
const fenceChars = "~~~"
@@ -29,20 +33,30 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
var sb strings.Builder
// Determine the correct H1 path
h1Path := result.AbsRoot
if singleFile && len(result.Entries) > 0 {
h1Path = filepath.Join(result.AbsRoot, result.Entries[0].RelPath)
}
fmt.Fprintf(&sb, "# %s\n\n", h1Path)
fmt.Fprintf(&sb, "# %s [ROOT]\n", h1Path)
if !appendMode {
fmt.Fprintf(&sb, "> Generated: %s\n", time.Now().Format(time.RFC3339))
sb.WriteString(git.FormatCommitLine(result.Commit))
if result.Instructions != "" {
sb.WriteString(instructions.Format(result.Instructions))
} else {
sb.WriteString("\n")
}
}
if !singleFile {
root := tree.Build(result.Entries)
rootName := filepath.Base(result.AbsRoot)
sb.WriteString("## Directory Tree\n\n")
sb.WriteString(fenceChars + "\n")
sb.WriteString(tree.Render(root, rootName))
sb.WriteString(renderTreeForLLM(root, rootName))
sb.WriteString(fenceChars + "\n\n")
}
@@ -53,16 +67,16 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
for _, entry := range result.Entries {
fullPath := filepath.Join(result.AbsRoot, entry.RelPath)
content, err := readFile(fullPath)
content, err := readFileForOutput(fullPath, result.OmitTest)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err)
continue
}
language := extToLang(filepath.Ext(entry.RelPath))
lines := countLines(content)
fmt.Fprintf(&sb, "### ./%s\n\n", entry.RelPath)
fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, entry.Size, lines)
fmt.Fprintf(&sb, "%s", fenceChars)
if language != "" {
fmt.Fprintf(&sb, "%s", language)
@@ -72,7 +86,7 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
if !strings.HasSuffix(content, "\n") {
fmt.Fprintf(&sb, "\n")
}
fmt.Fprintf(&sb, "%s\n\n", fenceChars)
fmt.Fprintf(&sb, "%s\n--- END FILE ---\n\n", fenceChars)
}
}
@@ -80,6 +94,325 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
return err
}
func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, error) {
entries := result.Entries
if len(entries) == 0 {
return nil, fmt.Errorf("no entries to write")
}
absRoot := result.AbsRoot
rootName := filepath.Base(absRoot)
topLevelDirs := make(map[string][]walker.Entry)
rootFiles := make([]walker.Entry, 0)
for _, e := range entries {
rel := e.RelPath
parts := strings.Split(rel, string(filepath.Separator))
if len(parts) == 1 {
rootFiles = append(rootFiles, e)
} else {
firstPart := parts[0]
topLevelDirs[firstPart] = append(topLevelDirs[firstPart], e)
}
}
var generated []string
indexBasename := "index_" + repoName + ".md"
rootPath := filepath.Join(outputDir, indexBasename)
generated = append(generated, rootPath)
if err := writeRootSlice(rootPath, absRoot, rootName, repoName, rootFiles, topLevelDirs, result.OmitTest, result.Commit, result.Instructions); err != nil {
return nil, fmt.Errorf("failed to write root slice: %w", err)
}
var dirNames []string
for k := range topLevelDirs {
dirNames = append(dirNames, k)
}
sort.Strings(dirNames)
for _, dirName := range dirNames {
sliceName := strings.TrimSuffix(dirName, "/") + "_" + repoName + ".md"
sliceDir := filepath.Join(outputDir, dirName)
if err := os.MkdirAll(sliceDir, 0755); err != nil {
return nil, fmt.Errorf("cannot create slice directory %s: %w", sliceDir, err)
}
slicePath := filepath.Join(sliceDir, sliceName)
generated = append(generated, slicePath)
absDir := filepath.Join(absRoot, dirName)
dirEntries := topLevelDirs[dirName]
treeEntries := make([]walker.Entry, len(dirEntries))
for i, e := range dirEntries {
sepIdx := strings.Index(e.RelPath, string(filepath.Separator))
if sepIdx >= 0 {
treeEntries[i] = walker.Entry{RelPath: e.RelPath[sepIdx+1:], Size: e.Size}
} else {
treeEntries[i] = e
}
}
if err := writeDirSlice(slicePath, absDir, absRoot, dirName, treeEntries, dirEntries, indexBasename, result.OmitTest, result.Commit, result.Instructions); err != nil {
return nil, fmt.Errorf("failed to write slice %s: %w", sliceName, err)
}
}
return generated, nil
}
func writeRootSlice(path, absRoot, rootName, repoName string, rootFiles []walker.Entry, topLevelDirs map[string][]walker.Entry, omitTest bool, commit *git.CommitInfo, instContent string) error {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("cannot create root file: %w", err)
}
defer f.Close()
var sb strings.Builder
fmt.Fprintf(&sb, "# %s [ROOT]\n", absRoot)
fmt.Fprintf(&sb, "> Generated: %s\n", time.Now().Format(time.RFC3339))
sb.WriteString(git.FormatCommitLine(commit))
if instContent != "" {
sb.WriteString(instructions.Format(instContent))
} else {
sb.WriteString("\n")
}
treeLines := buildTreeWithRefs(rootName, repoName, topLevelDirs, rootFiles)
sb.WriteString("## Directory Tree\n\n")
sb.WriteString(fenceChars + "\n")
sb.WriteString(treeLines)
sb.WriteString(fenceChars + "\n\n")
sb.WriteString("## Vertical Slices Index\n\n")
sb.WriteString("These files contain the full contents of each top-level directory.\n")
sb.WriteString("Read the relevant slice file for detailed source code.\n\n")
sb.WriteString("| Slice File | Directory | Files | Total Bytes |\n")
sb.WriteString("|------------|-----------|-------|-------------|\n")
var dirNames []string
for k := range topLevelDirs {
dirNames = append(dirNames, k)
}
sort.Strings(dirNames)
for _, dirName := range dirNames {
entries := topLevelDirs[dirName]
fileCount := len(entries)
totalBytes := int64(0)
for _, e := range entries {
totalBytes += e.Size
}
sliceFile := filepath.Join(dirName, strings.TrimSuffix(dirName, "/")+"_"+repoName+".md")
fmt.Fprintf(&sb, "| %s | %s/ | %d | %d |\n", sliceFile, dirName, fileCount, totalBytes)
}
sb.WriteString("\n")
if len(rootFiles) > 0 {
sb.WriteString("## Root Contents\n\n")
sort.Slice(rootFiles, func(i, j int) bool { return rootFiles[i].RelPath < rootFiles[j].RelPath })
for _, entry := range rootFiles {
fullPath := filepath.Join(absRoot, entry.RelPath)
content, err := readFileForOutput(fullPath, omitTest)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err)
continue
}
language := extToLang(filepath.Ext(entry.RelPath))
lines := countLines(content)
fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, entry.Size, lines)
fmt.Fprintf(&sb, "%s", fenceChars)
if language != "" {
fmt.Fprintf(&sb, "%s", language)
}
fmt.Fprintf(&sb, "\n%s", content)
if !strings.HasSuffix(content, "\n") {
fmt.Fprintf(&sb, "\n")
}
fmt.Fprintf(&sb, "%s\n--- END FILE ---\n\n", fenceChars)
}
}
_, err = f.WriteString(sb.String())
return err
}
func writeDirSlice(path, absDir, absRoot, dirName string, treeEntries, fileEntries []walker.Entry, rootFileName string, omitTest bool, commit *git.CommitInfo, instContent string) error {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("cannot create slice file: %w", err)
}
defer f.Close()
var sb strings.Builder
fmt.Fprintf(&sb, "# %s [SLICE]\n", absDir)
fmt.Fprintf(&sb, "> Generated: %s\n", time.Now().Format(time.RFC3339))
sb.WriteString(git.FormatCommitLine(commit))
if instContent != "" {
sb.WriteString(instructions.Format(instContent))
} else {
sb.WriteString("\n")
}
fmt.Fprintf(&sb, "Root document: %s\n\n", rootFileName)
sb.WriteString("## Directory Tree\n\n")
sb.WriteString(fenceChars + "\n")
sliceTree := buildTreeFromEntries(dirName, treeEntries)
sb.WriteString(sliceTree)
sb.WriteString(fenceChars + "\n\n")
if len(fileEntries) > 0 {
sb.WriteString("## Contents\n\n")
sort.Slice(fileEntries, func(i, j int) bool { return fileEntries[i].RelPath < fileEntries[j].RelPath })
for _, entry := range fileEntries {
fullPath := filepath.Join(absRoot, entry.RelPath)
content, err := readFileForOutput(fullPath, omitTest)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err)
continue
}
language := extToLang(filepath.Ext(entry.RelPath))
lines := countLines(content)
fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, entry.Size, lines)
fmt.Fprintf(&sb, "%s", fenceChars)
if language != "" {
fmt.Fprintf(&sb, "%s", language)
}
fmt.Fprintf(&sb, "\n%s", content)
if !strings.HasSuffix(content, "\n") {
fmt.Fprintf(&sb, "\n")
}
fmt.Fprintf(&sb, "%s\n--- END FILE ---\n\n", fenceChars)
}
}
_, err = f.WriteString(sb.String())
return err
}
func buildTreeWithRefs(rootName, repoName string, topLevelDirs map[string][]walker.Entry, rootFiles []walker.Entry) string {
var sb strings.Builder
sb.WriteString(rootName)
sb.WriteString("/\n")
var dirNames []string
for k := range topLevelDirs {
dirNames = append(dirNames, k)
}
sort.Strings(dirNames)
sort.Slice(rootFiles, func(i, j int) bool {
return rootFiles[i].RelPath < rootFiles[j].RelPath
})
type treeItem struct {
name string
isDir bool
sliceRef string
}
items := make([]treeItem, 0, len(dirNames)+len(rootFiles))
for _, d := range dirNames {
sliceRef := filepath.Join(d, strings.TrimSuffix(d, "/")+"_"+repoName+".md")
items = append(items, treeItem{
name: d,
isDir: true,
sliceRef: sliceRef,
})
}
for _, rf := range rootFiles {
items = append(items, treeItem{
name: rf.RelPath,
isDir: false,
})
}
sort.Slice(items, func(i, j int) bool {
return items[i].name < items[j].name
})
for _, item := range items {
sb.WriteString("+- ")
sb.WriteString(item.name)
if item.isDir {
sb.WriteString("/")
sb.WriteString(" -> see ")
sb.WriteString(item.sliceRef)
}
sb.WriteString("\n")
}
return sb.String()
}
func buildTreeFromEntries(rootName string, entries []walker.Entry) string {
root := &tree.Node{IsDir: true, Children: make(map[string]*tree.Node)}
for _, e := range entries {
parts := strings.Split(e.RelPath, string(filepath.Separator))
cur := root
for i, part := range parts {
isLeaf := i == len(parts)-1
if _, ok := cur.Children[part]; !ok {
node := &tree.Node{Name: part, IsDir: !isLeaf}
if !isLeaf {
node.Children = make(map[string]*tree.Node)
}
cur.Children[part] = node
}
cur = cur.Children[part]
}
}
var sb strings.Builder
sb.WriteString(rootName)
sb.WriteString("/\n")
renderNodeLLM(&sb, root, "")
return sb.String()
}
func renderTreeForLLM(root *tree.Node, rootName string) string {
var sb strings.Builder
sb.WriteString(rootName)
sb.WriteString("/\n")
renderNodeLLM(&sb, root, "")
return sb.String()
}
func renderNodeLLM(sb *strings.Builder, node *tree.Node, prefix string) {
keys := make([]string, 0, len(node.Children))
for k := range node.Children {
keys = append(keys, k)
}
sort.Strings(keys)
for i, key := range keys {
child := node.Children[key]
isLast := i == len(keys)-1
sb.WriteString(prefix)
sb.WriteString("+- ")
sb.WriteString(child.Name)
if child.IsDir {
sb.WriteString("/")
}
sb.WriteString("\n")
if child.IsDir && child.Children != nil {
newPrefix := prefix
if isLast {
newPrefix += " "
} else {
newPrefix += "| "
}
renderNodeLLM(sb, child, newPrefix)
}
}
}
func readFile(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
@@ -103,6 +436,68 @@ func readFile(path string) (string, error) {
return content, nil
}
func readFileForOutput(fullPath string, omitTest bool) (string, error) {
content, err := readFile(fullPath)
if err != nil {
return "", err
}
if omitTest && strings.EqualFold(filepath.Ext(fullPath), ".rs") {
content = stripTestBlocks(content)
}
return content, nil
}
func stripTestBlocks(content string) string {
lines := strings.Split(content, "\n")
var result []string
i := 0
for i < len(lines) {
line := lines[i]
trimmed := strings.TrimSpace(line)
if isCfgTestAttr(trimmed) {
braceDepth := 0
foundBrace := false
for i < len(lines) {
line := lines[i]
if !foundBrace {
if strings.Contains(line, "{") {
braceDepth += strings.Count(line, "{") - strings.Count(line, "}")
foundBrace = true
}
i++
continue
}
braceDepth += strings.Count(line, "{") - strings.Count(line, "}")
i++
if braceDepth <= 0 {
break
}
}
continue
}
result = append(result, line)
i++
}
if len(result) == 0 {
return ""
}
return strings.Join(result, "\n")
}
func isCfgTestAttr(line string) bool {
return strings.Contains(line, "#[cfg(test)]") ||
strings.Contains(line, "#[cfg(all(test") ||
strings.Contains(line, "#[cfg(any(test")
}
func extToLang(ext string) string {
mapping := map[string]string{
".go": "go",
@@ -131,3 +526,14 @@ func extToLang(ext string) string {
}
return ""
}
func countLines(content string) int {
if content == "" {
return 0
}
count := strings.Count(content, "\n")
if len(content) > 0 && content[len(content)-1] != '\n' {
count++
}
return count
}
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"sort"
"strings"
"dirmd/internal/walker"
"git.flo.fo/FLO/dirmd/internal/walker"
)
type Node struct {
+100
View File
@@ -0,0 +1,100 @@
package tui
import (
"fmt"
"os"
"path/filepath"
"git.flo.fo/FLO/dirmd/internal/config"
"git.flo.fo/FLO/dirmd/internal/drive"
"git.flo.fo/FLO/dirmd/internal/renderer"
"git.flo.fo/FLO/dirmd/internal/walker"
)
type GenerateResult struct {
Files []string
Uploaded bool
Error error
}
func Generate(cfg *config.Config) GenerateResult {
result := GenerateResult{}
abs, err := filepath.Abs(cfg.InputPath)
if err != nil {
result.Error = fmt.Errorf("resolve input path: %w", err)
return result
}
cfg.AbsRoot = abs
absOutput, err := filepath.Abs(cfg.OutputPath)
if err != nil {
result.Error = fmt.Errorf("resolve output path: %w", err)
return result
}
if _, err := os.Stat(absOutput); err == nil && !cfg.Force {
if info, err2 := os.Stat(absOutput); err2 == nil && !info.IsDir() {
result.Error = fmt.Errorf("output %s exists and is not a directory (enable force)", absOutput)
return result
}
result.Error = fmt.Errorf("output %s exists (enable force)", absOutput)
return result
}
if err := os.MkdirAll(absOutput, 0755); err != nil {
result.Error = fmt.Errorf("create output dir: %w", err)
return result
}
cfg.OutputPath = absOutput
walkResult, err := walker.Run(cfg)
if err != nil {
result.Error = err
return result
}
if len(walkResult.Entries) == 0 {
result.Error = fmt.Errorf("no readable files in %s", cfg.AbsRoot)
return result
}
repoName := filepath.Base(cfg.AbsRoot)
if cfg.VerticalSlices {
generated, err := renderer.WriteSlices(walkResult, cfg.OutputPath, repoName)
if err != nil {
result.Error = err
return result
}
result.Files = generated
} else {
indexPath := filepath.Join(cfg.OutputPath, "index_"+repoName+".md")
if err := renderer.Write(walkResult, indexPath, false, false); err != nil {
result.Error = err
return result
}
result.Files = []string{indexPath}
}
if cfg.ProtonDrive {
remoteDir := cfg.DrivePath
if remoteDir == "" {
remoteDir = "/my-files/md/" + filepath.Base(cfg.OutputPath)
}
if err := drive.CheckAuth(); err != nil {
result.Error = fmt.Errorf("drive auth: %w", err)
return result
}
if err := drive.UploadDir(cfg.OutputPath, remoteDir); err != nil {
result.Error = fmt.Errorf("drive upload: %w", err)
return result
}
result.Uploaded = true
}
return result
}
+281
View File
@@ -0,0 +1,281 @@
package tui
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"charm.land/bubbles/v2/key"
"charm.land/huh/v2"
"charm.land/huh/v2/spinner"
"git.flo.fo/FLO/dirmd/internal/config"
)
func Run() error {
clearScreen()
var (
inputDir string
outputDir string
options []string
drivePath string
maxDepthStr string
maxSizeStr string
ignorePatterns string
finalConfirm bool
)
inputDir = "."
maxDepthStr = "20"
maxSizeStr = "512"
defOut := defaultOutputDir()
outputDir = defOut
drivePath = "/my-files/md/"
km := huh.NewDefaultKeyMap()
km.Input.Next = key.NewBinding(key.WithKeys("tab", "down"), key.WithHelp("tab/↓", "next"))
km.Input.Prev = key.NewBinding(key.WithKeys("shift+tab", "up"), key.WithHelp("shift+tab/↑", "prev"))
km.Input.Submit = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit"))
km.Text.Next = key.NewBinding(key.WithKeys("tab", "down"), key.WithHelp("tab/↓", "next"))
km.Text.Prev = key.NewBinding(key.WithKeys("shift+tab", "up"), key.WithHelp("shift+tab/↑", "prev"))
km.Text.NewLine = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "new line"))
km.Text.Submit = key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "submit"))
km.Confirm.Next = key.NewBinding(key.WithKeys("tab", "down"), key.WithHelp("tab/↓", "next"))
km.Confirm.Prev = key.NewBinding(key.WithKeys("shift+tab", "up"), key.WithHelp("shift+tab/↑", "prev"))
km.Confirm.Toggle = key.NewBinding(key.WithKeys("left", "right", "h", "l"), key.WithHelp("←/→", "toggle"))
km.Confirm.Submit = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm"))
km.Confirm.Accept = key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "yes"))
km.Confirm.Reject = key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "no"))
km.MultiSelect.Next = key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next field"))
km.MultiSelect.Prev = key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev field"))
km.MultiSelect.Up = key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up"))
km.MultiSelect.Down = key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down"))
km.MultiSelect.Toggle = key.NewBinding(key.WithKeys("enter", " "), key.WithHelp("enter/space", "toggle"))
km.MultiSelect.Submit = key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next"))
km.MultiSelect.Filter = key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter"))
km.MultiSelect.SelectAll = key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "select all"))
km.MultiSelect.SelectNone = key.NewBinding(key.WithKeys("A"), key.WithHelp("A", "select none"))
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().Title("Input directory").Value(&inputDir).Placeholder("."),
huh.NewInput().Title("Output directory").Value(&outputDir).Placeholder(defOut),
huh.NewMultiSelect[string]().
Title("Options").
Description("↑↓ navigate • enter/space toggle • tab next field").
Options(
huh.NewOption("Force overwrite", "force"),
huh.NewOption("Vertical slices", "vertical-slices"),
huh.NewOption("Skip frontend files", "skip-frontend"),
huh.NewOption("Frontend only", "frontend-only"),
huh.NewOption("Omit test files", "omit-test"),
huh.NewOption("Omit non-README markdown", "omit-md"),
huh.NewOption("Upload to Proton Drive", "proton-drive"),
huh.NewOption("Include .dirmd instructions", "instructions"),
).
Value(&options).
Height(10),
huh.NewInput().
Title("Proton Drive path").
Description("Used if 'Upload to Proton Drive' is toggled").
Value(&drivePath).
Placeholder("/my-files/md/"),
huh.NewInput().Title("Max depth").Value(&maxDepthStr).Placeholder("20"),
huh.NewInput().Title("Max size (KB)").Value(&maxSizeStr).Placeholder("512"),
huh.NewText().
Title("Ignore patterns (one per line)").
Description("enter = new line • tab = next field").
Value(&ignorePatterns).
Placeholder("One pattern per line...").
Lines(4),
huh.NewConfirm().
Title("Confirm and proceed?").
Description("←/→ or y/n to choose • enter to confirm").
Value(&finalConfirm).
Affirmative("Yes").
Negative("No"),
),
).WithWidth(80).WithKeyMap(km)
if err := form.Run(); err != nil {
return fmt.Errorf("form: %w", err)
}
force := containsOption(options, "force")
verticalSlices := containsOption(options, "vertical-slices")
skipFrontend := containsOption(options, "skip-frontend")
frontendOnly := containsOption(options, "frontend-only")
omitTest := containsOption(options, "omit-test")
omitMd := containsOption(options, "omit-md")
protonDrive := containsOption(options, "proton-drive")
includeInstructions := containsOption(options, "instructions")
if skipFrontend && frontendOnly {
return fmt.Errorf("--skip-frontend and --frontend-only are mutually exclusive")
}
if !finalConfirm {
fmt.Fprintln(os.Stderr, "Cancelled.")
os.Exit(0)
}
maxDepth, err := strconv.Atoi(strings.TrimSpace(maxDepthStr))
if err != nil || maxDepth <= 0 {
maxDepth = 20
}
maxSizeKB, err := strconv.Atoi(strings.TrimSpace(maxSizeStr))
if err != nil || maxSizeKB <= 0 {
maxSizeKB = 512
}
var ignores []string
raw := strings.TrimSpace(ignorePatterns)
if raw != "" {
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if line != "" {
ignores = append(ignores, line)
}
}
}
excludedExts := append([]string{}, config.DefaultExts...)
if skipFrontend {
excludedExts = append(excludedExts, config.FrontendExts...)
}
allIgnores := append([]string{}, config.DefaultIgnores...)
allIgnores = append(allIgnores, ignores...)
cfg := &config.Config{
InputPath: inputDir,
OutputPath: outputDir,
Force: force,
MaxSize: int64(maxSizeKB) * 1024,
MaxDepth: maxDepth,
Ignores: allIgnores,
Extensions: excludedExts,
ProtonDrive: protonDrive,
DrivePath: drivePath,
VerticalSlices: verticalSlices,
SkipFrontend: skipFrontend,
FrontendOnly: frontendOnly,
OmitTest: omitTest,
OmitMd: omitMd,
IncludeInstructions: includeInstructions,
}
cmd := buildCommand(cfg)
fmt.Fprintf(os.Stderr, "%s\n", cmd)
var result GenerateResult
if err := spinner.New().
Title("Generating...").
Action(func() {
result = Generate(cfg)
}).
Run(); err != nil {
return fmt.Errorf("interrupted: %w", err)
}
if result.Error != nil {
return result.Error
}
for _, f := range result.Files {
fmt.Fprintf(os.Stdout, " %s\n", f)
}
if result.Uploaded {
fmt.Fprintln(os.Stdout, "Uploaded to Proton Drive")
}
return nil
}
func clearScreen() {
cmd := exec.Command("clear")
cmd.Stdout = os.Stdout
cmd.Run()
}
func containsOption(opts []string, target string) bool {
for _, o := range opts {
if o == target {
return true
}
}
return false
}
func defaultOutputDir() string {
cwd, err := os.Getwd()
if err != nil {
return "/tmp/output_dirmd"
}
base := filepath.Base(cwd)
return filepath.Join("/tmp", base+"_dirmd")
}
func buildCommand(cfg *config.Config) string {
var parts []string
parts = append(parts, "dirmd")
parts = append(parts, "-o", cfg.OutputPath)
if cfg.Force {
parts = append(parts, "-f")
}
if cfg.VerticalSlices {
parts = append(parts, "--vertical-slices")
}
if cfg.SkipFrontend {
parts = append(parts, "--skip-frontend")
}
if cfg.FrontendOnly {
parts = append(parts, "--frontend-only")
}
if cfg.OmitTest {
parts = append(parts, "--omit-test")
}
if cfg.OmitMd {
parts = append(parts, "--omit-md")
}
if cfg.ProtonDrive {
parts = append(parts, "--proton-drive")
if cfg.DrivePath != "" {
parts = append(parts, "--drive-path", cfg.DrivePath)
}
}
if cfg.MaxDepth != 20 {
parts = append(parts, "--max-depth", strconv.Itoa(cfg.MaxDepth))
}
if cfg.MaxSize != 512*1024 {
parts = append(parts, "--max-size", strconv.FormatInt(cfg.MaxSize, 10))
}
for _, ig := range extraIgnores(cfg) {
parts = append(parts, "--ignore", ig)
}
parts = append(parts, cfg.InputPath)
return strings.Join(parts, " ")
}
func extraIgnores(cfg *config.Config) []string {
defaultSet := make(map[string]bool)
for _, d := range config.DefaultIgnores {
defaultSet[d] = true
}
var extra []string
for _, ig := range cfg.Ignores {
if !defaultSet[ig] {
extra = append(extra, ig)
}
}
return extra
}
+47 -17
View File
@@ -7,8 +7,10 @@ import (
"path/filepath"
"strings"
"dirmd/internal/config"
"dirmd/internal/filter"
"git.flo.fo/FLO/dirmd/internal/config"
"git.flo.fo/FLO/dirmd/internal/filter"
"git.flo.fo/FLO/dirmd/internal/git"
"git.flo.fo/FLO/dirmd/internal/instructions"
)
type Entry struct {
@@ -17,9 +19,12 @@ type Entry struct {
}
type Result struct {
Entries []Entry
AbsRoot string
SingleFile bool
Entries []Entry
AbsRoot string
SingleFile bool
OmitTest bool
Commit *git.CommitInfo
Instructions string
}
func Run(cfg *config.Config) (*Result, error) {
@@ -40,31 +45,56 @@ func processSingleFile(cfg *config.Config) (*Result, error) {
}
flt := &filter.Filter{
MaxSize: cfg.MaxSize,
Extensions: cfg.Extensions,
Ignores: cfg.Ignores,
MaxSize: cfg.MaxSize,
Extensions: cfg.Extensions,
Ignores: cfg.Ignores,
OmitTest: cfg.OmitTest,
FrontendOnly: cfg.FrontendOnly,
OmitMd: cfg.OmitMd,
}
if flt.ShouldSkipFile(cfg.AbsRoot, filepath.Base(cfg.AbsRoot), info) {
return nil, fmt.Errorf("file %s filtered out", cfg.AbsRoot)
}
return &Result{
Entries: []Entry{{RelPath: filepath.Base(cfg.AbsRoot), Size: info.Size()}},
AbsRoot: filepath.Dir(cfg.AbsRoot),
SingleFile: true,
}, nil
commitInfo := git.GetCommitInfo(filepath.Dir(cfg.AbsRoot))
var instContent string
if cfg.IncludeInstructions {
instContent, _ = instructions.Read(filepath.Dir(cfg.AbsRoot))
}
return &Result{
Entries: []Entry{{RelPath: filepath.Base(cfg.AbsRoot), Size: info.Size()}},
AbsRoot: filepath.Dir(cfg.AbsRoot),
SingleFile: true,
OmitTest: cfg.OmitTest,
Commit: commitInfo,
Instructions: instContent,
}, nil
}
func walkDirectory(cfg *config.Config) (*Result, error) {
flt := &filter.Filter{
MaxSize: cfg.MaxSize,
Extensions: cfg.Extensions,
Ignores: cfg.Ignores,
MaxSize: cfg.MaxSize,
Extensions: cfg.Extensions,
Ignores: cfg.Ignores,
OmitTest: cfg.OmitTest,
FrontendOnly: cfg.FrontendOnly,
OmitMd: cfg.OmitMd,
}
result := &Result{AbsRoot: cfg.AbsRoot}
result := &Result{AbsRoot: cfg.AbsRoot, OmitTest: cfg.OmitTest}
result.Commit = git.GetCommitInfo(cfg.AbsRoot)
if cfg.IncludeInstructions {
instContent, err := instructions.Read(cfg.AbsRoot)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: could not read .dirmd: %v\n", err)
} else {
result.Instructions = instContent
}
}
err := filepath.WalkDir(cfg.AbsRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil {
+1 -2
View File
@@ -1,8 +1,7 @@
package main
import "dirmd/cmd"
import "git.flo.fo/FLO/dirmd/cmd"
func main() {
cmd.Execute()
}