hatcheck 0.1.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.
- hatcheck/__init__.py +6 -0
- hatcheck/best.py +198 -0
- hatcheck/bind.py +354 -0
- hatcheck/cli.py +83 -0
- hatcheck/desktop.py +338 -0
- hatcheck/launch.py +146 -0
- hatcheck/move.py +1219 -0
- hatcheck/open.py +277 -0
- hatcheck/provider.py +283 -0
- hatcheck/sessions.py +1067 -0
- hatcheck/show.py +76 -0
- hatcheck/unbind.py +19 -0
- hatcheck/usage.py +763 -0
- hatcheck/where.py +94 -0
- hatcheck-0.1.0.dist-info/METADATA +885 -0
- hatcheck-0.1.0.dist-info/RECORD +20 -0
- hatcheck-0.1.0.dist-info/WHEEL +5 -0
- hatcheck-0.1.0.dist-info/entry_points.txt +7 -0
- hatcheck-0.1.0.dist-info/licenses/LICENSE +21 -0
- hatcheck-0.1.0.dist-info/top_level.txt +1 -0
hatcheck/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Hatcheck: Claude Code and Codex accounts on one computer, each kept apart.
|
|
2
|
+
|
|
3
|
+
The subcommands are the scripts in this folder; hatcheck.py beside it, or the hatcheck
|
|
4
|
+
command a package install makes, runs them. They load each other by path, so keep the
|
|
5
|
+
folder whole."""
|
|
6
|
+
__version__ = "0.1.0"
|
hatcheck/best.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Which account to use now, and where to carry a session.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
hatcheck best [--for SESSION] [--cached]
|
|
6
|
+
|
|
7
|
+
--for : where to carry a session, by its ID or the start of it: the hatcheck
|
|
8
|
+
move command for the best other account of its agent.
|
|
9
|
+
--cached : don't call the APIs; use the snapshots Claude Code and Codex last saved.
|
|
10
|
+
|
|
11
|
+
Claude Code accounts are ranked as hatcheck usage sorts them. First come those with
|
|
12
|
+
Fable left, the soonest Fable reset on top, since what's left goes unused once it
|
|
13
|
+
resets. Next come those out of Fable, or out of the weekly limit for all models
|
|
14
|
+
that it counts against too, the soonest back on top. Then come accounts without a
|
|
15
|
+
Fable limit, by the tightest limit that applies (the one with the least left of the
|
|
16
|
+
5-hour and weekly limits), the sooner reset first on a tie. Codex homes are ranked
|
|
17
|
+
by their tightest limit too. Each line ends with the command that starts the agent
|
|
18
|
+
on that account. --for skips accounts that are out, and the session's own.
|
|
19
|
+
|
|
20
|
+
Advice only: it runs when you ask, prints commands, and never starts, moves or
|
|
21
|
+
switches anything. Using several accounts to get around one plan's limits may break
|
|
22
|
+
Anthropic's or OpenAI's terms; this is for choosing between accounts you may use for
|
|
23
|
+
the work at hand, such as your own and your company's.
|
|
24
|
+
|
|
25
|
+
This script loads sessions.py, usage.py and move.py, so keep them in one folder.
|
|
26
|
+
"""
|
|
27
|
+
import argparse
|
|
28
|
+
import importlib.util
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
import time
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_sibling(name, file):
|
|
35
|
+
sys.dont_write_bytecode = True # leave no __pycache__ folder beside the scripts
|
|
36
|
+
path = os.path.join(os.path.dirname(os.path.realpath(__file__)), file)
|
|
37
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
38
|
+
module = importlib.util.module_from_spec(spec)
|
|
39
|
+
spec.loader.exec_module(module)
|
|
40
|
+
return module
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def meters(usage, saved_at, now, u):
|
|
44
|
+
"""(short name, % left, next reset, kind) for an account's 5-hour, weekly and per-model limits."""
|
|
45
|
+
out = []
|
|
46
|
+
for r in (usage or {}).get("limits") or []:
|
|
47
|
+
if isinstance(r, dict):
|
|
48
|
+
left, reset = u.meter_state(r, saved_at, now)
|
|
49
|
+
out.append((u.row_label(r, short=True), left, reset, r.get("kind")))
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def rank(usage, saved_at, now, u):
|
|
54
|
+
"""Sort key and state for an account: (group, time or -% left, reset), and whether it
|
|
55
|
+
has room now. Groups: 0 Fable left, 1 out of Fable, 2 no Fable limit, 3 no data."""
|
|
56
|
+
if usage is None:
|
|
57
|
+
return (3, 0, 0), False
|
|
58
|
+
group, at = u.fable_rank(usage, saved_at, now)
|
|
59
|
+
if group < 2:
|
|
60
|
+
return (group, at, 0), group == 0
|
|
61
|
+
rows = [m for m in meters(usage, saved_at, now, u) if not str(m[3] or "").endswith("_scoped")]
|
|
62
|
+
if not rows:
|
|
63
|
+
return (2, 0, 0), True
|
|
64
|
+
tightest = min(rows, key=lambda m: (m[1], m[2] or float("inf")))
|
|
65
|
+
return (2, -tightest[1], tightest[2] or float("inf")), tightest[1] > 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def summary(usage, saved_at, why, now, u, room):
|
|
69
|
+
"""What the line says about an account: its Fable limit first, then the tightest other."""
|
|
70
|
+
if usage is None:
|
|
71
|
+
return "no data (%s)" % why
|
|
72
|
+
rows = meters(usage, saved_at, now, u)
|
|
73
|
+
fable = next(((n, left, reset) for n, left, reset, kind in rows if kind == "weekly_scoped" and "fable" in n.lower()),
|
|
74
|
+
None)
|
|
75
|
+
others = sorted((m for m in rows if not str(m[3] or "").endswith("_scoped")), key=lambda m: m[1])
|
|
76
|
+
parts = []
|
|
77
|
+
if fable:
|
|
78
|
+
name, left, reset = fable
|
|
79
|
+
parts.append("%s %d%% left%s" % (name, left, ", resets %s" % u.clock(reset, now) if reset and left else
|
|
80
|
+
", back %s" % u.clock(reset, now) if reset else ""))
|
|
81
|
+
for name, left, reset, _ in others[:2 if not fable else 1]:
|
|
82
|
+
if left <= 0 and reset:
|
|
83
|
+
parts.append("%s used up, back %s" % (name, u.clock(reset, now)))
|
|
84
|
+
else:
|
|
85
|
+
parts.append("%s %d%%" % (name, left))
|
|
86
|
+
if not room and fable and fable[1] > 0:
|
|
87
|
+
parts.append("(the weekly limit blocks Fable)")
|
|
88
|
+
text = " · ".join(parts) or "no limits"
|
|
89
|
+
return text + (" · cached %s ago" % u.duration(now - saved_at) if saved_at is not None else "")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def ranked(accounts, now, u):
|
|
93
|
+
"""[(key, room, entry, result)] best first, for one agent's accounts."""
|
|
94
|
+
out = []
|
|
95
|
+
for entry, result in accounts:
|
|
96
|
+
usage, saved_at, _ = result
|
|
97
|
+
key, room = rank(usage, saved_at, now, u)
|
|
98
|
+
out.append((key, room, entry, result))
|
|
99
|
+
return sorted(out, key=lambda a: a[0])
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def place_name(agent, profile, sessions):
|
|
103
|
+
"""How hatcheck move names a profile or home: default, NAME, codex, codex:NAME, or a path."""
|
|
104
|
+
home = os.path.join(sessions.HOME, ".codex" if agent == "codex" else ".claude")
|
|
105
|
+
if sessions.same_folder(profile, home):
|
|
106
|
+
return "codex" if agent == "codex" else "default"
|
|
107
|
+
parent, base = os.path.split(profile.rstrip("/" + os.sep))
|
|
108
|
+
prefix = os.path.basename(home) + "-"
|
|
109
|
+
if sessions.same_folder(parent, sessions.HOME) and base.startswith(prefix):
|
|
110
|
+
return ("codex:" if agent == "codex" else "") + base[len(prefix):]
|
|
111
|
+
return profile
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def main(argv=None, prog=None):
|
|
115
|
+
parser = argparse.ArgumentParser(prog=prog, description="Which account to use now, and where to carry a session. "
|
|
116
|
+
"Advice only: it never starts, moves or switches anything.")
|
|
117
|
+
parser.add_argument("--for", dest="session", metavar="SESSION",
|
|
118
|
+
help="where to carry this session: the hatcheck move command for the best other account")
|
|
119
|
+
parser.add_argument("--cached", action="store_true",
|
|
120
|
+
help="don't call the APIs; use the snapshots Claude Code and Codex last saved")
|
|
121
|
+
args = parser.parse_args(argv)
|
|
122
|
+
|
|
123
|
+
sessions = load_sibling("hatcheck_sessions", "sessions.py")
|
|
124
|
+
u = sessions.load_usage_module()
|
|
125
|
+
if not u:
|
|
126
|
+
sys.exit("hatcheck best needs usage.py in its folder")
|
|
127
|
+
ansi = u.setup_console()
|
|
128
|
+
color = sys.stdout.isatty() and not os.environ.get("NO_COLOR") and ansi
|
|
129
|
+
paint = (lambda code, s: "\033[%sm%s\033[0m" % (code, s) if code else s) if color else (lambda code, s: s)
|
|
130
|
+
show = sessions.shell_path
|
|
131
|
+
now = time.time()
|
|
132
|
+
loaded = u.load_usage(sessions.find_profiles(), args.cached, sessions.find_codex_homes())
|
|
133
|
+
by_agent = {"claude": ranked([a for a in loaded if not a[0].get("codex")], now, u),
|
|
134
|
+
"codex": ranked([a for a in loaded if a[0].get("codex")], now, u)}
|
|
135
|
+
|
|
136
|
+
if args.session:
|
|
137
|
+
return carry(args, sessions, u, by_agent, now, show, (parser.prog or "hatcheck").split()[0])
|
|
138
|
+
|
|
139
|
+
for agent, title in (("claude", "Claude Code, best first"), ("codex", "Codex, best first")):
|
|
140
|
+
accounts = by_agent[agent]
|
|
141
|
+
if not accounts:
|
|
142
|
+
continue
|
|
143
|
+
print(paint("1", title) + (paint("2", " (sorted by Fable, as hatcheck usage sorts)") if agent == "claude" else ""))
|
|
144
|
+
rows = []
|
|
145
|
+
for n, (key, room, entry, (usage, saved_at, why)) in enumerate(accounts, 1):
|
|
146
|
+
names = ", ".join(show(p) for p in entry["profiles"])
|
|
147
|
+
rows.append(("%d." % n if key[0] < 3 else " -", names, summary(usage, saved_at, why, now, u, room),
|
|
148
|
+
sessions.start_command(agent, entry["profiles"][0]), room))
|
|
149
|
+
width = max(len(r[1]) for r in rows)
|
|
150
|
+
text_width = max(len(r[2]) for r in rows)
|
|
151
|
+
for number, names, text, command, room in rows:
|
|
152
|
+
print(" %s %s %s %s" % (number.rjust(3), paint("1" if room else "2", names.ljust(width)),
|
|
153
|
+
paint("" if room else "2", text.ljust(text_width)), paint("36", "→ " + command)))
|
|
154
|
+
me = (parser.prog or "hatcheck best").split()[0]
|
|
155
|
+
print(paint("2", "Advice only: nothing is started, moved or switched. %s best --for <session> says where "
|
|
156
|
+
"to carry a session." % me))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def carry(args, sessions, u, by_agent, now, show, me):
|
|
160
|
+
"""--for: the best other account for a session of the same agent, as a hatcheck move command."""
|
|
161
|
+
copies = sessions.session_copies(args.session)
|
|
162
|
+
if not copies:
|
|
163
|
+
sys.exit("No session starting with %s" % args.session)
|
|
164
|
+
if len(copies) > 1:
|
|
165
|
+
sys.exit("%s matches %d sessions; give more of the ID" % (args.session, len(copies)))
|
|
166
|
+
((agent, sid), where), = copies.items()
|
|
167
|
+
found = [s for s in (sessions.summarize(agent, p, f) for p, f in where) if s]
|
|
168
|
+
if not found:
|
|
169
|
+
sys.exit("Session %s holds no conversation" % sid[:8])
|
|
170
|
+
s = max(found, key=lambda s: (s["when"], s.get("canonical", True), s["size"]))
|
|
171
|
+
accounts = by_agent[agent]
|
|
172
|
+
mine = next((a for a in accounts if any(sessions.same_folder(p, s["profile"]) for p in a[2]["profiles"])), None)
|
|
173
|
+
if mine:
|
|
174
|
+
_, room, entry, (usage, saved_at, why) = mine
|
|
175
|
+
state = summary(usage, saved_at, why, now, u, room)
|
|
176
|
+
else:
|
|
177
|
+
state = "no usage data"
|
|
178
|
+
print('Session %s "%s" is in %s: %s.' % (sid[:8], sessions.truncate(s["title"], 60), show(s["profile"]), state))
|
|
179
|
+
best = next((a for a in accounts if a[1] and a is not mine), None)
|
|
180
|
+
if mine and accounts and mine[1] and accounts.index(mine) < (accounts.index(best) if best else len(accounts)):
|
|
181
|
+
print("Its own account is the best one now; resume it there with:")
|
|
182
|
+
if s.get("cwd"):
|
|
183
|
+
print(" cd " + show(s["cwd"]))
|
|
184
|
+
print(" " + sessions.session_command(s, False))
|
|
185
|
+
return
|
|
186
|
+
if best is None:
|
|
187
|
+
print("No other %s account has room now." % ("Codex" if agent == "codex" else "Claude Code"))
|
|
188
|
+
return 1
|
|
189
|
+
_, _, entry, (usage, saved_at, why) = best
|
|
190
|
+
dest = entry["profiles"][0]
|
|
191
|
+
print("Carry it to %s: %s." % (show(dest), summary(usage, saved_at, why, now, u, True)))
|
|
192
|
+
print(" %s move %s %s --from %s" % (me, sid[:8], place_name(agent, dest, sessions),
|
|
193
|
+
place_name(agent, s["profile"], sessions)))
|
|
194
|
+
print("It prints the command that resumes it there, and offers to run it.")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
if __name__ == "__main__":
|
|
198
|
+
sys.exit(main())
|
hatcheck/bind.py
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Tie a folder to a profile, so work there happens under the right account.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
hatcheck bind PROFILE [FOLDER] [--codex HOME] bind FOLDER (this one by default) and
|
|
6
|
+
everything under it
|
|
7
|
+
hatcheck bind what this folder is bound to
|
|
8
|
+
hatcheck bind --list every bound folder
|
|
9
|
+
hatcheck bind --hook warn when Claude Code or Codex starts in a
|
|
10
|
+
bound folder under another profile
|
|
11
|
+
hatcheck unbind [FOLDER] remove a folder's binding
|
|
12
|
+
hatcheck unbind --hook remove that warning again
|
|
13
|
+
|
|
14
|
+
PROFILE : NAME for ~/.claude-NAME, default for ~/.claude, or a path. The folder's
|
|
15
|
+
Codex home is the one by the same name, ~/.codex-NAME (~/.codex for
|
|
16
|
+
default), when there is one.
|
|
17
|
+
--codex : the Codex home to bind instead: NAME for ~/.codex-NAME, default for
|
|
18
|
+
~/.codex, or a path. Without PROFILE it binds just the Codex home.
|
|
19
|
+
|
|
20
|
+
Bindings live in your own config, ~/.config/hatcheck/bindings.json
|
|
21
|
+
(%APPDATA%\\hatcheck\\bindings.json on Windows), not in the repo, so profile names
|
|
22
|
+
don't end up in git. The deepest bound folder wins, and folders are compared once
|
|
23
|
+
symlinks are resolved.
|
|
24
|
+
|
|
25
|
+
cl and cx use them: without a profile name in a bound folder, cl --resume <id>
|
|
26
|
+
means cl work --resume <id>, and cx resume <id> means cx work resume <id>. The
|
|
27
|
+
session list marks a session that ran in a bound folder under another profile,
|
|
28
|
+
"⚠ folder bound to ~/.claude-work", so it's easy to spot and hatcheck move.
|
|
29
|
+
|
|
30
|
+
--hook adds a SessionStart hook to every Claude Code profile's settings.json and
|
|
31
|
+
every Codex home's hooks.json, which runs hatcheck bind --check. When a session
|
|
32
|
+
starts in a bound folder under another profile, Claude Code or Codex shows a
|
|
33
|
+
warning that names the folder's profile and the command to use. It only warns;
|
|
34
|
+
nothing is blocked. Codex runs a new hook only once you trust it, with /hooks in
|
|
35
|
+
codex. Claude Code shows the warning in its interactive mode, not with claude -p.
|
|
36
|
+
|
|
37
|
+
This script loads sessions.py, so keep the two in one folder.
|
|
38
|
+
"""
|
|
39
|
+
import argparse
|
|
40
|
+
import importlib.util
|
|
41
|
+
import json
|
|
42
|
+
import os
|
|
43
|
+
import shlex
|
|
44
|
+
import shutil
|
|
45
|
+
import sys
|
|
46
|
+
|
|
47
|
+
HOOK = "bind --check" # in the hook's command, to find it again
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_sessions():
|
|
51
|
+
sys.dont_write_bytecode = True # leave no __pycache__ folder beside the scripts
|
|
52
|
+
path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "sessions.py")
|
|
53
|
+
spec = importlib.util.spec_from_file_location("hatcheck_sessions", path)
|
|
54
|
+
module = importlib.util.module_from_spec(spec)
|
|
55
|
+
spec.loader.exec_module(module)
|
|
56
|
+
return module
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def write_json(path, data):
|
|
60
|
+
"""Write JSON whole, under a temporary name until it's complete."""
|
|
61
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
62
|
+
tmp = path + ".hatcheck-tmp"
|
|
63
|
+
with open(tmp, "w", encoding="utf-8", newline="\n") as fh:
|
|
64
|
+
json.dump(data, fh, ensure_ascii=False, indent=2)
|
|
65
|
+
fh.write("\n")
|
|
66
|
+
os.replace(tmp, path)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def save_bindings(sessions, bindings):
|
|
70
|
+
write_json(sessions.bindings_path(), {"version": 1, "folders": dict(sorted(bindings.items()))})
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def name_of(sessions, agent, spec):
|
|
74
|
+
"""(name to keep, folder) for a PROFILE or --codex argument: default, a NAME or a path."""
|
|
75
|
+
if spec == "default" or ("/" not in spec and os.sep not in spec and not spec.startswith("~")):
|
|
76
|
+
return spec, sessions.profile_folder(agent, spec)
|
|
77
|
+
path = os.path.abspath(os.path.expanduser(spec))
|
|
78
|
+
return path, path
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def describe(sessions, binding):
|
|
82
|
+
show = sessions.shell_path
|
|
83
|
+
parts = ["%s %s" % (label, show(sessions.profile_folder(agent, binding[agent])))
|
|
84
|
+
for agent, label in (("claude", "Claude Code"), ("codex", "Codex")) if binding.get(agent)]
|
|
85
|
+
return " · ".join(parts) or "nothing"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def bind(args, sessions):
|
|
89
|
+
show = sessions.shell_path
|
|
90
|
+
folder = os.path.realpath(os.path.expanduser(args.folder or os.getcwd()))
|
|
91
|
+
if not os.path.isdir(folder):
|
|
92
|
+
sys.exit("%s isn't a folder" % show(folder))
|
|
93
|
+
bindings = sessions.load_bindings()
|
|
94
|
+
old = bindings.get(folder) or {}
|
|
95
|
+
binding = {} if args.profile else {k: v for k, v in old.items() if k == "claude"}
|
|
96
|
+
notes = []
|
|
97
|
+
if args.profile:
|
|
98
|
+
name, path = name_of(sessions, "claude", args.profile)
|
|
99
|
+
if not os.path.isdir(path):
|
|
100
|
+
sys.exit("%s isn't a profile folder. Sign in there first, e.g. with: %s"
|
|
101
|
+
% (show(path), "claude" if name == "default" else "cl " + name))
|
|
102
|
+
binding["claude"] = name
|
|
103
|
+
if args.codex:
|
|
104
|
+
name, path = name_of(sessions, "codex", args.codex)
|
|
105
|
+
if not os.path.isdir(path):
|
|
106
|
+
sys.exit("%s isn't a Codex home. Sign in there first, e.g. with: %s"
|
|
107
|
+
% (show(path), "codex login" if name == "default" else "cx %s login" % name))
|
|
108
|
+
binding["codex"] = name
|
|
109
|
+
elif args.profile and binding["claude"] == args.profile and not any(c in args.profile for c in "/" + os.sep):
|
|
110
|
+
path = sessions.profile_folder("codex", args.profile) # the home named like the profile
|
|
111
|
+
if os.path.isdir(path):
|
|
112
|
+
binding["codex"] = args.profile
|
|
113
|
+
else:
|
|
114
|
+
notes.append("Codex isn't bound: there's no %s (add --codex NAME for another home)." % show(path))
|
|
115
|
+
bindings[folder] = binding
|
|
116
|
+
save_bindings(sessions, bindings)
|
|
117
|
+
print("Bound %s and everything under it: %s." % (show(folder), describe(sessions, binding)))
|
|
118
|
+
for note in notes:
|
|
119
|
+
print(note)
|
|
120
|
+
print("Here, %s." % " and ".join(text for agent, text in (
|
|
121
|
+
("claude", "cl --resume <id> means %s --resume <id>" % sessions.start_command("claude", sessions.profile_folder(
|
|
122
|
+
"claude", binding.get("claude") or "default"))),
|
|
123
|
+
("codex", "cx resume <id> means %s resume <id>" % sessions.start_command("codex", sessions.profile_folder(
|
|
124
|
+
"codex", binding.get("codex") or "default")))) if binding.get(agent)))
|
|
125
|
+
if not any(has_hook(read_settings(path)[0]) for _, _, path in hook_files(sessions)):
|
|
126
|
+
print("hatcheck bind --hook adds a warning to Claude Code when a session starts here under another profile.")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def show_here(sessions):
|
|
130
|
+
show = sessions.shell_path
|
|
131
|
+
bindings = sessions.load_bindings()
|
|
132
|
+
folder = sessions.bound_folder(os.getcwd(), bindings)
|
|
133
|
+
if not folder:
|
|
134
|
+
print("This folder isn't bound. Bind it with: hatcheck bind <profile>")
|
|
135
|
+
return
|
|
136
|
+
where = "This folder" if sessions.same_folder(folder, os.getcwd()) else "This folder is under %s, which" % show(folder)
|
|
137
|
+
print("%s is bound to %s." % (where, describe(sessions, bindings[folder])))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def list_bindings(sessions):
|
|
141
|
+
show = sessions.shell_path
|
|
142
|
+
bindings = sessions.load_bindings()
|
|
143
|
+
if not bindings:
|
|
144
|
+
print("No folders are bound. Bind this one with: hatcheck bind <profile>")
|
|
145
|
+
return
|
|
146
|
+
print("Bound folders, from %s:" % show(sessions.bindings_path()))
|
|
147
|
+
width = max(len(show(f)) for f in bindings)
|
|
148
|
+
for folder in sorted(bindings, key=str.lower):
|
|
149
|
+
missing = "" if os.path.isdir(folder) else " (folder missing)"
|
|
150
|
+
print(" %s %s%s" % (show(folder).ljust(width), describe(sessions, bindings[folder]), missing))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def which(args, sessions):
|
|
154
|
+
"""For cl and cx: the name the folder (this one by default) is bound to, or exit 1."""
|
|
155
|
+
bindings = sessions.load_bindings()
|
|
156
|
+
folder = sessions.bound_folder(os.path.expanduser(args.profile or os.getcwd()), bindings)
|
|
157
|
+
name = bindings[folder].get(args.which) if folder else None
|
|
158
|
+
if not isinstance(name, str) or not name:
|
|
159
|
+
return 1
|
|
160
|
+
print(name)
|
|
161
|
+
return 0
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# The warning hook: in each Claude Code profile's settings.json, and each Codex home's hooks.json.
|
|
165
|
+
|
|
166
|
+
def hook_command():
|
|
167
|
+
"""How Claude Code and Codex run the hook: hatcheck from your PATH, or this Python and hatcheck.py."""
|
|
168
|
+
quote = (lambda s: '"%s"' % s) if os.name == "nt" else shlex.quote
|
|
169
|
+
exe = shutil.which("hatcheck")
|
|
170
|
+
if exe:
|
|
171
|
+
return "%s %s" % (quote(exe), HOOK)
|
|
172
|
+
script = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), "hatcheck.py")
|
|
173
|
+
return "%s %s %s" % (quote(sys.executable), quote(script), HOOK)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def hook_files(sessions):
|
|
177
|
+
"""(agent, profile or home, the file its hooks are in) for every Claude Code profile and Codex home."""
|
|
178
|
+
return [("claude", p, os.path.join(p, "settings.json")) for p in sessions.find_profiles()] + \
|
|
179
|
+
[("codex", h, os.path.join(h, "hooks.json")) for h in sessions.find_codex_homes()]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def read_settings(path):
|
|
183
|
+
"""(the JSON object in path, None), {} when there's no file, or (None, why not)."""
|
|
184
|
+
if not os.path.exists(path):
|
|
185
|
+
return {}, None
|
|
186
|
+
name = os.path.basename(path)
|
|
187
|
+
try:
|
|
188
|
+
with open(path, encoding="utf-8") as fh:
|
|
189
|
+
settings = json.load(fh)
|
|
190
|
+
except (OSError, ValueError) as e:
|
|
191
|
+
return None, "can't read its %s (%s)" % (name, e.strerror if isinstance(e, OSError) else "not valid JSON")
|
|
192
|
+
return (settings, None) if isinstance(settings, dict) else (None, "its %s isn't a JSON object" % name)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def ours(hook):
|
|
196
|
+
command = hook.get("command") if isinstance(hook, dict) else None
|
|
197
|
+
return isinstance(command, str) and HOOK in command and "hatcheck" in command
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def has_hook(settings):
|
|
201
|
+
entries = ((settings or {}).get("hooks") or {}).get("SessionStart") if isinstance(settings, dict) else None
|
|
202
|
+
return any(ours(h) for e in entries or [] if isinstance(e, dict) for h in e.get("hooks") or [])
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def add_hooks(sessions):
|
|
206
|
+
show, command, codex = sessions.shell_path, hook_command(), False
|
|
207
|
+
for agent, folder, path in hook_files(sessions):
|
|
208
|
+
settings, why = read_settings(path)
|
|
209
|
+
if settings is None:
|
|
210
|
+
print(" %s skipped: %s" % (show(folder), why))
|
|
211
|
+
continue
|
|
212
|
+
if has_hook(settings):
|
|
213
|
+
print(" %s already has it" % show(folder))
|
|
214
|
+
continue
|
|
215
|
+
hooks = settings.setdefault("hooks", {})
|
|
216
|
+
if not isinstance(hooks, dict) or not isinstance(hooks.setdefault("SessionStart", []), list):
|
|
217
|
+
print(" %s skipped: its hooks aren't in the usual form" % show(folder))
|
|
218
|
+
continue
|
|
219
|
+
hooks["SessionStart"].append({"hooks": [{"type": "command", "command": command + (" codex" * (agent == "codex"))}]})
|
|
220
|
+
write_json(path, settings)
|
|
221
|
+
print(" %s added, in %s" % (show(folder), os.path.basename(path)))
|
|
222
|
+
codex |= agent == "codex"
|
|
223
|
+
print("They run: %s (with codex after it for Codex)" % command)
|
|
224
|
+
print("Claude Code and Codex warn when a session starts in a bound folder under another profile. "
|
|
225
|
+
"hatcheck unbind --hook removes them.")
|
|
226
|
+
if codex:
|
|
227
|
+
print("Codex runs a new hook only once you trust it: in each Codex home, open /hooks in codex (or cx <name>) "
|
|
228
|
+
"and trust it.")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def remove_hooks(sessions):
|
|
232
|
+
show = sessions.shell_path
|
|
233
|
+
for agent, folder, path in hook_files(sessions):
|
|
234
|
+
settings, why = read_settings(path)
|
|
235
|
+
if not has_hook(settings):
|
|
236
|
+
continue
|
|
237
|
+
hooks = settings["hooks"]
|
|
238
|
+
kept = []
|
|
239
|
+
for entry in hooks["SessionStart"]:
|
|
240
|
+
if isinstance(entry, dict) and isinstance(entry.get("hooks"), list):
|
|
241
|
+
entry["hooks"] = [h for h in entry["hooks"] if not ours(h)]
|
|
242
|
+
if not entry["hooks"]:
|
|
243
|
+
continue
|
|
244
|
+
kept.append(entry)
|
|
245
|
+
hooks["SessionStart"] = kept
|
|
246
|
+
if not kept:
|
|
247
|
+
del hooks["SessionStart"]
|
|
248
|
+
if not hooks:
|
|
249
|
+
del settings["hooks"]
|
|
250
|
+
if agent == "codex" and not settings:
|
|
251
|
+
os.remove(path) # hooks.json held nothing else
|
|
252
|
+
else:
|
|
253
|
+
write_json(path, settings)
|
|
254
|
+
print(" %s removed" % show(folder))
|
|
255
|
+
print("No profile or home runs the hook now.")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def check(sessions, agent):
|
|
259
|
+
"""Run by the SessionStart hook in Claude Code, or in Codex (agent codex): when the
|
|
260
|
+
session's folder is bound to another profile or home, have it show a warning. Never
|
|
261
|
+
blocks, and stays quiet on any error."""
|
|
262
|
+
try:
|
|
263
|
+
data = json.loads(sys.stdin.read() or "{}")
|
|
264
|
+
except (ValueError, UnicodeError):
|
|
265
|
+
return 0
|
|
266
|
+
if not isinstance(data, dict):
|
|
267
|
+
return 0
|
|
268
|
+
show = sessions.shell_path
|
|
269
|
+
cwd = data.get("cwd") or os.getcwd()
|
|
270
|
+
variable, default = ("CODEX_HOME", ".codex") if agent == "codex" else ("CLAUDE_CONFIG_DIR", ".claude")
|
|
271
|
+
current = os.path.expanduser(os.environ.get(variable) or os.path.join(sessions.HOME, default))
|
|
272
|
+
bindings = sessions.load_bindings()
|
|
273
|
+
folder = sessions.bound_folder(cwd, bindings)
|
|
274
|
+
name = bindings[folder].get(agent) if folder else None
|
|
275
|
+
if not isinstance(name, str) or not name:
|
|
276
|
+
return 0
|
|
277
|
+
want = sessions.profile_folder(agent, name)
|
|
278
|
+
if sessions.same_folder(want, current):
|
|
279
|
+
return 0
|
|
280
|
+
start, sid = sessions.start_command(agent, want), data.get("session_id") or ""
|
|
281
|
+
if data.get("source") == "resume" and sid:
|
|
282
|
+
if agent == "codex":
|
|
283
|
+
to = name if any(c in name for c in "/" + os.sep) else "codex" if name == "default" else "codex:" + name
|
|
284
|
+
resume = sessions.codex_command(want, "resume " + sid, False)
|
|
285
|
+
else:
|
|
286
|
+
to, resume = name, sessions.claude_command(want, "--resume " + sid, False)
|
|
287
|
+
todo = "Quit, then carry this session there with: hatcheck move %s %s, and resume it with %s" % (
|
|
288
|
+
sid[:8], to, resume)
|
|
289
|
+
else:
|
|
290
|
+
todo = "Quit and run %s here instead" % start
|
|
291
|
+
message = "hatcheck: %s is bound to %s, but this session runs in %s. %s." % (
|
|
292
|
+
show(folder), show(want), show(current), todo)
|
|
293
|
+
print(json.dumps({"systemMessage": message}))
|
|
294
|
+
return 0
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def main(argv=None, prog=None):
|
|
298
|
+
parser = argparse.ArgumentParser(prog=prog, description="Tie a folder to a profile, so cl and cx pick it there and "
|
|
299
|
+
"the session list flags work done under another one.")
|
|
300
|
+
parser.add_argument("profile", nargs="?", help="NAME for ~/.claude-NAME, default for ~/.claude, or a path")
|
|
301
|
+
parser.add_argument("folder", nargs="?", help="the folder to bind (default: this one)")
|
|
302
|
+
parser.add_argument("--codex", metavar="HOME", help="the Codex home to bind: NAME for ~/.codex-NAME, default for "
|
|
303
|
+
"~/.codex, or a path (default: the one named like PROFILE)")
|
|
304
|
+
parser.add_argument("--list", action="store_true", help="list every bound folder")
|
|
305
|
+
parser.add_argument("--hook", action="store_true", help="warn in Claude Code and Codex when a session starts in a "
|
|
306
|
+
"bound folder under another profile")
|
|
307
|
+
parser.add_argument("--which", nargs="?", const="claude", choices=("claude", "codex"),
|
|
308
|
+
help=argparse.SUPPRESS) # for cl and cx: the name bound to FOLDER, given as PROFILE
|
|
309
|
+
parser.add_argument("--check", nargs="?", const="claude", choices=("claude", "codex"),
|
|
310
|
+
help=argparse.SUPPRESS) # run by the hook, in Claude Code or in Codex
|
|
311
|
+
args = parser.parse_args(argv)
|
|
312
|
+
sessions = load_sessions()
|
|
313
|
+
if args.check:
|
|
314
|
+
return check(sessions, args.check)
|
|
315
|
+
if args.which:
|
|
316
|
+
return which(args, sessions)
|
|
317
|
+
usage = sessions.load_usage_module()
|
|
318
|
+
if usage:
|
|
319
|
+
usage.setup_console() # UTF-8 output on Windows
|
|
320
|
+
if args.list:
|
|
321
|
+
return list_bindings(sessions)
|
|
322
|
+
if args.hook:
|
|
323
|
+
return add_hooks(sessions)
|
|
324
|
+
if args.profile or args.codex:
|
|
325
|
+
return bind(args, sessions)
|
|
326
|
+
return show_here(sessions)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def unbind_main(argv=None, prog=None):
|
|
330
|
+
parser = argparse.ArgumentParser(prog=prog, description="Remove a folder's binding, or the warning hook.")
|
|
331
|
+
parser.add_argument("folder", nargs="?", help="the bound folder (default: this one)")
|
|
332
|
+
parser.add_argument("--hook", action="store_true", help="remove the warning hook from every profile")
|
|
333
|
+
args = parser.parse_args(argv)
|
|
334
|
+
sessions = load_sessions()
|
|
335
|
+
show = sessions.shell_path
|
|
336
|
+
if args.hook:
|
|
337
|
+
return remove_hooks(sessions)
|
|
338
|
+
folder = os.path.realpath(os.path.expanduser(args.folder or os.getcwd()))
|
|
339
|
+
bindings = sessions.load_bindings()
|
|
340
|
+
key = next((f for f in bindings if sessions.same_folder(f, folder)), None)
|
|
341
|
+
if key:
|
|
342
|
+
binding = bindings.pop(key)
|
|
343
|
+
save_bindings(sessions, bindings)
|
|
344
|
+
print("Unbound %s (it was bound to %s)." % (show(folder), describe(sessions, binding)))
|
|
345
|
+
return 0
|
|
346
|
+
parent = sessions.bound_folder(folder, bindings)
|
|
347
|
+
if parent:
|
|
348
|
+
sys.exit("%s isn't bound itself; it's under %s, which is: hatcheck unbind %s"
|
|
349
|
+
% (show(folder), show(parent), show(parent)))
|
|
350
|
+
sys.exit("%s isn't bound" % show(folder))
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
if __name__ == "__main__":
|
|
354
|
+
sys.exit(main())
|
hatcheck/cli.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""hatcheck: Claude Code and Codex accounts on one computer, each kept apart. Lists
|
|
3
|
+
recent sessions with the command that resumes each one and what's left of each
|
|
4
|
+
plan, and carries sessions between accounts and agents.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
hatcheck [sessions] [HOURS] ... recent sessions in every profile and home, with
|
|
8
|
+
resume commands and plan usage (the default)
|
|
9
|
+
hatcheck usage ... plan usage left for every profile and home
|
|
10
|
+
hatcheck where SESSION where a session is stored, by its ID or the start of it
|
|
11
|
+
hatcheck move SESSION PROFILE ... copy a session to another profile or home, or convert
|
|
12
|
+
it between Claude Code and Codex
|
|
13
|
+
hatcheck open [SESSION] ... resume a session in a new terminal, in its folder
|
|
14
|
+
hatcheck show SESSION a session's conversation, read-only
|
|
15
|
+
hatcheck best [--for SESSION] which account to use now, or to carry a session to
|
|
16
|
+
hatcheck bind PROFILE ... tie a folder to a profile, for cl, cx and the list
|
|
17
|
+
hatcheck unbind [FOLDER] untie it again
|
|
18
|
+
hatcheck desktop ... Claude Desktop instances on macOS: list, open, make one
|
|
19
|
+
hatcheck provider ... the Z.ai and Kimi keys and models behind claude-z,
|
|
20
|
+
claude-k, cl -z and cl -k
|
|
21
|
+
|
|
22
|
+
hatcheck, usage, where, show, desktop and move take --json, for scripts, status bars
|
|
23
|
+
and the desktop app.
|
|
24
|
+
|
|
25
|
+
`hatcheck SUBCOMMAND --help` lists a subcommand's options, and hatcheck --version
|
|
26
|
+
says which version this is. hat is the short name: hat usage is hatcheck usage,
|
|
27
|
+
and so on. cl and cx run Claude Code and Codex with a profile.
|
|
28
|
+
|
|
29
|
+
The subcommands are the files in the hatcheck/ folder: this one, cli.py, runs them.
|
|
30
|
+
"""
|
|
31
|
+
import importlib.util
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import sys
|
|
35
|
+
|
|
36
|
+
HERE = os.path.dirname(os.path.realpath(__file__))
|
|
37
|
+
SUBCOMMANDS = ("sessions", "usage", "where", "move", "open", "show", "best", "bind", "unbind", "desktop",
|
|
38
|
+
"provider")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load(name):
|
|
42
|
+
"""A subcommand's module, from this folder."""
|
|
43
|
+
sys.dont_write_bytecode = True # leave no __pycache__ folder beside the scripts
|
|
44
|
+
spec = importlib.util.spec_from_file_location("hatcheck_" + name, os.path.join(HERE, name + ".py"))
|
|
45
|
+
module = importlib.util.module_from_spec(spec)
|
|
46
|
+
spec.loader.exec_module(module)
|
|
47
|
+
return module
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def version():
|
|
51
|
+
with open(os.path.join(HERE, "__init__.py"), encoding="utf-8") as fh:
|
|
52
|
+
return re.search(r'__version__ = "([^"]+)"', fh.read()).group(1)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main(argv=None):
|
|
56
|
+
argv = sys.argv[1:] if argv is None else list(argv)
|
|
57
|
+
# The name it was run by, for its messages: hat, the short name, or hatcheck. A link
|
|
58
|
+
# or command named hat says so in sys.argv[0]; install.ps1's hat.cmd passes --as hat.
|
|
59
|
+
name = os.path.splitext(os.path.basename(sys.argv[0]))[0]
|
|
60
|
+
if len(argv) >= 2 and argv[0] == "--as":
|
|
61
|
+
name, argv = argv[1], argv[2:]
|
|
62
|
+
me = "hat" if name == "hat" else "hatcheck"
|
|
63
|
+
if argv and argv[0] in SUBCOMMANDS:
|
|
64
|
+
command = argv.pop(0)
|
|
65
|
+
prog = me + " " + command
|
|
66
|
+
elif argv and argv[0] in ("-h", "--help", "help"):
|
|
67
|
+
if me == "hat":
|
|
68
|
+
print("hat is the short name for hatcheck.\n")
|
|
69
|
+
print(__doc__.strip())
|
|
70
|
+
return 0
|
|
71
|
+
elif argv and argv[0] in ("-V", "--version"):
|
|
72
|
+
print("%s %s" % (me, version()))
|
|
73
|
+
return 0
|
|
74
|
+
elif argv and not argv[0].startswith("-") and not re.fullmatch(r"\d+(\.\d+)?[hd]?", argv[0].lower()):
|
|
75
|
+
sys.stderr.write("%s: there's no %r subcommand; see %s --help\n" % (me, argv[0], me))
|
|
76
|
+
return 2
|
|
77
|
+
else: # options or HOURS: the session list
|
|
78
|
+
command, prog = "sessions", me
|
|
79
|
+
return load(command).main(argv, prog)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
sys.exit(main())
|