codetool-explore 0.5.0__tar.gz → 0.6.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.
Files changed (69) hide show
  1. codetool_explore-0.6.0/.gitignore +11 -0
  2. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/PKG-INFO +22 -19
  3. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/README.md +21 -18
  4. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/pyproject.toml +1 -1
  5. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/scripts/update_readme_benchmarks.py +17 -37
  6. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/src/codetool_explore/api.py +7 -6
  7. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/src/codetool_explore/cli.py +2 -2
  8. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/src/codetool_explore/explorer.py +235 -62
  9. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/tests/test_api.py +121 -1
  10. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/tests/test_cli.py +57 -0
  11. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/uv.lock +1 -1
  12. codetool_explore-0.5.0/.gitignore +0 -19
  13. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/LICENSE +0 -0
  14. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/benchmarks/benchmark_output_lengths.py +0 -0
  15. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/benchmarks/benchmark_search.py +0 -0
  16. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/hatch_build.py +0 -0
  17. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/rust/Cargo.lock +0 -0
  18. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/rust/Cargo.toml +0 -0
  19. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/rust/src/app.rs +0 -0
  20. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/rust/src/case.rs +0 -0
  21. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/rust/src/config.rs +0 -0
  22. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/rust/src/constants.rs +0 -0
  23. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/rust/src/file_search.rs +0 -0
  24. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/rust/src/ignore_rules.rs +0 -0
  25. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/rust/src/literal.rs +0 -0
  26. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/rust/src/main.rs +0 -0
  27. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/rust/src/matcher.rs +0 -0
  28. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/rust/src/models.rs +0 -0
  29. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/rust/src/output.rs +0 -0
  30. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/rust/src/path_utils.rs +0 -0
  31. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/rust/src/ranking.rs +0 -0
  32. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/rust/src/regex_search.rs +0 -0
  33. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/rust/src/search.rs +0 -0
  34. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/rust/src/text.rs +0 -0
  35. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/rust/src/walker.rs +0 -0
  36. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/scripts/package_rust_binary.py +0 -0
  37. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/src/codetool_explore/__init__.py +0 -0
  38. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/src/codetool_explore/compression.py +0 -0
  39. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/src/codetool_explore/cursor.py +0 -0
  40. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/src/codetool_explore/errors.py +0 -0
  41. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/src/codetool_explore/ignore.py +0 -0
  42. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/src/codetool_explore/py.typed +0 -0
  43. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/src/codetool_explore/python_backend/__init__.py +0 -0
  44. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/src/codetool_explore/python_backend/case.py +0 -0
  45. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/src/codetool_explore/python_backend/config.py +0 -0
  46. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/src/codetool_explore/python_backend/constants.py +0 -0
  47. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/src/codetool_explore/python_backend/file_search.py +0 -0
  48. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/src/codetool_explore/python_backend/ignore_rules.py +0 -0
  49. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/src/codetool_explore/python_backend/literal.py +0 -0
  50. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/src/codetool_explore/python_backend/matcher.py +0 -0
  51. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/src/codetool_explore/python_backend/models.py +0 -0
  52. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/src/codetool_explore/python_backend/output.py +0 -0
  53. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/src/codetool_explore/python_backend/regex_search.py +0 -0
  54. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/src/codetool_explore/python_backend/search.py +0 -0
  55. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/src/codetool_explore/python_backend/text.py +0 -0
  56. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/src/codetool_explore/python_backend/walker.py +0 -0
  57. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/src/codetool_explore/ranking.py +0 -0
  58. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/src/codetool_explore/roots.py +0 -0
  59. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/src/codetool_explore/rust_backend.py +0 -0
  60. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/src/codetool_explore/text_output.py +0 -0
  61. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/tests/test___init__.py +0 -0
  62. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/tests/test_cursor.py +0 -0
  63. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/tests/test_hatch_build.py +0 -0
  64. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/tests/test_ignore.py +0 -0
  65. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/tests/test_packaged_binary.py +0 -0
  66. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/tests/test_python_backend.py +0 -0
  67. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/tests/test_ranking.py +0 -0
  68. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/tests/test_rust_backend.py +0 -0
  69. {codetool_explore-0.5.0 → codetool_explore-0.6.0}/tests/test_rust_cli.py +0 -0
@@ -0,0 +1,11 @@
1
+ .venv/
2
+ __pycache__/
3
+ .pytest_cache/
4
+ *.py[cod]
5
+ rust/target/
6
+ benchmark-corpus/
7
+ benchmarks/corpus/
8
+ reports/
9
+ research/
10
+ .tmp*/
11
+ .ruff_cache/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codetool-explore
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Fast, dependency-free workspace search, read, and list exploration for coding-agent tools with Rust backend
5
5
  Project-URL: Homepage, https://github.com/pbi-agent/codetool-explore
6
6
  Project-URL: Repository, https://github.com/pbi-agent/codetool-explore
@@ -109,16 +109,19 @@ path-only rows under `target="content_or_path"` are returned without
109
109
  line/snippet fields.
110
110
 
111
111
  `target="read"` treats `pattern` as one known file path, resolves relative paths
112
- under a single `root`, and returns plain text with no line-number prefixes.
113
- Use `start_line` and `limit` to cap the returned line range; if more lines
114
- remain, text output starts with `-- more: cursor=N`. CSV files are read as
115
- ordinary text. Binary-looking, missing, unreadable, or directory paths fail with
112
+ under each root, and returns plain text with no line-number prefixes. When more
113
+ than one file is read, each file is prefixed by a compact `path:` header. Use
114
+ `start_line` and `limit` to cap the returned line range; if more lines remain,
115
+ text output starts with `-- more: cursor=N`. CSV files are read as ordinary
116
+ text. Binary-looking, missing, unreadable, or directory paths fail with
116
117
  controlled `ExploreError` subclasses.
117
118
 
118
119
  `target="list"` treats `pattern` as one file/directory path and returns one
119
- directory level. Text output uses the same compact tree display as raw search
120
- output when that saves tokens. Directories end with `/`; file paths are returned
121
- as one entry. It honors `glob`, `exclude`, ignore files, `limit`, and `cursor`.
120
+ directory level under each root. Text output uses the same compact tree display
121
+ as raw search output when that saves tokens, appending each root's listing in
122
+ root order. Directories end with `/`; file paths are returned as one entry. It
123
+ honors multi-root common-base paths, `glob`, `exclude`, ignore files, `limit`,
124
+ and `cursor`.
122
125
  Read/list use the pure-Python stdlib implementation even when `backend="auto"`
