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 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())