cc-session-tools 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ """Claude Code session management CLI tools (ccd, ccr, ccs)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["__version__"]
6
+
7
+
8
+ def _get_version() -> str:
9
+ # Prefer the installed-distribution version (set by pipx/pip install).
10
+ try:
11
+ from importlib.metadata import PackageNotFoundError, version
12
+ return version("cc-session-tools")
13
+ except PackageNotFoundError:
14
+ pass
15
+ except ImportError:
16
+ pass
17
+ # Fallback for source-tree runs (e.g. PYTHONPATH=src python -m ...).
18
+ return "0.0.0+source"
19
+
20
+
21
+ __version__ = _get_version()
File without changes
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+
9
+ from cc_session_tools import __version__
10
+ from cc_session_tools.lib import prompts, rules
11
+ from cc_session_tools.lib.tasklist import id_for_project
12
+
13
+
14
+ def launch_claude(cmd: list[str], env: dict[str, str]) -> None:
15
+ """Replace the current process with `claude`. Wrapped in a function so
16
+ tests can monkeypatch this without performing a real exec."""
17
+ os.execvpe(cmd[0], cmd, env)
18
+
19
+
20
+ def _build_parser() -> argparse.ArgumentParser:
21
+ p = argparse.ArgumentParser(
22
+ prog="ccd",
23
+ description="Start a new Claude Code session with a pre-created cc-sessions/ dir.",
24
+ )
25
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
26
+ p.add_argument("--force", action="store_true",
27
+ help="Skip root, project-name, and tag-prefix checks.")
28
+ p.add_argument("--debug", action="store_true",
29
+ help="Enable debug output (also: CCX_DEBUG=1).")
30
+ p.add_argument("tag", help="Name tag (no spaces; use dashes).")
31
+ p.add_argument("extra", nargs=argparse.REMAINDER,
32
+ help="Additional args passed through to claude.")
33
+ return p
34
+
35
+
36
+ def main(argv: list[str] | None = None) -> int:
37
+ args = _build_parser().parse_args(argv)
38
+
39
+ if args.debug:
40
+ os.environ["CCX_DEBUG"] = "1"
41
+ from cc_session_tools.lib.debug import debug
42
+
43
+ real_pwd = Path.cwd().resolve()
44
+
45
+ # Run the Levenshtein typo / missing-prefix prompts FIRST so that a tag
46
+ # like "test-foo" under the strict root can be corrected to "oneshot-test-foo"
47
+ # interactively, before the rules validator would reject it outright.
48
+ # The prompt is a no-op outside the strict (PROJ) root.
49
+ tag = args.tag
50
+ if not args.force:
51
+ tag = prompts.maybe_correct_tag(real_pwd, tag)
52
+
53
+ # Validate tag/cwd via shared rules (on the possibly-corrected tag).
54
+ ok, errors = rules.check_session_init(real_pwd, tag, force=args.force)
55
+ if not ok:
56
+ print("ccd: validation failed:", file=sys.stderr)
57
+ for e in errors:
58
+ for line in e.splitlines():
59
+ print(f" {line}", file=sys.stderr)
60
+ if not args.force:
61
+ print(" (use --force to bypass root and strict-root checks)",
62
+ file=sys.stderr)
63
+ return 1
64
+
65
+ date_str = datetime.now().strftime("%Y%m%d")
66
+ session_name = f"{date_str}-{tag}"
67
+ session_dir = real_pwd / "cc-sessions" / session_name
68
+ debug(f"tag: {tag!r}")
69
+ debug(f"session_dir: {session_dir}")
70
+
71
+ if session_dir.exists():
72
+ print(
73
+ f"ccd: session '{session_name}' already started today in this directory.",
74
+ file=sys.stderr,
75
+ )
76
+ print(f"ccd: existing: {session_dir}", file=sys.stderr)
77
+ print(
78
+ f"ccd: Use a different name tag, or 'ccr {tag}' to resume the existing one.",
79
+ file=sys.stderr,
80
+ )
81
+ return 1
82
+
83
+ (session_dir / "working").mkdir(parents=True)
84
+ (session_dir / "out").mkdir(parents=True)
85
+
86
+ # Build env for the SessionStart hook + task list. Drop any inherited
87
+ # CLAUDE_CODE_TASK_LIST_ID so the new one (or absence of one) is authoritative.
88
+ env = os.environ.copy()
89
+ env.pop("CLAUDE_CODE_TASK_LIST_ID", None)
90
+ env["CLD_SESSION_TAG"] = tag
91
+ env["CLD_SESSION_DIR"] = str(session_dir.relative_to(real_pwd))
92
+ env["CLD_SESSION_MODE"] = "new"
93
+ task_list_id = id_for_project(real_pwd)
94
+ if task_list_id is not None:
95
+ env["CLAUDE_CODE_TASK_LIST_ID"] = task_list_id
96
+
97
+ cmd = [
98
+ "claude",
99
+ "-n", session_name,
100
+ "--remote-control", session_name,
101
+ *(args.extra or []),
102
+ ]
103
+ # Chdir to the resolved project path so Claude Code records its
104
+ # ~/.claude/projects/<encoded-cwd>/ key against the canonical, symlink-
105
+ # resolved path. Matches the original bash ccd's `cd "$real_pwd"` step.
106
+ debug(f"launching: {cmd}")
107
+ os.chdir(real_pwd)
108
+ launch_claude(cmd, env)
109
+ return 0
110
+
111
+
112
+ if __name__ == "__main__":
113
+ sys.exit(main())
@@ -0,0 +1,151 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import shutil
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from cc_session_tools import __version__
10
+ from cc_session_tools.lib.claude_flags import get_claude_flags
11
+ from cc_session_tools.lib.roots import load_session_roots
12
+ from cc_session_tools.lib.sessions import SESSION_FULL_RE, SessionMatch, find_matching_sessions, session_tag
13
+ from cc_session_tools.lib.tasklist import id_for_project
14
+
15
+
16
+ def launch_claude_resume(cmd: list[str], env: dict[str, str], cwd: Path | None = None) -> None:
17
+ """Replace the current process with `claude --resume`. Wrapped so tests
18
+ can monkeypatch this without performing a real exec."""
19
+ if cwd is not None:
20
+ os.chdir(cwd)
21
+ os.execvpe(cmd[0], cmd, env)
22
+
23
+
24
+ def _build_parser() -> argparse.ArgumentParser:
25
+ p = argparse.ArgumentParser(
26
+ prog="ccr",
27
+ description="Resume a Claude Code session by name-tag fragment.",
28
+ )
29
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
30
+ p.add_argument("fragment", help="Substring to match against session basenames.")
31
+ p.add_argument("--debug", action="store_true",
32
+ help="Enable debug output (also: CCX_DEBUG=1).")
33
+ return p
34
+
35
+
36
+ def main(argv: list[str] | None = None) -> int:
37
+ args, remainder = _build_parser().parse_known_args(argv)
38
+
39
+ if args.debug:
40
+ os.environ["CCX_DEBUG"] = "1"
41
+ from cc_session_tools.lib.debug import debug
42
+
43
+ roots = load_session_roots()
44
+
45
+ # Exact-match fast-path: if fragment looks like a full basename, try a
46
+ # direct directory lookup before falling back to substring search. This
47
+ # prevents "20260504-foo" from being treated as ambiguous when
48
+ # "20260504-foo-bar" also exists.
49
+ exact_match: SessionMatch | None = None
50
+ if SESSION_FULL_RE.fullmatch(args.fragment):
51
+ for root in roots:
52
+ if not root.is_dir():
53
+ continue
54
+ for proj in root.iterdir():
55
+ if not proj.is_dir():
56
+ continue
57
+ candidate = proj / "cc-sessions" / args.fragment
58
+ if candidate.is_dir():
59
+ exact_match = SessionMatch(
60
+ basename=args.fragment,
61
+ project_dir=proj,
62
+ session_dir=candidate,
63
+ )
64
+ break
65
+ if exact_match:
66
+ break
67
+
68
+ matches = [exact_match] if exact_match else find_matching_sessions(args.fragment, roots)
69
+ debug(f"fragment: {args.fragment!r}")
70
+ debug(f"matches: {[m.basename for m in matches]}")
71
+
72
+ if not matches:
73
+ print(f"ccr: no sessions match '{args.fragment}'", file=sys.stderr)
74
+ return 1
75
+
76
+ if len(matches) > 1:
77
+ if len(matches) <= 10 and sys.stdin.isatty():
78
+ from cc_session_tools.lib.picker import pick_from_list
79
+ from cc_session_tools.lib.sessions import session_start_date
80
+ matches.sort(key=lambda x: session_start_date(x.basename) or "", reverse=True)
81
+ labels = [f"{m.basename} ({m.project_dir})" for m in matches]
82
+ idx = pick_from_list(labels)
83
+ if idx is None:
84
+ return 0
85
+ m = matches[idx]
86
+ # Fall through to single-match resume logic below
87
+ else:
88
+ print("Multiple sessions match that name tag fragment:")
89
+ for m in matches:
90
+ print(f" {m.basename} ({m.project_dir})")
91
+ print(
92
+ "Please re-run ccr with an unambiguous fragment of the name tag "
93
+ "of the session you want to resume."
94
+ )
95
+ return 0
96
+ else:
97
+ m = matches[0]
98
+
99
+ # single match (or picker selection) - variable m is set above
100
+ tag = session_tag(m.basename)
101
+ if tag is None:
102
+ # Should not happen because find_matching_sessions only returns
103
+ # basenames that match SESSION_BASENAME_RE, but fall back gracefully.
104
+ tag = m.basename
105
+
106
+ # Fail fast with a clear message when claude is not on PATH.
107
+ if not shutil.which("claude"):
108
+ print(
109
+ "ccr: 'claude' not found on PATH - is Claude Code installed?",
110
+ file=sys.stderr,
111
+ )
112
+ return 1
113
+
114
+ # Validate and pass through any extra flags from remainder.
115
+ # Long flags (--foo) are checked against claude's known flags.
116
+ # Short flags (-f) pass through without validation.
117
+ if remainder:
118
+ valid_flags = get_claude_flags()
119
+ for arg in remainder:
120
+ if arg.startswith("--"):
121
+ flag_name = arg.split("=")[0]
122
+ if flag_name not in valid_flags:
123
+ print(
124
+ f"ccr: unknown flag '{arg}'; not a recognised claude option",
125
+ file=sys.stderr,
126
+ )
127
+ return 1
128
+
129
+ env = os.environ.copy()
130
+ env.pop("CLAUDE_CODE_TASK_LIST_ID", None)
131
+ env["CLD_SESSION_TAG"] = tag
132
+ env["CLD_SESSION_DIR"] = str(m.session_dir)
133
+ env["CLD_SESSION_MODE"] = "resume"
134
+ task_list_id = id_for_project(m.project_dir)
135
+ if task_list_id is not None:
136
+ env["CLAUDE_CODE_TASK_LIST_ID"] = task_list_id
137
+
138
+ cmd = [
139
+ "claude",
140
+ "--resume", m.basename,
141
+ "--remote-control", m.basename,
142
+ ]
143
+ if remainder:
144
+ cmd.extend(remainder)
145
+ debug(f"resuming: {m.basename} in {m.project_dir}")
146
+ launch_claude_resume(cmd, env, cwd=m.project_dir)
147
+ return 0
148
+
149
+
150
+ if __name__ == "__main__":
151
+ sys.exit(main())