lsallcp 1.0.0 → 2.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/README.md +17 -20
- package/dist/binary.js +7 -0
- package/dist/cli.js +96 -0
- package/dist/clipboard.js +30 -0
- package/dist/matcher.js +51 -0
- package/dist/render.js +26 -0
- package/dist/scanner.js +32 -0
- package/package.json +10 -7
- package/bin/lsallcp.js +0 -17
- package/src/binary.ts +0 -5
- package/src/cli.ts +0 -99
- package/src/clipboard.ts +0 -37
- package/src/matcher.ts +0 -45
- package/src/render.ts +0 -26
- package/src/scanner.ts +0 -37
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# lsallcp
|
|
2
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
|
|
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 Node.js.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
|
|
6
|
+
npm install -g lsallcp
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
---
|
|
@@ -34,39 +34,36 @@ It's built for quickly dumping a project's source into a chat window, a doc, or
|
|
|
34
34
|
| Live progress bar | Real-time progress while files are read, powered by `cli-atelier` |
|
|
35
35
|
| Colorized output | Paths, fences, and log messages are colored for readability |
|
|
36
36
|
| Clipboard export | `-c` / `--copy` copies plain-text results directly to your system clipboard |
|
|
37
|
-
| Zero runtime dependencies (besides `cli-atelier`) |
|
|
37
|
+
| Zero runtime dependencies (besides `cli-atelier`) | Ships precompiled JavaScript -- no separate build step at install time |
|
|
38
38
|
| Cross-platform | Works in Windows Terminal, Git Bash, macOS Terminal, and Linux shells |
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
42
|
## Requirements
|
|
43
43
|
|
|
44
|
-
| Dependency | Minimum version | Notes
|
|
45
|
-
| ---------- | --------------- |
|
|
46
|
-
|
|
|
47
|
-
| Node.js | `>= 18.0.0` | Only needed if installing globally via `npm`/`pnpm`/`yarn` |
|
|
44
|
+
| Dependency | Minimum version | Notes |
|
|
45
|
+
| ---------- | --------------- | ------------------------------------------------------- |
|
|
46
|
+
| Node.js | `>= 18.0.0` | Runs the compiled CLI directly, no other runtime needed |
|
|
48
47
|
|
|
49
|
-
|
|
48
|
+
`lsallcp` is a pure Node.js package. TypeScript sources are compiled to plain JavaScript with `tsc` and published under `dist/`, so no Bun, `ts-node`, or extra runtime is required to use it.
|
|
50
49
|
|
|
51
50
|
---
|
|
52
51
|
|
|
53
52
|
## Installation
|
|
54
53
|
|
|
55
54
|
```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
55
|
npm install -g lsallcp
|
|
56
|
+
# or
|
|
61
57
|
pnpm add -g lsallcp
|
|
58
|
+
# or
|
|
62
59
|
yarn global add lsallcp
|
|
63
60
|
```
|
|
64
61
|
|
|
65
62
|
To use it inside a single project instead of globally:
|
|
66
63
|
|
|
67
64
|
```bash
|
|
68
|
-
|
|
69
|
-
|
|
65
|
+
npm install -D lsallcp
|
|
66
|
+
npx lsallcp .
|
|
70
67
|
```
|
|
71
68
|
|
|
72
69
|
---
|
|
@@ -167,10 +164,10 @@ If none of these tools are available on Linux, `lsallcp` prints a warning and le
|
|
|
167
164
|
|
|
168
165
|
Each file is rendered as:
|
|
169
166
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
167
|
+
\<relativePath>:<br>
|
|
168
|
+
\<code>\<extension><br>
|
|
169
|
+
\<file content><br>
|
|
170
|
+
\</code>
|
|
174
171
|
|
|
175
172
|
- `<relativePath>` is colored cyan and bold, relative to the scanned root, using forward slashes on all platforms.
|
|
176
173
|
- `<extension>` is the file's extension without the leading dot (`ts`, `json`, `md`); extensionless files fall back to `text`.
|
|
@@ -189,13 +186,13 @@ Each file is rendered as:
|
|
|
189
186
|
|
|
190
187
|
## How it works
|
|
191
188
|
|
|
192
|
-
`lsallcp` is a thin, purpose-built CLI in three stages:
|
|
189
|
+
`lsallcp` is a thin, purpose-built CLI in three stages, written in TypeScript and compiled to JavaScript via `tsc` (`npm run build`, wired to `prepare` so it's built automatically on install from source):
|
|
193
190
|
|
|
194
191
|
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
192
|
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
193
|
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
194
|
|
|
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
|
|
195
|
+
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.
|
|
199
196
|
|
|
200
197
|
---
|
|
201
198
|
|
package/dist/binary.js
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
import { Display, Log, Header, Spinner, Progress, Box } from 'cli-atelier';
|
|
5
|
+
import { buildSkipMatchers } from './matcher.js';
|
|
6
|
+
import { scanDirectory } from './scanner.js';
|
|
7
|
+
import { renderFile } from './render.js';
|
|
8
|
+
import { copyToClipboard } from './clipboard.js';
|
|
9
|
+
const COPY_FLAGS = new Set(['--copy', '-c']);
|
|
10
|
+
function printHelp() {
|
|
11
|
+
Box.render([
|
|
12
|
+
Display.paint('lsallcp <path> [skipPattern...] [--copy|-c]', Display.color.white, Display.color.bold),
|
|
13
|
+
'',
|
|
14
|
+
' lsallcp . List every file under the current dir',
|
|
15
|
+
' lsallcp . node_modules Skip any file/folder named node_modules',
|
|
16
|
+
' lsallcp . *.png *.lock Skip by glob pattern',
|
|
17
|
+
' lsallcp . /\\.test\\.ts$/i Skip by regex literal',
|
|
18
|
+
" lsallcp . --copy List, copy to clipboard, don't print content",
|
|
19
|
+
" lsallcp . node_modules -c Skip + copy, flag order doesn't matter",
|
|
20
|
+
'',
|
|
21
|
+
Display.paint('Note:', Display.color.yellow) + ' .git is always skipped.',
|
|
22
|
+
], { style: 'round', color: Display.color.cyan, title: 'lsallcp' });
|
|
23
|
+
}
|
|
24
|
+
async function resolveRoot(targetArgument) {
|
|
25
|
+
const root = path.resolve(process.cwd(), targetArgument);
|
|
26
|
+
const stat = await fs.stat(root);
|
|
27
|
+
if (!stat.isDirectory())
|
|
28
|
+
throw new Error(`Target is not a directory: ${root}`);
|
|
29
|
+
return root;
|
|
30
|
+
}
|
|
31
|
+
function parseArgs(argv) {
|
|
32
|
+
let copy = false;
|
|
33
|
+
const rest = [];
|
|
34
|
+
for (const arg of argv) {
|
|
35
|
+
if (COPY_FLAGS.has(arg))
|
|
36
|
+
copy = true;
|
|
37
|
+
else
|
|
38
|
+
rest.push(arg);
|
|
39
|
+
}
|
|
40
|
+
const [targetArgument, ...skipArguments] = rest;
|
|
41
|
+
return { targetArgument, skipArguments, copy };
|
|
42
|
+
}
|
|
43
|
+
async function main() {
|
|
44
|
+
const argv = process.argv.slice(2);
|
|
45
|
+
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
|
|
46
|
+
printHelp();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const { targetArgument, skipArguments, copy } = parseArgs(argv);
|
|
50
|
+
if (!targetArgument) {
|
|
51
|
+
printHelp();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
Header.render('lsallcp', { style: 'banner', color: Display.color.magenta });
|
|
55
|
+
const root = await resolveRoot(targetArgument);
|
|
56
|
+
Log.info(`Target: ${Display.paint(root, Display.color.yellow)}`);
|
|
57
|
+
if (skipArguments.length > 0)
|
|
58
|
+
Log.info(`Skip patterns: ${Display.paint(skipArguments.join(', '), Display.color.gray)}`);
|
|
59
|
+
if (copy)
|
|
60
|
+
Log.info(`Clipboard: ${Display.paint('enabled', Display.color.green)} (content will not be printed)`);
|
|
61
|
+
const matchers = buildSkipMatchers(skipArguments);
|
|
62
|
+
const spinner = new Spinner('Walking directory tree...');
|
|
63
|
+
spinner.start();
|
|
64
|
+
const files = await scanDirectory(root, matchers);
|
|
65
|
+
spinner.stop(`Found ${files.length} file(s) after skipping`);
|
|
66
|
+
if (files.length === 0) {
|
|
67
|
+
Log.warn('No files matched. Nothing to list.');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const bar = new Progress({ label: 'Reading files', width: 40, color: Display.color.cyan });
|
|
71
|
+
const blocks = [];
|
|
72
|
+
for (let index = 0; index < files.length; index++) {
|
|
73
|
+
blocks.push(await renderFile(files[index]));
|
|
74
|
+
bar.update(Math.round(((index + 1) / files.length) * 100));
|
|
75
|
+
}
|
|
76
|
+
bar.finish('All files read');
|
|
77
|
+
const output = blocks.join('\n\n');
|
|
78
|
+
if (!copy) {
|
|
79
|
+
Header.divider();
|
|
80
|
+
console.log(output);
|
|
81
|
+
Header.divider();
|
|
82
|
+
}
|
|
83
|
+
Log.success(`Listed ${files.length} file(s) from ${Display.paint(root, Display.color.yellow)}`);
|
|
84
|
+
if (copy) {
|
|
85
|
+
const plainOutput = Display.strip(output);
|
|
86
|
+
const copied = await copyToClipboard(plainOutput);
|
|
87
|
+
if (copied)
|
|
88
|
+
Log.success('Result copied to clipboard');
|
|
89
|
+
else
|
|
90
|
+
Log.warn('Could not copy to clipboard (no clip/pbcopy/wl-copy/xclip/xsel found)');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
main().catch((error) => {
|
|
94
|
+
Log.error(error instanceof Error ? error.stack ?? error.message : String(error));
|
|
95
|
+
process.exit(1);
|
|
96
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
function candidateCommands() {
|
|
3
|
+
switch (process.platform) {
|
|
4
|
+
case 'win32': return [{ command: 'clip', args: [] }];
|
|
5
|
+
case 'darwin': return [{ command: 'pbcopy', args: [] }];
|
|
6
|
+
default: return [
|
|
7
|
+
{ command: 'wl-copy', args: [] },
|
|
8
|
+
{ command: 'xclip', args: ['-selection', 'clipboard'] },
|
|
9
|
+
{ command: 'xsel', args: ['--clipboard', '--input'] },
|
|
10
|
+
];
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function tryCommand(command, text) {
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
const child = spawn(command.command, command.args, { stdio: ['pipe', 'ignore', 'ignore'] });
|
|
16
|
+
child.once('error', () => resolve(false));
|
|
17
|
+
child.once('close', (code) => resolve(code === 0));
|
|
18
|
+
child.stdin.on('error', () => { });
|
|
19
|
+
child.stdin.write(text);
|
|
20
|
+
child.stdin.end();
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
export async function copyToClipboard(text) {
|
|
24
|
+
for (const command of candidateCommands()) {
|
|
25
|
+
const ok = await tryCommand(command, text);
|
|
26
|
+
if (ok)
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
}
|
package/dist/matcher.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
function defaultGitMatcher(baseName) { return baseName === '.git'; }
|
|
2
|
+
function parseRegexLiteral(raw) {
|
|
3
|
+
const match = raw.match(/^\/(.+)\/([a-z]*)$/i);
|
|
4
|
+
if (!match)
|
|
5
|
+
return null;
|
|
6
|
+
try {
|
|
7
|
+
return new RegExp(match[1], match[2]);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function globToRegExp(glob) {
|
|
14
|
+
let out = '^';
|
|
15
|
+
for (let index = 0; index < glob.length; index++) {
|
|
16
|
+
const char = glob[index];
|
|
17
|
+
if (char === '*') {
|
|
18
|
+
if (glob[index + 1] === '*') {
|
|
19
|
+
out += '.*';
|
|
20
|
+
index++;
|
|
21
|
+
}
|
|
22
|
+
else
|
|
23
|
+
out += '[^/]*';
|
|
24
|
+
}
|
|
25
|
+
else if (char === '?')
|
|
26
|
+
out += '[^/]';
|
|
27
|
+
else if ('.+^$()[]{}|\\'.includes(char))
|
|
28
|
+
out += '\\' + char;
|
|
29
|
+
else
|
|
30
|
+
out += char;
|
|
31
|
+
}
|
|
32
|
+
out += '$';
|
|
33
|
+
return new RegExp(out, 'i');
|
|
34
|
+
}
|
|
35
|
+
function compilePattern(raw) {
|
|
36
|
+
const regexLiteral = parseRegexLiteral(raw);
|
|
37
|
+
if (regexLiteral)
|
|
38
|
+
return (relativePath, baseName) => regexLiteral.test(baseName) || regexLiteral.test(relativePath);
|
|
39
|
+
if (/[*?]/.test(raw)) {
|
|
40
|
+
const compiled = globToRegExp(raw);
|
|
41
|
+
return (relativePath, baseName) => compiled.test(baseName) || compiled.test(relativePath);
|
|
42
|
+
}
|
|
43
|
+
const lowered = raw.toLowerCase();
|
|
44
|
+
return (_relativePath, baseName) => baseName.toLowerCase() === lowered;
|
|
45
|
+
}
|
|
46
|
+
export function buildSkipMatchers(patterns) {
|
|
47
|
+
return [defaultGitMatcher, ...patterns.map(compilePattern)];
|
|
48
|
+
}
|
|
49
|
+
export function shouldSkip(matchers, relativePath, baseName, isDirectory) {
|
|
50
|
+
return matchers.some((matcher) => matcher(relativePath, baseName, isDirectory));
|
|
51
|
+
}
|
package/dist/render.js
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 { isBinaryBuffer } from './binary.js';
|
|
5
|
+
function resolveExtension(relativePath) {
|
|
6
|
+
const extension = path.extname(relativePath).slice(1);
|
|
7
|
+
return extension.length > 0 ? extension : 'text';
|
|
8
|
+
}
|
|
9
|
+
export async function renderFile(file) {
|
|
10
|
+
const extension = resolveExtension(file.relativePath);
|
|
11
|
+
let body;
|
|
12
|
+
try {
|
|
13
|
+
const buffer = await fs.readFile(file.absolutePath);
|
|
14
|
+
if (isBinaryBuffer(buffer))
|
|
15
|
+
body = Display.paint('[binary file - content skipped]', Display.color.gray, Display.color.italic);
|
|
16
|
+
else
|
|
17
|
+
body = buffer.toString('utf8');
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
body = Display.paint(`[unreadable: ${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/dist/scanner.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { Log } from 'cli-atelier';
|
|
4
|
+
import { shouldSkip } from './matcher.js';
|
|
5
|
+
export async function scanDirectory(root, matchers) {
|
|
6
|
+
const results = [];
|
|
7
|
+
await walk(root, '', matchers, results);
|
|
8
|
+
results.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
9
|
+
return results;
|
|
10
|
+
}
|
|
11
|
+
async function walk(directory, relativeBase, matchers, results) {
|
|
12
|
+
let entries;
|
|
13
|
+
try {
|
|
14
|
+
entries = await fs.readdir(directory, { withFileTypes: true });
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
Log.warn(`Skipping unreadable directory: ${directory} (${error.message})`);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
const relativePath = relativeBase ? `${relativeBase}/${entry.name}` : entry.name;
|
|
22
|
+
const isDirectory = entry.isDirectory();
|
|
23
|
+
if (shouldSkip(matchers, relativePath, entry.name, isDirectory))
|
|
24
|
+
continue;
|
|
25
|
+
if (isDirectory) {
|
|
26
|
+
await walk(path.join(directory, entry.name), relativePath, matchers, results);
|
|
27
|
+
}
|
|
28
|
+
else if (entry.isFile()) {
|
|
29
|
+
results.push({ absolutePath: path.join(directory, entry.name), relativePath });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
package/package.json
CHANGED
|
@@ -1,21 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lsallcp",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"lsallcp": "./
|
|
7
|
+
"lsallcp": "./dist/cli.js"
|
|
8
8
|
},
|
|
9
|
-
"
|
|
9
|
+
"main": "./dist/cli.js",
|
|
10
|
+
"files": ["dist", "package.json", "README.md", "LICENSE"],
|
|
10
11
|
"engines": {
|
|
11
|
-
"
|
|
12
|
+
"node": ">=18.0.0"
|
|
12
13
|
},
|
|
13
14
|
"scripts": {
|
|
14
|
-
"
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"prepare": "bun run build",
|
|
17
|
+
"start": "bun run dist/cli.js"
|
|
15
18
|
},
|
|
16
|
-
"keywords": ["cli", "
|
|
19
|
+
"keywords": ["cli", "node", "list", "files", "dump", "tree"],
|
|
17
20
|
"devDependencies": {
|
|
18
|
-
"@types/
|
|
21
|
+
"@types/node": "latest",
|
|
19
22
|
"typescript": "latest"
|
|
20
23
|
},
|
|
21
24
|
"dependencies": {
|
package/bin/lsallcp.js
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
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/src/binary.ts
DELETED
package/src/cli.ts
DELETED
|
@@ -1,99 +0,0 @@
|
|
|
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
|
-
})
|
package/src/clipboard.ts
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
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
|
-
}
|