123
126
  or `"rust"` is requested.
124
127
 
@@ -139,9 +142,10 @@ treated as multiple roots when that exact path does not exist and every split
139
142
  token is an existing file or directory. Existing paths with spaces still take
140
143
  priority; quote individual spaced paths if combining them in one string.
141
144
 
142
- File roots search only that file and report paths relative to the file's parent
143
- directory. Multi-root searches report paths relative to the roots' common base,
144
- so sibling roots keep prefixes such as `src/...` and `tests/...`; this also lets
145
+ File roots search/read only that file and report paths relative to the file's
146
+ parent directory; listing a file path returns one file entry. Multi-root
147
+ searches/reads/listings report paths relative to the roots' common base, so
148
+ sibling roots keep prefixes such as `src/...` and `tests/...`; this also lets
145
149
  `exclude=["src/generated/**"]` target one root.
146
150
 
147
151
  Controlled failures raise `ExploreError` subclasses:
@@ -159,15 +163,16 @@ codetool-explore "service" . --target path --literal
159
163
  codetool-explore "User(Service|Repository)" --root src --mode snippets --raw
160
164
  codetool-explore "search_workspace" --root src --root webapp --root tests --literal
161
165
  codetool-explore --read README.md --start-line 20 --limit 40
162
- codetool-explore --list src --glob "*.py"
166
+ codetool-explore --read settings.py --root src --root tests
167
+ codetool-explore --list . --root src --root tests --glob "*.py"
163
168
  ```
164
169
 
165
170
  The CLI defaults to compact JSON for search, plain text for `--read`, and
166
171
  tree-compressed text for `--list`.
167
172
  Use `--format text` or `--raw` for raw search text; no search matches print
168
- `No Match`. Repeat `--root` for multiple search roots; read/list accept a single
169
- root only. A single quoted space-delimited `--root` is accepted as a compatibility
170
- fallback when it can be split into existing roots.
173
+ `No Match`. Repeat `--root` for multiple search/read/list roots. A single
174
+ quoted space-delimited `--root` is accepted as a compatibility fallback when it
175
+ can be split into existing roots.
171
176
 
172
177
  ## Install
173
178
 
@@ -217,13 +222,11 @@ Token counts use `tiktoken` when available. The table compares output across 7 R
217
222
 
218
223
  | Output | Tokens | Bytes | Chart |
219
224
  | --- | ---: | ---: | --- |
220
- | `explore(..., result_format="text")` | 11,008 | 34.3 KB | ██░░░░░░░░░░░░░░░░ |
225
+ | `codetool-explore` | 11,008 | 34.3 KB | ██░░░░░░░░░░░░░░░░ |
221
226
  | `rtk grep` stdout | 19,646 | 60.1 KB | ███░░░░░░░░░░░░░░░ |
222
- | default `explore(...)` | 38,393 | 125.3 KB | █████░░░░░░░░░░░░░ |
223
- | `explore(..., result_format="full")` | 39,027 | 134.7 KB | █████░░░░░░░░░░░░░ |
224
227
  | `rg` stdout | 129,775 | 402.4 KB | ██████████████████ |
225
228
 
226
- Default structured output is 7.03% smaller than the full structured shape. Raw text omits backend/totals metadata, includes only a cursor hint when truncated, and prints `No Match` for empty pages. Raw text is 0.56× the `rtk grep` token count in this run.
229
+ `codetool-explore` is raw text from `explore(..., result_format="text")`; it omits backend/totals metadata, includes only a cursor hint when truncated, and prints `No Match` for empty pages. It is 0.56× the `rtk grep` token count in this run.
227
230
 
228
231
  Source: `reports/rtk_vs_codetool_output_lengths.json`.
229
232
 
@@ -79,16 +79,19 @@ path-only rows under `target="content_or_path"` are returned without
79
79
  line/snippet fields.
80
80
 
81
81
  `target="read"` treats `pattern` as one known file path, resolves relative paths
82
- under a single `root`, and returns plain text with no line-number prefixes.
83
- Use `start_line` and `limit` to cap the returned line range; if more lines
84
- remain, text output starts with `-- more: cursor=N`. CSV files are read as
85
- ordinary text. Binary-looking, missing, unreadable, or directory paths fail with
82
+ under each root, and returns plain text with no line-number prefixes. When more
83
+ than one file is read, each file is prefixed by a compact `path:` header. Use
84
+ `start_line` and `limit` to cap the returned line range; if more lines remain,
85
+ text output starts with `-- more: cursor=N`. CSV files are read as ordinary
86
+ text. Binary-looking, missing, unreadable, or directory paths fail with
86
87
  controlled `ExploreError` subclasses.
87
88
 
88
89
  `target="list"` treats `pattern` as one file/directory path and returns one
89
- directory level. Text output uses the same compact tree display as raw search
90
- output when that saves tokens. Directories end with `/`; file paths are returned
91
- as one entry. It honors `glob`, `exclude`, ignore files, `limit`, and `cursor`.
90
+ directory level under each root. Text output uses the same compact tree display
91
+ as raw search output when that saves tokens, appending each root's listing in
92
+ root order. Directories end with `/`; file paths are returned as one entry. It
93
+ honors multi-root common-base paths, `glob`, `exclude`, ignore files, `limit`,
94
+ and `cursor`.
92
95
  Read/list use the pure-Python stdlib implementation even when `backend="auto"`
93
96
  or `"rust"` is requested.
94
97
 
@@ -109,9 +112,10 @@ treated as multiple roots when that exact path does not exist and every split
109
112
  token is an existing file or directory. Existing paths with spaces still take
110
113
  priority; quote individual spaced paths if combining them in one string.
111
114
 
112
- File roots search only that file and report paths relative to the file's parent
113
- directory. Multi-root searches report paths relative to the roots' common base,
114
- so sibling roots keep prefixes such as `src/...` and `tests/...`; this also lets
115
+ File roots search/read only that file and report paths relative to the file's
116
+ parent directory; listing a file path returns one file entry. Multi-root
117
+ searches/reads/listings report paths relative to the roots' common base, so
118
+ sibling roots keep prefixes such as `src/...` and `tests/...`; this also lets
115
119
  `exclude=["src/generated/**"]` target one root.
116
120
 
117
121
  Controlled failures raise `ExploreError` subclasses:
@@ -129,15 +133,16 @@ codetool-explore "service" . --target path --literal
129
133
  codetool-explore "User(Service|Repository)" --root src --mode snippets --raw
130
134
  codetool-explore "search_workspace" --root src --root webapp --root tests --literal
131
135
  codetool-explore --read README.md --start-line 20 --limit 40
132
- codetool-explore --list src --glob "*.py"
136
+ codetool-explore --read settings.py --root src --root tests
137
+ codetool-explore --list . --root src --root tests --glob "*.py"
133
138
  ```
