Files
agent-skills/skills/hourlog/scripts/me-api.py
T
naps62-yolo 2f092f9ee0
ci / nix (push) Successful in 8s
ci / lint (push) Successful in 10s
feat(ci): lint skills, refs and installer drift (#15)
2026-08-20 14:23:19 +01:00

142 lines
5.1 KiB
Python
Executable File

#!/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")
log_p = sub.add_parser("log")
g = log_p.add_mutually_exclusive_group(required=True)
g.add_argument("--project", help="project id")
g.add_argument("--category", help="investment category id")
log_p.add_argument("--dates", required=True, help="comma-separated YYYY-MM-DD")
log_p.add_argument("--hours", required=True, help="decimal hours per day")
log_p.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())