trls-cli 0.4.0__tar.gz → 0.5.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trls-cli
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: A modern tree-style CLI for humans and AI prompts.
5
5
  Author: Yonglin and Yuanben
6
6
  License: MIT
@@ -41,6 +41,7 @@ Classic `tree` output is useful, but `trls` focuses on modern developer workflow
41
41
  - `prompt` output for AI-friendly, copy-pasteable directory snapshots
42
42
  - snapshot diff by default, so each run shows what changed since last time
43
43
  - `-c` clipboard copy for a compact, paste-ready LLM format
44
+ - `--skill` output for agent workflows, with automatic clipboard copy
44
45
  - always-on progress feedback during scans in interactive terminals
45
46
  - richer colors for common media, office, and data files
46
47
  - `text` output for universal shell compatibility
@@ -77,6 +78,12 @@ Copy a compact LLM-friendly version to the clipboard while keeping the normal te
77
78
  trls . -c
78
79
  ```
79
80
 
81
+ Print the built-in agent skill text and copy it to the clipboard:
82
+
83
+ ```bash
84
+ trls --skill
85
+ ```
86
+
80
87
  In interactive terminals, `trls` also shows live scan progress so large trees do not feel stuck.
81
88
 
82
89
  Snapshots are saved automatically after each run. If you want to refresh the baseline without showing any diff markers, force-save it explicitly:
@@ -206,7 +213,7 @@ Diff markers:
206
213
  - `-` removed file or directory
207
214
  - `~` modified file or directory
208
215
 
209
- Current behavior in `v0.4.0`:
216
+ Current behavior in `v0.5.0`:
210
217
 
211
218
  - every successful run updates the latest snapshot for that path
212
219
  - default `trls` output compares against the previous run for that path
@@ -228,6 +235,38 @@ Clipboard behavior:
228
235
  - the clipboard payload is intentionally different from the terminal render to save tokens
229
236
  - WSL-aware clipboard fallbacks are attempted before reporting an error
230
237
 
238
+ ## Agent Skill Output
239
+
240
+ Use `trls -skill` or `trls --skill` to print a built-in English skill block for Cursor-style agents and copy it to the clipboard automatically.
241
+
242
+ Skill behavior:
243
+
244
+ - prints a ready-to-paste agent instruction block to stdout
245
+ - copies the same text to the clipboard automatically
246
+ - tells agents to prefer `trls` for file structure analysis before broad manual exploration
247
+ - suggests `--format prompt`, `--depth`, `--ignore`, and `-diff` as the primary workflow
248
+
249
+ Recommended agent command order:
250
+
251
+ 1. Run `trls <path> --format prompt` for the first structure pass.
252
+ 2. Add `--depth <n>` if the repository is too large.
253
+ 3. Add repeated `--ignore <pattern>` options to remove noisy folders such as `.git`, `node_modules`, `dist`, or build output.
254
+ 4. Add `--hidden` only when hidden files are likely relevant.
255
+ 5. Use `-diff` when you want to compare against the previous snapshot.
256
+ 6. Use `-c` when you want a compact clipboard-oriented summary.
257
+
258
+ Common agent examples:
259
+
260
+ ```bash
261
+ trls . --format prompt
262
+ trls . --format prompt --depth 2
263
+ trls . --format prompt --ignore .git --ignore node_modules --ignore dist
264
+ trls src --format prompt --depth 3
265
+ trls . -diff --format prompt
266
+ trls . -c
267
+ trls --skill
268
+ ```
269
+
231
270
  ## Progress And Color UX
232
271
 
233
272
  - interactive runs show a transient progress indicator while scanning
@@ -236,7 +275,7 @@ Clipboard behavior:
236
275
  - spreadsheet and office files such as `.xlsx`, `.csv`, `.docx`, and `.pptx` have their own color family
237
276
  - data-oriented files such as `.parquet` and `.ipynb` are also colorized
238
277
 
239
- ## CLI contract for `v0.4.0`
278
+ ## CLI contract for `v0.5.0`
240
279
 
241
280
  The first public release guarantees:
242
281
 
@@ -249,6 +288,7 @@ The first public release guarantees:
249
288
  - snapshots are automatically updated after successful runs
250
289
  - snapshot diffing is available via `-save`/`--save-snapshot`, `-diff`/`--diff-last`, `-compare`/`--compare-with`, and `-update`/`--update-snapshot`
251
290
  - clipboard copy is available via `-c`/`--copy`
291
+ - agent skill output is available via `-skill`/`--skill`
252
292
  - interactive scans show live progress feedback
253
293
  - directories are listed before files and names are sorted case-insensitively
254
294
  - unreadable directories are reported in the output instead of crashing the renderers
@@ -290,6 +330,7 @@ Recommended flow:
290
330
  2. Verify `pip install`, `trls --version`, and one real CLI example.
291
331
  3. Publish the tagged release to PyPI with Trusted Publishing.
292
332
 
333
+ .venv\Scripts\Activate.ps1
293
334
  Remove-Item -Recurse -Force .\dist
294
335
  python -m pytest
295
336
  python -m build
@@ -15,6 +15,7 @@ Classic `tree` output is useful, but `trls` focuses on modern developer workflow
15
15
  - `prompt` output for AI-friendly, copy-pasteable directory snapshots
16
16
  - snapshot diff by default, so each run shows what changed since last time
17
17
  - `-c` clipboard copy for a compact, paste-ready LLM format
18
+ - `--skill` output for agent workflows, with automatic clipboard copy
18
19
  - always-on progress feedback during scans in interactive terminals
19
20
  - richer colors for common media, office, and data files
20
21
  - `text` output for universal shell compatibility
@@ -51,6 +52,12 @@ Copy a compact LLM-friendly version to the clipboard while keeping the normal te
51
52
  trls . -c
52
53
  ```