134
139
 
135
140
  The CLI defaults to compact JSON for search, plain text for `--read`, and
136
141
  tree-compressed text for `--list`.
137
142
  Use `--format text` or `--raw` for raw search text; no search matches print
138
- `No Match`. Repeat `--root` for multiple search roots; read/list accept a single
139
- root only. A single quoted space-delimited `--root` is accepted as a compatibility
140
- fallback when it can be split into existing roots.
143
+ `No Match`. Repeat `--root` for multiple search/read/list roots. A single
144
+ quoted space-delimited `--root` is accepted as a compatibility fallback when it
145
+ can be split into existing roots.
141
146
 
142
147
  ## Install
143
148
 
@@ -187,13 +192,11 @@ Token counts use `tiktoken` when available. The table compares output across 7 R
187
192
 
188
193
  | Output | Tokens | Bytes | Chart |
189
194
  | --- | ---: | ---: | --- |
190
- | `explore(..., result_format="text")` | 11,008 | 34.3 KB | ██░░░░░░░░░░░░░░░░ |
195
+ | `codetool-explore` | 11,008 | 34.3 KB | ██░░░░░░░░░░░░░░░░ |
191
196
  | `rtk grep` stdout | 19,646 | 60.1 KB | ███░░░░░░░░░░░░░░░ |
192
- | default `explore(...)` | 38,393 | 125.3 KB | █████░░░░░░░░░░░░░ |
193
- | `explore(..., result_format="full")` | 39,027 | 134.7 KB | █████░░░░░░░░░░░░░ |
194
197
  | `rg` stdout | 129,775 | 402.4 KB | ██████████████████ |
195
198
 
196
- Default structured output is 7.03% smaller than the full structured shape. Raw text omits backend/totals metadata, includes only a cursor hint when truncated, and prints `No Match` for empty pages. Raw text is 0.56× the `rtk grep` token count in this run.
199
+ `codetool-explore` is raw text from `explore(..., result_format="text")`; it omits backend/totals metadata, includes only a cursor hint when truncated, and prints `No Match` for empty pages. It is 0.56× the `rtk grep` token count in this run.
197
200
 
198
201
  Source: `reports/rtk_vs_codetool_output_lengths.json`.
199
202
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codetool-explore"
3
- version = "0.5.0"
3
+ version = "0.6.0"
4
4
  description = "Fast, dependency-free workspace search, read, and list exploration for coding-agent tools with Rust backend"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -175,37 +175,30 @@ def render_tokens(data: dict[str, Any], source: str) -> list[str]:
175
175
  if "summary" not in data:
176
176
  aggregate = compression_totals(data)
177
177
  totals = aggregate["totals"]
178
- full = int(totals["full_bytes"])
179
- compressed = int(totals["compressed_bytes"])
180
178
  raw = int(totals["raw_bytes"])
181
179
  rtk = int(totals["rtk_bytes"])
182
180
  rg = int(totals["rg_bytes"])
183
- grep = int(totals["grep_bytes"])
184
- full_tokens = int(totals["full_tokens"])
185
- compressed_tokens = int(totals["compressed_tokens"])
186
181
  raw_tokens = int(totals["raw_tokens"])
187
182
  rtk_tokens = int(totals["rtk_tokens"])
188
183
  rg_tokens = int(totals["rg_tokens"])
189
- grep_tokens = int(totals["grep_tokens"])
190
184
  savings = aggregate["savings"]
191
- if not full or not compressed:
185
+ if not raw:
192
186
  raise SystemExit(f"{source} does not contain token-compression data")
193
- rows = [
194
- ('`explore(..., result_format="full")`', full_tokens, full),
195
- ("default `explore(...)`", compressed_tokens, compressed),
196
- ]
187
+ rows = []
197
188
  if raw:
198
- rows.append(('`explore(..., result_format="text")`', raw_tokens, raw))
189
+ rows.append(("`codetool-explore`", raw_tokens, raw))
199
190
  if rtk:
200
191
  rows.append(("`rtk grep` stdout", rtk_tokens, rtk))
201
192
  if rg:
202
193
  rows.append(("`rg` stdout", rg_tokens, rg))
203
- if grep:
204
- rows.append(("GNU `grep` stdout", grep_tokens, grep))
205
194
  max_tokens = max(tokens for _, tokens, _ in rows)
206
- percent = round(((full - compressed) / full) * 100, 2)
207
- average_percent = round(mean(savings), 2) if savings else percent
208
195
  samples = aggregate["samples"] or len(savings)
