@4pm/cli 1.18.0 → 1.19.2

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.
@@ -1,193 +0,0 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- """
4
- Manage .claude/.autonomous.histories.json — the SINGLE file combining the autonomous workflow's state + history.
5
-
6
- Structure:
7
- {
8
- "cron_applied": "*/10 * * * *", # the cron schedule applied last time (to detect a schedule change)
9
- "consecutive_fails": 0, # number of CONSECUTIVE failed command runs (reset on success)
10
- "ticks": {"day": "YYYY-MM-DD", "count": N}, # count of ticks that actually invoked Claude today
11
- "records": [ {...}, ... ] # cron run history, NEWEST LAST, at most 50 records
12
- }
13
-
14
- Each record:
15
- {
16
- "ts": "YYYY-MM-DD HH:MM:SS",
17
- "status": "success" | "failure" | "skip",
18
- "rc": <int|null>, # the command's exit code (null if it didn't run)
19
- "gate": "<token-gate string>",
20
- "note": "<short note>",
21
- "command": "/auto-cycle",
22
- "tick": "YYYY-MM-DD #N", # day + tick number within the day
23
- "config": { cron_schedule, model, max_session_pct, max_weekly_pct, quiet_hours,
24
- max_ticks_per_day, stop_on_consecutive_failures, paused }
25
- }
26
-
27
- Usage (the file path is the first arg):
28
- history.py <file> migrate <cron-applied> <fails> <ticks> # one-time load from 3 old files (paths)
29
- history.py <file> get-cron
30
- history.py <file> set-cron <schedule>
31
- history.py <file> get-fails
32
- history.py <file> set-fails <n>
33
- history.py <file> get-ticks <today> # print the count for 'today' (0 if a different day/missing)
34
- history.py <file> set-ticks <day> <count>
35
- history.py <file> record <ts> <status> <rc> <gate> <note> # config is read from the CFG_* env vars
36
-
37
- Philosophy: NEVER break on a missing/corrupt file — always initialize the default skeleton.
38
- """
39
- import json
40
- import os
41
- import sys
42
-
43
- MAX_RECORDS = 50
44
-
45
- SKELETON = {
46
- "cron_applied": "",
47
- "consecutive_fails": 0,
48
- "ticks": {"day": "", "count": 0},
49
- "records": [],
50
- }
51
-
52
-
53
- def load(path):
54
- try:
55
- with open(path, encoding="utf-8") as f:
56
- d = json.load(f)
57
- if not isinstance(d, dict):
58
- raise ValueError("not an object")
59
- except Exception:
60
- d = {}
61
- out = dict(SKELETON)
62
- out.update({k: d[k] for k in SKELETON if k in d})
63
- if not isinstance(out.get("ticks"), dict):
64
- out["ticks"] = dict(SKELETON["ticks"])
65
- if not isinstance(out.get("records"), list):
66
- out["records"] = []
67
- return out
68
-
69
-
70
- def save(path, d):
71
- tmp = path + ".tmp"
72
- with open(tmp, "w", encoding="utf-8") as f:
73
- json.dump(d, f, ensure_ascii=False, indent=2)
74
- f.write("\n")
75
- os.replace(tmp, path)
76
-
77
-
78
- def cfg_from_env():
79
- def g(k, default=""):
80
- return os.environ.get(k, default)
81
- return {
82
- "cron_schedule": g("CFG_CRON_SCHEDULE"),
83
- "model": g("CFG_MODEL"),
84
- "max_session_pct": g("CFG_MAX_SESSION_PCT"),
85
- "max_weekly_pct": g("CFG_MAX_WEEKLY_PCT"),
86
- "quiet_hours": g("CFG_QUIET_HOURS"),
87
- "max_ticks_per_day": g("CFG_MAX_TICKS_PER_DAY"),
88
- "stop_on_consecutive_failures": g("CFG_STOP_ON_CONSEC_FAILURES"),
89
- "paused": g("CFG_PAUSED"),
90
- }
91
-
92
-
93
- def main():
94
- if len(sys.argv) < 3:
95
- sys.stderr.write("missing args: <file> <op> [...]\n")
96
- return 2
97
- path = sys.argv[1]
98
- op = sys.argv[2]
99
- args = sys.argv[3:]
100
- d = load(path)
101
-
102
- if op == "get-cron":
103
- sys.stdout.write(str(d.get("cron_applied", "")))
104
- return 0
105
-
106
- if op == "set-cron":
107
- d["cron_applied"] = args[0] if args else ""
108
- save(path, d)
109
- return 0
110
-
111
- if op == "get-fails":
112
- sys.stdout.write(str(int(d.get("consecutive_fails", 0) or 0)))
113
- return 0
114
-
115
- if op == "set-fails":
116
- d["consecutive_fails"] = int(args[0]) if args else 0
117
- save(path, d)
118
- return 0
119
-
120
- if op == "get-ticks":
121
- today = args[0] if args else ""
122
- t = d.get("ticks", {})
123
- sys.stdout.write(str(int(t.get("count", 0) or 0) if t.get("day") == today else 0))
124
- return 0
125
-
126
- if op == "set-ticks":
127
- day = args[0] if len(args) > 0 else ""
128
- count = int(args[1]) if len(args) > 1 else 0
129
- d["ticks"] = {"day": day, "count": count}
130
- save(path, d)
131
- return 0
132
-
133
- if op == "record":
134
- ts = args[0] if len(args) > 0 else ""
135
- status = args[1] if len(args) > 1 else ""
136
- rc_raw = args[2] if len(args) > 2 else ""
137
- gate = args[3] if len(args) > 3 else ""
138
- note = args[4] if len(args) > 4 else ""
139
- try:
140
- rc = int(rc_raw)
141
- except (ValueError, TypeError):
142
- rc = None
143
- t = d.get("ticks", {})
144
- rec = {
145
- "ts": ts,
146
- "status": status,
147
- "rc": rc,
148
- "gate": gate,
149
- "note": note,
150
- "command": os.environ.get("CFG_COMMAND", ""),
151
- "tick": "%s #%s" % (t.get("day", ""), t.get("count", 0)),
152
- "config": cfg_from_env(),
153
- }
154
- d["records"].append(rec)
155
- if len(d["records"]) > MAX_RECORDS:
156
- d["records"] = d["records"][-MAX_RECORDS:]
157
- save(path, d)
158
- return 0
159
-
160
- if op == "migrate":
161
- # Only load when histories.json has no real data yet (don't overwrite live data).
162
- cron_p = args[0] if len(args) > 0 else ""
163
- fails_p = args[1] if len(args) > 1 else ""
164
- ticks_p = args[2] if len(args) > 2 else ""
165
-
166
- def read_txt(p):
167
- try:
168
- with open(p, encoding="utf-8") as f:
169
- return f.read().strip()
170
- except Exception:
171
- return ""
172
-
173
- if cron_p and not d.get("cron_applied"):
174
- v = read_txt(cron_p)
175
- if v:
176
- d["cron_applied"] = v
177
- if fails_p and not d.get("consecutive_fails"):
178
- v = read_txt(fails_p)
179
- if v.isdigit():
180
- d["consecutive_fails"] = int(v)
181
- if ticks_p and not d.get("ticks", {}).get("day"):
182
- parts = read_txt(ticks_p).split()
183
- if len(parts) == 2 and parts[1].isdigit():
184
- d["ticks"] = {"day": parts[0], "count": int(parts[1])}
185
- save(path, d)
186
- return 0
187
-
188
- sys.stderr.write("invalid op: %s\n" % op)
189
- return 2
190
-
191
-
192
- if __name__ == "__main__":
193
- sys.exit(main())
@@ -1,40 +0,0 @@
1
- ---
2
- name: check-usage
3
- description: Show Claude Code plan usage limits — current session % and reset time, weekly % and reset time (same data as the built-in /usage screen). Use when the user asks "how much usage left", "khi nao reset", "check usage/quota/limit", or invokes /check-usage.
4
- ---
5
-
6
- # check-usage — Claude Code plan usage limits
7
-
8
- Fetches the same data the built-in `/usage` screen shows (session + weekly utilization and
9
- reset times), by calling the Anthropic OAuth usage endpoint with the local credentials.
10
-
11
- ## How to run
12
-
13
- Run the bundled script with the Bash tool. It prints a compact bullet list to the console;
14
- relay that output to the user.
15
-
16
- ```bash
17
- python .claude/skills/check-usage/check_usage.py
18
- ```
19
-
20
- On Windows if `python` is missing, try `py .claude/skills/check-usage/check_usage.py`.
21
-
22
- Add `--json` to dump the raw API response instead of the formatted view:
23
-
24
- ```bash
25
- python .claude/skills/check-usage/check_usage.py --json
26
- ```
27
-
28
- ## Output file
29
- Every run (the formatted view, not `--json`) rewrites `./output/last-usage-check.json` next to
30
- the script: it deletes the previous file then creates a fresh one with this run's result. The
31
- record has machine-readable fields the autonomous workflow reads back:
32
- `session_pct`, `weekly_pct`, `session_resets_at`, `weekly_resets_at`, `checked_at`, `plan`,
33
- plus the human `summary` and the full `raw` API payload.
34
-
35
- ## Notes
36
- - Reads the OAuth access token from `~/.claude/.credentials.json` (Claude Code keeps it refreshed).
37
- - If the call returns HTTP 401, the token expired — tell the user to run the real `/usage` once
38
- inside Claude Code (or re-login) to refresh credentials, then retry.
39
- - Reset times are printed in the machine's local timezone with a "con Xh Ym" countdown.
40
- - Do not print the access token.
@@ -1,161 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Fetch Claude Code plan-usage limits (same data as the built-in /usage screen).
3
-
4
- Reads the OAuth access token from ~/.claude/.credentials.json and queries the
5
- Anthropic usage endpoint, then prints session + weekly utilization and reset times
6
- in the local timezone.
7
- """
8
- import json
9
- import os
10
- import subprocess
11
- import sys
12
- import urllib.request
13
- import urllib.error
14
- from datetime import datetime, timezone
15
-
16
- # Windows consoles default to cp1252 which can't encode the emoji/bar glyphs.
17
- for _s in (sys.stdout, sys.stderr):
18
- try:
19
- _s.reconfigure(encoding="utf-8")
20
- except Exception:
21
- pass
22
-
23
- CRED_PATH = os.path.expanduser("~/.claude/.credentials.json")
24
- URL = "https://api.anthropic.com/api/oauth/usage"
25
-
26
-
27
- class UsageError(Exception):
28
- """Recoverable failure while loading the token or calling the usage API.
29
- Raised (instead of exiting) so the caller can refresh the token and retry once."""
30
-
31
-
32
- def load_token():
33
- try:
34
- with open(CRED_PATH, "r", encoding="utf-8") as f:
35
- data = json.load(f)
36
- except FileNotFoundError:
37
- raise UsageError(f"Credentials not found: {CRED_PATH} (log in to Claude Code first).")
38
- oauth = data.get("claudeAiOauth", data)
39
- token = oauth.get("accessToken")
40
- if not token:
41
- raise UsageError("No accessToken in credentials.")
42
- exp = oauth.get("expiresAt")
43
- if exp and datetime.now(timezone.utc).timestamp() * 1000 > exp:
44
- print("Warning: token may be expired — run /usage once in Claude Code to refresh.\n",
45
- file=sys.stderr)
46
- return token, oauth.get("subscriptionType", "?")
47
-
48
-
49
- def fetch(token):
50
- req = urllib.request.Request(URL, headers={
51
- "Authorization": f"Bearer {token}",
52
- "anthropic-beta": "oauth-2025-04-20",
53
- "User-Agent": "claude-cli/2.0.0 (external, cli)",
54
- })
55
- try:
56
- with urllib.request.urlopen(req, timeout=20) as r:
57
- return json.load(r)
58
- except urllib.error.HTTPError as e:
59
- body = e.read().decode("utf-8", "replace")[:300]
60
- raise UsageError(f"API error HTTP {e.code}: {body}")
61
- except urllib.error.URLError as e:
62
- raise UsageError(f"Could not reach API: {e.reason}")
63
-
64
-
65
- def get_usage():
66
- """Load the token and query the usage API. Raises UsageError on any failure."""
67
- token, sub = load_token()
68
- return fetch(token), sub
69
-
70
-
71
- def refresh_token():
72
- """Best-effort token refresh: run `claude /usage`, which refreshes the OAuth
73
- token on startup. On Windows we go through `wsl` (the credentials this script
74
- reads live in WSL); on POSIX we call `claude` directly. Output is discarded and
75
- the call is bounded by a timeout so it can never hang the usage check."""
76
- cmd = ["wsl", "claude", "/usage"] if sys.platform == "win32" else ["claude", "/usage"]
77
- print(f"Refreshing token via: {' '.join(cmd)}", file=sys.stderr)
78
- try:
79
- subprocess.run(
80
- cmd, timeout=60,
81
- stdin=subprocess.DEVNULL,
82
- stdout=subprocess.DEVNULL,
83
- stderr=subprocess.DEVNULL,
84
- )
85
- except Exception as e:
86
- print(f"Token refresh attempt failed: {e}", file=sys.stderr)
87
-
88
-
89
- def fmt_reset(iso):
90
- if not iso:
91
- return "?"
92
- dt = datetime.fromisoformat(iso).astimezone() # -> local tz
93
- return f"{dt:%a %d/%m %H:%M}"
94
-
95
-
96
- def save_last_result(record):
97
- """Delete the previous ./output/last-usage-check.json, then write a fresh one
98
- holding this run's result. Console output is still printed by the caller."""
99
- out_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output")
100
- os.makedirs(out_dir, exist_ok=True)
101
- out_path = os.path.join(out_dir, "last-usage-check.json")
102
- # Remove the stale file first, then recreate it from scratch.
103
- try:
104
- os.remove(out_path)
105
- except FileNotFoundError:
106
- pass
107
- with open(out_path, "w", encoding="utf-8") as f:
108
- json.dump(record, f, ensure_ascii=False, indent=2)
109
- return out_path
110
-
111
-
112
- def main():
113
- try:
114
- data, sub = get_usage()
115
- except UsageError as e:
116
- # First attempt failed — try to refresh the token once, then retry once.
117
- print(f"Usage check failed: {e}", file=sys.stderr)
118
- refresh_token()
119
- try:
120
- data, sub = get_usage()
121
- except UsageError as e2:
122
- print(f"Still failing after token refresh — skipping: {e2}", file=sys.stderr)
123
- sys.exit(1)
124
-
125
- if "--json" in sys.argv:
126
- print(json.dumps(data, indent=2))
127
- return
128
-
129
- fh = data.get("five_hour") or {}
130
- sd = data.get("seven_day") or {}
131
-
132
- lines = [
133
- f"- Plan: {sub}",
134
- f"- Session: {fh.get('utilization', 0):.0f}% — reset {fmt_reset(fh.get('resets_at'))}",
135
- f"- Weekly: {sd.get('utilization', 0):.0f}% — reset {fmt_reset(sd.get('resets_at'))}",
136
- ]
137
- extra = data.get("extra_usage") or {}
138
- if extra.get("is_enabled") and extra.get("used_credits"):
139
- dp = extra.get("decimal_places", 2)
140
- lines.append(f"- Extra: {extra['used_credits'] / (10 ** dp):.2f} {extra.get('currency', 'USD')}")
141
-
142
- summary = "\n".join(lines)
143
- print(summary) # keep the console output
144
-
145
- # Persist this run's result so the autonomous workflow can read it back.
146
- record = {
147
- "checked_at": datetime.now().astimezone().isoformat(timespec="seconds"),
148
- "plan": sub,
149
- "session_pct": fh.get("utilization", 0),
150
- "weekly_pct": sd.get("utilization", 0),
151
- "session_resets_at": fh.get("resets_at"),
152
- "weekly_resets_at": sd.get("resets_at"),
153
- "summary": summary,
154
- "raw": data,
155
- }
156
- out_path = save_last_result(record)
157
- print(f"\nSaved result to: {out_path}")
158
-
159
-
160
- if __name__ == "__main__":
161
- main()