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.
- cc_session_tools/__init__.py +21 -0
- cc_session_tools/cli/__init__.py +0 -0
- cc_session_tools/cli/ccd.py +113 -0
- cc_session_tools/cli/ccr.py +151 -0
- cc_session_tools/cli/ccs.py +697 -0
- cc_session_tools/lib/__init__.py +0 -0
- cc_session_tools/lib/claude_flags.py +59 -0
- cc_session_tools/lib/debug.py +13 -0
- cc_session_tools/lib/levenshtein.py +20 -0
- cc_session_tools/lib/picker.py +26 -0
- cc_session_tools/lib/prompts.py +89 -0
- cc_session_tools/lib/roots.py +113 -0
- cc_session_tools/lib/rules.py +187 -0
- cc_session_tools/lib/sessions.py +242 -0
- cc_session_tools/lib/tasklist.py +17 -0
- cc_session_tools-0.7.0.dist-info/METADATA +348 -0
- cc_session_tools-0.7.0.dist-info/RECORD +33 -0
- cc_session_tools-0.7.0.dist-info/WHEEL +5 -0
- cc_session_tools-0.7.0.dist-info/entry_points.txt +5 -0
- cc_session_tools-0.7.0.dist-info/licenses/LICENSE +21 -0
- cc_session_tools-0.7.0.dist-info/top_level.txt +2 -0
- claude_code_usage/__init__.py +8 -0
- claude_code_usage/attribution.py +81 -0
- claude_code_usage/cache.py +202 -0
- claude_code_usage/ccusage_wrapper.py +139 -0
- claude_code_usage/cli.py +349 -0
- claude_code_usage/parent_inference.py +105 -0
- claude_code_usage/parser.py +199 -0
- claude_code_usage/pricing.py +97 -0
- claude_code_usage/query.py +295 -0
- claude_code_usage/report.py +136 -0
- claude_code_usage/schema.py +99 -0
- claude_code_usage/session_names.py +172 -0
|
@@ -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())
|