#!/usr/bin/env python3 """Thin client for the company timesheet REST API. Auth is a personal access token in $HOURLOG_TOKEN (scopes: profile:read, schedule:read, and schedule:write only if you intend to submit). The base URL is $HOURLOG_API. Both live in ~/.env.claude — never pass a token on argv, it lands in shell history and in the process table. Commands: whoami GET /auth/me projects GET /my/projects categories GET /investment-categories schedule --start D --end D GET /my-schedule overdue GET /my-schedule/overdue confirm --entry ID --hours H PUT /day-entries/ID log --project ID --dates D,D --hours H POST /my/day-entries log --category ID --dates D,D --hours H POST /my/day-entries raw METHOD PATH [JSON] escape hatch Every write takes --dry-run, which prints the exact request and sends nothing. """ import argparse import json import os import sys import urllib.error import urllib.parse import urllib.request def request(method, path, body=None, dry=False): api = os.environ.get("HOURLOG_API") if not api: sys.exit("HOURLOG_API is not set — add the API base URL to " "~/.env.claude (it ends in /api)") url = api.rstrip("/") + path payload = json.dumps(body).encode() if body is not None else None if dry: print(f"DRY RUN {method} {url}") if body is not None: print(json.dumps(body, indent=1)) return None token = os.environ.get("HOURLOG_TOKEN") if not token: sys.exit("HOURLOG_TOKEN is not set — add it to ~/.env.claude " "(create one under profile > personal access tokens)") req = urllib.request.Request(url, data=payload, method=method, headers={ "Content-Type": "application/json", "Authorization": f"Bearer {token}", }) try: with urllib.request.urlopen(req, timeout=30) as r: raw = r.read() return json.loads(raw) if raw else None except urllib.error.HTTPError as e: detail = e.read().decode(errors="replace")[:400] if e.code == 401: sys.exit("401 — token missing, expired, or lacking the scope") if e.code == 423: sys.exit("423 — that period is locked; ops has to reopen it") sys.exit(f"{e.code} {method} {path}: {detail}") except urllib.error.URLError as e: sys.exit(f"cannot reach the API: {e.reason}") def show(obj): json.dump(obj, sys.stdout, indent=1) print() def main(): ap = argparse.ArgumentParser() sub = ap.add_subparsers(dest="cmd", required=True) sub.add_parser("whoami") sub.add_parser("projects") sub.add_parser("categories") sub.add_parser("overdue") s = sub.add_parser("schedule") s.add_argument("--start", required=True) s.add_argument("--end", required=True) c = sub.add_parser("confirm") c.add_argument("--entry", required=True, help="day entry id") c.add_argument("--hours", required=True, help="decimal hours, e.g. 4 or 7.5") c.add_argument("--dry-run", action="store_true") l = sub.add_parser("log") g = l.add_mutually_exclusive_group(required=True) g.add_argument("--project", help="project id") g.add_argument("--category", help="investment category id") l.add_argument("--dates", required=True, help="comma-separated YYYY-MM-DD") l.add_argument("--hours", required=True, help="decimal hours per day") l.add_argument("--dry-run", action="store_true") r = sub.add_parser("raw") r.add_argument("method") r.add_argument("path") r.add_argument("body", nargs="?") r.add_argument("--dry-run", action="store_true") a = ap.parse_args() if a.cmd == "whoami": show(request("GET", "/auth/me")) elif a.cmd == "projects": show(request("GET", "/my/projects")) elif a.cmd == "categories": show(request("GET", "/investment-categories")) elif a.cmd == "overdue": show(request("GET", "/my-schedule/overdue")) elif a.cmd == "schedule": q = urllib.parse.urlencode({"start_date": a.start, "end_date": a.end}) show(request("GET", f"/my-schedule?{q}")) elif a.cmd == "confirm": body = {"day_entry": {"actual_hours": a.hours}} out = request("PUT", f"/day-entries/{a.entry}", body, a.dry_run) if out is not None: show(out) elif a.cmd == "log": entry = {"dates": [d.strip() for d in a.dates.split(",") if d.strip()], "hours": a.hours} if a.project: entry["project_id"] = a.project else: entry["investment_category_id"] = a.category out = request("POST", "/my/day-entries", {"day_entry": entry}, a.dry_run) if out is not None: show(out) elif a.cmd == "raw": body = json.loads(a.body) if a.body else None out = request(a.method.upper(), a.path, body, a.dry_run) if out is not None: show(out) return 0 if __name__ == "__main__": sys.exit(main())