196
+ raw_ratio = round(raw_tokens / rtk_tokens, 2) if rtk_tokens else None
197
+ raw_note = (
198
+ f" It is {raw_ratio}× the `rtk grep` token count in this run."
199
+ if raw_ratio is not None
200
+ else ""
201
+ )
209
202
  lines = [
210
203
  "### Token compression",
211
204
  "",
@@ -224,9 +217,9 @@ def render_tokens(data: dict[str, Any], source: str) -> list[str]:
224
217
  lines += [
225
218
  "",
226
219
  (
227
- f"Default structured output is {percent}% smaller in bytes total and "
228
- f"{average_percent}% smaller on average per sample than the full shape. "
229
- "Raw text omits metadata and is optimized for minimum tokens."
220
+ "`codetool-explore` is raw text from "
221
+ '`explore(..., result_format="text")`; it omits metadata and '
222
+ "is optimized for minimum tokens." + raw_note
230
223
  ),
231
224
  "",
232
225
  f"Source: `{source}`.",
@@ -235,33 +228,20 @@ def render_tokens(data: dict[str, Any], source: str) -> list[str]:
235
228
 
236
229
  summary = data["summary"]
237
230
  totals = summary["total_bytes"]
238
- compressed = int(totals["codetool_compressed_json"])
239
- full = int(totals["codetool_full_json"])
240
231
  raw = int(totals.get("codetool_raw_text", 0))
241
232
  rtk = int(totals.get("rtk_grep_stdout", 0))
242
233
  rg = int(totals.get("rg_stdout", 0))
243
- grep = int(totals.get("grep_stdout", 0))
244
234
  token_totals = summary.get("total_tokens", {})
245
- full_tokens = int(token_totals.get("codetool_full_json", round(full / 4)))
246
- compressed_tokens = int(
247
- token_totals.get("codetool_compressed_json", round(compressed / 4))
248
- )
249
235
  raw_tokens = int(token_totals.get("codetool_raw_text", round(raw / 4)))
250
236
  rtk_tokens = int(token_totals.get("rtk_grep_stdout", round(rtk / 4)))
251
237
  rg_tokens = int(token_totals.get("rg_stdout", round(rg / 4)))
252
- grep_tokens = int(token_totals.get("grep_stdout", round(grep / 4)))
253
- rows = [
254
- ('`explore(..., result_format="full")`', full_tokens, full),
255
- ("default `explore(...)`", compressed_tokens, compressed),
256
- ]
238
+ rows = []
257
239
  if raw:
258
- rows.append(('`explore(..., result_format="text")`', raw_tokens, raw))
240
+ rows.append(("`codetool-explore`", raw_tokens, raw))
259
241
  if rtk:
260
242
  rows.append(("`rtk grep` stdout", rtk_tokens, rtk))
261
243
  if rg:
262
244
  rows.append(("`rg` stdout", rg_tokens, rg))
263
- if grep:
264
- rows.append(("GNU `grep` stdout", grep_tokens, grep))
265
245
  max_tokens = max(tokens for _, tokens, _ in rows)
266
246
  comparison = summary["total_comparison"]
267
247
  lines = [
@@ -281,15 +261,15 @@ def render_tokens(data: dict[str, Any], source: str) -> list[str]:
281
261
  )
282
262
  raw_ratio = comparison.get("codetool_raw_over_rtk_tokens_ratio")
283
263
  raw_note = (
284
- f" Raw text is {raw_ratio}× the `rtk grep` token count in this run."
264
+ f" It is {raw_ratio}× the `rtk grep` token count in this run."
285
265
  if raw_ratio is not None
286
266
  else ""
287
267
  )
288
268
  lines += [
289
269
  "",
290
270
  (
291
- f"Default structured output is {comparison['codetool_compressed_vs_full_percent_smaller']}% "
292
- "smaller than the full structured shape. Raw text omits backend/totals "
271
+ "`codetool-explore` is raw text from "
272
+ '`explore(..., result_format="text")`; it omits backend/totals '
293
273
  "metadata, includes only a cursor hint when truncated, and prints "
294
274
  "`No Match` for empty pages." + raw_note
295
275
  ),
@@ -107,12 +107,13 @@ def explore(
107
107
  paths without opening files, and ``"content_or_path"`` returns files
108
108
  matching either.
109
109
  ``"read"`` treats ``pattern`` as one file path and returns a controlled
110
- line range. ``"list"`` treats ``pattern`` as one file/directory path and
111
- returns a one-level listing.
110
+ line range for each root. ``"list"`` treats ``pattern`` as one
111
+ file/directory path and returns a one-level listing for each root.
112
112
  Patterns are interpreted as regex by default; pass ``regex=False`` for exact
113
113
  literal search. ``root`` may be one directory/file path or a non-empty list
114
- of directory/file paths. Multi-root searches report paths relative to the
115
- roots' common base, so sibling roots keep prefixes such as ``src/...``.
114
+ of directory/file paths. Multi-root searches/reads/listings report paths
115
+ relative to the roots' common base, so sibling roots keep prefixes such as
116
+ ``src/...``.
116
117
  To tolerate common JSON/tool-call mistakes, a whitespace-separated root
117
118
  string is treated as multiple roots only when that exact path does not
118
119
  exist and every split token is an existing file or directory.
@@ -122,8 +123,8 @@ def explore(
122
123
  compatibility fallback.
123
124
 
124
125
  Search results are returned in a compact structured shape by default; read
125
- results default to plain text and list results default to tree-compressed
126
- text. Pass
126
+ results default to plain text (with path headers only when multiple files
127
+ are read) and list results default to tree-compressed text. Pass
127
128
  ``result_format="full"`` to receive the pre-compression backend result
128
129
  dictionary unchanged. Pass ``result_format="text"`` (or ``"raw"``) for an
129
130
  RTK-inspired plain-text rendering optimized for token compression.
@@ -26,12 +26,12 @@ def build_parser() -> argparse.ArgumentParser:
26
26
  action_group.add_argument(
27
27
  "--read",
28
28
  dest="read_path",
29
- help="read one known file path with controlled line output",
29
+ help="read one known file path under each root with controlled line output",
30
30
  )
31
31
  action_group.add_argument(
32
32
  "--list",
33
33
  dest="list_path",
34
- help="list one directory level or one file path",
34
+ help="list one directory level or one file path under each root",
35
35
  )
36
36
  parser.add_argument(
37
37
  "--root",
@@ -19,7 +19,8 @@ from .ignore import (
19
19
  )
20
20
  from .python_backend.constants import binary_check_bytes
21
21
  from .python_backend.ignore_rules import ignore_patterns_for_root
22
- from .roots import RootInput, normalize_search_roots
22
+ from .python_backend.models import IgnorePatterns
23
+ from .roots import NormalizedRoots, RootInput, SearchRoot, normalize_search_roots
23
24
 
24
25
  MAX_READ_CHARS = 100_000
25
26
  READ_CHUNK_BYTES = 8192
@@ -35,6 +36,9 @@ class ResolvedExplorePath:
35
36
  root_display: str | list[str]
36
37
  display_base: str | None
37
38
  root_base: str
39
+ search_root_abs: str
40
+ root_is_file: bool
41
+ rel_base_abs: str | None
38
42
 
39
43
 
40
44
  def read_file_target(
@@ -45,38 +49,58 @@ def read_file_target(
45
49
  limit: int = 50,
46
50
  cursor: str | int | None = None,
47
51
  ) -> dict[str, object]:
48
- """Return a controlled line range from one text file."""
52
+ """Return a controlled line range from one text file under each root."""
49
53
 
50
54
  safe_limit = normalize_limit(limit)
51
55
  safe_start_line = _normalize_start_line(start_line, cursor=cursor)
52
- resolved = _resolve_explore_path(path, root=root, target="read")
53
-
54
- if not os.path.exists(resolved.abs_path):
55
- raise ExploreRootError(f"file does not exist: {resolved.query!r}")
56
- if os.path.isdir(resolved.abs_path):
57
- raise ExploreRootError(f"read target is a directory: {resolved.query!r}")
58
- if not os.path.isfile(resolved.abs_path):
59
- raise ExploreRootError(f"read target is not a file: {resolved.query!r}")
60
-
61
- _raise_if_binary(resolved.abs_path, resolved.display_path)
62
- text, returned_lines, has_more_lines, content_truncated = _read_text_range(
63
- resolved.abs_path,
64
- display_path=resolved.display_path,
65
- start_line=safe_start_line,
66
- limit=safe_limit,
56
+ resolved_paths = _resolve_explore_paths(
57
+ path,
58
+ root=root,
59
+ target="read",
60
+ allow_multiple=True,
67
61
  )
68
- next_cursor = (
69
- str(safe_start_line + returned_lines)
70
- if has_more_lines and returned_lines > 0
71
- else None
62
+ files: list[dict[str, object]] = []
63
+ seen_files: set[str] = set()
64
+ for resolved in resolved_paths:
65
+ identity = os.path.normcase(os.path.abspath(resolved.abs_path))
66
+ if identity in seen_files:
67
+ continue
68
+ seen_files.add(identity)
69
+ error_path = (
70
+ resolved.query if len(resolved_paths) == 1 else resolved.display_path
71
+ )
72
+ files.append(
73
+ _read_resolved_file(
74
+ resolved,
75
+ error_path=error_path,
76
+ start_line=safe_start_line,
77
+ limit=safe_limit,
78
+ )
79
+ )
80
+
81
+ text = _format_read_files_text(files)
82
+ returned_lines = sum(int(item.get("returned", 0) or 0) for item in files)
83
+ content_truncated = any(bool(item.get("content_truncated")) for item in files)
84
+ next_cursor = next(
85
+ (
86
+ str(item_next)
87
+ for item in files
88
+ if (item_next := item.get("next_cursor")) not in (None, "")
89
+ ),
90
+ None,
72
91
  )
92
+ path_display: object
93
+ if len(files) == 1:
94
+ path_display = files[0]["path"]
95
+ else:
96
+ path_display = [item["path"] for item in files]
73
97
 
74
- return {
75
- "pattern": resolved.query,
76
- "root": resolved.root_display,
98
+ result: dict[str, object] = {
99
+ "pattern": resolved_paths[0].query,
100
+ "root": resolved_paths[0].root_display,
77
101
  "target": "read",
78
102
  "mode": "read",
79
- "path": resolved.display_path,
103
+ "path": path_display,
80
104
  "start_line": safe_start_line,
81
105
  "limit": safe_limit,
82
106
  "cursor": None if cursor in (None, "") else str(cursor),
@@ -84,12 +108,16 @@ def read_file_target(
84
108
  "returned": returned_lines,
85
109
  "line_count": returned_lines,
86
110
  "count": returned_lines,
87
- "truncated": bool(has_more_lines or content_truncated),
111
+ "file_count": len(files),
112
+ "truncated": bool(next_cursor or content_truncated),
88
113
  "content_truncated": content_truncated,
89
114
  "next_cursor": next_cursor,
90
115
  "offset": safe_start_line - 1,
91
116
  "backend": "python",
92
117
  }
118
+ if len(files) > 1:
119
+ result["files"] = files
120
+ return result
93
121
 
94
122
 
95
123
  def list_path_target(
@@ -104,41 +132,64 @@ def list_path_target(
104
132
  """Return one ls-like page for a file or one directory level."""
105
133
 
106
134
  safe_limit = normalize_limit(limit)
107
- resolved = _resolve_explore_path(path, root=root, target="list", allow_empty=True)
135
+ resolved_paths = _resolve_explore_paths(
136
+ path,
137
+ root=root,
138
+ target="list",
139
+ allow_empty=True,
140
+ allow_multiple=True,
141
+ )
108
142
  glob_patterns = normalize_patterns(glob)
109
143
  exclude_patterns = normalize_patterns(exclude)
110
144
 
111
- if not os.path.exists(resolved.abs_path):
112
- raise ExploreRootError(f"list target does not exist: {resolved.query!r}")
113
- if os.path.isfile(resolved.abs_path):
114
- entries = _list_single_file(
115
- resolved,
116
- glob_patterns=glob_patterns,
117
- exclude_patterns=exclude_patterns,
118
- )
119
- elif os.path.isdir(resolved.abs_path):
120
- entries = _list_directory(
121
- resolved,
122
- glob_patterns=glob_patterns,
123
- exclude_patterns=exclude_patterns,
124
- )
125
- else:
126
- raise ExploreRootError(
127
- f"list target is neither a directory nor file: {resolved.query!r}"
145
+ entries: list[dict[str, object]] = []
146
+ seen_entries: set[tuple[str, str]] = set()
147
+ for resolved in resolved_paths:
148
+ error_path = (
149
+ resolved.query if len(resolved_paths) == 1 else resolved.display_path
128
150
  )
151
+ if not os.path.exists(resolved.abs_path):
152
+ raise ExploreRootError(f"list target does not exist: {error_path!r}")
153
+ if os.path.isfile(resolved.abs_path):
154
+ root_entries = _list_single_file(
155
+ resolved,
156
+ glob_patterns=glob_patterns,
157
+ exclude_patterns=exclude_patterns,
158
+ )
159
+ elif os.path.isdir(resolved.abs_path):
160
+ root_entries = _list_directory(
161
+ resolved,
162
+ glob_patterns=glob_patterns,
163
+ exclude_patterns=exclude_patterns,
164
+ )
165
+ else:
166
+ raise ExploreRootError(
167
+ f"list target is neither a directory nor file: {error_path!r}"
168
+ )
169
+ for entry in root_entries:
170
+ identity = _entry_identity(entry)
171
+ if identity in seen_entries:
172
+ continue
173
+ seen_entries.add(identity)
174
+ entries.append(entry)
129
175
 
130
176
  page, truncated, next_cursor, offset = page_items(
131
177
  entries, limit=safe_limit, cursor=cursor
132
178
  )
133
179
  total_files = sum(1 for entry in entries if entry.get("kind") == "file")
134
180
  total_dirs = sum(1 for entry in entries if entry.get("kind") == "dir")
181
+ path_display: object
182
+ if len(resolved_paths) == 1:
183
+ path_display = resolved_paths[0].display_path
184
+ else:
185
+ path_display = [resolved.display_path for resolved in resolved_paths]
135
186
 
136
187
  result: dict[str, object] = {
137
- "pattern": resolved.query,
138
- "root": resolved.root_display,
188
+ "pattern": resolved_paths[0].query,
189
+ "root": resolved_paths[0].root_display,
139
190
  "target": "list",
140
191
  "mode": "list",
141
- "path": resolved.display_path,
192
+ "path": path_display,
142
193
  "entries": page,
143
194
  "returned": len(page),
144
195
  "total_entries": len(entries),
@@ -167,8 +218,25 @@ def _resolve_explore_path(
167
218
  target: str,
168
219
  allow_empty: bool = False,
169
220
  ) -> ResolvedExplorePath:
221
+ return _resolve_explore_paths(
222
+ path,
223
+ root=root,
224
+ target=target,
225
+ allow_empty=allow_empty,
226
+ allow_multiple=False,
227
+ )[0]
228
+
229
+
230
+ def _resolve_explore_paths(
231
+ path: object,
232
+ *,
233
+ root: RootInput,
234
+ target: str,
235
+ allow_empty: bool = False,
236
+ allow_multiple: bool = False,
237
+ ) -> tuple[ResolvedExplorePath, ...]:
170
238
  root_set = normalize_search_roots(root)
171
- if root_set.has_multiple:
239
+ if root_set.has_multiple and not allow_multiple:
172
240
  raise ExploreArgumentError(f"target={target!r} supports one root at a time")
173
241
 
174
242
  try:
@@ -183,15 +251,32 @@ def _resolve_explore_path(
183
251
  else:
184
252
  raise ExploreArgumentError(f"{target} path must not be empty")
185
253
 
186
- search_root = root_set.roots[0]
254
+ return tuple(
255
+ _resolve_path_for_search_root(query, search_root=search_root, root_set=root_set)
256
+ for search_root in root_set.roots
257
+ )
258
+
259
+
260
+ def _resolve_path_for_search_root(
261
+ query: str,
262
+ *,
263
+ search_root: SearchRoot,
264
+ root_set: NormalizedRoots,
265
+ ) -> ResolvedExplorePath:
187
266
  root_abs = search_root.abs_path
267
+ root_is_file = os.path.isfile(root_abs)
188
268
  root_base = root_abs if os.path.isdir(root_abs) else os.path.dirname(root_abs)
189
269
  if os.path.isabs(query):
190
270
  abs_path = os.path.abspath(query)
191
- display_base = root_base if _is_under(abs_path, root_base) else None
271
+ base = root_set.rel_base if root_set.has_multiple else root_base
272
+ display_base = base if base and _is_under(abs_path, base) else None
192
273
  else:
193
- abs_path = os.path.abspath(os.path.join(root_base, query))
194
- display_base = root_base
274
+ abs_path = (
275
+ os.path.abspath(root_abs)
276
+ if root_is_file and query == "."
277
+ else os.path.abspath(os.path.join(root_base, query))
278
+ )
279
+ display_base = root_set.rel_base if root_set.has_multiple else root_base
195
280
  display_path = _display_path(abs_path, display_base)
196
281
  return ResolvedExplorePath(
197
282
  query=query,
@@ -200,6 +285,9 @@ def _resolve_explore_path(
200
285
  root_display=root_set.display,
201
286
  display_base=display_base,
202
287
  root_base=root_base,
288
+ search_root_abs=root_abs,
289
+ root_is_file=root_is_file,
290
+ rel_base_abs=root_set.rel_base,
203
291
  )
204
292
 
205
293
 
@@ -219,6 +307,56 @@ def _normalize_start_line(start_line: int, *, cursor: str | int | None) -> int:
219
307
  return value
220
308
 
221
309
 
310
+ def _read_resolved_file(
311
+ resolved: ResolvedExplorePath,
312
+ *,
313
+ error_path: str,
314
+ start_line: int,
315
+ limit: int,
316
+ ) -> dict[str, object]:
317
+ if not os.path.exists(resolved.abs_path):
318
+ raise ExploreRootError(f"file does not exist: {error_path!r}")
319
+ if os.path.isdir(resolved.abs_path):
320
+ raise ExploreRootError(f"read target is a directory: {error_path!r}")
321
+ if not os.path.isfile(resolved.abs_path):
322
+ raise ExploreRootError(f"read target is not a file: {error_path!r}")
323
+
324
+ _raise_if_binary(resolved.abs_path, resolved.display_path)
325
+ text, returned_lines, has_more_lines, content_truncated = _read_text_range(
326
+ resolved.abs_path,
327
+ display_path=resolved.display_path,
328
+ start_line=start_line,
329
+ limit=limit,
330
+ )
331
+ next_cursor = (
332
+ str(start_line + returned_lines)
333
+ if has_more_lines and returned_lines > 0
334
+ else None
335
+ )
336
+ return {
337
+ "path": resolved.display_path,
338
+ "text": text,
339
+ "returned": returned_lines,
340
+ "line_count": returned_lines,
341
+ "content_truncated": content_truncated,
342
+ "next_cursor": next_cursor,
343
+ }
344
+
345
+
346
+ def _format_read_files_text(files: list[dict[str, object]]) -> str:
347
+ if len(files) == 1:
348
+ return str(files[0].get("text", ""))
349
+ sections: list[str] = []
350
+ for item in files:
351
+ header = f"{item.get('path', '')}:"
352
+ text = str(item.get("text", ""))
353
+ if text or int(item.get("returned", 0) or 0) > 0:
354
+ sections.append(f"{header}\n{text}")
355
+ else:
356
+ sections.append(header)
357
+ return "\n".join(sections)
358
+
359
+
222
360
  def _is_under(path: str, base: str) -> bool:
223
361
  try:
224
362
  common_path = os.path.commonpath(
@@ -378,11 +516,9 @@ def _list_single_file(
378
516
  ) -> list[dict[str, object]]:
379
517
  parent = os.path.dirname(resolved.abs_path) or os.curdir
380
518
  rel_path = _display_path(resolved.abs_path, resolved.display_base)
381
- api_ignore_base = _listing_api_ignore_base(resolved, is_file=True)
382
- api_ignore_patterns = ignore_patterns_for_root(
383
- api_ignore_base,
384
- rel_base_abs=None,
385
- is_file=False,
519
+ api_ignore_patterns, api_ignore_base = _listing_ignore_patterns(
520
+ resolved,
521
+ is_file=True,
386
522
  )
387
523
  local_ignore_patterns = ignore_patterns_for_root(
388
524
  resolved.abs_path,
@@ -393,6 +529,7 @@ def _list_single_file(
393
529
  rel_path,
394
530
  is_dir=False,
395
531
  exclude_patterns=exclude_patterns,
532
+ ignore_patterns=api_ignore_patterns.common,
396
533
  root_ignore_patterns=api_ignore_patterns.root,
397
534
  common_rel_path=relative_path(resolved.abs_path, api_ignore_base),
398
535
  ) or _is_ignored_by_local_listing_patterns(
@@ -412,10 +549,8 @@ def _list_directory(
412
549
  glob_patterns: tuple[str, ...],
413
550
  exclude_patterns: tuple[str, ...],
414
551
  ) -> list[dict[str, object]]:
415
- api_ignore_base = _listing_api_ignore_base(resolved, is_file=False)
416
- api_ignore_patterns = ignore_patterns_for_root(
417
- api_ignore_base,
418
- rel_base_abs=None,
552
+ api_ignore_patterns, api_ignore_base = _listing_ignore_patterns(
553
+ resolved,
419
554
  is_file=False,
420
555
  )
421
556
  local_ignore_patterns = ignore_patterns_for_root(
@@ -439,6 +574,7 @@ def _list_directory(
439
574
  rel_path,
440
575
  is_dir=is_dir,
441
576
  exclude_patterns=exclude_patterns,
577
+ ignore_patterns=api_ignore_patterns.common,
442
578
  root_ignore_patterns=api_ignore_patterns.root,
443
579
  common_rel_path=relative_path(entry.path, api_ignore_base),
444
580
  ) or _is_ignored_by_local_listing_patterns(
@@ -466,6 +602,37 @@ def _list_directory(
466
602
  return entries
467
603
 
468
604
 
605
+ def _listing_ignore_patterns(
606
+ resolved: ResolvedExplorePath,
607
+ *,
608
+ is_file: bool,
609
+ ) -> tuple[IgnorePatterns, str]:
610
+ root_filter_base = (
611
+ os.path.dirname(resolved.search_root_abs) or os.curdir
612
+ if resolved.root_is_file
613
+ else resolved.search_root_abs
614
+ )
615
+ if _is_under(resolved.abs_path, root_filter_base):
616
+ return (
617
+ ignore_patterns_for_root(
618
+ resolved.search_root_abs,
619
+ rel_base_abs=resolved.rel_base_abs,
620
+ is_file=resolved.root_is_file,
621
+ ),
622
+ root_filter_base,
623
+ )
624
+
625
+ api_ignore_base = _listing_api_ignore_base(resolved, is_file=is_file)
626
+ return (
627
+ ignore_patterns_for_root(
628
+ api_ignore_base,
629
+ rel_base_abs=None,
630
+ is_file=False,
631
+ ),
632
+ api_ignore_base,
633
+ )
634
+
635
+
469
636
  def _listing_api_ignore_base(resolved: ResolvedExplorePath, *, is_file: bool) -> str:
470
637
  if _is_under(resolved.abs_path, resolved.root_base):
471
638
  return resolved.root_base
@@ -494,4 +661,10 @@ def _is_ignored_by_local_listing_patterns(
494
661
 
495
662
  def _entry_sort_key(entry: dict[str, Any]) -> tuple[str, str]:
496
663
  path = str(entry.get("path", ""))
497
- return (path.rstrip("/").casefold(), path)
664
+ return (path.rstrip("/").casefold(), path)
665
+
666
+
667
+ def _entry_identity(entry: dict[str, object]) -> tuple[str, str]:
668
+ kind = str(entry.get("kind", ""))
669
+ path = normalize_relpath(str(entry.get("path", ""))).rstrip("/")
670
+ return (os.path.normcase(path), kind)
@@ -297,6 +297,67 @@ def test_read_target_reports_more_and_supports_csv_as_text(tmp_path):
297
297
  assert compressed["text"] == "name,value"
298
298
 
299
299
 
300
+ def test_read_target_accepts_root_list_with_headers(tmp_path):
301
+ write(tmp_path / "src" / "settings.txt", "src one\nsrc two\n")
302
+ write(tmp_path / "tests" / "settings.txt", "test one\ntest two\n")
303
+
304
+ text = api.explore(
305
+ "settings.txt",
306
+ root=[str(tmp_path / "src"), str(tmp_path / "tests")],
307
+ target="read",
308
+ limit=1,
309
+ )
310
+ full = api.explore(
311
+ "settings.txt",
312
+ root=[str(tmp_path / "src"), str(tmp_path / "tests")],
313
+ target="read",
314
+ limit=1,
315
+ result_format="full",
316
+ )
317
+
318
+ assert text == "\n".join(
319
+ [
320
+ "-- more: cursor=2",
321
+ "src/settings.txt:",
322
+ "src one",
323
+ "tests/settings.txt:",
324
+ "test one",
325
+ ]
326
+ )
327
+ assert full["root"] == [str(tmp_path / "src"), str(tmp_path / "tests")]
328
+ assert full["path"] == ["src/settings.txt", "tests/settings.txt"]
329
+ assert full["text"] == "\n".join(
330
+ [
331
+ "src/settings.txt:",
332
+ "src one",
333
+ "tests/settings.txt:",
334
+ "test one",
335
+ ]
336
+ )
337
+ assert full["returned"] == 2
338
+ assert full["line_count"] == 2
339
+ assert full["file_count"] == 2
340
+ assert full["next_cursor"] == "2"
341
+ assert full["files"] == [
342
+ {
343
+ "path": "src/settings.txt",
344
+ "text": "src one",
345
+ "returned": 1,
346
+ "line_count": 1,
347
+ "content_truncated": False,
348
+ "next_cursor": "2",
349
+ },
350
+ {
351
+ "path": "tests/settings.txt",
352
+ "text": "test one",
353
+ "returned": 1,
354
+ "line_count": 1,
355
+ "content_truncated": False,
356
+ "next_cursor": "2",
357
+ },
358
+ ]
359
+
360
+
300
361
  def test_read_target_preserves_blank_line_when_more_marker_is_present(tmp_path):
301
362
  write(tmp_path / "blank.txt", "\nsecond\n")
302
363
 
@@ -331,7 +392,8 @@ def test_read_target_clean_errors(tmp_path):
331
392
  api.explore("src", root=str(tmp_path), target="read")
332
393
  with pytest.raises(ExploreRootError, match="binary"):
333
394
  api.explore("binary.dat", root=str(tmp_path), target="read")
334
- with pytest.raises(ExploreArgumentError, match="one root"):
395
+ write(tmp_path / "src" / "a.py", "")
396
+ with pytest.raises(ExploreRootError, match="tests/a.py"):
335
397
  api.explore(
336
398
  "a.py",
337
399
  root=[str(tmp_path / "src"), str(tmp_path / "tests")],
@@ -442,6 +504,64 @@ def test_list_text_output_uses_tree_for_repeated_prefixes(tmp_path):
442
504
  assert "\n\n" not in result
443
505
 
444
506
 
507
+ def test_list_target_accepts_root_list_and_appends_each_tree(tmp_path):
508
+ write(tmp_path / "src" / "codetool_explore" / "__init__.py", "")
509
+ for name in ("test_api.py", "test_cli.py", "test_cursor.py", "test_ignore.py"):
510
+ write(tmp_path / "tests" / name, "")
511
+
512
+ result = api.explore(
513
+ ".",
514
+ root=[str(tmp_path / "src"), str(tmp_path / "tests")],
515
+ target="list",
516
+ )
517
+ full = api.explore(
518
+ ".",
519
+ root=[str(tmp_path / "src"), str(tmp_path / "tests")],
520
+ target="list",
521
+ result_format="full",
522
+ )
523
+
524
+ assert result == "\n".join(
525
+ [
526
+ "src/",
527
+ " codetool_explore/",
528
+ "tests/",
529
+ " test_api.py",
530
+ " test_cli.py",
531
+ " test_cursor.py",
532
+ " test_ignore.py",
533
+ ]
534
+ )
535
+ assert full["root"] == [str(tmp_path / "src"), str(tmp_path / "tests")]
536
+ assert full["path"] == ["src", "tests"]
537
+ assert full["entries"] == [
538
+ {"path": "src/codetool_explore/", "kind": "dir"},
539
+ {"path": "tests/test_api.py", "kind": "file"},
540
+ {"path": "tests/test_cli.py", "kind": "file"},
541
+ {"path": "tests/test_cursor.py", "kind": "file"},
542
+ {"path": "tests/test_ignore.py", "kind": "file"},
543
+ ]
544
+
545
+
546
+ def test_list_target_root_list_uses_search_ignore_logic(tmp_path):
547
+ write(tmp_path / ".gitignore", "build/\n*.tmp\n")
548
+ write(tmp_path / "build" / "keep.py", "")
549
+ write(tmp_path / "build" / "skip.tmp", "")
550
+ write(tmp_path / "src" / "app.py", "")
551
+
552
+ result = api.explore(
553
+ ".",
554
+ root=[str(tmp_path / "build"), str(tmp_path / "src")],
555
+ target="list",
556
+ result_format="full",
557
+ )
558
+
559
+ assert result["entries"] == [
560
+ {"path": "build/keep.py", "kind": "file"},
561
+ {"path": "src/app.py", "kind": "file"},
562
+ ]
563
+
564
+
445
565
  def test_compressed_snippets_preserve_line_snippet_context_and_page(tmp_path):
446
566
  write(tmp_path / "a.py", "before\nneedle one\nneedle two\n")
447
567
 
@@ -212,6 +212,33 @@ def test_cli_read_defaults_to_text_and_honors_line_range(tmp_path, capsys):
212
212
  assert capsys.readouterr().out == "-- more: cursor=3\ntwo\n"
213
213
 
214
214
 
215
+ def test_cli_read_accepts_repeated_roots_with_headers(tmp_path, capsys):
216
+ write(tmp_path / "src" / "settings.txt", "src one\n")
217
+ write(tmp_path / "tests" / "settings.txt", "test one\n")
218
+
219
+ code = cli.main(
220
+ [
221
+ "--read",
222
+ "settings.txt",
223
+ "--root",
224
+ str(tmp_path / "src"),
225
+ "--root",
226
+ str(tmp_path / "tests"),
227
+ ]
228
+ )
229
+
230
+ assert code == 0
231
+ assert capsys.readouterr().out == "\n".join(
232
+ [
233
+ "src/settings.txt:",
234
+ "src one",
235
+ "tests/settings.txt:",
236
+ "test one",
237
+ "",
238
+ ]
239
+ )
240
+
241
+
215
242
  def test_cli_list_defaults_to_text(tmp_path, capsys):
216
243
  write(tmp_path / "src" / "app.py", "")
217
244
  write(tmp_path / "src" / "pkg" / "mod.py", "")
@@ -222,6 +249,36 @@ def test_cli_list_defaults_to_text(tmp_path, capsys):
222
249
  assert capsys.readouterr().out == "src/\n app.py\n pkg/\n"
223
250
 
224
251
 
252
+ def test_cli_list_accepts_repeated_roots(tmp_path, capsys):
253
+ write(tmp_path / "src" / "codetool_explore" / "__init__.py", "")
254
+ for name in ("test_api.py", "test_cli.py", "test_cursor.py"):
255
+ write(tmp_path / "tests" / name, "")
256
+
257
+ code = cli.main(
258
+ [
259
+ "--list",
260
+ ".",
261
+ "--root",
262
+ str(tmp_path / "src"),
263
+ "--root",
264
+ str(tmp_path / "tests"),
265
+ ]
266
+ )
267
+
268
+ assert code == 0
269
+ assert capsys.readouterr().out == "\n".join(
270
+ [
271
+ "src/",
272
+ " codetool_explore/",
273
+ "tests/",
274
+ " test_api.py",
275
+ " test_cli.py",
276
+ " test_cursor.py",
277
+ "",
278
+ ]
279
+ )
280
+
281
+
225
282
  def test_cli_read_full_format_returns_json(tmp_path, capsys):
226
283
  write(tmp_path / "docs" / "guide.txt", "one\ntwo\n")
227
284
 
@@ -86,7 +86,7 @@ wheels = [
86
86
 
87
87
  [[package]]
88
88
  name = "codetool-explore"
89
- version = "0.5.0"
89
+ version = "0.6.0"
90
90
  source = { editable = "." }
91
91
 
92
92
  [package.dev-dependencies]
@@ -1,19 +0,0 @@
1
- .venv/
2
- __pycache__/
3
- .pytest_cache/
4
- *.py[cod]
5
- rust/target/
6
- benchmark-corpus/
7
- benchmarks/corpus/
8
- reports/*
9
- !reports/research_lab_benchmark.json
10
- !reports/research_lab_result.html
11
- !reports/rtk_vs_codetool_output_length_analysis.html
12
- !reports/rtk_vs_codetool_output_lengths.json
13
- !reports/search_benchmark.json
14
- !reports/search_benchmark_analysis.html
15
- !reports/search_benchmark_rtk_compression.json
16
- !reports/search_compression_analysis.html
17
- research/
18
- .tmp*/
19
- .ruff_cache/