agent-tool-openproject-cli 0.2.1__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.
opcli/cli.py ADDED
@@ -0,0 +1,166 @@
1
+ """Top-level Typer application wiring together every command group."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+
8
+ import typer
9
+
10
+ from . import __version__
11
+ from .context import AppContext
12
+ from .errors import OpError
13
+ from .output import OutputFormat, print_error
14
+
15
+
16
+ def _version_callback(value: bool) -> None:
17
+ if value:
18
+ typer.echo(__version__)
19
+ raise typer.Exit()
20
+
21
+ app = typer.Typer(
22
+ name="openproject",
23
+ help=(
24
+ "Agent-friendly CLI for OpenProject (work packages, projects, time, search, invoicing).\n\n"
25
+ "Output is JSON on stdout by default (errors are JSON on stderr with a non-zero exit code); "
26
+ "add `-o table` or `-o markdown`, or trim with `--fields id,subject`. Pass names not ids "
27
+ "(`--assignee jane.doe`, `--status \"In progress\"`, `me`).\n\n"
28
+ "New here / no context? Run `openproject guide` for the full playbook, or "
29
+ "`openproject search fields` to discover what you can filter on."
30
+ ),
31
+ epilog=(
32
+ "Learn more: `openproject guide` · `openproject guide <topic>` · "
33
+ "`openproject <group> --help` · discover filters with `openproject search fields`."
34
+ ),
35
+ no_args_is_help=True,
36
+ add_completion=False,
37
+ pretty_exceptions_show_locals=False,
38
+ )
39
+
40
+ # Remembered so the central error handler in main() can render errors in the
41
+ # same format the user selected, even for failures raised before a command runs.
42
+ _ERROR_FORMAT = OutputFormat.json
43
+
44
+
45
+ @app.callback()
46
+ def _root(
47
+ ctx: typer.Context,
48
+ output: OutputFormat = typer.Option(
49
+ None, "--output", "-o",
50
+ help="Output format: json (default), table, or markdown. Also available as --format/-f anywhere on the line.",
51
+ ),
52
+ fields: str = typer.Option(
53
+ None, "--fields", "--columns",
54
+ help="Comma-separated fields to return/show, e.g. 'id,subject,status,assignee.name'. Works anywhere on the line.",
55
+ ),
56
+ profile: str = typer.Option(
57
+ None, "--profile", "-p", help="Configuration profile (overrides the active one)."
58
+ ),
59
+ no_color: bool = typer.Option(False, "--no-color", help="Disable coloured output."),
60
+ version: bool = typer.Option(
61
+ None, "--version", "-V", callback=_version_callback, is_eager=True, help="Show version and exit."
62
+ ),
63
+ ) -> None:
64
+ if profile:
65
+ os.environ["OPCLI_PROFILE"] = profile
66
+ # Don't prompt for a default format for meta commands (settings/guide).
67
+ interactive = ctx.invoked_subcommand not in ("settings", "guide") and sys.stdin.isatty() and sys.stdout.isatty()
68
+ ctx.obj = AppContext(output=output, color=not no_color, interactive=interactive)
69
+ global _ERROR_FORMAT
70
+ _ERROR_FORMAT = ctx.obj.emitter.fmt
71
+
72
+
73
+ _FORMAT_FLAGS = ("--format", "-f", "--output", "-o")
74
+ _FIELDS_FLAGS = ("--fields", "--columns")
75
+
76
+
77
+ def _pop_globals(argv: list[str]) -> tuple[str | None, str | None, list[str]]:
78
+ """Extract the output-format and --fields flags (with values) from anywhere on
79
+ the command line, so they work *after* a subcommand too — e.g.
80
+ ``openproject wp list -f markdown --fields id,subject``. Honours ``--``."""
81
+ out: list[str] = []
82
+ fmt: str | None = None
83
+ fields: str | None = None
84
+ i, stop = 0, False
85
+
86
+ def take_value(idx: int) -> tuple[str | None, int]:
87
+ return (argv[idx + 1], idx + 1) if idx + 1 < len(argv) else (None, idx)
88
+
89
+ while i < len(argv):
90
+ a = argv[i]
91
+ if not stop and a == "--":
92
+ stop = True
93
+ out.append(a)
94
+ elif not stop and a in _FORMAT_FLAGS:
95
+ fmt, i = take_value(i)
96
+ elif not stop and a in _FIELDS_FLAGS:
97
+ fields, i = take_value(i)
98
+ elif not stop and (a.startswith("--format=") or a.startswith("--output=") or a.startswith("-f=") or a.startswith("-o=")):
99
+ fmt = a.split("=", 1)[1]
100
+ elif not stop and (a.startswith("--fields=") or a.startswith("--columns=")):
101
+ fields = a.split("=", 1)[1]
102
+ else:
103
+ out.append(a)
104
+ i += 1
105
+ return fmt, fields, out
106
+
107
+
108
+ # ---- command groups (registered below to avoid circular imports) ----
109
+ from .commands import ( # noqa: E402
110
+ attachments,
111
+ auth,
112
+ comments,
113
+ costs,
114
+ custom_fields,
115
+ filelinks,
116
+ guide,
117
+ memberships,
118
+ notifications,
119
+ projects,
120
+ raw,
121
+ search,
122
+ settings,
123
+ time_entries,
124
+ users,
125
+ wiki,
126
+ workpackages,
127
+ )
128
+
129
+ app.command("guide", help="Built-in operating guide — how to use this CLI without external docs.")(guide.guide)
130
+
131
+ app.add_typer(auth.app, name="auth", help="Log in, log out, inspect credentials.")
132
+ app.add_typer(projects.app, name="project", help="Create, list, archive projects.")
133
+ app.add_typer(workpackages.app, name="wp", help="Work packages: CRUD, move, assign, watch.")
134
+ app.add_typer(search.app, name="search", help="Powerful work-package (and global) search.")
135
+ app.add_typer(comments.app, name="comment", help="Add, edit, list work-package comments.")
136
+ app.add_typer(time_entries.app, name="time", help="Log, edit, list time entries + reports.")
137
+ app.add_typer(users.app, name="user", help="Users, groups, memberships, assignable people.")
138
+ app.add_typer(memberships.app, name="member", help="Project memberships & roles.")
139
+ app.add_typer(custom_fields.app, name="cf", help="Inspect custom fields / resource schemas.")
140
+ app.add_typer(notifications.app, name="notify", help="In-app notifications.")
141
+ app.add_typer(wiki.app, name="wiki", help="Wiki pages (read + write where supported).")
142
+ app.add_typer(attachments.app, name="attach", help="Attachments / file uploads on work packages.")
143
+ app.add_typer(filelinks.app, name="filelink", help="Nextcloud/file-storage links on work packages.")
144
+ app.add_typer(costs.app, name="cost", help="Time & cost reporting per person/project (invoicing).")
145
+ app.add_typer(raw.app, name="raw", help="Escape hatch: call any API endpoint directly.")
146
+ app.add_typer(settings.app, name="settings", help="View & change CLI settings (default output format).")
147
+
148
+
149
+ def main() -> None:
150
+ fmt, fields, argv = _pop_globals(sys.argv[1:])
151
+ if fmt is not None:
152
+ os.environ["OPCLI_CLI_FORMAT"] = fmt
153
+ if fields is not None:
154
+ os.environ["OPCLI_CLI_FIELDS"] = fields
155
+ try:
156
+ app(args=argv)
157
+ except OpError as exc:
158
+ print_error(exc, _ERROR_FORMAT)
159
+ sys.exit(exc.exit_code)
160
+ except KeyboardInterrupt: # pragma: no cover
161
+ print_error(OpError("interrupted"), _ERROR_FORMAT)
162
+ sys.exit(130)
163
+
164
+
165
+ if __name__ == "__main__": # pragma: no cover
166
+ main()
opcli/client.py ADDED
@@ -0,0 +1,252 @@
1
+ """Thin, robust HTTP client for the OpenProject API v3 (HAL+JSON).
2
+
3
+ Responsibilities:
4
+
5
+ * Basic-auth with ``apikey:<token>`` (the standard OpenProject API-key scheme).
6
+ * Normalising paths so callers may pass ``"work_packages"``,
7
+ ``"/api/v3/work_packages/1"`` or a full URL interchangeably (following the
8
+ ``href`` values the API returns "just works").
9
+ * Turning HAL error bodies into typed :class:`opcli.errors.OpError` subclasses.
10
+ * Auto-pagination of collection endpoints.
11
+ * Optimistic-locking updates (fetch ``lockVersion`` → PATCH → retry on 409).
12
+ * JSON *and* multipart requests (the latter for attachment uploads).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json as jsonlib
18
+ from typing import Any, Callable, Iterator
19
+
20
+ import httpx
21
+
22
+ from . import __version__, hal
23
+ from .errors import (
24
+ ApiError,
25
+ AuthError,
26
+ ConflictError,
27
+ NotFoundError,
28
+ OpError,
29
+ ValidationError,
30
+ )
31
+
32
+ Json = dict[str, Any]
33
+
34
+ USER_AGENT = f"agent-tool-openproject-cli/{__version__}"
35
+
36
+
37
+ class Client:
38
+ def __init__(
39
+ self,
40
+ base_url: str,
41
+ token: str,
42
+ *,
43
+ verify_ssl: bool = True,
44
+ timeout: float = 60.0,
45
+ ):
46
+ self.base = base_url.rstrip("/")
47
+ self.api_root = self.base + "/api/v3"
48
+ self._http = httpx.Client(
49
+ auth=httpx.BasicAuth("apikey", token),
50
+ verify=verify_ssl,
51
+ timeout=timeout,
52
+ headers={"User-Agent": USER_AGENT, "Accept": "application/json"},
53
+ follow_redirects=True,
54
+ )
55
+
56
+ # ---- lifecycle ---------------------------------------------------
57
+ def close(self) -> None:
58
+ self._http.close()
59
+
60
+ def __enter__(self) -> "Client":
61
+ return self
62
+
63
+ def __exit__(self, *exc: Any) -> None:
64
+ self.close()
65
+
66
+ # ---- url handling ------------------------------------------------
67
+ def url(self, path: str) -> str:
68
+ if path.startswith("http://") or path.startswith("https://"):
69
+ return path
70
+ if path.startswith("/"):
71
+ return self.base + path
72
+ return self.api_root + "/" + path
73
+
74
+ # ---- low-level request -------------------------------------------
75
+ def request(
76
+ self,
77
+ method: str,
78
+ path: str,
79
+ *,
80
+ params: dict[str, Any] | None = None,
81
+ json: Any = None,
82
+ data: dict[str, Any] | None = None,
83
+ files: Any = None,
84
+ headers: dict[str, str] | None = None,
85
+ parse: bool = True,
86
+ ) -> Any:
87
+ url = self.url(path)
88
+ clean_params = {k: v for k, v in (params or {}).items() if v is not None}
89
+ # OpenProject rejects body-less writes with 406 unless a JSON content-type
90
+ # is present. Set it for JSON writes (but never for multipart uploads,
91
+ # where httpx must supply the multipart boundary itself).
92
+ eff_headers = dict(headers or {})
93
+ if files is None and method.upper() in ("POST", "PATCH", "PUT"):
94
+ eff_headers.setdefault("Content-Type", "application/json")
95
+ try:
96
+ resp = self._http.request(
97
+ method,
98
+ url,
99
+ params=clean_params or None,
100
+ json=json,
101
+ data=data,
102
+ files=files,
103
+ headers=eff_headers or None,
104
+ )
105
+ except httpx.ConnectError as exc:
106
+ raise ApiError(f"cannot connect to {self.base}: {exc}") from exc
107
+ except httpx.HTTPError as exc:
108
+ raise ApiError(f"request to {url} failed: {exc}") from exc
109
+
110
+ if resp.status_code >= 400:
111
+ self._raise_for_error(resp)
112
+
113
+ if not parse:
114
+ return resp
115
+ if resp.status_code == 204 or not resp.content:
116
+ return {}
117
+ ctype = resp.headers.get("content-type", "")
118
+ if "json" in ctype:
119
+ return resp.json()
120
+ return resp.content
121
+
122
+ # ---- error mapping -----------------------------------------------
123
+ def _raise_for_error(self, resp: httpx.Response) -> None:
124
+ status = resp.status_code
125
+ payload: Any = None
126
+ message = f"HTTP {status}"
127
+ field_errors = None
128
+ try:
129
+ payload = resp.json()
130
+ except Exception:
131
+ payload = resp.text or None
132
+
133
+ if isinstance(payload, dict) and payload.get("_type") == "Error":
134
+ message = payload.get("message", message)
135
+ embedded = (payload.get("_embedded") or {}).get("errors")
136
+ if isinstance(embedded, list) and embedded:
137
+ field_errors = [e.get("message") for e in embedded if isinstance(e, dict)]
138
+ message = "; ".join(m for m in field_errors if m) or message
139
+
140
+ if status in (401,):
141
+ raise AuthError(
142
+ message if message != f"HTTP {status}" else "authentication failed — check your API token",
143
+ detail=payload,
144
+ )
145
+ if status in (403,):
146
+ raise AuthError(
147
+ message if message != f"HTTP {status}" else "forbidden — the API user lacks permission",
148
+ detail=payload,
149
+ )
150
+ if status == 404:
151
+ raise NotFoundError(message if message != f"HTTP {status}" else "resource not found", detail=payload)
152
+ if status == 409:
153
+ raise ConflictError(message, detail=payload)
154
+ if status == 422:
155
+ raise ValidationError(message, detail=payload, field_errors=field_errors)
156
+ raise ApiError(message, status=status, detail=payload)
157
+
158
+ # ---- verb helpers ------------------------------------------------
159
+ def get(self, path: str, *, params: dict[str, Any] | None = None) -> Json:
160
+ return self.request("GET", path, params=params)
161
+
162
+ def post(self, path: str, *, json: Any = None, params: dict[str, Any] | None = None) -> Json:
163
+ return self.request("POST", path, json=json, params=params)
164
+
165
+ def patch(self, path: str, *, json: Any = None, params: dict[str, Any] | None = None) -> Json:
166
+ return self.request("PATCH", path, json=json, params=params)
167
+
168
+ def delete(self, path: str, *, params: dict[str, Any] | None = None) -> Json:
169
+ return self.request("DELETE", path, params=params)
170
+
171
+ # ---- collections / pagination ------------------------------------
172
+ def paginate(
173
+ self,
174
+ path: str,
175
+ *,
176
+ params: dict[str, Any] | None = None,
177
+ page_size: int = 100,
178
+ limit: int | None = None,
179
+ ) -> Iterator[Json]:
180
+ """Yield every element of a collection endpoint, following pages.
181
+
182
+ OpenProject paginates with 1-based ``offset`` (page number) and
183
+ ``pageSize``. We stop when we've seen ``total`` items, an empty page,
184
+ or reached ``limit``.
185
+
186
+ Note: the server may cap ``pageSize`` below what we ask for, so we must
187
+ NOT stop merely because a page came back shorter than requested — we
188
+ rely on the authoritative ``total`` and on an empty page instead.
189
+ """
190
+ offset = 1
191
+ seen = 0
192
+ # guard against a server that never advances (pathological) — bounded by total
193
+ while True:
194
+ page_params = dict(params or {})
195
+ page_params["offset"] = offset
196
+ page_params["pageSize"] = page_size
197
+ doc = self.get(path, params=page_params)
198
+ batch = hal.elements(doc)
199
+ if not batch:
200
+ break
201
+ for el in batch:
202
+ yield el
203
+ seen += 1
204
+ if limit is not None and seen >= limit:
205
+ return
206
+ if seen >= hal.total(doc):
207
+ break
208
+ offset += 1
209
+
210
+ def collect(
211
+ self,
212
+ path: str,
213
+ *,
214
+ params: dict[str, Any] | None = None,
215
+ page_size: int = 100,
216
+ limit: int | None = None,
217
+ ) -> list[Json]:
218
+ return list(self.paginate(path, params=params, page_size=page_size, limit=limit))
219
+
220
+ # ---- optimistic-locking update -----------------------------------
221
+ def update_locked(
222
+ self,
223
+ path: str,
224
+ patch: Json | Callable[[Json], Json],
225
+ *,
226
+ params: dict[str, Any] | None = None,
227
+ retries: int = 2,
228
+ ) -> Json:
229
+ """PATCH a resource, supplying the current ``lockVersion`` automatically.
230
+
231
+ ``patch`` may be a dict, or a callable receiving the freshly-fetched
232
+ resource and returning the patch body (useful when the new value
233
+ depends on the old one). Retries on 409 conflicts.
234
+ """
235
+ last_exc: OpError | None = None
236
+ for _ in range(retries + 1):
237
+ current = self.get(path)
238
+ body = patch(current) if callable(patch) else jsonlib.loads(jsonlib.dumps(patch))
239
+ lv = current.get("lockVersion")
240
+ if lv is not None:
241
+ body.setdefault("lockVersion", lv)
242
+ try:
243
+ return self.patch(path, json=body, params=params)
244
+ except ConflictError as exc:
245
+ last_exc = exc
246
+ continue
247
+ assert last_exc is not None
248
+ raise last_exc
249
+
250
+ # ---- convenience -------------------------------------------------
251
+ def me(self) -> Json:
252
+ return self.get("users/me")
@@ -0,0 +1 @@
1
+ """Command groups for the OpenProject CLI."""
@@ -0,0 +1,50 @@
1
+ """Helpers shared across command modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ import typer
9
+
10
+ from ..context import AppContext
11
+ from ..errors import OpError
12
+
13
+
14
+ def ctx_obj(ctx: typer.Context) -> AppContext:
15
+ return ctx.obj
16
+
17
+
18
+ def parse_json_option(value: str | None, *, what: str = "value") -> Any:
19
+ """Parse a JSON string passed on the command line (``--fields '{...}'``)."""
20
+ if value is None:
21
+ return None
22
+ try:
23
+ return json.loads(value)
24
+ except json.JSONDecodeError as exc:
25
+ raise OpError(f"invalid JSON for {what}: {exc}") from exc
26
+
27
+
28
+ def set_link(body: dict, name: str, href: str | None) -> None:
29
+ body.setdefault("_links", {})[name] = {"href": href}
30
+
31
+
32
+ def apply_custom_fields(body: dict, raw: str | None) -> None:
33
+ """Merge a ``--custom-fields`` JSON object into a write body.
34
+
35
+ Scalars land at the top level; values shaped like ``{"href": ...}`` (or a
36
+ list of them) go under ``_links`` — matching OpenProject's split between
37
+ value and reference custom fields.
38
+ """
39
+ if not raw:
40
+ return
41
+ data = parse_json_option(raw, what="--custom-fields")
42
+ if not isinstance(data, dict):
43
+ raise OpError("--custom-fields must be a JSON object keyed by customFieldN")
44
+ for key, val in data.items():
45
+ if isinstance(val, dict) and set(val.keys()) <= {"href"}:
46
+ set_link(body, key, val.get("href"))
47
+ elif isinstance(val, list) and all(isinstance(v, dict) and "href" in v for v in val):
48
+ body.setdefault("_links", {})[key] = val
49
+ else:
50
+ body[key] = val
@@ -0,0 +1,104 @@
1
+ """Attachment commands: list, upload, download, get, delete.
2
+
3
+ Upload is multipart/form-data with two parts: a JSON ``metadata`` part
4
+ (``fileName`` + optional ``description``) and the raw ``file`` part. Downloads
5
+ follow the attachment's ``downloadLocation`` link (which may redirect to remote
6
+ storage).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import mimetypes
13
+ from pathlib import Path
14
+
15
+ import typer
16
+
17
+ from .. import hal, serialize
18
+ from ..errors import OpError
19
+ from ._shared import ctx_obj
20
+
21
+ app = typer.Typer(no_args_is_help=True)
22
+
23
+ _COLUMNS = [
24
+ ("ID", "id"),
25
+ ("File", "fileName"),
26
+ ("Size", "fileSize"),
27
+ ("Type", "contentType"),
28
+ ("Author", lambda r: (r.get("author") or {}).get("name")),
29
+ ("When", "createdAt"),
30
+ ]
31
+
32
+
33
+ @app.command("list")
34
+ def list_attachments(
35
+ ctx: typer.Context,
36
+ wp_id: int = typer.Argument(..., help="Work package id."),
37
+ ) -> None:
38
+ """List attachments on a work package."""
39
+ obj = ctx_obj(ctx)
40
+ rows = [serialize.attachment(a) for a in obj.client().collect(f"work_packages/{wp_id}/attachments")]
41
+ obj.emitter.emit(rows, columns=_COLUMNS, empty="(no attachments)")
42
+
43
+
44
+ @app.command()
45
+ def upload(
46
+ ctx: typer.Context,
47
+ wp_id: int = typer.Argument(..., help="Work package id."),
48
+ path: Path = typer.Argument(..., help="Local file to upload."),
49
+ name: str = typer.Option(None, "--name", help="Override the stored file name."),
50
+ description: str = typer.Option(None, "--description", "-d", help="Attachment description."),
51
+ ) -> None:
52
+ """Upload a file and attach it to a work package."""
53
+ obj = ctx_obj(ctx)
54
+ if not path.exists() or not path.is_file():
55
+ raise OpError(f"file not found: {path}")
56
+ file_name = name or path.name
57
+ metadata = {"fileName": file_name}
58
+ if description is not None:
59
+ metadata["description"] = description
60
+ content_type = mimetypes.guess_type(file_name)[0] or "application/octet-stream"
61
+ files = {
62
+ "metadata": (None, json.dumps(metadata), "application/json"),
63
+ "file": (file_name, path.read_bytes(), content_type),
64
+ }
65
+ doc = obj.client().request("POST", f"work_packages/{wp_id}/attachments", files=files)
66
+ obj.emitter.emit(serialize.attachment(doc))
67
+
68
+
69
+ @app.command()
70
+ def get(ctx: typer.Context, attachment_id: int = typer.Argument(..., help="Attachment id.")) -> None:
71
+ """Show attachment metadata."""
72
+ obj = ctx_obj(ctx)
73
+ obj.emitter.emit(serialize.attachment(obj.client().get(f"attachments/{attachment_id}")))
74
+
75
+
76
+ @app.command()
77
+ def download(
78
+ ctx: typer.Context,
79
+ attachment_id: int = typer.Argument(..., help="Attachment id."),
80
+ output: Path = typer.Option(None, "--output", "-O", help="Output path (default: original file name)."),
81
+ ) -> None:
82
+ """Download an attachment's content."""
83
+ obj = ctx_obj(ctx)
84
+ client = obj.client()
85
+ meta = client.get(f"attachments/{attachment_id}")
86
+ href = hal.link_href(meta, "downloadLocation") or f"/api/v3/attachments/{attachment_id}/content"
87
+ resp = client.request("GET", href, parse=False)
88
+ dest = output or Path(meta.get("fileName") or f"attachment-{attachment_id}")
89
+ dest.write_bytes(resp.content)
90
+ obj.emitter.emit({"status": "downloaded", "attachment": attachment_id, "path": str(dest), "bytes": len(resp.content)})
91
+
92
+
93
+ @app.command()
94
+ def delete(
95
+ ctx: typer.Context,
96
+ attachment_id: int = typer.Argument(..., help="Attachment id."),
97
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
98
+ ) -> None:
99
+ """Delete an attachment."""
100
+ obj = ctx_obj(ctx)
101
+ if not yes:
102
+ typer.confirm(f"Delete attachment {attachment_id}?", abort=True)
103
+ obj.client().delete(f"attachments/{attachment_id}")
104
+ obj.emitter.emit({"status": "deleted", "attachment": attachment_id})
opcli/commands/auth.py ADDED
@@ -0,0 +1,113 @@
1
+ """Authentication commands: login, logout, status, whoami."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ import typer
8
+
9
+ from .. import credentials, serialize
10
+ from ..client import Client
11
+ from ..config import Profile
12
+ from ..errors import AuthError, OpError
13
+ from ._shared import ctx_obj
14
+
15
+ app = typer.Typer(no_args_is_help=True)
16
+
17
+
18
+ @app.command()
19
+ def login(
20
+ ctx: typer.Context,
21
+ url: str = typer.Option(None, "--url", "-u", help="Base URL, e.g. https://op.example.com"),
22
+ token: str = typer.Option(None, "--token", "-t", help="API token (prompted if omitted)."),
23
+ name: str = typer.Option(None, "--name", help="Profile name to store under."),
24
+ username: str = typer.Option(None, "--username", help="Informational: the API user's login."),
25
+ no_verify_ssl: bool = typer.Option(False, "--no-verify-ssl", help="Skip TLS verification."),
26
+ ) -> None:
27
+ """Verify and store an API token securely (OS keyring by default).
28
+
29
+ Non-interactive example:
30
+ openproject auth login --url http://localhost:8090 --token opapi-xxxx
31
+ """
32
+ obj = ctx_obj(ctx)
33
+ profile_name = name or os.environ.get("OPCLI_PROFILE") or obj.config.current_profile or "default"
34
+
35
+ base_url = url or os.environ.get("OPCLI_BASE_URL")
36
+ if not base_url:
37
+ base_url = typer.prompt("OpenProject base URL")
38
+ if not token:
39
+ token = os.environ.get("OPCLI_TOKEN") or typer.prompt("API token", hide_input=True)
40
+
41
+ # Verify before persisting anything.
42
+ with Client(base_url, token, verify_ssl=not no_verify_ssl) as client:
43
+ try:
44
+ me = client.me()
45
+ except AuthError as exc:
46
+ raise AuthError(f"login failed: {exc.message}") from exc
47
+
48
+ prof = Profile(
49
+ name=profile_name,
50
+ base_url=base_url,
51
+ username=username or me.get("login"),
52
+ verify_ssl=not no_verify_ssl,
53
+ )
54
+ obj.config.upsert_profile(prof, make_current=True)
55
+ obj.config.save()
56
+ backend = credentials.store_token(profile_name, token)
57
+
58
+ obj.emitter.emit(
59
+ {
60
+ "status": "logged in",
61
+ "profile": profile_name,
62
+ "baseUrl": base_url,
63
+ "user": serialize.user(me),
64
+ "tokenStoredIn": backend,
65
+ }
66
+ )
67
+
68
+
69
+ @app.command()
70
+ def status(ctx: typer.Context) -> None:
71
+ """Show the active profile, credential backend, and connectivity."""
72
+ obj = ctx_obj(ctx)
73
+ try:
74
+ prof = obj.profile()
75
+ except OpError as exc:
76
+ obj.emitter.emit({"configured": False, "reason": exc.message})
77
+ return
78
+
79
+ info = {
80
+ "profile": prof.name,
81
+ "baseUrl": prof.base_url,
82
+ "username": prof.username,
83
+ "verifySsl": prof.verify_ssl,
84
+ "credentialBackend": credentials.backend_name(),
85
+ "hasToken": bool(obj.token()),
86
+ }
87
+ if obj.token():
88
+ try:
89
+ info["reachable"] = True
90
+ info["me"] = serialize.user(obj.client().me())
91
+ except OpError as exc:
92
+ info["reachable"] = False
93
+ info["error"] = exc.message
94
+ obj.emitter.emit(info)
95
+
96
+
97
+ @app.command()
98
+ def whoami(ctx: typer.Context) -> None:
99
+ """Print the authenticated user (GET /users/me)."""
100
+ obj = ctx_obj(ctx)
101
+ obj.emitter.emit(serialize.user(obj.client().me()))
102
+
103
+
104
+ @app.command()
105
+ def logout(
106
+ ctx: typer.Context,
107
+ name: str = typer.Option(None, "--name", help="Profile to log out (defaults to active)."),
108
+ ) -> None:
109
+ """Remove the stored API token for a profile."""
110
+ obj = ctx_obj(ctx)
111
+ profile_name = name or obj.config.active_profile_name()
112
+ credentials.delete_token(profile_name)
113
+ obj.emitter.emit({"status": "logged out", "profile": profile_name})