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 +23 -0
- trls/__main__.py +5 -0
- trls/cli.py +165 -0
- trls/clipboard.py +42 -0
- trls/renderers.py +211 -0
- trls/snapshot.py +60 -0
- trls/tree.py +225 -0
- trls_cli-0.2.0.dist-info/METADATA +281 -0
- trls_cli-0.2.0.dist-info/RECORD +12 -0
- trls_cli-0.2.0.dist-info/WHEEL +4 -0
- trls_cli-0.2.0.dist-info/entry_points.txt +2 -0
- trls_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
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
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,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.
|