#!/usr/bin/env python3 """ op — command-line interface to an OpenProject instance. A thin, friendly CLI over the OpenProject API v3, designed for Claude (and you) to read and manage projects, work packages, time entries, and more. Run `op.py --help` or `op.py --help` for usage. Output: human-readable tables by default; add `--json` to any command for the raw HAL+JSON (useful for chaining / precise field access). Examples: op.py ping op.py projects --search infra op.py wp list --project 3 --assignee me --status open op.py wp show 1234 op.py wp create --project 3 --type Task --subject "Fix the thing" \ --description "details..." --assignee me --priority High op.py wp update 1234 --status "In progress" --done-ratio 50 op.py wp comment 1234 --text "Working on it" op.py wp close 1234 op.py wp delete 1234 --yes op.py time log --wp 1234 --hours 1.5 --comment "investigation" op.py raw GET /api/v3/statuses """ from __future__ import annotations import argparse import datetime import json import sys from opclient import OpenProjectClient, OpenProjectError def _today(): return datetime.date.today().isoformat() # --------------------------------------------------------------------------- # # Output helpers # --------------------------------------------------------------------------- # def emit_json(obj): print(json.dumps(obj, indent=2, ensure_ascii=False)) def table(rows, headers): """Render a simple aligned text table.""" if not rows: print("(no results)") return cols = list(zip(*([headers] + rows))) widths = [max(len(str(c)) for c in col) for col in cols] line = " ".join(str(h).ljust(w) for h, w in zip(headers, widths)) print(line) print(" ".join("-" * w for w in widths)) for row in rows: print(" ".join(str(c).ljust(w) for c, w in zip(row, widths))) def wp_row(wp, client): return [ wp.get("id"), (wp.get("subject") or "")[:50], client.link_title(wp, "type") or "", client.link_title(wp, "status") or "", client.link_title(wp, "assignee") or "—", client.link_title(wp, "project") or "", ] # --------------------------------------------------------------------------- # # Filter building # --------------------------------------------------------------------------- # def build_wp_filters(client, args): filters = [] if getattr(args, "project", None): proj = client.find_project(args.project) filters.append({"project": {"operator": "=", "values": [str(proj["id"])]}}) if getattr(args, "assignee", None): if args.assignee.lower() == "me": filters.append({"assignee": {"operator": "=", "values": ["me"]}}) else: user = client.find_user(args.assignee) filters.append({"assignee": {"operator": "=", "values": [str(user["id"])]}}) if getattr(args, "status", None): s = args.status.lower() if s == "open": filters.append({"status": {"operator": "o", "values": None}}) elif s in ("closed", "close"): filters.append({"status": {"operator": "c", "values": None}}) else: st = client.find_status(args.status) filters.append({"status": {"operator": "=", "values": [str(st["id"])]}}) if getattr(args, "type", None): t = client.find_type(args.type) filters.append({"type": {"operator": "=", "values": [str(t["id"])]}}) if getattr(args, "query", None): filters.append({"search": {"operator": "**", "values": [args.query]}}) return filters # --------------------------------------------------------------------------- # # Commands # --------------------------------------------------------------------------- # def cmd_ping(client, args): me = client.me() name = me.get("name") or me.get("login") print(f"OK — authenticated as {name} (id {me.get('id')}) at {client.base_url}") def cmd_me(client, args): me = client.me() if args.json: emit_json(me) return for k in ("id", "name", "login", "email", "admin", "status"): if k in me: print(f"{k:>8}: {me[k]}") def cmd_projects(client, args): filters = None if args.search: filters = [{"name_and_identifier": {"operator": "~", "values": [args.search]}}] items = client.get_all("/api/v3/projects", params={"filters": filters} if filters else None, max_items=args.limit) if args.json: emit_json(items) return rows = [[p.get("id"), p.get("identifier"), (p.get("name") or "")[:50], "archived" if not p.get("active", True) else "active"] for p in items] table(rows, ["ID", "IDENTIFIER", "NAME", "STATE"]) print(f"\n{len(items)} project(s)") def cmd_wp_list(client, args): filters = build_wp_filters(client, args) params = {"pageSize": args.limit} if filters: params["filters"] = filters if args.sort: params["sortBy"] = [args.sort.split(":")] if ":" in args.sort else [[args.sort, "asc"]] items = client.get_all("/api/v3/work_packages", params=params, max_items=args.limit) if args.json: emit_json(items) return rows = [wp_row(wp, client) for wp in items] table(rows, ["ID", "SUBJECT", "TYPE", "STATUS", "ASSIGNEE", "PROJECT"]) print(f"\n{len(items)} work package(s)") def cmd_wp_show(client, args): wp = client.get(f"/api/v3/work_packages/{args.id}") if args.json: emit_json(wp) return L = client.link_title print(f"#{wp.get('id')} {wp.get('subject')}") print(f" project : {L(wp,'project')}") print(f" type : {L(wp,'type')}") print(f" status : {L(wp,'status')} ({wp.get('percentageDone', 0)}% done)") print(f" priority: {L(wp,'priority')}") print(f" assignee: {L(wp,'assignee') or '—'}") print(f" author : {L(wp,'author')}") if wp.get("startDate") or wp.get("dueDate"): print(f" dates : {wp.get('startDate') or '?'} → {wp.get('dueDate') or '?'}") print(f" version : lockVersion={wp.get('lockVersion')}") desc = (wp.get("description") or {}).get("raw") or "" if desc.strip(): print(" description:") for line in desc.splitlines(): print(f" {line}") def _wp_link_body(client, args): """Build the _links + scalar body for create/update from common args.""" body = {} links = {} if getattr(args, "subject", None) is not None: body["subject"] = args.subject if getattr(args, "description", None) is not None: body["description"] = {"format": "markdown", "raw": args.description} if getattr(args, "start", None) is not None: body["startDate"] = args.start if getattr(args, "due", None) is not None: body["dueDate"] = args.due if getattr(args, "done_ratio", None) is not None: body["percentageDone"] = int(args.done_ratio) if getattr(args, "project", None): links["project"] = {"href": f"/api/v3/projects/{client.find_project(args.project)['id']}"} if getattr(args, "type", None): links["type"] = {"href": f"/api/v3/types/{client.find_type(args.type)['id']}"} if getattr(args, "status", None): links["status"] = {"href": f"/api/v3/statuses/{client.find_status(args.status)['id']}"} if getattr(args, "priority", None): links["priority"] = {"href": f"/api/v3/priorities/{client.find_priority(args.priority)['id']}"} if getattr(args, "assignee", None): if args.assignee.lower() in ("none", "unassign"): links["assignee"] = {"href": None} else: links["assignee"] = {"href": f"/api/v3/users/{client.find_user(args.assignee)['id']}"} if getattr(args, "parent", None): links["parent"] = {"href": f"/api/v3/work_packages/{args.parent}"} if links: body["_links"] = links return body def cmd_wp_create(client, args): body = _wp_link_body(client, args) if "subject" not in body: raise OpenProjectError("--subject is required to create a work package.") if "_links" not in body or "project" not in body["_links"]: raise OpenProjectError("--project is required to create a work package.") if "type" not in body.get("_links", {}): # Default to the project's first available type. proj_id = client.id_from_href(body["_links"]["project"]["href"]) types = client.get_all(f"/api/v3/projects/{proj_id}/types", max_items=1) if types: body["_links"]["type"] = {"href": types[0]["_links"]["self"]["href"]} created = client.create_work_package(body) if args.json: emit_json(created) return print(f"Created #{created.get('id')}: {created.get('subject')}") print(f" {client.base_url}/work_packages/{created.get('id')}") def cmd_wp_update(client, args): current = client.get(f"/api/v3/work_packages/{args.id}") body = _wp_link_body(client, args) if not body: raise OpenProjectError("Nothing to update — pass at least one field.") body["lockVersion"] = current.get("lockVersion") updated = client.patch(f"/api/v3/work_packages/{args.id}", body=body) if args.json: emit_json(updated) return print(f"Updated #{updated.get('id')}: {updated.get('subject')} " f"[{client.link_title(updated,'status')}]") def cmd_wp_comment(client, args): body = {"comment": {"format": "markdown", "raw": args.text}} res = client.post(f"/api/v3/work_packages/{args.id}/activities", body=body) if args.json: emit_json(res) return print(f"Comment added to #{args.id}.") def cmd_wp_close(client, args): current = client.get(f"/api/v3/work_packages/{args.id}") status = client.find_status(args.status_name) body = { "lockVersion": current.get("lockVersion"), "_links": {"status": {"href": f"/api/v3/statuses/{status['id']}"}}, } updated = client.patch(f"/api/v3/work_packages/{args.id}", body=body) print(f"#{args.id} → {client.link_title(updated,'status')}") def cmd_wp_delete(client, args): if not args.yes: raise OpenProjectError( f"Refusing to delete #{args.id} without --yes. " f"This permanently removes the work package." ) client.delete(f"/api/v3/work_packages/{args.id}") print(f"Deleted work package #{args.id}.") def cmd_time_log(client, args): wp = client.get(f"/api/v3/work_packages/{args.wp}") proj_href = wp["_links"]["project"]["href"] body = { "hours": f"PT{args.hours}H" if not str(args.hours).startswith("PT") else args.hours, "_links": { "workPackage": {"href": f"/api/v3/work_packages/{args.wp}"}, "project": {"href": proj_href}, }, } if args.comment: body["comment"] = {"format": "markdown", "raw": args.comment} # spentOn is required by the API; default to today when not given. body["spentOn"] = args.date or _today() if args.activity: act = client._resolve_by_name("/api/v3/time_entries/activities", args.activity) body["_links"]["activity"] = {"href": act["_links"]["self"]["href"]} res = client.post("/api/v3/time_entries", body=body) if args.json: emit_json(res) return print(f"Logged {args.hours}h on #{args.wp}.") def cmd_users(client, args): filters = None if args.search: filters = [{"name": {"operator": "~", "values": [args.search]}}] items = client.get_all("/api/v3/users", params={"filters": filters} if filters else None, max_items=args.limit) if args.json: emit_json(items) return rows = [[u.get("id"), u.get("login"), u.get("name"), u.get("email", "")] for u in items] table(rows, ["ID", "LOGIN", "NAME", "EMAIL"]) def _simple_list(client, path, args, name_field="name"): items = client.get_all(path, max_items=200) if args.json: emit_json(items) return rows = [[i.get("id"), i.get(name_field)] for i in items] table(rows, ["ID", "NAME"]) def cmd_types(client, args): if args.project: proj = client.find_project(args.project) _simple_list(client, f"/api/v3/projects/{proj['id']}/types", args) else: _simple_list(client, "/api/v3/types", args) def cmd_statuses(client, args): _simple_list(client, "/api/v3/statuses", args) def cmd_priorities(client, args): _simple_list(client, "/api/v3/priorities", args) def cmd_roles(client, args): _simple_list(client, "/api/v3/roles", args) def cmd_project_create(client, args): created = client.create_project( args.name, identifier=args.identifier, description=args.description, parent=args.parent) if args.json: emit_json(created) return print(f"Created project '{created.get('name')}' " f"(id {created.get('id')}, identifier {created.get('identifier')})") print(f" {client.base_url}/projects/{created.get('identifier')}") def cmd_members_add(client, args): proj = client.find_project(args.project) user = client.find_user(args.user) client.add_membership(proj["id"], user["id"], args.role) print(f"Added {user.get('name')} to '{proj.get('name')}' as {args.role}.") def cmd_wp_relate(client, args): res = client.create_relation(args.id, args.to, rel_type=args.type, description=args.description) print(f"#{args.id} {args.type} #{args.to} (relation {res.get('id')})") # --------------------------------------------------------------------------- # # Import: spreadsheet -> project + work packages # --------------------------------------------------------------------------- # # Default value mappings (tuned for the APTA planning register; override-able # would be a future flag, but these are sensible defaults for H/M/L + statuses). PRIORITY_MAP = {"H": "High", "M": "Normal", "L": "Low", "HIGH": "High", "MEDIUM": "Normal", "LOW": "Low"} STATUS_MAP = { "OPEN": "New", "NEW": "New", "IN PROGRESS": "In progress", "INPROGRESS": "In progress", "COMPLETE": "Closed", "COMPLETED": "Closed", "DONE": "Closed", "CLOSED": "Closed", } DEFAULT_PRIORITY = "Normal" DEFAULT_STATUS = "New" # Column header -> internal field. Header lookup is case-insensitive and matches # on a prefix so "Priority (H/M/L)" maps to priority. COLUMN_ALIASES = { "task": "subject", "title": "subject", "name": "subject", "category": "category", "group": "category", "owner": "owner", "assignee": "owner", "assigned": "owner", "priority": "priority", "status": "status", "due": "due", "due date": "due", "notes": "notes", "notes / dependencies": "notes", "dependencies": "notes", "#": "ref", "id": "ref", } def _map_columns(headers): """Build {internal_field: header_string} from the sheet headers.""" mapping = {} for h in headers: if not h: continue key = h.strip().lower() field = COLUMN_ALIASES.get(key) if not field: for alias, fld in COLUMN_ALIASES.items(): if key.startswith(alias): field = fld break if field and field not in mapping: mapping[field] = h return mapping def _build_description(rec, cols, owner_noted): notes = (rec.get(cols.get("notes", "")) or "").strip() meta = [] if cols.get("ref") and rec.get(cols["ref"]): ref = str(rec[cols["ref"]]).rstrip("0").rstrip(".") meta.append(f"- Source row #: {ref}") if cols.get("category") and rec.get(cols["category"]): meta.append(f"- Category: {rec[cols['category']].strip()}") if owner_noted: meta.append(f"- Owner: {owner_noted}") if cols.get("due") and (rec.get(cols["due"]) or "").strip(): meta.append(f"- Due (from sheet): {rec[cols['due']].strip()}") parts = [] if notes: parts.append(notes) if meta: parts.append("---\n" + "\n".join(meta)) return "\n\n".join(parts) if parts else None def cmd_import_xlsx(client, args): import xlsx_read # local import so the CLI still loads without the file present headers, records = xlsx_read.read_table(args.path, args.sheet) cols = _map_columns(headers) if "subject" not in cols: raise OpenProjectError( f"Could not find a task/title column in sheet '{args.sheet}'. " f"Headers seen: {[h for h in headers if h]}") # Keep only rows with a real subject. rows = [r for r in records if (r.get(cols["subject"]) or "").strip()] if args.limit: rows = rows[: args.limit] # Category normalization (merge near-duplicates), e.g. # --merge-category "Display & Technology=Display & Tech" merge_map = {} for pair in getattr(args, "merge_category", None) or []: if "=" in pair: old, new = pair.split("=", 1) merge_map[old.strip().lower()] = new.strip() cat_col = cols.get("category") def norm_cat(r): c = (r.get(cat_col) or "Uncategorized").strip() if cat_col else "Tasks" return merge_map.get(c.lower(), c) # Preserve first-seen category order. categories = [] for r in rows: c = norm_cat(r) if c not in categories: categories.append(c) # Resolve owners once (match existing users, else note in description). owner_col = cols.get("owner") owner_cache = {} if owner_col: for r in rows: o = (r.get(owner_col) or "").strip() if not o or o.upper() == "TBD" or o in owner_cache: continue try: owner_cache[o] = client.find_user(o) except OpenProjectError: owner_cache[o] = None def map_priority(r): raw = (r.get(cols["priority"]) or "").strip().upper() if "priority" in cols else "" return PRIORITY_MAP.get(raw, DEFAULT_PRIORITY) def map_status(r): raw = (r.get(cols["status"]) or "").strip().upper() if "status" in cols else "" return STATUS_MAP.get(raw, DEFAULT_STATUS) # ----- Dry run: report the plan, write nothing ----- # if not args.execute: print(f"DRY RUN — would import {len(rows)} task(s) from sheet " f"'{args.sheet}' into a new project '{args.project_name}'.\n") print(f"Detected columns: {cols}\n") print(f"Hierarchy: {len(categories)} parent Summary task(s), each with its rows as children:") for c in categories: n = sum(1 for r in rows if norm_cat(r) == c) print(f" • {c} ({n})") if owner_col: matched = {o: u for o, u in owner_cache.items() if u} unmatched = sorted(o for o, u in owner_cache.items() if not u) print(f"\nOwners: {len(matched)} matched to users " f"{[u.get('name') for u in matched.values()]}; " f"{len(unmatched)} recorded in description: {unmatched}") print("\nSample of first 5 mapped tasks:") for r in rows[:5]: print(f" [{map_priority(r)}/{map_status(r)}] " f"{(r.get(cols['subject']) or '').strip()[:60]}") print("\nNothing was written. Re-run with --execute to create everything.") return # ----- Execute: create project -> parents -> children ----- # print(f"Creating project '{args.project_name}'...") project = client.create_project(args.project_name, identifier=args.identifier, description=args.project_description) pid = project["id"] proj_href = f"/api/v3/projects/{pid}" summary_type = client.find_type("Summary task") task_type = client.find_type(args.task_type) print(f" project id {pid} — {client.base_url}/projects/{project['identifier']}") # Parent per category. parent_id = {} for c in categories: body = {"subject": c, "_links": { "project": {"href": proj_href}, "type": {"href": f"/api/v3/types/{summary_type['id']}"}}} wp = client.create_work_package(body) parent_id[c] = wp["id"] print(f" parent: {c} -> #{wp['id']}") created, errors = 0, [] for r in rows: subject = (r.get(cols["subject"]) or "").strip() cat = norm_cat(r) owner_raw = (r.get(owner_col) or "").strip() if owner_col else "" owner_user = owner_cache.get(owner_raw) owner_noted = owner_raw if (owner_raw and owner_raw.upper() != "TBD" and not owner_user) else None links = { "project": {"href": proj_href}, "type": {"href": f"/api/v3/types/{task_type['id']}"}, "parent": {"href": f"/api/v3/work_packages/{parent_id[cat]}"}, "priority": {"href": f"/api/v3/priorities/{client.find_priority(map_priority(r))['id']}"}, "status": {"href": f"/api/v3/statuses/{client.find_status(map_status(r))['id']}"}, } if owner_user: links["assignee"] = {"href": f"/api/v3/users/{owner_user['id']}"} body = {"subject": subject[:255], "_links": links} desc = _build_description(r, cols, owner_noted) if desc: body["description"] = {"format": "markdown", "raw": desc} try: wp = client.create_work_package(body) created += 1 if created % 20 == 0: print(f" ... {created}/{len(rows)} tasks created") except OpenProjectError as exc: errors.append((subject[:50], str(exc))) print(f"\nDone. Project '{project['name']}': " f"{len(categories)} parents + {created} tasks created.") print(f" {client.base_url}/projects/{project['identifier']}/work_packages") if errors: print(f"\n{len(errors)} row(s) failed:") for subj, err in errors[:15]: print(f" ✗ {subj}: {err}") def cmd_raw(client, args): body = json.loads(args.data) if args.data else None method = args.method.upper() if method == "GET": res = client.get(args.path) elif method == "POST": res = client.post(args.path, body=body) elif method == "PATCH": res = client.patch(args.path, body=body) elif method == "DELETE": res = client.delete(args.path) else: raise OpenProjectError(f"Unsupported method {method}") emit_json(res) # --------------------------------------------------------------------------- # # Argument parser # --------------------------------------------------------------------------- # def build_parser(): p = argparse.ArgumentParser(prog="op", description="OpenProject CLI") p.add_argument("--json", action="store_true", help="raw JSON output") sub = p.add_subparsers(dest="command", required=True) sub.add_parser("ping", help="verify connectivity + auth") sub.add_parser("me", help="show the authenticated user") sp = sub.add_parser("projects", help="list/search projects") sp.add_argument("--search", help="name/identifier fragment") sp.add_argument("--limit", type=int, default=100) us = sub.add_parser("users", help="list/search users") us.add_argument("--search") us.add_argument("--limit", type=int, default=100) ty = sub.add_parser("types", help="list work package types") ty.add_argument("--project", help="restrict to types enabled in a project") sub.add_parser("statuses", help="list statuses") sub.add_parser("priorities", help="list priorities") sub.add_parser("roles", help="list membership roles") pj = sub.add_parser("project", help="project admin") pjsub = pj.add_subparsers(dest="project_command", required=True) pc = pjsub.add_parser("create", help="create a project") pc.add_argument("--name", required=True) pc.add_argument("--identifier", help="url slug (default: derived from name)") pc.add_argument("--description") pc.add_argument("--parent", help="parent project ref (for a subproject)") mb = sub.add_parser("members", help="project memberships") mbsub = mb.add_subparsers(dest="members_command", required=True) ma = mbsub.add_parser("add", help="add a user to a project with a role") ma.add_argument("--project", required=True) ma.add_argument("--user", required=True) ma.add_argument("--role", required=True, help="role name or id (e.g. Member)") wp = sub.add_parser("wp", help="work packages") wpsub = wp.add_subparsers(dest="wp_command", required=True) wl = wpsub.add_parser("list", help="list/search work packages") wl.add_argument("--project") wl.add_argument("--assignee", help="user ref or 'me'") wl.add_argument("--status", help="'open', 'closed', or a status name/id") wl.add_argument("--type") wl.add_argument("--query", help="free-text search") wl.add_argument("--sort", help="field or field:asc|desc") wl.add_argument("--limit", type=int, default=50) ws = wpsub.add_parser("show", help="show one work package") ws.add_argument("id") wc = wpsub.add_parser("create", help="create a work package") wc.add_argument("--project", required=True) wc.add_argument("--subject", required=True) wc.add_argument("--type") wc.add_argument("--description") wc.add_argument("--assignee") wc.add_argument("--status") wc.add_argument("--priority") wc.add_argument("--parent") wc.add_argument("--start") wc.add_argument("--due") wc.add_argument("--done-ratio", dest="done_ratio") wu = wpsub.add_parser("update", help="update a work package (auto lockVersion)") wu.add_argument("id") wu.add_argument("--subject") wu.add_argument("--description") wu.add_argument("--assignee", help="user ref, 'me', or 'none' to unassign") wu.add_argument("--status") wu.add_argument("--priority") wu.add_argument("--type") wu.add_argument("--parent") wu.add_argument("--start") wu.add_argument("--due") wu.add_argument("--done-ratio", dest="done_ratio") wcm = wpsub.add_parser("comment", help="add a comment") wcm.add_argument("id") wcm.add_argument("--text", required=True) wcl = wpsub.add_parser("close", help="set status (default 'Closed')") wcl.add_argument("id") wcl.add_argument("--status-name", dest="status_name", default="Closed") wd = wpsub.add_parser("delete", help="delete a work package (needs --yes)") wd.add_argument("id") wd.add_argument("--yes", action="store_true") wr = wpsub.add_parser("relate", help="link two work packages") wr.add_argument("id") wr.add_argument("--to", required=True, help="target work package id") wr.add_argument("--type", default="relates", help="relates|blocks|blocked|precedes|follows|requires|...") wr.add_argument("--description") tm = sub.add_parser("time", help="time entries") tmsub = tm.add_subparsers(dest="time_command", required=True) tl = tmsub.add_parser("log", help="log time on a work package") tl.add_argument("--wp", required=True) tl.add_argument("--hours", required=True) tl.add_argument("--comment") tl.add_argument("--date", help="YYYY-MM-DD (default today)") tl.add_argument("--activity") im = sub.add_parser("import", help="import a spreadsheet into a project") imsub = im.add_subparsers(dest="import_command", required=True) ix = imsub.add_parser("xlsx", help="import an .xlsx sheet as a project of tasks") ix.add_argument("path", help="path to the .xlsx file") ix.add_argument("--sheet", required=True, help="worksheet name to import") ix.add_argument("--project-name", dest="project_name", required=True) ix.add_argument("--identifier", help="project url slug") ix.add_argument("--project-description", dest="project_description") ix.add_argument("--task-type", dest="task_type", default="Task") ix.add_argument("--merge-category", dest="merge_category", action="append", default=[], help="merge a category: 'OLD=NEW' (repeatable)") ix.add_argument("--limit", type=int, help="only import the first N rows") ix.add_argument("--execute", action="store_true", help="actually write (default is a dry run)") rw = sub.add_parser("raw", help="raw API call (escape hatch)") rw.add_argument("method") rw.add_argument("path") rw.add_argument("--data", help="JSON request body") return p DISPATCH = { ("ping",): cmd_ping, ("me",): cmd_me, ("projects",): cmd_projects, ("users",): cmd_users, ("types",): cmd_types, ("statuses",): cmd_statuses, ("priorities",): cmd_priorities, ("roles",): cmd_roles, ("project", "create"): cmd_project_create, ("members", "add"): cmd_members_add, ("wp", "list"): cmd_wp_list, ("wp", "show"): cmd_wp_show, ("wp", "create"): cmd_wp_create, ("wp", "update"): cmd_wp_update, ("wp", "comment"): cmd_wp_comment, ("wp", "close"): cmd_wp_close, ("wp", "delete"): cmd_wp_delete, ("wp", "relate"): cmd_wp_relate, ("time", "log"): cmd_time_log, ("import", "xlsx"): cmd_import_xlsx, ("raw",): cmd_raw, } def main(argv=None): args = build_parser().parse_args(argv) key = (args.command,) if args.command == "wp": key = ("wp", args.wp_command) elif args.command == "time": key = ("time", args.time_command) elif args.command == "project": key = ("project", args.project_command) elif args.command == "members": key = ("members", args.members_command) elif args.command == "import": key = ("import", args.import_command) handler = DISPATCH.get(key) if not handler: print(f"Unknown command: {key}", file=sys.stderr) return 2 try: client = OpenProjectClient.from_config() handler(client, args) return 0 except OpenProjectError as exc: print(f"error: {exc}", file=sys.stderr) return 1 if __name__ == "__main__": sys.exit(main())