53
54
 
55
+ Print the built-in agent skill text and copy it to the clipboard:
56
+
57
+ ```bash
58
+ trls --skill
59
+ ```
60
+
54
61
  In interactive terminals, `trls` also shows live scan progress so large trees do not feel stuck.
55
62
 
56
63
  Snapshots are saved automatically after each run. If you want to refresh the baseline without showing any diff markers, force-save it explicitly:
@@ -180,7 +187,7 @@ Diff markers:
180
187
  - `-` removed file or directory
181
188
  - `~` modified file or directory
182
189
 
183
- Current behavior in `v0.4.0`:
190
+ Current behavior in `v0.5.0`:
184
191
 
185
192
  - every successful run updates the latest snapshot for that path
186
193
  - default `trls` output compares against the previous run for that path
@@ -202,6 +209,38 @@ Clipboard behavior:
202
209
  - the clipboard payload is intentionally different from the terminal render to save tokens
203
210
  - WSL-aware clipboard fallbacks are attempted before reporting an error
204
211
 
212
+ ## Agent Skill Output
213
+
214
+ Use `trls -skill` or `trls --skill` to print a built-in English skill block for Cursor-style agents and copy it to the clipboard automatically.
215
+
216
+ Skill behavior:
217
+
218
+ - prints a ready-to-paste agent instruction block to stdout
219
+ - copies the same text to the clipboard automatically
220
+ - tells agents to prefer `trls` for file structure analysis before broad manual exploration
221
+ - suggests `--format prompt`, `--depth`, `--ignore`, and `-diff` as the primary workflow
222
+
223
+ Recommended agent command order:
224
+
225
+ 1. Run `trls <path> --format prompt` for the first structure pass.
226
+ 2. Add `--depth <n>` if the repository is too large.
227
+ 3. Add repeated `--ignore <pattern>` options to remove noisy folders such as `.git`, `node_modules`, `dist`, or build output.
228
+ 4. Add `--hidden` only when hidden files are likely relevant.
229
+ 5. Use `-diff` when you want to compare against the previous snapshot.
230
+ 6. Use `-c` when you want a compact clipboard-oriented summary.
231
+
232
+ Common agent examples:
233
+
234
+ ```bash
235
+ trls . --format prompt
236
+ trls . --format prompt --depth 2
237
+ trls . --format prompt --ignore .git --ignore node_modules --ignore dist
238
+ trls src --format prompt --depth 3
239
+ trls . -diff --format prompt
240
+ trls . -c
241
+ trls --skill
242
+ ```
243
+
205
244
  ## Progress And Color UX
206
245
 
207
246
  - interactive runs show a transient progress indicator while scanning
@@ -210,7 +249,7 @@ Clipboard behavior:
210
249
  - spreadsheet and office files such as `.xlsx`, `.csv`, `.docx`, and `.pptx` have their own color family
211
250
  - data-oriented files such as `.parquet` and `.ipynb` are also colorized
212
251
 
213
- ## CLI contract for `v0.4.0`
252
+ ## CLI contract for `v0.5.0`
214
253
 
215
254
  The first public release guarantees:
216
255
 
@@ -223,6 +262,7 @@ The first public release guarantees:
223
262
  - snapshots are automatically updated after successful runs
224
263
  - snapshot diffing is available via `-save`/`--save-snapshot`, `-diff`/`--diff-last`, `-compare`/`--compare-with`, and `-update`/`--update-snapshot`
225
264
  - clipboard copy is available via `-c`/`--copy`
265
+ - agent skill output is available via `-skill`/`--skill`
226
266
  - interactive scans show live progress feedback
227
267
  - directories are listed before files and names are sorted case-insensitively
228
268
  - unreadable directories are reported in the output instead of crashing the renderers
@@ -264,6 +304,7 @@ Recommended flow:
264
304
  2. Verify `pip install`, `trls --version`, and one real CLI example.
265
305
  3. Publish the tagged release to PyPI with Trusted Publishing.
266
306
 
307
+ .venv\Scripts\Activate.ps1
267
308
  Remove-Item -Recurse -Force .\dist
268
309
  python -m pytest
269
310
  python -m build
@@ -6,7 +6,7 @@ This repository is prepared for a CLI-first PyPI release.
6
6
 
7
7
  - distribution name: `trls-cli`
8
8
  - import package: `trls`
9
- - version: `0.4.0`
9
+ - version: `0.5.0`
10
10
  - release channel: TestPyPI first, then PyPI
11
11
 
12
12
  At the time of preparation, `https://pypi.org/project/trls-cli/` should be re-checked right before uploading.
@@ -79,8 +79,8 @@ The workflow already expects the `pypi` environment in GitHub Actions.
79
79
  After Trusted Publishing is configured:
80
80
 
