diff --git a/.fln.json b/.fln.json
new file mode 100644
index 0000000..76c672f
--- /dev/null
+++ b/.fln.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "https://fln.nesvet.dev/schema",
+ "output": "output.md",
+ "excludePatterns": [],
+ "includePatterns": [],
+ "gitignore": true,
+ "includeHidden": false,
+ "maxFileSize": "10mb",
+ "maxTotalSize": "0",
+ "includeTree": true,
+ "includeContents": true,
+ "format": "md",
+ "followSymlinks": false,
+ "overwrite": false
+}
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index f47e16a..d3c1dc8 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -21,6 +21,8 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: "1.3.10"
- name: Cache dependencies
uses: actions/cache@v5
diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml
index 6e40201..95ed812 100644
--- a/.github/workflows/publish.yaml
+++ b/.github/workflows/publish.yaml
@@ -22,6 +22,8 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: "1.3.10"
- name: Setup Node.js
uses: actions/setup-node@v6
diff --git a/.github/workflows/release-binary.yaml b/.github/workflows/release-binary.yaml
index f0ec88e..ac17dc7 100644
--- a/.github/workflows/release-binary.yaml
+++ b/.github/workflows/release-binary.yaml
@@ -60,7 +60,7 @@ jobs:
architecture: arm64
archiveName: fln-windows-arm64.zip
packageType: zip
- targetName: bun-windows-x64
+ targetName: bun-windows-arm64
outputName: fln.exe
steps:
- name: Checkout
@@ -68,6 +68,8 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: "1.3.10"
- name: Install dependencies
run: bun install --frozen-lockfile
diff --git a/.gitignore b/.gitignore
index 78afc6a..b85c2e3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,7 @@ src/version.ts
node_modules/
dist/
fln
+fln-*.md
+output.md
+output-*.md
+docs/
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f0d7da8..197a01e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [1.2.0] - 2026-02-26
+
+### Removed (Breaking)
+
+- Public API reduced to `fln`, `FlnOptions`, `FlnResult`, `LogLevel`, `ProgressCallback`. Removed exports: `scanTree`, `writeOutput`, `renderTree`, `IgnoreMatcher`, `parseByteSize`, `formatByteSize`, `formatTokenCount`, `collectExtensionStats`, `collectProcessedFiles`, core types (`FileNode`, `ScanResult`, etc.), `VERSION`.
+
+### Added
+
+- New option names: `input` (replaces `rootDirectory`), `output` (replaces `outputFile`), `maxFileSize`, `maxTotalSize`, `date`, `gitignore`, `ansi` (API, config, CLI)
+- JSON output now includes `input` field (in addition to `rootDirectory` for backward compatibility)
+- `bannerFile` — path to file whose contents are prepended to output (file excluded from tree)
+- `footerFile` — path to file whose contents are appended to output (file excluded from tree)
+- New CLI features: `fln init`, `--stdout`, `--ext`, `--since`
+- New CLI flags: `--date`, `--banner-file`, `--footer-file`
+- Config schema and `fln init` template (`$schema`) for `.fln.json`
+
+### Changed
+
+- JSON output `options` object now uses `maxFileSize`, `maxTotalSize`, `gitignore` (old names deprecated)
+- Output now supports stdout target (`-`) and auto-adds extension (`.md`/`.json`) when missing
+- Banner/footer content now combines inline text with file-based content
+- Project metadata detection now also supports `pom.xml`
+
+### Fixed
+
+- `excludePatterns` and `includePatterns` now normalize leading `./` and safely ignore paths resolving outside input (for example, `../...`)
+- Windows ARM64 binary release target corrected in release workflow
+
+### Deprecated
+
+- `rootDirectory` — use `input` instead. Will be removed in 2.0.
+- `outputFile` — use `output` instead (in API options and `.fln.json`). Will be removed in 2.0.
+- `maximumFileSizeBytes` — use `maxFileSize` instead. Will be removed in 2.0.
+- `maximumTotalSizeBytes` — use `maxTotalSize` instead. Will be removed in 2.0.
+- `generatedDate` — use `date` instead. Will be removed in 2.0.
+- `useGitignore` — use `gitignore` instead. Will be removed in 2.0.
+- `useAnsi` — use `ansi` instead. Will be removed in 2.0.
+- CLI `--generated-date` — use `--date` instead. Will be removed in 2.0.
+- JSON output field `rootDirectory` — use `input` instead. Will be removed in 2.0.
+
## [1.1.3] - 2026-02-12
### Fixed
@@ -75,7 +115,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Cross-platform shell installers with SHA256 verification (macOS, Linux, Windows)
- Comprehensive test suite
-[Unreleased]: https://github.com/nesvet/fln/compare/1.1.3...HEAD
+[Unreleased]: https://github.com/nesvet/fln/compare/1.2.0...HEAD
+[1.2.0]: https://github.com/nesvet/fln/compare/1.1.3...1.2.0
[1.1.3]: https://github.com/nesvet/fln/compare/1.1.2...1.1.3
[1.1.2]: https://github.com/nesvet/fln/compare/1.1.1...1.1.2
[1.1.1]: https://github.com/nesvet/fln/compare/1.0.0...1.1.1
diff --git a/README.md b/README.md
index 8717cd6..1e936a4 100644
--- a/README.md
+++ b/README.md
@@ -2,124 +2,190 @@
[](https://github.com/nesvet/fln/actions/workflows/ci.yaml)
[](https://www.npmjs.com/package/fln)
+[](https://www.npmjs.com/package/fln)
[](LICENSE)
-**Your entire codebase → One AI-ready file.**
+**Your entire codebase → one AI-ready file.**
Stop wrestling with file pickers and attachment limits — feed your whole project to any LLM in one shot.
```bash
-fln .
-```
-
-Or run instantly:
-
-```bash
-npx fln . -o codebase.md
+npx fln
```
Works with **Claude**, **ChatGPT**, **Gemini**, **Grok**, **Cursor**, **Copilot**, and *any* AI tool.
-**`fln`** (short for *flatten*) is **language-agnostic** by design: TypeScript, Python, Java, Go, Rust, Bash, SQL, mixed monorepos — it treats everything as plain text, detects project metadata from common manifests (`package.json`, `pyproject.toml`, `pom.xml`, `go.mod`, `Cargo.toml`, `CMakeLists.txt`, `vcpkg.json`), respects `.gitignore`, and skips binaries by default.
+**`fln`** (short for *flatten*) is language-agnostic by design: TypeScript, Python, Rust, Go, Java, mixed monorepos — it treats everything as plain text, auto-detects project metadata from ecosystem manifests, respects `.gitignore`, and skips binaries automatically.
+
+---
## Why fln exists
-If you use LLMs for real projects, you’ve hit these limits:
+If you use LLMs for real projects, you've hit these limits:
-- **Context windows** — large projects don’t fit.
-- **Upload friction** — selecting dozens of files for every session.
-- **Partial understanding** — AI sees fragments, not the architecture.
-- **Manual prep** — repeating the same setup context again and again.
+- **Context windows** — large projects don't fit
+- **Upload friction** — selecting dozens of files for every session
+- **Partial understanding** — AI sees fragments, not the architecture
+- **Manual prep** — repeating the same setup context again and again
**`fln` removes that overhead.**
It turns your project into a single, structured snapshot that LLMs can actually reason about.
+---
+
## What fln enables
**→ Full-context refactoring**
-Ask architectural questions that are impossible file-by-file:
-> “Where is the real coupling here?”
-> “What should be split into modules?”
+Ask architectural questions impossible file-by-file:
+> "Where is the real coupling here?"
+> "What should be split into modules?"
**→ Instant onboarding**
-One markdown file instead of “start by opening these 12 folders”. Perfect for reading code on a tablet or onboarding new developers without an IDE.
+One Markdown file instead of "start by opening these 12 folders." Perfect for reading code on a tablet or onboarding new developers without an IDE.
**→ Project-level code reviews**
-Let AI detect patterns, inconsistencies, and risks across the entire codebase.
+Detect patterns, inconsistencies, and risks across the entire codebase at once.
-**→ Auditable Snapshots**
-Create a single, clean artifact of your codebase state for security reviews, compliance audits, or legal records without granting full repo access.
+**→ Auditable snapshots**
+A single, clean artifact of your codebase state for security reviews, compliance audits, or legal records — without granting full repo access.
-**→ Dataset Preparation**
+**→ Dataset preparation**
Generate clean, formatted data for RAG pipelines and fine-tuning custom models.
**→ LLM-friendly diffs**
-Flatten → commit → flatten again. See how the *whole project* changed structurally.
+Flatten → commit → flatten again. See how the whole project changed structurally.
+
+---
+
+## Instant clipboard
+
+```bash
+fln --stdout | pbcopy # macOS
+fln --stdout | wl-copy # Linux (Wayland)
+fln --stdout | xclip -selection clipboard # Linux (X11)
+```
+
+One command — your entire codebase is in the clipboard. Open any AI chat, paste, ask.
+
+---
+
+## Pipe directly to a CLI LLM tool
-## Compatible with your AI workflow
+```bash
+fln --stdout | llm "What are the biggest architecture issues here?"
+fln --stdout | aichat "Write a CHANGELOG entry for the latest changes"
+fln --stdout | sgpt "Suggest a refactoring plan for this codebase"
+```
+
+---
+
+## Only changed files — perfect for code review
+
+```bash
+# Everything changed since the last commit
+fln --since HEAD~1
+
+# Everything that differs from main
+fln --since main
-- **Claude** — ideal for large architectural prompts (200K+ tokens).
-- **Gemini** — push massive codebases into 1M token windows.
-- **ChatGPT** — single-shot analysis without attachments.
-- **Cursor / Windsurf** — reference the full project in prompts.
-- **GitHub Copilot** — better context → better suggestions.
-- **Local LLMs** — datasets for RAG and fine-tuning.
+# Only changed TypeScript files (intersection, not union)
+fln --since main --ext ts,tsx
+```
+
+`--since` calls `git diff --name-only` under the hood. Zero config.
+
+---
+
+## Only the file types you care about
+
+```bash
+fln --ext ts,tsx # TypeScript only — dramatically reduces token count
+fln --ext py # Python only
+fln --ext rs # Rust only
+fln --ext go # Go only
+```
+
+---
+
+## Embed a custom prompt right into the snapshot
+
+Put your system prompt in a file — it will be prepended to the output and excluded from the directory tree:
+
+```bash
+fln --banner-file .prompt.md
+```
+
+```markdown
+
+You are a senior engineer reviewing a production codebase.
+Identify architecture issues, suggest improvements, point out any bugs.
+```
+
+Or inline:
+
+```bash
+fln --banner "Review this codebase for security vulnerabilities."
+```
+
+Same for footers: `--footer-file` / `--footer`.
+
+---
## Built for real projects
-- ⚡ **Fast parallel scanning** — thousands of files in seconds.
-- 🎯 **Smart filtering** — respects `.gitignore`, excludes binaries, configurable size limits.
-- 📁 **Intentional file order** — entry points and configs first, not alphabetical noise.
-- 🔄 **Auto-detection** — skips files previously generated by `fln`.
-- 📐 **Deterministic output** — same input → same snapshot.
-- 🧠 **Project metadata detection** — name & version from ecosystem-native manifests.
-- 🛠️ **Developer-friendly** — `Markdown` for humans, `JSON` for tooling, `--dry-run` mode for safety.
-- 🔒 **No surprises** — runs locally, no data leaves your machine.
+- ⚡ **Fast parallel scanning** — thousands of files in seconds
+- 🎯 **Smart filtering** — respects `.gitignore`, skips binaries and lock files, configurable size limits
+- 📁 **Intentional file order** — `README`, entry points, and configs first; `LICENSE`, `CHANGELOG` last. LLMs see the most important context first
+- 📊 **Token count upfront** — every run reports estimated tokens so you know what you're sending before you hit send
+- 🔍 **Extension breakdown** — `--verbose` shows token distribution by file type, so you know exactly what's eating your context window
+- 🔄 **Self-aware** — skips files previously generated by `fln`, never recurses into its own output
+- 🛡️ **Backtick-safe** — if a file contains ` ``` `, fln automatically uses longer fences so the Markdown never breaks
+- 📐 **Deterministic output** — same input → same snapshot
+- 🧠 **Project metadata detection** — output named `my-app-1.2.0.md` automatically from `package.json`, `Cargo.toml`, `pyproject.toml`, `pom.xml`, `go.mod`, `vcpkg.json`, `CMakeLists.txt`
+- 🛠️ **Two output formats** — `md` for humans, `json` for tooling
+- 🔒 **Fully local** — zero telemetry, zero network calls, no data leaves your machine
-Zero dependencies on external services. Zero tracking. Just a tool that does its job.
+---
## Install
-##### npm
+##### npm / Bun
```bash
+npx fln # run once without installing
+bunx fln
+
npm install -g fln
+bun add -g fln
```
-##### Linux & macOS ([view install script](./install.sh))
+##### macOS / Linux — native binary, no Node.js required
```bash
curl -fsSL https://fln.nesvet.dev/install | sh
```
-##### Windows ([view install script](./install.ps1))
-```bash
+##### Windows — native binary, no Node.js required
+```powershell
powershell -c "irm fln.nesvet.dev/install.ps1 | iex"
```
-##### Or just run without installing
-```bash
-npx fln . -o codebase.md
-```
-
More installation options
-### One-line installer options (macOS/Linux)
-
-Pin a version or custom install directory:
+**Pin a specific version or install to a custom directory (macOS/Linux):**
```bash
-curl -fsSL "https://fln.nesvet.dev/install" | FLN_VERSION="" INSTALL_DIR="$HOME/.local/bin" sh
+curl -fsSL https://fln.nesvet.dev/install | FLN_VERSION="" INSTALL_DIR="$HOME/.local/bin" sh
```
-### One-line installer options (Windows PowerShell)
+**Windows PowerShell:**
```powershell
$env:FLN_VERSION = ""
-$env:INSTALL_DIR = "$env:LOCALAPPDATA\\fln\\bin"
+$env:INSTALL_DIR = "$env:LOCALAPPDATA\fln\bin"
powershell -c "irm fln.nesvet.dev/install.ps1 | iex"
```
-### Manual download (GitHub Releases)
+**Manual download from GitHub Releases:**
```bash
curl -L "https://github.com/nesvet/fln/releases/latest/download/fln-macos-x64.tar.gz" | tar -xz -C /usr/local/bin
@@ -128,74 +194,160 @@ chmod +x /usr/local/bin/fln
+---
+
## Usage
```bash
-fln [directory] [options]
+fln [directory] [...flags]
+fln init [--overwrite]
```
-Examples:
-
```bash
-# Flatten entire project
-fln .
+# Flatten the current directory → my-app-1.2.0.md
+fln
+
+# Specify input and output
+fln . -o context.md
+
+# Scan src/, save output to the project root
+fln src -o .
+
+# Source files only — no tests, no fixtures
+fln -e "*.test.ts" -e "*.spec.ts" -e "fixtures/"
+
+# Include all source files but exclude markdown — except README
+fln -e "*.md" -e '!README.md'
-# Exclude tests and fixtures
-fln src -e "**/*.test.ts" -e "fixtures/"
+# TypeScript source only
+fln --ext ts,tsx
-# Force include a file (even if ignored)
-fln . -i "dist/output.md"
+# Changed files since last commit
+fln --since HEAD~1
-# Generate JSON for tooling
-fln . --no-contents --format json
+# Tree only — no file contents
+fln --no-contents
-# Preview without writing
-fln . --dry-run
+# Force-include a file that's in .gitignore
+fln -i "src/generated/schema.ts"
-# Overwrite output file instead of creating codebase-1.md
-fln . -o codebase.md -w
+# Preview what would be included, with per-extension breakdown
+fln --dry-run --verbose
+
+# Overwrite instead of creating codebase-1.md
+fln -o codebase.md -w
+
+# JSON output for programmatic use
+fln --format json -o snapshot.json
```
-All CLI options
-
-- `-o, --output ` Output file or directory
-- `-w, --overwrite` Overwrite output file instead of adding numeric suffix
-- `-e, --exclude ` Exclude patterns (repeatable)
-- `-i, --include ` Force include patterns
-- `--include-hidden` Include hidden files and directories
-- `--no-gitignore` Ignore `.gitignore`
-- `--max-size ` Max file size (`10mb`, `512kb`)
-- `--max-total-size ` Max total included size
-- `--no-contents` Exclude file contents
-- `--no-tree` Exclude directory tree
-- `--format ` Output format
-- `--dry-run` Scan without writing output
-- `--follow-symlinks` Follow symlinks
-- `--no-ansi` Disable ANSI colors
-- `--no-sponsor-message` Hide support message (also: `FLN_NO_SPONSOR=1`)
-- `--generated-date ` Use this date in the “Generated” header (format: `YYYY-MM-DD HH:mm`)
-- `--banner ` Add text at the beginning
-- `--footer ` Add text at the end of the output
-- `-q, --quiet` Minimal output
-- `-V, --verbose` Verbose output with breakdown
-- `--debug` Debug output with file list
-- `-v, --version` Show version
-- `-h, --help` Show help
+All CLI flags
+
+**Output**
+
+| Flag | Description |
+|---|---|
+| `-o, --output ` | Output file or directory. Adds `.md`/`.json` if no extension given. Default: `-.md` |
+| `-w, --overwrite` | Overwrite instead of adding numeric suffix |
+| `--stdout` | Write to stdout instead of file (implies `--quiet`) |
+| `--format ` | Output format (default: `md`) |
+| `--dry-run` | Scan and report without writing anything |
+
+**Filtering**
+
+| Flag | Description |
+|---|---|
+| `-e, --exclude ` | Exclude pattern — repeatable |
+| `-i, --include ` | Whitelist mode — only matching files are included, repeatable |
+| `--ext ` | Include only these extensions, e.g. `ts,tsx,js` |
+| `--since [` | Only files changed since git ref, e.g. `HEAD~1`, `main` |
+| `--include-hidden` | Include hidden files and directories |
+| `--no-gitignore` | Ignore `.gitignore` rules |
+| `--max-size ` | Max individual file size, e.g. `10mb`, `512kb` |
+| `--max-total-size ` | Max total size of all included files |
+| `--follow-symlinks` | Follow symlinks while scanning |
+
+**Content**
+
+| Flag | Description |
+|---|---|
+| `--no-contents` | Exclude file contents (tree only) |
+| `--no-tree` | Exclude directory tree |
+| `--banner ` | Prepend text after the header |
+| `--banner-file ` | Prepend file contents — relative to input, excluded from tree |
+| `--footer ` | Append text at the end |
+| `--footer-file ` | Append file contents — relative to input, excluded from tree |
+| `--date ` | Fix the Generated date (useful for reproducible output) |
+
+**Logging & other**
+
+| Flag | Description |
+|---|---|
+| `-q, --quiet` | Minimal output |
+| `-V, --verbose` | Verbose output with per-extension token breakdown |
+| `--debug` | Debug output with full file list |
+| `--no-ansi` | Disable colors |
+| `--no-sponsor-message` | Hide support message (also: `FLN_NO_SPONSOR=1`) |
+| `-v, --version` | Show version |
+| `-h, --help` | Show help |
+
+> **Note:** Quote glob patterns to prevent shell expansion — `"*.test.ts"`, not `*.test.ts`.
+> To un-exclude a specific file, use negation in `--exclude`: `-e "*.md" -e '!README.md'`.
]
-## CI/CD & Automation
+---
+
+## Config file
+
+```bash
+fln init
+```
-Integrate `fln` into your pipeline to keep your codebase “AI-ready” automatically.
+Generates `.fln.json` with full IntelliSense support in VS Code, WebStorm, and any editor with JSON Schema — autocomplete and validation out of the box, no extensions needed.
-### GitHub Actions: Auto-generate Snapshots
+
+.fln.json reference
-Generate a fresh `codebase.md` artifact on every push. Download it anytime to chat with LLMs about the *exact* state of your main branch or a specific PR without manual scanning.
+```json
+{
+ "$schema": "https://fln.nesvet.dev/schema",
+ "output": "snapshot.md",
+ "overwrite": false,
+ "excludePatterns": [ "dist/", "**/*.snap" ],
+ "includePatterns": [],
+ "includeHidden": false,
+ "gitignore": true,
+ "maxFileSize": "10mb",
+ "maxTotalSize": "0",
+ "includeTree": true,
+ "includeContents": true,
+ "format": "md",
+ "followSymlinks": false,
+ "logLevel": "normal",
+ "date": "2026-02-20 12:00",
+ "banner": "You are reviewing a production codebase.",
+ "bannerFile": ".prompt.md",
+ "footer": "End of snapshot.",
+ "footerFile": "docs/footer.md"
+}
+```
+
+**Pattern format:** gitignore-style globs relative to the input directory. Leading `./` is normalized. Use `!` for negation (`*.log` + `!important.log`). Trailing slash `src/` matches directories only. CLI flags always override the config file.
-Create `.github/workflows/codebase-snapshot.yaml`:
+
+
+---
+
+## CI/CD & Automation
+
+### GitHub Actions — auto-generate snapshots
+
+Fresh `codebase.md` on every push. Download it anytime to chat with LLMs about the exact state of your main branch or a specific PR:
```yaml
+# .github/workflows/codebase-snapshot.yaml
name: Snapshot Codebase
on:
@@ -206,136 +358,140 @@ on:
jobs:
snapshot:
runs-on: ubuntu-latest
- permissions:
- contents: read
steps:
- uses: actions/checkout@v6
-
- - name: Generate Snapshot
- # Generates codebase.md without installing fln globally
+ - name: Generate snapshot
run: npx fln . -o codebase.md -w --no-ansi
-
- - name: Upload Artifact
- uses: actions/upload-artifact@v6
+ - uses: actions/upload-artifact@v6
with:
name: codebase-snapshot
path: codebase.md
retention-days: 7
```
-### Git Hooks: Pre-commit Context Guard
+### Pre-commit context guard
-Prevent accidental “context bloat” (e.g., committing large datasets or wrong lockfiles) by failing commits if the flattened codebase exceeds a specific size. This ensures your project always fits within LLM context windows.
-
-Add to your pre-commit hook (e.g., via `husky` or `lint-staged`):
+Fail the commit if the flattened codebase exceeds a size limit — ensures the project always fits in LLM context windows:
```bash
-# Fails the commit if the flattened codebase exceeds 5MB (configurable)
-# --dry-run ensures no files are written to disk
+# .husky/pre-commit
npx fln . --dry-run --max-total-size 5mb
```
-## JavaScript API
+---
+
+## API
+
+```bash
+npm install fln
+```
```typescript
import { fln } from "fln";
const result = await fln({
- rootDirectory: "./src",
- outputFile: "output.md",
- overwrite: true,
+ input: "./src",
+ output: "snapshot.md",
excludePatterns: [ "*.test.ts", "fixtures/" ],
- format: "md",
onProgress: (current, total) => {
- console.log(`Progress: ${current}/${total}`);
+ process.stdout.write(`\r${current}/${total} files`);
}
});
-console.log(`Processed ${result.files} files`);
-console.log(`Output: ${result.outputPath}`);
-console.log(`Tokens: ${result.outputTokenCount}`);
+console.log(`${result.files} files → ${result.outputPath}`);
+console.log(`~${result.outputTokenCount.toLocaleString()} tokens`);
```
-All CLI options are available via `FlnOptions`.
-
-
-## Advanced
-
-Configuration file (.fln.json)
+Full API reference
+
+**Options**
+
+| Option | Type | Default | Description |
+|---|---|---|---|
+| `input` | `string` | `process.cwd()` | Directory to flatten |
+| `output` | `string` | auto | Output file path or directory. `"-"` for stdout |
+| `overwrite` | `boolean` | `false` | Overwrite instead of numeric suffix |
+| `excludePatterns` | `string[]` | `[]` | Glob patterns to exclude |
+| `includePatterns` | `string[]` | `[]` | Glob patterns to force include |
+| `includeHidden` | `boolean` | `false` | Include hidden files/dirs |
+| `gitignore` | `boolean` | `true` | Respect `.gitignore` rules |
+| `maxFileSize` | `number \| string` | `"10mb"` | Max individual file size |
+| `maxTotalSize` | `number \| string` | `0` | Max total size (0 = unlimited) |
+| `includeContents` | `boolean` | `true` | Include file contents |
+| `includeTree` | `boolean` | `true` | Include directory tree |
+| `format` | `"md" \| "json"` | `"md"` | Output format |
+| `followSymlinks` | `boolean` | `false` | Follow symlinks |
+| `date` | `string` | current | Fixed `YYYY-MM-DD HH:mm` for Generated header |
+| `banner` | `string` | — | Text prepended after header |
+| `bannerFile` | `string` | — | File prepended (relative to input) |
+| `footer` | `string` | — | Text appended at end |
+| `footerFile` | `string` | — | File appended (relative to input) |
+| `logLevel` | `"silent" \| "normal" \| "verbose" \| "debug"` | `"silent"` | Log level |
+| `ansi` | `boolean` | `false` | ANSI colors in log output |
+| `onProgress` | `(current, total) => void` | — | Progress callback |
+
+**Result**
-```json
-{
- "outputFile": "output.md",
- "overwrite": false,
- "excludePatterns": [
- "dist/",
- "**/*.snap"
- ],
- "includePatterns": [],
- "includeHidden": false,
- "useGitignore": true,
- "maximumFileSizeBytes": "10mb",
- "maximumTotalSizeBytes": "0",
- "includeTree": true,
- "includeContents": true,
- "format": "md",
- "followSymlinks": false,
- "logLevel": "normal",
- "generatedDate": "2026-02-09 12:00",
- "banner": "This is a snapshot of the codebase.",
- "footer": "End of snapshot."
-}
+```typescript
+type FlnResult = {
+ projectName: string; // from package.json, pom.xml, Cargo.toml, etc.
+ files: number; // files included
+ directories: number; // directories scanned
+ binary: number; // binary files (shown as [BINARY FILE: X kb] in output)
+ skipped: number; // skipped — too large, generated by fln, or read errors
+ errors: number; // read errors
+ totalSizeBytes: number; // total input size
+ outputSizeBytes: number; // output file size
+ outputTokenCount: number; // estimated token count
+ outputPath: string; // absolute path ("-" for stdout)
+};
```
-
-Output naming & formats
+---
-- Uses project name + version if available (`package.json`, `pyproject.toml`, `pom.xml`, `go.mod`, `Cargo.toml`, `CMakeLists.txt` or `vcpkg.json`)
-- `md` includes tree + contents
-- `json` includes `rootDirectory`, `tree`, `stats`
+## Preview
-
+Real outputs from [`examples/`](examples/):
+
+- [TypeScript](examples/ts-app.md)
+- [Python](examples/python-app.md)
+- [Go](examples/go-app.md)
+- [Rust](examples/rust-app.md)
+- [Java](examples/java-app.md)
+
+---
Runtime compatibility
-**Node.js**
-- Requires Node.js `>=18.3`
-- ESM-only package (`"type": "module"`)
-- CLI works via `npm i -g fln` or `npx fln`
+**Node.js** — requires `>=18.3.0`, ESM-only (`"type": "module"`). Install via `npm i -g fln` or run with `npx`.
-**Bun**
-- Requires Bun `>=1.0.0`
-- CLI works via `bun install -g fln` or `bunx fln`
+**Bun** — requires `>=1.0.0`. Install via `bun add -g fln` or run with `bunx`.
-
+**Standalone binary** — no runtime required. Install via the `curl` / PowerShell one-liner above.
-## Preview
+
-Full real outputs are provided below. Each example is a compact project in [`examples/`](examples/). `fln` outputs the directory tree and file contents with **entry points and configs first** (intentional file order):
-
-- [TypeScript](examples/ts-app.md)
-- [Python](examples/python-app.md)
-- [Java](examples/java-app.md)
-- [Go](examples/go-app.md)
-- [Rust](examples/rust-app.md)
+---
## Support this project
-**fln is free, open-source, and maintained by one developer.**
+**`fln` is free, open-source, and maintained by one developer.**
If it saves you time or improves your AI workflow:
-- ⭐️ Star the repo — it genuinely helps discoverability
-- 💙 Support on [Patreon](https://www.patreon.com/nesvet) — priority features & long-term maintenance
+
+- ⭐️ **Star the repo** — it genuinely helps discoverability
+- 💙 **[Support on Patreon](https://www.patreon.com/nesvet)** — keeps development going
+
+---
## Contributing
-PRs and issues are welcome.
-See [`CONTRIBUTING.md`](CONTRIBUTING.md) for setup and guidelines.
+PRs and issues are welcome. See [`CONTRIBUTING.md`](CONTRIBUTING.md) for setup and guidelines.
## License
-MIT
+MIT © [Eugene Nesvetaev](https://nesvet.dev)
diff --git a/bun.lock b/bun.lock
index ed05529..f777f84 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,12 +5,16 @@
"": {
"name": "fln",
"dependencies": {
+ "bytes": "^3.1.2",
"ignore": "^7.0.5",
+ "p-limit": "^7.3.0",
"picocolors": "^1.1.1",
+ "strip-ansi": "^7.1.2",
},
"devDependencies": {
"@nesvet/eslint-config": "latest",
"@types/bun": "latest",
+ "@types/bytes": "latest",
"@types/node": "latest",
"typescript": "latest",
},
@@ -89,7 +93,9 @@
"@stylistic/stylelint-plugin": ["@stylistic/stylelint-plugin@5.0.1", "", { "dependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "@csstools/media-query-list-parser": "^5.0.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.1", "postcss-value-parser": "^4.2.0", "style-search": "^0.1.0" }, "peerDependencies": { "stylelint": "^17.0.0" } }, "sha512-NaVwCNVZ2LyPA3TnUwvjO9c6P6VUjgRB8UP8SOW+cAOJBVqPPuOIDawsvvtql/LhkuR3JuTdGvr/RM3dUl8l2Q=="],
- "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
+ "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
+
+ "@types/bytes": ["@types/bytes@3.1.5", "", {}, "sha512-VgZkrJckypj85YxEsEavcMmmSOIzkUHqWmM4CCyia5dc54YwsXzJ5uT4fYxBQNEXx+oF1krlhgCbvfubXqZYsQ=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@@ -101,7 +107,7 @@
"@types/minimatch": ["@types/minimatch@6.0.0", "", { "dependencies": { "minimatch": "*" } }, "sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA=="],
- "@types/node": ["@types/node@25.2.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ=="],
+ "@types/node": ["@types/node@25.3.1", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-hj9YIJimBCipHVfHKRMnvmHg+wfhKc0o4mTtXh9pKBjC8TLJzz0nzGmLi5UJsYAUgSvXFHgb0V2oY10DUFtImw=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.54.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/type-utils": "8.54.0", "@typescript-eslint/utils": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.54.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ=="],
@@ -173,7 +179,9 @@
"builtin-modules": ["builtin-modules@5.0.0", "", {}, "sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg=="],
- "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
+ "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
+
+ "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"cacheable": ["cacheable@2.3.2", "", { "dependencies": { "@cacheable/memory": "^2.0.7", "@cacheable/utils": "^2.3.3", "hookified": "^1.15.0", "keyv": "^5.5.5", "qified": "^0.6.0" } }, "sha512-w+ZuRNmex9c1TR9RcsxbfTKCjSL0rh1WA5SABbrWprIHeNBdmyQLSYonlDy9gpD+63XT8DgZ/wNh1Smvc9WnJA=="],
@@ -589,7 +597,7 @@
"own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
- "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
+ "p-limit": ["p-limit@7.3.0", "", { "dependencies": { "yocto-queue": "^1.2.1" } }, "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
@@ -765,7 +773,7 @@
"unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
- "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
+ "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"unicorn-magic": ["unicorn-magic@0.4.0", "", {}, "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw=="],
@@ -791,7 +799,7 @@
"write-file-atomic": ["write-file-atomic@7.0.0", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, "sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg=="],
- "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
+ "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="],
"@cacheable/memory/keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="],
@@ -807,6 +815,8 @@
"@keyv/bigmap/keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="],
+ "@types/glob/@types/node": ["@types/node@25.2.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ=="],
+
"@types/minimatch/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
@@ -815,6 +825,8 @@
"brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
+ "bun-types/@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="],
+
"cacheable/keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="],
"clean-regexp/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
@@ -859,6 +871,8 @@
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
+ "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
+
"sort-package-json/globby": ["globby@10.0.0", "", { "dependencies": { "@types/glob": "^7.1.1", "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.0.3", "glob": "^7.1.3", "ignore": "^5.1.1", "merge2": "^1.2.3", "slash": "^3.0.0" } }, "sha512-3LifW9M4joGZasyYPz2A1U74zbC/45fvpXUvO/9KbSa+VV0aGZarWkfdgKyR9sExNP0t0x0ss/UMJpNpcaTspw=="],
"stylelint/file-entry-cache": ["file-entry-cache@11.1.2", "", { "dependencies": { "flat-cache": "^6.1.20" } }, "sha512-N2WFfK12gmrK1c1GXOqiAJ1tc5YE+R53zvQ+t5P8S5XhnmKYVB5eZEiLNZKDSmoG8wqqbF9EXYBBW/nef19log=="],
@@ -873,6 +887,8 @@
"@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
+ "@types/glob/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
+
"@types/minimatch/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
@@ -881,6 +897,8 @@
"eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
+ "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
+
"sort-package-json/globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"sort-package-json/globby/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
diff --git a/examples/go-app.md b/examples/go-app.md
index f7f298e..8d4cd38 100644
--- a/examples/go-app.md
+++ b/examples/go-app.md
@@ -1,8 +1,8 @@
-
+
# Codebase Snapshot: go-app
-Generated: 2026-01-01 00:00
+Generated: 2026-02-26 00:00
Files: 7 | Directories: 8
---
diff --git a/examples/java-app.md b/examples/java-app.md
index 0f33cea..f745f77 100644
--- a/examples/java-app.md
+++ b/examples/java-app.md
@@ -1,8 +1,8 @@
-
+
# Codebase Snapshot: java-app
-Generated: 2026-01-01 00:00
+Generated: 2026-02-26 00:00
Files: 7 | Directories: 6
---
diff --git a/examples/python-app.md b/examples/python-app.md
index 97583e9..86b60dc 100644
--- a/examples/python-app.md
+++ b/examples/python-app.md
@@ -1,8 +1,8 @@
-
+
# Codebase Snapshot: python-app
-Generated: 2026-01-01 00:00
+Generated: 2026-02-26 00:00
Files: 8 | Directories: 3
---
diff --git a/examples/rust-app.md b/examples/rust-app.md
index 34f018e..27f5adc 100644
--- a/examples/rust-app.md
+++ b/examples/rust-app.md
@@ -1,8 +1,8 @@
-
+
# Codebase Snapshot: rust-app
-Generated: 2026-01-01 00:00
+Generated: 2026-02-26 00:00
Files: 8 | Directories: 2
---
diff --git a/examples/ts-app.md b/examples/ts-app.md
index 32fc21d..a57911e 100644
--- a/examples/ts-app.md
+++ b/examples/ts-app.md
@@ -1,8 +1,8 @@
-
+
# Codebase Snapshot: ts-app
-Generated: 2026-01-01 00:00
+Generated: 2026-02-26 00:00
Files: 8 | Directories: 2
---
diff --git a/package.json b/package.json
index 3058ccc..a1406b5 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "fln",
- "version": "1.1.3",
+ "version": "1.2.0",
"description": "Feed your entire codebase to any LLM in one shot. No attachment limits, no upload hassles.",
"keywords": [
"cli",
@@ -62,16 +62,20 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
+ "bytes": "^3.1.2",
"ignore": "^7.0.5",
- "picocolors": "^1.1.1"
+ "p-limit": "^7.3.0",
+ "picocolors": "^1.1.1",
+ "strip-ansi": "^7.1.2"
},
"devDependencies": {
"@nesvet/eslint-config": "latest",
"@types/bun": "latest",
+ "@types/bytes": "latest",
"@types/node": "latest",
"typescript": "latest"
},
- "packageManager": "bun@1.3.9",
+ "packageManager": "bun@1.3.10",
"engines": {
"bun": ">=1.0.0",
"node": ">=18.3.0"
diff --git a/schema/fln.json b/schema/fln.json
new file mode 100644
index 0000000..5442ec7
--- /dev/null
+++ b/schema/fln.json
@@ -0,0 +1,121 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "https://fln.nesvet.dev/schema",
+ "title": "fln configuration",
+ "description": "Configuration file for fln — flatten your codebase into a single file for LLMs",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "$schema": {
+ "type": "string"
+ },
+ "output": {
+ "type": "string",
+ "description": "Output file path or directory. Default: auto-generated from project metadata (e.g. my-app-1.0.0.md)"
+ },
+ "overwrite": {
+ "type": "boolean",
+ "description": "Overwrite output file instead of adding numeric suffix when it already exists",
+ "default": false
+ },
+ "excludePatterns": {
+ "type": "array",
+ "items": { "type": "string" },
+ "description": "Glob patterns to exclude (e.g. [\"*.test.ts\", \"fixtures/\"])",
+ "default": []
+ },
+ "includePatterns": {
+ "type": "array",
+ "items": { "type": "string" },
+ "description": "Glob patterns to force include, overriding .gitignore rules",
+ "default": []
+ },
+ "includeHidden": {
+ "type": "boolean",
+ "description": "Include hidden files and directories (starting with .)",
+ "default": false
+ },
+ "gitignore": {
+ "type": "boolean",
+ "description": "Use .gitignore rules for filtering",
+ "default": true
+ },
+ "maxFileSize": {
+ "oneOf": [
+ { "type": "integer", "minimum": 1 },
+ {
+ "type": "string",
+ "pattern": "^\\d+(\\.\\d+)?\\s*(b|kb|mb|gb)?$",
+ "examples": [ "10mb", "512kb", "1gb" ]
+ }
+ ],
+ "description": "Maximum individual file size. Default: 10mb"
+ },
+ "maxTotalSize": {
+ "oneOf": [
+ { "type": "integer", "minimum": 0 },
+ {
+ "type": "string",
+ "pattern": "^\\d+(\\.\\d+)?\\s*(b|kb|mb|gb)?$",
+ "examples": [ "100mb", "1gb" ]
+ }
+ ],
+ "description": "Maximum total size for all included files. 0 means unlimited",
+ "default": 0
+ },
+ "includeContents": {
+ "type": "boolean",
+ "description": "Include file contents in output",
+ "default": true
+ },
+ "includeTree": {
+ "type": "boolean",
+ "description": "Include directory tree structure",
+ "default": true
+ },
+ "format": {
+ "type": "string",
+ "enum": [ "md", "json" ],
+ "description": "Output format",
+ "default": "md"
+ },
+ "followSymlinks": {
+ "type": "boolean",
+ "description": "Follow symlinks while scanning",
+ "default": false
+ },
+ "ansi": {
+ "type": "boolean",
+ "description": "Use ANSI colors in log output",
+ "default": true
+ },
+ "logLevel": {
+ "type": "string",
+ "enum": [ "silent", "normal", "verbose", "debug" ],
+ "description": "Logging level",
+ "default": "normal"
+ },
+ "date": {
+ "type": "string",
+ "pattern": "^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}$",
+ "description": "Fixed date string for the Generated header (overrides current date). Format: YYYY-MM-DD HH:mm",
+ "examples": [ "2026-01-01 00:00" ]
+ },
+ "banner": {
+ "type": "string",
+ "description": "Custom text prepended to output after the header"
+ },
+ "bannerFile": {
+ "type": "string",
+ "description": "Path to file whose contents are prepended to output (relative to input directory). File is excluded from tree"
+ },
+ "footer": {
+ "type": "string",
+ "description": "Custom text appended to the end of output"
+ },
+ "footerFile": {
+ "type": "string",
+ "description": "Path to file whose contents are appended to output (relative to input directory). File is excluded from tree"
+ }
+ }
+}
diff --git a/scripts/generate-examples.ts b/scripts/generate-examples.ts
index 1fda4fb..85718da 100644
--- a/scripts/generate-examples.ts
+++ b/scripts/generate-examples.ts
@@ -11,10 +11,10 @@ const names = readdirSync(examplesDir, { withFileTypes: true }).filter(entry =>
for (const name of names)
await fln({
- rootDirectory: join(examplesDir, name),
- outputFile: join(examplesDir, `${name}.md`),
+ input: join(examplesDir, name),
+ output: join(examplesDir, `${name}.md`),
overwrite: true,
- generatedDate: "2026-01-01 00:00"
+ date: "2026-02-26 00:00"
});
console.info(`✓ Generated ${names.length} snapshot(s)`);
diff --git a/src/api/fln.ts b/src/api/fln.ts
index ad8b531..9a9e296 100644
--- a/src/api/fln.ts
+++ b/src/api/fln.ts
@@ -1,4 +1,5 @@
-import { relative, resolve, sep } from "node:path";
+import { readFile, stat } from "node:fs/promises";
+import { resolve } from "node:path";
import {
defaultConfigFileName,
getProjectMetadata,
@@ -8,7 +9,8 @@ import {
resolveOutputPath
} from "../config/index.js";
import { parseByteSize, scanTree, writeOutput } from "../core/index.js";
-import { createLogger } from "../infra/index.js";
+import { createLogger, resolveOption, symbols } from "../infra/index.js";
+import { resolveFromBase, toCanonicalRelative } from "../path/index.js";
import type { FlnOptions, FlnResult } from "./types.js";
/**
@@ -19,8 +21,8 @@ import type { FlnOptions, FlnResult } from "./types.js";
* import { fln } from "fln";
*
* const result = await fln({
- * rootDirectory: "./src",
- * outputFile: "output.md",
+ * input: "./src",
+ * output: "output.md",
* excludePatterns: ["*.test.ts"],
* onProgress: (current, total) => {
* console.log(`Progress: ${current}/${total}`);
@@ -33,73 +35,121 @@ import type { FlnOptions, FlnResult } from "./types.js";
* ```
*/
export async function fln(options: FlnOptions = {}): Promise {
- const rootDirectory = resolve(options.rootDirectory ?? process.cwd());
+ // TODO(major): remove rootDirectory fallback
+ const input = resolve(options.input ?? options.rootDirectory ?? process.cwd());
+ const inputStats = await stat(input);
+ if (!inputStats.isDirectory())
+ throw new Error(`Input must be a directory, got file: ${input}`);
- const projectMetadata = await getProjectMetadata(rootDirectory);
+ const projectMetadata = await getProjectMetadata(input);
- const configFilePath = resolve(rootDirectory, defaultConfigFileName);
+ const configFilePath = resolve(input, defaultConfigFileName);
- const fileConfig = normalizeConfigFile(await loadConfigFile(configFilePath));
+ const { config: rawFileConfig, loaded: configLoaded, parseError } = await loadConfigFile(configFilePath);
+ if (parseError)
+ console.warn(parseError);
+
+ const fileConfig = normalizeConfigFile(rawFileConfig);
const format: "json" | "md" = options.format ?? (fileConfig.format as "json" | "md" | undefined) ?? "md";
const overwrite = options.overwrite ?? fileConfig.overwrite ?? false;
- const outputValue = options.outputFile ?? fileConfig.outputFile;
- const outputFile = await resolveOutputPath(
- outputValue ? resolve(outputValue) : undefined,
- rootDirectory,
+ // TODO(major): remove outputFile fallback
+ const outputValue = options.output ?? options.outputFile ?? fileConfig.output;
+ const output = await resolveOutputPath(
+ outputValue === "-" ? "-" : (outputValue ? resolveFromBase(outputValue, input) : undefined),
+ input,
+ projectMetadata,
overwrite,
format
);
+ const gitignore = resolveOption(options, "gitignore", "useGitignore", "API");
+ const maxFileSize = resolveOption(options, "maxFileSize", "maximumFileSizeBytes", "API");
+ const maxTotalSize = resolveOption(options, "maxTotalSize", "maximumTotalSizeBytes", "API");
+ const date = resolveOption(options, "date", "generatedDate", "API");
+ const ansi = resolveOption(options, "ansi", "useAnsi", "API") ?? false;
+
const userConfig = {
- outputFile,
+ output,
overwrite: options.overwrite,
excludePatterns: options.excludePatterns,
includePatterns: options.includePatterns,
includeHidden: options.includeHidden,
- useGitignore: options.useGitignore,
- maximumFileSizeBytes: typeof options.maximumFileSizeBytes === "string" ?
- parseByteSize(options.maximumFileSizeBytes) :
- options.maximumFileSizeBytes,
- maximumTotalSizeBytes: typeof options.maximumTotalSizeBytes === "string" ?
- parseByteSize(options.maximumTotalSizeBytes) :
- options.maximumTotalSizeBytes,
+ gitignore,
+ maxFileSize: maxFileSize === undefined ?
+ undefined :
+ (typeof maxFileSize === "string" ? parseByteSize(maxFileSize) : maxFileSize),
+ maxTotalSize: maxTotalSize === undefined ?
+ undefined :
+ (typeof maxTotalSize === "string" ? parseByteSize(maxTotalSize) : maxTotalSize),
includeContents: options.includeContents,
includeTree: options.includeTree,
format,
followSymlinks: options.followSymlinks,
- useAnsi: false,
+ ansi,
logLevel: options.logLevel ?? "silent",
- generatedDate: options.generatedDate,
+ date,
banner: options.banner,
- footer: options.footer
+ bannerFile: options.bannerFile,
+ footer: options.footer,
+ footerFile: options.footerFile
};
- const config = resolveConfig(rootDirectory, fileConfig, userConfig);
+ const config = resolveConfig(input, fileConfig, userConfig);
+
+ const outputCanonical = config.output === "-" ? null : toCanonicalRelative(config.output, input);
+ if (outputCanonical && outputCanonical !== "")
+ config.excludedPaths = [ outputCanonical ];
+
+ async function resolveBannerFooterFile(
+ filePath: string | undefined
+ ): Promise<{ content: string; excludedPath?: string }> {
+ if (filePath) {
+ const absolutePath = resolveFromBase(filePath, input);
+ const content = await readFile(absolutePath, "utf8");
+ const excludedPath = toCanonicalRelative(absolutePath, input) ?? undefined;
+
+ return { content, excludedPath };
+ }
+
+ return { content: "" };
+ }
- const outputRelativePath = relative(rootDirectory, config.outputFile);
- const outputRelativeNormalized = outputRelativePath.split(sep).join("/");
+ const [ bannerFileResult, footerFileResult ] = await Promise.all([
+ resolveBannerFooterFile(config.bannerFile),
+ resolveBannerFooterFile(config.footerFile)
+ ]);
- if (outputRelativeNormalized !== "" && !outputRelativeNormalized.startsWith("../") && outputRelativeNormalized !== "..")
- config.excludedPaths = [ outputRelativeNormalized ];
+ const bannerParts = [ config.banner, bannerFileResult.content ].filter(Boolean) as string[];
+ const footerParts = [ config.footer, footerFileResult.content ].filter(Boolean) as string[];
+ config.banner = bannerParts.length > 0 ? bannerParts.join("\n\n") : undefined;
+ config.footer = footerParts.length > 0 ? footerParts.join("\n\n") : undefined;
+
+ if (bannerFileResult.excludedPath)
+ config.excludedPaths.push(bannerFileResult.excludedPath);
+ if (footerFileResult.excludedPath)
+ config.excludedPaths.push(footerFileResult.excludedPath);
if (!config.includeContents) {
- config.maximumFileSizeBytes = Number.MAX_SAFE_INTEGER;
- config.maximumTotalSizeBytes = 0;
+ config.maxFileSize = Number.MAX_SAFE_INTEGER;
+ config.maxTotalSize = 0;
}
- if (config.maximumFileSizeBytes <= 0)
+ if (config.maxFileSize <= 0)
throw new Error("Max file size must be greater than 0.");
- if (config.maximumTotalSizeBytes < 0)
+ if (config.maxTotalSize < 0)
throw new Error("Max total size must be 0 or greater.");
const logger = createLogger({
- useAnsi: config.useAnsi,
+ ansi: config.ansi,
logLevel: config.logLevel
});
+ if (configLoaded)
+ logger.info(`${symbols.info} Using config: ${defaultConfigFileName}`);
+
const result = await scanTree({
projectName: projectMetadata.name,
...config,
@@ -118,7 +168,7 @@ export async function fln(options: FlnOptions = {}): Promise {
totalSizeBytes: result.stats.totalSizeBytes,
outputSizeBytes: result.stats.outputSizeBytes,
outputTokenCount: result.stats.outputTokenCount,
- outputPath: config.outputFile,
+ outputPath: config.output,
_root: result.root
};
}
diff --git a/src/api/types.ts b/src/api/types.ts
index 7dd18b1..e26f079 100644
--- a/src/api/types.ts
+++ b/src/api/types.ts
@@ -1,20 +1,31 @@
-import type { LogLevel, ProgressCallback } from "../core/index.js";
+export type LogLevel = "debug" | "normal" | "silent" | "verbose";
+export type ProgressCallback = (current: number, total: number) => void;
/**
* Configuration options for fln function
*/
export type FlnOptions = {
/**
- * Root directory to scan
+ * Directory to flatten (input)
* @default process.cwd()
*/
- rootDirectory?: string;
+ input?: string;
/**
* Output file path or directory
* @default Auto-generated from project metadata (e.g., "my-app-1.0.0.md")
*/
+ output?: string;
+
+ /**
+ * @deprecated Use input. Remove in next major.
+ */
+ rootDirectory?: string;
+
+ /**
+ * @deprecated Use output. Remove in next major.
+ */
outputFile?: string;
/**
@@ -45,18 +56,33 @@ export type FlnOptions = {
* Use .gitignore rules for filtering
* @default true
*/
+ gitignore?: boolean;
+
+ /**
+ * @deprecated Use gitignore. Remove in next major.
+ */
useGitignore?: boolean;
/**
* Maximum individual file size (bytes or string like "10mb")
* @default 10485760 (10 MB)
*/
+ maxFileSize?: number | string;
+
+ /**
+ * @deprecated Use maxFileSize. Remove in next major.
+ */
maximumFileSizeBytes?: number | string;
/**
* Maximum total size for all included files (bytes or string like "100mb")
* @default 0 (unlimited)
*/
+ maxTotalSize?: number | string;
+
+ /**
+ * @deprecated Use maxTotalSize. Remove in next major.
+ */
maximumTotalSizeBytes?: number | string;
/**
@@ -86,6 +112,11 @@ export type FlnOptions = {
/**
* Date string for the "Generated" header (format: YYYY-MM-DD HH:mm). If omitted, current date is used.
*/
+ date?: string;
+
+ /**
+ * @deprecated Use date. Remove in next major.
+ */
generatedDate?: string;
/**
@@ -93,11 +124,21 @@ export type FlnOptions = {
*/
banner?: string;
+ /**
+ * Path to file whose contents are prepended to output (relative to input). File is excluded from tree.
+ */
+ bannerFile?: string;
+
/**
* Custom footer text at the end of output
*/
footer?: string;
+ /**
+ * Path to file whose contents are appended to output (relative to input). File is excluded from tree.
+ */
+ footerFile?: string;
+
/**
* Progress callback function
* @param current Current number of processed items
@@ -110,6 +151,17 @@ export type FlnOptions = {
* @default "silent"
*/
logLevel?: LogLevel;
+
+ /**
+ * Use ANSI colors in log output
+ * @default false (for programmatic use)
+ */
+ ansi?: boolean;
+
+ /**
+ * @deprecated Use ansi. Remove in next major.
+ */
+ useAnsi?: boolean;
};
/**
diff --git a/src/cli/commandLine.ts b/src/cli/commandLine.ts
index a17fc71..6e4749e 100644
--- a/src/cli/commandLine.ts
+++ b/src/cli/commandLine.ts
@@ -1,6 +1,8 @@
import { resolve } from "node:path";
import { parseArgs } from "node:util";
+import ignore from "ignore";
import { fln } from "../api/index.js";
+import { runInit } from "../config/index.js";
import {
collectExtensionStats,
collectProcessedFiles,
@@ -8,11 +10,17 @@ import {
type FileNode
} from "../core/index.js";
import {
+ createProgressRenderer,
+ filterPathsUnderBase,
+ getChangedFilesSince,
getTerminalInfo,
incrementUsageCount,
shouldShowSponsorMessage,
- shouldUseColors
+ shouldUseColors,
+ warnDeprecated
} from "../infra/index.js";
+import { getNullishOutput, resolveFromBase } from "../path/index.js";
+import { normalizeIncludePattern } from "../pattern/index.js";
import { VERSION } from "../version.js";
import { formatHelpMessage } from "./help.js";
import { OutputRenderer } from "./output/index.js";
@@ -44,6 +52,7 @@ export async function runCommandLine(): Promise {
output: { type: "string", short: "o" },
exclude: { type: "string", short: "e", multiple: true },
include: { type: "string", short: "i", multiple: true },
+ ext: { type: "string" },
"include-hidden": { type: "boolean" },
"no-gitignore": { type: "boolean" },
"max-size": { type: "string" },
@@ -52,6 +61,7 @@ export async function runCommandLine(): Promise {
"no-tree": { type: "boolean" },
format: { type: "string" },
"dry-run": { type: "boolean" },
+ stdout: { type: "boolean" },
overwrite: { type: "boolean", short: "w" },
quiet: { type: "boolean", short: "q" },
verbose: { type: "boolean", short: "V" },
@@ -59,9 +69,13 @@ export async function runCommandLine(): Promise {
"no-ansi": { type: "boolean" },
"follow-symlinks": { type: "boolean" },
"no-sponsor-message": { type: "boolean" },
+ date: { type: "string" },
"generated-date": { type: "string" },
banner: { type: "string" },
+ "banner-file": { type: "string" },
footer: { type: "string" },
+ "footer-file": { type: "string" },
+ since: { type: "string" },
version: { type: "boolean", short: "v" },
help: { type: "boolean", short: "h" }
},
@@ -80,6 +94,11 @@ export async function runCommandLine(): Promise {
process.exit(0);
}
+ if (positionals[0] === "init") {
+ await runInit(values.overwrite ?? false);
+ process.exit(0);
+ }
+
if (values.quiet && values.verbose)
throw new Error("Cannot use --quiet and --verbose together.");
@@ -90,45 +109,86 @@ export async function runCommandLine(): Promise {
throw new Error("Cannot use --verbose and --debug together.");
const runCount = await incrementUsageCount();
- const rootDirectory = resolve(process.cwd(), positionals[0] || ".");
+ const input = resolve(process.cwd(), positionals[0] || ".");
const isDryRun = values["dry-run"] ?? false;
+ const isStdout = values.stdout ?? false;
+
+ const logLevel = (values.quiet || isStdout) ? "silent" : values.debug ? "debug" : values.verbose ? "verbose" : "normal";
+ const ansi = shouldUseColors() && !values["no-ansi"];
+
+ if (values["generated-date"] !== undefined && values.date === undefined)
+ warnDeprecated("--generated-date", "--date", "CLI");
+
+ const date = values.date ?? values["generated-date"];
+
+ const cwd = process.cwd();
+ const sincePatterns = values.since ?
+ filterPathsUnderBase(getChangedFilesSince(values.since, cwd), cwd, input) :
+ [];
+ const extPatterns = values.ext ?
+ values.ext.split(",").map(ext => `**/*.${ext.trim().replace(/^\./, "")}`).filter(Boolean) :
+ [];
+ const sinceFiltered = values.since && values.ext ?
+ (() => {
+ const normalized = extPatterns
+ .map(p => normalizeIncludePattern(p, input))
+ .filter((p): p is string => p !== null);
+
+ if (normalized.length === 0)
+ return sincePatterns;
+ const extMatcher = ignore().add(normalized);
+
+ return sincePatterns.filter(path => extMatcher.ignores(path));
+ })() :
+ sincePatterns;
+ if (values.since && sinceFiltered.length === 0 && !values.include?.length) {
+ const extSuffix = values.ext ? ` matching --ext ${values.ext}` : "";
+ console.info(`No changed files since ${values.since}${extSuffix}`);
+ process.exit(0);
+ }
+ const includePatterns = values.since && values.ext ?
+ [ ...sinceFiltered, ...(values.include ?? []) ] :
+ [ ...sinceFiltered, ...extPatterns, ...(values.include ?? []) ];
- const logLevel = values.quiet ? "silent" : values.debug ? "debug" : values.verbose ? "verbose" : "normal";
- const useAnsi = shouldUseColors() && !values["no-ansi"];
-
- const renderer = new OutputRenderer({ logLevel, useAnsi });
-
- const progress = renderer.createProgressBar(100);
+ const renderer = new OutputRenderer({ logLevel, ansi });
+ const progress = createProgressRenderer("🥞 Scanning...", ansi, (values.quiet ?? false) || isStdout);
const startTime = Date.now();
+ progress.start();
const result = await fln({
- rootDirectory,
- outputFile: isDryRun ?
- (process.platform === "win32" ? "nul" : "/dev/null") :
- (values.output ? resolve(values.output) : undefined),
+ input,
+ ansi,
+ output: isDryRun ?
+ getNullishOutput() :
+ (isStdout ? "-" : (values.output ? resolveFromBase(values.output, cwd) : undefined)),
overwrite: values.overwrite,
excludePatterns: values.exclude,
- includePatterns: values.include,
+ includePatterns:
+ includePatterns.length > 0 || values.since ?
+ includePatterns :
+ undefined,
includeHidden: values["include-hidden"],
- useGitignore: values["no-gitignore"] ? false : undefined,
- maximumFileSizeBytes: values["max-size"] ? parseByteSize(values["max-size"]) : undefined,
- maximumTotalSizeBytes: values["max-total-size"] ? parseByteSize(values["max-total-size"]) : undefined,
+ gitignore: values["no-gitignore"] ? false : undefined,
+ maxFileSize: values["max-size"] ? parseByteSize(values["max-size"]) : undefined,
+ maxTotalSize: values["max-total-size"] ? parseByteSize(values["max-total-size"]) : undefined,
includeContents: values["no-contents"] ? false : undefined,
includeTree: values["no-tree"] ? false : undefined,
format: values.format as "json" | "md" | undefined,
followSymlinks: values["follow-symlinks"],
- generatedDate: values["generated-date"],
+ date,
banner: values.banner,
+ bannerFile: values["banner-file"],
footer: values.footer,
- onProgress: () => {
- progress.increment();
+ footerFile: values["footer-file"],
+ onProgress: (current, total) => {
+ progress.update(current, total, "files");
},
logLevel
});
const elapsedMs = Date.now() - startTime;
- progress.clear();
+ progress.cleanup();
if (isDryRun && logLevel !== "silent")
console.info("Dry run mode — output was not written");
diff --git a/src/cli/help.ts b/src/cli/help.ts
index 1a464b6..b42e71d 100644
--- a/src/cli/help.ts
+++ b/src/cli/help.ts
@@ -1,21 +1,24 @@
import { applyColor, colors } from "./output/index.js";
-export function formatHelpMessage(useAnsi: boolean): string {
- const bold = (text: string) => applyColor(text, colors.bold, useAnsi);
- const dim = (text: string) => applyColor(text, colors.dim, useAnsi);
- const cyan = (text: string) => applyColor(text, colors.info, useAnsi);
- const green = (text: string) => applyColor(text, colors.success, useAnsi);
+export function formatHelpMessage(ansi: boolean): string {
+ const bold = (text: string) => applyColor(text, colors.bold, ansi);
+ const dim = (text: string) => applyColor(text, colors.dim, ansi);
+ const cyan = (text: string) => applyColor(text, colors.info, ansi);
+ const green = (text: string) => applyColor(text, colors.success, ansi);
- return `${bold("fln")} ${dim("—")} Flatten your codebase into a single file for LLMs.
+ return `🥞 ${bold("fln")} ${dim("—")} Flatten your codebase into a single file for LLMs.
${bold("Usage:")} fln ${cyan("[directory]")} ${dim("[...flags]")}
+ fln ${cyan("init")} ${dim("[--overwrite]")}
${bold("Options:")}
- ${cyan("-o, --output")} ${dim("")} Output file or directory path ${dim("(default: -.)")}
+ ${cyan("-o, --output")} ${dim("")} Output file or directory path ${dim("(default: -.; adds .md/.json if no extension)")}
${cyan("-w, --overwrite")} Overwrite output file instead of adding numeric suffix
${cyan("-e, --exclude")} ${dim("")} Exclude patterns ${dim("(repeatable)")}
${cyan("-i, --include")} ${dim("")} Force include patterns ${dim("(repeatable)")}
+ ${cyan(" --ext")} ${dim("")} Include only these extensions ${dim("(e.g. ts,tsx,js)")}
+ ${cyan(" --since")} ${dim("[")} Include only files changed since git ref ${dim("(e.g. HEAD~1, main)")}
${cyan(" --include-hidden")} Include hidden files and directories
${cyan(" --no-gitignore")} Ignore .gitignore files
${cyan(" --max-size")} ${dim("")} Max file size ${dim("(e.g. 10mb, 512kb)")}
@@ -24,12 +27,15 @@ ${bold("Options:")}
${cyan(" --no-tree")} Exclude directory tree
${cyan(" --format")} ${dim("")} Output format ${dim("(default: md)")}
${cyan(" --dry-run")} Scan and report without writing output
+ ${cyan(" --stdout")} Write output to stdout instead of file ${dim("(implies --quiet)")}
${cyan(" --follow-symlinks")} Follow symlinks while scanning
${cyan(" --no-ansi")} Disable ANSI colors
${cyan(" --no-sponsor-message")} Hide support message ${dim("(also: FLN_NO_SPONSOR=1)")}
- ${cyan(" --generated-date")} ${dim("")} Use this date in the "Generated" header ${dim("(YYYY-MM-DD HH:mm)")}
+ ${cyan(" --date")} ${dim("")} Use this date in the "Generated" header ${dim("(YYYY-MM-DD HH:mm)")}
${cyan(" --banner")} ${dim("")} Add text at the beginning of the output
+ ${cyan(" --banner-file")} ${dim("")} Prepend file contents at the beginning ${dim("(relative to input)")}
${cyan(" --footer")} ${dim("")} Add text at the end of the output
+ ${cyan(" --footer-file")} ${dim("")} Append file contents at the end ${dim("(relative to input)")}
${cyan("-q, --quiet")} Minimal output
${cyan("-V, --verbose")} Verbose output
${cyan(" --debug")} Debug output with file list
@@ -37,8 +43,14 @@ ${bold("Options:")}
${cyan("-h, --help")} Show this help message
${bold("Examples:")}
+ ${dim("$")} fln ${cyan("init")}
${dim("$")} fln . ${cyan("-o")} output.md
+ ${dim("$")} fln ${cyan("--stdout")} | pbcopy
+ ${dim("$")} fln ${cyan("--ext")} ts,tsx,js
+ ${dim("$")} fln ${cyan("--since")} HEAD~1
${dim("$")} fln src ${cyan("-e")} ${green('"*.test.ts"')} ${cyan("-e")} ${green('"fixtures/"')}
${dim("$")} fln . ${cyan("--no-contents --format")} json
+
+${bold("Note:")} Quote glob patterns ${dim('(e.g. "*.md")')} to prevent shell expansion.
`;
}
diff --git a/src/cli/output/components/index.ts b/src/cli/output/components/index.ts
index dff0ae9..127fbdf 100644
--- a/src/cli/output/components/index.ts
+++ b/src/cli/output/components/index.ts
@@ -1,5 +1,4 @@
export * from "./breakdown.js";
export * from "./errors.js";
-export * from "./progressBar.js";
export * from "./summary.js";
export * from "./warnings.js";
diff --git a/src/cli/output/components/progressBar.ts b/src/cli/output/components/progressBar.ts
deleted file mode 100644
index a13fac3..0000000
--- a/src/cli/output/components/progressBar.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import { applyColor, colors, symbols } from "../styles.js";
-
-
-export class ProgressBar {
- #total: number;
- #current = 0;
- #visible = false;
- #isTTY: boolean;
- #useColors: boolean;
-
- constructor(total: number, useColors: boolean) {
- this.#total = total;
- this.#isTTY = Boolean(process.stdout.isTTY);
- this.#useColors = useColors;
- }
-
- increment(message?: string): void {
- this.#current++;
-
- if (!this.#isTTY)
- return;
-
- if (!this.#visible)
- this.#visible = true;
-
- this.#render(message);
- }
-
- clear(): void {
- if (!this.#visible || !this.#isTTY)
- return;
-
- process.stdout.write(`\r${" ".repeat(80)}\r`);
- this.#visible = false;
- }
-
- #render(message?: string): void {
- const percent = Math.floor((this.#current / this.#total) * 100);
- const filledCount = Math.floor(percent / 5);
- const emptyCount = 20 - filledCount;
-
- const filledBar = applyColor(
- symbols.barFull.repeat(filledCount),
- colors.info,
- this.#useColors
- );
- const emptyBar = applyColor(
- symbols.barEmpty.repeat(emptyCount),
- colors.dim,
- this.#useColors
- );
-
- const bar = filledBar + emptyBar;
- const text = message ?? "Scanning...";
- const counter = applyColor(
- `${this.#current}/${this.#total}`,
- colors.dim,
- this.#useColors
- );
-
- process.stdout.write(
- `\r${symbols.pancake} ${text} ${bar} ${percent}% (${counter} files)`
- );
- }
-}
diff --git a/src/cli/output/renderer.ts b/src/cli/output/renderer.ts
index d0fb8c4..5a6378b 100644
--- a/src/cli/output/renderer.ts
+++ b/src/cli/output/renderer.ts
@@ -1,7 +1,6 @@
import type { FlnResult } from "../../api/index.js";
import type { LogLevel } from "../../core/index.js";
import {
- ProgressBar,
renderBreakdown,
renderErrors,
renderSummary,
@@ -14,7 +13,7 @@ import { applyColor, colors, symbols } from "./styles.js";
export type RendererOptions = {
logLevel: LogLevel;
- useAnsi: boolean;
+ ansi: boolean;
};
export type SuccessData = {
@@ -31,11 +30,7 @@ export class OutputRenderer {
constructor(options: RendererOptions) {
this.#logLevel = options.logLevel;
- this.#useColors = options.useAnsi;
- }
-
- createProgressBar(total: number): ProgressBar {
- return new ProgressBar(total, this.#useColors);
+ this.#useColors = options.ansi;
}
renderSuccess(data: SuccessData): void {
diff --git a/src/config/defaults.ts b/src/config/defaults.ts
index f7b6267..a4817c8 100644
--- a/src/config/defaults.ts
+++ b/src/config/defaults.ts
@@ -1,2 +1,2 @@
-export const defaultMaximumFileSizeBytes = 10 * 1024 * 1024;
+export const defaultMaxFileSize = 10 * 1024 * 1024;
export const defaultConfigFileName = ".fln.json";
diff --git a/src/config/index.ts b/src/config/index.ts
index 35c1693..5d808d4 100644
--- a/src/config/index.ts
+++ b/src/config/index.ts
@@ -1,4 +1,5 @@
export * from "./defaults.js";
+export * from "./initTemplate.js";
export * from "./loader.js";
export * from "./resolver.js";
export * from "./types.js";
diff --git a/src/config/initTemplate.ts b/src/config/initTemplate.ts
new file mode 100644
index 0000000..6606b94
--- /dev/null
+++ b/src/config/initTemplate.ts
@@ -0,0 +1,38 @@
+import { access, writeFile } from "node:fs/promises";
+import { resolve } from "node:path";
+import { defaultConfigFileName } from "./defaults.js";
+
+
+const SCHEMA_URL = "https://fln.nesvet.dev/schema";
+
+const initTemplate = {
+ $schema: SCHEMA_URL,
+ output: "output.md",
+ excludePatterns: [] as string[],
+ includePatterns: [] as string[],
+ gitignore: true,
+ includeHidden: false,
+ maxFileSize: "10mb",
+ maxTotalSize: "0",
+ includeTree: true,
+ includeContents: true,
+ format: "md",
+ followSymlinks: false,
+ overwrite: false
+};
+
+export async function runInit(overwrite: boolean): Promise {
+ const configPath = resolve(process.cwd(), defaultConfigFileName);
+
+ if (!overwrite)
+ try {
+ await access(configPath);
+ console.error(`fln: ${defaultConfigFileName} already exists. Use --overwrite to replace.`);
+ process.exit(1);
+ } catch {}
+
+
+ const content = `${JSON.stringify(initTemplate, null, "\t")}\n`;
+ await writeFile(configPath, content);
+ console.info(`✓ Created ${defaultConfigFileName}`);
+}
diff --git a/src/config/loader.ts b/src/config/loader.ts
index dea050a..9baa77a 100644
--- a/src/config/loader.ts
+++ b/src/config/loader.ts
@@ -2,13 +2,27 @@ import { readFile } from "node:fs/promises";
import type { RawConfigFile } from "./types.js";
-export async function loadConfigFile(configPath: string): Promise {
+export type LoadConfigResult = {
+ config: RawConfigFile;
+ loaded: boolean;
+ parseError?: string;
+};
+
+export async function loadConfigFile(configPath: string): Promise {
try {
const content = await readFile(configPath, "utf8");
- const parsed = JSON.parse(content) as RawConfigFile;
+ const { $schema: _, ...parsed } = JSON.parse(content) as RawConfigFile;
+
+ return { config: parsed, loaded: true };
+ } catch (error) {
+ const typedError = error as { code?: string; message?: string };
+ if (typedError.code === "ENOENT")
+ return { config: {}, loaded: false };
+
+ const message = error instanceof SyntaxError ?
+ `Invalid JSON in ${configPath}: ${error.message}` :
+ `Failed to load ${configPath}: ${typedError.message}`;
- return parsed;
- } catch {
- return {};
+ return { config: {}, loaded: false, parseError: message };
}
}
diff --git a/src/config/resolver.ts b/src/config/resolver.ts
index e6222ea..62d2cf0 100644
--- a/src/config/resolver.ts
+++ b/src/config/resolver.ts
@@ -1,28 +1,28 @@
import { parseByteSize, type OutputFormat } from "../core/index.js";
-import { parseGeneratedDate } from "../infra/index.js";
-import { defaultMaximumFileSizeBytes } from "./defaults.js";
+import { parseGeneratedDate, resolveOption } from "../infra/index.js";
+import { defaultMaxFileSize } from "./defaults.js";
import type { FlnConfig, RawConfigFile } from "./types.js";
type ConfigOverrides = Partial> & Partial<{ format: string }>;
+ "maxFileSize" |
+ "maxTotalSize" |
+ "output" |
+ "overwrite"
+>> & Partial<{ bannerFile: string; format: string; footerFile: string; outputFile: string }>; // outputFile: TODO(major): remove
function parseOptionalSize(value: number | string | undefined): number | undefined {
if (value === undefined)
@@ -39,42 +39,57 @@ function getDefaultOutputFile(format: OutputFormat): string {
return format === "json" ? "output.json" : "output.md";
}
-
export function normalizeConfigFile(fileConfig: RawConfigFile): ConfigOverrides {
+ // TODO(major): remove outputFile fallback
+ const output = fileConfig.output ?? fileConfig.outputFile;
+
+ const gitignore = resolveOption(fileConfig, "gitignore", "useGitignore", "config file");
+ const maxFileSize = resolveOption(fileConfig, "maxFileSize", "maximumFileSizeBytes", "config file", v =>
+ parseOptionalSize(v as number | string | undefined)
+ );
+ const maxTotalSize = resolveOption(fileConfig, "maxTotalSize", "maximumTotalSizeBytes", "config file", v =>
+ parseOptionalSize(v as number | string | undefined)
+ );
+ const ansi = resolveOption(fileConfig, "ansi", "useAnsi", "config file");
+ const date = resolveOption(fileConfig, "date", "generatedDate", "config file");
+
return {
- outputFile: fileConfig.outputFile,
+ output,
overwrite: fileConfig.overwrite,
excludePatterns: fileConfig.excludePatterns,
includePatterns: fileConfig.includePatterns,
includeHidden: fileConfig.includeHidden,
- useGitignore: fileConfig.useGitignore,
- maximumFileSizeBytes: parseOptionalSize(fileConfig.maximumFileSizeBytes),
- maximumTotalSizeBytes: parseOptionalSize(fileConfig.maximumTotalSizeBytes),
+ gitignore,
+ maxFileSize,
+ maxTotalSize,
includeTree: fileConfig.includeTree,
includeContents: fileConfig.includeContents,
format: fileConfig.format,
followSymlinks: fileConfig.followSymlinks,
- useAnsi: fileConfig.useAnsi,
+ ansi,
logLevel: fileConfig.logLevel,
- generatedDate: fileConfig.generatedDate,
+ date,
banner: fileConfig.banner,
- footer: fileConfig.footer
+ bannerFile: fileConfig.bannerFile,
+ footer: fileConfig.footer,
+ footerFile: fileConfig.footerFile
};
}
export function resolveConfig(
- rootDirectory: string,
+ input: string,
fileConfig: ConfigOverrides,
userConfig: ConfigOverrides
): FlnConfig {
const format = resolveFormat(userConfig.format ?? fileConfig.format);
- const outputFile = userConfig.outputFile ?? fileConfig.outputFile ?? getDefaultOutputFile(format);
- const rawGeneratedDate = userConfig.generatedDate ?? fileConfig.generatedDate;
- const generatedDate = rawGeneratedDate === undefined ? undefined : parseGeneratedDate(rawGeneratedDate);
+ // TODO(major): remove outputFile fallback
+ const output = userConfig.output ?? userConfig.outputFile ?? fileConfig.output ?? fileConfig.outputFile ?? getDefaultOutputFile(format);
+ const rawDate = userConfig.date ?? fileConfig.date;
+ const date = rawDate === undefined ? undefined : parseGeneratedDate(rawDate);
return {
- rootDirectory,
- outputFile,
+ input,
+ output,
overwrite: userConfig.overwrite ?? fileConfig.overwrite ?? false,
excludePatterns: [
...(fileConfig.excludePatterns ?? []),
@@ -86,17 +101,19 @@ export function resolveConfig(
],
excludedPaths: [],
includeHidden: userConfig.includeHidden ?? fileConfig.includeHidden ?? false,
- useGitignore: userConfig.useGitignore ?? fileConfig.useGitignore ?? true,
- maximumFileSizeBytes: userConfig.maximumFileSizeBytes ?? fileConfig.maximumFileSizeBytes ?? defaultMaximumFileSizeBytes,
- maximumTotalSizeBytes: userConfig.maximumTotalSizeBytes ?? fileConfig.maximumTotalSizeBytes ?? 0,
+ gitignore: userConfig.gitignore ?? fileConfig.gitignore ?? true,
+ maxFileSize: userConfig.maxFileSize ?? fileConfig.maxFileSize ?? defaultMaxFileSize,
+ maxTotalSize: userConfig.maxTotalSize ?? fileConfig.maxTotalSize ?? 0,
includeContents: userConfig.includeContents ?? fileConfig.includeContents ?? true,
includeTree: userConfig.includeTree ?? fileConfig.includeTree ?? true,
format,
followSymlinks: userConfig.followSymlinks ?? fileConfig.followSymlinks ?? false,
- useAnsi: userConfig.useAnsi ?? fileConfig.useAnsi ?? true,
+ ansi: userConfig.ansi ?? fileConfig.ansi ?? true,
logLevel: userConfig.logLevel ?? fileConfig.logLevel ?? "normal",
- generatedDate,
+ date,
banner: userConfig.banner ?? fileConfig.banner,
- footer: userConfig.footer ?? fileConfig.footer
+ bannerFile: userConfig.bannerFile ?? fileConfig.bannerFile,
+ footer: userConfig.footer ?? fileConfig.footer,
+ footerFile: userConfig.footerFile ?? fileConfig.footerFile
};
}
diff --git a/src/config/types.ts b/src/config/types.ts
index 6d66c70..4a8d375 100644
--- a/src/config/types.ts
+++ b/src/config/types.ts
@@ -2,43 +2,54 @@ import type { LogLevel, OutputFormat } from "../core/index.js";
export type RawConfigFile = Partial<{
- outputFile: string;
+ $schema: string;
+ output: string;
+ outputFile: string; // TODO(major): remove outputFile fallback
overwrite: boolean;
excludePatterns: string[];
includePatterns: string[];
includeHidden: boolean;
- useGitignore: boolean;
- maximumFileSizeBytes: number | string;
- maximumTotalSizeBytes: number | string;
+ gitignore: boolean;
+ useGitignore: boolean; // deprecated
+ maxFileSize: number | string;
+ maximumFileSizeBytes: number | string; // deprecated
+ maxTotalSize: number | string;
+ maximumTotalSizeBytes: number | string; // deprecated
includeTree: boolean;
includeContents: boolean;
format: OutputFormat;
followSymlinks: boolean;
- useAnsi: boolean;
+ ansi: boolean;
+ useAnsi: boolean; // deprecated
logLevel: LogLevel;
- generatedDate: string;
+ date: string;
+ generatedDate: string; // deprecated
banner: string;
+ bannerFile: string;
footer: string;
+ footerFile: string;
}>;
export type FlnConfig = {
- rootDirectory: string;
- outputFile: string;
+ input: string;
+ output: string;
overwrite: boolean;
excludePatterns: string[];
includePatterns: string[];
excludedPaths: string[];
includeHidden: boolean;
- useGitignore: boolean;
- maximumFileSizeBytes: number;
- maximumTotalSizeBytes: number;
+ gitignore: boolean;
+ maxFileSize: number;
+ maxTotalSize: number;
includeTree: boolean;
includeContents: boolean;
format: OutputFormat;
followSymlinks: boolean;
- useAnsi: boolean;
+ ansi: boolean;
logLevel: LogLevel;
- generatedDate?: string;
+ date?: string;
banner?: string;
+ bannerFile?: string;
footer?: string;
+ footerFile?: string;
};
diff --git a/src/config/utils.ts b/src/config/utils.ts
index cec3cc7..d7c8d70 100644
--- a/src/config/utils.ts
+++ b/src/config/utils.ts
@@ -1,15 +1,12 @@
import { readFile, stat } from "node:fs/promises";
import { basename, join, parse } from "node:path";
+import { hasTrailingSeparator, isNullishOutput } from "../path/index.js";
export function normalizeFileToken(rawValue: string): string {
return rawValue
.trim()
- .replaceAll("@", "")
- .replaceAll("/", "-")
- .replaceAll("\\", "-")
- .replaceAll(" ", "-")
- .replaceAll(/[^\w.-]/g, "-")
+ .replaceAll(/[^\w.-]+/g, "-")
.replaceAll(/-+/g, "-")
.replaceAll(/^[.-]+|[.-]+$/g, "");
}
@@ -23,35 +20,9 @@ async function readTextFile(filePath: string): Promise {
}
}
-function extractTomlValue(content: string, sectionName: string, key: string): string | undefined {
- const lines = content.split("\n");
- let isInSection = false;
-
- for (const rawLine of lines) {
- const trimmedLine = rawLine.split("#")[0]?.trim() ?? "";
- if (trimmedLine === "")
- continue;
-
- if (trimmedLine.startsWith("[") && trimmedLine.endsWith("]")) {
- isInSection = trimmedLine === `[${sectionName}]`;
- continue;
- }
-
- if (!isInSection)
- continue;
-
- const match = trimmedLine.match(new RegExp(String.raw`^${key}\s*=\s*["'](.+)["']\s*$`));
- if (match)
- return match[1];
- }
-
- return undefined;
-}
-
-
-export async function getProjectMetadata(rootDirectory: string): Promise<{ name: string; version?: string }> {
+export async function getProjectMetadata(input: string): Promise<{ name: string; version?: string }> {
// Node.js (package.json)
- const packageJsonContent = await readTextFile(join(rootDirectory, "package.json"));
+ const packageJsonContent = await readTextFile(join(input, "package.json"));
if (packageJsonContent)
try {
const packageJson = JSON.parse(packageJsonContent) as { name?: string; version?: string };
@@ -65,7 +36,7 @@ export async function getProjectMetadata(rootDirectory: string): Promise<{ name:
} catch {}
// C++ Modern (vcpkg.json)
- const vcpkgContent = await readTextFile(join(rootDirectory, "vcpkg.json"));
+ const vcpkgContent = await readTextFile(join(input, "vcpkg.json"));
if (vcpkgContent)
try {
const vcpkg = JSON.parse(vcpkgContent) as { name?: string; version?: string };
@@ -78,13 +49,43 @@ export async function getProjectMetadata(rootDirectory: string): Promise<{ name:
};
} catch {}
+ // Java/Kotlin (pom.xml)
+ const pomContent = await readTextFile(join(input, "pom.xml"));
+ if (pomContent) {
+ const projectSection = pomContent
+ .replace(//i, "");
+ const artifactIdMatch = projectWithoutParent.match(/\s*([^\s<]+)\s*<\/artifactid>/i);
+ if (artifactIdMatch) {
+ const normalizedName = normalizeFileToken(artifactIdMatch[1]);
+ if (normalizedName) {
+ const directVersionMatch = projectSection
+ .replace(//i, "")
+ .match(/\s*([^\s$<][^\s<]*)\s*<\/version>/i);
+ const parentVersionMatch = projectSection.match(
+ /\s*([^\s$<][^\s<]*)\s*<\/version>[\S\s]*?<\/parent>/i
+ );
+ const rawVersion = directVersionMatch?.[1] ?? parentVersionMatch?.[1];
+ const normalizedVersion = rawVersion ? normalizeFileToken(rawVersion) : "";
+
+ return {
+ name: normalizedName,
+ ...(normalizedVersion && { version: normalizedVersion })
+ };
+ }
+ }
+ }
+
// Python (pyproject.toml)
- const pyprojectContent = await readTextFile(join(rootDirectory, "pyproject.toml"));
+ const pyprojectContent = await readTextFile(join(input, "pyproject.toml"));
if (pyprojectContent) {
- const pythonName = extractTomlValue(pyprojectContent, "project", "name") ??
- extractTomlValue(pyprojectContent, "tool.poetry", "name");
- const pythonVersion = extractTomlValue(pyprojectContent, "project", "version") ??
- extractTomlValue(pyprojectContent, "tool.poetry", "version");
+ const pythonName = pyprojectContent.match(/^\[project][^[]*?^name\s*=\s*["']([^\n\r"']+)["']/ms)?.[1] ??
+ pyprojectContent.match(/^\[tool\.poetry][^[]*?^name\s*=\s*["']([^\n\r"']+)["']/ms)?.[1];
+ const pythonVersion = pyprojectContent.match(/^\[project][^[]*?^version\s*=\s*["']([^\n\r"']+)["']/ms)?.[1] ??
+ pyprojectContent.match(/^\[tool\.poetry][^[]*?^version\s*=\s*["']([^\n\r"']+)["']/ms)?.[1];
const normalizedName = pythonName ? normalizeFileToken(pythonName) : "";
const normalizedVersion = pythonVersion ? normalizeFileToken(pythonVersion) : "";
@@ -96,10 +97,10 @@ export async function getProjectMetadata(rootDirectory: string): Promise<{ name:
}
// Rust (Cargo.toml)
- const cargoContent = await readTextFile(join(rootDirectory, "Cargo.toml"));
+ const cargoContent = await readTextFile(join(input, "Cargo.toml"));
if (cargoContent) {
- const rustName = extractTomlValue(cargoContent, "package", "name");
- const rustVersion = extractTomlValue(cargoContent, "package", "version");
+ const rustName = cargoContent.match(/^\[package][^[]*?^name\s*=\s*["']([^\n\r"']+)["']/ms)?.[1];
+ const rustVersion = cargoContent.match(/^\[package][^[]*?^version\s*=\s*["']([^\n\r"']+)["']/ms)?.[1];
const normalizedName = rustName ? normalizeFileToken(rustName) : "";
const normalizedVersion = rustVersion ? normalizeFileToken(rustVersion) : "";
@@ -111,7 +112,7 @@ export async function getProjectMetadata(rootDirectory: string): Promise<{ name:
}
// Go (go.mod)
- const goModContent = await readTextFile(join(rootDirectory, "go.mod"));
+ const goModContent = await readTextFile(join(input, "go.mod"));
if (goModContent) {
const match = goModContent.match(/^module\s+(.+)$/m);
@@ -128,7 +129,7 @@ export async function getProjectMetadata(rootDirectory: string): Promise<{ name:
}
// C++ Legacy/Standard (CMakeLists.txt)
- const cmakeContent = await readTextFile(join(rootDirectory, "CMakeLists.txt"));
+ const cmakeContent = await readTextFile(join(input, "CMakeLists.txt"));
if (cmakeContent) {
const nameMatch = cmakeContent.match(/project\s*\(\s*([\w.-]+)/i);
const versionMatch = cmakeContent.match(/version\s+([\d.]+)/i);
@@ -144,7 +145,7 @@ export async function getProjectMetadata(rootDirectory: string): Promise<{ name:
}
return {
- name: normalizeFileToken(basename(rootDirectory)) || "project"
+ name: normalizeFileToken(basename(input)) || "project"
};
}
@@ -179,32 +180,37 @@ async function resolveUniquePath(filePath: string, overwrite: boolean): Promise<
}
}
-
export async function resolveOutputPath(
outputValue: string | undefined,
- rootDirectory: string,
+ input: string,
+ projectMetadata: { name: string; version?: string },
overwrite: boolean,
format: "json" | "md"
): Promise {
- const projectMeta = await getProjectMetadata(rootDirectory);
- const baseFileName = projectMeta.version ?
- `${projectMeta.name}-${projectMeta.version}.${format}` :
- `${projectMeta.name}.${format}`;
+ const baseFileName = projectMetadata.version ?
+ `${projectMetadata.name}-${projectMetadata.version}.${format}` :
+ `${projectMetadata.name}.${format}`;
if (!outputValue)
- return await resolveUniquePath(join(rootDirectory, baseFileName), overwrite);
+ return await resolveUniquePath(join(input, baseFileName), overwrite);
- if (outputValue === "/dev/null" || outputValue === "nul")
+ if (outputValue === "-")
+ return "-";
+
+ if (isNullishOutput(outputValue))
return outputValue;
- const hasTrailingSeparator = /[/\\]+$/.test(outputValue);
+ const hasTrailingSep = hasTrailingSeparator(outputValue);
const outputStats = await tryStat(outputValue);
- if (hasTrailingSeparator || outputStats?.isDirectory()) {
+ if (hasTrailingSep || outputStats?.isDirectory()) {
const filePath = join(outputValue, baseFileName);
return await resolveUniquePath(filePath, overwrite);
}
- return await resolveUniquePath(outputValue, overwrite);
+ const hasRealExtension = /\.[A-Za-z]+$/.test(outputValue);
+ const filePath = hasRealExtension ? outputValue : `${outputValue}.${format}`;
+
+ return await resolveUniquePath(filePath, overwrite);
}
diff --git a/src/core/ignoreMatcher.ts b/src/core/ignoreMatcher.ts
index 970d573..a9cc858 100644
--- a/src/core/ignoreMatcher.ts
+++ b/src/core/ignoreMatcher.ts
@@ -1,14 +1,20 @@
-import { constants } from "node:fs";
-import { access, readFile } from "node:fs/promises";
-import { join, relative, sep } from "node:path";
+import { readFile } from "node:fs/promises";
+import { join, relative } from "node:path";
import ignore from "ignore";
+import {
+ stripLeadingDotSlash,
+ toDisplayPath,
+ toIgnoreSafePath,
+ toPosixPath
+} from "../path/index.js";
+import { normalizeExcludePattern } from "../pattern/index.js";
import type { Logger } from "../infra/index.js";
type IgnoreMatcherOptions = {
- rootDirectory: string;
+ input: string;
excludePatterns: string[];
- useGitignore: boolean;
+ gitignore: boolean;
logger?: Logger;
};
@@ -25,15 +31,6 @@ const defaultIgnorePatterns = [
"pnpm-lock.yaml"
];
-function normalizeRelativePath(relativePath: string): string {
- const normalized = relativePath.split(sep).join("/");
-
- if (normalized.startsWith("./"))
- return normalized.slice(2);
-
- return normalized;
-}
-
function convertGitignorePattern(pattern: string, relativeDirectory: string): string | undefined {
const trimmed = pattern.trim();
if (trimmed === "" || trimmed.startsWith("#"))
@@ -43,7 +40,7 @@ function convertGitignorePattern(pattern: string, relativeDirectory: string): st
const rawPattern = isEscaped ? trimmed.slice(1) : trimmed;
const isNegated = !isEscaped && rawPattern.startsWith("!");
const patternBody = isNegated ? rawPattern.slice(1) : rawPattern;
- const normalizedDirectory = normalizeRelativePath(relativeDirectory);
+ const normalizedDirectory = stripLeadingDotSlash(toPosixPath(relativeDirectory));
const prefix = normalizedDirectory === "" ? "" : `${normalizedDirectory}/`;
if (patternBody === "")
@@ -63,44 +60,44 @@ function convertGitignorePattern(pattern: string, relativeDirectory: string): st
return isNegated ? `!${convertedPattern}` : convertedPattern;
}
-function normalizeExcludePattern(pattern: string): string {
- const normalized = pattern.trim();
- const isNegated = normalized.startsWith("!");
- const body = isNegated ? normalized.slice(1) : normalized;
- const trimmedTrailingSlash = body.endsWith("/") ? body.slice(0, -1) : body;
-
- if (body.startsWith("/"))
- return isNegated ? `!${body.slice(1)}` : body.slice(1);
-
- const result = trimmedTrailingSlash.includes("/") ? body : `**/${body}`;
-
- return isNegated ? `!${result}` : result;
-}
-
export class IgnoreMatcher {
- #rootDirectory: string;
- #useGitignore: boolean;
+ #input: string;
+ #gitignore: boolean;
#logger?: Logger;
#processedGitignore = new Set();
#matcher = ignore();
constructor(options: IgnoreMatcherOptions) {
- this.#rootDirectory = options.rootDirectory;
- this.#useGitignore = options.useGitignore;
+ this.#input = options.input;
+ this.#gitignore = options.gitignore;
this.#logger = options.logger;
- this.#matcher.add(defaultIgnorePatterns.map(pattern => normalizeExcludePattern(pattern)));
- this.#matcher.add(options.excludePatterns.map(pattern => normalizeExcludePattern(pattern)));
+ const defaultPatterns = defaultIgnorePatterns
+ .map(pattern => normalizeExcludePattern(pattern, this.#input))
+ .filter((p): p is string => p !== null);
+ this.#matcher.add(defaultPatterns);
+
+ const userPatterns = options.excludePatterns
+ .map(pattern => normalizeExcludePattern(pattern, this.#input))
+ .filter((p): p is string => p !== null);
+ this.#matcher.add(userPatterns);
}
public ignores(relativePath: string): boolean {
- const normalized = normalizeRelativePath(relativePath);
+ const safe = toIgnoreSafePath(relativePath, this.#input);
- return normalized !== "" && this.#matcher.ignores(normalized);
+ return this.ignoresSafePath(safe);
+ }
+
+ public ignoresSafePath(safePath: string | null): boolean {
+ if (safePath === null || safePath === "")
+ return false;
+
+ return this.#matcher.ignores(safePath);
}
public async addGitignoreForDirectory(directoryPath: string): Promise {
- if (!this.#useGitignore)
+ if (!this.#gitignore)
return;
if (this.#processedGitignore.has(directoryPath))
@@ -109,15 +106,17 @@ export class IgnoreMatcher {
this.#processedGitignore.add(directoryPath);
const gitignorePath = join(directoryPath, ".gitignore");
- const relativeDirectory = relative(this.#rootDirectory, directoryPath);
+ const relativeDirectory = relative(this.#input, directoryPath);
+ let content: string;
try {
- await access(gitignorePath, constants.F_OK);
- } catch {
+ content = await readFile(gitignorePath, "utf8");
+ } catch (error) {
+ if ((error as { code?: string }).code !== "ENOENT")
+ this.#logger?.debug(`Failed to read .gitignore at ${gitignorePath}: ${String(error)}`);
+
return;
}
-
- const content = await readFile(gitignorePath, "utf8");
const patterns = content
.split("\n")
.map(line => convertGitignorePattern(line, relativeDirectory))
@@ -125,7 +124,7 @@ export class IgnoreMatcher {
if (patterns.length > 0) {
this.#matcher.add(patterns);
- this.#logger?.debug(`Loaded ${patterns.length} patterns from ${normalizeRelativePath(relativeDirectory) || "."}/.gitignore`);
+ this.#logger?.debug(`Loaded ${patterns.length} patterns from ${toDisplayPath(relativeDirectory, this.#input)}/.gitignore`);
}
}
}
diff --git a/src/core/renderOutput.ts b/src/core/renderOutput.ts
index fd4475f..7c13d64 100644
--- a/src/core/renderOutput.ts
+++ b/src/core/renderOutput.ts
@@ -68,42 +68,47 @@ async function writeMarkdownContent(
await writer.write("\n");
}
-function *iterateFileNodes(node: FileNode): Generator {
- if (node.type === "file")
- yield node;
-
- if (node.children)
- for (const child of node.children)
- yield* iterateFileNodes(child);
-}
-
-function filterSkippedNodes(node: FileNode): FileNode | undefined {
+function filterAndCollectFileNodes(node: FileNode): {
+ filtered: FileNode | undefined;
+ fileNodes: FileNode[];
+} {
if (node.skipReason)
- return undefined;
+ return { filtered: undefined, fileNodes: [] };
- if (!node.children || node.children.length === 0)
- return { ...node };
+ if (node.type === "file")
+ return { filtered: { ...node }, fileNodes: [ node ] };
+
+ const allFileNodes: FileNode[] = [];
+ const filteredChildren: FileNode[] = [];
+ for (const child of node.children ?? []) {
+ const { filtered, fileNodes } = filterAndCollectFileNodes(child);
+ allFileNodes.push(...fileNodes);
+ if (filtered)
+ filteredChildren.push(filtered);
+ }
- const children = node.children
- .map(child => filterSkippedNodes(child))
- .filter((child): child is FileNode => child !== undefined);
+ if (filteredChildren.length === 0)
+ return { filtered: undefined, fileNodes: allFileNodes };
- return { ...node, children };
+ return {
+ filtered: { ...node, children: filteredChildren },
+ fileNodes: allFileNodes
+ };
}
async function writeMarkdown(result: ScanResult, config: FlnConfig): Promise {
- const writer = await createOutputWriter(config.outputFile, config.maximumTotalSizeBytes);
- const outputRoot = filterSkippedNodes(result.root);
-
- if (!outputRoot)
- throw new Error("Root directory was skipped.");
+ const writer = await createOutputWriter(config.output, config.maxTotalSize);
+ const { filtered: outputRoot, fileNodes } = filterAndCollectFileNodes(result.root);
+ const effectiveRoot = outputRoot ?? { ...result.root, children: [] };
try {
- await writer.writeLine(``);
- await writer.writeLine("");
+ if (config.output !== "-") {
+ await writer.writeLine(``);
+ await writer.writeLine("");
+ }
await writer.writeLine(`# Codebase Snapshot: ${result.projectName}`);
await writer.writeLine("");
- await writer.writeLine(`Generated: ${config.generatedDate ?? formatDateTime()} `);
+ await writer.writeLine(`Generated: ${config.date ?? formatDateTime()} `);
await writer.writeLine(`Files: ${result.stats.files} | Directories: ${result.stats.directories}`);
await writer.writeLine("");
await writer.writeLine("---");
@@ -117,15 +122,15 @@ async function writeMarkdown(result: ScanResult, config: FlnConfig): Promise 0)
+ await writeMarkdownFiles(fileNodes, writer, config);
if (config.footer) {
await writer.writeLine("");
@@ -144,20 +149,18 @@ async function writeMarkdown(result: ScanResult, config: FlnConfig): Promise>,
renderConfig: FlnConfig
): Promise {
await outputWriter.writeLine("## Source Files");
await outputWriter.writeLine("");
- const fileNodes = Array.from(iterateFileNodes(rootNode));
-
for (let i = 0; i < fileNodes.length; i++) {
const node = fileNodes[i];
const language = getLanguageFromFilename(node.name);
const isLastFile = i === fileNodes.length - 1;
- const filePath = join(renderConfig.rootDirectory, node.path);
+ const filePath = join(renderConfig.input, node.path);
let fenceLength = 3;
if (!node.isBinary)
@@ -189,41 +192,41 @@ async function writeMarkdownFiles(
}
async function writeJson(result: ScanResult, config: FlnConfig): Promise {
- const writer = await createOutputWriter(config.outputFile, config.maximumTotalSizeBytes);
- const outputRoot = filterSkippedNodes(result.root);
-
- if (!outputRoot)
- throw new Error("Root directory was skipped.");
+ const writer = await createOutputWriter(config.output, config.maxTotalSize);
+ const { filtered: outputRoot, fileNodes } = filterAndCollectFileNodes(result.root);
+ const effectiveRoot = outputRoot ?? { ...result.root, children: [] };
try {
await writer.write("{");
await writer.write(`"version":${JSON.stringify(VERSION)}`);
- await writer.write(`,"generated":${JSON.stringify(config.generatedDate ?? formatDateTime())}`);
+ await writer.write(`,"generated":${JSON.stringify(config.date ?? formatDateTime())}`);
await writer.write(`,"projectName":${JSON.stringify(result.projectName)}`);
- await writer.write(`,"rootDirectory":${JSON.stringify(config.rootDirectory)}`);
- await writer.write(`,"stats":${JSON.stringify(result.stats)}`);
+ // TODO(major): remove rootDirectory from JSON output
+ await writer.write(`,"input":${JSON.stringify(config.input)}`);
+ await writer.write(`,"rootDirectory":${JSON.stringify(config.input)}`);
+ const { outputSizeBytes: _, outputTokenCount: __, ...statsForJson } = result.stats;
+ await writer.write(`,"stats":${JSON.stringify(statsForJson)}`);
await writer.write(`,"options":${JSON.stringify({
includeTree: config.includeTree,
includeContents: config.includeContents,
format: config.format,
- maximumFileSizeBytes: config.maximumFileSizeBytes,
- maximumTotalSizeBytes: config.maximumTotalSizeBytes,
+ maxFileSize: config.maxFileSize,
+ maxTotalSize: config.maxTotalSize,
includeHidden: config.includeHidden,
- useGitignore: config.useGitignore,
+ gitignore: config.gitignore,
excludePatterns: config.excludePatterns,
includePatterns: config.includePatterns,
followSymlinks: config.followSymlinks,
banner: config.banner,
footer: config.footer
})}`);
- await writer.write(`,"tree":${JSON.stringify(outputRoot)}`);
- await writer.write(`,"stats":${JSON.stringify(result.stats)}`);
+ await writer.write(`,"tree":${JSON.stringify(effectiveRoot)}`);
if (config.includeContents) {
await writer.write(",\"files\":[");
let isFirst = true;
- for (const node of iterateFileNodes(outputRoot)) {
+ for (const node of fileNodes) {
if (!isFirst)
await writer.write(",");
@@ -234,14 +237,11 @@ async function writeJson(result: ScanResult, config: FlnConfig): Promise {
await writer.write(`,"language":${JSON.stringify(getLanguageFromFilename(node.name))}`);
await writer.write(`,"isBinary":${JSON.stringify(Boolean(node.isBinary))}`);
- if (node.skipReason)
- await writer.write(`,"skipReason":${JSON.stringify(node.skipReason)}`);
-
- if (node.isBinary || node.skipReason)
+ if (node.isBinary)
await writer.write(",\"content\":null");
else
try {
- const filePath = join(config.rootDirectory, node.path);
+ const filePath = join(config.input, node.path);
const content = await readFile(filePath, "utf8");
await writer.write(`,"content":${JSON.stringify(content)}`);
diff --git a/src/core/scanTree.ts b/src/core/scanTree.ts
index fe7fc04..1e20feb 100644
--- a/src/core/scanTree.ts
+++ b/src/core/scanTree.ts
@@ -10,6 +10,9 @@ import { cpus } from "node:os";
import { relative, sep } from "node:path";
import type { Dirent } from "node:fs";
import ignore from "ignore";
+import pLimit from "p-limit";
+import { toCanonicalRelative, toIgnoreSafePath, toPosixPath } from "../path/index.js";
+import { normalizeIncludePattern } from "../pattern/index.js";
import type { Logger } from "../infra/index.js";
import { IgnoreMatcher } from "./ignoreMatcher.js";
import type {
@@ -21,13 +24,6 @@ import type {
} from "./types.js";
-function normalizePathSegment(pathSegment: string): string {
- if (sep === "/")
- return pathSegment;
-
- return pathSegment.split(sep).join("/");
-}
-
function getFileScore(fileName: string): number {
const lowerName = fileName.toLowerCase();
@@ -44,6 +40,7 @@ function getFileScore(fileName: string): number {
lowerName === "makefile" ||
lowerName === "dockerfile" ||
lowerName === "vcpkg.json" ||
+ lowerName === "pom.xml" ||
lowerName.startsWith(".env") ||
lowerName.includes(".config.") ||
lowerName.startsWith(".prettier") ||
@@ -93,38 +90,23 @@ function getFileScore(fileName: string): number {
return 10;
}
-async function isBinaryFile(filePath: string, fileSize: number): Promise {
+async function inspectFile(filePath: string, fileSize: number): Promise<{
+ isGenerated: boolean;
+ isBinary: boolean;
+}> {
if (fileSize === 0)
- return false;
+ return { isGenerated: false, isBinary: false };
const handle = await open(filePath, "r");
try {
const buffer = Buffer.alloc(Math.min(512, fileSize));
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
+ const header = buffer.toString("utf8", 0, Math.min(100, bytesRead));
- for (let index = 0; index < bytesRead; index++)
- if (buffer[index] === 0)
- return true;
+ const isGenerated = header.includes("]