trls-cli 0.2.0__py3-none-any.whl

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.
trls/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ from trls.renderers import (
2
+ build_rich_tree,
3
+ render_compact_copy,
4
+ render_json,
5
+ render_markdown,
6
+ render_prompt,
7
+ render_text,
8
+ )
9
+ from trls.tree import TreeNode, scan_tree
10
+
11
+ __version__ = "0.2.0"
12
+
13
+ __all__ = [
14
+ "TreeNode",
15
+ "__version__",
16
+ "build_rich_tree",
17
+ "render_compact_copy",
18
+ "render_json",
19
+ "render_markdown",
20
+ "render_prompt",
21
+ "render_text",
22
+ "scan_tree",
23
+ ]
trls/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from trls.cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
trls/cli.py ADDED
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Sequence
7
+
8
+ from rich.console import Console
9
+
10
+ from trls import __version__
11
+ from trls.clipboard import copy_text
12
+ from trls.renderers import (
13
+ build_rich_tree,
14
+ render_compact_copy,
15
+ render_json,
16
+ render_markdown,
17
+ render_prompt,
18
+ render_text,
19
+ )
20
+ from trls.snapshot import load_last_snapshot, load_snapshot, save_last_snapshot
21
+ from trls.tree import DEFAULT_IGNORE_PATTERNS, diff_trees, scan_tree
22
+
23
+
24
+ def build_parser() -> argparse.ArgumentParser:
25
+ parser = argparse.ArgumentParser(
26
+ prog="trls",
27
+ description="Render directory trees for humans and LLMs.",
28
+ )
29
+ parser.add_argument(
30
+ "path",
31
+ nargs="?",
32
+ default=".",
33
+ help="Directory to scan. Defaults to the current directory.",
34
+ )
35
+ parser.add_argument(
36
+ "--depth",
37
+ type=int,
38
+ default=None,
39
+ help="Maximum directory depth to include.",
40
+ )
41
+ parser.add_argument(
42
+ "--hidden",
43
+ action="store_true",
44
+ help="Include hidden files and directories.",
45
+ )
46
+ parser.add_argument(
47
+ "--ignore",
48
+ action="append",
49
+ default=[],
50
+ metavar="PATTERN",
51
+ help=(
52
+ "Add an extra ignore name or glob pattern. Repeat for multiple "
53
+ f"patterns. Defaults also ignore: {', '.join(DEFAULT_IGNORE_PATTERNS)}."
54
+ ),
55
+ )
56
+ parser.add_argument(
57
+ "--format",
58
+ choices=("prompt", "rich", "text", "markdown", "json"),
59
+ default="rich",
60
+ help="Output format. Defaults to rich.",
61
+ )
62
+ parser.add_argument(
63
+ "-diff",
64
+ "--diff-last",
65
+ action="store_true",
66
+ help="Compare the current tree with the previous snapshot for this path.",
67
+ )
68
+ parser.add_argument(
69
+ "-compare",
70
+ "--compare-with",
71
+ metavar="SNAPSHOT",
72
+ help="Compare the current tree with an explicit snapshot file.",
73
+ )
74
+ parser.add_argument(
75
+ "-save",
76
+ "--save-snapshot",
77
+ action="store_true",
78
+ help="Save the current tree as the snapshot baseline for this path without diffing.",
79
+ )
80
+ parser.add_argument(
81
+ "-update",
82
+ "--update-snapshot",
83
+ action="store_true",
84
+ help="After diffing, replace the saved snapshot baseline with the current tree.",
85
+ )
86
+ parser.add_argument(
87
+ "-c",
88
+ "--copy",
89
+ action="store_true",
90
+ help="Copy a compact LLM-friendly version to the clipboard after rendering.",
91
+ )
92
+ parser.add_argument(
93
+ "--version",
94
+ action="version",
95
+ version=f"trls {__version__}",
96
+ )
97
+ return parser
98
+
99
+
100
+ def main(argv: Sequence[str] | None = None) -> int:
101
+ parser = build_parser()
102
+ args = parser.parse_args(argv)
103
+
104
+ if args.depth is not None and args.depth < 0:
105
+ parser.error("--depth must be greater than or equal to 0")
106
+ if args.diff_last and args.compare_with:
107
+ parser.error("Use either --diff-last or --compare-with, not both")
108
+ if args.save_snapshot and (args.diff_last or args.compare_with):
109
+ parser.error(
110
+ "--save-snapshot cannot be combined with diff flags; use --update-snapshot "
111
+ "after diffing instead"
112
+ )
113
+ if args.update_snapshot and not (args.diff_last or args.compare_with):
114
+ parser.error("--update-snapshot requires -diff/--diff-last or -compare/--compare-with")
115
+
116
+ root_path = Path(args.path).expanduser().resolve()
117
+ needs_fingerprints = True
118
+
119
+ current_tree = scan_tree(
120
+ root_path,
121
+ max_depth=args.depth,
122
+ show_hidden=args.hidden,
123
+ ignore_patterns=args.ignore,
124
+ include_fingerprints=needs_fingerprints,
125
+ )
126
+ output_tree = current_tree
127
+
128
+ if args.save_snapshot:
129
+ output_tree = current_tree
130
+ elif args.compare_with:
131
+ try:
132
+ output_tree = diff_trees(current_tree, load_snapshot(args.compare_with))
133
+ except FileNotFoundError as exc:
134
+ parser.error(str(exc))
135
+ else:
136
+ try:
137
+ output_tree = diff_trees(current_tree, load_last_snapshot(root_path))
138
+ except FileNotFoundError:
139
+ output_tree = current_tree
140
+
141
+ if args.format == "rich":
142
+ Console().print(build_rich_tree(output_tree))
143
+ elif args.format == "prompt":
144
+ print(render_prompt(output_tree))
145
+ elif args.format == "text":
146
+ print(render_text(output_tree))
147
+ elif args.format == "markdown":
148
+ print(render_markdown(output_tree))
149
+ else:
150
+ print(render_json(output_tree))
151
+
152
+ save_last_snapshot(root_path, current_tree)
153
+
154
+ if args.copy:
155
+ try:
156
+ copy_text(render_compact_copy(output_tree))
157
+ except RuntimeError as exc:
158
+ print(f"Failed to copy to clipboard: {exc}", file=sys.stderr)
159
+ return 1
160
+
161
+ return 0
162
+
163
+
164
+ if __name__ == "__main__":
165
+ raise SystemExit(main())
trls/clipboard.py ADDED
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ import platform
4
+ import shutil
5
+ import subprocess
6
+
7
+
8
+ def copy_text(text: str) -> None:
9
+ system = platform.system()
10
+ if system == "Windows":
11
+ _run_clipboard_command(["clip"], text)
12
+ return
13
+ if system == "Darwin":
14
+ _run_clipboard_command(["pbcopy"], text)
15
+ return
16
+ if shutil.which("wl-copy"):
17
+ _run_clipboard_command(["wl-copy"], text)
18
+ return
19
+ if shutil.which("xclip"):
20
+ _run_clipboard_command(["xclip", "-selection", "clipboard"], text)
21
+ return
22
+ if shutil.which("xsel"):
23
+ _run_clipboard_command(["xsel", "--clipboard", "--input"], text)
24
+ return
25
+
26
+ raise RuntimeError("No supported clipboard command was found on this system.")
27
+
28
+
29
+ def _run_clipboard_command(command: list[str], text: str) -> None:
30
+ try:
31
+ subprocess.run(
32
+ command,
33
+ input=text,
34
+ text=True,
35
+ check=True,
36
+ capture_output=True,
37
+ )
38
+ except FileNotFoundError as exc:
39
+ raise RuntimeError(f"Clipboard command not found: {command[0]}") from exc
40
+ except subprocess.CalledProcessError as exc:
41
+ stderr = exc.stderr.strip() if exc.stderr else "unknown clipboard error"
42
+ raise RuntimeError(f"Clipboard command failed: {stderr}") from exc
trls/renderers.py ADDED
@@ -0,0 +1,211 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from rich.text import Text
7
+ from rich.tree import Tree as RichTree
8
+
9
+ from trls.tree import TreeNode
10
+
11
+ DIRECTORY_STYLE = "bold cyan"
12
+ DEFAULT_FILE_STYLE = "white"
13
+
14
+ FILE_STYLES = {
15
+ ".py": "green",
16
+ ".md": "magenta",
17
+ ".rst": "magenta",
18
+ ".txt": "white",
19
+ ".toml": "yellow",
20
+ ".yaml": "yellow",
21
+ ".yml": "yellow",
22
+ ".json": "yellow",
23
+ ".ini": "yellow",
24
+ ".cfg": "yellow",
25
+ ".lock": "yellow",
26
+ ".sh": "bright_green",
27
+ ".ps1": "bright_green",
28
+ ".bat": "bright_green",
29
+ ".exe": "bright_red",
30
+ ".whl": "bright_red",
31
+ ".tar": "bright_red",
32
+ ".gz": "bright_red",
33
+ ".zip": "bright_red",
34
+ }
35
+
36
+ SPECIAL_FILE_STYLES = {
37
+ "license": "bright_white",
38
+ "readme.md": "bright_magenta",
39
+ "pyproject.toml": "bright_yellow",
40
+ }
41
+ DIFF_MARKERS = {
42
+ "added": "+ ",
43
+ "removed": "- ",
44
+ "modified": "~ ",
45
+ "unchanged": "",
46
+ None: "",
47
+ }
48
+ DIFF_STYLES = {
49
+ "added": "green",
50
+ "removed": "red",
51
+ "modified": "yellow",
52
+ "unchanged": "",
53
+ None: "",
54
+ }
55
+
56
+
57
+ def build_rich_tree(node: TreeNode) -> RichTree:
58
+ label = _rich_label(node)
59
+ tree = RichTree(label, guide_style="bright_black")
60
+ for child in node.children:
61
+ _append_rich_branch(tree, child)
62
+ return tree
63
+
64
+
65
+ def render_text(node: TreeNode) -> str:
66
+ lines = [f"{_diff_marker(node)}{node.display_name}"]
67
+ lines.extend(_render_text_children(node.children, prefix=""))
68
+ return "\n".join(lines)
69
+
70
+
71
+ def render_prompt(node: TreeNode) -> str:
72
+ lines: list[str] = []
73
+ _append_prompt_lines(node, lines, depth=0)
74
+ return "\n".join(lines)
75
+
76
+
77
+ def render_compact_copy(node: TreeNode) -> str:
78
+ lines = [node.display_name]
79
+ for child in node.children:
80
+ _append_compact_copy_lines(child, lines, prefix=node.display_name)
81
+ return "\n".join(lines)
82
+
83
+
84
+ def render_markdown(node: TreeNode) -> str:
85
+ lines: list[str] = []
86
+ _append_markdown_lines(node, lines, depth=0)
87
+ return "\n".join(lines)
88
+
89
+
90
+ def render_json(node: TreeNode) -> str:
91
+ return json.dumps(node.to_dict(), indent=2)
92
+
93
+
94
+ def _append_rich_branch(branch: RichTree, node: TreeNode) -> None:
95
+ child_branch = branch.add(_rich_label(node))
96
+ for child in node.children:
97
+ _append_rich_branch(child_branch, child)
98
+
99
+
100
+ def _rich_label(node: TreeNode) -> Text:
101
+ label = Text()
102
+ marker = _diff_marker(node)
103
+ if marker:
104
+ label.append(marker, style=DIFF_STYLES[node.diff_status])
105
+
106
+ if node.is_dir:
107
+ label.append(node.display_name, style=DIRECTORY_STYLE)
108
+ else:
109
+ label.append(node.name, style=_style_for_file(node.name))
110
+
111
+ if node.error:
112
+ label.append(f" [unreadable: {node.error}]", style="red")
113
+
114
+ return label
115
+
116
+
117
+ def _style_for_file(name: str) -> str:
118
+ normalized_name = name.lower()
119
+ if normalized_name in SPECIAL_FILE_STYLES:
120
+ return SPECIAL_FILE_STYLES[normalized_name]
121
+
122
+ suffixes = Path(normalized_name).suffixes
123
+ for suffix in reversed(suffixes):
124
+ if suffix in FILE_STYLES:
125
+ return FILE_STYLES[suffix]
126
+
127
+ return DEFAULT_FILE_STYLE
128
+
129
+
130
+ def _append_prompt_lines(node: TreeNode, lines: list[str], depth: int) -> None:
131
+ indent = " " * depth
132
+ line = f"{indent}{_diff_marker(node)}[{_prompt_tag(node)}] {node.display_name}"
133
+ if node.error:
134
+ line = f"{line} (unreadable: {node.error})"
135
+ lines.append(line)
136
+
137
+ for child in node.children:
138
+ _append_prompt_lines(child, lines, depth + 1)
139
+
140
+
141
+ def _append_compact_copy_lines(node: TreeNode, lines: list[str], prefix: str) -> None:
142
+ marker = _diff_marker(node)
143
+ full_path = f"{prefix}{node.name}/" if node.is_dir else f"{prefix}{node.name}"
144
+ lines.append(f"{marker}{full_path}")
145
+
146
+ child_prefix = f"{full_path}" if node.is_dir else prefix
147
+ for child in node.children:
148
+ _append_compact_copy_lines(child, lines, child_prefix)
149
+
150
+
151
+ def _prompt_tag(node: TreeNode) -> str:
152
+ if node.is_dir:
153
+ return "dir"
154
+
155
+ normalized_name = node.name.lower()
156
+ if normalized_name == "license":
157
+ return "license"
158
+ if normalized_name == "pyproject.toml":
159
+ return "meta"
160
+ if normalized_name == "readme.md":
161
+ return "doc"
162
+
163
+ suffixes = Path(normalized_name).suffixes
164
+ for suffix in reversed(suffixes):
165
+ if suffix == ".py":
166
+ return "py"
167
+ if suffix in {".md", ".rst", ".txt"}:
168
+ return "doc"
169
+ if suffix in {".toml", ".yaml", ".yml", ".json", ".ini", ".cfg", ".lock"}:
170
+ return "meta"
171
+ if suffix in {".sh", ".ps1", ".bat"}:
172
+ return "shell"
173
+ if suffix in {".exe", ".whl", ".tar", ".gz", ".zip"}:
174
+ return "archive"
175
+
176
+ return "file"
177
+
178
+
179
+ def _render_text_children(children: list[TreeNode], prefix: str) -> list[str]:
180
+ lines: list[str] = []
181
+
182
+ for index, child in enumerate(children):
183
+ is_last = index == len(children) - 1
184
+ connector = "`-- " if is_last else "|-- "
185
+ lines.append(f"{prefix}{connector}{_diff_marker(child)}{child.display_name}")
186
+
187
+ if child.error:
188
+ error_prefix = " " if is_last else "| "
189
+ lines.append(f"{prefix}{error_prefix}[unreadable: {child.error}]")
190
+
191
+ child_prefix = f"{prefix}{' ' if is_last else '| '}"
192
+ lines.extend(_render_text_children(child.children, child_prefix))
193
+
194
+ return lines
195
+
196
+
197
+ def _append_markdown_lines(node: TreeNode, lines: list[str], depth: int) -> None:
198
+ indent = " " * depth
199
+ marker = _diff_marker(node).strip()
200
+ prefix = f"{marker} " if marker else ""
201
+ line = f"{indent}- {prefix}`{node.display_name}`"
202
+ if node.error:
203
+ line = f"{line} _(unreadable: {node.error})_"
204
+ lines.append(line)
205
+
206
+ for child in node.children:
207
+ _append_markdown_lines(child, lines, depth + 1)
208
+
209
+
210
+ def _diff_marker(node: TreeNode) -> str:
211
+ return DIFF_MARKERS[node.diff_status]
trls/snapshot.py ADDED
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+ from trls.tree import TreeNode
10
+
11
+ SNAPSHOT_VERSION = 1
12
+ SNAPSHOT_DIR_ENV = "TRLS_SNAPSHOT_DIR"
13
+
14
+
15
+ def default_snapshot_path(root: str | Path) -> Path:
16
+ root_path = Path(root).expanduser().resolve()
17
+ normalized_root = root_path.as_posix().lower()
18
+ key = hashlib.sha256(normalized_root.encode("utf-8")).hexdigest()[:16]
19
+ return snapshot_storage_dir() / f"{key}.json"
20
+
21
+
22
+ def snapshot_storage_dir() -> Path:
23
+ configured_dir = os.environ.get(SNAPSHOT_DIR_ENV)
24
+ if configured_dir:
25
+ return Path(configured_dir).expanduser().resolve()
26
+ return Path.home() / ".trls" / "snapshots"
27
+
28
+
29
+ def save_snapshot(snapshot_path: str | Path, root: str | Path, tree: TreeNode) -> Path:
30
+ destination = Path(snapshot_path).expanduser().resolve()
31
+ destination.parent.mkdir(parents=True, exist_ok=True)
32
+ root_path = Path(root).expanduser().resolve()
33
+ payload = {
34
+ "snapshot_version": SNAPSHOT_VERSION,
35
+ "root_path": str(root_path),
36
+ "created_at": datetime.now(timezone.utc).isoformat(),
37
+ "tree": tree.to_dict(include_fingerprint=True),
38
+ }
39
+ destination.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
40
+ return destination
41
+
42
+
43
+ def save_last_snapshot(root: str | Path, tree: TreeNode) -> Path:
44
+ return save_snapshot(default_snapshot_path(root), root, tree)
45
+
46
+
47
+ def load_snapshot(snapshot_path: str | Path) -> TreeNode:
48
+ path = Path(snapshot_path).expanduser().resolve()
49
+ payload = json.loads(path.read_text(encoding="utf-8"))
50
+ return TreeNode.from_dict(payload["tree"])
51
+
52
+
53
+ def load_last_snapshot(root: str | Path) -> TreeNode:
54
+ snapshot_path = default_snapshot_path(root)
55
+ if not snapshot_path.exists():
56
+ raise FileNotFoundError(
57
+ f"No saved snapshot for {Path(root).expanduser().resolve()}. "
58
+ "Run trls with --save-snapshot first."
59
+ )
60
+ return load_snapshot(snapshot_path)
trls/tree.py ADDED
@@ -0,0 +1,225 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from dataclasses import dataclass, field
5
+ from fnmatch import fnmatch
6
+ from pathlib import Path
7
+ from typing import Any, Literal
8
+
9
+ DEFAULT_IGNORE_PATTERNS = (".venv", "dist", "__pycache__")
10
+ DiffStatus = Literal["added", "removed", "modified", "unchanged"]
11
+
12
+
13
+ @dataclass
14
+ class TreeNode:
15
+ name: str
16
+ path: str
17
+ kind: str
18
+ children: list["TreeNode"] = field(default_factory=list)
19
+ error: str | None = None
20
+ size: int | None = None
21
+ sha256: str | None = None
22
+ diff_status: DiffStatus | None = None
23
+
24
+ @property
25
+ def is_dir(self) -> bool:
26
+ return self.kind == "directory"
27
+
28
+ @property
29
+ def display_name(self) -> str:
30
+ return f"{self.name}/" if self.is_dir else self.name
31
+
32
+ def to_dict(self, *, include_fingerprint: bool = False) -> dict[str, object]:
33
+ payload: dict[str, object] = {
34
+ "name": self.name,
35
+ "path": self.path,
36
+ "kind": self.kind,
37
+ }
38
+ if self.error:
39
+ payload["error"] = self.error
40
+ if self.diff_status:
41
+ payload["diff_status"] = self.diff_status
42
+ if include_fingerprint and not self.is_dir:
43
+ if self.size is not None:
44
+ payload["size"] = self.size
45
+ if self.sha256 is not None:
46
+ payload["sha256"] = self.sha256
47
+ if self.children:
48
+ payload["children"] = [
49
+ child.to_dict(include_fingerprint=include_fingerprint)
50
+ for child in self.children
51
+ ]
52
+ return payload
53
+
54
+ @classmethod
55
+ def from_dict(cls, payload: dict[str, Any]) -> "TreeNode":
56
+ return cls(
57
+ name=payload["name"],
58
+ path=payload["path"],
59
+ kind=payload["kind"],
60
+ children=[cls.from_dict(child) for child in payload.get("children", [])],
61
+ error=payload.get("error"),
62
+ size=payload.get("size"),
63
+ sha256=payload.get("sha256"),
64
+ diff_status=payload.get("diff_status"),
65
+ )
66
+
67
+
68
+ def scan_tree(
69
+ root: str | Path = ".",
70
+ *,
71
+ max_depth: int | None = None,
72
+ show_hidden: bool = False,
73
+ ignore_patterns: list[str] | None = None,
74
+ include_fingerprints: bool = False,
75
+ ) -> TreeNode:
76
+ root_path = Path(root).expanduser().resolve()
77
+ if not root_path.exists():
78
+ raise FileNotFoundError(f"Path does not exist: {root}")
79
+
80
+ patterns = (*DEFAULT_IGNORE_PATTERNS, *(ignore_patterns or []))
81
+ return _scan_path(
82
+ path=root_path,
83
+ root=root_path,
84
+ depth=0,
85
+ max_depth=max_depth,
86
+ show_hidden=show_hidden,
87
+ ignore_patterns=patterns,
88
+ include_fingerprints=include_fingerprints,
89
+ )
90
+
91
+
92
+ def _scan_path(
93
+ *,
94
+ path: Path,
95
+ root: Path,
96
+ depth: int,
97
+ max_depth: int | None,
98
+ show_hidden: bool,
99
+ ignore_patterns: tuple[str, ...],
100
+ include_fingerprints: bool,
101
+ ) -> TreeNode:
102
+ kind = "directory" if path.is_dir() else "file"
103
+ node = TreeNode(name=path.name or str(path), path=str(path), kind=kind)
104
+
105
+ if not path.is_dir():
106
+ if include_fingerprints:
107
+ node.size = path.stat().st_size
108
+ node.sha256 = _sha256_for_file(path)
109
+ return node
110
+
111
+ if max_depth is not None and depth >= max_depth:
112
+ return node
113
+
114
+ try:
115
+ entries = sorted(
116
+ path.iterdir(),
117
+ key=lambda item: (not item.is_dir(), item.name.lower()),
118
+ )
119
+ except OSError as exc:
120
+ node.error = str(exc)
121
+ return node
122
+
123
+ for entry in entries:
124
+ if not show_hidden and _is_hidden(entry):
125
+ continue
126
+
127
+ relative_path = entry.relative_to(root).as_posix()
128
+ if _matches_ignore(entry.name, relative_path, ignore_patterns):
129
+ continue
130
+
131
+ node.children.append(
132
+ _scan_path(
133
+ path=entry,
134
+ root=root,
135
+ depth=depth + 1,
136
+ max_depth=max_depth,
137
+ show_hidden=show_hidden,
138
+ ignore_patterns=ignore_patterns,
139
+ include_fingerprints=include_fingerprints,
140
+ )
141
+ )
142
+
143
+ return node
144
+
145
+
146
+ def _is_hidden(path: Path) -> bool:
147
+ return path.name.startswith(".")
148
+
149
+
150
+ def _matches_ignore(name: str, relative_path: str, patterns: tuple[str, ...]) -> bool:
151
+ return any(
152
+ fnmatch(name, pattern) or fnmatch(relative_path, pattern)
153
+ for pattern in patterns
154
+ )
155
+
156
+
157
+ def diff_trees(current: TreeNode, previous: TreeNode) -> TreeNode:
158
+ if current.kind != previous.kind:
159
+ node = _clone_tree(current)
160
+ node.diff_status = "modified"
161
+ if node.is_dir:
162
+ node.children = [_mark_tree(child, "added") for child in node.children]
163
+ return node
164
+
165
+ if not current.is_dir:
166
+ node = _clone_tree(current)
167
+ node.diff_status = (
168
+ "unchanged"
169
+ if current.sha256 == previous.sha256 and current.size == previous.size
170
+ else "modified"
171
+ )
172
+ return node
173
+
174
+ previous_by_name = {child.name: child for child in previous.children}
175
+ current_by_name = {child.name: child for child in current.children}
176
+ merged_children: list[TreeNode] = []
177
+
178
+ for name in sorted(set(previous_by_name) | set(current_by_name), key=str.lower):
179
+ current_child = current_by_name.get(name)
180
+ previous_child = previous_by_name.get(name)
181
+
182
+ if current_child and previous_child:
183
+ merged_children.append(diff_trees(current_child, previous_child))
184
+ elif current_child:
185
+ merged_children.append(_mark_tree(current_child, "added"))
186
+ elif previous_child:
187
+ merged_children.append(_mark_tree(previous_child, "removed"))
188
+
189
+ merged_children.sort(key=lambda item: (not item.is_dir, item.name.lower()))
190
+ node = _clone_tree(current)
191
+ node.children = merged_children
192
+ node.diff_status = (
193
+ "modified"
194
+ if any(child.diff_status != "unchanged" for child in merged_children)
195
+ else "unchanged"
196
+ )
197
+ return node
198
+
199
+
200
+ def _mark_tree(node: TreeNode, status: DiffStatus) -> TreeNode:
201
+ cloned = _clone_tree(node)
202
+ cloned.diff_status = status
203
+ cloned.children = [_mark_tree(child, status) for child in cloned.children]
204
+ return cloned
205
+
206
+
207
+ def _clone_tree(node: TreeNode) -> TreeNode:
208
+ return TreeNode(
209
+ name=node.name,
210
+ path=node.path,
211
+ kind=node.kind,
212
+ children=[_clone_tree(child) for child in node.children],
213
+ error=node.error,
214
+ size=node.size,
215
+ sha256=node.sha256,
216
+ diff_status=node.diff_status,
217
+ )
218
+
219
+
220
+ def _sha256_for_file(path: Path) -> str:
221
+ hasher = hashlib.sha256()
222
+ with path.open("rb") as handle:
223
+ while chunk := handle.read(8192):
224
+ hasher.update(chunk)
225
+ return hasher.hexdigest()
@@ -0,0 +1,281 @@
1
+ Metadata-Version: 2.4
2
+ Name: trls-cli
3
+ Version: 0.2.0
4
+ Summary: A modern tree-style CLI for humans and AI prompts.
5
+ Author: Yonglin and Yuanben
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Keywords: cli,filesystem,llm,prompt,tree
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Classifier: Topic :: Utilities
19
+ Requires-Python: >=3.9
20
+ Requires-Dist: rich>=13.9.0
21
+ Provides-Extra: dev
22
+ Requires-Dist: build>=1.2.2; extra == 'dev'
23
+ Requires-Dist: pytest>=8.3.4; extra == 'dev'
24
+ Requires-Dist: twine>=6.1.0; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # trls
28
+
29
+ `trls` is a modern tree-style CLI that renders file structures for humans and AI prompts.
30
+
31
+ It is built for two common workflows:
32
+
33
+ - inspect a project in the terminal with a cleaner, more readable tree view
34
+ - export a stable directory snapshot that can be pasted into prompts, docs, or automation
35
+
36
+ ## Why `trls`
37
+
38
+ Classic `tree` output is useful, but `trls` focuses on modern developer workflows:
39
+
40
+ - `rich` tree output by default for a polished terminal experience
41
+ - `prompt` output for AI-friendly, copy-pasteable directory snapshots
42
+ - snapshot diff by default, so each run shows what changed since last time
43
+ - `-c` clipboard copy for a compact, paste-ready LLM format
44
+ - `text` output for universal shell compatibility
45
+ - `markdown` output for docs and prompt sharing
46
+ - `json` output for tooling and automation
47
+
48
+ The first release keeps the scope intentionally small: one command, predictable output, and formats that are easy to copy into AI conversations.
49
+
50
+ ## Install
51
+
52
+ ```bash
53
+ pip install trls-cli
54
+ ```
55
+
56
+ ## Quick start
57
+
58
+ Show the current directory:
59
+
60
+ ```bash
61
+ trls
62
+ ```
63
+
64
+ By default, this compares the current tree against the previous run for the same path. On first run, it simply prints the current tree and creates the baseline.
65
+
66
+ Export an AI-friendly directory snapshot:
67
+
68
+ ```bash
69
+ trls . --format prompt
70
+ ```
71
+
72
+ Copy a compact LLM-friendly version to the clipboard while keeping the normal terminal output:
73
+
74
+ ```bash
75
+ trls . -c
76
+ ```
77
+
78
+ Snapshots are saved automatically after each run. If you want to refresh the baseline without showing any diff markers, force-save it explicitly:
79
+
80
+ ```bash
81
+ trls . -save
82
+ ```
83
+
84
+ Compare against the previous run for the same path:
85
+
86
+ ```bash
87
+ trls . -diff
88
+ ```
89
+
90
+ Compare against an explicit snapshot file:
91
+
92
+ ```bash
93
+ trls . -compare snapshot.json --format prompt
94
+ ```
95
+
96
+ Show a specific directory:
97
+
98
+ ```bash
99
+ trls path/to/project
100
+ ```
101
+
102
+ Limit traversal depth:
103
+
104
+ ```bash
105
+ trls . --depth 2
106
+ ```
107
+
108
+ Include hidden files:
109
+
110
+ ```bash
111
+ trls . --hidden
112
+ ```
113
+
114
+ Ignore noisy paths:
115
+
116
+ ```bash
117
+ trls . --ignore ".git" --ignore "__pycache__" --ignore "*.pyc"
118
+ ```
119
+
120
+ Export machine-readable output:
121
+
122
+ ```bash
123
+ trls . --format json
124
+ ```
125
+
126
+ ## Example output
127
+
128
+ Prompt output:
129
+
130
+ ```text
131
+ [dir] project/
132
+ [doc] README.md
133
+ [meta] pyproject.toml
134
+ [dir] src/
135
+ [dir] trls/
136
+ [py] cli.py
137
+ [py] tree.py
138
+ ```
139
+
140
+ Text output:
141
+
142
+ ```text
143
+ project/
144
+ |-- README.md
145
+ |-- pyproject.toml
146
+ `-- src/
147
+ `-- trls/
148
+ |-- cli.py
149
+ `-- tree.py
150
+ ```
151
+
152
+ Markdown output:
153
+
154
+ ```markdown
155
+ - `project/`
156
+ - `README.md`
157
+ - `pyproject.toml`
158
+ - `src/`
159
+ - `trls/`
160
+ - `cli.py`
161
+ - `tree.py`
162
+ ```
163
+
164
+ Prompt diff output:
165
+
166
+ ```text
167
+ [dir] project/
168
+ ~ [doc] README.md
169
+ [meta] pyproject.toml
170
+ [dir] src/
171
+ - [py] old.py
172
+ + [py] new.py
173
+ ```
174
+
175
+ Clipboard copy output with `-c`:
176
+
177
+ ```text
178
+ project/
179
+ project/src/
180
+ project/src/trls/
181
+ project/src/trls/cli.py
182
+ ~ project/README.md
183
+ + project/src/new_file.py
184
+ - project/src/old_file.py
185
+ ```
186
+
187
+ ## Python API
188
+
189
+ `trls` can also be used as a small Python library:
190
+
191
+ ```python
192
+ from trls import render_prompt, scan_tree
193
+
194
+ tree = scan_tree("src", max_depth=2, ignore_patterns=["__pycache__", "*.pyc"])
195
+ print(render_prompt(tree))
196
+ ```
197
+
198
+ ## Snapshot Diff
199
+
200
+ `trls` automatically persists a file tree snapshot after each run and can compare future scans against it.
201
+
202
+ Diff markers:
203
+
204
+ - `+` added file or directory
205
+ - `-` removed file or directory
206
+ - `~` modified file or directory
207
+
208
+ Current behavior in `v0.2.0`:
209
+
210
+ - every successful run updates the latest snapshot for that path
211
+ - default `trls` output compares against the previous run for that path
212
+ - the first `--diff-last` run creates the baseline automatically if none exists yet
213
+ - modification detection is hash-based for files
214
+ - directories are marked modified when any descendant changes
215
+ - explicit snapshots and automatic "last snapshot" comparison are both supported
216
+
217
+ ## Clipboard Copy
218
+
219
+ Use `trls -c` to keep the normal terminal render while also copying a compact prompt-oriented version to your clipboard.
220
+
221
+ Clipboard behavior:
222
+
223
+ - the first line keeps the root directory name
224
+ - every later line uses a full root-prefixed path
225
+ - `+`, `-`, and `~` diff markers are preserved
226
+ - the clipboard payload is intentionally different from the terminal render to save tokens
227
+
228
+ ## CLI contract for `v0.2.0`
229
+
230
+ The first public release guarantees:
231
+
232
+ - scan the current directory or a user-provided path
233
+ - five output formats: `rich`, `prompt`, `text`, `markdown`, and `json`
234
+ - `rich` is the default output format
235
+ - default output is also a diff against the previous run when a baseline exists
236
+ - hidden files are excluded by default and included with `--hidden`
237
+ - ignore rules may be repeated with `--ignore`
238
+ - snapshots are automatically updated after successful runs
239
+ - snapshot diffing is available via `-save`/`--save-snapshot`, `-diff`/`--diff-last`, `-compare`/`--compare-with`, and `-update`/`--update-snapshot`
240
+ - clipboard copy is available via `-c`/`--copy`
241
+ - directories are listed before files and names are sorted case-insensitively
242
+ - unreadable directories are reported in the output instead of crashing the renderers
243
+
244
+ ## Development
245
+
246
+ On macOS or Linux:
247
+
248
+ ```bash
249
+ python -m venv .venv
250
+ source .venv/bin/activate
251
+ python -m pip install --upgrade pip
252
+ pip install -e ".[dev]"
253
+ pytest
254
+ ```
255
+
256
+ On Windows PowerShell:
257
+
258
+ ```powershell
259
+ python -m venv .venv
260
+ .venv\Scripts\Activate.ps1
261
+ python -m pip install --upgrade pip
262
+ pip install -e ".[dev]"
263
+ pytest
264
+ ```
265
+
266
+ ## Release
267
+
268
+ Build locally:
269
+
270
+ ```bash
271
+ python -m build
272
+ twine check dist/*
273
+ ```
274
+
275
+ Recommended flow:
276
+
277
+ 1. Upload a test release to TestPyPI.
278
+ 2. Verify `pip install`, `trls --version`, and one real CLI example.
279
+ 3. Publish the tagged release to PyPI with Trusted Publishing.
280
+
281
+ See `RELEASING.md` and `.github/workflows/publish.yml` for the release checklist.
@@ -0,0 +1,12 @@
1
+ trls/__init__.py,sha256=xSZVUpeVLDrxFB3xc5wTgP1mfKgCgw9wT8e3VU0DaRw,415
2
+ trls/__main__.py,sha256=KEYAlzDhxAiYWK82jQxP__VHf9qkrIn7vfRL_e_Cdus,84
3
+ trls/cli.py,sha256=hRkNQ7TUt9Gh8GB4OxdIDKfswPTXy313zs9hRZc4eLI,4920
4
+ trls/clipboard.py,sha256=Hd8T8xkO8PyfhI89oX1a046fh5fC9hc8_3HKDWicgF4,1353
5
+ trls/renderers.py,sha256=wtK0ceHBOXVIHEz552jLNuJlYxp1RLQ_fVpLxGNbqsk,5858
6
+ trls/snapshot.py,sha256=x8ncrnDETVwfSsAXzdhcM3_FfTStwxBxE_aLmMAhyvg,2083
7
+ trls/tree.py,sha256=rkY6vYXBWpuqqUCfikmI8bBXR2TK3kZrRhu0Ao4f1C4,6775
8
+ trls_cli-0.2.0.dist-info/METADATA,sha256=ji708F_GIx4G90BZ8PLCRd2XvjWoObbtIvNMB7YtGZM,6675
9
+ trls_cli-0.2.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
10
+ trls_cli-0.2.0.dist-info/entry_points.txt,sha256=WxQnx5xOstjnZgDPcNB0RAL0XbjvYvEPntpfysdk0jQ,39
11
+ trls_cli-0.2.0.dist-info/licenses/LICENSE,sha256=J1usaWY_pV5oRHZpXuBXPTtCBGuq6TCQw_mI0DNXaho,1085
12
+ trls_cli-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ trls = trls.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yuanben
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.