81
81
  ```bash
82
- git tag v0.4.0
83
- git push origin v0.4.0
82
+ git tag v0.5.0
83
+ git push origin v0.5.0
84
84
  ```
85
85
 
86
86
  The GitHub Actions workflow will:
@@ -97,4 +97,4 @@ The GitHub Actions workflow will:
97
97
  3. Add the real repository URLs to `pyproject.toml`.
98
98
  4. Ensure README examples still match current CLI behavior.
99
99
  5. Upload to TestPyPI and validate installation.
100
- 6. Push the `v0.4.0` tag to trigger the production release.
100
+ 6. Push the `v0.5.0` tag to trigger the production release.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "trls-cli"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "A modern tree-style CLI for humans and AI prompts."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -8,7 +8,7 @@ from trls.renderers import (
8
8
  )
9
9
  from trls.tree import TreeNode, scan_tree
10
10
 
11
- __version__ = "0.4.0"
11
+ __version__ = "0.5.0"
12
12
 
13
13
  __all__ = [
14
14
  "TreeNode",
@@ -2,7 +2,6 @@ from __future__ import annotations
2
2
 
3
3
  import argparse
4
4
  import sys
5
- from pathlib import Path
6
5
  from typing import Sequence
7
6
 
8
7
  from rich.console import Console
@@ -10,6 +9,7 @@ from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
10
9
 
11
10
  from trls import __version__
12
11
  from trls.clipboard import copy_text
12
+ from trls.paths import normalize_input_path
13
13
  from trls.renderers import (
14
14
  build_rich_tree,
15
15
  render_compact_copy,
@@ -18,6 +18,7 @@ from trls.renderers import (
18
18
  render_prompt,
19
19
  render_text,
20
20
  )
21
+ from trls.skill import AGENT_SKILL_TEXT
21
22
  from trls.snapshot import load_last_snapshot, load_snapshot, save_last_snapshot
22
23
  from trls.tree import DEFAULT_IGNORE_PATTERNS, diff_trees, scan_tree
23
24
 
@@ -90,6 +91,12 @@ def build_parser() -> argparse.ArgumentParser:
90
91
  action="store_true",
91
92
  help="Copy a compact LLM-friendly version to the clipboard after rendering.",
92
93
  )
94
+ parser.add_argument(
95
+ "-skill",
96
+ "--skill",
97
+ action="store_true",
98
+ help="Print the built-in agent skill text and copy it to the clipboard.",
99
+ )
93
100
  parser.add_argument(
94
101
  "--version",
95
102
  action="version",
@@ -102,6 +109,15 @@ def main(argv: Sequence[str] | None = None) -> int:
102
109
  parser = build_parser()
103
110
  args = parser.parse_args(argv)
104
111
 
112
+ if args.skill:
113
+ print(AGENT_SKILL_TEXT)
114
+ try:
115
+ copy_text(AGENT_SKILL_TEXT)
116
+ except RuntimeError as exc:
117
+ print(f"Failed to copy to clipboard: {exc}", file=sys.stderr)
118
+ return 1
119
+ return 0
120
+
105
121
  if args.depth is not None and args.depth < 0:
106
122
  parser.error("--depth must be greater than or equal to 0")
107
123
  if args.diff_last and args.compare_with:
@@ -114,7 +130,7 @@ def main(argv: Sequence[str] | None = None) -> int:
114
130
  if args.update_snapshot and not (args.diff_last or args.compare_with):
115
131
  parser.error("--update-snapshot requires -diff/--diff-last or -compare/--compare-with")
116
132
 
117
- root_path = Path(args.path).expanduser().resolve()
133
+ root_path = normalize_input_path(args.path)
118
134
  needs_fingerprints = True
119
135
 
120
136
  current_tree = _scan_tree_with_progress(
@@ -1,108 +1,98 @@
1
- from __future__ import annotations
2
-
3
- import os
4
- import platform
5
- import shutil
6
- import subprocess
7
- from pathlib import Path
8
-
9
-
10
- def copy_text(text: str) -> None:
11
- backends = _clipboard_backends()
12
- if not backends:
13
- raise RuntimeError(
14
- "No supported clipboard command was found. "
15
- "On Linux/WSL, install wl-clipboard, xclip, or xsel."
16
- )
17
-
18
- errors: list[str] = []
19
- for backend_name, command in backends:
20
- try:
21
- _run_clipboard_command(command, text)
22
- return
23
- except RuntimeError as exc:
24
- errors.append(f"{backend_name}: {exc}")
25
-
26
- attempted_names = ", ".join(name for name, _ in backends)
27
- error_details = "; ".join(errors)
28
- raise RuntimeError(
29
- f"No clipboard backend succeeded. Tried: {attempted_names}. {error_details}"
30
- )
31
-
32
-
33
- def _clipboard_backends() -> list[tuple[str, list[str]]]:
34
- system = platform.system()
35
- backends: list[tuple[str, list[str]]] = []
36
-
37
- if system == "Windows":
38
- command = _resolve_command("clip")
39
- if command:
40
- backends.append(("clip", [command]))
41
- return backends
42
-
43
- if system == "Darwin":
44
- command = _resolve_command("pbcopy")
45
- if command:
46
- backends.append(("pbcopy", [command]))
47
- return backends
48
-
49
- if _is_wsl():
50
- clip_exe = _resolve_command("clip.exe") or _resolve_windows_clip_path()
51
- if clip_exe:
52
- backends.append(("clip.exe", [clip_exe]))
53
- powershell_exe = _resolve_command("powershell.exe")
54
- if powershell_exe:
55
- backends.append(
56
- (
57
- "powershell.exe Set-Clipboard",
58
- [powershell_exe, "-NoProfile", "-Command", "Set-Clipboard"],
59
- )
60
- )
61
-
62
- wl_copy = _resolve_command("wl-copy")
63
- if wl_copy:
64
- backends.append(("wl-copy", [wl_copy]))
65
-
66
- xclip = _resolve_command("xclip")
67
- if xclip:
68
- backends.append(("xclip", [xclip, "-selection", "clipboard"]))
69
-
70
- xsel = _resolve_command("xsel")
71
- if xsel:
72
- backends.append(("xsel", [xsel, "--clipboard", "--input"]))
73
-
74
- return backends
75
-
76
-
77
- def _resolve_command(name: str) -> str | None:
78
- return shutil.which(name)
79
-
80
-
81
- def _resolve_windows_clip_path() -> str | None:
82
- candidate = Path("/mnt/c/Windows/System32/clip.exe")
83
- return str(candidate) if candidate.exists() else None
84
-
85
-
86
- def _is_wsl() -> bool:
87
- if os.environ.get("WSL_DISTRO_NAME"):
88
- return True
89
- try:
90
- return "microsoft" in Path("/proc/version").read_text(encoding="utf-8").lower()
91
- except OSError:
92
- return False
93
-
94
-
95
- def _run_clipboard_command(command: list[str], text: str) -> None:
96
- try:
97
- subprocess.run(
98
- command,
99
- input=text,
100
- text=True,
101
- check=True,
102
- capture_output=True,
103
- )
104
- except FileNotFoundError as exc:
105
- raise RuntimeError(f"Clipboard command not found: {command[0]}") from exc
106
- except subprocess.CalledProcessError as exc:
107
- stderr = exc.stderr.strip() if exc.stderr else "unknown clipboard error"
108
- raise RuntimeError(f"Clipboard command failed: {stderr}") from exc
1
+ from __future__ import annotations
2
+
3
+ import platform
4
+ import shutil
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ from trls.paths import is_wsl
9
+
10
+
11
+ def copy_text(text: str) -> None:
12
+ backends = _clipboard_backends()
13
+ if not backends:
14
+ raise RuntimeError(
15
+ "No supported clipboard command was found. "
16
+ "On Linux/WSL, install wl-clipboard, xclip, or xsel."
17
+ )
18
+
19
+ errors: list[str] = []
20
+ for backend_name, command in backends:
21
+ try:
22
+ _run_clipboard_command(command, text)
23
+ return
24
+ except RuntimeError as exc:
25
+ errors.append(f"{backend_name}: {exc}")
26
+
27
+ attempted_names = ", ".join(name for name, _ in backends)
28
+ error_details = "; ".join(errors)
29
+ raise RuntimeError(
30
+ f"No clipboard backend succeeded. Tried: {attempted_names}. {error_details}"
31
+ )
32
+
33
+
34
+ def _clipboard_backends() -> list[tuple[str, list[str]]]:
35
+ system = platform.system()
36
+ backends: list[tuple[str, list[str]]] = []
37
+
38
+ if system == "Windows":
39
+ command = _resolve_command("clip")
40
+ if command:
41
+ backends.append(("clip", [command]))
42
+ return backends
43
+
44
+ if system == "Darwin":
45
+ command = _resolve_command("pbcopy")
46
+ if command:
47
+ backends.append(("pbcopy", [command]))
48
+ return backends
49
+
50
+ if is_wsl():
51
+ clip_exe = _resolve_command("clip.exe") or _resolve_windows_clip_path()
52
+ if clip_exe:
53
+ backends.append(("clip.exe", [clip_exe]))
54
+ powershell_exe = _resolve_command("powershell.exe")
55
+ if powershell_exe:
56
+ backends.append(
57
+ (
58
+ "powershell.exe Set-Clipboard",
59
+ [powershell_exe, "-NoProfile", "-Command", "Set-Clipboard"],
60
+ )
61
+ )
62
+
63
+ wl_copy = _resolve_command("wl-copy")
64
+ if wl_copy:
65
+ backends.append(("wl-copy", [wl_copy]))
66
+
67
+ xclip = _resolve_command("xclip")
68
+ if xclip:
69
+ backends.append(("xclip", [xclip, "-selection", "clipboard"]))
70
+
71
+ xsel = _resolve_command("xsel")
72
+ if xsel:
73
+ backends.append(("xsel", [xsel, "--clipboard", "--input"]))
74
+
75
+ return backends
76
+
77
+
78
+ def _resolve_command(name: str) -> str | None:
79
+ return shutil.which(name)
80
+
81
+
82
+ def _resolve_windows_clip_path() -> str | None:
83
+ candidate = Path("/mnt/c/Windows/System32/clip.exe")
84
+ return str(candidate) if candidate.exists() else None
85
+ def _run_clipboard_command(command: list[str], text: str) -> None:
86
+ try:
87
+ subprocess.run(
88
+ command,
89
+ input=text,
90
+ text=True,
91
+ check=True,
92
+ capture_output=True,
93
+ )
94
+ except FileNotFoundError as exc:
95
+ raise RuntimeError(f"Clipboard command not found: {command[0]}") from exc
96
+ except subprocess.CalledProcessError as exc:
97
+ stderr = exc.stderr.strip() if exc.stderr else "unknown clipboard error"
98
+ raise RuntimeError(f"Clipboard command failed: {stderr}") from exc
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import platform
5
+ import re
6
+ from pathlib import Path, PureWindowsPath
7
+
8
+ _WINDOWS_DRIVE_PATH_RE = re.compile(r"^([a-zA-Z]):(?:[\\/](.*))?$")
9
+ _WSL_MOUNT_PATH_RE = re.compile(r"^/mnt(?:/host)?/([a-zA-Z])(?:/(.*))?$")
10
+
11
+
12
+ def is_wsl() -> bool:
13
+ if os.environ.get("WSL_DISTRO_NAME"):
14
+ return True
15
+ try:
16
+ return "microsoft" in Path("/proc/version").read_text(encoding="utf-8").lower()
17
+ except OSError:
18
+ return False
19
+
20
+
21
+ def normalize_input_path(path_value: str | Path) -> Path:
22
+ translated = translate_input_path(path_value)
23
+ return Path(translated).expanduser().resolve()
24
+
25
+
26
+ def translate_input_path(path_value: str | Path) -> str:
27
+ raw_path = os.fspath(path_value)
28
+ mounted_windows_path = _parse_wsl_mounted_windows_path(raw_path)
29
+
30
+ if platform.system() == "Windows":
31
+ if mounted_windows_path is not None:
32
+ drive, parts = mounted_windows_path
33
+ return _build_windows_path(drive, parts)
34
+ return raw_path
35
+
36
+ if mounted_windows_path is not None:
37
+ drive, parts = mounted_windows_path
38
+ translated_path = _build_wsl_mount_path(drive, parts)
39
+ mount_root = Path("/mnt") / drive.lower()
40
+ if is_wsl() or mount_root.exists():
41
+ return translated_path
42
+ return raw_path
43
+
44
+ windows_drive_path = _parse_windows_drive_path(raw_path)
45
+ if windows_drive_path is None:
46
+ return raw_path
47
+
48
+ drive, parts = windows_drive_path
49
+ translated_path = _build_wsl_mount_path(drive, parts)
50
+ mount_root = Path("/mnt") / drive.lower()
51
+ if is_wsl() or mount_root.exists():
52
+ return translated_path
53
+ return raw_path
54
+
55
+
56
+ def canonicalize_path_identity(path_value: str | Path) -> str:
57
+ normalized_path = normalize_input_path(path_value)
58
+ windows_identity = _windows_identity(normalized_path)
59
+ if windows_identity is not None:
60
+ return f"win:{windows_identity}"
61
+ return f"posix:{normalized_path.as_posix()}"
62
+
63
+
64
+ def _parse_windows_drive_path(raw_path: str) -> tuple[str, list[str]] | None:
65
+ match = _WINDOWS_DRIVE_PATH_RE.match(raw_path)
66
+ if match is None:
67
+ return None
68
+
69
+ drive = match.group(1)
70
+ remainder = match.group(2) or ""
71
+ parts = [part for part in re.split(r"[\\/]+", remainder) if part]
72
+ return drive, parts
73
+
74
+
75
+ def _parse_wsl_mounted_windows_path(raw_path: str) -> tuple[str, list[str]] | None:
76
+ match = _WSL_MOUNT_PATH_RE.match(raw_path)
77
+ if match is None:
78
+ return None
79
+
80
+ drive = match.group(1)
81
+ remainder = match.group(2) or ""
82
+ parts = [part for part in remainder.split("/") if part]
83
+ return drive, parts
84
+
85
+
86
+ def _build_windows_path(drive: str, parts: list[str]) -> str:
87
+ return str(PureWindowsPath(f"{drive.upper()}:\\", *parts))
88
+
89
+
90
+ def _build_wsl_mount_path(drive: str, parts: list[str]) -> str:
91
+ mounted_path = Path("/mnt") / drive.lower()
92
+ for part in parts:
93
+ mounted_path /= part
94
+ return mounted_path.as_posix()
95
+
96
+
97
+ def _windows_identity(path: Path) -> str | None:
98
+ windows_drive_path = _parse_windows_drive_path(str(path))
99
+ if windows_drive_path is not None:
100
+ drive, parts = windows_drive_path
101
+ return _build_windows_identity(drive, parts)
102
+
103
+ mounted_windows_path = _parse_wsl_mounted_windows_path(path.as_posix())
104
+ if mounted_windows_path is not None:
105
+ drive, parts = mounted_windows_path
106
+ return _build_windows_identity(drive, parts)
107
+
108
+ return None
109
+
110
+
111
+ def _build_windows_identity(drive: str, parts: list[str]) -> str:
112
+ if not parts:
113
+ return drive.lower()
114
+ return f"{drive.lower()}/{'/'.join(parts)}"
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ AGENT_SKILL_TEXT = """Use `trls` first when you need to understand a project's file structure.
4
+
5
+ Priority rules:
6
+ - Prefer `trls` over ad hoc `ls`, `tree`, or broad recursive file listing when your goal is repository structure analysis.
7
+ - Start with `trls <path> --format prompt` for an AI-friendly directory snapshot.
8
+ - If the tree is too large, rerun with `--depth <n>` before opening files.
9
+ - If the tree is noisy, rerun with repeated `--ignore <pattern>` arguments.
10
+ - Use `--hidden` only when hidden files are likely relevant.
11
+ - Use `-diff` when you need to inspect structure changes between runs.
12
+ - Use `-c` when you want a compact clipboard-friendly summary for prompts.
13
+ - Use `--format json` only when a downstream tool needs machine-readable output.
14
+
15
+ Recommended workflow:
16
+ 1. Run `trls <target> --format prompt`.
17
+ 2. If needed, narrow the scope with `trls <target> --format prompt --depth <n>`.
18
+ 3. If needed, remove noise with repeated `--ignore` patterns such as `.git`, `node_modules`, `dist`, `build`, or caches.
19
+ 4. Review the tree and identify the smallest relevant subdirectory.
20
+ 5. Run `trls` again on that narrower path before opening many files.
21
+ 6. Only then open specific files or run targeted code searches.
22
+
23
+ Argument guide:
24
+ - `--format prompt`: default choice for agent-oriented structure inspection
25
+ - `--depth <n>`: limit traversal when the repository is large
26
+ - `--ignore <pattern>`: suppress noisy paths; can be repeated
27
+ - `--hidden`: include hidden files and directories when required
28
+ - `-diff`: compare the current tree with the previous snapshot
29
+ - `-c`: copy a compact prompt-oriented summary to the clipboard
30
+ - `--format json`: use for automation, not for first-pass exploration
31
+
32
+ Examples:
33
+ - `trls . --format prompt`
34
+ - `trls . --format prompt --depth 2`
35
+ - `trls . --format prompt --ignore .git --ignore node_modules --ignore dist`
36
+ - `trls src --format prompt --depth 3 -diff`
37
+ - `trls . -c`
38
+
39
+ Goal:
40
+ Use `trls` to reduce noisy exploration, keep structural analysis consistent, and produce prompt-ready project snapshots before deeper file inspection.
41
+ """
@@ -1,60 +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)
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.paths import canonicalize_path_identity, normalize_input_path
10
+ from trls.tree import TreeNode
11
+
12
+ SNAPSHOT_VERSION = 1
13
+ SNAPSHOT_DIR_ENV = "TRLS_SNAPSHOT_DIR"
14
+
15
+
16
+ def default_snapshot_path(root: str | Path) -> Path:
17
+ normalized_root = canonicalize_path_identity(root)
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 normalize_input_path(configured_dir)
26
+ return Path.home() / ".trls" / "snapshots"
27
+
28
+
29
+ def save_snapshot(snapshot_path: str | Path, root: str | Path, tree: TreeNode) -> Path:
30
+ destination = normalize_input_path(snapshot_path)
31
+ destination.parent.mkdir(parents=True, exist_ok=True)
32
+ root_path = normalize_input_path(root)
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 = normalize_input_path(snapshot_path)
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 {normalize_input_path(root)}. "
58
+ "Run trls with --save-snapshot first."
59
+ )
60
+ return load_snapshot(snapshot_path)
@@ -6,6 +6,8 @@ from fnmatch import fnmatch
6
6
  from pathlib import Path
7
7
  from typing import Any, Callable, Literal
8
8
 
9
+ from trls.paths import normalize_input_path
10
+
9
11
  DEFAULT_IGNORE_PATTERNS = (".venv", "dist", "__pycache__")
10
12
  DiffStatus = Literal["added", "removed", "modified", "unchanged"]
11
13
 
@@ -74,7 +76,7 @@ def scan_tree(
74
76
  include_fingerprints: bool = False,
75
77
  on_visit: Callable[[Path], None] | None = None,
76
78
  ) -> TreeNode:
77
- root_path = Path(root).expanduser().resolve()
79
+ root_path = normalize_input_path(root)
78
80
  if not root_path.exists():
79
81
  raise FileNotFoundError(f"Path does not exist: {root}")
80
82
 
@@ -1,11 +1,14 @@
1
1
  import json
2
+ from pathlib import Path
2
3
 
3
4
  import pytest
4
5
 
5
6
  from trls import __version__
6
7
  import trls.clipboard as clipboard_module
7
8
  import trls.cli as cli_module
9
+ import trls.snapshot as snapshot_module
8
10
  from trls.cli import main
11
+ from trls.skill import AGENT_SKILL_TEXT
9
12
  from trls.snapshot import save_snapshot
10
13
  from trls.tree import scan_tree
11
14
 
@@ -97,6 +100,111 @@ def test_cli_json_output(capsys, tmp_path):
97
100
  assert any(child["name"] == "src" for child in payload["children"])
98
101
 
99
102
 
103
+ def test_cli_skill_prints_and_copies_without_scanning(capsys, monkeypatch):
104
+ copied = {}
105
+
106
+ def fake_copy_text(text):
107
+ copied["text"] = text
108
+
109
+ monkeypatch.setattr(cli_module, "copy_text", fake_copy_text)
110
+ monkeypatch.setattr(
111
+ cli_module,
112
+ "normalize_input_path",
113
+ lambda raw_path: (_ for _ in ()).throw(AssertionError("skill mode should not scan")),
114
+ )
115
+
116
+ exit_code = main(["--skill"])
117
+ captured = capsys.readouterr()
118
+
119
+ assert exit_code == 0
120
+ assert captured.out == AGENT_SKILL_TEXT + "\n"
121
+ assert copied["text"] == AGENT_SKILL_TEXT
122
+
123
+ @pytest.mark.parametrize("flag", ["-skill", "--skill"])
124
+ def test_cli_skill_aliases_are_supported(capsys, monkeypatch, flag):
125
+ monkeypatch.setattr(cli_module, "copy_text", lambda text: None)
126
+
127
+ exit_code = main([flag])
128
+ captured = capsys.readouterr()
129
+
130
+ assert exit_code == 0
131
+ assert "Use `trls` first" in captured.out
132
+
133
+
134
+ def test_cli_short_s_is_not_skill_alias(capsys):
135
+ with pytest.raises(SystemExit):
136
+ main(["-s"])
137
+
138
+ captured = capsys.readouterr()
139
+
140
+ assert "ambiguous option: -s" in captured.err
141
+
142
+
143
+ def test_cli_skill_reports_clipboard_failure(capsys, monkeypatch):
144
+ def failing_copy_text(text):
145
+ raise RuntimeError("clipboard unavailable")
146
+
147
+ monkeypatch.setattr(cli_module, "copy_text", failing_copy_text)
148
+
149
+ exit_code = main(["--skill"])
150
+ captured = capsys.readouterr()
151
+
152
+ assert exit_code == 1
153
+ assert captured.out == AGENT_SKILL_TEXT + "\n"
154
+ assert "Failed to copy to clipboard: clipboard unavailable" in captured.err
155
+
156
+
157
+ def test_cli_normalizes_foreign_input_path_before_scan(capsys, monkeypatch, tmp_path):
158
+ root = create_project(tmp_path)
159
+ normalized_inputs = []
160
+
161
+ def fake_normalize_input_path(raw_path):
162
+ normalized_inputs.append(raw_path)
163
+ return root
164
+
165
+ monkeypatch.setattr(cli_module, "normalize_input_path", fake_normalize_input_path)
166
+
167
+ exit_code = main(["/mnt/host/c/Users/user/Desktop/ZYB/genewa/trls", "--format", "text"])
168
+ captured = capsys.readouterr()
169
+
170
+ assert exit_code == 0
171
+ assert normalized_inputs == ["/mnt/host/c/Users/user/Desktop/ZYB/genewa/trls"]
172
+ assert "demo/" in captured.out
173
+
174
+
175
+ def test_cli_snapshot_dir_env_uses_normalized_path(capsys, monkeypatch, tmp_path):
176
+ root = create_project(tmp_path)
177
+ root_input = "/mnt/c/Users/user/Desktop/ZYB/genewa/demo"
178
+ snapshot_env = "/mnt/host/c/Users/user/.trls/snapshots"
179
+ normalized_snapshot_dir = tmp_path / "snapshots"
180
+ normalized_calls = []
181
+
182
+ def fake_cli_normalize_input_path(raw_path):
183
+ normalized_calls.append(raw_path)
184
+ if raw_path == root_input:
185
+ return root
186
+ return Path(raw_path).resolve()
187
+
188
+ def fake_snapshot_normalize_input_path(raw_path):
189
+ normalized_calls.append(raw_path)
190
+ if raw_path == snapshot_env:
191
+ return normalized_snapshot_dir
192
+ if raw_path == root_input:
193
+ return root
194
+ return Path(raw_path).resolve()
195
+
196
+ monkeypatch.setenv("TRLS_SNAPSHOT_DIR", snapshot_env)
197
+ monkeypatch.setattr(cli_module, "normalize_input_path", fake_cli_normalize_input_path)
198
+ monkeypatch.setattr(snapshot_module, "normalize_input_path", fake_snapshot_normalize_input_path)
199
+
200
+ exit_code = main([root_input, "--format", "prompt"])
201
+ capsys.readouterr()
202
+
203
+ assert exit_code == 0
204
+ assert snapshot_env in normalized_calls
205
+ assert any(normalized_snapshot_dir.iterdir())
206
+
207
+
100
208
  def test_cli_copy_writes_compact_clipboard_output(capsys, monkeypatch, tmp_path):
101
209
  root = create_project(tmp_path)
102
210
  copied = {}
@@ -319,7 +427,7 @@ def test_cli_diff_last_first_run_creates_baseline_without_error(capsys, monkeypa
319
427
  def test_copy_text_uses_clip_exe_in_wsl(monkeypatch):
320
428
  calls = []
321
429
 
322
- monkeypatch.setattr(clipboard_module, "_is_wsl", lambda: True)
430
+ monkeypatch.setattr(clipboard_module, "is_wsl", lambda: True)
323
431
  monkeypatch.setattr(clipboard_module.platform, "system", lambda: "Linux")
324
432
  monkeypatch.setattr(
325
433
  clipboard_module,
@@ -342,7 +450,7 @@ def test_copy_text_uses_clip_exe_in_wsl(monkeypatch):
342
450
  def test_copy_text_reports_install_hint_when_no_backend_exists(monkeypatch):
343
451
  monkeypatch.delenv("WSL_DISTRO_NAME", raising=False)
344
452
  monkeypatch.setattr(clipboard_module.platform, "system", lambda: "Linux")
345
- monkeypatch.setattr(clipboard_module, "_is_wsl", lambda: False)
453
+ monkeypatch.setattr(clipboard_module, "is_wsl", lambda: False)
346
454
  monkeypatch.setattr(clipboard_module, "_resolve_command", lambda name: None)
347
455
  monkeypatch.setattr(clipboard_module, "_resolve_windows_clip_path", lambda: None)
348
456
 
@@ -1,5 +1,8 @@
1
1
  import json
2
+ import os
2
3
 
4
+ import trls.paths as paths_module
5
+ import trls.snapshot as snapshot_module
3
6
  from trls import (
4
7
  build_rich_tree,
5
8
  render_compact_copy,
@@ -9,7 +12,8 @@ from trls import (
9
12
  render_text,
10
13
  scan_tree,
11
14
  )
12
- from trls.snapshot import load_snapshot, save_snapshot
15
+ from trls.paths import canonicalize_path_identity
16
+ from trls.snapshot import default_snapshot_path, load_snapshot, save_snapshot, snapshot_storage_dir
13
17
  from trls.tree import diff_trees
14
18
 
15
19
 
@@ -95,6 +99,88 @@ def test_rich_renderer_applies_styles_by_node_type(tmp_path):
95
99
  assert labels["sheet.xlsx"] == "bright_green"
96
100
 
97
101
 
102
+ def test_translate_input_path_converts_wsl_mount_paths_for_windows_runtime(monkeypatch):
103
+ monkeypatch.setattr(paths_module.platform, "system", lambda: "Windows")
104
+
105
+ assert (
106
+ paths_module.translate_input_path("/mnt/c/Users/test/project")
107
+ == r"C:\Users\test\project"
108
+ )
109
+ assert (
110
+ paths_module.translate_input_path("/mnt/host/d/Work/demo")
111
+ == r"D:\Work\demo"
112
+ )
113
+
114
+
115
+ def test_translate_input_path_converts_windows_paths_for_wsl_runtime(monkeypatch):
116
+ monkeypatch.setattr(paths_module.platform, "system", lambda: "Linux")
117
+ monkeypatch.setattr(paths_module, "is_wsl", lambda: True)
118
+
119
+ assert (
120
+ paths_module.translate_input_path(r"C:\Users\test\project")
121
+ == "/mnt/c/Users/test/project"
122
+ )
123
+ assert paths_module.translate_input_path("D:/Work/demo") == "/mnt/d/Work/demo"
124
+
125
+
126
+ def test_translate_input_path_normalizes_host_mount_paths_for_wsl_runtime(monkeypatch):
127
+ monkeypatch.setattr(paths_module.platform, "system", lambda: "Linux")
128
+ monkeypatch.setattr(paths_module, "is_wsl", lambda: True)
129
+
130
+ assert (
131
+ paths_module.translate_input_path("/mnt/host/c/Users/test/project")
132
+ == "/mnt/c/Users/test/project"
133
+ )
134
+
135
+
136
+ def test_canonicalize_path_identity_matches_equivalent_aliases(monkeypatch, tmp_path):
137
+ project = create_sample_tree(tmp_path)
138
+ alias_map = {
139
+ os.fspath(project): project,
140
+ "/mnt/c/Users/test/project": project,
141
+ r"C:\Users\test\project": project,
142
+ }
143
+
144
+ monkeypatch.setattr(
145
+ paths_module,
146
+ "normalize_input_path",
147
+ lambda raw_path: alias_map[os.fspath(raw_path)],
148
+ )
149
+
150
+ mounted_identity = canonicalize_path_identity("/mnt/c/Users/test/project")
151
+ windows_identity = canonicalize_path_identity(r"C:\Users\test\project")
152
+
153
+ assert mounted_identity == windows_identity
154
+
155
+
156
+ def test_snapshot_storage_dir_normalizes_foreign_env_path(monkeypatch, tmp_path):
157
+ normalized_snapshot_dir = tmp_path / "snapshots"
158
+ foreign_snapshot_dir = "/mnt/host/c/Users/user/.trls/snapshots"
159
+
160
+ monkeypatch.setenv(snapshot_module.SNAPSHOT_DIR_ENV, foreign_snapshot_dir)
161
+ monkeypatch.setattr(
162
+ snapshot_module,
163
+ "normalize_input_path",
164
+ lambda raw_path: normalized_snapshot_dir,
165
+ )
166
+
167
+ assert snapshot_storage_dir() == normalized_snapshot_dir
168
+
169
+
170
+ def test_default_snapshot_path_matches_equivalent_aliases(monkeypatch, tmp_path):
171
+ monkeypatch.setattr(snapshot_module, "snapshot_storage_dir", lambda: tmp_path)
172
+ monkeypatch.setattr(
173
+ snapshot_module,
174
+ "canonicalize_path_identity",
175
+ lambda raw_path: "win:c/Users/test/project",
176
+ )
177
+
178
+ mounted_path = default_snapshot_path("/mnt/c/Users/test/project")
179
+ windows_path = default_snapshot_path(r"C:\Users\test\project")
180
+
181
+ assert mounted_path == windows_path
182
+
183
+
98
184
  def test_snapshot_round_trip_and_diff_statuses(tmp_path):
99
185
  project = create_sample_tree(tmp_path)
100
186
  snapshot_path = tmp_path / "snapshot.json"
File without changes
File without changes
File without changes
File without changes