cartotree 0.1.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lumina
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,200 @@
1
+ # 🌲 cartotree
2
+
3
+ > AI-friendly codebase mapping CLI — annotated directory trees that save tokens, not just space.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/cartotree)](https://www.npmjs.com/package/cartotree)
6
+ [![license](https://img.shields.io/npm/l/cartotree)](./LICENSE)
7
+
8
+ ---
9
+
10
+ ## The Problem
11
+
12
+ When you paste your project structure into an AI assistant, two things happen:
13
+
14
+ 1. **Token waste** — Raw `tree` output burns hundreds of tokens on ASCII box characters with zero semantic value.
15
+ 2. **Hallucination** — AI has no idea what each folder *does*, so it guesses wrong.
16
+
17
+ ## The Solution
18
+
19
+ cartotree generates a compact, annotated map of your codebase — folder descriptions, exported functions, and an AI-optimized serialization format that fits your architecture into a fraction of the tokens.
20
+
21
+ ---
22
+
23
+ ## Quick Start
24
+
25
+ ```bash
26
+ npx cartotree
27
+ ```
28
+
29
+ No install required.
30
+
31
+ ---
32
+
33
+ ## Output Formats
34
+
35
+ ### Default — Annotated ASCII Tree
36
+ ```
37
+ my-app/
38
+ ├── src/ # Core application source
39
+ │ ├── components/ # Reusable UI components
40
+ │ │ ├── Button.tsx <Button, ButtonProps>
41
+ │ │ └── Modal.tsx <Modal, useModal>
42
+ │ ├── hooks/
43
+ │ │ └── useAuth.ts <useAuth, useUser>
44
+ │ └── services/
45
+ │ └── api.ts <fetchUser, postData>
46
+ └── package.json
47
+ ```
48
+
49
+ ### `--ai` — Token-Optimized Serialization
50
+ ```
51
+ # cartotree fmt: name{desc}[children]<exports>
52
+ my-app{Core application source}[src{Core application source}[components{Reusable UI components}[Button.tsx<Button,ButtonProps>,Modal.tsx<Modal,useModal>],hooks[useAuth.ts<useAuth,useUser>],services[api.ts<fetchUser,postData>]],package.json]
53
+ ```
54
+
55
+ ### `--json` — JSON Format
56
+ ```json
57
+ {
58
+ "my-app": {
59
+ "src": {
60
+ "_desc": "Core application source",
61
+ "components": {
62
+ "Button.tsx": ["Button", "ButtonProps"],
63
+ "Modal.tsx": ["Modal", "useModal"]
64
+ }
65
+ }
66
+ }
67
+ }
68
+ ```
69
+
70
+ ---
71
+
72
+ ## Usage
73
+
74
+ ```bash
75
+ # Default annotated ASCII tree
76
+ npx cartotree
77
+
78
+ # AI-optimized single-line serialization
79
+ npx cartotree --ai
80
+
81
+ # JSON format
82
+ npx cartotree --json
83
+
84
+ # Copy to clipboard (paste directly into AI)
85
+ npx cartotree --ai --copy
86
+
87
+ # Sync into CLAUDE.md / AGENTS.md
88
+ npx cartotree --ai --sync
89
+
90
+ # Sync into custom file
91
+ npx cartotree --sync .cursorrules
92
+
93
+ # Limit depth
94
+ npx cartotree --depth 2
95
+
96
+ # No depth limit
97
+ npx cartotree --full
98
+
99
+ # Exclude patterns
100
+ npx cartotree --exclude "tests,*.log"
101
+
102
+ # Disable export parsing
103
+ npx cartotree --no-exports
104
+ ```
105
+
106
+ ---
107
+
108
+ ## Options
109
+
110
+ | Option | Default | Description |
111
+ |--------|---------|-------------|
112
+ | `--ai` | false | AI-optimized compact serialization |
113
+ | `--json` | false | JSON format |
114
+ | `--copy` | false | Copy output to clipboard |
115
+ | `--sync [file]` | CLAUDE.md | Inject into AI config file |
116
+ | `--depth <n>` | 3 | Max traversal depth |
117
+ | `--full` | false | No depth limit |
118
+ | `--exclude <patterns>` | — | Comma-separated exclude patterns |
119
+ | `--no-exports` | false | Disable export parsing |
120
+
121
+ ---
122
+
123
+ ## Configuration
124
+
125
+ Create `.cartotreerc` in your project root to set persistent defaults:
126
+
127
+ ```json
128
+ {
129
+ "default": {
130
+ "ai": true,
131
+ "exports": false,
132
+ "depth": 3
133
+ }
134
+ }
135
+ ```
136
+
137
+ Now `npx cartotree` automatically runs with `--ai --no-exports`.
138
+
139
+ ---
140
+
141
+ ## Folder Annotations
142
+
143
+ Add a `.folder.md` to any directory — the first line becomes the annotation:
144
+
145
+ ```
146
+ # Reusable UI components
147
+ Atomic design system built with Radix UI primitives.
148
+ ```
149
+
150
+ cartotree also falls back to the first line of `README.md` if no `.folder.md` exists.
151
+
152
+ ---
153
+
154
+ ## Ignoring Files
155
+
156
+ Create `.cartotreeignore` in your project root:
157
+
158
+ ```
159
+ # .cartotreeignore
160
+ *.log
161
+ temp
162
+ coverage
163
+ .env*
164
+ ```
165
+
166
+ `node_modules`, `.git`, `dist`, `build` are excluded by default.
167
+
168
+ ---
169
+
170
+ ## `--sync` Integration
171
+
172
+ Running `cartotree --sync` injects the tree between marker tags:
173
+
174
+ ```html
175
+ <!-- CARTOTREE-START -->
176
+ (auto-updated on every sync)
177
+ <!-- CARTOTREE-END -->
178
+ ```
179
+
180
+ Works with `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, or any file.
181
+
182
+ ---
183
+
184
+ ## Export Parsing
185
+
186
+ cartotree reads your source files and surfaces exported symbols directly in the tree:
187
+
188
+ ```
189
+ hooks/
190
+ └── useAuth.ts <useAuth, useUser, AuthProvider>
191
+ ```
192
+
193
+ Supports `.js`, `.ts`, `.jsx`, `.tsx`, `.mjs`, `.cjs`.
194
+
195
+ ---
196
+
197
+ ## License
198
+ MIT
199
+
200
+ ---
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { program } = require('commander');
4
+ const { generateTree } = require('../src/tree');
5
+ const { generateAI, generateJSON } = require('../src/ai');
6
+ const { syncToFile } = require('../src/sync');
7
+ const { copyToClipboard } = require('../src/clipboard');
8
+ const { loadIgnorePatterns } = require('../src/ignore');
9
+ const { loadConfig } = require('../src/config');
10
+
11
+ program
12
+ .name('cartotree')
13
+ .description('AI-friendly annotated directory tree')
14
+ .version('0.1.0')
15
+ .option('-d, --depth <number>', 'max depth')
16
+ .option('-e, --exclude <patterns>', 'exclude patterns (comma-separated)')
17
+ .option('--ai', 'AI-optimized compact format')
18
+ .option('--json', 'JSON format')
19
+ .option('--no-exports', 'disable export parsing')
20
+ .option('--copy', 'copy to clipboard')
21
+ .option('--sync [file]', 'sync to CLAUDE.md or specified file')
22
+ .option('--full', 'no depth limit')
23
+ .parse(process.argv);
24
+
25
+ const opts = program.opts();
26
+ const config = loadConfig(process.cwd());
27
+
28
+ const depth = opts.full ? Infinity : parseInt(opts.depth ?? config.depth);
29
+ const useAI = opts.ai || config.ai;
30
+ const useJSON = opts.json || config.json;
31
+ const useCopy = opts.copy || config.copy;
32
+ const useSync = opts.sync || config.sync;
33
+ const showExports = opts.exports ?? config.exports;
34
+
35
+ const ignorePatterns = loadIgnorePatterns(process.cwd());
36
+ const manualExclude = opts.exclude ? opts.exclude.split(',').map(s => s.trim()) : [];
37
+ const exclude = [...new Set([...ignorePatterns, ...manualExclude])];
38
+ const target = typeof useSync === 'string' ? useSync : 'CLAUDE.md';
39
+
40
+ let output;
41
+ if (useAI) {
42
+ output = generateAI(process.cwd(), { depth, exclude, showExports });
43
+ } else if (useJSON) {
44
+ output = generateJSON(process.cwd(), { depth, exclude, showExports });
45
+ } else {
46
+ output = generateTree(process.cwd(), { depth, exclude, showExports });
47
+ }
48
+
49
+ if (useCopy) {
50
+ copyToClipboard(output);
51
+ } else if (useSync) {
52
+ syncToFile(output, target);
53
+ } else {
54
+ console.log(output);
55
+ }
package/package.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "cartotree",
3
+ "version": "0.1.0",
4
+ "description": "AI-friendly codebase mapping CLI — annotated directory trees that save tokens",
5
+ "main": "src/tree.js",
6
+ "bin": {
7
+ "cartotree": "./bin/cartotree.js"
8
+ },
9
+ "keywords": ["cli", "ai", "developer-tools", "tree", "claude", "cursor", "tokens"],
10
+ "license": "MIT",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/kingluminance/cartotree.git"
14
+ }
15
+ }
package/src/ai.js ADDED
@@ -0,0 +1,113 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { shouldIgnore } = require('./ignore');
4
+ const { parseExports } = require('./exports');
5
+
6
+ const AI_FORMAT_HEADER = '# cartotree fmt: name{desc}[children]<exports>';
7
+
8
+ function getFolderDescription(dirPath) {
9
+ const folderMd = path.join(dirPath, '.folder.md');
10
+ const readmeMd = path.join(dirPath, 'README.md');
11
+
12
+ if (fs.existsSync(folderMd)) {
13
+ return fs.readFileSync(folderMd, 'utf-8').trim().split('\n')[0].replace(/^#\s*/, '');
14
+ }
15
+ if (fs.existsSync(readmeMd)) {
16
+ const first = fs.readFileSync(readmeMd, 'utf-8').trim().split('\n')[0].replace(/^#\s*/, '');
17
+ if (first.length < 80) return first;
18
+ }
19
+ return null;
20
+ }
21
+
22
+ function buildAINode(entryPath, name, opts, isDir, currentDepth = 0) {
23
+ if (isDir) {
24
+ let entries;
25
+ try {
26
+ entries = fs.readdirSync(entryPath, { withFileTypes: true });
27
+ } catch {
28
+ return `${name}[!]`;
29
+ }
30
+
31
+ const filtered = entries
32
+ .filter(e => !shouldIgnore(e.name, opts.exclude))
33
+ .sort((a, b) => {
34
+ if (a.isDirectory() && !b.isDirectory()) return -1;
35
+ if (!a.isDirectory() && b.isDirectory()) return 1;
36
+ return a.name.localeCompare(b.name);
37
+ });
38
+
39
+ const desc = getFolderDescription(entryPath);
40
+ const descPart = desc ? `{${desc}}` : '';
41
+
42
+ if (filtered.length === 0) return `${name}${descPart}[]`;
43
+
44
+ if (currentDepth >= opts.depth) {
45
+ const names = filtered.map(e => e.name + (e.isDirectory() ? '/' : '')).join(',');
46
+ return `${name}${descPart}[${names}]`;
47
+ }
48
+
49
+ const children = filtered.map(e => {
50
+ const fullPath = path.join(entryPath, e.name);
51
+ return buildAINode(fullPath, e.name, opts, e.isDirectory(), currentDepth + 1);
52
+ });
53
+
54
+ return `${name}${descPart}[${children.join(',')}]`;
55
+ } else {
56
+ const exports = opts.showExports ? parseExports(entryPath) : null;
57
+ const exportPart = exports && exports.length > 0
58
+ ? `<${exports.slice(0, 4).join(',')}${exports.length > 4 ? `+${exports.length - 4}` : ''}>`
59
+ : '';
60
+ return `${name}${exportPart}`;
61
+ }
62
+ }
63
+
64
+ function buildJSONNode(entryPath, opts, currentDepth = 0) {
65
+ let entries;
66
+ try {
67
+ entries = fs.readdirSync(entryPath, { withFileTypes: true });
68
+ } catch {
69
+ return null;
70
+ }
71
+
72
+ const filtered = entries
73
+ .filter(e => !shouldIgnore(e.name, opts.exclude))
74
+ .sort((a, b) => {
75
+ if (a.isDirectory() && !b.isDirectory()) return -1;
76
+ if (!a.isDirectory() && b.isDirectory()) return 1;
77
+ return a.name.localeCompare(b.name);
78
+ });
79
+
80
+ const result = {};
81
+
82
+ const desc = getFolderDescription(entryPath);
83
+ if (desc) result['_desc'] = desc;
84
+
85
+ filtered.forEach(entry => {
86
+ const fullPath = path.join(entryPath, entry.name);
87
+
88
+ if (entry.isDirectory()) {
89
+ result[entry.name] = currentDepth < opts.depth
90
+ ? buildJSONNode(fullPath, opts, currentDepth + 1)
91
+ : {};
92
+ } else {
93
+ const exports = parseExports(fullPath);
94
+ result[entry.name] = exports ?? null;
95
+ }
96
+ });
97
+
98
+ return result;
99
+ }
100
+
101
+ function generateAI(rootPath, opts) {
102
+ const rootName = path.basename(rootPath);
103
+ const node = buildAINode(rootPath, rootName, opts, true);
104
+ return `${AI_FORMAT_HEADER}\n${node}`;
105
+ }
106
+
107
+ function generateJSON(rootPath, opts) {
108
+ const rootName = path.basename(rootPath);
109
+ const tree = buildJSONNode(rootPath, opts);
110
+ return JSON.stringify({ [rootName]: tree }, null, 2);
111
+ }
112
+
113
+ module.exports = { generateAI, generateJSON };
@@ -0,0 +1,11 @@
1
+ const clipboardy = require('clipboardy');
2
+ const chalk = require('chalk');
3
+
4
+ function copyToClipboard(text) {
5
+ const plain = text.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '');
6
+ clipboardy.writeSync(plain);
7
+ console.log(chalk.green('✔ Copied to clipboard!'));
8
+ console.log(chalk.gray('(Color codes stripped for clean paste)'));
9
+ }
10
+
11
+ module.exports = { copyToClipboard };
package/src/config.js ADDED
@@ -0,0 +1,26 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const DEFAULTS = {
5
+ ai: false,
6
+ json: false,
7
+ exports: true,
8
+ depth: 3,
9
+ copy: false,
10
+ sync: false,
11
+ };
12
+
13
+ function loadConfig(rootPath) {
14
+ const configPath = path.join(rootPath, '.cartotreerc');
15
+ if (!fs.existsSync(configPath)) return DEFAULTS;
16
+
17
+ try {
18
+ const parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
19
+ return { ...DEFAULTS, ...parsed.default };
20
+ } catch (err) {
21
+ console.warn('âš  .cartotreerc parse error, using defaults');
22
+ return DEFAULTS;
23
+ }
24
+ }
25
+
26
+ module.exports = { loadConfig };
package/src/exports.js ADDED
@@ -0,0 +1,65 @@
1
+ const fs = require('fs');
2
+
3
+ const SUPPORTED_EXTENSIONS = ['.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs'];
4
+
5
+ function parseExports(filePath) {
6
+ const ext = SUPPORTED_EXTENSIONS.find(e => filePath.endsWith(e));
7
+ if (!ext) return null;
8
+
9
+ let content;
10
+ try {
11
+ content = fs.readFileSync(filePath, 'utf-8');
12
+ } catch {
13
+ return null;
14
+ }
15
+
16
+ const names = new Set();
17
+
18
+ // export function foo / export async function foo / export class foo / export const foo
19
+ const namedPattern = /export\s+(?:async\s+)?(?:function|const|let|var|class)\s+(\w+)/g;
20
+ let match;
21
+ while ((match = namedPattern.exec(content)) !== null) {
22
+ if (match[1] !== 'default') names.add(match[1]);
23
+ }
24
+
25
+ // export { foo, bar as baz }
26
+ const bracketPattern = /export\s*\{([^}]+)\}/g;
27
+ while ((match = bracketPattern.exec(content)) !== null) {
28
+ match[1].split(',')
29
+ .map(s => s.trim().split(/\s+as\s+/).pop().trim())
30
+ .filter(Boolean)
31
+ .forEach(n => names.add(n));
32
+ }
33
+
34
+ // export default function foo / export default class foo
35
+ const defaultNamedPattern = /export\s+default\s+(?:function|class)\s+(\w+)/g;
36
+ while ((match = defaultNamedPattern.exec(content)) !== null) {
37
+ names.add(match[1]);
38
+ }
39
+
40
+ // module.exports = { foo, bar }
41
+ const cjsPattern = /module\.exports\s*=\s*\{([^}]+)\}/g;
42
+ while ((match = cjsPattern.exec(content)) !== null) {
43
+ match[1].split(',')
44
+ .map(s => s.trim().split(':')[0].trim())
45
+ .filter(s => /^\w+$/.test(s))
46
+ .forEach(n => names.add(n));
47
+ }
48
+
49
+ // module.exports.foo = ...
50
+ const cjsDotPattern = /module\.exports\.(\w+)\s*=/g;
51
+ while ((match = cjsDotPattern.exec(content)) !== null) {
52
+ names.add(match[1]);
53
+ }
54
+
55
+ return names.size > 0 ? [...names] : null;
56
+ }
57
+
58
+ function formatExports(names, max = 4) {
59
+ if (!names) return '';
60
+ const visible = names.slice(0, max);
61
+ const rest = names.length > max ? `, +${names.length - max}` : '';
62
+ return `<${visible.join(', ')}${rest}>`;
63
+ }
64
+
65
+ module.exports = { parseExports, formatExports };
package/src/ignore.js ADDED
@@ -0,0 +1,31 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const DEFAULTS = [
5
+ 'node_modules', '.git', 'dist', 'build', '.next',
6
+ '.nuxt', 'coverage', '.cache', '.DS_Store', 'Thumbs.db'
7
+ ];
8
+
9
+ function loadIgnorePatterns(rootPath) {
10
+ const ignorePath = path.join(rootPath, '.cartotreeignore');
11
+
12
+ if (!fs.existsSync(ignorePath)) return DEFAULTS;
13
+
14
+ const lines = fs.readFileSync(ignorePath, 'utf-8')
15
+ .split('\n')
16
+ .map(l => l.trim())
17
+ .filter(l => l && !l.startsWith('#'));
18
+
19
+ return [...new Set([...DEFAULTS, ...lines])];
20
+ }
21
+
22
+ function shouldIgnore(name, patterns) {
23
+ return patterns.some(pattern => {
24
+ if (pattern.startsWith('*.')) {
25
+ return name.endsWith(pattern.slice(1));
26
+ }
27
+ return name === pattern;
28
+ });
29
+ }
30
+
31
+ module.exports = { loadIgnorePatterns, shouldIgnore };
package/src/sync.js ADDED
@@ -0,0 +1,30 @@
1
+ const fs = require('fs');
2
+ const chalk = require('chalk');
3
+
4
+ const START_MARKER = '<!-- CARTOTREE-START -->';
5
+ const END_MARKER = '<!-- CARTOTREE-END -->';
6
+
7
+ function syncToFile(tree, filePath) {
8
+ const plain = tree.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '');
9
+ const block = `${START_MARKER}\n\`\`\`\n${plain}\`\`\`\n${END_MARKER}`;
10
+
11
+ if (fs.existsSync(filePath)) {
12
+ let content = fs.readFileSync(filePath, 'utf-8');
13
+ const startIdx = content.indexOf(START_MARKER);
14
+ const endIdx = content.indexOf(END_MARKER);
15
+
16
+ if (startIdx !== -1 && endIdx !== -1) {
17
+ content = content.slice(0, startIdx) + block + content.slice(endIdx + END_MARKER.length);
18
+ } else {
19
+ content += `\n\n${block}\n`;
20
+ }
21
+
22
+ fs.writeFileSync(filePath, content, 'utf-8');
23
+ console.log(chalk.green(`✔ Synced to ${filePath}`));
24
+ } else {
25
+ fs.writeFileSync(filePath, `# Project Structure\n\n${block}\n`, 'utf-8');
26
+ console.log(chalk.green(`✔ Created ${filePath}`));
27
+ }
28
+ }
29
+
30
+ module.exports = { syncToFile };
package/src/tree.js ADDED
@@ -0,0 +1,97 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const chalk = require('chalk');
4
+ const { shouldIgnore } = require('./ignore');
5
+ const { parseExports, formatExports } = require('./exports');
6
+
7
+
8
+ function getFolderDescription(dirPath) {
9
+ const folderMd = path.join(dirPath, '.folder.md');
10
+ const readmeMd = path.join(dirPath, 'README.md');
11
+
12
+ if (fs.existsSync(folderMd)) {
13
+ const content = fs.readFileSync(folderMd, 'utf-8').trim();
14
+ return content.split('\n')[0].replace(/^#\s*/, '');
15
+ }
16
+
17
+ if (fs.existsSync(readmeMd)) {
18
+ const content = fs.readFileSync(readmeMd, 'utf-8').trim();
19
+ const firstLine = content.split('\n')[0].replace(/^#\s*/, '');
20
+ if (firstLine.length < 80) return firstLine;
21
+ }
22
+
23
+ return null;
24
+ }
25
+
26
+ function buildTree(dirPath, opts, prefix = '', currentDepth = 0) {
27
+ if (currentDepth > opts.depth) return '';
28
+
29
+ let entries;
30
+ try {
31
+ entries = fs.readdirSync(dirPath, { withFileTypes: true });
32
+ } catch (err) {
33
+ if (err.code === 'EACCES') {
34
+ return `${prefix}${chalk.red('[permission denied]')}\n`;
35
+ }
36
+ return `${prefix}${chalk.red(`[error: ${err.code}]`)}\n`;
37
+ }
38
+
39
+ const filtered = entries
40
+ .filter(e => !shouldIgnore(e.name, opts.exclude))
41
+ .sort((a, b) => {
42
+ if (a.isDirectory() && !b.isDirectory()) return -1;
43
+ if (!a.isDirectory() && b.isDirectory()) return 1;
44
+ return a.name.localeCompare(b.name);
45
+ });
46
+
47
+ if (filtered.length === 0) {
48
+ return `${prefix}${chalk.gray('(empty)')}\n`;
49
+ }
50
+
51
+ let output = '';
52
+
53
+ filtered.forEach((entry, index) => {
54
+ const isLast = index === filtered.length - 1;
55
+ const connector = isLast ? '└── ' : '├── ';
56
+ const childPrefix = isLast ? ' ' : '│ ';
57
+ const fullPath = path.join(dirPath, entry.name);
58
+
59
+ if (entry.isSymbolicLink()) {
60
+ try {
61
+ const target = fs.readlinkSync(fullPath);
62
+ output += `${prefix}${connector}${chalk.magenta(entry.name)}${chalk.gray(` -> ${target}`)}\n`;
63
+ } catch {
64
+ output += `${prefix}${connector}${chalk.magenta(entry.name)}${chalk.red(' -> [broken link]')}\n`;
65
+ }
66
+ return;
67
+ }
68
+
69
+ if (entry.isDirectory()) {
70
+ const desc = getFolderDescription(fullPath);
71
+ const annotation = desc ? chalk.gray(` # ${desc}`) : '';
72
+ output += `${prefix}${connector}${chalk.cyan(entry.name)}/${annotation}\n`;
73
+ if (currentDepth < opts.depth) {
74
+ output += buildTree(fullPath, opts, prefix + childPrefix, currentDepth + 1);
75
+ }
76
+ } else {
77
+ const exports = opts.showExports ? parseExports(fullPath) : null;
78
+ const exportTag = exports ? chalk.yellow(` ${formatExports(exports)}`) : '';
79
+ output += `${prefix}${connector}${entry.name}${exportTag}\n`;
80
+ }
81
+ });
82
+
83
+ return output;
84
+ }
85
+
86
+ function generateTree(rootPath, opts) {
87
+ const rootName = path.basename(rootPath);
88
+ const desc = getFolderDescription(rootPath);
89
+ const annotation = desc ? chalk.gray(` # ${desc}`) : '';
90
+
91
+ let output = `${chalk.bold.cyan(rootName)}/${annotation}\n`;
92
+ output += buildTree(rootPath, opts);
93
+
94
+ return output;
95
+ }
96
+
97
+ module.exports = { generateTree };