Skip to content

Commit 079d432

Browse files
committed
v0.2.3: feat: pathkit slug disambig + csb resume cd/TTY fix
Finalizes the v0.2.3 epic. Closes the last two issues in the bundle (#23 pathkit multi-candidate slug disambiguation, #24 csb resume cd plus Windows TTY-handoff fix), folds in a small bug-fix surfaced by the tester agent during the v0.2.3 checklist run, and converts the CHANGELOG `[Unreleased]` section to `[0.2.3] -- 2026-05-06`. Version files were bumped from 0.2.2 -> 0.2.3 in the prior commit (442cec7); this commit does not bump them again, but cuts the actual release section in CHANGELOG and updates the version-comparison footer link. # pathkit slug disambiguation (#23) When a project-dir slug like `C--code-New--Project` happens to decode to two real folders on disk (e.g., a literal `New--Project` folder AND a sibling `New\.Project` folder, both of which sanitize to the same slug), pathkit now picks the right one via a three-tier fallback chain in `_disambiguate`: Tier 1 (definitive, O(N)): if the JSONL's `first_cwd` matches any candidate exactly OR as a prefix-with-separator, return that candidate. This is the canonical "session-open cwd" oracle. Tier 2 (full histogram, O(N*M)): if no candidate matches `first_cwd` but `folder_usage` is provided, find the candidate with the highest sum of matching cwd-counts. Subdirectory cwds count toward their parent candidate. Tier 3 (no signal): fall back to the encoded-length heuristic (current candidate ordering from `_collect_candidates`), which preserves #19's first-match return for callers without JSONL info. Zero perf change for the unambiguous case. The signature change is additive and backward-compatible: decode_project_slug(slug, first_cwd=None, folder_usage=None) derive_start_at(jsonl_path, first_cwd=None, folder_usage=None) `timeline._resolve_start_at` builds the `folder_usage` dict from the existing `Session` object (no new indexer fields needed) and threads both signals through. Path comparisons go through a `_path_matches` helper using stdlib `os.path.normcase` + `os.path.normpath` for case + separator + trailing-slash insensitivity. The original AC suggested `dazzle_filekit.paths.normalize_path_no_resolve`; we used the stdlib equivalent for now and noted in a code comment that filekit is a drop-in replacement when the broader path-utils consolidation pass happens (separate issue, not blocking this release). Old `_decode_under` is kept as a thin backward-compat wrapper that calls `_collect_candidates` + `_disambiguate(..., None, None)` -- the existing 33 #19-era pathkit tests pass unchanged. # csb resume cd + Windows TTY handoff (#24) Three layers of fix for what was originally filed as one bug: Layer 1 -- cmd_resume now invokes claude with cwd=target rather than printing `cd` decoratively and exec'ing claude in the user's terminal cwd. Pre-fix, `claude --resume <uuid>` ran from the wrong cwd and failed with "No conversation found." Layer 2 -- target is derived from `pathkit.derive_start_at( jsonl_path, first_cwd, folder_usage)` rather than `session['start_folder']`. Per the upstream-source audit (#25), the slug-decoded path is the only cwd whose slug equals the JSONL's parent directory -- which is the only cwd from which `claude --resume` will find the file. Falls back to start_folder for legacy session rows that lack `jsonl_path`. Layer 3 -- launch via `subprocess.run([...], cwd=target)` rather than `os.chdir + os.execvp`. On Windows, Python's `os.execvp` is `_spawnv(P_OVERLAY, ...)`: the parent exits and a child spawns, but the controlling-TTY relationship doesn't transfer cleanly. Symptom: claude TUI rendered to stdout but stdin keystrokes went into the void. subprocess.run inherits the parent's stdin/stdout/stderr handles so the TUI works correctly. The python parent stays alive (~30MB) while claude runs and propagates claude's exit code. The original AC for #24 specified `os.chdir + os.execvp`; the subprocess.run approach is a strict improvement that addresses Layer 3 (which the original AC didn't anticipate). FileNotFoundError from subprocess.run is disambiguated between "target folder deleted" and "claude not in PATH" via `os.path.isdir(target)`. The TTY-handoff regression was caught by the tester agent flagging HV.1 as MANUAL during the v0.2.3 checklist run, and confirmed by the user manually running `csb resume <uuid>` from a foreign cwd and observing claude not accept keystrokes. Fixed before commit. # 2-positional scan grammar fix (regression from #20) The tester agent also found that `csb scan ./amdead my-paper` was rejected by argparse with "unrecognized arguments: my-paper" -- the parser had a single `term` positional that consumed `./amdead`, leaving no slot for `my-paper`. The dot-prefix-shortcut help text documented this combined form but the parser didn't accept it. Fix: added `term2` as a second optional positional. cmd_scan validates that two positionals are only allowed when the first is a `./<dir>` / `.\<dir>` / bare `.` shortcut; otherwise rejects with a clear error suggesting `csb scan -d <dir> <term>`. # CHANGELOG converted to [0.2.3] `[Unreleased]` -> `[0.2.3] -- 2026-05-06`. Release preamble summarizes the full v0.2.3 epic (#19, #21, display_top_folders, #20, #23, #24; #25 closed by source audit). Comparison-link footer updated. Added: - claude_session_backup/pathkit.py: `_normalize_path`, `_path_matches`, `_collect_candidates`, `_disambiguate`. New kwargs on `decode_project_slug` and `derive_start_at`. Module docstring updated to remove the "documented limitation" caveat from #19. - claude_session_backup/timeline.py: `_resolve_start_at` threads `start_folder` and the `folders` histogram through to `derive_start_at` for disambiguation. - claude_session_backup/commands.py: `cmd_resume` rewrite using `subprocess.run(cwd=target)` with three-layer target derivation (slug-decoded -> start_folder -> None). `cmd_scan` validates the 2-positional form requires a dot-prefix first arg. - claude_session_backup/cli.py: scan parser gains `term2` second optional positional. - tests/test_pathkit.py: 20 new tests for `_collect_candidates`, three-tier `_disambiguate` (Tier 1 exact, prefix, both directions, fall-through; Tier 2 highest-count, subdirectory-rollup, fall-through; Tier 3 single-candidate short-circuit, encoded-length heuristic, empty list); path-comparison normalization (case, separators, trailing slash); signature-compat smoke tests for the new kwargs. - tests/test_commands.py: 9 new `cmd_resume` tests covering the subprocess.run launch with cwd=target, returncode propagation, FileNotFoundError disambiguation between deleted-target and missing-claude, session-not-found early exit, Layer 2 slug-decoded preference over start_folder, fallback on `<unresolved:>` sentinel, and fallback when session row lacks jsonl_path. 1 new `cmd_scan` test for the 2-positional rejection without dot-prefix. - tests/test_cli.py: 4 new parser tests for the `term2` positional (dot-prefix + term combo, single-positional case, three-positional rejection). - tests/checklists/v0.2.3__Feature__csb-scan-disambiguation.md: restructured per the test-checklist skill template (header block, High-Value Verification, public/private planning lineage, cross-shell prerequisites, automated-test coverage table, expanded "what mocks don't cover", reset/cleanup sections, reporting guidance). Added Section 11 (#24 cd + TTY) and Section 12 (#23 pathkit) detail. - tests/one-offs/check_resume_target.py: validation script left by the tester agent during checklist run (kept per CLAUDE.md's "include test code" rule). Changed: - CHANGELOG: `[Unreleased]` content moved to `[0.2.3] -- 2026-05-06` with epic-summary preamble. Comparison-link footer updated. - Implementation deviation from #23 AC #7 (path normalization): used stdlib `os.path.normcase`/`normpath` instead of `dazzle_filekit. paths.normalize_path_no_resolve`. Functionally equivalent; filekit consolidation deferred to #26. - Implementation deviation from #24 ACs #1, #4, #5 (chdir/execvp): used `subprocess.run(cwd=target)` for Windows TTY-handoff correctness. Functionally satisfies the original ACs and addresses Layer 3 the original spec didn't anticipate. 226 tests passing (was 192; +34 net for #23 + #24 + scan 2-positional). Closes #23 Closes #24 Refs #20 (closed in 442cec7; this completes the 0.2.3 wave) Refs #21 (top-N infrastructure consumed by #23's Tier 2 gating) Refs #19 (pathkit foundation extended) Refs #25 (closed; upstream-source audit grounds #23 algorithm and #24 Layer 2 justification) Refs #26 (tracks the deferred dazzle_filekit consolidation that #23 ACs #7 and #15 originally specified) Design: 2026-05-06__15-21-24__senior-eng-claude-code-source-audit-slug-mechanisms.md 2026-05-06__11-17-07__senior-eng-investigation-pathkit-slug-vs-first-cwd.md 2026-05-05__19-00-30__csb-scan-term-vs-folder-disambiguation.md 2026-05-06__16-30-00__both_csb-resume-tty-handoff-broken-on-windows.md
1 parent 442cec7 commit 079d432

10 files changed

Lines changed: 1084 additions & 78 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,16 @@ Status: **prealpha**. Until the first alpha release, breaking changes may land b
99

1010
## [Unreleased]
1111

12+
## [0.2.3] -- 2026-05-06 (prealpha)
13+
14+
Closes the v0.2.3 epic bundle: pathkit `start at` semantics (#19), folder-usage long tail with `--top N` / `--all-folders` (#21), `display_top_folders` config (#21 follow-up), `csb scan` term-vs-folder disambiguation with `-d` / `-D` / `-s` flags (#20), pathkit multi-candidate slug disambiguation (#23), and `csb resume` cd + Windows TTY-handoff fix (#24). The release is grounded in a senior-eng upstream-source audit (#25, closed) that ruled out the file-relocation hypothesis and confirmed the slug encoder behavior. 226/226 tests pass.
15+
1216
### Fixed
1317
- **`csb list` / `csb scan` "start at" line now reports the cwd that lets `claude --resume` find the session** -- previously derived from a JSONL `cwd` histogram (most-common cwd across all events), which silently misled users when Claude Code was launched from a parent dir and `cd`-ed into a subdir afterwards. Now derived from the project-dir slug (`~/.claude/projects/<slug>/`) via filesystem-validated reverse decoding in the new `claude_session_backup/pathkit.py` module. Mirrors the upstream encoder at `claude-code/utils/sessionStoragePortable.ts:311-319` (`replace(/[^a-zA-Z0-9]/g, '-')`); the inverse uses `os.listdir` per directory level and the longest-encoded-entry-first heuristic to disambiguate slugs that have multiple valid filesystem decodings (e.g., a folder literally named `New--Project` vs. `New\.Project`). Returns the sentinel `<unresolved:slug>` when no candidate decodes (e.g., the original cwd has been deleted) so maintainers can still see the slug. Closes #19. (#19, prealpha-blocker)
1418
- **Indexer no longer truncates folder usage to top-N; `csb list --top N` / `--all-folders` reveal the long tail** -- `metadata.py` previously kept only `1 + top_n_folders` rows in the SQLite `folder_usage` table at index time, which made the long tail of cwds invisible regardless of any renderer flag. The slug-decoded "start at" cwd was a frequent casualty (e.g., AMD_INTIGRITI's `C:\` ranked 5th by JSONL count and got dropped), surfacing as a missing `(Nx)` count next to the "start at" line. The indexer now persists every distinct cwd from the JSONL events; the renderer truncates at display time using the new flags. Closes #21.
1519
- **`csb scan` no longer auto-resolves the positional argument as a path against cwd** -- previously, `csb scan amdead` silently treated `amdead` as `<cwd>\amdead` even when the user meant a metadata-search term. The bare positional now means "filter sessions whose name, project, or folder paths contain this term"; explicit path-strict mode is reached via the new `-d` / `-D` flags. Closes #20.
20+
- **`csb resume` now actually launches claude in the correct cwd, with working stdin** -- previously `cmd_resume` printed `cd <start_folder>` as informational text and then called `os.execvp("claude", ...)` from the user's terminal cwd, which made `claude --resume <uuid>` fail with "No conversation found" whenever the user wasn't already in the right directory. The fix has two layers and an implementation note: (a) target is derived from `pathkit.derive_start_at(jsonl_path)` -- the slug-decoded path is the only cwd whose slug matches the JSONL's parent directory, per the upstream-source audit -- with fallback to `start_folder` for legacy session rows that lack `jsonl_path`; (b) the launch uses `subprocess.run(["claude", "--resume", uuid], cwd=target)` rather than `os.chdir + os.execvp`. The subprocess approach is required on Windows because Python's `os.execvp` there is `_spawnv(P_OVERLAY, ...)` -- the parent process exits and a child spawns, but the controlling-TTY relationship doesn't transfer cleanly (claude TUI renders to stdout but stdin keystrokes go into the void). `subprocess.run` inherits the parent's stdin/stdout/stderr handles, so the TUI works correctly. The python parent process stays alive (~30MB) while claude runs and propagates claude's exit code. `FileNotFoundError` from `subprocess.run` disambiguates between "target folder deleted" and "`claude` not in PATH" via `os.path.isdir(target)`. Closes #24.
21+
- **Pathkit slug decoding now disambiguates ambiguous on-disk decodings via JSONL signals** -- when the slug `C--code-New--Project` happens to decode to two real folders (e.g., a literal `New--Project` AND a `New\.Project` sibling), `pathkit.decode_project_slug(slug, first_cwd, folder_usage)` picks the correct one via three-tier fallback: Tier 1 if `first_cwd` matches a candidate exactly or as a prefix-with-separator; Tier 2 if the JSONL's `folder_usage` histogram weights one candidate higher; Tier 3 (encoded-length heuristic) when neither signal matches -- preserving #19's behavior for callers without JSONL access. Path comparisons use case+separator+trailing-slash normalization (stdlib `os.path.normcase`/`normpath`) so Windows variants (`C:\Code\...` vs `c:/code/.../`) compare equal. The `derive_start_at(jsonl_path, first_cwd, folder_usage)` signature mirrors this, and `timeline._resolve_start_at` threads the signals from the existing session dict (no new indexer fields needed). Closes #23.
1622

1723
### Added
1824
- **`csb list --top N` and `csb list --all-folders` (also on `csb scan`)** -- control how many "other" folder rows display beneath the "start at" line. `--top N` shows the top N most-used cwds; `--all-folders` shows everything. Default unchanged (top 3). Mutually exclusive flags. Acceptance criteria from #21 are met. (#21)
@@ -21,10 +27,11 @@ Status: **prealpha**. Until the first alpha release, breaking changes may land b
2127
- **`csb scan -D PATTERN` / `--directory-only`** -- like `-d` but excludes descendants (only this folder, no subdirectories). With wildcard `-D amdead*`, matches sibling basenames at the same directory level only. Mutually exclusive with `-d`. (#20)
2228
- **`csb scan -s PATTERN` / `--start-dir-only`** -- path-strict on `start_folder` only; skips `folder_usage` entirely. Answers "what sessions originated here?" -- useful in a directory to ask "is there anything I can resume that started in this folder?" Trailing `*` for sibling-prefix expansion. Mutually exclusive with `-d` / `-D`. (#20)
2329
- **`csb scan <term>` (positional)** -- broad metadata substring search across session name, project, start_folder, and top-N folder_usage paths. Same vocabulary as `csb list <filter>` but with top-N gating to keep results coherent with what the renderer displays. Combinable with `-d` / `-D` / `-s` for "scope-then-filter" semantics. Emits an `[info]` hint to stderr if the term coincides with a cwd subfolder, suggesting `-d <term>` for path-strict search. (#20)
24-
- **`./dirname` and `.\dirname` shortcut** -- when the positional starts with `./` or `.\` (the conventional shell indicator for a relative path), it's auto-promoted to `-d <dirname>` path-strict mode. So `csb scan ./amdead` is equivalent to `csb scan -d amdead`, no flag-name to remember. Bare `csb scan .` is also promoted (equivalent to `csb scan -d .`). Suppressed if the user already passed `-d` or `-D` explicitly. (#20)
30+
- **`./dirname` and `.\dirname` shortcut** -- when the positional starts with `./` or `.\` (the conventional shell indicator for a relative path), it's auto-promoted to `-d <dirname>` path-strict mode. So `csb scan ./amdead` is equivalent to `csb scan -d amdead`, no flag-name to remember. Bare `csb scan .` is also promoted (equivalent to `csb scan -d .`). Suppressed if the user already passed `-d` or `-D` explicitly. The shortcut also composes with a term filter: `csb scan ./amdead my-paper` parses as two positionals and is equivalent to `csb scan -d amdead my-paper`. A bare two-positional form without the dot-prefix (`csb scan amdead my-paper`) is rejected with a clear error suggesting the explicit `-d` form. (#20)
2531
- **`csb scan` info / warning hints (stderr-routed)** -- `[info]` when a term coincides with a cwd subfolder; `[warning]` when `-d <pattern>` resolves to a path that doesn't exist (with graceful fallback to broad-term search if a term was provided). Stderr routing keeps `--json` stdout clean for tooling consumers. (#20)
2632
- **`find_sessions_by_directory` and `find_sessions_by_term` SQL helpers** in `claude_session_backup/index.py` with SQLite window-function-based top-N gating (`ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY usage_count DESC)`). Uses `ESCAPE '|'` clause for safe LIKE-pattern composition with user-supplied paths containing `%` / `_`. Requires SQLite >= 3.25 (2018; modern Python ships 3.31+). (#20)
2733
- **73 new tests** -- 25 in `test_index.py` (window-function correctness, top-N gating, escape semantics, deleted-session exclusion, `start_folder_only=True` branch); 27 in `test_cli.py` covering all forms of the new scan grammar + mutex enforcement (including `-s` short/long, `-s/-d` and `-s/-D` mutex); 21 in `test_commands.py` for `_resolve_directory_pattern` (relative + absolute paths, wildcard variants, special character escaping) and `_maybe_promote_dot_prefix` (./ and .\ shortcut auto-promotion). Total: 192/192 pass.
34+
- **34 new tests for #23 + #24 + 2-positional fix** -- 20 in `test_pathkit.py` (`_collect_candidates` returns ALL on-disk decodings; `_disambiguate` Tier 1 / Tier 2 / Tier 3 fallback chain; path-comparison normalization for case + separator + trailing-slash; backward-compat signature for `decode_project_slug` and `derive_start_at` accepting the new `first_cwd` / `folder_usage` kwargs); 9 in `test_commands.py` for `cmd_resume` covering subprocess.run launch with `cwd=target`, returncode propagation, missing-target FileNotFoundError vs missing-claude FileNotFoundError disambiguation, session-not-found, Layer 2 slug-decoded path preference, and fallback to `start_folder` on `<unresolved:>` sentinel or missing `jsonl_path`; 1 in `test_commands.py` for `cmd_scan`'s 2-positional rejection without dot-prefix; 4 in `test_cli.py` for the new `term2` parser positional (dot-prefix + term combo, single-positional case, three-positional rejection, etc.). Total: 226/226 pass.
2835

2936
### Changed
3037
- **`extract_metadata` no longer accepts `top_n_folders`** -- the indexer always stores all rows. Callers updated. Removed the `top_n_folders` config key (was dead config now that the indexer is single-policy).
@@ -63,7 +70,8 @@ First release with the repository public. Focus: make the install path work toda
6370

6471
First public release. `csb list --sort`, `csb scan` with folder-usage search, cross-platform Claude Code plugin with Node.js bootstrapper, two-commit backup model, timeline view with purge countdown, session resume and restore. 73/73 tests pass. See the [v0.2.0 release notes](https://github.com/DazzleML/Claude-Session-Backup/releases/tag/v0.2.0) for the full highlight list.
6572

66-
[Unreleased]: https://github.com/DazzleML/Claude-Session-Backup/compare/v0.2.2...HEAD
73+
[Unreleased]: https://github.com/DazzleML/Claude-Session-Backup/compare/v0.2.3...HEAD
74+
[0.2.3]: https://github.com/DazzleML/Claude-Session-Backup/compare/v0.2.2...v0.2.3
6775
[0.2.2]: https://github.com/DazzleML/Claude-Session-Backup/compare/v0.2.1...v0.2.2
6876
[0.2.1]: https://github.com/DazzleML/Claude-Session-Backup/compare/v0.2.0...v0.2.1
6977
[0.2.0]: https://github.com/DazzleML/Claude-Session-Backup/releases/tag/v0.2.0

claude_session_backup/cli.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,14 @@ def build_parser():
200200
_add_common_flags(p_scan)
201201
p_scan.add_argument(
202202
"term", nargs="?", default=None,
203-
help="Filter sessions whose name, project, or folder paths contain term (case-insensitive)",
203+
help="Filter sessions whose name, project, or folder paths contain term (case-insensitive). "
204+
"If this starts with `./` or `.\\` (or is a bare `.`), it's auto-promoted to implicit -d.",
205+
)
206+
p_scan.add_argument(
207+
"term2", nargs="?", default=None,
208+
help="Optional second positional. Only valid when the first positional is a "
209+
"`./dirname` / `.\\dirname` shortcut -- in that case `term2` is the actual term "
210+
"filter (equivalent to `csb scan -d dirname term2`). Otherwise rejected.",
204211
)
205212
p_scan.add_argument("-n", type=int, default=20, help="Number of sessions to show")
206213
p_scan.add_argument(

claude_session_backup/commands.py

Lines changed: 75 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,8 @@ def cmd_config(args) -> int:
433433

434434
def cmd_resume(args) -> int:
435435
"""Launch claude --resume with the full session UUID."""
436+
from .pathkit import derive_start_at
437+
436438
config = _get_config(args)
437439
conn = open_db(config["index_path"])
438440
init_schema(conn)
@@ -445,23 +447,68 @@ def cmd_resume(args) -> int:
445447
return 1
446448

447449
full_id = session["session_id"]
448-
start_folder = session.get("start_folder")
449450
name = session.get("session_name") or "(unnamed)"
450451

452+
# Resolve cd target via pathkit (slug-decoded path = the only cwd whose
453+
# slug matches the JSONL's parent directory; per the upstream-source audit,
454+
# that's the only cwd from which `claude --resume <uuid>` will find the
455+
# file). Falls back to start_folder for sessions without a jsonl_path
456+
# (e.g., legacy index rows pre-#19).
457+
target = None
458+
jsonl_path = session.get("jsonl_path")
459+
if jsonl_path:
460+
first_cwd = session.get("start_folder")
461+
folders = session.get("folders") or []
462+
folder_usage = {f["folder_path"]: f.get("usage_count", 0) for f in folders}
463+
decoded = derive_start_at(jsonl_path, first_cwd=first_cwd, folder_usage=folder_usage)
464+
if decoded and not decoded.startswith("<"):
465+
target = decoded
466+
if target is None:
467+
target = session.get("start_folder")
468+
451469
print(f"Resuming: {name}")
452470
print(f" ID: {full_id}")
453-
if start_folder:
454-
print(f" cd {start_folder}")
471+
if target:
472+
print(f" cd {target}")
455473
print(f" claude --resume {full_id}")
456474
print()
457475

458-
# Execute claude --resume (replaces this process)
476+
# Launch claude --resume as a child process. We use subprocess.run with
477+
# cwd=target rather than os.chdir + os.execvp because Python's os.execvp
478+
# on Windows is _spawnv(P_OVERLAY, ...) -- the parent process exits and
479+
# spawns a child, but the controlling-TTY relationship doesn't transfer
480+
# cleanly. Symptom: claude TUI renders to stdout but stdin keystrokes
481+
# don't reach claude (they go into the void). subprocess.run inherits
482+
# the parent's stdin/stdout/stderr handles, which are still attached to
483+
# the user's terminal, so the TUI works correctly.
484+
#
485+
# Trade-off: the python process stays alive in memory while claude
486+
# runs (~30MB cost). When claude exits, its return code propagates.
487+
import subprocess
459488
try:
460-
os.execvp("claude", ["claude", "--resume", full_id])
461-
except FileNotFoundError:
489+
result = subprocess.run(
490+
["claude", "--resume", full_id],
491+
cwd=target if target else None,
492+
check=False,
493+
)
494+
return result.returncode
495+
except FileNotFoundError as e:
496+
# FileNotFoundError can fire from two places:
497+
# (a) the cwd= path doesn't exist (target folder deleted)
498+
# (b) the `claude` binary isn't in PATH
499+
# Disambiguate by checking whether the target itself is the issue.
500+
if target and not os.path.isdir(target):
501+
print(f"Error: cannot cd to {target}: {e}", file=sys.stderr)
502+
print("The folder may have been deleted. Run manually:", file=sys.stderr)
503+
print(f" cd <correct-folder> && claude --resume {full_id}", file=sys.stderr)
504+
return 1
462505
print("Error: 'claude' command not found in PATH.", file=sys.stderr)
463506
print(f"Run manually: claude --resume {full_id}", file=sys.stderr)
464507
return 1
508+
except NotADirectoryError as e:
509+
# Edge case: target exists but isn't a directory (file with same name).
510+
print(f"Error: cannot cd to {target}: {e}", file=sys.stderr)
511+
return 1
465512

466513

467514
def _resolve_directory_pattern(
@@ -574,13 +621,35 @@ def cmd_scan(args) -> int:
574621
directory_only = getattr(args, "directory_only", None)
575622
start_dir_only = getattr(args, "start_dir_only", None)
576623
term = getattr(args, "term", None)
624+
term2 = getattr(args, "term2", None)
625+
626+
# Two positionals are only valid when the FIRST is a `./` / `.\` / `.` shortcut.
627+
# In that case the second positional is the actual term filter (equivalent to
628+
# `csb scan -d <dirname> <term>`). Otherwise we reject -- a bare two-positional
629+
# form like `csb scan amdead my-paper` is ambiguous.
630+
if term2 is not None:
631+
first_is_dot_prefix = term in (".", "./", ".\\") or (
632+
term and (term.startswith("./") or term.startswith(".\\"))
633+
)
634+
if not first_is_dot_prefix:
635+
print(
636+
"Error: too many positional arguments. The two-positional form requires the "
637+
"first to be `./<dir>`, `.\\<dir>`, or bare `.` -- otherwise use "
638+
"`csb scan -d <dir> <term>` for the explicit form.",
639+
file=sys.stderr,
640+
)
641+
return 2
577642

578643
# Auto-promote ./ or .\ prefixed positional to implicit -d
579644
# (only when -d/-D/-s are not already set explicitly).
580645
if directories_below is None and directory_only is None and start_dir_only is None:
581646
term, promoted = _maybe_promote_dot_prefix(term)
582647
if promoted is not None:
583648
directories_below = promoted
649+
# If the user gave two positionals (dot-prefix + term), the SECOND is
650+
# the actual term filter to apply within the path-strict scope.
651+
if term2 is not None:
652+
term = term2
584653

585654
# Pattern + descendant flag (None pattern means: bare, treat as implicit "-d .")
586655
pattern: str | None = directories_below or directory_only or start_dir_only

0 commit comments

Comments
 (0)