lsallcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,13 @@
1
+ DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
2
+ Version 2, December 2004
3
+
4
+ Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
5
+
6
+ Everyone is permitted to copy and distribute verbatim or modified
7
+ copies of this license document, and changing it is allowed as long
8
+ as the name is changed.
9
+
10
+ DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
11
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
12
+
13
+ 0. You just DO WHAT THE FUCK YOU WANT TO.
package/README.md ADDED
@@ -0,0 +1,204 @@
1
+ # lsallcp
2
+
3
+ > Recursively dump every file in a directory as colorized, fenced-code blocks -- with smart skip patterns, a live progress bar, and one-flag clipboard export. Built on [`cli-atelier`](https://www.npmjs.com/package/cli-atelier), runs on [Bun](https://bun.sh).
4
+
5
+ ```bash
6
+ bun add -g lsallcp
7
+ ```
8
+
9
+ ---
10
+
11
+ ## What it does
12
+
13
+ `lsallcp` walks a directory tree and prints every file it finds as a labeled, syntax-tagged code block:
14
+
15
+ ```
16
+ src/index.ts:
17
+ <code>ts
18
+ console.log("hello")
19
+ </code>
20
+ ```
21
+
22
+ It's built for quickly dumping a project's source into a chat window, a doc, or a clipboard -- for code review, LLM prompts, documentation, or debugging -- without manually opening and copy-pasting dozens of files.
23
+
24
+ ---
25
+
26
+ ## Features
27
+
28
+ | Feature | Description |
29
+ | ------------------------------------------------- | --------------------------------------------------------------------------- |
30
+ | Recursive listing | Walks the full directory tree from any starting path |
31
+ | Smart skip patterns | Skip by exact name, glob (`*.png`), or regex literal (`/\.test\.ts$/i`) |
32
+ | True recursive pruning | A matched directory is never descended into -- not just filtered afterward |
33
+ | Binary-safe | Detects binary files and shows a placeholder instead of dumping raw bytes |
34
+ | Live progress bar | Real-time progress while files are read, powered by `cli-atelier` |
35
+ | Colorized output | Paths, fences, and log messages are colored for readability |
36
+ | Clipboard export | `-c` / `--copy` copies plain-text results directly to your system clipboard |
37
+ | Zero runtime dependencies (besides `cli-atelier`) | No bundler, no build step -- pure Bun + TypeScript |
38
+ | Cross-platform | Works in Windows Terminal, Git Bash, macOS Terminal, and Linux shells |
39
+
40
+ ---
41
+
42
+ ## Requirements
43
+
44
+ | Dependency | Minimum version | Notes |
45
+ | ---------- | --------------- | ------------------------------------------------------------ |
46
+ | Bun | `>= 1.1.0` | Required at runtime -- the CLI executes as raw `.ts` via Bun |
47
+ | Node.js | `>= 18.0.0` | Only needed if installing globally via `npm`/`pnpm`/`yarn` |
48
+
49
+ > `lsallcp` always executes through Bun. When installed via `npm install -g`, the generated shim (`bin/lsallcp.js`) runs under Node just long enough to spawn Bun as a subprocess -- this is what makes the same package work identically in Windows Terminal, Git Bash, macOS, and Linux.
50
+
51
+ ---
52
+
53
+ ## Installation
54
+
55
+ ```bash
56
+ # Bun (recommended -- installs and runs natively)
57
+ bun add -g lsallcp
58
+
59
+ # npm / pnpm / yarn (cross-platform shim, still requires Bun on PATH)
60
+ npm install -g lsallcp
61
+ pnpm add -g lsallcp
62
+ yarn global add lsallcp
63
+ ```
64
+
65
+ To use it inside a single project instead of globally:
66
+
67
+ ```bash
68
+ bun add -d lsallcp
69
+ bunx lsallcp .
70
+ ```
71
+
72
+ ---
73
+
74
+ ## Usage
75
+
76
+ ```bash
77
+ lsallcp <path> [skipPattern...] [--copy | -c]
78
+ ```
79
+
80
+ | Argument | Required | Description |
81
+ | -------------- | -------- | -------------------------------------------------------------- |
82
+ | `<path>` | Yes | Directory to scan, e.g. `.`, `./src`, `../my-project` |
83
+ | `skipPattern` | No | Zero or more names/globs/regexes to skip, recursively |
84
+ | `--copy`, `-c` | No | Copy plain-text output to the clipboard instead of printing it |
85
+
86
+ Flags can appear anywhere after the path -- order doesn't matter.
87
+
88
+ ### Basic examples
89
+
90
+ ```bash
91
+ # List every file in the current directory
92
+ lsallcp .
93
+
94
+ # Skip an exact folder name (matched at any depth)
95
+ lsallcp . node_modules
96
+
97
+ # Skip multiple exact names at once
98
+ lsallcp . node_modules dist .env
99
+
100
+ # Skip by glob pattern
101
+ lsallcp . *.png *.lock
102
+
103
+ # Skip by regex literal (wrap in slashes, flags optional)
104
+ lsallcp . /\.test\.ts$/i
105
+
106
+ # Combine skip types freely
107
+ lsallcp . node_modules *.png /\.spec\.ts$/
108
+
109
+ # Copy the result to your clipboard instead of printing it
110
+ lsallcp . --copy
111
+ lsallcp . node_modules -c
112
+ ```
113
+
114
+ ---
115
+
116
+ ## Skip patterns
117
+
118
+ Every skip argument is compiled into a matcher and checked against both the file/folder's **basename** and its **relative path**. A match on a directory prunes the entire subtree -- files inside are never even read.
119
+
120
+ | Pattern type | Syntax | Example | Matches |
121
+ | ------------- | ------------------- | --------------------- | ---------------------------------------- |
122
+ | Exact name | plain string | `node_modules` | Any file/folder named exactly this |
123
+ | Glob | contains `*` or `?` | `*.png`, `**/dist/**` | Shell-style wildcard match |
124
+ | Regex literal | `/pattern/flags` | `/\.test\.ts$/i` | Full regular expression, flags supported |
125
+
126
+ Glob rules:
127
+
128
+ - `*` matches any characters except `/`
129
+ - `**` matches any characters, including `/`
130
+ - `?` matches exactly one character (not `/`)
131
+
132
+ `.git` is **always** skipped automatically, regardless of the patterns you pass.
133
+
134
+ ---
135
+
136
+ ## Clipboard export
137
+
138
+ Passing `-c` or `--copy` copies the full, ANSI-free plain-text result directly to your system clipboard and suppresses the normal terminal printout -- you'll only see the scan log, progress bar, and a final confirmation:
139
+
140
+ ```bash
141
+ lsallcp . node_modules --copy
142
+ ```
143
+
144
+ ```
145
+ ℹ Target: /Users/<nolly>/projects/my-app
146
+ ℹ Skip patterns: node_modules
147
+ ℹ Clipboard: enabled (content will not be printed)
148
+ ✔ Found 214 file(s) after skipping
149
+ ✔ All files read
150
+ ✔ Listed 214 file(s) from /Users/<nolly>/projects/my-app
151
+ ✔ Result copied to clipboard
152
+ ```
153
+
154
+ Clipboard support is zero-dependency and platform-native:
155
+
156
+ | Platform | Tool used |
157
+ | -------- | ------------------------------------------- |
158
+ | Windows | `clip` (built-in, also works from Git Bash) |
159
+ | macOS | `pbcopy` (built-in) |
160
+ | Linux | `wl-copy` → `xclip` → `xsel`, first found |
161
+
162
+ If none of these tools are available on Linux, `lsallcp` prints a warning and leaves the terminal output untouched.
163
+
164
+ ---
165
+
166
+ ## Output format
167
+
168
+ Each file is rendered as:
169
+
170
+ <relativePath>:
171
+ <code><extension>
172
+ <file content>
173
+ </code>
174
+
175
+ - `<relativePath>` is colored cyan and bold, relative to the scanned root, using forward slashes on all platforms.
176
+ - `<extension>` is the file's extension without the leading dot (`ts`, `json`, `md`); extensionless files fall back to `text`.
177
+ - Binary files render as a dimmed, italic `[binary file - content skipped]` placeholder instead of raw bytes.
178
+ - Unreadable files (permissions, broken symlinks, etc.) render a red `[unreadable: <reason>]` placeholder instead of crashing the whole run.
179
+
180
+ ---
181
+
182
+ ## Exit behavior
183
+
184
+ - Directory scanning never aborts on a single unreadable subdirectory -- it logs a warning and continues.
185
+ - If the target path doesn't exist or isn't a directory, `lsallcp` exits with an error and a non-zero status code.
186
+ - If no files remain after skip patterns are applied, `lsallcp` logs a warning and exits cleanly.
187
+
188
+ ---
189
+
190
+ ## How it works
191
+
192
+ `lsallcp` is a thin, purpose-built CLI in three stages:
193
+
194
+ 1. **Scan** -- recursively walks the target directory (`src/scanner.ts`), pruning any path matched by a compiled skip matcher (`src/matcher.ts`) before ever entering it.
195
+ 2. **Read** -- reads each remaining file, detects binary content (`src/binary.ts`), and formats it into a colorized fenced block (`src/render.ts`), updating a live progress bar as it goes.
196
+ 3. **Output** -- either prints the assembled result to stdout, or strips ANSI codes and sends it to the system clipboard (`src/clipboard.ts`) when `-c`/`--copy` is set.
197
+
198
+ All terminal UI -- headers, spinners, progress bars, logs, boxes -- comes from [`cli-atelier`](https://www.npmjs.com/package/cli-atelier), a zero-dependency, pure-ANSI terminal framework for Bun.
199
+
200
+ ---
201
+
202
+ ## License
203
+
204
+ Released under the [WTFPL License](./LICENSE).
package/bin/lsallcp.js ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { spawnSync } = require('node:child_process')
4
+ const path = require('node:path')
5
+
6
+ const cliEntry = path.join(__dirname, '..', 'src', 'cli.ts')
7
+ const args = process.argv.slice(2)
8
+
9
+ const result = spawnSync('bun', [cliEntry, ...args], { stdio: 'inherit' })
10
+
11
+ if (result.error) {
12
+ console.error('lsallcp requires Bun to be installed and available on your PATH.')
13
+ console.error('Install it from https://bun.sh/ then try again.')
14
+ process.exit(1)
15
+ }
16
+
17
+ process.exit(result.status === null ? 1 : result.status)
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "lsallcp",
3
+ "version": "1.0.0",
4
+ "description": "Recursively list every file's content as relativePath: ```ext code fences```, with glob/regex/name skip patterns, progress bar and colored output",
5
+ "type": "module",
6
+ "bin": {
7
+ "lsallcp": "./bin/lsallcp.js"
8
+ },
9
+ "files": ["src", "bin", "package.json", "README.md", "LICENSE"],
10
+ "engines": {
11
+ "bun": ">=1.1.0"
12
+ },
13
+ "scripts": {
14
+ "start": "bun src/cli.ts"
15
+ },
16
+ "keywords": ["cli", "bun", "list", "files", "dump", "tree"],
17
+ "devDependencies": {
18
+ "@types/bun": "latest",
19
+ "typescript": "latest"
20
+ },
21
+ "dependencies": {
22
+ "cli-atelier": "^1.0.2"
23
+ },
24
+ "license": "WTFPL"
25
+ }
package/src/binary.ts ADDED
@@ -0,0 +1,5 @@
1
+ export function isBinaryBuffer(buffer: Buffer, sampleSize = 8000): boolean {
2
+ const length = Math.min(buffer.length, sampleSize)
3
+ for (let index = 0; index < length; index++) if (buffer[index] === 0) return true
4
+ return false
5
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import path from 'node:path'
4
+ import { promises as fs } from 'node:fs'
5
+ import { Display, Log, Header, Spinner, Progress, Box } from 'cli-atelier'
6
+ import { buildSkipMatchers } from './matcher'
7
+ import { scanDirectory } from './scanner'
8
+ import { renderFile } from './render'
9
+ import { copyToClipboard } from './clipboard'
10
+
11
+ const COPY_FLAGS = new Set(['--copy', '-c'])
12
+
13
+ function printHelp(): void {
14
+ Box.render(
15
+ [
16
+ Display.paint('lsallcp <path> [skipPattern...] [--copy|-c]', Display.color.white, Display.color.bold),
17
+ '',
18
+ ' lsallcp . List every file under the current dir',
19
+ ' lsallcp . node_modules Skip any file/folder named node_modules',
20
+ ' lsallcp . *.png *.lock Skip by glob pattern',
21
+ ' lsallcp . /\\.test\\.ts$/i Skip by regex literal',
22
+ " lsallcp . --copy List, copy to clipboard, don't print content",
23
+ " lsallcp . node_modules -c Skip + copy, flag order doesn't matter",
24
+ '',
25
+ Display.paint('Note:', Display.color.yellow) + ' .git is always skipped.',
26
+ ],
27
+ { style: 'round', color: Display.color.cyan, title: 'lsallcp' }
28
+ )
29
+ }
30
+
31
+ async function resolveRoot(targetArgument: string): Promise<string> {
32
+ const root = path.resolve(process.cwd(), targetArgument)
33
+ const stat = await fs.stat(root)
34
+ if (!stat.isDirectory()) throw new Error(`Target is not a directory: ${root}`)
35
+ return root
36
+ }
37
+
38
+ function parseArgs(argv: string[]): { targetArgument: string, skipArguments: string[], copy: boolean } {
39
+ let copy = false
40
+ const rest: string[] = []
41
+ for (const arg of argv) {
42
+ if (COPY_FLAGS.has(arg)) copy = true
43
+ else rest.push(arg)
44
+ }
45
+ const [targetArgument, ...skipArguments] = rest
46
+ return { targetArgument, skipArguments, copy }
47
+ }
48
+
49
+ async function main(): Promise<void> {
50
+ const argv = process.argv.slice(2)
51
+ if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
52
+ printHelp()
53
+ return
54
+ }
55
+ const { targetArgument, skipArguments, copy } = parseArgs(argv)
56
+ if (!targetArgument) {
57
+ printHelp()
58
+ return
59
+ }
60
+ Header.render('lsallcp', { style: 'banner', color: Display.color.magenta })
61
+ const root = await resolveRoot(targetArgument)
62
+ Log.info(`Target: ${Display.paint(root, Display.color.yellow)}`)
63
+ if (skipArguments.length > 0) Log.info(`Skip patterns: ${Display.paint(skipArguments.join(', '), Display.color.gray)}`)
64
+ if (copy) Log.info(`Clipboard: ${Display.paint('enabled', Display.color.green)} (content will not be printed)`)
65
+ const matchers = buildSkipMatchers(skipArguments)
66
+ const spinner = new Spinner('Walking directory tree...')
67
+ spinner.start()
68
+ const files = await scanDirectory(root, matchers)
69
+ spinner.stop(`Found ${files.length} file(s) after skipping`)
70
+ if (files.length === 0) {
71
+ Log.warn('No files matched. Nothing to list.')
72
+ return
73
+ }
74
+ const bar = new Progress({ label: 'Reading files', width: 40, color: Display.color.cyan })
75
+ const blocks: string[] = []
76
+ for (let index = 0; index < files.length; index++) {
77
+ blocks.push(await renderFile(files[index]))
78
+ bar.update(Math.round(((index + 1) / files.length) * 100))
79
+ }
80
+ bar.finish('All files read')
81
+ const output = blocks.join('\n\n')
82
+ if (!copy) {
83
+ Header.divider()
84
+ console.log(output)
85
+ Header.divider()
86
+ }
87
+ Log.success(`Listed ${files.length} file(s) from ${Display.paint(root, Display.color.yellow)}`)
88
+ if (copy) {
89
+ const plainOutput = Display.strip(output)
90
+ const copied = await copyToClipboard(plainOutput)
91
+ if (copied) Log.success('Result copied to clipboard')
92
+ else Log.warn('Could not copy to clipboard (no clip/pbcopy/wl-copy/xclip/xsel found)')
93
+ }
94
+ }
95
+
96
+ main().catch((error) => {
97
+ Log.error(error instanceof Error ? error.stack ?? error.message : String(error))
98
+ process.exit(1)
99
+ })
@@ -0,0 +1,37 @@
1
+ import { spawn } from 'node:child_process'
2
+
3
+ interface ClipboardCommand {
4
+ command: string
5
+ args: string[]
6
+ }
7
+
8
+ function candidateCommands(): ClipboardCommand[] {
9
+ switch (process.platform) {
10
+ case 'win32': return [{ command: 'clip', args: [] }]
11
+ case 'darwin': return [{ command: 'pbcopy', args: [] }]
12
+ default: return [
13
+ { command: 'wl-copy', args: [] },
14
+ { command: 'xclip', args: ['-selection', 'clipboard'] },
15
+ { command: 'xsel', args: ['--clipboard', '--input'] },
16
+ ]
17
+ }
18
+ }
19
+
20
+ function tryCommand(command: ClipboardCommand, text: string): Promise<boolean> {
21
+ return new Promise((resolve) => {
22
+ const child = spawn(command.command, command.args, { stdio: ['pipe', 'ignore', 'ignore'] })
23
+ child.once('error', () => resolve(false))
24
+ child.once('close', (code) => resolve(code === 0))
25
+ child.stdin.on('error', () => { })
26
+ child.stdin.write(text)
27
+ child.stdin.end()
28
+ })
29
+ }
30
+
31
+ export async function copyToClipboard(text: string): Promise<boolean> {
32
+ for (const command of candidateCommands()) {
33
+ const ok = await tryCommand(command, text)
34
+ if (ok) return true
35
+ }
36
+ return false
37
+ }
package/src/matcher.ts ADDED
@@ -0,0 +1,45 @@
1
+ export type SkipMatcher = (relativePath: string, baseName: string, isDirectory: boolean) => boolean
2
+
3
+ function defaultGitMatcher(baseName: string): boolean { return baseName === '.git' }
4
+
5
+ function parseRegexLiteral(raw: string): RegExp | null {
6
+ const match = raw.match(/^\/(.+)\/([a-z]*)$/i)
7
+ if (!match) return null
8
+ try { return new RegExp(match[1], match[2]) } catch { return null }
9
+ }
10
+
11
+ function globToRegExp(glob: string): RegExp {
12
+ let out = '^'
13
+ for (let index = 0; index < glob.length; index++) {
14
+ const char = glob[index]
15
+ if (char === '*') {
16
+ if (glob[index + 1] === '*') {
17
+ out += '.*'
18
+ index++
19
+ } else out += '[^/]*'
20
+ } else if (char === '?') out += '[^/]'
21
+ else if ('.+^$()[]{}|\\'.includes(char)) out += '\\' + char
22
+ else out += char
23
+ }
24
+ out += '$'
25
+ return new RegExp(out, 'i')
26
+ }
27
+
28
+ function compilePattern(raw: string): SkipMatcher {
29
+ const regexLiteral = parseRegexLiteral(raw)
30
+ if (regexLiteral) return (relativePath, baseName) => regexLiteral.test(baseName) || regexLiteral.test(relativePath)
31
+ if (/[*?]/.test(raw)) {
32
+ const compiled = globToRegExp(raw)
33
+ return (relativePath, baseName) => compiled.test(baseName) || compiled.test(relativePath)
34
+ }
35
+ const lowered = raw.toLowerCase()
36
+ return (_relativePath, baseName) => baseName.toLowerCase() === lowered
37
+ }
38
+
39
+ export function buildSkipMatchers(patterns: string[]): SkipMatcher[] {
40
+ return [defaultGitMatcher, ...patterns.map(compilePattern)]
41
+ }
42
+
43
+ export function shouldSkip(matchers: SkipMatcher[], relativePath: string, baseName: string, isDirectory: boolean): boolean {
44
+ return matchers.some((matcher) => matcher(relativePath, baseName, isDirectory))
45
+ }
package/src/render.ts ADDED
@@ -0,0 +1,26 @@
1
+ import path from 'node:path'
2
+ import { promises as fs } from 'node:fs'
3
+ import { Display } from 'cli-atelier'
4
+ import type { ScannedFile } from './scanner'
5
+ import { isBinaryBuffer } from './binary'
6
+
7
+ function resolveExtension(relativePath: string): string {
8
+ const extension = path.extname(relativePath).slice(1)
9
+ return extension.length > 0 ? extension : 'text'
10
+ }
11
+
12
+ export async function renderFile(file: ScannedFile): Promise<string> {
13
+ const extension = resolveExtension(file.relativePath)
14
+ let body: string
15
+ try {
16
+ const buffer = await fs.readFile(file.absolutePath)
17
+ if (isBinaryBuffer(buffer)) body = Display.paint('[binary file - content skipped]', Display.color.gray, Display.color.italic)
18
+ else body = buffer.toString('utf8')
19
+ } catch (error) {
20
+ body = Display.paint(`[unreadable: ${(error as Error).message}]`, Display.color.red)
21
+ }
22
+ const header = Display.paint(file.relativePath, Display.color.cyan, Display.color.bold) + ':'
23
+ const openFence = Display.paint('```' + extension, Display.color.gray)
24
+ const closeFence = Display.paint('```', Display.color.gray)
25
+ return `${header}\n${openFence}\n${body}\n${closeFence}`
26
+ }
package/src/scanner.ts ADDED
@@ -0,0 +1,37 @@
1
+ import path from 'node:path'
2
+ import { promises as fs } from 'node:fs'
3
+ import { Log } from 'cli-atelier'
4
+ import type { SkipMatcher } from './matcher'
5
+ import { shouldSkip } from './matcher'
6
+
7
+ export interface ScannedFile {
8
+ absolutePath: string
9
+ relativePath: string
10
+ }
11
+
12
+ export async function scanDirectory(root: string, matchers: SkipMatcher[]): Promise<ScannedFile[]> {
13
+ const results: ScannedFile[] = []
14
+ await walk(root, '', matchers, results)
15
+ results.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
16
+ return results
17
+ }
18
+
19
+ async function walk(directory: string, relativeBase: string, matchers: SkipMatcher[], results: ScannedFile[]): Promise<void> {
20
+ let entries: import('node:fs').Dirent[]
21
+ try {
22
+ entries = await fs.readdir(directory, { withFileTypes: true })
23
+ } catch (error) {
24
+ Log.warn(`Skipping unreadable directory: ${directory} (${(error as Error).message})`)
25
+ return
26
+ }
27
+ for (const entry of entries) {
28
+ const relativePath = relativeBase ? `${relativeBase}/${entry.name}` : entry.name
29
+ const isDirectory = entry.isDirectory()
30
+ if (shouldSkip(matchers, relativePath, entry.name, isDirectory)) continue
31
+ if (isDirectory) {
32
+ await walk(path.join(directory, entry.name), relativePath, matchers, results)
33
+ } else if (entry.isFile()) {
34
+ results.push({ absolutePath: path.join(directory, entry.name), relativePath })
35
+ }
36
+ }
37
+ }